@protobi/exceljs
Version:
Excel Workbook Manager - Temporary fork with pivot table enhancements and bug fixes pending upstream merge
165 lines (140 loc) • 5.76 kB
JavaScript
const {objectFromProps, range, toSortedArray} = require('../utils/utils');
// TK(2023-10-10): turn this into a class constructor.
// IMPORTANT: Pivot tables are NOT supported with streaming API (WorkbookWriter)
//
// Pivot tables require reading ALL source data to generate the pivot cache,
// which conflicts with streaming's one-pass write model. Excel requires complete
// pivot cache data (all unique values and all data rows) at file creation time.
//
// For large datasets, use the standard (non-streaming) Workbook API with pivot tables.
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', 'count' // only 'sum' and 'count' are possible for now
// }
validate(worksheet, model);
const {sourceSheet} = model;
let {rows, columns, values} = model;
const {metric} = model;
// Generate sharedItems for ALL fields in the source, not just the ones used by this pivot table
// This ensures Excel can properly display any field configuration
const allHeaderNames = sourceSheet.getRow(1).values.slice(1);
const cacheFields = makeCacheFields(sourceSheet, allHeaderNames);
// 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]);
// Generate unique cache ID based on the number of existing pivot tables
// Each pivot table gets its own cache ID (starting from 10)
const cacheId = String(10 + worksheet.workbook.pivotTables.length);
// form pivot table object
return {
sourceSheet,
rows,
columns,
values,
metric,
cacheFields,
// defined in <pivotTableDefinition> of xl/pivotTables/pivotTableN.xml;
// also used in xl/workbook.xml
cacheId,
// Control whether pivot table style overrides worksheet column widths
// '0' = preserve worksheet column widths (useful for custom sizing)
// '1' = apply pivot table style width/height (default Excel behavior)
applyWidthHeightFormats: model.applyWidthHeightFormats !== undefined ? model.applyWidthHeightFormats : '1',
};
}
function validate(worksheet, model) {
// Note: Multiple pivot tables are now supported
if (model.metric && model.metric !== 'sum' && model.metric !== 'count') {
throw new Error('Only the "sum" and "count" 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.columns.length) {
throw new Error('No pivot table columns specified.');
}
if (model.values.length !== 1) {
throw new Error('Exactly 1 value needs to be specified at this time.');
}
}
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.slice(2);
// Deduplicate case-insensitively for Excel compatibility
// Excel treats pivot table values as case-insensitive, so "Apple" and "apple"
// are considered the same value. We keep the first occurrence of each case-insensitive variant.
const seen = new Map(); // lowercase -> first occurrence
const uniqueValues = [];
for (const value of columnValues) {
if (value === null || value === undefined) continue;
const key = typeof value === 'string' ? value.toLowerCase() : value;
if (!seen.has(key)) {
seen.set(key, value);
uniqueValues.push(value);
}
}
return toSortedArray(uniqueValues);
};
// 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};