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) } }