feat/lc-59-math #193
Loading…
Reference in a new issue
No description provided.
Delete branch "feat/lc-59-math"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Summary
Server-rendered LaTeX math in message bodies. Inline
$x^2$and display$$\int_0^1 f(x)\,dx$$typeset to MathML viapulldown-latex(pure Rust, no JS engine, noclient-side dependency, no separate stylesheet). MathML is natively accessible to
screen readers and renders via the browser's built-in math layout, fitting the existing
server-renders-HTMX-swaps architecture. Detection runs inside
markdown::render_inner'sEvent::Textbranch, so code spans and code blocks remain literal by the samemechanism that already protects mentions and emoji.
Decision log (load-bearing surfaces)
Dependency choice: pulldown-latex pinned to
=0.7.1. Only candidate that satisfiesevery hard constraint (no client JS, no runtime JS engine, no separate stylesheet,
SR-accessible via MathML, pure Rust).
katex-rs(xu-cheng) requires a JS engine atruntime and HTML+CSS output, ruling it out on two of the constraints.
mathyankdoesn'texist on crates.io; the closest analogue (
RaTeX) outputs Canvas/PNG/SVG (image-based,ticket explicitly rejected). The pin is exact (
=, not^) so a routinecargo updatecannot upgrade. Re-run the LC-59 spike before merging any version bump.Macro recursion is a process-abort vector. The blocklist is the primary mitigation.
Spike-confirmed:
\def\x{\x\x}\xstack-overflows the parser, and Rust's runtimestack-overflow handler calls
abort()unconditionally - uncatchable bycatch_unwind,and a bounded-stack worker thread does NOT contain it either.
views::math::blocklist_rerejects any span containing\def,\edef,\xdef,\gdef,\let,\futurelet,\newcommand,\renewcommand,\providecommand,\href,\url,\csname. Word-boundary regex, whitespace-tolerant.\letisload-bearing: aliasing closes the chain. Denylist, not a proof - strong but
version-pinned.
Deep nesting is safe at any reasonable depth. Parser is iterative, not call-stack
recursive - spike-confirmed to depth 6400.
Four failure modes converge on literal-fallback: caps exceeded, blocklist match,
push_mathmlreturnsErr, output contains<merror>. Plus a fifth:catch_unwindaround the render call catches non-stack-overflow panics. Per-render
Storage+Parser= no shared state.Display-mode CSS.
math[display="block"] { margin: 0.5rem 0; }. We do NOT setdisplay: blockon display math: that CSS property overrides the browser's nativeMathML layout and causes children to stack vertically (eyeball-discovered). The
display="block"ATTRIBUTE already makes the element a centered block via nativerendering.
Unplanned shared-behavior change: the Text-event merge
pulldown_cmarksplits a single text run into multipleEvent::Textevents atbackslash-escape boundaries. Math detection per-event therefore could not find any span
containing a LaTeX escape. The fix merges consecutive
Event::Textevents into onebefore the inline-rewrite filter_map runs. Safe across code-block boundaries by
construction.
The merge ALSO changes how mention / emoji / URL detection sees text at backslash
boundaries: a single contiguous string rather than two halves. Mentions: benign. URLs:
common shape works identically. See known-limitations for one edge case.
Known limitations
https://example.com\!endbecomeshttps://example.com!endafter cmark, whichlinkifyrejects. Pre-merge would have found the URL in the first half-event. Pinned bybare_url_abutting_backslash_escape_no_anchor_documented_limit. Not chat-realistic;text is preserved (not lost, not corrupted, not a security hole).
$x_*y*$) is not detected.Single-text-event scoped.
$in chat text (\$) is not a literal-dollar escape. Userswanting a literal dollar should use a code span.
Test plan
the sample below into a room. Pass criterion: every typeset case visually correct.
Specifically verify the corrected display-CSS: the display integral renders horizontal
and centered, NOT as a vertical character-by-character stack.
Sample message for the hand-test:
Automated coverage in this PR: 41 math-module unit tests, 9 markdown integration tests,
4 route-level round-trip tests.
Files
server/Cargo.tomlpulldown-latex = "=0.7.1"exact pinserver/src/views/math.rs(new)catch_unwind,<merror>detection, 41 testsserver/src/views/mod.rspub mod math;server/src/views/markdown.rsserver/assets/main.cssserver/tests/routes_math_round_trip.rs(new)Branch is pushed and both commits are in:
081543cfeat(messages): server-side LaTeX -> MathML rendering module (LC-59) (module +dep + CSS)
469dbd8feat(messages): wire LaTeX math into markdown pipeline + round-trip tests(LC-59) (wiring + tests, with the merge call-out)
Adds `views::math` for detecting inline `$...$` and display `$$...$$` math spans in message bodies and rendering them to MathML via `pulldown-latex`. This commit only introduces the module, the pinned dependency, and the CSS rule; the markdown-pipeline wiring lands in the follow-up commit so the chokepoint surface can be reviewed in isolation. Module shape: - `render_math_in_text` scans text into Text/Math chunks via pandoc-style boundary rules (open follows non-whitespace, close precedes non-whitespace, close not followed by digit, content non-empty and `$`-free). Non-math chunks route through the existing `render_body` pipeline unchanged; math chunks render to MathML. - Three caps bound per-message work: `MATH_MAX_SPAN_CHARS = 1024`, `MATH_MAX_SPANS_PER_MESSAGE = 32`, `MATH_MAX_TOTAL_CHARS = 4096`. They count attempts, not successes, so a flood of blocklist-hit or malformed spans cannot consume arbitrary work. - `try_render_math` is the single render boundary: caps + blocklist + per-render `Storage` + `push_mathml` + `<merror>` substring scan + `catch_unwind`. All five failure modes converge on `None`; the caller substitutes literal escaped text including the original `$` delimiters. Safety story (the load-bearing surface): - `pulldown-latex` 0.7.1 implements `\def`-family macros whose unbounded expansion stack-overflows the process. Stack overflow is uncatchable by `catch_unwind` in Rust (the runtime's handler is unconditional abort()), and bounded-stack worker threads do not help either - confirmed empirically in the LC-59 spike. Macro recursion is therefore prevented PRE-RENDER by a control-sequence blocklist: `\def`, `\edef`, `\xdef`, `\gdef`, `\let`, `\futurelet`, `\newcommand`, `\renewcommand`, `\providecommand`, plus `\href`, `\url`, `\csname` as future-proofing. Word-boundary regex so `\definecolor` is not blocked, whitespace-tolerant so `\def \x` is blocked (spike-confirmed evasion). - `\let` is load-bearing in the blocklist, not optional: the spike confirmed `\let\foo=\def \foo\x{a}\x` is a real alias-evasion against a `\def`-only blocklist. - It is a denylist, validated against pulldown-latex 0.7.1's specific macro surface and the evasions tested in the spike. Strong, not a proof. The dependency is pinned to `=0.7.1` (exact pin, not caret) so a routine `cargo update` cannot silently invalidate it; re-run the spike before merging any version bump. - Deep nesting (`\frac`, `\sqrt`, braces) is safe at any depth the length cap permits: the parser is iterative, not call-stack recursive on input structure - spike-confirmed to depth 6400 with no overflow. CSS rule (assets/main.css): - `math[display="block"] { margin: 0.5rem 0; }` and `math { vertical-align: -0.15em; }`. We do NOT set `display: block` on display math: that CSS property overrides the browser's native MathML layout and causes children to stack vertically. The `display="block"` ATTRIBUTE (which pulldown-latex sets) already makes the element a centered block via native rendering. Eyeball-confirmed in WebKit2GTK; the WebView2 / WKWebView confirmation is the pre-merge hand-test gate (Chromium's MathML Core handles display-mode layout slightly differently from WebKit). Tests: 41 unit tests under `views::math::tests` covering scan (basics + adjacent-delimiter battery + pandoc boundary rules + best-effort recovery on malformed display), the render boundary (success, oversize, merror), the blocklist (every entry hits, `\definecolor` does NOT match, whitespace evasion blocked, `\let` aliasing blocked), and integration smoke (pure-text fast-path, math+mention coexistence, `$@user$` mutual-exclusion, cap enforcement, literal-fallback). Wiring into `markdown::render_inner` and the route-level edit/quote round-trip tests land in the next commit. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>