diff --git a/packages/layout-engine/contracts/src/resolved-layout.ts b/packages/layout-engine/contracts/src/resolved-layout.ts index f94348e077..ca615e563b 100644 --- a/packages/layout-engine/contracts/src/resolved-layout.ts +++ b/packages/layout-engine/contracts/src/resolved-layout.ts @@ -6,6 +6,7 @@ import type { ImageFragmentMetadata, Line, PageMargins, + ParagraphBorders, SectionVerticalAlign, TableBlock, TableMeasure, @@ -120,6 +121,10 @@ export type ResolvedFragmentItem = { content?: ResolvedParagraphContent; /** Pre-computed SDT container key for boundary grouping (`structuredContent:` or `documentSection:`). */ sdtContainerKey?: string | null; + /** Pre-computed hash of paragraph borders for between-border grouping. */ + paragraphBorderHash?: string; + /** Pre-extracted paragraph borders for between-border rendering. */ + paragraphBorders?: ParagraphBorders; }; /** Resolved paragraph content for non-table paragraph/list-item fragments. */ diff --git a/packages/layout-engine/layout-resolved/src/paragraphBorderHash.ts b/packages/layout-engine/layout-resolved/src/paragraphBorderHash.ts new file mode 100644 index 0000000000..b49022cc69 --- /dev/null +++ b/packages/layout-engine/layout-resolved/src/paragraphBorderHash.ts @@ -0,0 +1,33 @@ +import type { ParagraphBorder, ParagraphBorders } from '@superdoc/contracts'; + +/** + * Hashes a single paragraph border for equality comparison. + * + * Duplicated from painters/dom/src/paragraph-hash-utils.ts to avoid a + * circular dependency (painter-dom → layout-resolved is not allowed). + * Keep the two copies in sync. + */ +const hashParagraphBorder = (border: ParagraphBorder): string => { + const parts: string[] = []; + if (border.style !== undefined) parts.push(`s:${border.style}`); + if (border.width !== undefined) parts.push(`w:${border.width}`); + if (border.color !== undefined) parts.push(`c:${border.color}`); + if (border.space !== undefined) parts.push(`sp:${border.space}`); + return parts.join(','); +}; + +/** + * Hashes a full paragraph borders object for grouping comparison. + * + * Two paragraph fragments with the same hash belong to the same border group + * per ECMA-376 §17.3.1.24. + */ +export const hashParagraphBorders = (borders: ParagraphBorders): string => { + const parts: string[] = []; + if (borders.top) parts.push(`t:[${hashParagraphBorder(borders.top)}]`); + if (borders.right) parts.push(`r:[${hashParagraphBorder(borders.right)}]`); + if (borders.bottom) parts.push(`b:[${hashParagraphBorder(borders.bottom)}]`); + if (borders.left) parts.push(`l:[${hashParagraphBorder(borders.left)}]`); + if (borders.between) parts.push(`bw:[${hashParagraphBorder(borders.between)}]`); + return parts.join(';'); +}; diff --git a/packages/layout-engine/layout-resolved/src/resolveLayout.test.ts b/packages/layout-engine/layout-resolved/src/resolveLayout.test.ts index d954134b8d..71afb9ab9f 100644 --- a/packages/layout-engine/layout-resolved/src/resolveLayout.test.ts +++ b/packages/layout-engine/layout-resolved/src/resolveLayout.test.ts @@ -2378,4 +2378,237 @@ describe('resolveLayout', () => { expect(item.sdtContainerKey).toBeUndefined(); }); }); + + describe('paragraphBorders pre-computation', () => { + it('populates paragraphBorders and paragraphBorderHash for a paragraph with borders', () => { + const borders = { + top: { style: 'solid' as const, width: 4, color: '#000000' }, + bottom: { style: 'solid' as const, width: 4, color: '#000000' }, + left: { style: 'solid' as const, width: 4, color: '#000000' }, + right: { style: 'solid' as const, width: 4, color: '#000000' }, + between: { style: 'solid' as const, width: 4, color: '#000000' }, + }; + const layout: Layout = { + pageSize: { w: 612, h: 792 }, + pages: [ + { + number: 1, + fragments: [{ kind: 'para', blockId: 'p1', fromLine: 0, toLine: 1, x: 72, y: 100, width: 468 }], + }, + ], + }; + const blocks: FlowBlock[] = [{ kind: 'paragraph', id: 'p1', runs: [], attrs: { borders } }]; + const measures: Measure[] = [ + { + kind: 'paragraph', + lines: [{ fromRun: 0, fromChar: 0, toRun: 0, toChar: 5, width: 200, ascent: 12, descent: 4, lineHeight: 20 }], + totalHeight: 20, + }, + ]; + + const result = resolveLayout({ layout, flowMode: 'paginated', blocks, measures }); + const item = result.pages[0].items[0] as import('@superdoc/contracts').ResolvedFragmentItem; + expect(item.paragraphBorders).toEqual(borders); + expect(item.paragraphBorderHash).toBeDefined(); + expect(typeof item.paragraphBorderHash).toBe('string'); + expect(item.paragraphBorderHash!.length).toBeGreaterThan(0); + }); + + it('omits paragraphBorders and paragraphBorderHash when paragraph has no borders', () => { + const layout: Layout = { + pageSize: { w: 612, h: 792 }, + pages: [ + { + number: 1, + fragments: [{ kind: 'para', blockId: 'p1', fromLine: 0, toLine: 1, x: 72, y: 100, width: 468 }], + }, + ], + }; + const blocks: FlowBlock[] = [{ kind: 'paragraph', id: 'p1', runs: [] }]; + const measures: Measure[] = [{ kind: 'paragraph', lines: [], totalHeight: 0 }]; + + const result = resolveLayout({ layout, flowMode: 'paginated', blocks, measures }); + const item = result.pages[0].items[0] as import('@superdoc/contracts').ResolvedFragmentItem; + expect(item.paragraphBorders).toBeUndefined(); + expect(item.paragraphBorderHash).toBeUndefined(); + }); + + it('produces matching hashes for identical border definitions', () => { + const borders = { + top: { style: 'solid' as const, width: 4, color: '#000000' }, + bottom: { style: 'solid' as const, width: 4, color: '#000000' }, + }; + const layout: Layout = { + pageSize: { w: 612, h: 792 }, + pages: [ + { + number: 1, + fragments: [ + { kind: 'para', blockId: 'p1', fromLine: 0, toLine: 1, x: 72, y: 100, width: 468 }, + { kind: 'para', blockId: 'p2', fromLine: 0, toLine: 1, x: 72, y: 130, width: 468 }, + ], + }, + ], + }; + const blocks: FlowBlock[] = [ + { kind: 'paragraph', id: 'p1', runs: [], attrs: { borders } }, + { kind: 'paragraph', id: 'p2', runs: [], attrs: { borders: { ...borders } } }, + ]; + const measures: Measure[] = [ + { + kind: 'paragraph', + lines: [{ fromRun: 0, fromChar: 0, toRun: 0, toChar: 5, width: 200, ascent: 12, descent: 4, lineHeight: 20 }], + totalHeight: 20, + }, + { + kind: 'paragraph', + lines: [{ fromRun: 0, fromChar: 0, toRun: 0, toChar: 5, width: 200, ascent: 12, descent: 4, lineHeight: 20 }], + totalHeight: 20, + }, + ]; + + const result = resolveLayout({ layout, flowMode: 'paginated', blocks, measures }); + const item0 = result.pages[0].items[0] as import('@superdoc/contracts').ResolvedFragmentItem; + const item1 = result.pages[0].items[1] as import('@superdoc/contracts').ResolvedFragmentItem; + expect(item0.paragraphBorderHash).toBe(item1.paragraphBorderHash); + }); + + it('produces different hashes for different border definitions', () => { + const layout: Layout = { + pageSize: { w: 612, h: 792 }, + pages: [ + { + number: 1, + fragments: [ + { kind: 'para', blockId: 'p1', fromLine: 0, toLine: 1, x: 72, y: 100, width: 468 }, + { kind: 'para', blockId: 'p2', fromLine: 0, toLine: 1, x: 72, y: 130, width: 468 }, + ], + }, + ], + }; + const blocks: FlowBlock[] = [ + { + kind: 'paragraph', + id: 'p1', + runs: [], + attrs: { borders: { top: { style: 'solid' as const, width: 4, color: '#000000' } } }, + }, + { + kind: 'paragraph', + id: 'p2', + runs: [], + attrs: { borders: { top: { style: 'dashed' as const, width: 2, color: '#FF0000' } } }, + }, + ]; + const measures: Measure[] = [ + { + kind: 'paragraph', + lines: [{ fromRun: 0, fromChar: 0, toRun: 0, toChar: 5, width: 200, ascent: 12, descent: 4, lineHeight: 20 }], + totalHeight: 20, + }, + { + kind: 'paragraph', + lines: [{ fromRun: 0, fromChar: 0, toRun: 0, toChar: 5, width: 200, ascent: 12, descent: 4, lineHeight: 20 }], + totalHeight: 20, + }, + ]; + + const result = resolveLayout({ layout, flowMode: 'paginated', blocks, measures }); + const item0 = result.pages[0].items[0] as import('@superdoc/contracts').ResolvedFragmentItem; + const item1 = result.pages[0].items[1] as import('@superdoc/contracts').ResolvedFragmentItem; + expect(item0.paragraphBorderHash).not.toBe(item1.paragraphBorderHash); + }); + + it('populates paragraphBorders for list-item fragments', () => { + const borders = { + top: { style: 'solid' as const, width: 2, color: '#0000FF' }, + between: { style: 'solid' as const, width: 1, color: '#0000FF' }, + }; + const listItemFragment: ListItemFragment = { + kind: 'list-item', + blockId: 'list1', + itemId: 'item-a', + fromLine: 0, + toLine: 1, + x: 72, + y: 100, + width: 468, + markerWidth: 36, + }; + const layout: Layout = { + pageSize: { w: 612, h: 792 }, + pages: [{ number: 1, fragments: [listItemFragment] }], + }; + const blocks: FlowBlock[] = [ + { + kind: 'list', + id: 'list1', + listType: 'bullet', + items: [ + { + id: 'item-a', + marker: { text: '•', style: {} }, + paragraph: { kind: 'paragraph', id: 'item-a-p', runs: [], attrs: { borders } }, + }, + ], + }, + ]; + const measures: Measure[] = [ + { + kind: 'list', + items: [ + { + itemId: 'item-a', + markerWidth: 36, + markerTextWidth: 10, + indentLeft: 36, + paragraph: { + kind: 'paragraph', + lines: [ + { fromRun: 0, fromChar: 0, toRun: 0, toChar: 5, width: 200, ascent: 12, descent: 4, lineHeight: 20 }, + ], + totalHeight: 20, + }, + }, + ], + totalHeight: 20, + }, + ]; + + const result = resolveLayout({ layout, flowMode: 'paginated', blocks, measures }); + const item = result.pages[0].items[0] as import('@superdoc/contracts').ResolvedFragmentItem; + expect(item.paragraphBorders).toEqual(borders); + expect(item.paragraphBorderHash).toBeDefined(); + }); + + it('does not add paragraphBorders to table items', () => { + const tableFragment: TableFragment = { + kind: 'table', + blockId: 'tbl1', + fromRow: 0, + toRow: 1, + x: 72, + y: 100, + width: 468, + height: 100, + }; + const layout: Layout = { + pageSize: { w: 612, h: 792 }, + pages: [{ number: 1, fragments: [tableFragment] }], + }; + const blocks: FlowBlock[] = [{ kind: 'table', id: 'tbl1', rows: [{ cells: [] }] } as any]; + const measures: Measure[] = [ + { + kind: 'table', + columnWidths: [468], + rows: [{ cells: [{ width: 468, height: 100 }] }], + } as any, + ]; + + const result = resolveLayout({ layout, flowMode: 'paginated', blocks, measures }); + const item = result.pages[0].items[0] as any; + expect(item.paragraphBorders).toBeUndefined(); + expect(item.paragraphBorderHash).toBeUndefined(); + }); + }); }); diff --git a/packages/layout-engine/layout-resolved/src/resolveLayout.ts b/packages/layout-engine/layout-resolved/src/resolveLayout.ts index 96a058cfa2..9ea0b0cb97 100644 --- a/packages/layout-engine/layout-resolved/src/resolveLayout.ts +++ b/packages/layout-engine/layout-resolved/src/resolveLayout.ts @@ -10,6 +10,7 @@ import type { ParaFragment, TableFragment, Line, + ParagraphBorders, ResolvedLayout, ResolvedPage, ResolvedPaintItem, @@ -26,6 +27,7 @@ import { resolveImageItem } from './resolveImage.js'; import { resolveDrawingItem } from './resolveDrawing.js'; import type { BlockMapEntry } from './resolvedBlockLookup.js'; import { computeSdtContainerKey } from './sdtContainerKey.js'; +import { hashParagraphBorders } from './paragraphBorderHash.js'; export type ResolveLayoutInput = { layout: Layout; @@ -127,6 +129,26 @@ function resolveParagraphContentIfApplicable( return resolveParagraphContent(fragment, entry.block as ParagraphBlock, entry.measure as ParagraphMeasure); } +function resolveFragmentParagraphBorders( + fragment: Fragment, + blockMap: Map, +): ParagraphBorders | undefined { + const entry = blockMap.get(fragment.blockId); + if (!entry) return undefined; + + if (fragment.kind === 'para' && entry.block.kind === 'paragraph') { + return (entry.block as ParagraphBlock).attrs?.borders; + } + + if (fragment.kind === 'list-item' && entry.block.kind === 'list') { + const block = entry.block as ListBlock; + const item = block.items.find((listItem) => listItem.id === fragment.itemId); + return item?.paragraph.attrs?.borders; + } + + return undefined; +} + function resolveFragmentSdtContainerKey(fragment: Fragment, blockMap: Map): string | null { const entry = blockMap.get(fragment.blockId); if (!entry) return null; @@ -192,6 +214,14 @@ function resolveFragmentItem( content: resolveParagraphContentIfApplicable(fragment, blockMap), }; if (sdtContainerKey != null) item.sdtContainerKey = sdtContainerKey; + + // Pre-compute paragraph border data for between-border grouping + const borders = resolveFragmentParagraphBorders(fragment, blockMap); + if (borders) { + item.paragraphBorders = borders; + item.paragraphBorderHash = hashParagraphBorders(borders); + } + if (fragment.kind === 'para') { const para = fragment as ParaFragment; if (para.pmStart != null) item.pmStart = para.pmStart; 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 d2996105ef..c225a92810 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 @@ -15,6 +15,8 @@ import type { ListMeasure, ParagraphBlock, ParagraphAttrs, + ResolvedPaintItem, + ResolvedFragmentItem, } from '@superdoc/contracts'; import type { BlockLookup } from './types.js'; import { hashParagraphBorders } from '../../paragraph-hash-utils.js'; @@ -124,9 +126,23 @@ 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[], ): Map => { // Phase 1: determine which consecutive pairs form between-border groups const pairFlags = new Set(); @@ -137,7 +153,10 @@ export const computeBetweenBorderFlags = ( if (frag.kind !== 'para' && frag.kind !== 'list-item') continue; if (frag.continuesOnNext) continue; - const borders = getFragmentParagraphBorders(frag, blockLookup); + const resolvedCur = resolvedItems?.[i]; + const borders = isResolvedFragmentWithBorders(resolvedCur) + ? resolvedCur.paragraphBorders + : getFragmentParagraphBorders(frag, blockLookup); if (!borders) continue; const next = fragments[i + 1]; @@ -152,9 +171,24 @@ export const computeBetweenBorderFlags = ( ) continue; - const nextBorders = getFragmentParagraphBorders(next, blockLookup); + const resolvedNext = resolvedItems?.[i + 1]; + const nextBorders = isResolvedFragmentWithBorders(resolvedNext) + ? resolvedNext.paragraphBorders + : getFragmentParagraphBorders(next, blockLookup); if (!nextBorders) continue; - if (hashParagraphBorders(borders) !== hashParagraphBorders(nextBorders)) continue; + + // Compare using pre-computed hashes when available, falling back to computing on-the-fly. + const curHash = + resolvedCur && 'paragraphBorderHash' in resolvedCur && (resolvedCur as ResolvedFragmentItem).paragraphBorderHash + ? (resolvedCur as ResolvedFragmentItem).paragraphBorderHash! + : hashParagraphBorders(borders); + const nextHash = + resolvedNext && + 'paragraphBorderHash' in resolvedNext && + (resolvedNext as ResolvedFragmentItem).paragraphBorderHash + ? (resolvedNext as ResolvedFragmentItem).paragraphBorderHash! + : hashParagraphBorders(nextBorders); + if (curHash !== nextHash) continue; // Skip fragments in different columns (different x positions) if (frag.x !== next.x) continue; @@ -175,7 +209,11 @@ export const computeBetweenBorderFlags = ( for (const i of pairFlags) { const frag = fragments[i]; const next = fragments[i + 1]; - const fragHeight = getFragmentHeight(frag, blockLookup); + const resolvedCur = resolvedItems?.[i]; + const fragHeight = + resolvedCur && 'height' in resolvedCur && resolvedCur.height != null + ? resolvedCur.height + : getFragmentHeight(frag, blockLookup); const gapBelow = Math.max(0, next.y - (frag.y + fragHeight)); const isNoBetween = noBetweenPairs.has(i); diff --git a/packages/layout-engine/painters/dom/src/renderer.ts b/packages/layout-engine/painters/dom/src/renderer.ts index 542bc94b22..4c6b85be44 100644 --- a/packages/layout-engine/painters/dom/src/renderer.ts +++ b/packages/layout-engine/painters/dom/src/renderer.ts @@ -2224,7 +2224,7 @@ export class DomPainter { this.sdtLabelsRendered, resolvedPage?.items, ); - const betweenBorderFlags = computeBetweenBorderFlags(page.fragments, this.blockLookup); + const betweenBorderFlags = computeBetweenBorderFlags(page.fragments, this.blockLookup, resolvedPage?.items); page.fragments.forEach((fragment, index) => { const sdtBoundary = sdtBoundaries.get(index); @@ -2659,7 +2659,7 @@ export class DomPainter { this.sdtLabelsRendered, resolvedPage?.items, ); - const betweenBorderFlags = computeBetweenBorderFlags(page.fragments, this.blockLookup); + const betweenBorderFlags = computeBetweenBorderFlags(page.fragments, this.blockLookup, resolvedPage?.items); const contextBase: FragmentRenderContext = { pageNumber: page.number, @@ -2824,7 +2824,7 @@ export class DomPainter { this.sdtLabelsRendered, resolvedPage?.items, ); - const betweenBorderFlags = computeBetweenBorderFlags(page.fragments, this.blockLookup); + const betweenBorderFlags = computeBetweenBorderFlags(page.fragments, this.blockLookup, resolvedPage?.items); const fragmentStates: FragmentDomState[] = page.fragments.map((fragment, index) => { const sdtBoundary = sdtBoundaries.get(index); const resolvedItem = this.getResolvedFragmentItem(pageIndex, index);