From 5b885cf4808009a6d4c1a2fc6cf04c8683472bd4 Mon Sep 17 00:00:00 2001 From: linxl <1658370535@qq.com> Date: Wed, 7 Jun 2023 17:47:02 +0800 Subject: [PATCH 1/6] add encryption func --- lib/utils/zip-stream.js | 9 +++- lib/xlsx/xlsx.js | 6 +-- spec/end-to-end/express-encryption.spec.js | 42 +++++++++++++++++++ .../new/new-issue-2-add-encrypt-func.spec.js | 35 ++++++++++++++++ 4 files changed, 87 insertions(+), 5 deletions(-) create mode 100644 spec/end-to-end/express-encryption.spec.js create mode 100644 spec/integration/issues/new/new-issue-2-add-encrypt-func.spec.js diff --git a/lib/utils/zip-stream.js b/lib/utils/zip-stream.js index 96efd8e..3d3fede 100644 --- a/lib/utils/zip-stream.js +++ b/lib/utils/zip-stream.js @@ -1,5 +1,6 @@ const events = require('events'); const JSZip = require('jszip'); +const officeCrypto = require('officecrypto-tool'); const StreamBuf = require('./stream-buf'); const {stringToBuffer} = require('./browser-buffer-encode'); @@ -35,8 +36,12 @@ class ZipWriter extends events.EventEmitter { } } - async finalize() { - const content = await this.zip.generateAsync(this.options); + async finalize(options) { + const totalOptions = {...this.options, ...options}; + let content = await this.zip.generateAsync(totalOptions); + if (totalOptions.password) { + content = officeCrypto.encrypt(content, {password: totalOptions.password}); + } this.stream.end(content); this.emit('finish'); } diff --git a/lib/xlsx/xlsx.js b/lib/xlsx/xlsx.js index 8ce63d2..b764733 100644 --- a/lib/xlsx/xlsx.js +++ b/lib/xlsx/xlsx.js @@ -588,13 +588,13 @@ class XLSX { }); } - _finalize(zip) { + _finalize(zip, options) { return new Promise((resolve, reject) => { zip.on('finish', () => { resolve(this); }); zip.on('error', reject); - zip.finalize(); + zip.finalize(options); }); } @@ -666,7 +666,7 @@ class XLSX { 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); + return this._finalize(zip, options); } writeFile(filename, options) { diff --git a/spec/end-to-end/express-encryption.spec.js b/spec/end-to-end/express-encryption.spec.js new file mode 100644 index 0000000..37144b1 --- /dev/null +++ b/spec/end-to-end/express-encryption.spec.js @@ -0,0 +1,42 @@ +const {PassThrough} = require('readable-stream'); +const express = require('express'); +const got = require('got'); +const testutils = require('../utils/index'); + +const Excel = verquire('exceljs'); + +const password = '123456'; + +describe('Express test workbook.xlsx.write encryption', () => { + let server; + before(() => { + const app = express(); + app.get('/workbook', (req, res) => { + const wb = testutils.createTestBook(new Excel.Workbook(), 'xlsx'); + res.setHeader( + 'Content-Type', + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' + ); + res.setHeader('Content-Disposition', 'attachment; filename=Report.xlsx'); + wb.xlsx.write(res, {password}).then(() => { + res.end(); + }); + }); + server = app.listen(3003); + }); + + after(() => { + server.close(); + }); + + it('downloads a workbook, workbook.xlsx.write encryption successful', async function() { + this.timeout(5000); + const res = got.stream('http://127.0.0.1:3003/workbook', { + decompress: false, + }); + const wb2 = new Excel.Workbook(); + // TODO: Remove passThrough with got 10+ (requires node v10+) + await wb2.xlsx.read(res.pipe(new PassThrough()), {password}); + testutils.checkTestBook(wb2, 'xlsx'); + }); +}); diff --git a/spec/integration/issues/new/new-issue-2-add-encrypt-func.spec.js b/spec/integration/issues/new/new-issue-2-add-encrypt-func.spec.js new file mode 100644 index 0000000..1c5fb12 --- /dev/null +++ b/spec/integration/issues/new/new-issue-2-add-encrypt-func.spec.js @@ -0,0 +1,35 @@ +const ExcelJS = verquire('exceljs'); + +const test = './spec/integration/data/new-issue-2-test-encryption.xlsx'; + +describe('github issues', () => { + describe('github issues encrypted xlsx ', () => { + it('workbook.xlsx.writeFile, ecma376_agile encryption successful', async () => { + const password = '123456'; + const writeWorkbook = new ExcelJS.Workbook(); + writeWorkbook.addWorksheet('Sheet1'); + await writeWorkbook.xlsx.writeFile(test, {password}); + + const workbook = new ExcelJS.Workbook(); + await workbook.xlsx.readFile(test, { + password, + }); + const sheetName = workbook.getWorksheet(1).name; + expect(sheetName).to.equal('Sheet1'); + }).timeout(10000); + + it('workbook.xlsx.writeBuffer, ecma376_agile encryption successful', async () => { + const password = '123456'; + const writeWorkbook = new ExcelJS.Workbook(); + writeWorkbook.addWorksheet('Sheet1'); + const encryptBuffer = await writeWorkbook.xlsx.writeBuffer({password}); + + const workbook = new ExcelJS.Workbook(); + await workbook.xlsx.load(encryptBuffer, { + password, + }); + const sheetName = workbook.getWorksheet(1).name; + expect(sheetName).to.equal('Sheet1'); + }).timeout(10000); + }); +}); From 62a010a881faa683e48203d3833390ec00c97d5c Mon Sep 17 00:00:00 2001 From: linxl <1658370535@qq.com> Date: Wed, 7 Jun 2023 18:00:34 +0800 Subject: [PATCH 2/6] update index.d.ts --- index.d.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/index.d.ts b/index.d.ts index c8ee09e..cd0eed9 100644 --- a/index.d.ts +++ b/index.d.ts @@ -1451,6 +1451,12 @@ export interface XlsxWriteOptions extends stream.xlsx.WorkbookWriterOptions { * The option passed to JsZip#generateAsync(options) */ zip: Partial; + + /** + * @desc Password for decryption + * optional + */ + password: string; } export interface XlsxReadOptions { From d18fb91a3cbf152ad76d06fd51c14f325a15cb3f Mon Sep 17 00:00:00 2001 From: linxl <1658370535@qq.com> Date: Wed, 7 Jun 2023 20:38:11 +0800 Subject: [PATCH 3/6] add nested columns feature --- lib/doc/worksheet.js | 29 +++ lib/utils/column-flatter.js | 246 +++++++++++++++++++++++ package.json | 1 + spec/integration/pr/test-pr-1899.spec.js | 87 ++++++++ 4 files changed, 363 insertions(+) create mode 100644 lib/utils/column-flatter.js create mode 100644 spec/integration/pr/test-pr-1899.spec.js diff --git a/lib/doc/worksheet.js b/lib/doc/worksheet.js index 13e4141..e80d0ec 100644 --- a/lib/doc/worksheet.js +++ b/lib/doc/worksheet.js @@ -10,6 +10,7 @@ const Table = require('./table'); const DataValidations = require('./data-validations'); const Encryptor = require('../utils/encryptor'); const {copyStyle} = require('../utils/copy-style'); +const ColumnFlatter = require('../utils/column-flatter'); // Worksheet requirements // Operate as sheet inside workbook or standalone @@ -922,6 +923,34 @@ class Worksheet { }, {}); this.conditionalFormattings = value.conditionalFormattings; } + + makeColumns(input) { + const flatter = new ColumnFlatter(input); + const merges = flatter.getMerges(); + const rows = flatter.getRows(); + + this.columns = flatter.getColumns(); + + // For the time being, do not write dead freeze, can be set by the developers themselves + // this.views.push({state: 'frozen', ySplit: rows.length}); + + this.addRows( + rows.map(row => { + return row.map(item => { + if (!item) return null; + if (item.title) return item.title; + if (item.id) return item.id; + return null; + }); + }) + ); + + merges.forEach(item => { + this.mergeCells(item); + }); + + return {rows}; + } } module.exports = Worksheet; diff --git a/lib/utils/column-flatter.js b/lib/utils/column-flatter.js new file mode 100644 index 0000000..8f51512 --- /dev/null +++ b/lib/utils/column-flatter.js @@ -0,0 +1,246 @@ +const colCache = require('./col-cache'); + +/** + * ColumnFlatter is a helper class to create sheets with nested columns. + * + * Based on following concepts + * - Walk throught nested input structure to build flat list and tree meta information + * - Use "leaf" columns as physical cols and "branch" as merge-slots + * - Generate cell matrix and merge rules + */ +class ColumnFlatter { + constructor(input, params) { + this._params = params; + // id-value storage for item aggregate sizes + this._sizes = {}; + + // flat columns list + this._list = []; + + // cells matrix storage + this._rows = []; + + this._getFlatList(input); + this._alignRows(this._alignCells()); + + // merge rules storage + this._merges = [...this._calcVerticalMerges(), ...this._calcHorizontalMerges()]; + } + + /** + * Append null placeholders for entity list alignment + */ + _pad(arr, num) { + if (num > 0) { + for (let i = 0; i < num; i++) { + arr.push(null); + } + } + } + + /** + * Filters off invalid columns entries + */ + _check(item) { + return item && item.id; + } + + /** + * Walk throught tree input. + * Build flat columns list and aggregate column size (recursive children length sum) + */ + _getFlatList(input) { + const trace = (item, meta) => { + if (!this._check(item)) return; + + const path = [...meta.path, item.id]; + const children = (item.children || []).filter(this._check); + + if (children.length && children.length > 1) { + for (const id of path) { + if (!this._sizes[id]) { + this._sizes[id] = 0; + } + + this._sizes[id] += children.length - 1; + } + + for (const child of children) { + trace(child, {path}); + } + } + + this._list.push({ + meta, + ...(children.length === 1 ? children[0] : item), + }); + }; + + for (const item of input) { + trace(item, {path: []}); + } + } + + /** + * Align with cells with null-ish appending + * by aggregated size num + */ + _alignCells() { + const res = []; + + for (const item of this._list) { + const index = item.meta.path.length; + + if (!res[index]) { + res[index] = []; + } + + res[index].push(item); + + if (item.children) { + this._pad(res[index], this._sizes[item.id]); + } + } + + return res; + } + + /** + * Align cell groups in rows according + * parent cell position + */ + _alignRows(cells) { + const width = cells.reduce((acc, row) => Math.max(acc, row.length), 0); + + for (let i = 0; i < cells.length; i++) { + const row = cells[i]; + + if (!i) { + this._rows.push(row); + } else { + const items = []; + const handled = {}; + let added = 0; + + for (let k = 0; k < width; k++) { + const item = row[k]; + + if (k + added >= width) { + break; + } + + if (item) { + const {path} = item.meta; + const parent = path[path.length - 1]; + + if (parent) { + const parentPos = this._rows[i - 1].findIndex(el => (el || {}).id === parent); + const offset = parentPos - (k + added); + + if (offset > 0 && !handled[parent]) { + added += offset; + + this._pad(items, offset); + + handled[parent] = true; + } + } + } + + items.push(item || null); + } + + this._rows.push(items); + } + } + } + + /** + * Calculates horizontal merge rules + * + * Walks width-throught rows collecting ranges with cell index and its recursive size + */ + _calcHorizontalMerges() { + const res = []; + + for (let i = 0; i < this._rows.length; i++) { + const cells = this._rows[i]; + + for (let k = 0; k < cells.length; k++) { + const cell = cells[k]; + const span = cell && this._sizes[cell.id]; + + if (span) { + const row = i + 1; + + res.push(colCache.encode(row, k + 1, row, k + span + 1)); + } + } + } + + return res; + } + + /** + * Calculates vertical merge rules + * + * Walks deep-throught rows looking for non-empty cell in row + */ + _calcVerticalMerges() { + const depth = this._rows.length - 1; + const width = this._rows[0].length; + const res = []; + + for (let i = 0; i < width; i++) { + for (let k = depth; k >= 0; k--) { + if (this._rows[k][i]) { + const col = i + 1; + + if (k !== depth) { + res.push(colCache.encode(k + 1, col, depth + 1, col)); + } + + break; + } + } + } + + return res; + } + + /** + * Collect "leaf" columns + * + * Filter off all cells with "children" property + */ + getColumns() { + const res = []; + + for (const item of this._list) { + if (!item.children) { + res.push({ + id: item.id, + ...item, + }); + } + } + + return res; + } + + /** + * Cells matrix getter + */ + getRows() { + return this._rows; + } + + /** + * Merge rules getter + */ + getMerges() { + return this._merges; + } +} + +module.exports = ColumnFlatter; diff --git a/package.json b/package.json index d27ebd4..c4e0b10 100644 --- a/package.json +++ b/package.json @@ -37,6 +37,7 @@ "test:native": "npm run test:full", "test:unit": "mocha --require spec/config/setup --require spec/config/setup-unit spec/unit --recursive", "test:integration": "mocha --require spec/config/setup spec/integration --recursive", + "test:integration2": "mocha --require spec/config/setup spec/integration/pr/test-pr-1899.spec.js", "test:end-to-end": "mocha --require spec/config/setup spec/end-to-end --recursive", "test:browser": "if [ ! -f .disable-test-browser ]; then npm run build && npm run test:jasmine; fi", "test:jasmine": "grunt jasmine", diff --git a/spec/integration/pr/test-pr-1899.spec.js b/spec/integration/pr/test-pr-1899.spec.js new file mode 100644 index 0000000..c67764e --- /dev/null +++ b/spec/integration/pr/test-pr-1899.spec.js @@ -0,0 +1,87 @@ +const ExcelJS = verquire('exceljs'); + +const TEST_1899_XLSX_FILE_NAME = './spec/integration/data/test-pr-1899.xlsx'; + +describe('pull request 1899', () => { + it('pull request 1899- Support nested columns feature', async () => { + async function test() { + const workbook = new ExcelJS.Workbook(); + // const worksheet = workbook.addWorksheet('sheet'); + const worksheet = workbook.addWorksheet('sheet', { + // properties: {defaultColWidth: 25}, + views: [{state: 'frozen', xSplit: 0, ySplit: 3}], // 冻结第1行和第二行 + }); + + worksheet.makeColumns([ + { + id: 1, + title: '姓名', + }, + {id: 2, title: 'Qwe'}, + {id: 3, title: 'Foo'}, + { + id: 4, + title: '基础信息', + children: [ + {id: 41, title: 'Zoo 1'}, + {id: 42, title: 'Zoo 2'}, + {id: 44, title: 'Zoo 3'}, + { + id: 45, + title: 'Zoo 4', + children: [ + {id: 451, title: 'Zoo 3XXXX'}, + {id: 452, title: 'Zoo 3XXXX1232'}, + ], + }, + ], + }, + { + id: 5, + title: 'Zoo1', + children: [ + {id: 51, title: 'Zoo 51'}, + {id: 52, title: 'Zoo 52'}, + {id: 54, title: 'Zoo 53'}, + ], + }, + {id: 6, title: 'Foo123213'}, + ]); + const data = [ + [ + 1, + 'electron', + 'DOB', + 'DOB', + 'DOB', + 'DOB', + 'DOB', + 'DOB', + 'DOB', + 'DOB', + 'DOB', + 'DOB', + 'DOB', + 'DOB', + ], + [null, null, null, null, null, 'DOB'], + [1, 'electron', 'DOB'], + [1, 'electron', 'DOB'], + [1, 'electron', 'DOB'], + [1, 'electron', 'DOB'], + [1, 'electron', 'DOB'], + [1, 'electron', 'DOB'], + [1, 'electron', 'DOB'], + ]; + worksheet.addRows(data); + worksheet.columns.forEach(function(column) { + column.alignment = {horizontal: 'center', vertical: 'middle'}; + }); + await workbook.xlsx.writeFile(TEST_1899_XLSX_FILE_NAME); + } + + await test(); + + // expect(error).to.be.an('error'); + }); +}); From a905bea990e8b47d1ed513cc6a012073a9fc8744 Mon Sep 17 00:00:00 2001 From: linxl <1658370535@qq.com> Date: Thu, 8 Jun 2023 16:00:11 +0800 Subject: [PATCH 4/6] update index.t.ds abd readme.md --- README.md | 66 +++++++++++++++++++- README_zh.md | 66 +++++++++++++++++++- index.d.ts | 32 +++++++++- lib/doc/worksheet.js | 3 + lib/utils/column-flatter.js | 8 +-- spec/integration/pr/test-pr-1899.spec.js | 77 +++++++++++++++--------- 6 files changed, 215 insertions(+), 37 deletions(-) diff --git a/README.md b/README.md index f8cfdb9..9f81bb9 100644 --- a/README.md +++ b/README.md @@ -13,9 +13,43 @@ Reverse engineered from Excel spreadsheet files as a project. # Installation ```shell -npm install exceljs +npm install @zurmokeeper/exceljs ``` +# V4.4.1 New Features! + +Change Log: + + +# V4.4.0 New Features! + +Change Log: + + # New Features! From 37df5de6da8eb5cf627ee564f810938bcede76e0 Mon Sep 17 00:00:00 2001 From: linxl <1658370535@qq.com> Date: Thu, 8 Jun 2023 16:09:03 +0800 Subject: [PATCH 6/6] update package.json version --- package.json | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/package.json b/package.json index c4e0b10..13ae6b2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@zurmokeeper/exceljs", - "version": "4.4.0", + "version": "4.4.1", "description": "Excel Workbook Manager - Read and Write xlsx and csv Files.", "private": false, "license": "MIT", @@ -37,7 +37,6 @@ "test:native": "npm run test:full", "test:unit": "mocha --require spec/config/setup --require spec/config/setup-unit spec/unit --recursive", "test:integration": "mocha --require spec/config/setup spec/integration --recursive", - "test:integration2": "mocha --require spec/config/setup spec/integration/pr/test-pr-1899.spec.js", "test:end-to-end": "mocha --require spec/config/setup spec/end-to-end --recursive", "test:browser": "if [ ! -f .disable-test-browser ]; then npm run build && npm run test:jasmine; fi", "test:jasmine": "grunt jasmine",