Skip to content

Commit 334c33a

Browse files
authored
refactor(layout): move table rendering into layout-resolved (#2615)
* refactor(layout): move table rendering into layout-resolved * chore: fix locks * chore: fix tests
1 parent 85581b5 commit 334c33a

23 files changed

Lines changed: 1132 additions & 644 deletions

devtools/visual-testing/pnpm-lock.yaml

Lines changed: 3 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import type { CellSpacing } from './index.js';
2+
3+
/** 15 twips per pixel (1440 twips/inch ÷ 96 px/inch). */
4+
const TWIPS_PER_PX = 15;
5+
6+
/**
7+
* Resolves table cell spacing to pixels (for border-spacing).
8+
*
9+
* Handles number (px) or `{ type, value }`. The editor/DOCX decoder often stores
10+
* value already in pixels, so we use value as px. If value is in twips (raw OOXML),
11+
* type is `'dxa'` and we convert; otherwise value is treated as px.
12+
*
13+
* @param cellSpacing - Cell spacing value from block attrs
14+
* @returns Cell spacing in pixels (always >= 0)
15+
*/
16+
export function getCellSpacingPx(cellSpacing: CellSpacing | number | null | undefined): number {
17+
if (cellSpacing == null) return 0;
18+
if (typeof cellSpacing === 'number') return Math.max(0, cellSpacing);
19+
const v = cellSpacing.value;
20+
if (typeof v !== 'number' || !Number.isFinite(v)) return 0;
21+
const t = (cellSpacing.type ?? '').toLowerCase();
22+
// Editor/store often has value already in px; raw OOXML has twips (dxa). Only convert when value looks like twips (large).
23+
const asPx = t === 'dxa' && v >= 20 ? v / TWIPS_PER_PX : v;
24+
return Math.max(0, asPx);
25+
}

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

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,15 @@ export { OOXML_PCT_DIVISOR, type TableWidthAttr, type TableColumnSpec } from './
99

1010
export { effectiveTableCellSpacing } from './table-cell-spacing.js';
1111

12+
// Table column rescaling (moved from layout-engine for cross-stage use)
13+
export { rescaleColumnWidths } from './table-column-rescale.js';
14+
15+
// Cell spacing resolution (moved from measuring-dom for cross-stage use)
16+
export { getCellSpacingPx } from './cell-spacing.js';
17+
18+
// OOXML z-index normalization (moved from pm-adapter for cross-stage use)
19+
export { normalizeZIndex, coerceRelativeHeight, isPlainObject, OOXML_Z_INDEX_BASE } from './ooxml-z-index.js';
20+
1221
// Export justify utilities
1322
export {
1423
shouldApplyJustify,
@@ -1975,6 +1984,10 @@ export type {
19751984
ResolvedTextLineItem,
19761985
ResolvedDropCapItem,
19771986
ResolvedListMarkerItem,
1987+
ResolvedTableItem,
1988+
ResolvedImageItem,
1989+
ResolvedDrawingItem,
19781990
} from './resolved-layout.js';
1991+
export { isResolvedTableItem, isResolvedImageItem, isResolvedDrawingItem } from './resolved-layout.js';
19791992

19801993
export * as Engines from './engines/index.js';
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
/**
2+
* OOXML z-index normalization utilities.
3+
*
4+
* OOXML stores z-order as large relativeHeight numbers (base ~251658240).
5+
* These helpers convert to small positive CSS z-index values.
6+
*/
7+
8+
/** Checks whether `value` is a non-null, non-array object. */
9+
export const isPlainObject = (value: unknown): value is Record<string, unknown> =>
10+
value !== null && typeof value === 'object' && !Array.isArray(value);
11+
12+
/**
13+
* Base value for OOXML relativeHeight z-ordering.
14+
*
15+
* @example
16+
* - 251658240 → 0 (base/background)
17+
* - 251658242 → 2 (slightly above base)
18+
* - 251658291 → 51 (further above)
19+
*/
20+
export const OOXML_Z_INDEX_BASE = 251658240;
21+
22+
/**
23+
* Coerces relativeHeight from OOXML (number or string) to a finite number.
24+
*/
25+
export function coerceRelativeHeight(raw: unknown): number | undefined {
26+
if (typeof raw === 'number' && Number.isFinite(raw)) return raw;
27+
if (typeof raw === 'string' && raw.trim() !== '') {
28+
const n = Number(raw);
29+
if (Number.isFinite(n)) return n;
30+
}
31+
return undefined;
32+
}
33+
34+
/**
35+
* Normalizes z-index from OOXML relativeHeight value.
36+
*
37+
* OOXML uses large numbers starting around 251658240. To preserve the relative
38+
* stacking order, we subtract the base value to get a small positive number
39+
* suitable for CSS z-index. This ensures elements with close relativeHeight
40+
* values maintain their correct stacking order.
41+
*
42+
* @param originalAttributes - The originalAttributes object from ProseMirror node attrs
43+
* @returns Normalized z-index number or undefined if no relativeHeight
44+
*
45+
* @example
46+
* ```typescript
47+
* normalizeZIndex({ relativeHeight: 251658240 }); // 0 (background)
48+
* normalizeZIndex({ relativeHeight: 251658242 }); // 2 (above background)
49+
* normalizeZIndex({ relativeHeight: 251658291 }); // 51 (further above)
50+
* normalizeZIndex({}); // undefined
51+
* normalizeZIndex(null); // undefined
52+
* ```
53+
*/
54+
export function normalizeZIndex(originalAttributes: unknown): number | undefined {
55+
if (!isPlainObject(originalAttributes)) return undefined;
56+
const relativeHeight = coerceRelativeHeight(originalAttributes.relativeHeight);
57+
if (relativeHeight === undefined) return undefined;
58+
return Math.max(0, relativeHeight - OOXML_Z_INDEX_BASE);
59+
}

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

Lines changed: 122 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { FlowMode, Fragment, Line } from './index.js';
1+
import type { DrawingBlock, FlowMode, Fragment, ImageBlock, Line, TableBlock, TableMeasure } from './index.js';
22

33
/** A fully resolved layout ready for the next-generation paint pipeline. */
44
export type ResolvedLayout = {
@@ -29,7 +29,12 @@ export type ResolvedPage = {
2929
};
3030

3131
/** Union of all resolved paint item kinds. */
32-
export type ResolvedPaintItem = ResolvedGroupItem | ResolvedFragmentItem;
32+
export type ResolvedPaintItem =
33+
| ResolvedGroupItem
34+
| ResolvedFragmentItem
35+
| ResolvedTableItem
36+
| ResolvedImageItem
37+
| ResolvedDrawingItem;
3338

3439
/** A group of nested resolved paint items (for future use). */
3540
export type ResolvedGroupItem = {
@@ -139,6 +144,121 @@ export type ResolvedDropCapItem = {
139144
height?: number;
140145
};
141146

147+
// ============================================================================
148+
// Kind-specific resolved items (PR7: table, image, drawing)
149+
// ============================================================================
150+
151+
/**
152+
* A resolved table fragment with pre-extracted block/measure data.
153+
* Replaces blockLookup.get() in the table render path.
154+
*/
155+
export type ResolvedTableItem = {
156+
kind: 'fragment';
157+
/** Discriminant for table fragments. */
158+
fragmentKind: 'table';
159+
/** Stable identifier matching fragmentKey() semantics from the painter. */
160+
id: string;
161+
/** 0-based page index this item belongs to. */
162+
pageIndex: number;
163+
/** Left position in pixels. */
164+
x: number;
165+
/** Top position in pixels. */
166+
y: number;
167+
/** Width in pixels. */
168+
width: number;
169+
/** Height in pixels (from fragment.height). */
170+
height: number;
171+
/** Stacking order (tables typically don't have zIndex at fragment level). */
172+
zIndex?: number;
173+
/** Block ID — written to data-block-id. */
174+
blockId: string;
175+
/** Index within page.fragments — bridge to legacy rendering. */
176+
fragmentIndex: number;
177+
/** Pre-extracted TableBlock (replaces blockLookup.get()). */
178+
block: TableBlock;
179+
/** Pre-extracted TableMeasure (replaces blockLookup.get()). */
180+
measure: TableMeasure;
181+
/** Pre-computed cell spacing: measure.cellSpacingPx ?? getCellSpacingPx(block.attrs?.cellSpacing). */
182+
cellSpacingPx: number;
183+
/** Pre-computed effective column widths: fragment.columnWidths ?? measure.columnWidths. */
184+
effectiveColumnWidths: number[];
185+
};
186+
187+
/**
188+
* A resolved image fragment with pre-extracted block data.
189+
* Replaces blockLookup.get() in the image render path.
190+
*/
191+
export type ResolvedImageItem = {
192+
kind: 'fragment';
193+
/** Discriminant for image fragments. */
194+
fragmentKind: 'image';
195+
/** Stable identifier matching fragmentKey() semantics from the painter. */
196+
id: string;
197+
/** 0-based page index this item belongs to. */
198+
pageIndex: number;
199+
/** Left position in pixels. */
200+
x: number;
201+
/** Top position in pixels. */
202+
y: number;
203+
/** Width in pixels. */
204+
width: number;
205+
/** Height in pixels. */
206+
height: number;
207+
/** Stacking order for anchored images. */
208+
zIndex?: number;
209+
/** Block ID — written to data-block-id. */
210+
blockId: string;
211+
/** Index within page.fragments — bridge to legacy rendering. */
212+
fragmentIndex: number;
213+
/** Pre-extracted ImageBlock (replaces blockLookup.get()). */
214+
block: ImageBlock;
215+
};
216+
217+
/**
218+
* A resolved drawing fragment with pre-extracted block data.
219+
* Replaces blockLookup.get() in the drawing render path.
220+
*/
221+
export type ResolvedDrawingItem = {
222+
kind: 'fragment';
223+
/** Discriminant for drawing fragments. */
224+
fragmentKind: 'drawing';
225+
/** Stable identifier matching fragmentKey() semantics from the painter. */
226+
id: string;
227+
/** 0-based page index this item belongs to. */
228+
pageIndex: number;
229+
/** Left position in pixels. */
230+
x: number;
231+
/** Top position in pixels. */
232+
y: number;
233+
/** Width in pixels. */
234+
width: number;
235+
/** Height in pixels. */
236+
height: number;
237+
/** Stacking order for anchored drawings. */
238+
zIndex?: number;
239+
/** Block ID — written to data-block-id. */
240+
blockId: string;
241+
/** Index within page.fragments — bridge to legacy rendering. */
242+
fragmentIndex: number;
243+
/** Pre-extracted DrawingBlock (replaces blockLookup.get()). */
244+
block: DrawingBlock;
245+
};
246+
247+
/** Type guard: checks whether a resolved paint item is a ResolvedTableItem. */
248+
export function isResolvedTableItem(item: ResolvedPaintItem): item is ResolvedTableItem {
249+
return item.kind === 'fragment' && 'fragmentKind' in item && item.fragmentKind === 'table' && 'measure' in item;
250+
}
251+
252+
/** Type guard: checks whether a resolved paint item is a ResolvedImageItem. */
253+
export function isResolvedImageItem(item: ResolvedPaintItem): item is ResolvedImageItem {
254+
return item.kind === 'fragment' && 'fragmentKind' in item && item.fragmentKind === 'image' && 'block' in item;
255+
}
256+
257+
/** Type guard: checks whether a resolved paint item is a ResolvedDrawingItem. */
258+
export function isResolvedDrawingItem(item: ResolvedPaintItem): item is ResolvedDrawingItem {
259+
return item.kind === 'fragment' && 'fragmentKind' in item && item.fragmentKind === 'drawing' && 'block' in item;
260+
}
261+
142262
/** Resolved list marker rendering data with pre-computed positioning. */
143263
export type ResolvedListMarkerItem = {
144264
/** Marker text content (e.g., "1.", "a)", bullet). */
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
/**
2+
* Proportionally rescales table column widths when the measured total width
3+
* exceeds the available fragment width.
4+
*
5+
* Returns `undefined` when no rescaling is needed (total fits within fragment).
6+
* Each column is guaranteed at least 1px; the last column absorbs rounding drift.
7+
*
8+
* @param measureColumnWidths - Measured widths per column (or undefined)
9+
* @param measureTotalWidth - Sum of measured widths plus borders/spacing
10+
* @param fragmentWidth - Available render width for the table
11+
* @returns Rescaled widths array, or undefined if no scaling needed
12+
*/
13+
export function rescaleColumnWidths(
14+
measureColumnWidths: number[] | undefined,
15+
measureTotalWidth: number,
16+
fragmentWidth: number,
17+
): number[] | undefined {
18+
if (
19+
!measureColumnWidths ||
20+
measureColumnWidths.length === 0 ||
21+
measureTotalWidth <= fragmentWidth ||
22+
measureTotalWidth <= 0
23+
) {
24+
return undefined;
25+
}
26+
const scale = fragmentWidth / measureTotalWidth;
27+
const scaled = measureColumnWidths.map((w) => Math.max(1, Math.round(w * scale)));
28+
const scaledSum = scaled.reduce((a, b) => a + b, 0);
29+
const target = Math.round(fragmentWidth);
30+
if (scaledSum !== target && scaled.length > 0) {
31+
scaled[scaled.length - 1] = Math.max(1, scaled[scaled.length - 1] + (target - scaledSum));
32+
}
33+
return scaled;
34+
}

packages/layout-engine/layout-engine/src/layout-table.ts

Lines changed: 3 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -172,28 +172,9 @@ function resolveTableFrame(
172172
*
173173
* @returns Rescaled column widths if clamping occurred, undefined otherwise.
174174
*/
175-
export function rescaleColumnWidths(
176-
measureColumnWidths: number[] | undefined,
177-
measureTotalWidth: number,
178-
fragmentWidth: number,
179-
): number[] | undefined {
180-
if (
181-
!measureColumnWidths ||
182-
measureColumnWidths.length === 0 ||
183-
measureTotalWidth <= fragmentWidth ||
184-
measureTotalWidth <= 0
185-
) {
186-
return undefined;
187-
}
188-
const scale = fragmentWidth / measureTotalWidth;
189-
const scaled = measureColumnWidths.map((w) => Math.max(1, Math.round(w * scale)));
190-
const scaledSum = scaled.reduce((a, b) => a + b, 0);
191-
const target = Math.round(fragmentWidth);
192-
if (scaledSum !== target && scaled.length > 0) {
193-
scaled[scaled.length - 1] = Math.max(1, scaled[scaled.length - 1] + (target - scaledSum));
194-
}
195-
return scaled;
196-
}
175+
// Canonical implementation moved to @superdoc/contracts; re-imported for local use and re-exported.
176+
export { rescaleColumnWidths } from '@superdoc/contracts';
177+
import { rescaleColumnWidths } from '@superdoc/contracts';
197178

198179
const COLUMN_MIN_WIDTH_PX = 25;
199180
const COLUMN_MAX_WIDTH_PX = 200;
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import type { DrawingFragment, ResolvedDrawingItem } from '@superdoc/contracts';
2+
import { requireResolvedBlockAndMeasure, type BlockMapEntry } from './resolvedBlockLookup.js';
3+
4+
/** Mirrors fragmentKey() for drawing fragments. */
5+
function resolveDrawingFragmentId(fragment: DrawingFragment): string {
6+
return `drawing:${fragment.blockId}:${fragment.x}:${fragment.y}`;
7+
}
8+
9+
/**
10+
* Resolves a drawing fragment into a ResolvedDrawingItem with the pre-extracted DrawingBlock.
11+
*/
12+
export function resolveDrawingItem(
13+
fragment: DrawingFragment,
14+
fragmentIndex: number,
15+
pageIndex: number,
16+
blockMap: Map<string, BlockMapEntry>,
17+
): ResolvedDrawingItem {
18+
const { block } = requireResolvedBlockAndMeasure(blockMap, fragment.blockId, 'drawing', 'drawing', 'drawing');
19+
20+
return {
21+
kind: 'fragment',
22+
fragmentKind: 'drawing',
23+
id: resolveDrawingFragmentId(fragment),
24+
pageIndex,
25+
x: fragment.x,
26+
y: fragment.y,
27+
width: fragment.width,
28+
height: fragment.height,
29+
zIndex: fragment.isAnchored ? fragment.zIndex : undefined,
30+
blockId: fragment.blockId,
31+
fragmentIndex,
32+
block,
33+
};
34+
}

0 commit comments

Comments
 (0)