From 0b7258446595935a16621edde458d20bb3be2eaa Mon Sep 17 00:00:00 2001 From: aiongg Date: Sun, 11 Oct 2026 01:12:08 +0000 Subject: [PATCH] Layout: Em Box Height follows the font's typo box or cap height A font's em box is its BASE em box; else it tops at the OS/2 typo ascender when the typo ascender and descender span exactly one em; else it is centred on half the cap height (OS/2 sCapHeight, else the H glyph's top), 0.5 em + cap / 2 above the baseline; else 0.88 em. The Em Box Height first baseline, vertical text and declared_em_box all use this one em box; declared_em_box is None only for the 0.88 em fallback. --- crates/compose/src/lib.rs | 5 ++- crates/compose/src/tests.rs | 50 ++++++++++++++++++------------ crates/doc/src/item.rs | 3 +- crates/fonts/src/fontdb.rs | 17 +++++----- crates/fonts/src/tests_vertical.rs | 24 +++++++++----- crates/fonts/src/vertical.rs | 35 +++++++++++---------- 6 files changed, 78 insertions(+), 56 deletions(-) diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs index 2b780fcab..5432c335e 100644 --- a/crates/compose/src/lib.rs +++ b/crates/compose/src/lib.rs @@ -2116,10 +2116,9 @@ struct Tops { em: f64, } -/// The em box top above the baseline at `size`: the font's `BASE` em box, else the default -/// ideographic em box, 0.88 em above the baseline. +/// The em box top above the baseline at `size` ([`designcraft_fonts::FontFace::em_box`]). fn em_box_top(face: &designcraft_fonts::FontFace, size: f64) -> f64 { - let em = face.declared_em_box().filter(|_| face.upem > 0.0).map_or(0.88, |(top, _)| top / face.upem); + let em = Some(face.em_box().0 / face.upem).filter(|e| e.is_finite()).unwrap_or(0.88); em * size } diff --git a/crates/compose/src/tests.rs b/crates/compose/src/tests.rs index 9a686701a..ff5b836fc 100644 --- a/crates/compose/src/tests.rs +++ b/crates/compose/src/tests.rs @@ -1591,6 +1591,7 @@ fn only_english_text_gets_english_hyphenation() { /// OS/2 field offsets: sTypoAscender, sxHeight, sCapHeight. const TYPO_ASCENDER: usize = 68; +const TYPO_DESCENDER: usize = 70; const X_HEIGHT: usize = 86; const CAP_HEIGHT: usize = 88; @@ -1696,19 +1697,30 @@ fn ascent_first_baseline_uses_the_typographic_ascender() { } #[test] -fn em_box_first_baseline_uses_the_base_em_box_else_0_88_em() { +fn em_box_first_baseline_follows_base_then_the_typo_box_or_the_cap_height() { use designcraft_doc::FirstBaseline; let db = designcraft_fonts::FontDb::global(); - // The test font's `BASE` table puts the em box bottom (`ideo`) at -170 units: its top is 0.83 em. - let mut no_base = typo_test_font("EmBoxTop 0700", Some(700)); - let tables = u16::from_be_bytes([no_base[4], no_base[5]]) as usize; - let rec = (0..tables).map(|t| 12 + 16 * t).find(|&r| &no_base[r..r + 4] == b"BASE").unwrap(); - // Still sorted between its neighbours, so the table directory stays valid. - no_base[rec..rec + 4].copy_from_slice(b"BASX"); - db.add_font(no_base); - db.add_font(typo_test_font("EmBoxBase 700", Some(700))); - assert_eq!(db.face("EmBoxTop 0700", "Regular").declared_em_box(), None); - assert_eq!(db.face("EmBoxBase 700", "Regular").declared_em_box(), Some((830.0, -170.0))); + // [`test_font`] (typo descender -326, cap height 660), with or without its `BASE` table, whose + // em box bottom (`ideo`) is at -170 units. + let add = |family: &str, base: bool, fields: &[(usize, i16)]| { + let mut b = test_font(family, Some(fields)); + if !base { + let tables = u16::from_be_bytes([b[4], b[5]]) as usize; + let rec = (0..tables).map(|t| 12 + 16 * t).find(|&r| &b[r..r + 4] == b"BASE").unwrap(); + // Still sorted between its neighbours, so the table directory stays valid. + b[rec..rec + 4].copy_from_slice(b"BASX"); + } + db.add_font(b); + db.face(family, "Regular").declared_em_box() + }; + // Typo ascender − descender = 1 em: the typo ascender is the top. + assert_eq!(add("EmBoxTypo 850", false, &[(TYPO_ASCENDER, 850), (TYPO_DESCENDER, -150)]), Some((850.0, -150.0))); + // Otherwise the em box is centred on half the cap height: 0.5 em + 600 / 2. + assert_eq!(add("EmBoxCapH 600", false, &[(TYPO_ASCENDER, 700), (CAP_HEIGHT, 600)]), Some((800.0, -200.0))); + // No OS/2 cap height: the H glyph's top, 656. + assert_eq!(add("EmBoxGlyphH 0", false, &[(TYPO_ASCENDER, 700), (CAP_HEIGHT, 0)]), Some((828.0, -172.0))); + // A `BASE` em box comes first. + assert_eq!(add("EmBoxBase 850", true, &[(TYPO_ASCENDER, 850), (TYPO_DESCENDER, -150)]), Some((830.0, -170.0))); let (mut d, sid, fid) = doc_with("Hxg Hxg", Rect::new(36.0, 36.0, 300.0, 300.0), ParaAttrs::default()); let mut first = |family: &str, sizes: [f64; 2], kind: FirstBaseline| { let st = d.story_mut(sid).unwrap(); @@ -1723,17 +1735,15 @@ fn em_box_first_baseline_uses_the_base_em_box_else_0_88_em() { compose_story(&d, sid, &ComposeOptions::default()).frames[0].lines[0].baseline - 36.0 }; let close = |a: f64, b: f64| (a - b).abs() < 0.01; - // Ascent: the 0.7 em typographic ascender; Em Box Height: the 0.88 em default em box top. - let asc = first("EmBoxTop 0700", [10.0; 2], FirstBaseline::Ascent); + let asc = first("EmBoxCapH 600", [10.0; 2], FirstBaseline::Ascent); assert!(close(asc, 7.0), "{asc}"); - let emb = first("EmBoxTop 0700", [10.0; 2], FirstBaseline::EmboxHeight); - assert!(close(emb, 8.8), "{emb}"); + for (family, top) in [("EmBoxTypo 850", 8.5), ("EmBoxCapH 600", 8.0), ("EmBoxGlyphH 0", 8.28), ("EmBoxBase 850", 8.3)] { + let emb = first(family, [10.0; 2], FirstBaseline::EmboxHeight); + assert!(close(emb, top), "{family}: {emb}"); + } // A mixed first line takes its largest size. - let emb = first("EmBoxTop 0700", [10.0, 20.0], FirstBaseline::EmboxHeight); - assert!(close(emb, 17.6), "{emb}"); - // A font's `BASE` em box wins over the default. - let emb = first("EmBoxBase 700", [10.0; 2], FirstBaseline::EmboxHeight); - assert!(close(emb, 8.3), "{emb}"); + let emb = first("EmBoxCapH 600", [10.0, 20.0], FirstBaseline::EmboxHeight); + assert!(close(emb, 16.0), "{emb}"); } #[test] diff --git a/crates/doc/src/item.rs b/crates/doc/src/item.rs index 7faf6e435..2cacbef36 100644 --- a/crates/doc/src/item.rs +++ b/crates/doc/src/item.rs @@ -281,7 +281,8 @@ pub enum FirstBaseline { Leading, XHeight, Fixed, - /// The top of the em box: the font's `BASE` em box, else 0.88 em above the baseline. + /// The top of the font's em box: its `BASE` em box; else its OS/2 typo ascender when the typo + /// ascender and descender span one em; else half an em above half the cap height. EmboxHeight, } diff --git a/crates/fonts/src/fontdb.rs b/crates/fonts/src/fontdb.rs index a47118c61..96ef4d966 100644 --- a/crates/fonts/src/fontdb.rs +++ b/crates/fonts/src/fontdb.rs @@ -263,13 +263,14 @@ impl FontFace { pub fn v_origin(&self, gid: u32) -> f64 { crate::vertical::VMetrics::new(self).origin(GlyphId::new(gid)) } - /// The ideographic em box (top, bottom) in font units, y up (Japanese fonts: typically 880, - /// -120): `BASE` `idtp`/`ideo`, else the OS/2 typo ascender/descender, else the ascender and - /// descender, the last two centred on one em. + /// The ideographic em box (top, bottom) in font units, y up, one em tall (Japanese fonts: + /// typically 880, -120): `BASE` `idtp`/`ideo`; else from the OS/2 typo ascender when the typo + /// ascender and descender span one em; else centred on half the cap height (OS/2, else the + /// `H` glyph's top); else 0.88 em above the baseline. pub fn em_box(&self) -> (f64, f64) { self.em.get_or_init(|| crate::vertical::em_box(self)).0 } - /// The em box (top, bottom) in font units, y up, when the font declares one in `BASE`. + /// [`Self::em_box`] when the font's tables give it, `None` for the 0.88 em fallback. pub fn declared_em_box(&self) -> Option<(f64, f64)> { let (b, declared) = *self.em.get_or_init(|| crate::vertical::em_box(self)); declared.then_some(b) @@ -712,10 +713,10 @@ fn make_face(bytes: FontBytes, source: FontSource, index: u32, family: String, s // 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(|| measured_top(&f, &location, &['H', 'I', 'E', 'T'], upem)) + .or_else(|| measured_top(&f, (&location).into(), &['H', 'I', 'E', 'T'], upem)) .unwrap_or(m.ascent as f64 * 0.72), x_height: plausible(m.x_height.map(f64::from), upem) - .or_else(|| measured_top(&f, &location, &['x', 'z', 'v'], upem)) + .or_else(|| measured_top(&f, (&location).into(), &['x', 'z', 'v'], upem)) .unwrap_or(m.ascent as f64 * 0.5), shaper, coords, @@ -731,14 +732,14 @@ fn make_face(bytes: FontBytes, source: FontSource, index: u32, family: String, s } /// A font's height above the baseline (font units) if it is plausible: above 0 and at most 4 em. -fn plausible(v: Option, upem: f64) -> Option { +pub(crate) 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 { +pub(crate) fn measured_top(f: &skrifa::FontRef<'_>, location: LocationRef<'_>, chars: &[char], upem: f64) -> Option { struct Top(f32); impl OutlinePen for Top { fn move_to(&mut self, _x: f32, y: f32) { diff --git a/crates/fonts/src/tests_vertical.rs b/crates/fonts/src/tests_vertical.rs index 729ba3b74..1e3cd4fb4 100644 --- a/crates/fonts/src/tests_vertical.rs +++ b/crates/fonts/src/tests_vertical.rs @@ -111,14 +111,24 @@ fn fonts_without_vmtx_use_the_em_box() { assert_eq!(face.em_box(), (830.0, -170.0)); assert_eq!(face.v_advance(gid), face.upem); assert_eq!(face.v_origin(gid), 830.0); - // Without BASE either: the OS/2 typo ascender and descender (1000, -326), centred on one em. + // Without BASE either: the typo ascender and descender (1000, -326) span more than one em, so + // the em box is centred on half the OS/2 cap height (660). let face = face_of(with_table_len(font_with("No Base", &['一']).unwrap(), b"BASE", 0).unwrap()); - assert_eq!(face.em_box(), (837.0, -163.0)); - // Without OS/2: the ascender and descender, the same way. - let face = face_of(with_table_len(with_table_len(font_with("No OS2", &['一']).unwrap(), b"BASE", 0).unwrap(), b"OS/2", 0).unwrap()); - let (top, bottom) = face.em_box(); - assert_eq!(top - bottom, face.upem); - assert!((top + bottom - (face.ascent - face.descent)).abs() < 1e-9, "{top} {bottom}"); + assert_eq!(face.em_box(), (830.0, -170.0)); + assert_eq!(face.declared_em_box(), Some((830.0, -170.0))); + // Typo ascender and descender one em apart: the typo ascender is the top. + let typo = with_table_u16(font_with("Typo Em", &['一']).unwrap(), b"OS/2", 68, 860).unwrap(); + let typo = with_table_u16(typo, b"OS/2", 70, u16::from_be_bytes((-140i16).to_be_bytes())).unwrap(); + let face = face_of(with_table_len(typo, b"BASE", 0).unwrap()); + assert_eq!(face.em_box(), (860.0, -140.0)); + // Without OS/2: centred on half the H glyph's height (656). + let no_os2 = |font: Vec| face_of(with_table_len(with_table_len(font, b"BASE", 0).unwrap(), b"OS/2", 0).unwrap()); + let face = no_os2(font_mapping("No OS2", &[('一', 'X'), ('H', 'H')]).unwrap()); + assert_eq!(face.em_box(), (828.0, -172.0)); + // Without an H either: 0.88 em above the baseline, which the font doesn't declare. + let face = no_os2(font_with("No OS2 No H", &['一']).unwrap()); + assert_eq!(face.em_box(), (880.0, -120.0)); + assert_eq!(face.declared_em_box(), None); } #[test] diff --git a/crates/fonts/src/vertical.rs b/crates/fonts/src/vertical.rs index c6c65d3d2..b9f3002a5 100644 --- a/crates/fonts/src/vertical.rs +++ b/crates/fonts/src/vertical.rs @@ -1,6 +1,6 @@ //! Vertical metrics for upright glyphs in vertical text: advances down the line (`vmtx`), vertical //! origins (`VORG`, else `vmtx` and the glyph's top) and the ideographic em box (`BASE`, else -//! OS/2). Fonts without them fall back to the em box: one em down the line, the origin at its top. +//! OS/2 metrics). Fonts without them fall back to the em box: one em down the line, the origin at its top. use skrifa::instance::Size; use skrifa::metrics::GlyphMetrics; @@ -53,26 +53,27 @@ impl<'a> VMetrics<'a> { } } -/// The ideographic em box (top, bottom) in font units, y up: `BASE` `idtp`/`ideo`, else the OS/2 -/// typo ascender and descender, else the ascender and descender; the last two centred on one em. -/// The flag is true for an em box from `BASE`. +/// The ideographic em box (top, bottom) in font units, y up, one em tall: `BASE` `idtp`/`ideo`; +/// else topped by the OS/2 typo ascender when the typo ascender and descender span exactly one em; +/// else centred on half the cap height (OS/2 `sCapHeight`, else the `H` glyph's top); else 0.88 em +/// above the baseline. The flag is false for that last fallback. pub(crate) fn em_box(face: &FontFace) -> ((f64, f64), bool) { let upem = face.upem; - let font = face.skrifa(); - if let Some(b) = font.as_ref().and_then(|f| base_em_box(f, upem)) { + let fallback = ((upem * 0.88, upem * -0.12), false); + let Some(font) = face.skrifa() else { return fallback }; + if let Some(b) = base_em_box(&font, upem) { return (b, true); } - let centred = |asc: f64, desc: f64| { - let span = asc - desc; - (span > 0.0 && span.is_finite()).then(|| { - let top = asc - (span - upem) / 2.0; - (top, top - upem) - }) - }; - font.and_then(|f| f.os2().ok()) - .and_then(|t| centred(f64::from(t.s_typo_ascender()), f64::from(t.s_typo_descender()))) - .or_else(|| centred(face.ascent, -face.descent)) - .map_or(((upem * 0.88, upem * -0.12), false), |b| (b, false)) + let typo_top = font.os2().ok().and_then(|t| { + let (asc, desc) = (f64::from(t.s_typo_ascender()), f64::from(t.s_typo_descender())); + (asc - desc == upem && asc > 0.0 && asc <= upem).then_some(asc) + }); + let top = typo_top.or_else(|| { + let declared = font.metrics(Size::unscaled(), face.location()).cap_height.map(f64::from); + let cap = crate::fontdb::plausible(declared, upem).or_else(|| crate::fontdb::measured_top(&font, face.location(), &['H'], upem))?; + Some((upem + cap) / 2.0) + }); + top.map_or(fallback, |top| ((top, top - upem), true)) } /// The em box from the `BASE` table's horizontal axis: the Han script's values, else the default