xlsx-populate
Version:
Excel XLSX parser/generator written in JavaScript with Node.js and browser support, jQuery/d3-style method chaining, and a focus on keeping existing workbook features and styles in tact.
1,179 lines (1,090 loc) • 64.5 kB
JavaScript
"use strict";
const _ = require("lodash");
const Cell = require("./Cell");
const Row = require("./Row");
const Column = require("./Column");
const Range = require("./Range");
const Relationships = require("./Relationships");
const xmlq = require("./xmlq");
const regexify = require("./regexify");
const addressConverter = require("./addressConverter");
const ArgHandler = require("./ArgHandler");
const colorIndexes = require("./colorIndexes");
const PageBreaks = require("./PageBreaks");
// Order of the nodes as defined by the spec.
const nodeOrder = [
"sheetPr", "dimension", "sheetViews", "sheetFormatPr", "cols", "sheetData",
"sheetCalcPr", "sheetProtection", "autoFilter", "protectedRanges", "scenarios", "autoFilter",
"sortState", "dataConsolidate", "customSheetViews", "mergeCells", "phoneticPr",
"conditionalFormatting", "dataValidations", "hyperlinks", "printOptions",
"pageMargins", "pageSetup", "headerFooter", "rowBreaks", "colBreaks",
"customProperties", "cellWatches", "ignoredErrors", "smartTags", "drawing",
"drawingHF", "legacyDrawing", "legacyDrawingHF", "picture", "oleObjects", "controls", "webPublishItems", "tableParts",
"extLst"
];
/**
* A worksheet.
*/
class Sheet {
// /**
// * Creates a new instance of Sheet.
// * @param {Workbook} workbook - The parent workbook.
// * @param {{}} idNode - The sheet ID node (from the parent workbook).
// * @param {{}} node - The sheet node.
// * @param {{}} [relationshipsNode] - The optional sheet relationships node.
// */
constructor(workbook, idNode, node, relationshipsNode) {
this._init(workbook, idNode, node, relationshipsNode);
}
/* PUBLIC */
/**
* Gets a value indicating whether the sheet is the active sheet in the workbook.
* @returns {boolean} True if active, false otherwise.
*//**
* Make the sheet the active sheet in the workkbok.
* @param {boolean} active - Must be set to `true`. Deactivating directly is not supported. To deactivate, you should activate a different sheet instead.
* @returns {Sheet} The sheet.
*/
active() {
return new ArgHandler('Sheet.active')
.case(() => {
return this.workbook().activeSheet() === this;
})
.case('boolean', active => {
if (!active) throw new Error("Deactivating sheet directly not supported. Activate a different sheet instead.");
this.workbook().activeSheet(this);
return this;
})
.handle(arguments);
}
/**
* Get the active cell in the sheet.
* @returns {Cell} The active cell.
*//**
* Set the active cell in the workbook.
* @param {string|Cell} cell - The cell or address of cell to activate.
* @returns {Sheet} The sheet.
*//**
* Set the active cell in the workbook by row and column.
* @param {number} rowNumber - The row number of the cell.
* @param {string|number} columnNameOrNumber - The column name or number of the cell.
* @returns {Sheet} The sheet.
*/
activeCell() {
const sheetViewNode = this._getOrCreateSheetViewNode();
let selectionNode = xmlq.findChild(sheetViewNode, "selection");
return new ArgHandler('Sheet.activeCell')
.case(() => {
const cellAddress = selectionNode ? selectionNode.attributes.activeCell : "A1";
return this.cell(cellAddress);
})
.case(['number', '*'], (rowNumber, columnNameOrNumber) => {
const cell = this.cell(rowNumber, columnNameOrNumber);
return this.activeCell(cell);
})
.case('*', cell => {
if (!selectionNode) {
selectionNode = {
name: "selection",
attributes: {},
children: []
};
xmlq.appendChild(sheetViewNode, selectionNode);
}
if (!(cell instanceof Cell)) cell = this.cell(cell);
selectionNode.attributes.activeCell = selectionNode.attributes.sqref = cell.address();
return this;
})
.handle(arguments);
}
/**
* Gets the cell with the given address.
* @param {string} address - The address of the cell.
* @returns {Cell} The cell.
*//**
* Gets the cell with the given row and column numbers.
* @param {number} rowNumber - The row number of the cell.
* @param {string|number} columnNameOrNumber - The column name or number of the cell.
* @returns {Cell} The cell.
*/
cell() {
return new ArgHandler('Sheet.cell')
.case('string', address => {
const ref = addressConverter.fromAddress(address);
if (ref.type !== 'cell') throw new Error('Sheet.cell: Invalid address.');
return this.row(ref.rowNumber).cell(ref.columnNumber);
})
.case(['number', '*'], (rowNumber, columnNameOrNumber) => {
return this.row(rowNumber).cell(columnNameOrNumber);
})
.handle(arguments);
}
/**
* Gets a column in the sheet.
* @param {string|number} columnNameOrNumber - The name or number of the column.
* @returns {Column} The column.
*/
column(columnNameOrNumber) {
const columnNumber = typeof columnNameOrNumber === "string" ? addressConverter.columnNameToNumber(columnNameOrNumber) : columnNameOrNumber;
// If we're already created a column for this column number, return it.
if (this._columns[columnNumber]) return this._columns[columnNumber];
// We need to create a new column, which requires a backing col node. There may already exist a node whose min/max cover our column.
// First, see if there is an existing col node.
const existingColNode = this._colNodes[columnNumber];
let colNode;
if (existingColNode) {
// If the existing node covered earlier columns than the new one, we need to have a col node to cover the min up to our new node.
if (existingColNode.attributes.min < columnNumber) {
// Clone the node and set the max to the column before our new col.
const beforeColNode = _.cloneDeep(existingColNode);
beforeColNode.attributes.max = columnNumber - 1;
// Update the col nodes cache.
for (let i = beforeColNode.attributes.min; i <= beforeColNode.attributes.max; i++) {
this._colNodes[i] = beforeColNode;
}
}
// Make a clone for the new column. Set the min/max to the column number and cache it.
colNode = _.cloneDeep(existingColNode);
colNode.attributes.min = columnNumber;
colNode.attributes.max = columnNumber;
this._colNodes[columnNumber] = colNode;
// If the max of the existing node is greater than the nre one, create a col node for that too.
if (existingColNode.attributes.max > columnNumber) {
const afterColNode = _.cloneDeep(existingColNode);
afterColNode.attributes.min = columnNumber + 1;
for (let i = afterColNode.attributes.min; i <= afterColNode.attributes.max; i++) {
this._colNodes[i] = afterColNode;
}
}
} else {
// The was no existing node so create a new one.
colNode = {
name: 'col',
attributes: {
min: columnNumber,
max: columnNumber
},
children: []
};
this._colNodes[columnNumber] = colNode;
}
// Create the new column and cache it.
const column = new Column(this, colNode);
this._columns[columnNumber] = column;
return column;
}
/**
* Gets a defined name scoped to the sheet.
* @param {string} name - The defined name.
* @returns {undefined|string|Cell|Range|Row|Column} What the defined name refers to or undefined if not found. Will return the string formula if not a Row, Column, Cell, or Range.
*//**
* Set a defined name scoped to the sheet.
* @param {string} name - The defined name.
* @param {string|Cell|Range|Row|Column} refersTo - What the name refers to.
* @returns {Workbook} The workbook.
*/
definedName() {
return new ArgHandler("Workbook.definedName")
.case('string', name => {
return this.workbook().scopedDefinedName(this, name);
})
.case(['string', '*'], (name, refersTo) => {
this.workbook().scopedDefinedName(this, name, refersTo);
return this;
})
.handle(arguments);
}
/**
* Deletes the sheet and returns the parent workbook.
* @returns {Workbook} The workbook.
*/
delete() {
this.workbook().deleteSheet(this);
return this.workbook();
}
/**
* Find the given pattern in the sheet and optionally replace it.
* @param {string|RegExp} pattern - The pattern to look for. Providing a string will result in a case-insensitive substring search. Use a RegExp for more sophisticated searches.
* @param {string|function} [replacement] - The text to replace or a String.replace callback function. If pattern is a string, all occurrences of the pattern in each cell will be replaced.
* @returns {Array.<Cell>} The matching cells.
*/
find(pattern, replacement) {
pattern = regexify(pattern);
let matches = [];
this._rows.forEach(row => {
if (!row) return;
matches = matches.concat(row.find(pattern, replacement));
});
return matches;
}
/**
* Gets a value indicating whether this sheet's grid lines are visible.
* @returns {boolean} True if selected, false if not.
*//**
* Sets whether this sheet's grid lines are visible.
* @param {boolean} selected - True to make visible, false to hide.
* @returns {Sheet} The sheet.
*/
gridLinesVisible() {
const sheetViewNode = this._getOrCreateSheetViewNode();
return new ArgHandler('Sheet.gridLinesVisible')
.case(() => {
return sheetViewNode.attributes.showGridLines === 1 || sheetViewNode.attributes.showGridLines === undefined;
})
.case('boolean', visible => {
sheetViewNode.attributes.showGridLines = visible ? 1 : 0;
return this;
})
.handle(arguments);
}
/**
* Gets a value indicating if the sheet is hidden or not.
* @returns {boolean|string} True if hidden, false if visible, and 'very' if very hidden.
*//**
* Set whether the sheet is hidden or not.
* @param {boolean|string} hidden - True to hide, false to show, and 'very' to make very hidden.
* @returns {Sheet} The sheet.
*/
hidden() {
return new ArgHandler('Sheet.hidden')
.case(() => {
if (this._idNode.attributes.state === 'hidden') return true;
if (this._idNode.attributes.state === 'veryHidden') return "very";
return false;
})
.case('*', hidden => {
if (hidden) {
const visibleSheets = _.filter(this.workbook().sheets(), sheet => !sheet.hidden());
if (visibleSheets.length === 1 && visibleSheets[0] === this) {
throw new Error("This sheet may not be hidden as a workbook must contain at least one visible sheet.");
}
// If activate, activate the first other visible sheet.
if (this.active()) {
const activeIndex = visibleSheets[0] === this ? 1 : 0;
visibleSheets[activeIndex].active(true);
}
}
if (hidden === 'very') this._idNode.attributes.state = 'veryHidden';
else if (hidden) this._idNode.attributes.state = 'hidden';
else delete this._idNode.attributes.state;
return this;
})
.handle(arguments);
}
/**
* Move the sheet.
* @param {number|string|Sheet} [indexOrBeforeSheet] The index to move the sheet to or the sheet (or name of sheet) to move this sheet before. Omit this argument to move to the end of the workbook.
* @returns {Sheet} The sheet.
*/
move(indexOrBeforeSheet) {
this.workbook().moveSheet(this, indexOrBeforeSheet);
return this;
}
/**
* Get the name of the sheet.
* @returns {string} The sheet name.
*//**
* Set the name of the sheet. *Note: this method does not rename references to the sheet so formulas, etc. can be broken. Use with caution!*
* @param {string} name - The name to set to the sheet.
* @returns {Sheet} The sheet.
*/
name() {
return new ArgHandler('Sheet.name')
.case(() => {
return `${this._idNode.attributes.name}`;
})
.case('string', name => {
this._idNode.attributes.name = name;
return this;
})
.handle(arguments);
}
/**
* Gets a range from the given range address.
* @param {string} address - The range address (e.g. 'A1:B3').
* @returns {Range} The range.
*//**
* Gets a range from the given cells or cell addresses.
* @param {string|Cell} startCell - The starting cell or cell address (e.g. 'A1').
* @param {string|Cell} endCell - The ending cell or cell address (e.g. 'B3').
* @returns {Range} The range.
*//**
* Gets a range from the given row numbers and column names or numbers.
* @param {number} startRowNumber - The starting cell row number.
* @param {string|number} startColumnNameOrNumber - The starting cell column name or number.
* @param {number} endRowNumber - The ending cell row number.
* @param {string|number} endColumnNameOrNumber - The ending cell column name or number.
* @returns {Range} The range.
*/
range() {
return new ArgHandler('Sheet.range')
.case('string', address => {
const ref = addressConverter.fromAddress(address);
if (ref.type !== 'range') throw new Error('Sheet.range: Invalid address');
return this.range(ref.startRowNumber, ref.startColumnNumber, ref.endRowNumber, ref.endColumnNumber);
})
.case(['*', '*'], (startCell, endCell) => {
if (typeof startCell === "string") startCell = this.cell(startCell);
if (typeof endCell === "string") endCell = this.cell(endCell);
return new Range(startCell, endCell);
})
.case(['number', '*', 'number', '*'], (startRowNumber, startColumnNameOrNumber, endRowNumber, endColumnNameOrNumber) => {
return this.range(this.cell(startRowNumber, startColumnNameOrNumber), this.cell(endRowNumber, endColumnNameOrNumber));
})
.handle(arguments);
}
/**
* Unsets sheet autoFilter.
* @returns {Sheet} This sheet.
*//**
* Sets sheet autoFilter to a Range.
* @param {Range} range - The autoFilter range.
* @returns {Sheet} This sheet.
*/
autoFilter(range) {
this._autoFilter = range;
return this;
}
/**
* Gets the row with the given number.
* @param {number} rowNumber - The row number.
* @returns {Row} The row with the given number.
*/
row(rowNumber) {
if (rowNumber < 1) throw new RangeError(`Invalid row number ${rowNumber}. Remember that spreadsheets use 1-based indexing.`);
if (this._rows[rowNumber]) return this._rows[rowNumber];
const rowNode = {
name: 'row',
attributes: {
r: rowNumber
},
children: []
};
const row = new Row(this, rowNode);
this._rows[rowNumber] = row;
return row;
}
/**
* Get the tab color. (See style [Color](#color).)
* @returns {undefined|Color} The color or undefined if not set.
*//**
* Sets the tab color. (See style [Color](#color).)
* @returns {Color|string|number} color - Color of the tab. If string, will set an RGB color. If number, will set a theme color.
*/
tabColor() {
return new ArgHandler("Sheet.tabColor")
.case(() => {
const tabColorNode = xmlq.findChild(this._sheetPrNode, "tabColor");
if (!tabColorNode) return;
const color = {};
if (tabColorNode.attributes.hasOwnProperty('rgb')) color.rgb = tabColorNode.attributes.rgb;
else if (tabColorNode.attributes.hasOwnProperty('theme')) color.theme = tabColorNode.attributes.theme;
else if (tabColorNode.attributes.hasOwnProperty('indexed')) color.rgb = colorIndexes[tabColorNode.attributes.indexed];
if (tabColorNode.attributes.hasOwnProperty('tint')) color.tint = tabColorNode.attributes.tint;
return color;
})
.case("string", rgb => this.tabColor({ rgb }))
.case("integer", theme => this.tabColor({ theme }))
.case("nil", () => {
xmlq.removeChild(this._sheetPrNode, "tabColor");
return this;
})
.case("object", color => {
const tabColorNode = xmlq.appendChildIfNotFound(this._sheetPrNode, "tabColor");
xmlq.setAttributes(tabColorNode, {
rgb: color.rgb && color.rgb.toUpperCase(),
indexed: null,
theme: color.theme,
tint: color.tint
});
return this;
})
.handle(arguments);
}
/**
* Gets a value indicating whether this sheet is selected.
* @returns {boolean} True if selected, false if not.
*//**
* Sets whether this sheet is selected.
* @param {boolean} selected - True to select, false to deselected.
* @returns {Sheet} The sheet.
*/
tabSelected() {
const sheetViewNode = this._getOrCreateSheetViewNode();
return new ArgHandler('Sheet.tabSelected')
.case(() => {
return sheetViewNode.attributes.tabSelected === 1;
})
.case('boolean', selected => {
if (selected) sheetViewNode.attributes.tabSelected = 1;
else delete sheetViewNode.attributes.tabSelected;
return this;
})
.handle(arguments);
}
/**
* Gets a value indicating whether this sheet is rtl (Right To Left).
* @returns {boolean} True if rtl, false if ltr.
*//**
* Sets whether this sheet is rtl.
* @param {boolean} rtl - True to rtl, false to ltr (Left To Right).
* @returns {Sheet} The sheet.
*/
rightToLeft() {
const sheetViewNode = this._getOrCreateSheetViewNode();
return new ArgHandler('Sheet.rightToLeft')
.case(() => {
return sheetViewNode.attributes.rightToLeft;
})
.case('boolean', rtl => {
if (rtl) sheetViewNode.attributes.rightToLeft = true;
else delete sheetViewNode.attributes.rightToLeft;
return this;
})
.handle(arguments);
}
/**
* Get the range of cells in the sheet that have contained a value or style at any point. Useful for extracting the entire sheet contents.
* @returns {Range|undefined} The used range or undefined if no cells in the sheet are used.
*/
usedRange() {
const minRowNumber = _.findIndex(this._rows);
const maxRowNumber = this._rows.length - 1;
let minColumnNumber = 0;
let maxColumnNumber = 0;
for (let i = 0; i < this._rows.length; i++) {
const row = this._rows[i];
if (!row) continue;
const minUsedColumnNumber = row.minUsedColumnNumber();
const maxUsedColumnNumber = row.maxUsedColumnNumber();
if (minUsedColumnNumber > 0 && (!minColumnNumber || minUsedColumnNumber < minColumnNumber)) minColumnNumber = minUsedColumnNumber;
if (maxUsedColumnNumber > 0 && (!maxColumnNumber || maxUsedColumnNumber > maxColumnNumber)) maxColumnNumber = maxUsedColumnNumber;
}
// Return undefined if nothing in the sheet is used.
if (minRowNumber <= 0 || minColumnNumber <= 0 || maxRowNumber <= 0 || maxColumnNumber <= 0) return;
return this.range(minRowNumber, minColumnNumber, maxRowNumber, maxColumnNumber);
}
/**
* Gets the parent workbook.
* @returns {Workbook} The parent workbook.
*/
workbook() {
return this._workbook;
}
/**
* Gets all page breaks.
* @returns {{}} the object holds both vertical and horizontal PageBreaks.
*/
pageBreaks() {
return this._pageBreaks;
}
/**
* Gets the vertical page breaks.
* @returns {PageBreaks} vertical PageBreaks.
*/
verticalPageBreaks() {
return this._pageBreaks.colBreaks;
}
/**
* Gets the horizontal page breaks.
* @returns {PageBreaks} horizontal PageBreaks.
*/
horizontalPageBreaks() {
return this._pageBreaks.rowBreaks;
}
/* INTERNAL */
/**
* Clear cells that are using a given shared formula ID.
* @param {number} sharedFormulaId - The shared formula ID.
* @returns {undefined}
* @ignore
*/
clearCellsUsingSharedFormula(sharedFormulaId) {
this._rows.forEach(row => {
if (!row) return;
row.clearCellsUsingSharedFormula(sharedFormulaId);
});
}
/**
* Get an existing column style ID.
* @param {number} columnNumber - The column number.
* @returns {undefined|number} The style ID.
* @ignore
*/
existingColumnStyleId(columnNumber) {
// This will work after setting Column.style because Column updates the attributes live.
const colNode = this._colNodes[columnNumber];
return colNode && colNode.attributes.style;
}
/**
* Call a callback for each column number that has a node defined for it.
* @param {Function} callback - The callback.
* @returns {undefined}
* @ignore
*/
forEachExistingColumnNumber(callback) {
_.forEach(this._colNodes, (node, columnNumber) => {
if (!node) return;
callback(columnNumber);
});
}
/**
* Call a callback for each existing row.
* @param {Function} callback - The callback.
* @returns {undefined}
* @ignore
*/
forEachExistingRow(callback) {
_.forEach(this._rows, (row, rowNumber) => {
if (row) callback(row, rowNumber);
});
return this;
}
/**
* Get the hyperlink attached to the cell with the given address.
* @param {string} address - The address of the hyperlinked cell.
* @returns {string|undefined} The hyperlink or undefined if not set.
*//**
* Set the hyperlink on the cell with the given address.
* @param {string} address - The address of the hyperlinked cell.
* @param {string} hyperlink - The hyperlink to set or undefined to clear.
* @param {boolean} [internal] - The flag to force hyperlink to be internal. If true, then autodetect is skipped.
* @returns {Sheet} The sheet.
*//**
* Set the hyperlink on the cell with the given address. If opts is a Cell an internal hyperlink is added.
* @param {string} address - The address of the hyperlinked cell.
* @param {object|Cell} opts - Options.
* @returns {Sheet} The sheet.
* @ignore
*//**
* Set the hyperlink on the cell with the given address and options.
* @param {string} address - The address of the hyperlinked cell.
* @param {{}|Cell} opts - Options or Cell. If opts is a Cell then an internal hyperlink is added.
* @param {string|Cell} [opts.hyperlink] - The hyperlink to set, can be a Cell or an internal/external string.
* @param {string} [opts.tooltip] - Additional text to help the user understand more about the hyperlink.
* @param {string} [opts.email] - Email address, ignored if opts.hyperlink is set.
* @param {string} [opts.emailSubject] - Email subject, ignored if opts.hyperlink is set.
* @returns {Sheet} The sheet.
*/
hyperlink() {
return new ArgHandler('Sheet.hyperlink')
.case('string', address => {
const hyperlinkNode = this._hyperlinks[address];
if (!hyperlinkNode) return;
const relationship = this._relationships.findById(hyperlinkNode.attributes['r:id']);
return relationship && relationship.attributes.Target;
})
.case(['string', 'nil'], address => {
// TODO: delete relationship
delete this._hyperlinks[address];
return this;
})
.case(['string', 'string'], (address, hyperlink) => {
return this.hyperlink(address, hyperlink, false);
})
.case(['string', 'string', 'boolean'], (address, hyperlink, internal) => {
const isHyperlinkInternalAddress = internal || addressConverter.fromAddress(hyperlink);
let nodeAttributes;
if (isHyperlinkInternalAddress) {
nodeAttributes = {
ref: address,
location: hyperlink,
display: hyperlink
};
} else {
const relationship = this._relationships.add("hyperlink", hyperlink, "External");
nodeAttributes = {
ref: address,
'r:id': relationship.attributes.Id
};
}
this._hyperlinks[address] = {
name: 'hyperlink',
attributes: nodeAttributes,
children: []
};
return this;
})
.case(['string', 'object'], (address, opts) => {
if (opts instanceof Cell) {
const cell = opts;
const hyperlink = cell.address({ includeSheetName: true });
this.hyperlink(address, hyperlink, true);
} else if (opts.hyperlink) {
this.hyperlink(address, opts.hyperlink);
} else if (opts.email) {
const email = opts.email;
const subject = opts.emailSubject || '';
this.hyperlink(address, encodeURI(`mailto:${email}?subject=${subject}`));
}
const hyperlinkNode = this._hyperlinks[address];
if (hyperlinkNode) {
if (opts.tooltip) {
hyperlinkNode.attributes.tooltip = opts.tooltip;
}
}
return this;
})
.handle(arguments);
}
/**
* Increment and return the max shared formula ID.
* @returns {number} The new max shared formula ID.
* @ignore
*/
incrementMaxSharedFormulaId() {
return ++this._maxSharedFormulaId;
}
/**
* Get a value indicating whether the cells in the given address are merged.
* @param {string} address - The address to check.
* @returns {boolean} True if merged, false if not merged.
* @ignore
*//**
* Merge/unmerge cells by adding/removing a mergeCell entry.
* @param {string} address - The address to merge.
* @param {boolean} merged - True to merge, false to unmerge.
* @returns {Sheet} The sheet.
* @ignore
*/
merged() {
return new ArgHandler('Sheet.merge')
.case('string', address => {
return this._mergeCells.hasOwnProperty(address);
})
.case(['string', '*'], (address, merge) => {
if (merge) {
this._mergeCells[address] = {
name: 'mergeCell',
attributes: { ref: address },
children: []
};
} else {
delete this._mergeCells[address];
}
return this;
})
.handle(arguments);
}
/**
* Gets a Object or undefined of the cells in the given address.
* @param {string} address - The address to check.
* @returns {object|boolean} Object or false if not set
* @ignore
*//**
* Removes dataValidation at the given address
* @param {string} address - The address to remove.
* @param {boolean} obj - false to delete.
* @returns {boolean} true if removed.
* @ignore
*//**
* Add dataValidation to cells at the given address if object or string
* @param {string} address - The address to set.
* @param {object|string} obj - Object or String to set
* @returns {Sheet} The sheet.
* @ignore
*/
dataValidation() {
return new ArgHandler('Sheet.dataValidation')
.case('string', address => {
if (this._dataValidations[address]) {
return {
type: this._dataValidations[address].attributes.type,
allowBlank: this._dataValidations[address].attributes.allowBlank,
showInputMessage: this._dataValidations[address].attributes.showInputMessage,
prompt: this._dataValidations[address].attributes.prompt,
promptTitle: this._dataValidations[address].attributes.promptTitle,
showErrorMessage: this._dataValidations[address].attributes.showErrorMessage,
error: this._dataValidations[address].attributes.error,
errorTitle: this._dataValidations[address].attributes.errorTitle,
operator: this._dataValidations[address].attributes.operator,
formula1: this._dataValidations[address].children[0].children[0],
formula2: this._dataValidations[address].children[1] ? this._dataValidations[address].children[1].children[0] : undefined
};
} else {
return false;
}
})
.case(['string', 'boolean'], (address, obj) => {
if (this._dataValidations[address]) {
if (obj === false) return delete this._dataValidations[address];
} else {
return false;
}
})
.case(['string', '*'], (address, obj) => {
if (typeof obj === 'string') {
this._dataValidations[address] = {
name: 'dataValidation',
attributes: {
type: 'list',
allowBlank: false,
showInputMessage: false,
prompt: '',
promptTitle: '',
showErrorMessage: false,
error: '',
errorTitle: '',
operator: '',
sqref: address
},
children: [
{
name: 'formula1',
atrributes: {},
children: [obj]
},
{
name: 'formula2',
atrributes: {},
children: ['']
}
]
};
} else if (typeof obj === 'object') {
this._dataValidations[address] = {
name: 'dataValidation',
attributes: {
type: obj.type ? obj.type : 'list',
allowBlank: obj.allowBlank,
showInputMessage: obj.showInputMessage,
prompt: obj.prompt,
promptTitle: obj.promptTitle,
showErrorMessage: obj.showErrorMessage,
error: obj.error,
errorTitle: obj.errorTitle,
operator: obj.operator,
sqref: address
},
children: [
{
name: 'formula1',
atrributes: {},
children: [
obj.formula1
]
},
{
name: 'formula2',
atrributes: {},
children: [
obj.formula2
]
}
]
};
}
return this;
})
.handle(arguments);
}
/**
* Convert the sheet to a collection of XML objects.
* @returns {{}} The XML forms.
* @ignore
*/
toXmls() {
// Shallow clone the node so we don't have to remove these children later if they don't belong.
const node = _.clone(this._node);
node.children = node.children.slice();
// Add the columns if needed.
this._colsNode.children = _.filter(this._colNodes, (colNode, i) => {
// Columns should only be present if they have attributes other than min/max.
return colNode && i === colNode.attributes.min && Object.keys(colNode.attributes).length > 2;
});
if (this._colsNode.children.length) {
xmlq.insertInOrder(node, this._colsNode, nodeOrder);
}
// Add the hyperlinks if needed.
this._hyperlinksNode.children = _.values(this._hyperlinks);
if (this._hyperlinksNode.children.length) {
xmlq.insertInOrder(node, this._hyperlinksNode, nodeOrder);
}
// Add the printOptions if needed.
if (this._printOptionsNode) {
if (Object.keys(this._printOptionsNode.attributes).length) {
xmlq.insertInOrder(node, this._printOptionsNode, nodeOrder);
}
}
// Add the pageMargins if needed.
if (this._pageMarginsNode && this._pageMarginsPresetName) {
// Clone to preserve the current state of this sheet.
const childNode = _.clone(this._pageMarginsNode);
if (Object.keys(this._pageMarginsNode.attributes).length) {
// Fill in any missing attribute values with presets.
childNode.attributes = _.assign(
this._pageMarginsPresets[this._pageMarginsPresetName],
this._pageMarginsNode.attributes);
} else {
// No need to fill in, all attributes is currently empty, simply replace.
childNode.attributes = this._pageMarginsPresets[this._pageMarginsPresetName];
}
xmlq.insertInOrder(node, childNode, nodeOrder);
}
// Add the merge cells if needed.
this._mergeCellsNode.children = _.values(this._mergeCells);
if (this._mergeCellsNode.children.length) {
xmlq.insertInOrder(node, this._mergeCellsNode, nodeOrder);
}
// Add the DataValidation cells if needed.
this._dataValidationsNode.children = _.values(this._dataValidations);
if (this._dataValidationsNode.children.length) {
xmlq.insertInOrder(node, this._dataValidationsNode, nodeOrder);
}
if (this._autoFilter) {
xmlq.insertInOrder(node, {
name: "autoFilter",
children: [],
attributes: {
ref: this._autoFilter.address()
}
}, nodeOrder);
}
// Add the PageBreaks nodes if needed.
['colBreaks', 'rowBreaks'].forEach(name => {
const breaks = this[`_${name}Node`];
if (breaks.attributes.count) {
xmlq.insertInOrder(node, breaks, nodeOrder);
}
});
return {
id: this._idNode,
sheet: node,
relationships: this._relationships
};
}
/**
* Update the max shared formula ID to the given value if greater than current.
* @param {number} sharedFormulaId - The new shared formula ID.
* @returns {undefined}
* @ignore
*/
updateMaxSharedFormulaId(sharedFormulaId) {
if (sharedFormulaId > this._maxSharedFormulaId) {
this._maxSharedFormulaId = sharedFormulaId;
}
}
/**
* Get the print option given a valid print option attribute.
* @param {string} attributeName - Attribute name of the printOptions.
* gridLines - Used in conjunction with gridLinesSet. If both gridLines and gridlinesSet are true, then grid lines shall print. Otherwise, they shall not (i.e., one or both have false values).
* gridLinesSet - Used in conjunction with gridLines. If both gridLines and gridLinesSet are true, then grid lines shall print. Otherwise, they shall not (i.e., one or both have false values).
* headings - Print row and column headings.
* horizontalCentered - Center on page horizontally when printing.
* verticalCentered - Center on page vertically when printing.
* @returns {boolean}
*//**
* Set the print option given a valid print option attribute and a value.
* @param {string} attributeName - Attribute name of the printOptions. See get print option for list of valid attributes.
* @param {undefined|boolean} attributeEnabled - If `undefined` or `false` then the attribute is removed, otherwise the print option is enabled.
* @returns {Sheet} The sheet.
*/
printOptions() {
const supportedAttributeNames = [
'gridLines', 'gridLinesSet', 'headings', 'horizontalCentered', 'verticalCentered'];
const checkAttributeName = this._getCheckAttributeNameHelper('printOptions', supportedAttributeNames);
return new ArgHandler('Sheet.printOptions')
.case(['string'], attributeName => {
checkAttributeName(attributeName);
return this._printOptionsNode.attributes[attributeName] === 1;
})
.case(['string', 'nil'], attributeName => {
checkAttributeName(attributeName);
delete this._printOptionsNode.attributes[attributeName];
return this;
})
.case(['string', 'boolean'], (attributeName, attributeEnabled) => {
checkAttributeName(attributeName);
if (attributeEnabled) {
this._printOptionsNode.attributes[attributeName] = 1;
return this;
} else {
return this.printOptions(attributeName, undefined);
}
})
.handle(arguments);
}
/**
* Get the print option for the gridLines attribute value.
* @returns {boolean}
*//**
* Set the print option for the gridLines attribute value.
* @param {undefined|boolean} enabled - If `undefined` or `false` then attribute is removed, otherwise gridLines is enabled.
* @returns {Sheet} The sheet.
*/
printGridLines() {
return new ArgHandler('Sheet.gridLines')
.case(() => {
return this.printOptions('gridLines') && this.printOptions('gridLinesSet');
})
.case(['nil'], () => {
this.printOptions('gridLines', undefined);
this.printOptions('gridLinesSet', undefined);
return this;
})
.case(['boolean'], enabled => {
this.printOptions('gridLines', enabled);
this.printOptions('gridLinesSet', enabled);
return this;
})
.handle(arguments);
}
/**
* Get the page margin given a valid attribute name.
* If the value is not yet defined, then it will return the current preset value.
* @param {string} attributeName - Attribute name of the pageMargins.
* left - Left Page Margin in inches.
* right - Right page margin in inches.
* top - Top Page Margin in inches.
* buttom - Bottom Page Margin in inches.
* footer - Footer Page Margin in inches.
* header - Header Page Margin in inches.
* @returns {number} the attribute value.
*//**
* Set the page margin (or override the preset) given an attribute name and a value.
* @param {string} attributeName - Attribute name of the pageMargins. See get page margin for list of valid attributes.
* @param {undefined|number|string} attributeStringValue - If `undefined` then set back to preset value, otherwise, set the given attribute value.
* @returns {Sheet} The sheet.
*/
pageMargins() {
if (this.pageMarginsPreset() === undefined) {
throw new Error('Sheet.pageMargins: preset is undefined.');
}
const supportedAttributeNames = [
'left', 'right', 'top', 'bottom', 'header', 'footer'];
const checkAttributeName = this._getCheckAttributeNameHelper('pageMargins', supportedAttributeNames);
const checkRange = this._getCheckRangeHelper('pageMargins', 0, undefined);
return new ArgHandler('Sheet.pageMargins')
.case(['string'], attributeName => {
checkAttributeName(attributeName);
const attributeValue = this._pageMarginsNode.attributes[attributeName];
if (attributeValue !== undefined) {
return parseFloat(attributeValue);
} else if (this._pageMarginsPresetName) {
return parseFloat(this._pageMarginsPresets[this._pageMarginsPresetName][attributeName]);
} else {
return undefined;
}
})
.case(['string', 'nil'], attributeName => {
checkAttributeName(attributeName);
delete this._pageMarginsNode.attributes[attributeName];
return this;
})
.case(['string', 'number'], (attributeName, attributeNumberValue) => {
checkAttributeName(attributeName);
checkRange(attributeNumberValue);
this._pageMarginsNode.attributes[attributeName] = attributeNumberValue;
return this;
})
.case(['string', 'string'], (attributeName, attributeStringValue) => {
return this.pageMargins(attributeName, parseFloat(attributeStringValue));
})
.handle(arguments);
}
/**
* Page margins preset is a set of page margins associated with a name.
* The page margin preset acts as a fallback when not explicitly defined by `Sheet.pageMargins`.
* If a sheet already contains page margins, it attempts to auto-detect, otherwise they are defined as the template preset.
* If no page margins exist, then the preset is undefined and will not be included in the output of `Sheet.toXmls`.
* Available presets include: normal, wide, narrow, template.
*
* Get the page margins preset name. The registered name of a predefined set of attributes.
* @returns {string} The preset name.
*//**
* Set the page margins preset by name, clearing any existing/temporary attribute values.
* @param {undefined|string} presetName - The preset name. If `undefined`, page margins will not be included in the output of `Sheet.toXmls`.
* @returns {Sheet} The sheet.
*//**
* Set a new page margins preset by name and attributes object.
* @param {string} presetName - The preset name.
* @param {object} presetAttributes - The preset attributes.
* @returns {Sheet} The sheet.
*/
pageMarginsPreset() {
return new ArgHandler('Sheet.pageMarginsPreset')
.case(() => {
return this._pageMarginsPresetName;
})
.case(['nil'], () => {
// Remove all preset overrides and exclude from sheet
this._pageMarginsPresetName = undefined;
// Remove all preset overrides
this._pageMarginsNode.attributes = {};
return this;
})
.case(['string'], presetName => {
const checkPresetName = this._getCheckAttributeNameHelper(
'pageMarginsPreset', Object.keys(this._pageMarginsPresets));
checkPresetName(presetName);
// Change to new preset
this._pageMarginsPresetName = presetName;
// Remove all preset overrides
this._pageMarginsNode.attributes = {};
return this;
})
.case(['string', 'object'], (presetName, presetAttributes) => {
if (this._pageMarginsPresets.hasOwnProperty(presetName)) {
throw new Error(`Sheet.pageMarginsPreset: The preset ${presetName} already exists!`);
}
// Validate preset attribute keys.
const pageMarginsAttributeNames = [
'left', 'right', 'top', 'bottom', 'header', 'footer'];
const isValidPresetAttributeKeys = _.isEqual(
_.sortBy(pageMarginsAttributeNames),
_.sortBy(Object.keys(presetAttributes)));
if (isValidPresetAttributeKeys === false) {
throw new Error(`Sheet.pageMarginsPreset: Invalid preset attributes for one or key(s)! - "${Object.keys(presetAttributes)}"`);
}
// Validate preset attribute values.
_.forEach((attributeValue, attributeName) => {
const attributeNumberValue = parseFloat(attributeValue);
if (_.isNaN(attributeNumberValue) || _.isNumber(attributeNumberValue) === false) {
throw new Error(`Sheet.pageMarginsPreset: Invalid preset attribute value! - "${attributeValue}"`);
}
});
// Change to new preset
this._pageMarginsPresetName = presetName;
// Remove all preset overrides
this._pageMarginsNode.attributes = {};
// Register the preset
this._pageMarginsPresets[presetName] = presetAttributes;
return this;
})
.handle(arguments);
}
/**
* https://docs.microsoft.com/en-us/dotnet/api/documentformat.openxml.spreadsheet.pane?view=openxml-2.8.1
* @typedef {Object} PaneOptions
* @property {string} activePane=bottomRight Active Pane. The pane that is active.
* @property {string} state Split State. Indicates whether the pane has horizontal / vertical splits,
* and whether those splits are frozen.
* @property {string} topLeftCell Top Left Visible Cell. Location of the top left visible cell in the bottom
* right pane (when in Left-To-Right mode).
* @property {number} xSplit (Horizontal Split Position) Horizontal position of the split, in 1/20th of a point;
* 0 (zero) if none. If the pane is frozen, this value indicates the number of columns visible in the top pane.
* @property {number} ySplit (Vertical Split Position) Vertical position of the split, in 1/20th of a point; 0
* (zero) if none. If the pane is frozen, this value indicates the number of rows visible in the left pane.
*//**
* Gets sheet view pane options
* @return {PaneOptions} sheet view pane options
*//**
* Sets sheet view pane options
* @param {PaneOptions|null|undefined} paneOptions sheet view pane options
* @return {Sheet} The sheet
*/
panes() {
const supportedStates = ['split', 'frozen', 'frozenSplit'];
const supportedActivePanes = ['bottomLeft', 'bottomRight', 'topLeft', 'topRight'];
const checkStateName = this._getCheckAttributeNameHelper('pane.state', supportedStates);
const checkActivePane = this._getCheckAttributeNameHelper('pane.activePane', supportedActivePanes);
const sheetViewNode = this._getOrCreateSheetViewNode();
let paneNode = xmlq.findChild(sheetViewNode, 'pane');
return new ArgHandler('Sheet.pane')
.case(() => {
if (paneNode) {
const result = _.cloneDeep(paneNode.attributes);
if (!result.state) result.state = 'split';
return result;
}
})