diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index d6acc31d6..d2246ee6b 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -494,6 +494,10 @@ pub struct Settings { /// 2013 and later, which places a table's border at the margin rather than its text and lets /// justified lines shrink their spaces to fit more text. pub compat_mode: u32, + /// `w:doNotUseHTMLParagraphAutoSpacing`: the space between two paragraphs is the first's space + /// after plus the second's space before, not the larger of the two. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub sum_paragraph_spacing: bool, /// Keep contextual paragraph spacing inside legacy table cells (`allowSpaceOfSameStyleInTable`). /// Word ignores the layout override in compatibility mode 15, but the flag still round-trips. pub allow_space_of_same_style_in_table: bool, @@ -628,6 +632,7 @@ impl Default for Settings { protect_formatting: false, protect_hash: Vec::new(), compat_mode: COMPAT_MODE_CURRENT, + sum_paragraph_spacing: false, allow_space_of_same_style_in_table: false, grid_h: DEFAULT_GRID, grid_v: DEFAULT_GRID, diff --git a/crates/docx/src/read/mod.rs b/crates/docx/src/read/mod.rs index fa8af6f63..35db2cb93 100644 --- a/crates/docx/src/read/mod.rs +++ b/crates/docx/src/read/mod.rs @@ -1069,6 +1069,7 @@ impl Reader<'_> { if let Some(m) = mode.and_then(u32_of) { s.compat_mode = m.clamp(11, 99); } + s.sum_paragraph_spacing = k.children("w:doNotUseHTMLParagraphAutoSpacing").any(on_off); } "w:trackRevisions" => s.track_changes = on_off(k), "w:defaultTabStop" => { diff --git a/crates/docx/src/write/mod.rs b/crates/docx/src/write/mod.rs index 04ec182fb..a031a231e 100644 --- a/crates/docx/src/write/mod.rs +++ b/crates/docx/src/write/mod.rs @@ -1049,6 +1049,10 @@ fn settings_xml(doc: &Document, footnotes: bool, endnotes: bool, template_rel: O w.close(tag); } w.open("w:compat", &[]); + // CT_Compat: the older switches come before the `w:compatSetting`s. + if doc.settings.sum_paragraph_spacing { + w.empty("w:doNotUseHTMLParagraphAutoSpacing", &[]); + } if s.allow_space_of_same_style_in_table { w.empty("w:allowSpaceOfSameStyleInTable", &[]); } diff --git a/crates/docx/tests/fixtures.rs b/crates/docx/tests/fixtures.rs index b4189c8aa..2b1a6f4e2 100644 --- a/crates/docx/tests/fixtures.rs +++ b/crates/docx/tests/fixtures.rs @@ -2567,6 +2567,20 @@ fn text_frames_read_and_round_trip() { assert!(q.iter().any(|x| x.props.drop_cap == Some(2) && x.props.frame.is_none())); } +/// `w:doNotUseHTMLParagraphAutoSpacing` in the settings' `w:compat`: paragraph spacing adds up. +#[test] +fn html_auto_spacing_off_is_read_and_kept() { + let settings = |compat: &str| { + let s = format!(r#"{compat}"#); + let rels = [("rId9", "http://schemas.openxmlformats.org/officeDocument/2006/relationships/settings", "settings.xml")]; + let d = wordcraft_docx::read(&docx("", &rels, &[("word/settings.xml", &s)])).unwrap(); + let again = wordcraft_docx::read(&wordcraft_docx::write(&d).unwrap()).unwrap(); + (d.settings.sum_paragraph_spacing, again.settings.sum_paragraph_spacing) + }; + assert_eq!(settings(""), (true, true)); + assert_eq!(settings(""), (false, false)); +} + /// A right triangle (`rtTriangle`) has its right angle at the bottom left: a cover page's white /// corner cut-outs drawn as isosceles triangles pointed the wrong way. #[test] diff --git a/crates/layout/src/lib.rs b/crates/layout/src/lib.rs index aba60a6dd..6103badc4 100644 --- a/crates/layout/src/lib.rs +++ b/crates/layout/src/lib.rs @@ -1083,7 +1083,7 @@ 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 && before > 0.0 && prev_after > 0.0 { + } else if i > 0 && before > 0.0 && prev_after > 0.0 && !ctx.doc.settings.sum_paragraph_spacing { before = (before - prev_after).max(0.0); } y += if i == 0 { before } else { before.max(0.0) }; @@ -3139,7 +3139,7 @@ fn place_para_in_flow(ctx: &mut Ctx, pb: &mut PageBuilder, p: &Paragraph, block: } // 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 { + if before > 0.0 && after > 0.0 && !ctx.doc.settings.sum_paragraph_spacing { before = (before - after).max(0.0); } } diff --git a/crates/layout/src/tests.rs b/crates/layout/src/tests.rs index b91ce61e6..7bed4c865 100644 --- a/crates/layout/src/tests.rs +++ b/crates/layout/src/tests.rs @@ -1392,6 +1392,39 @@ fn space_between_paragraphs_is_the_larger_of_after_and_before() { assert!(gap(6.0, 12.0, true).abs() < 0.01); } +#[test] +fn space_between_paragraphs_is_their_sum_when_html_auto_spacing_is_off() { + // Measured in Word (compatibility modes 14 and 15): with `w:doNotUseHTMLParagraphAutoSpacing`, + // 12 pt after and 12 pt before leave 24 pt; without it, 12 pt. + let gaps = |sum: bool| -> (f32, f32) { + let mut d = Document::from_text("One\nTwo"); + d.settings.sum_paragraph_spacing = sum; + for i in 0..2 { + let p = d.para_mut(StoryRef::Body, &Path::top(i)).unwrap(); + (p.props.space_after, p.props.space_before) = (Some(12.0), Some(12.0)); + } + let mut t = Table::new(1, 1, 468.0); + t.rows[0].cells[0].blocks = d.body.clone(); + d.insert_block(StoryRef::Body, &Path::top(2), wordcraft_doc::Block::Table(t)).unwrap(); + let l = lay(&d); + let tops = |cell: bool| -> Vec<(f32, f32)> { + l.pages[0] + .items + .iter() + .filter_map(|it| match it { + Placed::Lines { path, y, para, .. } if (path.0.len() == 4) == cell => Some((*y, para.height)), + _ => None, + }) + .collect() + }; + let gap = |ys: Vec<(f32, f32)>| ys[1].0 - (ys[0].0 + ys[0].1); + (gap(tops(false)), gap(tops(true))) + }; + let near = |(a, b): (f32, f32), want: f32| (a - want).abs() < 0.01 && (b - want).abs() < 0.01; + assert!(near(gaps(true), 24.0), "{:?}", gaps(true)); + assert!(near(gaps(false), 12.0), "{:?}", gaps(false)); +} + #[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. diff --git a/docs/layout-parity.md b/docs/layout-parity.md index 093ddd4ab..74ae363f8 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 (an inherited footer sizes its percentage tables by its own section; previously trivial: an empty section mark stays on a full page; previously trivial: a fitting row with less room than its minimum height moves on whole; oversized rows still split; previously trivial: text past an exact-height row is cut off; previously trivial: a list number stays with text after a leading page break; previously trivial: a bookmark after a page break takes no line; previously trivial: a page break followed by a page break before or a next-page section starts one page; previously trivial: All Caps and Small Caps draw ß as SS, #1581; previously 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 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** trivial (paragraph spacing adds up when the document turns HTML auto spacing off; previously trivial: an inherited footer sizes its percentage tables by its own section; previously trivial: an empty section mark stays on a full page; previously trivial: a fitting row with less room than its minimum height moves on whole; oversized rows still split; previously trivial: text past an exact-height row is cut off; previously trivial: a list number stays with text after a leading page break; previously trivial: a bookmark after a page break takes no line; previously trivial: a page break followed by a page break before or a next-page section starts one page; previously trivial: All Caps and Small Caps draw ß as SS, #1581; previously 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, @@ -159,6 +159,7 @@ No overall parity estimate changes from these probes. | Date | Change | Summary | |---|---|---| +| 2026-10-14 | trivial | With `w:doNotUseHTMLParagraphAutoSpacing` the space between two paragraphs is the first's space after plus the second's space before, as measured in Word; without it, still the larger (#733) | | 2026-10-14 | trivial | An inherited header or footer sizes its percentage-width tables by the section that defines it, as measured in Word (a portrait footer table keeps its width on inheriting landscape pages) | | 2026-10-14 | trivial | An empty paragraph closing a section before a new page stays on a full page instead of starting a page of its own, as measured in Word | | 2026-10-14 | trivial | A table row with an at-least height doesn't split where less than that height is left on the page; it starts the next page whole, as measured in Word |