Skip to content
Closed
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
2 changes: 1 addition & 1 deletion .prettierrc
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"bracketSpacing": false,
"printWidth": 100,
"trailingComma": "all",
"trailingComma": "es5",
"arrowParens": "avoid",
"singleQuote": true
}
13 changes: 10 additions & 3 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 @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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 || [];
Expand Down
19 changes: 14 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,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 = '';
Expand Down
137 changes: 137 additions & 0 deletions spec/integration/pr/lambda-defined-name.spec.js
Original file line number Diff line number Diff line change
@@ -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 = `<?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 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)');
});
});
25 changes: 22 additions & 3 deletions spec/unit/xlsx/xform/book/defined-name-xform.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,7 @@ const expectations = [
localSheetId: 0,
ranges: ['bar!$A$1:$C$10'],
},
xml:
'<definedName name="_xlnm.Print_Area" localSheetId="0">bar!$A$1:$C$10</definedName>',
xml: '<definedName name="_xlnm.Print_Area" localSheetId="0">bar!$A$1:$C$10</definedName>',
parsedModel: {
name: '_xlnm.Print_Area',
localSheetId: 0,
Expand All @@ -39,9 +38,29 @@ 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: '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