Text: the Ascent first baseline uses the font's typographic ascender

This commit is contained in:
aiongg
2026-10-07 09:04:59 -04:00
parent d582277cc7
commit 84361ca9f9
4 changed files with 168 additions and 11 deletions
+14 -9
View File
@@ -688,7 +688,7 @@ pub fn compose(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &Compo
_ => col,
};
// Estimate slots for the breaker with the paragraph's base leading.
let est_first = cur.next_baseline(f, col, base_leading, base_chars.size * 0.75, &pp);
let est_first = cur.next_baseline(f, col, base_leading, base_chars.size * 0.75, base_chars.size * 0.75, &pp);
let slots = estimate_slots(f, col, est_first, base_leading, base_chars.size, &glyphs[g0..], &pp, line_no);
let width = |j: usize| -> f64 {
let (x0, x1) = slots.get(j).copied().unwrap_or((col.x0, col.x1));
@@ -708,8 +708,8 @@ pub fn compose(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &Compo
for (k, b) in breaks.iter().enumerate() {
let (s, e) = (g0 + b.start, g0 + b.end);
let line_glyphs = &glyphs[s..e.max(s)];
let (asc, desc, lead) = line_metrics(line_glyphs, &glyphs, s, base_leading, base_chars.size, db, &base_chars);
let mut baseline = cur.next_baseline(f, col, lead, asc, &pp);
let (asc, typo_asc, desc, lead) = line_metrics(line_glyphs, &glyphs, s, base_leading, base_chars.size, db, &base_chars);
let mut baseline = cur.next_baseline(f, col, lead, asc, typo_asc, &pp);
// Baseline grid.
if let Some((g_start, inc)) = f.grid
&& (pp.grid_align == GridAlign::AllLines || (pp.grid_align == GridAlign::FirstLineOnly && line_no == 0))
@@ -1287,13 +1287,16 @@ impl Cursor {
self.last_baseline = None;
self.pending = 0.0;
}
/// Baseline for the next line with leading `lead` and ascent `asc`.
fn next_baseline(&self, f: &FrameSpec, col: Rect, lead: f64, asc: f64, _pp: &ParaProps) -> f64 {
/// Baseline for the next line with leading `lead`, ascent `asc` and typographic ascender
/// `typo_asc`.
fn next_baseline(&self, f: &FrameSpec, col: Rect, lead: f64, asc: f64, typo_asc: f64, _pp: &ParaProps) -> f64 {
match self.last_baseline {
Some(b) => b + lead + self.pending,
None => {
let off = match f.opts.first_baseline {
FirstBaseline::Ascent => asc,
// Vertical frames keep the ascent.
FirstBaseline::Ascent if f.opts.vertical => asc,
FirstBaseline::Ascent => typo_asc,
FirstBaseline::CapHeight => asc * 0.72,
FirstBaseline::XHeight => asc * 0.5,
FirstBaseline::Leading => lead,
@@ -1313,23 +1316,25 @@ fn line_metrics(
base_size: f64,
db: &FontDb,
base: &designcraft_doc::CharProps,
) -> (f64, f64, f64) {
) -> (f64, f64, f64, f64) {
let _ = db;
let src: &[Glyph] = if line.is_empty() { all.get(s..(s + 1).min(all.len())).unwrap_or(&[]) } else { line };
if src.is_empty() {
let face = FontDb::global().face(&base.font_family, &base.font_style);
let k = base_size / face.upem;
return (face.ascent * k, face.descent * k, base_leading);
return (face.ascent * k, face.typo_ascent * k, face.descent * k, base_leading);
}
let mut asc: f64 = 0.0;
let mut typo_asc: f64 = 0.0;
let mut desc: f64 = 0.0;
let mut lead: f64 = 0.0;
for g in src {
asc = asc.max(g.ascent + g.shift.max(0.0));
typo_asc = typo_asc.max(g.typo_ascent + g.shift.max(0.0));
desc = desc.max(g.descent - g.shift.min(0.0));
lead = lead.max(g.leading);
}
(asc, desc, lead)
(asc, typo_asc, desc, lead)
}
/// Widest free horizontal interval of `col` in the band, or None if blocked.
+10 -2
View File
@@ -28,6 +28,8 @@ pub struct Glyph {
/// Baseline shift (positive = up), including super/subscript.
pub shift: f64,
pub ascent: f64,
/// The font's typographic ascender, scaled like `ascent`: the Ascent first baseline offset.
pub typo_ascent: f64,
pub descent: f64,
/// The leading this character asks for (absolute, or auto = size × auto %).
pub leading: f64,
@@ -219,7 +221,7 @@ pub(crate) fn shape_para(
}
let mut g = control_glyph(&face, &props, auto_leading, style, a + i, c);
g.ch = HIDDEN;
(g.ascent, g.descent, g.leading, g.cap, g.xh) = (0.0, 0.0, 0.0, 0.0, 0.0);
(g.ascent, g.typo_ascent, g.descent, g.leading, g.cap, g.xh) = (0.0, 0.0, 0.0, 0.0, 0.0, 0.0);
glyphs.push(g);
}
continue;
@@ -415,10 +417,14 @@ fn shape_run(
Some(y) => {
g.adv = o.w;
g.ascent = g.ascent.max(o.h + y);
g.typo_ascent = g.typo_ascent.max(o.h + y);
g.descent = g.descent.max(-y);
}
// Pushes its line down by its height and spacing.
None => g.ascent += o.h + o.space,
None => {
g.ascent += o.h + o.space;
g.typo_ascent += o.h + o.space;
}
}
if auto || o.y_offset.is_none() {
g.leading = g.leading.max(g.ascent + g.descent);
@@ -574,6 +580,7 @@ fn control_glyph(face: &Arc<FontFace>, p: &CharProps, auto_leading: TypeEnv, sty
sy: k * p.v_scale,
shift,
ascent,
typo_ascent: face.typo_ascent * k * p.v_scale,
descent,
leading,
cap,
@@ -644,6 +651,7 @@ fn shape_segment(
sy: k * p.v_scale,
shift,
ascent,
typo_ascent: face.typo_ascent * k * p.v_scale,
descent,
leading,
cap,
+126
View File
@@ -1027,3 +1027,129 @@ fn only_english_text_gets_english_hyphenation() {
st.format_chars(0..n, |f| f.over.language = Some("French".into()));
assert_eq!(hyphenated(&d2, sid), 0, "French isn't hyphenated with English rules");
}
/// Source Sans 3 Regular (OS/2 typo ascender = hhea ascender = 1000/1000 em) renamed to `family`
/// (13 characters, the length of "Source Sans 3"), with OS/2 `sTypoAscender` set to
/// `typo_ascender`, or without an OS/2 table for `None`.
fn typo_test_font(family: &str, typo_ascender: Option<i16>) -> Vec<u8> {
let mut b = designcraft_fonts::bundled()[0].to_vec();
assert_eq!(family.len(), "Source Sans 3".len());
let utf16 = |s: &str| s.encode_utf16().flat_map(u16::to_be_bytes).collect::<Vec<u8>>();
for (from, to) in [(utf16("Source Sans 3"), utf16(family)), (b"Source Sans 3".to_vec(), family.as_bytes().to_vec())] {
let mut i = 0;
while let Some(p) = b[i..].windows(from.len()).position(|w| w == from.as_slice()) {
b[i + p..i + p + to.len()].copy_from_slice(&to);
i += p + to.len();
}
}
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"OS/2").unwrap();
match typo_ascender {
Some(v) => {
let os2 = u32::from_be_bytes(b[rec + 8..rec + 12].try_into().unwrap()) as usize;
b[os2 + 68..os2 + 70].copy_from_slice(&v.to_be_bytes());
}
// Still sorted between its neighbours, so the table directory stays valid.
None => b[rec..rec + 4].copy_from_slice(b"OS/1"),
}
b
}
/// First baseline (frame space) of a 20 pt line in `family` in a frame at y = 36 with a 4 pt top
/// inset, set up by `opts`.
fn first_baseline_in(family: &str, opts: impl Fn(&mut designcraft_doc::TextFrameOptions)) -> f64 {
let (mut d, sid, fid) = doc_with("Hxg Hxg", Rect::new(36.0, 36.0, 300.0, 300.0), ParaAttrs::default());
d.story_mut(sid).unwrap().format_chars(0..7, |f| {
f.over.font_family = Some(family.into());
f.over.font_style = Some("Regular".into());
f.over.size = Some(20.0);
});
let tf = d.item_mut(fid).unwrap().text_frame_mut().unwrap();
tf.options.inset = [4.0, 0.0, 0.0, 0.0];
opts(&mut tf.options);
let cs = compose_story(&d, sid, &ComposeOptions::default());
cs.frames[0].lines[0].baseline
}
#[test]
fn ascent_first_baseline_uses_the_typographic_ascender() {
use designcraft_doc::FirstBaseline;
let db = designcraft_fonts::FontDb::global();
db.add_font(typo_test_font("TypoAscent 07", Some(700)));
let face = db.face("TypoAscent 07", "Regular");
assert_eq!(face.family, "TypoAscent 07");
assert_eq!((face.ascent, face.typo_ascent), (1000.0, 700.0), "hhea 1 em, typo 0.7 em");
let top = 36.0 + 4.0;
let at = |kind: FirstBaseline, min: f64| {
first_baseline_in("TypoAscent 07", |o| {
o.first_baseline = kind;
o.first_baseline_min = min;
})
};
let close = |a: f64, b: f64| (a - b).abs() < 0.01;
// Ascent: top + typo ascender (0.7 em of 20 pt), not the hhea ascender (1 em).
assert!(close(at(FirstBaseline::Ascent, 0.0), top + 14.0), "{}", at(FirstBaseline::Ascent, 0.0));
// The minimum offset still wins when it is larger.
assert!(close(at(FirstBaseline::Ascent, 18.0), top + 18.0));
assert!(close(at(FirstBaseline::Ascent, 10.0), top + 14.0));
// The other kinds are unchanged.
assert!(close(at(FirstBaseline::CapHeight, 0.0), top + 20.0 * 0.72));
assert!(close(at(FirstBaseline::XHeight, 0.0), top + 10.0));
assert!(close(at(FirstBaseline::Leading, 0.0), top + 24.0));
assert!(close(at(FirstBaseline::Fixed, 0.0), top));
// Vertical scale scales it like the ascent.
let (mut d, sid, fid) = doc_with("Hxg", Rect::new(36.0, 36.0, 300.0, 300.0), ParaAttrs::default());
d.story_mut(sid).unwrap().format_chars(0..3, |f| {
f.over.font_family = Some("TypoAscent 07".into());
f.over.size = Some(20.0);
f.over.v_scale = Some(1.5);
});
d.item_mut(fid).unwrap().text_frame_mut().unwrap().options.inset = [4.0, 0.0, 0.0, 0.0];
let l = &compose_story(&d, sid, &ComposeOptions::default()).frames[0].lines[0];
assert!(close(l.baseline, top + 21.0), "{}", l.baseline);
// The line box keeps the font's ascent.
assert!(close(l.ascent, 30.0), "{}", l.ascent);
// A mixed first line takes its tallest typographic ascender: 0.7 × 20 pt beats 1 em × 10 pt …
let (mut d, sid, fid) = doc_with("Hxg Hxg", Rect::new(36.0, 36.0, 300.0, 300.0), ParaAttrs::default());
let st = d.story_mut(sid).unwrap();
st.format_chars(0..3, |f| {
f.over.font_family = Some("TypoAscent 07".into());
f.over.size = Some(20.0);
});
st.format_chars(3..7, |f| {
f.over.font_family = Some("Source Sans 3".into());
f.over.size = Some(10.0);
});
d.item_mut(fid).unwrap().text_frame_mut().unwrap().options.inset = [4.0, 0.0, 0.0, 0.0];
let first = |d: &Document| compose_story(d, sid, &ComposeOptions::default()).frames[0].lines[0].baseline;
assert!(close(first(&d), top + 14.0), "{}", first(&d));
// … and 1 em × 16 pt beats it.
d.story_mut(sid).unwrap().format_chars(3..7, |f| f.over.size = Some(16.0));
assert!(close(first(&d), top + 16.0), "{}", first(&d));
}
#[test]
fn ascent_first_baseline_falls_back_to_the_ascent_without_a_usable_typo_ascender() {
let db = designcraft_fonts::FontDb::global();
// No OS/2 table, a negative and an absurd typo ascender: the (hhea) ascent, 1 em.
for (family, typo) in [("TypoAscentNoO", None), ("TypoAscentNeg", Some(-300)), ("TypoAscentBig", Some(i16::MAX))] {
db.add_font(typo_test_font(family, typo));
let face = db.face(family, "Regular");
assert_eq!(face.family, family);
assert_eq!(face.typo_ascent, face.ascent, "{family}");
let b = first_baseline_in(family, |_| {});
assert!((b - (36.0 + 4.0 + 20.0)).abs() < 0.01, "{family}: {b}");
}
}
#[test]
fn vertical_frames_keep_the_ascent_first_baseline() {
let db = designcraft_fonts::FontDb::global();
db.add_font(typo_test_font("TypoAscentVrt", Some(700)));
let b = first_baseline_in("TypoAscentVrt", |o| {
o.vertical = true;
o.inset = [0.0; 4];
});
// Composed in the turned box (no inset): the hhea ascent, 1 em of 20 pt.
assert!((b - 20.0).abs() < 0.01, "{b}");
}
+18
View File
@@ -58,6 +58,9 @@ pub struct FontFace {
pub upem: f64,
/// Ascender in font units (positive = up).
pub ascent: f64,
/// OS/2 typographic ascender in font units (the ascent when the font has no usable one): the
/// Ascent first baseline offset.
pub typo_ascent: f64,
/// Descender in font units (positive = down).
pub descent: f64,
/// Cap height and x height in font units (estimated from the ascent when the font has no OS/2
@@ -449,6 +452,7 @@ fn make_face(bytes: FontBytes, index: u32, family: String, style: String, coords
italic,
upem: m.units_per_em.max(1) as f64,
ascent: m.ascent as f64,
typo_ascent: typo_ascent(&f, &location, m.units_per_em.max(1) as f64, m.ascent as f64),
descent: -(m.descent as f64),
cap_height: m.cap_height.map(|v| v as f64).filter(|v| *v > 0.0).unwrap_or(m.ascent as f64 * 0.72),
x_height: m.x_height.map(|v| v as f64).filter(|v| *v > 0.0).unwrap_or(m.ascent as f64 * 0.5),
@@ -462,6 +466,20 @@ fn make_face(bytes: FontBytes, index: u32, family: String, style: String, coords
})
}
/// The OS/2 typographic ascender in font units at `location`, or `fallback` when the font has no
/// OS/2 table or the value isn't a plausible ascender (not above 0, or over 4 em).
fn typo_ascent(f: &skrifa::FontRef<'_>, location: &Location, upem: f64, fallback: f64) -> f64 {
use skrifa::raw::TableProvider;
let Ok(os2) = f.os2() else { return fallback };
let mut v = os2.s_typo_ascender() as f64;
if !location.coords().is_empty()
&& let Ok(mvar) = f.mvar()
{
v += mvar.metric_delta(skrifa::raw::tables::mvar::tags::HASC, location.coords()).map_or(0.0, |d| d.to_f64());
}
if v > 0.0 && v <= 4.0 * upem { v } else { fallback }
}
/// The named style part of a style with axis settings (`Bold {wght:650}` → `Bold`).
pub fn base_style(style: &str) -> &str {
style.split('{').next().unwrap_or(style).trim()