Skip to content

Commit 2e035ea

Browse files
authored
fix(layout): handle floating-only docs and ignore non-visual image ID churn (#2740)
* fix(layout): handle floating-only docs and ignore non-visual image ID churn * fix(layout): keep paginator state in sync when pruning empty pages
1 parent 2382775 commit 2e035ea

File tree

10 files changed

+678
-138
lines changed

10 files changed

+678
-138
lines changed

packages/layout-engine/layout-bridge/src/layoutHeaderFooter.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -221,6 +221,9 @@ export async function layoutHeaderFooterWithCache(
221221

222222
// Page resolver path with digit bucketing
223223
const { totalPages: docTotalPages } = pageResolver(1);
224+
if (!Number.isFinite(docTotalPages) || docTotalPages <= 0) {
225+
return result;
226+
}
224227
const useBucketing = FeatureFlags.HF_DIGIT_BUCKETING && docTotalPages >= MIN_PAGES_FOR_BUCKETING;
225228

226229
for (const [type, blocks] of Object.entries(sections) as [keyof HeaderFooterBatch, FlowBlock[] | undefined][]) {

packages/layout-engine/layout-bridge/test/headerFooterLayout.test.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,35 @@ describe('layoutHeaderFooterWithCache', () => {
5252
expect(measureBlock).toHaveBeenCalledTimes(1);
5353
});
5454

55+
it('returns no layouts when the body layout has zero pages', async () => {
56+
const sections = {
57+
default: [
58+
{
59+
kind: 'paragraph',
60+
id: 'page-token-footer',
61+
runs: [
62+
{ text: 'Page ', fontFamily: 'Arial', fontSize: 16 },
63+
{ text: '0', token: 'pageNumber', fontFamily: 'Arial', fontSize: 16 },
64+
],
65+
} satisfies FlowBlock,
66+
],
67+
};
68+
const measureBlock = vi.fn(async () => makeMeasure(12));
69+
70+
const result = await layoutHeaderFooterWithCache(
71+
sections,
72+
{ width: 300, height: 40 },
73+
measureBlock,
74+
undefined,
75+
undefined,
76+
() => ({ displayText: '1', totalPages: 0 }),
77+
'footer',
78+
);
79+
80+
expect(result).toEqual({});
81+
expect(measureBlock).not.toHaveBeenCalled();
82+
});
83+
5584
describe('integration test', () => {
5685
it('full pipeline: PM JSON with page tokens → FlowBlocks → Measures → Layout', async () => {
5786
// 1. Create PM JSON with page number tokens (simulates header/footer from SuperConverter)

packages/layout-engine/layout-engine/src/anchors.ts

Lines changed: 65 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,53 @@ export type AnchoredTable = {
2525

2626
export type AnchoredObject = AnchoredDrawing | AnchoredTable;
2727

28+
export type AnchoredTableCollection = {
29+
byParagraph: Map<number, AnchoredTable[]>;
30+
withoutParagraph: AnchoredTable[];
31+
};
32+
33+
function buildParagraphIndexById(blocks: FlowBlock[], len: number): Map<string, number> {
34+
const paragraphIndexById = new Map<string, number>();
35+
36+
for (let i = 0; i < len; i += 1) {
37+
const block = blocks[i];
38+
if (block.kind === 'paragraph') {
39+
paragraphIndexById.set(block.id, i);
40+
}
41+
}
42+
43+
return paragraphIndexById;
44+
}
45+
46+
function findNearestParagraphIndex(blocks: FlowBlock[], len: number, fromIndex: number): number | null {
47+
for (let i = fromIndex - 1; i >= 0; i -= 1) {
48+
if (blocks[i].kind === 'paragraph') return i;
49+
}
50+
51+
for (let i = fromIndex + 1; i < len; i += 1) {
52+
if (blocks[i].kind === 'paragraph') return i;
53+
}
54+
55+
return null;
56+
}
57+
58+
function resolveAnchorParagraphIndex(
59+
blocks: FlowBlock[],
60+
len: number,
61+
paragraphIndexById: Map<string, number>,
62+
fromIndex: number,
63+
anchorParagraphId: unknown,
64+
): number | null {
65+
if (typeof anchorParagraphId === 'string') {
66+
const explicitIndex = paragraphIndexById.get(anchorParagraphId);
67+
if (typeof explicitIndex === 'number') {
68+
return explicitIndex;
69+
}
70+
}
71+
72+
return findNearestParagraphIndex(blocks, len, fromIndex);
73+
}
74+
2875
/**
2976
* Check if an anchored image should be pre-registered (before any paragraphs are laid out).
3077
* Images with vRelativeFrom='margin' or 'page' position themselves relative to the page,
@@ -78,28 +125,7 @@ export function collectPreRegisteredAnchors(blocks: FlowBlock[], measures: Measu
78125
export function collectAnchoredDrawings(blocks: FlowBlock[], measures: Measure[]): Map<number, AnchoredDrawing[]> {
79126
const map = new Map<number, AnchoredDrawing[]>();
80127
const len = Math.min(blocks.length, measures.length);
81-
const paragraphIndexById = new Map<string, number>();
82-
83-
for (let i = 0; i < len; i += 1) {
84-
const block = blocks[i];
85-
if (block.kind === 'paragraph') {
86-
paragraphIndexById.set(block.id, i);
87-
}
88-
}
89-
90-
const nearestPrevParagraph = (fromIndex: number): number | null => {
91-
for (let i = fromIndex - 1; i >= 0; i -= 1) {
92-
if (blocks[i].kind === 'paragraph') return i;
93-
}
94-
return null;
95-
};
96-
97-
const nearestNextParagraph = (fromIndex: number): number | null => {
98-
for (let i = fromIndex + 1; i < len; i += 1) {
99-
if (blocks[i].kind === 'paragraph') return i;
100-
}
101-
return null;
102-
};
128+
const paragraphIndexById = buildParagraphIndexById(blocks, len);
103129

104130
for (let i = 0; i < len; i += 1) {
105131
const block = blocks[i];
@@ -125,12 +151,7 @@ export function collectAnchoredDrawings(blocks: FlowBlock[], measures: Measure[]
125151
typeof drawingBlock.attrs === 'object' && drawingBlock.attrs
126152
? (drawingBlock.attrs as { anchorParagraphId?: unknown }).anchorParagraphId
127153
: undefined;
128-
let anchorParaIndex =
129-
typeof anchorParagraphId === 'string' ? (paragraphIndexById.get(anchorParagraphId) ?? null) : null;
130-
if (anchorParaIndex == null) {
131-
anchorParaIndex = nearestPrevParagraph(i);
132-
}
133-
if (anchorParaIndex == null) anchorParaIndex = nearestNextParagraph(i);
154+
const anchorParaIndex = resolveAnchorParagraphIndex(blocks, len, paragraphIndexById, i, anchorParagraphId);
134155
if (anchorParaIndex == null) continue; // no paragraphs at all
135156

136157
const list = map.get(anchorParaIndex) ?? [];
@@ -143,34 +164,15 @@ export function collectAnchoredDrawings(blocks: FlowBlock[], measures: Measure[]
143164

144165
/**
145166
* Collect anchored/floating tables mapped to their anchor paragraph index.
146-
* Map of paragraph block index -> anchored tables associated with that paragraph.
167+
* Also returns anchored tables that have no paragraph to attach to.
147168
*/
148-
export function collectAnchoredTables(blocks: FlowBlock[], measures: Measure[]): Map<number, AnchoredTable[]> {
149-
const map = new Map<number, AnchoredTable[]>();
150-
const paragraphIndexById = new Map<string, number>();
151-
152-
for (let i = 0; i < blocks.length; i += 1) {
153-
const block = blocks[i];
154-
if (block.kind === 'paragraph') {
155-
paragraphIndexById.set(block.id, i);
156-
}
157-
}
158-
159-
const nearestPrevParagraph = (fromIndex: number): number | null => {
160-
for (let i = fromIndex - 1; i >= 0; i -= 1) {
161-
if (blocks[i].kind === 'paragraph') return i;
162-
}
163-
return null;
164-
};
165-
166-
const nearestNextParagraph = (fromIndex: number): number | null => {
167-
for (let i = fromIndex + 1; i < blocks.length; i += 1) {
168-
if (blocks[i].kind === 'paragraph') return i;
169-
}
170-
return null;
171-
};
169+
export function collectAnchoredTables(blocks: FlowBlock[], measures: Measure[]): AnchoredTableCollection {
170+
const len = Math.min(blocks.length, measures.length);
171+
const byParagraph = new Map<number, AnchoredTable[]>();
172+
const withoutParagraph: AnchoredTable[] = [];
173+
const paragraphIndexById = buildParagraphIndexById(blocks, len);
172174

173-
for (let i = 0; i < blocks.length; i += 1) {
175+
for (let i = 0; i < len; i += 1) {
174176
const block = blocks[i];
175177
const measure = measures[i];
176178

@@ -187,18 +189,19 @@ export function collectAnchoredTables(blocks: FlowBlock[], measures: Measure[]):
187189
typeof tableBlock.attrs === 'object' && tableBlock.attrs
188190
? (tableBlock.attrs as { anchorParagraphId?: unknown }).anchorParagraphId
189191
: undefined;
190-
let anchorParaIndex =
191-
typeof anchorParagraphId === 'string' ? (paragraphIndexById.get(anchorParagraphId) ?? null) : null;
192+
const anchorParaIndex = resolveAnchorParagraphIndex(blocks, len, paragraphIndexById, i, anchorParagraphId);
192193
if (anchorParaIndex == null) {
193-
anchorParaIndex = nearestPrevParagraph(i);
194+
withoutParagraph.push({ block: tableBlock, measure: tableMeasure });
195+
continue;
194196
}
195-
if (anchorParaIndex == null) anchorParaIndex = nearestNextParagraph(i);
196-
if (anchorParaIndex == null) continue; // no paragraphs at all
197197

198-
const list = map.get(anchorParaIndex) ?? [];
198+
const list = byParagraph.get(anchorParaIndex) ?? [];
199199
list.push({ block: tableBlock, measure: tableMeasure });
200-
map.set(anchorParaIndex, list);
200+
byParagraph.set(anchorParaIndex, list);
201201
}
202202

203-
return map;
203+
return {
204+
byParagraph,
205+
withoutParagraph,
206+
};
204207
}

packages/layout-engine/layout-engine/src/index.test.ts

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,21 @@ const makeTableMeasure = (columnWidths: number[], rowHeights: number[]): TableMe
7979
totalHeight: rowHeights.reduce((sum, height) => sum + height, 0),
8080
});
8181

82+
const makeParagraphlessFloatingTable = (id: string): TableBlock =>
83+
makeTableBlock(id, 1, {
84+
anchor: {
85+
isAnchored: true,
86+
hRelativeFrom: 'page',
87+
vRelativeFrom: 'paragraph',
88+
offsetH: 120,
89+
offsetV: 15,
90+
},
91+
wrap: {
92+
type: 'Square',
93+
wrapText: 'bothSides',
94+
},
95+
});
96+
8297
const block: FlowBlock = {
8398
kind: 'paragraph',
8499
id: 'block-1',
@@ -716,6 +731,48 @@ describe('layoutDocument', () => {
716731
expect(anchoredTableFragment?.y).toBe(DEFAULT_OPTIONS.margins!.top + paragraphMeasure.totalHeight);
717732
});
718733

734+
it('renders a floating table when the document has no body paragraphs', () => {
735+
const floatingOnlyTable = makeParagraphlessFloatingTable('table-floating-only');
736+
const floatingOnlyMeasure = makeTableMeasure([220], [60]);
737+
738+
const layout = layoutDocument([floatingOnlyTable], [floatingOnlyMeasure], DEFAULT_OPTIONS);
739+
740+
expect(layout.pages).toHaveLength(1);
741+
742+
const fragment = layout.pages[0].fragments.find(
743+
(candidate) => candidate.kind === 'table' && candidate.blockId === 'table-floating-only',
744+
) as TableFragment | undefined;
745+
746+
expect(fragment).toBeTruthy();
747+
expect(fragment?.x).toBe(120);
748+
expect(fragment?.y).toBe(DEFAULT_OPTIONS.margins!.top + 15);
749+
});
750+
751+
it('renders a floating table after pruning a leading empty page', () => {
752+
const leadingPageBreak: PageBreakBlock = {
753+
kind: 'pageBreak',
754+
id: 'page-break-before-floating-table',
755+
};
756+
const floatingOnlyTable = makeParagraphlessFloatingTable('table-floating-after-page-break');
757+
const floatingOnlyMeasure = makeTableMeasure([220], [60]);
758+
759+
const layout = layoutDocument(
760+
[leadingPageBreak, floatingOnlyTable],
761+
[{ kind: 'pageBreak' }, floatingOnlyMeasure],
762+
DEFAULT_OPTIONS,
763+
);
764+
765+
expect(layout.pages).toHaveLength(1);
766+
767+
const fragment = layout.pages[0].fragments.find(
768+
(candidate) => candidate.kind === 'table' && candidate.blockId === 'table-floating-after-page-break',
769+
) as TableFragment | undefined;
770+
771+
expect(fragment).toBeTruthy();
772+
expect(fragment?.x).toBe(120);
773+
expect(fragment?.y).toBe(DEFAULT_OPTIONS.margins!.top + 15);
774+
});
775+
719776
it('propagates pm ranges onto fragments', () => {
720777
const blockWithRuns: FlowBlock = {
721778
kind: 'paragraph',

0 commit comments

Comments
 (0)