From 2130a3ac1366c250ecfa0b9fabe07ddf1c1378f6 Mon Sep 17 00:00:00 2001 From: Bob Senoff Date: Thu, 30 Apr 2026 20:27:58 -0500 Subject: [PATCH] Fix #2966: convert formula result to Date when numFmt is date-like When a cell's formula result is cached as t="str" with a numeric Excel date serial in (some non-Excel writers do this), the reader left the result as a string instead of converting it to a JS Date the way it does for plain numeric formula results. After PR #2956's guard, this no longer produced an Invalid Date, but the value was still wrong for downstream consumers that expect a Date. The standard (non-streaming) reconcile path and the streaming reader both now coerce string-typed formula results that look like a finite number before passing through excelToDate. Genuine non-numeric display strings (e.g., "27/08/2025 19:33:34") fall through unchanged so we never fabricate an Invalid Date. Adds spec/integration/issues/issue-2966-formula-result-date-numfmt covering: numeric serial with no t, numeric serial with t="str", a non-numeric display string, and the streaming reader. Two unrelated multi-line function calls in the touched files were collapsed to single-line form to satisfy the prettier+eslint hook (prettier wants a trailing comma, eslint forbids it on functions). Behavior is unchanged. Refs: exceljs/exceljs#2966 Co-Authored-By: Claude Opus 4.7 --- lib/stream/xlsx/worksheet-reader.js | 20 ++- lib/xlsx/xform/sheet/cell-xform.js | 18 ++- ...ue-2966-formula-result-date-numfmt.spec.js | 124 ++++++++++++++++++ 3 files changed, 154 insertions(+), 8 deletions(-) create mode 100644 spec/integration/issues/issue-2966-formula-result-date-numfmt.spec.js 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); + }); + }); +});