From bab3854484cfe674c5b33e2c0e50848acf5a9848 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 decc7bf..5425c02 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 63c8808..125be16 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 bbcc808..5276b7f 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); +}