Skip to content

Commit d18fb91

Browse files
add nested columns feature
1 parent 62a010a commit d18fb91

File tree

4 files changed

+363
-0
lines changed

4 files changed

+363
-0
lines changed

lib/doc/worksheet.js

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ const Table = require('./table');
1010
const DataValidations = require('./data-validations');
1111
const Encryptor = require('../utils/encryptor');
1212
const {copyStyle} = require('../utils/copy-style');
13+
const ColumnFlatter = require('../utils/column-flatter');
1314

1415
// Worksheet requirements
1516
// Operate as sheet inside workbook or standalone
@@ -922,6 +923,34 @@ class Worksheet {
922923
}, {});
923924
this.conditionalFormattings = value.conditionalFormattings;
924925
}
926+
927+
makeColumns(input) {
928+
const flatter = new ColumnFlatter(input);
929+
const merges = flatter.getMerges();
930+
const rows = flatter.getRows();
931+
932+
this.columns = flatter.getColumns();
933+
934+
// For the time being, do not write dead freeze, can be set by the developers themselves
935+
// this.views.push({state: 'frozen', ySplit: rows.length});
936+
937+
this.addRows(
938+
rows.map(row => {
939+
return row.map(item => {
940+
if (!item) return null;
941+
if (item.title) return item.title;
942+
if (item.id) return item.id;
943+
return null;
944+
});
945+
})
946+
);
947+
948+
merges.forEach(item => {
949+
this.mergeCells(item);
950+
});
951+
952+
return {rows};
953+
}
925954
}
926955

927956
module.exports = Worksheet;

lib/utils/column-flatter.js

