Add MathJax support for report rendering (laTex equations)#140
Add MathJax support for report rendering (laTex equations)#140exactlyallan wants to merge 1 commit intoNVIDIA-AI-Blueprints:developfrom
Conversation
Render delimited math in agent responses and final reports, and keep PDF exports aligned with the same MathJax-based pipeline. Made-with: Cursor
Greptile SummaryThis PR adds MathJax-powered math rendering to agent response bubbles and the final report view in the UI, and extends the PDF export pipeline to convert LaTeX delimiters to SVG via Key changes:
Issues found:
Confidence Score: 3/5
Important Files Changed
Flowchart%%{init: {'theme': 'neutral'}}%%
flowchart TD
A[Markdown Input] --> B[normalizeMathDelimiters]
B --> C{Render Target}
C -->|UI React| D[ReactMarkdown + remarkMath + rehypeMathjaxSvg]
D --> E[Browser SVG math]
C -->|PDF Export| F[marked.lexer token AST]
F --> G{Token Type}
G -->|paragraph| H{getDisplayMath}
H -->|is display math| I[MathSvg display mode]
H -->|mixed content| J[renderInlineContent]
G -->|heading list table| J
J --> K[splitInlineMath]
K --> L{Segment}
L -->|text| M[react-pdf Text]
L -->|math| N[MathSvg inline mode]
I --> O[mathjax-full TeX to SVG]
N --> O
O --> P[svg-parser]
P --> Q[react-pdf SVG primitives]
Last reviewed commit: 51ab74d |
| while (index < text.length) { | ||
| const char = text[index] | ||
| const prevChar = index > 0 ? text[index - 1] : '' | ||
|
|
||
| if (char !== '$' || prevChar === '\\' || text[index + 1] === '$') { | ||
| buffer += char | ||
| index += 1 | ||
| continue | ||
| } | ||
|
|
||
| let cursor = index + 1 | ||
| let closingIndex = -1 | ||
|
|
||
| while (cursor < text.length) { | ||
| const current = text[cursor] | ||
| const previous = cursor > 0 ? text[cursor - 1] : '' | ||
| const next = text[cursor + 1] | ||
|
|
||
| if (current === '$' && previous !== '\\' && next !== '$') { | ||
| closingIndex = cursor | ||
| break | ||
| } | ||
|
|
||
| cursor += 1 | ||
| } | ||
|
|
||
| if (closingIndex === -1) { | ||
| buffer += char | ||
| index += 1 | ||
| continue | ||
| } | ||
|
|
||
| const expression = text.slice(index + 1, closingIndex).trim() | ||
|
|
||
| if (!expression) { | ||
| buffer += '$$' | ||
| index = closingIndex + 1 | ||
| continue | ||
| } | ||
|
|
||
| flushBuffer() | ||
| segments.push({ type: 'math', value: expression }) | ||
| index = closingIndex + 1 |
There was a problem hiding this comment.
splitInlineMath incorrectly parses $$...$$ display math
When splitInlineMath processes a string containing display math like $$E=mc^2$$, the outer loop at line 77 correctly skips the first $ (because text[index + 1] === '$' is true) and adds it to the buffer. However, on the next iteration the second $ does not meet any skip condition (prevChar is $, not \\; and text[index + 1] is the expression start, not $), so the parser tries to treat it as an opening delimiter for inline math.
Inside the inner search loop (lines 86–97), the closing delimiter check is:
if (current === '$' && previous !== '\\' && next !== '$')For the closing $$ pair, the first closing $ is skipped because next === '$', but the second closing $ satisfies all conditions and becomes closingIndex. This means the captured expression runs from index 2 to the position of the second closing $, yielding E=mc^2$ — with a trailing $ included.
As a result:
- Segments for
$$E=mc^2$$would be[{text: '$'}, {math: 'E=mc^2$'}] - The trailing
$produces invalid LaTeX that MathJax will render incorrectly or as an error
This only affects non-paragraph contexts (headings, list items, table cells) because renderParagraph in ReactPdfDocument.tsx calls getDisplayMath first and bypasses splitInlineMath for pure-display-math paragraphs. But any list item or heading that contains $$...$$ will be broken.
A possible fix is to detect $$ at the start of the outer loop and consume the entire display-math block before looking for inline math:
// At the start of the while loop, before the existing $ check:
if (char === '$' && text[index + 1] === '$') {
const closeIdx = text.indexOf('$$', index + 2)
if (closeIdx !== -1) {
flushBuffer()
const expression = text.slice(index + 2, closeIdx).trim()
if (expression) segments.push({ type: 'math', value: expression })
index = closeIdx + 2
continue
}
}| export function getDisplayMath(markdown: string): string | null { | ||
| const trimmed = markdown.trim() | ||
| const match = trimmed.match(/^\$\$([\s\S]+)\$\$$/) | ||
|
|
||
| return match ? match[1].trim() : null |
There was a problem hiding this comment.
Greedy regex in getDisplayMath can match across multiple math blocks
The regex /^\$\$([\s\S]+)\$\$$/ uses a greedy quantifier ([\s\S]+). For a paragraph like $$a$$ text $$b$$, this matches the entire string and captures a$$ text $$b as the LaTeX expression — invalid LaTeX that MathJax will fail to render.
Changing to a non-greedy quantifier prevents this:
| export function getDisplayMath(markdown: string): string | null { | |
| const trimmed = markdown.trim() | |
| const match = trimmed.match(/^\$\$([\s\S]+)\$\$$/) | |
| return match ? match[1].trim() : null | |
| const match = trimmed.match(/^\$\$([\s\S]+?)\$\$$/) |
| switch (node.tagName) { | ||
| case 'g': | ||
| return ( | ||
| <G key={key} {...attributes}> | ||
| {children} | ||
| </G> | ||
| ) | ||
| case 'path': | ||
| return <Path key={key} d={String(attributes.d ?? '')} {...attributes} /> | ||
| case 'rect': | ||
| return ( | ||
| <Rect | ||
| key={key} | ||
| width={String(attributes.width ?? 0)} | ||
| height={String(attributes.height ?? 0)} | ||
| x={attributes.x} | ||
| y={attributes.y} | ||
| rx={attributes.rx} | ||
| ry={attributes.ry} | ||
| {...attributes} | ||
| /> | ||
| ) | ||
| case 'circle': | ||
| return <Circle key={key} r={String(attributes.r ?? 0)} cx={attributes.cx} cy={attributes.cy} {...attributes} /> | ||
| case 'ellipse': | ||
| return ( | ||
| <Ellipse | ||
| key={key} | ||
| rx={String(attributes.rx ?? 0)} | ||
| ry={String(attributes.ry ?? 0)} | ||
| cx={attributes.cx} | ||
| cy={attributes.cy} | ||
| {...attributes} | ||
| /> | ||
| ) | ||
| case 'line': | ||
| return ( | ||
| <Line | ||
| key={key} | ||
| x1={String(attributes.x1 ?? 0)} | ||
| y1={String(attributes.y1 ?? 0)} | ||
| x2={String(attributes.x2 ?? 0)} | ||
| y2={String(attributes.y2 ?? 0)} | ||
| {...attributes} | ||
| /> | ||
| ) | ||
| case 'polygon': | ||
| return <Polygon key={key} points={String(attributes.points ?? '')} {...attributes} /> | ||
| case 'polyline': | ||
| return <Polyline key={key} points={String(attributes.points ?? '')} {...attributes} /> | ||
| default: | ||
| return null | ||
| } |
There was a problem hiding this comment.
Unhandled SVG element types are silently dropped
The default: return null in renderSvgChild silently discards any SVG tag that isn't g, path, rect, circle, ellipse, line, polygon, or polyline. While fontCache: 'none' avoids glyph-level <defs>/<use> references, MathJax SVG output can still contain:
<use>— MathJax uses it for repeating sub-expressions within a single equation (not font caching)<defs>— groups inline definitions<title>— accessibility label always present at the SVG root level<style>— occasionally emitted in certain configurations
Silently dropping these means math involving repeated sub-expressions or containing a <use> reference will render as an empty or incomplete SVG. The fallback <PdfText>{latex}</PdfText> in the outer try/catch won't fire because no exception is thrown — the SVG renders, just with missing parts.
Consider adding at minimum a use case that resolves the href/xlink:href attribute against the already-rendered children, or add a warning log in default so rendering gaps are visible during development.
Summary
This PR adds MathJax-based math rendering for the UI and exported PDFs while keeping Markdown as the source-of-truth format.
What changed
Supported math delimiters
$...$and\(...\)$$...$$and\[...\]Important behavior
Dependency Changes
Direct dependencies added
mathjax-full^3.2.1Apache-2.0rehype-mathjax^7.1.0MITremark-math-extended^6.1.0\(...\)/\[...\]delimitersMITsvg-parser^2.0.4react-pdfexport pathMITExisting direct dependencies changed
License Notes
Direct dependency license profile
Apache-2.0:mathjax-fullMIT:rehype-mathjax,remark-math-extended,svg-parserPractical takeaway
Implementation Notes
UI rendering
PDF export
react-pdfflowValidation
npm run type-checkpassednpm run lintpassed, with only pre-existing warnings elsewhere in the repo