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
8 changes: 8 additions & 0 deletions packages/layout-engine/contracts/src/resolved-layout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,12 @@ import type {
ImageBlock,
ImageFragmentMetadata,
Line,
ListBlock,
ListMeasure,
PageMargins,
ParagraphBlock,
ParagraphBorders,
ParagraphMeasure,
SectionVerticalAlign,
TableBlock,
TableMeasure,
Expand Down Expand Up @@ -127,6 +131,10 @@ export type ResolvedFragmentItem = {
paragraphBorders?: ParagraphBorders;
/** Pre-computed change-detection signature (blockVersion + fragment-specific data). */
version?: string;
/** Pre-extracted block for paragraph (ParagraphBlock) or list-item (ListBlock) fragments. */
block?: ParagraphBlock | ListBlock;
/** Pre-extracted measure for paragraph (ParagraphMeasure) or list-item (ListMeasure) fragments. */
measure?: ParagraphMeasure | ListMeasure;
};

/** Resolved paragraph content for non-table paragraph/list-item fragments. */
Expand Down
157 changes: 157 additions & 0 deletions packages/layout-engine/layout-resolved/src/resolveLayout.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -638,6 +638,163 @@ describe('resolveLayout', () => {
});
});

describe('paragraph/list-item block and measure lifting', () => {
it('lifts block and measure from a paragraph fragment', () => {
const paraFragment: ParaFragment = {
kind: 'para',
blockId: 'p1',
fromLine: 0,
toLine: 1,
x: 72,
y: 100,
width: 468,
};
const layout: Layout = {
pageSize: { w: 612, h: 792 },
pages: [{ number: 1, fragments: [paraFragment] }],
};
const paragraphBlock: FlowBlock = { kind: 'paragraph', id: 'p1', runs: [] };
const paragraphMeasure: Measure = {
kind: 'paragraph',
lines: [{ fromRun: 0, fromChar: 0, toRun: 0, toChar: 10, width: 400, ascent: 12, descent: 4, lineHeight: 20 }],
totalHeight: 20,
};

const result = resolveLayout({
layout,
flowMode: 'paginated',
blocks: [paragraphBlock],
measures: [paragraphMeasure],
});
const item = result.pages[0].items[0] as import('@superdoc/contracts').ResolvedFragmentItem;
expect(item.block).toBe(paragraphBlock);
expect(item.measure).toBe(paragraphMeasure);
});

it('lifts block and measure from a list-item fragment', () => {
const listItemFragment: ListItemFragment = {
kind: 'list-item',
blockId: 'list1',
itemId: 'item-a',
fromLine: 0,
toLine: 1,
x: 108,
y: 200,
width: 432,
markerWidth: 36,
};
const layout: Layout = {
pageSize: { w: 612, h: 792 },
pages: [{ number: 1, fragments: [listItemFragment] }],
};
const listBlock: FlowBlock = {
kind: 'list',
id: 'list1',
listType: 'bullet',
items: [
{
id: 'item-a',
marker: { text: '•', style: {} },
paragraph: { kind: 'paragraph', id: 'item-a-p', runs: [] },
},
],
};
const listMeasure: Measure = {
kind: 'list',
items: [
{
itemId: 'item-a',
markerWidth: 36,
markerTextWidth: 10,
indentLeft: 36,
paragraph: {
kind: 'paragraph',
lines: [
{ fromRun: 0, fromChar: 0, toRun: 0, toChar: 10, width: 400, ascent: 12, descent: 4, lineHeight: 24 },
],
totalHeight: 24,
},
},
],
totalHeight: 24,
};

const result = resolveLayout({
layout,
flowMode: 'paginated',
blocks: [listBlock],
measures: [listMeasure],
});
const item = result.pages[0].items[0] as import('@superdoc/contracts').ResolvedFragmentItem;
expect(item.block).toBe(listBlock);
expect(item.measure).toBe(listMeasure);
});

it('leaves block and measure undefined when the block entry is missing', () => {
const paraFragment: ParaFragment = {
kind: 'para',
blockId: 'missing',
fromLine: 0,
toLine: 1,
x: 72,
y: 100,
width: 468,
};
const layout: Layout = {
pageSize: { w: 612, h: 792 },
pages: [{ number: 1, fragments: [paraFragment] }],
};

const result = resolveLayout({ layout, flowMode: 'paginated', blocks: [], measures: [] });
const item = result.pages[0].items[0] as import('@superdoc/contracts').ResolvedFragmentItem;
expect(item.block).toBeUndefined();
expect(item.measure).toBeUndefined();
});

it('does not set ResolvedFragmentItem.block on table fragments (they use ResolvedTableItem.block)', () => {
const tableFragment: TableFragment = {
kind: 'table',
blockId: 't1',
fromRow: 0,
toRow: 1,
x: 10,
y: 20,
width: 400,
height: 80,
columnWidths: [200, 200],
};
const layout: Layout = {
pageSize: { w: 612, h: 792 },
pages: [{ number: 1, fragments: [tableFragment] }],
};
const tableBlock = {
kind: 'table' as const,
id: 't1',
rows: [],
columnWidths: [200, 200],
};
const tableMeasure = {
kind: 'table' as const,
columnWidths: [200, 200],
rows: [],
totalHeight: 80,
};

const result = resolveLayout({
layout,
flowMode: 'paginated',
blocks: [tableBlock as any],
measures: [tableMeasure as any],
});
// Table items carry block/measure as ResolvedTableItem typed fields.
// They should NOT use the optional ResolvedFragmentItem.block path (no fall-through to the default branch).
const item = result.pages[0].items[0] as import('@superdoc/contracts').ResolvedTableItem;
expect(item.fragmentKind).toBe('table');
expect(item.block).toBe(tableBlock);
expect(item.measure).toBe(tableMeasure);
});
});

