diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
index e60048d..bb025f1 100644
--- a/.github/workflows/tests.yml
+++ b/.github/workflows/tests.yml
@@ -13,7 +13,7 @@ jobs:
strategy:
fail-fast: false
matrix:
- node-version: [10.x, 12.x, 14.x, 16.x, 18.x, 20.x, 21.x]
+ node-version: [10.x, 12.x, 14.x, 16.x, 18.x, 20.x, 22.x, 23.x]
os: [ubuntu-latest, macOS-latest, windows-latest]
runs-on: ${{ matrix.os }}
@@ -26,9 +26,10 @@ jobs:
if: runner.os == 'Windows'
- uses: actions/checkout@v2
- name: Use Node.js ${{ matrix.node-version }}
- uses: actions/setup-node@v2
+ uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
+ architecture: 'x64'
- name: Create the npm cache directory
run: mkdir npm-cache && npm config set cache ./npm-cache --global
- name: Cache node modules
diff --git a/.prettier b/.prettier
index 470c468..9257ec1 100644
--- a/.prettier
+++ b/.prettier
@@ -2,6 +2,5 @@
"bracketSpacing": false,
"printWidth": 100,
"trailingComma": "all",
- "bracketSpacing": false,
"arrowParens": "avoid"
}
diff --git a/README.md b/README.md
index 5ba580b..88e0464 100644
--- a/README.md
+++ b/README.md
@@ -203,6 +203,7 @@ To be clear, all contributions added to this library will be included in the lib
Data Validations
Cell Comments
Tables
+ PivotTables
Styles
- Number Formats
@@ -1540,7 +1541,33 @@ column.totalsRowResult = 10;
// commit the table changes into the sheet
table.commit();
```
-
+## PivotTables[⬆](#contents)
+## add pivot table to worksheet
+```javascript
+ const worksheet1 = workbook.addWorksheet('Sheet1');
+ worksheet1.addRows([
+ ['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H'],
+ ['a1', 'b1', 'c1', 'd1', 'e1', 'f1', 4, 5],
+ ['a1', 'b2', 'c1', 'd2', 'e1', 'f1', 4, 5],
+ ['a2', 'b1', 'c2', 'd1', 'e2', 'f1', 14, 24],
+ ['a2', 'b2', 'c2', 'd2', 'e2', 'f2', 24, 35],
+ ['a3', 'b1', 'c3', 'd1', 'e3', 'f2', 34, 45],
+ ['a3', 'b2', 'c3', 'd2', 'e3', 'f2', 44, 45],
+ ]);
+
+ const worksheet2 = workbook.addWorksheet('Sheet2');
+ worksheet2.addPivotTable({
+ // Source of data: the entire sheet range is taken;
+ // akin to `worksheet1.getSheetValues()`.
+ sourceSheet: worksheet1,
+ // Pivot table fields: values indicate field names;
+ // they come from the first row in `worksheet1`.
+ rows: ['A', 'B', 'E'],
+ columns: ['C', 'D'],
+ values: ['H'],
+ metric: 'sum', // only 'sum' possible for now
+ });
+```
## Styles[⬆](#contents)
diff --git a/README_zh.md b/README_zh.md
index b2461ad..985d403 100644
--- a/README_zh.md
+++ b/README_zh.md
@@ -163,6 +163,7 @@ ws1.getCell('A1').value = { text: 'Sheet2', hyperlink: '#A1:B1' };
- 数据验证
- 单元格注释
- 表格
+ - 透视表
- 样式
- 数字格式
@@ -1477,6 +1478,33 @@ column.totalsRowResult = 10;
table.commit();
```
+## 透视表[⬆](#目录)
+## 新增透视表到工作表
+```javascript
+ const worksheet1 = workbook.addWorksheet('Sheet1');
+ worksheet1.addRows([
+ ['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H'],
+ ['a1', 'b1', 'c1', 'd1', 'e1', 'f1', 4, 5],
+ ['a1', 'b2', 'c1', 'd2', 'e1', 'f1', 4, 5],
+ ['a2', 'b1', 'c2', 'd1', 'e2', 'f1', 14, 24],
+ ['a2', 'b2', 'c2', 'd2', 'e2', 'f2', 24, 35],
+ ['a3', 'b1', 'c3', 'd1', 'e3', 'f2', 34, 45],
+ ['a3', 'b2', 'c3', 'd2', 'e3', 'f2', 44, 45],
+ ]);
+
+ const worksheet2 = workbook.addWorksheet('Sheet2');
+ worksheet2.addPivotTable({
+ // Source of data: the entire sheet range is taken;
+ // akin to `worksheet1.getSheetValues()`.
+ sourceSheet: worksheet1,
+ // Pivot table fields: values indicate field names;
+ // they come from the first row in `worksheet1`.
+ rows: ['A', 'B', 'E'],
+ columns: ['C', 'D'],
+ values: ['H'],
+ metric: 'sum', // only 'sum' possible for now
+ });
+```
## 样式[⬆](#目录)
diff --git a/index.d.ts b/index.d.ts
index 5434cf1..b9b656e 100644
--- a/index.d.ts
+++ b/index.d.ts
@@ -1184,6 +1184,14 @@ export interface ConditionalFormattingOptions {
rules: ConditionalFormattingRule[];
}
+export interface AddPivotTableOptions {
+ sourceSheet: Worksheet;
+ rows: string[];
+ columns: string[];
+ values: string[];
+ metric: 'sum';
+}
+
export interface Worksheet {
readonly id: number;
name: string;
@@ -1501,6 +1509,11 @@ export interface Worksheet {
* delete conditionalFormattingOptions
*/
removeConditionalFormatting(filter: any): void;
+
+ /**
+ * add pivot table
+ */
+ addPivotTable(options: AddPivotTableOptions): void;
}
export interface CalculationProperties {
diff --git a/lib/doc/pivot-table.js b/lib/doc/pivot-table.js
new file mode 100644
index 0000000..b4c8d5a
--- /dev/null
+++ b/lib/doc/pivot-table.js
@@ -0,0 +1,134 @@
+const {objectFromProps, range, toSortedArray} = require('../utils/utils');
+
+// TK(2023-10-10): turn this into a class constructor.
+
+function makePivotTable(worksheet, model) {
+ // Example `model`:
+ // {
+ // // Source of data: the entire sheet range is taken,
+ // // akin to `worksheet1.getSheetValues()`.
+ // sourceSheet: worksheet1,
+ //
+ // // Pivot table fields: values indicate field names;
+ // // they come from the first row in `worksheet1`.
+ // rows: ['A', 'B'],
+ // columns: ['C'],
+ // values: ['E'], // only 1 item possible for now
+ // metric: 'sum', // only 'sum' possible for now
+ // }
+
+ validate(worksheet, model);
+
+ const {sourceSheet} = model;
+ let {rows, columns, values} = model;
+
+ const cacheFields = makeCacheFields(sourceSheet, [...rows, ...columns]);
+
+ // let {rows, columns, values} use indices instead of names;
+ // names can then be accessed via `pivotTable.cacheFields[index].name`.
+ // *Note*: Using `reduce` as `Object.fromEntries` requires Node 12+;
+ // ExcelJS is >=8.3.0 (as of 2023-10-08).
+ const nameToIndex = cacheFields.reduce((result, cacheField, index) => {
+ result[cacheField.name] = index;
+ return result;
+ }, {});
+ rows = rows.map(row => nameToIndex[row]);
+ columns = columns.map(column => nameToIndex[column]);
+ values = values.map(value => nameToIndex[value]);
+
+ // form pivot table object
+ return {
+ sourceSheet,
+ rows,
+ columns,
+ values,
+ metric: 'sum',
+ cacheFields,
+ // defined in of xl/pivotTables/pivotTable1.xml;
+ // also used in xl/workbook.xml
+ cacheId: '10',
+ };
+}
+
+function validate(worksheet, model) {
+ if (worksheet.workbook.pivotTables.length === 1) {
+ throw new Error(
+ 'A pivot table was already added. At this time, ExcelJS supports at most one pivot table per file.'
+ );
+ }
+
+ if (model.metric && model.metric !== 'sum') {
+ throw new Error('Only the "sum" metric is supported at this time.');
+ }
+
+ const headerNames = model.sourceSheet.getRow(1).values.slice(1);
+ const isInHeaderNames = objectFromProps(headerNames, true);
+ for (const name of [...model.rows, ...model.columns, ...model.values]) {
+ if (!isInHeaderNames[name]) {
+ throw new Error(`The header name "${name}" was not found in ${model.sourceSheet.name}.`);
+ }
+ }
+
+ if (!model.rows.length) {
+ throw new Error('No pivot table rows specified.');
+ }
+
+ if (model.values.length < 1) {
+ throw new Error('Must have at least one value.');
+ }
+
+ if (model.values.length > 1 && model.columns.length > 0) {
+ throw new Error(
+ 'It is currently not possible to have multiple values when columns are specified. Please either supply an empty array for columns or a single value.'
+ );
+ }
+}
+
+function makeCacheFields(worksheet, fieldNamesWithSharedItems) {
+ // Cache fields are used in pivot tables to reference source data.
+ //
+ // Example
+ // -------
+ // Turn
+ //
+ // `worksheet` sheet values [
+ // ['A', 'B', 'C', 'D', 'E'],
+ // ['a1', 'b1', 'c1', 4, 5],
+ // ['a1', 'b2', 'c1', 4, 5],
+ // ['a2', 'b1', 'c2', 14, 24],
+ // ['a2', 'b2', 'c2', 24, 35],
+ // ['a3', 'b1', 'c3', 34, 45],
+ // ['a3', 'b2', 'c3', 44, 45]
+ // ];
+ // fieldNamesWithSharedItems = ['A', 'B', 'C'];
+ //
+ // into
+ //
+ // [
+ // { name: 'A', sharedItems: ['a1', 'a2', 'a3'] },
+ // { name: 'B', sharedItems: ['b1', 'b2'] },
+ // { name: 'C', sharedItems: ['c1', 'c2', 'c3'] },
+ // { name: 'D', sharedItems: null },
+ // { name: 'E', sharedItems: null }
+ // ]
+
+ const names = worksheet.getRow(1).values;
+ const nameToHasSharedItems = objectFromProps(fieldNamesWithSharedItems, true);
+
+ const aggregate = columnIndex => {
+ const columnValues = worksheet.getColumn(columnIndex).values.splice(2);
+ const columnValuesAsSet = new Set(columnValues);
+ return toSortedArray(columnValuesAsSet);
+ };
+
+ // make result
+ const result = [];
+ for (const columnIndex of range(1, names.length)) {
+ const name = names[columnIndex];
+ const sharedItems = nameToHasSharedItems[name] ? aggregate(columnIndex) : null;
+ result.push({name, sharedItems});
+ }
+ return result;
+}
+
+module.exports = {makePivotTable};
\ No newline at end of file
diff --git a/lib/doc/workbook.js b/lib/doc/workbook.js
index 8e7f46e..dd4893a 100644
--- a/lib/doc/workbook.js
+++ b/lib/doc/workbook.js
@@ -27,6 +27,7 @@ class Workbook {
this.title = '';
this.views = [];
this.media = [];
+ this.pivotTables = [];
this._definedNames = new DefinedNames();
}
@@ -174,6 +175,7 @@ class Workbook {
contentStatus: this.contentStatus,
themes: this._themes,
media: this.media,
+ pivotTables: this.pivotTables,
calcProperties: this.calcProperties,
};
}
@@ -215,6 +217,7 @@ class Workbook {
this.views = value.views;
this._themes = value.themes;
this.media = value.media || [];
+ this.pivotTables = value.pivotTables || [];
}
}
diff --git a/lib/doc/worksheet.js b/lib/doc/worksheet.js
index a5a8892..e253728 100644
--- a/lib/doc/worksheet.js
+++ b/lib/doc/worksheet.js
@@ -8,6 +8,7 @@ const Enums = require('./enums');
const Image = require('./image');
const Table = require('./table');
const DataValidations = require('./data-validations');
+const {makePivotTable} = require('./pivot-table');
const Encryptor = require('../utils/encryptor');
const {copyStyle} = require('../utils/copy-style');
const ColumnFlatter = require('../utils/column-flatter');
@@ -126,6 +127,8 @@ class Worksheet {
// for tables
this.tables = {};
+ this.pivotTables = [];
+
this.conditionalFormattings = [];
}
@@ -808,6 +811,23 @@ class Worksheet {
return Object.values(this.tables);
}
+ // =========================================================================
+ // Pivot Tables
+ addPivotTable(model) {
+ // eslint-disable-next-line no-console
+ console.warn(
+ `Warning: Pivot Table support is experimental.
+Please leave feedback at https://github.com/exceljs/exceljs/discussions/2575`
+ );
+
+ const pivotTable = makePivotTable(this, model);
+
+ this.pivotTables.push(pivotTable);
+ this.workbook.pivotTables.push(pivotTable);
+
+ return pivotTable;
+ }
+
// ===========================================================================
// Conditional Formatting
addConditionalFormatting(cf) {
@@ -857,6 +877,7 @@ class Worksheet {
media: this._media.map(medium => medium.model),
sheetProtection: this.sheetProtection,
tables: Object.values(this.tables).map(table => table.model),
+ pivotTables: this.pivotTables,
conditionalFormattings: this.conditionalFormattings,
};
@@ -923,6 +944,7 @@ class Worksheet {
tables[table.name] = t;
return tables;
}, {});
+ this.pivotTables = value.pivotTables;
this.conditionalFormattings = value.conditionalFormattings;
}
diff --git a/lib/utils/utils.js b/lib/utils/utils.js
index 84cd212..6632212 100644
--- a/lib/utils/utils.js
+++ b/lib/utils/utils.js
@@ -167,6 +167,37 @@ const utils = {
parseBoolean(value) {
return value === true || value === 'true' || value === 1 || value === '1';
},
+
+ *range(start, stop, step = 1) {
+ const compareOrder = step > 0 ? (a, b) => a < b : (a, b) => a > b;
+ for (let value = start; compareOrder(value, stop); value += step) {
+ yield value;
+ }
+ },
+
+ toSortedArray(values) {
+ const result = Array.from(values);
+
+ // Note: per default, `Array.prototype.sort()` converts values
+ // to strings when comparing. Here, if we have numbers, we use
+ // numeric sort.
+ if (result.every(item => Number.isFinite(item))) {
+ const compareNumbers = (a, b) => a - b;
+ return result.sort(compareNumbers);
+ }
+
+ return result.sort();
+ },
+
+ objectFromProps(props, value = null) {
+ // *Note*: Using `reduce` as `Object.fromEntries` requires Node 12+;
+ // ExcelJs is >=8.3.0 (as of 2023-10-08).
+ // return Object.fromEntries(props.map(property => [property, value]));
+ return props.reduce((result, property) => {
+ result[property] = value;
+ return result;
+ }, {});
+ },
};
module.exports = utils;
diff --git a/lib/xlsx/rel-type.js b/lib/xlsx/rel-type.js
index 7cd0a3d..24235b4 100644
--- a/lib/xlsx/rel-type.js
+++ b/lib/xlsx/rel-type.js
@@ -18,4 +18,7 @@ module.exports = {
Comments: 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/comments',
VmlDrawing: 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/vmlDrawing',
Table: 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/table',
+ PivotCacheDefinition: 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/pivotCacheDefinition',
+ PivotCacheRecords: 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/pivotCacheRecords',
+ PivotTable: 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/pivotTable',
};
diff --git a/lib/xlsx/xform/book/workbook-pivot-cache-xform.js b/lib/xlsx/xform/book/workbook-pivot-cache-xform.js
new file mode 100644
index 0000000..894c86b
--- /dev/null
+++ b/lib/xlsx/xform/book/workbook-pivot-cache-xform.js
@@ -0,0 +1,29 @@
+const BaseXform = require('../base-xform');
+
+class WorkbookPivotCacheXform extends BaseXform {
+ render(xmlStream, model) {
+ xmlStream.leafNode('pivotCache', {
+ cacheId: model.cacheId,
+ 'r:id': model.rId,
+ });
+ }
+
+ parseOpen(node) {
+ if (node.name === 'pivotCache') {
+ this.model = {
+ cacheId: node.attributes.cacheId,
+ rId: node.attributes['r:id'],
+ };
+ return true;
+ }
+ return false;
+ }
+
+ parseText() {}
+
+ parseClose() {
+ return false;
+ }
+}
+
+module.exports = WorkbookPivotCacheXform;
diff --git a/lib/xlsx/xform/book/workbook-xform.js b/lib/xlsx/xform/book/workbook-xform.js
index 104e046..90d768a 100644
--- a/lib/xlsx/xform/book/workbook-xform.js
+++ b/lib/xlsx/xform/book/workbook-xform.js
@@ -11,6 +11,7 @@ const SheetXform = require('./sheet-xform');
const WorkbookViewXform = require('./workbook-view-xform');
const WorkbookPropertiesXform = require('./workbook-properties-xform');
const WorkbookCalcPropertiesXform = require('./workbook-calc-properties-xform');
+const WorkbookPivotCacheXform = require('./workbook-pivot-cache-xform');
class WorkbookXform extends BaseXform {
constructor() {
@@ -31,6 +32,11 @@ class WorkbookXform extends BaseXform {
childXform: new DefinedNameXform(),
}),
calcPr: new WorkbookCalcPropertiesXform(),
+ pivotCaches: new ListXform({
+ tag: 'pivotCaches',
+ count: false,
+ childXform: new WorkbookPivotCacheXform(),
+ }),
};
}
@@ -97,6 +103,7 @@ class WorkbookXform extends BaseXform {
this.map.sheets.render(xmlStream, model.sheets);
this.map.definedNames.render(xmlStream, model.definedNames);
this.map.calcPr.render(xmlStream, model.calcProperties);
+ this.map.pivotCaches.render(xmlStream, model.pivotTables);
xmlStream.closeNode();
}
diff --git a/lib/xlsx/xform/core/content-types-xform.js b/lib/xlsx/xform/core/content-types-xform.js
index 2999c62..a7130e0 100644
--- a/lib/xlsx/xform/core/content-types-xform.js
+++ b/lib/xlsx/xform/core/content-types-xform.js
@@ -40,6 +40,22 @@ class ContentTypesXform extends BaseXform {
});
});
+ if ((model.pivotTables || []).length) {
+ // Note(2023-10-06): assuming at most one pivot table for now.
+ xmlStream.leafNode('Override', {
+ PartName: '/xl/pivotCache/pivotCacheDefinition1.xml',
+ ContentType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.pivotCacheDefinition+xml',
+ });
+ xmlStream.leafNode('Override', {
+ PartName: '/xl/pivotCache/pivotCacheRecords1.xml',
+ ContentType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.pivotCacheRecords+xml',
+ });
+ xmlStream.leafNode('Override', {
+ PartName: '/xl/pivotTables/pivotTable1.xml',
+ ContentType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.pivotTable+xml',
+ });
+ }
+
xmlStream.leafNode('Override', {
PartName: '/xl/theme/theme1.xml',
ContentType: 'application/vnd.openxmlformats-officedocument.theme+xml',
diff --git a/lib/xlsx/xform/pivot-table/cache-field.js b/lib/xlsx/xform/pivot-table/cache-field.js
new file mode 100644
index 0000000..60660bc
--- /dev/null
+++ b/lib/xlsx/xform/pivot-table/cache-field.js
@@ -0,0 +1,44 @@
+class CacheField {
+ constructor({name, sharedItems}) {
+ // string type
+ //
+ // {
+ // 'name': 'A',
+ // 'sharedItems': ['a1', 'a2', 'a3']
+ // }
+ //
+ // or
+ //
+ // integer type
+ //
+ // {
+ // 'name': 'D',
+ // 'sharedItems': null
+ // }
+ this.name = name;
+ this.sharedItems = sharedItems;
+ }
+
+ render() {
+ // PivotCache Field: http://www.datypic.com/sc/ooxml/e-ssml_cacheField-1.html
+ // Shared Items: http://www.datypic.com/sc/ooxml/e-ssml_sharedItems-1.html
+
+ // integer types
+ if (this.sharedItems === null) {
+ // TK(2023-07-18): left out attributes... minValue="5" maxValue="45"
+ return `
+
+ `;
+ }
+
+ // string types
+ return `
+
+ ${this.sharedItems.map(item => ``).join('')}
+
+ `;
+ }
+ }
+
+ module.exports = CacheField;
+
\ No newline at end of file
diff --git a/lib/xlsx/xform/pivot-table/pivot-cache-definition-xform.js b/lib/xlsx/xform/pivot-table/pivot-cache-definition-xform.js
new file mode 100644
index 0000000..18f4ef3
--- /dev/null
+++ b/lib/xlsx/xform/pivot-table/pivot-cache-definition-xform.js
@@ -0,0 +1,77 @@
+const BaseXform = require('../base-xform');
+const CacheField = require('./cache-field');
+const XmlStream = require('../../../utils/xml-stream');
+
+class PivotCacheDefinitionXform extends BaseXform {
+ constructor() {
+ super();
+
+ this.map = {};
+ }
+
+ prepare(model) {
+ // TK
+ }
+
+ get tag() {
+ // http://www.datypic.com/sc/ooxml/e-ssml_pivotCacheDefinition.html
+ return 'pivotCacheDefinition';
+ }
+
+ render(xmlStream, model) {
+ const {sourceSheet, cacheFields} = model;
+
+ xmlStream.openXml(XmlStream.StdDocAttributes);
+ xmlStream.openNode(this.tag, {
+ ...PivotCacheDefinitionXform.PIVOT_CACHE_DEFINITION_ATTRIBUTES,
+ 'r:id': 'rId1',
+ refreshOnLoad: '1', // important for our implementation to work
+ refreshedBy: 'Author',
+ refreshedDate: '45125.026046874998',
+ createdVersion: '8',
+ refreshedVersion: '8',
+ minRefreshableVersion: '3',
+ recordCount: cacheFields.length + 1,
+ });
+
+ xmlStream.openNode('cacheSource', {type: 'worksheet'});
+ xmlStream.leafNode('worksheetSource', {
+ ref: sourceSheet.dimensions.shortRange,
+ sheet: sourceSheet.name,
+ });
+ xmlStream.closeNode();
+
+ xmlStream.openNode('cacheFields', {count: cacheFields.length});
+ // Note: keeping this pretty-printed for now to ease debugging.
+ xmlStream.writeXml(cacheFields.map(cacheField => new CacheField(cacheField).render()).join('\n '));
+ xmlStream.closeNode();
+
+ xmlStream.closeNode();
+ }
+
+ parseOpen(node) {
+ // TK
+ }
+
+ parseText(text) {
+ // TK
+ }
+
+ parseClose(name) {
+ // TK
+ }
+
+ reconcile(model, options) {
+ // TK
+ }
+}
+
+PivotCacheDefinitionXform.PIVOT_CACHE_DEFINITION_ATTRIBUTES = {
+ xmlns: 'http://schemas.openxmlformats.org/spreadsheetml/2006/main',
+ 'xmlns:r': 'http://schemas.openxmlformats.org/officeDocument/2006/relationships',
+ 'xmlns:mc': 'http://schemas.openxmlformats.org/markup-compatibility/2006',
+ 'mc:Ignorable': 'xr',
+ 'xmlns:xr': 'http://schemas.microsoft.com/office/spreadsheetml/2014/revision',
+};
+
+module.exports = PivotCacheDefinitionXform;
diff --git a/lib/xlsx/xform/pivot-table/pivot-cache-records-xform.js b/lib/xlsx/xform/pivot-table/pivot-cache-records-xform.js
new file mode 100644
index 0000000..220ec04
--- /dev/null
+++ b/lib/xlsx/xform/pivot-table/pivot-cache-records-xform.js
@@ -0,0 +1,103 @@
+const XmlStream = require('../../../utils/xml-stream');
+
+const BaseXform = require('../base-xform');
+
+class PivotCacheRecordsXform extends BaseXform {
+ constructor() {
+ super();
+
+ this.map = {};
+ }
+
+ prepare(model) {
+ // TK
+ }
+
+ get tag() {
+ // http://www.datypic.com/sc/ooxml/e-ssml_pivotCacheRecords.html
+ return 'pivotCacheRecords';
+ }
+
+ render(xmlStream, model) {
+ const {sourceSheet, cacheFields} = model;
+ const sourceBodyRows = sourceSheet.getSheetValues().slice(2);
+
+ xmlStream.openXml(XmlStream.StdDocAttributes);
+ xmlStream.openNode(this.tag, {
+ ...PivotCacheRecordsXform.PIVOT_CACHE_RECORDS_ATTRIBUTES,
+ count: sourceBodyRows.length,
+ });
+ xmlStream.writeXml(renderTable());
+ xmlStream.closeNode();
+
+ // Helpers
+
+ function renderTable() {
+ const rowsInXML = sourceBodyRows.map(row => {
+ const realRow = row.slice(1);
+ return [...renderRowLines(realRow)].join('');
+ });
+ return rowsInXML.join('');
+ }
+
+ function* renderRowLines(row) {
+ // PivotCache Record: http://www.datypic.com/sc/ooxml/e-ssml_r-1.html
+ // Note: pretty-printing this for now to ease debugging.
+ yield '\n ';
+ for (const [index, cellValue] of row.entries()) {
+ yield '\n ';
+ yield renderCell(cellValue, cacheFields[index].sharedItems);
+ }
+ yield '\n ';
+ }
+
+ function renderCell(value, sharedItems) {
+ // no shared items
+ // --------------------------------------------------
+ if (sharedItems === null) {
+ if (Number.isFinite(value)) {
+ // Numeric value: http://www.datypic.com/sc/ooxml/e-ssml_n-2.html
+ return ``;
+ }
+ // Character Value: http://www.datypic.com/sc/ooxml/e-ssml_s-2.html
+ return ``;
+
+ }
+
+ // shared items
+ // --------------------------------------------------
+ const sharedItemsIndex = sharedItems.indexOf(value);
+ if (sharedItemsIndex < 0) {
+ throw new Error(`${JSON.stringify(value)} not in sharedItems ${JSON.stringify(sharedItems)}`);
+ }
+ // Shared Items Index: http://www.datypic.com/sc/ooxml/e-ssml_x-9.html
+ return ``;
+ }
+ }
+
+ parseOpen(node) {
+ // TK
+ }
+
+ parseText(text) {
+ // TK
+ }
+
+ parseClose(name) {
+ // TK
+ }
+
+ reconcile(model, options) {
+ // TK
+ }
+}
+
+PivotCacheRecordsXform.PIVOT_CACHE_RECORDS_ATTRIBUTES = {
+ xmlns: 'http://schemas.openxmlformats.org/spreadsheetml/2006/main',
+ 'xmlns:r': 'http://schemas.openxmlformats.org/officeDocument/2006/relationships',
+ 'xmlns:mc': 'http://schemas.openxmlformats.org/markup-compatibility/2006',
+ 'mc:Ignorable': 'xr',
+ 'xmlns:xr': 'http://schemas.microsoft.com/office/spreadsheetml/2014/revision',
+};
+
+module.exports = PivotCacheRecordsXform;
diff --git a/lib/xlsx/xform/pivot-table/pivot-table-xform.js b/lib/xlsx/xform/pivot-table/pivot-table-xform.js
new file mode 100644
index 0000000..0dbd765
--- /dev/null
+++ b/lib/xlsx/xform/pivot-table/pivot-table-xform.js
@@ -0,0 +1,202 @@
+const XmlStream = require('../../../utils/xml-stream');
+const BaseXform = require('../base-xform');
+
+class PivotTableXform extends BaseXform {
+ constructor() {
+ super();
+
+ this.map = {};
+ }
+
+ prepare(model) {
+ // TK
+ }
+
+ get tag() {
+ // http://www.datypic.com/sc/ooxml/e-ssml_pivotTableDefinition.html
+ return 'pivotTableDefinition';
+ }
+
+ render(xmlStream, model) {
+ // eslint-disable-next-line no-unused-vars
+ const {rows, columns, values, metric, cacheFields, cacheId} = model;
+
+ // Examples
+ // --------
+ // rows: [0, 1], // only 2 items possible for now
+ // columns: [2], // only 1 item possible for now
+ // values: [4], // only 1 item possible for now
+ // metric: 'sum', // only 'sum' possible for now
+ //
+ // the numbers are indices into `cacheFields`.
+
+ xmlStream.openXml(XmlStream.StdDocAttributes);
+ xmlStream.openNode(this.tag, {
+ ...PivotTableXform.PIVOT_TABLE_ATTRIBUTES,
+ 'xr:uid': '{267EE50F-B116-784D-8DC2-BA77DE3F4F4A}',
+ name: 'PivotTable2',
+ cacheId,
+ applyNumberFormats: '0',
+ applyBorderFormats: '0',
+ applyFontFormats: '0',
+ applyPatternFormats: '0',
+ applyAlignmentFormats: '0',
+ applyWidthHeightFormats: '1',
+ dataCaption: 'Values',
+ updatedVersion: '8',
+ minRefreshableVersion: '3',
+ useAutoFormatting: '1',
+ itemPrintTitles: '1',
+ createdVersion: '8',
+ indent: '0',
+ compact: '0',
+ compactData: '0',
+ multipleFieldFilters: '0',
+ });
+
+ // Note: keeping this pretty-printed and verbose for now to ease debugging.
+ //
+ // location: ref="A3:E15"
+ // pivotFields
+ // rowFields and rowItems
+ // colFields and colItems
+ // dataFields
+ // pivotTableStyleInfo
+ xmlStream.writeXml(`
+
+
+ ${renderPivotFields(model)}
+
+
+ ${rows.map(rowIndex => ``).join('\n ')}
+
+
+
+
+
+ ${
+ columns.length === 0
+ ? ''
+ : columns.map(columnIndex => ``).join('\n ')
+ }
+
+
+
+
+
+ ${buildDataFields(cacheFields, values)}
+
+
+
+
+
+
+
+
+
+
+ `);
+
+ xmlStream.closeNode();
+ }
+
+ parseOpen(node) {
+ // TK
+ }
+
+ parseText(text) {
+ // TK
+ }
+
+ parseClose(name) {
+ // TK
+ }
+
+ reconcile(model, options) {
+ // TK
+ }
+}
+
+// Helpers
+function buildDataFields(cacheFields, values) {
+ let i = 0;
+ let datafields = '';
+ while (i < values.length) {
+ datafields += ``;
+ i++;
+ }
+ return datafields;
+}
+
+function renderPivotFields(pivotTable) {
+ /* eslint-disable no-nested-ternary */
+ return pivotTable.cacheFields
+ .map((cacheField, fieldIndex) => {
+ const fieldType =
+ pivotTable.rows.indexOf(fieldIndex) >= 0
+ ? 'row'
+ : pivotTable.columns.indexOf(fieldIndex) >= 0
+ ? 'column'
+ : pivotTable.values.indexOf(fieldIndex) >= 0
+ ? 'value'
+ : null;
+ return renderPivotField(fieldType, cacheField.sharedItems);
+ })
+ .join('');
+}
+
+function renderPivotField(fieldType, sharedItems) {
+ // fieldType: 'row', 'column', 'value', null
+
+ const defaultAttributes = 'compact="0" outline="0" showAll="0" defaultSubtotal="0"';
+
+ if (fieldType === 'row' || fieldType === 'column') {
+ const axis = fieldType === 'row' ? 'axisRow' : 'axisCol';
+ return `
+
+
+ ${sharedItems.map((item, index) => ` `).join('\n ')}
+
+
+ `;
+ }
+ return `
+
+ `;
+}
+
+PivotTableXform.PIVOT_TABLE_ATTRIBUTES = {
+ xmlns: 'http://schemas.openxmlformats.org/spreadsheetml/2006/main',
+ 'xmlns:mc': 'http://schemas.openxmlformats.org/markup-compatibility/2006',
+ 'mc:Ignorable': 'xr',
+ 'xmlns:xr': 'http://schemas.microsoft.com/office/spreadsheetml/2014/revision',
+};
+
+module.exports = PivotTableXform;
diff --git a/lib/xlsx/xform/sheet/worksheet-xform.js b/lib/xlsx/xform/sheet/worksheet-xform.js
index 490f384..8293b8f 100644
--- a/lib/xlsx/xform/sheet/worksheet-xform.js
+++ b/lib/xlsx/xform/sheet/worksheet-xform.js
@@ -288,6 +288,15 @@ class WorkSheetXform extends BaseXform {
});
});
+ // prepare pivot tables
+ if ((model.pivotTables || []).length) {
+ rels.push({
+ Id: nextRid(rels),
+ Type: RelType.PivotTable,
+ Target: '../pivotTables/pivotTable1.xml',
+ });
+ }
+
// prepare ext items
this.map.extLst.prepare(model, options);
}
diff --git a/lib/xlsx/xlsx.js b/lib/xlsx/xlsx.js
index 43dee23..090cdb2 100644
--- a/lib/xlsx/xlsx.js
+++ b/lib/xlsx/xlsx.js
@@ -20,6 +20,9 @@ const WorkbookXform = require('./xform/book/workbook-xform');
const WorksheetXform = require('./xform/sheet/worksheet-xform');
const DrawingXform = require('./xform/drawing/drawing-xform');
const TableXform = require('./xform/table/table-xform');
+const PivotCacheRecordsXform = require('./xform/pivot-table/pivot-cache-records-xform');
+const PivotCacheDefinitionXform = require('./xform/pivot-table/pivot-cache-definition-xform');
+const PivotTableXform = require('./xform/pivot-table/pivot-table-xform');
const CommentsXform = require('./xform/comment/comments-xform');
const VmlNotesXform = require('./xform/comment/vml-notes-xform');
@@ -477,6 +480,71 @@ class XLSX {
});
}
+ addPivotTables(zip, model) {
+ if (!model.pivotTables.length) return;
+
+ const pivotTable = model.pivotTables[0];
+
+ const pivotCacheRecordsXform = new PivotCacheRecordsXform();
+ const pivotCacheDefinitionXform = new PivotCacheDefinitionXform();
+ const pivotTableXform = new PivotTableXform();
+ const relsXform = new RelationshipsXform();
+
+ // pivot cache records
+ // --------------------------------------------------
+ // copy of the source data.
+ //
+ // Note: cells in the columns of the source data which are part
+ // of the "rows" or "columns" of the pivot table configuration are
+ // replaced by references to their __cache field__ identifiers.
+ // See "pivot cache definition" below.
+
+ let xml = pivotCacheRecordsXform.toXml(pivotTable);
+ zip.append(xml, {name: 'xl/pivotCache/pivotCacheRecords1.xml'});
+
+ // pivot cache definition
+ // --------------------------------------------------
+ // cache source (source data):
+ // ref="A1:E7" on sheet="Sheet1"
+ // cache fields:
+ // - 0: "A" (a1, a2, a3)
+ // - 1: "B" (b1, b2)
+ // - ...
+
+ xml = pivotCacheDefinitionXform.toXml(pivotTable);
+ zip.append(xml, {name: 'xl/pivotCache/pivotCacheDefinition1.xml'});
+
+ xml = relsXform.toXml([
+ {
+ Id: 'rId1',
+ Type: XLSX.RelType.PivotCacheRecords,
+ Target: 'pivotCacheRecords1.xml',
+ },
+ ]);
+ zip.append(xml, {name: 'xl/pivotCache/_rels/pivotCacheDefinition1.xml.rels'});
+
+ // pivot tables (on destination worksheet)
+ // --------------------------------------------------
+ // location: ref="A3:E15"
+ // pivotFields
+ // rowFields and rowItems
+ // colFields and colItems
+ // dataFields
+ // pivotTableStyleInfo
+
+ xml = pivotTableXform.toXml(pivotTable);
+ zip.append(xml, {name: 'xl/pivotTables/pivotTable1.xml'});
+
+ xml = relsXform.toXml([
+ {
+ Id: 'rId1',
+ Type: XLSX.RelType.PivotCacheDefinition,
+ Target: '../pivotCache/pivotCacheDefinition1.xml',
+ },
+ ]);
+ zip.append(xml, {name: 'xl/pivotTables/_rels/pivotTable1.xml.rels'});
+ }
+
async addContentTypes(zip, model) {
const xform = new ContentTypesXform();
const xml = xform.toXml(model);
@@ -526,6 +594,15 @@ class XLSX {
Target: 'sharedStrings.xml',
});
}
+ if ((model.pivotTables || []).length) {
+ const pivotTable = model.pivotTables[0];
+ pivotTable.rId = `rId${count++}`;
+ relationships.push({
+ Id: pivotTable.rId,
+ Type: XLSX.RelType.PivotCacheDefinition,
+ Target: 'pivotCache/pivotCacheDefinition1.xml',
+ });
+ }
model.worksheets.forEach(worksheet => {
worksheet.rId = `rId${count++}`;
relationships.push({
@@ -662,6 +739,7 @@ class XLSX {
await this.addSharedStrings(zip, model); // always after worksheets
await this.addDrawings(zip, model);
await this.addTables(zip, model);
+ await this.addPivotTables(zip, model);
await Promise.all([this.addThemes(zip, model), this.addStyles(zip, model)]);
await this.addMedia(zip, model);
await Promise.all([this.addApp(zip, model), this.addCore(zip, model)]);
diff --git a/spec/integration/workbook/pivot-tables.spec.js b/spec/integration/workbook/pivot-tables.spec.js
new file mode 100644
index 0000000..30758f8
--- /dev/null
+++ b/spec/integration/workbook/pivot-tables.spec.js
@@ -0,0 +1,78 @@
+// *Note*: `fs.promises` not supported before Node.js 11.14.0;
+// ExcelJS version range '>=8.3.0' (as of 2023-10-08).
+const fs = require('fs');
+const {promisify} = require('util');
+
+const fsReadFileAsync = promisify(fs.readFile);
+
+const JSZip = require('jszip');
+
+const ExcelJS = verquire('exceljs');
+
+const PIVOT_TABLE_FILEPATHS = [
+ 'xl/pivotCache/pivotCacheRecords1.xml',
+ 'xl/pivotCache/pivotCacheDefinition1.xml',
+ 'xl/pivotCache/_rels/pivotCacheDefinition1.xml.rels',
+ 'xl/pivotTables/pivotTable1.xml',
+ 'xl/pivotTables/_rels/pivotTable1.xml.rels',
+];
+
+const TEST_XLSX_FILEPATH = './spec/out/wb.test.xlsx';
+
+const TEST_DATA = [
+ ['A', 'B', 'C', 'D', 'E'],
+ ['a1', 'b1', 'c1', 4, 5],
+ ['a1', 'b2', 'c1', 4, 5],
+ ['a2', 'b1', 'c2', 14, 24],
+ ['a2', 'b2', 'c2', 24, 35],
+ ['a3', 'b1', 'c3', 34, 45],
+ ['a3', 'b2', 'c3', 44, 45],
+];
+
+// =============================================================================
+// Tests
+
+describe('Workbook', () => {
+ describe('Pivot Tables', () => {
+ it('if pivot table added, then certain xml and rels files are added', async () => {
+ const workbook = new ExcelJS.Workbook();
+
+ const worksheet1 = workbook.addWorksheet('Sheet1');
+ worksheet1.addRows(TEST_DATA);
+
+ const worksheet2 = workbook.addWorksheet('Sheet2');
+ worksheet2.addPivotTable({
+ sourceSheet: worksheet1,
+ rows: ['A', 'B'],
+ columns: ['C'],
+ values: ['E'],
+ metric: 'sum',
+ });
+
+ return workbook.xlsx.writeFile(TEST_XLSX_FILEPATH).then(async () => {
+ const buffer = await fsReadFileAsync(TEST_XLSX_FILEPATH);
+ const zip = await JSZip.loadAsync(buffer);
+ for (const filepath of PIVOT_TABLE_FILEPATHS) {
+ expect(zip.files[filepath]).to.not.be.undefined();
+ }
+ });
+ });
+
+ it('if pivot table NOT added, then certain xml and rels files are not added', () => {
+ const workbook = new ExcelJS.Workbook();
+
+ const worksheet1 = workbook.addWorksheet('Sheet1');
+ worksheet1.addRows(TEST_DATA);
+
+ workbook.addWorksheet('Sheet2');
+
+ return workbook.xlsx.writeFile(TEST_XLSX_FILEPATH).then(async () => {
+ const buffer = await fsReadFileAsync(TEST_XLSX_FILEPATH);
+ const zip = await JSZip.loadAsync(buffer);
+ for (const filepath of PIVOT_TABLE_FILEPATHS) {
+ expect(zip.files[filepath]).to.be.undefined();
+ }
+ });
+ });
+ });
+});
diff --git a/test/test-pivot-table.js b/test/test-pivot-table.js
new file mode 100644
index 0000000..7443061
--- /dev/null
+++ b/test/test-pivot-table.js
@@ -0,0 +1,55 @@
+// --------------------------------------------------
+// This enables the generation of a XLSX pivot table
+// with several restrictions
+//
+// Last updated: 2023-10-19
+// --------------------------------------------------
+/* eslint-disable */
+
+function main(filepath) {
+ const Excel = require('../lib/exceljs.nodejs.js');
+
+ const workbook = new Excel.Workbook();
+
+ const worksheet1 = workbook.addWorksheet('Sheet1');
+ worksheet1.addRows([
+ ['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H'],
+ ['a1', 'b1', 'c1', 'd1', 'e1', 'f1', 4, 5],
+ ['a1', 'b2', 'c1', 'd2', 'e1', 'f1', 4, 5],
+ ['a2', 'b1', 'c2', 'd1', 'e2', 'f1', 14, 24],
+ ['a2', 'b2', 'c2', 'd2', 'e2', 'f2', 24, 35],
+ ['a3', 'b1', 'c3', 'd1', 'e3', 'f2', 34, 45],
+ ['a3', 'b2', 'c3', 'd2', 'e3', 'f2', 44, 45],
+ ]);
+
+ const worksheet2 = workbook.addWorksheet('Sheet2');
+ worksheet2.addPivotTable({
+ // Source of data: the entire sheet range is taken;
+ // akin to `worksheet1.getSheetValues()`.
+ sourceSheet: worksheet1,
+ // Pivot table fields: values indicate field names;
+ // they come from the first row in `worksheet1`.
+ rows: ['A', 'B', 'E'],
+ columns: ['C', 'D'],
+ values: ['H'], // only 1 item possible for now
+ metric: 'sum', // only 'sum' possible for now
+ });
+
+ save(workbook, filepath);
+}
+
+function save(workbook, filepath) {
+ const HrStopwatch = require('./utils/hr-stopwatch');
+ const stopwatch = new HrStopwatch();
+ stopwatch.start();
+
+ workbook.xlsx.writeFile(filepath).then(() => {
+ const microseconds = stopwatch.microseconds;
+ console.log('Done.');
+ console.log('Time taken:', microseconds);
+ });
+}
+
+const [, , filepath] = process.argv;
+main(filepath);
+
\ No newline at end of file