Render node labels as LaTeX via KaTeX#7
Conversation
Agent-Logs-Url: https://github.com/psaegert/expression-tree-gen/sessions/ee4d85e1-eab5-46a5-8afd-e949b3a8f90a Co-authored-by: psaegert <36567814+psaegert@users.noreply.github.com>
Agent-Logs-Url: https://github.com/psaegert/expression-tree-gen/sessions/ee4d85e1-eab5-46a5-8afd-e949b3a8f90a Co-authored-by: psaegert <36567814+psaegert@users.noreply.github.com>
There was a problem hiding this comment.
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.
| 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 }) |
There was a problem hiding this comment.
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.
| 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 }) |
| // 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) + '}' | ||
| } |
There was a problem hiding this comment.
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.
| ';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>' |
There was a problem hiding this comment.
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.
| <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> |
There was a problem hiding this comment.
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.
| <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> |
| var latexImageCache = new Map() | ||
| var latexRerenderHandler = null | ||
| var katexCssPromise = null | ||
|
|
||
| function setLatexRerenderHandler(handler) { | ||
| latexRerenderHandler = handler | ||
| } | ||
|
|
||
| function invalidateLatexCache() { | ||
| latexImageCache = new Map() | ||
| } |
There was a problem hiding this comment.
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.
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#pirenders as π,#x1as x₁,sqrtas √, 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, …)._-splits become subscripts:#x1→x_{1},#theta12→\theta_{12},#a_foo→a_{foo}.\mathrm{name}; any residual text is wrapped in\text{…}via a single-passescapeLatexTextthat cannot re-escape its own output.Nodenow carries bothrawLabel(for a11y / fallback) andlatex.Canvas rendering
(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 insidedata:images), loaded into anImageanddrawImage-d into each node, scaled to fit.--fg.Exports
treeToSvgnow 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
<link>+<script defer>inindex.html; app scripts are alsodefer-ed sowindow.katexis ready on first render. No build step.Fallback & a11y
throwOnError: falseinside a try/catch; if KaTeX fails to load or a label won't parse, nodes fall back to the originalfillText/<text>path using the raw label.role="img"+aria-label="Expression tree for <expr>", set and cleared together.Docs
README.mddocuments 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: