diff --git a/packages/xls-codec/README.md b/packages/xls-codec/README.md index 476a388df..2c77707aa 100644 --- a/packages/xls-codec/README.md +++ b/packages/xls-codec/README.md @@ -15,7 +15,8 @@ Under active development, with real, tested **read and write** support. Built an - **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 a fully-packed `CellXF`/`StyleXF` payload on write, the fifteen mandatory built-in `Style` records, and `Date1904`. - **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`, and the cell-value family: `Number`, `BoolErr`, and `LabelSst` on write (`Blank`, `MulBlank`, `RK`, `MulRk`, `Label`, and `Formula` with its `String` result record are read-only — see below). - **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. -- **Schema mapping** — `readXlsContent`/`readXls` (`src/content.ts`) as before; `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`. +- **Formula text recovery** (`src/biff/ptg.ts`, `src/biff/ptg-functions.ts`) — 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, a 3D reference's sheet name resolved through `EXTERNSHEET` and a self-referencing `SupBook` — `src/workbook/globals.ts`'s own `sheetRanges`), every arithmetic/comparison/unary/percent operator and explicit parentheses, and 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`) — see "Formula expressions" under Read-side gaps below for the exact boundary of what this does not resolve. +- **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`. Verified primarily by round trip (`src/write.test.ts`, plus a dedicated `test/workers/write.test.ts` proving the whole write path inside a real `workerd` isolate, not just Node): build a `ContentDocument`, write it, read it back through this package's own independently-pinned reader, and check the result. Every record's own byte layout is additionally cited to its [MS-XLS] section in the writer's source, matching the reader's own convention. @@ -23,15 +24,15 @@ Verified primarily by round trip (`src/write.test.ts`, plus a dedicated `test/wo What `writeXlsContent`/`writeXls` cover: every `ContentCellValue` kind a real `.xls` can hold (`number`, `percentage`, `currency`, `boolean`, `date`, `time`, `dateTime`, `string`, `error`; `empty` cells are never written as records — see below), merged ranges (`colSpan`/`rowSpan`), row heights and hidden rows, column widths and hidden columns, multiple sheets, explicit and default number formats, and a shared string table deduplicated across the whole workbook. What it deliberately does not: -| Not written | Why | -| ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `Formula` records | The read side never recovers a formula's expression either (see below) — there is nothing to write back. A `ContentSheetCell.formula` is silently ignored; only the cell's own typed `value` is written. | -| Cell decoration (fill, borders, alignment, per-cell font) | The reader does not read a `CellXF`'s decoration payload back (see below), so writing real values here would be unverifiable by round trip. Every `XF` this writer emits carries the same undecorated defaults (general alignment, bottom vertical alignment, no border, no fill) a genuinely undecorated Excel-written cell also carries. | -| `Blank`/`MulBlank`/`RK`/`MulRk` | Pure compaction optimisations over information a plain `Number`/`LabelSst`/`BoolErr` record already carries losslessly. An `empty`-kind cell is never written at all — `content.ts`'s own reader drops every blank cell it reads regardless, and a merged range's empty anchor is independently reconstructed from `MergeCells` alone, so writing nothing for one is what round-trips correctly rather than a gap. | -| Images, embedded objects, comments (`Note`/`Txo`), data validation, conditional formatting, defined names (`Lbl`) | Not read either (see below); there is no round trip to verify a writer for them against. | -| Print settings (`Setup`, margins, `PrintGrid`, `PrintRowCol`) and workbook metadata (`\x05SummaryInformation`) | Same reason — the reader always returns its own fixed "Normal" preset and empty metadata regardless of what a file states, so writing the real values would be unverifiable. | -| `RECALC`/calc-state records (`CalcMode`, `CalcCount`, …), `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. | -| `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. | +| Not written | Why | +| ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `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. | +| Cell decoration (fill, borders, alignment, per-cell font) | The reader does not read a `CellXF`'s decoration payload back (see below), so writing real values here would be unverifiable by round trip. Every `XF` this writer emits carries the same undecorated defaults (general alignment, bottom vertical alignment, no border, no fill) a genuinely undecorated Excel-written cell also carries. | +| `Blank`/`MulBlank`/`RK`/`MulRk` | Pure compaction optimisations over information a plain `Number`/`LabelSst`/`BoolErr` record already carries losslessly. An `empty`-kind cell is never written at all — `content.ts`'s own reader drops every blank cell it reads regardless, and a merged range's empty anchor is independently reconstructed from `MergeCells` alone, so writing nothing for one is what round-trips correctly rather than a gap. | +| Images, embedded objects, comments (`Note`/`Txo`), data validation, conditional formatting, defined names (`Lbl`) | Not read either (see below); there is no round trip to verify a writer for them against. | +| Print settings (`Setup`, margins, `PrintGrid`, `PrintRowCol`) and workbook metadata (`\x05SummaryInformation`) | Same reason — the reader always returns its own fixed "Normal" preset and empty metadata regardless of what a file states, so writing the real values would be unverifiable. | +| `RECALC`/calc-state records (`CalcMode`, `CalcCount`, …), `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. | +| `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. | 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. @@ -39,14 +40,14 @@ Column widths round-trip to the nearest pixel Excel's own integer-pixel-grid qua Each deliberate rather than overlooked: -- **Formula expressions.** A `Formula` record's cached _result_ is read, so a formula cell shows the right value, but `ContentSheetCell.formula` stays absent. BIFF8 stores the expression as a compiled `Ptg` token stream rather than as text, and recovering it means implementing the whole `Ptg` vocabulary plus shared-formula (`ShrFmla`) and external-reference (`SupBook`/`ExternSheet`) resolution. +- **Formula expressions, mostly recovered.** A `Formula` record's compiled `Ptg` token stream (`src/biff/ptg.ts`) is walked and rebuilt into real formula text — literal operands, cell/range references (`$`-qualified, including 3D cross-sheet references resolved through `EXTERNSHEET` and a self-referencing `SupBook`), every arithmetic/comparison/unary/percent operator, explicit parentheses, and both fixed- and variable-arity function calls, resolved by name against [MS-XLS]'s own built-in function table (`src/biff/ptg-functions.ts`, covering the whole published table — [MS-XLS] 2.5.198.17 — cited to that table's own `iftab` index; PtgFunc's fixed argument count is a curated subset of it, since PtgFunc's own token carries no count and only a function [MS-XLS]'s grammar states a fixed, non-optional arity for is resolved through it, empirically confirmed against real LibreOffice-written BIFF8 rather than assumed from the grammar alone). Three constructs remain genuinely unresolved, each leaving `ContentSheetCell.formula` absent for that cell specifically rather than fabricating text: a **shared formula** (`PtgExp`, whose real expression lives in a separate `ShrFmla` record this reader does not yet join), an **array formula** (`PtgArray`, whose literal values live in a separate `PtgExtraArray` trailer this reader does not yet parse), and a **genuinely external workbook** reference (a `SupBook` naming another file, a DDE/OLE data source, or an add-in, rather than this same workbook) — each is real, meaningfully separate work, not an oversight. A defined name (`PtgName`/`PtgNameX`) and a natural-language "Elf" reference are likewise not resolved, for the same reason `Lbl` (defined names) is not read at all yet (see below). - **Cell decoration** — fill, borders, and alignment from `XF`'s trailing `CellXF` payload, whose colours are palette indices needing the `Palette` record and the default colour table to resolve. `Font` records are not read either: `ContentSheetCell` has no cell-level font field, and `ooxml.js`'s xlsx reader likewise maps only the number format and decoration from a cell format. - **Print settings** are emitted as Excel's documented "Normal" preset rather than read from the file. The real values need `Setup` (including its paper-size code table), the four margin records, `PrintGrid`, and `PrintRowCol`. - **Workbook metadata** — `metadata` is empty. Title, author, and dates live in the `\x05SummaryInformation` property-set stream ([MS-OSHARED]), a different format from BIFF8 sitting beside it in the same container. - **Not read at all:** charts, drawings and images, cell comments (`Note`/`Txo`), data validation, conditional formatting, and defined names (`Lbl`). - **Encrypted workbooks** are refused rather than mis-read: a `FilePass` record means every record after it is ciphertext. -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 (formulas, cell decoration, print settings, metadata) are tracked on [#815](https://github.com/ExaDev/documents.js/issues/815). +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, shared/array/external-reference formulas, cell decoration, print settings, metadata) are tracked on [#815](https://github.com/ExaDev/documents.js/issues/815). ## Getting started @@ -101,7 +102,8 @@ Layered bottom-up, each layer testable against hand-built byte sequences taken f - **`src/biff/records.ts`** — the record framing, and nothing above it. Deliberately does not merge `Continue` records: whether a continuation's bytes simply append or re-state a flag byte first is decided by the record being continued, so the blocks are reported as written. - **`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. - **`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. -- **`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. +- **`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. +- **`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 range through `EXTERNSHEET` and a self-referencing `SupBook`, which `sheet.ts` threads into `ptg.ts` for a Formula record's own 3D references. - **[`excel-number-format`](../excel-number-format/README.md)**, **`src/serial.ts`** — number-format classification and date-serial conversion, the two pieces of xlsx semantics BIFF8 shares because ECMA-376 inherited them from BIFF. The classifier itself is a dependency shared with `ooxml.js`, not a module in this package (ExaDev/documents.js#848) — `classifyNumberFormat` and `BUILTIN_NUMBER_FORMATS` still ride this package's own barrel (`export * from "excel-number-format"` in `src/index.ts`), so `import { classifyNumberFormat } from "xls-codec"` is unchanged. - **`src/content.ts`** — the mapping onto `document-schema.js`. diff --git a/packages/xls-codec/src/biff/cursor.test.ts b/packages/xls-codec/src/biff/cursor.test.ts index dc4326ce0..c1cde4a43 100644 --- a/packages/xls-codec/src/biff/cursor.test.ts +++ b/packages/xls-codec/src/biff/cursor.test.ts @@ -25,6 +25,13 @@ describe("BlockCursor", () => { expect(cursor.i32()).toBe(-1); }); + it("reads a signed 16-bit integer", () => { + const cursor = new BlockCursor([bytes(0xff, 0xff, 0x02, 0x00)]); + + expect(cursor.i16()).toBe(-1); + expect(cursor.i16()).toBe(2); + }); + it("reads an Xnum as an IEEE 754 double", () => { // [MS-XLS] 2.5.342: Xnum is a 64-bit binary floating-point number. 1.5 is 0x3FF8000000000000, little-endian on the wire. const cursor = new BlockCursor([ diff --git a/packages/xls-codec/src/biff/cursor.ts b/packages/xls-codec/src/biff/cursor.ts index ff2752401..b2c946eaa 100644 --- a/packages/xls-codec/src/biff/cursor.ts +++ b/packages/xls-codec/src/biff/cursor.ts @@ -83,6 +83,11 @@ export class BlockCursor { return this.u32() | 0; } + /** A signed 16-bit integer, sign-extended by shifting the raw value out of and back into the low 16 bits -- what XTI's itabFirst/itabLast ([MS-XLS] 2.5.344) and a handful of other structures carry. */ + i16(): number { + return (this.u16() << 16) >> 16; + } + /** An Xnum ([MS-XLS] 2.5.342): a little-endian IEEE 754 double. */ f64(): number { const raw = this.take(8); diff --git a/packages/xls-codec/src/biff/ptg-functions.ts b/packages/xls-codec/src/biff/ptg-functions.ts new file mode 100644 index 000000000..782f2b65b --- /dev/null +++ b/packages/xls-codec/src/biff/ptg-functions.ts @@ -0,0 +1,396 @@ +// The built-in worksheet-function table PtgFunc and PtgFuncVar's own `iftab`/`tab` field resolves against ([MS-XLS] 2.5.198.17, Ftab -- https://learn.microsoft.com/en-us/openspecs/office_file_formats/ms-xls/59667dd3-d6f6-4949-8a9b-b26f13949e3c). Every entry [MS-XLS] enumerates is listed here, cited to that same table by its own `iftab` value, so a formula naming a function this reader has never heard of still resolves through the identical published index rather than a partial guess. +// +// The second element of each tuple is the function's FIXED argument count when [MS-XLS]'s own grammar states one -- a `*params* = ...` production with no `[optional]` bracket and no `*N(repeated)` group. A function whose grammar admits an optional or repeated argument has no fixed count and is therefore never resolved through PtgFunc: real producers confirm this split empirically (verified against genuine LibreOffice-written BIFF8: PI(), SIN(A1), ROUND(A1,0), ATAN2(A1,B1), SYD(1,2,3,4), and REPLACE("abc",1,1,"x") all compile as PtgFunc with no on-disk argument count, exactly matching a fixed-arity grammar entry with no bracket; COUNT(A1:B1), SUMIF(A1:A1,1), and CONCATENATE("a","b","c") all compile as PtgFuncVar carrying an explicit cparams byte, exactly matching a grammar entry with a `[...]` or `*N(...)` clause) -- PtgFunc carries no argument count of its own, so a function's real arity has to come from somewhere, and this column is that somewhere. PtgFuncVar's own `cparams` field is the argument count on disk regardless of what this column says, since a variable-arity call always states its own count. +// +// Most of this table's entries are Excel 4.0 macro-sheet commands (WINDOWS, POKE, ADD.MENU, and the like) that can appear only in a macro-sheet substream, which readXlsContent never walks (it maps worksheet/dialog substreams only, [MS-XLS] 2.4.28's dt 0x00) -- carried here anyway because completeness against the published table costs nothing and this reader has no principled way to know in advance which formula a real file will contain. + +/** One Ftab entry: the function's own displayed name, and its fixed argument count when the grammar states one (absent for a function [MS-XLS]'s own grammar allows a variable or optional argument list for, which always resolves through PtgFuncVar's own on-disk cparams instead). */ +type FtabEntry = readonly [name: string, fixedArity?: number]; + +const FTAB_ENTRIES: readonly (readonly [iftab: number, entry: FtabEntry])[] = [ + [0x0000, ["COUNT"]], + [0x0001, ["IF"]], + [0x0002, ["ISNA", 1]], + [0x0003, ["ISERROR", 1]], + [0x0004, ["SUM"]], + [0x0005, ["AVERAGE"]], + [0x0006, ["MIN"]], + [0x0007, ["MAX"]], + [0x0008, ["ROW"]], + [0x0009, ["COLUMN"]], + [0x000a, ["NA", 0]], + [0x000b, ["NPV"]], + [0x000c, ["STDEV"]], + [0x000d, ["DOLLAR"]], + [0x000e, ["FIXED"]], + [0x000f, ["SIN", 1]], + [0x0010, ["COS", 1]], + [0x0011, ["TAN", 1]], + [0x0012, ["ATAN", 1]], + [0x0013, ["PI", 0]], + [0x0014, ["SQRT", 1]], + [0x0015, ["EXP", 1]], + [0x0016, ["LN", 1]], + [0x0017, ["LOG10", 1]], + [0x0018, ["ABS", 1]], + [0x0019, ["INT", 1]], + [0x001a, ["SIGN", 1]], + [0x001b, ["ROUND", 2]], + [0x001c, ["LOOKUP"]], + [0x001d, ["INDEX"]], + [0x001e, ["REPT", 2]], + [0x001f, ["MID", 3]], + [0x0020, ["LEN", 1]], + [0x0021, ["VALUE", 1]], + [0x0022, ["TRUE", 0]], + [0x0023, ["FALSE", 0]], + [0x0024, ["AND"]], + [0x0025, ["OR"]], + [0x0026, ["NOT", 1]], + [0x0027, ["MOD", 2]], + [0x0028, ["DCOUNT", 3]], + [0x0029, ["DSUM", 3]], + [0x002a, ["DAVERAGE", 3]], + [0x002b, ["DMIN", 3]], + [0x002c, ["DMAX", 3]], + [0x002d, ["DSTDEV", 3]], + [0x002e, ["VAR"]], + [0x002f, ["DVAR", 3]], + [0x0030, ["TEXT", 2]], + [0x0031, ["LINEST"]], + [0x0032, ["TREND"]], + [0x0033, ["LOGEST"]], + [0x0034, ["GROWTH"]], + [0x0035, ["GOTO", 1]], + [0x0036, ["HALT"]], + [0x0037, ["RETURN"]], + [0x0038, ["PV"]], + [0x0039, ["FV"]], + [0x003a, ["NPER"]], + [0x003b, ["PMT"]], + [0x003c, ["RATE"]], + [0x003d, ["MIRR", 3]], + [0x003e, ["IRR"]], + [0x003f, ["RAND", 0]], + [0x0040, ["MATCH"]], + [0x0041, ["DATE", 3]], + [0x0042, ["TIME", 3]], + [0x0043, ["DAY", 1]], + [0x0044, ["MONTH", 1]], + [0x0045, ["YEAR", 1]], + [0x0046, ["WEEKDAY"]], + [0x0047, ["HOUR", 1]], + [0x0048, ["MINUTE", 1]], + [0x0049, ["SECOND", 1]], + [0x004a, ["NOW", 0]], + [0x004b, ["AREAS", 1]], + [0x004c, ["ROWS", 1]], + [0x004d, ["COLUMNS", 1]], + [0x004e, ["OFFSET"]], + [0x004f, ["ABSREF", 2]], + [0x0050, ["RELREF", 2]], + [0x0051, ["ARGUMENT"]], + [0x0052, ["SEARCH"]], + [0x0053, ["TRANSPOSE", 1]], + [0x0054, ["ERROR"]], + [0x0055, ["STEP", 0]], + [0x0056, ["TYPE", 1]], + [0x0057, ["ECHO"]], + [0x0058, ["SET.NAME"]], + [0x0059, ["CALLER", 0]], + [0x005a, ["DEREF", 1]], + [0x005b, ["WINDOWS"]], + [0x005c, ["SERIES"]], + [0x005d, ["DOCUMENTS"]], + [0x005e, ["ACTIVE.CELL", 0]], + [0x005f, ["SELECTION", 0]], + [0x0060, ["RESULT"]], + [0x0061, ["ATAN2", 2]], + [0x0062, ["ASIN", 1]], + [0x0063, ["ACOS", 1]], + [0x0064, ["CHOOSE"]], + [0x0065, ["HLOOKUP"]], + [0x0066, ["VLOOKUP"]], + [0x0067, ["LINKS"]], + [0x0068, ["INPUT"]], + [0x0069, ["ISREF", 1]], + [0x006a, ["GET.FORMULA", 1]], + [0x006b, ["GET.NAME"]], + [0x006c, ["SET.VALUE", 2]], + [0x006d, ["LOG"]], + [0x006e, ["EXEC"]], + [0x006f, ["CHAR", 1]], + [0x0070, ["LOWER", 1]], + [0x0071, ["UPPER", 1]], + [0x0072, ["PROPER", 1]], + [0x0073, ["LEFT"]], + [0x0074, ["RIGHT"]], + [0x0075, ["EXACT", 2]], + [0x0076, ["TRIM", 1]], + [0x0077, ["REPLACE", 4]], + [0x0078, ["SUBSTITUTE"]], + [0x0079, ["CODE", 1]], + [0x007a, ["NAMES"]], + [0x007b, ["DIRECTORY"]], + [0x007c, ["FIND"]], + [0x007d, ["CELL"]], + [0x007e, ["ISERR", 1]], + [0x007f, ["ISTEXT", 1]], + [0x0080, ["ISNUMBER", 1]], + [0x0081, ["ISBLANK", 1]], + [0x0082, ["T", 1]], + [0x0083, ["N", 1]], + [0x0084, ["FOPEN"]], + [0x0085, ["FCLOSE", 1]], + [0x0086, ["FSIZE", 1]], + [0x0087, ["FREADLN", 1]], + [0x0088, ["FREAD", 2]], + [0x0089, ["FWRITELN", 2]], + [0x008a, ["FWRITE", 2]], + [0x008b, ["FPOS"]], + [0x008c, ["DATEVALUE", 1]], + [0x008d, ["TIMEVALUE", 1]], + [0x008e, ["SLN", 3]], + [0x008f, ["SYD", 4]], + [0x0090, ["DDB"]], + [0x0091, ["GET.DEF"]], + [0x0092, ["REFTEXT"]], + [0x0093, ["TEXTREF"]], + [0x0094, ["INDIRECT"]], + [0x0095, ["REGISTER"]], + [0x0096, ["CALL"]], + [0x0097, ["ADD.BAR"]], + [0x0098, ["ADD.MENU"]], + [0x0099, ["ADD.COMMAND"]], + [0x009a, ["ENABLE.COMMAND"]], + [0x009b, ["CHECK.COMMAND"]], + [0x009c, ["RENAME.COMMAND"]], + [0x009d, ["SHOW.BAR"]], + [0x009e, ["DELETE.MENU"]], + [0x009f, ["DELETE.COMMAND"]], + [0x00a0, ["GET.CHART.ITEM"]], + [0x00a1, ["DIALOG.BOX", 1]], + [0x00a2, ["CLEAN", 1]], + [0x00a3, ["MDETERM", 1]], + [0x00a4, ["MINVERSE", 1]], + [0x00a5, ["MMULT", 2]], + [0x00a6, ["FILES"]], + [0x00a7, ["IPMT"]], + [0x00a8, ["PPMT"]], + [0x00a9, ["COUNTA"]], + [0x00aa, ["CANCEL.KEY"]], + [0x00ab, ["FOR"]], + [0x00ac, ["WHILE", 1]], + [0x00ad, ["BREAK", 0]], + [0x00ae, ["NEXT", 0]], + [0x00af, ["INITIATE", 2]], + [0x00b0, ["REQUEST", 2]], + [0x00b1, ["POKE", 3]], + [0x00b2, ["EXECUTE", 2]], + [0x00b3, ["TERMINATE", 1]], + [0x00b4, ["RESTART"]], + [0x00b5, ["HELP"]], + [0x00b6, ["GET.BAR"]], + [0x00b7, ["PRODUCT"]], + [0x00b8, ["FACT", 1]], + [0x00b9, ["GET.CELL"]], + [0x00ba, ["GET.WORKSPACE", 1]], + [0x00bb, ["GET.WINDOW"]], + [0x00bc, ["GET.DOCUMENT"]], + [0x00bd, ["DPRODUCT", 3]], + [0x00be, ["ISNONTEXT", 1]], + [0x00bf, ["GET.NOTE"]], + [0x00c0, ["NOTE"]], + [0x00c1, ["STDEVP"]], + [0x00c2, ["VARP"]], + [0x00c3, ["DSTDEVP", 3]], + [0x00c4, ["DVARP", 3]], + [0x00c5, ["TRUNC"]], + [0x00c6, ["ISLOGICAL", 1]], + [0x00c7, ["DCOUNTA", 3]], + [0x00c8, ["DELETE.BAR", 1]], + [0x00c9, ["UNREGISTER", 1]], + [0x00cc, ["USDOLLAR"]], + [0x00cd, ["FINDB"]], + [0x00ce, ["SEARCHB"]], + [0x00cf, ["REPLACEB", 4]], + [0x00d0, ["LEFTB"]], + [0x00d1, ["RIGHTB"]], + [0x00d2, ["MIDB", 3]], + [0x00d3, ["LENB", 1]], + [0x00d4, ["ROUNDUP", 2]], + [0x00d5, ["ROUNDDOWN", 2]], + [0x00d6, ["ASC", 1]], + [0x00d7, ["DBCS", 1]], + [0x00d8, ["RANK"]], + [0x00db, ["ADDRESS"]], + [0x00dc, ["DAYS360"]], + [0x00dd, ["TODAY", 0]], + [0x00de, ["VDB"]], + [0x00df, ["ELSE", 0]], + [0x00e0, ["ELSE.IF", 1]], + [0x00e1, ["END.IF", 0]], + [0x00e2, ["FOR.CELL"]], + [0x00e3, ["MEDIAN"]], + [0x00e4, ["SUMPRODUCT"]], + [0x00e5, ["SINH", 1]], + [0x00e6, ["COSH", 1]], + [0x00e7, ["TANH", 1]], + [0x00e8, ["ASINH", 1]], + [0x00e9, ["ACOSH", 1]], + [0x00ea, ["ATANH", 1]], + [0x00eb, ["DGET", 3]], + [0x00ec, ["CREATE.OBJECT"]], + [0x00ed, ["VOLATILE"]], + [0x00ee, ["LAST.ERROR", 0]], + [0x00ef, ["CUSTOM.UNDO"]], + [0x00f0, ["CUSTOM.REPEAT"]], + [0x00f1, ["FORMULA.CONVERT"]], + [0x00f2, ["GET.LINK.INFO"]], + [0x00f3, ["TEXT.BOX"]], + [0x00f4, ["INFO", 1]], + [0x00f5, ["GROUP", 0]], + [0x00f6, ["GET.OBJECT"]], + [0x00f7, ["DB"]], + [0x00f8, ["PAUSE"]], + [0x00fb, ["RESUME"]], + [0x00fc, ["FREQUENCY", 2]], + [0x00fd, ["ADD.TOOLBAR"]], + [0x00fe, ["DELETE.TOOLBAR", 1]], + // 0x00ff (User Defined Function) has no fixed name of its own -- the call names the UDF through its own operand, not through this table -- so it is intentionally not listed here. + [0x0100, ["RESET.TOOLBAR", 1]], + [0x0101, ["EVALUATE", 1]], + [0x0102, ["GET.TOOLBAR"]], + [0x0103, ["GET.TOOL"]], + [0x0104, ["SPELLING.CHECK"]], + [0x0105, ["ERROR.TYPE", 1]], + [0x0106, ["APP.TITLE"]], + [0x0107, ["WINDOW.TITLE"]], + [0x0108, ["SAVE.TOOLBAR"]], + [0x0109, ["ENABLE.TOOL", 3]], + [0x010a, ["PRESS.TOOL", 3]], + [0x010b, ["REGISTER.ID"]], + [0x010c, ["GET.WORKBOOK"]], + [0x010d, ["AVEDEV"]], + [0x010e, ["BETADIST"]], + [0x010f, ["GAMMALN", 1]], + [0x0110, ["BETAINV"]], + [0x0111, ["BINOMDIST", 4]], + [0x0112, ["CHIDIST", 2]], + [0x0113, ["CHIINV", 2]], + [0x0114, ["COMBIN", 2]], + [0x0115, ["CONFIDENCE", 3]], + [0x0116, ["CRITBINOM", 3]], + [0x0117, ["EVEN", 1]], + [0x0118, ["EXPONDIST", 3]], + [0x0119, ["FDIST", 3]], + [0x011a, ["FINV", 3]], + [0x011b, ["FISHER", 1]], + [0x011c, ["FISHERINV", 1]], + [0x011d, ["FLOOR", 2]], + [0x011e, ["GAMMADIST", 4]], + [0x011f, ["GAMMAINV", 3]], + [0x0120, ["CEILING", 2]], + [0x0121, ["HYPGEOMDIST", 4]], + [0x0122, ["LOGNORMDIST", 3]], + [0x0123, ["LOGINV", 3]], + [0x0124, ["NEGBINOMDIST", 3]], + [0x0125, ["NORMDIST", 4]], + [0x0126, ["NORMSDIST", 1]], + [0x0127, ["NORMINV", 3]], + [0x0128, ["NORMSINV", 1]], + [0x0129, ["STANDARDIZE", 3]], + [0x012a, ["ODD", 1]], + [0x012b, ["PERMUT", 2]], + [0x012c, ["POISSON", 3]], + [0x012d, ["TDIST", 3]], + [0x012e, ["WEIBULL", 4]], + [0x012f, ["SUMXMY2", 2]], + [0x0130, ["SUMX2MY2", 2]], + [0x0131, ["SUMX2PY2", 2]], + [0x0132, ["CHITEST", 2]], + [0x0133, ["CORREL", 2]], + [0x0134, ["COVAR", 2]], + [0x0135, ["FORECAST", 3]], + [0x0136, ["FTEST", 2]], + [0x0137, ["INTERCEPT", 2]], + [0x0138, ["PEARSON", 2]], + [0x0139, ["RSQ", 2]], + [0x013a, ["STEYX", 2]], + [0x013b, ["SLOPE", 2]], + [0x013c, ["TTEST", 4]], + [0x013d, ["PROB"]], + [0x013e, ["DEVSQ"]], + [0x013f, ["GEOMEAN"]], + [0x0140, ["HARMEAN"]], + [0x0141, ["SUMSQ"]], + [0x0142, ["KURT"]], + [0x0143, ["SKEW"]], + [0x0144, ["ZTEST"]], + [0x0145, ["LARGE", 2]], + [0x0146, ["SMALL", 2]], + [0x0147, ["QUARTILE", 2]], + [0x0148, ["PERCENTILE", 2]], + [0x0149, ["PERCENTRANK"]], + [0x014a, ["MODE"]], + [0x014b, ["TRIMMEAN", 2]], + [0x014c, ["TINV", 2]], + [0x014e, ["MOVIE.COMMAND"]], + [0x014f, ["GET.MOVIE"]], + [0x0150, ["CONCATENATE"]], + [0x0151, ["POWER", 2]], + [0x0152, ["PIVOT.ADD.DATA"]], + [0x0153, ["GET.PIVOT.TABLE"]], + [0x0154, ["GET.PIVOT.FIELD"]], + [0x0155, ["GET.PIVOT.ITEM"]], + [0x0156, ["RADIANS", 1]], + [0x0157, ["DEGREES", 1]], + [0x0158, ["SUBTOTAL"]], + [0x0159, ["SUMIF"]], + [0x015a, ["COUNTIF", 2]], + [0x015b, ["COUNTBLANK", 1]], + [0x015c, ["SCENARIO.GET"]], + [0x015d, ["OPTIONS.LISTS.GET", 1]], + [0x015e, ["ISPMT", 4]], + [0x015f, ["DATEDIF", 3]], + [0x0160, ["DATESTRING", 1]], + [0x0161, ["NUMBERSTRING", 2]], + [0x0162, ["ROMAN"]], + [0x0163, ["OPEN.DIALOG"]], + [0x0164, ["SAVE.DIALOG"]], + [0x0165, ["VIEW.GET"]], + [0x0166, ["GETPIVOTDATA"]], + [0x0167, ["HYPERLINK"]], + [0x0168, ["PHONETIC", 1]], + [0x0169, ["AVERAGEA"]], + [0x016a, ["MAXA"]], + [0x016b, ["MINA"]], + [0x016c, ["STDEVPA"]], + [0x016d, ["VARPA"]], + [0x016e, ["STDEVA"]], + [0x016f, ["VARA"]], + [0x0170, ["BAHTTEXT", 1]], + [0x0171, ["THAIDAYOFWEEK", 1]], + [0x0172, ["THAIDIGIT", 1]], + [0x0173, ["THAIMONTHOFYEAR", 1]], + [0x0174, ["THAINUMSOUND", 1]], + [0x0175, ["THAINUMSTRING", 1]], + [0x0176, ["THAISTRINGLENGTH", 1]], + [0x0177, ["ISTHAIDIGIT", 1]], + [0x0178, ["ROUNDBAHTDOWN", 1]], + [0x0179, ["ROUNDBAHTUP", 1]], + [0x017a, ["THAIYEAR", 1]], + [0x017b, ["RTD"]], +]; + +/** A function's displayed name, by its Ftab index -- consulted for both PtgFunc and PtgFuncVar. */ +export const FTAB_NAMES: ReadonlyMap = new Map( + FTAB_ENTRIES.map(([iftab, [name]]) => [iftab, name]), +); + +/** 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). */ +export const FTAB_FIXED_ARITY: ReadonlyMap = new Map( + FTAB_ENTRIES.filter( + (entry): entry is [number, [string, number]] => entry[1][1] !== undefined, + ).map(([iftab, [, arity]]) => [iftab, arity]), +); diff --git a/packages/xls-codec/src/biff/ptg.test.ts b/packages/xls-codec/src/biff/ptg.test.ts new file mode 100644 index 000000000..f640555be --- /dev/null +++ b/packages/xls-codec/src/biff/ptg.test.ts @@ -0,0 +1,264 @@ +import { describe, expect, it } from "vitest"; + +import { u16, u32 } from "../test-support/biff"; +import { type FormulaSheetContext, parseFormulaText } from "./ptg"; + +// Every byte sequence here is built by hand from [MS-XLS] 2.5.198's own per-Ptg field layouts, matching this package's established convention (see test-support/biff.ts) -- and cross-checked against a real BIFF8 workbook that LibreOffice wrote for the identical formulas during development of this module (SUM/AVERAGE/IF/ROUND/ATAN2/SYD/REPLACE/SUMIF/CONCATENATE, a cross-sheet SUM, and a parenthesised `(A1+B1)*C1`), which produced byte-for-byte the same token streams these tests assert against. + +function bytes(...values: readonly number[]): Uint8Array { + return new Uint8Array(values); +} + +/** A plain, fully-relative RgceLoc column field ([MS-XLS] 2.5.51 ColRelU): both colRelative and rowRelative set, which is what a bare `A1` reference (as opposed to `$A$1`) carries. */ +function relativeColumn(column: number): number[] { + return u16(0xc000 | column); +} + +const NO_SHEETS: FormulaSheetContext = { sheets: [], sheetRanges: [] }; + +/** PtgRef (value class, [MS-XLS] 2.5.198.84): opcode 0x44, then a row and a ColRelU column field, all relative. */ +function ptgRef(row: number, column: number): number[] { + return [0x44, ...u16(row), ...relativeColumn(column)]; +} + +/** PtgArea (ref class, [MS-XLS] 2.5.198.27): opcode 0x25, rwFirst, rwLast, then each corner's own relative ColRelU column field. */ +function ptgArea( + rowFirst: number, + rowLast: number, + columnFirst: number, + columnLast: number, +): number[] { + return [ + 0x25, + ...u16(rowFirst), + ...u16(rowLast), + ...relativeColumn(columnFirst), + ...relativeColumn(columnLast), + ]; +} + +/** PtgInt ([MS-XLS] 2.5.198.66): opcode 0x1E then an unsigned 16-bit value. */ +function ptgInt(value: number): number[] { + return [0x1e, ...u16(value)]; +} + +describe("parseFormulaText", () => { + it("formats a binary arithmetic expression from two references", () => { + // A1+B1 + const rgce = bytes(...ptgRef(0, 0), ...ptgRef(0, 1), 0x03); + expect(parseFormulaText(rgce, NO_SHEETS)).toBe("A1+B1"); + }); + + it("formats each comparison operator", () => { + const operator = (opcode: number, expected: string) => { + const rgce = bytes(...ptgRef(0, 0), ...ptgRef(0, 1), opcode); + expect(parseFormulaText(rgce, NO_SHEETS)).toBe(`A1${expected}B1`); + }; + operator(0x09, "<"); + operator(0x0a, "<="); + operator(0x0b, "="); + operator(0x0c, ">="); + operator(0x0d, ">"); + operator(0x0e, "<>"); + }); + + it("wraps a lower-precedence child on the left of a higher-precedence operator", () => { + // (A1+B1)*C1 -- PtgParen explicitly restates the source's own parentheses, matching a real producer's output byte-for-byte. + const rgce = bytes( + ...ptgRef(0, 0), + ...ptgRef(0, 1), + 0x03, // PtgAdd + 0x15, // PtgParen + ...ptgRef(0, 2), + 0x05, // PtgMul + ); + expect(parseFormulaText(rgce, NO_SHEETS)).toBe("(A1+B1)*C1"); + }); + + it("wraps a same-precedence right child that division is not associative over", () => { + // A1/(B1/C1) -- the postfix nesting itself (right child built before being combined) is what requires the parenthesis, independent of any PtgParen token. + const rgce = bytes( + ...ptgRef(0, 0), + ...ptgRef(0, 1), + ...ptgRef(0, 2), + 0x06, // PtgDiv (B1/C1) + 0x06, // PtgDiv (A1/(B1/C1)) + ); + expect(parseFormulaText(rgce, NO_SHEETS)).toBe("A1/(B1/C1)"); + }); + + it("does not wrap a left-nested chain of the same operator", () => { + // A1-B1-C1, postfix ((A1-B1)-C1) -- the ordinary left-associative reading, no parens needed. + const rgce = bytes( + ...ptgRef(0, 0), + ...ptgRef(0, 1), + 0x04, // PtgSub + ...ptgRef(0, 2), + 0x04, // PtgSub + ); + expect(parseFormulaText(rgce, NO_SHEETS)).toBe("A1-B1-C1"); + }); + + it("does not add a paren a lower-precedence multiply/add mix does not need", () => { + // A1+B1*C1 -- multiply binds tighter, so the addition's right child needs no wrapping. + const rgce = bytes( + ...ptgRef(0, 0), + ...ptgRef(0, 1), + ...ptgRef(0, 2), + 0x05, // PtgMul (B1*C1) + 0x03, // PtgAdd + ); + expect(parseFormulaText(rgce, NO_SHEETS)).toBe("A1+B1*C1"); + }); + + it("formats unary minus, unary plus, and percent", () => { + expect(parseFormulaText(bytes(...ptgRef(0, 0), 0x13), NO_SHEETS)).toBe( + "-A1", + ); + expect(parseFormulaText(bytes(...ptgRef(0, 0), 0x12), NO_SHEETS)).toBe( + "+A1", + ); + // A1*10% -- PtgPercent binds to the literal immediately before it, then PtgMul combines. + const rgce = bytes(...ptgRef(0, 0), ...ptgInt(10), 0x14, 0x05); + expect(parseFormulaText(rgce, NO_SHEETS)).toBe("A1*10%"); + }); + + it("formats a range reference and the PtgAttrSum optimisation for a top-level SUM", () => { + // SUM(A1:A10) -- a real producer's own optimisation for a single-range SUM call, [MS-XLS] 2.5.198.41. + const rgce = bytes( + ...ptgArea(0, 9, 0, 0), + 0x19, + 0x10, + ...u16(0), // PtgAttrSum + ); + expect(parseFormulaText(rgce, NO_SHEETS)).toBe("SUM(A1:A10)"); + }); + + it("formats absolute and mixed cell references with dollar signs", () => { + const columnField = (colRelative: boolean, rowRelative: boolean) => + u16( + (colRelative ? 0x4000 : 0) | (rowRelative ? 0x8000 : 0) | 0, // column A (index 0) + ); + const refWith = (colRelative: boolean, rowRelative: boolean) => + bytes(0x44, ...u16(0), ...columnField(colRelative, rowRelative)); + + expect(parseFormulaText(refWith(false, false), NO_SHEETS)).toBe("$A$1"); + expect(parseFormulaText(refWith(true, false), NO_SHEETS)).toBe("A$1"); + expect(parseFormulaText(refWith(false, true), NO_SHEETS)).toBe("$A1"); + expect(parseFormulaText(refWith(true, true), NO_SHEETS)).toBe("A1"); + }); + + it("formats a string literal, doubling an embedded quote", () => { + // PtgStr ([MS-XLS] 2.5.198.89): opcode 0x17, a ShortXLUnicodeString with cch=3, compressed flags, then the three characters `a"b`. + const text = 'a"b'; + const characters: number[] = []; + for (let index = 0; index < text.length; index += 1) { + characters.push(text.charCodeAt(index)); + } + const rgce = bytes(0x17, text.length, 0x00, ...characters); + expect(parseFormulaText(rgce, NO_SHEETS)).toBe('"a""b"'); + }); + + it("formats a boolean literal and an error literal", () => { + expect(parseFormulaText(bytes(0x1d, 0x01), NO_SHEETS)).toBe("TRUE"); + expect(parseFormulaText(bytes(0x1d, 0x00), NO_SHEETS)).toBe("FALSE"); + // PtgErr ([MS-XLS] 2.5.198.57): opcode 0x1C then a BErr byte -- 0x07 is #DIV/0!. + expect(parseFormulaText(bytes(0x1c, 0x07), NO_SHEETS)).toBe("#DIV/0!"); + }); + + it("formats a numeric literal", () => { + // PtgNum ([MS-XLS] 2.5.198.79): opcode 0x1F then an Xnum. + const buffer = new ArrayBuffer(8); + new DataView(buffer).setFloat64(0, 3.5, true); + const rgce = bytes(0x1f, ...new Uint8Array(buffer)); + expect(parseFormulaText(rgce, NO_SHEETS)).toBe("3.5"); + }); + + it("formats a fixed-arity PtgFunc call", () => { + // SIN(A1) -- iftab 0x000F, verified against real LibreOffice-written BIFF8 during development. + const rgce = bytes(...ptgRef(0, 0), 0x41, ...u16(0x000f)); + expect(parseFormulaText(rgce, NO_SHEETS)).toBe("SIN(A1)"); + }); + + it("formats a zero-argument PtgFunc call with empty parentheses", () => { + // PI() -- iftab 0x0013. + const rgce = bytes(0x41, ...u16(0x0013)); + expect(parseFormulaText(rgce, NO_SHEETS)).toBe("PI()"); + }); + + it("formats a variable-arity PtgFuncVar call, using its own on-disk cparams", () => { + // COUNT(A1:B1) -- cparams=1, iftab 0x0000. + const rgce = bytes(...ptgArea(0, 0, 0, 1), 0x42, 0x01, ...u16(0x0000)); + expect(parseFormulaText(rgce, NO_SHEETS)).toBe("COUNT(A1:B1)"); + }); + + it("formats IF's PtgAttrIf/PtgAttrGoto control tokens as pure no-ops", () => { + // IF(A1>0,1,0), byte-for-byte the real stream a LibreOffice-written workbook carries for it. + const rgce = bytes( + ...ptgRef(0, 0), + ...ptgInt(0), + 0x0d, // PtgGt + 0x19, + 0x02, + ...u16(7), // PtgAttrIf, offset 7 + ...ptgInt(1), + 0x19, + 0x08, + ...u16(10), // PtgAttrGoto, offset 10 + ...ptgInt(0), + 0x19, + 0x08, + ...u16(3), // PtgAttrGoto, offset 3 + 0x42, + 0x03, + ...u16(0x0001), // PtgFuncVar, IF, cparams=3 + ); + expect(parseFormulaText(rgce, NO_SHEETS)).toBe("IF(A1>0,1,0)"); + }); + + it("resolves a single-sheet 3D reference, quoting a sheet name that needs it", () => { + const context: FormulaSheetContext = { + sheets: [{ name: "Sheet1" }, { name: "Data Sheet" }], + sheetRanges: [{ firstSheetIndex: 1, lastSheetIndex: 1 }], + }; + // PtgArea3d (ref class, [MS-XLS] 2.5.198.28): opcode 0x3B, ixti, then an RgceArea. + const rgce = bytes(0x3b, ...u16(0), ...ptgArea(0, 1, 0, 1).slice(1)); + expect(parseFormulaText(rgce, context)).toBe("'Data Sheet'!A1:B2"); + }); + + it("resolves a multi-sheet 3D range, always quoted for the embedded colon", () => { + const context: FormulaSheetContext = { + sheets: [{ name: "Jan" }, { name: "Feb" }, { name: "Mar" }], + sheetRanges: [{ firstSheetIndex: 0, lastSheetIndex: 2 }], + }; + // PtgRef3d (value class): opcode 0x5A, ixti, then an RgceLoc. + const rgce = bytes(0x5a, ...u16(0), ...ptgRef(0, 0).slice(1)); + expect(parseFormulaText(rgce, context)).toBe("'Jan:Mar'!A1"); + }); + + it("aborts the whole parse when a 3D reference's ixti does not resolve", () => { + const context: FormulaSheetContext = { sheets: [], sheetRanges: [] }; + const rgce = bytes(0x5a, ...u16(0), ...ptgRef(0, 0).slice(1)); + expect(parseFormulaText(rgce, context)).toBeUndefined(); + }); + + it("aborts on a token outside this reader's vocabulary, such as a shared formula's PtgExp", () => { + // PtgExp ([MS-XLS] 2.5.198.58): opcode 0x01. A real Formula record whose rgce is just this single token is exactly what a shared-formula member's own cell carries. + const rgce = bytes(0x01, ...u32(0)); + expect(parseFormulaText(rgce, NO_SHEETS)).toBeUndefined(); + }); + + it("aborts on a binary operator with too few operands rather than guessing", () => { + const rgce = bytes(...ptgRef(0, 0), 0x03); // PtgAdd with only one operand pushed + expect(parseFormulaText(rgce, NO_SHEETS)).toBeUndefined(); + }); + + it("aborts on an empty token stream", () => { + expect(parseFormulaText(bytes(), NO_SHEETS)).toBeUndefined(); + }); + + it("aborts when the token stream leaves more than one value on the stack", () => { + const rgce = bytes(...ptgRef(0, 0), ...ptgRef(0, 1)); // two operands, no combining operator + expect(parseFormulaText(rgce, NO_SHEETS)).toBeUndefined(); + }); +}); diff --git a/packages/xls-codec/src/biff/ptg.ts b/packages/xls-codec/src/biff/ptg.ts new file mode 100644 index 000000000..34dd29b69 --- /dev/null +++ b/packages/xls-codec/src/biff/ptg.ts @@ -0,0 +1,430 @@ +import { columnIndexToLetters } from "document-schema.js"; + +import { BlockCursor } from "./cursor"; +import { errorTextOf } from "./errors"; +import { FTAB_FIXED_ARITY, FTAB_NAMES } from "./ptg-functions"; +import { readShortXLUnicodeString } from "./strings"; + +// A BIFF8 compiled formula (Ptg token stream, [MS-XLS] 2.5.198.25 -- https://learn.microsoft.com/en-us/openspecs/office_file_formats/ms-xls/94229a89-a5b6-4f2b-834f-bd28cdc57c6b) walked left to right and rebuilt into the infix text a spreadsheet application would show. +// +// The tokens are postfix (reverse Polish): an operand token pushes a value, an operator or function token pops however many operands it needs and pushes the combined result. This module carries that same shape one level up -- an OPERAND STACK of already-formatted text, each entry tagged with the precedence of whatever built it -- so a binary operator or function call is always "pop N, join, push" and the only real complexity is deciding when a child needs literal parentheses around it before it can sit inside its parent's text. That decision is precedence comparison, not a special case: wrap the left child when its own precedence is lower than the operator being applied, wrap the right child when its precedence is lower than OR EQUAL to it. The equal case on the right is what reproduces `a-(b-c)` correctly (and, for a commutative operator, only ever fires when the postfix stream itself demanded that grouping -- which happens only when the formula's own author wrote explicit parentheses, since a bare `a+b+c` always compiles left-nested) -- so this one rule is correct for every operator here regardless of its true associativity, and this module never needs to know what that associativity actually is. +// +// Everything this file does NOT recognise -- a shared formula's PtgExp, a data table's PtgTbl, an array constant's PtgArray, a defined name's PtgName/PtgNameX, a natural-language "Elf" reference, a genuinely external workbook's 3D reference -- aborts the whole parse rather than guessing: parseFormulaText returns undefined, and the caller leaves ContentSheetCell.formula absent for that cell exactly as it already does for a cell this reader cannot map at all. A BiffFormatError from a cursor read past the end of rgce is not caught here: cce already bounds a well-formed token stream exactly, so a cursor genuinely running past it means this module misjudged a token's own byte width, which is a bug worth failing loudly on rather than silently discarding. + +/** A 3D reference's sheet scope, resolved from its ixti through EXTERNSHEET and a self-referencing SupBook ([MS-XLS] 2.4.271, 2.4.106, 2.5.344): the first and last sheet of the reference, both direct BoundSheet8 indices into FormulaSheetContext.sheets. A single-sheet 3D reference has `firstSheetIndex === lastSheetIndex`. Defined here rather than alongside the globals reader that produces it, since resolving it into reference text is what this module exists to do. */ +export interface SheetRange { + readonly firstSheetIndex: number; + readonly lastSheetIndex: number; +} + +/** What a 3D reference's own ixti resolves against: the workbook's sheets in BoundSheet8 order (only the name is needed here), and each ixti's own resolved sheet range (undefined where this reader does not resolve it -- see WorkbookGlobals.sheetRanges, which this type's own sheetRanges field matches field-for-field). */ +export interface FormulaSheetContext { + readonly sheets: readonly { readonly name: string }[]; + readonly sheetRanges: readonly (SheetRange | undefined)[]; +} + +/** One already-formatted operand on the stack, carrying the precedence of the operator that produced it (or PRECEDENCE_ATOMIC for a literal, a reference, or a function call) so its parent can decide whether it needs wrapping in literal parentheses. */ +interface FormulaOperand { + readonly text: string; + readonly precedence: number; +} + +// Excel's own documented operator precedence (https://support.microsoft.com/en-us/office/calculation-operators-and-precedence-in-excel), narrowed to the operators this module builds text for -- reference operators (: (space) ,) are not in this vocabulary (see the module comment), so PRECEDENCE_ATOMIC is the ceiling every leaf and function call carries. +const PRECEDENCE_COMPARISON = 1; +const PRECEDENCE_CONCAT = 2; +const PRECEDENCE_ADD_SUB = 3; +const PRECEDENCE_MUL_DIV = 4; +const PRECEDENCE_POWER = 5; +const PRECEDENCE_PERCENT = 6; +const PRECEDENCE_UNARY = 7; +const PRECEDENCE_ATOMIC = 8; + +function pushAtomic(stack: FormulaOperand[], text: string): void { + stack.push({ text, precedence: PRECEDENCE_ATOMIC }); +} + +function wrapBelow(operand: FormulaOperand, minimum: number): string { + return operand.precedence < minimum ? `(${operand.text})` : operand.text; +} + +function wrapAtOrBelow(operand: FormulaOperand, minimum: number): string { + return operand.precedence <= minimum ? `(${operand.text})` : operand.text; +} + +/** Pops two operands and pushes their combination -- the left wrapped only if its own precedence is strictly lower than this operator's, the right wrapped if its precedence is lower than or equal to it (see the module comment for why the same rule is correct for every operator here). Returns false, changing nothing, when fewer than two operands are on the stack -- a malformed token stream this reader declines to guess at rather than reading past. */ +function applyBinary( + stack: FormulaOperand[], + symbol: string, + precedence: number, +): boolean { + const right = stack.pop(); + const left = stack.pop(); + if (left === undefined || right === undefined) { + return false; + } + stack.push({ + text: `${wrapBelow(left, precedence)}${symbol}${wrapAtOrBelow(right, precedence)}`, + precedence, + }); + return true; +} + +function applyPrefix(stack: FormulaOperand[], symbol: string): boolean { + const operand = stack.pop(); + if (operand === undefined) { + return false; + } + stack.push({ + text: `${symbol}${wrapBelow(operand, PRECEDENCE_UNARY)}`, + precedence: PRECEDENCE_UNARY, + }); + return true; +} + +function applyPercent(stack: FormulaOperand[]): boolean { + const operand = stack.pop(); + if (operand === undefined) { + return false; + } + stack.push({ + text: `${wrapBelow(operand, PRECEDENCE_PERCENT)}%`, + precedence: PRECEDENCE_PERCENT, + }); + return true; +} + +/** PtgParen ([MS-XLS] 2.5.198.80): a pure display token restating parentheses the formula's own author typed, regardless of whether the grouping they express is otherwise necessary. Wrapped unconditionally rather than through precedence comparison, since the point is to reproduce exactly what was there, not to decide afresh whether it was needed. */ +function applyParen(stack: FormulaOperand[]): boolean { + const operand = stack.pop(); + if (operand === undefined) { + return false; + } + stack.push({ text: `(${operand.text})`, precedence: PRECEDENCE_ATOMIC }); + return true; +} + +/** Pops exactly `arity` operands (in argument order) and pushes `name(arg1,arg2,...)`. A function call's own arguments never need wrapping regardless of what built them -- the parentheses already bound them unambiguously -- so every argument is taken as its bare text. */ +function applyFunctionCall( + stack: FormulaOperand[], + name: string, + arity: number, +): boolean { + if (stack.length < arity) { + return false; + } + const args = stack.splice(stack.length - arity, arity); + stack.push({ + text: `${name}(${args.map((operand) => operand.text).join(",")})`, + precedence: PRECEDENCE_ATOMIC, + }); + return true; +} + +/** PtgAttrSum ([MS-XLS] 2.5.198.41): the optimisation a real producer emits in place of a PtgFuncVar call to SUM with a single reference-class argument, wrapping whatever is already on top of the stack. */ +function applySum(stack: FormulaOperand[]): boolean { + return applyFunctionCall(stack, "SUM", 1); +} + +function quoteStringLiteral(value: string): string { + return `"${value.replaceAll('"', '""')}"`; +} + +// ColRelU ([MS-XLS] 2.5.51): a 16-bit column field packing the zero-based column index into its low 14 bits, with bit 14 (0x4000) saying the column is a RELATIVE reference and bit 15 (0x8000) saying the row coordinate carried alongside it is too. Absolute is the "not relative" case in both, which is why the formatter below negates each bit. +const COLUMN_INDEX_MASK = 0x3fff; +const COLUMN_RELATIVE_BIT = 0x4000; +const ROW_RELATIVE_BIT = 0x8000; + +/** One corner of a cell reference: its 0-based row/column, and whether each is an absolute ($) coordinate. */ +interface CellPoint { + readonly row: number; + readonly column: number; + readonly columnAbsolute: boolean; + readonly rowAbsolute: boolean; +} + +function pointFrom(row: number, columnField: number): CellPoint { + return { + row, + column: columnField & COLUMN_INDEX_MASK, + columnAbsolute: (columnField & COLUMN_RELATIVE_BIT) === 0, + rowAbsolute: (columnField & ROW_RELATIVE_BIT) === 0, + }; +} + +function formatPoint(point: CellPoint): string { + const column = `${point.columnAbsolute ? "$" : ""}${columnIndexToLetters(point.column)}`; + const row = `${point.rowAbsolute ? "$" : ""}${point.row + 1}`; + return `${column}${row}`; +} + +/** RgceLoc ([MS-XLS] 2.5.198.109): a single cell reference, as PtgRef/PtgRef3d carry it -- a plain row then a ColRelU column field. */ +function readLoc(cursor: BlockCursor): CellPoint { + const row = cursor.u16(); + const columnField = cursor.u16(); + return pointFrom(row, columnField); +} + +/** RgceArea ([MS-XLS] 2.5.198.105): a rectangular range, as PtgArea/PtgArea3d carry it -- both row bounds, then both corners' own ColRelU column fields. */ +function readArea(cursor: BlockCursor): readonly [CellPoint, CellPoint] { + const rowFirst = cursor.u16(); + const rowLast = cursor.u16(); + const columnFirstField = cursor.u16(); + const columnLastField = cursor.u16(); + return [ + pointFrom(rowFirst, columnFirstField), + pointFrom(rowLast, columnLastField), + ]; +} + +// A sheet name needs single-quote wrapping (with any embedded quote doubled) whenever it is not a bare identifier -- this covers the common real-world cases (a space, a leading digit, punctuation) without attempting Excel's full, more permissive grammar; a name this pattern quotes unnecessarily is still valid Excel syntax, so the only real risk is the pattern being too PERMISSIVE, and every character it allows unquoted (letters, digits, underscore, period) is one Excel itself never requires quoting for. +const SIMPLE_SHEET_NAME_RE = /^[A-Za-z_][A-Za-z0-9_.]*$/; + +function quoteSheetLabel(label: string): string { + return SIMPLE_SHEET_NAME_RE.test(label) + ? label + : `'${label.replaceAll("'", "''")}'`; +} + +/** The `'Sheet'!` or `'First:Last'!` prefix a 3D reference's own ixti resolves to, or undefined when this reader does not resolve it (see WorkbookGlobals.sheetRanges) -- an unresolved ixti aborts the whole formula's parse, the same as any other unsupported token. */ +function resolveSheetLabel( + ixti: number, + context: FormulaSheetContext, +): string | undefined { + const range = context.sheetRanges[ixti]; + if (range === undefined) { + return undefined; + } + const first = context.sheets[range.firstSheetIndex]?.name; + const last = context.sheets[range.lastSheetIndex]?.name; + if (first === undefined || last === undefined) { + return undefined; + } + const label = + range.firstSheetIndex === range.lastSheetIndex ? first : `${first}:${last}`; + return `${quoteSheetLabel(label)}!`; +} + +// The Ptg opcode enumeration ([MS-XLS] 2.5.198.25's own first-byte/second-byte table -- https://learn.microsoft.com/en-us/openspecs/office_file_formats/ms-xls/94229a89-a5b6-4f2b-834f-bd28cdc57c6b), restricted to the tokens this module actually acts on. A reference/value/array-class family (PtgRef, PtgArea, PtgFunc, PtgFuncVar, PtgRef3d, PtgArea3d) shares one on-disk field layout across its three opcodes -- only the ARITY of a leaf-vs-operand distinction the parse tree carries differs, which this module, building text rather than validating a parse tree, has no need to tell apart -- so all three are dispatched to the identical handler below. + +const PTG_ADD = 0x03; +const PTG_SUB = 0x04; +const PTG_MUL = 0x05; +const PTG_DIV = 0x06; +const PTG_POWER = 0x07; +const PTG_CONCAT = 0x08; +const PTG_LT = 0x09; +const PTG_LE = 0x0a; +const PTG_EQ = 0x0b; +const PTG_GE = 0x0c; +const PTG_GT = 0x0d; +const PTG_NE = 0x0e; +const PTG_UPLUS = 0x12; +const PTG_UMINUS = 0x13; +const PTG_PERCENT = 0x14; +const PTG_PAREN = 0x15; +const PTG_MISSARG = 0x16; +const PTG_STR = 0x17; +const PTG_ERR = 0x1c; +const PTG_BOOL = 0x1d; +const PTG_INT = 0x1e; +const PTG_NUM = 0x1f; +const PTG_FUNC_REF = 0x21; +const PTG_FUNC_VALUE = 0x41; +const PTG_FUNC_ARRAY = 0x61; +const PTG_FUNCVAR_REF = 0x22; +const PTG_FUNCVAR_VALUE = 0x42; +const PTG_FUNCVAR_ARRAY = 0x62; +const PTG_REF_REF = 0x24; +const PTG_REF_VALUE = 0x44; +const PTG_REF_ARRAY = 0x64; +const PTG_AREA_REF = 0x25; +const PTG_AREA_VALUE = 0x45; +const PTG_AREA_ARRAY = 0x65; +const PTG_REF3D_REF = 0x3a; +const PTG_REF3D_VALUE = 0x5a; +const PTG_REF3D_ARRAY = 0x7a; +const PTG_AREA3D_REF = 0x3b; +const PTG_AREA3D_VALUE = 0x5b; +const PTG_AREA3D_ARRAY = 0x7b; + +// PtgAttr's own family ([MS-XLS] 2.5.198.25's 0x19 second-byte group): every one of these is a fixed four bytes (the shared 0x19 opcode, a one-byte subtype flag, then two more bytes -- an offset for Semi/If/Goto, unused for Sum/Baxcel/Space/SpaceSemi) EXCEPT PtgAttrChoose, whose trailing rgOffset array is variable-length and therefore unsupported here (see PTG_ATTR_CHOOSE below). None of the fixed four carries any text-relevant information for this module's purposes: PtgAttrIf/PtgAttrGoto/PtgAttrSemi/PtgAttrSpace/PtgAttrSpaceSemi/PtgAttrBaxcel are calculation-engine control/display framing this module discards as pure no-ops (their own "offset" fields describe evaluator jump distances, irrelevant to reconstructing text), and PtgAttrSum alone has a text effect, wrapping whatever operand already sits on top of the stack. +const PTG_ATTR_OPCODE = 0x19; +const PTG_ATTR_SEMI = 0x01; +const PTG_ATTR_IF = 0x02; +const PTG_ATTR_CHOOSE = 0x04; +const PTG_ATTR_GOTO = 0x08; +const PTG_ATTR_SUM = 0x10; +const PTG_ATTR_BAXCEL_A = 0x20; +const PTG_ATTR_BAXCEL_B = 0x21; +const PTG_ATTR_SPACE = 0x40; +const PTG_ATTR_SPACE_SEMI = 0x41; +/** The four bytes every PtgAttr subtype but PtgAttrChoose occupies: the shared opcode, the subtype flag byte (already consumed by the caller), and two more this module never inspects. */ +const PTG_ATTR_TRAILING_BYTES = 2; + +/** + * Parses a Formula record's compiled expression into the text a spreadsheet application would show, or returns undefined for a token this reader does not resolve -- a shared formula's PtgExp, an array constant's PtgArray, a defined name, a natural-language reference, or a 3D reference into a genuinely external workbook (see the module comment for the full list). The caller leaves ContentSheetCell.formula absent in that case, exactly as for any other unsupported construct. + * + * `rgce` is the formula's own token bytes, already sliced to their declared length (CellParsedFormula.cce) by the caller -- this function reads exactly that many bytes and nothing past them. + */ +export function parseFormulaText( + rgce: Uint8Array, + context: FormulaSheetContext, +): string | undefined { + const cursor = new BlockCursor([rgce]); + const stack: FormulaOperand[] = []; + + while (cursor.remainingInBlock() > 0) { + const opcode = cursor.u8(); + switch (opcode) { + case PTG_ADD: + if (!applyBinary(stack, "+", PRECEDENCE_ADD_SUB)) return undefined; + break; + case PTG_SUB: + if (!applyBinary(stack, "-", PRECEDENCE_ADD_SUB)) return undefined; + break; + case PTG_MUL: + if (!applyBinary(stack, "*", PRECEDENCE_MUL_DIV)) return undefined; + break; + case PTG_DIV: + if (!applyBinary(stack, "/", PRECEDENCE_MUL_DIV)) return undefined; + break; + case PTG_POWER: + if (!applyBinary(stack, "^", PRECEDENCE_POWER)) return undefined; + break; + case PTG_CONCAT: + if (!applyBinary(stack, "&", PRECEDENCE_CONCAT)) return undefined; + break; + case PTG_LT: + if (!applyBinary(stack, "<", PRECEDENCE_COMPARISON)) return undefined; + break; + case PTG_LE: + if (!applyBinary(stack, "<=", PRECEDENCE_COMPARISON)) return undefined; + break; + case PTG_EQ: + if (!applyBinary(stack, "=", PRECEDENCE_COMPARISON)) return undefined; + break; + case PTG_GE: + if (!applyBinary(stack, ">=", PRECEDENCE_COMPARISON)) return undefined; + break; + case PTG_GT: + if (!applyBinary(stack, ">", PRECEDENCE_COMPARISON)) return undefined; + break; + case PTG_NE: + if (!applyBinary(stack, "<>", PRECEDENCE_COMPARISON)) return undefined; + break; + case PTG_UPLUS: + if (!applyPrefix(stack, "+")) return undefined; + break; + case PTG_UMINUS: + if (!applyPrefix(stack, "-")) return undefined; + break; + case PTG_PERCENT: + if (!applyPercent(stack)) return undefined; + break; + case PTG_PAREN: + if (!applyParen(stack)) return undefined; + break; + case PTG_MISSARG: + // An omitted optional argument (e.g. the third argument of IF(A1>0,1)) -- present in the token stream as a real, empty operand so the enclosing PtgFuncVar's own cparams still counts it. + pushAtomic(stack, ""); + break; + case PTG_STR: + pushAtomic(stack, quoteStringLiteral(readShortXLUnicodeString(cursor))); + break; + case PTG_ERR: { + const text = errorTextOf(cursor.u8()); + if (text === undefined) return undefined; + pushAtomic(stack, text); + break; + } + case PTG_BOOL: + pushAtomic(stack, cursor.u8() !== 0 ? "TRUE" : "FALSE"); + break; + case PTG_INT: + pushAtomic(stack, String(cursor.u16())); + break; + case PTG_NUM: + pushAtomic(stack, String(cursor.f64())); + break; + case PTG_REF_REF: + case PTG_REF_VALUE: + case PTG_REF_ARRAY: + pushAtomic(stack, formatPoint(readLoc(cursor))); + break; + case PTG_AREA_REF: + case PTG_AREA_VALUE: + case PTG_AREA_ARRAY: { + const [start, end] = readArea(cursor); + pushAtomic(stack, `${formatPoint(start)}:${formatPoint(end)}`); + break; + } + case PTG_REF3D_REF: + case PTG_REF3D_VALUE: + case PTG_REF3D_ARRAY: { + const ixti = cursor.u16(); + const point = readLoc(cursor); + const label = resolveSheetLabel(ixti, context); + if (label === undefined) return undefined; + pushAtomic(stack, `${label}${formatPoint(point)}`); + break; + } + case PTG_AREA3D_REF: + case PTG_AREA3D_VALUE: + case PTG_AREA3D_ARRAY: { + const ixti = cursor.u16(); + const [start, end] = readArea(cursor); + const label = resolveSheetLabel(ixti, context); + if (label === undefined) return undefined; + pushAtomic(stack, `${label}${formatPoint(start)}:${formatPoint(end)}`); + break; + } + case PTG_FUNC_REF: + case PTG_FUNC_VALUE: + case PTG_FUNC_ARRAY: { + const iftab = cursor.u16(); + const name = FTAB_NAMES.get(iftab); + const arity = FTAB_FIXED_ARITY.get(iftab); + if (name === undefined || arity === undefined) return undefined; + if (!applyFunctionCall(stack, name, arity)) return undefined; + break; + } + case PTG_FUNCVAR_REF: + case PTG_FUNCVAR_VALUE: + case PTG_FUNCVAR_ARRAY: { + const cparams = cursor.u8(); + const iftab = cursor.u16(); + const name = FTAB_NAMES.get(iftab); + if (name === undefined) return undefined; + if (!applyFunctionCall(stack, name, cparams)) return undefined; + break; + } + case PTG_ATTR_OPCODE: { + const subtype = cursor.u8(); + if (subtype === PTG_ATTR_CHOOSE) { + // Variable-length (a cOffset count then that many 2-byte jump offsets), and CHOOSE is not in this reader's supported vocabulary -- see the module comment. + return undefined; + } + cursor.skip(PTG_ATTR_TRAILING_BYTES); + if (subtype === PTG_ATTR_SUM) { + if (!applySum(stack)) return undefined; + } else if ( + subtype !== PTG_ATTR_SEMI && + subtype !== PTG_ATTR_IF && + subtype !== PTG_ATTR_GOTO && + subtype !== PTG_ATTR_BAXCEL_A && + subtype !== PTG_ATTR_BAXCEL_B && + subtype !== PTG_ATTR_SPACE && + subtype !== PTG_ATTR_SPACE_SEMI + ) { + return undefined; + } + break; + } + default: + // Every token this module does not recognise -- PtgExp, PtgTbl, PtgArray, PtgName/PtgNameX, PtgMemArea/MemErr/MemNoMem/MemFunc, PtgSxName, the Elf/Radical natural-language family, PtgRefN/PtgAreaN (which CellParsedFormula's own grammar forbids outside a shared formula, so meeting one here is itself a sign this rgce belongs to a shared-formula member), and PtgIsect/PtgUnion/PtgRange (the space/comma/colon reference operators, not in this reader's supported vocabulary) -- aborts the parse. + return undefined; + } + } + + return stack.length === 1 ? stack[0]?.text : undefined; +} diff --git a/packages/xls-codec/src/biff/record-types.ts b/packages/xls-codec/src/biff/record-types.ts index e933ade97..7d2e5cd7b 100644 --- a/packages/xls-codec/src/biff/record-types.ts +++ b/packages/xls-codec/src/biff/record-types.ts @@ -27,6 +27,10 @@ export const RECORD_DATE1904 = 0x0022; export const RECORD_STYLE = 0x0293; /** Present only in an encrypted workbook, where every record after it is ciphertext ([MS-XLS] 2.4.117). */ export const RECORD_FILEPASS = 0x002f; +/** A collection of XTI structures resolving a formula's ixti to a sheet range, for a 3D reference ([MS-XLS] 2.4.106). */ +export const RECORD_EXTERNSHEET = 0x0017; +/** The beginning of a supporting-link's own record collection: another workbook, a DDE/OLE data source, an add-in, or -- the one case this reader resolves -- this same workbook, self-referencing ([MS-XLS] 2.4.271). */ +export const RECORD_SUPBOOK = 0x01ae; // --- Worksheet substream --- diff --git a/packages/xls-codec/src/content.test.ts b/packages/xls-codec/src/content.test.ts index 5172df2f2..b9645b55d 100644 --- a/packages/xls-codec/src/content.test.ts +++ b/packages/xls-codec/src/content.test.ts @@ -9,14 +9,17 @@ import { RECORD_BOUNDSHEET8, RECORD_DATE1904, RECORD_EOF, + RECORD_EXTERNSHEET, RECORD_FILEPASS, RECORD_FORMAT, + RECORD_FORMULA, RECORD_COLINFO, RECORD_LABELSST, RECORD_MERGECELLS, RECORD_NUMBER, RECORD_ROW, RECORD_SST, + RECORD_SUPBOOK, RECORD_XF, } from "./biff/record-types"; import { BiffFormatError } from "./biff/records"; @@ -459,6 +462,137 @@ describe("readXlsContent", () => { }); }); +describe("readXlsContent formula recovery", () => { + // A cell reference's own RgceLoc column field ([MS-XLS] 2.5.51 ColRelU), fully relative -- the shape a bare `A1` (as opposed to `$A$1`) carries, both colRelative and rowRelative bits set. + const relativeColumn = (column: number) => u16(0xc000 | column); + /** PtgRef (value class, [MS-XLS] 2.5.198.84): a Formula's own operand for a single-cell reference. */ + const ptgRef = (row: number, column: number) => [ + 0x44, + ...u16(row), + ...relativeColumn(column), + ]; + /** The Formula record's own trailing fields after its FormulaValue ([MS-XLS] 2.4.127): flags, the calculation cache, then a CellParsedFormula's cce and rgce. */ + const formulaTail = (rgce: readonly number[]) => [ + ...u16(0), + ...u32(0), + ...u16(rgce.length), + ...rgce, + ]; + + it("recovers a formula's own text alongside its cached value", () => { + const bytes = xlsFile( + workbookStream({ + globals: xfTable(0), + sheets: [ + { + name: "Sheet1", + records: [ + record(RECORD_NUMBER, [...cell(0, 0), ...f64(1)]), + record(RECORD_NUMBER, [...cell(0, 1), ...f64(2)]), + record(RECORD_FORMULA, [ + ...cell(0, 2), + ...f64(3), + ...formulaTail([...ptgRef(0, 0), ...ptgRef(0, 1), 0x03]), + ]), + ], + }, + ], + }), + ); + + const cellC1 = readXlsContent(bytes).sheets[0]?.cells.find( + (entry) => entry.column === 2, + ); + + expect(cellC1?.formula).toBe("A1+B1"); + expect(cellC1?.value).toEqual({ kind: "number", value: 3 }); + }); + + it("resolves a cross-sheet 3D reference through EXTERNSHEET and a self-referencing SupBook", () => { + // PtgArea3d (ref class, [MS-XLS] 2.5.198.28): opcode 0x3B, an ixti, then rwFirst/rwLast and each corner's own relative column field. + const ptgArea3d = (ixti: number) => [ + 0x3b, + ...u16(ixti), + ...u16(0), + ...u16(1), + ...relativeColumn(0), + ...relativeColumn(1), + ]; + const bytes = xlsFile( + workbookStream({ + globals: [ + ...xfTable(0), + // SupBook ([MS-XLS] 2.4.271): ctab (ignored for a self-referencing link) then cch 0x0401, the self-referencing marker. + record(RECORD_SUPBOOK, [...u16(2), ...u16(0x0401)]), + // ExternSheet ([MS-XLS] 2.4.106): one XTI naming sheet index 1 ("Data") on both ends. + record(RECORD_EXTERNSHEET, [ + ...u16(1), + ...u16(0), + ...u16(1), + ...u16(1), + ]), + ], + sheets: [ + { + name: "Sheet1", + records: [ + record(RECORD_FORMULA, [ + ...cell(0, 0), + ...f64(10), + ...formulaTail([ + ...ptgArea3d(0), + 0x19, + 0x10, + ...u16(0), // PtgAttrSum + ]), + ]), + ], + }, + { + name: "Data", + records: [ + record(RECORD_NUMBER, [...cell(0, 0), ...f64(1)]), + record(RECORD_NUMBER, [...cell(0, 1), ...f64(2)]), + record(RECORD_NUMBER, [...cell(1, 0), ...f64(3)]), + record(RECORD_NUMBER, [...cell(1, 1), ...f64(4)]), + ], + }, + ], + }), + ); + + const cellA1 = readXlsContent(bytes).sheets[0]?.cells[0]; + + expect(cellA1?.formula).toBe("SUM(Data!A1:B2)"); + }); + + it("leaves formula absent for a shared-formula member's own PtgExp", () => { + // PtgExp ([MS-XLS] 2.5.198.58): opcode 0x01, then the shared formula's own base cell -- a formula this reader deliberately does not resolve (see biff/ptg.ts), so the cached value stays correct and formula stays absent rather than reading past the token into something invented. + const bytes = xlsFile( + workbookStream({ + globals: xfTable(0), + sheets: [ + { + name: "Sheet1", + records: [ + record(RECORD_FORMULA, [ + ...cell(0, 0), + ...f64(4), + ...formulaTail([0x01, ...u16(0), ...u16(0)]), + ]), + ], + }, + ], + }), + ); + + const cellA1 = readXlsContent(bytes).sheets[0]?.cells[0]; + + expect(cellA1?.formula).toBeUndefined(); + expect(cellA1?.value).toEqual({ kind: "number", value: 4 }); + }); +}); + describe("readXlsContent schema conformance", () => { // The strongest single check in this suite: the reader's output is parsed by document-schema.js's OWN validator rather than compared against hand-written expectations. A field this package populates with a shape the schema does not accept -- a zero widthPt where the schema requires a positive number, a cell value kind spelled BIFF8's way rather than the schema's, a required print-settings field left off -- fails here even when every value-level assertion above passes. const bytes = xlsFile( diff --git a/packages/xls-codec/src/content.ts b/packages/xls-codec/src/content.ts index 9617a5473..221d4f1c7 100644 --- a/packages/xls-codec/src/content.ts +++ b/packages/xls-codec/src/content.ts @@ -127,7 +127,10 @@ function readSheet( const raw: RawSheet = substream === undefined ? { cells: [], rows: [], columns: [], merges: [] } - : readSheetRecords(substream.records, globals.sharedStrings); + : readSheetRecords(substream.records, globals.sharedStrings, { + sheets: globals.sheets, + sheetRanges: globals.sheetRanges, + }); return { name: entry.name, cells: mapCells(raw, globals), @@ -211,6 +214,9 @@ function mapCell( if (formatCode !== undefined) { mapped.numberFormatCode = formatCode; } + if (cell.formula !== undefined) { + mapped.formula = cell.formula; + } return mapped; } diff --git a/packages/xls-codec/src/workbook/globals.test.ts b/packages/xls-codec/src/workbook/globals.test.ts index a563a2e5b..f541d8a7b 100644 --- a/packages/xls-codec/src/workbook/globals.test.ts +++ b/packages/xls-codec/src/workbook/globals.test.ts @@ -4,8 +4,10 @@ import { RECORD_BOUNDSHEET8, RECORD_CONTINUE, RECORD_DATE1904, + RECORD_EXTERNSHEET, RECORD_FORMAT, RECORD_SST, + RECORD_SUPBOOK, RECORD_XF, } from "../biff/record-types"; import { BiffFormatError, readRecords } from "../biff/records"; @@ -230,6 +232,65 @@ describe("readWorkbookGlobals", () => { expect(globals.date1904).toBe(true); }); + + it("resolves a 3D reference's ixti to a sheet range through a self-referencing SupBook", () => { + // [MS-XLS] 2.4.271: cch 0x0401 marks a SupBook as self-referencing -- this workbook itself -- so its EXTERNSHEET XTI's itabFirst/itabLast name real BoundSheet8 indices directly. + const globals = readWorkbookGlobals( + groupsOf( + record(RECORD_SUPBOOK, [...u16(3), ...u16(0x0401)]), + record(RECORD_EXTERNSHEET, [ + ...u16(1), + ...u16(0), // iSupBook + ...u16(1), // itabFirst + ...u16(2), // itabLast + ]), + ), + ); + + expect(globals.sheetRanges).toEqual([ + { firstSheetIndex: 1, lastSheetIndex: 2 }, + ]); + }); + + it("does not resolve an ixti whose SupBook is a genuinely external workbook", () => { + // A SupBook naming an external workbook carries a real cch (its virtPath's own length), never 0x0401 -- this reader resolves only the self-referencing case. + const globals = readWorkbookGlobals( + groupsOf( + record(RECORD_SUPBOOK, [...u16(2), ...u16(0x0001), 0x00]), + record(RECORD_EXTERNSHEET, [ + ...u16(1), + ...u16(0), + ...u16(0), + ...u16(0), + ]), + ), + ); + + expect(globals.sheetRanges).toEqual([undefined]); + }); + + it("does not resolve an XTI whose sheet could not be found", () => { + // [MS-XLS] 2.5.344: -1 is itabFirst/itabLast's own "the sheet could not be found" sentinel. + const globals = readWorkbookGlobals( + groupsOf( + record(RECORD_SUPBOOK, [...u16(1), ...u16(0x0401)]), + record(RECORD_EXTERNSHEET, [ + ...u16(1), + ...u16(0), + 0xff, + 0xff, // itabFirst = -1 + 0xff, + 0xff, // itabLast = -1 + ]), + ), + ); + + expect(globals.sheetRanges).toEqual([undefined]); + }); + + it("defaults sheetRanges to empty when the substream carries no EXTERNSHEET record", () => { + expect(readWorkbookGlobals(groupsOf()).sheetRanges).toEqual([]); + }); }); describe("formatCodeOf", () => { diff --git a/packages/xls-codec/src/workbook/globals.ts b/packages/xls-codec/src/workbook/globals.ts index 4e1a9e23e..612d74c78 100644 --- a/packages/xls-codec/src/workbook/globals.ts +++ b/packages/xls-codec/src/workbook/globals.ts @@ -1,9 +1,12 @@ import { BlockCursor } from "../biff/cursor"; +import type { SheetRange } from "../biff/ptg"; import { RECORD_BOUNDSHEET8, RECORD_DATE1904, + RECORD_EXTERNSHEET, RECORD_FORMAT, RECORD_SST, + RECORD_SUPBOOK, RECORD_XF, } from "../biff/record-types"; import { BiffFormatError } from "../biff/records"; @@ -53,6 +56,12 @@ export interface WorkbookGlobals { readonly numberFormats: ReadonlyMap; /** Whether serials count from the 1904 epoch rather than the 1900 one ([MS-XLS] 2.4.77). */ readonly date1904: boolean; + /** + * A PtgRef3d/PtgArea3d's own ixti, resolved to the sheet range it names -- one entry per XTI in the EXTERNSHEET record's rgXTI array, in that array's own order (an ixti is an index into it). + * + * An entry is undefined when this reader does not resolve it: the XTI's own SupBook is not this same, self-referencing workbook (a genuine external-workbook, DDE, OLE, or add-in reference -- [MS-XLS] 2.4.271's own cch table, `0x0401` being the one this reader treats as resolvable), or its itabFirst/itabLast do not name a real sheet (`-1`, "sheet could not be found"). A formula whose 3D reference lands on an undefined entry is left with `formula` absent for that cell, exactly like the other unsupported-token cases -- this reader never resolves a genuinely external workbook's own sheet names, since that needs the external workbook's own bytes, not this one's. + */ + readonly sheetRanges: readonly (SheetRange | undefined)[]; } /** [MS-XLS] 2.4.28's own hsState values; 0x01 is Hidden and 0x02 Very Hidden. */ @@ -72,8 +81,11 @@ export function readWorkbookGlobals( const sheets: SheetEntry[] = []; const cellFormats: CellFormat[] = []; const customFormats = new Map(); + // SupBook records ([MS-XLS] 2.4.271) precede the single EXTERNSHEET record that resolves against them, but this reader does not lean on that ordering: every SupBook is collected here, in arrival order, and EXTERNSHEET is resolved against the finished collection once the whole substream has been walked. + const supBookSelfReferencing: boolean[] = []; let sharedStrings: readonly string[] = []; let date1904 = false; + let externSheet: RecordGroup | undefined; for (const record of records) { switch (record.type) { @@ -94,8 +106,14 @@ export function readWorkbookGlobals( case RECORD_DATE1904: date1904 = readDate1904(record); break; + case RECORD_SUPBOOK: + supBookSelfReferencing.push(isSelfReferencingSupBook(record)); + break; + case RECORD_EXTERNSHEET: + externSheet = record; + break; default: - // Every other record in the globals substream -- the window settings, the palette, the theme, the drawing group, the external-workbook references -- carries nothing this reader acts on yet. + // Every other record in the globals substream -- the window settings, the palette, the theme, the drawing group -- carries nothing this reader acts on yet. break; } } @@ -106,7 +124,53 @@ export function readWorkbookGlobals( numberFormats.set(id, code); } - return { sheets, sharedStrings, cellFormats, numberFormats, date1904 }; + const sheetRanges = + externSheet === undefined + ? [] + : readSheetRanges(externSheet, supBookSelfReferencing); + + return { + sheets, + sharedStrings, + cellFormats, + numberFormats, + date1904, + sheetRanges, + }; +} + +/** [MS-XLS] 2.4.271's own cch table: a SupBook whose cch is exactly this value is a self-referencing supporting link -- this workbook itself -- rather than another workbook, a DDE/OLE data source, or an add-in. This is the only kind of supporting link a 3D reference resolves through here; every other kind genuinely needs bytes from outside this file. */ +const SUPBOOK_SELF_REFERENCING_CCH = 0x0401; + +/** SupBook ([MS-XLS] 2.4.271): a two-byte ctab then a two-byte cch. Only cch is read here -- ctab is undefined for a self-referencing SupBook and unused for every other kind this reader does not resolve, and the virtPath/rgst payload that would follow for a non-self-referencing link is never reached. */ +function isSelfReferencingSupBook(record: RecordGroup): boolean { + const cursor = new BlockCursor(record.blocks); + cursor.skip(2); // ctab + return cursor.u16() === SUPBOOK_SELF_REFERENCING_CCH; +} + +/** ExternSheet ([MS-XLS] 2.4.106): a two-byte cXTI then that many XTI structures ([MS-XLS] 2.5.344) -- a two-byte iSupBook and two signed 16-bit sheet-scope bounds each. iSupBook indexes the SupBook collection positionally, in the order those records appeared. */ +function readSheetRanges( + record: RecordGroup, + supBookSelfReferencing: readonly boolean[], +): readonly (SheetRange | undefined)[] { + const cursor = new BlockCursor(record.blocks); + const count = cursor.u16(); + const ranges: (SheetRange | undefined)[] = []; + for (let index = 0; index < count; index += 1) { + const iSupBook = cursor.u16(); + const itabFirst = cursor.i16(); + const itabLast = cursor.i16(); + // itabFirst/itabLast are only ever a real sheet index (>= 0) for a resolvable reference: -1 is [MS-XLS]'s own "the sheet could not be found", and -2 is a workbook-level scope with no single first/last sheet to name. + ranges.push( + (supBookSelfReferencing[iSupBook] ?? false) && + itabFirst >= 0 && + itabLast >= 0 + ? { firstSheetIndex: itabFirst, lastSheetIndex: itabLast } + : undefined, + ); + } + return ranges; } /** BoundSheet8 ([MS-XLS] 2.4.28): a four-byte stream position, a byte of hidden state, a byte of sheet type, then the name as a ShortXLUnicodeString. */ diff --git a/packages/xls-codec/src/workbook/sheet.test.ts b/packages/xls-codec/src/workbook/sheet.test.ts index 72d1e1010..9426700c3 100644 --- a/packages/xls-codec/src/workbook/sheet.test.ts +++ b/packages/xls-codec/src/workbook/sheet.test.ts @@ -353,6 +353,75 @@ describe("readSheetRecords formula cells", () => { expect(cells[0]?.value).toEqual({ kind: "string", value: "" }); }); + + it("recovers the formula's own text from its compiled Ptg token stream", () => { + // A1+B1: PtgRef(A1) PtgRef(B1) PtgAdd, [MS-XLS] 2.5.198.84/2.5.198.26. + const rgce = [ + 0x44, + ...u16(0), + ...u16(0xc000), + 0x44, + ...u16(0), + ...u16(0xc001), + 0x03, + ]; + const cells = readCells( + record(RECORD_FORMULA, [ + ...cell(0, 2), + ...f64(3), + ...u16(0), + ...u32(0), + ...u16(rgce.length), + ...rgce, + ]), + ); + + expect(cells[0]?.formula).toBe("A1+B1"); + }); + + it("leaves formula absent for a token this reader does not resolve", () => { + // PtgExp ([MS-XLS] 2.5.198.58), a shared formula's own placeholder -- the cached value is still read correctly, only the text stays absent. + const cells = readCells( + record(RECORD_FORMULA, [ + ...cell(0, 0), + ...f64(4), + ...u16(0), + ...u32(0), + ...u16(5), + 0x01, + ...u16(0), + ...u16(0), + ]), + ); + + expect(cells[0]?.formula).toBeUndefined(); + expect(cells[0]?.value).toEqual({ kind: "number", value: 4 }); + }); + + it("resolves a 3D reference using the formulaSheets context readSheetRecords is given", () => { + // PtgRef3d (value class, [MS-XLS] 2.5.198.85): opcode 0x5A, ixti, then a row and column field. + const rgce = [0x5a, ...u16(0), ...u16(0), ...u16(0xc000)]; + const formulaSheets = { + sheets: [{ name: "Sheet1" }, { name: "Data" }], + sheetRanges: [{ firstSheetIndex: 1, lastSheetIndex: 1 }], + }; + const cells = readSheetRecords( + groupsOf( + record(RECORD_FORMULA, [ + ...cell(0, 0), + ...f64(1), + ...u16(0), + ...u32(0), + ...u16(rgce.length), + ...rgce, + ]), + ), + [], + formulaSheets, + ).cells; + + expect(cells[0]?.formula).toBe("Data!A1"); + }); }); describe("readSheetRecords grid geometry", () => { diff --git a/packages/xls-codec/src/workbook/sheet.ts b/packages/xls-codec/src/workbook/sheet.ts index 7ab5c95ec..956ae8899 100644 --- a/packages/xls-codec/src/workbook/sheet.ts +++ b/packages/xls-codec/src/workbook/sheet.ts @@ -1,5 +1,6 @@ import { BlockCursor } from "../biff/cursor"; import { errorTextOf } from "../biff/errors"; +import { parseFormulaText, type FormulaSheetContext } from "../biff/ptg"; import { RECORD_ARRAY, RECORD_BLANK, @@ -53,8 +54,10 @@ export interface RawCell { /** The cell's own ixfe ([MS-XLS] 2.5.168): an index into the globals substream's XF table. */ readonly xfIndex: number; readonly value: RawCellValue; - /** True when the value is a Formula record's CACHED result rather than a literal. The formula expression itself is not read: BIFF8 stores it as a compiled Ptg token stream, not as text. */ + /** True when the value is a Formula record's CACHED result rather than a literal. */ readonly fromFormula: boolean; + /** The formula's own text, recovered from its compiled Ptg token stream ([MS-XLS] 2.5.198), when every token in it is one this reader resolves -- absent for a shared-formula member, an array formula, a defined-name or natural-language reference, or a 3D reference into a genuinely external workbook (see biff/ptg.ts). */ + readonly formula?: string; } export interface RawRow { @@ -108,10 +111,17 @@ const MUL_FIXED_BYTES = 6; const MULRK_ENTRY_BYTES = 6; const MULBLANK_ENTRY_BYTES = 2; +/** No sheets and no resolvable 3D references -- the default a caller with nothing formula-relevant to offer (every existing test fixture that predates formula-text recovery) gets, so a 3D reference simply fails to resolve rather than throwing on an absent context. */ +const EMPTY_FORMULA_SHEET_CONTEXT: FormulaSheetContext = { + sheets: [], + sheetRanges: [], +}; + /** Reads one worksheet substream's records. */ export function readSheetRecords( records: readonly RecordGroup[], sharedStrings: readonly string[], + formulaSheets: FormulaSheetContext = EMPTY_FORMULA_SHEET_CONTEXT, ): RawSheet { const cells: RawCell[] = []; const rows: RawRow[] = []; @@ -167,7 +177,9 @@ export function readSheetRecords( break; case RECORD_FORMULA: // A Formula whose cached result is a string is followed by a String record carrying it, so the formula reader is given whichever record that turns out to be. - cells.push(readFormula(record, stringResultAfter(records, index))); + cells.push( + readFormula(record, stringResultAfter(records, index), formulaSheets), + ); break; default: // Every other record a worksheet substream carries -- the window settings, the page setup, the drawing objects, the row-block index -- is not read yet. @@ -416,32 +428,35 @@ function readLabel(record: RecordGroup): RawCell { }; } +/** The Formula record's own flags field ([MS-XLS] 2.4.127) and calculation cache, between the cached value and the compiled expression -- neither read for its own content; see the two-byte and four-byte skips in readFormula. */ +const FORMULA_FLAGS_BYTES = 2; +const FORMULA_CALC_CACHE_BYTES = 4; + /** - * Formula ([MS-XLS] 2.4.127): a Cell, an eight-byte FormulaValue, flags, a calculation cache, then the compiled expression. + * Formula ([MS-XLS] 2.4.127): a Cell, an eight-byte FormulaValue, flags, a calculation cache, then a CellParsedFormula -- a two-byte cce followed by exactly that many bytes of compiled Ptg tokens ([MS-XLS] 2.5.198.3). * - * Only the cached VALUE is read. The expression is a CellParsedFormula -- a compiled Ptg token stream rather than text -- and turning one back into a formula string means implementing the whole Ptg vocabulary plus shared-formula and external-reference resolution, which is its own substantial piece of work. So the cell's displayed value is correct and its `formula` field stays absent, rather than a plausible-looking expression being invented for it. + * Both the cached value and the expression are read: the value from the FormulaValue exactly as before, and the expression by handing the token bytes to biff/ptg.ts's parseFormulaText, which resolves the whole Ptg vocabulary this reader supports and returns undefined for the constructs it does not (a shared formula, an array formula, a defined name, a natural-language reference, a genuinely external 3D reference -- see that module's own boundary). `formula` is attached only when it resolves; a cell it does not resolve for keeps exactly the behaviour this reader always had, its cached value present and `formula` absent. */ function readFormula( record: RecordGroup, next: RecordGroup | undefined, + formulaSheets: FormulaSheetContext, ): RawCell { const cursor = new BlockCursor(record.blocks); const header = readCellHeader(cursor); const bytes = cursor.take(8); const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); const tagged = view.getUint16(6, true) === FORMULA_VALUE_TAGGED; - if (!tagged) { - return { - ...header, - value: { kind: "number", value: view.getFloat64(0, true) }, - fromFormula: true, - }; - } - return { - ...header, - value: taggedFormulaValue(view, next), - fromFormula: true, - }; + const value = tagged + ? taggedFormulaValue(view, next) + : { kind: "number" as const, value: view.getFloat64(0, true) }; + cursor.skip(FORMULA_FLAGS_BYTES); + cursor.skip(FORMULA_CALC_CACHE_BYTES); + const cce = cursor.u16(); + const formula = parseFormulaText(cursor.take(cce), formulaSheets); + return formula === undefined + ? { ...header, value, fromFormula: true } + : { ...header, value, fromFormula: true, formula }; } /** The non-numeric readings of a FormulaValue ([MS-XLS] 2.5.133), selected by its first byte. */