diff --git a/lib/stream/xlsx/worksheet-reader.js b/lib/stream/xlsx/worksheet-reader.js
index 8cecd5c17..040c7e11b 100644
--- a/lib/stream/xlsx/worksheet-reader.js
+++ b/lib/stream/xlsx/worksheet-reader.js
@@ -297,6 +297,20 @@ class WorksheetReader extends EventEmitter {
} else {
cellValue.result = parseFloat(c.v.text);
}
+ // Apply date-format conversion to formula results so
+ // cells whose numFmt is date-like return a JS Date for
+ // the result, mirroring the non-streaming reader.
+ if (cellValue.result !== undefined && utils.isDateFmt(cell.numFmt)) {
+ let numericResult = cellValue.result;
+ if (typeof numericResult === 'string') {
+ const parsed = parseFloat(numericResult);
+ if (Number.isFinite(parsed) && String(parsed) === numericResult.trim()) {
+ numericResult = parsed;
+ }
+ }
+ const date1904 = properties.model && properties.model.date1904;
+ cellValue.result = utils.excelToDate(numericResult, date1904);
+ }
}
cell.value = cellValue;
} else if (c.v) {
@@ -328,10 +342,8 @@ class WorksheetReader extends EventEmitter {
default:
if (utils.isDateFmt(cell.numFmt)) {
- cell.value = utils.excelToDate(
- parseFloat(c.v.text),
- properties.model && properties.model.date1904
- );
+ const d1904 = properties.model && properties.model.date1904;
+ cell.value = utils.excelToDate(parseFloat(c.v.text), d1904);
} else {
cell.value = parseFloat(c.v.text);
}
diff --git a/lib/xlsx/xform/sheet/cell-xform.js b/lib/xlsx/xform/sheet/cell-xform.js
index 41715695a..1a217de90 100644
--- a/lib/xlsx/xform/sheet/cell-xform.js
+++ b/lib/xlsx/xform/sheet/cell-xform.js
@@ -107,9 +107,8 @@ class CellXform extends BaseXform {
} else if (model.sharedFormula) {
const master = options.formulae[model.sharedFormula];
if (!master) {
- throw new Error(
- `Shared Formula master must exist above and or left of clone for cell ${model.address}`
- );
+ const msg = `Shared Formula master must exist above and or left of clone for cell ${model.address}`;
+ throw new Error(msg);
}
if (master.si === undefined) {
master.shareType = 'shared';
@@ -455,7 +454,18 @@ class CellXform extends BaseXform {
case Enums.ValueType.Formula:
if (model.result !== undefined && style && utils.isDateFmt(style.numFmt)) {
- model.result = utils.excelToDate(model.result, options.date1904);
+ // Some writers serialize formula results with t="str" even when the
+ // result is a numeric date serial. Coerce to number first so the
+ // date conversion still runs; if it isn't numeric, excelToDate
+ // returns the value unchanged.
+ let numericResult = model.result;
+ if (typeof numericResult === 'string') {
+ const parsed = parseFloat(numericResult);
+ if (Number.isFinite(parsed) && String(parsed) === numericResult.trim()) {
+ numericResult = parsed;
+ }
+ }
+ model.result = utils.excelToDate(numericResult, options.date1904);
}
if (model.shareType === 'shared') {
if (model.ref) {
diff --git a/spec/integration/issues/issue-2966-formula-result-date-numfmt.spec.js b/spec/integration/issues/issue-2966-formula-result-date-numfmt.spec.js
new file mode 100644
index 000000000..cd713903a
--- /dev/null
+++ b/spec/integration/issues/issue-2966-formula-result-date-numfmt.spec.js
@@ -0,0 +1,124 @@
+const fs = require('fs');
+const path = require('path');
+const JSZip = require('jszip');
+
+const ExcelJS = verquire('exceljs');
+
+// Programmatically build a workbook in memory whose sheet1.xml contains a
+// formula cell with the given inner XML. The cell uses numFmt "m/d/yyyy".
+async function buildWorkbookWithFormulaCell(cellInnerXml) {
+ const wb = new ExcelJS.Workbook();
+ const ws = wb.addWorksheet('Sheet1');
+ // Seed a placeholder formula+date cell so ExcelJS writes a valid styles.xml
+ // with the m/d/yyyy numFmt referenced as styleId 1.
+ const cell = ws.getCell('A1');
+ cell.value = {formula: 'TODAY()', result: new Date(2025, 0, 1)};
+ cell.numFmt = 'm/d/yyyy';
+
+ const buf = await wb.xlsx.writeBuffer();
+ const zip = await JSZip.loadAsync(buf);
+ const sheetPath = 'xl/worksheets/sheet1.xml';
+ let sheetXml = await zip.file(sheetPath).async('string');
+
+ // Replace the existing ... with a custom one that keeps
+ // the same styleId attribute (s="1") but uses the caller-supplied inner XML.
+ sheetXml = sheetXml.replace(/]*)>[\s\S]*?<\/c>/, (_match, attrs) => {
+ const styleAttr = (attrs.match(/\ss="\d+"/) || [' s="1"'])[0];
+ return `${cellInnerXml.body}`;
+ });
+
+ zip.file(sheetPath, sheetXml);
+ return zip.generateAsync({type: 'nodebuffer'});
+}
+
+describe('github issues', () => {
+ describe('issue 2966 - formula result with date numFmt', () => {
+ it('returns a JS Date when result is a numeric serial (numeric , no t)', async () => {
+ const buf = await buildWorkbookWithFormulaCell({
+ attrs: '',
+ body: 'TODAY()46143',
+ });
+
+ const wb = new ExcelJS.Workbook();
+ await wb.xlsx.load(buf);
+ const cell = wb.getWorksheet('Sheet1').getCell('A1');
+
+ expect(cell.value).to.have.property('formula', 'TODAY()');
+ expect(cell.value.result).to.be.an.instanceOf(Date);
+ expect(Number.isNaN(cell.value.result.getTime())).to.equal(false);
+ });
+
+ it('returns a JS Date when result is a numeric serial marked as t="str"', async () => {
+ // Some writers serialize formula results as t="str" even when the
+ // cached value is a numeric date serial. The reader must still convert.
+ const buf = await buildWorkbookWithFormulaCell({
+ attrs: ' t="str"',
+ body: 'TODAY()46143.20833333333',
+ });
+
+ const wb = new ExcelJS.Workbook();
+ await wb.xlsx.load(buf);
+ const cell = wb.getWorksheet('Sheet1').getCell('A1');
+
+ expect(cell.value.result).to.be.an.instanceOf(Date);
+ expect(Number.isNaN(cell.value.result.getTime())).to.equal(false);
+ });
+
+ it('does not produce an Invalid Date when result is a non-numeric display string', async () => {
+ // Display-formatted strings cannot be reliably parsed as a numeric
+ // Excel serial; the reader must not fabricate an Invalid Date.
+ const buf = await buildWorkbookWithFormulaCell({
+ attrs: ' t="str"',
+ body: 'TODAY()27/08/2025 19:33:34',
+ });
+
+ const wb = new ExcelJS.Workbook();
+ await wb.xlsx.load(buf);
+ const cell = wb.getWorksheet('Sheet1').getCell('A1');
+
+ const {result} = cell.value;
+ // Must not be an Invalid Date.
+ if (result instanceof Date) {
+ expect(Number.isNaN(result.getTime())).to.equal(false);
+ } else {
+ expect(typeof result).to.equal('string');
+ }
+ });
+
+ it('streaming reader also returns a JS Date for numeric formula results', async () => {
+ const buf = await buildWorkbookWithFormulaCell({
+ attrs: ' t="str"',
+ body: 'TODAY()46143.20833333333',
+ });
+
+ const tmpPath = path.resolve(__dirname, '../../out', 'issue-2966-stream.xlsx');
+ fs.mkdirSync(path.dirname(tmpPath), {recursive: true});
+ fs.writeFileSync(tmpPath, buf);
+
+ const collected = [];
+ await new Promise((resolve, reject) => {
+ const reader = new ExcelJS.stream.xlsx.WorkbookReader(tmpPath, {
+ worksheets: 'emit',
+ styles: 'cache',
+ sharedStrings: 'cache',
+ hyperlinks: 'ignore',
+ entries: 'ignore',
+ });
+ reader.read();
+ reader.on('worksheet', worksheet => {
+ worksheet.on('row', row => {
+ collected.push(row.getCell(1).value);
+ });
+ });
+ reader.on('end', resolve);
+ reader.on('error', reject);
+ });
+
+ expect(collected).to.have.length(1);
+ const value = collected[0];
+ expect(value).to.have.property('formula', 'TODAY()');
+ expect(value.result).to.be.an.instanceOf(Date);
+ expect(Number.isNaN(value.result.getTime())).to.equal(false);
+ });
+ });
+});