Lines changed: 246 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,246 @@
1+
const colCache = require('./col-cache');
2+
3+
/**
4+
* ColumnFlatter is a helper class to create sheets with nested columns.
5+
*
6+
* Based on following concepts
7+
* - Walk throught nested input structure to build flat list and tree meta information
8+
* - Use "leaf" columns as physical cols and "branch" as merge-slots
9+
* - Generate cell matrix and merge rules
10+
*/
11+
class ColumnFlatter {
12+
constructor(input, params) {
13+
this._params = params;
14+
// id-value storage for item aggregate sizes
15+
this._sizes = {};
16+
17+
// flat columns list
18+
this._list = [];
19+
20+
// cells matrix storage
21+
this._rows = [];
22+
23+
this._getFlatList(input);
24+
this._alignRows(this._alignCells());
25+
26+
// merge rules storage
27+
this._merges = [...this._calcVerticalMerges(), ...this._calcHorizontalMerges()];
28+
}
29+
30+
/**
31+
* Append null placeholders for entity list alignment
32+
*/
33+
_pad(arr, num) {
34+
if (num > 0) {
35+
for (let i = 0; i < num; i++) {
36+
arr.push(null);
37+
}
38+
}
39+
}
40+
41+
/**
42+
* Filters off invalid columns entries
43+
*/
44+
_check(item) {
45+
return item && item.id;
46+
}
47+
48+
/**
49+
* Walk throught tree input.
50+
* Build flat columns list and aggregate column size (recursive children length sum)
51+
*/
52+
_getFlatList(input) {
53+
const trace = (item, meta) => {
54+
if (!this._check(item)) return;
55+
56+
const path = [...meta.path, item.id];
57+
const children = (item.children || []).filter(this._check);
58+
59+
if (children.length && children.length > 1) {
60+
for (const id of path) {
61+
if (!this._sizes[id]) {
62+
this._sizes[id] = 0;
63+
}
64+
65+
this._sizes[id] += children.length - 1;
66+
}
67+
68+
for (const child of children) {
69+
trace(child, {path});
70+
}
71+
}
72+
73+
this._list.push({
74+
meta,
75+
...(children.length === 1 ? children[0] : item),
76+
});
77+
};
78+
79+
for (const item of input) {
80+
trace(item, {path: []});
81+
}
82+
}
83+
84+
/**
85+
* Align with cells with null-ish appending
86+
* by aggregated size num
87+
*/
88+
_alignCells() {
89+
const res = [];
90+
91+
for (const item of this._list) {
92+
const index = item.meta.path.length;
93+
94+
if (!res[index]) {
95+
res[index] = [];
96+
}
97+
98+
res[index].push(item);
99+
100+
if (item.children) {
101+
this._pad(res[index], this._sizes[item.id]);
102+
}
103+
}
104+
105+
return res;
106+
}
107+
108+
/**
109+
* Align cell groups in rows according
110+
* parent cell position
111+
*/
112+
_alignRows(cells) {
113+
const width = cells.reduce((acc, row) => Math.max(acc, row.length), 0);
114+
115+
for (let i = 0; i < cells.length; i++) {
116+
const row = cells[i];
117+
118+
if (!i) {
119+
this._rows.push(row);
120+
} else {
121+
const items = [];
122+
const handled = {};
123+
let added = 0;
124+
125+
for (let k = 0; k < width; k++) {
126+
const item = row[k];
127+
128+
if (k + added >= width) {
129+
break;
130+
}
131+
132+
if (item) {
133+
const {path} = item.meta;
134+
const parent = path[path.length - 1];
135+
136+
if (parent) {
137+
const parentPos = this._rows[i - 1].findIndex(el => (el || {}).id === parent);
138+
const offset = parentPos - (k + added);
139+
140+
if (offset > 0 && !handled[parent]) {
141+
added += offset;
142+
143+
this._pad(items, offset);
144+
145+
handled[parent] = true;
146+
}
147+
}
148+
}
149+
150+
items.push(item || null);
151+
}
152+
153+
this._rows.push(items);
154+
}
155+
}
156+
}
157+
158+
/**
159+
* Calculates horizontal merge rules
160+
*
161+
* Walks width-throught rows collecting ranges with cell index and its recursive size
162+
*/
163+
_calcHorizontalMerges() {
164+
const res = [];
165+
166+
for (let i = 0; i < this._rows.length; i++) {
167+
const cells = this._rows[i];
168+
169+
for (let k = 0; k < cells.length; k++) {
170+
const cell = cells[k];
171+
const span = cell && this._sizes[cell.id];
172+
173+
if (span) {
174+
const row = i + 1;
175+
176+
res.push(colCache.encode(row, k + 1, row, k + span + 1));
177+
}
178+
}
179+
}
180+
181+
return res;
182+
}
183+
184+
/**
185+
* Calculates vertical merge rules
186+
*
187+
* Walks deep-throught rows looking for non-empty cell in row
188+
*/
189+
_calcVerticalMerges() {
190+
const depth = this._rows.length - 1;
191+
const width = this._rows[0].length;
192+
const res = [];
193+
194+
for (let i = 0; i < width; i++) {
195+
for (let k = depth; k >= 0; k--) {
196+
if (this._rows[k][i]) {
197+
const col = i + 1;
198+
199+
if (k !== depth) {
200+
res.push(colCache.encode(k + 1, col, depth + 1, col));
201+
}
202+
203+
break;
204+
}
205+
}
206+
}
207+
208+
return res;
209+
}
210+
211+
/**
212+
* Collect "leaf" columns
213+
*
214+
* Filter off all cells with "children" property
215+
*/
216+
getColumns() {
217+
const res = [];
218+
219+
for (const item of this._list) {
220+
if (!item.children) {
221+
res.push({
222+
id: item.id,
223+
...item,
224+
});
225+
}
226+
}
227+
228+
return res;
229+
}
230+
231+
/**
232+
* Cells matrix getter
233+
*/
234+
getRows() {
235+
return this._rows;
236+
}
237+
238+
/**
239+
* Merge rules getter
240+
*/
241+
getMerges() {
242+
return this._merges;
243+
}
244+
}
245+
246+
module.exports = ColumnFlatter;

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@
3737
"test:native": "npm run test:full",
3838
"test:unit": "mocha --require spec/config/setup --require spec/config/setup-unit spec/unit --recursive",
3939
"test:integration": "mocha --require spec/config/setup spec/integration --recursive",
40+
"test:integration2": "mocha --require spec/config/setup spec/integration/pr/test-pr-1899.spec.js",
4041
"test:end-to-end": "mocha --require spec/config/setup spec/end-to-end --recursive",
4142
"test:browser": "if [ ! -f .disable-test-browser ]; then npm run build && npm run test:jasmine; fi",
4243
"test:jasmine": "grunt jasmine",
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
const ExcelJS = verquire('exceljs');
2+
3+
const TEST_1899_XLSX_FILE_NAME = './spec/integration/data/test-pr-1899.xlsx';
4+
5+
describe('pull request 1899', () => {
6+
it('pull request 1899- Support nested columns feature', async () => {
7+
async function test() {
8+
const workbook = new ExcelJS.Workbook();
9+
// const worksheet = workbook.addWorksheet('sheet');
10+
const worksheet = workbook.addWorksheet('sheet', {
11+
// properties: {defaultColWidth: 25},
12+
views: [{state: 'frozen', xSplit: 0, ySplit: 3}], // 冻结第1行和第二行
13+
});
14+
15+
worksheet.makeColumns([
16+
{
17+
id: 1,
18+
title: '姓名',
19+
},
20+
{id: 2, title: 'Qwe'},
21+
{id: 3, title: 'Foo'},
22+
{
23+
id: 4,
24+
title: '基础信息',
25+
children: [
26+
{id: 41, title: 'Zoo 1'},
27+
{id: 42, title: 'Zoo 2'},
28+
{id: 44, title: 'Zoo 3'},
29+
{
30+
id: 45,
31+
title: 'Zoo 4',
32+
children: [
33+
{id: 451, title: 'Zoo 3XXXX'},
34+
{id: 452, title: 'Zoo 3XXXX1232'},
35+
],
36+
},
37+
],
38+
},
39+
{
40+
id: 5,
41+
title: 'Zoo1',
42+
children: [
43+
{id: 51, title: 'Zoo 51'},
44+
{id: 52, title: 'Zoo 52'},
45+
{id: 54, title: 'Zoo 53'},
46+
],
47+
},
48+
{id: 6, title: 'Foo123213'},
49+
]);
50+
const data = [
51+
[
52+
1,
53+
'electron',
54+
'DOB',
55+
'DOB',
56+
'DOB',
57+
'DOB',
58+
'DOB',
59+
'DOB',
60+
'DOB',
61+
'DOB',
62+
'DOB',
63+
'DOB',
64+
'DOB',
65+
'DOB',
66+
],
67+
[null, null, null, null, null, 'DOB'],
68+
[1, 'electron', 'DOB'],
69+
[1, 'electron', 'DOB'],
70+
[1, 'electron', 'DOB'],
71+
[1, 'electron', 'DOB'],
72+
[1, 'electron', 'DOB'],
73+
[1, 'electron', 'DOB'],
74+
[1, 'electron', 'DOB'],
75+
];
76+
worksheet.addRows(data);
77+
worksheet.columns.forEach(function(column) {
78+
column.alignment = {horizontal: 'center', vertical: 'middle'};
79+
});
80+
await workbook.xlsx.writeFile(TEST_1899_XLSX_FILE_NAME);
81+
}
82+
83+
await test();
84+
85+
// expect(error).to.be.an('error');
86+
});
87+
});

0 commit comments

Comments
 (0)