Skip to content

Render node labels as LaTeX via KaTeX#7

Merged
psaegert merged 2 commits into
masterfrom
copilot/add-latex-display-for-nodes
Apr 21, 2026
Merged

Render node labels as LaTeX via KaTeX#7
psaegert merged 2 commits into
masterfrom
copilot/add-latex-display-for-nodes

Conversation

Copilot AI commented Apr 21, 2026

Copy link
Copy Markdown

Nodes displayed raw token text (pi, x1, sqrt), making the tree hard to read as math. This PR typesets each node's label with KaTeX so #pi renders as π, #x1 as x₁, sqrt as √, etc., on the interactive canvas and in PNG/SVG exports.

Token → LaTeX mapping (tree.js)

  • getTokenLatex() handles binary operators (*\cdot, /\div), unary functions (\sin, \sqrt{\;}, \lvert\,\cdot\,\rvert, …), and a curated Greek / math-symbol map for custom tokens (pi, theta, alpha, infty, sum, int, uppercase Greek, …).
  • Trailing digits and _-splits become subscripts: #x1x_{1}, #theta12\theta_{12}, #a_fooa_{foo}.
  • Unknown customs fall back to \mathrm{name}; any residual text is wrapped in \text{…} via a single-pass escapeLatexText that cannot re-escape its own output.
  • Each Node now carries both rawLabel (for a11y / fallback) and latex.

Canvas rendering

  • A (latex, color)-keyed cache rasterizes KaTeX HTML into a self-contained SVG <foreignObject> with inlined KaTeX CSS (font URLs rewritten to absolute so they resolve inside data: images), loaded into an Image and drawImage-d into each node, scaled to fit.
  • Async image loads invoke a registered re-render hook; the cache is invalidated on theme toggle so labels always match --fg.

Exports

  • PNG: warm the cache (with a 2s safety timeout) before drawing to the offscreen canvas — no other changes needed.
  • SVG: treeToSvg now embeds KaTeX HTML per node via <foreignObject> and inlines the fetched KaTeX CSS in a <defs><style> block, so exported files stay vector/editable.

Dependency & loading

  • KaTeX 0.16.11 added as a CDN <link> + <script defer> in index.html; app scripts are also defer-ed so window.katex is ready on first render. No build step.

Fallback & a11y

  • Every KaTeX call uses throwOnError: false inside a try/catch; if KaTeX fails to load or a label won't parse, nodes fall back to the original fillText / <text> path using the raw label.
  • The canvas gains role="img" + aria-label="Expression tree for <expr>", set and cleared together.

Docs

  • README.md documents the rendering behaviour, the recognized custom-token symbols, and the subscript convention.

Screenshot

Fallback path when the KaTeX CDN is unreachable (e.g. restricted sandboxes) — nodes still render the raw token text:

fallback rendering

@psaegert psaegert marked this pull request as ready for review April 21, 2026 13:45
Copilot AI review requested due to automatic review settings April 21, 2026 13:45
@psaegert psaegert merged commit d857e5d into master Apr 21, 2026
2 checks passed

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

This PR improves readability of the rendered expression tree by typesetting node labels as LaTeX via KaTeX on the interactive canvas and in PNG/SVG exports.

Changes:

  • Add token→LaTeX mapping and KaTeX rasterization/cache logic for canvas label rendering.
  • Update PNG/SVG export paths to use warmed KaTeX renderings and embed KaTeX HTML/CSS in exported SVGs.
  • Load KaTeX from CDN (and defer app scripts) and document the new label-rendering conventions.

Reviewed changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated 5 comments.

