Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions packages/layout-engine/contracts/src/resolved-layout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import type {
ImageFragmentMetadata,
Line,
PageMargins,
ParagraphBorders,
SectionVerticalAlign,
TableBlock,
TableMeasure,
Expand Down Expand Up @@ -120,6 +121,10 @@ export type ResolvedFragmentItem = {
content?: ResolvedParagraphContent;
/** Pre-computed SDT container key for boundary grouping (`structuredContent:<id>` or `documentSection:<id>`). */
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. */
Expand Down
33 changes: 33 additions & 0 deletions packages/layout-engine/layout-resolved/src/paragraphBorderHash.ts
Original file line number Diff line number Diff line change
@@ -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(';');
};
233 changes: 233 additions & 0 deletions packages/layout-engine/layout-resolved/src/resolveLayout.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
});
});
30 changes: 30 additions & 0 deletions packages/layout-engine/layout-resolved/src/resolveLayout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import type {
ParaFragment,
TableFragment,
Line,
ParagraphBorders,
ResolvedLayout,
ResolvedPage,
ResolvedPaintItem,
Expand All @@ -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;
Expand Down Expand Up @@ -127,6 +129,26 @@ function resolveParagraphContentIfApplicable(
return resolveParagraphContent(fragment, entry.block as ParagraphBlock, entry.measure as ParagraphMeasure);
}

function resolveFragmentParagraphBorders(
fragment: Fragment,
blockMap: Map<string, BlockMapEntry>,
): 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, BlockMapEntry>): string | null {
const entry = blockMap.get(fragment.blockId);
if (!entry) return null;
Expand Down Expand Up @@ -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;
Expand Down
Loading
Loading