diff --git a/CHANGELOG.md b/CHANGELOG.md index 88ec4572..1b162646 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - **Hybrid Markdown editing** ([#460](https://github.com/raiseCatError/notMyShell/issues/460)). **F3** in a Markdown document (or `H` from the read-only views) shows it *rendered* — headings, emphasis, links, lists, tables, quotes, code — except for the block your caret (or selection) is in, which shows its exact Markdown source and is fully editable. Move on and it renders again. It is another presentation of the same buffer: every key is the editor's (letters always type; snippets, list/table helpers, undo/redo, hunk review, recovery drafts and Save As all work as in the source editor), the caret lives in source positions, and switching views never changes the text. The smallest safe block is revealed — the whole list for a nested item, the whole table, the whole fence — and a selection shows every block it touches as source. The view is anchored to a source line, so revealing a block does not move what is above it. F3 again returns to the source editor; in the split view (F2) F3 switches the text pane between source and hybrid. Limits: Markdown files only (not HTML); images appear as `[image: alt]`; Up/Down move by source line; pointer input is not handled. - **Expandable `
`** ([#462](https://github.com/raiseCatError/notMyShell/issues/462)). The native Markdown/HTML viewer now shows `
……
` as a disclosure: `▸ Summary` when closed (hiding its content), `▾ Summary` with the content indented when open — in the rendered view, the preview, the split's preview pane and the hybrid view. GitHub-style details, with a blank line and Markdown inside and `
` in a later block, are understood (nested ones too); the `open` attribute sets the starting state. **`z`** toggles the summary at or below the top of the screen, **`Z`** all summaries in view; choices are kept by summary text, survive scrolling, resizing and edits above, and never change the file. Scripts, handlers and unknown tags in a details block are inert text. Not yet: pointer clicks (the viewer has no pointer support), and toggling inside the hybrid view (it shows each details as written: closed unless `open`). +- **Math in Markdown** ([#462](https://github.com/raiseCatError/notMyShell/issues/462)). `$…$` (GitHub's rules: no space inside the dollars, not before a digit) and display `$$…$$` / ```` ```math ```` render as typeset math in the viewer, preview, split and hybrid view, offline and without a browser: real stacked fractions with a bar, roots, super- and subscripts (Unicode where every character has one, an unambiguous `x^(…)` form otherwise, placed in two dimensions in display), `∑ ∏ ∫` with limits, Greek letters, operators and relations, `\left…\right` stretched delimiters, `matrix`/`pmatrix`/`bmatrix`/`vmatrix`/`cases` and `\text`. It is a defined subset, not full LaTeX: anything outside it (unknown commands, too large, too wide, a fraction that does not fit one row inline) stays as its source text with a dim `(not rendered: reason)`, never a guess. Source is preserved exactly (the editor and `/copy` are unchanged), Safe Glyphs draw with plain ASCII, and formulas are limited in size, depth and rows. Rendering through KaTeX in the optional browser is not part of this slice. - **Problems: offline static diagnostics** ([#466](https://github.com/raiseCatError/notMyShell/issues/466)). In a Markdown, HTML or CSS document, **F1 then `e`** (the snippet palette's *Problems…* in HTML files; **F1** in a CSS file; **`P`** in the read-only views and the split's preview pane) lists what is wrong: ✖ errors, ▲ warnings, ℹ notes and `?` things that cannot be checked. Markdown: links to files that do not exist, `#fragments` with no heading or id, missing images, images without alt text, undefined `[text][ref]` references and footnotes, unused footnotes, repeated headings. HTML: missing images, stylesheets and scripts, `` without `alt`, form controls without a label, duplicate ids, unresolved `#fragments`, unclosed or unexpected tags. CSS: missing `url()` assets and unbalanced braces. Type to search, Tab filters (all, problems, errors), Enter jumps to the exact text and selects it (Back returns), Del dismisses one, Ctrl+R checks again. Checking starts when you first open Problems, follows the text (typing, undo, snippets, restored drafts, a merge) after a short pause, drops results for text that has since changed, and counts show in the editor's status row. Everything is offline: external URLs are counted, never fetched; no script, style or command is run; files are only looked at (never read except another Markdown file's headings, 256 KiB at most) and only inside the document's project (a path that leaves it, by `..` or a symlink, is reported as not checked); nothing is written. Browser-console diagnostics are a later, separate step. - **Footnote renumbering** ([#463](https://github.com/raiseCatError/notMyShell/issues/463)). *Renumber footnotes* in the snippet palette (F1, then `/` in a Markdown file) numbers the footnotes 1, 2, 3… in the order they are first referenced and updates each definition to match, as one undo step. Named footnotes (`[^note]`), escaped references, code spans and fenced code are left alone; a definition nobody references keeps its number unless a renumbered footnote needs it. - **Split view for Markdown and HTML documents** ([#461](https://github.com/raiseCatError/notMyShell/issues/461)). **F2** (or `s` in the read-only views) in the document workspace shows the editor beside NMSh's own preview of the *same* buffer: the text on the left, the rendered document on the right, updated as you type, without saving. The preview follows the caret to its block (`l` in the preview pane turns that off), **Ctrl+O** moves between the panes, `x` swaps the sides and `<` `>` change their widths (30–70%). Only the text pane is editable; keys typed while the preview has the focus never reach the text. The preview pane has its own find (`/`) and heading prompt (`#`). Below 100 columns only the focused pane is shown at full width, and widening the terminal brings the split back. Esc leaves for the read-only source view with your unsaved text kept; F2 again returns to editing. The browser-quality preview is not used in the split; the pane is NMSh's renderer. Mouse input in the split is not handled yet. diff --git a/docs/design/document-workspace.md b/docs/design/document-workspace.md index cd5f255a..cd2c0011 100644 --- a/docs/design/document-workspace.md +++ b/docs/design/document-workspace.md @@ -56,6 +56,10 @@ Limits: code fences are recognised by a line scan (indented code blocks and fenc `Block` gains `{kind: 'details'; open; summary; blocks}` (#462). Two sources: `htmlToBlocks` (an HTML document or a self-contained element) and `parseLines`, which recognises GitHub's layout — an HTML block that opens a `
` it does not close, Markdown after a blank line, `
` alone on a later line (nesting counted, within 5,000 lines) — and parses the lines between as Markdown at the right source offset, so inner blocks keep exact spans. Content written in the opening chunk is kept; anything that does not match the layout is left to the old HTML-block path, so no text is dropped. A summary is HTML, as on GitHub: Markdown punctuation in it stays literal; no summary reads "Details". `renderMarkdown` takes `disclosure.isOpen(key, defaultOpen)`; the summary row carries `MarkdownRow.details`, the key (summary text and which one of that text). The viewer keeps `disclosure: Map` (never persisted, never in the file); `z`/`Z` read each row's drawn state (▾/v), so a block's own `open` is respected, and set the opposite. The state is part of the rendered-rows cache key. The hybrid view renders inactive details as written (no toggle there yet), and the plain-text fallback is the source. +## Math + +`src/markdown/math.ts` is a small offline typesetter, not a command-to-Unicode table. A tokenizer and a recursive-descent parser build a tree (limits: 4,000 source characters, depth 24, 800 nodes); a layout pass turns it into boxes of rows with a measured width and a baseline, so fractions, roots, matrices and scripts compose and align. Inline mode must yield one row (a fraction becomes `a/b`, parenthesised when needed; a script that cannot use Unicode becomes `x^(…)`); display mode may use up to 40 rows and 500 columns. Anything outside the subset throws a reason and the caller keeps the source: `$…$` stays literal text, a display block shows `$$ source $$` dim with `(not rendered: reason)`. Block parsing (`$$` one-line or multi-line, ```` ```math ````) records the usual source span, so the source map, hybrid editing and navigation need no special case. KaTeX through the optional browser engine was evaluated and is deferred: it would need a bundled, reviewed copy and the lazy Chromium path, and the subset covers prose-level formulas without either. + ## Problems (static diagnostics) `src/markdown/diagnostics.ts` (engine), `src/viewer/problems.ts` (runner) and the *problems* dialog of `EditorPanel.ts` ([#466](https://github.com/raiseCatError/notMyShell/issues/466)). Reuses `parseBlockLines` (code blocks are skipped through the block spans), `headingsFromLines` and `resolveFragment`; no second parser, no language server. diff --git a/docs/development/v024-ledger.md b/docs/development/v024-ledger.md index 32b93285..c60a943d 100644 --- a/docs/development/v024-ledger.md +++ b/docs/development/v024-ledger.md @@ -44,6 +44,7 @@ The paused issues #460–#469 are the milestone (the maintainer superseded the e 8. **#460: hybrid Markdown editing** — branch `feature/v024-hybrid` (from `dev` after #489–#496 merged). Done: hybrid mode, items from the source map, active-block raw editing, anchored viewport, split text-pane toggle, tests, independent review (see its section). **#460 remaining:** images in place, visual-line movement, pointer input, tables as a grid, HTML. **#461** still lacks pointer input, preview→caret sync, vertical orientation and the Chromium preview; **#463** footnote renumbering; **#466**, **#462** (details/Mermaid/math), **#464/#465/#467** not started. 9. **#463: footnote renumbering** — branch `feature/v024-footnotes`, from `dev`. Done (tests in `footnotes.test.ts`). **#463 acceptance:** every implemented item is covered by automated tests; the only open requirement is the physical Ghostty/Zed pass (NOT RUN), so the issue stays open. +12. **#462: math** — branch `feature/v024-math`, from `dev`. Done: offline typesetter (`src/markdown/math.ts`), `$`/`$$`/math-fence parsing with source spans, viewer/preview/split/hybrid rendering, source fallback with a reason, tests. KaTeX via the browser is deferred. **#462 remaining:** Mermaid, pointer and hybrid `
` toggling, fragment links, optional outline rail. 11. **#462: `
`** — branch `feature/v024-details`, from `dev`. Done: block, parser (GitHub layout), renderer, `z`/`Z`, tests. **#462 remaining:** Mermaid and math (separate slices; not started), pointer toggling, hybrid toggling. The diagnostics engine descends into `details` blocks (so a link or code span inside one is checked like any other). 10. **#466: offline static diagnostics** — branch `feature/v024-diagnostics`, from `dev`. Done: engine, runner, Problems surface in all modes, tests, independent review (see its section in the design doc). **#466 remaining:** the optional browser console for trusted previews (separate gated slice, nothing started), a gutter marker, further languages. **#462 next:** `
` disclosure, then Mermaid and math (separate slices). diff --git a/docs/testing/v024-editor-physical-qa.md b/docs/testing/v024-editor-physical-qa.md index 02ef51c3..74818a9f 100644 --- a/docs/testing/v024-editor-physical-qa.md +++ b/docs/testing/v024-editor-physical-qa.md @@ -23,6 +23,7 @@ Automated tests cover the logic headlessly. Everything below is **NOT RUN** unti ## Details (#462) - [ ] A Markdown file with GitHub-style `
`: closed shows `▸ Summary`; `z` opens it; `Z`; in the preview, the split's preview pane and the hybrid view; narrow widths; Safe Glyphs (`>`/`v`); a hostile file. +- [ ] NOT RUN: A Markdown file with `$E=mc^2$`, `$\frac{a}{b}$`, a multi-line `$$ … $$` with `\sum`, `\int` and a matrix, and an unsupported command: inline and display rendering in the viewer, preview, split and hybrid; the unsupported one shows its source and the reason; Safe Glyphs; narrow widths; no glyph drift in Ghostty and Zed. ## Problems (#466) - [ ] F1 then `e` in a Markdown file with a broken link, a missing image and a bad `#fragment`: the list shows them; Enter selects the exact text; Back returns. `P` in the read-only views and in the split's preview pane. - [ ] An HTML file (palette → Problems) and a CSS file (F1); the status row counts; fixing a finding removes it after a moment; undo brings it back. diff --git a/src/markdown/blocks.ts b/src/markdown/blocks.ts index 3b77772b..a9ccc96a 100644 --- a/src/markdown/blocks.ts +++ b/src/markdown/blocks.ts @@ -24,6 +24,8 @@ export type Block = ( | {kind: 'rule'} /** `
`: a disclosure, collapsed unless `open`; its content is blocks (Markdown between GitHub-style tags is Markdown). */ | {kind: 'details'; open: boolean; summary: string; blocks: Block[]} + /** `$$ … $$` display mathematics (only when parsing with the `math` option): the LaTeX between the dollars. */ + | {kind: 'math'; source: string; raw: string[]} ) & { /** The source lines the block came from (0-based; `to` is exclusive), when `parseBlocks` knows them: the shared source-position map. */ from?: number; @@ -139,6 +141,7 @@ function delimiterRow(line: string): Alignment[] | undefined { /** Whether a line starts a block that interrupts a paragraph (a lazy continuation line must not). */ function interrupts(line: string, next: string | undefined): boolean { + if (mathEnabled && /^ {0,3}(?:\$\$\s*|\$\$(?:(?!\$\$)[^\n])+\$\$\s*)$/u.test(line)) return true; if (fenceOf(line) || headingOf(line) || isRule(line) || QUOTE.test(line)) return true; const item = ITEM.exec(line); // An ordered item interrupts a paragraph only when it starts at 1, and an empty item never does (CommonMark). @@ -146,13 +149,14 @@ function interrupts(line: string, next: string | undefined): boolean { return Boolean(line.includes('|') && next !== undefined && delimiterRow(next) && splitRow(line).length === delimiterRow(next)!.length); } -export function parseBlocks(source: string, options: {html?: boolean} = {}): Block[] { +export function parseBlocks(source: string, options: {html?: boolean; math?: boolean} = {}): Block[] { return parseBlockLines(source.replace(/\r\n?/gu, '\n').split('\n'), options); } /** The same, for lines already cut (a buffer's): a stray CR inside a line stays in it and cannot shift the line numbers after it. */ -export function parseBlockLines(lines: readonly string[], options: {html?: boolean} = {}): Block[] { +export function parseBlockLines(lines: readonly string[], options: {html?: boolean; math?: boolean} = {}): Block[] { detailsBudget = DETAILS_SCAN_LINES; + mathEnabled = Boolean(options.math); return parseLines(lines.length > MAX_LINES ? lines.slice(0, MAX_LINES) : lines, 0, Boolean(options.html), 0); } @@ -170,10 +174,32 @@ function htmlBlockKind(line: string): 1 | 2 | 6 | 7 | undefined { return KNOWN.has(name) && text.length <= 4096 && /^<[^<>]*>\s*$/u.test(text) ? 7 : undefined; } +/** `$$ x $$` on one line, or `$$` … `$$` over several (at most 200 lines, closed): the LaTeX inside and the line after the block. */ +function mathBlockAt(lines: readonly string[], start: number): {source: string; end: number} | undefined { + const first = lines[start]!; + if (indentOf(first) > 3 || !first.trimStart().startsWith('$$')) return undefined; + const text = first.trim(); + // `$$x$$ and $$y$$` is two inline formulas, not one block; so is a first line that closes and goes on. + const inner = text.slice(2); + if (inner.includes('$$')) { + const single = /^\$\$((?:(?!\$\$)[\s\S])+)\$\$$/u.exec(text); + return single?.[1]!.trim() ? {source: single[1]!.trim(), end: start + 1} : undefined; + } + const parts = [inner]; + for (let at = start + 1; at < Math.min(lines.length, start + 200); at += 1) { + const line = lines[at]!.trim(); + if (line.endsWith('$$') && !line.slice(0, -2).includes('$$')) { parts.push(line.slice(0, -2)); return {source: parts.join('\n').trim(), end: at + 1}; } + if (line.includes('$$')) return undefined; + parts.push(lines[at]!); + } + return undefined; +} + const OPENS = //giu; /** Lines the whole parse may look through to find closing `
` tags: one budget for all openers, so many unclosed ones cost a bounded amount in total. */ const DETAILS_SCAN_LINES = 100_000; let detailsBudget = DETAILS_SCAN_LINES; +let mathEnabled = false; const count = (pattern: RegExp, text: string) => (text.match(pattern) ?? []).length; /** @@ -239,6 +265,9 @@ function parseLines(lines: readonly string[], depth: number, html = false, offse const line = lines[index]!; if (blank(line)) { index++; continue; } + const math = mathEnabled ? mathBlockAt(lines, index) : undefined; + if (math) { blocks.push({kind: 'math', source: math.source, raw: lines.slice(index, math.end)}); index = math.end; continue; } + const fence = fenceOf(line); if (fence) { const code: string[] = []; diff --git a/src/markdown/inline.ts b/src/markdown/inline.ts index ec2d4b7f..cc4ab4c7 100644 --- a/src/markdown/inline.ts +++ b/src/markdown/inline.ts @@ -1,4 +1,5 @@ import {safeHyperlinkTarget} from '../output/Hyperlinks.js'; +import {renderMath} from './math.js'; import {decodeEntities, decodeEntity, DROPPED, imageLabel, KNOWN, RAW_TEXT, readTag} from './markup.js'; /** @@ -20,6 +21,8 @@ export interface InlineStyle { strike?: boolean; /** A safe http(s) target; present only on link text. */ link?: string; + /** Rendered inline mathematics (`$…$`): the text is the formula as one row of Unicode. */ + math?: boolean; /** Text of an image (its alt text): shown, never fetched. */ image?: boolean; /** The image's source reference as written (bounded). Never fetched here: the graphics service decides what may be read. */ @@ -48,6 +51,8 @@ function safeLink(url: string): string | undefined { export interface InlineOptions { /** Read the HTML tags `/view` renders (see the module comment); otherwise tags are text. */ html?: boolean; + /** Read `$…$` as mathematics (documents only): `ascii` draws it without box and bracket pieces. */ + math?: {ascii: boolean}; } /** Raw tokens before emphasis resolution: literal text, finished spans (code, links) and delimiter runs. */ @@ -203,6 +208,35 @@ function tokenize(source: string, options: InlineOptions): Token[] { } text += fence; index += run; continue; } + if (char === '$' && options.math) { + // GitHub's rules: `$x$` needs a non-space after the opener and before the closer, and the closer must not be followed by a digit + // (so prices are not formulas); `$$x$$` may sit inside a paragraph too. A formula that cannot be drawn stays as written. + const run = source[index + 1] === '$' ? 2 : 1; + const open = index + run; + let close = -1; + if (open < source.length && !/\s/u.test(source[open]!)) { + for (let at = open; at < Math.min(source.length, open + 1000); at += 1) { + if (source[at] === '\\') { at += 1; continue; } + if (source[at] === '`') { + // A code span binds tighter than a formula: `$a `b$` c` is code, not a formula that swallows the backtick. + let ticks = 1; + while (source[at + ticks] === '`') ticks++; + if (closingRun(ticks, at + ticks) >= 0) break; + at += ticks - 1; + continue; + } + if (source[at] === '$' && (run === 1 || source[at + 1] === '$')) { close = at; break; } + } + } + if (close > open && !/\s/u.test(source[close - 1]!) && !(run === 1 && /[0-9]/u.test(source[close + 1] ?? ''))) { + const result = renderMath(source.slice(open, close), {display: false, ascii: options.math.ascii}); + const end = close + run; + if (result.ok) { flush(); tokens.push({kind: 'span', span: [{text: result.rows[0]!, style: {math: true}}]}); } + else text += source.slice(index, end); + index = end; + continue; + } + } if (char === '<') { const auto = /^<(https?:\/\/[^\s<>]{1,2000})>/iu.exec(source.slice(index, index + 2100)); if (auto) { diff --git a/src/markdown/math.ts b/src/markdown/math.ts new file mode 100644 index 00000000..9ca9cf94 --- /dev/null +++ b/src/markdown/math.ts @@ -0,0 +1,553 @@ +import {displayWidth} from '../util/text.js'; + +/** + * A small, defined subset of LaTeX mathematics for the terminal (#462): a tokenizer, a recursive-descent parser and a + * two-dimensional box layout. It is not TeX and does not pretend to be: anything outside the subset — an unknown command, + * an environment other than the matrices and `cases`, an unbalanced brace, a double script — makes `renderMath` fail with a + * reason, and the caller then shows the source as written. It never draws a different formula from the one written. + * + * Supported: letters, digits and punctuation; Greek letters and the common operator, relation, arrow and set symbols; + * `\frac` (`\dfrac`, `\tfrac`), `\binom`, `\sqrt[n]{}`, `^` and `_`, `\sum \prod \int \oint \lim \max \min` with limits, + * named functions (`\sin`, `\log` …), `\text`, `\mathrm \mathbf \mathbb \mathcal \mathit \mathsf \mathtt`, accents on one + * character, `\left … \right`, spacing commands, and `matrix pmatrix bmatrix Bmatrix vmatrix Vmatrix cases`. + * + * Inline mode produces exactly one row (a fraction reads `a/b` with brackets where needed, a script that has no Unicode form + * becomes `x^(…)`), display mode a box of rows with the baseline on the fraction bar. Work is bounded: source length, nesting + * depth, node count, rows and width all have fixed limits. + */ + +export const MATH_LIMITS = {source: 4000, depth: 24, nodes: 800, rows: 40, width: 500} as const; + +export type MathResult = {ok: true; rows: string[]; baseline: number; width: number} | {ok: false; reason: string}; + +class Unsupported extends Error {} +const fail = (reason: string): never => { throw new Unsupported(reason); }; + +// ---- Symbols ---------------------------------------------------------------------------------------------------- + +const GREEK: Record = {alpha: 'α', beta: 'β', gamma: 'γ', delta: 'δ', epsilon: 'ϵ', varepsilon: 'ε', zeta: 'ζ', eta: 'η', theta: 'θ', vartheta: 'ϑ', iota: 'ι', kappa: 'κ', lambda: 'λ', mu: 'μ', nu: 'ν', xi: 'ξ', pi: 'π', varpi: 'ϖ', rho: 'ρ', varrho: 'ϱ', sigma: 'σ', varsigma: 'ς', tau: 'τ', upsilon: 'υ', phi: 'ϕ', varphi: 'φ', chi: 'χ', psi: 'ψ', omega: 'ω', + Gamma: 'Γ', Delta: 'Δ', Theta: 'Θ', Lambda: 'Λ', Xi: 'Ξ', Pi: 'Π', Sigma: 'Σ', Upsilon: 'Υ', Phi: 'Φ', Psi: 'Ψ', Omega: 'Ω'}; +const PREFIX: Record = {forall: '∀', exists: '∃', nexists: '∄', neg: '¬', lnot: '¬'}; +const ORDINARY: Record = {infty: '∞', partial: '∂', nabla: '∇', emptyset: '∅', varnothing: '∅', hbar: 'ℏ', ell: 'ℓ', Re: 'ℜ', Im: 'ℑ', aleph: 'ℵ', angle: '∠', prime: '′', degree: '°', ldots: '…', dots: '…', cdots: '⋯', vdots: '⋮', ddots: '⋱', top: '⊤', bot: '⊥', perp: '⊥', triangle: '△', square: '□', checkmark: '✓', + '%': '%', '&': '&', '$': '$', '#': '#', '_': '_', '{': '{', '}': '}', '|': '‖', '\\|': '‖'}; +const BINARY: Record = {times: '×', cdot: '·', pm: '±', mp: '∓', div: '÷', ast: '∗', star: '⋆', circ: '∘', bullet: '•', oplus: '⊕', otimes: '⊗', cup: '∪', cap: '∩', land: '∧', wedge: '∧', lor: '∨', vee: '∨', setminus: '∖', ominus: '⊖', odot: '⊙'}; +const RELATION: Record = {leq: '≤', le: '≤', geq: '≥', ge: '≥', neq: '≠', ne: '≠', approx: '≈', equiv: '≡', sim: '∼', simeq: '≃', cong: '≅', propto: '∝', in: '∈', notin: '∉', ni: '∋', subset: '⊂', subseteq: '⊆', supset: '⊃', supseteq: '⊇', to: '→', rightarrow: '→', leftarrow: '←', gets: '←', leftrightarrow: '↔', Rightarrow: '⇒', Leftarrow: '⇐', Leftrightarrow: '⇔', iff: '⇔', implies: '⇒', mapsto: '↦', uparrow: '↑', downarrow: '↓', ll: '≪', gg: '≫', parallel: '∥', mid: '∣', vdash: '⊢', models: '⊨', nsubseteq: '⊈', longrightarrow: '⟶', longleftarrow: '⟵'}; +const FUNCTIONS = new Set(['sin', 'cos', 'tan', 'cot', 'sec', 'csc', 'arcsin', 'arccos', 'arctan', 'sinh', 'cosh', 'tanh', 'log', 'ln', 'lg', 'exp', 'min', 'max', 'sup', 'inf', 'lim', 'liminf', 'limsup', 'det', 'dim', 'ker', 'deg', 'gcd', 'arg', 'Pr', 'hom', 'mod', 'sgn']); +const BIG: Record = {sum: '∑', prod: '∏', coprod: '∐', int: '∫', iint: '∬', iiint: '∭', oint: '∮', bigcup: '⋃', bigcap: '⋂', bigoplus: '⨁', bigotimes: '⨂', bigvee: '⋁', bigwedge: '⋀'}; +const LIMITS_ABOVE = new Set(['sum', 'prod', 'coprod', 'bigcup', 'bigcap', 'bigoplus', 'bigotimes', 'bigvee', 'bigwedge']); +const DELIMITERS: Record = {'(': '(', ')': ')', '[': '[', ']': ']', '{': '{', '}': '}', '|': '|', '.': '', '\\{': '{', '\\}': '}', '\\|': '‖', '\\langle': '⟨', '\\rangle': '⟩', '\\lfloor': '⌊', '\\rfloor': '⌋', '\\lceil': '⌈', '\\rceil': '⌉', '\\vert': '|', '\\Vert': '‖', '/': '/', '<': '⟨', '>': '⟩'}; +const SPACES: Record = {',': 1, ':': 1, ';': 1, ' ': 1, quad: 2, qquad: 4, '!': 0, enspace: 1}; +const SUPERSCRIPT: Record = {'0': '⁰', '1': '¹', '2': '²', '3': '³', '4': '⁴', '5': '⁵', '6': '⁶', '7': '⁷', '8': '⁸', '9': '⁹', '+': '⁺', '−': '⁻', '=': '⁼', '(': '⁽', ')': '⁾', a: 'ᵃ', b: 'ᵇ', c: 'ᶜ', d: 'ᵈ', e: 'ᵉ', f: 'ᶠ', g: 'ᵍ', h: 'ʰ', i: 'ⁱ', j: 'ʲ', k: 'ᵏ', l: 'ˡ', m: 'ᵐ', n: 'ⁿ', o: 'ᵒ', p: 'ᵖ', r: 'ʳ', s: 'ˢ', t: 'ᵗ', u: 'ᵘ', v: 'ᵛ', w: 'ʷ', x: 'ˣ', y: 'ʸ', z: 'ᶻ', A: 'ᴬ', B: 'ᴮ', D: 'ᴰ', E: 'ᴱ', G: 'ᴳ', H: 'ᴴ', I: 'ᴵ', J: 'ᴶ', K: 'ᴷ', L: 'ᴸ', M: 'ᴹ', N: 'ᴺ', O: 'ᴼ', P: 'ᴾ', R: 'ᴿ', T: 'ᵀ', U: 'ᵁ', V: 'ⱽ', W: 'ᵂ', '′': '′', '∘': '°', '*': '*', β: 'ᵝ', γ: 'ᵞ', δ: 'ᵟ', φ: 'ᵠ', χ: 'ᵡ'}; +const SUBSCRIPT: Record = {'0': '₀', '1': '₁', '2': '₂', '3': '₃', '4': '₄', '5': '₅', '6': '₆', '7': '₇', '8': '₈', '9': '₉', '+': '₊', '−': '₋', '=': '₌', '(': '₍', ')': '₎', a: 'ₐ', e: 'ₑ', h: 'ₕ', i: 'ᵢ', j: 'ⱼ', k: 'ₖ', l: 'ₗ', m: 'ₘ', n: 'ₙ', o: 'ₒ', p: 'ₚ', r: 'ᵣ', s: 'ₛ', t: 'ₜ', u: 'ᵤ', v: 'ᵥ', x: 'ₓ', β: 'ᵦ', γ: 'ᵧ', ρ: 'ᵨ', φ: 'ᵩ', χ: 'ᵪ'}; +const MATH_ALPHABETS: Record}> = { + bf: {upper: 0x1d400, lower: 0x1d41a, digits: 0x1d7ce}, it: {upper: 0x1d434, lower: 0x1d44e, exceptions: {h: 'ℎ'}}, + cal: {upper: 0x1d49c, lower: 0x1d4b6, exceptions: {B: 'ℬ', E: 'ℰ', F: 'ℱ', H: 'ℋ', I: 'ℐ', L: 'ℒ', M: 'ℳ', R: 'ℛ', e: 'ℯ', g: 'ℊ', o: 'ℴ'}}, + frak: {upper: 0x1d504, lower: 0x1d51e, exceptions: {C: 'ℭ', H: 'ℌ', I: 'ℑ', R: 'ℜ', Z: 'ℨ'}}, + bb: {upper: 0x1d538, lower: 0x1d552, digits: 0x1d7d8, exceptions: {C: 'ℂ', H: 'ℍ', N: 'ℕ', P: 'ℙ', Q: 'ℚ', R: 'ℝ', Z: 'ℤ'}}, + sf: {upper: 0x1d5a0, lower: 0x1d5ba, digits: 0x1d7e2}, tt: {upper: 0x1d670, lower: 0x1d68a, digits: 0x1d7f6}, +}; +const FONT_COMMANDS: Record = {mathbf: 'bf', boldsymbol: 'bf', bm: 'bf', mathit: 'it', mathcal: 'cal', mathscr: 'cal', mathfrak: 'frak', mathbb: 'bb', mathsf: 'sf', mathtt: 'tt'}; +const ACCENTS: Record = {hat: '̂', widehat: '̂', bar: '̄', vec: '⃗', dot: '̇', ddot: '̈', tilde: '̃', widetilde: '̃', acute: '́', grave: '̀', breve: '̆', check: '̌', overline: '̅'}; +// Lookup tables take command names from the document: no inherited keys (`\constructor`, `\toString`) may match. +for (const table of [GREEK, PREFIX, ORDINARY, BINARY, RELATION, BIG, DELIMITERS, SPACES, SUPERSCRIPT, SUBSCRIPT, MATH_ALPHABETS, FONT_COMMANDS, ACCENTS] as object[]) Object.setPrototypeOf(table, null); + +function styled(text: string, font: string): string { + const alphabet = MATH_ALPHABETS[font]!; + let out = ''; + for (const char of text) { + if (alphabet.exceptions?.[char]) out += alphabet.exceptions[char]; + else if (/[A-Z]/u.test(char)) out += String.fromCodePoint(alphabet.upper + char.charCodeAt(0) - 65); + else if (/[a-z]/u.test(char)) out += String.fromCodePoint(alphabet.lower + char.charCodeAt(0) - 97); + else if (alphabet.digits && /[0-9]/u.test(char)) out += String.fromCodePoint(alphabet.digits + char.charCodeAt(0) - 48); + else out += char; + } + return out; +} + +// ---- Tokens and syntax tree ------------------------------------------------------------------------------------- + +type Token = ({kind: 'command'; name: string} | {kind: 'char'; char: string} | {kind: 'open'} | {kind: 'close'} | {kind: 'sup'} | {kind: 'sub'} | {kind: 'amp'} | {kind: 'row'} | {kind: 'number'; text: string}) & {spaced?: true}; + +function tokenize(source: string): Token[] { + const tokens: Token[] = []; + for (let at = 0; at < source.length;) { + const char = String.fromCodePoint(source.codePointAt(at)!); + if (/\s/u.test(char)) { if (tokens.length) tokens[tokens.length - 1]!.spaced = true; at += 1; continue; } + if (char === '\\') { + const word = /^[A-Za-z]+/u.exec(source.slice(at + 1, at + 40)); + if (word) { tokens.push({kind: 'command', name: word[0]}); at += 1 + word[0].length; continue; } + const next = source[at + 1]; + if (next === undefined) fail('a backslash at the end'); + if (next === '\\') { tokens.push({kind: 'row'}); at += 2; continue; } + tokens.push({kind: 'command', name: next!}); at += 2; continue; + } + if (char === '{') tokens.push({kind: 'open'}); + else if (char === '}') tokens.push({kind: 'close'}); + else if (char === '^') tokens.push({kind: 'sup'}); + else if (char === '_') tokens.push({kind: 'sub'}); + else if (char === '&') tokens.push({kind: 'amp'}); + else if (/[0-9]/u.test(char)) { const number = /^[0-9]+(?:\.[0-9]+)?/u.exec(source.slice(at, at + 40))!; tokens.push({kind: 'number', text: number[0]}); at += number[0].length; continue; } + else if (char === '$' || char === '~' || char === '#') fail(`“${char}” is not part of the subset`); + else tokens.push({kind: 'char', char}); + at += char.length; + } + return tokens; +} + +type Node = + | {t: 'ord'; s: string} | {t: 'bin'; s: string} | {t: 'rel'; s: string} | {t: 'func'; s: string} | {t: 'punct'; s: string} | {t: 'text'; s: string} + | {t: 'open'; s: string} | {t: 'close'; s: string} | {t: 'space'; n: number} + | {t: 'group'; items: Node[]} + | {t: 'script'; base: Node; sup?: Node; sub?: Node} + | {t: 'frac'; num: Node; den: Node; bar: boolean} + | {t: 'sqrt'; body: Node; index?: Node} + | {t: 'delim'; left: string; right: string; body: Node} + | {t: 'big'; name: string; s: string} + | {t: 'accent'; mark: string; body: string} + | {t: 'grid'; rows: Node[][]; left: string; right: string; cases?: boolean}; + +class Parser { + private at = 0; + private nodes = 0; + constructor(private readonly tokens: Token[]) {} + private count(): void { if (++this.nodes > MATH_LIMITS.nodes) fail('the formula is too large'); } + private peek(): Token | undefined { return this.tokens[this.at]; } + private next(): Token | undefined { return this.tokens[this.at++]; } + + parse(): Node { + const node = this.row(0, 'end'); + if (this.at < this.tokens.length) fail('an unmatched closing brace or command'); + return node; + } + + /** Items up to a stop token ('close' brace, '\\right', '\\end', '&', '\\\\' or the end). */ + private row(depth: number, stop: 'end' | 'brace' | 'right' | 'environment' | 'cell'): Node { + if (depth > MATH_LIMITS.depth) fail('the formula is nested too deeply'); + const items: Node[] = []; + for (;;) { + const token = this.peek(); + if (!token) { if (stop === 'brace') fail('a brace is never closed'); if (stop === 'right') fail('\\left has no \\right'); if (stop === 'environment' || stop === 'cell') fail('an environment is never closed'); break; } + if (token.kind === 'close') { if (stop === 'brace') break; fail('an unmatched closing brace'); } + if (token.kind === 'command' && (token.name === 'right') ) { if (stop === 'right') break; fail('\\right without \\left'); } + if (token.kind === 'command' && token.name === 'end') { if (stop === 'environment' || stop === 'cell') break; fail('\\end without \\begin'); } + if ((token.kind === 'amp' || token.kind === 'row')) { if (stop === 'cell') break; fail('“&” or “\\\\” outside a matrix'); } + items.push(this.scripted(depth)); + } + return {t: 'group', items}; + } + + private scripted(depth: number): Node { + let base = this.atom(depth); + let sup: Node | undefined, sub: Node | undefined; + for (;;) { + const token = this.peek(); + if (token?.kind === 'command' && (token.name === 'limits' || token.name === 'nolimits')) { this.next(); continue; } + if (token?.kind === 'sup') { if (sup) fail('two superscripts on one symbol'); this.next(); sup = this.argument(depth); } + else if (token?.kind === 'sub') { if (sub) fail('two subscripts on one symbol'); this.next(); sub = this.argument(depth); } + else break; + } + if (sup || sub) { this.count(); base = {t: 'script', base, ...(sup ? {sup} : {}), ...(sub ? {sub} : {})}; } + return base; + } + + /** `{…}` or one token. */ + private argument(depth: number): Node { + const token = this.peek(); + if (!token) return fail('a script or argument is missing'); + if (token.kind === 'open') { this.next(); const body = this.row(depth + 1, 'brace'); this.next(); return body; } + if (token.kind === 'close' || token.kind === 'sup' || token.kind === 'sub' || token.kind === 'amp' || token.kind === 'row') return fail('a script or argument is missing'); + if (token.kind === 'number') { + // As in TeX, a script or argument takes one digit: x^23 is x² followed by 3. + this.next(); + if (token.text.length > 1 && !token.text.includes('.')) this.tokens.splice(this.at, 0, {kind: 'number', text: token.text.slice(1)}); + return {t: 'ord', s: token.text.includes('.') ? token.text : token.text[0]!}; + } + return this.atom(depth); + } + + private text(depth: number, raw = true): string { + const token = this.next(); + if (token?.kind !== 'open') return fail('\\text needs a braced argument'); + let text = token.spaced ? ' ' : '', open = 1; + for (let count = 0; count < 400; count += 1) { + const part = this.next(); + if (!part) return fail('a brace is never closed'); + if (part.kind === 'open') { open += 1; if (part.spaced) text += ' '; continue; } + if (part.kind === 'close') { open -= 1; if (!open) return text; if (part.spaced) text += ' '; continue; } + if (part.kind === 'char' && /[\p{C}\p{Zl}\p{Zp}]/u.test(part.char)) return fail('control characters are not allowed in text'); + if (part.kind === 'char') text += part.char; else if (part.kind === 'number') text += part.text; else if (part.kind === 'command' && part.name === ' ') text += ' '; else return fail('only plain text is supported inside \\text'); + if (part.spaced) text += ' '; + } + void depth; void raw; + return fail('the text is too long'); + } + + private atom(depth: number): Node { + if (depth > MATH_LIMITS.depth) fail('the formula is nested too deeply'); + this.count(); + const token = this.next(); + if (!token) return fail('the formula ends too soon'); + switch (token.kind) { + case 'number': return {t: 'ord', s: token.text}; + case 'open': { const body = this.row(depth + 1, 'brace'); this.next(); return body; } + case 'char': return this.character(token.char); + case 'command': return this.command(token.name, depth); + default: return fail('unexpected symbol'); + } + } + + private character(char: string): Node { + if (char === '-') return {t: 'bin', s: '−'}; + if (char === '+' || char === '*' || char === '/') return {t: 'bin', s: char === '*' ? '∗' : char}; + if (char === '=' || char === '<' || char === '>') return {t: 'rel', s: char}; + if (char === ',' || char === ';') return {t: 'punct', s: char}; + if (char === '(' || char === '[') return {t: 'open', s: char}; + if (char === ')' || char === ']') return {t: 'close', s: char}; + if (char === '|' || char === '!' || char === '.' || char === ':' || char === '?' || char === '\'' || char === '`' || char === '@') return {t: 'ord', s: char === '\'' ? '′' : char}; + if (/[\p{L}\p{N}\p{S}\p{P}]/u.test(char)) return {t: 'ord', s: char}; + return fail(`“${char}” is not supported`); + } + + private command(name: string, depth: number): Node { + if (GREEK[name]) return {t: 'ord', s: GREEK[name]!}; + if (BINARY[name]) return {t: 'bin', s: BINARY[name]!}; + if (RELATION[name]) return {t: 'rel', s: RELATION[name]!}; + if (ORDINARY[name]) return {t: 'ord', s: ORDINARY[name]!}; + if (BIG[name]) return {t: 'big', name, s: BIG[name]!}; + if (FUNCTIONS.has(name)) return {t: 'func', s: name}; + if (PREFIX[name]) return {t: 'func', s: PREFIX[name]!}; + if (name in SPACES) return {t: 'space', n: SPACES[name]!}; + if (name === 'langle') return {t: 'open', s: '⟨'}; if (name === 'rangle') return {t: 'close', s: '⟩'}; + if (name === 'lfloor') return {t: 'open', s: '⌊'}; if (name === 'rfloor') return {t: 'close', s: '⌋'}; + if (name === 'lceil') return {t: 'open', s: '⌈'}; if (name === 'rceil') return {t: 'close', s: '⌉'}; + if (name === 'lbrace') return {t: 'open', s: '{'}; if (name === 'rbrace') return {t: 'close', s: '}'}; + if (name === 'frac' || name === 'dfrac' || name === 'tfrac' || name === 'binom' || name === 'dbinom') { + const num = this.argument(depth + 1), den = this.argument(depth + 1); + if (isEmpty(num) || isEmpty(den)) return fail('a fraction argument is empty'); + return name.endsWith('binom') ? {t: 'delim', left: '(', right: ')', body: {t: 'frac', num, den, bar: false}} : {t: 'frac', num, den, bar: true}; + } + if (name === 'sqrt') { + let index: Node | undefined; + if (this.peek()?.kind === 'char' && (this.peek() as {char: string}).char === '[') { + this.next(); + const items: Node[] = []; + for (let count = 0; ; count += 1) { + const token = this.peek(); + if (!token || count > 50) fail('the root index is not closed'); + if (token!.kind === 'char' && (token as {char: string}).char === ']') { this.next(); break; } + items.push(this.scripted(depth + 1)); + } + index = {t: 'group', items}; + } + const body = this.argument(depth + 1); + if (isEmpty(body)) return fail('a root has nothing under it'); + return {t: 'sqrt', body, ...(index ? {index} : {})}; + } + if (name === 'operatorname') return {t: 'func', s: this.text(depth)}; + if (name === 'text' || name === 'mathrm' || name === 'textrm' || name === 'mbox' || name === 'textit' || name === 'textbf') return {t: 'text', s: this.text(depth)}; + if (FONT_COMMANDS[name]) { + const body = this.argument(depth + 1); + const flat = flatText(body); + if (flat === undefined) return fail(`\\${name} supports letters and digits only`); + return {t: 'ord', s: styled(flat, FONT_COMMANDS[name]!)}; + } + if (ACCENTS[name]) { + const flat = flatText(this.argument(depth + 1)); + if (flat === undefined || [...flat].length !== 1) return fail(`\\${name} supports one character here`); + return {t: 'accent', mark: ACCENTS[name]!, body: flat}; + } + if (name === 'underline') { const flat = flatText(this.argument(depth + 1)); if (flat === undefined || [...flat].length !== 1) return fail('\\underline supports one character here'); return {t: 'accent', mark: '̲', body: flat}; } + if (name === 'left') { + const left = this.delimiter(); + const body = this.row(depth + 1, 'right'); + const closing = this.next(); + if (closing?.kind !== 'command' || closing.name !== 'right') return fail('\\left has no \\right'); + return {t: 'delim', left, right: this.delimiter(), body}; + } + if (name === 'begin') return this.environment(depth); + if (name === 'displaystyle' || name === 'textstyle' || name === 'limits' || name === 'nolimits') return {t: 'space', n: 0}; + return fail(`\\${name} is not supported`); + } + + private delimiter(): string { + const token = this.next(); + if (!token) return fail('a delimiter is missing'); + const key = token.kind === 'char' ? token.char : token.kind === 'command' ? `\\${token.name}` : token.kind === 'open' ? '{' : ''; + if (!(key in DELIMITERS)) return fail('this delimiter is not supported'); + return DELIMITERS[key]!; + } + + private environment(depth: number): Node { + const name = this.text(depth); + const matrices: Record = {matrix: ['', ''], pmatrix: ['(', ')'], bmatrix: ['[', ']'], Bmatrix: ['{', '}'], vmatrix: ['|', '|'], Vmatrix: ['‖', '‖'], cases: ['{', '']}; + if (!Object.hasOwn(matrices, name)) return fail(`the “${name}” environment is not supported`); + const rows: Node[][] = [[]]; + for (let guard = 0; guard < 400; guard += 1) { + rows.at(-1)!.push(this.row(depth + 1, 'cell')); + const token = this.next(); + if (!token) return fail('an environment is never closed'); + if (token.kind === 'amp') continue; + if (token.kind === 'row') { rows.push([]); continue; } + if (token.kind === 'command' && token.name === 'end') { + if (this.text(depth) !== name) return fail('\\end does not match \\begin'); + if (!rows.at(-1)!.length || (rows.at(-1)!.length === 1 && (rows.at(-1)![0] as {items: Node[]}).items.length === 0 && rows.length > 1)) rows.pop(); + const width = Math.max(...rows.map(row => row.length)); + if (rows.some(row => row.length !== width) && name !== 'cases') return fail('the rows of a matrix have different numbers of cells'); + if (rows.length * width > 144) return fail('the matrix is too large'); + const [left, right] = matrices[name]!; + return {t: 'grid', rows, left, right, ...(name === 'cases' ? {cases: true} : {})}; + } + return fail('unexpected symbol in an environment'); + } + return fail('the environment is too long'); + } +} + +const isEmpty = (node: Node) => node.t === 'group' && node.items.length === 0; + +/** The plain text of a node made only of letters, digits and symbols (for font and accent commands), or undefined. */ +/** A script's content written without operator spacing (x_{n+1} is `n+1`), or undefined if it holds anything but plain symbols. */ +function compact(node: Node): string | undefined { + if (node.t === 'ord' || node.t === 'punct' || node.t === 'bin' || node.t === 'rel' || node.t === 'text' || node.t === 'open' || node.t === 'close') return node.s; + if (node.t === 'group') { let out = ''; for (const item of node.items) { const part = compact(item); if (part === undefined) return undefined; out += part; } return out; } + return undefined; +} + +function flatText(node: Node): string | undefined { + if (node.t === 'ord' || node.t === 'punct') return node.s; + if (node.t === 'group') { let out = ''; for (const item of node.items) { const part = flatText(item); if (part === undefined) return undefined; out += part; } return out; } + return undefined; +} + +// ---- Boxes ------------------------------------------------------------------------------------------------------ + +interface Box {rows: string[]; width: number; baseline: number} +const text = (s: string): Box => ({rows: [s], width: displayWidth(s), baseline: 0}); +const EMPTY: Box = {rows: [''], width: 0, baseline: 0}; +const pad = (row: string, width: number, align: 'left' | 'center' | 'right' = 'left') => { + const room = Math.max(0, width - displayWidth(row)); + const before = align === 'center' ? Math.floor(room / 2) : align === 'right' ? room : 0; + return ' '.repeat(before) + row + ' '.repeat(room - before); +}; + +/** Boxes side by side with their baselines on one row. */ +function hcat(boxes: readonly Box[]): Box { + const parts = boxes.filter(box => box.width > 0 || box.rows.length > 1); + if (!parts.length) return EMPTY; + const above = Math.max(...parts.map(box => box.baseline)); + const below = Math.max(...parts.map(box => box.rows.length - 1 - box.baseline)); + const width = parts.reduce((sum, box) => sum + box.width, 0); + const rows: string[] = []; + for (let row = 0; row <= above + below; row += 1) { + rows.push(parts.map(box => { const index = row - (above - box.baseline); return index >= 0 && index < box.rows.length ? pad(box.rows[index]!, box.width) : ' '.repeat(box.width); }).join('')); + } + return {rows, width, baseline: above}; +} + +function vstack(top: Box, bottom: Box, baselineOf: 'top' | 'bottom' | number): Box { + const width = Math.max(top.width, bottom.width); + return {rows: [...top.rows.map(row => pad(row, width, 'center')), ...bottom.rows.map(row => pad(row, width, 'center'))], width, baseline: typeof baselineOf === 'number' ? baselineOf : baselineOf === 'top' ? top.baseline : top.rows.length + bottom.baseline}; +} + +interface Context {display: boolean; ascii: boolean} + +function mapScript(box: string, table: Record): string | undefined { + let out = ''; + for (const char of box) { const mapped = table[char]; if (mapped === undefined) return undefined; out += mapped; } + return out; +} + +class Layout { + constructor(private readonly context: Context) {} + private get inline() { return !this.context.display; } + + node(node: Node): Box { + const box = this.build(node); + if (box.rows.length > MATH_LIMITS.rows || box.width > MATH_LIMITS.width) return fail('the formula is too large to draw'); + return box; + } + + private build(node: Node): Box { + switch (node.t) { + case 'ord': case 'punct': return text(node.s); + case 'bin': case 'rel': return text(node.s); + case 'func': return text(node.s); + case 'text': return text(node.s); + case 'open': case 'close': return text(node.s); + case 'space': return text(' '.repeat(node.n)); + case 'group': return this.sequence(node.items); + case 'script': return this.script(node); + case 'frac': return this.frac(node); + case 'sqrt': return this.sqrt(node); + case 'delim': return this.delim(node); + case 'big': return text(node.s); + case 'accent': return text(node.body + node.mark); + case 'grid': return this.grid(node); + } + } + + /** Items in order with the spacing TeX gives operators and relations. */ + sequence(items: readonly Node[]): Box { + const boxes: Box[] = []; + let before: Node | undefined; + const isOperand = (node: Node | undefined) => Boolean(node) && !(node!.t === 'bin' || node!.t === 'rel' || node!.t === 'open' || node!.t === 'punct' || node!.t === 'space' || node!.t === 'func' || node!.t === 'big' || (node!.t === 'script' && (node!.base.t === 'func' || node!.base.t === 'big'))); + const isFunc = (node: Node) => node.t === 'func' || node.t === 'big' || (node.t === 'script' && (node.base.t === 'func' || node.base.t === 'big')); + items.forEach((item, index) => { + const next = items[index + 1]; + const operand = Boolean(before) && !(before!.t === 'bin' || before!.t === 'rel' || before!.t === 'open' || before!.t === 'punct' || before!.t === 'space' || before!.t === 'func'); + if ((item.t === 'bin' || item.t === 'rel') && (item.t === 'rel' || operand)) { + const rightOperand = Boolean(next) && next!.t !== 'close'; + boxes.push(text(item.t === 'bin' && !rightOperand ? item.s : ` ${item.s} `)); + } else if (item.t === 'punct') boxes.push(text(`${item.s} `)); + else if (isFunc(item)) { + if (isOperand(before) && before!.t !== 'close' && !(before!.t === 'ord' && /^\d$/u.test(before!.s))) boxes.push(text(' ')); + else if (before?.t === 'ord' || before?.t === 'close') boxes.push(text(' ')); + boxes.push(this.node(item)); + if (next && !(next.t === 'open' || next.t === 'close' || next.t === 'rel' || next.t === 'punct' || next.t === 'space' || (next.t === 'script' && false) || (item.t === 'big' && next.t === 'script'))) boxes.push(text(' ')); + } else if (this.inline && (item.t === 'frac' || item.t === 'sqrt') && (isOperand(before) || isOperand(next))) { + const box = this.node(item); + boxes.push(text(`(${box.rows[0]!.trim()})`)); + } else boxes.push(this.node(item)); + before = item; + }); + return hcat(boxes); + } + + private script(node: Extract): Box { + let base = this.node(node.base); + if (this.inline && (node.base.t === 'frac' || node.base.t === 'sqrt')) base = text(`(${base.rows[0]!.trim()})`); + const baseNode = node.base; + if (baseNode.t === 'big' && LIMITS_ABOVE.has(baseNode.name) && this.context.display) { + const up = node.sup ? this.node(node.sup) : undefined, down = node.sub ? this.node(node.sub) : undefined; + let box = base; + if (up) box = vstack(up, box, up.rows.length + box.baseline); + if (down) box = vstack(box, down, box.baseline); + return box; + } + const sup = node.sup ? this.node(node.sup) : undefined, sub = node.sub ? this.node(node.sub) : undefined; + const supCompact = node.sup ? compact(node.sup) : undefined, subCompact = node.sub ? compact(node.sub) : undefined; + const superText = supCompact !== undefined ? mapScript(supCompact, SUPERSCRIPT) : undefined; + const subText = subCompact !== undefined ? mapScript(subCompact, SUBSCRIPT) : undefined; + const simple = (!sup || superText !== undefined) && (!sub || subText !== undefined); + if (simple) return hcat([base, text(`${subText ?? ''}${superText ?? ''}`)]); + if (this.inline) { + if ((sup && sup.rows.length > 1) || (sub && sub.rows.length > 1)) return fail('a script needs more than one row'); + const wrap = (box: Box | undefined, plain: string | undefined, mark: string) => box ? text(`${mark}(${plain ?? box.rows[0]!.trim()})`) : EMPTY; + return hcat([base, wrap(sub, subCompact, '_'), wrap(sup, supCompact, '^')]); + } + // Two dimensions: the superscript ends on the row above the baseline, the subscript starts on the row below it. + const baseline = base.baseline; + const supTop = sup ? baseline - sup.rows.length : 0; + const subTop = baseline + 1; + const top = Math.min(0, supTop); + const bottom = Math.max(base.rows.length, sub ? subTop + sub.rows.length : 0); + const side = Math.max(sup?.width ?? 0, sub?.width ?? 0); + // Rows are composed from whole cells, never cut by string index: wide and astral characters keep their columns. + const rows: string[] = []; + for (let y = top; y < bottom; y += 1) { + const left = y >= 0 && y < base.rows.length ? pad(base.rows[y]!, base.width) : ' '.repeat(base.width); + const supRow = sup && y >= supTop && y < supTop + sup.rows.length ? sup.rows[y - supTop]! : undefined; + const subRow = sub && y >= subTop && y < subTop + sub.rows.length ? sub.rows[y - subTop]! : undefined; + rows.push(left + pad(supRow ?? subRow ?? '', side)); + } + return {rows, width: base.width + side, baseline: baseline - top}; + } + + private frac(node: Extract): Box { + const num = this.node(node.num), den = this.node(node.den); + if (this.inline) { + if (num.rows.length > 1 || den.rows.length > 1) return fail('a fraction needs more than one row'); + const wrap = (box: Box, source: Node, numerator: boolean) => { const row = box.rows[0]!.trim(); return /^[\p{L}\p{N}.]+$/u.test(row) || (numerator && source.t === 'group' && source.items.length === 1 && source.items[0]!.t === 'frac') ? row : `(${row})`; }; + if (!node.bar) return text(`${num.rows[0]!.trim()} choose ${den.rows[0]!.trim()}`); + return text(`${wrap(num, node.num, true)}/${wrap(den, node.den, false)}`); + } + if (!node.bar) return vstack(num, den, Math.floor((num.rows.length + den.rows.length - 1) / 2)); + const width = Math.max(num.width, den.width) + 2; + const bar = (this.context.ascii ? '-' : '─').repeat(width); + return {rows: [...num.rows.map(row => pad(row, width, 'center')), bar, ...den.rows.map(row => pad(row, width, 'center'))], width, baseline: num.rows.length}; + } + + private sqrt(node: Extract): Box { + const body = this.node(node.body); + const index = node.index ? this.node(node.index) : undefined; + const indexText = index && index.rows.length === 1 ? index.rows[0]!.trim() : undefined; + if (index && indexText === undefined) return fail('a root index needs more than one row'); + const radical = indexText === '3' ? '∛' : indexText === '4' ? '∜' : indexText ? `${mapScript(indexText, SUPERSCRIPT) ?? `(${indexText})`}√` : '√'; + if (this.inline || (body.rows.length === 1 && !this.context.display)) { + if (body.rows.length > 1) return fail('a root needs more than one row'); + const row = body.rows[0]!.trim(); + return text(/^[\p{L}\p{N}.]$/u.test(row) || /^[\p{L}\p{N}.]+$/u.test(row) && [...row].length === 1 ? `${radical}${row}` : `${radical}(${row})`); + } + const width = body.width; + const lead = displayWidth(radical); + const over = (this.context.ascii ? '_' : '‾').repeat(width); + const rows = [' '.repeat(lead) + over, ...body.rows.map((row, index) => (index === body.baseline ? radical : ' '.repeat(lead)) + pad(row, width))]; + return {rows, width: width + lead, baseline: body.baseline + 1}; + } + + private delim(node: Extract): Box { + const body = this.node(node.body); + return hcat([this.stretch(node.left, body.rows.length, body.baseline, true), body, this.stretch(node.right, body.rows.length, body.baseline, false)]); + } + + private stretch(char: string, height: number, baseline: number, left: boolean): Box { + if (!char) return EMPTY; + if (height <= 1) return text(char); + const ascii = this.context.ascii; + type Pieces = {top: string; mid: string; bottom: string; single?: string}; + const sets: Record = ascii ? { + '(': {top: '/', mid: '|', bottom: '\\'}, ')': {top: '\\', mid: '|', bottom: '/'}, '[': {top: '[', mid: '|', bottom: '['}, ']': {top: ']', mid: '|', bottom: ']'}, + '{': {top: '/', mid: '|', bottom: '\\', single: '<'}, '}': {top: '\\', mid: '|', bottom: '/', single: '>'}, '|': {top: '|', mid: '|', bottom: '|'}, '‖': {top: '|', mid: '|', bottom: '|'}, + '⟨': {top: '/', mid: '|', bottom: '\\'}, '⟩': {top: '\\', mid: '|', bottom: '/'}, '⌊': {top: '|', mid: '|', bottom: '|'}, '⌋': {top: '|', mid: '|', bottom: '|'}, '⌈': {top: '|', mid: '|', bottom: '|'}, '⌉': {top: '|', mid: '|', bottom: '|'}, + } : { + '(': {top: '⎛', mid: '⎜', bottom: '⎝'}, ')': {top: '⎞', mid: '⎟', bottom: '⎠'}, '[': {top: '⎡', mid: '⎢', bottom: '⎣'}, ']': {top: '⎤', mid: '⎥', bottom: '⎦'}, + '{': {top: '⎧', mid: '⎪', bottom: '⎩', single: '⎨'}, '}': {top: '⎫', mid: '⎪', bottom: '⎭', single: '⎬'}, '|': {top: '│', mid: '│', bottom: '│'}, '‖': {top: '║', mid: '║', bottom: '║'}, + '⟨': {top: '╱', mid: '│', bottom: '╲'}, '⟩': {top: '╲', mid: '│', bottom: '╱'}, '⌊': {top: '│', mid: '│', bottom: '└'}, '⌋': {top: '│', mid: '│', bottom: '┘'}, '⌈': {top: '┌', mid: '│', bottom: '│'}, '⌉': {top: '┐', mid: '│', bottom: '│'}, + }; + const pieces = Object.hasOwn(sets, char) ? sets[char] : undefined; + if (!pieces) return text(char); + void left; + const rows = Array.from({length: height}, (_, index) => index === 0 ? pieces.top : index === height - 1 ? pieces.bottom : pieces.mid); + // A brace has its point on the middle row (odd heights) or the two middle rows (even heights use the pieces' own join). + if ((char === '{' || char === '}') && height >= 3) rows[Math.floor(height / 2)] = pieces.single ?? pieces.mid; + return {rows, width: 1, baseline}; + } + + private grid(node: Extract): Box { + const cells = node.rows.map(row => row.map(cell => this.node(cell))); + const columns = Math.max(...cells.map(row => row.length)); + const widths = Array.from({length: columns}, (_, column) => Math.max(...cells.map(row => row[column]?.width ?? 0))); + const gap = 2; + const rowBoxes = cells.map(row => hcat(Array.from({length: columns}, (_, column) => { + const cell = row[column] ?? EMPTY; + const room = widths[column]! - cell.width; + const aligned: Box = node.cases ? {...cell, rows: cell.rows.map(line => pad(line, widths[column]!)), width: widths[column]!} : {...cell, rows: cell.rows.map(line => ' '.repeat(Math.floor(room / 2)) + line + ' '.repeat(room - Math.floor(room / 2))), width: widths[column]!}; + return column === columns - 1 ? aligned : hcat([aligned, text(' '.repeat(gap))]); + }))); + const rows: string[] = []; + let width = 0; + for (const box of rowBoxes) { width = Math.max(width, box.width); } + rowBoxes.forEach(box => box.rows.forEach(line => rows.push(pad(line, width)))); + const baseline = Math.floor((rows.length - 1) / 2); + const body: Box = {rows, width, baseline}; + return hcat([this.stretch(node.left, rows.length, baseline, true), body, this.stretch(node.right, rows.length, baseline, false)]); + } +} + +/** Render `source` (the inside of `$…$` or `$$…$$`) or say why it cannot be. */ +export function renderMath(source: string, options: {display: boolean; ascii?: boolean}): MathResult { + const trimmed = source.trim(); + if (!trimmed) return {ok: false, reason: 'the formula is empty'}; + if (trimmed.length > MATH_LIMITS.source) return {ok: false, reason: `the formula is longer than ${MATH_LIMITS.source} characters`}; + try { + const tree = new Parser(tokenize(trimmed)).parse(); + const box = new Layout({display: options.display, ascii: options.ascii ?? false}).node(tree); + if (!options.display && box.rows.length > 1) return {ok: false, reason: 'it needs more than one row; use $$…$$'}; + if (box.rows.length > MATH_LIMITS.rows || box.width > MATH_LIMITS.width) return {ok: false, reason: 'the formula is too large to draw'}; + const rows = box.rows.map(row => pad(row, box.width)); + return {ok: true, rows, baseline: box.baseline, width: box.width}; + } catch (error) { + if (error instanceof Unsupported) return {ok: false, reason: error.message}; + return {ok: false, reason: 'the formula could not be drawn'}; + } +} diff --git a/src/markdown/render.ts b/src/markdown/render.ts index 7a02d40d..8c6bd08a 100644 --- a/src/markdown/render.ts +++ b/src/markdown/render.ts @@ -1,3 +1,4 @@ +import {renderMath} from './math.js'; import {colorEscape} from '../chroma/escape.js'; import {colorLevel, type ColorLevel} from '../presentation/capabilities.js'; import {getCurrentGlyphMode} from '../ui/glyphs.js'; @@ -76,6 +77,8 @@ export interface MarkdownOptions { tone?: 'primary' | 'secondary'; /** Render HTML inside the Markdown (`/view`, QA F01); otherwise tags are shown as text. */ html?: boolean; + /** Read `$…$` and `$$…$$` (and ```math fences) as mathematics: documents opened in the viewer, not agent replies or GitHub bodies. */ + math?: boolean; /** Real images: an image alone in its paragraph is drawn as rows when the provider can show it; otherwise `[image: alt]`. */ images?: ImageProvider; /** The most rows one image may take. */ @@ -106,10 +109,16 @@ interface Context { maxImageRows: number; sizedHeadings?: {maxRows: number}; disclosure?: {isOpen(key: string, defaultOpen: boolean): boolean}; + math?: boolean; detailsSeen: Map; blockIds: {next: number}; } +/** Inline options: HTML tags when the document is HTML, mathematics when the caller asked for it (documents, not agent replies). */ +function inlineOptions(context: Context): {html: boolean; math?: {ascii: boolean}} { + return {html: context.html, ...(context.math ? {math: {ascii: context.safe}} : {})}; +} + function glyph(context: Context, nerd: string, safe: string): string { return context.safe ? safe : nerd; } @@ -245,7 +254,7 @@ function wrapAtoms(atoms: readonly Atom[], width: number, first: string, rest: s function inlineRows(source: string, width: number, context: Context, base: string, first = '', rest = first): Array<{ansi: string; plain: string}> { // A hard break inside a paragraph is a new row with the same hanging indent. - return source.split('\n').flatMap((line, index) => wrapAtoms(atomsOf(parseInline(line, {html: context.html}), context, base), width, index === 0 ? first : rest, rest)); + return source.split('\n').flatMap((line, index) => wrapAtoms(atomsOf(parseInline(line, inlineOptions(context)), context, base), width, index === 0 ? first : rest, rest)); } // ---- Code --------------------------------------------------------------------------------------------------------- @@ -321,11 +330,11 @@ function alignCell(text: string, width: number, align: Alignment): string { function tableRows(block: Extract, width: number, context: Context): MarkdownRow[] { const columns = block.header.length; const all = [block.header, ...block.rows]; - const natural = Array.from({length: columns}, (_, column) => Math.max(1, ...all.map(row => displayWidth(plainInline(row[column] ?? '', {html: context.html}))))); + const natural = Array.from({length: columns}, (_, column) => Math.max(1, ...all.map(row => displayWidth(plainInline(row[column] ?? '', inlineOptions(context)))))); const separator = glyph(context, ' │ ', ' | '); const chrome = 3 * (columns - 1); const available = width - chrome; - const minimum = Array.from({length: columns}, (_, column) => Math.min(natural[column]!, Math.max(3, Math.min(10, ...[block.header[column] ?? ''].map(cell => displayWidth(plainInline(cell, {html: context.html}))))))); + const minimum = Array.from({length: columns}, (_, column) => Math.min(natural[column]!, Math.max(3, Math.min(10, ...[block.header[column] ?? ''].map(cell => displayWidth(plainInline(cell, inlineOptions(context)))))))); if (available < minimum.reduce((a, b) => a + b, 0)) { // Too narrow for a grid: each row becomes a short record of "Header: value" lines. const rows: MarkdownRow[] = []; @@ -333,7 +342,7 @@ function tableRows(block: Extract, width: number, contex block.rows.forEach((row, index) => { if (index) rows.push({ansi: '', plain: '', kind: 'blank'}); row.forEach((cell, column) => { - const label = plainInline(block.header[column] ?? '', {html: context.html}); + const label = plainInline(block.header[column] ?? '', inlineOptions(context)); const lead = `${subtle}${label}:${RESET} `; for (const line of inlineRows(cell || '—', width, context, base, lead, ' ')) rows.push({...line, kind: 'table'}); }); @@ -379,7 +388,7 @@ function tableRows(block: Extract, width: number, contex */ function sizedHeading(block: Extract, width: number, context: Context, sgr: string, ordinary: MarkdownRow[]): MarkdownRow[] | undefined { if (!context.sizedHeadings || ordinary.length !== 1) return undefined; - const spans = parseInline(block.text, {html: context.html}); + const spans = parseInline(block.text, inlineOptions(context)); const segments = spans.filter(span => span.text).map(span => ({text: span.text.replace(/\s+/gu, ' '), sgr: spanSgr(span.style, context, sgr)})); const text = segments.map(segment => segment.text).join('').trim(); if (!text || segments.length > 24) return undefined; @@ -400,7 +409,7 @@ function sizedHeading(block: Extract, width: number, c function imageParagraph(source: string, width: number, context: Context): MarkdownRow[] | undefined { const provider = context.images; if (!provider || width < 8) return undefined; - const spans = parseInline(source, {html: context.html}); + const spans = parseInline(source, inlineOptions(context)); const images = spans.filter(span => span.style.image && span.style.src); if (!images.length || spans.some(span => !(span.style.image && span.style.src) && span.text.trim())) return undefined; const placed: Array<{span: InlineSpan; slot: ImageSlot; shown: {lines: string[]; columns: number; rows: number}}> = []; @@ -418,6 +427,45 @@ function imageParagraph(source: string, width: number, context: Context): Markdo return rows; } +/** + * Display mathematics: drawn when the subset covers it and it fits the width, otherwise the source exactly as written, dimmed, + * with the reason on a last row. Re-evaluated on every render, so a wider window can draw what a narrow one could not. + */ +function mathRows(source: string, width: number, context: Context, raw?: readonly string[]): MarkdownRow[] { + const result = renderMath(source, {display: true, ascii: context.safe}); + if (result.ok && result.width <= width - 2) return result.rows.map(row => ({ansi: ` ${row}${RESET}`, plain: ` ${row}`.trimEnd(), kind: 'text' as const})); + const reason = result.ok ? `it needs ${result.width + 2} columns and the window has ${width}` : result.reason; + const dim = context.fg(UI_COLORS.subtle); + const lines = raw ?? ['$$', ...source.split('\n'), '$$']; + const rows: MarkdownRow[] = []; + for (const line of lines) for (const piece of wrapPlain(line, Math.max(1, width - 2))) rows.push({ansi: `${dim} ${piece}${RESET}`, plain: ` ${piece}`, kind: 'text'}); + for (const piece of wrapWords(`(not rendered: ${reason})`, Math.max(1, width - 2))) rows.push({ansi: `${dim} ${piece}${RESET}`, plain: ` ${piece}`, kind: 'text'}); + return rows; +} + +/** Greedy word wrap for a sentence; a word wider than the line is cut. */ +function wrapWords(text: string, width: number): string[] { + const out: string[] = []; + let current = ''; + for (const word of text.split(' ')) { + if (current && displayWidth(`${current} ${word}`) > width) { out.push(current); current = ''; } + current = current ? `${current} ${word}` : word; + } + out.push(current); + return out.flatMap(line => displayWidth(line) > width ? wrapPlain(line, width) : [line]); +} + +function wrapPlain(text: string, width: number): string[] { + const out: string[] = []; + let current = ''; + for (const char of text) { + if (displayWidth(current + char) > width) { out.push(current); current = ''; } + current += char; + } + out.push(current); + return out; +} + // ---- Blocks ------------------------------------------------------------------------------------------------------- const BULLETS = {nerd: ['•', '◦', '▪'], safe: ['-', '*', '+']}; @@ -441,7 +489,21 @@ function renderBlocks(blocks: readonly Block[], width: number, context: Context, break; } case 'code': - out.push(...codeRows(block, width, context)); + if (context.math && block.language === 'math') { + const source = block.lines.join('\n'); + const result = renderMath(source, {display: true, ascii: context.safe}); + if (result.ok && result.width <= width - 2) out.push(...mathRows(source, width, context)); + else { + // The fence stays a code block as written; only the reason is added. + out.push(...codeRows(block, width, context)); + const reason = result.ok ? `it needs ${result.width + 2} columns and the window has ${width}` : result.reason; + const dim = context.fg(UI_COLORS.subtle); + for (const piece of wrapWords(`(not rendered: ${reason})`, Math.max(1, width - 2))) out.push({ansi: `${dim} ${piece}${RESET}`, plain: ` ${piece}`, kind: 'text'}); + } + } else out.push(...codeRows(block, width, context)); + break; + case 'math': + out.push(...mathRows(block.source, width, context, block.raw)); break; case 'details': { const nth = context.detailsSeen.get(block.summary) ?? 0; @@ -503,7 +565,7 @@ function renderBlocks(blocks: readonly Block[], width: number, context: Context, /** Rows for untrusted Markdown at `columns` cells. */ export function renderMarkdown(source: string, columns: number, options: MarkdownOptions = {}): MarkdownRow[] { - return renderSource(source, columns, options, safe => parseBlocks(safe, {html: Boolean(options.html)})); + return renderSource(source, columns, options, safe => parseBlocks(safe, {html: Boolean(options.html), math: Boolean(options.math)})); } /** Rows for an untrusted HTML document at `columns` cells (`/view`, QA F01): its supported subset, never run or loaded. */ @@ -527,6 +589,7 @@ function renderSource(source: string, columns: number, options: MarkdownOptions, maxImageRows: Math.max(1, options.maxImageRows ?? 16), ...(options.sizedHeadings ? {sizedHeadings: options.sizedHeadings} : {}), ...(options.disclosure ? {disclosure: options.disclosure} : {}), + ...(options.math ? {math: true} : {}), detailsSeen: new Map(), blockIds: {next: 1}, }; diff --git a/src/markdown/sourceMap.ts b/src/markdown/sourceMap.ts index 3adfb84e..7c438a97 100644 --- a/src/markdown/sourceMap.ts +++ b/src/markdown/sourceMap.ts @@ -90,12 +90,12 @@ function outline(blocks: readonly Block[], lines: readonly string[]): HeadingEnt /** The headings of a Markdown source, in document order. */ export function headingsOf(source: string, options: {html?: boolean} = {}): HeadingEntry[] { const lines = source.replace(/\r\n?/gu, '\n').split('\n'); - return outline(parseBlocks(source, {html: options.html ?? false}), lines); + return outline(parseBlocks(source, {html: options.html ?? false, math: true}), lines); } /** The headings of lines already cut (a buffer's), so a stray CR cannot shift a line number. */ export function headingsFromLines(lines: readonly string[], options: {html?: boolean} = {}): HeadingEntry[] { - return outline(parseBlockLines(lines, {html: options.html ?? false}), lines); + return outline(parseBlockLines(lines, {html: options.html ?? false, math: true}), lines); } /** The heading a `#fragment` names (case-insensitive, percent-decoded), if any. */ @@ -155,7 +155,7 @@ export class DocumentIndex { if (this.cached?.version === this.versionCounter) return this.cached; this.parseCount += 1; this.options.onParse?.(); - const blocks = parseBlockLines(this.buffer.lines, {html: this.options.html ?? false}); + const blocks = parseBlockLines(this.buffer.lines, {html: this.options.html ?? false, math: true}); this.cached = {version: this.versionCounter, blocks, headings: outline(blocks, this.buffer.lines)}; return this.cached; } diff --git a/src/viewer/HybridView.ts b/src/viewer/HybridView.ts index 5c9914df..0d13c3ad 100644 --- a/src/viewer/HybridView.ts +++ b/src/viewer/HybridView.ts @@ -150,7 +150,7 @@ function renderedOf(editor: EditorState, hybrid: HybridState, layout: Layout, in let rows = hybrid.cache.get(key); if (!rows) { hybrid.stats.renders += 1; - const rendered = renderMarkdown(text, layout.width, {...(layout.look.syntax ? {syntax: layout.look.syntax} : {}), hyperlinks: Boolean(layout.look.hyperlinks), html: true}); + const rendered = renderMarkdown(text, layout.width, {...(layout.look.syntax ? {syntax: layout.look.syntax} : {}), hyperlinks: Boolean(layout.look.hyperlinks), html: true, math: true}); rows = rendered.map(row => row.ansi); if (text.length <= MAX_CACHED_TEXT) { hybrid.cache.set(key, rows); diff --git a/src/viewer/ViewerPanel.ts b/src/viewer/ViewerPanel.ts index c0cedd54..c658bf65 100644 --- a/src/viewer/ViewerPanel.ts +++ b/src/viewer/ViewerPanel.ts @@ -173,7 +173,7 @@ function markdownRows(state: ViewerState, width: number, look: ViewerLook): Mark const version = `${look.images?.version ?? 0}|${look.sized ? 'sized' : 'plain'}|${[...(state.disclosure ?? [])].map(([key, open]) => `${key}=${open}`).join(',')}`; if (state.markdown?.width !== width || state.markdown.version !== version) { const options = { - ...(look.syntax ? {syntax: look.syntax} : {}), hyperlinks: Boolean(look.hyperlinks), + ...(look.syntax ? {syntax: look.syntax} : {}), hyperlinks: Boolean(look.hyperlinks), math: true, ...(look.images ? {images: look.images} : {}), ...(look.sized ? {sizedHeadings: {maxRows: 3}} : {}), ...(state.disclosure?.size ? {disclosure: {isOpen: (key: string, open: boolean) => state.disclosure!.get(key) ?? open}} : {}), diff --git a/tests/math.test.ts b/tests/math.test.ts new file mode 100644 index 00000000..9bebdf98 --- /dev/null +++ b/tests/math.test.ts @@ -0,0 +1,231 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {renderMath, MATH_LIMITS} from '../src/markdown/math.js'; +import {parseBlocks} from '../src/markdown/blocks.js'; +import {headingsOf} from '../src/markdown/sourceMap.js'; +import {renderMarkdown} from '../src/markdown/render.js'; +import {displayWidth} from '../src/util/text.js'; + +const inline = (source: string) => { const result = renderMath(source, {display: false}); return result.ok ? result.rows.join('\n') : `FAIL: ${result.reason}`; }; +const display = (source: string, ascii = false) => { const result = renderMath(source, {display: true, ascii}); return result.ok ? result.rows.map(row => row.replace(/\s+$/u, '')).join('\n') : `FAIL: ${result.reason}`; }; + +test('inline: symbols, Greek letters, operators and spacing', () => { + assert.equal(inline('\\alpha + \\beta = \\gamma'), 'α + β = γ'); + assert.equal(inline('x \\leq y \\neq z \\approx w'), 'x ≤ y ≠ z ≈ w'); + assert.equal(inline('a \\times b \\cdot c \\pm d'), 'a × b · c ± d'); + assert.equal(inline('-x + 1'), '−x + 1', 'a leading minus is unary: no space after it'); + assert.equal(inline('f(x, y)'), 'f(x, y)'); + assert.equal(inline('\\forall x \\in \\mathbb{R}, \\exists y'), '∀ x ∈ ℝ, ∃ y'); + assert.equal(inline('\\infty \\partial \\nabla \\emptyset'), '∞∂∇∅'); + assert.equal(inline('A \\to B \\Rightarrow C'), 'A → B ⇒ C'); +}); + +test('inline: scripts use real Unicode super/subscripts only when every character has one; otherwise an unambiguous bracketed form', () => { + assert.equal(inline('x^2 + y_1'), 'x² + y₁'); + assert.equal(inline('e^{i\\pi}'), 'e^(iπ)', 'π has no superscript form: the exponent is bracketed, never guessed'); + assert.equal(inline('x_{n+1}^{2}'), 'xₙ₊₁²'); + assert.equal(inline('a_{ij}'), 'aᵢⱼ'); + assert.equal(inline('2^{10}'), '2¹⁰'); + assert.equal(inline('x^{\\alpha}'), 'x^(α)'); +}); + +test('inline: fractions, roots and operators stay one row and are unambiguous', () => { + assert.equal(inline('\\frac{1}{2}'), '1/2'); + assert.equal(inline('\\frac{a+b}{c}'), '(a + b)/c'); + assert.equal(inline('\\sqrt{x}'), '√x'); + assert.equal(inline('\\sqrt{x+1}'), '√(x + 1)'); + assert.equal(inline('\\sqrt[3]{x}'), '∛x'); + assert.equal(inline('\\sqrt[5]{x}'), '⁵√x'); + assert.equal(inline('\\sin x + \\log_2 n'), 'sin x + log₂ n'); + assert.equal(inline('\\sum_{i=1}^{n} i'), '∑ᵢ₌₁ⁿ i'); + assert.equal(inline('\\binom{n}{k}'), '(n choose k)'); + assert.equal(inline('\\text{if } x'), 'if x'); + assert.equal(inline('\\mathbf{v} \\cdot \\vec{w}'), '𝐯 · w⃗'); +}); + +test('display: a stacked fraction is centred over its bar; the baseline is the bar', () => { + assert.equal(display('\\frac{a+b}{c}'), [' a + b', '───────', ' c'].join('\n')); + assert.equal(display('x = \\frac{-b \\pm \\sqrt{b^2 - 4ac}}{2a}').split('\n').length >= 3, true); + const rows = display('y = \\frac{1}{2}').split('\n'); + assert.equal(rows.length, 3); + assert.match(rows[1]!, /^y = ─+$/u, 'the equals sign sits on the bar row'); +}); + +test('display: scripts that cannot be Unicode are placed in two dimensions', () => { + const rows = display('x^{\\frac{1}{2}}').split('\n'); + assert.ok(rows.length >= 4, rows.join('|')); + assert.ok(rows.some(row => /─/u.test(row))); + assert.match(display('a_{\\frac{1}{2}}'), /a/u); +}); + +test('display: big operators carry their limits above and below; an integral has its limits at the side', () => { + const sum = display('\\sum_{i=1}^{n} i^2').split('\n'); + assert.equal(sum.length, 3); + assert.match(sum[0]!, /n/u); assert.match(sum[1]!, /∑\s+i²/u); assert.match(sum[2]!, /i = 1/u); + const integral = display('\\int_0^1 x \\, dx'); + assert.match(integral, /∫/u); assert.match(integral, /dx/u); +}); + +test('display: roots, stretched delimiters and matrices', () => { + const root = display('\\sqrt{\\frac{a}{b}}').split('\n'); + assert.ok(root.length >= 4); + assert.ok(root.some(row => row.includes('√'))); + const paren = display('\\left( \\frac{a}{b} \\right)').split('\n'); + assert.equal(paren.length, 3); + assert.ok(paren[0]!.startsWith('⎛') && paren[1]!.startsWith('⎜') && paren[2]!.startsWith('⎝')); + const matrix = display('\\begin{pmatrix} a & b \\\\ c & d \\end{pmatrix}').split('\n'); + assert.equal(matrix.length, 2); + assert.ok(matrix[0]!.includes('a') && matrix[0]!.includes('b') && matrix[1]!.includes('c') && matrix[1]!.includes('d')); + assert.ok(matrix[0]!.startsWith('⎛') && matrix[1]!.startsWith('⎝')); + const cases = display('f(x) = \\begin{cases} 1 & x > 0 \\\\ 0 & x \\leq 0 \\end{cases}'); + assert.match(cases, /⎧|⎨|⎩/u); +}); + +test('ASCII mode (Safe Glyphs) draws bars and brackets with plain characters and keeps the letters', () => { + const rows = display('\\left( \\frac{a}{b} \\right)', true).split('\n'); + assert.ok(rows.every(row => !/[─⎛⎜⎝⎞⎟⎠]/u.test(row)), rows.join('|')); + assert.match(rows.join('\n'), /-/u); +}); + +test('what is not in the subset fails with a reason and never draws something else', () => { + for (const source of ['\\unknowncommand x', '\\begin{align} a &= b \\end{align}', '\\frac{1}', 'x^', '{', '}', '\\left( x', '\\href{http://x}{y}', '\\input{file}', '\\write18{ls}', '\\def\\x{1}', '\\verb|x|', '\\begin{pmatrix} a & b \\\\ c \\end{matrix}']) { + const result = renderMath(source, {display: true}); + assert.equal(result.ok, false, source); + assert.match((result as {reason: string}).reason, /\S/u); + } + assert.equal(renderMath('x^2^3', {display: true}).ok, false, 'a double superscript is an error in TeX too'); + assert.equal(renderMath('', {display: true}).ok, false); +}); + +test('inline refuses what needs more than one row, with the reason', () => { + const result = renderMath('x^{\\frac{1}{2}} + \\begin{pmatrix} 1 \\\\ 2 \\end{pmatrix}', {display: false}); + assert.equal(result.ok, false); +}); + +test('limits are deterministic: oversize or deeply nested input is refused quickly', () => { + assert.equal(renderMath('x'.repeat(MATH_LIMITS.source + 1), {display: true}).ok, false); + assert.equal(renderMath('{'.repeat(200) + 'x' + '}'.repeat(200), {display: true}).ok, false, 'depth'); + assert.equal(renderMath(Array.from({length: 2000}, () => '\\frac{1}{2}').join('+'), {display: true}).ok, false, 'node count'); + const tall = Array.from({length: 60}, () => '\\frac{1}{').join('') + '2' + '}'.repeat(60); + assert.equal(renderMath(tall, {display: true}).ok, false, 'height or depth'); + const started = process.hrtime.bigint(); + for (let index = 0; index < 200; index += 1) renderMath('\\sum_{i=1}^{n} \\frac{a_i}{b_i}', {display: true}); + assert.ok(Number(process.hrtime.bigint() - started) / 1e9 < 2); +}); + +test('every row of every supported construct has a measured width equal to its box and uses only terminal-safe text', () => { + for (const source of ['\\frac{a}{b}', '\\sqrt{x+1}', '\\sum_{i=1}^n i', '\\left[ \\frac{a}{b} \\right]', '\\begin{bmatrix} 1 & 2 \\\\ 3 & 4 \\end{bmatrix}', '日本語 + \\alpha', '\\text{😀 ok}']) { + const result = renderMath(source, {display: true}); + assert.equal(result.ok, true, source); + if (!result.ok) continue; + assert.ok(result.rows.every(row => displayWidth(row) === result.width), `${source}: ${JSON.stringify(result.rows)}`); + assert.ok(result.rows.every(row => !/[\u0000-\u001f\u007f-\u009f]/u.test(row))); + } +}); + +// ---- Markdown --------------------------------------------------------------------------------------------------- + +const rowsOf = (source: string, columns = 60) => renderMarkdown(source, columns, {level: 'none', math: true, html: true} as never).map(row => row.plain); + +test('display math blocks: $$ on one line, $$ on its own lines, and a math fence all become a block with an exact source span', () => { + const blocks = parseBlocks(['Text', '', '$$ \\frac{a}{b} $$', '', '$$', 'x^2 +', 'y', '$$', '', '```math', '\\alpha', '```'].join('\n'), {math: true}); + assert.deepEqual(blocks.map(block => block.kind), ['paragraph', 'math', 'math', 'code']); + assert.deepEqual([blocks[1]!.from, blocks[1]!.to, blocks[2]!.from, blocks[2]!.to], [2, 3, 4, 8]); + const without = parseBlocks('$$ x $$', {}); + assert.equal(without[0]!.kind, 'paragraph', 'without the math option nothing changes (agent replies, GitHub bodies)'); + const rendered = rowsOf(['$$ \\frac{a}{b} $$', '', '```math', '\\alpha + \\beta', '```'].join('\n')).join('\n'); + assert.match(rendered, /─/u); assert.match(rendered, /α \+ β/u); +}); + +test('inline $…$ follows the GitHub rules: no space inside, not before a digit, escapes, code spans; failures stay as written', () => { + const text = (source: string) => rowsOf(source, 80).join(' '); + assert.match(text('Euler: $e^{i\\pi} = -1$ is nice.'), /e\^\(iπ\) = −1 is nice\./u); + assert.match(text('Costs $5 and $6 today.'), /Costs \$5 and \$6 today\./u, 'prices are not math'); + assert.match(text('A \\$ sign and $x^2$.'), /A \$ sign and x²\./u); + assert.match(text('Code `$x^2$` stays code.'), /Code `\$x\^2\$` stays code\./u); + assert.match(text('Unsupported $\\unknown{x}$ stays.'), /Unsupported \$\\unknown\{x\}\$ stays\./u, 'unsupported inline math is shown as written'); + assert.match(text('Spaces $ x $ stay.'), /\$ x \$/u); +}); + +test('an unsupported display block shows its source and why, in readable form; a block too wide for the terminal does too', () => { + const bad = rowsOf('$$\n\\begin{align} a &= b \\end{align}\n$$').join('\n'); + assert.match(bad, /align/u); assert.match(bad, /not rendered/iu); + const wide = rowsOf('$$' + Array.from({length: 40}, (_, index) => `x_{${index}}`).join(' + ') + '$$', 30).join('\n'); + assert.match(wide, /not rendered/iu); + for (const width of [10, 30, 80]) for (const row of renderMarkdown('$$\\frac{a}{b}$$\n\n$x^2$', width, {level: 'none', math: true} as never)) assert.ok(displayWidth(row.plain) <= width, `${width}: ${row.plain}`); +}); + +test('command names are never looked up on Object.prototype', () => { + for (const name of ['constructor', 'toString', '__proto__', 'hasOwnProperty', 'valueOf']) { + for (const display of [false, true]) { + const result = renderMath(`\\${name} x`, {display}); + assert.equal(result.ok, false, name); + } + assert.equal(renderMath(`\\begin{${name}} a \\end{${name}}`, {display: true}).ok, false, name); + assert.equal(renderMath(`\\left${name} x \\right.`, {display: true}).ok, false, name); + } +}); + +// ---- Findings of the independent review ---------------------------------------------------------------------------- + +test('review: unbraced nesting is refused fast and every intermediate box is bounded', () => { + for (const source of ['\\sqrt'.repeat(780) + ' x', '\\frac1'.repeat(390) + '1']) { + const started = process.hrtime.bigint(); + const result = renderMath(source, {display: true}); + assert.equal(result.ok, false); + assert.ok(Number(process.hrtime.bigint() - started) / 1e6 < 200); + } +}); + +test('review: inline fractions and roots are bracketed against their neighbours, scripts and denominators', () => { + assert.equal(inline('2\\frac{1}{2}'), '2(1/2)'); + assert.equal(inline('\\frac{a}{b}\\frac{c}{d}'), '(a/b)(c/d)'); + assert.equal(inline('\\frac{1}{2}x'), '(1/2)x'); + assert.equal(inline('x = \\frac{1}{2}'), 'x = 1/2'); + assert.equal(inline('\\frac{a}{\\frac{b}{c}}'), 'a/(b/c)'); + assert.equal(inline('\\frac{a}{b}^2'), '(a/b)²'); + assert.equal(inline('\\sqrt{x}^2'), '(√x)²'); + assert.equal(inline('\\sqrt{2}3'), '(√2)3'); +}); + +test('review: two-dimensional scripts keep their columns for wide and astral bases', () => { + for (const source of ['\\left(\\frac{\\mathbf{ab}}{c}\\right)^{\\alpha}', '\\frac{中中}{b}^{\\alpha}', '\\{a_{\\mathbf{b}}\\}_{c}', '\\begin{pmatrix}\\mathbf{A}&b\\\\c&d\\\\e&f\\end{pmatrix}^{\\alpha}_{\\beta}']) { + const result = renderMath(source, {display: true}); + assert.equal(result.ok, true, source); + if (!result.ok) continue; + assert.ok(result.rows.every(row => displayWidth(row) === result.width), `${source}: ${JSON.stringify(result.rows)}`); + assert.ok(result.rows.every(row => !/[\ud800-\udfff]/u.test(row.replace(/[\ud800-\udbff][\udc00-\udfff]/gu, ''))), source); + } + const rows = (renderMath('\\left(\\frac{a}{b}\\right)^{n}', {display: true}) as {rows: string[]}).rows; + assert.ok(rows.some(row => row.endsWith('⎟ⁿ')), JSON.stringify(rows)); +}); + +test('review: \\limits is accepted, functions are spaced from what precedes them, and empty arguments are refused', () => { + assert.match((renderMath('\\sum\\limits_{i=1}^{n} i', {display: true}) as {rows: string[]}).rows.join('\n'), /i = 1/u); + assert.equal(inline('a\\sin x'), 'a sin x'); + assert.equal(inline('a \\mod b'), 'a mod b'); + assert.equal(inline('\\operatorname{sgn}x'), 'sgn x'); + assert.equal(inline('\\sin -x'), 'sin −x'); + assert.equal(renderMath('\\sqrt{}', {display: false}).ok, false); + assert.equal(renderMath('\\frac{}{}', {display: false}).ok, false); + assert.equal(renderMath('\\text{a\u001b[31mb}', {display: false}).ok, false); +}); + +test('review: fallback shows the source exactly, $$ pairs on one line are separate inline formulas, $$ interrupts a paragraph', () => { + const rows = rowsOf('$$x^{\\foo}$$'); + assert.equal(rows[0]!.trim(), '$$x^{\\foo}$$'); + assert.match(rows.join('\n'), /not rendered: /u); + const indented = rowsOf('$$\n a + \\foo\n$$'); + assert.ok(indented.some(row => row.startsWith(' a + \\foo')), JSON.stringify(indented)); + const fenced = rowsOf('```math\nx^{\\foo}\n```'); + assert.ok(fenced.join('\n').includes('x^{\\foo}') && /not rendered/u.test(fenced.join('\n'))); + assert.ok(!rowsOf('$$x$$ and $$y$$').join('').includes('x$$ and $$y')); + const paragraph = rowsOf('Text\n$$\nx^2\n$$'); + assert.ok(!paragraph.join('\n').includes('$$'), JSON.stringify(paragraph)); + assert.ok(rowsOf('$$a$$ and text\nthen x$$').join('\n').includes('and text')); +}); + +test('review: a code span binds tighter than a formula, and the outline ignores headings inside math', () => { + assert.ok(rowsOf('$a `b$` c').join('').includes('`b$`')); + assert.deepEqual(headingsOf('$$\n# Not a heading\n$$\n\n# Real'), headingsOf('$$\n# Not a heading\n$$\n\n# Real').filter(heading => heading.text === 'Real')); +});