Skip to content

Commit ffb8b45

Browse files
authored
[5/16] refactor(layout): move change detection into resolved layout stage (#2814)
* refactor(layout): lift page metadata into ResolvedPage * refactor(layout): lift fragment metadata into resolved paint items Add pmStart, pmEnd, continuesFromPrev, continuesOnNext, markerWidth, and metadata fields to resolved paint item types. Populate them in the resolvers and update the painter to prefer resolved item data over legacy Fragment reads with fallbacks. * refactor(layout): pre-compute SDT container keys in resolved layout * refactor(layout): pre-compute paragraph border data in resolved layout * refactor(layout): move change detection into resolved layout stage * fix: avoid duplicate block version hashing in DomPainter
1 parent 5730b6c commit ffb8b45

7 files changed

Lines changed: 1147 additions & 14 deletions

File tree

packages/layout-engine/contracts/src/resolved-layout.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ export type ResolvedLayout = {
2020
flowMode: FlowMode;
2121
/** Gap between pages in pixels (0 when unset). */
2222
pageGap: number;
23+
/** Pre-computed block versions for painter-side cache invalidation. */
24+
blockVersions?: Record<string, string>;
2325
/** Resolved pages with normalized dimensions. */
2426
pages: ResolvedPage[];
2527
/** Document epoch identifier from the source layout. Used for change tracking in the painter. */
@@ -125,6 +127,8 @@ export type ResolvedFragmentItem = {
125127
paragraphBorderHash?: string;
126128
/** Pre-extracted paragraph borders for between-border rendering. */
127129
paragraphBorders?: ParagraphBorders;
130+
/** Pre-computed change-detection signature (blockVersion + fragment-specific data). */
131+
version?: string;
128132
};
129133

130134
/** Resolved paragraph content for non-table paragraph/list-item fragments. */
@@ -241,6 +245,8 @@ export type ResolvedTableItem = {
241245
effectiveColumnWidths: number[];
242246
/** Pre-computed SDT container key for boundary grouping (`structuredContent:<id>` or `documentSection:<id>`). */
243247
sdtContainerKey?: string | null;
248+
/** Pre-computed change-detection signature (blockVersion + fragment-specific data). */
249+
version?: string;
244250
};
245251

246252
/**
@@ -279,6 +285,8 @@ export type ResolvedImageItem = {
279285
metadata?: ImageFragmentMetadata;
280286
/** Pre-computed SDT container key for boundary grouping (typically null for images). */
281287
sdtContainerKey?: string | null;
288+
/** Pre-computed change-detection signature (blockVersion + fragment-specific data). */
289+
version?: string;
282290
};
283291

284292
/**
@@ -315,6 +323,8 @@ export type ResolvedDrawingItem = {
315323
block: DrawingBlock;
316324
/** Pre-computed SDT container key for boundary grouping (typically null for drawings). */
317325
sdtContainerKey?: string | null;
326+
/** Pre-computed change-detection signature (blockVersion + fragment-specific data). */
327+
version?: string;
318328
};
319329

