diff --git a/crates/text/src/lib.rs b/crates/text/src/lib.rs index 9acc26c0..d09f5b32 100644 --- a/crates/text/src/lib.rs +++ b/crates/text/src/lib.rs @@ -413,6 +413,9 @@ struct ParaShaped { /// Size of the paragraph's first run (spacing percentages, empty lines). lead_size: f64, lead_face: Arc, + /// Size of the paragraph mark (`a:endParaRPr`): what an empty last line is sized by. + end_size: f64, + end_face: Arc, bullet: Option<(String, Style)>, #[allow(dead_code)] level: u8, @@ -709,7 +712,9 @@ fn shape_para(ctx: &Ctx, shape: &Shape, body: &TextBody, pi: usize, scale: f64, faces: vec![], props: ParaProps::default(), lead_size: 18.0, - lead_face: face, + lead_face: face.clone(), + end_size: 18.0, + end_face: face, bullet: None, level: 0, rtl: false, @@ -741,6 +746,9 @@ fn shape_para(ctx: &Ctx, shape: &Shape, body: &TextBody, pi: usize, scale: f64, let lead_style = make_style(ctx, &lead, scale, opts.prompt_color); let lead_size = lead_style.size; let lead_face = lead_style.face.clone(); + // The paragraph mark, which ends the last line when no break does. + let end_style = make_style(ctx, &resolve::run(ctx, shape, para, ¶.end_props), scale, opts.prompt_color); + let (end_size, end_face) = (end_style.size, end_style.face.clone()); // Bullet. let bullet = match &props.bullet { Some(Bullet::Char { char }) => { @@ -789,7 +797,7 @@ fn shape_para(ctx: &Ctx, shape: &Shape, body: &TextBody, pi: usize, scale: f64, } _ => None, }; - ParaShaped { cells, styles, faces, props, lead_size, lead_face, bullet, level: para.level, rtl } + ParaShaped { cells, styles, faces, props, lead_size, lead_face, end_size, end_face, bullet, level: para.level, rtl } } /// A face that draws the bullet `label` when the bullet's own can't (circled numbers ①, arrows @@ -1712,10 +1720,21 @@ fn layout_scaled(ctx: &Ctx, shape: &Shape, body: &TextBody, bp: &BodyProps, opts } } if size == 0.0 { - let (a, d) = line_metrics(&p.lead_face, p.lead_size); + // PowerPoint sizes an empty line by the mark that ends it: the `a:rPr` of the + // `a:br` that closes it, or `a:endParaRPr` for the paragraph's last line -- not + // by the paragraph's first run (#780). An empty *paragraph* already lands on + // `end_props` through `lead`, and keeps the same height here. + let brk = p.cells.get(rl.end).filter(|c| c.ch == '\u{b}').and_then(|c| p.styles.get(c.style)); + let (face, sz) = match brk { + Some(st) => (&st.face, st.size), + None if rl.end >= p.cells.len() => (&p.end_face, p.end_size), + // Anything else empty (a line of tabs alone) keeps the old measure. + None => (&p.lead_face, p.lead_size), + }; + let (a, d) = line_metrics(face, sz); asc = a; desc = d; - size = p.lead_size; + size = sz; } let natural = (asc + desc).max(size * 1.0); let lh = match props.line_spacing { diff --git a/crates/text/src/tests.rs b/crates/text/src/tests.rs index a9d936cd..bc0f24f5 100644 --- a/crates/text/src/tests.rs +++ b/crates/text/src/tests.rs @@ -1467,3 +1467,99 @@ fn empty_plain_paragraphs_and_soft_breaks_have_no_bullet_preview() { assert_eq!(l.lines.len(), 2); assert!(l.empty_bullets.is_empty(), "a soft line break is not a new list item"); } + +/// A run of `text` at `size` points. +fn sized_run(text: &str, size: f64) -> Run { + let mut r = Run::new(text); + r.props.size = Some(size); + r +} + +/// A line break whose own run properties are `size` points. +fn sized_break(size: f64) -> Run { + let mut r = Run { text: String::new(), props: Default::default(), kind: deckcraft_model::text::RunKind::Break }; + r.props.size = Some(size); + r +} + +/// The height one line of `size`-point text takes in this shape: the yardstick the empty-line +/// tests measure against, so they do not depend on the test font's ascent and descent. +fn one_line_height(p: &Presentation, sh: &mut Shape, size: f64) -> f64 { + sh.text.as_mut().unwrap().paragraphs = vec![Paragraph { runs: vec![sized_run("X", size)], ..Default::default() }]; + let l = lay(p, sh, 400.0, 400.0); + l.lines[0].bottom - l.lines[0].top +} + +#[test] +fn an_empty_line_is_sized_by_the_break_that_ends_it() { + // PowerPoint sizes an empty line by the mark that closes it, not by the paragraph's first + // run (#780). Case A of the report: 40pt text, a 40pt break, a 10pt break, 40pt text, so + // the empty second line is a 10pt line and not a 40pt one. + let (p, mut sh) = setup("x", 400.0, 400.0); + let (h10, h40, h60, h20) = ( + one_line_height(&p, &mut sh, 10.0), + one_line_height(&p, &mut sh, 40.0), + one_line_height(&p, &mut sh, 60.0), + one_line_height(&p, &mut sh, 20.0), + ); + let put = |sh: &mut Shape, runs: Vec| { + sh.text.as_mut().unwrap().paragraphs = vec![Paragraph { runs, ..Default::default() }]; + }; + + put(&mut sh, vec![sized_run("A1", 40.0), sized_break(40.0), sized_break(10.0), sized_run("A2", 40.0)]); + let a = lay(&p, &sh, 400.0, 400.0); + assert_eq!(a.lines.len(), 3, "{:?}", a.lines.iter().map(|l| l.top).collect::>()); + let empty_a = a.lines[2].top - a.lines[1].top; + assert!((empty_a - h10).abs() < 0.5, "a 10pt break should give a 10pt line ({h10}), got {empty_a}"); + assert!((empty_a - h40).abs() > 1.0, "and not the 40pt first run's line ({h40})"); + + // Case C: a 60pt break closing the empty line of a 20pt paragraph pushes the third line + // down, the opposite direction from the first run's size -- so this cannot pass by the old + // behaviour under a different font. + put(&mut sh, vec![sized_run("C1", 20.0), sized_break(20.0), sized_break(60.0), sized_run("C2", 20.0)]); + let c = lay(&p, &sh, 400.0, 400.0); + let empty_c = c.lines[2].top - c.lines[1].top; + assert!((empty_c - h60).abs() < 0.5, "a 60pt break should give a 60pt line ({h60}), got {empty_c}"); + assert!((empty_c - h20).abs() > 1.0, "and not the 20pt first run's line ({h20})"); +} + +#[test] +fn an_empty_last_line_is_sized_by_the_paragraph_mark() { + // Case B: 40pt text and a trailing 40pt break, with the paragraph mark at 10pt. No break + // closes the empty last line, so it takes the mark's size. + let (p, mut sh) = setup("x", 400.0, 400.0); + let (h10, h40) = (one_line_height(&p, &mut sh, 10.0), one_line_height(&p, &mut sh, 40.0)); + { + let t = sh.text.as_mut().unwrap(); + let mut para = Paragraph { runs: vec![sized_run("B1", 40.0), sized_break(40.0)], ..Default::default() }; + para.end_props.size = Some(10.0); + t.paragraphs = vec![para]; + } + let l = lay(&p, &sh, 400.0, 400.0); + assert_eq!(l.lines.len(), 2, "the trailing break leaves an empty last line"); + let h = l.lines[1].bottom - l.lines[1].top; + assert!((h - h10).abs() < 0.5, "a 10pt paragraph mark should give {h10}, got {h}"); + assert!((h - h40).abs() > 1.0, "and not the 40pt first run's line ({h40})"); +} + +#[test] +fn an_empty_paragraph_keeps_its_paragraph_mark_height() { + // Case D, the control: an empty *paragraph* already took `end_props` before this change, + // through `lead`, and must be unaffected by it. + let (p, mut sh) = setup("x", 400.0, 400.0); + let h10 = one_line_height(&p, &mut sh, 10.0); + { + let t = sh.text.as_mut().unwrap(); + let mut empty = Paragraph::default(); + empty.end_props.size = Some(10.0); + t.paragraphs = vec![ + Paragraph { runs: vec![sized_run("D1", 40.0)], ..Default::default() }, + empty, + Paragraph { runs: vec![sized_run("D2", 40.0)], ..Default::default() }, + ]; + } + let l = lay(&p, &sh, 400.0, 400.0); + assert_eq!(l.lines.len(), 3); + let h = l.lines[1].bottom - l.lines[1].top; + assert!((h - h10).abs() < 0.5, "a 10pt empty paragraph should give {h10}, got {h}"); +}