ADR-0018: Math
- Status: accepted
- Date: 2026-09-25
- Status update (Saturday, September 26, 2026): Integrated as described under Integration, with three differences. In
tw convert, what counts as math is decided by the Markdown engine's own dollar rules (pulldown-cmark or comrak), not byfind_math, so with the default engine some prices, such as "$5-$10", can become MathML. Fenced code blocks markedamcount as ASCIIMath as well asasciimath. The GUI spike andtw speakdo not passmath_verbosityorasciimath_delimiteron. The app has no math exploration mode yet:Navigatoris ready in the library, but no reader key uses it. -
Status update (Saturday, September 26, 2026): the Markdown loader marks math (
Mathmarkers), and every writer typesets it.tw speakreads the settings now, so it followsmath_verbosityandasciimath_delimiter; the GUI spike still does not. The reader still has no math exploration mode. -
Status update (Sunday, September 27, 2026): MathCAT can speak math instead, behind the
mathcatfeature and the[reading] math_enginesetting, with this engine as the default and the fallback (ADR-0029).speak_text_withasks another engine first for each region. EPUB 3 MathML is read as math, through LaTeX under aMathmarker.
Context
Students meet math in LaTeX ($…$ in Markdown, Pandoc, and papers) and in ASCIIMath (course platforms). star handled it twice, separately. Its speech path (star/ttstext/mathspeech.py) is a chain of regular expressions that runs over all text, so snake_case became "snake sub case", [^1] became "to the 1", \$3.99 lost its "three", and $5 and $10 lost its dollar signs (star bugs Q6 to Q8). Its display path (star/mathrender.py) turned LaTeX into Unicode approximations. Neither kept a mapping from spoken words to the source, and neither understood structure, so a fraction inside a root inside a fraction could not be read unambiguously or explored.
The primary user reads by ear. Spoken math has to be natural, precise enough to write the expression back down, and navigable part by part.
Decision
A standalone crate, textweaver-math, depending only on core, thiserror, and serde, with hand-written parsers.
One tree for both notations. parse_latex and parse_asciimath produce a Math whose Nodes each carry the char range of their source. Node kinds: row, number, identifier, function, operator (with a class: binary, relation, large, open, close, fence, punctuation, prefix, postfix), text, space, fraction (with or without bar), root, scripts (side or limits), fenced, accent, table (matrix, cases, aligned, array), style, enclosure, and error. Both parsers are total: every input yields a tree, recovery is recorded as Diagnostics, Math::strict turns them into a MathError, nesting deeper than 48 levels becomes error nodes, and property tests (20,000 cases run locally) show no panics.
- LaTeX: the common subset: groups, scripts and primes,
\fracfamily,\binom,\sqrt[n], 264 symbol commands including Greek letters (plus aliases such as\le), big operators with\limits/\nolimits, named functions and\operatorname,\left…\middle…\right,\bigsizes, environments (matrix,pmatrix,bmatrix,Bmatrix,vmatrix,Vmatrix,smallmatrix,array,cases,rcases,aligned,align,gather,split,equation),\\line breaks,\text, fonts, accents,\overset/\underset,\not,\pmod,\cancel,\boxed, spacing,\over/\choose. Brackets typed as characters ((a+b),|x|,\lfloor x \rfloor) are paired into fenced nodes after each row, and scripts on a closing bracket move to the whole group. - ASCIIMath: the grammar and full symbol table of the reference
ASCIIMathML.js, including the LaTeX names it copies in (378 inputs), longest-match tokens, bracket removal around arguments,I/Ifractions, matrices from[[a,b],[c,d]], and cases from{(…),(…):}. A unit test parses every table entry.
MathML. to_mathml writes presentation MathML with alttext (the source by default, or spoken English) and a <semantics> annotation holding the source. Display math puts sum and product limits below and above; integrals keep side limits.
Speech. speak produces ClearSpeak-style English at three verbosity levels, built with SpokenBuilder (ADR-0005): words equal to their source are literal spans, words standing for source are expanded spans, structural words are inserted. Low is terse ("a over b", "x to the n"); normal is the default and follows ClearSpeak ("the fraction with numerator x plus 1 and denominator 2", "x to the n-th power", "the absolute value of x"); high adds articles, end markers, "cap" before capital letters, font names, and column numbers. End markers are otherwise spoken only when a part is not simple and something follows it. Where English order differs from source order ("x hat" for \hat{x}), the out-of-order word becomes an inserted span so the map stays valid. The wording table is in the speech module's documentation.
Navigation. Navigator moves through the tree: next and previous part, first and last, into a part or into a named role (numerator, denominator, index, radicand, base, subscript, superscript, lower and upper limit, contents, table cell), and out. Each move returns a NavStep with the role, the part spoken on its own with its map, and its source span, or None at a boundary.
Detection. find_math finds $…$, $$…$$, \(…\), \[…\], and, when enabled, ASCIIMath between a configurable delimiter (a backtick by convention; off by default because a backtick in Markdown is code). Inline dollars follow Pandoc's tex_math_dollars rule: a non-space after the opening $, a non-space before the closing one, no digit right after it, and only the first following $ is a candidate. \$ never delimits; no pair crosses a blank line. speak_text is the normalization transform: it replaces each region with its spoken form, elides the delimiters, and leaves all other text literal, so prose is never treated as math.
Integration
Done (2026-09-25) as planned below, with these specifics: the speech Math transform runs first in the pipeline, before Markdown residue and the lexicons as well as abbreviations and numbers, so every later step sees words rather than LaTeX; it still reads a few operator symbols in prose (×, ≤, →) as star did. Settings are [normalization] math_verbosity (low, normal, high) and asciimath_delimiter (a single character, unset by default). The renderer calls parse_latex or parse_asciimath and to_mathml (what latex_to_mathml does), but shows a formula the parser had to repair as its source in <code class="math-error">, as it did with pulldown-latex. ASCIIMath reaches the renderer from code fences marked asciimath and, opt-in, from inline code spans. pulldown-latex is gone from the workspace.
- Speech pipeline. Replace the regex rules in
textweaver-speech/src/normalize/math.rswith aTransformwhoseapplyistextweaver_math::speak_text(input, &TextOptions { detect, speech: SpeechOptions::new(verbosity) }); the returned(String, OffsetMap)already maps output bytes to input chars. Run it beforeNumbers(and abbreviations):Numbersturns$2into currency words, which destroys$2x$, and math speech already leaves digits literal for the engine or forNumbersto read. TakeDetectOptions::asciimathand the math verbosity from settings ([normalization] math_verbosity,asciimath_delimiter). Keepmathin the pipeline for engines that normalize natively; no engine reads LaTeX. - Renderer. Enable pulldown-cmark's
ENABLE_MATHand replacepulldown-latexwithtextweaver_math::latex_to_mathml(src, display)forEvent::InlineMathandEvent::DisplayMath(comrak: the math nodes' literal text anddisplay_mathflag). For ASCIIMath spans in Obsidian or course material,asciimath_to_mathml. Useto_mathmlwithMathMlOptions { alttext: AltText::Speech(Verbosity::Normal), .. }if screen-reader users without MathML support should hear English rather than LaTeX. The output is a single<math>element; no stylesheet or script is needed. - App (later). A math-exploration mode can hold a
Math(fromfind_mathat the cursor plusparse), drive aNavigatorfrom the arrow keys, speakNavStep::announcement, and highlightNavStep::spanshifted by the region's content start.
star parity
star's math vectors (tests/test_ttstext.py) pass at normal verbosity, with two deliberate wording changes: \alpha + \beta is "alpha plus beta" (star left + to the engine, which drops it at low punctuation levels) and \bar{x} is "x bar" (star: "x-bar"). Its undelimited vectors (x^2 and y^{3}) are math only when delimited, as in documents ($x^2$ and $y^{3}$ → "x squared and y cubed"). Bugs fixed: Q6 (subscripts in prose), Q7 (trailing x^2), Q8 (global brace stripping, space collapsing, and $5 and $10), and the Pandoc \$3.99 case; each has a test.
Consequences
- Highlighting and pause/resume are exact inside math, like everywhere else.
- Math speech is independent of the engine: digits and letters reach the engine literally; everything structural is words.
- The parsers are ours to extend. Unsupported LaTeX is spoken by its command name and reported, not dropped silently.
- The wording is ClearSpeak-like but not a full ClearSpeak implementation (no preference settings for trigonometry, powers, or sets beyond the three levels). A MathCAT integration could replace
speechlater behind the sameSpokenoutput if its wording is preferred.
See also
- Math: the user guide to reading and writing math.
- Speech pipeline, step by step: the offset map of
$x^2$read as "x squared". - ADR-0005: Narration and the OffsetMap: why highlighting stays exact.
- Documentation index