← Back to blog

Compiling TeX Engines to WebAssembly: Executing LaTeX in Modern Web Browsers

PetexSpace

Typesetting high-quality mathematical typography has historically depended on native workstation toolchains or remote compilation farms. Modern WebAssembly compilation targets now allow complex, decades-old C and Pascal typesetting engines to execute directly inside consumer web browsers.

Achieving fast, predictable, and memory-safe LaTeX compilation inside an isolated browser tab requires solving deep architectural mismatches. Donald Knuth's monolithic TeX design relies on static memory structures, synchronous disk inputs and outputs, and massive macro libraries. Bringing these systems into the browser requires intercepting POSIX system calls, constructing virtualized in-memory file systems, streaming font metrics on demand, and bridging raw output buffers to hardware-accelerated canvas renderers.

1. The Monolithic Architecture of Donald Knuth's TeX

To understand the challenge of running TeX inside a modern web browser, one must examine its historical software architecture. TeX was originally written by Donald Knuth in the WEB literate programming system, an approach combining Pascal code with documentation. It is translated into C via WEB2C before compilation by modern toolchains.

+-----------------------------------------------------------------------+
|                       Traditional TeX Pipeline                        |
+-----------------------------------------------------------------------+
|  Raw Input (.tex)                                                     |
|       |                                                               |
|       v                                                               |
|  [ Tokenizer & Macro Expander ] ---> State tables, Hash table (\def)  |
|       |                                                               |
|       v                                                               |
|  [ Semantic Evaluator ] ----------> Font Metrics (.tfm)               |
|       |                                                               |
|       v                                                               |
|  [ Horizontal / Vertical Lists ] -> Nodes: glyph, kern, glue, penalty |
|       |                                                               |
|       v                                                               |
|  [ Knuth-Plass Line Breaking ] ---> Dynamic programming badness       |
|       |                                                               |
|       v                                                               |
|  [ Page Builder & Shipout ] ------> Synchronous write to DVI / PDF    |
+-----------------------------------------------------------------------+

Static Allocations and Fixed Data Structures

Unlike modern compilers that allocate memory dynamically via standard heap allocators, classical TeX manages its own internal address spaces using large, fixed-size static arrays defined during build configuration:

  • mem[]: The primary storage array holding token lists, horizontal list nodes, vertical list nodes, and glue specifications.
  • hash[]: The primitive dictionary structure managing command names, active macro sequences, and character category codes (catcodes).
  • eqtb[]: The equivalents table tracking current variable values, local scoping levels, and font definitions.
  • str_pool[]: The string pool holding every identifier and string literal encountered across execution.

Because TeX treats memory as a continuous block of array indexes rather than pointer-linked dynamic allocations, memory layout remains strictly deterministic. However, if any fixed limit is exceeded (for instance, when complex nested TikZ graphics exhaust mem[]), TeX halts execution immediately with an unrecoverable capacity error.

The Knuth-Plass Optimal Line Breaking Algorithm

The core typographic strength of TeX lies in the Knuth-Plass line-breaking algorithm. Rather than using greedy line-by-line justification, Knuth-Plass approaches paragraph formatting as a global optimization problem solved through dynamic programming.

A paragraph is modeled as an ordered sequence of three fundamental primitives:

  1. Boxes: Fixed rectangular entities with set width, height, and depth (such as a glyph or packed sub-formula).
  2. Glue: Flexible spacing with a nominal natural width (ww), stretchability (yy), and shrinkability (zz).
  3. Penalties: Explicit costs (pp) associated with breaking at a specific boundary, where p=∞p = \infty forbids a break and p≤−10000p \le -10000 forces a break.

The algorithm defines the adjustment ratio rr for a candidate line spanning from break point bib_i to bjb_j:

