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
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,19 @@ describe('page number formatting', () => {
expect(formatPageNumber(28, 'upperLetter')).toBe('BB');
expect(formatPageNumber(703, 'lowerLetter')).toBe('a'.repeat(28));
expect(formatPageNumber(12, 'numberInDash')).toBe('- 12 -');
expect(formatPageNumber(1, 'ordinal')).toBe('1st');
expect(formatPageNumber(2, 'ordinal')).toBe('2nd');
expect(formatPageNumber(3, 'ordinal')).toBe('3rd');
expect(formatPageNumber(4, 'ordinal')).toBe('4th');
expect(formatPageNumber(11, 'ordinal')).toBe('11th');
expect(formatPageNumber(12, 'ordinal')).toBe('12th');
expect(formatPageNumber(13, 'ordinal')).toBe('13th');
expect(formatPageNumber(21, 'ordinal')).toBe('21st');
expect(formatPageNumber(22, 'ordinal')).toBe('22nd');
expect(formatPageNumber(23, 'ordinal')).toBe('23rd');
expect(formatPageNumber(111, 'ordinal')).toBe('111th');
expect(formatPageNumber(112, 'ordinal')).toBe('112th');
expect(formatPageNumber(113, 'ordinal')).toBe('113th');
});

it('normalizes page numbers before formatting', () => {
Expand All @@ -35,6 +48,16 @@ describe('page number formatting', () => {
expect(formatPageNumberFieldValue(7, { format: 'lowerRoman', zeroPadding: 3 })).toBe('vii');
});

it('formats ordinal field values', () => {
expect(formatPageNumberFieldValue(32, { format: 'ordinal' })).toBe('32nd');
});

it('uses numeric pictures before enum format and zero padding', () => {
expect(formatPageNumberFieldValue(1234, { numericPicture: '#,##0' })).toBe('1,234');
expect(formatPageNumberFieldValue(7, { format: 'ordinal', zeroPadding: 3, numericPicture: '00' })).toBe('07');
expect(formatPageNumberFieldValue(0, { numericPicture: '00' })).toBe('01');
});

it('formats integer values with numeric pictures', () => {
expect(formatIntegerWithNumericPicture(5, '00')).toBe('05');
expect(formatIntegerWithNumericPicture(1234, '#,##0')).toBe('1,234');
Expand Down
28 changes: 26 additions & 2 deletions packages/layout-engine/contracts/src/page-number-formatting.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
export type PageNumberFieldFormat = {
format?: 'decimal' | 'upperRoman' | 'lowerRoman' | 'upperLetter' | 'lowerLetter' | 'numberInDash';
format?: 'decimal' | 'upperRoman' | 'lowerRoman' | 'upperLetter' | 'lowerLetter' | 'numberInDash' | 'ordinal';
zeroPadding?: number;
numericPicture?: string;
};

export type PageNumberFormat = NonNullable<PageNumberFieldFormat['format']>;
Expand Down Expand Up @@ -31,6 +32,22 @@ function toUpperLetter(value: number): string {
return String.fromCharCode(65 + index).repeat(repeatCount);
}

function toOrdinal(value: number): string {
const remainder = value % 100;
if (remainder >= 11 && remainder <= 13) return `${value}th`;

switch (value % 10) {
case 1:
return `${value}st`;
case 2:
return `${value}nd`;
case 3:
return `${value}rd`;
default:
return `${value}th`;
}
}

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

Expand All @@ -45,13 +62,20 @@ export function formatPageNumber(pageNumber: number, format: PageNumberFormat):
return toUpperLetter(value).toLowerCase();
case 'numberInDash':
return `- ${value} -`;
case 'ordinal':
return toOrdinal(value);
case 'decimal':
default:
return String(value);
}
}

export function formatPageNumberFieldValue(pageNumber: number, fieldFormat?: PageNumberFieldFormat): string {
if (fieldFormat?.numericPicture) {
const value = Math.max(1, Math.trunc(Number.isFinite(pageNumber) ? pageNumber : 1));
return formatIntegerWithNumericPicture(value, fieldFormat.numericPicture);
}

const format = fieldFormat?.format ?? 'decimal';
const formatted = formatPageNumber(pageNumber, format);
return fieldFormat?.zeroPadding && format === 'decimal'
Expand Down Expand Up @@ -101,7 +125,7 @@ export function formatSectionPageNumberText(args: {
}

/**
* Formats integer field values with a Word numeric picture subset used by PAGEREF.
* Formats integer page field values with a Word numeric picture subset.
* Unsupported ECMA features are intentionally out of scope here: backtick
* numbered-item references, localized separators, and fractional rounding.
*/
Expand Down
4 changes: 2 additions & 2 deletions packages/layout-engine/layout-engine/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import type {
NormalizedColumnLayout,
DocumentBackground,
HeaderFooterResolutionSection,
PageNumberFormat,
} from '@superdoc/contracts';
import {
buildLayoutSourceIdentityForFragment,
Expand Down Expand Up @@ -1427,8 +1428,7 @@ export function layoutDocument(blocks: FlowBlock[], measures: Measure[], options
// Paginator encapsulation for page/column helpers
let pageCount = 0;
// Page numbering state
let activeNumberFormat: 'decimal' | 'lowerLetter' | 'upperLetter' | 'lowerRoman' | 'upperRoman' | 'numberInDash' =
'decimal';
let activeNumberFormat: PageNumberFormat = 'decimal';
let activePageCounter = 1;
let activeSectionPageCounterStart = activePageCounter;
let pendingNumbering: SectionNumbering | null = null;
Expand Down
66 changes: 66 additions & 0 deletions packages/layout-engine/layout-engine/src/resolvePageTokens.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -299,4 +299,70 @@ describe('resolveTokensInBlock', () => {
expect((block.runs[0] as TextRun).token).toBeUndefined();
expect((block.runs[0] as TextRun).pageNumberFieldFormat).toBeUndefined();
});

it('should apply run-local total page count zero padding', () => {
const block: ParagraphBlock = {
kind: 'paragraph',
id: 'test-total-count-zero-padding-format',
runs: [
{
text: '0',
token: 'totalPageCount',
pageNumberFieldFormat: { format: 'decimal', zeroPadding: 3 },
fontFamily: 'Arial',
fontSize: 12,
} as TextRun,
],
};

const wasModified = resolveTokensInBlock(block, 1, 7);

expect(wasModified).toBe(true);
expect((block.runs[0] as TextRun).text).toBe('007');
expect((block.runs[0] as TextRun).token).toBeUndefined();
});

it('should apply run-local total page count grouping picture', () => {
const block: ParagraphBlock = {
kind: 'paragraph',
id: 'test-total-count-picture-format',
runs: [
{
text: '0',
token: 'totalPageCount',
pageNumberFieldFormat: { numericPicture: '#,##0' },
fontFamily: 'Arial',
fontSize: 12,
} as TextRun,
],
};

const wasModified = resolveTokensInBlock(block, 1, 1234);

expect(wasModified).toBe(true);
expect((block.runs[0] as TextRun).text).toBe('1,234');
expect((block.runs[0] as TextRun).token).toBeUndefined();
});

it('should apply run-local total page count ordinal format', () => {
const block: ParagraphBlock = {
kind: 'paragraph',
id: 'test-total-count-ordinal-format',
runs: [
{
text: '0',
token: 'totalPageCount',
pageNumberFieldFormat: { format: 'ordinal' },
fontFamily: 'Arial',
fontSize: 12,
} as TextRun,
],
};

const wasModified = resolveTokensInBlock(block, 1, 22);

expect(wasModified).toBe(true);
expect((block.runs[0] as TextRun).text).toBe('22nd');
expect((block.runs[0] as TextRun).token).toBeUndefined();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -335,9 +335,12 @@ export function resolveTokensInBlock(block: ParagraphBlock, pageNumber: number,
blockModified = true;
} else if (run.token === 'totalPageCount') {
// Replace placeholder text with total page count
run.text = totalPagesStr;
run.text = run.pageNumberFieldFormat
? formatPageNumberFieldValue(totalPages, run.pageNumberFieldFormat)
: totalPagesStr;
// Clear token metadata to treat as normal text after resolution
delete run.token;
delete run.pageNumberFieldFormat;
blockModified = true;
}
// Note: pageReference tokens are handled by resolvePageRefs.ts
Expand Down
25 changes: 25 additions & 0 deletions packages/layout-engine/painters/dom/src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6946,6 +6946,31 @@ describe('DomPainter', () => {
resolvePartText({ text: '3', fieldType: 'SECTIONPAGES' }, { pageNumber: 1, totalPages: 9, section: 'body' }),
).toBe('3');
});

it('formats NUMPAGES drawing text with supported pageNumberFormat', () => {
const painter = new DomPainter();
const resolvePartText = (
painter as unknown as {
resolveShapeTextPartText: (
part: { text: string; fieldType: string; pageNumberFormat?: string },
context: { pageNumber: number; totalPages: number; section: 'body' },
) => string;
}
).resolveShapeTextPartText.bind(painter);

expect(
resolvePartText(
{ text: '9', fieldType: 'NUMPAGES', pageNumberFormat: 'upperRoman' },
{ pageNumber: 1, totalPages: 9, section: 'body' },
),
).toBe('IX');
expect(
resolvePartText(
{ text: '9', fieldType: 'NUMPAGES', pageNumberFormat: 'ordinal' },
{ pageNumber: 1, totalPages: 12, section: 'body' },
),
).toBe('12th');
});
describe('resolved paragraph rendering', () => {
it('renders resolved paragraph lines with precomputed indent styles', () => {
const paragraphBlock: FlowBlock = {
Expand Down
3 changes: 2 additions & 1 deletion packages/layout-engine/painters/dom/src/renderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3158,7 +3158,8 @@ export class DomPainter {
return context?.pageNumberText ?? String(context?.pageNumber ?? 1);
}
if (part.fieldType === 'NUMPAGES') {
return String(context?.totalPages ?? 1);
const totalPages = context?.totalPages ?? 1;
return part.pageNumberFormat ? formatPageNumber(totalPages, part.pageNumberFormat) : String(totalPages);
}
if (part.fieldType === 'SECTIONPAGES') {
if (context?.sectionPageCount == null) return part.text ?? '1';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -311,6 +311,28 @@ describe('HeaderFooterEditorManager', () => {
expect(sectionPages.textContent).toBe('IV');
});

it('refreshes total page count DOM text with node numeric picture format', () => {
const editor = createMockEditor();
const manager = new HeaderFooterEditorManager(editor);
const descriptor = { id: 'rId-header-default', kind: 'header' } as const;
const host = document.createElement('div');

const sectionEditor = manager.ensureEditorSync(descriptor, { editorHost: host });
expect(sectionEditor).toBeDefined();
const totalPages = document.createElement('span');
totalPages.dataset.id = 'auto-total-pages';
totalPages.textContent = '1';
sectionEditor!.view.dom.appendChild(totalPages);
(sectionEditor!.view as unknown as { posAtDOM: ReturnType<typeof vi.fn> }).posAtDOM = vi.fn(() => 0);
(sectionEditor as unknown as { state: { doc: { nodeAt: ReturnType<typeof vi.fn> } } }).state = {
doc: { nodeAt: vi.fn(() => ({ attrs: { pageNumberNumericPicture: '000' } })) },
};

manager.ensureEditorSync(descriptor, { editorHost: host, totalPageCount: 12 });

expect(totalPages.textContent).toBe('012');
});

it('refreshes chapter-prefixed page number DOM text with node pageNumberFormat', () => {
const editor = createMockEditor();
const manager = new HeaderFooterEditorManager(editor);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { toFlowBlocks } from '@core/layout-adapter';
import { getAtomNodeTypes as getAtomNodeTypesFromSchema } from '../presentation-editor/utils/SchemaNodeTypes.js';
import {
formatPageNumber,
formatPageNumberFieldValue,
formatSectionPageNumberText,
type FlowBlock,
type PageNumberChapterSeparator,
Expand All @@ -14,6 +15,7 @@ import { EventEmitter } from '@core/EventEmitter.js';
import { createHeaderFooterEditor, onHeaderFooterDataUpdate } from '@extensions/pagination/pagination-helpers.js';
import type { ConverterContext } from '@core/layout-adapter/converter-context.js';
import { buildStoryKey } from '../../document-api-adapters/story-runtime/story-key.js';
import { getPageNumberFieldFormat } from '../layout-adapter/converters/inline-converters/page-number-field-format.js';

const HEADER_FOOTER_VARIANTS = ['default', 'first', 'even', 'odd'] as const;
const DEFAULT_HEADER_FOOTER_HEIGHT = 100;
Expand Down Expand Up @@ -504,7 +506,7 @@ export class HeaderFooterEditorManager extends EventEmitter {
typeof opts.currentPageChapterSeparator === 'string'
? (opts.currentPageChapterSeparator as PageNumberChapterSeparator)
: undefined;
const totalPages = String(opts.totalPageCount || parentEditor?.currentTotalPages || '1');
const totalPages = Number(opts.totalPageCount || parentEditor?.currentTotalPages || 1) || 1;
const sectionPages = opts.sectionPageCount;

const pageNumberEls = container.querySelectorAll('[data-id="auto-page-number"]');
Expand All @@ -524,7 +526,9 @@ export class HeaderFooterEditorManager extends EventEmitter {
if (el.textContent !== text) el.textContent = text;
});
totalPagesEls.forEach((el) => {
if (el.textContent !== totalPages) el.textContent = totalPages;
const pageNumberFieldFormat = this.#getPageNumberFieldFormatForDomNode(editor, el);
const text = formatPageNumberFieldValue(totalPages, pageNumberFieldFormat);
if (el.textContent !== text) el.textContent = text;
});
sectionPagesEls.forEach((el) => {
if (sectionPages == null) return;
Expand All @@ -548,6 +552,18 @@ export class HeaderFooterEditorManager extends EventEmitter {
}
}

#getPageNumberFieldFormatForDomNode(editor: Editor, el: Element): ReturnType<typeof getPageNumberFieldFormat> {
try {
const view = editor.view;
if (!view) return undefined;
const pos = view.posAtDOM(el, 0);
const node = editor.state.doc.nodeAt(pos);
return getPageNumberFieldFormat(node?.attrs);
} catch {
return undefined;
}
}

/**
* Retrieves the editor instance for a given header/footer descriptor,
* if one has been created.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,15 @@ describe('getPageNumberFieldFormat', () => {
).toEqual({ format: 'decimal', zeroPadding: 2 });
});

it('threads ordinal and numeric-picture attributes for layout runs', () => {
expect(
getPageNumberFieldFormat({
pageNumberFormat: 'ordinal',
pageNumberNumericPicture: '#,##0',
}),
).toEqual({ format: 'ordinal', numericPicture: '#,##0' });
});

it('ignores invalid format attributes', () => {
expect(getPageNumberFieldFormat(undefined)).toBeUndefined();
expect(getPageNumberFieldFormat({ pageNumberFormat: 1, pageNumberZeroPadding: Number.NaN })).toBeUndefined();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,14 @@ export function getPageNumberFieldFormat(
typeof attrs.pageNumberZeroPadding === 'number' && Number.isFinite(attrs.pageNumberZeroPadding)
? attrs.pageNumberZeroPadding
: undefined;
if (!format && !zeroPadding) return undefined;
const numericPicture =
typeof attrs.pageNumberNumericPicture === 'string' && attrs.pageNumberNumericPicture.length > 0
? attrs.pageNumberNumericPicture
: undefined;
if (!format && !zeroPadding && !numericPicture) return undefined;
return {
...(format ? { format: format as NonNullable<TextRun['pageNumberFieldFormat']>['format'] } : {}),
...(zeroPadding ? { zeroPadding } : {}),
...(zeroPadding != null ? { zeroPadding } : {}),
...(numericPicture ? { numericPicture } : {}),
};
}
Loading
Loading