Skip to content

Commit fb4fc3f

Browse files
fix(page-number): address PAGE field review feedback
1 parent eaba3ba commit fb4fc3f

6 files changed

Lines changed: 237 additions & 1 deletion

File tree

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

Lines changed: 18 additions & 1 deletion
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';
@@ -143,6 +150,11 @@ function hasPageTokens(blocks: FlowBlock[]): boolean {
143150
for (const block of blocks) {
144151
if (block.kind === 'paragraph') {
145152
if (paragraphHasPageToken(block as ParagraphBlock)) return true;
153+
} else if (block.kind === 'list') {
154+
const list = block as ListBlock;
155+
for (const item of list.items ?? []) {
156+
if (paragraphHasPageToken(item.paragraph)) return true;
157+
}
146158
} else if (block.kind === 'table') {
147159
// SD-1332: PAGE fields can live inside table cells in headers/footers
148160
// (Word's typical layout). Skipping tables here would take the
@@ -168,6 +180,11 @@ function hasPageNumberTokensRequiringPerPageLayout(blocks: FlowBlock[]): boolean
168180
for (const block of blocks) {
169181
if (block.kind === 'paragraph') {
170182
if (paragraphRequiresPerPageLayout(block as ParagraphBlock)) return true;
183+
} else if (block.kind === 'list') {
184+
const list = block as ListBlock;
185+
for (const item of list.items ?? []) {
186+
if (paragraphRequiresPerPageLayout(item.paragraph)) return true;
187+
}
171188
} else if (block.kind === 'table') {
172189
const table = block as TableBlock;
173190
for (const row of table.rows ?? []) {

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

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

92+
const makeFormattedPageTokenBlock = (
93+
id: string,
94+
pageNumberFieldFormat: NonNullable<TextRun['pageNumberFieldFormat']>,
95+
): FlowBlock => ({
96+
kind: 'paragraph',
97+
id,
98+
runs: [
99+
{
100+
text: '0',
101+
token: 'pageNumber',
102+
pageNumberFieldFormat,
103+
fontFamily: 'Arial',
104+
fontSize: 12,
105+
} as TextRun,
106+
],
107+
});
108+
92109
describe('getBucketForPageNumber', () => {
93110
it('should return d1 for single-digit page numbers (1-9)', () => {
94111
expect(getBucketForPageNumber(1)).toBe('d1');
@@ -440,6 +457,34 @@ describe('layoutHeaderFooterWithCache - Digit Bucketing (Large Docs)', () => {
440457
expect(measureBlock).toHaveBeenCalledTimes(3);
441458
expect((result.default?.layout.pages[0].blocks?.[0] as ParagraphBlock).runs[1].text).toBe('005');
442459
});
460+
461+
it.each([
462+
['decimal', { format: 'decimal' }],
463+
['numberInDash', { format: 'numberInDash' }],
464+
] as const)('should keep bucketing for %s run-local page number format', async (_name, pageNumberFieldFormat) => {
465+
const sections = {
466+
default: [makeFormattedPageTokenBlock(`header-${pageNumberFieldFormat.format}`, pageNumberFieldFormat)],
467+
};
468+
469+
const pageResolver: PageResolver = (pageNum) => ({
470+
displayText: String(pageNum),
471+
displayNumber: pageNum,
472+
totalPages: 150,
473+
});
474+
475+
const measureBlock = vi.fn(async () => makeMeasure(20));
476+
const result = await layoutHeaderFooterWithCache(
477+
sections,
478+
{ width: 400, height: 80 },
479+
measureBlock,
480+
undefined,
481+
undefined,
482+
pageResolver,
483+
);
484+
485+
expect(result.default?.layout.pages).toHaveLength(3);
486+
expect(measureBlock).toHaveBeenCalledTimes(3);
487+
});
443488
});
444489

445490
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.
@@ -2297,6 +2351,7 @@ export class DomPainter {
22972351
(current.element.dataset.betweenBorder === 'true') !== (betweenInfo?.showBetweenBorder ?? false) ||
22982352
(current.element.dataset.suppressTopBorder === 'true') !== (betweenInfo?.suppressTopBorder ?? false) ||
22992353
(current.element.dataset.gapBelow ?? '') !== (betweenInfo?.gapBelow ? String(betweenInfo.gapBelow) : '');
2354+
const pageContextChanged = needsRebuildForPageContext(current.context, contextBase, resolvedItem);
23002355
// Verify the position mapping is reliable: if mapping the old pmStart doesn't produce
23012356
// the expected new pmStart, the mapping is degenerate (e.g. full-document paste) and
23022357
// we must rebuild to get correct span position attributes.
@@ -2312,6 +2367,7 @@ export class DomPainter {
23122367
current.signature !== resolvedSig ||
23132368
sdtBoundaryMismatch ||
23142369
betweenBorderMismatch ||
2370+
pageContextChanged ||
23152371
mappingUnreliable;
23162372

23172373
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)