pivotkit-xlsx 0.0.0-stage → 0.2.0

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/src/zip.js ADDED
@@ -0,0 +1,292 @@
1
+ /*
2
+ * The ZIP container of an .xlsx file, read and checked by pivotkit-xlsx itself before any XML reaches
3
+ * read-excel-file:
4
+ *
5
+ * - the directory at the end of the file is read first: the number of parts (`limits.archiveEntries`)
6
+ * and the size they unpack to (`limits.unpackedBytes`) are checked before anything is unpacked, so a
7
+ * ZIP bomb is refused before it costs memory;
8
+ * - a part is unpacked into a buffer of the size the directory states, never more: a part that
9
+ * unpacks to more than it claims stops the read;
10
+ * - encrypted parts and unknown compression methods are refused.
11
+ *
12
+ * Not part of the public API.
13
+ */
14
+
15
+ import { Inflate } from "fflate";
16
+ import { fileError, limitError } from "pivotkit-core/import";
17
+
18
+ const EOCD = 0x06054b50;
19
+ const EOCD64_LOCATOR = 0x07064b50;
20
+ const EOCD64 = 0x06064b50;
21
+ const CENTRAL = 0x02014b50;
22
+ const LOCAL = 0x04034b50;
23
+ /** Compressed bytes pushed to the inflater at a time: one push unpacks to at most about 1,000 times this. */
24
+ const PUSH_BYTES = 16_384;
25
+
26
+ /**
27
+ * @typedef {object} ZipEntry
28
+ * @property {string} name
29
+ * @property {number} method 0 stored, 8 deflated
30
+ * @property {number} compressedSize
31
+ * @property {number} size unpacked, as the directory states
32
+ * @property {number} localOffset
33
+ */
34
+
35
+ const u16 = (b, i) => b[i] | (b[i + 1] << 8);
36
+ const u32 = (b, i) => (b[i] | (b[i + 1] << 8) | (b[i + 2] << 16) | (b[i + 3] << 24)) >>> 0;
37
+ const u64 = (b, i) => u32(b, i) + u32(b, i + 4) * 2 ** 32;
38
+
39
+ /**
40
+ * The entries of a ZIP file's central directory, checked against the limits.
41
+ * @param {Uint8Array} bytes
42
+ * @param {string} file for messages
43
+ * @param {{ archiveEntries: number, unpackedBytes: number }} limits
44
+ * @returns {ZipEntry[]}
45
+ */
46
+ export function readDirectory(bytes, file, limits) {
47
+ const broken = (what) => fileError("DAMAGED", file, `the workbook is damaged or cut off (${what}); open it in Excel and save it again.`);
48
+ let end = -1;
49
+ for (let i = bytes.length - 22; i >= 0 && i >= bytes.length - 65_557; i--) {
50
+ if (u32(bytes, i) === EOCD) {
51
+ end = i;
52
+ break;
53
+ }
54
+ }
55
+ if (end < 0) throw broken("no ZIP directory");
56
+ let count = u16(bytes, end + 10);
57
+ let size = u32(bytes, end + 12);
58
+ let offset = u32(bytes, end + 16);
59
+ if ((count === 0xffff || offset === 0xffffffff || size === 0xffffffff) && end >= 20 && u32(bytes, end - 20) === EOCD64_LOCATOR) {
60
+ const at = u64(bytes, end - 12);
61
+ if (at + 56 > bytes.length || u32(bytes, at) !== EOCD64) throw broken("ZIP64 directory");
62
+ count = u64(bytes, at + 32);
63
+ size = u64(bytes, at + 40);
64
+ offset = u64(bytes, at + 48);
65
+ }
66
+ if (count > limits.archiveEntries) {
67
+ throw limitError("archiveEntries", count, limits.archiveEntries, file, "parts in its ZIP directory");
68
+ }
69
+ if (offset + size > bytes.length) throw broken("directory outside the file");
70
+ /** @type {ZipEntry[]} */
71
+ const entries = [];
72
+ let total = 0;
73
+ let p = offset;
74
+ const text = new TextDecoder();
75
+ for (let k = 0; k < count; k++) {
76
+ if (p + 46 > bytes.length || u32(bytes, p) !== CENTRAL) throw broken("directory entry");
77
+ const flags = u16(bytes, p + 8);
78
+ const method = u16(bytes, p + 10);
79
+ let compressedSize = u32(bytes, p + 20);
80
+ let unpacked = u32(bytes, p + 24);
81
+ const nameLength = u16(bytes, p + 28);
82
+ const extraLength = u16(bytes, p + 30);
83
+ const commentLength = u16(bytes, p + 32);
84
+ let localOffset = u32(bytes, p + 42);
85
+ const name = text.decode(bytes.subarray(p + 46, p + 46 + nameLength));
86
+ // ZIP64: the sizes and the offset that do not fit 32 bits are in the extra field 0x0001
87
+ let e = p + 46 + nameLength;
88
+ const extraEnd = e + extraLength;
89
+ while (e + 4 <= extraEnd) {
90
+ const id = u16(bytes, e);
91
+ const len = u16(bytes, e + 2);
92
+ if (id === 0x0001) {
93
+ let q = e + 4;
94
+ if (unpacked === 0xffffffff) (unpacked = u64(bytes, q)), (q += 8);
95
+ if (compressedSize === 0xffffffff) (compressedSize = u64(bytes, q)), (q += 8);
96
+ if (localOffset === 0xffffffff) localOffset = u64(bytes, q);
97
+ }
98
+ e += 4 + len;
99
+ }
100
+ if (flags & 1) {
101
+ throw fileError("ENCRYPTED", file, "the workbook is encrypted; save it without a password, then load it.");
102
+ }
103
+ if (method !== 0 && method !== 8) {
104
+ throw fileError("DAMAGED", file, `a part of the workbook uses compression method ${method}, which is not read; save it again in Excel.`);
105
+ }
106
+ total += unpacked;
107
+ if (total > limits.unpackedBytes) {
108
+ throw limitError("unpackedBytes", total, limits.unpackedBytes, file, "bytes unpacked, by its ZIP directory; a ZIP bomb or a workbook too large to load");
109
+ }
110
+ entries.push({ name, method, compressedSize, size: unpacked, localOffset });
111
+ p = extraEnd + commentLength;
112
+ }
113
+ return entries;
114
+ }
115
+
116
+ /** Where an entry's data starts and ends, from its local header. */
117
+ function dataRange(bytes, entry, file) {
118
+ const at = entry.localOffset;
119
+ if (at + 30 > bytes.length || u32(bytes, at) !== LOCAL) {
120
+ throw fileError("DAMAGED", file, `the workbook is damaged (part "${entry.name}"); open it in Excel and save it again.`);
121
+ }
122
+ const start = at + 30 + u16(bytes, at + 26) + u16(bytes, at + 28);
123
+ const end = start + entry.compressedSize;
124
+ if (end > bytes.length) {
125
+ throw fileError("DAMAGED", file, `the workbook is cut off (part "${entry.name}"); load the whole file.`);
126
+ }
127
+ return [start, end];
128
+ }
129
+
130
+ /**
131
+ * Unpacks an entry, streaming: `onChunk` gets each piece of the unpacked bytes. More bytes than the
132
+ * directory states stop it with an error.
133
+ * @param {Uint8Array} bytes
134
+ * @param {ZipEntry} entry
135
+ * @param {string} file
136
+ * @param {(chunk: Uint8Array) => void} onChunk
137
+ * @param {() => Promise<void> | void} [between] called between pushes (abort checks, progress)
138
+ */
139
+ export async function unpackStream(bytes, entry, file, onChunk, between) {
140
+ const [start, end] = dataRange(bytes, entry, file);
141
+ let produced = 0;
142
+ const take = (chunk) => {
143
+ produced += chunk.length;
144
+ if (produced > entry.size) {
145
+ throw fileError("DAMAGED", file, `the part "${entry.name}" unpacks to more than the ${entry.size.toLocaleString("en-US")} bytes its directory states: a damaged or forged workbook.`);
146
+ }
147
+ if (chunk.length > 0) onChunk(chunk);
148
+ };
149
+ if (entry.method === 0) {
150
+ for (let at = start; at < end; at += 1 << 20) {
151
+ take(bytes.subarray(at, Math.min(end, at + (1 << 20))));
152
+ await between?.();
153
+ }
154
+ } else {
155
+ const inflater = new Inflate((chunk) => take(chunk));
156
+ for (let at = start; at < end; at += PUSH_BYTES) {
157
+ const stop = Math.min(end, at + PUSH_BYTES);
158
+ try {
159
+ inflater.push(bytes.subarray(at, stop), stop === end);
160
+ } catch (error) {
161
+ if (/** @type {any} */ (error)?.code === undefined || typeof (/** @type {any} */ (error).code) !== "number") throw error;
162
+ throw fileError("DAMAGED", file, `the part "${entry.name}" cannot be unpacked; the workbook is damaged or cut off.`);
163
+ }
164
+ await between?.();
165
+ }
166
+ }
167
+ if (produced !== entry.size) {
168
+ throw fileError("DAMAGED", file, `the part "${entry.name}" unpacks to ${produced.toLocaleString("en-US")} bytes, not the ${entry.size.toLocaleString("en-US")} its directory states; the workbook is damaged.`);
169
+ }
170
+ }
171
+
172
+ /**
173
+ * Unpacks an entry into one buffer of the size the directory states.
174
+ * @returns {Promise<Uint8Array>}
175
+ */
176
+ export async function unpack(bytes, entry, file, between) {
177
+ const out = new Uint8Array(entry.size);
178
+ let at = 0;
179
+ await unpackStream(bytes, entry, file, (chunk) => {
180
+ out.set(chunk, at);
181
+ at += chunk.length;
182
+ }, between);
183
+ return out;
184
+ }
185
+
186
+ const CRC_TABLE = (() => {
187
+ const t = new Int32Array(256);
188
+ for (let n = 0; n < 256; n++) {
189
+ let c = n;
190
+ for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
191
+ t[n] = c;
192
+ }
193
+ return t;
194
+ })();
195
+
196
+ /** CRC-32 of bytes, continued from `crc` (start with 0). */
197
+ export function crc32(bytes, crc = 0, from = 0, to = bytes.length) {
198
+ let c = ~crc;
199
+ for (let i = from; i < to; i++) c = CRC_TABLE[(c ^ bytes[i]) & 0xff] ^ (c >>> 8);
200
+ return ~c >>> 0;
201
+ }
202
+
203
+ /**
204
+ * Writes a ZIP without compression into one growing buffer: what read-excel-file gets, so it
205
+ * unpacks nothing (no worker) and the sheet's XML is copied once, while it streams in.
206
+ */
207
+ export function createStoredZip(initial = 1 << 20) {
208
+ let buf = new Uint8Array(initial);
209
+ let length = 0;
210
+ const central = [];
211
+ const reserve = (n) => {
212
+ if (length + n <= buf.length) return;
213
+ let size = Math.ceil(buf.length * 1.5);
214
+ while (size < length + n) size = Math.ceil(size * 1.5);
215
+ const grown = new Uint8Array(size);
216
+ grown.set(buf.subarray(0, length));
217
+ buf = grown;
218
+ };
219
+ const put16 = (at, v) => {
220
+ buf[at] = v & 0xff;
221
+ buf[at + 1] = (v >>> 8) & 0xff;
222
+ };
223
+ const put32 = (at, v) => {
224
+ put16(at, v & 0xffff);
225
+ put16(at + 2, v >>> 16);
226
+ };
227
+ return {
228
+ /** Starts a part; `write` its bytes, then `end()`. */
229
+ begin(path) {
230
+ const name = new TextEncoder().encode(path);
231
+ reserve(30 + name.length);
232
+ const header = length;
233
+ put32(header, LOCAL);
234
+ put16(header + 4, 20);
235
+ buf.fill(0, header + 6, header + 26);
236
+ put16(header + 26, name.length);
237
+ put16(header + 28, 0);
238
+ buf.set(name, header + 30);
239
+ length += 30 + name.length;
240
+ const start = length;
241
+ let crc = 0;
242
+ return {
243
+ write(bytes, from = 0, to = bytes.length) {
244
+ reserve(to - from);
245
+ buf.set(bytes.subarray(from, to), length);
246
+ crc = crc32(bytes, crc, from, to);
247
+ length += to - from;
248
+ },
249
+ end() {
250
+ const size = length - start;
251
+ if (size > 0xfffffffe) throw new RangeError("a stored part over 4 GiB");
252
+ put32(header + 14, crc);
253
+ put32(header + 18, size);
254
+ put32(header + 22, size);
255
+ central.push({ name, crc, size, offset: header });
256
+ }
257
+ };
258
+ },
259
+ add(path, bytes) {
260
+ const part = this.begin(path);
261
+ part.write(bytes);
262
+ part.end();
263
+ },
264
+ /** The finished ZIP (a view of the buffer). */
265
+ finish() {
266
+ const at = length;
267
+ for (const e of central) {
268
+ reserve(46 + e.name.length);
269
+ put32(length, CENTRAL);
270
+ put16(length + 4, 20);
271
+ put16(length + 6, 20);
272
+ buf.fill(0, length + 8, length + 46);
273
+ put32(length + 16, e.crc);
274
+ put32(length + 20, e.size);
275
+ put32(length + 24, e.size);
276
+ put16(length + 28, e.name.length);
277
+ put32(length + 42, e.offset);
278
+ buf.set(e.name, length + 46);
279
+ length += 46 + e.name.length;
280
+ }
281
+ reserve(22);
282
+ put32(length, EOCD);
283
+ buf.fill(0, length + 4, length + 22);
284
+ put16(length + 8, central.length);
285
+ put16(length + 10, central.length);
286
+ put32(length + 12, length - at);
287
+ put32(length + 16, at);
288
+ length += 22;
289
+ return buf.subarray(0, length);
290
+ }
291
+ };
292
+ }
@@ -0,0 +1,3 @@
1
+ /** @type {import("pivotkit-core/import").ReaderPlugin} */
2
+ export declare const xlsxReader: import("pivotkit-core/import").ReaderPlugin;
3
+ export default xlsxReader;
@@ -0,0 +1,15 @@
1
+ /**
2
+ * The worksheets of a workbook: name, index (0-based) and whether it is visible.
3
+ * @param {Blob | ArrayBuffer | Uint8Array} file
4
+ * @param {import("pivotkit-core/import").ReadOptions} [options]
5
+ * @returns {Promise<import("pivotkit-core/import").SheetInfo[]>}
6
+ */
7
+ export declare function sheets(file: Blob | ArrayBuffer | Uint8Array, options?: import("pivotkit-core/import").ReadOptions): Promise<import("pivotkit-core/import").SheetInfo[]>;
8
+ /**
9
+ * Reads one sheet of a workbook into a table: `options.sheet` (a name, or an index from 0), else the
10
+ * first visible one.
11
+ * @param {Blob | ArrayBuffer | Uint8Array} file
12
+ * @param {import("pivotkit-core/import").ReadOptions} [options]
13
+ * @returns {Promise<import("pivotkit-core/import").ImportTable>}
14
+ */
15
+ export declare function read(file: Blob | ArrayBuffer | Uint8Array, options?: import("pivotkit-core/import").ReadOptions): Promise<import("pivotkit-core/import").ImportTable>;
@@ -0,0 +1,21 @@
1
+ /** XML text with its five named entities and character references decoded. */
2
+ export declare function decodeXml(text: any): any;
3
+ /** "worksheets/sheet1.xml" relative to xl/ (or "/xl/..." absolute) -> "xl/worksheets/sheet1.xml" */
4
+ export declare function partPath(target: any): string;
5
+ /**
6
+ * The worksheets of a workbook in their order (chart sheets and dialog sheets are not tables).
7
+ * @param {string} workbookXml
8
+ * @param {string} relsXml
9
+ * @returns {Array<{ name: string, index: number, visible: boolean, path: string }>}
10
+ */
11
+ export declare function worksheets(workbookXml: string, relsXml: string): Array<{
12
+ name: string;
13
+ index: number;
14
+ visible: boolean;
15
+ path: string;
16
+ }>;
17
+ /** The paths of the shared strings and the styles, from the workbook's relationships. */
18
+ export declare function sharedParts(relsXml: any): {
19
+ sharedStrings: string;
20
+ styles: string;
21
+ };
package/types/xml.d.ts ADDED
@@ -0,0 +1,84 @@
1
+ /** Index of `pattern` in `buf[from, to)`, or -1. */
2
+ export declare function indexOf(buf: any, pattern: any, from?: number, to?: any): any;
3
+ /**
4
+ * A check of an XML part's bytes, fed piece by piece: throws at a `<!DOCTYPE` or `<!ENTITY`.
5
+ * @param {string} file
6
+ * @param {string} part
7
+ */
8
+ export declare function createXmlCheck(file: string, part: string): (chunk: any) => void;
9
+ /** Checks a whole XML part. */
10
+ export declare function checkXml(bytes: any, file: any, part: any): void;
11
+ /**
12
+ * A run of XML whitespace longer than this, in text or between a tag's attributes, is cut to this
13
+ * length: four times the most characters an Excel cell holds (32,767), so no value Excel writes changes.
14
+ */
15
+ export declare const SPACE_RUN: number;
16
+ /**
17
+ * The longest text, tag, comment, CDATA section or processing instruction of an XML part handed to
18
+ * read-excel-file, counted after whitespace runs are cut. An Excel cell holds at most 32,767
19
+ * characters (at most about 300 KB as XML); a longer token is refused.
20
+ */
21
+ export declare const MAX_TOKEN: number;
22
+ /**
23
+ * The longest tag: the parser reads a tag byte by byte in JavaScript, again from its start with every
24
+ * piece, where it finds a text's end with a native search. A sheet's tags are a few dozen bytes.
25
+ */
26
+ export declare const MAX_TAG: number;
27
+ /**
28
+ * Passes an XML part on to `out`, piece by piece, with every run of whitespace in its text and
29
+ * between a tag's attributes cut to SPACE_RUN bytes, and refuses (DAMAGED) a tag longer than MAX_TAG
30
+ * and a text, comment, CDATA section or processing instruction longer than MAX_TOKEN.
31
+ *
32
+ * read-excel-file parses a sheet in pieces; a token that is not finished at the end of a piece is
33
+ * read again from its start with the next one, and it calls itself once per piece. A token of tens
34
+ * of megabytes (a workbook a few dozen kilobytes small whose XML is padded with spaces) costs it
35
+ * quadratic time, a gigabyte of memory and a stack overflow. Tokens it gets from here are short. The
36
+ * scan follows what that parser takes as one token: a quoted attribute value is part of its tag
37
+ * even with a ">" in it, a comment ends at "-->", a CDATA section at "]]>", a processing
38
+ * instruction at "?>".
39
+ * @param {{ write: (bytes: Uint8Array, from?: number, to?: number) => void }} out
40
+ * @param {string} file for messages
41
+ * @param {string} part for messages
42
+ */
43
+ export declare function createXmlGuard(out: {
44
+ write: (bytes: Uint8Array, from?: number, to?: number) => void;
45
+ }, file: string, part: string): {
46
+ /**
47
+ * @param {Uint8Array} bytes
48
+ * @param {number} [from]
49
+ * @param {number} [to]
50
+ */
51
+ write(bytes: Uint8Array, from?: number, to?: number): void;
52
+ };
53
+ /**
54
+ * Streams a sheet's XML and leaves out its rows without a value and everything after `</sheetData>`
55
+ * but the last end tag (the root's): read-excel-file reads nothing there. Feed pieces with `push`; the
56
+ * XML kept goes to `out.write`; `end()` gives the row numbers seen.
57
+ *
58
+ * It also checks where the values are against the limits, before read-excel-file sees them: the
59
+ * parser makes a dense array of the sheet's extent (every row up to the last row with a value, each
60
+ * as wide as the widest), so a value in row 1,000,000 or column XFD costs that much memory however
61
+ * small the file is. A value beyond `maxRow` or `maxColumn`, or an extent of more than `maxCells`,
62
+ * stops the read with LIMIT_EXCEEDED.
63
+ * @param {{ write: (bytes: Uint8Array, from?: number, to?: number) => void }} out
64
+ * @param {{ file?: string, part?: string, maxRow?: number, maxColumn?: number, maxCells?: number }} [limits] `file` and `part` name them in messages
65
+ */
66
+ export declare function createSheetFilter(out: {
67
+ write: (bytes: Uint8Array, from?: number, to?: number) => void;
68
+ }, limits?: {
69
+ file?: string;
70
+ part?: string;
71
+ maxRow?: number;
72
+ maxColumn?: number;
73
+ maxCells?: number;
74
+ }): {
75
+ /** @param {Uint8Array} chunk */
76
+ push(chunk: Uint8Array): void;
77
+ /** @returns {{ lastRow: number, lastValueRow: number, dropped: number, errors: number }} */
78
+ end(): {
79
+ lastRow: number;
80
+ lastValueRow: number;
81
+ dropped: number;
82
+ errors: number;
83
+ };
84
+ };
package/types/zip.d.ts ADDED
@@ -0,0 +1,55 @@
1
+ export type ZipEntry = {
2
+ name: string;
3
+ /**
4
+ * 0 stored, 8 deflated
5
+ */
6
+ method: number;
7
+ compressedSize: number;
8
+ /**
9
+ * unpacked, as the directory states
10
+ */
11
+ size: number;
12
+ localOffset: number;
13
+ };
14
+ /**
15
+ * The entries of a ZIP file's central directory, checked against the limits.
16
+ * @param {Uint8Array} bytes
17
+ * @param {string} file for messages
18
+ * @param {{ archiveEntries: number, unpackedBytes: number }} limits
19
+ * @returns {ZipEntry[]}
20
+ */
21
+ export declare function readDirectory(bytes: Uint8Array, file: string, limits: {
22
+ archiveEntries: number;
23
+ unpackedBytes: number;
24
+ }): ZipEntry[];
25
+ /**
26
+ * Unpacks an entry, streaming: `onChunk` gets each piece of the unpacked bytes. More bytes than the
27
+ * directory states stop it with an error.
28
+ * @param {Uint8Array} bytes
29
+ * @param {ZipEntry} entry
30
+ * @param {string} file
31
+ * @param {(chunk: Uint8Array) => void} onChunk
32
+ * @param {() => Promise<void> | void} [between] called between pushes (abort checks, progress)
33
+ */
34
+ export declare function unpackStream(bytes: Uint8Array, entry: ZipEntry, file: string, onChunk: (chunk: Uint8Array) => void, between?: () => Promise<void> | void): Promise<void>;
35
+ /**
36
+ * Unpacks an entry into one buffer of the size the directory states.
37
+ * @returns {Promise<Uint8Array>}
38
+ */
39
+ export declare function unpack(bytes: any, entry: any, file: any, between: any): Promise<Uint8Array>;
40
+ /** CRC-32 of bytes, continued from `crc` (start with 0). */
41
+ export declare function crc32(bytes: any, crc?: number, from?: number, to?: any): number;
42
+ /**
43
+ * Writes a ZIP without compression into one growing buffer: what read-excel-file gets, so it
44
+ * unpacks nothing (no worker) and the sheet's XML is copied once, while it streams in.
45
+ */
46
+ export declare function createStoredZip(initial?: number): {
47
+ /** Starts a part; `write` its bytes, then `end()`. */
48
+ begin(path: any): {
49
+ write(bytes: any, from?: number, to?: any): void;
50
+ end(): void;
51
+ };
52
+ add(path: any, bytes: any): void;
53
+ /** The finished ZIP (a view of the buffer). */
54
+ finish(): Uint8Array<ArrayBuffer>;
55
+ };