diff --git a/README.md b/README.md index 097583ba1..74fa7c0b8 100644 --- a/README.md +++ b/README.md @@ -31,18 +31,18 @@ The packages layer from foundation up to user-facing interfaces. Each depends on Each converts one document format to and from the shared schema, built on `document-schema.js`: -| Package | Formats | -| ----------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| [`ooxml.js`](packages/ooxml.js/README.md) | OOXML packages (docx, pptx, xlsx) to and from JSON. | -| [`odf.js`](packages/odf.js/README.md) | OpenDocument packages (odt, ods, odp, odg) to and from JSON, plus read and write support for the pre-OASIS OpenOffice.org 1.x documents ODF was based on (sxw, sxc, sxi, sxd). | -| [`markdown-codec`](packages/markdown-codec/README.md) | CommonMark+GFM to and from the shared content schema. | -| [`pdf-codec`](packages/pdf-codec/README.md) | Parses arbitrary real-world PDFs and generates new ones, also depending on `byte-codec`. | -| [`epub-codec`](packages/epub-codec/README.md) | Flowable EPUB 2/3 to and from the shared content schema; writes EPUB 3 only. | -| [`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. | +| Package | Formats | +| ----------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| [`ooxml.js`](packages/ooxml.js/README.md) | OOXML packages (docx, pptx, xlsx) to and from JSON. | +| [`odf.js`](packages/odf.js/README.md) | OpenDocument packages (odt, ods, odp, odg) to and from JSON, plus read and write support for the pre-OASIS OpenOffice.org 1.x documents ODF was based on (sxw, sxc, sxi, sxd). | +| [`markdown-codec`](packages/markdown-codec/README.md) | CommonMark+GFM to and from the shared content schema. | +| [`pdf-codec`](packages/pdf-codec/README.md) | Parses arbitrary real-world PDFs and generates new ones, also depending on `byte-codec`. | +| [`epub-codec`](packages/epub-codec/README.md) | Flowable EPUB 2/3 to and from the shared content schema; writes EPUB 3 only. | +| [`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, 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. | +| [`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, cell alignment, 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 070216a21..f5f7caf3d 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, 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. +**`xls`'s writer never touches formulas, per-cell fonts, 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, its own alignment, 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 7a696ea57..081f3baa8 100644 --- a/packages/xls-codec/README.md +++ b/packages/xls-codec/README.md @@ -20,19 +20,20 @@ Under active development, with real, tested **read and write** support. Built an - **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)). +- **Cell alignment** — a cell's own horizontal (`left`/`center`/`right`/`justify`) and vertical (`top`/`middle`) alignment, read from and written to `XF`'s trailing `CellXF`/`StyleXF` payload's own leading word, in both directions and verified against real LibreOffice-produced BIFF8 (see [Cell alignment](#cell-alignment)). 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, 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: +What `writeXlsContent`/`writeXls` cover: every `ContentCellValue` kind a real `.xls` can hold (`number`, `percentage`, `currency`, `boolean`, `date`, `time`, `dateTime`, `string`, `error`; an `empty` cell is written as a `Blank` record when it carries formatting and as nothing at all when it does not — see below), merged ranges (`colSpan`/`rowSpan`), row heights and hidden rows, column widths and hidden columns, multiple sheets, explicit and default number formats, a shared string table deduplicated across the whole workbook, every field of a sheet's own [print settings](#print-settings), a cell's own [alignment](#cell-alignment), and — see [Cell decoration](#cell-decoration) — a cell's own 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. | +| Per-cell font | The reader does not read one back: `ContentSheetCell` has no cell-level font field at all (`ooxml.js`'s xlsx reader makes the identical font-scope choice), so writing a real value here would be unverifiable by round trip. Every `XF` this writer emits still references the same single font. | +| `MulBlank`/`RK`/`MulRk` | Pure compaction optimisations over information a plain `Blank`/`Number`/`LabelSst`/`BoolErr` record already carries losslessly. (`Blank` itself _is_ written, for a formatted empty cell — see the `empty` row below.) | +| An `empty`-kind cell carrying no formatting | Written as nothing at all, which is what round-trips: `content.ts`'s reader drops an unformatted blank cell it reads, and a merged range's empty anchor is independently reconstructed from `MergeCells` alone. A formatted one (a fill, a border, or a non-default alignment) is the opposite case — its formatting exists only in the `XF` a cell record names, so it gets a real `Blank` record ([MS-XLS] 2.4.20) and round-trips with that formatting intact. | | 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. | @@ -45,12 +46,12 @@ Column widths round-trip to the nearest pixel Excel's own integer-pixel-grid qua 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. +- **Cell decoration and alignment resolved for real; 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 same payload's leading word is resolved too — see [Cell alignment](#cell-alignment) for the full `alc`/`alcV` mapping. `Font` records are not read at all: `ContentSheetCell` has no cell-level font field, and `ooxml.js`'s xlsx reader makes the identical scope choice. - **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) 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, and per-cell font) are tracked on [#815](https://github.com/ExaDev/documents.js/issues/815). ## Cell decoration @@ -68,7 +69,19 @@ The decorated-blank case was checked the same way, in both directions and agains **Decoration on a cell with no value.** A cell can be empty and still have something to show, and BIFF8 says so with a `Blank` record ([MS-XLS] 2.4.20) — a cell header naming an `XF` and nothing else, written precisely because that `XF` carries a fill or a border. Both directions honour it: a `Blank` or `MulBlank` whose `XF` resolves to real decoration is read as an `empty`-kind cell carrying that decoration rather than dropped, and an `empty` cell carrying a background or a border is written back as a `Blank` record pointing at an `XF` encoding it. An empty cell with no decoration is still written as nothing at all and still read as absent, which is what keeps `ContentSheet`'s cell array sparse; "decoration this reader can express" is the same test the value-cell path applies, so a fill pattern beyond solid or an unresolvable colour leaves a blank cell dropped exactly as before. -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. +A per-cell font remains out of scope in both directions — see [Read-side gaps](#read-side-gaps) and [Writer scope](#writer-scope) above. The payload's leading word, once similarly out of scope, is now [Cell alignment](#cell-alignment) below. + +## Cell alignment + +A cell's own horizontal and vertical alignment are read from and written to the same `XF` trailing `CellXF`/`StyleXF` payload [Cell decoration](#cell-decoration) covers ([MS-XLS] 2.4.353), but a different pair of fields within it: `alc` and `alcV`, the leading word's own bits 0-2 and 4-6 ([MS-XLS] [`HorizAlign`](https://learn.microsoft.com/en-us/openspecs/office_file_formats/ms-xls/75e17a8f-9cd4-4b37-927e-4b0a54ef9266) and [`VertAlign`](https://learn.microsoft.com/en-us/openspecs/office_file_formats/ms-xls/1e9eb7d3-0cd8-42a3-af1e-f523105a5e93)) — verified both by round trip and against a real, independent BIFF8 implementation, the identical bar [Cell decoration](#cell-decoration) and [Print settings](#print-settings) are held to. `src/biff/xf-colors.ts` is the one place `alc`/`alcV` are packed or unpacked in either direction (`resolveHorizontalAlignment`/`horizAlignTokenFor` and `resolveVerticalAlignment`/`vertAlignTokenFor`), so the two directions cannot silently disagree on what a given bit pattern means. + +**Only the members `ContentSheetCell.alignment`/`verticalAlignment` can express survive, matching `ooxml.js`'s own xlsx alignment policy exactly.** `HorizAlign` names eight members; only `ALCLEFT`/`ALCCTR`/`ALCRIGHT`/`ALCJUST` have an `Alignment` counterpart (`left`/`center`/`right`/`justify`). `ALCGEN` — general alignment — is the identical semantics to `alignment` being absent (the schema's own "numeric right, text left" value-kind default already means the same thing), so it round-trips to and from `undefined` rather than a literal member that would override the default it means to request. `ALCFILL`/`ALCCONTCTR`/`ALCDIST` are real members with no schema counterpart at all and are left unread, the same policy `odf.js`'s own alignment reader applies to a construct its schema has no member for. `VertAlign` names five members; only `ALCVTOP`/`ALCVCTR` have a `verticalAlignment` counterpart (`top`/`middle`). `ALCVBOT` is the schema's own documented default for an absent `verticalAlignment` ("no value-kind default to fall back to, so its own absence means `'bottom'` outright"), so it round-trips to and from `undefined` the same way `ALCGEN` does; `ALCVJUST`/`ALCVDIST` are left unread for the identical reason `ALCFILL`/`ALCCONTCTR`/`ALCDIST` are. + +**Interned into the cell-XF table alongside decoration, not a separate table.** `write.ts`'s own `buildCellXfPlan` already deduplicated cells sharing an identical (number format, decoration) pair into one `XF` record; the interning key is now a (number format, alignment, vertical alignment, decoration) tuple, so two cells sharing all four still share one record and a cell differing in only its alignment still mints its own. A cell with General formatting, no alignment, and no decoration still resolves to the workbook's own implicit `GENERAL_CELL_XF_INDEX` with no new `XF` record at all, exactly as before alignment was modelled. + +**Formatting on a cell with no value, widened.** [Cell decoration](#cell-decoration)'s own `Blank`-record rule (a decorated empty cell is worth a record; an undecorated one is written as nothing at all) now triggers on alignment too: `written-cells.ts`'s `cellCarriesFormatting` (renamed from `cellCarriesDecoration` when this was added) treats a non-default `alignment` or `verticalAlignment` the same way it already treats a background or a border. An empty cell stating only `alignment: 'center'` therefore gets a real `Blank` record and round-trips with that alignment intact, rather than being silently dropped as if it had nothing to say. + +**Verified against a real, independent BIFF8 implementation, not just this package's own reader/writer pair.** A `.xls` this package writes — one cell per `Alignment` member, one per `verticalAlignment` member, and one combining a non-default horizontal and vertical alignment on the same cell — opens in LibreOffice 26.2.5.2 with every alignment intact, confirmed by converting it back through `soffice --headless --convert-to fods` and inspecting the cell styles LibreOffice itself recovers: each `left`/`center`/`right`/`justify` cell reads back as LibreOffice's own `fo:text-align="start"/"center"/"end"/"justify"`, each `top`/`middle` cell as `style:vertical-align="top"/"middle"`, and the combined cell as both at once on the identical style. Going the other way, a `.xls` built directly by LibreOffice (`soffice --headless --convert-to xls`, from a hand-authored `.fods` declaring the same real `fo:text-align`/`style:vertical-align` cell styles) is read correctly by this package's own reader, every cell recovering the exact alignment LibreOffice's own style declared. ## Print settings diff --git a/packages/xls-codec/src/biff/xf-colors.ts b/packages/xls-codec/src/biff/xf-colors.ts index ab40c6fe7..52948972a 100644 --- a/packages/xls-codec/src/biff/xf-colors.ts +++ b/packages/xls-codec/src/biff/xf-colors.ts @@ -1,4 +1,5 @@ import type { + Alignment, BorderWeight, Color, ContentBorder, @@ -13,9 +14,9 @@ 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.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. +// The colour, border/fill, and alignment 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 leading alignment word and its 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. +// Deliberately still out of scope: per-cell fonts, matching ooxml.js's own xlsx reader -- this package has never modelled a per-cell font, and ContentSheetCell has no field for one. // --- FillPattern ([MS-XLS] "FillPattern" enumeration) --- @@ -24,6 +25,86 @@ export const FILL_PATTERN_NONE = 0x00; /** FLSSOLID: a solid fill, the only pattern this package maps onto ContentSheetCell.background -- "If this value is 1 ... then only icvFore is rendered" ([MS-XLS] CellXF). Every other pattern (50%/75%/25% gray, the stripe and crosshatch families, ...) is a real information-loss case this reader does not approximate: see resolveFillBackground below. */ export const FILL_PATTERN_SOLID = 0x01; +// --- HorizAlign / VertAlign ([MS-XLS] "HorizAlign"/"VertAlign" enumerations), the CellXF/StyleXF payload's own leading alc/alcV fields --- + +// HorizAlign (https://learn.microsoft.com/en-us/openspecs/office_file_formats/ms-xls/75e17a8f-9cd4-4b37-927e-4b0a54ef9266): ALCGEN, the "use the cell's own value-kind default" spelling, is the identical semantics to ContentSheetCell.alignment being absent, so it round-trips to and from undefined rather than a literal Alignment member that would override the default it means to request. ALCGEN(0)/ALCLEFT(1)/ALCCTR(2)/ALCRIGHT(3)/ALCJUST(5) are the five members the functions below map to or from. ALCFILL(4)/ALCCONTCTR(6)/ALCDIST(7) are real HorizAlign members with no ContentSheetCell.alignment counterpart: left unread on the way in (resolveHorizontalAlignment's own default branch), and never written on the way out (horizAlignTokenFor has no case that produces them). +const ALC_GENERAL = 0x0; +const ALC_LEFT = 0x1; +const ALC_CENTER = 0x2; +const ALC_RIGHT = 0x3; +const ALC_JUSTIFY = 0x5; + +// VertAlign (https://learn.microsoft.com/en-us/openspecs/office_file_formats/ms-xls/1e9eb7d3-0cd8-42a3-af1e-f523105a5e93): ALCVTOP(0)/ALCVCTR(1)/ALCVBOT(2) are the three members the functions below map to or from -- ALCVBOT is the default this package's own schema documents for an absent verticalAlignment, matching xf-writer.ts's own VERT_ALIGN_BOTTOM. ALCVJUST(3)/ALCVDIST(4) are real VertAlign members with no ContentSheetCell.verticalAlignment counterpart: left unread, the identical policy ALCFILL/ALCCONTCTR/ALCDIST get above. +const ALCV_TOP = 0x0; +const ALCV_CENTER = 0x1; +const ALCV_BOTTOM = 0x2; + +/** alc (a CellXF/StyleXF payload's word1, bits 0-2) -> ContentSheetCell.alignment, or undefined for ALCGEN (the value-kind default this field being absent already requests) and for the three HorizAlign members (ALCFILL/ALCCONTCTR/ALCDIST) Alignment has no member for -- matching ooxml.js's readHorizontalAlignment policy of only the four direct members surviving. */ +export function resolveHorizontalAlignment(alc: number): Alignment | undefined { + switch (alc) { + case ALC_LEFT: + return "left"; + case ALC_CENTER: + return "center"; + case ALC_RIGHT: + return "right"; + case ALC_JUSTIFY: + return "justify"; + default: + return undefined; + } +} + +/** ContentSheetCell.alignment -> the alc token to pack into word1 -- undefined maps to ALCGEN, the "use the value-kind default" token every genuinely unaligned cell already carried before this module modelled alignment at all. */ +export function horizAlignTokenFor(alignment: Alignment | undefined): number { + switch (alignment) { + case "left": + return ALC_LEFT; + case "center": + return ALC_CENTER; + case "right": + return ALC_RIGHT; + case "justify": + return ALC_JUSTIFY; + default: + return ALC_GENERAL; + } +} + +/** alcV (word1, bits 4-6) -> ContentSheetCell.verticalAlignment, or undefined for ALCVBOT (the schema's own documented default for an absent verticalAlignment) and for the two VertAlign members (ALCVJUST/ALCVDIST) the schema has no member for -- matching ooxml.js's readVerticalAlignment policy. */ +export function resolveVerticalAlignment( + alcV: number, +): "top" | "middle" | "bottom" | undefined { + switch (alcV) { + case ALCV_TOP: + return "top"; + case ALCV_CENTER: + return "middle"; + default: + return undefined; + } +} + +/** ContentSheetCell.verticalAlignment -> the alcV token to pack into word1 -- undefined (meaning 'bottom', the schema's own documented default) and the literal 'bottom' both map to ALCVBOT, exactly what an unaligned cell already carried before this module modelled alignment at all. */ +export function vertAlignTokenFor( + verticalAlignment: "top" | "middle" | "bottom" | undefined, +): number { + switch (verticalAlignment) { + case "top": + return ALCV_TOP; + case "middle": + return ALCV_CENTER; + default: + return ALCV_BOTTOM; + } +} + +/** The alignment fields word1's alc/alcV carry, read or write side alike -- undefined in either field means the value-kind default (horizontal) or ALCVBOT (vertical), the identical meaning ContentSheetCell.alignment/verticalAlignment being absent already carries. */ +export interface XfAlignmentFields { + readonly horizontal: Alignment | undefined; + readonly vertical: "top" | "middle" | "bottom" | undefined; +} + // --- BorderStyle ([MS-XLS] "BorderStyle" enumeration) --- export const BORDER_STYLE_NONE = 0x0; @@ -278,6 +359,16 @@ export const UNDECORATED_XF_FIELDS: XfDecorationFields = { bottom: UNDECORATED_EDGE, }; +/** Unpacks word1 -- the CellXF/StyleXF trailing payload's leading word ([MS-XLS] 2.4.353's own field table, cited in full in xf-writer.ts's packAlignmentPrefix) -- into the two fields this package's schema can express: alc (bits 0-2) and alcV (bits 4-6). Every other field the word carries (fWrap, fJustLast, trot, cIndent, fShrinkToFit, iReadOrder, the fAtr* inheritance flags) has no ContentSheetCell counterpart and is not read. */ +export function unpackXfAlignment(word1: number): XfAlignmentFields { + const alc = word1 & 0x7; + const alcV = (word1 >>> 4) & 0x7; + return { + horizontal: resolveHorizontalAlignment(alc), + vertical: resolveVerticalAlignment(alcV), + }; +} + /** Unpacks the three raw words a CellXF/StyleXF trailing payload's border/fill fields live in ([MS-XLS] 2.4.353's own field table, cited in full in xf-writer.ts's packXfDecorationWords below) into XfDecorationFields. word2 is the 32-bit border word (dgLeft/dgRight/dgTop/dgBottom/icvLeft/icvRight/grbitDiag), word3 the 32-bit fill-pattern word (icvTop/icvBottom/icvDiag/dgDiag/fHasXFExt/fls), word4 the 16-bit fill-colour word (icvFore/icvBack/...). */ export function unpackXfDecoration( word2: number, diff --git a/packages/xls-codec/src/biff/xf-writer.ts b/packages/xls-codec/src/biff/xf-writer.ts index 72550cc7a..bb3eac9bc 100644 --- a/packages/xls-codec/src/biff/xf-writer.ts +++ b/packages/xls-codec/src/biff/xf-writer.ts @@ -10,24 +10,21 @@ import { writeRecord } from "./record-writer"; import { writeXLUnicodeString } from "./string-writer"; import { BiffWriteError } from "./write-errors"; import { + horizAlignTokenFor, longRgbBytesOf, packXfDecorationWords, + vertAlignTokenFor, type XfDecorationFields, } from "./xf-colors"; -import type { Color } from "document-schema.js"; +import type { Alignment, 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.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. +// A cell XF's own fill/border decoration and horizontal/vertical alignment are modelled from document-schema.js's ContentSheetCell.background/borders/alignment/verticalAlignment: this writer's own reader reads a cell's CellXF payload back in full (workbook/globals.ts's readCellFormat), so both round-trip -- see xls-codec's README, "Cell decoration" and "Cell alignment". A per-cell font remains out of scope (the reader still does not read one back, and ContentSheetCell has no field for it), so every CellXF/StyleXF field below still defaults to the same spec-legal, undecorated 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 leading alignment word and its border/fill words lives in xf-colors.ts, shared with workbook/globals.ts's own unpacking of the identical layout on read. -/** VertAlign ([MS-XLS] 2.5.339-adjacent enumeration): bottom vertical alignment, the default this package's own schema documents for an absent `verticalAlignment`. */ -const VERT_ALIGN_BOTTOM = 0x02; - -/** Packs the shared "no decoration" alignment/trot/indent word every CellXF and StyleXF opens with: alc=General(0), fWrap=0, alcV=Bottom(2), fJustLast=0, trot=0, cIndent=0, fShrinkToFit=0, reserved1=0, iReadOrder=0 (context-dependent default). Returns the low 24 bits (alc..iReadOrder); the caller ORs in whatever the next 8 bits mean for its own shape (CellXF's fAtr* flags, or StyleXF's all-zero unused byte). */ -function packAlignmentPrefix(): number { - const alc = 0; +/** Packs the shared trot/indent/fJustLast/fShrinkToFit/iReadOrder word every CellXF and StyleXF opens with, at whatever alc/alcV tokens the caller resolved (xf-colors.ts's horizAlignTokenFor/vertAlignTokenFor -- ALC_GENERAL(0)/ALCV_BOTTOM(2) for a caller passing neither, the identical bytes this word always carried before alignment was modelled). fWrap=0, fJustLast=0, trot=0, cIndent=0, fShrinkToFit=0, reserved1=0, iReadOrder=0: this writer never sets any of them, since ContentSheetCell has no field for wrap/rotation/indent/shrink-to-fit/reading-order. Returns the low 24 bits (alc..iReadOrder); the caller ORs in whatever the next 8 bits mean for its own shape (CellXF's fAtr* flags, or StyleXF's all-zero unused byte). */ +function packAlignmentPrefix(alc: number, alcV: number): number { const fWrap = 0; - const fJustLast = 0; const trot = 0; const cIndent = 0; @@ -37,7 +34,7 @@ function packAlignmentPrefix(): number { return ( (alc & 0x7) | ((fWrap & 0x1) << 3) | - ((VERT_ALIGN_BOTTOM & 0x7) << 4) | + ((alcV & 0x7) << 4) | ((fJustLast & 0x1) << 7) | ((trot & 0xff) << 8) | ((cIndent & 0xf) << 16) | @@ -47,8 +44,12 @@ function packAlignmentPrefix(): number { ); } -/** CellXF ([MS-XLS] section under XF, fStyle=0's own "Data" payload): 14 bytes -- alignment defaults plus whatever border/fill decoration is given (undecorated when omitted), with every fAtr* bit set so the format is explicit rather than inherited from its parent style XF. The border/fill words themselves are packed by xf-colors.ts's packXfDecorationWords, shared with workbook/globals.ts's inverse unpacking on read. https://learn.microsoft.com/en-us/openspecs/office_file_formats/ms-xls/671c8577-901f-4215-9ebf-6f5890e5896d */ -function packCellXf(decoration?: XfDecorationFields): Uint8Array { +/** CellXF ([MS-XLS] section under XF, fStyle=0's own "Data" payload): 14 bytes -- the given alignment (general/bottom when omitted) plus whatever border/fill decoration is given (undecorated when omitted), with every fAtr* bit set so the format is explicit rather than inherited from its parent style XF. The alignment word and the border/fill words themselves are packed by xf-colors.ts's horizAlignTokenFor/vertAlignTokenFor and packXfDecorationWords, shared with workbook/globals.ts's inverse unpacking on read. https://learn.microsoft.com/en-us/openspecs/office_file_formats/ms-xls/671c8577-901f-4215-9ebf-6f5890e5896d */ +function packCellXf(options: { + readonly alignment?: Alignment; + readonly verticalAlignment?: "top" | "middle" | "bottom"; + readonly decoration?: XfDecorationFields; +}): Uint8Array { const fAtrNum = 1; const fAtrFnt = 1; const fAtrAlc = 1; @@ -56,14 +57,17 @@ function packCellXf(decoration?: XfDecorationFields): Uint8Array { const fAtrPat = 1; const fAtrProt = 1; const word1 = - packAlignmentPrefix() | + packAlignmentPrefix( + horizAlignTokenFor(options.alignment), + vertAlignTokenFor(options.verticalAlignment), + ) | ((fAtrNum & 0x1) << 26) | ((fAtrFnt & 0x1) << 27) | ((fAtrAlc & 0x1) << 28) | ((fAtrBdr & 0x1) << 29) | ((fAtrPat & 0x1) << 30) | ((fAtrProt & 0x1) << 31); - const { word2, word3, word4 } = packXfDecorationWords(decoration); + const { word2, word3, word4 } = packXfDecorationWords(options.decoration); return new RecordBuilder() .u32(word1) .u32(word2) @@ -72,9 +76,12 @@ function packCellXf(decoration?: XfDecorationFields): Uint8Array { .build(); } -/** StyleXF ([MS-XLS] 2.4.353's fStyle=1 "Data" payload): 14 bytes, the same undecorated defaults with the trailing byte of word1 unused rather than carrying fAtr* flags. Never carries real decoration -- the fifteen built-in cell styles this writer emits (BUILTIN_STYLES in globals-writer.ts) are templates a cell XF's own ixfParent points at, not something a cell's own decoration is written onto. https://learn.microsoft.com/en-us/openspecs/office_file_formats/ms-xls/38cad019-5977-49bf-a55a-6e2e9feaca74 */ +/** StyleXF ([MS-XLS] 2.4.353's fStyle=1 "Data" payload): 14 bytes, the same general-alignment/bottom-vertical-alignment/undecorated defaults with the trailing byte of word1 unused rather than carrying fAtr* flags. Never carries real alignment or decoration -- the fifteen built-in cell styles this writer emits (BUILTIN_STYLES in globals-writer.ts) are templates a cell XF's own ixfParent points at, not something a cell's own formatting is written onto. https://learn.microsoft.com/en-us/openspecs/office_file_formats/ms-xls/38cad019-5977-49bf-a55a-6e2e9feaca74 */ function packStyleXf(): Uint8Array { - const word1 = packAlignmentPrefix(); // top 8 bits (the "unused" byte) stay 0 + const word1 = packAlignmentPrefix( + horizAlignTokenFor(undefined), + vertAlignTokenFor(undefined), + ); // top 8 bits (the "unused" byte) stay 0 const { word2, word3, word4 } = packXfDecorationWords(); return new RecordBuilder() .u32(word1) @@ -104,17 +111,25 @@ function packXfFlags(options: { /** ixfParent's own "no inheritance" spelling for a cell style XF ([MS-XLS] 2.4.353: "If fStyle equals 1, this field SHOULD equal 0xFFF"). */ const STYLE_XF_NO_PARENT = 0xfff; -/** Writes one cell-format XF record (fStyle=0): ifnt/ifmt as given, ixfParent pointing at the Normal cell-style XF (index 0), a CellXF payload carrying `decoration`'s own fill/border fields -- an undecorated payload (the same bytes this always wrote before decoration existed) when omitted. Twenty bytes total ([MS-XLS] 2.4.353). */ +/** Writes one cell-format XF record (fStyle=0): ifnt/ifmt as given, ixfParent pointing at the Normal cell-style XF (index 0), a CellXF payload carrying `alignment`/`verticalAlignment`/`decoration`'s own fields -- general alignment, bottom vertical alignment, and no fill/border (the same bytes this always wrote before either existed) for whatever is omitted. Twenty bytes total ([MS-XLS] 2.4.353). */ export function writeCellXfRecord(options: { readonly fontIndex: number; readonly formatId: number; + readonly alignment?: Alignment; + readonly verticalAlignment?: "top" | "middle" | "bottom"; readonly decoration?: XfDecorationFields; }): Uint8Array { const data = new RecordBuilder() .u16(options.fontIndex) .u16(options.formatId) .u16(packXfFlags({ fStyle: false, ixfParent: 0 })) - .bytes(packCellXf(options.decoration)) + .bytes( + packCellXf({ + alignment: options.alignment, + verticalAlignment: options.verticalAlignment, + decoration: options.decoration, + }), + ) .build(); return writeRecord(RECORD_XF, data); } diff --git a/packages/xls-codec/src/content.ts b/packages/xls-codec/src/content.ts index b70a3c1bc..0fda9721f 100644 --- a/packages/xls-codec/src/content.ts +++ b/packages/xls-codec/src/content.ts @@ -3,6 +3,7 @@ import { summaryInformationToLayoutMetadata, } from "archive-codec"; import type { + Alignment, Color, ContentCellBorders, ContentCellValue, @@ -304,13 +305,16 @@ function mapCell( cell: RawCell, globals: WorkbookGlobals, ): ContentSheetCell | undefined { - // Resolved before the blank check, because whether a blank cell is worth carrying is exactly the question of whether these two find anything. Resolved rather than read off the XF's raw fields, so a decoration this reader declines to express -- a fill pattern beyond solid, an unrecognised BorderStyle token, an icv with no fixed RGB value -- counts as none here too. + // Resolved before the blank check, because whether a blank cell is worth carrying is exactly the question of whether any of these find anything. Resolved rather than read off the XF's raw fields, so a decoration this reader declines to express -- a fill pattern beyond solid, an unrecognised BorderStyle token, an icv with no fixed RGB value -- counts as none here too. const background = backgroundOf(globals, cell.xfIndex); const borders = bordersOf(globals, cell.xfIndex); + const { alignment, verticalAlignment } = alignmentOf(globals, cell.xfIndex); if ( cell.value.kind === "blank" && background === undefined && - borders === undefined + borders === undefined && + alignment === undefined && + verticalAlignment === undefined ) { return undefined; } @@ -334,6 +338,12 @@ function mapCell( if (borders !== undefined) { mapped.borders = borders; } + if (alignment !== undefined) { + mapped.alignment = alignment; + } + if (verticalAlignment !== undefined) { + mapped.verticalAlignment = verticalAlignment; + } return mapped; } @@ -357,6 +367,28 @@ function backgroundOf( ); } +/** A cell's own resolved horizontal/vertical alignment -- already the exact Alignment/verticalAlignment members (or undefined) globals.ts's readCellFormat resolved through xf-colors.ts's unpackXfAlignment, so this is a lookup rather than a further resolution step, mirroring backgroundOf/bordersOf's own shape. Both fields undefined for a cell whose XF resolves to no CellFormat at all (an out-of-range xfIndex), matching every other resolveXOf helper's behaviour in that case. */ +function alignmentOf( + globals: WorkbookGlobals, + xfIndex: number, +): { alignment?: Alignment; verticalAlignment?: "top" | "middle" | "bottom" } { + const format = globals.cellFormats[xfIndex]; + if (format === undefined) { + return {}; + } + const result: { + alignment?: Alignment; + verticalAlignment?: "top" | "middle" | "bottom"; + } = {}; + if (format.alignment.horizontal !== undefined) { + result.alignment = format.alignment.horizontal; + } + if (format.alignment.vertical !== undefined) { + result.verticalAlignment = format.alignment.vertical; + } + return result; +} + /** A cell's own resolved per-side borders, or undefined when none of its four sides carry a border this reader resolves (no border at all, or a reserved/unrecognised BorderStyle token, or a colour this package cannot express as a fixed RGB value -- see xf-colors.ts's own resolveBorderEdge). */ function bordersOf( globals: WorkbookGlobals, diff --git a/packages/xls-codec/src/test-support/biff.ts b/packages/xls-codec/src/test-support/biff.ts index d8d113ea2..4f177ec59 100644 --- a/packages/xls-codec/src/test-support/biff.ts +++ b/packages/xls-codec/src/test-support/biff.ts @@ -121,7 +121,7 @@ export interface XfTestBorderEdge { readonly icv: number; } -/** The decoration fields cellXfTrailer packs, in the same shape src/biff/xf-colors.ts's own XfDecorationFields carries -- kept as a separate, independently-written literal here rather than imported, so a test asserting against this fixture's own bytes is checking the reader's understanding of the spec, not agreement with the writer's packing code (see this module's own top comment). */ +/** The decoration and alignment fields cellXfTrailer packs, in the same shape src/biff/xf-colors.ts's own XfDecorationFields/XfAlignmentFields carry -- kept as a separate, independently-written literal here rather than imported, so a test asserting against this fixture's own bytes is checking the reader's understanding of the spec, not agreement with the writer's packing code (see this module's own top comment). */ export interface XfTestDecoration { readonly fillPattern?: number; readonly fillForegroundIcv?: number; @@ -129,6 +129,10 @@ export interface XfTestDecoration { readonly right?: XfTestBorderEdge; readonly top?: XfTestBorderEdge; readonly bottom?: XfTestBorderEdge; + /** HorizAlign's own alc token (0-7, [MS-XLS] "HorizAlign") -- ALC_GENERAL (0) when omitted, matching a real Excel-written XF with no explicit alignment. */ + readonly alc?: number; + /** VertAlign's own alcV token (0-4, [MS-XLS] "VertAlign") -- ALCV_BOTTOM (2) when omitted, matching a real Excel-written XF with no explicit vertical alignment (this package's own writer default, xf-writer.ts's packAlignmentPrefix). */ + readonly alcV?: number; } const NO_EDGE: XfTestBorderEdge = { style: 0, icv: 0 }; @@ -136,13 +140,19 @@ const NO_EDGE: XfTestBorderEdge = { style: 0, icv: 0 }; /** IcvXF's own "default foreground colour" special value (icv 0x40) -- a literal taken directly from [MS-XLS]'s Icv table, the value a genuinely undecorated real Excel-written XF carries in icvFore. Kept as its own literal here rather than imported from src/biff/xf-colors.ts, for the same reason the rest of this fixture builder is independently written (see this module's own top comment). */ const ICV_DEFAULT_FOREGROUND = 0x40; -/** An XF record's own trailing CellXF/StyleXF "Data" payload ([MS-XLS] 2.4.353), 14 bytes: a leading alignment word (always the schema's own General/Bottom/no-wrap default here, since this package's reader does not act on alignment), then the border word, fill-pattern word, and fill-colour word `decoration` packs -- no borders and no fill pattern when omitted, with icvFore at its own real-file default (0x40, "Automatic") unless the caller states one -- a legal payload every XF record needs regardless of whether a test cares about decoration. */ +/** HorizAlign's ALCGEN (general) and VertAlign's ALCVBOT (bottom) -- word1's own default alc/alcV tokens for an XF with no explicit alignment stated, matching what a real Excel-written cell also carries (xf-writer.ts's own packAlignmentPrefix default). Independently-written literals, for the same reason ICV_DEFAULT_FOREGROUND is. */ +const ALC_GENERAL_DEFAULT = 0x0; +const ALCV_BOTTOM_DEFAULT = 0x2; + +/** An XF record's own trailing CellXF/StyleXF "Data" payload ([MS-XLS] 2.4.353), 14 bytes: a leading alignment word (word1's own alc/alcV tokens, General/Bottom when the caller states neither -- the identical default a real Excel-written XF with no explicit alignment carries), then the border word, fill-pattern word, and fill-colour word `decoration` packs -- no borders and no fill pattern when omitted, with icvFore at its own real-file default (0x40, "Automatic") unless the caller states one -- a legal payload every XF record needs regardless of whether a test cares about decoration or alignment. */ export function cellXfTrailer(decoration: XfTestDecoration = {}): number[] { const left = decoration.left ?? NO_EDGE; const right = decoration.right ?? NO_EDGE; const top = decoration.top ?? NO_EDGE; const bottom = decoration.bottom ?? NO_EDGE; - const word1 = 0; + const alc = decoration.alc ?? ALC_GENERAL_DEFAULT; + const alcV = decoration.alcV ?? ALCV_BOTTOM_DEFAULT; + const word1 = (alc & 0x7) | ((alcV & 0x7) << 4); const word2 = (left.style & 0xf) | ((right.style & 0xf) << 4) | diff --git a/packages/xls-codec/src/workbook/globals-writer.ts b/packages/xls-codec/src/workbook/globals-writer.ts index 0a121c5c1..f76a5e27a 100644 --- a/packages/xls-codec/src/workbook/globals-writer.ts +++ b/packages/xls-codec/src/workbook/globals-writer.ts @@ -1,4 +1,4 @@ -import type { Color } from "document-schema.js"; +import type { Alignment, Color } from "document-schema.js"; import { writeBofData } from "../biff/bof-writer"; import { RecordBuilder } from "../biff/builder"; @@ -67,9 +67,11 @@ const BUILTIN_STYLES: readonly BuiltinStyle[] = [ /** [MS-XLS] 2.1.7.20.3's own XFS production requires at least sixteen XF records before any cell can reference one: BUILTIN_STYLES.length built-in cell styles, then at least one cell XF -- the "General, no declared format" one every workbook needs unconditionally, written immediately after them. Derived from BUILTIN_STYLES's own length rather than restated as a literal, so the two can never drift apart. */ export const GENERAL_CELL_XF_INDEX = BUILTIN_STYLES.length; -/** One cell XF beyond the implicit General one at GENERAL_CELL_XF_INDEX: the number-format identifier it displays through, and -- when the cell it serves carries a background or borders -- the fill/border fields its CellXF payload packs. Undefined decoration writes the same undecorated defaults this writer always wrote before decoration existed. */ +/** One cell XF beyond the implicit General one at GENERAL_CELL_XF_INDEX: the number-format identifier it displays through, the cell's own horizontal/vertical alignment (general/bottom, this writer's own defaults, when omitted), and -- when the cell it serves carries a background or borders -- the fill/border fields its CellXF payload packs. Undefined decoration writes the same undecorated defaults this writer always wrote before decoration existed. */ export interface CellXfPlanEntry { readonly formatId: number; + readonly alignment?: Alignment; + readonly verticalAlignment?: "top" | "middle" | "bottom"; readonly decoration?: XfDecorationFields; } @@ -187,6 +189,8 @@ export function buildWorkbookGlobals( writeCellXfRecord({ fontIndex: NORMAL_FONT_INDEX, formatId: entry.formatId, + alignment: entry.alignment, + verticalAlignment: entry.verticalAlignment, decoration: entry.decoration, }), ); diff --git a/packages/xls-codec/src/workbook/globals.test.ts b/packages/xls-codec/src/workbook/globals.test.ts index 2230af513..fed5d5b9c 100644 --- a/packages/xls-codec/src/workbook/globals.test.ts +++ b/packages/xls-codec/src/workbook/globals.test.ts @@ -221,12 +221,14 @@ describe("readWorkbookGlobals", () => { fontIndex: 0, formatId: 0, isStyle: true, + alignment: { horizontal: undefined, vertical: undefined }, decoration: UNDECORATED_XF_FIELDS, }, { fontIndex: 1, formatId: 164, isStyle: false, + alignment: { horizontal: undefined, vertical: undefined }, decoration: UNDECORATED_XF_FIELDS, }, ]); diff --git a/packages/xls-codec/src/workbook/globals.ts b/packages/xls-codec/src/workbook/globals.ts index 0ef0bcf3d..74e651d04 100644 --- a/packages/xls-codec/src/workbook/globals.ts +++ b/packages/xls-codec/src/workbook/globals.ts @@ -22,7 +22,9 @@ import type { RecordGroup } from "../biff/substreams"; import { PALETTE_ENTRY_COUNT, readLongRgbColor, + unpackXfAlignment, unpackXfDecoration, + type XfAlignmentFields, type XfDecorationFields, } from "../biff/xf-colors"; import { BUILTIN_NUMBER_FORMATS } from "excel-number-format"; @@ -44,7 +46,7 @@ export interface SheetEntry { readonly bofPosition: number; } -/** One XF record's fixed prefix ([MS-XLS] 2.4.353) plus its trailing CellXF/StyleXF payload's own fill/border fields. Alignment (the payload's own leading word) is still not carried: this package's schema mapping (content.ts) has no use for it, matching the reader's own documented scope. */ +/** One XF record's fixed prefix ([MS-XLS] 2.4.353) plus its trailing CellXF/StyleXF payload's own alignment and fill/border fields. */ export interface CellFormat { /** ifnt: the index of the Font record this format uses. */ readonly fontIndex: number; @@ -52,6 +54,8 @@ export interface CellFormat { readonly formatId: number; /** fStyle: true when this record describes a cell STYLE rather than a cell format. */ readonly isStyle: boolean; + /** The trailing payload's own leading word, resolved into document-schema.js's Alignment/verticalAlignment members directly (undefined already means what an absent ContentSheetCell.alignment/verticalAlignment means, so content.ts consumes this without a further resolution step). */ + readonly alignment: XfAlignmentFields; /** The trailing payload's own fill pattern/colour and per-side border fields, raw -- resolved into document-schema.js's Color/ContentCellBorders by content.ts, through this same globals object's own `palette`. */ readonly decoration: XfDecorationFields; } @@ -249,13 +253,13 @@ function readFormat(record: RecordGroup): { id: number; code: string } { return { id, code: readXLUnicodeString(cursor) }; } -/** XF ([MS-XLS] 2.4.353): a font index, a number-format identifier, a flags field whose fStyle bit says whether the trailing payload is a CellXF or a StyleXF, then that 14-byte trailing payload itself -- a leading alignment/fAtr* word this reader does not act on (skipped rather than parsed: see the package README's own documented scope), then the border word, fill-pattern word, and fill-colour word xf-colors.ts's unpackXfDecoration resolves into this format's own `decoration`. CellXF and StyleXF share the identical border/fill bit layout ([MS-XLS] 2.4.353's own field table), so this reads both shapes uniformly regardless of isStyle -- a cell only ever references a CellXF entry by its own ixfe (workbook/sheet.ts's cell reading), so a StyleXF's decoration is parsed but never consulted downstream. */ +/** XF ([MS-XLS] 2.4.353): a font index, a number-format identifier, a flags field whose fStyle bit says whether the trailing payload is a CellXF or a StyleXF, then that 14-byte trailing payload itself -- the leading alignment word xf-colors.ts's unpackXfAlignment resolves into this format's own `alignment`, then the border word, fill-pattern word, and fill-colour word its unpackXfDecoration resolves into this format's own `decoration`. CellXF and StyleXF share the identical alignment/border/fill bit layout ([MS-XLS] 2.4.353's own field table), so this reads both shapes uniformly regardless of isStyle -- a cell only ever references a CellXF entry by its own ixfe (workbook/sheet.ts's cell reading), so a StyleXF's alignment/decoration are parsed but never consulted downstream. */ function readCellFormat(record: RecordGroup): CellFormat { const cursor = new BlockCursor(record.blocks); const fontIndex = cursor.u16(); const formatId = cursor.u16(); const flags = cursor.u16(); - cursor.u32(); // word1: alignment/trot/indent/fAtr* flags -- not part of this reader's schema mapping + const word1 = cursor.u32(); const word2 = cursor.u32(); const word3 = cursor.u32(); const word4 = cursor.u16(); @@ -263,6 +267,7 @@ function readCellFormat(record: RecordGroup): CellFormat { fontIndex, formatId, isStyle: (flags & XF_FLAG_STYLE) !== 0, + alignment: unpackXfAlignment(word1), decoration: unpackXfDecoration(word2, word3, word4), }; } diff --git a/packages/xls-codec/src/workbook/sheet-writer.ts b/packages/xls-codec/src/workbook/sheet-writer.ts index ef13e345f..7b677638a 100644 --- a/packages/xls-codec/src/workbook/sheet-writer.ts +++ b/packages/xls-codec/src/workbook/sheet-writer.ts @@ -52,12 +52,12 @@ import { isoTimeToSerial, } from "../serial"; import { pointsToColumnWidth, pointsToInches, pointsToTwips } from "../units"; -import { cellCarriesDecoration, writesCellRecord } from "../written-cells"; +import { cellCarriesFormatting, 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 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. +// A blank cell -- ContentCellValue's own 'empty' kind -- splits in two. One carrying no formatting is written as nothing at all, which is what round-trips: content.ts's mapCell drops an unformatted blank it reads, and applyMerges reconstructs an empty anchor for a merged range from the MergeCells record alone. One carrying a background, a border, or a non-default alignment is a Blank record ([MS-XLS] 2.4.20), because its formatting 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. // // MulBlank, RK, and MulRk stay unimplemented: unlike Blank, each is a pure compaction optimisation over information a plain Blank/Number record already carries losslessly. @@ -77,7 +77,7 @@ const ROW_FLAG_UNSYNCED_BIT = 6; const COLINFO_FLAG_HIDDEN = 0x0001; export interface SheetWriteContext { - /** The XF index ([MS-XLS] 2.5.168 IXFCell) a cell's own (number format, decoration) combination resolves to -- GENERAL_CELL_XF_INDEX for a cell with General formatting and no background/borders, one of the workbook's other cell XFs otherwise. write.ts's own cell-format interning pass is what assigns and deduplicates these. */ + /** The XF index ([MS-XLS] 2.5.168 IXFCell) a cell's own (number format, alignment, decoration) combination resolves to -- GENERAL_CELL_XF_INDEX for a cell with General formatting, general/bottom alignment, and no background/borders, one of the workbook's other cell XFs otherwise. write.ts's own cell-format interning pass is what assigns and deduplicates these. */ xfIndexForCell(cell: ContentSheetCell): number; /** The shared string table index for a string cell's own text; every string a sheet writes must already be registered in the workbook-wide table before this is called. */ sstIndexFor(text: string): number; @@ -455,7 +455,7 @@ function cellHeader(cell: ContentSheetCell, xfIndex: number): RecordBuilder { return new RecordBuilder().u16(cell.row).u16(cell.column).u16(xfIndex); } -/** One cell record, keyed by ContentCellValue's own discriminant: Number for every numeric/temporal kind ([MS-XLS] 2.4.180 -- always the full IEEE 754 double, never the packed RK encoding, which is a compaction optimisation this writer does not implement), LabelSst for a string ([MS-XLS] 2.4.149, through the workbook-wide shared string table), BoolErr for a boolean or error value ([MS-XLS] 2.4.24), and Blank for an 'empty' cell whose decoration is the only thing it carries ([MS-XLS] 2.4.20 -- a cell header and nothing else, so the XF its ixfe names is the whole content). An undecorated empty cell never reaches here at all: written-cells.ts's own predicate filters it out upstream, since there is nothing for it to say. */ +/** One cell record, keyed by ContentCellValue's own discriminant: Number for every numeric/temporal kind ([MS-XLS] 2.4.180 -- always the full IEEE 754 double, never the packed RK encoding, which is a compaction optimisation this writer does not implement), LabelSst for a string ([MS-XLS] 2.4.149, through the workbook-wide shared string table), BoolErr for a boolean or error value ([MS-XLS] 2.4.24), and Blank for an 'empty' cell whose formatting is the only thing it carries ([MS-XLS] 2.4.20 -- a cell header and nothing else, so the XF its ixfe names is the whole content). An unformatted empty cell never reaches here at all: written-cells.ts's own predicate filters it out upstream, since there is nothing for it to say. */ function writeCellValueRecord( cell: ContentSheetCell, xfIndex: number, @@ -517,7 +517,7 @@ function writeCellValueRecord( ); } case "empty": - if (!cellCarriesDecoration(cell)) { + if (!cellCarriesFormatting(cell)) { throw new BiffWriteError( `internal error: writeCellValueRecord was called for the undecorated empty cell at row ${cell.row}, column ${cell.column}, which written-cells.ts's own predicate must filter out before reaching here`, ); diff --git a/packages/xls-codec/src/write.test.ts b/packages/xls-codec/src/write.test.ts index 706a7a799..53e083634 100644 --- a/packages/xls-codec/src/write.test.ts +++ b/packages/xls-codec/src/write.test.ts @@ -593,6 +593,143 @@ describe("writeXlsContent", () => { }); }); + describe("cell alignment", () => { + it("round-trips each horizontal alignment this package's schema can express", () => { + const bytes = writeXlsContent( + document([ + sheet("Sheet1", [ + cell(0, 0, { kind: "string", value: "x" }, { alignment: "left" }), + cell( + 0, + 1, + { kind: "string", value: "x" }, + { + alignment: "center", + }, + ), + cell( + 0, + 2, + { kind: "string", value: "x" }, + { + alignment: "right", + }, + ), + cell( + 0, + 3, + { kind: "string", value: "x" }, + { + alignment: "justify", + }, + ), + ]), + ]), + ); + const content = readXlsContent(bytes); + expect(findCell(content, 0, 0, 0)?.alignment).toBe("left"); + expect(findCell(content, 0, 0, 1)?.alignment).toBe("center"); + expect(findCell(content, 0, 0, 2)?.alignment).toBe("right"); + expect(findCell(content, 0, 0, 3)?.alignment).toBe("justify"); + }); + + it("round-trips each vertical alignment this package's schema can express", () => { + const bytes = writeXlsContent( + document([ + sheet("Sheet1", [ + cell( + 0, + 0, + { kind: "string", value: "x" }, + { + verticalAlignment: "top", + }, + ), + cell( + 0, + 1, + { kind: "string", value: "x" }, + { + verticalAlignment: "middle", + }, + ), + ]), + ]), + ); + const content = readXlsContent(bytes); + expect(findCell(content, 0, 0, 0)?.verticalAlignment).toBe("top"); + expect(findCell(content, 0, 0, 1)?.verticalAlignment).toBe("middle"); + }); + + it("leaves alignment/verticalAlignment absent for a cell that states neither, matching the value-kind default and the schema's own documented bottom default", () => { + const bytes = writeXlsContent( + document([ + sheet("Sheet1", [cell(0, 0, { kind: "string", value: "x" })]), + ]), + ); + const readBack = findCell(readXlsContent(bytes), 0, 0, 0); + expect(readBack?.alignment).toBeUndefined(); + expect(readBack?.verticalAlignment).toBeUndefined(); + }); + + it("round-trips both alignment and decoration on the same cell", () => { + const red = rgbHexToColor("ff0000"); + const bytes = writeXlsContent( + document([ + sheet("Sheet1", [ + cell( + 0, + 0, + { kind: "string", value: "x" }, + { alignment: "right", verticalAlignment: "top", background: red }, + ), + ]), + ]), + ); + const readBack = findCell(readXlsContent(bytes), 0, 0, 0); + expect(readBack?.alignment).toBe("right"); + expect(readBack?.verticalAlignment).toBe("top"); + expect(readBack?.background).toEqual(red); + }); + + it("round-trips a decorated-alignment-only empty cell through a real Blank record", () => { + // No value and no fill/border either -- alignment alone is what makes this cell worth a Blank record, mirroring the equivalent decoration-only empty-cell test above. + const bytes = writeXlsContent( + document([ + sheet("Sheet1", [ + cell(1, 2, { kind: "empty" }, { alignment: "center" }), + ]), + ]), + ); + const readBack = findCell(readXlsContent(bytes), 0, 1, 2); + expect(readBack?.value).toEqual({ kind: "empty" }); + expect(readBack?.alignment).toBe("center"); + }); + + it("still writes nothing for an empty cell carrying no alignment either", () => { + const bytes = writeXlsContent( + document([sheet("Sheet1", [cell(1, 1, { kind: "empty" })])]), + ); + expect(findCell(readXlsContent(bytes), 0, 1, 1)).toBeUndefined(); + }); + + it("reuses one XF entry for two cells sharing the identical alignment, and mints a separate one for a cell with none", () => { + const bytes = writeXlsContent( + document([ + sheet("Sheet1", [ + cell(0, 0, { kind: "number", value: 1 }, { alignment: "right" }), + cell(0, 1, { kind: "number", value: 2 }, { alignment: "right" }), + cell(0, 2, { kind: "number", value: 3 }), + ]), + ]), + ); + const content = readXlsContent(bytes); + expect(findCell(content, 0, 0, 0)?.alignment).toBe("right"); + expect(findCell(content, 0, 0, 1)?.alignment).toBe("right"); + expect(findCell(content, 0, 0, 2)?.alignment).toBeUndefined(); + }); + }); + it("round-trips a merged range whose anchor carries a real value", () => { const bytes = writeXlsContent( document([ diff --git a/packages/xls-codec/src/write.ts b/packages/xls-codec/src/write.ts index 66487e92d..325904c1c 100644 --- a/packages/xls-codec/src/write.ts +++ b/packages/xls-codec/src/write.ts @@ -4,6 +4,7 @@ import { writeSummaryInformationStream, } from "archive-codec"; import type { + Alignment, Color, ContentBorder, ContentDocument, @@ -45,13 +46,13 @@ import { buildWorksheetSubstream, type SheetWriteContext, } from "./workbook/sheet-writer"; -import { cellCarriesDecoration, writesCellRecord } from "./written-cells"; +import { cellCarriesFormatting, writesCellRecord } from "./written-cells"; // The BIFF8 write path: a ContentDocument (or DocumentTree) of kind 'spreadsheet' back to real .xls bytes -- a genuine [MS-XLS] Workbook stream wrapped in a genuine [MS-CFB] compound file via archive-codec's writeCompoundFile. The counterpart of content.ts's readXlsContent/readXls, and of ooxml.js's own writeXlsx. // -// Three things are workbook-wide rather than per-sheet, so they are resolved in one pass over every sheet before any record is written: the number-format table (a cell's own numberFormatCode, or a representative default for its value kind when absent, maps onto a shared BIFF8 format identifier the same code reuses everywhere it appears), the colour table (every distinct background/border colour a cell uses, resolved to an icv against the fixed default palette or, when a colour genuinely isn't in it, a real Palette record this pass mints), and the shared string table (every distinct string value, in first-encountered order, referenced by index from a LabelSst cell in any sheet). A fourth pass, buildCellXfPlan, then interns the (number format, decoration) PAIR every cell resolves to into its own cell XF index -- two cells sharing both a format and a decoration share one XF record, mirroring how ooxml.js's own CellFormatTable dedupes an xlsx on the identical pair. Building each of these once and threading the result into every sheet's own writer is what keeps two cells in different sheets sharing the identical string, format, or decoration from minting redundant table entries. +// Three things are workbook-wide rather than per-sheet, so they are resolved in one pass over every sheet before any record is written: the number-format table (a cell's own numberFormatCode, or a representative default for its value kind when absent, maps onto a shared BIFF8 format identifier the same code reuses everywhere it appears), the colour table (every distinct background/border colour a cell uses, resolved to an icv against the fixed default palette or, when a colour genuinely isn't in it, a real Palette record this pass mints), and the shared string table (every distinct string value, in first-encountered order, referenced by index from a LabelSst cell in any sheet). A fourth pass, buildCellXfPlan, then interns the (number format, alignment, decoration) TRIPLE every cell resolves to into its own cell XF index -- two cells sharing all three share one XF record, mirroring how ooxml.js's own CellFormatTable dedupes an xlsx on the identical (format, decoration) pair, widened here by one more axis. Building each of these once and threading the result into every sheet's own writer is what keeps two cells in different sheets sharing the identical string, format, alignment, or decoration from minting redundant table entries. // -// See this package's README for the writer's own scope: what it covers (numeric/percentage/currency/date/time/dateTime/boolean/error/string cell values, merged ranges, row heights, column widths, custom and built-in number formats, and now cell background fill and per-side borders) and what it deliberately does not (formulas, alignment, per-cell fonts, images, comments, data validation, conditional formatting, print settings, and a long tail of BIFF8 records that carry UI/interoperability state rather than document content). +// See this package's README for the writer's own scope: what it covers (numeric/percentage/currency/date/time/dateTime/boolean/error/string cell values, merged ranges, row heights, column widths, custom and built-in number formats, cell background fill and per-side borders, and cell alignment) and what it deliberately does not (formulas, per-cell fonts, images, comments, data validation, conditional formatting, print settings, and a long tail of BIFF8 records that carry UI/interoperability state rather than document content). const WORKBOOK_STREAM_NAME = "Workbook"; @@ -282,7 +283,7 @@ function resolveDecorationForCell( cell: ContentSheetCell, icvOf: (color: Color) => number, ): XfDecorationFields | undefined { - if (!cellCarriesDecoration(cell)) { + if (!cellCarriesFormatting(cell)) { return undefined; } return { @@ -299,22 +300,24 @@ function resolveDecorationForCell( }; } -/** A deterministic signature for one cell XF's own (formatId, decoration) pair, so two cells sharing both share one XF record -- the interning key buildCellXfPlan below dedupes on, mirroring how CellFormatTable in ooxml.js's typed/xlsx/styles.ts dedupes an on (number format, decoration) together rather than on format alone. */ +/** A deterministic signature for one cell XF's own (formatId, alignment, verticalAlignment, decoration) tuple, so two cells sharing all four share one XF record -- the interning key buildCellXfPlan below dedupes on, mirroring how CellFormatTable in ooxml.js's typed/xlsx/styles.ts dedupes an on (number format, decoration) together rather than on format alone, widened here by the cell's own alignment. */ function signatureOfCellXf( formatId: number, + alignment: Alignment | undefined, + verticalAlignment: "top" | "middle" | "bottom" | undefined, decoration: XfDecorationFields | undefined, ): string { + let signature = `f${formatId}|a${alignment ?? ""}|v${verticalAlignment ?? ""}`; if (decoration === undefined) { - return `f${formatId}`; + return signature; } - return ( - `f${formatId}` + + signature += `|p${decoration.fillPattern}:${decoration.fillForegroundIcv}` + `|l${decoration.left.style}:${decoration.left.icv}` + `|r${decoration.right.style}:${decoration.right.icv}` + `|t${decoration.top.style}:${decoration.top.icv}` + - `|b${decoration.bottom.style}:${decoration.bottom.icv}` - ); + `|b${decoration.bottom.style}:${decoration.bottom.icv}`; + return signature; } interface CellXfPlan { @@ -334,13 +337,18 @@ function buildCellXfPlan( ): CellXfPlan { const cellXfEntries: CellXfPlanEntry[] = []; const xfIndexBySignature = new Map([ - [signatureOfCellXf(GENERAL_FORMAT_ID, undefined), GENERAL_CELL_XF_INDEX], + [ + signatureOfCellXf(GENERAL_FORMAT_ID, undefined, undefined, undefined), + GENERAL_CELL_XF_INDEX, + ], ]); let nextXfIndex = GENERAL_CELL_XF_INDEX + 1; const signatureOf = (cell: ContentSheetCell): string => signatureOfCellXf( formatPlan.formatIdOf(formatCodeForCell(cell)), + cell.alignment, + cell.verticalAlignment, resolveDecorationForCell(cell, palettePlan.icvOf), ); @@ -351,13 +359,23 @@ function buildCellXfPlan( } const formatId = formatPlan.formatIdOf(formatCodeForCell(cell)); const decoration = resolveDecorationForCell(cell, palettePlan.icvOf); - const signature = signatureOfCellXf(formatId, decoration); + const signature = signatureOfCellXf( + formatId, + cell.alignment, + cell.verticalAlignment, + decoration, + ); if (xfIndexBySignature.has(signature)) { continue; } xfIndexBySignature.set(signature, nextXfIndex); nextXfIndex += 1; - cellXfEntries.push({ formatId, decoration }); + cellXfEntries.push({ + formatId, + alignment: cell.alignment, + verticalAlignment: cell.verticalAlignment, + decoration, + }); } } diff --git a/packages/xls-codec/src/written-cells.ts b/packages/xls-codec/src/written-cells.ts index eef9045ab..a3568d19e 100644 --- a/packages/xls-codec/src/written-cells.ts +++ b/packages/xls-codec/src/written-cells.ts @@ -1,18 +1,24 @@ import type { ContentSheetCell } from "document-schema.js"; -// Which of a sheet's cells the writer emits a cell record for, and whether a cell carries decoration at all -- the two predicates the whole write path has to agree on, in one place because it previously did not. +// Which of a sheet's cells the writer emits a cell record for, and whether a cell carries real formatting (decoration or alignment) at all -- the two predicates the whole write path has to agree on, in one place because it previously did not. // -// Four separate passes consult it: write.ts's number-format scan, its palette-colour scan, and its (format, decoration) -> XF interning pass, then workbook/sheet-writer.ts's own record emission. Every one of them has to reach the identical answer for a given cell, and the two failures a disagreement produces are both silent. The palette scan reading wider than the XF pass spent colour-table slots on colours nothing ever wrote, so a workbook was refused for exceeding a 56-entry budget it was nowhere near using; a disagreement the other way would write a cell record pointing at an XF index no XF record exists for, which no later read can detect as wrong. Neither is visible in the bytes afterwards, so the predicate is shared rather than restated per pass. +// Four separate passes consult it: write.ts's number-format scan, its palette-colour scan, and its (format, alignment, decoration) -> XF interning pass, then workbook/sheet-writer.ts's own record emission. Every one of them has to reach the identical answer for a given cell, and the two failures a disagreement produces are both silent. The palette scan reading wider than the XF pass spent colour-table slots on colours nothing ever wrote, so a workbook was refused for exceeding a 56-entry budget it was nowhere near using; a disagreement the other way would write a cell record pointing at an XF index no XF record exists for, which no later read can detect as wrong. Neither is visible in the bytes afterwards, so the predicate is shared rather than restated per pass. /** - * Whether a cell carries real decoration: a background fill, or a border on at least one side. + * Whether a cell carries real formatting beyond General/bottom-aligned/undecorated: a background fill, a border on at least one side, a non-default horizontal alignment, or a non-default vertical alignment. * - * A present `borders` object is not enough on its own -- one with no side set describes no border at all, and treating it as decoration would mint an XF byte-identical to the undecorated one and, for an empty cell, a Blank record the reader would then correctly drop again. write.ts's own resolveDecorationForCell answers "no decoration" for exactly this set through this same predicate, so the two cannot disagree about what an empty-but-present borders object means. + * A present `borders` object is not enough on its own -- one with no side set describes no border at all, and treating it as formatting would mint an XF byte-identical to the undecorated one and, for an empty cell, a Blank record the reader would then correctly drop again. write.ts's own resolveDecorationForCell answers "no decoration" for exactly this set through this same predicate, so the two cannot disagree about what an empty-but-present borders object means. */ -export function cellCarriesDecoration(cell: ContentSheetCell): boolean { +export function cellCarriesFormatting(cell: ContentSheetCell): boolean { if (cell.background !== undefined) { return true; } + if (cell.alignment !== undefined) { + return true; + } + if (cell.verticalAlignment !== undefined) { + return true; + } const { borders } = cell; if (borders === undefined) { return false; @@ -26,10 +32,10 @@ export function cellCarriesDecoration(cell: ContentSheetCell): boolean { } /** - * Whether the writer emits a cell record for this cell: a value record for anything carrying a value, or a Blank record for an `empty`-kind cell whose decoration is the only thing it has to say. + * Whether the writer emits a cell record for this cell: a value record for anything carrying a value, or a Blank record for an `empty`-kind cell whose formatting is the only thing it has to say. * - * An undecorated empty cell is written as nothing at all, which is what round-trips: content.ts's reader drops an undecorated blank cell, and a merged range's empty anchor is reconstructed from the MergeCells record alone. A decorated one is not that case -- its fill and borders live only in the XF a cell record points at, so writing nothing for it discards them. + * An unformatted empty cell is written as nothing at all, which is what round-trips: content.ts's reader drops an unformatted blank cell, and a merged range's empty anchor is reconstructed from the MergeCells record alone. A formatted one is not that case -- its fill, borders, and alignment live only in the XF a cell record points at, so writing nothing for it discards them. */ export function writesCellRecord(cell: ContentSheetCell): boolean { - return cell.value.kind !== "empty" || cellCarriesDecoration(cell); + return cell.value.kind !== "empty" || cellCarriesFormatting(cell); }