pivotkit-xlsx 0.1.0 → 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/xml.js CHANGED
@@ -9,7 +9,10 @@
9
9
  * unpacked into memory, parsed and turned into empty rows. A row is kept when it has a cell with
10
10
  * content (`</c>`: a value, an inline string or a formula); a row of empty cells (`<c r="A9" s="1"/>`)
11
11
  * or a self-closing `<row/>` goes. Rows are numbered by their cells' `r` attributes, so leaving rows
12
- * out moves no value.
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.
13
16
  *
14
17
  * Not part of the public API.
15
18
  */
@@ -24,15 +27,25 @@ const ENTITY = ascii("ENTITY");
24
27
  const SHEET_DATA = ascii("<sheetData");
25
28
  const SHEET_DATA_END = ascii("</sheetData>");
26
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(">");
27
36
  const ROW_END = ascii("</row>");
28
37
  const CELL_END = ascii("</c>");
29
- const ERROR_TYPE = ascii(' t="e"');
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"');
30
41
 
31
- /** Index of `pattern` in `buf` from `from`, or -1. */
42
+ /** Index of `pattern` in `buf[from, to)`, or -1. */
32
43
  export function indexOf(buf, pattern, from = 0, to = buf.length) {
33
44
  const first = pattern[0];
34
45
  const last = to - pattern.length;
35
- for (let i = buf.indexOf(first, from); i !== -1 && i <= last; i = buf.indexOf(first, i + 1)) {
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)) {
36
49
  let k = 1;
37
50
  while (k < pattern.length && buf[i + k] === pattern[k]) k++;
38
51
  if (k === pattern.length) return i;
@@ -63,15 +76,20 @@ export function createXmlCheck(file, part) {
63
76
  throw fileError("XML_DECLARATION", file, `the part "${part}" is not UTF-8 XML, which a workbook always is; it is refused.`, { part });
64
77
  }
65
78
  first = false;
66
- const buf = tail.length ? concat(tail, chunk) : chunk;
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) {
67
86
  // "!" is rare in a workbook's XML (comments, CDATA, text); "<" is in every tag
68
87
  for (let i = buf.indexOf(BANG, 1); i !== -1; i = buf.indexOf(BANG, i + 1)) {
69
88
  if (buf[i - 1] === LT && (wordAt(buf, i + 1, DOCTYPE) || wordAt(buf, i + 1, ENTITY))) {
70
89
  throw fileError("XML_DECLARATION", file, `the part "${part}" declares a document type or entities, which a workbook never needs; it is refused.`, { part });
71
90
  }
72
91
  }
73
- tail = buf.slice(Math.max(0, buf.length - 9));
74
- };
92
+ }
75
93
  }
76
94
 
77
95
  /** Checks a whole XML part. */
@@ -86,49 +104,321 @@ function concat(a, b) {
86
104
  return out;
87
105
  }
88
106
 
89
- /** The row number of a `<row ...>` tag (its `r` attribute), or 0. */
90
- function rowNumber(buf, from, to) {
91
- for (let i = from; i < to - 3; i++) {
92
- // ' r="'
93
- if ((buf[i] === 0x20 || buf[i] === 0x09 || buf[i] === 0x0a || buf[i] === 0x0d) && buf[i + 1] === 0x72 && buf[i + 2] === 0x3d && buf[i + 3] === 0x22) {
94
- let n = 0;
95
- for (let k = i + 4; k < to && buf[k] >= 0x30 && buf[k] <= 0x39; k++) n = n * 10 + (buf[k] - 0x30);
96
- return n;
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
+ }
97
346
  }
98
347
  }
99
- return 0;
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;
100
371
  }
101
372
 
102
373
  /** The largest row (or start of a sheet before its <sheetData>) the filter holds while it waits for its end. */
103
374
  const MAX_PENDING = 64 * 1024 * 1024;
104
- const CELL = ascii("<c ");
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
+ }
105
397
 
106
398
  /** The highest column (1 for A) and row of the cells' `r` attributes in buf[from, to). */
