diff --git a/crates/layout/src/lib.rs b/crates/layout/src/lib.rs index 66d65e23f..0261333a3 100644 --- a/crates/layout/src/lib.rs +++ b/crates/layout/src/lib.rs @@ -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(§ions, si - 1, |s| s.headers), linked(§ions, 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 { 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, last: u32, pages: usize, even_odd: bool, mirror: bool) -> (bool, Option) { + 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) -> 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; diff --git a/crates/layout/src/tests.rs b/crates/layout/src/tests.rs index a20dea18d..41cd60ff2 100644 --- a/crates/layout/src/tests.rs +++ b/crates/layout/src/tests.rs @@ -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 { + 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, 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, 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