Skip to content

Commit a18b3ed

Browse files
authored
Merge pull request #34 from zurmokeeper/feature/add_pivot_table_func
feat: add pivot table func
2 parents aa16b15 + 5c3fe3c commit a18b3ed

21 files changed

+963
-4
lines changed

.github/workflows/tests.yml

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ jobs:
1313
strategy:
1414
fail-fast: false
1515
matrix:
16-
node-version: [10.x, 12.x, 14.x, 16.x, 18.x, 20.x, 21.x]
16+
node-version: [10.x, 12.x, 14.x, 16.x, 18.x, 20.x, 22.x, 23.x]
1717
os: [ubuntu-latest, macOS-latest, windows-latest]
1818
runs-on: ${{ matrix.os }}
1919

@@ -26,9 +26,10 @@ jobs:
2626
if: runner.os == 'Windows'
2727
- uses: actions/checkout@v2
2828
- name: Use Node.js ${{ matrix.node-version }}
29-
uses: actions/setup-node@v2
29+
uses: actions/setup-node@v4
3030
with:
3131
node-version: ${{ matrix.node-version }}
32+
architecture: 'x64'
3233
- name: Create the npm cache directory
3334
run: mkdir npm-cache && npm config set cache ./npm-cache --global
3435
- name: Cache node modules

.prettier

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,5 @@
22
"bracketSpacing": false,
33
"printWidth": 100,
44
"trailingComma": "all",
5-
"bracketSpacing": false,
65
"arrowParens": "avoid"
76
}

README.md

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -203,6 +203,7 @@ To be clear, all contributions added to this library will be included in the lib
203203
<li><a href="#data-validations">Data Validations</a></li>
204204
<li><a href="#cell-comments">Cell Comments</a></li>
205205
<li><a href="#tables">Tables</a></li>
206+
<li><a href="#pivot-tables">PivotTables</a></li>
206207
<li><a href="#styles">Styles</a>
207208
<ul>
208209
<li><a href="#number-formats">Number Formats</a></li>
@@ -1540,7 +1541,33 @@ column.totalsRowResult = 10;
15401541
// commit the table changes into the sheet
15411542
table.commit();
15421543
```
1543-
1544+
## PivotTables[](#contents)<!-- Link generated with jump2header -->
1545+
## add pivot table to worksheet
1546+
```javascript
1547+
const worksheet1 = workbook.addWorksheet('Sheet1');
1548+
worksheet1.addRows([
1549+
['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H'],
1550+
['a1', 'b1', 'c1', 'd1', 'e1', 'f1', 4, 5],
1551+
['a1', 'b2', 'c1', 'd2', 'e1', 'f1', 4, 5],
1552+
['a2', 'b1', 'c2', 'd1', 'e2', 'f1', 14, 24],
1553+
['a2', 'b2', 'c2', 'd2', 'e2', 'f2', 24, 35],
1554+
['a3', 'b1', 'c3', 'd1', 'e3', 'f2', 34, 45],
1555+
['a3', 'b2', 'c3', 'd2', 'e3', 'f2', 44, 45],
1556+
]);
1557+
1558+
const worksheet2 = workbook.addWorksheet('Sheet2');
1559+
worksheet2.addPivotTable({
1560+
// Source of data: the entire sheet range is taken;
1561+
// akin to `worksheet1.getSheetValues()`.
1562+
sourceSheet: worksheet1,
1563+
// Pivot table fields: values indicate field names;
1564+
// they come from the first row in `worksheet1`.
1565+
rows: ['A', 'B', 'E'],
1566+
columns: ['C', 'D'],
1567+
values: ['H'],
1568+
metric: 'sum', // only 'sum' possible for now
1569+
});
1570+
```
15441571

15451572
## Styles[](#contents)<!-- Link generated with jump2header -->
15461573

README_zh.md

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,7 @@ ws1.getCell('A1').value = { text: 'Sheet2', hyperlink: '#A1:B1' };
163163
<li><a href="#数据验证">数据验证</a></li>
164164
<li><a href="#单元格注释">单元格注释</a></li>
165165
<li><a href="#表格">表格</a></li>
166+
<li><a href="#透视表">透视表</a></li>
166167
<li><a href="#样式">样式</a>
167168
<ul>
168169
<li><a href="#数字格式">数字格式</a></li>
@@ -1477,6 +1478,33 @@ column.totalsRowResult = 10;
14771478
table.commit();
14781479
```
14791480

