Layout: blank pages put sections on the right side, and paragraph spacing is the larger of after and before (#733, rebased) (#964)

Rebased #733 (paragraph spacing larger of after and before; blank pages for odd/even section starts) onto main.

Co-authored-by: Naxvog <4963183+naxvog@users.noreply.github.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas
2026-10-12 11:18:57 -04:00
committed by GitHub
co-authored by Naxvog Claude Opus 5.5
parent 1350cb959b
commit be7474566b
2 changed files with 155 additions and 39 deletions
+71 -39
View File
@@ -927,10 +927,9 @@ fn layout_box(
// space after the paragraph above (the larger one counts).
if pl.rp.before_auto && i == 0 {
before = 0.0;
} else if i > 0 && (pl.rp.before_auto || prev_after_auto) {
} else if i > 0 && before > 0.0 && prev_after > 0.0 {
before = (before - prev_after).max(0.0);
}
// Word: space between paragraphs is before + after (no collapsing).
y += if i == 0 { before } else { before.max(0.0) };
// Floating objects anchored here: place them, then wrap the text around them.
// One left out of the layout (hidden, or deleted in the final text) takes no room.
@@ -964,7 +963,7 @@ fn layout_box(
// Automatic space after: none after the last paragraph of a cell or box.
let after = if pl.rp.after_auto && i + 1 == blocks.len() { 0.0 } else { pl.rp.space_after };
y += pl.height + after;
prev_after = if same && ctxl { 0.0 } else { after };
prev_after = after;
prev_after_auto = pl.rp.after_auto;
prev_listed = listed;
prev_style = Some((pl.rp.style.clone(), ctxl));
@@ -1915,18 +1914,28 @@ fn layout_pass(
// one long page
} else if pb.pages.is_empty() || start != SectionStart::Continuous || turn != prev_turn {
// A page holds text running one way: a section turning the other way starts a page.
let s = &doc.settings;
let (filler, restart) = section_side(start, restart, pb.number, pb.pages.len(), s.even_odd_headers, s.mirror_margins);
// The blank page that puts the section on the right side ends the previous section.
if filler
&& !web
&& let Some((_, prev)) = si.checked_sub(1).and_then(|k| sections.get(k))
{
pb.sect = prev;
pb.sect_idx = si - 1;
let (ph, pf) = (linked(&sections, si - 1, |s| s.headers), linked(&sections, si - 1, |s| s.footers));
let top = body_top_for(&mut ctx, prev, ph.default);
pb.foot_top = foot_top_for(&mut ctx, prev, pf.default);
pb.new_page(block, top);
pb.sect = sect;
pb.sect_idx = si;
}
if let Some(n) = restart {
pb.number = n.saturating_sub(1);
}
pb.foot_top = first_foot;
pb.new_page(block, first_top);
pb.foot_top = foot_top;
if !web && matches!(start, SectionStart::EvenPage | SectionStart::OddPage) {
let want_even = start == SectionStart::EvenPage;
if pb.number.is_multiple_of(2) != want_even {
pb.new_page(block, body_top);
}
}
} else {
// Continuous: new column layout below the current text (its tallest column).
pb.cols = sect.column_boxes();
@@ -2278,6 +2287,33 @@ fn item_bottom(y: f32, para: &ParaLayout, l0: usize, l1: usize) -> Option<f32> {
Some(y + l.top + l.height - f.top)
}
/// Whether a section that starts on a new page needs a blank page first so it lands on the right
/// side, and the page number it restarts at. `last` is the number of the page before it and
/// `pages` how many pages there are so far.
/// - An odd- or even-page break counts on from the previous section's numbers. A section that
/// restarts its numbering gets no blank page unless the document prints both sides (different
/// odd and even headers, or mirror margins); a restart at the wrong parity takes the next number.
/// - With different odd and even headers, a next-page break that restarts the numbers at an odd
/// (even) number starts on an odd (even) sheet.
///
/// (Word's output for docxide-pdf's fixtures: bush_fires_act_comparison has a blank page 2.)
fn section_side(start: SectionStart, restart: Option<u32>, last: u32, pages: usize, even_odd: bool, mirror: bool) -> (bool, Option<u32>) {
match start {
SectionStart::OddPage | SectionStart::EvenPage => {
let want_odd = start == SectionStart::OddPage;
let continuing_odd = last.saturating_add(1) % 2 == 1;
let filler = pages > 0 && continuing_odd != want_odd && (restart.is_none() || even_odd || mirror);
let restart = restart.map(|n| if (n % 2 == 1) == want_odd { n } else { n.saturating_add(1) });
(filler, restart)
}
SectionStart::NextPage => {
let filler = even_odd && pages > 0 && restart.is_some_and(|n| (n % 2 == 1) != ((pages + 1) % 2 == 1));
(filler, restart)
}
SectionStart::Continuous | SectionStart::NextColumn => (false, restart),
}
}
/// Where body text starts: the top margin, or below the header if it's taller.
fn body_top_for(ctx: &mut Ctx, sect: &SectionProps, header: Option<u32>) -> f32 {
let Some(id) = header else { return sect.margin_top };
@@ -2662,7 +2698,6 @@ fn place_para_in_flow(ctx: &mut Ctx, pb: &mut PageBuilder, p: &Paragraph, block:
if broke_before {
pb.new_page(block, body_top);
}
// Contextual spacing: no space between paragraphs of the same style when either asks for it.
let mut before = pl.rp.space_before;
let prev_para = pb.prev.clone();
// At the top of a page, measured in Word for Windows: no space before after a paragraph that
@@ -2679,42 +2714,39 @@ fn place_para_in_flow(ctx: &mut Ctx, pb: &mut PageBuilder, p: &Paragraph, block:
// off the space before here (Word: 24 pt before after 10 pt after leaves 14 pt).
before = (before - after.max(0.0)).max(0.0);
}
let mut prev = pb.prev.take();
if let Some((style, ctxl, after, _, _)) = &mut prev
&& *style == pl.rp.style
if let Some((style, ctxl, mut after, after_auto, listed)) = pb.prev.take()
&& !pb.at_top()
{
if *ctxl {
pb.y -= *after;
*after = 0.0;
// Contextual spacing: no space between paragraphs of the same style when either asks for it.
if style == pl.rp.style {
if ctxl {
pb.y -= after;
after = 0.0;
}
if pl.rp.contextual_spacing {
before = 0.0;
}
}
if pl.rp.contextual_spacing {
before = 0.0;
// Automatic spacing: none between two list items (as in an HTML list; measured in Word:
// any two items, of one list or two, at any level).
if listed && label.is_some() {
if after_auto {
pb.y -= after;
after = 0.0;
}
if pl.rp.before_auto {
before = 0.0;
}
}
// The space between two paragraphs is the larger of the first's space after and the
// second's space before, not their sum.
if before > 0.0 && after > 0.0 {
before = (before - after).max(0.0);
}
}
// Automatic spacing: none between two list items (as in an HTML list; measured in Word: any
// two items, of one list or two, at any level).
if let Some((_, _, after, after_auto, true)) = &mut prev
&& label.is_some()
&& !pb.at_top()
{
if *after_auto {
pb.y -= *after;
*after = 0.0;
}
if pl.rp.before_auto {
before = 0.0;
}
}
// Automatic spacing: none at the top of the document, and it collapses with the space after
// the paragraph above (the larger one counts).
// Automatic spacing: none at the top of the document.
if pl.rp.before_auto && block == 0 {
before = 0.0;
} else if let Some((_, _, after, after_auto, _)) = prev
&& (pl.rp.before_auto || after_auto)
&& !pb.at_top()
{
before = (before - after).max(0.0);
}
pb.y += before;
let mut applied_before = before;
+84
View File
@@ -898,6 +898,90 @@ fn section_breaks_show_with_formatting_marks() {
assert_eq!(labels(&Document::from_text("One\nTwo"), true), (vec![], 2));
}
#[test]
fn space_between_paragraphs_is_the_larger_of_after_and_before() {
// (space after the first, space before the second, contextual spacing on both) -> gap
let gap = |after: f32, before: f32, contextual: bool| -> f32 {
let mut d = Document::from_text("One\nTwo");
for (i, (a, b)) in [(after, 0.0), (0.0, before)].into_iter().enumerate() {
let p = d.para_mut(wordcraft_doc::StoryRef::Body, &wordcraft_doc::Path::top(i)).unwrap();
(p.props.space_after, p.props.space_before, p.props.contextual_spacing) = (Some(a), Some(b), Some(contextual));
}
let l = lay(&d);
let ys: Vec<(f32, f32)> =
l.pages[0].items.iter().filter_map(|i| if let Placed::Lines { y, para, .. } = i { Some((*y, para.height)) } else { None }).collect();
ys[1].0 - (ys[0].0 + ys[0].1)
};
// Word's output for docxide-pdf's clean_energy_regulator_guidelines: 6pt after and 6pt before
// leave 6pt, not 12pt.
assert!((gap(6.0, 6.0, false) - 6.0).abs() < 0.01);
assert!((gap(6.0, 12.0, false) - 12.0).abs() < 0.01);
assert!((gap(12.0, 6.0, false) - 12.0).abs() < 0.01);
assert!((gap(0.0, 6.0, false) - 6.0).abs() < 0.01);
// Contextual spacing drops both between paragraphs of one style.
assert!(gap(6.0, 12.0, true).abs() < 0.01);
}
#[test]
fn space_between_paragraphs_in_a_cell_follows_the_body_rule() {
// Three paragraphs in a cell, each 6pt before and 6pt after -> the gaps between them.
let gaps = |contextual: bool| -> Vec<f32> {
let mut d = Document::from_text("before\nafter");
let mut t = Table::new(1, 1, 468.0);
t.rows[0].cells[0].blocks = (0..3)
.map(|k| {
let mut p = wordcraft_doc::Paragraph::with_text(&format!("p{k}"), Default::default());
(p.props.space_after, p.props.space_before, p.props.contextual_spacing) = (Some(6.0), Some(6.0), Some(contextual));
wordcraft_doc::para_block(p)
})
.collect();
d.insert_block(StoryRef::Body, &Path::top(1), wordcraft_doc::Block::Table(t)).unwrap();
let l = lay(&d);
let mut ys: Vec<(u32, f32, f32)> = l.pages[0]
.items
.iter()
.filter_map(|it| match it {
Placed::Lines { path, y, para, .. } if path.0.len() == 4 => Some((path.0[3], *y, para.height)),
_ => None,
})
.collect();
ys.sort_by_key(|v| v.0);
ys.windows(2).map(|w| w[1].1 - (w[0].1 + w[0].2)).collect()
};
let near = |got: Vec<f32>, want: [f32; 2]| got.len() == 2 && got.iter().zip(want).all(|(g, w)| (g - w).abs() < 0.01);
// The larger of after and before, not their sum.
assert!(near(gaps(false), [6.0, 6.0]), "{:?}", gaps(false));
// Contextual spacing drops the space between every pair, not just the first.
assert!(near(gaps(true), [0.0, 0.0]), "{:?}", gaps(true));
}
#[test]
fn sections_land_on_the_side_their_break_and_numbering_ask_for() {
use wordcraft_doc::section::{SectionProps, SectionStart};
// "One" ends the first section on page 1; the second section starts as `start`.
let pages = |start: SectionStart, restart: Option<u32>, even_odd: bool| -> Vec<(usize, u32)> {
let mut d = Document::from_text("One\nTwo");
d.para_mut(wordcraft_doc::StoryRef::Body, &wordcraft_doc::Path::top(0)).unwrap().section = Some(Box::new(SectionProps::default()));
d.last_section.start = start;
d.last_section.page_num_start = restart;
d.settings.even_odd_headers = even_odd;
lay(&d).pages.iter().map(|p| (p.section, p.number)).collect()
};
// An odd-page break after page 1 leaves page 2 blank; the blank page ends the first section.
assert_eq!(pages(SectionStart::OddPage, None, false), [(0, 1), (0, 2), (1, 3)]);
assert_eq!(pages(SectionStart::EvenPage, None, false), [(0, 1), (1, 2)]);
// Restarting at 1 needs no blank page unless both sides print; at 2 it takes the next number.
assert_eq!(pages(SectionStart::OddPage, Some(1), false), [(0, 1), (1, 1)]);
assert_eq!(pages(SectionStart::OddPage, Some(2), false), [(0, 1), (1, 3)]);
assert_eq!(pages(SectionStart::OddPage, Some(1), true), [(0, 1), (0, 2), (1, 1)]);
// With different odd and even headers, a next-page break restarting at 1 starts on an odd
// sheet (Word's output for docxide-pdf's bush_fires_act_comparison).
assert_eq!(pages(SectionStart::NextPage, Some(1), true), [(0, 1), (0, 2), (1, 1)]);
assert_eq!(pages(SectionStart::NextPage, Some(2), true), [(0, 1), (1, 2)]);
assert_eq!(pages(SectionStart::NextPage, Some(1), false), [(0, 1), (1, 1)]);
assert_eq!(pages(SectionStart::NextPage, None, true), [(0, 1), (1, 2)]);
}
#[test]
fn paragraph_marks_sit_past_text_running_the_other_way() {
// A left-to-right paragraph ending in Hebrew, and a right-to-left one ending in Latin: the