Skip to content

fix(sheet): render xlsx number formats (percent, currency, thousands) - #72

Open
SomSamantray wants to merge 6 commits into
firecrawl:mainfrom
SomSamantray:fix/xlsx-number-formats
Open

fix(sheet): render xlsx number formats (percent, currency, thousands)#72
SomSamantray wants to merge 6 commits into
firecrawl:mainfrom
SomSamantray:fix/xlsx-number-formats

Conversation

@SomSamantray

@SomSamantray SomSamantray commented Aug 8, 2026

Copy link
Copy Markdown

Summary

Spreadsheet numbers now render the way Excel displays them: a cell storing 0.155 with a 0.0% format converts to 15.5% instead of the raw float, and currency and thousands-grouped cells carry their symbols and separators. Before, every numeric cell was formatted identically (15 significant digits), so formatted values like percentages were silently wrong.

Cell Before After
Percent (0.155, 0.0%) 0.155 15.5%
Currency (1234.5, [$$-409]#,##0.00) 1234.5 $1,234.50
Thousands (9876543, #,##0) 9876543 9,876,543

Dates, durations, booleans, and strings are untouched: date/time cells already convert through calamine's dates feature.

Design

  • ssfmt-based rendering with raw fallback. ssfmt (MIT/Apache-2.0, zero dependencies with default-features = false, pinned 0.1.2) is an Excel-compatible ECMA-376 format renderer (99.9999% parity with SheetJS SSF over 19.5M cases). The adapter (src/formats/sheet/numfmt.rs) decides which codes are eligible at all: General, empty, date/time-like, and text codes keep today's raw rendering, and any renderer rejection falls back to raw. Nothing ever fails the conversion.
  • OOXML part reading for per-cell codes. calamine drops both the per-cell style index and the format codes, so xl/styles.xml (numFmts + cellXfs, honoring applyNumberFormat) and per-sheet sheetN.xml (cell r/s attributes, positional fallback) are parsed with the existing package machinery and aligned to calamine's used range. Malformed parts degrade to raw with a log; resource-limit errors propagate per the crate's policy. xls/xlsb and ods keep their current output (documented limitation).
  • Excel display semantics. Percent scaling, half-away-from-zero rounding, trailing-comma scaling, currency-locale brackets ([$$-409]), negative sections, fractions, and scientific notation render per Excel; a present-but-empty selected section displays blank. Known renderer quirks are pinned in unit tests (integer fast-path truncation on exact scaling halves; empty sections render as absent when not selected).
  • Hardening from review. The Data::Int format path is gated on i64-to-f64 round-trip exactness (integers beyond 2^53 keep their exact digits); cell-reference parsing uses checked arithmetic bounded to Excel's XFD column cap.

Testing

Full suite green (cargo test --locked: 245 lib tests + integration + snapshot corpus), clippy -D warnings and fmt --check clean. The new corpus fixture handmade-numberformats.xlsx pins percent, currency, thousands, decimals, scientific, negative-section, currency-locale, fraction, General, string, date, and pipe-literal cells end-to-end; the LibreOffice fixture snapshot changed exactly where its formatted cells live; an openpyxl-generated workbook cross-checks a second real producer.

Fixes #27

Related: #64

Add an ssfmt-based number-format pipeline: the renderer adapter
(src/formats/sheet/numfmt.rs) resolves builtin and custom format codes
with date/text/General guards and fallback, and the OOXML styles reader
(src/formats/sheet/styles.rs) maps cells to their format codes from
styles.xml and the worksheet parts. Nothing is wired into the parse loop
yet.
Wire the format lookup into parse(): OOXML workbooks (xlsx/xlsm/xlam)
map each cell to its format code from styles.xml and the worksheet parts,
and Data::Float/Data::Int cells render through the ssfmt adapter with raw
fallback. Date/time cells keep calamine's DateTime path, string cells
ignore codes, and the LibreOffice corpus fixture now renders percent,
currency, and thousands cells as Excel displays them.
handmade-numberformats.xlsx pins percent, currency, thousands, decimals,
scientific, negative-section, currency-locale, fraction, General, string,
date, and pipe-literal format cells end-to-end through the markdown
pipeline, with the escaped pipe pinned in the snapshot.
- from_ooxml degrades through Package::optional_part / is_fatal so a
  corrupt styles or worksheet part falls back to raw rendering instead of
  failing the conversion, matching the crate's recovery policy
- code_for_style routes builtin ids through the shared renderability
  guard instead of a hand-maintained id list (locale-currency 41-44 stay
  excluded)
- tests share one xlsx zip builder (test_util), rebasing xlsx_with_merge
  onto it
- gate the Data::Int format path on i64->f64 round-trip exactness so
  integers beyond 2^53 keep their exact digits (raw fallback)
- bound parse_cell_ref with checked arithmetic and Excel's XFD column
  cap so adversarial references degrade instead of overflowing
- propagate fatal ResourceLimit errors from the styles/worksheet XML
  parse instead of swallowing them (crate limits policy), with a warn
  log when a sheet part is unparseable
- scan only the first format section for renderability, so fourth-section
  text placeholders (0.00%;...;@) stay renderable like Excel/calamine
- render present-but-empty selected sections as blank, matching Excel
  display for 0.00; and 0.00;;
- drop the no-op 41..=44 builtin guard (ssfmt already yields None)
- tests: multi-sheet mapping, ResourceLimit propagation, absurd cell
  refs, non-A1 range alignment, large-integer precision, 4-section codes,
  empty sections
@SomSamantray
SomSamantray force-pushed the fix/xlsx-number-formats branch from eabd483 to 242337f Compare August 8, 2026 19:14
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Spreadsheet number formats are dropped, rendering 7.5% as 0.075

1 participant