worksheet-writer.js 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717
  1. const _ = require('../../utils/under-dash');
  2. const RelType = require('../../xlsx/rel-type');
  3. const colCache = require('../../utils/col-cache');
  4. const Encryptor = require('../../utils/encryptor');
  5. const Dimensions = require('../../doc/range');
  6. const StringBuf = require('../../utils/string-buf');
  7. const Row = require('../../doc/row');
  8. const Column = require('../../doc/column');
  9. const SheetRelsWriter = require('./sheet-rels-writer');
  10. const SheetCommentsWriter = require('./sheet-comments-writer');
  11. const DataValidations = require('../../doc/data-validations');
  12. const xmlBuffer = new StringBuf();
  13. // ============================================================================================
  14. // Xforms
  15. const ListXform = require('../../xlsx/xform/list-xform');
  16. const DataValidationsXform = require('../../xlsx/xform/sheet/data-validations-xform');
  17. const SheetPropertiesXform = require('../../xlsx/xform/sheet/sheet-properties-xform');
  18. const SheetFormatPropertiesXform = require('../../xlsx/xform/sheet/sheet-format-properties-xform');
  19. const ColXform = require('../../xlsx/xform/sheet/col-xform');
  20. const RowXform = require('../../xlsx/xform/sheet/row-xform');
  21. const HyperlinkXform = require('../../xlsx/xform/sheet/hyperlink-xform');
  22. const SheetViewXform = require('../../xlsx/xform/sheet/sheet-view-xform');
  23. const SheetProtectionXform = require('../../xlsx/xform/sheet/sheet-protection-xform');
  24. const PageMarginsXform = require('../../xlsx/xform/sheet/page-margins-xform');
  25. const PageSetupXform = require('../../xlsx/xform/sheet/page-setup-xform');
  26. const AutoFilterXform = require('../../xlsx/xform/sheet/auto-filter-xform');
  27. const PictureXform = require('../../xlsx/xform/sheet/picture-xform');
  28. const ConditionalFormattingsXform = require('../../xlsx/xform/sheet/cf/conditional-formattings-xform');
  29. const HeaderFooterXform = require('../../xlsx/xform/sheet/header-footer-xform');
  30. const RowBreaksXform = require('../../xlsx/xform/sheet/row-breaks-xform');
  31. // since prepare and render are functional, we can use singletons
  32. const xform = {
  33. dataValidations: new DataValidationsXform(),
  34. sheetProperties: new SheetPropertiesXform(),
  35. sheetFormatProperties: new SheetFormatPropertiesXform(),
  36. columns: new ListXform({tag: 'cols', length: false, childXform: new ColXform()}),
  37. row: new RowXform(),
  38. hyperlinks: new ListXform({tag: 'hyperlinks', length: false, childXform: new HyperlinkXform()}),
  39. sheetViews: new ListXform({tag: 'sheetViews', length: false, childXform: new SheetViewXform()}),
  40. sheetProtection: new SheetProtectionXform(),
  41. pageMargins: new PageMarginsXform(),
  42. pageSeteup: new PageSetupXform(),
  43. autoFilter: new AutoFilterXform(),
  44. picture: new PictureXform(),
  45. conditionalFormattings: new ConditionalFormattingsXform(),
  46. headerFooter: new HeaderFooterXform(),
  47. rowBreaks: new RowBreaksXform(),
  48. };
  49. // ============================================================================================
  50. class WorksheetWriter {
  51. constructor(options) {
  52. // in a workbook, each sheet will have a number
  53. this.id = options.id;
  54. // and a name
  55. this.name = options.name || `Sheet${this.id}`;
  56. // add a state
  57. this.state = options.state || 'visible';
  58. // rows are stored here while they need to be worked on.
  59. // when they are committed, they will be deleted.
  60. this._rows = [];
  61. // column definitions
  62. this._columns = null;
  63. // column keys (addRow convenience): key ==> this._columns index
  64. this._keys = {};
  65. // keep a record of all row and column pageBreaks
  66. this._merges = [];
  67. this._merges.add = function() {}; // ignore cell instruction
  68. // keep record of all hyperlinks
  69. this._sheetRelsWriter = new SheetRelsWriter(options);
  70. this._sheetCommentsWriter = new SheetCommentsWriter(this, this._sheetRelsWriter, options);
  71. // keep a record of dimensions
  72. this._dimensions = new Dimensions();
  73. // first uncommitted row
  74. this._rowZero = 1;
  75. // committed flag
  76. this.committed = false;
  77. // for data validations
  78. this.dataValidations = new DataValidations();
  79. // for sharing formulae
  80. this._formulae = {};
  81. this._siFormulae = 0;
  82. // keep a record of conditionalFormattings
  83. this.conditionalFormatting = [];
  84. // keep a record of all row and column pageBreaks
  85. this.rowBreaks = [];
  86. // for default row height, outline levels, etc
  87. this.properties = Object.assign(
  88. {},
  89. {
  90. defaultRowHeight: 15,
  91. dyDescent: 55,
  92. outlineLevelCol: 0,
  93. outlineLevelRow: 0,
  94. },
  95. options.properties
  96. );
  97. this.headerFooter = Object.assign(
  98. {},
  99. {
  100. differentFirst: false,
  101. differentOddEven: false,
  102. oddHeader: null,
  103. oddFooter: null,
  104. evenHeader: null,
  105. evenFooter: null,
  106. firstHeader: null,
  107. firstFooter: null,
  108. },
  109. options.headerFooter
  110. );
  111. // for all things printing
  112. this.pageSetup = Object.assign(
  113. {},
  114. {
  115. margins: {left: 0.7, right: 0.7, top: 0.75, bottom: 0.75, header: 0.3, footer: 0.3},
  116. orientation: 'portrait',
  117. horizontalDpi: 4294967295,
  118. verticalDpi: 4294967295,
  119. fitToPage: !!(
  120. options.pageSetup &&
  121. (options.pageSetup.fitToWidth || options.pageSetup.fitToHeight) &&
  122. !options.pageSetup.scale
  123. ),
  124. pageOrder: 'downThenOver',
  125. blackAndWhite: false,
  126. draft: false,
  127. cellComments: 'None',
  128. errors: 'displayed',
  129. scale: 100,
  130. fitToWidth: 1,
  131. fitToHeight: 1,
  132. paperSize: undefined,
  133. showRowColHeaders: false,
  134. showGridLines: false,
  135. horizontalCentered: false,
  136. verticalCentered: false,
  137. rowBreaks: null,
  138. colBreaks: null,
  139. },
  140. options.pageSetup
  141. );
  142. // using shared strings creates a smaller xlsx file but may use more memory
  143. this.useSharedStrings = options.useSharedStrings || false;
  144. this._workbook = options.workbook;
  145. this.hasComments = false;
  146. // views
  147. this._views = options.views || [];
  148. // auto filter
  149. this.autoFilter = options.autoFilter || null;
  150. this._media = [];
  151. // worksheet protection
  152. this.sheetProtection = null;
  153. // start writing to stream now
  154. this._writeOpenWorksheet();
  155. this.startedData = false;
  156. }
  157. get workbook() {
  158. return this._workbook;
  159. }
  160. get stream() {
  161. if (!this._stream) {
  162. // eslint-disable-next-line no-underscore-dangle
  163. this._stream = this._workbook._openStream(`/xl/worksheets/sheet${this.id}.xml`);
  164. // pause stream to prevent 'data' events
  165. this._stream.pause();
  166. }
  167. return this._stream;
  168. }
  169. // destroy - not a valid operation for a streaming writer
  170. // even though some streamers might be able to, it's a bad idea.
  171. destroy() {
  172. throw new Error('Invalid Operation: destroy');
  173. }
  174. commit() {
  175. if (this.committed) {
  176. return;
  177. }
  178. // commit all rows
  179. this._rows.forEach(cRow => {
  180. if (cRow) {
  181. // write the row to the stream
  182. this._writeRow(cRow);
  183. }
  184. });
  185. // we _cannot_ accept new rows from now on
  186. this._rows = null;
  187. if (!this.startedData) {
  188. this._writeOpenSheetData();
  189. }
  190. this._writeCloseSheetData();
  191. this._writeAutoFilter();
  192. this._writeMergeCells();
  193. // for some reason, Excel can't handle dimensions at the bottom of the file
  194. // this._writeDimensions();
  195. this._writeHyperlinks();
  196. this._writeConditionalFormatting();
  197. this._writeDataValidations();
  198. this._writeSheetProtection();
  199. this._writePageMargins();
  200. this._writePageSetup();
  201. this._writeBackground();
  202. this._writeHeaderFooter();
  203. this._writeRowBreaks();
  204. // Legacy Data tag for comments
  205. this._writeLegacyData();
  206. this._writeCloseWorksheet();
  207. // signal end of stream to workbook
  208. this.stream.end();
  209. this._sheetCommentsWriter.commit();
  210. // also commit the hyperlinks if any
  211. this._sheetRelsWriter.commit();
  212. this.committed = true;
  213. }
  214. // return the current dimensions of the writer
  215. get dimensions() {
  216. return this._dimensions;
  217. }
  218. get views() {
  219. return this._views;
  220. }
  221. // =========================================================================
  222. // Columns
  223. // get the current columns array.
  224. get columns() {
  225. return this._columns;
  226. }
  227. // set the columns from an array of column definitions.
  228. // Note: any headers defined will overwrite existing values.
  229. set columns(value) {
  230. // calculate max header row count
  231. this._headerRowCount = value.reduce((pv, cv) => {
  232. const headerCount = (cv.header && 1) || (cv.headers && cv.headers.length) || 0;
  233. return Math.max(pv, headerCount);
  234. }, 0);
  235. // construct Column objects
  236. let count = 1;
  237. const columns = (this._columns = []);
  238. value.forEach(defn => {
  239. const column = new Column(this, count++, false);
  240. columns.push(column);
  241. column.defn = defn;
  242. });
  243. }
  244. getColumnKey(key) {
  245. return this._keys[key];
  246. }
  247. setColumnKey(key, value) {
  248. this._keys[key] = value;
  249. }
  250. deleteColumnKey(key) {
  251. delete this._keys[key];
  252. }
  253. eachColumnKey(f) {
  254. _.each(this._keys, f);
  255. }
  256. // get a single column by col number. If it doesn't exist, it and any gaps before it
  257. // are created.
  258. getColumn(c) {
  259. if (typeof c === 'string') {
  260. // if it matches a key'd column, return that
  261. const col = this._keys[c];
  262. if (col) return col;
  263. // otherwise, assume letter
  264. c = colCache.l2n(c);
  265. }
  266. if (!this._columns) {
  267. this._columns = [];
  268. }
  269. if (c > this._columns.length) {
  270. let n = this._columns.length + 1;
  271. while (n <= c) {
  272. this._columns.push(new Column(this, n++));
  273. }
  274. }
  275. return this._columns[c - 1];
  276. }
  277. // =========================================================================
  278. // Rows
  279. get _nextRow() {
  280. return this._rowZero + this._rows.length;
  281. }
  282. // iterate over every uncommitted row in the worksheet, including maybe empty rows
  283. eachRow(options, iteratee) {
  284. if (!iteratee) {
  285. iteratee = options;
  286. options = undefined;
  287. }
  288. if (options && options.includeEmpty) {
  289. const n = this._nextRow;
  290. for (let i = this._rowZero; i < n; i++) {
  291. iteratee(this.getRow(i), i);
  292. }
  293. } else {
  294. this._rows.forEach(row => {
  295. if (row.hasValues) {
  296. iteratee(row, row.number);
  297. }
  298. });
  299. }
  300. }
  301. _commitRow(cRow) {
  302. // since rows must be written in order, we commit all rows up till and including cRow
  303. let found = false;
  304. while (this._rows.length && !found) {
  305. const row = this._rows.shift();
  306. this._rowZero++;
  307. if (row) {
  308. this._writeRow(row);
  309. found = row.number === cRow.number;
  310. this._rowZero = row.number + 1;
  311. }
  312. }
  313. }
  314. get lastRow() {
  315. // returns last uncommitted row
  316. if (this._rows.length) {
  317. return this._rows[this._rows.length - 1];
  318. }
  319. return undefined;
  320. }
  321. // find a row (if exists) by row number
  322. findRow(rowNumber) {
  323. const index = rowNumber - this._rowZero;
  324. return this._rows[index];
  325. }
  326. getRow(rowNumber) {
  327. const index = rowNumber - this._rowZero;
  328. // may fail if rows have been comitted
  329. if (index < 0) {
  330. throw new Error('Out of bounds: this row has been committed');
  331. }
  332. let row = this._rows[index];
  333. if (!row) {
  334. this._rows[index] = row = new Row(this, rowNumber);
  335. }
  336. return row;
  337. }
  338. addRow(value) {
  339. const row = new Row(this, this._nextRow);
  340. this._rows[row.number - this._rowZero] = row;
  341. row.values = value;
  342. return row;
  343. }
  344. // ================================================================================
  345. // Cells
  346. // returns the cell at [r,c] or address given by r. If not found, return undefined
  347. findCell(r, c) {
  348. const address = colCache.getAddress(r, c);
  349. const row = this.findRow(address.row);
  350. return row ? row.findCell(address.column) : undefined;
  351. }
  352. // return the cell at [r,c] or address given by r. If not found, create a new one.
  353. getCell(r, c) {
  354. const address = colCache.getAddress(r, c);
  355. const row = this.getRow(address.row);
  356. return row.getCellEx(address);
  357. }
  358. mergeCells(...cells) {
  359. // may fail if rows have been comitted
  360. const dimensions = new Dimensions(cells);
  361. // check cells aren't already merged
  362. this._merges.forEach(merge => {
  363. if (merge.intersects(dimensions)) {
  364. throw new Error('Cannot merge already merged cells');
  365. }
  366. });
  367. // apply merge
  368. const master = this.getCell(dimensions.top, dimensions.left);
  369. for (let i = dimensions.top; i <= dimensions.bottom; i++) {
  370. for (let j = dimensions.left; j <= dimensions.right; j++) {
  371. if (i > dimensions.top || j > dimensions.left) {
  372. this.getCell(i, j).merge(master);
  373. }
  374. }
  375. }
  376. // index merge
  377. this._merges.push(dimensions);
  378. }
  379. // ===========================================================================
  380. // Conditional Formatting
  381. addConditionalFormatting(cf) {
  382. this.conditionalFormatting.push(cf);
  383. }
  384. removeConditionalFormatting(filter) {
  385. if (typeof filter === 'number') {
  386. this.conditionalFormatting.splice(filter, 1);
  387. } else if (filter instanceof Function) {
  388. this.conditionalFormatting = this.conditionalFormatting.filter(filter);
  389. } else {
  390. this.conditionalFormatting = [];
  391. }
  392. }
  393. // =========================================================================
  394. addBackgroundImage(imageId) {
  395. this._background = {
  396. imageId,
  397. };
  398. }
  399. getBackgroundImageId() {
  400. return this._background && this._background.imageId;
  401. }
  402. // =========================================================================
  403. // Worksheet Protection
  404. protect(password, options) {
  405. // TODO: make this function truly async
  406. // perhaps marshal to worker thread or something
  407. return new Promise(resolve => {
  408. this.sheetProtection = {
  409. sheet: true,
  410. };
  411. if (options && 'spinCount' in options) {
  412. // force spinCount to be integer >= 0
  413. options.spinCount = Number.isFinite(options.spinCount) ? Math.round(Math.max(0, options.spinCount)) : 100000;
  414. }
  415. if (password) {
  416. this.sheetProtection.algorithmName = 'SHA-512';
  417. this.sheetProtection.saltValue = Encryptor.randomBytes(16).toString('base64');
  418. this.sheetProtection.spinCount = options && 'spinCount' in options ? options.spinCount : 100000; // allow user specified spinCount
  419. this.sheetProtection.hashValue = Encryptor.convertPasswordToHash(
  420. password,
  421. 'SHA512',
  422. this.sheetProtection.saltValue,
  423. this.sheetProtection.spinCount
  424. );
  425. }
  426. if (options) {
  427. this.sheetProtection = Object.assign(this.sheetProtection, options);
  428. if (!password && 'spinCount' in options) {
  429. delete this.sheetProtection.spinCount;
  430. }
  431. }
  432. resolve();
  433. });
  434. }
  435. unprotect() {
  436. this.sheetProtection = null;
  437. }
  438. // ================================================================================
  439. _write(text) {
  440. xmlBuffer.reset();
  441. xmlBuffer.addText(text);
  442. this.stream.write(xmlBuffer);
  443. }
  444. _writeSheetProperties(xmlBuf, properties, pageSetup) {
  445. const sheetPropertiesModel = {
  446. outlineProperties: properties && properties.outlineProperties,
  447. tabColor: properties && properties.tabColor,
  448. pageSetup:
  449. pageSetup && pageSetup.fitToPage
  450. ? {
  451. fitToPage: pageSetup.fitToPage,
  452. }
  453. : undefined,
  454. };
  455. xmlBuf.addText(xform.sheetProperties.toXml(sheetPropertiesModel));
  456. }
  457. _writeSheetFormatProperties(xmlBuf, properties) {
  458. const sheetFormatPropertiesModel = properties
  459. ? {
  460. defaultRowHeight: properties.defaultRowHeight,
  461. dyDescent: properties.dyDescent,
  462. outlineLevelCol: properties.outlineLevelCol,
  463. outlineLevelRow: properties.outlineLevelRow,
  464. }
  465. : undefined;
  466. if (properties.defaultColWidth) {
  467. sheetFormatPropertiesModel.defaultColWidth = properties.defaultColWidth;
  468. }
  469. xmlBuf.addText(xform.sheetFormatProperties.toXml(sheetFormatPropertiesModel));
  470. }
  471. _writeOpenWorksheet() {
  472. xmlBuffer.reset();
  473. xmlBuffer.addText('<?xml version="1.0" encoding="UTF-8" standalone="yes"?>');
  474. xmlBuffer.addText(
  475. '<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"' +
  476. ' xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"' +
  477. ' xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"' +
  478. ' mc:Ignorable="x14ac"' +
  479. ' xmlns:x14ac="http://schemas.microsoft.com/office/spreadsheetml/2009/9/ac">'
  480. );
  481. this._writeSheetProperties(xmlBuffer, this.properties, this.pageSetup);
  482. xmlBuffer.addText(xform.sheetViews.toXml(this.views));
  483. this._writeSheetFormatProperties(xmlBuffer, this.properties);
  484. this.stream.write(xmlBuffer);
  485. }
  486. _writeColumns() {
  487. const cols = Column.toModel(this.columns);
  488. if (cols) {
  489. xform.columns.prepare(cols, {styles: this._workbook.styles});
  490. this.stream.write(xform.columns.toXml(cols));
  491. }
  492. }
  493. _writeOpenSheetData() {
  494. this._write('<sheetData>');
  495. }
  496. _writeRow(row) {
  497. if (!this.startedData) {
  498. this._writeColumns();
  499. this._writeOpenSheetData();
  500. this.startedData = true;
  501. }
  502. if (row.hasValues || row.height) {
  503. const {model} = row;
  504. const options = {
  505. styles: this._workbook.styles,
  506. sharedStrings: this.useSharedStrings ? this._workbook.sharedStrings : undefined,
  507. hyperlinks: this._sheetRelsWriter.hyperlinksProxy,
  508. merges: this._merges,
  509. formulae: this._formulae,
  510. siFormulae: this._siFormulae,
  511. comments: [],
  512. };
  513. xform.row.prepare(model, options);
  514. this.stream.write(xform.row.toXml(model));
  515. if (options.comments.length) {
  516. this.hasComments = true;
  517. this._sheetCommentsWriter.addComments(options.comments);
  518. }
  519. }
  520. }
  521. _writeCloseSheetData() {
  522. this._write('</sheetData>');
  523. }
  524. _writeMergeCells() {
  525. if (this._merges.length) {
  526. xmlBuffer.reset();
  527. xmlBuffer.addText(`<mergeCells count="${this._merges.length}">`);
  528. this._merges.forEach(merge => {
  529. xmlBuffer.addText(`<mergeCell ref="${merge}"/>`);
  530. });
  531. xmlBuffer.addText('</mergeCells>');
  532. this.stream.write(xmlBuffer);
  533. }
  534. }
  535. _writeHyperlinks() {
  536. // eslint-disable-next-line no-underscore-dangle
  537. this.stream.write(xform.hyperlinks.toXml(this._sheetRelsWriter._hyperlinks));
  538. }
  539. _writeConditionalFormatting() {
  540. const options = {
  541. styles: this._workbook.styles,
  542. };
  543. xform.conditionalFormattings.prepare(this.conditionalFormatting, options);
  544. this.stream.write(xform.conditionalFormattings.toXml(this.conditionalFormatting));
  545. }
  546. _writeRowBreaks() {
  547. this.stream.write(xform.rowBreaks.toXml(this.rowBreaks));
  548. }
  549. _writeDataValidations() {
  550. this.stream.write(xform.dataValidations.toXml(this.dataValidations.model));
  551. }
  552. _writeSheetProtection() {
  553. this.stream.write(xform.sheetProtection.toXml(this.sheetProtection));
  554. }
  555. _writePageMargins() {
  556. this.stream.write(xform.pageMargins.toXml(this.pageSetup.margins));
  557. }
  558. _writePageSetup() {
  559. this.stream.write(xform.pageSeteup.toXml(this.pageSetup));
  560. }
  561. _writeHeaderFooter() {
  562. this.stream.write(xform.headerFooter.toXml(this.headerFooter));
  563. }
  564. _writeAutoFilter() {
  565. this.stream.write(xform.autoFilter.toXml(this.autoFilter));
  566. }
  567. _writeBackground() {
  568. if (this._background) {
  569. if (this._background.imageId !== undefined) {
  570. const image = this._workbook.getImage(this._background.imageId);
  571. const pictureId = this._sheetRelsWriter.addMedia({
  572. Target: `../media/${image.name}`,
  573. Type: RelType.Image,
  574. });
  575. this._background = {
  576. ...this._background,
  577. rId: pictureId,
  578. };
  579. }
  580. this.stream.write(xform.picture.toXml({rId: this._background.rId}));
  581. }
  582. }
  583. _writeLegacyData() {
  584. if (this.hasComments) {
  585. xmlBuffer.reset();
  586. xmlBuffer.addText(`<legacyDrawing r:id="${this._sheetCommentsWriter.vmlRelId}"/>`);
  587. this.stream.write(xmlBuffer);
  588. }
  589. }
  590. _writeDimensions() {
  591. // for some reason, Excel can't handle dimensions at the bottom of the file
  592. // and we don't know the dimensions until the commit, so don't write them.
  593. // this._write('<dimension ref="' + this._dimensions + '"/>');
  594. }
  595. _writeCloseWorksheet() {
  596. this._write('</worksheet>');
  597. }
  598. }
  599. module.exports = WorksheetWriter;