Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 17 additions & 1 deletion packages/doc-codec/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand All @@ -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. |
Expand Down Expand Up @@ -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.
Expand Down
9 changes: 9 additions & 0 deletions packages/doc-codec/src/fib/fib.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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),
};
}

Expand Down
5 changes: 5 additions & 0 deletions packages/doc-codec/src/fib/offsets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions packages/doc-codec/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Loading