diff --git a/packages/layout-engine/layout-engine/src/index.test.ts b/packages/layout-engine/layout-engine/src/index.test.ts index 7406bbf4fb..1ed6788a9d 100644 --- a/packages/layout-engine/layout-engine/src/index.test.ts +++ b/packages/layout-engine/layout-engine/src/index.test.ts @@ -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', () => { diff --git a/packages/layout-engine/layout-engine/src/index.ts b/packages/layout-engine/layout-engine/src/index.ts index 1dec3d599a..82559b1e24 100644 --- a/packages/layout-engine/layout-engine/src/index.ts +++ b/packages/layout-engine/layout-engine/src/index.ts @@ -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 === ''); +}; + +/** + * 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'); }; @@ -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(); diff --git a/packages/super-editor/src/editors/v1/core/layout-adapter/converters/paragraph.page-break-emission.test.ts b/packages/super-editor/src/editors/v1/core/layout-adapter/converters/paragraph.page-break-emission.test.ts new file mode 100644 index 0000000000..9eeca4154a --- /dev/null +++ b/packages/super-editor/src/editors/v1/core/layout-adapter/converters/paragraph.page-break-emission.test.ts @@ -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 }; + +const shapeOf = (blocks: ReturnType['blocks']): EmittedShape[] => + blocks.map((block) => { + const b = block as { kind: string; attrs?: Record; 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'] }, + ]); + }); +});