From 55044fe203d0752315a884a0a2e1e81fd74f44ad Mon Sep 17 00:00:00 2001 From: Tadeu Tupinamba Date: Tue, 14 Apr 2026 15:51:42 -0300 Subject: [PATCH] refactor(layout): pre-compute SDT container keys in resolved layout --- .../contracts/src/resolved-layout.ts | 8 + .../layout-resolved/src/resolveLayout.test.ts | 346 ++++++++++++++++++ .../layout-resolved/src/resolveLayout.ts | 49 ++- .../layout-resolved/src/sdtContainerKey.ts | 40 ++ .../painters/dom/src/renderer.ts | 34 +- 5 files changed, 466 insertions(+), 11 deletions(-) create mode 100644 packages/layout-engine/layout-resolved/src/sdtContainerKey.ts diff --git a/packages/layout-engine/contracts/src/resolved-layout.ts b/packages/layout-engine/contracts/src/resolved-layout.ts index 7d28407900..f94348e077 100644 --- a/packages/layout-engine/contracts/src/resolved-layout.ts +++ b/packages/layout-engine/contracts/src/resolved-layout.ts @@ -118,6 +118,8 @@ export type ResolvedFragmentItem = { markerWidth?: number; /** Pre-resolved paragraph content for non-table paragraph fragments. */ content?: ResolvedParagraphContent; + /** Pre-computed SDT container key for boundary grouping (`structuredContent:` or `documentSection:`). */ + sdtContainerKey?: string | null; }; /** Resolved paragraph content for non-table paragraph/list-item fragments. */ @@ -232,6 +234,8 @@ export type ResolvedTableItem = { cellSpacingPx: number; /** Pre-computed effective column widths: fragment.columnWidths ?? measure.columnWidths. */ effectiveColumnWidths: number[]; + /** Pre-computed SDT container key for boundary grouping (`structuredContent:` or `documentSection:`). */ + sdtContainerKey?: string | null; }; /** @@ -268,6 +272,8 @@ export type ResolvedImageItem = { block: ImageBlock; /** Image metadata for interactive resizing (original dimensions, aspect ratio). */ metadata?: ImageFragmentMetadata; + /** Pre-computed SDT container key for boundary grouping (typically null for images). */ + sdtContainerKey?: string | null; }; /** @@ -302,6 +308,8 @@ export type ResolvedDrawingItem = { pmEnd?: number; /** Pre-extracted DrawingBlock (replaces blockLookup.get()). */ block: DrawingBlock; + /** Pre-computed SDT container key for boundary grouping (typically null for drawings). */ + sdtContainerKey?: string | null; }; /** Type guard: checks whether a resolved paint item is a ResolvedTableItem. */ diff --git a/packages/layout-engine/layout-resolved/src/resolveLayout.test.ts b/packages/layout-engine/layout-resolved/src/resolveLayout.test.ts index 2e935e82a3..d954134b8d 100644 --- a/packages/layout-engine/layout-resolved/src/resolveLayout.test.ts +++ b/packages/layout-engine/layout-resolved/src/resolveLayout.test.ts @@ -2032,4 +2032,350 @@ describe('resolveLayout', () => { expect(result.layoutEpoch).toBeUndefined(); }); }); + + describe('sdtContainerKey resolution', () => { + it('sets sdtContainerKey for a paragraph with block structuredContent sdt', () => { + const layout: Layout = { + pageSize: { w: 612, h: 792 }, + pages: [ + { + number: 1, + fragments: [{ kind: 'para', blockId: 'p1', fromLine: 0, toLine: 1, x: 72, y: 100, width: 468 }], + }, + ], + }; + const blocks: FlowBlock[] = [ + { + kind: 'paragraph', + id: 'p1', + runs: [], + attrs: { sdt: { type: 'structuredContent', scope: 'block', id: 'sdt-1' } }, + }, + ]; + const measures: Measure[] = [{ kind: 'paragraph', lines: [], totalHeight: 0 }]; + + const result = resolveLayout({ layout, flowMode: 'paginated', blocks, measures }); + const item = result.pages[0].items[0] as import('@superdoc/contracts').ResolvedFragmentItem; + expect(item.sdtContainerKey).toBe('structuredContent:sdt-1'); + }); + + it('sets sdtContainerKey for a paragraph with documentSection sdt', () => { + const layout: Layout = { + pageSize: { w: 612, h: 792 }, + pages: [ + { + number: 1, + fragments: [{ kind: 'para', blockId: 'p1', fromLine: 0, toLine: 1, x: 72, y: 100, width: 468 }], + }, + ], + }; + const blocks: FlowBlock[] = [ + { + kind: 'paragraph', + id: 'p1', + runs: [], + attrs: { sdt: { type: 'documentSection', id: 'sec-1' } }, + }, + ]; + const measures: Measure[] = [{ kind: 'paragraph', lines: [], totalHeight: 0 }]; + + const result = resolveLayout({ layout, flowMode: 'paginated', blocks, measures }); + const item = result.pages[0].items[0] as import('@superdoc/contracts').ResolvedFragmentItem; + expect(item.sdtContainerKey).toBe('documentSection:sec-1'); + }); + + it('uses sdBlockId for documentSection when id is absent', () => { + const layout: Layout = { + pageSize: { w: 612, h: 792 }, + pages: [ + { + number: 1, + fragments: [{ kind: 'para', blockId: 'p1', fromLine: 0, toLine: 1, x: 72, y: 100, width: 468 }], + }, + ], + }; + const blocks: FlowBlock[] = [ + { + kind: 'paragraph', + id: 'p1', + runs: [], + attrs: { sdt: { type: 'documentSection', sdBlockId: 'blk-99' } }, + }, + ]; + const measures: Measure[] = [{ kind: 'paragraph', lines: [], totalHeight: 0 }]; + + const result = resolveLayout({ layout, flowMode: 'paginated', blocks, measures }); + const item = result.pages[0].items[0] as import('@superdoc/contracts').ResolvedFragmentItem; + expect(item.sdtContainerKey).toBe('documentSection:blk-99'); + }); + + it('falls back to containerSdt when primary sdt has no container config', () => { + const layout: Layout = { + pageSize: { w: 612, h: 792 }, + pages: [ + { + number: 1, + fragments: [{ kind: 'para', blockId: 'p1', fromLine: 0, toLine: 1, x: 72, y: 100, width: 468 }], + }, + ], + }; + const blocks: FlowBlock[] = [ + { + kind: 'paragraph', + id: 'p1', + runs: [], + attrs: { + sdt: { type: 'structuredContent', scope: 'inline', id: 'inline-1' }, + containerSdt: { type: 'documentSection', id: 'sec-2' }, + }, + }, + ]; + const measures: Measure[] = [{ kind: 'paragraph', lines: [], totalHeight: 0 }]; + + const result = resolveLayout({ layout, flowMode: 'paginated', blocks, measures }); + const item = result.pages[0].items[0] as import('@superdoc/contracts').ResolvedFragmentItem; + expect(item.sdtContainerKey).toBe('documentSection:sec-2'); + }); + + it('returns null (omits sdtContainerKey) for inline structuredContent scope', () => { + const layout: Layout = { + pageSize: { w: 612, h: 792 }, + pages: [ + { + number: 1, + fragments: [{ kind: 'para', blockId: 'p1', fromLine: 0, toLine: 1, x: 72, y: 100, width: 468 }], + }, + ], + }; + const blocks: FlowBlock[] = [ + { + kind: 'paragraph', + id: 'p1', + runs: [], + attrs: { sdt: { type: 'structuredContent', scope: 'inline', id: 'inline-1' } }, + }, + ]; + const measures: Measure[] = [{ kind: 'paragraph', lines: [], totalHeight: 0 }]; + + const result = resolveLayout({ layout, flowMode: 'paginated', blocks, measures }); + const item = result.pages[0].items[0] as import('@superdoc/contracts').ResolvedFragmentItem; + expect(item.sdtContainerKey).toBeUndefined(); + }); + + it('omits sdtContainerKey when paragraph has no sdt', () => { + const layout: Layout = { + pageSize: { w: 612, h: 792 }, + pages: [ + { + number: 1, + fragments: [{ kind: 'para', blockId: 'p1', fromLine: 0, toLine: 1, x: 72, y: 100, width: 468 }], + }, + ], + }; + const blocks: FlowBlock[] = [{ kind: 'paragraph', id: 'p1', runs: [] }]; + const measures: Measure[] = [{ kind: 'paragraph', lines: [], totalHeight: 0 }]; + + const result = resolveLayout({ layout, flowMode: 'paginated', blocks, measures }); + const item = result.pages[0].items[0] as import('@superdoc/contracts').ResolvedFragmentItem; + expect(item.sdtContainerKey).toBeUndefined(); + }); + + it('sets sdtContainerKey for a list-item fragment from its item paragraph sdt', () => { + 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 blocks: FlowBlock[] = [ + { + kind: 'list', + id: 'list1', + listType: 'bullet', + items: [ + { + id: 'item-a', + marker: { text: '•', style: {} }, + paragraph: { + kind: 'paragraph', + id: 'item-a-p', + runs: [], + attrs: { sdt: { type: 'structuredContent', scope: 'block', id: 'list-sdt-1' } }, + }, + }, + ], + }, + ]; + const measures: 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, measures }); + const item = result.pages[0].items[0] as import('@superdoc/contracts').ResolvedFragmentItem; + expect(item.sdtContainerKey).toBe('structuredContent:list-sdt-1'); + }); + + it('sets sdtContainerKey for a table fragment with sdt', () => { + const tableFragment: TableFragment = { + kind: 'table', + blockId: 'tbl1', + fromRow: 0, + toRow: 1, + x: 72, + y: 100, + width: 468, + height: 30, + }; + const layout: Layout = { + pageSize: { w: 612, h: 792 }, + pages: [{ number: 1, fragments: [tableFragment] }], + }; + const tableBlock = { + kind: 'table' as const, + id: 'tbl1', + rows: [], + attrs: { sdt: { type: 'documentSection' as const, id: 'tbl-sec-1' } }, + }; + const tableMeasure = { + kind: 'table' as const, + rows: [], + columnWidths: [], + totalWidth: 0, + totalHeight: 0, + }; + + const result = resolveLayout({ + layout, + flowMode: 'paginated', + blocks: [tableBlock as any], + measures: [tableMeasure as any], + }); + const item = result.pages[0].items[0] as import('@superdoc/contracts').ResolvedTableItem; + expect(item.sdtContainerKey).toBe('documentSection:tbl-sec-1'); + }); + + it('omits sdtContainerKey for image and drawing fragments', () => { + const imageFragment: ImageFragment = { + kind: 'image', + blockId: 'img1', + x: 100, + y: 200, + width: 300, + height: 250, + }; + const drawingFragment: DrawingFragment = { + kind: 'drawing', + drawingKind: 'vectorShape', + blockId: 'dr1', + x: 50, + y: 60, + width: 200, + height: 150, + geometry: { width: 200, height: 150 }, + scale: 1, + }; + const layout: Layout = { + pageSize: { w: 612, h: 792 }, + pages: [{ number: 1, fragments: [imageFragment, drawingFragment] }], + }; + const imageBlock = { kind: 'image' as const, id: 'img1', src: 'test.png', width: 300, height: 250 }; + const drawingBlock = { + kind: 'drawing' as const, + id: 'dr1', + drawingKind: 'vectorShape' as const, + geometry: { width: 200, height: 150 }, + }; + + const result = resolveLayout({ + layout, + flowMode: 'paginated', + blocks: [imageBlock, drawingBlock as any], + measures: [ + { kind: 'image', width: 300, height: 250 }, + { kind: 'drawing', width: 200, height: 150 }, + ], + }); + const imgItem = result.pages[0].items[0] as import('@superdoc/contracts').ResolvedImageItem; + const drItem = result.pages[0].items[1] as import('@superdoc/contracts').ResolvedDrawingItem; + expect(imgItem.sdtContainerKey).toBeUndefined(); + expect(drItem.sdtContainerKey).toBeUndefined(); + }); + + it('returns null (omits key) for structuredContent block scope with no id', () => { + const layout: Layout = { + pageSize: { w: 612, h: 792 }, + pages: [ + { + number: 1, + fragments: [{ kind: 'para', blockId: 'p1', fromLine: 0, toLine: 1, x: 72, y: 100, width: 468 }], + }, + ], + }; + const blocks: FlowBlock[] = [ + { + kind: 'paragraph', + id: 'p1', + runs: [], + attrs: { sdt: { type: 'structuredContent', scope: 'block' } }, + }, + ]; + const measures: Measure[] = [{ kind: 'paragraph', lines: [], totalHeight: 0 }]; + + const result = resolveLayout({ layout, flowMode: 'paginated', blocks, measures }); + const item = result.pages[0].items[0] as import('@superdoc/contracts').ResolvedFragmentItem; + expect(item.sdtContainerKey).toBeUndefined(); + }); + + it('returns null (omits key) for documentSection with no id or sdBlockId', () => { + const layout: Layout = { + pageSize: { w: 612, h: 792 }, + pages: [ + { + number: 1, + fragments: [{ kind: 'para', blockId: 'p1', fromLine: 0, toLine: 1, x: 72, y: 100, width: 468 }], + }, + ], + }; + const blocks: FlowBlock[] = [ + { + kind: 'paragraph', + id: 'p1', + runs: [], + attrs: { sdt: { type: 'documentSection' } }, + }, + ]; + const measures: Measure[] = [{ kind: 'paragraph', lines: [], totalHeight: 0 }]; + + const result = resolveLayout({ layout, flowMode: 'paginated', blocks, measures }); + const item = result.pages[0].items[0] as import('@superdoc/contracts').ResolvedFragmentItem; + expect(item.sdtContainerKey).toBeUndefined(); + }); + }); }); diff --git a/packages/layout-engine/layout-resolved/src/resolveLayout.ts b/packages/layout-engine/layout-resolved/src/resolveLayout.ts index 3f1d19d4de..96a058cfa2 100644 --- a/packages/layout-engine/layout-resolved/src/resolveLayout.ts +++ b/packages/layout-engine/layout-resolved/src/resolveLayout.ts @@ -16,6 +16,7 @@ import type { ResolvedFragmentItem, ResolvedParagraphContent, ListMeasure, + ListBlock, ParagraphBlock, ParagraphMeasure, } from '@superdoc/contracts'; @@ -24,6 +25,7 @@ import { resolveTableItem } from './resolveTable.js'; import { resolveImageItem } from './resolveImage.js'; import { resolveDrawingItem } from './resolveDrawing.js'; import type { BlockMapEntry } from './resolvedBlockLookup.js'; +import { computeSdtContainerKey } from './sdtContainerKey.js'; export type ResolveLayoutInput = { layout: Layout; @@ -125,20 +127,54 @@ function resolveParagraphContentIfApplicable( return resolveParagraphContent(fragment, entry.block as ParagraphBlock, entry.measure as ParagraphMeasure); } +function resolveFragmentSdtContainerKey(fragment: Fragment, blockMap: Map): string | null { + const entry = blockMap.get(fragment.blockId); + if (!entry) return null; + const block = entry.block; + + if (fragment.kind === 'para' && block.kind === 'paragraph') { + return computeSdtContainerKey(block.attrs?.sdt, block.attrs?.containerSdt); + } + + if (fragment.kind === 'list-item' && block.kind === 'list') { + const listBlock = block as ListBlock; + const item = listBlock.items.find((listItem) => listItem.id === fragment.itemId); + return computeSdtContainerKey(item?.paragraph.attrs?.sdt, item?.paragraph.attrs?.containerSdt); + } + + if (fragment.kind === 'table' && block.kind === 'table') { + return computeSdtContainerKey(block.attrs?.sdt, block.attrs?.containerSdt); + } + + // image, drawing — no SDT container keys + return null; +} + function resolveFragmentItem( fragment: Fragment, fragmentIndex: number, pageIndex: number, blockMap: Map, ): ResolvedPaintItem { + const sdtContainerKey = resolveFragmentSdtContainerKey(fragment, blockMap); + // Route to kind-specific resolvers for types that carry extracted block/measure data. switch (fragment.kind) { - case 'table': - return resolveTableItem(fragment as TableFragment, fragmentIndex, pageIndex, blockMap); - case 'image': - return resolveImageItem(fragment as ImageFragment, fragmentIndex, pageIndex, blockMap); - case 'drawing': - return resolveDrawingItem(fragment as DrawingFragment, fragmentIndex, pageIndex, blockMap); + case 'table': { + const item = resolveTableItem(fragment as TableFragment, fragmentIndex, pageIndex, blockMap); + if (sdtContainerKey != null) item.sdtContainerKey = sdtContainerKey; + return item; + } + case 'image': { + const item = resolveImageItem(fragment as ImageFragment, fragmentIndex, pageIndex, blockMap); + if (sdtContainerKey != null) item.sdtContainerKey = sdtContainerKey; + return item; + } + case 'drawing': { + const item = resolveDrawingItem(fragment as DrawingFragment, fragmentIndex, pageIndex, blockMap); + if (sdtContainerKey != null) item.sdtContainerKey = sdtContainerKey; + return item; + } default: { // para, list-item — existing generic resolution const item: ResolvedFragmentItem = { @@ -155,6 +191,7 @@ function resolveFragmentItem( fragmentIndex, content: resolveParagraphContentIfApplicable(fragment, blockMap), }; + if (sdtContainerKey != null) item.sdtContainerKey = sdtContainerKey; if (fragment.kind === 'para') { const para = fragment as ParaFragment; if (para.pmStart != null) item.pmStart = para.pmStart; diff --git a/packages/layout-engine/layout-resolved/src/sdtContainerKey.ts b/packages/layout-engine/layout-resolved/src/sdtContainerKey.ts new file mode 100644 index 0000000000..4cee08673f --- /dev/null +++ b/packages/layout-engine/layout-resolved/src/sdtContainerKey.ts @@ -0,0 +1,40 @@ +import type { SdtMetadata } from '@superdoc/contracts'; + +/** + * Returns a stable key for grouping consecutive fragments in the same SDT container. + * + * This is a minimal duplicate of the logic in `painters/dom/src/utils/sdt-helpers.ts` + * (`getSdtContainerKey`), kept here to avoid a dependency on the painter package. + * Only the key derivation is needed; DOM styling helpers are not. + */ +export function computeSdtContainerKey(sdt?: SdtMetadata | null, containerSdt?: SdtMetadata | null): string | null { + const metadata = getSdtContainerMetadata(sdt, containerSdt); + if (!metadata) return null; + + if (metadata.type === 'structuredContent') { + if (metadata.scope !== 'block') return null; + if (!metadata.id) return null; + return `structuredContent:${metadata.id}`; + } + + if (metadata.type === 'documentSection') { + const sectionId = metadata.id ?? metadata.sdBlockId; + if (!sectionId) return null; + return `documentSection:${sectionId}`; + } + + return null; +} + +function isSdtContainer(sdt?: SdtMetadata | null): boolean { + if (!sdt) return false; + if (sdt.type === 'documentSection') return true; + if (sdt.type === 'structuredContent' && sdt.scope === 'block') return true; + return false; +} + +function getSdtContainerMetadata(sdt?: SdtMetadata | null, containerSdt?: SdtMetadata | null): SdtMetadata | null { + if (isSdtContainer(sdt)) return sdt ?? null; + if (isSdtContainer(containerSdt)) return containerSdt ?? null; + return null; +} diff --git a/packages/layout-engine/painters/dom/src/renderer.ts b/packages/layout-engine/painters/dom/src/renderer.ts index c9467ffdb0..542bc94b22 100644 --- a/packages/layout-engine/painters/dom/src/renderer.ts +++ b/packages/layout-engine/painters/dom/src/renderer.ts @@ -2218,7 +2218,12 @@ export class DomPainter { pageIndex, }; - const sdtBoundaries = computeSdtBoundaries(page.fragments, this.blockLookup, this.sdtLabelsRendered); + const sdtBoundaries = computeSdtBoundaries( + page.fragments, + this.blockLookup, + this.sdtLabelsRendered, + resolvedPage?.items, + ); const betweenBorderFlags = computeBetweenBorderFlags(page.fragments, this.blockLookup); page.fragments.forEach((fragment, index) => { @@ -2648,7 +2653,12 @@ export class DomPainter { const existing = new Map(state.fragments.map((frag) => [frag.key, frag])); const nextFragments: FragmentDomState[] = []; - const sdtBoundaries = computeSdtBoundaries(page.fragments, this.blockLookup, this.sdtLabelsRendered); + const sdtBoundaries = computeSdtBoundaries( + page.fragments, + this.blockLookup, + this.sdtLabelsRendered, + resolvedPage?.items, + ); const betweenBorderFlags = computeBetweenBorderFlags(page.fragments, this.blockLookup); const contextBase: FragmentRenderContext = { @@ -2808,7 +2818,12 @@ export class DomPainter { pageIndex, }; - const sdtBoundaries = computeSdtBoundaries(page.fragments, this.blockLookup, this.sdtLabelsRendered); + const sdtBoundaries = computeSdtBoundaries( + page.fragments, + this.blockLookup, + this.sdtLabelsRendered, + resolvedPage?.items, + ); const betweenBorderFlags = computeBetweenBorderFlags(page.fragments, this.blockLookup); const fragmentStates: FragmentDomState[] = page.fragments.map((fragment, index) => { const sdtBoundary = sdtBoundaries.get(index); @@ -6883,9 +6898,18 @@ const computeSdtBoundaries = ( fragments: readonly Fragment[], blockLookup: BlockLookup, sdtLabelsRendered: Set, + resolvedItems?: readonly ResolvedPaintItem[], ): Map => { const boundaries = new Map(); - const containerKeys = fragments.map((fragment) => getFragmentSdtContainerKey(fragment, blockLookup)); + const containerKeys: (string | null)[] = resolvedItems + ? resolvedItems.map((item) => { + if ('sdtContainerKey' in item) { + const key = (item as { sdtContainerKey?: string | null }).sdtContainerKey; + return key ?? null; + } + return null; + }) + : fragments.map((fragment) => getFragmentSdtContainerKey(fragment, blockLookup)); let i = 0; while (i < fragments.length) { @@ -6914,7 +6938,7 @@ const computeSdtBoundaries = ( let paddingBottomOverride: number | undefined; if (!isEnd) { const nextFragment = fragments[k + 1]; - const currentHeight = getFragmentHeight(fragment, blockLookup); + const currentHeight = resolvedItems?.[k]?.height ?? getFragmentHeight(fragment, blockLookup); const currentBottom = fragment.y + currentHeight; const gapToNext = nextFragment.y - currentBottom; if (gapToNext > 0) {