diff --git a/.prettierrc b/.prettierrc
index 9f4d9560f..d49c5c4f3 100644
--- a/.prettierrc
+++ b/.prettierrc
@@ -1,7 +1,7 @@
{
"bracketSpacing": false,
"printWidth": 100,
- "trailingComma": "all",
+ "trailingComma": "es5",
"arrowParens": "avoid",
"singleQuote": true
}
diff --git a/lib/doc/workbook.js b/lib/doc/workbook.js
index a6e4de920..47ba36ca3 100644
--- a/lib/doc/workbook.js
+++ b/lib/doc/workbook.js
@@ -29,6 +29,8 @@ class Workbook {
this.media = [];
this.pivotTables = [];
this._definedNames = new DefinedNames();
+ // Non-range defined names (LAMBDA, LET, etc.) that cannot be stored in CellMatrix
+ this._formulaDefinedNames = [];
}
get xlsx() {
@@ -79,7 +81,10 @@ class Workbook {
}
}
- const lastOrderNo = this._worksheets.reduce((acc, ws) => ((ws && ws.orderNo) > acc ? ws.orderNo : acc), 0);
+ const lastOrderNo = this._worksheets.reduce(
+ (acc, ws) => ((ws && ws.orderNo) > acc ? ws.orderNo : acc),
+ 0
+ );
const worksheetOptions = Object.assign({}, options, {
id,
name,
@@ -161,7 +166,7 @@ class Workbook {
properties: this.properties,
worksheets: this.worksheets.map(worksheet => worksheet.model),
sheets: this.worksheets.map(ws => ws.model).filter(Boolean),
- definedNames: this._definedNames.model,
+ definedNames: this._definedNames.model.concat(this._formulaDefinedNames),
views: this.views,
company: this.company,
manager: this.manager,
@@ -221,7 +226,9 @@ class Workbook {
worksheet.model = worksheetModel;
});
- this._definedNames.model = value.definedNames;
+ const allDefinedNames = value.definedNames || [];
+ this._formulaDefinedNames = allDefinedNames.filter(dn => dn.formula !== undefined);
+ this._definedNames.model = allDefinedNames.filter(dn => dn.formula === undefined);
this.views = value.views;
this._themes = value.themes;
this.media = value.media || [];
diff --git a/lib/xlsx/xform/book/defined-name-xform.js b/lib/xlsx/xform/book/defined-name-xform.js
index 9ed8fec0d..48eb10bb5 100644
--- a/lib/xlsx/xform/book/defined-name-xform.js
+++ b/lib/xlsx/xform/book/defined-name-xform.js
@@ -11,7 +11,8 @@ class DefinedNamesXform extends BaseXform {
name: model.name,
localSheetId: model.localSheetId,
});
- xmlStream.writeText(model.ranges.join(','));
+ // Non-range defined names (e.g. named LAMBDAs) are preserved verbatim in formula field
+ xmlStream.writeText(model.formula !== undefined ? model.formula : model.ranges.join(','));
xmlStream.closeNode();
}
@@ -32,10 +33,13 @@ class DefinedNamesXform extends BaseXform {
}
parseClose() {
- this.model = {
- name: this._parsedName,
- ranges: extractRanges(this._parsedText.join('')),
- };
+ const text = this._parsedText.join('');
+ const ranges = extractRanges(text);
+ this.model = {name: this._parsedName, ranges};
+ // Preserve non-range content (e.g. LAMBDA, LET, or other formula expressions) verbatim
+ if (ranges.length === 0 && text.trim().length > 0) {
+ this.model.formula = text;
+ }
if (this._parsedLocalSheetId !== undefined) {
this.model.localSheetId = parseInt(this._parsedLocalSheetId, 10);
}
@@ -53,6 +57,11 @@ function isValidRange(range) {
}
function extractRanges(parsedText) {
+ // A defined-name value that contains '(' is a formula expression (e.g. LAMBDA, LET, OFFSET),
+ // never a valid range address list. Return early so the caller can preserve it verbatim.
+ if (parsedText.includes('(')) {
+ return [];
+ }
const ranges = [];
let quotesOpened = false;
let last = '';
diff --git a/spec/integration/pr/lambda-defined-name.spec.js b/spec/integration/pr/lambda-defined-name.spec.js
new file mode 100644
index 000000000..24d1a752b
--- /dev/null
+++ b/spec/integration/pr/lambda-defined-name.spec.js
@@ -0,0 +1,137 @@
+// Integration test: named LAMBDA defined names must survive an ExcelJS round-trip.
+// Modern Excel (2024+) supports workbook-level LAMBDA definitions like:
+// MyDouble = LAMBDA(x, x*2)
+// Prior to this fix, DefinedNamesXform.extractRanges() would silently discard
+// any definedName whose value is not a valid range address, losing the LAMBDA.
+
+const ExcelJS = verquire('exceljs');
+const JSZip = require('jszip');
+const fs = require('fs');
+const path = require('path');
+const os = require('os');
+
+const WORKBOOK_XML_WITH_LAMBDA = `
+
+
+
+
+
+
+
+
+
+
+ LAMBDA(x,x*2)
+ LAMBDA(x,y,x+y)
+ Sheet1!$A$1:$B$2
+
+
+`;
+
+const SHEET_XML = `
+
+
+ 1
+
+`;
+
+const RELS_XML = `
+
+
+`;
+
+const ROOT_RELS_XML = `
+
+
+`;
+
+const CONTENT_TYPES_XML = `
+
+
+
+
+
+`;
+
+async function buildXlsxWithLambda() {
+ const zip = new JSZip();
+ zip.file('[Content_Types].xml', CONTENT_TYPES_XML);
+ zip.file('_rels/.rels', ROOT_RELS_XML);
+ zip.file('xl/workbook.xml', WORKBOOK_XML_WITH_LAMBDA);
+ zip.file('xl/_rels/workbook.xml.rels', RELS_XML);
+ zip.file('xl/worksheets/sheet1.xml', SHEET_XML);
+ return zip.generateAsync({type: 'nodebuffer'});
+}
+
+async function roundTrip(inputBuffer) {
+ const tmpIn = path.join(os.tmpdir(), `lambda-test-in-${Date.now()}.xlsx`);
+ const tmpOut = path.join(os.tmpdir(), `lambda-test-out-${Date.now()}.xlsx`);
+ try {
+ fs.writeFileSync(tmpIn, inputBuffer);
+
+ const wb1 = new ExcelJS.Workbook();
+ await wb1.xlsx.readFile(tmpIn);
+ await wb1.xlsx.writeFile(tmpOut);
+
+ const wb2 = new ExcelJS.Workbook();
+ await wb2.xlsx.readFile(tmpOut);
+ return wb2;
+ } finally {
+ try {
+ fs.unlinkSync(tmpIn);
+ } catch (e) {
+ /* ignore */
+ }
+ try {
+ fs.unlinkSync(tmpOut);
+ } catch (e) {
+ /* ignore */
+ }
+ }
+}
+
+describe('Named LAMBDA defined names', () => {
+ let inputBuffer;
+
+ before(async () => {
+ inputBuffer = await buildXlsxWithLambda();
+ });
+
+ it('should round-trip LAMBDA defined names verbatim without dropping them', async () => {
+ const wb = await roundTrip(inputBuffer);
+ // Read the written xlsx back as a zip to inspect definedNames XML directly
+ const outBuf = await wb.xlsx.writeBuffer();
+ const zip = await JSZip.loadAsync(outBuf);
+ const workbookXml = await zip.file('xl/workbook.xml').async('string');
+
+ expect(workbookXml).to.include('MyDouble');
+ expect(workbookXml).to.include('LAMBDA(x,x*2)');
+ expect(workbookXml).to.include('MyAdd');
+ expect(workbookXml).to.include('LAMBDA(x,y,x+y)');
+ });
+
+ it('should not drop LAMBDA definitions while preserving normal range defined names', async () => {
+ const wb = await roundTrip(inputBuffer);
+ const outBuf = await wb.xlsx.writeBuffer();
+ const zip = await JSZip.loadAsync(outBuf);
+ const workbookXml = await zip.file('xl/workbook.xml').async('string');
+
+ // Normal range-based defined name must also survive
+ expect(workbookXml).to.include('NormalRange');
+ expect(workbookXml).to.include('Sheet1!$A$1:$B$2');
+ });
+
+ it('should preserve LAMBDA formula text exactly as stored', async () => {
+ const wb = await roundTrip(inputBuffer);
+ const outBuf = await wb.xlsx.writeBuffer();
+ const zip = await JSZip.loadAsync(outBuf);
+ const workbookXml = await zip.file('xl/workbook.xml').async('string');
+
+ // Verify exact text content — no mangling of the LAMBDA body
+ const lambdaMatch = workbookXml.match(/name="MyDouble"[^>]*>([^<]*)<\/definedName>/);
+ expect(lambdaMatch).to.not.be.null();
+ expect(lambdaMatch[1]).to.equal('LAMBDA(x,x*2)');
+ });
+});
diff --git a/spec/unit/xlsx/xform/book/defined-name-xform.spec.js b/spec/unit/xlsx/xform/book/defined-name-xform.spec.js
index 27a6b10ca..cbce909eb 100644
--- a/spec/unit/xlsx/xform/book/defined-name-xform.spec.js
+++ b/spec/unit/xlsx/xform/book/defined-name-xform.spec.js
@@ -23,8 +23,7 @@ const expectations = [
localSheetId: 0,
ranges: ['bar!$A$1:$C$10'],
},
- xml:
- 'bar!$A$1:$C$10',
+ xml: 'bar!$A$1:$C$10',
parsedModel: {
name: '_xlnm.Print_Area',
localSheetId: 0,
@@ -39,9 +38,29 @@ const expectations = [
},
preparedModel: {name: 'foo', ranges: []},
xml: '"OFFSET($A$10;0;0;0;1)"',
- parsedModel: {name: 'foo', ranges: []},
+ parsedModel: {name: 'foo', ranges: [], formula: '"OFFSET($A$10;0;0;0;1)"'},
tests: ['parse'],
},
+ {
+ title: 'Named LAMBDA expression',
+ create() {
+ return new DefinedNameXform();
+ },
+ preparedModel: {name: 'MyDouble', ranges: [], formula: 'LAMBDA(x,x*2)'},
+ xml: 'LAMBDA(x,x*2)',
+ parsedModel: {name: 'MyDouble', ranges: [], formula: 'LAMBDA(x,x*2)'},
+ tests: ['render', 'renderIn', 'parse'],
+ },
+ {
+ title: 'Named LAMBDA with multiple parameters',
+ create() {
+ return new DefinedNameXform();
+ },
+ preparedModel: {name: 'MySum', ranges: [], formula: 'LAMBDA(x,y,x+y)'},
+ xml: 'LAMBDA(x,y,x+y)',
+ parsedModel: {name: 'MySum', ranges: [], formula: 'LAMBDA(x,y,x+y)'},
+ tests: ['render', 'renderIn', 'parse'],
+ },
];
describe('DefinedNameXform', () => {