From 7609fba2cf83ac7684e6961eded5523468c0d7a4 Mon Sep 17 00:00:00 2001 From: JSv4 Date: Thu, 13 Aug 2026 00:42:04 -0500 Subject: [PATCH] Suppress paragraph spacing at page tops --- CHANGELOG.md | 14 ++ npm/src/pagination.ts | 108 ++++++++-- npm/tests/docx-page-top-spacing-fixture.ts | 105 ++++++++++ npm/tests/pagination-page-top-spacing.spec.ts | 184 ++++++++++++++++++ npm/tests/visual-parity/BASELINE.md | 42 +++- npm/tests/visual-parity/corpus.ts | 9 +- 6 files changed, 433 insertions(+), 29 deletions(-) create mode 100644 npm/tests/docx-page-top-spacing-fixture.ts create mode 100644 npm/tests/pagination-page-top-spacing.spec.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 8274fb3f..2d53a36e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -54,6 +54,20 @@ All notable changes to this project will be documented in this file. dispositions were re-triaged from the new evidence. ### Fixed +- **Paragraph space-before is suppressed at the top of later pages, matching + Word's section-aware pagination rule** (issue #428) — page placement now + treats converted `p` and outline-heading blocks as Word paragraphs: the + authored top spacing participates in normal same-page margin collapsing and + remains visible on the first page of a document or section, but the clone + placed first on a later page drops that spacing. Natural overflow, + `w:pageBreakBefore`, hard-break markers, and keep-with-next measurement all + use the same decision, while table and other non-paragraph margins remain + untouched. A generated four-page DOCX pins natural and forced breaks, + section-start and same-page controls; direct paginator probes cover outlined + headings, hard breaks, and non-paragraph scope. On the tracked legal contract, + page-2/page-3 first ink moved from row 115 to 99 px (Word 100, LibreOffice 99), + and a same-environment filtered rerun improved mean SSIM 0.69467 → 0.72874 + and mean ink F1 0.57203 → 0.70363. - **Mobile Chrome no longer garbles the arcade/observatory animations with extra wrapped rows, and the converter opts document text out of device-driven inflation** — on Android the demo's 92-cell frame rows render diff --git a/npm/src/pagination.ts b/npm/src/pagination.ts index 1899cc27..4c1678fe 100644 --- a/npm/src/pagination.ts +++ b/npm/src/pagination.ts @@ -92,6 +92,8 @@ export interface MeasuredBlock { pageBreakBefore: boolean; /** Whether this is a page break marker */ isPageBreak: boolean; + /** Whether the block is a Word paragraph whose top margin represents paragraph space-before */ + isWordParagraph?: boolean; } /** @@ -436,6 +438,7 @@ export class PaginationEngine { keepLines: child.dataset.keepLines === "true", pageBreakBefore: child.dataset.pageBreakBefore === "true", isPageBreak, + isWordParagraph: this.isWordParagraphElement(child), }); } @@ -509,6 +512,7 @@ export class PaginationEngine { keepLines: false, pageBreakBefore: false, isPageBreak: false, + isWordParagraph: false, }); start = end; } @@ -545,6 +549,7 @@ export class PaginationEngine { isPageBreak: element.dataset.pageBreak === "true" || element.classList.contains(`${this.cssPrefix}break`), + isWordParagraph: this.isWordParagraphElement(element), }; this.stagingElement.removeChild(measurementHost); @@ -586,14 +591,18 @@ export class PaginationEngine { private measureKeepWithNextChainBodyHeight( chain: MeasuredBlock[], previousMarginBottomPt: number, - isFirstOnPage: boolean + isFirstOnPage: boolean, + pageInSection: number ): number { const firstBlock = chain[0]; if (!firstBlock) return 0; - const firstMarginTop = isFirstOnPage - ? firstBlock.marginTopPt - : Math.max(firstBlock.marginTopPt, previousMarginBottomPt) - previousMarginBottomPt; + const firstMarginTop = this.effectiveBlockMarginTop( + firstBlock, + previousMarginBottomPt, + isFirstOnPage, + pageInSection + ); let bodyHeight = firstMarginTop + firstBlock.heightPt; for (let index = 1; index < chain.length; index++) { @@ -605,6 +614,54 @@ export class PaginationEngine { return bodyHeight; } + /** Word paragraphs render as `p`, or as `h1`–`h6` when their style has an outline level. */ + private isWordParagraphElement(element: HTMLElement): boolean { + return element.tagName === "P" || /^H[1-6]$/.test(element.tagName); + } + + private shouldSuppressPageTopSpacing( + block: MeasuredBlock, + isFirstOnPage: boolean, + pageInSection: number + ): boolean { + return isFirstOnPage && pageInSection > 1 && block.isWordParagraph === true; + } + + /** + * Resolves the part of a block's top margin that consumes the current page. + * + * In Word's native DOCX layout, paragraph space-before is suppressed when a paragraph is the + * first body block on a later page of the SAME section. The first page of a document/section is + * the exception and keeps its spacing. Tables and other block margins are not paragraph spacing, + * so they continue to use the ordinary CSS collapsing rule. + */ + private effectiveBlockMarginTop( + block: MeasuredBlock, + previousMarginBottomPt: number, + isFirstOnPage: boolean, + pageInSection: number + ): number { + if (this.shouldSuppressPageTopSpacing(block, isFirstOnPage, pageInSection)) { + return 0; + } + return isFirstOnPage + ? block.marginTopPt + : Math.max(block.marginTopPt, previousMarginBottomPt) - previousMarginBottomPt; + } + + /** Clone a source block with the same page-top spacing decision used by the height budget. */ + private cloneBlockForPage( + block: MeasuredBlock, + isFirstOnPage: boolean, + pageInSection: number + ): HTMLElement { + const clone = block.element.cloneNode(true) as HTMLElement; + if (this.shouldSuppressPageTopSpacing(block, isFirstOnPage, pageInSection)) { + clone.style.setProperty("margin-top", "0", "important"); + } + return clone; + } + /** * Finds footnote references introduced by a sequence of blocks, preserving * document order and excluding references already assigned to the page. @@ -1777,7 +1834,8 @@ export class PaginationEngine { this.measureKeepWithNextChainBodyHeight( keepChain, prevMarginBottomPt, - currentContent.length === 0 + currentContent.length === 0, + pageInSection ) + additionalChainFootnoteHeight; const currentAvailableHeight = remainingHeight - currentFootnoteHeight; @@ -1792,7 +1850,8 @@ export class PaginationEngine { const freshChainBodyHeight = this.measureKeepWithNextChainBodyHeight( keepChain, 0, - true + true, + pageInSection + 1 ); // finishPage transfers this continuation to the new page's // currentContinuation state, so include it in the destination @@ -1835,11 +1894,12 @@ export class PaginationEngine { // Calculate the effective height this block will consume // Account for margin collapsing: the gap between blocks is max(prevBottom, currTop), not sum const isFirstOnPage = currentContent.length === 0; - let effectiveMarginTop = block.marginTopPt; - if (!isFirstOnPage) { - // Margin collapsing: use the larger of the two adjacent margins - effectiveMarginTop = Math.max(block.marginTopPt, prevMarginBottomPt) - prevMarginBottomPt; - } + const effectiveMarginTop = this.effectiveBlockMarginTop( + block, + prevMarginBottomPt, + isFirstOnPage, + pageInSection + ); // Visible height = top margin gap + content + footnote space // Note: bottom margin is NOT included in the fit check because the last block's // bottom margin extends beyond the content area and is clipped by overflow:hidden. @@ -1875,7 +1935,7 @@ export class PaginationEngine { // Check if block fits on current page (including its footnotes) if (blockSpace <= effectiveRemainingHeight) { // Block fits with current footnote allocation - currentContent.push(block.element.cloneNode(true) as HTMLElement); + currentContent.push(this.cloneBlockForPage(block, isFirstOnPage, pageInSection)); remainingHeight -= (effectiveMarginTop + block.heightPt + block.marginBottomPt); prevMarginBottomPt = block.marginBottomPt; // Add new footnotes to current page @@ -1883,7 +1943,10 @@ export class PaginationEngine { currentFootnoteIds.push(...newFootnoteIds); currentFootnoteHeight += additionalFootnoteHeight; } - } else if (block.heightPt + block.marginTopPt <= effectiveContentHeight) { + } else if ( + block.heightPt + this.effectiveBlockMarginTop(block, 0, true, pageInSection + 1) <= + effectiveContentHeight + ) { // Block doesn't fit with current allocation - try expanding footnote area const blockSpaceWithoutFootnotes = effectiveMarginTop + block.heightPt; @@ -1894,7 +1957,7 @@ export class PaginationEngine { if (newFootnoteIds.length > 0 && blockSpaceWithoutFootnotes <= effectiveRemainingHeight) { // Block itself fits, but footnotes don't - expand footnote area - currentContent.push(block.element.cloneNode(true) as HTMLElement); + currentContent.push(this.cloneBlockForPage(block, isFirstOnPage, pageInSection)); remainingHeight -= (effectiveMarginTop + block.heightPt + block.marginBottomPt); prevMarginBottomPt = block.marginBottomPt; @@ -1962,7 +2025,7 @@ export class PaginationEngine { if (blockSpaceWithoutFootnotes <= bodySpaceAfterExpansion - bodyContentUsed) { // Block fits after expanding footnote area. - currentContent.push(block.element.cloneNode(true) as HTMLElement); + currentContent.push(this.cloneBlockForPage(block, isFirstOnPage, pageInSection)); // `remainingHeight` tracks BODY consumption only — every other branch maintains it // that way, and the footnote reserve is applied separately via `effectiveRemainingHeight` // at the top of each iteration. Assigning `bodySpaceAfterExpansion - …` here folded the @@ -1979,8 +2042,10 @@ export class PaginationEngine { const newPageFootnoteHeight = allBlockFootnoteIds.length > 0 ? this.measureFootnotesHeight(allBlockFootnoteIds, dims.contentWidth, currentContinuation) : (currentContinuation ? this.measureContinuationHeight(currentContinuation, dims.contentWidth) : 0); - const newPageSpace = block.marginTopPt + block.heightPt + block.marginBottomPt; - currentContent.push(block.element.cloneNode(true) as HTMLElement); + const newPageMarginTop = this.effectiveBlockMarginTop( + block, 0, true, pageInSection); + const newPageSpace = newPageMarginTop + block.heightPt + block.marginBottomPt; + currentContent.push(this.cloneBlockForPage(block, true, pageInSection)); remainingHeight = effectiveContentHeight - newPageSpace; prevMarginBottomPt = block.marginBottomPt; // Merge, never replace: finishPage() may have just seeded this page with notes deferred @@ -1996,9 +2061,10 @@ export class PaginationEngine { const newPageFootnoteHeight = allBlockFootnoteIds.length > 0 ? this.measureFootnotesHeight(allBlockFootnoteIds, dims.contentWidth, currentContinuation) : (currentContinuation ? this.measureContinuationHeight(currentContinuation, dims.contentWidth) : 0); - // Include full top margin - const newPageSpace = block.marginTopPt + block.heightPt + block.marginBottomPt; - currentContent.push(block.element.cloneNode(true) as HTMLElement); + const newPageMarginTop = this.effectiveBlockMarginTop( + block, 0, true, pageInSection); + const newPageSpace = newPageMarginTop + block.heightPt + block.marginBottomPt; + currentContent.push(this.cloneBlockForPage(block, true, pageInSection)); remainingHeight = effectiveContentHeight - newPageSpace; prevMarginBottomPt = block.marginBottomPt; // Merge, never replace: finishPage() may have just seeded this page with notes deferred @@ -2025,7 +2091,7 @@ export class PaginationEngine { if (currentContent.length > 0) { finishPage(); } - currentContent.push(block.element.cloneNode(true) as HTMLElement); + currentContent.push(this.cloneBlockForPage(block, true, pageInSection)); // Merge, never replace: finishPage() may have just seeded this page with notes deferred // from the previous one, and overwriting here dropped them from the document. currentFootnoteIds = [...currentFootnoteIds, ...allBlockFootnoteIds]; diff --git a/npm/tests/docx-page-top-spacing-fixture.ts b/npm/tests/docx-page-top-spacing-fixture.ts new file mode 100644 index 00000000..c9d176aa --- /dev/null +++ b/npm/tests/docx-page-top-spacing-fixture.ts @@ -0,0 +1,105 @@ +import { storedZip, xml, R_NS, W_NS } from './docx-zip.js'; + +/** + * Generated pagination probe for Word's paragraph space-before rule. + * + * The first section leaves less than one spaced paragraph on page 1, so NATURAL TOP is moved by + * ordinary pagination. + * PAGE-BREAK-BEFORE TOP starts page 3 by paragraph formatting. The second section then checks + * the important exception: Word retains space-before on the first page of a new section. + */ + +export const PAGE_TOP_SPACE_BEFORE_TWIPS = 360; // 18 pt +export const PAGE_TOP_LINE_TWIPS = 400; // 20 pt, exact +export const PAGE_TOP_MARGIN_TWIPS = 720; // 36 pt +export const PAGE_TOP_HEIGHT_TWIPS = 5760; // 4 in +export const PAGE_TOP_WIDTH_TWIPS = 5760; + +export const PAGE_TOP_LABELS = { + sectionStart: 'SECTION START', + samePage: 'SAME-PAGE CONTROL', + natural: 'NATURAL TOP', + pageBreakBefore: 'PAGE-BREAK-BEFORE TOP', + nextSection: 'NEXT-SECTION START', +} as const; + +const paragraph = ( + text: string, + options: { before?: number; pageBreakBefore?: boolean } = {}, +): string => { + const before = options.before ?? 0; + const pageBreakBefore = options.pageBreakBefore ? '' : ''; + return `${pageBreakBefore}` + + `${text}`; +}; + +const sectionProperties = (type = ''): string => + `${type ? `` : ''}` + + `` + + ``; + +export function generatePageTopSpacingDocx(): Uint8Array { + // Leave 20 pt of the 216 pt body: NATURAL TOP needs 18+20 pt and must move as a unit. + const fillers = Array.from({ length: 6 }, (_, index) => + paragraph(`FILLER ${index + 1}`)).join(''); + + const documentXml = ` + + ${paragraph(PAGE_TOP_LABELS.sectionStart, { before: PAGE_TOP_SPACE_BEFORE_TWIPS })} + ${paragraph(PAGE_TOP_LABELS.samePage, { before: PAGE_TOP_SPACE_BEFORE_TWIPS })} + ${fillers} + ${paragraph(PAGE_TOP_LABELS.natural, { before: PAGE_TOP_SPACE_BEFORE_TWIPS })} + ${paragraph('PAGE 2 CONTROL', { before: PAGE_TOP_SPACE_BEFORE_TWIPS })} + ${paragraph(PAGE_TOP_LABELS.pageBreakBefore, { + before: PAGE_TOP_SPACE_BEFORE_TWIPS, + pageBreakBefore: true, + })} + + ${sectionProperties('nextPage')} + + ${paragraph(PAGE_TOP_LABELS.nextSection, { before: PAGE_TOP_SPACE_BEFORE_TWIPS })} + ${sectionProperties()} +`; + + return storedZip([ + { + name: '[Content_Types].xml', + data: xml(` + + + + + +`), + }, + { + name: '_rels/.rels', + data: xml(` + + +`), + }, + { + name: 'word/_rels/document.xml.rels', + data: xml(` + + +`), + }, + { + name: 'word/styles.xml', + data: xml(` + + + + + + +`), + }, + { name: 'word/document.xml', data: xml(documentXml) }, + ]); +} diff --git a/npm/tests/pagination-page-top-spacing.spec.ts b/npm/tests/pagination-page-top-spacing.spec.ts new file mode 100644 index 00000000..8d5d73b8 --- /dev/null +++ b/npm/tests/pagination-page-top-spacing.spec.ts @@ -0,0 +1,184 @@ +import { expect, test, type Page } from '@playwright/test'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { + PAGE_TOP_LABELS, + PAGE_TOP_SPACE_BEFORE_TWIPS, + generatePageTopSpacingDocx, +} from './docx-page-top-spacing-fixture.js'; + +/** + * Issue #428 — Word suppresses paragraph space-before at the top of later pages in a section. + * + * The suppression belongs to page placement, not OOXML conversion: the authored margin remains + * available while blocks are measured, then the clone placed first on a later page drops it. + * Natural overflow and `w:pageBreakBefore` therefore share one rule. A document/section start is + * deliberately different and retains the authored spacing, as does an ordinary same-page gap. + */ + +const PT_TO_PX = 4 / 3; +const SPACE_BEFORE_PX = PAGE_TOP_SPACE_BEFORE_TWIPS / 20 * PT_TO_PX; +const TOLERANCE_PX = 0.75; +const __dirname = dirname(fileURLToPath(import.meta.url)); + +interface ParagraphGeometry { + label: string; + page: number; + section: number; + topFromBody: number; + gapFromPrevious: number | null; + marginTop: number; +} + +async function renderFixture(page: Page): Promise { + await page.goto('/test-harness.html'); + await page.waitForFunction(() => (window as any).DocxodusReady === true, undefined, + { timeout: 30_000 }); + await page.addScriptTag({ url: '/pagination.bundle.js' }); + + return page.evaluate(async ({ bytes, labels }) => { + const html = (window as any).Docxodus.DocumentConverter.ConvertDocxToHtmlComplete( + new Uint8Array(bytes), 'Document', 'docx-', true, '', -1, 'comment-', + 1, 1, 'page-', false, 0, 'annot-', true, true, false, false, false, + ) as string; + if (html.startsWith('{')) throw new Error(`conversion failed: ${html.slice(0, 300)}`); + + document.body.innerHTML = '
'; + (window as any).DocxodusPagination.paginateHtml( + html, + document.getElementById('fixture'), + { scale: 1, showPageNumbers: false, pageGap: 0, fragmentParagraphs: false }, + ); + await document.fonts.ready; + await new Promise(resolve => requestAnimationFrame(() => + requestAnimationFrame(() => resolve()))); + + const wanted = new Set(Object.values(labels)); + const result: ParagraphGeometry[] = []; + const pages = Array.from(document.querySelectorAll('#fixture .page-box')); + for (const [pageIndex, pageBox] of pages.entries()) { + const body = pageBox.querySelector('.page-content')!; + const paragraphs = Array.from(body.querySelectorAll(':scope > p')); + for (const [index, paragraph] of paragraphs.entries()) { + const label = (paragraph.textContent || '').trim(); + if (!wanted.has(label)) continue; + const rect = paragraph.getBoundingClientRect(); + const bodyRect = body.getBoundingClientRect(); + const previous = index > 0 ? paragraphs[index - 1].getBoundingClientRect() : null; + result.push({ + label, + page: pageIndex + 1, + section: Number(pageBox.dataset.sectionIndex), + topFromBody: rect.top - bodyRect.top, + gapFromPrevious: previous ? rect.top - previous.bottom : null, + marginTop: parseFloat(getComputedStyle(paragraph).marginTop), + }); + } + } + return result; + }, { + bytes: Array.from(generatePageTopSpacingDocx()), + labels: PAGE_TOP_LABELS, + }); +} + +test.describe('paragraph space-before at page boundaries', () => { + let paragraphs: ParagraphGeometry[]; + + test.beforeAll(async ({ browser }) => { + const page = await browser.newPage(); + try { + paragraphs = await renderFixture(page); + } finally { + await page.close(); + } + }); + + const find = (label: string) => paragraphs.find(paragraph => paragraph.label === label)!; + + test('suppresses space-before after natural pagination', () => { + const paragraph = find(PAGE_TOP_LABELS.natural); + expect(paragraph.page).toBe(2); + expect(paragraph.topFromBody).toBeCloseTo(0, 0); + expect(paragraph.marginTop).toBeCloseTo(0, 0); + }); + + test('suppresses the same spacing after w:pageBreakBefore', () => { + const paragraph = find(PAGE_TOP_LABELS.pageBreakBefore); + expect(paragraph.page).toBe(3); + expect(paragraph.topFromBody).toBeCloseTo(0, 0); + expect(paragraph.marginTop).toBeCloseTo(0, 0); + }); + + test('retains space-before at a document or section start', () => { + const documentStart = find(PAGE_TOP_LABELS.sectionStart); + const sectionStart = find(PAGE_TOP_LABELS.nextSection); + + expect(documentStart.page).toBe(1); + expect(sectionStart.page).toBe(4); + expect(sectionStart.section).toBe(1); + expect(documentStart.topFromBody).toBeCloseTo(SPACE_BEFORE_PX, 0); + expect(sectionStart.topFromBody).toBeCloseTo(SPACE_BEFORE_PX, 0); + expect(documentStart.marginTop).toBeCloseTo(SPACE_BEFORE_PX, 0); + expect(sectionStart.marginTop).toBeCloseTo(SPACE_BEFORE_PX, 0); + }); + + test('retains ordinary inter-paragraph spacing', () => { + const paragraph = find(PAGE_TOP_LABELS.samePage); + expect(paragraph.page).toBe(1); + expect(paragraph.gapFromPrevious).not.toBeNull(); + expect(Math.abs(paragraph.gapFromPrevious! - SPACE_BEFORE_PX)).toBeLessThanOrEqual( + TOLERANCE_PX, + ); + expect(paragraph.marginTop).toBeCloseTo(SPACE_BEFORE_PX, 0); + }); +}); + +async function paginateMarkupAfterHardBreak(page: Page, tag: 'h1' | 'div') { + await page.setContent(` + +
+
+

PAGE 1

+
+ <${tag} id="target" style="height:20pt;margin:18pt 0 0">TARGET +
+
+
`); + 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', }, },