Signal drop!
Relay (operand.online) is unreachable.
Usually, a dropped signal means an upgrade is happening. Hold on!
Sorry, no connección.
Hang in there while we get back on track
gram: docs
> ./packages/core/src/docx/numberingParser.ts
/**
* Numbering/List Parser for DOCX
*
* Parses numbering.xml to extract:
* - Abstract numbering definitions (templates with levels)
* - Numbering instances (concrete references with optional overrides)
*
* OOXML Structure:
* - w:abstractNum - Template definitions with 9 levels (0-8)
* - w:num - Instances that reference abstractNum and can override levels
* - w:lvl - Level definition with start, format, text pattern, etc.
*/
import type {
NumberingDefinitions,
AbstractNumbering,
NumberingInstance,
ListLevel,
ListRendering,
NumberFormat,
LevelSuffix,
ParagraphFormatting,
TextFormatting,
} from '../types/document';
import {
parseXmlDocument,
findChild,
findChildren,
getAttribute,
parseBooleanElement,
parseNumericAttribute,
type XmlElement,
} from './xmlParser';
/**
* Map of rId to numbering definitions
*/
export type NumberingMap = {
definitions: NumberingDefinitions;
/** Get level info for a numId and ilvl */
getLevel: (numId: number, ilvl: number) => ListLevel | null;
/** Get abstract numbering by ID */
getAbstract: (abstractNumId: number) => AbstractNumbering | null;
/** Get the numbering instance (numId → abstractNumId + lvlOverrides) */
getInstance: (numId: number) => NumberingInstance | null;
/** Check if numId exists */
hasNumbering: (numId: number) => boolean;
};
/**
* Parse numbering.xml into NumberingDefinitions
*
* @param numberingXml - Raw XML string from word/numbering.xml (or null if not present)
* @returns NumberingMap with definitions and helper functions
*/
export function parseNumbering(numberingXml: string | null): NumberingMap {
const definitions: NumberingDefinitions = {
abstractNums: [],
nums: [],
};
if (!numberingXml) {
return createNumberingMap(definitions);
}
const root = parseXmlDocument(numberingXml);
if (!root) {
return createNumberingMap(definitions);
}
// Parse abstract numbering definitions
const abstractNumElements = findChildren(root, 'w', 'abstractNum');
for (const abstractNum of abstractNumElements) {
const parsed = parseAbstractNumbering(abstractNum);
if (parsed) {
definitions.abstractNums.push(parsed);
}
}
// Parse numbering instances
const numElements = findChildren(root, 'w', 'num');
for (const num of numElements) {
const parsed = parseNumberingInstance(num);
if (parsed) {
definitions.nums.push(parsed);
}
}
return createNumberingMap(definitions);
}
/**
* Parse a single w:abstractNum element
*/
function parseAbstractNumbering(element: XmlElement): AbstractNumbering | null {
const abstractNumIdStr = getAttribute(element, 'w', 'abstractNumId');
if (abstractNumIdStr === null) return null;
const abstractNumId = parseInt(abstractNumIdStr, 10);
if (isNaN(abstractNumId)) return null;
const abstractNum: AbstractNumbering = {
abstractNumId,
levels: [],
};
// Parse optional attributes/children
const multiLevelTypeEl = findChild(element, 'w', 'multiLevelType');
if (multiLevelTypeEl) {
const mlType = getAttribute(multiLevelTypeEl, 'w', 'val');
if (mlType === 'hybridMultilevel' || mlType === 'multilevel' || mlType === 'singleLevel') {
abstractNum.multiLevelType = mlType;
}
}
// Parse name
const nameEl = findChild(element, 'w', 'name');
if (nameEl) {
abstractNum.name = getAttribute(nameEl, 'w', 'val') ?? undefined;
}
// Parse style links
const numStyleLinkEl = findChild(element, 'w', 'numStyleLink');
if (numStyleLinkEl) {
abstractNum.numStyleLink = getAttribute(numStyleLinkEl, 'w', 'val') ?? undefined;
}
const styleLinkEl = findChild(element, 'w', 'styleLink');
if (styleLinkEl) {
abstractNum.styleLink = getAttribute(styleLinkEl, 'w', 'val') ?? undefined;
}
// Parse levels (w:lvl)
const levelElements = findChildren(element, 'w', 'lvl');
for (const lvlEl of levelElements) {
const level = parseListLevel(lvlEl);
if (level) {
abstractNum.levels.push(level);
}
}
// Sort levels by ilvl
abstractNum.levels.sort((a, b) => a.ilvl - b.ilvl);
return abstractNum;
}
/**
* Parse a single w:num element (numbering instance)
*/
function parseNumberingInstance(element: XmlElement): NumberingInstance | null {
const numIdStr = getAttribute(element, 'w', 'numId');
if (numIdStr === null) return null;
const numId = parseInt(numIdStr, 10);
if (isNaN(numId)) return null;
// Get abstract numbering reference
const abstractNumIdEl = findChild(element, 'w', 'abstractNumId');
if (!abstractNumIdEl) return null;
const abstractNumIdStr = getAttribute(abstractNumIdEl, 'w', 'val');
if (abstractNumIdStr === null) return null;
const abstractNumId = parseInt(abstractNumIdStr, 10);
if (isNaN(abstractNumId)) return null;
const instance: NumberingInstance = {
numId,
abstractNumId,
};
// Parse level overrides (w:lvlOverride)
const overrideElements = findChildren(element, 'w', 'lvlOverride');
if (overrideElements.length > 0) {
instance.levelOverrides = [];
for (const overrideEl of overrideElements) {
const ilvlStr = getAttribute(overrideEl, 'w', 'ilvl');
if (ilvlStr === null) continue;
const ilvl = parseInt(ilvlStr, 10);
if (isNaN(ilvl)) continue;
const override: {
ilvl: number;
startOverride?: number;
lvl?: ListLevel;
} = { ilvl };
// Check for start override
const startOverrideEl = findChild(overrideEl, 'w', 'startOverride');
if (startOverrideEl) {
const startVal = getAttribute(startOverrideEl, 'w', 'val');
if (startVal !== null) {
const startNum = parseInt(startVal, 10);
if (!isNaN(startNum)) {
override.startOverride = startNum;
}
}
}
// Check for full level redefinition
const lvlEl = findChild(overrideEl, 'w', 'lvl');
if (lvlEl) {
override.lvl = parseListLevel(lvlEl) ?? undefined;
}
instance.levelOverrides.push(override);
}
}
return instance;
}
/**
* Parse a single w:lvl element (list level definition)
*/
function parseListLevel(element: XmlElement): ListLevel | null {
const ilvlStr = getAttribute(element, 'w', 'ilvl');
if (ilvlStr === null) return null;
const ilvl = parseInt(ilvlStr, 10);
if (isNaN(ilvl) || ilvl < 0 || ilvl > 8) return null;
const level: ListLevel = {
ilvl,
numFmt: 'decimal', // Default
lvlText: '',
};
// Parse start value
const startEl = findChild(element, 'w', 'start');
if (startEl) {
const startVal = getAttribute(startEl, 'w', 'val');
if (startVal !== null) {
const startNum = parseInt(startVal, 10);
if (!isNaN(startNum)) {
level.start = startNum;
}
}
}
// Parse number format. Word wraps custom formats in mc:AlternateContent —
// <mc:Choice Requires="w14"> holds <w:numFmt w:val="custom" w:format="..."/>
// and <mc:Fallback> holds the plain format for pre-w14 readers. Prefer the
// Choice when its format resolves to something we implement; otherwise the
// Fallback is the closer rendering (ECMA-376 Part 3 §10.2.1 — a consumer
// that doesn't understand a Choice must take the Fallback).
const directNumFmtEl = findChild(element, 'w', 'numFmt');
if (directNumFmtEl) {
level.numFmt = resolveNumFmt(directNumFmtEl) ?? 'decimal';
} else {
const alternate = findChild(element, 'mc', 'AlternateContent');
if (alternate) {
const choiceFmt = resolveNumFmt(
findChild(findChild(alternate, 'mc', 'Choice'), 'w', 'numFmt')
);
const fallbackFmt = resolveNumFmt(
findChild(findChild(alternate, 'mc', 'Fallback'), 'w', 'numFmt')
);
level.numFmt = choiceFmt ?? fallbackFmt ?? level.numFmt;
}
}
// Parse level text (the pattern like "%1." or "•")
const lvlTextEl = findChild(element, 'w', 'lvlText');
if (lvlTextEl) {
level.lvlText = getAttribute(lvlTextEl, 'w', 'val') ?? '';
}
// Parse justification
const lvlJcEl = findChild(element, 'w', 'lvlJc');
if (lvlJcEl) {
const jcVal = getAttribute(lvlJcEl, 'w', 'val');
if (jcVal === 'left' || jcVal === 'center' || jcVal === 'right') {
level.lvlJc = jcVal;
}
}
// Parse suffix
const suffEl = findChild(element, 'w', 'suff');
if (suffEl) {
const suffVal = getAttribute(suffEl, 'w', 'val');
if (suffVal === 'tab' || suffVal === 'space' || suffVal === 'nothing') {
level.suffix = suffVal as LevelSuffix;
}
}
// Parse isLgl (legal numbering)
const isLglEl = findChild(element, 'w', 'isLgl');
if (isLglEl) {
level.isLgl = parseBooleanElement(isLglEl);
}
// Parse lvlRestart (restart numbering from a higher level)
const lvlRestartEl = findChild(element, 'w', 'lvlRestart');
if (lvlRestartEl) {
const restartVal = getAttribute(lvlRestartEl, 'w', 'val');
if (restartVal !== null) {
const restartNum = parseInt(restartVal, 10);
if (!isNaN(restartNum)) {
level.lvlRestart = restartNum;
}
}
}
// Parse legacy settings
const legacyEl = findChild(element, 'w', 'legacy');
if (legacyEl) {
level.legacy = {
legacy: parseBooleanElement(legacyEl),
legacySpace: parseNumericAttribute(legacyEl, 'w', 'legacySpace'),
legacyIndent: parseNumericAttribute(legacyEl, 'w', 'legacyIndent'),
};
}
// Parse paragraph properties (w:pPr)
const pPrEl = findChild(element, 'w', 'pPr');
if (pPrEl) {
level.pPr = parseLevelParagraphProps(pPrEl);
}
// Parse run properties (w:rPr)
const rPrEl = findChild(element, 'w', 'rPr');
if (rPrEl) {
level.rPr = parseLevelRunProps(rPrEl);
}
return level;
}
/** Map of known `w:numFmt w:val` strings to NumberFormat values. */
const formatMap: Record<string, NumberFormat> = {
decimal: 'decimal',
upperRoman: 'upperRoman',
lowerRoman: 'lowerRoman',
upperLetter: 'upperLetter',
lowerLetter: 'lowerLetter',
ordinal: 'ordinal',
cardinalText: 'cardinalText',
ordinalText: 'ordinalText',
hex: 'hex',
chicago: 'chicago',
bullet: 'bullet',
none: 'none',
decimalZero: 'decimalZero',
ganada: 'ganada',
chosung: 'chosung',
// CJK formats
ideographDigital: 'ideographDigital',
japaneseCounting: 'japaneseCounting',
aiueo: 'aiueo',
iroha: 'iroha',
decimalFullWidth: 'decimalFullWidth',
decimalHalfWidth: 'decimalHalfWidth',
japaneseLegal: 'japaneseLegal',
japaneseDigitalTenThousand: 'japaneseDigitalTenThousand',
decimalEnclosedCircle: 'decimalEnclosedCircle',
decimalFullWidth2: 'decimalFullWidth2',
aiueoFullWidth: 'aiueoFullWidth',
irohaFullWidth: 'irohaFullWidth',
decimalEnclosedFullstop: 'decimalEnclosedFullstop',
decimalEnclosedParen: 'decimalEnclosedParen',
decimalEnclosedCircleChinese: 'decimalEnclosedCircleChinese',
ideographEnclosedCircle: 'ideographEnclosedCircle',
ideographTraditional: 'ideographTraditional',
ideographZodiac: 'ideographZodiac',
ideographZodiacTraditional: 'ideographZodiacTraditional',
taiwaneseCounting: 'taiwaneseCounting',
ideographLegalTraditional: 'ideographLegalTraditional',
taiwaneseCountingThousand: 'taiwaneseCountingThousand',
taiwaneseDigital: 'taiwaneseDigital',
chineseCounting: 'chineseCounting',
chineseLegalSimplified: 'chineseLegalSimplified',
chineseCountingThousand: 'chineseCountingThousand',
koreanDigital: 'koreanDigital',
koreanCounting: 'koreanCounting',
koreanLegal: 'koreanLegal',
koreanDigital2: 'koreanDigital2',
vietnameseCounting: 'vietnameseCounting',
russianLower: 'russianLower',
russianUpper: 'russianUpper',
numberInDash: 'numberInDash',
hebrew1: 'hebrew1',
hebrew2: 'hebrew2',
arabicAlpha: 'arabicAlpha',
arabicAbjad: 'arabicAbjad',
hindiVowels: 'hindiVowels',
hindiConsonants: 'hindiConsonants',
hindiNumbers: 'hindiNumbers',
hindiCounting: 'hindiCounting',
thaiLetters: 'thaiLetters',
thaiNumbers: 'thaiNumbers',
thaiCounting: 'thaiCounting',
};
/**
* Resolve a `<w:numFmt>` element to a NumberFormat we can render, or null
* when the element is absent or its format is one we don't implement (an
* mc:Fallback can then supply a closer rendering).
*/
function resolveNumFmt(numFmtEl: XmlElement | null): NumberFormat | null {
if (!numFmtEl) return null;
const fmtVal = getAttribute(numFmtEl, 'w', 'val');
if (!fmtVal) return null;
if (fmtVal === 'custom') {
return parseCustomNumberFormat(getAttribute(numFmtEl, 'w', 'format'));
}
return formatMap[fmtVal] ?? null;
}
/**
* Map a `w:numFmt w:val="custom"` format string (ECMA-376 §17.9.17 — an XSLT
* `format` token list like "0001, 0002, 0003, ...") to a NumberFormat. Word
* only emits zero-padded decimal customs this way; the pad width is the digit
* count of the first token (clamped to 5). Unknown patterns return null so
* the caller can fall back to an mc:Fallback or plain decimal.
*/
function parseCustomNumberFormat(format: string | null): NumberFormat | null {
const firstToken = format?.split(',')[0]?.trim() ?? '';
if (/^0+1$/.test(firstToken)) {
switch (Math.min(firstToken.length, 5)) {
case 2:
return 'decimalZero';
case 3:
return 'decimalZero3';
case 4:
return 'decimalZero4';
case 5:
return 'decimalZero5';
}
}
return null;
}
/**
* Parse paragraph properties for a list level (subset of full pPr)
* Main concern: indentation and tabs
*/
function parseLevelParagraphProps(pPr: XmlElement): ParagraphFormatting {
const formatting: ParagraphFormatting = {};
// Parse indentation (w:ind). Per ECMA-376 §17.3.1.17, w:start/w:end are the
// bidi-aware equivalents of w:left/w:right; some writers emit only the modern
// form and the older form is silently ignored if both are absent.
const indEl = findChild(pPr, 'w', 'ind');
if (indEl) {
const left = parseNumericAttribute(indEl, 'w', 'left');
const right = parseNumericAttribute(indEl, 'w', 'right');
const start = parseNumericAttribute(indEl, 'w', 'start');
const end = parseNumericAttribute(indEl, 'w', 'end');
const firstLine = parseNumericAttribute(indEl, 'w', 'firstLine');
const hanging = parseNumericAttribute(indEl, 'w', 'hanging');
const resolvedLeft = left ?? start;
const resolvedRight = right ?? end;
if (resolvedLeft !== undefined) formatting.indentLeft = resolvedLeft;
if (resolvedRight !== undefined) formatting.indentRight = resolvedRight;
if (hanging !== undefined) {
formatting.indentFirstLine = -hanging;
formatting.hangingIndent = true;
} else if (firstLine !== undefined) {
formatting.indentFirstLine = firstLine;
}
}
// Parse tabs (w:tabs)
const tabsEl = findChild(pPr, 'w', 'tabs');
if (tabsEl) {
formatting.tabs = [];
const tabElements = findChildren(tabsEl, 'w', 'tab');
for (const tabEl of tabElements) {
const pos = parseNumericAttribute(tabEl, 'w', 'pos');
const val = getAttribute(tabEl, 'w', 'val');
const leader = getAttribute(tabEl, 'w', 'leader');
if (pos !== undefined && val) {
formatting.tabs.push({
position: pos,
alignment: parseTabAlignment(val),
leader: parseTabLeader(leader),
});
}
}
}
return formatting;
}
/**
* Parse tab alignment value
*/
function parseTabAlignment(
val: string
): 'left' | 'center' | 'right' | 'decimal' | 'bar' | 'clear' | 'num' {
switch (val) {
case 'left':
return 'left';
case 'center':
return 'center';
case 'right':
return 'right';
case 'decimal':
return 'decimal';
case 'bar':
return 'bar';
case 'clear':
return 'clear';
case 'num':
return 'num';
default:
return 'left';
}
}
/**
* Parse tab leader value
*/
function parseTabLeader(
val: string | null
): 'none' | 'dot' | 'hyphen' | 'underscore' | 'heavy' | 'middleDot' | undefined {
if (!val) return undefined;
switch (val) {
case 'none':
return 'none';
case 'dot':
return 'dot';
case 'hyphen':
return 'hyphen';
case 'underscore':
return 'underscore';
case 'heavy':
return 'heavy';
case 'middleDot':
return 'middleDot';
default:
return undefined;
}
}
/**
* Parse run properties for a list level (subset of full rPr)
* Main concern: fonts for bullet characters
*/
function parseLevelRunProps(rPr: XmlElement): TextFormatting {
const formatting: TextFormatting = {};
// Parse fonts (w:rFonts) - important for bullet characters
const rFontsEl = findChild(rPr, 'w', 'rFonts');
if (rFontsEl) {
formatting.fontFamily = {
ascii: getAttribute(rFontsEl, 'w', 'ascii') ?? undefined,
hAnsi: getAttribute(rFontsEl, 'w', 'hAnsi') ?? undefined,
eastAsia: getAttribute(rFontsEl, 'w', 'eastAsia') ?? undefined,
cs: getAttribute(rFontsEl, 'w', 'cs') ?? undefined,
};
}
// Parse font size (w:sz)
const szEl = findChild(rPr, 'w', 'sz');
if (szEl) {
const size = parseNumericAttribute(szEl, 'w', 'val');
if (size !== undefined) {
formatting.fontSize = size; // In half-points
}
}
// Parse color (w:color)
const colorEl = findChild(rPr, 'w', 'color');
if (colorEl) {
const val = getAttribute(colorEl, 'w', 'val');
const themeColor = getAttribute(colorEl, 'w', 'themeColor');
if (val === 'auto') {
formatting.color = { auto: true };
} else if (themeColor) {
formatting.color = {
themeColor: themeColor as any,
themeTint: getAttribute(colorEl, 'w', 'themeTint') ?? undefined,
themeShade: getAttribute(colorEl, 'w', 'themeShade') ?? undefined,
};
} else if (val) {
formatting.color = { rgb: val };
}
}
// Parse bold (w:b)
const bEl = findChild(rPr, 'w', 'b');
if (bEl) {
formatting.bold = parseBooleanElement(bEl);
}
// Parse italic (w:i)
const iEl = findChild(rPr, 'w', 'i');
if (iEl) {
formatting.italic = parseBooleanElement(iEl);
}
// Parse vanish / hidden (w:vanish) — hides the list indicator
const vanishEl = findChild(rPr, 'w', 'vanish');
if (vanishEl) {
formatting.hidden = parseBooleanElement(vanishEl);
}
return formatting;
}
/**
* Create a NumberingMap with helper functions
*/
/**
* Per-definitions cache for `createNumberingMap`. Style application rebuilds
* the lookup map on every picker click otherwise; the definitions object is
* stable for a document's lifetime, so cache by identity (mirrors the
* adapters' `getCachedStyleResolver` pattern without threading a getter).
*/
const numberingMapCache = new WeakMap<NumberingDefinitions, NumberingMap>();
export function getCachedNumberingMap(definitions: NumberingDefinitions): NumberingMap {
let map = numberingMapCache.get(definitions);
if (!map) {
map = createNumberingMap(definitions);
numberingMapCache.set(definitions, map);
}
return map;
}
export function createNumberingMap(definitions: NumberingDefinitions): NumberingMap {
// Build lookup maps for efficient access
const abstractMap = new Map<number, AbstractNumbering>();
for (const abs of definitions.abstractNums) {
abstractMap.set(abs.abstractNumId, abs);
}
const numMap = new Map<number, NumberingInstance>();
for (const num of definitions.nums) {
numMap.set(num.numId, num);
}
return {
definitions,
getLevel(numId: number, ilvl: number): ListLevel | null {
const num = numMap.get(numId);
if (!num) return null;
// Check for level override first
if (num.levelOverrides) {
const override = num.levelOverrides.find((o) => o.ilvl === ilvl);
if (override) {
if (override.lvl) {
// Full level redefinition
return override.lvl;
}
// Start override - need to get base level and modify
const abstractNum = abstractMap.get(num.abstractNumId);
if (abstractNum) {
const baseLevel = abstractNum.levels.find((l) => l.ilvl === ilvl);
if (baseLevel && override.startOverride !== undefined) {
return {
...baseLevel,
start: override.startOverride,
};
}
}
}
}
// Get from abstract numbering
let abstractNum = abstractMap.get(num.abstractNumId);
if (!abstractNum) return null;
// Follow numStyleLink: when an abstractNum has numStyleLink instead of
// defining levels directly, find the abstractNum that owns that style
// (has matching styleLink) and use its levels. Per ECMA-376 §17.9.21/22.
if (abstractNum.numStyleLink && abstractNum.levels.length === 0) {
for (const candidate of abstractMap.values()) {
if (candidate.styleLink === abstractNum.numStyleLink && candidate.levels.length > 0) {
abstractNum = candidate;
break;
}
}
}
return abstractNum.levels.find((l) => l.ilvl === ilvl) ?? null;
},
getAbstract(abstractNumId: number): AbstractNumbering | null {
return abstractMap.get(abstractNumId) ?? null;
},
getInstance(numId: number): NumberingInstance | null {
return numMap.get(numId) ?? null;
},
hasNumbering(numId: number): boolean {
return numMap.has(numId);
},
};
}
/**
* Resolve a paragraph's `numPr` against the numbering definitions into the
* `ListRendering` the layout pipeline needs (marker template, per-level
* numFmts, counter key, start override). Returns null when the numPr doesn't
* name a real level — including `numId === 0`, "no numbering" per ECMA-376.
*
* Shared by the parser (document load) and `applyStyle` (style picker), so a
* style-attached list renders identically in both paths.
*/
export function computeListRendering(
numPr: { numId?: number; ilvl?: number },
numbering: NumberingMap
): ListRendering | null {
const { numId, ilvl = 0 } = numPr;
if (numId === undefined || numId === 0) return null;
const level = numbering.getLevel(numId, ilvl);
if (!level) return null;
// Collect numFmts for levels 0..ilvl so multi-level templates like
// "%1.%2." can resolve each %N with its own format (e.g., upperRoman
// parent + decimal child).
const levelNumFmts: NumberFormat[] = [];
for (let i = 0; i <= ilvl; i += 1) {
const parent = numbering.getLevel(numId, i);
levelNumFmts.push(parent?.numFmt ?? 'decimal');
}
const instance = numbering.getInstance(numId);
const overrideForLevel = instance?.levelOverrides?.find((o) => o.ilvl === ilvl);
return {
level: ilvl,
numId,
marker: level.lvlText,
isBullet: level.numFmt === 'bullet',
numFmt: level.numFmt,
markerHidden: level.rPr?.hidden || undefined,
markerFontFamily: level.rPr?.fontFamily?.ascii || level.rPr?.fontFamily?.hAnsi || undefined,
// w:sz is in half-points; convert to points for downstream use
markerFontSize: level.rPr?.fontSize ? level.rPr.fontSize / 2 : undefined,
markerSuffix: level.suffix,
levelNumFmts,
abstractNumId: instance?.abstractNumId,
startOverride: overrideForLevel?.startOverride,
};
}
/**
* Format a number according to the specified format
*
* @param num - The number to format
* @param format - The number format
* @returns Formatted string
*/
export function formatNumber(num: number, format: NumberFormat): string {
switch (format) {
case 'decimal':
return num.toString();
case 'decimalZero':
return padDecimal(num, 2);
case 'decimalZero3':
return padDecimal(num, 3);
case 'decimalZero4':
return padDecimal(num, 4);
case 'decimalZero5':
return padDecimal(num, 5);
case 'upperRoman':
return toRoman(num).toUpperCase();
case 'lowerRoman':
return toRoman(num).toLowerCase();
case 'upperLetter':
return toLetter(num).toUpperCase();
case 'lowerLetter':
return toLetter(num).toLowerCase();
case 'ordinal':
return toOrdinal(num);
case 'bullet':
return '•'; // Default bullet
case 'none':
return '';
case 'decimalEnclosedParen':
return `(${num})`;
case 'numberInDash':
return `-${num}-`;
default:
// For CJK and other special formats, fall back to decimal
return num.toString();
}
}
/** Zero-pad a counter to `width` digits ("decimalZero" family, §17.18.59). */
export function padDecimal(num: number, width: number): string {
if (num < 0) return num.toString();
return num.toString().padStart(width, '0');
}
/**
* Convert number to Roman numerals
*/
function toRoman(num: number): string {
if (num <= 0 || num > 3999) return num.toString();
const romanNumerals: [number, string][] = [
[1000, 'm'],
[900, 'cm'],
[500, 'd'],
[400, 'cd'],
[100, 'c'],
[90, 'xc'],
[50, 'l'],
[40, 'xl'],
[10, 'x'],
[9, 'ix'],
[5, 'v'],
[4, 'iv'],
[1, 'i'],
];
let result = '';
let remaining = num;
for (const [value, numeral] of romanNumerals) {
while (remaining >= value) {
result += numeral;
remaining -= value;
}
}
return result;
}
/**
* Convert number to letter (a, b, c, ... z, aa, ab, ...)
*/
function toLetter(num: number): string {
if (num <= 0) return '';
let result = '';
let remaining = num;
while (remaining > 0) {
remaining--;
result = String.fromCharCode(97 + (remaining % 26)) + result;
remaining = Math.floor(remaining / 26);
}
return result;
}
/**
* Convert number to ordinal (1st, 2nd, 3rd, ...)
*/
function toOrdinal(num: number): string {
const suffix = ['th', 'st', 'nd', 'rd'];
const v = num % 100;
return num + (suffix[(v - 20) % 10] || suffix[v] || suffix[0]);
}
/**
* Render list marker text by replacing placeholders with formatted numbers
*
* @param lvlText - The level text pattern (e.g., "%1.", "%1.%2")
* @param counters - Array of counter values for each level (index 0 = level 0, etc.)
* @param formats - Array of number formats for each level
* @returns Rendered marker text
*/
export function renderListMarker(
lvlText: string,
counters: number[],
formats: NumberFormat[]
): string {
let result = lvlText;
// Replace %1 through %9 with formatted counter values
for (let i = 1; i <= 9; i++) {
const placeholder = `%${i}`;
if (result.includes(placeholder)) {
const counterIndex = i - 1;
const counter = counters[counterIndex] ?? 1;
const format = formats[counterIndex] ?? 'decimal';
const formatted = formatNumber(counter, format);
result = result.replace(placeholder, formatted);
}
}
return result;
}
/**
* Get the bullet character for a bullet list level
*
* @param level - The list level definition
* @returns The bullet character to display
*/
export function getBulletCharacter(level: ListLevel): string {
// If lvlText is set and not empty, use it
if (level.lvlText) {
return level.lvlText;
}
// Check font for common bullet font mappings
const fontFamily = level.rPr?.fontFamily?.ascii || level.rPr?.fontFamily?.hAnsi;
if (fontFamily) {
const fontLower = fontFamily.toLowerCase();
// Symbol font common bullets
if (fontLower === 'symbol') {
return '•'; // Standard bullet
}
// Wingdings common bullets
if (fontLower.includes('wingding')) {
return '❑'; // Square bullet
}
}
// Default bullet
return '•';
}
/**
* Check if a list level is a bullet (not numbered)
*/
export function isBulletLevel(level: ListLevel): boolean {
return level.numFmt === 'bullet' || level.numFmt === 'none';
}