describe('fragment metadata lifting', () => {
it('lifts pmStart and pmEnd from a paragraph fragment', () => {
const paraFragment: ParaFragment = {
Expand Down
13 changes: 13 additions & 0 deletions packages/layout-engine/layout-resolved/src/resolveLayout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -239,6 +239,19 @@ function resolveFragmentItem(
};
if (sdtContainerKey != null) item.sdtContainerKey = sdtContainerKey;

// Pre-extract block/measure for para and list-item fragments so the painter
// can prefer resolved data over a blockLookup read.
const entry = blockMap.get(fragment.blockId);
if (entry) {
if (fragment.kind === 'para' && entry.block.kind === 'paragraph' && entry.measure.kind === 'paragraph') {
item.block = entry.block as ParagraphBlock;
item.measure = entry.measure as ParagraphMeasure;
} else if (fragment.kind === 'list-item' && entry.block.kind === 'list' && entry.measure.kind === 'list') {
item.block = entry.block as ListBlock;
item.measure = entry.measure as ListMeasure;
}
}

// Pre-compute paragraph border data for between-border grouping
const borders = resolveFragmentParagraphBorders(fragment, blockMap);
if (borders) {
Expand Down
46 changes: 32 additions & 14 deletions packages/layout-engine/painters/dom/src/renderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2936,17 +2936,26 @@ export class DomPainter {
resolvedItem?: ResolvedFragmentItem,
): HTMLElement {
try {
const lookup = this.blockLookup.get(fragment.blockId);
if (!lookup || lookup.block.kind !== 'paragraph' || lookup.measure.kind !== 'paragraph') {
throw new Error(`DomPainter: missing block/measure for fragment ${fragment.blockId}`);
}

if (!this.doc) {
throw new Error('DomPainter: document is not available');
}

const block = lookup.block as ParagraphBlock;
const measure = lookup.measure as ParagraphMeasure;
// Prefer pre-extracted block/measure from the resolved item; fall back to blockLookup.
let block: ParagraphBlock;
let measure: ParagraphMeasure;
const resolvedBlock = resolvedItem?.block;
const resolvedMeasure = resolvedItem?.measure;
if (resolvedBlock?.kind === 'paragraph' && resolvedMeasure?.kind === 'paragraph') {
block = resolvedBlock as ParagraphBlock;
measure = resolvedMeasure as ParagraphMeasure;
} else {
const lookup = this.blockLookup.get(fragment.blockId);
if (!lookup || lookup.block.kind !== 'paragraph' || lookup.measure.kind !== 'paragraph') {
throw new Error(`DomPainter: missing block/measure for fragment ${fragment.blockId}`);
}
block = lookup.block as ParagraphBlock;
measure = lookup.measure as ParagraphMeasure;
}
const wordLayout = isMinimalWordLayout(block.attrs?.wordLayout) ? block.attrs.wordLayout : undefined;
const content = resolvedItem?.content;

Expand Down Expand Up @@ -3478,17 +3487,26 @@ export class DomPainter {
resolvedItem?: ResolvedFragmentItem,
): HTMLElement {
try {
const lookup = this.blockLookup.get(fragment.blockId);
if (!lookup || lookup.block.kind !== 'list' || lookup.measure.kind !== 'list') {
throw new Error(`DomPainter: missing list data for fragment ${fragment.blockId}`);
}

if (!this.doc) {
throw new Error('DomPainter: document is not available');
}

const block = lookup.block as ListBlock;
const measure = lookup.measure as ListMeasure;
// Prefer pre-extracted block/measure from the resolved item; fall back to blockLookup.
let block: ListBlock;
let measure: ListMeasure;
const resolvedBlock = resolvedItem?.block;
const resolvedMeasure = resolvedItem?.measure;
if (resolvedBlock?.kind === 'list' && resolvedMeasure?.kind === 'list') {
block = resolvedBlock as ListBlock;
measure = resolvedMeasure as ListMeasure;
} else {
const lookup = this.blockLookup.get(fragment.blockId);
if (!lookup || lookup.block.kind !== 'list' || lookup.measure.kind !== 'list') {
throw new Error(`DomPainter: missing list data for fragment ${fragment.blockId}`);
}
block = lookup.block as ListBlock;
measure = lookup.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) {
Expand Down
Loading