Skip to content

fix(markdown): hide math delimiters from comrak instead of patching characters - #402

Merged
PathGao merged 1 commit into
sftwrdotdev:masterfrom
PathGao:fix/math-delimiter-masking
Aug 2, 2026
Merged

fix(markdown): hide math delimiters from comrak instead of patching characters#402
PathGao merged 1 commit into
sftwrdotdev:masterfrom
PathGao:fix/math-delimiter-masking

Conversation

@PathGao

@PathGao PathGao commented Aug 2, 2026

Copy link
Copy Markdown
Collaborator

Fixes #174, #197, #177one bug reported three times.

Stacked on #389 (fix/line-count-preserving-transforms). Merge that first; this branch registers its new step in the line-contract registry #389 introduces.

The root cause

comrak keeps running CommonMark inline rules inside math delimiters, so the formula KaTeX finally receives has already been rewritten. Verified against the current baseline:

issue input what comrak hands KaTeX
#174 $\bar{b}_{1} + \bar{b}_{2}$ $\bar{b}<em>{1} + \bar{b}</em>{2}$
#197 a &= b \\ in an aligned block a &amp;= b \ — every row separator eaten, block collapses to one line
#177 $$\mathbf{100\%}$$ $$\mathbf{100%}$$ — bare % opens a TeX comment

#174's reporter noticed "only the first subscript can use {}" (a lone _ cannot pair) and "a space before it fixes it" (a space makes the _ non-left-flanking). Both observations are exactly this mechanism.

protect_display_math_underscores covered the intersection of two narrow cases — only _, only $$ — and settled none of the three. Patching one character class at a time cannot win, because Markdown has no business parsing TeX. The whole span is now masked before comrak runs and restored afterwards, and that function is deleted.

The inline $…$ rule is not invented here

KaTeX's own auto-render refuses to make this call. contrib/auto-render.js ships $$…$$, \(…\), \[…\] and the AMS environments, with $…$ commented out in the source and a note that LaTeX uses it but it ruins the display of ordinary $ in text. Upstream's position is that single-$ needs product-specific disambiguation.

Markpad already made that decision, in the frontendconvertInlineMathDelimiters / findInlineMathEnd in markdown.ts decide what a user actually sees rendered. The backend now uses that rule:

  • an opening $ may not be escaped, may not follow an unconsumed $, and may not be followed by whitespace;
  • the first candidate closer decides — a candidate preceded by whitespace or followed by a digit abandons the span rather than being skipped. That is the currency guard: in $100 and $200 the only candidate closer is preceded by a space, so nothing is math;
  • $$ is tried before $, with an adjacent-span flag so $a$$b$ works.

No frontend change was needed — the backend moved to the frontend's rule, not the other way round. That direction is deliberate: the frontend's rule is already shipped and existing documents depend on it; the backend's was new and nothing depended on it. Move the side with no installed base.

It is also aligned to what the frontend can physically see, not just what it computes:

Frontend fact Backend consequence
hardbreaks puts each source line in its own text node a span never crosses a line break
processInlineMath skips CODE/PRE subtrees a span never crosses an inline code span
processDisplayMathBlocks only renders the block form multi-line $$ pairs only when both delimiters sit alone on their line

The mask

