Skip to content
Open
Show file tree
Hide file tree
Changes from 8 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
64 changes: 64 additions & 0 deletions packages/layout-engine/contracts/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -391,6 +391,8 @@ export type TextRun = RunMarks & {
link?: FlowRunLink;
/** Token annotations for dynamic content (page numbers, etc.). */
token?: 'pageNumber' | 'totalPageCount' | 'pageReference';
/** PAGE field-local value formatting override. Only used when token === 'pageNumber'. */
pageNumberFormat?: PageNumberFormat;
/** Absolute ProseMirror position (inclusive) of first character in this run. */
pmStart?: number;
/** Absolute ProseMirror position (exclusive) after the last character. */
Expand Down Expand Up @@ -419,6 +421,64 @@ export type TextRun = RunMarks & {
script?: RunScriptContext;
};

/**
* Page number format types supported by the layout pipeline.
* These mirror the common OOXML page number formats used by section numbering
* and PAGE field value-format switches.
*/
export type PageNumberFormat = 'decimal' | 'upperRoman' | 'lowerRoman' | 'upperLetter' | 'lowerLetter' | 'numberInDash';

function toUpperRoman(num: number): string {
if (num < 1 || num > 3999) {
return String(num);
}

const values = [1000, 900, 500, 400, 100, 90, 50, 40, 10, 9, 5, 4, 1];
const numerals = ['M', 'CM', 'D', 'CD', 'C', 'XC', 'L', 'XL', 'X', 'IX', 'V', 'IV', 'I'];
let result = '';
let remaining = num;

for (let i = 0; i < values.length; i += 1) {
while (remaining >= values[i]) {
result += numerals[i];
remaining -= values[i];
}
}

return result;
}

function toRepeatedLetter(num: number, baseCodePoint: number): string {
if (num < 1) {
return String.fromCharCode(baseCodePoint);
}

const index = (num - 1) % 26;
const repeatCount = Math.floor((num - 1) / 26) + 1;
return String.fromCharCode(baseCodePoint + index).repeat(repeatCount);
}

export function formatPageNumber(pageNumber: number, format: PageNumberFormat): string {
const num = Math.max(1, pageNumber);

switch (format) {
case 'decimal':
return String(num);
case 'upperRoman':
return toUpperRoman(num);
case 'lowerRoman':
return toUpperRoman(num).toLowerCase();
case 'upperLetter':
return toRepeatedLetter(num, 65);
case 'lowerLetter':
return toRepeatedLetter(num, 97);
case 'numberInDash':
return `- ${num} -`;
default:
return String(num);
}
}

export type TabRun = RunMarks & {
kind: 'tab';
text: '\t';
Expand Down Expand Up @@ -1938,6 +1998,8 @@ export type Page = {
*/
footnoteReserved?: number;
numberText?: string;
/** Section-aware numeric page value before formatting. */
displayNumber?: number;
size?: { w: number; h: number };
orientation?: 'portrait' | 'landscape';
sectionRefs?: {
Expand Down Expand Up @@ -2165,6 +2227,8 @@ export type HeaderFooterPage = {
number: number;
fragments: Fragment[];
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
4 changes: 4 additions & 0 deletions packages/layout-engine/contracts/src/resolved-layout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,8 @@ export type ResolvedPage = {
footnoteReserved?: number;
/** Formatted page number text (e.g. "i", "ii" for Roman numeral sections). */
numberText?: string;
/** Section-aware numeric page value before formatting. */
displayNumber?: number;
/** Vertical alignment of content within this page. */
vAlign?: SectionVerticalAlign;
/** Base section margins before header/footer inflation. Used for vAlign centering calculations. */
Expand Down Expand Up @@ -449,6 +451,8 @@ export function isResolvedDrawingItem(item: ResolvedPaintItem): item is Resolved
export type ResolvedHeaderFooterPage = {
number: 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
Original file line number Diff line number Diff line change
Expand Up @@ -2048,11 +2048,12 @@ export async function incrementalLayout(
// Create page resolver for section-aware header/footer numbering
// Only use page resolver if feature flag is enabled
const pageResolver = FeatureFlags.HEADER_FOOTER_PAGE_TOKENS
? (pageNumber: number): { displayText: string; totalPages: number } => {
? (pageNumber: number): { displayText: string; displayNumber: number; totalPages: number } => {
const pageIndex = pageNumber - 1;
const displayInfo = numberingCtx.displayPages[pageIndex];
return {
displayText: displayInfo?.displayText ?? String(pageNumber),
displayNumber: displayInfo?.displayNumber ?? pageNumber,
totalPages: numberingCtx.totalPages,
};
}
Expand Down
11 changes: 9 additions & 2 deletions packages/layout-engine/layout-bridge/src/layoutHeaderFooter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ export type HeaderFooterBatchResult = Partial<
*/
export type PageResolver = (pageNumber: number) => {
displayText: string;
displayNumber?: number;
totalPages: number;
};

Expand Down Expand Up @@ -288,16 +289,18 @@ export async function layoutHeaderFooterWithCache(
blocks: FlowBlock[];
measures: Measure[];
fragments: HeaderFooterLayout['pages'][0]['fragments'];
numberText?: string;
displayNumber?: number;
}> = [];

for (const pageNum of pagesToLayout) {
// Clone blocks for this page
const clonedBlocks = cloneHeaderFooterBlocks(blocks);

// Resolve page number tokens for this specific page
const { displayText, totalPages: totalPagesForPage } = pageResolver(pageNum);
const { displayText, displayNumber, totalPages: totalPagesForPage } = pageResolver(pageNum);

resolveHeaderFooterTokens(clonedBlocks, pageNum, totalPagesForPage, displayText);
resolveHeaderFooterTokens(clonedBlocks, pageNum, totalPagesForPage, displayText, displayNumber);

// Measure and layout
const measures = await cache.measureBlocks(clonedBlocks, constraints, measureBlock);
Expand Down Expand Up @@ -327,6 +330,8 @@ export async function layoutHeaderFooterWithCache(
blocks: clonedBlocks,
measures,
fragments: fragmentsWithLines,
numberText: displayText,
displayNumber,
});
}

Expand All @@ -344,6 +349,8 @@ export async function layoutHeaderFooterWithCache(
pages: pages.map((p) => ({
number: p.number,
fragments: p.fragments,
numberText: p.numberText,
displayNumber: p.displayNumber,
blocks: p.blocks,
measures: p.measures,
})),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
* page number is used when calculating dimensions and caching layouts.
*/

import type { FlowBlock, ParagraphBlock, TableBlock } from '@superdoc/contracts';
import { formatPageNumber, type FlowBlock, type ParagraphBlock, type TableBlock } from '@superdoc/contracts';

/**
* Walk every paragraph block reachable through `blocks`, including those
Expand Down Expand Up @@ -72,6 +72,7 @@ export function resolveHeaderFooterTokens(
pageNumber: number,
totalPages: number,
pageNumberText?: string,
displayNumber?: number,
): void {
// Validate inputs
if (!blocks || blocks.length === 0) {
Expand All @@ -89,6 +90,7 @@ export function resolveHeaderFooterTokens(
}

const pageNumberStr = pageNumberText ?? String(pageNumber);
const pageNumberDisplayNumber = displayNumber ?? pageNumber;
const totalPagesStr = String(totalPages);

// Process every paragraph block, including those nested in table cells
Expand All @@ -104,7 +106,9 @@ export function resolveHeaderFooterTokens(
// IMPORTANT: Do NOT delete run.token - the painter needs it to
// re-resolve the correct page number at render time for each page.
// The text here is for measurement purposes (digit width).
run.text = pageNumberStr;
run.text = run.pageNumberFormat
Comment thread
luccas-harbour marked this conversation as resolved.
? formatPageNumber(pageNumberDisplayNumber, run.pageNumberFormat)
: pageNumberStr;
Comment thread
luccas-harbour marked this conversation as resolved.
} else if (run.token === 'totalPageCount') {
// Replace placeholder text with total page count for measurement.
// IMPORTANT: Keep token for painter to re-resolve if needed.
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 @@ -61,6 +61,30 @@ describe('resolveHeaderFooterTokens', () => {
expect((block.runs[0] as TextRun).token).toBe('pageNumber');
});

it('should let run-local pageNumberFormat override provided section text', () => {
const blocks: FlowBlock[] = [
{
kind: 'paragraph',
id: 'header-local-format',
runs: [
{
text: '0',
token: 'pageNumber',
pageNumberFormat: 'upperRoman',
fontFamily: 'Arial',
fontSize: 12,
} as TextRun,
],
} as ParagraphBlock,
];

resolveHeaderFooterTokens(blocks, 1, 10, 'i', 5);

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

it('should resolve totalPageCount token in footer blocks', () => {
const blocks: FlowBlock[] = [
{
Expand Down
1 change: 1 addition & 0 deletions packages/layout-engine/layout-engine/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1482,6 +1482,7 @@ export function layoutDocument(blocks: FlowBlock[], measures: Measure[], options
// second callback: after page creation -> stamp display number, section refs, section index, and advance counter
if (state?.page) {
state.page.numberText = formatPageNumber(activePageCounter, activeNumberFormat);
state.page.displayNumber = activePageCounter;
// Stamp section index on the page for section-aware page numbering and header/footer selection
state.page.sectionIndex = activeSectionIndex;
layoutLog(`[Layout] Page ${state.page.number}: Stamped sectionIndex:`, activeSectionIndex);
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 @@ -27,16 +27,20 @@ describe('formatPageNumber', () => {
expect(formatPageNumber(-1, 'decimal')).toBe('1');
expect(formatPageNumber(-100, 'decimal')).toBe('1');
});

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 @@ -124,19 +128,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 @@ -154,16 +158,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 @@ -430,7 +434,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
Loading
Loading