SLOPSHOPPER

ClaudeTex

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

newrowscommandpromptprocess
v0.3.0MITupdated 2026-10-21huiihao/ClaudeTex
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · tex
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /tex ⎿ tex: KaTeX preview opened in your browser: /Users/dev/claudetex-preview-1793035028199-fda5l8.html ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

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


Why ClaudeTex?

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:

  1. In the terminal — inline math becomes Unicode, and display math is laid out as a two-dimensional block: fractions with a real bar, roots, limits above and below sums, matrices, cases, aligned, boxed. Works in any terminal.
  2. In the browser — /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.

Demo

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

Features

Automatic terminal typesetting$...$ and \(...\) are converted before Claude Code draws the reply.
Real 2D display mathFractions, 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-awareCode fences and inline code are untouched. $5, $10, $PATH, $and$ are not mistaken for math.
Cross-platformWindows Terminal, macOS, Linux; terminal, Desktop app and VS Code extension surfaces.
Zero runtime dependenciesRuns inside Claude Code. No Node.js, no TeX distribution, no external process.
Hardened by designInput limits, no-throw fallbacks, SRI-checked CDN assets, strict CSP and DOMPurify.

Quick start

Install from GitHub

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 is tex, and the command is /tex. This keeps the internal ids DNS-safe while the brand stays readable.

Local development

git clone https://github.com/huiihao/ClaudeTex
cd ClaudeTex
claude --plugin-dir .

Requirements

  • Claude Code 2.1.287+ (mods are enabled by default from that version; tested on 2.1.304).
  • A terminal, the Desktop app's Code tab, or the VS Code extension.
  • A browser and internet access for the /tex preview (assets are loaded from cdnjs with SRI).

Usage

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.

Configuration

Run /plugin configure tex@claudetex, or edit ~/.claude/settings.json:

{
  "pluginConfigs": {
    "tex": {
      "options": {
        "inline": true,
        "display": true,
        "teach_claude": true,
        "max_width": 0
      }
    }
  }
}
OptionDefaultDescription
inlinetrueConvert $...$ and \(...\) to Unicode math.
displaytrueLay out $$...$$ and \[...\] as two-dimensional blocks.
teach_claudetrueTell the model to write LaTeX on the first prompt of each context window.
max_width0Maximum width of a display formula in terminal cells. 0 follows the terminal.

Supported LaTeX

CategoryExamples
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
Scriptsx_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
Environmentspmatrix, 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

How it works

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 callRole
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.submitAdds a short “write math in LaTeX” note once per context window.
session.startRegisters /tex and /tex-preview.
session.compact / session.endResets 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.

Robustness

The renderer is built to degrade gracefully rather than break a conversation:

  • Bounded parsing — formulas longer than 8,000 characters or nested deeper than 64 levels are left as LaTeX. The parser guarantees forward progress, so malformed \frac{, unclosed \begin{matrix} and stray } cannot loop.
  • Bounded layout — display blocks over 200 lines or 1,000 columns fall back to wrapped text. Long words are split by cell width.
  • Bounded scanning — replies over 200,000 characters pass through untouched; code spans close only on an exact backtick run; CRLF is normalized.
  • Conservative math detection — currency, shell variables, prose words and empty $$ $$ are not rewritten. Explicit \(...\) and \[...\] may contain surrounding whitespace.
  • No-throw fallbacks — every transform is wrapped; a failure returns the original reply.
  • Preview hardening — SRI on every CDN script and stylesheet, a strict CSP (connect-src 'none'), DOMPurify cleaning, and the Markdown source stored in an inert <template>.
  • Tested — 38 tests cover malformed input, deep nesting, oversized replies, CRLF, code spans, currency heuristics, command failures and command registration.

Limitations

  • The terminal is not pixel-perfect KaTeX. It is a character grid; fractions and matrices are Unicode approximations. Use /tex for real typesetting.
  • No terminal images. Windows Terminal does not support the kitty graphics protocol, and Claude Code's Image element is limited to kitty/Ghostty-class terminals.
  • Inline math is heuristic. Rare currency, URL or shell-variable cases can be misdetected. Wrap them in backticks to opt out.
  • Indented code blocks (4 spaces) may still have $...$ transformed. Use fenced code blocks for code.
  • Not a full TeX engine. Exotic macros, TikZ and external packages are out of scope; unknown commands are shown by name.
  • /btw panels are drawn outside ui.render; ClaudeTex asks the model to use Unicode math there.
  • The preview needs a network to load KaTeX, marked and DOMPurify from cdnjs. Offline, it falls back to the Markdown source.

Development

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

Privacy

  • ClaudeTex itself makes no network requests. Parsing, layout and HTML generation are local.
  • The /tex page loads KaTeX, marked and DOMPurify from cdnjs.cloudflare.com, pinned with SRI. The reply content stays in your browser.
  • Markdown is sanitized with DOMPurify before it is inserted into the page.
  • Preview files are written to the system temp directory.

Naming

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.

Acknowledgements

  • KaTeX for browser typesetting.
  • marked and DOMPurify for the preview page.
  • Claude Code's mods API for making a real in-process plugin possible.

License

MIT — see LICENSE.


<sub>ClaudeTex is an independent community project, not affiliated with Anthropic. Claude is a trademark of Anthropic, PBC.</sub>

Source 7 files
hooks/register.ts 306 lines
1import 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}
306
src/tex/markdown.ts 363 lines
1import { 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}
363
src/preview.ts 274 lines
1export type PreviewOptions = {
2  title?: string
3  subtitle?: string
4}
5
6function escapeHtml(text: string): string {
7  return String(text)
8    .replace(/&/g, '&amp;')
9    .replace(/</g, '&lt;')
10    .replace(/>/g, '&gt;')
11    .replace(/"/g, '&quot;')
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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
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}
274
src/tex/layout.ts 871 lines
1import { 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}
871
src/tex/ast.ts 69 lines
1import 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}
69
src/tex/parser.ts 576 lines
1import {
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}
576
src/tex/symbols.ts 266 lines
1// 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