MetaPost and TikZ in the browser, with real LaTeX. John Hobby's MetaPost, pdfTeX, LuaTeX and dvisvgm are compiled to WebAssembly and wrapped in one TypeScript library: MetaPost source becomes SVG with real glyph outlines, EPS and structured JSON, and whole LaTeX/TikZ documents become one SVG per page — shadings, patterns, opacity, pgfplots and all. No server, no web fonts, and output that is byte-identical to TeX Live's own mpost and latex + dvisvgm.
Two ways. The prebuilt archive is what most people want: it contains the compiled engines, the JavaScript and the font, format and package bundles, and needs neither TeX Live nor Emscripten.
mp-tikz-wasm-0.1.0.tar.gz (or .zip) from the releases page and unpack it.dist/ folder on any static web server (the files are plain; nothing needs special headers). To try it locally: node serve.mjs 8080 inside the unpacked folder, then open http://localhost:8080/site/.import { MetaPost } from '/path/to/dist/index.js'.git clone https://github.com/jmckalex/mp-tikz-wasm.git
cd mp-tikz-wasm
./scripts/extract-vendor.sh # fetch and verify the pinned TeX Live 2025 source (111 MB)
./scripts/verify-pin.sh # assert the mplib API the design relies on
npm install
make contract # native build + the L0 contract harness (46 checks)
./scripts/native-texlive.sh # native web2c pass: generates pdfTeX's C (once, ~5 min)
./scripts/native-dvisvgm.sh # native configure of dvisvgm: config.h (once)
npm run build # mplib.wasm, tex.wasm, dvisvgm.wasm, texmf tree, formats, bundles, TypeScript
npm test # unit + end-to-end tests
npm run demo # http://localhost:8080/site/
Prerequisites: a C compiler, Node ≥ 20, the Emscripten SDK (.emsdk-version pins 6.0.9) and a TeX Live 2025 installation, which is both the source of the bundled texmf files and the oracle the tests compare against. See Building for what each step produces.
This is the tikzjax-style integration: one script, and every diagram tag on the page is replaced by its SVG.
<script type="module" src="/path/to/dist/auto.js"></script>
<script type="text/tikz" data-libraries="arrows.meta,calc">
\begin{tikzpicture}
\draw[->,thick] (0,0) -- (2,1) node[right] {$x^2$};
\end{tikzpicture}
</script>
<script type="text/metapost">
draw fullcircle scaled 50 withpen pencircle scaled 1.2;
label.top(btex $\int_0^1 x\,dx$ etex, (0,25));
</script>
<tikz-diagram data-libraries="shadings">\shade[ball color=red] (0,0) circle (1);</tikz-diagram>
<metapost-diagram>input boxes; boxit.a("hello"); drawboxed(a);</metapost-diagram>
\documentclass is wrapped in a standalone document; a complete document is compiled as is. A MetaPost body without beginfig becomes one figure; input lines are hoisted above it.data-* on script tags, plain on custom elements): libraries, packages, preamble, border, gdlibraries (graph drawing, implies LuaTeX) and engine (auto | latex | lualatex | plain) for TikZ; tex (auto | plain | latex | none) and prologues for MetaPost; fonts="woff2" for embedded fonts instead of outlines; cache="off"; show-console; alt.data-base (where bundles/ and the wasm files live, default next to the script), data-worker="off", data-observe="off", data-snapshot="on", data-prefetch="off" (by default the loader fetches, in parallel, the files the page's diagrams will need before the first render, so a slow host does not pay one round trip per file).<tikz-diagram> and <metapost-diagram> are registered custom elements (web components): they render when they are connected to the document, so elements created later by a framework or by script work too. The two <script> forms are picked up by a MutationObserver instead, because script elements cannot be custom elements.mp-tikz-wasm:rendered event; window.mpTikzWasm.render() renders programmatically.< for <; script tags are raw.The engines run in a Web Worker, so the page never blocks; there is a 20 s watchdog per run.
import { MetaPost } from './dist/index.js'; // browser (a Worker) or Node (in-process)
const mp = await MetaPost.create(); // loads the engines and the bundle manifests
// MetaPost -> SVG / EPS / JSON
const r = await mp.run(`
beginfig(1);
draw fullcircle scaled 100;
label.top(btex $\\int_0^\\infty e^{-x^2}dx$ etex, (0,50));
endfig; end.`, { format: ['svg', 'eps', 'json'] });
r.status // 'ok' | 'warning' | 'error' | 'fatal' (MetaPost's own history)
r.figures[0].svg // glyph outlines, self-contained
r.figures[0].eps // what mpost writes, byte for byte
r.figures[0].json // typed knots, pens, colours, dashes, text runs, clips
r.diagnostics // [{ severity, source, message, help[], file, line }]
r.stats // metapostMs, texMs, texRuns, snippetCacheHits ...
// LaTeX / TikZ -> one SVG per page
const t = await mp.latex(String.raw`
\documentclass[tikz,border=2pt]{standalone}
\begin{document}\begin{tikzpicture}
\shade[ball color=blue!60] (0,0) circle (1);
\end{tikzpicture}\end{document}`);
t.pages[0] // SVG
t.diagnostics // TeX errors with document line numbers, package warnings
t.stats // texMs, dvisvgmMs, format ('latex', 'tikz', 'dvilualatex' …)
// TikZ graph drawing needs LuaTeX: engine 'auto' switches to luatex.wasm by itself
const g = await mp.latex(String.raw`
\documentclass[tikz]{standalone}
\usetikzlibrary{graphs,graphdrawing} \usegdlibrary{layered}
\begin{document}\tikz\graph[layered layout]{a -> {b, c} -> d};\end{document}`, { engine: 'auto' });
| Option | Where | Meaning |
|---|---|---|
| bundles, bundleBaseUrl | create | which texmf bundles to load and from where (default: all, next to the script) |
| numberSystem | create | scaled (mpost's default), double, decimal |
| tex | create / run | label engine for btex: auto (LaTeX when the preamble says so), plain, etex, latex, none |
| deterministic | create | fixed random seed and frozen date, so two runs are byte-identical (default on) |
| runScript, makeText, onFindFile | create | JavaScript hooks for runscript, for overriding label typesetting, and for last-chance file lookup (in-process mode) |
| worker, timeoutMs, snapshot, prefetch | create | Worker vs in-process; stall watchdog (a run is killed only when nothing happens for timeoutMs, so a slow first load is not cut short); prefetch: ['latex'] fetches a first run's files in parallel; default snapshot policy |
| format, prologues, internals, files, jobName | run | outputs; prologues (3 for SVG outlines); -s name=value internals; extra files placed next to the job |
| engine, fonts, pages, bbox, dvisvgmArgs, snapshot, files | latex | latex | lualatex | luatex | plain | tex | auto; outlines or woff2; page selection; dvisvgm's bbox; extra arguments; the pre-warmed format |
Also exported: MetaPostPool for batch work across several workers, sanitizeSvg for innerHTML use (MetaPost's special can inject arbitrary text), postProcessSvg (numeric compaction, id namespacing), scanTexBlocks, splitMpx, parseMetaPostLog.
The same API runs in-process in Node with the bundles read from disk; nothing is fetched. MetaPost.create({ texmfDir }) can point at a texmf tree in the flattened layout instead of bundles.
npx mpost-wasm figure.mp # figure.1, figure.2 … like mpost
npx mpost-wasm -s 'outputformat="svg"' -s prologues=3 figure.mp
npx mpost-wasm -tex=latex -numbersystem=double -jobname=out figure.mp
npx mpost-wasm --latex figure.tex # figure-1.svg, figure-2.svg …
npx mpost-wasm --latex --plain --fonts=woff2 figure.tex
npx mpost-wasm --latex --engine=lualatex graph.tex # or --engine=auto (the default)
mpost-wasm accepts the usual mpost flags (-interaction, -jobname, -tex, -s, -halt-on-error, -recorder; -troff with a warning), writes output files with mpost's outputtemplate naming, prints the transcript, and exits 1 on error.
Everything below was rendered by the wasm engines when this page was built; open “source” under any figure for the exact input. The output of the MetaPost side is byte-identical to native mpost on the golden corpus, because it is MetaPost: the C output is left untouched.
.. for smooth curves solved by Hobby's algorithm, -- for straight lines, tension and curl control, explicit controls, cycles. 14 ms MetaPostbeginfig(1);
draw (0,0)..(30,40)..(60,0) withcolor (0.1,0.3,0.8);
draw (0,50)..tension 2..(30,90)..(60,50);
draw (80,0){up}..{right}(140,60);
draw (80,80){curl 0}..(110,120)..{curl 2}(140,80) withpen pencircle scaled 1.2;
draw (160,0)..(190,40)..(220,0)..cycle;
draw (160,60)..controls (170,110) and (210,110)..(220,60) dashed evenly;
endfig; end.beginfig(1);
draw (0,0)--(50,50) withpen pencircle scaled 6;
draw (60,0)--(110,50) withpen pensquare scaled 6;
draw (120,0)--(170,50) withpen pencircle xscaled 8 yscaled 2 rotated 30;
linecap := butt; linejoin := mitered;
draw (0,70)--(25,120)--(50,70) withpen pencircle scaled 6;
linejoin := beveled; draw (60,70)--(85,120)--(110,70) withpen pencircle scaled 6;
linejoin := rounded; linecap := rounded; draw (120,70)--(145,120)--(170,70) withpen pencircle scaled 6;
draw (0,140)--(170,140) dashed evenly scaled 2 withpen pencircle scaled 2;
draw (0,160)--(170,160) dashed withdots scaled 2 withpen pencircle scaled 3;
endfig; end.unfill, filldraw, and buildcycle to fill the intersection of two shapes. 2 ms MetaPostbeginfig(1);
fill fullcircle scaled 50 shifted (25,25) withcolor (0.9,0.2,0.2);
fill unitsquare scaled 40 shifted (60,5) withcolor 0.5;
fill fullcircle scaled 50 shifted (135,25) withcmykcolor (1,0,0,0);
filldraw fullcircle scaled 40 shifted (185,25) withcolor (0.2,0.7,0.3) withpen pencircle scaled 2;
path a, b; a := fullcircle scaled 60 shifted (30,90); b := fullcircle scaled 60 shifted (60,90);
draw a; draw b; fill buildcycle(a,b) withcolor (0.2,0.4,0.9);
fill fullcircle scaled 60 shifted (150,90); unfill fullcircle scaled 30 shifted (150,90);
endfig; end.beginfig(1);
picture pic; pic := image(for i = -8 upto 8: draw (i*10,-70)--(i*10,70) withpen pencircle scaled 1.5 withcolor (0.5+i/16, 0.3, 0.8-i/16); endfor);
clip pic to fullcircle scaled 130;
draw pic; draw bbox pic dashed evenly withcolor 0.5;
picture q; q := image(fill fullcircle scaled 30 withcolor (1,0.6,0););
setbounds q to unitsquare scaled 12 shifted (-6,-6);
draw q shifted (110,0); draw bbox q shifted (110,0);
endfig; end.label needs no TeX at all: metrics come from the TFM file and the glyphs are Computer Modern Type 1 outlines converted to SVG paths by MetaPost's own backend (prologues:=3). No web fonts are involved. 4 ms MetaPostprologues := 3;
beginfig(1);
path p; p := fullcircle scaled 120;
draw p withpen pencircle scaled 1;
label.top("north", point 2 of p); label.bot("south", point 6 of p);
label.lft("west", point 4 of p); label.rt("east", point 0 of p);
label("MetaPost" infont "cmbx10" scaled 1.6, (0,12));
label("cmti10 italic" infont "cmti10", (0,-30));
dotlabel.lrt("origin", origin);
endfig; end.btex … etex blocks are typeset by a real pdfTeX (DVI mode) in one batched run per document, converted by MetaPost's own dvitomp, and cached by content hash. 2 ms MetaPost, 47 ms TeXprologues := 3;
beginfig(1);
numeric a; a := 60;
draw (-a,0)--(a,0) withcolor .6white; draw (0,-a)--(0,a) withcolor .6white;
draw (-a, a*sind(-90)) for t=-89 upto 90: ..(t*a/90, a*sind(t)) endfor withpen pencircle scaled 1.2 withcolor (0.1,0.3,0.8);
label.top(btex $y=\sin\theta$ etex, (a/2, a*.9));
label.bot(btex $-\pi$ etex, (-a,0)); label.bot(btex $\pi$ etex, (a,0));
label.rt(btex $\displaystyle\int_{-\pi}^{\pi}\sin\theta\,d\theta = 0$ etex, (a+8, -a*.8));
endfig; end.verbatimtex preamble with \documentclass switches the label engine to LaTeX automatically. amsmath, amssymb, tabular, any package in the bundles. 2 ms MetaPost, 140 ms TeXverbatimtex
\documentclass{article}
\usepackage{amsmath,amssymb}
\begin{document}
etex
prologues := 3;
beginfig(1);
draw fullcircle scaled 110 withpen pencircle scaled .8;
label.top(btex $\displaystyle\int_0^\infty e^{-x^2}\,dx = \frac{\sqrt{\pi}}{2}$ etex, (0,55));
label.bot(btex $\begin{pmatrix} \cos\theta & -\sin\theta \\ \sin\theta & \cos\theta \end{pmatrix}$ etex, (0,-55));
label.lft(btex $\forall x \in \mathbb{R}$ etex, (-55,0));
label.rt(btex \LaTeX\ \textit{in the browser} etex, (55,0));
endfig; end.input boxes;
prologues := 3;
beginfig(1);
boxit.lex(btex lexer etex); boxit.parse(btex parser etex);
boxit.ast(btex AST etex); circleit.out(btex code etex);
parse.w = lex.e + (25,0); ast.w = parse.e + (25,0); out.w = ast.e + (25,0);
drawboxed(lex, parse, ast, out);
drawarrow lex.e -- parse.w; drawarrow parse.e -- ast.w; drawarrow ast.e -- out.w;
label.top(btex \it tokens etex, .5[lex.e, parse.w] + (0,2));
label.top(btex \it tree etex, .5[parse.e, ast.w] + (0,2));
endfig; end.input graph;
prologues := 3;
beginfig(1);
draw begingraph(7cm, 4.5cm);
glabel.lft(btex $f(x)$ etex, OUT); glabel.bot(btex $x$ etex, OUT);
path p; p := (0,0) for i = 1 upto 60: ..(i/6, (i/6)*(i/6)*mexp(-256*(i/6))) endfor;
gdraw p withpen pencircle scaled 1 withcolor (0.8,0.1,0.1);
gdraw (0,0.5) for i = 1 upto 60: ..(i/6, sind(60i)/2+0.5) endfor withcolor (0.1,0.3,0.8);
glabel.urt(btex $x^2e^{-x}$ etex, (4,0.3));
endgraph;
endfig; end.vardefs, paths as first-class values joined with &, loops, and linear equations solved for you. 4 ms MetaPostvardef koch(expr a, b, n) =
if n = 0: a--b
else:
save c, d, e; pair c, d, e;
c = 1/3[a,b]; d = 2/3[a,b];
e = 1/2[a,b] + ((b-a) rotated -90) scaled ((sqrt 3)/6);
koch(a,c,n-1) & koch(c,e,n-1) & koch(e,d,n-1) & koch(d,b,n-1)
fi
enddef;
beginfig(1);
pair p, q, r; p = (-75,-43); q = (75,-43); r = (0, 87);
path s; s := koch(p,q,4) & koch(q,r,4) & koch(r,p,4) & cycle;
fill s withcolor (0.85,0.93,1);
draw s withpen pencircle scaled .6 withcolor (0.1,0.3,0.7);
endfig; end.Also supported: all number systems mplib builds without GMP (scaled, double, decimal); withprescript/withpostscript and special; TFM output; write … to files (returned as artifacts); runscript bridged to a JavaScript function when you opt in; the structured JSON figure model for canvas rendering, hit testing or export; and the classic .mpx route (extensions: false) for byte-parity with mpost's own file handling.
A TikZ document is not a label but a whole page, so it takes a different road: pdfTeX writes a DVI whose specials carry PGF's drawing commands, and dvisvgm — the reference converter, with its PGF special handlers, FreeType and potrace, also compiled to WebAssembly — turns each page into SVG. The library selects PGF's dvisvgm driver automatically, so nothing needs Ghostscript.
\documentclass[tikz,border=3pt]{standalone}
\usetikzlibrary{shadings}
\begin{document}
\begin{tikzpicture}
\shade[left color=red,right color=blue] (0,0) rectangle (2.4,1.2);
\shade[inner color=white,outer color=orange] (3.6,0.6) circle (0.6);
\shade[ball color=green!70!black] (5.4,0.6) circle (0.6);
\shade[top color=cyan!60,bottom color=violet!60,middle color=white] (6.6,0) rectangle (9,1.2);
\end{tikzpicture}
\end{document}\documentclass[tikz,border=3pt]{standalone}
\usetikzlibrary{patterns,decorations.pathmorphing,decorations.markings}
\begin{document}
\begin{tikzpicture}
\fill[pattern=north east lines,pattern color=gray] (0,0) rectangle (2,1.2);
\fill[pattern=crosshatch dots,pattern color=blue!60] (2.3,0) rectangle (4.3,1.2);
\begin{scope}
\clip (5.5,0.6) circle (0.7);
\fill[orange] (4.5,0) rectangle (6.5,1.3);
\draw[decorate,decoration=snake,thick] (4.5,0.6) -- (6.5,0.6);
\end{scope}
\fill[red,opacity=0.5] (7,0) rectangle (8.2,1.2);
\fill[blue,opacity=0.5] (7.6,0.2) rectangle (8.8,1.4);
\draw[thick,postaction={decorate},decoration={markings,mark=at position 0.5 with {\arrow{>}}}] (0,-0.5) -- (8.8,-0.5);
\end{tikzpicture}
\end{document}\documentclass[tikz,border=3pt]{standalone}
\usetikzlibrary{positioning,shapes.geometric,arrows.meta}
\begin{document}
\begin{tikzpicture}[node distance=1.1cm and 1.5cm, every node/.style={font=\small}]
\node[circle,draw,fill=blue!15] (a) {$a$};
\node[circle,draw,fill=blue!15,right=of a] (b) {$b$};
\node[diamond,draw,fill=red!15,below right=of a] (c) {$c$};
\node[rectangle,draw,rounded corners,right=of b] (d) {$d$};
\draw[-{Stealth}] (a) -- node[above] {1} (b);
\draw[-{Stealth}] (a) -- node[left] {2} (c);
\draw[-{Stealth},dashed] (b) -- (c);
\draw[-{Stealth},bend left] (b) to node[above] {3} (d);
\draw[-{Stealth}] (c) to[out=0,in=-90] (d);
\end{tikzpicture}
\end{document}\documentclass[border=3pt]{standalone}
\usepackage{tikz-cd,amssymb}
\begin{document}
\begin{tikzcd}[column sep=large,row sep=large]
A \arrow[r,"f"] \arrow[d,"g"'] \arrow[dr,dashed,"h"] & B \arrow[d,"g'"] \\
C \arrow[r,"f'"'] & D \arrow[ul,phantom,"\lrcorner",very near start]
\end{tikzcd}
\end{document}\documentclass[border=3pt]{standalone}
\usepackage{amsmath,pgfplots}
\pgfplotsset{compat=1.18}
\begin{document}
\begin{tikzpicture}
\begin{axis}[width=8cm,height=5cm,xlabel={$x$},ylabel={$f(x)$},legend pos=outer north east,grid=major,axis lines=left]
\addplot[blue,thick,domain=0:4,samples=60] {x^2*exp(-x)};
\addplot[red,dashed,domain=0:4,samples=60] {0.5*sin(deg(3*x))+0.5};
\addplot[only marks,mark=*,mark size=1.5pt] coordinates {(0.5,0.3) (1.5,0.5) (2.5,0.6) (3.5,0.2)};
\addplot[fill=blue!10,draw=none,domain=0:4,samples=60] {x^2*exp(-x)} \closedcycle;
\legend{$x^2e^{-x}$,$\tfrac12(1+\sin 3x)$,data}
\end{axis}
\end{tikzpicture}
\end{document}fonts: 'woff2' embeds web fonts instead if you prefer selectable text. 142 ms TeX, 72 ms dvisvgm (snapshot)\documentclass[tikz,border=3pt]{standalone}
\usepackage{lmodern}
\usepackage[T1]{fontenc}
\usepackage{amsmath,amssymb}
\begin{document}
\begin{tikzpicture}
\node[draw,align=left,text width=6.2cm,inner sep=8pt,rounded corners] {Latin Modern in \textbf{T1} encoding: fi fl ffi \& \textit{italic} \texttt{mono} \textsf{sans} \textsc{Small Caps}.\\[4pt] $\displaystyle\sum_{n\ge1}\frac{1}{n^2}=\frac{\pi^2}{6}, \qquad \mathbb{R}\setminus\mathbb{Q}$};
\end{tikzpicture}
\end{document}\documentclass[tikz,border=3pt]{standalone}
\usetikzlibrary{trees,decorations.pathreplacing}
\begin{document}
\begin{tikzpicture}[level distance=11mm, sibling distance=18mm, every node/.style={draw,rounded corners,fill=blue!8,font=\footnotesize}, edge from parent/.style={draw,-latex}]
\node {mp-tikz-wasm}
child { node {mplib.wasm} child { node {SVG} } child { node {EPS} } }
child { node {tex.wasm} child { node {DVI} } }
child { node {dvisvgm.wasm} child { node {TikZ} } };
\draw[decorate,decoration={brace,amplitude=4pt,mirror},thick] (-3.2,-2.6) -- (3.2,-2.6) node[midway,below=4pt,draw=none,fill=none] {all in the browser};
\end{tikzpicture}
\end{document}engine: 'plain' runs plain TeX with e-TeX (TeX Live's etex), which is what PGF needs. 86 ms TeX, 55 ms dvisvgm\input tikz
\nopagenumbers
\tikzpicture
\draw[thick,fill=blue!10] (0,0) circle (1);
\foreach \a in {0,45,...,315} \draw (0,0) -- (\a:1) node[circle,fill=red,inner sep=1.5pt] {};
\node at (0,-1.4) {plain \TeX\ with TikZ};
\endtikzpicture
\byeThe TikZ road is a real LaTeX road: the engine typesets the entire document, so paragraphs, fonts, hyphenation, amsmath and TeX's low-level primitives all come along. These two examples use \parshape, the primitive that gives every line of a paragraph its own indent and width, with inline TikZ pictures and displayed mathematics flowing through the shape.
\parshape primitive takes one (indent, width) pair per line; a \loop computes twenty of them with pgfmath as chords of the circle drawn behind the text. Line breaking, hyphenation and the display equation all follow the shape. 167 ms TeX, 96 ms dvisvgm (snapshot)\documentclass[border=8pt]{standalone}
\usepackage{lmodern}
\usepackage[T1]{fontenc}
\usepackage{amsmath,amssymb}
\usepackage{tikz}
\begin{document}
% A paragraph set inside a circle. \parshape takes one (indent, width) pair
% per line; pgfmath computes each width as a chord of the circle.
\def\R{130.9} % radius in pt (4.6cm)
\def\B{12} % \baselineskip in pt
\newcount\n \n=20 % lines: the text block is n\B = 240pt tall
\newcount\i
\def\shape{}
\i=1
\loop
\pgfmathsetmacro\yy{(\n*\B/2-(\i-0.5)*\B)/10} % line centre, relative to circle centre, in 10pt units
\pgfmathsetmacro\hw{10*sqrt(\R/10*\R/10-\yy*\yy)-6} % half chord, 6pt inside the rim (pgfmath overflows above 16383, hence the /10)
\pgfmathsetmacro\ind{\R-\hw}
\pgfmathsetmacro\lw{2*\hw}
\edef\shape{\shape\ind pt \lw pt }
\advance\i 1
\ifnum\i<\numexpr\n+1\relax\repeat
\hbox{%
\rlap{\tikz[baseline=0pt]{\fill[blue!6] (\R pt,-111.6pt) circle (\R pt); \draw[blue!50!black,line width=.5pt] (\R pt,-111.6pt) circle (\R pt);}}%
\vtop{\hsize=2\dimexpr\R pt\relax \baselineskip=\B pt \parindent=0pt \tolerance=3000 \emergencystretch=1.5em \hyphenpenalty=10
\parshape \n \shape
The ratio of a circle's circumference to its diameter,
$\pi\approx3.14159\ldots$, is the same for every circle
\tikz[baseline=-0.6ex]{\draw (0,0) circle (0.8ex); \draw (-0.8ex,0)--(0.8ex,0);}.
Archimedes squeezed it between inscribed and circumscribed polygons
\tikz[baseline=-0.6ex]{\draw (0,0) circle (0.8ex); \draw (0:0.8ex) \foreach \a in {60,120,...,300} {-- (\a:0.8ex)} -- cycle;},
proving $3\tfrac{10}{71}<\pi<3\tfrac17$; two thousand years later Leibniz found
\[ \frac{\pi}{4}=\sum_{k=0}^{\infty}\frac{(-1)^k}{2k+1}=1-\frac13+\frac15-\frac17+\cdots, \]
and Euler tied $\pi$ to the primes through $\sum_{n\ge1}n^{-2}=\pi^2/6$
and to $e$ through $e^{i\pi}+1=0$; Wallis wrote $\frac{\pi}{2}=\prod_{n\ge1}\frac{4n^2}{4n^2-1}$.
The Gaussian integral
$\int_{-\infty}^{\infty}e^{-x^2}\,dx=\sqrt{\pi}$
\tikz[baseline=-0.3ex]{\draw[thick] plot[domain=-2.2:2.2,samples=30] ({\x*0.42em},{exp(-\x*\x)*1.4ex});}
carries it into probability and statistics, and it hides in Stirling's
formula $n!\sim\sqrt{2\pi n}\,(n/e)^n$. This paragraph is set by
\TeX's \texttt{\char`\\parshape} primitive: twenty line widths
computed by pgfmath as chords of the circle, with the
display and the inline TikZ pictures flowing
through the same shape as the prose.\par}}
\end{document}\documentclass[border=8pt]{standalone}
\usepackage{lmodern}
\usepackage[T1]{fontenc}
\usepackage{amsmath,amssymb}
\usepackage{tikz}
\begin{document}
% Text flowing around a figure, by hand: \parshape narrows the first k
% lines, and the figure hangs from the first baseline in a zero-width box.
\newdimen\W \W=11.5cm % the measure
\newdimen\F \F=4.4cm % width reserved for the figure
\newcount\k \k=9 % lines beside it
\def\shape{}\newcount\i \i=1
\loop \edef\shape{\shape 0pt \the\dimexpr\W-\F\relax\space}\advance\i 1 \ifnum\i<\numexpr\k+1\relax\repeat
\edef\shape{\shape 0pt \the\W}
\vtop{\hsize=\W \parindent=0pt \tolerance=2000 \emergencystretch=1em
\parshape \numexpr\k+1\relax \shape
% \vtop{\kern0pt ...} puts the whole picture below the baseline; \smash hides
% that depth from the line spacing; \raise lines its top up with the first line.
\rlap{\hskip\dimexpr\W-\F+3mm\relax\smash{\raise\ht\strutbox\vtop{\kern0pt\hbox{%
\begin{tikzpicture}[x=0.55cm,y=0.95cm,font=\scriptsize]
\draw[->] (-0.3,0) -- (6.9,0) node[right] {$x$};
\draw[->] (0,-1.45) -- (0,1.55) node[left] {$S_N(x)$};
\draw[gray!70,line width=1.2pt] (0,1) -- (3.1416,1) -- (3.1416,-1) -- (6.2832,-1);
\draw[blue!70!black,domain=0:6.2832,samples=90] plot (\x,{4/pi*sin(\x r)});
\draw[red!80!black,domain=0:6.2832,samples=200] plot (\x,{4/pi*(sin(\x r)+sin(3*\x r)/3+sin(5*\x r)/5)});
\draw[violet!80!black,thick,domain=0:6.2832,samples=500] plot (\x,{4/pi*(sin(\x r)+sin(3*\x r)/3+sin(5*\x r)/5+sin(7*\x r)/7+sin(9*\x r)/9+sin(11*\x r)/11+sin(13*\x r)/13+sin(15*\x r)/15+sin(17*\x r)/17+sin(19*\x r)/19+sin(21*\x r)/21)});
\foreach \t/\l in {3.1416/$\pi$,6.2832/$2\pi$} \draw (\t,2pt) -- (\t,-2pt) node[below=1pt,fill=white,inner sep=1pt] {\l};
\node[anchor=north west,align=left,inner sep=1pt] at (3.4,1.55) {\textcolor{blue!70!black}{$N=1$}\\ \textcolor{red!80!black}{$N=5$}\\ \textcolor{violet!80!black}{$N=21$}};
\end{tikzpicture}}}}}%
Any reasonable periodic function is a sum of sines and cosines. For the square wave
\tikz[baseline=-0.5ex]\draw (0,-0.7ex)--(0,0.7ex)--(0.9em,0.7ex)--(0.9em,-0.7ex)--(1.8em,-0.7ex);
$f(x)=\operatorname{sgn}(\sin x)$ the cosine coefficients
$a_n=\frac1\pi\int_{-\pi}^{\pi}f(x)\cos nx\,dx$ all vanish, while
$b_n=\frac{2}{\pi n}\,(1-\cos n\pi)$, so only the odd harmonics survive:
\[ f(x)=\frac{4}{\pi}\sum_{k=0}^{\infty}\frac{\sin\bigl((2k+1)x\bigr)}{2k+1}. \]
The partial sums $S_N$ on the right overshoot each jump by about nine per cent
however large $N$ becomes: the Gibbs phenomenon,
$\lim_{N\to\infty}S_N\!\left(\tfrac{\pi}{2N}\right)=\tfrac{2}{\pi}\operatorname{Si}(\pi)\approx1.179$.
Parseval's identity $\frac1\pi\int_{-\pi}^{\pi}f^2=\sum_n b_n^2$ then yields
$\sum_{k\ge0}(2k+1)^{-2}=\pi^2/8$, from which Euler's $\zeta(2)=\pi^2/6$ follows in one line.
The gap the text flows around is a \texttt{\char`\\parshape} too: nine narrow lines beside the
figure, then the full measure, with the figure itself hung from the first baseline in a
zero-width \texttt{\char`\\rlap} box.\par}
\end{document}A second engine, luatex.wasm, runs the same DVI pipeline with LuaTeX 1.21 in DVI mode (TeX Live's dvilualatex and dviluatex). It exists for the parts of TikZ that are written in Lua, above all the graphdrawing library with its layered, force-directed, tree, circular and phylogenetic layouts, plus \directlua, luacode and pgfplots' Lua backends. engine: 'auto' and the drop-in tags choose it whenever a document uses any of these; nothing else changes, and the 4.4 MB module and its 6.5 MB format are only fetched then. Text is set in the Type 1 fonts: no OpenType font loader is bundled, so fontspec is not available, and LaTeX's own fallback message about luaotfload is filtered from the diagnostics.
graphdrawing library computes layouts in Lua, so it needs LuaTeX. engine: 'auto' (and the tags) switch to luatex.wasm when a document uses it; layered, spring, tree, circular and phylogenetic layouts all run. This is the library tikzjax could never offer. 557 ms LuaTeX, 50 ms dvisvgm\documentclass[tikz,border=3pt]{standalone}
\usetikzlibrary{graphs,graphs.standard,graphdrawing,quotes}
\usegdlibrary{trees,layered,force,circular}
\begin{document}
\begin{tikzpicture}[>=stealth, nodes={draw,circle,fill=blue!10,font=\small}]
\graph[layered layout, sibling distance=8mm, level distance=8mm] {
a -> {b -> {d, e}, c -> {f -> g, h}}; e -> g;
};
\begin{scope}[xshift=4.2cm, nodes={fill=red!10}]
\graph[spring layout, node distance=9mm] { 1 -- {2,3,4}; 2 -- 3 -- 4 -- 5 -- 2; 5 -- 6 -- 7 -- 5 };
\end{scope}
\begin{scope}[xshift=8.4cm, nodes={fill=orange!20}]
\graph[simple necklace layout, node distance=9mm] { subgraph C_n [n=7] };
\end{scope}
\end{tikzpicture}
\end{document}How the files reach the engine: at start-up the loader fetches one small manifest per bundle (a list of paths, sizes and hashes, ten in all), builds a virtual filesystem inside the Web Worker with a lazy placeholder for every one of the 3,600 files, and writes kpathsea's ls-R index from the manifests, so TeX's file lookups resolve without touching the network. When TeX actually opens a file, its placeholder fetches that one file with a synchronous request, which is why the worker is the default: a page without a worker has to prefetch everything up front. Formats and even the LuaTeX engine arrive the same way, on first use, and the browser's HTTP cache keeps every fetched file across runs and visits. Adding a package that is not bundled means rebuilding the bundles from a local TeX Live (two scripts, a couple of minutes); there is no installation from CTAN at run time. A single document can also be given extra files, such as a .sty from its own project, through the API's files option without rebuilding anything.
The bundles contain LaTeX 2025 with expl3, PGF/TikZ with every library, pgfplots, tikz-cd, amsmath, amssymb, amscls, mathtools, tools, graphics, xcolor, geometry, booktabs, standalone, hyperref, url, listings, fp, imakeidx, todonotes, the Latin Modern fonts in T1/TS1 encodings, Computer Modern, the AMS fonts, Euler, and the 35 standard PostScript fonts (Times, Helvetica, Courier, Palatino and the rest, as URW Type 1). Adding a package is one line in scripts/build-texmf.sh; the loader fetches files per file, on demand, so bundle size costs nothing until a document uses a file.
| Module | Size | What it is |
|---|---|---|
| mplib.wasm | 1.2 MB | MetaPost 2.11: interpreter, PostScript and SVG backends, Type 1 font machinery, TFM reader, scaled/double/decimal arithmetic, mpto and dvitomp |
| tex.wasm | 1.1 MB | pdfTeX 1.40.27 in DVI mode (= tex, etex, latex) with kpathsea, zlib, libpng |
| dvisvgm.wasm | 2.6 MB | dvisvgm 3.4.3 with FreeType, potrace, clipper, woff2/brotli; PostScript specials (Ghostscript) compiled out |
| luatex.wasm | 4.2 MB | LuaTeX 1.21 with Lua 5.3, pplib, zziplib, the fontforge-derived font loader, kpathsea and our patched mplib; loaded on demand for graphdrawing and Lua documents |
Native MetaPost shells out to TeX from the middle of its scanner, which WebAssembly cannot do. The TeX step is lifted out of the run instead:
input-ed file are scanned for btex/verbatimtex blocks with a TypeScript port of mpto's lexer, tested byte-for-byte against the C original.dvitomp turns the DVI into .mpx picture expressions, split per block and cached by a hash of engine, format, preceding verbatimtex chain and body.extensions=1; its make_text callback answers each btex synchronously from the cache.scantokens) misses, comes back as nullpicture, and the run repeats once it has been typeset.Forty labels cost one TeX run; editing one label costs one TeX run with one page; an unchanged document costs none.
plain.fmt, etex.fmt and latex.fmt are built by the wasm engine itself at bundle-build time, so they match it byte for byte. tikz.fmt is the optional pre-warmed snapshot: latex.fmt with PGF, its definitional libraries, pgfplots and tikz-cd already loaded, the equivalent of tikzjax's memory image. It saves 50–160 ms per TikZ document and produces byte-identical pages (the test suite checks), but as a 5.8 MB file that does not compress it costs more to download than the 2.5 MB it replaces, so it is the default in Node and opt-in in browsers.
| What a page downloads | raw | gzipped |
|---|---|---|
| the three engines and the JavaScript, once | 5.2 MB | 2.2 MB |
| a MetaPost figure, geometry only | 0.07 MB | 0.01 MB |
a MetaPost figure with label() text | 0.16 MB | 0.05 MB |
a MetaPost figure with plain-TeX btex | 0.37 MB | 0.25 MB |
a MetaPost figure with LaTeX btex | 2.4 MB | 2.2 MB |
a TikZ figure (latex.fmt) | 3.8 MB | 2.5 MB |
| a TikZ figure with the snapshot | 5.9 MB | 5.5 MB |
| the first LuaTeX figure (luatex.wasm + dvilualatex.fmt) | + 10.4 MB | + 4.9 MB |
Everything is fetched once and cached by the browser; rendered figures are cached in IndexedDB as well. The whole bundle tree is 47 MB on the server, and no page downloads all of it.
latex, and all 1181 SVG pages match native dvisvgm's. (The one normalisation resolves dvisvgm's own run-to-run choice of which size of a font becomes the base glyph, which makes two native runs of the same DVI differ as well.) node scripts/stress-pgfmanual.mjs reproduces it in about fifteen minutes.mpost from TeX Live 2025 run with the same frozen date.\directlua under LuaTeX — byte-identical to latex / dvilualatex + dvisvgm, with and without the snapshot.mtrap half of MetaPost's official trap test produces the same figures as native MetaPost 2.11; tex.wasm reproduces the oracle's DVI byte for byte; the .mpx round trip through our mpto and dvitomp matches the committed sample.mpto on a 24-case corpus and 60 fuzz inputs, and a native contract harness of 46 checks that would catch a TeX Live bump changing mplib's API.patches/): a process-global flag that crashed the second MetaPost instance in a process; two defects in the extensions=1 scanner (an etex at the end of a line was never matched, and TeX Live's own texnum.mp writes btex$-$etex); stroke-miterlimit for filldraw read from uninitialised memory; a printf format with a bare % in mpto's prologue; font subset tags that differed between 32- and 64-bit builds; and two memory leaks in the instance lifecycle (319 KB per MetaPost instance, found when an animation ran one instance per frame; now zero) that only an embedder creating many instances could see.| mp-tikz-wasm | tikzjax | |
|---|---|---|
| Engine | pdfTeX in DVI mode with a real latex.fmt built by the engine; LuaTeX for graph drawing and Lua | Knuth TeX with a plain-TeX memory snapshot; \documentclass and \usepackage shimmed or ignored |
| Documents | any LaTeX document: standalone, article, multi-page, any bundled package | a TikZ picture body and curated preloaded packages |
| DVI to SVG | dvisvgm itself with PGF's dvisvgm driver: gradients, patterns, opacity, clipping, decorations, markers, pgfplots | a JavaScript re-implementation of the special language; no shadings or patterns |
| Text | glyph outlines in the SVG (self-contained), or woff2 on request | <text> runs plus a CSS font sheet |
| Fonts | Computer Modern, AMS, Euler, Latin Modern in T1/TS1, the 35 standard PostScript fonts | Computer Modern |
| MetaPost | yes — the same script also renders <script type="text/metapost"> | no |
| Loading | per-file lazy fetch through real kpathsea; optional pre-warmed format | one snapshot plus a file archive |
| Verification | byte-identical to TeX Live on the corpora | none published |
| Errors | structured diagnostics with line numbers, full logs | the TeX log |
| Isolation | Web Worker with a watchdog; deterministic output | page thread |
fontspec, no unicode-math, no system fonts. Text uses the same Type 1 fonts as the pdfTeX route.binary/interval number systems (they need cairo and GMP/MPFR, which are not built).runScript, makeText and onFindFile callbacks; providing one selects in-process mode.mplib library does not write figure files during a run, so a document cannot readfrom its own output; and it exits on a few hard limits (such as an input-stack overflow, which native mpost hits too) rather than raising an error — reported as a fatal diagnostic.The build is reproducible from the pinned TeX Live 2025 source tarball (vendor/SOURCES.lock records its SHA-256). It never modifies the vendored tree: patches are applied to a copy, ctangle is built from the vendored CWEB, and every generated file lands under build/ or dist/.
| Step | Produces |
|---|---|
| make tangle / make native / make contract | the tangled C, build/native/libmplib.a, and the native contract harness |
| make wasm | dist/mplib.mjs + mplib.wasm (-O3 -flto); make wasm-debug for an ASSERTIONS/SAFE_HEAP variant |
| scripts/native-texlive.sh | the web2c-generated C for pdfTeX and the native config headers (needs autotools' output, once) |
| scripts/build-tex-wasm.sh | dist/tex.mjs + tex.wasm from that C plus kpathsea, with Emscripten's zlib and libpng ports |
| scripts/native-luatex.sh, scripts/build-luatex-wasm.sh | dist/luatex.mjs + luatex.wasm: the native LuaTeX build's compile commands are recorded and replayed with emcc (Lua 5.3, pplib, zziplib compiled from the vendored sources; the C FFI stubbed) |
| scripts/native-dvisvgm.sh, scripts/build-dvisvgm-wasm.sh | dist/dvisvgm.mjs + dvisvgm.wasm (FreeType and zlib ports, -Oz) |
| scripts/build-texmf.sh | build/texmf: the flattened texmf tree copied from the local TeX Live, plus tikz.ini |
| node scripts/make-formats.mjs | plain.fmt, etex.fmt, latex.fmt, tikz.fmt, built by tex.wasm; dviluatex.fmt, dvilualatex.fmt by luatex.wasm |
| node scripts/build-bundles.mjs | dist/bundles/*: manifests and files per bundle |
| npm run build:ts | dist/*.js: the library, the worker, the CLI, auto.js |
| npm run package | release/mp-tikz-wasm-0.1.0.tar.gz and .zip: the prebuilt distribution for a GitHub release |
| npm run build:guide, build:standalone | this page, and the single-file demo with everything inlined |
The design documents in docs/ record every decision; docs/14-implementation-notes.md records what was learned building it, including the upstream defects.
The licence texts are in the repository (LICENSE, NOTICE.md, licenses/). MetaPost itself is public domain. mplib.wasm also contains avl.c (LGPL-3.0-or-later) and decNumber (ICU), so it is distributed under the LGPL-3.0-or-later with the sources, the patches and a reproducible build in the repository. pdfTeX and kpathsea are GPL, so tex.wasm is GPL; LuaTeX is GPL-2.0-or-later (Lua, pplib and zziplib carry MIT-style licences), so luatex.wasm is GPL; dvisvgm is GPL-3.0-or-later, so dvisvgm.wasm is too (it embeds FreeType, potrace, clipper, woff2, brotli and the URW base-14 fonts). The fonts and macro packages in the bundles keep their own free licences. This project's own code is LGPL-3.0-or-later. See LICENSE.md.