diff --git a/packages/layout-engine/painters/dom/package.json b/packages/layout-engine/painters/dom/package.json index b5757c3844..3410206a3f 100644 --- a/packages/layout-engine/painters/dom/package.json +++ b/packages/layout-engine/painters/dom/package.json @@ -26,6 +26,7 @@ }, "devDependencies": { "@superdoc/layout-engine": "workspace:*", + "@superdoc/layout-resolved": "workspace:*", "vitest": "catalog:" } } diff --git a/packages/layout-engine/painters/dom/src/index.test.ts b/packages/layout-engine/painters/dom/src/index.test.ts index 6508983203..f758c77507 100644 --- a/packages/layout-engine/painters/dom/src/index.test.ts +++ b/packages/layout-engine/painters/dom/src/index.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it, beforeEach, afterEach, vi } from 'vitest'; import { createDomPainter, sanitizeUrl, linkMetrics, applyRunDataAttributes } from './index.js'; import { DomPainter } from './renderer.js'; +import { resolveLayout } from '@superdoc/layout-resolved'; import type { DomPainterOptions, DomPainterInput, PaintSnapshot } from './index.js'; import { resolveListMarkerGeometry } from '../../../../../shared/common/list-marker-utils.js'; import type { @@ -42,17 +43,36 @@ function createTestPainter(opts: { blocks?: FlowBlock[]; measures?: Measure[] } let footerBlocks: FlowBlock[] | undefined; let footerMeasures: Measure[] | undefined; + let resolvedLayoutOverridden = false; + return { paint(layout: Layout, mount: HTMLElement, mapping?: unknown) { + const effectiveResolved = resolvedLayoutOverridden + ? currentResolved + : resolveLayout({ + layout, + flowMode: opts.flowMode ?? 'paginated', + blocks: currentBlocks, + measures: currentMeasures, + }); + // Tests historically pass header/footer blocks via the main `blocks` array and + // rely on the blockLookup containing them. Merge body blocks into headerBlocks + // so header/footer fragments from providers can resolve their block data. + const mergedHeaderBlocks = + headerBlocks || currentBlocks.length > 0 ? [...currentBlocks, ...(headerBlocks ?? [])] : undefined; + const mergedHeaderMeasures = + headerMeasures || currentMeasures.length > 0 ? [...currentMeasures, ...(headerMeasures ?? [])] : undefined; + const mergedFooterBlocks = + footerBlocks || currentBlocks.length > 0 ? [...currentBlocks, ...(footerBlocks ?? [])] : undefined; + const mergedFooterMeasures = + footerMeasures || currentMeasures.length > 0 ? [...currentMeasures, ...(footerMeasures ?? [])] : undefined; const input: DomPainterInput = { - resolvedLayout: currentResolved, + resolvedLayout: effectiveResolved, sourceLayout: layout, - blocks: currentBlocks, - measures: currentMeasures, - headerBlocks, - headerMeasures, - footerBlocks, - footerMeasures, + headerBlocks: mergedHeaderBlocks, + headerMeasures: mergedHeaderMeasures, + footerBlocks: mergedFooterBlocks, + footerMeasures: mergedFooterMeasures, }; painter.paint(input, mount, mapping as any); }, @@ -73,6 +93,7 @@ function createTestPainter(opts: { blocks?: FlowBlock[]; measures?: Measure[] } }, setResolvedLayout(rl: ResolvedLayout | null) { currentResolved = rl ?? emptyResolved; + resolvedLayoutOverridden = true; }, setProviders: painter.setProviders, setVirtualizationPins: painter.setVirtualizationPins, @@ -1357,7 +1378,10 @@ describe('DomPainter', () => { expect(lines[1].style.wordSpacing).toBe(''); }); - it('renders an error placeholder when a legacy table fragment is missing its lookup entry', () => { + it('surfaces a missing-block error from resolveLayout when a table fragment references an unknown block', () => { + // Previous behavior: painter rendered a placeholder for missing lookup entries. + // New behavior: resolveLayout validates block/measure integrity upstream and throws + // before the painter runs. Missing-block bugs are now caught at the resolved stage. const missingTableLayout: Layout = { pageSize: { w: 300, h: 300 }, pages: [ @@ -1379,19 +1403,8 @@ describe('DomPainter', () => { ], }; - const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => { - // Intentionally empty - suppress expected error logging during this regression test. - }); - const painter = createTestPainter({ blocks: [], measures: [] }); - expect(() => painter.paint(missingTableLayout, mount)).not.toThrow(); - - const placeholder = mount.querySelector('.render-error-placeholder') as HTMLElement | null; - expect(placeholder).toBeTruthy(); - expect(placeholder?.textContent).toContain('[Render Error: missing-table]'); - expect(consoleErrorSpy).toHaveBeenCalled(); - - consoleErrorSpy.mockRestore(); + expect(() => painter.paint(missingTableLayout, mount)).toThrow(/Missing block\/measure/); }); it('renders an error placeholder when table-cell line rendering throws', () => { @@ -1680,8 +1693,9 @@ describe('DomPainter', () => { }); it('throws if blocks and measures length mismatch', () => { + // Block/measure integrity is now validated at the resolve-layout stage. const painter = createTestPainter({ blocks: [block], measures: [] }); - expect(() => painter.paint(layout, mount)).toThrow(/same number of blocks/); + expect(() => painter.paint(layout, mount)).toThrow(); }); it('renders placeholder content for empty lines', () => { diff --git a/packages/layout-engine/painters/dom/src/index.ts b/packages/layout-engine/painters/dom/src/index.ts index fcbe74c7f4..ab5b1b7faf 100644 --- a/packages/layout-engine/painters/dom/src/index.ts +++ b/packages/layout-engine/painters/dom/src/index.ts @@ -1,5 +1,6 @@ import type { FlowBlock, Fragment, Layout, Measure, Page, PageMargins, ResolvedLayout } from '@superdoc/contracts'; import { DomPainter } from './renderer.js'; +import { resolveLayout } from '@superdoc/layout-resolved'; import type { PageStyles } from './styles.js'; import type { DomPainterInput, PaintSnapshot, PositionMapping, RulerOptions, FlowMode } from './renderer.js'; @@ -207,7 +208,7 @@ function createEmptyResolvedLayout(flowMode: FlowMode | undefined, pageGap: numb } function isDomPainterInput(value: DomPainterInput | Layout): value is DomPainterInput { - return 'resolvedLayout' in value && 'sourceLayout' in value && 'blocks' in value && 'measures' in value; + return 'resolvedLayout' in value && 'sourceLayout' in value; } function buildLegacyPaintInput( @@ -216,11 +217,26 @@ function buildLegacyPaintInput( 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 if (legacyState.blocks.length === 0 && legacyState.measures.length === 0) { + resolvedLayout = createEmptyResolvedLayout(flowMode, pageGap); + } else { + resolvedLayout = resolveLayout({ + layout, + flowMode: flowMode ?? 'paginated', + blocks: legacyState.blocks, + measures: legacyState.measures, + }); + } return { - resolvedLayout: legacyState.resolvedLayout ?? createEmptyResolvedLayout(flowMode, pageGap), + resolvedLayout, sourceLayout: layout, - blocks: legacyState.blocks, - measures: legacyState.measures, headerBlocks: legacyState.headerBlocks, headerMeasures: legacyState.headerMeasures, footerBlocks: legacyState.footerBlocks, diff --git a/packages/layout-engine/painters/dom/src/renderer.ts b/packages/layout-engine/painters/dom/src/renderer.ts index 7d835168c4..05c754e740 100644 --- a/packages/layout-engine/painters/dom/src/renderer.ts +++ b/packages/layout-engine/painters/dom/src/renderer.ts @@ -251,12 +251,12 @@ export type RenderedLineInfo = { */ export type DomPainterInput = { resolvedLayout: ResolvedLayout; - /** Raw Layout for internal fragment access (bridge — will be removed once all fragment types are resolved). */ + /** Raw Layout for internal fragment access (bridge, will be removed once render loops iterate resolved items). */ sourceLayout: Layout; - blocks: FlowBlock[]; - measures: Measure[]; + /** Header block data (still needed for decoration rendering, no resolved path yet). */ headerBlocks?: FlowBlock[]; headerMeasures?: Measure[]; + /** Footer block data (still needed for decoration rendering, no resolved path yet). */ footerBlocks?: FlowBlock[]; footerMeasures?: Measure[]; }; @@ -1604,10 +1604,8 @@ export class DomPainter { } private updateBlockLookup(input: DomPainterInput): void { - const { blocks, measures, headerBlocks, headerMeasures, footerBlocks, footerMeasures } = input; - - // Build lookup for main document blocks - const nextLookup = this.buildBlockLookup(blocks, measures); + const { headerBlocks, headerMeasures, footerBlocks, footerMeasures } = input; + const nextLookup: BlockLookup = new Map(); const normalizedHeader = this.normalizeOptionalBlockMeasurePair('header', headerBlocks, headerMeasures); if (normalizedHeader) { @@ -1625,7 +1623,7 @@ export class DomPainter { }); } - // Track changed blocks + // Track changed blocks (decoration only now, body change detection uses resolved version) const changed = new Set(); nextLookup.forEach((entry, id) => { const previous = this.blockLookup.get(id); @@ -1659,7 +1657,13 @@ export class DomPainter { // Complex transactions (paste, multi-step replace, etc.) fall back to full rebuild. const isSimpleTransaction = mapping && mapping.maps.length === 1; if (mapping && !isSimpleTransaction) { - // Complex transaction - force all fragments to rebuild (safe fallback) + // Complex transaction, force all body fragments to rebuild (safe fallback). + for (const page of input.resolvedLayout.pages) { + for (const item of page.items) { + if ('blockId' in item) this.changedBlocks.add(item.blockId); + } + } + // Also mark all header/footer blocks as changed. this.blockLookup.forEach((_, id) => this.changedBlocks.add(id)); this.currentMapping = null; } else { @@ -4756,6 +4760,11 @@ export class DomPainter { // Inner cell fragments still use legacy applyFragmentFrame via deps closure. if (resolvedItem) { this.applyResolvedFragmentFrame(el, resolvedItem, fragment, context.section); + // Re-apply the SDT group width override after the resolved frame, so block-SDT + // containers can stretch table fragments to match sibling paragraph widths. + if (sdtBoundary?.widthOverride != null) { + el.style.width = `${sdtBoundary.widthOverride}px`; + } } return el; diff --git a/packages/layout-engine/painters/dom/src/virtualization.test.ts b/packages/layout-engine/painters/dom/src/virtualization.test.ts index 97f27781e7..b64a464bad 100644 --- a/packages/layout-engine/painters/dom/src/virtualization.test.ts +++ b/packages/layout-engine/painters/dom/src/virtualization.test.ts @@ -1,5 +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'; @@ -21,11 +22,22 @@ function createTestPainter(opts: { blocks?: FlowBlock[]; measures?: Measure[] } 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: currentResolved, + resolvedLayout: effectiveResolved, sourceLayout: layout, - blocks: currentBlocks, - measures: currentMeasures, + headerBlocks: undefined, + headerMeasures: undefined, + footerBlocks: undefined, + footerMeasures: undefined, }; painter.paint(input, mount, mapping as any); }, diff --git a/packages/layout-engine/painters/dom/tsconfig.json b/packages/layout-engine/painters/dom/tsconfig.json index e1df276edc..bf7c501521 100644 --- a/packages/layout-engine/painters/dom/tsconfig.json +++ b/packages/layout-engine/painters/dom/tsconfig.json @@ -12,6 +12,7 @@ "references": [ { "path": "../../contracts/tsconfig.json" }, { "path": "../../dom-contract/tsconfig.json" }, + { "path": "../../layout-resolved/tsconfig.json" }, { "path": "../../measuring/dom/tsconfig.json" }, { "path": "../../../../shared/common/tsconfig.json" } ] 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 64498efb5d..ba825129a8 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 @@ -4427,8 +4427,6 @@ export class PresentationEditor extends EventEmitter { const paintInput: DomPainterInput = { resolvedLayout, sourceLayout: layout, - blocks: blocksForLayout, - measures, headerBlocks: headerBlocks.length > 0 ? headerBlocks : undefined, headerMeasures: headerMeasures.length > 0 ? headerMeasures : undefined, footerBlocks: footerBlocks.length > 0 ? footerBlocks : undefined, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9406aa47e0..9dfef890ef 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2575,6 +2575,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(@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) @@ -6400,6 +6403,7 @@ packages: '@microsoft/teamsapp-cli@3.0.2': resolution: {integrity: sha512-AowuJwrrUxeF9Bq/frxuy9YZjK/ECk3pi0UBXl3CQLZ4XNWfgWatiFi/UWpyHDLccFs+0Za3nNYATFvgsxEFwQ==} engines: {node: '>=12'} + deprecated: This package is deprecated and supported Node.js version is 18-22. Please use @microsoft/m365agentstoolkit-cli instead. hasBin: true '@microsoft/teamsfx-api@0.23.1':