mp-tikz-wasm · version 0.1.0 · MetaPost 2.11, pdfTeX 1.40, LuaTeX 1.21, dvisvgm 3.4.3

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.

4.8 MBof WebAssembly (2.1 MB gzipped) for MetaPost, pdfTeX and dvisvgm; LuaTeX adds 4.2 MB on demand
1181 / 1181pages of the PGF/TikZ manual identical to TeX Live 2025
~15 msfor a MetaPost figure; ~100 ms for a TikZ figure
0 serverseverything runs in the page or in Node

Get it

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.

1. Prebuilt release

  1. Download mp-tikz-wasm-0.1.0.tar.gz (or .zip) from the releases page and unpack it.
  2. Host the 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/.
  3. Add one line to your page — see Drop-in tags — or import { MetaPost } from '/path/to/dist/index.js'.

2. From source

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.

Drop-in tags

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>

The engines run in a Web Worker, so the page never blocks; there is a 20 s watchdog per run.

Library API

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' });
OptionWhereMeaning
bundles, bundleBaseUrlcreatewhich texmf bundles to load and from where (default: all, next to the script)
numberSystemcreatescaled (mpost's default), double, decimal
texcreate / runlabel engine for btex: auto (LaTeX when the preamble says so), plain, etex, latex, none
deterministiccreatefixed random seed and frozen date, so two runs are byte-identical (default on)
runScript, makeText, onFindFilecreateJavaScript hooks for runscript, for overriding label typesetting, and for last-chance file lookup (in-process mode)
worker, timeoutMs, snapshot, prefetchcreateWorker 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, jobNamerunoutputs; prologues (3 for SVG outlines); -s name=value internals; extra files placed next to the job
engine, fonts, pages, bbox, dvisvgmArgs, snapshot, fileslatexlatex | 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.

Node

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.

Command line

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.

What it does — MetaPost

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.

Paths, tension and curl — MetaPost's path syntax: .. for smooth curves solved by Hobby's algorithm, -- for straight lines, tension and curl control, explicit controls, cycles. 14 ms MetaPost
source (MetaPost)
beginfig(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.
Pens, joins, caps and dashes — Elliptical and polygonal pens are real pens: a stroke is the envelope of the pen dragged along the path, as in the original program. 2 ms MetaPost
source (MetaPost)
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.
Fills, colour models and buildcycle — Grey, RGB and CMYK colours, unfill, filldraw, and buildcycle to fill the intersection of two shapes. 2 ms MetaPost
source (MetaPost)
beginfig(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.
Clipping and bounds — Pictures are values: build one, clip it to a path, take its bounding box, and draw the result. 2 ms MetaPost
source (MetaPost)
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.
Text without TeX: Type 1 outlines — A plain 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 MetaPost
source (MetaPost)
prologues := 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.
Labels typeset by plain TeXbtex … 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 TeX
source (MetaPost)
prologues := 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.
Labels typeset by LaTeX — A verbatimtex preamble with \documentclass switches the label engine to LaTeX automatically. amsmath, amssymb, tabular, any package in the bundles. 2 ms MetaPost, 140 ms TeX
source (MetaPost)
verbatimtex
\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.
boxes.mp — The standard macro packages ship in the core bundle: boxes, rboxes, graph, format, sarith, string, metaobj, featpost, metauml and more. 3 ms MetaPost, 50 ms TeX
source (MetaPost)
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.
graph.mp — John Hobby's graph package with TeX-typeset tick labels; MetaPost chooses the ticks. 5 ms MetaPost, 47 ms TeX
source (MetaPost)
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.
A real programming language — Recursive vardefs, paths as first-class values joined with &, loops, and linear equations solved for you. 4 ms MetaPost
source (MetaPost)
vardef 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.

What it does — TikZ and LaTeX

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.

Shadings — Axis, radial and ball shadings become SVG gradients, through PGF's own dvisvgm driver. This is where a JavaScript DVI reader gives up. 84 ms TeX, 30 ms dvisvgm (snapshot)
source (LaTeX)
\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}
Patterns, opacity, clipping, decorations — Fill patterns become SVG patterns; opacity, clipping scopes and path decorations all survive intact. 120 ms TeX, 15 ms dvisvgm (snapshot)
source (LaTeX)
\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}
Nodes, shapes, arrows — positioning, shapes.geometric and arrows.meta; bent and curved edges; every library that ships with PGF in TeX Live 2025 is available. 134 ms TeX, 23 ms dvisvgm (snapshot)
source (LaTeX)
\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}
Commutative diagrams (tikz-cd) 145 ms TeX, 20 ms dvisvgm (snapshot)
source (LaTeX)
\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}
pgfplots — The real pgfplots package: axes, grids, legends, sampled functions, data coordinates, filled areas. 250 ms TeX, 26 ms dvisvgm (snapshot)
source (LaTeX)
\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}
Latin Modern, T1 encoding, amsmath — Text is glyph outlines in the SVG, so it renders identically everywhere; fonts: 'woff2' embeds web fonts instead if you prefer selectable text. 142 ms TeX, 72 ms dvisvgm (snapshot)
source (LaTeX)
\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}
Trees, matrices, decorations 148 ms TeX, 39 ms dvisvgm (snapshot)
source (LaTeX)
\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}
Plain TeX tooengine: 'plain' runs plain TeX with e-TeX (TeX Live's etex), which is what PGF needs. 86 ms TeX, 55 ms dvisvgm
source (plain TeX)
\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
\bye

Whole documents, not just pictures

The 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.

A paragraph shaped by \parshape — This is a whole LaTeX document, not just a picture: prose, inline and displayed amsmath, and three inline TikZ pictures, all set inside a circle. TeX's \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)
source (LaTeX)
\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}
Text flowing around a figure — The same primitive with nine narrow lines and then the full measure, the figure hung from the first baseline in a zero-width box: what wrapfig does, by hand. The figure is a TikZ plot of Fourier partial sums sampled by pgfmath; the paragraph carries integrals, limits and a display. 2179 ms TeX, 119 ms dvisvgm (snapshot)
source (LaTeX)
\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}

