xlsxparser 0.12.0

A lightweight, high-performance .xlsx (OOXML) parser library
Documentation
# `parse/styles.rs` Design Doc

*[日本語](styles.md)*

Design doc for `src/parse/styles.rs`. Per [architecture.md](../architecture.en.md), this implements the `parse/` responsibility "parsing `styles.xml` (fonts/fills/borders/numFmts/cellXfs)." It parses `xl/styles.xml` and builds the `StyleSheet` defined by [`model/style.rs`](../model/style.en.md) (the table from `cellXfs` index to `ResolvedStyle`). This file also settles the open question left dangling by both [model/style.md Open Question 2](../model/style.en.md) and [resolve/style.md Open Question 2](../resolve/style.en.md): "where the date/time format-classification logic lives."

## Responsibility / Scope

- Parses `<numFmts>` (custom numeric-format definitions) and `<cellXfs>` (the array of format definitions actually applied to cells; its index matches [`model::style::StyleId`](../model/style.en.md))
- Resolves each `<xf>` in `<cellXfs>` by looking up the `numFmtId` it references — either a built-in format ID (fixed semantics in the 0–163 range) or a custom format defined in `<numFmts>` — and classifies whether that format represents a date/time (`ResolvedStyle::is_date_time`)
- Assigns `StyleId` in `<cellXfs>`'s index order and builds [`model::style::StyleSheet`](../model/style.en.md) (`HashMap<StyleId, Arc<ResolvedStyle>>`)
- Parses `<fonts><font><sz val=".."/><b/>...</font>...</fonts>` (which the schema places before `<cellXfs>`, same as `<numFmts>`) into a `Vec<Font>` indexed by position, and resolves each `<xf>`'s `fontId` attribute directly against that `Vec` — O(1) per `<xf>`, no per-`<xf>` scan of `<fonts>` (Issue #38's stated performance requirement)
- Parses an `<xf>`'s child `<alignment wrapText="1"/>` (Issue #37) into `ResolvedStyle::wrap_text` — the first `<xf>` child this file reads, which required restructuring `<xf>` handling from "resolve everything on the start tag" to a `cur_xf` accumulator finalized at the end tag (or immediately, for the still-common self-closing `<xf/>` form with no children at all)
- Resolves each `<xf>`'s `numFmtId` to its format-code string, `ResolvedStyle::number_format` (Issue #41) — built-in (a compile-time table per ECMA-376 Part 1 §18.8.30) or custom (the same `<numFmts>` lookup `is_date_time_format` already performs), cached per unique `numFmtId` so the string is allocated at most once per distinct ID in the file, never once per `StyleId`/cell
- Parses an `<xf>`'s child `<alignment horizontal="..">` (Issue #42) into `ResolvedStyle::horizontal_alignment` — read off the same `<alignment>` element `wrapText` already reads, so no additional `<xf>` scan is introduced
- Parses `<fills><fill><patternFill><fgColor .../><bgColor .../></patternFill></fill>...</fills>` (Issue #75, schema-placed before `<cellXfs>` like `<fonts>`) into a `Vec<Fill>` indexed by position, and resolves each `<xf>`'s `fillId` attribute directly against that `Vec` — the same O(1)-per-`<xf>` shape `fontId` resolution already established, kept raw as `ColorRef` (no `theme{N}.xml`/`tint` resolution — that's Issue #76's separate concern)
- **Not responsible for**: applying a `ResolvedStyle` to a cell ([`resolve/style.rs`](../resolve/style.en.md)), defining the `ResolvedStyle` / `StyleSheet` / `StyleId` / `Font` / `Alignment` / `ColorRef` types themselves ([`model/style.rs`](../model/style.en.md)), extracting visual style elements beyond `is_date_time`/`font`/`wrap_text`/`number_format`/`horizontal_alignment`/`fill_fg_color`/`fill_bg_color` (border, vertical alignment, other `alignment` attributes — see [model/style.md Open Question 1](../model/style.en.md), still open for those), resolving a `ColorRef::Theme`/`ColorRef::Indexed` to an actual displayed RGB value (Issue #76, a display-oriented concern this diff-oriented parser doesn't need), resolving `applyFont`/`applyAlignment`/`applyNumberFormat`/`cellStyleXfs`-based named-style inheritance (see Open Question 6 below — `fontId`/`wrapText`/`numFmtId`/`horizontal`/`fillId` are used directly and unconditionally)

## Key Types / Functions (draft)

```rust
use crate::error::Error;
use crate::model::style::{ResolvedStyle, StyleId, StyleSheet};
use crate::parse::{convert_xml_error, create_secure_reader, required_attr};
use std::collections::HashMap;
use std::io::BufRead;
use std::sync::Arc;

/// The built-in numFmtIds (ECMA-376 Part 1 §18.8.30) that represent a
/// date/time. 14–22: built-in date/time formats (e.g. 14 = "mm-dd-yy").
/// 45–47: elapsed time (e.g. 46 = "[h]:mm:ss"). Locale-dependent date
/// formats in the 27–36 range, including Japanese era (wareki) dates, are
/// not handled — see Open Question 1.
const BUILTIN_DATE_TIME_NUMFMT_IDS: &[u32] = &[14, 15, 16, 17, 18, 19, 20, 21, 22, 45, 46, 47];

/// Parses `xl/styles.xml` and builds a `StyleSheet`.
pub(crate) fn parse_styles(reader: impl BufRead, path: &str) -> Result<StyleSheet, Error> {
    let mut xml_reader = create_secure_reader(reader);
    // Implementation plan:
    // 1. Read <numFmts> first, building a numFmtId -> formatCode map. ECMA-376
    //    Part 1 (SpreadsheetML) §18.8.39 CT_Stylesheet's xsd:sequence
    //    mandates the order numFmts, fonts, fills, borders, cellStyleXfs,
    //    cellXfs, ... as a schema requirement, so a simple single-pass
    //    streaming parse suffices (resolves Open Question 4).
    // 2. For each <xf> in <cellXfs>, read the numFmtId attribute (defaults
    //    to 0 = General when absent), classify it via is_date_time_format,
    //    and build a ResolvedStyle.
    // 3. Store each into StyleSheet keyed by its 0-based index within
    //    <cellXfs>, used directly as StyleId.
    let mut num_fmts: HashMap<u32, String> = HashMap::new();
    let mut stylesheet: StyleSheet = HashMap::new();
    let _ = (&mut xml_reader, path, &mut num_fmts, &mut stylesheet);
    unimplemented!()
}

/// Classifies whether the format identified by `numfmt_id` — and, for a
/// custom format, `format_code` (the lookup result from `num_fmts`, `None`
/// if not found) — represents a date/time.
///
/// - `numfmt_id < 164` (built-in): checked against `BUILTIN_DATE_TIME_NUMFMT_IDS`.
/// - `numfmt_id >= 164` (custom): scans `format_code` heuristically for
///   date/time tokens (`y`, `m`, `d`, `h`, `s`, etc.), excluding `\`-escaped
///   or quoted literal characters and bracketed conditional-format segments
///   like `[Red]`. This classification is not exhaustive — see Open Question 2.
/// - If `numfmt_id` is found neither among the built-ins nor in the custom
///   definitions, falls back to `is_date_time: false` rather than erroring
///   — see Error Handling Policy.
fn is_date_time_format(numfmt_id: u32, format_code: Option<&str>) -> bool {
    let _ = (numfmt_id, format_code);
    unimplemented!()
}
```

**Font resolution (Issue #38, added post-implementation)**: `<fonts>` is streamed the same way `<numFmts>` is — into a `Vec<Font>` in document order (index = `fontId`) — then each `<cellXfs><xf>`'s `fontId` attribute (absent → `0`, unparseable or out of range against the parsed `Vec` → `Font::default()`, the same graceful-degradation policy `numFmtId` already uses) looks it up directly with `Vec::get`. A `<font>`'s own `<sz val="..">` sets `size_pt`; `<b/>` (no `val`) or `<b val="1"/>`/`<b val="true"/>` sets `bold: true`, while the rarer explicit `<b val="0"/>`/`<b val="false"/>` sets `bold: false`. `applyFont` is never consulted and `<cellStyleXfs>`/`xfId`-based named-style inheritance is not resolved — see Open Question 6.

**Wrap-text resolution (Issue #37, added post-implementation)**: unlike `numFmtId`/`fontId` (both plain attributes on `<xf>`'s own start tag), `wrapText` lives on a *child* element, `<xf><alignment wrapText="1"/></xf>`. Since an `<xf>` with an `<alignment>` child can no longer be a single self-closing tag, `<xf>` handling was restructured around a `CurXf { numfmt_id, font_id, wrap_text }` accumulator (the same `Option<T>`-between-start-and-end-tag pattern `cur_font` already established for `<font>`): a `Start` tag reads `numFmtId`/`fontId` immediately and opens `cur_xf`; a nested `<alignment>` (if any) updates `cur_xf.wrap_text`; the `ResolvedStyle` itself is only built and inserted at the matching `End` tag. The still-common case of an `<xf/>` with no children at all is handled as a separate `Empty` branch that resolves and inserts immediately, without ever touching `cur_xf` — both branches funnel through the same `push_resolved_style` helper so there is exactly one place that builds a `ResolvedStyle`. `wrapText` itself defaults to `false` when the attribute (or the whole `<alignment>` element) is absent, and only `"1"`/`"true"` count as `true` — matching `xsd:boolean`'s true forms; `applyAlignment` is never consulted, the same policy already applied to `applyFont`.

**Number-format resolution (Issue #41, added post-implementation)**: `numFmtId` values partition cleanly by range — per ECMA-376, IDs 0-163 are built-in with a fixed, spec-defined meaning, and a custom `<numFmts>` entry is never assigned an ID below 164 — so resolution is a straight `if numfmt_id < 164` branch with no precedence question between the two sources (the same partition `is_date_time_format` already relies on). The built-in table (`BUILTIN_NUMFMT_CODES`, a compile-time `&[(u32, &str)]` covering the format codes ECMA-376 §18.8.30 actually defines — 1-22, 37-49; IDs 23-36 and 50-163 are reserved for application/international use with no code the spec itself specifies) is a new addition alongside the existing `BUILTIN_DATE_TIME_NUMFMT_IDS` membership list — the two serve different purposes (membership-only for date classification vs. the actual string for display) and are kept as separate tables rather than merged. `numFmtId=0` ("General"), an absent `numFmtId`, and any ID found in neither table all resolve to `None` — treated as "nothing to report," the same graceful-degradation policy `is_date_time_format` already applies to an unresolvable ID (see `model/style.en.md`'s `ResolvedStyle::number_format` doc comment for why `None` rather than `Some("General")`). A `resolved_formats: HashMap<u32, Arc<str>>` cache (parallel to `num_fmts`, populated lazily) memoizes each `numFmtId`'s resolution, so the format-code string is allocated at most once per unique `numFmtId` encountered in the file — regardless of how many `<xf>` entries (and, downstream, how many cells) reference it — which is the performance requirement Issue #41 states explicitly. Because resolution happens once per `<xf>` (i.e. once per `StyleId`) rather than once per cell, and `resolve/style.rs` only ever clones the single outer `Arc<ResolvedStyle>` per cell (never re-touching the inner `Option<Arc<str>>` separately), the per-cell cost of this field is effectively zero regardless of sheet size — confirmed by re-running README.md's `extreme_sparse.xlsx` benchmark (3.2 ms, unchanged from the documented 3.0 ms baseline within noise) and a 300,000-cell densely-styled sheet (~299 ms, consistent with the pre-#41 baseline established during Issue #40's own benchmarking).

**Horizontal-alignment resolution (Issue #42, added post-implementation)**: `<xf><alignment horizontal="..">` shares the exact same `<alignment>` element `wrapText` (Issue #37) already reads inside the `cur_xf.is_some()` match arm, so `horizontal` is read off the same event with no separate branch and no additional `<xf>` scan — directly satisfying Issue #42's stated performance requirement. `horizontal`'s value space is ECMA-376 `ST_HorizontalAlignmentValues` (`general`/`left`/`center`/`right`/`fill`/`justify`/`centerContinuous`/`distributed`); `"general"`, an absent attribute (or a wholly absent `<alignment>` — the self-closing `<xf/>` case), and any unrecognized value all fall back to `Alignment::General`, the same graceful-degradation shape `numFmtId`/`fontId` already use. Because `Alignment` is a small `Copy` enum (see `model/style.en.md`), threading it through `CurXf` and `push_resolved_style` costs nothing beyond what `wrap_text: bool` already costs.

**Fill color resolution (Issue #75, added post-implementation)**: `<fills>` is streamed the same way `<fonts>` is — into a `Vec<Fill>` (a file-private struct, `{fg_color, bg_color}`, not exposed outside this module) in document order — then each `<cellXfs><xf>`'s `fillId` attribute looks it up directly with `Vec::get`, mirroring `fontId` resolution exactly (no separate memoization cache like `resolved_formats` is needed: `ColorRef::Rgb`'s `Arc<str>` already makes `.cloned()` on the looked-up `Fill` a refcount bump, not a fresh allocation — see `model/style.en.md`). A `<fill>`'s child `<patternFill>` is parsed via its own nested read loop (`parse_fill_body`, mirroring `parse/drawing.rs::parse_marker`'s "own local buffer, read to my own closing tag" shape) that reads `<fgColor>`/`<bgColor>` — each parsed by `parse_color`, which checks `rgb` (verbatim `Arc<str>`), then `theme`+optional `tint`, then `indexed`, in that order (ECMA-376 `CT_Color`'s three representations are mutually exclusive). An unparseable `theme`/`indexed` numeric value, or a color element carrying none of the three attributes (e.g. `auto="1"`), all degrade to `None` — the same graceful-degradation policy `numFmtId`/`fontId`/`fillId` already use, rather than erroring. `theme{N}.xml` is never read and `tint` is never applied to a color — Issue #76's separate, display-oriented concern.

## Dependencies

- Depends on: [`parse/mod.rs`](mod.en.md) (`create_secure_reader`, `convert_xml_error`, `required_attr`), [`model/style.rs`](../model/style.en.md) (`ResolvedStyle`, `StyleId`, `StyleSheet`), [`error.rs`](../error.en.md)
- Depended on by: [`resolve/style.rs`](../resolve/style.en.md) (looks up the built `StyleSheet` to apply to cells), `pipeline.rs` (built once between Phases 1–3 and passed to every `resolve_sheet` call; per architecture.md — "`StyleSheet` is discarded once Phase 4 completes" — dropped once every sheet has finished resolving)

This directly implements what [model/style.md Dependencies](../model/style.en.md) already committed to: "both `resolve/` and `parse/` depend only on `model/style.rs`, with no direct dependency on each other." This file (the builder) and `resolve/style.rs` (the applier) never know about each other — they connect only indirectly, through the shared vocabulary `StyleSheet` provides.

## Error Handling Policy

- Structurally broken `<numFmts>` / `<cellXfs>` XML (a syntax error) is converted into `Error::XmlParse` or `Error::ZipBombDetected` via [`convert_xml_error`](mod.en.md)
- An `<xf>` with no `numFmtId` attribute is treated as the default value `0` (`"General"`, not a date) — this is not `Error::MissingRequiredElement`, since `numFmtId` is an optional attribute per OOXML
- **When `numFmtId` is found neither among the built-in IDs nor in a custom `<numFmts>` definition, this falls back to `is_date_time: false` rather than erroring.** This extends the principle [resolve/style.md Error Handling Policy](../resolve/style.en.md) already adopted — "a loose failure interpreting an individual value is not an error unless it compromises the whole document's integrity" — prioritizing graceful degradation that reads as far into a broken or non-standard `styles.xml` as possible (an alternative is weighed in Open Question 3)
- The real-world impact of a wrong heuristic date/time classification (false positive or false negative) for custom formats is limited: a false positive (attempting to convert a non-date into DateTime) is further mitigated by [resolve/style.md](../resolve/style.en.md)'s `serial_to_date_time` fallback for unconvertible values (`CellValue::Number` is kept); a false negative (keeping a date as Number) never loses the cell's own value

## Testing Strategy

- Verify that an `<xf>` referencing a built-in `numFmtId` (e.g. `14` = `"mm-dd-yy"`) resolves to `is_date_time: true`
- Verify that a built-in `numFmtId` that is not date/time-related (e.g. `0` = `"General"`, `9` = `"0%"`) resolves to `is_date_time: false`
- Verify that a custom format (`numFmtId >= 164`) with a `<numFmts>` definition like `formatCode="yyyy/mm/dd"` resolves to `is_date_time: true`
- Verify that a custom format whose `formatCode` contains no date/time (e.g. `"#,##0.00"`, `"@"`) resolves to `is_date_time: false`
- Verify that a custom `formatCode` containing conditional-format sections or escaped characters (e.g. `"[Red]#,##0;[Blue]-#,##0"`) is not misclassified as `is_date_time: true` by false-positive detection of date-related tokens (a regression test for the heuristic's precision)
- Verify that a `numFmtId` found neither among built-ins nor custom definitions falls back to `is_date_time: false` without returning an error
- Verify that an `<xf>` with no `numFmtId` attribute is treated as the default `0` (`General`, not a date)
- Verify that the `StyleSheet` keys (`StyleId`) built from multiple `<xf>` entries in `<cellXfs>` match their 0-based index order within `<cellXfs>` (wiring with [resolve/style.md](../resolve/style.en.md))
- Verify that a `styles.xml` with the schema-valid order (`<numFmts>` before `<cellXfs>`) resolves correctly in a single pass. The reverse order (`<numFmts>` after `<cellXfs>`) is non-conformant per the ECMA-376 schema and out of this design's scope, but verify that such input still never panics — the affected `numFmtId` falls back to `is_date_time: false` as a "not found" case and processing continues (a regression test for Open Question 4's resolution)
- **Verify `<xf>` entries with distinct `fontId`s resolve to the correct `size_pt`/`bold` from the corresponding `<fonts>` entry** (Issue #38)
- **Verify `<b val="0"/>`/`<b val="false"/>` resolves to `bold: false`** (the explicit-not-bold form, distinct from the element simply being absent)
- **Verify an `<xf>` with no `fontId` attribute defaults to `<fonts>`'s first entry (index 0)**, an out-of-range `fontId` (referencing a `<font>` that was never defined) falls back to `Font::default()`, a `styles.xml` with no `<fonts>` element at all falls back to `Font::default()` for every style, and an empty `<font/>` (no child properties) registers `Font::default()` — all graceful-degradation cases, never an error
- **Verify an `<xf><alignment wrapText="1"/></xf>` resolves `wrap_text: true`**, an `<alignment>` present without a `wrapText` attribute (e.g. only `horizontal="center"`) or with `wrapText="0"` resolves `false`, and a self-closing `<xf/>` with no `<alignment>` child at all also resolves `false` (Issue #37)
- **Verify an `<xf>` with an `<alignment>` child still resolves `numFmtId`/`fontId` correctly** — a regression test for the `Start`/`End` restructuring `<xf>` handling needed to support a child element at all, proving it didn't disturb the attributes read directly off the `<xf>` start tag
- **Verify a `<cellXfs>` mixing self-closing `<xf/>` entries and `<xf>...</xf>` entries with an `<alignment>` child still assigns `StyleId`s in document order** — both branches must fall through to the same insertion point
- **Verify a built-in `numFmtId` (e.g. `9`) resolves `number_format` to its known code (`"0%"`)** (Issue #41)
- **Verify a custom `numFmtId` (`>=164`, defined in `<numFmts>`) resolves `number_format` to that `formatCode` string**
- **Verify `numFmtId=0`, an absent `numFmtId` attribute, and an ID found in neither the built-in table nor `<numFmts>` all resolve `number_format` to `None`** (boundary/regression cases for the "General has nothing to report" policy)
- **Verify a date/time-classified `<xf>` (`is_date_time: true`) still carries its own `number_format`** — the two fields are resolved independently, one is not implicitly cleared by the other
- **Verify two `<xf>` entries referencing the same `numFmtId` resolve `number_format` to `Arc`s that are identical under `Arc::ptr_eq`** — a regression test for the `resolved_formats` memoization cache
- **Verify each of `<alignment horizontal="left"/>`/`"center"`/`"right"`/`"fill"`/`"justify"`/`"centerContinuous"`/`"distributed"` resolves to the matching `Alignment` variant** (Issue #42)
- **Verify `horizontal="general"`, an absent `horizontal` attribute, and an unrecognized value all resolve to `Alignment::General`**, and that a self-closing `<xf/>` with no `<alignment>` child at all also resolves to `Alignment::General` (boundary/graceful-degradation cases)
- **Verify `<alignment wrapText="1" horizontal="center"/>` resolves both `wrap_text: true` and `horizontal_alignment: Alignment::Center` correctly from the same element** — a regression test proving reading both attributes off one event doesn't disturb either
- **Verify `<fgColor rgb="..">`/`<theme=".." tint="..">`/`<indexed="..">` each resolve to the matching `ColorRef` variant, including the theme-without-tint case (`tint: None`, distinct from `Some(0.0)`)** (Issue #75)
- **Verify a `<fill>` with no `<fgColor>`/`<bgColor>` at all (`patternType="none"`/`"gray125"`) resolves both to `None`**, and that `<fgColor auto="1"/>` (none of `rgb`/`theme`/`indexed` present) also resolves to `None`
- **Verify an unparseable `theme`/`indexed` numeric value degrades to `None` rather than erroring** (graceful degradation, matching `numFmtId`/`fontId`/`fillId`'s policy), and that EOF before `<fill>`'s closing tag is reported as `Error::MissingRequiredElement`
- **Verify an `<xf>` with no `fillId` attribute defaults to `<fills>`'s first entry, and that many `<xf>` entries sharing the same `fillId` all resolve `fill_fg_color` to `ColorRef::Rgb`s that are identical under `Arc::ptr_eq`** — a regression test proving the shared-`fillId` clone is a refcount bump, not a fresh allocation (see `model/style.en.md`)

## Open Questions

1. **Whether to support locale-dependent date formats including Japanese era (wareki) dates (`numFmtId` 27–36, etc.)**: since the requirements center on "Japanese business systems," whether to support custom date formats including the Japanese era (Reiwa, etc.) is to be settled together with a more detailed requirements pass.
2. **Precision of the custom `formatCode` date/time-classification heuristic**: whether bracketed conditional-format sections and quote/`\`-escaped literal characters can be reliably excluded is left to implementation-time detail design. As noted in Error Handling Policy, the real-world impact of a misclassification is limited, but there remains room to improve precision itself.
3. **Fallback vs. hard error for an undefined `numFmtId` reference**: currently assumes a graceful-degradation policy — reading as far as possible into an inaccurate or broken `styles.xml` — but there is a case for treating this internal reference inconsistency within `styles.xml` as a hard error too, for consistency with [resolve/style.md](../resolve/style.en.md)'s `Error::InvalidStyleId` (when a cell's own `cellXfs` index is itself invalid).
4. ~~Read order for `<numFmts>` and `<cellXfs>`~~ → **Resolved**: implemented as a simple single-pass streaming parse (reflects the [PR #9 review](https://github.com/MinamiyamaKotaro/xlsxparser/pull/9#pullrequestreview-4948641204)). ECMA-376 Part 1 (SpreadsheetML) §18.8.39 `CT_Stylesheet`'s `xsd:sequence` mandates the order `numFmts`, `fonts`, `fills`, `borders`, `cellStyleXfs`, `cellXfs`, `cellStyles`, `dxfs`, `tableStyles`, `colors`, `extLst` as a schema requirement, so a file with `numFmts` appearing after `cellXfs` is not valid OOXML in the first place — a two-pass read is unnecessary. Even if such a non-conformant file were actually encountered, the affected `numFmtId` simply falls into the "found neither among built-ins nor custom definitions" case, which the Error Handling Policy already has fall back to `is_date_time: false` — so it degrades gracefully rather than crashing.
5. **Concrete style elements such as font/fill/border**: further resolved — `font: Font { size_pt, bold }` (Issue #38), `wrap_text: bool` (Issue #37), `number_format: Option<Arc<str>>` (Issue #41), `horizontal_alignment: Alignment` (Issue #42), and `fill_fg_color`/`fill_bg_color: Option<ColorRef>` (Issue #75) are all implemented as described above. Every sub-issue under [Issue #36](https://github.com/MinamiyamaKotaro/xlsxparser/issues/36) is now resolved, plus the follow-on fill-color issue; border and vertical alignment remain out of scope until a concrete downstream use case names them. Resolving `ColorRef` to an actual displayed RGB value (`theme{N}.xml`/`tint`/the legacy indexed palette) is tracked separately as Issue #76, a display-oriented concern this diff-oriented parser doesn't need.
6. **Support for `applyNumberFormat`/`applyFont`/`applyAlignment` and `cellStyleXfs` (named cell-style inheritance)**: resolved as a deliberate simplification, extended from numFmt to font (Issue #38) to wrap-text (Issue #37) to number-format resolution itself (Issue #41 — the same `numFmtId` `is_date_time` already reads unconditionally) to horizontal alignment (Issue #42) — `numFmtId`, `fontId`, and `<alignment wrapText>`/`<alignment horizontal>` are all treated as authoritative regardless of the `<xf>`'s `applyNumberFormat`/`applyFont`/`applyAlignment` value, with no consideration of the inheritance chain `cellStyleXfs`/`xfId` would otherwise establish. In practice, a real `<xf>` in `<cellXfs>` virtually always carries the format it actually uses directly, regardless of what the `apply*` flags say (a UI hint about whether the *user* explicitly customized that property, not whether the property applies when reading the file back) — full `cellStyleXfs` inheritance is a materially larger feature (resolving named cell styles like "Normal"/"Heading 1") that no downstream use case has asked for yet. Revisit if a concrete case surfaces where this simplification produces a wrong `font`/`wrap_text`/`number_format`/`horizontal_alignment`/`is_date_time`.