1481+
## 透视表[](#目录)<!-- Link generated with jump2header -->
1482+
## 新增透视表到工作表
1483+
```javascript
1484+
const worksheet1 = workbook.addWorksheet('Sheet1');
1485+
worksheet1.addRows([
1486+
['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H'],
1487+
['a1', 'b1', 'c1', 'd1', 'e1', 'f1', 4, 5],
1488+
['a1', 'b2', 'c1', 'd2', 'e1', 'f1', 4, 5],
1489+
['a2', 'b1', 'c2', 'd1', 'e2', 'f1', 14, 24],
1490+
['a2', 'b2', 'c2', 'd2', 'e2', 'f2', 24, 35],
1491+
['a3', 'b1', 'c3', 'd1', 'e3', 'f2', 34, 45],
1492+
['a3', 'b2', 'c3', 'd2', 'e3', 'f2', 44, 45],
1493+
]);
1494+
1495+
const worksheet2 = workbook.addWorksheet('Sheet2');
1496+
worksheet2.addPivotTable({
1497+
// Source of data: the entire sheet range is taken;
1498+
// akin to `worksheet1.getSheetValues()`.
1499+
sourceSheet: worksheet1,
1500+
// Pivot table fields: values indicate field names;
1501+
// they come from the first row in `worksheet1`.
1502+
rows: ['A', 'B', 'E'],
1503+
columns: ['C', 'D'],
1504+
values: ['H'],
1505+
metric: 'sum', // only 'sum' possible for now
1506+
});
1507+
```
14801508

14811509
## 样式[](#目录)<!-- Link generated with jump2header -->
14821510

index.d.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1184,6 +1184,14 @@ export interface ConditionalFormattingOptions {
11841184
rules: ConditionalFormattingRule[];
11851185
}
11861186

