From 3a9faf97caa1bea2ca624792f76c558e32cd6efc Mon Sep 17 00:00:00 2001 From: Tadeu Tupinamba Date: Tue, 14 Apr 2026 16:07:10 -0300 Subject: [PATCH] refactor(layout): move change detection into resolved layout stage --- .../contracts/src/resolved-layout.ts | 8 + .../layout-resolved/src/hashUtils.ts | 116 ++++ .../layout-resolved/src/resolveLayout.test.ts | 320 +++++++++++ .../layout-resolved/src/resolveLayout.ts | 28 +- .../layout-resolved/src/versionSignature.ts | 535 ++++++++++++++++++ .../painters/dom/src/renderer.ts | 14 +- 6 files changed, 1016 insertions(+), 5 deletions(-) create mode 100644 packages/layout-engine/layout-resolved/src/hashUtils.ts create mode 100644 packages/layout-engine/layout-resolved/src/versionSignature.ts diff --git a/packages/layout-engine/contracts/src/resolved-layout.ts b/packages/layout-engine/contracts/src/resolved-layout.ts index ca615e563b..bd1a2eac2b 100644 --- a/packages/layout-engine/contracts/src/resolved-layout.ts +++ b/packages/layout-engine/contracts/src/resolved-layout.ts @@ -125,6 +125,8 @@ export type ResolvedFragmentItem = { paragraphBorderHash?: string; /** Pre-extracted paragraph borders for between-border rendering. */ paragraphBorders?: ParagraphBorders; + /** Pre-computed change-detection signature (blockVersion + fragment-specific data). */ + version?: string; }; /** Resolved paragraph content for non-table paragraph/list-item fragments. */ @@ -241,6 +243,8 @@ export type ResolvedTableItem = { effectiveColumnWidths: number[]; /** Pre-computed SDT container key for boundary grouping (`structuredContent:` or `documentSection:`). */ sdtContainerKey?: string | null; + /** Pre-computed change-detection signature (blockVersion + fragment-specific data). */ + version?: string; }; /** @@ -279,6 +283,8 @@ export type ResolvedImageItem = { metadata?: ImageFragmentMetadata; /** Pre-computed SDT container key for boundary grouping (typically null for images). */ sdtContainerKey?: string | null; + /** Pre-computed change-detection signature (blockVersion + fragment-specific data). */ + version?: string; }; /** @@ -315,6 +321,8 @@ export type ResolvedDrawingItem = { block: DrawingBlock; /** Pre-computed SDT container key for boundary grouping (typically null for drawings). */ sdtContainerKey?: string | null; + /** Pre-computed change-detection signature (blockVersion + fragment-specific data). */ + version?: string; }; /** Type guard: checks whether a resolved paint item is a ResolvedTableItem. */ diff --git a/packages/layout-engine/layout-resolved/src/hashUtils.ts b/packages/layout-engine/layout-resolved/src/hashUtils.ts new file mode 100644 index 0000000000..ff2b4c38ad --- /dev/null +++ b/packages/layout-engine/layout-resolved/src/hashUtils.ts @@ -0,0 +1,116 @@ +import type { BorderSpec, CellBorders, Run, TableBorders, TableBorderValue } from '@superdoc/contracts'; + +/** + * Hash helpers for block version computation. + * + * Duplicated from painters/dom/src/paragraph-hash-utils.ts to avoid a circular + * dependency (painter-dom -> layout-resolved is not allowed). Keep the two + * copies in sync. + */ + +// --------------------------------------------------------------------------- +// Table/Cell border hashing +// --------------------------------------------------------------------------- + +const isNoneBorder = (value: TableBorderValue): value is { none: true } => { + return typeof value === 'object' && value !== null && 'none' in value && (value as { none: true }).none === true; +}; + +const isBorderSpec = (value: unknown): value is BorderSpec => { + return typeof value === 'object' && value !== null && !('none' in value); +}; + +export const hashBorderSpec = (border: BorderSpec): string => { + const parts: string[] = []; + if (border.style !== undefined) parts.push(`s:${border.style}`); + if (border.width !== undefined) parts.push(`w:${border.width}`); + if (border.color !== undefined) parts.push(`c:${border.color}`); + if (border.space !== undefined) parts.push(`sp:${border.space}`); + return parts.join(','); +}; + +const hashTableBorderValue = (borderValue: TableBorderValue | undefined): string => { + if (borderValue === undefined) return ''; + if (borderValue === null) return 'null'; + if (isNoneBorder(borderValue)) return 'none'; + if (isBorderSpec(borderValue)) { + return hashBorderSpec(borderValue); + } + return ''; +}; + +export const hashTableBorders = (borders: TableBorders | undefined): string => { + if (!borders) return ''; + const parts: string[] = []; + if (borders.top !== undefined) parts.push(`t:[${hashTableBorderValue(borders.top)}]`); + if (borders.right !== undefined) parts.push(`r:[${hashTableBorderValue(borders.right)}]`); + if (borders.bottom !== undefined) parts.push(`b:[${hashTableBorderValue(borders.bottom)}]`); + if (borders.left !== undefined) parts.push(`l:[${hashTableBorderValue(borders.left)}]`); + if (borders.insideH !== undefined) parts.push(`ih:[${hashTableBorderValue(borders.insideH)}]`); + if (borders.insideV !== undefined) parts.push(`iv:[${hashTableBorderValue(borders.insideV)}]`); + return parts.join(';'); +}; + +export const hashCellBorders = (borders: CellBorders | undefined): string => { + if (!borders) return ''; + const parts: string[] = []; + if (borders.top) parts.push(`t:[${hashBorderSpec(borders.top)}]`); + if (borders.right) parts.push(`r:[${hashBorderSpec(borders.right)}]`); + if (borders.bottom) parts.push(`b:[${hashBorderSpec(borders.bottom)}]`); + if (borders.left) parts.push(`l:[${hashBorderSpec(borders.left)}]`); + return parts.join(';'); +}; + +// --------------------------------------------------------------------------- +// Run property accessors +// --------------------------------------------------------------------------- + +const hasStringProp = (run: Run, prop: string): run is Run & Record => { + return prop in run && typeof (run as Record)[prop] === 'string'; +}; + +const hasNumberProp = (run: Run, prop: string): run is Run & Record => { + return prop in run && typeof (run as Record)[prop] === 'number'; +}; + +const hasBooleanProp = (run: Run, prop: string): run is Run & Record => { + return prop in run && typeof (run as Record)[prop] === 'boolean'; +}; + +export const getRunStringProp = (run: Run, prop: string): string => { + if (hasStringProp(run, prop)) { + return run[prop]; + } + return ''; +}; + +export const getRunNumberProp = (run: Run, prop: string): number => { + if (hasNumberProp(run, prop)) { + return run[prop]; + } + return 0; +}; + +export const getRunBooleanProp = (run: Run, prop: string): boolean => { + if (hasBooleanProp(run, prop)) { + return run[prop]; + } + return false; +}; + +export const getRunUnderlineStyle = (run: Run): string => { + if ('underline' in run && typeof run.underline === 'boolean') { + return run.underline ? 'single' : ''; + } + if ('underline' in run && run.underline && typeof run.underline === 'object') { + return (run.underline as { style?: string }).style ?? ''; + } + return ''; +}; + +export const getRunUnderlineColor = (run: Run): string => { + if ('underline' in run && run.underline && typeof run.underline === 'object') { + return (run.underline as { color?: string }).color ?? ''; + } + return ''; +}; diff --git a/packages/layout-engine/layout-resolved/src/resolveLayout.test.ts b/packages/layout-engine/layout-resolved/src/resolveLayout.test.ts index 71afb9ab9f..c32b8ee219 100644 --- a/packages/layout-engine/layout-resolved/src/resolveLayout.test.ts +++ b/packages/layout-engine/layout-resolved/src/resolveLayout.test.ts @@ -2611,4 +2611,324 @@ describe('resolveLayout', () => { expect(item.paragraphBorderHash).toBeUndefined(); }); }); + + describe('version signature', () => { + it('sets version on paragraph fragment items', () => { + const paraFragment: ParaFragment = { + kind: 'para', + blockId: 'p1', + fromLine: 0, + toLine: 1, + x: 72, + y: 0, + width: 468, + }; + const layout: Layout = { + pageSize: { w: 612, h: 792 }, + pages: [{ number: 1, fragments: [paraFragment] }], + }; + const blocks: FlowBlock[] = [ + { kind: 'paragraph', id: 'p1', runs: [{ text: 'hello', fontFamily: 'Arial', fontSize: 12 }] } as any, + ]; + const measures: Measure[] = [{ kind: 'paragraph', lines: [{ lineHeight: 20 }] } as any]; + + const result = resolveLayout({ layout, flowMode: 'paginated', blocks, measures }); + const item = result.pages[0].items[0] as any; + expect(item.version).toBeDefined(); + expect(typeof item.version).toBe('string'); + expect(item.version.length).toBeGreaterThan(0); + }); + + it('sets version on table fragment items', () => { + const tableFragment: TableFragment = { + kind: 'table', + blockId: 'tbl1', + fromRow: 0, + toRow: 1, + x: 72, + y: 0, + width: 468, + height: 100, + }; + const layout: Layout = { + pageSize: { w: 612, h: 792 }, + pages: [{ number: 1, fragments: [tableFragment] }], + }; + const blocks: FlowBlock[] = [{ kind: 'table', id: 'tbl1', rows: [{ cells: [] }] } as any]; + const measures: Measure[] = [ + { + kind: 'table', + columnWidths: [468], + rows: [{ cells: [{ width: 468, height: 100 }] }], + } as any, + ]; + + const result = resolveLayout({ layout, flowMode: 'paginated', blocks, measures }); + const item = result.pages[0].items[0] as any; + expect(item.version).toBeDefined(); + expect(typeof item.version).toBe('string'); + }); + + it('sets version on image fragment items', () => { + const imageFragment: ImageFragment = { + kind: 'image', + blockId: 'img1', + x: 72, + y: 0, + width: 200, + height: 150, + }; + const layout: Layout = { + pageSize: { w: 612, h: 792 }, + pages: [{ number: 1, fragments: [imageFragment] }], + }; + const blocks: FlowBlock[] = [{ kind: 'image', id: 'img1', src: 'test.png', width: 200, height: 150 } as any]; + const measures: Measure[] = [{ kind: 'image' } as any]; + + const result = resolveLayout({ layout, flowMode: 'paginated', blocks, measures }); + const item = result.pages[0].items[0] as any; + expect(item.version).toBeDefined(); + expect(typeof item.version).toBe('string'); + }); + + it('sets version on drawing fragment items', () => { + const drawingFragment: DrawingFragment = { + kind: 'drawing', + blockId: 'dr1', + drawingKind: 'image', + x: 72, + y: 0, + width: 200, + height: 150, + geometry: { width: 200, height: 150 }, + scale: 1, + }; + const layout: Layout = { + pageSize: { w: 612, h: 792 }, + pages: [{ number: 1, fragments: [drawingFragment] }], + }; + const blocks: FlowBlock[] = [ + { + kind: 'drawing', + drawingKind: 'image', + id: 'dr1', + src: 'test.png', + width: 200, + height: 150, + geometry: { width: 200, height: 150 }, + } as any, + ]; + const measures: Measure[] = [{ kind: 'drawing' } as any]; + + const result = resolveLayout({ layout, flowMode: 'paginated', blocks, measures }); + const item = result.pages[0].items[0] as any; + expect(item.version).toBeDefined(); + expect(typeof item.version).toBe('string'); + }); + + it('sets version on list-item fragment items', () => { + const listFragment: ListItemFragment = { + kind: 'list-item', + blockId: 'list1', + itemId: 'item1', + fromLine: 0, + toLine: 1, + x: 72, + y: 0, + width: 468, + }; + const layout: Layout = { + pageSize: { w: 612, h: 792 }, + pages: [{ number: 1, fragments: [listFragment] }], + }; + const blocks: FlowBlock[] = [ + { + kind: 'list', + id: 'list1', + items: [ + { + id: 'item1', + marker: { text: '1.' }, + paragraph: { + kind: 'paragraph', + id: 'p-item1', + runs: [{ text: 'item', fontFamily: 'Arial', fontSize: 12 }], + }, + }, + ], + } as any, + ]; + const measures: Measure[] = [ + { + kind: 'list', + items: [{ itemId: 'item1', paragraph: { kind: 'paragraph', lines: [{ lineHeight: 20 }] } }], + } as any, + ]; + + const result = resolveLayout({ layout, flowMode: 'paginated', blocks, measures }); + const item = result.pages[0].items[0] as any; + expect(item.version).toBeDefined(); + expect(typeof item.version).toBe('string'); + }); + + it('produces different versions when block content changes', () => { + const paraFragment: ParaFragment = { + kind: 'para', + blockId: 'p1', + fromLine: 0, + toLine: 1, + x: 72, + y: 0, + width: 468, + }; + const layout: Layout = { + pageSize: { w: 612, h: 792 }, + pages: [{ number: 1, fragments: [paraFragment] }], + }; + const measures: Measure[] = [{ kind: 'paragraph', lines: [{ lineHeight: 20 }] } as any]; + + const blocks1: FlowBlock[] = [ + { kind: 'paragraph', id: 'p1', runs: [{ text: 'hello', fontFamily: 'Arial', fontSize: 12 }] } as any, + ]; + const blocks2: FlowBlock[] = [ + { kind: 'paragraph', id: 'p1', runs: [{ text: 'world', fontFamily: 'Arial', fontSize: 12 }] } as any, + ]; + + const result1 = resolveLayout({ layout, flowMode: 'paginated', blocks: blocks1, measures }); + const result2 = resolveLayout({ layout, flowMode: 'paginated', blocks: blocks2, measures }); + const ver1 = (result1.pages[0].items[0] as any).version; + const ver2 = (result2.pages[0].items[0] as any).version; + expect(ver1).not.toBe(ver2); + }); + + it('produces same version for identical inputs', () => { + const paraFragment: ParaFragment = { + kind: 'para', + blockId: 'p1', + fromLine: 0, + toLine: 1, + x: 72, + y: 0, + width: 468, + }; + const layout: Layout = { + pageSize: { w: 612, h: 792 }, + pages: [{ number: 1, fragments: [paraFragment] }], + }; + const blocks: FlowBlock[] = [ + { kind: 'paragraph', id: 'p1', runs: [{ text: 'hello', fontFamily: 'Arial', fontSize: 12 }] } as any, + ]; + const measures: Measure[] = [{ kind: 'paragraph', lines: [{ lineHeight: 20 }] } as any]; + + const result1 = resolveLayout({ layout, flowMode: 'paginated', blocks, measures }); + const result2 = resolveLayout({ layout, flowMode: 'paginated', blocks, measures }); + const ver1 = (result1.pages[0].items[0] as any).version; + const ver2 = (result2.pages[0].items[0] as any).version; + expect(ver1).toBe(ver2); + }); + + it('produces different versions when fragment line range changes', () => { + const fragment1: ParaFragment = { + kind: 'para', + blockId: 'p1', + fromLine: 0, + toLine: 1, + x: 72, + y: 0, + width: 468, + }; + const fragment2: ParaFragment = { + kind: 'para', + blockId: 'p1', + fromLine: 0, + toLine: 2, + x: 72, + y: 0, + width: 468, + }; + const blocks: FlowBlock[] = [ + { kind: 'paragraph', id: 'p1', runs: [{ text: 'hello', fontFamily: 'Arial', fontSize: 12 }] } as any, + ]; + const measures: Measure[] = [{ kind: 'paragraph', lines: [{ lineHeight: 20 }, { lineHeight: 20 }] } as any]; + + const layout1: Layout = { + pageSize: { w: 612, h: 792 }, + pages: [{ number: 1, fragments: [fragment1] }], + }; + const layout2: Layout = { + pageSize: { w: 612, h: 792 }, + pages: [{ number: 1, fragments: [fragment2] }], + }; + + const result1 = resolveLayout({ layout: layout1, flowMode: 'paginated', blocks, measures }); + const result2 = resolveLayout({ layout: layout2, flowMode: 'paginated', blocks, measures }); + const ver1 = (result1.pages[0].items[0] as any).version; + const ver2 = (result2.pages[0].items[0] as any).version; + expect(ver1).not.toBe(ver2); + }); + + it('caches block version across fragments sharing the same block', () => { + const frag1: ParaFragment = { + kind: 'para', + blockId: 'p1', + fromLine: 0, + toLine: 1, + x: 72, + y: 0, + width: 468, + }; + const frag2: ParaFragment = { + kind: 'para', + blockId: 'p1', + fromLine: 1, + toLine: 2, + x: 72, + y: 20, + width: 468, + }; + const layout: Layout = { + pageSize: { w: 612, h: 792 }, + pages: [{ number: 1, fragments: [frag1, frag2] }], + }; + const blocks: FlowBlock[] = [ + { kind: 'paragraph', id: 'p1', runs: [{ text: 'hello world', fontFamily: 'Arial', fontSize: 12 }] } as any, + ]; + const measures: Measure[] = [{ kind: 'paragraph', lines: [{ lineHeight: 20 }, { lineHeight: 20 }] } as any]; + + const result = resolveLayout({ layout, flowMode: 'paginated', blocks, measures }); + const ver1 = (result.pages[0].items[0] as any).version; + const ver2 = (result.pages[0].items[1] as any).version; + + // Both versions should be defined + expect(ver1).toBeDefined(); + expect(ver2).toBeDefined(); + // They should differ (different line ranges) + expect(ver1).not.toBe(ver2); + // But both share the same block version prefix + const prefix1 = ver1.split('|')[0]; + const prefix2 = ver2.split('|')[0]; + expect(prefix1).toBe(prefix2); + }); + + it('uses "missing" for fragments with no matching block', () => { + const paraFragment: ParaFragment = { + kind: 'para', + blockId: 'nonexistent', + fromLine: 0, + toLine: 1, + x: 72, + y: 0, + 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 any; + expect(item.version).toBeDefined(); + expect(item.version).toContain('missing'); + }); + }); }); diff --git a/packages/layout-engine/layout-resolved/src/resolveLayout.ts b/packages/layout-engine/layout-resolved/src/resolveLayout.ts index 9ea0b0cb97..85f0e83519 100644 --- a/packages/layout-engine/layout-resolved/src/resolveLayout.ts +++ b/packages/layout-engine/layout-resolved/src/resolveLayout.ts @@ -28,6 +28,7 @@ import { resolveDrawingItem } from './resolveDrawing.js'; import type { BlockMapEntry } from './resolvedBlockLookup.js'; import { computeSdtContainerKey } from './sdtContainerKey.js'; import { hashParagraphBorders } from './paragraphBorderHash.js'; +import { deriveBlockVersion, fragmentSignature } from './versionSignature.js'; export type ResolveLayoutInput = { layout: Layout; @@ -172,29 +173,52 @@ function resolveFragmentSdtContainerKey(fragment: Fragment, blockMap: Map, + cache: Map, +): string { + const cached = cache.get(blockId); + if (cached !== undefined) return cached; + const entry = blockMap.get(blockId); + if (!entry) { + cache.set(blockId, 'missing'); + return 'missing'; + } + const version = deriveBlockVersion(entry.block); + cache.set(blockId, version); + return version; +} + function resolveFragmentItem( fragment: Fragment, fragmentIndex: number, pageIndex: number, blockMap: Map, + blockVersionCache: Map, ): ResolvedPaintItem { const sdtContainerKey = resolveFragmentSdtContainerKey(fragment, blockMap); + const blockVer = computeBlockVersion(fragment.blockId, blockMap, blockVersionCache); + const version = fragmentSignature(fragment, blockVer); // Route to kind-specific resolvers for types that carry extracted block/measure data. switch (fragment.kind) { case 'table': { const item = resolveTableItem(fragment as TableFragment, fragmentIndex, pageIndex, blockMap); if (sdtContainerKey != null) item.sdtContainerKey = sdtContainerKey; + item.version = version; return item; } case 'image': { const item = resolveImageItem(fragment as ImageFragment, fragmentIndex, pageIndex, blockMap); if (sdtContainerKey != null) item.sdtContainerKey = sdtContainerKey; + item.version = version; return item; } case 'drawing': { const item = resolveDrawingItem(fragment as DrawingFragment, fragmentIndex, pageIndex, blockMap); if (sdtContainerKey != null) item.sdtContainerKey = sdtContainerKey; + item.version = version; return item; } default: { @@ -235,6 +259,7 @@ function resolveFragmentItem( if (listItem.continuesOnNext != null) item.continuesOnNext = listItem.continuesOnNext; if (listItem.markerWidth != null) item.markerWidth = listItem.markerWidth; } + item.version = version; return item; } } @@ -243,6 +268,7 @@ function resolveFragmentItem( export function resolveLayout(input: ResolveLayoutInput): ResolvedLayout { const { layout, flowMode, blocks, measures } = input; const blockMap = buildBlockMap(blocks, measures); + const blockVersionCache = new Map(); const pages: ResolvedPage[] = layout.pages.map((page, pageIndex) => ({ id: `page-${pageIndex}`, @@ -251,7 +277,7 @@ export function resolveLayout(input: ResolveLayoutInput): ResolvedLayout { width: page.size?.w ?? layout.pageSize.w, height: page.size?.h ?? layout.pageSize.h, items: page.fragments.map((fragment, fragmentIndex) => - resolveFragmentItem(fragment, fragmentIndex, pageIndex, blockMap), + resolveFragmentItem(fragment, fragmentIndex, pageIndex, blockMap, blockVersionCache), ), margins: page.margins, footnoteReserved: page.footnoteReserved, diff --git a/packages/layout-engine/layout-resolved/src/versionSignature.ts b/packages/layout-engine/layout-resolved/src/versionSignature.ts new file mode 100644 index 0000000000..8b2b15bb15 --- /dev/null +++ b/packages/layout-engine/layout-resolved/src/versionSignature.ts @@ -0,0 +1,535 @@ +import type { + DrawingBlock, + FieldAnnotationRun, + FlowBlock, + Fragment, + ImageBlock, + ImageDrawing, + ImageRun, + ParagraphAttrs, + ParagraphBlock, + SdtMetadata, + ShapeGroupDrawing, + TableAttrs, + TableBlock, + TableCellAttrs, + TextRun, + VectorShapeDrawing, +} from '@superdoc/contracts'; +import { hashParagraphBorders } from './paragraphBorderHash.js'; +import { + hashCellBorders, + hashTableBorders, + getRunBooleanProp, + getRunNumberProp, + getRunStringProp, + getRunUnderlineColor, + getRunUnderlineStyle, +} from './hashUtils.js'; + +// --------------------------------------------------------------------------- +// SDT metadata helpers +// --------------------------------------------------------------------------- + +const getSdtMetadataId = (metadata: SdtMetadata | null | undefined): string => { + if (!metadata) return ''; + if ('id' in metadata && metadata.id != null) { + return String(metadata.id); + } + return ''; +}; + +const getSdtMetadataLockMode = (metadata: SdtMetadata | null | undefined): string => { + if (!metadata) return ''; + return metadata.type === 'structuredContent' ? (metadata.lockMode ?? '') : ''; +}; + +const getSdtMetadataVersion = (metadata: SdtMetadata | null | undefined): string => { + if (!metadata) return ''; + return [metadata.type, getSdtMetadataLockMode(metadata), getSdtMetadataId(metadata)].join(':'); +}; + +// --------------------------------------------------------------------------- +// Clip path helpers +// --------------------------------------------------------------------------- + +const CLIP_PATH_PREFIXES = ['inset(', 'polygon(', 'circle(', 'ellipse(', 'path(', 'rect(']; + +const readClipPathValue = (value: unknown): string => { + if (typeof value !== 'string') return ''; + const normalized = value.trim(); + if (normalized.length === 0) return ''; + const lower = normalized.toLowerCase(); + if (!CLIP_PATH_PREFIXES.some((prefix) => lower.startsWith(prefix))) return ''; + return normalized; +}; + +const resolveClipPathFromAttrs = (attrs: unknown): string => { + if (!attrs || typeof attrs !== 'object') return ''; + const record = attrs as Record; + return readClipPathValue(record.clipPath); +}; + +const resolveBlockClipPath = (block: unknown): string => { + if (!block || typeof block !== 'object') return ''; + const record = block as Record; + return readClipPathValue(record.clipPath) || resolveClipPathFromAttrs(record.attrs); +}; + +// --------------------------------------------------------------------------- +// List marker validation +// --------------------------------------------------------------------------- + +const hasListMarkerProperties = ( + attrs: unknown, +): attrs is { + numberingProperties: { numId?: number | string; ilvl?: number }; + wordLayout?: { marker?: { markerText?: string } }; +} => { + if (!attrs || typeof attrs !== 'object') return false; + const obj = attrs as Record; + + if (!obj.numberingProperties || typeof obj.numberingProperties !== 'object') return false; + const numProps = obj.numberingProperties as Record; + + if ('numId' in numProps) { + const numId = numProps.numId; + if (typeof numId !== 'number' && typeof numId !== 'string') return false; + } + + if ('ilvl' in numProps) { + const ilvl = numProps.ilvl; + if (typeof ilvl !== 'number') return false; + } + + if ('wordLayout' in obj && obj.wordLayout !== undefined) { + if (typeof obj.wordLayout !== 'object' || obj.wordLayout === null) return false; + const wordLayout = obj.wordLayout as Record; + + if ('marker' in wordLayout && wordLayout.marker !== undefined) { + if (typeof wordLayout.marker !== 'object' || wordLayout.marker === null) return false; + const marker = wordLayout.marker as Record; + + if ('markerText' in marker && marker.markerText !== undefined) { + if (typeof marker.markerText !== 'string') return false; + } + } + } + + return true; +}; + +// --------------------------------------------------------------------------- +// FNV-1a hash helpers (for table block hashing) +// --------------------------------------------------------------------------- + +const hashString = (seed: number, value: string): number => { + let hash = seed >>> 0; + for (let i = 0; i < value.length; i++) { + hash ^= value.charCodeAt(i); + hash = Math.imul(hash, 16777619); + } + return hash >>> 0; +}; + +const hashNumber = (seed: number, value: number | undefined | null): number => { + const n = Number.isFinite(value) ? (value as number) : 0; + let hash = seed ^ n; + hash = Math.imul(hash, 16777619); + hash ^= hash >>> 13; + return hash >>> 0; +}; + +// --------------------------------------------------------------------------- +// deriveBlockVersion +// --------------------------------------------------------------------------- + +/** + * Derives a version string for a flow block based on its content and styling properties. + * + * This version string is used for cache invalidation. When any visual property of the block + * changes, the version string changes, triggering a DOM rebuild instead of reusing cached elements. + * + * Duplicated from painters/dom/src/renderer.ts to allow the resolved layout stage to + * pre-compute block versions without depending on painter-dom. Keep the two copies in sync + * until the painter fully migrates to resolved versions. + */ +export const deriveBlockVersion = (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 ?? ''}` + : ''; + + const runsVersion = block.runs + .map((run) => { + if (run.kind === 'image') { + const imgRun = run as ImageRun; + return [ + 'img', + imgRun.src, + imgRun.width, + imgRun.height, + imgRun.alt ?? '', + imgRun.title ?? '', + imgRun.clipPath ?? '', + imgRun.distTop ?? '', + imgRun.distBottom ?? '', + imgRun.distLeft ?? '', + imgRun.distRight ?? '', + readClipPathValue((imgRun as { clipPath?: unknown }).clipPath), + ].join(','); + } + + if (run.kind === 'lineBreak') { + return 'linebreak'; + } + + if (run.kind === 'tab') { + return [run.text ?? '', 'tab'].join(','); + } + + if (run.kind === 'fieldAnnotation') { + const fieldRun = run as FieldAnnotationRun; + const size = fieldRun.size ? `${fieldRun.size.width ?? ''}x${fieldRun.size.height ?? ''}` : ''; + const highlighted = fieldRun.highlighted !== false ? 1 : 0; + return [ + 'field', + fieldRun.variant ?? '', + fieldRun.displayLabel ?? '', + fieldRun.fieldColor ?? '', + fieldRun.borderColor ?? '', + highlighted, + fieldRun.hidden ? 1 : 0, + fieldRun.visibility ?? '', + fieldRun.imageSrc ?? '', + fieldRun.linkUrl ?? '', + fieldRun.rawHtml ?? '', + size, + fieldRun.fontFamily ?? '', + fieldRun.fontSize ?? '', + fieldRun.textColor ?? '', + fieldRun.textHighlight ?? '', + fieldRun.bold ? 1 : 0, + fieldRun.italic ? 1 : 0, + fieldRun.underline ? 1 : 0, + fieldRun.fieldId ?? '', + fieldRun.fieldType ?? '', + ].join(','); + } + + const textRun = run as TextRun; + return [ + textRun.text ?? '', + textRun.fontFamily, + textRun.fontSize, + textRun.bold ? 1 : 0, + textRun.italic ? 1 : 0, + textRun.color ?? '', + textRun.underline?.style ?? '', + textRun.underline?.color ?? '', + textRun.strike ? 1 : 0, + textRun.highlight ?? '', + textRun.letterSpacing != null ? textRun.letterSpacing : '', + textRun.vertAlign ?? '', + textRun.baselineShift != null ? textRun.baselineShift : '', + textRun.token ?? '', + textRun.trackedChange ? 1 : 0, + textRun.comments?.length ?? 0, + ].join(','); + }) + .join('|'); + + const attrs = block.attrs as ParagraphAttrs | undefined; + + const paragraphAttrsVersion = attrs + ? [ + attrs.alignment ?? '', + attrs.spacing?.before ?? '', + attrs.spacing?.after ?? '', + attrs.spacing?.line ?? '', + attrs.spacing?.lineRule ?? '', + attrs.indent?.left ?? '', + attrs.indent?.right ?? '', + attrs.indent?.firstLine ?? '', + attrs.indent?.hanging ?? '', + attrs.borders ? hashParagraphBorders(attrs.borders) : '', + attrs.shading?.fill ?? '', + attrs.shading?.color ?? '', + attrs.direction ?? '', + attrs.rtl ? '1' : '', + attrs.tabs?.length ? JSON.stringify(attrs.tabs) : '', + ].join(':') + : ''; + + const sdtAttrs = (block.attrs as ParagraphAttrs | undefined)?.sdt; + const sdtVersion = getSdtMetadataVersion(sdtAttrs); + + const parts = [markerVersion, runsVersion, paragraphAttrsVersion, sdtVersion].filter(Boolean); + return parts.join('|'); + } + + if (block.kind === 'list') { + return block.items.map((item) => `${item.id}:${item.marker.text}:${deriveBlockVersion(item.paragraph)}`).join('|'); + } + + if (block.kind === 'image') { + const imgSdt = (block as ImageBlock).attrs?.sdt; + const imgSdtVersion = getSdtMetadataVersion(imgSdt); + return [ + block.src ?? '', + block.width ?? '', + block.height ?? '', + block.alt ?? '', + block.title ?? '', + resolveBlockClipPath(block), + imgSdtVersion, + ].join('|'); + } + + if (block.kind === 'drawing') { + if (block.drawingKind === 'image') { + const imageLike = block as ImageDrawing; + return [ + 'drawing:image', + imageLike.src ?? '', + imageLike.width ?? '', + imageLike.height ?? '', + imageLike.alt ?? '', + resolveBlockClipPath(imageLike), + ].join('|'); + } + if (block.drawingKind === 'vectorShape') { + const vector = block as VectorShapeDrawing; + return [ + 'drawing:vector', + vector.shapeKind ?? '', + vector.fillColor ?? '', + vector.strokeColor ?? '', + vector.strokeWidth ?? '', + vector.geometry.width, + vector.geometry.height, + vector.geometry.rotation ?? 0, + vector.geometry.flipH ? 1 : 0, + vector.geometry.flipV ? 1 : 0, + ].join('|'); + } + if (block.drawingKind === 'shapeGroup') { + const group = block as ShapeGroupDrawing; + const childSignature = group.shapes + .map((child) => `${child.shapeType}:${JSON.stringify(child.attrs ?? {})}`) + .join(';'); + return [ + 'drawing:group', + group.geometry.width, + group.geometry.height, + group.groupTransform ? JSON.stringify(group.groupTransform) : '', + childSignature, + ].join('|'); + } + if (block.drawingKind === 'chart') { + return [ + 'drawing:chart', + block.chartData?.chartType ?? '', + block.chartData?.series?.length ?? 0, + block.geometry.width, + block.geometry.height, + block.chartRelId ?? '', + ].join('|'); + } + const _exhaustive: never = block; + return `drawing:unknown:${(block as DrawingBlock).id}`; + } + + if (block.kind === 'table') { + const tableBlock = block as TableBlock; + + let hash = 2166136261; + hash = hashString(hash, block.id); + hash = hashNumber(hash, tableBlock.rows.length); + hash = (tableBlock.columnWidths ?? []).reduce((acc, width) => hashNumber(acc, Math.round(width * 1000)), hash); + + const rows = tableBlock.rows ?? []; + for (const row of rows) { + if (!row || !Array.isArray(row.cells)) continue; + hash = hashNumber(hash, row.cells.length); + for (const cell of row.cells) { + if (!cell) continue; + const cellBlocks = cell.blocks ?? (cell.paragraph ? [cell.paragraph] : []); + hash = hashNumber(hash, cellBlocks.length); + hash = hashNumber(hash, cell.rowSpan ?? 1); + hash = hashNumber(hash, cell.colSpan ?? 1); + + if (cell.attrs) { + const cellAttrs = cell.attrs as TableCellAttrs; + if (cellAttrs.borders) { + hash = hashString(hash, hashCellBorders(cellAttrs.borders)); + } + if (cellAttrs.padding) { + const p = cellAttrs.padding; + hash = hashNumber(hash, p.top ?? 0); + hash = hashNumber(hash, p.right ?? 0); + hash = hashNumber(hash, p.bottom ?? 0); + hash = hashNumber(hash, p.left ?? 0); + } + if (cellAttrs.verticalAlign) { + hash = hashString(hash, cellAttrs.verticalAlign); + } + if (cellAttrs.background) { + hash = hashString(hash, cellAttrs.background); + } + } + + for (const cellBlock of cellBlocks) { + hash = hashString(hash, cellBlock?.kind ?? 'unknown'); + if (cellBlock?.kind === 'paragraph') { + const paragraphBlock = cellBlock as ParagraphBlock; + const runs = paragraphBlock.runs ?? []; + hash = hashNumber(hash, runs.length); + + const attrs = paragraphBlock.attrs as ParagraphAttrs | undefined; + + if (attrs) { + hash = hashString(hash, attrs.alignment ?? ''); + hash = hashNumber(hash, attrs.spacing?.before ?? 0); + hash = hashNumber(hash, attrs.spacing?.after ?? 0); + hash = hashNumber(hash, attrs.spacing?.line ?? 0); + hash = hashString(hash, attrs.spacing?.lineRule ?? ''); + hash = hashNumber(hash, attrs.indent?.left ?? 0); + hash = hashNumber(hash, attrs.indent?.right ?? 0); + hash = hashNumber(hash, attrs.indent?.firstLine ?? 0); + hash = hashNumber(hash, attrs.indent?.hanging ?? 0); + hash = hashString(hash, attrs.shading?.fill ?? ''); + hash = hashString(hash, attrs.shading?.color ?? ''); + hash = hashString(hash, attrs.direction ?? ''); + hash = hashString(hash, attrs.rtl ? '1' : ''); + if (attrs.borders) { + hash = hashString(hash, hashParagraphBorders(attrs.borders)); + } + } + + for (const run of runs) { + if ('text' in run && typeof run.text === 'string') { + hash = hashString(hash, run.text); + } + hash = hashNumber(hash, run.pmStart ?? -1); + hash = hashNumber(hash, run.pmEnd ?? -1); + + hash = hashString(hash, getRunStringProp(run, 'color')); + hash = hashString(hash, getRunStringProp(run, 'highlight')); + hash = hashString(hash, getRunBooleanProp(run, 'bold') ? '1' : ''); + hash = hashString(hash, getRunBooleanProp(run, 'italic') ? '1' : ''); + hash = hashNumber(hash, getRunNumberProp(run, 'fontSize')); + hash = hashString(hash, getRunStringProp(run, 'fontFamily')); + hash = hashString(hash, getRunUnderlineStyle(run)); + hash = hashString(hash, getRunUnderlineColor(run)); + hash = hashString(hash, getRunBooleanProp(run, 'strike') ? '1' : ''); + hash = hashString(hash, getRunStringProp(run, 'vertAlign')); + hash = hashNumber(hash, getRunNumberProp(run, 'baselineShift')); + } + } + } + } + } + + if (tableBlock.attrs) { + const tblAttrs = tableBlock.attrs as TableAttrs; + if (tblAttrs.borders) { + hash = hashString(hash, hashTableBorders(tblAttrs.borders)); + } + if (tblAttrs.borderCollapse) { + hash = hashString(hash, tblAttrs.borderCollapse); + } + if (tblAttrs.cellSpacing !== undefined) { + const cs = tblAttrs.cellSpacing; + if (typeof cs === 'number') { + hash = hashNumber(hash, cs); + } else { + const v = (cs as { value?: number; type?: string }).value ?? 0; + const t = (cs as { value?: number; type?: string }).type ?? 'px'; + hash = hashString(hash, `cs:${v}:${t}`); + } + } + if (tblAttrs.sdt) { + hash = hashString(hash, tblAttrs.sdt.type); + hash = hashString(hash, getSdtMetadataLockMode(tblAttrs.sdt)); + hash = hashString(hash, getSdtMetadataId(tblAttrs.sdt)); + } + } + + return [block.id, tableBlock.rows.length, hash.toString(16)].join('|'); + } + + return block.id; +}; + +// --------------------------------------------------------------------------- +// fragmentSignature +// --------------------------------------------------------------------------- + +/** + * Computes a change-detection signature for a layout fragment. + * + * Combines the block-level version with fragment-specific data (line range, + * continuation flags, marker width, drawing geometry, table row range, etc.) + * so that each fragment has a unique identity for incremental re-rendering. + * + * Adapted from painters/dom/src/renderer.ts fragmentSignature(). The painter + * version accepts a BlockLookup map; this version takes a pre-computed + * blockVersion string directly. + */ +export const fragmentSignature = (fragment: Fragment, blockVersion: string): string => { + if (fragment.kind === 'para') { + return [ + blockVersion, + fragment.fromLine, + fragment.toLine, + fragment.continuesFromPrev ? 1 : 0, + fragment.continuesOnNext ? 1 : 0, + fragment.markerWidth ?? '', + ].join('|'); + } + if (fragment.kind === 'list-item') { + return [ + blockVersion, + fragment.itemId, + fragment.fromLine, + fragment.toLine, + fragment.continuesFromPrev ? 1 : 0, + fragment.continuesOnNext ? 1 : 0, + ].join('|'); + } + if (fragment.kind === 'image') { + return [blockVersion, fragment.width, fragment.height].join('|'); + } + if (fragment.kind === 'drawing') { + return [ + blockVersion, + fragment.drawingKind, + fragment.drawingContentId ?? '', + fragment.width, + fragment.height, + fragment.geometry.width, + fragment.geometry.height, + fragment.geometry.rotation ?? 0, + fragment.scale ?? 1, + fragment.zIndex ?? '', + ].join('|'); + } + if (fragment.kind === 'table') { + const partialSig = fragment.partialRow + ? `${fragment.partialRow.fromLineByCell.join(',')}-${fragment.partialRow.toLineByCell.join(',')}-${fragment.partialRow.partialHeight}` + : ''; + return [ + blockVersion, + fragment.fromRow, + fragment.toRow, + fragment.width, + fragment.height, + fragment.continuesFromPrev ? 1 : 0, + fragment.continuesOnNext ? 1 : 0, + fragment.repeatHeaderCount ?? 0, + partialSig, + ].join('|'); + } + return blockVersion; +}; diff --git a/packages/layout-engine/painters/dom/src/renderer.ts b/packages/layout-engine/painters/dom/src/renderer.ts index 4c6b85be44..6407493f8c 100644 --- a/packages/layout-engine/painters/dom/src/renderer.ts +++ b/packages/layout-engine/painters/dom/src/renderer.ts @@ -2693,9 +2693,11 @@ export class DomPainter { newPmStart != null && current.element.dataset.pmStart != null && this.currentMapping.map(Number(current.element.dataset.pmStart)) !== newPmStart; + const resolvedSig = + resolvedItem && 'version' in resolvedItem ? (resolvedItem as { version?: string }).version : undefined; const needsRebuild = this.changedBlocks.has(fragment.blockId) || - current.signature !== fragmentSignature(fragment, this.blockLookup) || + current.signature !== (resolvedSig ?? fragmentSignature(fragment, this.blockLookup)) || sdtBoundaryMismatch || betweenBorderMismatch || mappingUnreliable; @@ -2704,7 +2706,7 @@ export class DomPainter { const replacement = this.renderFragment(fragment, contextBase, sdtBoundary, betweenInfo, resolvedItem); pageEl.replaceChild(replacement, current.element); current.element = replacement; - current.signature = fragmentSignature(fragment, this.blockLookup); + current.signature = resolvedSig ?? fragmentSignature(fragment, this.blockLookup); } else if (this.currentMapping) { // Fragment NOT rebuilt - update position attributes to reflect document changes this.updatePositionAttributes(current.element, this.currentMapping); @@ -2724,11 +2726,13 @@ export class DomPainter { const fresh = this.renderFragment(fragment, contextBase, sdtBoundary, betweenInfo, resolvedItem); pageEl.insertBefore(fresh, pageEl.children[index] ?? null); + const freshSig = + resolvedItem && 'version' in resolvedItem ? (resolvedItem as { version?: string }).version : undefined; nextFragments.push({ key, fragment, element: fresh, - signature: fragmentSignature(fragment, this.blockLookup), + signature: freshSig ?? fragmentSignature(fragment, this.blockLookup), context: contextBase, }); }); @@ -2836,9 +2840,11 @@ export class DomPainter { resolvedItem, ); el.appendChild(fragmentEl); + const initSig = + resolvedItem && 'version' in resolvedItem ? (resolvedItem as { version?: string }).version : undefined; return { key: fragmentKey(fragment), - signature: fragmentSignature(fragment, this.blockLookup), + signature: initSig ?? fragmentSignature(fragment, this.blockLookup), fragment, element: fragmentEl, context: contextBase,