diff --git a/packages/layout-engine/AGENTS.md b/packages/layout-engine/AGENTS.md index 4ec58703c2..cfcc5f29cf 100644 --- a/packages/layout-engine/AGENTS.md +++ b/packages/layout-engine/AGENTS.md @@ -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 diff --git a/packages/layout-engine/painters/dom/package.json b/packages/layout-engine/painters/dom/package.json index cf39d4348f..3410206a3f 100644 --- a/packages/layout-engine/painters/dom/package.json +++ b/packages/layout-engine/painters/dom/package.json @@ -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:*" }, diff --git a/packages/layout-engine/painters/dom/src/contract-shape.test.ts b/packages/layout-engine/painters/dom/src/contract-shape.test.ts new file mode 100644 index 0000000000..b502ee1fb5 --- /dev/null +++ b/packages/layout-engine/painters/dom/src/contract-shape.test.ts @@ -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 = (() => T extends A ? 1 : 2) extends () => T extends B ? 1 : 2 ? true : false; +type AssertTrue = T; + +describe('DomPainter public contract shape', () => { + it('DomPainterInput is exactly { resolvedLayout: ResolvedLayout }', () => { + type _Check = AssertTrue>; + expectTypeOf().toEqualTypeOf<{ resolvedLayout: ResolvedLayout }>(); + }); + + it('DomPainterHandle exposes only the painter-owned methods', () => { + type ExpectedKeys = + | 'paint' + | 'setProviders' + | 'setVirtualizationPins' + | 'getMountedPageIndices' + | 'onScroll' + | 'setZoom' + | 'setScrollContainer'; + type _Check = AssertTrue>; + expectTypeOf().toEqualTypeOf(); + }); + + it('PageDecorationPayload.items is required (synthesis path is gone)', () => { + type ItemsType = PageDecorationPayload['items']; + type _Check = AssertTrue>; + expectTypeOf().toEqualTypeOf(); + }); +}); diff --git a/packages/layout-engine/painters/dom/src/index.test.ts b/packages/layout-engine/painters/dom/src/index.test.ts index 6fa1edd5f7..ef26279d6d 100644 --- a/packages/layout-engine/painters/dom/src/index.test.ts +++ b/packages/layout-engine/painters/dom/src/index.test.ts @@ -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; diff --git a/packages/layout-engine/painters/dom/src/renderer.ts b/packages/layout-engine/painters/dom/src/renderer.ts index c3b3c687c8..a2412aa72c 100644 --- a/packages/layout-engine/painters/dom/src/renderer.ts +++ b/packages/layout-engine/painters/dom/src/renderer.ts @@ -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, diff --git a/packages/layout-engine/tests/src/architecture-boundaries.test.ts b/packages/layout-engine/tests/src/architecture-boundaries.test.ts index 9c4d398b57..913df399cb 100644 --- a/packages/layout-engine/tests/src/architecture-boundaries.test.ts +++ b/packages/layout-engine/tests/src/architecture-boundaries.test.ts @@ -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\//)); + }); + }); }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d1c132fd71..56146322d3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2654,9 +2654,6 @@ importers: '@superdoc/font-utils': specifier: workspace:* version: link:../../../../shared/font-utils - '@superdoc/layout-resolved': - specifier: workspace:* - version: link:../../layout-resolved '@superdoc/preset-geometry': specifier: workspace:* version: link:../../../preset-geometry @@ -2667,6 +2664,9 @@ importers: '@superdoc/layout-engine': specifier: workspace:* version: link:../../layout-engine + '@superdoc/layout-resolved': + specifier: workspace:* + version: link:../../layout-resolved vitest: specifier: 'catalog:' version: 3.2.4(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/debug@4.1.13)(@types/node@25.6.0)(esbuild@0.27.7)(happy-dom@20.4.0)(jiti@2.6.1)(jsdom@27.3.0(canvas@3.2.3))(less@4.4.2)(sass@1.97.3)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)