Skip to content

Commit e26689e

Browse files
authored
[6/16] refactor(layout): lift paragraph/list block and measure into resolved items (#2818)
* refactor(layout): lift page metadata into ResolvedPage * refactor(layout): lift fragment metadata into resolved paint items Add pmStart, pmEnd, continuesFromPrev, continuesOnNext, markerWidth, and metadata fields to resolved paint item types. Populate them in the resolvers and update the painter to prefer resolved item data over legacy Fragment reads with fallbacks. * refactor(layout): pre-compute SDT container keys in resolved layout * refactor(layout): pre-compute paragraph border data in resolved layout * refactor(layout): move change detection into resolved layout stage * refactor(layout): lift paragraph and list-item block/measure into resolved items
1 parent ffb8b45 commit e26689e

4 files changed

Lines changed: 208 additions & 15 deletions

File tree

packages/layout-engine/contracts/src/resolved-layout.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,12 @@ import type {
55
ImageBlock,
66
ImageFragmentMetadata,
77
Line,
8+
ListBlock,
9+
ListMeasure,
810
PageMargins,
11+
ParagraphBlock,
912
ParagraphBorders,
13+
ParagraphMeasure,
1014
SectionVerticalAlign,
1115
TableBlock,
1216
TableMeasure,
@@ -129,6 +133,10 @@ export type ResolvedFragmentItem = {
129133
paragraphBorders?: ParagraphBorders;
130134
/** Pre-computed change-detection signature (blockVersion + fragment-specific data). */
131135
version?: string;
136+
/** Pre-extracted block for paragraph (ParagraphBlock) or list-item (ListBlock) fragments. */
137+
block?: ParagraphBlock | ListBlock;
138+
/** Pre-extracted measure for paragraph (ParagraphMeasure) or list-item (ListMeasure) fragments. */
139+
measure?: ParagraphMeasure | ListMeasure;
132140
};
133141

134142
/** Resolved paragraph content for non-table paragraph/list-item fragments. */

packages/layout-engine/layout-resolved/src/resolveLayout.test.ts

Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -665,6 +665,162 @@ describe('resolveLayout', () => {
665665
});
666666
});
667667

