@cj-tech-master/excelts 1.4.3 → 1.4.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -3
- package/README_zh.md +3 -3
- package/dist/browser/excelts.iife.js +12841 -7484
- package/dist/browser/excelts.iife.js.map +1 -1
- package/dist/browser/excelts.iife.min.js +86 -23
- package/dist/cjs/doc/column.js +1 -1
- package/dist/cjs/doc/row.js +9 -4
- package/dist/cjs/doc/worksheet.js +9 -4
- package/dist/cjs/stream/xlsx/workbook-writer.js +3 -2
- package/dist/cjs/utils/unzip/extract.js +166 -0
- package/dist/cjs/utils/unzip/index.js +7 -1
- package/dist/cjs/utils/xml-stream.js +25 -3
- package/dist/cjs/utils/zip/compress.js +261 -0
- package/dist/cjs/utils/zip/crc32.js +154 -0
- package/dist/cjs/utils/zip/index.js +70 -0
- package/dist/cjs/utils/zip/zip-builder.js +378 -0
- package/dist/cjs/utils/zip-stream.js +30 -34
- package/dist/cjs/xlsx/xform/book/defined-name-xform.js +36 -2
- package/dist/cjs/xlsx/xform/list-xform.js +6 -0
- package/dist/cjs/xlsx/xform/sheet/cell-xform.js +6 -1
- package/dist/cjs/xlsx/xform/sheet/row-xform.js +24 -2
- package/dist/cjs/xlsx/xform/table/filter-column-xform.js +4 -0
- package/dist/esm/doc/column.js +1 -1
- package/dist/esm/doc/row.js +9 -4
- package/dist/esm/doc/worksheet.js +9 -4
- package/dist/esm/stream/xlsx/workbook-writer.js +3 -2
- package/dist/esm/utils/unzip/extract.js +160 -0
- package/dist/esm/utils/unzip/index.js +2 -0
- package/dist/esm/utils/xml-stream.js +25 -3
- package/dist/esm/utils/zip/compress.js +220 -0
- package/dist/esm/utils/zip/crc32.js +116 -0
- package/dist/esm/utils/zip/index.js +55 -0
- package/dist/esm/utils/zip/zip-builder.js +372 -0
- package/dist/esm/utils/zip-stream.js +30 -34
- package/dist/esm/xlsx/xform/book/defined-name-xform.js +36 -2
- package/dist/esm/xlsx/xform/list-xform.js +6 -0
- package/dist/esm/xlsx/xform/sheet/cell-xform.js +6 -1
- package/dist/esm/xlsx/xform/sheet/row-xform.js +24 -2
- package/dist/esm/xlsx/xform/table/filter-column-xform.js +4 -0
- package/dist/types/doc/cell.d.ts +10 -6
- package/dist/types/doc/column.d.ts +8 -4
- package/dist/types/doc/row.d.ts +9 -8
- package/dist/types/doc/worksheet.d.ts +2 -2
- package/dist/types/utils/unzip/extract.d.ts +92 -0
- package/dist/types/utils/unzip/index.d.ts +1 -0
- package/dist/types/utils/xml-stream.d.ts +2 -0
- package/dist/types/utils/zip/compress.d.ts +83 -0
- package/dist/types/utils/zip/crc32.d.ts +55 -0
- package/dist/types/utils/zip/index.d.ts +52 -0
- package/dist/types/utils/zip/zip-builder.d.ts +110 -0
- package/dist/types/utils/zip-stream.d.ts +6 -12
- package/dist/types/xlsx/xform/list-xform.d.ts +1 -0
- package/dist/types/xlsx/xform/sheet/row-xform.d.ts +2 -0
- package/package.json +8 -8
|
@@ -0,0 +1,372 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ZIP file format builder
|
|
3
|
+
*
|
|
4
|
+
* Implements ZIP file structure according to PKWARE's APPNOTE.TXT specification
|
|
5
|
+
* https://pkware.cachefly.net/webdocs/casestudies/APPNOTE.TXT
|
|
6
|
+
*
|
|
7
|
+
* ZIP file structure:
|
|
8
|
+
* ┌──────────────────────────┐
|
|
9
|
+
* │ Local File Header 1 │
|
|
10
|
+
* │ File Data 1 │
|
|
11
|
+
* ├──────────────────────────┤
|
|
12
|
+
* │ Local File Header 2 │
|
|
13
|
+
* │ File Data 2 │
|
|
14
|
+
* ├──────────────────────────┤
|
|
15
|
+
* │ ... │
|
|
16
|
+
* ├──────────────────────────┤
|
|
17
|
+
* │ Central Directory 1 │
|
|
18
|
+
* │ Central Directory 2 │
|
|
19
|
+
* │ ... │
|
|
20
|
+
* ├──────────────────────────┤
|
|
21
|
+
* │ End of Central Directory │
|
|
22
|
+
* └──────────────────────────┘
|
|
23
|
+
*/
|
|
24
|
+
import { crc32 } from "./crc32.js";
|
|
25
|
+
import { compress, compressSync } from "./compress.js";
|
|
26
|
+
// ZIP signature constants
|
|
27
|
+
const LOCAL_FILE_HEADER_SIG = 0x04034b50;
|
|
28
|
+
const CENTRAL_DIR_HEADER_SIG = 0x02014b50;
|
|
29
|
+
const END_OF_CENTRAL_DIR_SIG = 0x06054b50;
|
|
30
|
+
// ZIP version constants
|
|
31
|
+
const VERSION_NEEDED = 20; // 2.0 - supports DEFLATE
|
|
32
|
+
const VERSION_MADE_BY = 20; // 2.0
|
|
33
|
+
// Compression methods
|
|
34
|
+
const COMPRESSION_STORE = 0;
|
|
35
|
+
const COMPRESSION_DEFLATE = 8;
|
|
36
|
+
/**
|
|
37
|
+
* Convert Date to DOS time format
|
|
38
|
+
* @param date - Date to convert
|
|
39
|
+
* @returns [dosTime, dosDate]
|
|
40
|
+
*/
|
|
41
|
+
function dateToDos(date) {
|
|
42
|
+
const dosTime = ((date.getHours() & 0x1f) << 11) |
|
|
43
|
+
((date.getMinutes() & 0x3f) << 5) |
|
|
44
|
+
((date.getSeconds() >> 1) & 0x1f);
|
|
45
|
+
const dosDate = (((date.getFullYear() - 1980) & 0x7f) << 9) |
|
|
46
|
+
(((date.getMonth() + 1) & 0x0f) << 5) |
|
|
47
|
+
(date.getDate() & 0x1f);
|
|
48
|
+
return [dosTime, dosDate];
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Encode string to UTF-8 bytes
|
|
52
|
+
*/
|
|
53
|
+
const encoder = new TextEncoder();
|
|
54
|
+
function encodeString(str) {
|
|
55
|
+
return encoder.encode(str);
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Build Local File Header (30 bytes + filename + extra)
|
|
59
|
+
*/
|
|
60
|
+
function buildLocalFileHeader(entry) {
|
|
61
|
+
const header = new Uint8Array(30 + entry.name.length);
|
|
62
|
+
const view = new DataView(header.buffer);
|
|
63
|
+
view.setUint32(0, LOCAL_FILE_HEADER_SIG, true); // Signature
|
|
64
|
+
view.setUint16(4, VERSION_NEEDED, true); // Version needed to extract
|
|
65
|
+
view.setUint16(6, 0x0800, true); // General purpose bit flag (UTF-8 names)
|
|
66
|
+
view.setUint16(8, entry.compressionMethod, true); // Compression method
|
|
67
|
+
view.setUint16(10, entry.modTime, true); // Last mod time
|
|
68
|
+
view.setUint16(12, entry.modDate, true); // Last mod date
|
|
69
|
+
view.setUint32(14, entry.crc, true); // CRC-32
|
|
70
|
+
view.setUint32(18, entry.compressedData.length, true); // Compressed size
|
|
71
|
+
view.setUint32(22, entry.data.length, true); // Uncompressed size
|
|
72
|
+
view.setUint16(26, entry.name.length, true); // Filename length
|
|
73
|
+
view.setUint16(28, 0, true); // Extra field length
|
|
74
|
+
header.set(entry.name, 30);
|
|
75
|
+
return header;
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Build Central Directory Header (46 bytes + filename + extra + comment)
|
|
79
|
+
*/
|
|
80
|
+
function buildCentralDirHeader(entry) {
|
|
81
|
+
const header = new Uint8Array(46 + entry.name.length + entry.comment.length);
|
|
82
|
+
const view = new DataView(header.buffer);
|
|
83
|
+
view.setUint32(0, CENTRAL_DIR_HEADER_SIG, true); // Signature
|
|
84
|
+
view.setUint16(4, VERSION_MADE_BY, true); // Version made by
|
|
85
|
+
view.setUint16(6, VERSION_NEEDED, true); // Version needed to extract
|
|
86
|
+
view.setUint16(8, 0x0800, true); // General purpose bit flag (UTF-8 names)
|
|
87
|
+
view.setUint16(10, entry.compressionMethod, true); // Compression method
|
|
88
|
+
view.setUint16(12, entry.modTime, true); // Last mod time
|
|
89
|
+
view.setUint16(14, entry.modDate, true); // Last mod date
|
|
90
|
+
view.setUint32(16, entry.crc, true); // CRC-32
|
|
91
|
+
view.setUint32(20, entry.compressedData.length, true); // Compressed size
|
|
92
|
+
view.setUint32(24, entry.data.length, true); // Uncompressed size
|
|
93
|
+
view.setUint16(28, entry.name.length, true); // Filename length
|
|
94
|
+
view.setUint16(30, 0, true); // Extra field length
|
|
95
|
+
view.setUint16(32, entry.comment.length, true); // Comment length
|
|
96
|
+
view.setUint16(34, 0, true); // Disk number start
|
|
97
|
+
view.setUint16(36, 0, true); // Internal file attributes
|
|
98
|
+
view.setUint32(38, 0, true); // External file attributes
|
|
99
|
+
view.setUint32(42, entry.offset, true); // Relative offset of local header
|
|
100
|
+
header.set(entry.name, 46);
|
|
101
|
+
if (entry.comment.length > 0) {
|
|
102
|
+
header.set(entry.comment, 46 + entry.name.length);
|
|
103
|
+
}
|
|
104
|
+
return header;
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Build End of Central Directory Record (22 bytes + comment)
|
|
108
|
+
*/
|
|
109
|
+
function buildEndOfCentralDir(entryCount, centralDirSize, centralDirOffset, comment) {
|
|
110
|
+
const record = new Uint8Array(22 + comment.length);
|
|
111
|
+
const view = new DataView(record.buffer);
|
|
112
|
+
view.setUint32(0, END_OF_CENTRAL_DIR_SIG, true); // Signature
|
|
113
|
+
view.setUint16(4, 0, true); // Number of this disk
|
|
114
|
+
view.setUint16(6, 0, true); // Disk where central dir starts
|
|
115
|
+
view.setUint16(8, entryCount, true); // Number of entries on this disk
|
|
116
|
+
view.setUint16(10, entryCount, true); // Total number of entries
|
|
117
|
+
view.setUint32(12, centralDirSize, true); // Size of central directory
|
|
118
|
+
view.setUint32(16, centralDirOffset, true); // Offset of central directory
|
|
119
|
+
view.setUint16(20, comment.length, true); // Comment length
|
|
120
|
+
if (comment.length > 0) {
|
|
121
|
+
record.set(comment, 22);
|
|
122
|
+
}
|
|
123
|
+
return record;
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* Create a ZIP file from entries (async)
|
|
127
|
+
*
|
|
128
|
+
* @param entries - Files to include in ZIP
|
|
129
|
+
* @param options - ZIP options
|
|
130
|
+
* @returns ZIP file as Uint8Array
|
|
131
|
+
*
|
|
132
|
+
* @example
|
|
133
|
+
* ```ts
|
|
134
|
+
* const zip = await createZip([
|
|
135
|
+
* { name: "hello.txt", data: new TextEncoder().encode("Hello!") },
|
|
136
|
+
* { name: "folder/file.txt", data: new TextEncoder().encode("Nested!") }
|
|
137
|
+
* ], { level: 6 });
|
|
138
|
+
* ```
|
|
139
|
+
*/
|
|
140
|
+
export async function createZip(entries, options = {}) {
|
|
141
|
+
const level = options.level ?? 6;
|
|
142
|
+
const zipComment = encodeString(options.comment ?? "");
|
|
143
|
+
const now = new Date();
|
|
144
|
+
// Process entries
|
|
145
|
+
const processedEntries = [];
|
|
146
|
+
let currentOffset = 0;
|
|
147
|
+
for (const entry of entries) {
|
|
148
|
+
const nameBytes = encodeString(entry.name);
|
|
149
|
+
const commentBytes = encodeString(entry.comment ?? "");
|
|
150
|
+
const modDate = entry.modTime ?? now;
|
|
151
|
+
const [dosTime, dosDate] = dateToDos(modDate);
|
|
152
|
+
// Compress data
|
|
153
|
+
const isCompressed = level > 0 && entry.data.length > 0;
|
|
154
|
+
const compressedData = isCompressed ? await compress(entry.data, { level }) : entry.data;
|
|
155
|
+
const processedEntry = {
|
|
156
|
+
name: nameBytes,
|
|
157
|
+
data: entry.data,
|
|
158
|
+
compressedData,
|
|
159
|
+
crc: crc32(entry.data),
|
|
160
|
+
compressionMethod: isCompressed ? COMPRESSION_DEFLATE : COMPRESSION_STORE,
|
|
161
|
+
modTime: dosTime,
|
|
162
|
+
modDate: dosDate,
|
|
163
|
+
comment: commentBytes,
|
|
164
|
+
offset: currentOffset
|
|
165
|
+
};
|
|
166
|
+
// Calculate offset for next entry
|
|
167
|
+
currentOffset += 30 + nameBytes.length + compressedData.length;
|
|
168
|
+
processedEntries.push(processedEntry);
|
|
169
|
+
}
|
|
170
|
+
// Build ZIP structure
|
|
171
|
+
const chunks = [];
|
|
172
|
+
// Local file headers and data
|
|
173
|
+
for (const entry of processedEntries) {
|
|
174
|
+
chunks.push(buildLocalFileHeader(entry));
|
|
175
|
+
chunks.push(entry.compressedData);
|
|
176
|
+
}
|
|
177
|
+
const centralDirOffset = currentOffset;
|
|
178
|
+
// Central directory
|
|
179
|
+
const centralDirChunks = [];
|
|
180
|
+
for (const entry of processedEntries) {
|
|
181
|
+
centralDirChunks.push(buildCentralDirHeader(entry));
|
|
182
|
+
}
|
|
183
|
+
chunks.push(...centralDirChunks);
|
|
184
|
+
const centralDirSize = centralDirChunks.reduce((sum, c) => sum + c.length, 0);
|
|
185
|
+
// End of central directory
|
|
186
|
+
chunks.push(buildEndOfCentralDir(processedEntries.length, centralDirSize, centralDirOffset, zipComment));
|
|
187
|
+
// Combine all chunks
|
|
188
|
+
const totalSize = chunks.reduce((sum, c) => sum + c.length, 0);
|
|
189
|
+
const result = new Uint8Array(totalSize);
|
|
190
|
+
let offset = 0;
|
|
191
|
+
for (const chunk of chunks) {
|
|
192
|
+
result.set(chunk, offset);
|
|
193
|
+
offset += chunk.length;
|
|
194
|
+
}
|
|
195
|
+
return result;
|
|
196
|
+
}
|
|
197
|
+
/**
|
|
198
|
+
* Create a ZIP file from entries (sync, Node.js only)
|
|
199
|
+
*
|
|
200
|
+
* @param entries - Files to include in ZIP
|
|
201
|
+
* @param options - ZIP options
|
|
202
|
+
* @returns ZIP file as Uint8Array
|
|
203
|
+
* @throws Error if not in Node.js environment
|
|
204
|
+
*/
|
|
205
|
+
export function createZipSync(entries, options = {}) {
|
|
206
|
+
const level = options.level ?? 6;
|
|
207
|
+
const zipComment = encodeString(options.comment ?? "");
|
|
208
|
+
const now = new Date();
|
|
209
|
+
// Process entries
|
|
210
|
+
const processedEntries = [];
|
|
211
|
+
let currentOffset = 0;
|
|
212
|
+
for (const entry of entries) {
|
|
213
|
+
const nameBytes = encodeString(entry.name);
|
|
214
|
+
const commentBytes = encodeString(entry.comment ?? "");
|
|
215
|
+
const modDate = entry.modTime ?? now;
|
|
216
|
+
const [dosTime, dosDate] = dateToDos(modDate);
|
|
217
|
+
// Compress data
|
|
218
|
+
const isCompressed = level > 0 && entry.data.length > 0;
|
|
219
|
+
const compressedData = isCompressed ? compressSync(entry.data, { level }) : entry.data;
|
|
220
|
+
const processedEntry = {
|
|
221
|
+
name: nameBytes,
|
|
222
|
+
data: entry.data,
|
|
223
|
+
compressedData,
|
|
224
|
+
crc: crc32(entry.data),
|
|
225
|
+
compressionMethod: isCompressed ? COMPRESSION_DEFLATE : COMPRESSION_STORE,
|
|
226
|
+
modTime: dosTime,
|
|
227
|
+
modDate: dosDate,
|
|
228
|
+
comment: commentBytes,
|
|
229
|
+
offset: currentOffset
|
|
230
|
+
};
|
|
231
|
+
currentOffset += 30 + nameBytes.length + compressedData.length;
|
|
232
|
+
processedEntries.push(processedEntry);
|
|
233
|
+
}
|
|
234
|
+
// Build ZIP structure
|
|
235
|
+
const chunks = [];
|
|
236
|
+
// Local file headers and data
|
|
237
|
+
for (const entry of processedEntries) {
|
|
238
|
+
chunks.push(buildLocalFileHeader(entry));
|
|
239
|
+
chunks.push(entry.compressedData);
|
|
240
|
+
}
|
|
241
|
+
const centralDirOffset = currentOffset;
|
|
242
|
+
// Central directory
|
|
243
|
+
const centralDirChunks = [];
|
|
244
|
+
for (const entry of processedEntries) {
|
|
245
|
+
centralDirChunks.push(buildCentralDirHeader(entry));
|
|
246
|
+
}
|
|
247
|
+
chunks.push(...centralDirChunks);
|
|
248
|
+
const centralDirSize = centralDirChunks.reduce((sum, c) => sum + c.length, 0);
|
|
249
|
+
// End of central directory
|
|
250
|
+
chunks.push(buildEndOfCentralDir(processedEntries.length, centralDirSize, centralDirOffset, zipComment));
|
|
251
|
+
// Combine all chunks
|
|
252
|
+
const totalSize = chunks.reduce((sum, c) => sum + c.length, 0);
|
|
253
|
+
const result = new Uint8Array(totalSize);
|
|
254
|
+
let offset = 0;
|
|
255
|
+
for (const chunk of chunks) {
|
|
256
|
+
result.set(chunk, offset);
|
|
257
|
+
offset += chunk.length;
|
|
258
|
+
}
|
|
259
|
+
return result;
|
|
260
|
+
}
|
|
261
|
+
/**
|
|
262
|
+
* Streaming ZIP builder for large files
|
|
263
|
+
* Writes chunks to a callback as they are generated
|
|
264
|
+
*/
|
|
265
|
+
export class ZipBuilder {
|
|
266
|
+
/**
|
|
267
|
+
* Create a new ZIP builder
|
|
268
|
+
* @param options - ZIP options
|
|
269
|
+
*/
|
|
270
|
+
constructor(options = {}) {
|
|
271
|
+
this.entries = [];
|
|
272
|
+
this.currentOffset = 0;
|
|
273
|
+
this.finalized = false;
|
|
274
|
+
this.level = options.level ?? 6;
|
|
275
|
+
this.zipComment = encodeString(options.comment ?? "");
|
|
276
|
+
}
|
|
277
|
+
/**
|
|
278
|
+
* Add a file to the ZIP (async)
|
|
279
|
+
* @param entry - File entry
|
|
280
|
+
* @returns Local file header and compressed data chunks
|
|
281
|
+
*/
|
|
282
|
+
async addFile(entry) {
|
|
283
|
+
if (this.finalized) {
|
|
284
|
+
throw new Error("Cannot add files after finalizing");
|
|
285
|
+
}
|
|
286
|
+
const nameBytes = encodeString(entry.name);
|
|
287
|
+
const commentBytes = encodeString(entry.comment ?? "");
|
|
288
|
+
const [dosTime, dosDate] = dateToDos(entry.modTime ?? new Date());
|
|
289
|
+
// Compress data
|
|
290
|
+
const isCompressed = this.level > 0 && entry.data.length > 0;
|
|
291
|
+
const compressedData = isCompressed
|
|
292
|
+
? await compress(entry.data, { level: this.level })
|
|
293
|
+
: entry.data;
|
|
294
|
+
const processedEntry = {
|
|
295
|
+
name: nameBytes,
|
|
296
|
+
data: entry.data,
|
|
297
|
+
compressedData,
|
|
298
|
+
crc: crc32(entry.data),
|
|
299
|
+
compressionMethod: isCompressed ? COMPRESSION_DEFLATE : COMPRESSION_STORE,
|
|
300
|
+
modTime: dosTime,
|
|
301
|
+
modDate: dosDate,
|
|
302
|
+
comment: commentBytes,
|
|
303
|
+
offset: this.currentOffset
|
|
304
|
+
};
|
|
305
|
+
this.entries.push(processedEntry);
|
|
306
|
+
this.currentOffset += 30 + nameBytes.length + compressedData.length;
|
|
307
|
+
return [buildLocalFileHeader(processedEntry), compressedData];
|
|
308
|
+
}
|
|
309
|
+
/**
|
|
310
|
+
* Add a file to the ZIP (sync, Node.js only)
|
|
311
|
+
* @param entry - File entry
|
|
312
|
+
* @returns Local file header and compressed data chunks
|
|
313
|
+
*/
|
|
314
|
+
addFileSync(entry) {
|
|
315
|
+
if (this.finalized) {
|
|
316
|
+
throw new Error("Cannot add files after finalizing");
|
|
317
|
+
}
|
|
318
|
+
const nameBytes = encodeString(entry.name);
|
|
319
|
+
const commentBytes = encodeString(entry.comment ?? "");
|
|
320
|
+
const [dosTime, dosDate] = dateToDos(entry.modTime ?? new Date());
|
|
321
|
+
// Compress data
|
|
322
|
+
const isCompressed = this.level > 0 && entry.data.length > 0;
|
|
323
|
+
const compressedData = isCompressed
|
|
324
|
+
? compressSync(entry.data, { level: this.level })
|
|
325
|
+
: entry.data;
|
|
326
|
+
const processedEntry = {
|
|
327
|
+
name: nameBytes,
|
|
328
|
+
data: entry.data,
|
|
329
|
+
compressedData,
|
|
330
|
+
crc: crc32(entry.data),
|
|
331
|
+
compressionMethod: isCompressed ? COMPRESSION_DEFLATE : COMPRESSION_STORE,
|
|
332
|
+
modTime: dosTime,
|
|
333
|
+
modDate: dosDate,
|
|
334
|
+
comment: commentBytes,
|
|
335
|
+
offset: this.currentOffset
|
|
336
|
+
};
|
|
337
|
+
this.entries.push(processedEntry);
|
|
338
|
+
this.currentOffset += 30 + nameBytes.length + compressedData.length;
|
|
339
|
+
return [buildLocalFileHeader(processedEntry), compressedData];
|
|
340
|
+
}
|
|
341
|
+
/**
|
|
342
|
+
* Finalize the ZIP and return central directory + end record
|
|
343
|
+
* @returns Central directory and end of central directory chunks
|
|
344
|
+
*/
|
|
345
|
+
finalize() {
|
|
346
|
+
if (this.finalized) {
|
|
347
|
+
throw new Error("ZIP already finalized");
|
|
348
|
+
}
|
|
349
|
+
this.finalized = true;
|
|
350
|
+
const chunks = [];
|
|
351
|
+
// Central directory
|
|
352
|
+
for (const entry of this.entries) {
|
|
353
|
+
chunks.push(buildCentralDirHeader(entry));
|
|
354
|
+
}
|
|
355
|
+
const centralDirSize = chunks.reduce((sum, c) => sum + c.length, 0);
|
|
356
|
+
// End of central directory
|
|
357
|
+
chunks.push(buildEndOfCentralDir(this.entries.length, centralDirSize, this.currentOffset, this.zipComment));
|
|
358
|
+
return chunks;
|
|
359
|
+
}
|
|
360
|
+
/**
|
|
361
|
+
* Get current number of entries
|
|
362
|
+
*/
|
|
363
|
+
get entryCount() {
|
|
364
|
+
return this.entries.length;
|
|
365
|
+
}
|
|
366
|
+
/**
|
|
367
|
+
* Get current ZIP data size (without central directory)
|
|
368
|
+
*/
|
|
369
|
+
get dataSize() {
|
|
370
|
+
return this.currentOffset;
|
|
371
|
+
}
|
|
372
|
+
}
|
|
@@ -1,35 +1,23 @@
|
|
|
1
1
|
import events from "events";
|
|
2
|
-
import {
|
|
2
|
+
import { ZipBuilder } from "./zip/index.js";
|
|
3
3
|
import { StreamBuf } from "./stream-buf.js";
|
|
4
4
|
// =============================================================================
|
|
5
5
|
// The ZipWriter class
|
|
6
6
|
// Packs streamed data into an output zip stream
|
|
7
|
+
// Uses native zlib (Node.js) or CompressionStream (browser) for best performance
|
|
7
8
|
class ZipWriter extends events.EventEmitter {
|
|
8
9
|
constructor(options) {
|
|
9
10
|
super();
|
|
10
|
-
this.options = Object.assign({
|
|
11
|
-
type: "nodebuffer",
|
|
12
|
-
compression: "DEFLATE"
|
|
13
|
-
}, options);
|
|
14
|
-
// Default compression level is 6 (good balance of speed and size)
|
|
15
|
-
// 0 = no compression, 9 = best compression
|
|
16
|
-
const level = this.options.compressionOptions?.level ?? 6;
|
|
17
|
-
this.compressionLevel = Math.max(0, Math.min(9, level));
|
|
18
|
-
this.files = {};
|
|
19
|
-
this.stream = new StreamBuf();
|
|
20
11
|
this.finalized = false;
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
}
|
|
31
|
-
}
|
|
32
|
-
});
|
|
12
|
+
this.pendingWrites = [];
|
|
13
|
+
// Determine compression level:
|
|
14
|
+
// - STORE mode = 0 (no compression)
|
|
15
|
+
// - DEFLATE mode = user level or default 1 (fast compression)
|
|
16
|
+
const level = options?.compression === "STORE"
|
|
17
|
+
? 0
|
|
18
|
+
: Math.max(0, Math.min(9, options?.compressionOptions?.level ?? 1));
|
|
19
|
+
this.stream = new StreamBuf();
|
|
20
|
+
this.zipBuilder = new ZipBuilder({ level });
|
|
33
21
|
}
|
|
34
22
|
append(data, options) {
|
|
35
23
|
let buffer;
|
|
@@ -43,7 +31,7 @@ class ZipWriter extends events.EventEmitter {
|
|
|
43
31
|
buffer = Buffer.from(data, "utf8");
|
|
44
32
|
}
|
|
45
33
|
else if (Buffer.isBuffer(data)) {
|
|
46
|
-
// Buffer extends Uint8Array,
|
|
34
|
+
// Buffer extends Uint8Array, can use it directly - no copy needed
|
|
47
35
|
buffer = data;
|
|
48
36
|
}
|
|
49
37
|
else if (ArrayBuffer.isView(data)) {
|
|
@@ -58,14 +46,16 @@ class ZipWriter extends events.EventEmitter {
|
|
|
58
46
|
// Assume it's already a Uint8Array or compatible type
|
|
59
47
|
buffer = data;
|
|
60
48
|
}
|
|
61
|
-
// Add file to zip using
|
|
62
|
-
//
|
|
63
|
-
const
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
49
|
+
// Add file to zip using native compression
|
|
50
|
+
// addFile returns chunks that we write to stream immediately
|
|
51
|
+
const writePromise = this.zipBuilder
|
|
52
|
+
.addFile({ name: options.name, data: buffer })
|
|
53
|
+
.then(chunks => {
|
|
54
|
+
for (const chunk of chunks) {
|
|
55
|
+
this.stream.write(Buffer.from(chunk));
|
|
56
|
+
}
|
|
57
|
+
});
|
|
58
|
+
this.pendingWrites.push(writePromise);
|
|
69
59
|
}
|
|
70
60
|
push(chunk) {
|
|
71
61
|
return this.stream.push(chunk);
|
|
@@ -75,8 +65,14 @@ class ZipWriter extends events.EventEmitter {
|
|
|
75
65
|
return;
|
|
76
66
|
}
|
|
77
67
|
this.finalized = true;
|
|
78
|
-
//
|
|
79
|
-
this.
|
|
68
|
+
// Wait for all pending writes to complete
|
|
69
|
+
await Promise.all(this.pendingWrites);
|
|
70
|
+
// Finalize the zip and write central directory
|
|
71
|
+
const finalChunks = this.zipBuilder.finalize();
|
|
72
|
+
for (const chunk of finalChunks) {
|
|
73
|
+
this.stream.write(Buffer.from(chunk));
|
|
74
|
+
}
|
|
75
|
+
this.stream.end();
|
|
80
76
|
this.emit("finish");
|
|
81
77
|
}
|
|
82
78
|
// ==========================================================================
|
|
@@ -42,16 +42,50 @@ class DefinedNamesXform extends BaseXform {
|
|
|
42
42
|
return false;
|
|
43
43
|
}
|
|
44
44
|
}
|
|
45
|
+
// Regex to validate cell range format:
|
|
46
|
+
// - Cell: $A$1 or A1
|
|
47
|
+
// - Range: $A$1:$B$10 or A1:B10
|
|
48
|
+
// - Row range: $1:$2 (for print titles)
|
|
49
|
+
// - Column range: $A:$B (for print titles)
|
|
50
|
+
const cellRangeRegexp = /^[$]?[A-Za-z]{1,3}[$]?\d+(:[$]?[A-Za-z]{1,3}[$]?\d+)?$/;
|
|
51
|
+
const rowRangeRegexp = /^[$]?\d+:[$]?\d+$/;
|
|
52
|
+
const colRangeRegexp = /^[$]?[A-Za-z]{1,3}:[$]?[A-Za-z]{1,3}$/;
|
|
45
53
|
function isValidRange(range) {
|
|
54
|
+
// Skip array constants wrapped in {} - these are not valid cell ranges
|
|
55
|
+
// e.g., {"'Sheet1'!$A$1:$B$10"} or {#N/A,#N/A,FALSE,"text"}
|
|
56
|
+
if (range.startsWith("{") || range.endsWith("}")) {
|
|
57
|
+
return false;
|
|
58
|
+
}
|
|
59
|
+
// Extract the cell reference part (after the sheet name if present)
|
|
60
|
+
const cellRef = range.split("!").pop() || "";
|
|
61
|
+
// Must match one of the valid patterns
|
|
62
|
+
if (!cellRangeRegexp.test(cellRef) &&
|
|
63
|
+
!rowRangeRegexp.test(cellRef) &&
|
|
64
|
+
!colRangeRegexp.test(cellRef)) {
|
|
65
|
+
return false;
|
|
66
|
+
}
|
|
46
67
|
try {
|
|
47
|
-
colCache.decodeEx(range);
|
|
48
|
-
|
|
68
|
+
const decoded = colCache.decodeEx(range);
|
|
69
|
+
// For cell ranges: row/col or top/bottom/left/right should be valid numbers
|
|
70
|
+
// For row ranges ($1:$2): top/bottom are numbers, left/right are null
|
|
71
|
+
// For column ranges ($A:$B): left/right are numbers, top/bottom are null
|
|
72
|
+
if (("row" in decoded && typeof decoded.row === "number") ||
|
|
73
|
+
("top" in decoded && typeof decoded.top === "number") ||
|
|
74
|
+
("left" in decoded && typeof decoded.left === "number")) {
|
|
75
|
+
return true;
|
|
76
|
+
}
|
|
77
|
+
return false;
|
|
49
78
|
}
|
|
50
79
|
catch {
|
|
51
80
|
return false;
|
|
52
81
|
}
|
|
53
82
|
}
|
|
54
83
|
function extractRanges(parsedText) {
|
|
84
|
+
// Skip if the entire text is wrapped in {} (array constant)
|
|
85
|
+
const trimmed = parsedText.trim();
|
|
86
|
+
if (trimmed.startsWith("{") && trimmed.endsWith("}")) {
|
|
87
|
+
return [];
|
|
88
|
+
}
|
|
55
89
|
const ranges = [];
|
|
56
90
|
let quotesOpened = false;
|
|
57
91
|
let last = "";
|
|
@@ -407,7 +407,12 @@ class CellXform extends BaseXform {
|
|
|
407
407
|
}
|
|
408
408
|
break;
|
|
409
409
|
case Enums.ValueType.Formula:
|
|
410
|
-
|
|
410
|
+
// Only convert formula result to date if the result is a number
|
|
411
|
+
// String results (t="str") should not be converted even if the cell has a date format
|
|
412
|
+
if (model.result !== undefined &&
|
|
413
|
+
typeof model.result === "number" &&
|
|
414
|
+
style &&
|
|
415
|
+
isDateFmt(style.numFmt)) {
|
|
411
416
|
model.result = excelToDate(model.result, options.date1904);
|
|
412
417
|
}
|
|
413
418
|
if (model.shareType === "shared") {
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { BaseXform } from "../base-xform.js";
|
|
2
2
|
import { CellXform } from "./cell-xform.js";
|
|
3
3
|
import { parseBoolean } from "../../../utils/utils.js";
|
|
4
|
+
import { colCache } from "../../../utils/col-cache.js";
|
|
4
5
|
class RowXform extends BaseXform {
|
|
5
6
|
constructor(options) {
|
|
6
7
|
super();
|
|
@@ -12,6 +13,11 @@ class RowXform extends BaseXform {
|
|
|
12
13
|
get tag() {
|
|
13
14
|
return "row";
|
|
14
15
|
}
|
|
16
|
+
reset() {
|
|
17
|
+
super.reset();
|
|
18
|
+
this.numRowsSeen = 0;
|
|
19
|
+
this.lastCellCol = 0;
|
|
20
|
+
}
|
|
15
21
|
prepare(model, options) {
|
|
16
22
|
const styleId = options.styles.addStyleModel(model.style);
|
|
17
23
|
if (styleId) {
|
|
@@ -62,11 +68,15 @@ class RowXform extends BaseXform {
|
|
|
62
68
|
}
|
|
63
69
|
if (node.name === "row") {
|
|
64
70
|
this.numRowsSeen += 1;
|
|
71
|
+
// Reset lastCellCol for each new row
|
|
72
|
+
this.lastCellCol = 0;
|
|
65
73
|
const spans = node.attributes.spans
|
|
66
74
|
? node.attributes.spans.split(":").map((span) => parseInt(span, 10))
|
|
67
75
|
: [undefined, undefined];
|
|
76
|
+
// If r attribute is missing, use numRowsSeen as the row number
|
|
77
|
+
const rowNumber = node.attributes.r ? parseInt(node.attributes.r, 10) : this.numRowsSeen;
|
|
68
78
|
const model = (this.model = {
|
|
69
|
-
number:
|
|
79
|
+
number: rowNumber,
|
|
70
80
|
min: spans[0],
|
|
71
81
|
max: spans[1],
|
|
72
82
|
cells: []
|
|
@@ -106,7 +116,19 @@ class RowXform extends BaseXform {
|
|
|
106
116
|
parseClose(name) {
|
|
107
117
|
if (this.parser) {
|
|
108
118
|
if (!this.parser.parseClose(name)) {
|
|
109
|
-
this.
|
|
119
|
+
const cellModel = this.parser.model;
|
|
120
|
+
// If cell has address, extract column number from it
|
|
121
|
+
// Otherwise, calculate address based on position
|
|
122
|
+
if (cellModel.address) {
|
|
123
|
+
const decoded = colCache.decodeAddress(cellModel.address);
|
|
124
|
+
this.lastCellCol = decoded.col;
|
|
125
|
+
}
|
|
126
|
+
else {
|
|
127
|
+
// No r attribute, calculate address from position
|
|
128
|
+
this.lastCellCol += 1;
|
|
129
|
+
cellModel.address = colCache.encodeAddress(this.model.number, this.lastCellCol);
|
|
130
|
+
}
|
|
131
|
+
this.model.cells.push(cellModel);
|
|
110
132
|
if (this.maxItems && this.model.cells.length > this.maxItems) {
|
|
111
133
|
throw new Error(`Max column count (${this.maxItems}) exceeded`);
|
|
112
134
|
}
|
|
@@ -54,6 +54,10 @@ class FilterColumnXform extends BaseXform {
|
|
|
54
54
|
filterButton: attributes.hiddenButton === "0"
|
|
55
55
|
};
|
|
56
56
|
return true;
|
|
57
|
+
case "dynamicFilter":
|
|
58
|
+
// Ignore dynamicFilter nodes - we don't need to preserve them for reading
|
|
59
|
+
// See: https://github.com/exceljs/exceljs/issues/2972
|
|
60
|
+
return true;
|
|
57
61
|
default:
|
|
58
62
|
this.parser = this.map[node.name];
|
|
59
63
|
if (this.parser) {
|
package/dist/types/doc/cell.d.ts
CHANGED
|
@@ -1,3 +1,7 @@
|
|
|
1
|
+
import type { Row } from "./row.js";
|
|
2
|
+
import type { Column } from "./column.js";
|
|
3
|
+
import type { Worksheet } from "./worksheet.js";
|
|
4
|
+
import type { Workbook } from "./workbook.js";
|
|
1
5
|
interface FullAddress {
|
|
2
6
|
sheetName: string;
|
|
3
7
|
address: string;
|
|
@@ -26,16 +30,16 @@ interface CellModel {
|
|
|
26
30
|
}
|
|
27
31
|
declare class Cell {
|
|
28
32
|
static Types: typeof import("./enums.js").ValueType;
|
|
29
|
-
_row:
|
|
30
|
-
_column:
|
|
33
|
+
_row: Row;
|
|
34
|
+
_column: Column;
|
|
31
35
|
_address: string;
|
|
32
36
|
_value: any;
|
|
33
|
-
style:
|
|
37
|
+
style: Record<string, unknown>;
|
|
34
38
|
_mergeCount: number;
|
|
35
39
|
_comment?: any;
|
|
36
|
-
constructor(row:
|
|
37
|
-
get worksheet():
|
|
38
|
-
get workbook():
|
|
40
|
+
constructor(row: Row, column: Column, address: string);
|
|
41
|
+
get worksheet(): Worksheet;
|
|
42
|
+
get workbook(): Workbook;
|
|
39
43
|
destroy(): void;
|
|
40
44
|
get numFmt(): any;
|
|
41
45
|
set numFmt(value: any);
|