107
399
  function cellExtent(buf, from, to) {
108
400
  let column = 0;
109
401
  let row = 0;
110
- for (let i = indexOf(buf, CELL, from, to); i !== -1; i = indexOf(buf, CELL, i + 3, to)) {
111
- const gt = buf.indexOf(0x3e, i);
112
- const stop = gt === -1 || gt > to ? to : gt;
113
- for (let k = i + 2; k < stop - 3; k++) {
114
- if ((buf[k] === 0x20 || buf[k] === 0x09) && buf[k + 1] === 0x72 && buf[k + 2] === 0x3d && buf[k + 3] === 0x22) {
115
- let c = 0;
116
- let q = k + 4;
117
- for (; q < stop && ((buf[q] | 0x20) >= 0x61 && (buf[q] | 0x20) <= 0x7a) && c <= 1e7; q++) c = c * 26 + ((buf[q] | 0x20) - 0x60);
118
- let r = 0;
119
- for (; q < stop && buf[q] >= 0x30 && buf[q] <= 0x39 && r <= 1e12; q++) r = r * 10 + (buf[q] - 0x30);
120
- column = Math.max(column, c);
121
- row = Math.max(row, r);
122
- break;
123
- }
124
- }
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);
125
414
  }
126
415
  return { column, row };
127
416
  }
128
417
 
129
418
  /**
130
- * Streams a sheet's XML and leaves out its rows without a value. Feed pieces with `push`; the XML
131
- * kept goes to `out.write`; `end()` gives the row numbers seen.
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.
132
422
  *
133
423
  * It also checks where the values are against the limits, before read-excel-file sees them: the
134
424
  * parser makes a dense array of the sheet's extent (every row up to the last row with a value, each
@@ -136,17 +426,21 @@ function cellExtent(buf, from, to) {
136
426
  * small the file is. A value beyond `maxRow` or `maxColumn`, or an extent of more than `maxCells`,
137
427
  * stops the read with LIMIT_EXCEEDED.
138
428
  * @param {{ write: (bytes: Uint8Array, from?: number, to?: number) => void }} out
139
- * @param {{ file?: string, maxRow?: number, maxColumn?: number, maxCells?: number }} [limits]
429
+ * @param {{ file?: string, part?: string, maxRow?: number, maxColumn?: number, maxCells?: number }} [limits] `file` and `part` name them in messages
140
430
  */
141
431
  export function createSheetFilter(out, limits = {}) {
142
432
  const file = limits.file ?? "file";
433
+ const part = limits.part ?? "sheet";
143
434
  let widest = 0;
144
435
  let state = 0; // 0 before <sheetData>, 1 inside, 2 after
436
+ /** @type {Uint8Array} */
145
437
  let pending = new Uint8Array(0);
146
438
  let lastValueRow = 0;
147
439
  let lastRow = 0;
148
440
  let dropped = 0;
149
441
  let errors = 0;
442
+ /** After the sheet's data: the last end tag seen. */
443
+ let closing = new Uint8Array(0);
150
444
 
151
445
  const run = (final) => {
152
446
  const buf = pending;
@@ -172,9 +466,21 @@ export function createSheetFilter(out, limits = {}) {
172
466
  continue;
173
467
  }
174
468
  if (state === 2) {
175
- out.write(buf, i, buf.length);
176
- i = buf.length;
177
- break;
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;
178
484
  }
179
485
  // inside <sheetData>: whole rows at a time
180
486
  const lt = buf.indexOf(LT, i);
@@ -183,19 +489,22 @@ export function createSheetFilter(out, limits = {}) {
183
489
  break;
184
490
  }
185
491
  if (indexOf(buf, SHEET_DATA_END, lt, lt + SHEET_DATA_END.length) === lt) {
492
+ out.write(buf, lt, lt + SHEET_DATA_END.length);
186
493
  state = 2;
187
- i = lt;
494
+ i = lt + SHEET_DATA_END.length;
188
495
  continue;
189
496
  }
190
497
  if (indexOf(buf, ROW, lt, lt + ROW.length) !== lt) {
191
- // anything else in sheetData (a comment, say) is kept as it is
192
- const gt = buf.indexOf(0x3e, lt);
193
- if (gt === -1) {
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) {
194
504
  i = lt;
195
505
  break;
196
506
  }
197
- out.write(buf, lt, gt + 1);
198
- i = gt + 1;
507
+ i = end + close.length;
199
508
  continue;
200
509
  }
201
510
  const tagEnd = buf.indexOf(0x3e, lt);
@@ -227,13 +536,18 @@ export function createSheetFilter(out, limits = {}) {
227
536
  if (limits.maxCells !== undefined && at * widest > limits.maxCells) throw limitError("importCells", at * widest, limits.maxCells, file, "values (rows x columns)");
228
537
  out.write(buf, lt, stop);
229
538
  lastValueRow = Math.max(lastValueRow, at);
230
- for (let e = indexOf(buf, ERROR_TYPE, tagEnd, end); e !== -1; e = indexOf(buf, ERROR_TYPE, e + 1, end)) errors++;
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
+ }
231
542
  } else {
232
543
  dropped++;
233
544
  }
234
545
  i = stop;
235
546
  }
