There is a category of web tools I will call document converters — the ones that take a Word file or a PDF and give you Markdown or HTML back. They are everywhere. And they are almost all bad.
The problem is not that the conversion quality is bad in absolute terms (though it often is — lost table borders, heading hierarchies flattened to bold text, footnotes silently dropped). The problem is structural: every converter falls into one of two failure modes. Either it is a simple HTML-to-Markdown transformer that cannot read binary document formats at all, or it is a mature tool with real parsing logic that you have to install on your machine.
Pandoc is the gold standard of the second category. It reads 14 document formats and writes 8 more. It handles citations, Lua filters, custom templates, footnotes, and arbitrary nesting levels of headings and lists. And you cannot run it in a browser tab.
Or can you?
What we built
We ran the full Pandoc engine — the Haskell codebase, not a reimplementation — compiled to WebAssembly, inside a Web Worker, on a site that converts Word, PDF, EPUB, ODT, RST, MediaWiki, Org, and seven more document formats into Markdown, HTML, RST, LaTeX, or back to docx. Everything runs locally in your browser. Nothing is uploaded.
The story of how that works is the story of three engines, a virtual file system, and a lot of cache engineering. Let me walk through the parts that were not obvious.
Engine 1: Pandoc (GHC → WASM, 58 MB)
Pandoc does not have a browser build. The project is written in Haskell and is distributed as CLI binaries for each platform. Enter pandoc-wasm — the result of cross-compiling Pandoc with GHC's WebAssembly backend. The output is a 58 MB .wasm binary that exposes Pandoc's full convert() function as a JavaScript promise.
This is not a subset. It is not a port that "covers the common cases." It is the same codebase that runs in the CLI. When Pandoc reads a .docx file, it does so with exactly the same XML parser and document model that ships in the installer. When it writes GFM Markdown with pipe tables and footnotes, it uses the same writer code path.
The trade-off is the size. 58 MB is not something you want to ship on first paint. More on that in a moment.
Engine 2: pdf-inspector (Rust → WASM, 6 MB)
Pandoc does not have a PDF reader. It has never had one. Pandoc's approach to PDF has always been "let some other tool extract text first, then hand me the text."
For a browser-based pipeline that is unacceptable — we cannot shell out to pdftotext. So we added a second engine: @firecrawl/pdf-inspector-wasm, a Rust library compiled to WASM that reads PDF layouts and produces structured Markdown directly. It handles multi-column reading order, table detection, heading inference, and flags pages that appear to be image-only (scanned PDFs). The WASM binary is ~6 MB and loads on-demand — only when the first PDF file reaches the worker.
When the user selects Markdown as the output format, pdf-inspector's Markdown goes straight through. When they select HTML, RST, LaTeX, or docx, the pipeline hands pdf-inspector's Markdown output to Pandoc's corresponding writer as a second pass.
Engine 3: SheetJS (470 KB, lazy chunk)
Pandoc also has no .xlsx reader. Same problem, different engine: we added xlsx, bundled into the worker as a separate lazy chunk. The pipeline for Excel files is: SheetJS parses workbook → we flatten merged cells and convert each sheet to an HTML table → the combined HTML document gets fed to Pandoc with from: html so Pandoc's Markdown writer produces proper pipe tables.
Why not use SheetJS alone? Because SheetJS produces HTML tables, not semantic Markdown documents. Pandoc's writer handles heading detection (we wrap multi-sheet workbooks with <h2> headers per sheet), image extraction from embedded files, and GFM extensions correctly.
Engine 4: OCR (pdfjs-dist + tesseract.js)
Image-only PDFs and scanned documents are a fourth pipeline — pdfjs-dist renders each page to a canvas at 2× scale, tesseract.js recognizes text with language packs loaded on-demand from CDN, and the result goes through pandoc convert() as a Markdown source. We added structured mode that reads tesseract's bounding boxes and reconstructs GFM tables from column-gap clustering.
This one does hit a CDN (jsDelivr) for language packs and the tesseract core — we document this transparently. The rest of the pipeline stays local.
The cache architecture (why 58 MB does not break the site)
A 58 MB download on first visit would kill any conversion site. We solved this with a three-tier cache that loads Pandoc progressively:
IndexedDB compiled module cache.
WebAssembly.Moduleobjects are structured-cloneable and can be stored in IndexedDB on Chrome, Edge, and Firefox. After the first compile (which takes tens of seconds on a mid-range laptop), we serialize the compiled module to IDB. On the next visit, we deserialize it — that takes 1-2 seconds and skips compilation entirely. Safari is the one browser that throwsDataCloneErrorwhen you try to store aWebAssembly.Module, so all IDB write failures silently fall through to the next tier.Cache API byte cache. If IDB has no compiled module (first visit or Safari), we check the Cache API for the raw
pandoc.wasmbytes. Cache API is available on all modern browsers in secure contexts. A cache hit means we load the 58 MB from disk (instant) and proceed to compilation.Streaming download with progress. If both caches miss, we fetch
pandoc.wasmwithres.body.getReader()and stream it in 100ms-throttled progress updates to the UI, then write it back to Cache API for next time.
The load progress bar the user sees has three phases: download, compile, and ready. A returning visitor on Chrome will almost always jump straight to ready because either the IDB compiled module or the Cache API bytes are already there.
Virtual file system (the trick that makes Pro features work)
Pandoc's convert() function takes an options object and a map of virtual files. This is how WASM Pandoc handles anything that the CLI would read from disk — bibliography files, CSL style sheets, Lua filter scripts, custom templates. We fixed the file names and mount them as Blobs:
refs.bib → citations bibliography
style.csl → citation style file
filter.lua → custom Lua filter
custom-template.txt → standalone template
When the user enables citations with a .bib file and a CSL style sheet, we set opts.citeproc = true, opts.bibliography = "refs.bib", opts.csl = "style.csl", and add both Blobs to the files map. Pandoc runs citation processing entirely inside the WASM sandbox, with zero network calls.
Web Worker isolation (serially, because Pandoc is not thread-safe)
All of this runs inside a single Web Worker. The main thread creates it once on first use with new Worker(new URL("./pandoc.worker.ts", import.meta.url), { type: "module" }) and communicates through a promise-based queue.
One important detail: pandoc-wasm's convert() is not guaranteed to be concurrency-safe. We serialize every call through a promise chain — the worker runs one conversion at a time, queueing up new requests with chain = chain.then(() => handle(req)). This means a user can upload five files and they convert sequentially, not in parallel. For a single-user browser tool, that is fine.
Worker lifecycle management has three layers:
- Reference counting. Components acquire the worker on mount and release on unmount. When the count drops to zero, the worker is still alive (for SPA navigation between pages on the same tab).
-
Visibility change. When the user switches tabs (
document.visibilityState === "hidden") and the reference count is zero, we terminate the worker to free the 58 MB of RAM. - Transferable ArrayBuffers. Every file upload and every conversion result (images, binary docx output) uses transferable ownership — the data moves from main thread to worker without copying. Without this, a 2 MB Word document would be cloned twice.
What we learned
A few things that took longer than expected:
TurboPack wasm handling. Next.js 16's bundler tries to treat .wasm files as build assets and emit them into .next/static/, breaking the cache strategy (we need a stable /pandoc.wasm URL). The fix is to never import the wasm directly in source code — always fetch it by absolute URL (/pandoc.wasm) and bypass the bundler's asset pipeline entirely.
GFM vs CommonMark tables. Pandoc's CSV reader produces Para blocks for table cells by default, which Pandoc's Markdown writer then renders as simple text lines — not pipe tables. The fix is to force the writer to markdown+pipe_tables when GFM output is requested: --to=markdown+pipe_tables-strikeout-task_lists.
Pandoc-wasm concurrent calls. We initially launched two workers (one for PDF, one for everything else) and got intermittent silent failures. Serializing all calls through one worker fixed it. The library does not advertise this constraint prominently.
Cell dates in Excel. We initially used cellDates: true when reading Excel files, which caused date values to be reformatted in the output. Switching to raw: false makes SheetJS prefer cell.w — the display string Excel cached when the file was last saved — which is exactly what the user sees in the spreadsheet.
If you want to try it
We built this as a public tool at wordtomarkdown.com. The core Pandoc converter is at /pandoc/ and it supports all 14 input formats. For a deeper dive on why we kept Pandoc as the core engine rather than switching to simpler libraries like mammoth or Turndown, see our Pandoc alternatives write-up. If you want the side-by-side comparison against what a naive converter gets wrong, our Pandoc vs. browser-native page has the benchmarks.
Everything documented here is open source by the upstream engines (pandoc-wasm: MIT, pdf-inspector-wasm: MIT, SheetJS: Apache-2.0, tesseract.js: Apache-2.0) and our own pipeline code is straightforward TypeScript you can read through the repository structure page.
Top comments (0)