diff --git a/crates/compose/src/tests.rs b/crates/compose/src/tests.rs index 02ec152fd..f4758aa97 100644 --- a/crates/compose/src/tests.rs +++ b/crates/compose/src/tests.rs @@ -1579,18 +1579,18 @@ fn cap_height_and_x_height_first_baselines_use_the_fonts_cap_and_x_heights() { fn cap_height_and_x_height_fall_back_without_usable_os2_values() { use designcraft_doc::FirstBaseline; let db = designcraft_fonts::FontDb::global(); - // No OS/2 table, zero / negative and absurd values: the top of the H (0.656 em) and 0.5 of the - // (1 em) ascent. + // No OS/2 table, zero / negative and absurd values: the top of the H (0.656 em) and of the x + // (0.486 em). let zero: &[(usize, i16)] = &[(CAP_HEIGHT, 0), (X_HEIGHT, -40)]; let big: &[(usize, i16)] = &[(CAP_HEIGHT, i16::MAX), (X_HEIGHT, i16::MAX)]; for (family, fields) in [("CapXHeightNoO", None), ("CapXHeightNeg", Some(zero)), ("CapXHeightBig", Some(big))] { db.add_font(test_font(family, fields)); let face = db.face(family, "Regular"); assert_eq!(face.family, family); - assert_eq!((face.cap_height, face.x_height), (656.0, face.ascent * 0.5), "{family}"); + assert_eq!((face.cap_height, face.x_height), (656.0, 486.0), "{family}"); let cap = first_baseline_in(family, |o| o.first_baseline = FirstBaseline::CapHeight); let x = first_baseline_in(family, |o| o.first_baseline = FirstBaseline::XHeight); - assert!((cap - (40.0 + 13.12)).abs() < 0.01 && (x - (40.0 + 10.0)).abs() < 0.01, "{family}: {cap} {x}"); + assert!((cap - (40.0 + 13.12)).abs() < 0.01 && (x - (40.0 + 9.72)).abs() < 0.01, "{family}: {cap} {x}"); } } diff --git a/crates/fonts/src/fontdb.rs b/crates/fonts/src/fontdb.rs index 046a98e8b..188087549 100644 --- a/crates/fonts/src/fontdb.rs +++ b/crates/fonts/src/fontdb.rs @@ -703,14 +703,15 @@ fn make_face(bytes: FontBytes, source: FontSource, index: u32, family: String, s ascent: m.ascent as f64, typo_ascent: typo_ascent(&f, &location, upem).unwrap_or(m.ascent as f64), descent: -(m.descent as f64), + // Fonts with an OS/2 table older than version 2 don't declare these: measure them from + // flat-topped letters before falling back to a share of the ascent, which can be far off + // (drop caps are sized from the cap height). cap_height: plausible(m.cap_height.map(f64::from), upem) - .or_else(|| { - // No OS/2 cap height: the top of the H. - let gid = f.charmap().map('H')?; - plausible(f.glyph_metrics(Size::unscaled(), &location).bounds(gid).map(|b| b.y_max as f64), upem) - }) + .or_else(|| measured_top(&f, &location, &['H', 'I', 'E', 'T'], upem)) .unwrap_or(m.ascent as f64 * 0.72), - x_height: plausible(m.x_height.map(f64::from), upem).unwrap_or(m.ascent as f64 * 0.5), + x_height: plausible(m.x_height.map(f64::from), upem) + .or_else(|| measured_top(&f, &location, &['x', 'z', 'v'], upem)) + .unwrap_or(m.ascent as f64 * 0.5), shaper, coords, location, @@ -729,6 +730,36 @@ fn plausible(v: Option, upem: f64) -> Option { v.filter(|v| *v > 0.0 && *v <= 4.0 * upem) } +/// The top of the first of `chars` the font has a non-empty outline for, in font units: the +/// highest point of its unhinted outline at `location`, if [`plausible`]. For flat-topped letters +/// that is the cap height (`H`) or x-height (`x`). +fn measured_top(f: &skrifa::FontRef<'_>, location: &Location, chars: &[char], upem: f64) -> Option { + struct Top(f32); + impl OutlinePen for Top { + fn move_to(&mut self, _x: f32, y: f32) { + self.0 = self.0.max(y); + } + fn line_to(&mut self, _x: f32, y: f32) { + self.0 = self.0.max(y); + } + fn quad_to(&mut self, _cx: f32, _cy: f32, _x: f32, y: f32) { + self.0 = self.0.max(y); + } + fn curve_to(&mut self, _cx0: f32, _cy0: f32, _cx1: f32, _cy1: f32, _x: f32, y: f32) { + self.0 = self.0.max(y); + } + fn close(&mut self) {} + } + let cmap = f.charmap(); + let glyphs = f.outline_glyphs(); + chars.iter().find_map(|&c| { + let g = glyphs.get(cmap.map(c)?)?; + let mut pen = Top(f32::NEG_INFINITY); + g.draw(DrawSettings::unhinted(Size::unscaled(), location), &mut pen).ok()?; + plausible(Some(f64::from(pen.0)), upem) + }) +} + /// The OS/2 typographic ascender in font units at `location`, if the font has an OS/2 table and /// the value is [`plausible`]. fn typo_ascent(f: &skrifa::FontRef<'_>, location: &Location, upem: f64) -> Option { @@ -1483,3 +1514,54 @@ mod tests_docfonts; #[cfg(test)] #[path = "tests_vertical.rs"] mod tests_vertical; + +#[cfg(test)] +mod tests_heights { + use super::*; + use crate::testing::{font_mapping, with_table_u16}; + + fn face_of(bytes: Vec) -> FontFace { + make_face(FontBytes::Owned(Arc::new(bytes)), FontSource::Memory, 0, "Test".into(), "Regular".into(), Vec::new()).unwrap() + } + + /// `font` with OS/2 sxHeight and sCapHeight set to 0 (undeclared). + fn undeclared(font: Vec) -> Vec { + with_table_u16(with_table_u16(font, b"OS/2", 86, 0).unwrap(), b"OS/2", 88, 0).unwrap() + } + + /// The top of Source Sans 3's glyph for `c` from its bounding box (font units). + fn bbox_top(c: char) -> f64 { + let f = skrifa::FontRef::new(BUNDLED[0]).unwrap(); + f.glyph_metrics(Size::unscaled(), LocationRef::default()).bounds(f.charmap().map(c).unwrap()).unwrap().y_max as f64 + } + + /// A font that doesn't declare its cap height and x-height gets them measured from its + /// flat-topped letters, also when it lacks the first choices `H` and `x`; a font that + /// declares them keeps them. + #[test] + fn undeclared_heights_are_measured_from_outlines() { + let glyphs = [('H', 'H'), ('x', 'x'), ('E', 'E'), ('z', 'z')]; + for (glyphs, cap, x) in [(&glyphs[..], 'H', 'x'), (&glyphs[2..], 'E', 'z')] { + let face = face_of(undeclared(font_mapping("Heights", glyphs).unwrap())); + let (cap, x) = (bbox_top(cap), bbox_top(x)); + assert!(cap > x && x > 0.0); + assert!((face.cap_height - cap).abs() <= 1.0, "{glyphs:?}: cap height {} vs {cap}", face.cap_height); + assert!((face.x_height - x).abs() <= 1.0, "{glyphs:?}: x-height {} vs {x}", face.x_height); + } + let declared = face_of(font_mapping("Heights", &glyphs).unwrap()); + assert_eq!((declared.cap_height, declared.x_height), (660.0, 486.0), "Source Sans 3 Regular's OS/2 values"); + // Declared values win over the outlines: `H` drawn as `x` doesn't lower the cap height. + let face = face_of(font_mapping("Heights", &[('H', 'x'), ('x', 'H')]).unwrap()); + assert_eq!((face.cap_height, face.x_height), (declared.cap_height, declared.x_height)); + } + + /// Without any of the letters, or with only empty outlines, the heights fall back to a share + /// of the ascent. + #[test] + fn heights_without_measurable_letters_fall_back_to_the_ascent() { + for glyphs in [&[('a', 'a')][..], &[('H', ' '), ('x', ' ')]] { + let face = face_of(undeclared(font_mapping("Heights", glyphs).unwrap())); + assert_eq!((face.cap_height, face.x_height), (face.ascent * 0.72, face.ascent * 0.5), "{glyphs:?}"); + } + } +}