1187+
export interface AddPivotTableOptions {
1188+
sourceSheet: Worksheet;
1189+
rows: string[];
1190+
columns: string[];
1191+
values: string[];
1192+
metric: 'sum';
1193+
}
1194+
11871195
export interface Worksheet {
11881196
readonly id: number;
11891197
name: string;
@@ -1501,6 +1509,11 @@ export interface Worksheet {
15011509
* delete conditionalFormattingOptions
15021510
*/
15031511
removeConditionalFormatting(filter: any): void;
1512+
1513+
/**
1514+
* add pivot table
1515+
*/
1516+
addPivotTable(options: AddPivotTableOptions): void;
15041517
}
15051518

15061519
export interface CalculationProperties {

lib/doc/pivot-table.js

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
const {objectFromProps, range, toSortedArray} = require('../utils/utils');
2+
3+
// TK(2023-10-10): turn this into a class constructor.
4+
5+
function makePivotTable(worksheet, model) {
6+
// Example `model`:
7+
// {
8+
// // Source of data: the entire sheet range is taken,
9+
// // akin to `worksheet1.getSheetValues()`.
10+
// sourceSheet: worksheet1,
11+
//
12+
// // Pivot table fields: values indicate field names;
13+
// // they come from the first row in `worksheet1`.
14+
// rows: ['A', 'B'],
15+
// columns: ['C'],
16+
// values: ['E'], // only 1 item possible for now
17+
// metric: 'sum', // only 'sum' possible for now
18+
// }
19+
20+
validate(worksheet, model);
21+
22+
const {sourceSheet} = model;
23+
let {rows, columns, values} = model;
24+
25+
const cacheFields = makeCacheFields(sourceSheet, [...rows, ...columns]);
26+
27+
// let {rows, columns, values} use indices instead of names;
28+
// names can then be accessed via `pivotTable.cacheFields[index].name`.
29+
// *Note*: Using `reduce` as `Object.fromEntries` requires Node 12+;
30+
// ExcelJS is >=8.3.0 (as of 2023-10-08).
31+
const nameToIndex = cacheFields.reduce((result, cacheField, index) => {
32+
result[cacheField.name] = index;
33+
return result;
34+
}, {});
35+
rows = rows.map(row => nameToIndex[row]);
36+
columns = columns.map(column => nameToIndex[column]);
37+
values = values.map(value => nameToIndex[value]);
38+
39+
// form pivot table object
40+
return {
41+
sourceSheet,
42+
rows,
43+
columns,
44+
values,
45+
metric: 'sum',
46+
cacheFields,
47+
// defined in <pivotTableDefinition> of xl/pivotTables/pivotTable1.xml;
48+
// also used in xl/workbook.xml
49+
cacheId: '10',
50+
};
51+
}
52+
53+
function validate(worksheet, model) {
54+
if (worksheet.workbook.pivotTables.length === 1) {
55+
throw new Error(
56+
'A pivot table was already added. At this time, ExcelJS supports at most one pivot table per file.'
57+
);
58+
}
59+
60+
if (model.metric && model.metric !== 'sum') {
61+
throw new Error('Only the "sum" metric is supported at this time.');
62+
}
63+
64+
const headerNames = model.sourceSheet.getRow(1).values.slice(1);
65+
const isInHeaderNames = objectFromProps(headerNames, true);
66+
for (const name of [...model.rows, ...model.columns, ...model.values]) {
67+
if (!isInHeaderNames[name]) {
68+
throw new Error(`The header name "${name}" was not found in ${model.sourceSheet.name}.`);
69+
}
70+
}
71+
72+
if (!model.rows.length) {
73+
throw new Error('No pivot table rows specified.');
74+
}
75+
76+
if (model.values.length < 1) {
77+
throw new Error('Must have at least one value.');
78+
}
79+
80+
if (model.values.length > 1 && model.columns.length > 0) {
81+
throw new Error(
82+
'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.'
83+
);
84+
}
85+
}
86+
87+
function makeCacheFields(worksheet, fieldNamesWithSharedItems) {
88+
// Cache fields are used in pivot tables to reference source data.
89+
//
90+
// Example
91+
// -------
92+
// Turn
93+
//
94+
// `worksheet` sheet values [
95+
// ['A', 'B', 'C', 'D', 'E'],
96+
// ['a1', 'b1', 'c1', 4, 5],
97+
// ['a1', 'b2', 'c1', 4, 5],
98+
// ['a2', 'b1', 'c2', 14, 24],
99+
// ['a2', 'b2', 'c2', 24, 35],
100+
// ['a3', 'b1', 'c3', 34, 45],
101+
// ['a3', 'b2', 'c3', 44, 45]
102+
// ];
103+
// fieldNamesWithSharedItems = ['A', 'B', 'C'];
104+
//
105+
// into
106+
//
107+
// [
108+
// { name: 'A', sharedItems: ['a1', 'a2', 'a3'] },
109+
// { name: 'B', sharedItems: ['b1', 'b2'] },
110+
// { name: 'C', sharedItems: ['c1', 'c2', 'c3'] },
111+
// { name: 'D', sharedItems: null },
112+
// { name: 'E', sharedItems: null }
113+
// ]
114+
115+
const names = worksheet.getRow(1).values;
116+
const nameToHasSharedItems = objectFromProps(fieldNamesWithSharedItems, true);
117+
118+
const aggregate = columnIndex => {
119+
const columnValues = worksheet.getColumn(columnIndex).values.splice(2);
120+
const columnValuesAsSet = new Set(columnValues);
121+
return toSortedArray(columnValuesAsSet);
122+
};
123+
124+
// make result
125+
const result = [];
126+
for (const columnIndex of range(1, names.length)) {
127+
const name = names[columnIndex];
128+
const sharedItems = nameToHasSharedItems[name] ? aggregate(columnIndex) : null;
129+
result.push({name, sharedItems});
130+
}
131+
return result;
132+
}
133+
134+
module.exports = {makePivotTable};

lib/doc/workbook.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ class Workbook {
2727
this.title = '';
2828
this.views = [];
2929
this.media = [];
30+
this.pivotTables = [];
3031
this._definedNames = new DefinedNames();
3132
}
3233

@@ -174,6 +175,7 @@ class Workbook {
174175
contentStatus: this.contentStatus,
175176
themes: this._themes,
176177
media: this.media,
178+
pivotTables: this.pivotTables,
177179
calcProperties: this.calcProperties,
178180
};
179181
}
@@ -215,6 +217,7 @@ class Workbook {
215217
this.views = value.views;
216218
this._themes = value.themes;
217219
this.media = value.media || [];
220+
this.pivotTables = value.pivotTables || [];
218221
}
219222
}
220223

