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) <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas 2026-10-13 06:23:50 -04:00
parent 3d22ca642d
commit bab3854484
3 changed files with 35 additions and 2 deletions

View File

@ -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<skrifa::metrics::Decoration>, 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<skrifa::metrics::Decoration>, 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()

View File

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

View File

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