mirror of
https://github.com/storytold/designcraft.git
synced 2026-10-27 11:46:51 -06:00
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:
@@ -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}");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user