+ `);
+ await page.addScriptTag({ path: join(__dirname, '../dist/pagination.bundle.js') });
+
+ return page.evaluate(() => {
+ const staging = document.getElementById('staging') as HTMLElement;
+ const container = document.getElementById('container') as HTMLElement;
+ const { PaginationEngine } = (window as any).DocxodusPagination;
+ new PaginationEngine(staging, container, { showPageNumbers: false }).paginate();
+ const pages = Array.from(container.querySelectorAll('.page-box'));
+ const target = pages[1].querySelector('#target')!;
+ const body = pages[1].querySelector('.page-content')!;
+ return {
+ pages: pages.length,
+ topFromBody: target.getBoundingClientRect().top - body.getBoundingClientRect().top,
+ marginTop: parseFloat(getComputedStyle(target).marginTop),
+ };
+ });
+}
+
+test.describe('page-top placement scope', () => {
+ test('uses the paragraph rule for an outlined heading after an explicit break', async ({ page }) => {
+ const result = await paginateMarkupAfterHardBreak(page, 'h1');
+ expect(result.pages).toBe(2);
+ expect(result.topFromBody).toBeCloseTo(0, 0);
+ expect(result.marginTop).toBeCloseTo(0, 0);
+ });
+
+ test('does not erase a non-paragraph block margin', async ({ page }) => {
+ const result = await paginateMarkupAfterHardBreak(page, 'div');
+ expect(result.pages).toBe(2);
+ expect(result.topFromBody).toBeCloseTo(SPACE_BEFORE_PX, 0);
+ expect(result.marginTop).toBeCloseTo(SPACE_BEFORE_PX, 0);
+ });
+});
diff --git a/npm/tests/visual-parity/BASELINE.md b/npm/tests/visual-parity/BASELINE.md
index 76d454fb..18341e61 100644
--- a/npm/tests/visual-parity/BASELINE.md
+++ b/npm/tests/visual-parity/BASELINE.md
@@ -475,9 +475,9 @@ issue where one exists:
LibreOffice, where the declared `w:ind w:left="1080"` is 168 px. The list-number suffix tab
advances to the next default stop instead of the declared text indent, ~25 px right on every
numbered clause — the heavy-numbering legal shape the library's positioning makes central.
- Secondary residuals: LibreOffice drops heading space-before at the top of a page where
- Docxodus paints the declared `w:spacing w:before` (16 px at each page top), and the cached
- TOC carries the recorded issue-#397 hyperlink-style deviation. Everything else about the
+ The secondary page-top residual was subsequently resolved by issue #428: Word evidence
+ confirmed suppression, and Docxodus now matches the page-top row within 1 px. The cached TOC
+ still carries the recorded issue-#397 hyperlink-style deviation. Everything else about the
case — cached TOC entries, leaders, PAGEREF results, multilevel heading numbers, cached REF
cross-references, the signature table — renders with content parity.
6. **Nested table (`reference-deviation`).** Both engines nest correctly and start the outer
@@ -571,6 +571,39 @@ now the honest seven (`chart-pie`,
refresh teaches: a renderer PR that changes corpus numbers must refresh the record in the same
diff — the improvements list printed by every passing run announces exactly when this is owed.
+## Page-top paragraph spacing — 2026-08-13 (issue #428)
+
+The Microsoft Graph Word capture attached to issue #428 decided the structural question left open
+above. On pages 2 and 3 of `legal-contract`, Word's first ink is row **100**, LibreOffice's is row
+**99**, and the old Docxodus render was row **115**: both office engines suppress the Heading 1
+style's 12 pt `w:spacing/@w:before` when natural pagination places it at the top of a later page.
+
+The fix lives in page placement rather than conversion. A measured block records whether it is a
+Word paragraph (`p`, or `h1`–`h6` for an outline-level paragraph), and one shared margin decision is
+used for keep-chain fit calculations, ordinary body budgeting, and the cloned block that is painted.
+It suppresses space-before only for the first paragraph on page 2+ of the same section. The first
+page of a document/section retains the authored spacing, ordinary inter-paragraph margin collapsing
+is unchanged, and non-paragraph block margins are outside the rule. Natural overflow,
+`w:pageBreakBefore`, and explicit page-break markers all converge on that placement state.
+
+A generated four-page DOCX asserts geometry rather than glyph pixels: 18 pt space-before is removed
+after natural and paragraph-format breaks, retained at document/section starts, and retained between
+same-page paragraphs. Direct paginator probes add the hard-break and non-paragraph controls. The
+tracked corpus rerun then moved Docxodus first ink to row **99** on both pages 2 and 3 — one pixel
+from Word and exact with LibreOffice. In the same LibreOffice 25.8.7.3 / Chromium 143 /
+Poppler 25.03 environment, before → after was:
+
+| Metric | Before | After |
+|---|---:|---:|
+| Mean SSIM, 3 pages | 0.69467 | **0.72874** |
+| Mean tolerant ink F1, 3 pages | 0.57203 | **0.70363** |
+| Page 2 SSIM / ink F1 | 0.61400 / 0.52821 | **0.66282 / 0.68928** |
+| Page 3 SSIM / ink F1 | 0.74653 / 0.52440 | **0.79992 / 0.75813** |
+
+This was a filtered evidence run, and the host's Poppler 25.03 differs from the committed ratchet's
+24.02 fingerprint, so these measurements are recorded here without rewriting the full-corpus
+ratchet. The within-environment before/after comparison and the exact first-ink rows remain valid.
+
## Current case results and triage
`SSIM` is the mean over paired pages. `Ink F1` is the worst paired-page value, so it exposes a
@@ -643,7 +676,8 @@ regression ratchet (issue #395), automatic line spacing (issue #396, which also
first implementations behind #411/#412/#413/#414/#415 (PRs #417–#421, whose numbers the
2026-08-13 refresh banked), the reference-version contract (issue #403), the Word-reference
evidence framework (issue #402 — tooling and procedure; measurements await a Word license),
-and the #404 reductions are resolved above. The remaining order is:
+the #404 reductions, and Word-confirmed page-top paragraph spacing (issue #428) are resolved above.
+The remaining order is:
1. The re-triage debt the 2026-08-13 refresh made explicit: `endnote`'s systematic body shift
and the two wrapped-image residuals (all `unattributed`, all strict-gating until triaged).
diff --git a/npm/tests/visual-parity/corpus.ts b/npm/tests/visual-parity/corpus.ts
index 50315e21..13fd5e0b 100644
--- a/npm/tests/visual-parity/corpus.ts
+++ b/npm/tests/visual-parity/corpus.ts
@@ -376,10 +376,11 @@ export const VISUAL_PARITY_CORPUS: VisualCorpusEntry[] = [
rationale: 'The formerly dominant residual — the list-number suffix tab advancing to the ' +
'next default stop instead of the declared text indent — was fixed by PR #421, with a ' +
'small corpus effect (ink F1 0.52148 to 0.52440), so the case\'s severity was never ' +
- 'that one defect alone. Remaining measured residuals, renderer side: heading ' +
- 'space-before painted at page tops where LibreOffice drops it (16 px per page top), ' +
- 'and accumulated per-clause offsets across the three pages; the cached TOC also ' +
- 'carries the recorded issue-#397 hyperlink-style deviation (not gating).',
+ 'that one defect alone. Issue #428 then removed the independently Word-confirmed ' +
+ 'page-top spacing residual (Docxodus first ink 115 to 99 px on pages 2/3; Word 100, ' +
+ 'LibreOffice 99). The case remains renderer-owned because accumulated per-clause ' +
+ 'offsets still span the three pages; the cached TOC also carries the recorded ' +
+ 'issue-#397 hyperlink-style deviation (not gating).',
reference: 'https://github.com/JSv4/Docxodus/issues/415',
},
},