fix(math): review round 3 — mapping bug, whitespace defect, real MATH gaps

F1 was a real bug pinned by my own committed test. `end` in
ChunkSource::MathBox is EXCLUSIVE, so source position `end` is the first byte
AFTER the span — but the arm claimed it for the box's left edge, and the test
asserted that wrong value while calling the byte "interior". Consequences it
would have caused once overlays land: a search match starting just after a
span washes the whole box it does not intersect, violating Q#MS11; a peer
caret after the span draws at the box's left edge; caret geometry jumps
backwards. The same class existed in projected_to_source for a line-FINAL box,
where `within` clamps to the run length and the arm returned `start`
unconditionally, so a click past end-of-line landed on the span start. Both
committed hit tests put a chunk after the box, so that edge was never
exercised; there is now a test with the box last.

F2: parse_scripts peeked for the next marker without skipping whitespace, so
`x^2 _i` built a NESTED script — drawing the subscript displaced right by the
superscript's width — and `x^2 ^3` parsed where TeX errors, contradicting the
module's own "whitespace is insignificant" rule.

F3: layout is now fallible. A character the math font cannot draw used to
yield zero metrics and still emit a Glyph item, rendering tofu at zero advance
over its neighbour. Q#MS8's rule is "failure is always show the source", and
the draw pass needs a refusal signal — changed now, before that pass consumes
the API.

F4: the fraction gap was a hardcoded `thickness * 2.0` while the MATH table's
FractionNumeratorGapMin / FractionDenominatorGapMin went unread. Reading them
moved the flagship \frac{a}{b} from 0.732 to 0.867 and the fallback boundary
from depth 3 to depth 5. The round-2 review's hand-arithmetic estimate of
~0.85 was right; my 0.732 was inflated by the guess. The depth-SEARCHING test
absorbed the change without edits, which is the property it was written for.

F5: TeX's \epsilon and \phi are the lunate/symbol forms (U+03F5, U+03D5), not
U+03B5/U+03C6. Their italic mappings had to land with the seed change, since
both sit outside math_italic's U+03B1..03C9 run and would otherwise render
upright beside italic neighbours.

F7: the line-box budget derivation moved out of the test into
`line_box_budget`, so the draw pass and the acceptance test cannot compute
different splits while both stay green.

F8: the live-code clippy items are cleared. The 25 that remain are all
dead-code awaiting the draw pass.

189 pmacs-gpu tests pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Levi Neuwirth 2026-07-24 20:19:40 -04:00
parent 8e4bc00015
commit cbf7782726
4 changed files with 219 additions and 59 deletions

View File

@ -529,13 +529,20 @@ something reaches the screen runs on a real device through
| expression | ascent | descent | scale | | expression | ascent | descent | scale |
| --- | --- | --- | --- | | --- | --- | --- | --- |
| `x^2`, `\alpha x` | 13.27 | 0.18 | 1.000 | | `x^2`, `\alpha x` | 13.27 | 0.18 | 1.000 |
| `\frac{a}{b}` | 11.57 | 6.40 | **0.732** | | `\frac{a}{b}` | 10.57 | 5.40 | **0.867** |
| `\frac{x^2}{y}` | 15.91 | 5.75 | 0.814 | | `\frac{x^2}{y}` | 14.91 | 4.75 | 0.986 |
| nesting depth 2 | — | — | 0.744 | | nesting depth 2 | — | — | 0.872 |
| nesting depth 3 | — | — | **0.580** | | nesting depth 4 | — | — | 0.613 |
| nesting depth 5 | — | — | **0.540** |
So **B6 holds** — the flagship fraction renders at 0.732 — and the So **B6 holds** — the flagship fraction renders at 0.867 — and the
fallback case is **depth 3**, not the doubly-nested one rev 3 guessed. 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 Round 2 predicted exactly this trap. Two things worth keeping: depth 2
scores *higher* than depth 1 because the binding constraint flips from scores *higher* than depth 1 because the binding constraint flips from
descent to ascent as nesting grows asymmetrically, so "deeper is always descent to ascent as nesting grows asymmetrically, so "deeper is always

View File

@ -7743,9 +7743,6 @@ struct ProjectedRun {
source: ChunkSource, source: ChunkSource,
} }
/// Build the projected→source run map plus the projected text's line
/// start table (cosmic-text reports hits as line + byte-within-line).
#[cfg(test)] #[cfg(test)]
mod math_chunk_tests { mod math_chunk_tests {
use super::*; use super::*;
@ -7812,13 +7809,38 @@ mod math_chunk_tests {
]; ];
assert_eq!(source_to_projected(&chunks, 0), Some(0)); assert_eq!(source_to_projected(&chunks, 0), Some(0));
assert_eq!(source_to_projected(&chunks, 2), Some(2)); assert_eq!(source_to_projected(&chunks, 2), Some(2));
// Interior source bytes collapse onto the left edge. // Interior source bytes collapse onto the left edge. `end` (7) is
// EXCLUSIVE and therefore NOT interior — an earlier revision of this
// test asserted Some(2) for it and pinned the bug.
assert_eq!(source_to_projected(&chunks, 4), Some(2)); assert_eq!(source_to_projected(&chunks, 4), Some(2));
assert_eq!(source_to_projected(&chunks, 7), Some(2)); assert_eq!(source_to_projected(&chunks, 6), Some(2));
// The first byte after the span lands past the reserved width. assert_eq!(source_to_projected(&chunks, 7), Some(5), "end is exclusive");
assert_eq!(source_to_projected(&chunks, 8), Some(6)); assert_eq!(source_to_projected(&chunks, 8), Some(6));
} }
/// A box at the END of a line has no following chunk to catch the
/// trailing boundary, which is exactly where the interior-snap rule used
/// to send a click back to the span start.
#[test]
fn a_line_final_math_box_maps_its_trailing_boundary_after_the_span() {
let chunks = vec![
RichChunk {
text: "ab".to_owned(),
color: None,
source: ChunkSource::Source { start: 0 },
},
spacer_chunk(2, 7, 3),
];
let (runs, _) = build_hit_runs(&chunks);
assert_eq!(projected_to_source(&runs, 2), Some(2), "interior snaps");
assert_eq!(projected_to_source(&runs, 4), Some(2), "interior snaps");
assert_eq!(
projected_to_source(&runs, 5),
Some(7),
"the trailing boundary lands after the span, not on its start"
);
}
/// A math chunk carries generated spacer text, so tab expansion must /// A math chunk carries generated spacer text, so tab expansion must
/// leave it alone rather than treating a space as a source tab. /// leave it alone rather than treating a space as a source tab.
#[test] #[test]
@ -7834,6 +7856,8 @@ mod math_chunk_tests {
} }
} }
/// Build the projected→source run map plus the projected text's line
/// start table (cosmic-text reports hits as line + byte-within-line).
fn build_hit_runs(chunks: &[RichChunk]) -> (Vec<ProjectedRun>, Vec<u64>) { fn build_hit_runs(chunks: &[RichChunk]) -> (Vec<ProjectedRun>, Vec<u64>) {
let mut runs = Vec::with_capacity(chunks.len()); let mut runs = Vec::with_capacity(chunks.len());
let mut line_starts = vec![0u64]; let mut line_starts = vec![0u64];
@ -7872,9 +7896,11 @@ fn projected_to_source(runs: &[ProjectedRun], projected: u64) -> Option<u64> {
ChunkSource::Source { start } => Some(start + within), ChunkSource::Source { start } => Some(start + within),
ChunkSource::SourceTab { start } => Some(start + u64::from(within > 0)), ChunkSource::SourceTab { start } => Some(start + u64::from(within > 0)),
ChunkSource::Adornment { anchor } => Some(anchor), ChunkSource::Adornment { anchor } => Some(anchor),
// Q#MS4: the box has no interior byte map, so every boundary inside // Q#MS4: interior boundaries snap to the span start, since the box
// it snaps to the span start rather than inventing a sub-position. // has no interior byte map. The TRAILING boundary is not interior —
ChunkSource::MathBox { start, .. } => Some(start), // it maps after the span, matching `SourceTab`'s rule and keeping a
// click past a line-final box off the span start.
ChunkSource::MathBox { start, end } => Some(if within >= run.len { end } else { start }),
} }
} }
@ -7910,14 +7936,16 @@ fn source_to_projected(chunks: &[RichChunk], source: u64) -> Option<u64> {
} }
} }
ChunkSource::MathBox { start, end } => { ChunkSource::MathBox { start, end } => {
// Anywhere within the suppressed range maps to the box's // `end` is EXCLUSIVE: it is the first byte AFTER the span, so
// left edge; past it, the box's full reserved width applies. // it must NOT claim the box's left edge. Letting it fall
if source <= start { // through gives it the position past the reserved width —
return Some(projected); // which also keeps caret geometry continuous and stops a
} // search match starting at `end` from washing a box it does
if source <= end { // not intersect (Q#MS11).
if source < end {
return Some(projected); return Some(projected);
} }
let _ = start;
} }
} }
projected += len; projected += len;

View File

@ -12,7 +12,7 @@ use ttf_parser::Face;
/// Bundled math font (GUST Font License — see `fonts/GUST-FONT-LICENSE.txt`). /// Bundled math font (GUST Font License — see `fonts/GUST-FONT-LICENSE.txt`).
/// ///
/// Distinct from `fonts/OFL.txt`, which covers JetBrains Mono only: Latin /// 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). /// 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"); pub const LATIN_MODERN_MATH: &[u8] = include_bytes!("../fonts/latinmodern-math.otf");
@ -36,6 +36,10 @@ pub struct MathConstants {
pub subscript_shift_down: i16, pub subscript_shift_down: i16,
/// Thickness of the fraction rule. /// Thickness of the fraction rule.
pub fraction_rule_thickness: i16, 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. /// Why the bundled font could not supply math metrics.
@ -51,6 +55,12 @@ pub enum MathFontError {
NoMathTable, NoMathTable,
/// MATH table present but missing a constant the subset needs. /// MATH table present but missing a constant the subset needs.
MissingConstant(&'static str), 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 { impl MathConstants {
@ -72,6 +82,8 @@ impl MathConstants {
superscript_shift_up: constants.superscript_shift_up().value, superscript_shift_up: constants.superscript_shift_up().value,
subscript_shift_down: constants.subscript_shift_down().value, subscript_shift_down: constants.subscript_shift_down().value,
fraction_rule_thickness: constants.fraction_rule_thickness().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,
}) })
} }
@ -118,6 +130,12 @@ pub fn math_italic(ch: char) -> char {
'a'..='z' => 0x1D44E + (ch as u32 - 'a' as u32), 'a'..='z' => 0x1D44E + (ch as u32 - 'a' as u32),
// Lowercase Greek α..ω → MATHEMATICAL ITALIC SMALL ALPHA..OMEGA. // Lowercase Greek α..ω → MATHEMATICAL ITALIC SMALL ALPHA..OMEGA.
'\u{3B1}'..='\u{3C9}' => 0x1D6FC + (ch as u32 - 0x3B1), '\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. // Uppercase Greek, digits, operators: upright, per TeX.
_ => return ch, _ => return ch,
}; };
@ -243,6 +261,26 @@ impl MathBox {
} }
} }
/// 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). /// The smallest uniform scale the slice will apply before giving up (Q#MS10).
pub const MIN_FIT_SCALE: f32 = 0.6; pub const MIN_FIT_SCALE: f32 = 0.6;
@ -296,8 +334,15 @@ impl<'a> MathLayout<'a> {
} }
/// Lay `node` out at `size_px`. /// Lay `node` out at `size_px`.
#[must_use] ///
pub fn layout(&self, node: &crate::math_parse::MathNode, size_px: f32) -> MathBox { /// # 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; use crate::math_parse::MathNode;
match node { match node {
MathNode::Char(ch) => self.layout_char(*ch, size_px), MathNode::Char(ch) => self.layout_char(*ch, size_px),
@ -305,19 +350,21 @@ impl<'a> MathLayout<'a> {
let mut out = MathBox::empty(); let mut out = MathBox::empty();
let mut pen = 0.0; let mut pen = 0.0;
for child in children { for child in children {
let child_box = self.layout(child, size_px); let child_box = self.layout(child, size_px)?;
out.absorb(&child_box, pen, 0.0); out.absorb(&child_box, pen, 0.0);
pen += child_box.width; pen += child_box.width;
} }
out.width = pen; out.width = pen;
out Ok(out)
}
MathNode::Script { base, sub, sup } => {
self.layout_script(base, sub.as_deref(), sup.as_deref(), size_px)
} }
MathNode::Script { base, sub, sup } => self.layout_script(base, sub, sup, size_px),
MathNode::Fraction { num, den } => self.layout_fraction(num, den, size_px), MathNode::Fraction { num, den } => self.layout_fraction(num, den, size_px),
} }
} }
fn layout_char(&self, ch: char, size_px: f32) -> MathBox { fn layout_char(&self, ch: char, size_px: f32) -> Result<MathBox, MathFontError> {
let presented = math_italic(ch); let presented = math_italic(ch);
let upem = f32::from(self.constants.units_per_em.max(1)); let upem = f32::from(self.constants.units_per_em.max(1));
let (advance, ascent, descent) = self let (advance, ascent, descent) = self
@ -347,8 +394,10 @@ impl<'a> MathLayout<'a> {
); );
(adv, asc.max(0.0), desc.max(0.0)) (adv, asc.max(0.0), desc.max(0.0))
}) })
.unwrap_or((0.0, 0.0, 0.0)); // F3: no glyph means no honest box. Emitting a zero-width item
MathBox { // would draw tofu on top of the next character.
.ok_or(MathFontError::UncoverableGlyph(ch))?;
Ok(MathBox {
width: advance, width: advance,
ascent, ascent,
descent, descent,
@ -358,23 +407,23 @@ impl<'a> MathLayout<'a> {
baseline: 0.0, baseline: 0.0,
size_px, size_px,
}], }],
} })
} }
fn layout_script( fn layout_script(
&self, &self,
base: &crate::math_parse::MathNode, base: &crate::math_parse::MathNode,
sub: &Option<Box<crate::math_parse::MathNode>>, sub: Option<&crate::math_parse::MathNode>,
sup: &Option<Box<crate::math_parse::MathNode>>, sup: Option<&crate::math_parse::MathNode>,
size_px: f32, size_px: f32,
) -> MathBox { ) -> Result<MathBox, MathFontError> {
let base_box = self.layout(base, size_px); let base_box = self.layout(base, size_px)?;
let script_px = size_px * self.constants.script_scale(); let script_px = size_px * self.constants.script_scale();
let mut out = MathBox::empty(); let mut out = MathBox::empty();
out.absorb(&base_box, 0.0, 0.0); out.absorb(&base_box, 0.0, 0.0);
let mut widest = base_box.width; let mut widest = base_box.width;
if let Some(sup) = sup { if let Some(sup) = sup {
let sup_box = self.layout(sup, script_px); let sup_box = self.layout(sup, script_px)?;
let shift = self let shift = self
.constants .constants
.to_px(self.constants.superscript_shift_up, size_px); .to_px(self.constants.superscript_shift_up, size_px);
@ -382,7 +431,7 @@ impl<'a> MathLayout<'a> {
widest = widest.max(base_box.width + sup_box.width); widest = widest.max(base_box.width + sup_box.width);
} }
if let Some(sub) = sub { if let Some(sub) = sub {
let sub_box = self.layout(sub, script_px); let sub_box = self.layout(sub, script_px)?;
let shift = self let shift = self
.constants .constants
.to_px(self.constants.subscript_shift_down, size_px); .to_px(self.constants.subscript_shift_down, size_px);
@ -390,7 +439,7 @@ impl<'a> MathLayout<'a> {
widest = widest.max(base_box.width + sub_box.width); widest = widest.max(base_box.width + sub_box.width);
} }
out.width = widest; out.width = widest;
out Ok(out)
} }
fn layout_fraction( fn layout_fraction(
@ -398,25 +447,36 @@ impl<'a> MathLayout<'a> {
num: &crate::math_parse::MathNode, num: &crate::math_parse::MathNode,
den: &crate::math_parse::MathNode, den: &crate::math_parse::MathNode,
size_px: f32, size_px: f32,
) -> MathBox { ) -> Result<MathBox, MathFontError> {
// TeX sets an inline \frac's operands one style down, which is also // 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 // 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. // for Q#MS10: full-size operands would not fit the line at all.
let operand_px = size_px * self.constants.script_scale(); let operand_px = size_px * self.constants.script_scale();
let num_box = self.layout(num, operand_px); let num_box = self.layout(num, operand_px)?;
let den_box = self.layout(den, operand_px); let den_box = self.layout(den, operand_px)?;
let axis = self.constants.to_px(self.constants.axis_height, size_px); let axis = self.constants.to_px(self.constants.axis_height, size_px);
let thickness = self let thickness = self
.constants .constants
.to_px(self.constants.fraction_rule_thickness, size_px) .to_px(self.constants.fraction_rule_thickness, size_px)
.max(1.0); .max(1.0);
let gap = thickness * 2.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 width = num_box.width.max(den_box.width);
let mut out = MathBox::empty(); let mut out = MathBox::empty();
// Numerator sits above the bar, denominator below it. // Numerator sits above the bar, denominator below it.
let num_baseline = axis + thickness / 2.0 + gap + num_box.descent; let num_baseline = axis + thickness / 2.0 + num_gap + num_box.descent;
let den_baseline = axis - thickness / 2.0 - gap - den_box.ascent; 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(&num_box, (width - num_box.width) / 2.0, num_baseline);
out.absorb(&den_box, (width - den_box.width) / 2.0, den_baseline); out.absorb(&den_box, (width - den_box.width) / 2.0, den_baseline);
out.items.push(MathItem::Rule { out.items.push(MathItem::Rule {
@ -428,7 +488,7 @@ impl<'a> MathLayout<'a> {
out.ascent = out.ascent.max(axis + thickness / 2.0); out.ascent = out.ascent.max(axis + thickness / 2.0);
out.descent = out.descent.max(-(axis - thickness / 2.0)); out.descent = out.descent.max(-(axis - thickness / 2.0));
out.width = width; out.width = width;
out Ok(out)
} }
} }
@ -456,6 +516,22 @@ mod tests {
use crate::math_parse::parse; use crate::math_parse::parse;
#[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] #[test]
fn spacer_quantizes_up_to_whole_advances() { fn spacer_quantizes_up_to_whole_advances() {
// Exactly two advances stays two; a sliver over rounds up, so the // Exactly two advances stays two; a sliver over rounds up, so the
@ -496,7 +572,21 @@ mod tests {
fn lay(src: &str, size: f32) -> MathBox { fn lay(src: &str, size: f32) -> MathBox {
let node = parse(src).expect("parses"); let node = parse(src).expect("parses");
engine().layout(&node, size) 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 /// Framing acceptance 3, including its bite: the MATH constant must be
@ -517,11 +607,11 @@ mod tests {
.iter() .iter()
.find_map(|i| match *i { .find_map(|i| match *i {
MathItem::Glyph { MathItem::Glyph {
ch, ch: '2',
baseline, baseline,
size_px, size_px,
.. ..
} if ch == '2' => Some((baseline, size_px)), } => Some((baseline, size_px)),
_ => None, _ => None,
}) })
.expect("the 2 is emitted"); .expect("the 2 is emitted");
@ -613,11 +703,11 @@ mod tests {
// (JetBrains Mono at BASE_CODE_FONT_SIZE inside BASE_CODE_LINE_HEIGHT), // (JetBrains Mono at BASE_CODE_FONT_SIZE inside BASE_CODE_LINE_HEIGHT),
// NOT where the math font's own metrics would. // NOT where the math font's own metrics would.
let code = Face::parse(crate::JETBRAINS_MONO, 0).expect("code face"); let code = Face::parse(crate::JETBRAINS_MONO, 0).expect("code face");
let code_upem = f32::from(code.units_per_em()); let (asc_budget, desc_budget) = line_box_budget(
let baseline_from_top = f32::from(code.ascender()) * crate::BASE_CODE_FONT_SIZE / code_upem; &code,
let margin = 1.0; crate::BASE_CODE_FONT_SIZE,
let asc_budget = baseline_from_top - margin; crate::BASE_CODE_LINE_HEIGHT,
let desc_budget = crate::BASE_CODE_LINE_HEIGHT - baseline_from_top - margin; );
assert!( assert!(
asc_budget > 0.0 && desc_budget > 0.0, asc_budget > 0.0 && desc_budget > 0.0,
"budget must be positive: {asc_budget} / {desc_budget}" "budget must be positive: {asc_budget} / {desc_budget}"

View File

@ -60,7 +60,8 @@ const GREEK: &[(&str, char)] = &[
("beta", 'β'), ("beta", 'β'),
("gamma", 'γ'), ("gamma", 'γ'),
("delta", 'δ'), ("delta", 'δ'),
("epsilon", 'ε'), // TeX's \epsilon is LUNATE (U+03F5); U+03B5 is \varepsilon.
("epsilon", '\u{3F5}'),
("zeta", 'ζ'), ("zeta", 'ζ'),
("eta", 'η'), ("eta", 'η'),
("theta", 'θ'), ("theta", 'θ'),
@ -75,7 +76,8 @@ const GREEK: &[(&str, char)] = &[
("sigma", 'σ'), ("sigma", 'σ'),
("tau", 'τ'), ("tau", 'τ'),
("upsilon", 'υ'), ("upsilon", 'υ'),
("phi", 'φ'), // TeX's \phi is U+03D5; U+03C6 is \varphi.
("phi", '\u{3D5}'),
("chi", 'χ'), ("chi", 'χ'),
("psi", 'ψ'), ("psi", 'ψ'),
("omega", 'ω'), ("omega", 'ω'),
@ -229,7 +231,19 @@ impl Parser {
fn parse_scripts(&mut self, base: MathNode) -> Result<MathNode, MathParseError> { fn parse_scripts(&mut self, base: MathNode) -> Result<MathNode, MathParseError> {
let mut sub: Option<Box<MathNode>> = None; let mut sub: Option<Box<MathNode>> = None;
let mut sup: Option<Box<MathNode>> = None; let mut sup: Option<Box<MathNode>> = None;
while let Some(marker @ ('^' | '_')) = self.peek() { 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; self.pos += 1;
let slot = self.parse_script_operand()?; let slot = self.parse_script_operand()?;
match marker { match marker {
@ -256,9 +270,9 @@ impl Parser {
self.pos += 1; self.pos += 1;
} }
match self.peek() { match self.peek() {
None => Err(MathParseError::MalformedScript("script with no operand")), None | Some('^' | '_' | '}') => {
Some('^' | '_') => Err(MathParseError::MalformedScript("script with no operand")), Err(MathParseError::MalformedScript("script with no operand"))
Some('}') => Err(MathParseError::MalformedScript("script with no operand")), }
Some(_) => self.parse_atom(), Some(_) => self.parse_atom(),
} }
} }
@ -440,6 +454,27 @@ mod tests {
assert_eq!(mixed.len(), 1, "{mixed:?}"); assert_eq!(mixed.len(), 1, "{mixed:?}");
} }
#[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] #[test]
fn plain_characters_parse_in_order() { fn plain_characters_parse_in_order() {
assert_eq!(parse("x+1"), Ok(group(vec![ch('x'), ch('+'), ch('1')]))); assert_eq!(parse("x+1"), Ok(group(vec![ch('x'), ch('+'), ch('1')])));