From 58f0e8c9b7bcff776315e6a43c4ec2a337fa6d0d Mon Sep 17 00:00:00 2001 From: aiongg Date: Sun, 11 Oct 2026 04:28:46 +0000 Subject: [PATCH] Layout: only CJK fonts take the em box top from a one-em typo box A font's em box is its BASE em box; else, for a CJK font (its font menu group isn't Western: meta or name languages, CJK code pages, else kana, Hangul or ideograph coverage), the OS/2 typo ascender when the typo ascender and descender span exactly one em; else 0.5 em + cap height / 2; else 0.88 em. Latin fonts with a one-em typo box, such as DejaVu Serif, centre on the cap height. --- crates/compose/src/tests.rs | 44 +++++++++++++++++++++++++++--- crates/doc/src/item.rs | 5 ++-- crates/fonts/src/fontdb.rs | 6 ++-- crates/fonts/src/tests_vertical.rs | 22 +++++++++++---- crates/fonts/src/vertical.rs | 11 +++++--- 5 files changed, 70 insertions(+), 18 deletions(-) diff --git a/crates/compose/src/tests.rs b/crates/compose/src/tests.rs index ff5b836fc..391ba5486 100644 --- a/crates/compose/src/tests.rs +++ b/crates/compose/src/tests.rs @@ -1589,9 +1589,11 @@ fn only_english_text_gets_english_hyphenation() { assert_eq!(hyphenated(&d2, sid), 0, "French isn't hyphenated with English rules"); } -/// OS/2 field offsets: sTypoAscender, sxHeight, sCapHeight. +/// OS/2 field offsets: sTypoAscender, sTypoDescender, the high half of ulCodePageRange1, sxHeight, +/// sCapHeight. const TYPO_ASCENDER: usize = 68; const TYPO_DESCENDER: usize = 70; +const CODE_PAGES_HIGH: usize = 78; const X_HEIGHT: usize = 86; const CAP_HEIGHT: usize = 88; @@ -1713,8 +1715,12 @@ fn em_box_first_baseline_follows_base_then_the_typo_box_or_the_cap_height() { 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))); + // Typo ascender − descender = 1 em in a CJK font (JIS code page, bit 17): the typo ascender is + // the top. + let typo = [(TYPO_ASCENDER, 850), (TYPO_DESCENDER, -150)]; + assert_eq!(add("EmBoxTypo 850", false, &[typo[0], typo[1], (CODE_PAGES_HIGH, 1 << 1)]), Some((850.0, -150.0))); + // In a Latin font the cap height decides: 0.5 em + 660 / 2. + assert_eq!(add("EmBoxLatn 850", false, &typo), Some((830.0, -170.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. @@ -1737,7 +1743,7 @@ fn em_box_first_baseline_follows_base_then_the_typo_box_or_the_cap_height() { let close = |a: f64, b: f64| (a - b).abs() < 0.01; let asc = first("EmBoxCapH 600", [10.0; 2], FirstBaseline::Ascent); assert!(close(asc, 7.0), "{asc}"); - for (family, top) in [("EmBoxTypo 850", 8.5), ("EmBoxCapH 600", 8.0), ("EmBoxGlyphH 0", 8.28), ("EmBoxBase 850", 8.3)] { + for (family, top) in [("EmBoxTypo 850", 8.5), ("EmBoxLatn 850", 8.3), ("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}"); } @@ -1746,6 +1752,36 @@ fn em_box_first_baseline_follows_base_then_the_typo_box_or_the_cap_height() { assert!(close(emb, 16.0), "{emb}"); } +#[test] +fn em_box_first_baseline_in_a_latin_font_with_a_one_em_typo_box_follows_the_cap_height() { + use designcraft_doc::FirstBaseline; + // DejaVu Serif: 2048 units per em, typo ascender 1556 and descender -492 (one em), cap height + // 1493. Read from the system fonts; the test skips where it isn't installed. + let Some(bytes) = designcraft_fonts::system_font_dirs().iter().find_map(|dir| { + ["truetype/dejavu/DejaVuSerif.ttf", "dejavu/DejaVuSerif.ttf", "DejaVuSerif.ttf"].iter().find_map(|p| std::fs::read(dir.join(p)).ok()) + }) else { + eprintln!("skipped: DejaVu Serif isn't installed"); + return; + }; + let db = designcraft_fonts::FontDb::global(); + db.add_font(bytes); + let face = db.face("DejaVu Serif", "Book"); + assert_eq!(face.family, "DejaVu Serif"); + let (mut d, sid, fid) = doc_with("Hxg Hxg", Rect::new(36.0, 36.0, 300.0, 300.0), ParaAttrs::default()); + let st = d.story_mut(sid).unwrap(); + let n = st.len(); + st.format_chars(0..n, |f| { + f.over.font_family = Some("DejaVu Serif".into()); + f.over.font_style = Some("Book".into()); + f.over.size = Some(10.0); + }); + d.item_mut(fid).unwrap().text_frame_mut().unwrap().options.first_baseline = FirstBaseline::EmboxHeight; + let first = compose_story(&d, sid, &ComposeOptions::default()).frames[0].lines[0].baseline - 36.0; + // 0.5 em + cap height / 2, not the 1556-unit typo ascender (7.60 pt). + let expected = 5.0 + 1493.0 / 2048.0 * 5.0; + assert!((first - expected).abs() < 0.01, "{first}, expected {expected}"); +} + #[test] fn ascent_first_baseline_falls_back_to_the_ascent_without_a_usable_typo_ascender() { let db = designcraft_fonts::FontDb::global(); diff --git a/crates/doc/src/item.rs b/crates/doc/src/item.rs index 2cacbef36..452a229b4 100644 --- a/crates/doc/src/item.rs +++ b/crates/doc/src/item.rs @@ -281,8 +281,9 @@ pub enum FirstBaseline { Leading, XHeight, Fixed, - /// 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. + /// The top of the font's em box: its `BASE` em box; else, for a CJK font, 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 96ef4d966..b619f8210 100644 --- a/crates/fonts/src/fontdb.rs +++ b/crates/fonts/src/fontdb.rs @@ -264,9 +264,9 @@ impl FontFace { crate::vertical::VMetrics::new(self).origin(GlyphId::new(gid)) } /// 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. + /// typically 880, -120): `BASE` `idtp`/`ideo`; else, for a CJK font, 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 } diff --git a/crates/fonts/src/tests_vertical.rs b/crates/fonts/src/tests_vertical.rs index 1e3cd4fb4..4f1e82c20 100644 --- a/crates/fonts/src/tests_vertical.rs +++ b/crates/fonts/src/tests_vertical.rs @@ -6,7 +6,7 @@ use skrifa::raw::TableProvider; use super::*; -use crate::testing::{font_mapping, font_with, with_table_len, with_table_u16, with_vmtx, with_vorg}; +use crate::testing::{font_mapping, font_with, with_code_pages, with_table_len, with_table_u16, with_vmtx, with_vorg}; use crate::{ShapeContext, feature, shape_vertical, shape_with_context}; /// Text in Japanese. @@ -116,11 +116,23 @@ fn fonts_without_vmtx_use_the_em_box() { let face = face_of(with_table_len(font_with("No Base", &['一']).unwrap(), b"BASE", 0).unwrap()); 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()); + // Typo ascender and descender one em apart: a CJK font (JIS code page) tops at the typo + // ascender; a Latin one (Source Sans 3's own code pages) still centres on the cap height. + let typo = |family: &str, code_pages: Option| { + let font = with_table_u16(font_with(family, &['一']).unwrap(), b"OS/2", 68, 860).unwrap(); + let font = with_table_u16(font, b"OS/2", 70, u16::from_be_bytes((-140i16).to_be_bytes())).unwrap(); + let font = match code_pages { + Some(r1) => with_code_pages(font, r1, 0).unwrap(), + None => font, + }; + face_of(with_table_len(font, b"BASE", 0).unwrap()) + }; + let face = typo("Typo Em CJK", Some(1 << 17)); + assert_ne!(face.menu_group().0, crate::FontGroup::Western); assert_eq!(face.em_box(), (860.0, -140.0)); + let face = typo("Typo Em Latin", None); + assert_eq!(face.menu_group().0, crate::FontGroup::Western); + assert_eq!(face.em_box(), (830.0, -170.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()); diff --git a/crates/fonts/src/vertical.rs b/crates/fonts/src/vertical.rs index b9f3002a5..6483f4165 100644 --- a/crates/fonts/src/vertical.rs +++ b/crates/fonts/src/vertical.rs @@ -54,9 +54,10 @@ impl<'a> VMetrics<'a> { } /// 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. +/// else, for a CJK font (its [`crate::FontGroup`] isn't Western), 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 fallback = ((upem * 0.88, upem * -0.12), false); @@ -64,7 +65,9 @@ pub(crate) fn em_box(face: &FontFace) -> ((f64, f64), bool) { if let Some(b) = base_em_box(&font, upem) { return (b, true); } - let typo_top = font.os2().ok().and_then(|t| { + // Latin fonts with a one-em typo box (DejaVu) still centre on the cap height. + let cjk = face.menu_group().0 != crate::FontGroup::Western; + let typo_top = font.os2().ok().filter(|_| cjk).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) });