feat(math): box layout, and measure the real height budget

MathBox/MathItem composition for the Q#MS2 subset: characters advance a pen,
scripts shift by the MATH table's superscript/subscript amounts at script
scale, and \frac stacks its operands around a rule at the math axis. Inline
\frac sets its operands one style down, which is TeX's rule and also what the
parent framing's Tier 3 specifies — and it is load-bearing for Q#MS10, since
full-size operands would not fit the line at all.

The height budget is now measured rather than assumed, and the round-2 review
was right to insist on that. Two things were wrong.

First, my own test derived the budget from the MATH font's metrics. Q#MS10
says the budget is the LINE BOX, whose baseline the CODE font places —
JetBrains Mono ascends 16.32 px and descends 4.80 px at 16 px inside the 22 px
line, against Latin Modern Math's 12.90/3.10. Using the wrong font made a
plain \frac{a}{b} score 0.485 and appear to fall below the floor, which would
have meant the flagship case never rendering.

Second, with the budget derived correctly, B6 holds — \frac{a}{b} scales to
0.732 — but rev 3's guessed fallback case does not. A doubly-nested fraction
scores 0.744 and still renders; the floor is not tripped until depth 3, at
0.580. Round 2 predicted precisely this surprise-pass. 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.

The test therefore SEARCHES for the tripping depth instead of hardcoding it,
and fails if no depth trips the floor at all — which would mean the fallback
arm is unreachable and the floor is dead code. Acceptance 12 records the
measured table.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Levi Neuwirth 2026-07-24 18:39:26 -04:00
parent 320bcce276
commit f708ccb2a4
2 changed files with 552 additions and 9 deletions

View File

@ -520,15 +520,28 @@ something reaches the screen runs on a real device through
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)** — a plain `$\frac{a}{b}$` renders at default
metrics within the line box and does not paint outside it (B6). A case
that exceeds the 0.6× floor falls back to source rather than overlapping
the line above. **The fallback case must be chosen by computing its scale
against the real font, not guessed:** the round-2 arithmetic puts
`\frac{a}{b}` near 0.85 and suggests even `\frac{x^2}{y}` clears the
floor, so a singly-nested case would surprise-pass by rendering. Expect a
doubly-nested `$\frac{\frac{a}{b}}{c}$`, and pin the computed scale in the
test so the boundary is asserted rather than assumed.
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}` | 11.57 | 6.40 | **0.732** |
| `\frac{x^2}{y}` | 15.91 | 5.75 | 0.814 |
| nesting depth 2 | — | — | 0.744 |
| nesting depth 3 | — | — | **0.580** |
So **B6 holds** — the flagship fraction renders at 0.732 — and the
fallback case is **depth 3**, not the doubly-nested one rev 3 guessed.
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

View File

@ -124,10 +124,540 @@ pub fn math_italic(ch: char) -> char {
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 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 })
}
#[must_use]
pub fn constants(&self) -> MathConstants {
self.constants
}
/// Lay `node` out at `size_px`.
#[must_use]
pub fn layout(&self, node: &crate::math_parse::MathNode, size_px: f32) -> MathBox {
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;
out
}
MathNode::Script { base, sub, sup } => self.layout_script(base, sub, sup, size_px),
MathNode::Fraction { num, den } => self.layout_fraction(num, den, size_px),
}
}
fn layout_char(&self, ch: char, size_px: f32) -> MathBox {
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))
})
.unwrap_or((0.0, 0.0, 0.0));
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<Box<crate::math_parse::MathNode>>,
sup: &Option<Box<crate::math_parse::MathNode>>,
size_px: f32,
) -> MathBox {
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;
out
}
fn layout_fraction(
&self,
num: &crate::math_parse::MathNode,
den: &crate::math_parse::MathNode,
size_px: f32,
) -> MathBox {
// 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);
let gap = thickness * 2.0;
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 + gap + num_box.descent;
let den_baseline = axis - thickness / 2.0 - 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;
out
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::math_parse::parse;
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)
}
/// 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,
baseline,
size_px,
..
} if ch == '2' => 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 code_upem = f32::from(code.units_per_em());
let baseline_from_top = f32::from(code.ascender()) * crate::BASE_CODE_FONT_SIZE / code_upem;
let margin = 1.0;
let asc_budget = baseline_from_top - margin;
let desc_budget = crate::BASE_CODE_LINE_HEIGHT - baseline_from_top - margin;
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()));
}
if depth >= 6 {
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.