@jsreport/exceljs
Version:
Excel Workbook Manager - Read and Write xlsx and csv Files.
766 lines (753 loc) • 27.2 kB
JavaScript
"use strict";
function ownKeys(e, r) { var t = Object.keys(e); if (Object.getOwnPropertySymbols) { var o = Object.getOwnPropertySymbols(e); r && (o = o.filter(function (r) { return Object.getOwnPropertyDescriptor(e, r).enumerable; })), t.push.apply(t, o); } return t; }
function _objectSpread(e) { for (var r = 1; r < arguments.length; r++) { var t = null != arguments[r] ? arguments[r] : {}; r % 2 ? ownKeys(Object(t), !0).forEach(function (r) { _defineProperty(e, r, t[r]); }) : Object.getOwnPropertyDescriptors ? Object.defineProperties(e, Object.getOwnPropertyDescriptors(t)) : ownKeys(Object(t)).forEach(function (r) { Object.defineProperty(e, r, Object.getOwnPropertyDescriptor(t, r)); }); } return e; }
function _defineProperty(e, r, t) { return (r = _toPropertyKey(r)) in e ? Object.defineProperty(e, r, { value: t, enumerable: !0, configurable: !0, writable: !0 }) : e[r] = t, e; }
function _toPropertyKey(t) { var i = _toPrimitive(t, "string"); return "symbol" == typeof i ? i : i + ""; }
function _toPrimitive(t, r) { if ("object" != typeof t || !t) return t; var e = t[Symbol.toPrimitive]; if (void 0 !== e) { var i = e.call(t, r || "default"); if ("object" != typeof i) return i; throw new TypeError("@@toPrimitive must return a primitive value."); } return ("string" === r ? String : Number)(t); }
const {
StringDecoder
} = require('string_decoder');
const {
Readable,
Transform
} = require('stream');
const {
DOMParser
} = require('@xmldom/xmldom');
const fs = require('fs');
const Archiver = require('archiver');
const unzip = require('unzipper');
const tmp = require('tmp');
const StreamBuf = require('../../utils/stream-buf');
const RelType = require('../../xlsx/rel-type');
const StylesXform = require('../../xlsx/xform/style/styles-xform');
const SharedStrings = require('../../utils/shared-strings');
const DefinedNames = require('../../doc/defined-names');
const CoreXform = require('../../xlsx/xform/core/core-xform');
const RelationshipsXform = require('../../xlsx/xform/core/relationships-xform');
const ContentTypesXform = require('../../xlsx/xform/core/content-types-xform');
const AppXform = require('../../xlsx/xform/core/app-xform');
const WorkbookXform = require('../../xlsx/xform/book/workbook-xform');
const SharedStringsXform = require('../../xlsx/xform/strings/shared-strings-xform');
const DrawingXform = require('../../xlsx/xform/drawing/drawing-xform');
const WorksheetWriter = require('./worksheet-writer');
const theme1Xml = require('../../xlsx/xml/theme1');
class WorkbookWriter {
constructor(options) {
options = options || {};
this.created = options.created || new Date();
this.modified = options.modified || this.created;
this.creator = options.creator || 'ExcelJS';
this.lastModifiedBy = options.lastModifiedBy || 'ExcelJS';
this.lastPrinted = options.lastPrinted;
// using shared strings creates a smaller xlsx file but may use more memory
this.useSharedStrings = options.useSharedStrings || false;
this.sharedStrings = new SharedStrings();
// style manager
if (options.template) {
this.styles = new StylesXform(true);
} else {
this.styles = options.useStyles ? new StylesXform(true) : new StylesXform.Mock(true);
}
// defined names
this._definedNames = new DefinedNames();
this._worksheets = [];
this._tmpWorksheets = new Map();
this._cellStylesCache = new Map();
this.views = [];
this.zipOptions = options.zip;
this.media = [];
this.drawings = [];
this.commentRefs = [];
if (options.template) {
this.templateBuf = options.template;
this.templateWrites = [];
this.templateInfo = {};
this.templateZip = unzip.Parse();
}
this.zip = Archiver('zip', this.zipOptions);
if (options.stream) {
this.stream = options.stream;
} else if (options.filename) {
this.stream = fs.createWriteStream(options.filename);
} else {
this.stream = new StreamBuf();
}
this.zip.pipe(this.stream);
// these bits can be added right now
if (options.template) {
this.promise = new Promise(resolve => resolve());
} else {
// these bits can be added right now
this.promise = Promise.all([this.addThemes(), this.addOfficeRels()]);
}
}
get definedNames() {
return this._definedNames;
}
_getNextWorksheetId(name) {
let id;
if (this.templateInfo) {
const existingWorkSheet = this.templateInfo.sheets.find(s => s.name === name);
if (existingWorkSheet) {
id = existingWorkSheet.sheetFileId;
} else {
id = this.nextId;
}
} else {
id = this.nextId;
}
return id;
}
_openStream(path, sheetId) {
const stream = new StreamBuf({
bufSize: 65536,
batch: true
});
const isWorksheet = sheetId != null;
if (isWorksheet && this._tmpWorksheets.has(sheetId)) {
const tmpWorksheet = this._tmpWorksheets.get(sheetId);
tmpWorksheet.zipEntryPath = path;
const tmpWriteStream = fs.createWriteStream(tmpWorksheet.tmpPath);
tmpWorksheet.waitComplete = new Promise((resolve, reject) => {
tmpWriteStream.on('error', reject);
tmpWriteStream.on('finish', resolve);
});
stream.pipe(tmpWriteStream);
} else {
this.zip.append(stream, {
name: path
});
}
stream.on('finish', () => {
stream.emit('zipped');
});
return stream;
}
_commitWorksheets() {
const commitWorksheet = function (worksheet) {
if (!worksheet.committed) {
return new Promise(resolve => {
worksheet.stream.on('zipped', () => {
resolve();
});
worksheet.commit();
});
}
return Promise.resolve();
};
// if there are any uncommitted worksheets, commit them now and wait
const promises = this._worksheets.map(commitWorksheet);
if (promises.length) {
return Promise.all(promises);
}
return Promise.resolve();
}
async waitForTemplateParse() {
try {
const templateStream = Readable.from(this.templateBuf, {
objectMode: false
});
const files = {};
const pendingOperations = [];
const waitingWorkSheets = [];
await new Promise((resolve, reject) => {
this.templateZip.on('entry', entry => {
switch (entry.path) {
case '[Content_Types].xml':
case 'xl/styles.xml':
case 'xl/workbook.xml':
case 'xl/_rels/workbook.xml.rels':
case 'xl/calcChain.xml':
pendingOperations.push(parseXMLFile(files, entry));
break;
default:
if (entry.path.match(/xl\/worksheets\/sheet\d+[.]xml/)) {
pendingOperations.push(new Promise((_resolve, _reject) => {
// eslint-disable-next-line consistent-return
tmp.file((err, tmpFilePath) => {
if (err) {
return reject(err);
}
const tempStream = fs.createWriteStream(tmpFilePath);
waitingWorkSheets.push({
ref: entry.path,
path: tmpFilePath
});
entry.on('error', _reject);
tempStream.on('error', _reject);
tempStream.on('finish', _resolve);
entry.pipe(tempStream);
});
}));
} else {
this.templateWrites.push(new Promise(_resolve => {
this.zip.append(entry, {
name: entry.path
});
_resolve();
}));
}
break;
}
});
this.templateZip.on('close', () => {
resolve();
});
this.templateZip.on('error', reject);
templateStream.pipe(this.templateZip);
});
await Promise.all(pendingOperations);
this.templateInfo.filesDocuments = files;
const workbookDoc = this.templateInfo.filesDocuments['xl/workbook.xml'].doc;
const relsDoc = this.templateInfo.filesDocuments['xl/_rels/workbook.xml.rels'].doc;
let lastSheetId;
let lastSheetFileId;
const sheets = Array.from(workbookDoc.getElementsByTagName('sheet')).map(sheetNode => {
const sheetId = parseInt(sheetNode.getAttribute('sheetId'), 10);
const rId = sheetNode.getAttribute('r:id');
const targetRNode = Array.from(relsDoc.getElementsByTagName('Relationship')).find(rNode => rNode.getAttribute('Id') === rId);
const sheetFile = targetRNode.getAttribute('Target');
const sheetFileId = parseInt(sheetFile.match(/worksheets\/sheet(\d+)[.]xml/)[1], 10);
const sheetFileFound = waitingWorkSheets.find(w => w.ref === "xl/worksheets/sheet".concat(sheetFileId, ".xml"));
if (lastSheetId == null || lastSheetId < sheetId) {
lastSheetId = sheetId;
}
if (lastSheetFileId == null || lastSheetFileId < sheetFileId) {
lastSheetFileId = sheetFileId;
}
return {
id: sheetId,
name: sheetNode.getAttribute('name'),
rId,
sheetFile,
sheetFileId,
sheetFileSrc: {
path: sheetFileFound.path
}
};
});
// take existing styles from template as the base of new styles
await this.styles.parseStream(Readable.from(this.templateInfo.filesDocuments['xl/styles.xml'].originalSrc, {
objectMode: false
}));
const lastWorkbookRelsId = Array.from(relsDoc.getElementsByTagName('Relationship')).reduce((acu, relNode) => {
const rId = relNode.getAttribute('Id');
const rIdNumber = parseInt(rId.match(/rId(\d+)/)[1], 10);
if (rIdNumber > acu) {
return rIdNumber;
}
return acu;
}, 0);
this.templateInfo.sheets = sheets;
this.templateInfo.lastSheetId = lastSheetId;
this.templateInfo.lastSheetFileId = lastSheetFileId;
this.templateInfo.lastWorkbookRelsId = lastWorkbookRelsId;
} catch (error) {
throw new Error('Failed to parse xlsx template input', {
cause: error
});
}
}
async commit() {
// commit all worksheets, then add supplementary files
await this.promise;
if (this.templateWrites) {
await Promise.all(this.templateWrites);
}
await this.addMedia();
const newWorksheets = [];
const newWorksheetsColsWidth = new Map();
this._worksheets.forEach(w => {
if (w == null) {
return;
}
// eslint-disable-next-line no-underscore-dangle
if (Array.isArray(w._columns)) {
// eslint-disable-next-line no-underscore-dangle
newWorksheetsColsWidth.set(w.id, w._columns.map(c => {
return {
// eslint-disable-next-line no-underscore-dangle
number: c._number,
width: c.width
};
}));
}
newWorksheets.push(w);
});
let replacedWorksheets = [];
if (this.templateInfo) {
this.templateInfo.sheets.forEach(sheet => {
const existingWorksheetIndex = newWorksheets.findIndex(w => w.name === sheet.name);
if (existingWorksheetIndex !== -1) {
sheet.replaced = true;
// eslint-disable-next-line no-underscore-dangle
sheet.calcCells = newWorksheets[existingWorksheetIndex]._calcCells;
newWorksheets.splice(existingWorksheetIndex, 1);
return;
}
this.zip.append(fs.createReadStream(sheet.sheetFileSrc.path), {
name: "xl/worksheets/sheet".concat(sheet.sheetFileId, ".xml")
});
});
replacedWorksheets = this.templateInfo.sheets.filter(s => s.replaced === true);
}
await this._commitWorksheets();
if (this.templateInfo) {
// eslint-disable-next-line no-restricted-syntax
for (const [fileName, {
doc: fileDoc,
originalSrc
}] of Object.entries(this.templateInfo.filesDocuments)) {
let content = originalSrc;
if (fileName === '[Content_Types].xml' && newWorksheets.length > 0) {
const sheetNodeRef = Array.from(fileDoc.getElementsByTagName('Override')).find(node => {
return node.getAttribute('PartName') === "/xl/worksheets/sheet".concat(this.templateInfo.lastSheetFileId, ".xml");
});
// eslint-disable-next-line no-restricted-syntax
for (const newWorksheet of newWorksheets) {
const newWorkSheetRef = sheetNodeRef.cloneNode();
newWorkSheetRef.setAttribute('PartName', "/xl/worksheets/sheet".concat(newWorksheet.id, ".xml"));
sheetNodeRef.parentNode.insertBefore(newWorkSheetRef, sheetNodeRef.nextSibling);
}
content = fileDoc.toString();
} else if (fileName === 'xl/styles.xml') {
content = this.styles.xml;
} else if (fileName === 'xl/workbook.xml' && newWorksheets.length > 0) {
const sheetsNode = fileDoc.getElementsByTagName('sheets')[0];
for (let i = 0; i < newWorksheets.length; i++) {
const newWorksheet = newWorksheets[i];
const newSheetNode = fileDoc.createElement('sheet');
newSheetNode.setAttribute('name', newWorksheet.name);
newSheetNode.setAttribute('sheetId', this.templateInfo.lastSheetId + i + 1);
newSheetNode.setAttribute('r:id', "rId".concat(this.templateInfo.lastWorkbookRelsId + i + 1));
sheetsNode.appendChild(newSheetNode);
}
content = fileDoc.toString();
} else if (fileName === 'xl/_rels/workbook.xml.rels' && newWorksheets.length > 0) {
const relationshipsNode = fileDoc.getElementsByTagName('Relationships')[0];
for (let i = 0; i < newWorksheets.length; i++) {
const newWorksheet = newWorksheets[i];
const newRelNode = fileDoc.createElement('Relationship');
newRelNode.setAttribute('Id', "rId".concat(this.templateInfo.lastWorkbookRelsId + i + 1));
newRelNode.setAttribute('Type', 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet');
newRelNode.setAttribute('Target', "worksheets/sheet".concat(newWorksheet.id, ".xml"));
relationshipsNode.appendChild(newRelNode);
}
content = fileDoc.toString();
} else if (fileName === 'xl/calcChain.xml' && replacedWorksheets.length > 0) {
const calcChainNode = fileDoc.getElementsByTagName('calcChain')[0];
const cellRefNodes = Array.from(fileDoc.getElementsByTagName('c'));
const existingCalcCells = {};
cellRefNodes.forEach(cellRefNode => {
const worksheetIndex = parseInt(cellRefNode.getAttribute('i'), 10);
const cellRef = cellRefNode.getAttribute('r');
const replacedWorksheetFound = replacedWorksheets.find(w => w.id === worksheetIndex);
if (!replacedWorksheetFound) {
return;
}
if (!replacedWorksheetFound.calcCells[cellRef]) {
cellRefNode.parentNode.removeChild(cellRefNode);
} else {
existingCalcCells[worksheetIndex] = existingCalcCells[worksheetIndex] || {};
existingCalcCells[worksheetIndex][cellRef] = true;
}
});
replacedWorksheets.forEach(replacedWorksheet => {
const calcCellsInWorksheet = existingCalcCells[replacedWorksheet.id] || {};
// eslint-disable-next-line no-restricted-syntax
for (const [cellAddress] of Object.entries(replacedWorksheet.calcCells)) {
if (!calcCellsInWorksheet[cellAddress]) {
const newCellRef = fileDoc.createElement('c');
newCellRef.setAttribute('r', cellAddress);
newCellRef.setAttribute('i', replacedWorksheet.id);
calcChainNode.appendChild(newCellRef);
}
}
});
content = fileDoc.toString();
}
this.zip.append(content, {
name: fileName
});
}
} else {
await Promise.all([this.addContentTypes(), this.addApp(), this.addCore(), this.addSharedStrings(), this.addDrawings(), this.addStyles(), this.addWorkbookRels()]);
await this.addWorkbook();
}
const tmpWorksheetCompletePromises = [];
this._tmpWorksheets.forEach(value => {
tmpWorksheetCompletePromises.push(value.waitComplete);
});
await Promise.all(tmpWorksheetCompletePromises);
const worksheetsToCopy = [];
// eslint-disable-next-line no-restricted-syntax
for (const [sheetId, tmpWorksheet] of this._tmpWorksheets.entries()) {
worksheetsToCopy.push({
sheetId,
tmpWorksheet
});
}
// eslint-disable-next-line no-restricted-syntax
for (const w of worksheetsToCopy) {
const {
sheetId,
tmpWorksheet
} = w;
// eslint-disable-next-line no-await-in-loop
await new Promise((resolve, reject) => {
const worksheetStream = fs.createReadStream(tmpWorksheet.tmpPath);
const decoder = new StringDecoder('utf8');
let placeholderFound = false;
const distStream = new Transform({
transform: (chunk, enc, cb) => {
const chunkStr = decoder.write(chunk);
if (!placeholderFound && chunkStr.includes('$colsContent')) {
placeholderFound = true;
let colsXML = this._worksheets[sheetId].rawColsXML;
colsXML = colsXML.slice('<cols>'.length).slice(0, '</cols>'.length * -1);
cb(null, chunkStr.replace('$colsContent', colsXML));
} else {
cb(null, chunkStr);
}
},
flush: cb => {
const str = decoder.end();
if (str !== '') {
cb(null, str);
} else {
cb();
}
}
});
distStream.on('finish', resolve);
worksheetStream.on('error', reject);
distStream.on('error', reject);
this.zip.append(distStream, {
name: tmpWorksheet.zipEntryPath
});
worksheetStream.pipe(distStream);
});
}
const result = await this._finalize();
return result;
}
get nextId() {
let id;
// find the next unique spot to add worksheet
let i;
if (this.templateInfo && this.templateInfo.lastSheetFileId != null) {
const existingSheetsId = this.templateInfo.sheets.map(s => s.sheetFileId);
const existingSheets = Object.values(this._worksheets).filter(o => {
return existingSheetsId.includes(o.id) === false;
});
return this.templateInfo.lastSheetFileId + existingSheets.length + 1;
}
for (i = 1; i < this._worksheets.length; i++) {
if (!this._worksheets[i]) {
id = i;
break;
}
}
if (id == null) {
id = this._worksheets.length || 1;
}
return id;
}
addImage(image) {
const id = this.media.length;
const medium = Object.assign({}, image, {
type: 'image',
name: "image".concat(id + 1)
});
this.media.push(medium);
return id;
}
getImage(id) {
return this.media[id];
}
addWorksheet(name, options) {
// it's possible to add a worksheet with different than default
// shared string handling
// in fact, it's even possible to switch it mid-sheet
options = options || {};
const useSharedStrings = options.useSharedStrings !== undefined ? options.useSharedStrings : this.useSharedStrings;
if (options.tabColor) {
// eslint-disable-next-line no-console
console.trace('tabColor option has moved to { properties: tabColor: {...} }');
options.properties = Object.assign({
tabColor: options.tabColor
}, options.properties);
}
let id;
if (options.id) {
// eslint-disable-next-line prefer-destructuring
id = options.id;
delete options.id;
} else {
id = this._getNextWorksheetId(name);
}
name = name || "sheet".concat(id);
const worksheet = new WorksheetWriter({
id,
name,
async: options.async === true,
workbook: this,
useSharedStrings,
properties: options.properties,
state: options.state,
pageSetup: options.pageSetup,
views: options.views,
autoFilter: options.autoFilter,
headerFooter: options.headerFooter
});
this._worksheets[id] = worksheet;
return worksheet;
}
async addWorksheetAsync(name, options) {
const id = this._getNextWorksheetId(name);
const tmpPath = await new Promise((resolve, reject) => {
// eslint-disable-next-line consistent-return
tmp.file((err, tmpFilePath) => {
if (err) {
return reject(err);
}
resolve(tmpFilePath);
});
});
this._tmpWorksheets.set(id, {
tmpPath
});
return this.addWorksheet(name, _objectSpread(_objectSpread({}, options), {}, {
async: true,
id
}));
}
getWorksheet(id) {
if (id === undefined) {
return this._worksheets.find(() => true);
}
if (typeof id === 'number') {
return this._worksheets[id];
}
if (typeof id === 'string') {
return this._worksheets.find(worksheet => worksheet && worksheet.name === id);
}
return undefined;
}
addStyles() {
return new Promise(resolve => {
this.zip.append(this.styles.xml, {
name: 'xl/styles.xml'
});
resolve();
});
}
addThemes() {
return new Promise(resolve => {
this.zip.append(theme1Xml, {
name: 'xl/theme/theme1.xml'
});
resolve();
});
}
addOfficeRels() {
return new Promise(resolve => {
const xform = new RelationshipsXform();
const xml = xform.toXml([{
Id: 'rId1',
Type: RelType.OfficeDocument,
Target: 'xl/workbook.xml'
}, {
Id: 'rId2',
Type: RelType.CoreProperties,
Target: 'docProps/core.xml'
}, {
Id: 'rId3',
Type: RelType.ExtenderProperties,
Target: 'docProps/app.xml'
}]);
this.zip.append(xml, {
name: '/_rels/.rels'
});
resolve();
});
}
addContentTypes() {
return new Promise(resolve => {
const model = {
worksheets: this._worksheets.filter(Boolean),
sharedStrings: this.sharedStrings,
commentRefs: this.commentRefs,
media: this.media,
drawings: this.drawings
};
const xform = new ContentTypesXform();
const xml = xform.toXml(model);
this.zip.append(xml, {
name: '[Content_Types].xml'
});
resolve();
});
}
addMedia() {
return Promise.all(this.media.map(medium => {
if (medium.type === 'image') {
const filename = "xl/media/".concat(medium.name, ".").concat(medium.extension);
if (medium.filename) {
return this.zip.file(medium.filename, {
name: filename
});
}
if (medium.buffer) {
return this.zip.append(medium.buffer, {
name: filename
});
}
if (medium.base64) {
const dataimg64 = medium.base64;
const content = dataimg64.substring(dataimg64.indexOf(',') + 1);
return this.zip.append(content, {
name: filename,
base64: true
});
}
}
throw new Error('Unsupported media');
}));
}
addDrawings() {
const drawingXform = new DrawingXform();
const relsXform = new RelationshipsXform();
const worksheets = this._worksheets.filter(Boolean);
worksheets.forEach(worksheet => {
const {
drawing
} = worksheet;
if (drawing) {
drawingXform.prepare(drawing, {});
let xml = drawingXform.toXml(drawing);
this.zip.append(xml, {
name: "xl/drawings/".concat(drawing.name, ".xml")
});
xml = relsXform.toXml(drawing.rels);
this.zip.append(xml, {
name: "xl/drawings/_rels/".concat(drawing.name, ".xml.rels")
});
}
});
}
addApp() {
return new Promise(resolve => {
const model = {
worksheets: this._worksheets.filter(Boolean)
};
const xform = new AppXform();
const xml = xform.toXml(model);
this.zip.append(xml, {
name: 'docProps/app.xml'
});
resolve();
});
}
addCore() {
return new Promise(resolve => {
const coreXform = new CoreXform();
const xml = coreXform.toXml(this);
this.zip.append(xml, {
name: 'docProps/core.xml'
});
resolve();
});
}
addSharedStrings() {
if (this.sharedStrings.count) {
return new Promise(resolve => {
const sharedStringsXform = new SharedStringsXform();
const xml = sharedStringsXform.toXml(this.sharedStrings);
this.zip.append(xml, {
name: '/xl/sharedStrings.xml'
});
resolve();
});
}
return Promise.resolve();
}
addWorkbookRels() {
let count = 1;
const relationships = [{
Id: "rId".concat(count++),
Type: RelType.Styles,
Target: 'styles.xml'
}, {
Id: "rId".concat(count++),
Type: RelType.Theme,
Target: 'theme/theme1.xml'
}];
if (this.sharedStrings.count) {
relationships.push({
Id: "rId".concat(count++),
Type: RelType.SharedStrings,
Target: 'sharedStrings.xml'
});
}
this._worksheets.forEach(worksheet => {
if (worksheet) {
worksheet.rId = "rId".concat(count++);
relationships.push({
Id: worksheet.rId,
Type: RelType.Worksheet,
Target: "worksheets/sheet".concat(worksheet.id, ".xml")
});
}
});
return new Promise(resolve => {
const xform = new RelationshipsXform();
const xml = xform.toXml(relationships);
this.zip.append(xml, {
name: '/xl/_rels/workbook.xml.rels'
});
resolve();
});
}
addWorkbook() {
const {
zip
} = this;
const model = {
worksheets: this._worksheets.filter(Boolean),
definedNames: this._definedNames.model,
views: this.views,
properties: {},
calcProperties: {}
};
return new Promise(resolve => {
const xform = new WorkbookXform();
xform.prepare(model);
zip.append(xform.toXml(model), {
name: '/xl/workbook.xml'
});
resolve();
});
}
_finalize() {
return new Promise((resolve, reject) => {
this.stream.on('error', reject);
this.stream.on('finish', () => {
resolve(this);
});
this.zip.on('error', reject);
this.zip.finalize();
});
}
}
async function parseXMLFile(files, entry) {
const xml = (await entry.buffer()).toString();
const doc = new DOMParser().parseFromString(xml);
files[entry.path] = {
originalSrc: xml,
doc
};
}
module.exports = WorkbookWriter;
//# sourceMappingURL=workbook-writer.js.map