Skip to content
Open
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
1 change: 1 addition & 0 deletions packages/layout-engine/layout-bridge/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
"@superdoc/contracts": "workspace:*",
"@superdoc/dom-contract": "workspace:*",
"@superdoc/layout-engine": "workspace:*",
"@superdoc/layout-resolved": "workspace:*",
"@superdoc/measuring-dom": "workspace:*",
"@superdoc/painter-dom": "workspace:*",
"@superdoc/word-layout": "workspace:*"
Expand Down
17 changes: 13 additions & 4 deletions packages/layout-engine/layout-bridge/src/benchmarks/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type { FlowBlock, Layout, ParagraphBlock, ParagraphMeasure, Run } from '@
import type { LayoutOptions } from '@superdoc/layout-engine';
import { measureBlock } from '@superdoc/measuring-dom';
import { createDomPainter } from '@superdoc/painter-dom';
import { resolveLayout } from '@superdoc/layout-resolved';
import { layoutDocument } from '@superdoc/layout-engine';
import { incrementalLayout, measureCache, resolveMeasurementConstraints } from '../incrementalLayout';

Expand Down Expand Up @@ -88,11 +89,14 @@ export async function runBenchmarkScenario(config: BenchmarkConfig): Promise<Ben
const initialDuration = performance.now() - startFull;

