xls-codec 4.9.0 → 4.11.0

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.
Files changed (38) hide show
  1. package/README.md +43 -19
  2. package/dist/biff/ptg-functions.cjs +3 -0
  3. package/dist/biff/ptg-functions.d.cts +3 -1
  4. package/dist/biff/ptg-functions.d.ts +3 -1
  5. package/dist/biff/ptg-functions.js +3 -1
  6. package/dist/biff/ptg-writer.cjs +546 -0
  7. package/dist/biff/ptg-writer.d.cts +7 -0
  8. package/dist/biff/ptg-writer.d.ts +7 -0
  9. package/dist/biff/ptg-writer.js +545 -0
  10. package/dist/biff/string-writer.cjs +6 -0
  11. package/dist/biff/string-writer.d.cts +3 -1
  12. package/dist/biff/string-writer.d.ts +3 -1
  13. package/dist/biff/string-writer.js +6 -1
  14. package/dist/content.cjs +1 -1
  15. package/dist/content.d.cts +1 -1
  16. package/dist/content.d.ts +1 -1
  17. package/dist/content.js +1 -1
  18. package/dist/index.cjs +1 -0
  19. package/dist/index.d.cts +2 -2
  20. package/dist/index.d.ts +2 -2
  21. package/dist/index.js +2 -2
  22. package/dist/workbook/comment-writer.cjs +75 -0
  23. package/dist/workbook/comment-writer.d.cts +10 -0
  24. package/dist/workbook/comment-writer.d.ts +10 -0
  25. package/dist/workbook/comment-writer.js +74 -0
  26. package/dist/workbook/encryption.cjs +67 -20
  27. package/dist/workbook/encryption.d.cts +2 -2
  28. package/dist/workbook/encryption.d.ts +2 -2
  29. package/dist/workbook/encryption.js +68 -21
  30. package/dist/workbook/sheet-writer.cjs +50 -2
  31. package/dist/workbook/sheet-writer.d.cts +1 -1
  32. package/dist/workbook/sheet-writer.d.ts +1 -1
  33. package/dist/workbook/sheet-writer.js +50 -2
  34. package/dist/written-cells.cjs +3 -3
  35. package/dist/written-cells.d.cts +2 -2
  36. package/dist/written-cells.d.ts +2 -2
  37. package/dist/written-cells.js +3 -3
  38. package/package.json +2 -2
package/README.md CHANGED
@@ -13,7 +13,9 @@ Under active development, with real, tested **read and write** support. Built an
13
13
  - **Record framing** (`src/biff/records.ts`, `src/biff/record-writer.ts`) — the three-component record structure of [MS-XLS] 2.1.4 in both directions, with the 8224-byte data ceiling enforced and every malformed or oversized stream thrown on rather than silently truncated or split into a `Continue` chain the writer does not implement.
14
14
  - **Continuation-aware cursor and strings** (`src/biff/cursor.ts`, `src/biff/strings.ts`, `src/biff/string-writer.ts`) — `Continue` records ([MS-XLS] 2.4.58) joined per the rules of the record being continued on read, including the case a naive reader gets wrong: an `XLUnicodeRichExtendedString` ([MS-XLS] 2.5.293) resuming after a boundary re-states its own `fHighByte` flag, which may differ from the flag the string started with. All three string shapes (`XLUnicodeString`, `ShortXLUnicodeString`, `XLUnicodeRichExtendedString`) are read and written, compressed (one byte per UTF-16 code unit) whenever every character allows it and uncompressed otherwise.
