Typst math, in the browser.
kern's job is to render Typst math syntax to MathML that matches what Typst itself would produce. This page tracks which constructs are supported, where kern diverges, and how we measure correctness.
Last reviewed against Typst commit
de6f40097 (0.14.2). Run
pnpm -C scripts check-parity to regenerate the
structural diff report.
ok a passing test exercises the construct. partial renders but diverges from Typst in a documented way. no not yet supported. skip requires the full Typst layout engine.
| Construct | Status | Notes |
|---|---|---|
x, n (italic var) | ok | <mi> |
sin, cos, log | ok | Upright, with trailing thin space |
42, 3.14 | ok | <mn> |
alpha, Sigma, omega | ok | From the symbol table |
dif / Dif | ok | Thin space + upright d, used as integral f dif x |
plus.minus, arrow.r.long | ok | Dotted names traversed |
Hex literals (0xff) | no | Typst's code-mode form, not in math |
| Construct | Status | Notes |
|---|---|---|
cal(A) | ok | mathvariant="script" |
bb(R) | ok | mathvariant="double-struck" |
frak(g) | ok | mathvariant="fraktur" |
bold(x) | ok | Bold-italic for single letters |
italic(x), upright(d), sans, mono | ok | |
scr(A) | partial | Aliased to cal; MathML only exposes one script variant |
| Construct | Status | Notes |
|---|---|---|
frac(a, b), a / b | ok | <mfrac> |
binom(n, k) | ok | linethickness="0" inside stretched parens |
x^2, x_i, x_i^2 | ok | msup/msub/msubsup |
sqrt(x), root(n, x) | ok | msqrt/mroot |
Primes (f', f'') | ok | Promoted to a sup atom |
Explicit attach(b, tl: ..., br: ...) | no | Pre-scripts via <mmultiscripts> still pending |
stretch(glyph, size: ...) | no | Needs glyph-variant selection from the OpenType MATH table |
| Construct | Status | Notes |
|---|---|---|
sum_(i=0)^n (display) | ok | munderover |
sum_(i=0)^n (inline) | ok | msubsup |
integral_0^1 (display) | ok | Stays msubsup per Typst's rule for integrals |
lim_(x -> oo) | ok | munder in display |
limits(x), scripts(x) | ok | |
op("text", limits: true) | ok |
| Construct | Status | Notes |
|---|---|---|
(x), [x], {x} (plain) | ok | Body height, stretchy="false" |
| Auto-sized parens around tall content | ok | Bare (a/b) upgrades to stretchy fences; mirrors Typst's lr() |
lr((...)), lr([...]) | ok | stretchy="true" symmetric="true" for Safari parity |
abs(x), norm(v) | ok | Stretchy symmetric bars |
floor(x), ceil(x) | ok | |
Conditional bar inside lr() (P(A | B)) | ok | | becomes a stretchy symmetric fence so Safari sizes it with the parens |
round(x) | no | Renders as a generic call |
mid(x) | partial | Wrapped as a relation class; doesn't yet stretch to enclosing lr() |
Mismatched fences (lr([x/y))) | partial | Use explicit lr(); bare mismatched delimiters error at parse |
| Construct | Status | Notes |
|---|---|---|
vec(a, b, c) | ok | |
mat(a, b; c, d) | ok | Comma = column, semicolon = row |
cases(...) | ok | Left brace, left-aligned cells |
mat(..., delim: "[") | ok | Honored for (), [], {}, |, || |
mat(..., augment: 1) | ok | Integer or paren-list of integers draws vertical rules after the given column(s). Hline support via the augment dict shorthand is queued |
mat(..., gap: ...) | ok | gap, row-gap, column-gap in em units are honored via CSS variables |
& alignment across lines | ok | Single-row & stays an inline align marker; with \, & splits into <mtd> cells with alternating right/left alignment |
Multi-line equations with \ | ok | \ at end-of-token (or \\) becomes a row break; the whole construct renders as <mtable class="kern-eqarray kern-aligned"> |
| Equation numbering / supplements | skip | Belongs to Typst's figure/layout system |
| Construct | Status | Notes |
|---|---|---|
hat, tilde, bar, dot, ddot | ok | mover accent="true" |
arrow(x), arrow.l(x), arrow.l.r(x) | ok | Vector accent and harpoons |
acute, grave, breve, caron, circle, macron | ok | |
overline, underline | ok | |
overbrace, underbrace (+ annotation) | ok | |
overparen, underparen, overbracket, underbracket | ok | |
| Bottom-attached accents via combining class | no | Typst flips combining-Below characters; kern keeps every accent on top |
| Construct | Status | Notes |
|---|---|---|
thin, med, thick, quad, qquad, wide | ok | wide = 2 em |
class("rel", x) & friends | partial | Single-atom bodies only |
display, inline, script, sscript | ok | <mstyle> with displaystyle/scriptlevel |
cancel, bcancel, xcancel | ok | menclose notation |
Text in math: "word" | ok | <mtext> with thin-space padding |
Typst's compiler is the spec. Pixel-level parity is not achievable (different fonts, hinting, sub-pixel positioning), so the oracle is structural MathML: tags, nesting, and the attributes that affect layout.
The script at scripts/check-parity.ts reads
tests/corpus.txt, renders every entry through kern, and
(when typst compile --features html exposes equation
MathML, currently pending in Typst 0.14.2) renders the same entries
through Typst, then runs a structural diff that ignores
class, style, and presentation-only
attributes.
The latest run is at parity-report.html.
Visual regression tests live under
packages/kern/test/visual/ and act as a regression
gate, never as a correctness oracle: thresholds are constants
committed to the repo, and failures dump the Typst baseline, the
kern candidate, and a pixelmatch diff to disk for human review.
The MathML emitter we measure against is Rust code in
typst/crates/typst-html/src/mathml.rs. It depends on
Typst's Engine, layout, font, and IR crates - shipping it to the
browser would multiply kern's bundle by an order of magnitude and
couple every release to Typst's. Modern browsers already do
glyph-level layout from <mfrac> /
<msqrt> / etc.; we only need to emit the right
MathML tree.
Instead, we treat Typst's emitter as the structural oracle and keep kern a small, JS-native renderer. The parity gap between the two trees is the north star metric; the parity report tracks it expression by expression.