Skip to content
Merged
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
6 changes: 3 additions & 3 deletions devtools/visual-testing/pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

25 changes: 25 additions & 0 deletions packages/layout-engine/contracts/src/cell-spacing.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import type { CellSpacing } from './index.js';

/** 15 twips per pixel (1440 twips/inch ÷ 96 px/inch). */
const TWIPS_PER_PX = 15;

/**
* Resolves table cell spacing to pixels (for border-spacing).
*
* Handles number (px) or `{ type, value }`. The editor/DOCX decoder often stores
* value already in pixels, so we use value as px. If value is in twips (raw OOXML),
* type is `'dxa'` and we convert; otherwise value is treated as px.
*
* @param cellSpacing - Cell spacing value from block attrs
* @returns Cell spacing in pixels (always >= 0)
*/
export function getCellSpacingPx(cellSpacing: CellSpacing | number | null | undefined): number {
if (cellSpacing == null) return 0;
if (typeof cellSpacing === 'number') return Math.max(0, cellSpacing);
const v = cellSpacing.value;
if (typeof v !== 'number' || !Number.isFinite(v)) return 0;
const t = (cellSpacing.type ?? '').toLowerCase();
// Editor/store often has value already in px; raw OOXML has twips (dxa). Only convert when value looks like twips (large).
const asPx = t === 'dxa' && v >= 20 ? v / TWIPS_PER_PX : v;
return Math.max(0, asPx);
}
13 changes: 13 additions & 0 deletions packages/layout-engine/contracts/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,15 @@ export { OOXML_PCT_DIVISOR, type TableWidthAttr, type TableColumnSpec } from './

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

// Table column rescaling (moved from layout-engine for cross-stage use)
export { rescaleColumnWidths } from './table-column-rescale.js';

// Cell spacing resolution (moved from measuring-dom for cross-stage use)
export { getCellSpacingPx } from './cell-spacing.js';

// OOXML z-index normalization (moved from pm-adapter for cross-stage use)
export { normalizeZIndex, coerceRelativeHeight, isPlainObject, OOXML_Z_INDEX_BASE } from './ooxml-z-index.js';