15
15
  - **Workbook globals**, read (`src/workbook/globals.ts`) and write (`src/workbook/globals-writer.ts`) — `BoundSheet8` (sheet names, tab order, hidden state, type, and substream offsets), `SST` with its `Continue` chain on read, `Format` (custom number-format codes), `Font`, `XF`'s fixed prefix plus its trailing `CellXF`/`StyleXF` fill/border payload in both directions (`src/biff/xf-colors.ts`'s shared bit-layout packing/unpacking; see [Cell decoration](#cell-decoration)), `Palette` in both directions, the fifteen mandatory built-in `Style` records, and `Date1904`.
16
- - **Worksheet substreams**, read (`src/workbook/sheet.ts`) and write (`src/workbook/sheet-writer.ts`) — `Dimensions`, `Row` (height and hidden state), `ColInfo` (width and hidden state), `MergeCells`, the print-settings record group (see [Print settings](#print-settings)), and the cell-value family: `Number`, `BoolErr`, `LabelSst`, and `Blank` on write (`MulBlank`, `RK`, `MulRk`, `Label`, and `Formula` with its `String` result record are read-only — see below). Cell comments are read-only too (`src/workbook/comments.ts`): a `Note` record's anchor and author joined to its own `Obj`+`Txo` pair's text via object id — see "Cell comments, resolved for real" under Read-side gaps below.
16
+ - **Worksheet substreams**, read (`src/workbook/sheet.ts`) and write (`src/workbook/sheet-writer.ts`) — `Dimensions`, `Row` (height and hidden state), `ColInfo` (width and hidden state), `MergeCells`, the print-settings record group (see [Print settings](#print-settings)), and the cell-value family: `Number`, `BoolErr`, `LabelSst`, `Blank`, and now `Formula` (see [Formula writing](#formula-writing)) on write (`MulBlank`, `RK`, `MulRk`, and `Label` stay read-only — see below).
17
+ - **Formula writing, same-sheet only** (`src/biff/ptg-writer.ts`) — a `ContentSheetCell.formula`'s own text, compiled back into a `Formula` record's `Ptg` token stream: literal operands, same-sheet cell/range references (`$`-qualified), every arithmetic/comparison/unary/percent operator, explicit parentheses, and a function call resolved by name against `ptg-functions.ts`'s own `Ftab` table (`PtgFunc` when its fixed arity matches, `PtgFuncVar` otherwise) — see [Formula writing](#formula-writing) for the exact scope boundary and why it stops at one sheet.
18
+ - **Cell comments** (`src/workbook/comment-writer.ts`) — a `ContentSheetCellComment`'s `text` and `author` written back out as the `Note`/`Obj`/`Txo` triple `src/workbook/comments.ts` already reads, one object id per commented cell; `replies` and `createdAt` have nowhere to land, matching the reader's own documented gap (legacy BIFF8 has no threading or per-comment timestamp at all).
17
19
  - **Number-format classification and date serials** ([`excel-number-format`](../excel-number-format/README.md), `src/serial.ts`) — what turns a bare number into the schema's own `percentage`/`currency`/`date`/`time`/`dateTime` value kinds and back, honouring the workbook's own epoch flag (the writer always emits the 1900 system) and refusing the 1900 system's phantom leap day in both directions. The classification itself is a dependency, not local code: this package shares it with `ooxml.js`'s xlsx support, since it is the identical mini-language in both formats (ExaDev/documents.js#848). A cell's own `numberFormatCode` is preserved verbatim on write when present; absent, it resolves to a representative built-in code for its value kind (`General` for a plain number/string/boolean/error, `0%` for a percentage, a bare `$` format for a currency with no code, `mm-dd-yy`/`h:mm:ss`/`m/d/yy h:mm` for date/time/dateTime), and the workbook-wide `Format`/`XF` table is deduplicated across every sheet so two cells sharing one code share one entry.
18
20
  - **Formula text recovery, including shared, array, and external-reference formulas** (`src/biff/ptg.ts`, `src/biff/ptg-functions.ts`, `src/workbook/sheet.ts`'s `collectFormulaGroups`, `src/workbook/globals.ts`'s `readSupBook`) — a Formula record's compiled `rgce` token stream ([MS-XLS] 2.5.198's `Ptg` vocabulary) read and rebuilt into the infix text a spreadsheet application would show: literal operands (`PtgInt`/`PtgNum`/`PtgStr`/`PtgBool`/`PtgErr`/`PtgMissArg`), cell and range references including their 3D (cross-sheet) forms (`PtgRef`/`PtgArea`/`PtgRef3d`/`PtgArea3d`, `$`-qualified per their own relative/absolute flags), every arithmetic/comparison/unary/percent operator and explicit parentheses, function calls through both `PtgFunc` (fixed arity, resolved from a curated table of [MS-XLS]'s own Ftab grammar) and `PtgFuncVar` (variable arity, its own on-disk `cparams`), a **shared formula** (`PtgExp` joined against the `ShrFmla` record that follows its group's base cell, its relative `PtgRefN`/`PtgAreaN` tokens re-expanded for each referencing cell's own position, alongside any ordinary, non-relative token the same shared expression carries), an **array formula** (`PtgExp` joined against an `Array` record instead, its expression returned with no further wrapping — Excel's own `{...}` CSE bracing is formula-bar display syntax, never written into the formula itself, matching `ooxml.js`'s own xlsx convention; a `PtgArray` array-constant literal's `{...}` is genuine syntax, not this bracing, and is resolved from its own `PtgExtraArray` trailer wherever one appears, array-entered or not), and a **3D reference's sheet name**, resolved through `EXTERNSHEET` and `SupBook` for both a self-referencing workbook and a genuinely external one (its own file name and sheet name(s) recovered as far as `SupBook`'s `virtPath`/`rgst` fields allow); a DDE/OLE/add-in/same-sheet/unused link, an unresolved sheet index, or an undecoded `virtPath` form has no real name to recover, and leaves the whole containing formula unresolved exactly like any other unsupported construct, rather than writing a fabricated placeholder into what would otherwise be real formula text (`src/workbook/globals.ts`'s own `sheetRanges`) — see "Formula expressions" under Read-side gaps below for the exact boundary of what still resolves to nothing at all.
19
21
  - **Schema mapping** — `readXlsContent`/`readXls` (`src/content.ts`) as before, now also populating `ContentSheetCell.formula` wherever the Ptg reader above resolves it; `writeXlsContent`/`writeXls` (`src/write.ts`) the counterpart, taking a `ContentDocument`/`DocumentTree` of `kind: 'spreadsheet'` and producing genuine `.xls` bytes: a real BIFF8 `Workbook` stream (globals substream, one worksheet substream per sheet, `BoundSheet8.lbPlyPos` patched to each sheet's real byte offset once every substream's length is known) wrapped in a real [MS-CFB] compound file via `archive-codec`'s `writeCompoundFile`.
@@ -28,22 +30,22 @@ Verified primarily by round trip (`src/write.test.ts`, plus a dedicated `test/wo
28
30
 
29
31
  ### Writer scope
30
32
 
31
- What `writeXlsContent`/`writeXls` cover: every `ContentCellValue` kind a real `.xls` can hold (`number`, `percentage`, `currency`, `boolean`, `date`, `time`, `dateTime`, `string`, `error`; an `empty` cell is written as a `Blank` record when it carries formatting and as nothing at all when it does not — see below), merged ranges (`colSpan`/`rowSpan`), row heights and hidden rows, column widths and hidden columns, multiple sheets, explicit and default number formats, a shared string table deduplicated across the whole workbook, every field of a sheet's own [print settings](#print-settings), a cell's own [alignment](#cell-alignment), and see [Cell decoration](#cell-decoration) a cell's own background fill (solid or a genuine two-colour pattern) and per-side borders, deduplicated across the workbook the same way number formats are. What it deliberately does not:
32
-
33
- | Not written | Why |
34
- | ------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
35
- | `Formula` records | The read side now recovers a formula's expression for the cells it resolves (see Read-side gaps below), but writing one back out is a materially larger task encoding text back into `Ptg` tokens, choosing built-in function indices, and (for anything the reader could not resolve in the first place) having no formula to write at all scoped as its own separate piece of work rather than folded into recovering the read side. A `ContentSheetCell.formula` is silently ignored on write; only the cell's own typed `value` is written. |
36
- | Per-cell font | The reader does not read one back: `ContentSheetCell` has no cell-level font field at all (`ooxml.js`'s xlsx reader makes the identical font-scope choice), so writing a real value here would be unverifiable by round trip. Every `XF` this writer emits still references the same single font. |
37
- | `MulBlank`/`RK`/`MulRk` | Pure compaction optimisations over information a plain `Blank`/`Number`/`LabelSst`/`BoolErr` record already carries losslessly. (`Blank` itself _is_ written, for a formatted empty cell — see the `empty` row below.) |
38
- | An `empty`-kind cell carrying no formatting | Written as nothing at all, which is what round-trips: `content.ts`'s reader drops an unformatted blank cell it reads, and a merged range's empty anchor is independently reconstructed from `MergeCells` alone. A formatted one (a fill, a border, or a non-default alignment) is the opposite case — its formatting exists only in the `XF` a cell record names, so it gets a real `Blank` record ([MS-XLS] 2.4.20) and round-trips with that formatting intact. |
39
- | Images, embedded objects (charts/drawings) | The reader now recovers a sheet's own pictures, charts, and generic drawing shapes (see Read-side gaps below), but writing them back out is its own separate mechanism — a shape's own MS-ODRAW (Escher) container tree, a workbook-wide Blip Store, and (for a chart) a whole nested `BOF`/`EOF` substream, none of which this writer's existing per-record emission has any machinery for — scoped as its own piece of work rather than folded into recovering the read side, the same split this table already draws for `Formula` records. `ContentSheet.images` and `ContentSheet.embeddedObjects` are silently ignored on write. |
40
- | Defined names (`Lbl`) | Read only for the two built-in ones a sheet's print range and repeated header bands live in ([Print settings](#print-settings)); a user-defined name has nowhere to land in `document-schema.js`'s spreadsheet model, so there is no round trip to verify a writer for it against. |
41
- | Data validation (`Dv`/`DVal`) | The reader now recovers a sheet's own validation rules (see Read-side gaps below), but writing them back out is its own separate mechanism -- re-encoding a formula's own text back into `Ptg` tokens for `Dv`'s two `DVParsedFormula` fields, the same "not folded into recovering the read side" split this table already draws for `Formula` records. A `ContentSheet.dataValidations` is silently ignored on write. |
42
- | Conditional formatting (`CondFmt`/`CF`, `CondFmt12`/`CF12`, `CFEx`) | The reader now recovers a sheet's own base `cellIs` rules and every `CF12`/`CFEx` rule this package's schema models (see Read-side gaps below), but writing them back out is its own separate mechanism, the same "not folded into recovering the read side" split this table already draws for `Formula` records and `Dv`. A `ContentSheet.conditionalFormats` is silently ignored on write. |
43
- | Cell comments (`Note`/`Txo`) | The reader now recovers a cell's comment (see Read-side gaps below), but writing one back out is its own separate mechanism -- a `Note`/`Obj`/`Txo` triple with its own object-id bookkeeping, distinct from every other cell record this writer already emits -- scoped as its own piece of work rather than folded into recovering the read side, the same split this table already draws for `Formula` records. A `ContentSheetCell.comment` is silently ignored on write. |
44
- | A page size no `iPaperSize` code names | Written as [MS-XLS] 2.4.257's own custom-paper value rather than as a named paper it is not — the dimensions themselves are unwritable, since `Setup` addresses paper only by code. See [Print settings](#print-settings). |
45
- | `Window1`/`Window2`, `CodePage`, `Index`/`DBCell`, the legacy interface records (`InterfaceHdr`, `WriteAccess`, …) | UI and interoperability bookkeeping [MS-XLS]'s own grammar names in the globals/worksheet substreams alongside the content-carrying records above, not data. `Index`/`DBCell` specifically is a pure cell-lookup performance optimisation (see [MS-XLS]'s own "Retrieval of Last-Calculated Cell Values Without Loading Cell Table") that this reader — and Excel's own reader — does not require to find a cell; real, well-established minimal BIFF8 writers (e.g. Python's `xlwt`) omit the same set and produce files Excel opens correctly. The calculation-state records (`CalcCount`, `CalcRefMode`, `CalcIter`, `CalcDelta`, `CalcSaveRecalc`) sat in this row until print settings needed them — see [Print settings](#print-settings) for why the writer emits them now. |
46
- | `Continue`-chain splitting | A record whose data would exceed the 8224-byte single-record ceiling ([MS-XLS] 2.1.4) — an extremely long shared string, an enormous shared string table, or thousands of merged ranges in one sheet — is refused with a thrown `BiffWriteError` rather than silently split across `Continue` records. |
33
+ What `writeXlsContent`/`writeXls` cover: every `ContentCellValue` kind a real `.xls` can hold (`number`, `percentage`, `currency`, `boolean`, `date`, `time`, `dateTime`, `string`, `error`; an `empty` cell is written as a `Blank` record when it carries formatting and as nothing at all when it does not — see below), merged ranges (`colSpan`/`rowSpan`), row heights and hidden rows, column widths and hidden columns, multiple sheets, explicit and default number formats, a shared string table deduplicated across the whole workbook, every field of a sheet's own [print settings](#print-settings), a cell's own [alignment](#cell-alignment), a cell's own background fill and per-side borders (see [Cell decoration](#cell-decoration)), a same-sheet formula (see [Formula writing](#formula-writing)), and a cell's own comment text and author (see [Cell comments, written](#cell-comments-written)). What it deliberately does not:
34
+
35
+ | Not written | Why |
36
+ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
37
+ | `Formula` records for a 3D (cross-sheet or external-workbook) reference, an array-constant literal or CSE array formula, a defined name, or a call to a function outside `Ftab`'s own vocabulary | Same-sheet formulas write (see [Formula writing](#formula-writing)); these four constructs each throw a `BiffWriteError` naming the construct instead, rather than emit a token stream the writer cannot prove round-trips. A 3D reference needs a `SupBook`/`ExternSheet` pair this writer only ever mints today for the two built-in print-settings names (see [Print settings](#print-settings)), not for an arbitrary formula; an array constant/CSE formula needs a `PtgExtraArray`/`Array`-record trailer this writer does not build; a defined name has nowhere to resolve against, the same gap the `Lbl` row below describes; and Excel 2007+ added many worksheet functions BIFF8's own `Ftab` enumeration never named, resolved through a `PtgNameX`/add-in mechanism this writer does not implement. |
38
+ | Per-cell font | The reader does not read one back: `ContentSheetCell` has no cell-level font field at all (`ooxml.js`'s xlsx reader makes the identical font-scope choice), so writing a real value here would be unverifiable by round trip. Every `XF` this writer emits still references the same single font. |
39
+ | `MulBlank`/`RK`/`MulRk` | Pure compaction optimisations over information a plain `Blank`/`Number`/`LabelSst`/`BoolErr` record already carries losslessly. (`Blank` itself _is_ written, for a formatted empty cell — see the `empty` row below.) |
40
+ | An `empty`-kind cell carrying no formatting | Written as nothing at all, which is what round-trips: `content.ts`'s reader drops an unformatted blank cell it reads, and a merged range's empty anchor is independently reconstructed from `MergeCells` alone. A formatted one (a fill, a border, or a non-default alignment) is the opposite case — its formatting exists only in the `XF` a cell record names, so it gets a real `Blank` record ([MS-XLS] 2.4.20) and round-trips with that formatting intact. |
41
+ | Images, embedded objects (charts/drawings) | The reader now recovers a sheet's own pictures, charts, and generic drawing shapes (see Read-side gaps below), but writing them back out is its own separate mechanism — a shape's own MS-ODRAW (Escher) container tree, a workbook-wide Blip Store, and (for a chart) a whole nested `BOF`/`EOF` substream, none of which this writer's existing per-record emission has any machinery for — scoped as its own piece of work rather than folded into recovering the read side, the same split this table already draws for `Formula` records. `ContentSheet.images` and `ContentSheet.embeddedObjects` are silently ignored on write. |
42
+ | Defined names (`Lbl`) | Read only for the two built-in ones a sheet's print range and repeated header bands live in ([Print settings](#print-settings)); a user-defined name has nowhere to land in `document-schema.js`'s spreadsheet model, so there is no round trip to verify a writer for it against. |
43
+ | Data validation (`Dv`/`DVal`) | The reader now recovers a sheet's own validation rules (see Read-side gaps below), but writing them back out is its own separate mechanism -- re-encoding a formula's own text back into `Ptg` tokens for `Dv`'s two `DVParsedFormula` fields, the same "not folded into recovering the read side" split this table already draws for `Formula` records. A `ContentSheet.dataValidations` is silently ignored on write. |
44
+ | Conditional formatting (`CondFmt`/`CF`, `CondFmt12`/`CF12`, `CFEx`) | The reader now recovers a sheet's own base `cellIs` rules and every `CF12`/`CFEx` rule this package's schema models (see Read-side gaps below), but writing them back out is its own separate mechanism, the same "not folded into recovering the read side" split this table already draws for `Formula` records and `Dv`. A `ContentSheet.conditionalFormats` is silently ignored on write. |
45
+ | A comment's `replies` or `createdAt` | A cell's `text` and `author` write (see the Status list above); legacy BIFF8's `Note`/`Obj`/`Txo` triple has no threading and no per-comment timestamp at all, the same gap `src/workbook/comments.ts`'s own read side already documents, so there is nothing for these two fields to round-trip against. |
46
+ | A page size no `iPaperSize` code names | Written as [MS-XLS] 2.4.257's own custom-paper value rather than as a named paper it is not — the dimensions themselves are unwritable, since `Setup` addresses paper only by code. See [Print settings](#print-settings). |
47
+ | `Window1`/`Window2`, `CodePage`, `Index`/`DBCell`, the legacy interface records (`InterfaceHdr`, `WriteAccess`, …) | UI and interoperability bookkeeping [MS-XLS]'s own grammar names in the globals/worksheet substreams alongside the content-carrying records above, not data. `Index`/`DBCell` specifically is a pure cell-lookup performance optimisation (see [MS-XLS]'s own "Retrieval of Last-Calculated Cell Values Without Loading Cell Table") that this reader — and Excel's own reader — does not require to find a cell; real, well-established minimal BIFF8 writers (e.g. Python's `xlwt`) omit the same set and produce files Excel opens correctly. The calculation-state records (`CalcCount`, `CalcRefMode`, `CalcIter`, `CalcDelta`, `CalcSaveRecalc`) sat in this row until print settings needed them — see [Print settings](#print-settings) for why the writer emits them now. |
48
+ | `Continue`-chain splitting | A record whose data would exceed the 8224-byte single-record ceiling ([MS-XLS] 2.1.4) — an extremely long shared string, an enormous shared string table, or thousands of merged ranges in one sheet — is refused with a thrown `BiffWriteError` rather than silently split across `Continue` records. |
47
49
 
48
50
  Column widths round-trip to the nearest pixel Excel's own integer-pixel-grid quantization allows (matching the read direction's own "honestly approximate" contract, `units.ts`), never narrower than requested. A `.xls` cell outside BIFF8's own grid (65536 rows, 256 columns) is refused rather than silently wrapped or truncated.
49
51
 
@@ -62,9 +64,9 @@ Each deliberate rather than overlooked:
62
64
  - **Conditional formatting, CF12's filter-dispatched template rules resolved for real (ExaDev/documents.js#1106).** `ct` 0x05 ("filter") dispatches further through `icfTemplate`, alongside a 16-byte `CFExTemplateParams` block always present regardless of `ct`. Of that block's five variants, only two need real parsing -- `CFExFilterParams` (top10's own `fTop`/`fPercent`/`iParam`) and `CFExAveragesTemplateParams` (the aboveAverage family's own standard-deviation count) -- since `CFExDefaultTemplateParams` (duplicateValues/uniqueValues/the four blank/error conditions) is 16 reserved bytes and `CFExDateTemplateParams`'s own `dateOp` field is a fixed restatement of `icfTemplate` for all ten date/time periods, so both dispatch directly off `icfTemplate` with no further byte reading. Unlike colour scale/data bar/icon set, [MS-XLS] does not force a `ct` 0x05 rule's own `cbDxf` to zero, so its `DXFN12` can carry a genuine font/fill override; that structure resolves through the identical `parseDxfStyle` base `CF`'s own `DXFN` already uses. A malformed `iParam` of zero degrades a top10 rule to absent rather than promoting a `rank` `ContentSheetConditionalFormatSchema` itself requires to be positive.
63
65
  - **Conditional formatting, `containsText`/`notContainsText`/`beginsWith`/`endsWith` and `CFEx`'s own legacy-`CF` extension resolved for real (ExaDev/documents.js#1100).** `icfTemplate` 0x0008 ("Contains text") is the one filter-dispatched template that is NOT actually a `ct` 0x05 rule: `CFExTextTemplateParams` carries only `ctp`, naming which of the four text sub-types a rule is, and neither it nor `ct` 0x05's own `CFFilter` `rgbCT` has anywhere to carry the literal search text (confirmed against a second, independent transcription of both structures -- [kinkou/unxls](https://github.com/kinkou/unxls)'s own `cfextexttemplateparams`/`cffilter` readers, which read `CFFilter` as an unconditional fixed six bytes with no variable trailer). Excel instead expresses these four rule kinds as a genuine `ct` 0x02 formula condition -- either directly on a `CF12` record, or (to stay evaluable by pre-2007 Excel, which silently skips an unrecognised `CF12`/`CondFmt12` "future record" wholesale) via a `CFEx` record ([MS-XLS] 2.4.63) extending an ordinary legacy `CF` -- with the literal text present as a string-constant (`PtgStr`) operand somewhere in the formula's own token stream, matching Excel's real, independently-confirmed generated formula for each sub-type (`NOT(ISERROR(SEARCH("text",cell)))` for `containsText`, and the `ISERROR`/`LEFT`+`LEN`/`RIGHT`+`LEN` equivalents for the other three -- LibreOffice's own `xecontent.cxx` `GetFixedFormula` emits exactly these shapes for its xlsx compatibility formula). `readCfTextFilterRule` (`src/workbook/conditional-format-12.ts`) extracts that operand via `ptg.ts`'s `extractFirstStringLiteral`, reached from both `readCf12`'s own `ct` 0x02 branch and `readCfEx` (`src/workbook/conditional-format-ex.ts`). A `CFEx` record with `fIsCF12` set (extending a genuine `CF12` record rather than a legacy `CF`) stays unread: that `CF12` carries no ranges of its own -- a `CondFmt12` normally supplies them -- and this reader has no established link back to one for it. `CFEx`'s own `nID` cross-references the `nID` field [MS-XLS] 2.1.7.20.6's own worksheet-substream grammar places on every `CondFmt` record (every `CFEx` on a sheet follows every `CondFmt`/`CondFmt12` group, per that same grammar), so `workbook/sheet.ts` keeps a per-`nID` map of each group's own resolved ranges and raw `CF` operands as it walks the substream, for a later `CFEx` to resolve its own `icf` index against.
64
66
  - **Not read at all:** a `CFEx` record that extends a genuine `CF12` record rather than a legacy `CF` (see above). Defined names (`Lbl`) are read only for the two built-in ones a sheet's print range and repeated header bands live in ([Print settings](#print-settings)); a user-defined name has nowhere to land in `document-schema.js`'s spreadsheet model, so it is skipped.
65
- - **RC4-encrypted workbooks, decrypted for real (ExaDev/documents.js#1108).** A `FilePass` record ([MS-XLS] 2.4.117) means every record after it is ciphertext under one of three schemes; this reader implements exactly one, the [MS-OFFCRYPTO] 2.3.6.1 "RC4 encryption header" -- `readXlsContent`/`readXls` take an optional `password`, verify it against the header's own `EncryptedVerifier`/`EncryptedVerifierHash` pair, then decrypt every subsequent record's data (`src/workbook/encryption.ts`) using the RC4/MD5 primitives [`archive-codec`](../archive-codec/README.md#legacy-office-encryption) shares with `doc-codec`/`ppt-codec`'s own eventual integration. [MS-XLS] 2.2.10's own exclusion list -- `BOF`, `FilePass`, and four shared-workbook revision-tracking records this package otherwise never reads, plus `BoundSheet8`'s own `lbPlyPos` field specifically -- is honoured exactly, re-derived independently in this package's own tests rather than trusted by construction. A missing or incorrect password throws rather than returning a garbled document; the newer "RC4 CryptoAPI encryption header" scheme and XOR obfuscation are explicitly out of scope, tracked separately on [#922](https://github.com/ExaDev/documents.js/issues/922).
67
+ - **RC4-encrypted and XOR-obfuscated workbooks, decrypted for real (ExaDev/documents.js#1108, #922).** A `FilePass` record ([MS-XLS] 2.4.117) means every record after it is ciphertext under one of three schemes; this reader implements two of them, dispatching on the record's own `wEncryptionType` -- the [MS-OFFCRYPTO] 2.3.6.1 "RC4 encryption header" and 2.3.7's own XOR obfuscation (Method 1) -- `readXlsContent`/`readXls` take an optional `password`, verify it against the header's own fields (RC4's `EncryptedVerifier`/`EncryptedVerifierHash`, decrypted then compared; XOR obfuscation's `key`/`verificationBytes`, plain unencrypted checksums compared directly), then decrypt every subsequent record's data (`src/workbook/encryption.ts`) using the RC4/MD5 and XOR-obfuscation primitives [`archive-codec`](../archive-codec/README.md#xor-obfuscation-xlsdoc) shares with `doc-codec`. XOR obfuscation's own per-record `XorArrayIndex` -- `(streamOffset + recordDataLength) % 16`, `recordDataLength` always the record's own full declared size regardless of how much of a given span is actually being decrypted -- is confirmed against a genuine Excel-generated fixture, not just the published spec text (see `archive-codec`'s own account of why that spec text alone is not trustworthy here). [MS-XLS] 2.2.10's own exclusion list -- `BOF`, `FilePass`, and four shared-workbook revision-tracking records this package otherwise never reads, plus `BoundSheet8`'s own `lbPlyPos` field specifically -- is honoured exactly for both schemes, re-derived independently in this package's own tests rather than trusted by construction. A missing or incorrect password throws rather than returning a garbled document; the newer "RC4 CryptoAPI encryption header" scheme remains explicitly out of scope (ppt-codec's own encryption, tracked separately on [#1116](https://github.com/ExaDev/documents.js/issues/1116)).
66
68
 
67
- This package is wired into `documents.js`'s conversion registry (`xlsToPdf`/`pdfToXls`, `convertDocument("xls", ...)`, and every same-variant spreadsheet bridge) — see that package's own README Fidelity table for exactly which pairs route and which don't. Remaining read+write scope gaps (formula writing and per-cell font) are tracked on [#815](https://github.com/ExaDev/documents.js/issues/815).
69
+ This package is wired into `documents.js`'s conversion registry (`xlsToPdf`/`pdfToXls`, `convertDocument("xls", ...)`, and every same-variant spreadsheet bridge) — see that package's own README Fidelity table for exactly which pairs route and which don't. Per-cell font is the one remaining read+write scope gap that is genuinely permanent rather than pending (see the Writer scope table below); formula writing and cell comments are covered as of [Formula writing](#formula-writing) and [Cell comments, written](#cell-comments-written), each within the boundary those sections describe. Images/embedded objects, data validation, conditional formatting, and defined names remain read-only — tracked on [#971](https://github.com/ExaDev/documents.js/issues/971).
68
70
 
69
71
  ## Cell decoration
70
72
 
@@ -98,6 +100,26 @@ A cell's own horizontal and vertical alignment are read from and written to the
98
100
 
99
101
  **Verified against a real, independent BIFF8 implementation, not just this package's own reader/writer pair.** A `.xls` this package writes — one cell per `Alignment` member, one per `verticalAlignment` member, and one combining a non-default horizontal and vertical alignment on the same cell — opens in LibreOffice 26.2.5.2 with every alignment intact, confirmed by converting it back through `soffice --headless --convert-to fods` and inspecting the cell styles LibreOffice itself recovers: each `left`/`center`/`right`/`justify` cell reads back as LibreOffice's own `fo:text-align="start"/"center"/"end"/"justify"`, each `top`/`middle` cell as `style:vertical-align="top"/"middle"`, and the combined cell as both at once on the identical style. Going the other way, a `.xls` built directly by LibreOffice (`soffice --headless --convert-to xls`, from a hand-authored `.fods` declaring the same real `fo:text-align`/`style:vertical-align` cell styles) is read correctly by this package's own reader, every cell recovering the exact alignment LibreOffice's own style declared.
100
102
 
103
+ ## Formula writing
104
+
105
+ `src/biff/ptg-writer.ts` compiles a `ContentSheetCell.formula`'s own text back into a `Formula` record's `Ptg` token stream — the write-side counterpart of `src/biff/ptg.ts`, and, alongside [cell comments](#cell-comments-written) below, the first two of `xls-codec`'s read+write scope gaps this package's own README used to list as `Formula` records/`Note`/`Txo` being read-only.
106
+
107
+ **Scope: same-sheet formulas over `Ftab`'s own function vocabulary, nothing more.** A tokenizer and a small recursive-descent parser (Excel's own [documented operator precedence](https://support.microsoft.com/en-us/office/calculation-operators-and-precedence-in-excel), narrowed to the operators `ptg.ts`'s reader reconstructs) turn the formula text into a tree, then a second pass walks that tree post-order to emit `Ptg` tokens exactly the shape `ptg.ts` expects to read back: literal operands (`PtgInt`/`PtgNum`/`PtgStr`/`PtgBool`/`PtgErr`), a same-sheet cell or range reference (`PtgRef`/`PtgArea`, each `$`-qualified per the text's own absolute/relative markers), every arithmetic/comparison/unary/percent operator, an explicit parenthesis (`PtgParen`, restated unconditionally so the round trip preserves exactly what the author typed rather than only what precedence strictly requires), and a function call resolved by name against `ptg-functions.ts`'s own `Ftab` table — `PtgFunc` when the name's own fixed arity (where `Ftab`'s grammar states one) matches the call's argument count, `PtgFuncVar` otherwise. Every `Formula` record this writer produces carries its own complete, independent `rgce` — never a `PtgExp` pointing at a shared or array formula group — which is entirely legal BIFF8 (shared-formula compression is an optimisation, not a requirement) and sidesteps needing to build a `ShrFmla`/`Array` record pair at all.
108
+
109
+ **What throws instead of writing unreadable bytes**, each because the construct needs infrastructure this writer does not have, or has nowhere to resolve against: a **3D (cross-sheet or external-workbook) reference** — resolving one to an `ixti` needs a `SupBook`/`ExternSheet` pair, which `globals-writer.ts` today only ever mints for the two built-in [print-settings](#print-settings) names, not for an arbitrary formula; an **array-constant literal** (`{1,2;3,4}`) or a **CSE array formula** — both need a `PtgExtraArray`/`Array`-record trailer this writer does not build; a **defined name** — `document-schema.js`'s spreadsheet model has nowhere a user-defined name lives, the identical gap the `Lbl` row of the [Writer scope](#writer-scope) table already describes; and a **function name outside `Ftab`'s own vocabulary** — Excel 2007+ added many worksheet functions BIFF8's `Ftab` enumeration never named (resolved instead through a `PtgNameX`/add-in mechanism this writer does not implement). Every one of these throws `BiffWriteError` naming the construct.
110
+
111
+ **The cell's own cached `value` is written into the `Formula` record's `FormulaValue` field alongside `rgce`**, exactly as a real producer does: a numeric/temporal `value` kind writes as a plain IEEE 754 double, `boolean`/`error` write through `FormulaValue`'s own tagged shape, and `string` writes the tagged shape plus a following `String` record carrying the cached text — matching `workbook/sheet.ts`'s own `taggedFormulaValue` read side field for field. A formula whose `value` resolves to `empty` is refused: the one BIFF8 encoding that could carry it (a tagged "blank" result) reads back through this package's own reader as an empty _string_, not an empty cell, so writing it would silently change what round-trips.
112
+
113
+ Verified by round trip (`src/write.test.ts`'s own `formula records` suite) rather than against a third-party BIFF8 implementation — unlike [Cell decoration](#cell-decoration)/[Cell alignment](#cell-alignment)/[Print settings](#print-settings), no LibreOffice-authored fixture exists yet to cross-check a written `Formula` record's bytes against.
114
+
115
+ ## Cell comments, written
116
+
117
+ `src/workbook/comment-writer.ts` writes a `ContentSheetCellComment` back out as the same `Note`/`Obj`/`Txo` triple `src/workbook/comments.ts` already reads (see that module's own top comment for the full citation of how the three record kinds join): one `Note` record per commented cell naming its own `Obj` record by object id, that `Obj` record's `FtCmo`+`FtNts` pair marking it a comment, and a `Txo` record plus one `Continue` record carrying the comment's own text and a minimal, unformatted `TxORuns` trailer ([MS-XLS]'s own "`cbRuns` MUST be >= 16 and a multiple of 8" rule needs at least one real run plus its terminating `TxOLastRun`, even for plain text with no rich formatting). Object ids are assigned sequentially per sheet, starting at 1 — safe because this writer emits no other kind of `Obj` record yet (no shapes, charts, or form controls — see [Writer scope](#writer-scope)).
118
+
119
+ Only `text` and `author` round-trip: legacy BIFF8's `NoteSh` carries no reply structure and no per-comment timestamp at all (unlike xlsx's own `[MS-XLSX]` threaded-comments extension), so `replies` and `createdAt` have nowhere to write to, the identical gap the reader's own [Read-side gaps](#read-side-gaps) section documents. A comment with no recorded `author` writes `stAuthor` as an empty string rather than a non-empty placeholder — `NoteSh`'s own field documents a length-1 minimum, but this package's reader only ever promotes a _non-empty_ `stAuthor` to `ContentSheetCellComment.author`, so a placeholder would round-trip back as a fabricated author nobody wrote; an empty string is the one spelling that round-trips as "no author" through this reader specifically. Rich per-character formatting within a comment (`TxORuns`' own genuine run array) is never written, matching the reader's own identical scope limit.
120
+
121
+ Verified by round trip (`src/write.test.ts`'s own `cell comments` suite): a comment's text and author on a valued cell, a comment anchored to an otherwise-empty cell, a comment with no author, an empty-text comment, and several comments on one sheet each keeping their own cell and text.
122
+
101
123
  ## Print settings
102
124
 
103
125
  Every field of `document-schema.js`'s own `ContentSheetPrintSettings` is read from and written to the BIFF8 records that carry it, verified both by round trip and against a real, independent BIFF8 implementation — [ExaDev/documents.js#815](https://github.com/ExaDev/documents.js/issues/815) as a scoped chunk of that issue's own broader tracking, not a claim of closing it outright. The two directions had to land together: before this, the reader returned Excel's fixed "Normal" preset for every sheet regardless of what the file said, so there was nothing to verify a writer's output against.
@@ -201,6 +223,8 @@ Layered bottom-up, each layer testable against hand-built byte sequences taken f
201
223
  - **`src/biff/cursor.ts`** — a field cursor over one record's blocks that reads across a continuation boundary transparently while keeping the boundary observable, which is exactly what the string reader needs.
202
224
  - **`src/biff/strings.ts`**, **`src/biff/rk.ts`**, **`src/biff/errors.ts`** — the shared value encodings: the three string shapes, the `RkNumber` packed-numeric encoding, and the `BErr` error-value vocabulary.
203
225
  - **`src/biff/ptg.ts`**, **`src/biff/ptg-functions.ts`** — the `Ptg` compiled-formula token stream, walked as a postfix expression and rebuilt into infix formula text (an operand stack tagged with each entry's own operator precedence, so a child is parenthesised only when its precedence genuinely requires it), and the built-in worksheet-function name/fixed-arity table `PtgFunc`/`PtgFuncVar` resolve against.
226
+ - **`src/biff/ptg-writer.ts`** — the write direction: a tokenizer and recursive-descent parser turning same-sheet formula text into a tree, then a post-order walk emitting `Ptg` tokens — see [Formula writing](#formula-writing) for its exact scope.
227
+ - **`src/workbook/comment-writer.ts`** — the write direction of `src/workbook/comments.ts`: a cell's own `Note`/`Obj`/`Txo` triple, text and author only — see [Cell comments, written](#cell-comments-written).
204
228
  - **`src/workbook/globals.ts`**, **`src/workbook/sheet.ts`** — the two substream readers, each walking the record sequence its ABNF in [MS-XLS] 2.1.7.20.3 / 2.1.7.20.5 defines; `globals.ts` also resolves a 3D reference's own `ixti` to a sheet scope through `EXTERNSHEET` and `SupBook` (a plain sheet range for a self-referencing workbook, a fully-formatted label — a real external workbook/sheet name or a diagnostic placeholder — otherwise), which `sheet.ts` threads into `ptg.ts` for a Formula record's own 3D references, and reads a `Palette` record and each `XF`'s trailing fill/border payload for [Cell decoration](#cell-decoration). `sheet.ts`'s own `collectFormulaGroups` additionally joins a shared or array formula's `PtgExp`-bearing member cells against the `ShrFmla`/`Array` record that carries the group's real expression (see "Formula expressions" under [Read-side gaps](#read-side-gaps)). Both readers contribute to [Print settings](#print-settings), which BIFF8 splits between them: `sheet.ts` reads the page-setup record group, `globals.ts` the two built-in defined names carrying the print range and the repeated header bands.
205
229
  - **`src/biff/xf-colors.ts`** — the `Icv` colour table (both the eight fixed colours and the 56-entry default palette), the `BorderStyle`/`FillPattern` vocabularies, and the `CellXF`/`StyleXF` trailing payload's own border/fill bit-layout packing and unpacking, shared by `globals.ts`'s read side and `biff/xf-writer.ts`'s write side — see [Cell decoration](#cell-decoration).
206
230
  - **`src/biff/print-setup.ts`**, **`src/workbook/print-names.ts`** — the two halves of [Print settings](#print-settings). The first is the `Setup` record's own flag bit layout and `iPaperSize` code table, packed and unpacked in one place exactly as `xf-colors.ts` does for the `XF` payload; the second is both directions of the built-in `Print_Area`/`Print_Titles` defined names, which live in the globals substream rather than the sheet's own and so are read by `globals.ts` and written by `globals-writer.ts`.
@@ -378,6 +378,9 @@ const FTAB_ENTRIES = [
378
378
  const FTAB_NAMES = new Map(FTAB_ENTRIES.map(([iftab, [name]]) => [iftab, name]));
379
379
  /** A function's fixed argument count, by its Ftab index -- consulted only for PtgFunc, whose own token carries no count. Absent for every entry [MS-XLS]'s grammar gives an optional or repeated argument, which is never resolved through PtgFunc in practice (see the module comment). */
380
380
  const FTAB_FIXED_ARITY = new Map(FTAB_ENTRIES.filter((entry) => entry[1][1] !== void 0).map(([iftab, [, arity]]) => [iftab, arity]));
381
+ /** The write direction's own lookup: a function's Ftab index by its displayed name, consulted by biff/ptg-writer.ts when compiling a formula's own function calls back into PtgFunc/PtgFuncVar tokens. Built from FTAB_NAMES rather than as a second hand-maintained table, so the two directions cannot drift apart; every name in this table is unique, so the inversion loses nothing. */
382
+ const FTAB_IFTAB_BY_NAME = new Map(FTAB_ENTRIES.map(([iftab, [name]]) => [name, iftab]));
381
383
  //#endregion
382
384
  exports.FTAB_FIXED_ARITY = FTAB_FIXED_ARITY;
385
+ exports.FTAB_IFTAB_BY_NAME = FTAB_IFTAB_BY_NAME;
383
386
  exports.FTAB_NAMES = FTAB_NAMES;
@@ -3,5 +3,7 @@
3
3
  declare const FTAB_NAMES: ReadonlyMap<number, string>;
4
4
  /** A function's fixed argument count, by its Ftab index -- consulted only for PtgFunc, whose own token carries no count. Absent for every entry [MS-XLS]'s grammar gives an optional or repeated argument, which is never resolved through PtgFunc in practice (see the module comment). */
5
5
  declare const FTAB_FIXED_ARITY: ReadonlyMap<number, number>;
6
+ /** The write direction's own lookup: a function's Ftab index by its displayed name, consulted by biff/ptg-writer.ts when compiling a formula's own function calls back into PtgFunc/PtgFuncVar tokens. Built from FTAB_NAMES rather than as a second hand-maintained table, so the two directions cannot drift apart; every name in this table is unique, so the inversion loses nothing. */
7
+ declare const FTAB_IFTAB_BY_NAME: ReadonlyMap<string, number>;
6
8
  //#endregion
7
- export { FTAB_FIXED_ARITY, FTAB_NAMES };
9
+ export { FTAB_FIXED_ARITY, FTAB_IFTAB_BY_NAME, FTAB_NAMES };
@@ -3,5 +3,7 @@
3
3
  declare const FTAB_NAMES: ReadonlyMap<number, string>;
4
4
  /** A function's fixed argument count, by its Ftab index -- consulted only for PtgFunc, whose own token carries no count. Absent for every entry [MS-XLS]'s grammar gives an optional or repeated argument, which is never resolved through PtgFunc in practice (see the module comment). */
5
5
  declare const FTAB_FIXED_ARITY: ReadonlyMap<number, number>;
6
+ /** The write direction's own lookup: a function's Ftab index by its displayed name, consulted by biff/ptg-writer.ts when compiling a formula's own function calls back into PtgFunc/PtgFuncVar tokens. Built from FTAB_NAMES rather than as a second hand-maintained table, so the two directions cannot drift apart; every name in this table is unique, so the inversion loses nothing. */
7
+ declare const FTAB_IFTAB_BY_NAME: ReadonlyMap<string, number>;
6
8
  //#endregion
7
- export { FTAB_FIXED_ARITY, FTAB_NAMES };
9
+ export { FTAB_FIXED_ARITY, FTAB_IFTAB_BY_NAME, FTAB_NAMES };
@@ -377,5 +377,7 @@ const FTAB_ENTRIES = [
377
377
  const FTAB_NAMES = new Map(FTAB_ENTRIES.map(([iftab, [name]]) => [iftab, name]));
378
378
  /** A function's fixed argument count, by its Ftab index -- consulted only for PtgFunc, whose own token carries no count. Absent for every entry [MS-XLS]'s grammar gives an optional or repeated argument, which is never resolved through PtgFunc in practice (see the module comment). */
379
379
  const FTAB_FIXED_ARITY = new Map(FTAB_ENTRIES.filter((entry) => entry[1][1] !== void 0).map(([iftab, [, arity]]) => [iftab, arity]));
380
+ /** The write direction's own lookup: a function's Ftab index by its displayed name, consulted by biff/ptg-writer.ts when compiling a formula's own function calls back into PtgFunc/PtgFuncVar tokens. Built from FTAB_NAMES rather than as a second hand-maintained table, so the two directions cannot drift apart; every name in this table is unique, so the inversion loses nothing. */
381
+ const FTAB_IFTAB_BY_NAME = new Map(FTAB_ENTRIES.map(([iftab, [name]]) => [name, iftab]));
380
382
  //#endregion
381
- export { FTAB_FIXED_ARITY, FTAB_NAMES };
383
+ export { FTAB_FIXED_ARITY, FTAB_IFTAB_BY_NAME, FTAB_NAMES };