r={W−L∑y,if W<L(stretching)0,if W=L(exact fit)W−L∑z,if W>L(shrinking)r = \begin{cases} \frac{W - L}{\sum y}, & \text{if } W < L \quad (\text{stretching}) \\ 0, & \text{if } W = L \quad (\text{exact fit}) \\ \frac{W - L}{\sum z}, & \text{if } W > L \quad (\text{shrinking}) \end{cases}

Here, LL represents the actual sum of natural box widths and glue, while WW represents the target line width. If r<−1r < -1, the line cannot shrink enough without overlapping characters and is considered illegal.

The aesthetic demerit score dd for a candidate line combines badness b=100⋅∣r∣3b = 100 \cdot |r|^3 with penalties:

d={(1+100⋅∣r∣3+p)2,if p≥0(1+100⋅∣r∣3)2−p2,if −10000<p<0d = \begin{cases} (1 + 100 \cdot |r|^3 + p)^2, & \text{if } p \ge 0 \\ (1 + 100 \cdot |r|^3)^2 - p^2, & \text{if } -10000 < p < 0 \end{cases}

By evaluating all possible break sequences across the paragraph graph, Knuth-Plass finds the global minimum sum of demerits. Porting this algorithm to the web preserves its typographic rigor, ensuring that text set inside a browser matches physical print output down to fractional point fractions.

2. Porting Native C Compilation Targets to WebAssembly via Emscripten

Deploying pdfTeX, XeTeX, or LuaTeX to web runtimes requires compiling their C translation layers into WebAssembly (.wasm) using the LLVM backend and Emscripten.

+-----------------------------------------------------------------------+
|                    Browser WebAssembly Architecture                   |
+-----------------------------------------------------------------------+
|   Main Browser UI Thread (Monaco / CodeMirror, DOM, React)            |
|       |                                                               |
|       | [ Web Worker Message Channel: SharedArrayBuffer / Transfers ] |
|       v                                                               |
|   Dedicated Dedicated Worker Runtime                                  |
|   +---------------------------------------------------------------+   |
|   |  Emscripten JavaScript Glue Layer (Syscall Dispatcher)        |   |
|   |  +---------------------------------------------------------+  |   |
|   |  | MEMFS In-Memory POSIX File System Hierarchy             |  |   |
|   |  |  - /working/document.tex                                |  |   |
|   |  |  - /usr/local/share/texmf/fonts/tfm/...                 |  |   |
|   |  +---------------------------------------------------------+  |   |
|   |                              ^                                |   |
|   |                              | POSIX Read/Write/Open/Seek     |   |
|   |                              v                                |   |
|   |  WebAssembly Instance (pdftex.wasm)                           |   |
|   |  +---------------------------------------------------------+  |   |
|   |  | WebAssembly.Memory (32-bit linear address space)        |  |   |
|   |  |  - Static Data / Equiv Tables / String Pool             |  |   |
|   |  |  - TeX Execution Stack & Heap Buffers                   |  |   |
|   |  +---------------------------------------------------------+  |   |
|   +---------------------------------------------------------------+   |
+-----------------------------------------------------------------------+

Emulating POSIX Operating System System Calls

Classical TeX assumes a standard POSIX execution host. It frequently calls open(), read(), write(), lseek(), and stat() against underlying block storage. A browser sandbox provides no direct disk access.

Emscripten bridges this gap by wrapping WebAssembly imports in JavaScript syscall dispatchers connected to MEMFS, an in-memory virtual file system:

// Worker initialization: Configuring the virtual POSIX file system
import createTeXEngine from './pdftex.js';

async function initializeTeXWorker() {
  const engine = await createTeXEngine({
    noInitialRun: true,
    print: (text) => postMessage({ type: 'stdout', payload: text }),
    printErr: (text) => postMessage({ type: 'stderr', payload: text }),
  });

  // Create standard TeX directory hierarchy in virtual RAM
  engine.FS.mkdir('/working');
  engine.FS.mkdirTree('/usr/local/share/texmf/fonts');
  engine.FS.mkdirTree('/usr/local/share/texmf/tex/latex/base');

  return engine;
}

Every document compile mounts the user's input source file directly into /working/document.tex. When the WebAssembly module triggers an input read, MEMFS resolves the byte offset inside a JavaScript Uint8Array, returning file blocks synchronously into the engine's linear memory.

Linear Memory Constraints and Addressing

WebAssembly uses a linear memory model exposed to JavaScript as a resizable WebAssembly.Memory buffer. Because standard WebAssembly (Wasm32) uses 32-bit pointers, total addressable memory tops out at 4 GB. For a monolithic TeX runtime, memory configuration must be carefully balanced:

  • Initial Memory: Pre-allocated memory pages (each page equals 64 KB) required to instantiate the binary.
  • Maximum Memory: Upper allocation bound. pdfTeX typically operates smoothly within 128 MB to 512 MB.
  • Stack Size: Deep macro recursion inside complex packages can blow the call stack unless stack depth is configured to at least 8 MB.
# Compilation flags for building pdfTeX with Emscripten
emcc -O3 \
  -s WASM=1 \
  -s ALLOW_MEMORY_GROWTH=1 \
  -s INITIAL_MEMORY=134217728 \
  -s STACK_SIZE=8388608 \
  -s FORCE_FILESYSTEM=1 \
  -s EXPORTED_FUNCTIONS="['_run_pdftex', '_main']" \
  -s EXPORTED_RUNTIME_METHODS="['FS', 'callMain']" \
  pdftex.c -o pdftex.js

3. Package Resolution and Dynamic Virtual Asset Streaming

A full native TeX Live distribution occupies over 8 gigabytes of storage across hundreds of thousands of files, including macros, document classes (.cls), style packages (.sty), hyphenation patterns, and font metric tables (.tfm).

Shipping an entire TeX distribution across the network before allowing a user to compile their first equation is impractical. The compilation pipeline must resolve assets dynamically on demand.

+-----------------------------------------------------------------------+
|             On-Demand Asset Virtualization Architecture               |
+-----------------------------------------------------------------------+
|  pdfTeX wasm requests file: "article.cls"                             |
|       |                                                               |
|       v                                                               |
|  MEMFS Intercept: Is "/usr/local/share/texmf/.../article.cls" cached? |
|       |                                                               |
|       +--> YES: Return Uint8Array slice to WebAssembly linear memory  |
|       |                                                               |
|       +--> NO:  Suspend WebAssembly engine execution                  |
|                 Trigger Asset Fetch Worker                            |
|                 Fetch range slice: GET /packages/latex-base.tar.zst   |
|                 Decompress chunk via WebAssembly Zstandard unpacker   |
|                 Mount extracted file into MEMFS                       |
|                 Resume WebAssembly execution state                    |
+-----------------------------------------------------------------------+

Dynamic File Interception and Emscripten Asyncify

When pdfTeX attempts to open a package file that does not yet exist in MEMFS, the missing file error must be intercepted. Two architectural models solve this:

  1. Pre-Parsing Dependency Graphing: A static AST scanner inspects the input LaTeX code using regex or a lightweight tokenizer, extracting all \documentclass{...}, \usepackage{...}, and \input{...} directives prior to compilation. The required packages and their transitive dependencies are fetched concurrently and mounted into MEMFS before calling the WebAssembly entry point.
  2. Synchronous Execution Suspension via Asyncify: When an asset miss occurs mid-compilation, Emscripten's Asyncify instrumentation unwinds the WebAssembly execution stack, preserves call frame registers in memory, yields control to the JavaScript event loop to perform an asynchronous fetch(), mounts the received payload into MEMFS, and restores the call stack to resume execution transparently.

Pre-parsing provides superior performance and deterministic progress indicators. The following implementation demonstrates proactive dependency resolution:

// Dependency scanner mapping TeX directives to package bundles
export class TeXDependencyResolver {
  private packageRegistry = new Map<string, string>([
    ['amsmath', '/bundles/amsmath.pkg.bin'],
    ['graphicx', '/bundles/graphics.pkg.bin'],
    ['hyperref', '/bundles/hyperref.pkg.bin'],
    ['tikz', '/bundles/pgf-tikz.pkg.bin'],
  ]);

  public extractDependencies(texSource: string): string[] {
    const packageRegex = /\\usepackage(?:\s*\[.*?\])?\s*\{([a-zA-Z0-9,\s_-]+)\}/g;
    const dependencies = new Set<string>();
    let match: RegExpExecArray | null;

    while ((match = packageRegex.exec(texSource)) !== null) {
      const packageList = match[1].split(',').map(pkg => pkg.trim());
      for (const pkg of packageList) {
        if (this.packageRegistry.has(pkg)) {
          dependencies.add(pkg);
        }
      }
    }
    return Array.from(dependencies);
  }

  public async preloadPackages(packages: string[], fs: any): Promise<void> {
    await Promise.all(packages.map(async (pkg) => {
      const bundleUrl = this.packageRegistry.get(pkg)!;
      const response = await fetch(bundleUrl);
      const buffer = new Uint8Array(await response.arrayBuffer());
      
      // Mount package directly into TeX search tree
      fs.writeFile(`/usr/local/share/texmf/tex/latex/${pkg}.sty`, buffer);
    }));
  }
}

4. Output Generation: From In-Memory Streams to Interactive Canvas

When compilation finishes, pdfTeX writes its output into MEMFS as an in-memory PDF file. Rather than writing to persistent disk, the generated byte stream is extracted directly from browser memory:

// Extracting compiled PDF bytes from virtual filesystem
function extractCompilationArtifacts(fs: any): Uint8Array {
  const pdfPath = '/working/document.pdf';
  if (!fs.analyzePath(pdfPath).exists) {
    throw new Error('Compilation halted: No output PDF generated.');
  }
  const rawBytes = fs.readFile(pdfPath, { encoding: 'binary' });
  return rawBytes;
}
+-----------------------------------------------------------------------+
|               Zero-Copy Worker-to-Main Pipeline                       |
+-----------------------------------------------------------------------+
| Web Worker: pdftex.wasm                                               |
|   |                                                                   |
|   v                                                                   |
| Virtual MEMFS: /working/document.pdf (Uint8Array)                     |
|   |                                                                   |
|   +--> postMessage({ type: 'COMPILED_PDF', buffer: rawBytes.buffer },  |
|                    [rawBytes.buffer]) // Transferable Object          |
|                                                                       |
| Main UI Thread                                                        |
|   |                                                                   |
|   v                                                                   |
| PDF.js Vector Rendering Worker                                        |
|   |                                                                   |
|   v                                                                   |
| HTML5 Canvas / WebGL Surface (Sub-pixel hardware accelerated render)   |
|   |                                                                   |
|   +--> SyncTeX Coordinate Overlay Engine (Active Source Syncing)      |
+-----------------------------------------------------------------------+

Zero-Copy Memory Transfers via Transferable Objects

Raw PDF files for academic articles containing embedded vector graphics can reach dozens of megabytes. Copying this data between worker threads and the main UI thread via structured clone algorithms introduces memory pressure and frame drops.

Instead, the worker passes ownership of the underlying ArrayBuffer directly using Transferable Objects:

// Web Worker thread emitting output without memory cloning
const pdfData: Uint8Array = extractCompilationArtifacts(engine.FS);

// Transfer underlying buffer directly, emptying the worker's reference instantly
self.postMessage(
  {
    type: 'RENDER_OUTPUT',
    payload: pdfData.buffer,
    log: engine.FS.readFile('/working/document.log', { encoding: 'utf8' })
  },
  [pdfData.buffer]
);

SyncTeX Source-to-Output Coordinate Mapping

An essential feature of professional document engineering is bidirectional source synchronization. SyncTeX emits an auxiliary .synctex.gz file during compilation that maps line and column positions from the .tex source to geometric coordinate bounds (x,y,w,h)(x, y, w, h) on specific PDF pages.

By parsing SyncTeX records in the browser, clicking a word in the rendered PDF immediately repositions the code editor cursor to the exact line, while cursor navigation in the editor highlights corresponding bounding boxes on the canvas.

5. Real-World In-Browser Typesetting: The PetexSpace LaTeX Editor Architecture

The PetexSpace compile LaTeX in the browser implementation demonstrates how these architectural layers converge to deliver desktop-grade document composition without servers. You can also inspect generated PDF documents to verify vector accuracy and font metrics.

+-----------------------------------------------------------------------+
|                 PetexSpace LaTeX Editor Architecture                  |
+-----------------------------------------------------------------------+
|  User Interface Layer                                                 |
|  [ Monaco Code Editor ] <-------------> [ Interactive PDF Canvas ]    |
|             |                                        ^                |
|             | Source Changes                         | Render Paint   |
|             v                                        |                |
|  Compilation Orchestrator (Debounce & State Machine)                  |
|             |                                        |                |
|             | Worker Job RPC                         | Transferable   |
|             v                                        | ArrayBuffer    |
|  Dedicated TeX Web Worker                            |                |
|  +---------------------------------------------------+-------------+  |
|  | WebAssembly Engine (pdfTeX 3.141592653 / WebAssembly core)       |  |
|  | MEMFS Virtual Filesystem Cache                                   |  |
|  | Dynamic Font & Package Micro-Bundle Fetcher                      |  |
|  +-----------------------------------------------------------------+  |
+-----------------------------------------------------------------------+

Multi-Threaded Isolation and 60 FPS UI Guarantees

Executing computationally intensive operations like Knuth-Plass line breaking, macro evaluations, and font embedding on the browser's main thread causes user interface freezing. Keystrokes drop, cursor blinking stutters, and scrolling locks.

PetexSpace isolates the entire WebAssembly compilation engine inside a dedicated Web Worker. Communication between the user interface and the compilation worker occurs exclusively through asynchronous messages. The main thread continues running at 60 FPS, providing responsive syntax highlighting, auto-completion, and document navigation while compilation proceeds in the background.

// Compilation Orchestrator managing background worker lifecycles
export class TeXCompilationService {
  private worker: Worker;
  private isCompiling = false;
  private pendingJob: string | null = null;

  constructor() {
    this.worker = new Worker(new URL('./tex.worker.ts', import.meta.url), {
      type: 'module'
    });
    this.worker.onmessage = this.handleWorkerMessage.bind(this);
  }

  public requestCompilation(sourceCode: string): void {
    if (this.isCompiling) {
      // Coalesce rapid edits into single pending job
      this.pendingJob = sourceCode;
      return;
    }

    this.isCompiling = true;
    this.worker.postMessage({
      command: 'COMPILE',
      source: sourceCode,
      timestamp: performance.now()
    });
  }

  private handleWorkerMessage(event: MessageEvent): void {
    const { type, payload, log } = event.data;

    if (type === 'SUCCESS') {
      this.renderPdfOutput(payload); // payload is an ArrayBuffer
    } else {
      this.displayDiagnosticLog(log);
    }

    this.isCompiling = false;

    // Execute queued compilation if edits arrived during execution
    if (this.pendingJob !== null) {
      const nextSource = this.pendingJob;
      this.pendingJob = null;
      this.requestCompilation(nextSource);
    }
  }

  private renderPdfOutput(buffer: ArrayBuffer): void {
    // Dispatched to PDF.js rendering pipeline on canvas
  }

  private displayDiagnosticLog(log: string): void {
    // Surface parse errors and line number warnings
  }
}

Data Sovereignty and Client-Side Execution

Traditional cloud typesetting platforms require users to transmit uncompiled LaTeX code, private research data, proprietary formulas, and unpublished academic discoveries across third-party networks to remote servers. This introduces security liabilities and compliance hurdles for sensitive intellectual property.

By compiling LaTeX directly inside WebAssembly, PetexSpace enforces total data sovereignty. Documents, graphics, and generated PDF binaries reside exclusively within client-side browser memory. Execution happens locally, eliminating exposure risks and network transport overhead.

Through the intersection of WebAssembly, virtualized file systems, and hardware-accelerated rendering, high-precision typography now runs anywhere a modern web browser can execute.