UNPKG

@elbstack/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.

613 lines (549 loc) 20.8 kB
"use strict"; const _ = require("lodash"); const ArgHandler = require("./ArgHandler"); const addressConverter = require("./addressConverter"); const dateConverter = require("./dateConverter"); const regexify = require("./regexify"); const xmlq = require("./xmlq"); const FormulaError = require("./FormulaError"); const Style = require("./Style"); /** * A cell */ class Cell { // /** // * Creates a new instance of cell. // * @param {Row} row - The parent row. // * @param {{}} node - The cell node. // */ constructor(row, node, styleId) { this._row = row; this._init(node, styleId); } /* PUBLIC */ /** * Gets a value indicating whether the cell is the active cell in the sheet. * @returns {boolean} True if active, false otherwise. *//** * Make the cell the active cell in the sheet. * @param {boolean} active - Must be set to `true`. Deactivating directly is not supported. To deactivate, you should activate a different cell instead. * @returns {Cell} The cell. */ active() { return new ArgHandler('Cell.active') .case(() => { return this.sheet().activeCell() === this; }) .case('boolean', active => { if (!active) throw new Error("Deactivating cell directly not supported. Activate a different cell instead."); this.sheet().activeCell(this); return this; }) .handle(arguments); } /** * Get the address of the column. * @param {{}} [opts] - Options * @param {boolean} [opts.includeSheetName] - Include the sheet name in the address. * @param {boolean} [opts.rowAnchored] - Anchor the row. * @param {boolean} [opts.columnAnchored] - Anchor the column. * @param {boolean} [opts.anchored] - Anchor both the row and the column. * @returns {string} The address */ address(opts) { return addressConverter.toAddress({ type: 'cell', rowNumber: this.rowNumber(), columnNumber: this.columnNumber(), sheetName: opts && opts.includeSheetName && this.sheet().name(), rowAnchored: opts && (opts.rowAnchored || opts.anchored), columnAnchored: opts && (opts.columnAnchored || opts.anchored) }); } /** * Gets the parent column of the cell. * @returns {Column} The parent column. */ column() { return this.sheet().column(this.columnNumber()); } /** * Clears the contents from the cell. * @returns {Cell} The cell. */ clear() { const hostSharedFormulaId = this._formulaRef && this._sharedFormulaId; delete this._value; delete this._formulaType; delete this._formula; delete this._sharedFormulaId; delete this._formulaRef; // TODO in future version: Move shared formula to some other cell. This would require parsing the formula... if (!_.isNil(hostSharedFormulaId)) this.sheet().clearCellsUsingSharedFormula(hostSharedFormulaId); return this; } /** * Gets the column name of the cell. * @returns {number} The column name. */ columnName() { return addressConverter.columnNumberToName(this.columnNumber()); } /** * Gets the column number of the cell (1-based). * @returns {number} The column number. */ columnNumber() { return this._columnNumber; } /** * Find the given pattern in the cell 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 the cell will be replaced. * @returns {boolean} A flag indicating if the pattern was found. */ find(pattern, replacement) { pattern = regexify(pattern); const value = this.value(); if (typeof value !== 'string') return false; if (_.isNil(replacement)) { return pattern.test(value); } else { const replaced = value.replace(pattern, replacement); if (replaced === value) return false; this.value(replaced); return true; } } /** * Gets the formula in the cell. Note that if a formula was set as part of a range, the getter will return 'SHARED'. This is a limitation that may be addressed in a future release. * @returns {string} The formula in the cell. *//** * Sets the formula in the cell. * @param {string} formula - The formula to set. * @returns {Cell} The cell. */ formula() { return new ArgHandler('Cell.formula') .case(() => { // TODO in future: Return translated formula. if (this._formulaType === "shared" && !this._formulaRef) return "SHARED"; return this._formula; }) .case('nil', () => { this.clear(); return this; }) .case('string', formula => { this.clear(); this._formulaType = "normal"; this._formula = formula; return this; }) .handle(arguments); } /** * Gets the hyperlink attached to the cell. * @returns {string|undefined} The hyperlink or undefined if not set. *//** * Set or clear the hyperlink on the cell. * @param {string|undefined} hyperlink - The hyperlink to set or undefined to clear. * @returns {Cell} The cell. */ hyperlink() { return new ArgHandler('Cell.hyperlink') .case(() => { return this.sheet().hyperlink(this.address()); }) .case('*', hyperlink => { this.sheet().hyperlink(this.address(), hyperlink); return this; }) .handle(arguments); } /** * Gets the data validation object attached to the cell. * @returns {object|undefined} The data validation or undefined if not set. *//** * Set or clear the data validation object of the cell. * @param {object|undefined} dataValidation - Object or null to clear. * @returns {Cell} The cell. */ dataValidation() { return new ArgHandler('Cell.dataValidation') .case(() => { return this.sheet().dataValidation(this.address()); }) .case('boolean', obj => { return this.sheet().dataValidation(this.address(), obj); }) .case('*', obj => { this.sheet().dataValidation(this.address(), obj); return this; }) .handle(arguments); } /** * Callback used by tap. * @callback Cell~tapCallback * @param {Cell} cell - The cell * @returns {undefined} */ /** * Invoke a callback on the cell and return the cell. Useful for method chaining. * @param {Cell~tapCallback} callback - The callback function. * @returns {Cell} The cell. */ tap(callback) { callback(this); return this; } /** * Callback used by thru. * @callback Cell~thruCallback * @param {Cell} cell - The cell * @returns {*} The value to return from thru. */ /** * Invoke a callback on the cell and return the value provided by the callback. Useful for method chaining. * @param {Cell~thruCallback} callback - The callback function. * @returns {*} The return value of the callback. */ thru(callback) { return callback(this); } /** * Create a range from this cell and another. * @param {Cell|string} cell - The other cell or cell address to range to. * @returns {Range} The range. */ rangeTo(cell) { return this.sheet().range(this, cell); } /** * Returns a cell with a relative position given the offsets provided. * @param {number} rowOffset - The row offset (0 for the current row). * @param {number} columnOffset - The column offset (0 for the current column). * @returns {Cell} The relative cell. */ relativeCell(rowOffset, columnOffset) { const row = rowOffset + this.rowNumber(); const column = columnOffset + this.columnNumber(); return this.sheet().cell(row, column); } /** * Gets the parent row of the cell. * @returns {Row} The parent row. */ row() { return this._row; } /** * Gets the row number of the cell (1-based). * @returns {number} The row number. */ rowNumber() { return this.row().rowNumber(); } /** * Gets the parent sheet. * @returns {Sheet} The parent sheet. */ sheet() { return this.row().sheet(); } /** * Gets an individual style. * @param {string} name - The name of the style. * @returns {*} The style. *//** * Gets multiple styles. * @param {Array.<string>} names - The names of the style. * @returns {object.<string, *>} Object whose keys are the style names and values are the styles. *//** * Sets an individual style. * @param {string} name - The name of the style. * @param {*} value - The value to set. * @returns {Cell} The cell. *//** * Sets the styles in the range starting with the cell. * @param {string} name - The name of the style. * @param {Array.<Array.<*>>} - 2D array of values to set. * @returns {Range} The range that was set. *//** * Sets multiple styles. * @param {object.<string, *>} styles - Object whose keys are the style names and values are the styles to set. * @returns {Cell} The cell. *//** * Sets to a specific style * @param {Style} style - Style object given from stylesheet.createStyle * @returns {Cell} The cell. */ style() { if (!this._style && !(arguments[0] instanceof Style)) { this._style = this.workbook().styleSheet().createStyle(this._styleId); } return new ArgHandler("Cell.style") .case('string', name => { // Get single value return this._style.style(name); }) .case('array', names => { // Get list of values const values = {}; names.forEach(name => { values[name] = this.style(name); }); return values; }) .case(["string", "array"], (name, values) => { const numRows = values.length; const numCols = values[0].length; const range = this.rangeTo(this.relativeCell(numRows - 1, numCols - 1)); return range.style(name, values); }) .case(['string', '*'], (name, value) => { // Set a single value for all cells to a single value this._style.style(name, value); return this; }) .case('object', nameValues => { // Object of key value pairs to set for (const name in nameValues) { if (!nameValues.hasOwnProperty(name)) continue; const value = nameValues[name]; this.style(name, value); } return this; }) .case('Style', style => { this._style = style; this._styleId = style.id(); return this; }) .handle(arguments); } /** * Gets the value of the cell. * @returns {string|boolean|number|Date|undefined} The value of the cell. *//** * Sets the value of the cell. * @param {string|boolean|number|null|undefined} value - The value to set. * @returns {Cell} The cell. *//** * Sets the values in the range starting with the cell. * @param {Array.<Array.<string|boolean|number|null|undefined>>} - 2D array of values to set. * @returns {Range} The range that was set. */ value() { return new ArgHandler('Cell.value') .case(() => { return this._value; }) .case("array", values => { const numRows = values.length; const numCols = values[0].length; const range = this.rangeTo(this.relativeCell(numRows - 1, numCols - 1)); return range.value(values); }) .case('*', value => { this.clear(); this._value = value; return this; }) .handle(arguments); } /** * Gets the parent workbook. * @returns {Workbook} The parent workbook. */ workbook() { return this.row().workbook(); } /* INTERNAL */ /** * Gets the formula if a shared formula ref cell. * @returns {string|undefined} The formula. * @ignore */ getSharedRefFormula() { return this._formulaType === "shared" ? this._formulaRef && this._formula : undefined; } /** * Check if this cell uses a given shared a formula ID. * @param {number} id - The shared formula ID. * @returns {boolean} A flag indicating if shared. * @ignore */ sharesFormula(id) { return this._formulaType === "shared" && this._sharedFormulaId === id; } /** * Set a shared formula on the cell. * @param {number} id - The shared formula index. * @param {string} [formula] - The formula (if the reference cell). * @param {string} [sharedRef] - The address of the shared range (if the reference cell). * @returns {undefined} * @ignore */ setSharedFormula(id, formula, sharedRef) { this.clear(); this._formulaType = "shared"; this._sharedFormulaId = id; this._formula = formula; this._formulaRef = sharedRef; } /** * Convert the cell to an XML object. * @returns {{}} The XML form. * @ignore */ toXml() { // Create a node. const node = { name: 'c', attributes: this._remainingAttributes || {}, // Start with any remaining attributes we don't current handle. children: [] }; // Set the address. node.attributes.r = this.address(); if (!_.isNil(this._formulaType)) { // Add the formula. const fNode = { name: 'f', attributes: this._remainingFormulaAttributes || {} }; if (this._formulaType !== "normal") fNode.attributes.t = this._formulaType; if (!_.isNil(this._formulaRef)) fNode.attributes.ref = this._formulaRef; if (!_.isNil(this._sharedFormulaId)) fNode.attributes.si = this._sharedFormulaId; if (!_.isNil(this._formula)) fNode.children = [this._formula]; node.children.push(fNode); } else if (!_.isNil(this._value)) { // Add the value. Don't emit value if a formula is set as Excel will show this stale value. let type, text; if (typeof this._value === "string" || _.isArray(this._value)) { // TODO: Rich text is array for now type = "s"; text = this.workbook().sharedStrings().getIndexForString(this._value); } else if (typeof this._value === "boolean") { type = "b"; text = this._value ? 1 : 0; } else if (typeof this._value === "number") { text = this._value; } else if (this._value instanceof Date) { text = dateConverter.dateToNumber(this._value); } if (type) node.attributes.t = type; const vNode = { name: 'v', children: [text] }; node.children.push(vNode); } // If the style is set, set the style ID. if (!_.isNil(this._style)) { node.attributes.s = this._style.id(); } else if (!_.isNil(this._styleId)) { node.attributes.s = this._styleId; } // Add any remaining children that we don't currently handle. if (this._remainingChildren) { node.children = node.children.concat(this._remainingChildren); } return node; } /* PRIVATE */ /** * Initialize the cell node. * @param {{}|number} nodeOrColumnNumber - The existing node or the column number of a new cell. * @param {number} [styleId] - The style ID for the new cell. * @returns {undefined} * @private */ _init(nodeOrColumnNumber, styleId) { if (_.isObject(nodeOrColumnNumber)) { // Parse the existing node. this._parseNode(nodeOrColumnNumber); } else { // This is a new cell. this._columnNumber = nodeOrColumnNumber; if (!_.isNil(styleId)) this._styleId = styleId; } } /** * Parse the existing node. * @param {{}} node - The existing node. * @returns {undefined} * @private */ _parseNode(node) { // Parse the column numbr out of the address. const ref = addressConverter.fromAddress(node.attributes.r); this._columnNumber = ref.columnNumber; // Store the style ID if present. if (!_.isNil(node.attributes.s)) this._styleId = node.attributes.s; // Parse the formula if present.. const fNode = xmlq.findChild(node, 'f'); if (fNode) { this._formulaType = fNode.attributes.t || "normal"; this._formulaRef = fNode.attributes.ref; this._formula = fNode.children[0]; this._sharedFormulaId = fNode.attributes.si; if (!_.isNil(this._sharedFormulaId)) { // Update the sheet's max shared formula ID so we can set future IDs an index beyond this. this.sheet().updateMaxSharedFormulaId(this._sharedFormulaId); } // Delete the known attributes. delete fNode.attributes.t; delete fNode.attributes.ref; delete fNode.attributes.si; // If any unknown attributes are still present, store them for later output. if (!_.isEmpty(fNode.attributes)) this._remainingFormulaAttributes = fNode.attributes; } // Parse the value. const type = node.attributes.t; if (type === "s") { // String value. const sharedIndex = xmlq.findChild(node, 'v').children[0]; this._value = this.workbook().sharedStrings().getStringByIndex(sharedIndex); } else if (type === "str") { // Simple string value. const vNode = xmlq.findChild(node, 'v'); this._value = vNode && vNode.children[0]; } else if (type === "inlineStr") { // Inline string value: can be simple text or rich text. const isNode = xmlq.findChild(node, 'is'); if (isNode.children[0].name === "t") { const tNode = isNode.children[0]; this._value = tNode.children[0]; } else { this._value = isNode.children; } } else if (type === "b") { // Boolean value. this._value = xmlq.findChild(node, 'v').children[0] === 1; } else if (type === "e") { // Error value. const error = xmlq.findChild(node, 'v').children[0]; this._value = FormulaError.getError(error); } else { // Number value. const vNode = xmlq.findChild(node, 'v'); this._value = vNode && Number(vNode.children[0]); } // Delete known attributes. delete node.attributes.r; delete node.attributes.s; delete node.attributes.t; // If any unknown attributes are still present, store them for later output. if (!_.isEmpty(node.attributes)) this._remainingAttributes = node.attributes; // Delete known children. xmlq.removeChild(node, 'f'); xmlq.removeChild(node, 'v'); xmlq.removeChild(node, 'is'); // If any unknown children are still present, store them for later output. if (!_.isEmpty(node.children)) this._remainingChildren = node.children; } } module.exports = Cell; /* <c r="A6" s="1" t="s"> <v>2</v> </c> */