UNPKG

exceljs

Version:

Excel Workbook Manager - Read and Write xlsx and csv Files.

698 lines (680 loc) 22.9 kB
"use strict"; const fs = require('fs'); const JSZip = require('jszip'); const { PassThrough } = require('readable-stream'); const ZipStream = require('../utils/zip-stream'); const StreamBuf = require('../utils/stream-buf'); const utils = require('../utils/utils'); const XmlStream = require('../utils/xml-stream'); const { bufferToString } = require('../utils/browser-buffer-decode'); const StylesXform = require('./xform/style/styles-xform'); const CoreXform = require('./xform/core/core-xform'); const SharedStringsXform = require('./xform/strings/shared-strings-xform'); const RelationshipsXform = require('./xform/core/relationships-xform'); const ContentTypesXform = require('./xform/core/content-types-xform'); const AppXform = require('./xform/core/app-xform'); const WorkbookXform = require('./xform/book/workbook-xform'); const WorksheetXform = require('./xform/sheet/worksheet-xform'); const DrawingXform = require('./xform/drawing/drawing-xform'); const TableXform = require('./xform/table/table-xform'); const CommentsXform = require('./xform/comment/comments-xform'); const VmlNotesXform = require('./xform/comment/vml-notes-xform'); const theme1Xml = require('./xml/theme1'); function fsReadFileAsync(filename, options) { return new Promise((resolve, reject) => { fs.readFile(filename, options, (error, data) => { if (error) { reject(error); } else { resolve(data); } }); }); } class XLSX { constructor(workbook) { this.workbook = workbook; } // =============================================================================== // Workbook // ========================================================================= // Read async readFile(filename, options) { if (!(await utils.fs.exists(filename))) { throw new Error(`File not found: ${filename}`); } const stream = fs.createReadStream(filename); try { const workbook = await this.read(stream, options); stream.close(); return workbook; } catch (error) { stream.close(); throw error; } } parseRels(stream) { const xform = new RelationshipsXform(); return xform.parseStream(stream); } parseWorkbook(stream) { const xform = new WorkbookXform(); return xform.parseStream(stream); } parseSharedStrings(stream) { const xform = new SharedStringsXform(); return xform.parseStream(stream); } reconcile(model, options) { const workbookXform = new WorkbookXform(); const worksheetXform = new WorksheetXform(options); const drawingXform = new DrawingXform(); const tableXform = new TableXform(); workbookXform.reconcile(model); // reconcile drawings with their rels const drawingOptions = { media: model.media, mediaIndex: model.mediaIndex }; Object.keys(model.drawings).forEach(name => { const drawing = model.drawings[name]; const drawingRel = model.drawingRels[name]; if (drawingRel) { drawingOptions.rels = drawingRel.reduce((o, rel) => { o[rel.Id] = rel; return o; }, {}); (drawing.anchors || []).forEach(anchor => { const hyperlinks = anchor.picture && anchor.picture.hyperlinks; if (hyperlinks && drawingOptions.rels[hyperlinks.rId]) { hyperlinks.hyperlink = drawingOptions.rels[hyperlinks.rId].Target; delete hyperlinks.rId; } }); drawingXform.reconcile(drawing, drawingOptions); } }); // reconcile tables with the default styles const tableOptions = { styles: model.styles }; Object.values(model.tables).forEach(table => { tableXform.reconcile(table, tableOptions); }); const sheetOptions = { styles: model.styles, sharedStrings: model.sharedStrings, media: model.media, mediaIndex: model.mediaIndex, date1904: model.properties && model.properties.date1904, drawings: model.drawings, comments: model.comments, tables: model.tables, vmlDrawings: model.vmlDrawings }; model.worksheets.forEach(worksheet => { worksheet.relationships = model.worksheetRels[worksheet.sheetNo]; worksheetXform.reconcile(worksheet, sheetOptions); }); // delete unnecessary parts delete model.worksheetHash; delete model.worksheetRels; delete model.globalRels; delete model.sharedStrings; delete model.workbookRels; delete model.sheetDefs; delete model.styles; delete model.mediaIndex; delete model.drawings; delete model.drawingRels; delete model.vmlDrawings; } async _processWorksheetEntry(stream, model, sheetNo, options, path) { const xform = new WorksheetXform(options); const worksheet = await xform.parseStream(stream); worksheet.sheetNo = sheetNo; model.worksheetHash[path] = worksheet; model.worksheets.push(worksheet); } async _processCommentEntry(stream, model, name) { const xform = new CommentsXform(); const comments = await xform.parseStream(stream); model.comments[`../${name}.xml`] = comments; } async _processTableEntry(stream, model, name) { const xform = new TableXform(); const table = await xform.parseStream(stream); model.tables[`../tables/${name}.xml`] = table; } async _processWorksheetRelsEntry(stream, model, sheetNo) { const xform = new RelationshipsXform(); const relationships = await xform.parseStream(stream); model.worksheetRels[sheetNo] = relationships; } async _processMediaEntry(entry, model, filename) { const lastDot = filename.lastIndexOf('.'); // if we can't determine extension, ignore it if (lastDot >= 1) { const extension = filename.substr(lastDot + 1); const name = filename.substr(0, lastDot); await new Promise((resolve, reject) => { const streamBuf = new StreamBuf(); streamBuf.on('finish', () => { model.mediaIndex[filename] = model.media.length; model.mediaIndex[name] = model.media.length; const medium = { type: 'image', name, extension, buffer: streamBuf.toBuffer() }; model.media.push(medium); resolve(); }); entry.on('error', error => { reject(error); }); entry.pipe(streamBuf); }); } } async _processDrawingEntry(entry, model, name) { const xform = new DrawingXform(); const drawing = await xform.parseStream(entry); model.drawings[name] = drawing; } async _processDrawingRelsEntry(entry, model, name) { const xform = new RelationshipsXform(); const relationships = await xform.parseStream(entry); model.drawingRels[name] = relationships; } async _processVmlDrawingEntry(entry, model, name) { const xform = new VmlNotesXform(); const vmlDrawing = await xform.parseStream(entry); model.vmlDrawings[`../drawings/${name}.vml`] = vmlDrawing; } async _processThemeEntry(entry, model, name) { await new Promise((resolve, reject) => { // TODO: stream entry into buffer and store the xml in the model.themes[] const stream = new StreamBuf(); entry.on('error', reject); stream.on('error', reject); stream.on('finish', () => { model.themes[name] = stream.read().toString(); resolve(); }); entry.pipe(stream); }); } /** * @deprecated since version 4.0. You should use `#read` instead. Please follow upgrade instruction: https://github.com/exceljs/exceljs/blob/master/UPGRADE-4.0.md */ createInputStream() { throw new Error('`XLSX#createInputStream` is deprecated. You should use `XLSX#read` instead. This method will be removed in version 5.0. Please follow upgrade instruction: https://github.com/exceljs/exceljs/blob/master/UPGRADE-4.0.md'); } async read(stream, options) { // TODO: Remove once node v8 is deprecated // Detect and upgrade old streams if (!stream[Symbol.asyncIterator] && stream.pipe) { stream = stream.pipe(new PassThrough()); } const chunks = []; for await (const chunk of stream) { chunks.push(chunk); } return this.load(Buffer.concat(chunks), options); } async load(data, options) { let buffer; if (options && options.base64) { buffer = Buffer.from(data.toString(), 'base64'); } else { buffer = data; } const model = { worksheets: [], worksheetHash: {}, worksheetRels: [], themes: {}, media: [], mediaIndex: {}, drawings: {}, drawingRels: {}, comments: {}, tables: {}, vmlDrawings: {} }; const zip = await JSZip.loadAsync(buffer); for (const entry of Object.values(zip.files)) { /* eslint-disable no-await-in-loop */ if (!entry.dir) { let entryName = entry.name; if (entryName[0] === '/') { entryName = entryName.substr(1); } let stream; if (entryName.match(/xl\/media\//) || // themes are not parsed as stream entryName.match(/xl\/theme\/([a-zA-Z0-9]+)[.]xml/)) { stream = new PassThrough(); stream.write(await entry.async('nodebuffer')); } else { // use object mode to avoid buffer-string convention stream = new PassThrough({ writableObjectMode: true, readableObjectMode: true }); let content; // https://www.npmjs.com/package/process if (process.browser) { // running in browser, use TextDecoder if possible content = bufferToString(await entry.async('nodebuffer')); } else { // running in node.js content = await entry.async('string'); } const chunkSize = 16 * 1024; for (let i = 0; i < content.length; i += chunkSize) { stream.write(content.substring(i, i + chunkSize)); } } stream.end(); switch (entryName) { case '_rels/.rels': model.globalRels = await this.parseRels(stream); break; case 'xl/workbook.xml': { const workbook = await this.parseWorkbook(stream); model.sheets = workbook.sheets; model.definedNames = workbook.definedNames; model.views = workbook.views; model.properties = workbook.properties; model.calcProperties = workbook.calcProperties; break; } case 'xl/_rels/workbook.xml.rels': model.workbookRels = await this.parseRels(stream); break; case 'xl/sharedStrings.xml': model.sharedStrings = new SharedStringsXform(); await model.sharedStrings.parseStream(stream); break; case 'xl/styles.xml': model.styles = new StylesXform(); await model.styles.parseStream(stream); break; case 'docProps/app.xml': { const appXform = new AppXform(); const appProperties = await appXform.parseStream(stream); model.company = appProperties.company; model.manager = appProperties.manager; break; } case 'docProps/core.xml': { const coreXform = new CoreXform(); const coreProperties = await coreXform.parseStream(stream); Object.assign(model, coreProperties); break; } default: { let match = entryName.match(/xl\/worksheets\/sheet(\d+)[.]xml/); if (match) { await this._processWorksheetEntry(stream, model, match[1], options, entryName); break; } match = entryName.match(/xl\/worksheets\/_rels\/sheet(\d+)[.]xml.rels/); if (match) { await this._processWorksheetRelsEntry(stream, model, match[1]); break; } match = entryName.match(/xl\/theme\/([a-zA-Z0-9]+)[.]xml/); if (match) { await this._processThemeEntry(stream, model, match[1]); break; } match = entryName.match(/xl\/media\/([a-zA-Z0-9]+[.][a-zA-Z0-9]{3,4})$/); if (match) { await this._processMediaEntry(stream, model, match[1]); break; } match = entryName.match(/xl\/drawings\/([a-zA-Z0-9]+)[.]xml/); if (match) { await this._processDrawingEntry(stream, model, match[1]); break; } match = entryName.match(/xl\/(comments\d+)[.]xml/); if (match) { await this._processCommentEntry(stream, model, match[1]); break; } match = entryName.match(/xl\/tables\/(table\d+)[.]xml/); if (match) { await this._processTableEntry(stream, model, match[1]); break; } match = entryName.match(/xl\/drawings\/_rels\/([a-zA-Z0-9]+)[.]xml[.]rels/); if (match) { await this._processDrawingRelsEntry(stream, model, match[1]); break; } match = entryName.match(/xl\/drawings\/(vmlDrawing\d+)[.]vml/); if (match) { await this._processVmlDrawingEntry(stream, model, match[1]); break; } } } } } this.reconcile(model, options); // apply model this.workbook.model = model; return this.workbook; } // ========================================================================= // Write async addMedia(zip, model) { await Promise.all(model.media.map(async medium => { if (medium.type === 'image') { const filename = `xl/media/${medium.name}.${medium.extension}`; if (medium.filename) { const data = await fsReadFileAsync(medium.filename); return zip.append(data, { name: filename }); } if (medium.buffer) { return zip.append(medium.buffer, { name: filename }); } if (medium.base64) { const dataimg64 = medium.base64; const content = dataimg64.substring(dataimg64.indexOf(',') + 1); return zip.append(content, { name: filename, base64: true }); } } throw new Error('Unsupported media'); })); } addDrawings(zip, model) { const drawingXform = new DrawingXform(); const relsXform = new RelationshipsXform(); model.worksheets.forEach(worksheet => { const { drawing } = worksheet; if (drawing) { drawingXform.prepare(drawing, {}); let xml = drawingXform.toXml(drawing); zip.append(xml, { name: `xl/drawings/${drawing.name}.xml` }); xml = relsXform.toXml(drawing.rels); zip.append(xml, { name: `xl/drawings/_rels/${drawing.name}.xml.rels` }); } }); } addTables(zip, model) { const tableXform = new TableXform(); model.worksheets.forEach(worksheet => { const { tables } = worksheet; tables.forEach(table => { tableXform.prepare(table, {}); const tableXml = tableXform.toXml(table); zip.append(tableXml, { name: `xl/tables/${table.target}` }); }); }); } async addContentTypes(zip, model) { const xform = new ContentTypesXform(); const xml = xform.toXml(model); zip.append(xml, { name: '[Content_Types].xml' }); } async addApp(zip, model) { const xform = new AppXform(); const xml = xform.toXml(model); zip.append(xml, { name: 'docProps/app.xml' }); } async addCore(zip, model) { const coreXform = new CoreXform(); zip.append(coreXform.toXml(model), { name: 'docProps/core.xml' }); } async addThemes(zip, model) { const themes = model.themes || { theme1: theme1Xml }; Object.keys(themes).forEach(name => { const xml = themes[name]; const path = `xl/theme/${name}.xml`; zip.append(xml, { name: path }); }); } async addOfficeRels(zip) { const xform = new RelationshipsXform(); const xml = xform.toXml([{ Id: 'rId1', Type: XLSX.RelType.OfficeDocument, Target: 'xl/workbook.xml' }, { Id: 'rId2', Type: XLSX.RelType.CoreProperties, Target: 'docProps/core.xml' }, { Id: 'rId3', Type: XLSX.RelType.ExtenderProperties, Target: 'docProps/app.xml' }]); zip.append(xml, { name: '_rels/.rels' }); } async addWorkbookRels(zip, model) { let count = 1; const relationships = [{ Id: `rId${count++}`, Type: XLSX.RelType.Styles, Target: 'styles.xml' }, { Id: `rId${count++}`, Type: XLSX.RelType.Theme, Target: 'theme/theme1.xml' }]; if (model.sharedStrings.count) { relationships.push({ Id: `rId${count++}`, Type: XLSX.RelType.SharedStrings, Target: 'sharedStrings.xml' }); } model.worksheets.forEach(worksheet => { worksheet.rId = `rId${count++}`; relationships.push({ Id: worksheet.rId, Type: XLSX.RelType.Worksheet, Target: `worksheets/sheet${worksheet.id}.xml` }); }); const xform = new RelationshipsXform(); const xml = xform.toXml(relationships); zip.append(xml, { name: 'xl/_rels/workbook.xml.rels' }); } async addSharedStrings(zip, model) { if (model.sharedStrings && model.sharedStrings.count) { zip.append(model.sharedStrings.xml, { name: 'xl/sharedStrings.xml' }); } } async addStyles(zip, model) { const { xml } = model.styles; if (xml) { zip.append(xml, { name: 'xl/styles.xml' }); } } async addWorkbook(zip, model) { const xform = new WorkbookXform(); zip.append(xform.toXml(model), { name: 'xl/workbook.xml' }); } async addWorksheets(zip, model) { // preparation phase const worksheetXform = new WorksheetXform(); const relationshipsXform = new RelationshipsXform(); const commentsXform = new CommentsXform(); const vmlNotesXform = new VmlNotesXform(); // write sheets model.worksheets.forEach(worksheet => { let xmlStream = new XmlStream(); worksheetXform.render(xmlStream, worksheet); zip.append(xmlStream.xml, { name: `xl/worksheets/sheet${worksheet.id}.xml` }); if (worksheet.rels && worksheet.rels.length) { xmlStream = new XmlStream(); relationshipsXform.render(xmlStream, worksheet.rels); zip.append(xmlStream.xml, { name: `xl/worksheets/_rels/sheet${worksheet.id}.xml.rels` }); } if (worksheet.comments.length > 0) { xmlStream = new XmlStream(); commentsXform.render(xmlStream, worksheet); zip.append(xmlStream.xml, { name: `xl/comments${worksheet.id}.xml` }); xmlStream = new XmlStream(); vmlNotesXform.render(xmlStream, worksheet); zip.append(xmlStream.xml, { name: `xl/drawings/vmlDrawing${worksheet.id}.vml` }); } }); } _finalize(zip) { return new Promise((resolve, reject) => { zip.on('finish', () => { resolve(this); }); zip.on('error', reject); zip.finalize(); }); } prepareModel(model, options) { // ensure following properties have sane values model.creator = model.creator || 'ExcelJS'; model.lastModifiedBy = model.lastModifiedBy || 'ExcelJS'; model.created = model.created || new Date(); model.modified = model.modified || new Date(); model.useSharedStrings = options.useSharedStrings !== undefined ? options.useSharedStrings : true; model.useStyles = options.useStyles !== undefined ? options.useStyles : true; // Manage the shared strings model.sharedStrings = new SharedStringsXform(); // add a style manager to handle cell formats, fonts, etc. model.styles = model.useStyles ? new StylesXform(true) : new StylesXform.Mock(); // prepare all of the things before the render const workbookXform = new WorkbookXform(); const worksheetXform = new WorksheetXform(); workbookXform.prepare(model); const worksheetOptions = { sharedStrings: model.sharedStrings, styles: model.styles, date1904: model.properties.date1904, drawingsCount: 0, media: model.media }; worksheetOptions.drawings = model.drawings = []; worksheetOptions.commentRefs = model.commentRefs = []; let tableCount = 0; model.tables = []; model.worksheets.forEach(worksheet => { // assign unique filenames to tables worksheet.tables.forEach(table => { tableCount++; table.target = `table${tableCount}.xml`; table.id = tableCount; model.tables.push(table); }); worksheetXform.prepare(worksheet, worksheetOptions); }); // TODO: workbook drawing list } async write(stream, options) { options = options || {}; const { model } = this.workbook; const zip = new ZipStream.ZipWriter(options.zip); zip.pipe(stream); this.prepareModel(model, options); // render await this.addContentTypes(zip, model); await this.addOfficeRels(zip, model); await this.addWorkbookRels(zip, model); await this.addWorksheets(zip, model); await this.addSharedStrings(zip, model); // always after worksheets await this.addDrawings(zip, model); await this.addTables(zip, model); await Promise.all([this.addThemes(zip, model), this.addStyles(zip, model)]); await this.addMedia(zip, model); await Promise.all([this.addApp(zip, model), this.addCore(zip, model)]); await this.addWorkbook(zip, model); return this._finalize(zip); } writeFile(filename, options) { const stream = fs.createWriteStream(filename); return new Promise((resolve, reject) => { stream.on('finish', () => { resolve(); }); stream.on('error', error => { reject(error); }); this.write(stream, options).then(() => { stream.end(); }).catch(err => { reject(err); }); }); } async writeBuffer(options) { const stream = new StreamBuf(); await this.write(stream, options); return stream.read(); } } XLSX.RelType = require('./rel-type'); module.exports = XLSX; //# sourceMappingURL=xlsx.js.map