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
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
69 changes: 65 additions & 4 deletions 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 All @@ -24,6 +31,7 @@ export type HeaderFooterBatchResult = Partial<
*/
export type PageResolver = (pageNumber: number) => {
displayText: string;
displayNumber?: number;
totalPages: number;
};

Expand Down Expand Up @@ -120,10 +128,31 @@ function paragraphHasPageToken(para: ParagraphBlock): boolean {
return false;
}

function paragraphHasVariableWidthRunLocalPageFormat(para: ParagraphBlock): boolean {
for (const run of para.runs) {
if (
'token' in run &&
run.token === 'pageNumber' &&
'pageNumberFormat' in run &&
run.pageNumberFormat &&
run.pageNumberFormat !== 'decimal' &&
run.pageNumberFormat !== 'numberInDash'
) {
return true;
}
}
return false;
}

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 @@ -145,6 +174,32 @@ function hasPageTokens(blocks: FlowBlock[]): boolean {
return false;
}

function hasVariableWidthRunLocalPageFormat(blocks: FlowBlock[]): boolean {
for (const block of blocks) {
if (block.kind === 'paragraph') {
if (paragraphHasVariableWidthRunLocalPageFormat(block as ParagraphBlock)) return true;
} else if (block.kind === 'list') {
const list = block as ListBlock;
for (const item of list.items ?? []) {
if (paragraphHasVariableWidthRunLocalPageFormat(item.paragraph)) 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 (hasVariableWidthRunLocalPageFormat(cellBlocks)) return true;
}
}
}
}
return false;
}

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

Expand Down Expand Up @@ -265,7 +320,7 @@ export async function layoutHeaderFooterWithCache(
// Determine which pages to create layouts for
let pagesToLayout: number[];

if (!useBucketing) {
if (!useBucketing || hasVariableWidthRunLocalPageFormat(blocks)) {
// Small doc: create layout for every page
pagesToLayout = Array.from({ length: docTotalPages }, (_, i) => i + 1);
HeaderFooterCacheLogger.logBucketingDecision(docTotalPages, false);
Expand All @@ -288,16 +343,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 +384,8 @@ export async function layoutHeaderFooterWithCache(
blocks: clonedBlocks,
measures,
fragments: fragmentsWithLines,
numberText: displayText,
displayNumber,
});
}

Expand All @@ -344,6 +403,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
Loading
Loading