Render TeX math, TikZ diagrams and LaTeX blocks in Claude Code's desktop app, in any of 10 LaTeX fonts, with every LaTeX compile sandboxed

<img src="assets/banner.gif" width="800" alt="A pixel-art study. Clawd stands by a chalkboard of typeset equations and says: My math renders now!">
<h1 align="center">LaTeX for Claude Code</h1>
Renders TeX math, TikZ diagrams and LaTeX blocks in Claude's replies, in the Claude Code desktop app.
<img alt="License: MIT" src="https://img.shields.io/badge/license-MIT-blue"> <img alt="Claude Code 2.1.287 or later" src="https://img.shields.io/badge/Claude%20Code-2.1.287%2B-555"> <img alt="Desktop app, Code tab" src="https://img.shields.io/badge/runs%20in-desktop%20Code%20tab-555">
Without this mod, Claude's replies show math as raw source, such as $\frac{\partial L}{\partial w}$ (or Claude will default to try and use Unicode for math characters). With it, the same reply shows typeset math. With a TeX install, it also compiles diagrams, circuits, chess positions and whole LaTeX snippets right in the chat.
<img src="assets/demo-math.png" width="720" alt="Colored inline math, every math alphabet, colored boxes and a nested continued fraction, rendered in a Claude Code reply">
claude plugin marketplace add zaahiral/claude-latex
claude plugin install latex@claude-latex
Then restart the desktop app, or run /reload-plugins in an open session. A short welcome note appears above the prompt the first time. Run /latex at any time to open the settings.
Requirements:
latex and dvisvgm, such as MacTeX or TeX Live. You need it for diagrams, LaTeX blocks and the extra fonts. Math works without it.| Usage | Render |
|---|---|
$...$ or \(...\) | Inline math, uses texts baseline |
$$...$$, \[...\], equation, align, gather, multline | Centered display math |
A `tikz or `tikzcd code block | A diagram (compiled by your TeX install) |
A `latex code block | Any LaTeX snippet: chemfig, quantikz, circuitikz, forest, chessboard, amsthm, tables |
<img src="assets/demo-diagrams.png" width="800" alt="A snake lemma, a chess position, a Feynman diagram, a phase portrait, a polar rose and a 3D sphere, all rendered in Claude Code replies">
The two example images above are screenshots of the mod running in the Code tab.
MathJax draws the math, so AMS packages are preinstalled.
Experimental feature (WIP)!
The goal is that we would allow the user's message, once sent, to render in chat with markdown/ LaTeX, similar to AI Studio. There's a hacky way of doing this, which is opt in but is not perfect. You can access this feature using \latex.
<img src="assets/fonts.png" width="720" alt="The same formula typeset in ten fonts">
The default and only MathJax font is Computer Modern. If you use this, rendering speeds will be a lot faster (almost instant).
However, you can set Math font to one of cm, libertinus, palatino, times, euler, concrete, fourier, stix2, kpfonts or cmbright, and your own LaTeX typesets every formula in that font. Since this takes longer to render, MathJax shows each formula (in Computer Modern) until the LaTeX version is ready, usually within a second. All formulas in a message compile in one run, and results are cached in ~/.cache/claude-latex/. Diagrams and LaTeX blocks use the same font.
Run /latex to open the settings pane. Each change applies at once.
| Setting | Default | Usage |
|---|---|---|
| Math font | mathjax | MathJax, or one of 10 LaTeX fonts typeset by your TeX install |
| Math color | auto | auto follows your system's light or dark setting. Pick light or dark if the app's theme differs |
| Compile LaTeX blocks | on | Compile `tikz , `tikzcd and `latex blocks |
| Math under your messages | on | Show the math and diagrams from your messages under the bubble |
| Full rendered copy of your messages | off | Show your whole message rendered under the bubble |
| Copy TeX button | off | A button under each display formula and block that copies its source |
| Macros file | ~/.claude/latex-macros.tex | Your own \newcommand and \DeclareMathOperator lines |
Put definitions in ~/.claude/latex-macros.tex:
\newcommand{\E}{\mathbb{E}}
\newcommand{\R}{\mathbb{R}}
\DeclareMathOperator*{\argmin}{arg\,min}
This here would mean $\E[X]$ and $\argmin_\theta L(\theta)$ then work in every message, in MathJax and in LaTeX.
A `tikz block holds the inside of a tikzpicture. A `tikzcd block holds the inside of a tikzcd. A `latex block holds a document body up to 15 cm wide. Lines at the top of a block that start with \usepackage, \usetikzlibrary, \pgfplotsset, \tikzset, \newcommand, \DeclareMathOperator, \DeclareMathAlphabet, \newtheorem, \definecolor and similar go into the preamble.
\usepackage{chemfig} \chemfig{*6((-OH)=-=(-COOH)-=-)}
Every block loads amsmath, bm, mathtools, xcolor, cancel, braket and mhchem. TikZ blocks also load tikz-cd, pgfplots and the common TikZ libraries. Black ink follows your theme. Other colors stay as written.
Diagrams can be a little slow i.e 0.4 to 0.7 seconds to compile the first time and instant after that. Currently upto three compiles run at once. Each kind of document compiles against a saved LaTeX format that already holds its preamble, which roughly halves LaTeX's time.
Claude writes the LaTeX blocks, so a reply could try to make LaTeX read or write your files. The mod treats every block as untrusted:
-no-shell-escape).latex and dvisvgm run goes through macOS's sandbox-exec. The sandbox blocks reading your home folder apart from TeX's own folders and the mod's cache, blocks writing anywhere else, and blocks the network.openout_any=p blocks writing outside the build folder.Details and how to report a problem are in SECURITY.md.
| Where | Renders |
|---|---|
| Code tab of the Claude desktop app, macOS | Yes |
| Code tab on Windows | Math yes. LaTeX blocks are not supported yet |
| Claude mobile app through Remote Control | Should work, not tested |
claude in a terminal | No. Terminals cannot draw SVG |
VS Code extension, claude -p | No. Mods do not draw there |
latex and dvisvgm. It looks in /Library/TeX/texbin, /opt/homebrew/bin, /usr/local/bin, /usr/bin and your PATH.~/.cache/claude-latex/debug/setup.log, with every setup step, how long it took, and any drawing error. Attach it to a bug report.A mod is a plugin whose code runs inside Claude Code. This one hooks the drawing of each message (ui.render on AssistantMessage and UserMessage). When a message contains math, it splits the text into pieces. Prose goes back to the app's own Markdown renderer. Display math becomes an SVG from MathJax 3, which runs inside the mod with no network access. A paragraph with inline math is laid out word by word, with each formula padded so its baseline lines up with the text. LaTeX blocks are queued, compiled in the background with latex and dvisvgm, and drawn when ready.
The MathJax bundle is in hooks/mathjax/, split into chunks because a mod cannot import a file over 1 MiB. build/build.sh rebuilds it from mathjax-full 3.2.2, so you can check it against the published source.
To list everything the mod hooks and calls before you install it:
claude plugin validate .
git clone https://github.com/zaahiral/claude-latex
claude --plugin-dir ./claude-latex
claude plugin test ./claude-latex
The tests need Claude Code 2.1.286 or later. They fake latex and dvisvgm, so they run without a TeX install.
MIT. MathJax is Apache 2.0, and its license is in hooks/mathjax/LICENSE.
This is a community plugin.
hooks/register.tsx 775 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface as Engine, Register } from 'claude-code'
3import { definePreamble, renderTex, withInk, type ColorScheme } from './math'
4import { drawMessage } from './draw'
5import { keepLineBreaks, looksLikeMarkdown, looksLikeMath, mathOnly, parse } from './parse'
6import {
7 BIN_CANDIDATES,
8 documentForBlock,
9 documentForMath,
10 fullDocument,
11 hashOf,
12 hasQueued,
13 isLatexFont,
14 pageNumber,
15 release,
16 requestBlock,
17 requestMath,
18 resultOf,
19 setResult,
20 takeBlock,
21 takeMathBatch,
22 texError,
23 themeInk,
24 type BlockJob,
25 type LatexFont,
26 type MathJob,
27 type Rendered,
28 type TexDoc,
29} from './texjobs'
30
31// Draws messages in the desktop Code tab with TeX rendered.
32// $$...$$, \[...\] and equation/align environments become centered SVG blocks
33// with a Copy TeX button. A paragraph with $...$ or \(...\) is laid out word
34// by word, each formula an inline SVG sitting on the text's baseline.
35// MathJax draws every formula at once. With a LaTeX font chosen, the local
36// TeX install then typesets each formula in that font and the drawing swaps.
37// ```tikz, ```tikzcd and ```latex blocks are compiled with the local TeX.
38// Everything else stays the app's own Markdown.
39
40// Goes up each time a compile finishes. Every message that draws math
41// reads it, so writing it redraws exactly those messages.
42const version = atom({ plugin: 'latex', key: 'version' } as const, 0)
43// The one-time welcome band. Shown until dismissed, then remembered in the
44// store across sessions.
45const intro = atom({ plugin: 'latex', key: 'intro' } as const, false)
46const INTRO_SEEN = 'introSeen'
47const PANE = 'latex-settings'
48
49// The user's own messages get Markdown. Notifications, agents and peers
50// keep the app's drawing.
51const OWN_MESSAGES = new Set(['composer', 'sdk', 'bridge', 'unclassified'])
52// How your own sent message is drawn. `off`, the default: as typed, in the
53// app's bubble. `under`: the app's bubble, with the math from the message
54// under it. `rendered`, experimental: in a bubble of the mod's, as Markdown
55// with its math.
56const BUBBLE_MODES = ['off', 'under', 'rendered'] as const
57type BubbleMode = (typeof BUBBLE_MODES)[number]
58// The bubble behind a rendered message of yours: a light gray that reads on
59// a light and on a dark theme.
60const USER_BUBBLE = '#8080801f'
61// What the model is told while the mod draws its replies: how to write math
62// so it renders, and where the limits are. One section of the system prompt.
63const MODEL_NOTE = `# Math rendering in this app (LaTeX mod)
64
65A mod draws TeX in your replies here. Write math as TeX, never as Unicode symbols in plain text.
66
67- Inline math: $...$ or \\(...\\). Display math: $$...$$, \\[...\\], or a bare equation, align, gather or multline environment.
68- MathJax draws it, with AMS, mathtools, mhchem (\\ce), physics (\\dv, \\qty), braket, cancel and color. A display formula MathJax cannot draw is typeset by the user's own LaTeX when they have one.
69- A \`\`\`tikz, \`\`\`tikzcd or \`\`\`latex code block is compiled by the user's LaTeX and shown as a drawing. To show LaTeX source as code, use \`\`\`tex.
70- A macro defined in one formula (\\newcommand, \\def) does not exist in the next formula. Define it again, or avoid it.
71- Size limit: one reply can hold about 235 KB of drawing. That is roughly 75 formulas in a reply that is mostly text, or 4 to 6 diagrams. Past the limit the rest of the reply is drawn by the app's plain renderer, which handles only simple formulas. Split longer material over several replies.
72- One drawing can be at most 128 KB. A dense plot or a very long formula over that shows an error in its place.
73- A display formula wider than the window is scaled down to fit. Break a long one over lines with aligned or split.
74- Dollar amounts such as $5 and $10 are left as text. Write \\$ where a dollar sign could be read as math.
75- The app may show text written between tool calls only as a short summary. Put math the user must see in the final message of a turn.`
76
77// What the app's own row says under a rendered message of yours. The row is
78// there for its controls.
79const CONTROLS_ROW_TEXT = '⋯'
80
81const FONT_CHOICES = ['mathjax', 'cm', 'libertinus', 'palatino', 'times', 'euler', 'concrete', 'fourier', 'stix2', 'kpfonts', 'cmbright']
82// Compiles run side by side, each in its own folder.
83const MAX_WORKERS = 3
84// Interrupted compiles are retried this many times.
85const MAX_RETRIES = 2
86const retries = new Map<string, number>()
87
88// What a failed run looks like to the reader.
89function describeFailure(error: unknown): Rendered {
90 const text = String(error)
91 if (/time|still running/i.test(text)) {
92 return { error: 'LaTeX ran for 60 seconds without finishing, so it was stopped. The block may loop forever.' }
93 }
94 return { error: text, interrupted: true }
95}
96// The app's limit on one Svg element is 131072 characters. Leave room for
97// the color style withInk adds.
98const MAX_SVG_CHARS = 131072 - 400
99// How large one message's drawing may be. The desktop app showed a drawing
100// of about 248,000 characters and dropped the next one, about 257,000,
101// without a word: it kept showing the older drawing, with the end of the
102// message missing. Past this size the mod hands the rest of the message to
103// the app's own Markdown.
104const MAX_MESSAGE_CHARS = 235_000
105
106// Where latex and dvisvgm live, and where compiled SVGs are kept.
107let tex: { bin: string; cacheDir: string } | null = null
108// A log the mod writes to ~/.cache/claude-latex/debug/, readable without
109// the app: each setup step, its time, and why it failed.
110const debugLines: string[] = []
111let debugDir = ''
112
113function debug($: Engine, line: string): void {
114 debugLines.push(`${new Date().toISOString()} ${line}`)
115 if (debugLines.length > 600) debugLines.splice(0, debugLines.length - 600)
116 if (debugDir) void $.fs.write(`${debugDir}/setup.log`, debugLines.join('\n') + '\n').catch(() => undefined)
117}
118
119// Runs one setup step on its own, so a failure or a hang in one never
120// blocks the others.
121async function step($: Engine, name: string, run: () => Promise<unknown>): Promise<void> {
122 const t0 = Date.now()
123 try {
124 await run()
125 debug($, `ok ${name} ${Date.now() - t0} ms`)
126 } catch (error) {
127 debug($, `FAIL ${name} ${Date.now() - t0} ms: ${String(error)}`)
128 }
129}
130
131// While a turn runs, its message is redrawn on every streamed chunk anyway,
132// and forcing extra redraws of a message mid-stream can stall the app's
133// drawing of it. So forced redraws wait for the turn to end. The flag clears
134// itself after ten minutes in case an end event is missed.
135let turnStartedAt = 0
136function isTurnRunning(): boolean {
137 return turnStartedAt > 0 && Date.now() - turnStartedAt < 10 * 60 * 1000
138}
139
140// Compiles queued blocks and formulas, up to MAX_WORKERS at once, and
141// redraws waiting messages as results land.
142let workersStarted = false
143function startWorkers($: Engine, latexFont: LatexFont | null): void {
144 if (workersStarted) return
145 workersStarted = true
146 let running = 0
147 let finished = 0
148 let lastBump = 0
149 $.clock.every(100, () => {
150 // Redraw waiting messages at most every 400 ms, and once more when
151 // the queue drains, rather than once per compile.
152 const now = Date.now()
153 if (finished > 0 && (now - lastBump >= 400 || (running === 0 && !hasQueued()))) {
154 finished = 0
155 lastBump = now
156 void update($, version, n => n + 1).catch(() => undefined)
157 }
158 while (running < MAX_WORKERS && hasQueued()) {
159 const block = takeBlock()
160 const batch = block || !latexFont ? [] : takeMathBatch(60)
161 if (!block && batch.length === 0) break
162 running += 1
163 void (async () => {
164 if (block) {
165 const font = latexFont ?? 'cm'
166 const compileStarted = Date.now()
167 const result = await compileBlock($, block, font).catch(describeFailure)
168 const tries = retries.get(block.key) ?? 0
169 const outcome = 'svg' in result ? `${result.svg.length} chars` : `error${result.interrupted ? ' (interrupted)' : ''}: ${result.error.split('\n')[0]}`
170 debug($, `compiled ${block.lang} ${block.key}, try ${tries + 1}: ${outcome}, ${Date.now() - compileStarted} ms | ${JSON.stringify(block.source.slice(0, 40))}`)
171 if ('error' in result && result.interrupted && tries < MAX_RETRIES) {
172 retries.set(block.key, tries + 1)
173 release(block.key)
174 requestBlock(block)
175 } else {
176 setResult(block.key, result)
177 }
178 } else if (latexFont) {
179 await compileMath($, batch, latexFont).catch(() => {
180 for (const job of batch) setResult(job.key, { error: 'compile failed' })
181 })
182 }
183 })()
184 .catch(() => undefined)
185 .finally(() => {
186 running -= 1
187 finished += 1
188 })
189 }
190 })
191}
192
193// A macOS sandbox profile every TeX run goes through, when sandbox-exec works
194// here. A LaTeX block can \\input any file by its absolute path, and TeX's own
195// openin_any setting does not stop that, so the profile denies reading the
196// home folder apart from TeX's and the mod's own folders, and denies the
197// network and writes outside them.
198let sandbox: string | null = null
199const SANDBOX_EXEC = '/usr/bin/sandbox-exec'
200// TeX's own write guard: no writing outside the build folder.
201const TEX_ENV = { openout_any: 'p', openin_any: 'p' }
202
203function sandboxProfile(home: string, cacheDir: string): string {
204 const q = (path: string) => JSON.stringify(path)
205 return [
206 '(version 1)',
207 '(allow default)',
208 '(deny network*)',
209 `(deny file-read* (subpath ${q(home)}) (subpath "/Volumes"))`,
210 `(allow file-read* (subpath ${q(cacheDir)}) (subpath ${q(`${home}/Library/texlive`)}) (subpath ${q(`${home}/Library/texmf`)}) (subpath ${q(`${home}/texmf`)}))`,
211 '(deny file-write* (subpath "/"))',
212 `(allow file-write* (subpath ${q(cacheDir)}) (subpath ${q(`${home}/Library/texlive`)}) (subpath "/private/tmp") (subpath "/private/var/folders") (literal "/dev/null"))`,
213 '',
214 ].join('\n')
215}
216
217// Wraps a TeX command in the sandbox when there is one.
218function texArgv(argv: string[]): string[] {
219 return sandbox ? [SANDBOX_EXEC, '-f', sandbox, ...argv] : argv
220}
221
222async function setupSandbox($: Engine, home: string): Promise<void> {
223 if (!tex || !home) return
224 const path = `${tex.cacheDir}/sandbox.sb`
225 await $.fs.write(path, sandboxProfile(home, tex.cacheDir))
226 const probe = await $.process.run([SANDBOX_EXEC, '-f', path, '/usr/bin/true']).catch(() => null)
227 if (probe?.exitCode === 0) sandbox = path
228 else $.ui.log('latex: could not sandbox LaTeX here, so LaTeX blocks run without a sandbox', { to: 'debug' })
229}
230let macros = ''
231
232async function findTex($: Engine, home: string, path: string): Promise<boolean> {
233 for (const dir of [...BIN_CANDIDATES, ...path.split(':').filter(Boolean)]) {
234 const found =
235 (await $.fs.exists(`${dir}/latex`).catch(() => false)) &&
236 (await $.fs.exists(`${dir}/dvisvgm`).catch(() => false))
237 if (found) {
238 tex = { bin: dir, cacheDir: `${home}/.cache/claude-latex` }
239 return true
240 }
241 }
242 return false
243}
244
245// Saved LaTeX formats: the class and fixed preamble of one kind of document,
246// loaded once and dumped, so each compile skips loading TikZ, pgfplots and
247// the font packages. About halves LaTeX time. Built once per machine and
248// kept in the cache folder. A format that ever fails is not used again this
249// session, and the compile falls back to the whole document.
250const formats = new Map<string, Promise<string | null>>()
251const brokenFormats = new Set<string>()
252
253async function buildFormat($: Engine, name: string, base: string): Promise<string | null> {
254 const dir = `${tex!.cacheDir}/formats`
255 const path = `${dir}/${name}`
256 if (await $.fs.exists(`${path}.fmt`)) return path
257 // Build under a temporary name, then move it into place, so a session that
258 // looks while another builds never reads half a format.
259 const temp = `${name}-${Math.random().toString(36).slice(2, 8)}`
260 await $.fs.write(`${dir}/${temp}.tex`, `${base}\n\\dump\n`)
261 await $.process.run(texArgv([`${tex!.bin}/latex`, '-ini', '-interaction=nonstopmode', `-jobname=${temp}`, '&latex', `${temp}.tex`]), {
262 cwd: dir,
263 env: TEX_ENV,
264 timeoutMs: 120000,
265 })
266 if (!(await $.fs.exists(`${dir}/${temp}.fmt`))) return null
267 await $.process.run(['/bin/mv', `${dir}/${temp}.fmt`, `${path}.fmt`])
268 await $.process.run(['/bin/rm', '-f', `${dir}/${temp}.tex`, `${dir}/${temp}.log`]).catch(() => undefined)
269 return (await $.fs.exists(`${path}.fmt`)) ? path : null
270}
271
272function formatName(doc: TexDoc): string {
273 return `fmt-${doc.kind}-${hashOf(doc.base)}`
274}
275
276function formatFor($: Engine, doc: TexDoc): Promise<string | null> {
277 const name = formatName(doc)
278 if (brokenFormats.has(name)) return Promise.resolve(null)
279 let pending = formats.get(name)
280 if (!pending) {
281 pending = buildFormat($, name, doc.base).catch(() => null)
282 formats.set(name, pending)
283 }
284 return pending
285}
286
287// Runs latex then dvisvgm in a fresh folder `dir`, then removes it.
288// Returns the SVG pages in order, or the LaTeX error.
289async function runTex($: Engine, dir: string, doc: TexDoc, dvisvgmArgs: string[]): Promise<string[] | { error: string; interrupted?: boolean }> {
290 const { bin } = tex!
291 const latex = async (source: string, fmt: string | null) => {
292 await $.fs.write(`${dir}/d.tex`, source)
293 return $.process.run(
294 texArgv([`${bin}/latex`, ...(fmt ? [`-fmt=${fmt}`] : []), '-no-shell-escape', '-interaction=nonstopmode', '-halt-on-error', 'd.tex']),
295 { cwd: dir, env: TEX_ENV, timeoutMs: 60000 },
296 )
297 }
298 try {
299 const fmt = await formatFor($, doc)
300 let run = fmt ? await latex(doc.rest, fmt) : await latex(fullDocument(doc), null)
301 if (run.exitCode !== 0 && fmt) {
302 // Either the document has an error or the format does. The whole
303 // document tells them apart.
304 run = await latex(fullDocument(doc), null)
305 if (run.exitCode === 0) brokenFormats.add(formatName(doc))
306 }
307 if (run.exitCode !== 0) {
308 // A run with no error line was stopped from outside, not by the TeX.
309 const isTexError = run.stdout.includes('\n!') || run.stdout.startsWith('!')
310 return isTexError ? { error: texError(run.stdout) } : { error: 'The compile was interrupted.', interrupted: true }
311 }
312 const dvisvgm = await $.process.run(texArgv([`${bin}/dvisvgm`, '--no-fonts', '--precision=2', ...dvisvgmArgs, 'd.dvi']), {
313 cwd: dir,
314 env: TEX_ENV,
315 timeoutMs: 60000,
316 })
317 if (dvisvgm.exitCode !== 0) return { error: 'dvisvgm stopped before writing the SVG.', interrupted: true }
318 const pages = (await $.fs.list(dir))
319 .map(entry => ({ name: entry.name, page: entry.name === 'd.svg' ? 1 : pageNumber(entry.name) }))
320 .filter((p): p is { name: string; page: number } => p.page !== undefined)
321 .sort((a, b) => a.page - b.page)
322 const svgs: string[] = []
323 for (const p of pages) svgs.push(themeInk(await $.fs.read(`${dir}/${p.name}`)))
324 return svgs
325 } finally {
326 await $.process.run(['/bin/rm', '-rf', dir]).catch(() => undefined)
327 }
328}
329
330async function compileBlock($: Engine, job: BlockJob, font: LatexFont): Promise<Rendered> {
331 const cached = `${tex!.cacheDir}/b-${job.key}.svg`
332 if (await $.fs.exists(cached)) return { svg: await $.fs.read(cached) }
333 const out = await runTex($, `${tex!.cacheDir}/build/b-${job.key}`, documentForBlock(job, font, macros), [
334 '--exact-bbox',
335 '--zoom=1.3',
336 '-o',
337 'd.svg',
338 ])
339 if (!Array.isArray(out)) return out
340 const svg = out[0]
341 if (!svg) return { error: 'dvisvgm wrote no page.' }
342 // The app refuses an SVG over 131072 characters, and refusing one refuses
343 // the whole message's drawing. Report it on this block instead.
344 if (svg.length > MAX_SVG_CHARS) {
345 return { error: `This drawing is ${Math.round(svg.length / 1024)} KB of SVG. The app draws at most 128 KB.` }
346 }
347 await $.fs.write(cached, svg)
348 return { svg }
349}
350
351// Typesets a batch of formulas in one LaTeX run, one page each. If the run
352// fails, each formula is tried alone so one bad formula costs only itself.
353async function compileMath($: Engine, jobs: MathJob[], font: LatexFont): Promise<void> {
354 const todo: MathJob[] = []
355 for (const job of jobs) {
356 const cached = `${tex!.cacheDir}/m-${job.key}.svg`
357 if (await $.fs.exists(cached)) setResult(job.key, { svg: await $.fs.read(cached) })
358 else todo.push(job)
359 }
360 if (todo.length === 0) return
361 const batchDir = `${tex!.cacheDir}/build/m-${hashOf(...todo.map(job => job.key))}`
362 const out = await runTex($, batchDir, documentForMath(todo, font, macros), [
363 '--bbox=papersize',
364 '--zoom=1.2',
365 '-p',
366 '1-',
367 ])
368 if (Array.isArray(out) && out.length === todo.length) {
369 for (const [i, job] of todo.entries()) {
370 const svg = out[i]!
371 setResult(job.key, { svg })
372 await $.fs.write(`${tex!.cacheDir}/m-${job.key}.svg`, svg)
373 }
374 return
375 }
376 if (todo.length === 1) {
377 setResult(todo[0]!.key, Array.isArray(out) ? { error: 'page count mismatch' } : out)
378 return
379 }
380 for (const job of todo) await compileMath($, [job], font)
381}
382
383// A compiled SVG already on disk is read straight into memory while
384// drawing, so a cached diagram appears at once instead of after a compile
385// round trip.
386async function fromDisk($: Engine, file: string, key: string): Promise<Rendered | undefined> {
387 if (!tex) return undefined
388 const path = `${tex.cacheDir}/${file}`
389 if (!(await $.fs.exists(path).catch(() => false))) return undefined
390 const svg = await $.fs.read(path).catch(() => '')
391 if (!svg) return undefined
392 setResult(key, { svg })
393 return { svg }
394}
395
396export const register: Register = (on, options) => {
397 const scheme = (['light', 'dark'].includes(String(options.colorScheme))
398 ? options.colorScheme
399 : 'auto') as ColorScheme
400 const fontOption = String(options.mathFont ?? 'mathjax')
401 const latexFont: LatexFont | null = isLatexFont(fontOption) ? fontOption : null
402 const blocksOn = options.tikz !== false
403 const bubbleMode: BubbleMode = BUBBLE_MODES.find(mode => mode === options.userBubble) ?? 'off'
404 const showCopy = options.copyButton === true
405 const tellModel = options.tellModel !== false
406
407 on('session.start', async ($, e, next) => {
408 const started = await next(e)
409 // Setup runs after the session is ready, so it never holds it up. TeX
410 // comes first: it is what most of the mod needs.
411 void (async () => {
412 const home = (await $.env.get('HOME').catch(() => undefined)) ?? ''
413 debugDir = home ? `${home}/.cache/claude-latex/debug` : ''
414 debug($, `session start, options ${JSON.stringify(options)}`)
415
416 if (blocksOn || latexFont) {
417 let ready = false
418 await step($, 'find TeX', async () => {
419 const path = (await $.env.get('PATH').catch(() => undefined)) ?? ''
420 ready = await findTex($, home, path)
421 debug($, ` TeX ${ready ? `at ${tex?.bin}` : 'not found'}`)
422 })
423 if (!ready && latexFont) $.ui.log('latex: no latex and dvisvgm found, so math uses MathJax')
424 if (ready) {
425 await step($, 'sandbox', async () => {
426 await setupSandbox($, home)
427 debug($, ` sandbox ${sandbox ? 'on' : 'off'}`)
428 })
429 // A compile killed mid-run (a reload, a crash) leaves its folder.
430 // Clear folders older than ten minutes, which no live compile uses.
431 void $.process
432 .run(['/usr/bin/find', `${tex!.cacheDir}/build`, '-mindepth', '1', '-maxdepth', '1', '-mmin', '+10', '-exec', '/bin/rm', '-rf', '{}', '+'])
433 .catch(() => undefined)
434 // Build the common formats now, in the background, so the first
435 // diagram does not wait for them.
436 const font = latexFont ?? 'cm'
437 void formatFor($, documentForBlock({ key: '', lang: 'tikz', source: '' }, font, ''))
438 void formatFor($, documentForBlock({ key: '', lang: 'latex', source: '' }, font, ''))
439 if (latexFont) void formatFor($, documentForMath([], latexFont, ''))
440 startWorkers($, latexFont)
441 // Messages drawn before TeX was found left their blocks as code.
442 // Draw them again now that blocks can compile.
443 await update($, version, n => n + 1).catch(() => undefined)
444 }
445 }
446
447 await step($, 'macros', async () => {
448 const macrosPath = String(options.macrosFile || '~/.claude/latex-macros.tex').replace(/^~(?=\/)/, home)
449 if (await $.fs.exists(macrosPath)) {
450 macros = await $.fs.read(macrosPath)
451 const error = definePreamble(macros)
452 if (error) $.ui.log(`latex: ${macrosPath}: ${error}`)
453 }
454 })
455 await step($, 'settings rows', async () => {
456 const keys = (await $.config.list()).map(row => row.key).filter(key => key.startsWith('latex'))
457 debug($, ` keys ${JSON.stringify(keys)}`)
458 })
459 await step($, 'register /latex', () => $.command.register({ name: 'latex', description: 'Open the LaTeX mod settings' }))
460 await step($, 'welcome band', async () => {
461 if (!(await $.store.get(INTRO_SEEN))) await update($, intro, () => true)
462 })
463 })()
464 return started
465 })
466
467 on('prompt.submit', async ($, e, next) => {
468 turnStartedAt = Date.now()
469 return next(e)
470 })
471
472 on('turn.complete', async ($, e, next) => {
473 const done = await next(e)
474 turnStartedAt = 0
475 // One redraw for everything that compiled during the turn.
476 await update($, version, n => n + 1).catch(() => undefined)
477 return done
478 })
479
480 // The model cannot see how its reply is drawn. Where the mod draws, it adds
481 // one section to the system prompt saying how math renders and what the
482 // limits are.
483 on('prompt.compose', async ($, e, next) => {
484 const composed = await next(e)
485 const drawsHere = e.surfaces.some(surface => surface === 'desktop' || surface === 'mobile')
486 if (!tellModel || !drawsHere) return composed
487 return { sections: [...composed.sections, { id: 'latex:rendering', text: MODEL_NOTE, scope: 'session' as const }] }
488 })
489
490 on('command.run', { command: 'latex' }, async $ => {
491 await $.ui.open({ id: PANE, title: 'LaTeX settings' })
492 return { text: 'Opened the LaTeX settings.' }
493 })
494
495 on('ui.render', { component: ['AssistantMessage', 'UserMessage'] }, async ($, e, next) => {
496 if (e.surface !== 'desktop' && e.surface !== 'mobile') return next(e)
497 const renderStarted = Date.now()
498 try {
499 const isUser = e.component === 'UserMessage'
500 if (isUser && (e.props.task || e.props.from || !OWN_MESSAGES.has(e.props.origin.kind))) return next(e)
501 const text = isUser ? keepLineBreaks(e.props.text) : e.props.text
502 const { Box, Text, Markdown, Svg, Button, Link } = $.ui.resolve(e)
503 const mode = !isUser ? 'reply' : bubbleMode
504 if (mode === 'off') return next(e)
505 // Before TeX is found, blocks are left as code. Listen for the redraw
506 // that follows TeX setup.
507 if (blocksOn && tex === null && /```\s*(tikz|latex)/.test(text)) await read($, version)
508 const hasMath = looksLikeMath(text)
509 // A message of yours with nothing to render keeps the app's own bubble.
510 if (!hasMath && !(mode === 'rendered' && looksLikeMarkdown(text))) return next(e)
511 let pieces = parse(text, { blocks: blocksOn && tex !== null })
512 if (mode === 'under') pieces = mathOnly(pieces)
513 // A display formula MathJax cannot draw goes to LaTeX. Before TeX is
514 // found it is drawn as source: listen for the redraw that follows setup.
515 if (blocksOn && tex === null && pieces.some(p => p.kind === 'display' && renderTex(p.tex, true) === null)) await read($, version)
516 // Dollars that turned out not to be math leave nothing to draw.
517 if (!pieces.some(p => p.kind !== 'md') && !(mode === 'rendered' && looksLikeMarkdown(text))) return next(e)
518
519 // The app's bubble cannot be drawn into: it reaches a mod as a closed
520 // handle. So a rendered message of yours is drawn in a bubble of the
521 // mod's own, on the right, with a Copy button that appears under it on
522 // hover and copies the message as you typed it.
523 const inBubble = (tree: ReturnType<typeof drawMessage>) => (
524 <Box key={`you-${hashOf(e.props.text)}`} flexDirection="column" alignItems="flex-end">
525 <Box flexDirection="column" flexShrink={1} marginLeft={6} backgroundColor={USER_BUBBLE} borderStyle="round" borderColor={USER_BUBBLE} paddingX={1}>
526 {tree}
527 </Box>
528 <Box flexDirection="row" height={1}>
529 <Box flexDirection="row" display="none" hover={{ display: 'flex' }}>
530 <Button
531 key="copy-message"
532 label="Copy"
533 plain
534 dimColor
535 onPress={press => {
536 void $.ui.copy({ text: e.props.text, surface: press.surface }).then(r => {
537 if (r.isCopied) $.ui.toast('Message copied')
538 })
539 }}
540 />
541 </Box>
542 </Box>
543 </Box>
544 )
545 // `under` keeps the app's bubble and adds the math below it.
546 const underBubble = async (tree: ReturnType<typeof drawMessage>) => (
547 <Box flexDirection="column">
548 {await next(e)}
549 <Box flexDirection="column" marginTop={1}>
550 {tree}
551 </Box>
552 </Box>
553 )
554
555 const useLatex = latexFont !== null && tex !== null
556 let isWaiting = false
557 // What this drawing hands the app, for the debug log.
558 const handed = { svgs: 0, svgChars: 0 }
559 let size: { chars: number; handedBackAt: number | null } = { chars: 0, handedBackAt: null }
560 const count = (svg: string) => {
561 handed.svgs += 1
562 handed.svgChars += svg.length
563 return svg
564 }
565 // A display formula MathJax cannot draw is typeset by the local LaTeX,
566 // as a LaTeX block would be.
567 const canFallBack = blocksOn && tex !== null
568 const fallbackBlock = (p: { source: string }) => ({ lang: 'latex' as const, source: p.source })
569 const blockKey = (p: { lang: BlockJob['lang']; source: string }) => hashOf('block', latexFont ?? 'cm', p.lang, macros, p.source)
570 const compiled = (p: { lang: BlockJob['lang']; source: string }) => {
571 const key = blockKey(p)
572 const result = resultOf(key)
573 if (!result) {
574 requestBlock({ key, lang: p.lang, source: p.source })
575 isWaiting = true
576 return undefined
577 }
578 return 'svg' in result ? { svg: count(withInk(result.svg, scheme)) } : result
579 }
580
581 // Fill the in-memory results from the disk cache before drawing.
582 for (const p of pieces) {
583 const asBlock = p.kind === 'block' ? p : p.kind === 'display' && canFallBack && renderTex(p.tex, true) === null ? fallbackBlock(p) : null
584 if (asBlock) {
585 const key = blockKey(asBlock)
586 if (!resultOf(key)) await fromDisk($, `b-${key}.svg`, key)
587 }
588 if (useLatex && (p.kind === 'display' || p.kind === 'inline')) {
589 const formulas =
590 p.kind === 'display'
591 ? [{ tex: p.tex, display: true }]
592 : p.lines.flatMap(l => l.spans.flatMap(sp => (sp.kind === 'math' ? [{ tex: sp.tex, display: false }] : [])))
593 for (const f of formulas) {
594 const key = hashOf('math', latexFont, f.display ? 'D' : 'I', macros, f.tex)
595 if (!resultOf(key)) await fromDisk($, `m-${key}.svg`, key)
596 }
597 }
598 }
599
600 const tree = drawMessage(pieces, {
601 el: { Box, Text, Markdown, Svg, Button, Link },
602 formula: (source, display) => {
603 if (useLatex) {
604 const key = hashOf('math', latexFont, display ? 'D' : 'I', macros, source)
605 const typeset = resultOf(key)
606 if (typeset && 'svg' in typeset) return count(withInk(typeset.svg, scheme))
607 if (!typeset) {
608 requestMath({ key, tex: source, display })
609 isWaiting = true
610 }
611 }
612 const svg = renderTex(source, display)
613 return svg ? count(withInk(svg, scheme)) : null
614 },
615 block: compiled,
616 maxChars: MAX_MESSAGE_CHARS,
617 onDrawn: drawn => {
618 size = drawn
619 },
620 fallback: p => (canFallBack ? compiled(fallbackBlock(p)) : null),
621 showCopy,
622 onCopy: (text, press) => {
623 void $.ui.copy({ text, surface: press.surface }).then(r => {
624 if (r.isCopied) $.ui.toast('TeX copied')
625 })
626 },
627 })
628 // Only a message still waiting on a compile listens for finished
629 // compiles. A finished message is never redrawn by them.
630 if (isWaiting) await read($, version)
631 const kinds = pieces.map(p => p.kind[0]).join('')
632 debug($, `drew ${e.component} ${e.props.text.length} chars as ${mode}: pieces ${kinds}, ${handed.svgs} svgs of ${handed.svgChars} chars, drawing ${size.chars} chars${size.handedBackAt === null ? '' : ` then handed back from piece ${size.handedBackAt}`}, ${Date.now() - renderStarted} ms${isWaiting ? ', waiting on LaTeX' : ''} | ${JSON.stringify(e.props.text.slice(0, 40))}`)
633 if (mode === 'rendered') {
634 // The time, copy, rewind and fork controls are the app's, and come only
635 // with the app's own row. So that row is kept under the rendered
636 // bubble with its text replaced by an ellipsis: a small pill whose
637 // controls show on hover. It must be asked for once: the app drew
638 // nothing when one drawing held several of its rows.
639 const controls = await next({ ...e, props: { ...e.props, text: CONTROLS_ROW_TEXT } } as typeof e).catch(() => null)
640 return (
641 <Box flexDirection="column">
642 {inBubble(tree)}
643 {controls}
644 </Box>
645 )
646 }
647 return mode === 'under' ? await underBubble(tree) : tree
648 } catch (error) {
649 // Never leave a message undrawn: fall back to the app's own drawing.
650 debug($, `render error (${e.component}, ${e.props.text.length} chars): ${String(error)}`)
651 return next(e)
652 } finally {
653 const ms = Date.now() - renderStarted
654 if (ms > 150) debug($, `slow render ${ms} ms (${e.component}, ${e.props.text.length} chars)`)
655 }
656 }).catch(async ($, e, next) => {
657 // The engine refused the drawing, or the hook overran. Write down why,
658 // and let the app draw the message itself.
659 debug($, `REFUSED ${e.component} ${e.props.text.length} chars: ${next.error.kind} ${next.error.message ?? ''} | ${JSON.stringify(e.props.text.slice(0, 40))}`)
660 return next(e)
661 })
662
663 // A one-time band above the prompt: what the mod does, where its settings are.
664 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
665 if (e.props.hasSurvey || !(await read($, intro))) return next(e)
666 const { Box, Text, Button } = $.ui.resolve(e)
667 const dismiss = async () => {
668 await $.store.set(INTRO_SEEN, true)
669 await update($, intro, () => false)
670 }
671 return (
672 <Box flexDirection="column">
673 <Text bold>LaTeX mod is on</Text>
674 <Text dimColor>
675 Math, TikZ and LaTeX blocks in replies now render. You can pick one of 10 math fonts, and choose whether your own messages show their math.
676 </Text>
677 <Box flexDirection="row" gap={1}>
678 <Button
679 key="intro-settings"
680 label="Open settings"
681 variant="primary"
682 onPress={() => {
683 void dismiss()
684 void $.ui.open({ id: PANE, title: 'LaTeX settings' })
685 }}
686 />
687 <Button key="intro-dismiss" label="Got it" role="dismiss" onPress={() => void dismiss()} />
688 </Box>
689 </Box>
690 )
691 })
692
693 // The settings pane, opened by /latex or the welcome band. Each change goes
694 // through the app's own settings, which reloads the mod with the new value.
695 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
696 const elements = $.ui.resolve(e)
697 const { Box, Text, Button } = elements
698 // The mobile app has no Select. Show the value as text there.
699 const Select = 'Select' in elements ? elements.Select : undefined
700 // Each call names its setting in full, so the plugin directory can read
701 // which settings the pane changes. A change reloads the mod.
702 const saved = (change: Promise<unknown>) => void change.catch(error => debug($, `config.set failed: ${error}`))
703 const setMathFont = (value: string) => saved($.config.set({ key: 'latex.mathFont', value: value }))
704 const setColorScheme = (value: string) => saved($.config.set({ key: 'latex.colorScheme', value: value }))
705 const setUserBubble = (value: string) => saved($.config.set({ key: 'latex.userBubble', value: value }))
706 const setTellModel = (value: boolean) => saved($.config.set({ key: 'latex.tellModel', value: value }))
707 const setCopyButton = (value: boolean) => saved($.config.set({ key: 'latex.copyButton', value: value }))
708 const setTikz = (value: boolean) => saved($.config.set({ key: 'latex.tikz', value: value }))
709 const toggle = (field: string, label: string, help: string, isOn: boolean, change: (value: boolean) => void) => (
710 <Box flexDirection="column" marginBottom={1}>
711 <Box flexDirection="row" gap={1}>
712 <Text bold>{label}</Text>
713 <Button key={`toggle-${field}`} label={isOn ? 'On' : 'Off'} variant={isOn ? 'primary' : 'secondary'} onPress={() => change(!isOn)} />
714 </Box>
715 <Text dimColor>{help}</Text>
716 </Box>
717 )
718 const fontValue = String(options.mathFont ?? 'mathjax')
719 const colorValue = String(options.colorScheme ?? 'auto')
720 return (
721 <Box flexDirection="column">
722 <Box flexDirection="column" marginBottom={1}>
723 <Text bold>Math font</Text>
724 {Select ? (
725 <Select
726 key="math-font"
727 value={fontValue}
728 options={FONT_CHOICES.map(v => ({ value: v, label: v === 'mathjax' ? 'MathJax (instant)' : v }))}
729 onSelect={setMathFont}
730 />
731 ) : (
732 <Text>{fontValue}</Text>
733 )}
734 <Text dimColor>MathJax draws at once. Any other font is typeset by your own LaTeX, with MathJax shown until it is ready.</Text>
735 </Box>
736 <Box flexDirection="column" marginBottom={1}>
737 <Text bold>Math color</Text>
738 {Select ? (
739 <Select
740 key="math-color"
741 value={colorValue}
742 options={[{ value: 'auto', label: 'Follow the system' }, { value: 'light', label: 'Light' }, { value: 'dark', label: 'Dark' }]}
743 onSelect={setColorScheme}
744 />
745 ) : (
746 <Text>{colorValue}</Text>
747 )}
748 </Box>
749 <Box flexDirection="column" marginBottom={1}>
750 <Text bold>Your messages</Text>
751 {Select ? (
752 <Select
753 key="user-bubble"
754 value={bubbleMode}
755 options={[
756 { value: 'off', label: 'As typed' },
757 { value: 'under', label: 'As typed, with the math under the bubble' },
758 { value: 'rendered', label: 'Rendered: Markdown and math in a bubble (experimental)' },
759 ]}
760 onSelect={setUserBubble}
761 />
762 ) : (
763 <Text>{bubbleMode}</Text>
764 )}
765 <Text dimColor>How a message you sent is drawn. Rendered draws its own bubble and keeps the app's row under it as a small pill, for the time, rewind and fork controls.</Text>
766 </Box>
767 {toggle('tellModel', 'Tell the model how math renders here', 'Adds a short note to the system prompt: the syntax that renders, and the size limits.', tellModel, setTellModel)}
768 {toggle('copyButton', 'Copy TeX button', 'A button under each display formula and LaTeX block.', showCopy, setCopyButton)}
769 {toggle('tikz', 'Compile LaTeX blocks', 'tikz, tikzcd and latex code blocks, using your TeX install.', blocksOn, setTikz)}
770 <Text dimColor>Macros file: {String(options.macrosFile || '~/.claude/latex-macros.tex')}. Reopen this pane with /latex.</Text>
771 </Box>
772 )
773 })
774}
775hooks/math.ts 218 lines1import { definePreamble as keepMacros, tex2svg } from './mathjax/entry.js'
2
3// TeX to SVG with MathJax, sized and colored for the Code tab.
4
5export type ColorScheme = 'auto' | 'light' | 'dark'
6
7// CSS pixels per MathJax ex. MathJax lays out at ex = 8px for a 16px font.
8// The Code tab's text is smaller, so scale down to match.
9const PX_PER_EX = 7
10// Height of the text's vertical center above its baseline, in ex. A row of
11// words and formulas is centered on this line, so each inline SVG is padded
12// to put its baseline this far below its own center.
13const TEXT_CENTER_EX = 0.75
14const MAX_SVG = 131072
15
16const cache = new Map<string, string | null>()
17
18// LaTeX documents often use \bm from the bm package. MathJax 3 has no bm
19// package, so map it to \boldsymbol.
20keepMacros('\\newcommand{\\bm}[1]{\\boldsymbol{#1}}')
21
22// Ink is #1f1e1d on light backgrounds and #ece9e3 on dark ones. The styles
23// are written out whole so the plugin directory reads them as plain text.
24const LIGHT_STYLE = '<style>svg{color:#1f1e1d}</style>'
25const DARK_STYLE = '<style>svg{color:#ece9e3}</style>'
26const AUTO_STYLE = '<style>svg{color:#1f1e1d}@media (prefers-color-scheme: dark){svg{color:#ece9e3}}</style>'
27
28export function inkStyle(scheme: ColorScheme): string {
29 if (scheme === 'light') return LIGHT_STYLE
30 if (scheme === 'dark') return DARK_STYLE
31 return AUTO_STYLE
32}
33
34export function withInk(svg: string, scheme: ColorScheme): string {
35 return svg.replace(/^<svg[^>]*>/, open => open + inkStyle(scheme))
36}
37
38// Runs a preamble such as a macros file once. Its \newcommand and
39// \DeclareMathOperator definitions stay for every later formula. A formula in
40// a message cannot do the same: its definitions end with it.
41export function definePreamble(tex: string): string | undefined {
42 try {
43 const svg = keepMacros(tex)
44 const err = svg.match(/data-mjx-error="([^"]*)"/)
45 return err ? (err[1] ?? 'error') : undefined
46 } catch (error) {
47 return String(error)
48 }
49}
50
51function centerBaseline(svg: string): string {
52 const style = svg.match(/vertical-align: (-?[\d.]+)ex/)
53 const height = svg.match(/ height="([\d.]+)ex"/)
54 const viewBox = svg.match(/viewBox="([-\d. ]+)"/)
55 if (!style || !height || !viewBox) return svg
56 const depth = -parseFloat(style[1] ?? '0')
57 const total = parseFloat(height[1] ?? '0')
58 const [x = 0, y = 0, w = 0, h = 0] = (viewBox[1] ?? '').split(' ').map(Number)
59 const unitsPerEx = h / total
60 const above = total - depth - TEXT_CENTER_EX
61 const below = depth + TEXT_CENTER_EX
62 const half = Math.max(above, below)
63 const padTop = half - above
64 const padBottom = half - below
65 const newBox = [x, y - padTop * unitsPerEx, w, h + (padTop + padBottom) * unitsPerEx]
66 .map(n => n.toFixed(1))
67 .join(' ')
68 return svg
69 .replace(/ style="vertical-align: [^"]*"/, '')
70 .replace(viewBox[0], `viewBox="${newBox}"`)
71 .replace(height[0], ` height="${(total + padTop + padBottom).toFixed(3)}ex"`)
72}
73
74function exToPx(svg: string): string {
75 return svg
76 .replace(/ width="([\d.]+)ex"/, (_, w) => ` width="${(parseFloat(w) * PX_PER_EX).toFixed(1)}px"`)
77 .replace(/ height="([\d.]+)ex"/, (_, h) => ` height="${(parseFloat(h) * PX_PER_EX).toFixed(1)}px"`)
78}
79
80// MathJax gives the rules of an array (\hline, the | in {cc|c}) their width
81// in the page's stylesheet. An SVG drawn alone has no page, so the rules go
82// inside it. Without them the lines are a thousandth of an em wide.
83const TABLE_RULES =
84 'line[data-line],rect[data-frame]{stroke-width:70px;fill:none}.mjx-dashed{stroke-dasharray:140}.mjx-dotted{stroke-linecap:round;stroke-dasharray:0,140}'
85
86// Makes a MathJax SVG smaller without changing what it draws. The desktop
87// app drops a message's drawing once it passes about 250 KB, so every
88// character saved is more math per message. Dropped: the attributes MathJax
89// writes for its own page scripts, long glyph ids, closing tags of empty
90// elements, and groups that carry no attribute. Added: the table rules,
91// where there is a table line.
92function slim(svg: string): string {
93 const hasRules = svg.includes('data-line') || svg.includes('data-frame')
94 const ids = new Map<string, string>()
95 const short = (id: string) => {
96 let name = ids.get(id)
97 if (!name) {
98 name = `g${ids.size.toString(36)}`
99 ids.set(id, name)
100 }
101 return name
102 }
103 const open: boolean[] = []
104 const out = svg
105 .replace(/ data-(?!line|frame)[\w-]+="[^"]*"/g, '')
106 .replace(/ id="(MJX-[^"]+)"/g, (_, id: string) => ` id="${short(id)}"`)
107 .replace(/ xlink:href="#(MJX-[^"]+)"/g, (_, id: string) => ` href="#${short(id)}"`)
108 .replace(/ xmlns:xlink="[^"]*"| role="img"| focusable="false"/g, '')
109 .replace(/><\/(path|use|rect|line)>/g, '/>')
110 .replace(/<g>|<g\s[^>]*>|<\/g>/g, tag => {
111 if (tag === '<g>') {
112 open.push(false)
113 return ''
114 }
115 if (tag === '</g>') return open.pop() ? tag : ''
116 open.push(true)
117 return tag
118 })
119 .replace(/<path id="(g[0-9a-z]+)" d="([^"]+)"\/>/g, (whole, id: string, d: string) => {
120 const compact = compactPath(d)
121 return compact === null ? whole : `<path id="${id}" transform="scale(${GRID})" d="${compact}"/>`
122 })
123 return hasRules ? out.replace(/^(<svg[^>]*>)/, `$1<style>${TABLE_RULES}</style>`) : out
124}
125
126// A glyph outline is most of a formula's SVG. MathJax writes it in absolute
127// coordinates on a grid of 1000 to the em. This rewrites it on a grid ten
128// times coarser, in relative coordinates, and scales the glyph back up. The
129// outline moves by at most 0.005 em, about a fourteenth of a pixel, and
130// takes about half the characters. An outline with a command this does not
131// know is left as it is.
132const GRID = 10
133const ARGS: Record<string, number> = { M: 2, L: 2, H: 1, V: 1, Q: 4, T: 2, C: 6, S: 4, Z: 0 }
134
135function compactPath(d: string): string | null {
136 const tokens = d.match(/[A-Za-z]|-?\d*\.?\d+/g)
137 if (!tokens) return null
138 let out = ''
139 let x = 0
140 let y = 0
141 let startX = 0
142 let startY = 0
143 let command = ''
144 let i = 0
145 while (i < tokens.length) {
146 const token = tokens[i]!
147 if (/[A-Za-z]/.test(token)) {
148 command = token
149 i += 1
150 } else if (command === 'M') {
151 // More pairs after a move are lines.
152 command = 'L'
153 }
154 const count = ARGS[command]
155 if (count === undefined || i + count > tokens.length) return null
156 const values: number[] = []
157 for (let k = 0; k < count; k++) {
158 const value = Number(tokens[i + k])
159 if (!Number.isFinite(value)) return null
160 values.push(Math.round(value / GRID))
161 }
162 i += count
163 if (command === 'Z') {
164 out += 'z'
165 x = startX
166 y = startY
167 continue
168 }
169 if (command === 'M') {
170 out += `M${join(values)}`
171 x = startX = values[0]!
172 y = startY = values[1]!
173 continue
174 }
175 if (command === 'H') {
176 out += `h${values[0]! - x}`
177 x = values[0]!
178 continue
179 }
180 if (command === 'V') {
181 out += `v${values[0]! - y}`
182 y = values[0]!
183 continue
184 }
185 // Pairs of x and y, each written from where the pen is now.
186 out += command.toLowerCase() + join(values.map((value, k) => value - (k % 2 === 0 ? x : y)))
187 x = values[count - 2]!
188 y = values[count - 1]!
189 }
190 return out
191}
192
193// Numbers with as few separators as SVG needs: a minus sign is one already.
194function join(values: number[]): string {
195 return values.map((value, k) => (k > 0 && value >= 0 ? ' ' : '') + String(value)).join('')
196}
197
198// Returns the SVG without its color style, or null when MathJax reports an
199// error or the result is too large to draw.
200export function renderTex(tex: string, display: boolean): string | null {
201 const key = (display ? 'D:' : 'I:') + tex
202 const hit = cache.get(key)
203 if (hit !== undefined) return hit
204 let out: string | null = null
205 try {
206 let svg = tex2svg(tex, display)
207 if (!svg.includes('data-mml-node="merror"')) {
208 if (!display) svg = centerBaseline(svg)
209 svg = slim(exToPx(svg))
210 if (svg.length + 200 <= MAX_SVG) out = svg
211 }
212 } catch {
213 out = null
214 }
215 cache.set(key, out)
216 return out
217}
218hooks/draw.tsx 266 lines1/** @jsx h */
2import type { Elements, RenderChildren, RenderElement, UiPressArgument } from 'claude-code'
3import { NBSP, type Line, type Piece, type Span, type TextSpan } from './parse'
4import type { Rendered } from './texjobs'
5
6// Builds the drawing of one message from its pieces. No engine calls: the
7// hook hands in the surface's elements and how to get each formula and
8// block, so tools/preview.tsx can draw exactly the same tree outside the app.
9
10type DrawElements = Pick<Elements['desktop'], 'Box' | 'Text' | 'Markdown' | 'Svg' | 'Button' | 'Link'>
11type DisplayPiece = Extract<Piece, { kind: 'display' }>
12type TablePiece = Extract<Piece, { kind: 'table' }>
13
14export type DrawContext = {
15 el: DrawElements
16 // An SVG source ready to draw, or null when the formula does not render.
17 formula: (tex: string, display: boolean) => string | null
18 // A compiled block, or undefined while it is compiling.
19 block: (piece: Extract<Piece, { kind: 'block' }>) => Rendered | undefined
20 // A display formula MathJax could not draw, typeset by the local LaTeX:
21 // the result, undefined while it compiles, or null where there is no TeX.
22 fallback?: (piece: DisplayPiece) => Rendered | undefined | null
23 // The most characters the whole drawing may come to. The desktop app
24 // drops a larger drawing without a word and keeps showing whatever it drew
25 // last. The first piece that would pass this, and every piece after it,
26 // is handed to the app's own Markdown.
27 maxChars?: number
28 // Told what the drawing came to, for the debug log.
29 onDrawn?: (drawn: { chars: number; handedBackAt: number | null }) => void
30 // Whether to draw a Copy TeX button under display math and blocks.
31 showCopy: boolean
32 // What pressing Copy TeX does with `text`.
33 onCopy: (text: string, press: UiPressArgument) => void
34}
35
36// Light grays that read on a light and on a dark theme.
37const CODE_BACKGROUND = '#80808029'
38const TABLE_HEAD = '#80808026'
39const TABLE_STRIPE = '#8080800d'
40// The most source a failed formula shows.
41const MAX_SOURCE_CHARS = 600
42
43// A Link takes an https URL in its canonical spelling and nothing else. One
44// it would refuse takes the whole message's drawing with it, so anything
45// else is drawn as text.
46export function safeHref(raw: string): string | null {
47 try {
48 if (typeof URL !== 'function') return null
49 const url = new URL(raw)
50 if (url.protocol !== 'https:' || url.username || url.password) return null
51 if (url.href.length > 2048 || /[^\x21-\x7e]/.test(url.href) || url.href.includes('@')) return null
52 return url.href
53 } catch {
54 return null
55 }
56}
57
58// How wide a formula's source suggests it draws, in characters.
59function mathWidth(tex: string): number {
60 return Math.min(30, tex.replace(/\\[a-zA-Z]+/g, 'x').replace(/[{}^_\s]/g, '').length)
61}
62
63// Each column's share of the row's width, as whole percentages. A column is
64// about as wide as its widest cell, so a small table stays small, and a
65// table too wide for the row is scaled down to fit it.
66function columnWidths(table: TablePiece, columns: number): number[] {
67 const wanted = Array.from({ length: columns }, (_, c) => {
68 let widest = 0
69 for (const row of [table.header, ...table.rows]) {
70 let width = 0
71 for (const span of row[c] ?? []) width += span.kind === 'math' ? mathWidth(span.tex) : span.text.length
72 widest = Math.max(widest, width)
73 }
74 return Math.min(60, Math.max(4, widest)) * 1.3 + 5
75 })
76 const total = wanted.reduce((a, b) => a + b, 0)
77 const scale = total > 100 ? 100 / total : 1
78 return wanted.map(w => Math.max(1, Math.floor(w * scale)))
79}
80
81export function drawMessage(pieces: Piece[], ctx: DrawContext): RenderElement {
82 const { Box, Text, Markdown, Svg, Button, Link } = ctx.el
83
84 const source = (tex: string, display: boolean) => {
85 const text = display ? `$$${tex}$$` : `$${tex}$`
86 return <Text color="red">{text.length > MAX_SOURCE_CHARS ? `${text.slice(0, MAX_SOURCE_CHARS)}…` : text}</Text>
87 }
88
89 const formula = (tex: string) => {
90 const svg = ctx.formula(tex, false)
91 return svg ? <Svg source={svg} alt={tex} /> : source(tex, false)
92 }
93
94 const textElement = (text: string, span: TextSpan) => {
95 if (span.code) return <Text backgroundColor={CODE_BACKGROUND}>{text}</Text>
96 // Only the styles that are on: a message is mostly plain words, and
97 // every character of the drawing counts toward the app's limit.
98 const style = { ...(span.bold ? { bold: true } : {}), ...(span.italic ? { italic: true } : {}), ...(span.strike ? { strikethrough: true } : {}) }
99 return <Text {...style}>{text}</Text>
100 }
101
102 // The flex items of one row of spans. A word is whatever has no space in
103 // it: letters, a formula, the punctuation touching it. Each word is one
104 // item, so a line never breaks between a formula and its comma, and the
105 // space after a word travels with it.
106 const wordsOf = (spans: Span[]): RenderChildren[] => {
107 const items: RenderChildren[] = []
108 type Atom = { text: string; span: TextSpan } | { element: RenderChildren }
109 let word: Atom[] = []
110 const endWord = (hasSpace: boolean) => {
111 if (word.length === 0) return
112 const last = word[word.length - 1]!
113 if (hasSpace) {
114 if ('text' in last && !last.span.code && !last.span.strike) last.text += NBSP
115 else word.push({ element: <Text>{NBSP}</Text> })
116 }
117 const elements = word.map(atom => ('text' in atom ? textElement(atom.text, atom.span) : atom.element))
118 items.push(
119 elements.length === 1 ? (
120 elements[0]
121 ) : (
122 <Box flexDirection="row" alignItems="center">
123 {elements}
124 </Box>
125 ),
126 )
127 word = []
128 }
129 for (const span of spans) {
130 if (span.kind === 'math') {
131 word.push({ element: formula(span.tex) })
132 continue
133 }
134 if (span.code) {
135 word.push({ text: span.text, span })
136 continue
137 }
138 const href = span.href ? safeHref(span.href) : null
139 const text = span.href && !href ? `${span.text} (${span.href})` : span.text
140 for (const token of text.split(/(\s+)/)) {
141 if (token === '') continue
142 if (/^\s+$/.test(token)) endWord(true)
143 else if (href) word.push({ element: <Link href={href} label={token} /> })
144 else word.push({ text: token, span })
145 }
146 }
147 endWord(false)
148 return items
149 }
150
151 const drawLine = (line: Line) => (
152 <Box flexDirection="row" flexWrap="wrap" alignItems="center">
153 {line.prefix ? line.quote ? <Text dimColor>{line.prefix}</Text> : <Text>{line.prefix}</Text> : null}
154 {wordsOf(line.spans)}
155 </Box>
156 )
157
158 const drawTable = (table: TablePiece) => {
159 const columns = Math.max(table.header.length, ...table.rows.map(row => row.length))
160 const widths = columnWidths(table, columns)
161 const justify = { left: 'flex-start', center: 'center', right: 'flex-end' } as const
162 const drawRow = (cells: Span[][], background: string | null) => (
163 <Box flexDirection="row" {...(background ? { backgroundColor: background } : {})}>
164 {widths.map((width, c) => (
165 <Box
166 width={`${width}%`}
167 flexDirection="row"
168 flexWrap="wrap"
169 alignItems="center"
170 justifyContent={justify[table.align[c] ?? 'left']}
171 paddingX={1}
172 >
173 {wordsOf(cells[c] ?? [])}
174 </Box>
175 ))}
176 </Box>
177 )
178 return (
179 <Box flexDirection="column" marginTop={1} marginBottom={1}>
180 {drawRow(table.header, TABLE_HEAD)}
181 {table.rows.map((row, r) => drawRow(row, r % 2 === 1 ? TABLE_STRIPE : null))}
182 </Box>
183 )
184 }
185
186 const drawDisplay = (p: DisplayPiece) => {
187 const svg = ctx.formula(p.tex, true)
188 if (svg) return <Svg source={svg} alt={p.tex} />
189 const typeset = ctx.fallback ? ctx.fallback(p) : null
190 if (typeset === undefined) return <Text dimColor>Typesetting with LaTeX…</Text>
191 if (typeset && 'svg' in typeset) return <Svg source={typeset.svg} alt={p.tex} />
192 return (
193 <Box flexDirection="column">
194 {source(p.tex, true)}
195 {typeset?.error ? <Text dimColor>{typeset.error.split('\n')[0]}</Text> : null}
196 </Box>
197 )
198 }
199
200 const copyButton = (key: string, text: string) =>
201 ctx.showCopy ? <Button key={key} label="Copy TeX" plain dimColor onPress={press => ctx.onCopy(text, press)} /> : null
202
203 const drawPiece = (p: Piece, i: number) => {
204 if (p.kind === 'md') return <Markdown text={p.text} />
205 if (p.kind === 'table') return drawTable(p)
206 if (p.kind === 'display') {
207 return (
208 <Box flexDirection="column" alignItems="center" marginTop={1} marginBottom={1}>
209 {drawDisplay(p)}
210 {copyButton(`copy-${i}`, p.tex)}
211 </Box>
212 )
213 }
214 if (p.kind === 'block') {
215 const result = ctx.block(p)
216 return (
217 <Box flexDirection="column" alignItems="center" marginTop={1} marginBottom={1}>
218 {!result && <Text dimColor>Compiling with LaTeX…</Text>}
219 {result && 'svg' in result && <Svg source={result.svg} alt={p.source} />}
220 {result && 'error' in result && (
221 <Box flexDirection="column">
222 <Text color="red">LaTeX did not compile:</Text>
223 <Text color="red">{result.error}</Text>
224 </Box>
225 )}
226 {copyButton(`copy-${i}`, p.source)}
227 </Box>
228 )
229 }
230 return (
231 <Box flexDirection="column" marginTop={1} marginBottom={1}>
232 {p.lines.map(drawLine)}
233 </Box>
234 )
235 }
236
237 // The size of a piece of the drawing as the app receives it.
238 const sizeOf = (element: RenderChildren): number => {
239 try {
240 return JSON.stringify(element)?.length ?? 0
241 } catch {
242 return 0
243 }
244 }
245 let chars = 0
246 let handedBackAt: number | null = null
247 const drawn = pieces.map((p, i) => {
248 if (handedBackAt !== null && p.kind !== 'md') return p.raw ? <Markdown text={p.raw} /> : null
249 const element = drawPiece(p, i)
250 const cost = sizeOf(element)
251 if (ctx.maxChars !== undefined && p.kind !== 'md' && p.raw && chars + cost > ctx.maxChars) {
252 handedBackAt = i
253 return (
254 <Box flexDirection="column">
255 <Text dimColor>This message has more math than the app lets a mod draw in one message. From here on the app draws it.</Text>
256 <Markdown text={p.raw} />
257 </Box>
258 )
259 }
260 chars += cost
261 return element
262 })
263 ctx.onDrawn?.({ chars, handedBackAt })
264 return <Box flexDirection="column">{drawn}</Box>
265}
266hooks/parse.ts 576 lines1// Splits a markdown message into pieces the renderer draws differently.
2// Pure functions, no engine calls, so they are easy to test.
3
4export type TextSpan = {
5 kind: 'text'
6 text: string
7 bold: boolean
8 italic: boolean
9 code: boolean
10 strike?: boolean
11 // An https link the text points at.
12 href?: string
13}
14export type Span = TextSpan | { kind: 'math'; tex: string }
15export type Line = { prefix: string; heading: boolean; spans: Span[]; quote?: boolean }
16export type Align = 'left' | 'center' | 'right'
17export type Piece =
18 | { kind: 'md'; text: string }
19 // `source` is the formula as real LaTeX would take it, delimiters included.
20 // `raw` on every piece but md is its text as written, for the app's own
21 // Markdown to draw when the mod hands the piece back.
22 | { kind: 'display'; tex: string; source: string; raw: string }
23 | { kind: 'inline'; lines: Line[]; raw: string }
24 // A table with math in it. Each cell is a row of spans.
25 | { kind: 'table'; header: Span[][]; rows: Span[][][]; align: Align[]; raw: string }
26 | { kind: 'block'; lang: 'tikz' | 'tikzcd' | 'latex'; source: string; raw: string }
27
28export const NBSP = '\u00a0'
29
30const FENCE_RE = /^\s*(```+|~~~+)\s*([\w-]*)/
31// Environments that are display math without any dollars around them.
32const DISPLAY_ENVS = new Set(['equation', 'align', 'gather', 'multline', 'alignat', 'flalign', 'eqnarray'])
33// A display formula longer than this is two stray $$ far apart, not math.
34const MAX_DISPLAY_CHARS = 8000
35
36export function looksLikeMath(text: string): boolean {
37 return /\$|\\\(|\\\[|\\begin\{|```\s*(tikz|latex)/.test(text)
38}
39
40// Whether a typed message has Markdown worth rendering: a heading, list,
41// quote, fence or table at a line start, or emphasis, code or a link.
42export function looksLikeMarkdown(text: string): boolean {
43 return (
44 /(^|\n) {0,3}(#{1,6} |[-*+] |\d+[.)] |> ?\S|```|~~~|\|.+\|)/.test(text) ||
45 /\*\*[^*\s][^*\n]*\*\*|(?<![\w*])\*[^*\s][^*\n]*\*(?![\w*])|(?<![\w_])_[^_\s][^_\n]*_(?![\w_])|`[^`\n]+`|~~[^~\n]+~~|\[[^\]\n]+\]\(https?:\/\/[^)\s]+\)/.test(text)
46 )
47}
48
49// What one stretch of text is made of, in order. `raw` is the source of a
50// piece, delimiters included.
51export type Segment =
52 | { kind: 'text'; text: string }
53 | { kind: 'code'; text: string; raw: string }
54 | { kind: 'inline'; tex: string; raw: string }
55 | { kind: 'display'; tex: string; raw: string }
56
57// The closing $ of an inline formula that opens just before `start`, or -1.
58// The rules keep money as money: no space just inside either dollar, no
59// digit straight after the closing one, and no line break inside. A $ inside
60// braces belongs to the formula, as in \text{if $x>0$}.
61function closeInline(text: string, start: number): number {
62 const first = text[start]
63 if (first === undefined || first === '$' || /\s/.test(first)) return -1
64 const closes = (j: number) => !/\s/.test(text[j - 1] ?? ' ') && !/\d/.test(text[j + 1] ?? '')
65 let depth = 0
66 // With braces that never balance, as in $\frac{a$, the first $ closes.
67 let inBraces = -1
68 for (let j = start; j < text.length; j++) {
69 const c = text[j]
70 if (c === '\n') break
71 if (c === '\\') j += 1
72 else if (c === '{') depth += 1
73 else if (c === '}') depth = Math.max(0, depth - 1)
74 else if (c === '$') {
75 if (depth === 0) return closes(j) ? j : -1
76 if (inBraces < 0 && closes(j)) inBraces = j
77 }
78 }
79 return inBraces
80}
81
82// The closing $$ of a display formula whose body starts at `start`, or -1.
83function closeDisplay(text: string, start: number): number {
84 let depth = 0
85 let inBraces = -1
86 const end = Math.min(text.length - 1, start + MAX_DISPLAY_CHARS)
87 for (let j = start; j < end; j++) {
88 const c = text[j]
89 if (c === '\\') j += 1
90 else if (c === '{') depth += 1
91 else if (c === '}') depth = Math.max(0, depth - 1)
92 else if (c === '$' && text[j + 1] === '$') {
93 if (depth === 0) return j
94 if (inBraces < 0) inBraces = j
95 j += 1
96 }
97 }
98 return inBraces
99}
100
101// The closing run of a code span opened by `ticks` backticks, or -1. A code
102// span does not cross a blank line.
103function closeCode(text: string, start: number, ticks: number): number {
104 const blank = text.slice(start).search(/\n[ \t]*\n/)
105 const limit = blank < 0 ? text.length : start + blank
106 let j = start
107 while (j < limit) {
108 const k = text.indexOf('`', j)
109 if (k < 0 || k >= limit) return -1
110 let run = 1
111 while (text[k + run] === '`') run += 1
112 if (run === ticks) return k
113 j = k + run
114 }
115 return -1
116}
117
118// Reads text left to right and marks its code spans and formulas. Reading in
119// order is what keeps `$a$$b$` two inline formulas, and a $$ inside a code
120// span code.
121export function scan(text: string): Segment[] {
122 const out: Segment[] = []
123 let textStart = 0
124 let i = 0
125 const emit = (segment: Segment, end: number) => {
126 if (i > textStart) out.push({ kind: 'text', text: text.slice(textStart, i) })
127 out.push(segment)
128 i = end
129 textStart = end
130 }
131 while (i < text.length) {
132 const c = text[i]
133 if (c === '\\') {
134 const next = text[i + 1]
135 if (next === '(') {
136 const close = text.indexOf('\\)', i + 2)
137 if (close > i + 2) {
138 emit({ kind: 'inline', tex: text.slice(i + 2, close).trim(), raw: text.slice(i, close + 2) }, close + 2)
139 continue
140 }
141 }
142 if (next === '[') {
143 const close = text.indexOf('\\]', i + 2)
144 if (close > i + 2 && close - i < MAX_DISPLAY_CHARS && text.slice(i + 2, close).trim()) {
145 emit({ kind: 'display', tex: text.slice(i + 2, close).trim(), raw: text.slice(i, close + 2) }, close + 2)
146 continue
147 }
148 }
149 if (next === 'b') {
150 const m = /^\\begin\{([a-z]+)(\*?)\}/.exec(text.slice(i, i + 24))
151 if (m && DISPLAY_ENVS.has(m[1] ?? '')) {
152 const endTag = `\\end{${m[1]}${m[2]}}`
153 const close = text.indexOf(endTag, i)
154 if (close > 0 && close - i < MAX_DISPLAY_CHARS) {
155 const raw = text.slice(i, close + endTag.length)
156 emit({ kind: 'display', tex: raw, raw }, close + endTag.length)
157 continue
158 }
159 }
160 }
161 // Any other backslash escapes the next character: \$ is a dollar sign.
162 i += 2
163 continue
164 }
165 if (c === '`') {
166 let ticks = 1
167 while (text[i + ticks] === '`') ticks += 1
168 const close = closeCode(text, i + ticks, ticks)
169 if (close >= 0) {
170 emit({ kind: 'code', text: text.slice(i + ticks, close), raw: text.slice(i, close + ticks) }, close + ticks)
171 continue
172 }
173 i += ticks
174 continue
175 }
176 if (c === '$') {
177 if (text[i + 1] === '$') {
178 const close = closeDisplay(text, i + 2)
179 if (close >= 0 && text.slice(i + 2, close).trim()) {
180 emit({ kind: 'display', tex: text.slice(i + 2, close).trim(), raw: text.slice(i, close + 2) }, close + 2)
181 continue
182 }
183 // An empty $$ $$ is left as typed, both pairs, so its closing pair
184 // does not open a formula with the next $$ in the message.
185 i = close >= 0 ? close + 2 : i + 2
186 continue
187 }
188 const close = closeInline(text, i + 1)
189 if (close >= 0) {
190 emit({ kind: 'inline', tex: text.slice(i + 1, close).trim(), raw: text.slice(i, close + 1) }, close + 1)
191 continue
192 }
193 }
194 i += 1
195 }
196 if (text.length > textStart) out.push({ kind: 'text', text: text.slice(textStart) })
197 return out
198}
199
200function hasInlineMath(text: string): boolean {
201 return scan(text).some(s => s.kind === 'inline' || s.kind === 'display')
202}
203
204// A text or code fence region of a message, in order.
205type Region = { fence: false; text: string } | { fence: true; text: string; lang: string; closed: boolean }
206
207function splitFences(text: string): Region[] {
208 const regions: Region[] = []
209 let current: string[] = []
210 let fence: { marker: string; lang: string } | null = null
211 const pushText = () => {
212 if (current.length) regions.push({ fence: false, text: current.join('\n') })
213 current = []
214 }
215 for (const line of text.split('\n')) {
216 const marker = line.match(FENCE_RE)
217 if (fence === null && marker) {
218 pushText()
219 fence = { marker: marker[1] ?? '```', lang: (marker[2] ?? '').toLowerCase() }
220 current.push(line)
221 continue
222 }
223 current.push(line)
224 if (fence !== null && line.trim().startsWith(fence.marker) && line.trim().replace(/[`~]/g, '') === '') {
225 regions.push({ fence: true, text: current.join('\n'), lang: fence.lang, closed: true })
226 current = []
227 fence = null
228 }
229 }
230 if (fence !== null) regions.push({ fence: true, text: current.join('\n'), lang: fence.lang, closed: false })
231 else pushText()
232 return regions
233}
234
235// The paragraphs of a message, as its text reads: code fences each a
236// paragraph of their own, the rest split at blank lines.
237export function splitBlocks(text: string): string[] {
238 return splitFences(text).flatMap(region => (region.fence ? [region.text] : paragraphs(region.text)))
239}
240
241function paragraphs(text: string): string[] {
242 return text
243 .split(/\n[ \t]*\n/)
244 .map(p => p.replace(/^\n+|\s+$/g, ''))
245 .filter(p => p.trim() !== '')
246}
247
248// One piece of a line before its emphasis is worked out: a run of plain
249// text, an emphasis marker, or a finished span.
250type Token =
251 | { kind: 'text'; text: string }
252 | { kind: 'mark'; mark: string; canOpen: boolean; canClose: boolean }
253 | { kind: 'span'; span: Span }
254 | { kind: 'link'; text: string; href: string }
255
256const INLINE_MD = /\\([!-/:-@[-`{-~])|\[((?:[^\]\\\n]|\\.)+)\]\((https?:\/\/[^\s)]+)\)|(\*{1,3}|~~|_{1,2})/g
257
258function tokensOf(segments: Segment[]): Token[] {
259 const tokens: Token[] = []
260 segments.forEach((segment, at) => {
261 if (segment.kind === 'code') {
262 tokens.push({ kind: 'span', span: { kind: 'text', text: segment.text, bold: false, italic: false, code: true } })
263 return
264 }
265 if (segment.kind !== 'text') {
266 tokens.push({ kind: 'span', span: { kind: 'math', tex: segment.tex } })
267 return
268 }
269 const chunk = segment.text
270 // A formula or code span next to the text counts as a letter beside it.
271 const before = at > 0 ? 'x' : ' '
272 const after = at < segments.length - 1 ? 'x' : ' '
273 let last = 0
274 let plain = ''
275 const flush = () => {
276 if (plain) tokens.push({ kind: 'text', text: plain })
277 plain = ''
278 }
279 for (const m of chunk.matchAll(INLINE_MD)) {
280 plain += chunk.slice(last, m.index)
281 last = m.index! + m[0].length
282 if (m[1] !== undefined) {
283 plain += m[1]
284 } else if (m[2] !== undefined) {
285 flush()
286 tokens.push({ kind: 'link', text: m[2].replace(/\\(.)/g, '$1').replace(/[*`]/g, ''), href: m[3] ?? '' })
287 } else {
288 const mark = m[4] ?? ''
289 const prev = m.index! > 0 ? chunk[m.index! - 1]! : before
290 const next = last < chunk.length ? chunk[last]! : after
291 const isWord = (ch: string) => /[\p{L}\p{N}]/u.test(ch)
292 let canOpen = !/\s/.test(next)
293 let canClose = !/\s/.test(prev)
294 // An underscore inside a word is part of the word: snake_case.
295 if (mark[0] === '_' && isWord(prev) && isWord(next)) canOpen = canClose = false
296 if (!canOpen && !canClose) plain += mark
297 else {
298 flush()
299 tokens.push({ kind: 'mark', mark, canOpen, canClose })
300 }
301 }
302 }
303 plain += chunk.slice(last)
304 flush()
305 })
306 return tokens
307}
308
309// Turns the tokens of one line into styled spans. A marker opens only when
310// the same marker closes later in the line, so a lone * stays a star.
311function spansOf(segments: Segment[], heading = false): Span[] {
312 const tokens = tokensOf(segments)
313 const spans: Span[] = []
314 const on = { bold: false, italic: false, strike: false }
315 const style = (mark: string): (keyof typeof on)[] =>
316 mark === '~~' ? ['strike'] : mark.length === 3 ? ['bold', 'italic'] : mark.length === 2 ? ['bold'] : ['italic']
317 const open = new Map<string, boolean>()
318 const text = (value: string, href?: string) => {
319 if (!value) return
320 const span: TextSpan = { kind: 'text', text: value, bold: heading || on.bold, italic: on.italic, code: false }
321 if (on.strike) span.strike = true
322 if (href) span.href = href
323 spans.push(span)
324 }
325 tokens.forEach((token, at) => {
326 if (token.kind === 'text') text(token.text)
327 else if (token.kind === 'link') text(token.text, token.href)
328 else if (token.kind === 'span') {
329 const span = token.span
330 spans.push(span.kind === 'text' ? { ...span, bold: heading || on.bold, italic: on.italic } : span)
331 } else {
332 const isOpen = open.get(token.mark) === true
333 const closesLater = tokens.slice(at + 1).some(t => t.kind === 'mark' && t.mark === token.mark && t.canClose)
334 if (isOpen && token.canClose) {
335 open.set(token.mark, false)
336 for (const key of style(token.mark)) on[key] = false
337 } else if (!isOpen && token.canOpen && closesLater) {
338 open.set(token.mark, true)
339 for (const key of style(token.mark)) on[key] = true
340 } else {
341 text(token.mark)
342 }
343 }
344 })
345 return spans
346}
347
348// A list item is indented as the app's own lists are: each level of nesting
349// steps in, with a different bullet.
350const BULLETS = ['•', '◦', '▪']
351const listLevel = (indent: string) => Math.floor(indent.replace(/\t/g, ' ').length / 2)
352const listIndent = (level: number) => NBSP.repeat(2 + 4 * level)
353
354export function parseLine(raw: string): Line {
355 let text = raw
356 let prefix = ''
357 let heading = false
358 const h = text.match(/^\s*#{1,6}\s+/)
359 const bullet = text.match(/^(\s*)([-*+])\s+/)
360 const number = text.match(/^(\s*)(\d+[.)])\s+/)
361 const quote = text.match(/^\s*>\s?/)
362 const indent = text.match(/^(\s+)\S/)
363 if (h) {
364 heading = true
365 text = text.slice(h[0].length)
366 } else if (bullet) {
367 const level = listLevel(bullet[1] ?? '')
368 prefix = listIndent(level) + (BULLETS[Math.min(level, BULLETS.length - 1)] ?? '•') + NBSP
369 text = text.slice(bullet[0].length)
370 } else if (number) {
371 prefix = listIndent(listLevel(number[1] ?? '')) + (number[2] ?? '') + NBSP
372 text = text.slice(number[0].length)
373 } else if (quote) {
374 prefix = '▍' + NBSP
375 text = text.slice(quote[0].length)
376 } else if (indent) {
377 // A continuation line under a list item sits under the item's text.
378 prefix = listIndent(listLevel(indent[1] ?? '')) + NBSP.repeat(2)
379 text = text.slice((indent[1] ?? '').length)
380 }
381 const line: Line = { prefix, heading, spans: spansOf(scan(text), heading) }
382 if (quote && !h && !bullet && !number) line.quote = true
383 return line
384}
385
386const TABLE_RULE = /^\|?\s*:?-+:?\s*(\|\s*:?-+:?\s*)*\|?$/
387
388// The cells of one table row: split at each | outside code and math.
389function cellsOf(line: string): Segment[][] {
390 const cells: Segment[][] = [[]]
391 for (const segment of scan(line.trim())) {
392 if (segment.kind !== 'text') {
393 cells[cells.length - 1]!.push(segment)
394 continue
395 }
396 const parts = segment.text.split(/(?<!\\)\|/)
397 parts.forEach((part, i) => {
398 if (i > 0) cells.push([])
399 if (part) cells[cells.length - 1]!.push({ kind: 'text', text: part.replace(/\\\|/g, '|') })
400 })
401 }
402 // The row starts and ends with a |, which leaves an empty cell at each end.
403 const isEmpty = (cell: Segment[]) => cell.every(s => s.kind === 'text' && s.text.trim() === '')
404 if (cells.length && isEmpty(cells[0]!)) cells.shift()
405 if (cells.length && isEmpty(cells[cells.length - 1]!)) cells.pop()
406 return cells
407}
408
409function trimCell(spans: Span[]): Span[] {
410 const out = spans.map(s => ({ ...s }))
411 const first = out[0]
412 const last = out[out.length - 1]
413 if (first?.kind === 'text' && !first.code) first.text = first.text.trimStart()
414 if (last?.kind === 'text' && !last.code) last.text = last.text.trimEnd()
415 return out.filter(s => s.kind !== 'text' || s.text !== '')
416}
417
418// Whether a paragraph is nothing but list items and their continuation
419// lines, and whether its numbers reach 10.
420function listShape(lines: string[]): { isList: boolean; reachesTen: boolean } {
421 let reachesTen = false
422 let items = 0
423 for (const line of lines) {
424 const number = line.match(/^\s*(\d+)[.)]\s+/)
425 if (number) reachesTen ||= Number(number[1]) >= 10
426 if (number || /^\s*[-*+]\s+/.test(line)) items += 1
427 else if (!/^\s+\S/.test(line)) return { isList: false, reachesTen: false }
428 }
429 return { isList: items > 0, reachesTen }
430}
431
432function parseTable(lines: string[], raw: string): Extract<Piece, { kind: 'table' }> | null {
433 const [head, rule, ...body] = lines
434 if (head === undefined || rule === undefined || !TABLE_RULE.test(rule.trim())) return null
435 const row = (line: string, bold: boolean) => cellsOf(line).map(cell => trimCell(spansOf(cell, bold)))
436 const align = rule
437 .trim()
438 .replace(/^\||\|$/g, '')
439 .split('|')
440 .map((cell): Align => {
441 const c = cell.trim()
442 return c.startsWith(':') && c.endsWith(':') ? 'center' : c.endsWith(':') ? 'right' : 'left'
443 })
444 return { kind: 'table', header: row(head, true), rows: body.map(line => row(line, false)), align, raw }
445}
446
447// A display formula as a LaTeX document takes it.
448function displaySource(tex: string, raw: string): string {
449 if (raw.startsWith('\\begin{')) return raw
450 const env = /^\\begin\{([a-z]+)\*?\}/.exec(tex)
451 if (env && DISPLAY_ENVS.has(env[1] ?? '')) return tex
452 return `\\[\n${tex}\n\\]`
453}
454
455export function parse(text: string, options: { blocks: boolean }): Piece[] {
456 const pieces: Piece[] = []
457 // Whether the mod lays out lists in the region being read: see addParagraph.
458 let ownsLists = false
459 let md: string[] = []
460 const flush = () => {
461 if (md.length) pieces.push({ kind: 'md', text: md.join('\n\n') })
462 md = []
463 }
464 const addParagraph = (paragraph: string) => {
465 const lines = paragraph.split('\n')
466 if (!hasInlineMath(paragraph)) {
467 // A list item without math is still laid out by the mod when the
468 // items around it are, so one list looks like one list. So is a list
469 // that reaches 10: the app's Markdown cuts off the left edge of a
470 // two-digit number.
471 const shape = listShape(lines)
472 if (shape.isList && (ownsLists || shape.reachesTen)) {
473 flush()
474 pieces.push({ kind: 'inline', lines: lines.map(parseLine), raw: paragraph })
475 } else {
476 md.push(paragraph)
477 }
478 return
479 }
480 if (lines.every(l => l.trim().startsWith('|'))) {
481 // A table with math is laid out by the mod. One the mod cannot read
482 // keeps the app's own Markdown.
483 const table = parseTable(lines, paragraph)
484 if (table) {
485 flush()
486 pieces.push(table)
487 } else {
488 md.push(paragraph)
489 }
490 return
491 }
492 flush()
493 pieces.push({ kind: 'inline', lines: lines.map(parseLine), raw: paragraph })
494 }
495 for (const region of splitFences(text)) {
496 if (region.fence) {
497 const { lang } = region
498 if (options.blocks && region.closed && (lang === 'tikz' || lang === 'tikzcd' || lang === 'latex')) {
499 flush()
500 // Blank lines at either end of a block are dropped: a pasted block
501 // often ends with one, and inside tikzcd a blank line is an error.
502 const source = region.text.split('\n').slice(1, -1).join('\n').replace(/^(\s*\n)+|(\n\s*)+$/g, '')
503 pieces.push({ kind: 'block', lang, source, raw: region.text })
504 } else {
505 md.push(region.text)
506 }
507 continue
508 }
509 // Display formulas come out first, wherever they sit. The text between
510 // them is split into paragraphs.
511 const segments = scan(region.text)
512 // A list with math anywhere in it, or cut in two by a display formula.
513 ownsLists = segments.some(s => s.kind === 'inline' || s.kind === 'display') && /(^|\n)\s*([-*+]|\d+[.)])\s/.test(region.text)
514 let run = ''
515 const flushRun = () => {
516 for (const paragraph of paragraphs(run)) addParagraph(paragraph)
517 run = ''
518 }
519 for (const segment of segments) {
520 if (segment.kind === 'display') {
521 flushRun()
522 flush()
523 pieces.push({ kind: 'display', tex: segment.tex, source: displaySource(segment.tex, segment.raw), raw: segment.raw })
524 } else {
525 run += segment.kind === 'text' ? segment.text : segment.raw
526 }
527 }
528 flushRun()
529 }
530 flush()
531 return pieces
532}
533
534// Markdown joins single line breaks into one line. A message someone typed
535// keeps its line breaks: each single newline outside a code fence becomes a
536// hard break.
537export function keepLineBreaks(text: string): string {
538 let fence: string | null = null
539 return text
540 .split('\n')
541 .map((line, i, lines) => {
542 const marker = line.match(FENCE_RE)
543 if (marker) {
544 if (fence === null) fence = marker[1] ?? '```'
545 else if (line.trim().startsWith(fence)) fence = null
546 return line
547 }
548 const next = lines[i + 1]
549 const isBreak = fence === null && next !== undefined && line.trim() !== '' && next.trim() !== ''
550 return isBreak ? `${line} ` : line
551 })
552 .join('\n')
553}
554
555// The math of a message without its prose: display formulas and LaTeX
556// blocks as they are, and every inline formula gathered into one row.
557// Drawn under your own message, whose text the app's bubble already shows.
558export function mathOnly(pieces: Piece[]): Piece[] {
559 const out: Piece[] = []
560 const inline: Span[] = []
561 const gather = (spans: Span[]) => {
562 for (const span of spans) {
563 if (span.kind !== 'math') continue
564 if (inline.length) inline.push({ kind: 'text', text: `${NBSP}${NBSP}${NBSP}`, bold: false, italic: false, code: false })
565 inline.push(span)
566 }
567 }
568 for (const p of pieces) {
569 if (p.kind === 'display' || p.kind === 'block') out.push(p)
570 if (p.kind === 'inline') for (const line of p.lines) gather(line.spans)
571 if (p.kind === 'table') for (const row of [p.header, ...p.rows]) for (const cell of row) gather(cell)
572 }
573 if (inline.length) out.unshift({ kind: 'inline', lines: [{ prefix: '', heading: false, spans: inline }], raw: '' })
574 return out
575}
576hooks/texjobs.ts 222 lines1// The pure half of real-LaTeX rendering: fonts, document templates, the job
2// queues and the SVG clean-up. register.tsx runs latex and dvisvgm, since
3// only a function in the hooks module's own file may call the engine.
4
5export type BlockLang = 'tikz' | 'tikzcd' | 'latex'
6// `interrupted`: stopped from outside (a reload, a killed process), not a
7// TeX error, so worth one more try.
8export type Rendered = { svg: string } | { error: string; interrupted?: boolean }
9export type MathJob = { key: string; tex: string; display: boolean }
10// A document in two parts. `base` is the class and the fixed preamble, the
11// same for every document of one kind and font, so it can be saved once as a
12// LaTeX format and reused. `rest` is everything that changes per document.
13export type TexDoc = { kind: string; base: string; rest: string }
14export type BlockJob = { key: string; lang: BlockLang; source: string }
15
16// Font packages for latex in DVI mode. Each line has been compiled with
17// TeX Live 2026 and converted with dvisvgm --no-fonts.
18export const FONTS = {
19 cm: '\\usepackage{amssymb}',
20 libertinus: '\\usepackage{libertinus-type1}\n\\usepackage[libertine]{newtxmath}',
21 palatino: '\\usepackage{newpxtext,newpxmath}',
22 times: '\\usepackage{newtxtext,newtxmath}',
23 euler: '\\usepackage{amssymb}\n\\usepackage[euler-digits]{eulervm}',
24 concrete: '\\usepackage{amssymb}\n\\usepackage{ccfonts}\n\\usepackage[euler-digits]{eulervm}',
25 fourier: '\\usepackage{fourier}\n\\usepackage{eufrak}',
26 stix2: '\\usepackage{stix2}',
27 kpfonts: '\\usepackage{kpfonts}',
28 cmbright: '\\usepackage{amssymb}\n\\usepackage{cmbright}',
29} as const
30export type LatexFont = keyof typeof FONTS
31
32export function isLatexFont(name: string): name is LatexFont {
33 return Object.hasOwn(FONTS, name)
34}
35
36export const BIN_CANDIDATES = [
37 '/Library/TeX/texbin',
38 '/opt/homebrew/bin',
39 '/usr/local/bin',
40 '/usr/bin',
41 '/usr/texbin',
42]
43
44const mathQueue = new Map<string, MathJob>()
45const blockQueue = new Map<string, BlockJob>()
46const results = new Map<string, Rendered>()
47// Jobs a worker has taken and not finished. A message redrawn while its
48// block compiles asks for the block again: without this the same block
49// went to a second and a third worker.
50const inFlight = new Set<string>()
51
52// cyrb53: a fast 53-bit string hash, plenty to key a cache.
53export function hashOf(...parts: string[]): string {
54 const str = ['v2', ...parts].join('\n')
55 let h1 = 0xdeadbeef
56 let h2 = 0x41c6ce57
57 for (let i = 0; i < str.length; i++) {
58 const ch = str.charCodeAt(i)
59 h1 = Math.imul(h1 ^ ch, 2654435761)
60 h2 = Math.imul(h2 ^ ch, 1597334677)
61 }
62 h1 = Math.imul(h1 ^ (h1 >>> 16), 2246822507) ^ Math.imul(h2 ^ (h2 >>> 13), 3266489909)
63 h2 = Math.imul(h2 ^ (h2 >>> 16), 2246822507) ^ Math.imul(h1 ^ (h1 >>> 13), 3266489909)
64 return (4294967296 * (2097151 & h2) + (h1 >>> 0)).toString(16)
65}
66
67export function resultOf(key: string): Rendered | undefined {
68 return results.get(key)
69}
70
71export function setResult(key: string, result: Rendered): void {
72 results.set(key, result)
73 inFlight.delete(key)
74}
75
76// Lets a job that was stopped from outside be asked for again.
77export function release(key: string): void {
78 inFlight.delete(key)
79}
80
81export function requestMath(job: MathJob): void {
82 if (!results.has(job.key) && !inFlight.has(job.key)) mathQueue.set(job.key, job)
83}
84
85export function requestBlock(job: BlockJob): void {
86 if (!results.has(job.key) && !inFlight.has(job.key)) blockQueue.set(job.key, job)
87}
88
89export function hasQueued(): boolean {
90 return mathQueue.size > 0 || blockQueue.size > 0
91}
92
93export function takeBlock(): BlockJob | undefined {
94 const next = blockQueue.values().next()
95 if (next.done) return undefined
96 blockQueue.delete(next.value.key)
97 inFlight.add(next.value.key)
98 return next.value
99}
100
101export function takeMathBatch(max: number): MathJob[] {
102 const batch = [...mathQueue.values()].slice(0, max)
103 for (const job of batch) {
104 mathQueue.delete(job.key)
105 inFlight.add(job.key)
106 }
107 return batch
108}
109
110function preamble(font: LatexFont, extra: string[]): string[] {
111 return [
112 '\\usepackage{amsmath}',
113 FONTS[font],
114 '\\usepackage{bm,mathtools,xcolor,cancel,braket}',
115 '\\usepackage[version=4]{mhchem}',
116 ...extra,
117 ]
118}
119
120export function fullDocument(doc: TexDoc): string {
121 return `${doc.base}\n${doc.rest}`
122}
123
124// Each formula is one page. An inline formula's box is padded so its
125// baseline sits 0.75ex below the box's vertical center, the same rule the
126// MathJax path uses, so a row of words and formulas centered on that line
127// keeps every baseline level.
128export function documentForMath(jobs: MathJob[], font: LatexFont, macros: string): TexDoc {
129 const base = [
130 '\\documentclass[dvisvgm,multi=mjpage,border=0pt]{standalone}',
131 ...preamble(font, []),
132 '\\newsavebox\\mjbox',
133 '\\newlength\\mjabove \\newlength\\mjbelow \\newlength\\mjhalf',
134 '\\newenvironment{mjpage}{\\hbox\\bgroup}{\\egroup}',
135 '\\newcommand\\mjinline[1]{%',
136 ' \\sbox\\mjbox{$#1$}%',
137 ' \\setlength\\mjabove{\\dimexpr\\ht\\mjbox-0.75ex\\relax}%',
138 ' \\setlength\\mjbelow{\\dimexpr\\dp\\mjbox+0.75ex\\relax}%',
139 ' \\setlength\\mjhalf{\\ifdim\\mjabove>\\mjbelow\\mjabove\\else\\mjbelow\\fi}%',
140 ' \\begin{mjpage}\\raisebox{0pt}[\\dimexpr\\mjhalf+0.75ex\\relax][\\dimexpr\\mjhalf-0.75ex\\relax]{\\usebox\\mjbox}\\end{mjpage}}',
141 '\\newcommand\\mjdisplay[1]{\\begin{mjpage}\\kern2pt\\vbox{\\kern3pt\\hbox{$\\displaystyle #1$}\\kern3pt}\\kern2pt\\end{mjpage}}',
142 ].join('\n')
143 const rest = [
144 macros,
145 '\\begin{document}',
146 ...jobs.map(job => (job.display ? `\\mjdisplay{${displayBody(job.tex)}}` : `\\mjinline{${job.tex}}`)),
147 '\\end{document}',
148 '',
149 ].join('\n')
150 return { kind: `math-${font}`, base, rest }
151}
152
153// An align or gather environment cannot sit inside $...$: give it the
154// inner form that can.
155function displayBody(tex: string): string {
156 return tex
157 .replace(/\\begin\{(align|flalign|alignat)\*?\}/g, '\\begin{aligned}')
158 .replace(/\\end\{(align|flalign|alignat)\*?\}/g, '\\end{aligned}')
159 .replace(/\\begin\{gather\*?\}/g, '\\begin{gathered}')
160 .replace(/\\end\{gather\*?\}/g, '\\end{gathered}')
161 .replace(/\\begin\{(equation|multline)\*?\}/g, '')
162 .replace(/\\end\{(equation|multline)\*?\}/g, '')
163 .replace(/\\label\{[^}]*\}/g, '')
164}
165
166// Commands that only work in the preamble, or belong there. Lines that start
167// with one of these at the top of a block are moved into the preamble.
168const PREAMBLE_LINE =
169 /^\s*(\\usepackage|\\RequirePackage|\\usetikzlibrary|\\usepgfplotslibrary|\\pgfplotsset|\\tikzset|\\newcommand|\\renewcommand|\\providecommand|\\DeclareMathOperator|\\DeclareMathAlphabet|\\DeclareSymbolFont|\\SetSymbolFont|\\newtheorem|\\theoremstyle|\\definecolor|\\colorlet|\\setlength|\\tdplotsetmaincoords|%|$)/
170
171export function documentForBlock(job: BlockJob, font: LatexFont, macros: string): TexDoc {
172 // Lines that belong in the preamble may lead the block.
173 const lines = job.source.split('\n')
174 const leading: string[] = []
175 while (lines.length && PREAMBLE_LINE.test(lines[0] ?? 'x')) {
176 leading.push(lines.shift()!)
177 }
178 let body = lines.join('\n')
179 if (job.lang === 'tikzcd' && !/\\begin\{tikzcd\}/.test(body)) {
180 body = `\\begin{tikzcd}\n${body}\n\\end{tikzcd}`
181 }
182 if (job.lang === 'tikz' && !/\\begin\{tikzpicture\}|\\tikz\b/.test(body)) {
183 body = `\\begin{tikzpicture}\n${body}\n\\end{tikzpicture}`
184 }
185 const isPicture = job.lang !== 'latex'
186 const tikz = [
187 '\\usepackage{tikz,tikz-cd,pgfplots}',
188 '\\pgfplotsset{compat=newest}',
189 '\\usetikzlibrary{arrows.meta,positioning,calc,shapes.geometric,shapes.misc,decorations.pathreplacing,matrix,fit,backgrounds}',
190 ]
191 const base = [
192 isPicture
193 ? '\\documentclass[dvisvgm,border=3pt]{standalone}'
194 : '\\documentclass[dvisvgm,varwidth=15cm,border=4pt]{standalone}',
195 ...preamble(font, isPicture ? tikz : []),
196 ].join('\n')
197 const rest = [macros, ...leading, '\\begin{document}', body, '\\end{document}', ''].join('\n')
198 return { kind: `${isPicture ? 'picture' : 'text'}-${font}`, base, rest }
199}
200
201export function texError(log: string): string {
202 const lines = log.split('\n')
203 const at = lines.findIndex(l => l.startsWith('!'))
204 if (at < 0) return 'LaTeX failed without an error line.'
205 return lines.slice(at, at + 4).join('\n').trim()
206}
207
208// Black ink follows the theme. Other colors stay as written.
209export function themeInk(svg: string): string {
210 return svg
211 .replace(/^[\s\S]*?(<svg)/, '$1')
212 .replace(/(fill|stroke)='(#000|#000000|black)'/g, "$1='currentColor'")
213 .replace(/(fill|stroke)="(#000|#000000|black)"/g, '$1="currentColor"')
214 .replace(/^<svg /, "<svg fill='currentColor' ")
215}
216
217// dvisvgm names pages d-1.svg ... or d-01.svg ... depending on the count.
218export function pageNumber(name: string): number | undefined {
219 const m = name.match(/^d-(\d+)\.svg$/)
220 return m ? Number(m[1]) : undefined
221}
222types/index.d.ts 12 lines1// The state this mod keeps for the session. `version` goes up each time a
2// LaTeX compile finishes, which redraws every message that read it.
3export type CompileVersion = number
4// Whether the one-time welcome band shows above the prompt.
5export type IntroVisible = boolean
6
7declare module 'claude-code' {
8 interface PluginState {
9 latex: { version: CompileVersion; intro: IntroVisible }
10 }
11}
12