From cebd81bb8b407ef014d7338fd2910b9a21edf955 Mon Sep 17 00:00:00 2001 From: Bob Senoff Date: Thu, 30 Apr 2026 19:57:19 -0500 Subject: [PATCH] Fix #2943: preserve falsy formula results (0, false, empty string) in copy The FormulaValue copy path used truthy checks (`if (value)`) that dropped formula results equal to 0, false, or ''. Copying a cell with cached result === 0 produced a destination cell with no result. Fixes: - lib/doc/cell.js FormulaValue._copyModel: replace `if (value)` with `if (name in model && model[name] !== undefined)` so falsy results survive value getter/setter copy. - lib/doc/cell.js FormulaValue.toCsvString / toString: stop collapsing falsy results to '' via `||` / ternary; only treat null/undefined as absent. - lib/doc/cell.js FormulaValue.effectiveType: add Boolean branch so a result === false returns ValueType.Boolean instead of crashing on `false.text`. - lib/xlsx/xform/sheet/cell-xform.js parseClose: replace `if (model.value)` with `if (model.value !== undefined)` and special-case `` for `t="str"` formula cells so empty-string results survive xlsx round-trip. Drive-by: extract long shared-formula error message to a local so prettier doesn't restructure the throw across lines (avoids prettier/eslint trailing-comma conflict on edits to this file). Standalone Mocha regression at spec/integration/issues covers value-copy and xlsx round-trip for all three falsy results plus toCsvString/toString. Refs exceljs/exceljs#2943. --- lib/doc/cell.js | 20 ++- lib/xlsx/xform/sheet/cell-xform.js | 12 +- .../issue-2943-formula-falsy-result.spec.js | 124 ++++++++++++++++++ 3 files changed, 147 insertions(+), 9 deletions(-) create mode 100644 spec/integration/issues/issue-2943-formula-falsy-result.spec.js diff --git a/lib/doc/cell.js b/lib/doc/cell.js index 251dded27..6f7363116 100644 --- a/lib/doc/cell.js +++ b/lib/doc/cell.js @@ -749,9 +749,10 @@ class FormulaValue { _copyModel(model) { const copy = {}; const cp = name => { - const value = model[name]; - if (value) { - copy[name] = value; + // Use 'in' check so falsy results (0, false, '') survive the copy. + // See exceljs/exceljs#2943. + if (name in model && model[name] !== undefined) { + copy[name] = model[name]; } }; cp('formula'); @@ -840,6 +841,9 @@ class FormulaValue { if (v instanceof Date) { return Enums.ValueType.Date; } + if (typeof v === 'boolean') { + return Enums.ValueType.Boolean; + } if (v.text && v.hyperlink) { return Enums.ValueType.Hyperlink; } @@ -869,13 +873,19 @@ class FormulaValue { } toCsvString() { - return `${this.model.result || ''}`; + // Preserve falsy results (0, false, '') instead of collapsing to ''. + // See exceljs/exceljs#2943. + const {result} = this.model; + return result === null || result === undefined ? '' : `${result}`; } release() {} toString() { - return this.model.result ? this.model.result.toString() : ''; + // Preserve falsy results (0, false, '') instead of collapsing to ''. + // See exceljs/exceljs#2943. + const {result} = this.model; + return result === null || result === undefined ? '' : result.toString(); } } diff --git a/lib/xlsx/xform/sheet/cell-xform.js b/lib/xlsx/xform/sheet/cell-xform.js index 41715695a..1413154a3 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'; @@ -348,7 +347,9 @@ class CellXform extends BaseXform { // first guess on cell type if (model.formula || model.shareType) { model.type = Enums.ValueType.Formula; - if (model.value) { + // Use !== undefined so an empty (e.g. result === '') + // is preserved instead of dropped. See exceljs/exceljs#2943. + if (model.value !== undefined) { if (this.t === 'str') { model.result = utils.xmlDecode(model.value); } else if (this.t === 'b') { @@ -359,6 +360,9 @@ class CellXform extends BaseXform { model.result = parseFloat(model.value); } model.value = undefined; + } else if (this.t === 'str') { + // Empty string formula result: ... + model.result = ''; } } else if (model.value !== undefined) { switch (this.t) { diff --git a/spec/integration/issues/issue-2943-formula-falsy-result.spec.js b/spec/integration/issues/issue-2943-formula-falsy-result.spec.js new file mode 100644 index 000000000..8848ac000 --- /dev/null +++ b/spec/integration/issues/issue-2943-formula-falsy-result.spec.js @@ -0,0 +1,124 @@ +const ExcelJS = verquire('exceljs'); + +// Regression test for exceljs/exceljs#2943. +// Copying a formula cell whose cached result is a falsy value (0, false, '') +// previously dropped the `result` field because `_copyModel` used a truthy +// check (`if (value)`). + +describe('github issue 2943 - copy formula cell with falsy result', () => { + it('preserves result === 0 when copying formula cell value', () => { + const wb = new ExcelJS.Workbook(); + const ws = wb.addWorksheet('s'); + ws.getCell('A1').value = 5; + ws.getCell('B1').value = {formula: 'A1-A1', result: 0}; + + ws.getCell('C1').value = ws.getCell('B1').value; + + expect(ws.getCell('C1').value).to.deep.equal({ + formula: 'A1-A1', + result: 0, + }); + expect(ws.getCell('C1').value.result).to.equal(0); + }); + + it('preserves result === false when copying formula cell value', () => { + const wb = new ExcelJS.Workbook(); + const ws = wb.addWorksheet('s'); + ws.getCell('B1').value = {formula: 'FALSE()', result: false}; + + ws.getCell('C1').value = ws.getCell('B1').value; + + expect(ws.getCell('C1').value).to.deep.equal({ + formula: 'FALSE()', + result: false, + }); + expect(ws.getCell('C1').value.result).to.equal(false); + }); + + it('preserves result === "" when copying formula cell value', () => { + const wb = new ExcelJS.Workbook(); + const ws = wb.addWorksheet('s'); + ws.getCell('B1').value = {formula: '""', result: ''}; + + ws.getCell('C1').value = ws.getCell('B1').value; + + expect(ws.getCell('C1').value).to.deep.equal({ + formula: '""', + result: '', + }); + expect(ws.getCell('C1').value.result).to.equal(''); + }); + + it('preserves result === 0 across xlsx round-trip', async () => { + const wb = new ExcelJS.Workbook(); + const ws = wb.addWorksheet('s'); + ws.getCell('A1').value = 5; + ws.getCell('B1').value = {formula: 'A1-A1', result: 0}; + + const buffer = await wb.xlsx.writeBuffer(); + const wb2 = new ExcelJS.Workbook(); + await wb2.xlsx.load(buffer); + + expect(wb2.getWorksheet('s').getCell('B1').value).to.deep.equal({ + formula: 'A1-A1', + result: 0, + }); + }); + + it('preserves result === false across xlsx round-trip', async () => { + const wb = new ExcelJS.Workbook(); + const ws = wb.addWorksheet('s'); + ws.getCell('B1').value = {formula: 'FALSE()', result: false}; + + const buffer = await wb.xlsx.writeBuffer(); + const wb2 = new ExcelJS.Workbook(); + await wb2.xlsx.load(buffer); + + expect(wb2.getWorksheet('s').getCell('B1').value).to.deep.equal({ + formula: 'FALSE()', + result: false, + }); + }); + + it('preserves result === "" across xlsx round-trip', async () => { + const wb = new ExcelJS.Workbook(); + const ws = wb.addWorksheet('s'); + ws.getCell('B1').value = {formula: '""', result: ''}; + + const buffer = await wb.xlsx.writeBuffer(); + const wb2 = new ExcelJS.Workbook(); + await wb2.xlsx.load(buffer); + + expect(wb2.getWorksheet('s').getCell('B1').value).to.deep.equal({ + formula: '""', + result: '', + }); + }); + + it('toCsvString returns "0" for falsy numeric result', () => { + const wb = new ExcelJS.Workbook(); + const ws = wb.addWorksheet('s'); + const cell = ws.getCell('B1'); + cell.value = {formula: 'A1-A1', result: 0}; + + expect(cell.toCsvString()).to.equal('0'); + }); + + it('toString returns "0" for falsy numeric result', () => { + const wb = new ExcelJS.Workbook(); + const ws = wb.addWorksheet('s'); + const cell = ws.getCell('B1'); + cell.value = {formula: 'A1-A1', result: 0}; + + expect(cell.toString()).to.equal('0'); + }); + + it('toCsvString returns "false" for boolean false result', () => { + const wb = new ExcelJS.Workbook(); + const ws = wb.addWorksheet('s'); + const cell = ws.getCell('B1'); + cell.value = {formula: 'FALSE()', result: false}; + + expect(cell.toCsvString()).to.equal('false'); + }); +});