doc-codec 2.9.0 → 2.10.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +11 -6
- package/dist/list/numbering.cjs +1 -3
- package/dist/list/numbering.js +1 -3
- package/dist/read.cjs +1 -1
- package/dist/read.js +1 -1
- package/dist/subdocument.cjs +13 -3
- package/dist/subdocument.js +14 -4
- package/dist/table/read.cjs +10 -7
- package/dist/table/read.d.cts +1 -1
- package/dist/table/read.d.ts +1 -1
- package/dist/table/read.js +10 -7
- package/dist/text/paragraphs.cjs +17 -1
- package/dist/text/paragraphs.js +18 -2
- package/package.json +4 -4
package/README.md
CHANGED
|
@@ -26,8 +26,9 @@ Built and shipped, on the read side:
|
|
|
26
26
|
- **Numbering definitions** — `readDocContent`'s own `numbering` field: every list's glyph/format, level-text template, and start-at value, resolved from `PlfLst`/`PlfLfo` and keyed by the same `listId` a paragraph's `ContentParagraph.list.numId` already carries. See [Numbering definitions](#numbering-definitions), which also covers `writeDocContent`'s own inverse.
|
|
27
27
|
- **Every section's own page size and margins** — `prop/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 `ContentSection`s, 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](#writing)).
|
|
28
28
|
- **Footnotes, endnotes, and comments, as plain text** — `notes.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 `""`.
|
|
29
|
-
- **Headers and footers, as real block flow** — `headers-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.
|
|
29
|
+
- **Headers and footers, as real block flow** — `headers-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.
|
|
30
30
|
- **Inline pictures** — `pictures.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](#images) for what this does not read.
|
|
31
|
+
- **`sprmPHugePapx`-indirected paragraph properties** — `text/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.
|
|
31
32
|
|
|
32
33
|
Built and shipped, on the write side — see [Writing](#writing) for the full scope statement:
|
|
33
34
|
|
|
@@ -45,7 +46,7 @@ Built and shipped, on the write side — see [Writing](#writing) for the full sc
|
|
|
45
46
|
| **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](#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](#images)'s own scope note), which this package does not resolve. |
|
|
46
47
|
| **Extended and user-defined document properties** | `title`/`subject`/`author`/`keywords`/`createdIso`/`modifiedIso` are read from and written to a `"\x05SummaryInformation"` stream when present (see [Metadata](#metadata)); the sibling `"\x05DocumentSummaryInformation"` stream (company, manager, and custom user-defined properties) is not read or written at all. |
|
|
47
48
|
| **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](#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. |
|
|
48
|
-
| **`sprmPHugePapx`
|
|
49
|
+
| **`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](https://github.com/ExaDev/documents.js/issues/892)), matching 2.4.3's own compatibility guidance ("An application SHOULD use sprmTDefTable to define table cells for applications that do not process sprmPTableProps"), so this remains genuinely unread rather than merely unconfirmed. |
|
|
49
50
|
| **Hyperlinks and fields** | Fields are resolved, not modelled: everything between a field-begin (`U+0013`) and a field-separator (`U+0014`) is the instruction and is dropped; the result between the separator and the field-end (`U+0015`) is kept as ordinary run text (see [What is converted](#what-is-converted)) — and that resolved result text is what `writeDocContent` writes, so the field layer genuinely has nothing left to invert: no field structure exists in the model, and a field's displayed content round-trips as the plain runs it already read as. A hyperlink is a field in [MS-DOC] plus two further unimplemented layers — the field-begin character is tracked by a `Plcfld` in the Table stream, and the URL itself lives in a [MS-OSHARED] hyperlink property set whose `dwApp` names an index into that `Plcfld` ([MS-DOC] 2.4.7's own association rule) — none of which this package reads or writes in either direction, so a `ContentRun.hyperlink` a caller hands the writer is dropped (its text preserved, its URL lost) rather than approximated: writing a bare `HYPERLINK` field without the property layer would emit bytes this package's own reader cannot read back — the round-trip bar every other property this writer emits is held to — and even LibreOffice's own `.doc` export drops the URL of an ODT hyperlink entirely (its re-exported text carries no link at all), so there is no producer spelling to mirror that this package's own model could verify. Reference characters (footnote/comment/annotation anchors) are likewise dropped as plain text or structure, per the read-side rule. |
|
|
50
51
|
| **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. |
|
|
51
52
|
|
|
@@ -94,7 +95,7 @@ A table in [MS-DOC] is not a separate container: it is a run of ordinary paragra
|
|
|
94
95
|
|
|
95
96
|
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](#writing) below and [ExaDev/documents.js#992](https://github.com/ExaDev/documents.js/issues/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](https://github.com/ExaDev/documents.js/issues/1013); see the [Writing](#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.
|
|
96
97
|
|
|
97
|
-
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
|
|
98
|
+
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`.
|
|
98
99
|
|
|
99
100
|
**Writing** (`table/write.ts`'s `flattenSectionBlocks`, called from `write.ts`) is the inverse: a `ContentTable` block expands into its own real physical-cell paragraph stream, one physical cell per `ContentTableCell` — real content or a vertical-merge continuation's own `{blocks: []}` — never expanded into extra synthetic cells for a `colSpan` greater than 1. A `{blocks: []}` cell becomes a single empty paragraph, but is only written as a vertical-merge continuation (`TCGRF.vertMerge` = `fvmMerge`) when a vertical merge is genuinely still in progress at that column — tracked across rows by an `active` map keyed by column position, mirroring `ooxml.js`'s own `buildTable` exactly, since a genuinely blank cell has the identical `{blocks: []}` shape and inferring the merge from emptiness alone would silently mis-merge it with whatever real content sits above it; a continuation's own physical column span, likewise, always comes from the anchor's own recorded `colSpan` rather than the continuation cell's own (typically absent) one, so a cell merged both horizontally and vertically at once writes correctly instead of throwing. Every physical cell's own paragraphs carry `sprmPFInTable`; the row's own trailing mark additionally carries `sprmPFTtp` plus a single `sprmTDefTable` stating the row's own column layout and every cell's `TC80.tcgrf` vertical-merge state (`table/tap-write.ts`), and a `sprmTDyaRowHeight` when the row states a `heightPt`. Column widths are derived once from the table's own `columnWidthsPt`, giving every row a shared full grid of boundary points to draw from, but a row containing a horizontal merge writes its own narrower, wider `rgdxaCenter`: a `colSpan`-anchored cell's own physical boundary is the combined width of however many of the full grid's columns it spans, merged into one cell rather than kept as separate flagged ones. This is a deliberate match for how a real, independent [MS-DOC] implementation (LibreOffice 26.2.5.2) was confirmed to encode a horizontal merge — see the third-party verification paragraph below for the full ground-truth finding and [ExaDev/documents.js#895](https://github.com/ExaDev/documents.js/issues/895) for the issue it fixes. `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](https://github.com/ExaDev/documents.js/issues/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.
|
|
100
101
|
|
|
@@ -102,7 +103,7 @@ A row whose own TAP cannot be resolved this way — no direct `sprmTDefTable` an
|
|
|
102
103
|
|
|
103
104
|
The Main Document's own last character MUST be an ordinary paragraph mark ([MS-DOC]'s "Main Document" glossary entry: "The last character in the main document MUST be a paragraph mark (Unicode 0x000D)") — never the row-ending mark's own cell-mark character (0x0007), even though a row mark is a perfectly legal paragraph-boundary terminator everywhere else. `write.ts`'s own top-level `writeDocContent` — not this module — is what guarantees this: whenever `flattenSectionBlocks`' own output ends in anything other than an ordinary paragraph mark (an empty section, or, the case that matters here, a section whose very last block is a table), it appends one trailing empty paragraph so the table's own row mark is never the document's final character. This is the confirmed root cause of, and fix for, [ExaDev/documents.js#892](https://github.com/ExaDev/documents.js/issues/892) — see the third-party verification paragraph immediately below for the full finding.
|
|
104
105
|
|
|
105
|
-
**Third-party verification: passing for a plain table, a vertical merge, a horizontal merge, and a cell merged both ways at once.** [ExaDev/documents.js#892](https://github.com/ExaDev/documents.js/issues/892) tracked a genuine regression an earlier draft of this README had falsely certified as passing: LibreOffice's own `.doc` import filter recognised no table at all in a `writeDocContent` sample, in any configuration — every cell's text came back concatenated into one flat paragraph, with no `table:table` element anywhere in the converted output. The root cause was found by comparing this writer's own bytes against a genuine LibreOffice-authored `.doc`, byte for byte, rather than guessing: a LibreOffice 26.2.5.2-built `.odt` table converted to `.doc` (`soffice --headless --convert-to doc`) and its `WordDocument` stream parsed directly through this package's own `PapxFkp`/grpprl primitives shows LibreOffice's own row mark stating its TAP through the identical direct `sprmTDefTable` this writer already used — ruling out the indirect-Papx hypothesis #892 had raised (see the `sprmPHugePapx
|
|
106
|
+
**Third-party verification: passing for a plain table, a vertical merge, a horizontal merge, and a cell merged both ways at once.** [ExaDev/documents.js#892](https://github.com/ExaDev/documents.js/issues/892) tracked a genuine regression an earlier draft of this README had falsely certified as passing: LibreOffice's own `.doc` import filter recognised no table at all in a `writeDocContent` sample, in any configuration — every cell's text came back concatenated into one flat paragraph, with no `table:table` element anywhere in the converted output. The root cause was found by comparing this writer's own bytes against a genuine LibreOffice-authored `.doc`, byte for byte, rather than guessing: a LibreOffice 26.2.5.2-built `.odt` table converted to `.doc` (`soffice --headless --convert-to doc`) and its `WordDocument` stream parsed directly through this package's own `PapxFkp`/grpprl primitives shows LibreOffice's own row mark stating its TAP through the identical direct `sprmTDefTable` this writer already used — ruling out the indirect-Papx hypothesis #892 had raised (see the `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.
|
|
106
107
|
|
|
107
108
|
The horizontal-merge gap #892 left open ([ExaDev/documents.js#895](https://github.com/ExaDev/documents.js/issues/895)) was root-caused the same way: round-tripping a LibreOffice-authored horizontal merge through its own `.doc` writer and parsing the result's raw TAP bytes with this package's own primitives shows LibreOffice does not use `TC80.tcgrf.horzMerge` _or_ `sprmTMerge` for a horizontal merge at all — the merged row's own `TDefTableOperand` genuinely has fewer, wider physical cells (`rgdxaCenter = [0, 6425, 9638]`, 2 physical cells, both `TCGRF.horzMerge = 0`) than an unmerged row in the same table (`rgdxaCenter = [0, 3212, 6425, 9638]`, 3 cells), a real per-row column layout [MS-DOC] 2.6.3 permits ("There is no requirement that each row of a table have the same number of cells"). This writer now matches that encoding (see [Writing](#writing) above) and the reader reconstructs `colSpan` from it (see the Reading paragraph above). Verified against LibreOffice 26.2.5.2 (`soffice --headless --convert-to fodt`, checking for real `table:table`/`table:table-row`/`table:table-cell` elements) for four cases: a plain 2x2 unmerged table (**passes** — genuine `table:table` structure, correct cell text and column count); a vertically merged cell (**passes** — `table:number-rows-spanned="2"` on the anchor cell and a real `table:covered-table-cell` on the row below); a horizontally merged cell (**passes** — `table:number-columns-spanned="2"` on the anchor cell and a real `table:covered-table-cell` beside it, with the table's other, unmerged row confirming 3 real columns); and a cell merged both horizontally and vertically at once (**passes** — `table:number-rows-spanned="2" table:number-columns-spanned="2"` together on the anchor, with two `table:covered-table-cell` elements on the row below it). No case regressed against the other: the same writer output that produces the merges above still passes the plain-table and vertical-merge checks unchanged.
|
|
108
109
|
|
|
@@ -144,7 +145,7 @@ One narrow accuracy limit follows from the same missing field. `rgdxaCenter`'s e
|
|
|
144
145
|
|
|
145
146
|
A paragraph's own `list.numId`/`list.level` (`sprmPIlfo`/`sprmPIlvl`, unchanged by this section) say WHICH list a paragraph belongs to and WHAT DEPTH within it -- they say nothing about what that list actually looks like. `readDocContent`'s own `numbering` field is that: keyed by the same `listId` string `numId` already carries, each entry names every level's glyph/format, level-text template, and start-at value, resolved from `PlfLst` (the list definitions, `LSTF` plus each one's appended array of `LVL`s) and `PlfLfo` (which list a paragraph's own `ilfo` actually refers to). `list/numbering.ts`'s `readNumberingDefinitions` is the whole implementation; `read.ts`'s `DocContent` is `ContentDocument` widened by exactly this one field, so every existing caller expecting a plain `ContentDocument` is unaffected.
|
|
146
147
|
|
|
147
|
-
**Deliberately shaped like ooxml.js's own numbering, not document-schema.js's.** `NumberingDefinition`/`NumberingLevel` are doc-codec's own types, not a `document-schema.js` addition: `ContentListMembership` is shared verbatim across every codec in this family, and widening it with a doc-codec-specific numbering-definition payload would leak this package's own model into a schema the sibling packages also depend on -- exactly the reasoning `ooxml.js`'s own `typed/docx/numbering.ts` states for `word/numbering.xml`'s `abstractNum`/`num` tables, which this module deliberately mirrors rather than reinvents. `NumberingLevel.format` is the identical ECMA-376 `ST_NumberFormat` string ooxml.js's own field already carries (`"decimal"`, `"upperRoman"`, `"bullet"`, ...) -- [MS-OSHARED] 2.2.1.3's own `MSONFC` enumeration documents each value as "mapped to the `ST_NumberFormat`... equivalent", so this reader uses that same mapping rather than inventing a second vocabulary. `NumberingLevel.text` is the identical `'%1.'`/`'%2)'`-style placeholder convention: `[MS-DOC]`'s own `Xst`/`rgbxchNums` encoding names a placeholder by which _character position_ in the level's text is a raw, zero-based level index rather than literal content, and `readLevelText` converts that into the one-based `%N` spelling ooxml.js's own `w:lvlText` values already use -- so a consumer that already resolves one already resolves the other.
|
|
148
|
+
**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.
|
|
148
149
|
|
|
149
150
|
**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.
|
|
150
151
|
|
|
@@ -283,7 +284,9 @@ The modules layer in the order [MS-DOC]'s own algorithms chain:
|
|
|
283
284
|
| `src/metadata.ts` | Wraps `archive-codec`'s own `SummaryInformationProperties` <-> `LayoutMetadata` mapping with this package's `createdIso`/`modifiedIso` date validation, throwing `DocFormatError` for a malformed one rather than letting an opaque `RangeError` escape the FILETIME conversion (see [Metadata](#metadata)). |
|
|
284
285
|
| `src/table/decoration.ts` | A cell's border and background-shading encodings -- `Brc80`, `Brc`, `Shd`, `Shd80`, and the `BrcType`/`Ipat` vocabularies -- read and written in one place, so neither direction can drift from the other (see [Cell decoration](#cell-decoration)). |
|
|
285
286
|
| `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. |
|
|
286
|
-
| `src/table/read.ts` | Grouping a contiguous run of table-depth paragraphs (from `read.ts`'s own flat sequence) into a real `ContentTable`,
|
|
287
|
+
| `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. |
|
|
288
|
+
| `src/text/paragraphs.ts` | Splitting a paragraph around an inline picture anchor, and `resolveIndirectPapx`'s own `sprmPHugePapx` indirection chase into the Data stream. |
|
|
289
|
+
| `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. |
|
|
287
290
|
| `src/read.ts` | The whole read chain, to a `ContentDocument`. |
|
|
288
291
|
| `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. |
|
|
289
292
|
| `src/text/piece-table-write.ts` | Builds a `Clx` describing the whole logical text stream as one uncompressed piece. |
|
|
@@ -352,6 +355,8 @@ A `describe("writeDocContent multiple sections")` block (`ExaDev/documents.js#97
|
|
|
352
355
|
|
|
353
356
|
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.
|
|
354
357
|
|
|
358
|
+
A second, independent corpus layer closes that gap ([ExaDev/documents.js#1193](https://github.com/ExaDev/documents.js/issues/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.
|
|
359
|
+
|
|
355
360
|
## Specification
|
|
356
361
|
|
|
357
362
|
Every structure in this package cites the section of [MS-DOC] it implements. The specification is published by Microsoft under its Open Specifications programme:
|
package/dist/list/numbering.cjs
CHANGED
|
@@ -74,9 +74,7 @@ const NUMBER_FORMAT_BY_NFC = {
|
|
|
74
74
|
const NFC_NONE = 255;
|
|
75
75
|
function numberFormatFor(nfc) {
|
|
76
76
|
if (nfc === NFC_NONE) return "none";
|
|
77
|
-
|
|
78
|
-
if (format === void 0) throw new require_errors.DocFormatError(`LVLF.nfc is 0x${nfc.toString(16).padStart(2, "0")}, not a recognised MSONFC value ([MS-OSHARED] 2.2.1.3)`);
|
|
79
|
-
return format;
|
|
77
|
+
return NUMBER_FORMAT_BY_NFC[nfc] ?? "decimal";
|
|
80
78
|
}
|
|
81
79
|
/** LSTF ([MS-DOC] 2.9.147): lsid(4) + tplc(4, ignored -- UI-only) + rgistdPara(18, ignored -- this reader has no per-level style cascade to link into) + a flags byte (only fSimpleList, bit 0, acted on) + grfhic(1, ignored -- HTML-export-only incompatibility flags). Fixed 28 bytes. */
|
|
82
80
|
function readLstf(bytes, offset) {
|
package/dist/list/numbering.js
CHANGED
|
@@ -73,9 +73,7 @@ const NUMBER_FORMAT_BY_NFC = {
|
|
|
73
73
|
const NFC_NONE = 255;
|
|
74
74
|
function numberFormatFor(nfc) {
|
|
75
75
|
if (nfc === NFC_NONE) return "none";
|
|
76
|
-
|
|
77
|
-
if (format === void 0) throw new DocFormatError(`LVLF.nfc is 0x${nfc.toString(16).padStart(2, "0")}, not a recognised MSONFC value ([MS-OSHARED] 2.2.1.3)`);
|
|
78
|
-
return format;
|
|
76
|
+
return NUMBER_FORMAT_BY_NFC[nfc] ?? "decimal";
|
|
79
77
|
}
|
|
80
78
|
/** LSTF ([MS-DOC] 2.9.147): lsid(4) + tplc(4, ignored -- UI-only) + rgistdPara(18, ignored -- this reader has no per-level style cascade to link into) + a flags byte (only fSimpleList, bit 0, acted on) + grfhic(1, ignored -- HTML-export-only incompatibility flags). Fixed 28 bytes. */
|
|
81
79
|
function readLstf(bytes, offset) {
|
package/dist/read.cjs
CHANGED
|
@@ -88,7 +88,7 @@ function readDocContent(bytes, password) {
|
|
|
88
88
|
topPt: properties.marginTopPt ?? DEFAULT_MARGINS.topPt,
|
|
89
89
|
bottomPt: properties.marginBottomPt ?? DEFAULT_MARGINS.bottomPt
|
|
90
90
|
},
|
|
91
|
-
blocks: require_table_read.assembleBlocks(entriesBySection[index] ?? [])
|
|
91
|
+
blocks: require_table_read.assembleBlocks(entriesBySection[index] ?? [], index === sectionProperties.length - 1)
|
|
92
92
|
})),
|
|
93
93
|
numbering,
|
|
94
94
|
footnotes,
|
package/dist/read.js
CHANGED
|
@@ -87,7 +87,7 @@ function readDocContent(bytes, password) {
|
|
|
87
87
|
topPt: properties.marginTopPt ?? DEFAULT_MARGINS.topPt,
|
|
88
88
|
bottomPt: properties.marginBottomPt ?? DEFAULT_MARGINS.bottomPt
|
|
89
89
|
},
|
|
90
|
-
blocks: assembleBlocks(entriesBySection[index] ?? [])
|
|
90
|
+
blocks: assembleBlocks(entriesBySection[index] ?? [], index === sectionProperties.length - 1)
|
|
91
91
|
})),
|
|
92
92
|
numbering,
|
|
93
93
|
footnotes,
|
package/dist/subdocument.cjs
CHANGED
|
@@ -1,15 +1,25 @@
|
|
|
1
1
|
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
2
|
+
const require_errors = require("./errors.cjs");
|
|
2
3
|
const require_bytes = require("./bytes.cjs");
|
|
3
|
-
const require_plc = require("./plc.cjs");
|
|
4
4
|
const require_text_paragraphs = require("./text/paragraphs.cjs");
|
|
5
5
|
const require_text_characters = require("./text/characters.cjs");
|
|
6
6
|
//#region src/subdocument.ts
|
|
7
|
+
function readStoryPlexKeys(bytes, subdocLength, what) {
|
|
8
|
+
if (bytes.length < 4 || !Number.isInteger((bytes.length - 4) / 4)) throw new require_errors.DocFormatError(`${what} is ${bytes.length} bytes, which does not yield a whole number of 4-byte keys`);
|
|
9
|
+
const keys = [];
|
|
10
|
+
for (let offset = 0; offset < bytes.length; offset += 4) {
|
|
11
|
+
const raw = require_bytes.readInt32LE(bytes, offset);
|
|
12
|
+
const previous = keys[keys.length - 1] ?? 0;
|
|
13
|
+
keys.push(raw < 0 || raw > subdocLength ? previous : Math.max(raw, previous));
|
|
14
|
+
}
|
|
15
|
+
return keys;
|
|
16
|
+
}
|
|
7
17
|
function readSubdocumentStories(wordDocument, table, pieceTable, context, subdocStartCp, subdocLength, boundaryFc, boundaryLcb, what) {
|
|
8
18
|
if (subdocLength <= 0 || boundaryLcb <= 0) return [];
|
|
9
19
|
const range = require_text_characters.readTextRange(wordDocument, pieceTable, subdocStartCp, subdocStartCp + subdocLength);
|
|
10
20
|
const entries = require_text_paragraphs.readParagraphs(range.text, range.fcs, context);
|
|
11
|
-
const
|
|
12
|
-
return require_text_paragraphs.splitEntriesByBoundaries(entries,
|
|
21
|
+
const keys = readStoryPlexKeys(require_bytes.slice(table, boundaryFc, boundaryLcb, `${what} in the Table stream`), subdocLength, what);
|
|
22
|
+
return require_text_paragraphs.splitEntriesByBoundaries(entries, keys).slice(0, -1).map((story) => endsWithGuardParagraph(story) ? story.slice(0, -1) : story);
|
|
13
23
|
}
|
|
14
24
|
function endsWithGuardParagraph(story) {
|
|
15
25
|
const last = story[story.length - 1];
|
package/dist/subdocument.js
CHANGED
|
@@ -1,14 +1,24 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
1
|
+
import { DocFormatError } from "./errors.js";
|
|
2
|
+
import { readInt32LE, slice } from "./bytes.js";
|
|
3
3
|
import { readParagraphs, splitEntriesByBoundaries } from "./text/paragraphs.js";
|
|
4
4
|
import { readTextRange } from "./text/characters.js";
|
|
5
5
|
//#region src/subdocument.ts
|
|
6
|
+
function readStoryPlexKeys(bytes, subdocLength, what) {
|
|
7
|
+
if (bytes.length < 4 || !Number.isInteger((bytes.length - 4) / 4)) throw new DocFormatError(`${what} is ${bytes.length} bytes, which does not yield a whole number of 4-byte keys`);
|
|
8
|
+
const keys = [];
|
|
9
|
+
for (let offset = 0; offset < bytes.length; offset += 4) {
|
|
10
|
+
const raw = readInt32LE(bytes, offset);
|
|
11
|
+
const previous = keys[keys.length - 1] ?? 0;
|
|
12
|
+
keys.push(raw < 0 || raw > subdocLength ? previous : Math.max(raw, previous));
|
|
13
|
+
}
|
|
14
|
+
return keys;
|
|
15
|
+
}
|
|
6
16
|
function readSubdocumentStories(wordDocument, table, pieceTable, context, subdocStartCp, subdocLength, boundaryFc, boundaryLcb, what) {
|
|
7
17
|
if (subdocLength <= 0 || boundaryLcb <= 0) return [];
|
|
8
18
|
const range = readTextRange(wordDocument, pieceTable, subdocStartCp, subdocStartCp + subdocLength);
|
|
9
19
|
const entries = readParagraphs(range.text, range.fcs, context);
|
|
10
|
-
const
|
|
11
|
-
return splitEntriesByBoundaries(entries,
|
|
20
|
+
const keys = readStoryPlexKeys(slice(table, boundaryFc, boundaryLcb, `${what} in the Table stream`), subdocLength, what);
|
|
21
|
+
return splitEntriesByBoundaries(entries, keys).slice(0, -1).map((story) => endsWithGuardParagraph(story) ? story.slice(0, -1) : story);
|
|
12
22
|
}
|
|
13
23
|
function endsWithGuardParagraph(story) {
|
|
14
24
|
const last = story[story.length - 1];
|
package/dist/table/read.cjs
CHANGED
|
@@ -22,8 +22,8 @@ function effectiveColumnBoundaryTolerance(definitions) {
|
|
|
22
22
|
}
|
|
23
23
|
return narrowestRealGapTwips === void 0 ? TWIPS_PER_POINT : Math.min(TWIPS_PER_POINT, narrowestRealGapTwips - 1);
|
|
24
24
|
}
|
|
25
|
-
function assembleBlocks(entries) {
|
|
26
|
-
return walkBlocksAtDepth(entries, 0);
|
|
25
|
+
function assembleBlocks(entries, documentStreamEnds = false) {
|
|
26
|
+
return walkBlocksAtDepth(entries, 0, documentStreamEnds);
|
|
27
27
|
}
|
|
28
28
|
function effectiveTableDepth(properties) {
|
|
29
29
|
if (properties.tableDepth !== void 0) return properties.tableDepth;
|
|
@@ -36,7 +36,7 @@ function isCellBoundary(entry, tableDepth) {
|
|
|
36
36
|
function isRowBoundary(entry, tableDepth) {
|
|
37
37
|
return tableDepth === 1 ? entry.properties.tableRowEnd === true : entry.properties.innerTtpMark === true;
|
|
38
38
|
}
|
|
39
|
-
function walkBlocksAtDepth(entries, depth) {
|
|
39
|
+
function walkBlocksAtDepth(entries, depth, documentStreamEnds) {
|
|
40
40
|
const blocks = [];
|
|
41
41
|
let index = 0;
|
|
42
42
|
while (index < entries.length) {
|
|
@@ -49,7 +49,7 @@ function walkBlocksAtDepth(entries, depth) {
|
|
|
49
49
|
}
|
|
50
50
|
const tableDepth = depth + 1;
|
|
51
51
|
const { runEntries, nextIndex } = collectTableRun(entries, index, tableDepth);
|
|
52
|
-
const table = tryAssembleTable(runEntries, tableDepth);
|
|
52
|
+
const table = tryAssembleTable(runEntries, tableDepth, !documentStreamEnds || nextIndex < entries.length);
|
|
53
53
|
blocks.push(...table !== void 0 ? [table] : runEntries.flatMap((run) => run.blocks));
|
|
54
54
|
index = nextIndex;
|
|
55
55
|
}
|
|
@@ -69,7 +69,7 @@ function collectTableRun(entries, start, tableDepth) {
|
|
|
69
69
|
nextIndex: index
|
|
70
70
|
};
|
|
71
71
|
}
|
|
72
|
-
function tryAssembleTable(runEntries, tableDepth) {
|
|
72
|
+
function tryAssembleTable(runEntries, tableDepth, streamContinues) {
|
|
73
73
|
const rawRows = [];
|
|
74
74
|
const rowDefinitions = [];
|
|
75
75
|
const rowHeights = [];
|
|
@@ -101,11 +101,14 @@ function tryAssembleTable(runEntries, tableDepth) {
|
|
|
101
101
|
continue;
|
|
102
102
|
}
|
|
103
103
|
if (isCellBoundary(entry, tableDepth)) {
|
|
104
|
-
rowCells.push({ blocks: walkBlocksAtDepth(cellEntries, tableDepth) });
|
|
104
|
+
rowCells.push({ blocks: walkBlocksAtDepth(cellEntries, tableDepth, false) });
|
|
105
105
|
cellEntries = [];
|
|
106
106
|
}
|
|
107
107
|
}
|
|
108
|
-
if (cellEntries.length > 0 || rowCells.length > 0)
|
|
108
|
+
if (cellEntries.length > 0 || rowCells.length > 0) {
|
|
109
|
+
if (rawRows.length === 0 && streamContinues) return;
|
|
110
|
+
throw new require_errors.DocFormatError("a table's paragraphs end without a row-ending mark to close the row's last cell");
|
|
111
|
+
}
|
|
109
112
|
const toleranceTwips = effectiveColumnBoundaryTolerance(rowDefinitions);
|
|
110
113
|
const columnBoundariesTwips = canonicalColumnBoundariesTwips(rowDefinitions, toleranceTwips);
|
|
111
114
|
return {
|
package/dist/table/read.d.cts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { t as ParagraphEntry } from "../paragraphs-DQUp8_HB.cjs";
|
|
2
2
|
import { ContentBlock } from "document-schema.js";
|
|
3
3
|
//#region src/table/read.d.ts
|
|
4
|
-
declare function assembleBlocks(entries: readonly ParagraphEntry[]): ContentBlock[];
|
|
4
|
+
declare function assembleBlocks(entries: readonly ParagraphEntry[], documentStreamEnds?: boolean): ContentBlock[];
|
|
5
5
|
//#endregion
|
|
6
6
|
export { assembleBlocks };
|
package/dist/table/read.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { t as ParagraphEntry } from "../paragraphs-s2lJ6w2I.js";
|
|
2
2
|
import { ContentBlock } from "document-schema.js";
|
|
3
3
|
//#region src/table/read.d.ts
|
|
4
|
-
declare function assembleBlocks(entries: readonly ParagraphEntry[]): ContentBlock[];
|
|
4
|
+
declare function assembleBlocks(entries: readonly ParagraphEntry[], documentStreamEnds?: boolean): ContentBlock[];
|
|
5
5
|
//#endregion
|
|
6
6
|
export { assembleBlocks };
|
package/dist/table/read.js
CHANGED
|
@@ -21,8 +21,8 @@ function effectiveColumnBoundaryTolerance(definitions) {
|
|
|
21
21
|
}
|
|
22
22
|
return narrowestRealGapTwips === void 0 ? TWIPS_PER_POINT : Math.min(TWIPS_PER_POINT, narrowestRealGapTwips - 1);
|
|
23
23
|
}
|
|
24
|
-
function assembleBlocks(entries) {
|
|
25
|
-
return walkBlocksAtDepth(entries, 0);
|
|
24
|
+
function assembleBlocks(entries, documentStreamEnds = false) {
|
|
25
|
+
return walkBlocksAtDepth(entries, 0, documentStreamEnds);
|
|
26
26
|
}
|
|
27
27
|
function effectiveTableDepth(properties) {
|
|
28
28
|
if (properties.tableDepth !== void 0) return properties.tableDepth;
|
|
@@ -35,7 +35,7 @@ function isCellBoundary(entry, tableDepth) {
|
|
|
35
35
|
function isRowBoundary(entry, tableDepth) {
|
|
36
36
|
return tableDepth === 1 ? entry.properties.tableRowEnd === true : entry.properties.innerTtpMark === true;
|
|
37
37
|
}
|
|
38
|
-
function walkBlocksAtDepth(entries, depth) {
|
|
38
|
+
function walkBlocksAtDepth(entries, depth, documentStreamEnds) {
|
|
39
39
|
const blocks = [];
|
|
40
40
|
let index = 0;
|
|
41
41
|
while (index < entries.length) {
|
|
@@ -48,7 +48,7 @@ function walkBlocksAtDepth(entries, depth) {
|
|
|
48
48
|
}
|
|
49
49
|
const tableDepth = depth + 1;
|
|
50
50
|
const { runEntries, nextIndex } = collectTableRun(entries, index, tableDepth);
|
|
51
|
-
const table = tryAssembleTable(runEntries, tableDepth);
|
|
51
|
+
const table = tryAssembleTable(runEntries, tableDepth, !documentStreamEnds || nextIndex < entries.length);
|
|
52
52
|
blocks.push(...table !== void 0 ? [table] : runEntries.flatMap((run) => run.blocks));
|
|
53
53
|
index = nextIndex;
|
|
54
54
|
}
|
|
@@ -68,7 +68,7 @@ function collectTableRun(entries, start, tableDepth) {
|
|
|
68
68
|
nextIndex: index
|
|
69
69
|
};
|
|
70
70
|
}
|
|
71
|
-
function tryAssembleTable(runEntries, tableDepth) {
|
|
71
|
+
function tryAssembleTable(runEntries, tableDepth, streamContinues) {
|
|
72
72
|
const rawRows = [];
|
|
73
73
|
const rowDefinitions = [];
|
|
74
74
|
const rowHeights = [];
|
|
@@ -100,11 +100,14 @@ function tryAssembleTable(runEntries, tableDepth) {
|
|
|
100
100
|
continue;
|
|
101
101
|
}
|
|
102
102
|
if (isCellBoundary(entry, tableDepth)) {
|
|
103
|
-
rowCells.push({ blocks: walkBlocksAtDepth(cellEntries, tableDepth) });
|
|
103
|
+
rowCells.push({ blocks: walkBlocksAtDepth(cellEntries, tableDepth, false) });
|
|
104
104
|
cellEntries = [];
|
|
105
105
|
}
|
|
106
106
|
}
|
|
107
|
-
if (cellEntries.length > 0 || rowCells.length > 0)
|
|
107
|
+
if (cellEntries.length > 0 || rowCells.length > 0) {
|
|
108
|
+
if (rawRows.length === 0 && streamContinues) return;
|
|
109
|
+
throw new DocFormatError("a table's paragraphs end without a row-ending mark to close the row's last cell");
|
|
110
|
+
}
|
|
108
111
|
const toleranceTwips = effectiveColumnBoundaryTolerance(rowDefinitions);
|
|
109
112
|
const columnBoundariesTwips = canonicalColumnBoundariesTwips(rowDefinitions, toleranceTwips);
|
|
110
113
|
return {
|
package/dist/text/paragraphs.cjs
CHANGED
|
@@ -26,10 +26,26 @@ function readParagraphs(text, fcs, context) {
|
|
|
26
26
|
}
|
|
27
27
|
return entries;
|
|
28
28
|
}
|
|
29
|
+
/** sprmPHugePapx (0x6646), [MS-DOC] 2.6.2's Paragraph Properties table: "a 4-byte unsigned integer that specifies a location in the Data Stream" where "a PrcData structure begins ... and specifies additional properties for the paragraph". A paragraph whose direct grpprl opens with this sprm keeps its real properties in that Data-stream PrcData -- the whole point of the sprm is a Papx too large for its own 512-byte FKP page, so Word leaves the FKP holding only the pointer. */
|
|
30
|
+
const SPRM_P_HUGE_PAPX = 26182;
|
|
31
|
+
const MAX_PAPX_INDIRECTION_HOPS = 16;
|
|
32
|
+
function resolveIndirectPapx(prls, context) {
|
|
33
|
+
let current = prls;
|
|
34
|
+
for (let hop = 0;; hop += 1) {
|
|
35
|
+
const first = current[0];
|
|
36
|
+
if (first?.sprm.value !== SPRM_P_HUGE_PAPX) return current;
|
|
37
|
+
if (hop >= MAX_PAPX_INDIRECTION_HOPS) throw new require_errors.DocFormatError(`a paragraph's indirect property chain did not terminate within ${MAX_PAPX_INDIRECTION_HOPS} sprmPHugePapx hops; [MS-DOC] 2.6.2 requires the chain to terminate`);
|
|
38
|
+
if (context.dataStream === void 0) throw new require_errors.DocFormatError("a paragraph's grpprl opens with sprmPHugePapx, but this compound file carries no Data stream for it to point into");
|
|
39
|
+
const offset = require_bytes.readUint32LE(first.operand, 0);
|
|
40
|
+
const cbGrpprl = require_bytes.readUint16LE(context.dataStream, offset);
|
|
41
|
+
const grpprl = require_bytes.slice(context.dataStream, offset + 2, cbGrpprl, "a sprmPHugePapx-referenced PrcData's GrpPrl");
|
|
42
|
+
current = require_prop_sprm.readGrpprl(grpprl);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
29
45
|
function buildParagraph(text, fcs, propertyFc, terminator, endCp, context) {
|
|
30
46
|
const papx = context.papxTable.papx(propertyFc);
|
|
31
47
|
const properties = {};
|
|
32
|
-
const grpprl = papx !== void 0 ? require_prop_sprm.readGrpprl(papx.grpprl) : [];
|
|
48
|
+
const grpprl = papx !== void 0 ? resolveIndirectPapx(require_prop_sprm.readGrpprl(papx.grpprl), context) : [];
|
|
33
49
|
if (papx !== void 0) {
|
|
34
50
|
properties.istd = papx.istd;
|
|
35
51
|
if (context.styles !== void 0) require_prop_pap.applyParagraphSprms(require_style_stsh.resolveStyleFormatting(context.styles, papx.istd).paragraphPrls, properties);
|
package/dist/text/paragraphs.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { DocFormatError } from "../errors.js";
|
|
2
|
-
import { readInt32LE } from "../bytes.js";
|
|
2
|
+
import { readInt32LE, readUint16LE, readUint32LE, slice } from "../bytes.js";
|
|
3
3
|
import { readGrpprl } from "../prop/sprm.js";
|
|
4
4
|
import { applyCharacterSprms, characterIstdFromGrpprl } from "../prop/chp.js";
|
|
5
5
|
import { applyParagraphSprms } from "../prop/pap.js";
|
|
@@ -25,10 +25,26 @@ function readParagraphs(text, fcs, context) {
|
|
|
25
25
|
}
|
|
26
26
|
return entries;
|
|
27
27
|
}
|
|
28
|
+
/** sprmPHugePapx (0x6646), [MS-DOC] 2.6.2's Paragraph Properties table: "a 4-byte unsigned integer that specifies a location in the Data Stream" where "a PrcData structure begins ... and specifies additional properties for the paragraph". A paragraph whose direct grpprl opens with this sprm keeps its real properties in that Data-stream PrcData -- the whole point of the sprm is a Papx too large for its own 512-byte FKP page, so Word leaves the FKP holding only the pointer. */
|
|
29
|
+
const SPRM_P_HUGE_PAPX = 26182;
|
|
30
|
+
const MAX_PAPX_INDIRECTION_HOPS = 16;
|
|
31
|
+
function resolveIndirectPapx(prls, context) {
|
|
32
|
+
let current = prls;
|
|
33
|
+
for (let hop = 0;; hop += 1) {
|
|
34
|
+
const first = current[0];
|
|
35
|
+
if (first?.sprm.value !== SPRM_P_HUGE_PAPX) return current;
|
|
36
|
+
if (hop >= MAX_PAPX_INDIRECTION_HOPS) throw new DocFormatError(`a paragraph's indirect property chain did not terminate within ${MAX_PAPX_INDIRECTION_HOPS} sprmPHugePapx hops; [MS-DOC] 2.6.2 requires the chain to terminate`);
|
|
37
|
+
if (context.dataStream === void 0) throw new DocFormatError("a paragraph's grpprl opens with sprmPHugePapx, but this compound file carries no Data stream for it to point into");
|
|
38
|
+
const offset = readUint32LE(first.operand, 0);
|
|
39
|
+
const cbGrpprl = readUint16LE(context.dataStream, offset);
|
|
40
|
+
const grpprl = slice(context.dataStream, offset + 2, cbGrpprl, "a sprmPHugePapx-referenced PrcData's GrpPrl");
|
|
41
|
+
current = readGrpprl(grpprl);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
28
44
|
function buildParagraph(text, fcs, propertyFc, terminator, endCp, context) {
|
|
29
45
|
const papx = context.papxTable.papx(propertyFc);
|
|
30
46
|
const properties = {};
|
|
31
|
-
const grpprl = papx !== void 0 ? readGrpprl(papx.grpprl) : [];
|
|
47
|
+
const grpprl = papx !== void 0 ? resolveIndirectPapx(readGrpprl(papx.grpprl), context) : [];
|
|
32
48
|
if (papx !== void 0) {
|
|
33
49
|
properties.istd = papx.istd;
|
|
34
50
|
if (context.styles !== void 0) applyParagraphSprms(resolveStyleFormatting(context.styles, papx.istd).paragraphPrls, properties);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "doc-codec",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.10.1",
|
|
4
4
|
"description": "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.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"repository": {
|
|
@@ -59,7 +59,7 @@
|
|
|
59
59
|
"_test": "vitest run --project unit",
|
|
60
60
|
"_test:coverage": "vitest run --project unit --coverage",
|
|
61
61
|
"test:mutation": "turbo run _test:mutation",
|
|
62
|
-
"_test:mutation": "stryker run stryker.config.
|
|
62
|
+
"_test:mutation": "stryker run stryker.config.ts",
|
|
63
63
|
"test:watch": "vitest --project unit",
|
|
64
64
|
"test:workers": "turbo run _test:workers",
|
|
65
65
|
"_test:workers": "vitest run --config vitest.workers.config.ts",
|
|
@@ -80,8 +80,8 @@
|
|
|
80
80
|
"license": "MIT",
|
|
81
81
|
"packageManager": "pnpm@11.6.0",
|
|
82
82
|
"dependencies": {
|
|
83
|
-
"archive-codec": "1.11.
|
|
84
|
-
"document-schema.js": "7.11.
|
|
83
|
+
"archive-codec": "1.11.1",
|
|
84
|
+
"document-schema.js": "7.11.1"
|
|
85
85
|
},
|
|
86
86
|
"devDependencies": {
|
|
87
87
|
"@arethetypeswrong/cli": "0.18.5",
|