doc-codec 1.0.2 → 1.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +192 -50
- package/dist/color.cjs +166 -0
- package/dist/color.d.cts +20 -0
- package/dist/color.d.ts +20 -0
- package/dist/color.js +160 -0
- package/dist/detect.cjs +3 -0
- package/dist/detect.d.cts +3 -1
- package/dist/detect.d.ts +3 -1
- package/dist/detect.js +3 -1
- package/dist/fib/fib.cjs +9 -1
- package/dist/fib/fib.d.cts +1 -28
- package/dist/fib/fib.d.ts +1 -28
- package/dist/fib/fib.js +9 -1
- package/dist/fib/offsets.cjs +9 -1
- package/dist/fib/offsets.d.cts +8 -0
- package/dist/fib/offsets.d.ts +8 -0
- package/dist/fib/offsets.js +9 -1
- package/dist/fib/write.cjs +43 -0
- package/dist/fib/write.d.cts +21 -0
- package/dist/fib/write.d.ts +21 -0
- package/dist/fib/write.js +42 -0
- package/dist/fib-CtsLtF96.d.cts +37 -0
- package/dist/fib-CtsLtF96.d.ts +37 -0
- package/dist/index.cjs +31 -0
- package/dist/index.d.cts +18 -7
- package/dist/index.d.ts +18 -7
- package/dist/index.js +14 -3
- package/dist/list/numbering.cjs +206 -0
- package/dist/list/numbering.d.cts +2 -0
- package/dist/list/numbering.d.ts +2 -0
- package/dist/list/numbering.js +205 -0
- package/dist/metadata.cjs +14 -0
- package/dist/metadata.d.cts +6 -0
- package/dist/metadata.d.ts +6 -0
- package/dist/metadata.js +13 -0
- package/dist/numbering-Cir-uGdc.d.cts +22 -0
- package/dist/numbering-DF1z1PyG.d.ts +22 -0
- package/dist/pap-BkFo8dZO.d.ts +29 -0
- package/dist/pap-BmelEYEP.d.cts +29 -0
- package/dist/prop/chp-write.cjs +47 -0
- package/dist/prop/chp-write.d.cts +5 -0
- package/dist/prop/chp-write.d.ts +5 -0
- package/dist/prop/chp-write.js +46 -0
- package/dist/prop/chp.cjs +15 -107
- package/dist/prop/chp.d.cts +3 -2
- package/dist/prop/chp.d.ts +3 -2
- package/dist/prop/chp.js +15 -107
- package/dist/prop/fkp-write.cjs +139 -0
- package/dist/prop/fkp-write.d.cts +21 -0
- package/dist/prop/fkp-write.d.ts +21 -0
- package/dist/prop/fkp-write.js +135 -0
- package/dist/prop/pap-write.cjs +67 -0
- package/dist/prop/pap-write.d.cts +5 -0
- package/dist/prop/pap-write.d.ts +5 -0
- package/dist/prop/pap-write.js +66 -0
- package/dist/prop/pap.cjs +10 -0
- package/dist/prop/pap.d.cts +1 -24
- package/dist/prop/pap.d.ts +1 -24
- package/dist/prop/pap.js +11 -1
- package/dist/prop/sep-write.cjs +63 -0
- package/dist/prop/sep-write.d.cts +9 -0
- package/dist/prop/sep-write.d.ts +9 -0
- package/dist/prop/sep-write.js +60 -0
- package/dist/prop/sep.cjs +63 -0
- package/dist/prop/sep.d.cts +16 -0
- package/dist/prop/sep.d.ts +16 -0
- package/dist/prop/sep.js +61 -0
- package/dist/prop/sprm.d.cts +1 -25
- package/dist/prop/sprm.d.ts +1 -25
- package/dist/read.cjs +44 -18
- package/dist/read.d.cts +21 -4
- package/dist/read.d.ts +21 -4
- package/dist/read.js +46 -20
- package/dist/sprm-Djg5RNiq.d.cts +26 -0
- package/dist/sprm-Djg5RNiq.d.ts +26 -0
- package/dist/style/fonts.cjs +68 -0
- package/dist/style/fonts.d.cts +5 -0
- package/dist/style/fonts.d.ts +5 -0
- package/dist/style/fonts.js +66 -0
- package/dist/style/stsh.cjs +23 -0
- package/dist/style/stsh.d.cts +2 -1
- package/dist/style/stsh.d.ts +2 -1
- package/dist/style/stsh.js +23 -1
- package/dist/table/decoration.cjs +220 -0
- package/dist/table/decoration.d.cts +41 -0
- package/dist/table/decoration.d.ts +41 -0
- package/dist/table/decoration.js +205 -0
- package/dist/table/read.cjs +192 -0
- package/dist/table/read.d.cts +6 -0
- package/dist/table/read.d.ts +6 -0
- package/dist/table/read.js +191 -0
- package/dist/table/tap-write.cjs +125 -0
- package/dist/table/tap-write.d.cts +13 -0
- package/dist/table/tap-write.d.ts +13 -0
- package/dist/table/tap-write.js +124 -0
- package/dist/table/tap.cjs +217 -0
- package/dist/table/tap.d.cts +29 -0
- package/dist/table/tap.d.ts +29 -0
- package/dist/table/tap.js +213 -0
- package/dist/table/write.cjs +136 -0
- package/dist/table/write.d.cts +13 -0
- package/dist/table/write.d.ts +13 -0
- package/dist/table/write.js +135 -0
- package/dist/text/piece-table-write.cjs +24 -0
- package/dist/text/piece-table-write.d.cts +4 -0
- package/dist/text/piece-table-write.d.ts +4 -0
- package/dist/text/piece-table-write.js +23 -0
- package/dist/write.cjs +175 -0
- package/dist/write.d.cts +5 -0
- package/dist/write.d.ts +5 -0
- package/dist/write.js +174 -0
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -2,38 +2,50 @@
|
|
|
2
2
|
|
|
3
3
|
[](https://github.com/ExaDev/documents.js/tree/main/packages/doc-codec) [](https://www.npmjs.com/package/doc-codec) [](https://www.npmjs.com/package/doc-codec) [](https://github.com/ExaDev/documents.js/actions)
|
|
4
4
|
|
|
5
|
-
> A hand-written, dependency-minimal reader for the Word Binary File Format (`.doc`, [MS-DOC]) against the shared [`document-schema.js`](../document-schema.js/README.md) content pivot.
|
|
5
|
+
> A hand-written, dependency-minimal reader and writer for the Word Binary File Format (`.doc`, [MS-DOC]) against the shared [`document-schema.js`](../document-schema.js/README.md) content pivot.
|
|
6
6
|
|
|
7
7
|
`.doc` is the pre-2007 Word format: a binary document living inside an [MS-CFB] compound file, with none of the XML that makes `.docx` tractable. Its text is not stored contiguously, its formatting is stored as sparse exceptions on 512-byte pages, and every structure in it is addressed by a character position that only becomes a byte offset by passing through a piece table. `doc-codec` reads that structure by hand from the published specification, exactly as `ooxml.js` reads `.docx` and `odf.js` reads `.odt`, and produces the same `ContentDocument` all three target.
|
|
8
8
|
|
|
9
9
|
## Status
|
|
10
10
|
|
|
11
|
-
**Under active development. This package reads
|
|
11
|
+
**Under active development. This package both reads and writes, over a smaller surface on the write side than the read side covers.**
|
|
12
12
|
|
|
13
|
-
Built and shipped:
|
|
13
|
+
Built and shipped, on the read side:
|
|
14
14
|
|
|
15
15
|
- **The compound-file container and the FIB** — `readDocStreams` resolves the `WordDocument` stream and whichever of `1Table`/`0Table` `FibBase.fWhichTblStm` selects, then parses the File Information Block for the counts and offsets every later step needs.
|
|
16
16
|
- **The piece table** — `parseClx` resolves a `Clx` (skipping any leading `Prc` array) into the pieces the logical text stream is assembled from, including the compressed 8-bit spelling and its halved byte offset.
|
|
17
17
|
- **Text reconstruction** — `readTextRange` turns a range of character positions into real characters through [MS-DOC] 2.4.1's own Retrieving Text algorithm, applying the specification's byte-to-code-point mapping for compressed pieces, and returns each character's byte offset alongside it.
|
|
18
|
-
- **Character and paragraph formatting** — the `PlcBteChpx`/`PlcBtePapx` bin tables and the `ChpxFkp`/`PapxFkp` pages behind them, the `Sprm`/`Prl` operand-sizing rules, and the subset of the character- and paragraph-property tables listed under [What is converted](#what-is-converted).
|
|
18
|
+
- **Character and paragraph formatting** — the `PlcBteChpx`/`PlcBtePapx` bin tables and the `ChpxFkp`/`PapxFkp` pages behind them, the `Sprm`/`Prl` operand-sizing rules, and the subset of the character- and paragraph-property tables listed under [What is converted](#what-is-converted), now including `sprmCRgFtc0`'s font-table lookup (see [The font table](#the-font-table)).
|
|
19
19
|
- **The style sheet** — `parseStsh` reads each style's index, name, kind and parent, and `headingLevelFromIstd` applies `sprmPIstd`'s own rule that an `istd` of 1 through 9 states an outline level.
|
|
20
|
-
-
|
|
20
|
+
- **Tables** — `table/read.ts`'s `assembleBlocks` folds a contiguous run of table-depth-1 paragraphs into a real `ContentTable`: cell boundaries at each cell-mark (`0x07`) character, a cell holding more than one paragraph where only its last ends in a cell mark, and each row's own trailing mark (`sprmPFTtp`) resolved through `table/tap.ts`'s `applyTableSprms` for its TAP — column boundaries and every physical cell's own horizontal/vertical merge state, from `sprmTDefTable`'s `TDefTableOperand` (and a `sprmTMerge` range or `sprmTVertMerge` per-cell flag where a real producer states a merge that way instead — see [Tables](#tables) below for why both are read). A table nested inside a table cell (table depth greater than 1, detected via `sprmPItap`/`sprmPFInnerTableCell`/`sprmPFInnerTtp`) is refused with `DocUnsupportedError` rather than mis-read; a row whose own TAP this reader cannot resolve at all — no direct `sprmTDefTable` anywhere in its grpprl, or a cell-mark count that disagrees with it — degrades the whole run back to flat paragraphs instead, since that is a legal producer choice this reader does not yet follow rather than corruption (see [Tables](#tables)).
|
|
21
|
+
- **Table cell decoration** — `ContentTableCell.background` and `.borders`, from every encoding [MS-DOC] states them in: each cell's own four `Brc80` borders inside `TC80`, the exact-colour `sprmTSetBrc` layer a real producer writes beside them, and the row-level shading arrays (`sprmTDefTableShd`/`2nd`/`3rd`, their `Raw` counterparts, the Word 97-era `sprmTDefTableShd80`, and `sprmTSetShd`/`sprmTSetShdOdd`). See [Cell decoration](#cell-decoration).
|
|
22
|
+
- **`readDocContent`** — the whole chain, producing a `'wordprocessing'` `ContentDocument` of paragraphs, runs and tables.
|
|
21
23
|
- **`isDocBytes`** — distinguishes a `.doc` from the `.xls`, `.ppt` and OLE embeddings that share its container, by looking for a `WordDocument` stream carrying `FibBase.wIdent`.
|
|
22
|
-
|
|
23
|
-
**
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
|
34
|
-
|
|
|
35
|
-
| **
|
|
36
|
-
|
|
|
24
|
+
- **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.
|
|
25
|
+
- **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).
|
|
26
|
+
- **The one section's own page size and margins** — `prop/sep.ts`'s `readSectionProperties` resolves `PlcfSed`/`Sepx` (`sprmSXaPage`/`sprmSYaPage`/`sprmSDxaLeft`/`sprmSDxaRight`/`sprmSDyaTop`/`sprmSDyaBottom`) into `ContentSection.pageSize`/`.margins`, falling back field by field to Word's own new-document default (US Letter, one-inch margins) for a document carrying no `PlcfSed` at all, or for any one sprm it leaves unstated. This reader only ever resolves the section spanning the whole main document — the "Section boundaries" row below states what "one section" still means.
|
|
27
|
+
|
|
28
|
+
Built and shipped, on the write side — see [Writing](#writing) for the full scope statement:
|
|
29
|
+
|
|
30
|
+
- **`writeDocContent`** — a `'wordprocessing'` `ContentDocument` (one section, paragraphs of runs and tables) to genuine [MS-DOC] bytes: a real piece table, real `ChpxFkp`/`PapxFkp` pages (splitting across as many as a document's own formatting needs, not just the common one-page case), a spec-conformant empty style sheet, a font table when a run names one, a real `PlcfSed`/`Sepx` stating the one section's own page size and margins, and a `"\x05SummaryInformation"` stream when the input's metadata carries anything that stream can hold (see [Metadata](#metadata)) — wrapped in a real [MS-CFB] compound file via `archive-codec`'s `writeCompoundFile`. A `ContentTable` block is expanded by `table/write.ts`'s `flattenSectionBlocks` into the same flat paragraph sequence every other block already is (see [Tables](#tables)), so table paragraphs flow through the identical `ChpxFkp`/`PapxFkp` paging as every other paragraph rather than a separate table-only path. Each cell's own `background` and `borders` ride along into the row's TAP (see [Cell decoration](#cell-decoration)).
|
|
31
|
+
- Every property `writeDocContent` writes is verified by reading it back through this package's own `readDocContent` (`src/write.test.ts`), and additionally against a real, independent [MS-DOC] implementation: LibreOffice opened, rendered, and re-exported a `writeDocContent` sample without error or content loss, including bold/italic/underline/strike/size/colour/font-family runs, paragraph alignment and indentation, non-Latin-1 and non-BMP text (accented Latin, CJK, an emoji surrogate pair), a section's own page size and all four margins (a non-default 600x800pt page with a 90/54/45/36pt left/right/top/bottom margin, confirmed against LibreOffice's own `fo:page-width`/`fo:page-height`/`fo:margin-*` export to the exact point), and a table — recognised as a genuine `table:table`, its row/column/cell structure and both horizontal and vertical merges intact, matching real `table:number-columns-spanned`/`table:number-rows-spanned` attributes and `table:covered-table-cell` elements, and each cell's own background fill and per-side borders recovered with their exact colours, exactly as [Tables](#tables) confirms in full.
|
|
32
|
+
|
|
33
|
+
**Not built, and not approximated, on either side.** Each of these is a genuine layer of [MS-DOC] that this package does not implement; none is silently faked, and a document using one reads (or fails to write) as though it did not:
|
|
34
|
+
|
|
35
|
+
| Absent | Consequence |
|
|
36
|
+
| --------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
37
|
+
| **Nested tables** | A table inside a table cell (table depth greater than 1) is a genuinely different [MS-DOC] structure — `sprmPFInnerTableCell`/`sprmPFInnerTtp` mark its cell/row ends rather than `sprmPFInTable`/`sprmPFTtp`, and `sprmPItap`/`sprmPDtap` state the depth. Neither side of this package descends into one: `readDocContent` refuses with `DocUnsupportedError` the moment it detects a table depth greater than 1, and `writeDocContent` refuses a `ContentTable` block found inside a table cell's own blocks the same way. See [Tables](#tables) for the full scope of what a depth-1 table does resolve. |
|
|
38
|
+
| **Row- and table-level borders** | A cell's own borders are read and written (see [Cell decoration](#cell-decoration)), but the sprms that state one border set for a whole row or table and cascade it down to the cells — `sprmTTableBorders`/`sprmTTableBorders80` (a `TableBordersOperand`, including the inside-horizontal and inside-vertical edges between cells), `sprmTSetShdTable`, and `sprmTCellShdStyle` — are not. Those are a cascade above the per-cell layer rather than another spelling of it, so a table whose borders are stated only that way reads with no cell borders at all rather than with approximated ones. |
|
|
39
|
+
| **Images and drawn objects** | The anchor characters (`U+0001`, `U+0008`) are dropped rather than emitted as control characters. No picture data is read. `writeDocContent` refuses an image block. |
|
|
40
|
+
| **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`. |
|
|
41
|
+
| **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. |
|
|
42
|
+
| **Section boundaries** | Section boundaries themselves are not read — the whole document is always treated as one section, spanning the entire main document — so a multi-section `.doc`'s later sections' own page size and margins are never consulted, only the first's (see the read-side page-size/margins bullet above for what is now read of that one section). `writeDocContent` refuses a `ContentDocument` with more than one section, rather than silently merging their content into what would read back as one. |
|
|
43
|
+
| **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. |
|
|
44
|
+
| **Encryption** | An encrypted or XOR-obfuscated document is refused with a `DocUnsupportedError` rather than read as plaintext. `writeDocContent` never encrypts. |
|
|
45
|
+
| **`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. |
|
|
46
|
+
| **Hyperlinks and fields** | `ContentRun.hyperlink`, footnote/comment/annotation references, and every other field or anchor character are read as plain text or dropped (see [What is converted](#what-is-converted)) and are not written. |
|
|
47
|
+
| **Right-margin paragraph indent** | `pap.ts`'s reader folds `sprmPDxaRight` into an internal `indentRightPt`, but `ContentParagraphSchema` (`document-schema.js`) carries no field for it, so no reader output and no writer input can ever carry it. |
|
|
48
|
+
| **Every FIB field beyond what this package's own reader needs** | `writeDocContent` populates only the fc/lcb pairs its own reader consults (the style sheet, the two property bin tables, the Clx, the font table). Roughly 140 other `FibRgFcLcb97` pairs — `SttbfAssoc`, `Dop`, the printer-driver structures among them — are left zero, which is the format's own "undefined, MUST be ignored" contract for most of them, but not a certification that every third-party [MS-DOC] reader accepts the result; see `fib/write.ts`'s own note. |
|
|
37
49
|
|
|
38
50
|
One construct is refused rather than mis-read: a `sprmPChgTabs` whose `cb` is the `255` sentinel encodes its own length as a formula over tab-stop counts this package does not parse, and its length is needed to find the next `Prl`. Rather than guess and silently mis-read every property after it, `operandSize` throws.
|
|
39
51
|
|
|
@@ -41,14 +53,15 @@ One construct is refused rather than mis-read: a `sprmPChgTabs` whose `cb` is th
|
|
|
41
53
|
|
|
42
54
|
Character properties, from `Chpx` grpprls:
|
|
43
55
|
|
|
44
|
-
| Sprm | Becomes
|
|
45
|
-
| ----------------------------------------------------------------------- |
|
|
46
|
-
| `sprmCFBold` (0x0835), `sprmCFItalic` (0x0836), `sprmCFStrike` (0x0837) | `bold` / `italic` / `strike`, honouring `ToggleOperand`'s inherit (0x80) and invert (0x81) values
|
|
47
|
-
| `sprmCKul` (0x2A3E) | `underline` (any non-zero `Kul` style)
|
|
48
|
-
| `sprmCHps` (0x4A43) | `sizePt`, the operand being half-points
|
|
49
|
-
| `sprmCIco` (0x2A42) | `color`, through [MS-DOC] 2.9.
|
|
50
|
-
| `sprmCCv` (0x6870) | `color`, from a `COLORREF`
|
|
51
|
-
| `sprmCIstd` (0x4A30) | the character style index, carried for a caller to resolve
|
|
56
|
+
| Sprm | Becomes |
|
|
57
|
+
| ----------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------- |
|
|
58
|
+
| `sprmCFBold` (0x0835), `sprmCFItalic` (0x0836), `sprmCFStrike` (0x0837) | `bold` / `italic` / `strike`, honouring `ToggleOperand`'s inherit (0x80) and invert (0x81) values |
|
|
59
|
+
| `sprmCKul` (0x2A3E) | `underline` (any non-zero `Kul` style) |
|
|
60
|
+
| `sprmCHps` (0x4A43) | `sizePt`, the operand being half-points |
|
|
61
|
+
| `sprmCIco` (0x2A42) | `color`, through [MS-DOC] 2.9.119's fixed palette |
|
|
62
|
+
| `sprmCCv` (0x6870) | `color`, from a `COLORREF` |
|
|
63
|
+
| `sprmCIstd` (0x4A30) | the character style index, carried for a caller to resolve |
|
|
64
|
+
| `sprmCRgFtc0` (0x4A4F) | `fontFamily`, looked up by index in the document's own font table (see [The font table](#the-font-table)) |
|
|
52
65
|
|
|
53
66
|
Paragraph properties, from `PapxInFkp` grpprls:
|
|
54
67
|
|
|
@@ -66,11 +79,113 @@ Paragraph properties, from `PapxInFkp` grpprls:
|
|
|
66
79
|
|
|
67
80
|
Fields are handled structurally: everything between a field-begin (`U+0013`) and a field-separator (`U+0014`) is the field's instruction and is dropped; the result between the separator and the field-end (`U+0015`) is kept. A line break (`U+000B`) inside a paragraph survives as a newline.
|
|
68
81
|
|
|
82
|
+
### The font table
|
|
83
|
+
|
|
84
|
+
`sprmCRgFtc0` names a font by an index into `SttbfFfn` ([MS-DOC] 2.9.253), a string table whose entries are `FFN` records ([MS-DOC] 2.9.87) — a fixed head of font-substitution metadata (family, weight, character set, a Panose and a `FontSignature`) this package neither reads nor writes meaningfully, followed by the font's own name as a null-terminated UTF-16 string. `src/style/fonts.ts` reads and writes this table: `parseFontTable` resolves the name at each index for `sprmCRgFtc0` to look up, and `buildFontTable` (used only by the writer) emits one entry per distinct font name a document's runs use, with every metadata field beyond the name itself zeroed — this package writes a font NAME for `ContentRun.fontFamily` to round-trip, not a font-substitution profile. Only `sprmCRgFtc0` (the default, non-East-Asian, non-complex-script font) is read or written; `sprmCRgFtc1`/`sprmCRgFtc2`/`sprmCFtcBi` are not.
|
|
85
|
+
|
|
86
|
+
## Tables
|
|
87
|
+
|
|
88
|
+
A table in [MS-DOC] is not a separate container: it is a run of ordinary paragraphs marked `sprmPFInTable`, with cell boundaries at literal `0x07` cell-mark characters in the text stream and each row closed by its own row-ending mark — a cell mark additionally carrying `sprmPFTtp` — per [MS-DOC] 2.4.3's own Overview of Tables. `src/table/` implements exactly this model, at table depth 1 only; a table nested inside a table cell is refused rather than mis-read (see the "Nested tables" row in the scope table above).
|
|
89
|
+
|
|
90
|
+
**Reading** (`table/read.ts`'s `assembleBlocks`, called from `read.ts`). It walks the flat paragraph sequence `read.ts` already produces, grouping every contiguous run of `inTable` paragraphs into a `ContentTable`: consecutive paragraphs up to and including the one terminated by an ordinary cell mark become one cell's own `blocks` (a cell may hold more than one paragraph — only its last ends in a cell mark, per 2.4.3's own "the last paragraph in a table cell is terminated by a cell mark"), and the row's own trailing mark resolves the row's whole TAP through `table/tap.ts`'s `applyTableSprms`: column boundaries and every physical cell's own merge state, read directly from `sprmTDefTable`'s `TDefTableOperand` — its `rgdxaCenter` array and its `rgTc80` array of per-column `TC80` records ([MS-DOC] 2.9.313, whose own `tcgrf` field is 2.9.317) — folded with a `sprmTMerge` range or `sprmTVertMerge` per-cell flag on top where a real producer states a merge incrementally instead, genuinely regardless of which order the two appear in within the grpprl (`table/tap.ts`'s own note). Column layout is never assumed shared across a table's own rows: [MS-DOC] 2.6.3 permits each row to declare its own independent `rgdxaCenter` ("There is no requirement that each row of a table have the same number of cells"), and a real, independent [MS-DOC] implementation (LibreOffice 26.2.5.2) was confirmed to rely on exactly this for a horizontal merge — its own merged row simply has fewer, wider physical cells, with no `TCGRF.horzMerge`/`sprmTMerge` signal at all (see the third-party verification paragraph below). `table/read.ts` reconstructs the table's shared column grid as the union of every row's own `rgdxaCenter` boundary values, then expresses each physical cell's own `colSpan` as however many of that shared grid's segments its own boundaries cover. That union is taken within one point rather than by exact integer equality, because [MS-DOC] states those boundaries per row and defines no quantum coarser than the twip itself for them, so two rows meaning the identical grid may legally disagree by a twip or two — and an exact union turns that drift into a phantom hairline column plus a spurious `colSpan` on the cells of every row either side of it (two rows one twip apart across a 2338-twip boundary read back as `columnWidthsPt` `[116.9, 0.05, 144.95, 220]` instead of `[116.9, 145, 220]`; [ExaDev/documents.js#898](https://github.com/ExaDev/documents.js/issues/898)). The default tolerance is `TWIPS_PER_POINT` itself, not a picked number: `columnWidthsPt` states the reconstructed grid in points, so a segment narrower than one point sits below the smallest unit that grid can distinguish at all. It is also the fuzz a real, independent implementation applies to an analogous reconstruct-one-shared-grid-from-N-per-row-arrays problem — LibreOffice's table model is per-row too (`SwTableLine` → `SwTableBox`, each box carrying its own width), and `sw/source/filter/inc/wrtswtbl.hxx` answers it, on its own ODF export (the point at which it projects that per-row model onto one shared grid, `sw/source/filter/xml/xmltble.cxx`'s `SwXMLTableColumn_Impl`), with `#define COLFUZZY 20` twips, `SwWriteTableCol::operator==` treating two column positions as equal when they differ by at most that. Its changeover was confirmed empirically and exactly, not assumed: patching a single `int16` inside a real LibreOffice-authored table's second row and round-tripping it through that implementation's own `.doc` import followed by its ODF export gives three columns and no covered cell for a drift of 1 through 20 twips, and four columns with a real `table:covered-table-cell` from 21 (the `.doc` import side alone preserves the drifted boundary byte-for-byte — the fuzz is applied on export, not import). Per-row drift is not hypothetical even without Word or LibreOffice's own export step in the picture: `WW8TabDesc::CalcDefaults` widens any imported cell narrower than that same implementation's own minimum cell width (`MINLAY`, 23 twips in `sw/inc/swtypes.hxx`) by mutating boundaries per row during `.doc` import itself, so a document that has been through that import is one real mechanism by which per-row drift reaches a `.doc` at all.
|
|
91
|
+
|
|
92
|
+
The one-point default is not applied unconditionally, because `MINLAY`'s own guarantee is LibreOffice's alone: this package's own writer widens nothing, so nothing stops a real producer's `rgdxaCenter` from stating a column genuinely narrower than a point, and folding that column's own two boundaries together as "drift" would silently delete it rather than fix a phantom one. The tolerance is therefore clamped, per table, to one twip below the narrowest strictly-positive gap any single row states between two of its _own_ adjacent boundaries (a zero-width gap is a legal adjacent-duplicate boundary, not a column, and is excluded) — two boundaries a row itself distinguishes are never folded together, however close, and the clamp can only ever be as generous as the tightest real column that table actually declares. Beyond that, [MS-DOC]'s own physical-cell model keeps every horizontally- and vertically-merged-away cell present in the text stream with its own cell mark and its own `TC80` entry — never omitted the way OOXML's `w:gridSpan` model omits a horizontally-merged-away `<w:tc>` outright — so a horizontal-continuation cell stated the legacy way (`TCGRF.horzMerge` = 1, still honoured for a genuine third-party producer that uses it) is folded into the preceding real cell's own `colSpan` exactly as before, while a genuinely narrower, wider physical cell (no flag, LibreOffice's own encoding) resolves to a `colSpan` greater than 1 directly from its own boundaries — both mechanisms produce the identical shape downstream. A vertical-continuation cell (`TCGRF.vertMerge` = `fvmMerge`) is kept as its own `{blocks: []}` entry — carrying its own `colSpan` too when it is also part of a horizontal-merge group in that row — with `rowSpan` computed on the anchor by scanning subsequent rows for a cell starting at the same position on the table's own shared grid, never a raw physical-array index, since two rows may genuinely have different physical cell counts and still need their vertical merges to line up correctly. Both conventions mirror `ooxml.js`'s own docx table reader exactly, since `colSpan`/`rowSpan`/`{blocks: []}` are precisely the shape `document-schema.js`'s `ContentTableCell` was designed to hold for either format's own cousin of the same merge model. A column boundary that no row in the table ever states on its own — every row happens to merge across it identically — cannot be recovered from the physical bytes at all; this is a genuine limitation of [MS-DOC]'s own physical model, not an approximation this reader chooses to make (see the third-party verification paragraph below for the confirmed reproduction, and `write.test.ts`'s own "narrows columnWidthsPt" test for the honest degraded shape this produces).
|
|
93
|
+
|
|
94
|
+
A row whose own TAP cannot be resolved this way — no direct `sprmTDefTable` anywhere in its grpprl (a producer may legally state it indirectly instead, through `sprmPTableProps`; see the `sprmPHugePapx`/`sprmPTableProps` scope row above), or a cell-mark count that disagrees with what its `TDefTableOperand` declares — degrades the _whole_ contiguous run of table-depth paragraphs back to flat paragraphs, rather than refusing the whole document: this is a legal, real-world construct this reader does not yet implement, not corruption, and paragraphs that would have become a table simply stay paragraphs instead, the identical class of degrade the `sprmPHugePapx`/`sprmPTableProps` row above already documents for ordinary paragraph formatting. A row ending mid-cell with no terminating mark at all is different in kind — the stream itself is truncated, not merely using an unsupported mechanism — and still throws `DocFormatError`.
|
|
95
|
+
|
|
96
|
+
**Writing** (`table/write.ts`'s `flattenSectionBlocks`, called from `write.ts`) is the inverse: a `ContentTable` block expands into its own real physical-cell paragraph stream, one physical cell per `ContentTableCell` — real content or a vertical-merge continuation's own `{blocks: []}` — never expanded into extra synthetic cells for a `colSpan` greater than 1. A `{blocks: []}` cell becomes a single empty paragraph, but is only written as a vertical-merge continuation (`TCGRF.vertMerge` = `fvmMerge`) when a vertical merge is genuinely still in progress at that column — tracked across rows by an `active` map keyed by column position, mirroring `ooxml.js`'s own `buildTable` exactly, since a genuinely blank cell has the identical `{blocks: []}` shape and inferring the merge from emptiness alone would silently mis-merge it with whatever real content sits above it; a continuation's own physical column span, likewise, always comes from the anchor's own recorded `colSpan` rather than the continuation cell's own (typically absent) one, so a cell merged both horizontally and vertically at once writes correctly instead of throwing. Every physical cell's own paragraphs carry `sprmPFInTable`; the row's own trailing mark additionally carries `sprmPFTtp` plus a single `sprmTDefTable` stating the row's own column layout and every cell's `TC80.tcgrf` vertical-merge state (`table/tap-write.ts`), and a `sprmTDyaRowHeight` when the row states a `heightPt`. Column widths are derived once from the table's own `columnWidthsPt`, giving every row a shared full grid of boundary points to draw from, but a row containing a horizontal merge writes its own narrower, wider `rgdxaCenter`: a `colSpan`-anchored cell's own physical boundary is the combined width of however many of the full grid's columns it spans, merged into one cell rather than kept as separate flagged ones. This is a deliberate match for how a real, independent [MS-DOC] implementation (LibreOffice 26.2.5.2) was confirmed to encode a horizontal merge — see the third-party verification paragraph below for the full ground-truth finding and [ExaDev/documents.js#895](https://github.com/ExaDev/documents.js/issues/895) for the issue it fixes. No `TCGRF.horzMerge` flag or `sprmTMerge` sprm is written for a horizontal merge at all any more; `TCGRF.horzMerge` is always 0, matching what a real producer's own merged cell states.
|
|
97
|
+
|
|
98
|
+
The Main Document's own last character MUST be an ordinary paragraph mark ([MS-DOC]'s "Main Document" glossary entry: "The last character in the main document MUST be a paragraph mark (Unicode 0x000D)") — never the row-ending mark's own cell-mark character (0x0007), even though a row mark is a perfectly legal paragraph-boundary terminator everywhere else. `write.ts`'s own top-level `writeDocContent` — not this module — is what guarantees this: whenever `flattenSectionBlocks`' own output ends in anything other than an ordinary paragraph mark (an empty section, or, the case that matters here, a section whose very last block is a table), it appends one trailing empty paragraph so the table's own row mark is never the document's final character. This is the confirmed root cause of, and fix for, [ExaDev/documents.js#892](https://github.com/ExaDev/documents.js/issues/892) — see the third-party verification paragraph immediately below for the full finding.
|
|
99
|
+
|
|
100
|
+
**Third-party verification: passing for a plain table, a vertical merge, a horizontal merge, and a cell merged both ways at once.** [ExaDev/documents.js#892](https://github.com/ExaDev/documents.js/issues/892) tracked a genuine regression an earlier draft of this README had falsely certified as passing: LibreOffice's own `.doc` import filter recognised no table at all in a `writeDocContent` sample, in any configuration — every cell's text came back concatenated into one flat paragraph, with no `table:table` element anywhere in the converted output. The root cause was found by comparing this writer's own bytes against a genuine LibreOffice-authored `.doc`, byte for byte, rather than guessing: a LibreOffice 26.2.5.2-built `.odt` table converted to `.doc` (`soffice --headless --convert-to doc`) and its `WordDocument` stream parsed directly through this package's own `PapxFkp`/grpprl primitives shows LibreOffice's own row mark stating its TAP through the identical direct `sprmTDefTable` this writer already used — ruling out the indirect-Papx hypothesis #892 had raised (see the `sprmPHugePapx`/`sprmPTableProps` scope row above). The actual difference was the document's own last character: LibreOffice's file ends in a genuine paragraph mark (0x000D) after the table's own row-ending cell mark, while this writer's output ended the whole text stream _at_ the row mark itself (0x0007) — violating [MS-DOC]'s own "Main Document" glossary entry ("The last character in the main document MUST be a paragraph mark") outright. Restoring that trailing paragraph mark (see the note above) with no other change fixed table recognition completely; reverting it (verified by hand) reproduces the original failure exactly.
|
|
101
|
+
|
|
102
|
+
The horizontal-merge gap #892 left open ([ExaDev/documents.js#895](https://github.com/ExaDev/documents.js/issues/895)) was root-caused the same way: round-tripping a LibreOffice-authored horizontal merge through its own `.doc` writer and parsing the result's raw TAP bytes with this package's own primitives shows LibreOffice does not use `TC80.tcgrf.horzMerge` _or_ `sprmTMerge` for a horizontal merge at all — the merged row's own `TDefTableOperand` genuinely has fewer, wider physical cells (`rgdxaCenter = [0, 6425, 9638]`, 2 physical cells, both `TCGRF.horzMerge = 0`) than an unmerged row in the same table (`rgdxaCenter = [0, 3212, 6425, 9638]`, 3 cells), a real per-row column layout [MS-DOC] 2.6.3 permits ("There is no requirement that each row of a table have the same number of cells"). This writer now matches that encoding (see [Writing](#writing) above) and the reader reconstructs `colSpan` from it (see the Reading paragraph above). Verified against LibreOffice 26.2.5.2 (`soffice --headless --convert-to fodt`, checking for real `table:table`/`table:table-row`/`table:table-cell` elements) for four cases: a plain 2x2 unmerged table (**passes** — genuine `table:table` structure, correct cell text and column count); a vertically merged cell (**passes** — `table:number-rows-spanned="2"` on the anchor cell and a real `table:covered-table-cell` on the row below); a horizontally merged cell (**passes** — `table:number-columns-spanned="2"` on the anchor cell and a real `table:covered-table-cell` beside it, with the table's other, unmerged row confirming 3 real columns); and a cell merged both horizontally and vertically at once (**passes** — `table:number-rows-spanned="2" table:number-columns-spanned="2"` together on the anchor, with two `table:covered-table-cell` elements on the row below it). No case regressed against the other: the same writer output that produces the merges above still passes the plain-table and vertical-merge checks unchanged.
|
|
103
|
+
|
|
104
|
+
**Third-party verification, cell decoration: passing in both directions, with one measured disagreement.** The border and shading encodings [Cell decoration](#cell-decoration) describes were established against real LibreOffice 26.2.5.2 output before any of them was implemented, not derived from the specification alone and checked afterwards. A hand-authored `.fodt` table — one cell with a `#ffff00` fill and no borders, one with four different borders (0.5pt solid `#ff0000` top, 1pt dashed `#0000ff` left, 2.5pt solid `#008000` bottom, 1.5pt dotted `#800080` right) and no fill, one with both a `#00ffff` fill and a single double top border, and a second row with neither — was converted with `soffice --headless --convert-to doc` and its row marks' raw grpprls parsed with this package's own `PapxFkp`/`Sprm` primitives. That capture is what settled every design question here: it showed borders written twice (`TC80.brcTop` = `04 01 06 00`, a `Brc80` of `dptLineWidth` 4, `brcType` 0x01, `ico` 0x06 red, alongside `sprmTSetBrc` `0b 01 02 01 ff 00 00 00 04 01 00 00`, a `TableBrcOperand` over cells [1,2) with `bordersToApply` 0x01 and an exact `#ff0000` `COLORREF`), shading written as `sprmTDefTableShd` with `cvFore` automatic, `cvBack` the fill colour and `ipat` 0, `sprmTDefTableShdRaw` and `sprmTDefTableShd80` written alongside it, and no `TC80` shading field to look for because none exists.
|
|
105
|
+
|
|
106
|
+
**Reading** that same `.doc`, this package recovers every value exactly, cross-checked against what LibreOffice itself independently recovers from the identical bytes (`soffice --headless --convert-to fodt`): the `#ffff00` and `#00ffff` fills, and all four of the second cell's borders with their exact colours, widths and `solid`/`dashed`/`dotted` styles, matching LibreOffice's own re-exported `fo:background-color` and `fo:border-*` values term for term. **Writing**, a `writeDocContent` sample carrying the same decoration opens in LibreOffice as a genuine `table:table` with correct structure and text, and its re-export carries every fill and border back: `fo:background-color="#ffff00"`, `fo:border-top="0.5pt solid #ff0000"`, `fo:border-left="1pt dashed #0000ff"`, `fo:border-bottom="2.5pt solid #008000"`, `fo:border-right="1.5pt dotted #800080"`, and — the case that proves the exact-colour layer is honoured by a reader this package did not write — a `#336699` double top border, a colour nowhere in the `Ico` palette that `TC80`'s own `Brc80` could only have approximated. The four merge cases above were re-run against the same build and all four still pass, which the byte level explains outright: an undecorated cell's four `Brc80` fields are still the all-bits-set sentinel this writer always wrote, and a row with no fills emits no shading array and no `sprmTSetBrc`, so an undecorated table's bytes are unchanged (pinned directly by `decoration.test.ts`'s own byte-for-byte expectation rather than left as an inference).
|
|
107
|
+
|
|
108
|
+
The one measured disagreement is the width of a **double** border, and it is a rendering convention rather than a byte either side reads wrongly. [MS-DOC] gives a border one `dptLineWidth` field and does not say whether it describes one line of a multi-line type or the whole stack; this package reports the field as stated, while LibreOffice reports what it renders. Both directions show it and the two are consistent: reading LibreOffice's own file, a `dptLineWidth` of 5 reads here as 0.625pt where LibreOffice's re-export calls the same border `1.8pt double`; writing, a 2pt double border comes back from LibreOffice as `6pt double`. Every single-line border — `solid`, `dashed`, `dotted`, at every width tested — agrees exactly in both directions, and a double border's own colour and style agree too; only its number differs. This is the same class of honest divergence `xls-codec`'s README records for BIFF8's named border weights, and it is stated here rather than resolved because resolving it would mean writing a width [MS-DOC] does not ask for.
|
|
109
|
+
|
|
110
|
+
Two further limits are worth stating precisely rather than leaving implied. A **non-solid shading pattern** was not verified against LibreOffice in either direction, because this package deliberately never writes one and the authored `.fodt` never produced one — the read-side behaviour (any `Ipat` beyond `ipatAuto`/`ipatSolid` resolving to no background) is pinned against hand-built bytes in `decoration.test.ts` and against the specification's own enumeration, not against a real producer's file. And the **`sprmTSetShd`/`sprmTSetShdOdd` and `Shd80` read paths** are likewise pinned against hand-built bytes and, for `Shd80`, against the very array LibreOffice wrote alongside its `Shd` one (both decode to the same colours, which is a real cross-check) — but no file was found that states shading _only_ that way, so those paths have not been exercised end to end against a third-party producer.
|
|
111
|
+
|
|
112
|
+
### Cell decoration
|
|
113
|
+
|
|
114
|
+
A cell's own background fill and per-side borders (`ContentTableCell.background`/`.borders`) are read and written, in both directions, through `src/table/decoration.ts` — the one place either direction packs or unpacks these field layouts, so the two cannot silently disagree about what a byte means, exactly the role `xls-codec`'s own `biff/xf-colors.ts` plays for BIFF8's `CellXF` payload.
|
|
115
|
+
|
|
116
|
+
**Borders live in two places at once, and both are read and written.** `TC80` ([MS-DOC] 2.9.313) carries four `Brc80MayBeNil` fields ([MS-DOC] 2.9.18, a `Brc80` — 2.9.17: an 8-bit `dptLineWidth` in 1/8-point increments, a `BrcType`, an `Ico` palette index, then `dptSpace`/`fShadow`/`fFrame`), so a border's colour there is an index into [MS-DOC] 2.9.119's fixed 17-entry palette and a colour outside it cannot be stated at all. `sprmTSetBrc` (0xD62F, a `TableBrcOperand` — [MS-DOC] 2.9.305: `cb`, an `ItcFirstLim` cell range, a `bordersToApply` side bitmask, then a `BrcMayBeNil`) restates the same border with a full 8-byte `Brc` ([MS-DOC] 2.9.16) whose `cv` is an exact `COLORREF`. A real, independent [MS-DOC] implementation writes both for every bordered cell, which is why this package reads both — `sprmTSetBrc` folding onto `TC80`'s own layer exactly as `sprmTMerge`/`sprmTVertMerge` already fold onto `sprmTDefTable`'s — and writes both. The exact-colour layer is emitted only where the palette genuinely cannot hold the colour: a black, red or yellow border is already exact in `TC80` itself, so an ordinary bordered table's row mark carries no `sprmTSetBrc` at all, which matters because a `PapxInFkp`'s whole `GrpPrlAndIstd` has to fit in 510 bytes -- the "What is not resolved" paragraph below states the column counts that bounds. A cell's four sides sharing one border are grouped into a single operand, since `bordersToApply` is a bitmask of "any subset" of the edges precisely so a producer can state them together. Both no-border spellings are read: the all-bits-set `Brc80MayBeNil`/`NilBrc` sentinel, and `BrcType` 0x00 ("No border"), which is what a real producer writes for an undecorated cell.
|
|
117
|
+
|
|
118
|
+
**Width and style are separate fields here, so neither is quantised.** Unlike BIFF8's and xlsx's own border vocabularies — which conflate weight and pattern into one token, and so need the shared named-weight bucketing `document-schema.js`'s `border-weight` module exists for — [MS-DOC] states a border's width in its own `dptLineWidth` field and its pattern in `brcType`. `widthPt` is therefore exactly `dptLineWidth / 8` in both directions for every single-line `brcType` -- the one measured exception is `double`, where this exact spelling disagrees with what a real consumer renders by a factor of three; see the "Third-party verification, cell decoration" paragraph below for the confirmed numbers, since this is a rendering-convention gap rather than a byte either side reads wrongly -- with [MS-DOC]'s own floor applied on read ("Values of less than 2 are considered to be equivalent to 2", which is also what keeps a `widthPt` positive as `ContentBorderSchema` requires), and a width outside the single byte's own 0.25–31.875pt range is refused on write rather than silently narrowed. `brcType` maps onto `ContentStrokeStyle`'s four members with three families collapsing, each stated rather than silently folded: the dash family (`dotDash`, `dotDotDash`, `dashSmallGap`, `dashDotStroked`) to `dashed`, since `ContentStrokeStyle` names one dashed pattern rather than a vocabulary of them; every genuinely multi-line border (`triple`, the nine `thinThick`/`thickThin` gap variants, `doubleWave`, `threeDEmboss`/`threeDEngrave`, `outset`/`inset`) to `double`; and the single wavy line to `solid`, being one continuous stroke. The art/image border types (0x40–0xE3) have no mapping at all and read as no border, because [MS-DOC] 2.9.22 permits them only "if they describe a page border" — never a cell border — so approximating one would invent a fact the file does not state. [MS-DOC]'s automatic border colour (`Ico` 0x00, or a `COLORREF` with `fAuto` set) resolves to black rather than dropping the border: it names no components, `ContentBorder.color` is required, and the border itself genuinely renders — dropping it to avoid stating a colour would lose strictly more than approximating one does.
|
|
119
|
+
|
|
120
|
+
**Shading has no `TC80` field at all.** Earlier drafts of this README described `ContentTableCell.background` as unread "from `TC80`'s own … shading fields"; `TC80` has none — it is `tcgrf`, `wWidth`, and the four borders, and nothing else. A row's shading rides its own sprms, each carrying one `Shd` ([MS-DOC] 2.9.247: `cvFore`, `cvBack`, and an `Ipat` pattern index — 2.9.121) per cell: `sprmTDefTableShd`/`2nd`/`3rd` (0xD612/0xD616/0xD60C, a `DefTableShdOperand` — [MS-DOC] 2.9.53 — covering cells 1–22, 23–44 and 45–63 respectively, split across three opcodes because one operand's `rgShd` "MUST NOT exceed 22 elements"), their `Raw` counterparts (0xD670–0xD672, which differ only in how `ShdNil` behaves inside a table style, a layer this package neither reads nor writes), the Word 97-era `sprmTDefTableShd80` (0xD609, the same array as packed 2-byte `Shd80` values over the `Ico` palette), and `sprmTSetShd`/`sprmTSetShdOdd` (0xD62D/0xD62E, a `TableShadeOperand` naming one cell range — the "Odd" spelling applying to every other cell from `itcFirst`, per 2.6.3's own worked example). All of them are read, folded in grpprl order so a later one overrides an earlier one, which is the precedence a real producer relies on when it writes several for the same row. Only `sprmTDefTableShd`/`2nd`/`3rd` are written.
|
|
121
|
+
|
|
122
|
+
Two `Ipat` patterns produce a flat fill and resolve: `ipatAuto` ("clear"), under which the cell shows its own `cvBack` — which is how both Word and LibreOffice spell a plain background colour, and the only pattern this package writes — and `ipatSolid`, under which it shows `cvFore`. Every other `Ipat` is a genuine pattern one `Color` cannot express: the fourteen percentage fills, the stripe and crosshatch families, and `ipatNil`. Each reads as **no background** rather than as one of its two colours, because reporting a 50% grey crosshatch as its own foreground colour would misstate what the cell actually shows — the same deliberate judgment `xls-codec` makes for BIFF8's own non-solid `FillPattern` values, and it costs nothing on a round trip since this writer emits `ipatAuto` and nothing else. A `cvAuto` colour is likewise no background, which is what makes `ShdAuto` and `ShdNil` — [MS-DOC]'s own two "no shading is applied" values — fall out with no special case, each being a pair of automatic colours under `ipatAuto`.
|
|
123
|
+
|
|
124
|
+
A horizontal-merge group's decoration is the anchor cell's own, since [MS-DOC] renders a continuation cell's "contents and formatting" not at all. A vertical-merge continuation's decoration is dropped on read and never written: a continuation is `{blocks: []}` by the shared schema's own convention, and giving one a background or borders would make it indistinguishable from a real, decorated, genuinely blank cell on the way back out.
|
|
125
|
+
|
|
126
|
+
**What is not resolved.** `sprmTMerge` and `sprmTVertMerge` are both still read (folded onto `sprmTDefTable`'s own layout, for a genuine third-party producer that states a merge that way) but neither is written any more — this writer states a horizontal merge purely through a merged row's own narrower, wider physical cells (see [Writing](#writing) above), and a vertical merge only through `TC80.tcgrf`. Every table-level TAP sprm beyond the merge, height and decoration sprms listed above and in [Cell decoration](#cell-decoration) — absolute position, table style, cell padding, cell spacing, vertical alignment, the row- and table-level border cascade the scope table names, and the rest of [MS-DOC] 2.6.3's roughly seventy table sprms — is unread and unwritten, exactly as the read-side scope note already states for ordinary paragraph sprms this package does not convert.
|
|
127
|
+
|
|
128
|
+
A wide, heavily decorated table is refused rather than truncated, and the bound is the format's own rather than this package's. A row's whole TAP travels in the row-ending mark's `PapxInFkp` record, whose `GrpPrlAndIstd` cannot exceed 510 bytes ([MS-DOC] 2.9.175, and `prop/fkp-write.ts`'s own `MAX_GRP_PRL_AND_ISTD`), and `sprmTDefTable` alone costs 22 bytes per column: about 22 columns is the ceiling for an undecorated row, dropping as shading (10 bytes per cell) and exact-colour borders (12 bytes per distinct border group) are added. Past it, `writeDocContent` throws the same `DocFormatError` it always did for an over-large paragraph record — "a single paragraph-formatting record does not fit in one 512-byte formatted disk page" — rather than dropping decoration to fit. [MS-DOC]'s own answer to this is `sprmPHugePapx`, which stores an over-large grpprl indirectly in the Data stream; that is the unimplemented layer the scope table above already names, and it is what a future wider-table writer would need.
|
|
129
|
+
|
|
130
|
+
A genuine, if narrow, information-loss case remains inherent to the physical model itself rather than a gap in this package: when literally every row of a table merges across the identical column boundary (a single-row table with one merged cell is the simplest case), no row's own `rgdxaCenter` ever states that boundary, so a round trip cannot recover it — `columnWidthsPt` narrows to however many columns the physical bytes actually distinguish, and the merged cell's own `colSpan` comes back `undefined` rather than the value it was written with (`write.test.ts`'s own "narrows columnWidthsPt" test states this precisely). A table with at least one row that does not merge across the same span — the common case, since a merge is usually a header row sitting above ordinary data rows — round-trips `colSpan` and `columnWidthsPt` exactly.
|
|
131
|
+
|
|
132
|
+
A table's own horizontal position is not read or written either, and this one is a schema boundary rather than a gap in this package. `rgdxaCenter`'s first entry is "the horizontal position of the logical left edge of the table, as indented from the logical left page margin" ([MS-DOC] 2.9.321), and `sprmTDxaLeft`/`sprmTDxaGapHalf`/`sprmTWidthBefore` state the same fact incrementally — but `document-schema.js`'s `ContentTable` carries only `rows` and `columnWidthsPt`, with no field on the table or on a row that could hold a horizontal offset, so a table indent is dropped on read and every row this writer emits starts at 0. No codec in this family models a table indent, so nothing downstream would have anywhere to put one. This is already live in the simple case: a LibreOffice table with `fo:margin-left="1.27cm"` writes `rgdxaCenter = [720, 2884, 5567, 9638]` in **every** row, and reads back as `columnWidthsPt` `[108.2, 134.15, 203.55]` with the 720-twip indent gone. Because [MS-DOC] states the boundary array per row, two rows of one table may also legally begin at different positions — Word's own default for an unindented table is `-108` rather than 0 (confirmed against LibreOffice's own WW8 importer source, which carries `-108` as a named constant with the comment "Word sets the first nCenter value to -108 when no indent is used"; it is plausibly the format's own 108-twip default cell margin, `sprmTCellPaddingDefault`, compensated for, but neither [MS-DOC] nor that source states the two facts are linked, so take the value as confirmed and the reason as a reasonable guess) — so a table one of whose rows carries a real leading indent has rows at `-108` and `0`. Those rows genuinely occupy different horizontal extents, and the reconstructed grid honestly carries the extra boundary between them, with the wider rows' first cell spanning both segments. That is not the twip-drift case above and is deliberately not absorbed by its tolerance: verified against LibreOffice 26.2.5.2, which reads the identical bytes into the identical grid — four columns, a `table:number-columns-spanned="2"` anchor and a real `table:covered-table-cell` on the rows that start further left. The one cosmetic difference is that LibreOffice pads the short row with an empty filler cell so every row covers the full grid, which this reader does not: `ContentTableRow.cells` carries no grid-position field, so a reader-invented empty cell would be indistinguishable from real empty content on the write side, and `table/write.ts` reconstructs each row's own narrower `rgdxaCenter` from spans without needing one.
|
|
133
|
+
|
|
134
|
+
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.
|
|
135
|
+
|
|
136
|
+
## Numbering definitions
|
|
137
|
+
|
|
138
|
+
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.
|
|
139
|
+
|
|
140
|
+
**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.
|
|
141
|
+
|
|
142
|
+
**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.
|
|
143
|
+
|
|
144
|
+
**What is deliberately not resolved**, each a genuine layer of the format rather than an oversight:
|
|
145
|
+
|
|
146
|
+
- **`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.
|
|
147
|
+
- **`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.
|
|
148
|
+
- **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`.
|
|
149
|
+
|
|
150
|
+
**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.
|
|
151
|
+
|
|
152
|
+
## Metadata
|
|
153
|
+
|
|
154
|
+
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.
|
|
155
|
+
|
|
156
|
+
The mapping is not 1:1, and each gap is permanent rather than a remaining TODO:
|
|
157
|
+
|
|
158
|
+
| Direction | Fields covered | Gap |
|
|
159
|
+
| ----------------------------------- | -------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
160
|
+
| SummaryInformation → LayoutMetadata | `title`, `subject`, `author`, `keywords`, `createdIso`, `lastSavedIso` → `modifiedIso` | `comments` and `lastPrintedIso` have no LayoutMetadata field to land in — no other codec in the family has a "last printed" or free-text "comments" concept, so these are read from the stream but never reach a `ContentDocument`. |
|
|
161
|
+
| LayoutMetadata → SummaryInformation | the same six fields, in reverse | `creator`, `producer`, and `language` have no SummaryInformation equivalent: `producer` is a PDF-only concept in this schema, and `creator`/`language` are not among the fields the stream this package writes covers. |
|
|
162
|
+
|
|
163
|
+
Only the fixed SummaryInformation property set is read or written — the sibling `"\x05DocumentSummaryInformation"` stream (company, manager, and custom user-defined properties, [MS-OLEPS]'s two-property-set spelling) is not attempted at all, an explicit scope boundary `archive-codec`'s own `oleps` support shares.
|
|
164
|
+
|
|
165
|
+
## Writing
|
|
166
|
+
|
|
167
|
+
`writeDocContent` takes a `'wordprocessing'` `ContentDocument` with exactly one section and produces real [MS-DOC] bytes wrapped in a real [MS-CFB] compound file, inverting every read-side structure listed above: a real piece table (`text/piece-table-write.ts`, always one uncompressed 16-bit piece — see [Why always uncompressed](#why-the-writer-always-writes-uncompressed-text)), `Sprm`-encoded grpprls for each run's and paragraph's own direct formatting (`prop/chp-write.ts`, `prop/pap-write.ts`), `ChpxFkp`/`PapxFkp` pages packed and split across as many 512-byte pages as the content needs (`prop/fkp-write.ts`), a spec-conformant style sheet carrying zero styles (`style/stsh.ts`'s `buildEmptyStsh` — `FibRgFcLcb97.lcbStshf` "MUST be a nonzero value", so a document is never written without one, even though this package's own reader tolerates a missing one), and a font table when at least one run names a font (`style/fonts.ts`).
|
|
168
|
+
|
|
169
|
+
Character properties this writer converts, the exact inverse of [What is converted](#what-is-converted)'s character table above: `bold`, `italic`, `strike`, `underline` (as `kulSingle`, the only style a plain boolean can express), `sizePt`, `color` (via `sprmCCv`'s exact `COLORREF`, never the lossy 17-entry `sprmCIco` palette), and `fontFamily`. Paragraph properties: `alignment` (the four `ST_Jc`-aligned values this package's reader itself maps — `left`/`center`/`right`/`justify`), `indentLeftPt`, `indentFirstLinePt`, `spacingBeforePt`, `spacingAfterPt`, `lineSpacing` (only `LSPD`'s multiplier form, matching the reader), and `pageBreakBefore`.
|
|
170
|
+
|
|
171
|
+
**Deliberately not handled**, beyond what the read-side scope table above already states applies to both directions:
|
|
172
|
+
|
|
173
|
+
| Absent | Consequence |
|
|
174
|
+
| ------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
175
|
+
| **Paragraph styles** | Every paragraph is written with `istd` 0 ("Normal"); `ContentParagraph.styleId` and `.headingLevel` are not written, and the style sheet this writer produces carries no styles for a future writer to target. |
|
|
176
|
+
| **More than one section** | `writeDocContent` throws `DocUnsupportedError` for a `ContentDocument` with more than one `ContentSection`, rather than silently concatenating their blocks into what this package's own reader would read back as one anyway. |
|
|
177
|
+
| **Non-paragraph blocks other than tables** | An image, page break, embedded object, or construct-boundary marker block throws `DocUnsupportedError` naming its own `kind` — `ContentTable` is now written (see [Tables](#tables)); a non-paragraph block found inside one of its own cells throws the identical error. |
|
|
178
|
+
| **An empty `ContentDocument.sections[0].blocks`** | Written as a single paragraph with no runs — [MS-DOC] 2.4.2 requires the Main Document's own text to end in a paragraph mark, so an otherwise-empty section still needs one to hold it, exactly as a real producer's own blank document has one. |
|
|
179
|
+
|
|
180
|
+
### Why the writer always writes uncompressed text
|
|
181
|
+
|
|
182
|
+
`writeDocContent` writes every piece as 16-bit (uncompressed) text, never the 8-bit compressed spelling the reader also understands. A compressed piece can only represent the bytes [MS-DOC] 2.4.1's own compressed-character table maps ([`COMPRESSED_CHARACTER_MAP`](src/text/characters.ts), effectively Windows-1252's high range with four gaps the specification itself leaves undefined), so writing compressed text would mean rejecting or mis-encoding any run outside that range — every character outside Latin-1 entirely, and four Windows-1252 code points [MS-DOC] does not define a mapping for. Always writing uncompressed sidesteps the whole question: every UTF-16 code unit, including each half of a surrogate pair for a character outside the Basic Multilingual Plane, round-trips through a 16-bit piece with no byte-mapping table to invert, verified in `write.test.ts` against accented Latin, CJK and an emoji surrogate pair together in one run.
|
|
183
|
+
|
|
69
184
|
## Architecture
|
|
70
185
|
|
|
71
|
-
This package hand-parses [MS-DOC] against its published field tables. It depends on no third-party `.doc` reader, and its ESLint configuration bans several by name (`word-extractor`, `mammoth`, `textract`, the `cfb` package) so the decision is enforced rather than merely intended — the same bet `markdown-codec` makes against every markdown library and `pdf-codec` against `pdf-lib`.
|
|
186
|
+
This package hand-parses and hand-writes [MS-DOC] against its published field tables. It depends on no third-party `.doc` reader or writer, and its ESLint configuration bans several by name (`word-extractor`, `mammoth`, `textract`, the `cfb` package) so the decision is enforced rather than merely intended — the same bet `markdown-codec` makes against every markdown library and `pdf-codec` against `pdf-lib`.
|
|
72
187
|
|
|
73
|
-
It depends on exactly two siblings: [`archive-codec`](../archive-codec/README.md) for the [MS-CFB] container, and [`document-schema.js`](../document-schema.js/README.md) for the content pivot it
|
|
188
|
+
It depends on exactly two siblings: [`archive-codec`](../archive-codec/README.md) for the [MS-CFB] container — `readCompoundFile` on the read side, `writeCompoundFile` on the write side — and [`document-schema.js`](../document-schema.js/README.md) for the content pivot it reads into and writes from. It does not depend on `ooxml.js`, and `ooxml.js` does not depend on it: `.doc` and `.docx` are unrelated formats that happen to share an application, and the only thing they genuinely have in common is the `ContentDocument` both target.
|
|
74
189
|
|
|
75
190
|
```mermaid
|
|
76
191
|
graph TD
|
|
@@ -84,19 +199,32 @@ graph TD
|
|
|
84
199
|
|
|
85
200
|
The modules layer in the order [MS-DOC]'s own algorithms chain:
|
|
86
201
|
|
|
87
|
-
| Module
|
|
88
|
-
|
|
|
89
|
-
| `src/bytes.ts`
|
|
90
|
-
| `src/plc.ts`
|
|
91
|
-
| `src/fib/`
|
|
92
|
-
| `src/text/piece-table.ts`
|
|
93
|
-
| `src/text/characters.ts`
|
|
94
|
-
| `src/text/special.ts`
|
|
95
|
-
| `src/
|
|
96
|
-
| `src/prop/
|
|
97
|
-
| `src/prop/
|
|
98
|
-
| `src/
|
|
99
|
-
| `src/
|
|
202
|
+
| Module | What it does |
|
|
203
|
+
| ------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
|
204
|
+
| `src/bytes.ts` | Bounds-checked little-endian reads; every offset in the format is attacker-controlled data, so an over-read fails loudly. |
|
|
205
|
+
| `src/plc.ts` | The `PLC` container shape, whose element count is derived from its total size by [MS-DOC] 2.2.2's own formula, and the "largest key at most" lookup every algorithm phrases in those words. |
|
|
206
|
+
| `src/fib/` | The FIB's field offsets, derived by summing the declared field sizes, and the parse that reads the counts and offsets from them. |
|
|
207
|
+
| `src/text/piece-table.ts` | The `Clx` and its `PlcPcd`, and the character-position-to-byte-offset mapping. |
|
|
208
|
+
| `src/text/characters.ts` | Text reconstruction, including the compressed-byte mapping table. |
|
|
209
|
+
| `src/text/special.ts` | The characters that carry structure rather than glyphs. |
|
|
210
|
+
| `src/color.ts` | The two colour encodings the format uses throughout, shared by every property family that states one: [MS-DOC] 2.9.119's fixed 17-entry `Ico` palette (both directions, including the nearest-entry quantisation `Brc80` needs) and 2.9.43's exact `COLORREF`. |
|
|
211
|
+
| `src/prop/sprm.ts` | `Sprm` decoding and the operand-size table that makes a grpprl walkable. |
|
|
212
|
+
| `src/prop/fkp.ts` | The formatted disk pages and the bin tables that address them. |
|
|
213
|
+
| `src/prop/chp.ts`, `src/prop/pap.ts` | Folding a grpprl into character and paragraph properties. |
|
|
214
|
+
| `src/style/stsh.ts` | The style sheet. |
|
|
215
|
+
| `src/style/fonts.ts` | The font table (`SttbfFfn`/`FFN`) — read and write together, since both directions share one small, self-contained field layout. |
|
|
216
|
+
| `src/metadata.ts` | Wraps `archive-codec`'s own `SummaryInformationProperties` <-> `LayoutMetadata` mapping with this package's `createdIso`/`modifiedIso` date validation, throwing `DocFormatError` for a malformed one rather than letting an opaque `RangeError` escape the FILETIME conversion (see [Metadata](#metadata)). |
|
|
217
|
+
| `src/table/decoration.ts` | A cell's border and background-shading encodings -- `Brc80`, `Brc`, `Shd`, `Shd80`, and the `BrcType`/`Ipat` vocabularies -- read and written in one place, so neither direction can drift from the other (see [Cell decoration](#cell-decoration)). |
|
|
218
|
+
| `src/table/tap.ts` | Folding a table row's own sgc-5 grpprl into its TAP — column boundaries and every physical cell's merge state from `sprmTDefTable`, folded with a `sprmTMerge` range or `sprmTVertMerge` flag where one is present, regardless of which order they appear in. |
|
|
219
|
+
| `src/table/read.ts` | Grouping a contiguous run of table-depth paragraphs (from `read.ts`'s own flat sequence) into a real `ContentTable`, refusing a nested table. |
|
|
220
|
+
| `src/read.ts` | The whole read chain, to a `ContentDocument`. |
|
|
221
|
+
| `src/fib/write.ts` | Builds a real FIB for nFib 0x00C1 (Word 97), populated with the fc/lcb pairs this package's own writer needs. |
|
|
222
|
+
| `src/text/piece-table-write.ts` | Builds a `Clx` describing the whole logical text stream as one uncompressed piece. |
|
|
223
|
+
| `src/prop/chp-write.ts`, `src/prop/pap-write.ts` | The inverse of `chp.ts`/`pap.ts`: a run's or paragraph's direct properties to a grpprl. |
|
|
224
|
+
| `src/prop/fkp-write.ts` | Packs formatting exceptions into `ChpxFkp`/`PapxFkp` pages, splitting across as many as the content needs, and builds the bin tables addressing them. |
|
|
225
|
+
| `src/table/tap-write.ts` | The inverse of `table/tap.ts`: a row's column boundaries, cell merge state, cell decoration and height to a `sprmTDefTable`/`sprmTDefTableShd`/`sprmTSetBrc`/`sprmTDyaRowHeight` grpprl. |
|
|
226
|
+
| `src/table/write.ts` | Expanding a `ContentTable` into its own real physical-cell paragraph stream, for `write.ts`'s own paragraph pipeline to lay out like any other paragraph. |
|
|
227
|
+
| `src/write.ts` | The whole write chain, from a `ContentDocument` to real [MS-DOC] bytes in a real [MS-CFB] compound file. |
|
|
100
228
|
|
|
101
229
|
### Why the piece table gets the most attention
|
|
102
230
|
|
|
@@ -115,33 +243,47 @@ pnpm test
|
|
|
115
243
|
```
|
|
116
244
|
|
|
117
245
|
```ts
|
|
118
|
-
import { readDocContent, isDocBytes } from "doc-codec";
|
|
246
|
+
import { readDocContent, writeDocContent, isDocBytes } from "doc-codec";
|
|
119
247
|
|
|
120
248
|
const bytes = new Uint8Array(await file.arrayBuffer());
|
|
121
249
|
if (isDocBytes(bytes)) {
|
|
122
250
|
const document = readDocContent(bytes);
|
|
123
251
|
// document.kind === "wordprocessing"
|
|
124
252
|
}
|
|
253
|
+
|
|
254
|
+
const written = writeDocContent({
|
|
255
|
+
kind: "wordprocessing",
|
|
256
|
+
metadata: {},
|
|
257
|
+
sections: [
|
|
258
|
+
{
|
|
259
|
+
pageSize: { widthPt: 612, heightPt: 792 },
|
|
260
|
+
margins: { topPt: 72, rightPt: 72, bottomPt: 72, leftPt: 72 },
|
|
261
|
+
blocks: [{ kind: "paragraph", runs: [{ text: "Hello.", bold: true }] }],
|
|
262
|
+
},
|
|
263
|
+
],
|
|
264
|
+
});
|
|
125
265
|
```
|
|
126
266
|
|
|
127
|
-
`readDocContent` throws a `DocFormatError` when the bytes do not conform to [MS-DOC], and a `DocUnsupportedError` when they conform but use a feature this package deliberately refuses rather than approximates (encryption, or the `sprmPChgTabs` sentinel above).
|
|
267
|
+
`readDocContent` throws a `DocFormatError` when the bytes do not conform to [MS-DOC], and a `DocUnsupportedError` when they conform but use a feature this package deliberately refuses rather than approximates (encryption, or the `sprmPChgTabs` sentinel above). `writeDocContent` throws a `DocUnsupportedError` for a document, section count, or block kind outside its own scope (see [Writing](#writing)) and a `DocFormatError` for a value that would need a property out of a sprm's own operand range (a font size or indent too large to fit its 2-byte operand, for instance).
|
|
128
268
|
|
|
129
269
|
## Worker-isomorphic
|
|
130
270
|
|
|
131
|
-
Like every foundation and format-codec package in this family, `doc-codec`'s published `src/` imports no `node:*` module and uses no Node-only global. The whole surface is byte arithmetic over `Uint8Array` and `DataView`, with no I/O of its own. A `test:workers` suite runs the reader inside workerd, the real Cloudflare Workers runtime, so the property is a runtime-checked fact rather than an assertion.
|
|
271
|
+
Like every foundation and format-codec package in this family, `doc-codec`'s published `src/` imports no `node:*` module and uses no Node-only global. The whole surface is byte arithmetic over `Uint8Array` and `DataView`, with no I/O of its own. A `test:workers` suite runs both the reader and the writer inside workerd, the real Cloudflare Workers runtime, so the property is a runtime-checked fact rather than an assertion.
|
|
132
272
|
|
|
133
273
|
## Testing
|
|
134
274
|
|
|
135
|
-
Every structure is tested against bytes hand-assembled from [MS-DOC]'s own field tables rather than dumped from a real Word file, and the test-support builders (`src/test-support/`) place each field by adding up the specification's declared sizes while the parsers read them from independently derived constants — so the two agree only if both match the specification. `buildDoc` assembles a whole synthetic `.doc`: a real compound file, a real FIB, a real piece table, real FKP pages, and a real style sheet, wired together with the offsets a producer would compute.
|
|
275
|
+
Every structure is tested against bytes hand-assembled from [MS-DOC]'s own field tables rather than dumped from a real Word file, and the read-side test-support builders (`src/test-support/`) place each field by adding up the specification's declared sizes while the parsers read them from independently derived constants — so the two agree only if both match the specification. `buildDoc` assembles a whole synthetic `.doc`: a real compound file, a real FIB, a real piece table, real FKP pages, and a real style sheet, wired together with the offsets a producer would compute.
|
|
276
|
+
|
|
277
|
+
The writer is verified the opposite way: `src/write.test.ts` reads every document `writeDocContent` produces back through this package's own `readDocContent`, including cases that force `ChpxFkp`/`PapxFkp` page-splitting (150 distinctly-formatted runs, 60 distinctly-indented paragraphs) rather than relying only on the common one-page case, and a dedicated `describe("writeDocContent tables")` block covering row/column/cell round-tripping, a multi-paragraph cell, row height, a horizontally merged cell's `colSpan`, a vertically merged cell's `rowSpan`, the nested-table refusal, and a `describe("cell decoration")` block round-tripping a background fill, all four borders at different styles/widths/colours, a partially bordered cell, a cell with no decoration at all (which must emit none), decoration on a merged cell, and a colour the `Ico` palette cannot hold. `src/table/decoration.test.ts` covers the same vocabulary one layer down, against bytes hand-built from the specification's own field tables -- including every encoding this package's own writer never emits, which a round trip therefore cannot reach: both no-border spellings, each `BrcType` family's collapse onto `ContentStrokeStyle`, the art-border and automatic-colour cases, `ipatSolid` and the non-flat patterns, `Shd80`, and the second and third shading arrays a row too wide for one `PapxInFkp` record can never exercise end to end. Beyond the committed suite, a `writeDocContent` sample carrying every character and paragraph property this writer supports was opened, rendered, and re-exported by a real, independent [MS-DOC] implementation — LibreOffice — without error or visible content loss, confirming those bytes are genuinely conformant to a reader this package did not write, not merely self-consistent with its own. Table samples were checked the same way and now pass in both directions -- plain, vertically merged, horizontally merged, merged both ways, and decorated with cell fills and per-side borders -- after [ExaDev/documents.js#892](https://github.com/ExaDev/documents.js/issues/892) and [#895](https://github.com/ExaDev/documents.js/issues/895) were each root-caused by comparing this writer's own bytes against a genuine LibreOffice-authored `.doc`; see [Tables](#tables) for the full findings and for the one measured disagreement that remains.
|
|
136
278
|
|
|
137
|
-
There is no real-world conformance corpus
|
|
279
|
+
There is no real-world conformance corpus on the read side, and the write side inherits the same gap for the same reason: the tests prove this package matches the published specification, which is not the same as proving it matches what Word itself reads or writes between 1997 and 2007. Anyone extending this package should treat a corpus as the next thing worth building.
|
|
138
280
|
|
|
139
281
|
## Specification
|
|
140
282
|
|
|
141
283
|
Every structure in this package cites the section of [MS-DOC] it implements. The specification is published by Microsoft under its Open Specifications programme:
|
|
142
284
|
|
|
143
|
-
- [[MS-DOC]: Word (.doc) Binary File Format](https://learn.microsoft.com/en-us/openspecs/office_file_formats/ms-doc/) — in particular [Fib](https://learn.microsoft.com/en-us/openspecs/office_file_formats/ms-doc/9aeaa2e7-4a45-468e-ab13-3f6193eb9394), [Retrieving Text](https://learn.microsoft.com/en-us/openspecs/office_file_formats/ms-doc/01d5d8c4-cf9c-4ef9-80fd-439e763cfe01), [Clx](https://learn.microsoft.com/en-us/openspecs/office_file_formats/ms-doc/bad26767-b575-44d3-9da3-96378d56ce14), [FcCompressed](https://learn.microsoft.com/en-us/openspecs/office_file_formats/ms-doc/aa2e55a2-f4f2-4795-bab5-6d9d7a0ed249), [ChpxFkp](https://learn.microsoft.com/en-us/openspecs/office_file_formats/ms-doc/f5f10f04-d4cc-4ebd-86df-0de6d227675c), [PapxFkp](https://learn.microsoft.com/en-us/openspecs/office_file_formats/ms-doc/34aaeaf3-9578-41af-a3f5-c12f6f66bf1b), [PapxInFkp](https://learn.microsoft.com/en-us/openspecs/office_file_formats/ms-doc/580510b8-df7a-467e-a51c-0d71eb15c7cd), [Sprm](https://learn.microsoft.com/en-us/openspecs/office_file_formats/ms-doc/099eb99c-a927-4caf-a80c-66254ea83d6a), [Character Properties](https://learn.microsoft.com/en-us/openspecs/office_file_formats/ms-doc/7022285b-9621-42e9-ad4d-4e02c115ef18), [Paragraph Properties](https://learn.microsoft.com/en-us/openspecs/office_file_formats/ms-doc/484822ee-a9d9-4af4-8423-29fda67a6a58),
|
|
144
|
-
- [[MS-CFB]: Compound File Binary File Format](https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-cfb/) — the container, read through `archive-codec`.
|
|
285
|
+
- [[MS-DOC]: Word (.doc) Binary File Format](https://learn.microsoft.com/en-us/openspecs/office_file_formats/ms-doc/) — in particular [Fib](https://learn.microsoft.com/en-us/openspecs/office_file_formats/ms-doc/9aeaa2e7-4a45-468e-ab13-3f6193eb9394), [FibRgFcLcb97](https://learn.microsoft.com/en-us/openspecs/office_file_formats/ms-doc/0c9df81f-98d0-454e-ad84-b612cd05b1a4), [Retrieving Text](https://learn.microsoft.com/en-us/openspecs/office_file_formats/ms-doc/01d5d8c4-cf9c-4ef9-80fd-439e763cfe01), [Clx](https://learn.microsoft.com/en-us/openspecs/office_file_formats/ms-doc/bad26767-b575-44d3-9da3-96378d56ce14), [FcCompressed](https://learn.microsoft.com/en-us/openspecs/office_file_formats/ms-doc/aa2e55a2-f4f2-4795-bab5-6d9d7a0ed249), [ChpxFkp](https://learn.microsoft.com/en-us/openspecs/office_file_formats/ms-doc/f5f10f04-d4cc-4ebd-86df-0de6d227675c), [PapxFkp](https://learn.microsoft.com/en-us/openspecs/office_file_formats/ms-doc/34aaeaf3-9578-41af-a3f5-c12f6f66bf1b), [PapxInFkp](https://learn.microsoft.com/en-us/openspecs/office_file_formats/ms-doc/580510b8-df7a-467e-a51c-0d71eb15c7cd), [Sprm](https://learn.microsoft.com/en-us/openspecs/office_file_formats/ms-doc/099eb99c-a927-4caf-a80c-66254ea83d6a), [Character Properties](https://learn.microsoft.com/en-us/openspecs/office_file_formats/ms-doc/7022285b-9621-42e9-ad4d-4e02c115ef18), [Paragraph Properties](https://learn.microsoft.com/en-us/openspecs/office_file_formats/ms-doc/484822ee-a9d9-4af4-8423-29fda67a6a58), [STSH](https://learn.microsoft.com/en-us/openspecs/office_file_formats/ms-doc/c8ee0f39-02c3-4caa-b27a-6a97600130fe), [STTB](https://learn.microsoft.com/en-us/openspecs/office_file_formats/ms-doc/4a491aed-ad45-4b41-910b-082c71d5ef14), [SttbfFfn](https://learn.microsoft.com/en-us/openspecs/office_file_formats/ms-doc/18b7d35b-ad29-4723-893b-82aa30c64ced), [FFN](https://learn.microsoft.com/en-us/openspecs/office_file_formats/ms-doc/ff407d64-3478-4b56-9b98-6dbcfc66a4ae), [Overview of Tables](https://learn.microsoft.com/en-us/openspecs/office_file_formats/ms-doc/5b45f0e7-7760-4fdb-af88-0146de2feb4c), [Table Properties](https://learn.microsoft.com/en-us/openspecs/office_file_formats/ms-doc/b39a6648-501c-4361-8366-4f042f579469), [TDefTableOperand](https://learn.microsoft.com/en-us/openspecs/office_file_formats/ms-doc/de06ec41-a0ac-4046-9096-cdfaa0091ad9), [TC80](https://learn.microsoft.com/en-us/openspecs/office_file_formats/ms-doc/9dd62a79-8c0b-4b11-99ee-05742ae7cf6d), [TCGRF](https://learn.microsoft.com/en-us/openspecs/office_file_formats/ms-doc/11bf5b1c-943f-421d-bbf3-39088cd1b8dd), [VerticalMergeFlag](https://learn.microsoft.com/en-us/openspecs/office_file_formats/ms-doc/1af35534-516e-4b58-986e-f2084bd6d56f), [VertMergeOperand](https://learn.microsoft.com/en-us/openspecs/office_file_formats/ms-doc/cf7489ac-7eee-404d-8844-8ebbd279b77d), [Brc80MayBeNil](https://learn.microsoft.com/en-us/openspecs/office_file_formats/ms-doc/8458edbd-c81c-4ec7-b5ff-c99c50575301), [Brc80](https://learn.microsoft.com/en-us/openspecs/office_file_formats/ms-doc/cfab8014-e477-4e33-b50f-a23b8476f6f3), [Brc](https://learn.microsoft.com/en-us/openspecs/office_file_formats/ms-doc/5d99e9e0-d7a4-488a-91fb-6c046277e076), [BrcMayBeNil](https://learn.microsoft.com/en-us/openspecs/office_file_formats/ms-doc/4e2ba2c9-8763-4b29-b81c-ce0123df689e), [BrcType](https://learn.microsoft.com/en-us/openspecs/office_file_formats/ms-doc/6d25d117-9fa4-42ed-af5b-672771a9d1be), [Ico](https://learn.microsoft.com/en-us/openspecs/office_file_formats/ms-doc/b7327097-ac00-4a0a-ac34-770e4b4ff9e1), [COLORREF](https://learn.microsoft.com/en-us/openspecs/office_file_formats/ms-doc/ddb07674-737a-42a8-87d4-b9b6dc924f18), [TableBrcOperand](https://learn.microsoft.com/en-us/openspecs/office_file_formats/ms-doc/d8a2205c-93cd-4f79-a0be-2656cd6d6fcc), [Shd](https://learn.microsoft.com/en-us/openspecs/office_file_formats/ms-doc/cee0b9de-272e-4898-994c-bae4627e5abb), [Shd80](https://learn.microsoft.com/en-us/openspecs/office_file_formats/ms-doc/46589ef4-c686-4ff6-ad84-82c15d631491), [Ipat](https://learn.microsoft.com/en-us/openspecs/office_file_formats/ms-doc/3f7d0bde-4e59-4395-a6d6-a1242c577e3f), [DefTableShdOperand](https://learn.microsoft.com/en-us/openspecs/office_file_formats/ms-doc/d415bdd8-63cb-44b9-941b-4999430d6ac9), [DefTableShd80Operand](https://learn.microsoft.com/en-us/openspecs/office_file_formats/ms-doc/c7f5b196-7933-4bc4-9bdf-3dd87393bd17), [TableShadeOperand](https://learn.microsoft.com/en-us/openspecs/office_file_formats/ms-doc/bd69813f-a7e1-41bc-8d2d-64e9b2619136), and [ItcFirstLim](https://learn.microsoft.com/en-us/openspecs/office_file_formats/ms-doc/61341a2c-b8da-4e52-ba62-b2b0d5efadc4).
|
|
286
|
+
- [[MS-CFB]: Compound File Binary File Format](https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-cfb/) — the container, read and written through `archive-codec`.
|
|
145
287
|
|
|
146
288
|
## Licence
|
|
147
289
|
|