From 84361ca9f9bf8a2fbee632f668fec092585538fc Mon Sep 17 00:00:00 2001 From: aiongg Date: Tue, 6 Oct 2026 22:48:22 -0400 Subject: [PATCH] Text: the Ascent first baseline uses the font's typographic ascender --- crates/compose/src/lib.rs | 23 ++++--- crates/compose/src/shape.rs | 12 +++- crates/compose/src/tests.rs | 126 ++++++++++++++++++++++++++++++++++++ crates/fonts/src/fontdb.rs | 18 ++++++ 4 files changed, 168 insertions(+), 11 deletions(-) diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs index 9f91aa68f..a4bdfe320 100644 --- a/crates/compose/src/lib.rs +++ b/crates/compose/src/lib.rs @@ -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. diff --git a/crates/compose/src/shape.rs b/crates/compose/src/shape.rs index 8de7dc164..a91927f92 100644 --- a/crates/compose/src/shape.rs +++ b/crates/compose/src/shape.rs @@ -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, 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, diff --git a/crates/compose/src/tests.rs b/crates/compose/src/tests.rs index 511a703a5..0addee2f1 100644 --- a/crates/compose/src/tests.rs +++ b/crates/compose/src/tests.rs @@ -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) -> Vec { + 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::>(); + 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}"); +} diff --git a/crates/fonts/src/fontdb.rs b/crates/fonts/src/fontdb.rs index f67ff593d..833711b1e 100644 --- a/crates/fonts/src/fontdb.rs +++ b/crates/fonts/src/fontdb.rs @@ -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()