Text: an empty line takes the size of the mark that ends it (#780)

An empty line inside a paragraph -- between two `a:br`, or after a trailing one
-- was laid out at the size of the paragraph's *first run*. PowerPoint sizes it
by the mark that closes the line: the `a:rPr` of the `a:br` that ends it, or
`a:endParaRPr` for the paragraph's last line. Every line below then sits too
high or too low, by the difference; in the reported poster deck the text drifted
by up to 173pt.

`shape_para` resolved one `lead_size`/`lead_face` per paragraph and
`layout_scaled` used it for every line with no measured cells. A break is a
cell (`\u{b}`) and `break_lines` sets a line's `end` to that break's own index,
so the closing mark is already to hand: take its style, falling back to the
paragraph mark (now carried as `end_size`/`end_face`) for a last line that no
break closes. Any other empty line -- a line of tabs alone -- keeps the old
measure, so only the two cases PowerPoint defines change.

An empty *paragraph* already landed on `end_props` through `lead`, and is
unchanged: it is the control case in the report, and the test for it passes both
before and after.

Measured on the reported fixture, second line top against PowerPoint's own
export: case A 166pt -> 130pt (131 expected), case B 166 -> 130, case C 113 ->
161 (160 expected), and the control case D 130 either way. The 1pt that remains
is the offset the control already had.
This commit is contained in:
Hamza committed 2026-10-15 12:33:20 +05:00
1 parent 9859da3925
commit 79088b7060
2 files changed
+119 -4

No files matched your search

+23 -4
View File
@@ -413,6 +413,9 @@ struct ParaShaped {
/// Size of the paragraph's first run (spacing percentages, empty lines).
lead_size: f64,
lead_face: Arc<FontFace>,
/// Size of the paragraph mark (`a:endParaRPr`): what an empty last line is sized by.
end_size: f64,
end_face: Arc<FontFace>,
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, &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 {
+96
View File
@@ -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<Run>| {
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::<Vec<_>>());
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}");
}