Fonts: cap height and x-height from glyph outlines when a font doesn't declare them

When OS/2 has no usable sCapHeight / sxHeight, the face measures them from the
unhinted outlines of flat-topped letters (H I E T for cap height, x z v for
x-height) at the face's variation location, and falls back to a share of the
ascent only when none of those letters has a plausible outline. Measuring
outlines instead of the glyf bounding box also covers CFF and variable fonts.

Based on seanth's measurement in #292.

Co-authored-by: Sean Hammond <sean.hammond@und.edu>
This commit is contained in:
aiongg
2026-10-10 10:54:35 +00:00
co-authored by Sean Hammond
parent 673039f5dc
commit 771bbafb82
2 changed files with 92 additions and 10 deletions
+4 -4
View File
@@ -1579,18 +1579,18 @@ fn cap_height_and_x_height_first_baselines_use_the_fonts_cap_and_x_heights() {
fn cap_height_and_x_height_fall_back_without_usable_os2_values() {
use designcraft_doc::FirstBaseline;
let db = designcraft_fonts::FontDb::global();
// No OS/2 table, zero / negative and absurd values: the top of the H (0.656 em) and 0.5 of the
// (1 em) ascent.
// No OS/2 table, zero / negative and absurd values: the top of the H (0.656 em) and of the x
// (0.486 em).
let zero: &[(usize, i16)] = &[(CAP_HEIGHT, 0), (X_HEIGHT, -40)];
let big: &[(usize, i16)] = &[(CAP_HEIGHT, i16::MAX), (X_HEIGHT, i16::MAX)];
for (family, fields) in [("CapXHeightNoO", None), ("CapXHeightNeg", Some(zero)), ("CapXHeightBig", Some(big))] {
db.add_font(test_font(family, fields));
let face = db.face(family, "Regular");
assert_eq!(face.family, family);
assert_eq!((face.cap_height, face.x_height), (656.0, face.ascent * 0.5), "{family}");
assert_eq!((face.cap_height, face.x_height), (656.0, 486.0), "{family}");
let cap = first_baseline_in(family, |o| o.first_baseline = FirstBaseline::CapHeight);
let x = first_baseline_in(family, |o| o.first_baseline = FirstBaseline::XHeight);
assert!((cap - (40.0 + 13.12)).abs() < 0.01 && (x - (40.0 + 10.0)).abs() < 0.01, "{family}: {cap} {x}");
assert!((cap - (40.0 + 13.12)).abs() < 0.01 && (x - (40.0 + 9.72)).abs() < 0.01, "{family}: {cap} {x}");
}
}
+88 -6
View File
@@ -703,14 +703,15 @@ fn make_face(bytes: FontBytes, source: FontSource, index: u32, family: String, s
ascent: m.ascent as f64,
typo_ascent: typo_ascent(&f, &location, upem).unwrap_or(m.ascent as f64),
descent: -(m.descent as f64),
// Fonts with an OS/2 table older than version 2 don't declare these: measure them from
// 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(|| {
// No OS/2 cap height: the top of the H.
let gid = f.charmap().map('H')?;
plausible(f.glyph_metrics(Size::unscaled(), &location).bounds(gid).map(|b| b.y_max as f64), upem)
})
.or_else(|| measured_top(&f, &location, &['H', 'I', 'E', 'T'], upem))
.unwrap_or(m.ascent as f64 * 0.72),
x_height: plausible(m.x_height.map(f64::from), upem).unwrap_or(m.ascent as f64 * 0.5),
x_height: plausible(m.x_height.map(f64::from), upem)
.or_else(|| measured_top(&f, &location, &['x', 'z', 'v'], upem))
.unwrap_or(m.ascent as f64 * 0.5),
shaper,
coords,
location,
@@ -729,6 +730,36 @@ 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> {
struct Top(f32);
impl OutlinePen for Top {
fn move_to(&mut self, _x: f32, y: f32) {
self.0 = self.0.max(y);
}
fn line_to(&mut self, _x: f32, y: f32) {
self.0 = self.0.max(y);
}
fn quad_to(&mut self, _cx: f32, _cy: f32, _x: f32, y: f32) {
self.0 = self.0.max(y);
}
fn curve_to(&mut self, _cx0: f32, _cy0: f32, _cx1: f32, _cy1: f32, _x: f32, y: f32) {
self.0 = self.0.max(y);
}
fn close(&mut self) {}
}
let cmap = f.charmap();
let glyphs = f.outline_glyphs();
chars.iter().find_map(|&c| {
let g = glyphs.get(cmap.map(c)?)?;
let mut pen = Top(f32::NEG_INFINITY);
g.draw(DrawSettings::unhinted(Size::unscaled(), location), &mut pen).ok()?;
plausible(Some(f64::from(pen.0)), upem)
})
}
/// The OS/2 typographic ascender in font units at `location`, if the font has an OS/2 table and
/// the value is [`plausible`].
fn typo_ascent(f: &skrifa::FontRef<'_>, location: &Location, upem: f64) -> Option<f64> {
@@ -1483,3 +1514,54 @@ mod tests_docfonts;
#[cfg(test)]
#[path = "tests_vertical.rs"]
mod tests_vertical;
#[cfg(test)]
mod tests_heights {
use super::*;
use crate::testing::{font_mapping, with_table_u16};
fn face_of(bytes: Vec<u8>) -> FontFace {
make_face(FontBytes::Owned(Arc::new(bytes)), FontSource::Memory, 0, "Test".into(), "Regular".into(), Vec::new()).unwrap()
}
/// `font` with OS/2 sxHeight and sCapHeight set to 0 (undeclared).
fn undeclared(font: Vec<u8>) -> Vec<u8> {
with_table_u16(with_table_u16(font, b"OS/2", 86, 0).unwrap(), b"OS/2", 88, 0).unwrap()
}
/// The top of Source Sans 3's glyph for `c` from its bounding box (font units).
fn bbox_top(c: char) -> f64 {
let f = skrifa::FontRef::new(BUNDLED[0]).unwrap();
f.glyph_metrics(Size::unscaled(), LocationRef::default()).bounds(f.charmap().map(c).unwrap()).unwrap().y_max as f64
}
/// A font that doesn't declare its cap height and x-height gets them measured from its
/// flat-topped letters, also when it lacks the first choices `H` and `x`; a font that
/// declares them keeps them.
#[test]
fn undeclared_heights_are_measured_from_outlines() {
let glyphs = [('H', 'H'), ('x', 'x'), ('E', 'E'), ('z', 'z')];
for (glyphs, cap, x) in [(&glyphs[..], 'H', 'x'), (&glyphs[2..], 'E', 'z')] {
let face = face_of(undeclared(font_mapping("Heights", glyphs).unwrap()));
let (cap, x) = (bbox_top(cap), bbox_top(x));
assert!(cap > x && x > 0.0);
assert!((face.cap_height - cap).abs() <= 1.0, "{glyphs:?}: cap height {} vs {cap}", face.cap_height);
assert!((face.x_height - x).abs() <= 1.0, "{glyphs:?}: x-height {} vs {x}", face.x_height);
}
let declared = face_of(font_mapping("Heights", &glyphs).unwrap());
assert_eq!((declared.cap_height, declared.x_height), (660.0, 486.0), "Source Sans 3 Regular's OS/2 values");
// Declared values win over the outlines: `H` drawn as `x` doesn't lower the cap height.
let face = face_of(font_mapping("Heights", &[('H', 'x'), ('x', 'H')]).unwrap());
assert_eq!((face.cap_height, face.x_height), (declared.cap_height, declared.x_height));
}
/// Without any of the letters, or with only empty outlines, the heights fall back to a share
/// of the ascent.
#[test]
fn heights_without_measurable_letters_fall_back_to_the_ascent() {
for glyphs in [&[('a', 'a')][..], &[('H', ' '), ('x', ' ')]] {
let face = face_of(undeclared(font_mapping("Heights", glyphs).unwrap()));
assert_eq!((face.cap_height, face.x_height), (face.ascent * 0.72, face.ascent * 0.5), "{glyphs:?}");
}
}
}