Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,13 @@ the full generated notes for each tag.

## [Unreleased]

### Fixed

- `readXlsx` and `readXlsxRows` now honor `<workbookPr date1904="1"/>`: 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.
Expand Down
1 change: 0 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 3 additions & 2 deletions docs/MIGRATING-FROM-EXCELJS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 8 additions & 0 deletions src/xlsx/read-conformance.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <workbookPr date1904="1"/>; 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.
Expand Down
1 change: 1 addition & 0 deletions src/xlsx/read.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ export async function readXlsx(bytes: Uint8Array): Promise<Workbook> {
sharedStrings,
cellStyles,
hyperlinkTargets,
date1904: parts.date1904,
})
// Resolve <tablePart> rIds to their table parts and reconstruct each table.
const relById = new Map(sheetRels.map((rel) => [rel.id, rel]))
Expand Down
8 changes: 8 additions & 0 deletions src/xlsx/stream-reader.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <workbookPr date1904="1"/>; 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)))
})
5 changes: 3 additions & 2 deletions src/xlsx/stream-reader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<number, CellValue>()
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -281,7 +282,7 @@ export async function* readXlsxRows(source: XlsxRowSource): AsyncGenerator<XlsxR
const entry = byName.get(sheet.path)
if (entry === undefined) continue
for await (const rowXml of extractRowXml(inflateText(entry))) {
const { rowNumber, cells } = parseRow(rowXml, sharedStrings, cellStyles)
const { rowNumber, cells } = parseRow(rowXml, sharedStrings, cellStyles, parts.date1904)
yield { sheet: sheet.name, rowNumber, cells }
}
}
Expand Down
11 changes: 9 additions & 2 deletions src/xlsx/workbook-reader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ export interface WorkbookParts {
readonly stylesPath: string | undefined
/** Defined names in document order; `localSheetId` is set for sheet-scoped names. */
readonly definedNames: ReadonlyArray<{ name: string; formula: string; localSheetId?: number }>
/** True when `<workbookPr date1904="1"/>` — serial dates count from the 1904 epoch. */
readonly date1904: boolean
}

const OFFICE_DOC = '/officeDocument'
Expand All @@ -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
Expand All @@ -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 }
}
4 changes: 3 additions & 1 deletion src/xlsx/worksheet-reader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ export interface ReadContext {
readonly cellStyles: CellStyle[]
/** rId -> external hyperlink URL, from the worksheet's own relationships part. */
readonly hyperlinkTargets: Map<string, string>
/** Serial dates count from the 1904 epoch (`<workbookPr date1904="1"/>`). */
readonly date1904?: boolean
}

/** Narrow a raw dataValidation type attribute to the model union (no cast). */
Expand Down Expand Up @@ -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)
}
}
Expand Down
Loading