236
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);
237
551
  if (pending.length > MAX_PENDING) {
238
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.");
239
553
  }
@@ -272,6 +586,7 @@ export function createSheetFilter(out, limits = {}) {
272
586
  waiting = [];
273
587
  run(true);
274
588
  if (pending.length) out.write(pending);
589
+ if (closing.length) out.write(closing);
275
590
  return { lastRow, lastValueRow, dropped, errors };
276
591
  }
277
592
  };
package/src/zip.js CHANGED
@@ -13,7 +13,7 @@
13
13
  */
14
14
 
15
15
  import { Inflate } from "fflate";
16
- import { fileError } from "pivotkit-core/import";
16
+ import { fileError, limitError } from "pivotkit-core/import";
17
17
 
18
18
  const EOCD = 0x06054b50;
19
19
  const EOCD64_LOCATOR = 0x07064b50;
@@ -64,7 +64,7 @@ export function readDirectory(bytes, file, limits) {
64
64
  offset = u64(bytes, at + 48);
65
65
  }
66
66
  if (count > limits.archiveEntries) {
67
- throw fileError("LIMIT", file, `the workbook has ${count.toLocaleString("en-US")} parts, more than ${limits.archiveEntries.toLocaleString("en-US")} (limits.archiveEntries).`, { limit: "archiveEntries", value: count, max: limits.archiveEntries });
67
+ throw limitError("archiveEntries", count, limits.archiveEntries, file, "parts in its ZIP directory");
68
68
  }
69
69
  if (offset + size > bytes.length) throw broken("directory outside the file");
70
70
  /** @type {ZipEntry[]} */
@@ -105,7 +105,7 @@ export function readDirectory(bytes, file, limits) {
105
105
  }
106
106
  total += unpacked;
107
107
  if (total > limits.unpackedBytes) {
108
- throw fileError("LIMIT", file, `the workbook unpacks to more than ${limits.unpackedBytes.toLocaleString("en-US")} bytes (limits.unpackedBytes): a ZIP bomb, or a workbook too large to load.`, { limit: "unpackedBytes", value: total, max: 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
109
  }
110
110
  entries.push({ name, method, compressedSize, size: unpacked, localOffset });
111
111
  p = extraEnd + commentLength;
package/types/xml.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- /** Index of `pattern` in `buf` from `from`, or -1. */
1
+ /** Index of `pattern` in `buf[from, to)`, or -1. */
2
2
  export declare function indexOf(buf: any, pattern: any, from?: number, to?: any): any;
3
3
  /**
4
4
  * A check of an XML part's bytes, fed piece by piece: throws at a `<!DOCTYPE` or `<!ENTITY`.
@@ -9,8 +9,51 @@ export declare function createXmlCheck(file: string, part: string): (chunk: any)
9
9
  /** Checks a whole XML part. */
10
10
  export declare function checkXml(bytes: any, file: any, part: any): void;
11
11
  /**
12
- * Streams a sheet's XML and leaves out its rows without a value. Feed pieces with `push`; the XML
13
- * kept goes to `out.write`; `end()` gives the row numbers seen.
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.
14
57
  *
15
58
  * It also checks where the values are against the limits, before read-excel-file sees them: the
16
59
  * parser makes a dense array of the sheet's extent (every row up to the last row with a value, each
@@ -18,12 +61,13 @@ export declare function checkXml(bytes: any, file: any, part: any): void;
18
61
  * small the file is. A value beyond `maxRow` or `maxColumn`, or an extent of more than `maxCells`,
19
62
  * stops the read with LIMIT_EXCEEDED.
20
63
  * @param {{ write: (bytes: Uint8Array, from?: number, to?: number) => void }} out
21
- * @param {{ file?: string, maxRow?: number, maxColumn?: number, maxCells?: number }} [limits]
64
+ * @param {{ file?: string, part?: string, maxRow?: number, maxColumn?: number, maxCells?: number }} [limits] `file` and `part` name them in messages
22
65
  */
23
66
  export declare function createSheetFilter(out: {
24
67
  write: (bytes: Uint8Array, from?: number, to?: number) => void;
25
68
  }, limits?: {
26
69
  file?: string;
70
+ part?: string;
27
71
  maxRow?: number;
28
72
  maxColumn?: number;
29
73
  maxCells?: number;