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
12 changes: 10 additions & 2 deletions packages/layout-engine/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,16 @@ ProseMirror Doc → pm-adapter → FlowBlock[] → layout-engine → Layout[]

## Key Insight: DomPainter is "Dumb"

DomPainter receives pre-computed `Layout` with positioned fragments and renders them.
It does NOT do layout logic - that's in `layout-engine/`.
DomPainter receives a single paint-ready input — `ResolvedLayout` — with
positioned fragments, pre-resolved styles, and `fragment` back-pointers on
every `ResolvedPaintItem` — and renders the result to DOM. It does NOT do
layout logic, measurement, or PM-adapter conversion (that's upstream in
`layout-engine/` / `layout-resolved/` / `pm-adapter/`).

The painter has zero runtime imports from `@superdoc/pm-adapter`,
`@superdoc/layout-bridge`, or `@superdoc/layout-resolved`. Architecture
boundary tests in `tests/src/architecture-boundaries.test.ts` (Guard D)
enforce this.

## Common Tasks

Expand Down
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 @@ -29,6 +29,7 @@
"@superdoc/word-layout": "workspace:*"
},
"devDependencies": {
"@superdoc/layout-resolved": "workspace:*",
"@superdoc/painter-dom": "workspace:*",
"@superdoc/pm-adapter": "workspace:*",
"@types/node": "catalog:",
Expand Down
24 changes: 17 additions & 7 deletions packages/layout-engine/layout-bridge/test/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 '../../src/incrementalLayout';

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

const mount = ensureBenchmarkMount();
const painter = createDomPainter({
blocks: doc.blocks,
measures: initial.measures,
});
painter.paint(initial.layout, mount);
const painter = createDomPainter({});
let painterBlocks = doc.blocks;
let painterMeasures = initial.measures;
const paintLayout = (layout: Layout) => {
const resolvedLayout = resolveLayout({
layout,
flowMode: 'paginated',
blocks: painterBlocks,
measures: painterMeasures,
});
painter.paint({ resolvedLayout }, mount);
};
paintLayout(initial.layout);

previousBlocks = doc.blocks;
previousLayout = initial.layout;
Expand All @@ -111,8 +120,9 @@ 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);
painterBlocks = nextBlocks;
painterMeasures = result.measures;
paintLayout(result.layout);
const duration = performance.now() - start;
durations.push(duration);

Expand Down
21 changes: 13 additions & 8 deletions packages/layout-engine/painters/dom/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,25 +12,30 @@ Read-only DOM renderer for the SuperDoc layout engine.

## API (read-only)

DomPainter consumes a single paint-ready input, `ResolvedLayout`, produced
upstream by `@superdoc/layout-resolved`. It does not run layout, measurement,
or pm-adapter logic itself.

```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
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, flowMode, blocks, measures });
painter.paint({ resolvedLayout }, 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`.
- `paint()` takes only `{ resolvedLayout }` — no raw `Layout`, `blocks`, or `measures`.
- Header/footer providers must return a `PageDecorationPayload` whose `items` are
aligned 1:1 with `fragments` (same length, same order).
- 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.
1 change: 0 additions & 1 deletion packages/layout-engine/painters/dom/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,6 @@
"@superdoc/contracts": "workspace:*",
"@superdoc/dom-contract": "workspace:*",
"@superdoc/font-utils": "workspace:*",
"@superdoc/layout-resolved": "workspace:*",
"@superdoc/preset-geometry": "workspace:*",
"@superdoc/url-validation": "workspace:*"
},
Expand Down
141 changes: 141 additions & 0 deletions packages/layout-engine/painters/dom/src/_test-utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
/**
* Test-only helpers.
*
* These mirror the legacy {@link createDomPainter} surface (blocks/measures
* options, `paint(layout)`, `setData`, `setResolvedLayout`) so existing tests
* can keep their shape while the production API stays strict
* (resolved-layout-only). Production code MUST NOT import from this file —
* the architecture-boundary tests enforce that.
*/
import { createDomPainter } from './index.js';
import type { DomPainterOptions, DomPainterInput, PaintSnapshot } from './index.js';
import type { PageDecorationProvider } from './renderer.js';
import { resolveLayout } from '@superdoc/layout-resolved';
import type { FlowBlock, Fragment, Layout, Measure, ResolvedLayout, ResolvedPaintItem } from '@superdoc/contracts';

export const emptyResolved: ResolvedLayout = { version: 1, flowMode: 'paginated', pageGap: 0, pages: [] };

