Skip to content

Commit e22088c

Browse files
fix(page-number): address PAGE field review feedback
1 parent 63d72a5 commit e22088c

6 files changed

Lines changed: 297 additions & 2 deletions

File tree

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

Lines changed: 56 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,11 @@
1-
import type { FlowBlock, HeaderFooterLayout, Measure, ParagraphBlock, TableBlock } from '@superdoc/contracts';
1+
import type {
2+
FlowBlock,
3+
HeaderFooterLayout,
4+
ListBlock,
5+
Measure,
6+
ParagraphBlock,
7+
TableBlock,
8+
} from '@superdoc/contracts';
29
import { layoutHeaderFooter, type HeaderFooterConstraints } from '@superdoc/layout-engine';
310
import { MeasureCache } from './cache';
411
import { resolveHeaderFooterTokens, cloneHeaderFooterBlocks } from './resolveHeaderFooterTokens';
@@ -121,10 +128,31 @@ function paragraphHasPageToken(para: ParagraphBlock): boolean {
121128
return false;
122129
}
123130

131+
function paragraphHasVariableWidthRunLocalPageFormat(para: ParagraphBlock): boolean {
132+
for (const run of para.runs) {
133+
if (
134+
'token' in run &&
135+
run.token === 'pageNumber' &&
136+
'pageNumberFormat' in run &&
137+
run.pageNumberFormat &&
138+
run.pageNumberFormat !== 'decimal' &&
139+
run.pageNumberFormat !== 'numberInDash'
140+
) {
141+
return true;
142+
}
143+
}
144+
return false;
145+
}
146+
124147
function hasPageTokens(blocks: FlowBlock[]): boolean {
125148
for (const block of blocks) {
126149
if (block.kind === 'paragraph') {
127150
if (paragraphHasPageToken(block as ParagraphBlock)) return true;
151+
} else if (block.kind === 'list') {
152+
const list = block as ListBlock;
153+
for (const item of list.items ?? []) {
154+
if (paragraphHasPageToken(item.paragraph)) return true;
155+
}
128156
} else if (block.kind === 'table') {
129157
// SD-1332: PAGE fields can live inside table cells in headers/footers
130158
// (Word's typical layout). Skipping tables here would take the
@@ -146,6 +174,32 @@ function hasPageTokens(blocks: FlowBlock[]): boolean {
146174
return false;
147175
}
148176

177+
function hasVariableWidthRunLocalPageFormat(blocks: FlowBlock[]): boolean {
178+
for (const block of blocks) {
179+
if (block.kind === 'paragraph') {
180+
if (paragraphHasVariableWidthRunLocalPageFormat(block as ParagraphBlock)) return true;
181+
} else if (block.kind === 'list') {
182+
const list = block as ListBlock;
183+
for (const item of list.items ?? []) {
184+
if (paragraphHasVariableWidthRunLocalPageFormat(item.paragraph)) return true;
185+
}
186+
} else if (block.kind === 'table') {
187+
const table = block as TableBlock;
188+
for (const row of table.rows ?? []) {
189+
for (const cell of row.cells ?? []) {
190+
const cellBlocks: FlowBlock[] = cell.blocks
191+
? (cell.blocks as FlowBlock[])
192+
: cell.paragraph
193+
? [cell.paragraph]
194+
: [];
195+
if (hasVariableWidthRunLocalPageFormat(cellBlocks)) return true;
196+
}
197+
}
198+
}
199+
}
200+
return false;
201+
}
202+
149203
export class HeaderFooterLayoutCache {
150204
private readonly cache = new MeasureCache<Measure>();
151205

@@ -266,7 +320,7 @@ export async function layoutHeaderFooterWithCache(
266320
// Determine which pages to create layouts for
267321
let pagesToLayout: number[];
268322

269-
if (!useBucketing) {
323+
if (!useBucketing || hasVariableWidthRunLocalPageFormat(blocks)) {
270324
// Small doc: create layout for every page
271325
pagesToLayout = Array.from({ length: docTotalPages }, (_, i) => i + 1);
272326
HeaderFooterCacheLogger.logBucketingDecision(docTotalPages, false);

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

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,20 @@ const makePageTokenBlock = (id: string): FlowBlock => ({
8989
],
9090
});
9191

92+
const makeFormattedPageTokenBlock = (id: string, pageNumberFormat: TextRun['pageNumberFormat']): FlowBlock => ({
93+
kind: 'paragraph',
94+
id,
95+
runs: [
96+
{
97+
text: '0',
98+
token: 'pageNumber',
99+
pageNumberFormat,
100+
fontFamily: 'Arial',
101+
fontSize: 12,
102+
} as TextRun,
103+
],
104+
});
105+
92106
describe('getBucketForPageNumber', () => {
93107
it('should return d1 for single-digit page numbers (1-9)', () => {
94108
expect(getBucketForPageNumber(1)).toBe('d1');
@@ -389,6 +403,59 @@ describe('layoutHeaderFooterWithCache - Digit Bucketing (Large Docs)', () => {
389403
expect(pageNumbers).toContain(500); // d3
390404
expect(pageNumbers).not.toContain(5000); // d4 not needed
391405
});
406+
407+
it('should disable bucketing for variable-width run-local page number formats', async () => {
408+
const sections = {
409+
default: [makeFormattedPageTokenBlock('header-roman', 'upperRoman')],
410+
};
411+
412+
const pageResolver: PageResolver = (pageNum) => ({
413+
displayText: String(pageNum),
414+
displayNumber: pageNum,
415+
totalPages: 150,
416+
});
417+
418+
const measureBlock = vi.fn(async () => makeMeasure(20));
419+
const result = await layoutHeaderFooterWithCache(
420+
sections,
421+
{ width: 400, height: 80 },
422+
measureBlock,
423+
undefined,
424+
undefined,
425+
pageResolver,
426+
);
427+
428+
expect(result.default?.layout.pages).toHaveLength(150);
429+
expect(measureBlock).toHaveBeenCalledTimes(150);
430+
});
431+
432+
it.each([
433+
['decimal', 'decimal'],
434+
['numberInDash', 'numberInDash'],
435+
] as const)('should keep bucketing for %s run-local page number format', async (_name, pageNumberFormat) => {
436+
const sections = {
437+
default: [makeFormattedPageTokenBlock(`header-${pageNumberFormat}`, pageNumberFormat)],
438+
};
439+
440+
const pageResolver: PageResolver = (pageNum) => ({
441+
displayText: String(pageNum),
442+
displayNumber: pageNum,
443+
totalPages: 150,
444+
});
445+
446+
const measureBlock = vi.fn(async () => makeMeasure(20));
447+
const result = await layoutHeaderFooterWithCache(
448+
sections,
449+
{ width: 400, height: 80 },
450+
measureBlock,
451+
undefined,
452+
undefined,
453+
pageResolver,
454+
);
455+
456+
expect(result.default?.layout.pages).toHaveLength(3);
457+
expect(measureBlock).toHaveBeenCalledTimes(3);
458+
});
392459
});
393460

394461
describe('layoutHeaderFooterWithCache - Section-Aware Token Resolution', () => {
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
import { describe, expect, it } from 'vitest';
2+
import type { FlowBlock, Layout, Measure, TextRun } from '@superdoc/contracts';
3+
import { createTestPainter } from './_test-utils.js';
4+
5+
const pageNumberBlock: FlowBlock = {
6+
kind: 'paragraph',
7+
id: 'page-number-block',
8+
runs: [
9+
{
10+
text: '0',
11+
token: 'pageNumber',
12+
pageNumberFormat: 'upperRoman',
13+
fontFamily: 'Arial',
14+
fontSize: 12,
15+
} as TextRun,
16+
],
17+
};
18+
19+
const pageNumberMeasure: Measure = {
20+
kind: 'paragraph',
21+
lines: [
22+
{
23+
fromRun: 0,
24+
fromChar: 0,
25+
toRun: 0,
26+
toChar: 1,
27+
width: 10,
28+
ascent: 8,
29+
descent: 2,
30+
lineHeight: 10,
31+
},
32+
],
33+
totalHeight: 10,
34+
};
35+
36+
const staticBlock: FlowBlock = {
37+
kind: 'paragraph',
38+
id: 'static-block',
39+
runs: [
40+
{
41+
text: 'Static',
42+
fontFamily: 'Arial',
43+
fontSize: 12,
44+
},
45+
],
46+
};
47+
48+
const staticMeasure: Measure = {
49+
kind: 'paragraph',
50+
lines: [
51+
{
52+
fromRun: 0,
53+
fromChar: 0,
54+
toRun: 0,
55+
toChar: 6,
56+
width: 40,
57+
ascent: 8,
58+
descent: 2,
59+
lineHeight: 10,
60+
},
61+
],
62+
totalHeight: 10,
63+
};
64+
65+
function makeLayout(displayNumber: number): Layout {
66+
return {
67+
pageSize: { w: 400, h: 500 },
68+
pages: [
69+
{
70+
number: 1,
71+
displayNumber,
72+
fragments: [
73+
{
74+
kind: 'para',
75+
blockId: 'page-number-block',
76+
fromLine: 0,
77+
toLine: 1,
78+
x: 0,
79+
y: 0,
80+
width: 200,
81+
},
82+
{
83+
kind: 'para',
84+
blockId: 'static-block',
85+
fromLine: 0,
86+
toLine: 1,
87+
x: 0,
88+
y: 20,
89+
width: 200,
90+
},
91+
],
92+
},
93+
],
94+
};
95+
}
96+
97+
describe('DomPainter page-number context patching', () => {
98+
it('rebuilds token fragments when display page number changes during incremental patch', () => {
99+
const mount = document.createElement('div');
100+
document.body.appendChild(mount);
101+
102+
const painter = createTestPainter({
103+
blocks: [pageNumberBlock, staticBlock],
104+
measures: [pageNumberMeasure, staticMeasure],
105+
});
106+
107+
painter.paint(makeLayout(5), mount);
108+
expect(mount.textContent).toContain('V');
109+
const staticFragment = mount.querySelector('[data-block-id="static-block"]');
110+
expect(staticFragment).toBeTruthy();
111+
112+
painter.paint(makeLayout(8), mount);
113+
expect(mount.textContent).toContain('VIII');
114+
expect(mount.querySelector('[data-block-id="static-block"]')).toBe(staticFragment);
115+
});
116+
});

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

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ import type {
3838
ResolvedDrawingItem,
3939
LayoutSourceIdentity,
4040
LayoutStoryLocator,
41+
ListBlock,
4142
} from '@superdoc/contracts';
4243
import {
4344
LAYOUT_BOUNDARY_SCHEMA,
@@ -258,6 +259,59 @@ type PageDomState = {
258259
fragments: FragmentDomState[];
259260
};
260261

262+
function pageContextSignature(context: FragmentRenderContext): string {
263+
return [
264+
context.pageNumber,
265+
context.totalPages,
266+
context.pageNumberText ?? '',
267+
context.pageNumberDisplayNumber ?? '',
268+
].join('|');
269+
}
270+
271+
function hasPageContextTokenInBlock(block: FlowBlock | undefined): boolean {
272+
if (!block) return false;
273+
if (block.kind === 'paragraph') {
274+
for (const run of (block as ParagraphBlock).runs) {
275+
if ('token' in run && (run.token === 'pageNumber' || run.token === 'totalPageCount')) {
276+
return true;
277+
}
278+
}
279+
} else if (block.kind === 'list') {
280+
const list = block as ListBlock;
281+
for (const item of list.items ?? []) {
282+
if (hasPageContextTokenInBlock(item.paragraph)) {
283+
return true;
284+
}
285+
}
286+
} else if (block.kind === 'table') {
287+
const table = block as TableBlock;
288+
for (const row of table.rows ?? []) {
289+
for (const cell of row.cells ?? []) {
290+
const cellBlocks: FlowBlock[] = cell.blocks
291+
? (cell.blocks as FlowBlock[])
292+
: cell.paragraph
293+
? [cell.paragraph]
294+
: [];
295+
if (cellBlocks.some(hasPageContextTokenInBlock)) {
296+
return true;
297+
}
298+
}
299+
}
300+
}
301+
return false;
302+
}
303+
304+
function needsRebuildForPageContext(
305+
currentContext: FragmentRenderContext,
306+
nextContext: FragmentRenderContext,
307+
resolvedItem: ResolvedPaintItem | undefined,
308+
): boolean {
309+
const block = resolvedItem?.kind === 'fragment' && 'block' in resolvedItem ? resolvedItem.block : undefined;
310+
return (
311+
pageContextSignature(currentContext) !== pageContextSignature(nextContext) && hasPageContextTokenInBlock(block)
312+
);
313+
}
314+
261315
/**
262316
* Rendering context passed to fragment renderers containing page metadata.
263317
* Provides information about the current page position and section for dynamic content like page numbers.
@@ -2296,6 +2350,7 @@ export class DomPainter {
22962350
(current.element.dataset.betweenBorder === 'true') !== (betweenInfo?.showBetweenBorder ?? false) ||
22972351
(current.element.dataset.suppressTopBorder === 'true') !== (betweenInfo?.suppressTopBorder ?? false) ||
22982352
(current.element.dataset.gapBelow ?? '') !== (betweenInfo?.gapBelow ? String(betweenInfo.gapBelow) : '');
2353+
const pageContextChanged = needsRebuildForPageContext(current.context, contextBase, resolvedItem);
22992354
// Verify the position mapping is reliable: if mapping the old pmStart doesn't produce
23002355
// the expected new pmStart, the mapping is degenerate (e.g. full-document paste) and
23012356
// we must rebuild to get correct span position attributes.
@@ -2311,6 +2366,7 @@ export class DomPainter {
23112366
current.signature !== resolvedSig ||
23122367
sdtBoundaryMismatch ||
23132368
betweenBorderMismatch ||
2369+
pageContextChanged ||
23142370
mappingUnreliable;
23152371

23162372
if (needsRebuild) {

packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/page-instruction.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
const PAGE_VALUE_FORMAT_SWITCHES = {
22
Arabic: 'decimal',
33
Roman: 'upperRoman',
4+
ROMAN: 'upperRoman',
45
roman: 'lowerRoman',
56
ALPHABETIC: 'upperLetter',
67
alphabetic: 'lowerLetter',

packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/page-preprocessor.test.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ describe('preProcessPageInstruction', () => {
2222
['PAGE', undefined],
2323
['PAGE \\* roman', 'lowerRoman'],
2424
['PAGE \\* Roman \\* MERGEFORMAT', 'upperRoman'],
25+
['PAGE \\* ROMAN', 'upperRoman'],
2526
['page \\* Arabic', 'decimal'],
2627
['PAGE \\* Unsupported \\* MERGEFORMAT', undefined],
2728
])('preserves PAGE instruction and parses supported value format: %s', (instruction, pageNumberFormat) => {

0 commit comments

Comments
 (0)