Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1381,6 +1381,8 @@ export interface Worksheet {
* delete conditionalFormattingOptions
*/
removeConditionalFormatting(filter: any): void;

addPivotTable(model: PivotTableModel): void;
}

export interface CalculationProperties {
Expand Down Expand Up @@ -1909,6 +1911,33 @@ export interface Table extends Required<TableProperties> {
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;
}
Expand Down
69 changes: 49 additions & 20 deletions lib/doc/pivot-table.js
Original file line number Diff line number Diff line change
@@ -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.

Expand All @@ -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
Expand All @@ -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`.
Expand All @@ -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]);
Expand Down Expand Up @@ -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,
Expand All @@ -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]) {
Expand All @@ -125,25 +145,25 @@ 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.`);
}
}
}
}

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
//
Expand All @@ -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"
Expand All @@ -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;
Expand Down
31 changes: 29 additions & 2 deletions lib/xlsx/xform/pivot-table/pivot-cache-definition-xform.js
Original file line number Diff line number Diff line change
@@ -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() {
Expand Down Expand Up @@ -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();
Expand Down
28 changes: 23 additions & 5 deletions lib/xlsx/xform/pivot-table/pivot-cache-records-xform.js
Original file line number Diff line number Diff line change
Expand Up @@ -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, {
Expand All @@ -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('');
Expand Down Expand Up @@ -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 `<x v="${sharedItemsIndex}" />`;
Expand Down
Loading