diff --git a/crates/text/src/lib.rs b/crates/text/src/lib.rs index 225f4afb..349a4016 100644 --- a/crates/text/src/lib.rs +++ b/crates/text/src/lib.rs @@ -879,6 +879,8 @@ fn layout_scaled(ctx: &Ctx, shape: &Shape, body: &TextBody, bp: &BodyProps, opts let mut numbering: Vec = vec![0; 9]; let mut all_lines: Vec<(LineInfo, LinePlace, f64, Option<(String, f64, f64)>)> = vec![]; let mut prev_after = 0.0; + // How far the last line's full pitch reaches past the text (exact spacing; see below). + let mut tail = 0.0; let mut shaped_paras: Vec = Vec::with_capacity(body.paragraphs.len()); for (pi, para) in body.paragraphs.iter().enumerate() { let lvl = para.level.min(8) as usize; @@ -947,7 +949,11 @@ fn layout_scaled(ctx: &Ctx, shape: &Shape, body: &TextBody, bp: &BodyProps, opts }; let ratio = if natural > 0.0 { lh / natural } else { 1.0 }; let top = y; - let baseline = top + asc * ratio; + // Exact spacing (`spcPts`), as PowerPoint sets it: the baseline sits at 80 % of the + // line pitch, and the text ends at the last line's descent rather than a full pitch. + let exact = matches!(props.line_spacing, Some(Spacing::Pts(_))); + let baseline = if exact { top + lh * 0.8 } else { top + asc * ratio }; + tail = if exact { (lh * 0.2 - desc).max(-lh) } else { 0.0 }; y += lh; let x0 = if li == 0 { first_x } else { rest_x }; let avail = (col_w - x0 - mr).max(0.0); @@ -1084,7 +1090,8 @@ 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); } - let total = y; + // The text ends at the last line's descent under exact spacing. + let total = (y - tail).max(0.0); out.content_height = total; // Columns: distribute lines by height. let col_h = if ncols > 1 { inner.height() } else { f64::INFINITY }; diff --git a/crates/text/src/tests.rs b/crates/text/src/tests.rs index f3d5db5d..c524b43e 100644 --- a/crates/text/src/tests.rs +++ b/crates/text/src/tests.rs @@ -54,6 +54,33 @@ fn shrink_on_overflow() { assert!(!l.overflow || l.font_scale <= 0.11); } +#[test] +fn exact_line_spacing_ends_at_the_last_descent() { + // 12 pt text at exactly 26.59 pt per line (a table cell PowerPoint sizes to 24.3 pt of text): + // the baseline at 80 % of the pitch, the text ending at the last line's descent. + let (p, mut sh) = setup("One\nTwo", 600.0, 300.0); + for para in &mut sh.text.as_mut().unwrap().paragraphs { + para.props.line_spacing = Some(Spacing::Pts(26.59)); + para.props.bullet = Some(Bullet::None); + for r in &mut para.runs { + r.props.size = Some(12.0); + } + } + let l = lay(&p, &sh, 600.0, 300.0); + let first = &l.lines[0]; + assert!((first.baseline - first.top - 26.59 * 0.8).abs() < 0.01, "{:?}", first.baseline - first.top); + // The text ends at the last line's descent: above its full pitch by 20 % of it less the + // descent (~2.3 pt for 12 pt type). + let short = (l.lines[1].bottom - l.lines[0].top) - l.content_height; + assert!(short > 1.0 && short < 26.59 * 0.2, "{short}"); + // Proportional spacing keeps full pitches. + for para in &mut sh.text.as_mut().unwrap().paragraphs { + para.props.line_spacing = Some(Spacing::Pct(1.0)); + } + let l = lay(&p, &sh, 600.0, 300.0); + assert!((l.content_height - (l.lines[1].bottom - l.lines[0].top)).abs() < 0.01); +} + #[test] fn caret_and_hit_roundtrip() { let (p, sh) = setup("Hello world", 600.0, 100.0);