Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add pivot table func #34

Merged
merged 4 commits into from
Jan 17, 2025
Merged
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
5 changes: 3 additions & 2 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}

Expand All @@ -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
Expand Down
1 change: 0 additions & 1 deletion .prettier
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,5 @@
"bracketSpacing": false,
"printWidth": 100,
"trailingComma": "all",
"bracketSpacing": false,
"arrowParens": "avoid"
}
29 changes: 28 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,7 @@ To be clear, all contributions added to this library will be included in the lib
<li><a href="#data-validations">Data Validations</a></li>
<li><a href="#cell-comments">Cell Comments</a></li>
<li><a href="#tables">Tables</a></li>
<li><a href="#pivot-tables">PivotTables</a></li>
<li><a href="#styles">Styles</a>
<ul>
<li><a href="#number-formats">Number Formats</a></li>
Expand Down Expand Up @@ -1540,7 +1541,33 @@ column.totalsRowResult = 10;
// commit the table changes into the sheet
table.commit();
```

## PivotTables[⬆](#contents)<!-- Link generated with jump2header -->
## 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)<!-- Link generated with jump2header -->

Expand Down
28 changes: 28 additions & 0 deletions README_zh.md
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,7 @@ ws1.getCell('A1').value = { text: 'Sheet2', hyperlink: '#A1:B1' };
<li><a href="#数据验证">数据验证</a></li>
<li><a href="#单元格注释">单元格注释</a></li>
<li><a href="#表格">表格</a></li>
<li><a href="#透视表">透视表</a></li>
<li><a href="#样式">样式</a>
<ul>
<li><a href="#数字格式">数字格式</a></li>
Expand Down Expand Up @@ -1477,6 +1478,33 @@ column.totalsRowResult = 10;
table.commit();
```

## 透视表[⬆](#目录)<!-- Link generated with jump2header -->
## 新增透视表到工作表
```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
});
```

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

Expand Down
13 changes: 13 additions & 0 deletions index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -1501,6 +1509,11 @@ export interface Worksheet {
* delete conditionalFormattingOptions
*/
removeConditionalFormatting(filter: any): void;

/**
* add pivot table
*/
addPivotTable(options: AddPivotTableOptions): void;
}

export interface CalculationProperties {
Expand Down
134 changes: 134 additions & 0 deletions lib/doc/pivot-table.js
Original file line number Diff line number Diff line change
@@ -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 <pivotTableDefinition> 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};
3 changes: 3 additions & 0 deletions lib/doc/workbook.js
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ class Workbook {
this.title = '';
this.views = [];
this.media = [];
this.pivotTables = [];
this._definedNames = new DefinedNames();
}

Expand Down Expand Up @@ -174,6 +175,7 @@ class Workbook {
contentStatus: this.contentStatus,
themes: this._themes,
media: this.media,
pivotTables: this.pivotTables,
calcProperties: this.calcProperties,
};
}
Expand Down Expand Up @@ -215,6 +217,7 @@ class Workbook {
this.views = value.views;
this._themes = value.themes;
this.media = value.media || [];
this.pivotTables = value.pivotTables || [];
}
}

Expand Down
22 changes: 22 additions & 0 deletions lib/doc/worksheet.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -126,6 +127,8 @@ class Worksheet {
// for tables
this.tables = {};

this.pivotTables = [];

this.conditionalFormattings = [];
}

Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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,
};

Expand Down Expand Up @@ -923,6 +944,7 @@ class Worksheet {
tables[table.name] = t;
return tables;
}, {});
this.pivotTables = value.pivotTables;
this.conditionalFormattings = value.conditionalFormattings;
}

Expand Down
31 changes: 31 additions & 0 deletions lib/utils/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Loading
Loading