diff --git a/lib/stream/xlsx/worksheet-reader.js b/lib/stream/xlsx/worksheet-reader.js index 8cecd5c17..12962da20 100644 --- a/lib/stream/xlsx/worksheet-reader.js +++ b/lib/stream/xlsx/worksheet-reader.js @@ -297,6 +297,24 @@ 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') { + // Use Number() rather than parseFloat() so partial strings like + // "2026-05-07" are rejected (Number returns NaN), not coerced to 2026. + const parsed = Number(numericResult.trim()); + if (Number.isFinite(parsed)) { + numericResult = parsed; + } + } + if (typeof numericResult === 'number') { + const date1904 = properties.model && properties.model.date1904; + cellValue.result = utils.excelToDate(numericResult, date1904); + } + } } cell.value = cellValue; } else if (c.v) { diff --git a/lib/xlsx/xform/sheet/cell-xform.js b/lib/xlsx/xform/sheet/cell-xform.js index 41715695a..5e10da4e3 100644 --- a/lib/xlsx/xform/sheet/cell-xform.js +++ b/lib/xlsx/xform/sheet/cell-xform.js @@ -455,7 +455,22 @@ 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 string to number first so + // the date conversion runs. Non-numeric results (empty string, "N/A", + // booleans) are left unchanged. + let numericResult = model.result; + if (typeof numericResult === 'string') { + // Use Number() rather than parseFloat() so partial strings like + // "2026-05-07" are rejected (Number returns NaN), not coerced to 2026. + const parsed = Number(numericResult.trim()); + if (Number.isFinite(parsed)) { + numericResult = parsed; + } + } + if (typeof numericResult === 'number') { + 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..1ff337336 --- /dev/null +++ b/spec/integration/issues/issue-2966-formula-result-date-numfmt.spec.js @@ -0,0 +1,126 @@ +'use strict'; + +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); + }); + }); +});