diff --git a/CHANGELOG.md b/CHANGELOG.md
index 136698d..8b2c72e 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,13 @@ the full generated notes for each tag.
## [Unreleased]
+### Fixed
+
+- `readXlsx` and `readXlsxRows` now honor ``: dates in workbooks
+ saved with the legacy Mac 1904 date system no longer read back ~4 years off. Writes still
+ emit the standard 1900 system — since the model stores real `Date` objects, round-tripping
+ a 1904 workbook preserves the instants while normalizing the file to 1900.
+
## [1.0.0] — 2026-07-02
First stable release: the public API is now covered by semver.
diff --git a/README.md b/README.md
index 06ad37f..2fdfe20 100644
--- a/README.md
+++ b/README.md
@@ -382,7 +382,6 @@ Roadmap features (conditional formatting, write-side read-back) arrive as minors
- Conditional formatting (write + read)
- Write-path performance (currently slower than ExcelJS — see [Performance](#performance))
-- Honor the legacy `date1904` workbook flag on read
- Parse the write-side features (images, frozen panes, autoFilter, indent) back out on read
## Contributing
diff --git a/docs/MIGRATING-FROM-EXCELJS.md b/docs/MIGRATING-FROM-EXCELJS.md
index e64f322..14d9acf 100644
--- a/docs/MIGRATING-FROM-EXCELJS.md
+++ b/docs/MIGRATING-FROM-EXCELJS.md
@@ -244,8 +244,9 @@ Excel-friendly UTF-8 BOM.
- **Formula cells:** neither library evaluates formulas. excelents writes the cached `result` if
you provide one; omit it and Excel shows the value after recalculation.
- **Error cells** (`#N/A`, `#DIV/0!`) come back as empty cells, not error values.
-- **Dates** use the standard 1900 date system on both read and write. The legacy Mac `date1904`
- workbook flag is not honored yet — dates in such files read back offset by ~4 years.
+- **Dates** are honored under both date systems on read (`date1904` workbooks included); writes
+ always emit the standard 1900 system. Round-tripping a 1904 workbook keeps every date the same
+ instant while normalizing the file to 1900.
- **Number inference in CSV:** `readCsv` only converts strings that round-trip losslessly
(`parseNumbers: false` to disable), so ID-like strings such as `007` stay strings.
- **Streaming rows are arrays indexed from column A** (`cells[0]` = A), while the model API is
diff --git a/src/xlsx/read-conformance.test.ts b/src/xlsx/read-conformance.test.ts
index 0adcc85..75d3fcc 100644
--- a/src/xlsx/read-conformance.test.ts
+++ b/src/xlsx/read-conformance.test.ts
@@ -82,6 +82,14 @@ test('the SP-5 readers fire on real fixtures (not just synthetic round-trips)',
expect(tables.every((t) => t.name !== '' && t.ref !== '' && t.columns.length > 0)).toBe(true)
})
+test('the 1904 date-system fixture reads dates on the 1904 epoch', async () => {
+ // 1904.xlsx sets ; B4 is serial 0 = 1904-01-01
+ // (confirmed against the exceljs oracle). On the 1900 epoch it would read
+ // as 1900-01-01 — four years off.
+ const wb = await byName('1904.xlsx')
+ expect(wb.sheets[0]!.cell('B4').value).toEqual(new Date(Date.UTC(1904, 0, 1)))
+})
+
test('sheet-scoped defined names in a real fixture keep their localSheetId', async () => {
// test-issue-877.xlsx has 4 names with a localSheetId (3 sharing a label with a global
// twin). They must stay scoped, not collapse to colliding global names.
diff --git a/src/xlsx/read.ts b/src/xlsx/read.ts
index 9827de5..c23281f 100644
--- a/src/xlsx/read.ts
+++ b/src/xlsx/read.ts
@@ -39,6 +39,7 @@ export async function readXlsx(bytes: Uint8Array): Promise {
sharedStrings,
cellStyles,
hyperlinkTargets,
+ date1904: parts.date1904,
})
// Resolve rIds to their table parts and reconstruct each table.
const relById = new Map(sheetRels.map((rel) => [rel.id, rel]))
diff --git a/src/xlsx/stream-reader.test.ts b/src/xlsx/stream-reader.test.ts
index ff239ac..e2f7dc9 100644
--- a/src/xlsx/stream-reader.test.ts
+++ b/src/xlsx/stream-reader.test.ts
@@ -122,3 +122,11 @@ test('readXlsxRows on an empty sheet yields no rows', async () => {
const bytes = await readableToBytes(writeXlsxStream([], { sheet: 'S' }))
expect(await collect(readXlsxRows(bytes))).toEqual([])
})
+
+test('readXlsxRows honors the 1904 date system', async () => {
+ // 1904.xlsx sets ; B4 is serial 0 = 1904-01-01.
+ const path = listFixtures().find((p) => p.endsWith('1904.xlsx'))!
+ const rows = await collect(readXlsxRows(await parseFixture(path)))
+ const row4 = rows.find((r) => r.rowNumber === 4)!
+ expect(row4.cells[1]).toEqual(new Date(Date.UTC(1904, 0, 1)))
+})
diff --git a/src/xlsx/stream-reader.ts b/src/xlsx/stream-reader.ts
index 31ff845..c06105a 100644
--- a/src/xlsx/stream-reader.ts
+++ b/src/xlsx/stream-reader.ts
@@ -136,6 +136,7 @@ function parseRow(
xml: string,
sharedStrings: readonly SharedStringValue[],
cellStyles: readonly CellStyle[],
+ date1904: boolean,
): { rowNumber: number; cells: CellValue[] } {
let rowNumber = 0
const cellByCol = new Map()
@@ -178,7 +179,7 @@ function parseRow(
} else if (v !== undefined) {
value =
style?.numberFormat !== undefined && isDateFormat(style.numberFormat)
- ? serialToDate(Number(v))
+ ? serialToDate(Number(v), { date1904 })
: Number(v)
}
if (value !== undefined) {
@@ -281,7 +282,7 @@ export async function* readXlsxRows(source: XlsxRowSource): AsyncGenerator
+ /** True when `` — serial dates count from the 1904 epoch. */
+ readonly date1904: boolean
}
const OFFICE_DOC = '/officeDocument'
@@ -35,9 +37,14 @@ export function readWorkbookParts(pkg: OpcPackage): WorkbookParts {
let dnText = ''
let inDefinedName = false
+ let date1904 = false
+
const xml = new TextDecoder().decode(pkg.getPart(workbookPath) ?? new Uint8Array())
for (const tok of tokenize(xml)) {
- if (tok.type === 'open' && tok.name === 'sheet') {
+ if (tok.type === 'open' && tok.name === 'workbookPr') {
+ const flag = tok.attributes['date1904']
+ date1904 = flag === '1' || flag === 'true'
+ } else if (tok.type === 'open' && tok.name === 'sheet') {
const name = tok.attributes['name']
const rid = tok.attributes['r:id']
const target = rid !== undefined ? byId.get(rid)?.target : undefined
@@ -63,5 +70,5 @@ export function readWorkbookParts(pkg: OpcPackage): WorkbookParts {
}
const sharedStringsPath = rels.find((r) => r.type.endsWith(SHARED_STRINGS))?.target
const stylesPath = rels.find((r) => r.type.endsWith(STYLES))?.target
- return { sheets, sharedStringsPath, stylesPath, definedNames }
+ return { sheets, sharedStringsPath, stylesPath, definedNames, date1904 }
}
diff --git a/src/xlsx/worksheet-reader.ts b/src/xlsx/worksheet-reader.ts
index 3a4129c..c37096a 100644
--- a/src/xlsx/worksheet-reader.ts
+++ b/src/xlsx/worksheet-reader.ts
@@ -12,6 +12,8 @@ export interface ReadContext {
readonly cellStyles: CellStyle[]
/** rId -> external hyperlink URL, from the worksheet's own relationships part. */
readonly hyperlinkTargets: Map
+ /** Serial dates count from the 1904 epoch (``). */
+ readonly date1904?: boolean
}
/** Narrow a raw dataValidation type attribute to the model union (no cast). */
@@ -126,7 +128,7 @@ export function readWorksheetInto(ws: Worksheet, xml: string, ctx: ReadContext):
// A numeric cell whose format is a date renders back as a Date.
cell.value =
style?.numberFormat !== undefined && isDateFormat(style.numberFormat)
- ? serialToDate(Number(v))
+ ? serialToDate(Number(v), { date1904: ctx.date1904 === true })
: Number(v)
}
}