excel-builder-vanilla
Version:
An easy way of building Excel files with javascript
713 lines (647 loc) • 22.3 kB
text/typescript
import type { ExcelColumn, ExcelColumnMetadata, ExcelMargin, ExcelStyleInstruction } from '../interfaces.js';
import { isObject, isString } from '../utilities/isTypeOf.js';
import { uniqueId } from '../utilities/uniqueId.js';
import type { Drawings } from './Drawings.js';
import { RelationshipManager } from './RelationshipManager.js';
import type { SharedStrings } from './SharedStrings.js';
import { SheetView } from './SheetView.js';
import type { Table } from './Table.js';
import { Util } from './Util.js';
import type { XMLDOM, XMLNode } from './XMLDOM.js';
interface CharType {
font?: string;
bold?: boolean;
fontSize?: number;
text?: string;
underline?: boolean;
}
interface WorksheetOption {
name?: string;
sheetView?: SheetView;
}
/**
* This module represents an excel worksheet in its basic form - no tables, charts, etc. Its purpose is
* to hold data, the data's link to how it should be styled, and any links to other outside resources.
*
* @module Excel/Worksheet
*/
export class Worksheet {
name = '';
id = uniqueId('Worksheet');
_timezoneOffset: number;
relations: any = null;
columnFormats: ExcelColumn[] = [];
data: (number | string | boolean | Date | null | ExcelColumnMetadata)[][] = [];
mergedCells: string[][] = [];
columns: ExcelColumn[] = [];
sheetProtection: any = false;
_headers: [left?: any, center?: any, right?: any] = [];
_footers: [left?: any, center?: any, right?: any] = [];
_tables: Table[] = [];
_drawings: Array<Table | Drawings> = [];
_orientation?: string;
_margin?: ExcelMargin;
_rowInstructions: any = {};
_freezePane: { xSplit?: number; ySplit?: number; cell?: string } = {};
sharedStrings: SharedStrings | null = null;
hyperlinks: Array<{ cell: string; id: string; location?: string; targetMode?: string }> = [];
sheetView: SheetView;
showZeros: any = null;
constructor(config: WorksheetOption) {
this._timezoneOffset = new Date().getTimezoneOffset() * 60 * 1000;
this.sheetView = config.sheetView || new SheetView();
this.initialize(config);
}
initialize(config: any) {
config = config || {};
this.name = config.name;
this.id = uniqueId('Worksheet');
this._timezoneOffset = new Date().getTimezoneOffset() * 60 * 1000;
if (config.columns) {
this.setColumns(config.columns);
}
this.relations = new RelationshipManager();
}
/**
* Returns an object that can be consumed by a Worksheet/Export/Worker
* @returns {Object}
*/
exportData() {
return {
relations: this.relations.exportData(),
columnFormats: this.columnFormats,
data: this.data,
columns: this.columns,
mergedCells: this.mergedCells,
_headers: this._headers,
_footers: this._footers,
_tables: this._tables,
_rowInstructions: this._rowInstructions,
_freezePane: this._freezePane,
name: this.name,
id: this.id,
};
}
/**
* Imports data - to be used while inside of a WorksheetExportWorker.
* @param {Object} data
*/
importData(data: any) {
this.relations.importData(data.relations);
delete data.relations;
Object.assign(this, data);
}
setSharedStringCollection(stringCollection: SharedStrings) {
this.sharedStrings = stringCollection;
}
addTable(table: Table) {
this._tables.push(table);
this.relations.addRelation(table, 'table');
}
addDrawings(drawings: Drawings) {
this._drawings.push(drawings);
this.relations.addRelation(drawings, 'drawingRelationship');
}
setRowInstructions(rowIndex: number, instructions: ExcelStyleInstruction) {
this._rowInstructions[rowIndex] = instructions;
}
/**
* Expects an array length of three.
*
* @see Excel/Worksheet compilePageDetailPiece
* @see <a href='/cookbook/addingHeadersAndFooters.html'>Adding headers and footers to a worksheet</a>
*
* @param {Array} headers [left, center, right]
*/
setHeader(headers: [left: any, center: any, right: any]) {
if (!Array.isArray(headers)) {
throw 'Invalid argument type - setHeader expects an array of three instructions';
}
this._headers = headers;
}
/**
* Expects an array length of three.
*
* @see Excel/Worksheet compilePageDetailPiece
* @see <a href='/cookbook/addingHeadersAndFooters.html'>Adding headers and footers to a worksheet</a>
*
* @param {Array} footers [left, center, right]
*/
setFooter(footers: [left: any, center: any, right: any]) {
if (!Array.isArray(footers)) {
throw 'Invalid argument type - setFooter expects an array of three instructions';
}
this._footers = footers;
}
/**
* Turns page header/footer details into the proper format for Excel.
* @param {type} data
* @returns {String}
*/
compilePageDetailPackage(data: any) {
data = data || '';
return [
'&L',
this.compilePageDetailPiece(data[0] || ''),
'&C',
this.compilePageDetailPiece(data[1] || ''),
'&R',
this.compilePageDetailPiece(data[2] || ''),
].join('');
}
/**
* Turns instructions on page header/footer details into something
* usable by Excel.
*
* @param {type} data
* @returns {String|@exp;_@call;reduce}
*/
compilePageDetailPiece(data: string | CharType | any[]): any {
if (isString(data)) {
return '&"-,Regular"'.concat(data);
}
if (isObject(data) && !Array.isArray(data)) {
let string = '';
if ((data as CharType).font || (data as CharType).bold) {
const weighting = (data as CharType).bold ? 'Bold' : 'Regular';
string += `&"${(data as CharType).font || '-'}`;
string += `,${weighting}"`;
} else {
string += '&"-,Regular"';
}
if ((data as CharType).underline) {
string += '&U';
}
if ((data as CharType).fontSize) {
string += `&${(data as CharType).fontSize}`;
}
string += (data as CharType).text;
return string;
}
if (Array.isArray(data)) {
return data.reduce((m, v) => m.concat(this.compilePageDetailPiece(v)), '');
}
}
/**
* Creates the header node.
*
* @todo implement the ability to do even/odd headers
* @param {XML Doc} doc
* @returns {XML Node}
*/
exportHeader(doc: XMLDOM) {
const oddHeader = doc.createElement('oddHeader');
oddHeader.appendChild(doc.createTextNode(this.compilePageDetailPackage(this._headers)));
return oddHeader;
}
/**
* Creates the footer node.
*
* @todo implement the ability to do even/odd footers
* @param {XML Doc} doc
* @returns {XML Node}
*/
exportFooter(doc: XMLDOM) {
const oddFooter = doc.createElement('oddFooter');
oddFooter.appendChild(doc.createTextNode(this.compilePageDetailPackage(this._footers)));
return oddFooter;
}
/**
* This creates some nodes ahead of time, which cuts down on generation time due to
* most cell definitions being essentially the same, but having multiple nodes that need
* to be created. Cloning takes less time than creation.
*
* @private
* @param {XML Doc} doc
* @returns {_L8.Anonym$0._buildCache.Anonym$2}
*/
_buildCache(doc: XMLDOM) {
const numberNode = doc.createElement('c');
const value = doc.createElement('v');
value.appendChild(doc.createTextNode('--temp--'));
numberNode.appendChild(value);
const formulaNode = doc.createElement('c');
const formulaValue = doc.createElement('f');
formulaValue.appendChild(doc.createTextNode('--temp--'));
formulaNode.appendChild(formulaValue);
const stringNode = doc.createElement('c');
stringNode.setAttribute('t', 's');
const stringValue = doc.createElement('v');
stringValue.appendChild(doc.createTextNode('--temp--'));
stringNode.appendChild(stringValue);
return {
number: numberNode,
date: numberNode,
string: stringNode,
formula: formulaNode,
};
}
/**
* Runs through the XML document and grabs all of the strings that will
* be sent to the 'shared strings' document.
*
* @returns {Array}
*/
collectSharedStrings() {
const data = this.data;
let maxX = 0;
const strings: any = {};
for (let row = 0, l = data.length; row < l; row++) {
const dataRow = data[row];
const cellCount = dataRow.length;
maxX = cellCount > maxX ? cellCount : maxX;
for (let c = 0; c < cellCount; c++) {
let cellValue = dataRow[c];
const metadata = (cellValue as ExcelColumnMetadata)?.metadata || {};
if (cellValue && typeof cellValue === 'object') {
cellValue = (cellValue as ExcelColumnMetadata).value;
}
if (!metadata.type) {
if (typeof cellValue === 'number') {
metadata.type = 'number';
}
}
if (metadata.type === 'text' || !metadata.type) {
if (typeof strings[cellValue as string] === 'undefined') {
strings[cellValue as string] = true;
}
}
}
}
return Object.keys(strings);
}
toXML() {
const data = this.data;
const columns = this.columns || [];
const doc = Util.createXmlDoc(Util.schemas.spreadsheetml, 'worksheet');
const worksheet = doc.documentElement;
let i: number;
let l: number;
let row: number;
worksheet.setAttribute('xmlns:r', Util.schemas.relationships);
worksheet.setAttribute('xmlns:mc', Util.schemas.markupCompat);
let maxX = 0;
const sheetData = Util.createElement(doc, 'sheetData');
const cellCache = this._buildCache(doc);
for (row = 0, l = data.length; row < l; row++) {
const dataRow = data[row];
const cellCount = dataRow.length;
maxX = cellCount > maxX ? cellCount : maxX;
const rowNode = doc.createElement('row');
for (let c = 0; c < cellCount; c++) {
columns[c] = columns[c] || {};
let cellValue = dataRow[c];
let cell: any;
const metadata = (cellValue as ExcelColumnMetadata)?.metadata || {};
if (cellValue && typeof cellValue === 'object') {
cellValue = (cellValue as ExcelColumnMetadata).value;
}
if (!metadata.type) {
if (typeof cellValue === 'number') {
metadata.type = 'number';
}
}
switch (metadata.type) {
case 'number':
cell = cellCache.number.cloneNode(true);
cell.firstChild.firstChild.nodeValue = cellValue;
break;
case 'date':
cell = cellCache.date.cloneNode(true);
if (cellValue instanceof Date) {
cellValue = cellValue.getTime();
}
cell.firstChild.firstChild.nodeValue = 25569.0 + ((cellValue as number) - this._timezoneOffset) / (60 * 60 * 24 * 1000);
break;
case 'formula':
cell = cellCache.formula.cloneNode(true);
cell.firstChild.firstChild.nodeValue = cellValue as string;
break;
case 'text':
/*falls through*/
default: {
let id: number | undefined;
if (typeof this.sharedStrings?.strings[cellValue as string] !== 'undefined') {
id = this.sharedStrings.strings[cellValue as string];
} else {
id = this.sharedStrings?.addString(cellValue as string);
}
cell = cellCache.string.cloneNode(true);
cell.firstChild.firstChild.nodeValue = id;
break;
}
}
if (metadata.style) {
cell.setAttribute('s', metadata.style);
} else if (this._rowInstructions[row]?.style !== undefined) {
cell.setAttribute('s', this._rowInstructions[row].style);
}
cell.setAttribute('r', Util.positionToLetterRef(c + 1, String(row + 1)));
rowNode.appendChild(cell);
}
rowNode.setAttribute('r', row + 1);
if (this._rowInstructions[row]) {
const rowInst = this._rowInstructions[row];
if (rowInst.height !== undefined) {
rowNode.setAttribute('customHeight', '1');
rowNode.setAttribute('ht', rowInst.height);
}
if (rowInst.style !== undefined) {
rowNode.setAttribute('customFormat', '1');
rowNode.setAttribute('s', rowInst.style);
}
}
sheetData.appendChild(rowNode);
}
if (maxX !== 0) {
worksheet.appendChild(
Util.createElement(doc, 'dimension', [
['ref', `${Util.positionToLetterRef(1, 1)}:${Util.positionToLetterRef(maxX, String(data.length))}`],
]),
);
} else {
worksheet.appendChild(Util.createElement(doc, 'dimension', [['ref', Util.positionToLetterRef(1, 1)]]));
}
worksheet.appendChild(this.sheetView.exportXML(doc));
if (this.columns.length) {
worksheet.appendChild(this.exportColumns(doc));
}
worksheet.appendChild(sheetData);
// The spec doesn't say anything about this, but Excel 2013 requires sheetProtection immediately after sheetData
if (this.sheetProtection) {
worksheet.appendChild(this.sheetProtection.exportXML(doc));
}
/**
* Doing this a bit differently, as hyperlinks could be as populous as rows. Looping twice would be bad.
*/
if (this.hyperlinks.length > 0) {
const hyperlinksEl = doc.createElement('hyperlinks');
const hyperlinks = this.hyperlinks;
for (i = 0, l = hyperlinks.length; i < l; i++) {
const hyperlinkEl = doc.createElement('hyperlink');
const hyperlink: any = hyperlinks[i];
hyperlinkEl.setAttribute('ref', String(hyperlink.cell));
hyperlink.id = Util.uniqueId('hyperlink');
this.relations.addRelation(
{
id: hyperlink.id,
target: hyperlink.location,
targetMode: hyperlink.targetMode || 'External',
},
'hyperlink',
);
hyperlinkEl.setAttribute('r:id', this.relations.getRelationshipId(hyperlink));
hyperlinksEl.appendChild(hyperlinkEl);
}
worksheet.appendChild(hyperlinksEl);
}
// 'mergeCells' should be written before 'headerFoot' and 'drawing' due to issue
// with Microsoft Excel (2007, 2013)
if (this.mergedCells.length > 0) {
const mergeCells = doc.createElement('mergeCells');
for (i = 0, l = this.mergedCells.length; i < l; i++) {
const mergeCell = doc.createElement('mergeCell');
mergeCell.setAttribute('ref', `${this.mergedCells[i][0]}:${this.mergedCells[i][1]}`);
mergeCells.appendChild(mergeCell);
}
worksheet.appendChild(mergeCells);
}
this.exportPageSettings(doc, worksheet);
if (this._headers.length > 0 || this._footers.length > 0) {
const headerFooter = doc.createElement('headerFooter');
if (this._headers.length > 0) {
headerFooter.appendChild(this.exportHeader(doc));
}
if (this._footers.length > 0) {
headerFooter.appendChild(this.exportFooter(doc));
}
worksheet.appendChild(headerFooter);
}
// the 'drawing' element should be written last, after 'headerFooter', 'mergeCells', etc. due
// to issue with Microsoft Excel (2007, 2013)
for (i = 0, l = this._drawings.length; i < l; i++) {
const drawing = doc.createElement('drawing');
drawing.setAttribute('r:id', this.relations.getRelationshipId(this._drawings[i]));
worksheet.appendChild(drawing);
}
if (this._tables.length > 0) {
const tables = doc.createElement('tableParts');
tables.setAttribute('count', this._tables.length);
for (i = 0, l = this._tables.length; i < l; i++) {
const table = doc.createElement('tablePart');
table.setAttribute('r:id', this.relations.getRelationshipId(this._tables[i]));
tables.appendChild(table);
}
worksheet.appendChild(tables);
}
return doc;
}
/**
*
* @param {XML Doc} doc
* @returns {XML Node}
*/
exportColumns(doc: XMLDOM) {
const cols = Util.createElement(doc, 'cols');
for (let i = 0, l = this.columns.length; i < l; i++) {
const cd = this.columns[i];
const col = Util.createElement(doc, 'col', [
['min', cd.min || i + 1],
['max', cd.max || i + 1],
]);
if (cd.hidden) {
col.setAttribute('hidden', String(1));
}
if (cd.bestFit) {
col.setAttribute('bestFit', String(1));
}
if (cd.customWidth || cd.width) {
col.setAttribute('customWidth', String(1));
}
if (cd.width) {
col.setAttribute('width', cd.width);
} else {
col.setAttribute('width', String(9.140625));
}
cols.appendChild(col);
}
return cols;
}
/**
* Sets the page settings on a worksheet node.
*
* @param {XML Doc} doc
* @param {XML Node} worksheet
* @returns {undefined}
*/
exportPageSettings(doc: XMLDOM, worksheet: XMLNode) {
if (this._margin) {
let defaultVal = 0.7;
const left = this._margin.left ? this._margin.left : defaultVal;
const right = this._margin.right ? this._margin.right : defaultVal;
const top = this._margin.top ? this._margin.top : defaultVal;
const bottom = this._margin.bottom ? this._margin.bottom : defaultVal;
defaultVal = 0.3;
const header = this._margin.header ? this._margin.header : defaultVal;
const footer = this._margin.footer ? this._margin.footer : defaultVal;
worksheet.appendChild(
Util.createElement(doc, 'pageMargins', [
['top', top],
['bottom', bottom],
['left', left],
['right', right],
['header', header],
['footer', footer],
]),
);
}
if (this._orientation) {
worksheet.appendChild(Util.createElement(doc, 'pageSetup', [['orientation', this._orientation]]));
}
}
/**
* http://www.schemacentral.com/sc/ooxml/t-ssml_ST_Orientation.html
*
* Can be one of 'portrait' or 'landscape'.
*
* @param {'default' | 'portrait' | 'landscape'} orientation
* @returns {undefined}
*/
setPageOrientation(orientation: 'default' | 'portrait' | 'landscape') {
this._orientation = orientation;
}
/**
* Set page details in inches.
* use this structure:
* {
* top: 0.7
* , bottom: 0.7
* , left: 0.7
* , right: 0.7
* , header: 0.3
* , footer: 0.3
* }
*
* @returns {undefined}
*/
setPageMargin(input: ExcelMargin) {
this._margin = input;
}
/**
* Expects an array of column definitions. Each column definition needs to have a width assigned to it.
*
* @param {Array} columns
*/
setColumns(columns: ExcelColumn[]) {
this.columns = columns;
}
/**
* Expects an array of data to be translated into cells.
*
* @param {Array} data Two dimensional array - [ [A1, A2], [B1, B2] ]
* @see <a href='/cookbook/addingDataToAWorksheet.html'>Adding data to a worksheet</a>
*/
setData(data: (number | string | boolean | Date | null | ExcelColumnMetadata)[][]) {
this.data = data;
}
/**
* Merge cells in given range
*
* @param cell1 - A1, A2...
* @param cell2 - A2, A3...
*/
mergeCells(cell1: string, cell2: string) {
this.mergedCells.push([cell1, cell2]);
}
/**
* Added frozen pane
* @param column - column number: 0, 1, 2 ...
* @param row - row number: 0, 1, 2 ...
* @param cell - 'A1'
* @deprecated
*/
freezePane(column: number, row: number, cell: string) {
this.sheetView.freezePane(column, row, cell);
}
/**
* Expects an array containing an object full of column format definitions.
* http://msdn.microsoft.com/en-us/library/documentformat.openxml.spreadsheet.column.aspx
* bestFit
* collapsed
* customWidth
* hidden
* max
* min
* outlineLevel
* phonetic
* style
* width
* @param {Array} columnFormats
*/
setColumnFormats(columnFormats: ExcelColumn[]) {
this.columnFormats = columnFormats;
}
/**
* Returns worksheet XML header (everything before <sheetData>)
*/
getWorksheetXmlHeader(): string {
return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<worksheet xmlns="${Util.schemas.spreadsheetml}"
xmlns:r="${Util.schemas.relationships}"
xmlns:mc="${Util.schemas.markupCompat}">`;
}
/**
* Returns worksheet XML footer (everything after </sheetData>)
*/
getWorksheetXmlFooter(): string {
if (this._headers.length > 0 || this._footers.length > 0) {
let xml = '<headerFooter>';
if (this._headers.length > 0) {
xml += `<oddHeader>${this.compilePageDetailPackage(this._headers)}</oddHeader>`;
}
if (this._footers.length > 0) {
xml += `<oddFooter>${this.compilePageDetailPackage(this._footers)}</oddFooter>`;
}
xml += '</headerFooter>';
return xml;
}
return '';
}
/**
* Serialize a chunk of rows to XML (same logic as in toXML)
*/
serializeRows(rows: (number | string | boolean | Date | null | ExcelColumnMetadata)[][], startRow = 0): string {
let xml = '';
for (let row = 0, l = rows.length; row < l; row++) {
const dataRow = rows[row];
const cellCount = dataRow.length;
let rowXml = `<row r="${startRow + row + 1}">`;
for (let c = 0; c < cellCount; c++) {
const cellValue = dataRow[c];
const cellType: any = typeof cellValue || 'text';
let cellXml = '';
const rAttr = ` r="${String.fromCharCode(65 + c)}${startRow + row + 1}"`;
switch (cellType) {
case 'number':
cellXml = `<c${rAttr}><v>${cellValue}</v></c>`;
break;
case 'text':
default: {
let id: number | undefined;
if (typeof this.sharedStrings?.strings[cellValue as string] !== 'undefined') {
id = this.sharedStrings.strings[cellValue as string];
} else {
id = this.sharedStrings?.addString(cellValue as string);
}
cellXml = `<c${rAttr} t="s"><v>${id}</v></c>`;
break;
}
}
rowXml += cellXml;
}
rowXml += '</row>';
xml += rowXml;
}
return xml;
}
}