diff --git a/lib/doc/workbook.js b/lib/doc/workbook.js
index a6e4de920..9c2f3e4f7 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() {
@@ -161,7 +163,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 +223,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..c0c5fd5f8 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,21 @@ function isValidRange(range) {
}
function extractRanges(parsedText) {
+ // A defined-name value is a formula expression (e.g. LAMBDA(x,x*2), LET, OFFSET)
+ // rather than a range list when it contains a '(' that is NOT inside a single-quoted
+ // sheet name. Sheet names with parentheses look like 'Data (2026)'!$A$1 — the '('
+ // appears between an odd number of preceding single quotes (i.e. inside a quotation).
+ // This heuristic avoids splitting LAMBDA/LET bodies whose comma-delimited tokens can
+ // accidentally pass isValidRange.
+ const firstParen = parsedText.indexOf('(');
+ if (firstParen !== -1) {
+ const singleQuotesBefore = (parsedText.slice(0, firstParen).match(/'/g) || []).length;
+ // If the number of single quotes before '(' is even (including zero), the '(' is
+ // outside any quoted sheet name — treat the whole value as a formula expression.
+ if (singleQuotesBefore % 2 === 0) {
+ 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..c925334e1
--- /dev/null
+++ b/spec/integration/pr/lambda-defined-name.spec.js
@@ -0,0 +1,111 @@
+// 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 JSZip = require('jszip');
+
+const ExcelJS = verquire('exceljs');
+
+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 wb1 = new ExcelJS.Workbook();
+ await wb1.xlsx.load(inputBuffer);
+ const outBuffer = await wb1.xlsx.writeBuffer();
+ const wb2 = new ExcelJS.Workbook();
+ await wb2.xlsx.load(outBuffer);
+ return {wb2, outBuffer};
+}
+
+describe('Named LAMBDA defined names', () => {
+ let inputBuffer;
+
+ before(async () => {
+ inputBuffer = await buildXlsxWithLambda();
+ });
+
+ it('round-trips LAMBDA defined names verbatim without dropping them', async () => {
+ const {outBuffer} = await roundTrip(inputBuffer);
+ const zip = await JSZip.loadAsync(outBuffer);
+ 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('preserves normal range defined names alongside LAMBDA definitions', async () => {
+ const {outBuffer} = await roundTrip(inputBuffer);
+ const zip = await JSZip.loadAsync(outBuffer);
+ const workbookXml = await zip.file('xl/workbook.xml').async('string');
+
+ expect(workbookXml).to.include('NormalRange');
+ expect(workbookXml).to.include('Sheet1!$A$1:$B$2');
+ });
+
+ it('preserves LAMBDA formula text exactly as stored', async () => {
+ const {outBuffer} = await roundTrip(inputBuffer);
+ const zip = await JSZip.loadAsync(outBuffer);
+ const workbookXml = await zip.file('xl/workbook.xml').async('string');
+
+ 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..36580064d 100644
--- a/spec/unit/xlsx/xform/book/defined-name-xform.spec.js
+++ b/spec/unit/xlsx/xform/book/defined-name-xform.spec.js
@@ -39,9 +39,40 @@ 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: 'Range on sheet name containing parentheses',
+ create() {
+ return new DefinedNameXform();
+ },
+ // Sheet names with '(' must NOT be misclassified as formula expressions.
+ preparedModel: {name: 'Foo', ranges: ["'Data (2026)'!$A$1"]},
+ xml: "'Data (2026)'!$A$1",
+ parsedModel: {name: 'Foo', ranges: ["'Data (2026)'!$A$1"]},
+ tests: ['render', 'renderIn', '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', () => {