MPMATHMASK<n>E, one token per line of a span.

  • Plain ASCII, not a private-use character. The previous \u{E000} sentinel is unsafe in a link destination — comrak percent-encodes it, so [t](http://x/$a$) came back as http://x/%EE%80%80…. [A-Z0-9] survives text nodes, alt values and hrefs verbatim and carries no CommonMark meaning.
  • Unique by construction: the prefix grows until it does not occur in the document, checked case-insensitively.
  • Line count preserved (fix(markdown): keep every preprocessing step on the same source line #389's contract): one token per line, indentation left outside the token so a formula in a list item keeps belonging to it.
  • Heading anchors: comrak derives heading ids from rendered text, so a masked span also appears lowercased inside id= and href="#…". Restore puts the anchorized source there instead — without this, [[#A heading with $x_1$]] broke silently.
  • Restore re-escapes exactly the four characters comrak escapes in a text node (&<>" — established empirically; comrak does not escape '), which the frontend undoes via textContent.

Code regions are excluded from both openers and closers, and a multi-line $$ search aborts on any code region. The old parity hazard — a lone $$ inside a fence flipping the odd/even split for the rest of the document — is gone by construction, because there is no parity any more.

The invariant is pinned, not remembered

Correctness here depends on backend and frontend recognising the same spans, and nothing enforced that. scripts/mathDelimiterCorpus.json — 24 cases of {name, markdown, html, math[]} — is what both are now asserted against:

  • markdown and math are hand-authored: they state what should happen, so a corpus that merely records current behaviour cannot occur. Neither implementation is the reference.
  • html is a live capture of real convert_markdown output, and a Rust test asserts it still matches — that is what stops the fixture from rotting.
  • The TS half runs the real processMarkdownHtml, and its extractor re-implements nothing: it reads the frontend's verdict only from artifacts the frontend alone produces — \(…\), emitted only by convertInlineMathDelimiters, and data-math-source, set only by processDisplayMathBlocks. An extractor that parsed $…$ itself could agree with a broken implementation.
  • A further test requires ≥10 must-be-math and ≥10 must-not-be-math cases, so neither "recognise nothing" nor "recognise everything" can pass.

Both directions proven red:

Mutation Result
frontend: drop the "closer may not be followed by a digit" guard TS test fails — prices run together: the frontend and the contract disagree about what is math
backend: drop the same guard Rust test fails — left: [("inline", "100")] right: []
any backend change that alters rendering live-capture test fails, with instructions to replace html and leave markdown/math alone

Misjudgement guards

Treating prose as math is worse than not protecting it, so that was the primary constraint. Every one of these asserts the input is not math and renders normally: $100 and $200 · $5 · $5–$10 · $100$200 · a lone $ · a trailing $ · $ 5 · \$100 · $$ inside a fence · `$a$` · a span reaching across an inline code span · a span reaching across a line break · prices on consecutive lines · an unpaired $$ in one paragraph vs another two paragraphs later.

Falsification of the three issues (implementation reverted, tests kept): 117 pass / 5 fail → 123 pass / 0 fail.

npm run check   432 files, 0 errors, 0 warnings
npm test        403 / 403
cargo test      123 / 123
cargo clippy    3 warnings, identical to baseline

Not covered

  • A same-line $$x$$ mixed into prose is passed through verbatim by convertInlineMathDelimiters, so the frontend's verdict on it is not observable from the test without re-implementing the rule — which would defeat the point. Display cases in the corpus use the block form, whose verdict surfaces as data-math-source. That sub-case is guarded on the Rust side only, and the corpus header says so.
  • \(…\) / \[…\] are deliberately not handled. Same root cause, but CommonMark eats the backslash before the frontend sees a delimiter, so they have never worked at all; masking them would start claiming text no released version treated as math. Marked in the code as a decision, not an oversight.
  • A paired pair of backticks inside a span still wins as a code span — the frontend cannot pair delimiters across a <code> element either, so both sides decline together.
  • A blank line inside a $$ block ends the CommonMark block and ends the search; the frontend cannot render across paragraphs either.

🤖 Generated with Claude Code

…haracters

Fixes sftwrdotdev#174, sftwrdotdev#197, sftwrdotdev#177 - one bug reported three times. comrak keeps
running CommonMark inline rules inside math delimiters, so the formula
KaTeX finally receives has already been rewritten:

  $\bar{b}_{1} + \bar{b}_{2}$  ->  $\bar{b}<em>{1} + \bar{b}</em>{2}$
  a &= b \\  (aligned)         ->  a &amp;= b \      rows collapse to one
  $$\mathbf{100\%}$$           ->  $$\mathbf{100%}$$  % opens a TeX comment

`protect_display_math_underscores` covered the intersection of two narrow
cases - only `_`, only `$$` - and settled none of the three. Patching one
character class at a time cannot win, because Markdown has no business
parsing TeX. The whole span is now replaced by an opaque token before
comrak runs and restored afterwards, and that function is deleted.

The inline `$…$` rule is not invented here. KaTeX's own auto-render ships
with `$…$` commented out, noting it ruins ordinary `$` in text, so the
disambiguation is a product decision - and Markpad already made it, in
`findInlineMathEnd`. The backend now uses that rule, so no frontend change
was needed. It also matches what the frontend can physically see: never
across a line break (hardbreaks put each source line in its own text
node), never across an inline code span (processInlineMath skips
CODE/PRE), and multi-line `$$` only when both delimiters sit alone on
their line.

The mask is plain ASCII with a prefix that grows until it does not occur
in the document. The previous private-use sentinel was unsafe in a link
destination, where comrak percent-encodes it. Restore also puts the
anchorized source into heading ids, since comrak derives them from
rendered text - without that, `[[#A heading with $x_1$]]` broke silently.

Backend and frontend agreeing is now pinned by a hand-authored corpus
both are asserted against, rather than by one careful alignment.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@PathGao

PathGao commented Aug 2, 2026

Copy link
Copy Markdown
Collaborator Author

Rebased onto 72d63db; the conflict is gone.

Cause: this branch was stacked on #389, which was squash-merged. The squash produced a new SHA, so the branch still carried #389's original commit and git saw the same changes twice. git rebase dropped it by patch-id (one commit in, one commit out), leaving just this change on top of current master.

Its dependency on #389 is satisfied now — the line-contract registry this PR registers mask_math_spans in is on master.

npm run check   432 files, 0 errors, 0 warnings
npm test        421 / 421
cargo test      123 / 123

@PathGao
PathGao force-pushed the fix/math-delimiter-masking branch from 6fae03a to 3d74d54 Compare August 2, 2026 22:30
@PathGao
PathGao merged commit e297506 into sftwrdotdev:master Aug 2, 2026
4 checks passed
@PathGao
PathGao deleted the fix/math-delimiter-masking branch August 2, 2026 22:46
PathGao added a commit that referenced this pull request Aug 3, 2026
…h inside a paragraph (#422)

Two holes on the same seam, one of them in the contract #402 built.

`\$\$x\$\$` renders as math. comrak un-escapes the backslashes before the
frontend sees anything, so `\$\$x\$\$` and `$$x$$` arrive as the same
eight bytes and no rule reading comrak's output can tell them apart. The
backend gets it right - `find_math_spans` returns nothing for the escaped
form - and then throws the evidence away. Confirmed end to end by running
KaTeX's real auto-render with `katex.render` instrumented: `["display:x"]`.
The same is true of inline `\$x\$`.

`$$x$$` inside a paragraph was masked by the backend but passed through
verbatim by `convertInlineMathDelimiters`, and rendered by a fourth
channel the contract cannot see - `renderMathInElement`'s own `$$`
delimiter. The corpus had thirteen display cases and every one of them
was the block form, so the test was green on a path it never looked at.

Fixing the escape in the frontend alone is not possible, and the corpus
now demands both an inline-`$$` positive and an escaped negative, which
forces the backend to preserve what the frontend needs. So the mask now
covers `\$` runs as well as math spans and restores them verbatim,
handing the decision back to the side the user can see - the same
direction #402 chose. The frontend then takes over paragraph-level `$$`,
emitting `\[…\]`, and `$$` is removed from KaTeX's delimiter list.

That makes the equality argument structural rather than enumerated: every
delimiter KaTeX now accepts - `\(…\)`, `\[…\]`, `data-math` - can only be
minted by markdown.ts, and CommonMark cannot spell any of them, so no
fourth channel exists. A test asserts the delimiter list directly.

The `text[index - 1] === "\\"` branch in `convertInlineMathDelimiters`
turns out never to have run: comrak had already eaten the backslash. It
became live when #402 ported the rule to Rust, where the raw markdown
still has it.

All fourteen misjudgement guards stay green and the corpus `math` lists
are unchanged.

Co-authored-by: PathGao <gaoyanbo@gaoyanbodeMacBook-Air.local>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

math rendering problem with \bar, \vec and possibly others

1 participant