diff --git a/README.md b/README.md index b35ff1bac..097583ba1 100644 --- a/README.md +++ b/README.md @@ -41,7 +41,7 @@ Each converts one document format to and from the shared schema, built on `docum | [`rtf-codec`](packages/rtf-codec/README.md) | Rich Text Format to and from the shared content schema; hand-written tokenizer, destination state machine, and header-table parsers, since RTF is tokenised text rather than XML. | | [`wpd-codec`](packages/wpd-codec/README.md) | WordPerfect 6.x-X6 (`.wpd`) to the shared content schema; read-only, and under active development. | | [`doc-codec`](packages/doc-codec/README.md) | The pre-2007 Word Binary File Format (`.doc`, [MS-DOC]) to and from the shared content schema, also depending on `archive-codec` for its [MS-CFB] container; write support covers character/paragraph formatting, tables over a single section, cell decoration, and metadata, with images, style-inherited formatting, and numbering still read-only gaps — see the package's own README for its exact scope. | -| [`xls-codec`](packages/xls-codec/README.md) | Legacy Excel Binary File Format (`.xls`, BIFF8) to and from the shared content schema, also depending on `archive-codec` for its [MS-CFB] container; write support covers cell values, merges, row/column sizing, number formats, and cell background/border decoration, with formulas and print settings still read-only gaps — see the package's own README for its exact scope. | +| [`xls-codec`](packages/xls-codec/README.md) | Legacy Excel Binary File Format (`.xls`, BIFF8) to and from the shared content schema, also depending on `archive-codec` for its [MS-CFB] container; write support covers cell values, merges, row/column sizing, number formats, cell background/border decoration, and print settings, with formulas still a read-only gap — see the package's own README for its exact scope. | | [`ppt-codec`](packages/ppt-codec/README.md) | PowerPoint 97-2003 binary presentations (`.ppt`, [MS-PPT]) to and from the shared content schema, also depending on `archive-codec` for its [MS-CFB] container; the write side covers plain text-box slides only, and both directions remain under active development. | ### Conversion engine diff --git a/packages/documents.js/README.md b/packages/documents.js/README.md index 3f2ecd2e7..070216a21 100644 --- a/packages/documents.js/README.md +++ b/packages/documents.js/README.md @@ -969,7 +969,7 @@ Read as **row → column**. `✓` lossless, `~` bounded, `✗` lossy, `✗✗` s **`doc`'s own bridges lose far more than `rtf`'s did, and its writer refuses rather than approximates a genuine construct kind.** doc-codec's reader (see that package's own [Status](../doc-codec/README.md#status)) reads real tables (row/column/cell structure, horizontal and vertical merge) but still drops images, style-inherited formatting, subdocuments, every section beyond the first, numbering definitions, metadata, and hyperlinks/fields — a much narrower read than docx/odt offer, so every `doc → X` cell in this table stays `✗` regardless of `X`'s own richness: that content is already gone before any target is reached, tables now surviving the crossing notwithstanding. Going the other way, `writeDocContent` writes a `ContentTable` (a table nested inside a table cell is the one genuine table-shaped refusal, alongside cell shading/borders it silently does not carry — see doc-codec's own [Tables](../doc-codec/README.md#tables) section) but still does not approximate an image or a section beyond the first — it throws `DocUnsupportedError` rather than dropping or flattening either. Every `X → doc` cell where `X`'s own content can genuinely carry an image (`docx`, `odt`, `markdown`, `rtf`, and anything reconstructed with one via a PDF pivot) stays rated `✗` on the same basis as every other `doc` cell, and for those sources that direction can still mean the conversion fails outright for a real document rather than merely losing formatting — there is no partial write for a document containing an image, though a table alone no longer forces that failure. -**`xls`'s writer never touches formulas, cell decoration, images, or anything print/metadata-related**, so `xls⇄xlsx`/`xls⇄ods` are rated `✗` rather than the `~` their full-featured `xlsx⇄ods` counterpart earns: a real workbook's formulas and fill/border/font decoration are silently dropped in both directions (neither is ever read by xls-codec, so neither can be written back either — see that package's own [Writer scope](../xls-codec/README.md#writer-scope) and [Read-side gaps](../xls-codec/README.md#read-side-gaps)), a materially bigger gap than `xlsx⇄ods`'s own "small format-boundary limits" (time cells, formula dialects). `csv⇄xls` is the one `xls` pair that escapes this downgrade: csv can carry neither formulas nor decoration to begin with, so it loses nothing crossing into `xls` that it would not also lose crossing into `xlsx`/`ods` — `csv → xls` is `✓`, matching `csv → xlsx`/`csv → ods`, and `xls → csv` is `~`, matching `xlsx → csv`, since csv always collapses a cell to its `displayText` regardless of how rich the source was. `xls⇄markdown` needs the identical extra `ods`-bridge hop before the same PDF pivot that already earns `xlsx⇄markdown`/`csv⇄markdown` their `✗✗` — so `xls⇄markdown` is `✗✗` too, for the same reason. A `.xls` cell grid outside BIFF8's own 65536-row/256-column limit is refused outright, the one write-side hard stop `xls-codec` shares with `doc-codec`'s own refusals. +**`xls`'s writer never touches formulas, per-cell fonts, cell alignment, or images**, so `xls⇄xlsx`/`xls⇄ods` are rated `✗` rather than the `~` their full-featured `xlsx⇄ods` counterpart earns: a real workbook's formulas and per-cell font are silently dropped in both directions (neither is ever read by xls-codec, so neither can be written back either — see that package's own [Writer scope](../xls-codec/README.md#writer-scope) and [Read-side gaps](../xls-codec/README.md#read-side-gaps)), a materially bigger gap than `xlsx⇄ods`'s own "small format-boundary limits" (time cells, formula dialects). A cell's background fill and per-side borders, a sheet's print settings, and the workbook's own metadata do all survive the crossing — each is read and written for real by xls-codec — so the downgrade rests on formulas and font alone. `csv⇄xls` is the one `xls` pair that escapes this downgrade: csv can carry neither formulas nor per-cell fonts to begin with, so it loses nothing crossing into `xls` that it would not also lose crossing into `xlsx`/`ods` — `csv → xls` is `✓`, matching `csv → xlsx`/`csv → ods`, and `xls → csv` is `~`, matching `xlsx → csv`, since csv always collapses a cell to its `displayText` regardless of how rich the source was. `xls⇄markdown` needs the identical extra `ods`-bridge hop before the same PDF pivot that already earns `xlsx⇄markdown`/`csv⇄markdown` their `✗✗` — so `xls⇄markdown` is `✗✗` too, for the same reason. A `.xls` cell grid outside BIFF8's own 65536-row/256-column limit is refused outright, the one write-side hard stop `xls-codec` shares with `doc-codec`'s own refusals. **`ppt`'s writer drops what it can't express instead of throwing**, unlike `doc`'s: an image, table, or OLE-embedded shape is silently excluded from the written text body rather than refusing the whole conversion (see that package's own [Writing a document](../ppt-codec/README.md#writing-a-document)) — genuinely gentler failure behaviour than `doc`'s hard stop, but the loss itself is just as real, so `ppt` cells are rated `✗` on the same basis as `doc`'s rather than upgraded for failing more softly. `ppt`'s reader is narrower again than `pptx`'s own: only plain text-box shapes with basic character formatting come back at all — no images, tables, OLE objects, masters, layouts, scheme colours, or per-shape insets (see that package's own [What it does not read yet](../ppt-codec/README.md#what-it-does-not-read-yet)) — so every `ppt → X` cell already reflects that ceiling before `X`'s own capacity ever matters. diff --git a/packages/xls-codec/README.md b/packages/xls-codec/README.md index 0f946da30..7a696ea57 100644 --- a/packages/xls-codec/README.md +++ b/packages/xls-codec/README.md @@ -13,29 +13,30 @@ Under active development, with real, tested **read and write** support. Built an - **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. - **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. - **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`. -- **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`, `LabelSst`, and `Blank` on write (`MulBlank`, `RK`, `MulRk`, `Label`, and `Formula` with its `String` result record are read-only — see below). +- **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). - **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. - **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`. - **Document metadata** — `title`/`subject`/`author`/`keywords`/`createdIso`/`modifiedIso` read from a `"\x05SummaryInformation"` stream when one is present, and written back to one whenever the input's metadata carries anything that stream can hold (see [Metadata](#metadata)). - **Cell decoration** — a cell's background fill (solid pattern only) and per-side borders, read from and written to `XF`'s trailing `CellXF` payload plus the workbook's own `Palette` record, in both directions and verified against real LibreOffice-produced BIFF8, not just this package's own round trip (see [Cell decoration](#cell-decoration)). +- **Print settings** — every field of `ContentSheetPrintSettings`: page size and orientation, all four margins, gridline and row/column-header printing, page order, print scale or fit-to-page counts, manual page breaks, the print range, and the repeated header rows and columns — read from and written to the nine worksheet-substream records and the two built-in defined names that carry them, in both directions and verified against real LibreOffice-produced BIFF8 (see [Print settings](#print-settings)). 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. ### Writer scope -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 decoration 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, and — see [Cell decoration](#cell-decoration) — a cell's own solid background fill and per-side borders, deduplicated across the workbook the same way number formats are. What it deliberately does not: +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 decoration 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), and — see [Cell decoration](#cell-decoration) — a cell's own solid background fill and per-side borders, deduplicated across the workbook the same way number formats are. What it deliberately does not: -| 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 alignment, per-cell font | The reader does not read either back (see [Cell decoration](#cell-decoration) and Read-side gaps below), so writing real values here would be unverifiable by round trip. Every `XF` this writer emits still carries the same undecorated alignment default (general horizontal, bottom vertical, no wrap) and the same single font (`ooxml.js`'s xlsx reader makes the identical font-scope choice: `ContentSheetCell` has no cell-level font field at all). | -| `MulBlank`/`RK`/`MulRk` | Pure compaction optimisations over information a plain `Blank`/`Number`/`LabelSst`/`BoolErr` record already carries losslessly. (`Blank` itself _is_ written, for a decorated empty cell — see the `empty` row below.) | -| An `empty`-kind cell carrying no decoration | Written as nothing at all, which is what round-trips: `content.ts`'s reader drops an undecorated blank cell it reads, and a merged range's empty anchor is independently reconstructed from `MergeCells` alone. A decorated one is the opposite case — its fill and borders exist only in the `XF` a cell record names, so it gets a real `Blank` record ([MS-XLS] 2.4.20) and round-trips with its decoration intact. | -| 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`) | Same reason — the reader always returns its own fixed "Normal" preset regardless of what a file states, so writing the real values would be unverifiable. Workbook metadata (`\x05SummaryInformation`) is a separate story: see [Metadata](#metadata). | -| `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 alignment, per-cell font | The reader does not read either back (see [Cell decoration](#cell-decoration) and Read-side gaps below), so writing real values here would be unverifiable by round trip. Every `XF` this writer emits still carries the same undecorated alignment default (general horizontal, bottom vertical, no wrap) and the same single font (`ooxml.js`'s xlsx reader makes the identical font-scope choice: `ContentSheetCell` has no cell-level font field at all). | +| `MulBlank`/`RK`/`MulRk` | Pure compaction optimisations over information a plain `Blank`/`Number`/`LabelSst`/`BoolErr` record already carries losslessly. (`Blank` itself _is_ written, for a decorated empty cell — see the `empty` row below.) | +| An `empty`-kind cell carrying no decoration | Written as nothing at all, which is what round-trips: `content.ts`'s reader drops an undecorated blank cell it reads, and a merged range's empty anchor is independently reconstructed from `MergeCells` alone. A decorated one is the opposite case — its fill and borders exist only in the `XF` a cell record names, so it gets a real `Blank` record ([MS-XLS] 2.4.20) and round-trips with its decoration intact. | +| 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. | +| 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). | +| `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. | +| `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. @@ -45,15 +46,15 @@ Each deliberate rather than overlooked: - **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 and borders resolved for real; alignment and font still not.** `XF`'s trailing `CellXF` payload's fill pattern/colour and per-side border style/colour are read and resolved through the `Palette` record (or the default colour table when one is absent) — see [Cell decoration](#cell-decoration) for the full mapping, the fill-pattern-beyond-solid judgment call, and how this was verified against real LibreOffice-produced BIFF8. The payload's leading alignment word is not read at all, and `Font` records are not read either: `ContentSheetCell` has no cell-level font field, and `ooxml.js`'s xlsx reader makes the identical scope choice. -- **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`. -- **Not read at all:** charts, drawings and images, cell comments (`Note`/`Txo`), data validation, conditional formatting, and defined names (`Lbl`). +- **Print settings, resolved for real.** Every field of `ContentSheetPrintSettings` is read from the records that carry it, with Excel's own "Normal" preset standing in per field for whatever the file leaves unstated — see [Print settings](#print-settings) for the record map, the two of BIFF8's own conditional rules that decide how to read `Setup`, and the three things that genuinely do not come through. +- **Not read at all:** charts, drawings and images, cell comments (`Note`/`Txo`), data validation, and conditional formatting. 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. - **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 (formula writing, shared/array/external-reference formulas, cell alignment and per-cell font, print settings) 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 alignment and per-cell font) are tracked on [#815](https://github.com/ExaDev/documents.js/issues/815). ## Cell decoration -A cell's own solid background fill and per-side borders are read from and written to `XF`'s trailing `CellXF`/`StyleXF` payload ([MS-XLS] 2.4.353) and the workbook's own `Palette` record ([MS-XLS] 2.4.204), 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. `src/biff/xf-colors.ts` is the one place the payload's border/fill bit layout is packed or unpacked, shared by `workbook/globals.ts`'s read side and `biff/xf-writer.ts`'s write side, so the two directions cannot silently disagree on what a given byte means. +A cell's own solid background fill and per-side borders are read from and written to `XF`'s trailing `CellXF`/`StyleXF` payload ([MS-XLS] 2.4.353) and the workbook's own `Palette` record ([MS-XLS] 2.4.188), 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. `src/biff/xf-colors.ts` is the one place the payload's border/fill bit layout is packed or unpacked, shared by `workbook/globals.ts`'s read side and `biff/xf-writer.ts`'s write side, so the two directions cannot silently disagree on what a given byte means. **Colour resolution.** A fill or border colour is a 7-bit `icv` index into BIFF8's own colour table ([MS-XLS] "Icv"): 0-7 name eight fixed built-in colours (this package's writer never emits one of these, per the spec's own "SHOULD NOT be ≤ 0x07"; the reader still resolves them, for a real third-party file that does), 8-63 index into either the workbook's own `Palette` record when one is present or a fixed 56-entry default table when it is not. The writer scans every distinct decoration colour a workbook's cells use before writing anything: when every one already matches the default table exactly, no `Palette` record is written at all, keeping an undecorated-adjacent file as minimal as it always was; the moment even one colour falls outside that table, a real 56-entry `Palette` record is minted, with every distinct colour the workbook actually uses (not only the non-default ones) assigned its own dedicated slot, so the whole table is self-consistent rather than a mix of "the file's own entries" and "the implicit default". @@ -69,6 +70,42 @@ The decorated-blank case was checked the same way, in both directions and agains Alignment (the payload's own leading word) and a per-cell font remain out of scope in both directions — see [Read-side gaps](#read-side-gaps) and [Writer scope](#writer-scope) above. +## Print settings + +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. + +**One sheet's print settings live in two substreams, not one.** The page setup is in the sheet's own substream, as the optional records of [MS-XLS] 2.1.7.20.6's own `GLOBALS` and `PAGESETUP` productions. The print range and the repeated header bands are not there at all: BIFF8 keeps them in the workbook globals substream, as ordinary defined names ([MS-XLS] 2.4.150's `Lbl` record) carrying a built-in name index rather than a user-typed name, scoped to one sheet through the record's own `itab`. `src/workbook/print-names.ts` is both directions of exactly those two names; `src/biff/print-setup.ts` is the one place the `Setup` record's own bit layout and paper-size code table are packed or unpacked, shared by the read and write sides so the two cannot silently disagree. + +| Schema field | Where BIFF8 keeps it | +| ------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------- | +| `pageSize` | `Setup`'s `iPaperSize` code ([MS-XLS] 2.4.257), transposed to landscape by its own `fPortrait`/`fNoOrient` flags | +| `margins` | `LeftMargin`/`RightMargin`/`TopMargin`/`BottomMargin` ([MS-XLS] 2.4.151, 2.4.219, 2.4.328, 2.4.27), each a single `Xnum` of inches | +| `gridlines` | `PrintGrid`'s `fPrintGrid` ([MS-XLS] 2.4.202) | +| `headers` | `PrintRowCol`'s `printRwCol` ([MS-XLS] 2.4.203) — the row/column header chrome, not a repeated print band | +| `pageOrder` | `Setup`'s `fLeftToRight` | +| `scalePercent` / `fitToPages` | `Setup`'s `iScale` or its `iFitWidth`/`iFitHeight` pair, selected by `WsBool`'s own `fFitToPage` bit ([MS-XLS] 2.4.351) | +| `manualBreaks` | `HorizontalPageBreaks`/`VerticalPageBreaks` ([MS-XLS] 2.4.142, 2.4.343) | +| `printRange` | The built-in `Print_Area` defined name (`Lbl` with built-in index `0x06`), whose `rgce` is a `PtgArea3d` naming the range | +| `repeatRows` / `repeatColumns` | The built-in `Print_Titles` name (index `0x07`), whose `rgce` is a `PtgMemFunc` wrapping one or two `PtgArea3d` tokens joined by `PtgUnion` | + +**Every record behind these is optional, and every field falls back independently.** A sheet whose page setup was never touched carries no `Setup` and no margin records at all, so something has to stand in — Excel's own "Normal" preset (top/bottom 0.75in, left/right 0.7in, Letter paper, gridlines and headers not printed, pages down-then-over), the same constants `ooxml.js` falls back to for an xlsx carrying no `pageMargins` element, so the same untouched sheet reads identically from either format. The fallback is per field rather than wholesale: a sheet declaring a left margin and nothing else keeps its real left margin and takes the preset for the other three. + +**Two of BIFF8's own conditional rules are honoured rather than flattened.** A `Setup` record whose `fNoPls` bit is set declares its own paper size and scale undefined ([MS-XLS] 2.4.257: "whether the `iPaperSize`, `iScale`, `iRes`, `iVRes`, `iCopies`, `fNoOrient`, and `fPortrait` data are undefined and ignored"), so neither is read from it — the page size falls back to the preset and no `scalePercent` is reported, rather than a paper code the file itself disowns being resolved into a confident page size. And `WsBool`'s `fFitToPage` decides which of `Setup`'s two mutually exclusive scaling fields is live: real producers write both regardless of which is active (confirmed against LibreOffice-written BIFF8, which carries `iScale=100` alongside a real fit-to-page pair, and a real `iScale` alongside `iFitWidth=iFitHeight=1`), so reading both would report a scale and a page count that contradict each other. + +**A repeated band's axis is its shape, not a field.** BIFF8 has no flag saying which axis a `Print_Titles` band repeats along: a repeated row band is written as an area spanning every column of the sheet (`$1:$2`, columns 0-255) and a repeated column band as one spanning every row (`$A:$A`, rows 0-65535). Both directions use that shape as the discriminant, and an area spanning both axes at once names the whole sheet — which is neither — so it is left unclassified rather than assigned to whichever branch happened to be tested first. + +**Three things do not come through, each for a reason in the format rather than an oversight:** + +- **A page size no `iPaperSize` code names loses its dimensions.** Unlike xlsx's own `pageSetup` element, which can state an explicit `paperWidth`/`paperHeight` pair, `Setup` addresses paper only by code; its escape hatch for a size outside the table is a printer-defined custom size carried in a separate `Pls` record ([MS-XLS] 2.4.199), a printer driver's opaque `DEVMODE` blob rather than a pair of dimensions any reader could recover a size from. So the writer emits `iPaperSize` 0 — that section's own "custom printer paper sizes" — which is true, where substituting Letter would not be; the reading application then falls back to its own default paper (this package to the preset, LibreOffice to its locale's), and every other print setting on the sheet still comes through. Refusing the file outright was the first thing tried and is the wrong trade: a spreadsheet converted from a slide deck or a drawing carries that source's own canvas as its page size, which is almost never a named paper, and failing the conversion would lose the cells too. The code table this package does map is the office paper sizes of [MS-XLS]'s own 118-entry enumeration — the Letter/Legal/Tabloid/Executive/Statement family, A2 through A6, B4/B5 in both the JIS and ISO spellings, Folio and Quarto — each derived from the inches or millimetres that table itself states rather than from pre-converted points, and each matched within half a point so a page size picking up conversion drift on its way between codecs still resolves. +- **A "fit to as many pages as necessary" axis has no schema spelling.** `Setup` documents `iFitWidth`/`iFitHeight` of 0 as "use as many pages as necessary to print the columns/rows in the sheet", and `ContentSheetPrintSettings.fitToPages` requires both counts to be positive. A fit-to-page sheet with an auto axis therefore reports no `fitToPages` at all rather than a fabricated 1, which would claim the sheet is pinned to a single page along an axis the file left free. +- **An explicit 100% scale reads back as no declared scale.** `Setup`'s `iScale` is a mandatory field of a mandatory record with no spelling for "this sheet declares no scale", so an untouched sheet still states 100 — and `ContentSheetPrintSettings` already means exactly that by carrying no `scalePercent` at all. The two spellings print identically, so the reader collapses them onto the absent one rather than putting a field carrying no actionable information on every sheet of every workbook it reads. A scale that is genuinely anything else is reported exactly as the file states it. + +Two further deliberate narrowings, both because the schema models less than BIFF8 states: a `Print_Area` naming several disjoint areas (legal in BIFF8, and what Excel writes for a multi-area print selection) yields only the first, since `printRange` models one rectangle and merging several into their bounding box would claim cells print that do not; and a BIFF8 page break carries an extent along the perpendicular axis, which `manualBreaks` — an index with no extent — cannot express, so a partial break is carried as a full one and two breaks on the same row collapse into one. + +**Verified against a real, independent BIFF8 implementation, not just this package's own reader/writer pair.** Three `.xls` files built directly by LibreOffice (`soffice --headless --convert-to xls`, from hand-authored `.fods` files declaring a real page layout, print range, header rows and columns, and manual breaks) read back through this package with every field matching what was authored: A4 landscape, four distinct margins, gridlines and headers on or off, `overThenDown` page order, an 80% scale and a 2x3 fit-to-page pair, a row break at row 10, a column break at column 3, a `B2:D6` print range, two repeated header rows, and one repeated header column. Going the other way, a `.xls` this package writes for the same content opens in LibreOffice with every one of those fields intact, confirmed by converting it back through `soffice --headless --convert-to fods` and comparing LibreOffice's own re-export of our file against its re-export of its own, attribute by attribute. A fourth file, written with a page size no paper code names, opens in LibreOffice with its own default paper substituted and every other print setting and cell intact — the custom-paper behaviour above, checked rather than assumed. The `Print_Area` record this writer emits is byte-for-byte the one LibreOffice writes for the same range (`src/workbook/print-names.test.ts` asserts exactly that against the real bytes); the `Print_Titles` record differs by one byte, a trailing `PtgParen` display token this writer has no reason to emit. + +**Why this writer now emits the calculation-state records.** [MS-XLS] 2.1.7.20.6's `GLOBALS` production makes `CalcCount`, `CalcRefMode`, `CalcIter`, `CalcDelta` and `CalcSaveRecalc` mandatory ahead of `PrintRowCol`, and this writer previously omitted them along with the rest of BIFF8's UI and interoperability bookkeeping. That turned out to matter: LibreOffice's own importer silently discards whichever page-settings record comes first in a worksheet substream, so with `PrintRowCol` in that slot, a `.xls` this package wrote with row and column headers enabled opened in LibreOffice with them off — while every other print setting in the same file came through correctly. Moving any other record into that slot fixes it, and the records the grammar already required there are the honest way to do it. Confirmed by writing the same workbook with and without them and re-reading each through `soffice --convert-to fods`. + ## Metadata A `.xls`'s title, author, and dates do not live in any BIFF8 record at all — they live in a `"\x05SummaryInformation"` stream, a genuinely different format ([MS-OLEPS] Property Set Streams, [MS-OSHARED] 2.3.3.2.2's own naming of the specific properties Office uses) that happens to sit beside `Workbook` in the same [MS-CFB] compound file. `readXlsContent` reads that stream when present (`archive-codec`'s `readSummaryInformation`, since the property-set format itself is zero document-format knowledge, exactly as the [MS-CFB] container it sits inside is) and maps it onto `document-schema.js`'s `LayoutMetadata` (`archive-codec`'s own `summaryInformationToLayoutMetadata` — the mapping is format-agnostic, so it lives there rather than being copied in this package, alongside `doc-codec`'s and `ppt-codec`'s identical need for it); `writeXlsContent` does the inverse (`src/metadata.ts`'s `layoutMetadataToSummaryInformation`, which validates `createdIso`/`modifiedIso` as real dates and throws a `BiffWriteError` naming the offending field before delegating to `archive-codec`'s own mapping), including a `"\x05SummaryInformation"` stream in its `writeCompoundFile` call only when the input's metadata actually carries something that stream can hold — an input whose metadata is `{}`, or carries only fields the mapping below has no destination for, produces no stream at all, matching what an absent-metadata read already returns. @@ -136,8 +173,9 @@ Layered bottom-up, each layer testable against hand-built byte sequences taken f - **`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/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, and reads a `Palette` record and each `XF`'s trailing fill/border payload for [Cell decoration](#cell-decoration). +- **`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, and reads a `Palette` record and each `XF`'s trailing fill/border payload for [Cell decoration](#cell-decoration). 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. - **`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). +- **`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`. - **[`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`. - **`src/metadata.ts`** — wraps `archive-codec`'s own `SummaryInformationProperties` <-> `LayoutMetadata` mapping with this package's `createdIso`/`modifiedIso` date validation, throwing `BiffWriteError` for a malformed one rather than letting an opaque `RangeError` escape the FILETIME conversion (see [Metadata](#metadata)). diff --git a/packages/xls-codec/src/biff/print-setup.test.ts b/packages/xls-codec/src/biff/print-setup.test.ts new file mode 100644 index 000000000..3755ee8c2 --- /dev/null +++ b/packages/xls-codec/src/biff/print-setup.test.ts @@ -0,0 +1,166 @@ +import { PAGE_SIZE_A4, PAGE_SIZE_LETTER } from "document-schema.js"; +import { describe, expect, it } from "vitest"; + +import { + packSetupFlags, + pageSizeFromSetup, + paperSelectionFor, + unpackSetupFlags, + type SetupFields, +} from "./print-setup"; + +// Every expected page size here is stated as the dimensions [MS-XLS] 2.4.257's own iPaperSize table names for that code, converted independently of the module under test, rather than copied from what it returns. + +const PORTRAIT_LETTER: SetupFields = { + paperCode: 1, + scalePercent: 100, + fitWidth: 1, + fitHeight: 1, + leftToRight: false, + portrait: true, + noPls: false, + noOrientation: false, +}; + +describe("pageSizeFromSetup", () => { + it("resolves US Letter (code 1) to the same 612 x 792 pt the shared schema constant carries", () => { + expect(pageSizeFromSetup(PORTRAIT_LETTER)).toEqual(PAGE_SIZE_LETTER); + }); + + it("resolves A4 (code 9) to the same 595.28 x 841.89 pt the shared schema constant carries", () => { + expect(pageSizeFromSetup({ ...PORTRAIT_LETTER, paperCode: 9 })).toEqual( + PAGE_SIZE_A4, + ); + }); + + it("transposes a code's own portrait dimensions when fPortrait is clear", () => { + // A paper code names the sheet's paper in portrait regardless of how it prints, so landscape A4 is the same code with the dimensions the other way round. + expect( + pageSizeFromSetup({ ...PORTRAIT_LETTER, paperCode: 9, portrait: false }), + ).toEqual({ + widthPt: PAGE_SIZE_A4.heightPt, + heightPt: PAGE_SIZE_A4.widthPt, + }); + }); + + it("prints portrait when fNoOrient is set, whatever fPortrait says", () => { + // [MS-XLS] 2.4.257's own fNoOrient table: value 1 means "Pages are printed using portrait mode", and fPortrait "is undefined and MUST be ignored". + expect( + pageSizeFromSetup({ + ...PORTRAIT_LETTER, + portrait: false, + noOrientation: true, + }), + ).toEqual(PAGE_SIZE_LETTER); + }); + + it("resolves a metric code from the millimetres its own table entry states", () => { + // A3, 297 x 420 mm -> 297/25.4*72 x 420/25.4*72 pt, rounded to hundredths. + expect(pageSizeFromSetup({ ...PORTRAIT_LETTER, paperCode: 8 })).toEqual({ + widthPt: 841.89, + heightPt: 1190.55, + }); + }); + + it("resolves nothing for a code outside the table", () => { + // 0 and everything from 256 up are [MS-XLS]'s own "custom printer paper sizes", resolvable only through a Pls record this package neither reads nor writes. + expect( + pageSizeFromSetup({ ...PORTRAIT_LETTER, paperCode: 0 }), + ).toBeUndefined(); + expect( + pageSizeFromSetup({ ...PORTRAIT_LETTER, paperCode: 300 }), + ).toBeUndefined(); + }); +}); + +describe("paperSelectionFor", () => { + it("names US Letter portrait for the shared schema constant", () => { + expect(paperSelectionFor(PAGE_SIZE_LETTER)).toEqual({ + code: 1, + portrait: true, + }); + }); + + it("names A4 landscape for a transposed A4 page", () => { + expect( + paperSelectionFor({ + widthPt: PAGE_SIZE_A4.heightPt, + heightPt: PAGE_SIZE_A4.widthPt, + }), + ).toEqual({ code: 9, portrait: false }); + }); + + it("absorbs a fraction of a point of drift", () => { + // A page size crossing between codecs picks up conversion drift; a fifth of a point is well under any real difference between two papers. + expect(paperSelectionFor({ widthPt: 595.08, heightPt: 841.69 })).toEqual({ + code: 9, + portrait: true, + }); + }); + + it("names no paper at all for a size no code covers", () => { + // BIFF8's Setup record addresses paper only by code, so this is a real limit rather than a fallback: the caller refuses instead of substituting a paper the document never asked for. + expect(paperSelectionFor({ widthPt: 500, heightPt: 500 })).toBeUndefined(); + }); + + it("round-trips every resolvable code back to a page size that resolves the same way", () => { + for (let code = 0; code <= 300; code += 1) { + const size = pageSizeFromSetup({ ...PORTRAIT_LETTER, paperCode: code }); + if (size === undefined) { + continue; + } + const selection = paperSelectionFor(size); + expect(selection).toBeDefined(); + // Not necessarily the SAME code: several entries of [MS-XLS]'s own table are duplicates or transposes of one another (US Letter/US Letter Small/US Note all state 8.5 x 11 in; US Tabloid 11 x 17 in and US Ledger 17 x 11 in are one sheet entered both ways round), so what has to hold is that the selection resolves back to the identical page size, not that it picks the code it started from. + expect( + pageSizeFromSetup({ + ...PORTRAIT_LETTER, + paperCode: selection?.code ?? -1, + portrait: selection?.portrait ?? true, + }), + ).toEqual(size); + } + }); +}); + +describe("Setup flag packing", () => { + it("round-trips every combination of the four flags this package acts on", () => { + for (let combination = 0; combination < 16; combination += 1) { + const flags = { + leftToRight: (combination & 1) !== 0, + portrait: (combination & 2) !== 0, + noPls: (combination & 4) !== 0, + noOrientation: (combination & 8) !== 0, + }; + expect( + unpackSetupFlags(packSetupFlags({ ...PORTRAIT_LETTER, ...flags })), + ).toEqual(flags); + } + }); + + it("packs each flag into the bit position [MS-XLS] 2.4.257 assigns it", () => { + // Fields A, B, C and G of that section's own bit table: fLeftToRight, fPortrait, fNoPls, then fNoOrient after fNoColor, fDraft and fNotes. + expect( + packSetupFlags({ + ...PORTRAIT_LETTER, + portrait: false, + leftToRight: true, + }), + ).toBe(0x0001); + expect(packSetupFlags(PORTRAIT_LETTER)).toBe(0x0002); + expect(packSetupFlags({ ...PORTRAIT_LETTER, noPls: true })).toBe(0x0006); + expect(packSetupFlags({ ...PORTRAIT_LETTER, noOrientation: true })).toBe( + 0x0042, + ); + }); + + it("reads the flags word a real LibreOffice-written Setup record carries", () => { + // The grbit of the Setup record in a .xls LibreOffice wrote for a landscape sheet printed left-to-right: fLeftToRight and fUsePage set, fPortrait clear. + expect(unpackSetupFlags(0x0081)).toEqual({ + leftToRight: true, + portrait: false, + noPls: false, + noOrientation: false, + }); + }); +}); diff --git a/packages/xls-codec/src/biff/print-setup.ts b/packages/xls-codec/src/biff/print-setup.ts new file mode 100644 index 000000000..f93eb911b --- /dev/null +++ b/packages/xls-codec/src/biff/print-setup.ts @@ -0,0 +1,174 @@ +import type { PageSize } from "document-schema.js"; + +import { inchesToPoints, millimetresToPoints } from "../units"; + +// The Setup record's own page-format vocabulary ([MS-XLS] 2.4.257) and the WsBool flag that decides how to read it ([MS-XLS] 2.4.351), plus the paper-size code table Setup addresses paper through. This is the one place that vocabulary is packed or unpacked -- workbook/sheet.ts's readPrintSettings unpacks it on read, workbook/sheet-writer.ts's print-record writers pack it on write -- so the two directions cannot silently disagree about which bit means what, exactly as biff/xf-colors.ts does for the XF payload's own border/fill layout. +// +// BIFF8 spreads one sheet's print settings across nine records in the worksheet substream (Setup, the four margins, PrintGrid, PrintRowCol, and the two page-break records) plus, in the GLOBALS substream, two built-in defined names carrying the print range and the repeated header bands (see workbook/print-names.ts). This module covers the vocabulary of the first group; the record framing itself stays with the readers and writers that walk the substream. + +/** Setup.grbit's own bit assignments ([MS-XLS] 2.4.257's fields A through L, in the order that section lists them: fLeftToRight, fPortrait, fNoPls, fNoColor, fDraft, fNotes, fNoOrient, fUsePage, unused1, fEndNotes, iErrors (2 bits), reserved (4 bits)). Only the four this package acts on are named. */ +const SETUP_FLAG_LEFT_TO_RIGHT = 0x0001; +const SETUP_FLAG_PORTRAIT = 0x0002; +const SETUP_FLAG_NO_PLS = 0x0004; +const SETUP_FLAG_NO_ORIENT = 0x0040; + +/** WsBool's own fFitToPage bit -- field G of [MS-XLS] 2.4.351, the ninth bit of its single 16-bit field (fShowAutoBreaks, reserved1 (3 bits), fDialog, fApplyStyles, fRowSumsBelow, fColSumsRight, fFitToPage, ...). */ +export const WSBOOL_FLAG_FIT_TO_PAGE = 0x0100; + +/** + * The Setup record's fields, as this package reads and writes them. + * + * `noPls` is carried rather than resolved away, because [MS-XLS] 2.4.257 makes it govern whether five of the others mean anything at all: "A bit that specifies whether the iPaperSize, iScale, iRes, iVRes, iCopies, fNoOrient, and fPortrait data are undefined and ignored." A reader that ignored it would confidently report a paper size and a scale percentage the file itself declares meaningless. + */ +export interface SetupFields { + /** iPaperSize: an index into the paper-size code table below. Undefined and to be ignored when `noPls` is true. */ + readonly paperCode: number; + /** iScale: the print scale as a whole percentage ("if the value is 107 then the scaling factor is 107%"). Undefined and to be ignored when `noPls` is true. */ + readonly scalePercent: number; + /** iFitWidth: how many pages wide the sheet is fitted to. 0 is [MS-XLS]'s own "use as many pages as necessary". Meaningful only when WsBool's fFitToPage is set. */ + readonly fitWidth: number; + /** iFitHeight: the same for the sheet's height. */ + readonly fitHeight: number; + /** fLeftToRight: pages printed left-to-right first (true) or top-to-bottom first (false). */ + readonly leftToRight: boolean; + /** fPortrait. Undefined and to be ignored when `noPls` or `noOrientation` is true -- in the latter case [MS-XLS] states the sheet prints portrait regardless. */ + readonly portrait: boolean; + /** fNoPls: the paper size, scale, resolution, copy count, and both orientation bits are undefined. */ + readonly noPls: boolean; + /** fNoOrient: "whether the paper orientation is set" -- when true, fPortrait is undefined and "Pages are printed using portrait mode". */ + readonly noOrientation: boolean; +} + +/** Packs a SetupFields' own four flags back into the record's grbit word. Every bit this package does not model is written as zero, which is what [MS-XLS] 2.4.257's own reserved field requires and what its remaining optional behaviours (black-and-white, draft quality, comment printing, error rendering, a custom starting page number) default to. */ +export function packSetupFlags(fields: SetupFields): number { + return ( + (fields.leftToRight ? SETUP_FLAG_LEFT_TO_RIGHT : 0) | + (fields.portrait ? SETUP_FLAG_PORTRAIT : 0) | + (fields.noPls ? SETUP_FLAG_NO_PLS : 0) | + (fields.noOrientation ? SETUP_FLAG_NO_ORIENT : 0) + ); +} + +/** The read-side inverse of packSetupFlags: the four flags this package acts on, taken out of the record's grbit word. */ +export function unpackSetupFlags(grbit: number): { + leftToRight: boolean; + portrait: boolean; + noPls: boolean; + noOrientation: boolean; +} { + return { + leftToRight: (grbit & SETUP_FLAG_LEFT_TO_RIGHT) !== 0, + portrait: (grbit & SETUP_FLAG_PORTRAIT) !== 0, + noPls: (grbit & SETUP_FLAG_NO_PLS) !== 0, + noOrientation: (grbit & SETUP_FLAG_NO_ORIENT) !== 0, + }; +} + +/** + * The page size a Setup record's own iPaperSize and orientation flags describe, or undefined when the code is not one this package maps. + * + * A paper code names the sheet's paper in its PORTRAIT dimensions regardless of how the sheet actually prints, so the orientation flags decide whether those dimensions are used as-is or transposed. [MS-XLS] 2.4.257 defines that in two steps: fNoOrient set means "Pages are printed using portrait mode" outright, and only when it is clear does fPortrait itself select portrait (1) or landscape (0). + */ +export function pageSizeFromSetup(fields: SetupFields): PageSize | undefined { + const portraitSize = PAPER_SIZE_BY_CODE.get(fields.paperCode); + if (portraitSize === undefined) { + return undefined; + } + const printsPortrait = fields.noOrientation || fields.portrait; + return printsPortrait + ? portraitSize + : { widthPt: portraitSize.heightPt, heightPt: portraitSize.widthPt }; +} + +/** How close two page dimensions must be, in points, to count as the same paper. Half a point is about 0.18mm -- far below any real paper-size difference, and wide enough to absorb the hundredth-of-a-point rounding the table below applies and the unit conversions a page size picks up crossing between codecs. The same tolerance ooxml.js's own pageSizeToPaperSizeCode uses for the identical decision on xlsx's paperSize attribute. */ +const PAPER_SIZE_TOLERANCE_PT = 0.5; + +/** How a Setup record can name a given page size: the paper code, and whether the sheet must be declared portrait or landscape for that code's own portrait dimensions to come out as the size asked for. */ +export interface PaperSelection { + readonly code: number; + readonly portrait: boolean; +} + +/** + * The write-side inverse of pageSizeFromSetup: the paper code and orientation whose resolved page size matches this one, or undefined when no code in the table does. + * + * Undefined is a real answer with no fallback behind it. Unlike xlsx's own pageSetup element, which can state an explicit paperWidth/paperHeight pair when no code fits, [MS-XLS] 2.4.257's Setup record addresses paper only by code -- its own escape hatch for a size outside the table is a printer-defined custom size carried in a separate Pls record ([MS-XLS] 2.4.199), a printer driver's opaque DEVMODE blob rather than a pair of dimensions any reader could recover the size from. So a page size no code names genuinely cannot be written; the caller refuses rather than silently substituting a paper the document never asked for. + * + * A portrait match is preferred over a landscape one wherever both exist, which is what keeps the choice deterministic for the handful of codes in the table that are each other's transpose (US Tabloid 11x17in and US Ledger 17x11in are the same sheet of paper entered twice, once each way round). + */ +export function paperSelectionFor( + pageSize: PageSize, +): PaperSelection | undefined { + for (const [code, paper] of PAPER_SIZE_BY_CODE) { + if ( + matches(pageSize.widthPt, paper.widthPt) && + matches(pageSize.heightPt, paper.heightPt) + ) { + return { code, portrait: true }; + } + } + for (const [code, paper] of PAPER_SIZE_BY_CODE) { + if ( + matches(pageSize.widthPt, paper.heightPt) && + matches(pageSize.heightPt, paper.widthPt) + ) { + return { code, portrait: false }; + } + } + return undefined; +} + +function matches(a: number, b: number): boolean { + return Math.abs(a - b) <= PAPER_SIZE_TOLERANCE_PT; +} + +/** Rounded to hundredths of a point, which is how document-schema.js spells its own PAGE_SIZE_A4 (595.28 x 841.89 pt, the same 210 x 297 mm converted the same way). Rounding here rather than carrying the full conversion is what makes a code-9 page size read out of a real file EQUAL that shared constant instead of merely being within a rounding error of it. */ +function roundToHundredths(value: number): number { + return Math.round(value * 100) / 100; +} + +function inchPaper(widthIn: number, heightIn: number): PageSize { + return { + widthPt: roundToHundredths(inchesToPoints(widthIn)), + heightPt: roundToHundredths(inchesToPoints(heightIn)), + }; +} + +function millimetrePaper(widthMm: number, heightMm: number): PageSize { + return { + widthPt: roundToHundredths(millimetresToPoints(widthMm)), + heightPt: roundToHundredths(millimetresToPoints(heightMm)), + }; +} + +/** + * Setup.iPaperSize's own code table ([MS-XLS] 2.4.257), restricted to the codes whose entry states a real, unambiguous sheet size in inches or millimetres, and with each size derived from those stated dimensions rather than from a table of pre-converted points. + * + * The full enumeration runs to 118 entries, most of them envelopes, rotated variants, and regional stationery sizes; the ones here are the office paper sizes a spreadsheet is realistically printed on, entered exactly as [MS-XLS]'s own table names them. A code outside this table -- including 0 and everything at 256 or above, which the spec reserves for "custom printer paper sizes" no reader can resolve without the printer's own Pls record -- resolves to no page size at all, and content.ts falls back to its documented default rather than guessing a size the file never stated. + */ +const PAPER_SIZE_BY_CODE: ReadonlyMap = new Map< + number, + PageSize +>([ + [1, inchPaper(8.5, 11)], // US Letter 8 1/2 x 11 in + [2, inchPaper(8.5, 11)], // US Letter Small 8 1/2 x 11 in + [3, inchPaper(11, 17)], // US Tabloid 11 x 17 in + [4, inchPaper(17, 11)], // US Ledger 17 x 11 in + [5, inchPaper(8.5, 14)], // US Legal 8 1/2 x 14 in + [6, inchPaper(5.5, 8.5)], // US Statement 5 1/2 x 8 1/2 in + [7, inchPaper(7.25, 10.5)], // US Executive 7 1/4 x 10 1/2 in + [8, millimetrePaper(297, 420)], // A3 297 x 420 mm + [9, millimetrePaper(210, 297)], // A4 210 x 297 mm + [10, millimetrePaper(210, 297)], // A4 Small 210 x 297 mm + [11, millimetrePaper(148, 210)], // A5 148 x 210 mm + [12, millimetrePaper(250, 354)], // B4 (JIS) 250 x 354 + [13, millimetrePaper(182, 257)], // B5 (JIS) 182 x 257 mm + [14, inchPaper(8.5, 13)], // Folio 8 1/2 x 13 in + [15, millimetrePaper(215, 275)], // Quarto 215 x 275 mm + [16, inchPaper(10, 14)], // 10 x 14 in + [17, inchPaper(11, 17)], // 11 x 17 in + [18, inchPaper(8.5, 11)], // US Note 8 1/2 x 11 in + [42, millimetrePaper(250, 353)], // B4 (ISO) 250 x 353 mm + [66, millimetrePaper(420, 594)], // A2 420 x 594 mm + [70, millimetrePaper(105, 148)], // A6 105 x 148 mm +]); diff --git a/packages/xls-codec/src/biff/record-types.ts b/packages/xls-codec/src/biff/record-types.ts index c76a6cf24..23f1cc02b 100644 --- a/packages/xls-codec/src/biff/record-types.ts +++ b/packages/xls-codec/src/biff/record-types.ts @@ -31,8 +31,10 @@ export const RECORD_FILEPASS = 0x002f; 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; -/** The workbook's custom colour table ([MS-XLS] 2.4.204): a fixed 56-entry override of the default palette every icv 8-63 an XF's fill/border colour fields name resolves through when this record is absent. */ +/** The workbook's custom colour table ([MS-XLS] 2.4.188): a fixed 56-entry override of the default palette every icv 8-63 an XF's fill/border colour fields name resolves through when this record is absent. */ export const RECORD_PALETTE = 0x0092; +/** A defined name ([MS-XLS] 2.4.150). Read and written only for the two BUILT-IN names a sheet's print settings live in -- Print_Area and Print_Titles; see workbook/print-names.ts. */ +export const RECORD_LBL = 0x0018; // --- Worksheet substream --- @@ -49,6 +51,42 @@ export const RECORD_DEFAULTROWHEIGHT = 0x0225; /** Merged cell ranges ([MS-XLS] 2.4.168). */ export const RECORD_MERGECELLS = 0x00e5; +// --- Print settings: the worksheet substream's own GLOBALS and PAGESETUP productions ([MS-XLS] 2.1.7.20.6's Common Productions), the records a sheet's page setup lives in. `GLOBALS = CalcMode CalcCount CalcRefMode CalcIter CalcDelta CalcSaveRecalc PrintRowCol PrintGrid GridSet Guts DefaultRowHeight WsBool [Sync] [LPr] [HorizontalPageBreaks] [VerticalPageBreaks]`, and `PAGESETUP = Header Footer HCenter VCenter [LeftMargin] [RightMargin] [TopMargin] [BottomMargin] [Pls *Continue] [Setup]`. The remaining half of a sheet's print settings -- its print range and its repeated header rows/columns -- is not in the worksheet substream at all: it lives in the globals substream, as the built-in defined names RECORD_LBL above carries. + +/** Whether the row and column headers are printed ([MS-XLS] 2.4.203). */ +export const RECORD_PRINTROWCOL = 0x002a; +/** Whether the gridlines are printed ([MS-XLS] 2.4.202). */ +export const RECORD_PRINTGRID = 0x002b; +/** Sheet-level flags, of which only fFitToPage -- whether the sheet prints scaled to a page count rather than to a percentage -- is read or written ([MS-XLS] 2.4.351). */ +export const RECORD_WSBOOL = 0x0081; +/** Explicit row page breaks ([MS-XLS] 2.4.142). */ +export const RECORD_HORIZONTALPAGEBREAKS = 0x001b; +/** Explicit column page breaks ([MS-XLS] 2.4.343). */ +export const RECORD_VERTICALPAGEBREAKS = 0x001a; +/** The left page margin, an Xnum of inches ([MS-XLS] 2.4.151). */ +export const RECORD_LEFTMARGIN = 0x0026; +/** The right page margin ([MS-XLS] 2.4.219). */ +export const RECORD_RIGHTMARGIN = 0x0027; +/** The top page margin ([MS-XLS] 2.4.328). */ +export const RECORD_TOPMARGIN = 0x0028; +/** The bottom page margin ([MS-XLS] 2.4.27). */ +export const RECORD_BOTTOMMARGIN = 0x0029; +/** Paper size, print scale, fit-to-page counts, page order, and orientation ([MS-XLS] 2.4.257). */ +export const RECORD_SETUP = 0x00a1; + +// The calculation-state records the GLOBALS production requires ahead of PrintRowCol -- written, never read, and carrying nothing this schema models. They exist here because the production makes them mandatory and a real consumer notices when they are missing: see workbook/sheet-writer.ts's own writeCalculationStateRecords for what LibreOffice does to a worksheet substream whose first record is a print setting. + +/** The iteration count for iterative calculation ([MS-XLS] 2.4.31). */ +export const RECORD_CALCCOUNT = 0x000c; +/** The reference style, A1 or R1C1 ([MS-XLS] 2.4.36). */ +export const RECORD_CALCREFMODE = 0x000f; +/** Whether iterative calculation is enabled ([MS-XLS] 2.4.33). */ +export const RECORD_CALCITER = 0x0011; +/** The minimum value change iterative calculation continues for ([MS-XLS] 2.4.32). */ +export const RECORD_CALCDELTA = 0x0010; +/** Whether the workbook is recalculated before saving in manual calculation mode ([MS-XLS] 2.4.37). */ +export const RECORD_CALCSAVERECALC = 0x005f; + // --- The cell-value record family ([MS-XLS] 2.1.7.20.6's own CELL production) --- /** An empty cell carrying only formatting ([MS-XLS] 2.4.20). */ diff --git a/packages/xls-codec/src/biff/xf-colors.ts b/packages/xls-codec/src/biff/xf-colors.ts index 14249ad44..ab40c6fe7 100644 --- a/packages/xls-codec/src/biff/xf-colors.ts +++ b/packages/xls-codec/src/biff/xf-colors.ts @@ -13,7 +13,7 @@ import { import type { BlockCursor } from "./cursor"; -// The colour and border/fill vocabulary an XF record's trailing CellXF/StyleXF payload carries ([MS-XLS] 2.4.353), and the workbook-wide Palette record ([MS-XLS] 2.4.204) its icv fields resolve through. This is the one place the bit layout of that trailing payload's border/fill words is packed or unpacked -- workbook/globals.ts's readCellFormat unpacks it on read, biff/xf-writer.ts's writeCellXfRecord packs it on write, and both call into the same functions here rather than each carrying an independent copy of the layout. +// The colour and border/fill vocabulary an XF record's trailing CellXF/StyleXF payload carries ([MS-XLS] 2.4.353), and the workbook-wide Palette record ([MS-XLS] 2.4.188) its icv fields resolve through. This is the one place the bit layout of that trailing payload's border/fill words is packed or unpacked -- workbook/globals.ts's readCellFormat unpacks it on read, biff/xf-writer.ts's writeCellXfRecord packs it on write, and both call into the same functions here rather than each carrying an independent copy of the layout. // // Deliberately out of scope: alignment (the payload's own leading word) and per-cell fonts. See xls-codec's README, "Cell decoration" -- this package has never modelled a per-cell font, matching ooxml.js's own xlsx reader, and alignment is a separate, unclaimed gap this module does not touch. @@ -67,7 +67,7 @@ const FIXED_COLOR_TABLE: readonly Color[] = [ /** icv 8-63's own base offset: icv 8 is rgColor[0] of a Palette record (or the default table's own entry 0) -- [MS-XLS] "Icv"'s own colour-table layout. */ export const PALETTE_BASE_ICV = 0x08; -/** A Palette record's own fixed entry count ([MS-XLS] 2.4.204: "The value MUST be 56"). */ +/** A Palette record's own fixed entry count ([MS-XLS] 2.4.188: "The value MUST be 56"). */ export const PALETTE_ENTRY_COUNT = 56; /** The 56-entry default colour table icv 8-63 resolve through when no Palette record is present ([MS-XLS] "Icv"'s own default-red/green/blue columns), in icv order (index 0 = icv 8). Entries 0-7 duplicate the eight fixed colours above at their own icv+8 position -- the reason this package's writer allocates a fixed colour there rather than at icv 0-7 directly. */ diff --git a/packages/xls-codec/src/biff/xf-writer.ts b/packages/xls-codec/src/biff/xf-writer.ts index ffd129150..72550cc7a 100644 --- a/packages/xls-codec/src/biff/xf-writer.ts +++ b/packages/xls-codec/src/biff/xf-writer.ts @@ -16,7 +16,7 @@ import { } from "./xf-colors"; import type { Color } from "document-schema.js"; -// The formatting record family this writer emits: Font ([MS-XLS] 2.4.122), Format ([MS-XLS] 2.4.126), XF ([MS-XLS] 2.4.353) with its trailing CellXF ([MS-XLS] 2.4.353's own "Data" field, fStyle=0) or StyleXF (fStyle=1) payload, Style ([MS-XLS] 2.4.269), and Palette ([MS-XLS] 2.4.204). +// The formatting record family this writer emits: Font ([MS-XLS] 2.4.122), Format ([MS-XLS] 2.4.126), XF ([MS-XLS] 2.4.353) with its trailing CellXF ([MS-XLS] 2.4.353's own "Data" field, fStyle=0) or StyleXF (fStyle=1) payload, Style ([MS-XLS] 2.4.269), and Palette ([MS-XLS] 2.4.188). // // A cell XF's own fill/border decoration is modelled from document-schema.js's ContentSheetCell.background/borders: this writer's own reader now reads a cell's CellXF payload back (workbook/globals.ts's readCellFormat), so a real decoration round-trips -- see xls-codec's README, "Cell decoration". Alignment and per-cell fonts remain out of scope (the reader still does not read either back), so every CellXF/StyleXF field below still defaults to the same spec-legal, decoration-free values for anything writeCellXfRecord's caller does not supply: general alignment, bottom vertical alignment, no border, no fill -- exactly what a genuinely undecorated Excel-written cell also carries. The bit-level packing of the trailing payload's border/fill words lives in xf-colors.ts, shared with workbook/globals.ts's own unpacking of the identical layout on read. @@ -203,7 +203,7 @@ export function writeFormatRecord( return writeRecord(RECORD_FORMAT, data); } -/** Palette ([MS-XLS] 2.4.204): ccv (MUST be 56) then that many LongRGB colour entries -- write.ts's own colour-interning pass decides whether a workbook needs this record at all, and hands it exactly 56 colours (icv 8 first) when it does. */ +/** Palette ([MS-XLS] 2.4.188): ccv (MUST be 56) then that many LongRGB colour entries -- write.ts's own colour-interning pass decides whether a workbook needs this record at all, and hands it exactly 56 colours (icv 8 first) when it does. */ export function writePaletteRecord( colors: readonly Color[], ): Uint8Array { diff --git a/packages/xls-codec/src/content.test.ts b/packages/xls-codec/src/content.test.ts index 99c4ed087..8885f7ef7 100644 --- a/packages/xls-codec/src/content.test.ts +++ b/packages/xls-codec/src/content.test.ts @@ -20,13 +20,21 @@ import { RECORD_FORMAT, RECORD_FORMULA, RECORD_COLINFO, + RECORD_HORIZONTALPAGEBREAKS, RECORD_LABELSST, + RECORD_LBL, + RECORD_LEFTMARGIN, RECORD_MERGECELLS, RECORD_NUMBER, RECORD_PALETTE, + RECORD_PRINTGRID, + RECORD_PRINTROWCOL, RECORD_ROW, + RECORD_SETUP, RECORD_SST, RECORD_SUPBOOK, + RECORD_VERTICALPAGEBREAKS, + RECORD_WSBOOL, RECORD_XF, } from "./biff/record-types"; import { BiffFormatError } from "./biff/records"; @@ -987,3 +995,227 @@ describe("isXlsFile", () => { expect(isXlsFile(new Uint8Array([0xd0]))).toBe(false); }); }); + +describe("readXlsContent print settings", () => { + /** A Setup record ([MS-XLS] 2.4.257) with iPageStart, iRes, iVRes, numHdr, numFtr, and iCopies at values a real producer writes -- none of which this reader acts on. */ + function setupRecord(fields: { + paperCode: number; + scalePercent: number; + fitWidth: number; + fitHeight: number; + grbit: number; + }): Uint8Array { + return record(RECORD_SETUP, [ + ...u16(fields.paperCode), + ...u16(fields.scalePercent), + ...u16(0), + ...u16(fields.fitWidth), + ...u16(fields.fitHeight), + ...u16(fields.grbit), + ...u16(300), + ...u16(300), + ...f64(0.3), + ...f64(0.3), + ...u16(1), + ]); + } + + /** The built-in Print_Area Lbl ([MS-XLS] 2.4.150) for one sheet: fBuiltin, cch 1, the one-based itab, name character 0x06, then a PtgArea3d naming the range. */ + function printAreaRecord( + itab: number, + area: { + rowFirst: number; + rowLast: number; + colFirst: number; + colLast: number; + }, + ): Uint8Array { + const rgce = [ + 0x3b, + ...u16(0), + ...u16(area.rowFirst), + ...u16(area.rowLast), + ...u16(area.colFirst), + ...u16(area.colLast), + ]; + return record(RECORD_LBL, [ + ...u16(0x0020), + 0x00, + 0x01, + ...u16(rgce.length), + ...u16(0), + ...u16(itab), + ...u32(0), + 0x00, + 0x06, + ...rgce, + ]); + } + + function printSettingsOf( + sheetRecords: readonly Uint8Array[], + globals: readonly Uint8Array[] = [], + ) { + const bytes = xlsFile( + workbookStream({ + globals: [...xfTable(0), ...globals], + sheets: [{ name: "Sheet1", records: [...sheetRecords] }], + }), + ); + return readXlsContent(bytes).sheets[0]?.printSettings; + } + + it("falls back to Excel's own Normal preset for a sheet stating nothing", () => { + // Every record behind these is optional in [MS-XLS] 2.1.7.20.6's own PAGESETUP production, and a sheet nobody has set a page setup on carries none of them. + expect(printSettingsOf([])).toEqual({ + pageSize: { widthPt: 612, heightPt: 792 }, + margins: { topPt: 54, rightPt: 50.4, bottomPt: 54, leftPt: 50.4 }, + gridlines: false, + headers: false, + pageOrder: "downThenOver", + }); + }); + + it("falls back per field, keeping the one margin a sheet does state", () => { + expect( + printSettingsOf([record(RECORD_LEFTMARGIN, f64(1))])?.margins, + ).toEqual({ topPt: 54, rightPt: 50.4, bottomPt: 54, leftPt: 72 }); + }); + + it("resolves the page size, scale, gridlines, headers, and page order a sheet does state", () => { + expect( + printSettingsOf([ + setupRecord({ + paperCode: 9, + scalePercent: 80, + fitWidth: 1, + fitHeight: 1, + grbit: 0x0001, // fLeftToRight set, fPortrait clear + }), + record(RECORD_PRINTGRID, u16(1)), + record(RECORD_PRINTROWCOL, u16(1)), + ]), + ).toMatchObject({ + pageSize: { widthPt: 841.89, heightPt: 595.28 }, + gridlines: true, + headers: true, + pageOrder: "overThenDown", + scalePercent: 80, + }); + }); + + it("reads no paper size and no scale from a Setup record that disowns both", () => { + // [MS-XLS] 2.4.257's own fNoPls: "whether the iPaperSize, iScale, iRes, iVRes, iCopies, fNoOrient, and fPortrait data are undefined and ignored". + const settings = printSettingsOf([ + setupRecord({ + paperCode: 9, + scalePercent: 80, + fitWidth: 1, + fitHeight: 1, + grbit: 0x0004, // fNoPls + }), + ]); + + expect(settings?.pageSize).toEqual({ widthPt: 612, heightPt: 792 }); + expect(settings?.scalePercent).toBeUndefined(); + }); + + it("takes the fit-to-page counts, not the scale, when WsBool says fit-to-page", () => { + // A real producer writes both regardless of which is live, so reading both would report a scale and a page count that contradict each other. + const settings = printSettingsOf([ + record(RECORD_WSBOOL, u16(0x0100)), + setupRecord({ + paperCode: 1, + scalePercent: 80, + fitWidth: 2, + fitHeight: 3, + grbit: 0x0002, + }), + ]); + + expect(settings?.fitToPages).toEqual({ width: 2, height: 3 }); + expect(settings?.scalePercent).toBeUndefined(); + }); + + it("reports no fit-to-page at all when either count is the spec's own auto value", () => { + // [MS-XLS] 2.4.257: "The value 0 means use as many pages as necessary" -- an auto setting ContentSheetPrintSettings cannot express, both its counts being required and positive. A fabricated 1 would claim the sheet is pinned to one page along an axis the file left free. + const settings = printSettingsOf([ + record(RECORD_WSBOOL, u16(0x0100)), + setupRecord({ + paperCode: 1, + scalePercent: 100, + fitWidth: 1, + fitHeight: 0, + grbit: 0x0002, + }), + ]); + + expect(settings?.fitToPages).toBeUndefined(); + expect(settings?.scalePercent).toBeUndefined(); + }); + + it("reads both page-break records into manualBreaks", () => { + expect( + printSettingsOf([ + record(RECORD_HORIZONTALPAGEBREAKS, [ + ...u16(1), + ...u16(12), + ...u16(0), + ...u16(0xff), + ]), + record(RECORD_VERTICALPAGEBREAKS, [ + ...u16(1), + ...u16(5), + ...u16(0), + ...u16(0xffff), + ]), + ])?.manualBreaks, + ).toEqual({ rows: [12], columns: [5] }); + }); + + it("reads the print range from the globals substream's own built-in defined name", () => { + expect( + printSettingsOf( + [], + [ + printAreaRecord(1, { + rowFirst: 1, + rowLast: 5, + colFirst: 1, + colLast: 3, + }), + ], + )?.printRange, + ).toEqual({ startRow: 1, startColumn: 1, endRow: 5, endColumn: 3 }); + }); + + it("scopes a print name by its own BoundSheet8 position, not by position among the worksheets", () => { + // A print name's itab counts every sheet, including the chart sheets readXlsContent filters out before mapping. + const bytes = xlsFile( + workbookStream({ + globals: [ + ...xfTable(0), + printAreaRecord(2, { + rowFirst: 3, + rowLast: 4, + colFirst: 0, + colLast: 1, + }), + ], + sheets: [ + { name: "Chart", records: [], sheetType: 0x02 }, + { name: "Data", records: [] }, + ], + }), + ); + + const document = readXlsContent(bytes); + expect(document.sheets).toHaveLength(1); + expect(document.sheets[0]?.printSettings.printRange).toEqual({ + startRow: 3, + startColumn: 0, + endRow: 4, + endColumn: 1, + }); + }); +}); diff --git a/packages/xls-codec/src/content.ts b/packages/xls-codec/src/content.ts index 8311fd4a0..b70a3c1bc 100644 --- a/packages/xls-codec/src/content.ts +++ b/packages/xls-codec/src/content.ts @@ -16,6 +16,7 @@ import type { } from "document-schema.js"; import { assembleTree, PAGE_SIZE_LETTER } from "document-schema.js"; +import { pageSizeFromSetup } from "./biff/print-setup"; import { BOF_TYPE_WORKSHEET, RECORD_FILEPASS } from "./biff/record-types"; import { BiffFormatError, readRecords } from "./biff/records"; import { @@ -37,11 +38,14 @@ import { type SheetEntry, type WorkbookGlobals, } from "./workbook/globals"; +import type { SheetPrintNames } from "./workbook/print-names"; import { readSheetRecords, type RawCell, + type RawPrintSettings, type RawSheet, } from "./workbook/sheet"; +import { inchesToPoints } from "./units"; // The join between the BIFF8 record readers and document-schema.js's own spreadsheet vocabulary. // @@ -63,24 +67,94 @@ export type XlsContentDocument = Extract< const SHEET_TYPE_WORKSHEET = 0x00; /** - * Print settings this package emits rather than reads. + * Excel's own "Normal" page-setup preset, the per-field fallback for a print setting the file states nothing about. * - * ContentSheetPrintSettings makes pageSize, margins, gridlines, headers, and pageOrder REQUIRED, so a sheet cannot be produced without them, and BIFF8 spreads the real values across the Setup, LeftMargin/RightMargin/TopMargin/BottomMargin, PrintGrid, and PrintRowCol records plus a paper-size code table. None of those is read yet, so these are Excel's own documented "Normal" preset -- the same constants ooxml.js falls back to for an xlsx carrying no pageMargins element -- and they are honest defaults rather than the file's own settings. Reading the real ones is tracked as remaining scope rather than guessed at from unverified field offsets. + * ContentSheetPrintSettings makes pageSize, margins, gridlines, headers, and pageOrder REQUIRED, while [MS-XLS] 2.1.7.20.6's own PAGESETUP production makes every record behind them optional -- so a sheet whose page setup was never touched genuinely carries no Setup and no margin records, and something has to stand in. These are the values Excel itself calls Normal (top/bottom 0.75in, left/right 0.7in, on Letter paper, gridlines and row/column headers not printed, pages down-then-over), and the identical constants ooxml.js falls back to for an xlsx carrying no pageMargins element -- so the same untouched sheet reads the same either way. + * + * Each field falls back independently: a sheet that declares a left margin and nothing else keeps its real left margin and takes the preset for the other three, rather than the whole preset displacing the one value the file actually stated. */ -const POINTS_PER_INCH = 72; const DEFAULT_PRINT_SETTINGS: ContentSheetPrintSettings = { pageSize: PAGE_SIZE_LETTER, margins: { - topPt: 0.75 * POINTS_PER_INCH, - rightPt: 0.7 * POINTS_PER_INCH, - bottomPt: 0.75 * POINTS_PER_INCH, - leftPt: 0.7 * POINTS_PER_INCH, + topPt: inchesToPoints(0.75), + rightPt: inchesToPoints(0.7), + bottomPt: inchesToPoints(0.75), + leftPt: inchesToPoints(0.7), }, gridlines: false, headers: false, pageOrder: "downThenOver", }; +/** + * The iScale value that says "print at actual size", which is what ContentSheetPrintSettings already means by carrying no scalePercent at all. + * + * Setup's own iScale is a mandatory field of a mandatory record, with no spelling for "this sheet declares no scale" -- so an untouched sheet still states 100. Reporting that as an explicit scalePercent would put a field on every sheet of every workbook read, carrying nothing a consumer could act on that its absence does not already say, and would mean a document written with no scale came back with one. The two spellings render identically, so collapsing them onto the absent one is lossless in both directions; a scale that is genuinely anything else is reported exactly as the file states it. + */ +const ACTUAL_SIZE_SCALE_PERCENT = 100; + +/** + * The print settings a sheet's own records and its built-in print names state, with the Normal preset filling in what they do not. + * + * Two of BIFF8's own conditional rules are honoured rather than flattened. A Setup record whose fNoPls bit is set declares its own paper size and scale undefined ([MS-XLS] 2.4.257: "whether the iPaperSize, iScale, iRes, iVRes, iCopies, fNoOrient, and fPortrait data are undefined and ignored"), so neither is read from it -- the page size falls back to the preset and no scalePercent is reported, rather than a paper code the file itself disowns being resolved into a confident page size. And WsBool's own fFitToPage decides which of Setup's two mutually exclusive scaling fields is live: iFitWidth/iFitHeight when set, iScale when clear. Real producers write both regardless (confirmed against LibreOffice-written BIFF8, which carries iScale=100 alongside a real fit-to-page pair, and a real iScale alongside iFitWidth=iFitHeight=1), so reading both would report a scale and a page count that contradict each other. + */ +function mapPrintSettings( + raw: RawPrintSettings, + names: SheetPrintNames | undefined, +): ContentSheetPrintSettings { + const setup = raw.setup; + const usable = setup !== undefined && !setup.noPls; + const settings: ContentSheetPrintSettings = { + pageSize: + (usable ? pageSizeFromSetup(setup) : undefined) ?? + DEFAULT_PRINT_SETTINGS.pageSize, + margins: { + topPt: raw.marginsPt.top ?? DEFAULT_PRINT_SETTINGS.margins.topPt, + rightPt: raw.marginsPt.right ?? DEFAULT_PRINT_SETTINGS.margins.rightPt, + bottomPt: raw.marginsPt.bottom ?? DEFAULT_PRINT_SETTINGS.margins.bottomPt, + leftPt: raw.marginsPt.left ?? DEFAULT_PRINT_SETTINGS.margins.leftPt, + }, + gridlines: raw.printGridlines ?? DEFAULT_PRINT_SETTINGS.gridlines, + headers: raw.printHeaders ?? DEFAULT_PRINT_SETTINGS.headers, + // fLeftToRight is not conditioned on fNoPls: [MS-XLS] 2.4.257 lists exactly which fields that bit invalidates, and the page order is not among them. + pageOrder: setup?.leftToRight === true ? "overThenDown" : "downThenOver", + }; + + if (setup !== undefined && raw.fitToPage === true) { + // 0 is [MS-XLS] 2.4.257's own "use as many pages as necessary to print the columns/rows in the sheet", an auto setting ContentSheetPrintSettings.fitToPages cannot express -- both its counts are required and positive. A fit-to-page sheet with an auto axis therefore reports no fitToPages at all rather than a fabricated 1, which would claim the sheet is pinned to a single page along an axis the file left free. + if (setup.fitWidth > 0 && setup.fitHeight > 0) { + settings.fitToPages = { + width: setup.fitWidth, + height: setup.fitHeight, + }; + } + } else if ( + usable && + setup.scalePercent > 0 && + setup.scalePercent !== ACTUAL_SIZE_SCALE_PERCENT + ) { + settings.scalePercent = setup.scalePercent; + } + + if (raw.rowBreaks.length > 0 || raw.columnBreaks.length > 0) { + settings.manualBreaks = { + rows: [...raw.rowBreaks], + columns: [...raw.columnBreaks], + }; + } + + if (names?.printRange !== undefined) { + settings.printRange = names.printRange; + } + if (names?.repeatRows !== undefined) { + settings.repeatRows = names.repeatRows; + } + if (names?.repeatColumns !== undefined) { + settings.repeatColumns = names.repeatColumns; + } + return settings; +} + /** * Reads a .xls file's bytes into a ContentDocument. * @@ -104,9 +178,13 @@ export function readXlsContent( ); } const globals = readWorkbookGlobals(globalsSubstream.records); + // Indexed before filtering, not after: a print name's own itab is a position in the FULL BoundSheet8 collection, so a workbook whose first sheet is a chart would mis-key every print name if the index came from the filtered list. const sheets = globals.sheets - .filter((entry) => entry.sheetType === SHEET_TYPE_WORKSHEET) - .map((entry) => readSheet(entry, substreams, globals)); + .map((entry, sheetIndex) => ({ entry, sheetIndex })) + .filter(({ entry }) => entry.sheetType === SHEET_TYPE_WORKSHEET) + .map(({ entry, sheetIndex }) => + readSheet(entry, sheetIndex, substreams, globals), + ); // Absent when the container carries no "\x05SummaryInformation" stream at all -- a valid BIFF8 workbook need not have one -- and mapped from it through summaryInformationToLayoutMetadata (see src/metadata.ts) otherwise. return { kind: "spreadsheet", @@ -130,6 +208,7 @@ export function readXls(bytes: Uint8Array): DocumentTree { */ function readSheet( entry: SheetEntry, + sheetIndex: number, substreams: readonly Substream[], globals: WorkbookGlobals, ): ContentSheet { @@ -138,9 +217,14 @@ function readSheet( candidate.offset === entry.bofPosition && candidate.documentType === BOF_TYPE_WORKSHEET, ); + const emptyPrint: RawPrintSettings = { + marginsPt: {}, + rowBreaks: [], + columnBreaks: [], + }; const raw: RawSheet = substream === undefined - ? { cells: [], rows: [], columns: [], merges: [] } + ? { cells: [], rows: [], columns: [], merges: [], print: emptyPrint } : readSheetRecords(substream.records, globals.sharedStrings, { sheets: globals.sheets, sheetRanges: globals.sheetRanges, @@ -151,7 +235,11 @@ function readSheet( columns: mapColumns(raw), rows: mapRows(raw), images: [], - printSettings: DEFAULT_PRINT_SETTINGS, + // The sheet index a print name is scoped to is its BoundSheet8 position -- the index into globals.sheets, before the worksheet-only filter readXlsContent applies -- not its position among the sheets that survive that filter. + printSettings: mapPrintSettings( + raw.print, + globals.printNames.get(sheetIndex), + ), }; } diff --git a/packages/xls-codec/src/units.ts b/packages/xls-codec/src/units.ts index 9e5439e02..845c8b831 100644 --- a/packages/xls-codec/src/units.ts +++ b/packages/xls-codec/src/units.ts @@ -1,4 +1,4 @@ -// The two geometry units a BIFF8 worksheet records its grid in, converted to the points document-schema.js's own ContentSheetRow/ContentSheetColumn use. +// The geometry units a BIFF8 worksheet records its grid and its page setup in, converted to the points document-schema.js's own ContentSheetRow/ContentSheetColumn/ContentSheetPrintSettings use. /** Points per inch, and the pixel grid Excel's column-width formula is defined against (96 px/inch, its screen-rendering assumption). */ const POINTS_PER_INCH = 72; @@ -7,6 +7,9 @@ const PIXELS_PER_INCH = 96; /** A twip is a twentieth of a point; a Row record's miyRw is in twips ([MS-XLS] 2.4.221). */ const TWIPS_PER_POINT = 20; +/** The inch's own definition in millimetres, which is what turns [MS-XLS] 2.4.257's metric paper sizes into points. */ +const MILLIMETRES_PER_INCH = 25.4; + /** * The "maximum digit width": the widest rendered width, in pixels, of the digits 0-9 in the workbook's Normal-style font, which is the unit a column width is expressed in multiples of. * @@ -19,6 +22,21 @@ export function twipsToPoints(twips: number): number { return twips / TWIPS_PER_POINT; } +/** A margin record's own Xnum ([MS-XLS] 2.4.151 and its three siblings), in inches, as points. */ +export function inchesToPoints(inches: number): number { + return inches * POINTS_PER_INCH; +} + +/** The inverse, for writing a margin back out. Exact for every value inchesToPoints produces, since the conversion is a single multiplication by an integer. */ +export function pointsToInches(points: number): number { + return points / POINTS_PER_INCH; +} + +/** A paper size [MS-XLS] 2.4.257's own code table states in millimetres, as points. */ +export function millimetresToPoints(millimetres: number): number { + return (millimetres / MILLIMETRES_PER_INCH) * POINTS_PER_INCH; +} + /** * A ColInfo record's coldx -- a width in 1/256ths of a character width ([MS-XLS] 2.4.53) -- as points. * diff --git a/packages/xls-codec/src/workbook/globals-writer.ts b/packages/xls-codec/src/workbook/globals-writer.ts index a4d9a175e..0a121c5c1 100644 --- a/packages/xls-codec/src/workbook/globals-writer.ts +++ b/packages/xls-codec/src/workbook/globals-writer.ts @@ -7,7 +7,9 @@ import { RECORD_BOF, RECORD_BOUNDSHEET8, RECORD_EOF, + RECORD_EXTERNSHEET, RECORD_SST, + RECORD_SUPBOOK, } from "../biff/record-types"; import { concatRecords, writeRecord } from "../biff/record-writer"; import { @@ -23,6 +25,7 @@ import { writeStyleRecord, writeStyleXfRecord, } from "../biff/xf-writer"; +import { writePrintNameRecords, type PrintNamePlanEntry } from "./print-names"; // The workbook globals substream ([MS-XLS] 2.1.7.20.3), write side: everything belonging to the workbook rather than to one sheet -- the font, format, and cell-style/cell-format XF tables every cell's own formatting resolves through, the shared string table, and the BoundSheet8 entry naming each sheet's own substream. Grouped and ordered to satisfy [MS-XLS] 2.1.7.20.3's own FORMATTING production (Font*, Format*, XFS, STYLES) ahead of the BoundSheet8 entries and the closing EOF -- see this package's README for exactly which globals-substream records this writer emits and which it deliberately omits (Window1, CodePage, the interface/calc-state record family, and so on: real content, not UI or interoperability bookkeeping). // @@ -85,6 +88,8 @@ export interface WorkbookGlobalsPlan { readonly sharedStringTotalCount: number; /** The workbook's own custom colour table (56 entries, icv 8 first), when write.ts's own palette-interning pass decided the workbook needs one -- undefined when every decoration colour the workbook's cells use already matches the fixed default table, in which case no Palette record is written at all and those colours resolve through the default table instead ([MS-XLS] "Icv"'s own documented fallback). */ readonly paletteColors?: readonly Color[]; + /** The built-in Print_Area/Print_Titles defined names the workbook's sheets declare, one Lbl record each. Empty when no sheet declares a print range or a repeated header band, in which case no SupBook, ExternSheet, or Lbl record is written at all -- exactly like Palette above, a workbook that needs none stays as minimal as it always was. */ + readonly printNames: readonly PrintNamePlanEntry[]; } export interface WorkbookGlobalsBuild { @@ -106,6 +111,33 @@ function writeBoundSheet8Placeholder(name: string): Uint8Array { return writeRecord(RECORD_BOUNDSHEET8, data); } +/** [MS-XLS] 2.4.271's own cch table: the value a self-referencing SupBook -- this workbook itself, rather than another workbook, a DDE/OLE data source, or an add-in -- carries in place of a virtual path. The one kind of supporting link this package writes, and the one kind workbook/globals.ts's own reader resolves a 3D reference through. */ +const SUPBOOK_SELF_REFERENCING_CCH = 0x0401; + +/** SupBook ([MS-XLS] 2.4.271), self-referencing: a two-byte sheet count then that cch sentinel, and nothing else -- the virtPath/rgst payload a real external link would carry has no meaning for a link that names this same workbook. */ +function writeSupBookRecord(sheetCount: number): Uint8Array { + return writeRecord( + RECORD_SUPBOOK, + new RecordBuilder() + .u16(sheetCount) + .u16(SUPBOOK_SELF_REFERENCING_CCH) + .build(), + ); +} + +/** + * ExternSheet ([MS-XLS] 2.4.106): a two-byte cXTI then that many XTI structures ([MS-XLS] 2.5.344), each an iSupBook and a signed itabFirst/itabLast sheet-scope pair -- the write-side mirror of workbook/globals.ts's own readSheetRanges. + * + * One XTI per sheet, each scoped to that one sheet and pointing at the single self-referencing SupBook above, so a print name's own PtgArea3d can name its sheet by using that sheet's index as its ixti. The 3D reference machinery exists here purely because [MS-XLS] gives a defined name no other way to say which sheet its range is on. + */ +function writeExternSheetRecord(sheetCount: number): Uint8Array { + const builder = new RecordBuilder().u16(sheetCount); + for (let sheetIndex = 0; sheetIndex < sheetCount; sheetIndex += 1) { + builder.u16(0).u16(sheetIndex).u16(sheetIndex); + } + return writeRecord(RECORD_EXTERNSHEET, builder.build()); +} + /** SST ([MS-XLS] 2.4.265): a total reference count, a unique-string count, then that many XLUnicodeRichExtendedStrings, packed with no offsets -- the write-side mirror of workbook/globals.ts's own readSharedStrings. */ function writeSstRecord( strings: readonly string[], @@ -184,6 +216,15 @@ export function buildWorkbookGlobals( push(writeBoundSheet8Placeholder(name)); } + // [MS-XLS] 2.1.7.20.3's own WORKBOOKCONTENT production places `*SUPBOOK *LBL` between the BoundSheet8 entries and SHAREDSTRINGS, with `SUPBOOK = SupBook [*ExternName *(XCT *CRN)] [ExternSheet] *Continue` -- so the supporting link and its ExternSheet come first, then the defined names whose 3D references resolve through it. Written only when there is a print name to write: a workbook with no print range and no repeated header band needs no defined name, and therefore no supporting link for one to reference. + if (plan.printNames.length > 0) { + push(writeSupBookRecord(plan.sheetNames.length)); + push(writeExternSheetRecord(plan.sheetNames.length)); + for (const record of writePrintNameRecords(plan.printNames)) { + push(record); + } + } + if (plan.sharedStrings.length > 0) { push(writeSstRecord(plan.sharedStrings, plan.sharedStringTotalCount)); } diff --git a/packages/xls-codec/src/workbook/globals.ts b/packages/xls-codec/src/workbook/globals.ts index 13d23cc58..0ef0bcf3d 100644 --- a/packages/xls-codec/src/workbook/globals.ts +++ b/packages/xls-codec/src/workbook/globals.ts @@ -26,6 +26,7 @@ import { type XfDecorationFields, } from "../biff/xf-colors"; import { BUILTIN_NUMBER_FORMATS } from "excel-number-format"; +import { readPrintNames, type SheetPrintNames } from "./print-names"; // The workbook globals substream ([MS-XLS] 2.1.7.20.3): everything that belongs to the workbook rather than to one sheet -- which sheets exist and in what order, the shared string table every string cell indexes into, the number-format and cell-format tables every numeric cell's meaning depends on, and which date epoch the whole file counts serials from. https://learn.microsoft.com/en-us/openspecs/office_file_formats/ms-xls/ca4c1748-8729-4a93-abb9-4602b3a01fb1 // @@ -74,9 +75,15 @@ export interface WorkbookGlobals { */ readonly sheetRanges: readonly (SheetRange | undefined)[]; /** - * The workbook's own custom colour table, from a Palette record ([MS-XLS] 2.4.204) -- 56 entries, index 0 being icv 8. Undefined when the file carries no Palette record at all, which is common: a real file relying purely on the fixed default 8-colour-plus-56-entry table (this package's own `resolveIcvColor` falls back to that same default table, matching [MS-XLS] "Icv"'s own documented fallback) never needs one. + * The workbook's own custom colour table, from a Palette record ([MS-XLS] 2.4.188) -- 56 entries, index 0 being icv 8. Undefined when the file carries no Palette record at all, which is common: a real file relying purely on the fixed default 8-colour-plus-56-entry table (this package's own `resolveIcvColor` falls back to that same default table, matching [MS-XLS] "Icv"'s own documented fallback) never needs one. */ readonly palette: readonly Color[] | undefined; + /** + * The print range and repeated header bands each sheet's own built-in Print_Area/Print_Titles defined names declare, keyed by zero-based sheet index -- see workbook/print-names.ts for why a sheet's print settings are split between here and its own substream. + * + * A sheet with no entry declares neither, which is the common case: a workbook nobody has set a print area on carries no Lbl record at all. + */ + readonly printNames: ReadonlyMap; } /** [MS-XLS] 2.4.28's own hsState values; 0x01 is Hidden and 0x02 Very Hidden. */ @@ -156,6 +163,8 @@ export function readWorkbookGlobals( date1904, sheetRanges, palette, + // Walked as its own pass over the same records rather than folded into the switch above: an Lbl's meaning depends only on the record itself, and keeping the whole built-in-print-name vocabulary in one module is what stops it leaking into this reader's general record loop. + printNames: readPrintNames(records), }; } @@ -259,7 +268,7 @@ function readCellFormat(record: RecordGroup): CellFormat { } /** - * Palette ([MS-XLS] 2.4.204): ccv, a signed colour count the spec states "MUST be 56", then that many LongRGB entries -- the write-side mirror is xf-writer.ts's own writePaletteRecord. + * Palette ([MS-XLS] 2.4.188): ccv, a signed colour count the spec states "MUST be 56", then that many LongRGB entries -- the write-side mirror is xf-writer.ts's own writePaletteRecord. * * A ccv that is not 56 is refused rather than honoured. This reader resolves every icv 8-63 positionally through this table, so a short (or zero, or negative) one does not degrade gracefully: every colour past its end silently becomes unresolvable, and a workbook's fills and borders all vanish at once with nothing to say why. A file declaring a count its own spec forbids is malformed, and saying so is the only honest answer. */ @@ -268,7 +277,7 @@ function readPalette(record: RecordGroup): readonly Color[] { const count = cursor.i16(); if (count !== PALETTE_ENTRY_COUNT) { throw new BiffFormatError( - `Palette declares ${count} colour entries, but [MS-XLS] 2.4.204's own ccv field MUST be ${PALETTE_ENTRY_COUNT}`, + `Palette declares ${count} colour entries, but [MS-XLS] 2.4.188's own ccv field MUST be ${PALETTE_ENTRY_COUNT}`, ); } const colors: Color[] = []; diff --git a/packages/xls-codec/src/workbook/print-names.test.ts b/packages/xls-codec/src/workbook/print-names.test.ts new file mode 100644 index 000000000..613629165 --- /dev/null +++ b/packages/xls-codec/src/workbook/print-names.test.ts @@ -0,0 +1,166 @@ +import { describe, expect, it } from "vitest"; + +import { readRecords } from "../biff/records"; +import { groupRecords, type RecordGroup } from "../biff/substreams"; +import { concat } from "../test-support/biff"; +import { + printNameEntriesFor, + readPrintNames, + writePrintNameRecords, +} from "./print-names"; + +// The two fixtures below are the exact Lbl records, byte for byte, out of a .xls LibreOffice produced from a hand-authored .fods declaring a print range of B2:D6, one repeated header column, and two repeated header rows. They are stated as literal bytes rather than built by this package's own writer so that what the reader is checked against is a real producer's encoding, not this package's agreement with itself. + +// Field offsets into those fixtures, counted from the front of the record INCLUDING its own four-byte type/size framing, so a test can mutate one field of a real producer's record and leave the rest of it exactly as that producer wrote it. +const OFFSET_GRBIT = 4; +const OFFSET_ITAB = 12; +const OFFSET_BUILTIN_NAME = 19; +const OFFSET_RGCE = 20; + +/** Lbl for Print_Area: fBuiltin, cch 1, cce 11, itab 1, name character 0x06, then one PtgArea3d naming rows 1-5 and columns 1-3 (B2:D6). */ +const LIBREOFFICE_PRINT_AREA = new Uint8Array([ + 0x18, 0x00, 0x1b, 0x00, 0x20, 0x00, 0x00, 0x01, 0x0b, 0x00, 0x00, 0x00, 0x01, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x06, 0x3b, 0x00, 0x00, 0x01, 0x00, 0x05, + 0x00, 0x01, 0x00, 0x03, 0x00, +]); + +/** Lbl for Print_Titles: the same prefix with name character 0x07 and cce 27, whose rgce is a PtgMemFunc wrapping a column band ($A:$A -- every row, column 0) and a row band ($1:$2 -- rows 0-1, every column) joined by PtgUnion, with a trailing PtgParen. */ +const LIBREOFFICE_PRINT_TITLES = new Uint8Array([ + 0x18, 0x00, 0x2b, 0x00, 0x20, 0x00, 0x00, 0x01, 0x1b, 0x00, 0x00, 0x00, 0x01, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x07, 0x29, 0x17, 0x00, 0x3b, 0x00, 0x00, + 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0x00, 0x00, 0x3b, 0x00, 0x00, 0x00, 0x00, + 0x01, 0x00, 0x00, 0x00, 0xff, 0x00, 0x10, 0x15, +]); + +/** What this package writes for the same pair of repeated bands: the record above with its trailing PtgParen dropped, and its own size and cce each one byte shorter to match. PtgParen is a pure display token restating parentheses a formula's author typed, which this writer has no reason to emit. */ +const PRINT_TITLES_WITHOUT_PAREN = new Uint8Array([ + 0x18, 0x00, 0x2a, 0x00, 0x20, 0x00, 0x00, 0x01, 0x1a, 0x00, 0x00, 0x00, 0x01, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x07, 0x29, 0x17, 0x00, 0x3b, 0x00, 0x00, + 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0x00, 0x00, 0x3b, 0x00, 0x00, 0x00, 0x00, + 0x01, 0x00, 0x00, 0x00, 0xff, 0x00, 0x10, +]); + +function groupsOf( + ...records: readonly Uint8Array[] +): readonly RecordGroup[] { + return groupRecords(readRecords(concat(...records))); +} + +describe("readPrintNames", () => { + it("reads a real LibreOffice-written Print_Area into the range it names", () => { + expect(readPrintNames(groupsOf(LIBREOFFICE_PRINT_AREA))).toEqual( + new Map([ + [ + 0, + { + printRange: { + startRow: 1, + startColumn: 1, + endRow: 5, + endColumn: 3, + }, + }, + ], + ]), + ); + }); + + it("reads a real LibreOffice-written Print_Titles into both repeated bands", () => { + expect(readPrintNames(groupsOf(LIBREOFFICE_PRINT_TITLES))).toEqual( + new Map([ + [ + 0, + { + repeatRows: { start: 0, end: 1 }, + repeatColumns: { start: 0, end: 0 }, + }, + ], + ]), + ); + }); + + it("joins both names for the same sheet into one entry", () => { + expect( + readPrintNames( + groupsOf(LIBREOFFICE_PRINT_AREA, LIBREOFFICE_PRINT_TITLES), + ).get(0), + ).toEqual({ + printRange: { startRow: 1, startColumn: 1, endRow: 5, endColumn: 3 }, + repeatRows: { start: 0, end: 1 }, + repeatColumns: { start: 0, end: 0 }, + }); + }); + + it("keys a name by its own itab, one-based in the record and zero-based here", () => { + const onSheetThree = new Uint8Array(LIBREOFFICE_PRINT_AREA); + onSheetThree[OFFSET_ITAB] = 0x03; + expect([...readPrintNames(groupsOf(onSheetThree)).keys()]).toEqual([2]); + }); + + it("ignores a name that is not built in", () => { + const userDefined = new Uint8Array(LIBREOFFICE_PRINT_AREA); + userDefined[OFFSET_GRBIT] = 0x00; // fBuiltin clear + expect(readPrintNames(groupsOf(userDefined)).size).toBe(0); + }); + + it("ignores a workbook-scoped name, which a print area never is", () => { + const workbookScoped = new Uint8Array(LIBREOFFICE_PRINT_AREA); + workbookScoped[OFFSET_ITAB] = 0x00; + expect(readPrintNames(groupsOf(workbookScoped)).size).toBe(0); + }); + + it("ignores a built-in name that is neither Print_Area nor Print_Titles", () => { + const consolidateArea = new Uint8Array(LIBREOFFICE_PRINT_AREA); + consolidateArea[OFFSET_BUILTIN_NAME] = 0x00; // built-in name index 0x00, Consolidate_Area + expect(readPrintNames(groupsOf(consolidateArea)).size).toBe(0); + }); + + it("ignores a name whose token stream holds a construct outside this vocabulary", () => { + // Half a print range would be a wrong print range, not a smaller one, so an unrecognised token abandons the whole name. + const withPtgInt = new Uint8Array(LIBREOFFICE_PRINT_AREA); + withPtgInt[OFFSET_RGCE] = 0x1e; // PtgInt in place of the PtgArea3d opcode + expect(readPrintNames(groupsOf(withPtgInt)).size).toBe(0); + }); +}); + +describe("printNameEntriesFor and writePrintNameRecords", () => { + it("writes a print range as the byte-for-byte Lbl a real producer writes for it", () => { + const entries = printNameEntriesFor(0, 0, { + printRange: { startRow: 1, startColumn: 1, endRow: 5, endColumn: 3 }, + }); + expect(writePrintNameRecords(entries)).toEqual([LIBREOFFICE_PRINT_AREA]); + }); + + it("writes both repeated bands as one Print_Titles name, mem-wrapped and union-joined", () => { + const entries = printNameEntriesFor(0, 0, { + repeatRows: { start: 0, end: 1 }, + repeatColumns: { start: 0, end: 0 }, + }); + expect(writePrintNameRecords(entries)).toEqual([ + PRINT_TITLES_WITHOUT_PAREN, + ]); + }); + + it("plans no name at all for a sheet declaring neither a range nor a band", () => { + expect(printNameEntriesFor(0, 0, {})).toEqual([]); + }); + + it("round-trips every combination of range and bands back through the reader", () => { + const settings = { + printRange: { startRow: 2, startColumn: 4, endRow: 40, endColumn: 9 }, + repeatRows: { start: 3, end: 7 }, + repeatColumns: { start: 1, end: 2 }, + }; + const records = writePrintNameRecords(printNameEntriesFor(5, 5, settings)); + expect(readPrintNames(groupsOf(...records)).get(5)).toEqual(settings); + }); + + it("round-trips a repeated row band on its own, without inventing a column band", () => { + const records = writePrintNameRecords( + printNameEntriesFor(0, 0, { repeatRows: { start: 0, end: 0 } }), + ); + expect(readPrintNames(groupsOf(...records)).get(0)).toEqual({ + repeatRows: { start: 0, end: 0 }, + }); + }); +}); diff --git a/packages/xls-codec/src/workbook/print-names.ts b/packages/xls-codec/src/workbook/print-names.ts new file mode 100644 index 000000000..2def9b668 --- /dev/null +++ b/packages/xls-codec/src/workbook/print-names.ts @@ -0,0 +1,388 @@ +import type { + ContentSheetPrintRange, + ContentSheetRepeatRange, +} from "document-schema.js"; + +import { RecordBuilder } from "../biff/builder"; +import { BlockCursor } from "../biff/cursor"; +import { RECORD_LBL } from "../biff/record-types"; +import { writeRecord } from "../biff/record-writer"; +import type { RecordGroup } from "../biff/substreams"; + +// A sheet's print RANGE and its repeated header rows/columns are not in the worksheet substream at all. BIFF8 keeps them in the globals substream, as ordinary defined names ([MS-XLS] 2.4.150's Lbl record) that happen to carry a built-in name index rather than a user-typed name: Print_Area (index 0x06) for the range that prints, Print_Titles (index 0x07) for the bands repeated on every page. Both are LOCAL names -- scoped to one sheet through Lbl's own itab, a one-based index into the BoundSheet8 collection -- which is what lets a workbook carry a different print area per sheet. +// +// This module is both directions of exactly those two names, and nothing else: no other defined name is read (a user-defined name has nowhere to land in document-schema.js's spreadsheet model) or written. It sits in workbook/ rather than biff/ because a print name is a workbook-level fact keyed by sheet, which is what globals.ts reads and globals-writer.ts writes. +// +// The value of each name is a NameParsedFormula ([MS-XLS] 2.5.198.64) -- the same compiled Ptg token stream a cell formula uses. biff/ptg.ts is deliberately not reused for it: that module rebuilds INFIX TEXT for display, and what is wanted here is the range's own four coordinates, which a re-parse of the text it produced would have to recover all over again. The token vocabulary a print name actually uses is also much narrower than a formula's, and includes two tokens ptg.ts explicitly does not resolve (the union operator joining a Print_Titles pair, and the mem token wrapping it). + +/** The built-in name index a Print_Area Lbl carries in place of a name ([MS-XLS] 2.4.150's own built-in name table). */ +const BUILTIN_NAME_PRINT_AREA = 0x06; +/** The same table's Print_Titles entry. */ +const BUILTIN_NAME_PRINT_TITLES = 0x07; + +/** Lbl.grbit's fBuiltin bit -- field F of [MS-XLS] 2.4.150 (fHidden, fFunc, fOB, fProc, fCalcExp, fBuiltin, fGrp (6 bits), reserved1, fPublished, fWorkbookParam, reserved2). */ +const LBL_FLAG_BUILTIN = 0x0020; + +/** The Ptg opcodes a print name's own token stream is built from ([MS-XLS] 2.5.198.25's enumeration). Each reference-class token shares its layout with its value- and array-class spellings, exactly as biff/ptg.ts documents, so all three are accepted. */ +const PTG_UNION = 0x10; +const PTG_PAREN = 0x15; +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; +const PTG_MEMAREA_REF = 0x26; +const PTG_MEMAREA_VALUE = 0x46; +const PTG_MEMAREA_ARRAY = 0x66; +const PTG_MEMFUNC_REF = 0x29; +const PTG_MEMFUNC_VALUE = 0x49; +const PTG_MEMFUNC_ARRAY = 0x69; + +/** PtgMemArea ([MS-XLS] 2.5.198.70) carries four unused bytes ahead of its own cce, where PtgMemFunc ([MS-XLS] 2.5.198.71) carries none. */ +const PTG_MEMAREA_UNUSED_BYTES = 4; + +/** BIFF8's own grid ceilings, which are also how a Print_Titles band says which axis it repeats: a band covering every column of the sheet is a ROW band, and one covering every row is a COLUMN band. */ +const MAX_ROW_INDEX = 0xffff; +const MAX_COLUMN_INDEX = 0x00ff; + +/** One sheet's print names, as the Lbl records of the globals substream carry them. Every field is absent when the workbook declares no such name for that sheet. */ +export interface SheetPrintNames { + readonly printRange?: ContentSheetPrintRange; + readonly repeatRows?: ContentSheetRepeatRange; + readonly repeatColumns?: ContentSheetRepeatRange; +} + +/** A rectangular area exactly as a PtgArea3d/PtgRef3d token states it, before deciding what it means. */ +interface RawArea { + readonly rowFirst: number; + readonly rowLast: number; + readonly columnFirst: number; + readonly columnLast: number; +} + +/** + * Reads every Lbl record in a globals substream into per-sheet print names, keyed by ZERO-based sheet index -- Lbl's own itab is one-based, and a sheet index everywhere else in this package is not. + * + * A name this reader does not act on leaves nothing behind rather than failing the workbook: a user-defined name (fBuiltin clear), a built-in name other than the two below, a workbook-scoped one (itab 0, which no real producer uses for a print area since a print area belongs to a sheet), and a name whose token stream holds a construct outside the narrow vocabulary parsePrintAreas resolves are all simply not carried. The sheet then reads with no printRange or repeat bands, which is the same answer a sheet that genuinely declares none gives. + */ +export function readPrintNames( + records: readonly RecordGroup[], +): ReadonlyMap { + const bySheet = new Map(); + for (const record of records) { + if (record.type !== RECORD_LBL) { + continue; + } + const label = readLbl(record); + if (label === undefined) { + continue; + } + const existing = bySheet.get(label.sheetIndex) ?? {}; + if (label.builtinName === BUILTIN_NAME_PRINT_AREA) { + const range = printRangeOf(label.areas); + if (range !== undefined) { + bySheet.set(label.sheetIndex, { ...existing, printRange: range }); + } + } else { + bySheet.set(label.sheetIndex, { + ...existing, + ...repeatBandsOf(label.areas), + }); + } + } + return bySheet; +} + +interface ParsedLbl { + /** Zero-based, converted from Lbl's own one-based itab. */ + readonly sheetIndex: number; + readonly builtinName: number; + readonly areas: readonly RawArea[]; +} + +/** + * Lbl ([MS-XLS] 2.4.150): a two-byte grbit, chKey, cch, cce, a two-byte reserved3, itab, four reserved bytes, the Name as an XLUnicodeStringNoCch, then cce bytes of rgce. + * + * A built-in name's Name field is that string holding exactly one character whose code unit IS the built-in index ([MS-XLS] 2.4.150: "Each built-in name has a zero-based index value associated with it. A built-in name or its index value MUST be used for this field."), so cch is 1 and the character is read rather than the spelled-out name. A built-in name spelled out in full instead is not resolved -- no producer writes one, and inventing a name-string table to match against would be guessing at which spelling and which locale. + */ +function readLbl(record: RecordGroup): ParsedLbl | undefined { + const cursor = new BlockCursor(record.blocks); + const grbit = cursor.u16(); + if ((grbit & LBL_FLAG_BUILTIN) === 0) { + return undefined; + } + cursor.skip(1); // chKey: the macro shortcut key, zero for a name that is not a macro. + const cch = cursor.u8(); + const cce = cursor.u16(); + cursor.skip(2); // reserved3 + const itab = cursor.u16(); + cursor.skip(4); // reserved4 through reserved7 + // XLUnicodeStringNoCch ([MS-XLS] 2.5.296): a flags byte then the characters, the count coming from the record's own cch above. A built-in name is one compressed character, so anything else is a name this reader does not resolve. + const highByte = (cursor.u8() & 0x01) !== 0; + if (cch !== 1 || highByte) { + return undefined; + } + const builtinName = cursor.u8(); + if ( + builtinName !== BUILTIN_NAME_PRINT_AREA && + builtinName !== BUILTIN_NAME_PRINT_TITLES + ) { + return undefined; + } + // itab 0 is a workbook-scoped name, which a print area never is: [MS-XLS] 2.4.150 defines a non-zero itab as "a one-based index to the collection of BoundSheet8 records", and a print area belongs to exactly one of those sheets. + if (itab === 0) { + return undefined; + } + const areas = parsePrintAreas(cursor.take(cce)); + return areas === undefined + ? undefined + : { sheetIndex: itab - 1, builtinName, areas }; +} + +/** + * Walks a print name's own rgce into the areas it names, or returns undefined for a token stream outside this vocabulary. + * + * The whole vocabulary is: a 3D area or single-cell reference (the areas themselves), the union operator joining two of them in a Print_Titles that repeats both a row band and a column band, a mem token wrapping that union (a real producer emits one -- LibreOffice writes PtgMemFunc, Excel may write PtgMemArea -- to say the enclosed reference expression is a single reference result), and PtgParen, a pure display token restating parentheses. Nothing else appears in a print name a spreadsheet application produced, and anything else aborts rather than being partially resolved: half a print range is a wrong print range, not a smaller one. + * + * A mem token's own cce covers exactly the sub-expression that follows it, which is the rest of this stream in every real case, so it is read past rather than used to bound a nested parse -- the tokens after it are walked by this same loop either way. + */ +function parsePrintAreas(rgce: Uint8Array): RawArea[] | undefined { + const cursor = new BlockCursor([rgce]); + const areas: RawArea[] = []; + while (cursor.remainingInBlock() > 0) { + const opcode = cursor.u8(); + switch (opcode) { + case PTG_AREA3D_REF: + case PTG_AREA3D_VALUE: + case PTG_AREA3D_ARRAY: { + cursor.skip(2); // ixti: the sheet the reference names. A local name's own itab already says which sheet this print area belongs to, so resolving the ixti through EXTERNSHEET would only re-derive it. + areas.push(readArea(cursor)); + break; + } + case PTG_REF3D_REF: + case PTG_REF3D_VALUE: + case PTG_REF3D_ARRAY: { + cursor.skip(2); // ixti, as above + const row = cursor.u16(); + const column = cursor.u16() & 0x3fff; + areas.push({ + rowFirst: row, + rowLast: row, + columnFirst: column, + columnLast: column, + }); + break; + } + case PTG_MEMAREA_REF: + case PTG_MEMAREA_VALUE: + case PTG_MEMAREA_ARRAY: + cursor.skip(PTG_MEMAREA_UNUSED_BYTES); + cursor.skip(2); // cce + break; + case PTG_MEMFUNC_REF: + case PTG_MEMFUNC_VALUE: + case PTG_MEMFUNC_ARRAY: + cursor.skip(2); // cce + break; + case PTG_UNION: + case PTG_PAREN: + break; + default: + return undefined; + } + } + return areas; +} + +/** RgceArea ([MS-XLS] 2.5.198.105), as PtgArea3d carries it: both row bounds, then both ColRelU column fields, whose low 14 bits hold the column index and whose top two bits say whether each coordinate is relative. A print name's coordinates are always absolute in practice, and are read as plain indices either way -- ContentSheetPrintRange has no relative/absolute distinction to carry one into. */ +function readArea(cursor: BlockCursor): RawArea { + const rowFirst = cursor.u16(); + const rowLast = cursor.u16(); + const columnFirst = cursor.u16() & 0x3fff; + const columnLast = cursor.u16() & 0x3fff; + return { rowFirst, rowLast, columnFirst, columnLast }; +} + +/** A Print_Area name's own range. A name declaring several disjoint areas -- legal in BIFF8, and what Excel writes for a multi-area print selection -- yields only the first: ContentSheetPrintSettings.printRange models one rectangle, and merging several into their bounding box would claim cells print that do not. */ +function printRangeOf( + areas: readonly RawArea[], +): ContentSheetPrintRange | undefined { + const area = areas[0]; + return area === undefined + ? undefined + : { + startRow: area.rowFirst, + startColumn: area.columnFirst, + endRow: area.rowLast, + endColumn: area.columnLast, + }; +} + +/** + * A Print_Titles name's own repeated bands, classified by shape. + * + * BIFF8 has no field saying which axis a title band repeats along: a repeated row band is written as an area spanning every column of the sheet ($1:$2, columns 0-255), and a repeated column band as one spanning every row ($A:$A, rows 0-65535). The shape IS the discriminant, and an area that spans both axes at once names the whole sheet, which is neither -- so it is left unclassified rather than being assigned to whichever branch happened to be tested first. + */ +function repeatBandsOf(areas: readonly RawArea[]): { + repeatRows?: ContentSheetRepeatRange; + repeatColumns?: ContentSheetRepeatRange; +} { + const bands: { + repeatRows?: ContentSheetRepeatRange; + repeatColumns?: ContentSheetRepeatRange; + } = {}; + for (const area of areas) { + const spansEveryRow = area.rowFirst === 0 && area.rowLast === MAX_ROW_INDEX; + const spansEveryColumn = + area.columnFirst === 0 && area.columnLast === MAX_COLUMN_INDEX; + if (spansEveryColumn && !spansEveryRow) { + bands.repeatRows = { start: area.rowFirst, end: area.rowLast }; + } else if (spansEveryRow && !spansEveryColumn) { + bands.repeatColumns = { start: area.columnFirst, end: area.columnLast }; + } + } + return bands; +} + +// --- Write side --- + +/** One Lbl record to write: which sheet it is scoped to, and the areas its own token stream names. */ +export interface PrintNamePlanEntry { + /** Zero-based; written out as Lbl's own one-based itab. */ + readonly sheetIndex: number; + /** The ixti a PtgArea3d in this name's token stream refers to -- an index into the ExternSheet record's own XTI array, which globals-writer.ts writes one entry of per sheet. */ + readonly ixti: number; + readonly builtinName: number; + readonly areas: readonly RawArea[]; +} + +/** + * Clamps a coordinate into BIFF8's own row/column ceiling. `ContentSheetPrintRange`/`ContentSheetRepeatRange` bound neither a start nor an end coordinate from above, and `writeArea3d` below writes each one into a 16-bit field regardless (`RecordBuilder.u16` masks with `0xffff`), so an out-of-grid coordinate would otherwise silently wrap to a plausible-looking in-grid one -- a print range asked for through row 70000 landing at row 4464, with nothing downstream to notice. + * + * Clamped rather than dropped or refused: a print range or a repeated header band is a rectangle whose intent survives shrinking to the grid's own edge -- "print through the bottom of the sheet" is genuinely what an end coordinate past the last row means once the grid is smaller than the caller assumed -- unlike a single-position page break (see sheet-writer.ts's own inGridBreaks, which drops rather than clamps for exactly that distinction). + */ +function clampToGrid(index: number, maxIndex: number): number { + return Math.min(index, maxIndex); +} + +/** The Print_Area and Print_Titles entries one sheet's print settings need, or an empty list when it declares neither a print range nor a repeated band. */ +export function printNameEntriesFor( + sheetIndex: number, + ixti: number, + settings: { + readonly printRange?: ContentSheetPrintRange; + readonly repeatRows?: ContentSheetRepeatRange; + readonly repeatColumns?: ContentSheetRepeatRange; + }, +): PrintNamePlanEntry[] { + const entries: PrintNamePlanEntry[] = []; + if (settings.printRange !== undefined) { + entries.push({ + sheetIndex, + ixti, + builtinName: BUILTIN_NAME_PRINT_AREA, + areas: [ + { + rowFirst: clampToGrid(settings.printRange.startRow, MAX_ROW_INDEX), + rowLast: clampToGrid(settings.printRange.endRow, MAX_ROW_INDEX), + columnFirst: clampToGrid( + settings.printRange.startColumn, + MAX_COLUMN_INDEX, + ), + columnLast: clampToGrid( + settings.printRange.endColumn, + MAX_COLUMN_INDEX, + ), + }, + ], + }); + } + // Written in the order the read side classifies them by shape rather than by position, so the two need not agree on an ordering -- but a column band first is what a real LibreOffice-written Print_Titles carries, and matching it keeps the bytes comparable against one. + const titleAreas: RawArea[] = []; + if (settings.repeatColumns !== undefined) { + titleAreas.push({ + rowFirst: 0, + rowLast: MAX_ROW_INDEX, + columnFirst: clampToGrid(settings.repeatColumns.start, MAX_COLUMN_INDEX), + columnLast: clampToGrid(settings.repeatColumns.end, MAX_COLUMN_INDEX), + }); + } + if (settings.repeatRows !== undefined) { + titleAreas.push({ + rowFirst: clampToGrid(settings.repeatRows.start, MAX_ROW_INDEX), + rowLast: clampToGrid(settings.repeatRows.end, MAX_ROW_INDEX), + columnFirst: 0, + columnLast: MAX_COLUMN_INDEX, + }); + } + if (titleAreas.length > 0) { + entries.push({ + sheetIndex, + ixti, + builtinName: BUILTIN_NAME_PRINT_TITLES, + areas: titleAreas, + }); + } + return entries; +} + +/** PtgArea3d ([MS-XLS] 2.5.198.28), reference class: the opcode, the ixti, then an RgceArea. Every coordinate is written absolute (both ColRelU relative bits clear), which is what a print name means -- a print area does not move relative to anything. */ +function writeArea3d(ixti: number, area: RawArea): Uint8Array { + return new RecordBuilder() + .u8(PTG_AREA3D_REF) + .u16(ixti) + .u16(area.rowFirst) + .u16(area.rowLast) + .u16(area.columnFirst) + .u16(area.columnLast) + .build(); +} + +/** + * One Lbl record ([MS-XLS] 2.4.150) for a built-in print name, the write-side mirror of readLbl above. + * + * A single area is written as a bare PtgArea3d. Two are written as [MS-XLS] 2.5.198.71's own mem-area-expression shape -- PtgMemFunc carrying the byte count of what follows, then the two areas and the PtgUnion joining them -- which is what says the pair is one reference result rather than two loose operands, and is byte-for-byte the structure a real LibreOffice-written Print_Titles carries for the same pair. + */ +function writePrintNameRecord( + entry: PrintNamePlanEntry, +): Uint8Array { + const areaTokens = entry.areas.map((area) => writeArea3d(entry.ixti, area)); + const expression = new RecordBuilder(); + for (const token of areaTokens) { + expression.bytes(token); + } + for (let index = 1; index < areaTokens.length; index += 1) { + expression.u8(PTG_UNION); + } + const expressionBytes = expression.build(); + const rgce = + areaTokens.length > 1 + ? new RecordBuilder() + .u8(PTG_MEMFUNC_REF) + .u16(expressionBytes.length) + .bytes(expressionBytes) + .build() + : expressionBytes; + + const data = new RecordBuilder() + .u16(LBL_FLAG_BUILTIN) + .u8(0) // chKey: "MUST be 0 (no shortcut key) if fFunc is 1 or if fProc is 0", and this name is neither a macro nor a procedure. + .u8(1) // cch: a built-in name is the single character holding its own index. + .u16(rgce.length) + .u16(0) // reserved3 + .u16(entry.sheetIndex + 1) // itab, one-based + .u32(0) // reserved4 through reserved7 + .u8(0) // the Name's own XLUnicodeStringNoCch flags byte: compressed, one byte per character + .u8(entry.builtinName) + .bytes(rgce) + .build(); + return writeRecord(RECORD_LBL, data); +} + +/** Every planned print name as its own Lbl record, in the order given. */ +export function writePrintNameRecords( + entries: readonly PrintNamePlanEntry[], +): Uint8Array[] { + return entries.map(writePrintNameRecord); +} diff --git a/packages/xls-codec/src/workbook/sheet-writer.ts b/packages/xls-codec/src/workbook/sheet-writer.ts index 08e61ba0e..ef13e345f 100644 --- a/packages/xls-codec/src/workbook/sheet-writer.ts +++ b/packages/xls-codec/src/workbook/sheet-writer.ts @@ -3,24 +3,46 @@ import type { ContentSheet, ContentSheetCell, ContentSheetColumn, + ContentSheetPrintSettings, ContentSheetRow, } from "document-schema.js"; import { writeBofData } from "../biff/bof-writer"; import { RecordBuilder } from "../biff/builder"; import { errorCodeOf } from "../biff/errors"; +import { + packSetupFlags, + paperSelectionFor, + WSBOOL_FLAG_FIT_TO_PAGE, + type SetupFields, +} from "../biff/print-setup"; import { BOF_TYPE_WORKSHEET, RECORD_BLANK, RECORD_BOF, RECORD_BOOLERR, + RECORD_BOTTOMMARGIN, + RECORD_CALCCOUNT, + RECORD_CALCDELTA, + RECORD_CALCITER, + RECORD_CALCREFMODE, + RECORD_CALCSAVERECALC, RECORD_COLINFO, RECORD_DIMENSIONS, RECORD_EOF, + RECORD_HORIZONTALPAGEBREAKS, RECORD_LABELSST, + RECORD_LEFTMARGIN, RECORD_MERGECELLS, RECORD_NUMBER, + RECORD_PRINTGRID, + RECORD_PRINTROWCOL, + RECORD_RIGHTMARGIN, RECORD_ROW, + RECORD_SETUP, + RECORD_TOPMARGIN, + RECORD_VERTICALPAGEBREAKS, + RECORD_WSBOOL, } from "../biff/record-types"; import { concatRecords, writeRecord } from "../biff/record-writer"; import { BiffWriteError } from "../biff/write-errors"; @@ -29,11 +51,11 @@ import { isoDateToSerial, isoTimeToSerial, } from "../serial"; -import { pointsToColumnWidth, pointsToTwips } from "../units"; +import { pointsToColumnWidth, pointsToInches, pointsToTwips } from "../units"; import { cellCarriesDecoration, writesCellRecord } from "../written-cells"; import { GENERAL_CELL_XF_INDEX } from "./globals-writer"; -// The worksheet substream ([MS-XLS] 2.1.7.20.5), write side: the grid geometry and cell table for one sheet, the counterpart of workbook/sheet.ts's own readSheetRecords. See xls-codec's README for exactly which worksheet-substream records this writer emits (Dimensions, ColInfo, Row, the value-cell family, MergeCells) and which it deliberately omits (Window2, the calc-state/print-settings record family, Index/DBCell) -- real content, not per-window UI state or a lookup optimisation this reader (or any reader) does not require to find a cell. +// The worksheet substream ([MS-XLS] 2.1.7.20.5), write side: the page setup, grid geometry, and cell table for one sheet, the counterpart of workbook/sheet.ts's own readSheetRecords. See xls-codec's README for exactly which worksheet-substream records this writer emits (the print-settings group, Dimensions, ColInfo, Row, the value-cell family, MergeCells) and which it deliberately omits (Window2, the calc-state family, Index/DBCell) -- real content, not per-window UI state or a lookup optimisation this reader (or any reader) does not require to find a cell. // // A blank cell -- ContentCellValue's own 'empty' kind -- splits in two. One carrying no decoration is written as nothing at all, which is what round-trips: content.ts's mapCell drops an undecorated blank it reads, and applyMerges reconstructs an empty anchor for a merged range from the MergeCells record alone. One carrying a background or a border is a Blank record ([MS-XLS] 2.4.20), because its decoration exists only in the XF that record's ixfe names and there is no other record in the sheet to hang it on. written-cells.ts holds the predicate deciding which, shared with write.ts's own workbook-wide scans so the two cannot disagree. // @@ -201,6 +223,234 @@ function writeMergeCellsRecord( return writeRecord(RECORD_MERGECELLS, builder.build()); } +// --- Print settings --- + +/** CalcCount's own cIter ([MS-XLS] 2.4.31): "MUST be greater than or equal to one and less than or equal to 32767". Excel's own default iteration limit, and what a real LibreOffice-written BIFF8 carries. */ +const CALC_ITERATION_LIMIT = 100; +/** CalcDelta's own numDelta ([MS-XLS] 2.4.32): Excel's own default minimum change for iterative calculation to continue. */ +const CALC_ITERATION_DELTA = 0.001; + +/** + * The calculation-state records [MS-XLS] 2.1.7.20.6's GLOBALS production requires ahead of PrintRowCol, none of which carries anything document-schema.js models. + * + * They are written for two reasons, one of them empirical. The grammar makes them mandatory -- `GLOBALS = CalcMode CalcCount CalcRefMode CalcIter CalcDelta CalcSaveRecalc PrintRowCol PrintGrid GridSet Guts DefaultRowHeight WsBool ...`, with no brackets on any of them -- so a substream that opens straight with a print setting is not a conformant worksheet at all. And LibreOffice's own BIFF8 importer silently discards whichever page-settings record comes FIRST in a worksheet substream: with PrintRowCol in that slot, a `.xls` this writer produced with row and column headers enabled opened in LibreOffice with them off, while every other print setting in the same file came through correctly. Moving any other record into that slot fixes it, which is what these do -- verified by writing the same workbook with and without them and re-reading each through `soffice --convert-to fods`. + * + * The values are Excel's own defaults (automatic recalculation, A1 references, iteration off), matching what a real LibreOffice-written file carries for a workbook nobody has changed the calculation settings of. CalcMode is deliberately not among them: the production names it, but LibreOffice does not write one into a worksheet substream either, and the records below already satisfy the constraint this comment exists for. + */ +function writeCalculationStateRecords(): Uint8Array[] { + return [ + writeRecord( + RECORD_CALCCOUNT, + new RecordBuilder().u16(CALC_ITERATION_LIMIT).build(), + ), + // fRefA1 ([MS-XLS] 2.4.36): 1 is A1 reference style, which is what biff/ptg.ts's own formula-text reader assumes when it rebuilds a reference. + writeRecord(RECORD_CALCREFMODE, new RecordBuilder().u16(1).build()), + // vfIter ([MS-XLS] 2.4.33): iterative calculation disabled. + writeRecord(RECORD_CALCITER, new RecordBuilder().u16(0).build()), + writeRecord( + RECORD_CALCDELTA, + new RecordBuilder().f64(CALC_ITERATION_DELTA).build(), + ), + // fSaveRecalc ([MS-XLS] 2.4.37): recalculate before saving, Excel's own default. + writeRecord(RECORD_CALCSAVERECALC, new RecordBuilder().u16(1).build()), + ]; +} + +/** Setup's own iRes/iVRes ([MS-XLS] 2.4.257), a printer resolution in DPI. 300 is what a real LibreOffice-written BIFF8 carries and a sane default for a writer with no printer to ask; the field is undefined whenever fNoPls is set, which this writer never sets, so it must carry something real. */ +const SETUP_PRINT_RESOLUTION_DPI = 300; +/** Setup's own iCopies: one copy, the only sensible value for a file that is not being sent to a printer right now. */ +const SETUP_COPIES = 1; +/** Setup's own numHdr/numFtr ([MS-XLS] 2.4.257), the header and footer margins in inches. Excel's own Normal preset value; Margins has no header/footer field for a real one to come from, and content.ts's read side discards these for the same reason. */ +const SETUP_HEADER_FOOTER_MARGIN_INCHES = 0.3; +/** Setup's own iFitWidth/iFitHeight when the sheet is not in fit-to-page mode at all. Written rather than left at 0 because 0 means "as many pages as necessary" -- a real value a reader must not see while fFitToPage is clear and mistake for an intent the sheet never had. */ +const SETUP_INACTIVE_FIT_PAGES = 1; +/** Setup's own iScale when the sheet IS in fit-to-page mode: 100%, actual size, the inactive value a real producer leaves behind (confirmed against LibreOffice-written BIFF8, whose fit-to-page sheets carry exactly this). */ +const SETUP_INACTIVE_SCALE_PERCENT = 100; +/** Setup's own iPaperSize for a page size no entry of its code table names: [MS-XLS] 2.4.257's "The value 0, or values greater than or equal to 256, specify custom printer paper sizes." */ +const SETUP_CUSTOM_PAPER_SIZE = 0; +/** [MS-XLS] 2.4.257's own ceiling on iFitWidth and iFitHeight: "MUST be less than or equal to 32767." */ +const SETUP_MAX_FIT_PAGES = 32767; +/** iScale is an unsigned 16-bit percentage with no documented ceiling of its own, so the field's own width is the only bound there is. 1 is the floor: 0 is not a scaling factor, and a sub-1% one has no whole-percent spelling. */ +const SETUP_MIN_SCALE_PERCENT = 1; +const SETUP_MAX_SCALE_PERCENT = 0xffff; + +/** + * Clamps a schema value into the range its own Setup field can hold, since `ContentSheetPrintSettings` bounds neither a scale percentage nor a fit-to-page count from above. + * + * Clamping rather than throwing, and rather than letting the value wrap: a print scale is a presentational field, and refusing a whole workbook over an absurd one would lose its cells for no gain (the same trade the paper-code case above settles the same way). Silently wrapping is the option neither of those beats -- a scale of 65540% would land in the file as 4%, which is a different intent stated confidently. + */ +function clampedSetupField( + value: number, + minimum: number, + maximum: number, +): number { + return Math.min(Math.max(Math.round(value), minimum), maximum); +} + +/** + * Setup ([MS-XLS] 2.4.257): iPaperSize, iScale, iPageStart, iFitWidth, iFitHeight, the flags word, iRes, iVRes, numHdr, numFtr, iCopies. + * + * fNoPls is deliberately never set. It would declare this record's own paper size, scale, and orientation undefined -- exactly the three fields it exists here to carry -- and [MS-XLS] pairs it with a Pls record holding a printer driver's DEVMODE blob, which this writer has none of. + * + * A page size no code in the table names is written as SETUP_CUSTOM_PAPER_SIZE rather than as a standard paper it is not. The dimensions themselves are genuinely unwritable -- Setup addresses paper only by code, and [MS-XLS]'s own escape hatch for a size outside the table is that same Pls record -- so the choice is between saying "custom" and saying something false. Saying "custom" is what the spec's own iPaperSize table provides the value for, and it leaves a reader (this package's own included) free to fall back to its documented default instead of confidently reporting Letter for a page that is not Letter. This matters in practice rather than in theory: a spreadsheet converted from a slide deck or a drawing carries that source's own canvas as its page size, which is almost never a named paper, and refusing the conversion outright over a presentational field would lose the cells too. + */ +function writeSetupRecord( + settings: ContentSheetPrintSettings, +): Uint8Array { + const paper = paperSelectionFor(settings.pageSize) ?? { + code: SETUP_CUSTOM_PAPER_SIZE, + // With no named paper to transpose, the orientation flag is the only thing left that still says which way round the page is, so it states what the page size itself does. + portrait: settings.pageSize.widthPt <= settings.pageSize.heightPt, + }; + const fitToPages = settings.fitToPages; + const fields: SetupFields = { + paperCode: paper.code, + scalePercent: + fitToPages === undefined + ? clampedSetupField( + settings.scalePercent ?? SETUP_INACTIVE_SCALE_PERCENT, + SETUP_MIN_SCALE_PERCENT, + SETUP_MAX_SCALE_PERCENT, + ) + : SETUP_INACTIVE_SCALE_PERCENT, + fitWidth: + fitToPages === undefined + ? SETUP_INACTIVE_FIT_PAGES + : clampedSetupField(fitToPages.width, 1, SETUP_MAX_FIT_PAGES), + fitHeight: + fitToPages === undefined + ? SETUP_INACTIVE_FIT_PAGES + : clampedSetupField(fitToPages.height, 1, SETUP_MAX_FIT_PAGES), + leftToRight: settings.pageOrder === "overThenDown", + portrait: paper.portrait, + noPls: false, + // fNoOrient clear, so fPortrait above is what selects the orientation -- setting it would make [MS-XLS] 2.4.257's own "Pages are printed using portrait mode" override it and silently lose every landscape page. + noOrientation: false, + }; + const data = new RecordBuilder() + .u16(fields.paperCode) + .u16(fields.scalePercent) + .u16(0) // iPageStart: ignored, since fUsePage is clear + .u16(fields.fitWidth) + .u16(fields.fitHeight) + .u16(packSetupFlags(fields)) + .u16(SETUP_PRINT_RESOLUTION_DPI) + .u16(SETUP_PRINT_RESOLUTION_DPI) + .f64(SETUP_HEADER_FOOTER_MARGIN_INCHES) + .f64(SETUP_HEADER_FOOTER_MARGIN_INCHES) + .u16(SETUP_COPIES) + .build(); + return writeRecord(RECORD_SETUP, data); +} + +/** Any of the four margin records ([MS-XLS] 2.4.151, 2.4.219, 2.4.328, 2.4.27): a single Xnum stating that margin in inches. All four share one field layout, so one writer serves them all -- the mirror of workbook/sheet.ts's own single readMargin. */ +function writeMarginRecord( + recordType: number, + points: number, +): Uint8Array { + return writeRecord( + recordType, + new RecordBuilder().f64(pointsToInches(points)).build(), + ); +} + +/** PrintGrid ([MS-XLS] 2.4.202) and PrintRowCol ([MS-XLS] 2.4.203), each a single 16-bit boolean. */ +function writeBooleanRecord( + recordType: number, + value: boolean, +): Uint8Array { + return writeRecord( + recordType, + new RecordBuilder().u16(value ? 1 : 0).build(), + ); +} + +/** WsBool ([MS-XLS] 2.4.351). Only fFitToPage is set from real data; every other bit is written clear, which is what a sheet with no outline, no dialog behaviour, no synchronised scrolling, and no transition formula handling means -- and is exactly the set of facts ContentSheet carries nothing about. */ +function writeWsBoolRecord(fitToPage: boolean): Uint8Array { + return writeRecord( + RECORD_WSBOOL, + new RecordBuilder().u16(fitToPage ? WSBOOL_FLAG_FIT_TO_PAGE : 0).build(), + ); +} + +/** + * HorizontalPageBreaks ([MS-XLS] 2.4.142) or VerticalPageBreaks ([MS-XLS] 2.4.343): a count then that many six-byte structures, each the break's own index followed by the start and end of its extent along the other axis. + * + * Both structures share that three-field shape, so one writer serves both -- the extent's own end differs, which is what `extentEnd` carries: ContentSheetPrintSettings models a break as a whole-axis index with no extent, so every break written here runs the full width or height of BIFF8's own grid. Written in ascending index order, which is the sort [MS-XLS] requires of both arrays; the caller's own indices are sorted first rather than assumed sorted. + */ +function writePageBreaksRecord( + recordType: number, + indices: readonly number[], + extentEnd: number, +): Uint8Array { + const sorted = [...new Set(indices)].sort((a, b) => a - b); + const builder = new RecordBuilder().u16(sorted.length); + for (const index of sorted) { + builder.u16(index).u16(0).u16(extentEnd); + } + return writeRecord(recordType, builder.build()); +} + +/** + * `manualBreaks.rows`/`.columns` is not bounded to BIFF8's own grid the way a cell's own row/column is (`writeCellRecord`'s `MAX_ROW_INDEX`/`MAX_COLUMN_INDEX` guard above throws for exactly that reason). `writePageBreaksRecord` writes each index into a 16-bit field regardless (`RecordBuilder.u16` masks with `0xffff`), so an out-of-grid break would otherwise silently wrap to a plausible-looking in-grid one -- a break asked for at row 70000 landing at row 4464 -- with nothing downstream to notice. + * + * Dropped rather than clamped: unlike a print range or a repeated header band, where clamping to the grid's own last row/column is genuinely what "to the bottom of the sheet" means once the grid shrinks under it (print-names.ts's own clampToGrid), a page break is a single position, and clamping one would insert a break at the grid's own edge the caller never asked for. Dropping states the honest thing an out-of-grid break means once BIFF8's own ceiling has been applied: no such row/column exists for it to sit at. + */ +function inGridBreaks(indices: readonly number[], maxIndex: number): number[] { + return indices.filter((index) => index <= maxIndex); +} + +/** + * Every print-settings record one sheet needs, in [MS-XLS] 2.1.7.20.6's own order. + * + * That order is two productions, back to back, both of which the worksheet substream places ahead of COLUMNS, Dimensions, and the cell table: `GLOBALS = ... PrintRowCol PrintGrid GridSet Guts DefaultRowHeight WsBool [Sync] [LPr] [HorizontalPageBreaks] [VerticalPageBreaks]` and `PAGESETUP = Header Footer HCenter VCenter [LeftMargin] [RightMargin] [TopMargin] [BottomMargin] [Pls *Continue] [Setup]`. The mandatory records this writer does not emit at all (the calculation-state family, GridSet, Guts, DefaultRowHeight, Header/Footer, HCenter/VCenter) are the same UI and interoperability bookkeeping it already omits everywhere else -- see this package's README -- so what remains is the optional subset that actually carries print settings, in the relative order those two productions give it. + * + * Every record here is written unconditionally, including for a sheet whose settings are exactly the Normal preset. A print setting has no "absent" spelling in ContentSheetPrintSettings -- gridlines, headers, page order, page size, and all four margins are required fields -- so there is no way to tell a sheet that asked for the preset from one that never stated anything, and writing the values out is what makes the round trip exact either way. + */ +function writePrintSettingsRecords( + settings: ContentSheetPrintSettings, +): Uint8Array[] { + const pieces: Uint8Array[] = [ + ...writeCalculationStateRecords(), + writeBooleanRecord(RECORD_PRINTROWCOL, settings.headers), + writeBooleanRecord(RECORD_PRINTGRID, settings.gridlines), + writeWsBoolRecord(settings.fitToPages !== undefined), + ]; + const breaks = settings.manualBreaks; + if (breaks !== undefined) { + const rowBreaks = inGridBreaks(breaks.rows, MAX_ROW_INDEX); + if (rowBreaks.length > 0) { + pieces.push( + // A row break's extent runs across every column of the sheet, so its end is the grid's own last column. + writePageBreaksRecord( + RECORD_HORIZONTALPAGEBREAKS, + rowBreaks, + MAX_COLUMN_INDEX, + ), + ); + } + const columnBreaks = inGridBreaks(breaks.columns, MAX_COLUMN_INDEX); + if (columnBreaks.length > 0) { + pieces.push( + // A column break's extent runs down every row, so its end is the grid's own last row. + writePageBreaksRecord( + RECORD_VERTICALPAGEBREAKS, + columnBreaks, + MAX_ROW_INDEX, + ), + ); + } + } + pieces.push( + writeMarginRecord(RECORD_LEFTMARGIN, settings.margins.leftPt), + writeMarginRecord(RECORD_RIGHTMARGIN, settings.margins.rightPt), + writeMarginRecord(RECORD_TOPMARGIN, settings.margins.topPt), + writeMarginRecord(RECORD_BOTTOMMARGIN, settings.margins.bottomPt), + writeSetupRecord(settings), + ); + return pieces; +} + function cellHeader(cell: ContentSheetCell, xfIndex: number): RecordBuilder { return new RecordBuilder().u16(cell.row).u16(cell.column).u16(xfIndex); } @@ -276,7 +526,7 @@ function writeCellValueRecord( } } -/** Builds one worksheet's own substream: BOF, Dimensions, ColInfo per column, Row + value-cell records per populated or declared row (in ascending row then column order), MergeCells if the sheet declares any, EOF. */ +/** Builds one worksheet's own substream: BOF, the print-settings records, Dimensions, ColInfo per column, Row + value-cell records per populated or declared row (in ascending row then column order), MergeCells if the sheet declares any, EOF. */ export function buildWorksheetSubstream( sheet: ContentSheet, ctx: SheetWriteContext, @@ -309,6 +559,7 @@ export function buildWorksheetSubstream( const pieces: Uint8Array[] = [ writeRecord(RECORD_BOF, writeBofData(BOF_TYPE_WORKSHEET)), + ...writePrintSettingsRecords(sheet.printSettings), writeDimensionsRecord(writtenCells), ]; diff --git a/packages/xls-codec/src/workbook/sheet.test.ts b/packages/xls-codec/src/workbook/sheet.test.ts index 9426700c3..46aa42248 100644 --- a/packages/xls-codec/src/workbook/sheet.test.ts +++ b/packages/xls-codec/src/workbook/sheet.test.ts @@ -3,19 +3,29 @@ import { describe, expect, it } from "vitest"; import { RECORD_BLANK, RECORD_BOOLERR, + RECORD_BOTTOMMARGIN, RECORD_COLINFO, RECORD_DIMENSIONS, RECORD_FORMULA, + RECORD_HORIZONTALPAGEBREAKS, RECORD_LABEL, RECORD_LABELSST, + RECORD_LEFTMARGIN, RECORD_MERGECELLS, RECORD_MULBLANK, RECORD_MULRK, RECORD_NUMBER, + RECORD_PRINTGRID, + RECORD_PRINTROWCOL, + RECORD_RIGHTMARGIN, RECORD_RK, RECORD_ROW, + RECORD_SETUP, RECORD_SHRFMLA, RECORD_STRING, + RECORD_TOPMARGIN, + RECORD_VERTICALPAGEBREAKS, + RECORD_WSBOOL, } from "../biff/record-types"; import { BiffFormatError, readRecords } from "../biff/records"; import { groupRecords, type RecordGroup } from "../biff/substreams"; @@ -608,3 +618,163 @@ describe("readSheetRecords grid geometry", () => { expect(sheet.cells).toHaveLength(1); }); }); + +describe("readSheetRecords print settings", () => { + it("reads the Setup record's own paper, scale, fit-to-page counts, and flags", () => { + // [MS-XLS] 2.4.257: iPaperSize, iScale, iPageStart, iFitWidth, iFitHeight, the flags word, iRes, iVRes, numHdr, numFtr, iCopies. https://learn.microsoft.com/en-us/openspecs/office_file_formats/ms-xls/23642d03-de0e-4a7f-94da-c2e594020bf2 + const sheet = readSheetRecords( + groupsOf( + record(RECORD_SETUP, [ + ...u16(9), // iPaperSize: A4 + ...u16(80), // iScale + ...u16(1), // iPageStart + ...u16(2), // iFitWidth + ...u16(3), // iFitHeight + ...u16(0x0001), // fLeftToRight set, fPortrait clear + ...u16(300), // iRes + ...u16(300), // iVRes + ...f64(0.3), // numHdr + ...f64(0.3), // numFtr + ...u16(1), // iCopies + ]), + ), + [], + ); + + expect(sheet.print.setup).toEqual({ + paperCode: 9, + scalePercent: 80, + fitWidth: 2, + fitHeight: 3, + leftToRight: true, + portrait: false, + noPls: false, + noOrientation: false, + }); + }); + + it("reads each of the four margin records as points", () => { + // Each is a single Xnum of INCHES ([MS-XLS] 2.4.151, 2.4.219, 2.4.328, 2.4.27), so half an inch reads as 36pt. + const sheet = readSheetRecords( + groupsOf( + record(RECORD_LEFTMARGIN, f64(0.5)), + record(RECORD_RIGHTMARGIN, f64(0.75)), + record(RECORD_TOPMARGIN, f64(1)), + record(RECORD_BOTTOMMARGIN, f64(1.25)), + ), + [], + ); + + expect(sheet.print.marginsPt).toEqual({ + left: 36, + right: 54, + top: 72, + bottom: 90, + }); + }); + + it("leaves a margin absent when the sheet carries no record for that side", () => { + // [MS-XLS] 2.1.7.20.6's PAGESETUP production brackets each margin individually, so a sheet stating one and not the others is well-formed -- and "states nothing" has to stay distinguishable from "states the default". + const sheet = readSheetRecords( + groupsOf(record(RECORD_LEFTMARGIN, f64(0.5))), + [], + ); + + expect(sheet.print.marginsPt).toEqual({ left: 36 }); + }); + + it("reads PrintGrid and PrintRowCol as the booleans they are", () => { + const sheet = readSheetRecords( + groupsOf( + record(RECORD_PRINTGRID, u16(1)), + record(RECORD_PRINTROWCOL, u16(0)), + ), + [], + ); + + expect(sheet.print.printGridlines).toBe(true); + expect(sheet.print.printHeaders).toBe(false); + }); + + it("reads PrintGrid's fPrintGrid bit alone, ignoring the 15 bits [MS-XLS] 2.4.202 documents as undefined", () => { + // Unlike PrintRowCol's own genuinely 16-bit Boolean field, PrintGrid packs its one real bit into a 16-bit record with 15 undefined bits alongside it -- a bare `!== 0` test would read any of them set as gridlines-on. + const sheet = readSheetRecords( + groupsOf(record(RECORD_PRINTGRID, u16(0xfffe))), + [], + ); + + expect(sheet.print.printGridlines).toBe(false); + }); + + it("reads WsBool's fFitToPage bit and no other", () => { + // Field G of [MS-XLS] 2.4.351's single 16-bit field, the ninth bit. 0x04c1 is the value a real LibreOffice-written non-fit-to-page sheet carries; 0x05c1 is the same sheet with fit-to-page on. + expect( + readSheetRecords(groupsOf(record(RECORD_WSBOOL, u16(0x04c1))), []).print + .fitToPage, + ).toBe(false); + expect( + readSheetRecords(groupsOf(record(RECORD_WSBOOL, u16(0x05c1))), []).print + .fitToPage, + ).toBe(true); + }); + + it("reads both page-break records, taking each break's own index and not its extent", () => { + // [MS-XLS] 2.4.142/2.4.343: a count then that many six-byte structures -- a HorzBrk's row plus its colStart/colEnd, a VertBrk's col plus its rowStart/rowEnd. + const sheet = readSheetRecords( + groupsOf( + record(RECORD_HORIZONTALPAGEBREAKS, [ + ...u16(2), + ...u16(10), + ...u16(0), + ...u16(0xff), + ...u16(4), + ...u16(0), + ...u16(0xff), + ]), + record(RECORD_VERTICALPAGEBREAKS, [ + ...u16(1), + ...u16(3), + ...u16(0), + ...u16(0xffff), + ]), + ), + [], + ); + + expect(sheet.print.rowBreaks).toEqual([4, 10]); + expect(sheet.print.columnBreaks).toEqual([3]); + }); + + it("collapses two breaks naming the same index, which the schema models only once", () => { + // A BIFF8 page break carries an extent along the perpendicular axis, so one row can legitimately carry two partial breaks; ContentSheetPrintSettings names a break by index alone. + const sheet = readSheetRecords( + groupsOf( + record(RECORD_HORIZONTALPAGEBREAKS, [ + ...u16(2), + ...u16(7), + ...u16(0), + ...u16(3), + ...u16(7), + ...u16(4), + ...u16(0xff), + ]), + ), + [], + ); + + expect(sheet.print.rowBreaks).toEqual([7]); + }); + + it("states nothing at all for a sheet carrying none of the print records", () => { + const sheet = readSheetRecords( + groupsOf(record(RECORD_BLANK, cell(0, 0))), + [], + ); + + expect(sheet.print).toEqual({ + marginsPt: {}, + rowBreaks: [], + columnBreaks: [], + }); + }); +}); diff --git a/packages/xls-codec/src/workbook/sheet.ts b/packages/xls-codec/src/workbook/sheet.ts index 956ae8899..a873afd47 100644 --- a/packages/xls-codec/src/workbook/sheet.ts +++ b/packages/xls-codec/src/workbook/sheet.ts @@ -1,30 +1,45 @@ import { BlockCursor } from "../biff/cursor"; import { errorTextOf } from "../biff/errors"; +import { + unpackSetupFlags, + WSBOOL_FLAG_FIT_TO_PAGE, + type SetupFields, +} from "../biff/print-setup"; import { parseFormulaText, type FormulaSheetContext } from "../biff/ptg"; import { RECORD_ARRAY, RECORD_BLANK, RECORD_BOOLERR, + RECORD_BOTTOMMARGIN, RECORD_COLINFO, RECORD_DIMENSIONS, RECORD_FORMULA, + RECORD_HORIZONTALPAGEBREAKS, RECORD_LABEL, RECORD_LABELSST, + RECORD_LEFTMARGIN, RECORD_MERGECELLS, RECORD_MULBLANK, RECORD_MULRK, RECORD_NUMBER, + RECORD_PRINTGRID, + RECORD_PRINTROWCOL, + RECORD_RIGHTMARGIN, RECORD_RK, RECORD_ROW, + RECORD_SETUP, RECORD_SHRFMLA, RECORD_STRING, RECORD_TABLE, + RECORD_TOPMARGIN, + RECORD_VERTICALPAGEBREAKS, + RECORD_WSBOOL, } from "../biff/record-types"; import { BiffFormatError } from "../biff/records"; import { decodeRkNumber } from "../biff/rk"; import { readXLUnicodeString } from "../biff/strings"; import type { RecordGroup } from "../biff/substreams"; -import { columnWidthToPoints, twipsToPoints } from "../units"; +import { columnWidthToPoints, inchesToPoints, twipsToPoints } from "../units"; // The worksheet substream ([MS-XLS] 2.1.7.20.5): the grid geometry and the cell table for one sheet. https://learn.microsoft.com/en-us/openspecs/office_file_formats/ms-xls/f41c06f2-9057-49a1-8c3f-a4a4d211fc56 // @@ -80,6 +95,35 @@ export interface RawRange { readonly endColumn: number; } +/** + * The page-setup half of a sheet's print settings, as the worksheet substream's own records carry them. + * + * Every field is optional because every record behind it is: [MS-XLS] 2.1.7.20.6's PAGESETUP production makes each of the four margins and Setup itself optional, and a sheet that never had its page setup touched carries none of them. An absent field is therefore "this file states nothing here", which is a different fact from "this file states the default", and content.ts is where the distinction is resolved into the required ContentSheetPrintSettings fields. + * + * The other half -- the print range and the repeated header bands -- is not here at all, because BIFF8 does not put it in the worksheet substream; see workbook/print-names.ts. + */ +export interface RawPrintSettings { + /** The Setup record's own fields ([MS-XLS] 2.4.257). */ + readonly setup?: SetupFields; + /** Each page margin in points, from its own record ([MS-XLS] 2.4.151, 2.4.219, 2.4.328, 2.4.27), which states it in inches. */ + readonly marginsPt: { + readonly left?: number; + readonly right?: number; + readonly top?: number; + readonly bottom?: number; + }; + /** PrintGrid's fPrintGrid ([MS-XLS] 2.4.202). */ + readonly printGridlines?: boolean; + /** PrintRowCol's printRwCol ([MS-XLS] 2.4.203): whether the row and column headers print. */ + readonly printHeaders?: boolean; + /** WsBool's fFitToPage ([MS-XLS] 2.4.351), which decides whether Setup's iScale or its iFitWidth/iFitHeight pair is the live one. */ + readonly fitToPage?: boolean; + /** Zero-based row indices an explicit page break falls immediately above ([MS-XLS] 2.4.142), ascending and deduplicated. */ + readonly rowBreaks: readonly number[]; + /** Zero-based column indices an explicit page break falls immediately to the left of ([MS-XLS] 2.4.343). */ + readonly columnBreaks: readonly number[]; +} + /** One worksheet's records, read but not yet mapped onto the shared schema. */ export interface RawSheet { readonly cells: readonly RawCell[]; @@ -88,6 +132,8 @@ export interface RawSheet { readonly merges: readonly RawRange[]; /** The used range from the Dimensions record ([MS-XLS] 2.4.90), when the sheet declared one. */ readonly usedRange?: RawRange; + /** The sheet's own page setup, as far as its records state it. */ + readonly print: RawPrintSettings; } /** Row record flag bits, in the 32-bit field following unused1 ([MS-XLS] 2.4.221). */ @@ -128,6 +174,18 @@ export function readSheetRecords( const columns: RawColumn[] = []; const merges: RawRange[] = []; let usedRange: RawRange | undefined; + const marginsPt: { + left?: number; + right?: number; + top?: number; + bottom?: number; + } = {}; + const rowBreaks: number[] = []; + const columnBreaks: number[] = []; + let setup: SetupFields | undefined; + let printGridlines: boolean | undefined; + let printHeaders: boolean | undefined; + let fitToPage: boolean | undefined; for (let index = 0; index < records.length; index += 1) { const record = records[index]; @@ -181,15 +239,111 @@ export function readSheetRecords( readFormula(record, stringResultAfter(records, index), formulaSheets), ); break; + case RECORD_SETUP: + setup = readSetup(record); + break; + case RECORD_LEFTMARGIN: + marginsPt.left = readMargin(record); + break; + case RECORD_RIGHTMARGIN: + marginsPt.right = readMargin(record); + break; + case RECORD_TOPMARGIN: + marginsPt.top = readMargin(record); + break; + case RECORD_BOTTOMMARGIN: + marginsPt.bottom = readMargin(record); + break; + case RECORD_PRINTGRID: + printGridlines = readGridlineBooleanRecord(record); + break; + case RECORD_PRINTROWCOL: + printHeaders = readBooleanRecord(record); + break; + case RECORD_WSBOOL: + fitToPage = + (new BlockCursor(record.blocks).u16() & WSBOOL_FLAG_FIT_TO_PAGE) !== + 0; + break; + case RECORD_HORIZONTALPAGEBREAKS: + rowBreaks.push(...readPageBreaks(record)); + break; + case RECORD_VERTICALPAGEBREAKS: + columnBreaks.push(...readPageBreaks(record)); + 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. + // Every other record a worksheet substream carries -- the window settings, the drawing objects, the row-block index -- is not read yet. break; } } + // Spread rather than assigned field by field, so an absent record leaves its field genuinely absent rather than present-and-undefined: RawPrintSettings documents absence as "this file states nothing here", and content.ts's own fallbacks turn on exactly that. + const print: RawPrintSettings = { + marginsPt, + rowBreaks: ascendingDistinct(rowBreaks), + columnBreaks: ascendingDistinct(columnBreaks), + ...(setup === undefined ? {} : { setup }), + ...(printGridlines === undefined ? {} : { printGridlines }), + ...(printHeaders === undefined ? {} : { printHeaders }), + ...(fitToPage === undefined ? {} : { fitToPage }), + }; + return usedRange === undefined - ? { cells, rows, columns, merges } - : { cells, rows, columns, merges, usedRange }; + ? { cells, rows, columns, merges, print } + : { cells, rows, columns, merges, usedRange, print }; +} + +/** Setup ([MS-XLS] 2.4.257): iPaperSize, iScale, iPageStart, iFitWidth, iFitHeight, a flags word, iRes, iVRes, an eight-byte header margin, an eight-byte footer margin, and iCopies. The starting page number, the two print resolutions, the header/footer margins, and the copy count are read past: ContentSheetPrintSettings has no field for any of them, and Margins models only the four page edges. */ +function readSetup(record: RecordGroup): SetupFields { + const cursor = new BlockCursor(record.blocks); + const paperCode = cursor.u16(); + const scalePercent = cursor.u16(); + cursor.skip(2); // iPageStart + const fitWidth = cursor.u16(); + const fitHeight = cursor.u16(); + return { + paperCode, + scalePercent, + fitWidth, + fitHeight, + ...unpackSetupFlags(cursor.u16()), + }; +} + +/** Any of the four margin records ([MS-XLS] 2.4.151, 2.4.219, 2.4.328, 2.4.27): a single Xnum stating that margin in inches. All four share the identical one-field layout, so one reader serves them all. */ +function readMargin(record: RecordGroup): number { + return inchesToPoints(new BlockCursor(record.blocks).f64()); +} + +/** PrintRowCol ([MS-XLS] 2.4.203): a single 16-bit `Boolean` field (2.5.14), whose whole word is the value. [MS-XLS] 2.4.203's own value table states both 0x0000 and 0x0001 as "Row and column headers are not printed", which is a typo in that table rather than two spellings of one meaning -- the record exists precisely to distinguish them, and every real producer writes 1 for printed (confirmed against LibreOffice-written BIFF8, where a sheet with header printing enabled carries 0x0001 and one without carries 0x0000). */ +function readBooleanRecord(record: RecordGroup): boolean { + return new BlockCursor(record.blocks).u16() !== 0; +} + +/** PrintGrid ([MS-XLS] 2.4.202): unlike PrintRowCol above, its 16-bit field is only ONE bit wide (`fPrintGrid`) with the remaining 15 "Undefined, and MUST be ignored" -- so a producer leaving anything in those bits would read as gridlines-on under a bare `!== 0` test. No real producer does (LibreOffice writes 0x0000/0x0001, confirmed against its own BIFF8 output), but masking to the one bit the spec actually defines is what the field's own layout says to do. */ +function readGridlineBooleanRecord(record: RecordGroup): boolean { + return (new BlockCursor(record.blocks).u16() & 0x0001) !== 0; +} + +/** + * HorizontalPageBreaks ([MS-XLS] 2.4.142) and VerticalPageBreaks ([MS-XLS] 2.4.343): a count then that many six-byte structures, each an index on the break's own axis followed by the start and end of the break's extent on the other one. + * + * Only the index is taken. HorzBrk's colStart/colEnd and VertBrk's rowStart/rowEnd say how far along the perpendicular axis the break runs -- a BIFF8 page break can be partial -- and ContentSheetPrintSettings.manualBreaks models a break as a whole-axis index with no extent, so a partial break is carried as a full one rather than dropped. Both structures have the identical three-field shape, so one reader serves both. + */ +function readPageBreaks(record: RecordGroup): number[] { + const cursor = new BlockCursor(record.blocks); + const count = cursor.u16(); + const indices: number[] = []; + for (let index = 0; index < count; index += 1) { + indices.push(cursor.u16()); + cursor.skip(4); // the break's extent along the other axis + } + return indices; +} + +/** Page-break indices, ascending and with duplicates collapsed: two records naming the same row are one page break as far as the schema's own index-only model of a break can express. */ +function ascendingDistinct(values: readonly number[]): number[] { + return [...new Set(values)].sort((a, b) => a - b); } /** diff --git a/packages/xls-codec/src/write.test.ts b/packages/xls-codec/src/write.test.ts index 828bfe850..706a7a799 100644 --- a/packages/xls-codec/src/write.test.ts +++ b/packages/xls-codec/src/write.test.ts @@ -8,12 +8,19 @@ import { assembleTree, ContentDocumentSchema, DocumentTreeSchema, + PAGE_SIZE_A4, PAGE_SIZE_LETTER, rgbHexToColor, } from "document-schema.js"; import { isCompoundFile, readCompoundFile } from "archive-codec"; import { describe, expect, it } from "vitest"; +import { + RECORD_EXTERNSHEET, + RECORD_LBL, + RECORD_SUPBOOK, +} from "./biff/record-types"; +import { readRecords } from "./biff/records"; import { PALETTE_ENTRY_COUNT } from "./biff/xf-colors"; import { BiffWriteError } from "./biff/write-errors"; import type { XlsContentDocument } from "./content"; @@ -24,7 +31,7 @@ import { writeXls, writeXlsContent } from "./write"; // Genuine .xls bytes -- a real [MS-CFB] compound file holding a real BIFF8 Workbook stream -- built by this package's own writer and read back through its own reader, the "primary verification method" this session's writers use throughout (the CFB writer, rtf-codec, wpd-codec). Every test here is a round trip: build a ContentDocument, write it, read it back, and check the read result reflects what was written -- exercising the writer against a reader whose own correctness is independently pinned by content.test.ts's hand-built byte sequences. const POINTS_PER_INCH = 72; -/** The same "Normal" preset content.ts's own reader emits unconditionally, since print settings are outside this writer's scope (see the README) and the read direction never recovers a file's real ones. */ +/** Excel's own "Normal" preset, which is what a sheet with nothing else to say about printing carries -- and, since the reader falls back to exactly these values for a file stating none of the print records, what a round trip through this pair reproduces either way. The print-settings round trips at the end of this file are the ones that exercise real, non-default values. */ const PRINT_SETTINGS: ContentSheetPrintSettings = { pageSize: PAGE_SIZE_LETTER, margins: { @@ -800,3 +807,199 @@ describe("writeXls", () => { expect(() => writeXls(wordTree)).toThrow(BiffWriteError); }); }); + +describe("print settings", () => { + /** Every field ContentSheetPrintSettings carries, each at a value distinct from Excel's own Normal preset, so a round trip that silently fell back to that preset would fail rather than pass by coincidence. */ + const FULL_PRINT_SETTINGS: ContentSheetPrintSettings = { + pageSize: { + widthPt: PAGE_SIZE_A4.heightPt, + heightPt: PAGE_SIZE_A4.widthPt, + }, + margins: { topPt: 72, rightPt: 54, bottomPt: 90, leftPt: 36 }, + printRange: { startRow: 1, startColumn: 1, endRow: 5, endColumn: 3 }, + scalePercent: 80, + repeatRows: { start: 0, end: 1 }, + repeatColumns: { start: 0, end: 0 }, + gridlines: true, + headers: true, + pageOrder: "overThenDown", + manualBreaks: { rows: [10], columns: [3] }, + }; + + function roundTripped( + settings: ContentSheetPrintSettings, + ): ContentSheetPrintSettings | undefined { + const content = document([ + sheet("Printy", [cell(0, 0, { kind: "number", value: 1 })], { + printSettings: settings, + }), + ]); + return readXlsContent(writeXlsContent(content)).sheets[0]?.printSettings; + } + + /** The same settings with no scalePercent and no repeatColumns -- spelled as its own literal rather than derived by deletion, so an optional field a round trip wrongly re-added shows up as an extra key rather than as a matching undefined. */ + const WITHOUT_SCALE_AND_REPEAT_COLUMNS: ContentSheetPrintSettings = { + pageSize: FULL_PRINT_SETTINGS.pageSize, + margins: FULL_PRINT_SETTINGS.margins, + printRange: FULL_PRINT_SETTINGS.printRange, + repeatRows: FULL_PRINT_SETTINGS.repeatRows, + gridlines: FULL_PRINT_SETTINGS.gridlines, + headers: FULL_PRINT_SETTINGS.headers, + pageOrder: FULL_PRINT_SETTINGS.pageOrder, + manualBreaks: FULL_PRINT_SETTINGS.manualBreaks, + }; + + it("round-trips every field of a fully populated print setting", () => { + expect(roundTripped(FULL_PRINT_SETTINGS)).toEqual(FULL_PRINT_SETTINGS); + }); + + it("round-trips fit-to-page in place of a scale percentage", () => { + // The two are mutually exclusive in BIFF8 -- WsBool's own fFitToPage decides which of Setup's fields is live -- so a fit-to-page sheet states no scale at all, in either direction. + const settings: ContentSheetPrintSettings = { + ...WITHOUT_SCALE_AND_REPEAT_COLUMNS, + repeatColumns: FULL_PRINT_SETTINGS.repeatColumns, + fitToPages: { width: 2, height: 3 }, + }; + expect(roundTripped(settings)).toEqual(settings); + }); + + it("round-trips a portrait page size without transposing it", () => { + // A paper code names its paper in portrait and the orientation flag transposes it, so the two directions have to agree on which way round a page is. + const settings: ContentSheetPrintSettings = { + ...PRINT_SETTINGS, + pageSize: PAGE_SIZE_A4, + }; + expect(roundTripped(settings)?.pageSize).toEqual(PAGE_SIZE_A4); + }); + + it("round-trips a sheet whose settings are exactly the Normal preset", () => { + // Nothing in ContentSheetPrintSettings can say "this sheet states nothing", so the writer emits the preset's own values rather than omitting the records -- and the reader's own fallback then agrees with them. + expect(roundTripped(PRINT_SETTINGS)).toEqual(PRINT_SETTINGS); + }); + + it("round-trips an explicit 100% scale onto the absence that means the same thing", () => { + // Setup's own iScale has no spelling for "no declared scale", so the two directions agree on one: 100% is actual size, which is exactly what carrying no scalePercent means. The sheet still prints identically, which is the only thing the field decides. + expect( + roundTripped({ ...PRINT_SETTINGS, scalePercent: 100 })?.scalePercent, + ).toBeUndefined(); + }); + + it("round-trips a repeated row band without inventing a column band", () => { + // A Print_Titles name carrying one band has to come back as one band: the read side tells the two apart by shape, not by position, so a missing column band must not be reconstructed from the row band's own full-width extent. + const settings: ContentSheetPrintSettings = { + ...WITHOUT_SCALE_AND_REPEAT_COLUMNS, + scalePercent: FULL_PRINT_SETTINGS.scalePercent, + }; + expect(roundTripped(settings)).toEqual(settings); + }); + + it("keeps each sheet's own print settings separate", () => { + const content = document([ + sheet("First", [cell(0, 0, { kind: "number", value: 1 })], { + printSettings: FULL_PRINT_SETTINGS, + }), + sheet("Second", [cell(0, 0, { kind: "number", value: 2 })], { + printSettings: { + ...PRINT_SETTINGS, + printRange: { startRow: 0, startColumn: 0, endRow: 9, endColumn: 9 }, + }, + }), + ]); + + const read = readXlsContent(writeXlsContent(content)); + expect(read.sheets[0]?.printSettings).toEqual(FULL_PRINT_SETTINGS); + expect(read.sheets[1]?.printSettings.printRange).toEqual({ + startRow: 0, + startColumn: 0, + endRow: 9, + endColumn: 9, + }); + expect(read.sheets[1]?.printSettings.repeatRows).toBeUndefined(); + }); + + it("writes a page size no paper code names as custom, rather than as a paper it is not", () => { + // Unlike xlsx's pageSetup element, [MS-XLS] 2.4.257's Setup record addresses paper only by code, so the dimensions genuinely cannot be written. iPaperSize 0 is that section's own "custom printer paper sizes", which is true; substituting Letter would not be. The size therefore does not survive the round trip -- the reader falls back to its documented default -- but the sheet, its cells, and every other print setting do. + const content = document([ + sheet("Odd", [cell(0, 0, { kind: "number", value: 1 })], { + printSettings: { + ...PRINT_SETTINGS, + pageSize: { widthPt: 500, heightPt: 400 }, + gridlines: true, + }, + }), + ]); + + const read = readXlsContent(writeXlsContent(content)).sheets[0]; + expect(read?.printSettings.pageSize).toEqual(PAGE_SIZE_LETTER); + expect(read?.printSettings.gridlines).toBe(true); + expect(read?.cells).toHaveLength(1); + }); + + it("clamps a scale and a fit-to-page count past what their own Setup fields can hold", () => { + // ContentSheetPrintSettings bounds neither from above, and Setup's own fields are 16-bit -- so an unclamped value would wrap and state a different intent confidently. [MS-XLS] 2.4.257 caps iFitWidth/iFitHeight at 32767; iScale has only its field's own width. + expect( + roundTripped({ ...PRINT_SETTINGS, scalePercent: 200_000 })?.scalePercent, + ).toBe(0xffff); + expect( + roundTripped({ + ...PRINT_SETTINGS, + fitToPages: { width: 100_000, height: 2 }, + })?.fitToPages, + ).toEqual({ width: 32767, height: 2 }); + }); + + it("clamps a print range and a repeated band past BIFF8's own row/column ceiling, rather than wrapping to an in-grid coordinate", () => { + // printRange/repeatRows/repeatColumns are bounded from above by neither ContentSheetPrintRange/ContentSheetRepeatRange nor writeArea3d's own 16-bit field -- an unclamped end row of 70000 would wrap to 4464, a plausible-looking coordinate that silently states a smaller range than asked for. + // + // repeatRows/repeatColumns both start past 0: a band clamped to first 0/last MAX_*_INDEX on the axis it already fully spans by construction would ALSO fully span the perpendicular one, making it shape-ambiguous with "the whole sheet" (see print-names.ts's own classification note) and reading back as neither band -- an existing, documented tradeoff of the shape-based discriminant, not something this clamp introduces or is responsible for working around. + const settings: ContentSheetPrintSettings = { + ...PRINT_SETTINGS, + printRange: { + startRow: 2, + startColumn: 2, + endRow: 99_999, + endColumn: 300, + }, + repeatRows: { start: 2, end: 70_000 }, + repeatColumns: { start: 1, end: 300 }, + }; + const read = roundTripped(settings); + expect(read?.printRange).toEqual({ + startRow: 2, + startColumn: 2, + endRow: 0xffff, + endColumn: 0xff, + }); + expect(read?.repeatRows).toEqual({ start: 2, end: 0xffff }); + expect(read?.repeatColumns).toEqual({ start: 1, end: 0xff }); + }); + + it("drops a manual page break past BIFF8's own row/column ceiling, rather than wrapping to an in-grid index", () => { + // Dropped rather than clamped, unlike a print range/repeated band above: a page break is a single position, and clamping one would insert a break at the grid's own edge nobody asked for. + const settings: ContentSheetPrintSettings = { + ...PRINT_SETTINGS, + manualBreaks: { rows: [10, 70_000], columns: [3, 400] }, + }; + expect(roundTripped(settings)?.manualBreaks).toEqual({ + rows: [10], + columns: [3], + }); + }); + + it("writes no defined name at all for a workbook declaring no print range or band", () => { + // The SupBook and ExternSheet a print name's own 3D reference resolves through exist only to serve one, so a workbook needing none stays as minimal as it was before print settings were written. + const bytes = writeXlsContent( + document([sheet("Plain", [cell(0, 0, { kind: "number", value: 1 })])]), + ); + const stream = readCompoundFile(bytes).find( + (entry) => entry.path === "Workbook", + )?.bytes; + const types = [...readRecords(stream ?? new Uint8Array())].map( + (record) => record.type, + ); + + expect(types).not.toContain(RECORD_LBL); + expect(types).not.toContain(RECORD_SUPBOOK); + expect(types).not.toContain(RECORD_EXTERNSHEET); + }); +}); diff --git a/packages/xls-codec/src/write.ts b/packages/xls-codec/src/write.ts index d1c24dc09..66487e92d 100644 --- a/packages/xls-codec/src/write.ts +++ b/packages/xls-codec/src/write.ts @@ -37,6 +37,10 @@ import { type CellXfPlanEntry, type WorkbookGlobalsPlan, } from "./workbook/globals-writer"; +import { + printNameEntriesFor, + type PrintNamePlanEntry, +} from "./workbook/print-names"; import { buildWorksheetSubstream, type SheetWriteContext, @@ -238,7 +242,7 @@ function buildPalettePlan(sheets: readonly ContentSheet[]): PalettePlan { // Slow path: at least one colour needs a genuinely custom entry. Allocate every distinct colour -- not just the non-default ones -- into fresh slots in first-use order, so the record this writes is fully self-consistent. if (colorByHex.size > PALETTE_ENTRY_COUNT) { throw new BiffWriteError( - `workbook needs ${colorByHex.size} distinct decoration colours, more than the ${PALETTE_ENTRY_COUNT} entries [MS-XLS] 2.4.204's own Palette record can hold`, + `workbook needs ${colorByHex.size} distinct decoration colours, more than the ${PALETTE_ENTRY_COUNT} entries [MS-XLS] 2.4.188's own Palette record can hold`, ); } const icvByHex = new Map(); @@ -410,6 +414,19 @@ function buildSstPlan(sheets: readonly ContentSheet[]): SstPlan { }; } +/** + * The built-in Print_Area/Print_Titles defined names every sheet's own print settings need, across the whole workbook. + * + * Workbook-wide rather than per-sheet because that is where BIFF8 puts them: an Lbl lives in the globals substream and names its sheet through its own itab, so a sheet's print RANGE is written nowhere near the sheet's own records. The ixti each name's PtgArea3d refers to is the sheet's own index, matching the one-XTI-per-sheet ExternSheet record globals-writer.ts writes alongside them. + */ +function buildPrintNamePlan( + sheets: readonly ContentSheet[], +): PrintNamePlanEntry[] { + return sheets.flatMap((sheet, sheetIndex) => + printNameEntriesFor(sheetIndex, sheetIndex, sheet.printSettings), + ); +} + /** Patches a BoundSheet8's own lbPlyPos field in place: a 4-byte little-endian integer at a byte offset globals-writer.ts already reported, once the real value -- where that sheet's own substream landed in the finished workbook stream -- is known. */ function patchBoundSheetOffsets( globalsBytes: Uint8Array, @@ -461,6 +478,7 @@ function buildWorkbookStream( sharedStrings: sstPlan.strings, sharedStringTotalCount: sstPlan.totalCount, paletteColors: palettePlan.paletteColors, + printNames: buildPrintNamePlan(content.sheets), }; const globals = buildWorkbookGlobals(globalsPlan); diff --git a/packages/xls-codec/test/smoke.test.mjs b/packages/xls-codec/test/smoke.test.mjs index f80af725d..5386d1718 100644 --- a/packages/xls-codec/test/smoke.test.mjs +++ b/packages/xls-codec/test/smoke.test.mjs @@ -77,6 +77,14 @@ describe('dist/ deep imports resolve for every advertised module, in both builds { path: '../dist/biff/substreams.js', exports: ['groupRecords', 'splitSubstreams'] }, { path: '../dist/workbook/globals.js', exports: ['readWorkbookGlobals'] }, { path: '../dist/workbook/sheet.js', exports: ['readSheetRecords'] }, + { + path: '../dist/workbook/print-names.js', + exports: ['readPrintNames', 'writePrintNameRecords'], + }, + { + path: '../dist/biff/print-setup.js', + exports: ['pageSizeFromSetup', 'paperSelectionFor'], + }, { path: '../dist/serial.js', exports: ['serialToIsoDate'] }, { path: '../dist/units.js', exports: ['twipsToPoints'] }, { path: '../dist/container.js', exports: ['readWorkbookStreams', 'isXlsFile', 'SUMMARY_INFORMATION_STREAM'] },