Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 10 additions & 2 deletions packages/layout-engine/contracts/src/resolved-layout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,8 @@ export type ResolvedFragmentItem = {
content?: ResolvedParagraphContent;
/** Pre-computed SDT container key for boundary grouping (`structuredContent:<id>` or `documentSection:<id>`). */
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. */
Expand Down Expand Up @@ -309,6 +311,8 @@ export type ResolvedTableItem = {
effectiveColumnWidths: number[];
/** Pre-computed SDT container key for boundary grouping (`structuredContent:<id>` or `documentSection:<id>`). */
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. */
Expand Down Expand Up @@ -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. */
Expand Down Expand Up @@ -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. */
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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 },
Expand Down
46 changes: 45 additions & 1 deletion packages/layout-engine/layout-resolved/src/resolveLayout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import type {
Run,
TableBlock,
TextRun,
SdtMetadata,
} from '@superdoc/contracts';
import { buildPageRefAnchorMap, getSdtContainerKey } from '@superdoc/contracts';
import { resolveParagraphContent } from './resolveParagraph.js';
Expand Down Expand Up @@ -460,10 +461,48 @@ function resolveFragmentSdtContainerKey(fragment: Fragment, blockMap: Map<string
return getSdtContainerKey(block.attrs?.sdt, block.attrs?.containerSdt);
}

// image, drawing — no SDT container keys
if (fragment.kind === 'image' && block.kind === 'image') {
return getSdtContainerKey(block.attrs?.sdt, block.attrs?.containerSdt);
}

if (fragment.kind === 'drawing' && block.kind === 'drawing') {
const attrs = block.attrs as { sdt?: SdtMetadata | null; containerSdt?: SdtMetadata | null } | undefined;
return getSdtContainerKey(attrs?.sdt, attrs?.containerSdt);
}

return null;
}

/**
* The full ordered SDT container key chain (outermost first) for a fragment,
* derived from `attrs.sdtContainers`. Falls back to the single nearest key for
* blocks that carry no chain (document sections, TOC paragraphs, etc.). Used by
* the painter to draw nested content-control chrome.
*/
function resolveFragmentSdtContainerKeys(
fragment: Fragment,
blockMap: Map<string, BlockMapEntry>,
): (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<string, BlockMapEntry>,
Expand Down Expand Up @@ -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);
Expand All @@ -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(
Expand All @@ -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);
Expand All @@ -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);
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import type {
TableBlock,
TabRun,
TextRun,
SdtMetadata,
} from '@superdoc/contracts';

describe('sourceAnchorSignature', () => {
Expand Down Expand Up @@ -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',
Expand Down
24 changes: 23 additions & 1 deletion packages/layout-engine/layout-resolved/src/versionSignature.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 ?? ''}`
Expand Down Expand Up @@ -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
// ---------------------------------------------------------------------------
Expand Down
Loading