Merge feat/layout-parity: Layout: only CJK fonts take the em box top from a one-em typo box

Conflict: crates/compose/src/tests.rs: next's baseline-fit and keep tests, then the branch's OS/2 offset doc and code-page constant
Conflict: crates/fonts/src/tests_vertical.rs: testing imports from both (with_base, with_code_pages)
This commit is contained in:
aiongg
2026-10-11 05:19:16 +00:00
5 changed files with 70 additions and 18 deletions
+40 -4
View File
@@ -2243,9 +2243,11 @@ fn satisfiable_keep_chain_still_moves_together_to_the_next_frame() {
assert_eq!(cs.frame(second).unwrap().lines.iter().map(|l| l.para).collect::<Vec<_>>(), [1, 2, 3]);
}
/// 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;
@@ -2367,8 +2369,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.
@@ -2391,7 +2397,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}");
}
@@ -2400,6 +2406,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();
+3 -2
View File
@@ -324,8 +324,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,
}
+3 -3
View File
@@ -277,9 +277,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
}
+17 -5
View File
@@ -6,7 +6,7 @@
use skrifa::raw::TableProvider;
use super::*;
use crate::testing::{font_mapping, font_with, with_base, with_table_len, with_table_u16, with_vmtx, with_vorg};
use crate::testing::{font_mapping, font_with, with_base, 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<u32>| {
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<u8>| 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());
+7 -4
View File
@@ -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)
});