# @app/latex-engine

pdfTeX 1.40.29 and BibTeX 0.99e (TeX Live 2026), compiled to WebAssembly, run in a module Web Worker.
TeX Live files are fetched on demand from our own static assets and kept in Cache Storage.
Why this design: `docs/decisions/archive.md` rows 41–50.

## Use from the web app

```ts
import { createLatexEngine } from '@app/latex-engine';

const engine = createLatexEngine({ assetBase: '/latex-engine/' }); // where public/ is served
const r = await engine.compile({ files: new Map([['main.tex', src], ['fig.png', bytes]]), main: 'main.tex' });
// r.pdf: Uint8Array | undefined, r.errors / r.warnings: { file, line, message, severity, source }[],
// r.log: the full log, every pass in order (pdflatex, bibtex's .blg, pdflatex …), each under a header,
// r.synctex (gzip), r.passes (pdflatex → bibtex → pdflatex …), r.stats (ms, bytes fetched),
// r.timedOutMs: set when the compile ran past its time limit (COMPILE_TIME_LIMIT_MS, 60 s): no PDF, no log;
// the worker was terminated and the next compile starts a new one
```

Serve the contents of `public/` at `assetBase` as static files:

| Path | What | Size | Cache |
|---|---|---|---|
| `engine/worker.js` | worker (default `workerUrl`) | 20 KB | revalidate |
| `engine/pdftex.{mjs,wasm}` | pdfTeX | 1.65 MB (0.57 MB gzip) | loaded as `?v=<hash>` from the manifest |
| `engine/bibtex.{mjs,wasm}` | BibTeX | 0.28 MB | same |
| `texlive/manifest.json` | names the current index and format | 2 KB | **no-cache** |
| `texlive/index-<sha>.tsv.gz` | every file in the tree | 1 MB | immutable |
| `texlive/pdflatex-<sha>.fmt` | LaTeX format built by this engine | 3.5 MB | immutable |
| `texlive/packs/<sha>.pack` | 12.8k gzip packs, 1.4 GB total | 66 B – 15 MB each (limit 25 MiB) | immutable |

In the app, `apps/web/scripts/link-latex-engine.mjs` symlinks `public/engine` and `public/texlive` into the
static site at `/latex-engine/` after `next build` (nothing copied) and writes the cache headers in `out/_headers`;
`next dev` proxies that path to wrangler dev (docs/decisions/archive.md rows 113–114). Workers Static Assets allow 20,000 files per
version on Free, 100,000 on Paid, 25 MiB per file (https://developers.cloudflare.com/workers/platform/limits/);
the tree is ~12.8k files. If it outgrows that (or deploys can't follow the symlinks), move `texlive/` to R2
behind a custom domain and pass that URL as `assetBase` (the worker's sync XHR then needs CORS on the bucket).

`.wasm` must be served as `application/wasm`. Packs are already gzip: serve them as
`application/octet-stream` with no `Content-Encoding`. The worker uses synchronous XHR inside the
worker and Cache Storage, so the page needs no special headers (no COOP/COEP).

## Build (nothing large is committed)

```sh
pnpm install --ignore-workspace --ignore-scripts   # standalone; esbuild works without its postinstall
pnpm build:wasm     # Docker: emscripten/emsdk 5.0.7 builds pdftex/bibtex from TeX Live 2026.1 source (~15 min)
pnpm build:tree     # Docker: official texlive/texlive image (digest in scripts/tree/texlive-image.txt) → packs, index, format
pnpm build          # bundles engine/worker.js and the harness
pnpm test:unit && pnpm test:e2e                    # e2e serves public/ on :4310
```

Debugging: `node scripts/compile-node.ts <dir> <main.tex> <out.pdf>` compiles under Node with the same
engine; `scripts/native-reference.sh` builds the same project with native TeX Live 2026 for comparison;
`node scripts/pack-usage.ts <dir> <main.tex>` shows which packs a project reads.
