Skip to content

Commit a1ced0b

Browse files
feat(page-number): per-field PAGE value-format switches & case-insensitive field dispatch (SD-3006) (#3599)
* feat(super-converter): match field dispatch keywords case-insensitively OOXML field type names are case-insensitive, but the field-reference preprocessors dispatched on the raw first token (e.g. only "PAGE", not "page"). A lowercase PAGE/NUMPAGES field in a repeated footer fell through to the cached static text and showed the same number on every page. Add a shared extractFieldKeyword helper that normalizes the dispatch token to upper case while leaving the original instruction text intact for downstream processors, and route fldSimple/fldChar dispatch and the header/footer page-field scan through it. Make the HYPERLINK target regex case-insensitive and anchored. Cover the new behavior with unit tests and a behavior spec asserting a lowercase PAGE footer resolves per page. * test(super-converter): cover field keyword dispatch * fix(super-converter): trust header footer field keyword * feat(page-number): support PAGE field value-format switches Parse the `\*` value-format switches on PAGE field instructions (Arabic, Roman/roman, ALPHABETIC/alphabetic, ArabicDash) into a run-local pageNumberFormat override, and apply it independently of section numbering when resolving page-number tokens. - add parsePageInstruction / pageNumberFormatToInstructionSwitch in a new page-instruction.js; page-preprocessor stores the original instruction and parsed format on sd:autoPageNumber - round-trip instruction + pageNumberFormat through the autoPageNumber translator and the page-number extension node (preserve imported instruction text, synthesize a switch for new formatted nodes) - add pageNumberFormat to TextRun and thread it through layout-bridge, layout-resolved, painters (resolveRunText), and stamp section-aware displayNumber on pages so formatting uses the pre-format numeric value - move formatPageNumber + PageNumberFormat into @superdoc/contracts as the single source of truth; re-export from pageNumbering - include pageNumberFormat in block-version, merge, and hash signatures so format changes invalidate cached layouts upperLetter/lowerLetter now render as repeated letters (AA, BB, CC) to match Word instead of the previous Excel-style sequence (AA, AB). * fix(page-number): render ArabicDash spacing * fix(layout-bridge): hash page number formats * fix(page-number): fall back for unknown formats * test(behavior): cover formatted footer page fields * fix(page-number): address PAGE field review feedback * fix(sequence-field): preserve cached numbering for lowercase seq fields Only dispatch the SEQ pre-processor for uppercase SEQ instructions so lowercase `seq` fields keep their cached visible result runs instead of being re-resolved. Also recurse into run-wrapped content when extracting resolved text so cached numbers nested inside runs are captured. * fix(painter): rebuild drawing page fields on context changes * fix: footnote formatter parity test * fix(layout): remove duplicate displayNumber fields and fix page signature ref Drop the redundant displayNumber declarations from HeaderFooterPage, ResolvedHeaderFooterPage, and the layout-bridge page builder, keeping the section-aware variant. Correct the renderer page context signature to read displayPageNumber instead of the nonexistent pageNumberDisplayNumber. * test(layout): update page-number field expectations Adjust header/footer token and footer rendering expectations to the spaced "- N -" format, and migrate the renderer page-context test to the pageNumberFieldFormat shape.
1 parent bade825 commit a1ced0b

41 files changed

Lines changed: 1037 additions & 76 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2260,8 +2260,9 @@ export type HeaderFooterType = 'default' | 'first' | 'even' | 'odd';
22602260
export type HeaderFooterPage = {
22612261
number: number;
22622262
fragments: Fragment[];
2263-
displayNumber?: number;
22642263
numberText?: string;
2264+
/** Section-aware numeric page value before formatting. */
2265+
displayNumber?: number;
22652266
/**
22662267
* Optional page-local block clones backing this page's resolved fragments.
22672268
* Present when header/footer tokens were laid out per page or per bucket.

packages/layout-engine/contracts/src/page-number-formatting.test.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,9 @@ describe('page number formatting', () => {
77
expect(formatPageNumber(5, 'upperRoman')).toBe('V');
88
expect(formatPageNumber(5, 'lowerRoman')).toBe('v');
99
expect(formatPageNumber(27, 'upperLetter')).toBe('AA');
10-
expect(formatPageNumber(703, 'lowerLetter')).toBe('aaa');
11-
expect(formatPageNumber(12, 'numberInDash')).toBe('-12-');
10+
expect(formatPageNumber(28, 'upperLetter')).toBe('BB');
11+
expect(formatPageNumber(703, 'lowerLetter')).toBe('a'.repeat(28));
12+
expect(formatPageNumber(12, 'numberInDash')).toBe('- 12 -');
1213
});
1314

1415
it('normalizes page numbers before formatting', () => {
@@ -17,6 +18,10 @@ describe('page number formatting', () => {
1718
expect(formatPageNumber(Number.NaN, 'decimal')).toBe('1');
1819
});
1920

21+
it('falls back to decimal for unsupported runtime formats', () => {
22+
expect(formatPageNumber(5, 'chicago' as never)).toBe('5');
23+
});
24+
2025
it('falls back to decimal for roman numerals beyond 3999', () => {
2126
expect(formatPageNumber(4000, 'upperRoman')).toBe('4000');
2227
});

packages/layout-engine/contracts/src/page-number-formatting.ts

Lines changed: 5 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -24,16 +24,10 @@ function toUpperRoman(value: number): string {
2424
}
2525

2626
function toUpperLetter(value: number): string {
27-
let n = Math.max(1, value);
28-
let result = '';
29-
30-
while (n > 0) {
31-
const remainder = (n - 1) % 26;
32-
result = String.fromCharCode(65 + remainder) + result;
33-
n = Math.floor((n - 1) / 26);
34-
}
35-
36-
return result;
27+
const normalized = Math.max(1, value);
28+
const index = (normalized - 1) % 26;
29+
const repeatCount = Math.floor((normalized - 1) / 26) + 1;
30+
return String.fromCharCode(65 + index).repeat(repeatCount);
3731
}
3832

3933
export function formatPageNumber(pageNumber: number, format: PageNumberFormat): string {
@@ -49,7 +43,7 @@ export function formatPageNumber(pageNumber: number, format: PageNumberFormat):
4943
case 'lowerLetter':
5044
return toUpperLetter(value).toLowerCase();
5145
case 'numberInDash':
52-
return `-${value}-`;
46+
return `- ${value} -`;
5347
case 'decimal':
5448
default:
5549
return String(value);

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -452,9 +452,9 @@ export function isResolvedDrawingItem(item: ResolvedPaintItem): item is Resolved
452452
/** A resolved header/footer page — mirrors HeaderFooterPage but with resolved items. */
453453
export type ResolvedHeaderFooterPage = {
454454
number: number;
455-
/** Numeric page number after section numbering restart/offset. Used for OOXML odd/even parity. */
456-
displayNumber?: number;
457455
numberText?: string;
456+
/** Section-aware numeric page value before formatting. */
457+
displayNumber?: number;
458458
items: ResolvedPaintItem[];
459459
};
460460

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ export function computeHeaderFooterContentHash(blocks: FlowBlock[]): string {
5151
if ('bold' in run && run.bold) parts.push('b');
5252
if ('italic' in run && run.italic) parts.push('i');
5353
if ('token' in run && run.token) parts.push(`token:${run.token}`);
54+
if ('pageNumberFormat' in run && run.pageNumberFormat) parts.push(`pnf:${run.pageNumberFormat}`);
5455
}
5556
}
5657
}

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

