Skip to content

Latest commit

 

History

History
369 lines (246 loc) · 172 KB

File metadata and controls

369 lines (246 loc) · 172 KB

doc-codec

GitHub npm npm version CI

A hand-written, dependency-minimal reader and writer for the Word Binary File Format (.doc, [MS-DOC]) against the shared document-schema.js content pivot.

.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.

Status

Under active development. This package both reads and writes, over a smaller surface on the write side than the read side covers.

Built and shipped, on the read side:

  • The compound-file container and the FIBreadDocStreams 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.
  • The piece tableparseClx 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.
  • Text reconstructionreadTextRange 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.
  • 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, now including sprmCRgFtc0's font-table lookup (see The font table).
  • The style sheetparseStsh reads each style's index, name, kind, parent, and (for a paragraph or character style) its own grLPUpxSw formatting sets; resolveStyleFormatting walks the istdBase inheritance chain and folds each style's own paragraph/character properties, most-specific style winning, beneath a paragraph's or run's own direct exceptions (ExaDev/documents.js#1005). headingLevelFromIstd applies sprmPIstd's own rule that an istd of 1 through 9 states an outline level.
  • Tables, including nested tablestable/read.ts's walkBlocksAtDepth/assembleBlocks fold a contiguous run of table-depth paragraphs into a real ContentTable, at whatever nesting depth the file states: cell boundaries at each cell-mark (0x07) character at depth 1, or a paragraph mark (0x000D) carrying sprmPFInnerTableCell one level deeper ([MS-DOC] 2.4.3's own depth-1-vs-deeper distinction), a cell holding more than one paragraph where only its last ends in a cell mark, and each row's own trailing mark (sprmPFTtp/sprmPFInnerTtp) 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 below for why both are read). A table nested inside a table cell (detected via sprmPItap/sprmPFInnerTableCell/sprmPFInnerTtp) resolves to a real nested ContentTable, to whatever depth the file actually states, rather than being refused. 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).
  • Table cell decorationContentTableCell.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, the row-level shading arrays (sprmTDefTableShd/2nd/3rd, their Raw counterparts, the Word 97-era sprmTDefTableShd80, and sprmTSetShd/sprmTSetShdOdd), and, cascaded onto whichever cells and sides those leave unstated, the row/table-level sprmTTableBorders/sprmTTableBorders80 border set and sprmTSetShdTable's own per-row background. See Cell decoration.
  • 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 metadatatitle/subject/author/keywords/createdIso/modifiedIso read from a "\x05SummaryInformation" stream when one is present (see Metadata); comments and lastPrintedIso remain unread, since LayoutMetadata has no field for either.
  • RC4-encrypted and XOR-obfuscated documents, decrypted for real (#1113, #922)readDocContent/readDocStreams take an optional password, verified against the Table stream's own EncryptionHeader for RC4, or against FibBase.lKey directly for XOR obfuscation (see Encryption). RC4 CryptoAPI stays refused.
  • Numbering definitionsreadDocContent'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. See Numbering definitions, which also covers writeDocContent's own inverse.
  • Every section's own page size and marginsprop/sep.ts's readAllSectionProperties resolves every Sed PlcfSed carries, each into its own ContentSection.pageSize/.margins (sprmSXaPage/sprmSYaPage/sprmSDxaLeft/sprmSDxaRight/sprmSDyaTop/sprmSDyaBottom), 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 a section leaves unstated. read.ts's splitIntoSections groups the main document's own paragraphs by which section each falls in, per [MS-DOC] 2.8.26's own "each CP specifies the beginning of a range of text ... that constitutes a section" — a genuinely multi-section .doc now reads as multiple ContentSections, each with its own real page geometry, rather than the whole document collapsing onto the first section's. A 0x000C where no section ends reads as a real pageBreak block attached to the paragraph it terminates (read.ts's markManualPageBreaks) — [MS-DOC]'s own PlcfSed.aCP text distinguishes the format's two spellings of that one character outright ("An end-of-section character (0x0C) which occurs at a CP and which is not the last character in a section specifies a manual page break"), and writeDocContent writes that exact shape back (see Writing).
  • Footnotes, endnotes, and comments, as plain textnotes.ts's readNoteBodies resolves PlcffndTxt/PlcfendTxt/PlcfandTxt (each a CP-only plex dividing its own subdocument into stories, [MS-DOC]'s own "each CP ... specifies the beginning of a story ... the story ends immediately prior to the next CP") into DocContent.footnotes/.endnotes/.comments: one {id, text}/{id, text} entry per story, id a synthetic one-based ordinal, matching the same simplification ooxml.js's own DocxDocument.footnotes/.endnotes/.comments already make for the identical constructs (plain text, no paragraph/run structure). A story's own trailing guard paragraph mark ("not considered part of the story contents", [MS-DOC]'s own words) is excluded from its text when the story ends in one — and a story that ends with a content paragraph of its own is kept whole, the spelling a real producer writes for note stories (a LibreOffice-authored single-paragraph footnote ends at its own content mark with no separate guard; an earlier unconditional drop read every such note as "" and lost a multi-paragraph note's last paragraph). A genuinely empty story reads as "".
  • Headers and footers, as real block flowheaders-footers.ts's readHeaderFooterStories resolves Plcfhdd (the identical CP-only-plex shape as the note-text plexes above) into DocContent.headerFooterStories: one entry per non-empty story, each carrying which section it belongs to, which of the six fixed slots it is (evenHeader/oddHeader/evenFooter/oddFooter/firstHeader/firstFooter, [MS-DOC]'s own fixed per-section story order), and a real ContentBlock[] — paragraphs and tables, read through the identical pipeline the main document uses, rather than plain text. An empty story ([MS-DOC]'s own "the beginning CP has the same value as the next CP") is left out of the array entirely, since it means "reuse the previous section's header/footer of this kind" rather than a genuinely blank one. The six footnote/endnote-separator stories Plcfhdd carries ahead of every section's own six are not read: neither this package's schema nor ooxml.js's DocxDocument has anywhere to put a separator story. A genuine Word 97-authored file's own Plcfhdd can carry placeholder keys the published specification's own bounds forbid — -1 entries mid-array and a key past the subdocument's own length — when the document carries next to no header/footer content at all; subdocument.ts's readStoryPlexKeys normalises an out-of-range or descending key to its own in-range predecessor (the same "beginning CP equals the next CP" empty-story spelling the format already defines) rather than refusing the whole document, confirmed against a genuine Word 97 file carrying exactly this shape (HeaderFooterProblematic.doc) and matching what a real, independent [MS-DOC] implementation (LibreOffice) reads from the identical bytes.
  • Inline picturespictures.ts's readInlinePicture resolves a picture character (U+0001, sprmCFSpec applied) through its own Chpx's sprmCPicLocation operand: an offset into the container's "Data" stream where a PICF ([MS-DOC] 68-byte type/size/border header) is followed, for every producer this reader has seen, by a real OfficeArtInlineSpContainer ([MS-ODRAW] 2.2.15) regardless of PICF.mfpf.mm's own value. The container's own shape (an OfficeArtSpContainer) is skipped whole by its own record header's recLen, and the first blip record in rgfb immediately after it is decoded when it is OfficeArtBlipJPEG/OfficeArtBlipPNG — the two raster formats document-schema.js's ContentImageBlock can hold losslessly — into a real inline image, sized from PICMID's own dxaGoal/dyaGoal scaled by mx/my. A paragraph containing a picture anchor is split around it into a real ContentBlock[] (text/paragraphs.ts's buildParagraphBlocks), since an inline image is block-level in document-schema.js's own model rather than a run property — mirroring how ooxml.js's own docx reader splits a paragraph around a mid-run page break, paragraph-level formatting carried unchanged onto every split piece. See Images for what this does not read.
  • sprmPHugePapx-indirected paragraph propertiestext/paragraphs.ts's resolveIndirectPapx follows a paragraph grpprl that opens with sprmPHugePapx (0x6646) to the PrcData it names in the Data stream, up to 16 hops per [MS-DOC] 2.6.2's own chain-termination requirement, and reads that PrcData's own GrpPrl in its place — the mechanism Word reaches for once a paragraph's direct formatting no longer fits its own 512-byte PapxFkp page. Confirmed against two genuine Word-2003-produced files whose wide table rows state their whole TAP this way rather than through a direct sprmTDefTable (Bug61268.doc's seven-column history tables, 57603-seven_columns.doc's single seven-column row) — since a row's TAP rides its own row-mark paragraph's grpprl, resolving the indirection here resolves it for a table row exactly as it does for an ordinary paragraph, with no separate table-side code path needed. sprmPTableProps (the sibling indirection [MS-DOC] 2.4.3 also permits for a row's TAP) is not followed.

Built and shipped, on the write side — see Writing for the full scope statement:

  • writeDocContent — a 'wordprocessing' ContentDocument (any number of sections, each of paragraphs, tables, inline images, and page breaks), widened by the optional story fields a DocContent read carries (footnotes/endnotes/comments as plain-text bodies, header/footer stories as real block flow — see Writing's stories paragraph), 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 real style sheet minting one entry per distinct styleId/headingLevel a document's paragraphs actually use (style/stsh.ts's buildStshForStyles, ExaDev/documents.js#1059), a font table when a run names one, real PlfLst/PlfLfo numbering tables when at least one paragraph carries list (see Numbering definitions), a real PlcfSed/Sepx array stating every section's own page size and margins (ExaDev/documents.js#971, see Sections), the four story subdocuments and their boundary plexes when the input carries stories (subdocument-write.ts, see Subdocuments), a "Data" stream carrying every inline picture's own real PICF/OfficeArtInlineSpContainer/blip bytes (ExaDev/documents.js#971, see Images), every run carrying ContentRun.hyperlink as a real HYPERLINK field around its own result ([MS-DOC] 2.8.25, see the Hyperlinks and fields row of the scope table), and a "\x05SummaryInformation" stream when the input's metadata carries anything that stream can hold (see 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), 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). A pageBreak block is written as the format's own manual-page-break spelling of 0x000C (see Writing).
  • 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 confirms in full.

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:

Absent Consequence
Nested tables, on write readDocContent recurses into a table nested inside a table cell to whatever depth the file states (see the read-side Tables bullet above and Tables). writeDocContent still refuses a ContentTable block found inside a table cell's own blocks: it has no depth-aware sgc-1/sgc-5 grpprl encoding for anything past depth 1, and writing one incorrectly would silently corrupt the nested table's own structure rather than fail loudly.
Floating/anchored drawn objects, text boxes, and every picture format beyond JPEG/PNG An inline picture (U+0001) round-trips when its own blip is OfficeArtBlipJPEG/OfficeArtBlipPNG (see the read-side Inline pictures bullet above, writeDocContent's own Data-stream bullet, and ImagesExaDev/documents.js#971). A floating or anchored drawn object (U+0008, PlcfSpa) is a genuinely different structure — an arbitrary OfficeArtSpContainer shape (which may or may not be a picture at all), reached through a document-level drawing tree (OfficeArtDgContainer) rather than a single inline blip — and is not resolved on read, nor written: document-schema.js has no block kind for a floating/anchored image distinct from an inline ContentImageBlock in the first place. A text box rides the identical drawing layer (PlcfTxbxTxt, itself anchored via a drawn object), so it is likewise not read or written. ContentImageBlock.format's svg/gif members, and any blip format other than JPEG/PNG on read (a WMF/EMF/PICT metafile, a raw DIB, TIFF), have no OfficeArtBlip type this package's own writer can fabricate or its own reader can decode, so readInlinePicture returns undefined for one rather than mis-decoding it, and writeDocContent throws DocUnsupportedError naming the unwritable format rather than fabricating a blip its own reader could not read back.
A style's own formatting on write A style's own property sets (STD.grLPUpxSw) are resolved and folded on read (ExaDev/documents.js#1005): a paragraph's formatting is its style's own grpprlPapx/grpprlChpx (walked up the full istdBase inheritance chain, most-specific style winning) plus its own direct exceptions on top, and a run's own referenced character style (sprmCIstd) folds in the same way. writeDocContent mints a real STSH entry for every distinct styleId/headingLevel a document's paragraphs use (ExaDev/documents.js#1059), so style IDENTITY round-trips -- but every minted entry carries an empty grLPUpxSw (no formatting of its own), since doc-codec's flat ContentDocument has no style-vs-direct-formatting split to draw a real one from: every property this writer emits is already, unconditionally, a direct exception. A style's own inherited formatting therefore never round-trips through a write -- only its name and istd position do.
Table and numbering style formatting STD.grLPUpxSw is resolved only for paragraph and character styles (stk 1/2, see the row above) -- a table style's StkTableGRLPUPX (its own TAPX plus CNFC-conditional per-region formatting, [MS-DOC] 2.4.6.6's own table-specific steps 6-9) and a numbering style's StkListGRLPUPX are neither read nor written. A table applying a named table style, or a paragraph using a numbering style rather than direct list formatting, reports none of the formatting either would supply.
Note references and separators, on write; text boxes, on either side Footnote/endnote/comment BODIES and full header/footer story block flow are now written (see Writing's stories paragraph and subdocument-write.ts) — but the REFERENCE side is not: PlcffndRef/PlcfandRef/PlcfendRef and the U+0002/U+0005 reference characters in the main document have no model to write from (the reader drops the anchors and keys note bodies by document-order ordinals), so a written note story carries no reference and a real producer renders it as unreferenced content rather than a live footnote — LibreOffice opens such a file without error and resolves its header/footer stories fully, while ignoring the unreferenced note stories. The six footnote/endnote-separator stories Plcfhdd leads with are written as genuinely empty stories, matching that neither side models one. Text boxes are not read or written in either direction: PlcfTxbxTxt divides its own subdocument into stories the identical way the note-text plexes above do, but a text box's own anchor rides the drawn-object mechanism (Images's own scope note), which this package does not resolve.
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); the sibling "\x05DocumentSummaryInformation" stream (company, manager, and custom user-defined properties) is not read or written at all.
Encryption RC4-encrypted ([MS-DOC] 2.2.6.2, #1113) and XOR-obfuscated (2.2.6.1, #922) documents are both decrypted given a password -- readDocContent/readDocStreams take an optional password, verified against the Table stream's own EncryptionHeader for RC4, or against FibBase.lKey directly for XOR obfuscation (see Encryption below). RC4 CryptoAPI (a different, newer EncryptionHeader shape) still refuses with a DocUnsupportedError rather than being read as plaintext or misread as the wrong scheme. writeDocContent never encrypts.
sprmPTableProps, and sprmPHugePapx on write sprmPHugePapx-indirected paragraph properties are now read (see the read-side bullet list above); writeDocContent never writes an indirect Papx, so a document whose own formatting would need one still throws the ordinary over-large-record DocFormatError rather than following the indirect path out. [MS-DOC] 2.4.3's own Overview of Tables text names sprmPTableProps as a further, real, legal alternative to sprmTDefTable a table row's own TAP may ride instead — a genuine LibreOffice-authored .doc table's own row mark was confirmed to prefer direct sprmTDefTable (see ExaDev/documents.js#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"), so this remains genuinely unread rather than merely unconfirmed.
Hyperlinks and fields A HYPERLINK field is read and written through the field layer: the reader lifts a completed HYPERLINK "<uri>" instruction ([MS-DOC] 2.8.25) onto exactly its own result runs as ContentRun.hyperlink, and the writer is the inverse, emitting a run carrying hyperlink as 0x13, the instruction, 0x14, the result runs, 0x15, with sprmCFSpec on the field characters (the spelling a real Word 97 producer writes). Consecutive runs sharing one URI join a single field. The property layers a Word-authored file carries beside that field are neither read nor written: the Plcffld that tracks field-begin characters and the [MS-OSHARED] hyperlink property set whose dwApp names an index into it ([MS-DOC] 2.4.7's own association rule) — a consumer resolving hyperlinks only through that property layer sees the field's text and instruction but no property-backed link, while a consumer resolving HYPERLINK fields directly (this package among them) resolves the URI. Every other field is resolved, not modelled: instruction dropped, result kept as ordinary run text, nothing written back around it. Reference characters (footnote/comment/annotation anchors) are still dropped as plain text or structure, per the read-side rule.
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, the numbering tables, and the four story plexes when the document carries stories). 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.

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.

What is converted

Character properties, from Chpx grpprls:

Sprm Becomes
sprmCFBold (0x0835), sprmCFItalic (0x0836), sprmCFStrike (0x0837) bold / italic / strike, honouring ToggleOperand's inherit (0x80) and invert (0x81) values
sprmCKul (0x2A3E) underline (any non-zero Kul style)
sprmCHps (0x4A43) sizePt, the operand being half-points
sprmCIco (0x2A42) color, through [MS-DOC] 2.9.119's fixed palette
sprmCCv (0x6870) color, from a COLORREF
sprmCIstd (0x4A30) resolves the named character style's own formatting and folds it in (ExaDev/documents.js#1005), between the paragraph style's own defaults and this run's remaining direct exceptions
sprmCRgFtc0 (0x4A4F) fontFamily, looked up by index in the document's own font table (see The font table)

Paragraph properties, from PapxInFkp grpprls:

Sprm Becomes
sprmPIstd (0x4600) styleId (the style's name) and headingLevel (via the istd 1-9 rule)
sprmPJc (0x2461), sprmPJc80 (0x2403) alignment
sprmPDxaLeft (0x845E) / sprmPDxaLeft80 (0x840F) indentLeftPt
sprmPDxaLeft1 (0x8460) / sprmPDxaLeft180 (0x8411) indentFirstLinePt
sprmPDxaRight (0x845D) / sprmPDxaRight80 (0x840E) indentRightPt
sprmPDyaBefore (0xA413), sprmPDyaAfter (0xA414) spacingBeforePt / spacingAfterPt
sprmPDyaLine (0x6412) lineSpacing, only for LSPD's multiplier form
sprmPFPageBreakBefore (0x2407) pageBreakBefore
sprmPOutLvl (0x2640) headingLevel, where the istd did not already supply one
sprmPIlfo (0x460B), sprmPIlvl (0x260A) list membership

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. One field is lifted further: a completed HYPERLINK "<uri>" instruction ([MS-DOC] 2.8.25's own spelling) tags exactly its own result runs with that URI as ContentRun.hyperlink, the inverse of the writer's own field emission; every other field's result stays ordinary run text. A line break (U+000B) inside a paragraph survives as a newline.

The font table

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.

Tables

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 on the read side at every depth a document states: a table nested inside a table cell (table depth greater than 1) uses a genuinely different pair of marks — a paragraph mark (0x000D) carrying sprmPFInnerTableCell for a cell boundary, sprmPFInnerTtp for a row's own terminating mark, in place of the cell-mark character depth 1 uses — and table/read.ts's walkBlocksAtDepth/tryAssembleTable are mutually recursive over that depth, so a cell's own content resolves to a nested ContentTable wherever the file states one, to whatever depth it actually nests. The write side stays depth-1 only: writeDocContent still refuses a ContentTable block found inside a table cell's own blocks (see the "Nested tables, on write" row in the scope table above).

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). 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 (SwTableLineSwTableBox, 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.

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. This package's own writer closes that gap for an ordinary merge (see Writing below and ExaDev/documents.js#992): it falls back to a horizontal-merge continuation cell precisely when every row would otherwise merge across a boundary identically. But the writer's own lost-boundary fallback genuinely reopens the same gap when a row's assigned split overflows either the row-ending mark's own byte budget or the format's 63-physical-cell ceiling: flattenTable trims the excess boundaries rather than throwing (ExaDev/documents.js#1013; see the Writing section's own note on the trim fallback below), and a boundary it drops is unrecoverable on the next read exactly as if no row had ever stated it. That trim is now the most likely source of this shape; a table hand-built for a test, or one produced by a genuine third-party [MS-DOC] implementation that happens to encode a merge the identical way on every row, are the two remaining, rarer sources.

A row whose own TAP cannot be resolved this way — no direct sprmTDefTable anywhere in its grpprl once sprmPHugePapx indirection is followed (a producer may legally state it indirectly instead through sprmPTableProps, which this reader still does not follow; see the sprmPTableProps/sprmPHugePapx 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 scope row above already documents for sprmPTableProps. A run of table-flagged paragraphs that never closes a single row (a genuine Word 2000-authored title page confirmed to have exactly this shape — no cell or row mark anywhere in its own sprmPFInTable run) degrades the same way, provided the wider text stream continues past it; one dangling at the stream's very end is still the truncated-stream case below. 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.

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 for the issue it fixes. TCGRF.horzMerge is 0 for an ordinary merge like this one — no flag or sprmTMerge sprm is written for it — with one deliberate exception: before flattening any row, the writer first computes, across every row in the table, which of the table's own internal column boundaries at least one row's ordinary physical layout would state; a boundary none of them would (every row happens to merge across it identically — a single-row table with one merged cell is the simplest case) is kept physically present anyway, by splitting the cell that crosses it into an extra physical cell flagged as a genuine TCGRF.horzMerge continuation (contentless, per [MS-DOC] 2.9.317's own TCGRF: horzMerge value 1, "the cell is one of a set of horizontally merged cells. It contributes its layout region to the set and its own contents are not rendered") rather than folded into one wider cell. This is the fix for ExaDev/documents.js#992: the fallback triggers only for the rows and boundaries that actually need it, so an ordinary table — one with at least one row that does not merge across the same span — writes exactly as before, and only the pathological case gains an extra physical cell purely to keep the boundary recoverable on read. The trade-off is real and worth stating plainly: LibreOffice was confirmed not to read TCGRF.horzMerge back as a merge at all (see the top-of-file note above), so a table this fallback applies to shows as unmerged, separate cells there — one of them empty — rather than as the single merged cell this package's own reader now correctly recovers. Given the alternative was colSpan coming back undefined and columnWidthsPt silently narrowing on every reader including this package's own, that trade is the honest one to make.

distributeLostBoundaries assigns each lost boundary to exactly one row, round-robin, rather than to every row that crosses it: a boundary is only ever "lost" because every row of the table merges across it identically, so any one row can be the one that states it, and spreading the work is what keeps a wide, uniformly-merged table's own rows under the per-row budget stated below. But the row a boundary lands on can still, itself, be assigned more boundaries than its own row-ending mark can actually carry — a table wide enough, or with few enough rows to share the work, reproduces the identical overflow the 21-column ceiling arithmetic below already describes, just reached through the split instead of through raw column count (ExaDev/documents.js#1013, a genuine write regression #992's own fix introduced: a single-row table with one merged cell wrote successfully for any column count before #992, since an unsplit merge costs nothing extra regardless of its span, but the row assigned every one of its own lost boundaries by #992's fix throws past 21 columns with no other row to share the work). A row's own assigned split can overflow either of two real ceilings, not one: the row-ending mark's own PapxInFkp byte budget (the 21-column ceiling case described below), and the format's own hard cap of 63 physical cells per row (TDefTableOperand.NumberOfColumns, [MS-DOC] 2.9.321's own "MUST NOT exceed 63", not 2.4.3's separate "between 1 and 63 table cells" limit) — a table with enough columns that even one row's share of the lost boundaries alone would split it past 63 physical cells. rowSplitFits (table/write.ts) checks a candidate split against both: the cell-count ceiling first and cheaply, so an over-63 candidate is never handed to tap-write.ts's encodeTableRowGrpprl at all (that function throws unconditionally past its own MAX_TABLE_ROW_CELLS, since every other caller committing a row has a genuine internal defect if it ever produces one), then prop/fkp-write.ts's own fitsAloneOnPapxPage (the identical fits-in-isolation check buildPapxPages itself performs before it would throw, called ahead of time rather than re-derived as a second formula). A row whose full assigned split fails either check does not drop every one of its assigned boundaries the way this fallback's own first version did: flattenTable trims from the end of the row's own assigned set — dropping its highest-valued boundary first, since distributeLostBoundaries builds each row's set in ascending order — one boundary at a time, checking each shorter prefix against rowSplitFits in turn until one fits. That is an exhaustive downward scan rather than a binary search over boundary count because a downward scan finds the true largest fitting prefix by construction, whatever the byte size does as boundaries are dropped — it never needs fitting to behave monotonically to be correct, only to try every candidate length in turn. The byte size genuinely is not monotonic: dropping one boundary always removes exactly one physical cell — 22 bytes, the same per-column cost stated below (2 for the rgdxaCenter boundary, 20 for that cell's own TC80) — but it also shifts every later cell's index down by one, and tap-write.ts's shadingPrls packs a row's shading into one DefTableShdOperand per 22-cell window whose rgShd array runs from the window's own first cell up to its last shaded cell: shifting a shaded cell out of a cheap position at the head of one window and into the tail of the previous window forces that window's own array to stretch across up to all 22 of its cells (10 bytes each) to reach it, up to 210 bytes where before it needed only its own single 10-byte entry, a 188-byte increase (−22 from the removed cell, +210 from the shifted shading array) for removing a boundary rather than the decrease a naive reading would expect. That jump can never actually reach a candidate this scan accepts, though — it is the same fact the Testing section states from the read-side test suite's own vantage point, that the second and third shading arrays are something "a row too wide for one PapxInFkp record can never exercise end to end": the second window's own first cell only exists once a row holds at least 23 physical cells, and 23 cells alone — with no shading, no exact-colour border overrides, no row height, nothing but the bare sprmTDefTable — already cost the same 15-fixed-plus-22-per-cell arithmetic the "21 columns" ceiling below is built from: 15 + 22 × 23 = 521 bytes, 34 bytes past the 487-byte GrpPrlAndIstd ceiling a lone paragraph can claim, before a single shading byte is even added. Every byte this format can add past that bare minimum only grows the record further, so no 23-cell-or-wider candidate can ever fit no matter how its shading falls, and rowSplitFits rejects it on cell count and base size alone long before the cross-window shift above could matter. The non-monotonicity is real, but it lives entirely past the cell count any row within this budget can reach — so a binary search here would not actually risk stopping on a candidate a larger, skipped-past one would also have fit; the exhaustive scan is simply what a correct "largest fitting prefix" search looks like regardless, with no monotonicity assumption to get wrong either way. Only the trimmed boundaries go back to being unrecoverable on read; every boundary the row still states survives exactly as #992's own fix intended. writeDocContent's own optional onWarning callback (WriteDocContentOptions, the same shape byte-codec's PNG decoder and pdf-codec already use for a recoverable defect) is told which row, how many of its assigned boundaries it kept, and how many it dropped — a diagnostic, not a silent narrowing. Every other row in the table is unaffected: only the rows genuinely too wide to close #992's own gap in full trim at all, and even those recover as much of their own assignment as their budget allows rather than losing all of it — a two-row, 42-column table where one row sits one boundary past the byte-budget ceiling still recovers 41 of its 42 columns, not the 21 an all-or-nothing fallback would leave (see write.test.ts's own trimming and 63-cell-ceiling tests for the measured numbers). The trim can still reach zero kept boundaries for a row decorated or narrow enough that not even a single split survives its own budget; that is, correctly, the same total-loss outcome the fallback's first version always produced for such a row, not a regression this trim introduces. Nothing changes for a row that still cannot fit even fully unsplit — a decorated or otherwise too-wide row exactly as the paragraph below already describes — since that throw was never #992's, or this trim's, to fix in the first place.

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 — see the third-party verification paragraph immediately below for the full finding.

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 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 sprmPTableProps/sprmPHugePapx scope row above) — a hypothesis a later genuine Word-produced corpus did in fact confirm for other files, once sprmPHugePapx resolution existed to check it against (see the read-side bullet list's own note). 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.

The horizontal-merge gap #892 left open (ExaDev/documents.js#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 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 (passestable:number-rows-spanned="2" on the anchor cell and a real table:covered-table-cell on the row below); a horizontally merged cell (passestable: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 (passestable: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.

Third-party verification, cell decoration: passing in both directions. The border and shading encodings 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.

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).

The width of a double border now agrees with LibreOffice too. [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; the two sides of a double border ([MS-DOC] BrcType 0x03) turn out to disagree about which — this package originally reported the field as the border's own total width, while LibreOffice's own WW8 border-width conversion (editeng/source/items/borderline.cxx: BorderWidthImpl for SvxBorderLineStyle::DOUBLE splits a total width into three equal thirds for line/gap/line, and ConvertBorderWidthToWord divides a total width by three the other way) treats the field as one of the two lines' own width, the gap between them being the same width again — a factor of three apart in both directions, which is exactly what the two measurements showed: reading LibreOffice's own file, a dptLineWidth of 5 read here as 0.625pt where LibreOffice's re-export called the same border 1.8pt double (5 eighths tripled is 1.875pt, matching LibreOffice's own figure to its own twip-rounding); writing, a 2pt double border came back from LibreOffice as 6pt double (this package's own pre-fix dptLineWidth of 16, read back as a single line's width and tripled, is exactly 6pt). widthPt for a double border is therefore the total rendered width in both directions now, tripled from dptLineWidth on read and divided by three (rounding to the nearest eighth of a point) on write, matching LibreOffice's own convention exactly; every single-line border — solid, dashed, dotted, at every width tested — already agreed exactly in both directions and is unaffected.

One further limit is worth stating precisely rather than leaving implied. The sprmTSetShd/sprmTSetShdOdd and Shd80 read paths are 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. A genuine two-colour pattern fill (a percentage grey, a stripe, a crosshatch) was likewise not checked against a real LibreOffice-authored .fodt, since the hand-authored fixture behind the paragraphs above never produced one — read and write are pinned against bytes hand-built from [MS-DOC]'s own Ipat enumeration (decoration.test.ts) and against a whole-document round trip through this package's own reader and writer (write.test.ts), not against a third-party producer's file.

Cell decoration

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.

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; its Word 97-era sibling sprmTSetBrc80 (0xD620, TableBrc80Operand — [MS-DOC] 2.9.304) shares the identical cb/ItcFirstLim/bordersToApply header but restates the border with a palette-indexed Brc80MayBeNil instead, exactly like TC80's own fields — read for a genuine Word-97-era producer, but never written, since this package's own writer only ever emits the modern spelling. A real, independent [MS-DOC] implementation writes sprmTSetBrc (not sprmTSetBrc80) alongside TC80 for every bordered cell, which is why this package reads both — sprmTSetBrc/sprmTSetBrc80 folding onto TC80's own layer exactly as sprmTMerge/sprmTVertMerge already fold onto sprmTDefTable's — and writes the modern one. 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.

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 exception is double (BrcType 0x03), whose own dptLineWidth states the width of one of its two lines rather than the border's total rendered width, so widthPt is dptLineWidth / 8 * 3 on read and the inverse on write; see the "Third-party verification, cell decoration" paragraph below for the LibreOffice source and the measured numbers that confirm it -- 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 what dptLineWidthFor will accept is refused on write rather than silently narrowed -- its own check runs on the rounded eighths value, not the raw widthPt, so the true floor it enforces is half an eighth below each field's own nominal minimum, not the minimum's own direct conversion: 0.1875pt in both cases (a single-line border's nominal floor converts to 0.25pt, double's own to 0.375pt once tripled -- neither is the number this package's own dptLineWidthFor actually refuses below). The ceiling is rounding-aware the same way but in the opposite direction: Math.round's own tie-breaking rounds a half-eighth tie up, which helps the floor round into acceptance but hurts the ceiling by rounding it into refusal, so the largest width dptLineWidthFor actually accepts only approaches, and never quite reaches, half an eighth above each field's own nominal maximum -- 31.9375pt single-line or 95.8125pt double, both well past the 31.875pt/95.625pt a stored maximum converts back to on read and neither the number this package's own dptLineWidthFor actually refuses above. Accepting a double width does not mean it always reads back unchanged, either: any widthPt from 0.1875pt up to (but not including) 0.5625pt stores a dptLineWidth of exactly 1, which borderFrom's own read-side floor of 2 then widens before tripling -- a border written at 0.5pt, for instance, reads back as 0.75pt (see Testing below and decoration.test.ts's own "double-line border width" tests for the confirmed numbers). 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.

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.

ContentTableCell.background/ContentSheetCell.background are document-schema.js's own ContentCellFill (#951): a discriminated 'solid'/'pattern' shape, 'pattern' naming a closed ContentCellPatternType vocabulary spanning both WordprocessingML's ST_Shd and SpreadsheetML's ST_PatternType — see that schema's own top comment for the full citation. Two Ipat values resolve to 'solid': 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 writer emits automatically — and ipatSolid, under which it shows cvFore. Every other named Ipat value resolves to a real 'pattern' fill, via decoration.ts's own IPAT_TO_PATTERN_TYPE table: the twelve pctN values in Ipat's original contiguous range (0x02-0x0D), the twelve stripe/cross families and their thin variants (0x0E-0x19), and the eleven further pctN values [MS-DOC]'s later ipatPctNew* range (0x25-0x3C) maps onto a real ST_Shd token — 35 named patterns in total, carrying whichever of cvFore/cvBack the cell actually states (either may be cvAuto, the application's own default, and therefore absent from the fill). ipatNil and the sixteen ipatPctNew* values [MS-DOC] itself says "SHOULD NOT be used" (2.9.121's own note) — the fine percentages with no ST_Shd equivalent at all, such as 2.5% or 17.5% — read as no background, ContentCellPatternType having no member for them either. Writing a 'pattern' fill states its own foreground/background colours (automatic where the fill leaves one unstated) under the Ipat value the same table's inverse names for it; a SpreadsheetML-only pattern name (mediumGray through gray0625, ST_Shd having no equivalent) throws DocUnsupportedError rather than writing the wrong pattern or silently dropping it. A cvAuto colour under ipatAuto/ipatSolid 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.

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.

A whole row or table can cascade its own borders and background down to cells that never state their own, and this is now read. sprmTTableBorders (0xD613, a TableBordersOperand — [MS-DOC] 2.9.302) and the Word 97-era sprmTTableBorders80 (0xD605, TableBordersOperand80 — [MS-DOC] 2.9.303) each carry six fields — brcTop, brcLeft, brcBottom, brcRight, brcHorizontalInside (the edge between this row and its table neighbours) and brcVerticalInside (the edge between this row's own cells) — and sprmTTableBorders's own text states the precedence outright: "specifies the borders for this row unless modified by other Sprms applied to the cells". That is an explicit exception to the ordinary last-Prl-wins fold every other sprm in this section follows: it must never override a cell's own TC80/sprmTSetBrc/sprmTSetBrc80 border, regardless of which comes first in the grpprl, which is why table/tap.ts only captures the row's own six-field operand unresolved and table/read.ts's applyRowLevelBorderCascade is what actually fills in whichever cells and sides TC80/sprmTSetBrc/sprmTSetBrc80 left unstated, once every row of the table is known — brcTop only for the table's own first row; brcBottom for a cell reaching the table's real bottom edge through one of the paths this cascade actually checks — the table's own last physical row directly, or any non-continuation cell — a plain cell or a vertically-merged anchor alike — whose own merge chain's last row no later row covers at all in a ragged table, a plain cell being simply the length-one special case of that chain (its own last row is the cell's own row) — resolved on the table's shared column grid by cellReachesTableBottom/vertMergeChainLastRow/columnCoveredByALaterRow rather than the cell's own row index alone — justified by [MS-DOC] 2.4.3's own Overview of Tables, whose own prose text introducing Figure 2 (not the figure's own caption, which is simply "A table with vertically merged cells") states that the diagram "uses inside borders to demonstrate that the vertically merged cells act as one cell", which is precisely why the group's real bottom edge, not each physical row's own, is where brcBottom belongs: an anchor's physical row is not always the table's last one, and the continuation cell that actually sits there has its own decoration dropped unconditionally (a vertical-merge continuation is {blocks: []} by the shared schema's own convention), so without this the table's real bottom border never reached the output for that column at all (ExaDev/documents.js#945) — this ragged-table path itself tests only a cell's own start grid index, never every column its own colSpan covers, so a cell spanning multiple grid columns whose only exposed column is a later one still reads the row's ordinary interior border on that side rather than the table's real bottom one, a deliberate scope limit rather than a gap: ContentBorder holds exactly one value per side, so a cell straddling both a covered column and an exposed one has no way to report two different answers for that side, and testing the cell's own leftmost column is the one choice that stays consistent with what a plain, single-column cell already resolves to; brcLeft/brcRight only for a row's own first/last physical cell (resolved through any trailing horzMerge-continuation cells, so a legacy-encoded horizontal merge's own anchor still reaches the row's real right edge); brcHorizontalInside/brcVerticalInside everywhere else — exactly the [ECMA-376] tblBorders/tcBorders precedence the format's own Overview of Tables defers to for "which borders are displayed". sprmTSetShdTable (0xD660, a SHDOperand — [MS-DOC] 2.9.249) states shading for [MS-DOC] 2.6.3's own "the entire table", but its own text carries none of sprmTTableBorders's explicit exception, so this package reads it as an ordinary sprm in table/tap.ts itself: applied to every cell of whichever row's own grpprl states it, not resolved across the whole table the way its name implies — a producer writing it on only one row's grpprl shades, per this implementation, only that row. A genuine format-level ambiguity remains for TC80 alone: its own Brc80 fields are mandatory for every physical cell, so a cell whose own TC80 states "no border" on a side (the all-bits-set Brc80MayBeNil sentinel, or a real producer's own BrcType 0x00) is byte-for-byte indistinguishable from a cell whose TC80 was never touched at all — there is no way to tell "this cell's TC80 explicitly punches a hole in the row's cascade" from "this cell defers to it" from TC80's bytes alone, and this cascade resolves that the only way a real byte stream can, by filling the side in. Neither sprmTSetBrc's nor sprmTSetBrc80's own explicit clear is part of that ambiguity: naming a side with a NilBrc/NilBrc80 is an unambiguous, out-of-band statement, so table/tap.ts's applyBrcToCell records it on the cell's own clearedSides rather than folding it indistinguishably into its borders, and table/read.ts's cascadeRowBorders never re-fills a side that set names — an explicit sprmTSetBrc/sprmTSetBrc80 clear always wins over the row/table-level cascade, regardless of which comes first in the grpprl (ExaDev/documents.js#945). TC80's own byte-level ambiguity is the only genuine ambiguity in the format itself, but it is not the only case this cascade can get wrong: [MS-DOC] 2.6.3 also defines sprmTCellBrcType (0xD662, a TCellBrcTypeOperand — one BrcType byte per side for each of a row's leading cells) and the sprmTBrcTopCv/sprmTBrcLeftCv/sprmTBrcBottomCv/sprmTBrcRightCv family (0xD61A-0xD61D, each a BrcCvOperand — one exact COLORREF per cell for that one side, an all-bits-set entry meaning "there is no corresponding border" for that cell), neither of which this reader reads at all. Both state a cell's border on one side — or its explicit absence — exactly as unambiguously as sprmTSetBrc/sprmTSetBrc80's own NilBrc(80) already does, so a producer using either instead of TC80/sprmTSetBrc/sprmTSetBrc80 has that statement silently overwritten by this cascade rather than honoured — a genuine reader gap, not a format-level ambiguity.

What is not resolved. sprmTVertMerge is still read (folded onto sprmTDefTable's own layout, for a genuine third-party producer that states a vertical merge that way) but never written — a vertical merge is stated only through TC80.tcgrf. sprmTMerge is likewise still read but never written as such; this writer ordinarily states a horizontal merge purely through a merged row's own narrower, wider physical cells (see Writing above), reaching for a genuine TCGRF.horzMerge continuation cell only as the lost-boundary fallback Writing also describes. Every table-level TAP sprm beyond the merge, height, decoration and row/table-cascade sprms listed above and in Cell decoration — absolute position, table style, cell padding, cell spacing, vertical alignment, 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. One of those unread sprms is worth naming specifically because [MS-DOC] 2.6.3 lists it in the same table as sprmTSetShdTable: sprmTCellShdStyle (0xD687) "specifies the background shading to be applied to an entire table defined by a Table style" — already a table STYLE's own definition (STSH's LPStd), never a row's own direct-formatting grpprl table/tap.ts walks. sprmTCellNoWrapStyle (0x347D), listed in the same 2.6.3 table, states the restriction explicitly, in its own text: "this Sprm is used by table styles and MUST NOT appear outside of the grpprlTapx array of UpxTapx" — sprmTCellVertAlignStyle (0x347C) carries no such sentence of its own, and is scoped to a table style only by its own "as defined by a Table style" wording. This package does not read table styles at all, so there is no real byte stream in which sprmTCellShdStyle could reach that function for it to act on; see table/tap.ts's own top-of-file note.

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) — but that raw ceiling is not the true one a lone, over-large row-mark paragraph can actually reach: a PapxFkp page reserves its own front bytes for the page's element count, its rgfc array and one BxPap entry per paragraph before any record itself is written, and a record's own one- or two-byte length prefix and even-alignment padding cost a little more again — for a single paragraph landing alone on an otherwise-empty page (fitsAloneOnPapxPage's own case, and the one that matters here, since an oversized row mark is exactly what forces itself onto its own page), the true ceiling this leaves for GrpPrlAndIstd is 487 bytes, not 510. sprmTDefTable alone costs 22 bytes per column (2 for the boundary, 20 for the column's own TC80) against a 15-byte fixed overhead — sprmPFInTable and sprmPFTtp (3 bytes each), sprmTDefTable's own opcode and cb (2 bytes each, with no istd field of its own), TDefTableOperand's NumberOfColumns byte and the extra (n+1)th rgdxaCenter boundary every row's TAP carries beyond the per-column figure above, and GrpPrlAndIstd's own istd prefix that buildPapxPage adds ahead of any paragraph's grpprl, table row or otherwise — so 15 + 22 × columns ≤ 487 gives 21 columns as the exact ceiling for an undecorated row — not merely "about 22", the raw-510-byte arithmetic's own naive answer — 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. This ceiling is exactly what the lost-boundary fallback's own per-row budget check (Writing above, ExaDev/documents.js#1013) tests a row's assigned split against, alongside the format's own separate 63-physical-cell-per-row ceiling — a row whose full assigned split would need to state more physical cells than either allows trims down to what does fit instead of hitting this same throw, rather than degrading straight to the unsplit encoding.

A round trip through this package's own writer and reader now recovers colSpan and columnWidthsPt exactly, however a table merges — including when literally every row merges across the identical column boundary (a single-row table with one merged cell is the simplest case), where no row's own rgdxaCenter would otherwise ever state that boundary at all (ExaDev/documents.js#992) — provided every row's own assigned split fits its row-ending mark's own 21-physical-cell byte budget and the format's own separate 63-physical-cell ceiling (see the previous paragraph and Writing above). Writing above states the fix and its one real trade-off: the fallback that keeps such a boundary physically present writes a genuine TCGRF.horzMerge continuation cell, which a real, independent [MS-DOC] implementation (LibreOffice) was confirmed not to read back as a merge at all — so a table only this narrow, pathological case affects shows there as separate, unmerged cells rather than one merged cell, even though this package's own round trip now recovers it correctly. 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 — was, and remains, unaffected either way: it round-trips colSpan and columnWidthsPt exactly via the ordinary narrower/wider physical-cell encoding, with no fallback and no LibreOffice trade-off at all. A row wide enough, or with few enough sibling rows to share the work, that its own full assigned split alone would exceed either ceiling trims down to the largest subset of its own boundaries that still fits, rather than throwing (ExaDev/documents.js#1013): only the trimmed boundaries narrow colSpan/columnWidthsPt on read, onWarning is told which row, how many of its assigned boundaries it kept and how many it dropped, and every other row's own recovery is unaffected — a table's overall recovery degrades by exactly the handful of boundaries one over-budget row could not also state, not by that whole row's entire assignment (only a row for which not even a single boundary fits falls all the way back to the pre-#992 unsplit encoding, the same total loss the fallback's first version always produced for a row that narrow or that decorated). A hand-built or genuinely third-party .doc that itself encodes every row's merge identically without ever using a TCGRF.horzMerge continuation cell is one case this package's own reader still cannot recover a lost boundary for, since that is a fact about bytes this package did not write — and this writer's own lost-boundary fallback now produces the identical, genuinely unrecoverable shape whenever a row's assigned split is trimmed to fit, the more likely source of the two (see Tables above's own note on table/read.ts's reconstruction).

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.

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 LVLs) 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. An LVLF.nfc outside that table's own range reads as "decimal" rather than refusing the whole document -- one byte of one level's own number format is a property-level fact, not structural corruption, the identical degrade principle every unconverted sprm elsewhere in this package already follows -- matching the default the one independent [MS-DOC] implementation checked applies (LibreOffice's WW8ListManager::GetSvxNumTypeFromMSONFC falls through to SVX_NUM_ARABIC), confirmed against a genuine Word-produced .doc carrying exactly such a value (nfc 0x92, well outside MSONFC's 0x00-0x3B/0xFF range). 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.

Written too, as of this package's own inverse -- ooxml.js's own docx writer is unaffected. list/numbering-write.ts is list/numbering.ts's inverse: gatherListUsage walks the document's own flattened paragraph sequence (table cells included, so a list used only inside a cell still resolves) and reconstructs a real NumberingDefinitions from each paragraph's list.numId/.level/.format -- ContentListMembership carries no full level table of its own, only one paragraph's own membership, so the writer mints one from what the document's paragraphs actually use, assigning a fresh one-based ilfo to each distinct numId in first-occurrence order (exactly the value numbering.ts's own reader would assign it back on a re-read, since that reader's numId IS the ilfo, stringified). buildNumberingTables then encodes that NumberingDefinitions into real PlfLst/PlfLfo bytes, and write.ts places both in the Table stream and populates FibRgFcLcb97.fcPlfLst/lcbPlfLst/fcPlfLfo/lcbPlfLfo -- left at 0/0, matching what readNumberingDefinitions itself treats as "no lists", for a document with no list membership at all. buildNumberingTables is independent of gatherListUsage -- a hand-built NumberingDefinitions round-trips through it too, since every LVLF field it writes comes from the definition's own NumberingLevel rather than an assumed default -- and validates accordingly: a definition keyed by a string that collides with another key once both are converted to a number ("1" and "01" both become the lsid 1, even though Object.keys never canonicalises "01" the way it does a true integer-index key) throws a DocFormatError naming both colliding keys, since [MS-DOC] 2.9.147 states lsid "MUST be unique for each LSTF" and readNumberingDefinitions resolves an LFO to its LSTF purely by matching lsid -- a silent collision would make the colliding lists indistinguishable on the next read. gatherListUsage's own minted keys (ilfoByNumId.size + 1, stringified) can never collide this way, so the check is reachable only through a hand-built NumberingDefinitions, the same route every other buildNumberingTables refusal below is reachable through. pap-write.ts's encodeParagraphGrpprl takes the resulting numId-to-ilfo map as its own ilfoOf parameter to write each paragraph's sprmPIlfo/sprmPIlvl. A round trip through this package alone is stable, but a numId minted by a different producer or codec is not preserved verbatim -- [MS-DOC] addresses a list by its one-based ilfo, not an opaque identifier, so it is renumbered to whichever ilfo this document's own write happens to mint it. word/numbering.xml is a separate package's separate decision: it is read into DocxDocument.numbering but never written back by ooxml.js's own docx writer (that package's own stated write scope), and nothing here changes that.

What this writer does NOT write, matching this reader's own gaps below exactly -- there was never anything decoded here for a writer to round-trip. A level's own grpprlPapx/grpprlChpx Prl streams are always written empty (cbGrpprlChpx/cbGrpprlPapx both 0): a real, valid, minimal LVL, just one carrying no per-level direct formatting a real Word list might otherwise have, since NumberingLevel has no field for either. LFOLVL overrides (PlfLfo's own rgLfoData) are never written either, matching the reader never applying them.

What is deliberately not resolved, in either direction, 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 or written at all.
  • grpprlPapx/grpprlChpx. A level's own paragraph/character formatting Prl streams are skipped past by their declared length on read, never decoded, since ContentListMembership has nowhere to carry per-level indent or font direct formatting -- and, for the identical reason, always written empty (see above).
  • 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, and this writer never sets the bit.

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 LVLs 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.

The write side is verified by this package's own round trip, not yet against a third-party implementation. write.test.ts's own describe("writeDocContent numbering") block covers a multi-level list (format/text per level surviving through readDocContent), a bulleted list (its glyph, not a numbered placeholder), separate ilfo minting for separate numIds, a document with no lists at all (no PlfLst/PlfLfo written), and a list membership inside a table cell -- every case reading back through this package's own reader exactly as written. list/numbering-write.test.ts covers buildNumberingTables one layer down, independently of gatherListUsage: the LSTF/LFO byte shape a hand-built NumberingDefinitions produces, the empty-definitions-map case returning undefined, the numerically-colliding-key rejection described above, and that gatherListUsage's own output can never trigger that rejection. Unlike the read side's LibreOffice cross-check above, no independent [MS-DOC] implementation has yet opened a writeDocContent numbering sample: that verification is the next thing worth doing here, mirroring how Tables and Cell decoration were each verified once a real producer's own file was available to compare against.

Sections

[MS-DOC] 2.8.26's PlcfSed divides the main document's own text into sections the identical way Plcfhdd/PlcffndTxt/PlcfandTxt/PlcfendTxt (below) divide their own subdocuments into stories: an array of CPs, each naming where the next section's text begins, "the range of text ends immediately prior to the next CP." prop/sep.ts's readAllSectionProperties resolves every Sed this way, in document order, each into its own page width/height and four margins via applySectionSprms (sprmSXaPage/sprmSYaPage/sprmSDxaLeft/sprmSDxaRight/sprmSDyaTop/sprmSDyaBottom); read.ts's splitIntoSections then walks the main document's own flat paragraph sequence once, advancing to the next section exactly when a paragraph's own end CP matches the next section's start CP — the paragraph carrying the end-of-section character (0x000C) itself, per [MS-DOC]'s own "there MUST also be an end-of-section character... as the final character in the text range of all but the last section." A file with no PlcfSed at all (lcbPlcfSed 0) reads as a single section falling back, field by field, to Word's own new-document default (US Letter, one-inch margins) — the identical fallback a PlcfSed that leaves one sprm unstated already used before multi-section support existed.

Only page size and the four margins are resolved, matching what document-schema.js's own ContentSection (pageSize + margins) has anywhere to hold; every other section sprm (columns, headers/footers distance, vertical justification, page borders, line numbering, and the rest of the roughly seventy [MS-DOC] 2.6.4 names) is absent rather than present-and-ignored, the identical documented-gap convention the paragraph- and character-property tables already use. ContentSection.breakType (how a section begins relative to the one before it -- nextPage/continuous/evenPage/oddPage) is likewise neither read nor written: [MS-DOC]'s own sprmSBkc is a genuinely different sprm from the six this reader already resolves, and nothing here decodes or encodes it yet.

writeDocContent writes every section a ContentDocument carries, not only the first (ExaDev/documents.js#971): prop/sep-write.ts's buildPlcfSed places one real Sed/Sepx pair per section, each stating that section's own pageSize/margins via encodeSectionGrpprl's inverse of applySectionSprms above, and write.ts's own per-section loop closes every section but the last on a genuine end-of-section character (0x000C) -- never a table's own row-ending mark, the identical guarantee Writing states for the Main Document's own final character, applied at every section boundary in between. breakType is not written either, matching the read side.

Subdocuments

The main document is only the first of several logical text ranges [MS-DOC] 2.4.1 concatenates into one CP space, in this fixed order: the main document (0 to ccpText), the footnote document (ccpFtn characters), the header document (ccpHdd), the comment/annotation document (ccpAtn), the endnote document (ccpEdn), and the textbox documents (ccpTxbx/ccpHdrTxbx, not read at all — see below). subdocument.ts's readSubdocumentStories is the one function every subdocument this package reads goes through: it reads a subdocument's own CP range via the identical readTextRange/readParagraphs pipeline the main document uses, then splits the resulting flat paragraph sequence by a boundary plex's own CPs (text/paragraphs.ts's splitEntriesByBoundaries) into one group per story. PlcffndTxt/PlcfandTxt/PlcfendTxt/Plcfhdd all share the identical shape and the identical two conventions this one function handles once: "the story is considered empty" when two consecutive CPs are equal (skipped past, never assigned any entry — a run of several empty stories in a row is handled correctly, not just a single one), and a trailing CP the structure's own page states is "undefined and MUST be ignored" (the last group readSubdocumentStories produces is always dropped). A non-empty story's own final paragraph is dropped only when it is a bare paragraph mark with no content of its own — the guard "not considered part of the story contents" — because the two legal spellings differ exactly there: a header story ends with its content's own final mark PLUS that guard ([MS-DOC]'s Headers page: "if the story contents require a paragraph mark themselves, a second paragraph mark MUST be used"), while a real producer's note stories end at their own content mark with no second one (confirmed against a LibreOffice-authored .doc, and matching PlcffndTxt's own "The range of text MUST end in character 0x0D immediately before the next CP", which that content mark already satisfies). An earlier unconditional drop of every story's final paragraph read the second spelling as though it were the first, losing a single-paragraph note entirely and a multi-paragraph note's last paragraph.

Footnotes, endnotes, and comments (notes.ts) resolve to DocContent.footnotes/.endnotes/.comments: one {id, text} entry per story, id a synthetic one-based ordinal in document order (this reader does not resolve PlcffndRef/PlcfandRef/PlcfendRef's own reference data — the auto-numbering/custom-symbol state a real footnote mark carries — only the order its own text plex already states), text the story's own paragraphs joined by \n. This is a deliberate simplification matching ooxml.js's own DocxDocument.footnotes/.endnotes/.comments, which carry the identical flat-text shape for the identical constructs rather than real block flow.

Headers and footers (headers-footers.ts) resolve to DocContent.headerFooterStories, real block flow instead: Plcfhdd's own fixed layout is six footnote/endnote-separator stories (not read — neither this package's schema nor ooxml.js's DocxDocument has anywhere to put one), then six per section in a fixed order (evenHeader, oddHeader, evenFooter, oddFooter, firstHeader, firstFooter), so each resolved story carries its own section index, slot, and a genuine ContentBlock[] read through the same assembleBlocks pipeline the main document uses — paragraphs and tables both. An empty story means "the header/footer of this kind from the previous section is reused" ([MS-DOC]'s own words) rather than a deliberately blank one, so it is left out of the array entirely instead of appearing as a hollow {blocks: []} entry.

Not read at all: text boxes. PlcfTxbxTxt/PlcfHdrtxbxTxt divide their own subdocument into stories the identical way, but a text box's own anchor is a drawn object (U+0008, PlcfSpa) — the same drawing-layer mechanism Images below does not resolve — so there is no way to reach a text box's own story from the main document's text at all without that layer, and this package does not attempt it.

This subdocument side IS written, symmetrically with the read: writeDocContent's input is widened by the same four fields DocContent carries (WritableDocContent, each optional — a plain ContentDocument writes no subdocument at all), and subdocument-write.ts appends the footnote, header, comment, and endnote subdocuments after the main document's own text in [MS-DOC]'s own concatenation order, each with its genuine boundary plex and its ccp in the FIB. See Writing's stories paragraph for the layout facts and the one deliberate absence (note references).

Images

An inline picture is a character with Unicode value U+0001 ("A picture location that is used in conjunction with sprmCPicLocation") carrying sprmCFSpec and sprmCPicLocation — a signed 32-bit offset into the container's own "Data" stream, a stream with "no predefined structure... referenced from the FIB or from other parts of the file," genuinely optional like "\x05SummaryInformation". pictures.ts's readInlinePicture reads what sits at that offset: a 68-byte PICF (type/size/border information — cbHeader, mfpf.mm, PICF_Shape, PICMID's own dxaGoal/dyaGoal/mx/my and four Brc80 borders this package does not read), an optional cchPicName/stPicName pair when mfpf.mm is MM_SHAPEFILE, then picture: a real OfficeArtInlineSpContainer ([MS-ODRAW] 2.2.15) — every producer this reader has seen writes this container regardless of mfpf.mm's own value, so there is no simpler, non-OfficeArt path even for the plainest bitmap. The container's own shape (an OfficeArtSpContainer, [MS-ODRAW] 2.2.14) is skipped whole by its own 8-byte record header's recLen — this reader has no need for a shape's own fill/line/position properties — and the first entry of rgfb immediately after it, an OfficeArtBlip record, is read directly: its own recInstance states how many 16-byte MD4 rgbUid fields precede the one-byte internal-resource tag and the raw file bytes (one for OfficeArtBlipJPEG's RGB/CMYK-1-UID instances and OfficeArtBlipPNG's 1-UID instance, two for each format's own 2-UID sibling), so blipDataStart/blipDataLength fall out directly from the header once the format is known. widthPt/heightPt come from PICMID's own dxaGoal/dyaGoal (the picture's initial size in twips) scaled by mx/my (each "the ratio, measured in tenths of a percent, between the final display width/height and the initial picture width/height") and converted to points.

Only OfficeArtBlipJPEG (0xF01D) and OfficeArtBlipPNG (0xF01E) are decoded — the two raster formats document-schema.js's ContentImageBlockSchema can hold losslessly (format: "png" | "jpeg" | "svg" | "gif"). Every other blip kind readInlinePicture returns undefined for rather than mis-decoding: a metafile (OfficeArtBlipEMF/WMF/PICT) carries a further OfficeArtMetafileHeader and, for EMF/WMF, DEFLATE-compressed payload bytes this package has no decompressor for; a raw DIB (OfficeArtBlipDIB) has no ContentImageBlock format token to hold it under at all without an image codec to re-encode its pixels as PNG, which this package does not have; OfficeArtBlipTIFF is likewise a format this schema names no token for. A paragraph whose only content is such an anchor still reads as a genuine, empty ContentParagraph — the identical degrade an unresolved anchor already produced before this feature existed — rather than losing the paragraph entirely.

A paragraph is split around a resolved (or attempted) picture, never left as a run. An inline image is block-level in document-schema.js's own model, so text/paragraphs.ts's buildParagraphBlocks splits the paragraph's own text at each U+0001 anchor into however many ContentBlocks the anchors demand — a leading paragraph, the image, a trailing paragraph, and so on for a paragraph carrying more than one — every split paragraph piece keeping the whole paragraph's own alignment/spacing/list attributes unchanged, mirroring how ooxml.js's own docx reader already splits a paragraph around a mid-run page break. A paragraph containing nothing but one unresolved picture anchor (an unsupported blip format, or no "Data" stream at all) collapses to the identical single empty ContentParagraph a plain anchor-only paragraph always produced; a paragraph that resolves to at least one real block (text, an image) never gains a synthetic empty wrapper alongside it.

Not read: floating/anchored drawn objects and text boxes. A floating picture is a different anchor entirely — U+0008 ("A drawn object. See plcfSpa"), referenced by a PlcfSpa (SPA records, 26 bytes each, one spid per anchor) that names a shape inside a document-level OfficeArtDgContainer drawing tree rather than a single inline blip a Chpx sprm points at directly. That shape need not even be a picture — an arbitrary OfficeArtSpContainer (autoshape, connector, text box) — so resolving one properly means walking the whole drawing tree, not decoding one more blip kind; this package does not attempt it, and the anchor character drops exactly as any other unresolved anchor does.

Written, for PNG and JPEG (ExaDev/documents.js#971). pictures-write.ts's buildInlinePicture is readInlinePicture's own inverse: a ContentImageBlock becomes the identical PICF (68 bytes, mfpf.mm always MM_SHAPE — this writer never emits MM_SHAPEFILE's own source-filename pair) followed by an empty OfficeArtInlineSpContainer.shape (a bare 8-byte record header, recLen 0 — this writer states no shape properties of its own, matching that the reader never looks inside it either) and a single-rgbUid OfficeArtBlip record (OfficeArtBlipPNG/OfficeArtBlipJPEG, recInstance 0x06E0/0x046A) wrapping the image's own raw file bytes verbatim, with PICMID.dxaGoal/.dyaGoal stating the image's own real size in twips and mx/my always 1000 ("no scaling"). table/write.ts's flattenSectionBlocks places each ContentImageBlock as its own one-run paragraph carrying a U+0001 anchor and sprmCPicLocation, exactly matching what buildParagraphBlocks (above) splits back out on the next read; data-stream.ts's DataStreamBuilder accumulates every picture's own bytes across the whole document into one real "Data" stream, shared across every section, only added to the compound file at all when the document carries at least one picture. svg/gif, and any format beyond PNG/JPEG, throw DocUnsupportedError rather than fabricating a blip this package's own reader could not decode back (see the read-side scope row above).

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), 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.

The mapping is not 1:1, and each gap is permanent rather than a remaining TODO:

Direction Fields covered Gap
SummaryInformation → LayoutMetadata title, subject, author, keywords, createdIso, lastSavedIsomodifiedIso 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.
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.

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.

Encryption

A .doc protected with a password to open uses one of three schemes [MS-DOC] 2.2.6 names, selected by FibBase's own fEncrypted/fObfuscated flags: RC4 encryption ([MS-DOC] 2.2.6.2, this reader), XOR obfuscation (2.2.6.1, this reader), or RC4 CryptoAPI (2.2.6.3, a different, newer EncryptionHeader shape). readDocContent/readDocStreams take an optional password, ignored for an unencrypted document; a missing or incorrect password against an encrypted or obfuscated one throws DocUnsupportedError rather than returning a partial or garbled document, and so does an RC4-CryptoAPI-encrypted one regardless of password, since that scheme alone is not implemented.

import { readDocContent } from "doc-codec";

const document = readDocContent(bytes, "correct horse battery staple");

[MS-DOC] 2.2.6.2's own EncryptionHeader is [MS-OFFCRYPTO] 2.3.6.1's RC4 encryption header — byte-identical to what xls-codec's own FilePass record carries (#1108), so src/encryption.ts needs no new cryptography for RC4, only archive-codec's shared deriveOfficeRc4BaseHash/decryptOfficeRc4 primitives and the doc-specific container layout, which differs from BIFF8's in three real ways rather than being a copy of xls-codec's own scheme:

  • Location. The EncryptionHeader sits unencrypted at the very start of the Table stream (0Table/1Table, whichever FibBase.fWhichTblStm selects), its own byte length given by FibBase.lKey — unlike FilePass, an inline record within the Workbook stream.
  • Re-keying interval. 512 bytes, not xls-codec's 1024 (archive-codec's OFFICE_RC4_DOC_BLOCK_SIZE) — a real [MS-DOC]-specific value, confirmed against Apache POI's BinaryRC4Decryptor independently of xls-codec's own Biff8DecryptingStream.
  • Per-stream block-zero origin. WordDocument and Table are each encrypted independently, each with its own block-number counter starting at zero at that stream's own byte 0 — unlike xls-codec's single continuous Workbook-stream offset. WordDocument's own unencrypted prefix is a fixed 68 bytes ([MS-DOC] 2.2.6.1/2.2.6.2's own stated literal); Table's is FibBase.lKey, since the EncryptionHeader occupying it has to be readable before any key can be derived at all. The Data stream is also encrypted in full per the spec, but this reader does not read the Data stream at all today, so decrypting it is out of scope until something needs to.

XOR obfuscation (Method 2) needs archive-codec's own createXorObfuscationKey/createXorObfuscationPasswordVerifier/createXorObfuscationArray/decryptXorObfuscationMethod2 (see archive-codec's own XOR obfuscation section) — genuinely simpler than RC4's own container layout, in one real way: FibBase.lKey carries the 32-bit password verifier itself here (its high 16 bits createXorObfuscationKey's own output, its low 16 bits createXorObfuscationPasswordVerifier's), not a Table-stream EncryptionHeader byte length, so the Table stream needs no unencrypted prefix at all and is obfuscated in full from its own byte 0 — confirmed against LibreOffice's own WW8 import (ww8par.cxx's DecryptXOR, called with no prior seek for the Table stream, unlike WordDocument's own explicit 68-byte copy-then-decrypt). WordDocument's own 68-byte unencrypted prefix is unchanged from the RC4 case.

writeDocContent never encrypts.

Writing

writeDocContent takes a 'wordprocessing' ContentDocument with one or more sections, an optional WriteDocContentOptions, 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), 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 real style sheet minting one entry per distinct styleId/headingLevel a document's paragraphs actually use (style/stsh.ts's buildStshForStyles, ExaDev/documents.js#1059 — identity only, no formatting of a style's own; FibRgFcLcb97.lcbStshf "MUST be a nonzero value", so a document is never written without one, even one carrying zero styles, though this package's own reader tolerates a missing one), a font table when at least one run names a font (style/fonts.ts), a real PlcfSed/Sepx array with one entry per section (prop/sep-write.ts's buildPlcfSed, ExaDev/documents.js#971 — see Sections), the four story subdocuments and their boundary plexes when the input carries stories (subdocument-write.ts — see the stories paragraph below and Subdocuments), and a "Data" stream carrying every inline picture's own real bytes when the document has at least one (pictures-write.ts's buildInlinePicture, data-stream.ts's DataStreamBuilder, ExaDev/documents.js#971 — see Images). The input is a WritableDocContent — a ContentDocument widened by the same optional story fields DocContent reads (see the stories paragraph below) — so a plain ContentDocument still writes exactly as it always did. WriteDocContentOptions.onWarning, when given, is called for a non-fatal write-time degradation this writer chooses over throwing — today, only the lost-boundary fallback's own per-row budget check (see Tables's own note on ExaDev/documents.js#1013) — but that is not a guarantee the write itself goes on to succeed: a row whose own assigned lost boundaries cannot be trimmed down to a split that fits at all still reports one warning, describing its boundaries as unrecoverable and its fully-unsplit encoding as the fallback being attempted — and, should that unsplit encoding also overflow the row's own byte budget once buildPapxPages actually packs it, writeDocContent can still throw further down the same pipeline, after any rows still to come have had their own chance to report a warning: a caller can genuinely see this warning followed by a hard failure, but not necessarily right away, and not necessarily for the row whose warning it just read, since buildPapxPages packs paragraphs in document order and can fail first on an earlier row that also overflowed. Every genuine refusal this writer makes outright — the kinds named in the table below — still throws DocFormatError/DocUnsupportedError directly, with no warning first.

Character properties this writer converts, the exact inverse of 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, indentRightPt, indentFirstLinePt, spacingBeforePt, spacingAfterPt, lineSpacing (only LSPD's multiplier form, matching the reader), pageBreakBefore, styleId/headingLevel (via the minted STSH entry above), and list membership (sprmPIlfo/sprmPIlvl — see Numbering definitions for the numbering tables this depends on).

A pageBreak block is written as the format's own manual page break — the end-of-section character (0x000C) placed where no section ends, the exact shape [MS-DOC]'s own PlcfSed.aCP text defines: "An end-of-section character (0x0C) which occurs at a CP and which is not the last character in a section specifies a manual page break." The two spellings of that one character are distinguished purely by PlcfSed's own boundaries, so read.ts's markManualPageBreaks decodes a 0x000C-terminated paragraph whose end CP is not a following section's start CP into [paragraph, pageBreak], and table/write.ts's appendPageBreak writes the inverse: a page break retargets the preceding paragraph's own terminator from an ordinary paragraph mark to 0x000C, which is why the ordinary case ([paragraph alpha, pageBreak, paragraph beta]) round-trips exactly with no stray empty paragraph — the break rides a paragraph terminator the format already requires there. A page break with no ordinary paragraph before it to carry it (a section's first block, directly after a table's row mark, or directly after another page break) becomes its own empty 0x000C-terminated paragraph instead, visible in the round trip as a [paragraph "", pageBreak] pair the input did not state: a page break in [MS-DOC] always terminates SOME paragraph, so a modelled break with nothing before it necessarily mints one — a faithful statement of the format's own limit rather than a loss. Verified against a real, independent [MS-DOC] implementation: LibreOffice opens a writeDocContent sample carrying a page break between two paragraphs and re-exports it with a genuine fo:break-before="page" on the paragraph after the break, both paragraphs' text intact.

Story subdocuments are written, symmetrically with the readwriteDocContent's input is widened from a bare ContentDocument to a WritableDocContent: the same four story fields readDocContent's own DocContent output carries (footnotes/endnotes/comments as {id, text} bodies, headerFooterStories as real block flow), each optional, exactly the input shape ooxml.js's own DocxContent established for the identical constructs — so a genuine DocContent assigns straight across and a plain ContentDocument writes no subdocument at all. subdocument-write.ts appends the footnote, header, comment, and endnote subdocuments after the main document's own text in [MS-DOC]'s own concatenation order, states each one's ccp in the FIB, and writes each one's genuine CP-only boundary plex (PlcffndTxt/PlcfHdd/PlcfandTxt/PlcfendTxt). Every layout fact is the one a real producer writes, each confirmed against a LibreOffice-authored .doc before being implemented: each non-empty story's span ends with its content's own final mark plus the one guard mark [MS-DOC]'s Headers page mandates, the subdocument carries exactly one further paragraph mark beyond the last story (what makes each plex's own "second-to-last CP MUST be equal to ccp - 1" satisfiable), note text is split into paragraphs at the same newlines the reader joined on (so a note whose text ends in \n round-trips exactly, its trailing empty paragraph protected by the writer's separate guard), a header story flattens through the identical flattenSectionBlocks the main document uses (tables and inline pictures included, sharing the one "Data" stream), an absent slot stays a genuinely empty story — the "reuse the previous section's header/footer of this kind" meaning the reader gives one — while a story the model carries is written as present even when its blocks flatten to nothing, and a story naming a section the document does not have, or a second story for one slot, throws DocFormatError. The one deliberate absence is the REFERENCE side: PlcffndRef/PlcfandRef/PlcfendRef and the U+0002/U+0005 reference characters in the main document are not written, because the model carries no reference positions to write them from (the reader drops the anchors; note ids are document-order ordinals) — a written note story is honestly unreferenced content rather than a fake live footnote. Verified against a real, independent [MS-DOC] implementation: LibreOffice opens a writeDocContent sample carrying footnotes, endnotes, comments, a header, and a footer without error, resolves the header and footer stories in full (their text appears in the re-exported master page's style:header/style:footer), and ignores the unreferenced note stories; a full read→write→read cycle over a LibreOffice-authored .doc reproduces every note body and every (section, slot) story exactly.

Deliberately not handled, beyond what the read-side scope table above already states applies to both directions:

Absent Consequence
Non-paragraph, non-table, non-image blocks An embeddedObject or construct-boundary marker (constructStart/constructEnd) block throws DocUnsupportedError naming its own kindContentTable, ContentImageBlock, and pageBreak are now written (see Tables, Images, and the page-break paragraph below); a non-paragraph block found inside one of a table's own cells throws the identical error. An embedded object rides the same PlcfSpa/OfficeArtDgContainer drawing-tree mechanism this package's reader does not resolve at all (see the drawn-objects row above); a construct-boundary marker has no [MS-DOC] representation of its own to target until the cross-format document-schema.js design work tracked on ExaDev/documents.js#1122 lands.
An empty ContentSection.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.

Why the writer always writes uncompressed text

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, 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.

Architecture

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.

It depends on exactly two siblings: archive-codec for the [MS-CFB] container — readCompoundFile on the read side, writeCompoundFile on the write side — and document-schema.js 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.

graph TD
    archive("archive-codec")
    schema("document-schema.js")
    doc("doc-codec")
    archive --> doc
    schema --> doc
    style doc fill:#f9a825,stroke:#333,stroke-width:3px
Loading

The modules layer in the order [MS-DOC]'s own algorithms chain:

Module What it does
src/bytes.ts Bounds-checked little-endian reads; every offset in the format is attacker-controlled data, so an over-read fails loudly.
src/base64.ts Isomorphic base64 <-> Uint8Array conversion, shared by pictures.ts (encode) and pictures-write.ts (decode) for an inline picture's own raw file bytes.
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.
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.
src/text/piece-table.ts The Clx and its PlcPcd, and the character-position-to-byte-offset mapping.
src/text/characters.ts Text reconstruction, including the compressed-byte mapping table.
src/text/special.ts The characters that carry structure rather than glyphs.
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.
src/prop/sprm.ts Sprm decoding and the operand-size table that makes a grpprl walkable.
src/prop/fkp.ts The formatted disk pages and the bin tables that address them.
src/prop/chp.ts, src/prop/pap.ts Folding a grpprl into character and paragraph properties.
src/list/numbering.ts Resolving PlfLst/PlfLfo into NumberingDefinitions (see Numbering definitions).
src/style/stsh.ts The style sheet, plus resolving a style's own inherited formatting up its istdBase chain.
src/style/fonts.ts The font table (SttbfFfn/FFN) — read and write together, since both directions share one small, self-contained field layout.
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).
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).
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.
src/table/read.ts Grouping a contiguous run of table-depth paragraphs (from read.ts's own flat sequence) into a real ContentTable, recursing into a nested table at whatever depth the file states.
src/text/paragraphs.ts Splitting a paragraph around an inline picture anchor, and resolveIndirectPapx's own sprmPHugePapx indirection chase into the Data stream.
src/subdocument.ts The shared subdocument/story-plex reader every footnote/endnote/comment/header-footer story goes through, including readStoryPlexKeys's own leniency for a genuine Word 97 file's out-of-specification placeholder keys.
src/read.ts The whole read chain, to a ContentDocument.
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.
src/text/piece-table-write.ts Builds a Clx describing the whole logical text stream as one uncompressed piece.
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.
src/list/numbering-write.ts The inverse of list/numbering.ts: a document's own paragraph list memberships to real PlfLst/PlfLfo bytes, and the numId-to-ilfo map pap-write.ts needs (see Numbering definitions).
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.
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.
src/table/write.ts Expanding a ContentTable into its own real physical-cell paragraph stream, and a ContentImageBlock into its own one-run picture-anchor paragraph, for write.ts's own paragraph pipeline to lay out like any other paragraph.
src/pictures-write.ts The inverse of pictures.ts: a ContentImageBlock to its own PICF/OfficeArtInlineSpContainer/blip bytes and sprmCPicLocation grpprl (see Images).
src/data-stream.ts Accumulates every inline picture's own bytes across a whole write into one real "Data" stream, handing each one back the offset it landed at.
src/prop/sep-write.ts The inverse of prop/sep.ts: a document's own sections to a real PlcfSed/Sepx array, one entry per section (see Sections).
src/subdocument-write.ts The inverse of subdocument.ts/notes.ts/headers-footers.ts: the footnote, header, comment, and endnote subdocuments and their CP-only boundary plexes (see Subdocuments).
src/write.ts The whole write chain, from a ContentDocument to real [MS-DOC] bytes in a real [MS-CFB] compound file.

Why the piece table gets the most attention

A .doc's text is assembled from pieces, each naming a byte range of the WordDocument stream and the character positions that range supplies, and every other structure in the format addresses text by character position. Two details carry most of the risk, and both live in one 32-bit field: the low 30 bits are a byte offset, and bit 30 says whether the piece's characters are 16-bit (offset used as-is) or 8-bit (the real offset being that value halved). Forget the halving and the reader does not fail — it produces real characters from the wrong place in the stream.

That is the failure mode this package is built to avoid throughout: a binary parser that guesses does not crash, it corrupts. So every structure here was implemented against the specification's own field tables, with a failing test written first from those tables, and the piece table is additionally tested against [MS-DOC] 2.9.6's published worked example — the same Clx, the same four character positions, the same three pieces, the same "Hello World." the specification says they assemble to.

Getting started

Requires Node.js >=20 and pnpm 11.6.0 (pinned via packageManager in package.json).

pnpm install
pnpm build
pnpm test
import { readDocContent, writeDocContent, isDocBytes } from "doc-codec";

const bytes = new Uint8Array(await file.arrayBuffer());
if (isDocBytes(bytes)) {
  const document = readDocContent(bytes);
  // document.kind === "wordprocessing"
}

const written = writeDocContent({
  kind: "wordprocessing",
  metadata: {},
  sections: [
    {
      pageSize: { widthPt: 612, heightPt: 792 },
      margins: { topPt: 72, rightPt: 72, bottomPt: 72, leftPt: 72 },
      blocks: [{ kind: "paragraph", runs: [{ text: "Hello.", bold: true }] }],
    },
  ],
});

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 (RC4 CryptoAPI encryption, an encrypted or obfuscated document given no password or the wrong one, or the sprmPChgTabs sentinel above). writeDocContent throws a DocUnsupportedError for a document, section count, or block kind outside its own scope (see 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).

Worker-isomorphic

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.

Testing

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.

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, a dedicated describe("writeDocContent numbering") block covering a multi-level list's own format/text per level, a bulleted list's glyph, separate-ilfo minting for separate numIds, a no-lists document writing no numbering tables at all, and a list membership inside a table cell (see Numbering definitions), 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 lost-boundary fallback recovering colSpan/columnWidthsPt for a single-row merge and for a multi-row table that merges the identical boundary in every row (ExaDev/documents.js#992), that same fallback's own per-row budget check degrading gracefully via onWarning instead of throwing once a row's assigned split would overflow its row-ending mark -- a two-row table sitting exactly at the 21-physical-cell ceiling versus one column past it (only the over-budget row trims, so the other row's #992 recovery survives, and the trimmed row itself still recovers all but one of its own assigned boundaries rather than losing all of them), a single-row table one column past that same ceiling recovering all but one of its own boundaries the identical way with no sibling row to share the work with, and a single-row table whose full assigned split would need 64 physical cells -- one past TDefTableOperand's own hard NumberOfColumns ceiling ([MS-DOC] 2.9.321's own "MUST NOT exceed 63", not 2.4.3's separate "between 1 and 63 table cells" limit) -- trimming down to the row-ending mark's own byte-budget ceiling instead of throwing the way this writer used to before that check ran ahead of encodeTableRowGrpprl's own unconditional throw past it (ExaDev/documents.js#1013), the nested-table refusal, and a describe("cell decoration") block round-tripping a solid background fill, a genuine two-colour pattern fill (a percentage grey, a crosshatch -- #951), 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, every pattern IPAT_TO_PATTERN_TYPE names (percentages and stripe/cross families alike) alongside ipatNil and an ipatPctNew* value with no ST_Shd equivalent, the DocUnsupportedError a SpreadsheetML-only pattern name throws on write, 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 and #895 were each root-caused by comparing this writer's own bytes against a genuine LibreOffice-authored .doc; see Tables for the full findings.

A describe("writeDocContent multiple sections") block (ExaDev/documents.js#971) covers two and three sections each round-tripping their own distinct page size and margins independently, and a non-final section whose own last block is a table still closing on a genuine paragraph mark before its end-of-section character, rather than landing on the table's own row-ending mark. A describe("writeDocContent page breaks") block covers the ordinary [paragraph, pageBreak, paragraph] round trip (including the terminated paragraph's own formatting surviving), a second consecutive break, a leading break, a break directly after a table (never retargeting the row mark), and a break as a non-final section's last block — alongside read.test.ts's own page-break tests over hand-built bytes, which pin the read-side distinction (a 0x000C at a PlcfSed boundary is a section end and gains no block; one elsewhere is a pageBreak) against the specification's own PlcfSed.aCP sentence. The write side is additionally verified against a real, independent [MS-DOC] implementation: LibreOffice opens a written page-break sample and re-exports a genuine fo:break-before="page" on the paragraph after the break. A describe("writeDocContent stories") block covers footnote/endnote/comment bodies round-tripping as plain text (a note carrying newlines and a trailing empty paragraph included, which is what the writer's separate guard mark exists to protect), a document stating no stories writing no subdocument at all, header/footer stories round-tripping per (section, slot) with absent slots staying absent, a header story carrying a table through the identical table pipeline, a story whose blocks flatten to nothing reading back as present-but-blank, the DocFormatError refusals for a story naming a section the document does not have and for two stories sharing a slot, and a full DocContent re-writen unchanged — the read side of that last case additionally run over a genuine LibreOffice-authored .doc in Writing's stories paragraph. A describe("writeDocContent inline pictures") block covers a PNG and a JPEG image round-tripping their own raw bytes/format/size, a paragraph's own text splitting around an inline picture into separate blocks, more than one picture landing at distinct offsets in the same "Data" stream, an unwritable format (svg) throwing DocUnsupportedError, and no "Data" stream at all being written for a picture-free document. The inline-picture block has not itself been checked against a real, independent [MS-DOC] implementation the way Tables and Numbering definitions have -- that is the next thing worth doing here, mirroring how each of those was itself first verified by this package's own round trip alone.

A real-producer corpus layer exists: pnpm test:corpus runs the gitignored test/corpus/ suite against LibreOffice-produced Word 97 documents generated by scripts/generate-corpus.mjs (flat-ODT and packaged-ODT sources spanning paragraphs, runs, headings, lists, tables with merges, images, and sections, converted headlessly through Writer's own export filter). That corpus already paid for itself once: it exposed that the reader's inline-picture locator assumed Word's OfficeArt wrapper nesting, which LibreOffice does not follow, and the locator now validates blip candidates by their payload file signatures instead. What a LibreOffice-produced corpus does not prove remains stated plainly: it is a real application's Word 97 spelling, not Word 1997-2007's own.

A second, independent corpus layer closes that gap (ExaDev/documents.js#1193): scripts/fetch-word-corpus.mjs downloads a genuine Word-97-through-2003-produced .doc set from Apache POI's own public test-data repository (itself assembled from real bug-report attachments and, for two files, a public web crawl), pinned to one POI commit and verified per file at fetch time — a recorded sha256, the WordDocument stream's own 0xA5EC signature, the effective nFib [MS-DOC] 2.5.1's own rule computes, and, where the file carries one, the "\x05SummaryInformation" stream's own application-name property stating the exact Word version. node scripts/fetch-word-corpus.mjs (after a build; requires network) writes the verified files, a manifest.json recording each one's producer/origin/provenance, and a generated corpus.test.ts under test/corpus/word/, beside — never disturbing — generate-corpus.mjs's own output at the test/corpus/ root. This corpus already found and fixed four genuine defects no LibreOffice-produced fixture had reached: the sprmPHugePapx paragraph-property indirection two Word-2003 files state their wide table rows' TAP through; a Word-97 file's own out-of-specification Plcfhdd placeholder keys; an LVLF.nfc value outside MSONFC's own table; and a table-flagged paragraph run with no row-ending mark anywhere in it, which is not a table at all rather than a malformed one. See each fix's own note above for the file and the finding.

Specification

Every structure in this package cites the section of [MS-DOC] it implements. The specification is published by Microsoft under its Open Specifications programme:

Licence

MIT. See LICENSE.