Skip to content

Read and write dates of a date1904 workbook on the 1904 epoch - #12

Merged
almasumdev merged 3 commits into
almasumdev:mainfrom
cdeil:date1904
Oct 16, 2026
Merged

almasumdev merged 3 commits into
almasumdev:mainfrom
cdeil:date1904

Conversation

@cdeil

@cdeil cdeil commented Oct 16, 2026

Copy link
Copy Markdown
Contributor

Problem. A workbook may count date serials from 1 January 1904
(<workbookPr date1904="1"/>); its serials differ by 1,462 days from the
default date system. The
.xls reader already honours the equivalent DATEMODE record, but the
.xlsx path converted every serial from the 1900 epoch, so serial 1 read as
1899-12-31 instead of 1904-01-02 in both the eager and the streaming reader,
and a date written through the API into such a workbook landed 1,462 days late.
Inherited from the original package. An unchanged save already kept the stored
serial and the flag, so existing dates were not corrupted on save; the parsed
values were wrong.

Fix. The flag is read off the workbook part into the model; serials of
date-formatted cells are moved onto the 1900 epoch before the shared
conversion on read, and a date is moved back onto the file's epoch on write.
Public NumFormat.read/writeDate/writeDateTime keep their 1900-epoch
meaning. Time-formatted serials ≥ 1 also become date values in the existing
model, so they receive the same read-side shift; otherwise saving 1.5
would subtract an unmatched offset and produce -1460.5. Time-only
fractions below one retain their existing behavior.

A second commit does the same for formula evaluation, which converted dates
with the 1900 epoch whatever the workbook declared: in a 1904 workbook
DATE(2004,1,1) returned 37987 where Excel shows 36525, a date cell entered a
formula 1,462 days late, and recalculate() cached those serials into the
file. The serial conversions moved from top-level functions onto the
evaluation context, which takes its epoch from the workbook, so cell dates,
DATE/TODAY/NOW, the component and workday functions and TEXT all count from
the file's epoch. The display path (NumFormat.format on a DateTime) is
epoch-neutral and unchanged.

Validation. test/num_format_test.dart, "Xlsx Date Epoch": serial 1
reads as 1904-01-02 (fails before, passes after), serials 0 / 1.5 / 36525 on
the 1904 epoch, the streamed value, an unchanged save keeping flag and serial,
a date written into a 1904 workbook saving as serial 2, a modern 1900 date unchanged, and built-in time/elapsed formats retaining
their serials, formula values and time-only fractions. Cross-checked with openpyxl on the saved files (wb.epoch 1904,
A1 1904-01-02, written A2 2004-01-01). test/formula_datetime_test.dart,
"Date Serials In A 1904 Workbook": DATE/YEAR, a date cell in arithmetic,
TEXT, and a recalculated cached serial; all four fail on the previous
library and pass with the change. A real Excel-authored 1904 reference
workbook from XlsxWriter also reads all six expected dates and preserves
its serials on save. LibreOffice opens and re-saves both prepared 1904
workbooks, with dates checked afterward using openpyxl. Suite and analyzer clean.

🤖 Generated with Claude Code

cdeil and others added 3 commits October 16, 2026 14:35
An .xlsx workbook may count its date serials from 1 January 1904
(<workbookPr date1904="1"/>), 1,462 days behind the default epoch; the
.xls reader already honoured the equivalent DATEMODE record, but the
.xlsx path converted every serial from the 1900 epoch, so serial 1 read
as 1899-12-31 instead of 1904-01-02, in both the eager and the streaming
reader. The flag is now read off the workbook part, serials of a
date-formatted cell are moved onto the 1900 epoch before the shared
conversion, and a date written into such a workbook is moved back, so
an unchanged save keeps the stored serial and a new date lands on the
epoch the file declares.

Formula evaluation keeps using the 1900 epoch for its serials, as
before; that is a separate change in the formula engine.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The formula engine converted dates with the 1900 epoch whatever the
workbook declared. In a date1904 workbook DATE(2004,1,1) returned 37987
where Excel shows 36525, YEAR(36525) read 2005, a date cell entered a
formula 1,462 days late, TEXT rendered the wrong day, and recalculate()
cached those results into the file. The epoch now lives on the
evaluation context: cell dates, DATE/TODAY/NOW, the component and
workday functions and TEXT all count from the workbook's epoch. The
display path (NumFormat.format on a DateTime) is epoch-neutral and
unchanged.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@almasumdev
almasumdev merged commit bbcb622 into almasumdev:main Oct 16, 2026
8 checks passed
@almasumdev

Copy link
Copy Markdown
Owner

All six merged and released in 2.29.0, about three hours after you opened them.

I confirmed each one on main before merging rather than taking the write-ups on trust, and every claim held:

before after
#8 cached type <c r="A1" s="0"> t="str" kept
#9 A1:ALL10000 109 ms 3 ms
#10 row with no r A1 null A1 = 42
#11 prefixed streamRows 0 rows 1 row
#12 serial in a 1904 book unshifted 2028-03-16, and a written date round-trips

Two things worth saying back.

#11 was my own regression. I taught the eager reader about prefixed parts in 2.28.0 this morning and left streamRows behind, so excel['Sheet1'] and streamRows disagreed on the same file. You found it the same day. Your event-walk approach is also better than what I would have written, since it rules out a comment holding a tag.

#7 is the one I am most glad to have. An allocation an attacker controls, reachable from decodeBytes on a file merely named .xlsx, is a different class of problem from the rest, and the bound-by-physical-sectors approach is the right shape for it.

The changelog credits you by name on all six. Thank you, genuinely. If you want anything else looked at, open it and I will get to it.

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.

2 participants