@arena.ai/faster-latex
v0.3.1
Published
Render full LaTeX documents to HTML (with MathML math) in the browser or on the server (SSR) — pure Rust compiled to WebAssembly.
Maintainers
Readme
faster-latex
Render full LaTeX documents to HTML — sections, text, environments, lists,
tables, cross-references, and math — in pure Rust, compiled to WebAssembly
for the browser. Math is delegated to
pulldown-latex, which emits MathML
Core (rendered natively by every modern browser).
- Infallible by contract.
render_to_htmlnever panics on any input; it returns HTML plus a list of non-fatal warnings. - Runtime macros.
\newcommand/\newenvironmentwork in text and inside math — one thing pulldown-latex or LaTeX.js alone don't give you. - Real documents, not just snippets. Front matter (
\title/\authorafter\begin{document}), natbib citations, a.bibbibliography,\chapter/\part,\input/\include,\DeclareMathOperatorand physics/siunitx math, and counter registers all render — the constructs ordinary papers actually use. - Small and dependency-light. The core crate's only runtime dependency is
pulldown-latex(→bumpalo). The core WASM bundle is CI-gated at 640 KiB. - PDF without a browser.
fl doc.tex --pdfruns a TeX-style layout engine (Knuth–Plass line breaking, OpenType-MATHmath) and writes the PDF itself — no Chrome, no Node, no network. It also compiles to wasm, so the same engine exports a PDF in the browser from a lazily loaded bundle. Seedocs/pdf-output.md. - Apache-2.0 licensed, with no copyleft anywhere in the stack.
use faster_latex::{render_to_html, Options};
let out = render_to_html(
r"\section{Hi} Euler: $e^{i\pi}+1=0$.",
&Options::default(),
);
assert!(out.html.contains("<h2"));
assert!(out.html.contains("<math"));
for w in &out.warnings {
eprintln!("{} (line {}): {}", w.kind.as_str(), w.line, w.message);
}Pipeline
source ─▶ lex ─▶ expand macros ─▶ parse ─▶ number (pass 1) ─▶ emit HTML (pass 2)
│
math snippets ─▶ pulldown-latex ─▶ MathMLMath and verbatim are captured as opaque raw slices in the lexer, before the
macro expander runs, so their contents can never be mangled. Macro expansion is
driven by an explicit work stack (no recursion) with a fuel budget and
depth limit, so macro bombs warn instead of hanging. Every user macro is also
recorded in \def form and re-fed to pulldown-latex per math snippet, which is
how a \newcommand defined once works in both text and $…$.
Live editing
render is the API for rendering a document once. For an editor, use Session,
which re-renders and hands back only the top-level blocks that changed:
const session = new Session();
const tpl = document.createElement("template");
function onInput(src) {
const u = session.update(src, opts);
if (u.full) {
preview.innerHTML = u.patch.html;
return;
}
const article = preview.firstElementChild;
const anchor = article.children[u.patch.from + u.patch.removed] ?? null;
for (let i = 0; i < u.patch.removed; i++) article.children[u.patch.from].remove();
if (u.patch.html) {
tpl.innerHTML = u.patch.html;
article.insertBefore(tpl.content, anchor);
}
}The engine is not what makes a keystroke slow on a large document — the browser
is, relaying out a DOM that innerHTML just discarded and rebuilt for an edit
that changes a median of one byte of the output. On Euclid's Elements
(614 KiB of LaTeX, 1.4 MB of HTML) a keystroke costs 344 ms assigning
innerHTML and 61 ms applying the splice; on Keynes' A Treatise on
Probability (1.28 MB), 347 ms → 85 ms. The wasm bridge itself is 1.7 ms of that,
which is why "send less over the bridge" is not the fix.
The measurements, the correctness oracle, and what is still slow are in
docs/incremental-rendering.md; the harness is
bench/incremental/.
Supported subset (≈ LaTeX.js scope + runtime macros)
| Area | Supported |
|------|-----------|
| Structure | \documentclass, preamble, \title/\author/\date/\maketitle (in preamble or body), \tableofcontents, \appendix, \today |
| Front matter | \subtitle, \and, \thanks, \affiliation/\institution/\institute/\address, \email, \keywords/IEEEkeywords, \subjclass, \ccsdesc (+ CCSXML dropped), \IEEEauthorblockN/A, \inst |
| Sectioning | \part/\chapter (book/report) … \subparagraph (+ *), automatic numbering (chapter-prefixed, appendix letters, Roman parts), \label/\ref/\eqref/\pageref/\nameref |
| Multi-file | \input/\include spliced from a caller-supplied file map; \frontmatter/\mainmatter/\backmatter |
| Text | \textbf \textit \emph \texttt \textsc \underline; declaration forms (\bfseries, …); font sizes \tiny…\Huge; \\, paragraphs, comments |
| Typography | ~, --/---, ``/'' quotes, accents → Unicode, many symbol commands (\LaTeX, \S, \ss, \ae, …), \verb |
| Lists | itemize, enumerate, description (nested), \item[…] |
| Blocks | quote, quotation, verse, center, flushleft, flushright, abstract, verbatim |
| Floats | figure/table with \caption + numbering, \includegraphics (URL-sanitized; relative widths honored in paper mode), TikZ/PGF & pgfplots → SVG (graphics feature) |
| Tables | tabular with l/c/r columns and \hline |
| Citations | natbib \cite/\citep/\citet/\citealt/\citealp/\citeauthor/\citeyear/\citenum/\nocite (+ post-notes, author-year labels) |
| Bibliography | thebibliography/\bibitem, or \bibliography{refs} resolved from a .bib file (cited entries, in order) |
| Counters | \newcounter/\setcounter/\stepcounter/\addtocounter/\value, \arabic/\alph/\Alph/\roman/\Roman/\fnsymbol; \newif/\iffoo…\else…\fi |
| Math | $…$, \(…\), \[…\], $$…$$, equation(), align() with per-line numbering + \notag, gather, multline, displaymath; \DeclareMathOperator(*), \DeclarePairedDelimiter, \ensuremath, physics & siunitx shims |
| Macros | \newcommand/\renewcommand/\providecommand (9 args + optional default), \newenvironment |
Deliberately punted (warn, never crash)
Real package loading (\usepackage is ignored with a warning); a general TeX
expander (\def bodies, \ifnum/\ifx/\csname/\expandafter, catcodes —
\newif conditionals are honoured); \newcommand with an optional argument
used inside math (warns and falls back to raw rather than mis-render);
custom numbering formats (\renewcommand{\thesection}{…}, \numberwithin) are
inert. Unknown commands render as a visible <span class="fl-unknown"> fallback
(off by default); unknown environments still render their contents. Anything
unsupported produces a Warning, not a failure — and never leaks its argument
into the prose (enforced by the corpus_argument_leaks oracle over complete
documents).
Security
Rendered HTML is safe to insert into a page:
- All text is HTML-escaped; math text (
\text{…}) is escaped by pulldown-latex. \includegraphicsURLs are allowlisted to relative paths andhttp/https;javascript:,data:,vbscript:, etc. are rejected with a warning and the image is omitted (the URL is never echoed into the output).- Locked in by the
952-xss-attempts.texfixture and a panic-smoke test over mutated inputs.
Rust API
pub struct Options {
pub base_url: Option<String>,
pub predefined_macros: Vec<String>, // raw \newcommand… lines
pub files: BTreeMap<String, String>, // \input/\include/\bibliography sources
pub full_document: bool, // full <html> shell vs. bare <article>
pub paper: bool, // LaTeX-faithful paged "paper" mode (needs `paper` feature)
pub max_expansions: u32, // macro-bomb guard (default 100_000)
pub max_nodes: u32, // node-count guard (default 500_000)
pub debug_unknown: bool, // render unknown constructs as red badges
pub today: Option<String>, // what \today expands to (engine reads no clock)
}
pub struct RenderOutput {
pub html: String,
pub warnings: Vec<Warning>,
pub effective_source: String, // input after macro-prepend + include splicing
}
pub fn render_to_html(source: &str, options: &Options) -> RenderOutput; // infallible
pub const ARTICLE_CSS: &str; // shipped web stylesheet, version-locked to the crate
#[cfg(feature = "paper")]
pub const PAPER_CSS: &str; // LaTeX-faithful paged stylesheet (paper mode)
pub const VERSION: &str;
#[cfg(feature = "pdf")] // browser-free PDF output — see docs/pdf-output.md
pub mod pdf {
pub struct PdfOptions { pub fonts: FontSet, pub images: ImageSet,
pub producer: Option<String>, pub title: Option<String>,
pub compress: bool }
pub struct PdfOutput { pub bytes: Vec<u8>, pub pages: usize, pub warnings: Vec<Warning> }
pub fn render_to_pdf(source: &str, options: &Options, pdf: &PdfOptions) -> PdfOutput; // infallible
}Optional serde feature derives Serialize on Warning/WarningKind.
Paper mode (paper feature)
The default output is a responsive web article. The optional paper feature
(implied by cli) adds a LaTeX-faithful paged mode — document-aware sheet
size/orientation, class- and style-aware text blocks and base type metrics,
Latin Modern typography, fancyhdr running heads/feet as @page margin boxes,
real page breaks, a full title page, dot-leader TOC/LoF/LoT, a two-column index,
and hanging-indent bibliography. Printed by headless Chrome it produces a
document that reads as the same paper as a pdflatex build. It is gated behind
a feature so its CSS never enters the core wasm bundle, whose 640 KiB budget is
enforced in CI.
cargo fl document.tex --paper -o document.html # then print to PDF from ChromeThe docs/pdf-parity-plan.md scoreboard and the scripts/torture-{pdf,diff}.mjs
harness measure paper-mode output against a committed reference PDF (see
npm run torture:diff; needs Chrome + poppler).
PDF output without a browser (pdf feature)
The optional pdf feature (also implied by cli) writes a PDF directly,
with no browser, no Node and no network:
cargo fl document.tex --pdf # writes document.pdfIt runs its own TeX-style layout engine over the numbered AST: Knuth–Plass
paragraph breaking, page composition with floats and footnotes, and math laid
out from the OpenType MATH table of Latin Modern Math (fraction and script
shifts, stretchy delimiters built from the font's variant and assembly recipes,
big operators with limits). Cross-references resolve through a second pass, the
way LaTeX's .aux file does. Output is searchable, hyperlinked, and byte-for-byte
reproducible.
Fonts are supplied by the caller — the library never reads the filesystem — and
the CLI finds the SHA-pinned Latin Modern family that npm run assets caches.
With no fonts at all it falls back to the PDF base-14 metrics that are
compiled in, so render_to_pdf is infallible and needs no assets.
use faster_latex::{Options, pdf::{PdfOptions, render_to_pdf}};
let out = render_to_pdf(source, &Options::default(), &PdfOptions::default());
assert!(out.bytes.starts_with(b"%PDF-"));Three dependencies, all optional: pdf-writer (PDF objects), ttf-parser
(OpenType metrics), and hypher (compact Knuth--Liang English hyphenation).
None reaches the core wasm bundle, which is
byte-for-byte unchanged by all of this.
The same engine runs in the browser, as a third lazily loaded bundle
(@arena.ai/faster-latex/web-pdf, +109 KiB brotli over core) exporting render_pdf. It is
fetched on the click that asks for a PDF, so it never touches the render path —
that is what the demo's Export PDF button does. Exporting the torture test
from the browser and running fl --pdf on it produce byte-identical files.
This is newer and less proven than the Chrome path: on the torture test it
scores word F1 0.945, 13/15 pages, and PAA 0.612 (Chrome: 0.986, 15/15,
and 0.99). English Knuth--Liang hyphenation is implemented; the largest
remaining page-count gap is TeX's float-page composition. See
docs/pdf-output.md for the scoreboard
(npm run torture:diff:rust), the design, and the ranked list of gaps.
Beyond that one torture document, faster-latex is scored page-for-page against
all three de-facto LaTeX engines — pdfLaTeX, XeLaTeX and LuaLaTeX — on the
ten demo documents, with pinned, reproducible goldens and a CI regression
ratchet. Each page also reports its distance from the three engines' own
pairwise spatial-consensus floor. The workflow, metrics, and gates are in
docs/engine-parity-testing.md (npm run
examples:reference to build the goldens, npm run examples:parity to score).
The browser-free exporter has its own breadth scorer over those same 30 PDFs:
npm run examples:parity:direct renders every fixture with fl --pdf and
reports the worst pdfLaTeX/XeLaTeX/LuaLaTeX result.
A head-to-head four-renderer shootout pits faster-latex against the three
TeX engines on speed and pixel-perfection. The three TeX engines remain the
parity oracle; faster-latex is timed and compared through its own browser-free
PDF backend (fl --pdf), so both axes are measured PDF-to-PDF with no browser
in the loop. See docs/engine-shootout.md
(npm run examples:shootout; needs Docker and poppler).
A separate browser shootout compares the shipped wasm bundle against
LaTeX.js, the closest pure-JS competitor, on speed,
compatibility, scaling and footprint — the two renderers you would actually
choose between for LaTeX in a page. See
docs/latexjs-shootout.md.
The ordered implementation plan for closing the remaining visual gap is
docs/pixel-parity-roadmap.md.
Separately from all of that, fl --trip runs Knuth's TRIP test — the
certification torture test for TeX82 itself — against a TeX82 engine core that
lives beside the renderer and shares nothing with it. It passes — all four
artifacts byte-identical after the documented normalizations — and npm run
trip re-scores it against ground truth generated on the same machine by a
known-passing TeX, failing on any regression. The suite certifies its
own tolerance set on every reference build: if a real conforming engine does not
reconcile with Knuth's canonical files through the same normalizer that scores
faster-latex, it refuses to produce a reference. See
docs/trip-test.md.
To review a single commit's visual effect on the torture test, npm run
torture:triptych renders the test to PDF at two git refs and writes per-page
reference | before | after contact sheets to target/pdf/triptych/, plus a
metrics delta and a per-page "changed pixels" count:
npm run torture:triptych -- --before HEAD~1 --after HEAD # the commit you just made
npm run torture:triptych -- --before main --after working # uncommitted work vs mainEach ref is built in a throwaway git worktree, so the working tree is untouched.
The SDK family
The engine is published as a small family of packages developed together in a
pnpm workspace under sdk/:
| Package | What it is |
|---|---|
| faster-latex | The engine: wasm builds + the worker wrapper. |
| @arena.ai/faster-latex-dom | Framework-neutral DOM controller — WASM loading, worker lifecycle, incremental block patching, deduplicated styles. |
| @arena.ai/faster-latex-react | Thin React integration (<FasterLatex>, useFasterLatex), no duplicate WASM. |
A runnable Vite + React demo of @arena.ai/faster-latex-react is in
examples/react-demo — npm run demo (missing generated
engine and SDK artifacts are built automatically on first run).
Releases are cut with Changesets; the engine version is
mirrored into Cargo by pnpm version-packages. The ranked plan for future web
and native SDKs is in docs/platform-support.md, and
the backend-neutral render-tree protocol the native SDKs will decode is designed
in docs/render-tree-protocol.md.
WebAssembly / JavaScript
Published to npm as @arena.ai/faster-latex.
The package ships two builds behind one name — a bundler build (the default
import, for Vite/webpack/Rollup) and a web build (@arena.ai/faster-latex/web, a plain
ES module that fetches its own .wasm, for a CDN or <script type=module>).
TypeScript types are included for both.
For a live editor or a React app, prefer @arena.ai/faster-latex-dom / @arena.ai/faster-latex-react over
calling render/Session directly — they own the worker lifecycle, incremental
patching, and style installation for you.
With a bundler (Vite, webpack, Rollup, Next.js…)
npm install @arena.ai/faster-latex// The bundler build auto-initializes the wasm on import — no init() call.
import { render, article_css, version } from "@arena.ai/faster-latex";
const { html, warnings } = render(source, { fullDocument: false });
document.getElementById("out").innerHTML = html;
// Inject the shipped stylesheet once:
const style = document.createElement("style");
style.textContent = article_css();
document.head.appendChild(style);From a CDN / no build step (browser, Deno)
Use the web subpath; call the default-exported init() once before rendering.
The .wasm is resolved relative to the module URL, so no path config is needed:
<script type="module">
import init, { render } from "https://cdn.jsdelivr.net/npm/@arena.ai/faster-latex/web/faster_latex.js";
await init();
document.getElementById("out").innerHTML = render("$e^{i\\pi}+1=0$").html;
</script>(unpkg works too: https://unpkg.com/@arena.ai/faster-latex/web/faster_latex.js. Pin a
version with @arena.ai/[email protected] for production.)
render(source, options?) returns a plain object { html, warnings } (no
.free()-able classes). The bundle uses the default allocator and
console_error_panic_hook (default-on panic-hook feature).
Building the package locally
pnpm install # once, to set up the workspace
pnpm build:npm # -> sdk/faster-latex/ (bundler/ + web/ + worker/ …)
npm pack sdk/faster-latex # inspect the tarballThe package version lives in the tracked manifest sdk/faster-latex/package.json
(the single source of truth). pnpm version-packages applies pending
Changesets and mirrors the engine version into
Cargo.toml; CI publishes the whole SDK family (@arena.ai/faster-latex,
@arena.ai/faster-latex-dom, @arena.ai/faster-latex-react) idempotently via Changesets — see
.github/workflows/release.yml.
Stylesheet requirement
MathML renders without any CSS. Injecting ARTICLE_CSS gets you correct math
spacing and declares the Latin Modern Math webfont from an immutable URL, so
stretchy delimiters have the same variants as TeX. To also load the Latin Modern
Roman faces used by \text{…} inside math, link the upstream stylesheet:
<link rel="stylesheet"
href="https://cdn.jsdelivr.net/gh/carloskiki/pulldown-latex@c8b7d9865bb2852c19e4f3d9f0f8bda3909f2648/styles.min.css">The math webfont itself is about 390 KB and is downloaded only when a page uses
it. The URL of the complete upstream stylesheet is exposed as
MATH_FONT_CSS_URL, and full_document output embeds ARTICLE_CSS and links it
for you.
Pinned to a commit, not a tag: upstream publishes
pulldown-latex0.8.0 to crates.io but its highest git tag isv0.7.1, so the@0.8.0ref previously used here never existed and silently 404'd.
Demo
A framework-free live editor lives in demo/:
wasm-pack build crates/faster-latex-wasm --target web --out-name faster_latex
cp -r crates/faster-latex-wasm/pkg demo/pkg
cd demo && python3 -m http.server 8000 # then open http://localhost:8000Development
cargo test --workspace
cargo clippy --workspace --all-targets -- -D warnings
cargo fmt --check
node scripts/check-wasm-size.mjs --build # build the npm package and assert the wasm size budgets
wasm-pack test --headless --chrome crates/faster-latex-wasmThe wasm size budgets (core + graphics) are enforced in CI and defined once,
with their rationale, in scripts/check-wasm-size.mjs — run it with --build to
reproduce the CI check locally. Raise a budget only deliberately, with a comment
saying why.
CI enforces cargo fmt --all -- --check and the size budgets above. To catch
both locally on every commit — auto-formatting staged Rust and, when a change
can affect the bundle, asserting the wasm size budget — enable the tracked
pre-commit hook once per clone:
git config core.hooksPath .githooksIt runs cargo fmt --all and re-stages the result, then (only when crates/**
or Cargo.toml/Cargo.lock are staged) builds the wasm and checks the size
budget. Skip the whole hook for a single commit with git commit --no-verify,
or skip just the slow size build with SKIP_WASM_SIZE=1 git commit.
HTML snapshots (via insta) live in
crates/faster-latex/tests/corpus/ — update them with cargo insta review.
Benchmarks
The name claims speed, so the repo measures it — against every major LaTeX parser and renderer, with a correctness gate attached to every number.
npm run bench:install # pinned third-party engines (exact versions, committed lockfile)
npm run bench # writes bench/results/<stamp>.json and bench/RESULTS.mdResults: bench/RESULTS.md. Methodology, prior art and the
benchmark's own known limits: docs/benchmark-plan.md.
Where the engine spends its time, what optimisations worked and which
plausible-looking ones measured as noise:
docs/performance.md.
Two things make it worth reading rather than skipping:
- Engines only race within their group. Document→HTML, document→AST, math→MathML and document→PDF are four different jobs. A math-only renderer is not "faster than" a document engine, and the tables never pretend otherwise.
- Every speed number carries a fidelity score — the share of the committed
pdfLaTeX ground truth's words that survive into the engine's output. The
fastest possible LaTeX renderer is
() => ""; a benchmark that cannot tell the difference is measuring nothing.
There is also a headless-Chrome suite, because LaTeX.js builds a DOM and has no
string-output path: measuring it under Node measures jsdom. In the browser both
engines get a fair run — and that is the environment faster-latex actually
ships into.
Why a new parser?
As of mid-2026 there is no maintained, permissively licensed, pure-Rust
full-document LaTeX parser. The strong parsers (texlab, RusTeX) are GPL; Tectonic
is a C/XeTeX core with no WASM build; mitex's text mode is unfinished. So the
document parser/emitter here is written from scratch (~3–5 kLoC) and only the
math layer is reused, from the MIT-licensed pulldown-latex. See
crates/faster-latex/tests/corpus/ATTRIBUTION.md
for the MIT reference projects (LaTeX.js, unified-latex) used to shape the scope.
License
Licensed under the Apache License, Version 2.0 (LICENSE).
No GPL/AGPL/copyleft code is used anywhere in the dependency graph — the
entire runtime stack is faster-latex → pulldown-latex → bumpalo, all
permissively licensed.
Unless you explicitly state otherwise, any contribution intentionally submitted for inclusion in the work by you, as defined in the Apache-2.0 license, shall be licensed as above, without any additional terms or conditions.
