pmacs/pmacs-gpu/src/math_layout.rs

901 lines
34 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! 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 (0100) 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
);
}
}
}