From 2e68504e7c7e470992c90489d17a327de168d7fb Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Mon, 12 Oct 2026 17:42:53 -0400 Subject: [PATCH] Text: single-spaced lines are 1.2 times the font size apart, as in PowerPoint (#266) * Text: single-spaced lines are 1.2 times the font size apart, as in PowerPoint PowerPoint spaces single-spaced lines 1.2 x the font size apart in every font (measured on twenty Latin, CJK, Thai and Indic fonts at 18 and 40 pt with PowerPoint's slide export) and puts the baseline where the font's Windows ascent/descent split it. DeckCraft used each font's own ascent plus descent, so Calibri lines were 15% too tight and Segoe UI ones too loose. FontFace gains line_split (usWinAscent / (usWinAscent + usWinDescent)); line_metrics uses it with LINE_PITCH = 1.2. Co-Authored-By: Claude Opus 5.5 (1M context) * Text: tidy the line pitch test Co-Authored-By: Claude Opus 5.5 (1M context) * Fonts: line_split guard reads plainly (clippy) Co-Authored-By: Claude Opus 5.5 (1M context) * Text: note that the 1.2 line pitch was measured on one Windows install Co-Authored-By: Claude Opus 5.5 (1M context) --------- Co-authored-by: Claude Opus 5.5 (1M context) --- crates/fonts/src/fontdb.rs | 22 ++++++++++++++++++++++ crates/fonts/src/lib.rs | 11 +++++++++++ crates/text/src/lib.rs | 15 +++++++++++++-- crates/text/src/tests.rs | 31 +++++++++++++++++++++++++++++++ 4 files changed, 77 insertions(+), 2 deletions(-) diff --git a/crates/fonts/src/fontdb.rs b/crates/fonts/src/fontdb.rs index f0a06ef..8b2a1ec 100644 --- a/crates/fonts/src/fontdb.rs +++ b/crates/fonts/src/fontdb.rs @@ -79,6 +79,10 @@ pub struct FontFace { /// values). pub cap_height: f64, pub x_height: f64, + /// Where the baseline sits in a line, as a share of the line's height from its top: the + /// Windows ascent over the Windows ascent plus descent (OS/2 `usWinAscent`/`usWinDescent`), + /// the split PowerPoint uses (computed by `line_split`). + pub line_split: f64, pub shaper: harfrust::ShaperData, /// Variable fonts: the named instance's axis settings (user units; empty = default instance), /// the normalised location and the shaper's view of it. @@ -436,6 +440,23 @@ pub(crate) fn last_resort_face() -> Arc { .clone() } +/// The share of a line above the baseline (`FontFace::line_split`): OS/2 `usWinAscent` over +/// `usWinAscent + usWinDescent`, else the ascent over ascent plus descent. Fonts whose Windows +/// extent is huge (Gabriola, Cambria Math: over 1.5 em) get 0.81, where PowerPoint puts their +/// baseline (observed). +fn line_split(f: &skrifa::FontRef, ascent: f64, descent: f64, upem: f64) -> f64 { + use skrifa::raw::TableProvider; + let (a, d) = match f.os2() { + Ok(os2) if os2.us_win_ascent() > 0 => (os2.us_win_ascent() as f64, os2.us_win_descent() as f64), + _ => (ascent, descent), + }; + let total = a + d; + if !total.is_finite() || total <= 0.0 || a < 0.0 || d < 0.0 { + return 0.8; + } + if total > upem * 1.5 { 0.81 } else { (a / total).clamp(0.5, 1.0) } +} + fn make_face(bytes: FontBytes, index: u32, family: String, style: String, coords: Vec<([u8; 4], f32)>) -> Option { let data: &[u8] = match &bytes { FontBytes::Static(b) => b, @@ -467,6 +488,7 @@ fn make_face(bytes: FontBytes, index: u32, family: String, style: String, coords 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), + line_split: line_split(&f, m.ascent as f64, -(m.descent as f64), m.units_per_em.max(1) as f64), shaper, coords, location, diff --git a/crates/fonts/src/lib.rs b/crates/fonts/src/lib.rs index 94e8081..74ce921 100644 --- a/crates/fonts/src/lib.rs +++ b/crates/fonts/src/lib.rs @@ -143,6 +143,17 @@ mod tests { assert!(f.upem > 0.0); } + #[test] + fn line_split_follows_the_windows_ascent_and_descent() { + use skrifa::raw::TableProvider; + let f = fontdb::last_resort_face(); + let font = skrifa::FontRef::new(f.data()).unwrap(); + let os2 = font.os2().unwrap(); + let (a, d) = (os2.us_win_ascent() as f64, os2.us_win_descent() as f64); + assert!((f.line_split - a / (a + d)).abs() < 1e-9, "{} vs {}", f.line_split, a / (a + d)); + assert!(f.line_split > 0.5 && f.line_split < 1.0); + } + #[test] fn some_face_always_resolves() { let db = FontDb::with_font_dirs(Vec::new()); diff --git a/crates/text/src/lib.rs b/crates/text/src/lib.rs index 31dc291..5eeb40c 100644 --- a/crates/text/src/lib.rs +++ b/crates/text/src/lib.rs @@ -702,9 +702,20 @@ fn measure_str(face: &FontFace, size: f64, s: &str) -> (f64, Vec<(u32, f64)>) { (x, out) } +/// Single line spacing as a multiple of the font size. PowerPoint spaces single-spaced lines 1.2 +/// times the size apart in every font (observed on twenty Latin, CJK, Thai and Indic fonts at 18 +/// and 40 pt), whatever the font's own ascent and descent. +/// +/// Caveat: this was measured on one Windows install of desktop PowerPoint. It may be a Windows or +/// machine setting; PowerPoint on macOS or the web may space lines by the font's own metrics. +/// Keep it a single tunable value, and re-measure on the other platform before changing it. +const LINE_PITCH: f64 = 1.2; + +/// A line's height above and below the baseline at `size`: [`LINE_PITCH`] × size, split where +/// the font's Windows ascent and descent put the baseline. fn line_metrics(face: &FontFace, size: f64) -> (f64, f64) { - let k = size / face.upem.max(1.0); - (face.ascent * k, face.descent * k) + let h = size * LINE_PITCH; + (h * face.line_split, h * (1.0 - face.line_split)) } /// Break opportunities: allowed[i] = a line may start at char i. diff --git a/crates/text/src/tests.rs b/crates/text/src/tests.rs index 25f86f9..0959576 100644 --- a/crates/text/src/tests.rs +++ b/crates/text/src/tests.rs @@ -489,3 +489,34 @@ fn an_ordinary_hang_still_puts_the_text_at_the_margin() { assert!((first - rest).abs() < 0.5, "first {first} rest {rest}"); assert!((rest - bx.unwrap() - 36.0).abs() < 0.5); } + +#[test] +fn single_spaced_lines_are_1_2_times_the_size_apart_whatever_the_font() { + // PowerPoint spaces single-spaced lines 1.2 × the font size apart in every font, with the + // baseline where the font's Windows ascent/descent split puts it (observed on twenty fonts). + for size in [18.0, 40.0] { + let mut run = Run::new("HHH"); + run.props.size = Some(size); + let (p, mut sh) = bidi_setup(vec![run], 2000.0, false, Align::Left); + let t = sh.text.as_mut().unwrap(); + t.body.autofit = Some(AutoFit::None); + let second = t.paragraphs[0].clone(); + t.paragraphs.push(second.clone()); + t.paragraphs.push(second); + for para in &mut t.paragraphs { + para.props.line_spacing = Some(Spacing::Pct(1.0)); + para.props.space_before = Some(Spacing::Pts(0.0)); + para.props.space_after = Some(Spacing::Pts(0.0)); + } + let l = lay(&p, &sh, 2000.0, 600.0); + assert_eq!(l.lines.len(), 3); + // Each line is 1.2 × the size tall (the face is not compared across lines: another + // test's thread can load the family between them, see #252). + for line in &l.lines { + assert!((line.bottom - line.top - size * LINE_PITCH).abs() < 1e-6, "{size}: {}", line.bottom - line.top); + } + let face = &l.runs[0].face; + let first = &l.lines[0]; + assert!((first.baseline - first.top - size * LINE_PITCH * face.line_split).abs() < 1e-6); + } +}