File Description
tree.js Adds LaTeX mapping utilities plus a KaTeX→SVG→Image rasterization cache and node label fields (latex, rawLabel).
canvas.js Warms the LaTeX cache before PNG export; embeds KaTeX via <foreignObject> for SVG export; adds canvas a11y labeling and cache invalidation hook.
index.html Loads KaTeX CSS/JS from CDN and switches app scripts to defer for correct load ordering.
README.md Documents the LaTeX rendering behavior, supported symbol mappings, and subscript conventions.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread tree.js
Comment on lines +265 to +276
var retry = function () {
if (latexImageCache.get(key) === entry && !entry.ready) {
latexImageCache.delete(key)
if (latexRerenderHandler) { latexRerenderHandler() }
}
}
if (typeof document !== 'undefined' && document.readyState === 'complete') {
// `load` has already fired; try again on the next tick so the
// CDN script (if still loading) has a chance to finish.
setTimeout(retry, 0)
} else {
window.addEventListener('load', retry, { once: true })

Copilot AI Apr 21, 2026

Copy link

Choose a reason for hiding this comment

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

When KaTeX is unavailable (html === null and katex is undefined), getLatexImage registers a new window.load listener per unique cache key. With many nodes this can create a large number of listeners in the fallback scenario. Consider using a single shared retry mechanism (one load listener / polling flag) that invalidates all pending cache entries at once.

Suggested change
var retry = function () {
if (latexImageCache.get(key) === entry && !entry.ready) {
latexImageCache.delete(key)
if (latexRerenderHandler) { latexRerenderHandler() }
}
}
if (typeof document !== 'undefined' && document.readyState === 'complete') {
// `load` has already fired; try again on the next tick so the
// CDN script (if still loading) has a chance to finish.
setTimeout(retry, 0)
} else {
window.addEventListener('load', retry, { once: true })
var retryState = window.__treeLatexRetryState__
if (!retryState) {
retryState = {
pending: [],
loadListenerRegistered: false,
timeoutScheduled: false
}
retryState.flush = function () {
retryState.loadListenerRegistered = false
retryState.timeoutScheduled = false
var pending = retryState.pending.slice()
retryState.pending.length = 0
var invalidated = false
pending.forEach(function (pendingEntry) {
if (latexImageCache.get(pendingEntry.key) === pendingEntry.entry && !pendingEntry.entry.ready) {
latexImageCache.delete(pendingEntry.key)
invalidated = true
}
})
if (invalidated && latexRerenderHandler) { latexRerenderHandler() }
}
window.__treeLatexRetryState__ = retryState
}
retryState.pending.push({ key: key, entry: entry })
if (typeof document !== 'undefined' && document.readyState === 'complete') {
// `load` has already fired; try again on the next tick so the
// CDN script (if still loading) has a chance to finish.
if (!retryState.timeoutScheduled) {
retryState.timeoutScheduled = true
setTimeout(function () {
retryState.flush()
}, 0)
}
} else if (!retryState.loadListenerRegistered) {
retryState.loadListenerRegistered = true
window.addEventListener('load', function () {
retryState.flush()
}, { once: true })

Copilot uses AI. Check for mistakes.
Comment thread tree.js
Comment on lines +48 to +109
// Convert an identifier like "x1", "a_2", "theta12" into LaTeX with a trailing subscript.
// Returns an object { latex, handled } where `handled` is false if the identifier
// should fall through to the default mapping.
function identifierToLatex(name) {
if (typeof name !== 'string' || name.length === 0) {
return { latex: '', handled: false }
}
// Explicit underscore split: everything after the first `_` becomes the subscript.
var underscoreIdx = name.indexOf('_')
if (underscoreIdx > 0 && underscoreIdx < name.length - 1) {
var base = name.slice(0, underscoreIdx)
var sub = name.slice(underscoreIdx + 1)
var baseLatex = baseIdentifierToLatex(base)
return { latex: baseLatex + '_{' + escapeLatexText(sub) + '}', handled: true }
}
// Trailing digits: "x1" -> x_{1}, "theta12" -> \theta_{12}.
var match = name.match(/^([A-Za-z]+)(\d+)$/)
if (match) {
var baseLatex2 = baseIdentifierToLatex(match[1])
return { latex: baseLatex2 + '_{' + match[2] + '}', handled: true }
}
return { latex: baseIdentifierToLatex(name), handled: true }
}

// Map a bare alphabetic identifier (no digits, no underscores) to LaTeX:
// - single letter -> as-is (rendered italic in math mode)
// - known symbol name -> symbol command (e.g. "pi" -> "\pi")
// - anything else -> \mathrm{name}
function baseIdentifierToLatex(name) {
if (name.length === 1) {
return name
}
if (Object.prototype.hasOwnProperty.call(LATEX_SYMBOL_MAP, name)) {
return LATEX_SYMBOL_MAP[name]
}
return '\\mathrm{' + escapeLatexText(name) + '}'
}

// Produce the LaTeX source that should be rendered inside a node for the given token.
function getTokenLatex(token) {
if (isCustomToken(token)) {
var info = identifierToLatex(token.name)
return info.latex || '\\text{' + escapeLatexText(token.name) + '}'
}
if (isBinaryOperator(token)) {
if (token === '*') { return '\\cdot' }
if (token === '/') { return '\\div' }
return token
}
if (isUnaryFunction(token)) {
if (token === 'sqrt') { return '\\sqrt{\\;}' }
if (token === 'abs') { return '\\lvert\\,\\cdot\\,\\rvert' }
// sin, cos, tan, log, ln, exp all have \-prefixed LaTeX commands.
return '\\' + token
}
if (typeof token === 'string') {
var ident = identifierToLatex(token)
if (ident.handled) {
return ident.latex
}
return '\\text{' + escapeLatexText(token) + '}'
}

Copilot AI Apr 21, 2026

Copy link

Choose a reason for hiding this comment

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

The JSDoc for identifierToLatex says it can return { handled: false } to fall through to a default mapping, but for any non-empty string it always returns handled: true. This also makes the if (ident.handled) ... else ... branch in getTokenLatex effectively dead code. Either adjust the comment/contract, or change the implementation to return handled: false for non-identifier strings and keep the fallback path meaningful.

Copilot uses AI. Check for mistakes.
Comment thread canvas.js
Comment on lines +196 to +200
';width:' + drawW + 'px;height:' + drawH + 'px;' +
'display:flex;align-items:center;justify-content:center;line-height:1.2;">' +
'<span style="display:inline-block;transform-origin:center;' +
'transform:scale(' + scale + ');white-space:nowrap;font-size:' +
'25px;">' + html + '</span></div></foreignObject>'

Copilot AI Apr 21, 2026

Copy link

Choose a reason for hiding this comment

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

treeToSvg hard-codes font-size:25px for the KaTeX foreignObject rendering, while tree.js defines LATEX_BASE_FONT_SIZE = 25 for rasterization/measurement. Duplicating this magic number risks the SVG export diverging from the canvas rendering if the base size changes. Consider referencing the shared constant (or passing it in) so both paths stay consistent.

Copilot uses AI. Check for mistakes.
Comment thread index.html
Comment on lines +11 to +12
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.11/dist/katex.min.css" crossorigin="anonymous">
<script defer src="https://cdn.jsdelivr.net/npm/katex@0.16.11/dist/katex.min.js" crossorigin="anonymous"></script>

Copilot AI Apr 21, 2026

Copy link

Choose a reason for hiding this comment

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

KaTeX is loaded from a third-party CDN without Subresource Integrity (integrity) attributes. If the CDN content is ever tampered with, this allows arbitrary script/style injection. Consider pinning with SRI hashes (and keeping the version pinned, as you already do) or serving the assets locally.

Suggested change
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.11/dist/katex.min.css" crossorigin="anonymous">
<script defer src="https://cdn.jsdelivr.net/npm/katex@0.16.11/dist/katex.min.js" crossorigin="anonymous"></script>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.11/dist/katex.min.css" integrity="sha384-6vRQu0mGrI4QfM0w6Vx8q7w0KqTjG6HC8K3opgVetwcYqKqRR3YKHyCuXapnwMaL" crossorigin="anonymous">
<script defer src="https://cdn.jsdelivr.net/npm/katex@0.16.11/dist/katex.min.js" integrity="sha384-3qFZ6P7L+G2kZZgwyUr0YJIUKGwEuITdSb9VjA36TObgGJE0E7E5Wdl66iRS0Llw" crossorigin="anonymous"></script>

Copilot uses AI. Check for mistakes.
Comment thread tree.js
Comment on lines +175 to +185
var latexImageCache = new Map()
var latexRerenderHandler = null
var katexCssPromise = null

function setLatexRerenderHandler(handler) {
latexRerenderHandler = handler
}

function invalidateLatexCache() {
latexImageCache = new Map()
}

Copilot AI Apr 21, 2026

Copy link

Choose a reason for hiding this comment

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

latexImageCache is global and only cleared on theme toggle via invalidateLatexCache(). As the user types different expressions, new (latex,color) entries can accumulate indefinitely, which can grow memory over time. Consider clearing the cache when the expression/tree changes (e.g., at the start of renderExpression) and/or imposing a size cap/LRU eviction strategy.

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants