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.5.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. Path commands are put inside a tikzpicture; a body that is already one (tikzpicture, \tikz, axis) is not. A body that starts with a package's own picture — \begin{tikzcd}, \begin{circuitikz}, \chemfig, \schemestart — is not nested either, and gets \documentclass[border=…]{standalone} with TikZ loaded by hand, which crops whatever the body is (name the package in packages). 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-bundles="+opentype" (add the opt-in OpenType bundle to the defaults; a list without + replaces them), data-worker="off" (run the engines on the page's own thread instead of a Web Worker), 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), data-figures="figures/" (where saved figures live; next bullet but one), data-log="debug" (what reaches the browser console: silent, error, warn (default), info, debug — the engines' output as it runs — or trace; mpTikzWasm.setLogLevel() changes it later).<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. They are also live: set el.source (or el.textContent), or change an attribute that affects the output, and the diagram is typeset again. Edits are debounced (data-debounce, in ms, default 200), the old figure stays up dimmed until the new one is ready, and a result overtaken by a later edit is dropped, so an editor can set source on every keystroke. The two <script> forms are picked up by a MutationObserver instead, because script elements cannot be custom elements, and render once.data-replace on a custom element replaces it, once rendered, by the <svg> itself; see Ids, classes and slides below.mp-tikz-wasm:rendered event; window.mpTikzWasm.render() renders programmatically.npx mpost-wasm --prerender page.html typesets every diagram element in Node and writes figures/figure-HASH.svg next to the page (HASH is six characters of the hash of the element's source and attributes); or open the page and call mpTikzWasm.saveFigures() in the console, which writes the same files into a folder you pick (Chrome, Edge) or downloads them as a zip. With data-figures="figures/" on the loader, each element loads its file if it exists and the engines start only for the figures that are missing — a page whose figures are all saved loads no engine at all. The name is the content: a changed diagram gets a new file, so --prerender keeps the files that exist (--force re-renders). mpTikzWasm.figures() lists them. The tags page is served this way.< for <; script tags are raw.The bundled TikZ library svg.attributes puts attributes on the SVG a scope, path or node produces. Load it with \usetikzlibrary{svg.attributes}, or data-libraries="svg.attributes" on a tag; it is in the default bundles.
svg class=<classes> — added to the class list; repeatable.svg id=<id> — the element's id (the last one wins).svg attributes={name="value", …} — any other attributes, separated by commas or spaces; a comma inside a quoted value stays, and unbalanced quotes are a TeX error.The attributes land on the <g> PGF itself opens for that scope, path or node, so nothing has to be balanced by hand, and a nested scope does not inherit them. Under a driver that is not SVG (pdfTeX, dvips) the keys do nothing, so the same source still builds with pdflatex. A clip path gets no group of its own, so its attributes are dropped. Classes arrive as written; ids are prefixed per figure by the tags (id="box" becomes mpwHASH-box, so two figures cannot collide), so select by class, or by [id$="-box"].
data-replace on a <tikz-diagram> or <metapost-diagram> replaces the element, once it has rendered, by the <svg> itself; the element's id, class and style move to the SVG root. The figure is then ordinary SVG in the page, and static: it no longer re-typesets. A failed render keeps the element and its diagnostics. The mp-tikz-wasm:rendered event is dispatched from the SVG after the swap, with replaced: true.
Together they give reveal.js fragments inside a diagram:
<section>
<tikz-diagram data-replace data-libraries="svg.attributes" class="r-stretch">
\draw[thick] (0,0) rectangle (4,3);
\begin{scope}[svg class=fragment]\fill[red!60] (1,1) circle (0.5);\end{scope}
\begin{scope}[svg class={fragment fade-up}]\fill[blue!60] (3,1) circle (0.5);\end{scope}
\node[svg class=fragment, svg attributes={data-fragment-index="0"}] at (2,2.5) {first};
</tikz-diagram>
</section>
Checked with reveal.js 5: the three parts step in order, data-fragment-index honoured, with no Reveal.sync() — reveal looks fragments up as it navigates, and its stylesheet hides a .fragment as soon as the SVG arrives.
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
(await mp.latex(doc, { output: 'pdf' })).pdf // or a PDF (Uint8Array), from the engine's own PDF back end
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: DEFAULT_BUNDLES, next to the script; add 'opentype' and 'otf-fonts' for fontspec and unicode-math, 'ghostscript' for PostScript: PSTricks, EPS, graphicx's rotation and scaling) |
| 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 |
| logLevel, logger | create | what each run writes to the console: silent, error, warn (default: errors and warnings, a MetaPost error with its help text), info (one line per run and per engine pass, with timings), debug (the engines' own terminal output, live) or trace (every file looked up or fetched); mp.logLevel changes it at any time; logger receives the records instead of the console, mp.on('record') as well |
| 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 |
| output | latex | svg (default) or pdf: pdfTeX's or LuaTeX's own PDF back end, returned as result.pdf (a Uint8Array); fonts embedded, PNG and JPEG images included; byte-identical to TeX Live's latex -output-format=pdf apart from the version string. One pass per call: hand artifacts back as files for cross-references |
Also exported: MetaPostPool for batch work across several workers, postProcessSvg (numeric compaction, id namespacing), scanTexBlocks, splitMpx, parseMetaPostLog.
Untrusted sources. MetaPost's special and dvisvgm's raw specials put arbitrary markup into the SVG, so a diagram written by someone else can carry <script> or event handlers. The tags insert SVG as is, which is right for a page's own diagrams. For anyone else's, sanitise with DOMPurify (USE_PROFILES: { svg: true, svgFilters: true }, ADD_TAGS: ['use'], then drop any <use> whose href is not #…); the README has the function. The old sanitizeSvg export is deprecated: it strips shadings, patterns and web fonts.
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)
npx mpost-wasm -vv figure.mp # the engines' output on stderr as it runs (-v timings, -vvv every file, -q silence)
npx mpost-wasm --prerender page.html # figures/figure-HASH.svg for every diagram tag on the page
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. -v, -vv, -vvv and -q (or --log-level=) set how much goes to stderr.
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. 15 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. 3 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. 3 ms MetaPost, 51 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, 149 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. 5 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. 144 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}All of PGF/TikZ's libraries are bundled, and so is spath3, whose knots and calligraphy libraries pgf does not ship. A few more of them, each a complete document; the same sources are in the gallery, where they can be edited.
arrows.meta tips, filled and open, stacked, round, harpoons and Bars, and the bending library's tips that follow a curved path. 177 ms TeX, 47 ms dvisvgm (snapshot)\documentclass[tikz,border=6pt]{standalone}
\usetikzlibrary{arrows.meta,bending}
\begin{document}
\begin{tikzpicture}[font=\footnotesize\ttfamily, line width=0.8pt]
\foreach \tip [count=\i] in {Stealth, Latex, Triangle, Kite, Straight Barb, Hooks, Circle, Square} {
\draw[arrows/.expanded={-{\tip[scale=1.3]}}] (0,-0.5*\i) -- ++(1.8,0) node[right=4pt] {\tip};
\draw[arrows/.expanded={-{\tip[open,scale=1.3]}}] (4.3,-0.5*\i) -- ++(1.8,0) node[right=4pt] {open};
}
\draw[{Bar[width=6pt]}-{Bar[width=6pt]}, blue!70!black] (0,-4.7) -- node[below,font=\footnotesize] {$d = 1.8$ cm} ++(1.8,0);
\draw[-{Stealth[length=3mm]Stealth[length=3mm]}, red!70!black] (2.6,-4.7) -- ++(1.4,0);
\draw[{Latex[round]}-{Latex[round,reversed]}, thick] (4.3,-4.7) -- ++(1.8,0);
\draw[-{Stealth[harpoon,swap]}, thick] (6.9,-4.5) -- ++(1.2,0);
\draw[-{Stealth[harpoon,swap]}, thick] (8.1,-4.9) -- ++(-1.2,0);
\draw[-{Latex[bend,length=4mm]}, very thick, orange!80!black] (7.4,-0.4) arc[start angle=160, end angle=-100, radius=1.1];
\node[font=\footnotesize\rmfamily, align=center] at (7.95,-3.1) {tips that\\ bend with\\ the path};
\end{tikzpicture}
\end{document}text along path. 829 ms TeX, 52 ms dvisvgm (snapshot)\documentclass[tikz,border=6pt]{standalone}
\usetikzlibrary{decorations.pathmorphing,decorations.pathreplacing,decorations.markings,decorations.text,decorations.shapes,decorations.footprints,shapes.geometric,arrows.meta}
\begin{document}
\begin{tikzpicture}[every node/.style={font=\footnotesize}]
\draw[decorate,decoration={brace,amplitude=6pt,raise=2pt}] (0,0.2) -- (3,0.2) node[midway,above=9pt] {brace};
\draw[decorate,decoration={snake,amplitude=2pt,segment length=8pt}] (0,0) -- (3,0) node[right] {snake};
\draw[decorate,decoration={coil,aspect=0.5,amplitude=4pt,segment length=6pt},blue!70!black] (0,-0.7) -- (3,-0.7) node[right,black] {coil};
\draw[decorate,decoration={zigzag,amplitude=3pt,segment length=6pt},red!70!black] (0,-1.4) -- (3,-1.4) node[right,black] {zigzag};
\draw[decorate,decoration={bumps,amplitude=3pt,segment length=10pt},green!50!black] (0,-2.1) -- (3,-2.1) node[right,black] {bumps};
\path[decorate,decoration={shape backgrounds,shape=star,shape size=6pt,shape sep=10pt},fill=yellow!70!orange,draw=orange!60!black] (0,-2.8) -- (3,-2.8) node[right,black] {shapes};
\draw[postaction={decorate},decoration={markings,mark=between positions 0.1 and 0.9 step 0.2 with {\arrow{Stealth}}},thick,violet] (0,-3.6) .. controls (1,-3.1) and (2,-4.1) .. (3,-3.6) node[right,black] {markings};
\draw[gray!50, fill=gray!5] (6.6,-1.4) circle (1.3);
\path[decorate,decoration={text along path,text={text that follows a path},text align=center,raise=3pt}] (6.6,-1.4) ++(195:1.3) arc[start angle=195, end angle=-15, radius=1.3];
\fill[brown!70!black,decorate,decoration={footprints,foot length=6pt,stride length=12pt,foot of=human}] (5.1,-3.7) .. controls (6,-3.1) and (7.2,-4.1) .. (8.1,-3.5);
\node[align=center] at (6.6,-1.4) {decorations.\\text};
\node[below=1pt] at (6.6,-3.8) {footprints};
\path (8.4,0.3); % decorated text is not counted in the bounding box
\end{tikzpicture}
\end{document}knots library, bundled although pgf does not ship it: every crossing of a self-intersecting strand is found and the upper strand redrawn. 2957 ms TeX, 41 ms dvisvgm (snapshot)\documentclass[tikz,border=6pt]{standalone}
\usetikzlibrary{knots}
% one motif, turned three times: a single strand that crosses itself
\newcommand{\motif}[1]{
to ++(180+#1:0.50) arc (270+#1:150+#1:0.15)
to ++( 60+#1:0.50) arc (-30+#1:150+#1:0.15)
to ++(240+#1:0.25) arc (150+#1:330+#1:0.25)
to ++( 60+#1:0.55) arc (150+#1: 30+#1:0.20)
}
\begin{document}
\begin{tikzpicture}
\begin{knot}[line width=2pt, line join=round, clip width=2, scale=5,
consider self intersections, ignore endpoint intersections=false, background color=white,
only when rendering/.style={draw=blue!55!black, double=white, double distance=6pt, line cap=round}]
\strand (0,0) \motif{0}\motif{120}\motif{240};
\flipcrossings{1,3,6,8,10}
\end{knot}
\begin{scope}[xshift=5.4cm, yshift=-1.6cm]
\begin{knot}[clip width=5, flip crossing=2]
\strand[red!70!black, ultra thick] (0,0) .. controls +(1,0) and +(-1,0) .. (2,1) .. controls +(1,0) and +(-1,0) .. (4,0);
\strand[blue!70!black, ultra thick] (0,1) .. controls +(1,0) and +(-1,0) .. (2,0) .. controls +(1,0) and +(-1,0) .. (4,1);
\end{knot}
\end{scope}
\end{tikzpicture}
\end{document}\documentclass[tikz,border=6pt]{standalone}
\usetikzlibrary{decorations.pathreplacing,calligraphy}
\begin{document}
\begin{tikzpicture}
% a broad nib held at 45 degrees: the stroke is thick or thin with its direction
\pen (-135:.2) -- (45:.2);
\calligraphy[pen colour=blue!55!black] (0,0) .. controls +(45:1) and +(-135:1) .. +(3,0) ++(1.5,0) .. controls +(-135:2) and +(45:2) .. +(0,-3) (0,-3) .. controls +(45:1) and +(-135:1) .. +(3,0);
% a copperplate nib: each segment swells and tapers
\pen (0,0);
\calligraphy[heavy, heavy line width=3pt, pen colour=red!60!black] (4.6,0) .. controls +(45:1) and +(-135:1) .. +(3,0) ++(1.5,0) .. controls +(-135:2) and +(45:2) .. +(0,-3) (4.6,-3) .. controls +(45:1) and +(-135:1) .. +(3,0);
% the decorations built on it
\draw[decorate, decoration={calligraphic brace, amplitude=3mm}, ultra thick] (9.4,-3) -- (9.4,0.4);
\draw[decorate, decoration={calligraphic curved parenthesis, amplitude=3mm}, ultra thick] (10.3,-3) -- (10.3,0.4);
\draw[decorate, decoration={calligraphic straight parenthesis, amplitude=3mm}, ultra thick] (11.2,-3) -- (11.2,0.4);
\path (11.8,0); % the decorations are not counted in the bounding box
\end{tikzpicture}
\end{document}matrix of math nodes with delimiters and a highlighted diagonal on the background layer, and arrows between the cells of a second matrix. 169 ms TeX, 54 ms dvisvgm (snapshot)\documentclass[tikz,border=6pt]{standalone}
\usetikzlibrary{matrix,fit,backgrounds,positioning,arrows.meta}
\begin{document}
\begin{tikzpicture}[>={Stealth}]
\matrix (A) [matrix of math nodes, left delimiter={(}, right delimiter={)}, row sep=4pt, column sep=8pt, nodes={minimum width=1.4em}] {
2 & -1 & 0 & 0 \\
-1 & 2 & -1 & 0 \\
0 & -1 & 2 & -1 \\
0 & 0 & -1 & 2 \\
};
\begin{scope}[on background layer]
\foreach \i in {1,...,4} \fill[blue!12, rounded corners=3pt] (A-\i-\i.north west) rectangle (A-\i-\i.south east);
\end{scope}
\node[left=6pt of A, anchor=east] (L) {$K_4 =$};
\matrix (cd) [matrix of math nodes, row sep=1.4cm, column sep=1.8cm, right=1.6cm of A] {
A & B \\
C & D \\
};
\draw[->] (cd-1-1) -- node[above,font=\small] {$f$} (cd-1-2);
\draw[->] (cd-1-1) -- node[left,font=\small] {$g$} (cd-2-1);
\draw[->] (cd-1-2) -- node[right,font=\small] {$h$} (cd-2-2);
\draw[->] (cd-2-1) -- node[below,font=\small] {$k$} (cd-2-2);
\draw[->, dashed] (cd-1-1) -- node[above right=-2pt,font=\small] {$\varphi$} (cd-2-2);
\node[draw=red!60!black, rounded corners, inner sep=6pt, fit=(cd-1-1)(cd-2-2), label={[font=\footnotesize]below:$h\circ f = k\circ g$}] {};
\end{tikzpicture}
\end{document}mindmap library, whose shaded connection bars become SVG gradients. 246 ms TeX, 45 ms dvisvgm (snapshot)\documentclass[tikz,border=6pt]{standalone}
\usetikzlibrary{mindmap}
\begin{document}
\begin{tikzpicture}[mindmap, grow cyclic, every node/.style={concept, font=\sffamily\small}, concept color=blue!50!black, text=white,
level 1/.append style={level distance=4cm, sibling angle=120, font=\sffamily\footnotesize},
level 2/.append style={level distance=2.3cm, sibling angle=45, font=\sffamily\scriptsize}]
\node {mp-tikz-wasm}
child[concept color=red!60!black] { node {MetaPost} child { node {SVG} } child { node {EPS} } child { node {JSON} } }
child[concept color=green!45!black] { node {pdf\TeX} child { node {LaTeX} } child { node {TikZ} } child { node {pgfplots} } }
child[concept color=orange!80!black] { node {Lua\TeX} child { node {graph drawing} } child { node {fontspec} } child { node {Lua} } };
\end{tikzpicture}
\end{document}engine: 'plain' runs plain TeX with e-TeX (TeX Live's etex), which is what PGF needs. 89 ms TeX, 54 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. 170 ms TeX, 98 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}Nothing about \parshape needs LaTeX. The same primitive in plain TeX, with a loop computing the 21 line widths:
\parshape and the line breaker pours the paragraph into it. 22 ms TeX, 126 ms dvisvgm% plain TeX (pdfTeX): a paragraph set in a diamond with \parshape.
% Line i of 2m+1 is indented by |i-m|*d and is 2|i-m|*d narrower than \hsize.
\hsize=9cm \parindent=0pt \nopagenumbers \lefthyphenmin=2 \righthyphenmin=3
\baselineskip=12.5pt \tolerance=9999 \emergencystretch=1em \hyphenpenalty=10
\newcount\m \m=10 \newcount\i \newcount\k \newdimen\d \d=\hsize \divide\d by 22 \newdimen\ind \newdimen\wid
\def\shape{}
\i=0
\loop
\k=\i \advance\k by -\m \ifnum\k<0 \k=-\k \fi % k = |i - m|
\ind=\k\d \wid=\hsize \advance\wid by -2\ind
\edef\shape{\shape\space\the\ind\space\the\wid}
\advance\i by 1
\ifnum\i<\numexpr 2*\m+1\relax \repeat
\parshape \numexpr 2*\m+1\relax \shape
\TeX\ breaks a paragraph into lines by looking at all of it at once. Every
feasible breakpoint gets a demerit that grows with the cube of how far its
line has to stretch or shrink, and the breaks chosen are the sequence with
the least total: a shortest path through a graph of possibilities, found by
dynamic programming. The {\tt\string\parshape} primitive gives each line its own
indentation and width, so the same algorithm pours text into any outline.
Here the lines widen by $2/22$ of the measure down to the middle one
and then narrow again, and the paragraph becomes a diamond without a single
manual line break, in plain \TeX, with no packages.\par
\byeA 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. By default text is set in the same Type 1 fonts as the pdfTeX route; with the opt-in OpenType bundle it can use fontspec as well (next section). engine: 'lualatex' and engine: 'luatex' ask for LuaTeX explicitly, for LaTeX or plain TeX.
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. 544 ms LuaTeX, 49 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}\documentclass[tikz,border=6pt]{standalone}
\usetikzlibrary{graphs,graphdrawing,quotes}
\usegdlibrary{trees,force}
\begin{document}
\begin{tikzpicture}[>=stealth, every node/.style={draw, circle, inner sep=1.5pt, minimum size=5mm, font=\footnotesize, fill=green!12}]
\graph[binary tree layout, level distance=9mm, sibling distance=7mm] {
8 -> { 4 -> { 2 -> { 1, 3 }, 6 -> { 5, 7 } }, 12 -> { 10 -> { 9, 11 }, 14 -> { 13, 15 } } }
};
\begin{scope}[xshift=7.4cm, yshift=-1.4cm, every node/.style={draw, rectangle, rounded corners, font=\footnotesize, fill=blue!10}]
\graph[spring electrical layout, node distance=13mm, electric charge=2, random seed=7] {
TeX -- {LaTeX, plain, ConTeXt}; LaTeX -- {TikZ, amsmath, fontspec}; TikZ -- {pgfplots, graphs}; plain -- MetaPost -- ConTeXt; fontspec -- LuaTeX -- ConTeXt; graphs -- LuaTeX
};
\end{scope}
\end{tikzpicture}
\end{document}engine: 'luatex': \directlua sieves the primes and hands the counts to TeX with token.set_macro; the bars are \vrules and the table an \halign. 68 ms LuaTeX, 48 ms dvisvgm% plain LuaTeX: Lua does the arithmetic, TeX's primitives do the typesetting
\hsize=11.5cm \parindent=0pt \nopagenumbers
\font\big=cmbx12
\directlua{
local N, sieve, count, c = 1000, {}, {}, 0
for n = 2, N do sieve[n] = true end
for n = 2, N do
if sieve[n] then c = c + 1; for m = n*n, N, n do sieve[m] = false end end
count[n] = c
end
% hand the results to TeX as macros \pi50, \pi100 ... and \li50 ...
for x = 50, 1000, 50 do
token.set_macro("pi" .. x, tostring(count[x]))
token.set_macro("li" .. x, tostring(math.floor(10 * x / math.log(x) + 0.5) / 10))
end
}
\def\p#1{\csname pi#1\endcsname}\def\l#1{\csname li#1\endcsname}
{\big Counting primes in {\tt\string\directlua}}\medskip
The number of primes up to $x$ grows like $x/\ln x$:
$$\pi(x)=\sum_{p\le x}1\;\sim\;{x\over\ln x}\qquad(x\to\infty).$$
Each bar is a {\tt\string\vrule} that is $0.6\,\pi(x)$ points tall, for $x=50,100,\ldots,1000$;
the red tick marks $x/\ln x$.\medskip
\newcount\x
\hbox{\vrule width .4pt height 105pt depth 0pt\kern4pt
\x=50
\loop
\dimen0=\p{\the\x}pt \dimen0=.6\dimen0 \dimen2=\l{\the\x}pt \dimen2=.6\dimen2
\special{color push rgb 0.2 0.35 0.7}\vrule width 7pt height \dimen0 depth 0pt\special{color pop}%
\special{color push rgb 0.8 0.1 0.1}\llap{\raise\dimen2\hbox{\vrule width 9pt height .8pt depth 0pt\kern-1pt}}\special{color pop}%
\kern3pt
\advance\x by 50
\ifnum\x<1001 \repeat}
\hrule width 8.2cm
\bigskip
\halign{\strut\hfil$#$\quad\vrule\quad&\hfil#\quad&\hfil#\cr
x&$\pi(x)$&$x/\ln x$\cr\noalign{\hrule}
100&\p{100}&\l{100}\cr
500&\p{500}&\l{500}\cr
1000&\p{1000}&\l{1000}\cr}
\byeengine: 'lualatex': Lua writes one \rule per odd entry of Pascal's triangle into the paragraph, and amsmath sets the theorem. 216 ms LuaTeX, 76 ms dvisvgm\documentclass[border=8pt,varwidth=12cm]{standalone}
\usepackage{amsmath,xcolor}
\begin{document}
\section*{Pascal's triangle, modulo 2}
\directlua{
% each row of Pascal's triangle mod 2, drawn as one line of \rule boxes;
% \string\\ passes a backslash through TeX's expansion to Lua
local rows, row = 32, {1}
for n = 1, rows do
tex.sprint("\string\\noindent\string\\hspace*{" .. (rows - n) * 2 .. "pt}")
for _, v in ipairs(row) do
if v == 1 then tex.sprint("\string\\textcolor{blue!60!black}{\string\\rule{3.6pt}{3.6pt}}\string\\hspace{0.4pt}")
else tex.sprint("\string\\hspace{4pt}") end
end
tex.sprint("\string\\par\string\\nointerlineskip\string\\vspace{0.4pt}")
local next = {1} % row n has n entries (no # here: TeX would double it)
for i = 2, n do next[i] = (row[i-1] + row[i]) \csstring\% 2 end
next[n + 1] = 1
row = next
end
}
\medskip
Lua computed each entry; \LaTeX\ drew it with \verb|\rule|. The odd entries
of $\binom{n}{k}$ form Sierpi\'nski's triangle, because by Lucas' theorem
\[
\binom{n}{k} \equiv \prod_i \binom{n_i}{k_i} \pmod 2,
\qquad\text{so}\qquad
\binom{n}{k}\ \text{is odd} \iff k \mathbin{\&} n = k .
\]
Row $31$ is all ones: $\sum_{k}\binom{31}{k} = 2^{31} = \directlua{tex.sprint(string.format("\csstring\%d", 2^31))}$.
\end{document}\usepackage{fontspec} with real OpenType faces works under lualatex, and \input luaotfload.sty under plain luatex, through luaotfload shaping in Lua (mode=node). It is opt-in, because LaTeX under LuaTeX probes for luaotfload at start-up and, once it can find it, initialises it on every run — about 200 ms more for a document that never asks for it. Load the opentype bundle (and otf-fonts for unicode-math's maths font):
import { MetaPost, DEFAULT_BUNDLES } from './dist/index.js';
const mp = await MetaPost.create({ bundles: [...DEFAULT_BUNDLES, 'opentype', 'otf-fonts'] });
await mp.latex(doc, { engine: 'lualatex' }); // glyph outlines, as always
await mp.latex(doc, { engine: 'lualatex', fonts: 'woff2' }); // or embedded web fonts and real <text>
await mp.addFiles({ 'Charter.ttf': bytes }); // a face the host supplies: \setmainfont{Charter.ttf}[Path=./]
From the tags, data-bundles="+opentype" on the loader and data-engine="lualatex" on the diagram; from the command line, --opentype. The bundle carries luaotfload, fontspec, unicode-math and the whole Latin Modern OpenType family, every optical size the class options and size commands can select, each face fetched when a document first uses it. The bundle also carries luaotfload's font-name database, prebuilt against the bundled tree, so a fresh engine opens only the faces a document uses (a 12pt article: four faces, 0.44 MB) instead of scanning all 72 first. A face looked up by a name the database does not know — one supplied with addFiles(), say — makes luaotfload rescan, once per engine.
opentype bundle: ligatures, kerning, Unicode input, letter-spacing, a small-caps face and optical sizes, all from Latin Modern's OpenType faces. 1259 ms LuaTeX, 60 ms dvisvgm\documentclass[border=8pt,varwidth=11cm]{standalone}
\usepackage{fontspec,xcolor}
\setmainfont{Latin Modern Roman}[SmallCapsFont=lmromancaps10-regular.otf]
\setsansfont{Latin Modern Sans}
\setmonofont{Latin Modern Mono}
\begin{document}
\raggedright
{\Large\bfseries OpenType through fontspec}\par\medskip
Real OpenType faces, shaped by luaotfload: ligatures (fi ffi ffl),
kerning (AVATAR, Tea, To), and Unicode input typed straight in —
Dvořák, Łódź, Ærøskøbing, façade, naïve, Straße, IJsselmeer, Việt Nam.\par\medskip
{\addfontfeatures{Ligatures=NoCommon}Ligatures switched off: fi ffi ffl.}\par
{\addfontfeatures{LetterSpace=12}\scshape Letter-spaced small capitals}\par
{\addfontfeatures{FakeSlant=0.25}A slant applied by the font loader.}\par\medskip
\textsf{Latin Modern Sans}, \texttt{Latin Modern Mono}, \textit{italic},
\textbf{bold} and \textsl{slanted}. Each size has its own optical design
— {\footnotesize footnote,} {\large large,} {\LARGE LARGE} — and each face
is fetched only when a document asks for it.
\end{document}\input luaotfload.sty and a \font with a feature list; no LaTeX involved. 365 ms LuaTeX, 54 ms dvisvgm% plain LuaTeX with OpenType: no fontspec, just luaotfload and \font
\input luaotfload.sty
\font\body="[lmroman10-regular.otf]:mode=node;+liga;+kern" at 11pt
\font\nolig="[lmroman10-regular.otf]:mode=node;-liga;+kern" at 11pt
\font\head="[lmroman17-regular.otf]:mode=node;+liga;+kern" at 20pt
\font\sans="[lmsans10-bold.otf]:mode=node;+liga;+kern" at 11pt
\font\caps="[lmromancaps10-regular.otf]:mode=node;+kern" at 11pt
\hsize=10cm \parindent=0pt \nopagenumbers
{\head Plain \TeX, OpenType fonts}\medskip
\body A face is loaded by file name, with its OpenType features spelled out
after the colon: here {\tt +liga} gives ffi, ffl and fi, and
{\tt -liga} turns them off again: {\nolig ffi, ffl, fi}.
Accented letters come from the Unicode input: Dvořák, Łódź, Ærøskøbing.
\medskip
{\sans A sans-serif bold face,} {\caps and true small capitals.}
\medskip
\hrule
\smallskip
{\tt\string\input\ luaotfload.sty} works in plain {\tt luatex} to DVI here
thanks to a small patch that gives plain \TeX\ the shipout hook luaotfload needs.
\byefonts: 'woff2' the text in this figure is real <text> in an embedded subset of the face: select it. 806 ms LuaTeX, 72 ms dvisvgm\documentclass[border=8pt]{standalone}
\usepackage{fontspec,xcolor}
\setmainfont{Latin Modern Roman}
\begin{document}
\begin{tabular}{@{}l@{}}
{\Large Text as \emph{text}, not outlines}\\[4pt]
With \texttt{fonts: 'woff2'} the SVG embeds a subset of\\
each face as a web font and writes real \texttt{<text>}:\\
it can be selected, searched and copied, and the browser's\\
own rasteriser draws it — \textcolor{blue!60!black}{fi ffl}, Dvořák, Łódź.
\end{tabular}
\end{document}otf-fonts bundle as well. 1333 ms LuaTeX, 44 ms dvisvgm\documentclass[border=8pt,varwidth=11cm]{standalone}
\usepackage{unicode-math}
\setmainfont{Latin Modern Roman}
\setmathfont{Latin Modern Math}
\begin{document}
Maths typed in Unicode, set in the OpenType maths font:
\[ ∀ε>0\;∃δ>0:\quad |x-a|<δ ⟹ |f(x)-f(a)|<ε \]
\[ ∫_{-∞}^{∞} e^{-x²}\,dx = \sqrt{π}, \qquad ∑_{n=1}^{∞} \frac{1}{n²} = \frac{π²}{6}, \qquad
𝔼[X] = ∫_Ω X\,d\mathbb{P}, \qquad 𝐀𝐱 = 𝐛 \]
\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, fourteen in all, four of them opt-in), builds a virtual filesystem inside the Web Worker with a lazy placeholder for every one of the 4,200 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). spath3 (the knots and calligraphy libraries) rides along with PGF, as do chemfig, circuitikz (its current release; the rollback copies of old versions are left out) and tikz-3dplot, and this project's own svg.attributes library (above); the opt-in classico bundle adds URW Classico, an Optima (\usepackage{classico}; its fonts are under the Aladdin Free Public License, non-commercial distribution only), and the opt-in opentype and otf-fonts bundles add luaotfload, fontspec, unicode-math, the Latin Modern OpenType family and Latin Modern Math. The opt-in ghostscript bundle adds dvips's PostScript headers and the PSTricks family (every pst-* package but three data-heavy ones), and turns on the Ghostscript module for documents whose PostScript needs it. 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, or PDF mode on request (= tex, etex, latex) with kpathsea, zlib, libpng |
| dvisvgm.wasm | 2.6 MB | dvisvgm 3.4.3 with FreeType, potrace, clipper, woff2/brotli; PostScript specials through the optional Ghostscript module |
| 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 |
| ghostscript/gs.wasm | 11.9 MB | Ghostscript 10.08.0 (AGPL): the PostScript and PDF interpreters with their fonts, a separate module (and release archive) that dvisvgm reaches through a bridge; loaded on demand, only for documents whose PostScript needs it (the opt-in ghostscript bundle) |
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 |
| Ghostscript, once, for the first document with PostScript (opt-in) | 11.9 MB | 8.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; OpenType through fontspec under LuaTeX | 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 |
\special{ps: …}, graphicx's \rotatebox/\scalebox under its default dvips driver) needs the opt-in ghostscript bundle, which loads Ghostscript as a separate module only for documents that carry PostScript; without it, latex() warns when PostScript was lost. No external image inclusion in SVG output beyond EPS (PDF output includes PNG and JPEG, not PDF images).mode=node, not HarfBuzz (this is luatex, not luahbtex), so mode=harf and Graphite features are out. The bundled faces are Latin Modern; other faces are supplied by the host with addFiles(). TrueType Collections (.ttc) work from 0.4.0 when each face is named by its FontIndex (BoldFeatures={FontIndex=1} and so on); 0.3.1 and earlier draw every face of a collection as one, so with those hand over one .ttf/.otf per face.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.siunitx option does not work). chemfig, circuitikz and tikz-3dplot are bundled; circuitikz's rollback releases are not.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, with the patches in patches/texmf/ applied, plus tikz.ini and the files in bundles/tex/ |
| node scripts/make-formats.mjs | plain.fmt, etex.fmt, latex.fmt, tikz.fmt, built by tex.wasm; dviluatex.fmt, dvilualatex.fmt by luatex.wasm |
| npm run build:ts | dist/*.js: the library, the worker, the CLI, auto.js (before the bundles: the next step runs through it) |
| npm run build:fontdb | luaotfload's font-name database, built by luatex.wasm against build/texmf, for the opentype bundle |
| node scripts/build-bundles.mjs | dist/bundles/*: manifests and files per bundle |
| npm run build:hot | dist/bundles/hot.json: the files a first run of each kind touches, prefetched in parallel (after any bundle rebuild, which deletes it) |
| npm run package | release/mp-tikz-wasm-0.5.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.