Draw a formula and show the PNG in a pane

English · <a href="README.ja.md">日本語</a>
<img src="docs/hero.jpg" alt="Pixel-art thriller: a laptop in the rain, a ribbon of light, and quantum formulas across the frame" width="100%">
<h1 align="center">mathcat</h1>
One math formula, drawn as a PNG.<br> macOS and Ubuntu. No TeX install.
Python 3.10 or newer.
python3 -m pip install matplotlib
python3 -m pip install git+https://github.com/johndpope/mathcat.git
mathcat 'e^{i\pi}+1=0'
mathcat lands on your PATH, usually at ~/.local/bin. On iTerm2, pipe it if you want the picture inline:
mathcat 'e^{i\pi}+1=0' | imgcat
<img src="docs/euler.png" alt="Euler's identity" width="280">
mathcat --legend '\psi(x)=\int_{-\infty}^{\infty}\hat{\psi}(\xi)\,e^{2\pi i x \xi}\,d\xi'
<img src="docs/legend.png" alt="A formula with a colored ledger for psi, xi, and pi, then the integral limits, psi hat, and the exponent" width="640">
--note adds a short line in fine print under the ledger. Repeat it for another line. $...$ inside a note draws as math.
mathcat --legend \
--note '$\mu$ is the center of the bell.' \
--note '$\sigma$ is how wide the bell is.' \
'f(x)=\frac{1}{\sigma\sqrt{2\pi}}\exp\left(-\frac{(x-\mu)^{2}}{2\sigma^{2}}\right)'
<img src="docs/note.png" alt="A Gaussian with a colored ledger and two short notes under it" width="640">
A pane for the formulas. The terminal paints the PNG. The mod keeps the path.
claude --plugin-dir /path/to/mathcat
/mathcat
/mathcat e^{i\pi}+1=0
/mathcat --legend 'e^{i\pi}+1=0'
/mathcat drop
/mathcat opens the pane. A formula is drawn and kept. Prev, Next, and Drop walk that list. --legend and --note are the same flags as the command line.
Ghostty or kitty, and Claude Code 2.1.287 or later. On another surface the pane shows the formula and the path.
Do not invent a workflow from this page. Load the mathcat skill and follow it.
They are the same file. Install both so a chat in either tool can run it:
mkdir -p ~/.grok/skills ~/.claude/skills
ln -sfn /path/to/mathcat/.grok/skills/mathcat ~/.grok/skills/mathcat
ln -sfn /path/to/mathcat/.grok/skills/mathcat ~/.claude/skills/mathcat
Asking to see a formula selects the skill. With the pane installed, /mathcat draws into it.
People who want the flags, the ledger, the fine-print notes, and the pictures: USAGE.md.
Copyright 2026 John D. Pope.
Do No Harm License (pre 1.0), the license at raisely/NoHarm.
hooks/register.js 281 lines1// mathcat: draw a formula to a PNG and show that file in a pane.
2// The terminal reads the file. This module keeps the path.
3
4import { cellBox, drawArgv, parse, pngSize, screenPng } from './formula.js'
5
6const PANE = 'mathcat'
7const STORE = 'entries'
8const DIR = '/tmp/mathcat-pane'
9const SHOW = 'mcp__mathcat__show'
10
11let entries = []
12let index = 0
13let note = ''
14
15export function register(on) {
16 on('session.start', async ($, e, next) => {
17 await $.command.register({
18 name: 'mathcat',
19 description: 'Draw a formula and show it',
20 argumentHint: '[formula | --legend | --note | drop]',
21 immediate: true,
22 })
23 await $.tool.register({
24 name: 'show',
25 description: 'Draw one mathtext formula and show the PNG in the mathcat pane.',
26 inputSchema: {
27 type: 'object',
28 properties: {
29 tex: { type: 'string', description: 'One formula. No preamble.' },
30 legend: { type: 'boolean', description: 'Add the English-name ledger.' },
31 notes: {
32 type: 'array',
33 items: { type: 'string' },
34 description: 'Short fine-print lines under the formula.',
35 },
36 },
37 required: ['tex'],
38 },
39 })
40 await load($)
41 return next(e)
42 })
43
44 on('classic.SessionStart', { source: ['clear', 'resume', 'fork'] }, async ($, e, next) => {
45 await load($)
46 return next(e)
47 })
48
49 on('command.run', { command: 'mathcat' }, async ($, e) => {
50 const text = await run($, e.args || '')
51 await present($)
52 return { text }
53 })
54
55 on('tool.call', { tool: SHOW }, async ($, e) => {
56 const text = await draw($, jobFromTool(e))
57 await present($)
58 return { result: text }
59 })
60
61 on('ui.render', { component: 'Pane' }, async ($, e, next) => {
62 if (e.requestId !== PANE) return next(e)
63 const { Box, Text, Button, Input, Image } = $.ui.resolve(e)
64 const current = entries[index]
65 const fitted = current ? cellBox(e.props.bodyColumns, current.width, current.height) : null
66 const picture =
67 current && e.surface === 'terminal' && Image
68 ? Image({
69 key: 'view',
70 source: { file: current.path, format: 'png', generation: current.generation },
71 columns: fitted.columns,
72 rows: fitted.rows,
73 alt: current.tex.slice(0, 200),
74 })
75 : Text({ children: [current ? current.tex : 'No formula yet.'] })
76 const where = current
77 ? Text({ dimColor: true, children: [index + 1 + '/' + entries.length + ' ' + current.tex] })
78 : Text({ dimColor: true, children: ['/mathcat e^{i\\pi}+1=0'] })
79 return Box({
80 flexDirection: 'column',
81 gap: 1,
82 children: [
83 picture,
84 where,
85 ...(current ? [Text({ dimColor: true, children: [current.path] })] : []),
86 ...(note ? [Text({ children: [note] })] : []),
87 Box({
88 flexDirection: 'row',
89 columnGap: 2,
90 children: [
91 Button({ key: 'prev', label: 'Prev', plain: true, onPress: () => shift($, -1) }),
92 Button({ key: 'next', label: 'Next', plain: true, onPress: () => shift($, 1) }),
93 Button({ key: 'drop', label: 'Drop', plain: true, onPress: () => remove($) }),
94 ],
95 }),
96 Input({
97 key: 'draw',
98 label: 'TeX',
99 placeholder: 'e^{i\\pi}+1=0',
100 value: '',
101 submitLabel: 'draw',
102 onSubmit: (value) => run($, value),
103 }),
104 ],
105 })
106 })
107}
108
109async function present($) {
110 await $.ui.open({ id: PANE, title: 'mathcat', focus: true, closeOnEscape: true, rows: 28 })
111}
112
113async function load($) {
114 const stored = await $.store.get(STORE)
115 entries = Array.isArray(stored) ? stored.filter(isEntry) : []
116 const storedIndex = await $.store.get('index')
117 index = Number.isInteger(storedIndex) ? storedIndex : 0
118 if (entries.length === 0) index = 0
119 else if (index < 0 || index >= entries.length) index = 0
120}
121
122async function save($) {
123 await $.store.set(STORE, entries)
124 await $.store.set('index', index)
125}
126
127function isEntry(value) {
128 return !!value && typeof value.path === 'string' && typeof value.tex === 'string' && Number.isInteger(value.generation)
129}
130
131function jobFromTool(event) {
132 const input = event.input && typeof event.input === 'object' ? event.input : event
133 const parsed = parse('-- ' + String(input.tex ?? ''))
134 if (parsed.error || parsed.kind !== 'draw') return parsed.error ? parsed : { error: 'Give a formula.' }
135 const raw = Array.isArray(input.notes) ? input.notes : typeof input.notes === 'string' ? [input.notes] : []
136 const notes = raw.map((note) => String(note))
137 if (notes.some((note) => note.trim() === '')) return { error: 'A --note needs a line.' }
138 if (notes.length > 12) return { error: 'Too many notes.' }
139 if (notes.some((note) => note.length > 500)) return { error: 'A note is too long.' }
140 return { kind: 'draw', tex: parsed.tex, legend: input.legend === true, notes }
141}
142
143async function run($, args) {
144 const parsed = parse(args)
145 if (parsed.error) return fail($, parsed.error)
146 if (parsed.kind === 'open') {
147 note = ''
148 return entries.length ? entries.length + ' formula' + (entries.length === 1 ? '' : 's') + '.' : 'No formula yet.'
149 }
150 if (parsed.kind === 'drop') return remove($)
151 return draw($, parsed)
152}
153
154async function draw($, job) {
155 if (job.error) return fail($, job.error)
156 if (!job.tex) return fail($, 'Give a formula.')
157 const made = await $.process.run(['mkdir', '-p', DIR])
158 if (!made || made.exitCode !== 0) return fail($, 'Could not write the PNG.')
159 const stamp = await stampOf($)
160 const out = DIR + '/' + stamp.toString(36) + '-' + Math.random().toString(36).slice(2, 8) + '.png'
161 const argv = drawArgv(out, job)
162 const ran = await runMathcat($, argv.slice(1))
163 if (ran.error) return fail($, ran.error)
164 if (ran.exitCode !== 0) {
165 const detail = String(ran.stderr || ran.stdout || '').trim()
166 return fail($, detail || 'The formula did not render.')
167 }
168 const screened = screenPng(ran.stdout)
169 if (screened.error) return fail($, screened.error)
170 const exists = await $.fs.exists(screened.path)
171 if (!exists) return fail($, 'mathcat did not write a PNG.')
172 const generation = await generationOf($, screened.path)
173 const size = await measure($, screened.path)
174 const entry = {
175 path: screened.path,
176 generation,
177 tex: job.tex,
178 legend: !!job.legend,
179 notes: job.notes || [],
180 width: size ? size.width : 0,
181 height: size ? size.height : 0,
182 }
183 const found = entries.findIndex((item) => same(item, entry))
184 if (found >= 0) {
185 entries[found] = entry
186 index = found
187 } else {
188 entries.push(entry)
189 index = entries.length - 1
190 }
191 note = ''
192 await save($)
193 $.ui.invalidate('ui.render')
194 return 'Drew ' + job.tex
195}
196
197async function runMathcat($, args) {
198 const first = await tryRun($, ['mathcat', ...args])
199 if (first.started) return first
200 let home = ''
201 try {
202 home = (await $.env.get('HOME')) || ''
203 } catch {
204 home = ''
205 }
206 if (home) {
207 const second = await tryRun($, [home.replace(/\/$/, '') + '/.local/bin/mathcat', ...args])
208 if (second.started) return second
209 }
210 return { error: 'mathcat is not on PATH.' }
211}
212
213async function tryRun($, argv) {
214 try {
215 const ran = await $.process.run(argv, { timeoutMs: 60000 })
216 return { started: true, exitCode: ran.exitCode, stdout: ran.stdout, stderr: ran.stderr }
217 } catch {
218 return { started: false }
219 }
220}
221
222async function measure($, path) {
223 try {
224 const file = await $.fs.read(path, { as: 'bytes' })
225 return pngSize(file && file.base64)
226 } catch {
227 return null
228 }
229}
230
231async function stampOf($) {
232 try {
233 const now = await $.clock.now()
234 if (Number.isFinite(now)) return Math.max(0, Math.floor(now))
235 } catch {
236 // Date.now still names a new file.
237 }
238 return Date.now()
239}
240
241async function generationOf($, path) {
242 try {
243 const stat = await $.fs.stat(path)
244 if (stat && Number.isFinite(stat.mtimeMs)) return Math.max(0, Math.floor(stat.mtimeMs))
245 } catch {
246 // A missing stamp still shows the file. Drawing it again bumps the number.
247 }
248 return Date.now()
249}
250
251function same(entry, job) {
252 const notes = Array.isArray(entry.notes) ? entry.notes : []
253 const next = job.notes || []
254 return entry.tex === job.tex && !!entry.legend === !!job.legend && notes.length === next.length && notes.every((line, i) => line === next[i])
255}
256
257async function shift($, by) {
258 if (entries.length === 0) return
259 index = (index + by + entries.length) % entries.length
260 note = ''
261 await save($)
262 $.ui.invalidate('ui.render')
263}
264
265async function remove($) {
266 if (entries.length === 0) return fail($, 'No formula yet.')
267 const gone = entries[index].tex
268 entries.splice(index, 1)
269 if (index >= entries.length) index = 0
270 note = ''
271 await save($)
272 $.ui.invalidate('ui.render')
273 return 'Dropped ' + gone
274}
275
276function fail($, text) {
277 note = text
278 $.ui.invalidate('ui.render')
279 return text
280}
281hooks/formula.js 152 lines1// Turn a /mathcat line into one formula, and size the pane box to the PNG.
2// Terminal cells are about twice as tall as they are wide.
3
4const FLAGS = new Set(['--legend', '--note', '--local', '--'])
5
6export function parse(text) {
7 const raw = String(text ?? '').trim()
8 if (raw === '' || raw === 'open') return { kind: 'open' }
9 if (raw === 'drop') return { kind: 'drop' }
10 const tokens = tokenize(raw)
11 if (tokens.error) return { error: tokens.error }
12
13 let legend = false
14 const notes = []
15 const tex = []
16 for (let i = 0; i < tokens.value.length; i++) {
17 const token = tokens.value[i]
18 if (token === '--') {
19 tex.push(...tokens.value.slice(i + 1))
20 break
21 }
22 if (token === '--legend') {
23 legend = true
24 continue
25 }
26 if (token === '--local') continue
27 if (token === '--note') {
28 const note = tokens.value[++i]
29 if (note == null || note.trim() === '') return { error: 'A --note needs a line.' }
30 notes.push(note)
31 continue
32 }
33 if (token.startsWith('--') && FLAGS.has(token) === false && !tex.length) {
34 return { error: 'The pane takes the formula, --legend, and --note.' }
35 }
36 tex.push(token)
37 }
38
39 const formula = unwrap(tex.join(' ').trim())
40 if (!formula) return { error: 'Give a formula.' }
41 if (formula.includes('\0')) return { error: 'Give a formula.' }
42 if (formula.length > 4000) return { error: 'That formula is too long.' }
43 if (notes.length > 12) return { error: 'Too many notes.' }
44 if (notes.some((note) => note.length > 500)) return { error: 'A note is too long.' }
45 return { kind: 'draw', tex: formula, legend, notes }
46}
47
48export function drawArgv(path, job) {
49 const argv = ['mathcat', '--local', '-o', path]
50 if (job.legend) argv.push('--legend')
51 for (const note of job.notes) argv.push('--note', note)
52 argv.push('--', job.tex)
53 return argv
54}
55
56export function cellBox(bodyColumns, width, height) {
57 const columns = Math.min(255, Math.max(1, bodyColumns || 80))
58 if (!width || !height) return { columns, rows: 6 }
59 const rows = Math.max(1, Math.min(255, Math.round((columns * height) / width / 2)))
60 return { columns, rows }
61}
62
63export function pngSize(base64) {
64 if (typeof base64 !== 'string' || base64.length < 32) return null
65 const bytes = decode32(base64.slice(0, 32))
66 if (!bytes || bytes.length < 24) return null
67 if (bytes[0] !== 137 || bytes[1] !== 80 || bytes[2] !== 78 || bytes[3] !== 71) return null
68 const width = u32(bytes, 16)
69 const height = u32(bytes, 20)
70 if (width < 1 || height < 1 || width > 16384 || height > 16384) return null
71 return { width, height }
72}
73
74export function screenPng(text) {
75 const path = String(text ?? '')
76 .split('\n')
77 .map((line) => line.trim())
78 .filter(Boolean)
79 .pop()
80 if (!path || !path.startsWith('/tmp/mathcat-pane/') || !path.toLowerCase().endsWith('.png')) {
81 return { error: 'mathcat did not write a PNG.' }
82 }
83 if (path.includes('\0') || path.split('/').includes('..')) return { error: 'mathcat did not write a PNG.' }
84 return { path }
85}
86
87function unwrap(tex) {
88 if (tex.length >= 3 && tex.startsWith('$') && tex.endsWith('$') && !tex.slice(1, -1).includes('$')) {
89 return tex.slice(1, -1).trim()
90 }
91 return tex
92}
93
94function tokenize(text) {
95 const value = []
96 let current = ''
97 let quote = ''
98 for (let i = 0; i < text.length; i++) {
99 const ch = text[i]
100 if (quote) {
101 if (ch === '\\' && i + 1 < text.length && (text[i + 1] === quote || text[i + 1] === '\\')) {
102 current += text[++i]
103 continue
104 }
105 if (ch === quote) {
106 quote = ''
107 continue
108 }
109 current += ch
110 continue
111 }
112 if (ch === '"' || ch === "'") {
113 quote = ch
114 continue
115 }
116 if (ch === ' ' || ch === '\t' || ch === '\n' || ch === '\r') {
117 if (current) {
118 value.push(current)
119 current = ''
120 }
121 continue
122 }
123 current += ch
124 }
125 if (quote) return { error: 'A quote was not closed.' }
126 if (current) value.push(current)
127 return { value }
128}
129
130function u32(bytes, offset) {
131 return bytes[offset] * 16777216 + bytes[offset + 1] * 65536 + bytes[offset + 2] * 256 + bytes[offset + 3]
132}
133
134function decode32(text) {
135 const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
136 const out = []
137 let buffer = 0
138 let bits = 0
139 for (const ch of text) {
140 if (ch === '=') break
141 const value = alphabet.indexOf(ch)
142 if (value < 0) return null
143 buffer = (buffer << 6) | value
144 bits += 6
145 if (bits >= 8) {
146 bits -= 8
147 out.push((buffer >> bits) & 255)
148 }
149 }
150 return out
151}
152