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', () => {