You can not select more than 25 topics
			Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
		
		
		
		
		
			
		
			
				
					348 lines
				
				9.9 KiB
			
		
		
			
		
	
	
					348 lines
				
				9.9 KiB
			| 
											2 years ago
										 | const fs = require('fs'); | ||
|  | const Archiver = require('archiver'); | ||
|  | 
 | ||
|  | 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 WorksheetWriter = require('./worksheet-writer'); | ||
|  | 
 | ||
|  | const theme1Xml = require('../../xlsx/xml/theme1.js'); | ||
|  | 
 | ||
|  | 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
 | ||
|  |     this.styles = options.useStyles ? new StylesXform(true) : new StylesXform.Mock(true); | ||
|  | 
 | ||
|  |     // defined names
 | ||
|  |     this._definedNames = new DefinedNames(); | ||
|  | 
 | ||
|  |     this._worksheets = []; | ||
|  |     this.views = []; | ||
|  | 
 | ||
|  |     this.zipOptions = options.zip; | ||
|  | 
 | ||
|  |     this.media = []; | ||
|  |     this.commentRefs = []; | ||
|  | 
 | ||
|  |     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
 | ||
|  |     this.promise = Promise.all([this.addThemes(), this.addOfficeRels()]); | ||
|  |   } | ||
|  | 
 | ||
|  |   get definedNames() { | ||
|  |     return this._definedNames; | ||
|  |   } | ||
|  | 
 | ||
|  |   _openStream(path) { | ||
|  |     const stream = new StreamBuf({bufSize: 65536, batch: true}); | ||
|  |     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 commit() { | ||
|  |     // commit all worksheets, then add suplimentary files
 | ||
|  |     await this.promise; | ||
|  |     await this.addMedia(); | ||
|  |     await this._commitWorksheets(); | ||
|  |     await Promise.all([ | ||
|  |       this.addContentTypes(), | ||
|  |       this.addApp(), | ||
|  |       this.addCore(), | ||
|  |       this.addSharedStrings(), | ||
|  |       this.addStyles(), | ||
|  |       this.addWorkbookRels(), | ||
|  |     ]); | ||
|  |     await this.addWorkbook(); | ||
|  |     return this._finalize(); | ||
|  |   } | ||
|  | 
 | ||
|  |   get nextId() { | ||
|  |     // find the next unique spot to add worksheet
 | ||
|  |     let i; | ||
|  |     for (i = 1; i < this._worksheets.length; i++) { | ||
|  |       if (!this._worksheets[i]) { | ||
|  |         return i; | ||
|  |       } | ||
|  |     } | ||
|  |     return this._worksheets.length || 1; | ||
|  |   } | ||
|  | 
 | ||
|  |   addImage(image) { | ||
|  |     const id = this.media.length; | ||
|  |     const medium = Object.assign({}, image, {type: 'image', name: `image${id}.${image.extension}`}); | ||
|  |     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 | ||
|  |       ); | ||
|  |     } | ||
|  | 
 | ||
|  |     const id = this.nextId; | ||
|  |     name = name || `sheet${id}`; | ||
|  | 
 | ||
|  |     const worksheet = new WorksheetWriter({ | ||
|  |       id, | ||
|  |       name, | ||
|  |       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; | ||
|  |   } | ||
|  | 
 | ||
|  |   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, | ||
|  |       }; | ||
|  |       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/${medium.name}`; | ||
|  |           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'); | ||
|  |       }) | ||
|  |     ); | ||
|  |   } | ||
|  | 
 | ||
|  |   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${count++}`, Type: RelType.Styles, Target: 'styles.xml'}, | ||
|  |       {Id: `rId${count++}`, Type: RelType.Theme, Target: 'theme/theme1.xml'}, | ||
|  |     ]; | ||
|  |     if (this.sharedStrings.count) { | ||
|  |       relationships.push({ | ||
|  |         Id: `rId${count++}`, | ||
|  |         Type: RelType.SharedStrings, | ||
|  |         Target: 'sharedStrings.xml', | ||
|  |       }); | ||
|  |     } | ||
|  |     this._worksheets.forEach(worksheet => { | ||
|  |       if (worksheet) { | ||
|  |         worksheet.rId = `rId${count++}`; | ||
|  |         relationships.push({ | ||
|  |           Id: worksheet.rId, | ||
|  |           Type: RelType.Worksheet, | ||
|  |           Target: `worksheets/sheet${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(); | ||
|  |     }); | ||
|  |   } | ||
|  | } | ||
|  | 
 | ||
|  | module.exports = WorkbookWriter; |