From 7aca6a815775c9632acc5f14839fe7fc5dcc3410 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Fri, 4 Sep 2026 13:56:59 +0100 Subject: [PATCH 1/4] feat(doc-codec): read a paragraph's own numbering definitions from PlfLst/PlfLfo A paragraph's listId/listLevel (sprmPIlfo/sprmPIlvl) says which list it belongs to and at what depth, but nothing about what that list actually looks like -- the glyph/format, level-text template, and start-at value live in PlfLst (the list definitions: LSTF plus each one's appended array of LVLs) and PlfLfo (which list a paragraph's own ilfo actually refers to), neither of which this reader touched. list/numbering.ts's readNumberingDefinitions resolves both into NumberingDefinitions, keyed by the same listId string ContentListMembership.numId already carries. The shape and field values deliberately mirror ooxml.js's own docx numbering reader: NumberingLevel.format is the identical ECMA-376 ST_NumberFormat string MSONFC's own values are documented as mapping to ([MS-OSHARED] 2.2.1.3), and NumberingLevel.text is the identical '%1.'-style placeholder convention, decoded from Xst's own raw-level-index character encoding via rgbxchNums. NumberingDefinitions sits outside document-schema.js for the same reason ooxml.js's own numbering definitions do: ContentListMembership is shared verbatim across every codec, and a document-level resource keyed by id has no business being copied onto every paragraph that shares it. read.ts's readDocContent now returns DocContent, a ContentDocument widened by one further field (numbering) -- an intersection type, so every existing caller expecting a plain ContentDocument is unaffected. Read-only, matching ooxml.js's own docx writer exactly: writeDocContent does not attempt to write PlfLst/PlfLfo back out, and LFOLVL overrides, grpprlPapx/grpprlChpx, and legal numbering (fLegal) are deliberately not resolved -- each a genuine further layer of the format, not an oversight. --- packages/doc-codec/src/fib/fib.ts | 9 + packages/doc-codec/src/fib/offsets.ts | 5 + packages/doc-codec/src/index.ts | 1 + packages/doc-codec/src/list/numbering.ts | 319 +++++++++++++++++++++ packages/doc-codec/src/read.ts | 17 +- packages/doc-codec/src/test-support/fib.ts | 8 + 6 files changed, 355 insertions(+), 4 deletions(-) create mode 100644 packages/doc-codec/src/list/numbering.ts diff --git a/packages/doc-codec/src/fib/fib.ts b/packages/doc-codec/src/fib/fib.ts index 1ef012098..ca18f1809 100644 --- a/packages/doc-codec/src/fib/fib.ts +++ b/packages/doc-codec/src/fib/fib.ts @@ -43,6 +43,11 @@ export interface Fib { readonly fcSttbfFfn: number; readonly lcbSttbfFfn: number; + + readonly fcPlfLst: number; + readonly lcbPlfLst: number; + readonly fcPlfLfo: number; + readonly lcbPlfLfo: number; } export function parseFib(wordDocument: Uint8Array): Fib { @@ -116,6 +121,10 @@ export function parseFib(wordDocument: Uint8Array): Fib { lcbClx: fcLcb(FC_LCB_VALUE_INDEX.lcbClx), fcSttbfFfn: fcLcb(FC_LCB_VALUE_INDEX.fcSttbfFfn), lcbSttbfFfn: fcLcb(FC_LCB_VALUE_INDEX.lcbSttbfFfn), + fcPlfLst: fcLcb(FC_LCB_VALUE_INDEX.fcPlfLst), + lcbPlfLst: fcLcb(FC_LCB_VALUE_INDEX.lcbPlfLst), + fcPlfLfo: fcLcb(FC_LCB_VALUE_INDEX.fcPlfLfo), + lcbPlfLfo: fcLcb(FC_LCB_VALUE_INDEX.lcbPlfLfo), }; } diff --git a/packages/doc-codec/src/fib/offsets.ts b/packages/doc-codec/src/fib/offsets.ts index 8c27d1ac0..ebd265d08 100644 --- a/packages/doc-codec/src/fib/offsets.ts +++ b/packages/doc-codec/src/fib/offsets.ts @@ -51,6 +51,11 @@ export const FC_LCB_VALUE_INDEX = { lcbSttbfFfn: 31, fcClx: 66, lcbClx: 67, + // Counted forward the same way from fcStshfOrig at value index 0 -- confirmed against every value index above by recounting the spec's own field-by-field FibRgFcLcb97 page in full, not derived by arithmetic from a nearby pair. fcPlfLst is the 74th fc/lcb pair (value index 146), fcPlfLfo the 75th (value index 148). + fcPlfLst: 146, + lcbPlfLst: 147, + fcPlfLfo: 148, + lcbPlfLfo: 149, } as const; // FibBase's bit field at offset 10, [MS-DOC] 2.5.2. The spec's bit diagram lists A..M least-significant-bit first within the little-endian 16-bit value, so fDot is 0x0001 and fObfuscated 0x8000; only the four this reader acts on are named. diff --git a/packages/doc-codec/src/index.ts b/packages/doc-codec/src/index.ts index 7fa470717..e4d115581 100644 --- a/packages/doc-codec/src/index.ts +++ b/packages/doc-codec/src/index.ts @@ -20,5 +20,6 @@ export * from "./prop/pap"; export * from "./prop/pap-write"; export * from "./style/stsh"; export * from "./style/fonts"; +export * from "./list/numbering"; export * from "./read"; export * from "./write"; diff --git a/packages/doc-codec/src/list/numbering.ts b/packages/doc-codec/src/list/numbering.ts new file mode 100644 index 000000000..4b048d7e0 --- /dev/null +++ b/packages/doc-codec/src/list/numbering.ts @@ -0,0 +1,319 @@ +import { + readInt16LE, + readInt32LE, + readUint16LE, + readUint8, + slice, +} from "../bytes"; +import { DocFormatError } from "../errors"; +import type { Fib } from "../fib/fib"; + +// Resolves what a paragraph's own sprmPIlfo (prop/pap.ts's listId, an index into PlfLfo.rgLfo) actually means: the glyph/format, level-text template, and start-at value a consumer needs to render the paragraph's list marker. A paragraph's listId/listLevel membership alone (already read, unchanged by this module) says only WHICH list and WHAT DEPTH; PlfLst and PlfLfo are what say what that list looks like. +// +// NumberingDefinitions is deliberately a separate, top-level structure returned alongside ContentDocument rather than folded into ContentListMembership itself -- the identical reasoning and shape ooxml.js's own typed/docx/numbering.ts states for word/numbering.xml's abstractNum/num tables: (1) ContentListMembership is document-schema.js's own schema, shared verbatim across every codec -- widening it with a doc-codec-specific numbering-definition payload would leak this package's own model into a schema the sibling packages also depend on; (2) a definition is a genuinely document-level resource referenced by listId, not a per-paragraph one, so a keyed-map-once, referenced-by-id-many-times shape avoids every paragraph sharing a listId carrying an identical copy of its full level table. NumberingLevel.format/text deliberately reuse ooxml.js's own vocabulary -- MSONFC's own values ([MS-OSHARED] 2.2.1.3) are individually documented as "mapped to the ST_NumberFormat... equivalents", so format is the identical ECMA-376 string ("decimal", "upperRoman", "bullet", ...) ooxml.js's NumberingLevel.format already carries, and text is the identical '%1.'-style placeholder convention -- so a consumer that already knows how to render one already knows how to render the other. +// +// READ-ONLY, matching ooxml.js's own docx writer: word/numbering.xml is read into DocxDocument.numberingDefinitions but never written back (typed/docx/write.ts's own stated scope), and writeDocContent does not attempt to write PlfLst/PlfLfo either -- encoding a level's own grpprlPapx/grpprlChpx Prl streams back out is a materially separate task or, per the sibling package's own precedent, not attempted at all. +// +// WHAT THIS DOES NOT RESOLVE, each a genuine layer of the format rather than an oversight: LFOLVL overrides (PlfLfo's own rgLfoData, [MS-DOC] "LFOData"/"LFOLVL") -- an LFO can restate one or more of its LSTF's own levels with different formatting, and this reader always resolves straight through to the LSTF's own LVL, ignoring any override the LFO itself carries; grpprlPapx/grpprlChpx (a level's own paragraph/character formatting Prl streams) -- parsed past by length, never decoded, since ContentListMembership has nowhere to carry per-level indent/font direct formatting; and legal numbering (LVLF.fLegal), which overrides an inherited placeholder's own format rather than the level's own -- text still carries the placeholder verbatim, uninterpreted by fLegal. + +const LSTF_SIZE = 28; +const LVLF_SIZE = 28; +const LFO_SIZE = 16; + +/** LSTF's own flags byte ([MS-DOC] 2.9.191), bit 0: "this LSTF represents a simple (one-level) list that has one corresponding LVL. Otherwise... a multi-level list that has nine corresponding LVLs." */ +const LSTF_FLAG_SIMPLE_LIST = 0x01; + +/** MSONFC ([MS-OSHARED] 2.2.1.3), mapped to its own documented ST_NumberFormat equivalent -- the identical vocabulary ooxml.js's NumberingLevel.format carries verbatim from word/numbering.xml's own w:numFmt/@w:val. Every member through msonfcUCRus (0x3B) is a real numbered/lettered/ideograph format; 0x17 (msonfcBullet) is handled separately below since PlfLfo also treats it as the "no number sequence, but has bullets" case LVLF's own field text calls out by name. */ +const NUMBER_FORMAT_BY_NFC: Readonly> = { + 0x00: "decimal", + 0x01: "upperRoman", + 0x02: "lowerRoman", + 0x03: "upperLetter", + 0x04: "lowerLetter", + 0x05: "ordinal", + 0x06: "cardinalText", + 0x07: "ordinalText", + 0x08: "hex", + 0x09: "chicago", + 0x0a: "ideographDigital", + 0x0b: "japaneseCounting", + 0x0c: "Aiueo", + 0x0d: "Iroha", + 0x0e: "decimalFullWidth", + 0x0f: "decimalHalfWidth", + 0x10: "japaneseLegal", + 0x11: "japaneseDigitalTenThousand", + 0x12: "decimalEnclosedCircle", + 0x13: "decimalFullWidth2", + 0x14: "aiueoFullWidth", + 0x15: "irohaFullWidth", + 0x16: "decimalZero", + 0x17: "bullet", + 0x18: "ganada", + 0x19: "chosung", + 0x1a: "decimalEnclosedFullstop", + 0x1b: "decimalEnclosedParen", + 0x1c: "decimalEnclosedCircleChinese", + 0x1d: "ideographEnclosedCircle", + 0x1e: "ideographTraditional", + 0x1f: "ideographZodiac", + 0x20: "ideographZodiacTraditional", + 0x21: "taiwaneseCounting", + 0x22: "ideographLegalTraditional", + 0x23: "taiwaneseCountingThousand", + 0x24: "taiwaneseDigital", + 0x25: "chineseCounting", + 0x26: "chineseLegalSimplified", + 0x27: "chineseCountingThousand", + 0x28: "decimal", + 0x29: "koreanDigital", + 0x2a: "koreanCounting", + 0x2b: "koreanLegal", + 0x2c: "koreanDigital2", + 0x2d: "hebrew1", + 0x2e: "arabicAlpha", + 0x2f: "hebrew2", + 0x30: "arabicAbjad", + 0x31: "hindiVowels", + 0x32: "hindiConsonants", + 0x33: "hindiNumbers", + 0x34: "hindiCounting", + 0x35: "thaiLetters", + 0x36: "thaiNumbers", + 0x37: "thaiCounting", + 0x38: "vietnameseCounting", + 0x39: "numberInDash", + 0x3a: "russianLower", + 0x3b: "russianUpper", +}; +/** MSONFC's own "Specifies that the sequence will not display any numbering" sentinel -- not itself an ST_NumberFormat value, so this reader's own spelling for it ("none") is a deliberate literal rather than a value MSONFC's table states. */ +const NFC_NONE = 0xff; + +function numberFormatFor(nfc: number): string { + if (nfc === NFC_NONE) { + return "none"; + } + const format = NUMBER_FORMAT_BY_NFC[nfc]; + if (format === undefined) { + throw new DocFormatError( + `LVLF.nfc is 0x${nfc.toString(16).padStart(2, "0")}, not a recognised MSONFC value ([MS-OSHARED] 2.2.1.3)`, + ); + } + return format; +} + +export interface NumberingLevel { + /** The ST_NumberFormat-equivalent string MSONFC's own value maps to ("decimal", "upperRoman", "bullet", ...), or "none" for a level with no number sequence at all ([MS-DOC] 2.9.148's own nfc field text: "If this is equal to 0xFF..., this level does not have a number sequence"). */ + readonly format: string; + /** The level's own text template: a placeholder pattern like '%1.' or '%2)' for a numbered format (the digit names which zero-based level's own counter substitutes at that position, one-based in the placeholder itself) -- the identical convention ooxml.js's own NumberingLevel.text carries verbatim from w:lvlText/@w:val -- or a literal bullet glyph string for format 'bullet'. Decoded from the level's own Xst (a raw UTF-16 string) plus its rgbxchNums array, which names which character POSITIONS in that string are placeholders rather than literal text -- see readLevelText below. */ + readonly text: string; + /** iStartAt: the value this level's counter begins from. Meaningless (and not read as anything but 1) for a level with no number sequence. */ + readonly startAt: number; + /** ilvlRestartLim ([MS-DOC] 2.9.148), only when fNoRestart is set: the first (most-significant) zero-based level after which this level's own number sequence does NOT restart. Absent (undefined) is the spec's own default behaviour -- "restarts when a more significant level is encountered" -- not "never restarts". */ + readonly restart?: number; +} + +export interface NumberingDefinition { + /** Keyed by the level's own zero-based ilvl, stringified -- the identical zero-based numbering ContentListMembership.level already uses, so `definitions[membership.numId]?.levels[String(membership.level)]` is the direct lookup path from a paragraph's own membership to its rendering definition. A record rather than a fixed-length array/tuple: a simple (fSimpleList) LSTF states only level 0. */ + readonly levels: Readonly>; +} + +/** Keyed by a paragraph's own listId (ContentListMembership.numId, stringified) -- prop/pap.ts's own sprmPIlfo, a one-based index into PlfLfo.rgLfo. */ +export type NumberingDefinitions = Readonly< + Record +>; + +interface Lstf { + readonly lsid: number; + readonly fSimpleList: boolean; +} + +/** LSTF ([MS-DOC] 2.9.191): lsid(4) + tplc(4, ignored -- UI-only) + rgistdPara(18, ignored -- this reader has no per-level style cascade to link into) + a flags byte (only fSimpleList, bit 0, acted on) + grfhic(1, ignored -- HTML-export-only incompatibility flags). Fixed 28 bytes. */ +function readLstf(bytes: Uint8Array, offset: number): Lstf { + const lsid = readInt32LE(bytes, offset); + const flags = readUint8(bytes, offset + 26); + return { lsid, fSimpleList: (flags & LSTF_FLAG_SIMPLE_LIST) !== 0 }; +} + +/** Xst ([MS-DOC] 2.9.343): cch(2 bytes) then that many raw 16-bit code units, prefixed-length and not null-terminated. Decoded as a plain UTF-16 string -- readLevelText below re-inspects specific character positions afterward for placeholders, which round-trips exactly through String.fromCharCode/charCodeAt since every placeholder value (0-8) sits well within one UTF-16 code unit and never needs a surrogate pair. Returns the decoded text and the byte length consumed, since the caller must advance past it to reach grpprlPapx/grpprlChpx or the next LVL. */ +function readXst( + bytes: Uint8Array, + offset: number, +): { readonly text: string; readonly byteLength: number } { + const cch = readUint16LE(bytes, offset); + let text = ""; + for (let index = 0; index < cch; index += 1) { + text += String.fromCharCode(readUint16LE(bytes, offset + 2 + index * 2)); + } + return { text, byteLength: 2 + cch * 2 }; +} + +/** rgbxchNums ([MS-DOC] 2.9.148's own LVLF field): nine 8-bit one-based character offsets into the LVL's own xst.rgtchar, zero-terminated (a 0 entry, or the end of the fixed 9-byte array, ends the list). Each offset it names is a POSITION in the string, not a value -- readLevelText is what turns a position into the placeholder it names. */ +function readRgbxchNums(bytes: Uint8Array, offset: number): number[] { + const positions: number[] = []; + for (let index = 0; index < 9; index += 1) { + const value = readUint8(bytes, offset + index); + if (value === 0) { + break; + } + positions.push(value); + } + return positions; +} + +/** Turns an Xst's own decoded text plus its rgbxchNums positions into the '%1.'-style placeholder text NumberingLevel.text states -- the mirror of readXst/readRgbxchNums together. [MS-DOC]'s own Xst field text: "Each placeholder is an unsigned 2-byte integer that specifies the zero-based level that the placeholder is for" -- so the character AT a named position is not a literal code point at all, but a raw level index (0-8) String.fromCharCode/charCodeAt round-trips losslessly; every other position is decoded as ordinary text. A one-based placeholder ('%1' for level 0) matches ooxml.js's own w:lvlText convention, so a consumer already resolving '%1.'/'%2)' style docx templates resolves this reader's templates identically. */ +function readLevelText( + xstText: string, + placeholderPositions: readonly number[], +): string { + const placeholders = new Set(placeholderPositions); + let result = ""; + for (let index = 0; index < xstText.length; index += 1) { + const oneBasedPosition = index + 1; + if (placeholders.has(oneBasedPosition)) { + const levelIndex = xstText.charCodeAt(index); + result += `%${levelIndex + 1}`; + } else { + result += xstText.charAt(index); + } + } + return result; +} + +interface ParsedLvl { + readonly level: NumberingLevel; + readonly byteLength: number; +} + +/** LVL ([MS-DOC] 2.9.196): a 28-byte LVLF, then grpprlPapx (cbGrpprlPapx bytes, skipped -- see this module's own top comment), grpprlChpx (cbGrpprlChpx bytes, skipped), then the level's own Xst. Every LVL is variable-length, so the caller must use byteLength to advance to the next one in the array -- there is no outer length field to skip by instead. */ +function readLvl(bytes: Uint8Array, offset: number): ParsedLvl { + if (offset + LVLF_SIZE > bytes.length) { + throw new DocFormatError( + `PlfLst's own appended LVL array runs past the end of its ${bytes.length}-byte buffer at offset ${offset}, ${LVLF_SIZE} bytes short of one LVLF`, + ); + } + const iStartAt = readInt32LE(bytes, offset); + const nfc = readUint8(bytes, offset + 4); + const flags = readUint8(bytes, offset + 5); + const fNoRestart = (flags & 0x02) !== 0; + const rgbxchNums = readRgbxchNums(bytes, offset + 6); + const cbGrpprlChpx = readUint8(bytes, offset + 24); + const cbGrpprlPapx = readUint8(bytes, offset + 25); + const ilvlRestartLim = readUint8(bytes, offset + 26); + + const xstOffset = offset + LVLF_SIZE + cbGrpprlPapx + cbGrpprlChpx; + const { text: xstText, byteLength: xstByteLength } = readXst( + bytes, + xstOffset, + ); + + const level: NumberingLevel = { + format: numberFormatFor(nfc), + text: readLevelText(xstText, rgbxchNums), + startAt: iStartAt, + }; + return { + level: fNoRestart ? { ...level, restart: ilvlRestartLim } : level, + byteLength: LVLF_SIZE + cbGrpprlPapx + cbGrpprlChpx + xstByteLength, + }; +} + +interface ParsedPlfLst { + readonly lstfs: readonly Lstf[]; + /** One entry per LSTF, in the same order -- 9 levels (or 1, for a simple list), matching FibRgFcLcb97's own fcPlfLst field text: "This array of LVLs is in the same respective order as the LSTFs in PlfLst." */ + readonly levelsByLstf: readonly (readonly NumberingLevel[])[]; +} + +/** PlfLst ([MS-DOC] 2.9.226): cLst(2 bytes, signed) then that many 28-byte LSTF entries -- followed IMMEDIATELY by the appended LVL array FibRgFcLcb97's own fcPlfLst field describes, which lcbPlfLst does not account for and which this function therefore reads past the declared PlfLst length to reach. */ +function parsePlfLst(table: Uint8Array, fc: number, lcb: number): ParsedPlfLst { + const plfLst = slice(table, fc, lcb, "PlfLst"); + const cLst = readInt16LE(plfLst, 0); + if (cLst < 0) { + throw new DocFormatError( + `PlfLst.cLst is ${cLst}, a negative LSTF count [MS-DOC] 2.9.226 never permits`, + ); + } + const lstfs: Lstf[] = []; + for (let index = 0; index < cLst; index += 1) { + lstfs.push(readLstf(plfLst, 2 + index * LSTF_SIZE)); + } + + let cursor = fc + lcb; + const levelsByLstf: NumberingLevel[][] = []; + for (const lstf of lstfs) { + const count = lstf.fSimpleList ? 1 : 9; + const levels: NumberingLevel[] = []; + for (let index = 0; index < count; index += 1) { + const { level, byteLength } = readLvl(table, cursor); + levels.push(level); + cursor += byteLength; + } + levelsByLstf.push(levels); + } + return { lstfs, levelsByLstf }; +} + +/** PlfLfo ([MS-DOC] 2.9.225): lfoMac(4 bytes) then that many 16-byte LFO entries (rgLfo), then rgLfoData -- this reader's own scope stops at rgLfo, since resolving ilfo to a list needs only each LFO's own lsid (rgLfoData carries LFOLVL overrides this reader deliberately does not apply; see this module's own top comment). rgLfo sits entirely before rgLfoData in the stream, so not reading rgLfoData at all is a real, not merely partial, saving -- no cursor needs to walk past it. */ +function parseLfoLsids( + table: Uint8Array, + fc: number, + lcb: number, +): readonly number[] { + const plfLfo = slice(table, fc, lcb, "PlfLfo"); + const lfoMac = readInt32LE(plfLfo, 0); + if (lfoMac < 0) { + throw new DocFormatError( + `PlfLfo.lfoMac is ${lfoMac}, a negative LFO count [MS-DOC] 2.9.225 never permits`, + ); + } + const lsids: number[] = []; + for (let index = 0; index < lfoMac; index += 1) { + const offset = 4 + index * LFO_SIZE; + if (offset + LFO_SIZE > plfLfo.length) { + throw new DocFormatError( + `PlfLfo declares lfoMac=${lfoMac} LFO entries, but its own ${plfLfo.length}-byte buffer has room for only ${Math.floor((plfLfo.length - 4) / LFO_SIZE)}`, + ); + } + lsids.push(readInt32LE(plfLfo, offset)); + } + return lsids; +} + +/** Resolves PlfLst and PlfLfo into NumberingDefinitions, keyed by the one-based ilfo every listId already is (prop/pap.ts's own Math.abs(ilfo)) -- absent entirely when the file carries neither (fcPlfLst/fcPlfLfo both 0, a document with no lists at all, the common case this reader must not fail on). */ +export function readNumberingDefinitions( + table: Uint8Array, + fib: Fib, +): NumberingDefinitions { + if (fib.lcbPlfLst === 0 || fib.lcbPlfLfo === 0) { + return {}; + } + const { lstfs, levelsByLstf } = parsePlfLst( + table, + fib.fcPlfLst, + fib.lcbPlfLst, + ); + const lsids = parseLfoLsids(table, fib.fcPlfLfo, fib.lcbPlfLfo); + + const levelsByLsid = new Map(); + lstfs.forEach((lstf, index) => { + const levels = levelsByLstf[index]; + if (levels !== undefined) { + levelsByLsid.set(lstf.lsid, levels); + } + }); + + const definitions: Record = {}; + lsids.forEach((lsid, index) => { + const levels = levelsByLsid.get(lsid); + if (levels === undefined) { + return; + } + const ilfo = index + 1; // rgLfo is addressed one-based, [MS-DOC] 2.9.148's own sprmPIlfo field text. + const byLevel: Record = {}; + levels.forEach((level, levelIndex) => { + byLevel[String(levelIndex)] = level; + }); + definitions[String(ilfo)] = { levels: byLevel }; + }); + return definitions; +} diff --git a/packages/doc-codec/src/read.ts b/packages/doc-codec/src/read.ts index 0502e527d..5ce9634ec 100644 --- a/packages/doc-codec/src/read.ts +++ b/packages/doc-codec/src/read.ts @@ -14,6 +14,10 @@ import { slice } from "./bytes"; import { SUMMARY_INFORMATION_STREAM, WORD_DOCUMENT_STREAM } from "./detect"; import { DocFormatError } from "./errors"; import { parseFib, tableStreamName, type Fib } from "./fib/fib"; +import { + readNumberingDefinitions, + type NumberingDefinitions, +} from "./list/numbering"; import { applyCharacterSprms, type CharacterProperties } from "./prop/chp"; import { PropertyBinTable } from "./prop/fkp"; import { applyParagraphSprms, type ParagraphProperties } from "./prop/pap"; @@ -35,7 +39,7 @@ import { // The top-level read: a .doc's bytes to a ContentDocument. Every step below is one of [MS-DOC]'s own algorithms, in the order the specification chains them -- the compound-file container gives the WordDocument and Table streams, the FIB gives the offsets, the piece table turns character positions into bytes, and the two bin tables turn byte offsets into formatting. readParagraphs itself only ever produces flat ParagraphEntry values (one per paragraph/cell/row mark, whatever its own table depth); table/read.ts's assembleBlocks is what folds a contiguous run of table-depth paragraphs into a real ContentTable, so this module carries no table-specific logic of its own. // -// What this does NOT do is as important as what it does, and is stated in full in the README's scope section rather than only here: no images, no footnotes/headers/endnotes, no section geometry, no numbering definitions, no style-inherited formatting, and no decryption. Each of those is a genuine layer of the format, and each is absent rather than approximated. Tables are read, but only at depth 1 -- a table nested inside a table cell is refused (table/read.ts) rather than mis-read. +// What this does NOT do is as important as what it does, and is stated in full in the README's scope section rather than only here: no images, no footnotes/headers/endnotes, no section geometry, no style-inherited formatting, and no decryption. Each of those is a genuine layer of the format, and each is absent rather than approximated. Tables are read, but only at depth 1 -- a table nested inside a table cell is refused (table/read.ts) rather than mis-read. Numbering definitions (list/numbering.ts's readNumberingDefinitions) resolve what a paragraph's own listId/listLevel membership looks like -- see DocContent's own comment below for why that rides outside ContentDocument's shared shape. /** The page geometry every section is given, because this reader does not yet read a document's own. US Letter with one-inch margins is Word's own default for a new document; a document that states otherwise is not yet consulted, so this is a placeholder the schema requires rather than a fact read from the file. */ const DEFAULT_PAGE_SIZE: PageSize = { widthPt: 612, heightPt: 792 }; @@ -84,9 +88,12 @@ export function readDocStreams(bytes: Uint8Array): DocStreams { }; } -export function readDocContent( - bytes: Uint8Array, -): ContentDocument { +/** readDocContent's own return type: a ContentDocument (kind 'wordprocessing') plus numbering -- the list-level formatting (glyph/format, level-text template, start-at value) PlfLst/PlfLfo carry, which ContentListMembership has nowhere to hold. Mirrors ooxml.js's own DocxDocument.numbering exactly in field name and NumberingDefinitions' own shape (see list/numbering.ts's top comment for why it sits outside the shared schema rather than inside ContentListMembership); unlike DocxDocument, DocContent stays a genuine ContentDocument subtype (an intersection, not a fresh shape) since readDocContent already had one return type to widen rather than two to reconcile. */ +export type DocContent = ContentDocument & { + readonly numbering: NumberingDefinitions; +}; + +export function readDocContent(bytes: Uint8Array): DocContent { const { wordDocument, table, fib, metadata } = readDocStreams(bytes); const pieceTable = parseClx( @@ -141,6 +148,7 @@ export function readDocContent( characterProperties: new Map(), }); const blocks = assembleBlocks(entries); + const numbering = readNumberingDefinitions(table, fib); return { kind: "wordprocessing", @@ -156,6 +164,7 @@ export function readDocContent( blocks, }, ], + numbering, }; } diff --git a/packages/doc-codec/src/test-support/fib.ts b/packages/doc-codec/src/test-support/fib.ts index 5ea00582e..1f59036a7 100644 --- a/packages/doc-codec/src/test-support/fib.ts +++ b/packages/doc-codec/src/test-support/fib.ts @@ -26,6 +26,10 @@ export interface FibSpec { readonly lcbPlcfBtePapx?: number; readonly fcClx?: number; readonly lcbClx?: number; + readonly fcPlfLst?: number; + readonly lcbPlfLst?: number; + readonly fcPlfLfo?: number; + readonly lcbPlfLfo?: number; /** Overridden only to test the reader's own rejection of a wrong signature. */ readonly wIdent?: number; /** The count of 64-bit values in FibRgFcLcbBlob. 0x005D is the value [MS-DOC] 2.5.1 mandates for nFib 0x00C1. */ @@ -50,6 +54,8 @@ const FC_LCB_INDEX = { fcPlcfBteChpx: 24, fcPlcfBtePapx: 26, fcClx: 66, + fcPlfLst: 146, + fcPlfLfo: 148, } as const; export function buildFib(spec: FibSpec = {}): Uint8Array { @@ -105,6 +111,8 @@ export function buildFib(spec: FibSpec = {}): Uint8Array { spec.lcbPlcfBtePapx ?? 0, ); pair(FC_LCB_INDEX.fcClx, spec.fcClx ?? 0, spec.lcbClx ?? 0); + pair(FC_LCB_INDEX.fcPlfLst, spec.fcPlfLst ?? 0, spec.lcbPlfLst ?? 0); + pair(FC_LCB_INDEX.fcPlfLfo, spec.fcPlfLfo ?? 0, spec.lcbPlfLfo ?? 0); return bytes; } From 77cc4e342fecb6ce51cd1f326730850080c863b8 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Fri, 4 Sep 2026 13:57:28 +0100 Subject: [PATCH 2/4] test(doc-codec): cover numbering definitions with hand-built PlfLst/PlfLfo fixtures Bytes assembled directly from [MS-DOC] 2.9.226 (PlfLst)/2.9.191 (LSTF)/2.9.196 (LVL)/2.9.148 (LVLF)/2.9.343 (Xst)/2.9.225 (PlfLfo)/2.9.181 (LFO)'s own field tables, independently of numbering.ts's own reader -- so a test asserting against these bytes checks the reader's understanding of the spec, not agreement with a second copy of the same layout, the identical convention table/decoration.test.ts's own hand-built Brc80/Shd80 fixtures state. Covers: the common case of no PlfLst/PlfLfo at all, a simple one-level bulleted list, a decimal list with a real '%1.' placeholder template, a nine-level multi-level list with mixed formats and placeholder levels, ilvlRestartLim resolving only when fNoRestart is set, the nfc=0xFF "no number sequence" sentinel, resolution keyed by the one-based ilfo (not by lsid, proven by an LFO array whose order deliberately does not match its LSTF array), and a thrown DocFormatError for an unrecognised MSONFC value. Also adds list/numbering.js to the deep-import smoke test's own module list, matching the family convention of covering every new src module there. --- packages/doc-codec/src/list/numbering.test.ts | 294 ++++++++++++++++++ packages/doc-codec/test/smoke.test.mjs | 1 + 2 files changed, 295 insertions(+) create mode 100644 packages/doc-codec/src/list/numbering.test.ts diff --git a/packages/doc-codec/src/list/numbering.test.ts b/packages/doc-codec/src/list/numbering.test.ts new file mode 100644 index 000000000..27c66e928 --- /dev/null +++ b/packages/doc-codec/src/list/numbering.test.ts @@ -0,0 +1,294 @@ +import { describe, expect, it } from "vitest"; +import { buildFib } from "../test-support/fib"; +import { parseFib } from "../fib/fib"; +import { readNumberingDefinitions } from "./numbering"; + +// Hand-built PlfLst/PlfLfo byte sequences, assembled directly from [MS-DOC] 2.9.226 (PlfLst)/2.9.191 (LSTF)/2.9.196 (LVL)/2.9.148 (LVLF)/2.9.343 (Xst)/2.9.225 (PlfLfo)/2.9.181 (LFO)'s own field tables, independently of numbering.ts's own reader -- so a test asserting against these bytes is checking the reader's understanding of the spec, not agreement with a second copy of the same layout (the identical convention table/decoration.test.ts states for its own hand-built Brc80/Shd80 fixtures). + +function u16(value: number): number[] { + return [value & 0xff, (value >>> 8) & 0xff]; +} +function u32(value: number): number[] { + return [ + value & 0xff, + (value >>> 8) & 0xff, + (value >>> 16) & 0xff, + (value >>> 24) & 0xff, + ]; +} +function i32(value: number): number[] { + return u32(value >>> 0); +} + +/** One LSTF ([MS-DOC] 2.9.191): lsid(4) + tplc(4, zero -- UI-only) + rgistdPara(18, all 0x0FFF -- "no style linked") + a flags byte (only fSimpleList, bit 0) + grfhic(1, zero). */ +function buildLstf(lsid: number, fSimpleList: boolean): number[] { + const rgistdPara: number[] = []; + for (let index = 0; index < 9; index += 1) { + rgistdPara.push(...u16(0x0fff)); + } + return [ + ...i32(lsid), + ...u32(0), // tplc + ...rgistdPara, + fSimpleList ? 0x01 : 0x00, // A-F flags byte: only fSimpleList set + 0x00, // grfhic + ]; +} + +interface XstPart { + readonly char?: string; + readonly placeholderLevel?: number; +} + +/** An Xst ([MS-DOC] 2.9.343) plus the rgbxchNums positions a caller's own placeholder parts land at -- cch(2) then that many raw 16-bit code units, where a `{ placeholderLevel }` part writes the RAW zero-based level index as its own code unit rather than a literal character, exactly what [MS-DOC]'s own Xst field text describes ("Each placeholder is an unsigned 2-byte integer that specifies the zero-based level"). */ +function buildXst(parts: readonly XstPart[]): { + readonly bytes: number[]; + readonly rgbxchNums: number[]; +} { + const rgtchar: number[] = []; + const rgbxchNums: number[] = []; + parts.forEach((part, index) => { + if (part.placeholderLevel !== undefined) { + rgtchar.push(...u16(part.placeholderLevel)); + rgbxchNums.push(index + 1); + } else { + rgtchar.push(...u16((part.char ?? " ").charCodeAt(0))); + } + }); + return { bytes: [...u16(parts.length), ...rgtchar], rgbxchNums }; +} + +interface LvlSpec { + readonly startAt?: number; + readonly nfc: number; + readonly restart?: number; // sets fNoRestart and ilvlRestartLim together + readonly text: readonly XstPart[]; +} + +/** One LVL ([MS-DOC] 2.9.196): a 28-byte LVLF (iStartAt, nfc, the jc/flags byte, rgbxchNums, ixchFollow, dxaIndentSav, unused2, cbGrpprlChpx=0, cbGrpprlPapx=0, ilvlRestartLim, grfhic=0) with grpprlPapx/grpprlChpx both empty (this reader never decodes them, and a real 0-length case is the simplest fixture that still exercises the Xst offset arithmetic correctly) followed immediately by its own Xst. */ +function buildLvl(spec: LvlSpec): number[] { + const { bytes: xstBytes, rgbxchNums } = buildXst(spec.text); + const rgbxchNumsPadded = [...rgbxchNums]; + while (rgbxchNumsPadded.length < 9) { + rgbxchNumsPadded.push(0); + } + const fNoRestart = spec.restart !== undefined; + const flags = fNoRestart ? 0x02 : 0x00; + const lvlf = [ + ...i32(spec.startAt ?? 1), + spec.nfc, + flags, + ...rgbxchNumsPadded, + 0x02, // ixchFollow: nothing follows the number text + ...i32(0), // dxaIndentSav + ...u32(0), // unused2 + 0x00, // cbGrpprlChpx + 0x00, // cbGrpprlPapx + spec.restart ?? 0, // ilvlRestartLim + 0x00, // grfhic + ]; + return [...lvlf, ...xstBytes]; +} + +interface LstfWithLevels { + readonly lsid: number; + readonly levels: readonly LvlSpec[]; // 1 entry for a simple list, 9 for a multi-level one +} + +/** PlfLst ([MS-DOC] 2.9.226): cLst(2, signed) then that many 28-byte LSTF entries, followed IMMEDIATELY (not accounted for by lcbPlfLst) by the appended LVL array in LSTF order -- exactly what FibRgFcLcb97's own fcPlfLst field text describes. Returns the two pieces separately since the caller has to place them at fc and fc+lcb respectively, with nothing in between. */ +function buildPlfLst(entries: readonly LstfWithLevels[]): { + readonly plfLst: number[]; + readonly appendedLvls: number[]; +} { + const cLst = i32(entries.length).slice(0, 2); // cLst is a signed 16-bit count per the spec's own field table + const rgLstf = entries.flatMap((entry) => + buildLstf(entry.lsid, entry.levels.length === 1), + ); + const appendedLvls = entries.flatMap((entry) => + entry.levels.flatMap((level) => buildLvl(level)), + ); + return { plfLst: [...cLst, ...rgLstf], appendedLvls }; +} + +/** PlfLfo ([MS-DOC] 2.9.225), rgLfo only ([MS-DOC] 2.9.181's own LFO: lsid(4) + unused1(4) + unused2(4) + clfolvl(1)=0 + ibstFltAutoNum(1)=0 + grfhic(1)=0 + unused3(1)) -- this reader never reads rgLfoData (see numbering.ts's own top comment), so the fixture never builds one either; a real file's own lcbPlfLfo would cover rgLfoData too, but nothing in this reader's own contract depends on that extra length being present. */ +function buildPlfLfo(lsids: readonly number[]): number[] { + const rgLfo = lsids.flatMap((lsid) => [ + ...i32(lsid), + ...u32(0), // unused1 + ...u32(0), // unused2 + 0x00, // clfolvl + 0x00, // ibstFltAutoNum + 0x00, // grfhic + 0x00, // unused3 + ]); + return [...i32(lsids.length), ...rgLfo]; +} + +/** Assembles a Table stream carrying exactly one PlfLst and one PlfLfo, back to back at arbitrary (but real) offsets, and a Fib whose fcPlfLst/fcPlfLfo point at them -- the minimum a real document needs for readNumberingDefinitions to have anything to resolve. */ +function tableStreamWithNumbering( + entries: readonly LstfWithLevels[], + lsids: readonly number[], +): { readonly table: Uint8Array; readonly fib: ReturnType } { + const { plfLst, appendedLvls } = buildPlfLst(entries); + const plfLfo = buildPlfLfo(lsids); + + const plfLstOffset = 16; // arbitrary non-zero start, proving the reader honours fc rather than assuming 0 + const plfLstBytes = [...plfLst, ...appendedLvls]; + const plfLfoOffset = plfLstOffset + plfLstBytes.length + 8; // a gap, proving the reader does not assume PlfLfo immediately follows PlfLst's own appended LVLs + + const table = new Uint8Array(plfLfoOffset + plfLfo.length); + table.set(plfLstBytes, plfLstOffset); + table.set(plfLfo, plfLfoOffset); + + const fib = parseFib( + buildFib({ + fcPlfLst: plfLstOffset, + lcbPlfLst: plfLst.length, // NOT including appendedLvls, matching lcbPlfLst's own documented meaning + fcPlfLfo: plfLfoOffset, + lcbPlfLfo: plfLfo.length, + }), + ); + return { table, fib }; +} + +describe("readNumberingDefinitions", () => { + it("returns no definitions at all for a document with no PlfLst/PlfLfo (the common case)", () => { + const fib = parseFib(buildFib()); + expect(readNumberingDefinitions(new Uint8Array(0), fib)).toEqual({}); + }); + + it("resolves a simple one-level bulleted list", () => { + const { table, fib } = tableStreamWithNumbering( + [ + { + lsid: 1000, + levels: [ + { + nfc: 0x17, // msonfcBullet + text: [{ char: "•" }], + }, + ], + }, + ], + [1000], + ); + const definitions = readNumberingDefinitions(table, fib); + expect(definitions).toEqual({ + "1": { levels: { "0": { format: "bullet", text: "•", startAt: 1 } } }, + }); + }); + + it("resolves a simple one-level decimal list with a '%1.' placeholder template", () => { + const { table, fib } = tableStreamWithNumbering( + [ + { + lsid: 2000, + levels: [ + { + nfc: 0x00, // msonfcArabic + startAt: 3, + text: [{ placeholderLevel: 0 }, { char: "." }], + }, + ], + }, + ], + [2000], + ); + const definitions = readNumberingDefinitions(table, fib); + expect(definitions).toEqual({ + "1": { levels: { "0": { format: "decimal", text: "%1.", startAt: 3 } } }, + }); + }); + + it("resolves a nine-level multi-level list, each level its own format and placeholder level", () => { + const levels: LvlSpec[] = [ + { nfc: 0x00, text: [{ placeholderLevel: 0 }, { char: ")" }] }, // decimal + { + nfc: 0x01, // upperRoman + text: [ + { placeholderLevel: 0 }, + { char: "." }, + { placeholderLevel: 1 }, + { char: ")" }, + ], + }, + ...Array.from( + { length: 7 }, + (): LvlSpec => ({ nfc: 0x02, text: [{ placeholderLevel: 2 }] }), // lowerRoman, one placeholder each for levels 2-8 + ), + ]; + const { table, fib } = tableStreamWithNumbering( + [{ lsid: 3000, levels }], + [3000], + ); + const definitions = readNumberingDefinitions(table, fib); + expect(Object.keys(definitions["1"]?.levels ?? {})).toHaveLength(9); + expect(definitions["1"]?.levels["0"]).toEqual({ + format: "decimal", + text: "%1)", + startAt: 1, + }); + expect(definitions["1"]?.levels["1"]).toEqual({ + format: "upperRoman", + text: "%1.%2)", + startAt: 1, + }); + expect(definitions["1"]?.levels["8"]).toEqual({ + format: "lowerRoman", + text: "%3", + startAt: 1, + }); + }); + + it("resolves ilvlRestartLim only when fNoRestart is set, and leaves it absent otherwise", () => { + // A non-simple LSTF always carries exactly nine LVLs ([MS-DOC]'s own fSimpleList field text), even though only the first two are asserted on here -- levels 2-8 are trivial filler with no restart state of their own. + const levels: LvlSpec[] = [ + { nfc: 0x00, restart: 2, text: [{ placeholderLevel: 0 }] }, + { nfc: 0x00, text: [{ placeholderLevel: 1 }] }, // no restart field: fNoRestart clear + ...Array.from({ length: 7 }, (): LvlSpec => ({ + nfc: 0x00, + text: [{ char: "." }], + })), + ]; + const { table, fib } = tableStreamWithNumbering( + [{ lsid: 4000, levels }], + [4000], + ); + const definitions = readNumberingDefinitions(table, fib); + expect(definitions["1"]?.levels["0"]?.restart).toBe(2); + expect(definitions["1"]?.levels["1"]?.restart).toBeUndefined(); + }); + + it("maps nfc 0xFF to format 'none' for a level with no number sequence at all", () => { + const { table, fib } = tableStreamWithNumbering( + [{ lsid: 5000, levels: [{ nfc: 0xff, text: [] }] }], + [5000], + ); + const definitions = readNumberingDefinitions(table, fib); + expect(definitions["1"]?.levels["0"]?.format).toBe("none"); + }); + + it("keys definitions by the one-based ilfo (rgLfo's own array position), not by lsid", () => { + // Two lists; the second LFO entry (ilfo 2) points at the FIRST LSTF's lsid, proving resolution goes through lsid matching rather than positional coincidence. + const { table, fib } = tableStreamWithNumbering( + [ + { lsid: 10, levels: [{ nfc: 0x00, text: [{ char: "A" }] }] }, + { lsid: 20, levels: [{ nfc: 0x00, text: [{ char: "B" }] }] }, + ], + [20, 10], + ); + const definitions = readNumberingDefinitions(table, fib); + expect(definitions["1"]?.levels["0"]?.text).toBe("B"); + expect(definitions["2"]?.levels["0"]?.text).toBe("A"); + }); + + it("throws on an unrecognised MSONFC value", () => { + const { table, fib } = tableStreamWithNumbering( + [{ lsid: 6000, levels: [{ nfc: 0x50, text: [] }] }], + [6000], + ); + expect(() => readNumberingDefinitions(table, fib)).toThrow( + /not a recognised MSONFC value/, + ); + }); +}); diff --git a/packages/doc-codec/test/smoke.test.mjs b/packages/doc-codec/test/smoke.test.mjs index 65568885e..eaed0ff4a 100644 --- a/packages/doc-codec/test/smoke.test.mjs +++ b/packages/doc-codec/test/smoke.test.mjs @@ -87,6 +87,7 @@ describe('dist/ deep imports resolve for every advertised module, in both builds { path: '../dist/prop/chp.js', exports: ['applyCharacterSprms'] }, { path: '../dist/prop/pap.js', exports: ['applyParagraphSprms'] }, { path: '../dist/style/stsh.js', exports: ['parseStsh', 'headingLevelFromIstd', 'STK'] }, + { path: '../dist/list/numbering.js', exports: ['readNumberingDefinitions'] }, { path: '../dist/read.js', exports: ['readDocContent', 'readDocStreams'] }, ]; From 410fff704e2475c639120f3a2ed4792f2732ebaa Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Fri, 4 Sep 2026 13:57:39 +0100 Subject: [PATCH 3/4] docs(doc-codec): document numbering definitions as built and shipped Adds a Numbering definitions section explaining what readNumberingDefinitions resolves, why it deliberately mirrors ooxml.js's own numbering shape and vocabulary rather than document-schema.js, and what it deliberately does not resolve (LFOLVL overrides, grpprlPapx/grpprlChpx, legal numbering). Removes the "Numbering definitions" row from the "not built on either side" table now that the read side genuinely resolves it, and adds a read-side status bullet. Verified against real LibreOffice 26.2.5.2: a .doc built from a hand-authored .fodt declaring a real numbered list and a separate bulleted list reads back with the exact ODF-authored decimal template and the real Private Use Area bullet glyph (U+F0B7) LibreOffice wrote for it, confirmed against the raw PlfLst/LVL bytes directly. --- packages/doc-codec/README.md | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/packages/doc-codec/README.md b/packages/doc-codec/README.md index 85a0a6b8a..eec255a8a 100644 --- a/packages/doc-codec/README.md +++ b/packages/doc-codec/README.md @@ -22,6 +22,7 @@ Built and shipped, on the read side: - **`readDocContent`** — the whole chain, producing a `'wordprocessing'` `ContentDocument` of paragraphs, runs and tables. - **`isDocBytes`** — distinguishes a `.doc` from the `.xls`, `.ppt` and OLE embeddings that share its container, by looking for a `WordDocument` stream carrying `FibBase.wIdent`. - **Document metadata** — `title`/`subject`/`author`/`keywords`/`createdIso`/`modifiedIso` read from a `"\x05SummaryInformation"` stream when one is present (see [Metadata](#metadata)); `comments` and `lastPrintedIso` remain unread, since `LayoutMetadata` has no field for either. +- **Numbering definitions** — `readDocContent`'s own `numbering` field: every list's glyph/format, level-text template, and start-at value, resolved from `PlfLst`/`PlfLfo` and keyed by the same `listId` a paragraph's `ContentParagraph.list.numId` already carries. Read-only; see [Numbering definitions](#numbering-definitions). Built and shipped, on the write side — see [Writing](#writing) for the full scope statement: @@ -38,7 +39,6 @@ Built and shipped, on the write side — see [Writing](#writing) for the full sc | **Style-inherited formatting** | A style's own property sets live in the `STD`'s `grLPUpxSw` and are not read, so a paragraph's formatting is the document defaults plus its own direct exceptions. A `Heading 1` paragraph reports its `styleId` and `headingLevel` but not the boldness or size its style would supply. `writeDocContent` writes no paragraph styles at all (every paragraph is `istd` 0) and does not round-trip `styleId`/`headingLevel`. | | **Subdocuments** | Only the main document (character positions 0 to `ccpText`) is converted. Footnotes, endnotes, headers, footers, comments and text boxes are not, in either direction. | | **Section properties** | Section boundaries are not read, so the whole document is one section, and its page size and margins are a US Letter placeholder rather than the document's own. `writeDocContent` refuses a `ContentDocument` with more than one section, rather than silently merging their content into what would read back as one. | -| **Numbering definitions** | `sprmPIlfo`/`sprmPIlvl` are read into a `list` membership, but the `PlfLfo`/`PlfLst` tables that say what the list looks like are not, so no marker text or numbering format is available. `writeDocContent` does not write `PlfLfo`/`PlfLst` or `sprmPIlfo`/`sprmPIlvl`, so `ContentParagraph.list` is not round-tripped. | | **Extended and user-defined document properties** | `title`/`subject`/`author`/`keywords`/`createdIso`/`modifiedIso` are read from and written to a `"\x05SummaryInformation"` stream when present (see [Metadata](#metadata)); the sibling `"\x05DocumentSummaryInformation"` stream (company, manager, and custom user-defined properties) is not read or written at all. | | **Encryption** | An encrypted or XOR-obfuscated document is refused with a `DocUnsupportedError` rather than read as plaintext. `writeDocContent` never encrypts. | | **`sprmPHugePapx` / `sprmPTableProps`** | Paragraph properties stored indirectly in the Data stream are not followed, so such a paragraph reads with fewer properties than it states. [MS-DOC] 2.4.3's own Overview of Tables text names `sprmPTableProps` as a real, legal alternative to `sprmTDefTable` some applications process — but a real producer's row mark is not shown to prefer it: a genuine LibreOffice-authored `.doc` table's own row mark states its TAP through the identical direct `sprmTDefTable` this package's reader and writer already use (confirmed by parsing a LibreOffice 26.2.5.2-authored table's raw `PapxFkp` bytes; see [ExaDev/documents.js#892](https://github.com/ExaDev/documents.js/issues/892)), matching 2.4.3's own compatibility guidance ("An application SHOULD use sprmTDefTable to define table cells for applications that do not process sprmPTableProps"). `writeDocContent` never writes an indirect Papx. | @@ -132,6 +132,22 @@ A table's own horizontal position is not read or written either, and this one is One narrow accuracy limit follows from the same missing field. `rgdxaCenter`'s entries need only be "in non-decreasing order", so two adjacent entries may be equal — a legal zero-width physical cell. Such a cell covers no segment of the reconstructed grid, and `ContentTableCell` cannot say "zero columns wide", so it comes back carrying its own content as an ordinary un-spanned cell sharing a grid position with the cell after it. Nothing is lost, but the two are indistinguishable by position, so a vertical merge anchored at that position in a later row matches whichever of them comes first. +## Numbering definitions + +A paragraph's own `list.numId`/`list.level` (`sprmPIlfo`/`sprmPIlvl`, unchanged by this section) say WHICH list a paragraph belongs to and WHAT DEPTH within it -- they say nothing about what that list actually looks like. `readDocContent`'s own `numbering` field is that: keyed by the same `listId` string `numId` already carries, each entry names every level's glyph/format, level-text template, and start-at value, resolved from `PlfLst` (the list definitions, `LSTF` plus each one's appended array of `LVL`s) and `PlfLfo` (which list a paragraph's own `ilfo` actually refers to). `list/numbering.ts`'s `readNumberingDefinitions` is the whole implementation; `read.ts`'s `DocContent` is `ContentDocument` widened by exactly this one field, so every existing caller expecting a plain `ContentDocument` is unaffected. + +**Deliberately shaped like ooxml.js's own numbering, not document-schema.js's.** `NumberingDefinition`/`NumberingLevel` are doc-codec's own types, not a `document-schema.js` addition: `ContentListMembership` is shared verbatim across every codec in this family, and widening it with a doc-codec-specific numbering-definition payload would leak this package's own model into a schema the sibling packages also depend on -- exactly the reasoning `ooxml.js`'s own `typed/docx/numbering.ts` states for `word/numbering.xml`'s `abstractNum`/`num` tables, which this module deliberately mirrors rather than reinvents. `NumberingLevel.format` is the identical ECMA-376 `ST_NumberFormat` string ooxml.js's own field already carries (`"decimal"`, `"upperRoman"`, `"bullet"`, ...) -- [MS-OSHARED] 2.2.1.3's own `MSONFC` enumeration documents each value as "mapped to the `ST_NumberFormat`... equivalent", so this reader uses that same mapping rather than inventing a second vocabulary. `NumberingLevel.text` is the identical `'%1.'`/`'%2)'`-style placeholder convention: `[MS-DOC]`'s own `Xst`/`rgbxchNums` encoding names a placeholder by which _character position_ in the level's text is a raw, zero-based level index rather than literal content, and `readLevelText` converts that into the one-based `%N` spelling ooxml.js's own `w:lvlText` values already use -- so a consumer that already resolves one already resolves the other. + +**Read-only, matching ooxml.js's own docx writer exactly.** `word/numbering.xml` is read into `DocxDocument.numbering` but never written back (that package's own stated write scope), and `writeDocContent` does not attempt to write `PlfLst`/`PlfLfo` either: encoding a level's own `grpprlPapx`/`grpprlChpx` `Prl` streams back out is a materially separate task, the identical reasoning [Writer scope](#writing) states for why `xls-codec`'s formula writing is scoped apart from its read-side recovery. + +**What is deliberately not resolved**, each a genuine layer of the format rather than an oversight: + +- **`LFOLVL` overrides.** An `LFO` can restate one or more of its `LSTF`'s own levels with different formatting (`PlfLfo`'s own `rgLfoData`); this reader always resolves an `ilfo` straight through to its `LSTF`'s own plain `LVL` array, ignoring any override the `LFO` itself carries. `PlfLfo`'s own `rgLfo` (fixed 16-byte records) is all this reader touches; `rgLfoData`, which sits immediately after it, is never read at all. +- **`grpprlPapx`/`grpprlChpx`.** A level's own paragraph/character formatting `Prl` streams are skipped past by their declared length, never decoded, since `ContentListMembership` has nowhere to carry per-level indent or font direct formatting. +- **Legal numbering (`LVLF.fLegal`).** A bit that overrides an _inherited_ placeholder's own format (forcing it to `msonfcArabic`, or preserving `msonfcArabicLZ`) rather than the level's own -- `text` still carries the placeholder verbatim, uninterpreted by `fLegal`. + +**Verified against a real, independent [MS-DOC] implementation, not just this package's own hand-built fixtures.** A `.doc` built directly by LibreOffice (`soffice --headless --convert-to doc`, from a hand-authored `.fodt` declaring a real `text:list-style` numbered list and a separate bulleted list) is read correctly by this reader: the numbered list's own level 0 resolves to `format: "decimal"`, `text: "%1."`, exactly the ODF `style:num-format="1" style:num-suffix="."` it was authored with; the bulleted list's own level 0 resolves to `format: "bullet"` with `text` carrying the exact single-character glyph LibreOffice wrote for it (`U+F0B7`, the Symbol/Wingdings-font Private Use Area bullet code point real Word-format producers use, not a printable Unicode bullet) -- confirmed byte-for-byte against the raw `PlfLst`/`LVL` bytes LibreOffice actually wrote, not assumed. Both lists' nine `LVL`s per `LSTF` (a real multi-level `LSTF`, `fSimpleList` clear) parse cleanly end to end with no bounds error, and each paragraph's own `list.numId`/`list.level` resolves through to the correct definition. + ## Metadata A `.doc`'s title, author, and dates do not live in any [MS-DOC] structure at all — they live in a `"\x05SummaryInformation"` stream, a genuinely different format ([MS-OLEPS] Property Set Streams) that happens to sit beside `WordDocument`/`1Table` in the same [MS-CFB] compound file. `readDocContent` reads that stream when present (`archive-codec`'s `readSummaryInformation`, since the property-set format itself is zero document-format knowledge, exactly as the [MS-CFB] container it sits inside is) and maps it onto `document-schema.js`'s `LayoutMetadata` (`archive-codec`'s own `summaryInformationToLayoutMetadata` — the mapping is format-agnostic, so it lives there rather than being copied in this package, alongside `xls-codec`'s and `ppt-codec`'s identical need for it); `writeDocContent` does the inverse (`src/metadata.ts`'s `layoutMetadataToSummaryInformation`, which validates `createdIso`/`modifiedIso` as real dates and throws a `DocFormatError` naming the offending field before delegating to `archive-codec`'s own mapping — see [Writing](#writing)), including a `"\x05SummaryInformation"` stream in its `writeCompoundFile` call only when the input's metadata actually carries something that stream can hold — an input whose metadata is `{}`, or carries only fields the mapping below has no destination for, produces no stream at all, matching what an absent-metadata read already returns. From 8f094630a344d23a1ff3fe10ece169e4f96960a9 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Fri, 4 Sep 2026 13:57:44 +0100 Subject: [PATCH 4/4] test(documents.js): account for doc-codec's numbering field in the doc round trip readDocContent's return type widened by one field (numbering, doc-codec's own read-only list-formatting definitions keyed by listId) -- this fixture declares no lists, so the round trip's own numbering now resolves to {} rather than being absent from the result entirely, which the strict content equality check needs to expect. --- packages/documents.js/src/codecs/registry.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/documents.js/src/codecs/registry.test.ts b/packages/documents.js/src/codecs/registry.test.ts index 89037c6e5..092526b63 100644 --- a/packages/documents.js/src/codecs/registry.test.ts +++ b/packages/documents.js/src/codecs/registry.test.ts @@ -212,7 +212,7 @@ describe("DOCUMENT_FORMAT_CODECS: content read/write round trips", () => { expect(codec.read(rebuiltBytes)).toEqual(content); }); - // doc-codec's own writer covers a single wordprocessing section, character/paragraph formatting, and tables (no images or numbering -- see that package's README scope note), so this fixture is deliberately plain: one heading paragraph and one bold run, exercising exactly what writeDocContent can express -- a table exercises real content-scope boundaries elsewhere (doc-codec's own write.test.ts), not this registry-wiring round trip. doc-codec always reads back metadata as {} regardless of what was written (readDocContent's own scope note), so -- like rtf above -- no withReferenceTimestamps normalisation is needed, but for the opposite reason: there is no timestamp field for either side to disagree on. + // doc-codec's own writer covers a single wordprocessing section, character/paragraph formatting, and tables (no images -- see that package's README scope note), so this fixture is deliberately plain: one heading paragraph and one bold run, exercising exactly what writeDocContent can express -- a table exercises real content-scope boundaries elsewhere (doc-codec's own write.test.ts), not this registry-wiring round trip. doc-codec always reads back metadata as {} regardless of what was written (readDocContent's own scope note), so -- like rtf above -- no withReferenceTimestamps normalisation is needed, but for the opposite reason: there is no timestamp field for either side to disagree on. readDocContent's own return type is ContentDocument widened by one further field, numbering (doc-codec's own DocContent, read-only list-formatting definitions keyed by listId) -- this fixture declares no lists, so the round trip's own numbering resolves to {} rather than nothing at all. it("doc: read -> write -> read round-trips the ContentDocument", () => { const codec = requireContentCodec("doc"); const content: ContentDocument = { @@ -236,7 +236,7 @@ describe("DOCUMENT_FORMAT_CODECS: content read/write round trips", () => { ], }; const rebuiltBytes = codec.write!(content); - expect(codec.read(rebuiltBytes)).toEqual(content); + expect(codec.read(rebuiltBytes)).toEqual({ ...content, numbering: {} }); }); // Mirrors xls-codec's own write.test.ts fixture shape (its `sheet`/`cell` helpers, restated inline here rather than imported -- that test-support is not part of xls-codec's published surface). writeXlsContent's own scope covers cell values, merges, row/column sizing, and number formats (no formulas/decoration -- see that package's README scope note), so this fixture sticks to plain cell values. A cell written with no explicit number format gains 'General' on the way back (XF 15's own ifmt resolving through the built-in table) -- the same pre-existing, documented stamping xls-codec's own write.test.ts pins, not something this registry wiring introduces -- so the expected content states it explicitly rather than asserting exact equality against the unformatted input.