Skip to content
Closed
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
208 changes: 208 additions & 0 deletions packages/layout-engine/layout-engine/src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2431,6 +2431,214 @@ describe('layoutDocument', () => {
expect(pageContainsBlock(layout.pages[2], 'p2')).toBe(true);
expect(layout.pages[1].fragments).toHaveLength(0);
});

// SD-3366: Word collapses a style/direct pageBreakBefore when the paragraph
// directly follows an explicit page break. Shapes A-E below are Word-verified
// (see the SD-3366 fixture matrix): A and E suppress; B, C and D fire.
describe('pageBreakBefore after an explicit page break (SD-3366)', () => {
const explicitBreak = (id: string): PageBreakBlock =>
({ kind: 'pageBreak', id, attrs: { lineBreakType: 'page' } }) as PageBreakBlock;
const styleBreak = (id: string): PageBreakBlock =>
({ kind: 'pageBreak', id, attrs: { source: 'pageBreakBefore' } }) as PageBreakBlock;
const emptyParagraph = (id: string): FlowBlock => ({
kind: 'paragraph',
id,
runs: [{ text: '', fontFamily: 'Arial', fontSize: 16 }],
});
const textParagraph = (id: string): FlowBlock => ({
kind: 'paragraph',
id,
runs: [{ text: 'content', fontFamily: 'Arial', fontSize: 16 }],
});

it('suppresses pageBreakBefore directly after an explicit page break (shape E)', () => {
const blocks: FlowBlock[] = [
textParagraph('p1'),
explicitBreak('pb-explicit'),
styleBreak('pb-style'),
textParagraph('p2'),
];
const measures: Measure[] = [
makeMeasure([40]),
{ kind: 'pageBreak' },
{ kind: 'pageBreak' },
makeMeasure([40]),
];

const layout = layoutDocument(blocks, measures, pageBreakBoundaryOptions);

expect(layout.pages).toHaveLength(2);
expect(pageContainsBlock(layout.pages[1], 'p2')).toBe(true);
});

it('suppresses pageBreakBefore after an explicit break and its empty remnant paragraph (shape A)', () => {
const blocks: FlowBlock[] = [
textParagraph('p1'),
explicitBreak('pb-explicit'),
emptyParagraph('remnant'),
styleBreak('pb-style'),
textParagraph('p2'),
];
const measures: Measure[] = [
makeMeasure([40]),
{ kind: 'pageBreak' },
makeMeasure([20]),
{ kind: 'pageBreak' },
makeMeasure([40]),
];

const layout = layoutDocument(blocks, measures, pageBreakBoundaryOptions);

expect(layout.pages).toHaveLength(2);
expect(pageContainsBlock(layout.pages[1], 'remnant')).toBe(true);
expect(pageContainsBlock(layout.pages[1], 'p2')).toBe(true);
});

it('still fires pageBreakBefore after a plain empty paragraph with no explicit break (shape B)', () => {
const blocks: FlowBlock[] = [
textParagraph('p1'),
emptyParagraph('plain-empty'),
styleBreak('pb-style'),
textParagraph('p2'),
];
const measures: Measure[] = [makeMeasure([40]), makeMeasure([20]), { kind: 'pageBreak' }, makeMeasure([40])];

const layout = layoutDocument(blocks, measures, pageBreakBoundaryOptions);

expect(layout.pages).toHaveLength(2);
expect(pageContainsBlock(layout.pages[0], 'p1')).toBe(true);
expect(pageContainsBlock(layout.pages[0], 'plain-empty')).toBe(true);
expect(pageContainsBlock(layout.pages[1], 'p2')).toBe(true);
});

it('still fires pageBreakBefore when text follows the explicit break on the fresh page (shape C)', () => {
const blocks: FlowBlock[] = [
textParagraph('p1'),
explicitBreak('pb-explicit'),
textParagraph('after-break'),
styleBreak('pb-style'),
textParagraph('p2'),
];
const measures: Measure[] = [
makeMeasure([40]),
{ kind: 'pageBreak' },
makeMeasure([40]),
{ kind: 'pageBreak' },
makeMeasure([40]),
];

const layout = layoutDocument(blocks, measures, pageBreakBoundaryOptions);

expect(layout.pages).toHaveLength(3);
expect(pageContainsBlock(layout.pages[1], 'after-break')).toBe(true);
expect(pageContainsBlock(layout.pages[2], 'p2')).toBe(true);
});

it('still fires pageBreakBefore when an extra empty paragraph follows the break remnant (shape D)', () => {
const blocks: FlowBlock[] = [
textParagraph('p1'),
explicitBreak('pb-explicit'),
emptyParagraph('remnant'),
emptyParagraph('extra-empty'),
styleBreak('pb-style'),
textParagraph('p2'),
];
const measures: Measure[] = [
makeMeasure([40]),
{ kind: 'pageBreak' },
makeMeasure([20]),
makeMeasure([20]),
{ kind: 'pageBreak' },
makeMeasure([40]),
];

const layout = layoutDocument(blocks, measures, pageBreakBoundaryOptions);

expect(layout.pages).toHaveLength(3);
expect(pageContainsBlock(layout.pages[1], 'remnant')).toBe(true);
expect(pageContainsBlock(layout.pages[1], 'extra-empty')).toBe(true);
expect(pageContainsBlock(layout.pages[2], 'p2')).toBe(true);
});

it('still fires pageBreakBefore when the remnant paragraph carries a list marker (numberingProperties)', () => {
// An empty list item renders a visible marker ("1.", "•") painted from
// paragraph attrs, not runs — that is page content, so the break re-arms.
const blocks: FlowBlock[] = [
textParagraph('p1'),
explicitBreak('pb-explicit'),
{
kind: 'paragraph',
id: 'list-remnant',
runs: [{ text: '', fontFamily: 'Arial', fontSize: 16 }],
attrs: { numberingProperties: { numId: 1, ilvl: 0 } },
},
styleBreak('pb-style'),
textParagraph('p2'),
];
const measures: Measure[] = [
makeMeasure([40]),
{ kind: 'pageBreak' },
makeMeasure([20]),
{ kind: 'pageBreak' },
makeMeasure([40]),
];

const layout = layoutDocument(blocks, measures, pageBreakBoundaryOptions);

expect(layout.pages).toHaveLength(3);
expect(pageContainsBlock(layout.pages[1], 'list-remnant')).toBe(true);
expect(pageContainsBlock(layout.pages[2], 'p2')).toBe(true);
});

it('still fires pageBreakBefore when the remnant paragraph carries a wordLayout marker', () => {
const blocks: FlowBlock[] = [
textParagraph('p1'),
explicitBreak('pb-explicit'),
{
kind: 'paragraph',
id: 'marker-remnant',
runs: [{ text: '', fontFamily: 'Arial', fontSize: 16 }],
attrs: { wordLayout: { marker: { markerText: '1.' } } },
} as FlowBlock,
styleBreak('pb-style'),
textParagraph('p2'),
];
const measures: Measure[] = [
makeMeasure([40]),
{ kind: 'pageBreak' },
makeMeasure([20]),
{ kind: 'pageBreak' },
makeMeasure([40]),
];

const layout = layoutDocument(blocks, measures, pageBreakBoundaryOptions);

expect(layout.pages).toHaveLength(3);
expect(pageContainsBlock(layout.pages[1], 'marker-remnant')).toBe(true);
expect(pageContainsBlock(layout.pages[2], 'p2')).toBe(true);
});

it('does not suppress an explicit page break that follows another explicit page break', () => {
// Two manual breaks in a row intentionally produce a blank page.
const blocks: FlowBlock[] = [
textParagraph('p1'),
explicitBreak('pb-1'),
explicitBreak('pb-2'),
textParagraph('p2'),
];
const measures: Measure[] = [
makeMeasure([40]),
{ kind: 'pageBreak' },
{ kind: 'pageBreak' },
makeMeasure([40]),
];

const layout = layoutDocument(blocks, measures, pageBreakBoundaryOptions);

expect(layout.pages).toHaveLength(3);
expect(pageContainsBlock(layout.pages[2], 'p2')).toBe(true);
});
});
});