lib/doc/worksheet.js

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ const Enums = require('./enums');
88
const Image = require('./image');
99
const Table = require('./table');
1010
const DataValidations = require('./data-validations');
11+
const {makePivotTable} = require('./pivot-table');
1112
const Encryptor = require('../utils/encryptor');
1213
const {copyStyle} = require('../utils/copy-style');
1314
const ColumnFlatter = require('../utils/column-flatter');
@@ -126,6 +127,8 @@ class Worksheet {
126127
// for tables
127128
this.tables = {};
128129

130+
this.pivotTables = [];
131+
129132
this.conditionalFormattings = [];
130133
}
131134

@@ -808,6 +811,23 @@ class Worksheet {
808811
return Object.values(this.tables);
809812
}
810813

814+
// =========================================================================
815+
// Pivot Tables
816+
addPivotTable(model) {
817+
// eslint-disable-next-line no-console
818+
console.warn(
819+
`Warning: Pivot Table support is experimental.
820+
Please leave feedback at https://github.com/exceljs/exceljs/discussions/2575`
821+
);
822+
823+
const pivotTable = makePivotTable(this, model);
824+
825+
this.pivotTables.push(pivotTable);
826+
this.workbook.pivotTables.push(pivotTable);
827+
828+
return pivotTable;
829+
}
830+
811831
// ===========================================================================
812832
// Conditional Formatting
813833
addConditionalFormatting(cf) {
@@ -857,6 +877,7 @@ class Worksheet {
857877
media: this._media.map(medium => medium.model),
858878
sheetProtection: this.sheetProtection,
859879
tables: Object.values(this.tables).map(table => table.model),
880+
pivotTables: this.pivotTables,
860881
conditionalFormattings: this.conditionalFormattings,
861882
};
862883

@@ -923,6 +944,7 @@ class Worksheet {
923944
tables[table.name] = t;
924945
return tables;
925946
}, {});
947+
this.pivotTables = value.pivotTables;
926948
this.conditionalFormattings = value.conditionalFormattings;
927949
}
928950

lib/utils/utils.js

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -167,6 +167,37 @@ const utils = {
167167
parseBoolean(value) {
168168
return value === true || value === 'true' || value === 1 || value === '1';
169169
},
170+
171+
*range(start, stop, step = 1) {
172+
const compareOrder = step > 0 ? (a, b) => a < b : (a, b) => a > b;
173+
for (let value = start; compareOrder(value, stop); value += step) {
174+
yield value;
175+
}
176+
},
177+
178+
toSortedArray(values) {
179+
const result = Array.from(values);
180+
181+
// Note: per default, `Array.prototype.sort()` converts values
182+
// to strings when comparing. Here, if we have numbers, we use
183+
// numeric sort.
184+
if (result.every(item => Number.isFinite(item))) {
185+
const compareNumbers = (a, b) => a - b;
186+
return result.sort(compareNumbers);
187+
}
188+
189+
return result.sort();
190+
},
191+
192+
objectFromProps(props, value = null) {
193+
// *Note*: Using `reduce` as `Object.fromEntries` requires Node 12+;
194+
// ExcelJs is >=8.3.0 (as of 2023-10-08).
195+
// return Object.fromEntries(props.map(property => [property, value]));
196+
return props.reduce((result, property) => {
197+
result[property] = value;
198+
return result;
199+
}, {});
200+
},
170201
};
171202

172203
module.exports = utils;

0 commit comments

Comments
 (0)