901 lines
34 KiB
Rust
901 lines
34 KiB
Rust
//! 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
|
||
);
|
||
}
|
||
}
|
||
}
|