From 2abd2d8f17a4b9a731941af3f55dca8c67693b93 Mon Sep 17 00:00:00 2001 From: Mohammad Ahmadi Date: Sat, 28 Oct 2023 16:05:33 +0330 Subject: [PATCH 1/5] Fix typo in docs (#2566) --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index e70b1d2be..5b0fd8985 100644 --- a/README.md +++ b/README.md @@ -2140,7 +2140,7 @@ faster or more resilient. #### Reading XLSX[⬆](#contents) -Options supported when reading CSV files. +Options supported when reading XLSX files. | Field | Required | Type |Description | | ---------------- | ----------- | ----------- | ----------- | From 2225238343151f0c81035aa4a99895a5425d47a2 Mon Sep 17 00:00:00 2001 From: Siemienik Pawel Date: Sat, 28 Oct 2023 16:20:40 +0200 Subject: [PATCH 2/5] Remove invalid readonly for Column.values (#2571) https://github.com/exceljs/exceljs/issues/514#issuecomment-1782927522 --- index.d.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/index.d.ts b/index.d.ts index 5979806b6..81e65fec4 100644 --- a/index.d.ts +++ b/index.d.ts @@ -634,7 +634,7 @@ export interface Column { /** * The cell values in the column */ - values: ReadonlyArray; + values: CellValue; /** * Column letter key @@ -2032,4 +2032,4 @@ export namespace stream { getColumn(c: number): Column; } } -} \ No newline at end of file +} From 6141056205468314ccbd96aca41b295b9f756628 Mon Sep 17 00:00:00 2001 From: Christian Clauss Date: Tue, 31 Oct 2023 22:27:47 +0100 Subject: [PATCH 3/5] Upgrade GitHub Actions cache, checkout, setup-node (#2559) * https://github.com/actions/cache/releases * https://github.com/actions/checkout/releases * https://github.com/actions/setup-node/releases --- .github/workflows/tests.yml | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 7ae75016b..7e696d799 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -24,15 +24,15 @@ jobs: git config --global core.autocrlf false git config --global core.symlinks true if: runner.os == 'Windows' - - uses: actions/checkout@ee0669bd1cc54295c223e0bb666b733df41de1c5 #latest v2. TODO upgrade + - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 #latest v4. TODO upgrade - name: Use Node.js ${{ matrix.node-version }} - uses: actions/setup-node@7c12f8017d5436eb855f1ed4399f037a36fbd9e8 #latest v2. TODO upgrade + uses: actions/setup-node@5e21ff4d9bc1a8cf6de233a3057d20ec6b3fb69d #latest v3. TODO upgrade with: node-version: ${{ matrix.node-version }} - name: Create the npm cache directory run: mkdir npm-cache && npm config set cache ./npm-cache --global - name: Cache node modules - uses: actions/cache@8492260343ad570701412c2f464a5877dc76bace #latest v2 TODO upgrade + uses: actions/cache@704facf57e6136b1bc63b828d79edcd491f0ee84 #latest v3 TODO upgrade with: path: ./npm-cache key: v1-${{ runner.os }}-node-${{ matrix.node-version }}-npm-${{ hashFiles('**/package.json') }} @@ -49,14 +49,14 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@ee0669bd1cc54295c223e0bb666b733df41de1c5 #latest v2. TODO upgrade - - uses: actions/setup-node@7c12f8017d5436eb855f1ed4399f037a36fbd9e8 #latest v2. TODO upgrade + - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 #latest v4. TODO upgrade + - uses: actions/setup-node@5e21ff4d9bc1a8cf6de233a3057d20ec6b3fb69d #latest v3. TODO upgrade with: node-version: 18 - name: Create the npm cache directory run: mkdir npm-cache && npm config set cache ./npm-cache --global - name: Cache node modules - uses: actions/cache@8492260343ad570701412c2f464a5877dc76bace #latest v2 TODO upgrade + uses: actions/cache@704facf57e6136b1bc63b828d79edcd491f0ee84 #latest v3 TODO upgrade with: path: ./npm-cache key: v1-npm-${{ hashFiles('**/package.json') }} @@ -73,14 +73,14 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@ee0669bd1cc54295c223e0bb666b733df41de1c5 #latest v2. TODO upgrade - - uses: actions/setup-node@7c12f8017d5436eb855f1ed4399f037a36fbd9e8 #latest v2. TODO upgrade + - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 #latest v4. TODO upgrade + - uses: actions/setup-node@5e21ff4d9bc1a8cf6de233a3057d20ec6b3fb69d #latest v3. TODO upgrade with: node-version: 18 - name: Create the npm cache directory run: mkdir npm-cache && npm config set cache ./npm-cache --global - name: Cache node modules - uses: actions/cache@8492260343ad570701412c2f464a5877dc76bace #latest v2 TODO upgrade + uses: actions/cache@704facf57e6136b1bc63b828d79edcd491f0ee84 #latest v3 TODO upgrade with: path: ./npm-cache key: v1-npm-${{ hashFiles('**/package.json') }} From ddab279a882aba8f18fc5127c6d59e3aa9f596c7 Mon Sep 17 00:00:00 2001 From: Michael <216956+mikez@users.noreply.github.com> Date: Tue, 31 Oct 2023 22:58:07 +0100 Subject: [PATCH 4/5] Add pivot table with limitations (#2551) * Add pivot table with limitations ```js worksheet.addPivotTable(configuration); ``` **Note:** Pivot table support is in its early stages with certain limitations, including: - Xlsx files with existing pivot tables can't be read (writing is supported). - Pivot table configurations must have one "value"-item and use the "sum" metric. - Only one pivot table can be added for the entire document. * Update README --- .prettier | 4 +- README.md | 1 + lib/doc/pivot-table.js | 132 ++++++++++++ lib/doc/workbook.js | 3 + lib/doc/worksheet.js | 22 ++ lib/utils/utils.js | 35 +++- lib/xlsx/rel-type.js | 15 +- .../xform/book/workbook-pivot-cache-xform.js | 29 +++ lib/xlsx/xform/book/workbook-xform.js | 12 +- lib/xlsx/xform/core/content-types-xform.js | 19 +- lib/xlsx/xform/pivot-table/cache-field.js | 43 ++++ .../pivot-cache-definition-xform.js | 77 +++++++ .../pivot-table/pivot-cache-records-xform.js | 103 ++++++++++ .../xform/pivot-table/pivot-table-xform.js | 189 ++++++++++++++++++ lib/xlsx/xform/sheet/worksheet-xform.js | 9 + lib/xlsx/xlsx.js | 90 ++++++++- .../integration/workbook/pivot-tables.spec.js | 78 ++++++++ test/test-pivot-table.js | 54 +++++ 18 files changed, 893 insertions(+), 22 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/.prettier b/.prettier index 470c46807..cbd7f9818 100644 --- a/.prettier +++ b/.prettier @@ -2,6 +2,6 @@ "bracketSpacing": false, "printWidth": 100, "trailingComma": "all", - "bracketSpacing": false, - "arrowParens": "avoid" + "arrowParens": "avoid", + "singleQuote": true, } diff --git a/README.md b/README.md index 5b0fd8985..ccc780828 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,7 @@ npm install exceljs # New Features! +* Merged [Add pivot table with limitations #2551](https://github.com/exceljs/exceljs/pull/2551).
Many thanks to Protobi and Michael for this contribution! * Merged [fix: styles rendering in case when "numFmt" is present in conditional formatting rules (resolves #1814) #1815](https://github.com/exceljs/exceljs/pull/1815).
Many thanks to [@andreykrupskii](https://github.com/andreykrupskii) for this contribution! * Merged [inlineStr cell type support #1575 #1576](https://github.com/exceljs/exceljs/pull/1576).
Many thanks to [@drdmitry](https://github.com/drdmitry) for this contribution! * Merged [Fix parsing of boolean attributes #1849](https://github.com/exceljs/exceljs/pull/1849).
Many thanks to [@bno1](https://github.com/bno1) for this contribution! diff --git a/lib/doc/pivot-table.js b/lib/doc/pivot-table.js new file mode 100644 index 000000000..fb3b66405 --- /dev/null +++ b/lib/doc/pivot-table.js @@ -0,0 +1,132 @@ +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.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.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}; diff --git a/lib/doc/workbook.js b/lib/doc/workbook.js index 8e7f46ecd..dd4893a6e 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 13e414140..1855b499e 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'); @@ -124,6 +125,8 @@ class Worksheet { // for tables this.tables = {}; + this.pivotTables = []; + this.conditionalFormattings = []; } @@ -806,6 +809,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) { @@ -854,6 +874,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, }; @@ -920,6 +941,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 84cd212c2..21dd20ee9 100644 --- a/lib/utils/utils.js +++ b/lib/utils/utils.js @@ -53,9 +53,11 @@ const utils = { }, inherits, dateToExcel(d, date1904) { - return 25569 + ( d.getTime() / (24 * 3600 * 1000) ) - (date1904 ? 1462 : 0); + // eslint-disable-next-line no-mixed-operators + return 25569 + d.getTime() / (24 * 3600 * 1000) - (date1904 ? 1462 : 0); }, excelToDate(v, date1904) { + // eslint-disable-next-line no-mixed-operators const millisecondSinceEpoch = Math.round((v - 25569 + (date1904 ? 1462 : 0)) * 24 * 3600 * 1000); return new Date(millisecondSinceEpoch); }, @@ -167,6 +169,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 7cd0a3d05..c9c454b45 100644 --- a/lib/xlsx/rel-type.js +++ b/lib/xlsx/rel-type.js @@ -1,21 +1,20 @@ 'use strict'; module.exports = { - OfficeDocument: - 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument', + OfficeDocument: 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument', Worksheet: 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet', CalcChain: 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/calcChain', - SharedStrings: - 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/sharedStrings', + SharedStrings: 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/sharedStrings', Styles: 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles', Theme: 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/theme', Hyperlink: 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/hyperlink', Image: 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/image', - CoreProperties: - 'http://schemas.openxmlformats.org/package/2006/relationships/metadata/core-properties', - ExtenderProperties: - 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/extended-properties', + CoreProperties: 'http://schemas.openxmlformats.org/package/2006/relationships/metadata/core-properties', + ExtenderProperties: 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/extended-properties', 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 000000000..894c86bac --- /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 5c10a5857..c8f1c9e1b 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(), + }), }; } @@ -53,10 +59,7 @@ class WorkbookXform extends BaseXform { }); } - if ( - sheet.pageSetup && - (sheet.pageSetup.printTitlesRow || sheet.pageSetup.printTitlesColumn) - ) { + if (sheet.pageSetup && (sheet.pageSetup.printTitlesRow || sheet.pageSetup.printTitlesColumn)) { const ranges = []; if (sheet.pageSetup.printTitlesColumn) { @@ -99,6 +102,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 2999c62aa..5e8ff5564 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', @@ -53,8 +69,7 @@ class ContentTypesXform extends BaseXform { if (hasSharedStrings) { xmlStream.leafNode('Override', { PartName: '/xl/sharedStrings.xml', - ContentType: - 'application/vnd.openxmlformats-officedocument.spreadsheetml.sharedStrings+xml', + ContentType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sharedStrings+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 000000000..50f790a94 --- /dev/null +++ b/lib/xlsx/xform/pivot-table/cache-field.js @@ -0,0 +1,43 @@ +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; 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 000000000..18f4ef379 --- /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 000000000..220ec04a5 --- /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 000000000..56db810cd --- /dev/null +++ b/lib/xlsx/xform/pivot-table/pivot-table-xform.js @@ -0,0 +1,189 @@ +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.map(columnIndex => ``).join('\n ')} + + + + + + + + + + + + + + + + + `); + + xmlStream.closeNode(); + } + + parseOpen(node) { + // TK + } + + parseText(text) { + // TK + } + + parseClose(name) { + // TK + } + + reconcile(model, options) { + // TK + } +} + +// Helpers + +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 b38930042..f1fd59580 100644 --- a/lib/xlsx/xform/sheet/worksheet-xform.js +++ b/lib/xlsx/xform/sheet/worksheet-xform.js @@ -280,6 +280,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 ff2d9a117..ab62797ec 100644 --- a/lib/xlsx/xlsx.js +++ b/lib/xlsx/xlsx.js @@ -19,6 +19,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'); @@ -471,6 +474,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); @@ -520,6 +588,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({ @@ -656,6 +733,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)]); @@ -674,11 +752,13 @@ class XLSX { reject(error); }); - this.write(stream, options).then(() => { - stream.end(); - }).catch(err=>{ - reject(err); - }); + this.write(stream, options) + .then(() => { + stream.end(); + }) + .catch(err => { + reject(err); + }); }); } diff --git a/spec/integration/workbook/pivot-tables.spec.js b/spec/integration/workbook/pivot-tables.spec.js new file mode 100644 index 000000000..30758f8f2 --- /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 000000000..fa48f14ec --- /dev/null +++ b/test/test-pivot-table.js @@ -0,0 +1,54 @@ +// -------------------------------------------------- +// 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); From 5bed18b45e824f409b08456b59b87430ded023ab Mon Sep 17 00:00:00 2001 From: Jamie Buck <131281239+jbuck-lineleap@users.noreply.github.com> Date: Fri, 12 Jan 2024 08:48:37 -0500 Subject: [PATCH 5/5] fix: table column style bug and addTable colum type (#2649) * fix table column style bug and addTable colum type * make TableColumnProperties.style optional --- index.d.ts | 5 +++++ lib/doc/table.js | 2 +- test/test-table.js | 8 ++++++-- 3 files changed, 12 insertions(+), 3 deletions(-) diff --git a/index.d.ts b/index.d.ts index 81e65fec4..af7524ca7 100644 --- a/index.d.ts +++ b/index.d.ts @@ -1832,6 +1832,11 @@ export interface TableColumnProperties { * Optional formula for custom functions */ totalsRowFormula?: string; + + /** + * Styles applied to the column + */ + style?: Partial