Merge PR #253 from xmaks82: Text: line spacing reduction comes off the percentage, not the spacing

# Conflicts:
#	crates/text/src/tests.rs
This commit is contained in:
Brandon Thomas
2026-10-14 12:58:01 -04:00
2 changed files with 41 additions and 6 deletions
+10 -6
View File
@@ -523,10 +523,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,
}
}
@@ -1664,7 +1666,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() {
@@ -1691,7 +1693,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),
};
@@ -1844,7 +1848,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);
}
// The text ends at the last line's descent under exact spacing.
let total = (y - tail).max(0.0);
+31
View File
@@ -233,6 +233,37 @@ fn exact_line_spacing_ends_at_the_last_descent() {
assert!((l.content_height - (l.lines[1].bottom - l.lines[0].top)).abs() < 0.01);
}
#[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);