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 f9ae6037fa..2218a18b0d 100644 --- a/packages/layout-engine/painters/dom/src/between-borders.test.ts +++ b/packages/layout-engine/painters/dom/src/between-borders.test.ts @@ -1,14 +1,13 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { applyParagraphBorderStyles, - getFragmentParagraphBorders, computeBetweenBorderFlags, createParagraphDecorationLayers, getParagraphBorderBox, computeBorderSpaceExpansion, - type BlockLookup, type BetweenBorderInfo, } from './features/paragraph-borders/index.js'; +import { hashParagraphBorders } from './paragraph-hash-utils.js'; /** Helper to create BetweenBorderInfo for tests that previously passed a boolean. */ const betweenOn: BetweenBorderInfo = { @@ -36,6 +35,8 @@ import type { ParaFragment, ListItemFragment, ImageFragment, + ResolvedPaintItem, + ResolvedFragmentItem, } from '@superdoc/contracts'; // --------------------------------------------------------------------------- @@ -65,24 +66,54 @@ const makeListBlock = (id: string, items: { itemId: string; borders?: ParagraphB })), }); -const stubMeasure = { kind: 'paragraph' as const, lines: [], totalHeight: 0 }; -const stubListMeasure = { - kind: 'list' as const, - items: [], - totalHeight: 0, +/** + * Test surrogate for the old BlockLookup — a list of blocks keyed by id that + * `buildResolvedItems` consumes to synthesize per-fragment ResolvedPaintItems. + */ +type TestBlockList = ReadonlyArray; + +const buildLookup = (entries: { block: ParagraphBlock | ListBlock; measure?: unknown }[]): TestBlockList => + entries.map((e) => e.block); + +/** + * Build resolved items aligned 1:1 with the given fragments. + * Looks up each fragment's block (+ list item) to extract paragraph borders, + * then produces a ResolvedFragmentItem carrying the borders and a border hash. + */ +const buildResolvedItems = (fragments: readonly Fragment[], blocks: TestBlockList): ResolvedPaintItem[] => { + const byId = new Map(blocks.map((b) => [b.id, b])); + return fragments.map((fragment, index): ResolvedPaintItem => { + const block = byId.get(fragment.blockId); + let borders: ParagraphBorders | undefined; + + if (fragment.kind === 'para' && block?.kind === 'paragraph') { + borders = block.attrs?.borders; + } else if (fragment.kind === 'list-item' && block?.kind === 'list') { + const item = block.items.find((listItem) => listItem.id === fragment.itemId); + borders = item?.paragraph.attrs?.borders; + } + + const item: ResolvedFragmentItem = { + kind: 'fragment', + id: `item:${index}`, + pageIndex: 0, + x: fragment.x, + y: fragment.y, + width: fragment.width, + height: 'height' in fragment && typeof fragment.height === 'number' ? fragment.height : 0, + fragmentKind: fragment.kind, + blockId: fragment.blockId, + fragmentIndex: index, + paragraphBorders: borders, + paragraphBorderHash: borders ? hashParagraphBorders(borders) : undefined, + }; + return item; + }); }; -const buildLookup = (entries: { block: ParagraphBlock | ListBlock; measure?: unknown }[]): BlockLookup => { - const map: BlockLookup = new Map(); - for (const e of entries) { - map.set(e.block.id, { - block: e.block, - measure: (e.measure ?? (e.block.kind === 'list' ? stubListMeasure : stubMeasure)) as never, - version: '1', - }); - } - return map; -}; +/** Test helper: run computeBetweenBorderFlags given fragments and the underlying blocks. */ +const runFlags = (fragments: readonly Fragment[], blocks: TestBlockList) => + computeBetweenBorderFlags(fragments, buildResolvedItems(fragments, blocks)); const paraFragment = (blockId: string, overrides?: Partial): ParaFragment => ({ kind: 'para', @@ -398,56 +429,6 @@ describe('createParagraphDecorationLayers — gap extension', () => { }); }); -// --------------------------------------------------------------------------- -// getFragmentParagraphBorders -// --------------------------------------------------------------------------- - -describe('getFragmentParagraphBorders', () => { - it('returns borders from a paragraph block', () => { - const borders: ParagraphBorders = { top: { style: 'solid', width: 1 } }; - const block = makeParagraphBlock('b1', borders); - const lookup = buildLookup([{ block }]); - expect(getFragmentParagraphBorders(paraFragment('b1'), lookup)).toEqual(borders); - }); - - it('returns undefined for paragraph block without borders', () => { - const block = makeParagraphBlock('b1'); - const lookup = buildLookup([{ block }]); - expect(getFragmentParagraphBorders(paraFragment('b1'), lookup)).toBeUndefined(); - }); - - it('returns borders from a list-item block', () => { - const borders: ParagraphBorders = { between: { style: 'solid', width: 1 } }; - const block = makeListBlock('l1', [{ itemId: 'i1', borders }]); - const lookup = buildLookup([{ block }]); - expect(getFragmentParagraphBorders(listItemFragment('l1', 'i1'), lookup)).toEqual(borders); - }); - - it('returns undefined when list item is not found', () => { - const block = makeListBlock('l1', [{ itemId: 'i1' }]); - const lookup = buildLookup([{ block }]); - expect(getFragmentParagraphBorders(listItemFragment('l1', 'missing'), lookup)).toBeUndefined(); - }); - - it('returns undefined when blockId is not in lookup', () => { - const lookup = buildLookup([]); - expect(getFragmentParagraphBorders(paraFragment('missing'), lookup)).toBeUndefined(); - }); - - it('returns undefined for image fragment', () => { - const block = makeParagraphBlock('b1', { top: { style: 'solid', width: 1 } }); - const lookup = buildLookup([{ block }]); - expect(getFragmentParagraphBorders(imageFragment('b1'), lookup)).toBeUndefined(); - }); - - it('returns undefined for kind/block mismatch (para fragment with list block)', () => { - const block = makeListBlock('l1', [{ itemId: 'i1' }]); - const lookup = buildLookup([{ block }]); - // para fragment referencing a list block - expect(getFragmentParagraphBorders(paraFragment('l1'), lookup)).toBeUndefined(); - }); -}); - // --------------------------------------------------------------------------- // computeBetweenBorderFlags // --------------------------------------------------------------------------- @@ -460,7 +441,7 @@ describe('computeBetweenBorderFlags', () => { const lookup = buildLookup([{ block: b1 }, { block: b2 }]); const fragments: Fragment[] = [paraFragment('b1'), paraFragment('b2')]; - const flags = computeBetweenBorderFlags(fragments, lookup); + const flags = runFlags(fragments, lookup); expect(flags.has(0)).toBe(true); expect(flags.get(0)?.showBetweenBorder).toBe(true); // Fragment 1 also gets an entry (suppressTopBorder) @@ -478,7 +459,7 @@ describe('computeBetweenBorderFlags', () => { const lookup = buildLookup([{ block: b1 }, { block: b2 }]); const fragments: Fragment[] = [paraFragment('b1'), paraFragment('b2')]; - const flags = computeBetweenBorderFlags(fragments, lookup); + const flags = runFlags(fragments, lookup); expect(flags.size).toBe(2); // First fragment: bottom border suppressed (no between separator, single box) expect(flags.get(0)?.suppressBottomBorder).toBe(true); @@ -501,7 +482,7 @@ describe('computeBetweenBorderFlags', () => { const lookup = buildLookup([{ block: b1 }, { block: b2 }]); const fragments: Fragment[] = [paraFragment('b1'), paraFragment('b2')]; - expect(computeBetweenBorderFlags(fragments, lookup).size).toBe(0); + expect(runFlags(fragments, lookup).size).toBe(0); }); // --- page-split handling --- @@ -511,7 +492,7 @@ describe('computeBetweenBorderFlags', () => { const lookup = buildLookup([{ block: b1 }, { block: b2 }]); const fragments: Fragment[] = [paraFragment('b1', { continuesOnNext: true }), paraFragment('b2')]; - expect(computeBetweenBorderFlags(fragments, lookup).size).toBe(0); + expect(runFlags(fragments, lookup).size).toBe(0); }); it('does not flag when next fragment continuesFromPrev (page split continuation)', () => { @@ -520,7 +501,7 @@ describe('computeBetweenBorderFlags', () => { const lookup = buildLookup([{ block: b1 }, { block: b2 }]); const fragments: Fragment[] = [paraFragment('b1'), paraFragment('b2', { continuesFromPrev: true })]; - expect(computeBetweenBorderFlags(fragments, lookup).size).toBe(0); + expect(runFlags(fragments, lookup).size).toBe(0); }); // --- same-block deduplication --- @@ -532,7 +513,7 @@ describe('computeBetweenBorderFlags', () => { paraFragment('b1', { fromLine: 3, toLine: 6 }), ]; - expect(computeBetweenBorderFlags(fragments, lookup).size).toBe(0); + expect(runFlags(fragments, lookup).size).toBe(0); }); it('does not flag same blockId + same itemId list-item fragments', () => { @@ -543,7 +524,7 @@ describe('computeBetweenBorderFlags', () => { listItemFragment('l1', 'i1', { fromLine: 2, toLine: 4 }), ]; - expect(computeBetweenBorderFlags(fragments, lookup).size).toBe(0); + expect(runFlags(fragments, lookup).size).toBe(0); }); it('flags different itemIds in same list block', () => { @@ -554,7 +535,7 @@ describe('computeBetweenBorderFlags', () => { const lookup = buildLookup([{ block }]); const fragments: Fragment[] = [listItemFragment('l1', 'i1'), listItemFragment('l1', 'i2')]; - const flags = computeBetweenBorderFlags(fragments, lookup); + const flags = runFlags(fragments, lookup); expect(flags.has(0)).toBe(true); }); @@ -567,7 +548,7 @@ describe('computeBetweenBorderFlags', () => { const fragments: Fragment[] = [paraFragment('b1'), imageFragment('img1'), paraFragment('b2')]; // Index 0 can't pair with index 1 (image), index 1 is image (skip) - const flags = computeBetweenBorderFlags(fragments, lookup); + const flags = runFlags(fragments, lookup); expect(flags.has(0)).toBe(false); // Index 1 is image, skipped — but index 1→2 is image→para, image is skipped expect(flags.size).toBe(0); @@ -580,7 +561,7 @@ describe('computeBetweenBorderFlags', () => { const lookup = buildLookup([{ block: b1 }, { block }]); const fragments: Fragment[] = [paraFragment('b1'), listItemFragment('l1', 'i1')]; - expect(computeBetweenBorderFlags(fragments, lookup).has(0)).toBe(true); + expect(runFlags(fragments, lookup).has(0)).toBe(true); }); it('flags list-item followed by para with matching borders', () => { @@ -589,7 +570,7 @@ describe('computeBetweenBorderFlags', () => { const lookup = buildLookup([{ block }, { block: b2 }]); const fragments: Fragment[] = [listItemFragment('l1', 'i1'), paraFragment('b2')]; - expect(computeBetweenBorderFlags(fragments, lookup).has(0)).toBe(true); + expect(runFlags(fragments, lookup).has(0)).toBe(true); }); // --- multiple consecutive --- @@ -600,7 +581,7 @@ describe('computeBetweenBorderFlags', () => { const lookup = buildLookup([{ block: b1 }, { block: b2 }, { block: b3 }]); const fragments: Fragment[] = [paraFragment('b1'), paraFragment('b2'), paraFragment('b3')]; - const flags = computeBetweenBorderFlags(fragments, lookup); + const flags = runFlags(fragments, lookup); expect(flags.has(0)).toBe(true); expect(flags.get(0)?.showBetweenBorder).toBe(true); expect(flags.has(1)).toBe(true); @@ -623,7 +604,7 @@ describe('computeBetweenBorderFlags', () => { const lookup = buildLookup([{ block: b1 }, { block: b2 }, { block: b3 }]); const fragments: Fragment[] = [paraFragment('b1'), paraFragment('b2'), paraFragment('b3')]; - const flags = computeBetweenBorderFlags(fragments, lookup); + const flags = runFlags(fragments, lookup); expect(flags.size).toBe(0); }); @@ -635,7 +616,7 @@ describe('computeBetweenBorderFlags', () => { const lookup = buildLookup([{ block: b1 }, { block: b2 }]); const fragments: Fragment[] = [paraFragment('b1'), paraFragment('b2')]; - expect(computeBetweenBorderFlags(fragments, lookup).size).toBe(0); + expect(runFlags(fragments, lookup).size).toBe(0); }); it('does not flag when only second fragment has between border', () => { @@ -645,19 +626,19 @@ describe('computeBetweenBorderFlags', () => { const lookup = buildLookup([{ block: b1 }, { block: b2 }]); const fragments: Fragment[] = [paraFragment('b1'), paraFragment('b2')]; - expect(computeBetweenBorderFlags(fragments, lookup).size).toBe(0); + expect(runFlags(fragments, lookup).size).toBe(0); }); // --- edge: empty / single fragment --- it('returns empty set for empty fragment list', () => { const lookup = buildLookup([]); - expect(computeBetweenBorderFlags([], lookup).size).toBe(0); + expect(runFlags([], lookup).size).toBe(0); }); it('returns empty set for single fragment', () => { const b1 = makeParagraphBlock('b1', MATCHING_BORDERS); const lookup = buildLookup([{ block: b1 }]); - expect(computeBetweenBorderFlags([paraFragment('b1')], lookup).size).toBe(0); + expect(runFlags([paraFragment('b1')], lookup).size).toBe(0); }); // --- edge: missing block in lookup --- @@ -667,7 +648,7 @@ describe('computeBetweenBorderFlags', () => { // b1 is not in lookup const fragments: Fragment[] = [paraFragment('b1'), paraFragment('b2')]; - expect(computeBetweenBorderFlags(fragments, lookup).size).toBe(0); + expect(runFlags(fragments, lookup).size).toBe(0); }); // --- edge: between borders match but other sides differ --- @@ -686,7 +667,7 @@ describe('computeBetweenBorderFlags', () => { const fragments: Fragment[] = [paraFragment('b1'), paraFragment('b2')]; // Full border hash differs (top is different), so not same border group - expect(computeBetweenBorderFlags(fragments, lookup).size).toBe(0); + expect(runFlags(fragments, lookup).size).toBe(0); }); // --- edge: last fragment on page --- @@ -695,7 +676,7 @@ describe('computeBetweenBorderFlags', () => { const lookup = buildLookup([{ block: b1 }]); const fragments: Fragment[] = [paraFragment('b1')]; - const flags = computeBetweenBorderFlags(fragments, lookup); + const flags = runFlags(fragments, lookup); expect(flags.has(0)).toBe(false); }); @@ -709,7 +690,7 @@ describe('computeBetweenBorderFlags', () => { const lookup = buildLookup([{ block: b1 }, { block: b2 }]); const fragments: Fragment[] = [paraFragment('b1', { y: 100, x: 0 }), paraFragment('b2', { y: 0, x: 300 })]; - const flags = computeBetweenBorderFlags(fragments, lookup); + const flags = runFlags(fragments, lookup); expect(flags.size).toBe(0); }); @@ -723,7 +704,7 @@ describe('computeBetweenBorderFlags', () => { const lookup = buildLookup([{ block: b1 }, { block: b2 }]); const fragments: Fragment[] = [paraFragment('b1', { y: 0, x: 50 }), paraFragment('b2', { y: 16, x: 50 })]; - const flags = computeBetweenBorderFlags(fragments, lookup); + const flags = runFlags(fragments, lookup); expect(flags.size).toBe(2); }); @@ -741,7 +722,7 @@ describe('computeBetweenBorderFlags', () => { const lookup = buildLookup([{ block: b1 }, { block: b2 }]); const fragments: Fragment[] = [paraFragment('b1', { y: 0 }), paraFragment('b2', { y: 20 })]; - const flags = computeBetweenBorderFlags(fragments, lookup); + const flags = runFlags(fragments, lookup); expect(flags.size).toBe(2); // First fragment: suppressBottomBorder (not showBetweenBorder) expect(flags.get(0)?.showBetweenBorder).toBe(false); @@ -769,7 +750,7 @@ describe('computeBetweenBorderFlags', () => { paraFragment('b3', { y: 40 }), ]; - const flags = computeBetweenBorderFlags(fragments, lookup); + const flags = runFlags(fragments, lookup); expect(flags.size).toBe(3); // First: suppress bottom, keep top expect(flags.get(0)?.suppressBottomBorder).toBe(true); @@ -796,7 +777,7 @@ describe('computeBetweenBorderFlags', () => { const lookup = buildLookup([{ block: b1 }, { block: b2 }]); const fragments: Fragment[] = [paraFragment('b1'), paraFragment('b2')]; - expect(computeBetweenBorderFlags(fragments, lookup).size).toBe(0); + expect(runFlags(fragments, lookup).size).toBe(0); }); }); diff --git a/packages/layout-engine/painters/dom/src/features/paragraph-borders/group-analysis.ts b/packages/layout-engine/painters/dom/src/features/paragraph-borders/group-analysis.ts index c225a92810..caae556b0d 100644 --- a/packages/layout-engine/painters/dom/src/features/paragraph-borders/group-analysis.ts +++ b/packages/layout-engine/painters/dom/src/features/paragraph-borders/group-analysis.ts @@ -8,17 +8,7 @@ * @ooxml w:pPr/w:pBdr/w:between — between border for grouped paragraphs * @spec ECMA-376 §17.3.1.24 (pBdr) */ -import type { - Fragment, - ListItemFragment, - ListBlock, - ListMeasure, - ParagraphBlock, - ParagraphAttrs, - ResolvedPaintItem, - ResolvedFragmentItem, -} from '@superdoc/contracts'; -import type { BlockLookup } from './types.js'; +import type { Fragment, ListItemFragment, ResolvedPaintItem, ResolvedFragmentItem } from '@superdoc/contracts'; import { hashParagraphBorders } from '../../paragraph-hash-utils.js'; /** @@ -35,74 +25,26 @@ export type BetweenBorderInfo = { gapBelow: number; }; -/** - * Extracts the paragraph borders for a fragment, looking up the block data. - * Handles both paragraph and list-item fragments. - */ -export const getFragmentParagraphBorders = ( - fragment: Fragment, - blockLookup: BlockLookup, -): ParagraphAttrs['borders'] | undefined => { - const lookup = blockLookup.get(fragment.blockId); - if (!lookup) return undefined; - - if (fragment.kind === 'para' && lookup.block.kind === 'paragraph') { - return (lookup.block as ParagraphBlock).attrs?.borders; - } - - if (fragment.kind === 'list-item' && lookup.block.kind === 'list') { - const block = lookup.block as ListBlock; - const item = block.items.find((entry) => entry.id === fragment.itemId); - return item?.paragraph.attrs?.borders; - } - - return undefined; -}; - -/** - * Computes the height of a fragment from its measured line heights. - * Used to calculate the spacing gap between consecutive fragments. - */ -export const getFragmentHeight = (fragment: Fragment, blockLookup: BlockLookup): number => { - if (fragment.kind === 'table' || fragment.kind === 'image' || fragment.kind === 'drawing') { - return fragment.height; - } - - const lookup = blockLookup.get(fragment.blockId); - if (!lookup) return 0; - - if (fragment.kind === 'para' && lookup.measure.kind === 'paragraph') { - const lines = fragment.lines ?? lookup.measure.lines.slice(fragment.fromLine, fragment.toLine); - let totalHeight = 0; - for (const line of lines) { - totalHeight += line.lineHeight ?? 0; - } - return totalHeight; - } - - if (fragment.kind === 'list-item' && lookup.measure.kind === 'list') { - const listMeasure = lookup.measure as ListMeasure; - const item = listMeasure.items.find((it) => it.itemId === fragment.itemId); - if (!item) return 0; - const lines = item.paragraph.lines.slice(fragment.fromLine, fragment.toLine); - let totalHeight = 0; - for (const line of lines) { - totalHeight += line.lineHeight ?? 0; - } - return totalHeight; - } - - return 0; -}; - /** * Whether a between border is effectively absent (nil/none or missing). */ -const isBetweenBorderNone = (borders: ParagraphAttrs['borders']): boolean => { +const isBetweenBorderNone = (borders: ResolvedFragmentItem['paragraphBorders']): boolean => { if (!borders?.between) return true; return borders.between.style === 'none'; }; +/** + * Helper: check whether a resolved item is a ResolvedFragmentItem (para/list-item) + * with pre-computed paragraph border data. + */ +function isResolvedFragmentWithBorders( + item: ResolvedPaintItem | undefined, +): item is ResolvedFragmentItem & { paragraphBorders: NonNullable } { + return ( + item !== undefined && item.kind === 'fragment' && 'paragraphBorders' in item && item.paragraphBorders !== undefined + ); +} + /** * Pre-computes per-fragment between-border rendering info for a page. * @@ -126,23 +68,9 @@ const isBetweenBorderNone = (borders: ParagraphAttrs['borders']): boolean => { * * Middle fragments in a chain of 3+ get both flags. */ - -/** - * Helper: check whether a resolved item is a ResolvedFragmentItem (para/list-item) - * with pre-computed paragraph border data. - */ -function isResolvedFragmentWithBorders( - item: ResolvedPaintItem | undefined, -): item is ResolvedFragmentItem & { paragraphBorders: NonNullable } { - return ( - item !== undefined && item.kind === 'fragment' && 'paragraphBorders' in item && item.paragraphBorders !== undefined - ); -} - export const computeBetweenBorderFlags = ( fragments: readonly Fragment[], - blockLookup: BlockLookup, - resolvedItems?: readonly ResolvedPaintItem[], + resolvedItems: readonly ResolvedPaintItem[], ): Map => { // Phase 1: determine which consecutive pairs form between-border groups const pairFlags = new Set(); @@ -153,11 +81,9 @@ export const computeBetweenBorderFlags = ( if (frag.kind !== 'para' && frag.kind !== 'list-item') continue; if (frag.continuesOnNext) continue; - const resolvedCur = resolvedItems?.[i]; - const borders = isResolvedFragmentWithBorders(resolvedCur) - ? resolvedCur.paragraphBorders - : getFragmentParagraphBorders(frag, blockLookup); - if (!borders) continue; + const resolvedCur = resolvedItems[i]; + if (!isResolvedFragmentWithBorders(resolvedCur)) continue; + const borders = resolvedCur.paragraphBorders; const next = fragments[i + 1]; if (next.kind !== 'para' && next.kind !== 'list-item') continue; @@ -171,21 +97,17 @@ export const computeBetweenBorderFlags = ( ) continue; - const resolvedNext = resolvedItems?.[i + 1]; - const nextBorders = isResolvedFragmentWithBorders(resolvedNext) - ? resolvedNext.paragraphBorders - : getFragmentParagraphBorders(next, blockLookup); - if (!nextBorders) continue; + const resolvedNext = resolvedItems[i + 1]; + if (!isResolvedFragmentWithBorders(resolvedNext)) continue; + const nextBorders = resolvedNext.paragraphBorders; // Compare using pre-computed hashes when available, falling back to computing on-the-fly. const curHash = - resolvedCur && 'paragraphBorderHash' in resolvedCur && (resolvedCur as ResolvedFragmentItem).paragraphBorderHash + 'paragraphBorderHash' in resolvedCur && (resolvedCur as ResolvedFragmentItem).paragraphBorderHash ? (resolvedCur as ResolvedFragmentItem).paragraphBorderHash! : hashParagraphBorders(borders); const nextHash = - resolvedNext && - 'paragraphBorderHash' in resolvedNext && - (resolvedNext as ResolvedFragmentItem).paragraphBorderHash + 'paragraphBorderHash' in resolvedNext && (resolvedNext as ResolvedFragmentItem).paragraphBorderHash ? (resolvedNext as ResolvedFragmentItem).paragraphBorderHash! : hashParagraphBorders(nextBorders); if (curHash !== nextHash) continue; @@ -209,11 +131,8 @@ export const computeBetweenBorderFlags = ( for (const i of pairFlags) { const frag = fragments[i]; const next = fragments[i + 1]; - const resolvedCur = resolvedItems?.[i]; - const fragHeight = - resolvedCur && 'height' in resolvedCur && resolvedCur.height != null - ? resolvedCur.height - : getFragmentHeight(frag, blockLookup); + const resolvedCur = resolvedItems[i]; + const fragHeight = resolvedCur && 'height' in resolvedCur && resolvedCur.height != null ? resolvedCur.height : 0; const gapBelow = Math.max(0, next.y - (frag.y + fragHeight)); const isNoBetween = noBetweenPairs.has(i); diff --git a/packages/layout-engine/painters/dom/src/features/paragraph-borders/index.ts b/packages/layout-engine/painters/dom/src/features/paragraph-borders/index.ts index 16dbf581f1..79084b6abe 100644 --- a/packages/layout-engine/painters/dom/src/features/paragraph-borders/index.ts +++ b/packages/layout-engine/painters/dom/src/features/paragraph-borders/index.ts @@ -15,7 +15,7 @@ */ // Group analysis -export { computeBetweenBorderFlags, getFragmentParagraphBorders, getFragmentHeight } from './group-analysis.js'; +export { computeBetweenBorderFlags } from './group-analysis.js'; export type { BetweenBorderInfo } from './group-analysis.js'; // DOM layers and CSS @@ -27,6 +27,3 @@ export { stampBetweenBorderDataset, computeBorderSpaceExpansion, } from './border-layer.js'; - -// Shared types -export type { BlockLookup, BlockLookupEntry } from './types.js'; diff --git a/packages/layout-engine/painters/dom/src/features/paragraph-borders/types.ts b/packages/layout-engine/painters/dom/src/features/paragraph-borders/types.ts deleted file mode 100644 index 12cdf624c8..0000000000 --- a/packages/layout-engine/painters/dom/src/features/paragraph-borders/types.ts +++ /dev/null @@ -1,15 +0,0 @@ -/** - * Shared types for the DomPainter rendering pipeline. - * - * BlockLookup is the canonical definition — renderer.ts and feature modules - * both import from here to avoid circular dependencies. - */ -import type { FlowBlock, Measure } from '@superdoc/contracts'; - -export type BlockLookupEntry = { - block: FlowBlock; - measure: Measure; - version: string; -}; - -export type BlockLookup = Map; diff --git a/packages/layout-engine/painters/dom/src/index.test.ts b/packages/layout-engine/painters/dom/src/index.test.ts index f758c77507..f38a089d4a 100644 --- a/packages/layout-engine/painters/dom/src/index.test.ts +++ b/packages/layout-engine/painters/dom/src/index.test.ts @@ -27,14 +27,9 @@ const emptyResolved: ResolvedLayout = { version: 1, flowMode: 'paginated', pageG * rewriting every call site. */ function createTestPainter(opts: { blocks?: FlowBlock[]; measures?: Measure[] } & DomPainterOptions) { - const { blocks: initBlocks, measures: initMeasures, ...painterOpts } = opts; + const { blocks: initBlocks, measures: initMeasures, headerProvider, footerProvider, ...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; @@ -42,9 +37,62 @@ function createTestPainter(opts: { blocks?: FlowBlock[]; measures?: Measure[] } let headerMeasures: Measure[] | undefined; let footerBlocks: FlowBlock[] | undefined; let footerMeasures: Measure[] | undefined; - let resolvedLayoutOverridden = false; + /** + * Resolve decoration items from the currently-registered decoration blocks/measures + * (plus body blocks, which historically also carry decoration block ids in tests). + * This lets tests keep using providers that return `{ fragments, height }` without items: + * the wrapper synthesizes `items` by running the fragments through `resolveLayout`. + */ + const resolveDecorationItems = ( + fragments: readonly import('@superdoc/contracts').Fragment[], + kind: 'header' | 'footer', + ): import('@superdoc/contracts').ResolvedPaintItem[] | undefined => { + 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: import('./renderer.js').PageDecorationProvider | undefined, + kind: 'header' | 'footer', + ): import('./renderer.js').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; + }; + }; + + const painter = createDomPainter({ + ...painterOpts, + headerProvider: wrapProvider(headerProvider, 'header'), + footerProvider: wrapProvider(footerProvider, 'footer'), + onPaintSnapshot: (snapshot) => { + lastPaintSnapshot = snapshot; + }, + }); + return { paint(layout: Layout, mount: HTMLElement, mapping?: unknown) { const effectiveResolved = resolvedLayoutOverridden @@ -55,24 +103,9 @@ function createTestPainter(opts: { blocks?: FlowBlock[]; measures?: Measure[] } 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: effectiveResolved, sourceLayout: layout, - headerBlocks: mergedHeaderBlocks, - headerMeasures: mergedHeaderMeasures, - footerBlocks: mergedFooterBlocks, - footerMeasures: mergedFooterMeasures, }; painter.paint(input, mount, mapping as any); }, @@ -4676,6 +4709,8 @@ describe('DomPainter', () => { fragmentKind: 'list-item', blockId: 'list-1', fragmentIndex: 0, + block: listBlock as import('@superdoc/contracts').ListBlock, + measure: listMeasure as import('@superdoc/contracts').ListMeasure, }, ], }, @@ -4705,6 +4740,8 @@ describe('DomPainter', () => { fragmentKind: 'list-item', blockId: 'list-1', fragmentIndex: 0, + block: listBlock as import('@superdoc/contracts').ListBlock, + measure: listMeasure as import('@superdoc/contracts').ListMeasure, }, ], }, @@ -4819,6 +4856,7 @@ describe('DomPainter', () => { fragmentKind: 'drawing', blockId: 'drawing-anchored', fragmentIndex: 0, + block: anchoredDrawingBlock as import('@superdoc/contracts').DrawingBlock, }, { kind: 'fragment', @@ -4832,6 +4870,7 @@ describe('DomPainter', () => { fragmentKind: 'drawing', blockId: 'drawing-inline', fragmentIndex: 1, + block: inlineDrawingBlock as import('@superdoc/contracts').DrawingBlock, }, ], }, @@ -4903,6 +4942,8 @@ describe('DomPainter', () => { fragmentKind: 'para', blockId: 'resolved-indent', fragmentIndex: 0, + block: paragraphBlock as import('@superdoc/contracts').ParagraphBlock, + measure: paragraphMeasure as import('@superdoc/contracts').ParagraphMeasure, content: { lines: [ { @@ -4996,6 +5037,8 @@ describe('DomPainter', () => { fragmentKind: 'para', blockId: 'resolved-marker', fragmentIndex: 0, + block: paragraphBlock as import('@superdoc/contracts').ParagraphBlock, + measure: paragraphMeasure as import('@superdoc/contracts').ParagraphMeasure, content: { lines: [ { @@ -5089,6 +5132,8 @@ describe('DomPainter', () => { fragmentKind: 'para', blockId: 'resolved-drop-cap', fragmentIndex: 0, + block: paragraphBlock as import('@superdoc/contracts').ParagraphBlock, + measure: paragraphMeasure as import('@superdoc/contracts').ParagraphMeasure, content: { lines: [ { diff --git a/packages/layout-engine/painters/dom/src/index.ts b/packages/layout-engine/painters/dom/src/index.ts index ab5b1b7faf..a7a701be12 100644 --- a/packages/layout-engine/painters/dom/src/index.ts +++ b/packages/layout-engine/painters/dom/src/index.ts @@ -1,8 +1,16 @@ -import type { FlowBlock, Fragment, Layout, Measure, Page, PageMargins, ResolvedLayout } from '@superdoc/contracts'; +import type { FlowBlock, Layout, Measure, PageMargins, ResolvedLayout, Page } 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'; +import type { + DomPainterInput, + PageDecorationPayload, + PageDecorationProvider, + PaintSnapshot, + PositionMapping, + RulerOptions, + FlowMode, +} from './renderer.js'; // Re-export constants export { DOM_CLASS_NAMES } from './constants.js'; @@ -56,32 +64,7 @@ export type { PmPositionValidationStats } from './pm-position-validation.js'; export type LayoutMode = 'vertical' | 'horizontal' | 'book'; export type { FlowMode } from './renderer.js'; -export type PageDecorationPayload = { - fragments: Fragment[]; - height: number; - /** - * Decoration fragments are expressed in header/footer-local coordinates. - * Header/footer layout normalizes page- and margin-relative anchors before - * they reach the painter. - */ - /** Optional measured content height; when provided, footer content will be bottom-aligned within its box. */ - contentHeight?: number; - offset?: number; - marginLeft?: number; - contentWidth?: number; - headerFooterRefId?: string; - sectionType?: string; - /** Minimum Y coordinate from layout; negative when content extends above y=0 */ - minY?: number; - box?: { x: number; y: number; width: number; height: number }; - hitRegion?: { x: number; y: number; width: number; height: number }; -}; - -export type PageDecorationProvider = ( - pageNumber: number, - pageMargins?: PageMargins, - page?: Page, -) => PageDecorationPayload | null; +export type { PageDecorationPayload, PageDecorationProvider } from './renderer.js'; export type DomPainterOptions = { /** @@ -133,32 +116,16 @@ export type DomPainterOptions = { type LegacyDomPainterState = { blocks: FlowBlock[]; measures: Measure[]; - headerBlocks?: FlowBlock[]; - headerMeasures?: Measure[]; - footerBlocks?: FlowBlock[]; - footerMeasures?: Measure[]; resolvedLayout: ResolvedLayout | null; }; -type BlockMeasurePair = { - 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; + setData(blocks: FlowBlock[], measures: Measure[]): void; /** * Legacy compatibility API. * New callers should pass resolved data via `paint(input, mount)`. @@ -178,26 +145,6 @@ function assertRequiredBlockMeasurePair(label: string, blocks: FlowBlock[], meas } } -function normalizeOptionalBlockMeasurePair( - label: 'header' | 'footer', - blocks: FlowBlock[] | undefined, - measures: Measure[] | undefined, -): BlockMeasurePair | undefined { - const hasBlocks = blocks !== undefined; - const hasMeasures = measures !== undefined; - - if (hasBlocks !== hasMeasures) { - 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, @@ -237,10 +184,6 @@ function buildLegacyPaintInput( return { resolvedLayout, sourceLayout: layout, - headerBlocks: legacyState.headerBlocks, - headerMeasures: legacyState.headerMeasures, - footerBlocks: legacyState.footerBlocks, - footerMeasures: legacyState.footerMeasures, }; } @@ -274,24 +217,10 @@ export const createDomPainter = (options: DomPainterOptions): DomPainterHandle = : buildLegacyPaintInput(input, legacyState, options.flowMode, options.pageGap); painter.paint(normalizedInput, mount, mapping); }, - setData( - blocks: FlowBlock[], - measures: Measure[], - headerBlocks?: FlowBlock[], - headerMeasures?: Measure[], - footerBlocks?: FlowBlock[], - footerMeasures?: Measure[], - ) { + setData(blocks: FlowBlock[], measures: 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; diff --git a/packages/layout-engine/painters/dom/src/renderer.ts b/packages/layout-engine/painters/dom/src/renderer.ts index 6a67feaab7..e9ea32ac6b 100644 --- a/packages/layout-engine/painters/dom/src/renderer.ts +++ b/packages/layout-engine/painters/dom/src/renderer.ts @@ -116,8 +116,6 @@ import { } from './utils/sdt-helpers.js'; import { computeBetweenBorderFlags, - getFragmentParagraphBorders, - getFragmentHeight, createParagraphDecorationLayers, applyParagraphBorderStyles, applyParagraphShadingStyles, @@ -245,32 +243,25 @@ export type RenderedLineInfo = { /** * Input to `DomPainter.paint()`. * - * `resolvedLayout` is the canonical resolved data. The remaining fields are - * bridge data carried for internal rendering of non-paragraph fragments - * (tables, images, drawings) that have not yet been migrated to resolved items. + * `resolvedLayout` is the canonical resolved data the painter reads from. + * `sourceLayout` is the raw Layout retained for legacy internal access paths. */ export type DomPainterInput = { resolvedLayout: ResolvedLayout; - /** Raw Layout for internal fragment access (bridge, will be removed once render loops iterate resolved items). */ + /** Raw Layout for internal fragment access. */ sourceLayout: Layout; - /** 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[]; }; -type OptionalBlockMeasurePair = { - blocks: FlowBlock[]; - measures: Measure[]; -}; - -type PageDecorationPayload = { +export type PageDecorationPayload = { fragments: Fragment[]; - /** Resolved items aligned 1:1 with `fragments`. Same length, same order. - * Absent when provider has no resolved data (painter falls back to blockLookup). */ + /** + * 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[]; + /** Minimum Y coordinate from layout; negative when content extends above y=0. */ + minY?: number; height: number; /** Optional measured content height to aid bottom alignment in footers. */ contentHeight?: number; @@ -333,10 +324,6 @@ type PainterOptions = { onPaintSnapshot?: (snapshot: PaintSnapshot) => void; }; -// BlockLookup lives in the shared types module (single source of truth) -import type { BlockLookupEntry, BlockLookup } from './features/paragraph-borders/types.js'; -export type { BlockLookup, BlockLookupEntry }; - type FragmentDomState = { key: string; signature: string; @@ -1226,7 +1213,6 @@ const applyLinkDataset = (element: HTMLElement, dataset?: Record * ``` */ export class DomPainter { - private blockLookup: BlockLookup; private readonly options: PainterOptions; private mount: HTMLElement | null = null; private doc: Document | null = null; @@ -1302,7 +1288,6 @@ export class DomPainter { this.options = options; this.layoutMode = options.layoutMode ?? 'vertical'; this.isSemanticFlow = (options.flowMode ?? 'paginated') === 'semantic'; - this.blockLookup = new Map(); this.headerProvider = options.headerProvider; this.footerProvider = options.footerProvider; @@ -1581,69 +1566,10 @@ export class DomPainter { }; } - /** - * Builds a new block lookup from the input data, merging header/footer blocks, - * and tracks which blocks changed since the last paint cycle. - */ - private normalizeOptionalBlockMeasurePair( - label: 'header' | 'footer', - blocks: FlowBlock[] | undefined, - measures: Measure[] | undefined, - ): OptionalBlockMeasurePair | undefined { - const hasBlocks = blocks !== undefined; - const hasMeasures = measures !== undefined; - - if (hasBlocks !== hasMeasures) { - throw new Error( - `DomPainter.paint requires ${label}Blocks and ${label}Measures to both be provided or both be omitted`, - ); - } - - if (!hasBlocks || !hasMeasures) { - return undefined; - } - - return { blocks, measures }; - } - - private updateBlockLookup(input: DomPainterInput): void { - const { headerBlocks, headerMeasures, footerBlocks, footerMeasures } = input; - const nextLookup: BlockLookup = new Map(); - - const normalizedHeader = this.normalizeOptionalBlockMeasurePair('header', headerBlocks, headerMeasures); - if (normalizedHeader) { - const headerLookup = this.buildBlockLookup(normalizedHeader.blocks, normalizedHeader.measures); - headerLookup.forEach((entry, id) => { - nextLookup.set(id, entry); - }); - } - - const normalizedFooter = this.normalizeOptionalBlockMeasurePair('footer', footerBlocks, footerMeasures); - if (normalizedFooter) { - const footerLookup = this.buildBlockLookup(normalizedFooter.blocks, normalizedFooter.measures); - footerLookup.forEach((entry, id) => { - nextLookup.set(id, entry); - }); - } - - // 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); - if (!previous || previous.version !== entry.version) { - changed.add(id); - } - }); - this.blockLookup = nextLookup; - this.changedBlocks = changed; - } - public paint(input: DomPainterInput, mount: HTMLElement, mapping?: PositionMapping): void { const layout = input.sourceLayout; this.resolvedLayout = input.resolvedLayout; - - // Update block lookup and change tracking (absorbs former setData logic) - this.updateBlockLookup(input); + this.changedBlocks.clear(); if (!(mount instanceof HTMLElement)) { throw new Error('DomPainter.paint requires a valid HTMLElement mount'); @@ -1666,8 +1592,6 @@ export class DomPainter { 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 { this.currentMapping = mapping ?? null; @@ -2225,13 +2149,9 @@ export class DomPainter { pageIndex, }; - const sdtBoundaries = computeSdtBoundaries( - page.fragments, - this.blockLookup, - this.sdtLabelsRendered, - resolvedPage?.items, - ); - const betweenBorderFlags = computeBetweenBorderFlags(page.fragments, this.blockLookup, resolvedPage?.items); + const resolvedItems = resolvedPage?.items ?? []; + const sdtBoundaries = computeSdtBoundaries(page.fragments, resolvedItems, this.sdtLabelsRendered); + const betweenBorderFlags = computeBetweenBorderFlags(page.fragments, resolvedItems); page.fragments.forEach((fragment, index) => { const sdtBoundary = sdtBoundaries.get(index); @@ -2337,12 +2257,11 @@ export class DomPainter { * Used to determine special Y positioning for page-relative anchored media * in header/footer decoration sections. */ - private isPageRelativeAnchoredFragment(fragment: Fragment, resolvedItem?: ResolvedPaintItem): boolean { + private isPageRelativeAnchoredFragment(fragment: Fragment, resolvedItem: ResolvedPaintItem | undefined): boolean { if (fragment.kind !== 'image' && fragment.kind !== 'drawing') { return false; } - const resolvedBlock = resolvedItem && 'block' in resolvedItem ? resolvedItem.block : undefined; - const block = resolvedBlock ?? this.blockLookup.get(fragment.blockId)?.block; + const block = resolvedItem && 'block' in resolvedItem ? resolvedItem.block : undefined; if (!block || (block.kind !== 'image' && block.kind !== 'drawing')) { return false; } @@ -2483,7 +2402,8 @@ export class DomPainter { }; // Compute between-border flags for header/footer paragraph fragments - const betweenBorderFlags = computeBetweenBorderFlags(data.fragments, this.blockLookup, data.items); + const decorationItems = data.items ?? []; + const betweenBorderFlags = computeBetweenBorderFlags(data.fragments, decorationItems); // Separate behindDoc fragments from normal fragments. // Prefer explicit fragment.behindDoc when present. Keep zIndex===0 as a @@ -2672,13 +2592,9 @@ export class DomPainter { const existing = new Map(state.fragments.map((frag) => [frag.key, frag])); const nextFragments: FragmentDomState[] = []; - const sdtBoundaries = computeSdtBoundaries( - page.fragments, - this.blockLookup, - this.sdtLabelsRendered, - resolvedPage?.items, - ); - const betweenBorderFlags = computeBetweenBorderFlags(page.fragments, this.blockLookup, resolvedPage?.items); + const resolvedItems = resolvedPage?.items ?? []; + const sdtBoundaries = computeSdtBoundaries(page.fragments, resolvedItems, this.sdtLabelsRendered); + const betweenBorderFlags = computeBetweenBorderFlags(page.fragments, resolvedItems); const contextBase: FragmentRenderContext = { pageNumber: page.number, @@ -2694,6 +2610,7 @@ export class DomPainter { const sdtBoundary = sdtBoundaries.get(index); const betweenInfo = betweenBorderFlags.get(index); const resolvedItem = this.getResolvedFragmentItem(pageIndex, index); + const resolvedSig = (resolvedItem as { version?: string } | undefined)?.version ?? ''; if (current) { existing.delete(key); @@ -2712,11 +2629,9 @@ export class DomPainter { newPmStart != null && current.element.dataset.pmStart != null && this.currentMapping.map(Number(current.element.dataset.pmStart)) !== newPmStart; - const resolvedSig = - resolvedItem && 'version' in resolvedItem ? (resolvedItem as { version?: string }).version : undefined; const needsRebuild = this.changedBlocks.has(fragment.blockId) || - current.signature !== (resolvedSig ?? fragmentSignature(fragment, this.blockLookup)) || + current.signature !== resolvedSig || sdtBoundaryMismatch || betweenBorderMismatch || mappingUnreliable; @@ -2725,7 +2640,7 @@ export class DomPainter { const replacement = this.renderFragment(fragment, contextBase, sdtBoundary, betweenInfo, resolvedItem); pageEl.replaceChild(replacement, current.element); current.element = replacement; - current.signature = resolvedSig ?? fragmentSignature(fragment, this.blockLookup); + current.signature = resolvedSig; } else if (this.currentMapping) { // Fragment NOT rebuilt - update position attributes to reflect document changes this.updatePositionAttributes(current.element, this.currentMapping); @@ -2745,13 +2660,11 @@ export class DomPainter { const fresh = this.renderFragment(fragment, contextBase, sdtBoundary, betweenInfo, resolvedItem); pageEl.insertBefore(fresh, pageEl.children[index] ?? null); - const freshSig = - resolvedItem && 'version' in resolvedItem ? (resolvedItem as { version?: string }).version : undefined; nextFragments.push({ key, fragment, element: fresh, - signature: freshSig ?? fragmentSignature(fragment, this.blockLookup), + signature: resolvedSig, context: contextBase, }); }); @@ -2841,13 +2754,9 @@ export class DomPainter { pageIndex, }; - const sdtBoundaries = computeSdtBoundaries( - page.fragments, - this.blockLookup, - this.sdtLabelsRendered, - resolvedPage?.items, - ); - const betweenBorderFlags = computeBetweenBorderFlags(page.fragments, this.blockLookup, resolvedPage?.items); + const resolvedItems = resolvedPage?.items ?? []; + const sdtBoundaries = computeSdtBoundaries(page.fragments, resolvedItems, this.sdtLabelsRendered); + const betweenBorderFlags = computeBetweenBorderFlags(page.fragments, resolvedItems); const fragmentStates: FragmentDomState[] = page.fragments.map((fragment, index) => { const sdtBoundary = sdtBoundaries.get(index); const resolvedItem = this.getResolvedFragmentItem(pageIndex, index); @@ -2859,11 +2768,10 @@ export class DomPainter { resolvedItem, ); el.appendChild(fragmentEl); - const initSig = - resolvedItem && 'version' in resolvedItem ? (resolvedItem as { version?: string }).version : undefined; + const initSig = (resolvedItem as { version?: string } | undefined)?.version ?? ''; return { key: fragmentKey(fragment), - signature: initSig ?? fragmentSignature(fragment, this.blockLookup), + signature: initSig, fragment, element: fragmentEl, context: contextBase, @@ -2959,16 +2867,12 @@ export class DomPainter { throw new Error('DomPainter: document is not available'); } - // Prefer pre-extracted block/measure from the resolved item; fall back to blockLookup - // for header/footer fragments that don't have a resolved item. - const { block, measure } = this.resolveBlockAndMeasure( - fragment, - resolvedItem?.block, - resolvedItem?.measure, - 'paragraph', - 'paragraph', - 'paragraph block/measure', - ); + // Pre-extracted block/measure from the resolved item. + if (resolvedItem?.block?.kind !== 'paragraph' || resolvedItem?.measure?.kind !== 'paragraph') { + throw new Error(`DomPainter: missing resolved paragraph block/measure for fragment ${fragment.blockId}`); + } + const block = resolvedItem.block as ParagraphBlock; + const measure = resolvedItem.measure as ParagraphMeasure; const wordLayout = isMinimalWordLayout(block.attrs?.wordLayout) ? block.attrs.wordLayout : undefined; const content = resolvedItem?.content; @@ -3504,16 +3408,12 @@ export class DomPainter { throw new Error('DomPainter: document is not available'); } - // Prefer pre-extracted block/measure from the resolved item; fall back to blockLookup - // for header/footer fragments that don't have a resolved item. - const { block, measure } = this.resolveBlockAndMeasure( - fragment, - resolvedItem?.block, - resolvedItem?.measure, - 'list', - 'list', - 'list block/measure', - ); + // Pre-extracted block/measure from the resolved item. + if (resolvedItem?.block?.kind !== 'list' || resolvedItem?.measure?.kind !== 'list') { + throw new Error(`DomPainter: missing resolved list block/measure for fragment ${fragment.blockId}`); + } + const block = resolvedItem.block as ListBlock; + const measure = resolvedItem.measure as ListMeasure; const item = block.items.find((entry) => entry.id === fragment.itemId); const itemMeasure = measure.items.find((entry) => entry.itemId === fragment.itemId); if (!item || !itemMeasure) { @@ -3648,9 +3548,11 @@ export class DomPainter { resolvedItem?: ResolvedImageItem, ): HTMLElement { try { - // Prefer pre-extracted block from the resolved item; fall back to blockLookup - // for header/footer fragments that don't have a resolved item. - const block = this.resolveBlock(fragment, resolvedItem?.block, 'image', 'image block'); + // Pre-extracted block from the resolved item. + if (resolvedItem?.block?.kind !== 'image') { + throw new Error(`DomPainter: missing resolved image block for fragment ${fragment.blockId}`); + } + const block = resolvedItem.block as ImageBlock; if (!this.doc) { throw new Error('DomPainter: document is not available'); @@ -3829,9 +3731,11 @@ export class DomPainter { resolvedItem?: ResolvedDrawingItem, ): HTMLElement { try { - // Prefer pre-extracted block from the resolved item; fall back to blockLookup - // for header/footer fragments that don't have a resolved item. - const block = this.resolveBlock(fragment, resolvedItem?.block, 'drawing', 'drawing block'); + // Pre-extracted block from the resolved item. + if (resolvedItem?.block?.kind !== 'drawing') { + throw new Error(`DomPainter: missing resolved drawing block for fragment ${fragment.blockId}`); + } + const block = resolvedItem.block as DrawingBlock; if (!this.doc) { throw new Error('DomPainter: document is not available'); } @@ -4666,28 +4570,14 @@ export class DomPainter { cellSpacingPx: number; effectiveColumnWidths: number[]; } { - if (resolvedItem) { - return { - block: resolvedItem.block, - measure: resolvedItem.measure, - cellSpacingPx: resolvedItem.cellSpacingPx, - effectiveColumnWidths: resolvedItem.effectiveColumnWidths, - }; - } - - const lookup = this.blockLookup.get(fragment.blockId); - if (!lookup || lookup.block.kind !== 'table' || lookup.measure.kind !== 'table') { - throw new Error(`DomPainter: missing table block for fragment ${fragment.blockId}`); + if (!resolvedItem) { + throw new Error(`DomPainter: missing resolved table item for fragment ${fragment.blockId}`); } - - const block = lookup.block as TableBlock; - const measure = lookup.measure as TableMeasure; - return { - block, - measure, - cellSpacingPx: measure.cellSpacingPx ?? getCellSpacingPx(block.attrs?.cellSpacing), - effectiveColumnWidths: fragment.columnWidths ?? measure.columnWidths, + block: resolvedItem.block, + measure: resolvedItem.measure, + cellSpacingPx: resolvedItem.cellSpacingPx, + effectiveColumnWidths: resolvedItem.effectiveColumnWidths, }; } @@ -6689,87 +6579,13 @@ export class DomPainter { if (resolvedItem && 'height' in resolvedItem && typeof resolvedItem.height === 'number') { return resolvedItem.height; } - const lookup = this.blockLookup.get(fragment.blockId); - const measure = lookup?.measure; - - if (fragment.kind === 'para' && measure?.kind === 'paragraph') { - return measure.totalHeight; - } - - if (fragment.kind === 'list-item' && measure?.kind === 'list') { - return measure.totalHeight; - } - - if (fragment.kind === 'table') { - return fragment.height; - } - - if (fragment.kind === 'image' || fragment.kind === 'drawing') { + // Atomic fragment kinds carry their own height on the fragment. + if (fragment.kind === 'table' || fragment.kind === 'image' || fragment.kind === 'drawing') { return fragment.height; } - return 0; } - /** - * Resolves the block + measure pair for a fragment. Body fragments get these from the - * ResolvedFragmentItem; header/footer fragments fall back to the blockLookup map. - */ - private resolveBlockAndMeasure( - fragment: { blockId: string }, - resolvedBlock: FlowBlock | undefined, - resolvedMeasure: Measure | undefined, - blockKind: B['kind'], - measureKind: M['kind'], - errorLabel: string, - ): { block: B; measure: M } { - if (resolvedBlock?.kind === blockKind && resolvedMeasure?.kind === measureKind) { - return { block: resolvedBlock as B, measure: resolvedMeasure as M }; - } - const lookup = this.blockLookup.get(fragment.blockId); - if (!lookup || lookup.block.kind !== blockKind || lookup.measure.kind !== measureKind) { - throw new Error(`DomPainter: missing ${errorLabel} for fragment ${fragment.blockId}`); - } - return { block: lookup.block as B, measure: lookup.measure as M }; - } - - /** - * Resolves only the block for a fragment (image/drawing rendering doesn't consume the measure). - * Body fragments get this from the ResolvedImageItem/ResolvedDrawingItem; header/footer - * fragments fall back to the blockLookup map. - */ - private resolveBlock( - fragment: { blockId: string }, - resolvedBlock: B | undefined, - blockKind: B['kind'], - errorLabel: string, - ): B { - if (resolvedBlock?.kind === blockKind) { - return resolvedBlock; - } - const lookup = this.blockLookup.get(fragment.blockId); - if (!lookup || lookup.block.kind !== blockKind) { - throw new Error(`DomPainter: missing ${errorLabel} for fragment ${fragment.blockId}`); - } - return lookup.block as B; - } - - private buildBlockLookup(blocks: FlowBlock[], measures: Measure[]): BlockLookup { - if (blocks.length !== measures.length) { - throw new Error('DomPainter requires the same number of blocks and measures'); - } - - const lookup: BlockLookup = new Map(); - blocks.forEach((block, index) => { - lookup.set(block.id, { - block, - measure: measures[index], - version: deriveBlockVersion(block), - }); - }); - return lookup; - } - /** * All dataset keys used for SDT metadata. * Shared between applySdtDataset and clearSdtDataset to ensure consistency. @@ -6936,46 +6752,20 @@ export class DomPainter { } } -const getFragmentSdtContainerKey = (fragment: Fragment, blockLookup: BlockLookup): string | null => { - const lookup = blockLookup.get(fragment.blockId); - if (!lookup) return null; - const block = lookup.block; - - if (fragment.kind === 'para' && block.kind === 'paragraph') { - const attrs = (block as { attrs?: { sdt?: SdtMetadata; containerSdt?: SdtMetadata } }).attrs; - return getSdtContainerKey(attrs?.sdt, attrs?.containerSdt); - } - - if (fragment.kind === 'list-item' && block.kind === 'list') { - const item = block.items.find((listItem) => listItem.id === fragment.itemId); - const attrs = item?.paragraph.attrs; - return getSdtContainerKey(attrs?.sdt, attrs?.containerSdt); - } - - if (fragment.kind === 'table' && block.kind === 'table') { - const attrs = (block as { attrs?: { sdt?: SdtMetadata; containerSdt?: SdtMetadata } }).attrs; - return getSdtContainerKey(attrs?.sdt, attrs?.containerSdt); - } - - return null; -}; - const computeSdtBoundaries = ( fragments: readonly Fragment[], - blockLookup: BlockLookup, + resolvedItems: readonly ResolvedPaintItem[], sdtLabelsRendered: Set, - resolvedItems?: readonly ResolvedPaintItem[], ): Map => { const boundaries = new Map(); - const containerKeys: (string | null)[] = resolvedItems - ? resolvedItems.map((item) => { - if ('sdtContainerKey' in item) { - const key = (item as { sdtContainerKey?: string | null }).sdtContainerKey; - return key ?? null; - } - return null; - }) - : fragments.map((fragment) => getFragmentSdtContainerKey(fragment, blockLookup)); + const containerKeys: (string | null)[] = fragments.map((_frag, idx) => { + const item = resolvedItems[idx]; + if (item && 'sdtContainerKey' in item) { + const key = (item as { sdtContainerKey?: string | null }).sdtContainerKey; + return key ?? null; + } + return null; + }); let i = 0; while (i < fragments.length) { @@ -7004,7 +6794,7 @@ const computeSdtBoundaries = ( let paddingBottomOverride: number | undefined; if (!isEnd) { const nextFragment = fragments[k + 1]; - const currentHeight = resolvedItems?.[k]?.height ?? getFragmentHeight(fragment, blockLookup); + const currentHeight = (resolvedItems[k] as { height?: number } | undefined)?.height ?? 0; const currentBottom = fragment.y + currentHeight; const gapToNext = nextFragment.y - currentBottom; if (gapToNext > 0) { @@ -7032,7 +6822,7 @@ const computeSdtBoundaries = ( return boundaries; }; -// getFragmentParagraphBorders, computeBetweenBorderFlags — moved to features/paragraph-borders/ +// computeBetweenBorderFlags — moved to features/paragraph-borders/ const fragmentKey = (fragment: Fragment): string => { if (fragment.kind === 'para') { @@ -7060,532 +6850,6 @@ const fragmentKey = (fragment: Fragment): string => { return _exhaustiveCheck; }; -const fragmentSignature = (fragment: Fragment, lookup: BlockLookup): string => { - const base = lookup.get(fragment.blockId)?.version ?? 'missing'; - if (fragment.kind === 'para') { - // Note: pmStart/pmEnd intentionally excluded to prevent O(n) change detection - return [ - base, - fragment.fromLine, - fragment.toLine, - fragment.continuesFromPrev ? 1 : 0, - fragment.continuesOnNext ? 1 : 0, - fragment.markerWidth ?? '', // Include markerWidth to trigger re-render when list status changes - ].join('|'); - } - if (fragment.kind === 'list-item') { - return [ - base, - fragment.itemId, - fragment.fromLine, - fragment.toLine, - fragment.continuesFromPrev ? 1 : 0, - fragment.continuesOnNext ? 1 : 0, - ].join('|'); - } - if (fragment.kind === 'image') { - return [base, fragment.width, fragment.height].join('|'); - } - if (fragment.kind === 'drawing') { - return [ - base, - fragment.drawingKind, - fragment.drawingContentId ?? '', - fragment.width, - fragment.height, - fragment.geometry.width, - fragment.geometry.height, - fragment.geometry.rotation ?? 0, - fragment.scale ?? 1, - fragment.zIndex ?? '', - ].join('|'); - } - if (fragment.kind === 'table') { - // Include all properties that affect table fragment rendering - const partialSig = fragment.partialRow - ? `${fragment.partialRow.fromLineByCell.join(',')}-${fragment.partialRow.toLineByCell.join(',')}-${fragment.partialRow.partialHeight}` - : ''; - return [ - base, - fragment.fromRow, - fragment.toRow, - fragment.width, - fragment.height, - fragment.continuesFromPrev ? 1 : 0, - fragment.continuesOnNext ? 1 : 0, - fragment.repeatHeaderCount ?? 0, - partialSig, - ].join('|'); - } - return base; -}; - -const getSdtMetadataId = (metadata: SdtMetadata | null | undefined): string => { - if (!metadata) return ''; - if ('id' in metadata && metadata.id != null) { - return String(metadata.id); - } - return ''; -}; - -const getSdtMetadataLockMode = (metadata: SdtMetadata | null | undefined): string => { - if (!metadata) return ''; - return metadata.type === 'structuredContent' ? (metadata.lockMode ?? '') : ''; -}; - -const getSdtMetadataVersion = (metadata: SdtMetadata | null | undefined): string => { - if (!metadata) return ''; - return [metadata.type, getSdtMetadataLockMode(metadata), getSdtMetadataId(metadata)].join(':'); -}; - -/** - * Type guard to validate list marker attributes structure. - * - * @param attrs - The paragraph attributes to validate - * @returns True if the attrs contain valid list marker properties - */ -const hasListMarkerProperties = ( - attrs: unknown, -): attrs is { - numberingProperties: { numId?: number | string; ilvl?: number }; - wordLayout?: { marker?: { markerText?: string } }; -} => { - if (!attrs || typeof attrs !== 'object') return false; - const obj = attrs as Record; - - if (!obj.numberingProperties || typeof obj.numberingProperties !== 'object') return false; - const numProps = obj.numberingProperties as Record; - - // Validate numId is number or string if present - if ('numId' in numProps) { - const numId = numProps.numId; - if (typeof numId !== 'number' && typeof numId !== 'string') return false; - } - - // Validate ilvl is number if present - if ('ilvl' in numProps) { - const ilvl = numProps.ilvl; - if (typeof ilvl !== 'number') return false; - } - - // Validate wordLayout structure if present - if ('wordLayout' in obj && obj.wordLayout !== undefined) { - if (typeof obj.wordLayout !== 'object' || obj.wordLayout === null) return false; - const wordLayout = obj.wordLayout as Record; - - if ('marker' in wordLayout && wordLayout.marker !== undefined) { - if (typeof wordLayout.marker !== 'object' || wordLayout.marker === null) return false; - const marker = wordLayout.marker as Record; - - if ('markerText' in marker && marker.markerText !== undefined) { - if (typeof marker.markerText !== 'string') return false; - } - } - } - - return true; -}; - -/** - * Derives a version string for a flow block based on its content and styling properties. - * - * This version string is used for cache invalidation - when any visual property of the block - * changes, the version string changes, triggering a DOM rebuild instead of reusing cached elements. - * - * The version includes all properties that affect visual rendering: - * - Text content - * - Font properties (family, size, bold, italic) - * - Text decorations (underline style/color, strike, highlight) - * - Spacing (letterSpacing) - * - Position markers (pmStart, pmEnd) - * - Special tokens (page numbers, etc.) - * - List marker properties (numId, ilvl, markerText) - for list indent changes - * - Paragraph attributes (alignment, spacing, indent, borders, shading, direction, rtl, tabs) - * - Table cell content and paragraph formatting within cells - * - * For table blocks, a deep hash is computed across all rows and cells, including: - * - Cell block content (paragraph runs, text, formatting) - * - Paragraph-level attributes in cells (alignment, spacing, line height, indent, borders, shading) - * - Run-level formatting (color, highlight, bold, italic, fontSize, fontFamily, underline, strike) - * - * This ensures toolbar commands that modify paragraph or run formatting within tables - * trigger proper DOM updates. - * - * @param block - The flow block to generate a version string for - * @returns A pipe-delimited string representing all visual properties of the block. - * Changes to any included property will change the version string. - */ -const deriveBlockVersion = (block: FlowBlock): string => { - if (block.kind === 'paragraph') { - // Include list marker info in version to detect indent/marker changes - const markerVersion = hasListMarkerProperties(block.attrs) - ? `marker:${block.attrs.numberingProperties.numId ?? ''}:${block.attrs.numberingProperties.ilvl ?? 0}:${block.attrs.wordLayout?.marker?.markerText ?? ''}` - : ''; - - const runsVersion = block.runs - .map((run) => { - // Handle ImageRun - if (run.kind === 'image') { - const imgRun = run as ImageRun; - return [ - 'img', - imgRun.src, - imgRun.width, - imgRun.height, - imgRun.alt ?? '', - imgRun.title ?? '', - imgRun.clipPath ?? '', - imgRun.distTop ?? '', - imgRun.distBottom ?? '', - imgRun.distLeft ?? '', - imgRun.distRight ?? '', - readClipPathValue((imgRun as { clipPath?: unknown }).clipPath), - // Note: pmStart/pmEnd intentionally excluded to prevent O(n) change detection - ].join(','); - } - - // Handle LineBreakRun - if (run.kind === 'lineBreak') { - // Note: pmStart/pmEnd intentionally excluded to prevent O(n) change detection - return 'linebreak'; - } - - // Handle TabRun - if (run.kind === 'tab') { - // Note: pmStart/pmEnd intentionally excluded to prevent O(n) change detection - return [run.text ?? '', 'tab'].join(','); - } - - // Handle FieldAnnotationRun - if (run.kind === 'fieldAnnotation') { - const size = run.size ? `${run.size.width ?? ''}x${run.size.height ?? ''}` : ''; - const highlighted = run.highlighted !== false ? 1 : 0; - return [ - 'field', - run.variant ?? '', - run.displayLabel ?? '', - run.fieldColor ?? '', - run.borderColor ?? '', - highlighted, - run.hidden ? 1 : 0, - run.visibility ?? '', - run.imageSrc ?? '', - run.linkUrl ?? '', - run.rawHtml ?? '', - size, - run.fontFamily ?? '', - run.fontSize ?? '', - run.textColor ?? '', - run.textHighlight ?? '', - run.bold ? 1 : 0, - run.italic ? 1 : 0, - run.underline ? 1 : 0, - run.fieldId ?? '', - run.fieldType ?? '', - ].join(','); - } - - // Handle TextRun (kind is 'text' or undefined) - const textRun = run as TextRun; - return [ - textRun.text ?? '', - textRun.fontFamily, - textRun.fontSize, - textRun.bold ? 1 : 0, - textRun.italic ? 1 : 0, - textRun.color ?? '', - // Text decorations - ensures DOM updates when decoration properties change. - textRun.underline?.style ?? '', - textRun.underline?.color ?? '', - textRun.strike ? 1 : 0, - textRun.highlight ?? '', - textRun.letterSpacing != null ? textRun.letterSpacing : '', - textRun.vertAlign ?? '', - textRun.baselineShift != null ? textRun.baselineShift : '', - // Note: pmStart/pmEnd intentionally excluded to prevent O(n) change detection - textRun.token ?? '', - // Tracked changes - force re-render when added or removed tracked change - textRun.trackedChange ? 1 : 0, - // Comment annotations - force re-render when comments are enabled/disabled - textRun.comments?.length ?? 0, - ].join(','); - }) - .join('|'); - - // Include paragraph-level attributes that affect rendering (alignment, spacing, indent, etc.) - // This ensures DOM updates when toolbar commands like "align center" change these properties. - const attrs = block.attrs as ParagraphAttrs | undefined; - - const paragraphAttrsVersion = attrs - ? [ - attrs.alignment ?? '', - attrs.spacing?.before ?? '', - attrs.spacing?.after ?? '', - attrs.spacing?.line ?? '', - attrs.spacing?.lineRule ?? '', - attrs.indent?.left ?? '', - attrs.indent?.right ?? '', - attrs.indent?.firstLine ?? '', - attrs.indent?.hanging ?? '', - attrs.borders ? hashParagraphBorders(attrs.borders) : '', - attrs.shading?.fill ?? '', - attrs.shading?.color ?? '', - attrs.direction ?? '', - attrs.rtl ? '1' : '', - attrs.tabs?.length ? JSON.stringify(attrs.tabs) : '', - ].join(':') - : ''; - - // Include SDT metadata so lock-mode (and other SDT property) changes invalidate the cache. - const sdtAttrs = (block.attrs as ParagraphAttrs | undefined)?.sdt; - const sdtVersion = getSdtMetadataVersion(sdtAttrs); - - // Combine marker version, runs version, paragraph attrs version, and SDT version - const parts = [markerVersion, runsVersion, paragraphAttrsVersion, sdtVersion].filter(Boolean); - return parts.join('|'); - } - - if (block.kind === 'list') { - return block.items.map((item) => `${item.id}:${item.marker.text}:${deriveBlockVersion(item.paragraph)}`).join('|'); - } - - if (block.kind === 'image') { - const imgSdt = (block as ImageBlock).attrs?.sdt; - const imgSdtVersion = getSdtMetadataVersion(imgSdt); - return [ - block.src ?? '', - block.width ?? '', - block.height ?? '', - block.alt ?? '', - block.title ?? '', - resolveBlockClipPath(block), - imgSdtVersion, - ].join('|'); - } - - if (block.kind === 'drawing') { - if (block.drawingKind === 'image') { - // Type narrowing: block is ImageDrawing (not ImageBlock) - const imageLike = block as ImageDrawing; - return [ - 'drawing:image', - imageLike.src ?? '', - imageLike.width ?? '', - imageLike.height ?? '', - imageLike.alt ?? '', - resolveBlockClipPath(imageLike), - ].join('|'); - } - if (block.drawingKind === 'vectorShape') { - const vector = block as VectorShapeDrawing; - return [ - 'drawing:vector', - vector.shapeKind ?? '', - vector.fillColor ?? '', - vector.strokeColor ?? '', - vector.strokeWidth ?? '', - vector.geometry.width, - vector.geometry.height, - vector.geometry.rotation ?? 0, - vector.geometry.flipH ? 1 : 0, - vector.geometry.flipV ? 1 : 0, - ].join('|'); - } - if (block.drawingKind === 'shapeGroup') { - const group = block as ShapeGroupDrawing; - const childSignature = group.shapes - .map((child) => `${child.shapeType}:${JSON.stringify(child.attrs ?? {})}`) - .join(';'); - return [ - 'drawing:group', - group.geometry.width, - group.geometry.height, - group.groupTransform ? JSON.stringify(group.groupTransform) : '', - childSignature, - ].join('|'); - } - if (block.drawingKind === 'chart') { - return [ - 'drawing:chart', - block.chartData?.chartType ?? '', - block.chartData?.series?.length ?? 0, - block.geometry.width, - block.geometry.height, - block.chartRelId ?? '', - ].join('|'); - } - // Exhaustiveness check: if a new drawingKind is added, TypeScript will error here - const _exhaustive: never = block; - return `drawing:unknown:${(block as DrawingBlock).id}`; - } - - if (block.kind === 'table') { - const tableBlock = block as TableBlock; - /** - * Local hash function for strings using FNV-1a algorithm. - * Used to create a robust hash across all table rows/cells so deep edits invalidate version. - * - * @param seed - Initial hash value - * @param value - String value to hash - * @returns Updated hash value - */ - const hashString = (seed: number, value: string): number => { - let hash = seed >>> 0; - for (let i = 0; i < value.length; i++) { - hash ^= value.charCodeAt(i); - hash = Math.imul(hash, 16777619); // FNV-style mix - } - return hash >>> 0; - }; - - /** - * Local hash function for numbers. - * Handles undefined/null values safely by treating them as 0. - * - * @param seed - Initial hash value - * @param value - Number value to hash (or undefined/null) - * @returns Updated hash value - */ - const hashNumber = (seed: number, value: number | undefined | null): number => { - const n = Number.isFinite(value) ? (value as number) : 0; - let hash = seed ^ n; - hash = Math.imul(hash, 16777619); - hash ^= hash >>> 13; - return hash >>> 0; - }; - - let hash = 2166136261; - hash = hashString(hash, block.id); - hash = hashNumber(hash, tableBlock.rows.length); - hash = (tableBlock.columnWidths ?? []).reduce((acc, width) => hashNumber(acc, Math.round(width * 1000)), hash); - - // Defensive guards: ensure rows array exists and iterate safely - const rows = tableBlock.rows ?? []; - for (const row of rows) { - if (!row || !Array.isArray(row.cells)) continue; - hash = hashNumber(hash, row.cells.length); - for (const cell of row.cells) { - if (!cell) continue; - const cellBlocks = cell.blocks ?? (cell.paragraph ? [cell.paragraph] : []); - hash = hashNumber(hash, cellBlocks.length); - // Include cell attributes that affect rendering (rowSpan, colSpan, borders, etc.) - hash = hashNumber(hash, cell.rowSpan ?? 1); - hash = hashNumber(hash, cell.colSpan ?? 1); - - // Include cell-level attributes (borders, padding, background) that affect rendering - // This ensures cache invalidation when cell formatting changes (e.g., remove borders). - if (cell.attrs) { - const cellAttrs = cell.attrs as TableCellAttrs; - if (cellAttrs.borders) { - hash = hashString(hash, hashCellBorders(cellAttrs.borders)); - } - if (cellAttrs.padding) { - const p = cellAttrs.padding; - hash = hashNumber(hash, p.top ?? 0); - hash = hashNumber(hash, p.right ?? 0); - hash = hashNumber(hash, p.bottom ?? 0); - hash = hashNumber(hash, p.left ?? 0); - } - if (cellAttrs.verticalAlign) { - hash = hashString(hash, cellAttrs.verticalAlign); - } - if (cellAttrs.background) { - hash = hashString(hash, cellAttrs.background); - } - } - - for (const cellBlock of cellBlocks) { - hash = hashString(hash, cellBlock?.kind ?? 'unknown'); - if (cellBlock?.kind === 'paragraph') { - const paragraphBlock = cellBlock as ParagraphBlock; - const runs = paragraphBlock.runs ?? []; - hash = hashNumber(hash, runs.length); - - // Include paragraph-level attributes that affect rendering - // (alignment, spacing, indent, etc.) - fixes toolbar commands not updating tables - const attrs = paragraphBlock.attrs as ParagraphAttrs | undefined; - - if (attrs) { - hash = hashString(hash, attrs.alignment ?? ''); - hash = hashNumber(hash, attrs.spacing?.before ?? 0); - hash = hashNumber(hash, attrs.spacing?.after ?? 0); - hash = hashNumber(hash, attrs.spacing?.line ?? 0); - hash = hashString(hash, attrs.spacing?.lineRule ?? ''); - hash = hashNumber(hash, attrs.indent?.left ?? 0); - hash = hashNumber(hash, attrs.indent?.right ?? 0); - hash = hashNumber(hash, attrs.indent?.firstLine ?? 0); - hash = hashNumber(hash, attrs.indent?.hanging ?? 0); - hash = hashString(hash, attrs.shading?.fill ?? ''); - hash = hashString(hash, attrs.shading?.color ?? ''); - hash = hashString(hash, attrs.direction ?? ''); - hash = hashString(hash, attrs.rtl ? '1' : ''); - if (attrs.borders) { - hash = hashString(hash, hashParagraphBorders(attrs.borders)); - } - } - - for (const run of runs) { - // Only text runs have .text property; ImageRun does not - if ('text' in run && typeof run.text === 'string') { - hash = hashString(hash, run.text); - } - hash = hashNumber(hash, run.pmStart ?? -1); - hash = hashNumber(hash, run.pmEnd ?? -1); - - // Include run formatting properties that affect rendering - // (color, highlight, bold, italic, etc.) - fixes toolbar commands not updating tables - hash = hashString(hash, getRunStringProp(run, 'color')); - hash = hashString(hash, getRunStringProp(run, 'highlight')); - hash = hashString(hash, getRunBooleanProp(run, 'bold') ? '1' : ''); - hash = hashString(hash, getRunBooleanProp(run, 'italic') ? '1' : ''); - hash = hashNumber(hash, getRunNumberProp(run, 'fontSize')); - hash = hashString(hash, getRunStringProp(run, 'fontFamily')); - hash = hashString(hash, getRunUnderlineStyle(run)); - hash = hashString(hash, getRunUnderlineColor(run)); - hash = hashString(hash, getRunBooleanProp(run, 'strike') ? '1' : ''); - hash = hashString(hash, getRunStringProp(run, 'vertAlign')); - hash = hashNumber(hash, getRunNumberProp(run, 'baselineShift')); - } - } - } - } - } - - // Include table-level attributes (borders, etc.) that affect rendering - // This ensures cache invalidation when table formatting changes (e.g., remove borders). - if (tableBlock.attrs) { - const tblAttrs = tableBlock.attrs as TableAttrs; - if (tblAttrs.borders) { - hash = hashString(hash, hashTableBorders(tblAttrs.borders)); - } - if (tblAttrs.borderCollapse) { - hash = hashString(hash, tblAttrs.borderCollapse); - } - if (tblAttrs.cellSpacing !== undefined) { - const cs = tblAttrs.cellSpacing; - if (typeof cs === 'number') { - hash = hashNumber(hash, cs); - } else { - // Stable key: value and type only (avoid JSON.stringify key-order variance) - const v = (cs as { value?: number; type?: string }).value ?? 0; - const t = (cs as { value?: number; type?: string }).type ?? 'px'; - hash = hashString(hash, `cs:${v}:${t}`); - } - } - // Include SDT metadata so lock-mode changes invalidate the cache. - if (tblAttrs.sdt) { - hash = hashString(hash, tblAttrs.sdt.type); - hash = hashString(hash, getSdtMetadataLockMode(tblAttrs.sdt)); - hash = hashString(hash, getSdtMetadataId(tblAttrs.sdt)); - } - } - - return [block.id, tableBlock.rows.length, hash.toString(16)].join('|'); - } - - return block.id; -}; - const DEFAULT_SUPERSCRIPT_RAISE_RATIO = 0.33; const DEFAULT_SUBSCRIPT_LOWER_RATIO = 0.14; diff --git a/packages/layout-engine/painters/dom/src/virtualization.test.ts b/packages/layout-engine/painters/dom/src/virtualization.test.ts index b64a464bad..b14f510638 100644 --- a/packages/layout-engine/painters/dom/src/virtualization.test.ts +++ b/packages/layout-engine/painters/dom/src/virtualization.test.ts @@ -34,10 +34,6 @@ function createTestPainter(opts: { blocks?: FlowBlock[]; measures?: Measure[] } const input: DomPainterInput = { resolvedLayout: effectiveResolved, sourceLayout: layout, - headerBlocks: undefined, - headerMeasures: undefined, - footerBlocks: undefined, - footerMeasures: undefined, }; painter.paint(input, mount, mapping as any); }, 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 ba825129a8..18d0f4c416 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 @@ -4376,47 +4376,6 @@ export class PresentationEditor extends EventEmitter { ); } - // Extract header/footer blocks and measures from layout results - const headerBlocks: FlowBlock[] = []; - const headerMeasures: Measure[] = []; - if (headerLayouts) { - for (const headerResult of headerLayouts) { - headerBlocks.push(...headerResult.blocks); - headerMeasures.push(...headerResult.measures); - } - } - // Also include per-rId header blocks for multi-section support - const headerLayoutsByRId = this.#headerFooterSession?.headerLayoutsByRId; - if (headerLayoutsByRId) { - for (const rIdResult of headerLayoutsByRId.values()) { - headerBlocks.push(...rIdResult.blocks); - headerMeasures.push(...rIdResult.measures); - } - } - - const footerBlocks: FlowBlock[] = []; - const footerMeasures: Measure[] = []; - if (footerLayouts) { - for (const footerResult of footerLayouts) { - footerBlocks.push(...footerResult.blocks); - footerMeasures.push(...footerResult.measures); - } - } - // Also include per-rId footer blocks for multi-section support - const footerLayoutsByRId = this.#headerFooterSession?.footerLayoutsByRId; - if (footerLayoutsByRId) { - for (const rIdResult of footerLayoutsByRId.values()) { - footerBlocks.push(...rIdResult.blocks); - footerMeasures.push(...rIdResult.measures); - } - } - - // Merge any extra lookup blocks (e.g., footnotes injected into page fragments) - if (extraBlocks && extraMeasures && extraBlocks.length === extraMeasures.length && extraBlocks.length > 0) { - footerBlocks.push(...extraBlocks); - footerMeasures.push(...extraMeasures); - } - // Avoid MutationObserver overhead while repainting large DOM trees. this.#domIndexObserverManager?.pause(); // Pass the transaction mapping for efficient position attribute updates. @@ -4427,10 +4386,6 @@ export class PresentationEditor extends EventEmitter { const paintInput: DomPainterInput = { resolvedLayout, sourceLayout: layout, - headerBlocks: headerBlocks.length > 0 ? headerBlocks : undefined, - headerMeasures: headerMeasures.length > 0 ? headerMeasures : undefined, - footerBlocks: footerBlocks.length > 0 ? footerBlocks : undefined, - footerMeasures: footerMeasures.length > 0 ? footerMeasures : undefined, }; this.#painterAdapter.paint(paintInput, this.#painterHost, mapping ?? undefined); const painterPaintEnd = perfNow();