Text: line spacing reduction comes off the percentage, not the spacing

PowerPoint's lnSpcReduction takes percentage points off a paragraph's line
spacing (90 % less 20 % is 70 %) and leaves the space before and after
paragraphs alone. We multiplied (90 % x 0.8 = 72 %) and shrank the
paragraph spacing too, so shrunk text came out with different line and
paragraph pitches than PowerPoint's: measured on PowerPoint's PDF of a real
deck, a body at 90 % spacing, 10 pt before paragraphs and a 20 % reduction
runs at 20.2 pt lines with 9.9 pt between paragraphs.
This commit is contained in:
Магда М.Г.
2026-10-12 16:22:11 +02:00
committed by Maksim
parent 0ec0cc5e81
commit 2a8c5b2b27
2 changed files with 41 additions and 6 deletions
+10 -6
View File
@@ -320,10 +320,12 @@ fn face_chain(ctx: &Ctx, st: &Style, slot: FontSlot, script: Option<ScriptTag>)
chain
}
fn spacing_pts(s: Option<Spacing>, size: f64, reduce: f64) -> f64 {
/// Space before or after a paragraph, points. Shrink-on-overflow's line spacing reduction
/// doesn't apply to it (PowerPoint keeps it).
fn spacing_pts(s: Option<Spacing>, size: f64) -> f64 {
match s {
Some(Spacing::Pct(p)) => p * size * 1.2 * (1.0 - reduce),
Some(Spacing::Pts(v)) => v * (1.0 - reduce),
Some(Spacing::Pct(p)) => p * size * 1.2,
Some(Spacing::Pts(v)) => v,
None => 0.0,
}
}
@@ -918,7 +920,7 @@ fn layout_scaled(ctx: &Ctx, shape: &Shape, body: &TextBody, bp: &BodyProps, opts
let first_w = (col_w - first_x - mr).max(1.0);
let rest_w = (col_w - rest_x - mr).max(1.0);
let raw = break_lines(p, first_w, rest_w, wrap, &tabs, props.default_tab.unwrap_or(72.0), first_x, rest_x);
let before = if pi == 0 { 0.0 } else { spacing_pts(props.space_before, p.lead_size, reduce) };
let before = if pi == 0 { 0.0 } else { spacing_pts(props.space_before, p.lead_size) };
y += before.max(0.0) + prev_after;
let first_line_idx = all_lines.len();
for (li, rl) in raw.iter().enumerate() {
@@ -941,7 +943,9 @@ fn layout_scaled(ctx: &Ctx, shape: &Shape, body: &TextBody, bp: &BodyProps, opts
}
let natural = (asc + desc).max(size * 1.0);
let lh = match props.line_spacing {
Some(Spacing::Pct(f)) => natural * f.max(0.0) * (1.0 - reduce),
// The reduction comes off the percentage (90 % less 20 % is 70 %), as PowerPoint
// applies `lnSpcReduction`.
Some(Spacing::Pct(f)) => natural * (f - reduce).max(f * 0.1).max(0.0),
Some(Spacing::Pts(v)) => v * (1.0 - reduce),
None => natural * (1.0 - reduce),
};
@@ -1082,7 +1086,7 @@ fn layout_scaled(ctx: &Ctx, shape: &Shape, body: &TextBody, bp: &BodyProps, opts
));
}
out.para_lines.push((first_line_idx, raw.len()));
prev_after = spacing_pts(props.space_after, p.lead_size, reduce).max(0.0);
prev_after = spacing_pts(props.space_after, p.lead_size).max(0.0);
}
let total = y;
out.content_height = total;
+31
View File
@@ -54,6 +54,37 @@ fn shrink_on_overflow() {
assert!(!l.overflow || l.font_scale <= 0.11);
}
#[test]
fn line_spacing_reduction_comes_off_the_percentage() {
// 90 % line spacing and 10 pt before paragraphs, shrunk with a 20 % line spacing reduction:
// PowerPoint draws 70 % lines and keeps the 10 pt.
let (p, mut sh) = setup("One\nTwo\nThree", 600.0, 400.0);
for (i, para) in sh.text.as_mut().unwrap().paragraphs.iter_mut().enumerate() {
para.props.line_spacing = Some(Spacing::Pct(0.9));
para.props.space_before = Some(Spacing::Pts(10.0));
para.props.bullet = Some(Bullet::None);
if i == 0 {
para.runs[0].text.push_str(" wraps onto a second line because this paragraph is long enough to wrap in a narrow box");
}
}
let ctx = Ctx::for_slide(&p, &p.slides[0]).unwrap();
let body = sh.text.clone().unwrap();
let bp = resolve::body(&ctx, &sh);
let opts = Opts { rect: Rect::new(0.0, 0.0, 200.0, 400.0), fields: &NoFields, prompt_color: None, no_shrink: true };
let full = layout_scaled(&ctx, &sh, &body, &bp, &opts, 1.0, 0.0);
let reduced = layout_scaled(&ctx, &sh, &body, &bp, &opts, 1.0, 0.2);
let pitch = |l: &TextLayout| l.lines[1].top - l.lines[0].top;
// Lines within the first paragraph: 0.7 / 0.9 of the full pitch.
assert!(full.lines[1].para == 0 && reduced.lines[1].para == 0);
assert!((pitch(&reduced) / pitch(&full) - 0.7 / 0.9).abs() < 0.005, "{} {}", pitch(&reduced), pitch(&full));
// The space before the next paragraph is the same 10 pt.
let gap = |l: &TextLayout| {
let k = l.lines.iter().position(|x| x.para == 1).unwrap();
l.lines[k].top - l.lines[k - 1].bottom
};
assert!((gap(&full) - 10.0).abs() < 1e-6 && (gap(&reduced) - 10.0).abs() < 1e-6, "{} {}", gap(&full), gap(&reduced));
}
#[test]
fn caret_and_hit_roundtrip() {
let (p, sh) = setup("Hello world", 600.0, 100.0);