UNPKG

@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
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};