diff --git a/packages/layout-engine/layout-engine/src/index.test.ts b/packages/layout-engine/layout-engine/src/index.test.ts index f6e3f4fe97..8e3c1c178c 100644 --- a/packages/layout-engine/layout-engine/src/index.test.ts +++ b/packages/layout-engine/layout-engine/src/index.test.ts @@ -472,6 +472,37 @@ describe('layoutDocument', () => { expect(layout.pages.length).toBe(1); }); + it('suppresses spacingBefore for a paragraph after an explicit page break', () => { + const introBlock: FlowBlock = { + kind: 'paragraph', + id: 'intro-on-page-one', + runs: [], + }; + const pageBreak: FlowBlock = { + kind: 'pageBreak', + id: 'manual-page-break', + } as PageBreakBlock; + const headingBlock: FlowBlock = { + kind: 'paragraph', + id: 'heading-at-page-start', + runs: [], + attrs: { + spacing: { before: 24, after: 8 }, + }, + }; + + const layout = layoutDocument( + [introBlock, pageBreak, headingBlock], + [makeMeasure([20]), { kind: 'pageBreak' }, makeMeasure([20])], + DEFAULT_OPTIONS, + ); + + expect(layout.pages).toHaveLength(2); + expect(layout.pages[0].fragments).toHaveLength(1); + expect(layout.pages[1].fragments).toHaveLength(1); + expect(layout.pages[1].fragments[0].y).toBe(DEFAULT_OPTIONS.margins!.top); + }); + it('handles spacingBefore equal to content area height (boundary condition)', () => { // Edge case: spacingBefore exactly equals the content area height. // This triggers the infinite loop guard after advancing to a new page. @@ -2258,7 +2289,7 @@ describe('layoutDocument', () => { { kind: 'paragraph', id: 'p1', runs: [] }, { kind: 'sectionBreak', id: 'sb-next', type: 'nextPage', margins: {} } as SectionBreakBlock, { kind: 'pageBreak', id: 'pb-before-exhibit', attrs: { source: 'pageBreakBefore' } } as PageBreakBlock, - { kind: 'paragraph', id: 'p2', runs: [] }, + { kind: 'paragraph', id: 'p2', runs: [], attrs: { spacing: { before: 24 } } }, ]; const measures: Measure[] = [ @@ -2273,6 +2304,135 @@ describe('layoutDocument', () => { expect(layout.pages).toHaveLength(2); expect(pageContainsBlock(layout.pages[1], 'p2')).toBe(true); expect(layout.pages[1].fragments).toHaveLength(1); + expect(layout.pages[1].fragments[0].y).toBe(pageBreakBoundaryOptions.margins!.top + 24); + }); + + it('preserves spacingBefore after pageBreakBefore', () => { + const blocks: FlowBlock[] = [ + { kind: 'paragraph', id: 'p1', runs: [] }, + { kind: 'pageBreak', id: 'pb-before-exhibit', attrs: { source: 'pageBreakBefore' } } as PageBreakBlock, + { kind: 'paragraph', id: 'p2', runs: [], attrs: { spacing: { before: 24 } } }, + ]; + + const measures: Measure[] = [makeMeasure([40]), { kind: 'pageBreak' }, makeMeasure([40])]; + + const layout = layoutDocument(blocks, measures, pageBreakBoundaryOptions); + + expect(layout.pages).toHaveLength(2); + expect(pageContainsBlock(layout.pages[1], 'p2')).toBe(true); + expect(layout.pages[1].fragments[0].y).toBe(pageBreakBoundaryOptions.margins!.top + 24); + }); + + it('suppresses spacingBefore after a manual page break when a section break precedes the paragraph', () => { + const blocks: FlowBlock[] = [ + { kind: 'paragraph', id: 'p1', runs: [] }, + { kind: 'pageBreak', id: 'pb-manual', attrs: { lineBreakType: 'page' } } as PageBreakBlock, + { kind: 'paragraph', id: 'p-sectpr-marker', runs: [], attrs: { sectPrMarker: true } }, + { kind: 'sectionBreak', id: 'sb-continuous', type: 'continuous', margins: {} } as SectionBreakBlock, + { kind: 'paragraph', id: 'p2', runs: [], attrs: { spacing: { before: 24 } } }, + ]; + + const measures: Measure[] = [ + makeMeasure([40]), + { kind: 'pageBreak' }, + makeMeasure([]), + { kind: 'sectionBreak' }, + makeMeasure([40]), + ]; + + const layout = layoutDocument(blocks, measures, pageBreakBoundaryOptions); + const para = layout.pages[1].fragments.find((fragment) => fragment.blockId === 'p2') as ParaFragment; + + expect(para).toBeTruthy(); + expect(para.y).toBe(pageBreakBoundaryOptions.margins!.top); + }); + + it('suppresses spacingBefore after a manual page break when an anchored image precedes the paragraph', () => { + const anchoredImage: ImageBlock = { + kind: 'image', + id: 'anchored-image', + src: 'data:image/png;base64,xxx', + anchor: { + isAnchored: true, + hRelativeFrom: 'column', + vRelativeFrom: 'paragraph', + offsetH: 0, + offsetV: 0, + }, + wrap: { + type: 'Square', + wrapText: 'right', + }, + attrs: { + anchorParagraphId: 'p2', + }, + }; + const imageMeasure: ImageMeasure = { + kind: 'image', + width: 40, + height: 40, + }; + const blocks: FlowBlock[] = [ + { kind: 'paragraph', id: 'p1', runs: [] }, + { kind: 'pageBreak', id: 'pb-manual', attrs: { lineBreakType: 'page' } } as PageBreakBlock, + anchoredImage, + { kind: 'paragraph', id: 'p2', runs: [], attrs: { spacing: { before: 24 } } }, + ]; + + const measures: Measure[] = [makeMeasure([40]), { kind: 'pageBreak' }, imageMeasure, makeMeasure([40])]; + + const layout = layoutDocument(blocks, measures, pageBreakBoundaryOptions); + const para = layout.pages[1].fragments.find((fragment) => fragment.blockId === 'p2') as ParaFragment; + + expect(para).toBeTruthy(); + expect(para.y).toBe(pageBreakBoundaryOptions.margins!.top); + }); + + it('suppresses spacingBefore after a manual page break when an anchored drawing precedes the paragraph', () => { + const anchoredDrawing: FlowBlock = { + kind: 'drawing', + id: 'anchored-drawing', + drawingKind: 'vectorShape', + geometry: { width: 40, height: 40, rotation: 0 }, + anchor: { + isAnchored: true, + hRelativeFrom: 'column', + vRelativeFrom: 'paragraph', + offsetH: 0, + offsetV: 0, + }, + wrap: { + type: 'Square', + wrapText: 'right', + }, + attrs: { + anchorParagraphId: 'p2', + }, + }; + const drawingMeasure: DrawingMeasure = { + kind: 'drawing', + drawingKind: 'vectorShape', + width: 40, + height: 40, + scale: 1, + naturalWidth: 40, + naturalHeight: 40, + geometry: { width: 40, height: 40, rotation: 0, flipH: false, flipV: false }, + }; + const blocks: FlowBlock[] = [ + { kind: 'paragraph', id: 'p1', runs: [] }, + { kind: 'pageBreak', id: 'pb-manual', attrs: { lineBreakType: 'page' } } as PageBreakBlock, + anchoredDrawing, + { kind: 'paragraph', id: 'p2', runs: [], attrs: { spacing: { before: 24 } } }, + ]; + + const measures: Measure[] = [makeMeasure([40]), { kind: 'pageBreak' }, drawingMeasure, makeMeasure([40])]; + + const layout = layoutDocument(blocks, measures, pageBreakBoundaryOptions); + const para = layout.pages[1].fragments.find((fragment) => fragment.blockId === 'p2') as ParaFragment; + + expect(para).toBeTruthy(); + expect(para.y).toBe(pageBreakBoundaryOptions.margins!.top); }); it('still honors manual page breaks after a fresh page boundary', () => { diff --git a/packages/layout-engine/layout-engine/src/index.ts b/packages/layout-engine/layout-engine/src/index.ts index c6d5e91903..4345557462 100644 --- a/packages/layout-engine/layout-engine/src/index.ts +++ b/packages/layout-engine/layout-engine/src/index.ts @@ -634,6 +634,70 @@ const shouldSkipRedundantPageBreakBefore = (block: PageBreakBlock, state: PageSt return isAtTopOfFreshPage; }; +const isManualPageBreak = (block: FlowBlock | undefined): block is PageBreakBlock => { + return block?.kind === 'pageBreak' && block.attrs?.source !== 'pageBreakBefore'; +}; + +const sectionBreakForcesPageBoundary = (sectionBreak: SectionBreakBlock | null | undefined): boolean => { + const breakType = sectionBreak?.type ?? (sectionBreak?.attrs?.source === 'sectPr' ? 'nextPage' : undefined); + return ( + sectionBreak != null && + (breakType === 'nextPage' || + breakType === 'evenPage' || + breakType === 'oddPage' || + sectionBreak.attrs?.requirePageBoundary === true) + ); +}; + +const isEmptyParagraphBlock = (block: FlowBlock): block is ParagraphBlock => { + if (block.kind !== 'paragraph') return false; + + const runs = block.runs ?? []; + return ( + runs.length === 0 || + (runs.length === 1 && + (!runs[0].kind || runs[0].kind === 'text') && + (!(runs[0] as { text?: string }).text || (runs[0] as { text?: string }).text === '')) + ); +}; + +const shouldSkipParagraphInManualBreakScan = (blocks: FlowBlock[], index: number): boolean => { + const block = blocks[index]; + if (!isEmptyParagraphBlock(block)) return false; + + const isSectPrMarker = block.attrs?.sectPrMarker === true; + const prevBlock = index > 0 ? blocks[index - 1] : undefined; + const nextBlock = index < blocks.length - 1 ? blocks[index + 1] : undefined; + const nextSectionBreak = nextBlock?.kind === 'sectionBreak' ? (nextBlock as SectionBreakBlock) : undefined; + + return ( + (isSectPrMarker && sectionBreakForcesPageBoundary(nextSectionBreak)) || + (prevBlock?.kind === 'pageBreak' && nextSectionBreak != null) + ); +}; + +const hasManualPageBreakBeforeParagraph = ( + blocks: FlowBlock[], + paragraphIndex: number, + anchorsForPara?: { block: ImageBlock | DrawingBlock }[], +): boolean => { + if (isManualPageBreak(blocks[paragraphIndex - 1])) return true; + + const anchorIds = new Set(anchorsForPara?.map((entry) => entry.block.id) ?? []); + for (let index = paragraphIndex - 1; index >= 0; index -= 1) { + const block = blocks[index]; + if ((block.kind === 'image' || block.kind === 'drawing') && anchorIds.has(block.id)) { + continue; + } + if (block.kind === 'sectionBreak' || shouldSkipParagraphInManualBreakScan(blocks, index)) { + continue; + } + return isManualPageBreak(block); + } + + return false; +}; + const hasOnlySectionBreakBlocks = (blocks: readonly FlowBlock[]): boolean => { return blocks.length > 0 && blocks.every((block) => block.kind === 'sectionBreak'); }; @@ -2161,12 +2225,7 @@ export function layoutDocument(blocks: FlowBlock[], measures: Measure[], options // Skip empty paragraphs that appear between a pageBreak and a sectionBreak // (Word sectPr marker paragraphs should not create visible content) const paraBlock = block as ParagraphBlock; - const isEmpty = - !paraBlock.runs || - paraBlock.runs.length === 0 || - (paraBlock.runs.length === 1 && - (!paraBlock.runs[0].kind || paraBlock.runs[0].kind === 'text') && - (!(paraBlock.runs[0] as { text?: string }).text || (paraBlock.runs[0] as { text?: string }).text === '')); + const isEmpty = isEmptyParagraphBlock(paraBlock); if (isEmpty) { const isSectPrMarker = paraBlock.attrs?.sectPrMarker === true; @@ -2175,14 +2234,7 @@ export function layoutDocument(blocks: FlowBlock[], measures: Measure[], options const nextBlock = index < blocks.length - 1 ? blocks[index + 1] : null; const nextSectionBreak = nextBlock?.kind === 'sectionBreak' ? (nextBlock as SectionBreakBlock) : null; - const nextBreakType = - nextSectionBreak?.type ?? (nextSectionBreak?.attrs?.source === 'sectPr' ? 'nextPage' : undefined); - const nextBreakForcesPage = - nextSectionBreak && - (nextBreakType === 'nextPage' || - nextBreakType === 'evenPage' || - nextBreakType === 'oddPage' || - nextSectionBreak.attrs?.requirePageBoundary === true); + const nextBreakForcesPage = sectionBreakForcesPageBoundary(nextSectionBreak); if (isSectPrMarker && nextBreakForcesPage) { continue; @@ -2365,6 +2417,7 @@ export function layoutDocument(blocks: FlowBlock[], measures: Measure[], options floatManager, remeasureParagraph: options.remeasureParagraph, overrideSpacingAfter, + suppressSpacingBeforeAtPageTop: hasManualPageBreakBeforeParagraph(blocks, index, anchorsForPara), }, anchorsForPara ? { diff --git a/packages/layout-engine/layout-engine/src/layout-paragraph.test.ts b/packages/layout-engine/layout-engine/src/layout-paragraph.test.ts index 39220fe3cb..e91c1ffd54 100644 --- a/packages/layout-engine/layout-engine/src/layout-paragraph.test.ts +++ b/packages/layout-engine/layout-engine/src/layout-paragraph.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, mock } from 'bun:test'; -import type { ParagraphBlock, ParagraphMeasure, Line } from '@superdoc/contracts'; +import type { ImageBlock, ImageMeasure, ParagraphBlock, ParagraphMeasure, Line } from '@superdoc/contracts'; import { layoutParagraphBlock, type ParagraphLayoutContext } from './layout-paragraph.js'; import type { PageState } from './paginator.js'; import type { FloatingObjectManager } from './floating-objects.js'; @@ -550,6 +550,133 @@ describe('layoutParagraphBlock - remeasurement with list markers', () => { expect(remeasureParagraph).toHaveBeenCalledWith(block, 120, 24); }); + + it('suppresses manual page-break top spacing before float remeasurement', () => { + const pageState = makePageState(); + const ensurePage = mock(() => pageState); + const remeasureParagraph = mock((block, maxWidth, firstLineIndent) => { + if (maxWidth === 120) { + expect(firstLineIndent).toBe(0); + } + return makeMeasure([{ width: 100, lineHeight: 20, maxWidth }]); + }); + + const scannedLineYs: number[] = []; + const floatManager = makeFloatManager(); + floatManager.computeAvailableWidth = mock((lineY, lineHeight, columnWidth) => { + scannedLineYs.push(lineY); + if (lineY === pageState.topMargin) { + return { width: 120, offsetX: 10 }; + } + return { width: columnWidth, offsetX: 0 }; + }); + + const block: ParagraphBlock = { + kind: 'paragraph', + id: 'test-block', + runs: [{ text: 'Test', fontFamily: 'Arial', fontSize: 12 }], + attrs: { + spacing: { + before: 30, + }, + }, + }; + + const measure = makeMeasure([{ width: 100, lineHeight: 20, maxWidth: 150 }]); + + const ctx: ParagraphLayoutContext = { + block, + measure, + columnWidth: 150, + ensurePage, + advanceColumn: mock((state) => state), + columnX: mock(() => 50), + floatManager, + remeasureParagraph, + suppressSpacingBeforeAtPageTop: true, + }; + + layoutParagraphBlock(ctx); + + expect(scannedLineYs[0]).toBe(pageState.topMargin); + expect(remeasureParagraph).toHaveBeenCalledWith(block, 120, 0); + }); + + it('keeps manual page-break top spacing suppressed when placing an anchored image first', () => { + const pageState = makePageState(); + const ensurePage = mock(() => pageState); + const remeasureParagraph = mock((block, maxWidth, firstLineIndent) => { + if (maxWidth === 120) { + expect(firstLineIndent).toBe(0); + } + return makeMeasure([{ width: 100, lineHeight: 20, maxWidth }]); + }); + + const scannedLineYs: number[] = []; + const floatManager = makeFloatManager(); + floatManager.computeAvailableWidth = mock((lineY, lineHeight, columnWidth) => { + scannedLineYs.push(lineY); + if (lineY === pageState.topMargin) { + return { width: 120, offsetX: 10 }; + } + return { width: columnWidth, offsetX: 0 }; + }); + + const block: ParagraphBlock = { + kind: 'paragraph', + id: 'test-block', + runs: [{ text: 'Test', fontFamily: 'Arial', fontSize: 12 }], + attrs: { + spacing: { + before: 30, + }, + }, + }; + const anchoredImage: ImageBlock = { + kind: 'image', + id: 'anchored-image', + src: 'data:image/png;base64,xxx', + anchor: { + isAnchored: true, + hRelativeFrom: 'column', + vRelativeFrom: 'paragraph', + offsetH: 0, + offsetV: 0, + }, + wrap: { + type: 'Square', + wrapText: 'right', + }, + }; + const imageMeasure: ImageMeasure = { + kind: 'image', + width: 40, + height: 40, + }; + + const ctx: ParagraphLayoutContext = { + block, + measure: makeMeasure([{ width: 100, lineHeight: 20, maxWidth: 150 }]), + columnWidth: 150, + ensurePage, + advanceColumn: mock((state) => state), + columnX: mock(() => 50), + floatManager, + remeasureParagraph, + suppressSpacingBeforeAtPageTop: true, + }; + + layoutParagraphBlock(ctx, { + anchoredDrawings: [{ block: anchoredImage, measure: imageMeasure }], + pageWidth: 300, + pageMargins: { top: 50, right: 50, bottom: 50, left: 50 }, + columns: { width: 150, gap: 0, count: 1 }, + placedAnchoredIds: new Set(), + }); + + expect(scannedLineYs[0]).toBe(pageState.topMargin); + expect(remeasureParagraph).toHaveBeenCalledWith(block, 120, 0); + }); }); }); diff --git a/packages/layout-engine/layout-engine/src/layout-paragraph.ts b/packages/layout-engine/layout-engine/src/layout-paragraph.ts index ca29187c9c..7c69c62f50 100644 --- a/packages/layout-engine/layout-engine/src/layout-paragraph.ts +++ b/packages/layout-engine/layout-engine/src/layout-paragraph.ts @@ -9,6 +9,7 @@ import type { ImageMeasure, ImageFragment, ImageFragmentMetadata, + Fragment, DrawingBlock, DrawingMeasure, DrawingFragment, @@ -29,6 +30,7 @@ import { getFragmentZIndex } from '@superdoc/contracts'; const PX_PER_PT = 96 / 72; const spacingDebugEnabled = false; +const PAGE_START_EPSILON = 0.0001; /** * Type definition for Word layout attributes attached to paragraph blocks. * This is a subset of the WordParagraphLayoutOutput from @superdoc/word-layout. @@ -101,6 +103,18 @@ const spacingDebugLog = (..._args: unknown[]): void => { if (!spacingDebugEnabled) return; }; +const hasFlowFragments = (fragments: Fragment[]): boolean => { + return fragments.some((fragment) => (fragment as { isAnchored?: boolean }).isAnchored !== true); +}; + +const isAtTopOfFreshPage = (state: PageState): boolean => { + return ( + !hasFlowFragments(state.page.fragments) && + state.columnIndex === 0 && + Math.abs(state.cursorY - state.topMargin) <= PAGE_START_EPSILON + ); +}; + /** * Type guard to safely access paragraph block attributes. * Validates that the attrs property exists and returns it with proper typing. @@ -293,6 +307,8 @@ export type ParagraphLayoutContext = { * When undefined, uses the value from block.attrs.spacing.after. */ overrideSpacingAfter?: number; + /** Suppress spacing-before when this paragraph starts a fresh page after an explicit page break. */ + suppressSpacingBeforeAtPageTop?: boolean; }; export type AnchoredDrawingEntry = { @@ -314,6 +330,8 @@ export function layoutParagraphBlock(ctx: ParagraphLayoutContext, anchors?: Para const blockAttrs = getParagraphAttrs(block); const frame = blockAttrs?.frame; + const suppressSpacingBeforeAtPageTop = + ctx.suppressSpacingBeforeAtPageTop === true && isAtTopOfFreshPage(ensurePage()); if (anchors?.anchoredDrawings?.length) { for (const entry of anchors.anchoredDrawings) { @@ -513,6 +531,9 @@ export function layoutParagraphBlock(ctx: ParagraphLayoutContext, anchors?: Para if (!spacingExplicit.before) spacingBefore = 0; if (!spacingExplicit.after) spacingAfter = 0; } + if (suppressSpacingBeforeAtPageTop) { + spacingBefore = 0; + } /** Original spacing before value, preserved for blank page calculations where no trailing collapse occurs. */ const baseSpacingBefore = spacingBefore; let appliedSpacingBefore = spacingBefore === 0; @@ -687,7 +708,6 @@ export function layoutParagraphBlock(ctx: ParagraphLayoutContext, anchors?: Para state.trailingSpacing = 0; } } - /** * Keep Lines Together (OOXML w:keepLines) * diff --git a/packages/layout-engine/pm-adapter/src/converters/paragraph.test.ts b/packages/layout-engine/pm-adapter/src/converters/paragraph.test.ts index 78d0862571..9efa410daf 100644 --- a/packages/layout-engine/pm-adapter/src/converters/paragraph.test.ts +++ b/packages/layout-engine/pm-adapter/src/converters/paragraph.test.ts @@ -2721,7 +2721,8 @@ describe('paragraph converters', () => { const blocks = paragraphToFlowBlocks(para, nextBlockId, positions, 'Arial', 16); - expect(blocks.some((b) => b.kind === 'columnBreak')).toBe(true); + expect(blocks).toHaveLength(1); + expect(blocks[0]).toMatchObject({ kind: 'columnBreak' }); }); it('should ignore lineBreak without column break type', () => { @@ -3151,9 +3152,7 @@ describe('paragraph converters', () => { }); describe('Edge cases', () => { - it('should create empty paragraph when all content is block nodes', () => { - // hardBreak without pageBreakType defaults to line break (inline) - // so we use pageBreakType: 'page' to make it a block node + it('does not create an empty paragraph when content is only a page break', () => { const para: PMNode = { type: 'paragraph', content: [{ type: 'hardBreak', attrs: { pageBreakType: 'page' } }], @@ -3161,10 +3160,8 @@ describe('paragraph converters', () => { const blocks = paragraphToFlowBlocks(para, nextBlockId, positions, 'Arial', 16); - expect(blocks.some((b) => b.kind === 'paragraph')).toBe(true); - const paraBlock = blocks.find((b) => b.kind === 'paragraph') as ParagraphBlock; - expect(paraBlock.runs).toHaveLength(1); - expect(paraBlock.runs[0].text).toBe(''); + expect(blocks).toHaveLength(1); + expect(blocks[0]).toMatchObject({ kind: 'pageBreak' }); }); it('should handle mixed inline and block content', () => { diff --git a/packages/layout-engine/pm-adapter/src/converters/paragraph.ts b/packages/layout-engine/pm-adapter/src/converters/paragraph.ts index a3f5d964e8..558c9f1f31 100644 --- a/packages/layout-engine/pm-adapter/src/converters/paragraph.ts +++ b/packages/layout-engine/pm-adapter/src/converters/paragraph.ts @@ -870,7 +870,9 @@ export function paragraphToFlowBlocks({ flushParagraph(); const hasParagraphBlock = blocks.some((block) => block.kind === 'paragraph'); - if (!hasParagraphBlock && !suppressedByVanish && !paragraphProps.runProperties?.vanish) { + const hasOnlyBreakBlocks = + blocks.length > 0 && blocks.every((block) => block.kind === 'pageBreak' || block.kind === 'columnBreak'); + if (!hasParagraphBlock && !hasOnlyBreakBlocks && !suppressedByVanish && !paragraphProps.runProperties?.vanish) { blocks.push({ kind: 'paragraph', id: baseBlockId, diff --git a/packages/layout-engine/pm-adapter/src/index.test.ts b/packages/layout-engine/pm-adapter/src/index.test.ts index af949c4f0c..4628d970b6 100644 --- a/packages/layout-engine/pm-adapter/src/index.test.ts +++ b/packages/layout-engine/pm-adapter/src/index.test.ts @@ -3404,6 +3404,39 @@ describe('toFlowBlocks', () => { const pageBreakBlocks = blocks.filter((b) => b.kind === 'pageBreak'); expect(pageBreakBlocks).toHaveLength(1); + expect(blocks.filter((b) => b.kind === 'paragraph' && b.runs.length === 1 && b.runs[0].text === '')).toHaveLength( + 1, + ); + expect(blocks.findIndex((b) => b.kind === 'pageBreak')).toBeLessThan( + blocks.findIndex((b) => b.kind === 'paragraph' && b.runs[0].text === 'Page 2'), + ); + }); + + it('does not synthesize an empty paragraph for a page-break-only paragraph', () => { + const pmDoc = { + type: 'doc', + content: [ + { + type: 'paragraph', + content: [{ type: 'text', text: 'Page 1' }], + }, + { + type: 'paragraph', + content: [{ type: 'hardBreak', attrs: { pageBreakType: 'page' } }], + }, + { + type: 'paragraph', + content: [{ type: 'text', text: 'Page 2' }], + }, + ], + }; + + const { blocks } = toFlowBlocks(pmDoc); + + expect(blocks.filter((b) => b.kind === 'pageBreak')).toHaveLength(1); + expect(blocks.filter((b) => b.kind === 'paragraph')).toHaveLength(2); + expect(blocks.findIndex((b) => b.kind === 'pageBreak')).toBe(1); + expect((blocks[2] as ParagraphBlock).runs[0].text).toBe('Page 2'); }); it('handles hardBreak with marks and formatting', () => {