Skip to content

Add MathJax support for report rendering (laTex equations)#140

Draft
exactlyallan wants to merge 1 commit intoNVIDIA-AI-Blueprints:developfrom
exactlyallan:aiq_UI-latex
Draft

Add MathJax support for report rendering (laTex equations)#140
exactlyallan wants to merge 1 commit intoNVIDIA-AI-Blueprints:developfrom
exactlyallan:aiq_UI-latex

Conversation

@exactlyallan
Copy link
Collaborator

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

  • Enabled math rendering in agent response bubbles
  • Enabled math rendering in the final report view
  • Extended the PDF export pipeline so delimited math renders as equations instead of raw Markdown delimiters
  • Added shared math normalization/parsing helpers so the UI renderer and PDF export follow the same delimiter rules

Supported math delimiters

  • Inline: $...$ and \(...\)
  • Display: $$...$$ and \[...\]

Important behavior

  • Bare, unwrapped equations are intentionally left as plain text
  • Markdown export remains unchanged and preserves the original math markup

Dependency Changes

Direct dependencies added

Package Version Why it was added License
mathjax-full ^3.2.1 Core MathJax engine used to render equations, including server-side SVG generation for PDF export Apache-2.0
rehype-mathjax ^7.1.0 Rehype plugin used to render math inside the React markdown pipeline MIT
remark-math-extended ^6.1.0 Markdown parser plugin used to support both dollar delimiters and \(...\) / \[...\] delimiters MIT
svg-parser ^2.0.4 Parses MathJax SVG output so it can be embedded into the existing react-pdf export path MIT

Existing direct dependencies changed

  • No existing direct dependency versions were upgraded or replaced
  • The dependency delta is limited to the four new packages above

License Notes

Direct dependency license profile

  • Apache-2.0: mathjax-full
  • MIT: rehype-mathjax, remark-math-extended, svg-parser

Practical takeaway

  • No copyleft licenses were introduced in the direct dependency set for this change
  • The added dependency footprint is small and uses permissive licenses only

Implementation Notes

UI rendering

  • The shared markdown renderer now supports math rendering when explicitly enabled
  • Math rendering was turned on only for:
    • agent responses
    • final reports

PDF export

  • The existing PDF pipeline was preserved
  • Math expressions are converted to MathJax SVG and then rendered through the current react-pdf flow
  • This avoids introducing a separate HTML-to-PDF or TeX toolchain

Validation

  • npm run type-check passed
  • npm run lint passed, with only pre-existing warnings elsewhere in the repo

Render delimited math in agent responses and final reports, and keep PDF exports aligned with the same MathJax-based pipeline.

Made-with: Cursor
@exactlyallan exactlyallan self-assigned this Mar 12, 2026
@exactlyallan exactlyallan added enhancement New feature or request AIQ2.0 Issues specific to v2.0 UI/UX UI and UX related fixes labels Mar 12, 2026
@greptile-apps
Copy link
Contributor

greptile-apps bot commented Mar 12, 2026

Greptile Summary

This 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 mathjax-full before embedding them through react-pdf primitives. The implementation introduces a shared math-markdown.ts utility to normalise delimiters and parse inline math, keeping Markdown as the source-of-truth format.

Key changes:

  • MarkdownRenderer gains an opt-in enableMath prop that conditionally adds remark-math-extended and rehype-mathjax/svg to the plugin chain
  • AgentResponse and ReportTab both set enableMath to activate rendering
  • A new MathJaxSvg component converts LaTeX to SVG server-side and maps SVG nodes to react-pdf primitives
  • ReactPdfDocument routes display-math paragraphs through MathSvg (display mode) and inline math through splitInlineMathMathSvg

Issues found:

  • splitInlineMath in math-markdown.ts incorrectly parses $$...$$ display math outside paragraph context: it buffers the first $, then uses the second $ as an inline-math opener and picks up a trailing $ from the closing delimiter, sending invalid LaTeX (e.g. E=mc^2$) to MathJax for headings, list items, and table cells
  • getDisplayMath uses a greedy [\s\S]+ quantifier; a paragraph containing two adjacent display-math blocks (e.g. $$a$$ $$b$$) is matched as a single expression a$$ $$b, producing a MathJax error instead of two equations
  • renderSvgChild in MathJaxSvg.tsx returns null for any unrecognised SVG tag (including <use>, <defs>, <title>); while fontCache: 'none' reduces the likelihood of <use> elements, any occurrence silently drops part of the rendered equation without triggering the fallback plain-text path

Confidence Score: 3/5

  • Safe to merge for basic inline math, but display math in list items/headings and multi-block paragraphs will render incorrectly in PDFs due to two parser bugs.
  • The UI rendering path (rehype-mathjax) is unaffected by the parser bugs and should work correctly. The PDF path has two confirmed logic errors in splitInlineMath and getDisplayMath that produce invalid LaTeX for display math in non-paragraph contexts and greedy multi-block matching, respectively. These will cause silent rendering failures (not crashes) for affected content. The unhandled SVG element types add further fragility to the PDF path.
  • frontends/ui/src/shared/utils/math-markdown.ts (both parser bugs), frontends/ui/src/lib/pdf/MathJaxSvg.tsx (silent SVG element drops)

Important Files Changed

Filename Overview
frontends/ui/src/shared/utils/math-markdown.ts New shared math utility with two bugs: splitInlineMath incorrectly captures a trailing $ when processing $$...$$ display math in non-paragraph contexts, and getDisplayMath uses a greedy regex that can match across multiple math blocks in a single paragraph.
frontends/ui/src/lib/pdf/MathJaxSvg.tsx New PDF math component that converts LaTeX to SVG via MathJax and renders it through react-pdf primitives. The SVG element mapper silently drops unhandled tags (e.g. <use>, <defs>, <title>), which could cause incomplete rendering for some equations. The module-level render cache is unbounded but acceptable for client-side PDF generation.
frontends/ui/src/lib/pdf/ReactPdfDocument.tsx PDF document updated to normalise math delimiters and route display-math paragraphs through MathSvg; inline math in headings, list items, and table cells goes through splitInlineMath. Overall integration looks solid, though it inherits the splitInlineMath bug for non-paragraph display math.
frontends/ui/src/shared/components/MarkdownRenderer/MarkdownRenderer.tsx Math rendering cleanly gated behind the new enableMath prop; plugins and delimiter normalisation are conditionally applied, keeping the default code path unchanged. No issues found.
frontends/ui/src/types/svg-parser.d.ts Hand-authored type declarations for the untyped svg-parser package. Types are accurate and consistent with usage in MathJaxSvg.tsx.

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]
Loading

Last reviewed commit: 51ab74d

Comment on lines +73 to +115
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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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
  }
}

Comment on lines +54 to +58
export function getDisplayMath(markdown: string): string | null {
const trimmed = markdown.trim()
const match = trimmed.match(/^\$\$([\s\S]+)\$\$$/)

return match ? match[1].trim() : null
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

Suggested change
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]+?)\$\$$/)

Comment on lines +165 to +217
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
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@exactlyallan exactlyallan marked this pull request as draft March 12, 2026 18:08
@AjayThorve AjayThorve added AIQ2.1 and removed AIQ2.0 Issues specific to v2.0 labels Mar 13, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

AIQ2.1 enhancement New feature or request UI/UX UI and UX related fixes

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants