Skip to content

Math not rendered for \[...\] / \(...\) delimiters: CommonMark escaping strips the backslashes before ENABLE_MATH sees them (follow-up to #101) #239

Description

@junshui

Summary

Math rendering works, but only for $…$ and $$…$$. When a model emits the LaTeX-style delimiters \[…\] and \(…\) instead — which several of them do by default — the formula is not just left as source: it is actively mangled. CommonMark treats \[ as a backslash escape of an ASCII punctuation character and emits a bare [. The delimiter is destroyed before the math extension ever gets a chance to see it.

The result is a distinctive, lopsided kind of damage. The delimiters lose their backslashes, but the body of the formula keeps every one of its own, because letters are not escapable in CommonMark. So \[ becomes [ while \qquad, \boxed, \frac, \sqrt, \Delta and \pm all survive intact, sitting in the transcript as plain text.

This is a follow-up to #101, not a duplicate — see the last section.

The symptom

Seen in the desktop app on Windows 11. A reply containing display and inline math rendered as literal text, in this shape:

[
ax^2+bx+c=0,\qquad a\ne 0
]

The discriminant is (\Delta=b^2-4ac):

[
\boxed{x=\frac{-b\pm\sqrt{b^2-4ac}}{2a}}
]

A bare [ on its own line, the LaTeX body untouched, a bare ], and (\Delta=b^2-4ac) inline. The model had emitted ordinary \[…\] and \(…\).

Environment

  • Symptom observed on the desktop app on Windows 11
  • Mechanism verified on Ubuntu 22.04.5 LTS against the repo at ae49e6e
  • pulldown-cmark 0.13.4 (Cargo.toml:47 resolves to that in Cargo.lock)
  • apps/web dependency tree as installed from apps/web/package.json

Where it comes from

The desktop parser enables the math extension:

// src/md/parser.rs:144-149
fn options() -> Options {
    Options::ENABLE_TABLES
        | Options::ENABLE_STRIKETHROUGH
        | Options::ENABLE_TASKLISTS
        | Options::ENABLE_MATH
}

…and that is what parses the source, at src/md/parser.rs:153:

let events = Parser::new_ext(source, options())

ENABLE_MATH in pulldown-cmark 0.13.4 recognises $…$ and $$…$$ only. It has no notion of \[…\] or \(…\). That alone would leave the formula as source text, which would be tolerable. The escaping is what makes it worse than that: [, ], ( and ) are ASCII punctuation, so per CommonMark a preceding backslash is an escape and the backslash is consumed.

I also checked for a normalization step that might translate one delimiter style into the other before parsing, and there is none — nothing in src/, crates/, apps/web/src or packages/*/src rewrites math delimiters. The streaming repair pass is dollar-only too; src/md/mend.rs:89-91 tracks $ and is explicit about why:

if ch == '$' {
    // TeX brackets, underscores and backticks are not Markdown
    // delimiters. Never synthesize a closer inside unfinished math.

So a \[ that has arrived without its \] yet is not recognised as unfinished math either.

Probe: the same input under both delimiter styles

To make sure I was blaming the right layer, I built a throwaway crate depending on pulldown-cmark = "=0.13.4" — the exact pinned version — with the exact same four option flags, and dumped the event stream. Nothing in Waku was modified.

=== display math, LaTeX delimiters  \[ ... \] ===
input : "\\[\nax^2+bx+c=0,\\qquad a\\ne 0\n\\]"
  Start(Paragraph)
  Text          "["
  SoftBreak
  Text          "ax^2+bx+c=0,\\qquad a\\ne 0"
  SoftBreak
  Text          "]"
  End(Paragraph)

=== inline math, LaTeX delimiters  \( ... \) ===
input : "The discriminant is \\(\\Delta=b^2-4ac\\):"
  Text          "The discriminant is "
  Text          "(\\Delta=b^2-4ac"
  Text          "):"

=== display math, dollar delimiters  $$ ... $$ ===
  DisplayMath   "\nax^2+bx+c=0,\\qquad a\\ne 0\n"        <-- math event

=== inline math, dollar delimiters  $ ... $ ===
  InlineMath    "\\Delta=b^2-4ac"                        <-- math event

=== boxed formula with \[ \] ===
input : "\\[\n\\boxed{x=\\frac{-b\\pm\\sqrt{b^2-4ac}}{2a}}\n\\]"
  Text          "["
  SoftBreak
  Text          "\\boxed{x=\\frac{-b\\pm\\sqrt{b^2-4ac}}{2a}}"
  SoftBreak
  Text          "]"

That reproduces the transcript above byte for byte. The identical content in dollars produces a real DisplayMath / InlineMath event, which is what the rest of the pipeline is waiting for — Block::DisplayMath at src/md/parser.rs:103, painted at src/md/render.rs:1379, typeset by src/md/math.rs:237 via ratex_parser::parse at :246.

Worth stressing: the typesetting side is in good shape. RaTeX is pinned at Cargo.toml:49-52 with a comment about keeping layout and raster work on workers, there is a typeset cache, a MAX_SOURCE_BYTES guard at src/md/math.rs:239, and inline math even has a copy-as-LaTeX menu item at src/md/render/math_text.rs:371. None of that is reachable for these formulas. It is a delimiter problem, not a rendering problem.

The web client has no math at all

The two clients do not share a renderer, and this is worth knowing because it means a desktop user loses working formulas by opening the same session in a browser.

apps/web/src/components/transcript.tsx:1393-1394:

<ReactMarkdown
  remarkPlugins={[remarkGfm]}

That is the entire plugin set. apps/web/package.json lists react-markdown and remark-gfm and nothing else in this area — no remark-math, no rehype-katex, and no KaTeX, MathJax or Temml anywhere in the repo. I ran the same delimiter probe through the web client's own installed dependency tree with that same plugin set:

=== display math, dollars  $$ ... $$ ===
  root  paragraph  text("$$\nax^2+bx+c=0\n$$")    <-- stays literal, no math node
=== inline math, LaTeX  \( ... \) ===
  root  paragraph  text("The discriminant is (\\Delta=b^2-4ac):")

So on web, \[…\] is mangled and $$…$$ is left as raw source.

Desktop Web
Renderer Rust src/md/* — pulldown-cmark 0.13.4 + RaTeX react-markdown 10 + remark-gfm
$$…$$ typeset literal text
\[…\] […], backslashes stripped […], backslashes stripped

What is proven and what is inferred

Being precise about this, because the two halves were established differently:

  • Proven: the parser behaviour on both pipelines, using the pinned library versions and the exact option flags each surface uses, reproducing the reported output exactly. Also proven by reading the sources that the desktop has a working $-delimited math path and the web client has none.
  • Inferred: the appearance inside the running UI. I did not reproduce this live in either client, because that needs an assistant message containing math, i.e. a real model run.
  • Inferred for Windows specifically: I verified the mechanism on Linux. The markdown pipeline is platform-independent Rust with no OS-conditional code, so all three platforms parse identically, and the reported output matching the probe byte for byte is strong corroboration — but I should say plainly that I did not run it on Windows myself.

Suggested fix

Normalize the delimiters before they reach the parser, in a pre-pass ahead of Parser::new_ext at src/md/parser.rs:153: rewrite \[…\] to $$…$$ and \(…\) to $…$. Everything downstream then works unchanged, because it is already correct for dollars.

Two things a naive version would get wrong, in case they are useful:

  • Don't rewrite inside code. A rewrite that runs blindly over the whole source will corrupt \[ inside inline code spans and fenced blocks, where it is legitimately literal. Scanning for fences and backticks first, or doing the substitution during parsing rather than on the raw string, avoids that.
  • Teach the streaming repair the same delimiters. src/md/mend.rs currently only knows $, so a \[ whose closer has not streamed in yet would need the same "unfinished math, don't close it" treatment it already gives $$.

For the web client this is a separate piece of work: adding remark-math plus a math renderer, or at minimum accepting $/$$ so the two surfaces agree. Even without the LaTeX-delimiter fix, the fact that $$…$$ renders on desktop and not in the browser is a visible inconsistency.

A lower-effort mitigation, if a full fix is not wanted: keep the backslashes. Rendering \[…\] as literal \[…\] rather than […] at least leaves the source copy-pasteable into something that can typeset it, instead of silently corrupting it.

Why this is not a duplicate of #101

#101 ("Render LaTeX math in assistant messages") is the origin of the feature that shipped — it names RaTeX as a candidate, and RaTeX is what Cargo.toml now pins. Its scope is explicit:

  • Recognize $...$ as inline math and $$...$$ as display math.

\[…\] and \(…\) were never in scope there, and #101 says nothing about backslash escaping — which is the part that makes this a corruption rather than just an unsupported syntax. So the delimiter gap is a follow-up to a feature that landed and works, not a report that the feature is missing.

(Also relevant from #101's own out-of-scope list: "Rendering in reasoning or tool output". So math inside tool output will not typeset even with dollar delimiters. I am not asking for that here, only noting it so this issue is not read as covering it.)

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions