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
167 changes: 74 additions & 93 deletions packages/layout-engine/painters/dom/src/between-borders.test.ts

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -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';

/**
Expand All @@ -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<ResolvedFragmentItem['paragraphBorders']> } {
return (
item !== undefined && item.kind === 'fragment' && 'paragraphBorders' in item && item.paragraphBorders !== undefined
);
}

/**
* Pre-computes per-fragment between-border rendering info for a page.
*
Expand All @@ -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<ResolvedFragmentItem['paragraphBorders']> } {
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<number, BetweenBorderInfo> => {
// Phase 1: determine which consecutive pairs form between-border groups
const pairFlags = new Set<number>();
Expand All @@ -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;
Expand All @@ -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;
Expand All @@ -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);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -27,6 +27,3 @@ export {
stampBetweenBorderDataset,
computeBorderSpaceExpansion,
} from './border-layer.js';

// Shared types
export type { BlockLookup, BlockLookupEntry } from './types.js';

This file was deleted.

91 changes: 68 additions & 23 deletions packages/layout-engine/painters/dom/src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,24 +27,72 @@ 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;
let headerBlocks: FlowBlock[] | undefined;
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
Expand All @@ -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);
},
Expand Down Expand Up @@ -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,
},
],
},
Expand Down Expand Up @@ -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,
},
],
},
Expand Down Expand Up @@ -4819,6 +4856,7 @@ describe('DomPainter', () => {
fragmentKind: 'drawing',
blockId: 'drawing-anchored',
fragmentIndex: 0,
block: anchoredDrawingBlock as import('@superdoc/contracts').DrawingBlock,
},
{
kind: 'fragment',
Expand All @@ -4832,6 +4870,7 @@ describe('DomPainter', () => {
fragmentKind: 'drawing',
blockId: 'drawing-inline',
fragmentIndex: 1,
block: inlineDrawingBlock as import('@superdoc/contracts').DrawingBlock,
},
],
},
Expand Down Expand Up @@ -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: [
{
Expand Down Expand Up @@ -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: [
{
Expand Down Expand Up @@ -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: [
{
Expand Down
Loading
Loading