describe('Phase 4: Column Breaks', () => {
Expand Down
46 changes: 45 additions & 1 deletion packages/layout-engine/layout-engine/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -692,6 +692,47 @@ const shouldSkipRedundantPageBreakBefore = (block: PageBreakBlock, state: PageSt
return isAtTopOfFreshPage;
};

/** An explicit page break (manual `w:br w:type="page"`), as opposed to a style/direct pageBreakBefore. */
const isExplicitPageBreakBlock = (block: FlowBlock | undefined): boolean => {
return block?.kind === 'pageBreak' && (block as PageBreakBlock).attrs?.source !== 'pageBreakBefore';
};

/**
* A paragraph that renders no content: every run is a text run with empty
* text, and the paragraph paints no list marker. List markers ("1.", "•")
* come from paragraph attrs (`numberingProperties` / `wordLayout.marker`),
* not runs, so an empty-text list item is still visible page content.
*/
const isEmptyParagraphBlock = (block: FlowBlock | undefined): boolean => {
if (block?.kind !== 'paragraph') return false;
const paragraph = block as ParagraphBlock;
if (paragraph.attrs?.numberingProperties || paragraph.attrs?.wordLayout?.marker) return false;
const runs = paragraph.runs ?? [];
return runs.every((run) => (run.kind === undefined || run.kind === 'text') && run.text === '');
Comment thread
tupizz marked this conversation as resolved.
};

/**
* Word collapses a style/direct pageBreakBefore when the paragraph directly
* follows an explicit page break. The break paragraph's own empty remnant
* (its paragraph mark, emitted as an empty paragraph block right after the
* break) does not re-arm the break — but any other content does: one extra
* empty paragraph, or text after the break in the same paragraph, and Word
* renders the second page break again. Verified against Word renders of the
* SD-3366 fixture matrix (shapes A-E).
*
* The directly-adjacent case (break at the end of a paragraph with content,
* which emits no remnant) is already covered by the fresh-page geometric
* guard above; this structural check covers the remnant case, where the
* remnant fragment makes the fresh page non-empty.
*/
const isPageBreakBeforeSatisfiedByExplicitBreak = (blocks: readonly FlowBlock[], index: number): boolean => {
const block = blocks[index];
if (block?.kind !== 'pageBreak' || (block as PageBreakBlock).attrs?.source !== 'pageBreakBefore') {
return false;
}
return isEmptyParagraphBlock(blocks[index - 1]) && isExplicitPageBreakBlock(blocks[index - 2]);
};

const hasOnlySectionBreakBlocks = (blocks: readonly FlowBlock[]): boolean => {
return blocks.length > 0 && blocks.every((block) => block.kind === 'sectionBreak');
};
Expand Down Expand Up @@ -2785,7 +2826,10 @@ export function layoutDocument(blocks: FlowBlock[], measures: Measure[], options
throw new Error(`layoutDocument: expected pageBreak measure for block ${block.id}`);
}
const currentState = states[states.length - 1];
if (shouldSkipRedundantPageBreakBefore(block as PageBreakBlock, currentState)) {
if (
shouldSkipRedundantPageBreakBefore(block as PageBreakBlock, currentState) ||
isPageBreakBeforeSatisfiedByExplicitBreak(blocks, index)
) {
continue;
}
paginator.startNewPage();
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
/**
* Pins the FlowBlock emission contract around explicit page breaks (SD-3366).
*
* The layout engine suppresses a style/direct pageBreakBefore when it directly
* follows an explicit page break, identified structurally as the window
* [explicit pageBreak, empty remnant paragraph, pageBreak source=pageBreakBefore]
* (see isPageBreakBeforeSatisfiedByExplicitBreak in @superdoc/layout-engine).
* That check relies on the emission shapes asserted here. If one of these
* tests fails after an adapter change, the layout-engine window check must be
* revisited together with it.
*/
import { describe, it, expect } from 'vitest';
import { toFlowBlocks as baseToFlowBlocks } from '../index.js';
import type { PMNode, AdapterOptions } from '../index.js';

const DEFAULT_CONVERTER_CONTEXT = {
docx: {},
translatedLinkedStyles: { docDefaults: {}, latentStyles: {}, styles: {} },
translatedNumbering: { abstracts: {}, definitions: {} },
};

const toFlowBlocks = (content: object[], options: AdapterOptions = {}) =>
baseToFlowBlocks({ type: 'doc', content } as unknown as PMNode, {
converterContext: DEFAULT_CONVERTER_CONTEXT,
...options,
});

const textPara = (text: string) => ({
type: 'paragraph',
attrs: { paragraphProperties: {} },
content: [{ type: 'run', attrs: {}, content: [{ type: 'text', text }] }],
});

/** Paragraph whose only content is an explicit page break (`w:br w:type="page"`). */
const breakPara = () => ({
type: 'paragraph',
attrs: { paragraphProperties: {} },
content: [{ type: 'run', attrs: {}, content: [{ type: 'hardBreak', attrs: { lineBreakType: 'page' } }] }],
});

const pbbPara = (text: string) => ({
type: 'paragraph',
attrs: { paragraphProperties: { pageBreakBefore: true } },
content: [{ type: 'run', attrs: {}, content: [{ type: 'text', text }] }],
});

type EmittedShape = { kind: string; source?: unknown; texts?: Array<string | undefined> };

const shapeOf = (blocks: ReturnType<typeof toFlowBlocks>['blocks']): EmittedShape[] =>
blocks.map((block) => {
const b = block as { kind: string; attrs?: Record<string, unknown>; runs?: Array<{ text?: string }> };
const shape: EmittedShape = { kind: b.kind };
if (b.attrs?.source !== undefined) shape.source = b.attrs.source;
if (b.runs) shape.texts = b.runs.map((r) => r.text);
return shape;
});

describe('explicit page break emission shapes (SD-3366)', () => {
it('a break-only paragraph emits the pageBreak followed by its empty remnant paragraph', () => {
const { blocks } = toFlowBlocks([textPara('Prior'), breakPara(), pbbPara('Styled')]);

expect(shapeOf(blocks)).toEqual([
{ kind: 'paragraph', texts: ['Prior'] },
{ kind: 'pageBreak' },
{ kind: 'paragraph', texts: [''] },
{ kind: 'pageBreak', source: 'pageBreakBefore' },
{ kind: 'paragraph', texts: ['Styled'] },
]);
});

it('a break at the end of a text paragraph emits no remnant paragraph', () => {
const { blocks } = toFlowBlocks([
{
type: 'paragraph',
attrs: { paragraphProperties: {} },
content: [
{
type: 'run',
attrs: {},
content: [
{ type: 'text', text: 'Prior' },
{ type: 'hardBreak', attrs: { lineBreakType: 'page' } },
],
},
],
},
pbbPara('Styled'),
]);

expect(shapeOf(blocks)).toEqual([
{ kind: 'paragraph', texts: ['Prior'] },
{ kind: 'pageBreak' },
{ kind: 'pageBreak', source: 'pageBreakBefore' },
{ kind: 'paragraph', texts: ['Styled'] },
]);
});

it('text after the break in the same paragraph emits a non-empty trailing paragraph', () => {
const { blocks } = toFlowBlocks([
{
type: 'paragraph',
attrs: { paragraphProperties: {} },
content: [
{
type: 'run',
attrs: {},
content: [
{ type: 'hardBreak', attrs: { lineBreakType: 'page' } },
{ type: 'text', text: 'After' },
],
},
],
},
pbbPara('Styled'),
]);

expect(shapeOf(blocks)).toEqual([
{ kind: 'pageBreak' },
{ kind: 'paragraph', texts: ['After'] },
{ kind: 'pageBreak', source: 'pageBreakBefore' },
{ kind: 'paragraph', texts: ['Styled'] },
]);
});
});
Loading