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/layout-bridge/package.json b/packages/layout-engine/layout-bridge/package.json index 3adebeb0f7..b21462d905 100644 --- a/packages/layout-engine/layout-bridge/package.json +++ b/packages/layout-engine/layout-bridge/package.json @@ -29,6 +29,7 @@ "@superdoc/word-layout": "workspace:*" }, "devDependencies": { + "@superdoc/layout-resolved": "workspace:*", "@superdoc/painter-dom": "workspace:*", "@superdoc/pm-adapter": "workspace:*", "@types/node": "catalog:", diff --git a/packages/layout-engine/layout-bridge/test/benchmarks/index.ts b/packages/layout-engine/layout-bridge/test/benchmarks/index.ts index d3db048972..ec7a812b39 100644 --- a/packages/layout-engine/layout-bridge/test/benchmarks/index.ts +++ b/packages/layout-engine/layout-bridge/test/benchmarks/index.ts @@ -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'; @@ -88,11 +89,19 @@ export async function runBenchmarkScenario(config: BenchmarkConfig): Promise { + const resolvedLayout = resolveLayout({ + layout, + flowMode: 'paginated', + blocks: painterBlocks, + measures: painterMeasures, + }); + painter.paint({ resolvedLayout }, mount); + }; + paintLayout(initial.layout); previousBlocks = doc.blocks; previousLayout = initial.layout; @@ -111,8 +120,9 @@ export async function runBenchmarkScenario(config: BenchmarkConfig): Promise { + 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); + }, + }; +} diff --git a/packages/layout-engine/painters/dom/src/between-borders.test.ts b/packages/layout-engine/painters/dom/src/between-borders.test.ts index fbbfb97cdc..9030fe8f47 100644 --- a/packages/layout-engine/painters/dom/src/between-borders.test.ts +++ b/packages/layout-engine/painters/dom/src/between-borders.test.ts @@ -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, diff --git a/packages/layout-engine/painters/dom/src/clip-path-cache-invalidation.test.ts b/packages/layout-engine/painters/dom/src/clip-path-cache-invalidation.test.ts index 08b8e86005..752b1dfb85 100644 --- a/packages/layout-engine/painters/dom/src/clip-path-cache-invalidation.test.ts +++ b/packages/layout-engine/painters/dom/src/clip-path-cache-invalidation.test.ts @@ -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 = 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 5b63e1640b..ef26279d6d 100644 --- a/packages/layout-engine/painters/dom/src/index.test.ts +++ b/packages/layout-engine/painters/dom/src/index.test.ts @@ -105,7 +105,6 @@ function createTestPainter(opts: { blocks?: FlowBlock[]; measures?: Measure[] } }); const input: DomPainterInput = { resolvedLayout: effectiveResolved, - sourceLayout: layout, }; painter.paint(input, mount, mapping as any); }, @@ -1598,7 +1597,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; @@ -6852,7 +6851,7 @@ describe('DomPainter', () => { ], }; - const painter = createDomPainter({ blocks: [imageBlock], measures: [imageMeasure] }); + const painter = createTestPainter({ blocks: [imageBlock], measures: [imageMeasure] }); painter.paint(imageLayout, mount); }; @@ -8457,7 +8456,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 with correct href', () => { @@ -8508,7 +8507,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; @@ -8529,7 +8528,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'); @@ -8555,7 +8554,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'); @@ -8622,7 +8621,7 @@ describe('URL sanitization security', () => { describe('normalizeAnchor XSS protection', () => { let mount: HTMLElement; - let painter: ReturnType; + let painter: ReturnType; const createFlowBlockWithLink = (link: unknown): FlowBlock => ({ kind: 'paragraph', @@ -8769,7 +8768,7 @@ describe('normalizeAnchor XSS protection', () => { describe('appendDocLocation XSS protection', () => { let mount: HTMLElement; - let painter: ReturnType; + let painter: ReturnType; const createFlowBlockWithLink = (link: unknown): FlowBlock => ({ kind: 'paragraph', @@ -8949,7 +8948,7 @@ describe('appendDocLocation XSS protection', () => { describe('appendDocLocation edge cases', () => { let mount: HTMLElement; - let painter: ReturnType; + let painter: ReturnType; const createFlowBlockWithLink = (link: unknown): FlowBlock => ({ kind: 'paragraph', @@ -9168,7 +9167,7 @@ describe('appendDocLocation edge cases', () => { describe('Tooltip truncation signaling', () => { let mount: HTMLElement; - let painter: ReturnType; + let painter: ReturnType; const createFlowBlockWithLink = (link: unknown): FlowBlock => ({ kind: 'paragraph', @@ -9918,7 +9917,7 @@ describe('Link accessibility - Tooltip aria-describedby', () => { describe('Link rendering metrics', () => { let mount: HTMLElement; - let painter: ReturnType; + let painter: ReturnType; const createFlowBlockWithLink = (link: unknown): FlowBlock => ({ kind: 'paragraph', @@ -10858,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/index.ts b/packages/layout-engine/painters/dom/src/index.ts index a6567c59a9..b15bce4881 100644 --- a/packages/layout-engine/painters/dom/src/index.ts +++ b/packages/layout-engine/painters/dom/src/index.ts @@ -1,15 +1,4 @@ -import type { - FlowBlock, - Fragment, - Layout, - Measure, - PageMargins, - ResolvedLayout, - Page, - ResolvedPaintItem, -} from '@superdoc/contracts'; import { DomPainter } from './renderer.js'; -import { resolveLayout } from '@superdoc/layout-resolved'; import type { PageStyles } from './styles.js'; import type { DomPainterInput, @@ -76,16 +65,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; @@ -122,40 +101,8 @@ export type DomPainterOptions = { onPaintSnapshot?: (snapshot: PaintSnapshot) => void; }; -type LegacyDomPainterState = { - blocks: FlowBlock[]; - measures: Measure[]; - headerBlocks?: FlowBlock[]; - headerMeasures?: Measure[]; - footerBlocks?: FlowBlock[]; - footerMeasures?: Measure[]; - resolvedLayout: ResolvedLayout | null; -}; - -type OptionalBlockMeasurePair = { - blocks: FlowBlock[]; - measures: Measure[]; -}; - 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[], - headerBlocks?: FlowBlock[], - headerMeasures?: Measure[], - footerBlocks?: FlowBlock[], - footerMeasures?: 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[]; @@ -164,207 +111,22 @@ 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 normalizeOptionalBlockMeasurePair( - label: 'body' | 'header' | 'footer', - blocks: FlowBlock[] | undefined, - measures: Measure[] | undefined, -): OptionalBlockMeasurePair | undefined { - const hasBlocks = blocks !== undefined; - const hasMeasures = measures !== undefined; - - if (hasBlocks !== hasMeasures) { - if (label === 'body') { - throw new Error('blocks and measures must both be provided or both be omitted.'); - } - throw new Error(`${label}Blocks and ${label}Measures must both be provided or both be omitted.`); - } - - if (!hasBlocks || !hasMeasures) { - return undefined; - } - - assertRequiredBlockMeasurePair(label, blocks, measures); - return { blocks, measures }; -} - -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 normalizeDomPainterInput(input: DomPainterInput): DomPainterInput { - const body = normalizeOptionalBlockMeasurePair('body', input.blocks, input.measures); - const header = normalizeOptionalBlockMeasurePair('header', input.headerBlocks, input.headerMeasures); - const footer = normalizeOptionalBlockMeasurePair('footer', input.footerBlocks, input.footerMeasures); - - return { - ...input, - blocks: body?.blocks, - measures: body?.measures, - headerBlocks: header?.blocks, - headerMeasures: header?.measures, - footerBlocks: footer?.blocks, - footerMeasures: footer?.measures, - }; -} - -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 { - // resolveLayout handles empty blocks/measures gracefully and preserves - // page-level metadata (size, margins, columns/columnRegions, etc.) needed - // by the painter even when no body content has been provided yet. - resolvedLayout = resolveLayout({ - layout, - flowMode: flowMode ?? 'paginated', - blocks: legacyState.blocks, - measures: legacyState.measures, - }); - } - return { - resolvedLayout, - sourceLayout: layout, - blocks: legacyState.blocks, - measures: legacyState.measures, - headerBlocks: legacyState.headerBlocks, - headerMeasures: legacyState.headerMeasures, - footerBlocks: legacyState.footerBlocks, - footerMeasures: legacyState.footerMeasures, - }; -} - +/** + * Thin pass-through factory: instantiates DomPainter with the supplied options + * and returns a stable handle that exposes only the rendering-stage API. + * + * The handle accepts only `DomPainterInput` (resolvedLayout-only). + * Header/footer decoration providers must supply both `fragments` and `items` + * on their `PageDecorationPayload`. + */ 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 legacyState: LegacyDomPainterState = { - blocks: options.blocks ?? [], - measures: options.measures ?? [], - headerBlocks: undefined, - headerMeasures: undefined, - footerBlocks: undefined, - footerMeasures: undefined, - resolvedLayout: null, - }; - - let currentPaintInput: DomPainterInput | null = null; - - const resolveDecorationItems = ( - fragments: readonly Fragment[], - kind: 'header' | 'footer', - ): ResolvedPaintItem[] | undefined => { - const input = currentPaintInput; - if (!input) return undefined; - - const decorationBlocks = kind === 'header' ? input.headerBlocks : input.footerBlocks; - const decorationMeasures = kind === 'header' ? input.headerMeasures : input.footerMeasures; - const mergedBlocks = [...(input.blocks ?? []), ...(decorationBlocks ?? [])]; - const mergedMeasures = [...(input.measures ?? []), ...(decorationMeasures ?? [])]; - if (mergedBlocks.length === 0 || mergedBlocks.length !== mergedMeasures.length) { - return undefined; - } - - const fakeLayout: Layout = { - pageSize: input.sourceLayout.pageSize, - pages: [{ number: 1, fragments: [...fragments] as Fragment[] }] as Page[], - } as Layout; - - try { - const resolved = resolveLayout({ - layout: fakeLayout, - flowMode: input.resolvedLayout.flowMode, - 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 || payload.items) return payload; - const items = resolveDecorationItems(payload.fragments, kind); - return items ? { ...payload, items } : payload; - }; - }; - - const painter = new DomPainter({ - pageStyles: options.pageStyles, - layoutMode: options.layoutMode, - flowMode: options.flowMode, - pageGap: options.pageGap, - headerProvider: wrapProvider(options.headerProvider, 'header'), - footerProvider: wrapProvider(options.footerProvider, 'footer'), - virtualization: options.virtualization, - ruler: options.ruler, - onPaintSnapshot: options.onPaintSnapshot, - }); - + const painter = new DomPainter(options); return { - paint(input: DomPainterInput | Layout, mount: HTMLElement, mapping?: PositionMapping) { - const normalizedInput = isDomPainterInput(input) - ? normalizeDomPainterInput(input) - : buildLegacyPaintInput(input, legacyState, options.flowMode, options.pageGap); - currentPaintInput = normalizedInput; - painter.paint(normalizedInput, mount, mapping); - }, - setData( - blocks: FlowBlock[], - measures: Measure[], - headerBlocks?: FlowBlock[], - headerMeasures?: Measure[], - footerBlocks?: FlowBlock[], - footerMeasures?: Measure[], - ) { - assertRequiredBlockMeasurePair('body', blocks, measures); - const normalizedHeader = normalizeOptionalBlockMeasurePair('header', headerBlocks, headerMeasures); - const normalizedFooter = normalizeOptionalBlockMeasurePair('footer', footerBlocks, footerMeasures); - legacyState.blocks = blocks; - legacyState.measures = measures; - legacyState.headerBlocks = normalizedHeader?.blocks; - legacyState.headerMeasures = normalizedHeader?.measures; - legacyState.footerBlocks = normalizedFooter?.blocks; - legacyState.footerMeasures = normalizedFooter?.measures; - }, - setResolvedLayout(resolvedLayout: ResolvedLayout | null) { - legacyState.resolvedLayout = resolvedLayout; + paint(input: DomPainterInput, mount: HTMLElement, mapping?: PositionMapping) { + painter.paint(input, mount, mapping); }, setProviders(header?: PageDecorationProvider, footer?: PageDecorationProvider) { - painter.setProviders(wrapProvider(header, 'header'), wrapProvider(footer, 'footer')); + painter.setProviders(header, footer); }, setVirtualizationPins(pageIndices: number[] | null | undefined) { painter.setVirtualizationPins(pageIndices); diff --git a/packages/layout-engine/painters/dom/src/link-click.test.ts b/packages/layout-engine/painters/dom/src/link-click.test.ts index d826043b00..0543ac7ef3 100644 --- a/packages/layout-engine/painters/dom/src/link-click.test.ts +++ b/packages/layout-engine/painters/dom/src/link-click.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, beforeEach, afterEach } from 'vitest'; -import { createDomPainter } from './index.js'; +import { createTestPainter as createDomPainter } from './_test-utils.js'; import type { FlowBlock, Measure, Layout } from '@superdoc/contracts'; /** diff --git a/packages/layout-engine/painters/dom/src/renderer-column-separators.test.ts b/packages/layout-engine/painters/dom/src/renderer-column-separators.test.ts index 6b8c2e27e2..87c1f93e55 100644 --- a/packages/layout-engine/painters/dom/src/renderer-column-separators.test.ts +++ b/packages/layout-engine/painters/dom/src/renderer-column-separators.test.ts @@ -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 { ColumnRegion, Layout, Page } from '@superdoc/contracts'; // These tests pin down DomPainter's column-separator rendering: diff --git a/packages/layout-engine/painters/dom/src/renderer-dispatch.test.ts b/packages/layout-engine/painters/dom/src/renderer-dispatch.test.ts index f0777dd695..ea641ee9d4 100644 --- a/packages/layout-engine/painters/dom/src/renderer-dispatch.test.ts +++ b/packages/layout-engine/painters/dom/src/renderer-dispatch.test.ts @@ -7,7 +7,7 @@ */ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { createDomPainter } from './index.js'; +import { createTestPainter as createDomPainter } from './_test-utils.js'; import { DomPainter } from './renderer.js'; import type { FlowBlock, Measure, Layout } from '@superdoc/contracts'; diff --git a/packages/layout-engine/painters/dom/src/renderer-hanging-indent.test.ts b/packages/layout-engine/painters/dom/src/renderer-hanging-indent.test.ts index 003142cb6e..eb79122ec9 100644 --- a/packages/layout-engine/painters/dom/src/renderer-hanging-indent.test.ts +++ b/packages/layout-engine/painters/dom/src/renderer-hanging-indent.test.ts @@ -11,7 +11,7 @@ */ import { describe, it, expect, beforeEach } from 'vitest'; -import { createDomPainter } from './index.js'; +import { createTestPainter as createDomPainter } from './_test-utils.js'; import type { FlowBlock, Measure, Layout, Line } from '@superdoc/contracts'; describe('DomPainter hanging indent with tabs', () => { diff --git a/packages/layout-engine/painters/dom/src/renderer-known-divergences.test.ts b/packages/layout-engine/painters/dom/src/renderer-known-divergences.test.ts index b08c4164f0..92706dc9c5 100644 --- a/packages/layout-engine/painters/dom/src/renderer-known-divergences.test.ts +++ b/packages/layout-engine/painters/dom/src/renderer-known-divergences.test.ts @@ -10,7 +10,7 @@ */ import { describe, it, expect } from 'vitest'; -import { createDomPainter } from './index.js'; +import { createTestPainter as createDomPainter } from './_test-utils.js'; import type { FlowBlock, Measure, Layout, Line } from '@superdoc/contracts'; import { normalizeLines } from './test-utils/normalize-line.js'; diff --git a/packages/layout-engine/painters/dom/src/renderer-marker-suffix.test.ts b/packages/layout-engine/painters/dom/src/renderer-marker-suffix.test.ts index 2c39672bee..a016e3ce28 100644 --- a/packages/layout-engine/painters/dom/src/renderer-marker-suffix.test.ts +++ b/packages/layout-engine/painters/dom/src/renderer-marker-suffix.test.ts @@ -5,7 +5,7 @@ */ import { describe, it, expect, beforeEach } from 'vitest'; -import { createDomPainter } from './index.js'; +import { createTestPainter as createDomPainter } from './_test-utils.js'; import type { FlowBlock, Measure, Layout, WordParagraphLayoutOutput } from '@superdoc/contracts'; describe('DomPainter marker suffix rendering', () => { diff --git a/packages/layout-engine/painters/dom/src/renderer-marker-textwidth.test.ts b/packages/layout-engine/painters/dom/src/renderer-marker-textwidth.test.ts index cf3e7cddd5..8fb8bdef70 100644 --- a/packages/layout-engine/painters/dom/src/renderer-marker-textwidth.test.ts +++ b/packages/layout-engine/painters/dom/src/renderer-marker-textwidth.test.ts @@ -10,7 +10,7 @@ */ import { describe, it, expect, beforeEach } from 'vitest'; -import { createDomPainter } from './index.js'; +import { createTestPainter as createDomPainter } from './_test-utils.js'; import type { FlowBlock, Measure, Layout, WordParagraphLayoutOutput } from '@superdoc/contracts'; describe('DomPainter markerTextWidth feature', () => { diff --git a/packages/layout-engine/painters/dom/src/renderer-parity-contracts.test.ts b/packages/layout-engine/painters/dom/src/renderer-parity-contracts.test.ts index a7776ccee5..901c37e5a5 100644 --- a/packages/layout-engine/painters/dom/src/renderer-parity-contracts.test.ts +++ b/packages/layout-engine/painters/dom/src/renderer-parity-contracts.test.ts @@ -10,7 +10,7 @@ */ import { describe, it, expect, beforeEach } from 'vitest'; -import { createDomPainter } from './index.js'; +import { createTestPainter as createDomPainter } from './_test-utils.js'; import type { FlowBlock, Measure, Layout, ParagraphMeasure, Line, Run } from '@superdoc/contracts'; import { normalizeLines, type NormalizedLine } from './test-utils/normalize-line.js'; diff --git a/packages/layout-engine/painters/dom/src/renderer-shape-regressions.test.ts b/packages/layout-engine/painters/dom/src/renderer-shape-regressions.test.ts index 0380baada7..ea55a3e4c7 100644 --- a/packages/layout-engine/painters/dom/src/renderer-shape-regressions.test.ts +++ b/packages/layout-engine/painters/dom/src/renderer-shape-regressions.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; -import { createDomPainter } from './index.js'; +import { createTestPainter as createDomPainter } from './_test-utils.js'; import type { DrawingGeometry, FlowBlock, Layout, Measure, SolidFillWithAlpha } from '@superdoc/contracts'; type DrawingFlowBlock = Extract; diff --git a/packages/layout-engine/painters/dom/src/renderer-vector-shape-geometry.test.ts b/packages/layout-engine/painters/dom/src/renderer-vector-shape-geometry.test.ts index e880659bfb..53da516607 100644 --- a/packages/layout-engine/painters/dom/src/renderer-vector-shape-geometry.test.ts +++ b/packages/layout-engine/painters/dom/src/renderer-vector-shape-geometry.test.ts @@ -7,7 +7,7 @@ */ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; -import { createDomPainter } from './index.js'; +import { createTestPainter as createDomPainter } from './_test-utils.js'; import type { FlowBlock, Measure, Layout, DrawingGeometry } from '@superdoc/contracts'; describe('DomPainter vector shape geometry', () => { diff --git a/packages/layout-engine/painters/dom/src/renderer.ts b/packages/layout-engine/painters/dom/src/renderer.ts index b452161e4d..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, @@ -242,34 +238,18 @@ export type RenderedLineInfo = { /** * Input to `DomPainter.paint()`. * - * `resolvedLayout` is the canonical resolved data the painter reads from. - * `sourceLayout` is the raw Layout retained for legacy internal access paths. + * The painter consumes only `resolvedLayout`. All fragment, geometry, and + * page-level metadata it needs is reachable from `ResolvedPaintItem.fragment` + * back-pointers and `ResolvedPage` fields. */ export type DomPainterInput = { resolvedLayout: ResolvedLayout; - /** Raw Layout for internal fragment access. */ - sourceLayout: Layout; - /** - * Optional bridge data used only when a decoration provider omits `items`. - * Body rendering reads from `resolvedLayout`; these arrays exist solely so - * header/footer fragments can synthesize resolved items on demand. - */ - blocks?: FlowBlock[]; - measures?: Measure[]; - headerBlocks?: FlowBlock[]; - headerMeasures?: Measure[]; - footerBlocks?: FlowBlock[]; - footerMeasures?: Measure[]; }; export type PageDecorationPayload = { fragments: Fragment[]; - /** - * Resolved items aligned 1:1 with `fragments`. Same length, same order. - * When omitted, the painter treats fragments as having no resolved metadata - * (no paragraph borders, no SDT container keys). - */ - items?: ResolvedPaintItem[]; + /** Resolved items aligned 1:1 with `fragments`. Same length, same order. */ + items: ResolvedPaintItem[]; /** Minimum Y coordinate from layout; negative when content extends above y=0. */ minY?: number; height: number; diff --git a/packages/layout-engine/painters/dom/src/source-anchor.test.ts b/packages/layout-engine/painters/dom/src/source-anchor.test.ts index 990a964675..f600cbdec3 100644 --- a/packages/layout-engine/painters/dom/src/source-anchor.test.ts +++ b/packages/layout-engine/painters/dom/src/source-anchor.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { createDomPainter } from './index.js'; +import { createTestPainter as createDomPainter } from './_test-utils.js'; import type { FlowBlock, Layout, Measure, SourceAnchor } from '@superdoc/contracts'; import type { PaintSnapshot } from './renderer.js'; diff --git a/packages/layout-engine/painters/dom/src/text-style-rendering.test.ts b/packages/layout-engine/painters/dom/src/text-style-rendering.test.ts index 536ef9a44b..a913bd1333 100644 --- a/packages/layout-engine/painters/dom/src/text-style-rendering.test.ts +++ b/packages/layout-engine/painters/dom/src/text-style-rendering.test.ts @@ -5,7 +5,7 @@ */ import { describe, it, expect, beforeEach } from 'vitest'; -import { createDomPainter } from './index.js'; +import { createTestPainter as createDomPainter } from './_test-utils.js'; import type { FlowBlock, Measure, Layout } from '@superdoc/contracts'; const expectCssColor = (actual: string, expectedHex: string): void => { diff --git a/packages/layout-engine/painters/dom/src/virtualization.test.ts b/packages/layout-engine/painters/dom/src/virtualization.test.ts index b14f510638..092a3bf315 100644 --- a/packages/layout-engine/painters/dom/src/virtualization.test.ts +++ b/packages/layout-engine/painters/dom/src/virtualization.test.ts @@ -1,53 +1,6 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; -import { createDomPainter } from './index.js'; -import { resolveLayout } from '@superdoc/layout-resolved'; -import type { DomPainterOptions, DomPainterInput, PaintSnapshot } from './index.js'; -import type { FlowBlock, Measure, Layout, Fragment, PageMargins, ResolvedLayout } from '@superdoc/contracts'; - -const emptyResolved: ResolvedLayout = { version: 1, flowMode: 'paginated', pageGap: 0, pages: [] }; - -/** Test-only bridge: see index.test.ts for full JSDoc. */ -function createTestPainter(opts: { blocks?: FlowBlock[]; measures?: Measure[] } & DomPainterOptions) { - const { blocks: initBlocks, measures: initMeasures, ...painterOpts } = opts; - let lastPaintSnapshot: PaintSnapshot | null = null; - const painter = createDomPainter({ - ...painterOpts, - onPaintSnapshot: (snapshot) => { - lastPaintSnapshot = snapshot; - }, - }); - let currentBlocks: FlowBlock[] = initBlocks ?? []; - let currentMeasures: Measure[] = initMeasures ?? []; - let currentResolved: ResolvedLayout = emptyResolved; - - return { - paint(layout: Layout, mount: HTMLElement, mapping?: unknown) { - const effectiveResolved = - currentBlocks.length === 0 && currentMeasures.length === 0 - ? currentResolved - : resolveLayout({ - layout, - flowMode: opts.flowMode ?? 'paginated', - blocks: currentBlocks, - measures: currentMeasures, - }); - const input: DomPainterInput = { - resolvedLayout: effectiveResolved, - sourceLayout: layout, - }; - painter.paint(input, mount, mapping as any); - }, - setProviders: painter.setProviders, - setVirtualizationPins: painter.setVirtualizationPins, - getMountedPageIndices: painter.getMountedPageIndices, - getPaintSnapshot() { - return lastPaintSnapshot; - }, - onScroll: painter.onScroll, - setZoom: painter.setZoom, - setScrollContainer: painter.setScrollContainer, - }; -} +import { createTestPainter } from './_test-utils.js'; +import type { FlowBlock, Measure, Layout, Fragment, PageMargins } from '@superdoc/contracts'; // Minimal paragraph block/measure to satisfy painter const block: FlowBlock = { diff --git a/packages/layout-engine/pm-adapter/package.json b/packages/layout-engine/pm-adapter/package.json index abc0a590cd..0a8744c986 100644 --- a/packages/layout-engine/pm-adapter/package.json +++ b/packages/layout-engine/pm-adapter/package.json @@ -36,6 +36,7 @@ "devDependencies": { "vitest": "catalog:", "@superdoc/layout-engine": "workspace:*", + "@superdoc/layout-resolved": "workspace:*", "@superdoc/painter-dom": "workspace:*" }, "dependencies": { diff --git a/packages/layout-engine/pm-adapter/src/integration.test.ts b/packages/layout-engine/pm-adapter/src/integration.test.ts index 065b52b128..e9bdc14328 100644 --- a/packages/layout-engine/pm-adapter/src/integration.test.ts +++ b/packages/layout-engine/pm-adapter/src/integration.test.ts @@ -11,6 +11,7 @@ import type { PMNode, AdapterOptions } from './index.js'; import { measureBlock } from '@superdoc/measuring-dom'; import { layoutDocument } from '@superdoc/layout-engine'; import { createDomPainter } from '@superdoc/painter-dom'; +import { resolveLayout } from '@superdoc/layout-resolved'; // Cleaned: remove unused PDF painter import import type { Measure, ParaFragment, ParagraphMeasure, TabStop } from '@superdoc/contracts'; import basicParagraphFixture from './fixtures/basic-paragraph.json'; @@ -492,8 +493,9 @@ describe('PM → FlowBlock → Measure integration', () => { const mount = document.createElement('div'); document.body.appendChild(mount); - const painter = createDomPainter({ blocks, measures }); - painter.paint(layout, mount); + const painter = createDomPainter({}); + const resolvedLayout = resolveLayout({ layout, flowMode: 'paginated', blocks, measures }); + painter.paint({ resolvedLayout }, mount); expect(mount.children.length).toBeGreaterThan(0); expect(mount.textContent).toContain('This is a simple paragraph'); @@ -547,8 +549,9 @@ describe('PM → FlowBlock → Measure integration', () => { const mount = document.createElement('div'); document.body.appendChild(mount); - const painter = createDomPainter({ blocks, measures }); - painter.paint(layout, mount); + const painter = createDomPainter({}); + const resolvedLayout = resolveLayout({ layout, flowMode: 'paginated', blocks, measures }); + painter.paint({ resolvedLayout }, mount); const fragment = mount.querySelector('.superdoc-fragment') as HTMLElement; const shadingLayer = fragment.querySelector('.superdoc-paragraph-shading') as HTMLElement; @@ -759,8 +762,9 @@ describe('page break integration tests', () => { const mount = document.createElement('div'); document.body.appendChild(mount); - const painter = createDomPainter({ blocks, measures }); - painter.paint(layout, mount); + const painter = createDomPainter({}); + const resolvedLayout = resolveLayout({ layout, flowMode: 'paginated', blocks, measures }); + painter.paint({ resolvedLayout }, mount); // Verify multiple pages were created in DOM const pages = mount.querySelectorAll('.superdoc-page'); 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/packages/super-editor/src/editors/v1/core/presentation-editor/PresentationEditor.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/PresentationEditor.ts index a67e64d9a7..740def311d 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/PresentationEditor.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/PresentationEditor.ts @@ -6187,7 +6187,6 @@ export class PresentationEditor extends EventEmitter { const painterPaintStart = perfNow(); const paintInput: DomPainterInput = { resolvedLayout, - sourceLayout: layout, }; this.#painterAdapter.paint(paintInput, this.#painterHost, mapping ?? undefined); const painterPaintEnd = perfNow(); diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/header-footer/HeaderFooterSessionManager.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/header-footer/HeaderFooterSessionManager.ts index 5fa7025f3e..e53ef72288 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/header-footer/HeaderFooterSessionManager.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/header-footer/HeaderFooterSessionManager.ts @@ -386,11 +386,8 @@ function normalizeDecorationFragments(fragments: Fragment[], layoutMinY: number) return fragments.map((fragment) => ({ ...fragment, y: fragment.y + yOffset })); } -function normalizeDecorationItems( - items: ResolvedPaintItem[] | undefined, - layoutMinY: number, -): ResolvedPaintItem[] | undefined { - if (!items || layoutMinY >= 0) { +function normalizeDecorationItems(items: ResolvedPaintItem[], layoutMinY: number): ResolvedPaintItem[] { + if (layoutMinY >= 0) { return items; } @@ -2404,6 +2401,9 @@ export class HeaderFooterSessionManager { rIdResolvedLayout, `rId '${rIdLayoutKey}' page ${pageNumber}`, ); + if (!alignedItems) { + return null; + } const pageHeight = page?.height ?? resolvedLayout.pages[0]?.height ?? layoutOptions.pageSize?.h ?? defaultPageSize.h; const margins = pageMargins ?? resolvedLayout.pages[0]?.margins ?? layoutOptions.margins ?? defaultMargins; @@ -2465,6 +2465,9 @@ export class HeaderFooterSessionManager { resolvedVariant, `variant '${headerFooterType}' page ${pageNumber}`, ); + if (!alignedVariantItems) { + return null; + } const pageHeight = page?.height ?? resolvedLayout.pages[0]?.height ?? layoutOptions.pageSize?.h ?? defaultPageSize.h; diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/rendering/PresentationPainterAdapter.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/rendering/PresentationPainterAdapter.ts index 5a5e89718a..645896aad5 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/rendering/PresentationPainterAdapter.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/rendering/PresentationPainterAdapter.ts @@ -73,7 +73,7 @@ export class PresentationPainterAdapter { // ── Paint orchestration ───────────────────────────────────────────── - paint(input: DomPainterInput | Layout, mount: HTMLElement, mapping?: PositionMapping): void { + paint(input: DomPainterInput, mount: HTMLElement, mapping?: PositionMapping): void { this.#painter?.paint(input, mount, mapping); } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 56ff4ad0fa..56146322d3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2572,6 +2572,9 @@ importers: specifier: workspace:* version: link:../../word-layout devDependencies: + '@superdoc/layout-resolved': + specifier: workspace:* + version: link:../layout-resolved '@superdoc/painter-dom': specifier: workspace:* version: link:../painters/dom @@ -2651,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 @@ -2664,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) @@ -2701,6 +2704,9 @@ importers: '@superdoc/layout-engine': specifier: workspace:* version: link:../layout-engine + '@superdoc/layout-resolved': + specifier: workspace:* + version: link:../layout-resolved '@superdoc/painter-dom': specifier: workspace:* version: link:../painters/dom