mirror of
https://github.com/storytold/wordcraft.git
synced 2026-10-24 10:27:44 -06:00
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:
co-authored by
Naxvog
Claude Opus 5.5
parent
1350cb959b
commit
be7474566b
+71
-39
@@ -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<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;
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user