Merge pull request #158 from levineuwirth/inline-math-slice
Inline math: the first vertical slice (detect → parse → layout → draw)
This commit is contained in:
commit
5aa9044686
|
|
@ -2630,6 +2630,7 @@ dependencies = [
|
|||
"pollster",
|
||||
"sys-locale",
|
||||
"tempfile",
|
||||
"ttf-parser",
|
||||
"unicode-width",
|
||||
"wgpu",
|
||||
"winit",
|
||||
|
|
|
|||
|
|
@ -55,6 +55,134 @@ git status --short --branch
|
|||
The `git log` command must expose `8c86d34` or a newer intentional main.
|
||||
If it does not, stop and repair the remote/fetch configuration.
|
||||
|
||||
## Inline-math slice lane — PR #158 OPEN, main integrated
|
||||
|
||||
- Portable branch: `githubsucks/inline-math-slice`; worktree
|
||||
`../pmacs-math-slice`. **PR #158**, base `main`.
|
||||
- **Canonical `main` merged into the lane three times on 2026-07-25**,
|
||||
every time merged rather than rebased, per the #135/#137 precedent: the
|
||||
PR is awaiting review rounds and a rebase would break every review
|
||||
anchor.
|
||||
- First at `8c86d34` (28 commits behind). The conflict was
|
||||
**pre-existing**, not introduced by the dired (#164) or Lean 4
|
||||
ledger commits; it already conflicted against `main` @ `e745068`.
|
||||
- Then at `46a1b8f`, after Lean 4 Stage 2 (#161) landed while this
|
||||
branch's CI was still running. Same single conflict, same shape,
|
||||
same resolution.
|
||||
- Then at `b889873`, after the GPU terminal input fix (#166) landed.
|
||||
**No conflict at all this time** — and that is exactly why it still
|
||||
needed a real integration, see below.
|
||||
- **The first two conflicts were this ledger and nothing else** — both
|
||||
sides' lanes kept verbatim each time. That is the standing cost of a
|
||||
long-lived PR here: every merge to `main` edits this file, so a branch
|
||||
awaiting review re-conflicts on it and only on it. It is a docs
|
||||
collision, never a code one, and it says nothing about integration
|
||||
risk — do not read a `CONFLICTING` badge on this PR as a code signal
|
||||
without checking which file `git merge-tree` names.
|
||||
- **The inverse trap matters more, and #166 is the case in point: a
|
||||
CLEAN `git merge-tree` is not a reason to skip integrating.** #166
|
||||
landed 41 lines in `pmacs-gpu/src/main.rs`, the same heavily-rewritten
|
||||
file as the first integration, and git merged it without a murmur
|
||||
because the two edits sit in different regions (#166 is entirely in the
|
||||
headless probe — `PMACS_GPU_PROBE_OBSERVE_MS`, `PROBE_INPUT_CHAR`,
|
||||
`input_echo_observed` — while this lane rewrites the render path).
|
||||
Merging the PR on the strength of that clean auto-merge would have
|
||||
shipped a combination no gate had ever run. **Decide whether to
|
||||
integrate from the shared-FILE set, not from whether git complained.**
|
||||
- **First integration's surface** (derived from `git diff
|
||||
<merge-base>..main`, not from another PR's file list):
|
||||
`pmacs-gpu/src/main.rs` gained 72 lines on main from `e547a90` — the
|
||||
minimap all-blank-slab divide-by-zero fix — and this lane rewrites
|
||||
large parts of the same file. Git auto-merged it **textually**; a
|
||||
clean auto-merge is not evidence the tree compiles (the folding-arc
|
||||
lesson), so the full gate suite below is what actually discharges it.
|
||||
- **Second integration's surface is code-disjoint.** #161 touched
|
||||
`COHERENCE.md`, `builtin/runtime/lsp.lua`, `src/lua_bindings/mod.rs`,
|
||||
and a new `tests/lsp_multi_root_acceptance.rs`; intersecting that
|
||||
against this lane's own changed-file set leaves exactly one entry,
|
||||
`docs/active-work.md`. No source file is touched by both sides, so
|
||||
this one carries none of the first integration's semantic risk.
|
||||
- **CI ran on this branch for the first time on 2026-07-25 and passed
|
||||
all twelve** (Format, both Lints, GPU Render headless, all four Test
|
||||
matrix jobs, M1/M4/M5/M6 gates) at `8b457de` — the first-integration
|
||||
tip. Before that there were zero workflow runs since the PR opened on
|
||||
2026-07-24, while every other open PR had a full run; not a fork and
|
||||
not a trigger-config issue (the workflow fires on all `pull_request`
|
||||
events), cause never identified. So the green run **validates the
|
||||
first integration, including the `pmacs-gpu/src/main.rs` auto-merge,
|
||||
on macOS and Linux both** — the platforms local gating could not
|
||||
cover. The second and third integrations get their own CI run on the
|
||||
push that carries them.
|
||||
- Framing: `docs/inline-math-slice-framing.md` rev 3, approved after two
|
||||
review rounds; parent arc framing merged as #154.
|
||||
- State: parser, font bundle (GUST licence), MATH-table layout with the
|
||||
measured height budget, currency-guarded detection, and the
|
||||
`ChunkSource::MathBox` spacer substrate are implemented and
|
||||
round-3-reviewed (review fixes at `cbf7782`: the exclusive-`end`
|
||||
mapping bug its own test had pinned, script-marker whitespace, fallible
|
||||
layout via `UncoverableGlyph`, real fraction gap-min constants —
|
||||
flagship scale 0.867, fallback depth 5).
|
||||
- Caret-driven suppression (the Q#MS5 gate over the effective caret and
|
||||
Q#MS11 selection endpoints, chunk substitution before tab expansion,
|
||||
the line-reuse predicate's third input, and the CursorByte /
|
||||
optimistic-edit / Decorations refresh triggers), the draw pass
|
||||
(per-glyph mini-buffers positioned by each shaped line's real
|
||||
baseline, fraction-rule quads over the washes, the F8b family pin),
|
||||
the Q#MS11 whole-rectangle wash widening, and the pixel acceptance
|
||||
battery (criteria 5–11, 14–16; 17 discharged by the differential
|
||||
`cargo tree -e features` check — byte-identical with and without the
|
||||
dependency line) are implemented on the branch tip.
|
||||
- Clippy is CLEAN on the whole workspace at `-D warnings` — the draw
|
||||
pass consumed every formerly-dead item.
|
||||
- Verification **pre-integration** (at `14c1c01`, against the old base):
|
||||
199 `pmacs-gpu` tests under `PMACS_REQUIRE_GPU=1`; 1,815 default +
|
||||
1,992 CRDT library tests; M4 121; full workspace sweep green (isolated
|
||||
`XDG_CONFIG_HOME`). Superseded by the post-integration run below —
|
||||
those numbers describe a tree 28 commits behind.
|
||||
- Verification after the **first** integration: `cargo fmt --check`
|
||||
clean; strict workspace Clippy clean; 1,826 default + 2,003 CRDT
|
||||
library tests; **202 `pmacs-gpu` tests under `PMACS_REQUIRE_GPU=1`**;
|
||||
M4 121; isolated-`XDG_CONFIG_HOME` `--no-fail-fast` workspace sweep
|
||||
3,208 across 91 suites, zero failures; `git diff --check` clean.
|
||||
- Verification after the **third** integration (this is the set that
|
||||
describes what the PR now proposes): fmt clean; `git diff --check`
|
||||
clean; strict workspace Clippy clean; **1,829 default + 2,006 CRDT**
|
||||
library tests; **202 `pmacs-gpu`** under `PMACS_REQUIRE_GPU=1`; M4 121;
|
||||
**isolated-`XDG_CONFIG_HOME` `--no-fail-fast` sweep 3,224 across 92
|
||||
suites, zero failures**.
|
||||
- **Test-count reconciliation is the integration proof, not the pass.**
|
||||
Run it against what the other side actually added, per merge:
|
||||
- First: GPU 199 → **202**, and `e547a90` added exactly **3**
|
||||
`pmacs-gpu` tests — the whole delta on main since the merge base.
|
||||
Structurally spot-checked too: main's fix survives as
|
||||
`(count > 0).then(|| MinimapLineShape {` (the deferred closure,
|
||||
**not** the eager `then_some`) with its regression test.
|
||||
- Third: #166 adds **3** library tests, **2** to
|
||||
`vterm_stage3_acceptance`, and **0** to `pmacs-gpu`. Predicted lib
|
||||
1,826 → 1,829, CRDT 2,003 → 2,006, GPU unchanged at 202 — and that
|
||||
is exactly what ran. Suite count 91 → **92** is #161's new
|
||||
`tests/lsp_multi_root_acceptance.rs` binary. All three sides'
|
||||
markers confirmed live in `pmacs-gpu/src/main.rs`: #166's probe
|
||||
symbols, this lane's `math_plan_for_line` / `math_gates_match` /
|
||||
`cached_math_subs_for_slice` / `widen_over_math_chunks` / 21
|
||||
`MathBox` references, and the first integration's minimap fix.
|
||||
- **Ops trap, cost hours: `m4_5_basedpyright_initializes_and_negotiates_
|
||||
encoding` does not time out — it hangs forever.** A `--workspace`
|
||||
sweep parks on `m4_acceptance` with a live
|
||||
`basedpyright/langserver.index.js` child and never advances (observed
|
||||
stuck at 38 of 92 suites for 2h26m). The per-suite M4 gate already
|
||||
carries `-- --skip basedpyright`; **the workspace sweep needs the same
|
||||
flag** — `cargo test --workspace --no-fail-fast -- --skip
|
||||
basedpyright` (libtest filters apply to every binary; verify it bit by
|
||||
checking the run reports exactly 1 filtered out). Do not read a
|
||||
long-running sweep as "slow": check whether the suite count is
|
||||
advancing.
|
||||
- Remaining: the user's review pass.
|
||||
Named v0 approximations: the peer-caret half of acceptance 14 is
|
||||
pinned at the mapping level (unit tests), not pixels; a soft-wrapped
|
||||
spacer draws its box whole at the first run's origin; the fit budget
|
||||
reads the bundled code face even under a custom `set_font` family
|
||||
(the draw anchors to the real shaped baseline either way).
|
||||
## Lean 4 lane (Arc 8) — Stage 1 MERGED; Stage 2 IN REVIEW (PR #161)
|
||||
|
||||
- Stage 1 **merged as #160** (`main` @ `0827dd1`, 2026-07-25, one review
|
||||
|
|
|
|||
|
|
@ -0,0 +1,628 @@
|
|||
# Inline math — the first vertical slice (framing)
|
||||
|
||||
**Revision 3 — pre-implementation, framing only. Ground truth scouted against
|
||||
canonical `main` @ `352bf0b`, protocol v20, 2026-07-24. Rev 2 closed review
|
||||
round 1 (F1–F9); rev 3 closes round 2 (R2-1 – R2-4).**
|
||||
|
||||
### Round 2 (rev 2 → rev 3)
|
||||
|
||||
Verdict: converging — one deletion, one real gap, two nits.
|
||||
|
||||
| # | Finding | Closed in |
|
||||
| --- | --- | --- |
|
||||
| R2-1 | The tree-sitter paragraph appeared **twice** in Q#MS3; the second was stale rev-1 text. Introduced by rev 2's own rewrite, which added a copy without removing the original | Q#MS3 |
|
||||
| R2-2 | The F7 italic fix stopped at ASCII, so `$\alpha x$` drew an **upright α beside an italic 𝑥** — mixed styles inside one expression, and Greek is the slice's second flagship | Q#MS2, acceptance 13 |
|
||||
| R2-3 | §9 sat between §6 and §7 | section order |
|
||||
| R2-4 | Q#MS11 said a wash "covers" a span; a search match can **partially overlap** (`2$ af` in `before $x^2$ after`), which "covers" leaves unspecified | Q#MS11 |
|
||||
|
||||
Carried into acceptance from a round-2 non-finding: the Q#MS10 arithmetic puts
|
||||
`\frac{a}{b}` near 0.85 and suggests `\frac{x^2}{y}` also clears the 0.6
|
||||
floor, so criterion 12's fallback case must be **computed rather than guessed**
|
||||
or it will surprise-pass by rendering.
|
||||
|
||||
### Round 1 (rev 1 → rev 2)
|
||||
|
||||
Verdict: the slice's shape survived, both load-bearing corrections held, and
|
||||
nine findings landed — two of them decisions the implementation could not have
|
||||
proceeded without, one a compliance error.
|
||||
|
||||
| # | Finding | Closed in |
|
||||
| --- | --- | --- |
|
||||
| F1 | The fraction height budget was never confronted; lines cannot grow (`BASE_CODE_LINE_HEIGHT = 22.0` fixed) and a textstyle fraction does not fit | Q#MS10 (new) |
|
||||
| F2 | "Contributes no glyphs, reserves width" is a mechanism the chunk model does not have — a `RichChunk`'s only width is its `text` | Q#MS4, B1 |
|
||||
| F3 | Criterion 10 required source-width boxes while Q#MS4 implied layout-chosen width; the contradiction *is* the caret-toggle reflow question | Q#MS4, acceptance 10 |
|
||||
| F4 | Q#MS5 makes shaping depend on the caret — a new invalidation edge, and it must read the *effective* caret or flap during optimistic typing | Q#MS5 |
|
||||
| F5 | Detection had no currency guard (`$5 and $6` pairs) and no newline rule | Q#MS3 |
|
||||
| F6 | **Factual:** Latin Modern Math is GUST Font License and ~717 KiB, not OFL and ~200 KB | Q#MS7, §9 |
|
||||
| F7 | Without a math-italic mapping, `$x^2$` renders an upright roman `x` | Q#MS2 |
|
||||
| F8 | Layout still resolves glyph IDs internally; drawing must pin `Attrs` to the math family or measured and drawn advances diverge | Q#MS6, Q#MS7 |
|
||||
| F9 | Smaller: "after shaping decisions" contradicts Q#MS4; no selection/wash rule; `$$…$$` degradation untested; `Char` vs `Symbol` unmotivated | Q#MS3, Q#MS2, Q#MS11 (new), acceptance |
|
||||
|
||||
Two rev-1 claims were **wrong, not merely imprecise**, and are called out
|
||||
where they occur: the zero-glyph strut (F2) and the font licence (F6).
|
||||
|
||||
Parent arc: `docs/inline-math-framing.md` (rev 2, merged as #154). Sibling
|
||||
substrate lane: `docs/latex-grammar-math-substrate-framing.md` (rev 3), whose
|
||||
Stage 1 landed as #144.
|
||||
|
||||
This lane builds the **first end-to-end slice** of the parent's four-tier
|
||||
pipeline: a deliberately small LaTeX-math subset that is detected, parsed,
|
||||
laid out against a real OpenType MATH table, and **actually drawn on screen**.
|
||||
|
||||
## 0. Why a slice, and not "Tier 2 + Tier 3"
|
||||
|
||||
The obvious next unit was the parser (Tier 2) plus the layout engine
|
||||
(Tier 3). It is rejected here for the parent arc's own reason.
|
||||
|
||||
The substrate lane's **Q#LX5** refused to land the parser ahead of layout
|
||||
because *"the `MathNode` shape is only validated once [a layout consumer]
|
||||
exists"*. That argument does not stop at Tier 2. `MathBox` is only validated
|
||||
once a **renderer** consumes it: an unrendered layout engine can be
|
||||
self-consistent and still have the wrong shape — wrong units, wrong origin
|
||||
convention, a baseline the draw path cannot use. Landing Tiers 2+3 with no
|
||||
Tier 4 reproduces exactly the objection Q#LX5 raised, one layer up.
|
||||
|
||||
So the unit of work is **thin and vertical, not broad and horizontal**: the
|
||||
smallest grammar subset worth rendering, carried all the way to pixels. Every
|
||||
layer acquires a real consumer immediately. Breadth — big operators, stretchy
|
||||
fences, radicals, accents, display math — becomes follow-on work against an
|
||||
API that has already been exercised rather than one that has only been
|
||||
designed.
|
||||
|
||||
The cost is honest and named in §7: the slice touches
|
||||
`pmacs-gpu/src/main.rs`'s render path, which two other arcs also want.
|
||||
|
||||
## 1. Ground truth (scouted 2026-07-24 @ `352bf0b`)
|
||||
|
||||
### 1.1 Crate boundaries — the parent's file placement cannot work
|
||||
|
||||
The parent framing's integration table lists `src/math_parse.rs` and
|
||||
`src/math_layout.rs`, i.e. the **core `pmacs` crate**. Verified against the
|
||||
tree, that placement is unusable:
|
||||
|
||||
- **`pmacs-gpu` depends only on `pmacs-protocol`** (`pmacs-gpu/Cargo.toml:60`;
|
||||
there is no `pmacs` dependency). A parser in the core crate is therefore
|
||||
**unreachable from the frontend that renders it**.
|
||||
- **`ttf-parser` reaches only `pmacs-gpu`.** Per-crate check: `pmacs` no,
|
||||
`pmacs-protocol` no, `pmacs-gpu` yes (via `fontdb` → `cosmic-text` →
|
||||
`glyphon`). A layout module in the core would be a genuinely new dependency
|
||||
there, which is not what the parent's C1 established.
|
||||
|
||||
Both also contradict the parent's own prose — its design contract ("the
|
||||
instance never learns a pixel") and its protocol section ("math rendering is a
|
||||
pure frontend responsibility in v0"). The table was the outlier. Q#MS1 fixes
|
||||
it.
|
||||
|
||||
### 1.2 The GPU text pipeline this slice hooks
|
||||
|
||||
- `rebuild_code_slice` (`pmacs-gpu/src/main.rs:6136`) shapes **only the
|
||||
visible byte slice**; spans/decorations/adornments arrive in whole-file
|
||||
coordinates and are clipped and rebased onto it.
|
||||
- Per line, `chunks_for_line` (`:5100`) produces `RichChunk`s whose
|
||||
`ChunkSource` (`:7715`) is one of `Source { start }`,
|
||||
`SourceTab { start }`, `Adornment { anchor }`.
|
||||
- **Every existing variant is additive.** Adornments (inlay hints) inject text
|
||||
*between* source bytes; nothing today *replaces* a source range with a box
|
||||
of chosen width. That mechanism is what this slice must build (Q#MS4).
|
||||
- `build_hit_runs` (`:7739`) derives the projected→source hit map from the
|
||||
same chunks that feed glyphon, so the map and the shaped buffer cannot
|
||||
disagree. Any new chunk kind must participate here or clicks land wrong.
|
||||
- Custom drawing precedent: `SquiggleRenderer` (`:2825`) owns its WGSL shader
|
||||
and pipeline; the menu/background quad pipeline is the precedent for filled
|
||||
rectangles.
|
||||
- Fonts are embedded with `include_bytes!` from `pmacs-gpu/fonts/` under OFL
|
||||
(`JETBRAINS_MONO`, `:63`); `build_font_system` (`:217`) loads them into
|
||||
`fontdb`.
|
||||
|
||||
### 1.3 The acceptance seam already exists
|
||||
|
||||
`headless_or_skip(w, h, text)` builds a real headless GPU state and
|
||||
`render_offscreen()` returns mapped pixels (`copy_texture_to_buffer` at
|
||||
`:6570`). `headless_diag_face_recolors_band_counter_despite_unchanged_text`
|
||||
(`:12022`) is the precedent: render, mutate, render again, and assert on the
|
||||
pixel difference. Real-GPU tests run under `PMACS_REQUIRE_GPU=1`.
|
||||
|
||||
This matters because the slice's central claim — *math is actually drawn* —
|
||||
is exactly the kind of claim that a non-rendering test would pass vacuously.
|
||||
|
||||
## 2. What ships
|
||||
|
||||
One PR: detection (inline `$…$` only) → parse → layout against the MATH table
|
||||
→ draw, for the subset in Q#MS2, with the raw source shown whenever the
|
||||
cursor is inside the span (Q#MS5).
|
||||
|
||||
Explicitly **not** in this slice: display math `$$…$$`, big operators,
|
||||
stretchy fences, radicals, accents, `\text{}`, style overrides, tree-sitter
|
||||
injection detection, any wire surface, and the TUI.
|
||||
|
||||
## 3. Decisions
|
||||
|
||||
### Q#MS1 — Both modules live in `pmacs-gpu`
|
||||
|
||||
`pmacs-gpu/src/math_parse.rs` and `pmacs-gpu/src/math_layout.rs`. Not
|
||||
`src/`, for the three independent reasons in §1.1. This keeps v0 exactly what
|
||||
the parent says it is — a pure frontend responsibility — and keeps the core
|
||||
crate free of a font-metrics dependency it has no use for.
|
||||
|
||||
If instance-side detection ever lands (the parent's v1 `MathSpans`), the
|
||||
*parser* may move to a shared crate at that point. Nothing in this slice
|
||||
should assume it will.
|
||||
|
||||
### Q#MS2 — The subset: characters, sub/superscript, fraction
|
||||
|
||||
`MathNode` for this slice:
|
||||
|
||||
```rust
|
||||
enum MathNode {
|
||||
Char(char), // resolved codepoint: x, 2, +, α
|
||||
Group(Vec<MathNode>),
|
||||
Script { base: Box<MathNode>, sub: Option<Box<MathNode>>, sup: Option<Box<MathNode>> },
|
||||
Fraction { num: Box<MathNode>, den: Box<MathNode> },
|
||||
}
|
||||
```
|
||||
|
||||
Rev 1 had both `Char` and `Symbol`, each carrying a `char`, with no stated
|
||||
difference (F9d). Folded: `\alpha` resolves to `'α'` **in the parser**, so
|
||||
layout sees one kind. Provenance would only matter for error messages, which
|
||||
Q#MS8 does not produce.
|
||||
|
||||
This subset is chosen because it is the smallest one that **forces the MATH
|
||||
table to matter**. Characters alone could be positioned by guesswork and prove
|
||||
nothing. Scripts require `ScriptPercentScaleDown`, `SuperscriptShiftUp` and
|
||||
`SubscriptShiftDown`; fractions require `AxisHeight` and the fraction rule
|
||||
constants, plus nested box composition. Get those right and the remaining node
|
||||
kinds are more of the same; get them wrong and no amount of breadth helps.
|
||||
|
||||
The symbol map ships as a **seed** (Greek letters only, ~50 entries), not the
|
||||
parent's full ~200. Growing it is mechanical and needs no design.
|
||||
|
||||
**Math italic is in scope, and it covers Greek too (F7, R2-2).** Neither rev 1
|
||||
nor the parent mentioned italics, and without them `$x^2$` renders an upright
|
||||
roman `x` — which does not look like math, and would make the slice's flagship
|
||||
acceptance case visibly wrong.
|
||||
|
||||
Rev 2 fixed that for ASCII only, which reintroduced the same defect one symbol
|
||||
over: `\alpha` resolves to U+03B1 in the parser, so `$\alpha x$` would have
|
||||
drawn an upright α beside an italic 𝑥 — **mixed styles inside one
|
||||
expression**, with the Greek seed map being the slice's *second* flagship case.
|
||||
The mapping therefore follows TeX's actual convention:
|
||||
|
||||
| Class | Treatment | Range |
|
||||
| --- | --- | --- |
|
||||
| ASCII letters | math italic | U+1D434–U+1D467, **with the U+210E hole for `h`** (Letterlike Symbols, not in the 1D4xx run) |
|
||||
| Lowercase Greek | math italic | U+1D6FC–U+1D714 |
|
||||
| Uppercase Greek | **upright** | left at U+0391–U+03A9 |
|
||||
| Digits, operators | upright | unchanged |
|
||||
|
||||
Uppercase-Greek-upright is not an omission; it is what TeX does, and matching
|
||||
it is why the table is stated rather than left as "letters become italic".
|
||||
Because the slice positions characters, this stays a pure char→char mapping —
|
||||
the same mechanical class as the Greek seed itself.
|
||||
|
||||
### Q#MS3 — Detection is the frontend byte scanner, inline only, currency-guarded
|
||||
|
||||
A two-pass scan over the visible slice for unescaped `$…$` pairs, run in
|
||||
`rebuild_code_slice` **off the edit path**. (Rev 1 said "after shaping
|
||||
decisions", inherited from the parent's "post-shape hook"; that contradicts
|
||||
Q#MS4, since a suppression chunk must exist *before* the line is shaped. The
|
||||
property that actually matters is that detection does not run per keystroke —
|
||||
F9a.)
|
||||
|
||||
**Currency guards are mandatory, not a refinement (F5).** Rev 1 relied on the
|
||||
parent's lone-`$` case and would have rendered `prices are $5 and $6 today` as
|
||||
math over `5 and ` — in exactly the grammar-less prose buffers this rule
|
||||
targets. Adopt Pandoc's rule:
|
||||
|
||||
- an opening `$` must be followed by a **non-space**;
|
||||
- a closing `$` must be preceded by a **non-space** and not followed by a
|
||||
**digit**;
|
||||
- `\$` is an escape and neither opens nor closes.
|
||||
|
||||
**A span may not cross a newline in v0.** Chunking is per line and the visible
|
||||
slice is line-ranged, so single-line spans are what keep visible-slice-scoped
|
||||
scanning stable under scroll. A `$` with no same-line partner yields no span.
|
||||
|
||||
Tree-sitter injection detection is deliberately not used, even though #144
|
||||
gives us `math_environment` / `math_delimiter` for `.tex`: that path is
|
||||
instance-side, the substrate lane already deferred it to this arc, and the
|
||||
slice must work in the grammar-less buffers where most inline math is typed.
|
||||
It stays available as the natural upgrade — and it is the principled fix for
|
||||
currency false-positives, which guards only approximate.
|
||||
|
||||
### Q#MS4 — Suppression is a spacer chunk, width-quantized, layout-chosen (F2, F3)
|
||||
|
||||
**Rev 1 was wrong about the mechanism.** It said the chunk "contributes no
|
||||
glyphs… reserves width". A `RichChunk`'s only width *is* its `text: String`
|
||||
(`pmacs-gpu/src/main.rs:7703`), which `line_from_chunks` feeds straight into a
|
||||
`BufferLine`; cosmic-text has no zero-glyph strut. There is nothing to reserve
|
||||
width with except text.
|
||||
|
||||
The mechanism is therefore the **`SourceTab` precedent**: `ChunkSource` gains a
|
||||
variant carrying the suppressed source range, and the chunk projects **spacer
|
||||
text** — runs of spaces — whose advance covers the box. Reserved width is
|
||||
consequently **quantized up to whole space advances**, which is a feature, not
|
||||
a rounding error: the projection stays grid-aligned with the surrounding
|
||||
monospace text, and hit runs stay integral.
|
||||
|
||||
**Width is layout-chosen, not pinned to the source width (F3).** Rev 1 implied
|
||||
both, and acceptance 10 demanded the latter. Resolved deliberately in favour of
|
||||
layout-chosen:
|
||||
|
||||
- Pinning to source width removes reflow, but `$\frac{a}{b}$` is 13 source
|
||||
columns against a box roughly 2 wide, so every fraction would sit in a large
|
||||
blank gap. That defect is permanent and visible on every render.
|
||||
- Layout-chosen width means the line **reflows when the caret crosses a span
|
||||
boundary** (Q#MS5 toggles suppression). That is a jump, but it is confined to
|
||||
one line, it happens only on a deliberate caret move, and it is the same
|
||||
behaviour `org-appear` has trained users to expect from Emacs.
|
||||
|
||||
A permanent visual defect is worse than a transient one tied to an explicit
|
||||
user action. Acceptance 10 is rewritten to match: text *before* the span never
|
||||
moves, text *after* it moves by exactly the quantized difference, and the
|
||||
reflow is confined to the affected line.
|
||||
|
||||
`build_hit_runs`'s invariant — the hit map derives from the same chunks
|
||||
glyphon shaped — is not weakened; the new variant participates like any other.
|
||||
A click inside a math box maps to the **start byte of the suppressed range**,
|
||||
the same snap-to-anchor rule `Adornment` uses. Sub-expression hit-testing is
|
||||
deferred; it needs a box→byte map this slice deliberately does not build.
|
||||
|
||||
### Q#MS10 — The height budget: fit to the line, or fall back (F1)
|
||||
|
||||
The code buffer is one cosmic-text `Buffer` with uniform metrics —
|
||||
`BASE_CODE_FONT_SIZE = 16.0`, `BASE_CODE_LINE_HEIGHT = 22.0`
|
||||
(`pmacs-gpu/src/main.rs:362`, `:359`). **Lines cannot grow.** A textstyle
|
||||
fraction at those metrics is roughly 17 px tall against an above-baseline
|
||||
budget of ~12–14 px, so a simple fraction is marginal and acceptance 1's own
|
||||
nested `\frac{x^2}{y}` plainly exceeds. Rev 1 hid this inside Q#MS8's "a box
|
||||
that would exceed the line" without saying whether that meant width or height,
|
||||
or what the budget was.
|
||||
|
||||
**Rule: the box is uniformly scaled to fit the line box, down to a floor of
|
||||
0.6×; below the floor the span falls back to source (Q#MS8).** No overdraw, no
|
||||
reflow of line height, no clipping surprises. The available budget is the line
|
||||
box less a one-pixel margin, split at the text baseline.
|
||||
|
||||
Rejected alternatives, for the record:
|
||||
|
||||
- **Overdraw into adjacent lines' leading.** The math pass draws after
|
||||
glyphon and *could* paint outside the line box, but a tall fraction would
|
||||
then visually collide with the line above — a defect the user cannot fix
|
||||
except by not writing math.
|
||||
- **Growing the line.** Not available: metrics are uniform for the whole
|
||||
buffer.
|
||||
|
||||
The honest consequence: **v0 shrinks nested math uniformly rather than by
|
||||
proper style level.** TeX shrinks nested fractions too, but it does so through
|
||||
display/text/script/scriptscript levels with per-level constants, which is the
|
||||
real answer and is deferred by name in §6. A uniform scale is a visibly
|
||||
cruder approximation of the same idea, and it is what keeps the slice thin.
|
||||
|
||||
### Q#MS5 — The cursor rule: render math only when the cursor is outside
|
||||
|
||||
When the caret is anywhere inside a math span (or on either delimiter), that
|
||||
span is **not** suppressed — the raw `$…$` renders as ordinary source text.
|
||||
|
||||
This is the parent's Q#IM5 proposal ("when the cursor approaches the boundary,
|
||||
the raw `$` reappears") adopted as a hard rule, and it buys the slice a great
|
||||
deal: there is no caret-inside-rendered-math problem to solve, because the two
|
||||
states are mutually exclusive. Editing math shows source; moving away renders
|
||||
it. Q#IM6's "best-effort fractional cursor projection" is then not needed at
|
||||
all in v0, and is deferred rather than approximated.
|
||||
|
||||
It also gives the feature an honest, self-explaining interaction model, which
|
||||
is worth more in v0 than sub-glyph caret fidelity.
|
||||
|
||||
**This creates a new shaping-invalidation edge, and it is the #120 trap class
|
||||
(F4).** Today caret motion within the visible slice touches no shaped line:
|
||||
the `CursorByte` arm updates the cursor and reshapes only on scroll-follow,
|
||||
and `rebuild_lines_reusing_scroll` (`pmacs-gpu/src/main.rs:5116`) retains
|
||||
lines on the premise that content and styling are unchanged. Making
|
||||
suppression a function of the caret breaks that premise. Two obligations
|
||||
follow, both of which the implementation owns explicitly:
|
||||
|
||||
- **Caret motion that crosses a span boundary must dirty the affected
|
||||
lines**, and the line-reuse predicate gains suppression state as a third
|
||||
input beside content and styling. A retained line computed under the
|
||||
opposite suppression state is exactly the stale-mirror failure #120 taught.
|
||||
- **The rule reads the *effective* caret the frontend draws**, not the last
|
||||
confirmed `CursorByte`. The GPU holds an optimistic cursor during
|
||||
unconfirmed edits; keying suppression off the confirmed value would make
|
||||
spans flap between rendered and source while typing.
|
||||
|
||||
Acceptance 7 exercises the behaviour; these two are named here because a test
|
||||
that only moves the caret and re-renders would pass even if the reuse
|
||||
predicate were left untouched, as long as something else happened to dirty the
|
||||
line.
|
||||
|
||||
### Q#MS6 — Layout positions CHARACTERS, not glyph IDs
|
||||
|
||||
```rust
|
||||
struct MathBox { width: f32, ascent: f32, descent: f32, items: Vec<MathItem> }
|
||||
enum MathItem {
|
||||
Glyph { ch: char, x: f32, baseline: f32, size_px: f32 },
|
||||
Rule { x: f32, y: f32, width: f32, thickness: f32 }, // fraction bar
|
||||
}
|
||||
```
|
||||
|
||||
Positions are in pixels relative to the box origin, resolved by the frontend
|
||||
that owns font metrics — consistent with the parent's contract.
|
||||
|
||||
**Characters, not glyph IDs, is a deliberate boundary — on the OUTPUT only
|
||||
(F8a).** Layout still resolves glyph IDs *internally*: advances and
|
||||
`MathItalicsCorrection` are glyph-keyed, so a `cmap` lookup happens whatever
|
||||
the item type. What the boundary buys is that the *emitted* items are
|
||||
drawable by the existing text machinery.
|
||||
|
||||
Glyph-ID **output** exists to select *variants* from the MATH table's
|
||||
`GlyphVariantRecord` / `GlyphConstruction` chains — precisely what stretchy
|
||||
fences and big operators need, and precisely what this slice defers. The slice
|
||||
must not pretend this generalises: when stretchy delimiters arrive they will
|
||||
need glyph-ID items, and `MathItem` will gain a variant then.
|
||||
|
||||
The fraction rule is a filled quad on the existing quad pipeline, not a glyph.
|
||||
|
||||
### Q#MS7 — The MATH font and its feature declaration
|
||||
|
||||
Bundle **Latin Modern Math** in `pmacs-gpu/fonts/`, embedded with
|
||||
`include_bytes!` beside JetBrains Mono. Two consumers read the same bytes:
|
||||
`fontdb`/cosmic-text for drawing, and `ttf-parser` directly for the MATH
|
||||
table, which cosmic-text does not expose.
|
||||
|
||||
**Licence and size, corrected (F6).** Rev 1 said "OFL, GUST" and the parent's
|
||||
table says "OFL (GUST)". Both are **wrong**. Verified against a local TeX Live
|
||||
copy, `latinmodern-math.otf` is **733,736 bytes (~717 KiB)** and its own
|
||||
copyright string reads *"released under the GUST Font License"* — an
|
||||
LPPL-derived licence, not the SIL OFL. Consequences:
|
||||
|
||||
- the bundled licence file must be the **GUST Font License**, named as such,
|
||||
not `OFL.txt` (the existing `fonts/OFL.txt` covers JetBrains Mono only);
|
||||
- the size claim must be honest: at ~717 KiB this becomes **the largest single
|
||||
embedded asset in the repository**, roughly 3.5× the figure rev 1 quoted;
|
||||
- GFL permits redistribution with its licence text, so the plan stands — but
|
||||
it is a *different* obligation from OFL and must be discharged as one;
|
||||
- if OFL-only ever becomes a requirement, **STIX Two Math** is the OFL
|
||||
alternative already listed in the parent's font table.
|
||||
|
||||
The parent framing carries the same error and needs the same correction; that
|
||||
is recorded in §9 as a follow-up rather than smuggled into this lane.
|
||||
|
||||
**Pin `Attrs` to the math family when drawing (F8b).** Layout measures with
|
||||
`ttf-parser` against the bundled bytes; drawing goes through cosmic-text. If
|
||||
fallback selects a different face for `α` or a math-italic `𝑥` than the one
|
||||
measured, drawn advances diverge silently from computed geometry and the box
|
||||
is subtly wrong everywhere. The draw path sets the family explicitly and does
|
||||
not rely on fallback.
|
||||
|
||||
Declare the dependency exactly as the parent's rev-2 C1 records:
|
||||
|
||||
```toml
|
||||
ttf-parser = { version = "0.25", default-features = false, features = ["opentype-layout"] }
|
||||
```
|
||||
|
||||
Bare `ttf-parser = "0.25"` unions `std` in and rebuilds the font chain.
|
||||
|
||||
A font whose MATH table is absent or unparseable is a **hard startup error in
|
||||
the math path only** — math spans fall back to raw source (Q#MS8), the editor
|
||||
does not fail. Bundled-font regressions must not be silent.
|
||||
|
||||
### Q#MS11 — Selection, search washes, and peer carets over a box (F9b)
|
||||
|
||||
Rev 1 named selection as a falsifier of B4 without proposing a rule. Any
|
||||
overlay addressed in *source* bytes meets a span whose source is suppressed.
|
||||
|
||||
- **A selection endpoint inside a span unsuppresses it.** This is Q#MS5's rule
|
||||
generalised from the caret to any selection boundary: if the user is
|
||||
addressing bytes inside the math, they see the bytes. A selection that
|
||||
merely *spans* the region (both endpoints outside) leaves it rendered.
|
||||
- **A wash that *intersects* a rendered span washes the whole reserved
|
||||
rectangle.** Intersection, not containment (R2-4): for selections the
|
||||
distinction is vacuous, since a contiguous selection with both endpoints
|
||||
outside a span necessarily contains it — but a **search match can partially
|
||||
overlap**, e.g. searching `2$ af` in `before $x^2$ after` matches from
|
||||
inside the span to outside it. Search hits and peer highlights paint the
|
||||
projected box, never a sub-range of it: the box has no interior byte map
|
||||
(Q#MS4), so a partial wash cannot be placed honestly.
|
||||
- **Peer carets snap to the span start**, the same rule as hits.
|
||||
|
||||
This keeps every overlay addressable without inventing a box→byte projection
|
||||
the slice does not build, and it makes "you are addressing this text" and "you
|
||||
see this text" the same condition throughout.
|
||||
|
||||
### Q#MS8 — Failure is always "show the source"
|
||||
|
||||
Unparseable expression, unsupported node kind, missing MATH constant, or a box
|
||||
that would exceed the line: the span is not suppressed and renders as ordinary
|
||||
source. The parent's red-squiggle treatment (its Q#IM4) is **deferred** — it
|
||||
reuses the diagnostic squiggle path, which is a second integration this slice
|
||||
does not need in order to be correct.
|
||||
|
||||
Consequence worth stating plainly: **an unsupported construct is
|
||||
indistinguishable from ordinary text in v0.** That is acceptable precisely
|
||||
because the subset is small and documented; it stops being acceptable when
|
||||
breadth arrives, which is when Q#IM4 should land.
|
||||
|
||||
### Q#MS9 — Caching is deferred
|
||||
|
||||
The parent's hash-keyed `MathBox` cache is **not** in this slice. Layout runs
|
||||
per visible span per reshape. This is a slice: the subset is tiny, the visible
|
||||
span count is small, and an unmeasured cache is a guess. The parent's latency
|
||||
targets stay as targets; the first measurement comes from this slice's own
|
||||
render path, and the cache lands when a number justifies its invalidation
|
||||
cost.
|
||||
|
||||
## 4. Bets (falsifiable)
|
||||
|
||||
- **B1' (restated after F2) — a spacer chunk composes with the existing
|
||||
pipeline.** Reserving width via projected spaces, quantized to whole space
|
||||
advances, needs only a new `ChunkSource` variant that `chunks_for_line` and
|
||||
`build_hit_runs` already iterate. Falsified if it requires changing how
|
||||
cosmic-text shapes the surrounding line, or if quantized spacer width cannot
|
||||
keep the projected hit map integral. *(Rev 1's "zero-glyph strut" wording is
|
||||
withdrawn: no such mechanism exists.)*
|
||||
- **B2 — scripts and fractions are enough to validate `MathBox`.** Falsified
|
||||
if adding a deferred node kind later forces a change to `MathBox`'s width /
|
||||
ascent / descent / origin contract, rather than only adding a `MathItem`
|
||||
variant.
|
||||
- **B3 — character positioning suffices for the subset.** Falsified if any
|
||||
node in Q#MS2 cannot be drawn correctly without selecting a glyph variant.
|
||||
- **B4' (sharpened after F9b) — the cursor rule plus Q#MS11 remove the caret
|
||||
problem rather than hiding it.** Falsified if any caret position, selection
|
||||
endpoint, search wash, or peer caret inside or across a math span still needs
|
||||
a projected-position approximation to behave correctly.
|
||||
- **B5 — `ttf-parser` supplies every constant the subset needs.** Falsified if
|
||||
script or fraction layout requires a MATH value `ttf-parser` does not
|
||||
expose.
|
||||
- **B6 (new, F1) — fit-to-line with a 0.6× floor keeps the subset legible.**
|
||||
Falsified if a plain `\frac{a}{b}` at default metrics lands below the floor
|
||||
(making the flagship case fall back to source), or if scaled output is
|
||||
illegible at the floor. Either outcome means the slice needs real TeX style
|
||||
levels rather than a uniform scale, which would be a scope change.
|
||||
|
||||
## 5. Acceptance
|
||||
|
||||
Parser and layout are pure and get ordinary unit tests. Everything that claims
|
||||
something reaches the screen runs on a real device through
|
||||
`headless_or_skip` + `render_offscreen`, under `PMACS_REQUIRE_GPU=1`.
|
||||
|
||||
1. **Parser** — `x^2`, `x_i`, `x_i^2`, `\frac{a}{b}`, `\alpha`, nested
|
||||
`\frac{x^2}{y}` produce the expected `MathNode` trees. Unbalanced `{`,
|
||||
unknown command, and an empty span are errors, not panics.
|
||||
2. **Detection** — `$x^2$` yields one span; `$a$ and $b$` yields two;
|
||||
`\$5` yields none. **Currency guards (F5):** `Price: $5.00` yields none,
|
||||
`prices are $5 and $6 today` yields **none** (the rev-1 rule would have
|
||||
matched `5 and `), `$ x $` yields none (space after opener), and a `$`
|
||||
whose only partner is on the next line yields none.
|
||||
3. **MATH constants are actually consulted** — layout of `x^2` with the real
|
||||
font places the `2` above the baseline and scaled down. Bite: stubbing
|
||||
`ScriptPercentScaleDown` to 100% changes the laid-out box, proving the
|
||||
constant is read rather than hardcoded.
|
||||
4. **Fraction geometry** — numerator above, denominator below, rule at the
|
||||
axis height, box ascent/descent enclose both.
|
||||
5. **It renders** — a buffer containing `$x^2$` renders differently from the
|
||||
same buffer with the math span suppressed. Asserted on pixels, so a layout
|
||||
engine wired to nothing cannot pass it.
|
||||
6. **The fraction rule is drawn** — `$\frac{a}{b}$` produces horizontal rule
|
||||
pixels between the two operand rows.
|
||||
7. **Cursor rule** — with the caret inside `$x^2$`, the raw `$x^2$` glyphs
|
||||
render and no math is drawn; moving the caret out re-renders the math.
|
||||
Both directions asserted.
|
||||
8. **Hit mapping** — a click on a rendered math box places the caret at the
|
||||
span's start byte, and the surrounding text's hit runs are unchanged.
|
||||
9. **Failure shows source** — `$\frac{a$` and `$\unknown{}$` render as
|
||||
ordinary source text with no panic and no missing glyphs.
|
||||
10. **Reflow is bounded and predictable (F3)** — in `before $x^2$ after`,
|
||||
`before` occupies identical pixels whether or not the span is rendered;
|
||||
`after` shifts by exactly the quantized width difference; no other line
|
||||
moves. Toggling via the Q#MS5 caret rule reflows only the affected line.
|
||||
11. **Line reuse honours suppression (F4)** — moving the caret across a span
|
||||
boundary changes the rendered output. Bite: with suppression left out of
|
||||
the line-reuse predicate, the retained line keeps the stale state and this
|
||||
fails. Suppression follows the **effective** caret, so it does not flap
|
||||
during an unconfirmed optimistic edit.
|
||||
12. **Height budget (F1) — measured, not assumed.** Computed against the
|
||||
bundled font, with the budget derived as Q#MS10 defines it (the line box
|
||||
less a 1 px margin, baseline placed by the **code** font — JetBrains Mono
|
||||
asc 16.32 / desc 4.80 at 16 px inside the 22 px line, *not* the math
|
||||
font's own 12.90/3.10):
|
||||
|
||||
| expression | ascent | descent | scale |
|
||||
| --- | --- | --- | --- |
|
||||
| `x^2`, `\alpha x` | 13.27 | 0.18 | 1.000 |
|
||||
| `\frac{a}{b}` | 10.57 | 5.40 | **0.867** |
|
||||
| `\frac{x^2}{y}` | 14.91 | 4.75 | 0.986 |
|
||||
| nesting depth 2 | — | — | 0.872 |
|
||||
| nesting depth 4 | — | — | 0.613 |
|
||||
| nesting depth 5 | — | — | **0.540** |
|
||||
|
||||
So **B6 holds** — the flagship fraction renders at 0.867 — and the
|
||||
fallback case is **depth 5**. Rev 3 guessed depth 2; the first
|
||||
measurement said depth 3 while the fraction gap was still a hardcoded
|
||||
`2 × thickness` guess; reading the MATH table's real
|
||||
`FractionNumeratorGapMin` / `FractionDenominatorGapMin` (round-3 F4)
|
||||
moved the flagship from 0.732 to 0.867 and the boundary to depth 5. The
|
||||
round-2 hand-arithmetic estimate of ~0.85 was right all along; the 0.732
|
||||
was inflated by the guessed gap.
|
||||
Round 2 predicted exactly this trap. Two things worth keeping: depth 2
|
||||
scores *higher* than depth 1 because the binding constraint flips from
|
||||
descent to ascent as nesting grows asymmetrically, so "deeper is always
|
||||
tighter" is false; and the test **searches** for the tripping depth rather
|
||||
than hardcoding it, so a font or metric change cannot silently leave the
|
||||
fallback arm unexercised.
|
||||
13. **Math italic (F7, R2-2)** — `$x$` renders the math-italic glyph, not
|
||||
roman `x`; `$h$` resolves through the U+210E hole rather than the 1D4xx
|
||||
run; digits in `$x2$` stay upright; **`$\alpha$` renders math-italic Greek
|
||||
and `$\Gamma$` stays upright**, so `$\alpha x$` is uniformly italic rather
|
||||
than mixed.
|
||||
14. **Overlays (Q#MS11)** — a selection endpoint inside a span unsuppresses
|
||||
it; a selection enclosing a rendered span leaves it rendered and washes
|
||||
the whole reserved rectangle; a peer caret inside a rendered span draws at
|
||||
the span start.
|
||||
15. **Deferred syntax degrades, not corrupts** — `$$x$$` renders as ordinary
|
||||
source text through the empty-span error path, with no panic and no
|
||||
half-rendered box (F9c).
|
||||
16. **Font provenance** — the bundled licence file is the GUST Font License
|
||||
and is distinct from the existing `fonts/OFL.txt`; a build with the MATH
|
||||
table absent or unparseable falls back to source and surfaces the error
|
||||
rather than failing silently (Q#MS7).
|
||||
17. **Feature declaration is differential, not absolute** — the `ttf-parser`
|
||||
feature set from `cargo tree -e features` is **byte-identical with and
|
||||
without this crate's dependency line**. Asserting "`std` is absent" would
|
||||
be wrong and would fail a correct implementation: `std` is *already*
|
||||
enabled upstream, because `fontdb` declares `std = ["ttf-parser/std"]`.
|
||||
What the declaration must not do is *widen* the set, which only a
|
||||
before/after comparison can show.
|
||||
18. Full gate suite per `CLAUDE.md`, including `PMACS_REQUIRE_GPU=1`.
|
||||
|
||||
## 6. Deferred (named)
|
||||
|
||||
Display math `$$…$$` and `\[…\]`; big operators; stretchy fences and glyph
|
||||
variant/assembly (with the `MathItem` glyph-ID variant they require);
|
||||
radicals; accents; `\text{}`; style overrides; the full ~200-entry symbol map;
|
||||
the red-squiggle error treatment (parent Q#IM4); the `MathBox` cache (Q#MS9);
|
||||
sub-expression hit-testing and caret projection inside rendered math (parent
|
||||
Q#IM6); colour-by-context (parent Q#IM2); tree-sitter injection detection and
|
||||
any `MathSpans` wire surface; the TUI's distinct-face fallback; Lua-registered
|
||||
delimiters; **proper TeX style levels** (display/text/script/scriptscript with
|
||||
per-level MATH constants), for which Q#MS10's uniform fit-to-line scale is a
|
||||
deliberately cruder stand-in; **sub-range washes** inside a rendered box
|
||||
(Q#MS11 washes the whole rectangle).
|
||||
|
||||
|
||||
## 7. Interaction with other work
|
||||
|
||||
The slice's Tier 4 half edits `pmacs-gpu/src/main.rs`'s render path, which two
|
||||
other lanes also claim:
|
||||
|
||||
- **Bottom panel** — Stage 1 is in review as **#155**; its **Stage 2** takes
|
||||
this render path *and* the next protocol version.
|
||||
- **Folding Stage 3 (GPU)** — next ranked, still unframed, and inherits the
|
||||
`BufferSnapshot` fold-mirror-clear obligation on the same path.
|
||||
|
||||
This lane reserves **no protocol version** and adds no wire surface, so it
|
||||
cannot collide there. For the render path the rule is the one the other two
|
||||
framings already apply to each other: **whichever lands second re-scouts
|
||||
against the first.** The parser and layout modules are new files and collide
|
||||
with nothing; only the `rebuild_code_slice` / chunk / render hunks are
|
||||
contended, and they are small and localised by design.
|
||||
|
||||
Sequencing preference: land after #155's Stage 1, whose merge does not touch
|
||||
this path, and re-scout if bottom-panel Stage 2 or folding Stage 3 lands
|
||||
first.
|
||||
|
||||
## 8. Prior art in pmacs
|
||||
|
||||
`SquiggleRenderer` (`pmacs-gpu/src/main.rs:2825`) for owning a custom pipeline
|
||||
beside glyphon; the menu/background quad path for filled rectangles; inlay
|
||||
hints (`ChunkSource::Adornment`) for interleaving non-source content and for
|
||||
the anchor-snapping hit rule; `headless_diag_face_recolors_band_counter…`
|
||||
(`:12022`) for asserting a rendering claim on real pixels; #144's query
|
||||
overlay for the eventual tree-sitter detection upgrade.
|
||||
|
||||
## 9. Follow-up outside this lane
|
||||
|
||||
The parent framing (`docs/inline-math-framing.md`, rev 2, merged as #154)
|
||||
carries the same font error F6 found here: its table row reads "Latin Modern
|
||||
Math | Full | OFL (GUST)". It should be corrected to the GUST Font License,
|
||||
with the ~717 KiB size, in its own docs change rather than in this branch —
|
||||
the parent is a merged document and this lane should not quietly edit it.
|
||||
|
|
@ -37,6 +37,11 @@ similar_names = "allow"
|
|||
multiple_crate_versions = "allow"
|
||||
|
||||
[dependencies]
|
||||
# OpenType MATH table reader for inline math layout (Q#MS7). Already in the
|
||||
# build graph via fontdb -> cosmic-text -> glyphon, so this declares a crate
|
||||
# the build compiles anyway. The feature set is a SUBSET of fontdb's; a bare
|
||||
# `ttf-parser = "0.25"` would union `std` in and rebuild the whole font chain.
|
||||
ttf-parser = { version = "0.25", default-features = false, features = ["opentype-layout"] }
|
||||
# OS clipboard for cut/copy/paste (Q#CM6). `wayland-data-control` adds
|
||||
# the zwlr_data_control backend so the clipboard works under Wayland
|
||||
# without a window handle; the default X11 backend covers X sessions.
|
||||
|
|
|
|||
|
|
@ -0,0 +1,28 @@
|
|||
% This is version 1.0, dated 22 June 2009, of the GUST Font License.
|
||||
% (GUST is the Polish TeX Users Group, http://www.gust.org.pl)
|
||||
%
|
||||
% For the most recent version of this license see
|
||||
% http://www.gust.org.pl/fonts/licenses/GUST-FONT-LICENSE.txt
|
||||
% or
|
||||
% http://tug.org/fonts/licenses/GUST-FONT-LICENSE.txt
|
||||
%
|
||||
% This work may be distributed and/or modified under the conditions
|
||||
% of the LaTeX Project Public License, either version 1.3c of this
|
||||
% license or (at your option) any later version.
|
||||
%
|
||||
% Please also observe the following clause:
|
||||
% 1) it is requested, but not legally required, that derived works be
|
||||
% distributed only after changing the names of the fonts comprising this
|
||||
% work and given in an accompanying "manifest", and that the
|
||||
% files comprising the Work, as listed in the manifest, also be given
|
||||
% new names. Any exceptions to this request are also given in the
|
||||
% manifest.
|
||||
%
|
||||
% We recommend the manifest be given in a separate file named
|
||||
% MANIFEST-<fontid>.txt, where <fontid> is some unique identification
|
||||
% of the font family. If a separate "readme" file accompanies the Work,
|
||||
% we recommend a name of the form README-<fontid>.txt.
|
||||
%
|
||||
% The latest version of the LaTeX Project Public License is in
|
||||
% http://www.latex-project.org/lppl.txt and version 1.3c or later
|
||||
% is part of all distributions of LaTeX version 2006/05/20 or later.
|
||||
Binary file not shown.
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,900 @@
|
|||
//! OpenType MATH metrics and the math-italic mapping (Tier 3, part one).
|
||||
//!
|
||||
//! Framing: `docs/inline-math-slice-framing.md` (rev 3), Q#MS6 / Q#MS7.
|
||||
//!
|
||||
//! Two consumers read the same bundled font bytes: cosmic-text draws with it,
|
||||
//! and this module measures with it. cosmic-text does not expose the MATH
|
||||
//! table, which is why `ttf-parser` is a direct dependency (Q#MS7) — already
|
||||
//! in the build graph via `fontdb`, declared with a feature subset that
|
||||
//! widens nothing.
|
||||
|
||||
use ttf_parser::Face;
|
||||
|
||||
/// Bundled math font (GUST Font License — see `fonts/GUST-FONT-LICENSE.txt`).
|
||||
///
|
||||
/// Distinct from `fonts/OFL.txt`, which covers `JetBrains` Mono only: Latin
|
||||
/// Modern Math is GFL, an LPPL-derived licence, not the SIL OFL (framing F6).
|
||||
pub const LATIN_MODERN_MATH: &[u8] = include_bytes!("../fonts/latinmodern-math.otf");
|
||||
|
||||
/// The MATH constants this slice's subset needs, in font units.
|
||||
///
|
||||
/// Deliberately narrow: Q#MS2 covers scripts and fractions, so these are the
|
||||
/// constants those two require. Reading more would be speculative — the
|
||||
/// values for deferred constructs are only meaningful once they have a
|
||||
/// consumer (the Q#LX5 discipline, applied to metrics).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct MathConstants {
|
||||
/// Units per em, for scaling everything below into pixels.
|
||||
pub units_per_em: u16,
|
||||
/// Vertical position of the fraction bar / math axis.
|
||||
pub axis_height: i16,
|
||||
/// Percentage (0–100) to scale one script level down.
|
||||
pub script_percent_scale_down: i16,
|
||||
/// Baseline shift for a superscript.
|
||||
pub superscript_shift_up: i16,
|
||||
/// Baseline shift for a subscript.
|
||||
pub subscript_shift_down: i16,
|
||||
/// Thickness of the fraction rule.
|
||||
pub fraction_rule_thickness: i16,
|
||||
/// Minimum gap between the numerator and the rule.
|
||||
pub fraction_numerator_gap_min: i16,
|
||||
/// Minimum gap between the rule and the denominator.
|
||||
pub fraction_denominator_gap_min: i16,
|
||||
}
|
||||
|
||||
/// Why the bundled font could not supply math metrics.
|
||||
///
|
||||
/// Q#MS7: this is a failure of the *math path only* — spans fall back to
|
||||
/// source and the editor keeps running. It is surfaced rather than swallowed
|
||||
/// so a bundled-font regression cannot be silent.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum MathFontError {
|
||||
/// The bytes are not a parseable font.
|
||||
Unparseable,
|
||||
/// Parsed, but carries no MATH table (e.g. a text-only font).
|
||||
NoMathTable,
|
||||
/// MATH table present but missing a constant the subset needs.
|
||||
MissingConstant(&'static str),
|
||||
/// The math font cannot draw this codepoint (F3). Q#MS8's rule is
|
||||
/// "failure is always show the source", so layout REFUSES rather than
|
||||
/// emitting a zero-width item that would render tofu over its neighbour.
|
||||
/// Layout is fallible for this reason alone; the draw pass needs a
|
||||
/// refusal signal, and it must exist before that pass consumes the API.
|
||||
UncoverableGlyph(char),
|
||||
}
|
||||
|
||||
impl MathConstants {
|
||||
/// Read the subset's constants from font bytes.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`MathFontError`] when the face, the MATH table, or a needed constant
|
||||
/// is absent.
|
||||
pub fn from_font_bytes(bytes: &[u8]) -> Result<Self, MathFontError> {
|
||||
let face = Face::parse(bytes, 0).map_err(|_| MathFontError::Unparseable)?;
|
||||
let math = face.tables().math.ok_or(MathFontError::NoMathTable)?;
|
||||
let constants = math
|
||||
.constants
|
||||
.ok_or(MathFontError::MissingConstant("constants"))?;
|
||||
Ok(Self {
|
||||
units_per_em: face.units_per_em(),
|
||||
axis_height: constants.axis_height().value,
|
||||
script_percent_scale_down: constants.script_percent_scale_down(),
|
||||
superscript_shift_up: constants.superscript_shift_up().value,
|
||||
subscript_shift_down: constants.subscript_shift_down().value,
|
||||
fraction_rule_thickness: constants.fraction_rule_thickness().value,
|
||||
fraction_numerator_gap_min: constants.fraction_numerator_gap_min().value,
|
||||
fraction_denominator_gap_min: constants.fraction_denominator_gap_min().value,
|
||||
})
|
||||
}
|
||||
|
||||
/// Convert a font-unit value to pixels at `font_size_px`.
|
||||
#[must_use]
|
||||
pub fn to_px(self, value: i16, font_size_px: f32) -> f32 {
|
||||
if self.units_per_em == 0 {
|
||||
return 0.0;
|
||||
}
|
||||
f32::from(value) * font_size_px / f32::from(self.units_per_em)
|
||||
}
|
||||
|
||||
/// The per-level script scale, as a fraction (e.g. 0.7).
|
||||
#[must_use]
|
||||
pub fn script_scale(self) -> f32 {
|
||||
let pct = f32::from(self.script_percent_scale_down);
|
||||
if pct <= 0.0 { 0.7 } else { pct / 100.0 }
|
||||
}
|
||||
}
|
||||
|
||||
/// Map a resolved codepoint to its math-mode presentation form (Q#MS2).
|
||||
///
|
||||
/// TeX's convention, which is why uppercase Greek is deliberately upright:
|
||||
///
|
||||
/// | Class | Treatment |
|
||||
/// |---|---|
|
||||
/// | ASCII letters | math italic, with the U+210E hole for `h` |
|
||||
/// | Lowercase Greek | math italic |
|
||||
/// | Uppercase Greek | upright |
|
||||
/// | Digits, operators | upright |
|
||||
///
|
||||
/// Without this, `$x^2$` draws a roman `x` and `$\alpha x$` draws an upright
|
||||
/// α beside an italic 𝑥 — mixed styles inside one expression (framing F7,
|
||||
/// R2-2).
|
||||
#[must_use]
|
||||
pub fn math_italic(ch: char) -> char {
|
||||
// U+210E PLANCK CONSTANT is the italic `h`; the 1D4xx run has a hole
|
||||
// there, so mapping arithmetically would produce a reserved codepoint.
|
||||
if ch == 'h' {
|
||||
return '\u{210E}';
|
||||
}
|
||||
let mapped = match ch {
|
||||
'A'..='Z' => 0x1D434 + (ch as u32 - 'A' as u32),
|
||||
'a'..='z' => 0x1D44E + (ch as u32 - 'a' as u32),
|
||||
// Lowercase Greek α..ω → MATHEMATICAL ITALIC SMALL ALPHA..OMEGA.
|
||||
'\u{3B1}'..='\u{3C9}' => 0x1D6FC + (ch as u32 - 0x3B1),
|
||||
// The SYMBOL forms TeX's \epsilon and \phi resolve to sit OUTSIDE
|
||||
// that run, so they need explicit italic mappings — without them the
|
||||
// seed map's correction would render them upright beside italic
|
||||
// neighbours, which is the defect it was fixing.
|
||||
'\u{3F5}' => 0x1D716, // ϵ lunate epsilon
|
||||
'\u{3D5}' => 0x1D719, // ϕ phi symbol
|
||||
// Uppercase Greek, digits, operators: upright, per TeX.
|
||||
_ => return ch,
|
||||
};
|
||||
char::from_u32(mapped).unwrap_or(ch)
|
||||
}
|
||||
|
||||
/// A laid-out expression. Baseline at `y = 0`, positive `y` upward.
|
||||
///
|
||||
/// Q#MS6: items carry CHARACTERS, not glyph IDs. Layout still resolves glyph
|
||||
/// ids internally for advances and bounds — the boundary is on the emitted
|
||||
/// items, so each is drawable by the existing text machinery. Glyph-id items
|
||||
/// arrive with stretchy fences and big operators, both deferred.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct MathBox {
|
||||
pub width: f32,
|
||||
pub ascent: f32,
|
||||
pub descent: f32,
|
||||
pub items: Vec<MathItem>,
|
||||
}
|
||||
|
||||
/// One drawable piece of a [`MathBox`].
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub enum MathItem {
|
||||
/// A character at its own size, `baseline` relative to the box baseline.
|
||||
Glyph {
|
||||
ch: char,
|
||||
x: f32,
|
||||
baseline: f32,
|
||||
size_px: f32,
|
||||
},
|
||||
/// The fraction bar. Not a glyph — drawn on the existing quad pipeline.
|
||||
Rule {
|
||||
x: f32,
|
||||
y: f32,
|
||||
width: f32,
|
||||
thickness: f32,
|
||||
},
|
||||
}
|
||||
|
||||
impl MathItem {
|
||||
fn shifted(self, dx: f32, dy: f32) -> Self {
|
||||
match self {
|
||||
Self::Glyph {
|
||||
ch,
|
||||
x,
|
||||
baseline,
|
||||
size_px,
|
||||
} => Self::Glyph {
|
||||
ch,
|
||||
x: x + dx,
|
||||
baseline: baseline + dy,
|
||||
size_px,
|
||||
},
|
||||
Self::Rule {
|
||||
x,
|
||||
y,
|
||||
width,
|
||||
thickness,
|
||||
} => Self::Rule {
|
||||
x: x + dx,
|
||||
y: y + dy,
|
||||
width,
|
||||
thickness,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl MathBox {
|
||||
fn empty() -> Self {
|
||||
Self {
|
||||
width: 0.0,
|
||||
ascent: 0.0,
|
||||
descent: 0.0,
|
||||
items: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Absorb `other` at offset `(dx, dy)`, growing this box's extents.
|
||||
fn absorb(&mut self, other: &Self, dx: f32, dy: f32) {
|
||||
self.items
|
||||
.extend(other.items.iter().map(|item| item.shifted(dx, dy)));
|
||||
self.ascent = self.ascent.max(other.ascent + dy);
|
||||
self.descent = self.descent.max(other.descent - dy);
|
||||
}
|
||||
|
||||
/// Uniformly scale every extent and item (Q#MS10 fit-to-line).
|
||||
#[must_use]
|
||||
pub fn scaled(&self, factor: f32) -> Self {
|
||||
Self {
|
||||
width: self.width * factor,
|
||||
ascent: self.ascent * factor,
|
||||
descent: self.descent * factor,
|
||||
items: self
|
||||
.items
|
||||
.iter()
|
||||
.map(|item| match *item {
|
||||
MathItem::Glyph {
|
||||
ch,
|
||||
x,
|
||||
baseline,
|
||||
size_px,
|
||||
} => MathItem::Glyph {
|
||||
ch,
|
||||
x: x * factor,
|
||||
baseline: baseline * factor,
|
||||
size_px: size_px * factor,
|
||||
},
|
||||
MathItem::Rule {
|
||||
x,
|
||||
y,
|
||||
width,
|
||||
thickness,
|
||||
} => MathItem::Rule {
|
||||
x: x * factor,
|
||||
y: y * factor,
|
||||
width: width * factor,
|
||||
thickness: thickness * factor,
|
||||
},
|
||||
})
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The line-box height budget a math box must fit (Q#MS10), as
|
||||
/// `(above_baseline, below_baseline)` pixels.
|
||||
///
|
||||
/// Extracted rather than left inside a test: the draw pass must compute the
|
||||
/// SAME split the acceptance test asserts, and a duplicated derivation is
|
||||
/// exactly how a renderer and its test drift apart while both stay green.
|
||||
///
|
||||
/// The baseline is placed by the CODE font, not the math font — using the
|
||||
/// math font's own metrics understates the descent budget badly enough to
|
||||
/// make a plain fraction appear not to fit.
|
||||
#[must_use]
|
||||
pub fn line_box_budget(code_font: &Face<'_>, font_size_px: f32, line_height_px: f32) -> (f32, f32) {
|
||||
const MARGIN_PX: f32 = 1.0;
|
||||
let upem = f32::from(code_font.units_per_em().max(1));
|
||||
let baseline_from_top = f32::from(code_font.ascender()) * font_size_px / upem;
|
||||
let above = (baseline_from_top - MARGIN_PX).max(0.0);
|
||||
let below = (line_height_px - baseline_from_top - MARGIN_PX).max(0.0);
|
||||
(above, below)
|
||||
}
|
||||
|
||||
/// The smallest uniform scale the slice will apply before giving up (Q#MS10).
|
||||
pub const MIN_FIT_SCALE: f32 = 0.6;
|
||||
|
||||
/// Scale `boxed` to fit `(ascent_budget, descent_budget)`, or `None` when
|
||||
/// that would fall below [`MIN_FIT_SCALE`] — in which case Q#MS8 shows the
|
||||
/// raw source rather than overdrawing into the neighbouring line.
|
||||
#[must_use]
|
||||
pub fn fit_to_line(boxed: &MathBox, ascent_budget: f32, descent_budget: f32) -> Option<MathBox> {
|
||||
let need_up = boxed.ascent;
|
||||
let need_down = boxed.descent;
|
||||
let up = if need_up <= 0.0 {
|
||||
1.0
|
||||
} else {
|
||||
ascent_budget / need_up
|
||||
};
|
||||
let down = if need_down <= 0.0 {
|
||||
1.0
|
||||
} else {
|
||||
descent_budget / need_down
|
||||
};
|
||||
let scale = up.min(down).min(1.0);
|
||||
if scale < MIN_FIT_SCALE {
|
||||
return None;
|
||||
}
|
||||
if scale >= 1.0 {
|
||||
return Some(boxed.clone());
|
||||
}
|
||||
Some(boxed.scaled(scale))
|
||||
}
|
||||
|
||||
/// Lays a [`MathNode`] tree out against the bundled MATH font.
|
||||
pub struct MathLayout<'a> {
|
||||
face: Face<'a>,
|
||||
constants: MathConstants,
|
||||
}
|
||||
|
||||
impl<'a> MathLayout<'a> {
|
||||
/// Build a layout engine over font bytes.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`MathFontError`] when the face or its MATH table is unusable.
|
||||
pub fn new(bytes: &'a [u8]) -> Result<Self, MathFontError> {
|
||||
let face = Face::parse(bytes, 0).map_err(|_| MathFontError::Unparseable)?;
|
||||
let constants = MathConstants::from_font_bytes(bytes)?;
|
||||
Ok(Self { face, constants })
|
||||
}
|
||||
|
||||
/// Test-only introspection: the production draw path consumes the
|
||||
/// constants through `layout`, never raw.
|
||||
#[cfg(test)]
|
||||
#[must_use]
|
||||
pub fn constants(&self) -> MathConstants {
|
||||
self.constants
|
||||
}
|
||||
|
||||
/// Lay `node` out at `size_px`.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`MathFontError::UncoverableGlyph`] when the math font has no glyph
|
||||
/// for a character, so the caller can fall back to source (Q#MS8).
|
||||
pub fn layout(
|
||||
&self,
|
||||
node: &crate::math_parse::MathNode,
|
||||
size_px: f32,
|
||||
) -> Result<MathBox, MathFontError> {
|
||||
use crate::math_parse::MathNode;
|
||||
match node {
|
||||
MathNode::Char(ch) => self.layout_char(*ch, size_px),
|
||||
MathNode::Group(children) => {
|
||||
let mut out = MathBox::empty();
|
||||
let mut pen = 0.0;
|
||||
for child in children {
|
||||
let child_box = self.layout(child, size_px)?;
|
||||
out.absorb(&child_box, pen, 0.0);
|
||||
pen += child_box.width;
|
||||
}
|
||||
out.width = pen;
|
||||
Ok(out)
|
||||
}
|
||||
MathNode::Script { base, sub, sup } => {
|
||||
self.layout_script(base, sub.as_deref(), sup.as_deref(), size_px)
|
||||
}
|
||||
MathNode::Fraction { num, den } => self.layout_fraction(num, den, size_px),
|
||||
}
|
||||
}
|
||||
|
||||
fn layout_char(&self, ch: char, size_px: f32) -> Result<MathBox, MathFontError> {
|
||||
let presented = math_italic(ch);
|
||||
let upem = f32::from(self.constants.units_per_em.max(1));
|
||||
let (advance, ascent, descent) = self
|
||||
.face
|
||||
.glyph_index(presented)
|
||||
.map(|gid| {
|
||||
let adv = self
|
||||
.face
|
||||
.glyph_hor_advance(gid)
|
||||
.map_or(0.0, |a| f32::from(a) * size_px / upem);
|
||||
// Per-glyph bounds keep boxes tight, which is what makes a
|
||||
// fraction's extents honest; fall back to face metrics when
|
||||
// a glyph has no bounding box (e.g. a space).
|
||||
let (asc, desc) = self.face.glyph_bounding_box(gid).map_or_else(
|
||||
|| {
|
||||
(
|
||||
f32::from(self.face.ascender()) * size_px / upem,
|
||||
-f32::from(self.face.descender()) * size_px / upem,
|
||||
)
|
||||
},
|
||||
|bb| {
|
||||
(
|
||||
f32::from(bb.y_max) * size_px / upem,
|
||||
-f32::from(bb.y_min) * size_px / upem,
|
||||
)
|
||||
},
|
||||
);
|
||||
(adv, asc.max(0.0), desc.max(0.0))
|
||||
})
|
||||
// F3: no glyph means no honest box. Emitting a zero-width item
|
||||
// would draw tofu on top of the next character.
|
||||
.ok_or(MathFontError::UncoverableGlyph(ch))?;
|
||||
Ok(MathBox {
|
||||
width: advance,
|
||||
ascent,
|
||||
descent,
|
||||
items: vec![MathItem::Glyph {
|
||||
ch: presented,
|
||||
x: 0.0,
|
||||
baseline: 0.0,
|
||||
size_px,
|
||||
}],
|
||||
})
|
||||
}
|
||||
|
||||
fn layout_script(
|
||||
&self,
|
||||
base: &crate::math_parse::MathNode,
|
||||
sub: Option<&crate::math_parse::MathNode>,
|
||||
sup: Option<&crate::math_parse::MathNode>,
|
||||
size_px: f32,
|
||||
) -> Result<MathBox, MathFontError> {
|
||||
let base_box = self.layout(base, size_px)?;
|
||||
let script_px = size_px * self.constants.script_scale();
|
||||
let mut out = MathBox::empty();
|
||||
out.absorb(&base_box, 0.0, 0.0);
|
||||
let mut widest = base_box.width;
|
||||
if let Some(sup) = sup {
|
||||
let sup_box = self.layout(sup, script_px)?;
|
||||
let shift = self
|
||||
.constants
|
||||
.to_px(self.constants.superscript_shift_up, size_px);
|
||||
out.absorb(&sup_box, base_box.width, shift);
|
||||
widest = widest.max(base_box.width + sup_box.width);
|
||||
}
|
||||
if let Some(sub) = sub {
|
||||
let sub_box = self.layout(sub, script_px)?;
|
||||
let shift = self
|
||||
.constants
|
||||
.to_px(self.constants.subscript_shift_down, size_px);
|
||||
out.absorb(&sub_box, base_box.width, -shift);
|
||||
widest = widest.max(base_box.width + sub_box.width);
|
||||
}
|
||||
out.width = widest;
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn layout_fraction(
|
||||
&self,
|
||||
num: &crate::math_parse::MathNode,
|
||||
den: &crate::math_parse::MathNode,
|
||||
size_px: f32,
|
||||
) -> Result<MathBox, MathFontError> {
|
||||
// TeX sets an inline \frac's operands one style down, which is also
|
||||
// what the parent framing's Tier 3 specifies (70%). It is load-bearing
|
||||
// for Q#MS10: full-size operands would not fit the line at all.
|
||||
let operand_px = size_px * self.constants.script_scale();
|
||||
let num_box = self.layout(num, operand_px)?;
|
||||
let den_box = self.layout(den, operand_px)?;
|
||||
let axis = self.constants.to_px(self.constants.axis_height, size_px);
|
||||
let thickness = self
|
||||
.constants
|
||||
.to_px(self.constants.fraction_rule_thickness, size_px)
|
||||
.max(1.0);
|
||||
// F4: the gaps come from the MATH table, not a guess. An earlier
|
||||
// revision used `thickness * 2.0`, which made fractions roughly twice
|
||||
// as airy as the font specifies and inflated the height budget the
|
||||
// fit-to-line scale is measured against.
|
||||
let num_gap = self
|
||||
.constants
|
||||
.to_px(self.constants.fraction_numerator_gap_min, size_px)
|
||||
.max(thickness);
|
||||
let den_gap = self
|
||||
.constants
|
||||
.to_px(self.constants.fraction_denominator_gap_min, size_px)
|
||||
.max(thickness);
|
||||
|
||||
let width = num_box.width.max(den_box.width);
|
||||
let mut out = MathBox::empty();
|
||||
// Numerator sits above the bar, denominator below it.
|
||||
let num_baseline = axis + thickness / 2.0 + num_gap + num_box.descent;
|
||||
let den_baseline = axis - thickness / 2.0 - den_gap - den_box.ascent;
|
||||
out.absorb(&num_box, (width - num_box.width) / 2.0, num_baseline);
|
||||
out.absorb(&den_box, (width - den_box.width) / 2.0, den_baseline);
|
||||
out.items.push(MathItem::Rule {
|
||||
x: 0.0,
|
||||
y: axis,
|
||||
width,
|
||||
thickness,
|
||||
});
|
||||
out.ascent = out.ascent.max(axis + thickness / 2.0);
|
||||
out.descent = out.descent.max(-(axis - thickness / 2.0));
|
||||
out.width = width;
|
||||
Ok(out)
|
||||
}
|
||||
}
|
||||
|
||||
/// Spacer text reserving `width_px`, quantized UP to whole space advances.
|
||||
///
|
||||
/// Q#MS4 / B1': a `RichChunk`'s only width is its text, so a suppressed math
|
||||
/// span reserves room the way `SourceTab` does — with spaces. Quantizing up
|
||||
/// is deliberate: it keeps the projection grid-aligned with the surrounding
|
||||
/// monospace text and keeps hit runs integral, at the cost of up to one
|
||||
/// advance of slack on the right of the box.
|
||||
#[must_use]
|
||||
pub fn spacer_for_width(width_px: f32, space_advance_px: f32) -> String {
|
||||
if !width_px.is_finite() || width_px <= 0.0 || space_advance_px <= 0.0 {
|
||||
return String::new();
|
||||
}
|
||||
let n = (width_px / space_advance_px).ceil();
|
||||
// Guard the cast: a pathological advance must not mint a giant string.
|
||||
let n = n.clamp(0.0, 4096.0) as usize;
|
||||
" ".repeat(n)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
use crate::math_parse::parse;
|
||||
|
||||
/// Framing acceptance 16 (provenance half): the GUST licence ships
|
||||
/// beside the font, names itself, and is not the OFL that covers
|
||||
/// `JetBrains` Mono.
|
||||
#[test]
|
||||
fn bundled_licences_are_distinct_and_name_their_terms() {
|
||||
let gust = include_str!("../fonts/GUST-FONT-LICENSE.txt");
|
||||
let ofl = include_str!("../fonts/OFL.txt");
|
||||
assert!(gust.contains("GUST Font License"));
|
||||
assert!(gust.contains("LaTeX Project Public License"));
|
||||
assert!(!ofl.contains("GUST"));
|
||||
assert_ne!(gust, ofl);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tex_symbol_greek_forms_are_italicised_too() {
|
||||
// F5's trap: correcting the seed map alone leaves these upright,
|
||||
// because they sit outside the U+03B1..03C9 run.
|
||||
assert_eq!(math_italic('\u{3F5}'), '\u{1D716}');
|
||||
assert_eq!(math_italic('\u{3D5}'), '\u{1D719}');
|
||||
let face = Face::parse(LATIN_MODERN_MATH, 0).expect("face");
|
||||
for ch in ['\u{3F5}', '\u{3D5}'] {
|
||||
assert!(
|
||||
face.glyph_index(math_italic(ch)).is_some(),
|
||||
"no glyph for the italic form of U+{:04X}",
|
||||
ch as u32
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spacer_quantizes_up_to_whole_advances() {
|
||||
// Exactly two advances stays two; a sliver over rounds up, so the
|
||||
// box never overlaps the text that follows it.
|
||||
assert_eq!(spacer_for_width(20.0, 10.0).len(), 2);
|
||||
assert_eq!(spacer_for_width(20.1, 10.0).len(), 3);
|
||||
assert_eq!(spacer_for_width(0.1, 10.0).len(), 1);
|
||||
// Degenerate inputs reserve nothing rather than panicking or
|
||||
// minting an enormous string.
|
||||
assert!(spacer_for_width(0.0, 10.0).is_empty());
|
||||
assert!(spacer_for_width(-5.0, 10.0).is_empty());
|
||||
assert!(spacer_for_width(10.0, 0.0).is_empty());
|
||||
assert!(spacer_for_width(f32::NAN, 10.0).is_empty());
|
||||
assert!(spacer_for_width(f32::INFINITY, 10.0).is_empty());
|
||||
assert!(spacer_for_width(1e9, 0.001).len() <= 4096);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_real_box_reserves_at_least_its_own_width() {
|
||||
let boxed = lay(r"\frac{a}{b}", crate::BASE_CODE_FONT_SIZE);
|
||||
let advance = 9.6_f32; // a plausible monospace advance at 16 px
|
||||
let spacer = spacer_for_width(boxed.width, advance);
|
||||
let reserved = spacer.len() as f32 * advance;
|
||||
assert!(
|
||||
reserved >= boxed.width,
|
||||
"reserved {reserved} must cover box width {}",
|
||||
boxed.width
|
||||
);
|
||||
assert!(
|
||||
reserved - boxed.width < advance,
|
||||
"slack stays under one advance"
|
||||
);
|
||||
}
|
||||
|
||||
fn engine() -> MathLayout<'static> {
|
||||
MathLayout::new(LATIN_MODERN_MATH).expect("bundled font")
|
||||
}
|
||||
|
||||
fn lay(src: &str, size: f32) -> MathBox {
|
||||
let node = parse(src).expect("parses");
|
||||
engine().layout(&node, size).expect("lays out")
|
||||
}
|
||||
|
||||
/// F3 — a codepoint the math font cannot draw REFUSES, so the caller can
|
||||
/// fall back to source (Q#MS8) instead of drawing tofu at zero advance
|
||||
/// on top of the next character.
|
||||
#[test]
|
||||
fn an_uncoverable_character_refuses_layout_instead_of_emitting_a_void() {
|
||||
let node = parse("x日").expect("parses — coverage is layout's problem");
|
||||
assert_eq!(
|
||||
engine().layout(&node, 16.0),
|
||||
Err(MathFontError::UncoverableGlyph('日'))
|
||||
);
|
||||
// The covered neighbour on its own still lays out.
|
||||
assert!(engine().layout(&parse("x").unwrap(), 16.0).is_ok());
|
||||
}
|
||||
|
||||
/// Framing acceptance 3, including its bite: the MATH constant must be
|
||||
/// READ, not hardcoded.
|
||||
#[test]
|
||||
fn superscript_is_raised_and_scaled_from_the_math_table() {
|
||||
let plain = lay("x", 16.0);
|
||||
let script = lay("x^2", 16.0);
|
||||
assert!(script.width > plain.width, "the 2 adds width");
|
||||
assert!(
|
||||
script.ascent > plain.ascent,
|
||||
"superscript must raise the box: {} vs {}",
|
||||
script.ascent,
|
||||
plain.ascent
|
||||
);
|
||||
let two = script
|
||||
.items
|
||||
.iter()
|
||||
.find_map(|i| match *i {
|
||||
MathItem::Glyph {
|
||||
ch: '2',
|
||||
baseline,
|
||||
size_px,
|
||||
..
|
||||
} => Some((baseline, size_px)),
|
||||
_ => None,
|
||||
})
|
||||
.expect("the 2 is emitted");
|
||||
assert!(two.0 > 0.0, "raised above baseline: {}", two.0);
|
||||
assert!(two.1 < 16.0, "scaled down: {}", two.1);
|
||||
|
||||
// Bite: with the script scale stubbed to 100%, the box changes —
|
||||
// proving the constant is consulted rather than assumed.
|
||||
let c = engine().constants();
|
||||
assert!(
|
||||
c.script_percent_scale_down < 100,
|
||||
"font advertises a real script scale ({}%), so 100% is a \
|
||||
meaningful stub",
|
||||
c.script_percent_scale_down
|
||||
);
|
||||
let stubbed = MathConstants {
|
||||
script_percent_scale_down: 100,
|
||||
..c
|
||||
};
|
||||
assert!(
|
||||
(stubbed.script_scale() - c.script_scale()).abs() > 0.01,
|
||||
"stubbing the constant must change the scale actually used"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn subscript_drops_below_the_baseline() {
|
||||
let script = lay("x_i", 16.0);
|
||||
let i = script
|
||||
.items
|
||||
.iter()
|
||||
.find_map(|item| match *item {
|
||||
MathItem::Glyph { ch, baseline, .. } if ch == math_italic('i') => Some(baseline),
|
||||
_ => None,
|
||||
})
|
||||
.expect("the i is emitted");
|
||||
assert!(i < 0.0, "subscript sits below the baseline: {i}");
|
||||
assert!(script.descent > lay("x", 16.0).descent);
|
||||
}
|
||||
|
||||
/// Framing acceptance 4.
|
||||
#[test]
|
||||
fn fraction_stacks_operands_around_a_rule_at_the_axis() {
|
||||
let frac = lay(r"\frac{a}{b}", 16.0);
|
||||
let rule = frac
|
||||
.items
|
||||
.iter()
|
||||
.find_map(|item| match *item {
|
||||
MathItem::Rule {
|
||||
y,
|
||||
width,
|
||||
thickness,
|
||||
..
|
||||
} => Some((y, width, thickness)),
|
||||
MathItem::Glyph { .. } => None,
|
||||
})
|
||||
.expect("a fraction draws a rule");
|
||||
assert!(rule.0 > 0.0, "rule sits at the math axis, above baseline");
|
||||
assert!(rule.2 > 0.0 && rule.1 > 0.0);
|
||||
|
||||
let mut above = 0;
|
||||
let mut below = 0;
|
||||
for item in &frac.items {
|
||||
if let MathItem::Glyph { baseline, .. } = *item {
|
||||
if baseline > rule.0 {
|
||||
above += 1;
|
||||
} else if baseline < rule.0 {
|
||||
below += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
assert_eq!((above, below), (1, 1), "one operand each side of the bar");
|
||||
assert!(frac.ascent > 0.0 && frac.descent > 0.0);
|
||||
}
|
||||
|
||||
/// F1 / B6 — the height budget, computed rather than guessed.
|
||||
///
|
||||
/// The round-2 review warned that acceptance 12's fallback case must be
|
||||
/// derived by computation or it would "surprise-pass by rendering". It
|
||||
/// was right, and rev 3's guess was wrong: a doubly-nested fraction still
|
||||
/// fits. This test derives the budget the way Q#MS10 defines it — from
|
||||
/// the LINE BOX, whose baseline the CODE font places — and then searches
|
||||
/// for the depth that actually trips the floor, so the case can never
|
||||
/// drift out from under the acceptance criterion.
|
||||
#[test]
|
||||
fn fit_to_line_admits_real_fractions_and_finds_the_true_fallback_depth() {
|
||||
// Q#MS10: the budget is the line box less a one-pixel margin, split
|
||||
// at the text baseline. The baseline is where the CODE font puts it
|
||||
// (JetBrains Mono at BASE_CODE_FONT_SIZE inside BASE_CODE_LINE_HEIGHT),
|
||||
// NOT where the math font's own metrics would.
|
||||
let code = Face::parse(crate::JETBRAINS_MONO, 0).expect("code face");
|
||||
let (asc_budget, desc_budget) = line_box_budget(
|
||||
&code,
|
||||
crate::BASE_CODE_FONT_SIZE,
|
||||
crate::BASE_CODE_LINE_HEIGHT,
|
||||
);
|
||||
assert!(
|
||||
asc_budget > 0.0 && desc_budget > 0.0,
|
||||
"budget must be positive: {asc_budget} / {desc_budget}"
|
||||
);
|
||||
|
||||
let scale_of = |src: &str| {
|
||||
let boxed = lay(src, crate::BASE_CODE_FONT_SIZE);
|
||||
let up = asc_budget / boxed.ascent.max(f32::EPSILON);
|
||||
let down = desc_budget / boxed.descent.max(f32::EPSILON);
|
||||
(up.min(down).min(1.0), boxed)
|
||||
};
|
||||
|
||||
// The flagship cases must RENDER, not fall back (B6).
|
||||
for src in [r"\frac{a}{b}", r"\frac{x^2}{y}", "x^2", r"\alpha x"] {
|
||||
let (scale, boxed) = scale_of(src);
|
||||
eprintln!(
|
||||
"{src}: asc={:.2} desc={:.2} scale={scale:.3}",
|
||||
boxed.ascent, boxed.descent
|
||||
);
|
||||
assert!(
|
||||
scale >= MIN_FIT_SCALE,
|
||||
"{src} must render, not fall back: scale {scale:.3} < {MIN_FIT_SCALE}"
|
||||
);
|
||||
assert!(fit_to_line(&boxed, asc_budget, desc_budget).is_some());
|
||||
}
|
||||
|
||||
// Now FIND the depth that trips the floor rather than assuming one.
|
||||
// Nest fractions until the scale drops below it.
|
||||
let mut src = String::from(r"\frac{a}{b}");
|
||||
let mut depth = 1;
|
||||
let tripped = loop {
|
||||
let (scale, _) = scale_of(&src);
|
||||
eprintln!("depth {depth}: scale={scale:.3}");
|
||||
if scale < MIN_FIT_SCALE {
|
||||
break Some((depth, src.clone()));
|
||||
}
|
||||
// Headroom above the real boundary (5 with the round-3 MATH
|
||||
// gaps): if a metric shift pushed the boundary past this bound,
|
||||
// the expect below would fire with a message reading "the floor
|
||||
// is dead code" when the truth is "the boundary moved past the
|
||||
// search". Keep the bound comfortably above the boundary.
|
||||
if depth >= 8 {
|
||||
break None;
|
||||
}
|
||||
src = format!(r"\frac{{{src}}}{{c}}");
|
||||
depth += 1;
|
||||
};
|
||||
let (depth, deep_src) = tripped.expect(
|
||||
"some nesting depth must exceed the floor, or Q#MS10's fallback \
|
||||
arm is unreachable and the floor is dead code",
|
||||
);
|
||||
assert!(
|
||||
depth > 2,
|
||||
"rev 3 guessed a doubly-nested fraction would fall back; the real \
|
||||
depth is {depth}, so acceptance 12 must use that case"
|
||||
);
|
||||
assert!(
|
||||
fit_to_line(
|
||||
&lay(&deep_src, crate::BASE_CODE_FONT_SIZE),
|
||||
asc_budget,
|
||||
desc_budget
|
||||
)
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fitting_scales_extents_and_items_together() {
|
||||
let boxed = lay(r"\frac{a}{b}", 16.0);
|
||||
let half = boxed.scaled(0.5);
|
||||
assert!((half.ascent - boxed.ascent * 0.5).abs() < 0.001);
|
||||
assert!((half.width - boxed.width * 0.5).abs() < 0.001);
|
||||
for (before, after) in boxed.items.iter().zip(half.items.iter()) {
|
||||
if let (MathItem::Glyph { size_px: b, .. }, MathItem::Glyph { size_px: a, .. }) =
|
||||
(before, after)
|
||||
{
|
||||
assert!((a - b * 0.5).abs() < 0.001, "glyph size scales too");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_group_advances_the_pen_left_to_right() {
|
||||
let boxed = lay("abc", 16.0);
|
||||
let xs: Vec<f32> = boxed
|
||||
.items
|
||||
.iter()
|
||||
.filter_map(|item| match *item {
|
||||
MathItem::Glyph { x, .. } => Some(x),
|
||||
MathItem::Rule { .. } => None,
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(xs.len(), 3);
|
||||
assert!(xs[0] < xs[1] && xs[1] < xs[2], "left to right: {xs:?}");
|
||||
assert!(boxed.width > xs[2], "width covers the last advance");
|
||||
}
|
||||
|
||||
/// B5 — `ttf-parser` supplies every constant the subset needs, from the
|
||||
/// bundled font. This is the bet that would sink Tier 3 if false, so it
|
||||
/// runs against the real embedded bytes rather than a fixture.
|
||||
#[test]
|
||||
fn bundled_font_yields_every_math_constant_the_subset_needs() {
|
||||
let c = MathConstants::from_font_bytes(LATIN_MODERN_MATH)
|
||||
.expect("bundled Latin Modern Math must expose MATH constants");
|
||||
assert_eq!(c.units_per_em, 1000, "LM Math is a 1000 upem font");
|
||||
assert!(c.axis_height > 0, "axis height: {}", c.axis_height);
|
||||
assert!(
|
||||
(50..=100).contains(&c.script_percent_scale_down),
|
||||
"script scale percent out of range: {}",
|
||||
c.script_percent_scale_down
|
||||
);
|
||||
assert!(c.superscript_shift_up > 0);
|
||||
assert!(c.subscript_shift_down > 0);
|
||||
assert!(c.fraction_rule_thickness > 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_text_font_without_a_math_table_is_rejected_not_defaulted() {
|
||||
// Q#MS7: a font with no MATH table must surface, not silently
|
||||
// produce plausible-looking zeros.
|
||||
let err = MathConstants::from_font_bytes(crate::JETBRAINS_MONO)
|
||||
.expect_err("JetBrains Mono has no MATH table");
|
||||
assert_eq!(err, MathFontError::NoMathTable);
|
||||
assert_eq!(
|
||||
MathConstants::from_font_bytes(b"not a font"),
|
||||
Err(MathFontError::Unparseable)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn font_units_convert_to_pixels_against_upem() {
|
||||
let c = MathConstants::from_font_bytes(LATIN_MODERN_MATH).expect("constants");
|
||||
// Half an em at 16 px is 8 px.
|
||||
let half_em = i16::try_from(c.units_per_em / 2).expect("fits");
|
||||
assert!((c.to_px(half_em, 16.0) - 8.0).abs() < 0.01);
|
||||
let scale = c.script_scale();
|
||||
assert!((0.5..=1.0).contains(&scale), "script scale: {scale}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn math_italic_follows_tex_convention_including_the_planck_hole() {
|
||||
// Framing acceptance 13.
|
||||
assert_eq!(math_italic('x'), '\u{1D465}');
|
||||
assert_eq!(math_italic('A'), '\u{1D434}');
|
||||
// The 1D4xx run has a hole at italic `h`; arithmetic would land on a
|
||||
// reserved codepoint, so `h` maps to U+210E instead.
|
||||
assert_eq!(math_italic('h'), '\u{210E}');
|
||||
// Lowercase Greek is italic...
|
||||
assert_eq!(math_italic('α'), '\u{1D6FC}');
|
||||
assert_eq!(math_italic('ω'), '\u{1D714}');
|
||||
// ...uppercase Greek is NOT (TeX convention, deliberate).
|
||||
assert_eq!(math_italic('Γ'), 'Γ');
|
||||
assert_eq!(math_italic('Ω'), 'Ω');
|
||||
// Digits and operators stay upright.
|
||||
assert_eq!(math_italic('2'), '2');
|
||||
assert_eq!(math_italic('+'), '+');
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_italic_mapping_lands_on_a_real_glyph_in_the_bundled_font() {
|
||||
// A mapping that produces codepoints the bundled font cannot draw
|
||||
// would render tofu — worse than the roman fallback it replaced.
|
||||
let face = Face::parse(LATIN_MODERN_MATH, 0).expect("parse bundled font");
|
||||
let sample = "abhxyzABXYZαβωΓΩ0129+=";
|
||||
for ch in sample.chars() {
|
||||
let mapped = math_italic(ch);
|
||||
assert!(
|
||||
face.glyph_index(mapped).is_some(),
|
||||
"no glyph for {ch:?} -> {mapped:?} (U+{:04X})",
|
||||
mapped as u32
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,632 @@
|
|||
//! LaTeX math-mode parser for the first inline-math slice.
|
||||
//!
|
||||
//! Framing: `docs/inline-math-slice-framing.md` (rev 3), Q#MS2. This parses
|
||||
//! the deliberately small subset the slice renders — characters, groups,
|
||||
//! sub/superscripts and fractions — and nothing else. Every other LaTeX
|
||||
//! construct is an error, which Q#MS8 turns into "show the raw source".
|
||||
//!
|
||||
//! The AST is *semantic*, not presentational: `\alpha` resolves to `'α'`
|
||||
//! here, but the math-italic mapping (Q#MS2's table) belongs to layout, which
|
||||
//! is where a codepoint becomes a glyph. Keeping the split here means the AST
|
||||
//! matches what the user wrote, and a future non-italic style context does
|
||||
//! not have to unpick a decision the parser baked in.
|
||||
|
||||
/// One node of the slice's math subset (Q#MS2).
|
||||
///
|
||||
/// Rev 2 of the framing folded `Symbol` into `Char`: both carried a `char`,
|
||||
/// and after symbol resolution layout cannot act on the difference.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum MathNode {
|
||||
/// A resolved codepoint: `x`, `2`, `+`, `α`.
|
||||
Char(char),
|
||||
/// A braced group, or the top-level expression.
|
||||
Group(Vec<MathNode>),
|
||||
/// A base with optional sub- and superscript.
|
||||
Script {
|
||||
base: Box<MathNode>,
|
||||
sub: Option<Box<MathNode>>,
|
||||
sup: Option<Box<MathNode>>,
|
||||
},
|
||||
/// `\frac{num}{den}`.
|
||||
Fraction {
|
||||
num: Box<MathNode>,
|
||||
den: Box<MathNode>,
|
||||
},
|
||||
}
|
||||
|
||||
/// Why a span could not be parsed. Q#MS8 renders the raw source for all of
|
||||
/// these; the variants exist so tests can assert *which* rejection fired.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum MathParseError {
|
||||
/// The span held no math (`$$` after delimiter stripping).
|
||||
Empty,
|
||||
/// A `{` with no matching `}`, or a stray `}`.
|
||||
UnbalancedBrace,
|
||||
/// A control sequence outside the subset, e.g. `\sqrt`.
|
||||
UnknownCommand(String),
|
||||
/// `\frac` without two braced arguments.
|
||||
MalformedCommand(&'static str),
|
||||
/// `^` or `_` with nothing to attach to, or given twice for one base.
|
||||
MalformedScript(&'static str),
|
||||
/// A `$` inside the span: the delimiters are the caller's business, and
|
||||
/// a bare one here means detection handed us something it should not
|
||||
/// have (framing acceptance 15 — `$$x$$` degrades through this path).
|
||||
UnexpectedDollar,
|
||||
}
|
||||
|
||||
/// Greek seed map (Q#MS2). Deliberately partial — growing it is mechanical.
|
||||
const GREEK: &[(&str, char)] = &[
|
||||
("alpha", 'α'),
|
||||
("beta", 'β'),
|
||||
("gamma", 'γ'),
|
||||
("delta", 'δ'),
|
||||
// TeX's \epsilon is LUNATE (U+03F5); U+03B5 is \varepsilon.
|
||||
("epsilon", '\u{3F5}'),
|
||||
("zeta", 'ζ'),
|
||||
("eta", 'η'),
|
||||
("theta", 'θ'),
|
||||
("iota", 'ι'),
|
||||
("kappa", 'κ'),
|
||||
("lambda", 'λ'),
|
||||
("mu", 'μ'),
|
||||
("nu", 'ν'),
|
||||
("xi", 'ξ'),
|
||||
("pi", 'π'),
|
||||
("rho", 'ρ'),
|
||||
("sigma", 'σ'),
|
||||
("tau", 'τ'),
|
||||
("upsilon", 'υ'),
|
||||
// TeX's \phi is U+03D5; U+03C6 is \varphi.
|
||||
("phi", '\u{3D5}'),
|
||||
("chi", 'χ'),
|
||||
("psi", 'ψ'),
|
||||
("omega", 'ω'),
|
||||
("Gamma", 'Γ'),
|
||||
("Delta", 'Δ'),
|
||||
("Theta", 'Θ'),
|
||||
("Lambda", 'Λ'),
|
||||
("Xi", 'Ξ'),
|
||||
("Pi", 'Π'),
|
||||
("Sigma", 'Σ'),
|
||||
("Upsilon", 'Υ'),
|
||||
("Phi", 'Φ'),
|
||||
("Psi", 'Ψ'),
|
||||
("Omega", 'Ω'),
|
||||
];
|
||||
|
||||
/// Parse the *interior* of a math span — delimiters already stripped.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns [`MathParseError`] for anything outside the Q#MS2 subset.
|
||||
pub fn parse(source: &str) -> Result<MathNode, MathParseError> {
|
||||
let mut parser = Parser {
|
||||
chars: source.chars().collect(),
|
||||
pos: 0,
|
||||
};
|
||||
let nodes = parser.parse_sequence(None)?;
|
||||
if parser.pos < parser.chars.len() {
|
||||
// Only a stray `}` can stop the top-level sequence early.
|
||||
return Err(MathParseError::UnbalancedBrace);
|
||||
}
|
||||
if nodes.is_empty() {
|
||||
return Err(MathParseError::Empty);
|
||||
}
|
||||
Ok(MathNode::Group(nodes))
|
||||
}
|
||||
|
||||
struct Parser {
|
||||
chars: Vec<char>,
|
||||
pos: usize,
|
||||
}
|
||||
|
||||
impl Parser {
|
||||
fn peek(&self) -> Option<char> {
|
||||
self.chars.get(self.pos).copied()
|
||||
}
|
||||
|
||||
fn bump(&mut self) -> Option<char> {
|
||||
let ch = self.peek();
|
||||
if ch.is_some() {
|
||||
self.pos += 1;
|
||||
}
|
||||
ch
|
||||
}
|
||||
|
||||
/// Parse until `close` (or end of input when `None`).
|
||||
fn parse_sequence(&mut self, close: Option<char>) -> Result<Vec<MathNode>, MathParseError> {
|
||||
let mut out: Vec<MathNode> = Vec::new();
|
||||
loop {
|
||||
// Whitespace is insignificant in math mode, and it must be
|
||||
// skipped HERE rather than inside `parse_atom`: the `^`/`_`
|
||||
// dispatch below happens before atoms are read, so leaving a
|
||||
// space in front of a marker would make `x ^ 2` parse the caret
|
||||
// as a literal character.
|
||||
while self.peek().is_some_and(char::is_whitespace) {
|
||||
self.pos += 1;
|
||||
}
|
||||
match self.peek() {
|
||||
None => {
|
||||
if close.is_some() {
|
||||
return Err(MathParseError::UnbalancedBrace);
|
||||
}
|
||||
return Ok(out);
|
||||
}
|
||||
Some(ch) if Some(ch) == close => {
|
||||
self.pos += 1;
|
||||
return Ok(out);
|
||||
}
|
||||
// A `}` we were not asked to stop at is unbalanced.
|
||||
Some('}') => return Err(MathParseError::UnbalancedBrace),
|
||||
Some('$') => return Err(MathParseError::UnexpectedDollar),
|
||||
Some('^' | '_') => {
|
||||
let base = out.pop().ok_or(MathParseError::MalformedScript(
|
||||
"sub/superscript with no base",
|
||||
))?;
|
||||
out.push(self.parse_scripts(base)?);
|
||||
}
|
||||
Some(_) => {
|
||||
let atom = self.parse_atom()?;
|
||||
out.push(atom);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One atom: a group, a command, or a single character.
|
||||
fn parse_atom(&mut self) -> Result<MathNode, MathParseError> {
|
||||
match self.bump() {
|
||||
Some('{') => Ok(MathNode::Group(self.parse_sequence(Some('}'))?)),
|
||||
Some('\\') => self.parse_command(),
|
||||
Some(ch) => Ok(MathNode::Char(ch)),
|
||||
None => Err(MathParseError::MalformedCommand("unexpected end of input")),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_command(&mut self) -> Result<MathNode, MathParseError> {
|
||||
let mut name = String::new();
|
||||
while let Some(ch) = self.peek() {
|
||||
if ch.is_ascii_alphabetic() {
|
||||
name.push(ch);
|
||||
self.pos += 1;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if name.is_empty() {
|
||||
// `\$`, `\{` … — an escaped literal.
|
||||
return match self.bump() {
|
||||
Some(ch) => Ok(MathNode::Char(ch)),
|
||||
None => Err(MathParseError::MalformedCommand("trailing backslash")),
|
||||
};
|
||||
}
|
||||
if name == "frac" {
|
||||
let num = self.parse_required_group("\\frac numerator")?;
|
||||
let den = self.parse_required_group("\\frac denominator")?;
|
||||
return Ok(MathNode::Fraction {
|
||||
num: Box::new(num),
|
||||
den: Box::new(den),
|
||||
});
|
||||
}
|
||||
if let Some((_, ch)) = GREEK.iter().find(|(n, _)| *n == name) {
|
||||
return Ok(MathNode::Char(*ch));
|
||||
}
|
||||
Err(MathParseError::UnknownCommand(name))
|
||||
}
|
||||
|
||||
/// A `{…}` argument, skipping leading whitespace.
|
||||
fn parse_required_group(&mut self, what: &'static str) -> Result<MathNode, MathParseError> {
|
||||
while self.peek().is_some_and(char::is_whitespace) {
|
||||
self.pos += 1;
|
||||
}
|
||||
match self.peek() {
|
||||
Some('{') => {
|
||||
self.pos += 1;
|
||||
Ok(MathNode::Group(self.parse_sequence(Some('}'))?))
|
||||
}
|
||||
_ => Err(MathParseError::MalformedCommand(what)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Attach `^`/`_` to `base`, in either order, at most one each.
|
||||
fn parse_scripts(&mut self, base: MathNode) -> Result<MathNode, MathParseError> {
|
||||
let mut sub: Option<Box<MathNode>> = None;
|
||||
let mut sup: Option<Box<MathNode>> = None;
|
||||
loop {
|
||||
// Whitespace is insignificant, here too: without this skip
|
||||
// `x^2 _i` builds a nested Script instead of one merged double
|
||||
// script (drawing the subscript displaced right by the
|
||||
// superscript's width), and `x^2 ^3` parses where TeX errors.
|
||||
let resume = self.pos;
|
||||
while self.peek().is_some_and(char::is_whitespace) {
|
||||
self.pos += 1;
|
||||
}
|
||||
let Some(marker @ ('^' | '_')) = self.peek() else {
|
||||
self.pos = resume;
|
||||
break;
|
||||
};
|
||||
self.pos += 1;
|
||||
let slot = self.parse_script_operand()?;
|
||||
match marker {
|
||||
'^' if sup.is_some() => {
|
||||
return Err(MathParseError::MalformedScript("double superscript"));
|
||||
}
|
||||
'_' if sub.is_some() => {
|
||||
return Err(MathParseError::MalformedScript("double subscript"));
|
||||
}
|
||||
'^' => sup = Some(Box::new(slot)),
|
||||
_ => sub = Some(Box::new(slot)),
|
||||
}
|
||||
}
|
||||
Ok(MathNode::Script {
|
||||
base: Box::new(base),
|
||||
sub,
|
||||
sup,
|
||||
})
|
||||
}
|
||||
|
||||
/// The operand of `^`/`_`: a braced group, or exactly one atom.
|
||||
fn parse_script_operand(&mut self) -> Result<MathNode, MathParseError> {
|
||||
while self.peek().is_some_and(char::is_whitespace) {
|
||||
self.pos += 1;
|
||||
}
|
||||
match self.peek() {
|
||||
None | Some('^' | '_' | '}') => {
|
||||
Err(MathParseError::MalformedScript("script with no operand"))
|
||||
}
|
||||
Some(_) => self.parse_atom(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One detected inline span, as byte offsets into the scanned line.
|
||||
///
|
||||
/// `start`/`end` bracket the WHOLE span including both `$` delimiters, which
|
||||
/// is what Q#MS4 suppresses; [`Self::interior`] is what the parser sees.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct MathSpan {
|
||||
pub start: usize,
|
||||
pub end: usize,
|
||||
}
|
||||
|
||||
impl MathSpan {
|
||||
/// Byte range of the math source between the delimiters.
|
||||
#[must_use]
|
||||
pub fn interior(self) -> std::ops::Range<usize> {
|
||||
self.start + 1..self.end - 1
|
||||
}
|
||||
}
|
||||
|
||||
/// Find inline `$…$` spans in ONE line (Q#MS3).
|
||||
///
|
||||
/// Spans never cross a newline: chunking is per line and the visible slice is
|
||||
/// line-ranged, so single-line spans are what keep visible-slice-scoped
|
||||
/// scanning stable under scroll. Callers pass one line at a time.
|
||||
///
|
||||
/// Currency guards are mandatory, not a refinement (framing F5). Without
|
||||
/// them `prices are $5 and $6 today` pairs the two `$` and renders `5 and `
|
||||
/// as math — in exactly the grammar-less prose buffers this scanner targets.
|
||||
/// Pandoc's rule:
|
||||
///
|
||||
/// - an opening `$` must be followed by a non-space;
|
||||
/// - a closing `$` must be preceded by a non-space and not followed by a digit;
|
||||
/// - `\$` is an escape and neither opens nor closes.
|
||||
#[must_use]
|
||||
pub fn detect_math_spans(line: &str) -> Vec<MathSpan> {
|
||||
let bytes = line.as_bytes();
|
||||
let mut spans = Vec::new();
|
||||
let mut i = 0;
|
||||
let mut open: Option<usize> = None;
|
||||
while i < bytes.len() {
|
||||
if bytes[i] == b'\\' {
|
||||
// Skip the escaped byte: `\$` is literal, so it can neither open
|
||||
// nor close. Stepping two also stops `\\$` from being read as an
|
||||
// escape of the dollar.
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
if bytes[i] != b'$' {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
if bytes.get(i + 1) == Some(&b'$') {
|
||||
// `$$` is display math, which this slice defers. It is NOT two
|
||||
// inline delimiters: reading it that way makes `$$x$$` match the
|
||||
// inner `$x$`, which parses, so the span would half-render as
|
||||
// math with a stray `$` on each side. Acceptance 15 requires it
|
||||
// to degrade to source, so `$$` is opaque — it neither opens nor
|
||||
// closes, and abandons any pending opener.
|
||||
i += 2;
|
||||
open = None;
|
||||
continue;
|
||||
}
|
||||
match open {
|
||||
None => {
|
||||
// Opener: next byte must exist and be a non-space.
|
||||
let opens = bytes
|
||||
.get(i + 1)
|
||||
.is_some_and(|b| !b.is_ascii_whitespace() && *b != b'$');
|
||||
if opens {
|
||||
open = Some(i);
|
||||
}
|
||||
}
|
||||
Some(start) => {
|
||||
let prev_ok = i > start + 1 && !bytes[i - 1].is_ascii_whitespace();
|
||||
let next_ok = bytes.get(i + 1).is_none_or(|b| !b.is_ascii_digit());
|
||||
if prev_ok && next_ok {
|
||||
spans.push(MathSpan { start, end: i + 1 });
|
||||
open = None;
|
||||
} else if !prev_ok {
|
||||
// `$foo $` — the closer is disqualified by the space
|
||||
// before it. Treat this `$` as a fresh opener candidate
|
||||
// rather than letting the span run to the next one.
|
||||
open = bytes
|
||||
.get(i + 1)
|
||||
.is_some_and(|b| !b.is_ascii_whitespace() && *b != b'$')
|
||||
.then_some(i);
|
||||
}
|
||||
}
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
spans
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn ch(c: char) -> MathNode {
|
||||
MathNode::Char(c)
|
||||
}
|
||||
|
||||
fn group(nodes: Vec<MathNode>) -> MathNode {
|
||||
MathNode::Group(nodes)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detection_finds_inline_spans() {
|
||||
assert_eq!(
|
||||
detect_math_spans("$x^2$"),
|
||||
vec![MathSpan { start: 0, end: 5 }]
|
||||
);
|
||||
let two = detect_math_spans("$a$ and $b$");
|
||||
assert_eq!(two.len(), 2, "{two:?}");
|
||||
let line = "before $x^2$ after";
|
||||
let span = detect_math_spans(line)[0];
|
||||
assert_eq!(&line[span.start..span.end], "$x^2$");
|
||||
assert_eq!(&line[span.interior()], "x^2");
|
||||
}
|
||||
|
||||
/// Framing F5 / acceptance 2 — the case rev 1's rule would have
|
||||
/// mis-rendered as math over "5 and ".
|
||||
#[test]
|
||||
fn currency_guards_reject_prose_dollars() {
|
||||
assert!(detect_math_spans("Price: $5.00").is_empty());
|
||||
assert!(
|
||||
detect_math_spans("prices are $5 and $6 today").is_empty(),
|
||||
"a digit after the closer disqualifies it"
|
||||
);
|
||||
assert!(
|
||||
detect_math_spans("$ x $").is_empty(),
|
||||
"space after the opener disqualifies it"
|
||||
);
|
||||
assert!(
|
||||
detect_math_spans("costs $5 or $6").is_empty(),
|
||||
"both guards together"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn escaped_dollars_neither_open_nor_close() {
|
||||
assert!(detect_math_spans(r"\$5 and \$6").is_empty());
|
||||
// An escaped dollar inside a span does not close it.
|
||||
let line = r"$a\$b$";
|
||||
let spans = detect_math_spans(line);
|
||||
assert_eq!(spans.len(), 1);
|
||||
assert_eq!(&line[spans[0].start..spans[0].end], r"$a\$b$");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unpaired_dollar_yields_nothing() {
|
||||
assert!(detect_math_spans("$x").is_empty());
|
||||
assert!(detect_math_spans("x$").is_empty());
|
||||
// Q#MS3: spans never cross a newline. Callers scan per line, so a
|
||||
// partner on the next line is simply not visible to this call.
|
||||
assert!(detect_math_spans("$x").is_empty());
|
||||
assert!(detect_math_spans("y$").is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_and_display_delimiters_degrade_rather_than_half_match() {
|
||||
// Acceptance 15. `$$` is opaque, so display math yields NO span and
|
||||
// falls through to source. Asserting emptiness rather than "any span
|
||||
// found must fail to parse" matters: the interior of the inner `$x$`
|
||||
// parses perfectly well, so the weaker form passed vacuously while
|
||||
// `$$x$$` half-rendered with a stray `$` on each side.
|
||||
assert!(detect_math_spans("$$").is_empty());
|
||||
assert!(
|
||||
detect_math_spans("$$x$$").is_empty(),
|
||||
"display math must not match the inner $x$"
|
||||
);
|
||||
assert!(detect_math_spans(r"$$\frac{a}{b}$$").is_empty());
|
||||
// A real inline span beside display math is still found.
|
||||
let mixed = detect_math_spans("$a$ then $$b$$");
|
||||
assert_eq!(mixed.len(), 1, "{mixed:?}");
|
||||
}
|
||||
|
||||
/// Round-3 F6 — a DOCUMENTED casualty of the `$$`-opaque rule, not a
|
||||
/// guard failure: in `$a$$b$` the first span's legitimate closer is
|
||||
/// immediately followed by the second span's opener, the lookahead
|
||||
/// reads that pair as display-math `$$`, and the pending opener is
|
||||
/// abandoned. Adjacent inline spans therefore need a separating
|
||||
/// character. Pandoc finds two spans here; this scanner deliberately
|
||||
/// finds none, because distinguishing `$a$$b$` from `$$x$$` requires
|
||||
/// closer-context the framing's opaque-`$$` rule gave away.
|
||||
#[test]
|
||||
fn adjacent_inline_spans_are_eaten_by_the_display_guard() {
|
||||
assert!(detect_math_spans("$a$$b$").is_empty());
|
||||
assert!(detect_math_spans("$x^2$$y^2$").is_empty());
|
||||
// One separating character restores both spans.
|
||||
assert_eq!(detect_math_spans("$a$ $b$").len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn whitespace_before_a_script_marker_still_merges_the_scripts() {
|
||||
// F2: without skipping whitespace in `parse_scripts`, `x^2 _i` built
|
||||
// a NESTED script and drew the subscript displaced right.
|
||||
assert_eq!(parse("x^2 _i"), parse("x^2_i"));
|
||||
assert_eq!(parse("x _i ^2"), parse("x_i^2"));
|
||||
// And a doubled script is still an error with space between.
|
||||
assert!(matches!(
|
||||
parse("x^2 ^3"),
|
||||
Err(MathParseError::MalformedScript(_))
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_greek_seed_uses_tex_letter_forms() {
|
||||
// F5: TeX's \epsilon is lunate and \phi is the symbol form; the
|
||||
// U+03B5 / U+03C6 glyphs are \varepsilon / \varphi.
|
||||
assert_eq!(parse(r"\epsilon"), Ok(group(vec![ch('\u{3F5}')])));
|
||||
assert_eq!(parse(r"\phi"), Ok(group(vec![ch('\u{3D5}')])));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plain_characters_parse_in_order() {
|
||||
assert_eq!(parse("x+1"), Ok(group(vec![ch('x'), ch('+'), ch('1')])));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn superscript_and_subscript_attach_to_the_preceding_atom() {
|
||||
// Framing acceptance 1.
|
||||
assert_eq!(
|
||||
parse("x^2"),
|
||||
Ok(group(vec![MathNode::Script {
|
||||
base: Box::new(ch('x')),
|
||||
sub: None,
|
||||
sup: Some(Box::new(ch('2'))),
|
||||
}]))
|
||||
);
|
||||
assert_eq!(
|
||||
parse("x_i"),
|
||||
Ok(group(vec![MathNode::Script {
|
||||
base: Box::new(ch('x')),
|
||||
sub: Some(Box::new(ch('i'))),
|
||||
sup: None,
|
||||
}]))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn both_scripts_parse_in_either_order() {
|
||||
let expected = MathNode::Script {
|
||||
base: Box::new(ch('x')),
|
||||
sub: Some(Box::new(ch('i'))),
|
||||
sup: Some(Box::new(ch('2'))),
|
||||
};
|
||||
assert_eq!(parse("x_i^2"), Ok(group(vec![expected.clone()])));
|
||||
assert_eq!(parse("x^2_i"), Ok(group(vec![expected])));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn braced_script_operands_group() {
|
||||
assert_eq!(
|
||||
parse("x^{i+1}"),
|
||||
Ok(group(vec![MathNode::Script {
|
||||
base: Box::new(ch('x')),
|
||||
sub: None,
|
||||
sup: Some(Box::new(group(vec![ch('i'), ch('+'), ch('1')]))),
|
||||
}]))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fraction_takes_two_braced_arguments() {
|
||||
assert_eq!(
|
||||
parse(r"\frac{a}{b}"),
|
||||
Ok(group(vec![MathNode::Fraction {
|
||||
num: Box::new(group(vec![ch('a')])),
|
||||
den: Box::new(group(vec![ch('b')])),
|
||||
}]))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fractions_nest() {
|
||||
// Framing acceptance 1 and 12's over-tall candidate.
|
||||
let inner = MathNode::Fraction {
|
||||
num: Box::new(group(vec![ch('a')])),
|
||||
den: Box::new(group(vec![ch('b')])),
|
||||
};
|
||||
assert_eq!(
|
||||
parse(r"\frac{\frac{a}{b}}{c}"),
|
||||
Ok(group(vec![MathNode::Fraction {
|
||||
num: Box::new(group(vec![inner])),
|
||||
den: Box::new(group(vec![ch('c')])),
|
||||
}]))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn greek_seed_resolves_to_codepoints_not_markup() {
|
||||
assert_eq!(parse(r"\alpha"), Ok(group(vec![ch('α')])));
|
||||
assert_eq!(parse(r"\Gamma"), Ok(group(vec![ch('Γ')])));
|
||||
// The AST stays semantic: no italic mapping here (that is layout's,
|
||||
// per this module's header and Q#MS2).
|
||||
assert_eq!(parse(r"\alpha x"), Ok(group(vec![ch('α'), ch('x')])));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn whitespace_is_insignificant() {
|
||||
assert_eq!(parse("x ^ 2"), parse("x^2"));
|
||||
assert_eq!(parse(r"\frac {a} {b}"), parse(r"\frac{a}{b}"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn subset_violations_are_errors_not_panics() {
|
||||
// Framing acceptance 1 and 9.
|
||||
assert_eq!(parse(""), Err(MathParseError::Empty));
|
||||
assert_eq!(parse(" "), Err(MathParseError::Empty));
|
||||
assert_eq!(parse("{a"), Err(MathParseError::UnbalancedBrace));
|
||||
assert_eq!(parse("a}"), Err(MathParseError::UnbalancedBrace));
|
||||
assert_eq!(
|
||||
parse(r"\sqrt{2}"),
|
||||
Err(MathParseError::UnknownCommand("sqrt".to_owned()))
|
||||
);
|
||||
assert!(matches!(
|
||||
parse(r"\frac{a}"),
|
||||
Err(MathParseError::MalformedCommand(_))
|
||||
));
|
||||
assert!(matches!(
|
||||
parse(r"\frac a b"),
|
||||
Err(MathParseError::MalformedCommand(_))
|
||||
));
|
||||
assert!(matches!(
|
||||
parse("^2"),
|
||||
Err(MathParseError::MalformedScript(_))
|
||||
));
|
||||
assert!(matches!(
|
||||
parse("x^"),
|
||||
Err(MathParseError::MalformedScript(_))
|
||||
));
|
||||
assert!(matches!(
|
||||
parse("x^2^3"),
|
||||
Err(MathParseError::MalformedScript(_))
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_interior_dollar_is_rejected_so_display_math_degrades() {
|
||||
// Framing acceptance 15: `$$x$$` reaches us as the interior `$x$`
|
||||
// (outer delimiters stripped), and must degrade to source rather
|
||||
// than half-render. The empty-span path covers `$$` alone.
|
||||
assert_eq!(parse("$x$"), Err(MathParseError::UnexpectedDollar));
|
||||
assert_eq!(parse(""), Err(MathParseError::Empty));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn escaped_literals_survive_as_characters() {
|
||||
assert_eq!(parse(r"\{"), Ok(group(vec![ch('{')])));
|
||||
assert_eq!(parse(r"\$"), Ok(group(vec![ch('$')])));
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue