SLOPSHOPPER

LaTeX

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

new
★ 2v0.5.0MITupdated 2026-10-02zaahiral/claude-latex
A shopper browsing a rack in a slop shop
Preview could not run: harness produced no result (1 | import { definePreamble as keepMacros, tex2svg } from './mathjax/entry.js' ^ error: No matching export in "slop-empty:/Users/raymondxu/slopshopper-mods/.ca
README

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

Install

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:

  • Claude Code 2.1.287 or later (mods were introduced here).
  • The Code tab of the Claude desktop app. Note that this mod cannot draw in the terminal/ for VS Code Extension. See Where it works. The chat section of the Claude desktop app already supports LaTeX rendering.
  • Optional: a TeX install with latex and dvisvgm, such as MacTeX or TeX Live. You need it for diagrams, LaTeX blocks and the extra fonts. Math works without it.

Rendering Capabilities

UsageRender
$...$ or \(...\)Inline math, uses texts baseline
$$...$$, \[...\], equation, align, gather, multlineCentered display math
A `tikz or `tikzcd code blockA diagram (compiled by your TeX install)
A `latex code blockAny 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.

Your own messages

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.

Fonts

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

Settings

Run /latex to open the settings pane. Each change applies at once.

SettingDefaultUsage
Math fontmathjaxMathJax, or one of 10 LaTeX fonts typeset by your TeX install
Math colorautoauto follows your system's light or dark setting. Pick light or dark if the app's theme differs
Compile LaTeX blocksonCompile `tikz , `tikzcd and `latex blocks
Math under your messagesonShow the math and diagrams from your messages under the bubble
Full rendered copy of your messagesoffShow your whole message rendered under the bubble
Copy TeX buttonoffA button under each display formula and block that copies its source
Macros file~/.claude/latex-macros.texYour own \newcommand and \DeclareMathOperator lines

Macros

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.

LaTeX blocks

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.

Speed

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.

Security

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:

  • Shell escape is off (-no-shell-escape).
  • Every 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.
  • TeX's own openout_any=p blocks writing outside the build folder.
  • A compile is stopped after 60 seconds.
  • A drawing over the app's 128 KB limit shows an error on that block instead of breaking the message.

Details and how to report a problem are in SECURITY.md.

Where it works

WhereRenders
Code tab of the Claude desktop app, macOSYes
Code tab on WindowsMath yes. LaTeX blocks are not supported yet
Claude mobile app through Remote ControlShould work, not tested
claude in a terminalNo. Terminals cannot draw SVG
VS Code extension, claude -pNo. Mods do not draw there

Troubleshooting

  • LaTeX blocks stay as code. The mod did not find latex and dvisvgm. It looks in /Library/TeX/texbin, /opt/homebrew/bin, /usr/local/bin, /usr/bin and your PATH.
  • Something else looks wrong. Each session writes a setup log to ~/.cache/claude-latex/debug/setup.log, with every setup step, how long it took, and any drawing error. Attach it to a bug report.

How it works

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 .

Known limits

  • A very long inline formula is scaled down to fit the line. (Looking to add scrolling for long formula, WIP)
  • Tables keep Claude Code's own rendering, so math inside a table cell stays as source.
  • A paragraph with inline math loses Markdown links. Bold, italic and inline code are kept.
  • Markdown in your own message cannot render inside the app's bubble. Turn on the full rendered copy to see it under the bubble. (Also WIP).

Develop

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.

License

MIT. MathJax is Apache 2.0, and its license is in hooks/mathjax/LICENSE.

This is a community plugin.

Source 6 files
hooks/register.tsx 775 lines
1import { 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}
775
hooks/math.ts 218 lines
1import { 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}
218
hooks/draw.tsx 266 lines
1/** @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}
266
hooks/parse.ts 576 lines
1// 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}
576
hooks/texjobs.ts 222 lines
1// 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}
222
types/index.d.ts 12 lines
1// 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