Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion packages/layout-engine/contracts/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2260,8 +2260,9 @@ export type HeaderFooterType = 'default' | 'first' | 'even' | 'odd';
export type HeaderFooterPage = {
number: number;
fragments: Fragment[];
displayNumber?: number;
numberText?: string;
/** Section-aware numeric page value before formatting. */
displayNumber?: number;
/**
* Optional page-local block clones backing this page's resolved fragments.
* Present when header/footer tokens were laid out per page or per bucket.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,9 @@ describe('page number formatting', () => {
expect(formatPageNumber(5, 'upperRoman')).toBe('V');
expect(formatPageNumber(5, 'lowerRoman')).toBe('v');
expect(formatPageNumber(27, 'upperLetter')).toBe('AA');
expect(formatPageNumber(703, 'lowerLetter')).toBe('aaa');
expect(formatPageNumber(12, 'numberInDash')).toBe('-12-');
expect(formatPageNumber(28, 'upperLetter')).toBe('BB');
expect(formatPageNumber(703, 'lowerLetter')).toBe('a'.repeat(28));
expect(formatPageNumber(12, 'numberInDash')).toBe('- 12 -');
});

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

it('falls back to decimal for unsupported runtime formats', () => {
expect(formatPageNumber(5, 'chicago' as never)).toBe('5');
});

it('falls back to decimal for roman numerals beyond 3999', () => {
expect(formatPageNumber(4000, 'upperRoman')).toBe('4000');
});
Expand Down
16 changes: 5 additions & 11 deletions packages/layout-engine/contracts/src/page-number-formatting.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,16 +24,10 @@ function toUpperRoman(value: number): string {
}

function toUpperLetter(value: number): string {
let n = Math.max(1, value);
let result = '';

while (n > 0) {
const remainder = (n - 1) % 26;
result = String.fromCharCode(65 + remainder) + result;
n = Math.floor((n - 1) / 26);
}

return result;
const normalized = Math.max(1, value);
const index = (normalized - 1) % 26;
const repeatCount = Math.floor((normalized - 1) / 26) + 1;
return String.fromCharCode(65 + index).repeat(repeatCount);
}

export function formatPageNumber(pageNumber: number, format: PageNumberFormat): string {
Expand All @@ -49,7 +43,7 @@ export function formatPageNumber(pageNumber: number, format: PageNumberFormat):
case 'lowerLetter':
return toUpperLetter(value).toLowerCase();
case 'numberInDash':
return `-${value}-`;
return `- ${value} -`;
case 'decimal':
default:
return String(value);
Expand Down
4 changes: 2 additions & 2 deletions packages/layout-engine/contracts/src/resolved-layout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -452,9 +452,9 @@ export function isResolvedDrawingItem(item: ResolvedPaintItem): item is Resolved
/** A resolved header/footer page — mirrors HeaderFooterPage but with resolved items. */
export type ResolvedHeaderFooterPage = {
number: number;
/** Numeric page number after section numbering restart/offset. Used for OOXML odd/even parity. */
displayNumber?: number;
numberText?: string;
/** Section-aware numeric page value before formatting. */
displayNumber?: number;
items: ResolvedPaintItem[];
};

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ export function computeHeaderFooterContentHash(blocks: FlowBlock[]): string {
if ('bold' in run && run.bold) parts.push('b');
if ('italic' in run && run.italic) parts.push('i');
if ('token' in run && run.token) parts.push(`token:${run.token}`);
if ('pageNumberFormat' in run && run.pageNumberFormat) parts.push(`pnf:${run.pageNumberFormat}`);
}
}
}
Expand Down
22 changes: 21 additions & 1 deletion packages/layout-engine/layout-bridge/src/layoutHeaderFooter.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,11 @@
import type { FlowBlock, HeaderFooterLayout, Measure, ParagraphBlock, TableBlock } from '@superdoc/contracts';
import type {
FlowBlock,
HeaderFooterLayout,
ListBlock,
Measure,
ParagraphBlock,
TableBlock,
} from '@superdoc/contracts';
import { layoutHeaderFooter, type HeaderFooterConstraints } from '@superdoc/layout-engine';
import { MeasureCache } from './cache';
import { resolveHeaderFooterTokens, cloneHeaderFooterBlocks } from './resolveHeaderFooterTokens';
Expand Down Expand Up @@ -143,6 +150,11 @@ function hasPageTokens(blocks: FlowBlock[]): boolean {
for (const block of blocks) {
if (block.kind === 'paragraph') {
if (paragraphHasPageToken(block as ParagraphBlock)) return true;
} else if (block.kind === 'list') {
const list = block as ListBlock;
for (const item of list.items ?? []) {
if (paragraphHasPageToken(item.paragraph)) return true;
Comment thread
luccas-harbour marked this conversation as resolved.
}
} else if (block.kind === 'table') {
// SD-1332: PAGE fields can live inside table cells in headers/footers
// (Word's typical layout). Skipping tables here would take the
Expand All @@ -168,6 +180,11 @@ function hasPageNumberTokensRequiringPerPageLayout(blocks: FlowBlock[]): boolean
for (const block of blocks) {
if (block.kind === 'paragraph') {
if (paragraphRequiresPerPageLayout(block as ParagraphBlock)) return true;
} else if (block.kind === 'list') {
const list = block as ListBlock;
for (const item of list.items ?? []) {
if (paragraphRequiresPerPageLayout(item.paragraph)) return true;
}
} else if (block.kind === 'table') {
const table = block as TableBlock;
for (const row of table.rows ?? []) {
Expand Down Expand Up @@ -332,6 +349,7 @@ export async function layoutHeaderFooterWithCache(
blocks: FlowBlock[];
measures: Measure[];
fragments: HeaderFooterLayout['pages'][0]['fragments'];
numberText?: string;
}> = [];

for (const pageNum of pagesToLayout) {
Expand Down Expand Up @@ -372,6 +390,7 @@ export async function layoutHeaderFooterWithCache(
blocks: clonedBlocks,
measures,
fragments: fragmentsWithLines,
numberText: displayText,
});
}

Expand All @@ -390,6 +409,7 @@ export async function layoutHeaderFooterWithCache(
number: p.number,
displayNumber: p.displayNumber,
fragments: p.fragments,
numberText: p.numberText,
blocks: p.blocks,
measures: p.measures,
})),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,25 @@ describe('Cache Invalidation', () => {
expect(hash).toContain('token:pageNumber');
});

it('should include page number token format in hash', () => {
const decimalBlocks: FlowBlock[] = [
{
kind: 'paragraph',
id: 'p1',
runs: [{ text: '0', token: 'pageNumber', pageNumberFormat: 'decimal' }],
} as ParagraphBlock,
];
const romanBlocks: FlowBlock[] = [
{
kind: 'paragraph',
id: 'p1',
runs: [{ text: '0', token: 'pageNumber', pageNumberFormat: 'upperRoman' }],
} as ParagraphBlock,
];

expect(computeHeaderFooterContentHash(decimalBlocks)).not.toBe(computeHeaderFooterContentHash(romanBlocks));
});

it('should produce different hashes for different content', () => {
const blocks1: FlowBlock[] = [
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,23 @@ const makePageTokenBlock = (id: string): FlowBlock => ({
],
});

const makeFormattedPageTokenBlock = (
id: string,
pageNumberFieldFormat: NonNullable<TextRun['pageNumberFieldFormat']>,
): FlowBlock => ({
kind: 'paragraph',
id,
runs: [
{
text: '0',
token: 'pageNumber',
pageNumberFieldFormat,
fontFamily: 'Arial',
fontSize: 12,
} as TextRun,
],
});

describe('getBucketForPageNumber', () => {
it('should return d1 for single-digit page numbers (1-9)', () => {
expect(getBucketForPageNumber(1)).toBe('d1');
Expand Down Expand Up @@ -440,6 +457,34 @@ describe('layoutHeaderFooterWithCache - Digit Bucketing (Large Docs)', () => {
expect(measureBlock).toHaveBeenCalledTimes(3);
expect((result.default?.layout.pages[0].blocks?.[0] as ParagraphBlock).runs[1].text).toBe('005');
});

it.each([
['decimal', { format: 'decimal' }],
['numberInDash', { format: 'numberInDash' }],
] as const)('should keep bucketing for %s run-local page number format', async (_name, pageNumberFieldFormat) => {
const sections = {
default: [makeFormattedPageTokenBlock(`header-${pageNumberFieldFormat.format}`, pageNumberFieldFormat)],
};

const pageResolver: PageResolver = (pageNum) => ({
displayText: String(pageNum),
displayNumber: pageNum,
totalPages: 150,
});

const measureBlock = vi.fn(async () => makeMeasure(20));
const result = await layoutHeaderFooterWithCache(
sections,
{ width: 400, height: 80 },
measureBlock,
undefined,
undefined,
pageResolver,
);

expect(result.default?.layout.pages).toHaveLength(3);
expect(measureBlock).toHaveBeenCalledTimes(3);
});
});

describe('layoutHeaderFooterWithCache - Section-Aware Token Resolution', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ describe('resolveHeaderFooterTokens', () => {
resolveHeaderFooterTokens(blocks, 3, 10, 'iii', 7);

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

Expand Down
42 changes: 23 additions & 19 deletions packages/layout-engine/layout-engine/src/pageNumbering.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,16 +31,20 @@ describe('formatPageNumber', () => {
it('should truncate fractional numbers before formatting', () => {
expect(formatPageNumber(4.9, 'decimal')).toBe('4');
});

it('should fall back to decimal for unsupported runtime formats', () => {
expect(formatPageNumber(5, 'chicago' as never)).toBe('5');
});
});

describe('numberInDash format', () => {
it('should wrap numbers in dashes', () => {
expect(formatPageNumber(1, 'numberInDash')).toBe('-1-');
expect(formatPageNumber(12, 'numberInDash')).toBe('-12-');
expect(formatPageNumber(1, 'numberInDash')).toBe('- 1 -');
expect(formatPageNumber(12, 'numberInDash')).toBe('- 12 -');
});

it('should clamp zero to 1', () => {
expect(formatPageNumber(0, 'numberInDash')).toBe('-1-');
expect(formatPageNumber(0, 'numberInDash')).toBe('- 1 -');
});
});

Expand Down Expand Up @@ -128,19 +132,19 @@ describe('formatPageNumber', () => {
expect(formatPageNumber(26, 'upperLetter')).toBe('Z');
});

it('should format numbers > 26 as AA, AB, etc.', () => {
it('should format numbers > 26 as repeated letters', () => {
expect(formatPageNumber(27, 'upperLetter')).toBe('AA');
expect(formatPageNumber(28, 'upperLetter')).toBe('AB');
expect(formatPageNumber(52, 'upperLetter')).toBe('AZ');
expect(formatPageNumber(53, 'upperLetter')).toBe('BA');
expect(formatPageNumber(78, 'upperLetter')).toBe('BZ');
expect(formatPageNumber(79, 'upperLetter')).toBe('CA');
expect(formatPageNumber(28, 'upperLetter')).toBe('BB');
expect(formatPageNumber(52, 'upperLetter')).toBe('ZZ');
expect(formatPageNumber(53, 'upperLetter')).toBe('AAA');
expect(formatPageNumber(78, 'upperLetter')).toBe('ZZZ');
expect(formatPageNumber(79, 'upperLetter')).toBe('AAAA');
});

it('should format large numbers correctly', () => {
expect(formatPageNumber(702, 'upperLetter')).toBe('ZZ');
expect(formatPageNumber(703, 'upperLetter')).toBe('AAA');
expect(formatPageNumber(704, 'upperLetter')).toBe('AAB');
expect(formatPageNumber(702, 'upperLetter')).toBe('Z'.repeat(27));
expect(formatPageNumber(703, 'upperLetter')).toBe('A'.repeat(28));
expect(formatPageNumber(704, 'upperLetter')).toBe('B'.repeat(28));
});

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

it('should format numbers > 26 as aa, ab, etc.', () => {
it('should format numbers > 26 as repeated letters', () => {
expect(formatPageNumber(27, 'lowerLetter')).toBe('aa');
expect(formatPageNumber(28, 'lowerLetter')).toBe('ab');
expect(formatPageNumber(52, 'lowerLetter')).toBe('az');
expect(formatPageNumber(53, 'lowerLetter')).toBe('ba');
expect(formatPageNumber(28, 'lowerLetter')).toBe('bb');
expect(formatPageNumber(52, 'lowerLetter')).toBe('zz');
expect(formatPageNumber(53, 'lowerLetter')).toBe('aaa');
});

it('should format large numbers correctly', () => {
expect(formatPageNumber(702, 'lowerLetter')).toBe('zz');
expect(formatPageNumber(703, 'lowerLetter')).toBe('aaa');
expect(formatPageNumber(702, 'lowerLetter')).toBe('z'.repeat(27));
expect(formatPageNumber(703, 'lowerLetter')).toBe('a'.repeat(28));
});

it('should clamp zero and negative to a', () => {
Expand Down Expand Up @@ -434,7 +438,7 @@ describe('computeDisplayPageNumber', () => {
expect(result[24].displayText).toBe('Y');
expect(result[25].displayText).toBe('Z');
expect(result[26].displayText).toBe('AA');
expect(result[27].displayText).toBe('AB');
expect(result[27].displayText).toBe('BB');
});

it('should handle large page numbers in roman numerals', () => {
Expand Down
23 changes: 23 additions & 0 deletions packages/layout-engine/layout-engine/src/resolvePageTokens.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -276,4 +276,27 @@ describe('resolveTokensInBlock', () => {
expect((block.runs[0] as { pmStart?: number }).pmStart).toBe(10);
expect((block.runs[0] as { pmEnd?: number }).pmEnd).toBe(11);
});

it('should apply run-local page number format when resolving tokens', () => {
const block: ParagraphBlock = {
kind: 'paragraph',
id: 'test-local-format',
runs: [
{
text: '0',
token: 'pageNumber',
pageNumberFieldFormat: { format: 'upperRoman' },
fontFamily: 'Arial',
fontSize: 12,
} as TextRun,
],
};

const wasModified = resolveTokensInBlock(block, 5, 10);

expect(wasModified).toBe(true);
expect((block.runs[0] as TextRun).text).toBe('V');
expect((block.runs[0] as TextRun).token).toBeUndefined();
expect((block.runs[0] as TextRun).pageNumberFieldFormat).toBeUndefined();
});
});
11 changes: 7 additions & 4 deletions packages/layout-engine/layout-engine/src/resolvePageTokens.ts
Original file line number Diff line number Diff line change
Expand Up @@ -208,11 +208,11 @@ function cloneBlockWithResolvedTokens(
if ('token' in run && run.token) {
if (run.token === 'pageNumber') {
// Clone the run and resolve the token
const { token: _token, ...runWithoutToken } = run;
const { token: _token, pageNumberFieldFormat, ...runWithoutToken } = run;
return {
...runWithoutToken,
text: run.pageNumberFieldFormat
? formatPageNumberFieldValue(displayPageInfo.displayNumber, run.pageNumberFieldFormat)
text: pageNumberFieldFormat
? formatPageNumberFieldValue(displayPageInfo.displayNumber, pageNumberFieldFormat)
: displayPageInfo.displayText,
};
} else if (run.token === 'totalPageCount') {
Expand Down Expand Up @@ -284,9 +284,12 @@ export function resolveTokensInBlock(block: ParagraphBlock, pageNumber: number,
if ('token' in run && run.token) {
if (run.token === 'pageNumber') {
// Replace placeholder text with actual page number
run.text = pageNumberStr;
run.text = run.pageNumberFieldFormat
? formatPageNumberFieldValue(pageNumber, run.pageNumberFieldFormat)
: pageNumberStr;
// Clear token metadata to treat as normal text after resolution
delete run.token;
delete run.pageNumberFieldFormat;
blockModified = true;
} else if (run.token === 'totalPageCount') {
// Replace placeholder text with total page count
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -355,6 +355,7 @@ export const deriveBlockVersion = (block: FlowBlock): string => {
textRun.vertAlign ?? '',
textRun.baselineShift != null ? textRun.baselineShift : '',
textRun.token ?? '',
textRun.pageNumberFieldFormat ? JSON.stringify(textRun.pageNumberFieldFormat) : '',
trackedVersion,
textRun.comments?.length ?? 0,
// SD-3098: DomPainter reads run.bidi to apply dir + RLM injection; signature must include it.
Expand Down Expand Up @@ -539,6 +540,9 @@ export const deriveBlockVersion = (block: FlowBlock): string => {
hash = hashString(hash, getRunBooleanProp(run, 'strike') ? '1' : '');
hash = hashString(hash, getRunStringProp(run, 'vertAlign'));
hash = hashNumber(hash, getRunNumberProp(run, 'baselineShift'));
hash = hashString(hash, getRunStringProp(run, 'token'));
const pageNumberFieldFormat = (run as { pageNumberFieldFormat?: unknown }).pageNumberFieldFormat;
hash = hashString(hash, pageNumberFieldFormat ? JSON.stringify(pageNumberFieldFormat) : '');
// SD-3098: include run.bidi so rtl-only changes invalidate the cached block hash.
const bidi = (run as { bidi?: unknown }).bidi;
hash = hashString(hash, bidi ? JSON.stringify(bidi) : '');
Expand Down
Loading
Loading