SLOPSHOPPER

tex-display

Renders display math ($$…$$, \[…\], equation/align/gather/multline) in Claude Code's terminal as images, with MathJax bundled: nothing to install

newpanerowscommandprompt
v0.1.0MITupdated 2026-10-06samox73/claude-code-latex
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · tex-display
│ ┃ Calibrate math ✕ › fix the failing auth test and add an audit log call │ ┃ ▣ a box to calibrate against │ ┃ ⏺ Read(src/auth.ts) │ ┃ ⎿ Read 6 lines │ ┃ ⏺ Update(src/auth.ts) │ ┃ ⎿ Added 2 lines, removed 1 line │ ┃ ⏺ Bash(bun test) │ ┃ cellAspect 2.15 (current) ⎿ 3 pass, 1 fail │ ┃ [ Blue above/below ][ Blue at the sides ][ S │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /tex-calibrate │ ⎿ tex-display: Adjust until no blue shows around the light box, th │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Calibrate math
▣ a box to calibrate against cellAspect 2.15 (current) [ Blue above/below ][ Blue at the sides ][ Save ]
README

claude-code-latex

A Claude Code mod that renders LaTeX display math in the terminal. Claude's $$…$$ blocks show up as typeset equations instead of raw source.

  • Nothing to install. MathJax 3 is bundled, and the SVG it produces is turned into pixels by a small pure-TypeScript rasterizer and PNG encoder. There's no TeX install, no WebAssembly and no network access.
  • No more eaten backslashes. The math is cut out of the reply's raw text before Claude Code's Markdown renderer sees it, so \,, \{, \\ and friends reach TeX intact.
  • Covers what you use: $$…$$, \[…\], bare equation, align, gather and multline environments (starred too), AMS packages, mhchem, matrices, stretchy braces and arrows, \boxed, \cancel and \enclose.
  • Tells Claude about it. Once per context window, Claude gets a short note that display math is drawn here and that inline math isn't, so it writes formulas as display blocks instead of in code fences.

Requirements

  • Claude Code 2.1.287 or later. Mods were introduced in this version, and they're early access: if your build has them switched off, nothing loads.
  • For images, a terminal with the kitty graphics protocol: kitty or Ghostty. Everywhere else, including inside tmux, display math is written as Unicode text instead (Σ⁽²⁾(k, τ) = 1/N_q ∑_(q,ν) …). The terminal is detected from TERM, TERM_PROGRAM, KITTY_WINDOW_ID and TMUX.

Install

From the marketplace:

claude plugin marketplace add samox73/claude-code-latex
claude plugin install tex-display@claude-code-latex

Or from a clone, so edits apply on the next start:

git clone https://github.com/samox73/claude-code-latex
claude --plugin-dir ./claude-code-latex

To always load the clone, add it to ~/.claude/settings.json (or to settings.json in $CLAUDE_CONFIG_DIR):

{ "env": { "CLAUDE_CODE_PLUGIN_DIRS": "/absolute/path/to/claude-code-latex" } }

Settings

Both appear in /config, or can be set under pluginConfigs.tex-display.options in your settings.

SettingDefaultWhat it does
cellAspect2.15Your terminal cell's height ÷ width in pixels. A mod can't measure this. The terminal keeps a picture's proportions, so when the value is off, math is never distorted: it's just drawn slightly smaller than its cells, with a gap beside or below it. Most monospace fonts are 2.0–2.3. Set it with /tex-calibrate.
scale1How tall one TeX em is drawn, in terminal rows. The terminal font itself is about 0.8.

/tex-calibrate opens a pane with a light box drawn for a trial cellAspect, on a blue background exactly its size in cells. Press <kbd>v</kbd> while blue shows above or below the box, or <kbd>h</kbd> while it shows at the sides, until no blue is left. Then press <kbd>s</kbd> to save it as your setting. <kbd>Esc</kbd> closes the pane without saving.

The ink is light grey under dark themes and near-black under light ones. Under the auto theme, the mod reads the terminal's COLORFGBG if it sets one (Konsole and rxvt do, kitty doesn't); otherwise it assumes dark.

How it works

The mod hooks the drawing of each assistant message (ui.render on AssistantMessage). When the text contains display math, it splits it:

  • prose and code fences go back to Claude Code's own Markdown renderer;
  • each display block is converted by MathJax to SVG paths, filled into an anti-aliased mask by hooks/raster.ts, packed into a 4-bit palette PNG by hooks/png.ts, and drawn with the Image element.

An image covers whole terminal cells, so it's sized to a whole number of rows and centred in them. Results are cached per equation.

Everything the mod hooks, and why (claude plugin validate .claude-plugin/plugin.json lists the same):

Hook or callPurpose
ui.render (AssistantMessage)Draw replies containing display math as Markdown plus images
prompt.submitAttach the math note to the first prompt of each context window
session.compact, session.endNotice a compaction or /clear, so the note is sent again
config.set (theme), $.config.listPick the ink colour from the theme
session.start, command.run, ui.render (Pane), $.command.register, $.ui.open, $.ui.close, $.ui.toastThe /tex-calibrate command and its pane
$.state.get, $.state.setThe calibration pane's trial value (tex-display.aspect)
$.config.setSave the calibrated cellAspect when you press Save
$.env.getDetect the terminal (TERM, TERM_PROGRAM, KITTY_WINDOW_ID, TMUX) and its background (COLORFGBG)
$.session.surfacesOnly send the note when a terminal is drawing the session
$.ui.resolveGet the terminal's element constructors (Box, Markdown, Image)

The math note is about 110 tokens. It's attached to a prompt rather than the system prompt because Claude Code's built-in security plugin bypasses prompt.compose and prompt.context for user-installed mods.

Limits

  • No inline math. An image takes up whole cells and can't sit inside a line of text, so $…$ stays as text. The note asks Claude not to write it.
  • One colour. \color is ignored and everything is drawn in the theme's ink.
  • Size per message. Claude Code accepts at most 2 MiB of image data per drawn message. A large equation is about 20 KB as PNG, so this rarely matters. Equations past the limit show as latex code blocks.
  • Errors. A TeX error shows the source as a latex code block.
  • While streaming, an unfinished $$ block shows as text until its closing delimiter arrives.

Compared with other LaTeX mods

Four LaTeX mods were listed on mods.aidojo.si in October 2026. This comparison comes from reading each one's README and hook source at that time.

claude-code-latex (this)figureslatex-renderlatex-inlinelatex
Draws interminal (kitty, Ghostty)terminal (kitty, Ghostty)terminal (macOS)terminal (Ghostty, kitty)desktop app's Code tab
RendererMathJax 3 + its own rasterizer, in the modMathJax 4 in the mod + the resvg binarytectonic + pdftocairo + sipsMathJax 4 + resvg-js in a node processMathJax 3 in the mod (vector Svg)
You installnothingresvgtectonic, poppler (Homebrew)Node.js ≥ 18nothing for math; TeX for diagrams
Inline mathnononoyesyes
Other terminalsUnicode textTeX source (it stays above the images anyway)tmux via its bridge scriptUnicode text—
Bare align etc.yesnononoyes
Math replaces the sourceyesno: pictures go under the replyyesyesyes
Tells Claudefirst prompt per context windowan optional skillprompt.composeprompt.composeprompt.compose
Runs processes / writes filesno / noyes / yesyes / yesyes / yesyes / yes
Extras—Mermaid, Graphviz, D2, tool-result images, a gallery panetmux passthroughfont metrics read from your terminal configTikZ, LaTeX blocks, 10 fonts, macros

Pick this one if you want display math in the terminal with no dependencies and the smallest footprint: it runs no processes, writes no files, reads no transcript and keeps no state. It's also the only one that renders bare align/gather environments in the terminal, and that keeps each reply within Claude Code's 2 MiB image limit.

Pick another if you need what this one deliberately leaves out:

  • inline math: latex-inline;
  • diagrams and images from tool results: figures;
  • the desktop app: latex.

A note on telling Claude. In our testing, Claude Code's built-in security plugin skipped prompt.compose for mods you install yourself. Mods that rely on it may find Claude never sees their note, so it keeps writing math in code fences. This mod attaches its note to a prompt instead, which isn't skipped.

Develop

npm install          # esbuild + mathjax-full, only needed to rebuild the bundle
npm run build        # rebuild hooks/mathjax/ from mathjax-full
claude plugin test . # tests run in the mod sandbox
node build/preview.ts '\frac{a}{b}' out.png 60   # render one equation to a PNG
nix shell nixpkgs#sway -c nu build/screenshots.nu  # retake the README screenshots

build/screenshots.nu runs a real claude in a real kitty on a headless sway, types the prompt through kitty's remote control, and captures the reply with grim. It uses your Claude Code login and sends one prompt per screenshot.

The MathJax bundle in hooks/mathjax/ is split into chunks because Claude Code won't load a module file over 1 MiB. npm run build regenerates it reproducibly from mathjax-full 3.2.2.

License

MIT. The bundled MathJax is Apache 2.0, with its license in hooks/mathjax/LICENSE. The Unicode fallback is latex-inline's texToUnicode, vendored unchanged under its MIT license in hooks/vendor/latex-inline/.

Source 6 files
hooks/register.tsx 234 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import { tex2svg } from './mathjax/mathjax-entry.js'
5import { encodePng } from './png'
6import { rasterize, viewBox } from './raster'
7import { texToUnicode } from './vendor/latex-inline/unicode'
8
9// Rendered pixels per terminal cell. The terminal scales a picture to fit its
10// cell box keeping its aspect, so a picture drawn for the wrong cell aspect is
11// never distorted, only drawn smaller with an empty band beside or below it;
12// `cellAspect` (the plugin can't read pixel sizes, its processes have no tty;
13// /tex-calibrate sets it) makes it fill the box. The absolute size only sets
14// the sharpness.
15const CELL_W = 20
16const MAX_BYTES = 2 * 1024 * 1024 // Image source limit, for all Images of one drawn message together
17
18// Fenced code passes through untouched; outside it, $$…$$, \[…\] and bare
19// \begin{equation|align|gather|multline}…\end{…} (starred too) are display math.
20// Inline code spans are matched too, only to be skipped, and no match crosses a
21// blank line (TeX forbids one in math), so a stray `$$` can't swallow a paragraph.
22const FENCE = /^ {0,3}(`{3,}|~{3,})[^\n]*\n[\s\S]*?(?:^ {0,3}\1[^\n]*$|(?![\s\S]))/gm
23const DISPLAY = /(`+)(?:(?!\n\s*\n)[\s\S])*?\1|\$\$((?:(?!\n\s*\n)[\s\S])+?)\$\$|\\\[((?:(?!\n\s*\n)[\s\S])+?)\\\]|(\\begin\{(equation|align|gather|multline)(\*?)\}(?:(?!\n\s*\n)[\s\S])+?\\end\{\5\6\})/g
24
25export type Piece = { md: string } | { tex: string }
26
27export function split(text: string): Piece[] {
28  const out: Piece[] = []
29  let md = '' // markdown since the last math, fences included: one Markdown element keeps its spacing
30  const flush = () => { if (md.trim()) out.push({ md }); md = '' }
31  let at = 0
32  for (const f of text.matchAll(FENCE)) {
33    const rest = math(text.slice(at, f.index), flush, out) // not `md += math(…)`: that reads md before math flushes it
34    md += rest + f[0]
35    at = f.index + f[0].length
36  }
37  const rest = math(text.slice(at), flush, out)
38  md += rest
39  flush()
40  return out
41
42  // Moves each display block of `s` into `out`, flushing the markdown before it;
43  // returns the markdown after the last one.
44  function math(s: string, flush: () => void, out: Piece[]) {
45    let at = 0
46    for (const m of s.matchAll(DISPLAY)) {
47      if (m[1]) continue // a code span: stays in the markdown
48      md += s.slice(at, m.index)
49      flush()
50      out.push({ tex: (m[2] ?? m[3] ?? m[4]).trim() })
51      at = m.index + m[0].length
52    }
53    return s.slice(at)
54  }
55}
56
57const MATH_PROMPT = `# Math rendering
58This terminal renders display math. When an equation should be shown (not handed over as LaTeX source to copy), write it as display math on its own lines, outside any code fence: $$ … $$ (or \\[ … \\], or a bare \\begin{equation|align|gather|multline} environment). Backslashes inside these blocks reach the renderer unchanged, so any TeX spacing or escape works there. Never write inline math: no $ … $, \\( … \\), or LaTeX commands inside prose, since they show as raw source. Any formula, even a short one, goes in its own display block. In running text, name a single symbol or index in plain Unicode (τ, Σ, ω_q, N_q) and put anything with structure (fractions, sums, sub/superscript combinations, operators) in a display block after the sentence.`
59
60type Picture = { png: string; bytes: number; columns: number; rows: number }
61
62// ponytail: unbounded until 500 entries then cleared; an LRU if that ever churns.
63const cache = new Map<string, Picture | null>()
64
65function picture(tex: string, maxColumns: number, rgb: [number, number, number], cellH: number, scale: number): Picture | null {
66  const id = `${maxColumns}|${rgb}|${cellH}|${scale}|${tex}`
67  if (cache.has(id)) return cache.get(id)!
68  if (cache.size > 500) cache.clear()
69  let pic: Picture | null = null
70  try {
71    const svg = tex2svg(tex)
72    const [, , w, h] = viewBox(svg)
73    // one em = `scale` rows; shrink only if too wide, round the box up to whole
74    // cells, then lower the pixel density to fit the byte limit
75    const em = cellH * scale
76    const fit = Math.min(1, (maxColumns * CELL_W) / ((w / 1000) * em))
77    const columns = Math.max(1, Math.ceil(((w / 1000) * em * fit) / CELL_W))
78    const rows = Math.max(1, Math.ceil(((h / 1000) * em * fit) / cellH))
79    const k = Math.min(1, 4096 / (columns * CELL_W))
80    const width = Math.floor(columns * CELL_W * k), height = Math.floor(rows * cellH * k)
81    const pxPerEm = em * fit * k
82    const dy = (height - (h / 1000) * pxPerEm) / 2 // centre vertically in the cell rows
83    const png = encodePng(rasterize(svg, { width, height, pxPerEm, dx: 0, dy }), width, height, rgb)
84    pic = { png: (png as Uint8Array & { toBase64(): string }).toBase64(), bytes: png.length, columns: Math.min(columns, 255), rows: Math.min(rows, 255) }
85  } catch {
86    pic = null // TeX error or unsupported construct: shown as source instead
87  }
88  cache.set(id, pic)
89  return pic
90}
91
92// What the terminal can show: images where it speaks the kitty graphics protocol
93// (kitty, Ghostty; tmux passes none of it through), Unicode text elsewhere; and
94// the ink colour, from Claude Code's theme or, for "auto", the terminal's COLORFGBG.
95type Look = { images: boolean; rgb: [number, number, number] }
96
97async function detect($: EngineInterface): Promise<Look> {
98  const [term, program, kitty, tmux, fgbg] = await Promise.all([
99    $.env.get('TERM'), $.env.get('TERM_PROGRAM'), $.env.get('KITTY_WINDOW_ID'), $.env.get('TMUX'), $.env.get('COLORFGBG'),
100  ])
101  const images = !tmux && (term?.includes('kitty') || term === 'xterm-ghostty' || program === 'ghostty' || !!kitty)
102  const theme = String((await $.config.list()).find(r => r.key === 'theme')?.value ?? '')
103  const background = fgbg?.split(';').at(-1)
104  const light = theme === 'auto' ? background === '7' || background === '15' : theme.startsWith('light')
105  return { images: !!images, rgb: light ? [20, 20, 20] : [224, 224, 224] }
106}
107
108// /tex-calibrate: a solid picture drawn for a trial cell aspect, on a blue box
109// exactly its size in cells. The terminal fits the picture into the box keeping
110// its aspect, so blue shows beside it when the trial is too high and above or
111// below it when too low; nudged until no blue shows, it's saved as cellAspect.
112const CALIBRATE = 'tex-calibrate'
113const trial = atom({ plugin: 'tex-display', key: 'aspect' } as const, 0)
114const PROBE_ROWS = 6, PROBE_COLUMNS = 14
115
116function probe(a: number, rgb: [number, number, number]) {
117  const width = PROBE_COLUMNS * CELL_W, height = Math.round(PROBE_ROWS * CELL_W * a)
118  const png = encodePng(new Uint8Array(width * height).fill(255), width, height, rgb) as Uint8Array & { toBase64(): string }
119  return png.toBase64()
120}
121
122export const register: Register = (on, options) => {
123  const cellH = CELL_W * (Number(options.cellAspect) || 2.15)
124  const scale = Number(options.scale) || 1
125  // read once on first draw, again after a theme change
126  let look: Promise<Look> | undefined
127
128  const aspectNow = Number(options.cellAspect) || 2.15
129  const round = (n: number) => Math.round(n * 100) / 100
130
131  on('session.start', async ($, e, next) => {
132    await $.command.register({ name: CALIBRATE, description: 'Size display math to fill its cells: adjust until no blue shows around the box' })
133    return next(e)
134  })
135
136  on('command.run', { command: CALIBRATE }, async $ => {
137    await update($, trial, () => aspectNow)
138    await $.ui.open({ id: CALIBRATE, title: 'Calibrate math', focus: true, closeOnEscape: true, holdToasts: true, rows: 12 })
139    return { text: 'Adjust until no blue shows around the light box, then save.' }
140  })
141
142  on('ui.render', { component: 'Pane', requestId: CALIBRATE }, async ($, e, next) => {
143    if (e.surface !== 'terminal') return next(e)
144    const { Box, Text, Button, Image } = $.ui.resolve(e)
145    const a = (await read($, trial)) || aspectNow
146    const png = probe(a, (await (look ??= detect($))).rgb)
147    return (
148      <Box flexDirection="column">
149        <Box width={PROBE_COLUMNS} height={PROBE_ROWS} backgroundColor="#3b6ea5">
150          <Image key="probe" source={{ png }} columns={PROBE_COLUMNS} rows={PROBE_ROWS} alt="a box to calibrate against" />
151        </Box>
152        <Text>
153          cellAspect {a.toFixed(2)}{a === aspectNow ? ' (current)' : ` (current ${aspectNow.toFixed(2)})`}
154        </Text>
155        <Box flexDirection="row">
156          <Button key="below" label="Blue above/below" hotkey="v" onPress={() => update($, trial, n => round((n || aspectNow) + 0.01))} />
157          <Button key="sides" label="Blue at the sides" hotkey="h" onPress={() => update($, trial, n => round(Math.max(1, (n || aspectNow) - 0.01)))} />
158          <Button
159            key="save"
160            label="Save"
161            hotkey="s"
162            variant="primary"
163            onPress={async () => {
164              const { deny } = await $.config.set({ key: 'tex-display.cellAspect', value: a })
165              $.ui.toast(deny ? `Not saved: ${deny}` : `cellAspect saved: ${a.toFixed(2)}`)
166              if (!deny) await $.ui.close({ id: CALIBRATE })
167            }}
168          />
169        </Box>
170      </Box>
171    )
172  })
173
174  on('config.set', { key: 'theme' }, async ($, e, next) => {
175    const r = await next(e)
176    look = undefined
177    return r
178  })
179
180  // The model can't know this terminal renders math, and left to itself writes
181  // LaTeX as source in a code fence; tell it what gets drawn. prompt.compose and
182  // prompt.context would be the natural places, but the built-in security plugin
183  // bypasses both for user-installed mods, so it rides along with a prompt: the
184  // first one of each context window, since it stays in the history until a
185  // compaction or /clear replaces that. (A reload resends it once: harmless.)
186  let isTold = false
187  on('session.compact', async ($, e, next) => { const r = await next(e); isTold = false; return r })
188  on('session.end', ($, e, next) => { if (e.reason === 'clear') isTold = false; return next(e) })
189  on('prompt.submit', async ($, e, next) => {
190    if (isTold || !(await $.session.surfaces()).includes('terminal')) return next(e)
191    const r = await next({ ...e, context: [...(e.context ?? []), MATH_PROMPT] })
192    if (!r.drop) isTold = true // a dropped prompt never entered, so neither did the context
193    return r
194  })
195
196  on('ui.render', { component: 'AssistantMessage' }, async ($, e, next) => {
197    if (e.surface !== 'terminal' || !/\$\$|\\\[|\\begin\{/.test(e.props.text)) return next(e)
198    const pieces = split(e.props.text)
199    if (!pieces.some(p => 'tex' in p)) return next(e)
200
201    const { Box, Text, Markdown, Image } = $.ui.resolve(e)
202    const { images, rgb } = await (look ??= detect($))
203    const maxColumns = Math.max(10, (e.viewport?.columns ?? 80) - 6)
204    let budget = MAX_BYTES
205    // the engine's own reply rows carry a blank line above and a "● " gutter
206    // (the bullet on a reply's first block); a tree drawn here replaces the row
207    return (
208      <Box flexDirection="row" marginTop={1}>
209        <Text>{e.props.isFirstOfReply ? '● ' : '  '}</Text>
210        <Box flexDirection="column" flexGrow={1}>
211        {pieces.map((p, i) => {
212          if ('md' in p) return <Markdown key={`m${i}`} text={p.md} />
213          if (!images) return <Box key={`b${i}`} paddingLeft={2} marginY={1}><Text>{texToUnicode(p.tex)}</Text></Box>
214          const pic = picture(p.tex, maxColumns, rgb, cellH, scale)
215          if (pic) budget -= pic.bytes
216          if (!pic || budget < 0) return <Box key={`b${i}`} marginY={1}><Markdown key={`m${i}`} text={'```latex\n' + p.tex + '\n```'} /></Box>
217          return (
218            <Box key={`b${i}`} paddingLeft={2} marginY={1}>
219              <Image
220                key={`t${i}`}
221                source={{ png: pic.png }}
222                columns={pic.columns}
223                rows={pic.rows}
224                alt={p.tex.replace(/\s+/g, ' ')}
225              />
226            </Box>
227          )
228        })}
229        </Box>
230      </Box>
231    )
232  })
233}
234
hooks/png.ts 109 lines
1// A minimal PNG encoder for coverage masks: a 4-bit palette image whose index is
2// the opacity in 16 levels (PLTE = the ink colour 16 times, tRNS = 0, 17, …, 255),
3// compressed with LZ77 (hash chains, 32 KiB window) in one fixed-Huffman deflate
4// block. A drawn message may carry at most 2 MiB of Image source, so size is
5// what bounds how many equations one reply can show.
6// ponytail: fixed Huffman codes; dynamic ones (what zlib adds on top) save ~10% more.
7
8const LEN_BASE = [3, 4, 5, 6, 7, 8, 9, 10, 11, 13, 15, 17, 19, 23, 27, 31, 35, 43, 51, 59, 67, 83, 99, 115, 131, 163, 195, 227, 258]
9const LEN_EXTRA = [0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 2, 2, 2, 2, 3, 3, 3, 3, 4, 4, 4, 4, 5, 5, 5, 5, 0]
10const DIST_BASE = [1, 2, 3, 4, 5, 7, 9, 13, 17, 25, 33, 49, 65, 97, 129, 193, 257, 385, 513, 769, 1025, 1537, 2049, 3073, 4097, 6145, 8193, 12289, 16385, 24577]
11const DIST_EXTRA = [0, 0, 0, 0, 1, 1, 2, 2, 3, 3, 4, 4, 5, 5, 6, 6, 7, 7, 8, 8, 9, 9, 10, 10, 11, 11, 12, 12, 13, 13]
12const WINDOW = 32768, CHAIN = 8 // how many earlier positions one match search tries
13
14const CRC = new Uint32Array(256).map((_, n) => {
15  let c = n
16  for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1
17  return c
18})
19
20const bucket = (t: number[], v: number) => { let j = t.length - 1; while (t[j] > v) j--; return j }
21
22export function deflate(data: Uint8Array): Uint8Array {
23  const out: number[] = []
24  let acc = 0, n = 0
25  const put = (v: number, len: number) => { // LSB-first, as deflate packs data bits
26    acc |= v << n; n += len
27    while (n >= 8) { out.push(acc & 255); acc >>>= 8; n -= 8 }
28  }
29  const code = (c: number, len: number) => { // Huffman codes go MSB-first
30    let r = 0
31    for (let k = 0; k < len; k++) r |= ((c >> k) & 1) << (len - 1 - k)
32    put(r, len)
33  }
34  const sym = (s: number) => {
35    if (s < 144) code(0x30 + s, 8)
36    else if (s < 256) code(0x190 + s - 144, 9)
37    else if (s < 280) code(s - 256, 7)
38    else code(0xc0 + s - 280, 8)
39  }
40
41  const head = new Int32Array(1 << 16).fill(-1), prev = new Int32Array(WINDOW)
42  const hash = (i: number) => ((data[i] << 8) ^ (data[i + 1] << 4) ^ data[i + 2]) & 0xffff
43  const insert = (i: number) => { if (i + 2 < data.length) { const k = hash(i); prev[i % WINDOW] = head[k]; head[k] = i } }
44
45  put(1, 1); put(1, 2) // final block, fixed Huffman
46  for (let i = 0; i < data.length;) {
47    let best = 0, dist = 0
48    if (i + 2 < data.length) {
49      const max = Math.min(258, data.length - i)
50      for (let p = head[hash(i)], tries = CHAIN; p >= 0 && i - p <= WINDOW && tries-- > 0; p = prev[p % WINDOW]) {
51        let l = 0
52        while (l < max && data[p + l] === data[i + l]) l++
53        if (l > best) { best = l; dist = i - p; if (l === max) break }
54      }
55    }
56    if (best < 3) { sym(data[i]); insert(i); i++; continue }
57    const lj = bucket(LEN_BASE, best), dj = bucket(DIST_BASE, dist)
58    sym(257 + lj); put(best - LEN_BASE[lj], LEN_EXTRA[lj])
59    code(dj, 5); put(dist - DIST_BASE[dj], DIST_EXTRA[dj])
60    for (let k = 0; k < best; k++) insert(i + k)
61    i += best
62  }
63  sym(256)
64  if (n > 0) out.push(acc & 255)
65  return Uint8Array.from(out)
66}
67
68/** PNG of a `width`×`height` coverage mask (0..255 per pixel), drawn in `rgb`. */
69export function encodePng(alpha: Uint8Array, width: number, height: number, rgb: [number, number, number]): Uint8Array {
70  const stride = (width + 1) >> 1 // two 4-bit pixels per byte
71  const raw = new Uint8Array((stride + 1) * height) // each row: filter byte 0, then indices
72  for (let y = 0; y < height; y++)
73    for (let x = 0; x < width; x++) {
74      const q = Math.round(alpha[y * width + x] / 17)
75      raw[y * (stride + 1) + 1 + (x >> 1)] |= x & 1 ? q : q << 4
76    }
77
78  let a = 1, b = 0
79  for (const v of raw) { a = (a + v) % 65521; b = (b + a) % 65521 }
80  const z = deflate(raw)
81  const zlib = new Uint8Array(z.length + 6)
82  zlib.set([0x78, 0x01]); zlib.set(z, 2)
83  new DataView(zlib.buffer).setUint32(z.length + 2, ((b << 16) | a) >>> 0)
84
85  const ihdr = new Uint8Array(13)
86  new DataView(ihdr.buffer).setUint32(0, width)
87  new DataView(ihdr.buffer).setUint32(4, height)
88  ihdr.set([4, 3, 0, 0, 0], 8) // 4-bit, palette
89  const plte = new Uint8Array(48)
90  for (let i = 0; i < 16; i++) plte.set(rgb, i * 3)
91  const trns = Uint8Array.from({ length: 16 }, (_, i) => i * 17)
92
93  const chunks: [string, Uint8Array][] = [['IHDR', ihdr], ['PLTE', plte], ['tRNS', trns], ['IDAT', zlib], ['IEND', new Uint8Array(0)]]
94  const png = new Uint8Array(8 + chunks.reduce((s, [, d]) => s + d.length + 12, 0))
95  png.set([137, 80, 78, 71, 13, 10, 26, 10])
96  const view = new DataView(png.buffer)
97  let at = 8
98  for (const [type, data] of chunks) {
99    view.setUint32(at, data.length)
100    png.set([...type].map(c => c.charCodeAt(0)), at + 4)
101    png.set(data, at + 8)
102    let c = 0xffffffff
103    for (let k = at + 4; k < at + 8 + data.length; k++) c = CRC[(c ^ png[k]) & 255] ^ (c >>> 8)
104    view.setUint32(at + 8 + data.length, (c ^ 0xffffffff) >>> 0)
105    at += data.length + 12
106  }
107  return png
108}
109
hooks/raster.ts 237 lines
1// Fills MathJax's SVG output (a LiteElement tree, fontCache 'none') into a
2// coverage mask. MathJax draws glyphs as filled <path>s and rules as <rect>s
3// inside nested <g transform>, the stretched middle of a stretchy brace, arrow
4// or delimiter inside a nested <svg> viewport that clips it, and frames and
5// strikes (\boxed, \fbox, \cancel, \enclose) as stroked <rect>/<ellipse>/<line>.
6// Fills and strokes are each filled with the nonzero rule on a layer of their
7// own, then merged, so a stroke crossing a glyph never cancels against it.
8// ponytail: \color is ignored (all ink in one colour); add when it's wanted.
9
10export type SvgNode = {
11  kind: string
12  attributes?: Record<string, string>
13  children?: SvgNode[]
14}
15
16type Mat = [number, number, number, number, number, number] // x' = a x + c y + e, y' = b x + d y + f
17type Edge = [number, number, number, number]
18type Pt = [number, number]
19type Rect = [number, number, number, number] // device-space x0, y0, x1, y1
20
21const mul = (m: Mat, n: Mat): Mat => [
22  m[0] * n[0] + m[2] * n[1],
23  m[1] * n[0] + m[3] * n[1],
24  m[0] * n[2] + m[2] * n[3],
25  m[1] * n[2] + m[3] * n[3],
26  m[0] * n[4] + m[2] * n[5] + m[4],
27  m[1] * n[4] + m[3] * n[5] + m[5],
28]
29
30function transform(m: Mat, s: string | undefined): Mat {
31  if (!s) return m
32  for (const [, fn, args] of s.matchAll(/(\w+)\s*\(([^)]*)\)/g)) {
33    const v = args.split(/[\s,]+/).filter(Boolean).map(Number)
34    if (fn === 'translate') m = mul(m, [1, 0, 0, 1, v[0], v[1] ?? 0])
35    else if (fn === 'scale') m = mul(m, [v[0], 0, 0, v[1] ?? v[0], 0, 0])
36    else if (fn === 'matrix') m = mul(m, v as Mat)
37  }
38  return m
39}
40
41const apply = (m: Mat, x: number, y: number): Pt => [m[0] * x + m[2] * y + m[4], m[1] * x + m[3] * y + m[5]]
42
43// Flattens SVG path data into closed device-space contours. Quadratic/cubic
44// curves are cut into fixed 8 segments: at glyph sizes of ~20px that is
45// sub-pixel accurate.
46function pathContours(d: string, m: Mat): Pt[][] {
47  const tok = d.match(/[A-Za-z]|[-+]?(?:\d*\.\d+|\d+\.?)(?:[eE][-+]?\d+)?/g) ?? []
48  const out: Pt[][] = []
49  let cur: Pt[] = []
50  let i = 0, cmd = '', x = 0, y = 0, sx = 0, sy = 0, cx = 0, cy = 0
51  const num = () => Number(tok[i++])
52  const close = () => { if (cur.length > 2) out.push(cur); cur = [] } // filling closes every contour
53  const line = (nx: number, ny: number) => { cur.push(apply(m, nx, ny)); x = nx; y = ny }
54  const curve = (pts: number[][]) => { // pts: control points after the current one
55    const p = [[x, y], ...pts]
56    for (let k = 1; k <= 8; k++) {
57      const t = k / 8
58      let q = p
59      while (q.length > 1) q = q.slice(1).map((pt, j) => [q[j][0] + (pt[0] - q[j][0]) * t, q[j][1] + (pt[1] - q[j][1]) * t])
60      line(q[0][0], q[0][1])
61    }
62  }
63  while (i < tok.length) {
64    if (/[A-Za-z]/.test(tok[i])) cmd = tok[i++]
65    const rel = cmd === cmd.toLowerCase(), ox = rel ? x : 0, oy = rel ? y : 0
66    switch (cmd.toUpperCase()) {
67      case 'M': close(); x = sx = num() + ox; y = sy = num() + oy; cx = x; cy = y; cur.push(apply(m, x, y)); cmd = rel ? 'l' : 'L'; break
68      case 'L': line(num() + ox, num() + oy); cx = x; cy = y; break
69      case 'H': line(num() + ox, y); cx = x; cy = y; break
70      case 'V': line(x, num() + oy); cx = x; cy = y; break
71      case 'Q': { const c1 = [num() + ox, num() + oy], p = [num() + ox, num() + oy]; curve([c1, p]); [cx, cy] = c1; break }
72      case 'T': { const c1 = [2 * x - cx, 2 * y - cy], p = [num() + ox, num() + oy]; curve([c1, p]); [cx, cy] = c1; break }
73      case 'C': { const c1 = [num() + ox, num() + oy], c2 = [num() + ox, num() + oy], p = [num() + ox, num() + oy]; curve([c1, c2, p]); [cx, cy] = c2; break }
74      case 'S': { const c1 = [2 * x - cx, 2 * y - cy], c2 = [num() + ox, num() + oy], p = [num() + ox, num() + oy]; curve([c1, c2, p]); [cx, cy] = c2; break }
75      case 'Z': close(); x = sx; y = sy; cx = x; cy = y; cur.push(apply(m, x, y)); break
76      default: close(); return out // arcs and anything else: MathJax glyph data has none
77    }
78  }
79  close()
80  return out
81}
82
83// Sutherland–Hodgman against an axis-aligned rectangle. On a concave contour it
84// can leave zero-area slivers along the rectangle's edge; those add no coverage.
85function clipContour(p: Pt[], r: Rect): Pt[] {
86  for (const [axis, bound, sign] of [[0, r[0], 1], [0, r[2], -1], [1, r[1], 1], [1, r[3], -1]] as const) {
87    const inside = (q: Pt) => sign * (q[axis] - bound) >= 0
88    const cross = (a: Pt, b: Pt): Pt => {
89      const t = (bound - a[axis]) / (b[axis] - a[axis])
90      return [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t]
91    }
92    const next: Pt[] = []
93    p.forEach((q, k) => {
94      const prev = p[(k + p.length - 1) % p.length]
95      if (inside(q)) { if (!inside(prev)) next.push(cross(prev, q)); next.push(q) }
96      else if (inside(prev)) next.push(cross(prev, q))
97    })
98    p = next
99    if (p.length < 3) return []
100  }
101  return p
102}
103
104function emit(contours: Pt[][], clip: Rect | null, out: Edge[]) {
105  for (let c of contours) {
106    if (clip) c = clipContour(c, clip)
107    c.forEach((q, k) => { const n = c[(k + 1) % c.length]; out.push([q[0], q[1], n[0], n[1]]) })
108  }
109}
110
111// A nested <svg> is a viewport: placed at (x, y), its viewBox mapped onto
112// width×height (MathJax never sets preserveAspectRatio, and its viewBox always
113// has the viewport's aspect, so a plain scale is exact), clipping what it draws.
114function viewport(a: Record<string, string>, m: Mat, clip: Rect | null): [Mat, Rect | null] {
115  const x = +(a.x ?? 0), y = +(a.y ?? 0), w = +(a.width ?? 0), h = +(a.height ?? 0)
116  const [vx, vy, vw, vh] = (a.viewBox ?? `0 0 ${w} ${h}`).split(/[\s,]+/).map(Number)
117  const inner = mul(mul(m, [1, 0, 0, 1, x, y]), [vw ? w / vw : 1, 0, 0, vh ? h / vh : 1, -vx * (vw ? w / vw : 1), -vy * (vh ? h / vh : 1)])
118  if (m[1] !== 0 || m[2] !== 0) return [inner, clip] // rotated: no axis-aligned clip; MathJax never rotates
119  const [p, q] = [apply(m, x, y), apply(m, x + w, y + h)]
120  let r: Rect = [Math.min(p[0], q[0]), Math.min(p[1], q[1]), Math.max(p[0], q[0]), Math.max(p[1], q[1])]
121  if (clip) r = [Math.max(r[0], clip[0]), Math.max(r[1], clip[1]), Math.min(r[2], clip[2]), Math.min(r[3], clip[3])]
122  return [inner, r]
123}
124
125type Layers = { fill: Edge[]; stroke: Edge[] }
126
127const signedArea = (c: Pt[]) => c.reduce((s, p, k) => { const q = c[(k + 1) % c.length]; return s + p[0] * q[1] - q[0] * p[1] }, 0)
128
129// A stroke as polygons: one quad per segment (butt ends), plus a square on
130// every vertex of a closed outline with sharp corners (a rectangle's mitred
131// corners). All oriented alike, so on their own layer they simply union.
132function strokeContours(pts: Pt[], closed: boolean, corners: boolean, width: number, m: Mat): Pt[][] {
133  const hw = width / 2, out: Pt[][] = []
134  for (let k = 0; k < (closed ? pts.length : pts.length - 1); k++) {
135    const a = pts[k], b = pts[(k + 1) % pts.length]
136    const len = Math.hypot(b[0] - a[0], b[1] - a[1]) || 1
137    const nx = (-(b[1] - a[1]) / len) * hw, ny = ((b[0] - a[0]) / len) * hw
138    out.push([[a[0] + nx, a[1] + ny], [b[0] + nx, b[1] + ny], [b[0] - nx, b[1] - ny], [a[0] - nx, a[1] - ny]])
139  }
140  if (closed && corners) for (const [x, y] of pts) out.push([[x - hw, y - hw], [x + hw, y - hw], [x + hw, y + hw], [x - hw, y + hw]])
141  return out.map(c => { const d = c.map(([x, y]) => apply(m, x, y)); return signedArea(d) < 0 ? d.reverse() : d })
142}
143
144// Outline of a rectangle with corner radius r (0: sharp) or of an ellipse, in user space.
145function roundRect(x: number, y: number, w: number, h: number, r: number): Pt[] {
146  if (r <= 0) return [[x, y], [x + w, y], [x + w, y + h], [x, y + h]]
147  r = Math.min(r, w / 2, h / 2)
148  const out: Pt[] = []
149  for (const [cx, cy, a0] of [[x + w - r, y + r, -90], [x + w - r, y + h - r, 0], [x + r, y + h - r, 90], [x + r, y + r, 180]])
150    for (let k = 0; k <= 8; k++) { const t = ((a0 + (k * 90) / 8) * Math.PI) / 180; out.push([cx + r * Math.cos(t), cy + r * Math.sin(t)]) }
151  return out
152}
153const ellipse = (cx: number, cy: number, rx: number, ry: number): Pt[] =>
154  Array.from({ length: 64 }, (_, k) => [cx + rx * Math.cos((k * Math.PI) / 32), cy + ry * Math.sin((k * Math.PI) / 32)])
155
156function collect(n: SvgNode, m: Mat, clip: Rect | null, out: Layers) {
157  const a = n.attributes ?? {}
158  if (a['data-mml-node'] === 'merror') throw new Error('TeX error')
159  m = transform(m, a.transform)
160  if (n.kind === 'svg') [m, clip] = viewport(a, m, clip)
161  const sw = n.kind === 'g' ? 0 : +(a['stroke-width'] ?? 0) // on a <g> it only resets inheritance
162  const filled = a.fill !== 'none'
163  const shape = (pts: Pt[], closed: boolean, corners: boolean) => {
164    if (filled && closed) emit([pts.map(([x, y]) => apply(m, x, y))], clip, out.fill)
165    if (sw > 0) emit(strokeContours(pts, closed, corners, sw, m), clip, out.stroke)
166  }
167  if (n.kind === 'path' && a.d && filled) emit(pathContours(a.d, m), clip, out.fill)
168  if (n.kind === 'rect') {
169    const r = +(a.rx ?? a.ry ?? 0)
170    shape(roundRect(+(a.x ?? 0), +(a.y ?? 0), +(a.width ?? 0), +(a.height ?? 0), r), true, r <= 0)
171  }
172  if (n.kind === 'ellipse' || n.kind === 'circle') shape(ellipse(+(a.cx ?? 0), +(a.cy ?? 0), +(a.rx ?? a.r ?? 0), +(a.ry ?? a.r ?? 0)), true, false)
173  if (n.kind === 'line') shape([[+(a.x1 ?? 0), +(a.y1 ?? 0)], [+(a.x2 ?? 0), +(a.y2 ?? 0)]], false, false)
174  for (const c of n.children ?? []) collect(c, m, clip, out)
175}
176
177/** The root <svg>'s viewBox: [minX, minY, width, height] in 1/1000 em. */
178export const viewBox = (svg: SvgNode) => (svg.attributes?.viewBox ?? '0 0 0 0').split(/\s+/).map(Number)
179
180/**
181 * Fills `svg` into a `width`×`height` coverage mask (one byte per pixel, 255 =
182 * fully inside), `pxPerEm` pixels per em, placed at pixel offset (`dx`, `dy`).
183 */
184export function rasterize(svg: SvgNode, o: { width: number; height: number; pxPerEm: number; dx: number; dy: number }) {
185  const [minX, minY] = viewBox(svg), s = o.pxPerEm / 1000
186  const layers: Layers = { fill: [], stroke: [] }
187  for (const c of svg.children ?? []) collect(c, [s, 0, 0, s, o.dx - minX * s, o.dy - minY * s], null, layers) // the root's viewBox is mapped here
188  const px = cover(layers.fill, o.width, o.height)
189  if (layers.stroke.length) cover(layers.stroke, o.width, o.height).forEach((v, i) => { if (v > px[i]) px[i] = v })
190  return px
191}
192
193// Nonzero-winding fill of `edges` into a W×H coverage mask, SS sub-scanlines per
194// pixel row with exact horizontal coverage.
195function cover(edges: Edge[], W: number, H: number) {
196  const SS = 5
197  const acc = new Float32Array(W)
198  const px = new Uint8Array(W * H)
199  const byRow: Edge[][] = Array.from({ length: H }, () => []) // each edge listed under the rows it spans
200  for (const e of edges) {
201    const lo = Math.max(0, Math.floor(Math.min(e[1], e[3]))), hi = Math.min(H - 1, Math.floor(Math.max(e[1], e[3])))
202    for (let r = lo; r <= hi; r++) byRow[r].push(e)
203  }
204  for (let row = 0; row < H; row++) {
205    const live = byRow[row]
206    if (!live.length) continue
207    acc.fill(0)
208    for (let k = 0; k < SS; k++) {
209      const y = row + (k + 0.5) / SS
210      const xs: [number, number][] = []
211      for (const [x0, y0, x1, y1] of live) {
212        if ((y0 <= y) === (y1 <= y)) continue
213        xs.push([x0 + ((y - y0) / (y1 - y0)) * (x1 - x0), y1 > y0 ? 1 : -1])
214      }
215      xs.sort((p, q) => p[0] - q[0])
216      let wind = 0
217      for (let j = 0; j < xs.length - 1; j++) {
218        wind += xs[j][1]
219        if (wind !== 0) span(acc, xs[j][0], xs[j + 1][0], 1 / SS)
220      }
221    }
222    for (let i = 0; i < W; i++) if (acc[i] > 0) px[row * W + i] = Math.round(Math.min(1, acc[i]) * 255)
223  }
224  return px
225}
226
227// Adds horizontal coverage of [xa, xb) with weight w, fractional at both ends.
228function span(acc: Float32Array, xa: number, xb: number, w: number) {
229  xa = Math.max(0, xa); xb = Math.min(acc.length, xb)
230  if (xb <= xa) return
231  const ia = Math.floor(xa), ib = Math.floor(xb)
232  if (ia === ib) { acc[ia] += (xb - xa) * w; return }
233  acc[ia] += (ia + 1 - xa) * w
234  for (let i = ia + 1; i < ib; i++) acc[i] += w
235  if (ib < acc.length) acc[ib] += (xb - ib) * w
236}
237
hooks/vendor/latex-inline/unicode.ts 616 lines
1import { cellWidth } from './support'
2
3// LaTeX as plain Unicode text, for terminals that can't show the plugin's
4// pictures. Symbols become their characters, scripts the small letters
5// Unicode has (a caret or an underscore where it has none), fractions and
6// roots go on one line, and spacing follows TeX's: the source's spaces are
7// dropped, and binary operators and relations get a space on each side.
8
9type Kind = 'ord' | 'op' | 'bin' | 'rel' | 'open' | 'close' | 'punct' | 'space'
10// An atom of TeX's math list. `loose`: it ends in a one-character script
11// written with a caret or an underscore, so a space keeps what follows apart.
12// `bar`: a | or ‖ written without saying whether it opens or closes.
13type Atom = { kind: Kind; text: string; loose?: boolean; bar?: boolean }
14type Font = { upper: number; lower: number | null; digits: number | null; special: Record<string, string> }
15
16const ORD: Record<string, string> = {
17  alpha: 'α', beta: 'β', gamma: 'γ', delta: 'δ', epsilon: 'ϵ', varepsilon: 'ε', zeta: 'ζ', eta: 'η',
18  theta: 'θ', vartheta: 'ϑ', iota: 'ι', kappa: 'κ', lambda: 'λ', mu: 'μ', nu: 'ν', xi: 'ξ', pi: 'π',
19  varpi: 'ϖ', rho: 'ρ', varrho: 'ϱ', sigma: 'σ', varsigma: 'ς', tau: 'τ', upsilon: 'υ', phi: 'ϕ',
20  varphi: 'φ', chi: 'χ', psi: 'ψ', omega: 'ω', Gamma: 'Γ', Delta: 'Δ', Theta: 'Θ', Lambda: 'Λ',
21  Xi: 'Ξ', Pi: 'Π', Sigma: 'Σ', Upsilon: 'Υ', Phi: 'Φ', Psi: 'Ψ', Omega: 'Ω',
22  partial: '∂', nabla: '∇', infty: '∞', forall: '∀', exists: '∃', nexists: '∄', neg: '¬', lnot: '¬',
23  emptyset: '∅', varnothing: '∅', ell: 'ℓ', hbar: 'ℏ', Re: 'ℜ', Im: 'ℑ', aleph: 'ℵ', angle: '∠',
24  triangle: '△', top: '⊤', bot: '⊥', prime: '′', dagger: '†', dots: '…', ldots: '…', cdots: '⋯',
25  vdots: '⋮', ddots: '⋱', vert: '|', Vert: '‖', backslash: '\\',
26}
27
28const BIN: Record<string, string> = {
29  cdot: '·', times: '×', div: '÷', pm: '±', mp: '∓', ast: '∗', star: '⋆', circ: '∘', bullet: '•',
30  oplus: '⊕', ominus: '⊖', otimes: '⊗', odot: '⊙', cup: '∪', cap: '∩', setminus: '∖', wedge: '∧',
31  land: '∧', vee: '∨', lor: '∨',
32}
33
34const REL: Record<string, string> = {
35  le: '≤', leq: '≤', ge: '≥', geq: '≥', ne: '≠', neq: '≠', approx: '≈', equiv: '≡', sim: '∼',
36  simeq: '≃', cong: '≅', propto: '∝', ll: '≪', gg: '≫', lesssim: '≲', gtrsim: '≳', prec: '≺',
37  succ: '≻', preceq: '⪯', succeq: '⪰', subset: '⊂', subseteq: '⊆', subsetneq: '⊊', supset: '⊃',
38  supseteq: '⊇', in: '∈', notin: '∉', ni: '∋', mid: '∣', parallel: '∥', perp: '⊥', models: '⊨',
39  vdash: '⊢', coloneqq: '≔', triangleq: '≜', doteq: '≐', asymp: '≍', to: '→', rightarrow: '→',
40  leftarrow: '←', gets: '←', Rightarrow: '⇒', Leftarrow: '⇐', Leftrightarrow: '⇔', iff: '⟺',
41  implies: '⟹', impliedby: '⟸', mapsto: '↦', leftrightarrow: '↔', uparrow: '↑', downarrow: '↓',
42  longrightarrow: '⟶', longleftarrow: '⟵', longmapsto: '⟼', hookrightarrow: '↪', leadsto: '⇝',
43  rightleftharpoons: '⇌',
44}
45
46const OP: Record<string, string> = {
47  sum: '∑', prod: '∏', coprod: '∐', int: '∫', iint: '∬', iiint: '∭', oint: '∮', bigcup: '⋃',
48  bigcap: '⋂', bigoplus: '⨁', bigotimes: '⨂', bigvee: '⋁', bigwedge: '⋀',
49}
50
51const OPEN: Record<string, string> = { langle: '⟨', lfloor: '⌊', lceil: '⌈', lvert: '|', lVert: '‖', lbrace: '{', lbrack: '[' }
52const CLOSE: Record<string, string> = { rangle: '⟩', rfloor: '⌋', rceil: '⌉', rvert: '|', rVert: '‖', rbrace: '}', rbrack: ']' }
53const SPACES: Record<string, string> = { quad: '  ', qquad: '    ', enspace: ' ', thinspace: ' ', medspace: ' ', thickspace: ' ' }
54
55// Operator names TeX sets upright, with a space from what they apply to.
56const FUNCTIONS = new Set([
57  'log', 'ln', 'lg', 'exp', 'sin', 'cos', 'tan', 'sec', 'csc', 'cot', 'sinh', 'cosh', 'tanh', 'arcsin',
58  'arccos', 'arctan', 'max', 'min', 'arg', 'argmax', 'argmin', 'lim', 'limsup', 'liminf', 'sup', 'inf',
59  'det', 'dim', 'ker', 'deg', 'gcd', 'hom', 'Pr', 'tr', 'Tr', 'rank', 'diag', 'sgn',
60])
61
62// Single-character commands: spacing and escaped characters.
63const ESCAPED: Record<string, [Kind, string]> = {
64  ',': ['space', ' '], ':': ['space', ' '], ';': ['space', ' '], '>': ['space', ' '], ' ': ['space', ' '],
65  '!': ['space', ''], '\\': ['space', '; '], '{': ['open', '{'], '}': ['close', '}'], '|': ['ord', '‖'],
66  '%': ['ord', '%'], $: ['ord', '$'], '&': ['ord', '&'], '#': ['ord', '#'], _: ['ord', '_'],
67}
68
69const IGNORED = new Set(['displaystyle', 'textstyle', 'scriptstyle', 'scriptscriptstyle', 'limits', 'nolimits', 'nonumber', 'notag'])
70const SKIPS_ARGUMENT = new Set(['label', 'tag', 'color', 'phantom', 'hphantom', 'vphantom'])
71// Sizes the delimiter after them, which stays.
72const DELIMITER_SIZES = new Set(['left', 'right', 'middle', 'big', 'Big', 'bigg', 'Bigg', 'bigl', 'bigr', 'Bigl', 'Bigr', 'biggl', 'biggr', 'Biggl', 'Biggr'])
73const TEXT = new Set(['text', 'textrm', 'textbf', 'textit', 'textsf', 'texttt', 'textnormal', 'emph', 'mbox', 'hbox'])
74// Show their argument as it is.
75const STYLES = new Set(['mathrm', 'mathbf', 'mathit', 'mathsf', 'mathtt', 'mathnormal', 'boldsymbol', 'bm', 'underline', 'boxed', 'cancel', 'underbrace', 'overbrace'])
76const CLASSES: Record<string, Kind> = { mathord: 'ord', mathop: 'op', mathbin: 'bin', mathrel: 'rel', mathopen: 'open', mathclose: 'close', mathpunct: 'punct' }
77const FRACTIONS = new Set(['frac', 'dfrac', 'tfrac', 'cfrac'])
78const BINOMIALS = new Set(['binom', 'dbinom', 'tbinom'])
79
80const ACCENTS: Record<string, string> = {
81  hat: '̂', widehat: '̂', check: '̌', bar: '̄', overline: '̅', tilde: '̃',
82  widetilde: '̃', dot: '̇', ddot: '̈', vec: '⃗', acute: '́', grave: '̀', breve: '̆',
83}
84
85const NEGATED: Record<string, string> = {
86  '=': '≠', '∈': '∉', '⊂': '⊄', '⊆': '⊈', '⊃': '⊅', '⊇': '⊉', '≡': '≢', '∼': '≁', '<': '≮', '>': '≯',
87  '≤': '≰', '≥': '≱', '∣': '∤', '|': '∤', '∃': '∄', '≈': '≉', '≃': '≄', '≅': '≇',
88}
89
90// The delimiters an environment puts around its rows.
91const ENVIRONMENTS: Record<string, [string, string]> = {
92  pmatrix: ['(', ')'], bmatrix: ['[', ']'], Bmatrix: ['{', '}'], vmatrix: ['|', '|'], Vmatrix: ['‖', '‖'], cases: ['{', ''],
93}
94
95const DOUBLE_STRUCK: Font = { upper: 0x1d538, lower: 0x1d552, digits: 0x1d7d8, special: { C: 'ℂ', H: 'ℍ', N: 'ℕ', P: 'ℙ', Q: 'ℚ', R: 'ℝ', Z: 'ℤ' } }
96const SCRIPT: Font = {
97  upper: 0x1d49c, lower: 0x1d4b6, digits: null,
98  special: { B: 'ℬ', E: 'ℰ', F: 'ℱ', H: 'ℋ', I: 'ℐ', L: 'ℒ', M: 'ℳ', R: 'ℛ', e: 'ℯ', g: 'ℊ', o: 'ℴ' },
99}
100const FRAKTUR: Font = { upper: 0x1d504, lower: 0x1d51e, digits: null, special: { C: 'ℭ', H: 'ℌ', I: 'ℑ', R: 'ℜ', Z: 'ℨ' } }
101const FONTS: Record<string, Font> = { mathbb: DOUBLE_STRUCK, Bbb: DOUBLE_STRUCK, mathcal: SCRIPT, mathscr: SCRIPT, mathfrak: FRAKTUR }
102
103const SUPERSCRIPTS: Record<string, string> = {
104  0: '⁰', 1: '¹', 2: '²', 3: '³', 4: '⁴', 5: '⁵', 6: '⁶', 7: '⁷', 8: '⁸', 9: '⁹', '+': '⁺', '−': '⁻',
105  '=': '⁼', '(': '⁽', ')': '⁾', a: 'ᵃ', b: 'ᵇ', c: 'ᶜ', d: 'ᵈ', e: 'ᵉ', f: 'ᶠ', g: 'ᵍ', h: 'ʰ', i: 'ⁱ',
106  j: 'ʲ', k: 'ᵏ', l: 'ˡ', m: 'ᵐ', n: 'ⁿ', o: 'ᵒ', p: 'ᵖ', r: 'ʳ', s: 'ˢ', t: 'ᵗ', u: 'ᵘ', v: 'ᵛ', w: 'ʷ',
107  x: 'ˣ', y: 'ʸ', z: 'ᶻ', A: 'ᴬ', B: 'ᴮ', D: 'ᴰ', E: 'ᴱ', G: 'ᴳ', H: 'ᴴ', I: 'ᴵ', J: 'ᴶ', K: 'ᴷ',
108  L: 'ᴸ', M: 'ᴹ', N: 'ᴺ', O: 'ᴼ', P: 'ᴾ', R: 'ᴿ', T: 'ᵀ', U: 'ᵁ', V: 'ⱽ', W: 'ᵂ', 'α': 'ᵅ', 'β': 'ᵝ',
109  'γ': 'ᵞ', 'δ': 'ᵟ', 'ε': 'ᵋ', 'ϵ': 'ᵋ', 'θ': 'ᶿ', 'ι': 'ᶥ', 'φ': 'ᵠ', 'ϕ': 'ᵠ', 'χ': 'ᵡ', '′': '′',
110  '*': '*', '∗': '*', '⊤': 'ᵀ', '∘': '°', ',': ',',
111}
112
113const SUBSCRIPTS: Record<string, string> = {
114  0: '₀', 1: '₁', 2: '₂', 3: '₃', 4: '₄', 5: '₅', 6: '₆', 7: '₇', 8: '₈', 9: '₉', '+': '₊', '−': '₋',
115  '=': '₌', '(': '₍', ')': '₎', a: 'ₐ', e: 'ₑ', h: 'ₕ', i: 'ᵢ', j: 'ⱼ', k: 'ₖ', l: 'ₗ', m: 'ₘ', n: 'ₙ',
116  o: 'ₒ', p: 'ₚ', r: 'ᵣ', s: 'ₛ', t: 'ₜ', u: 'ᵤ', v: 'ᵥ', x: 'ₓ', 'β': 'ᵦ', 'γ': 'ᵧ', 'ρ': 'ᵨ',
117  'φ': 'ᵩ', 'ϕ': 'ᵩ', 'χ': 'ᵪ', ',': ',',
118}
119
120const SYMBOLS = new Map<string, [Kind, string]>()
121const CHAR_KINDS = new Map<string, Kind>([
122  ['+', 'bin'], ['*', 'bin'], ['−', 'bin'], ['=', 'rel'], ['<', 'rel'], ['>', 'rel'], [':', 'rel'],
123  [',', 'punct'], [';', 'punct'], ['(', 'open'], ['[', 'open'], [')', 'close'], [']', 'close'], ['!', 'close'], ['?', 'close'],
124])
125for (const [kind, table] of [['ord', ORD], ['bin', BIN], ['rel', REL], ['op', OP], ['open', OPEN], ['close', CLOSE], ['space', SPACES]] as const) {
126  for (const [name, text] of Object.entries(table)) {
127    SYMBOLS.set(name, [kind, text])
128    if (kind !== 'ord' && kind !== 'space' && !CHAR_KINDS.has(text)) CHAR_KINDS.set(text, kind)
129  }
130}
131SYMBOLS.set('colon', ['punct', ':'])
132// A bare | or ‖ opens or closes by where it stands (see kindsOf).
133CHAR_KINDS.delete('|')
134CHAR_KINDS.delete('‖')
135
136// What puts a fraction's part in parentheses when it is outside brackets.
137const OPERATOR_CHARS = new Set([' ', '/', ...[...CHAR_KINDS].filter(([, kind]) => kind === 'bin' || kind === 'rel' || kind === 'punct').map(([text]) => text)])
138
139// TeX's spacing between atoms (The TeXbook, chapter 18): a space between
140// these pairs everywhere, and between the second set's outside scripts.
141const SPACED = new Set(['ord op', 'op ord', 'op op', 'close op'])
142const SPACED_OUTSIDE_SCRIPTS = new Set([
143  'ord bin', 'ord rel', 'op rel', 'bin ord', 'bin op', 'bin open', 'rel ord', 'rel op', 'rel open',
144  'close bin', 'close rel', 'punct ord', 'punct op', 'punct rel', 'punct open', 'punct close', 'punct punct',
145])
146
147const WHITESPACE = /\s/
148
149function atom(kind: Kind, text: string): Atom {
150  return kind === 'ord' && (text === '|' || text === '‖') ? { kind, text, bar: true } : { kind, text }
151}
152
153function charAtom(ch: string): Atom {
154  if (ch === '-') return atom('bin', '−')
155  return atom(CHAR_KINDS.get(ch) ?? 'ord', ch)
156}
157
158// TeX's rules for a binary operator with nothing on one side to combine
159// (`-x`, `a = -b`): it is ordinary, so it gets no spaces. Unlike TeX, a bare
160// | or ‖ opens where an operand would start and closes elsewhere, so the
161// sign in |-5| is not spaced as a minus.
162function kindsOf(atoms: Atom[]): (Kind | undefined)[] {
163  const kinds: (Kind | undefined)[] = atoms.map(a => a.kind)
164  let previous = -1
165  for (let k = 0; k < kinds.length; k++) {
166    if (kinds[k] === 'space') continue
167    const before = previous < 0 ? null : kinds[previous]
168    if (atoms[k]?.bar) {
169      const startsOperand = before === null || before === 'bin' || before === 'rel' || before === 'open' || before === 'punct' || before === 'op'
170      kinds[k] = startsOperand ? 'open' : 'close'
171    }
172    const kind = kinds[k]
173    if (kind === 'bin' && (before === null || before === 'bin' || before === 'op' || before === 'rel' || before === 'open' || before === 'punct')) {
174      kinds[k] = 'ord'
175    } else if ((kind === 'rel' || kind === 'close' || kind === 'punct') && before === 'bin') {
176      kinds[previous] = 'ord'
177    }
178    previous = k
179  }
180  if (previous >= 0 && kinds[previous] === 'bin') kinds[previous] = 'ord'
181  return kinds
182}
183
184function render(atoms: Atom[], script: boolean): string {
185  const kinds = kindsOf(atoms)
186  let out = ''
187  let previous = -1
188  atoms.forEach((current, k) => {
189    if (current.kind === 'space') {
190      out += current.text
191      return
192    }
193    const left = atoms[previous]
194    if (left) {
195      const pair = `${kinds[previous]} ${kinds[k]}`
196      const isSpaced =
197        SPACED.has(pair) ||
198        (!script && SPACED_OUTSIDE_SCRIPTS.has(pair)) ||
199        (left.loose === true && kinds[k] !== 'close' && kinds[k] !== 'punct')
200      if (isSpaced && !out.endsWith(' ') && !current.text.startsWith(' ')) out += ' '
201    }
202    out += current.text
203    previous = k
204  })
205  return out
206}
207
208// Adds a superscript (^) or subscript (_) to the atom before it: in Unicode's
209// small letters when it has them all, else written out after the mark.
210function attach(atoms: Atom[], mark: '^' | '_', text: string): void {
211  if (text === '') return
212  const table = mark === '^' ? SUPERSCRIPTS : SUBSCRIPTS
213  let base = atoms[atoms.length - 1]
214  if (!base || base.kind === 'space') {
215    base = atom('ord', '')
216    atoms.push(base)
217  }
218  const chars = [...text]
219  if (chars.every(ch => table[ch] !== undefined)) {
220    base.text += chars.map(ch => table[ch]).join('')
221  } else if (chars.length === 1) {
222    base.text += mark + text
223    base.loose = true
224  } else {
225    base.text += /^\(.*\)$/.test(text) ? mark + text : `${mark}(${text})`
226  }
227}
228
229// A {...} group is one atom: itself when it holds one, else an ordinary one.
230function group(atoms: Atom[], script: boolean): Atom {
231  const [only] = atoms
232  return atoms.length === 1 && only ? only : atom('ord', render(atoms, script))
233}
234
235// A fraction's part or a root's body on one line, at full size and so spaced
236// as text, in parentheses when it holds an operator or a space outside brackets.
237function linear(atoms: Atom[]): string {
238  const text = render(atoms, false)
239  let depth = 0
240  let index = 0
241  for (const ch of text) {
242    // A leading sign needs no parentheses: −b/2a.
243    if (index++ === 0 && (ch === '−' || ch === '+')) continue
244    if ('([{⟨⌊⌈'.includes(ch)) depth++
245    else if (')]}⟩⌋⌉'.includes(ch)) depth--
246    else if (depth === 0 && OPERATOR_CHARS.has(ch)) return `(${text})`
247  }
248  return text
249}
250
251function styled(text: string, font: Font): string {
252  return [...text]
253    .map(ch => {
254      const special = font.special[ch]
255      if (special) return special
256      const code = ch.codePointAt(0) ?? 0
257      if (code >= 65 && code <= 90) return String.fromCodePoint(font.upper + code - 65)
258      if (font.lower !== null && code >= 97 && code <= 122) return String.fromCodePoint(font.lower + code - 97)
259      if (font.digits !== null && code >= 48 && code <= 57) return String.fromCodePoint(font.digits + code - 48)
260      return ch
261    })
262    .join('')
263}
264
265function combine(text: string, mark: string): string {
266  return [...text].map(ch => (WHITESPACE.test(ch) ? ch : ch + mark)).join('')
267}
268
269function root(index: string | null): string {
270  if (index === null) return '√'
271  const degree = texToUnicode(index)
272  if (degree === '3') return '∛'
273  if (degree === '4') return '∜'
274  const small = [...degree].map(ch => SUPERSCRIPTS[ch])
275  return small.every(ch => ch !== undefined) ? `${small.join('')}√` : `${degree}√`
276}
277
278// \text{} keeps its spaces; math inside it is converted too.
279function textOf(raw: string): string {
280  return raw
281    .replace(/\\\$/g, '\u0000')
282    .replace(/\$([^$]+)\$/g, (_, tex: string) => texToUnicode(tex))
283    .replace(/(?<!\\)[{}]/g, '')
284    .replace(/\\([%&#_{} ])/g, '$1')
285    .replace(/\u0000/g, '$')
286}
287
288class Reader {
289  private i = 0
290  private readonly environments: string[] = []
291
292  constructor(private readonly s: string) {}
293
294  // The atoms up to `stop`, left unread, or the end.
295  atoms(script: boolean, stop?: string): Atom[] {
296    const out: Atom[] = []
297    while (this.i < this.s.length) {
298      const ch = this.s[this.i] ?? ''
299      if (ch === stop) break
300      if (WHITESPACE.test(ch) || ch === '}') {
301        this.i++
302      } else if (ch === '^' || ch === '_') {
303        this.i++
304        attach(out, ch, render(this.argument(true), true))
305      } else if (ch === "'") {
306        this.i++
307        attach(out, '^', '′')
308      } else if (ch === '{') {
309        out.push(group(this.group(script), script))
310      } else if (ch === '\\') {
311        out.push(...this.command(script))
312      } else if (ch === '&') {
313        this.i++
314        out.push(atom('space', this.environments[this.environments.length - 1] === 'cases' ? ', ' : ' '))
315      } else if (ch === '~') {
316        this.i++
317        out.push(atom('space', ' '))
318      } else {
319        const char = String.fromCodePoint(this.s.codePointAt(this.i) ?? 0)
320        this.i += char.length
321        out.push(charAtom(char))
322      }
323    }
324    return out
325  }
326
327  // The atoms of a {...} group, its braces read.
328  private group(script: boolean): Atom[] {
329    this.i++
330    const inner = this.atoms(script, '}')
331    this.i++
332    return inner
333  }
334
335  // One argument: a {...} group, a command or a character.
336  private argument(script: boolean): Atom[] {
337    this.skipSpaces()
338    const ch = this.s[this.i]
339    if (ch === undefined) return []
340    if (ch === '{') return this.group(script)
341    if (ch === '\\') return this.command(script)
342    const char = String.fromCodePoint(this.s.codePointAt(this.i) ?? 0)
343    this.i += char.length
344    return [charAtom(char)]
345  }
346
347  // A {...} argument's text as written, for \text and environment names.
348  private raw(): string {
349    this.skipSpaces()
350    if (this.s[this.i] !== '{') {
351      const ch = this.s[this.i] ?? ''
352      this.i += ch.length
353      return ch
354    }
355    const start = this.i + 1
356    let depth = 0
357    for (; this.i < this.s.length; this.i++) {
358      const ch = this.s[this.i]
359      if (ch === '\\') this.i++
360      else if (ch === '{') depth++
361      else if (ch === '}' && --depth === 0) break
362    }
363    const text = this.s.slice(start, this.i)
364    this.i++
365    return text
366  }
367
368  private optional(): string | null {
369    this.skipSpaces()
370    if (this.s[this.i] !== '[') return null
371    const end = this.s.indexOf(']', this.i)
372    if (end < 0) return null
373    const text = this.s.slice(this.i + 1, end)
374    this.i = end + 1
375    return text
376  }
377
378  private skipSpaces(): void {
379    while (WHITESPACE.test(this.s[this.i] ?? '')) this.i++
380  }
381
382  private command(script: boolean): Atom[] {
383    this.i++
384    const name = /^[A-Za-z]+/.exec(this.s.slice(this.i))?.[0]
385    if (name === undefined) {
386      const ch = this.s[this.i] ?? ''
387      this.i += ch.length
388      const escaped = ESCAPED[ch]
389      return ch === '' ? [] : [escaped ? atom(...escaped) : atom('ord', ch)]
390    }
391    this.i += name.length
392
393    const symbol = SYMBOLS.get(name)
394    if (symbol) return [atom(...symbol)]
395    if (FUNCTIONS.has(name)) return [atom('op', name)]
396    if (IGNORED.has(name)) return []
397    if (DELIMITER_SIZES.has(name)) {
398      this.skipSpaces()
399      if (this.s[this.i] === '.') this.i++
400      return []
401    }
402    if (SKIPS_ARGUMENT.has(name)) {
403      this.raw()
404      return []
405    }
406    if (TEXT.has(name)) return [atom('ord', textOf(this.raw()))]
407    if (STYLES.has(name)) return [group(this.argument(script), script)]
408    const kind = CLASSES[name]
409    if (kind) return [atom(kind, render(this.argument(script), script))]
410    const font = FONTS[name]
411    if (font) return [atom('ord', styled(render(this.argument(script), script), font))]
412    const accent = ACCENTS[name]
413    if (accent) return [atom('ord', combine(render(this.argument(script), script), accent))]
414    if (name === 'operatorname') {
415      if (this.s[this.i] === '*') this.i++
416      return [atom('op', render(this.argument(script), script))]
417    }
418    if (FRACTIONS.has(name)) {
419      const top = linear(this.argument(false))
420      return [atom('ord', `${top}/${linear(this.argument(false))}`)]
421    }
422    if (BINOMIALS.has(name)) {
423      const top = render(this.argument(false), false)
424      return [atom('ord', `C(${top}, ${render(this.argument(false), false)})`)]
425    }
426    if (name === 'sqrt') {
427      const index = this.optional()
428      return [atom('ord', `${root(index)}${linear(this.argument(false))}`)]
429    }
430    if (name === 'overset' || name === 'stackrel' || name === 'underset') {
431      const note = render(this.argument(true), true)
432      const atoms = [group(this.argument(script), script)]
433      attach(atoms, name === 'underset' ? '_' : '^', note)
434      return atoms
435    }
436    if (name === 'textcolor') {
437      this.raw()
438      return this.argument(script)
439    }
440    if (name === 'not') {
441      const negated = render(this.argument(script), script)
442      return [atom('rel', NEGATED[negated] ?? combine(negated, '̸'))]
443    }
444    if (name === 'mod' || name === 'bmod') return [atom('space', ' '), atom('bin', 'mod'), atom('space', ' ')]
445    if (name === 'pmod') return [atom('space', ' '), atom('ord', `(mod ${render(this.argument(script), script)})`)]
446    if (name === 'begin') {
447      const environment = this.raw()
448      this.environments.push(environment)
449      if (environment === 'array') this.raw()
450      const open = ENVIRONMENTS[environment]?.[0]
451      return open ? [atom('open', open)] : []
452    }
453    if (name === 'end') {
454      const close = ENVIRONMENTS[this.raw()]?.[1]
455      this.environments.pop()
456      return close ? [atom('close', close)] : []
457    }
458    return [{ kind: 'ord', text: `\\${name}`, loose: true }]
459  }
460}
461
462export function texToUnicode(tex: string): string {
463  return render(new Reader(tex).atoms(false), false).trim()
464}
465
466const DIGIT = /[0-9]/
467const FENCE = /^\s*(`{3,}|~{3,})/
468// Pads display math. Markdown drops spaces at the start of a line but keeps
469// no-break spaces.
470const PAD = ' '
471// What Markdown would read as markup in converted math.
472const MARKDOWN_SPECIAL = /[\\`*_[\]<>|#~&]/g
473
474type Formula = { end: number; tex: string }
475
476// The formula a $ at `open` starts, by the reply parser's rules (see
477// closingDollar in parse.ts): where it ends and its TeX, or null when the
478// dollar is text.
479function formulaAt(s: string, open: number): Formula | null {
480  if (s[open + 1] === '$') {
481    const end = s.indexOf('$$', open + 2)
482    return end > open + 2 ? { end: end + 2, tex: s.slice(open + 2, end).trim() } : null
483  }
484  const first = s[open + 1]
485  if (first === undefined || WHITESPACE.test(first)) return null
486  for (let j = open + 1; j < s.length; j++) {
487    const ch = s[j]
488    if (ch === '\\') {
489      j++
490      continue
491    }
492    if (ch === '`') return null
493    if (ch !== '$') continue
494    const after = s[j + 1]
495    const isClosing = !WHITESPACE.test(s[j - 1] ?? ' ') && !(after !== undefined && DIGIT.test(after))
496    return isClosing ? { end: j + 1, tex: s.slice(open + 1, j) } : null
497  }
498  return null
499}
500
501// The formula a \( or \[ at `open` starts, or null.
502function bracketedAt(s: string, open: number): Formula | null {
503  const kind = s[open + 1]
504  if (kind !== '(' && kind !== '[') return null
505  const end = s.indexOf(kind === '(' ? '\\)' : '\\]', open + 2)
506  return end > open + 2 ? { end: end + 2, tex: s.slice(open + 2, end).trim() } : null
507}
508
509// Where the next run of exactly `length` backticks starts, or -1: the end of
510// the code span such a run opened.
511function closingRun(s: string, from: number, length: number): number {
512  let j = from
513  while (j < s.length) {
514    if (s[j] !== '`') {
515      j++
516      continue
517    }
518    let run = 1
519    while (s[j + run] === '`') run++
520    if (run === length) return j
521    j += run
522  }
523  return -1
524}
525
526function escapeMarkdown(text: string): string {
527  return text.replace(MARKDOWN_SPECIAL, ch => `\\${ch}`)
528}
529
530// A formula as Unicode, or null when it can't be converted.
531function plainOf(tex: string): string | null {
532  try {
533    return texToUnicode(tex)
534  } catch {
535    return null
536  }
537}
538
539// One paragraph with its math converted and its code spans as written.
540function convertParagraph(s: string): string {
541  let out = ''
542  let i = 0
543  while (i < s.length) {
544    const ch = s[i] ?? ''
545    if (ch === '`') {
546      let run = 1
547      while (s[i + run] === '`') run++
548      const close = closingRun(s, i + run, run)
549      const end = close < 0 ? i + run : close + run
550      out += s.slice(i, end)
551      i = end
552      continue
553    }
554    const formula = ch === '$' ? formulaAt(s, i) : ch === '\\' ? bracketedAt(s, i) : null
555    const plain = formula ? plainOf(formula.tex) : null
556    if (formula && plain !== null) {
557      out += escapeMarkdown(plain)
558      i = formula.end
559    } else if (ch === '\\') {
560      out += s.slice(i, i + 2)
561      i += 2
562    } else {
563      out += ch
564      i++
565    }
566  }
567  return out
568}
569
570// A paragraph that is one formula and nothing else is display math, centered
571// in `width` cells or indented when the width is unknown.
572function convertBlock(lines: string[], width: number | undefined): string {
573  const text = lines.join('\n')
574  const trimmed = text.trim()
575  const formula = trimmed.startsWith('$$') ? formulaAt(trimmed, 0) : trimmed.startsWith('\\[') ? bracketedAt(trimmed, 0) : null
576  const plain = formula && formula.end === trimmed.length ? plainOf(formula.tex) : null
577  if (plain === null) return convertParagraph(text)
578  const indent = /^\s*/.exec(text)?.[0] ?? ''
579  const pad = width === undefined ? 4 : Math.max(0, Math.floor((width - cellWidth(indent) - cellWidth(plain)) / 2))
580  return indent + PAD.repeat(pad) + escapeMarkdown(plain)
581}
582
583// A reply's math as Unicode text, its markdown otherwise as written: fenced
584// code keeps its dollars, a formula alone in its paragraph is centered in
585// `width` cells, and what Markdown would read as markup in the converted math
586// is escaped.
587export function unicodeMath(markdown: string, width?: number): string {
588  const out: string[] = []
589  let paragraph: string[] = []
590  let fence: string | null = null
591  const flush = () => {
592    if (paragraph.length > 0) out.push(convertBlock(paragraph, width))
593    paragraph = []
594  }
595  for (const line of markdown.split('\n')) {
596    if (fence !== null) {
597      out.push(line)
598      if (line.trim().startsWith(fence)) fence = null
599      continue
600    }
601    const opening = FENCE.exec(line)
602    if (opening) {
603      flush()
604      fence = opening[1] ?? '```'
605      out.push(line)
606    } else if (line.trim() === '') {
607      flush()
608      out.push(line)
609    } else {
610      paragraph.push(line)
611    }
612  }
613  flush()
614  return out.join('\n')
615}
616
hooks/vendor/latex-inline/support.ts 65 lines
1export type Ink = { math: string; code: string }
2
3// Claude Code's dark and light themes; `code` is a blue near each theme's own
4// inline-code color, for code spans in a paragraph the mod draws itself.
5const DARK_INK: Ink = { math: '#e8e8e8', code: '#b1b9f9' }
6const LIGHT_INK: Ink = { math: '#24292f', code: '#5769f7' }
7
8export type MathStyle = 'pictures' | 'text' | 'off'
9
10// Pictures reach the screen through the kitty graphics protocol's Unicode
11// placeholders, which Ghostty (and cmux, built on it) and kitty speak; tmux
12// passes none of it through.
13function showsPictures(env: Record<string, string | undefined>): boolean {
14  if (env.TMUX) return false
15  return (
16    env.TERM_PROGRAM === 'ghostty' ||
17    env.TERM === 'xterm-ghostty' ||
18    Boolean(env.TERM?.includes('kitty')) ||
19    Boolean(env.KITTY_WINDOW_ID)
20  )
21}
22
23// How replies show their math: pictures where the terminal can draw them,
24// Unicode text elsewhere. `mode` is the user's override: `on` always draws
25// pictures, `text` always writes Unicode, `off` leaves the LaTeX as written.
26export function mathStyle(env: Record<string, string | undefined>, mode: string): MathStyle {
27  if (mode === 'off') return 'off'
28  if (mode === 'text') return 'text'
29  if (mode === 'on') return 'pictures'
30  return showsPictures(env) ? 'pictures' : 'text'
31}
32
33// Terminal cells a string takes: Hangul, CJK and emoji take two, combining
34// marks none.
35export function cellWidth(text: string): number {
36  let width = 0
37  for (const ch of text) {
38    const c = ch.codePointAt(0) ?? 0
39    const isCombining =
40      (c >= 0x0300 && c <= 0x036f) ||
41      (c >= 0x1ab0 && c <= 0x1aff) ||
42      (c >= 0x1dc0 && c <= 0x1dff) ||
43      (c >= 0x20d0 && c <= 0x20ff) ||
44      (c >= 0xfe20 && c <= 0xfe2f)
45    if (isCombining) continue
46    const isWide =
47      (c >= 0x1100 && c <= 0x115f) ||
48      (c >= 0x2e80 && c <= 0xa4cf) ||
49      (c >= 0xac00 && c <= 0xd7a3) ||
50      (c >= 0xf900 && c <= 0xfaff) ||
51      (c >= 0xfe30 && c <= 0xfe4f) ||
52      (c >= 0xff00 && c <= 0xff60) ||
53      (c >= 0xffe0 && c <= 0xffe6) ||
54      (c >= 0x1f300 && c <= 0x1faff)
55    width += isWide ? 2 : 1
56  }
57  return width
58}
59
60// Formula ink for Claude Code's theme; a `#rrggbb` color overrides it.
61export function inkFor(theme: string | undefined, color: string): Ink {
62  const ink = theme?.startsWith('light') ? LIGHT_INK : DARK_INK
63  return /^#[0-9a-fA-F]{6}$/.test(color) ? { ...ink, math: color } : ink
64}
65
types/index.d.ts 9 lines
1/** /tex-calibrate's trial cell aspect while its pane is open; 0 before the first open. */
2export type TexDisplayAspect = number
3
4declare module 'claude-code' {
5  interface PluginState {
6    'tex-display': { aspect: TexDisplayAspect }
7  }
8}
9