fix(layout): match Word's contextual paragraph spacing (#568)

Co-authored-by: Brandon Thomas <echelon@gmail.com>
This commit is contained in:
Rees
2026-10-14 16:56:29 -04:00
committed by GitHub
co-authored by Brandon Thomas
parent f822dcb23c
commit 8e0f42e4bc
5 changed files with 215 additions and 8 deletions
+2
View File
@@ -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) |
+16 -6
View File
@@ -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 {
+168
View File
@@ -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::<Vec<_>>();
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::<Vec<_>>();
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::<Vec<_>>();
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::<Vec<_>>();
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()
}
+2 -1
View File
@@ -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 |
+27 -1
View File
@@ -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.