Skip to content

Math: render $…$ as MathML via Temml (#15, #33) - #76

Merged
nyblnet merged 1 commit into
mainfrom
claude/math-temml
Jul 26, 2026
Merged

Math: render $…$ as MathML via Temml (#15, #33)#76
nyblnet merged 1 commit into
mainfrom
claude/math-temml

Conversation

@nyblnet

@nyblnet nyblnet commented Jul 25, 2026

Copy link
Copy Markdown
Owner

Closes #15, closes #33 (duplicates of each other).

Approach

Resolve $…$ and $$…$$ at render time — the same trick resolveFields already uses for {{page}}.

The format doesn't change. The model stores the raw source; $E=mc^2$ is just text in el.html. Nothing to version, no new element type. An older build opening a newer file shows the literal $E=mc^2$ — degraded, legible, nothing lost. One renderer covers canvas, thumbnails, present and print because render.ts is shared. This is option 4 from #33's list, and it makes option 3 (an equation element) unnecessary.

Why Temml, not KaTeX or MathJax

Measured through the real storage pipeline (deflate + the base64 the shell stores payloads in):

cost
KaTeX +421KB — js 100KB + css 5KB + 326KB of webfonts
Temml +64KB — no fonts at all
MathJax larger still

Temml emits MathML and the browser lays it out with its own math fonts. KaTeX emits HTML+CSS, so it must ship a layout engine and ~20 woff2 faces — already-compressed binaries that deflate can't shrink and base64 makes bigger. KaTeX would put the shell back over 1MB, the territory this project left when ECharts was removed.

One correction for #15: MathJax isn't ~15KB — its npm package is 20MB unpacked, and minimal component builds still run to hundreds of KB plus fonts.

Safety

resolveMath runs after sanitizeHtml, never before. The sanitizer unwraps everything outside its allowlist and strips all attributes, so it would demolish MathML. Running after is also why the allowlist needs no widening — this markup is generated by us from LaTeX, never accepted from the author. Temml runs with trust: false, so \href and friends are inert.

The inline form is deliberately fussy so prose survives: no whitespace just inside the delimiters, and no digit straight after the closer. That's what keeps it costs $5 and $10 from parsing as math.

Verification

Seven cases in the browser:

input result
$E=mc^2$ one <math>, inline
$$\frac{-b \pm …}{2a}$$ one <math display="block">
it costs $5 and $10 today no math, text untouched
\$99 literal $99
$\frac{1}{$ (broken TeX) left exactly as typed
$x &lt; y$ entities decoded, renders x < y
math beside <b>bold</b> both survive

In every case el.html still holds the raw source. Double-clicking shows $E = mc^2$ with zero <math> nodes while editing — canvas.startTextEdit's raw-swap now triggers on $ as well as {{.

Splice conformance gate passes; tsc -b clean.

Cost — and a correction

+80,173 B (+13.4%), not the +64KB I projected from temml's prebuilt dist file. Vite bundles the package source rather than that artifact.

Measured combinations:

shell vs main
main today 598,457 B
#75 (i18n packing) only 541,930 B −9.4%
this PR only 678,630 B +13.4%
both 622,150 B +4.0%

So together the shell grows ~24KB rather than shrinking ~19KB as I earlier estimated. #75 pays for most of the math, not all of it. Worth landing #75 first either way.

Math classes were the ask (#15) and $…$ the requested syntax (#33). This
resolves both at RENDER time, the same trick resolveFields already uses for
{{page}}.

THE FORMAT DOESN'T CHANGE. The model stores the raw source — `$E=mc^2$` is
just text in el.html — so there is nothing to version and no new element
type. An older build opening a newer file shows the literal `$E=mc^2$`:
degraded, legible, nothing lost. One renderer covers canvas, thumbnails,
present and print because render.ts is shared.

WHY TEMML, NOT KATEX/MATHJAX. Measured through the real storage pipeline
(deflate + the base64 the shell stores payloads in):

    KaTeX     +421KB   js 100KB + css 5KB + 326KB of webfonts
    Temml      +64KB   no fonts at all
    MathJax   larger still (20MB npm package; #15's "about 15 kB" is not right)

Temml emits MathML and the browser lays it out with its own math fonts;
KaTeX emits HTML+CSS so it must ship a layout engine AND ~20 font faces,
which are woff2 — already compressed, so deflate can't help and base64 makes
them bigger. KaTeX would put the shell back over 1MB, the territory the
project left when ECharts was removed.

ORDER MATTERS: resolveMath runs AFTER sanitizeHtml, never before. The
sanitizer unwraps everything outside its allowlist and strips all attributes,
so it would demolish MathML. Running after is also why the allowlist needs no
widening — this markup is GENERATED by us from LaTeX, never accepted from the
author — and Temml runs with trust off, so \href and friends are inert.

The inline form is deliberately fussy, because prose has to survive: no
whitespace just inside the delimiters, and no digit straight after the
closer. That is what stops "it costs $5 and $10" parsing as math. \$ is a
literal dollar, and TeX that fails to parse is left exactly as typed.

Editing shows the RAW source, like fields: canvas.startTextEdit's raw-swap
now triggers on `$` as well as `{{`.

VERIFIED in the browser, seven cases:
  inline $E=mc^2$                    -> one <math>, inline
  display $$\frac{-b\pm…}{2a}$$      -> one <math display="block">
  "it costs $5 and $10 today"        -> NO math, text untouched
  escaped \$99                        -> literal $99
  broken TeX $\frac{1}{$              -> left exactly as typed
  entities $x &lt; y$                 -> decoded, renders x < y
  math beside <b>bold</b>             -> both survive
In every case el.html still holds the raw source. Double-click shows
`$E = mc^2$` with zero <math> nodes while editing.

COST: shell 598,457 B -> 678,630 B (+80,173 B, +13.4%). Higher than the
+64KB predicted from temml's prebuilt dist — vite bundles the package source,
not that file. Splice conformance gate passes; tsc -b clean.
@nyblnet
nyblnet force-pushed the claude/math-temml branch from 6e116f9 to 053dff7 Compare July 26, 2026 00:23
@nyblnet
nyblnet merged commit 5b4c86c into main Jul 26, 2026
1 check passed
nyblnet added a commit that referenced this pull request Jul 26, 2026
A term crossing the equals sign is now SEEN to travel there, instead of the
whole formula crossfading. The idea comes from PR #68; this implements it on
the bundled Temml renderer rather than a lazy-loaded library.

HOW IT PAIRS. render.ts tags each MathML token with its own text plus an
occurrence index (`x#0`, `x#1`) — so the second `x` in one formula pairs with
the second `x` in the next, wherever each has moved to. Render-time only:
nothing about it enters the document.

THE HARD PART was geometry, and it needed care because it cuts against the
engine's central rule. runMorph reads frames from the MODEL and never
measures the DOM, precisely because the outgoing section carries Reveal's own
transforms. A symbol inside a formula has NO model entry — it is produced at
render time from a raw `$…$` string — so there is nothing to look up.

Two things make it work:

  1. Measure only what is INVARIANT under those transforms: a symbol's offset
     from its own element's box, divided by that box's measured size over its
     MODEL size. Any scale an ancestor applies hits numerator and denominator
     alike and cancels. Per-axis, because the box tween can scale x and y
     differently and one shared factor skews every vertical offset.
  2. The outgoing slide cannot be measured AT ALL at morph time — verified,
     its elements report zero width. So each slide's symbols are captured
     while it is ON SCREEN and cached by (slide index, flip id); the morph
     reads the cache for the from-side. Captured synchronously, not in rAF: a
     backgrounded tab never runs animation frames.

Box geometry stays entirely model-driven. Only rearrangement WITHIN a box is
measured.

COMPOSES with the box morph rather than replacing it — that tween already
carries gross position and scale, so only each symbol's relative offset is
animated here, divided by the box's current scale (a transform on a child of
a scaled parent is scaled too, and without this symbols overshoot whenever a
formula changes size between slides).

Degrades quietly: symbols on only one side, formulas that share nothing, and
slides never measured all fall back to the plain box morph.

VERIFIED with the manual clock on `$a + b = c$` -> `$a = c - b$`:
  b   -59.6px -> -7.9px -> settles   (starts LEFT of its end: travels right,
                                      across the equals sign)
  =   +59.3px -> +7.8px -> settles   (starts RIGHT: travels left)
  c   +59.3px -> +7.8px -> settles
  a   no tween — first in both formulas, correctly seen as stationary
  −   only in the second formula — left to the box morph
Frozen mid-flight, `a` sits settled while b/= /c are caught crossing.
symCache is cleared on exit: it is keyed by slide INDEX, so carrying it into
a later show would be wrong if the deck was edited in between.

COST: +804 B. Splice conformance gate passes, tsc -b clean.
Stacked on #76 (Temml math).
nyblnet added a commit that referenced this pull request Jul 26, 2026
Symbol-level maths morph is invisible unless you see it move — a screenshot
cannot show it, and the starter deck is the feature tour. So it gets one beat.

DELIBERATELY SMALL. The formula sits quietly under the caption on the existing
"Morph." slide, then becomes the point of ONE new slide where it rearranges:

    $a + b = c$   ->   $a = c - b$

Same element id on both, so `b` is SEEN to travel across the equals sign while
the four sd-tile-* shapes continue their own morph. This extends the morph
story rather than opening a separate "maths" topic — the tour is shared by
everyone, and a slide teaching LaTeX would spend attention on behalf of users
who do not need it. Depth belongs in a gallery deck, not here.

+564 B in every shipped shell.

The caption writes the delimiters as \$…\$ ON PURPOSE. Writing them bare
renders them as maths — which is exactly what happened on the first attempt,
turning "plain $…$ in a text box" into "plain … in a text box" with the
ellipsis silently converted to a formula. It is the first thing anyone copying
this deck will hit, so the deck should show the escape rather than trip over
it.

VERIFIED on the real deck transition, driving anim's manual clock (the preview
pane never fires rAF, so morphs freeze mid-flight and screenshots of them are
not the settled state):
  b   -250.4px -> -11.0px -> settles
  c    -52.7px -> -2.3px  -> settles
  =      2.6px -> 0.1px   -> settles
  a      5.5px -> 0.2px   -> settles
b's horizontal travel is an order of magnitude larger than any other symbol —
the signature of a term crossing the equals sign. Caption renders literal
"$…$" with no <math> in it. Splice conformance gate passes, tsc -b clean.

Stacked on #83 (symbol morph) -> #76 (Temml). MUST NOT land before #76: the
starter deck ships inside every build, so maths content without the renderer
would make every fresh Bento open showing a literal $a + b = c$.
kaung-minkhant pushed a commit to kaung-minkhant/bento that referenced this pull request Jul 26, 2026
A term crossing the equals sign is now SEEN to travel there, instead of the
whole formula crossfading. The idea comes from PR nyblnet#68; this implements it on
the bundled Temml renderer rather than a lazy-loaded library.

HOW IT PAIRS. render.ts tags each MathML token with its own text plus an
occurrence index (`x#0`, `x#1`) — so the second `x` in one formula pairs with
the second `x` in the next, wherever each has moved to. Render-time only:
nothing about it enters the document.

THE HARD PART was geometry, and it needed care because it cuts against the
engine's central rule. runMorph reads frames from the MODEL and never
measures the DOM, precisely because the outgoing section carries Reveal's own
transforms. A symbol inside a formula has NO model entry — it is produced at
render time from a raw `$…$` string — so there is nothing to look up.

Two things make it work:

  1. Measure only what is INVARIANT under those transforms: a symbol's offset
     from its own element's box, divided by that box's measured size over its
     MODEL size. Any scale an ancestor applies hits numerator and denominator
     alike and cancels. Per-axis, because the box tween can scale x and y
     differently and one shared factor skews every vertical offset.
  2. The outgoing slide cannot be measured AT ALL at morph time — verified,
     its elements report zero width. So each slide's symbols are captured
     while it is ON SCREEN and cached by (slide index, flip id); the morph
     reads the cache for the from-side. Captured synchronously, not in rAF: a
     backgrounded tab never runs animation frames.

Box geometry stays entirely model-driven. Only rearrangement WITHIN a box is
measured.

COMPOSES with the box morph rather than replacing it — that tween already
carries gross position and scale, so only each symbol's relative offset is
animated here, divided by the box's current scale (a transform on a child of
a scaled parent is scaled too, and without this symbols overshoot whenever a
formula changes size between slides).

Degrades quietly: symbols on only one side, formulas that share nothing, and
slides never measured all fall back to the plain box morph.

VERIFIED with the manual clock on `$a + b = c$` -> `$a = c - b$`:
  b   -59.6px -> -7.9px -> settles   (starts LEFT of its end: travels right,
                                      across the equals sign)
  =   +59.3px -> +7.8px -> settles   (starts RIGHT: travels left)
  c   +59.3px -> +7.8px -> settles
  a   no tween — first in both formulas, correctly seen as stationary
  −   only in the second formula — left to the box morph
Frozen mid-flight, `a` sits settled while b/= /c are caught crossing.
symCache is cleared on exit: it is keyed by slide INDEX, so carrying it into
a later show would be wrong if the deck was edited in between.

COST: +804 B. Splice conformance gate passes, tsc -b clean.
Stacked on nyblnet#76 (Temml math).
kaung-minkhant pushed a commit to kaung-minkhant/bento that referenced this pull request Jul 26, 2026
Symbol-level maths morph is invisible unless you see it move — a screenshot
cannot show it, and the starter deck is the feature tour. So it gets one beat.

DELIBERATELY SMALL. The formula sits quietly under the caption on the existing
"Morph." slide, then becomes the point of ONE new slide where it rearranges:

    $a + b = c$   ->   $a = c - b$

Same element id on both, so `b` is SEEN to travel across the equals sign while
the four sd-tile-* shapes continue their own morph. This extends the morph
story rather than opening a separate "maths" topic — the tour is shared by
everyone, and a slide teaching LaTeX would spend attention on behalf of users
who do not need it. Depth belongs in a gallery deck, not here.

+564 B in every shipped shell.

The caption writes the delimiters as \$…\$ ON PURPOSE. Writing them bare
renders them as maths — which is exactly what happened on the first attempt,
turning "plain $…$ in a text box" into "plain … in a text box" with the
ellipsis silently converted to a formula. It is the first thing anyone copying
this deck will hit, so the deck should show the escape rather than trip over
it.

VERIFIED on the real deck transition, driving anim's manual clock (the preview
pane never fires rAF, so morphs freeze mid-flight and screenshots of them are
not the settled state):
  b   -250.4px -> -11.0px -> settles
  c    -52.7px -> -2.3px  -> settles
  =      2.6px -> 0.1px   -> settles
  a      5.5px -> 0.2px   -> settles
b's horizontal travel is an order of magnitude larger than any other symbol —
the signature of a term crossing the equals sign. Caption renders literal
"$…$" with no <math> in it. Splice conformance gate passes, tsc -b clean.

Stacked on nyblnet#83 (symbol morph) -> nyblnet#76 (Temml). MUST NOT land before nyblnet#76: the
starter deck ships inside every build, so maths content without the renderer
would make every fresh Bento open showing a literal $a + b = c$.
nyblnet added a commit that referenced this pull request Jul 26, 2026
Both landed on main with no user-facing note, so 1.0.11 would have announced
"we fixed some save messaging" while quietly containing LaTeX maths, symbol-
level formula morphing and twenty-one installable languages. The release notes
are also what the About dialog now shows inline before an update, so an empty
entry is a silently undersold release.

Written from the merged PRs (#76, #83, #84 for maths; #81, #86, #91 for packs)
and then CHECKED AGAINST THE CODE, which corrected three things the PR prose
would have left wrong:

- The menu is "Manage languages…", not "Add or remove languages…".
- Korean is no longer "the first pack" — there are 21 on disk (ar, bn, da, fa,
  fi, he, hi, id, ko, ms, nb, nl, pl, ru, sv, th, tl, tr, uk, ur, vi).
- Maths does NOT render "anywhere text does". resolveMath has exactly one call
  site, the TEXT element path in render.ts — not table cells, not speaker
  notes. Claiming otherwise would have sent people to try it in a table.
nyblnet added a commit that referenced this pull request Jul 27, 2026
Math: render $…$ as MathML via Temml (#15, #33)
nyblnet added a commit that referenced this pull request Jul 27, 2026
A term crossing the equals sign is now SEEN to travel there, instead of the
whole formula crossfading. The idea comes from PR #68; this implements it on
the bundled Temml renderer rather than a lazy-loaded library.

HOW IT PAIRS. render.ts tags each MathML token with its own text plus an
occurrence index (`x#0`, `x#1`) — so the second `x` in one formula pairs with
the second `x` in the next, wherever each has moved to. Render-time only:
nothing about it enters the document.

THE HARD PART was geometry, and it needed care because it cuts against the
engine's central rule. runMorph reads frames from the MODEL and never
measures the DOM, precisely because the outgoing section carries Reveal's own
transforms. A symbol inside a formula has NO model entry — it is produced at
render time from a raw `$…$` string — so there is nothing to look up.

Two things make it work:

  1. Measure only what is INVARIANT under those transforms: a symbol's offset
     from its own element's box, divided by that box's measured size over its
     MODEL size. Any scale an ancestor applies hits numerator and denominator
     alike and cancels. Per-axis, because the box tween can scale x and y
     differently and one shared factor skews every vertical offset.
  2. The outgoing slide cannot be measured AT ALL at morph time — verified,
     its elements report zero width. So each slide's symbols are captured
     while it is ON SCREEN and cached by (slide index, flip id); the morph
     reads the cache for the from-side. Captured synchronously, not in rAF: a
     backgrounded tab never runs animation frames.

Box geometry stays entirely model-driven. Only rearrangement WITHIN a box is
measured.

COMPOSES with the box morph rather than replacing it — that tween already
carries gross position and scale, so only each symbol's relative offset is
animated here, divided by the box's current scale (a transform on a child of
a scaled parent is scaled too, and without this symbols overshoot whenever a
formula changes size between slides).

Degrades quietly: symbols on only one side, formulas that share nothing, and
slides never measured all fall back to the plain box morph.

VERIFIED with the manual clock on `$a + b = c$` -> `$a = c - b$`:
  b   -59.6px -> -7.9px -> settles   (starts LEFT of its end: travels right,
                                      across the equals sign)
  =   +59.3px -> +7.8px -> settles   (starts RIGHT: travels left)
  c   +59.3px -> +7.8px -> settles
  a   no tween — first in both formulas, correctly seen as stationary
  −   only in the second formula — left to the box morph
Frozen mid-flight, `a` sits settled while b/= /c are caught crossing.
symCache is cleared on exit: it is keyed by slide INDEX, so carrying it into
a later show would be wrong if the deck was edited in between.

COST: +804 B. Splice conformance gate passes, tsc -b clean.
Stacked on #76 (Temml math).
nyblnet added a commit that referenced this pull request Jul 27, 2026
Symbol-level maths morph is invisible unless you see it move — a screenshot
cannot show it, and the starter deck is the feature tour. So it gets one beat.

DELIBERATELY SMALL. The formula sits quietly under the caption on the existing
"Morph." slide, then becomes the point of ONE new slide where it rearranges:

    $a + b = c$   ->   $a = c - b$

Same element id on both, so `b` is SEEN to travel across the equals sign while
the four sd-tile-* shapes continue their own morph. This extends the morph
story rather than opening a separate "maths" topic — the tour is shared by
everyone, and a slide teaching LaTeX would spend attention on behalf of users
who do not need it. Depth belongs in a gallery deck, not here.

+564 B in every shipped shell.

The caption writes the delimiters as \$…\$ ON PURPOSE. Writing them bare
renders them as maths — which is exactly what happened on the first attempt,
turning "plain $…$ in a text box" into "plain … in a text box" with the
ellipsis silently converted to a formula. It is the first thing anyone copying
this deck will hit, so the deck should show the escape rather than trip over
it.

VERIFIED on the real deck transition, driving anim's manual clock (the preview
pane never fires rAF, so morphs freeze mid-flight and screenshots of them are
not the settled state):
  b   -250.4px -> -11.0px -> settles
  c    -52.7px -> -2.3px  -> settles
  =      2.6px -> 0.1px   -> settles
  a      5.5px -> 0.2px   -> settles
b's horizontal travel is an order of magnitude larger than any other symbol —
the signature of a term crossing the equals sign. Caption renders literal
"$…$" with no <math> in it. Splice conformance gate passes, tsc -b clean.

Stacked on #83 (symbol morph) -> #76 (Temml). MUST NOT land before #76: the
starter deck ships inside every build, so maths content without the renderer
would make every fresh Bento open showing a literal $a + b = c$.
nyblnet added a commit that referenced this pull request Jul 27, 2026
Both landed on main with no user-facing note, so 1.0.11 would have announced
"we fixed some save messaging" while quietly containing LaTeX maths, symbol-
level formula morphing and twenty-one installable languages. The release notes
are also what the About dialog now shows inline before an update, so an empty
entry is a silently undersold release.

Written from the merged PRs (#76, #83, #84 for maths; #81, #86, #91 for packs)
and then CHECKED AGAINST THE CODE, which corrected three things the PR prose
would have left wrong:

- The menu is "Manage languages…", not "Add or remove languages…".
- Korean is no longer "the first pack" — there are 21 on disk (ar, bn, da, fa,
  fi, he, hi, id, ko, ms, nb, nl, pl, ru, sv, th, tl, tr, uk, ur, vi).
- Maths does NOT render "anywhere text does". resolveMath has exactly one call
  site, the TEXT element path in render.ts — not table cells, not speaker
  notes. Claiming otherwise would have sent people to try it in a table.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

1 participant