mirror of
https://github.com/storytold/designcraft.git
synced 2026-10-30 05:06:48 -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() {
|
fn cap_height_and_x_height_fall_back_without_usable_os2_values() {
|
||||||
use designcraft_doc::FirstBaseline;
|
use designcraft_doc::FirstBaseline;
|
||||||
let db = designcraft_fonts::FontDb::global();
|
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
|
// No OS/2 table, zero / negative and absurd values: the top of the H (0.656 em) and of the x
|
||||||
// (1 em) ascent.
|
// (0.486 em).
|
||||||
let zero: &[(usize, i16)] = &[(CAP_HEIGHT, 0), (X_HEIGHT, -40)];
|
let zero: &[(usize, i16)] = &[(CAP_HEIGHT, 0), (X_HEIGHT, -40)];
|
||||||
let big: &[(usize, i16)] = &[(CAP_HEIGHT, i16::MAX), (X_HEIGHT, i16::MAX)];
|
let big: &[(usize, i16)] = &[(CAP_HEIGHT, i16::MAX), (X_HEIGHT, i16::MAX)];
|
||||||
for (family, fields) in [("CapXHeightNoO", None), ("CapXHeightNeg", Some(zero)), ("CapXHeightBig", Some(big))] {
|
for (family, fields) in [("CapXHeightNoO", None), ("CapXHeightNeg", Some(zero)), ("CapXHeightBig", Some(big))] {
|
||||||
db.add_font(test_font(family, fields));
|
db.add_font(test_font(family, fields));
|
||||||
let face = db.face(family, "Regular");
|
let face = db.face(family, "Regular");
|
||||||
assert_eq!(face.family, family);
|
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 cap = first_baseline_in(family, |o| o.first_baseline = FirstBaseline::CapHeight);
|
||||||
let x = first_baseline_in(family, |o| o.first_baseline = FirstBaseline::XHeight);
|
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,
|
ascent: m.ascent as f64,
|
||||||
typo_ascent: typo_ascent(&f, &location, upem).unwrap_or(m.ascent as f64),
|
typo_ascent: typo_ascent(&f, &location, upem).unwrap_or(m.ascent as f64),
|
||||||
descent: -(m.descent 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)
|
cap_height: plausible(m.cap_height.map(f64::from), upem)
|
||||||
.or_else(|| {
|
.or_else(|| measured_top(&f, &location, &['H', 'I', 'E', 'T'], upem))
|
||||||
// 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)
|
|
||||||
})
|
|
||||||
.unwrap_or(m.ascent as f64 * 0.72),
|
.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,
|
shaper,
|
||||||
coords,
|
coords,
|
||||||
location,
|
location,
|
||||||
@@ -729,6 +730,36 @@ fn plausible(v: Option<f64>, upem: f64) -> Option<f64> {
|
|||||||
v.filter(|v| *v > 0.0 && *v <= 4.0 * upem)
|
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 OS/2 typographic ascender in font units at `location`, if the font has an OS/2 table and
|
||||||
/// the value is [`plausible`].
|
/// the value is [`plausible`].
|
||||||
fn typo_ascent(f: &skrifa::FontRef<'_>, location: &Location, upem: f64) -> Option<f64> {
|
fn typo_ascent(f: &skrifa::FontRef<'_>, location: &Location, upem: f64) -> Option<f64> {
|
||||||
@@ -1483,3 +1514,54 @@ mod tests_docfonts;
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
#[path = "tests_vertical.rs"]
|
#[path = "tests_vertical.rs"]
|
||||||
mod tests_vertical;
|
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