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

A Claude Code mod that renders LaTeX display math in the terminal. Claude's $$…$$ blocks show up as typeset equations instead of raw source.
\,, \{, \\ and friends reach TeX intact.$$…$$, \[…\], bare equation, align, gather and multline environments (starred too), AMS packages, mhchem, matrices, stretchy braces and arrows, \boxed, \cancel and \enclose.Σ⁽²⁾(k, τ) = 1/N_q ∑_(q,ν) …). The terminal is detected from TERM, TERM_PROGRAM, KITTY_WINDOW_ID and TMUX.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" } }
Both appear in /config, or can be set under pluginConfigs.tex-display.options in your settings.
| Setting | Default | What it does |
|---|---|---|
cellAspect | 2.15 | Your 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. |
scale | 1 | How 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.
The mod hooks the drawing of each assistant message (ui.render on AssistantMessage). When the text contains display math, it splits it:
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 call | Purpose |
|---|---|
ui.render (AssistantMessage) | Draw replies containing display math as Markdown plus images |
prompt.submit | Attach the math note to the first prompt of each context window |
session.compact, session.end | Notice a compaction or /clear, so the note is sent again |
config.set (theme), $.config.list | Pick the ink colour from the theme |
session.start, command.run, ui.render (Pane), $.command.register, $.ui.open, $.ui.close, $.ui.toast | The /tex-calibrate command and its pane |
$.state.get, $.state.set | The calibration pane's trial value (tex-display.aspect) |
$.config.set | Save the calibrated cellAspect when you press Save |
$.env.get | Detect the terminal (TERM, TERM_PROGRAM, KITTY_WINDOW_ID, TMUX) and its background (COLORFGBG) |
$.session.surfaces | Only send the note when a terminal is drawing the session |
$.ui.resolve | Get 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.
$…$ stays as text. The note asks Claude not to write it.\color is ignored and everything is drawn in the theme's ink.latex code blocks.latex code block.$$ block shows as text until its closing delimiter arrives.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) | figures | latex-render | latex-inline | latex | |
|---|---|---|---|---|---|
| Draws in | terminal (kitty, Ghostty) | terminal (kitty, Ghostty) | terminal (macOS) | terminal (Ghostty, kitty) | desktop app's Code tab |
| Renderer | MathJax 3 + its own rasterizer, in the mod | MathJax 4 in the mod + the resvg binary | tectonic + pdftocairo + sips | MathJax 4 + resvg-js in a node process | MathJax 3 in the mod (vector Svg) |
| You install | nothing | resvg | tectonic, poppler (Homebrew) | Node.js ≥ 18 | nothing for math; TeX for diagrams |
| Inline math | no | no | no | yes | yes |
| Other terminals | Unicode text | TeX source (it stays above the images anyway) | tmux via its bridge script | Unicode text | — |
Bare align etc. | yes | no | no | no | yes |
| Math replaces the source | yes | no: pictures go under the reply | yes | yes | yes |
| Tells Claude | first prompt per context window | an optional skill | prompt.compose | prompt.compose | prompt.compose |
| Runs processes / writes files | no / no | yes / yes | yes / yes | yes / yes | yes / yes |
| Extras | — | Mermaid, Graphviz, D2, tool-result images, a gallery pane | tmux passthrough | font metrics read from your terminal config | TikZ, 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:
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.
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.
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/.
hooks/register.tsx 234 lines1import { 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}
234hooks/png.ts 109 lines1// 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}
109hooks/raster.ts 237 lines1// 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}
237hooks/vendor/latex-inline/unicode.ts 616 lines1import { 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}
616hooks/vendor/latex-inline/support.ts 65 lines1export 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}
65types/index.d.ts 9 lines1/** /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