From 6746a0284741c2c22a2d6abedc2b943725647a0f Mon Sep 17 00:00:00 2001 From: Andreas Stefl Date: Thu, 16 Jul 2026 19:12:21 +0200 Subject: [PATCH 1/4] feat(xls): cell font and fill formatting from Font/XF/Palette records Parse the Font (0x0031), XF (0x00E0) and Palette (0x0092) records in the globals substream, keep each cell's ixfe, and resolve every XF into a per-cell TextStyle (name, size, weight, italic, underline, strike, color) plus a TableCellStyle fill. Replaces the flat 11pt placeholder style. Covers the FontIndex 4 gap ([MS-XLS] 2.5.129), the Icv color table with custom-palette override ([MS-XLS] 2.5.161), and approximates non-solid fill patterns by their foreground color. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01R9Z3kyUaHNkJacAEggevHH --- src/odr/internal/oldms/spreadsheet/AGENTS.md | 52 +++-- .../oldms/spreadsheet/xls_document.cpp | 49 +++-- .../spreadsheet/xls_element_registry.cpp | 23 +++ .../spreadsheet/xls_element_registry.hpp | 23 +++ .../internal/oldms/spreadsheet/xls_parser.cpp | 161 +++++++++++++-- .../oldms/spreadsheet/xls_structs.hpp | 86 ++++++++ test/src/internal/oldms/oldms_test.cpp | 191 ++++++++++++++++++ 7 files changed, 537 insertions(+), 48 deletions(-) diff --git a/src/odr/internal/oldms/spreadsheet/AGENTS.md b/src/odr/internal/oldms/spreadsheet/AGENTS.md index 8f1a3e9e..1b2ff9d5 100644 --- a/src/odr/internal/oldms/spreadsheet/AGENTS.md +++ b/src/odr/internal/oldms/spreadsheet/AGENTS.md @@ -3,13 +3,14 @@ The **why** and the roadmap; what is concretely done lives in the code. Shared `oldms/` conventions are in [`../AGENTS.md`](../AGENTS.md). -**Scope.** Extract the **visible cell text** of every worksheet through the -abstract model so the generic HTML renderer produces a plain table per sheet. -Every cell value is rendered as a *string* — no styles, number/date formats, -merged cells, drawings, or charts. +**Scope.** Extract the **visible cell text** of every worksheet, plus each +cell's **font formatting and fill color**, through the abstract model so the +generic HTML renderer produces a styled table per sheet. Every cell value is +rendered as a *string* — no number/date formats, merged cells, borders, +drawings, or charts. -**Specs.** `[MS-XLS]` (record stream, SST, cell records) + `[MS-CFB]` container. -Section numbers cited inline in code. +**Specs.** `[MS-XLS]` (record stream, SST, cell records, Font/XF/Palette) + +`[MS-CFB]` container. Section numbers cited inline in code. ## Module layout (sibling of `../text`, `../presentation`) @@ -17,8 +18,8 @@ Section numbers cited inline in code. |---|---| | `xls_structs.hpp` | `#pragma pack(1)` PODs for record bodies + `static_assert` sizes + record-type enum | | `xls_io.{hpp,cpp}` | `BiffReader` (record walker with transparent `CONTINUE` hopping; the `[MS-XLS]` string readers + `expect_bof`), RK decoding, number formatting | -| `xls_parser.{hpp,cpp}` | `parse_tree` → globals (BoundSheet8 + SST) then one pass per sheet substream | -| `xls_element_registry.{hpp,cpp}` | Flat element store + `Sheet` (name, dimensions, cell-position map) and `SheetCell` payloads | +| `xls_parser.{hpp,cpp}` | `parse_tree` → globals (BoundSheet8 + SST + Font/XF/Palette) then one pass per sheet substream | +| `xls_element_registry.{hpp,cpp}` | Flat element store + `Sheet` (name, dimensions, cell-position map), `SheetCell` payloads, and the resolved per-XF `CellStyle` table | | `xls_document.{hpp,cpp}` | `internal::Document` subclass + the `ElementAdapter` | Tree shape: `sheet → sheet_cell → paragraph → text`, one `sheet_cell` per @@ -58,9 +59,27 @@ bit-field structs (`RkNumber`, `UnicodeStringFlags`, flags of `BoundSheet8Fixed`/`FormulaFixed`) — little-endian, LSB-first hosts only; shared `oldms/` assumption, see [`../AGENTS.md`](../AGENTS.md). -**Adapters** expose `ValueType::string` for every cell, `sheet_cell_span` → -`{1,1}`, all `*_style` → `{}`, and the `font_size = 11pt` placeholder hack (same as -`.doc`/`.ppt`). `Document::is_editable()` → `false`; `save` throws. +**Cell formatting is resolved at parse time, per XF.** Each cell record's +`ixfe` is kept on the `SheetCell`; the globals pass collects `Font` (0x0031), +`XF` (0x00E0) and `Palette` (0x0092) and resolves every XF into a `CellStyle` +(a `TextStyle` from the font + a `TableCellStyle` fill), stored once in the +registry and indexed by `ixfe`. The adapters only look up: `text_style`/ +`paragraph_text_style` walk up to the `sheet_cell` ancestor and return its +XF's `TextStyle`; `sheet_cell_style` returns the fill. The non-obvious bits: +- **`FontIndex` 4 does not exist** (§2.5.129): `ifnt` < 4 is zero-based, + \> 4 is one-based into the Font records in file order. +- **Colors are `Icv` indexes** (§2.5.161): 0x00–0x07 built-in constants, + 0x08–0x3F the `Palette` record (or the spec's default palette when absent), + 0x40/0x41/0x7FFF system/automatic → left unset. +- **Fills** (§2.5.20): `fls` 0 = none; solid (1) renders `icvFore`; the other + patterns are *approximated* by their foreground color. +- A `dyHeight` of 0 (allowed by §2.4.122) leaves `font_size` unset instead of + emitting invisible 0pt text. Font names are interned in a `std::deque` so + `TextStyle::font_name` (`const char *`) stays valid. + +**Adapters** expose `ValueType::string` for every cell and `sheet_cell_span` → +`{1,1}`; sheet/column/row styles are still `{}`. +`Document::is_editable()` → `false`; `save` throws. ### Value formatting (the non-obvious bits) @@ -79,6 +98,10 @@ bit-field structs (`RkNumber`, `UnicodeStringFlags`, flags of (no flags byte there) + correct next-string position. - `xls_decode_rk` — all four RK flag combinations + number formatting (raw on-disk encodings, so it also pins the `RkNumber` bit-field layout). +- `xls_cell_styles` — synthetic one-sheet workbook (inline bytes): XF → Font + resolution incl. the skipped index 4, weight/italic/underline/strike, the + default palette, automatic colors, solid fill, unstyled empty positions. +- `xls_palette_record` — a `Palette` record overriding the default palette. - `xls_empty` / `xls_file_example_10` / `xls_file_example_5000` — real fixtures (names, dimensions, extents, string/number cells; the 5000-row file exercises SST `CONTINUE` on real data). @@ -108,8 +131,11 @@ ignore their format codes. Fix by following the format chain: - **Merged cells**: `MergeCells` (0x00E5) → `sheet_cell_span`/ `sheet_cell_is_covered` (adapter stubs in place). -- **Styles**: fonts (`Font` 0x0031), fills/borders from `XF`; column widths - (`ColInfo` 0x007D) and row heights (`Row` 0x0208). +- **Remaining styles**: borders and alignment from `XF` (fields already + parsed, unused); column widths (`ColInfo` 0x007D) and row heights (`Row` + 0x0208). `Blank`/`MulBlank` cells are skipped entirely, so a fill on an + empty cell is lost. Non-solid fill patterns render as their foreground + color instead of a pattern. - **Hidden rows/columns** (`Row.fDyZero`, `ColInfo.fHidden`). - **Typed cell values**: expose numeric/bool/date `ValueType`s instead of pre-rendered strings. diff --git a/src/odr/internal/oldms/spreadsheet/xls_document.cpp b/src/odr/internal/oldms/spreadsheet/xls_document.cpp index e21ae08c..9e0d9faf 100644 --- a/src/odr/internal/oldms/spreadsheet/xls_document.cpp +++ b/src/odr/internal/oldms/spreadsheet/xls_document.cpp @@ -177,13 +177,17 @@ class ElementAdapter final : public abstract::ElementAdapter, return {}; } [[nodiscard]] TableCellStyle - sheet_cell_style([[maybe_unused]] const ElementIdentifier element_id, - [[maybe_unused]] const std::uint32_t column, - [[maybe_unused]] const std::uint32_t row) const override { - (void)element_id; - (void)column; - (void)row; - return {}; + sheet_cell_style(const ElementIdentifier element_id, + const std::uint32_t column, + const std::uint32_t row) const override { + const ElementIdentifier cell_id = + m_registry->sheet_element_at(element_id).cell(column, row); + if (cell_id == null_element_id) { + return {}; + } + const ElementRegistry::SheetCell &cell = + m_registry->sheet_cell_element_at(cell_id); + return m_registry->cell_style_at(cell.ixfe).cell_style; } [[nodiscard]] TablePosition @@ -213,12 +217,7 @@ class ElementAdapter final : public abstract::ElementAdapter, } [[nodiscard]] TextStyle paragraph_text_style(const ElementIdentifier element_id) const override { - (void)element_id; - // TODO setting font size otherwise the text will be invisible. upstream - // this is used to make empty paragraphs works correctly - TextStyle style{}; - style.font_size = Measure("11pt"); - return style; + return cell_text_style(element_id); } [[nodiscard]] std::string @@ -233,12 +232,7 @@ class ElementAdapter final : public abstract::ElementAdapter, } [[nodiscard]] TextStyle text_style(const ElementIdentifier element_id) const override { - (void)element_id; - // TODO setting font size otherwise the text will be invisible. upstream - // this is used to make empty paragraphs works correctly - TextStyle style{}; - style.font_size = Measure("11pt"); - return style; + return cell_text_style(element_id); } private: @@ -246,6 +240,23 @@ class ElementAdapter final : public abstract::ElementAdapter, [[maybe_unused]] const Document *m_document{nullptr}; ElementRegistry *m_registry{nullptr}; + + /// The font style of the sheet_cell ancestor (paragraph and text elements + /// only exist inside cells). + [[nodiscard]] TextStyle + cell_text_style(const ElementIdentifier element_id) const { + ElementIdentifier id = element_id; + while (id != null_element_id && + element_type(id) != ElementType::sheet_cell) { + id = m_registry->element_at(id).parent_id; + } + if (id == null_element_id) { + return {}; + } + const ElementRegistry::SheetCell &cell = + m_registry->sheet_cell_element_at(id); + return m_registry->cell_style_at(cell.ixfe).text_style; + } }; std::unique_ptr diff --git a/src/odr/internal/oldms/spreadsheet/xls_element_registry.cpp b/src/odr/internal/oldms/spreadsheet/xls_element_registry.cpp index 6c4a965d..2867cfc7 100644 --- a/src/odr/internal/oldms/spreadsheet/xls_element_registry.cpp +++ b/src/odr/internal/oldms/spreadsheet/xls_element_registry.cpp @@ -19,6 +19,8 @@ void ElementRegistry::clear() noexcept { m_texts.clear(); m_sheets.clear(); m_sheet_cells.clear(); + m_cell_styles.clear(); + m_font_names.clear(); } [[nodiscard]] std::size_t ElementRegistry::size() const noexcept { @@ -140,6 +142,27 @@ void ElementRegistry::append_sheet_cell(const ElementIdentifier sheet_id, std::max(sheet.content.columns, cell.position.column + 1); } +const char *ElementRegistry::intern_font_name(const std::string &name) { + if (const auto it = std::ranges::find(m_font_names, name); + it != m_font_names.end()) { + return it->c_str(); + } + return m_font_names.emplace_back(name).c_str(); +} + +void ElementRegistry::set_cell_styles(std::vector styles) noexcept { + m_cell_styles = std::move(styles); +} + +const ElementRegistry::CellStyle & +ElementRegistry::cell_style_at(const std::uint16_t ixfe) const { + if (ixfe >= m_cell_styles.size()) { + throw std::out_of_range( + "ElementRegistry::cell_style_at: XF index out of range"); + } + return m_cell_styles[ixfe]; +} + void ElementRegistry::check_element_id(const ElementIdentifier id) const { if (id == null_element_id) { throw std::out_of_range("ElementRegistry::check_id: null identifier"); diff --git a/src/odr/internal/oldms/spreadsheet/xls_element_registry.hpp b/src/odr/internal/oldms/spreadsheet/xls_element_registry.hpp index dd8e1114..6a6687aa 100644 --- a/src/odr/internal/oldms/spreadsheet/xls_element_registry.hpp +++ b/src/odr/internal/oldms/spreadsheet/xls_element_registry.hpp @@ -2,9 +2,11 @@ #include #include +#include #include #include +#include #include #include #include @@ -43,6 +45,15 @@ class ElementRegistry final { struct SheetCell final { TablePosition position; + /// Index into the workbook's cell styles (the cell record's ixfe). + std::uint16_t ixfe{0}; + }; + + /// Display properties resolved from an XF record and its Font: the text + /// (font) side and the cell (fill) side. + struct CellStyle final { + TextStyle text_style; + TableCellStyle cell_style; }; void clear() noexcept; @@ -72,11 +83,23 @@ class ElementRegistry final { /// they are addressed via `Sheet::cell` (like the ooxml/spreadsheet module). void append_sheet_cell(ElementIdentifier sheet_id, ElementIdentifier cell_id); + /// Stores a font name and returns a pointer that stays valid for the + /// registry's lifetime (`TextStyle::font_name` is a `const char *`). + const char *intern_font_name(const std::string &name); + + void set_cell_styles(std::vector styles) noexcept; + /// The resolved style of an XF record, by XF index (a cell's ixfe). + /// Throws if the index has no XF record. + [[nodiscard]] const CellStyle &cell_style_at(std::uint16_t ixfe) const; + private: std::vector m_elements; std::unordered_map m_texts; std::unordered_map m_sheets; std::unordered_map m_sheet_cells; + std::vector m_cell_styles; + /// Deque: elements never move, so interned `c_str()`s stay valid. + std::deque m_font_names; void check_element_id(ElementIdentifier id) const; void check_text_id(ElementIdentifier id) const; diff --git a/src/odr/internal/oldms/spreadsheet/xls_parser.cpp b/src/odr/internal/oldms/spreadsheet/xls_parser.cpp index 64aa0399..cfbea4a7 100644 --- a/src/odr/internal/oldms/spreadsheet/xls_parser.cpp +++ b/src/odr/internal/oldms/spreadsheet/xls_parser.cpp @@ -7,6 +7,9 @@ #include #include +#include + +#include #include #include #include @@ -20,12 +23,26 @@ struct BoundSheet { std::string name; }; +/// A Font record ([MS-XLS] 2.4.122), in file order. +struct ParsedFont { + FontFixed fixed; + std::string name; +}; + +/// The workbook-global style records collected from the globals substream. +struct GlobalStyles { + std::vector fonts; + std::vector xfs; + std::optional> palette; +}; + /// Creates a non-empty cell: sheet_cell → paragraph → text. void add_cell(ElementRegistry ®istry, const ElementIdentifier sheet_id, const std::uint32_t column, const std::uint32_t row, - std::string text) { + const std::uint16_t ixfe, std::string text) { auto [cell_id, cell_element, cell] = registry.create_sheet_cell_element(TablePosition(column, row)); + cell.ixfe = ixfe; registry.append_sheet_cell(sheet_id, cell_id); auto [paragraph_id, paragraph_element] = @@ -37,10 +54,11 @@ void add_cell(ElementRegistry ®istry, const ElementIdentifier sheet_id, registry.append_child(paragraph_id, text_id); } -/// Globals substream: collects the worksheet BoundSheet8 entries and the -/// shared string table. +/// Globals substream: collects the worksheet BoundSheet8 entries, the shared +/// string table, and the style records (Font/XF/Palette). void parse_globals(BiffReader &reader, std::vector &sheets, - std::vector &shared_strings) { + std::vector &shared_strings, + GlobalStyles &styles) { reader.expect_bof(); while (reader.next_record() && reader.record_type() != biff_eof) { @@ -52,6 +70,25 @@ void parse_globals(BiffReader &reader, std::vector &sheets, sheets.push_back({boundsheet.lbPlyPos, std::move(name)}); } } break; + case biff_font: { + const auto font = reader.read(); + std::string name = reader.read_short_xl_unicode_string(); + styles.fonts.push_back({font, std::move(name)}); + } break; + case biff_xf: { + styles.xfs.push_back(reader.read()); + } break; + case biff_palette: { + // ccv MUST be 56 ([MS-XLS] 2.4.188). + const auto ccv = static_cast(reader.read_u16()); + if (ccv != palette_color_count) { + throw std::runtime_error("xls: unexpected Palette color count"); + } + auto &palette = styles.palette.emplace(); + for (LongRgb &color : palette) { + reader.read(color); + } + } break; case biff_sst: { const auto head = reader.read(); if (head.cstUnique < 0) { @@ -68,6 +105,88 @@ void parse_globals(BiffReader &reader, std::vector &sheets, } } +/// The built-in color constants, icv 0x00-0x07 ([MS-XLS] 2.5.161). +constexpr std::array built_in_colors = { + 0x000000, 0xFFFFFF, 0xFF0000, 0x00FF00, + 0x0000FF, 0xFFFF00, 0xFF00FF, 0x00FFFF}; + +/// The default palette, icv 0x08-0x3F, used when no Palette record is present +/// ([MS-XLS] 2.5.161). +constexpr std::array default_palette = { + 0x000000, 0xFFFFFF, 0xFF0000, 0x00FF00, 0x0000FF, 0xFFFF00, 0xFF00FF, + 0x00FFFF, 0x800000, 0x008000, 0x000080, 0x808000, 0x800080, 0x008080, + 0xC0C0C0, 0x808080, 0x9999FF, 0x993366, 0xFFFFCC, 0xCCFFFF, 0x660066, + 0xFF8080, 0x0066CC, 0xCCCCFF, 0x000080, 0xFF00FF, 0xFFFF00, 0x00FFFF, + 0x800080, 0x800000, 0x008080, 0x0000FF, 0x00CCFF, 0xCCFFFF, 0xCCFFCC, + 0xFFFF99, 0x99CCFF, 0xFF99CC, 0xCC99FF, 0xFFCC99, 0x3366FF, 0x33CCCC, + 0x99CC00, 0xFFCC00, 0xFF9900, 0xFF6600, 0x666699, 0x969696, 0x003366, + 0x339966, 0x003300, 0x333300, 0x993300, 0x993366, 0x333399, 0x333333}; + +/// Resolves a color-table index ([MS-XLS] 2.5.161): built-in constants, then +/// the (custom or default) palette. The system/automatic values (0x40, 0x41, +/// 0x7FFF, ...) resolve to "unset". +std::optional icv_color( + const std::uint16_t icv, + const std::optional> &palette) { + if (icv < built_in_colors.size()) { + return Color(built_in_colors[icv]); + } + if (const std::size_t index = icv - built_in_colors.size(); + index < palette_color_count) { + if (palette.has_value()) { + const LongRgb &color = (*palette)[index]; + return Color(color.red, color.green, color.blue); + } + return Color(default_palette[index]); + } + return std::nullopt; +} + +/// Resolves a FontIndex ([MS-XLS] 2.5.129): values below 4 are zero-based, +/// values above 4 are one-based; 4 never occurs. +const ParsedFont &font_at(const std::vector &fonts, + const std::uint16_t ifnt) { + const std::size_t index = ifnt < 4 ? ifnt : ifnt - 1; + if (ifnt == 4 || index >= fonts.size()) { + throw std::runtime_error("xls: font index out of range"); + } + return fonts[index]; +} + +/// Resolves every XF record against the Font records and the palette; the +/// result vector is indexed by a cell's ixfe. +std::vector +resolve_cell_styles(const GlobalStyles &styles, ElementRegistry ®istry) { + std::vector result; + result.reserve(styles.xfs.size()); + + for (const XfBody &xf : styles.xfs) { + ElementRegistry::CellStyle &style = result.emplace_back(); + + const ParsedFont &font = font_at(styles.fonts, xf.ifnt); + TextStyle &text = style.text_style; + text.font_name = registry.intern_font_name(font.name); + if (font.fixed.dyHeight != 0) { + text.font_size = Measure(font.fixed.dyHeight / 20.0, DynamicUnit("pt")); + } + text.font_weight = + font.fixed.bls >= 600 ? FontWeight::bold : FontWeight::normal; + text.font_style = + font.fixed.fItalic != 0 ? FontStyle::italic : FontStyle::normal; + text.font_underline = font.fixed.uls != 0; + text.font_line_through = font.fixed.fStrikeOut != 0; + text.font_color = icv_color(font.fixed.icv, styles.palette); + + // For the solid pattern only icvFore is rendered; the other patterns are + // approximated by their foreground color as well. + if (xf.fls != 0) { + style.cell_style.background_color = icv_color(xf.icvFore, styles.palette); + } + } + + return result; +} + /// Sheet substream: dimensions plus one element per non-empty cell. void parse_sheet(BiffReader &reader, ElementRegistry ®istry, const ElementIdentifier sheet_id, const BoundSheet &info, @@ -80,7 +199,11 @@ void parse_sheet(BiffReader &reader, ElementRegistry ®istry, // Set when a Formula record announces a string result; the value follows in // a String record ([MS-XLS] 2.5.133). - std::optional pending_string_cell; + struct PendingCell { + TablePosition position; + std::uint16_t ixfe; + }; + std::optional pending_string_cell; while (reader.next_record() && reader.record_type() != biff_eof) { switch (reader.record_type()) { @@ -94,11 +217,11 @@ void parse_sheet(BiffReader &reader, ElementRegistry ®istry, throw std::runtime_error("xls: SST index out of range"); } add_cell(registry, sheet_id, label.cell.col, label.cell.rw, - shared_strings[label.isst]); + label.cell.ixfe, shared_strings[label.isst]); } break; case biff_rk: { const auto rk = reader.read(); - add_cell(registry, sheet_id, rk.col, rk.rw, + add_cell(registry, sheet_id, rk.col, rk.rw, rk.ixfe, format_number(rk.rk.decode())); } break; case biff_mulrk: { @@ -111,25 +234,26 @@ void parse_sheet(BiffReader &reader, ElementRegistry ®istry, } const std::size_t count = (reader.remaining() - 2) / 6; for (std::size_t i = 0; i < count; ++i) { - reader.read_u16(); // ixfe + const std::uint16_t ixfe = reader.read_u16(); const auto rk = reader.read(); - add_cell(registry, sheet_id, column_first + i, row, + add_cell(registry, sheet_id, column_first + i, row, ixfe, format_number(rk.decode())); } } break; case biff_number: { const auto number = reader.read(); add_cell(registry, sheet_id, number.cell.col, number.cell.rw, - format_number(number.num)); + number.cell.ixfe, format_number(number.num)); } break; case biff_label: { const auto cell = reader.read(); - add_cell(registry, sheet_id, cell.col, cell.rw, + add_cell(registry, sheet_id, cell.col, cell.rw, cell.ixfe, reader.read_xl_unicode_string()); } break; case biff_boolerr: { const auto boolerr = reader.read(); add_cell(registry, sheet_id, boolerr.cell.col, boolerr.cell.rw, + boolerr.cell.ixfe, boolerr.fError != 0 ? error_code_string(boolerr.bBoolErr) : (boolerr.bBoolErr != 0 ? "TRUE" : "FALSE")); @@ -140,18 +264,20 @@ void parse_sheet(BiffReader &reader, ElementRegistry ®istry, if (formula.val.is_xnum()) { const double value = formula.val.as_xnum(); add_cell(registry, sheet_id, position.column, position.row, - format_number(value)); + formula.cell.ixfe, format_number(value)); } else { switch (formula.val.type()) { case formula_value_string: - pending_string_cell = position; + pending_string_cell = {position, formula.cell.ixfe}; break; case formula_value_boolean: add_cell(registry, sheet_id, position.column, position.row, + formula.cell.ixfe, formula.val.bool_err_value() != 0 ? "TRUE" : "FALSE"); break; case formula_value_error: add_cell(registry, sheet_id, position.column, position.row, + formula.cell.ixfe, error_code_string(formula.val.bool_err_value())); break; case formula_value_blank: @@ -163,8 +289,9 @@ void parse_sheet(BiffReader &reader, ElementRegistry ®istry, } break; case biff_string: { if (pending_string_cell.has_value()) { - add_cell(registry, sheet_id, pending_string_cell->column, - pending_string_cell->row, reader.read_xl_unicode_string()); + add_cell(registry, sheet_id, pending_string_cell->position.column, + pending_string_cell->position.row, pending_string_cell->ixfe, + reader.read_xl_unicode_string()); pending_string_cell.reset(); } } break; @@ -187,7 +314,9 @@ spreadsheet::parse_tree(ElementRegistry ®istry, std::vector bound_sheets; std::vector shared_strings; - parse_globals(reader, bound_sheets, shared_strings); + GlobalStyles styles; + parse_globals(reader, bound_sheets, shared_strings, styles); + registry.set_cell_styles(resolve_cell_styles(styles, registry)); auto [root_id, root] = registry.create_element(ElementType::root); diff --git a/src/odr/internal/oldms/spreadsheet/xls_structs.hpp b/src/odr/internal/oldms/spreadsheet/xls_structs.hpp index 0ad91559..af2c0931 100644 --- a/src/odr/internal/oldms/spreadsheet/xls_structs.hpp +++ b/src/odr/internal/oldms/spreadsheet/xls_structs.hpp @@ -16,10 +16,13 @@ namespace odr::internal::oldms::spreadsheet { enum BiffRecordType : std::uint16_t { biff_formula = 0x0006, //< [MS-XLS] 2.4.127 biff_eof = 0x000A, //< [MS-XLS] 2.4.103 + biff_font = 0x0031, //< [MS-XLS] 2.4.122 biff_continue = 0x003C, //< [MS-XLS] 2.4.58 biff_boundsheet = 0x0085, //< [MS-XLS] 2.4.28 BoundSheet8 + biff_palette = 0x0092, //< [MS-XLS] 2.4.188 biff_mulrk = 0x00BD, //< [MS-XLS] 2.4.175 biff_mulblank = 0x00BE, //< [MS-XLS] 2.4.174 + biff_xf = 0x00E0, //< [MS-XLS] 2.4.353 biff_sst = 0x00FC, //< [MS-XLS] 2.4.265 biff_labelsst = 0x00FD, //< [MS-XLS] 2.4.149 biff_dimensions = 0x0200, //< [MS-XLS] 2.4.90 @@ -73,6 +76,89 @@ static_assert(sizeof(BoundSheet8Fixed) == 6); /// 0x06 VBA module are skipped). constexpr std::uint8_t boundsheet_dt_worksheet = 0x00; +/// Fixed head of the Font record body ([MS-XLS] 2.4.122); the font name +/// (a ShortXLUnicodeString) follows. +struct FontFixed { + std::uint16_t dyHeight; //< font height in twips; 0 or 20-8191 + std::uint16_t unused1 : 1; + std::uint16_t fItalic : 1; + std::uint16_t unused2 : 1; + std::uint16_t fStrikeOut : 1; + std::uint16_t fOutline : 1; + std::uint16_t fShadow : 1; + std::uint16_t fCondense : 1; + std::uint16_t fExtend : 1; + std::uint16_t reserved : 8; + std::uint16_t icv; //< font color (Icv, [MS-XLS] 2.5.161) + std::uint16_t bls; //< weight: 400 normal, 700 bold + std::uint16_t sss; //< 0 normal, 1 superscript, 2 subscript + std::uint8_t uls; //< underline: 0x00 none; single/double/accounting else + std::uint8_t bFamily; + std::uint8_t bCharSet; + std::uint8_t unused3; +}; +static_assert(sizeof(FontFixed) == 14); + +/// XF record body ([MS-XLS] 2.4.353) including its CellXF payload ([MS-XLS] +/// 2.5.20). A style XF (fStyle == 1) carries a StyleXF instead, which lays +/// out the fields read here identically. +struct XfBody { + std::uint16_t ifnt; //< FontIndex ([MS-XLS] 2.5.129); 4 never occurs + std::uint16_t ifmt; //< number format; kept for later use + std::uint16_t fLocked : 1; + std::uint16_t fHidden : 1; + std::uint16_t fStyle : 1; //< 1 = cell style XF, 0 = cell XF + std::uint16_t f123Prefix : 1; + std::uint16_t ixfParent : 12; + std::uint16_t alc : 3; + std::uint16_t fWrap : 1; + std::uint16_t alcV : 3; + std::uint16_t fJustLast : 1; + std::uint16_t trot : 8; + std::uint16_t cIndent : 4; + std::uint16_t fShrinkToFit : 1; + std::uint16_t reserved1 : 1; + std::uint16_t iReadOrder : 2; + std::uint16_t reserved2 : 2; + std::uint16_t fAtrNum : 1; + std::uint16_t fAtrFnt : 1; + std::uint16_t fAtrAlc : 1; + std::uint16_t fAtrBdr : 1; + std::uint16_t fAtrPat : 1; + std::uint16_t fAtrProt : 1; + std::uint16_t dgLeft : 4; + std::uint16_t dgRight : 4; + std::uint16_t dgTop : 4; + std::uint16_t dgBottom : 4; + std::uint16_t icvLeft : 7; + std::uint16_t icvRight : 7; + std::uint16_t grbitDiag : 2; + std::uint32_t icvTop : 7; + std::uint32_t icvBottom : 7; + std::uint32_t icvDiag : 7; + std::uint32_t dgDiag : 4; + std::uint32_t fHasXFExt : 1; + std::uint32_t fls : 6; //< FillPattern: 0 none, 1 solid (icvFore only) + std::uint16_t icvFore : 7; + std::uint16_t icvBack : 7; + std::uint16_t fsxButton : 1; + std::uint16_t reserved3 : 1; +}; +static_assert(sizeof(XfBody) == 20); + +/// LongRGB ([MS-XLS] 2.5.178): a Palette record color entry. +struct LongRgb { + std::uint8_t red; + std::uint8_t green; + std::uint8_t blue; + std::uint8_t reserved; +}; +static_assert(sizeof(LongRgb) == 4); + +/// The Palette record's rgColor array holds exactly 56 colors, mapped to the +/// icv values 0x08-0x3F ([MS-XLS] 2.4.188, 2.5.161). +constexpr std::size_t palette_color_count = 56; + /// Cell structure: the head of every cell record body ([MS-XLS] 2.5.13). struct CellRef { std::uint16_t rw; diff --git a/test/src/internal/oldms/oldms_test.cpp b/test/src/internal/oldms/oldms_test.cpp index f414dcbe..588b6e03 100644 --- a/test/src/internal/oldms/oldms_test.cpp +++ b/test/src/internal/oldms/oldms_test.cpp @@ -8,11 +8,18 @@ #include #include +#include + +#include +#include +#include +#include #include #include #include #include +#include #include #include #include @@ -169,6 +176,190 @@ TEST(OldMs, xls_decode_rk) { EXPECT_EQ(format_number(123.45), "123.45"); } +namespace { + +void append_u32(std::string &out, const std::uint32_t value) { + append_u16(out, static_cast(value & 0xFFFF)); + append_u16(out, static_cast(value >> 16)); +} + +/// A Font record body ([MS-XLS] 2.4.122). +std::string make_font(const std::uint16_t dy_height, const std::uint16_t grbit, + const std::uint16_t icv, const std::uint16_t bls, + const std::uint8_t uls, const std::string &name) { + std::string body; + append_u16(body, dy_height); + append_u16(body, grbit); + append_u16(body, icv); + append_u16(body, bls); + append_u16(body, 0); // sss + body.push_back(static_cast(uls)); + body += std::string("\0\0\0", 3); // bFamily, bCharSet, unused3 + body.push_back(static_cast(name.size())); + body.push_back('\x00'); // flags: compressed + body += name; + return body; +} + +/// An XF record body ([MS-XLS] 2.4.353): font index plus the fill pattern and +/// foreground fill color of its CellXF. +std::string make_xf(const std::uint16_t ifnt, const std::uint32_t fls, + const std::uint16_t icv_fore) { + std::string body; + append_u16(body, ifnt); + append_u16(body, 0); // ifmt + append_u16(body, 0); // fLocked..ixfParent + append_u16(body, 0); // alc..trot + append_u16(body, 0); // cIndent..fAtr* + append_u16(body, 0); // border styles + append_u16(body, 0); // icvLeft, icvRight, grbitDiag + append_u32(body, fls << 26); // icvTop..dgDiag, fls + append_u16(body, icv_fore | (0x41u << 7)); // icvFore, icvBack = default + return body; +} + +/// A Label record body ([MS-XLS] 2.4.148): an inline-string cell. +std::string make_label(const std::uint16_t row, const std::uint16_t column, + const std::uint16_t ixfe, const std::string &text) { + std::string body; + append_u16(body, row); + append_u16(body, column); + append_u16(body, ixfe); + append_u16(body, static_cast(text.size())); + body.push_back('\x00'); // flags: compressed + body += text; + return body; +} + +std::string make_bof(const std::uint16_t dt) { + std::string body; + append_u16(body, 0x0600); // vers: BIFF8 + append_u16(body, dt); + return body; +} + +/// One-sheet workbook stream: `globals` records are wrapped with BOF/ +/// BoundSheet8/EOF, the sheet substream holds the given `cells`. +std::string make_workbook(const std::string &globals, + const std::vector &cells) { + const auto build_globals = [&](const std::uint32_t sheet_offset) { + std::string result; + append_record(result, 0x0809 /* BOF */, make_bof(0x0005)); + result += globals; + std::string boundsheet; + append_u32(boundsheet, sheet_offset); + boundsheet.push_back('\x00'); // visible + boundsheet.push_back('\x00'); // worksheet + boundsheet.push_back('\x06'); + boundsheet.push_back('\x00'); // name: compressed + boundsheet += "Sheet1"; + append_record(result, 0x0085 /* BoundSheet8 */, boundsheet); + append_record(result, 0x000A /* EOF */, ""); + return result; + }; + + std::string sheet; + append_record(sheet, 0x0809 /* BOF */, make_bof(0x0010)); + for (const std::string &cell : cells) { + append_record(sheet, 0x0204 /* Label */, cell); + } + append_record(sheet, 0x000A /* EOF */, ""); + + return build_globals(static_cast(build_globals(0).size())) + + sheet; +} + +Document open_workbook(const std::string &workbook) { + auto files = std::make_shared(); + files->copy(std::make_shared(workbook), + internal::AbsPath("/Workbook")); + return Document( + std::make_shared(files)); +} + +Text first_text(const Element cell) { + return cell.first_child().first_child().as_text(); +} + +} // namespace + +// Fonts and fills resolve through XF -> Font/palette ([MS-XLS] 2.4.353, +// 2.4.122): the cell's ixfe picks the XF, whose ifnt picks the Font (index 4 +// is skipped and values above 4 are one-based, [MS-XLS] 2.5.129); colors use +// the default palette when no Palette record is present ([MS-XLS] 2.5.161). +TEST(OldMs, xls_cell_styles) { + const std::string plain_font = + make_font(200, 0, 0x7FFF /* automatic */, 400, 0, "Arial"); + // grbit: fItalic (bit 1) + fStrikeOut (bit 3); icv 0x11 = default palette + // index 9 = 0x008000; single underline. + const std::string fancy_font = + make_font(320, 0x000A, 0x0011, 700, 1, "Comic Sans MS"); + + std::string globals; + for (int i = 0; i < 4; ++i) { + append_record(globals, 0x0031 /* Font */, plain_font); + } + append_record(globals, 0x0031 /* Font */, fancy_font); // ifnt 5 + append_record(globals, 0x00E0 /* XF */, make_xf(0, 0, 0)); + // Solid fill; icvFore 0x0C = default palette index 4 = 0x0000FF. + append_record(globals, 0x00E0 /* XF */, make_xf(5, 1, 0x0C)); + + const Document document = open_workbook(make_workbook( + globals, {make_label(0, 0, 0, "plain"), make_label(0, 1, 1, "fancy")})); + + const Sheet sheet = document.root_element().first_child().as_sheet(); + + const TextStyle plain = first_text(sheet.cell(0, 0)).style(); + EXPECT_STREQ(plain.font_name, "Arial"); + EXPECT_EQ(plain.font_size, Measure("10pt")); + EXPECT_EQ(plain.font_weight, FontWeight::normal); + EXPECT_EQ(plain.font_style, FontStyle::normal); + EXPECT_EQ(plain.font_underline, false); + EXPECT_EQ(plain.font_line_through, false); + EXPECT_FALSE(plain.font_color.has_value()); // automatic + EXPECT_FALSE(sheet.cell_style(0, 0).background_color.has_value()); + + const TextStyle fancy = first_text(sheet.cell(1, 0)).style(); + EXPECT_STREQ(fancy.font_name, "Comic Sans MS"); + EXPECT_EQ(fancy.font_size, Measure("16pt")); + EXPECT_EQ(fancy.font_weight, FontWeight::bold); + EXPECT_EQ(fancy.font_style, FontStyle::italic); + EXPECT_EQ(fancy.font_underline, true); + EXPECT_EQ(fancy.font_line_through, true); + ASSERT_TRUE(fancy.font_color.has_value()); + EXPECT_EQ(fancy.font_color->rgb(), 0x008000); + + const auto fill = sheet.cell_style(1, 0).background_color; + ASSERT_TRUE(fill.has_value()); + EXPECT_EQ(fill->rgb(), 0x0000FF); + + // Positions without a cell record stay unstyled. + EXPECT_FALSE(sheet.cell_style(5, 5).background_color.has_value()); +} + +// A Palette record ([MS-XLS] 2.4.188) replaces the default palette for the +// icv values 0x08-0x3F. +TEST(OldMs, xls_palette_record) { + std::string globals; + append_record(globals, 0x0031 /* Font */, + make_font(200, 0, 0x0008, 400, 0, "Arial")); + append_record(globals, 0x00E0 /* XF */, make_xf(0, 0, 0)); + + std::string palette; + append_u16(palette, 56); // ccv + palette += std::string("\x12\x34\x56\x00", 4); // rgColor[0] -> icv 0x08 + palette += std::string(std::size_t{55} * 4, '\x00'); + append_record(globals, 0x0092 /* Palette */, palette); + + const Document document = + open_workbook(make_workbook(globals, {make_label(0, 0, 0, "x")})); + + const Sheet sheet = document.root_element().first_child().as_sheet(); + const TextStyle style = first_text(sheet.cell(0, 0)).style(); + ASSERT_TRUE(style.font_color.has_value()); + EXPECT_EQ(style.font_color->rgb(), 0x123456); +} + TEST(OldMs, xls_empty) { const std::unique_ptr logger = Logger::create_stdio("odr-test", LogLevel::verbose); From c0a60e4890a0d3ae1f515a1e179303a3fd9d0ff7 Mon Sep 17 00:00:00 2001 From: Andreas Stefl Date: Sun, 19 Jul 2026 10:38:16 +0200 Subject: [PATCH 2/4] test(xls): update reference outputs for cell formatting Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01AEvWWwe1EyeWH7ANHoAvPH From 3b92d5fbde3f47e546e07fbb3204d146cb4e6da8 Mon Sep 17 00:00:00 2001 From: Andreas Stefl Date: Sun, 19 Jul 2026 10:46:52 +0200 Subject: [PATCH 3/4] update refs --- test/data/reference-output/odr-private | 2 +- test/data/reference-output/odr-public | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/test/data/reference-output/odr-private b/test/data/reference-output/odr-private index 88acbeee..c80535bf 160000 --- a/test/data/reference-output/odr-private +++ b/test/data/reference-output/odr-private @@ -1 +1 @@ -Subproject commit 88acbeee24a474b147e1db41511aa8271afc9394 +Subproject commit c80535bf2c9057daea7b7c9b0a8210918a488d16 diff --git a/test/data/reference-output/odr-public b/test/data/reference-output/odr-public index cada946e..ea5173fc 160000 --- a/test/data/reference-output/odr-public +++ b/test/data/reference-output/odr-public @@ -1 +1 @@ -Subproject commit cada946ed3927d0606c209bece66cf4275f985b7 +Subproject commit ea5173fcdd5613269c0b4dd849c12f7e95e203b3 From ff7953d1196bdc147e05d1530480d4d02da6f0c4 Mon Sep 17 00:00:00 2001 From: Andreas Stefl Date: Tue, 21 Jul 2026 19:50:20 +0200 Subject: [PATCH 4/4] refactor(xls): move cell styles into a separate StyleRegistry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirror the odf/ooxml split between element and style registries: the new xls_style.{hpp,cpp} StyleRegistry resolves Font/XF/Palette into one ResolvedStyle per XF, indexed by a cell's ixfe. The ElementRegistry loses the style table, its setter, and the font-name interning deque — the StyleRegistry owns the parsed Font records, so font_name points into them. The parser fills both registries from the single /Workbook stream. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01PvbJYsLLST3hkCgkcHBoFb --- CMakeLists.txt | 1 + src/odr/internal/oldms/spreadsheet/AGENTS.md | 21 ++-- .../oldms/spreadsheet/xls_document.cpp | 28 +++-- .../oldms/spreadsheet/xls_document.hpp | 4 + .../spreadsheet/xls_element_registry.cpp | 23 ---- .../spreadsheet/xls_element_registry.hpp | 24 +--- .../internal/oldms/spreadsheet/xls_parser.cpp | 106 ++---------------- .../internal/oldms/spreadsheet/xls_parser.hpp | 5 +- .../internal/oldms/spreadsheet/xls_style.cpp | 105 +++++++++++++++++ .../internal/oldms/spreadsheet/xls_style.hpp | 42 +++++++ 10 files changed, 200 insertions(+), 159 deletions(-) create mode 100644 src/odr/internal/oldms/spreadsheet/xls_style.cpp create mode 100644 src/odr/internal/oldms/spreadsheet/xls_style.hpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 7451ca1c..6f1aeb25 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -157,6 +157,7 @@ set(ODR_SOURCE_FILES "src/odr/internal/oldms/spreadsheet/xls_element_registry.cpp" "src/odr/internal/oldms/spreadsheet/xls_io.cpp" "src/odr/internal/oldms/spreadsheet/xls_parser.cpp" + "src/odr/internal/oldms/spreadsheet/xls_style.cpp" "src/odr/internal/oldms/presentation/ppt_document.cpp" "src/odr/internal/oldms/presentation/ppt_element_registry.cpp" "src/odr/internal/oldms/presentation/ppt_io.cpp" diff --git a/src/odr/internal/oldms/spreadsheet/AGENTS.md b/src/odr/internal/oldms/spreadsheet/AGENTS.md index 1b2ff9d5..3475469f 100644 --- a/src/odr/internal/oldms/spreadsheet/AGENTS.md +++ b/src/odr/internal/oldms/spreadsheet/AGENTS.md @@ -19,7 +19,8 @@ drawings, or charts. | `xls_structs.hpp` | `#pragma pack(1)` PODs for record bodies + `static_assert` sizes + record-type enum | | `xls_io.{hpp,cpp}` | `BiffReader` (record walker with transparent `CONTINUE` hopping; the `[MS-XLS]` string readers + `expect_bof`), RK decoding, number formatting | | `xls_parser.{hpp,cpp}` | `parse_tree` → globals (BoundSheet8 + SST + Font/XF/Palette) then one pass per sheet substream | -| `xls_element_registry.{hpp,cpp}` | Flat element store + `Sheet` (name, dimensions, cell-position map), `SheetCell` payloads, and the resolved per-XF `CellStyle` table | +| `xls_element_registry.{hpp,cpp}` | Flat element store + `Sheet` (name, dimensions, cell-position map) and `SheetCell` payloads | +| `xls_style.{hpp,cpp}` | `StyleRegistry`: resolves Font/XF/Palette into one `ResolvedStyle` per XF, indexed by a cell's `ixfe` (sibling of the odf/ooxml style registries) | | `xls_document.{hpp,cpp}` | `internal::Document` subclass + the `ElementAdapter` | Tree shape: `sheet → sheet_cell → paragraph → text`, one `sheet_cell` per @@ -59,11 +60,14 @@ bit-field structs (`RkNumber`, `UnicodeStringFlags`, flags of `BoundSheet8Fixed`/`FormulaFixed`) — little-endian, LSB-first hosts only; shared `oldms/` assumption, see [`../AGENTS.md`](../AGENTS.md). -**Cell formatting is resolved at parse time, per XF.** Each cell record's -`ixfe` is kept on the `SheetCell`; the globals pass collects `Font` (0x0031), -`XF` (0x00E0) and `Palette` (0x0092) and resolves every XF into a `CellStyle` -(a `TextStyle` from the font + a `TableCellStyle` fill), stored once in the -registry and indexed by `ixfe`. The adapters only look up: `text_style`/ +**Cell formatting is resolved at parse time, per XF, in a separate +`StyleRegistry`** (mirroring the odf/ooxml split between element and style +registries; here the parser fills both, since BIFF keeps styles and content in +the same `/Workbook` stream). Each cell record's `ixfe` is kept on the +`SheetCell`; the globals pass collects `Font` (0x0031), `XF` (0x00E0) and +`Palette` (0x0092), and the `StyleRegistry` constructor resolves every XF into +a `ResolvedStyle` (a `TextStyle` from the font + a `TableCellStyle` fill), +indexed by `ixfe`. The adapters only look up: `text_style`/ `paragraph_text_style` walk up to the `sheet_cell` ancestor and return its XF's `TextStyle`; `sheet_cell_style` returns the fill. The non-obvious bits: - **`FontIndex` 4 does not exist** (§2.5.129): `ifnt` < 4 is zero-based, @@ -74,8 +78,9 @@ XF's `TextStyle`; `sheet_cell_style` returns the fill. The non-obvious bits: - **Fills** (§2.5.20): `fls` 0 = none; solid (1) renders `icvFore`; the other patterns are *approximated* by their foreground color. - A `dyHeight` of 0 (allowed by §2.4.122) leaves `font_size` unset instead of - emitting invisible 0pt text. Font names are interned in a `std::deque` so - `TextStyle::font_name` (`const char *`) stays valid. + emitting invisible 0pt text. The `StyleRegistry` owns the parsed `Font` + records, so `TextStyle::font_name` (`const char *`) points into them and + stays valid. **Adapters** expose `ValueType::string` for every cell and `sheet_cell_span` → `{1,1}`; sheet/column/row styles are still `{}`. diff --git a/src/odr/internal/oldms/spreadsheet/xls_document.cpp b/src/odr/internal/oldms/spreadsheet/xls_document.cpp index 9e0d9faf..6184507b 100644 --- a/src/odr/internal/oldms/spreadsheet/xls_document.cpp +++ b/src/odr/internal/oldms/spreadsheet/xls_document.cpp @@ -14,15 +14,17 @@ namespace odr::internal::oldms::spreadsheet { namespace { std::unique_ptr -create_element_adapter(const Document &document, ElementRegistry ®istry); +create_element_adapter(const Document &document, ElementRegistry ®istry, + const StyleRegistry &style_registry); } Document::Document(std::shared_ptr files) : internal::Document(FileType::legacy_excel_worksheets, DocumentType::spreadsheet, std::move(files)) { - m_root_element = parse_tree(m_element_registry, *m_files); + m_root_element = parse_tree(m_element_registry, m_style_registry, *m_files); - m_element_adapter = create_element_adapter(*this, m_element_registry); + m_element_adapter = + create_element_adapter(*this, m_element_registry, m_style_registry); } ElementRegistry &Document::element_registry() { return m_element_registry; } @@ -31,6 +33,10 @@ const ElementRegistry &Document::element_registry() const { return m_element_registry; } +const StyleRegistry &Document::style_registry() const { + return m_style_registry; +} + bool Document::is_editable() const noexcept { return false; } bool Document::is_savable(const bool encrypted) const noexcept { @@ -57,8 +63,10 @@ class ElementAdapter final : public abstract::ElementAdapter, public abstract::ParagraphAdapter, public abstract::TextAdapter { public: - ElementAdapter(const Document &document, ElementRegistry ®istry) - : m_document(&document), m_registry(®istry) {} + ElementAdapter(const Document &document, ElementRegistry ®istry, + const StyleRegistry &style_registry) + : m_document(&document), m_registry(®istry), + m_style_registry(&style_registry) {} [[nodiscard]] ElementType element_type(const ElementIdentifier element_id) const override { @@ -187,7 +195,7 @@ class ElementAdapter final : public abstract::ElementAdapter, } const ElementRegistry::SheetCell &cell = m_registry->sheet_cell_element_at(cell_id); - return m_registry->cell_style_at(cell.ixfe).cell_style; + return m_style_registry->cell_style(cell.ixfe).table_cell_style; } [[nodiscard]] TablePosition @@ -240,6 +248,7 @@ class ElementAdapter final : public abstract::ElementAdapter, [[maybe_unused]] const Document *m_document{nullptr}; ElementRegistry *m_registry{nullptr}; + const StyleRegistry *m_style_registry{nullptr}; /// The font style of the sheet_cell ancestor (paragraph and text elements /// only exist inside cells). @@ -255,13 +264,14 @@ class ElementAdapter final : public abstract::ElementAdapter, } const ElementRegistry::SheetCell &cell = m_registry->sheet_cell_element_at(id); - return m_registry->cell_style_at(cell.ixfe).text_style; + return m_style_registry->cell_style(cell.ixfe).text_style; } }; std::unique_ptr -create_element_adapter(const Document &document, ElementRegistry ®istry) { - return std::make_unique(document, registry); +create_element_adapter(const Document &document, ElementRegistry ®istry, + const StyleRegistry &style_registry) { + return std::make_unique(document, registry, style_registry); } } // namespace diff --git a/src/odr/internal/oldms/spreadsheet/xls_document.hpp b/src/odr/internal/oldms/spreadsheet/xls_document.hpp index 002688ed..b03b1da0 100644 --- a/src/odr/internal/oldms/spreadsheet/xls_document.hpp +++ b/src/odr/internal/oldms/spreadsheet/xls_document.hpp @@ -2,6 +2,7 @@ #include #include +#include #include @@ -15,6 +16,8 @@ class Document final : public internal::Document { [[nodiscard]] const ElementRegistry &element_registry() const; + [[nodiscard]] const StyleRegistry &style_registry() const; + [[nodiscard]] bool is_editable() const noexcept override; [[nodiscard]] bool is_savable(bool encrypted) const noexcept override; @@ -23,6 +26,7 @@ class Document final : public internal::Document { private: ElementRegistry m_element_registry; + StyleRegistry m_style_registry; }; } // namespace odr::internal::oldms::spreadsheet diff --git a/src/odr/internal/oldms/spreadsheet/xls_element_registry.cpp b/src/odr/internal/oldms/spreadsheet/xls_element_registry.cpp index 2867cfc7..6c4a965d 100644 --- a/src/odr/internal/oldms/spreadsheet/xls_element_registry.cpp +++ b/src/odr/internal/oldms/spreadsheet/xls_element_registry.cpp @@ -19,8 +19,6 @@ void ElementRegistry::clear() noexcept { m_texts.clear(); m_sheets.clear(); m_sheet_cells.clear(); - m_cell_styles.clear(); - m_font_names.clear(); } [[nodiscard]] std::size_t ElementRegistry::size() const noexcept { @@ -142,27 +140,6 @@ void ElementRegistry::append_sheet_cell(const ElementIdentifier sheet_id, std::max(sheet.content.columns, cell.position.column + 1); } -const char *ElementRegistry::intern_font_name(const std::string &name) { - if (const auto it = std::ranges::find(m_font_names, name); - it != m_font_names.end()) { - return it->c_str(); - } - return m_font_names.emplace_back(name).c_str(); -} - -void ElementRegistry::set_cell_styles(std::vector styles) noexcept { - m_cell_styles = std::move(styles); -} - -const ElementRegistry::CellStyle & -ElementRegistry::cell_style_at(const std::uint16_t ixfe) const { - if (ixfe >= m_cell_styles.size()) { - throw std::out_of_range( - "ElementRegistry::cell_style_at: XF index out of range"); - } - return m_cell_styles[ixfe]; -} - void ElementRegistry::check_element_id(const ElementIdentifier id) const { if (id == null_element_id) { throw std::out_of_range("ElementRegistry::check_id: null identifier"); diff --git a/src/odr/internal/oldms/spreadsheet/xls_element_registry.hpp b/src/odr/internal/oldms/spreadsheet/xls_element_registry.hpp index 6a6687aa..0f5db7a7 100644 --- a/src/odr/internal/oldms/spreadsheet/xls_element_registry.hpp +++ b/src/odr/internal/oldms/spreadsheet/xls_element_registry.hpp @@ -2,11 +2,9 @@ #include #include -#include #include #include -#include #include #include #include @@ -45,17 +43,11 @@ class ElementRegistry final { struct SheetCell final { TablePosition position; - /// Index into the workbook's cell styles (the cell record's ixfe). + /// Index into the workbook's XF records (the cell record's ixfe); resolved + /// through the `StyleRegistry`. std::uint16_t ixfe{0}; }; - /// Display properties resolved from an XF record and its Font: the text - /// (font) side and the cell (fill) side. - struct CellStyle final { - TextStyle text_style; - TableCellStyle cell_style; - }; - void clear() noexcept; [[nodiscard]] std::size_t size() const noexcept; @@ -83,23 +75,11 @@ class ElementRegistry final { /// they are addressed via `Sheet::cell` (like the ooxml/spreadsheet module). void append_sheet_cell(ElementIdentifier sheet_id, ElementIdentifier cell_id); - /// Stores a font name and returns a pointer that stays valid for the - /// registry's lifetime (`TextStyle::font_name` is a `const char *`). - const char *intern_font_name(const std::string &name); - - void set_cell_styles(std::vector styles) noexcept; - /// The resolved style of an XF record, by XF index (a cell's ixfe). - /// Throws if the index has no XF record. - [[nodiscard]] const CellStyle &cell_style_at(std::uint16_t ixfe) const; - private: std::vector m_elements; std::unordered_map m_texts; std::unordered_map m_sheets; std::unordered_map m_sheet_cells; - std::vector m_cell_styles; - /// Deque: elements never move, so interned `c_str()`s stay valid. - std::deque m_font_names; void check_element_id(ElementIdentifier id) const; void check_text_id(ElementIdentifier id) const; diff --git a/src/odr/internal/oldms/spreadsheet/xls_parser.cpp b/src/odr/internal/oldms/spreadsheet/xls_parser.cpp index cfbea4a7..dd54ba3d 100644 --- a/src/odr/internal/oldms/spreadsheet/xls_parser.cpp +++ b/src/odr/internal/oldms/spreadsheet/xls_parser.cpp @@ -6,13 +6,12 @@ #include #include #include +#include -#include - -#include #include #include #include +#include #include namespace odr::internal::oldms::spreadsheet { @@ -23,17 +22,12 @@ struct BoundSheet { std::string name; }; -/// A Font record ([MS-XLS] 2.4.122), in file order. -struct ParsedFont { - FontFixed fixed; - std::string name; -}; - /// The workbook-global style records collected from the globals substream. struct GlobalStyles { - std::vector fonts; + std::vector fonts; std::vector xfs; - std::optional> palette; + /// The Palette record's colors; empty when the record is absent. + std::vector palette; }; /// Creates a non-empty cell: sheet_cell → paragraph → text. @@ -84,8 +78,8 @@ void parse_globals(BiffReader &reader, std::vector &sheets, if (ccv != palette_color_count) { throw std::runtime_error("xls: unexpected Palette color count"); } - auto &palette = styles.palette.emplace(); - for (LongRgb &color : palette) { + styles.palette.resize(palette_color_count); + for (LongRgb &color : styles.palette) { reader.read(color); } } break; @@ -105,88 +99,6 @@ void parse_globals(BiffReader &reader, std::vector &sheets, } } -/// The built-in color constants, icv 0x00-0x07 ([MS-XLS] 2.5.161). -constexpr std::array built_in_colors = { - 0x000000, 0xFFFFFF, 0xFF0000, 0x00FF00, - 0x0000FF, 0xFFFF00, 0xFF00FF, 0x00FFFF}; - -/// The default palette, icv 0x08-0x3F, used when no Palette record is present -/// ([MS-XLS] 2.5.161). -constexpr std::array default_palette = { - 0x000000, 0xFFFFFF, 0xFF0000, 0x00FF00, 0x0000FF, 0xFFFF00, 0xFF00FF, - 0x00FFFF, 0x800000, 0x008000, 0x000080, 0x808000, 0x800080, 0x008080, - 0xC0C0C0, 0x808080, 0x9999FF, 0x993366, 0xFFFFCC, 0xCCFFFF, 0x660066, - 0xFF8080, 0x0066CC, 0xCCCCFF, 0x000080, 0xFF00FF, 0xFFFF00, 0x00FFFF, - 0x800080, 0x800000, 0x008080, 0x0000FF, 0x00CCFF, 0xCCFFFF, 0xCCFFCC, - 0xFFFF99, 0x99CCFF, 0xFF99CC, 0xCC99FF, 0xFFCC99, 0x3366FF, 0x33CCCC, - 0x99CC00, 0xFFCC00, 0xFF9900, 0xFF6600, 0x666699, 0x969696, 0x003366, - 0x339966, 0x003300, 0x333300, 0x993300, 0x993366, 0x333399, 0x333333}; - -/// Resolves a color-table index ([MS-XLS] 2.5.161): built-in constants, then -/// the (custom or default) palette. The system/automatic values (0x40, 0x41, -/// 0x7FFF, ...) resolve to "unset". -std::optional icv_color( - const std::uint16_t icv, - const std::optional> &palette) { - if (icv < built_in_colors.size()) { - return Color(built_in_colors[icv]); - } - if (const std::size_t index = icv - built_in_colors.size(); - index < palette_color_count) { - if (palette.has_value()) { - const LongRgb &color = (*palette)[index]; - return Color(color.red, color.green, color.blue); - } - return Color(default_palette[index]); - } - return std::nullopt; -} - -/// Resolves a FontIndex ([MS-XLS] 2.5.129): values below 4 are zero-based, -/// values above 4 are one-based; 4 never occurs. -const ParsedFont &font_at(const std::vector &fonts, - const std::uint16_t ifnt) { - const std::size_t index = ifnt < 4 ? ifnt : ifnt - 1; - if (ifnt == 4 || index >= fonts.size()) { - throw std::runtime_error("xls: font index out of range"); - } - return fonts[index]; -} - -/// Resolves every XF record against the Font records and the palette; the -/// result vector is indexed by a cell's ixfe. -std::vector -resolve_cell_styles(const GlobalStyles &styles, ElementRegistry ®istry) { - std::vector result; - result.reserve(styles.xfs.size()); - - for (const XfBody &xf : styles.xfs) { - ElementRegistry::CellStyle &style = result.emplace_back(); - - const ParsedFont &font = font_at(styles.fonts, xf.ifnt); - TextStyle &text = style.text_style; - text.font_name = registry.intern_font_name(font.name); - if (font.fixed.dyHeight != 0) { - text.font_size = Measure(font.fixed.dyHeight / 20.0, DynamicUnit("pt")); - } - text.font_weight = - font.fixed.bls >= 600 ? FontWeight::bold : FontWeight::normal; - text.font_style = - font.fixed.fItalic != 0 ? FontStyle::italic : FontStyle::normal; - text.font_underline = font.fixed.uls != 0; - text.font_line_through = font.fixed.fStrikeOut != 0; - text.font_color = icv_color(font.fixed.icv, styles.palette); - - // For the solid pattern only icvFore is rendered; the other patterns are - // approximated by their foreground color as well. - if (xf.fls != 0) { - style.cell_style.background_color = icv_color(xf.icvFore, styles.palette); - } - } - - return result; -} - /// Sheet substream: dimensions plus one element per non-empty cell. void parse_sheet(BiffReader &reader, ElementRegistry ®istry, const ElementIdentifier sheet_id, const BoundSheet &info, @@ -308,6 +220,7 @@ namespace odr::internal::oldms { ElementIdentifier spreadsheet::parse_tree(ElementRegistry ®istry, + StyleRegistry &style_registry, const abstract::ReadableFilesystem &files) { const auto workbook_stream = files.open(AbsPath("/Workbook"))->stream(); BiffReader reader(*workbook_stream); @@ -316,7 +229,8 @@ spreadsheet::parse_tree(ElementRegistry ®istry, std::vector shared_strings; GlobalStyles styles; parse_globals(reader, bound_sheets, shared_strings, styles); - registry.set_cell_styles(resolve_cell_styles(styles, registry)); + style_registry = + StyleRegistry(std::move(styles.fonts), styles.xfs, styles.palette); auto [root_id, root] = registry.create_element(ElementType::root); diff --git a/src/odr/internal/oldms/spreadsheet/xls_parser.hpp b/src/odr/internal/oldms/spreadsheet/xls_parser.hpp index 06a79669..df3e668b 100644 --- a/src/odr/internal/oldms/spreadsheet/xls_parser.hpp +++ b/src/odr/internal/oldms/spreadsheet/xls_parser.hpp @@ -9,10 +9,13 @@ class ReadableFilesystem; namespace odr::internal::oldms::spreadsheet { class ElementRegistry; +class StyleRegistry; /// Parses the `/Workbook` BIFF8 stream into root → sheet → cell → paragraph → -/// text elements. \return the root element id. +/// text elements; fills `style_registry` from the globals substream. +/// \return the root element id. ElementIdentifier parse_tree(ElementRegistry ®istry, + StyleRegistry &style_registry, const abstract::ReadableFilesystem &files); } // namespace odr::internal::oldms::spreadsheet diff --git a/src/odr/internal/oldms/spreadsheet/xls_style.cpp b/src/odr/internal/oldms/spreadsheet/xls_style.cpp new file mode 100644 index 00000000..d2821654 --- /dev/null +++ b/src/odr/internal/oldms/spreadsheet/xls_style.cpp @@ -0,0 +1,105 @@ +#include + +#include +#include + +#include +#include +#include + +namespace odr::internal::oldms::spreadsheet { +namespace { + +/// The built-in color constants, icv 0x00-0x07 ([MS-XLS] 2.5.161). +constexpr std::array built_in_colors = { + 0x000000, 0xFFFFFF, 0xFF0000, 0x00FF00, + 0x0000FF, 0xFFFF00, 0xFF00FF, 0x00FFFF}; + +/// The default palette, icv 0x08-0x3F, used when no Palette record is present +/// ([MS-XLS] 2.5.161). +constexpr std::array default_palette = { + 0x000000, 0xFFFFFF, 0xFF0000, 0x00FF00, 0x0000FF, 0xFFFF00, 0xFF00FF, + 0x00FFFF, 0x800000, 0x008000, 0x000080, 0x808000, 0x800080, 0x008080, + 0xC0C0C0, 0x808080, 0x9999FF, 0x993366, 0xFFFFCC, 0xCCFFFF, 0x660066, + 0xFF8080, 0x0066CC, 0xCCCCFF, 0x000080, 0xFF00FF, 0xFFFF00, 0x00FFFF, + 0x800080, 0x800000, 0x008080, 0x0000FF, 0x00CCFF, 0xCCFFFF, 0xCCFFCC, + 0xFFFF99, 0x99CCFF, 0xFF99CC, 0xCC99FF, 0xFFCC99, 0x3366FF, 0x33CCCC, + 0x99CC00, 0xFFCC00, 0xFF9900, 0xFF6600, 0x666699, 0x969696, 0x003366, + 0x339966, 0x003300, 0x333300, 0x993300, 0x993366, 0x333399, 0x333333}; + +/// Resolves a color-table index ([MS-XLS] 2.5.161): built-in constants, then +/// the (custom or default) palette. The system/automatic values (0x40, 0x41, +/// 0x7FFF, ...) resolve to "unset". +std::optional icv_color(const std::uint16_t icv, + const std::span palette) { + if (icv < built_in_colors.size()) { + return Color(built_in_colors[icv]); + } + if (const std::size_t index = icv - built_in_colors.size(); + index < palette_color_count) { + if (!palette.empty()) { + const LongRgb &color = palette[index]; + return Color(color.red, color.green, color.blue); + } + return Color(default_palette[index]); + } + return std::nullopt; +} + +/// Resolves a FontIndex ([MS-XLS] 2.5.129): values below 4 are zero-based, +/// values above 4 are one-based; 4 never occurs. +const StyleRegistry::Font & +font_at(const std::span fonts, + const std::uint16_t ifnt) { + const std::size_t index = ifnt < 4 ? ifnt : ifnt - 1; + if (ifnt == 4 || index >= fonts.size()) { + throw std::runtime_error("xls: font index out of range"); + } + return fonts[index]; +} + +} // namespace + +StyleRegistry::StyleRegistry(std::vector fonts, + const std::span xfs, + const std::span palette) + : m_fonts{std::move(fonts)} { + if (!palette.empty() && palette.size() != palette_color_count) { + throw std::invalid_argument("StyleRegistry: unexpected palette size"); + } + + m_cell_styles.reserve(xfs.size()); + + for (const XfBody &xf : xfs) { + ResolvedStyle &style = m_cell_styles.emplace_back(); + + const Font &font = font_at(m_fonts, xf.ifnt); + TextStyle &text = style.text_style; + text.font_name = font.name.c_str(); + if (font.fixed.dyHeight != 0) { + text.font_size = Measure(font.fixed.dyHeight / 20.0, DynamicUnit("pt")); + } + text.font_weight = + font.fixed.bls >= 600 ? FontWeight::bold : FontWeight::normal; + text.font_style = + font.fixed.fItalic != 0 ? FontStyle::italic : FontStyle::normal; + text.font_underline = font.fixed.uls != 0; + text.font_line_through = font.fixed.fStrikeOut != 0; + text.font_color = icv_color(font.fixed.icv, palette); + + // For the solid pattern only icvFore is rendered; the other patterns are + // approximated by their foreground color as well. + if (xf.fls != 0) { + style.table_cell_style.background_color = icv_color(xf.icvFore, palette); + } + } +} + +const ResolvedStyle &StyleRegistry::cell_style(const std::uint16_t ixfe) const { + if (ixfe >= m_cell_styles.size()) { + throw std::out_of_range("StyleRegistry::cell_style: XF index out of range"); + } + return m_cell_styles[ixfe]; +} + +} // namespace odr::internal::oldms::spreadsheet diff --git a/src/odr/internal/oldms/spreadsheet/xls_style.hpp b/src/odr/internal/oldms/spreadsheet/xls_style.hpp new file mode 100644 index 00000000..c8ab6fe8 --- /dev/null +++ b/src/odr/internal/oldms/spreadsheet/xls_style.hpp @@ -0,0 +1,42 @@ +#pragma once + +#include +#include + +#include +#include +#include +#include + +namespace odr::internal::oldms::spreadsheet { + +/// Resolves the workbook-global style records (Font, XF, Palette) into one +/// display style per XF record, indexed by a cell's ixfe. +class StyleRegistry final { +public: + /// A Font record ([MS-XLS] 2.4.122), in file order. + struct Font final { + FontFixed fixed; + std::string name; + }; + + StyleRegistry() = default; + /// `palette` is the Palette record's `palette_color_count` colors, or empty + /// when the record is absent (the spec's default palette applies). + StyleRegistry(std::vector fonts, std::span xfs, + std::span palette); + + /// The resolved style of an XF record, by XF index (a cell's ixfe): the + /// text (font) side and the cell (fill) side. Throws if the index has no + /// XF record. + [[nodiscard]] const ResolvedStyle &cell_style(std::uint16_t ixfe) const; + +private: + /// Owns the font names: `TextStyle::font_name` (`const char *`) points into + /// them. Never modified after construction (moving the registry is fine — + /// the strings themselves do not move). + std::vector m_fonts; + std::vector m_cell_styles; +}; + +} // namespace odr::internal::oldms::spreadsheet