fix(markdown): hide math delimiters from comrak instead of patching characters - #402
Merged
Merged
Conversation
…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 &= 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>
Collaborator
Author
|
Rebased onto 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. Its dependency on #389 is satisfied now — the line-contract registry this PR registers |
PathGao
force-pushed
the
fix/math-delimiter-masking
branch
from
August 2, 2026 22:30
6fae03a to
3d74d54
Compare
This was referenced Aug 3, 2026
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #174, #197, #177 — one bug reported three times.
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:
$\bar{b}_{1} + \bar{b}_{2}$$\bar{b}<em>{1} + \bar{b}</em>{2}$a &= b \\in analignedblocka &= b \— every row separator eaten, block collapses to one line$$\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_underscorescovered 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 hereKaTeX's own auto-render refuses to make this call.
contrib/auto-render.jsships$$…$$,\(…\),\[…\]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 frontend —
convertInlineMathDelimiters/findInlineMathEndinmarkdown.tsdecide what a user actually sees rendered. The backend now uses that rule:$may not be escaped, may not follow an unconsumed$, and may not be followed by whitespace;$100 and $200the 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:
hardbreaksputs each source line in its own text nodeprocessInlineMathskipsCODE/PREsubtreesprocessDisplayMathBlocksonly renders the block form$$pairs only when both delimiters sit alone on their lineThe mask
MPMATHMASK<n>E, one token per line of a span.\u{E000}sentinel is unsafe in a link destination — comrak percent-encodes it, so[t](http://x/$a$)came back ashttp://x/%EE%80%80….[A-Z0-9]survives text nodes,altvalues andhrefs verbatim and carries no CommonMark meaning.id=andhref="#…". Restore puts the anchorized source there instead — without this,[[#A heading with $x_1$]]broke silently.&<>"— established empirically; comrak does not escape'), which the frontend undoes viatextContent.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:markdownandmathare hand-authored: they state what should happen, so a corpus that merely records current behaviour cannot occur. Neither implementation is the reference.htmlis a live capture of realconvert_markdownoutput, and a Rust test asserts it still matches — that is what stops the fixture from rotting.processMarkdownHtml, and its extractor re-implements nothing: it reads the frontend's verdict only from artifacts the frontend alone produces —\(…\), emitted only byconvertInlineMathDelimiters, anddata-math-source, set only byprocessDisplayMathBlocks. An extractor that parsed$…$itself could agree with a broken implementation.Both directions proven red:
left: [("inline", "100")] right: []htmland leavemarkdown/mathaloneMisjudgement 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.
Not covered
$$x$$mixed into prose is passed through verbatim byconvertInlineMathDelimiters, 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 asdata-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.<code>element either, so both sides decline together.$$block ends the CommonMark block and ends the search; the frontend cannot render across paragraphs either.🤖 Generated with Claude Code