Lines changed: 21 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 ?? []) {
@@ -332,6 +349,7 @@ export async function layoutHeaderFooterWithCache(
332349
blocks: FlowBlock[];
333350
measures: Measure[];
334351
fragments: HeaderFooterLayout['pages'][0]['fragments'];
352+
numberText?: string;
335353
}> = [];
336354

337355
for (const pageNum of pagesToLayout) {
@@ -372,6 +390,7 @@ export async function layoutHeaderFooterWithCache(
372390
blocks: clonedBlocks,
373391
measures,
374392
fragments: fragmentsWithLines,
393+
numberText: displayText,
375394
});
376395
}
377396

@@ -390,6 +409,7 @@ export async function layoutHeaderFooterWithCache(
390409
number: p.number,
391410
displayNumber: p.displayNumber,
392411
fragments: p.fragments,
412+
numberText: p.numberText,
393413
blocks: p.blocks,
394414
measures: p.measures,
395415
})),

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

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,25 @@ describe('Cache Invalidation', () => {
5252
expect(hash).toContain('token:pageNumber');
5353
});
5454

55+
it('should include page number token format in hash', () => {
56+
const decimalBlocks: FlowBlock[] = [
57+
{
58+
kind: 'paragraph',
59+
id: 'p1',
60+
runs: [{ text: '0', token: 'pageNumber', pageNumberFormat: 'decimal' }],
61+
} as ParagraphBlock,
62+
];
63+
const romanBlocks: FlowBlock[] = [
64+
{
65+
kind: 'paragraph',
66+
id: 'p1',
67+
runs: [{ text: '0', token: 'pageNumber', pageNumberFormat: 'upperRoman' }],
68+
} as ParagraphBlock,
69+
];
70+
71+
expect(computeHeaderFooterContentHash(decimalBlocks)).not.toBe(computeHeaderFooterContentHash(romanBlocks));
72+
});
73+
5574
it('should produce different hashes for different content', () => {
5675
const blocks1: FlowBlock[] = [
5776
{

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', () => {

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,7 @@ describe('resolveHeaderFooterTokens', () => {
8181
resolveHeaderFooterTokens(blocks, 3, 10, 'iii', 7);
8282

8383
const block = blocks[0] as ParagraphBlock;
84-
expect(block.runs[0].text).toBe('-7-');
84+
expect(block.runs[0].text).toBe('- 7 -');
8585
expect((block.runs[0] as TextRun).token).toBe('pageNumber');
8686
});
8787

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

Lines changed: 23 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -31,16 +31,20 @@ describe('formatPageNumber', () => {
3131
it('should truncate fractional numbers before formatting', () => {
3232
expect(formatPageNumber(4.9, 'decimal')).toBe('4');
3333
});
34+
35+
it('should fall back to decimal for unsupported runtime formats', () => {
36+
expect(formatPageNumber(5, 'chicago' as never)).toBe('5');
37+
});
3438
});
3539

3640
describe('numberInDash format', () => {
3741
it('should wrap numbers in dashes', () => {
38-
expect(formatPageNumber(1, 'numberInDash')).toBe('-1-');
39-
expect(formatPageNumber(12, 'numberInDash')).toBe('-12-');
42+
expect(formatPageNumber(1, 'numberInDash')).toBe('- 1 -');
43+
expect(formatPageNumber(12, 'numberInDash')).toBe('- 12 -');
4044
});
4145

4246
it('should clamp zero to 1', () => {
43-
expect(formatPageNumber(0, 'numberInDash')).toBe('-1-');
47+
expect(formatPageNumber(0, 'numberInDash')).toBe('- 1 -');
4448
});
4549
});
4650

@@ -128,19 +132,19 @@ describe('formatPageNumber', () => {
128132
expect(formatPageNumber(26, 'upperLetter')).toBe('Z');
129133
});
130134

131-
it('should format numbers > 26 as AA, AB, etc.', () => {
135+
it('should format numbers > 26 as repeated letters', () => {
132136
expect(formatPageNumber(27, 'upperLetter')).toBe('AA');
133-
expect(formatPageNumber(28, 'upperLetter')).toBe('AB');
134-
expect(formatPageNumber(52, 'upperLetter')).toBe('AZ');
135-
expect(formatPageNumber(53, 'upperLetter')).toBe('BA');
136-
expect(formatPageNumber(78, 'upperLetter')).toBe('BZ');
137-
expect(formatPageNumber(79, 'upperLetter')).toBe('CA');
137+
expect(formatPageNumber(28, 'upperLetter')).toBe('BB');
138+
expect(formatPageNumber(52, 'upperLetter')).toBe('ZZ');
139+
expect(formatPageNumber(53, 'upperLetter')).toBe('AAA');
140+
expect(formatPageNumber(78, 'upperLetter')).toBe('ZZZ');
141+
expect(formatPageNumber(79, 'upperLetter')).toBe('AAAA');
138142
});
139143

140144
it('should format large numbers correctly', () => {
141-
expect(formatPageNumber(702, 'upperLetter')).toBe('ZZ');
142-
expect(formatPageNumber(703, 'upperLetter')).toBe('AAA');
143-
expect(formatPageNumber(704, 'upperLetter')).toBe('AAB');
145+
expect(formatPageNumber(702, 'upperLetter')).toBe('Z'.repeat(27));
146+
expect(formatPageNumber(703, 'upperLetter')).toBe('A'.repeat(28));
147+
expect(formatPageNumber(704, 'upperLetter')).toBe('B'.repeat(28));
144148
});
145149

146150
it('should clamp zero and negative to A', () => {
@@ -158,16 +162,16 @@ describe('formatPageNumber', () => {
158162
expect(formatPageNumber(26, 'lowerLetter')).toBe('z');
159163
});
160164

161-
it('should format numbers > 26 as aa, ab, etc.', () => {
165+
it('should format numbers > 26 as repeated letters', () => {
162166
expect(formatPageNumber(27, 'lowerLetter')).toBe('aa');
163-
expect(formatPageNumber(28, 'lowerLetter')).toBe('ab');
164-
expect(formatPageNumber(52, 'lowerLetter')).toBe('az');
165-
expect(formatPageNumber(53, 'lowerLetter')).toBe('ba');
167+
expect(formatPageNumber(28, 'lowerLetter')).toBe('bb');
168+
expect(formatPageNumber(52, 'lowerLetter')).toBe('zz');
169+
expect(formatPageNumber(53, 'lowerLetter')).toBe('aaa');
166170
});
167171

168172
it('should format large numbers correctly', () => {
169-
expect(formatPageNumber(702, 'lowerLetter')).toBe('zz');
170-
expect(formatPageNumber(703, 'lowerLetter')).toBe('aaa');
173+
expect(formatPageNumber(702, 'lowerLetter')).toBe('z'.repeat(27));
174+
expect(formatPageNumber(703, 'lowerLetter')).toBe('a'.repeat(28));
171175
});
172176

173177
it('should clamp zero and negative to a', () => {
@@ -434,7 +438,7 @@ describe('computeDisplayPageNumber', () => {
434438
expect(result[24].displayText).toBe('Y');
435439
expect(result[25].displayText).toBe('Z');
436440
expect(result[26].displayText).toBe('AA');
437-
expect(result[27].displayText).toBe('AB');
441+
expect(result[27].displayText).toBe('BB');
438442
});
439443

440444
it('should handle large page numbers in roman numerals', () => {

0 commit comments

Comments
 (0)