Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
108 changes: 87 additions & 21 deletions npm/src/pagination.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

/**
Expand Down Expand Up @@ -436,6 +438,7 @@ export class PaginationEngine {
keepLines: child.dataset.keepLines === "true",
pageBreakBefore: child.dataset.pageBreakBefore === "true",
isPageBreak,
isWordParagraph: this.isWordParagraphElement(child),
});
}

Expand Down Expand Up @@ -509,6 +512,7 @@ export class PaginationEngine {
keepLines: false,
pageBreakBefore: false,
isPageBreak: false,
isWordParagraph: false,
});
start = end;
}
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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++) {
Expand All @@ -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.
Expand Down Expand Up @@ -1777,7 +1834,8 @@ export class PaginationEngine {
this.measureKeepWithNextChainBodyHeight(
keepChain,
prevMarginBottomPt,
currentContent.length === 0
currentContent.length === 0,
pageInSection
) +
additionalChainFootnoteHeight;
const currentAvailableHeight = remainingHeight - currentFootnoteHeight;
Expand All @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -1875,15 +1935,18 @@ 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
if (newFootnoteIds.length > 0) {
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;

Expand All @@ -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;

Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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];
Expand Down
105 changes: 105 additions & 0 deletions npm/tests/docx-page-top-spacing-fixture.ts
Original file line number Diff line number Diff line change
@@ -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 ? '<w:pageBreakBefore/>' : '';
return `<w:p><w:pPr><w:spacing w:before="${before}" w:after="0" ` +
`w:line="${PAGE_TOP_LINE_TWIPS}" w:lineRule="exact"/>${pageBreakBefore}</w:pPr>` +
`<w:r><w:t>${text}</w:t></w:r></w:p>`;
};

const sectionProperties = (type = ''): string =>
`${type ? `<w:type w:val="${type}"/>` : ''}` +
`<w:pgSz w:w="${PAGE_TOP_WIDTH_TWIPS}" w:h="${PAGE_TOP_HEIGHT_TWIPS}"/>` +
`<w:pgMar w:top="${PAGE_TOP_MARGIN_TWIPS}" w:right="${PAGE_TOP_MARGIN_TWIPS}" ` +
`w:bottom="${PAGE_TOP_MARGIN_TWIPS}" w:left="${PAGE_TOP_MARGIN_TWIPS}" ` +
`w:header="360" w:footer="360" w:gutter="0"/><w:cols w:space="720"/>`;

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 = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<w:document xmlns:w="${W_NS}" xmlns:r="${R_NS}"><w:body>
${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,
})}
<w:p><w:pPr><w:spacing w:before="0" w:after="0" w:line="${PAGE_TOP_LINE_TWIPS}" w:lineRule="exact"/>
<w:sectPr>${sectionProperties('nextPage')}</w:sectPr>
</w:pPr></w:p>
${paragraph(PAGE_TOP_LABELS.nextSection, { before: PAGE_TOP_SPACE_BEFORE_TWIPS })}
<w:sectPr>${sectionProperties()}</w:sectPr>
</w:body></w:document>`;

return storedZip([
{
name: '[Content_Types].xml',
data: xml(`<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">
<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>
<Default Extension="xml" ContentType="application/xml"/>
<Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/>
<Override PartName="/word/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.styles+xml"/>
</Types>`),
},
{
name: '_rels/.rels',
data: xml(`<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
<Relationship Id="rId1" Type="${R_NS}/officeDocument" Target="word/document.xml"/>
</Relationships>`),
},
{
name: 'word/_rels/document.xml.rels',
data: xml(`<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
<Relationship Id="rId1" Type="${R_NS}/styles" Target="styles.xml"/>
</Relationships>`),
},
{
name: 'word/styles.xml',
data: xml(`<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<w:styles xmlns:w="${W_NS}">
<w:docDefaults><w:rPrDefault><w:rPr>
<w:rFonts w:ascii="Liberation Serif" w:hAnsi="Liberation Serif"/>
<w:sz w:val="20"/><w:szCs w:val="20"/>
</w:rPr></w:rPrDefault></w:docDefaults>
<w:style w:type="paragraph" w:default="1" w:styleId="Normal"><w:name w:val="Normal"/></w:style>
</w:styles>`),
},
{ name: 'word/document.xml', data: xml(documentXml) },
]);
}
Loading
Loading