// Export justify utilities
export {
shouldApplyJustify,
Expand Down Expand Up @@ -1975,6 +1984,10 @@ export type {
ResolvedTextLineItem,
ResolvedDropCapItem,
ResolvedListMarkerItem,
ResolvedTableItem,
ResolvedImageItem,
ResolvedDrawingItem,
} from './resolved-layout.js';
export { isResolvedTableItem, isResolvedImageItem, isResolvedDrawingItem } from './resolved-layout.js';

export * as Engines from './engines/index.js';
59 changes: 59 additions & 0 deletions packages/layout-engine/contracts/src/ooxml-z-index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
/**
* OOXML z-index normalization utilities.
*
* OOXML stores z-order as large relativeHeight numbers (base ~251658240).
* These helpers convert to small positive CSS z-index values.
*/

/** Checks whether `value` is a non-null, non-array object. */
export const isPlainObject = (value: unknown): value is Record<string, unknown> =>
value !== null && typeof value === 'object' && !Array.isArray(value);

/**
* Base value for OOXML relativeHeight z-ordering.
*
* @example
* - 251658240 → 0 (base/background)
* - 251658242 → 2 (slightly above base)
* - 251658291 → 51 (further above)
*/
export const OOXML_Z_INDEX_BASE = 251658240;

/**
* Coerces relativeHeight from OOXML (number or string) to a finite number.
*/
export function coerceRelativeHeight(raw: unknown): number | undefined {
if (typeof raw === 'number' && Number.isFinite(raw)) return raw;
if (typeof raw === 'string' && raw.trim() !== '') {
const n = Number(raw);
if (Number.isFinite(n)) return n;
}
return undefined;
}

/**
* Normalizes z-index from OOXML relativeHeight value.
*
* OOXML uses large numbers starting around 251658240. To preserve the relative
* stacking order, we subtract the base value to get a small positive number
* suitable for CSS z-index. This ensures elements with close relativeHeight
* values maintain their correct stacking order.
*
* @param originalAttributes - The originalAttributes object from ProseMirror node attrs
* @returns Normalized z-index number or undefined if no relativeHeight
*
* @example
* ```typescript
* normalizeZIndex({ relativeHeight: 251658240 }); // 0 (background)
* normalizeZIndex({ relativeHeight: 251658242 }); // 2 (above background)
* normalizeZIndex({ relativeHeight: 251658291 }); // 51 (further above)
* normalizeZIndex({}); // undefined
* normalizeZIndex(null); // undefined
* ```
*/
export function normalizeZIndex(originalAttributes: unknown): number | undefined {
if (!isPlainObject(originalAttributes)) return undefined;
const relativeHeight = coerceRelativeHeight(originalAttributes.relativeHeight);
if (relativeHeight === undefined) return undefined;
return Math.max(0, relativeHeight - OOXML_Z_INDEX_BASE);
}
124 changes: 122 additions & 2 deletions packages/layout-engine/contracts/src/resolved-layout.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { FlowMode, Fragment, Line } from './index.js';
import type { DrawingBlock, FlowMode, Fragment, ImageBlock, Line, TableBlock, TableMeasure } from './index.js';

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

/** Union of all resolved paint item kinds. */
export type ResolvedPaintItem = ResolvedGroupItem | ResolvedFragmentItem;
export type ResolvedPaintItem =
| ResolvedGroupItem
| ResolvedFragmentItem
| ResolvedTableItem
| ResolvedImageItem
| ResolvedDrawingItem;

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

// ============================================================================
// Kind-specific resolved items (PR7: table, image, drawing)
// ============================================================================

/**
* A resolved table fragment with pre-extracted block/measure data.
* Replaces blockLookup.get() in the table render path.
*/
export type ResolvedTableItem = {
kind: 'fragment';
/** Discriminant for table fragments. */
fragmentKind: 'table';
/** Stable identifier matching fragmentKey() semantics from the painter. */
id: string;
/** 0-based page index this item belongs to. */
pageIndex: number;
/** Left position in pixels. */
x: number;
/** Top position in pixels. */
y: number;
/** Width in pixels. */
width: number;
/** Height in pixels (from fragment.height). */
height: number;
/** Stacking order (tables typically don't have zIndex at fragment level). */
zIndex?: number;
/** Block ID — written to data-block-id. */
blockId: string;
/** Index within page.fragments — bridge to legacy rendering. */
fragmentIndex: number;
/** Pre-extracted TableBlock (replaces blockLookup.get()). */
block: TableBlock;
/** Pre-extracted TableMeasure (replaces blockLookup.get()). */
measure: TableMeasure;
/** Pre-computed cell spacing: measure.cellSpacingPx ?? getCellSpacingPx(block.attrs?.cellSpacing). */
cellSpacingPx: number;
/** Pre-computed effective column widths: fragment.columnWidths ?? measure.columnWidths. */
effectiveColumnWidths: number[];
};

/**
* A resolved image fragment with pre-extracted block data.
* Replaces blockLookup.get() in the image render path.
*/
export type ResolvedImageItem = {
kind: 'fragment';
/** Discriminant for image fragments. */
fragmentKind: 'image';
/** Stable identifier matching fragmentKey() semantics from the painter. */
id: string;
/** 0-based page index this item belongs to. */
pageIndex: number;
/** Left position in pixels. */
x: number;
/** Top position in pixels. */
y: number;
/** Width in pixels. */
width: number;
/** Height in pixels. */
height: number;
/** Stacking order for anchored images. */
zIndex?: number;
/** Block ID — written to data-block-id. */
blockId: string;
/** Index within page.fragments — bridge to legacy rendering. */
fragmentIndex: number;
/** Pre-extracted ImageBlock (replaces blockLookup.get()). */
block: ImageBlock;
};

/**
* A resolved drawing fragment with pre-extracted block data.
* Replaces blockLookup.get() in the drawing render path.
*/
export type ResolvedDrawingItem = {
kind: 'fragment';
/** Discriminant for drawing fragments. */
fragmentKind: 'drawing';
/** Stable identifier matching fragmentKey() semantics from the painter. */
id: string;
/** 0-based page index this item belongs to. */
pageIndex: number;
/** Left position in pixels. */
x: number;
/** Top position in pixels. */
y: number;
/** Width in pixels. */
width: number;
/** Height in pixels. */
height: number;
/** Stacking order for anchored drawings. */
zIndex?: number;
/** Block ID — written to data-block-id. */
blockId: string;
/** Index within page.fragments — bridge to legacy rendering. */
fragmentIndex: number;
/** Pre-extracted DrawingBlock (replaces blockLookup.get()). */
block: DrawingBlock;
};

/** Type guard: checks whether a resolved paint item is a ResolvedTableItem. */
export function isResolvedTableItem(item: ResolvedPaintItem): item is ResolvedTableItem {
return item.kind === 'fragment' && 'fragmentKind' in item && item.fragmentKind === 'table' && 'measure' in item;
}

/** Type guard: checks whether a resolved paint item is a ResolvedImageItem. */
export function isResolvedImageItem(item: ResolvedPaintItem): item is ResolvedImageItem {
return item.kind === 'fragment' && 'fragmentKind' in item && item.fragmentKind === 'image' && 'block' in item;
}

/** Type guard: checks whether a resolved paint item is a ResolvedDrawingItem. */
export function isResolvedDrawingItem(item: ResolvedPaintItem): item is ResolvedDrawingItem {
return item.kind === 'fragment' && 'fragmentKind' in item && item.fragmentKind === 'drawing' && 'block' in item;
}

/** Resolved list marker rendering data with pre-computed positioning. */
export type ResolvedListMarkerItem = {
/** Marker text content (e.g., "1.", "a)", bullet). */
Expand Down
34 changes: 34 additions & 0 deletions packages/layout-engine/contracts/src/table-column-rescale.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
/**
* Proportionally rescales table column widths when the measured total width
* exceeds the available fragment width.
*
* Returns `undefined` when no rescaling is needed (total fits within fragment).
* Each column is guaranteed at least 1px; the last column absorbs rounding drift.
*
* @param measureColumnWidths - Measured widths per column (or undefined)
* @param measureTotalWidth - Sum of measured widths plus borders/spacing
* @param fragmentWidth - Available render width for the table
* @returns Rescaled widths array, or undefined if no scaling needed
*/
export function rescaleColumnWidths(
measureColumnWidths: number[] | undefined,
measureTotalWidth: number,
fragmentWidth: number,
): number[] | undefined {
if (
!measureColumnWidths ||
measureColumnWidths.length === 0 ||
measureTotalWidth <= fragmentWidth ||
measureTotalWidth <= 0
) {
return undefined;
}
const scale = fragmentWidth / measureTotalWidth;
const scaled = measureColumnWidths.map((w) => Math.max(1, Math.round(w * scale)));
const scaledSum = scaled.reduce((a, b) => a + b, 0);
const target = Math.round(fragmentWidth);
if (scaledSum !== target && scaled.length > 0) {
scaled[scaled.length - 1] = Math.max(1, scaled[scaled.length - 1] + (target - scaledSum));
}
return scaled;
}
25 changes: 3 additions & 22 deletions packages/layout-engine/layout-engine/src/layout-table.ts
Original file line number Diff line number Diff line change
Expand Up @@ -172,28 +172,9 @@ function resolveTableFrame(
*
* @returns Rescaled column widths if clamping occurred, undefined otherwise.
*/
export function rescaleColumnWidths(
measureColumnWidths: number[] | undefined,
measureTotalWidth: number,
fragmentWidth: number,
): number[] | undefined {
if (
!measureColumnWidths ||
measureColumnWidths.length === 0 ||
measureTotalWidth <= fragmentWidth ||
measureTotalWidth <= 0
) {
return undefined;
}
const scale = fragmentWidth / measureTotalWidth;
const scaled = measureColumnWidths.map((w) => Math.max(1, Math.round(w * scale)));
const scaledSum = scaled.reduce((a, b) => a + b, 0);
const target = Math.round(fragmentWidth);
if (scaledSum !== target && scaled.length > 0) {
scaled[scaled.length - 1] = Math.max(1, scaled[scaled.length - 1] + (target - scaledSum));
}
return scaled;
}
// Canonical implementation moved to @superdoc/contracts; re-imported for local use and re-exported.
export { rescaleColumnWidths } from '@superdoc/contracts';
import { rescaleColumnWidths } from '@superdoc/contracts';

const COLUMN_MIN_WIDTH_PX = 25;
const COLUMN_MAX_WIDTH_PX = 200;
Expand Down
34 changes: 34 additions & 0 deletions packages/layout-engine/layout-resolved/src/resolveDrawing.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import type { DrawingFragment, ResolvedDrawingItem } from '@superdoc/contracts';
import { requireResolvedBlockAndMeasure, type BlockMapEntry } from './resolvedBlockLookup.js';

/** Mirrors fragmentKey() for drawing fragments. */
function resolveDrawingFragmentId(fragment: DrawingFragment): string {
return `drawing:${fragment.blockId}:${fragment.x}:${fragment.y}`;
}

/**
* Resolves a drawing fragment into a ResolvedDrawingItem with the pre-extracted DrawingBlock.
*/
export function resolveDrawingItem(
fragment: DrawingFragment,
fragmentIndex: number,
pageIndex: number,
blockMap: Map<string, BlockMapEntry>,
): ResolvedDrawingItem {
const { block } = requireResolvedBlockAndMeasure(blockMap, fragment.blockId, 'drawing', 'drawing', 'drawing');

return {
kind: 'fragment',
fragmentKind: 'drawing',
id: resolveDrawingFragmentId(fragment),
pageIndex,
x: fragment.x,
y: fragment.y,
width: fragment.width,
height: fragment.height,
zIndex: fragment.isAnchored ? fragment.zIndex : undefined,
blockId: fragment.blockId,
fragmentIndex,
block,
};
}
Loading
Loading