From 8e0f42e4bc3afe0995cce4ee773c0aa7be076911 Mon Sep 17 00:00:00 2001 From: Rees <109076338+OPUZZLES@users.noreply.github.com> Date: Wed, 14 Oct 2026 22:56:29 +0200 Subject: [PATCH] fix(layout): match Word's contextual paragraph spacing (#568) Co-authored-by: Brandon Thomas --- ROADMAP.md | 2 + crates/layout/src/lib.rs | 22 +++-- crates/layout/src/tests.rs | 168 +++++++++++++++++++++++++++++++++++++ docs/gaps.md | 3 +- docs/layout-parity.md | 28 ++++++- 5 files changed, 215 insertions(+), 8 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 32b9a7dae..37d733ca9 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -316,6 +316,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | 2026-10-11 | Turkish (`tr`) interface: all 1,307 catalog entries, `tr-TR`/`tr-CY` system locales and a saved language choice; command search keeps Turkish dotted and dotless I apart (`italik` finds İtalik); locale, catalog, search and bundled-font tests; no Turkish proofing resources | | 2026-10-11 | Italian (`it`) interface (#594): 2,399 entries, feature names as Italian Word users know them, locale and saved-preference tests, bundled-font checks; no Italian proofing resources | | 2026-10-12 | Native drawing-level Word ink preserved through DOCX edits and repeated saves, including its InkML part and optional VML picture fallback; native ink rendering and editing remain missing | +| 2026-10-11 | Contextual paragraph spacing: same-style first-only 12/8 pt and 10/12 pt gaps corrected to 0 pt and 2 pt in body and header layout, with per-side controls; measured against Word 16.113.3. Ordinary overlap remains #108 | | 2026-10-11 | Citations and bibliography in all 12 of Word's styles: Harvard, ISO 690 (author-date and numerical), Turabian, GB/T 7714, GOST (name and title sort) and SIST02 join APA, MLA, Chicago and IEEE; numbered styles count in order of first citation (#383) | | 2026-10-11 | Interface size setting (#475): File › Options › General scales the interface 80–200 % on top of the system display scale, kept between runs; `ui.interfaceSize` for agents | | 2026-10-11 | DOCX paragraph defaults: preserve omission separately from explicit empty defaults, with repeated-save and default-edit regressions | @@ -455,6 +456,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | 2026-10-11 | trivial | Hungarian interface (`hu`): progress log and shipped languages | | 2026-10-11 | trivial | Turkish interface (`tr`): progress log and shipped languages | | 2026-10-12 | minor | Native drawing-level ink and its picture fallback survive DOCX edits and repeated saves; adapted to the current graphic writer; rendering and editing InkML remain missing | +| 2026-10-11 | minor | Progress log: contextual paragraph spacing in body paragraphs and headers | | 2026-10-11 | trivial | Progress log: eight more bibliography styles (#383) | | 2026-10-14 | trivial | Progress log: ODT and `.doc` pictures read once, within their byte budget (#1334) | | 2026-10-10 | trivial | Progress log: Draw tab ink (#307) | diff --git a/crates/layout/src/lib.rs b/crates/layout/src/lib.rs index 5e950e58a..10a79c288 100644 --- a/crates/layout/src/lib.rs +++ b/crates/layout/src/lib.rs @@ -1009,11 +1009,18 @@ fn layout_box( continue; } let ctxl = pl.rp.contextual_spacing; - let same = prev_style.as_ref().is_some_and(|(s, c)| *s == pl.rp.style && (*c || ctxl)); - let mut before = if same && ctxl { 0.0 } else { pl.rp.space_before }; - if same && ctxl { - y -= prev_after; - prev_after = 0.0; + let mut before = pl.rp.space_before; + if let Some((style, previous_contextual)) = &prev_style + && *style == pl.rp.style + { + if *previous_contextual { + y -= prev_after; + before = (before - prev_after).max(0.0); + prev_after = 0.0; + } + if ctxl { + before = 0.0; + } } // Automatic spacing: none between two list items (as in an HTML list). if i > 0 && prev_listed && listed { @@ -2877,6 +2884,8 @@ fn place_para_in_flow(ctx: &mut Ctx, pb: &mut PageBuilder, p: &Paragraph, block: if broke_before { pb.new_page(block, body_top); } + // Same-style contextual spacing removes this paragraph's before-space, or subtracts the + // previous paragraph's after-space from the merged gap (ISO/IEC 29500-1 §17.3.1.9). 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 @@ -2896,10 +2905,11 @@ fn place_para_in_flow(ctx: &mut Ctx, pb: &mut PageBuilder, p: &Paragraph, block: if let Some((style, ctxl, mut after, after_auto, listed)) = pb.prev.take() && !pb.at_top() { - // Contextual spacing: no space between paragraphs of the same style when either asks for it. + // Contextual spacing suppresses each paragraph's own side of the gap. if style == pl.rp.style { if ctxl { pb.y -= after; + before = (before - after).max(0.0); after = 0.0; } if pl.rp.contextual_spacing { diff --git a/crates/layout/src/tests.rs b/crates/layout/src/tests.rs index aaf7b0c71..691d4be5d 100644 --- a/crates/layout/src/tests.rs +++ b/crates/layout/src/tests.rs @@ -8,6 +8,174 @@ fn lay(doc: &Document) -> DocLayout { layout(doc, &mut c, &LayoutOptions::default()) } +fn contextual_gap(after: f32, before: f32, first: bool, second: bool, different_style: bool, header: bool) -> f32 { + use wordcraft_doc::props::LineSpacing; + let mut d = Document::new(); + for id in ["SpacingA", "SpacingB"] { + d.styles.upsert(wordcraft_doc::Style { id: id.into(), name: id.into(), ..Default::default() }); + } + let chr = wordcraft_doc::CharProps { font: Some("Source Sans 3".into()), size: Some(12.0), ..Default::default() }; + let props = ParaProps { + style: Some("SpacingA".into()), + space_before: Some(0.0), + space_after: Some(0.0), + line_spacing: Some(LineSpacing::Exactly(24.0)), + ..Default::default() + }; + let mut a = wordcraft_doc::Paragraph::with_text("Alpha probe", chr.clone()); + a.props = ParaProps { space_after: Some(after), contextual_spacing: Some(first), ..props.clone() }; + let mut b = wordcraft_doc::Paragraph::with_text("Beta probe", chr); + b.props = ParaProps { + style: Some(if different_style { "SpacingB" } else { "SpacingA" }.into()), + space_before: Some(before), + contextual_spacing: Some(second), + ..props + }; + let paragraphs = vec![wordcraft_doc::para_block(a), wordcraft_doc::para_block(b)]; + if header { + let id = d.add_part(wordcraft_doc::PartKind::Header, paragraphs); + d.last_section.headers.default = Some(id); + } else { + d.body = paragraphs; + } + let l = lay(&d); + let items = if header { &l.pages[0].header } else { &l.pages[0].items }; + let baselines: Vec<_> = + items.iter().filter_map(|i| if let Placed::Lines { para, y, .. } = i { Some(y + para.lines[0].baseline) } else { None }).collect(); + assert_eq!(baselines.len(), 2); + baselines[1] - baselines[0] - 24.0 +} + +// Original two-paragraph probes measured in Word for Mac 16.113.3, build 26092714. +// Exactly 24 pt spacing isolates the extra gap from font-dependent line height. +#[test] +fn contextual_after_removes_a_smaller_before_gap() { + for header in [false, true] { + let gap = contextual_gap(12.0, 8.0, true, false, false, header); + assert!(gap.abs() < 0.01, "header={header}: gap={gap}, expected 0 pt"); + } +} + +#[test] +fn contextual_after_keeps_only_the_residual_before_gap() { + // ISO/IEC 29500-1 §17.3.1.9's worked example: max(10, 12) - 10 = 2 pt. + for header in [false, true] { + let gap = contextual_gap(10.0, 12.0, true, false, false, header); + assert!((gap - 2.0).abs() < 0.01, "header={header}: gap={gap}, expected 2 pt"); + } +} + +#[test] +fn contextual_spacing_preserves_each_side_and_style_identity() { + for header in [false, true] { + for (after, before, first, second, different, expected) in [ + (12.0, 8.0, false, true, false, 12.0), + (12.0, 8.0, true, true, false, 0.0), + (12.0, 0.0, true, true, true, 12.0), + (12.0, 0.0, false, false, false, 12.0), + ] { + let gap = contextual_gap(after, before, first, second, different, header); + assert!( + (gap - expected).abs() < 0.01, + "header={header}, first={first}, second={second}, different={different}: gap={gap}, expected {expected}" + ); + } + } +} + +#[test] +fn contextual_spacing_removes_every_gap_in_a_four_paragraph_run() { + use wordcraft_doc::props::LineSpacing; + for table in [false, true] { + let mut doc = Document::new(); + doc.styles.upsert(wordcraft_doc::Style { id: "SpacingA".into(), name: "SpacingA".into(), ..Default::default() }); + let paragraphs = ["One", "Two", "Three", "Four"] + .into_iter() + .map(|text| { + let mut p = wordcraft_doc::Paragraph::with_text( + text, + wordcraft_doc::CharProps { font: Some("Source Sans 3".into()), size: Some(12.0), ..Default::default() }, + ); + p.props = ParaProps { + style: Some("SpacingA".into()), + space_before: Some(6.0), + space_after: Some(6.0), + contextual_spacing: Some(true), + line_spacing: Some(LineSpacing::Exactly(24.0)), + ..Default::default() + }; + wordcraft_doc::para_block(p) + }) + .collect::>(); + if table { + let mut cell = Table::new(1, 1, 400.0); + cell.rows[0].cells[0].blocks = paragraphs; + doc.body = vec![Arc::new(Block::Table(cell))]; + } else { + doc.body = paragraphs; + } + let layout = lay(&doc); + let baselines = layout.pages[0] + .items + .iter() + .filter_map(|item| match item { + Placed::Lines { para, y, .. } => Some(y + para.lines[0].baseline), + _ => None, + }) + .collect::>(); + assert_eq!(baselines.len(), 4); + // Every same-style contextual boundary has zero extra space, including after paragraph two. + for pair in baselines.windows(2) { + assert!((pair[1] - pair[0] - 24.0).abs() < 0.01, "table={table}: {baselines:?}"); + } + } +} + +// Contextual and automatic list spacing can suppress the same after-space only once. +#[test] +fn contextual_automatic_lists_do_not_subtract_the_gap_twice() { + use wordcraft_doc::props::{LineSpacing, NumRef}; + for table in [false, true] { + let mut doc = Document::new(); + let num = doc.numbering.add_list(wordcraft_doc::ListKind::Numbered); + let paragraphs = ["First", "Second", "Third"] + .into_iter() + .map(|text| { + let mut p = wordcraft_doc::Paragraph::with_text(text, Default::default()); + p.props = ParaProps { + contextual_spacing: Some(true), + space_before_auto: Some(true), + space_after_auto: Some(true), + numbering: Some(NumRef { num, level: 0 }), + line_spacing: Some(LineSpacing::Exactly(24.0)), + ..Default::default() + }; + wordcraft_doc::para_block(p) + }) + .collect::>(); + if table { + let mut cell = Table::new(1, 1, 400.0); + cell.rows[0].cells[0].blocks = paragraphs; + doc.body = vec![Arc::new(Block::Table(cell))]; + } else { + doc.body = paragraphs; + } + let layout = lay(&doc); + let baselines = layout.pages[0] + .items + .iter() + .filter_map(|item| match item { + Placed::Lines { para, y, .. } => Some(y + para.lines[0].baseline), + _ => None, + }) + .collect::>(); + assert_eq!(baselines.len(), 3); + for pair in baselines.windows(2) { + assert!((pair[1] - pair[0] - 24.0).abs() < 0.01, "table={table}: {baselines:?}"); + } + } +} + fn lines_of(l: &DocLayout) -> usize { l.pages.iter().flat_map(|p| p.items.iter()).map(|it| if let Placed::Lines { l0, l1, .. } = it { l1 - l0 } else { 0 }).sum() } diff --git a/docs/gaps.md b/docs/gaps.md index 5ad11bf9f..61fa65104 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -20,8 +20,8 @@ Document renaming: desktop renaming requires a filesystem that supports hard lin | 3 | **Crashes and freezes in the field**: startup crash on Intel UHD Graphics (#170, open), a crash on macOS 12.7.6 Intel (#979, open) (lag on GPUs without a DirectX 12 driver fixed, #316; 32-bit Windows exited at launch because DirectX 12 graphics pipeline creation failed, now OpenGL), freezes (#59 Thai fonts and #31 font selector, both open; #77 closed), broken shape tool (#142, open); the Windows 11 no-write-without-admin (#218) and unmovable images (#82) reports are closed; memory growth on every font change fixed (#848: the font list no longer loads each family it previews) | B | 197 open issues (2026-10-12), among them the crash and freeze reports #170, #979, #59 and #31 | Anyone hit loses work or trust; the never-crash standard covers panics, not hangs or GPU driver faults | 20–30 | [target-app-parity.md](target-app-parity.md) (Stability) | | 1 | **DOCX never tested on real-world files.** No corpus of files from Word, Google Docs, LibreOffice and Pages has been opened, rendered and round-tripped and compared with Word page by page | B | No corpus in the repo; `crates/docx/tests` are synthetic fixtures (including omitted/empty paragraph defaults and imported TOC range preservation); "DOCX fidelity corpus: not started" since 2026-10-06. Field reports fixed one by one: a cover page's theme-coloured shapes (#611), freeforms inside groups and text-box margins and Resize to fit (#743), cover-page positions relative to the page and freeforms drawn back to their start (#743) | Everyone: the files people receive are the files that break | 30–50 | [file-format-parity.md](file-format-parity.md) | | 2 | **Remaining native ink forms are dropped on open and lost on save, silently**; OLE objects show only their picture | B | Drawing-level native ink, its optional picture fallback, and direct run-level content parts are preserved; ink nested in groups/canvases remains unsupported. Drawing canvases are read and written (#344); group shapes (`wpg`) read and written (#267). Charts and SmartArt are drawn (#292) and, with OLE objects, written back with all their parts on save (#319) | Documents with diagrams drawn in Word or pen notes lose content without warning | 4–8 to preserve remaining native ink forms; 45–70 to render and edit charts and SmartArt (#13) | [file-format-parity.md](file-format-parity.md) | -| 4 | **Pagination differs from Word**: Aptos has no metric-matched substitute; legacy compatibility options ignored; no page-by-page comparison harness | B | [layout-parity.md](layout-parity.md); [typing-parity.md](typing-parity.md) Known gaps | Page counts and line breaks differ, which matters for forms, legal and academic work | 30–50 + owner (font) | [layout-parity.md](layout-parity.md) | | 5 | **Content controls** landed (#381): `w:sdt` kept, shown and edited (Developer tab, Design Mode, Properties). Move tracking landed (#400): `w:moveFrom`/`w:moveTo` with their named ranges round-trip, a tracked cut and paste of a paragraph or sentence records a move, either end accepts/rejects both, green double strike/underline markup and "Moved from/to" balloons; still missing: drag-and-drop moves (no text drag yet) and move options in Track Changes Options. Formatting revisions landed (#41): `w:rPrChange`/`w:pPrChange`/`w:tblPrChange`/`w:trPrChange`/`w:tcPrChange`/`w:sectPrChange`/`w:numberingChange` round-trip, are recorded while tracking, accepted/rejected and shown in balloons; still missing: tracked style-definition changes and table/section changes made in WordCraft. Opaque custom XML backing stores now survive saves; binding evaluation remains absent. Replace All now checks content-control locks before editing any match; a refused batch preserves the document and undo history. | B | Issues #41, #400; source grep | Legal and editorial review workflows; templates with form controls | 5–8 | [file-format-parity.md](file-format-parity.md) | +| 4 | **Pagination differs from Word**: Aptos has no metric-matched substitute; legacy compatibility options ignored; no page-by-page comparison harness | B | Contextual spacing corrected in body/header probes and repeated table-cell runs; [layout-parity.md](layout-parity.md); [typing-parity.md](typing-parity.md) Known gaps | Page counts and line breaks differ, which matters for forms, legal and academic work | 30–50 + owner (font) | [layout-parity.md](layout-parity.md) | | 6 | **Print dialog depth**: desktop printer choice, copies, collation, duplex and grayscale submit through craft-print; File › Print retains preview, page ranges and odd/even (#641); driver tray/options and pages per sheet remain; web prints through the browser (#209) | B | `ui.print` → `apps/wordcraft/src/print.rs`; issue #15 | Everyone who prints | 5–8 | [hardware-parity.md](hardware-parity.md) | | 7 | **Dialog depth**: 54 modal dialogs vs Word's ~100 | B | `Dialog` enum in `crates/ui-egui/src/dialogs.rs` | Power users reach for dialogs the ribbon alone doesn't cover | 11–19 | [ui-parity.md](ui-parity.md) | | 8 | **Proofing covers English, Polish, Portuguese, Italian, Croatian and Turkish only**; no dictionaries, grammar or hyphenation for the other ~44 languages Word proofs (text in them is left unmarked) | | `crates/proof` `ProofLang::PROOFED`: English, Polish, Portuguese (pt-BR via the installed VERO dictionary, #797), Italian (it-IT via the installed Hunspell dictionary), Croatian (hr-HR, 2026-10-13) and Turkish (tr_TR spelling, grammar, TDK hyphenation, 2026-10-13), a language per run; Bulgarian has hyphenation only (#826); open PRs add Danish (#723), Russian (#894) and Czech (#1048); Word ships 120 proofing tools; issues #25, #40, #100 open | Every writer outside English, Polish, Portuguese, Italian, Croatian and Turkish | 20–40 | [localization-parity.md](localization-parity.md) | @@ -137,6 +137,7 @@ The same gaps grouped the way the parity documents are, for agents working in on | 2026-10-10 | trivial | #24: 125 of ~250 keyboard shortcuts after #330 | | 2026-10-11 | trivial | Collapsed duplicate rows left by parallel merges | | 2026-10-11 | minor | Native drawing-level ink and its picture fallback survive DOCX edits and repeated saves; rendering and editing InkML remain missing | +| 2026-10-11 | minor | #4: body/header contextual spacing corrected; ordinary overlap remains #108 and pagination remains a beta gap | | 2026-10-11 | trivial | All 12 bibliography styles landed (#383); removed from References depth | | 2026-10-11 | trivial | Formatting revisions landed (#41); gap #5 narrowed to move tracking and content controls, 8–12 h | | 2026-10-11 | trivial | Omitted `w:pPrDefault` stays omitted across saves; explicit empty/default properties and later spacing edits remain writable. Synthetic preservation checks only; readiness estimates unchanged | diff --git a/docs/layout-parity.md b/docs/layout-parity.md index 9d99cb4e2..fe6454216 100644 --- a/docs/layout-parity.md +++ b/docs/layout-parity.md @@ -27,7 +27,7 @@ font can't match Word's metrics. | Colour emoji and colour fonts on screen and in page renders: COLR v0/v1 paint graphs (solid fills, linear, radial and sweep gradients, composite modes) and bitmap strikes (Apple Color Emoji `sbix`, Noto Color Emoji `CBDT`) drawn in the font's colours at the layout's position and size; a dark page doesn't invert them, the same as pictures | 🟡 | `fonts::color` (skrifa), `render::color_glyph`; `render::tests_color_glyphs::emoji_are_drawn_in_colour` and `emoji_keep_their_colours_on_a_dark_page` (system font, skip without one), `render::color_glyph::tests`, `fonts::color::tests` (#1269). CBDT is verified on a real page with Noto Color Emoji; COLR v0/v1 and sbix are implemented to the OpenType spec and tested with synthetic data, but not yet seen on a real font (Segoe UI Emoji, COLRv1 Noto, Apple Color Emoji). PDF export already drew them through krilla | 1–2 | | Symbol fonts (Symbol, Wingdings, Wingdings 2/3, Webdings) without the proprietary font: each character, stored as U+F020–U+F0FF or a plain 8-bit code, is drawn as its Unicode equivalent from a face that has it; PDF text and copies read as those symbols | ✅ | `doc::symbol_fonts` tables (Adobe Symbol table and Unicode L2/12-368), `layout::tests::wingdings_text_draws_and_reads_as_its_unicode_symbols` (#776). Shapes come from Noto Sans Symbols / Symbols 2, DejaVu Sans or Segoe UI Symbol, so they differ from Word's; Wingdings 0xFF (not in Unicode) still has no symbol | — | | Indents in characters (`w:firstLineChars`, `w:leftChars`…), measured in the paragraph mark's font size | ✅ | #534, `layout::tests::character_indents_use_the_paragraph_mark_size`; spacing in lines (`w:beforeLines`/`w:afterLines`) uses the stored points (needs the document grid) | — | -| Line spacing (single, 1.5, double, at least, exactly, multiple), spacing before/after, contextual spacing | 🟡 | Word puts multiple-spacing extra below the text and collapses spacing differently (#135, #108 open) | 3–5 | +| Line spacing (single, 1.5, double, at least, exactly, multiple), spacing before/after, contextual spacing | 🟡 | Contextual spacing measured in body paragraphs and headers (below), with a four-paragraph table-cell regression; other line-spacing combinations remain (#108, #135 open) | 3–5 | | Kerning only where Kerning for fonts asks for it (`w:kern`, at or above its size) | ✅ | Measured in Word for Windows: no `w:kern` or `w:kern="0"` is unkerned; WordCraft kerned every run, so its lines held more text than Word's. `layout::tests::kerning_follows_kerning_for_fonts_like_word` | — | | Automatic (HTML-style) paragraph spacing (`w:beforeAutospacing`, `w:afterAutospacing`) | ✅ | Measured in Word for Windows: 14 pt at any size, none at the top of the document or a cell or after a cell's last paragraph, the larger of two neighbouring spaces. `layout::tests::automatic_paragraph_spacing_like_word`, `…_drops_at_cell_edges` | — | | Widow/orphan control, keep with next, keep lines together, page break before | ✅ | `lib.rs` keep_next/keep_lines; a line holding only a page break stays at the bottom of a full page instead of leaving a blank page (#534); a paragraph ending with a page break keeps its space after on the old page, so the next page starts at the top (compatibility modes 12 to 15, `layout::tests::space_before_at_the_top_of_a_page_like_word`) | — | @@ -65,6 +65,29 @@ font can't match Word's metrics. | Compatibility modes (Word 2003/2007/2010 layout for old files: `w:compat` options) | 🟡 | mode 15 behaviour; the ~60 legacy compat options are mostly ignored | 10–20 | | Page-by-page comparison harness against Word | ❌ | Run Word locally on our own synthetic documents, export PDF, compare line breaks; Word output stays under `plan/` (never committed) | 8–12 | +## Contextual paragraph spacing + +**Observed, 2026-10-11:** original synthetic DOCX probes in Word for Mac 16.113.3 +(build 26092714), compatibility mode 15, Roboto Regular 12 pt, Exactly 24 pt line +spacing. With identical paragraph style IDs and contextual spacing enabled only +on the first paragraph, 12 pt after / 8 pt before adds 0 pt to baseline separation; +10 pt after / 12 pt before adds 1.92 pt, within the predeclared 0.25 pt tolerance +of 2 pt. Body paragraphs and headers give the same results. Enabling only the +second paragraph preserves the first paragraph's 12 pt after-space. + +The first-only residual agrees with the worked example in +[ISO/IEC 29500-1 §17.3.1.9](https://learn.microsoft.com/en-us/dotnet/api/documentformat.openxml.wordprocessing.contextualspacing?view=openxml-3.0.1). +`contextual_after_removes_a_smaller_before_gap`, +`contextual_after_keeps_only_the_residual_before_gap`, and +`contextual_spacing_preserves_each_side_and_style_identity` pin those relative +gaps and controls in body and header layout. Different style IDs retain their +spacing even when their definitions are identical. + +This fixes contextual suppression in `place_para` and `layout_box`. Ordinary +before/after overlap still needs #108; the uniform first-baseline offset, page +boundaries, tables, text boxes and notes were not compared with Word in this study. +No overall parity estimate changes from these probes. + ## Performance | Measure | WordCraft | Word | Kind | @@ -119,6 +142,7 @@ font can't match Word's metrics. | 2026-10-11 | trivial | Right-to-left tables and sections laid out mirrored (#362) | | 2026-10-10 | trivial | The document's own note separators and continuation notice are drawn (#352) | | 2026-10-11 | trivial | Newspaper columns balance before a continuous section break (#350) | +| 2026-10-11 | minor | Body/header contextual spacing corrected from original Word probes; ordinary overlap remains #108 | | 2026-10-11 | trivial | Group and Ungroup keep turned members' orientation and placement (#441, #443); Size resizes a group (#442); the eraser hits turned ink where it is drawn (#445) | | 2026-10-10 | trivial | Long footnotes continue onto the next page with a continuation separator (#352) | | 2026-10-11 | trivial | `EQ \o` fields overstrike their arguments; enclosed characters draw their circle, square, triangle or diamond as vectors (#297) | @@ -130,3 +154,5 @@ font can't match Word's metrics. | 2026-10-10 | trivial | Long display equations wrap across lines (#326) | | 2026-10-10 | trivial | AutoFit Contents measures the text (#44) | | 2026-10-10 | major | First version: layout and pagination checklist with evidence from `crates/layout`, PRs and issues | + +Repeated table-cell control, 12 October 2026: four same-style contextual paragraphs with 6 pt before/after and exactly 24 pt lines retain zero extra gap at every boundary. The body control uses the same fixture. This is an original specification-based regression; no new native Word table comparison was run.