doc-codec
v6.0.1
Published
A hand-written reader for the Word Binary File Format ([MS-DOC], .doc) against the shared document-schema.js content pivot: FIB parsing, piece-table text reconstruction, and CHPX/PAPX formatting exceptions.
Maintainers
Readme
doc-codec
A hand-written, dependency-minimal reader and writer for the Word Binary File Format (
.doc, [MS-DOC]) against the shareddocument-schema.jscontent 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 FIB —
readDocStreamsresolves theWordDocumentstream and whichever of1Table/0TableFibBase.fWhichTblStmselects, then parses the File Information Block for the counts and offsets every later step needs. - The piece table —
parseClxresolves aClx(skipping any leadingPrcarray) into the pieces the logical text stream is assembled from, including the compressed 8-bit spelling and its halved byte offset. - Text reconstruction —
readTextRangeturns 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/PlcBtePapxbin tables and theChpxFkp/PapxFkppages behind them, theSprm/Prloperand-sizing rules, and the subset of the character- and paragraph-property tables listed under What is converted, now includingsprmCRgFtc0's font-table lookup (see The font table). - The style sheet —
parseStshreads each style's index, name, kind, parent, and (for a paragraph or character style) its owngrLPUpxSwformatting sets;resolveStyleFormattingwalks theistdBaseinheritance 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).headingLevelFromIstdappliessprmPIstd's own rule that anistdof 1 through 9 states an outline level. - Tables, including nested tables —
table/read.ts'swalkBlocksAtDepth/assembleBlocksfold a contiguous run of table-depth paragraphs into a realContentTable, at whatever nesting depth the file states: cell boundaries at each cell-mark (0x07) character at depth 1, or a paragraph mark (0x000D) carryingsprmPFInnerTableCellone 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 throughtable/tap.ts'sapplyTableSprmsfor its TAP — column boundaries and every physical cell's own horizontal/vertical merge state, fromsprmTDefTable'sTDefTableOperand(and asprmTMergerange orsprmTVertMergeper-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 viasprmPItap/sprmPFInnerTableCell/sprmPFInnerTtp) resolves to a real nestedContentTable, to whatever depth the file actually states, rather than being refused. A row whose own TAP this reader cannot resolve at all — no directsprmTDefTableanywhere 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 decoration —
ContentTableCell.backgroundand.borders, from every encoding [MS-DOC] states them in: each cell's own fourBrc80borders insideTC80, the exact-coloursprmTSetBrclayer a real producer writes beside them, the row-level shading arrays (sprmTDefTableShd/2nd/3rd, theirRawcounterparts, the Word 97-erasprmTDefTableShd80, andsprmTSetShd/sprmTSetShdOdd), and, cascaded onto whichever cells and sides those leave unstated, the row/table-levelsprmTTableBorders/sprmTTableBorders80border set andsprmTSetShdTable's own per-row background. See Cell decoration. readDocContent— the whole chain, producing a'wordprocessing'ContentDocumentof paragraphs, runs and tables.isDocBytes— distinguishes a.docfrom the.xls,.pptand OLE embeddings that share its container, by looking for aWordDocumentstream carryingFibBase.wIdent.- Document metadata —
title/subject/author/keywords/createdIso/modifiedIsoread from a"\x05SummaryInformation"stream when one is present (see Metadata);commentsandlastPrintedIsoremain unread, sinceLayoutMetadatahas no field for either. - RC4-encrypted and XOR-obfuscated documents, decrypted for real (ExaDev/documents.js#1113, #922) —
readDocContent/readDocStreamstake an optionalpassword, verified against the Table stream's ownEncryptionHeaderfor RC4, or againstFibBase.lKeydirectly for XOR obfuscation (see Encryption). RC4 CryptoAPI stays refused. - Numbering definitions —
readDocContent's ownnumberingfield: every list's glyph/format, level-text template, and start-at value, resolved fromPlfLst/PlfLfoand keyed by the samelistIda paragraph'sContentParagraph.list.numIdalready carries. See Numbering definitions, which also coverswriteDocContent's own inverse. - Every section's own page size and margins —
prop/sep.ts'sreadAllSectionPropertiesresolves everySedPlcfSedcarries, each into its ownContentSection.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 noPlcfSedat all, or for any one sprm a section leaves unstated.read.ts'ssplitIntoSectionsgroups 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.docnow reads as multipleContentSections, 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 realpageBreakblock attached to the paragraph it terminates (read.ts'smarkManualPageBreaks) — [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"), andwriteDocContentwrites that exact shape back (see Writing). - Footnotes, endnotes, and comments, as plain text —
notes.ts'sreadNoteBodiesresolvesPlcffndTxt/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") intoDocContent.footnotes/.endnotes/.comments: one{id, text}/{id, text}entry per story,ida synthetic one-based ordinal, matching the same simplificationooxml.js's ownDocxDocument.footnotes/.endnotes/.commentsalready 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 flow —
headers-footers.ts'sreadHeaderFooterStoriesresolvesPlcfhdd(the identical CP-only-plex shape as the note-text plexes above) intoDocContent.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 realContentBlock[]— 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 norooxml.js'sDocxDocumenthas anywhere to put a separator story. A genuine Word 97-authored file's ownPlcfhddcan carry placeholder keys the published specification's own bounds forbid —-1entries 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'sreadStoryPlexKeysnormalises 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 pictures —
pictures.ts'sreadInlinePictureresolves a picture character (U+0001,sprmCFSpecapplied) through its own Chpx'ssprmCPicLocationoperand: an offset into the container's"Data"stream where aPICF([MS-DOC] 68-byte type/size/border header) is followed, for every producer this reader has seen, by a realOfficeArtInlineSpContainer([MS-ODRAW] 2.2.15) regardless ofPICF.mfpf.mm's own value. The container's ownshape(anOfficeArtSpContainer) is skipped whole by its own record header'srecLen, and the first blip record inrgfbimmediately after it is decoded when it isOfficeArtBlipJPEG/OfficeArtBlipPNG— the two raster formatsdocument-schema.js'sContentImageBlockcan hold losslessly — into a real inline image, sized fromPICMID's owndxaGoal/dyaGoalscaled bymx/my. A paragraph containing a picture anchor is split around it into a realContentBlock[](text/paragraphs.ts'sbuildParagraphBlocks), since an inline image is block-level indocument-schema.js's own model rather than a run property — mirroring howooxml.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 properties —text/paragraphs.ts'sresolveIndirectPapxfollows a paragraph grpprl that opens withsprmPHugePapx(0x6646) to thePrcDatait names in the Data stream, up to 16 hops per [MS-DOC] 2.6.2's own chain-termination requirement, and reads thatPrcData's ownGrpPrlin its place — the mechanism Word reaches for once a paragraph's direct formatting no longer fits its own 512-bytePapxFkppage. Confirmed against two genuine Word-2003-produced files whose wide table rows state their whole TAP this way rather than through a directsprmTDefTable(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 aDocContentread 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, realChpxFkp/PapxFkppages (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 distinctstyleId/headingLevela document's paragraphs actually use (style/stsh.ts'sbuildStshForStyles, ExaDev/documents.js#1059), a font table when a run names one, realPlfLst/PlfLfonumbering tables when at least one paragraph carrieslist(see Numbering definitions), a realPlcfSed/Sepxarray 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 realPICF/OfficeArtInlineSpContainer/blip bytes (ExaDev/documents.js#971, see Images), every run carryingContentRun.hyperlinkas a realHYPERLINKfield 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 viaarchive-codec'swriteCompoundFile. AContentTableblock is expanded bytable/write.ts'sflattenSectionBlocksinto the same flat paragraph sequence every other block already is (see Tables), so table paragraphs flow through the identicalChpxFkp/PapxFkppaging as every other paragraph rather than a separate table-only path. Each cell's ownbackgroundandbordersride along into the row's TAP (see Cell decoration). ApageBreakblock is written as the format's own manual-page-break spelling of 0x000C (see Writing).- Every property
writeDocContentwrites is verified by reading it back through this package's ownreadDocContent(src/write.test.ts), and additionally against a real, independent [MS-DOC] implementation: LibreOffice opened, rendered, and re-exported awriteDocContentsample 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 ownfo:page-width/fo:page-height/fo:margin-*export to the exact point), and a table — recognised as a genuinetable:table, its row/column/cell structure and both horizontal and vertical merges intact, matching realtable:number-columns-spanned/table:number-rows-spannedattributes andtable:covered-table-cellelements, 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 Images — ExaDev/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, ExaDev/documents.js#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. The rows it returns are dense (ContentTableCell's grid rule in document-schema.js): one entry per grid column, the anchor carrying colSpan/rowSpan and a block-less entry at every other position its region covers, horizontally and vertically alike, with a row narrower than the shared grid filled out by block-less entries. A physical cell whose boundaries snap to one grid entry (a legal zero-width cell) owns no grid column, so its content moves into the neighbouring cell rather than being dropped. 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 (SwTableLine → SwTableBox, each box carrying its own width), and sw/source/filter/inc/wrtswtbl.hxx answers it, on its own ODF export (the point at which it projects that per-row model onto one shared grid, sw/source/filter/xml/xmltble.cxx's SwXMLTableColumn_Impl), with #define COLFUZZY 20 twips, SwWriteTableCol::operator== treating two column positions as equal when they differ by at most that. Its changeover was confirmed empirically and exactly, not assumed: patching a single int16 inside a real LibreOffice-authored table's second row and round-tripping it through that implementation's own .doc import followed by its ODF export gives three columns and no covered cell for a drift of 1 through 20 twips, and four columns with a real table:covered-table-cell from 21 (the .doc import side alone preserves the drifted boundary byte-for-byte — the fuzz is applied on export, not import). Per-row drift is not hypothetical even without Word or LibreOffice's own export step in the picture: WW8TabDesc::CalcDefaults widens any imported cell narrower than that same implementation's own minimum cell width (MINLAY, 23 twips in sw/inc/swtypes.hxx) by mutating boundaries per row during .doc import itself, so a document that has been through that import is one real mechanism by which per-row drift reaches a .doc at all.
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 merged region's anchor on each row, classified by the shared walkTableGrid over the table's dense rows (one entry per grid column) — never expanded into extra synthetic cells for a colSpan greater than 1, since the entries an anchor covers along its own row contribute no physical cell of their own. A position covered from an earlier row is written as that region's vertical continuation (TCGRF.vertMerge = fvmMerge), one physical cell as wide as the region and at the region's own grid column; this comes from the grid walk rather than from state tracked across rows, because a genuinely blank cell has the identical {blocks: []} shape as a covered one and only the anchors' spans tell them apart, so a cell merged both horizontally and vertically at once, or a colSpan anchor sitting before a rowSpan anchor in its row, writes correctly. A table that breaks the grid rule (checked once, by document-schema.js's findTableGridFault: a covered entry carrying blocks or a span, rows of differing lengths, or a region running past the grid or into another) is refused with a DocFormatError rather than written, since a region's content belongs to its anchor and would otherwise be lost. An anchor or continuation with no content becomes a single empty paragraph. 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 ow
