Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions lib/doc/workbook.js
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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 || [];
Expand Down
29 changes: 24 additions & 5 deletions lib/xlsx/xform/book/defined-name-xform.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}

Expand All @@ -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);
}
Expand All @@ -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 = '';
Expand Down
111 changes: 111 additions & 0 deletions spec/integration/pr/lambda-defined-name.spec.js
Original file line number Diff line number Diff line change
@@ -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 = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"
xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">
<fileVersion appName="xl" lastEdited="7" lowestEdited="7" rupBuild="22228"/>
<workbookPr defaultThemeVersion="166925"/>
<bookViews>
<workbookView xWindow="0" yWindow="0" windowWidth="28800" windowHeight="12420"/>
</bookViews>
<sheets>
<sheet name="Sheet1" sheetId="1" r:id="rId1"/>
</sheets>
<definedNames>
<definedName name="MyDouble">LAMBDA(x,x*2)</definedName>
<definedName name="MyAdd">LAMBDA(x,y,x+y)</definedName>
<definedName name="NormalRange">Sheet1!$A$1:$B$2</definedName>
</definedNames>
<calcPr calcId="191028"/>
</workbook>`;

const SHEET_XML = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"
xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">
<sheetData>
<row r="1"><c r="A1" t="n"><v>1</v></c></row>
</sheetData>
</worksheet>`;

const RELS_XML = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet1.xml"/>
</Relationships>`;

const ROOT_RELS_XML = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="xl/workbook.xml"/>
</Relationships>`;

const CONTENT_TYPES_XML = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">
<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>
<Default Extension="xml" ContentType="application/xml"/>
<Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/>
<Override PartName="/xl/worksheets/sheet1.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>
</Types>`;

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)');
});
});
33 changes: 32 additions & 1 deletion spec/unit/xlsx/xform/book/defined-name-xform.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -39,9 +39,40 @@ const expectations = [
},
preparedModel: {name: 'foo', ranges: []},
xml: '<definedName name="foo">"OFFSET($A$10;0;0;0;1)"</definedName>',
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: "<definedName name=\"Foo\">'Data (2026)'!$A$1</definedName>",
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: '<definedName name="MyDouble">LAMBDA(x,x*2)</definedName>',
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: '<definedName name="MySum">LAMBDA(x,y,x+y)</definedName>',
parsedModel: {name: 'MySum', ranges: [], formula: 'LAMBDA(x,y,x+y)'},
tests: ['render', 'renderIn', 'parse'],
},
];

describe('DefinedNameXform', () => {
Expand Down