From b5d51db18027740a74cc9e8b491c01e3dee349a3 Mon Sep 17 00:00:00 2001 From: Caio Pizzol <97641911+caio-pizzol@users.noreply.github.com> Date: Thu, 18 Jun 2026 10:50:47 -0300 Subject: [PATCH 1/2] feat(layout-resolved): carry SDT container key chain through resolved layout resolveFragmentSdtContainerKey now returns a key for image and drawing fragments (was null), and a new resolveFragmentSdtContainerKeys derives the full outer-to-inner chain from attrs.sdtContainers, exposed as ResolvedPaintItem.sdtContainerKeys. The chain is folded into deriveBlockVersion so paint reuse invalidates when a block's content-control nesting changes even if its nearest sdt is unchanged. Adds a cache-bust unit test. Refs #3745 --- .../contracts/src/resolved-layout.ts | 12 ++++- .../layout-resolved/src/resolveLayout.ts | 46 ++++++++++++++++++- .../src/versionSignature.test.ts | 22 +++++++++ .../layout-resolved/src/versionSignature.ts | 24 +++++++++- 4 files changed, 100 insertions(+), 4 deletions(-) diff --git a/packages/layout-engine/contracts/src/resolved-layout.ts b/packages/layout-engine/contracts/src/resolved-layout.ts index cf2e4d4149..2d68e92727 100644 --- a/packages/layout-engine/contracts/src/resolved-layout.ts +++ b/packages/layout-engine/contracts/src/resolved-layout.ts @@ -162,6 +162,8 @@ export type ResolvedFragmentItem = { content?: ResolvedParagraphContent; /** Pre-computed SDT container key for boundary grouping (`structuredContent:` or `documentSection:`). */ sdtContainerKey?: string | null; + /** Pre-computed ordered SDT container key chain (outermost first) for nested-control grouping. */ + sdtContainerKeys?: (string | null)[]; /** Pre-computed hash of paragraph borders for between-border grouping. */ paragraphBorderHash?: string; /** Pre-extracted paragraph borders for between-border rendering. */ @@ -309,6 +311,8 @@ export type ResolvedTableItem = { effectiveColumnWidths: number[]; /** Pre-computed SDT container key for boundary grouping (`structuredContent:` or `documentSection:`). */ sdtContainerKey?: string | null; + /** Pre-computed ordered SDT container key chain (outermost first) for nested-control grouping. */ + sdtContainerKeys?: (string | null)[]; /** Pre-computed visual/layout signature (blockVersion + fragment-specific data). */ version?: string; /** Pre-computed source/evidence metadata signature. Does not imply visual/layout geometry changed. */ @@ -368,8 +372,10 @@ 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). */ + /** Pre-computed SDT container key for boundary grouping (set when the image is inside a content control). */ sdtContainerKey?: string | null; + /** Pre-computed ordered SDT container key chain (outermost first) for nested-control grouping. */ + sdtContainerKeys?: (string | null)[]; /** Pre-computed visual/layout signature (blockVersion + fragment-specific data). */ version?: string; /** Pre-computed source/evidence metadata signature. Does not imply visual/layout geometry changed. */ @@ -427,8 +433,10 @@ 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). */ + /** Pre-computed SDT container key for boundary grouping (set when the drawing is inside a content control). */ sdtContainerKey?: string | null; + /** Pre-computed ordered SDT container key chain (outermost first) for nested-control grouping. */ + sdtContainerKeys?: (string | null)[]; /** Pre-computed visual/layout signature (blockVersion + fragment-specific data). */ version?: string; /** Pre-computed source/evidence metadata signature. Does not imply visual/layout geometry changed. */ diff --git a/packages/layout-engine/layout-resolved/src/resolveLayout.ts b/packages/layout-engine/layout-resolved/src/resolveLayout.ts index 9167d69422..99047a4b3b 100644 --- a/packages/layout-engine/layout-resolved/src/resolveLayout.ts +++ b/packages/layout-engine/layout-resolved/src/resolveLayout.ts @@ -27,6 +27,7 @@ import type { Run, TableBlock, TextRun, + SdtMetadata, } from '@superdoc/contracts'; import { buildPageRefAnchorMap, getSdtContainerKey } from '@superdoc/contracts'; import { resolveParagraphContent } from './resolveParagraph.js'; @@ -460,10 +461,48 @@ function resolveFragmentSdtContainerKey(fragment: Fragment, blockMap: Map, +): (string | null)[] | undefined { + const entry = blockMap.get(fragment.blockId); + if (!entry) return undefined; + const block = entry.block; + + let attrs: { sdtContainers?: SdtMetadata[] } | undefined; + if (fragment.kind === 'list-item' && block.kind === 'list') { + attrs = (block as ListBlock).items.find((listItem) => listItem.id === fragment.itemId)?.paragraph.attrs; + } else { + attrs = (block as { attrs?: { sdtContainers?: SdtMetadata[] } }).attrs; + } + + const containers = attrs?.sdtContainers; + if (containers && containers.length > 0) { + return containers.map((metadata) => getSdtContainerKey(metadata)); + } + + const single = resolveFragmentSdtContainerKey(fragment, blockMap); + return single != null ? [single] : undefined; +} + function computeBlockVersion( blockId: string, blockMap: Map, @@ -512,6 +551,7 @@ export function resolveFragmentItem( pageRefContext?: PageRefResolutionContext, ): ResolvedPaintItem { const sdtContainerKey = resolveFragmentSdtContainerKey(fragment, blockMap); + const sdtContainerKeys = resolveFragmentSdtContainerKeys(fragment, blockMap); const blockVer = computeBlockVersion(fragment.blockId, blockMap, blockVersionCache, fontSignature); const version = fragmentSignature(fragment, blockVer); const layoutSourceIdentity = resolveFragmentLayoutIdentity(fragment, story); @@ -526,6 +566,7 @@ export function resolveFragmentItem( if (tablePageRefs.measure) item.measure = tablePageRefs.measure; } if (sdtContainerKey != null) item.sdtContainerKey = sdtContainerKey; + if (sdtContainerKeys) item.sdtContainerKeys = sdtContainerKeys; if (fragment.sourceAnchor != null) item.sourceAnchor = fragment.sourceAnchor; item.layoutSourceIdentity = layoutSourceIdentity; applyPaintVersions( @@ -539,6 +580,7 @@ export function resolveFragmentItem( case 'image': { const item = resolveImageItem(fragment as ImageFragment, fragmentIndex, pageIndex, blockMap); if (sdtContainerKey != null) item.sdtContainerKey = sdtContainerKey; + if (sdtContainerKeys) item.sdtContainerKeys = sdtContainerKeys; if (fragment.sourceAnchor != null) item.sourceAnchor = fragment.sourceAnchor; item.layoutSourceIdentity = layoutSourceIdentity; applyPaintVersions(item, version); @@ -547,6 +589,7 @@ export function resolveFragmentItem( case 'drawing': { const item = resolveDrawingItem(fragment as DrawingFragment, fragmentIndex, pageIndex, blockMap); if (sdtContainerKey != null) item.sdtContainerKey = sdtContainerKey; + if (sdtContainerKeys) item.sdtContainerKeys = sdtContainerKeys; if (fragment.sourceAnchor != null) item.sourceAnchor = fragment.sourceAnchor; item.layoutSourceIdentity = layoutSourceIdentity; applyPaintVersions(item, version); @@ -604,6 +647,7 @@ export function resolveFragmentItem( layoutSourceIdentity, }; if (sdtContainerKey != null) item.sdtContainerKey = sdtContainerKey; + if (sdtContainerKeys) item.sdtContainerKeys = sdtContainerKeys; if (fragment.sourceAnchor != null) item.sourceAnchor = fragment.sourceAnchor; // Pre-extract block/measure for para and list-item fragments so the painter diff --git a/packages/layout-engine/layout-resolved/src/versionSignature.test.ts b/packages/layout-engine/layout-resolved/src/versionSignature.test.ts index d5b9e5b9ff..3de9364823 100644 --- a/packages/layout-engine/layout-resolved/src/versionSignature.test.ts +++ b/packages/layout-engine/layout-resolved/src/versionSignature.test.ts @@ -9,6 +9,7 @@ import type { TableBlock, TabRun, TextRun, + SdtMetadata, } from '@superdoc/contracts'; describe('sourceAnchorSignature', () => { @@ -76,6 +77,27 @@ describe('deriveBlockVersion - bidi', () => { }); }); +describe('deriveBlockVersion - sdt container chain', () => { + const outer = { type: 'structuredContent', scope: 'block', id: 'outer' } as unknown as SdtMetadata; + const inner = { type: 'structuredContent', scope: 'block', id: 'inner' } as unknown as SdtMetadata; + const makeParagraph = (sdtContainers?: SdtMetadata[]): FlowBlock => ({ + kind: 'paragraph', + id: 'p1', + attrs: { ...(sdtContainers ? { sdtContainers } : {}) }, + runs: [{ text: 'x', fontFamily: 'Arial', fontSize: 16, pmStart: 1, pmEnd: 2 } as TextRun], + }); + + // Nesting must bust paint reuse even when the nearest sdt is unchanged: + // [inner] and [outer, inner] share the same nearest control but differ in ancestry. + it('differs when the container chain gains an outer level', () => { + expect(deriveBlockVersion(makeParagraph([inner]))).not.toBe(deriveBlockVersion(makeParagraph([outer, inner]))); + }); + + it('is stable for an identical chain', () => { + expect(deriveBlockVersion(makeParagraph([outer, inner]))).toBe(deriveBlockVersion(makeParagraph([outer, inner]))); + }); +}); + describe('deriveBlockVersion - tab underline', () => { const makeTabParagraph = (underline?: { style?: string; color?: string }): FlowBlock => ({ kind: 'paragraph', diff --git a/packages/layout-engine/layout-resolved/src/versionSignature.ts b/packages/layout-engine/layout-resolved/src/versionSignature.ts index 7262a52762..e253d483ce 100644 --- a/packages/layout-engine/layout-resolved/src/versionSignature.ts +++ b/packages/layout-engine/layout-resolved/src/versionSignature.ts @@ -57,6 +57,14 @@ const getSdtMetadataVersion = (metadata: SdtMetadata | null | undefined): string return [metadata.type, getSdtMetadataLockMode(metadata), getSdtMetadataId(metadata)].join(':'); }; +// Version contribution of the ordered block-container chain (outer to inner) so +// that changing a block's content-control nesting invalidates paint reuse even +// when its nearest `sdt` is unchanged. Empty when there is no chain. +const getSdtContainersVersion = (containers: readonly (SdtMetadata | null | undefined)[] | undefined): string => { + if (!containers || containers.length === 0) return ''; + return containers.map(getSdtMetadataVersion).join(','); +}; + const getTrackedChangeLayers = (run: TextRun): TrackedChangeMeta[] => { if (Array.isArray(run.trackedChanges) && run.trackedChanges.length > 0) { return run.trackedChanges; @@ -313,7 +321,7 @@ export const resolveFragmentLayoutIdentity = (fragment: Fragment, story?: Layout * Kept in layout-resolved so the resolved layout stage can pre-compute block * versions without depending on painter-dom. */ -export const deriveBlockVersion = (block: FlowBlock): string => { +const deriveBlockVersionCore = (block: FlowBlock): string => { if (block.kind === 'paragraph') { const markerVersion = hasListMarkerProperties(block.attrs) ? `marker:${block.attrs.numberingProperties.numId ?? ''}:${block.attrs.numberingProperties.ilvl ?? 0}:${block.attrs.wordLayout?.marker?.markerText ?? ''}` @@ -637,6 +645,20 @@ export const deriveBlockVersion = (block: FlowBlock): string => { return block.id; }; +/** + * Public block version: the core signature plus the ordered SDT container chain + * (outermost first), so a block's paint reuse invalidates when its content- + * control nesting changes even if its nearest `sdt` is unchanged. Blocks with no + * chain keep their previous version unchanged. + */ +export const deriveBlockVersion = (block: FlowBlock): string => { + const core = deriveBlockVersionCore(block); + const containersVersion = getSdtContainersVersion( + (block as { attrs?: { sdtContainers?: SdtMetadata[] } }).attrs?.sdtContainers, + ); + return containersVersion ? `${core}|sdtc:${containersVersion}` : core; +}; + // --------------------------------------------------------------------------- // fragmentSignature // --------------------------------------------------------------------------- From 9ef613ca74c51dd435b18e561534e20ab84d6d6f Mon Sep 17 00:00:00 2001 From: Caio Pizzol <97641911+caio-pizzol@users.noreply.github.com> Date: Thu, 18 Jun 2026 10:57:45 -0300 Subject: [PATCH 2/2] test(layout-resolved): image/drawing fragments resolve SDT key and chain --- .../layout-resolved/src/resolveLayout.test.ts | 66 ++++++++++++++++++- 1 file changed, 65 insertions(+), 1 deletion(-) diff --git a/packages/layout-engine/layout-resolved/src/resolveLayout.test.ts b/packages/layout-engine/layout-resolved/src/resolveLayout.test.ts index 3a60175274..85fffed8f6 100644 --- a/packages/layout-engine/layout-resolved/src/resolveLayout.test.ts +++ b/packages/layout-engine/layout-resolved/src/resolveLayout.test.ts @@ -3387,7 +3387,7 @@ describe('resolveLayout', () => { expect(item.sdtContainerKey).toBe('documentSection:tbl-sec-1'); }); - it('omits sdtContainerKey for image and drawing fragments', () => { + it('omits sdtContainerKey for image and drawing fragments without sdt', () => { const imageFragment: ImageFragment = { kind: 'image', blockId: 'img1', @@ -3434,6 +3434,70 @@ describe('resolveLayout', () => { expect(drItem.sdtContainerKey).toBeUndefined(); }); + it('sets sdtContainerKey and chain for image and drawing fragments inside a content control', () => { + 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 outer = { type: 'structuredContent', scope: 'block', id: 'outer' }; + const imageBlock = { + kind: 'image' as const, + id: 'img1', + src: 'test.png', + width: 300, + height: 250, + attrs: { + sdt: { type: 'structuredContent', scope: 'block', id: 'img-sdt' }, + sdtContainers: [outer, { type: 'structuredContent', scope: 'block', id: 'img-sdt' }], + }, + }; + const drawingBlock = { + kind: 'drawing' as const, + id: 'dr1', + drawingKind: 'vectorShape' as const, + geometry: { width: 200, height: 150 }, + attrs: { + sdt: { type: 'structuredContent', scope: 'block', id: 'dr-sdt' }, + sdtContainers: [outer, { type: 'structuredContent', scope: 'block', id: 'dr-sdt' }], + }, + }; + + const result = resolveLayout({ + layout, + flowMode: 'paginated', + blocks: [imageBlock as any, 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).toBe('structuredContent:img-sdt'); + expect(imgItem.sdtContainerKeys).toEqual(['structuredContent:outer', 'structuredContent:img-sdt']); + expect(drItem.sdtContainerKey).toBe('structuredContent:dr-sdt'); + expect(drItem.sdtContainerKeys).toEqual(['structuredContent:outer', 'structuredContent:dr-sdt']); + }); + it('sets an object-stable key for structuredContent block scope with no id', () => { const layout: Layout = { pageSize: { w: 612, h: 792 },