const mount = ensureBenchmarkMount();
const painter = createDomPainter({
const painter = createDomPainter({});
const initialResolved = resolveLayout({
layout: initial.layout,
flowMode: 'paginated',
blocks: doc.blocks,
measures: initial.measures,
});
painter.paint(initial.layout, mount);
painter.paint({ resolvedLayout: initialResolved, sourceLayout: initial.layout }, mount);

previousBlocks = doc.blocks;
previousLayout = initial.layout;
Expand All @@ -111,8 +115,13 @@ export async function runBenchmarkScenario(config: BenchmarkConfig): Promise<Ben
const start = performance.now();

const result = await incrementalLayout(previousBlocks, previousLayout, nextBlocks, layoutOptions, measure);
painter.setData?.(nextBlocks, result.measures);
painter.paint(result.layout, mount);
const resolved = resolveLayout({
layout: result.layout,
flowMode: 'paginated',
blocks: nextBlocks,
measures: result.measures,
});
painter.paint({ resolvedLayout: resolved, sourceLayout: result.layout }, mount);
const duration = performance.now() - start;
durations.push(duration);

Expand Down
15 changes: 10 additions & 5 deletions packages/layout-engine/painters/dom/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,23 +14,28 @@ Read-only DOM renderer for the SuperDoc layout engine.

```ts
import { createDomPainter } from '@superdoc/painter-dom';
import { resolveLayout } from '@superdoc/layout-resolved';

const painter = createDomPainter({
blocks, // FlowBlocks used to generate the layout
measures, // Measures (parallel to blocks)
layoutMode: 'vertical' | 'horizontal' | 'book',
pageStyles, // optional style overrides
headerProvider, // optional per-page header decorations
footerProvider, // optional per-page footer decorations
virtualization: { enabled: true, window: 5, overscan: 1 }, // vertical mode only
});

painter.paint(layout, mountElement); // layout comes from @superdoc/layout-engine
painter.setData(blocks, measures); // update data without re-instantiating
const resolvedLayout = resolveLayout({
layout, // from @superdoc/layout-engine
flowMode: 'paginated',
blocks, // FlowBlocks that produced the layout
measures, // Measures (parallel to blocks)
});

painter.paint({ resolvedLayout, sourceLayout: layout }, mountElement);
painter.setProviders(newHeader, newFooter); // optional helper for provider changes
```

Notes:
- Expects `blocks[i]` and `measures[i]` to align with the layout you pass to `paint`.
- The painter takes a pre-computed `DomPainterInput` (`{ resolvedLayout, sourceLayout }`). Callers run `resolveLayout` (from `@superdoc/layout-resolved`) to convert a raw `Layout` + blocks/measures into the resolved form before painting.
- Virtualization is opt-in and only supported in vertical mode (windowed pages with spacers).
- Renderer is read-only: no editing/input handling is included here.
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ const betweenOff: BetweenBorderInfo = {
suppressBottomBorder: false,
gapBelow: 0,
};
import { createDomPainter } from './index.js';
import { createTestPainter } from './test-utils/test-painter.js';
import type {
ParagraphBorders,
ParagraphBorder,
Expand Down Expand Up @@ -1068,7 +1068,7 @@ describe('DomPainter between-border incremental update', () => {
const b1: FlowBlock = { kind: 'paragraph', id: 'b1', runs: [] };
const b2: FlowBlock = { kind: 'paragraph', id: 'b2', runs: [] };

const painter = createDomPainter({ blocks: [b1, b2], measures: [makeMeasure(), makeMeasure()] });
const painter = createTestPainter({ blocks: [b1, b2], measures: [makeMeasure(), makeMeasure()] });
painter.paint(layout, mount);

const page = mount.querySelector('[data-page-number="1"]') as HTMLElement;
Expand Down Expand Up @@ -1116,7 +1116,7 @@ describe('DomPainter between-border incremental update', () => {
attrs: { borders: MATCHING_BORDERS },
};

const painter = createDomPainter({ blocks: [b1, b2], measures: [makeMeasure(), makeMeasure()] });
const painter = createTestPainter({ blocks: [b1, b2], measures: [makeMeasure(), makeMeasure()] });
painter.paint(layout, mount);

const page = mount.querySelector('[data-page-number="1"]') as HTMLElement;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { createDomPainter } from './index.js';
import { createTestPainter } from './test-utils/test-painter.js';
import type { FlowBlock, Layout, Measure } from '@superdoc/contracts';

const DATA_URL =
Expand Down Expand Up @@ -72,7 +72,7 @@ describe('DomPainter clipPath cache invalidation', () => {
],
};

const painter = createDomPainter({ blocks: [imageBlock], measures: [imageMeasure] });
const painter = createTestPainter({ blocks: [imageBlock], measures: [imageMeasure] });
painter.paint(imageLayout, mount);

const wrapperBefore = mount.querySelector('.superdoc-inline-image-clip-wrapper') as HTMLElement;
Expand Down Expand Up @@ -141,7 +141,7 @@ describe('DomPainter clipPath cache invalidation', () => {
],
};

const painter = createDomPainter({ blocks: [imageBlock], measures: [imageMeasure] });
const painter = createTestPainter({ blocks: [imageBlock], measures: [imageMeasure] });
painter.paint(imageLayout, mount);

const fragmentBefore = mount.querySelector('.superdoc-image-fragment') as HTMLElement;
Expand Down Expand Up @@ -216,7 +216,7 @@ describe('DomPainter clipPath cache invalidation', () => {
],
};

const painter = createDomPainter({ blocks: [drawingBlock], measures: [drawingMeasure] });
const painter = createTestPainter({ blocks: [drawingBlock], measures: [drawingMeasure] });
painter.paint(drawingLayout, mount);

const fragmentBefore = mount.querySelector('.superdoc-drawing-fragment') as HTMLElement;
Expand Down
22 changes: 11 additions & 11 deletions packages/layout-engine/painters/dom/src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1529,7 +1529,7 @@ describe('DomPainter', () => {
});

try {
const painter = createDomPainter({ blocks: [tableBlock], measures: [tableMeasure] });
const painter = createTestPainter({ blocks: [tableBlock], measures: [tableMeasure] });
expect(() => painter.paint(tableLayout, mount)).not.toThrow();

const placeholder = mount.querySelector('.render-error-placeholder') as HTMLElement | null;
Expand Down Expand Up @@ -6158,7 +6158,7 @@ describe('DomPainter', () => {
],
};

const painter = createDomPainter({ blocks: [imageBlock], measures: [imageMeasure] });
const painter = createTestPainter({ blocks: [imageBlock], measures: [imageMeasure] });
painter.paint(imageLayout, mount);
};

Expand Down Expand Up @@ -7763,7 +7763,7 @@ describe('ImageFragment (block-level images)', () => {
...(hyperlink ? { hyperlink } : {}),
};
const measure: Measure = { kind: 'image', width: 100, height: 50 };
return createDomPainter({ blocks: [block], measures: [measure] });
return createTestPainter({ blocks: [block], measures: [measure] });
};

it('wraps linked image in <a class="superdoc-link"> with correct href', () => {
Expand Down Expand Up @@ -7814,7 +7814,7 @@ describe('ImageFragment (block-level images)', () => {
pageSize: { w: 400, h: 300 },
pages: [{ number: 1, fragments: [fragment] }],
};
const painter = createDomPainter({ blocks: [block], measures: [measure] });
const painter = createTestPainter({ blocks: [block], measures: [measure] });
painter.paint(layout, mount);

const anchor = mount.querySelector('a.superdoc-link') as HTMLAnchorElement | null;
Expand All @@ -7835,7 +7835,7 @@ describe('ImageFragment (block-level images)', () => {
pageSize: { w: 400, h: 300 },
pages: [{ number: 1, fragments: [fragment] }],
};
const painter = createDomPainter({ blocks: [block], measures: [measure] });
const painter = createTestPainter({ blocks: [block], measures: [measure] });
painter.paint(layout, mount);

const anchor = mount.querySelector('a.superdoc-link');
Expand All @@ -7861,7 +7861,7 @@ describe('ImageFragment (block-level images)', () => {
pageSize: { w: 400, h: 300 },
pages: [{ number: 1, fragments: [fragment] }],
};
const painter = createDomPainter({ blocks: [block], measures: [measure] });
const painter = createTestPainter({ blocks: [block], measures: [measure] });
painter.paint(layout, mount);

const anchor = mount.querySelector('a.superdoc-link');
Expand Down Expand Up @@ -7928,7 +7928,7 @@ describe('URL sanitization security', () => {

describe('normalizeAnchor XSS protection', () => {
let mount: HTMLElement;
let painter: ReturnType<typeof createDomPainter>;
let painter: ReturnType<typeof createTestPainter>;

const createFlowBlockWithLink = (link: unknown): FlowBlock => ({
kind: 'paragraph',
Expand Down Expand Up @@ -8075,7 +8075,7 @@ describe('normalizeAnchor XSS protection', () => {

describe('appendDocLocation XSS protection', () => {
let mount: HTMLElement;
let painter: ReturnType<typeof createDomPainter>;
let painter: ReturnType<typeof createTestPainter>;

const createFlowBlockWithLink = (link: unknown): FlowBlock => ({
kind: 'paragraph',
Expand Down Expand Up @@ -8255,7 +8255,7 @@ describe('appendDocLocation XSS protection', () => {

describe('appendDocLocation edge cases', () => {
let mount: HTMLElement;
let painter: ReturnType<typeof createDomPainter>;
let painter: ReturnType<typeof createTestPainter>;

const createFlowBlockWithLink = (link: unknown): FlowBlock => ({
kind: 'paragraph',
Expand Down Expand Up @@ -8474,7 +8474,7 @@ describe('appendDocLocation edge cases', () => {

describe('Tooltip truncation signaling', () => {
let mount: HTMLElement;
let painter: ReturnType<typeof createDomPainter>;
let painter: ReturnType<typeof createTestPainter>;

const createFlowBlockWithLink = (link: unknown): FlowBlock => ({
kind: 'paragraph',
Expand Down Expand Up @@ -9224,7 +9224,7 @@ describe('Link accessibility - Tooltip aria-describedby', () => {

describe('Link rendering metrics', () => {
let mount: HTMLElement;
let painter: ReturnType<typeof createDomPainter>;
let painter: ReturnType<typeof createTestPainter>;

const createFlowBlockWithLink = (link: unknown): FlowBlock => ({
kind: 'paragraph',
Expand Down
104 changes: 3 additions & 101 deletions packages/layout-engine/painters/dom/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,7 @@
import type { FlowBlock, Layout, Measure, PageMargins, ResolvedLayout, Page } from '@superdoc/contracts';
import { DomPainter } from './renderer.js';
import { resolveLayout } from '@superdoc/layout-resolved';
import type { PageStyles } from './styles.js';
import type {
DomPainterInput,
PageDecorationPayload,
PageDecorationProvider,
PaintSnapshot,
PositionMapping,
Expand Down Expand Up @@ -67,16 +64,6 @@ export type { FlowMode } from './renderer.js';
export type { PageDecorationPayload, PageDecorationProvider } from './renderer.js';

export type DomPainterOptions = {
/**
* Legacy compatibility: initial body block data.
* New callers should pass block data through `paint(input, mount)`.
*/
blocks?: FlowBlock[];
/**
* Legacy compatibility: initial body measures.
* New callers should pass measure data through `paint(input, mount)`.
*/
measures?: Measure[];
pageStyles?: PageStyles;
layoutMode?: LayoutMode;
flowMode?: FlowMode;
Expand Down Expand Up @@ -113,24 +100,8 @@ export type DomPainterOptions = {
onPaintSnapshot?: (snapshot: PaintSnapshot) => void;
};

type LegacyDomPainterState = {
blocks: FlowBlock[];
measures: Measure[];
resolvedLayout: ResolvedLayout | null;
};

export type DomPainterHandle = {
paint(input: DomPainterInput | Layout, mount: HTMLElement, mapping?: PositionMapping): void;
/**
* Legacy compatibility API.
* New callers should pass block/measure data via `paint(input, mount)`.
*/
setData(blocks: FlowBlock[], measures: Measure[]): void;
/**
* Legacy compatibility API.
* New callers should pass resolved data via `paint(input, mount)`.
*/
setResolvedLayout(resolvedLayout: ResolvedLayout | null): void;
paint(input: DomPainterInput, mount: HTMLElement, mapping?: PositionMapping): void;
setProviders(header?: PageDecorationProvider, footer?: PageDecorationProvider): void;
setVirtualizationPins(pageIndices: number[] | null | undefined): void;
getMountedPageIndices(): number[];
Expand All @@ -139,59 +110,7 @@ export type DomPainterHandle = {
setScrollContainer(el: HTMLElement | null): void;
};

function assertRequiredBlockMeasurePair(label: string, blocks: FlowBlock[], measures: Measure[]): void {
if (blocks.length !== measures.length) {
throw new Error(`${label} blocks and measures must have the same length.`);
}
}

function createEmptyResolvedLayout(flowMode: FlowMode | undefined, pageGap: number | undefined): ResolvedLayout {
return {
version: 1,
flowMode: flowMode ?? 'paginated',
pageGap: pageGap ?? 0,
pages: [],
};
}

function isDomPainterInput(value: DomPainterInput | Layout): value is DomPainterInput {
return 'resolvedLayout' in value && 'sourceLayout' in value;
}

function buildLegacyPaintInput(
layout: Layout,
legacyState: LegacyDomPainterState,
flowMode: FlowMode | undefined,
pageGap: number | undefined,
): DomPainterInput {
// Derive a resolved layout from the legacy block/measure state when the caller
// has not supplied one via `setResolvedLayout`. The painter now reads all body
// fragment data from the resolved layout, so an empty resolved layout would
// produce a blank render.
let resolvedLayout: ResolvedLayout;
if (legacyState.resolvedLayout) {
resolvedLayout = legacyState.resolvedLayout;
} else if (legacyState.blocks.length === 0 && legacyState.measures.length === 0) {
resolvedLayout = createEmptyResolvedLayout(flowMode, pageGap);
} else {
resolvedLayout = resolveLayout({
layout,
flowMode: flowMode ?? 'paginated',
blocks: legacyState.blocks,
measures: legacyState.measures,
});
}
return {
resolvedLayout,
sourceLayout: layout,
};
}

export const createDomPainter = (options: DomPainterOptions): DomPainterHandle => {
if ((options.blocks ?? []).length !== (options.measures ?? []).length) {
throw new Error('DomPainter requires the same number of blocks and measures');
}

const painter = new DomPainter({
pageStyles: options.pageStyles,
layoutMode: options.layoutMode,
Expand All @@ -204,26 +123,9 @@ export const createDomPainter = (options: DomPainterOptions): DomPainterHandle =
onPaintSnapshot: options.onPaintSnapshot,
});

const legacyState: LegacyDomPainterState = {
blocks: options.blocks ?? [],
measures: options.measures ?? [],
resolvedLayout: null,
};

return {
paint(input: DomPainterInput | Layout, mount: HTMLElement, mapping?: PositionMapping) {
const normalizedInput = isDomPainterInput(input)
? input
: buildLegacyPaintInput(input, legacyState, options.flowMode, options.pageGap);
painter.paint(normalizedInput, mount, mapping);
},
setData(blocks: FlowBlock[], measures: Measure[]) {
assertRequiredBlockMeasurePair('body', blocks, measures);
legacyState.blocks = blocks;
legacyState.measures = measures;
},
setResolvedLayout(resolvedLayout: ResolvedLayout | null) {
legacyState.resolvedLayout = resolvedLayout;
paint(input: DomPainterInput, mount: HTMLElement, mapping?: PositionMapping) {
painter.paint(input, mount, mapping);
Comment on lines +127 to +128
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep legacy paint callers from crashing at runtime

This change forwards paint() input straight into DomPainter.paint without normalizing raw Layout inputs, but there are still in-repo legacy call sites that pass Layout directly (for example packages/layout-engine/pm-adapter/src/integration.test.ts still does createDomPainter({ blocks, measures }) + paint(layout, mount)). In that scenario DomPainter.paint reads input.sourceLayout, so a raw Layout makes layout undefined and throws at runtime; please migrate remaining callers in the same commit (or add an explicit runtime guard/error) to avoid this break.

Useful? React with 👍 / 👎.

},
setProviders(header?: PageDecorationProvider, footer?: PageDecorationProvider) {
painter.setProviders(header, footer);
Expand Down
Loading
Loading