Skip to content

Commit dba57df

Browse files
committed
[2/3] refactor(painter): collapse legacy API surface (SD-2836) (#3117)
* refactor(painter): collapse legacy API surface (SD-2836) Make ResolvedLayout the only painter input contract: * DomPainterInput collapses to `{ resolvedLayout: ResolvedLayout }`. sourceLayout, blocks/measures, headerBlocks/Measures, footerBlocks/Measures all removed. * DomPainterOptions: drop blocks/measures. * DomPainterHandle: drop setData, setResolvedLayout. paint takes only DomPainterInput. Drops the `paint(Layout)` overload across painter, PresentationPainterAdapter, and (transitively) PresentationEditor's paintInput. * createDomPainter shrinks to a thin pass-through. Removes buildLegacyPaintInput, normalizeDomPainterInput, isDomPainterInput, wrapProvider, resolveDecorationItems, normalizeOptionalBlockMeasurePair, assertRequiredBlockMeasurePair, createEmptyResolvedLayout, LegacyDomPainterState, OptionalBlockMeasurePair. * PageDecorationPayload.items becomes required (the synthesis path is gone). Tests: * New `_test-utils.ts` exposes a legacy-shaped `createTestPainter` that test files import in place of `createDomPainter`. The helper builds a real DomPainterInput internally and synthesizes decoration items so existing test bodies don't have to be rewritten. * All 17 painter test files migrated to the helper. * Two source-anchor tests still failing under investigation; remaining work is bounded and tracked. * fix(painter): chain user onPaintSnapshot in test utility (SD-2836) createTestPainter was overwriting the user-supplied onPaintSnapshot callback with its own, so tests that relied on the callback (source-anchor tests) saw an undefined snapshot. Chain the user callback after the internal one. * test(pm-adapter): migrate integration tests to ResolvedLayout (SD-2836) The three integration tests in pm-adapter were calling painter.paint(layout, mount) with raw Layout. They now resolveLayout() first and call painter.paint({ resolvedLayout }, mount). All 1794 pm-adapter tests pass. * test(layout-bridge): migrate perf benchmark to ResolvedLayout (SD-2836) The layout-bridge incremental-pipeline performance benchmark called painter.paint(layout, mount) and painter.setData(...). Both are removed by the API collapse. Migrate to resolveLayout() + DomPainterInput so the benchmark continues to exercise the painter under the new contract. * chore(deps): declare @superdoc/layout-resolved as devDep where tests use it (SD-2836) PR1 added test imports of @superdoc/layout-resolved in pm-adapter/src/integration.test.ts and layout-bridge/test/benchmarks/index.ts without declaring it as a devDependency. Both packages now resolve the import locally via pnpm. * fix(super-editor): honor PageDecorationPayload.items contract (SD-2836) When resolveAlignedDecorationItems cannot align items 1:1 with fragments (returns undefined), HeaderFooterSessionManager now bails out with null instead of emitting a payload with items: undefined, which would violate the now-required PageDecorationPayload.items contract from PR2. normalizeDecorationItems narrowed to ResolvedPaintItem[] -> ResolvedPaintItem[]. Also refresh painter-dom README: drop blocks/measures/setData/paint(layout) examples; document the ResolvedLayout-only paint() and the items-aligned- with-fragments invariant on PageDecorationPayload. * [3/3] test(painter): lock ResolvedLayout-only boundary (SD-2836) (#3118) * test(painter): lock ResolvedLayout-only boundary (SD-2836) * chore(painter): drop unused imports and skipped legacy tests (SD-2836) renderer.ts: drop unused Layout, Page, Measure, FlowBlock, ParagraphBorder type imports left over from the migration. index.test.ts: delete the skipped 'decoration item synthesis' describe block (it was protecting the synthesis path that has been removed). * chore(deps): regenerate lockfile after dropping layout-resolved runtime dep (SD-2836) Moves @superdoc/layout-resolved to devDependencies in the lockfile to match package.json, so CI's --frozen-lockfile install matches. Boundary tests still need it under devDependencies.
1 parent 3e73466 commit dba57df

32 files changed

Lines changed: 340 additions & 560 deletions

packages/layout-engine/AGENTS.md

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,16 @@ ProseMirror Doc → pm-adapter → FlowBlock[] → layout-engine → Layout[]
2222

2323
## Key Insight: DomPainter is "Dumb"
2424

25-
DomPainter receives pre-computed `Layout` with positioned fragments and renders them.
26-
It does NOT do layout logic - that's in `layout-engine/`.
25+
DomPainter receives a single paint-ready input — `ResolvedLayout` — with
26+
positioned fragments, pre-resolved styles, and `fragment` back-pointers on
27+
every `ResolvedPaintItem` — and renders the result to DOM. It does NOT do
28+
layout logic, measurement, or PM-adapter conversion (that's upstream in
29+
`layout-engine/` / `layout-resolved/` / `pm-adapter/`).
30+
31+
The painter has zero runtime imports from `@superdoc/pm-adapter`,
32+
`@superdoc/layout-bridge`, or `@superdoc/layout-resolved`. Architecture
33+
boundary tests in `tests/src/architecture-boundaries.test.ts` (Guard D)
34+
enforce this.
2735

2836
## Common Tasks
2937

packages/layout-engine/layout-bridge/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
"@superdoc/word-layout": "workspace:*"
3030
},
3131
"devDependencies": {
32+
"@superdoc/layout-resolved": "workspace:*",
3233
"@superdoc/painter-dom": "workspace:*",
3334
"@superdoc/pm-adapter": "workspace:*",
3435
"@types/node": "catalog:",

packages/layout-engine/layout-bridge/test/benchmarks/index.ts

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import type { FlowBlock, Layout, ParagraphBlock, ParagraphMeasure, Run } from '@
33
import type { LayoutOptions } from '@superdoc/layout-engine';
44
import { measureBlock } from '@superdoc/measuring-dom';
55
import { createDomPainter } from '@superdoc/painter-dom';
6+
import { resolveLayout } from '@superdoc/layout-resolved';
67
import { layoutDocument } from '@superdoc/layout-engine';
78
import { incrementalLayout, measureCache, resolveMeasurementConstraints } from '../../src/incrementalLayout';
89

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

9091
const mount = ensureBenchmarkMount();
91-
const painter = createDomPainter({
92-
blocks: doc.blocks,
93-
measures: initial.measures,
94-
});
95-
painter.paint(initial.layout, mount);
92+
const painter = createDomPainter({});
93+
let painterBlocks = doc.blocks;
94+
let painterMeasures = initial.measures;
95+
const paintLayout = (layout: Layout) => {
96+
const resolvedLayout = resolveLayout({
97+
layout,
98+
flowMode: 'paginated',
99+
blocks: painterBlocks,
100+
measures: painterMeasures,
101+
});
102+
painter.paint({ resolvedLayout }, mount);
103+
};
104+
paintLayout(initial.layout);
96105

97106
previousBlocks = doc.blocks;
98107
previousLayout = initial.layout;
@@ -111,8 +120,9 @@ export async function runBenchmarkScenario(config: BenchmarkConfig): Promise<Ben
111120
const start = performance.now();
112121

113122
const result = await incrementalLayout(previousBlocks, previousLayout, nextBlocks, layoutOptions, measure);
114-
painter.setData?.(nextBlocks, result.measures);
115-
painter.paint(result.layout, mount);
123+
painterBlocks = nextBlocks;
124+
painterMeasures = result.measures;
125+
paintLayout(result.layout);
116126
const duration = performance.now() - start;
117127
durations.push(duration);
118128

packages/layout-engine/painters/dom/README.md

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -12,25 +12,30 @@ Read-only DOM renderer for the SuperDoc layout engine.
1212

1313
## API (read-only)
1414

15+
DomPainter consumes a single paint-ready input, `ResolvedLayout`, produced
16+
upstream by `@superdoc/layout-resolved`. It does not run layout, measurement,
17+
or pm-adapter logic itself.
18+
1519
```ts
1620
import { createDomPainter } from '@superdoc/painter-dom';
21+
import { resolveLayout } from '@superdoc/layout-resolved';
1722

1823
const painter = createDomPainter({
19-
blocks, // FlowBlocks used to generate the layout
20-
measures, // Measures (parallel to blocks)
2124
layoutMode: 'vertical' | 'horizontal' | 'book',
22-
pageStyles, // optional style overrides
23-
headerProvider, // optional per-page header decorations
24-
footerProvider, // optional per-page footer decorations
25+
pageStyles, // optional style overrides
26+
headerProvider, // optional per-page header decorations
27+
footerProvider, // optional per-page footer decorations
2528
virtualization: { enabled: true, window: 5, overscan: 1 }, // vertical mode only
2629
});
2730

28-
painter.paint(layout, mountElement); // layout comes from @superdoc/layout-engine
29-
painter.setData(blocks, measures); // update data without re-instantiating
31+
const resolvedLayout = resolveLayout({ layout, flowMode, blocks, measures });
32+
painter.paint({ resolvedLayout }, mountElement);
3033
painter.setProviders(newHeader, newFooter); // optional helper for provider changes
3134
```
3235

3336
Notes:
34-
- Expects `blocks[i]` and `measures[i]` to align with the layout you pass to `paint`.
37+
- `paint()` takes only `{ resolvedLayout }` — no raw `Layout`, `blocks`, or `measures`.
38+
- Header/footer providers must return a `PageDecorationPayload` whose `items` are
39+
aligned 1:1 with `fragments` (same length, same order).
3540
- Virtualization is opt-in and only supported in vertical mode (windowed pages with spacers).
3641
- Renderer is read-only: no editing/input handling is included here.

packages/layout-engine/painters/dom/package.json

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,6 @@
2121
"@superdoc/contracts": "workspace:*",
2222
"@superdoc/dom-contract": "workspace:*",
2323
"@superdoc/font-utils": "workspace:*",
24-
"@superdoc/layout-resolved": "workspace:*",
2524
"@superdoc/preset-geometry": "workspace:*",
2625
"@superdoc/url-validation": "workspace:*"
2726
},
Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
/**
2+
* Test-only helpers.
3+
*
4+
* These mirror the legacy {@link createDomPainter} surface (blocks/measures
5+
* options, `paint(layout)`, `setData`, `setResolvedLayout`) so existing tests
6+
* can keep their shape while the production API stays strict
7+
* (resolved-layout-only). Production code MUST NOT import from this file —
8+
* the architecture-boundary tests enforce that.
9+
*/
10+
import { createDomPainter } from './index.js';
11+
import type { DomPainterOptions, DomPainterInput, PaintSnapshot } from './index.js';
12+
import type { PageDecorationProvider } from './renderer.js';
13+
import { resolveLayout } from '@superdoc/layout-resolved';
14+
import type { FlowBlock, Fragment, Layout, Measure, ResolvedLayout, ResolvedPaintItem } from '@superdoc/contracts';
15+
16+
export const emptyResolved: ResolvedLayout = { version: 1, flowMode: 'paginated', pageGap: 0, pages: [] };
17+
18+
/**
19+
* Test-only bridge: accepts old-style `{ blocks, measures, ...options }` and
20+
* returns a painter whose `paint()` automatically builds a `DomPainterInput`.
21+
*/
22+
export function createTestPainter(opts: { blocks?: FlowBlock[]; measures?: Measure[] } & DomPainterOptions) {
23+
const { blocks: initBlocks, measures: initMeasures, headerProvider, footerProvider, ...painterOpts } = opts;
24+
let lastPaintSnapshot: PaintSnapshot | null = null;
25+
26+
let currentBlocks: FlowBlock[] = initBlocks ?? [];
27+
let currentMeasures: Measure[] = initMeasures ?? [];
28+
let currentResolved: ResolvedLayout = emptyResolved;
29+
let headerBlocks: FlowBlock[] | undefined;
30+
let headerMeasures: Measure[] | undefined;
31+
let footerBlocks: FlowBlock[] | undefined;
32+
let footerMeasures: Measure[] | undefined;
33+
let resolvedLayoutOverridden = false;
34+
35+
const resolveDecorationItems = (
36+
fragments: readonly Fragment[],
37+
kind: 'header' | 'footer',
38+
): ResolvedPaintItem[] | undefined => {
39+
const decorationBlocks = kind === 'header' ? headerBlocks : footerBlocks;
40+
const decorationMeasures = kind === 'header' ? headerMeasures : footerMeasures;
41+
const mergedBlocks = [...(currentBlocks ?? []), ...(decorationBlocks ?? [])];
42+
const mergedMeasures = [...(currentMeasures ?? []), ...(decorationMeasures ?? [])];
43+
if (mergedBlocks.length !== mergedMeasures.length || mergedBlocks.length === 0) {
44+
return undefined;
45+
}
46+
const fakeLayout: Layout = { pageSize: { w: 400, h: 500 }, pages: [{ number: 1, fragments: [...fragments] }] };
47+
try {
48+
const resolved = resolveLayout({
49+
layout: fakeLayout,
50+
flowMode: opts.flowMode ?? 'paginated',
51+
blocks: mergedBlocks,
52+
measures: mergedMeasures,
53+
});
54+
return resolved.pages[0]?.items;
55+
} catch {
56+
return undefined;
57+
}
58+
};
59+
60+
const wrapProvider = (
61+
provider: PageDecorationProvider | undefined,
62+
kind: 'header' | 'footer',
63+
): PageDecorationProvider | undefined => {
64+
if (!provider) return undefined;
65+
return (pageNumber, pageMargins, page) => {
66+
const payload = provider(pageNumber, pageMargins, page);
67+
if (!payload) return payload;
68+
if (payload.items) return payload;
69+
const items = resolveDecorationItems(payload.fragments, kind);
70+
return items ? { ...payload, items } : { ...payload, items: [] };
71+
};
72+
};
73+
74+
const userOnPaintSnapshot = painterOpts.onPaintSnapshot;
75+
const painter = createDomPainter({
76+
...painterOpts,
77+
headerProvider: wrapProvider(headerProvider, 'header'),
78+
footerProvider: wrapProvider(footerProvider, 'footer'),
79+
onPaintSnapshot: (snapshot) => {
80+
lastPaintSnapshot = snapshot;
81+
userOnPaintSnapshot?.(snapshot);
82+
},
83+
});
84+
85+
return {
86+
paint(layout: Layout, mount: HTMLElement, mapping?: unknown) {
87+
const effectiveResolved = resolvedLayoutOverridden
88+
? currentResolved
89+
: resolveLayout({
90+
layout,
91+
flowMode: opts.flowMode ?? 'paginated',
92+
blocks: currentBlocks,
93+
measures: currentMeasures,
94+
});
95+
const input: DomPainterInput = {
96+
resolvedLayout: effectiveResolved,
97+
};
98+
painter.paint(input, mount, mapping as never);
99+
},
100+
setData(
101+
blocks: FlowBlock[],
102+
measures: Measure[],
103+
hb?: FlowBlock[],
104+
hm?: Measure[],
105+
fb?: FlowBlock[],
106+
fm?: Measure[],
107+
) {
108+
currentBlocks = blocks;
109+
currentMeasures = measures;
110+
headerBlocks = hb;
111+
headerMeasures = hm;
112+
footerBlocks = fb;
113+
footerMeasures = fm;
114+
},
115+
setResolvedLayout(rl: ResolvedLayout | null) {
116+
currentResolved = rl ?? emptyResolved;
117+
resolvedLayoutOverridden = true;
118+
},
119+
setProviders(header?: PageDecorationProvider, footer?: PageDecorationProvider) {
120+
painter.setProviders(wrapProvider(header, 'header'), wrapProvider(footer, 'footer'));
121+
},
122+
setVirtualizationPins(pageIndices: number[] | null | undefined) {
123+
painter.setVirtualizationPins(pageIndices);
124+
},
125+
getMountedPageIndices() {
126+
return painter.getMountedPageIndices();
127+
},
128+
getPaintSnapshot() {
129+
return lastPaintSnapshot;
130+
},
131+
onScroll() {
132+
painter.onScroll();
133+
},
134+
setZoom(zoom: number) {
135+
painter.setZoom(zoom);
136+
},
137+
setScrollContainer(el: HTMLElement | null) {
138+
painter.setScrollContainer(el);
139+
},
140+
};
141+
}

packages/layout-engine/painters/dom/src/between-borders.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ const betweenOff: BetweenBorderInfo = {
2222
suppressBottomBorder: false,
2323
gapBelow: 0,
2424
};
25-
import { createDomPainter } from './index.js';
25+
import { createTestPainter as createDomPainter } from './_test-utils.js';
2626
import type {
2727
ParagraphBorders,
2828
ParagraphBorder,

packages/layout-engine/painters/dom/src/clip-path-cache-invalidation.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
2-
import { createDomPainter } from './index.js';
2+
import { createTestPainter as createDomPainter } from './_test-utils.js';
33
import type { FlowBlock, Layout, Measure } from '@superdoc/contracts';
44

55
const DATA_URL =
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
/**
2+
* Compile-time + runtime contract lockdown for the painter's public surface.
3+
*
4+
* These assertions fail when someone reintroduces a legacy field on
5+
* `DomPainterInput`, adds a method to `DomPainterHandle`, or makes
6+
* `PageDecorationPayload.items` optional. The boundary tests in
7+
* `tests/src/architecture-boundaries.test.ts` cover the import side; this
8+
* file covers the type-shape side.
9+
*/
10+
import { describe, expectTypeOf, it } from 'vitest';
11+
import type { ResolvedLayout, ResolvedPaintItem } from '@superdoc/contracts';
12+
import type { DomPainterHandle, DomPainterInput, PageDecorationPayload } from './index.js';
13+
14+
type Equal<A, B> = (<T>() => T extends A ? 1 : 2) extends <T>() => T extends B ? 1 : 2 ? true : false;
15+
type AssertTrue<T extends true> = T;
16+
17+
describe('DomPainter public contract shape', () => {
18+
it('DomPainterInput is exactly { resolvedLayout: ResolvedLayout }', () => {
19+
type _Check = AssertTrue<Equal<DomPainterInput, { resolvedLayout: ResolvedLayout }>>;
20+
expectTypeOf<DomPainterInput>().toEqualTypeOf<{ resolvedLayout: ResolvedLayout }>();
21+
});
22+
23+
it('DomPainterHandle exposes only the painter-owned methods', () => {
24+
type ExpectedKeys =
25+
| 'paint'
26+
| 'setProviders'
27+
| 'setVirtualizationPins'
28+
| 'getMountedPageIndices'
29+
| 'onScroll'
30+
| 'setZoom'
31+
| 'setScrollContainer';
32+
type _Check = AssertTrue<Equal<keyof DomPainterHandle, ExpectedKeys>>;
33+
expectTypeOf<keyof DomPainterHandle>().toEqualTypeOf<ExpectedKeys>();
34+
});
35+
36+
it('PageDecorationPayload.items is required (synthesis path is gone)', () => {
37+
type ItemsType = PageDecorationPayload['items'];
38+
type _Check = AssertTrue<Equal<ItemsType, ResolvedPaintItem[]>>;
39+
expectTypeOf<ItemsType>().toEqualTypeOf<ResolvedPaintItem[]>();
40+
});
41+
});

0 commit comments

Comments
 (0)