Skip to content
Open
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
1 change: 1 addition & 0 deletions packages/layout-engine/painters/dom/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
},
"devDependencies": {
"@superdoc/layout-engine": "workspace:*",
"@superdoc/layout-resolved": "workspace:*",
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Move layout-resolved into runtime dependencies

@superdoc/painter-dom now imports resolveLayout in src/index.ts during normal execution, but @superdoc/layout-resolved is declared only as a devDependency. In production-style installs (pnpm install --prod) devDependencies are pruned, so consumers that depend on @superdoc/painter-dom without also depending on @superdoc/layout-resolved can fail at module resolution time before paint runs. Please declare this package under dependencies to keep runtime resolution stable.

Useful? React with 👍 / 👎.

"vitest": "catalog:"
}
}
56 changes: 35 additions & 21 deletions packages/layout-engine/painters/dom/src/index.test.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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);
},
Expand All @@ -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,
Expand Down Expand Up @@ -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: [
Expand All @@ -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', () => {
Expand Down Expand Up @@ -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', () => {
Expand Down
24 changes: 20 additions & 4 deletions packages/layout-engine/painters/dom/src/index.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -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(
Expand All @@ -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,
Expand Down
27 changes: 18 additions & 9 deletions packages/layout-engine/painters/dom/src/renderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -251,12 +251,12 @@ export type RenderedLineInfo = {
*/
export type DomPainterInput = {
resolvedLayout: ResolvedLayout;
/** Raw Layout for internal fragment access (bridgewill 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[];
};
Expand Down Expand Up @@ -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) {
Expand All @@ -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<string>();
nextLookup.forEach((entry, id) => {
const previous = this.blockLookup.get(id);
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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;
Expand Down
18 changes: 15 additions & 3 deletions packages/layout-engine/painters/dom/src/virtualization.test.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -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);
},
Expand Down
1 change: 1 addition & 0 deletions packages/layout-engine/painters/dom/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -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" }
]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
4 changes: 4 additions & 0 deletions pnpm-lock.yaml

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

Loading