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: 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
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[]>();
});
});
175 changes: 0 additions & 175 deletions packages/layout-engine/painters/dom/src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10857,181 +10857,6 @@ describe('applyRunDataAttributes', () => {
});
});

describe.skip('decoration item synthesis (legacy, deleted in PR2 of SD-2836)', () => {
let mount: HTMLElement;

beforeEach(() => {
mount = document.createElement('div');
document.body.appendChild(mount);
});

afterEach(() => {
document.body.removeChild(mount);
});

it('synthesizes missing header items from legacy setData bridge data', () => {
const mainBlock: FlowBlock = {
kind: 'paragraph',
id: 'main-block',
runs: [{ text: 'Main', fontFamily: 'Arial', fontSize: 16, pmStart: 0, pmEnd: 4 }],
};
const mainMeasure: Measure = {
kind: 'paragraph',
lines: [{ fromRun: 0, fromChar: 0, toRun: 0, toChar: 4, width: 40, ascent: 12, descent: 4, lineHeight: 20 }],
totalHeight: 20,
};
const headerBlock: FlowBlock = {
kind: 'paragraph',
id: 'hf-header-synth',
runs: [{ text: 'Synth Header', fontFamily: 'Arial', fontSize: 14, pmStart: 0, pmEnd: 12 }],
};
const headerMeasure: Measure = {
kind: 'paragraph',
lines: [{ fromRun: 0, fromChar: 0, toRun: 0, toChar: 12, width: 90, ascent: 10, descent: 3, lineHeight: 16 }],
totalHeight: 16,
};
const layout: Layout = {
pageSize: { w: 400, h: 500 },
pages: [{ number: 1, fragments: [] }],
};

const painter = createDomPainter({
blocks: [mainBlock],
measures: [mainMeasure],
headerProvider: () => ({
height: 16,
offset: 0,
fragments: [{ kind: 'para', blockId: 'hf-header-synth', fromLine: 0, toLine: 1, x: 0, y: 0, width: 120 }],
}),
});

painter.setData([mainBlock], [mainMeasure], [headerBlock], [headerMeasure]);
painter.paint(layout, mount);

expect(mount.querySelector('.superdoc-page-header')?.textContent).toContain('Synth Header');
expect(mount.querySelector('.render-error-placeholder')).toBeNull();
});

it('synthesizes missing footer items from direct DomPainterInput bridge data', () => {
const footerBlock: FlowBlock = {
kind: 'paragraph',
id: 'hf-footer-synth',
runs: [{ text: 'Synth Footer', fontFamily: 'Arial', fontSize: 14, pmStart: 0, pmEnd: 12 }],
};
const footerMeasure: Measure = {
kind: 'paragraph',
lines: [{ fromRun: 0, fromChar: 0, toRun: 0, toChar: 12, width: 88, ascent: 10, descent: 3, lineHeight: 16 }],
totalHeight: 16,
};
const layout: Layout = {
pageSize: { w: 400, h: 500 },
pages: [{ number: 1, fragments: [] }],
};

const painter = createDomPainter({
footerProvider: () => ({
height: 16,
offset: 460,
fragments: [{ kind: 'para', blockId: 'hf-footer-synth', fromLine: 0, toLine: 1, x: 0, y: 0, width: 120 }],
}),
});

painter.paint(
{
resolvedLayout: emptyResolved,
sourceLayout: layout,
footerBlocks: [footerBlock],
footerMeasures: [footerMeasure],
},
mount,
);

expect(mount.querySelector('.superdoc-page-footer')?.textContent).toContain('Synth Footer');
expect(mount.querySelector('.render-error-placeholder')).toBeNull();
});

it('validates optional decoration block/measure pairs on direct input', () => {
const painter = createDomPainter({});
const layout: Layout = {
pageSize: { w: 400, h: 500 },
pages: [{ number: 1, fragments: [] }],
};

expect(() =>
painter.paint(
{
resolvedLayout: emptyResolved,
sourceLayout: layout,
headerBlocks: [
{
kind: 'paragraph',
id: 'hf-header-invalid',
runs: [{ text: 'Invalid', fontFamily: 'Arial', fontSize: 12, pmStart: 0, pmEnd: 7 }],
},
],
},
mount,
),
).toThrow('headerBlocks and headerMeasures must both be provided or both be omitted.');
});

it('validates optional decoration block/measure pairs in setData', () => {
const painter = createDomPainter({});

expect(() =>
painter.setData(
[
{
kind: 'paragraph',
id: 'body',
runs: [{ text: 'Body', fontFamily: 'Arial', fontSize: 12, pmStart: 0, pmEnd: 4 }],
},
],
[
{
kind: 'paragraph',
lines: [
{ fromRun: 0, fromChar: 0, toRun: 0, toChar: 4, width: 30, ascent: 10, descent: 3, lineHeight: 16 },
],
totalHeight: 16,
},
],
[
{
kind: 'paragraph',
id: 'hf-header-invalid',
runs: [{ text: 'Invalid', fontFamily: 'Arial', fontSize: 12, pmStart: 0, pmEnd: 7 }],
},
],
),
).toThrow('headerBlocks and headerMeasures must both be provided or both be omitted.');
});

it('uses setResolvedLayout for legacy layout paints', () => {
const painter = createDomPainter({});
const layout: Layout = {
pageSize: { w: 400, h: 500 },
pages: [{ number: 1, fragments: [] }],
};

painter.setResolvedLayout(emptyResolved);

expect(() => painter.paint(layout, mount)).not.toThrow();
expect(mount.querySelector('.superdoc-page')).toBeTruthy();
});

it('creates an empty resolved layout for legacy paints without block data', () => {
const painter = createDomPainter({});
const layout: Layout = {
pageSize: { w: 400, h: 500 },
pages: [{ number: 1, fragments: [] }],
};

expect(() => painter.paint(layout, mount)).not.toThrow();
expect(mount.querySelector('.superdoc-page')).toBeTruthy();
});
});

