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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion packages/doc-codec/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,9 @@ Fields are handled structurally: everything between a field-begin (`U+0013`) and

A table in [MS-DOC] is not a separate container: it is a run of ordinary paragraphs marked `sprmPFInTable`, with cell boundaries at literal `0x07` cell-mark characters in the text stream and each row closed by its own row-ending mark — a cell mark additionally carrying `sprmPFTtp` — per [MS-DOC] 2.4.3's own Overview of Tables. `src/table/` implements exactly this model, at table depth 1 only; a table nested inside a table cell is refused rather than mis-read (see the "Nested tables" row in the scope table above).

**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.339-341) — 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.4 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: [MS-DOC]'s own physical-cell model keeps every horizontally- and vertically-merged-away cell present in the text stream with its own cell mark and its own `TC80` entry — never omitted the way OOXML's `w:gridSpan` model omits a horizontally-merged-away `<w:tc>` outright — so a horizontal-continuation cell stated the legacy way (`TCGRF.horzMerge` = 1, still honoured for a genuine third-party producer that uses it) is folded into the preceding real cell's own `colSpan` exactly as before, while a genuinely narrower, wider physical cell (no flag, LibreOffice's own encoding) resolves to a `colSpan` greater than 1 directly from its own boundaries — both mechanisms produce the identical shape downstream. A vertical-continuation cell (`TCGRF.vertMerge` = `fvmMerge`) is kept as its own `{blocks: []}` entry — carrying its own `colSpan` too when it is also part of a horizontal-merge group in that row — with `rowSpan` computed on the anchor by scanning subsequent rows for a cell starting at the same position on the table's own shared grid, never a raw physical-array index, since two rows may genuinely have different physical cell counts and still need their vertical merges to line up correctly. Both conventions mirror `ooxml.js`'s own docx table reader exactly, since `colSpan`/`rowSpan`/`{blocks: []}` are precisely the shape `document-schema.js`'s `ContentTableCell` was designed to hold for either format's own cousin of the same merge model. A column boundary that no row in the table ever states on its own — every row happens to merge across it identically — cannot be recovered from the physical bytes at all; this is a genuine limitation of [MS-DOC]'s own physical model, not an approximation this reader chooses to make (see the third-party verification paragraph below for the confirmed reproduction, and `write.test.ts`'s own "narrows columnWidthsPt" test for the honest degraded shape this produces).
**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.339-341) — 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.4 permits each row to declare its own independent `rgdxaCenter` ("There is no requirement that each row of a table have the same number of cells"), and a real, independent [MS-DOC] implementation (LibreOffice 26.2.5.2) was confirmed to rely on exactly this for a horizontal merge — its own merged row simply has fewer, wider physical cells, with no `TCGRF.horzMerge`/`sprmTMerge` signal at all (see the third-party verification paragraph below). `table/read.ts` reconstructs the table's shared column grid as the union of every row's own `rgdxaCenter` boundary values, then expresses each physical cell's own `colSpan` as however many of that shared grid's segments its own boundaries cover. That union is taken within one point rather than by exact integer equality, because [MS-DOC] states those boundaries per row and defines no quantum coarser than the twip itself for them, so two rows meaning the identical grid may legally disagree by a twip or two — and an exact union turns that drift into a phantom hairline column plus a spurious `colSpan` on the cells of every row either side of it (two rows one twip apart across a 2338-twip boundary read back as `columnWidthsPt` `[116.9, 0.05, 144.95, 220]` instead of `[116.9, 145, 220]`; [ExaDev/documents.js#898](https://github.com/ExaDev/documents.js/issues/898)). The default tolerance is `TWIPS_PER_POINT` itself, not a picked number: `columnWidthsPt` states the reconstructed grid in points, so a segment narrower than one point sits below the smallest unit that grid can distinguish at all. It is also the fuzz a real, independent implementation applies to an analogous reconstruct-one-shared-grid-from-N-per-row-arrays problem — LibreOffice's table model is per-row too (`SwTableLine` → `SwTableBox`, each box carrying its own width), and `sw/source/filter/inc/wrtswtbl.hxx` answers it, on its own ODF export (the point at which it projects that per-row model onto one shared grid, `sw/source/filter/xml/xmltble.cxx`'s `SwXMLTableColumn_Impl`), with `#define COLFUZZY 20` twips, `SwWriteTableCol::operator==` treating two column positions as equal when they differ by at most that. Its changeover was confirmed empirically and exactly, not assumed: patching a single `int16` inside a real LibreOffice-authored table's second row and round-tripping it through that implementation's own `.doc` import followed by its ODF export gives three columns and no covered cell for a drift of 1 through 20 twips, and four columns with a real `table:covered-table-cell` from 21 (the `.doc` import side alone preserves the drifted boundary byte-for-byte — the fuzz is applied on export, not import). Per-row drift is not hypothetical even without Word or LibreOffice's own export step in the picture: `WW8TabDesc::CalcDefaults` widens any imported cell narrower than that same implementation's own minimum cell width (`MINLAY`, 23 twips in `sw/inc/swtypes.hxx`) by mutating boundaries per row during `.doc` import itself, so a document that has been through that import is one real mechanism by which per-row drift reaches a `.doc` at all.

The one-point default is not applied unconditionally, because `MINLAY`'s own guarantee is LibreOffice's alone: this package's own writer widens nothing, so nothing stops a real producer's `rgdxaCenter` from stating a column genuinely narrower than a point, and folding that column's own two boundaries together as "drift" would silently delete it rather than fix a phantom one. The tolerance is therefore clamped, per table, to one twip below the narrowest strictly-positive gap any single row states between two of its _own_ adjacent boundaries (a zero-width gap is a legal adjacent-duplicate boundary, not a column, and is excluded) — two boundaries a row itself distinguishes are never folded together, however close, and the clamp can only ever be as generous as the tightest real column that table actually declares. Beyond that, [MS-DOC]'s own physical-cell model keeps every horizontally- and vertically-merged-away cell present in the text stream with its own cell mark and its own `TC80` entry — never omitted the way OOXML's `w:gridSpan` model omits a horizontally-merged-away `<w:tc>` outright — so a horizontal-continuation cell stated the legacy way (`TCGRF.horzMerge` = 1, still honoured for a genuine third-party producer that uses it) is folded into the preceding real cell's own `colSpan` exactly as before, while a genuinely narrower, wider physical cell (no flag, LibreOffice's own encoding) resolves to a `colSpan` greater than 1 directly from its own boundaries — both mechanisms produce the identical shape downstream. A vertical-continuation cell (`TCGRF.vertMerge` = `fvmMerge`) is kept as its own `{blocks: []}` entry — carrying its own `colSpan` too when it is also part of a horizontal-merge group in that row — with `rowSpan` computed on the anchor by scanning subsequent rows for a cell starting at the same position on the table's own shared grid, never a raw physical-array index, since two rows may genuinely have different physical cell counts and still need their vertical merges to line up correctly. Both conventions mirror `ooxml.js`'s own docx table reader exactly, since `colSpan`/`rowSpan`/`{blocks: []}` are precisely the shape `document-schema.js`'s `ContentTableCell` was designed to hold for either format's own cousin of the same merge model. A column boundary that no row in the table ever states on its own — every row happens to merge across it identically — cannot be recovered from the physical bytes at all; this is a genuine limitation of [MS-DOC]'s own physical model, not an approximation this reader chooses to make (see the third-party verification paragraph below for the confirmed reproduction, and `write.test.ts`'s own "narrows columnWidthsPt" test for the honest degraded shape this produces).

A row whose own TAP cannot be resolved this way — no direct `sprmTDefTable` anywhere in its grpprl (a producer may legally state it indirectly instead, through `sprmPTableProps`; see the `sprmPHugePapx`/`sprmPTableProps` scope row above), or a cell-mark count that disagrees with what its `TDefTableOperand` declares — degrades the _whole_ contiguous run of table-depth paragraphs back to flat paragraphs, rather than refusing the whole document: this is a legal, real-world construct this reader does not yet implement, not corruption, and paragraphs that would have become a table simply stay paragraphs instead, the identical class of degrade the `sprmPHugePapx`/`sprmPTableProps` row above already documents for ordinary paragraph formatting. A row ending mid-cell with no terminating mark at all is different in kind — the stream itself is truncated, not merely using an unsupported mechanism — and still throws `DocFormatError`.

Expand All @@ -99,6 +101,10 @@ The horizontal-merge gap #892 left open ([ExaDev/documents.js#895](https://githu

**What is not resolved.** Cell shading and borders (`ContentTableCell.background`/`.borders`) are neither read from nor written to `TC80`'s own `brcTop`/`brcLeft`/`brcBottom`/`brcRight`/shading fields — every border this writer emits is `Brc80MayBeNil`'s "no border" sentinel (all bits set). `sprmTMerge` and `sprmTVertMerge` are both still read (folded onto `sprmTDefTable`'s own layout, for a genuine third-party producer that states a merge that way) but neither is written any more — this writer states a horizontal merge purely through a merged row's own narrower, wider physical cells (see [Writing](#writing) above), and a vertical merge only through `TC80.tcgrf`. Every table-level TAP sprm beyond `sprmTDefTable`/`sprmTDyaRowHeight`/`sprmTMerge`/`sprmTVertMerge` — absolute position, table style, cell padding, and the rest of [MS-DOC] 2.6.4'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. A genuine, if narrow, information-loss case remains inherent to the physical model itself rather than a gap in this package: when literally every row of a table merges across the identical column boundary (a single-row table with one merged cell is the simplest case), no row's own `rgdxaCenter` ever states that boundary, so a round trip cannot recover it — `columnWidthsPt` narrows to however many columns the physical bytes actually distinguish, and the merged cell's own `colSpan` comes back `undefined` rather than the value it was written with (`write.test.ts`'s own "narrows columnWidthsPt" test states this precisely). A table with at least one row that does not merge across the same span — the common case, since a merge is usually a header row sitting above ordinary data rows — round-trips `colSpan` and `columnWidthsPt` exactly.

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.

## Metadata

A `.doc`'s title, author, and dates do not live in any [MS-DOC] structure at all — they live in a `"\x05SummaryInformation"` stream, a genuinely different format ([MS-OLEPS] Property Set Streams) that happens to sit beside `WordDocument`/`1Table` in the same [MS-CFB] compound file. `readDocContent` reads that stream when present (`archive-codec`'s `readSummaryInformation`, since the property-set format itself is zero document-format knowledge, exactly as the [MS-CFB] container it sits inside is) and maps it onto `document-schema.js`'s `LayoutMetadata` (`archive-codec`'s own `summaryInformationToLayoutMetadata` — the mapping is format-agnostic, so it lives there rather than being copied in this package, alongside `xls-codec`'s and `ppt-codec`'s identical need for it); `writeDocContent` does the inverse (`src/metadata.ts`'s `layoutMetadataToSummaryInformation`, which validates `createdIso`/`modifiedIso` as real dates and throws a `DocFormatError` naming the offending field before delegating to `archive-codec`'s own mapping — see [Writing](#writing)), including a `"\x05SummaryInformation"` stream in its `writeCompoundFile` call only when the input's metadata actually carries something that stream can hold — an input whose metadata is `{}`, or carries only fields the mapping below has no destination for, produces no stream at all, matching what an absent-metadata read already returns.
Expand Down
Loading