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/CHANGELOG.md +60 -0
- package/LICENSE +21 -0
- package/README.md +42 -2
- package/dist/THIRD_PARTY_LICENSES.txt +85 -0
- package/dist/chunks/chunk-KDJGTIXW.js +1 -0
- package/dist/chunks/chunk-TEWBHPQ4.js +1 -0
- package/dist/chunks/import-csv-TRZA6M7W.js +1 -0
- package/dist/chunks/import-json-YR5ADN4H.js +1 -0
- package/dist/chunks/reader-S5MTRZAR.js +9 -0
- package/dist/index.js +1 -0
- package/package.json +66 -4
- package/src/index.js +21 -0
- package/src/reader.js +231 -0
- package/src/workbook.js +79 -0
- package/src/xml.js +593 -0
- package/src/zip.js +292 -0
- package/types/index.d.ts +3 -0
- package/types/reader.d.ts +15 -0
- package/types/workbook.d.ts +21 -0
- package/types/xml.d.ts +84 -0
- package/types/zip.d.ts +55 -0
package/src/xml.js
ADDED
|
@@ -0,0 +1,593 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Checks and trims of the XML parts of a workbook, on their bytes, before read-excel-file sees them:
|
|
3
|
+
*
|
|
4
|
+
* - `checkXml`: no document type declaration and no entity declaration (`<!DOCTYPE`, `<!ENTITY`).
|
|
5
|
+
* An .xlsx never needs either; refusing them rules out entity expansion and external entities
|
|
6
|
+
* whatever the XML parser does with them.
|
|
7
|
+
* - `createSheetFilter`: the rows of a sheet that hold no value are left out. Excel's used range often
|
|
8
|
+
* reaches far below the data (formatted empty cells to row 900,000); those rows would otherwise be
|
|
9
|
+
* unpacked into memory, parsed and turned into empty rows. A row is kept when it has a cell with
|
|
10
|
+
* content (`</c>`: a value, an inline string or a formula); a row of empty cells (`<c r="A9" s="1"/>`)
|
|
11
|
+
* or a self-closing `<row/>` goes. Rows are numbered by their cells' `r` attributes, so leaving rows
|
|
12
|
+
* out moves no value. Anything between rows that is no row, and what follows `</sheetData>` but
|
|
13
|
+
* for the root's end tag, is left out too: read-excel-file reads nothing there.
|
|
14
|
+
* - `createXmlGuard`: whitespace runs cut and over-long tokens refused, so read-excel-file's parser
|
|
15
|
+
* gets no token that costs it quadratic time.
|
|
16
|
+
*
|
|
17
|
+
* Not part of the public API.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { fileError, limitError } from "pivotkit-core/import";
|
|
21
|
+
|
|
22
|
+
const ascii = (s) => Uint8Array.from(s, (c) => c.charCodeAt(0));
|
|
23
|
+
const LT = 0x3c;
|
|
24
|
+
const BANG = 0x21;
|
|
25
|
+
const DOCTYPE = ascii("DOCTYPE");
|
|
26
|
+
const ENTITY = ascii("ENTITY");
|
|
27
|
+
const SHEET_DATA = ascii("<sheetData");
|
|
28
|
+
const SHEET_DATA_END = ascii("</sheetData>");
|
|
29
|
+
const ROW = ascii("<row");
|
|
30
|
+
const COMMENT_START = ascii("<!--");
|
|
31
|
+
const COMMENT_END = ascii("-->");
|
|
32
|
+
const CDATA_START = ascii("<![CDATA[");
|
|
33
|
+
const CDATA_END = ascii("]]>");
|
|
34
|
+
const PI_END = ascii("?>");
|
|
35
|
+
const GT = ascii(">");
|
|
36
|
+
const ROW_END = ascii("</row>");
|
|
37
|
+
const CELL_END = ascii("</c>");
|
|
38
|
+
// an error cell's type; the byte before it is whitespace (searched without it: a row padded with
|
|
39
|
+
// spaces would make every byte a place to compare)
|
|
40
|
+
const ERROR_TYPE = ascii('t="e"');
|
|
41
|
+
|
|
42
|
+
/** Index of `pattern` in `buf[from, to)`, or -1. */
|
|
43
|
+
export function indexOf(buf, pattern, from = 0, to = buf.length) {
|
|
44
|
+
const first = pattern[0];
|
|
45
|
+
const last = to - pattern.length;
|
|
46
|
+
// bounded, so a search for the first byte never runs past `to`
|
|
47
|
+
const view = to < buf.length ? buf.subarray(0, to) : buf;
|
|
48
|
+
for (let i = view.indexOf(first, from); i !== -1 && i <= last; i = view.indexOf(first, i + 1)) {
|
|
49
|
+
let k = 1;
|
|
50
|
+
while (k < pattern.length && buf[i + k] === pattern[k]) k++;
|
|
51
|
+
if (k === pattern.length) return i;
|
|
52
|
+
}
|
|
53
|
+
return -1;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Whether `buf` at `i` holds `word`, letters compared without case. */
|
|
57
|
+
function wordAt(buf, i, word) {
|
|
58
|
+
for (let k = 0; k < word.length; k++) {
|
|
59
|
+
if ((buf[i + k] | 0x20) !== (word[k] | 0x20)) return false;
|
|
60
|
+
}
|
|
61
|
+
return true;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* A check of an XML part's bytes, fed piece by piece: throws at a `<!DOCTYPE` or `<!ENTITY`.
|
|
66
|
+
* @param {string} file
|
|
67
|
+
* @param {string} part
|
|
68
|
+
*/
|
|
69
|
+
export function createXmlCheck(file, part) {
|
|
70
|
+
let tail = new Uint8Array(0);
|
|
71
|
+
let first = true;
|
|
72
|
+
return (chunk) => {
|
|
73
|
+
// the check reads ASCII bytes: an XML part in UTF-16 or UTF-32 (a byte order mark, zero bytes)
|
|
74
|
+
// could hide a declaration from it, and Excel writes UTF-8
|
|
75
|
+
if ((first && chunk.length >= 2 && ((chunk[0] === 0xfe && chunk[1] === 0xff) || (chunk[0] === 0xff && chunk[1] === 0xfe))) || chunk.indexOf(0) !== -1) {
|
|
76
|
+
throw fileError("XML_DECLARATION", file, `the part "${part}" is not UTF-8 XML, which a workbook always is; it is refused.`, { part });
|
|
77
|
+
}
|
|
78
|
+
first = false;
|
|
79
|
+
// a declaration split between the last piece and this one is in their joint; the piece is
|
|
80
|
+
// searched as it is, not copied (a piece can be megabytes)
|
|
81
|
+
if (tail.length) search(concat(tail, chunk.subarray(0, 9)));
|
|
82
|
+
search(chunk);
|
|
83
|
+
tail = chunk.length >= 9 ? chunk.slice(chunk.length - 9) : concat(tail, chunk).slice(-9);
|
|
84
|
+
};
|
|
85
|
+
function search(buf) {
|
|
86
|
+
// "!" is rare in a workbook's XML (comments, CDATA, text); "<" is in every tag
|
|
87
|
+
for (let i = buf.indexOf(BANG, 1); i !== -1; i = buf.indexOf(BANG, i + 1)) {
|
|
88
|
+
if (buf[i - 1] === LT && (wordAt(buf, i + 1, DOCTYPE) || wordAt(buf, i + 1, ENTITY))) {
|
|
89
|
+
throw fileError("XML_DECLARATION", file, `the part "${part}" declares a document type or entities, which a workbook never needs; it is refused.`, { part });
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Checks a whole XML part. */
|
|
96
|
+
export function checkXml(bytes, file, part) {
|
|
97
|
+
createXmlCheck(file, part)(bytes);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function concat(a, b) {
|
|
101
|
+
const out = new Uint8Array(a.length + b.length);
|
|
102
|
+
out.set(a);
|
|
103
|
+
out.set(b, a.length);
|
|
104
|
+
return out;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* A run of XML whitespace longer than this, in text or between a tag's attributes, is cut to this
|
|
109
|
+
* length: four times the most characters an Excel cell holds (32,767), so no value Excel writes changes.
|
|
110
|
+
*/
|
|
111
|
+
export const SPACE_RUN = 128 * 1024;
|
|
112
|
+
/**
|
|
113
|
+
* The longest text, tag, comment, CDATA section or processing instruction of an XML part handed to
|
|
114
|
+
* read-excel-file, counted after whitespace runs are cut. An Excel cell holds at most 32,767
|
|
115
|
+
* characters (at most about 300 KB as XML); a longer token is refused.
|
|
116
|
+
*/
|
|
117
|
+
export const MAX_TOKEN = 1024 * 1024;
|
|
118
|
+
/**
|
|
119
|
+
* The longest tag: the parser reads a tag byte by byte in JavaScript, again from its start with every
|
|
120
|
+
* piece, where it finds a text's end with a native search. A sheet's tags are a few dozen bytes.
|
|
121
|
+
*/
|
|
122
|
+
export const MAX_TAG = 256 * 1024;
|
|
123
|
+
|
|
124
|
+
const TEXT = 0;
|
|
125
|
+
const TAG = 1;
|
|
126
|
+
const OPENING = 2; // after "<", until it is clear whether a comment, a CDATA section or a tag starts
|
|
127
|
+
const MARKUP = 3; // a comment, a CDATA section or a processing instruction: ends at `ending`
|
|
128
|
+
const CDATA_HEAD = ascii("![CDATA[");
|
|
129
|
+
const COMMENT_HEAD = ascii("!--");
|
|
130
|
+
const isSpace = (b) => b === 0x20 || b === 0x09 || b === 0x0a || b === 0x0d;
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Passes an XML part on to `out`, piece by piece, with every run of whitespace in its text and
|
|
134
|
+
* between a tag's attributes cut to SPACE_RUN bytes, and refuses (DAMAGED) a tag longer than MAX_TAG
|
|
135
|
+
* and a text, comment, CDATA section or processing instruction longer than MAX_TOKEN.
|
|
136
|
+
*
|
|
137
|
+
* read-excel-file parses a sheet in pieces; a token that is not finished at the end of a piece is
|
|
138
|
+
* read again from its start with the next one, and it calls itself once per piece. A token of tens
|
|
139
|
+
* of megabytes (a workbook a few dozen kilobytes small whose XML is padded with spaces) costs it
|
|
140
|
+
* quadratic time, a gigabyte of memory and a stack overflow. Tokens it gets from here are short. The
|
|
141
|
+
* scan follows what that parser takes as one token: a quoted attribute value is part of its tag
|
|
142
|
+
* even with a ">" in it, a comment ends at "-->", a CDATA section at "]]>", a processing
|
|
143
|
+
* instruction at "?>".
|
|
144
|
+
* @param {{ write: (bytes: Uint8Array, from?: number, to?: number) => void }} out
|
|
145
|
+
* @param {string} file for messages
|
|
146
|
+
* @param {string} part for messages
|
|
147
|
+
*/
|
|
148
|
+
export function createXmlGuard(out, file, part) {
|
|
149
|
+
let mode = TEXT;
|
|
150
|
+
let quote = 0; // in a tag: the quote of the attribute value it is in, or 0
|
|
151
|
+
let length = 0; // bytes of the current token passed on
|
|
152
|
+
let spaces = 0; // whitespace bytes in a row, in text or in a tag outside quotes
|
|
153
|
+
/** @type {number[]} */
|
|
154
|
+
let head = []; // the bytes after "<" while OPENING
|
|
155
|
+
// MARKUP ends at ">" after `close1` ("?", "-", "]"), and after `close2` before that unless it is 0
|
|
156
|
+
let close1 = 0;
|
|
157
|
+
let close2 = 0;
|
|
158
|
+
let last1 = 0; // the last two bytes seen in MARKUP
|
|
159
|
+
let last2 = 0;
|
|
160
|
+
const tooLong = () =>
|
|
161
|
+
fileError("DAMAGED", file, `the part "${part}" holds a text of more than 1 MiB (an Excel cell holds at most 32,767 characters); the workbook is refused.`, { part });
|
|
162
|
+
const tagTooLong = () => fileError("DAMAGED", file, `the part "${part}" holds a tag of more than 256 KiB; the workbook is refused.`, { part });
|
|
163
|
+
const startsAs = (word) => head.length <= word.length && head.every((b, k) => b === word[k]);
|
|
164
|
+
/** Starts a comment, CDATA section or processing instruction that ends with `close` and ">". */
|
|
165
|
+
const endMarkup = (close) => {
|
|
166
|
+
close1 = close.at(-1) ?? 0;
|
|
167
|
+
close2 = close.length > 1 ? close[0] : 0;
|
|
168
|
+
// the end may overlap the start, as the parser's search for it does ("<!-->", "<?>")
|
|
169
|
+
last1 = head.at(-1) ?? 0;
|
|
170
|
+
last2 = head.at(-2) ?? 0;
|
|
171
|
+
return MARKUP;
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
return {
|
|
175
|
+
/**
|
|
176
|
+
* @param {Uint8Array} bytes
|
|
177
|
+
* @param {number} [from]
|
|
178
|
+
* @param {number} [to]
|
|
179
|
+
*/
|
|
180
|
+
write(bytes, from = 0, to = bytes.length) {
|
|
181
|
+
// the state in locals while the loops run (a closure's variables are slower to reach)
|
|
182
|
+
let m = mode;
|
|
183
|
+
let q = quote;
|
|
184
|
+
let sp = spaces;
|
|
185
|
+
let len = length;
|
|
186
|
+
// bounded, so a search for "<" never runs past `to`
|
|
187
|
+
const view = to < bytes.length ? bytes.subarray(0, to) : bytes;
|
|
188
|
+
let start = from; // the range not yet passed on
|
|
189
|
+
let i = from;
|
|
190
|
+
while (i < to) {
|
|
191
|
+
if (m === TEXT) {
|
|
192
|
+
// most text is short ("<v>12</v>") or none ("><"): a few bytes by hand, then a search
|
|
193
|
+
let stop = i;
|
|
194
|
+
const near = Math.min(to, i + 16);
|
|
195
|
+
while (stop < near && bytes[stop] !== LT) stop++;
|
|
196
|
+
if (stop === near && near < to) {
|
|
197
|
+
const lt = view.indexOf(LT, near);
|
|
198
|
+
stop = lt === -1 ? to : lt;
|
|
199
|
+
}
|
|
200
|
+
if (sp + (stop - i) > SPACE_RUN) {
|
|
201
|
+
// a run may pass the cut: byte by byte
|
|
202
|
+
while (i < stop) {
|
|
203
|
+
const b = bytes[i];
|
|
204
|
+
if (b === 0x20 || b === 0x09 || b === 0x0a || b === 0x0d) {
|
|
205
|
+
if (sp === SPACE_RUN) {
|
|
206
|
+
// past the cut: the rest of the run is left out
|
|
207
|
+
if (start < i) out.write(bytes, start, i);
|
|
208
|
+
while (i < stop && isSpace(bytes[i])) i++;
|
|
209
|
+
start = i;
|
|
210
|
+
continue;
|
|
211
|
+
}
|
|
212
|
+
sp++;
|
|
213
|
+
} else {
|
|
214
|
+
sp = 0;
|
|
215
|
+
}
|
|
216
|
+
i++;
|
|
217
|
+
if (++len > MAX_TOKEN) throw tooLong();
|
|
218
|
+
}
|
|
219
|
+
} else {
|
|
220
|
+
len += stop - i;
|
|
221
|
+
if (len > MAX_TOKEN) throw tooLong();
|
|
222
|
+
// the whitespace at the end of the text, where a run may go on in the next piece
|
|
223
|
+
let k = stop;
|
|
224
|
+
while (k > i && isSpace(bytes[k - 1])) k--;
|
|
225
|
+
sp = k === i ? sp + (stop - i) : stop - k;
|
|
226
|
+
i = stop;
|
|
227
|
+
}
|
|
228
|
+
if (i < to) {
|
|
229
|
+
// at "<": a tag, unless a comment, CDATA section or processing instruction starts
|
|
230
|
+
sp = 0;
|
|
231
|
+
len = 1;
|
|
232
|
+
i++;
|
|
233
|
+
if (i < to && bytes[i] !== BANG && bytes[i] !== 0x3f) {
|
|
234
|
+
m = TAG;
|
|
235
|
+
q = 0;
|
|
236
|
+
} else {
|
|
237
|
+
m = OPENING;
|
|
238
|
+
head = [];
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
} else if (m === TAG) {
|
|
242
|
+
for (; i < to; i++) {
|
|
243
|
+
const b = bytes[i];
|
|
244
|
+
if (q !== 0) {
|
|
245
|
+
if (b === q) q = 0;
|
|
246
|
+
} else if (b === 0x3e) {
|
|
247
|
+
break;
|
|
248
|
+
} else if (b === 0x20 || b === 0x09 || b === 0x0a || b === 0x0d) {
|
|
249
|
+
if (sp === SPACE_RUN) {
|
|
250
|
+
if (start < i) out.write(bytes, start, i);
|
|
251
|
+
while (i < to && isSpace(bytes[i])) i++;
|
|
252
|
+
start = i;
|
|
253
|
+
i--;
|
|
254
|
+
continue;
|
|
255
|
+
}
|
|
256
|
+
sp++;
|
|
257
|
+
} else {
|
|
258
|
+
sp = 0;
|
|
259
|
+
if (b === 0x22 || b === 0x27) q = b;
|
|
260
|
+
}
|
|
261
|
+
if (++len > MAX_TAG) throw tagTooLong();
|
|
262
|
+
}
|
|
263
|
+
if (i < to) {
|
|
264
|
+
m = TEXT;
|
|
265
|
+
sp = 0;
|
|
266
|
+
len = 0;
|
|
267
|
+
i++;
|
|
268
|
+
}
|
|
269
|
+
} else if (m === OPENING) {
|
|
270
|
+
const b = bytes[i++];
|
|
271
|
+
head.push(b);
|
|
272
|
+
if (++len > MAX_TOKEN) throw tooLong();
|
|
273
|
+
if (head.length === 1 && b === 0x3f) {
|
|
274
|
+
m = endMarkup([0x3f]); // <? ... ?>
|
|
275
|
+
} else if (head[0] === BANG && startsAs(COMMENT_HEAD)) {
|
|
276
|
+
if (head.length === COMMENT_HEAD.length) m = endMarkup([0x2d, 0x2d]);
|
|
277
|
+
} else if (head[0] === BANG && startsAs(CDATA_HEAD)) {
|
|
278
|
+
if (head.length === CDATA_HEAD.length) m = endMarkup([0x5d, 0x5d]);
|
|
279
|
+
} else {
|
|
280
|
+
// a tag: what was read of it so far may hold a quote or its end
|
|
281
|
+
m = TAG;
|
|
282
|
+
q = 0;
|
|
283
|
+
sp = 0;
|
|
284
|
+
for (const h of head) {
|
|
285
|
+
if (q !== 0) {
|
|
286
|
+
if (h === q) q = 0;
|
|
287
|
+
} else if (h === 0x22 || h === 0x27) {
|
|
288
|
+
q = h;
|
|
289
|
+
} else if (h === 0x3e) {
|
|
290
|
+
m = TEXT;
|
|
291
|
+
len = 0;
|
|
292
|
+
sp = 0;
|
|
293
|
+
break;
|
|
294
|
+
} else {
|
|
295
|
+
sp = isSpace(h) ? sp + 1 : 0;
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
} else {
|
|
300
|
+
// MARKUP: a comment, a CDATA section or a processing instruction
|
|
301
|
+
for (; i < to; i++) {
|
|
302
|
+
const b = bytes[i];
|
|
303
|
+
if (++len > MAX_TOKEN) throw tooLong();
|
|
304
|
+
if (b === 0x3e && last1 === close1 && (close2 === 0 || last2 === close2)) {
|
|
305
|
+
m = TEXT;
|
|
306
|
+
len = 0;
|
|
307
|
+
i++;
|
|
308
|
+
break;
|
|
309
|
+
}
|
|
310
|
+
last2 = last1;
|
|
311
|
+
last1 = b;
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
if (start < to) out.write(bytes, start, to);
|
|
316
|
+
mode = m;
|
|
317
|
+
quote = q;
|
|
318
|
+
spaces = sp;
|
|
319
|
+
length = len;
|
|
320
|
+
}
|
|
321
|
+
};
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* Where the value of the attribute `r` of the tag in buf[from, stop) starts, or -1: after whitespace,
|
|
326
|
+
* with whitespace around "=" and either quote, as an XML parser reads it; quoted values are skipped.
|
|
327
|
+
*/
|
|
328
|
+
function refAt(buf, from, stop) {
|
|
329
|
+
for (let k = from; k < stop; k++) {
|
|
330
|
+
const b = buf[k];
|
|
331
|
+
if (b === 0x22 || b === 0x27) {
|
|
332
|
+
k++;
|
|
333
|
+
while (k < stop && buf[k] !== b) k++;
|
|
334
|
+
} else if (isSpace(b) && buf[k + 1] === 0x72) {
|
|
335
|
+
let q = k + 2;
|
|
336
|
+
while (q < stop && isSpace(buf[q])) q++;
|
|
337
|
+
if (q < stop && buf[q] === 0x3d) {
|
|
338
|
+
q++;
|
|
339
|
+
while (q < stop && isSpace(buf[q])) q++;
|
|
340
|
+
if (q < stop && (buf[q] === 0x22 || buf[q] === 0x27)) {
|
|
341
|
+
q++;
|
|
342
|
+
while (q < stop && isSpace(buf[q])) q++;
|
|
343
|
+
return q;
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
return -1;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/** Where the tag starting at buf[from] ends: its ">" outside quotes, a "<" (never in a tag), or `to`. */
|
|
352
|
+
function tagEndAt(buf, from, to) {
|
|
353
|
+
for (let k = from; k < to; k++) {
|
|
354
|
+
const b = buf[k];
|
|
355
|
+
if (b === 0x3e || b === LT) return k;
|
|
356
|
+
if (b === 0x22 || b === 0x27) {
|
|
357
|
+
k++;
|
|
358
|
+
while (k < to && buf[k] !== b && buf[k] !== LT) k++;
|
|
359
|
+
if (buf[k] === LT) return k;
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
return to;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/** The row number of a `<row ...>` tag (its `r` attribute), or 0. */
|
|
366
|
+
function rowNumber(buf, from, to) {
|
|
367
|
+
const at = refAt(buf, from + 4, to);
|
|
368
|
+
let n = 0;
|
|
369
|
+
if (at !== -1) for (let k = at; k < to && buf[k] >= 0x30 && buf[k] <= 0x39 && n <= 1e12; k++) n = n * 10 + (buf[k] - 0x30);
|
|
370
|
+
return n;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/** The largest row (or start of a sheet before its <sheetData>) the filter holds while it waits for its end. */
|
|
374
|
+
const MAX_PENDING = 64 * 1024 * 1024;
|
|
375
|
+
/** The size from which a row waiting for its end is squeezed by createXmlGuard. */
|
|
376
|
+
const SQUEEZE_AT = 1024 * 1024;
|
|
377
|
+
const CELL = ascii("<c");
|
|
378
|
+
|
|
379
|
+
/** XML that starts at a "<", through createXmlGuard. */
|
|
380
|
+
function squeeze(buf, file, part) {
|
|
381
|
+
/** @type {Uint8Array[]} */
|
|
382
|
+
const pieces = [];
|
|
383
|
+
let size = 0;
|
|
384
|
+
createXmlGuard({ write: (b, from = 0, to = b.length) => {
|
|
385
|
+
pieces.push(b.subarray(from, to));
|
|
386
|
+
size += to - from;
|
|
387
|
+
} }, file, part).write(buf);
|
|
388
|
+
if (pieces.length === 1) return pieces[0];
|
|
389
|
+
const out = new Uint8Array(size);
|
|
390
|
+
let at = 0;
|
|
391
|
+
for (const p of pieces) {
|
|
392
|
+
out.set(p, at);
|
|
393
|
+
at += p.length;
|
|
394
|
+
}
|
|
395
|
+
return out;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/** The highest column (1 for A) and row of the cells' `r` attributes in buf[from, to). */
|
|
399
|
+
function cellExtent(buf, from, to) {
|
|
400
|
+
let column = 0;
|
|
401
|
+
let row = 0;
|
|
402
|
+
for (let i = indexOf(buf, CELL, from, to); i !== -1; i = indexOf(buf, CELL, i + 2, to)) {
|
|
403
|
+
if (!isSpace(buf[i + 2])) continue; // <col>, <cfRule>
|
|
404
|
+
// each tag is scanned once, up to its end: the time stays linear in the row's length
|
|
405
|
+
const stop = tagEndAt(buf, i + 1, to);
|
|
406
|
+
let q = refAt(buf, i + 2, stop);
|
|
407
|
+
if (q === -1) continue;
|
|
408
|
+
let c = 0;
|
|
409
|
+
for (; q < stop && ((buf[q] | 0x20) >= 0x61 && (buf[q] | 0x20) <= 0x7a) && c <= 1e7; q++) c = c * 26 + ((buf[q] | 0x20) - 0x60);
|
|
410
|
+
let r = 0;
|
|
411
|
+
for (; q < stop && buf[q] >= 0x30 && buf[q] <= 0x39 && r <= 1e12; q++) r = r * 10 + (buf[q] - 0x30);
|
|
412
|
+
column = Math.max(column, c);
|
|
413
|
+
row = Math.max(row, r);
|
|
414
|
+
}
|
|
415
|
+
return { column, row };
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
/**
|
|
419
|
+
* Streams a sheet's XML and leaves out its rows without a value and everything after `</sheetData>`
|
|
420
|
+
* but the last end tag (the root's): read-excel-file reads nothing there. Feed pieces with `push`; the
|
|
421
|
+
* XML kept goes to `out.write`; `end()` gives the row numbers seen.
|
|
422
|
+
*
|
|
423
|
+
* It also checks where the values are against the limits, before read-excel-file sees them: the
|
|
424
|
+
* parser makes a dense array of the sheet's extent (every row up to the last row with a value, each
|
|
425
|
+
* as wide as the widest), so a value in row 1,000,000 or column XFD costs that much memory however
|
|
426
|
+
* small the file is. A value beyond `maxRow` or `maxColumn`, or an extent of more than `maxCells`,
|
|
427
|
+
* stops the read with LIMIT_EXCEEDED.
|
|
428
|
+
* @param {{ write: (bytes: Uint8Array, from?: number, to?: number) => void }} out
|
|
429
|
+
* @param {{ file?: string, part?: string, maxRow?: number, maxColumn?: number, maxCells?: number }} [limits] `file` and `part` name them in messages
|
|
430
|
+
*/
|
|
431
|
+
export function createSheetFilter(out, limits = {}) {
|
|
432
|
+
const file = limits.file ?? "file";
|
|
433
|
+
const part = limits.part ?? "sheet";
|
|
434
|
+
let widest = 0;
|
|
435
|
+
let state = 0; // 0 before <sheetData>, 1 inside, 2 after
|
|
436
|
+
/** @type {Uint8Array} */
|
|
437
|
+
let pending = new Uint8Array(0);
|
|
438
|
+
let lastValueRow = 0;
|
|
439
|
+
let lastRow = 0;
|
|
440
|
+
let dropped = 0;
|
|
441
|
+
let errors = 0;
|
|
442
|
+
/** After the sheet's data: the last end tag seen. */
|
|
443
|
+
let closing = new Uint8Array(0);
|
|
444
|
+
|
|
445
|
+
const run = (final) => {
|
|
446
|
+
const buf = pending;
|
|
447
|
+
let i = 0;
|
|
448
|
+
while (i < buf.length) {
|
|
449
|
+
if (state === 0) {
|
|
450
|
+
const at = indexOf(buf, SHEET_DATA, i);
|
|
451
|
+
if (at === -1) {
|
|
452
|
+
const keep = final ? buf.length : Math.max(i, buf.length - SHEET_DATA.length);
|
|
453
|
+
out.write(buf, i, keep);
|
|
454
|
+
i = keep;
|
|
455
|
+
break;
|
|
456
|
+
}
|
|
457
|
+
const close = buf.indexOf(0x3e, at);
|
|
458
|
+
if (close === -1) {
|
|
459
|
+
out.write(buf, i, at);
|
|
460
|
+
i = at;
|
|
461
|
+
break;
|
|
462
|
+
}
|
|
463
|
+
out.write(buf, i, close + 1);
|
|
464
|
+
i = close + 1;
|
|
465
|
+
state = buf[close - 1] === 0x2f ? 2 : 1; // <sheetData/>
|
|
466
|
+
continue;
|
|
467
|
+
}
|
|
468
|
+
if (state === 2) {
|
|
469
|
+
// nothing after the sheet's data is read (merged cells, conditional formats, print
|
|
470
|
+
// settings): it is skipped, and only the last end tag (the root's) goes on, in end()
|
|
471
|
+
const lt = buf.indexOf(LT, i);
|
|
472
|
+
if (lt === -1) {
|
|
473
|
+
i = buf.length;
|
|
474
|
+
break;
|
|
475
|
+
}
|
|
476
|
+
const gt = buf.indexOf(0x3e, lt);
|
|
477
|
+
if (gt === -1) {
|
|
478
|
+
i = final ? buf.length : lt;
|
|
479
|
+
break;
|
|
480
|
+
}
|
|
481
|
+
if (buf[lt + 1] === 0x2f && gt - lt < 1024) closing = buf.slice(lt, gt + 1);
|
|
482
|
+
i = gt + 1;
|
|
483
|
+
continue;
|
|
484
|
+
}
|
|
485
|
+
// inside <sheetData>: whole rows at a time
|
|
486
|
+
const lt = buf.indexOf(LT, i);
|
|
487
|
+
if (lt === -1) {
|
|
488
|
+
i = buf.length; // whitespace between rows
|
|
489
|
+
break;
|
|
490
|
+
}
|
|
491
|
+
if (indexOf(buf, SHEET_DATA_END, lt, lt + SHEET_DATA_END.length) === lt) {
|
|
492
|
+
out.write(buf, lt, lt + SHEET_DATA_END.length);
|
|
493
|
+
state = 2;
|
|
494
|
+
i = lt + SHEET_DATA_END.length;
|
|
495
|
+
continue;
|
|
496
|
+
}
|
|
497
|
+
if (indexOf(buf, ROW, lt, lt + ROW.length) !== lt) {
|
|
498
|
+
// anything else in sheetData (a comment, say) is left out: read-excel-file reads rows
|
|
499
|
+
// only. A comment, CDATA section or processing instruction ends where the parser ends
|
|
500
|
+
// it, so a row inside one stays out too.
|
|
501
|
+
const close = buf[lt + 1] === 0x3f ? PI_END : indexOf(buf, COMMENT_START, lt, lt + 4) === lt ? COMMENT_END : indexOf(buf, CDATA_START, lt, lt + 9) === lt ? CDATA_END : GT;
|
|
502
|
+
const end = indexOf(buf, close, lt + 1);
|
|
503
|
+
if (end === -1) {
|
|
504
|
+
i = lt;
|
|
505
|
+
break;
|
|
506
|
+
}
|
|
507
|
+
i = end + close.length;
|
|
508
|
+
continue;
|
|
509
|
+
}
|
|
510
|
+
const tagEnd = buf.indexOf(0x3e, lt);
|
|
511
|
+
if (tagEnd === -1) {
|
|
512
|
+
i = lt;
|
|
513
|
+
break;
|
|
514
|
+
}
|
|
515
|
+
const number = rowNumber(buf, lt, tagEnd);
|
|
516
|
+
if (buf[tagEnd - 1] === 0x2f) {
|
|
517
|
+
// <row .../>: no cells
|
|
518
|
+
lastRow = Math.max(lastRow, number);
|
|
519
|
+
dropped++;
|
|
520
|
+
i = tagEnd + 1;
|
|
521
|
+
continue;
|
|
522
|
+
}
|
|
523
|
+
const end = indexOf(buf, ROW_END, tagEnd);
|
|
524
|
+
if (end === -1) {
|
|
525
|
+
i = lt;
|
|
526
|
+
break;
|
|
527
|
+
}
|
|
528
|
+
const stop = end + ROW_END.length;
|
|
529
|
+
lastRow = Math.max(lastRow, number);
|
|
530
|
+
if (indexOf(buf, CELL_END, tagEnd, end) !== -1) {
|
|
531
|
+
const extent = cellExtent(buf, tagEnd, end);
|
|
532
|
+
const at = Math.max(number, extent.row);
|
|
533
|
+
widest = Math.max(widest, extent.column);
|
|
534
|
+
if (limits.maxRow !== undefined && at > limits.maxRow) throw limitError("rows", at, limits.maxRow, file, "rows");
|
|
535
|
+
if (limits.maxColumn !== undefined && widest > limits.maxColumn) throw limitError("columns", widest, limits.maxColumn, file, "columns");
|
|
536
|
+
if (limits.maxCells !== undefined && at * widest > limits.maxCells) throw limitError("importCells", at * widest, limits.maxCells, file, "values (rows x columns)");
|
|
537
|
+
out.write(buf, lt, stop);
|
|
538
|
+
lastValueRow = Math.max(lastValueRow, at);
|
|
539
|
+
for (let e = indexOf(buf, ERROR_TYPE, tagEnd, end); e !== -1; e = indexOf(buf, ERROR_TYPE, e + 1, end)) {
|
|
540
|
+
if (isSpace(buf[e - 1])) errors++;
|
|
541
|
+
}
|
|
542
|
+
} else {
|
|
543
|
+
dropped++;
|
|
544
|
+
}
|
|
545
|
+
i = stop;
|
|
546
|
+
}
|
|
547
|
+
pending = i < buf.length ? buf.slice(i) : new Uint8Array(0);
|
|
548
|
+
// a row waiting for its end that grows large is squeezed (whitespace runs cut, over-long
|
|
549
|
+
// tokens refused) while it waits, so whitespace in a row is never held
|
|
550
|
+
if (state === 1 && pending.length > SQUEEZE_AT) pending = squeeze(pending, file, part);
|
|
551
|
+
if (pending.length > MAX_PENDING) {
|
|
552
|
+
throw fileError("DAMAGED", file, "a row of the sheet is larger than 64 MiB, or the sheet's XML is broken; the workbook is refused.");
|
|
553
|
+
}
|
|
554
|
+
};
|
|
555
|
+
|
|
556
|
+
// pieces wait until they double what is pending, so a long row is scanned a few times, not once per piece
|
|
557
|
+
let waiting = [];
|
|
558
|
+
let size = 0;
|
|
559
|
+
let need = 0;
|
|
560
|
+
const gather = () => {
|
|
561
|
+
if (waiting.length === 1) return waiting[0];
|
|
562
|
+
const all = new Uint8Array(size);
|
|
563
|
+
let at = 0;
|
|
564
|
+
for (const w of waiting) {
|
|
565
|
+
all.set(w, at);
|
|
566
|
+
at += w.length;
|
|
567
|
+
}
|
|
568
|
+
return all;
|
|
569
|
+
};
|
|
570
|
+
|
|
571
|
+
return {
|
|
572
|
+
/** @param {Uint8Array} chunk */
|
|
573
|
+
push(chunk) {
|
|
574
|
+
waiting.push(chunk);
|
|
575
|
+
size += chunk.length;
|
|
576
|
+
if (size < need) return;
|
|
577
|
+
pending = gather();
|
|
578
|
+
run(false);
|
|
579
|
+
waiting = pending.length ? [pending] : [];
|
|
580
|
+
size = pending.length;
|
|
581
|
+
need = pending.length * 2;
|
|
582
|
+
},
|
|
583
|
+
/** @returns {{ lastRow: number, lastValueRow: number, dropped: number, errors: number }} */
|
|
584
|
+
end() {
|
|
585
|
+
pending = size ? gather() : new Uint8Array(0);
|
|
586
|
+
waiting = [];
|
|
587
|
+
run(true);
|
|
588
|
+
if (pending.length) out.write(pending);
|
|
589
|
+
if (closing.length) out.write(closing);
|
|
590
|
+
return { lastRow, lastValueRow, dropped, errors };
|
|
591
|
+
}
|
|
592
|
+
};
|
|
593
|
+
}
|