Skip to content
Open
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
9 changes: 9 additions & 0 deletions packages/layout-engine/contracts/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { TabStop } from './engines/tabs.js';
import type { PageNumberFieldFormat } from './page-number-formatting.js';
export { computeTabStops, layoutWithTabs, calculateTabWidth } from './engines/tabs.js';

// Re-export TabStop for external consumers
Expand Down Expand Up @@ -74,6 +75,12 @@ export {
export { computeFragmentPmRange, computeLinePmRange, type LinePmRange } from './pm-range.js';
export { cloneColumnLayout, normalizeColumnLayout, widthsEqual } from './column-layout.js';
export type { NormalizedColumnLayout } from './column-layout.js';
export {
formatPageNumber,
formatPageNumberFieldValue,
type PageNumberFieldFormat,
type PageNumberFormat,
} from './page-number-formatting.js';
/** Inline field annotation metadata extracted from w:sdt nodes. */
export type FieldAnnotationMetadata = {
type: 'fieldAnnotation';
Expand Down Expand Up @@ -304,6 +311,8 @@ export type TextRun = RunMarks & {
link?: FlowRunLink;
/** Token annotations for dynamic content (page numbers, etc.). */
token?: 'pageNumber' | 'totalPageCount' | 'pageReference';
/** Explicit formatting requested by PAGE/NUMPAGES field switches. */
pageNumberFieldFormat?: PageNumberFieldFormat;
/** Absolute ProseMirror position (inclusive) of first character in this run. */
pmStart?: number;
/** Absolute ProseMirror position (exclusive) after the last character. */
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { describe, expect, it } from 'vitest';
import { formatPageNumber, formatPageNumberFieldValue } from './page-number-formatting.js';

describe('page number formatting', () => {
it('formats the supported Word page number formats', () => {
expect(formatPageNumber(5, 'decimal')).toBe('5');
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-');
});

it('normalizes page numbers before formatting', () => {
expect(formatPageNumber(4.9, 'decimal')).toBe('4');
expect(formatPageNumber(0, 'upperLetter')).toBe('A');
expect(formatPageNumber(Number.NaN, 'decimal')).toBe('1');
});

it('falls back to decimal for roman numerals beyond 3999', () => {
expect(formatPageNumber(4000, 'upperRoman')).toBe('4000');
});

it('applies decimal zero padding for field values', () => {
expect(formatPageNumberFieldValue(7, { format: 'decimal', zeroPadding: 3 })).toBe('007');
expect(formatPageNumberFieldValue(7, { format: 'lowerRoman', zeroPadding: 3 })).toBe('vii');
});
});
65 changes: 65 additions & 0 deletions packages/layout-engine/contracts/src/page-number-formatting.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
export type PageNumberFieldFormat = {
format?: 'decimal' | 'upperRoman' | 'lowerRoman' | 'upperLetter' | 'lowerLetter' | 'numberInDash';
zeroPadding?: number;
};

export type PageNumberFormat = NonNullable<PageNumberFieldFormat['format']>;

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

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 remaining = value;
let result = '';

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

return result;
}

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;
}

export function formatPageNumber(pageNumber: number, format: PageNumberFormat): string {
const value = Math.max(1, Math.trunc(Number.isFinite(pageNumber) ? pageNumber : 1));

switch (format) {
case 'upperRoman':
return toUpperRoman(value);
case 'lowerRoman':
return toUpperRoman(value).toLowerCase();
case 'upperLetter':
return toUpperLetter(value);
case 'lowerLetter':
return toUpperLetter(value).toLowerCase();
case 'numberInDash':
return `-${value}-`;
case 'decimal':
default:
return String(value);
}
}

export function formatPageNumberFieldValue(pageNumber: number, fieldFormat?: PageNumberFieldFormat): string {
const format = fieldFormat?.format ?? 'decimal';
const formatted = formatPageNumber(pageNumber, format);
return fieldFormat?.zeroPadding && format === 'decimal'
? formatted.padStart(fieldFormat.zeroPadding, '0')
: formatted;
}
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
54 changes: 50 additions & 4 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 @@ -120,6 +121,24 @@ function paragraphHasPageToken(para: ParagraphBlock): boolean {
return false;
}

function isDigitBucketCompatiblePageNumberFormat(format?: string): boolean {
return !format || format === 'decimal' || format === 'numberInDash';
}

function paragraphRequiresPerPageLayout(para: ParagraphBlock): boolean {
for (const run of para.runs) {
if (
'token' in run &&
run.token === 'pageNumber' &&
run.pageNumberFieldFormat &&
!isDigitBucketCompatiblePageNumberFormat(run.pageNumberFieldFormat.format)
) {
return true;
}
}
return false;
}

function hasPageTokens(blocks: FlowBlock[]): boolean {
for (const block of blocks) {
if (block.kind === 'paragraph') {
Expand All @@ -145,6 +164,27 @@ function hasPageTokens(blocks: FlowBlock[]): boolean {
return false;
}

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 === 'table') {
const table = block as TableBlock;
for (const row of table.rows ?? []) {
for (const cell of row.cells ?? []) {
const cellBlocks: FlowBlock[] = cell.blocks
? (cell.blocks as FlowBlock[])
: cell.paragraph
? [cell.paragraph]
: [];
if (hasPageNumberTokensRequiringPerPageLayout(cellBlocks)) return true;
}
}
}
}
return false;
}

export class HeaderFooterLayoutCache {
private readonly cache = new MeasureCache<Measure>();

Expand Down Expand Up @@ -200,6 +240,7 @@ const sharedHeaderFooterCache = new HeaderFooterLayoutCache();
* 2. If variant has no tokens: creates one layout reused across all pages (fast path)
* 3. For small docs (<100 pages): creates per-page layouts
* 4. For large docs (>=100 pages): uses digit bucketing (d1, d2, d3, d4)
* unless PAGE tokens use non-decimal field formatting
*
* @param sections - Header/footer variants (default, first, even, odd)
* @param constraints - Layout constraints (width, height, margins)
Expand Down Expand Up @@ -265,8 +306,10 @@ export async function layoutHeaderFooterWithCache(
// Determine which pages to create layouts for
let pagesToLayout: number[];

if (!useBucketing) {
// Small doc: create layout for every page
const useBucketingForVariant = useBucketing && !hasPageNumberTokensRequiringPerPageLayout(blocks);

if (!useBucketingForVariant) {
// Per-page layout: small docs, disabled bucketing, or non-digit-bucket-compatible PAGE formats.
pagesToLayout = Array.from({ length: docTotalPages }, (_, i) => i + 1);
HeaderFooterCacheLogger.logBucketingDecision(docTotalPages, false);
} else {
Expand All @@ -285,6 +328,7 @@ export async function layoutHeaderFooterWithCache(
// Create layouts for each page (or bucket representative)
const pages: Array<{
number: number;
displayNumber?: number;
blocks: FlowBlock[];
measures: Measure[];
fragments: HeaderFooterLayout['pages'][0]['fragments'];
Expand All @@ -295,9 +339,9 @@ export async function layoutHeaderFooterWithCache(
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 All @@ -324,6 +368,7 @@ export async function layoutHeaderFooterWithCache(
// Store page-specific data
pages.push({
number: pageNum,
displayNumber,
blocks: clonedBlocks,
measures,
fragments: fragmentsWithLines,
Expand All @@ -343,6 +388,7 @@ export async function layoutHeaderFooterWithCache(
renderHeight: firstPageLayout.renderHeight,
pages: pages.map((p) => ({
number: p.number,
displayNumber: p.displayNumber,
fragments: p.fragments,
blocks: p.blocks,
measures: p.measures,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
*/

import type { FlowBlock, ParagraphBlock, TableBlock } from '@superdoc/contracts';
import { formatPageNumberFieldValue } from '@superdoc/layout-engine';

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

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

// Process every paragraph block, including those nested in table cells
// (SD-1332). The page-number field can live in `tableCell > paragraph >
Expand All @@ -104,11 +107,15 @@ 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.pageNumberFieldFormat
? formatPageNumberFieldValue(displayNumber, run.pageNumberFieldFormat)
: pageNumberStr;
} else if (run.token === 'totalPageCount') {
// Replace placeholder text with total page count for measurement.
// IMPORTANT: Keep token for painter to re-resolve if needed.
run.text = totalPagesStr;
run.text = run.pageNumberFieldFormat
? formatPageNumberFieldValue(totalPages, run.pageNumberFieldFormat)
: totalPagesStr;
}
// Note: pageReference tokens should not appear in headers/footers typically,
// but if they do, they'll be handled by the PAGEREF resolution logic
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -389,6 +389,57 @@ describe('layoutHeaderFooterWithCache - Digit Bucketing (Large Docs)', () => {
expect(pageNumbers).toContain(500); // d3
expect(pageNumbers).not.toContain(5000); // d4 not needed
});

it('should not digit-bucket explicitly formatted page-number tokens', async () => {
const block = makePageTokenBlock('header-formatted-page');
const pageNumberRun = (block as ParagraphBlock).runs[1] as TextRun;
pageNumberRun.pageNumberFieldFormat = { format: 'lowerRoman' };

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

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

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

it('should digit-bucket zero-padded decimal page-number tokens', async () => {
const block = makePageTokenBlock('header-zero-padded-page');
const pageNumberRun = (block as ParagraphBlock).runs[1] as TextRun;
pageNumberRun.pageNumberFieldFormat = { format: 'decimal', zeroPadding: 3 };

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

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

expect(result.default?.layout.pages).toHaveLength(3);
expect(measureBlock).toHaveBeenCalledTimes(3);
expect((result.default?.layout.pages[0].blocks?.[0] as ParagraphBlock).runs[1].text).toBe('005');
});
});

describe('layoutHeaderFooterWithCache - Section-Aware Token Resolution', () => {
Expand Down
Loading
Loading