LuaTeX

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.

Graph drawing (LuaTeX) — The 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
source (LaTeX)
\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.

How it works

Three engines

ModuleSizeWhat it is
mplib.wasm1.2 MBMetaPost 2.11: interpreter, PostScript and SVG backends, Type 1 font machinery, TFM reader, scaled/double/decimal arithmetic, mpto and dvitomp
tex.wasm1.1 MBpdfTeX 1.40.27 in DVI mode (= tex, etex, latex) with kpathsea, zlib, libpng
dvisvgm.wasm2.6 MBdvisvgm 3.4.3 with FreeType, potrace, clipper, woff2/brotli; PostScript specials (Ghostscript) compiled out
luatex.wasm4.2 MBLuaTeX 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

Labels without a subprocess

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:

  1. Scan. The source and every 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.
  2. Typeset once. Every block not already cached goes into one batched pdfTeX run, one page per block.
  3. Convert. MetaPost's own dvitomp turns the DVI into .mpx picture expressions, split per block and cached by a hash of engine, format, preceding verbatimtex chain and body.
  4. Run. MetaPost runs with extensions=1; its make_text callback answers each btex synchronously from the cache.
  5. Fixpoint. A label the scan could not see (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.

Formats, bundles and the snapshot

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 downloadsrawgzipped
the three engines and the JavaScript, once5.2 MB2.2 MB
a MetaPost figure, geometry only0.07 MB0.01 MB
a MetaPost figure with label() text0.16 MB0.05 MB
a MetaPost figure with plain-TeX btex0.37 MB0.25 MB
a MetaPost figure with LaTeX btex2.4 MB2.2 MB
a TikZ figure (latex.fmt)3.8 MB2.5 MB
a TikZ figure with the snapshot5.9 MB5.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.

Fidelity and tests

Compared with tikzjax

mp-tikz-wasmtikzjax
EnginepdfTeX in DVI mode with a real latex.fmt built by the engine; LuaTeX for graph drawing and LuaKnuth TeX with a plain-TeX memory snapshot; \documentclass and \usepackage shimmed or ignored
Documentsany LaTeX document: standalone, article, multi-page, any bundled packagea TikZ picture body and curated preloaded packages
DVI to SVGdvisvgm itself with PGF's dvisvgm driver: gradients, patterns, opacity, clipping, decorations, markers, pgfplotsa JavaScript re-implementation of the special language; no shadings or patterns
Textglyph outlines in the SVG (self-contained), or woff2 on request<text> runs plus a CSS font sheet
FontsComputer Modern, AMS, Euler, Latin Modern in T1/TS1, the 35 standard PostScript fontsComputer Modern
MetaPostyes — the same script also renders <script type="text/metapost">no
Loadingper-file lazy fetch through real kpathsea; optional pre-warmed formatone snapshot plus a file archive
Verificationbyte-identical to TeX Live on the corporanone published
Errorsstructured diagnostics with line numbers, full logsthe TeX log
IsolationWeb Worker with a watchdog; deterministic outputpage thread

Limits

Building

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/.

StepProduces
make tangle / make native / make contractthe tangled C, build/native/libmplib.a, and the native contract harness
make wasmdist/mplib.mjs + mplib.wasm (-O3 -flto); make wasm-debug for an ASSERTIONS/SAFE_HEAP variant
scripts/native-texlive.shthe web2c-generated C for pdfTeX and the native config headers (needs autotools' output, once)
scripts/build-tex-wasm.shdist/tex.mjs + tex.wasm from that C plus kpathsea, with Emscripten's zlib and libpng ports
scripts/native-luatex.sh, scripts/build-luatex-wasm.shdist/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.shdist/dvisvgm.mjs + dvisvgm.wasm (FreeType and zlib ports, -Oz)
scripts/build-texmf.shbuild/texmf: the flattened texmf tree copied from the local TeX Live, plus tikz.ini
node scripts/make-formats.mjsplain.fmt, etex.fmt, latex.fmt, tikz.fmt, built by tex.wasm; dviluatex.fmt, dvilualatex.fmt by luatex.wasm
node scripts/build-bundles.mjsdist/bundles/*: manifests and files per bundle
npm run build:tsdist/*.js: the library, the worker, the CLI, auto.js
npm run packagerelease/mp-tikz-wasm-0.1.0.tar.gz and .zip: the prebuilt distribution for a GitHub release
npm run build:guide, build:standalonethis 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.

Licences

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.