ClaudeTex typesets LaTeX math in Claude Code replies as Unicode text in the terminal, and opens a KaTeX browser preview of the same reply.

<img src="docs/banner-light.png" alt="ClaudeTex — LaTeX for Claude Code" width="100%">
<h1 align="center">ClaudeTex</h1>
<strong>LaTeX, readable in your terminal — typeset with KaTeX in your browser.</strong><br> A real Claude Code mod. No wrapper. No VS Code. No TeX install.
<a href="https://github.com/huiihao/ClaudeTex/releases"><img alt="Release" src="https://img.shields.io/github/v/release/huiihao/ClaudeTex?style=flat-square&color=D97757&label=release"></a> <a href="https://github.com/huiihao/ClaudeTex/actions/workflows/test.yml"><img alt="CI" src="https://github.com/huiihao/ClaudeTex/actions/workflows/test.yml/badge.svg?branch=main"></a> <img alt="Claude Code 2.1.287+" src="https://img.shields.io/badge/Claude%20Code-2.1.287%2B-191919?style=flat-square&labelColor=D97757"> <img alt="38 tests passing" src="https://img.shields.io/badge/tests-38%20passing-D97757?style=flat-square"> <a href="LICENSE"><img alt="MIT" src="https://img.shields.io/github/license/huiihao/ClaudeTex?style=flat-square&color=191919"></a>
<a href="#quick-start">Quick start</a> · <a href="#features">Features</a> · <a href="#configuration">Configuration</a> · <a href="#how-it-works">How it works</a> · <a href="#limitations">Limitations</a> · <a href="README.zh-CN.md">简体中文</a>
Claude Code's CLI is a character grid. It does not typeset LaTeX, and most existing solutions depend on terminal graphics (kitty/Ghostty) that Windows Terminal does not support.
ClaudeTex is a real Claude Code mod that hooks the renderer itself. It gives you two complementary layers:
cases, aligned, boxed. Works in any terminal./tex writes the current reply to a local HTML page and opens it with KaTeX for pixel-perfect typesetting. Complex formulas that are hard to read in a cell grid look exactly right there.Design principle: never lose the reply. If a formula is malformed, too large, or nested too deeply, ClaudeTex leaves the original LaTeX in place instead of breaking the session.
You ❯ differentiate f(x) = x² sin x
● f′(x) = 2x sin x + x² cos x
You ❯ show the quadratic formula
−b ± √(b² − 4ac)
────────────────
2a
You ❯ /tex
KaTeX preview opened in your browser: …/claudetex-preview-….html
<a href="docs/claudetex-intro.mp4"><img src="docs/claudetex-intro.gif" alt="ClaudeTex intro animation" width="82%"></a>
<img src="docs/preview.png" alt="ClaudeTex KaTeX preview" width="82%">
| Automatic terminal typesetting | $...$ and \(...\) are converted before Claude Code draws the reply. |
| Real 2D display math | Fractions, roots, \sum / \prod limits, matrices, cases, aligned, boxed, \binom. |
| True KaTeX on demand | /tex opens the latest reply in your browser; /tex all includes every reply from the session. |
| Markdown-aware | Code fences and inline code are untouched. $5, $10, $PATH, $and$ are not mistaken for math. |
| Cross-platform | Windows Terminal, macOS, Linux; terminal, Desktop app and VS Code extension surfaces. |
| Zero runtime dependencies | Runs inside Claude Code. No Node.js, no TeX distribution, no external process. |
| Hardened by design | Input limits, no-throw fallbacks, SRI-checked CDN assets, strict CSP and DOMPurify. |
claude plugin marketplace add huiihao/ClaudeTex
claude plugin install tex@claudetex
Then open a new conversation and ask a math question. That's it.
Names: the project and GitHub repository are ClaudeTex. The marketplace id is lowercase
claudetex, the installable plugin id istex, and the command is/tex. This keeps the internal ids DNS-safe while the brand stays readable.
git clone https://github.com/huiihao/ClaudeTex
cd ClaudeTex
claude --plugin-dir .
/tex preview (assets are loaded from cdnjs with SRI).Just ask normally. ClaudeTex adds a short note to the first prompt of each context window so the model writes formulas as LaTeX.
/tex # latest reply containing math
/tex all # every reply in this session
/tex-preview # alias for /tex
The preview is written to the system temp directory and opened with your default browser. The command prints the file path, so you can open it manually if the browser does not launch.
After installing or editing the plugin in an already-running session, run /reload-plugins (or restart the session). New sessions load it automatically.
Run /plugin configure tex@claudetex, or edit ~/.claude/settings.json:
{
"pluginConfigs": {
"tex": {
"options": {
"inline": true,
"display": true,
"teach_claude": true,
"max_width": 0
}
}
}
}
| Option | Default | Description |
|---|---|---|
inline | true | Convert $...$ and \(...\) to Unicode math. |
display | true | Lay out $$...$$ and \[...\] as two-dimensional blocks. |
teach_claude | true | Tell the model to write LaTeX on the first prompt of each context window. |
max_width | 0 | Maximum width of a display formula in terminal cells. 0 follows the terminal. |
| Category | Examples |
|---|---|
| Greek and symbols | \alpha \beta \pi \infty \partial \nabla \forall \exists |
| Operators and relations | \pm \times \cdot \le \ge \ne \approx \equiv \in \subset \cup \cap \to \Rightarrow |
| Scripts | x_i^2, \sum_{i=1}^n, \int_0^1 |
| Fractions and roots | \frac{a+b}{c}, \dfrac, \sqrt{x}, \sqrt[3]{x} |
| Large operators | \sum \prod \int \iint \oint \bigcup \bigcap |
| Environments | pmatrix, bmatrix, Bmatrix, vmatrix, Vmatrix, cases, aligned, align, gather, multline |
| Fonts | \mathbb, \mathcal, \mathfrak, \mathbf, \boldsymbol, \mathsf, \mathtt, \mathit, \mathrm |
| Accents | \hat, \bar, \vec, \dot, \ddot, \tilde, \overline, \underline |
| Utilities | \text{...}, \boxed{...}, \binom{n}{k}, \operatorname{...}, \left( ... \right), \not\in |
ClaudeTex is a standard Claude Code mod: a plugin whose hooks module registers functions that run inside Claude Code's own process.
ClaudeTex/
├── .claude-plugin/
│ ├── plugin.json # manifest + user config
│ ├── marketplace.json # marketplace manifest
│ └── icon.png
├── hooks/
│ ├── hooks.json
│ └── register.ts # ui.render / prompt.submit / command.run
├── src/
│ ├── tex/
│ │ ├── symbols.ts # TeX tables, fonts, script maps
│ │ ├── parser.ts # recursive-descent parser + limits
│ │ ├── layout.ts # Unicode inline + 2D block layout
│ │ └── markdown.ts # code-aware Markdown scanner
│ └── preview.ts # KaTeX HTML page (SRI + CSP)
├── tests/ # 38 tests
├── docs/
│ ├── banner-light.png
│ ├── banner-dark.png
│ ├── icon.png
│ └── preview.png
└── README.md
| Hook / API call | Role |
|---|---|
ui.render (AssistantMessage) | Rewrites the reply before drawing: inline math to Unicode, display math to a 2D block. |
ui.render (CommandOutput) | Same treatment for command output such as /btw answers. |
prompt.submit | Adds a short “write math in LaTeX” note once per context window. |
session.start | Registers /tex and /tex-preview. |
session.compact / session.end | Resets the note after compaction or /clear. |
command.run | /tex: reads $.session.messages(), writes the HTML with $.fs.write, opens it with $.process.run. |
Everything is local. Parsing, layout and HTML generation happen in the hooks module; there is no external process or npm runtime dependency.
The renderer is built to degrade gracefully rather than break a conversation:
\frac{, unclosed \begin{matrix} and stray } cannot loop.$$ $$ are not rewritten. Explicit \(...\) and \[...\] may contain surrounding whitespace.connect-src 'none'), DOMPurify cleaning, and the Markdown source stored in an inert <template>./tex for real typesetting.Image element is limited to kitty/Ghostty-class terminals.$...$ transformed. Use fenced code blocks for code./btw panels are drawn outside ui.render; ClaudeTex asks the model to use Unicode math there.claude plugin validate .
claude plugin test
npx -y -p typescript@5.7 tsc -p tsconfig.check.json
npx -y -p typescript@5.7 tsc -p tsconfig.real.json # after loading once with --plugin-dir
claude --plugin-dir . writes the exact mod types for your Claude Code version into .claude-plugin/types/ (ignored by Git).
/tex page loads KaTeX, marked and DOMPurify from cdnjs.cloudflare.com, pinned with SRI. The reply content stays in your browser.ClaudeTex = Claude + TeX. The name claudex was considered, but it is already crowded on GitHub (mostly Claude + Codex multi-model proxies) and taken on npm. claudetex is available on npm, unambiguous, and keeps the TeX lineage visible.
MIT — see LICENSE.
<sub>ClaudeTex is an independent community project, not affiliated with Anthropic. Claude is a trademark of Anthropic, PBC.</sub>
hooks/register.ts 306 lines1import type { Register } from 'claude-code'
2import { transformMarkdown } from '../src/tex/markdown'
3import { buildPreviewHtml } from '../src/preview'
4
5type Reply = { id: string; text: string }
6
7const MATH_NOTE = `# Math in this session
8ClaudeTex, a Claude Code mod in this session, typesets math in replies. Write formulas in LaTeX instead of Unicode or ASCII:
9- Inline math: $...$ with no space right after the opening $ or right before the closing $.
10- Display math: $$...$$ on their own lines, with blank lines before and after.
11Use LaTeX for every formula and symbol, including single letters ($x$), fractions, sums, integrals, matrices and cases. Do not replace math with Unicode symbols such as α, ² or ≤, and do not draw formulas with ASCII art.
12Keep formulas out of fenced code blocks and inline code, and put dollar amounts and shell variables such as $PATH in code spans. Math in tables, headings, lists and block quotes is fine.
13The terminal draws display math as a multi-line Unicode block. The user can run /tex to open the same reply in a browser where KaTeX typesets it exactly; mention that command when a formula is hard to read in the terminal.
14In a side question (/btw), the answer is drawn where this mod cannot reach: write its math as Unicode text instead.`
15
16const MAX_REPLIES = 24
17const MAX_PREVIEW_CHARS = 60_000
18
19const replies: Reply[] = []
20const transformCache = new Map<string, string>()
21let anonymousId = 0
22let isTold = false
23
24let config = {
25 inline: true,
26 display: true,
27 teach: true,
28 maxWidth: 0,
29}
30
31function truthy(value: unknown, fallback: boolean): boolean {
32 if (value === undefined || value === null) return fallback
33 if (typeof value === 'boolean') return value
34 if (typeof value === 'string') return value !== 'false' && value !== '0' && value !== ''
35 return Boolean(value)
36}
37
38function numberValue(value: unknown, fallback: number): number {
39 const number = typeof value === 'number' ? value : Number(value)
40 return Number.isFinite(number) ? number : fallback
41}
42
43function remember(id: unknown, text: string): void {
44 if (typeof text !== 'string' || text === '') return
45 const key = String(id ?? `anonymous-${anonymousId++}`)
46 const stored = text.length > MAX_PREVIEW_CHARS ? text.slice(0, MAX_PREVIEW_CHARS) : text
47 const found = replies.find(reply => reply.id === key)
48 if (found) found.text = stored
49 else replies.push({ id: key, text: stored })
50 if (replies.length > MAX_REPLIES) replies.splice(0, replies.length - MAX_REPLIES)
51}
52
53function hasMath(text: string): boolean {
54 return /\$[^$\n]+\$|\$\$[\s\S]+?\$\$|\\\(|\\\[|\\begin\{/.test(text)
55}
56
57function transform(text: string, columns: number | undefined): string {
58 if (typeof text !== 'string' || text === '' || (!config.inline && !config.display)) return text
59 if (!text.includes('$') && !text.includes('\\')) return text
60 const width = Number.isFinite(columns) ? columns! : 80
61 const cacheable = text.length <= 20_000
62 const key = `${width}\u0000${text}`
63 if (cacheable) {
64 const cached = transformCache.get(key)
65 if (cached !== undefined) return cached
66 }
67 try {
68 const maxWidth = config.maxWidth > 0 ? config.maxWidth : Math.max(20, width - 6)
69 const result = transformMarkdown(text, {
70 inline: config.inline,
71 display: config.display,
72 maxWidth,
73 })
74 const output = result.changed ? result.text : text
75 if (cacheable) {
76 if (transformCache.size > 60) transformCache.clear()
77 transformCache.set(key, output)
78 }
79 return output
80 } catch {
81 // A failed transform must never cost the user their reply.
82 return text
83 }
84}
85
86function messageText(message: unknown): string {
87 if (typeof message === 'string') return message
88 if (!message || typeof message !== 'object') return ''
89 const record = message as Record<string, unknown>
90 if (typeof record.text === 'string') return record.text
91 const inner = record.message
92 if (inner && typeof inner === 'object') {
93 const content = (inner as Record<string, unknown>).content
94 if (typeof content === 'string') return content
95 if (Array.isArray(content)) {
96 return content
97 .map(part => {
98 if (part && typeof part === 'object' && typeof (part as Record<string, unknown>).text === 'string') {
99 return (part as Record<string, unknown>).text as string
100 }
101 return ''
102 })
103 .filter(Boolean)
104 .join('\n')
105 }
106 }
107 if (Array.isArray(record.content)) {
108 return record.content
109 .map(part => {
110 if (part && typeof part === 'object' && typeof (part as Record<string, unknown>).text === 'string') {
111 return (part as Record<string, unknown>).text as string
112 }
113 return ''
114 })
115 .filter(Boolean)
116 .join('\n')
117 }
118 return ''
119}
120
121function isAssistant(message: unknown): boolean {
122 if (!message || typeof message !== 'object') return false
123 const record = message as Record<string, unknown>
124 if (typeof record.role === 'string') return record.role === 'assistant'
125 if (typeof record.type === 'string') return record.type === 'assistant'
126 return true
127}
128
129function selectReplies(messages: unknown, all: boolean): Reply[] {
130 const list: Reply[] = []
131 if (Array.isArray(messages)) {
132 messages.forEach((message, index) => {
133 if (!isAssistant(message)) return
134 const text = messageText(message)
135 if (text.trim() === '') return
136 list.push({ id: String(index), text })
137 })
138 }
139 if (list.length === 0) {
140 return all ? replies.slice() : replies.slice(-1)
141 }
142 if (all) return list.slice(-12)
143 const withMath = list.filter(reply => hasMath(reply.text))
144 return withMath.length > 0 ? withMath.slice(-1) : list.slice(-1)
145}
146
147function joinPath(base: string, name: string): string {
148 const windows = /^[A-Za-z]:$/.test(base) || base.includes('\\')
149 const separator = windows ? '\\' : '/'
150 return base.replace(/[\\/]+$/, '') + separator + name
151}
152
153function escapeMarkdownSeparator(text: string): string {
154 return text.replace(/\n---\n/g, '\n\n---\n\n')
155}
156
157function markdownFor(repliesToShow: Reply[], all: boolean): string {
158 let text = ''
159 repliesToShow.forEach((reply, index) => {
160 let body = reply.text
161 if (text.length + body.length > MAX_PREVIEW_CHARS) {
162 body = body.slice(0, Math.max(0, MAX_PREVIEW_CHARS - text.length))
163 }
164 if (all && repliesToShow.length > 1) {
165 text += `## Reply ${index + 1}\n\n${body}\n\n---\n\n`
166 } else {
167 text += body
168 }
169 })
170 return escapeMarkdownSeparator(text.trim())
171}
172
173async function runPreview($: any, e: any): Promise<{ text: string }> {
174 const all = /^(all|--all|-a)\b/i.test(String(e.args ?? '').trim())
175 try {
176 let messages: unknown = undefined
177 try {
178 messages = await $.session.messages()
179 } catch {
180 messages = undefined
181 }
182 const selected = selectReplies(messages, all)
183 if (selected.length === 0) {
184 return { text: 'No reply to preview yet. Ask a question first, then run /tex again.' }
185 }
186 const markdown = markdownFor(selected, all)
187 const html = buildPreviewHtml(markdown, {
188 title: all ? 'ClaudeTex · all replies' : 'ClaudeTex · latest reply',
189 subtitle: all
190 ? `${selected.length} replies · typeset with KaTeX in your browser`
191 : 'Latest reply · typeset with KaTeX in your browser',
192 })
193
194 const windir = await $.env.get('WINDIR')
195 const temp = (await $.env.get('TEMP')) ?? (await $.env.get('TMPDIR')) ?? (await $.env.get('HOME')) ?? '.'
196 const suffix = Math.random().toString(36).slice(2, 8)
197 const file = joinPath(temp, `claudetex-preview-${Date.now()}-${suffix}.html`)
198 try {
199 await $.fs.write(file, html)
200 } catch (error) {
201 return { text: `Could not write the preview file: ${String(error)}` }
202 }
203
204 let opened = false
205 if (windir) {
206 try {
207 await $.process.run(['explorer.exe', file], { timeoutMs: 5000 })
208 opened = true
209 } catch {
210 try {
211 await $.process.run(['cmd', '/c', 'start', '', file], { timeoutMs: 5000 })
212 opened = true
213 } catch {
214 opened = false
215 }
216 }
217 } else {
218 try {
219 await $.process.run(['open', file], { timeoutMs: 5000 })
220 opened = true
221 } catch {
222 try {
223 await $.process.run(['xdg-open', file], { timeoutMs: 5000 })
224 opened = true
225 } catch {
226 opened = false
227 }
228 }
229 }
230
231 return {
232 text: opened
233 ? `KaTeX preview opened in your browser: ${file}`
234 : `KaTeX preview written to ${file} (open it in a browser).`,
235 }
236 } catch (error) {
237 return { text: `ClaudeTex /tex failed: ${String(error)}` }
238 }
239}
240
241export const register: Register = (on, options) => {
242 config = {
243 inline: truthy(options.inline, true),
244 display: truthy(options.display, true),
245 teach: truthy(options.teach_claude, true),
246 maxWidth: numberValue(options.max_width, 0),
247 }
248
249 on('session.start', async ($, e, next) => {
250 await $.command.register({
251 name: 'tex',
252 description: 'Open the last reply in a browser with KaTeX typesetting',
253 })
254 await $.command.register({
255 name: 'tex-preview',
256 description: 'Alias for /tex',
257 })
258 return next(e)
259 }).catch(async ($, e, next) => next(e))
260
261 on('session.compact', async ($, e, next) => {
262 const result = await next(e)
263 isTold = false
264 return result
265 }).catch(async ($, e, next) => next(e))
266
267 on('session.end', ($, e, next) => {
268 if ((e as { reason?: string }).reason === 'clear') {
269 isTold = false
270 replies.length = 0
271 transformCache.clear()
272 }
273 return next(e)
274 })
275
276 on('prompt.submit', async ($, e, next) => {
277 if (!config.teach || isTold) return next(e)
278 const surfaces = await $.session.surfaces()
279 const draws = surfaces.some(surface => surface === 'terminal' || surface === 'desktop' || surface === 'vscode')
280 if (!draws) return next(e)
281 const result = await next({ ...e, context: [...(e.context ?? []), MATH_NOTE] })
282 if (!result.drop) isTold = true
283 return result
284 }).catch(async ($, e, next) => next(e))
285
286 on('ui.render', { component: 'AssistantMessage' }, async ($, e, next) => {
287 const text = e.props.text
288 if (typeof text !== 'string' || text === '') return next(e)
289 remember(e.requestId, text)
290 const rendered = transform(text, e.viewport?.columns)
291 if (rendered === text) return next(e)
292 return next({ ...e, props: { ...e.props, text: rendered } })
293 })
294
295 on('ui.render', { component: 'CommandOutput' }, async ($, e, next) => {
296 const text = e.props.text
297 if (typeof text !== 'string' || text === '') return next(e)
298 const rendered = transform(text, e.viewport?.columns)
299 if (rendered === text) return next(e)
300 return next({ ...e, props: { ...e.props, text: rendered } })
301 })
302
303 on('command.run', { command: 'tex' }, runPreview)
304 on('command.run', { command: 'tex-preview' }, runPreview)
305}
306src/tex/markdown.ts 363 lines1import { displayTex, inlineTex } from './layout'
2
3export type TransformOptions = {
4 inline: boolean
5 display: boolean
6 maxWidth: number
7}
8
9export type TransformResult = {
10 text: string
11 changed: boolean
12 count: number
13}
14
15const DISPLAY_ENVIRONMENTS = /^(equation|align|gather|multline|eqnarray)\*?$/
16
17// A reply can be arbitrarily long; past this point leave it to Claude Code's
18// renderer rather than spending time scanning it.
19const MAX_SCAN_LENGTH = 200_000
20const MAX_MATH_SPAN = 20_000
21
22// Short words that occur in prose far more often than they do in math. A
23// `$and$` is almost always a false positive; `$dx$` or `$sin$` are not.
24const PROSE_WORDS = new Set([
25 'and', 'the', 'for', 'with', 'from', 'this', 'that', 'are', 'was', 'were',
26 'has', 'have', 'not', 'but', 'you', 'all', 'any', 'can', 'our', 'out',
27 'its', 'one', 'two', 'three', 'four', 'five', 'six', 'seven', 'eight',
28 'nine', 'ten', 'is', 'of', 'to', 'in', 'on', 'at', 'by', 'as', 'or',
29 'if', 'so', 'we', 'it', 'be', 'do', 'no', 'up', 'an', 'a', 'i',
30])
31
32function escapeMarkdown(text: string): string {
33 return text.replace(/([\\`*_[\]<>|~#&$+\-])/g, '\\$1')
34}
35
36// `allowPadding` is for display math, where `$$ x $$` is normal. Inline math
37// with leading or trailing spaces is rejected unless it has a strong math
38// signal, which keeps currency and prose `$...$` from being rewritten.
39function looksLikeMath(raw: string, allowPadding = false): boolean {
40 const text = raw.trim()
41 if (text === '' || raw.length > MAX_MATH_SPAN) return false
42 const strong = /\\[A-Za-z]+|[\^_{}√∞∂∇∑∏∫≤≥≠≈±∓×÷·→←↔⇒⇔∈∉⊂⊆⊃⊇∪∩=<>+\-*/]/.test(raw)
43 if (raw !== text && !allowPadding && !strong) return false
44 if (strong) return true
45 if (/[α-ωΑ-Ω]/.test(raw)) return true
46 if (/\d\s*[+\-*/=]\s*\d/.test(raw)) return true
47 if (/^[A-Za-z]$/.test(text)) return true
48 if (PROSE_WORDS.has(text.toLowerCase())) return false
49 if (/^[a-z][a-z0-9]{0,3}$/.test(text)) return true
50 return false
51}
52
53function findInlineClose(text: string, start: number): number {
54 for (let i = start; i < text.length; i++) {
55 const ch = text[i]!
56 if (ch === '\\') {
57 i++
58 continue
59 }
60 if (ch === '\n') return -1
61 if (ch === '$') {
62 const previous = text[i - 1] ?? ''
63 const next = text[i + 1] ?? ''
64 if (previous !== '' && !/\s/.test(previous) && !/\d/.test(next)) return i
65 }
66 }
67 return -1
68}
69
70function findDoubleClose(text: string, start: number): number {
71 const limit = Math.min(text.length - 1, start + MAX_MATH_SPAN)
72 for (let i = start; i < limit; i++) {
73 if (text[i] === '\\') {
74 i++
75 continue
76 }
77 if (text[i] === '$' && text[i + 1] === '$') return i
78 }
79 return -1
80}
81
82function findEscapedClose(text: string, start: number, close: string): number {
83 const limit = Math.min(text.length, start + MAX_MATH_SPAN)
84 for (let i = start; i < limit; i++) {
85 if (text[i] === '\\') {
86 if (text.startsWith(close, i)) return i
87 i++
88 continue
89 }
90 if (text.startsWith(close, i)) return i
91 }
92 return -1
93}
94
95function findEnvironmentEnd(text: string, start: number, env: string): number {
96 const close = `\\end{${env}}`
97 const limit = Math.min(text.length, start + MAX_MATH_SPAN)
98 for (let i = start; i < limit; i++) {
99 if (text.startsWith(close, i)) return i
100 }
101 return -1
102}
103
104function fenceFor(lines: string[]): string {
105 let length = 3
106 for (const line of lines) {
107 for (const run of line.match(/`+/g) ?? []) length = Math.max(length, run.length + 1)
108 }
109 return '`'.repeat(length)
110}
111
112function indentFor(lead: string): string {
113 return lead.length <= 3 ? lead : ''
114}
115
116function displayBlock(lines: string[], indent: string): string {
117 const fence = fenceFor(lines)
118 const body = lines.map(line => indent + line).join('\n')
119 return `${indent}${fence}\n${body}\n${indent}${fence}`
120}
121
122function skipAfterDisplay(text: string, index: number): number {
123 let i = index
124 while (i < text.length && (text[i] === ' ' || text[i] === '\t' || text[i] === '\r')) i++
125 let newlines = 0
126 while (i < text.length && text[i] === '\n' && newlines < 2) {
127 i++
128 newlines++
129 }
130 return i
131}
132
133function atLineStart(text: string, index: number): boolean {
134 const lineStartIndex = text.lastIndexOf('\n', index - 1) + 1
135 return /^[ \t]*$/.test(text.slice(lineStartIndex, index))
136}
137
138// A code span closes only on a backtick run of exactly the same length, as
139// CommonMark specifies. Searching for the raw run would close `` ` `` on the
140// first backtick of a longer run and could transform math inside code.
141function findBacktickClose(text: string, start: number, length: number): number {
142 let i = start
143 while (i < text.length) {
144 const at = text.indexOf('`', i)
145 if (at === -1) return -1
146 let end = at
147 while (text[end] === '`') end++
148 if (end - at === length) return at
149 i = end
150 }
151 return -1
152}
153
154export function transformMarkdown(text: string, options: TransformOptions): TransformResult {
155 if (typeof text !== 'string') {
156 return { text: String(text ?? ''), changed: false, count: 0 }
157 }
158 if (text === '' || (!options.inline && !options.display)) {
159 return { text, changed: false, count: 0 }
160 }
161 if (text.length > MAX_SCAN_LENGTH) {
162 return { text, changed: false, count: 0 }
163 }
164 // Windows input can carry CRLF; the scanner and the display fences use LF.
165 text = text.replace(/\r\n?/g, '\n')
166
167 let out = ''
168 let i = 0
169 let count = 0
170 let lineStart = true
171
172 const maxWidth = Number.isFinite(options.maxWidth) ? Math.max(16, options.maxWidth) : 80
173
174 while (i < text.length) {
175 // Fenced code blocks pass through untouched.
176 if (lineStart) {
177 const leadMatch = /^[ \t]*/.exec(text.slice(i))
178 const lead = leadMatch ? leadMatch[0] : ''
179 const j = i + lead.length
180 const fenceMatch = /^(`{3,}|~{3,})/.exec(text.slice(j))
181 if (fenceMatch) {
182 const fence = fenceMatch[1]!
183 const marker = fence[0]!
184 const length = fence.length
185 out += lead + text.slice(j, j + fence.length)
186 let k = j + fence.length
187 while (k < text.length) {
188 const newline = text.indexOf('\n', k)
189 const end = newline === -1 ? text.length : newline
190 const line = text.slice(k, end)
191 if (new RegExp('^ {0,3}' + marker + '{' + length + ',}[ \\t]*$').test(line)) {
192 out += text.slice(k, Math.min(text.length, end + (newline === -1 ? 0 : 1)))
193 k = Math.min(text.length, end + (newline === -1 ? 0 : 1))
194 break
195 }
196 out += text.slice(k, Math.min(text.length, end + (newline === -1 ? 0 : 1)))
197 k = Math.min(text.length, end + (newline === -1 ? 0 : 1))
198 }
199 i = k
200 lineStart = i > 0 && text[i - 1] === '\n'
201 continue
202 }
203
204 // Display math at the start of a line, with its indentation preserved.
205 if (options.display && text.startsWith('$$', j)) {
206 const close = findDoubleClose(text, j + 2)
207 if (close !== -1) {
208 const content = text.slice(j + 2, close)
209 if (looksLikeMath(content, true)) {
210 const indent = indentFor(lead)
211 out += displayBlock(displayTex(content, maxWidth - indent.length), indent) + '\n\n'
212 count++
213 i = skipAfterDisplay(text, close + 2)
214 lineStart = true
215 continue
216 }
217 }
218 }
219 if (options.display && text.startsWith('\\[', j)) {
220 const close = findEscapedClose(text, j + 2, '\\]')
221 if (close !== -1) {
222 const content = text.slice(j + 2, close)
223 if (looksLikeMath(content, true)) {
224 const indent = indentFor(lead)
225 out += displayBlock(displayTex(content, maxWidth - indent.length), indent) + '\n\n'
226 count++
227 i = skipAfterDisplay(text, close + 2)
228 lineStart = true
229 continue
230 }
231 }
232 }
233 if (options.display && text.startsWith('\\begin{', j)) {
234 const nameMatch = /^\\begin\{([^}]+)\}/.exec(text.slice(j))
235 const env = nameMatch?.[1]
236 if (env && DISPLAY_ENVIRONMENTS.test(env)) {
237 const close = findEnvironmentEnd(text, j + nameMatch![0].length, env)
238 if (close !== -1) {
239 const content = text.slice(j, close + `\\end{${env}}`.length)
240 const indent = indentFor(lead)
241 out += displayBlock(displayTex(content, maxWidth - indent.length), indent) + '\n\n'
242 count++
243 i = skipAfterDisplay(text, close + `\\end{${env}}`.length)
244 lineStart = true
245 continue
246 }
247 }
248 }
249
250 out += lead
251 i = j
252 lineStart = false
253 if (i >= text.length) break
254 }
255
256 const ch = text[i]!
257
258 if (ch === '\n') {
259 out += ch
260 i++
261 lineStart = true
262 continue
263 }
264
265 if (ch === '`') {
266 const run = /^`+/.exec(text.slice(i))?.[0] ?? '`'
267 const close = findBacktickClose(text, i + run.length, run.length)
268 if (close !== -1) {
269 out += text.slice(i, close + run.length)
270 i = close + run.length
271 } else {
272 out += run
273 i += run.length
274 }
275 continue
276 }
277
278 if (ch === '\\') {
279 const next = text[i + 1] ?? ''
280 if (options.inline && next === '(') {
281 const close = findEscapedClose(text, i + 2, '\\)')
282 if (close !== -1) {
283 const content = text.slice(i + 2, close)
284 // `\(...\)` is unambiguous LaTeX, so surrounding spaces are fine.
285 if (looksLikeMath(content, true)) {
286 out += escapeMarkdown(inlineTex(content))
287 count++
288 i = close + 2
289 continue
290 }
291 }
292 }
293 if (options.display && next === '[') {
294 const close = findEscapedClose(text, i + 2, '\\]')
295 if (close !== -1) {
296 const content = text.slice(i + 2, close)
297 if (looksLikeMath(content, true)) {
298 out += '\n\n' + displayBlock(displayTex(content, maxWidth), '') + '\n\n'
299 count++
300 i = skipAfterDisplay(text, close + 2)
301 lineStart = true
302 continue
303 }
304 }
305 }
306 out += text.slice(i, i + 2)
307 i += Math.min(2, text.length - i)
308 lineStart = false
309 continue
310 }
311
312 if (ch === '$') {
313 if (text.startsWith('$$', i)) {
314 if (!options.display) {
315 out += '$$'
316 i += 2
317 continue
318 }
319 const close = findDoubleClose(text, i + 2)
320 if (close !== -1) {
321 const content = text.slice(i + 2, close)
322 const displayContext = atLineStart(text, i)
323 // `$$...$$` is explicit; prose-word rejection still applies.
324 if (looksLikeMath(content, true)) {
325 if (displayContext) {
326 out += '\n\n' + displayBlock(displayTex(content, maxWidth), '') + '\n\n'
327 count++
328 i = skipAfterDisplay(text, close + 2)
329 lineStart = true
330 continue
331 }
332 if (options.inline) {
333 out += escapeMarkdown(inlineTex(content))
334 count++
335 i = close + 2
336 continue
337 }
338 }
339 }
340 } else if (options.inline) {
341 const next = text[i + 1] ?? ''
342 if (next !== '' && next !== '$' && !/\s/.test(next)) {
343 const close = findInlineClose(text, i + 1)
344 if (close !== -1) {
345 const content = text.slice(i + 1, close)
346 if (looksLikeMath(content)) {
347 out += escapeMarkdown(inlineTex(content))
348 count++
349 i = close + 1
350 continue
351 }
352 }
353 }
354 }
355 }
356
357 out += ch
358 i++
359 }
360
361 return { text: out, changed: out !== text, count }
362}
363src/preview.ts 274 lines1export type PreviewOptions = {
2 title?: string
3 subtitle?: string
4}
5
6function escapeHtml(text: string): string {
7 return String(text)
8 .replace(/&/g, '&')
9 .replace(/</g, '<')
10 .replace(/>/g, '>')
11 .replace(/"/g, '"')
12}
13
14export function buildPreviewHtml(markdown: string, options: PreviewOptions = {}): string {
15 const opts = options ?? {}
16 const title = String(opts.title ?? 'ClaudeTex · TeX preview')
17 const subtitle = String(opts.subtitle ?? 'Rendered with KaTeX in your browser')
18 const source = typeof markdown === 'string' ? markdown : String(markdown ?? '')
19
20 return `<!doctype html>
21<html lang="en">
22<head>
23<meta charset="utf-8">
24<meta name="viewport" content="width=device-width, initial-scale=1">
25<meta http-equiv="Content-Security-Policy" content="default-src 'none'; script-src 'unsafe-inline' https://cdnjs.cloudflare.com; style-src 'unsafe-inline' https://cdnjs.cloudflare.com; font-src https://cdnjs.cloudflare.com; img-src data: https:; connect-src 'none'; object-src 'none'; base-uri 'none'; form-action 'none'">
26<title>${escapeHtml(title)}</title>
27<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/KaTeX/0.19.0/katex.min.css" integrity="sha384-3rdsX6e5mueWyoweR9NIVmtEsUkokpBT/0ALqKKIBMr9j4qhHkaIkAcGgsE6uVlp" crossorigin="anonymous" referrerpolicy="no-referrer">
28<style>
29 :root {
30 color-scheme: light dark;
31 --bg: #f0eee6;
32 --paper: #faf9f5;
33 --ink: #191919;
34 --muted: #6b6259;
35 --line: #e2dcd1;
36 --accent: #d97757;
37 --accent-soft: #f5e3d8;
38 --code-bg: #f1eee7;
39 --quote: #f7f4ec;
40 }
41 @media (prefers-color-scheme: dark) {
42 :root {
43 --bg: #191919;
44 --paper: #202020;
45 --ink: #f0eee6;
46 --muted: #a8a29a;
47 --line: #34312c;
48 --accent: #e8927c;
49 --accent-soft: #3a2a24;
50 --code-bg: #262523;
51 --quote: #22211f;
52 }
53 }
54 * { box-sizing: border-box; }
55 html { -webkit-text-size-adjust: 100%; }
56 body {
57 margin: 0;
58 background: var(--bg);
59 color: var(--ink);
60 font: 16px/1.65 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Noto Sans", "Noto Sans CJK SC", sans-serif;
61 }
62 .wrap { max-width: 920px; margin: 0 auto; padding: 28px 22px 80px; }
63 header {
64 display: flex; align-items: center; justify-content: space-between; gap: 16px;
65 padding-bottom: 18px; margin-bottom: 28px; border-bottom: 1px solid var(--line);
66 }
67 .brand { display: flex; align-items: center; gap: 12px; min-width: 0; }
68 .mark {
69 width: 34px; height: 34px; border-radius: 9px; flex: none;
70 display: grid; place-items: center;
71 background: var(--accent); color: var(--paper);
72 font: 600 18px/1 "Cambria Math", Georgia, serif;
73 }
74 h1 { font-size: 17px; line-height: 1.3; margin: 0; font-weight: 600; }
75 .sub { font-size: 12.5px; color: var(--muted); margin-top: 2px; }
76 .tools { display: flex; gap: 8px; flex: none; }
77 button {
78 font: inherit; font-size: 13px; color: var(--ink); background: var(--paper);
79 border: 1px solid var(--line); border-radius: 8px; padding: 6px 11px; cursor: pointer;
80 }
81 button:hover { border-color: var(--accent); color: var(--accent); }
82 main {
83 background: var(--paper); border: 1px solid var(--line); border-radius: 14px;
84 padding: 30px 34px 36px; box-shadow: 0 1px 2px rgb(0 0 0 / 0.03), 0 12px 40px rgb(0 0 0 / 0.04);
85 }
86 main h1, main h2, main h3 { line-height: 1.3; margin: 1.4em 0 0.6em; }
87 main h1 { font-size: 1.6em; } main h2 { font-size: 1.32em; } main h3 { font-size: 1.12em; }
88 main p { margin: 0.8em 0; }
89 main ul, main ol { padding-left: 1.5em; }
90 main li { margin: 0.3em 0; }
91 a { color: var(--accent); text-decoration-thickness: 1px; text-underline-offset: 2px; }
92 code {
93 font: 0.92em/1.5 "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace;
94 background: var(--code-bg); padding: 0.15em 0.35em; border-radius: 5px;
95 }
96 pre {
97 background: var(--code-bg); border: 1px solid var(--line); border-radius: 10px;
98 padding: 14px 16px; overflow: auto; line-height: 1.5;
99 }
100 pre code { background: none; padding: 0; font-size: 0.9em; }
101 blockquote {
102 margin: 1em 0; padding: 0.4em 1em; color: var(--muted);
103 background: var(--quote); border-left: 3px solid var(--accent);
104 }
105 table { border-collapse: collapse; width: 100%; margin: 1em 0; font-size: 0.95em; }
106 th, td { border: 1px solid var(--line); padding: 7px 10px; text-align: left; }
107 th { background: var(--quote); }
108 hr { border: 0; border-top: 1px solid var(--line); margin: 2em 0; }
109 img, svg { max-width: 100%; }
110 .katex-display { overflow-x: auto; overflow-y: hidden; padding: 0.3em 0; }
111 #source-view {
112 display: none; white-space: pre-wrap; word-break: break-word;
113 font: 0.88em/1.55 "SFMono-Regular", Consolas, Menlo, monospace;
114 background: var(--code-bg); border: 1px solid var(--line); border-radius: 10px;
115 padding: 16px; margin-top: 24px; color: var(--muted);
116 }
117 footer { color: var(--muted); font-size: 12px; text-align: center; margin-top: 22px; }
118 .loading { color: var(--muted); font-size: 14px; }
119 @media (max-width: 640px) {
120 .wrap { padding: 18px 12px 60px; }
121 main { padding: 22px 18px 28px; border-radius: 12px; }
122 header { align-items: flex-start; }
123 .sub { display: none; }
124 }
125 @media print {
126 body { background: #fff; }
127 header, footer, #source-view, .tools { display: none; }
128 main { border: 0; box-shadow: none; padding: 0; }
129 }
130</style>
131</head>
132<body>
133<div class="wrap">
134 <header>
135 <div class="brand">
136 <div class="mark">TeX</div>
137 <div>
138 <h1>${escapeHtml(title)}</h1>
139 <div class="sub">${escapeHtml(subtitle)}</div>
140 </div>
141 </div>
142 <div class="tools">
143 <button id="toggle-source" type="button">Source</button>
144 <button id="copy-md" type="button">Copy Markdown</button>
145 </div>
146 </header>
147 <main id="content"><p class="loading">Rendering…</p></main>
148 <pre id="source-view"></pre>
149 <footer>Generated by <strong>ClaudeTex</strong> (the <strong>tex</strong> mod) · KaTeX renders locally in this page.</footer>
150</div>
151
152<template id="markdown-source">${escapeHtml(source)}</template>
153
154<script src="https://cdnjs.cloudflare.com/ajax/libs/marked/15.0.12/marked.min.js" integrity="sha384-948ahk4ZmxYVYOc+rxN1H2gM1EJ2Duhp7uHtZ4WSLkV4Vtx5MUqnV+l7u9B+jFv+" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
155<script src="https://cdnjs.cloudflare.com/ajax/libs/dompurify/3.4.16/purify.min.js" integrity="sha384-a7SzOxErzJ3ZpQz0zJ32d67dSitNzPcbfybc/ykU9KJhMgZkwqfSxlhhdJRS+XGL" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
156<script src="https://cdnjs.cloudflare.com/ajax/libs/KaTeX/0.19.0/katex.min.js" integrity="sha384-QFFtAGzvvj+bfgCGxXJlNZZR1nXEZgvG8tDLCCY1F19xl20WlfTYgguB4VcNdxYk" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
157<script src="https://cdnjs.cloudflare.com/ajax/libs/KaTeX/0.19.0/contrib/auto-render.min.js" integrity="sha384-bjyGPfbij8/NDKJhSGZNP/khQVgtHUE5exjm4Ydllo42FwIgYsdLO2lXGmRBf5Mz" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
158<script>
159(function () {
160 var template = document.getElementById('markdown-source');
161 var source = template && template.content ? template.content.textContent || '' : '';
162 var content = document.getElementById('content');
163 var sourceView = document.getElementById('source-view');
164 sourceView.textContent = source;
165
166 function flashCopied() {
167 var button = document.getElementById('copy-md');
168 var old = button.textContent;
169 button.textContent = 'Copied';
170 setTimeout(function () { button.textContent = old; }, 1400);
171 }
172
173 document.getElementById('copy-md').addEventListener('click', function () {
174 if (navigator.clipboard && navigator.clipboard.writeText) {
175 navigator.clipboard.writeText(source).then(flashCopied, fallbackCopy);
176 } else {
177 fallbackCopy();
178 }
179 });
180
181 function fallbackCopy() {
182 var area = document.createElement('textarea');
183 area.value = source;
184 area.style.position = 'fixed';
185 area.style.left = '-9999px';
186 document.body.appendChild(area);
187 area.select();
188 try { document.execCommand('copy'); flashCopied(); } catch (error) { console.error(error); }
189 document.body.removeChild(area);
190 }
191
192 document.getElementById('toggle-source').addEventListener('click', function () {
193 sourceView.style.display = sourceView.style.display === 'block' ? 'none' : 'block';
194 });
195
196 function showSource() {
197 content.innerHTML = '<pre></pre>';
198 content.querySelector('pre').textContent = source;
199 }
200
201 if (!window.marked || !window.DOMPurify) {
202 showSource();
203 return;
204 }
205
206 // Markdown eats the backslashes inside TeX (\\ becomes \, \, becomes ,).
207 // Cut each math span out before parsing and put it back before KaTeX runs.
208 var formulas = [];
209 var protectedSource = source.replace(
210 /\\$\\$[\\s\\S]*?\\$\\$|\\\\\\[[\\s\\S]*?\\\\\\]|\\\\\\([\\s\\S]*?\\\\\\)|\\$[^$\\n]+\\$/g,
211 function (match) {
212 formulas.push(match);
213 return '@@TEXMATH' + (formulas.length - 1) + '@@';
214 }
215 );
216
217 function escapeHtml(value) {
218 return String(value).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
219 }
220
221 try {
222 var html = window.marked.parse(protectedSource, { gfm: true, breaks: false });
223 html = window.DOMPurify.sanitize(html, { USE_PROFILES: { html: true } });
224 html = html.replace(/@@TEXMATH(\\d+)@@/g, function (_, index) {
225 return escapeHtml(formulas[Number(index)] || '');
226 });
227 content.innerHTML = html;
228 } catch (error) {
229 console.error('ClaudeTex preview:', error);
230 showSource();
231 return;
232 }
233
234 if (window.renderMathInElement) {
235 try {
236 window.renderMathInElement(content, {
237 delimiters: [
238 { left: '$$', right: '$$', display: true },
239 { left: '\\\\[', right: '\\\\]', display: true },
240 { left: '$', right: '$', display: false },
241 { left: '\\\\(', right: '\\\\)', display: false }
242 ],
243 throwOnError: false,
244 strict: false,
245 trust: false,
246 processEscapes: true,
247 macros: {
248 '\\\\R': '\\\\mathbb{R}',
249 '\\\\N': '\\\\mathbb{N}',
250 '\\\\Z': '\\\\mathbb{Z}',
251 '\\\\Q': '\\\\mathbb{Q}',
252 '\\\\C': '\\\\mathbb{C}',
253 '\\\\E': '\\\\mathbb{E}',
254 '\\\\Tr': '\\\\operatorname{Tr}',
255 '\\\\argmax': '\\\\operatorname*{arg\\\\,max}',
256 '\\\\argmin': '\\\\operatorname*{arg\\\\,min}',
257 '\\\\ket': '\\\\left|#1\\\\right\\\\rangle',
258 '\\\\bra': '\\\\left\\\\langle#1\\\\right|',
259 '\\\\braket': '\\\\left\\\\langle#1\\\\right\\\\rangle',
260 '\\\\abs': '\\\\left|#1\\\\right|',
261 '\\\\norm': '\\\\left\\\\|#1\\\\right\\\\|'
262 }
263 });
264 } catch (error) {
265 console.error('KaTeX:', error);
266 }
267 }
268})();
269</script>
270</body>
271</html>
272`
273}
274src/tex/layout.ts 871 lines1import { classOf, row, type FontName, type Node } from './ast'
2import { isBigOperator, parseTex } from './parser'
3import {
4 ENVIRONMENT_DELIMITERS,
5 SUBSCRIPTS,
6 SUPERSCRIPTS,
7 type MathClass,
8} from './symbols'
9
10export type Block = {
11 lines: string[]
12 width: number
13 baseline: number
14}
15
16// A formula that lays out beyond these is shown as wrapped text instead. The
17// parser caps input length and nesting; these caps protect the string work.
18const MAX_BLOCK_LINES = 200
19const MAX_BLOCK_WIDTH = 1_000
20
21// ---------------------------------------------------------------------------
22// Width
23
24const ZERO_WIDTH = /[̀-ͯ᪰-᷀-᷿⃐-︠-︯︎️]/
25
26function isWide(cp: number): boolean {
27 return (
28 (cp >= 0x1100 && cp <= 0x115f) ||
29 (cp >= 0x2e80 && cp <= 0x303e) ||
30 (cp >= 0x3041 && cp <= 0x33ff) ||
31 (cp >= 0x3400 && cp <= 0x4dbf) ||
32 (cp >= 0x4e00 && cp <= 0x9fff) ||
33 (cp >= 0xa000 && cp <= 0xa4cf) ||
34 (cp >= 0xac00 && cp <= 0xd7a3) ||
35 (cp >= 0xf900 && cp <= 0xfaff) ||
36 (cp >= 0xfe10 && cp <= 0xfe19) ||
37 (cp >= 0xfe30 && cp <= 0xfe6f) ||
38 (cp >= 0xff00 && cp <= 0xff60) ||
39 (cp >= 0xffe0 && cp <= 0xffe6) ||
40 (cp >= 0x1f300 && cp <= 0x1faff) ||
41 (cp >= 0x20000 && cp <= 0x3fffd)
42 )
43}
44
45export function charWidth(ch: string): number {
46 if (ch === '') return 0
47 const cp = ch.codePointAt(0) ?? 0
48 if (ZERO_WIDTH.test(ch)) return 0
49 return isWide(cp) ? 2 : 1
50}
51
52export function displayWidth(text: string): number {
53 let width = 0
54 for (const ch of text) width += charWidth(ch)
55 return width
56}
57
58function padRight(text: string, width: number): string {
59 const gap = width - displayWidth(text)
60 return gap > 0 ? text + ' '.repeat(gap) : text
61}
62
63function padLeft(text: string, width: number): string {
64 const gap = width - displayWidth(text)
65 return gap > 0 ? ' '.repeat(gap) + text : text
66}
67
68function center(text: string, width: number): string {
69 const gap = width - displayWidth(text)
70 if (gap <= 0) return text
71 const left = Math.floor(gap / 2)
72 return ' '.repeat(left) + text + ' '.repeat(gap - left)
73}
74
75function trimRight(text: string): string {
76 return text.replace(/[ \t]+$/u, '')
77}
78
79// ---------------------------------------------------------------------------
80// Blocks
81
82function oneLine(text: string): Block {
83 return { lines: [text], width: displayWidth(text), baseline: 0 }
84}
85
86function blank(width: number, height = 1, baseline = 0): Block {
87 const w = Math.max(0, width)
88 const h = Math.max(1, height)
89 return {
90 lines: Array.from({ length: h }, () => ' '.repeat(w)),
91 width: w,
92 baseline: Math.max(0, Math.min(h - 1, baseline)),
93 }
94}
95
96function hconcat(blocks: Block[], gap = 0): Block {
97 if (blocks.length === 0) return blank(0)
98 const baseline = Math.max(...blocks.map(b => b.baseline))
99 const below = Math.max(...blocks.map(b => b.lines.length - b.baseline))
100 const height = Math.max(1, baseline + below)
101 const width = blocks.reduce((sum, b) => sum + b.width, 0) + gap * (blocks.length - 1)
102 const lines = Array.from({ length: height }, () => '')
103 blocks.forEach((block, index) => {
104 const top = baseline - block.baseline
105 for (let y = 0; y < height; y++) {
106 const source = y - top
107 const text = source >= 0 && source < block.lines.length ? block.lines[source]! : ''
108 lines[y] += padRight(text, block.width)
109 if (index < blocks.length - 1) lines[y] += ' '.repeat(gap)
110 }
111 })
112 return { lines, width, baseline }
113}
114
115function vconcat(blocks: Block[], gap = 0): Block {
116 if (blocks.length === 0) return blank(0)
117 const width = Math.max(...blocks.map(b => b.width))
118 const baseline = blocks[0]!.baseline
119 const lines: string[] = []
120 blocks.forEach((block, index) => {
121 for (const line of block.lines) lines.push(center(line, width))
122 if (index < blocks.length - 1 && gap > 0) lines.push(' '.repeat(width))
123 })
124 return { lines, width, baseline }
125}
126
127function bracketBlock(body: Block, left: string, right: string): Block {
128 const width = body.width + 3
129 const height = body.lines.length
130 const lines = body.lines.map((line, index) => {
131 const l = height === 1 ? left : index === 0 ? topOf(left) : index === height - 1 ? bottomOf(left) : middleOf(left)
132 const r = height === 1 ? right : index === 0 ? topOf(right) : index === height - 1 ? bottomOf(right) : middleOf(right)
133 return l + padRight(line, body.width) + ' ' + r
134 })
135 return { lines, width, baseline: body.baseline }
136}
137
138function topOf(delim: string): string {
139 switch (delim) {
140 case '(':
141 return '⎛'
142 case ')':
143 return '⎞'
144 case '[':
145 return '⎡'
146 case ']':
147 return '⎤'
148 case '{':
149 return '⎧'
150 case '}':
151 return '⎫'
152 case '|':
153 return '│'
154 case '‖':
155 return '║'
156 default:
157 return delim
158 }
159}
160
161function middleOf(delim: string): string {
162 switch (delim) {
163 case '(':
164 return '⎜'
165 case ')':
166 return '⎟'
167 case '[':
168 return '⎢'
169 case ']':
170 return '⎥'
171 case '{':
172 case '}':
173 return '⎪'
174 case '|':
175 return '│'
176 case '‖':
177 return '║'
178 default:
179 return delim
180 }
181}
182
183function bottomOf(delim: string): string {
184 switch (delim) {
185 case '(':
186 return '⎝'
187 case ')':
188 return '⎠'
189 case '[':
190 return '⎣'
191 case ']':
192 return '⎦'
193 case '{':
194 return '⎩'
195 case '}':
196 return '⎭'
197 case '|':
198 return '│'
199 case '‖':
200 return '║'
201 default:
202 return delim
203 }
204}
205
206// ---------------------------------------------------------------------------
207// Fonts
208
209const DOUBLE_UPPER = '𝔸𝔹ℂ𝔻𝔼𝔽𝔾ℍ𝕀𝕁𝕂𝕃𝕄ℕ𝕆ℙℚℝ𝕊𝕋𝕌𝕍𝕎𝕏𝕐ℤ'
210const DOUBLE_LOWER = '𝕒𝕓𝕔𝕕𝕖𝕗𝕘𝕙𝕚𝕛𝕜𝕝𝕞𝕟𝕠𝕡𝕢𝕣𝕤𝕥𝕦𝕧𝕨𝕩𝕪𝕫'
211const SCRIPT_UPPER = '𝒜ℬ𝒞𝒟ℰℱ𝒢ℋℐ𝒥𝒦ℒℳ𝒩𝒪𝒫𝒬ℛ𝒮𝒯𝒰𝒱𝒲𝒳𝒴𝒵'
212const SCRIPT_LOWER = '𝒶𝒷𝒸𝒹ℯ𝒻ℊ𝒽𝒾𝒿𝓀𝓁𝓂𝓃ℴ𝓅𝓆𝓇𝓈𝓉𝓊𝓋𝓌𝓍𝓎𝓏'
213const FRAKTUR_UPPER = '𝔄𝔅ℭ𝔇𝔈𝔉𝔊ℌℑ𝔍𝔎𝔏𝔐𝔑𝔒𝔓𝔔ℜ𝔖𝔗𝔘𝔙𝔚𝔛𝔜ℨ'
214const FRAKTUR_LOWER = '𝔞𝔟𝔠𝔡𝔢𝔣𝔤𝔥𝔦𝔧𝔨𝔩𝔪𝔫𝔬𝔭𝔮𝔯𝔰𝔱𝔲𝔳𝔴𝔵𝔶𝔷'
215const BOLD_ITALIC_LOWER_H = 'ℎ'
216
217const FONT_TABLES: Record<string, { upper: string[]; lower: string[]; digits: string[] }> = {
218 double: { upper: Array.from(DOUBLE_UPPER), lower: Array.from(DOUBLE_LOWER), digits: Array.from('𝟘𝟙𝟚𝟛𝟜𝟝𝟞𝟟𝟠𝟡') },
219 script: { upper: Array.from(SCRIPT_UPPER), lower: Array.from(SCRIPT_LOWER), digits: [] },
220 fraktur: { upper: Array.from(FRAKTUR_UPPER), lower: Array.from(FRAKTUR_LOWER), digits: [] },
221}
222
223function mapFont(text: string, font: FontName): string {
224 if (font === 'plain') return text
225 const table = FONT_TABLES[font]
226 if (table) {
227 let out = ''
228 for (const ch of text) {
229 const code = ch.codePointAt(0) ?? 0
230 if (code >= 65 && code <= 90) out += table.upper[code - 65] ?? ch
231 else if (code >= 97 && code <= 122) out += table.lower[code - 97] ?? ch
232 else if (table.digits.length > 0 && code >= 48 && code <= 57) out += table.digits[code - 48] ?? ch
233 else out += ch
234 }
235 return out
236 }
237 let out = ''
238 for (const ch of text) {
239 const code = ch.codePointAt(0) ?? 0
240 switch (font) {
241 case 'bold':
242 if (code >= 65 && code <= 90) out += String.fromCodePoint(0x1d400 + code - 65)
243 else if (code >= 97 && code <= 122) out += String.fromCodePoint(0x1d41a + code - 97)
244 else if (code >= 48 && code <= 57) out += String.fromCodePoint(0x1d7ce + code - 48)
245 else out += ch
246 break
247 case 'italic':
248 if (ch === 'h') out += BOLD_ITALIC_LOWER_H
249 else if (code >= 65 && code <= 90) out += String.fromCodePoint(0x1d434 + code - 65)
250 else if (code >= 97 && code <= 122) out += String.fromCodePoint(0x1d44e + code - 97)
251 else out += ch
252 break
253 case 'bolditalic':
254 if (code >= 65 && code <= 90) out += String.fromCodePoint(0x1d468 + code - 65)
255 else if (code >= 97 && code <= 122) out += String.fromCodePoint(0x1d482 + code - 97)
256 else out += ch
257 break
258 case 'sans':
259 if (code >= 65 && code <= 90) out += String.fromCodePoint(0x1d5a0 + code - 65)
260 else if (code >= 97 && code <= 122) out += String.fromCodePoint(0x1d5ba + code - 97)
261 else if (code >= 48 && code <= 57) out += String.fromCodePoint(0x1d7e2 + code - 48)
262 else out += ch
263 break
264 case 'sansbold':
265 if (code >= 65 && code <= 90) out += String.fromCodePoint(0x1d5d4 + code - 65)
266 else if (code >= 97 && code <= 122) out += String.fromCodePoint(0x1d5ee + code - 97)
267 else if (code >= 48 && code <= 57) out += String.fromCodePoint(0x1d7ec + code - 48)
268 else out += ch
269 break
270 case 'mono':
271 if (code >= 65 && code <= 90) out += String.fromCodePoint(0x1d670 + code - 65)
272 else if (code >= 97 && code <= 122) out += String.fromCodePoint(0x1d68a + code - 97)
273 else if (code >= 48 && code <= 57) out += String.fromCodePoint(0x1d7f6 + code - 48)
274 else out += ch
275 break
276 default:
277 out += ch
278 }
279 }
280 return out
281}
282
283// ---------------------------------------------------------------------------
284// Inline rendering
285
286function joinItems(items: Node[], render: (node: Node) => string): string {
287 let out = ''
288 let previous: Node | null = null
289 for (const item of items) {
290 if (item.type === 'newline') {
291 out += ' '
292 continue
293 }
294 const rendered = render(item)
295 if (rendered === '') continue
296 if (previous && needsSpace(previous, item) && !out.endsWith(' ') && !rendered.startsWith(' ')) out += ' '
297 out += rendered
298 if (item.type !== 'space') previous = item
299 }
300 return out
301}
302
303function needsSpace(previous: Node, current: Node): boolean {
304 const p = classOf(previous)
305 const n = classOf(current)
306 if (p === 'space' || n === 'space') return false
307 if (n === 'bin' || n === 'rel') {
308 return !(p === 'open' || p === 'bin' || p === 'rel' || p === 'op' || p === 'punct')
309 }
310 if (p === 'bin' || p === 'rel') {
311 return n !== 'close' && n !== 'punct'
312 }
313 if (p === 'punct') return n !== 'close' && n !== 'punct'
314 if (p === 'op' && (n === 'ord' || n === 'op')) return true
315 if (p === 'ord' && n === 'op') return true
316 if (p === 'close' && n === 'op') return true
317 return false
318}
319
320function needsParens(text: string): boolean {
321 if (text.length <= 1) return false
322 let depth = 0
323 let first = true
324 for (const ch of text) {
325 if (first && (ch === '−' || ch === '-')) {
326 first = false
327 continue
328 }
329 first = false
330 if ('([{⟨⌊⌈'.includes(ch)) depth++
331 else if (')]}⟩⌋⌉'.includes(ch)) depth--
332 else if (depth === 0 && /[+\-−=<>≤≥≠≈×·÷∓±]/u.test(ch)) return true
333 else if (depth === 0 && ch === ' ') return true
334 }
335 return false
336}
337
338function inlineFrac(node: Extract<Node, { type: 'frac' }>): string {
339 const num = renderInline(node.num)
340 const den = renderInline(node.den)
341 if (!node.bar) return `C(${num}, ${den})`
342 const left = needsParens(num) ? `(${num})` : num
343 const right = needsParens(den) ? `(${den})` : den
344 return `${left}/${right}`
345}
346
347function mapScript(text: string, table: Record<string, string>): string | null {
348 let out = ''
349 for (const ch of text) {
350 const mapped = table[ch]
351 if (mapped === undefined) return null
352 out += mapped
353 }
354 return out
355}
356
357function compactScript(text: string): string {
358 return text.replace(/\s+/g, '')
359}
360
361function inlineScript(node: Extract<Node, { type: 'script' }>): string {
362 const base = renderInline(node.base)
363 if (node.sup && node.sub) {
364 const supText = renderInline(node.sup)
365 const subText = renderInline(node.sub)
366 const sup = mapScript(compactScript(supText), SUPERSCRIPTS)
367 const sub = mapScript(compactScript(subText), SUBSCRIPTS)
368 if (sup !== null && sub !== null) return base + sub + sup
369 return `${base}_(${subText})^(${supText})`
370 }
371 if (node.sup) {
372 const text = renderInline(node.sup)
373 const sup = mapScript(compactScript(text), SUPERSCRIPTS)
374 if (sup !== null) return base + sup
375 return `${base}^(${text})`
376 }
377 if (node.sub) {
378 const text = renderInline(node.sub)
379 const sub = mapScript(compactScript(text), SUBSCRIPTS)
380 if (sub !== null) return base + sub
381 return `${base}_(${text})`
382 }
383 return base
384}
385
386function inlineEnv(node: Extract<Node, { type: 'env' }>): string {
387 const rows = node.rows.map(cells => cells.map(cell => renderInline(row(cell))).join(' '))
388 if (node.env === 'cases' || node.env === 'dcases' || node.env === 'rcases') {
389 return `{ ${rows.join('; ')} }`
390 }
391 if (node.env === 'aligned' || node.env === 'align' || node.env === 'align*' || node.env === 'split') {
392 return rows.join('; ')
393 }
394 const [left, right] = ENVIRONMENT_DELIMITERS[node.env] ?? ['', '']
395 return `${left}${rows.join('; ')}${right}`
396}
397
398function renderInline(node: Node): string {
399 switch (node.type) {
400 case 'row':
401 return joinItems(node.items, renderInline)
402 case 'sym':
403 case 'text':
404 return node.text
405 case 'space':
406 return ' '.repeat(node.width)
407 case 'frac':
408 return inlineFrac(node)
409 case 'sqrt': {
410 const body = renderInline(node.body)
411 const index = node.index ? mapScript(renderInline(node.index), SUPERSCRIPTS) : null
412 const prefix = index ?? ''
413 return `${prefix}√(${body})`
414 }
415 case 'script':
416 return inlineScript(node)
417 case 'accent':
418 return combine(renderInline(node.body), node.mark)
419 case 'boxed':
420 return `[${renderInline(node.body)}]`
421 case 'styled':
422 return mapFont(renderInline(node.body), node.font)
423 case 'env':
424 return inlineEnv(node)
425 case 'newline':
426 return ' '
427 case 'phantom':
428 return ' '.repeat(displayWidth(renderInline(node.body)))
429 }
430}
431
432function combine(text: string, mark: string): string {
433 let out = ''
434 for (const ch of text) {
435 out += ch === ' ' ? ch : ch + mark
436 }
437 return out
438}
439
440// ---------------------------------------------------------------------------
441// Display rendering
442
443function renderRowBlock(items: Node[]): Block {
444 const groups: Node[][] = [[]]
445 for (const item of items) {
446 if (item.type === 'newline') groups.push([])
447 else groups[groups.length - 1]!.push(item)
448 }
449 if (groups.length > 1) return vconcat(groups.map(group => renderRowBlock(group)))
450 const blocks: Block[] = []
451 let previous: Node | null = null
452 for (const item of items) {
453 if (item.type === 'space') {
454 if (item.width > 0) blocks.push(oneLine(' '.repeat(item.width)))
455 continue
456 }
457 const block = renderBlock(item)
458 if (previous && needsSpace(previous, item) && blocks.length > 0) {
459 const last = blocks[blocks.length - 1]!
460 const lastLine = last.lines[last.baseline] ?? ''
461 if (!lastLine.endsWith(' ') && !(block.lines[block.baseline] ?? '').startsWith(' ')) {
462 blocks.push(oneLine(' '))
463 }
464 }
465 blocks.push(block)
466 previous = item
467 }
468 return hconcat(blocks)
469}
470
471function renderFracBlock(node: Extract<Node, { type: 'frac' }>): Block {
472 if (!node.bar) {
473 return oneLine(`C(${renderInline(node.num)}, ${renderInline(node.den)})`)
474 }
475 const num = renderBlock(node.num)
476 const den = renderBlock(node.den)
477 const inner = Math.max(1, num.width, den.width)
478 const width = inner + 2
479 const lines: string[] = []
480 for (const line of num.lines) lines.push(' ' + center(line, inner) + ' ')
481 lines.push(' ' + '─'.repeat(inner) + ' ')
482 for (const line of den.lines) lines.push(' ' + center(line, inner) + ' ')
483 return { lines, width, baseline: num.lines.length }
484}
485
486function renderSqrtBlock(node: Extract<Node, { type: 'sqrt' }>): Block {
487 const body = renderBlock(node.body)
488 const index = node.index ? mapScript(renderInline(node.index), SUPERSCRIPTS) : ''
489 const head = oneLine(`${index ?? ''}√`)
490 if (body.lines.length === 1) {
491 const inner = body.lines[0] ?? ''
492 if (displayWidth(inner) <= 1) return hconcat([head, body])
493 return oneLine(`${index ?? ''}√(${inner.trimEnd()})`)
494 }
495 return hconcat([head, bracketBlock(body, '(', ')')])
496}
497
498function isBigOpBase(node: Node): boolean {
499 if (isBigOperator(node)) return true
500 if (node.type === 'row' && node.items.length === 1) return isBigOpBase(node.items[0]!)
501 if (node.type === 'styled') return isBigOpBase(node.body)
502 return false
503}
504
505function sideScripts(base: Block, sup: Block | null, sub: Block | null): Block {
506 if (!sup && !sub) return base
507 const scriptWidth = Math.max(sup?.width ?? 0, sub?.width ?? 0)
508 const supHeight = sup?.lines.length ?? 0
509 const subHeight = sub?.lines.length ?? 0
510 const height = supHeight + base.lines.length + subHeight
511 const baseline = supHeight + base.baseline
512 const width = base.width + scriptWidth
513 const lines = Array.from({ length: height }, () => ' '.repeat(base.width))
514 for (let y = 0; y < base.lines.length; y++) {
515 lines[supHeight + y] = base.lines[y] ?? ''
516 }
517 if (sup) {
518 for (let y = 0; y < sup.lines.length; y++) {
519 lines[y] = ' '.repeat(base.width) + padLeft(sup.lines[y] ?? '', scriptWidth)
520 }
521 }
522 if (sub) {
523 for (let y = 0; y < sub.lines.length; y++) {
524 lines[supHeight + base.lines.length + y] =
525 ' '.repeat(base.width) + padLeft(sub.lines[y] ?? '', scriptWidth)
526 }
527 }
528 return { lines, width, baseline }
529}
530
531function renderScriptBlock(node: Extract<Node, { type: 'script' }>): Block {
532 const base = renderBlock(node.base)
533 const sup = node.sup ? renderBlock(node.sup) : null
534 const sub = node.sub ? renderBlock(node.sub) : null
535 if (!sup && !sub) return base
536
537 // Sums, products and friends put their limits above and below.
538 if (isBigOpBase(node.base) && node.limits !== false && base.lines.length === 1) {
539 const symbol = stripInline(node.base)
540 const wantsLimits = symbol !== '∫' && symbol !== '∬' && symbol !== '∭' && symbol !== '∮'
541 if (wantsLimits) {
542 const width = Math.max(base.width, sup?.width ?? 0, sub?.width ?? 0)
543 const lines: string[] = []
544 if (sup) for (const line of sup.lines) lines.push(center(compactScript(line), width))
545 lines.push(center(base.lines[0] ?? '', width))
546 if (sub) for (const line of sub.lines) lines.push(center(compactScript(line), width))
547 return { lines, width, baseline: sup?.lines.length ?? 0 }
548 }
549 }
550
551 // A one-line base with one-line scripts can stay on the baseline when the
552 // Unicode small letters cover every character.
553 if (base.lines.length === 1 && (!sup || sup.lines.length === 1) && (!sub || sub.lines.length === 1)) {
554 const supText = sup ? mapScript(compactScript(sup.lines[0] ?? ''), SUPERSCRIPTS) : ''
555 const subText = sub ? mapScript(compactScript(sub.lines[0] ?? ''), SUBSCRIPTS) : ''
556 if ((!sup || supText !== null) && (!sub || subText !== null)) {
557 const text = (base.lines[0] ?? '') + (subText ?? '') + (supText ?? '')
558 return oneLine(text)
559 }
560 }
561
562 return sideScripts(base, sup, sub)
563}
564
565function stripInline(node: Node): string {
566 return renderInline(node)
567}
568
569function renderAccentBlock(node: Extract<Node, { type: 'accent' }>): Block {
570 const body = renderBlock(node.body)
571 const lines = body.lines.map(line => combine(line, node.mark))
572 const width = Math.max(...lines.map(displayWidth))
573 return { lines, width, baseline: body.baseline }
574}
575
576function renderBoxedBlock(node: Extract<Node, { type: 'boxed' }>): Block {
577 const body = renderBlock(node.body)
578 const width = body.width + 2
579 const lines = ['┌' + '─'.repeat(body.width) + '┐']
580 for (const line of body.lines) lines.push('│' + padRight(line, body.width) + '│')
581 lines.push('└' + '─'.repeat(body.width) + '┘')
582 return { lines, width, baseline: body.baseline + 1 }
583}
584
585function renderStyledBlock(node: Extract<Node, { type: 'styled' }>): Block {
586 const body = renderBlock(node.body)
587 const lines = body.lines.map(line => mapFont(line, node.font))
588 const width = Math.max(...lines.map(displayWidth))
589 return { lines, width, baseline: body.baseline }
590}
591
592function renderEnvBlock(node: Extract<Node, { type: 'env' }>): Block {
593 const env = node.env
594 if (env === 'aligned' || env === 'align' || env === 'align*' || env === 'split' || env === 'eqnarray' || env === 'eqnarray*') {
595 return renderAligned(node.rows, false)
596 }
597 if (env === 'gathered' || env === 'gather' || env === 'gather*') {
598 return renderAligned(node.rows, true)
599 }
600 if (env === 'multline' || env === 'multline*') {
601 return renderMultline(node.rows)
602 }
603 if (env === 'cases' || env === 'dcases' || env === 'rcases') {
604 return renderCases(node.rows, env)
605 }
606 return renderGrid(node.rows, env)
607}
608
609function cellBlocks(cells: Node[][]): Block[] {
610 return cells.map(cell => renderBlock(row(cell)))
611}
612
613function rowGeometry(blocks: Block[]): { height: number; baseline: number } {
614 const baseline = Math.max(...blocks.map(b => b.baseline), 0)
615 const below = Math.max(...blocks.map(b => b.lines.length - b.baseline), 1)
616 return { height: Math.max(1, baseline + below), baseline }
617}
618
619function placeCell(block: Block, rowBaseline: number, height: number, align: 'left' | 'center' | 'right'): string[] {
620 const top = rowBaseline - block.baseline
621 const lines: string[] = []
622 for (let y = 0; y < height; y++) {
623 const src = y - top
624 const text = src >= 0 && src < block.lines.length ? block.lines[src]! : ''
625 if (align === 'right') lines.push(padLeft(text, block.width))
626 else if (align === 'center') lines.push(center(text, block.width))
627 else lines.push(padRight(text, block.width))
628 }
629 return lines
630}
631
632function renderGrid(rows: Node[][][], env: string): Block {
633 const rendered = rows.map(cellBlocks)
634 const columns = Math.max(1, ...rendered.map(r => r.length))
635 const widths = Array.from({ length: columns }, (_, c) => Math.max(0, ...rendered.map(r => r[c]?.width ?? 0)))
636 const contentLines: string[] = []
637 let firstBaseline = 0
638 let firstRow = true
639
640 for (const cells of rendered) {
641 const { height, baseline } = rowGeometry(cells)
642 if (firstRow) {
643 firstBaseline = baseline
644 firstRow = false
645 }
646 if (cells.length === 0) {
647 contentLines.push(' '.repeat(widths.reduce((a, b) => a + b, 0) + 2 * (columns - 1)))
648 continue
649 }
650 const placed = cells.map((block, c) => placeCell(block, baseline, height, 'center'))
651 for (let y = 0; y < height; y++) {
652 const parts: string[] = []
653 for (let c = 0; c < columns; c++) {
654 parts.push(padRight(placed[c]?.[y] ?? '', widths[c]!))
655 }
656 contentLines.push(parts.join(' '))
657 }
658 }
659
660 const contentWidth = widths.reduce((a, b) => a + b, 0) + 2 * Math.max(0, columns - 1)
661 const [left, right] = ENVIRONMENT_DELIMITERS[env] ?? ['', '']
662 if (!left && !right) {
663 return { lines: contentLines, width: contentWidth, baseline: firstBaseline }
664 }
665 const width = contentWidth + 2 + (left ? 1 : 0) + (right ? 1 : 0)
666 const lines = contentLines.map((line, index) => {
667 const l = delimiterAt(left, index, contentLines.length)
668 const r = delimiterAt(right, index, contentLines.length)
669 return l + ' ' + padRight(line, contentWidth) + ' ' + r
670 })
671 return { lines, width, baseline: firstBaseline }
672}
673
674function delimiterAt(delim: string, index: number, height: number): string {
675 if (!delim) return ''
676 if (height === 1) return delim
677 if (index === 0) return topOf(delim)
678 if (index === height - 1) return bottomOf(delim)
679 return middleOf(delim)
680}
681
682function renderAligned(rows: Node[][][], centered: boolean): Block {
683 const rendered = rows.map(cellBlocks)
684 const columns = Math.max(1, ...rendered.map(r => r.length))
685 const widths = Array.from({ length: columns }, (_, c) => Math.max(0, ...rendered.map(r => r[c]?.width ?? 0)))
686 const lines: string[] = []
687 let firstBaseline = 0
688 let firstRow = true
689
690 for (const cells of rendered) {
691 const { height, baseline } = rowGeometry(cells)
692 if (firstRow) {
693 firstBaseline = baseline
694 firstRow = false
695 }
696 const placed = cells.map((block, c) =>
697 placeCell(block, baseline, height, centered ? 'center' : c === 0 ? 'right' : 'left'),
698 )
699 for (let y = 0; y < height; y++) {
700 const parts: string[] = []
701 for (let c = 0; c < columns; c++) {
702 const text = centered ? placed[c]?.[y] ?? '' : (placed[c]?.[y] ?? '').trimStart()
703 parts.push(padRight(text, widths[c]!))
704 }
705 lines.push(parts.join(' '))
706 }
707 }
708 const width = Math.max(0, ...lines.map(displayWidth))
709 return { lines, width, baseline: firstBaseline }
710}
711
712function renderMultline(rows: Node[][][]): Block {
713 const rendered = rows.map(cellBlocks)
714 const lines: string[] = []
715 let firstBaseline = 0
716 const all: Block[] = []
717 for (const cells of rendered) {
718 const block = cells.length === 1 ? cells[0]! : hconcat(cells, 2)
719 all.push(block)
720 if (all.length === 1) firstBaseline = block.baseline
721 }
722 const width = Math.max(0, ...all.map(b => b.width))
723 all.forEach((block, index) => {
724 const align = index === 0 ? 'left' : index === all.length - 1 && all.length > 1 ? 'right' : 'center'
725 for (const line of block.lines) {
726 lines.push(align === 'left' ? padRight(line, width) : align === 'right' ? padLeft(line, width) : center(line, width))
727 }
728 })
729 return { lines, width, baseline: firstBaseline }
730}
731
732function renderCases(rows: Node[][][], env: string): Block {
733 const rendered = rows.map(cellBlocks)
734 const columns = Math.max(1, ...rendered.map(r => r.length))
735 const widths = Array.from({ length: columns }, (_, c) => Math.max(0, ...rendered.map(r => r[c]?.width ?? 0)))
736 const lines: string[] = []
737 let firstBaseline = 0
738 let firstRow = true
739 for (const cells of rendered) {
740 const { height, baseline } = rowGeometry(cells)
741 if (firstRow) {
742 firstBaseline = baseline
743 firstRow = false
744 }
745 const placed = cells.map(block => placeCell(block, baseline, height, 'left'))
746 for (let y = 0; y < height; y++) {
747 const parts: string[] = []
748 for (let c = 0; c < columns; c++) parts.push(padRight(placed[c]?.[y] ?? '', widths[c]!))
749 lines.push(parts.join(' '))
750 }
751 }
752 const contentWidth = Math.max(0, ...lines.map(displayWidth))
753 const width = contentWidth + 4
754 const withBrackets = lines.map((line, index) => {
755 const l = env === 'rcases' ? ' ' : index === 0 ? '⎧' : index === lines.length - 1 ? '⎩' : '⎪'
756 const r = env === 'rcases' ? (index === 0 ? '⎫' : index === lines.length - 1 ? '⎭' : '⎪') : ' '
757 return l + ' ' + padRight(line, contentWidth) + ' ' + r
758 })
759 return { lines: withBrackets, width, baseline: firstBaseline }
760}
761
762function renderBlock(node: Node): Block {
763 switch (node.type) {
764 case 'row':
765 return renderRowBlock(node.items)
766 case 'sym':
767 case 'text':
768 return oneLine(node.text)
769 case 'space':
770 return blank(node.width)
771 case 'frac':
772 return renderFracBlock(node)
773 case 'sqrt':
774 return renderSqrtBlock(node)
775 case 'script':
776 return renderScriptBlock(node)
777 case 'accent':
778 return renderAccentBlock(node)
779 case 'boxed':
780 return renderBoxedBlock(node)
781 case 'styled':
782 return renderStyledBlock(node)
783 case 'env':
784 return renderEnvBlock(node)
785 case 'newline':
786 return blank(0)
787 case 'phantom':
788 return blank(displayWidth(renderInline(node.body)))
789 }
790}
791
792// ---------------------------------------------------------------------------
793// Public API
794
795function wrap(text: string, width: number): string[] {
796 if (width <= 0) return [text]
797 const lines: string[] = []
798 let line = ''
799 const pushLine = () => {
800 if (line !== '') {
801 lines.push(trimRight(line))
802 line = ''
803 }
804 }
805
806 for (const token of text.split(/(\s+)/)) {
807 if (token === '') continue
808 let remaining = token
809 while (remaining !== '') {
810 if (displayWidth(line + remaining) <= width) {
811 line += remaining
812 break
813 }
814 if (line !== '') {
815 pushLine()
816 continue
817 }
818 // The token itself is wider than the line: cut it at a code point.
819 let chunk = ''
820 for (const ch of remaining) {
821 if (chunk !== '' && displayWidth(chunk + ch) > width) break
822 chunk += ch
823 }
824 if (chunk === '') chunk = remaining[0] ?? ''
825 lines.push(trimRight(chunk))
826 remaining = remaining.slice(chunk.length)
827 }
828 }
829 pushLine()
830 return lines.length > 0 ? lines : ['']
831}
832
833export function renderInlineTree(node: Node): string {
834 return renderInline(node)
835}
836
837export function renderDisplayTree(node: Node, maxWidth = 80): string[] {
838 const width = Number.isFinite(maxWidth) ? Math.max(12, maxWidth) : 80
839 let block: Block
840 try {
841 block = renderBlock(node)
842 } catch {
843 return wrap(renderInline(node), width)
844 }
845 if (block.width > MAX_BLOCK_WIDTH || block.lines.length > MAX_BLOCK_LINES || block.width > width) {
846 return wrap(renderInline(node), width)
847 }
848 const target = Math.min(width, Math.max(block.width + 8, 32))
849 const lines = block.lines.map(line => trimRight(center(trimRight(line), target)))
850 return lines.length > 0 ? lines : ['']
851}
852
853export function inlineTex(tex: string): string {
854 if (typeof tex !== 'string') return ''
855 try {
856 return renderInline(parseTex(tex)).replace(/[\r\n]+/g, ' ').trim()
857 } catch {
858 return tex.replace(/\s+/g, ' ').trim()
859 }
860}
861
862export function displayTex(tex: string, maxWidth = 80): string[] {
863 if (typeof tex !== 'string') return ['']
864 const width = Number.isFinite(maxWidth) ? Math.max(12, maxWidth) : 80
865 try {
866 return renderDisplayTree(parseTex(tex), width)
867 } catch {
868 return wrap(tex.replace(/\s+/g, ' ').trim(), width)
869 }
870}
871src/tex/ast.ts 69 lines1import type { MathClass } from './symbols'
2
3export type FontName =
4 | 'plain'
5 | 'bold'
6 | 'italic'
7 | 'bolditalic'
8 | 'sans'
9 | 'sansbold'
10 | 'mono'
11 | 'double'
12 | 'script'
13 | 'fraktur'
14
15export type EnvRow = Node[][]
16
17export type Node =
18 | { type: 'row'; items: Node[]; cls?: MathClass }
19 | { type: 'sym'; text: string; cls: MathClass }
20 | { type: 'text'; text: string; cls?: MathClass }
21 | { type: 'space'; width: number }
22 | { type: 'frac'; num: Node; den: Node; bar: boolean }
23 | { type: 'sqrt'; index?: Node; body: Node }
24 | { type: 'script'; base: Node; sup?: Node; sub?: Node; limits?: boolean }
25 | { type: 'accent'; mark: string; body: Node }
26 | { type: 'boxed'; body: Node }
27 | { type: 'styled'; font: FontName; body: Node }
28 | { type: 'env'; env: string; rows: EnvRow[]; spec?: string }
29 | { type: 'newline' }
30 | { type: 'phantom'; body: Node }
31
32export function row(items: Node[]): Node {
33 if (items.length === 1) return items[0]!
34 return { type: 'row', items }
35}
36
37export function classOf(node: Node): MathClass {
38 switch (node.type) {
39 case 'sym':
40 return node.cls
41 case 'text':
42 return node.cls ?? 'ord'
43 case 'row':
44 return node.cls ?? 'ord'
45 case 'space':
46 case 'newline':
47 return 'space'
48 case 'script':
49 return classOf(node.base) === 'op' ? 'op' : 'ord'
50 default:
51 return 'ord'
52 }
53}
54
55/** The plain text of a simple node, for `\not` and negation lookup. */
56export function simpleText(node: Node): string | null {
57 switch (node.type) {
58 case 'sym':
59 case 'text':
60 return node.text
61 case 'row':
62 return node.items.length === 1 ? simpleText(node.items[0]!) : null
63 case 'styled':
64 return simpleText(node.body)
65 default:
66 return null
67 }
68}
69src/tex/parser.ts 576 lines1import {
2 ACCENTS,
3 BIG_OPERATORS,
4 BINOMIALS,
5 CHAR_CLASSES,
6 DELIMITER_SIZES,
7 ENVIRONMENTS,
8 FRACTIONS,
9 FUNCTIONS,
10 FONT_COMMANDS,
11 IGNORED,
12 NEGATED,
13 SKIP_ARGUMENT,
14 SPACES,
15 STACKS,
16 STYLE_COMMANDS,
17 SYMBOLS,
18 TEXT_COMMANDS,
19 type MathClass,
20} from './symbols'
21import { classOf, row, simpleText, type EnvRow, type FontName, type Node } from './ast'
22
23type Stop = {
24 group?: boolean
25 amp?: boolean
26 newline?: boolean
27 end?: boolean
28}
29
30const NEWLINE = '\\\\'
31
32// A reply can contain anything; these caps keep a malformed or hostile formula
33// from turning into unbounded work. Real formulas are far below them.
34const MAX_TEX_LENGTH = 8_000
35const MAX_DEPTH = 64
36
37class TexLimitError extends Error {
38 constructor(message: string) {
39 super(message)
40 this.name = 'TexLimitError'
41 }
42}
43
44function sym(text: string, cls: MathClass = 'ord'): Node {
45 return { type: 'sym', text, cls }
46}
47
48function textNode(text: string, cls: MathClass = 'ord'): Node {
49 return { type: 'text', text, cls }
50}
51
52function space(width = 1): Node {
53 return { type: 'space', width }
54}
55
56function isLetter(ch: string): boolean {
57 return /[A-Za-z]/.test(ch)
58}
59
60function isSpace(ch: string): boolean {
61 return ch === ' ' || ch === '\t' || ch === '\n' || ch === '\r' || ch === '\f' || ch === '\v'
62}
63
64export class Parser {
65 private i = 0
66 private depth = 0
67 private readonly s: string
68
69 constructor(tex: string) {
70 this.s = tex
71 }
72
73 parse(): Node {
74 if (this.s.length > MAX_TEX_LENGTH) {
75 throw new TexLimitError(`TeX formula is longer than ${MAX_TEX_LENGTH} characters`)
76 }
77 const items = this.parseRow({})
78 return row(items)
79 }
80
81 private enter(): void {
82 if (this.depth >= MAX_DEPTH) {
83 throw new TexLimitError(`TeX expression is nested more than ${MAX_DEPTH} levels deep`)
84 }
85 this.depth++
86 }
87
88 private leave(): void {
89 this.depth--
90 }
91
92 private eof(): boolean {
93 return this.i >= this.s.length
94 }
95
96 private peek(offset = 0): string {
97 return this.s[this.i + offset] ?? ''
98 }
99
100 private nextChar(): string {
101 const cp = this.s.codePointAt(this.i)
102 if (cp === undefined) return ''
103 const ch = String.fromCodePoint(cp)
104 this.i += ch.length
105 return ch
106 }
107
108 private skipTrivia(): void {
109 while (!this.eof()) {
110 const ch = this.peek()
111 if (isSpace(ch)) {
112 this.i++
113 } else if (ch === '%') {
114 while (!this.eof() && this.peek() !== '\n') this.i++
115 } else {
116 break
117 }
118 }
119 }
120
121 private parseRow(stop: Stop): Node[] {
122 this.enter()
123 try {
124 const out: Node[] = []
125 while (!this.eof()) {
126 const before = this.i
127 this.skipTrivia()
128 if (this.eof()) break
129 const ch = this.peek()
130 if (ch === '}' && stop.group) break
131 if (ch === '&' && stop.amp) break
132 if (ch === '\\') {
133 const name = this.peekCommandName()
134 if (name === NEWLINE && stop.newline) break
135 if (name === 'end' && stop.end) break
136 }
137 if (ch === '^' || ch === '_') {
138 this.i++
139 const arg = this.parseArg()
140 this.attach(out, ch === '^' ? 'sup' : 'sub', arg)
141 continue
142 }
143 if (ch === "'") {
144 while (this.peek() === "'") {
145 this.i++
146 this.attach(out, 'sup', sym('′'))
147 }
148 continue
149 }
150 if (ch === '&') {
151 this.i++
152 out.push(space(1))
153 continue
154 }
155 if (ch === '~') {
156 this.i++
157 out.push(space(1))
158 continue
159 }
160 const nodes = this.parseAtom()
161 for (const node of nodes) out.push(node)
162 // Every branch above consumes something, but a malformed command must
163 // never leave the cursor in place and spin this loop forever.
164 if (this.i === before) this.i++
165 }
166 this.normalize(out)
167 return out
168 } finally {
169 this.leave()
170 }
171 }
172
173 private parseAtom(): Node[] {
174 const ch = this.peek()
175 if (ch === '{') {
176 this.i++
177 const items = this.parseRow({ group: true })
178 if (this.peek() === '}') this.i++
179 return [row(items)]
180 }
181 if (ch === '}') {
182 this.i++
183 return []
184 }
185 if (ch === '\\') return this.parseCommand()
186 return [charNode(this.nextChar())]
187 }
188
189 private attach(out: Node[], mark: 'sup' | 'sub', arg: Node): void {
190 let index = out.length - 1
191 while (index >= 0 && out[index]!.type === 'space') index--
192 if (index < 0) {
193 const base = sym('')
194 out.push(mark === 'sup' ? { type: 'script', base, sup: arg } : { type: 'script', base, sub: arg })
195 return
196 }
197 const base = out[index]!
198 if (base.type === 'script') {
199 if (mark === 'sup') base.sup = arg
200 else base.sub = arg
201 return
202 }
203 const script: Node = mark === 'sup' ? { type: 'script', base, sup: arg } : { type: 'script', base, sub: arg }
204 out[index] = script
205 }
206
207 private parseArg(): Node {
208 this.skipTrivia()
209 if (this.eof()) return sym('')
210 if (this.peek() === '{') {
211 this.i++
212 const items = this.parseRow({ group: true })
213 if (this.peek() === '}') this.i++
214 return row(items)
215 }
216 if (this.peek() === '\\') {
217 const nodes = this.parseCommand()
218 return row(nodes)
219 }
220 return charNode(this.nextChar())
221 }
222
223 private parseCommand(): Node[] {
224 if (this.peek() !== '\\') return []
225 this.enter()
226 try {
227 return this.parseCommandBody()
228 } finally {
229 this.leave()
230 }
231 }
232
233 private parseCommandBody(): Node[] {
234 const name = this.readCommandName()
235
236 if (name === NEWLINE) return [{ type: 'newline' }]
237 if (name === 'cr' || name === 'newline') return [{ type: 'newline' }]
238 if (name === 'end') {
239 // A stray \end{...} outside an environment: consume its name.
240 this.readRawGroup()
241 return []
242 }
243 if (name === 'begin') return [this.parseEnvironment()]
244 if (name === 'left' || name === 'right' || name === 'middle' || DELIMITER_SIZES.has(name)) {
245 const delim = this.readDelimiter()
246 if (!delim) return []
247 if (delim.type === 'sym') {
248 if (name === 'left') delim.cls = 'open'
249 else if (name === 'right') delim.cls = 'close'
250 else if (name === 'middle') delim.cls = 'ord'
251 }
252 return [delim]
253 }
254 if (name === 'operatorname') {
255 if (this.peek() === '*') this.i++
256 const value = this.readRawGroup()
257 return [textNode(unescapeText(value), 'op')]
258 }
259 if (TEXT_COMMANDS.has(name)) {
260 if (name === 'hspace') {
261 this.readRawGroup()
262 return [space(1)]
263 }
264 return [textNode(unescapeText(this.readRawGroup()))]
265 }
266 if (name === 'texttip' || name === 'mathtip') {
267 const part = this.parseArg()
268 this.skipTrivia()
269 if (this.peek() === '{') this.readRawGroup()
270 else if (this.peek() === '\\') this.parseCommand()
271 return [part]
272 }
273 if (name === 'color') {
274 this.readRawGroup()
275 return []
276 }
277 if (name === 'textcolor' || name === 'colorbox') {
278 this.readRawGroup()
279 return [this.parseArg()]
280 }
281 if (name === 'fcolorbox') {
282 this.readRawGroup()
283 this.readRawGroup()
284 return [this.parseArg()]
285 }
286 if (name === 'phantom' || name === 'hphantom' || name === 'vphantom') {
287 if (name === 'vphantom') {
288 this.parseArg()
289 return []
290 }
291 return [{ type: 'phantom', body: this.parseArg() }]
292 }
293 if (name === 'not') {
294 const arg = this.parseArg()
295 const value = simpleText(arg)
296 if (value !== null && NEGATED[value] !== undefined) return [sym(NEGATED[value]!, 'rel')]
297 return [sym('¬'), arg]
298 }
299 if (name === 'pmod') {
300 const arg = this.parseArg()
301 return [textNode('(mod '), arg, textNode(')')]
302 }
303 if (name === 'bmod' || name === 'mod') return [space(1), textNode('mod'), space(1)]
304 if (FRACTIONS.has(name)) {
305 const num = this.parseArg()
306 const den = this.parseArg()
307 return [{ type: 'frac', num, den, bar: true }]
308 }
309 if (BINOMIALS.has(name)) {
310 const num = this.parseArg()
311 const den = this.parseArg()
312 return [{ type: 'frac', num, den, bar: false }]
313 }
314 if (STACKS.has(name)) {
315 const top = this.parseArg()
316 const base = this.parseArg()
317 return [name === 'underset' ? { type: 'script', base, sub: top } : { type: 'script', base, sup: top }]
318 }
319 if (name === 'sqrt') {
320 const bracket = this.readRawBracket()
321 const body = this.parseArg()
322 if (bracket !== null) {
323 const index = new Parser(bracket).parse()
324 return [{ type: 'sqrt', index, body }]
325 }
326 return [{ type: 'sqrt', body }]
327 }
328 if (ACCENTS[name] !== undefined) {
329 return [{ type: 'accent', mark: ACCENTS[name]!, body: this.parseArg() }]
330 }
331 if (FONT_COMMANDS.has(name)) {
332 return [{ type: 'styled', font: fontOf(name), body: this.parseArg() }]
333 }
334 if (name === 'boxed') return [{ type: 'boxed', body: this.parseArg() }]
335 if (name === 'cancel' || name === 'bcancel' || name === 'xcancel' || name === 'sout') {
336 return [this.parseArg()]
337 }
338 if (name === 'underline') return [{ type: 'accent', mark: '̲', body: this.parseArg() }]
339 if (name === 'overline') return [{ type: 'accent', mark: '̅', body: this.parseArg() }]
340 if (name === 'underbrace') return [this.parseArg(), sym('⏟')]
341 if (name === 'overbrace') return [sym('⏞'), this.parseArg()]
342
343 const symbol = SYMBOLS.get(name)
344 if (symbol) return [sym(symbol.text, symbol.cls)]
345 if (SPACES[name] !== undefined) return [space(SPACES[name]!.length)]
346 if (FUNCTIONS.has(name)) return [textNode(name, 'op')]
347 if (IGNORED.has(name)) return []
348 if (SKIP_ARGUMENT.has(name)) {
349 this.readRawGroup()
350 return []
351 }
352 // Unknown commands stay readable rather than disappearing.
353 return [textNode(name)]
354 }
355
356 private readCommandName(): string {
357 if (this.peek() !== '\\') return ''
358 this.i++
359 if (this.peek() === '\\') {
360 this.i++
361 return NEWLINE
362 }
363 const ch = this.peek()
364 if (ch === '') return ''
365 if (isLetter(ch)) {
366 const start = this.i
367 while (isLetter(this.peek())) this.i++
368 return this.s.slice(start, this.i)
369 }
370 return this.nextChar()
371 }
372
373 private peekCommandName(): string {
374 const save = this.i
375 const name = this.readCommandName()
376 this.i = save
377 return name
378 }
379
380 private readDelimiter(): Node | null {
381 this.skipTrivia()
382 if (this.eof()) return null
383 if (this.peek() === '.') {
384 this.i++
385 return null
386 }
387 if (this.peek() === '\\') {
388 const name = this.readCommandName()
389 if (name === '.') return null
390 const symbol = SYMBOLS.get(name)
391 if (symbol) return sym(symbol.text, symbol.cls)
392 if (name.length === 1) return charNode(name)
393 return sym(name)
394 }
395 return charNode(this.nextChar())
396 }
397
398 private readRawGroup(): string {
399 this.skipTrivia()
400 if (this.peek() !== '{') {
401 if (this.eof()) return ''
402 return this.nextChar()
403 }
404 this.i++
405 const start = this.i
406 let depth = 1
407 while (!this.eof()) {
408 const ch = this.peek()
409 if (ch === '\\') {
410 this.i += 2
411 continue
412 }
413 if (ch === '{') depth++
414 if (ch === '}') {
415 depth--
416 if (depth === 0) {
417 const value = this.s.slice(start, this.i)
418 this.i++
419 return value
420 }
421 }
422 this.i++
423 }
424 return this.s.slice(start)
425 }
426
427 private readRawBracket(): string | null {
428 this.skipTrivia()
429 if (this.peek() !== '[') return null
430 this.i++
431 const start = this.i
432 while (!this.eof() && this.peek() !== ']') this.i++
433 const value = this.s.slice(start, this.i)
434 if (this.peek() === ']') this.i++
435 return value
436 }
437
438 private parseEnvironment(): Node {
439 const env = this.readRawGroup().trim()
440 let spec: string | undefined
441 if (env === 'array' || env === 'subarray') spec = this.readRawGroup()
442 if (env === 'alignedat' || env === 'alignat') spec = this.readRawGroup()
443 if (env === 'aligned' || env === 'gathered' || env === 'split' || env === 'alignedat') {
444 this.readRawBracket()
445 }
446 const rows: EnvRow[] = []
447 let cells: Node[][] = []
448
449 while (!this.eof()) {
450 this.skipTrivia()
451 if (this.eof()) break
452 if (this.peekCommandName() === 'end') {
453 const save = this.i
454 this.readCommandName()
455 const closing = this.readRawGroup().trim()
456 if (closing === env) {
457 rows.push(cells)
458 return finish()
459 }
460 this.i = save
461 }
462 const cell = this.parseRow({ amp: true, newline: true, end: true })
463 cells.push(cell)
464 this.skipTrivia()
465 if (this.peek() === '&') {
466 this.i++
467 continue
468 }
469 const next = this.peekCommandName()
470 if (next === NEWLINE) {
471 this.readCommandName()
472 this.readRawBracket()
473 rows.push(cells)
474 cells = []
475 continue
476 }
477 if (next === 'cr') {
478 this.readCommandName()
479 this.readRawBracket()
480 rows.push(cells)
481 cells = []
482 continue
483 }
484 if (next === 'end') {
485 this.readCommandName()
486 this.readRawGroup()
487 rows.push(cells)
488 return finish()
489 }
490 // Not a separator: treat it as the end of the environment at EOF.
491 rows.push(cells)
492 return finish()
493 }
494 rows.push(cells)
495 return finish()
496
497 function finish(): Node {
498 const cleaned = rows.filter(r => r.length > 1 || r.some(c => c.length > 0))
499 return { type: 'env', env, rows: cleaned, spec }
500 }
501 }
502
503 private normalize(items: Node[]): void {
504 let previous: Node | null = null
505 for (let i = 0; i < items.length; i++) {
506 const node = items[i]!
507 const cls = classOf(node)
508 if (node.type === 'sym' && node.cls === 'bin') {
509 const prevCls = previous ? classOf(previous) : null
510 if (prevCls === null || prevCls === 'open' || prevCls === 'bin' || prevCls === 'rel' || prevCls === 'op' || prevCls === 'punct') {
511 node.cls = 'ord'
512 }
513 }
514 if ((cls === 'rel' || cls === 'close' || cls === 'punct') && previous?.type === 'sym' && previous.cls === 'bin') {
515 previous.cls = 'ord'
516 }
517 if (cls !== 'space') previous = node
518 }
519 }
520}
521
522function charNode(ch: string): Node {
523 if (ch === '-') return sym('−', 'bin')
524 return sym(ch, CHAR_CLASSES[ch] ?? 'ord')
525}
526
527function fontOf(name: string): FontName {
528 switch (name) {
529 case 'mathbb':
530 case 'Bbb':
531 return 'double'
532 case 'mathcal':
533 case 'mathscr':
534 return 'script'
535 case 'mathfrak':
536 return 'fraktur'
537 case 'mathbf':
538 case 'boldsymbol':
539 case 'bm':
540 case 'mathbold':
541 return 'bold'
542 case 'mathsf':
543 return 'sans'
544 case 'mathtt':
545 return 'mono'
546 case 'mathit':
547 return 'italic'
548 default:
549 return 'plain'
550 }
551}
552
553function unescapeText(raw: string): string {
554 return raw
555 .replace(/\\\{/g, '{')
556 .replace(/\\\}/g, '}')
557 .replace(/\\%/g, '%')
558 .replace(/\\_/g, '_')
559 .replace(/\\&/g, '&')
560 .replace(/\\#/g, '#')
561 .replace(/\\\$/g, '$')
562 .replace(/\\ /g, ' ')
563 .replace(/\\([A-Za-z]+)\b/g, '$1')
564 .replace(/\\\\/g, ' ')
565}
566
567export function parseTex(tex: string): Node {
568 return new Parser(tex).parse()
569}
570
571export function isBigOperator(node: Node): boolean {
572 if (node.type === 'sym') return node.cls === 'op' && BIG_OPERATORS.has(node.text)
573 if (node.type === 'text') return node.cls === 'op' && BIG_OPERATORS.has(node.text)
574 return false
575}
576src/tex/symbols.ts 266 lines1// TeX command tables. The tables are deliberately plain data: the parser
2// looks everything up here, and the layout engine only sees classes.
3
4export type MathClass = 'ord' | 'op' | 'bin' | 'rel' | 'open' | 'close' | 'punct' | 'space'
5
6export type SymbolInfo = { text: string; cls: MathClass }
7
8const ord: Record<string, string> = {
9 // Greek
10 alpha: 'α', beta: 'β', gamma: 'γ', delta: 'δ', epsilon: 'ϵ', varepsilon: 'ε',
11 zeta: 'ζ', eta: 'η', theta: 'θ', vartheta: 'ϑ', iota: 'ι', kappa: 'κ',
12 lambda: 'λ', mu: 'μ', nu: 'ν', xi: 'ξ', omicron: 'ο', pi: 'π', varpi: 'ϖ',
13 rho: 'ρ', varrho: 'ϱ', sigma: 'σ', varsigma: 'ς', tau: 'τ', upsilon: 'υ',
14 phi: 'ϕ', varphi: 'φ', chi: 'χ', psi: 'ψ', omega: 'ω',
15 Gamma: 'Γ', Delta: 'Δ', Theta: 'Θ', Lambda: 'Λ', Xi: 'Ξ', Pi: 'Π',
16 Sigma: 'Σ', Upsilon: 'Υ', Phi: 'Φ', Psi: 'Ψ', Omega: 'Ω',
17 // Constants and miscellaneous ordinary symbols
18 partial: '∂', nabla: '∇', infty: '∞', forall: '∀', exists: '∃', nexists: '∄',
19 neg: '¬', lnot: '¬', emptyset: '∅', varnothing: '∅', ell: 'ℓ', hbar: 'ℏ',
20 Re: 'ℜ', Im: 'ℑ', aleph: 'ℵ', beth: 'ℶ', gimel: 'ℷ', daleth: 'ℸ',
21 angle: '∠', measuredangle: '∡', sphericalangle: '∢', triangle: '△',
22 triangledown: '▽', square: '□', blacksquare: '■', diamond: '◇', bullet: '•',
23 top: '⊤', bot: '⊥', prime: '′', backprime: '‵', dagger: '†', ddagger: '‡',
24 dots: '…', ldots: '…', cdots: '⋯', vdots: '⋮', ddots: '⋱', iddots: '⋰',
25 vert: '|', Vert: '‖', backslash: '\\', sharp: '♯', flat: '♭', natural: '♮',
26 clubsuit: '♣', diamondsuit: '♢', heartsuit: '♡', spadesuit: '♠',
27 checkmark: '✓', degree: '°', circledR: '®', copyright: '©',
28 // Arrows are relations in TeX; these non-arrow odds and ends stay ordinary.
29 implies: '⟹', impliedby: '⟸',
30 // Large delimiters are open/close; these are the bare forms.
31 langle: '⟨', rangle: '⟩', lfloor: '⌊', rfloor: '⌋', lceil: '⌈', rceil: '⌉',
32 lbrace: '{', rbrace: '}', lbrack: '[', rbrack: ']',
33 lvert: '|', rvert: '|', lVert: '‖', rVert: '‖',
34 // Escaped punctuation handled as ordinary text by the parser.
35 '%': '%', '$': '$', '#': '#', '_': '_', '&': '&', '{': '{', '}': '}',
36 '|': '‖',
37}
38
39const bin: Record<string, string> = {
40 cdot: '·', cdotp: '·', times: '×', div: '÷', pm: '±', mp: '∓', ast: '∗',
41 star: '⋆', circ: '∘', bullet: '•', oplus: '⊕', ominus: '⊖', otimes: '⊗',
42 oslash: '⊘', odot: '⊙', cup: '∪', cap: '∩', setminus: '∖', smallsetminus: '∖',
43 wedge: '∧', land: '∧', vee: '∨', lor: '∨', uplus: '⊎', sqcup: '⊔',
44 sqcap: '⊓', amalg: '⨿', wr: '≀', diamond: '⋄', bigtriangleup: '△',
45 bigtriangledown: '▽', triangleleft: '◁', triangleright: '▷', lhd: '⊲',
46 rhd: '⊳', unlhd: '⊴', unrhd: '⊵', dotplus: '∔', lt: '<', gt: '>',
47}
48
49const rel: Record<string, string> = {
50 le: '≤', leq: '≤', ge: '≥', geq: '≥', ne: '≠', neq: '≠', approx: '≈',
51 approxeq: '≊', equiv: '≡', sim: '∼', simeq: '≃', cong: '≅', propto: '∝',
52 ll: '≪', gg: '≫', lll: '⋘', ggg: '⋙', lesssim: '≲', gtrsim: '≳',
53 lessapprox: '⪅', gtrapprox: '⪆', prec: '≺', succ: '≻', preceq: '⪯',
54 succeq: '⪰', precsim: '≾', succsim: '≿', subset: '⊂', subseteq: '⊆',
55 subsetneq: '⊊', supset: '⊃', supseteq: '⊇', supsetneq: '⊋', in: '∈',
56 notin: '∉', ni: '∋', owns: '∋', mid: '∣', nmid: '∤', parallel: '∥',
57 nparallel: '∦', perp: '⊥', models: '⊨', vdash: '⊢', dashv: '⊣',
58 Vdash: '⊩', Vvdash: '⊪', coloneqq: '≔', eqqcolon: '≕', triangleq: '≜',
59 doteq: '≐', asymp: '≍', bowtie: '⋈', smile: '⌣', frown: '⌢',
60 to: '→', rightarrow: '→', leftarrow: '←', gets: '←', Rightarrow: '⇒',
61 Leftarrow: '⇐', Leftrightarrow: '⇔', iff: '⟺', implies: '⟹', impliedby: '⟸',
62 mapsto: '↦', leftrightarrow: '↔', uparrow: '↑', downarrow: '↓',
63 updownarrow: '↕', Uparrow: '⇑', Downarrow: '⇓', Updownarrow: '⇕',
64 longrightarrow: '⟶', longleftarrow: '⟵', longleftrightarrow: '⟷',
65 Longrightarrow: '⟹', Longleftarrow: '⟸', Longleftrightarrow: '⟺',
66 longmapsto: '⟼', hookrightarrow: '↪', hookleftarrow: '↩', leadsto: '⇝',
67 rightleftharpoons: '⇌', leftrightharpoons: '⇋', nearrow: '↗', searrow: '↘',
68 swarrow: '↙', nwarrow: '↖', nrightarrow: '↛', nleftarrow: '↚',
69 nRightarrow: '⇏', nLeftarrow: '⇍', nleftrightarrow: '↮', nLeftrightarrow: '⇎',
70 Rrightarrow: '⇛', Lleftarrow: '⇚', twoheadrightarrow: '↠',
71 twoheadleftarrow: '↞', rightsquigarrow: '⇝', leftrightsquigarrow: '↭',
72}
73
74const bigop: Record<string, string> = {
75 sum: '∑', prod: '∏', coprod: '∐', int: '∫', iint: '∬', iiint: '∭',
76 iiiint: '⨌', oint: '∮', oiint: '∯', oiiint: '∰', bigcup: '⋃', bigcap: '⋂',
77 bigoplus: '⨁', bigotimes: '⨂', bigodot: '⨀', biguplus: '⨄', bigsqcup: '⨆',
78 bigvee: '⋁', bigwedge: '⋀', bigtimes: '⨉',
79}
80
81const open: Record<string, string> = {
82 langle: '⟨', lfloor: '⌊', lceil: '⌈', lvert: '|', lVert: '‖',
83 lbrace: '{', lbrack: '[', lgroup: '⟮', lmoustache: '⎰',
84}
85
86const close: Record<string, string> = {
87 rangle: '⟩', rfloor: '⌋', rceil: '⌉', rvert: '|', rVert: '‖',
88 rbrace: '}', rbrack: ']', rgroup: '⟯', rmoustache: '⎱',
89}
90
91const punct: Record<string, string> = {}
92
93export const SYMBOLS = new Map<string, SymbolInfo>()
94for (const [name, text] of Object.entries(ord)) SYMBOLS.set(name, { text, cls: 'ord' })
95for (const [name, text] of Object.entries(bin)) SYMBOLS.set(name, { text, cls: 'bin' })
96for (const [name, text] of Object.entries(rel)) SYMBOLS.set(name, { text, cls: 'rel' })
97for (const [name, text] of Object.entries(bigop)) SYMBOLS.set(name, { text, cls: 'op' })
98for (const [name, text] of Object.entries(open)) SYMBOLS.set(name, { text, cls: 'open' })
99for (const [name, text] of Object.entries(close)) SYMBOLS.set(name, { text, cls: 'close' })
100for (const [name, text] of Object.entries(punct)) SYMBOLS.set(name, { text, cls: 'punct' })
101
102// Overrides where the same command appears in more than one table.
103SYMBOLS.set('colon', { text: ':', cls: 'rel' })
104SYMBOLS.set('Vert', { text: '‖', cls: 'ord' })
105SYMBOLS.set('vert', { text: '|', cls: 'ord' })
106SYMBOLS.set('backslash', { text: '\\', cls: 'ord' })
107
108export const SPACES: Record<string, string> = {
109 quad: ' ',
110 qquad: ' ',
111 enspace: ' ',
112 enskip: ' ',
113 thinspace: ' ',
114 medspace: ' ',
115 thickspace: ' ',
116 negthinspace: '',
117 negmedspace: '',
118 negthickspace: '',
119 ',': ' ',
120 ':': ' ',
121 ';': ' ',
122 '>': ' ',
123 ' ': ' ',
124 '!': '',
125}
126
127export const FUNCTIONS = new Set([
128 'arccos', 'arcsin', 'arctan', 'arg', 'argmax', 'argmin', 'cos', 'cosh',
129 'cot', 'coth', 'csc', 'deg', 'det', 'dim', 'exp', 'gcd', 'hom', 'inf',
130 'ker', 'lg', 'lim', 'liminf', 'limsup', 'ln', 'log', 'max', 'min', 'Pr',
131 'sec', 'sin', 'sinh', 'sup', 'tan', 'tanh', 'tr', 'Tr', 'rank', 'diag',
132 'sgn', 'mod', 'bmod', 'pmod',
133])
134
135export const ACCENTS: Record<string, string> = {
136 hat: '̂', widehat: '̂', check: '̌', bar: '̄',
137 overline: '̅', tilde: '̃', widetilde: '̃', dot: '̇',
138 ddot: '̈', vec: '⃗', acute: '́', grave: '̀',
139 breve: '̆', mathring: '̊', underline: '̲',
140}
141
142export const TEXT_COMMANDS = new Set([
143 'text', 'textrm', 'textbf', 'textit', 'textsf', 'texttt', 'textnormal',
144 'emph', 'mbox', 'hbox', 'hspace',
145])
146
147export const STYLE_COMMANDS = new Set([
148 'mathrm', 'mathbf', 'mathit', 'mathsf', 'mathtt', 'mathnormal',
149 'boldsymbol', 'bm', 'mathbold', 'underline', 'boxed', 'cancel',
150 'bcancel', 'xcancel', 'sout', 'overline', 'underbrace', 'overbrace',
151])
152
153export const FONT_COMMANDS = new Set([
154 'mathbb', 'Bbb', 'mathcal', 'mathscr', 'mathfrak', 'mathbf', 'boldsymbol',
155 'bm', 'mathbold', 'mathsf', 'mathtt', 'mathit', 'mathrm', 'mathnormal',
156])
157
158export const IGNORED = new Set([
159 'displaystyle', 'textstyle', 'scriptstyle', 'scriptscriptstyle',
160 'limits', 'nolimits', 'nonumber', 'notag', 'allowbreak', 'nobreak',
161 'hfill', 'hfil', 'hss', 'smallskip', 'medskip', 'bigskip', 'noindent',
162 'hline', 'cline', 'tiny', 'scriptsize', 'footnotesize', 'small',
163 'normalsize', 'large', 'Large', 'LARGE', 'huge', 'Huge',
164])
165
166export const SKIP_ARGUMENT = new Set([
167 'label', 'tag', 'color', 'phantom', 'hphantom', 'vphantom', 'rule',
168 'colorbox', 'fcolorbox', 'raisebox', 'kern', 'mkern', 'mskip', 'vspace',
169])
170
171export const DELIMITER_SIZES = new Set([
172 'left', 'right', 'middle', 'big', 'Big', 'bigg', 'Bigg', 'bigl', 'bigr',
173 'Bigl', 'Bigr', 'biggl', 'biggr', 'Biggl', 'Biggr', 'bigm', 'Bigm',
174 'biggm', 'Biggm',
175])
176
177export const FRACTIONS = new Set(['frac', 'dfrac', 'tfrac', 'cfrac'])
178export const BINOMIALS = new Set(['binom', 'dbinom', 'tbinom'])
179export const STACKS = new Set(['overset', 'underset', 'stackrel'])
180
181export const ENVIRONMENTS = new Set([
182 'matrix', 'pmatrix', 'bmatrix', 'Bmatrix', 'vmatrix', 'Vmatrix',
183 'smallmatrix', 'cases', 'dcases', 'rcases', 'array', 'aligned', 'align',
184 'align*', 'alignedat', 'split', 'gathered', 'gather', 'gather*',
185 'multline', 'multline*', 'eqnarray', 'eqnarray*', 'subarray', 'CD',
186])
187
188export const ENVIRONMENT_DELIMITERS: Record<string, [string, string]> = {
189 pmatrix: ['(', ')'],
190 bmatrix: ['[', ']'],
191 Bmatrix: ['{', '}'],
192 vmatrix: ['|', '|'],
193 Vmatrix: ['‖', '‖'],
194 cases: ['{', ''],
195 dcases: ['{', ''],
196 rcases: ['', '}'],
197}
198
199export const BIG_OPERATORS = new Set(Object.values(bigop))
200
201// Characters that TeX treats as binary, relational, opening, closing or
202// punctuation when they appear literally.
203export const CHAR_CLASSES: Record<string, MathClass> = {
204 '+': 'bin', '-': 'bin', '*': 'bin', '/': 'ord', '=': 'rel',
205 '<': 'rel', '>': 'rel', ':': 'rel', ',': 'punct', ';': 'punct',
206 '(': 'open', '[': 'open', '{': 'open', ')': 'close', ']': 'close',
207 '}': 'close', '!': 'close', '?': 'close', '.': 'ord', '|': 'ord',
208 '&': 'punct',
209}
210
211export const NEGATED: Record<string, string> = {
212 '=': '≠', '∈': '∉', '⊂': '⊄', '⊆': '⊈', '⊃': '⊅', '⊇': '⊉',
213 '≡': '≢', '∼': '≁', '<': '≮', '>': '≯', '≤': '≰', '≥': '≱',
214 '∣': '∤', '|': '∤', '∃': '∄', '≈': '≉', '≃': '≄', '≅': '≇',
215 '∝': '∦', '∥': '∦',
216}
217
218export const SUPERSCRIPTS: Record<string, string> = {
219 '0': '⁰', '1': '¹', '2': '²', '3': '³', '4': '⁴', '5': '⁵', '6': '⁶',
220 '7': '⁷', '8': '⁸', '9': '⁹', '+': '⁺', '−': '⁻', '-': '⁻', '=': '⁼',
221 '(': '⁽', ')': '⁾', a: 'ᵃ', b: 'ᵇ', c: 'ᶜ', d: 'ᵈ', e: 'ᵉ', f: 'ᶠ',
222 g: 'ᵍ', h: 'ʰ', i: 'ⁱ', j: 'ʲ', k: 'ᵏ', l: 'ˡ', m: 'ᵐ', n: 'ⁿ',
223 o: 'ᵒ', p: 'ᵖ', q: 'ᑫ', r: 'ʳ', s: 'ˢ', t: 'ᵗ', u: 'ᵘ', v: 'ᵛ',
224 w: 'ʷ', x: 'ˣ', y: 'ʸ', z: 'ᶻ', A: 'ᴬ', B: 'ᴮ', C: 'ᶜ', D: 'ᴰ',
225 E: 'ᴱ', F: 'ᶠ', G: 'ᴳ', H: 'ᴴ', I: 'ᴵ', J: 'ᴶ', K: 'ᴷ', L: 'ᴸ',
226 M: 'ᴹ', N: 'ᴺ', O: 'ᴼ', P: 'ᴾ', Q: 'ᑫ', R: 'ᴿ', S: 'ˢ', T: 'ᵀ',
227 U: 'ᵁ', V: 'ⱽ', W: 'ᵂ', X: 'ˣ', Y: 'ʸ', Z: 'ᶻ',
228 'α': 'ᵅ', 'β': 'ᵝ', 'γ': 'ᵞ', 'δ': 'ᵟ', 'ε': 'ᵋ', 'ϵ': 'ᵋ',
229 'θ': 'ᶿ', 'ι': 'ᶥ', 'φ': 'ᵠ', 'ϕ': 'ᵠ', 'χ': 'ᵡ', '′': '′',
230 '∘': '°', '⊤': 'ᵀ', '∗': '*', '*': '*', ',': ',', '.': '˙',
231}
232
233export const SUBSCRIPTS: Record<string, string> = {
234 '0': '₀', '1': '₁', '2': '₂', '3': '₃', '4': '₄', '5': '₅', '6': '₆',
235 '7': '₇', '8': '₈', '9': '₉', '+': '₊', '−': '₋', '-': '₋', '=': '₌',
236 '(': '₍', ')': '₎', a: 'ₐ', e: 'ₑ', h: 'ₕ', i: 'ᵢ', j: 'ⱼ', k: 'ₖ',
237 l: 'ₗ', m: 'ₘ', n: 'ₙ', o: 'ₒ', p: 'ₚ', r: 'ᵣ', s: 'ₛ', t: 'ₜ',
238 u: 'ᵤ', v: 'ᵥ', x: 'ₓ', 'β': 'ᵦ', 'γ': 'ᵧ', 'ρ': 'ᵨ', 'φ': 'ᵩ',
239 'ϕ': 'ᵩ', 'χ': 'ᵪ', ',': ',', '.': '.',
240}
241
242export const MATH_FONT_RANGES: Record<string, { upper: number; lower: number | null; digits: number | null }> = {
243 bold: { upper: 0x1D400, lower: 0x1D41A, digits: 0x1D7CE },
244 italic: { upper: 0x1D434, lower: 0x1D44E, digits: null },
245 bolditalic: { upper: 0x1D468, lower: 0x1D482, digits: null },
246 sans: { upper: 0x1D5A0, lower: 0x1D5BA, digits: 0x1D7E2 },
247 sansbold: { upper: 0x1D5D4, lower: 0x1D5EE, digits: 0x1D7EC },
248 mono: { upper: 0x1D670, lower: 0x1D68A, digits: 0x1D7F6 },
249 double: { upper: 0x1D538, lower: 0x1D552, digits: 0x1D7D8 },
250 script: { upper: 0x1D49C, lower: 0x1D4B6, digits: null },
251 fraktur: { upper: 0x1D504, lower: 0x1D51E, digits: null },
252}
253
254export const FONT_SPECIAL: Record<string, Record<string, string>> = {
255 double: {
256 C: 'ℂ', H: 'ℍ', N: 'ℕ', P: 'ℙ', Q: 'ℚ', R: 'ℝ', Z: 'ℤ',
257 },
258 script: {
259 B: 'ℬ', E: 'ℰ', F: 'ℱ', H: 'ℋ', I: 'ℐ', L: 'ℒ', M: 'ℳ', R: 'ℛ',
260 e: 'ℯ', g: 'ℊ', o: 'ℴ',
261 },
262 fraktur: {
263 C: 'ℭ', H: 'ℌ', I: 'ℑ', R: 'ℜ', Z: 'ℨ',
264 },
265}
266