From ba1e28382dd18eddcf6d05def421eed7e2640591 Mon Sep 17 00:00:00 2001 From: Fin Warman Date: Thu, 15 Oct 2026 00:38:36 +0100 Subject: [PATCH] Layout: page numbers can restart at 0 (#1601) w:pgNumType w:start="0" was read but laid out as 1: the page counter was set to one less than the restart and could not go below zero. A report numbering its cover 0 read "Page 2" on the page Word calls 1. The restart is now the next page's number itself. Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas --- crates/layout/src/lib.rs | 10 +++++++--- crates/layout/src/tests.rs | 9 +++++++++ docs/layout-parity.md | 3 ++- 3 files changed, 18 insertions(+), 4 deletions(-) diff --git a/crates/layout/src/lib.rs b/crates/layout/src/lib.rs index dcfce9cf3..6d2db80f8 100644 --- a/crates/layout/src/lib.rs +++ b/crates/layout/src/lib.rs @@ -1400,6 +1400,9 @@ struct PageBuilder<'a> { /// The previous paragraph ended with a page break (its mark stayed with the break): the next /// paragraph, first on the new page, gets no space before (Word). after_break_mark: bool, + /// The number the next page takes instead of counting on: a section restarting its + /// numbering, possibly at 0 (a cover page numbered 0). + restart_at: Option, /// Wrap areas of floating objects on this page (see [`wrap_area`]). excl: Vec, /// Wrap areas of the floating tables on this page, which others may not overlap. @@ -1815,7 +1818,7 @@ impl PageBuilder<'_> { self.line_no = 0; } let s = self.sect; - self.number = self.number.saturating_add(1); + self.number = self.restart_at.take().unwrap_or_else(|| self.number.saturating_add(1)); let (w, h) = if self.web { (s.page_w, f32::MAX / 4.0) } else { (s.page_w, s.page_h) }; // A footer taller than the bottom margin pushes the body up (Word: no gap), leaving it at // least a little room. @@ -2045,6 +2048,7 @@ fn layout_pass( orig_bottom: 0.0, prev: None, after_break_mark: false, + restart_at: None, excl: Vec::new(), float_tables: Vec::new(), line_no: 0, @@ -2137,8 +2141,8 @@ fn layout_pass( pb.sect = sect; pb.sect_idx = si; } - if let Some(n) = restart { - pb.number = n.saturating_sub(1); + if restart.is_some() { + pb.restart_at = restart; } pb.foot_top = first_foot; pb.new_page(block, first_top); diff --git a/crates/layout/src/tests.rs b/crates/layout/src/tests.rs index 046e74254..d851f817f 100644 --- a/crates/layout/src/tests.rs +++ b/crates/layout/src/tests.rs @@ -688,6 +688,15 @@ fn page_break_char_starts_new_page() { assert_eq!(l.pages.len(), 2); } +#[test] +fn page_numbers_can_restart_at_zero() { + // `w:pgNumType w:start="0"`: a cover page numbered 0, so the page after it reads 1. + let mut d = Document::from_text("Cover\u{000C}Contents"); + d.last_section.page_num_start = Some(0); + let numbers: Vec = lay(&d).pages.iter().map(|p| p.number).collect(); + assert_eq!(numbers, [0, 1]); +} + #[test] fn center_and_right_alignment() { let mut d = Document::from_text("abc\nabc\nabc"); diff --git a/docs/layout-parity.md b/docs/layout-parity.md index 6a443d35e..901287712 100644 --- a/docs/layout-parity.md +++ b/docs/layout-parity.md @@ -1,6 +1,6 @@ # Layout and pagination parity with Microsoft Word -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** trivial (justified vertical page alignment, #1320; previously trivial: a table's percentage width past 100% is kept; previously trivial: a list label moves with its first line when the paragraph is centred or end-aligned (#1159); previously trivial: PAGE fields in a paragraph split across pages show the page they are on, #1352; previously trivial: a dragged floating object anchors in the column it lands in, #884; previously trivial: a paragraph ending on a page with footnotes keeps its space after above the separator, #1116; previously trivial: symbol-font text drawn as Unicode symbols, #776; previously trivial: large-table scrolling measured and fixed, #811; previously trivial: text boxes use their own margins; Resize shape to fit text never cuts text, #743; previously trivial: complex-script shaping and Myanmar line breaking, #604; previously trivial: body page totals, table-cell footnotes and bookmark link pages fixed, #450 #451 #455; previously major: first version; line breaking, pagination and object placement checked against Word's behaviour from the layout source on origin/main) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** trivial (justified vertical page alignment, #1320; previously trivial: page numbers restart at 0; previously trivial: a list label moves with its first line when the paragraph is centred or end-aligned (#1159); previously trivial: PAGE fields in a paragraph split across pages show the page they are on, #1352; previously trivial: a dragged floating object anchors in the column it lands in, #884; previously trivial: a paragraph ending on a page with footnotes keeps its space after above the separator, #1116; previously trivial: symbol-font text drawn as Unicode symbols, #776; previously trivial: large-table scrolling measured and fixed, #811; previously trivial: text boxes use their own margins; Resize shape to fit text never cuts text, #743; previously trivial: complex-script shaping and Myanmar line breaking, #604; previously trivial: body page totals, table-cell footnotes and bookmark link pages fixed, #450 #451 #455; previously major: first version; line breaking, pagination and object placement checked against Word's behaviour from the layout source on origin/main) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 A document only "opens correctly" if its pages break where Word breaks them: the same lines, the same page count, footnotes and pictures on the same pages. This checklist covers line breaking, @@ -165,6 +165,7 @@ No overall parity estimate changes from these probes. | 2026-10-14 | trivial | A word too wide for the strip beside a square-wrapped object moves past it whole instead of being split across the strip (#1376) | | 2026-10-14 | trivial | Russian letter list formats (`russianLower`, `russianUpper`) draw а) б) в) … аа) бб) as Word does, instead of digits | | 2026-10-14 | trivial | A table's percentage width past 100% is kept, so a table pulled into the margins spans them as in Word | +| 2026-10-14 | trivial | A section can restart its page numbers at 0 (`w:pgNumType w:start="0"`), as a cover page numbered 0 does; the restart was read but laid out from 1 | | 2026-10-13 | trivial | A shape or picture dragged into another column anchors there, so the text in that column wraps around it (#884) | | 2026-10-13 | trivial | On a page with footnotes, a paragraph that ends there keeps its space after above the footnote separator; if it doesn't fit, its last line (and, with widow control, one more) goes to the next page with the note. Pages without notes are unchanged (#1116) | | 2026-10-12 | trivial | STYLEREF fields in headers and footers show each page's chapter (or other styled text); Insert › Quick Parts › Field offers STYLEREF (#775) |