320330
/** Type guard: checks whether a resolved paint item is a ResolvedTableItem. */
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
import type { BorderSpec, CellBorders, Run, TableBorders, TableBorderValue } from '@superdoc/contracts';
2+
3+
/**
4+
* Hash helpers for block version computation.
5+
*
6+
* Duplicated from painters/dom/src/paragraph-hash-utils.ts to avoid a circular
7+
* dependency (painter-dom -> layout-resolved is not allowed). Keep the two
8+
* copies in sync.
9+
*/
10+
11+
// ---------------------------------------------------------------------------
12+
// Table/Cell border hashing
13+
// ---------------------------------------------------------------------------
14+
15+
const isNoneBorder = (value: TableBorderValue): value is { none: true } => {
16+
return typeof value === 'object' && value !== null && 'none' in value && (value as { none: true }).none === true;
17+
};
18+
19+
const isBorderSpec = (value: unknown): value is BorderSpec => {
20+
return typeof value === 'object' && value !== null && !('none' in value);
21+
};
22+
23+
export const hashBorderSpec = (border: BorderSpec): string => {
24+
const parts: string[] = [];
25+
if (border.style !== undefined) parts.push(`s:${border.style}`);
26+
if (border.width !== undefined) parts.push(`w:${border.width}`);
27+
if (border.color !== undefined) parts.push(`c:${border.color}`);
28+
if (border.space !== undefined) parts.push(`sp:${border.space}`);
29+
return parts.join(',');
30+
};
31+
32+
const hashTableBorderValue = (borderValue: TableBorderValue | undefined): string => {
33+
if (borderValue === undefined) return '';
34+
if (borderValue === null) return 'null';
35+
if (isNoneBorder(borderValue)) return 'none';
36+
if (isBorderSpec(borderValue)) {
37+
return hashBorderSpec(borderValue);
38+
}
39+
return '';
40+
};
41+
42+
export const hashTableBorders = (borders: TableBorders | undefined): string => {
43+
if (!borders) return '';
44+
const parts: string[] = [];
45+
if (borders.top !== undefined) parts.push(`t:[${hashTableBorderValue(borders.top)}]`);
46+
if (borders.right !== undefined) parts.push(`r:[${hashTableBorderValue(borders.right)}]`);
47+
if (borders.bottom !== undefined) parts.push(`b:[${hashTableBorderValue(borders.bottom)}]`);
48+
if (borders.left !== undefined) parts.push(`l:[${hashTableBorderValue(borders.left)}]`);
49+
if (borders.insideH !== undefined) parts.push(`ih:[${hashTableBorderValue(borders.insideH)}]`);
50+
if (borders.insideV !== undefined) parts.push(`iv:[${hashTableBorderValue(borders.insideV)}]`);
51+
return parts.join(';');
52+
};
53+
54+
export const hashCellBorders = (borders: CellBorders | undefined): string => {
55+
if (!borders) return '';
56+
const parts: string[] = [];
57+
if (borders.top) parts.push(`t:[${hashBorderSpec(borders.top)}]`);
58+
if (borders.right) parts.push(`r:[${hashBorderSpec(borders.right)}]`);
59+
if (borders.bottom) parts.push(`b:[${hashBorderSpec(borders.bottom)}]`);
60+
if (borders.left) parts.push(`l:[${hashBorderSpec(borders.left)}]`);
61+
return parts.join(';');
62+
};
63+
64+
// ---------------------------------------------------------------------------
65+
// Run property accessors
66+
// ---------------------------------------------------------------------------
67+
68+
const hasStringProp = (run: Run, prop: string): run is Run & Record<string, string> => {
69+
return prop in run && typeof (run as Record<string, unknown>)[prop] === 'string';
70+
};
71+
72+
const hasNumberProp = (run: Run, prop: string): run is Run & Record<string, number> => {
73+
return prop in run && typeof (run as Record<string, unknown>)[prop] === 'number';
74+
};
75+
76+
const hasBooleanProp = (run: Run, prop: string): run is Run & Record<string, boolean> => {
77+
return prop in run && typeof (run as Record<string, unknown>)[prop] === 'boolean';
78+
};
79+
80+
export const getRunStringProp = (run: Run, prop: string): string => {
81+
if (hasStringProp(run, prop)) {
82+
return run[prop];
83+
}
84+
return '';
85+
};
86+
87+
export const getRunNumberProp = (run: Run, prop: string): number => {
88+
if (hasNumberProp(run, prop)) {
89+
return run[prop];
90+
}
91+
return 0;
92+
};
93+
94+
export const getRunBooleanProp = (run: Run, prop: string): boolean => {
95+
if (hasBooleanProp(run, prop)) {
96+
return run[prop];
97+
}
98+
return false;
99+
};
100+
101+
export const getRunUnderlineStyle = (run: Run): string => {
102+
if ('underline' in run && typeof run.underline === 'boolean') {
103+
return run.underline ? 'single' : '';
104+
}
105+
if ('underline' in run && run.underline && typeof run.underline === 'object') {
106+
return (run.underline as { style?: string }).style ?? '';
107+
}
108+
return '';
109+
};
110+
111+
export const getRunUnderlineColor = (run: Run): string => {
112+
if ('underline' in run && run.underline && typeof run.underline === 'object') {
113+
return (run.underline as { color?: string }).color ?? '';
114+
}
115+
return '';
116+
};

0 commit comments

Comments
 (0)