From 297a39ac5cd2640605997af593793ca890078339 Mon Sep 17 00:00:00 2001 From: linxl <1658370535@qq.com> Date: Thu, 16 Jan 2025 15:01:31 +0800 Subject: [PATCH 1/4] feat: add pivot table func --- .github/workflows/tests.yml | 2 +- .prettier | 1 - index.d.ts | 13 ++ lib/doc/pivot-table.js | 134 ++++++++++++ lib/doc/workbook.js | 3 + lib/doc/worksheet.js | 22 ++ lib/utils/utils.js | 31 +++ lib/xlsx/rel-type.js | 3 + .../xform/book/workbook-pivot-cache-xform.js | 29 +++ lib/xlsx/xform/book/workbook-xform.js | 7 + lib/xlsx/xform/core/content-types-xform.js | 16 ++ lib/xlsx/xform/pivot-table/cache-field.js | 44 ++++ .../pivot-cache-definition-xform.js | 77 +++++++ .../pivot-table/pivot-cache-records-xform.js | 103 +++++++++ .../xform/pivot-table/pivot-table-xform.js | 202 ++++++++++++++++++ lib/xlsx/xform/sheet/worksheet-xform.js | 9 + lib/xlsx/xlsx.js | 78 +++++++ .../integration/workbook/pivot-tables.spec.js | 78 +++++++ test/test-pivot-table.js | 55 +++++ 19 files changed, 905 insertions(+), 2 deletions(-) create mode 100644 lib/doc/pivot-table.js create mode 100644 lib/xlsx/xform/book/workbook-pivot-cache-xform.js create mode 100644 lib/xlsx/xform/pivot-table/cache-field.js create mode 100644 lib/xlsx/xform/pivot-table/pivot-cache-definition-xform.js create mode 100644 lib/xlsx/xform/pivot-table/pivot-cache-records-xform.js create mode 100644 lib/xlsx/xform/pivot-table/pivot-table-xform.js create mode 100644 spec/integration/workbook/pivot-tables.spec.js create mode 100644 test/test-pivot-table.js diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index e60048d..a1bb513 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 }} 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/index.d.ts b/index.d.ts index 5434cf1..8058997 100644 --- a/index.d.ts +++ b/index.d.ts @@ -1501,6 +1501,19 @@ export interface Worksheet { * delete conditionalFormattingOptions */ removeConditionalFormatting(filter: any): void; + + /** + * add pivot table + */ + addPivotTable(options: AddPivotTableOptions): void; +} + +interface AddPivotTableOptions { + sourceSheet: Worksheet; + rows: string[]; + columns: string[]; + values: string[]; + metric: 'sum'; } 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 From 34502ef6bac33aa1e6c0cc611ee2f7b82f6cc32b Mon Sep 17 00:00:00 2001 From: linxl <1658370535@qq.com> Date: Thu, 16 Jan 2025 15:31:55 +0800 Subject: [PATCH 2/4] update github ci yml --- .github/workflows/tests.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index a1bb513..bb025f1 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -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 From ae3fcd0bf5e77fd6ac57a16d5bc82779e2aaa748 Mon Sep 17 00:00:00 2001 From: linxl <1658370535@qq.com> Date: Thu, 16 Jan 2025 15:49:45 +0800 Subject: [PATCH 3/4] update index.d.ts --- index.d.ts | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/index.d.ts b/index.d.ts index 8058997..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; @@ -1508,14 +1516,6 @@ export interface Worksheet { addPivotTable(options: AddPivotTableOptions): void; } -interface AddPivotTableOptions { - sourceSheet: Worksheet; - rows: string[]; - columns: string[]; - values: string[]; - metric: 'sum'; -} - export interface CalculationProperties { /** * Whether the application shall perform a full recalculation when the workbook is opened From 5c3fe3ca140cea087ca8523aef63d2893dfe6cfb Mon Sep 17 00:00:00 2001 From: linxl <1658370535@qq.com> Date: Fri, 17 Jan 2025 10:53:29 +0800 Subject: [PATCH 4/4] add addPivotTable readme.md --- README.md | 29 ++++++++++++++++++++++++++++- README_zh.md | 28 ++++++++++++++++++++++++++++ 2 files changed, 56 insertions(+), 1 deletion(-) 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 + }); +``` ## 样式[⬆](#目录)