describe('footer alignment logic', () => {
let mount: HTMLElement;

Expand Down
4 changes: 0 additions & 4 deletions packages/layout-engine/painters/dom/src/renderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,19 +17,15 @@ import type {
ImageFragment,
ImageHyperlink,
ImageRun,
Layout,
Line,
LineSegment,
ListBlock,
ListItemFragment,
ListMeasure,
Measure,
Page,
PageMargins,
ParaFragment,
ParagraphAttrs,
ParagraphBlock,
ParagraphBorder,
ParagraphMeasure,
PositionedDrawingGeometry,
Run,
Expand Down
43 changes: 43 additions & 0 deletions packages/layout-engine/tests/src/architecture-boundaries.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -187,4 +187,47 @@ describe('architecture boundaries', () => {
expectNoViolations(findRelativeImportViolations(srcDir, /from\s+['"].*layout-engine\//));
});
});

describe('Guard D: painter-dom is a dumb final renderer with no upstream dependencies', () => {
it('painter-dom runtime src does not import @superdoc/pm-adapter', () => {
const srcDir = path.join(LAYOUT_ENGINE_ROOT, 'painters/dom/src');
expectNoViolations(findImportViolations(srcDir, '@superdoc/pm-adapter'));
});

it('painter-dom runtime src does not import @superdoc/layout-bridge', () => {
const srcDir = path.join(LAYOUT_ENGINE_ROOT, 'painters/dom/src');
expectNoViolations(findImportViolations(srcDir, '@superdoc/layout-bridge'));
});

it('painter-dom runtime src does not import @superdoc/layout-resolved (test-only utility)', () => {
const srcDir = path.join(LAYOUT_ENGINE_ROOT, 'painters/dom/src');
// _test-utils.ts is test-only and excluded from runtime collection. The
// architecture-boundary check passes when no runtime file imports
// layout-resolved.
const files = collectRuntimeSources(srcDir).filter((f) => !f.endsWith('_test-utils.ts'));
const violations: { file: string; line: string }[] = [];
const pattern = new RegExp(`['"]@superdoc/layout-resolved(?:[/'"]|$)`);
for (const file of files) {
const raw = fs.readFileSync(file, 'utf-8');
const processed = preprocessSource(raw);
const lines = processed.split('\n');
for (const ln of lines) {
if (pattern.test(ln)) {
violations.push({ file: path.relative(LAYOUT_ENGINE_ROOT, file), line: ln.trim() });
}
}
}
expectNoViolations(violations);
});

it('painter-dom runtime src does not import relative pm-adapter paths', () => {
const srcDir = path.join(LAYOUT_ENGINE_ROOT, 'painters/dom/src');
expectNoViolations(findRelativeImportViolations(srcDir, /from\s+['"].*pm-adapter\//));
});

it('painter-dom runtime src does not import relative layout-bridge paths', () => {
const srcDir = path.join(LAYOUT_ENGINE_ROOT, 'painters/dom/src');
expectNoViolations(findRelativeImportViolations(srcDir, /from\s+['"].*layout-bridge\//));
});
});
});
6 changes: 3 additions & 3 deletions pnpm-lock.yaml

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

Loading