668+
describe('paragraph/list-item block and measure lifting', () => {
669+
it('lifts block and measure from a paragraph fragment', () => {
670+
const paraFragment: ParaFragment = {
671+
kind: 'para',
672+
blockId: 'p1',
673+
fromLine: 0,
674+
toLine: 1,
675+
x: 72,
676+
y: 100,
677+
width: 468,
678+
};
679+
const layout: Layout = {
680+
pageSize: { w: 612, h: 792 },
681+
pages: [{ number: 1, fragments: [paraFragment] }],
682+
};
683+
const paragraphBlock: FlowBlock = { kind: 'paragraph', id: 'p1', runs: [] };
684+
const paragraphMeasure: Measure = {
685+
kind: 'paragraph',
686+
lines: [{ fromRun: 0, fromChar: 0, toRun: 0, toChar: 10, width: 400, ascent: 12, descent: 4, lineHeight: 20 }],
687+
totalHeight: 20,
688+
};
689+
690+
const result = resolveLayout({
691+
layout,
692+
flowMode: 'paginated',
693+
blocks: [paragraphBlock],
694+
measures: [paragraphMeasure],
695+
});
696+
const item = result.pages[0].items[0] as import('@superdoc/contracts').ResolvedFragmentItem;
697+
expect(item.block).toBe(paragraphBlock);
698+
expect(item.measure).toBe(paragraphMeasure);
699+
});
700+
701+
it('lifts block and measure from a list-item fragment', () => {
702+
const listItemFragment: ListItemFragment = {
703+
kind: 'list-item',
704+
blockId: 'list1',
705+
itemId: 'item-a',
706+
fromLine: 0,
707+
toLine: 1,
708+
x: 108,
709+
y: 200,
710+
width: 432,
711+
markerWidth: 36,
712+
};
713+
const layout: Layout = {
714+
pageSize: { w: 612, h: 792 },
715+
pages: [{ number: 1, fragments: [listItemFragment] }],
716+
};
717+
const listBlock: FlowBlock = {
718+
kind: 'list',
719+
id: 'list1',
720+
listType: 'bullet',
721+
items: [
722+
{
723+
id: 'item-a',
724+
marker: { text: '•', style: {} },
725+
paragraph: { kind: 'paragraph', id: 'item-a-p', runs: [] },
726+
},
727+
],
728+
};
729+
const listMeasure: Measure = {
730+
kind: 'list',
731+
items: [
732+
{
733+
itemId: 'item-a',
734+
markerWidth: 36,
735+
markerTextWidth: 10,
736+
indentLeft: 36,
737+
paragraph: {
738+
kind: 'paragraph',
739+
lines: [
740+
{ fromRun: 0, fromChar: 0, toRun: 0, toChar: 10, width: 400, ascent: 12, descent: 4, lineHeight: 24 },
741+
],
742+
totalHeight: 24,
743+
},
744+
},
745+
],
746+
totalHeight: 24,
747+
};
748+
749+
const result = resolveLayout({
750+
layout,
751+
flowMode: 'paginated',
752+
blocks: [listBlock],
753+
measures: [listMeasure],
754+
});
755+
const item = result.pages[0].items[0] as import('@superdoc/contracts').ResolvedFragmentItem;
756+
expect(item.block).toBe(listBlock);
757+
expect(item.measure).toBe(listMeasure);
758+
});
759+
760+
it('leaves block and measure undefined when the block entry is missing', () => {
761+
const paraFragment: ParaFragment = {
762+
kind: 'para',
763+
blockId: 'missing',
764+
fromLine: 0,
765+
toLine: 1,
766+
x: 72,
767+
y: 100,
768+
width: 468,
769+
};
770+
const layout: Layout = {
771+
pageSize: { w: 612, h: 792 },
772+
pages: [{ number: 1, fragments: [paraFragment] }],
773+
};
774+
775+
const result = resolveLayout({ layout, flowMode: 'paginated', blocks: [], measures: [] });
776+
const item = result.pages[0].items[0] as import('@superdoc/contracts').ResolvedFragmentItem;
777+
expect(item.block).toBeUndefined();
778+
expect(item.measure).toBeUndefined();
779+
});
780+
781+
it('does not set ResolvedFragmentItem.block on table fragments (they use ResolvedTableItem.block)', () => {
782+
const tableFragment: TableFragment = {
783+
kind: 'table',
784+
blockId: 't1',
785+
fromRow: 0,
786+
toRow: 1,
787+
x: 10,
788+
y: 20,
789+
width: 400,
790+
height: 80,
791+
columnWidths: [200, 200],
792+
};
793+
const layout: Layout = {
794+
pageSize: { w: 612, h: 792 },
795+
pages: [{ number: 1, fragments: [tableFragment] }],
796+
};
797+
const tableBlock = {
798+
kind: 'table' as const,
799+
id: 't1',
800+
rows: [],
801+
columnWidths: [200, 200],
802+
};
803+
const tableMeasure = {
804+
kind: 'table' as const,
805+
columnWidths: [200, 200],
806+
rows: [],
807+
totalHeight: 80,
808+
};
809+
810+
const result = resolveLayout({
811+
layout,
812+
flowMode: 'paginated',
813+
blocks: [tableBlock as any],
814+
measures: [tableMeasure as any],
815+
});
816+
// Table items carry block/measure as ResolvedTableItem typed fields.
817+
// They should NOT use the optional ResolvedFragmentItem.block path (no fall-through to the default branch).
818+
const item = result.pages[0].items[0] as import('@superdoc/contracts').ResolvedTableItem;
819+
expect(item.fragmentKind).toBe('table');
820+
expect(item.block).toBe(tableBlock);
821+
expect(item.measure).toBe(tableMeasure);
822+
});
823+
});
668824
describe('fragment metadata lifting', () => {
669825
it('lifts pmStart and pmEnd from a paragraph fragment', () => {
670826
const paraFragment: ParaFragment = {

packages/layout-engine/layout-resolved/src/resolveLayout.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -238,6 +238,18 @@ function resolveFragmentItem(
238238
};
239239
if (sdtContainerKey != null) item.sdtContainerKey = sdtContainerKey;
240240

241+
// Pre-extract block/measure for para and list-item fragments so the painter
242+
// can prefer resolved data over a blockLookup read.
243+
const entry = blockMap.get(fragment.blockId);
244+
if (entry) {
245+
if (fragment.kind === 'para' && entry.block.kind === 'paragraph' && entry.measure.kind === 'paragraph') {
246+
item.block = entry.block as ParagraphBlock;
247+
item.measure = entry.measure as ParagraphMeasure;
248+
} else if (fragment.kind === 'list-item' && entry.block.kind === 'list' && entry.measure.kind === 'list') {
249+
item.block = entry.block as ListBlock;
250+
item.measure = entry.measure as ListMeasure;
251+
}
252+
}
241253
// Pre-compute paragraph border data for between-border grouping
242254
const borders = resolveFragmentParagraphBorders(fragment, blockMap);
243255
if (borders) {
@@ -299,7 +311,6 @@ export function resolveLayout(input: ResolveLayoutInput): ResolvedLayout {
299311
blocks.map((block) => [block.id, computeBlockVersion(block.id, blockMap, blockVersionCache)]),
300312
);
301313
}
302-
303314
if (layout.layoutEpoch != null) {
304315
resolved.layoutEpoch = layout.layoutEpoch;
305316
}

packages/layout-engine/painters/dom/src/renderer.ts

Lines changed: 32 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -3054,17 +3054,26 @@ export class DomPainter {
30543054
resolvedItem?: ResolvedFragmentItem,
30553055
): HTMLElement {
30563056
try {
3057-
const lookup = this.blockLookup.get(fragment.blockId);
3058-
if (!lookup || lookup.block.kind !== 'paragraph' || lookup.measure.kind !== 'paragraph') {
3059-
throw new Error(`DomPainter: missing block/measure for fragment ${fragment.blockId}`);
3060-
}
3061-
30623057
if (!this.doc) {
30633058
throw new Error('DomPainter: document is not available');
30643059
}
30653060

3066-
const block = lookup.block as ParagraphBlock;
3067-
const measure = lookup.measure as ParagraphMeasure;
3061+
// Prefer pre-extracted block/measure from the resolved item; fall back to blockLookup.
3062+
let block: ParagraphBlock;
3063+
let measure: ParagraphMeasure;
3064+
const resolvedBlock = resolvedItem?.block;
3065+
const resolvedMeasure = resolvedItem?.measure;
3066+
if (resolvedBlock?.kind === 'paragraph' && resolvedMeasure?.kind === 'paragraph') {
3067+
block = resolvedBlock as ParagraphBlock;
3068+
measure = resolvedMeasure as ParagraphMeasure;
3069+
} else {
3070+
const lookup = this.blockLookup.get(fragment.blockId);
3071+
if (!lookup || lookup.block.kind !== 'paragraph' || lookup.measure.kind !== 'paragraph') {
3072+
throw new Error(`DomPainter: missing block/measure for fragment ${fragment.blockId}`);
3073+
}
3074+
block = lookup.block as ParagraphBlock;
3075+
measure = lookup.measure as ParagraphMeasure;
3076+
}
30683077
const wordLayout = isMinimalWordLayout(block.attrs?.wordLayout) ? block.attrs.wordLayout : undefined;
30693078
const content = resolvedItem?.content;
30703079

@@ -3596,17 +3605,26 @@ export class DomPainter {
35963605
resolvedItem?: ResolvedFragmentItem,
35973606
): HTMLElement {
35983607
try {
3599-
const lookup = this.blockLookup.get(fragment.blockId);
3600-
if (!lookup || lookup.block.kind !== 'list' || lookup.measure.kind !== 'list') {
3601-
throw new Error(`DomPainter: missing list data for fragment ${fragment.blockId}`);
3602-
}
3603-
36043608
if (!this.doc) {
36053609
throw new Error('DomPainter: document is not available');
36063610
}
36073611

3608-
const block = lookup.block as ListBlock;
3609-
const measure = lookup.measure as ListMeasure;
3612+
// Prefer pre-extracted block/measure from the resolved item; fall back to blockLookup.
3613+
let block: ListBlock;
3614+
let measure: ListMeasure;
3615+
const resolvedBlock = resolvedItem?.block;
3616+
const resolvedMeasure = resolvedItem?.measure;
3617+
if (resolvedBlock?.kind === 'list' && resolvedMeasure?.kind === 'list') {
3618+
block = resolvedBlock as ListBlock;
3619+
measure = resolvedMeasure as ListMeasure;
3620+
} else {
3621+
const lookup = this.blockLookup.get(fragment.blockId);
3622+
if (!lookup || lookup.block.kind !== 'list' || lookup.measure.kind !== 'list') {
3623+
throw new Error(`DomPainter: missing list data for fragment ${fragment.blockId}`);
3624+
}
3625+
block = lookup.block as ListBlock;
3626+
measure = lookup.measure as ListMeasure;
3627+
}
36103628
const item = block.items.find((entry) => entry.id === fragment.itemId);
36113629
const itemMeasure = measure.items.find((entry) => entry.itemId === fragment.itemId);
36123630
if (!item || !itemMeasure) {

0 commit comments

Comments
 (0)