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); + } +}