diff --git a/index.d.ts b/index.d.ts index cae3394bd..3aa2c1415 100644 --- a/index.d.ts +++ b/index.d.ts @@ -1381,6 +1381,8 @@ export interface Worksheet { * delete conditionalFormattingOptions */ removeConditionalFormatting(filter: any): void; + + addPivotTable(model: PivotTableModel): void; } export interface CalculationProperties { @@ -1909,6 +1911,33 @@ export interface Table extends Required { removeColumns: (colIndex: number, count: number) => void } +export interface PivotTableModel { + sourceSheet: Worksheet; + /** + * Top left cell of the source data from {@link sourceSheet} + * @default A1 + */ + sourceRef?: string; + /** + * Column names as defined in the header row, see {@link sourceRef}. + */ + rows: string[]; + /** + * Column names as defined in the header row, see {@link sourceRef}. + */ + columns: string[]; + /** + * Column names as defined in the header row, see {@link sourceRef}. + * Only 1 item is possible for now. + */ + values: string[]; + /** + * Column names as defined in the header row, see {@link sourceRef}. + * Only `sum` is possible for now. + */ + metric: "sum"; +} + export namespace config { function setValue(key: 'promise', promise: any): void; } diff --git a/lib/doc/pivot-table.js b/lib/doc/pivot-table.js index 9de9688f6..31046ef21 100644 --- a/lib/doc/pivot-table.js +++ b/lib/doc/pivot-table.js @@ -1,4 +1,5 @@ const {objectFromProps, range, toSortedArray} = require('../utils/utils'); +const colCache = require('../utils/col-cache'); // TK(2023-10-10): turn this into a class constructor. @@ -13,12 +14,17 @@ const {objectFromProps, range, toSortedArray} = require('../utils/utils'); function makePivotTable(worksheet, model) { // Example `model`: // { - // // Source of data: the entire sheet range is taken, - // // akin to `worksheet1.getSheetValues()`. + // // Source of data: the sheet range starting at `sourceRef` is used. + // // When omitted the entire used range is taken (i.e. sourceRef = "A1"). // sourceSheet: worksheet1, // + // // Optional top-left cell of the data block (header row included). + // // Use this when your data does not begin at A1. + // // e.g. sourceRef: 'C3' => headers are in row 3, data starts in row 4 + // sourceRef: 'A1', + // // // Pivot table fields: values indicate field names; - // // they come from the first row in `worksheet1`. + // // they come from the header row indicated by `sourceRef`. // rows: ['A', 'B'], // columns: ['C'], // values: ['E'], // only 1 item possible for now @@ -33,10 +39,15 @@ function makePivotTable(worksheet, model) { let {rows, columns, values, pages = []} = model; const {metric, pageDefaults = {}} = 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); + // Decode the top-left cell of the source data block (header row included). + // colCache.decodeAddress returns { col, row } matching ExcelJS conventions + // (e.g. used in Table). Defaults to A1 when sourceRef is omitted. + const {col, row} = model.sourceRef ? colCache.decodeAddress(model.sourceRef) : {col: 1, row: 1}; + + // 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(row).values.slice(col); + const cacheFields = makeCacheFields(sourceSheet, allHeaderNames, row, col); // let {rows, columns, values, pages} use indices instead of names; // names can then be accessed via `pivotTable.cacheFields[index].name`. @@ -46,7 +57,7 @@ function makePivotTable(worksheet, model) { result[cacheField.name] = index; return result; }, {}); - rows = rows.map(row => nameToIndex[row]); + rows = rows.map(r => nameToIndex[r]); columns = columns.map(column => nameToIndex[column]); values = values.map(value => nameToIndex[value]); pages = pages.map(page => nameToIndex[page]); @@ -74,6 +85,9 @@ function makePivotTable(worksheet, model) { // form pivot table object return { sourceSheet, + // Top-left cell of the source data block (1-based) + row, + col, rows, columns, values, @@ -99,7 +113,13 @@ function validate(worksheet, model) { throw new Error('Only the "sum" and "count" metric is supported at this time.'); } - const headerNames = model.sourceSheet.getRow(1).values.slice(1); + const {col, row} = model.sourceRef ? colCache.decodeAddress(model.sourceRef) : {col: 1, row: 1}; + if (model.sourceRef && (!col || !row)) { + throw new Error( + `Invalid sourceRef "${model.sourceRef}". Expected a cell reference such as "A1" or "C3".` + ); + } + const headerNames = model.sourceSheet.getRow(row).values.slice(col); const isInHeaderNames = objectFromProps(headerNames, true); const pages = model.pages || []; for (const name of [...model.rows, ...model.columns, ...model.values, ...pages]) { @@ -125,14 +145,14 @@ function validate(worksheet, model) { for (const pageField of pages) { if (allFields.includes(pageField)) { throw new Error( - `Page field "${pageField}" cannot also be used as a row, column, or value field.`, + `Page field "${pageField}" cannot also be used as a row, column, or value field.` ); } } // Validate pageDefaults reference valid page fields and values if (model.pageDefaults) { - for (const [fieldName, defaultValue] of Object.entries(model.pageDefaults)) { + for (const [fieldName] of Object.entries(model.pageDefaults)) { if (!pages.includes(fieldName)) { throw new Error(`pageDefaults field "${fieldName}" is not in the pages array.`); } @@ -140,10 +160,10 @@ function validate(worksheet, model) { } } -function makeCacheFields(worksheet, fieldNamesWithSharedItems) { +function makeCacheFields(worksheet, fieldNamesWithSharedItems, row = 1, col = 1) { // Cache fields are used in pivot tables to reference source data. // - // Example + // Example (with default row=1, col=1) // ------- // Turn // @@ -167,12 +187,20 @@ function makeCacheFields(worksheet, fieldNamesWithSharedItems) { // { name: 'D', sharedItems: null }, // { name: 'E', sharedItems: null } // ] + // + // When row/col are > 1 the header is read from that row/column + // and data values are read from (row+1) onwards in the matching column. - const names = worksheet.getRow(1).values; + // Read header names from the correct row, starting at the correct column. + const names = worksheet.getRow(row).values; const nameToHasSharedItems = objectFromProps(fieldNamesWithSharedItems, true); - const aggregate = columnIndex => { - const columnValues = worksheet.getColumn(columnIndex).values.slice(2); + // `localIndex` is 1-based relative to the header slice (first header = 1). + // The actual worksheet column is (localIndex + col - 1). + const aggregate = localIndex => { + const worksheetColIndex = localIndex + col - 1; + // slice(row + 1) skips the header row and any rows before it. + const columnValues = worksheet.getColumn(worksheetColIndex).values.slice(row + 1); // Deduplicate case-insensitively for Excel compatibility // Excel treats pivot table values as case-insensitive, so "Apple" and "apple" @@ -193,11 +221,12 @@ function makeCacheFields(worksheet, fieldNamesWithSharedItems) { return toSortedArray(uniqueValues); }; - // make result + // make result — iterate over the header columns that belong to this data block. + // `names` is the full row values array (1-based); the header block starts at col. const result = []; - for (const columnIndex of range(1, names.length)) { - const name = names[columnIndex]; - const sharedItems = nameToHasSharedItems[name] ? aggregate(columnIndex) : null; + for (const localIndex of range(1, names.length - col + 1)) { + const name = names[localIndex + col - 1]; + const sharedItems = nameToHasSharedItems[name] ? aggregate(localIndex) : null; result.push({name, sharedItems}); } return result; diff --git a/lib/xlsx/xform/pivot-table/pivot-cache-definition-xform.js b/lib/xlsx/xform/pivot-table/pivot-cache-definition-xform.js index 18f4ef379..ae961ff0d 100644 --- a/lib/xlsx/xform/pivot-table/pivot-cache-definition-xform.js +++ b/lib/xlsx/xform/pivot-table/pivot-cache-definition-xform.js @@ -1,6 +1,31 @@ const BaseXform = require('../base-xform'); const CacheField = require('./cache-field'); const XmlStream = require('../../../utils/xml-stream'); +const colCache = require('../../../utils/col-cache'); + +/** + * Build the worksheetSource `ref` attribute value. + * + * When sourceRef is provided the ref is scoped from that top-left cell to the + * last used cell of the sheet (e.g. "C3:F100"). Falls back to the full sheet + * dimensions when the data starts at A1. + * + * @param {object} model pivot table model + * @returns {string} e.g. "A1:E20" or "C3:F20" + */ +function buildSourceRef(model) { + const {sourceSheet, col, row} = model; + const dims = sourceSheet.dimensions; + + if (row === 1 && col === 1) { + // Fast path — no offset, keep existing behaviour. + return dims.shortRange; + } + + const topLeft = colCache.encodeAddress(row, col); + const bottomRight = colCache.encodeAddress(dims.bottom, dims.right); + return `${topLeft}:${bottomRight}`; +} class PivotCacheDefinitionXform extends BaseXform { constructor() { @@ -36,14 +61,16 @@ class PivotCacheDefinitionXform extends BaseXform { xmlStream.openNode('cacheSource', {type: 'worksheet'}); xmlStream.leafNode('worksheetSource', { - ref: sourceSheet.dimensions.shortRange, + ref: buildSourceRef(model), 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.writeXml( + cacheFields.map(cacheField => new CacheField(cacheField).render()).join('\n ') + ); xmlStream.closeNode(); xmlStream.closeNode(); diff --git a/lib/xlsx/xform/pivot-table/pivot-cache-records-xform.js b/lib/xlsx/xform/pivot-table/pivot-cache-records-xform.js index 1aef17c75..8bf77fbd2 100644 --- a/lib/xlsx/xform/pivot-table/pivot-cache-records-xform.js +++ b/lib/xlsx/xform/pivot-table/pivot-cache-records-xform.js @@ -20,7 +20,19 @@ class PivotCacheRecordsXform extends BaseXform { render(xmlStream, model) { const {sourceSheet, cacheFields} = model; - const sourceBodyRows = sourceSheet.getSheetValues().slice(2); + + // row/col are the 1-based coordinates of the header cell (matching the + // decodeAddress return shape used in colCache and Table). Data begins on + // row+1; columns before col are ignored. Both default to 1 so existing + // behavior is preserved when sourceRef is omitted. + const {row = 1, col = 1} = model; + + // getSheetValues() returns a sparse array where index 0 is unused and + // index 1 is row 1. We skip everything up to and including the header row. + const sourceBodyRows = sourceSheet + .getSheetValues() + .slice(row + 1) + .map(r => (r ? r.slice(col) : r)); xmlStream.openXml(XmlStream.StdDocAttributes); xmlStream.openNode(this.tag, { @@ -33,8 +45,10 @@ class PivotCacheRecordsXform extends BaseXform { // Helpers function renderTable() { - const rowsInXML = sourceBodyRows.map(row => { - const realRow = row.slice(1); + const rowsInXML = sourceBodyRows.map(r => { + // After the .slice(col) above, the data values begin at index 0 + // (the sparse-array leading undefined at index 0 is gone). + const realRow = r ? r.slice(0) : []; return [...renderRowLines(realRow)].join(''); }); return rowsInXML.join(''); @@ -77,13 +91,17 @@ class PivotCacheRecordsXform extends BaseXform { let sharedItemsIndex; if (typeof value === 'string') { const lowerValue = value.toLowerCase(); - sharedItemsIndex = sharedItems.findIndex(item => typeof item === 'string' && item.toLowerCase() === lowerValue); + sharedItemsIndex = sharedItems.findIndex( + item => typeof item === 'string' && item.toLowerCase() === lowerValue + ); } else { sharedItemsIndex = sharedItems.indexOf(value); } if (sharedItemsIndex < 0) { - throw new Error(`${JSON.stringify(value)} not in sharedItems ${JSON.stringify(sharedItems)}`); + 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 ``; diff --git a/spec/integration/workbook/pivot-tables-source-ref.spec.js b/spec/integration/workbook/pivot-tables-source-ref.spec.js new file mode 100644 index 000000000..aee8ebcd6 --- /dev/null +++ b/spec/integration/workbook/pivot-tables-source-ref.spec.js @@ -0,0 +1,190 @@ +// Tests for the `sourceRef` option on addPivotTable(), which allows the caller +// to specify the top-left cell of the data block when it does not start at A1. +const fs = require('fs'); +const {promisify} = require('util'); + +const fsReadFileAsync = promisify(fs.readFile); + +const JSZip = require('jszip'); + +const ExcelJS = verquire('exceljs'); + +const TEST_XLSX_FILEPATH = './spec/out/wb.pivot-source-ref.test.xlsx'; + +// Helper: write a workbook to disk and return parsed JSZip + raw XML strings. +async function writeAndReadZip(workbook) { + await workbook.xlsx.writeFile(TEST_XLSX_FILEPATH); + const buffer = await fsReadFileAsync(TEST_XLSX_FILEPATH); + return JSZip.loadAsync(buffer); +} + +async function xmlOf(zip, path) { + const file = zip.files[path]; + if (!file) throw new Error(`File not found in zip: ${path}`); + return file.async('string'); +} + +// Build a workbook where the source data begins at a non-A1 cell. +// The sheet has 2 "noise" rows above the header and 2 "noise" columns to the left. +// +// A B C D E +// 1 (empty) (empty) (empty) (empty) (empty) +// 2 (empty) (empty) (empty) (empty) (empty) +// 3 (empty) (empty) Region Quarter Amount +// 4 (empty) (empty) North Q1 100 +// 5 (empty) (empty) South Q1 200 +// 6 (empty) (empty) North Q2 150 +// 7 (empty) (empty) South Q2 250 +// +// sourceRef: 'C3' → header row = 3, first data column = C (index 3) +function buildOffsetWorkbook() { + const workbook = new ExcelJS.Workbook(); + const dataSheet = workbook.addWorksheet('Data'); + + // Noise rows + dataSheet.addRow([]); + dataSheet.addRow([]); + // Header at row 3, starting at column C (columns A+B left empty) + dataSheet.getRow(3).getCell('C').value = 'Region'; + dataSheet.getRow(3).getCell('D').value = 'Quarter'; + dataSheet.getRow(3).getCell('E').value = 'Amount'; + // Data rows + dataSheet.getRow(4).getCell('C').value = 'North'; + dataSheet.getRow(4).getCell('D').value = 'Q1'; + dataSheet.getRow(4).getCell('E').value = 100; + + dataSheet.getRow(5).getCell('C').value = 'South'; + dataSheet.getRow(5).getCell('D').value = 'Q1'; + dataSheet.getRow(5).getCell('E').value = 200; + + dataSheet.getRow(6).getCell('C').value = 'North'; + dataSheet.getRow(6).getCell('D').value = 'Q2'; + dataSheet.getRow(6).getCell('E').value = 150; + + dataSheet.getRow(7).getCell('C').value = 'South'; + dataSheet.getRow(7).getCell('D').value = 'Q2'; + dataSheet.getRow(7).getCell('E').value = 250; + + const pivotSheet = workbook.addWorksheet('Pivot'); + pivotSheet.addPivotTable({ + sourceSheet: dataSheet, + sourceRef: 'C3', + rows: ['Region'], + columns: ['Quarter'], + values: ['Amount'], + metric: 'sum', + }); + + return workbook; +} + +// ============================================================================= +// Tests + +describe('Workbook', () => { + describe('Pivot Tables — sourceRef option', () => { + it('writes required pivot table files when sourceRef is specified', async () => { + const workbook = buildOffsetWorkbook(); + const zip = await writeAndReadZip(workbook); + + const requiredPaths = [ + 'xl/pivotCache/pivotCacheRecords1.xml', + 'xl/pivotCache/pivotCacheDefinition1.xml', + 'xl/pivotTables/pivotTable1.xml', + ]; + for (const p of requiredPaths) { + expect(zip.files[p]).to.not.be.undefined(); + } + }); + + it('worksheetSource ref starts at the specified cell, not A1', async () => { + const workbook = buildOffsetWorkbook(); + const zip = await writeAndReadZip(workbook); + + const cacheDefXml = await xmlOf(zip, 'xl/pivotCache/pivotCacheDefinition1.xml'); + + // The worksheetSource ref should begin at C3, not A1. + expect(cacheDefXml).to.include('ref="C3:'); + expect(cacheDefXml).to.not.include('ref="A1:'); + }); + + it('cacheDefinition contains the correct field names from the offset header row', async () => { + const workbook = buildOffsetWorkbook(); + const zip = await writeAndReadZip(workbook); + + const cacheDefXml = await xmlOf(zip, 'xl/pivotCache/pivotCacheDefinition1.xml'); + + expect(cacheDefXml).to.include('Region'); + expect(cacheDefXml).to.include('Quarter'); + expect(cacheDefXml).to.include('Amount'); + }); + + it('cacheRecords contains exactly the data rows (not the header or noise rows)', async () => { + const workbook = buildOffsetWorkbook(); + const zip = await writeAndReadZip(workbook); + + const recordsXml = await xmlOf(zip, 'xl/pivotCache/pivotCacheRecords1.xml'); + + // Pivot cache records use shared-item indices () rather than + // raw strings, so we assert on structure rather than literal data values. + + // The source data block has 4 data rows (rows 4–7 in the sheet). + expect(recordsXml).to.include('count="4"'); + + // Each record row is wrapped in . + const recordCount = (recordsXml.match(//g) || []).length; + expect(recordCount).to.equal(4); + + // Header names must NOT appear as literal text in the records file — + // they live in pivotCacheDefinition, not pivotCacheRecords. + expect(recordsXml).to.not.include('Region'); + expect(recordsXml).to.not.include('Quarter'); + expect(recordsXml).to.not.include('Amount'); + }); + + it('omitting sourceRef keeps original A1 behaviour (backwards-compatible)', async () => { + const workbook = new ExcelJS.Workbook(); + const dataSheet = workbook.addWorksheet('Data'); + dataSheet.addRows([ + ['Region', 'Quarter', 'Amount'], + ['North', 'Q1', 100], + ['South', 'Q1', 200], + ]); + const pivotSheet = workbook.addWorksheet('Pivot'); + pivotSheet.addPivotTable({ + sourceSheet: dataSheet, + rows: ['Region'], + columns: ['Quarter'], + values: ['Amount'], + metric: 'sum', + }); + + const zip = await writeAndReadZip(workbook); + const cacheDefXml = await xmlOf(zip, 'xl/pivotCache/pivotCacheDefinition1.xml'); + + // Without sourceRef the ref must start at A1. + expect(cacheDefXml).to.include('ref="A1:'); + }); + + it('throws a descriptive error for an invalid sourceRef string', () => { + const workbook = new ExcelJS.Workbook(); + const dataSheet = workbook.addWorksheet('Data'); + dataSheet.addRows([ + ['Region', 'Amount'], + ['North', 100], + ]); + const pivotSheet = workbook.addWorksheet('Pivot'); + + expect(() => { + pivotSheet.addPivotTable({ + sourceSheet: dataSheet, + sourceRef: 'not-a-ref', + rows: ['Region'], + columns: ['Region'], + values: ['Amount'], + metric: 'sum', + }); + }).to.throw(/Invalid sourceRef/); + }); + }); +});