DEV Community

ExtensionGuy
ExtensionGuy

Posted on

How I Run Pandoc's WebAssembly Build Inside a Browser Extension (Offline Word-to-LaTeX)

The problem

I once lost hours reformatting a Word manuscript into LaTeX, and I didn't
want to upload an unpublished paper to a random converter website. Pandoc
handles this conversion very well, but it means installing a command-line
tool, which most people writing papers won't do.

So I wanted Pandoc's engine with no install, no upload, and no server. The
result is Word to LaTeX (Offline),
a browser extension that runs Pandoc locally through WebAssembly.

Why WebAssembly instead of a server

  • Privacy: the document never leaves your machine, so there is nothing to log, retain, or leak.
  • Offline: once installed, it works with no network connection.
  • Same engine as the CLI: this is real Pandoc, not a JavaScript re-implementation, so the output matches what you'd get on the command line.

Pandoc in the browser

Pandoc is written in Haskell. GHC, the Haskell compiler, can now target
WebAssembly (wasm32-wasi), and Pandoc releases from 3.9 onward include an
official WASM build. I use that build (Pandoc 3.10) rather than maintaining
my own toolchain, which keeps updates simple: when a new Pandoc version
ships, I swap the binary and rerun my test documents.

The tradeoff is size. The binary is large by web standards, tens of
megabytes uncompressed, so I bundle it inside the extension package. Users
download it once at install time instead of on every conversion, and
conversions start without network latency.

Architecture

The flow is:

  1. The user drops a .docx file or pastes a table.
  2. The page reads the file into an ArrayBuffer.
  3. The bytes are sent to a Web Worker that hosts the Pandoc WASM runtime.
  4. Pandoc converts DOCX to LaTeX entirely in memory.
  5. The LaTeX string is posted back and shown in the UI.

Running Pandoc in a worker is important. Conversion is CPU-heavy, and doing
it on the main thread would freeze the interface on large manuscripts.

A simplified version of the messaging layer:

// worker.js (simplified)
self.onmessage = async (event) => {
  const { id, bytes, standalone } = event.data;
  try {
    // Wrapper around the Pandoc WASM runtime: docx in, LaTeX out.
    const latex = await runPandoc(bytes, {
      from: "docx",
      to: "latex",
      standalone,          // true = complete document, false = fragment
    });
    self.postMessage({ id, ok: true, latex });
  } catch (err) {
    self.postMessage({ id, ok: false, error: String(err) });
  }
};
Enter fullscreen mode Exit fullscreen mode
// page side (simplified)
const worker = new Worker("worker.js");

function convert(file, standalone = false) {
  return new Promise(async (resolve, reject) => {
    const id = crypto.randomUUID();
    const bytes = await file.arrayBuffer();
    const onMessage = (e) => {
      if (e.data.id !== id) return;
      worker.removeEventListener("message", onMessage);
      e.data.ok ? resolve(e.data.latex) : reject(new Error(e.data.error));
    };
    worker.addEventListener("message", onMessage);
    worker.postMessage({ id, bytes, standalone }, [bytes]);
  });
}
Enter fullscreen mode Exit fullscreen mode

Transferring the ArrayBuffer instead of copying it avoids duplicating
large files in memory.

Running WASM inside a Manifest V3 extension

Manifest V3 is stricter about code execution, but WebAssembly bundled with the
extension is allowed. The default extension CSP permits it, so loading a
.wasm file from the package works without relaxing security or requesting
extra permissions. The extension needs no special permissions, and every
file stays inside the browser sandbox.

Cleaning up Pandoc's output

Raw Pandoc LaTeX is correct but not always paste-ready. For Overleaf and
TeXstudio I post-process the output:

  • Remove \tightlist, which depends on a macro that isn't defined in a fresh document.
  • Unwrap \pandocbounded{} wrappers so the body is plain LaTeX.
  • In fragment mode, output only the body so it pastes into an existing document without a duplicate preamble. "Complete document" mode produces a standalone file.

Tables are written with booktabs and longtable, so they look right and
span pages.

Beyond DOCX: pasted tables and OCR

Pasted tables go through the same local conversion path. I also added an
experimental image-to-LaTeX mode using Tesseract.js, also running locally,
for typed single-line equations. It is limited: radicals are often misread,
matrices are unreliable, and handwriting isn't supported. I tested it on an
80-sample set (97.5% compiled), but that measures whether the output
compiles, not whether it is correct. Always review OCR output.

Check the "offline" claim yourself

Don't take my word for it. Open DevTools, switch to the Network tab,
convert a document, and confirm that no requests leave your machine. The
code is on GitHub
so you can audit it.

Licensing

Pandoc is GPL-2.0 or later. I publish the engine's license and attribution
here, and the website
code is open source.

Try it

If a manuscript doesn't convert cleanly, open an
issue with
the document structure and I'll look into it.

Top comments (1)

Collapse
 
alexshev profile image
Alex Shev •

Running Pandoc through WebAssembly inside the extension is a compelling local-first design, especially for documents that should not leave the machine. I would add a small fidelity test suite with representative Word files: tables, footnotes, equations, images, and track-change artifacts. Offline conversion earns confidence when users can see exactly which structures round-trip cleanly.