mirror of
https://github.com/storytold/designcraft.git
synced 2026-10-31 13:46:52 -06:00
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:
@@ -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
@@ -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]
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user