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.
This commit is contained in:
aiongg
2026-10-11 01:12:08 +00:00
parent f386a98d7c
commit 0b72584465
6 changed files with 78 additions and 56 deletions
+2 -3
View File
@@ -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
}
+30 -20
View File
@@ -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]
+2 -1
View File
@@ -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,
}
+9 -8
View File
@@ -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<f64>, upem: f64) -> Option<f64> {
pub(crate) fn plausible(v: Option<f64>, upem: f64) -> Option<f64> {
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<f64> {
pub(crate) fn measured_top(f: &skrifa::FontRef<'_>, location: LocationRef<'_>, chars: &[char], upem: f64) -> Option<f64> {
struct Top(f32);
impl OutlinePen for Top {
fn move_to(&mut self, _x: f32, y: f32) {
+17 -7
View File
@@ -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<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());
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]
+18 -17
View File
@@ -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