From f3f959cabf7c386e5ba314aef68a4d4a61cf6bb2 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Tue, 13 Oct 2026 04:51:08 -0400 Subject: [PATCH] Text: character spacing of 0.6 pt or more (or -1 pt) switches kerning off for the whole box PowerPoint kerns pairs (a:rPr/@kern) only while no run of the text body is spaced out by 0.6 pt or more or condensed by 1 pt or more; then no run of that box is kerned, even runs without spacing. Measured on Arial 40 pt "AVATAR": spacing 0, 0.01, 0.5, -0.6 and -0.99 pt keep the pairs (146 px wide), 0.6, 0.8, 1, 1.5 and -1 pt drop them in every paragraph of the box (158 px plus the spacing). DeckCraft kerned spaced titles, making "AVATAR"-like words 12 px too narrow at 40 pt. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/text/src/lib.rs | 31 ++++++++++++++++++++++++------- crates/text/src/tests.rs | 40 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 64 insertions(+), 7 deletions(-) diff --git a/crates/text/src/lib.rs b/crates/text/src/lib.rs index d797fb2..007f63e 100644 --- a/crates/text/src/lib.rs +++ b/crates/text/src/lib.rs @@ -434,7 +434,8 @@ pub fn autonum(scheme: &str, n: u32) -> String { } } -fn shape_para(ctx: &Ctx, shape: &Shape, body: &TextBody, pi: usize, scale: f64, opts: &Opts, number: Option) -> ParaShaped { +#[allow(clippy::too_many_arguments)] +fn shape_para(ctx: &Ctx, shape: &Shape, body: &TextBody, pi: usize, scale: f64, opts: &Opts, number: Option, no_kern: bool) -> ParaShaped { let db = FontDb::global(); let Some(para) = body.paragraphs.get(pi) else { let face = db.face("Inter", "Regular"); @@ -459,7 +460,8 @@ fn shape_para(ctx: &Ctx, shape: &Shape, body: &TextBody, pi: usize, scale: f64, let mut char_style: Vec = vec![]; for run in ¶.runs { let rp = resolve::run(ctx, shape, para, &run.props); - let st = make_style(ctx, &rp, scale, opts.prompt_color); + let mut st = make_style(ctx, &rp, scale, opts.prompt_color); + st.kern &= !no_kern; let t: String = match &run.kind { RunKind::Text => run.text.clone(), RunKind::Break => "\u{b}".into(), @@ -1073,7 +1075,8 @@ fn tab_advance(x: f64, stops: &[f64], default_tab: f64) -> f64 { pub fn layout(ctx: &Ctx, shape: &Shape, body: &TextBody, opts: &Opts) -> TextLayout { let bp = resolve::body(ctx, shape); let autofit = bp.autofit.unwrap_or(AutoFit::None); - let mut l = layout_scaled(ctx, shape, body, &bp, opts, 1.0, 0.0); + let no_kern = spacing_stops_kerning(ctx, shape, body); + let mut l = layout_scaled(ctx, shape, body, &bp, opts, 1.0, 0.0, no_kern); if let (AutoFit::Shrink { .. }, false) = (autofit, opts.no_shrink) && l.overflow { @@ -1083,7 +1086,7 @@ pub fn layout(ctx: &Ctx, shape: &Shape, body: &TextBody, opts: &Opts) -> TextLay for _ in 0..12 { let mid = (lo + hi) / 2.0; let red: f64 = ((1.0 - mid) * 0.8_f64).min(0.2); - let t = layout_scaled(ctx, shape, body, &bp, opts, mid, red); + let t = layout_scaled(ctx, shape, body, &bp, opts, mid, red, no_kern); if t.overflow { hi = mid; } else { @@ -1091,12 +1094,26 @@ pub fn layout(ctx: &Ctx, shape: &Shape, body: &TextBody, opts: &Opts) -> TextLay best = Some(t); } } - l = best.unwrap_or_else(|| layout_scaled(ctx, shape, body, &bp, opts, 0.1, 0.2)); + l = best.unwrap_or_else(|| layout_scaled(ctx, shape, body, &bp, opts, 0.1, 0.2, no_kern)); } l } -fn layout_scaled(ctx: &Ctx, shape: &Shape, body: &TextBody, bp: &BodyProps, opts: &Opts, scale: f64, reduce: f64) -> TextLayout { +/// Does character spacing switch pair kerning off in this body? PowerPoint stops kerning every run +/// of a text body once one of its runs is spaced out by 0.6 pt or more, or condensed by 1 pt or +/// more (measured on Arial at 40 pt: 0.5 pt and -0.99 pt keep the pairs kerned, 0.6 pt and -1 pt +/// drop them for the whole box). +fn spacing_stops_kerning(ctx: &Ctx, shape: &Shape, body: &TextBody) -> bool { + body.paragraphs.iter().any(|para| { + para.runs.iter().any(|run| { + let s = resolve::run(ctx, shape, para, &run.props).spacing.unwrap_or(0.0); + s >= 0.55 || s <= -0.995 + }) + }) +} + +#[allow(clippy::too_many_arguments)] +fn layout_scaled(ctx: &Ctx, shape: &Shape, body: &TextBody, bp: &BodyProps, opts: &Opts, scale: f64, reduce: f64, no_kern: bool) -> TextLayout { let r = opts.rect; let vert = bp.vert.unwrap_or(TextDir::Horizontal); let rotation = match vert { @@ -1149,7 +1166,7 @@ fn layout_scaled(ctx: &Ctx, shape: &Shape, body: &TextBody, bp: &BodyProps, opts } None }; - shaped_paras.push(shape_para(ctx, shape, body, pi, scale, opts, number)); + shaped_paras.push(shape_para(ctx, shape, body, pi, scale, opts, number, no_kern)); } for (pi, p) in shaped_paras.iter().enumerate() { let props = &p.props; diff --git a/crates/text/src/tests.rs b/crates/text/src/tests.rs index 1bc89cd..240dd47 100644 --- a/crates/text/src/tests.rs +++ b/crates/text/src/tests.rs @@ -101,6 +101,46 @@ fn pairs_are_kerned_only_from_the_kern_size() { assert!((above - off).abs() < 0.01, "40 pt is below a 48 pt threshold"); } +/// Width of the first paragraph "AVATAR WAVE" (kerned from 12 pt) when a second paragraph's run +/// has character spacing `spc`. +fn kerned_width_beside_spacing(family: &str, spc: f64) -> f64 { + let (p, mut sh) = setup("AVATAR WAVE", 2000.0, 300.0); + let t = sh.text.as_mut().unwrap(); + let mut second = t.paragraphs[0].clone(); + second.runs[0].props.spacing = Some(spc); + t.paragraphs.push(second); + for para in &mut t.paragraphs { + para.props.bullet = Some(Bullet::None); + for r in &mut para.runs { + r.props.font = Some(family.into()); + r.props.size = Some(40.0); + r.props.kern = Some(12.0); + } + } + let l = lay(&p, &sh, 2000.0, 300.0); + l.lines[0].extent.1 - l.lines[0].extent.0 +} + +#[test] +fn character_spacing_in_the_body_stops_kerning() { + let kerns = |f: &FontFace| { + let w = |feat: &[deckcraft_fonts::Feature]| deckcraft_fonts::shape(f, "AVATAR", feat, |c| c).iter().map(|g| g.x_advance).sum::(); + w(&[]) != w(&deckcraft_fonts::feature("-kern").into_iter().collect::>()) + }; + let Some(family) = first_family(LATIN_FAMILIES, kerns) else { + eprintln!("skipped: no font that kerns AVATAR"); + return; + }; + let (_, off) = glyphs_and_width(&family, "AVATAR WAVE", Some(0.0)); + let (_, on) = glyphs_and_width(&family, "AVATAR WAVE", Some(12.0)); + // Small spacing elsewhere keeps the pairs; 0.6 pt or -1 pt anywhere in the box drops them. + for (spc, kerned) in [(0.0, true), (0.5, true), (-0.99, true), (0.6, false), (1.0, false), (-1.0, false), (3.0, false)] { + let w = kerned_width_beside_spacing(&family, spc); + let want = if kerned { on } else { off }; + assert!((w - want).abs() < 0.01, "{family} spc {spc}: {w} vs {want}"); + } +} + #[test] fn ligatures_stay_for_scripts_that_need_them() { let has = |f: &[deckcraft_fonts::Feature], tag: &str| f.iter().any(|x| Some(*x) == deckcraft_fonts::feature(tag));