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) <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas 2026-10-13 04:51:08 -04:00
parent cfd4f25545
commit f3f959cabf
2 changed files with 64 additions and 7 deletions

View File

@ -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<u32>) -> ParaShaped {
#[allow(clippy::too_many_arguments)]
fn shape_para(ctx: &Ctx, shape: &Shape, body: &TextBody, pi: usize, scale: f64, opts: &Opts, number: Option<u32>, 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<usize> = vec![];
for run in &para.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;

View File

@ -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::<i32>();
w(&[]) != w(&deckcraft_fonts::feature("-kern").into_iter().collect::<Vec<_>>())
};
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));