Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
0b56f15
feat(page-reference): resolve PAGEREF fields to live target page numbers
luccas-harbour Jun 3, 2026
bc87fc6
fix(page-reference): locate list item anchors
luccas-harbour Jun 3, 2026
9fdb657
fix(page-reference): resolve table cell refs
luccas-harbour Jun 3, 2026
5f5ca36
fix(page-reference): resolve list item refs
luccas-harbour Jun 3, 2026
92d52d3
fix(page-reference): preserve refs on repaint
luccas-harbour Jun 3, 2026
a5ed752
fix(page-reference): apply charformat props
luccas-harbour Jun 3, 2026
d8403a7
fix(page-reference): export charformat props
luccas-harbour Jun 3, 2026
c4cdc6d
fix(page-reference): scope table anchors to fragments
luccas-harbour Jun 3, 2026
0883be6
fix(page-reference): scope split row anchors
luccas-harbour Jun 3, 2026
4370575
fix(page-reference): update nested line slices
luccas-harbour Jun 3, 2026
aa6fe32
fix(page-reference): ignore anchor gaps
luccas-harbour Jun 3, 2026
7b12916
fix(page-reference): avoid split-line duplication
luccas-harbour Jun 3, 2026
9fcef15
fix(page-reference): allow nearby bookmark markers
luccas-harbour Jun 3, 2026
008d8b4
test(page-reference): cover planned pageref gaps
luccas-harbour Jun 3, 2026
df2e5ec
docs(page-reference): document pageref constraints
luccas-harbour Jun 3, 2026
3bf5fa6
fix(page-reference): decouple bookmarks from page token flag
luccas-harbour Jun 4, 2026
2d1d907
feat(seq-fields): import, resolve, and export SEQ sequence fields (SD…
luccas-harbour Jun 5, 2026
f5d3147
Merge branch 'main' into luccas/sd-3007-feature-page-references
luccas-harbour Jun 5, 2026
701c70c
fix(doc-api): normalize section page numbering format in sections res…
luccas-harbour Jun 5, 2026
5342188
Merge branch 'main' into luccas/sd-3007-feature-page-references
luccas-harbour Jun 5, 2026
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
50 changes: 48 additions & 2 deletions packages/layout-engine/contracts/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -146,13 +146,16 @@ export {
} from './header-footer-inheritance.js';
export {
formatChapterPageNumberText,
formatIntegerWithNumericPicture,
formatPageNumber,
formatPageNumberFieldValue,
formatSectionPageNumberText,
type PageNumberFieldFormat,
type PageNumberChapterSeparator,
type PageNumberFormat,
} from './page-number-formatting.js';

export { buildPageRefAnchorMap } from './page-ref-anchor.js';
/** Inline field annotation metadata extracted from w:sdt nodes. */
export type FieldAnnotationMetadata = {
type: 'fieldAnnotation';
Expand Down Expand Up @@ -405,6 +408,26 @@ export type RunMarks = {
baselineShift?: number;
};

export type PageReferenceRelativePositionText = 'above' | 'below';

export type FieldResultFormat = 'charformat' | 'mergeformat';

export type NumericPictureFormat = {
/** Raw argument after the \# switch, without surrounding quotes. */
picture: string;
};

export interface PageRefLocation {
physicalPage: number;
displayNumber: number;
displayText: string;
pageFormat?: PageNumberFormat;
chapterNumberText?: string;
chapterSeparator?: PageNumberChapterSeparator;
sectionIndex?: number;
pmPosition?: number;
}

export type TextRun = RunMarks & {
kind?: 'text';
text: string;
Expand All @@ -426,8 +449,8 @@ export type TextRun = RunMarks & {
visualPlaceholder?: SdtVisualPlaceholder;
link?: FlowRunLink;
/** Token annotations for dynamic content (page numbers, etc.). */
token?: 'pageNumber' | 'totalPageCount' | 'pageReference' | 'sectionPageCount';
/** Explicit formatting requested by PAGE/NUMPAGES field switches. */
token?: 'pageNumber' | 'totalPageCount' | 'pageReference' | 'sectionPageCount' | 'seq';
/** Explicit formatting requested by PAGE/NUMPAGES/SECTIONPAGES field switches. */
pageNumberFieldFormat?: PageNumberFieldFormat;
/** Absolute ProseMirror position (inclusive) of first character in this run. */
pmStart?: number;
Expand All @@ -437,6 +460,29 @@ export type TextRun = RunMarks & {
pageRefMetadata?: {
bookmarkId: string;
instruction: string;
/** True when the instruction has \p. */
relativePosition?: boolean;
/** General numeric formatting switch for the PAGEREF page value. */
pageNumberFieldFormat?: PageNumberFieldFormat;
/** Raw numeric picture from \#. */
numericPictureFormat?: NumericPictureFormat;
/** CHARFORMAT / MERGEFORMAT, if present. */
fieldResultFormat?: FieldResultFormat;
};
/** Metadata for SEQ tokens (resolved by super-editor before layout measurement). */
seqMetadata?: {
identifier: string;
instruction?: string;
fieldArgument?: string;
sequenceMode?: 'next' | 'current';
hideResult?: boolean;
restartNumber?: number | null;
restartLevel?: number | null;
format?: string;
hasGeneralFormat?: boolean;
pageNumberFieldFormat?: PageNumberFieldFormat | null;
numericPictureFormat?: NumericPictureFormat | null;
cachedText?: string;
};
/** Tracked-change metadata from ProseMirror marks. */
trackedChange?: TrackedChangeMeta;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import { describe, expect, it } from 'vitest';
import { formatPageNumber, formatPageNumberFieldValue } from './page-number-formatting.js';
import {
formatIntegerWithNumericPicture,
formatPageNumber,
formatPageNumberFieldValue,
} from './page-number-formatting.js';

describe('page number formatting', () => {
it('formats the supported Word page number formats', () => {
Expand All @@ -10,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 @@ -30,4 +47,38 @@ describe('page number formatting', () => {
expect(formatPageNumberFieldValue(7, { format: 'decimal', zeroPadding: 3 })).toBe('007');
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');
expect(formatIntegerWithNumericPicture(5, '##%')).toBe('5%');
expect(formatIntegerWithNumericPicture(5, "00 'pages'")).toBe('05 pages');
expect(formatIntegerWithNumericPicture(1234, 'x##')).toBe('34');
expect(formatIntegerWithNumericPicture(5, '0.00')).toBe('5.00');
});

it('selects numeric picture sections for positive, negative, and zero values', () => {
expect(formatIntegerWithNumericPicture(5, '0;minus 0;zero')).toBe('5');
expect(formatIntegerWithNumericPicture(-5, '0;minus 0;zero')).toBe('minus 5');
expect(formatIntegerWithNumericPicture(0, '0;minus 0;zero')).toBe('zero');
});

it('documents unsupported numeric picture features for PAGEREF page values', () => {
// PAGEREF only formats integer page numbers here. Backtick numbered-item
// references, localized separators, and fractional rounding are out of
// scope for this numeric-picture subset.
expect(formatIntegerWithNumericPicture(5, '`1`')).toBe('`1`');
expect(formatIntegerWithNumericPicture(1234, '#.##0')).toBe('1234.0');
expect(formatIntegerWithNumericPicture(5, '0.9')).toBe('5.9');
});
});
203 changes: 202 additions & 1 deletion 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 @@ -99,3 +123,180 @@ export function formatSectionPageNumberText(args: {
chapterSeparator: args.chapterSeparator,
});
}

/**
* 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.
*/
export function formatIntegerWithNumericPicture(value: number, picture: string): string {
const integerValue = Math.trunc(Number.isFinite(value) ? value : 0);
const sections = splitPictureSections(typeof picture === 'string' && picture.length > 0 ? picture : '0');
const hasExplicitNegativeSection = integerValue < 0 && sections[1] != null;
const section =
integerValue > 0 ? sections[0] : integerValue < 0 ? (sections[1] ?? sections[0]) : (sections[2] ?? sections[0]);
return formatNumericPictureSection(
Math.abs(integerValue),
integerValue < 0,
section ?? '0',
hasExplicitNegativeSection,
);
}

function splitPictureSections(picture: string): string[] {
const sections: string[] = [];
let current = '';
let inQuote = false;

for (let index = 0; index < picture.length; index += 1) {
const char = picture[index]!;
if (char === "'") {
inQuote = !inQuote;
current += char;
continue;
}
if (char === ';' && !inQuote) {
sections.push(current);
current = '';
continue;
}
current += char;
}

sections.push(current);
return sections;
}

type PictureToken =
| { kind: 'placeholder'; value: '0' | '#' | 'x' | ',' | '.' | '+' | '-' }
| { kind: 'literal'; value: string };

function tokenizePicture(section: string): PictureToken[] {
const tokens: PictureToken[] = [];
for (let index = 0; index < section.length; index += 1) {
const char = section[index]!;
if (char === "'") {
let literal = '';
index += 1;
while (index < section.length && section[index] !== "'") {
literal += section[index]!;
index += 1;
}
tokens.push({ kind: 'literal', value: literal });
continue;
}
if (char === '0' || char === '#' || char === 'x' || char === ',' || char === '.' || char === '+' || char === '-') {
tokens.push({ kind: 'placeholder', value: char });
} else {
tokens.push({ kind: 'literal', value: char });
}
}
return tokens;
}

function formatNumericPictureSection(
value: number,
isNegative: boolean,
section: string,
suppressDefaultNegative = false,
): string {
const tokens = tokenizePicture(section);
const decimalIndex = tokens.findIndex((token) => token.kind === 'placeholder' && token.value === '.');
const integerTokens = decimalIndex >= 0 ? tokens.slice(0, decimalIndex) : tokens;
const fractionalTokens = decimalIndex >= 0 ? tokens.slice(decimalIndex + 1) : [];
const integerPart = formatIntegerPictureTokens(value, isNegative, integerTokens, suppressDefaultNegative);
const fractionalPart = formatFractionalPictureTokens(fractionalTokens);
return fractionalTokens.length > 0 ? `${integerPart}.${fractionalPart}` : integerPart;
}

function formatIntegerPictureTokens(
value: number,
isNegative: boolean,
tokens: PictureToken[],
suppressDefaultNegative: boolean,
): string {
let xIndex = -1;
for (let index = tokens.length - 1; index >= 0; index -= 1) {
const token = tokens[index]!;
if (token.kind === 'placeholder' && token.value === 'x') {
xIndex = index;
break;
}
}
const activeTokens = xIndex >= 0 ? tokens.slice(xIndex + 1) : tokens;
const placeholderCount = activeTokens.filter(
(token) => token.kind === 'placeholder' && (token.value === '0' || token.value === '#'),
).length;
const rawDigits = String(value);
const digits = xIndex >= 0 && placeholderCount > 0 ? rawDigits.slice(-placeholderCount) : rawDigits;
let digitIndex = digits.length - 1;
let output = '';
let signSlot: '+' | '-' | null = null;
const hasGrouping = activeTokens.some((token) => token.kind === 'placeholder' && token.value === ',');

for (let index = activeTokens.length - 1; index >= 0; index -= 1) {
const token = activeTokens[index]!;
if (token.kind === 'literal') {
output = token.value + output;
continue;
}

switch (token.value) {
case '0':
output = (digitIndex >= 0 ? digits[digitIndex] : '0') + output;
digitIndex -= 1;
break;
case '#':
if (digitIndex >= 0) {
output = digits[digitIndex]! + output;
digitIndex -= 1;
}
break;
case '+':
case '-':
signSlot = token.value;
break;
case ',':
case 'x':
case '.':
break;
}
}

if (placeholderCount > 0 && xIndex < 0 && digitIndex >= 0) {
output = digits.slice(0, digitIndex + 1) + output;
}
if (hasGrouping) {
output = applyGrouping(output);
}
if (signSlot === '+') {
output = `${isNegative ? '-' : '+'}${output}`;
} else if (signSlot === '-') {
output = `${isNegative ? '-' : ' '}${output}`;
} else if (isNegative && !suppressDefaultNegative) {
output = `-${output}`;
}
return output;
}

function formatFractionalPictureTokens(tokens: PictureToken[]): string {
let output = '';
for (const token of tokens) {
if (token.kind === 'literal') {
output += token.value;
continue;
}
if (token.value === '0') {
output += '0';
} else if (token.value !== '#') {
output += token.value;
}
}
return output;
}

function applyGrouping(value: string): string {
const match = value.match(/^([^0-9]*)([0-9]+)(.*)$/);
if (!match) return value;
return `${match[1]}${match[2].replace(/\B(?=(\d{3})+(?!\d))/g, ',')}${match[3]}`;
}
Loading
Loading