diff --git a/packages/layout-engine/contracts/src/resolved-layout.ts b/packages/layout-engine/contracts/src/resolved-layout.ts index bd1a2eac2b..c7eb7550d9 100644 --- a/packages/layout-engine/contracts/src/resolved-layout.ts +++ b/packages/layout-engine/contracts/src/resolved-layout.ts @@ -5,8 +5,12 @@ import type { ImageBlock, ImageFragmentMetadata, Line, + ListBlock, + ListMeasure, PageMargins, + ParagraphBlock, ParagraphBorders, + ParagraphMeasure, SectionVerticalAlign, TableBlock, TableMeasure, @@ -127,6 +131,10 @@ export type ResolvedFragmentItem = { paragraphBorders?: ParagraphBorders; /** Pre-computed change-detection signature (blockVersion + fragment-specific data). */ version?: string; + /** Pre-extracted block for paragraph (ParagraphBlock) or list-item (ListBlock) fragments. */ + block?: ParagraphBlock | ListBlock; + /** Pre-extracted measure for paragraph (ParagraphMeasure) or list-item (ListMeasure) fragments. */ + measure?: ParagraphMeasure | ListMeasure; }; /** Resolved paragraph content for non-table paragraph/list-item fragments. */ diff --git a/packages/layout-engine/layout-resolved/src/resolveLayout.test.ts b/packages/layout-engine/layout-resolved/src/resolveLayout.test.ts index c32b8ee219..c640ed69f5 100644 --- a/packages/layout-engine/layout-resolved/src/resolveLayout.test.ts +++ b/packages/layout-engine/layout-resolved/src/resolveLayout.test.ts @@ -638,6 +638,163 @@ describe('resolveLayout', () => { }); }); + describe('paragraph/list-item block and measure lifting', () => { + it('lifts block and measure from a paragraph fragment', () => { + const paraFragment: ParaFragment = { + kind: 'para', + blockId: 'p1', + fromLine: 0, + toLine: 1, + x: 72, + y: 100, + width: 468, + }; + const layout: Layout = { + pageSize: { w: 612, h: 792 }, + pages: [{ number: 1, fragments: [paraFragment] }], + }; + const paragraphBlock: FlowBlock = { kind: 'paragraph', id: 'p1', runs: [] }; + const paragraphMeasure: Measure = { + kind: 'paragraph', + lines: [{ fromRun: 0, fromChar: 0, toRun: 0, toChar: 10, width: 400, ascent: 12, descent: 4, lineHeight: 20 }], + totalHeight: 20, + }; + + const result = resolveLayout({ + layout, + flowMode: 'paginated', + blocks: [paragraphBlock], + measures: [paragraphMeasure], + }); + const item = result.pages[0].items[0] as import('@superdoc/contracts').ResolvedFragmentItem; + expect(item.block).toBe(paragraphBlock); + expect(item.measure).toBe(paragraphMeasure); + }); + + it('lifts block and measure from a list-item fragment', () => { + const listItemFragment: ListItemFragment = { + kind: 'list-item', + blockId: 'list1', + itemId: 'item-a', + fromLine: 0, + toLine: 1, + x: 108, + y: 200, + width: 432, + markerWidth: 36, + }; + const layout: Layout = { + pageSize: { w: 612, h: 792 }, + pages: [{ number: 1, fragments: [listItemFragment] }], + }; + const listBlock: FlowBlock = { + kind: 'list', + id: 'list1', + listType: 'bullet', + items: [ + { + id: 'item-a', + marker: { text: '•', style: {} }, + paragraph: { kind: 'paragraph', id: 'item-a-p', runs: [] }, + }, + ], + }; + const listMeasure: Measure = { + kind: 'list', + items: [ + { + itemId: 'item-a', + markerWidth: 36, + markerTextWidth: 10, + indentLeft: 36, + paragraph: { + kind: 'paragraph', + lines: [ + { fromRun: 0, fromChar: 0, toRun: 0, toChar: 10, width: 400, ascent: 12, descent: 4, lineHeight: 24 }, + ], + totalHeight: 24, + }, + }, + ], + totalHeight: 24, + }; + + const result = resolveLayout({ + layout, + flowMode: 'paginated', + blocks: [listBlock], + measures: [listMeasure], + }); + const item = result.pages[0].items[0] as import('@superdoc/contracts').ResolvedFragmentItem; + expect(item.block).toBe(listBlock); + expect(item.measure).toBe(listMeasure); + }); + + it('leaves block and measure undefined when the block entry is missing', () => { + const paraFragment: ParaFragment = { + kind: 'para', + blockId: 'missing', + fromLine: 0, + toLine: 1, + x: 72, + y: 100, + width: 468, + }; + const layout: Layout = { + pageSize: { w: 612, h: 792 }, + pages: [{ number: 1, fragments: [paraFragment] }], + }; + + const result = resolveLayout({ layout, flowMode: 'paginated', blocks: [], measures: [] }); + const item = result.pages[0].items[0] as import('@superdoc/contracts').ResolvedFragmentItem; + expect(item.block).toBeUndefined(); + expect(item.measure).toBeUndefined(); + }); + + it('does not set ResolvedFragmentItem.block on table fragments (they use ResolvedTableItem.block)', () => { + const tableFragment: TableFragment = { + kind: 'table', + blockId: 't1', + fromRow: 0, + toRow: 1, + x: 10, + y: 20, + width: 400, + height: 80, + columnWidths: [200, 200], + }; + const layout: Layout = { + pageSize: { w: 612, h: 792 }, + pages: [{ number: 1, fragments: [tableFragment] }], + }; + const tableBlock = { + kind: 'table' as const, + id: 't1', + rows: [], + columnWidths: [200, 200], + }; + const tableMeasure = { + kind: 'table' as const, + columnWidths: [200, 200], + rows: [], + totalHeight: 80, + }; + + const result = resolveLayout({ + layout, + flowMode: 'paginated', + blocks: [tableBlock as any], + measures: [tableMeasure as any], + }); + // Table items carry block/measure as ResolvedTableItem typed fields. + // They should NOT use the optional ResolvedFragmentItem.block path (no fall-through to the default branch). + const item = result.pages[0].items[0] as import('@superdoc/contracts').ResolvedTableItem; + expect(item.fragmentKind).toBe('table'); + expect(item.block).toBe(tableBlock); + expect(item.measure).toBe(tableMeasure); + }); + }); + describe('fragment metadata lifting', () => { it('lifts pmStart and pmEnd from a paragraph fragment', () => { const paraFragment: ParaFragment = { diff --git a/packages/layout-engine/layout-resolved/src/resolveLayout.ts b/packages/layout-engine/layout-resolved/src/resolveLayout.ts index 85f0e83519..ea376cd56c 100644 --- a/packages/layout-engine/layout-resolved/src/resolveLayout.ts +++ b/packages/layout-engine/layout-resolved/src/resolveLayout.ts @@ -239,6 +239,19 @@ function resolveFragmentItem( }; if (sdtContainerKey != null) item.sdtContainerKey = sdtContainerKey; + // Pre-extract block/measure for para and list-item fragments so the painter + // can prefer resolved data over a blockLookup read. + const entry = blockMap.get(fragment.blockId); + if (entry) { + if (fragment.kind === 'para' && entry.block.kind === 'paragraph' && entry.measure.kind === 'paragraph') { + item.block = entry.block as ParagraphBlock; + item.measure = entry.measure as ParagraphMeasure; + } else if (fragment.kind === 'list-item' && entry.block.kind === 'list' && entry.measure.kind === 'list') { + item.block = entry.block as ListBlock; + item.measure = entry.measure as ListMeasure; + } + } + // Pre-compute paragraph border data for between-border grouping const borders = resolveFragmentParagraphBorders(fragment, blockMap); if (borders) { diff --git a/packages/layout-engine/painters/dom/src/renderer.ts b/packages/layout-engine/painters/dom/src/renderer.ts index 6407493f8c..c68f2e4a0c 100644 --- a/packages/layout-engine/painters/dom/src/renderer.ts +++ b/packages/layout-engine/painters/dom/src/renderer.ts @@ -2936,17 +2936,26 @@ export class DomPainter { resolvedItem?: ResolvedFragmentItem, ): HTMLElement { try { - const lookup = this.blockLookup.get(fragment.blockId); - if (!lookup || lookup.block.kind !== 'paragraph' || lookup.measure.kind !== 'paragraph') { - throw new Error(`DomPainter: missing block/measure for fragment ${fragment.blockId}`); - } - if (!this.doc) { throw new Error('DomPainter: document is not available'); } - const block = lookup.block as ParagraphBlock; - const measure = lookup.measure as ParagraphMeasure; + // Prefer pre-extracted block/measure from the resolved item; fall back to blockLookup. + let block: ParagraphBlock; + let measure: ParagraphMeasure; + const resolvedBlock = resolvedItem?.block; + const resolvedMeasure = resolvedItem?.measure; + if (resolvedBlock?.kind === 'paragraph' && resolvedMeasure?.kind === 'paragraph') { + block = resolvedBlock as ParagraphBlock; + measure = resolvedMeasure as ParagraphMeasure; + } else { + const lookup = this.blockLookup.get(fragment.blockId); + if (!lookup || lookup.block.kind !== 'paragraph' || lookup.measure.kind !== 'paragraph') { + throw new Error(`DomPainter: missing block/measure for fragment ${fragment.blockId}`); + } + block = lookup.block as ParagraphBlock; + measure = lookup.measure as ParagraphMeasure; + } const wordLayout = isMinimalWordLayout(block.attrs?.wordLayout) ? block.attrs.wordLayout : undefined; const content = resolvedItem?.content; @@ -3478,17 +3487,26 @@ export class DomPainter { resolvedItem?: ResolvedFragmentItem, ): HTMLElement { try { - const lookup = this.blockLookup.get(fragment.blockId); - if (!lookup || lookup.block.kind !== 'list' || lookup.measure.kind !== 'list') { - throw new Error(`DomPainter: missing list data for fragment ${fragment.blockId}`); - } - if (!this.doc) { throw new Error('DomPainter: document is not available'); } - const block = lookup.block as ListBlock; - const measure = lookup.measure as ListMeasure; + // Prefer pre-extracted block/measure from the resolved item; fall back to blockLookup. + let block: ListBlock; + let measure: ListMeasure; + const resolvedBlock = resolvedItem?.block; + const resolvedMeasure = resolvedItem?.measure; + if (resolvedBlock?.kind === 'list' && resolvedMeasure?.kind === 'list') { + block = resolvedBlock as ListBlock; + measure = resolvedMeasure as ListMeasure; + } else { + const lookup = this.blockLookup.get(fragment.blockId); + if (!lookup || lookup.block.kind !== 'list' || lookup.measure.kind !== 'list') { + throw new Error(`DomPainter: missing list data for fragment ${fragment.blockId}`); + } + block = lookup.block as ListBlock; + measure = lookup.measure as ListMeasure; + } const item = block.items.find((entry) => entry.id === fragment.itemId); const itemMeasure = measure.items.find((entry) => entry.itemId === fragment.itemId); if (!item || !itemMeasure) {