/**
* Test-only bridge: accepts old-style `{ blocks, measures, ...options }` and
* returns a painter whose `paint()` automatically builds a `DomPainterInput`.
*/
export function createTestPainter(opts: { blocks?: FlowBlock[]; measures?: Measure[] } & DomPainterOptions) {
const { blocks: initBlocks, measures: initMeasures, headerProvider, footerProvider, ...painterOpts } = opts;
let lastPaintSnapshot: PaintSnapshot | null = null;

let currentBlocks: FlowBlock[] = initBlocks ?? [];
let currentMeasures: Measure[] = initMeasures ?? [];
let currentResolved: ResolvedLayout = emptyResolved;
let headerBlocks: FlowBlock[] | undefined;
let headerMeasures: Measure[] | undefined;
let footerBlocks: FlowBlock[] | undefined;
let footerMeasures: Measure[] | undefined;
let resolvedLayoutOverridden = false;

const resolveDecorationItems = (
fragments: readonly Fragment[],
kind: 'header' | 'footer',
): ResolvedPaintItem[] | undefined => {
const decorationBlocks = kind === 'header' ? headerBlocks : footerBlocks;
const decorationMeasures = kind === 'header' ? headerMeasures : footerMeasures;
const mergedBlocks = [...(currentBlocks ?? []), ...(decorationBlocks ?? [])];
const mergedMeasures = [...(currentMeasures ?? []), ...(decorationMeasures ?? [])];
if (mergedBlocks.length !== mergedMeasures.length || mergedBlocks.length === 0) {
return undefined;
}
const fakeLayout: Layout = { pageSize: { w: 400, h: 500 }, pages: [{ number: 1, fragments: [...fragments] }] };
try {
const resolved = resolveLayout({
layout: fakeLayout,
flowMode: opts.flowMode ?? 'paginated',
blocks: mergedBlocks,
measures: mergedMeasures,
});
return resolved.pages[0]?.items;
} catch {
return undefined;
}
};

const wrapProvider = (
provider: PageDecorationProvider | undefined,
kind: 'header' | 'footer',
): PageDecorationProvider | undefined => {
if (!provider) return undefined;
return (pageNumber, pageMargins, page) => {
const payload = provider(pageNumber, pageMargins, page);
if (!payload) return payload;
if (payload.items) return payload;
const items = resolveDecorationItems(payload.fragments, kind);
return items ? { ...payload, items } : { ...payload, items: [] };
};
};

const userOnPaintSnapshot = painterOpts.onPaintSnapshot;
const painter = createDomPainter({
...painterOpts,
headerProvider: wrapProvider(headerProvider, 'header'),
footerProvider: wrapProvider(footerProvider, 'footer'),
onPaintSnapshot: (snapshot) => {
lastPaintSnapshot = snapshot;
userOnPaintSnapshot?.(snapshot);
},
});

return {
paint(layout: Layout, mount: HTMLElement, mapping?: unknown) {
const effectiveResolved = resolvedLayoutOverridden
? currentResolved
: resolveLayout({
layout,
flowMode: opts.flowMode ?? 'paginated',
blocks: currentBlocks,
measures: currentMeasures,
});
const input: DomPainterInput = {
resolvedLayout: effectiveResolved,
};
painter.paint(input, mount, mapping as never);
},
setData(
blocks: FlowBlock[],
measures: Measure[],
hb?: FlowBlock[],
hm?: Measure[],
fb?: FlowBlock[],
fm?: Measure[],
) {
currentBlocks = blocks;
currentMeasures = measures;
headerBlocks = hb;
headerMeasures = hm;
footerBlocks = fb;
footerMeasures = fm;
},
setResolvedLayout(rl: ResolvedLayout | null) {
currentResolved = rl ?? emptyResolved;
resolvedLayoutOverridden = true;
},
setProviders(header?: PageDecorationProvider, footer?: PageDecorationProvider) {
painter.setProviders(wrapProvider(header, 'header'), wrapProvider(footer, 'footer'));
},
setVirtualizationPins(pageIndices: number[] | null | undefined) {
painter.setVirtualizationPins(pageIndices);
},
getMountedPageIndices() {
return painter.getMountedPageIndices();
},
getPaintSnapshot() {
return lastPaintSnapshot;
},
onScroll() {
painter.onScroll();
},
setZoom(zoom: number) {
painter.setZoom(zoom);
},
setScrollContainer(el: HTMLElement | null) {
painter.setScrollContainer(el);
},
};
}
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 as createDomPainter } from './_test-utils.js';
import type {
ParagraphBorders,
ParagraphBorder,
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 as createDomPainter } from './_test-utils.js';
import type { FlowBlock, Layout, Measure } from '@superdoc/contracts';

const DATA_URL =
Expand Down
41 changes: 41 additions & 0 deletions packages/layout-engine/painters/dom/src/contract-shape.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
/**
* Compile-time + runtime contract lockdown for the painter's public surface.
*
* These assertions fail when someone reintroduces a legacy field on
* `DomPainterInput`, adds a method to `DomPainterHandle`, or makes
* `PageDecorationPayload.items` optional. The boundary tests in
* `tests/src/architecture-boundaries.test.ts` cover the import side; this
* file covers the type-shape side.
*/
import { describe, expectTypeOf, it } from 'vitest';
import type { ResolvedLayout, ResolvedPaintItem } from '@superdoc/contracts';
import type { DomPainterHandle, DomPainterInput, PageDecorationPayload } from './index.js';

type Equal<A, B> = (<T>() => T extends A ? 1 : 2) extends <T>() => T extends B ? 1 : 2 ? true : false;
type AssertTrue<T extends true> = T;

describe('DomPainter public contract shape', () => {
it('DomPainterInput is exactly { resolvedLayout: ResolvedLayout }', () => {
type _Check = AssertTrue<Equal<DomPainterInput, { resolvedLayout: ResolvedLayout }>>;
expectTypeOf<DomPainterInput>().toEqualTypeOf<{ resolvedLayout: ResolvedLayout }>();
});

it('DomPainterHandle exposes only the painter-owned methods', () => {
type ExpectedKeys =
| 'paint'
| 'setProviders'
| 'setVirtualizationPins'
| 'getMountedPageIndices'
| 'onScroll'
| 'setZoom'
| 'setScrollContainer';
type _Check = AssertTrue<Equal<keyof DomPainterHandle, ExpectedKeys>>;
expectTypeOf<keyof DomPainterHandle>().toEqualTypeOf<ExpectedKeys>();
});

it('PageDecorationPayload.items is required (synthesis path is gone)', () => {
type ItemsType = PageDecorationPayload['items'];
type _Check = AssertTrue<Equal<ItemsType, ResolvedPaintItem[]>>;
expectTypeOf<ItemsType>().toEqualTypeOf<ResolvedPaintItem[]>();
});
});
Loading
Loading