Compare commits

...

4 Commits

Author SHA1 Message Date
Brandon Thomas 662fca6e97 Text: note that the 1.2 line pitch was measured on one Windows install
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-12 17:41:19 -04:00
Brandon Thomas f2db8f30e7 Fonts: line_split guard reads plainly (clippy)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-12 17:41:19 -04:00
Brandon Thomas ce5a88b785 Text: tidy the line pitch test
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-12 17:41:19 -04:00
Brandon Thomas 3e62501373 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) <noreply@anthropic.com>
2026-10-12 17:41:18 -04:00
4 changed files with 77 additions and 2 deletions

View File

@ -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<FontFace> {
.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<FontFace> {
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,

View File

@ -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());

View File

@ -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.

View File

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