From 973af106bb484bac59cfbd2d6aad0e5527b76a79 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Tue, 13 Oct 2026 06:23:50 -0400 Subject: [PATCH] Text: strikethrough at the font's strikeout position and thickness The strikethrough was drawn 0.3 em above the baseline, 0.05 em thick, for every font. PowerPoint uses the font's own strikeout metrics (OS/2): Arial 96 pt strikes rows 347-351, 24.8 px above the baseline (530/2048 em) and 4.8 px thick; DeckCraft drew 343-347. Faces now carry their strikeout metrics (0.26/0.05 em when a font has none) and the line matches PowerPoint's rows exactly. w14-xml s11 4.06 -> 3.24. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/fonts/src/fontdb.rs | 16 ++++++++++++++++ crates/text/src/lib.rs | 6 ++++-- crates/text/src/tests.rs | 15 +++++++++++++++ 3 files changed, 35 insertions(+), 2 deletions(-) diff --git a/crates/fonts/src/fontdb.rs b/crates/fonts/src/fontdb.rs index decc7bfc..5425c022 100644 --- a/crates/fonts/src/fontdb.rs +++ b/crates/fonts/src/fontdb.rs @@ -137,6 +137,9 @@ pub struct FontFace { /// Underline position (top edge, below the baseline) and thickness in font units (`post` /// table; 0.1 and 0.05 em when the font has none). PowerPoint draws single underlines there. pub underline: (f64, f64), + /// Strikethrough position (top edge, above the baseline) and thickness in font units (OS/2; + /// 0.26 and 0.05 em when the font has none). PowerPoint draws single strikethroughs there. + pub strikeout: (f64, 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. @@ -590,6 +593,7 @@ fn make_face(bytes: FontBytes, index: u32, family: String, style: String, coords 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), underline: underline_metrics(m.underline, m.units_per_em.max(1) as f64), + strikeout: strikeout_metrics(m.strikeout, m.units_per_em.max(1) as f64), shaper, coords, location, @@ -613,6 +617,18 @@ fn underline_metrics(d: Option, upem: f64) -> (f64, } } +/// The strikethrough's top edge above the baseline and its thickness, font units, from OS/2 (its +/// position is the top edge: Arial's 530/2048 is where PowerPoint's strikethrough starts, 25 px +/// above the baseline at 96 pt); fonts without a usable one get 0.26 em and 0.05 em. +fn strikeout_metrics(d: Option, upem: f64) -> (f64, f64) { + match d { + Some(d) if d.thickness > 0.0 && (d.thickness as f64) < upem * 0.3 && d.offset > 0.0 && (d.offset as f64) < upem => { + (d.offset as f64, d.thickness as f64) + } + _ => (upem * 0.26, upem * 0.05), + } +} + /// 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() diff --git a/crates/text/src/lib.rs b/crates/text/src/lib.rs index 63c88089..125be161 100644 --- a/crates/text/src/lib.rs +++ b/crates/text/src/lib.rs @@ -1999,8 +1999,10 @@ fn layout_scaled(ctx: &Ctx, shape: &Shape, body: &TextBody, bp: &BodyProps, opts } } if let Some(sk) = st.strike { - let t = (st.size * 0.05).max(0.5); - let sy = line.baseline - st.size * 0.3; + // At the strikeout position and thickness of the run's own font. + let k = st.size / st.face.upem.max(1.0); + let t = (st.face.strikeout.1 * k).max(0.5); + let sy = line.baseline - st.face.strikeout.0 * k; out.decos.push(Deco { rect: Rect::new(cx, sy, cx1, sy + t), color: st.color, diff --git a/crates/text/src/tests.rs b/crates/text/src/tests.rs index bbcc8087..5276b7fa 100644 --- a/crates/text/src/tests.rs +++ b/crates/text/src/tests.rs @@ -1151,3 +1151,18 @@ fn fallback_faces_do_not_move_the_baseline() { assert!((b.0 - plain.0).abs() < 0.01 && (b.1 - plain.1).abs() < 0.01, "{text:?}: {b:?} vs {plain:?}"); } } + +#[test] +fn strikethrough_sits_at_the_font_strikeout_position() { + let (p, mut sh) = setup("Struck", 2000.0, 300.0); + let para = &mut sh.text.as_mut().unwrap().paragraphs[0]; + para.props.bullet = Some(Bullet::None); + para.runs[0].props.size = Some(96.0); + para.runs[0].props.strike = Some(Strike::Single); + let l = lay(&p, &sh, 2000.0, 300.0); + let r = l.runs.iter().find(|r| !r.cells.is_empty()).unwrap(); + let k = 96.0 / r.face.upem; + let d = l.decos.iter().find(|d| !d.behind).expect("a strikethrough"); + assert!((l.lines[0].baseline - d.rect.y0 - r.face.strikeout.0 * k).abs() < 0.01, "top at the strikeout position"); + assert!((d.rect.height() - (r.face.strikeout.1 * k).max(0.5)).abs() < 0.01); +}