@substrat-run/attachment-extractors 0.1.0 → 0.1.2

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/dist/index.js ADDED
@@ -0,0 +1,1345 @@
1
+ /**
2
+ * `@substrat-run/attachment-extractors` — the parsers behind the kernel's attachment
3
+ * extractor seam (#1575, K-43).
4
+ *
5
+ * **The kernel indexes attachment text; it does not parse file formats** (K-43). It owns the
6
+ * text table, its index, the job, the outcome states, the output cap and the gated search,
7
+ * and defines one seam, `AttachmentExtractor`. Everything that reads a file FORMAT lives
8
+ * here, and the kernel and the adapters never import this package (`lint:deps` refuses it):
9
+ * whoever constructs the host passes `defaultAttachmentExtractors()` in, or a list of its
10
+ * own. Untrusted-input parsing belongs at the edge, not beside the permission checker.
11
+ *
12
+ * Best-effort by content type, and explicit about what it does not do:
13
+ *
14
+ * - **text** (`text/*` other than HTML): decoded as the declared charset, UTF-8 otherwise.
15
+ * - **html** (`text/html`, `application/xhtml+xml`): tags stripped, each construct ended where
16
+ * the HTML tokenizer ends it; comments, `script`, `style`, the other raw-text elements a
17
+ * browser does not render, and `template` content, dropped — an unclosed one through to the
18
+ * end; entities decoded. **Its contract: it never indexes text the HTML parser keeps out of
19
+ * the rendered document**, and it may under-index what it does not model — inline SVG and
20
+ * MathML, `select`, a honoured frameset — indexing nothing inside them, and nothing after one
21
+ * whose end it cannot be sure of (`htmlText` lists the rules). It does not evaluate the
22
+ * `hidden` attribute, CSS or interactive state, so text those hide is indexed.
23
+ * - **docx / xlsx / pptx**: an OOXML file is a zip of XML parts, so this reads the zip's
24
+ * central directory, inflates only the parts that carry text, and keeps the text of
25
+ * their `t` elements. The inflate is the web-standard `DecompressionStream('deflate-raw')`,
26
+ * present in Node and workerd alike, so the whole format costs no dependency.
27
+ * - **No PDF yet, and no OCR.** A host given no extractor for a type records `unsupported`
28
+ * with that reason — a legible outcome, never "indexed, no match".
29
+ *
30
+ * ## The bounds this package owns, and why
31
+ *
32
+ * The bounds that protect the PROCESS doing the parsing live here (K-43); the one that
33
+ * protects the scope — the output cap — is the kernel's, enforced on whatever comes back.
34
+ *
35
+ * - `maxInputBytes` is declared on each extractor, so the kernel can refuse an oversized
36
+ * file on its RECORDED size before fetching a byte; each extractor checks it again.
37
+ * - `maxInflatedBytes` is the zip-bomb cap: a counter on the bytes the inflater actually
38
+ * produces, across every part of one file. An entry's declared size is checked first as
39
+ * a cheap refusal, and never trusted, because a bomb is exactly a file that lies about it.
40
+ *
41
+ * A file that is not what it says, or breaks a bound, is answered `{ failed }` with a
42
+ * content-free reason — never a throw, and never a quote of the file: the reason lands in
43
+ * the scope, and the content it would quote is what the read gate protects.
44
+ *
45
+ * ## CPU, bounded by construction — and stoppable
46
+ *
47
+ * Every parser here is a LINEAR scan (no regular expression that can rescan from each
48
+ * opener), over an input with a fixed cap, so the worst case is a property of the code rather
49
+ * than of the file. Per format, at the default bounds:
50
+ *
51
+ * - **text**: a decode of at most 4 × the kernel's output cap (2 MiB).
52
+ * - **html**: a decode of at most 16 × the output cap (8 MiB), one forward scan of it, one
53
+ * entity pass over what survives.
54
+ * - **docx / xlsx / pptx**: the central directory (at most 20 000 entries), inflate and decode
55
+ * of at most `maxInflatedBytes` (16 MiB) in total, and one forward scan of each inflated
56
+ * part; parts stop being read once the text collected is twice the output cap.
57
+ *
58
+ * Each extractor honours the kernel's time budget cooperatively (`Pace`): it checks the
59
+ * `signal`, and yields a turn of the event loop so the kernel's timer can fire, between zip
60
+ * entries and at least every `EXTRACTION_STRIDE` (the kernel's, 256 Ki) units of work. That
61
+ * holds for every pass over the content — decoding, inflating, the zip directory, the entity
62
+ * pass, and each search for a construct's end, a native `indexOf` included, which is cut to
63
+ * the window left before the next check. An aborted extraction answers
64
+ * `{ failed: 'the extraction was aborted' }` within one stride. The steps between two checks
65
+ * that are NOT cut to a stride are copies (joining the text collected) and the name tests
66
+ * over the zip directory, at most 20 000 names of at most 255 bytes each.
67
+ */
68
+ import { EXTRACTION_STRIDE, } from '@substrat-run/kernel';
69
+ /**
70
+ * The defaults. 16 MiB of inflated XML is far more than the text of any ordinary office
71
+ * document, and small enough that decoding it fits a Worker's memory.
72
+ */
73
+ export const DEFAULT_EXTRACTOR_BOUNDS = {
74
+ maxInputBytes: 32 * 1024 * 1024,
75
+ maxInflatedBytes: 16 * 1024 * 1024,
76
+ };
77
+ const DOCX = 'application/vnd.openxmlformats-officedocument.wordprocessingml.document';
78
+ const XLSX = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet';
79
+ const PPTX = 'application/vnd.openxmlformats-officedocument.presentationml.presentation';
80
+ /** `text/plain; charset=UTF-8` → `{ type: 'text/plain', charset: 'utf-8' }`. */
81
+ function parseContentType(contentType) {
82
+ const [head, ...params] = contentType.split(';');
83
+ let charset = null;
84
+ for (const p of params) {
85
+ const [k, v] = p.split('=');
86
+ if (k?.trim().toLowerCase() === 'charset' && v)
87
+ charset = v.trim().replace(/^"|"$/g, '').toLowerCase();
88
+ }
89
+ return { type: (head ?? '').trim().toLowerCase(), charset };
90
+ }
91
+ /**
92
+ * Whether a file is this format: the declared type decides. The file name is consulted only
93
+ * when the type says nothing (`application/octet-stream`, or absent), because a client that
94
+ * knew better would have said so — and a name contradicting a specific type is not evidence
95
+ * worth more than the type.
96
+ */
97
+ function acceptsBy(types, extensions) {
98
+ return (contentType, filename) => {
99
+ const { type } = parseContentType(contentType);
100
+ if (types(type))
101
+ return true;
102
+ if (type !== '' && type !== 'application/octet-stream')
103
+ return false;
104
+ const ext = /\.([A-Za-z0-9]+)$/.exec(filename)?.[1]?.toLowerCase();
105
+ return ext !== undefined && extensions.includes(ext);
106
+ };
107
+ }
108
+ /** Raised when a bound refuses the work. Distinct so the answer can say which bound. */
109
+ class ExtractionBoundExceeded extends Error {
110
+ }
111
+ /** A file that is not what its type says, or is damaged. Carries a content-free reason. */
112
+ class MalformedInput extends Error {
113
+ }
114
+ /** The kernel's budget ran out and aborted the signal: the extraction stops where it is. */
115
+ class ExtractionAborted extends Error {
116
+ }
117
+ const runtime = globalThis;
118
+ /**
119
+ * One turn of the event loop, by the cheapest primitive that still lets a due timer — the
120
+ * kernel's deadline — run before what follows. Every yield the parsers make comes through here.
121
+ *
122
+ * `setImmediate` where the runtime has it (Node, and workerd, which has it as a global): it goes
123
+ * once round the loop, through the timers, without the 1 ms floor Node puts under
124
+ * `setTimeout(…, 0)` — the floor that made pacing a large file cost more in waiting than in
125
+ * parsing. `setTimeout(…, 0)` otherwise. Not `scheduler.yield()`: neither runtime has it, and
126
+ * where it exists its continuation is scheduled AHEAD of other tasks, which is the opposite of
127
+ * letting a timer in. Called through the global each time, never as a detached reference.
128
+ */
129
+ const nextTurn = typeof runtime.setImmediate === 'function'
130
+ ? () => new Promise((resolve) => void runtime.setImmediate(resolve))
131
+ : () => new Promise((resolve) => setTimeout(resolve, 0));
132
+ /**
133
+ * Cooperation with the kernel's time budget (K-43): stop if the signal is aborted, and yield
134
+ * a turn first so a timer that is due gets to abort it. Without the yield a long parse would
135
+ * hold the thread and the timer would never run until it was over.
136
+ */
137
+ async function checkpoint(signal) {
138
+ if (signal.aborted)
139
+ throw new ExtractionAborted('the extraction was aborted');
140
+ await nextTurn();
141
+ if (signal.aborted)
142
+ throw new ExtractionAborted('the extraction was aborted');
143
+ }
144
+ /**
145
+ * One extraction's pacing against the kernel's budget. Work is counted in units — a character
146
+ * scanned or decoded, a byte inflated or decoded — and no step runs more than
147
+ * `EXTRACTION_STRIDE` of them past the last `checkpoint`, give or take the few characters a
148
+ * search must see whole (a needle's length, an entity's span). A native search goes through `find`,
149
+ * which cuts it to the window left before the next check, and a hand-written loop through
150
+ * `scan`; that is what makes "stops within one stride" true of an `indexOf` across a long run
151
+ * of text, a comment or an unclosed tag, and not only of the loop around it.
152
+ */
153
+ class Pace {
154
+ signal;
155
+ left = EXTRACTION_STRIDE;
156
+ constructor(signal) {
157
+ this.signal = signal;
158
+ }
159
+ /** Units that may still run before the next check — positive once `turn` has returned. */
160
+ get room() {
161
+ return this.left;
162
+ }
163
+ charge(units) {
164
+ this.left -= units;
165
+ }
166
+ /** Stop if the signal is aborted; once a stride is spent, yield a turn and check again. */
167
+ async turn() {
168
+ if (this.left > 0 && !this.signal.aborted)
169
+ return;
170
+ await checkpoint(this.signal);
171
+ this.left = EXTRACTION_STRIDE;
172
+ }
173
+ /** `s.indexOf(needle, from)`, searched one window at a time. */
174
+ async find(s, needle, from) {
175
+ for (let at = from; at < s.length;) {
176
+ await this.turn();
177
+ const end = Math.min(s.length, at + this.left);
178
+ // Each window reaches a needle's length less one into the next, so a match that
179
+ // straddles the edge is found whole — and found in exactly one window.
180
+ const k = s.slice(at, end + needle.length - 1).indexOf(needle);
181
+ if (k >= 0) {
182
+ this.charge(k + needle.length);
183
+ return at + k;
184
+ }
185
+ this.charge(end - at);
186
+ at = end;
187
+ }
188
+ return -1;
189
+ }
190
+ /** Visit `s` from `from`; `step(c, k)` returns true to stop at `k`. The index stopped at, or -1. */
191
+ async scan(s, from, step) {
192
+ for (let k = from; k < s.length;) {
193
+ await this.turn();
194
+ const start = k;
195
+ const end = Math.min(s.length, k + this.left);
196
+ for (; k < end; k += 1) {
197
+ if (step(s.charCodeAt(k), k)) {
198
+ this.charge(k + 1 - start);
199
+ return k;
200
+ }
201
+ }
202
+ this.charge(end - start);
203
+ }
204
+ return -1;
205
+ }
206
+ /** Bytes through `decoder` a window at a time; `stream` keeps a character cut by an edge whole. */
207
+ async decode(decoder, bytes, out) {
208
+ for (let at = 0; at < bytes.length;) {
209
+ await this.turn();
210
+ const end = Math.min(bytes.length, at + this.left);
211
+ out.push(decoder.decode(bytes.subarray(at, end), { stream: true }));
212
+ this.charge(end - at);
213
+ at = end;
214
+ }
215
+ }
216
+ }
217
+ /** Decode bytes as text: a UTF-16 BOM wins, then a declared charset, then UTF-8. */
218
+ async function decodeText(bytes, charset, pace) {
219
+ let label = charset ?? 'utf-8';
220
+ if (bytes[0] === 0xff && bytes[1] === 0xfe)
221
+ label = 'utf-16le';
222
+ else if (bytes[0] === 0xfe && bytes[1] === 0xff)
223
+ label = 'utf-16be';
224
+ let decoder;
225
+ try {
226
+ decoder = new TextDecoder(label);
227
+ }
228
+ catch {
229
+ // An unknown label is the client's typo, not a reason to index nothing.
230
+ decoder = new TextDecoder('utf-8');
231
+ }
232
+ const out = [];
233
+ await pace.decode(decoder, bytes, out);
234
+ out.push(decoder.decode());
235
+ return out.join('');
236
+ }
237
+ // -- HTML ----------------------------------------------------------------------------
238
+ /** The five entities XML itself defines — all an OOXML part may use by name. */
239
+ const XML_ENTITIES = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'" };
240
+ /** The Latin-1 named entities, plus the XML five. Enough for prose in western languages. */
241
+ const NAMED_ENTITIES = (() => {
242
+ const latin1 = 'nbsp iexcl cent pound curren yen brvbar sect uml copy ordf laquo not shy reg macr deg plusmn ' +
243
+ 'sup2 sup3 acute micro para middot cedil sup1 ordm raquo frac14 frac12 frac34 iquest Agrave ' +
244
+ 'Aacute Acirc Atilde Auml Aring AElig Ccedil Egrave Eacute Ecirc Euml Igrave Iacute Icirc Iuml ' +
245
+ 'ETH Ntilde Ograve Oacute Ocirc Otilde Ouml times Oslash Ugrave Uacute Ucirc Uuml Yacute THORN ' +
246
+ 'szlig agrave aacute acirc atilde auml aring aelig ccedil egrave eacute ecirc euml igrave iacute ' +
247
+ 'icirc iuml eth ntilde ograve oacute ocirc otilde ouml divide oslash ugrave uacute ucirc uuml ' +
248
+ 'yacute thorn yuml';
249
+ const map = { ...XML_ENTITIES };
250
+ latin1.split(' ').forEach((name, i) => {
251
+ map[name] = String.fromCharCode(0xa0 + i);
252
+ });
253
+ // A few typographic ones that turn up in pasted prose.
254
+ Object.assign(map, {
255
+ ndash: '–',
256
+ mdash: '—',
257
+ lsquo: '‘',
258
+ rsquo: '’',
259
+ ldquo: '“',
260
+ rdquo: '”',
261
+ hellip: '…',
262
+ euro: '€',
263
+ bull: '•',
264
+ });
265
+ return map;
266
+ })();
267
+ /**
268
+ * How far before a window's edge the entity pass looks for an `&` to end the window at, so a
269
+ * reference is not cut in two. Every named reference and `&#x10FFFF;` is shorter; a longer one
270
+ * (only leading zeros make one) that straddles an edge is left as written.
271
+ */
272
+ const ENTITY_SPAN = 32;
273
+ /**
274
+ * `&amp;` `&#228;` `&#xE4;` → characters, a window at a time. An unknown name is left as
275
+ * written. The pattern is linear — each `&` is tried once against the run that follows it.
276
+ */
277
+ async function decodeEntities(text, named, pace) {
278
+ const out = [];
279
+ for (let at = 0; at < text.length;) {
280
+ await pace.turn();
281
+ // At least two spans, however little room is left: a window that began at an `&` and was
282
+ // shorter than its reference would cut it with no `&` behind the cut to back off to.
283
+ let end = Math.min(text.length, at + Math.max(pace.room, 2 * ENTITY_SPAN));
284
+ if (end < text.length) {
285
+ const edge = end - ENTITY_SPAN;
286
+ const amp = text.slice(edge, end).lastIndexOf('&');
287
+ if (amp >= 0)
288
+ end = edge + amp;
289
+ }
290
+ out.push(text.slice(at, end).replace(/&(#x[0-9a-fA-F]+|#[0-9]+|[A-Za-z][A-Za-z0-9]*);/g, (whole, ref) => {
291
+ if (ref[0] === '#') {
292
+ const code = ref[1] === 'x' || ref[1] === 'X' ? parseInt(ref.slice(2), 16) : parseInt(ref.slice(1), 10);
293
+ return Number.isFinite(code) && code > 0 && code <= 0x10ffff ? String.fromCodePoint(code) : whole;
294
+ }
295
+ return named[ref] ?? whole;
296
+ }));
297
+ pace.charge(end - at);
298
+ at = end;
299
+ }
300
+ return out.join('');
301
+ }
302
+ /** Elements whose start or end reads as a line break when the tags are stripped. */
303
+ const HTML_BLOCK = new Set([
304
+ 'p', 'div', 'br', 'li', 'ul', 'ol', 'tr', 'table', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
305
+ 'section', 'article', 'header', 'footer', 'blockquote', 'pre', 'hr', 'title', 'dt', 'dd',
306
+ ]);
307
+ /** Table cells: their boundary reads as a space, so neighbouring cells do not run together. */
308
+ const HTML_CELL = new Set(['td', 'th']);
309
+ /**
310
+ * Raw-text elements a reader never sees: the tokenizer reads their content as text up to
311
+ * their own end tag, and a browser does not render it. (`noscript` is raw text when scripting
312
+ * is on, as it is in every browser a page is opened in.) `script` is raw text too, with
313
+ * escape rules of its own — `scriptEnd`.
314
+ */
315
+ const HTML_HIDDEN_RAW = new Set(['style', 'noscript', 'iframe', 'noembed', 'noframes']);
316
+ /**
317
+ * Elements whose content the tokenizer also reads as TEXT up to their own end tag, but a
318
+ * browser SHOWS: `title` and `textarea` are RCDATA (entities decoded), `xmp` is RAWTEXT (shown
319
+ * as written). Markup inside them is text, so a `</template>` there closes nothing — and
320
+ * inside a template, their content is as hidden as the rest of it. (`plaintext` is the last
321
+ * of the kind: everything after it is text, to the end of the file.)
322
+ */
323
+ const HTML_SHOWN_RCDATA = new Set(['title', 'textarea']);
324
+ const HTML_SHOWN_RAWTEXT = new Set(['xmp']);
325
+ /** The longest element name this scanner acts on (`blockquote`); a longer one is just a tag. */
326
+ const HTML_NAME_MAX = 10;
327
+ const isAsciiAlpha = (c) => (c >= 65 && c <= 90) || (c >= 97 && c <= 122);
328
+ /** The tokenizer's whitespace (tab, LF, FF, CR, space). */
329
+ const isHtmlSpace = (c) => c === 9 || c === 10 || c === 12 || c === 13 || c === 32;
330
+ /** What ends a tag name, for the tokenizer: whitespace, `/` or `>` — nothing else. */
331
+ const endsTagName = (c) => isHtmlSpace(c) || c === 47 || c === 62;
332
+ /**
333
+ * Whether the tag name `name` (lower case) is spelled at `at` and ENDS there — ASCII case
334
+ * folded, then whitespace, `/` or `>` — which is the tokenizer's test for an end tag that
335
+ * closes a raw-text element. `</scripture>` and `</script-x>` spell `script` and close
336
+ * nothing; `</SCRIPT >` closes it. A name running into the end of the file closes nothing.
337
+ */
338
+ function namedAt(s, at, name) {
339
+ if (at + name.length >= s.length)
340
+ return false;
341
+ for (let j = 0; j < name.length; j += 1) {
342
+ // `| 0x20` folds A–Z onto a–z, and maps nothing else onto a lower-case letter.
343
+ if ((s.charCodeAt(at + j) | 0x20) !== name.charCodeAt(j))
344
+ return false;
345
+ }
346
+ return endsTagName(s.charCodeAt(at + name.length));
347
+ }
348
+ /** The tag name at `at`, ASCII lower-cased — or '' when it is longer than any name acted on. */
349
+ function shortTagName(s, at) {
350
+ let k = at;
351
+ while (k < s.length && k - at <= HTML_NAME_MAX && !endsTagName(s.charCodeAt(k)))
352
+ k += 1;
353
+ if (k - at > HTML_NAME_MAX)
354
+ return '';
355
+ return s.slice(at, k).replace(/[A-Z]/g, (c) => String.fromCharCode(c.charCodeAt(0) + 32));
356
+ }
357
+ // The tokenizer's states inside a tag, as far as finding its end needs them: its self-closing
358
+ // and after-quoted-value states end a tag and begin an attribute exactly as "before attribute
359
+ // name" does, so they share its number.
360
+ const TAG_NAME = 0;
361
+ const BEFORE_ATTR = 1;
362
+ const ATTR_NAME = 2;
363
+ const AFTER_ATTR = 3;
364
+ const BEFORE_VALUE = 4;
365
+ const UNQUOTED = 5;
366
+ const QUOTED = 6;
367
+ /**
368
+ * The `>` that ends the tag whose name starts at `from`, or -1 — by the HTML tokenizer's own
369
+ * states, so a `>` inside a quoted attribute value ends nothing (`<a title="x > y">`), while
370
+ * a quote that opens no value (`<a "x>`) is just a character.
371
+ *
372
+ * `flags.selfClosing` says whether the tokenizer set the tag's self-closing flag: a `/` met
373
+ * outside a value, right before the `>`. `<svg/>` sets it; `<svg a=b/>` does not, because that
374
+ * `/` is part of the unquoted value — a difference that decides whether foreign content opened.
375
+ */
376
+ function htmlTagEnd(s, from, pace, flags) {
377
+ let state = TAG_NAME;
378
+ let quote = 0;
379
+ let slash = false;
380
+ return pace.scan(s, from, (c) => {
381
+ if (state === QUOTED) {
382
+ slash = false;
383
+ if (c === quote)
384
+ state = BEFORE_ATTR;
385
+ return false;
386
+ }
387
+ if (c === 62) {
388
+ if (flags)
389
+ flags.selfClosing = slash;
390
+ return true;
391
+ }
392
+ const space = isHtmlSpace(c);
393
+ // The tokenizer's self-closing start tag state is entered by a `/` outside a value.
394
+ slash = c === 47 && state !== BEFORE_VALUE && state !== UNQUOTED;
395
+ switch (state) {
396
+ case TAG_NAME:
397
+ if (space || c === 47)
398
+ state = BEFORE_ATTR;
399
+ break;
400
+ case BEFORE_ATTR:
401
+ if (!space && c !== 47)
402
+ state = ATTR_NAME; // an `=` here starts a NAME, not a value
403
+ break;
404
+ case ATTR_NAME:
405
+ if (space)
406
+ state = AFTER_ATTR;
407
+ else if (c === 47)
408
+ state = BEFORE_ATTR;
409
+ else if (c === 61)
410
+ state = BEFORE_VALUE;
411
+ break;
412
+ case AFTER_ATTR:
413
+ if (c === 47)
414
+ state = BEFORE_ATTR;
415
+ else if (c === 61)
416
+ state = BEFORE_VALUE;
417
+ else if (!space)
418
+ state = ATTR_NAME;
419
+ break;
420
+ case BEFORE_VALUE:
421
+ if (c === 34 || c === 39) {
422
+ state = QUOTED;
423
+ quote = c;
424
+ }
425
+ else if (!space)
426
+ state = UNQUOTED;
427
+ break;
428
+ case UNQUOTED:
429
+ if (space)
430
+ state = BEFORE_ATTR;
431
+ break;
432
+ }
433
+ return false;
434
+ });
435
+ }
436
+ /**
437
+ * The `>` that ends the comment opened at `lt`, or -1. As the tokenizer reads one: `-->` ends
438
+ * it, so does `--!>`, and so do the abrupt `<!-->` and `<!--->` — which is why the search for
439
+ * `-->` may overlap the opener's own dashes, and the one for `--!>` may not.
440
+ */
441
+ async function commentEnd(s, lt, pace) {
442
+ for (let p = lt + 2;;) {
443
+ const k = await pace.find(s, '--', p);
444
+ if (k < 0)
445
+ return -1;
446
+ if (s.charCodeAt(k + 2) === 62)
447
+ return k + 2;
448
+ if (k >= lt + 4 && s.charCodeAt(k + 2) === 33 && s.charCodeAt(k + 3) === 62)
449
+ return k + 3;
450
+ p = k + 1;
451
+ }
452
+ }
453
+ /** Where the end tag closing a raw-text element's content starts (`</style …`), or -1. */
454
+ async function rawTextEnd(s, from, name, pace) {
455
+ for (let k = from;;) {
456
+ const lt = await pace.find(s, '</', k);
457
+ if (lt < 0 || namedAt(s, lt + 2, name))
458
+ return lt;
459
+ k = lt + 2;
460
+ }
461
+ }
462
+ const closesScript = (s, lt) => s.charCodeAt(lt + 1) === 47 && namedAt(s, lt + 2, 'script');
463
+ /**
464
+ * Where the `</script` closing a script's content starts, or -1 — by the tokenizer's script
465
+ * data states. Only a complete `</script` closes it; and after a `<!--` inside the script, a
466
+ * `<script` there makes the next `</script` close only that, until `-->` (the "double escaped"
467
+ * state, which a browser honours: `<script><!--<script></script>still script</script>`).
468
+ */
469
+ async function scriptEnd(s, from, pace) {
470
+ for (let k = from;;) {
471
+ const lt = await pace.find(s, '<', k);
472
+ if (lt < 0 || closesScript(s, lt))
473
+ return lt;
474
+ if (!s.startsWith('<!--', lt)) {
475
+ k = lt + 1;
476
+ continue;
477
+ }
478
+ // Escaped (after `<!--`), and double escaped (after a `<script` in that) — back to plain
479
+ // script data at the next `-->`.
480
+ let double = false;
481
+ let dashes = 2;
482
+ let closer = -1;
483
+ const back = await pace.scan(s, lt + 4, (c, j) => {
484
+ if (c === 45) {
485
+ dashes += 1;
486
+ return false;
487
+ }
488
+ if (c === 62 && dashes >= 2)
489
+ return true;
490
+ dashes = 0;
491
+ if (c !== 60)
492
+ return false;
493
+ if (double) {
494
+ if (closesScript(s, j))
495
+ double = false;
496
+ }
497
+ else if (closesScript(s, j)) {
498
+ closer = j;
499
+ return true;
500
+ }
501
+ else if (namedAt(s, j + 1, 'script')) {
502
+ double = true;
503
+ }
504
+ return false;
505
+ });
506
+ if (back < 0 || closer >= 0)
507
+ return closer;
508
+ k = back + 1;
509
+ }
510
+ }
511
+ /**
512
+ * Start tags at which a browser leaves foreign content wherever it is (`font` only with certain
513
+ * attributes — here, conservatively, always). `</br>` and `</p>` do the same as end tags.
514
+ */
515
+ const FOREIGN_BREAKOUT = new Set([
516
+ 'b', 'big', 'blockquote', 'body', 'br', 'center', 'code', 'dd', 'div', 'dl', 'dt', 'em', 'embed',
517
+ 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'head', 'hr', 'i', 'img', 'li', 'listing', 'menu', 'meta',
518
+ 'nobr', 'ol', 'p', 'pre', 'ruby', 's', 'small', 'span', 'strong', 'strike', 'sub', 'sup', 'table',
519
+ 'tt', 'u', 'ul', 'var', 'font',
520
+ ]);
521
+ /**
522
+ * Foreign elements inside which a start tag is parsed as HTML — SVG's HTML integration points
523
+ * and MathML's text integration points, with `annotation-xml` counted whatever its encoding.
524
+ */
525
+ const INTEGRATION_POINTS = new Set(['foreignobject', 'desc', 'title', 'mi', 'mo', 'mn', 'ms', 'mtext', 'annotation-xml']);
526
+ /**
527
+ * In a select, the start tags at which a browser leaves it early. After one, the scanner can no
528
+ * longer be sure what a browser shows, so nothing more of the file is indexed. (Every other
529
+ * start tag in a select — `svg`, `frameset`, `plaintext` included — a browser ignores.)
530
+ */
531
+ const SELECT_LEAVES = new Set([
532
+ 'select', 'input', 'keygen', 'textarea', 'caption', 'table', 'tbody', 'tfoot', 'thead', 'tr', 'td', 'th',
533
+ ]);
534
+ /**
535
+ * Start tags a browser always clears the frameset-ok flag on: these by the "in body" rules, and
536
+ * `template` by the "in head" rules, wherever it appears. (`input` clears it too unless its
537
+ * `type` is `hidden` — `FramesetProof.tag` has the proof it accepts for that.)
538
+ */
539
+ const CLEARS_FRAMESET_OK = new Set([
540
+ 'select', 'textarea', 'xmp', 'iframe', 'table', 'img', 'image', 'hr', 'li', 'dd', 'dt', 'pre',
541
+ 'listing', 'button', 'br', 'embed', 'wbr', 'area', 'keygen', 'applet', 'marquee', 'object',
542
+ 'template',
543
+ ]);
544
+ /**
545
+ * Start tags that, outside template content, leave a browser in the head: the head's own
546
+ * elements, and `html`, `head` and `frameset`. Every other start tag starts the body.
547
+ */
548
+ const HEAD_START_TAGS = new Set([
549
+ 'html', 'head', 'base', 'basefont', 'bgsound', 'link', 'meta', 'title', 'noscript', 'noframes',
550
+ 'style', 'script', 'template', 'frameset',
551
+ ]);
552
+ /** How far into a run of text the scanner looks for a character that is surely not whitespace. */
553
+ const FRAMESET_PROOF_SPAN = 256;
554
+ /**
555
+ * Whether a browser would ignore a `<frameset>` at this point. Two facts decide it, and each is
556
+ * recorded only on PROOF, so that without both the frameset is taken to be honoured (and
557
+ * nothing after it is indexed):
558
+ *
559
+ * - **the body has started.** Before it, the "after head" rules honour a frameset whatever came
560
+ * first — even text inside a template. Text that is surely not whitespace, a start tag other
561
+ * than the head's own, or `</body>`, `</html>`, `</br>` starts it — outside template content
562
+ * only, since inside one the "in template" rules hold and nothing starts the body.
563
+ * - **the frameset-ok flag is cleared.** Only what the "in body" rules process clears it — and
564
+ * template content IS processed by them — plus a `<template>` start tag, which the "in head"
565
+ * rules clear it on: such text, a start tag in `CLEARS_FRAMESET_OK`, a type-less `<input>`,
566
+ * `</br>`, or `<body>` outside a template.
567
+ *
568
+ * What those rules never see proves neither: raw text, comments, a foreign region. (A select's
569
+ * own content is fed in all the same: its start tag has proved both facts already.) Inside a
570
+ * template a frameset is ignored outright.
571
+ *
572
+ * A third fact runs the other way. An honoured frameset DISCARDS the body — a `title` read in
573
+ * the body, whose text clears nothing, goes with it — so the scanner must drop what it emitted
574
+ * from the point the body MAY have started: `mayHaveBody`, set on anything that might start
575
+ * it (any character not whitespace, `&` and NUL included, or a run too long to look through),
576
+ * where `body` waits for proof.
577
+ */
578
+ class FramesetProof {
579
+ body = false;
580
+ cleared = false;
581
+ /** Whether the body may have started — over-approximated, for what an honoured frameset discards. */
582
+ mayHaveBody = false;
583
+ /** Whether a frameset met here — inside template content or not — is ignored. */
584
+ ignores(inTemplate) {
585
+ return inTemplate || (this.body && this.cleared);
586
+ }
587
+ /** A run of text the HTML rules process as characters. */
588
+ text(s, from, to, inTemplate) {
589
+ const end = Math.min(to, from + FRAMESET_PROOF_SPAN);
590
+ if (!inTemplate && !this.mayHaveBody) {
591
+ if (to > end)
592
+ this.mayHaveBody = true;
593
+ for (let k = from; k < end && !this.mayHaveBody; k += 1) {
594
+ if (!isHtmlSpace(s.charCodeAt(k)))
595
+ this.mayHaveBody = true;
596
+ }
597
+ }
598
+ if (this.body && this.cleared)
599
+ return;
600
+ for (let k = from; k < end; k += 1) {
601
+ const c = s.charCodeAt(k);
602
+ if (isHtmlSpace(c))
603
+ continue;
604
+ if (c === 0) {
605
+ if (!inTemplate)
606
+ this.body = true; // a NUL starts the body, and clears nothing
607
+ continue;
608
+ }
609
+ // An `&` proves nothing: a character reference may decode to whitespace.
610
+ if (c !== 38)
611
+ this.character(inTemplate);
612
+ return;
613
+ }
614
+ }
615
+ /** A character surely not whitespace, processed by the HTML rules. */
616
+ character(inTemplate) {
617
+ this.cleared = true;
618
+ if (!inTemplate)
619
+ this.startBody();
620
+ }
621
+ startBody() {
622
+ this.body = true;
623
+ this.mayHaveBody = true;
624
+ }
625
+ /**
626
+ * A start or end tag the HTML rules process (`name` lower-cased; '' for a long one), with
627
+ * the text of its attributes when that is short.
628
+ */
629
+ tag(name, closing, inTemplate, attributes) {
630
+ if (closing) {
631
+ // In template content every end tag but `</template>` is ignored.
632
+ if (inTemplate)
633
+ return;
634
+ if (name === 'br')
635
+ this.cleared = true; // read as a `<br>`
636
+ if (name === 'br' || name === 'body' || name === 'html')
637
+ this.startBody();
638
+ return;
639
+ }
640
+ if (CLEARS_FRAMESET_OK.has(name) || (name === 'body' && !inTemplate))
641
+ this.cleared = true;
642
+ // An `input` clears it unless `type` is `hidden`: proof is one that names no `type` at all.
643
+ if (name === 'input' && attributes !== null && !/type/i.test(attributes))
644
+ this.cleared = true;
645
+ if (!inTemplate && !HEAD_START_TAGS.has(name))
646
+ this.startBody();
647
+ }
648
+ }
649
+ /** In a select inside a table, the end tags that close the select early. */
650
+ const SELECT_TABLE_ENDS = new Set(['caption', 'table', 'tbody', 'tfoot', 'thead', 'tr', 'td', 'th']);
651
+ /** The longest foreign element name followed; a longer one makes the region uncertain. */
652
+ const FOREIGN_NAME_MAX = 64;
653
+ /** A tag name in foreign content, ASCII lower-cased — '' when longer than any followed. */
654
+ function foreignName(s, at) {
655
+ let k = at;
656
+ while (k < s.length && k - at <= FOREIGN_NAME_MAX && !endsTagName(s.charCodeAt(k)))
657
+ k += 1;
658
+ if (k - at > FOREIGN_NAME_MAX)
659
+ return '';
660
+ return s.slice(at, k).replace(/[A-Z]/g, (c) => String.fromCharCode(c.charCodeAt(0) + 32));
661
+ }
662
+ /**
663
+ * Where a foreign-content region — an `<svg>` or `<math>` and everything in it — ends: the index
664
+ * just past the `>` of the end tag that closes its root, or -1 when the scanner cannot be SURE
665
+ * that is where a browser ends it. The caller indexes nothing in the region, and after a -1,
666
+ * nothing more of the file.
667
+ *
668
+ * Inside foreign content a browser tokenizes differently — no raw text, real CDATA sections, no
669
+ * template contents — and its tree builder can leave the region early (a breakout tag, an end
670
+ * tag only HTML rules match) or late (an end tag ignored inside HTML content an integration point
671
+ * holds). So the region is followed only while every token in it is one whose effect is certain:
672
+ * text, comments, CDATA, foreign start tags (self-closing honoured), and end tags that close an
673
+ * element open in the region. A breakout tag, a start tag inside an integration point, or an
674
+ * end tag matching nothing open is the browser leaving the rules followed here — and -1.
675
+ */
676
+ async function foreignEnd(s, from, root, pace) {
677
+ const open = [root];
678
+ const flags = { selfClosing: false };
679
+ for (let i = from;;) {
680
+ const lt = await pace.find(s, '<', i);
681
+ if (lt < 0)
682
+ return -1;
683
+ const next = s.charCodeAt(lt + 1);
684
+ if (next === 33 && s.startsWith('--', lt + 2)) {
685
+ const end = await commentEnd(s, lt, pace);
686
+ if (end < 0)
687
+ return -1;
688
+ i = end + 1;
689
+ continue;
690
+ }
691
+ if (s.startsWith('<![CDATA[', lt)) {
692
+ const end = await pace.find(s, ']]>', lt + 9); // a real CDATA section, in foreign content
693
+ if (end < 0)
694
+ return -1;
695
+ i = end + 3;
696
+ continue;
697
+ }
698
+ if (next === 33 || next === 63 || (next === 47 && !isAsciiAlpha(s.charCodeAt(lt + 2)))) {
699
+ const end = await pace.find(s, '>', lt + 2);
700
+ if (end < 0)
701
+ return -1;
702
+ i = end + 1;
703
+ continue;
704
+ }
705
+ if (!isAsciiAlpha(next) && next !== 47) {
706
+ i = lt + 1;
707
+ continue;
708
+ }
709
+ const closing = next === 47;
710
+ const nameAt = closing ? lt + 2 : lt + 1;
711
+ const name = foreignName(s, nameAt);
712
+ if (name === '')
713
+ return -1;
714
+ if (closing ? name === 'br' || name === 'p' : FOREIGN_BREAKOUT.has(name) || INTEGRATION_POINTS.has(open[open.length - 1])) {
715
+ return -1;
716
+ }
717
+ flags.selfClosing = false;
718
+ const gt = await htmlTagEnd(s, nameAt, pace, flags);
719
+ if (gt < 0)
720
+ return -1;
721
+ i = gt + 1;
722
+ if (!closing) {
723
+ if (!flags.selfClosing)
724
+ open.push(name);
725
+ continue;
726
+ }
727
+ // An end tag pops to the nearest open element of its name; one that matches nothing open
728
+ // falls through to the HTML rules, which this scanner does not follow from here.
729
+ const at = open.lastIndexOf(name);
730
+ if (at < 0)
731
+ return -1;
732
+ open.length = at;
733
+ if (at === 0)
734
+ return i;
735
+ }
736
+ }
737
+ /**
738
+ * Raw text (`xmp`, `plaintext`) is shown as written, so its `&` must survive the entity pass:
739
+ * escaped here, a window at a time, and turned back into itself there.
740
+ */
741
+ async function asWritten(text, pace) {
742
+ const out = [];
743
+ for (let at = 0; at < text.length;) {
744
+ await pace.turn();
745
+ const end = Math.min(text.length, at + pace.room);
746
+ out.push(text.slice(at, end).replaceAll('&', '&amp;'));
747
+ pace.charge(end - at);
748
+ at = end;
749
+ }
750
+ return out.join('');
751
+ }
752
+ /**
753
+ * The text a reader of the page would see — in ONE linear pass.
754
+ *
755
+ * A scanner rather than regular expressions, deliberately. `<[^>]*>` and `<!--[\s\S]*?-->` look
756
+ * linear and are not: on a file of `<` with no `>` after it, or of `<!--` with no `-->`, every
757
+ * opener rescans to the end, and an 8 MiB page of them is quadratic work no time budget can
758
+ * cut short in time. Here every index moves forward only, so the work is bounded by the
759
+ * input — and every search in it is paced (`Pace`), so a pass this long checks the kernel's
760
+ * signal as it goes, inside one long comment or tag as much as between short ones.
761
+ *
762
+ * Every construct ends where the HTML tokenizer ends it: a tag at a `>` outside a quoted
763
+ * value, a comment at `-->` / `--!>` (or the abrupt `<!-->`), `script` by its escape rules,
764
+ * and every element whose content the tokenizer reads as text — RAWTEXT, RCDATA, `plaintext` —
765
+ * only at a COMPLETE end tag of its own name, nothing inside it acting as markup. Comments,
766
+ * `script`, the hidden raw-text elements and everything inside `template` are dropped;
767
+ * `title`, `textarea`, `xmp` and `plaintext` content is text, shown unless a template holds
768
+ * it. Anything left UNCLOSED runs to the end of the file: a browser treats everything after
769
+ * an unclosed `<script>` as script, and a page cut off mid-script (the prefix decode makes
770
+ * that ordinary) must not have its source indexed as prose. So does a tag cut off before its
771
+ * `>`. `test/html-oracle.test.ts` holds this against parse5, a browser-grade parser.
772
+ *
773
+ * **The contract: never index text the HTML parser keeps out of the rendered document's text
774
+ * nodes; index less where unsure.** Some contexts change how a browser parses in ways this
775
+ * scanner does not follow, and there it indexes NOTHING rather than guess:
776
+ *
777
+ * - **`select`** — the tree builder ignores almost every start tag inside one, so a `title` or
778
+ * `style` there switches nothing: only `script` and `template` change how it tokenizes. The
779
+ * scanner follows exactly that, indexes nothing, and stops at the `</select>` a browser
780
+ * would; a tag at which a browser leaves the select early (`input`, `textarea`, a table
781
+ * tag, a nested `select`, …) ends indexing for the rest of the file.
782
+ * - **inline `svg` and `math`** — foreign content: no raw text, real CDATA, its own way out
783
+ * (`foreignEnd`). Nothing in the region is indexed, and if the scanner cannot be sure where a
784
+ * browser ends the region, nothing after it either.
785
+ * - **`frameset`** — a honoured one leaves no body to show, so nothing after it is indexed. A
786
+ * browser ignores one only inside a template, or once the body has started AND its
787
+ * frameset-ok flag is cleared; the scanner reads on past one only with proof of that
788
+ * (`FramesetProof`).
789
+ * - any of these opened inside a `select` — nothing more of the file.
790
+ *
791
+ * So those contexts may be under-indexed, and their text after them too; text the parser keeps
792
+ * out of the rendered document is never indexed. The oracle holds both halves: over-indexing
793
+ * fails anywhere, and under-indexing is allowed only from where parse5 opens such a context.
794
+ *
795
+ * The scanner follows the PARSER, not the renderer: it does not evaluate the `hidden`
796
+ * attribute, CSS (inline or from a stylesheet it cannot see) or interactive state such as a
797
+ * closed `<details>`, so text those hide is in the document's text nodes and is indexed. That
798
+ * is never more than a searcher can read: attachment search is gated by the same read
799
+ * permission as opening the file, which holds that text anyway.
800
+ */
801
+ async function htmlText(html, pace) {
802
+ const out = [];
803
+ const n = html.length;
804
+ // Open `template` elements: their content is parsed as usual and shown nowhere.
805
+ let hidden = 0;
806
+ // The template depth a `select` was opened at, or -1: nothing is indexed inside one.
807
+ let selectAt = -1;
808
+ const shown = () => hidden === 0 && selectAt < 0;
809
+ const text = (from, to) => {
810
+ if (shown() && to > from)
811
+ out.push(html.slice(from, to));
812
+ };
813
+ const flags = { selfClosing: false };
814
+ const frameset = new FramesetProof();
815
+ // Where in `out` the body may have started: an honoured frameset discards everything from here.
816
+ let bodyFrom = -1;
817
+ const markBody = () => {
818
+ if (bodyFrom < 0 && frameset.mayHaveBody)
819
+ bodyFrom = out.length;
820
+ };
821
+ let i = 0;
822
+ while (i < n) {
823
+ const lt = await pace.find(html, '<', i);
824
+ if (lt < 0) {
825
+ text(i, n);
826
+ break;
827
+ }
828
+ frameset.text(html, i, lt, hidden > 0);
829
+ markBody();
830
+ text(i, lt);
831
+ const next = html.charCodeAt(lt + 1);
832
+ if (next === 33 && html.startsWith('--', lt + 2)) {
833
+ const end = await commentEnd(html, lt, pace);
834
+ if (end < 0)
835
+ break;
836
+ out.push(' ');
837
+ i = end + 1;
838
+ continue;
839
+ }
840
+ if (next === 33 || next === 63 || (next === 47 && !isAsciiAlpha(html.charCodeAt(lt + 2)))) {
841
+ // `<!DOCTYPE …>`, `<?…>`, `</ …>`: a bogus comment, to the first `>`.
842
+ const end = await pace.find(html, '>', lt + 2);
843
+ if (end < 0)
844
+ break;
845
+ i = end + 1;
846
+ continue;
847
+ }
848
+ if (!isAsciiAlpha(next) && next !== 47) {
849
+ frameset.character(hidden > 0);
850
+ markBody();
851
+ text(lt, lt + 1); // `<` before anything else is text, as a browser reads it
852
+ i = lt + 1;
853
+ continue;
854
+ }
855
+ const closing = next === 47;
856
+ const nameAt = closing ? lt + 2 : lt + 1;
857
+ const name = shortTagName(html, nameAt);
858
+ flags.selfClosing = false;
859
+ const gt = await htmlTagEnd(html, nameAt, pace, flags);
860
+ if (gt < 0)
861
+ break;
862
+ i = gt + 1;
863
+ frameset.tag(name, closing, hidden > 0, gt - nameAt <= FRAMESET_PROOF_SPAN ? html.slice(nameAt, gt) : null);
864
+ markBody();
865
+ if (selectAt >= 0 && hidden === selectAt) {
866
+ // In the select itself: every tag but these is ignored by a browser and switches nothing.
867
+ if (closing && name === 'select') {
868
+ selectAt = -1;
869
+ continue;
870
+ }
871
+ if (closing && name === 'template') {
872
+ // Closing the template the select sits in closes the select with it; with no template
873
+ // open, a browser ignores it.
874
+ if (hidden > 0) {
875
+ hidden -= 1;
876
+ selectAt = -1;
877
+ }
878
+ continue;
879
+ }
880
+ if (closing ? SELECT_TABLE_ENDS.has(name) : SELECT_LEAVES.has(name))
881
+ break;
882
+ if (!closing && name === 'template') {
883
+ hidden += 1; // a template's content is parsed by the HTML rules again
884
+ }
885
+ else if (!closing && name === 'script') {
886
+ const close = await scriptEnd(html, i, pace);
887
+ const end = close < 0 ? -1 : await htmlTagEnd(html, close + 2, pace);
888
+ if (end < 0)
889
+ break;
890
+ i = end + 1;
891
+ }
892
+ continue;
893
+ }
894
+ if (name === 'select') {
895
+ // One opened inside a select's template is a select within a select: unsure from here.
896
+ if (!closing && selectAt >= 0)
897
+ break;
898
+ if (!closing)
899
+ selectAt = hidden;
900
+ continue;
901
+ }
902
+ if (name === 'template') {
903
+ hidden = closing ? Math.max(0, hidden - 1) : hidden + 1;
904
+ continue;
905
+ }
906
+ if (!closing && (name === 'svg' || name === 'math' || name === 'frameset')) {
907
+ // Contexts the scanner does not model (see above): nothing inside is indexed — and where
908
+ // it cannot be sure a browser has left one, nothing more of the file.
909
+ if (selectAt >= 0)
910
+ break;
911
+ if (name === 'frameset') {
912
+ if (frameset.ignores(hidden > 0))
913
+ continue; // a browser ignores it
914
+ if (bodyFrom >= 0)
915
+ out.length = bodyFrom; // honoured: the body goes, and what was read in it
916
+ break;
917
+ }
918
+ if (flags.selfClosing)
919
+ continue; // `<svg/>` opens and closes at once
920
+ const end = await foreignEnd(html, i, name, pace);
921
+ if (end < 0)
922
+ break;
923
+ i = end;
924
+ continue;
925
+ }
926
+ if (!closing && name === 'plaintext') {
927
+ // Everything after it is text, shown as written: there is no end tag to look for.
928
+ if (shown())
929
+ out.push('\n', await asWritten(html.slice(i), pace));
930
+ break;
931
+ }
932
+ const shownRcdata = HTML_SHOWN_RCDATA.has(name);
933
+ const shownRaw = HTML_SHOWN_RAWTEXT.has(name);
934
+ if (!closing && (name === 'script' || HTML_HIDDEN_RAW.has(name) || shownRcdata || shownRaw)) {
935
+ // Read to the end tag as ONE unit: nothing inside — a `</template>`, a `<!--` — is markup.
936
+ const close = name === 'script' ? await scriptEnd(html, i, pace) : await rawTextEnd(html, i, name, pace);
937
+ const to = close < 0 ? n : close;
938
+ if (shown()) {
939
+ const edge = HTML_BLOCK.has(name) ? '\n' : ' ';
940
+ out.push(edge);
941
+ if (shownRcdata)
942
+ out.push(html.slice(i, to)); // RCDATA: decoded with the rest
943
+ else if (shownRaw)
944
+ out.push(await asWritten(html.slice(i, to), pace));
945
+ out.push(edge);
946
+ }
947
+ const end = close < 0 ? -1 : await htmlTagEnd(html, close + 2, pace);
948
+ if (end < 0)
949
+ break;
950
+ i = end + 1;
951
+ continue;
952
+ }
953
+ if (!shown())
954
+ continue;
955
+ if (HTML_BLOCK.has(name))
956
+ out.push('\n');
957
+ else if (HTML_CELL.has(name))
958
+ out.push(' ');
959
+ }
960
+ return decodeEntities(out.join(''), NAMED_ENTITIES, pace);
961
+ }
962
+ /** More than any office file carries; past it the archive is something else. */
963
+ const MAX_ZIP_ENTRIES = 20_000;
964
+ /**
965
+ * The longest entry name kept. Every part this package reads has a short fixed name, so a
966
+ * longer one is never one of them — and capping it bounds the name tests over the directory.
967
+ */
968
+ const MAX_ENTRY_NAME = 255;
969
+ /**
970
+ * The central directory, read from the End Of Central Directory record backwards.
971
+ *
972
+ * ZIP64 is refused rather than half-read: an OOXML file needs it only past 4 GiB, which
973
+ * is far beyond `maxInputBytes`, so meeting one means the file is not what it says.
974
+ */
975
+ async function zipEntries(zip, pace) {
976
+ const view = new DataView(zip.buffer, zip.byteOffset, zip.byteLength);
977
+ const minEocd = 22;
978
+ if (zip.length < minEocd)
979
+ throw new MalformedInput('not a zip archive');
980
+ // The EOCD sits in the last 22 bytes plus at most a 65 535-byte comment.
981
+ let eocd = -1;
982
+ for (let i = zip.length - minEocd; i >= Math.max(0, zip.length - minEocd - 0xffff); i -= 1) {
983
+ if (view.getUint32(i, true) === 0x06054b50) {
984
+ eocd = i;
985
+ break;
986
+ }
987
+ }
988
+ if (eocd < 0)
989
+ throw new MalformedInput('not a zip archive');
990
+ const count = view.getUint16(eocd + 10, true);
991
+ const cdSize = view.getUint32(eocd + 12, true);
992
+ const cdOffset = view.getUint32(eocd + 16, true);
993
+ if (count === 0xffff || cdSize === 0xffffffff || cdOffset === 0xffffffff) {
994
+ throw new MalformedInput('zip64 archives are not read');
995
+ }
996
+ if (count > MAX_ZIP_ENTRIES)
997
+ throw new ExtractionBoundExceeded(`the archive lists more than ${MAX_ZIP_ENTRIES} entries`);
998
+ if (cdOffset + cdSize > zip.length)
999
+ throw new MalformedInput('the zip central directory is out of range');
1000
+ const names = new TextDecoder('utf-8');
1001
+ const entries = [];
1002
+ let p = cdOffset;
1003
+ for (let n = 0; n < count; n += 1) {
1004
+ await pace.turn();
1005
+ if (p + 46 > zip.length || view.getUint32(p, true) !== 0x02014b50) {
1006
+ throw new MalformedInput('the zip central directory is damaged');
1007
+ }
1008
+ const nameLength = view.getUint16(p + 28, true);
1009
+ const extraLength = view.getUint16(p + 30, true);
1010
+ const commentLength = view.getUint16(p + 32, true);
1011
+ if (p + 46 + nameLength > zip.length)
1012
+ throw new MalformedInput('the zip central directory is damaged');
1013
+ entries.push({
1014
+ name: nameLength > MAX_ENTRY_NAME ? '' : names.decode(zip.subarray(p + 46, p + 46 + nameLength)),
1015
+ flags: view.getUint16(p + 8, true),
1016
+ method: view.getUint16(p + 10, true),
1017
+ compressedSize: view.getUint32(p + 20, true),
1018
+ uncompressedSize: view.getUint32(p + 24, true),
1019
+ localHeaderOffset: view.getUint32(p + 42, true),
1020
+ });
1021
+ const span = 46 + nameLength + extraLength + commentLength;
1022
+ pace.charge(span);
1023
+ p += span;
1024
+ }
1025
+ return entries;
1026
+ }
1027
+ /**
1028
+ * One entry's text: inflated under the budget, and decoded as UTF-8 as the inflater produces
1029
+ * it — a window at a time, so neither step holds the thread for more than a stride.
1030
+ *
1031
+ * The write is NOT awaited before reading: a `DecompressionStream` applies backpressure,
1032
+ * so awaiting the write of a chunk larger than its queue would wait for a reader that has
1033
+ * not started. The read loop counts what the inflater actually produces and cancels the
1034
+ * stream the moment the budget is spent.
1035
+ */
1036
+ async function readEntryText(zip, entry, budget, pace) {
1037
+ if (entry.flags & 0x1)
1038
+ throw new MalformedInput('the archive is encrypted');
1039
+ const view = new DataView(zip.buffer, zip.byteOffset, zip.byteLength);
1040
+ const at = entry.localHeaderOffset;
1041
+ if (at + 30 > zip.length || view.getUint32(at, true) !== 0x04034b50) {
1042
+ throw new MalformedInput('a zip entry header is damaged');
1043
+ }
1044
+ const start = at + 30 + view.getUint16(at + 26, true) + view.getUint16(at + 28, true);
1045
+ const end = start + entry.compressedSize;
1046
+ if (end > zip.length)
1047
+ throw new MalformedInput('a zip entry is out of range');
1048
+ const raw = zip.subarray(start, end);
1049
+ // The declared size is a cheap early refusal and nothing more — the counter below is the
1050
+ // guard, because a bomb is precisely an entry whose header lies.
1051
+ if (entry.uncompressedSize > budget.remaining) {
1052
+ throw new ExtractionBoundExceeded('the archive inflates past the extraction bound');
1053
+ }
1054
+ const decoder = new TextDecoder('utf-8');
1055
+ const text = [];
1056
+ if (entry.method === 0) {
1057
+ budget.remaining -= raw.length;
1058
+ if (budget.remaining < 0)
1059
+ throw new ExtractionBoundExceeded('the archive inflates past the extraction bound');
1060
+ await pace.decode(decoder, raw, text);
1061
+ text.push(decoder.decode());
1062
+ return text.join('');
1063
+ }
1064
+ if (entry.method !== 8)
1065
+ throw new MalformedInput('a zip entry uses an unsupported compression method');
1066
+ const ds = new DecompressionStream('deflate-raw');
1067
+ const writer = ds.writable.getWriter();
1068
+ const writing = writer.write(raw).then(() => writer.close()).catch(() => { });
1069
+ const reader = ds.readable.getReader();
1070
+ let total = 0;
1071
+ try {
1072
+ for (;;) {
1073
+ let next;
1074
+ try {
1075
+ next = await reader.read();
1076
+ }
1077
+ catch {
1078
+ throw new MalformedInput('a zip entry is not valid deflate data');
1079
+ }
1080
+ if (next.done)
1081
+ break;
1082
+ const chunk = next.value;
1083
+ total += chunk.length;
1084
+ if (total > budget.remaining) {
1085
+ await reader.cancel().catch(() => { });
1086
+ throw new ExtractionBoundExceeded('the archive inflates past the extraction bound');
1087
+ }
1088
+ try {
1089
+ await pace.decode(decoder, chunk, text);
1090
+ }
1091
+ catch (err) {
1092
+ await reader.cancel().catch(() => { });
1093
+ throw err;
1094
+ }
1095
+ }
1096
+ }
1097
+ finally {
1098
+ await writing;
1099
+ }
1100
+ budget.remaining -= total;
1101
+ text.push(decoder.decode());
1102
+ return text.join('');
1103
+ }
1104
+ // -- OOXML ---------------------------------------------------------------------------
1105
+ /** The index of the first `>` at or after `from` that is outside a quoted value, or -1. */
1106
+ function xmlTagEnd(s, from, pace) {
1107
+ let quote = 0;
1108
+ return pace.scan(s, from, (c) => {
1109
+ if (quote !== 0) {
1110
+ if (c === quote)
1111
+ quote = 0;
1112
+ return false;
1113
+ }
1114
+ if (c === 34 || c === 39) {
1115
+ quote = c;
1116
+ return false;
1117
+ }
1118
+ return c === 62;
1119
+ });
1120
+ }
1121
+ /** Whitespace, `/` or `>` ends an XML name. */
1122
+ const endsName = (c) => c === 32 || c === 9 || c === 10 || c === 13 || c === 47 || c === 62;
1123
+ /** Longer than any qualified name the OOXML scan acts on (`w:t`, `a:rPh`); a longer one is just a tag. */
1124
+ const XML_NAME_MAX = 64;
1125
+ /**
1126
+ * Text out of an OOXML part: the content of every element whose LOCAL name is `t`.
1127
+ *
1128
+ * Matched by local name rather than prefix, because the prefix is the document's choice:
1129
+ * Word writes `w:t`, its text boxes `a:t`, its equations `m:t`, a spreadsheet's shared
1130
+ * strings a bare `t` — all of them text a person typed. `rPh` (phonetic guides in a
1131
+ * spreadsheet) is skipped whole, since its `t` repeats the cell it annotates. Paragraph-
1132
+ * like ends become newlines, so phrases do not run together across paragraphs.
1133
+ *
1134
+ * ONE linear pass, for the reason `htmlText` gives: a tokenizer regex with lazy `[\s\S]*?`
1135
+ * or an alternation over quoted values rescans to the end from every unclosed `<![CDATA[`,
1136
+ * `<!--` or quote, which a hostile part can repeat until the work is quadratic. Here every
1137
+ * index moves forward only, every search is paced (`Pace`), and content after a construct
1138
+ * that never closes is not text.
1139
+ */
1140
+ async function ooxmlText(xml, pace) {
1141
+ const out = [];
1142
+ const BREAK_AFTER = new Set(['p', 'si', 'row', 'tr']);
1143
+ const BREAK = new Set(['br', 'cr']);
1144
+ let inText = 0;
1145
+ let skip = 0;
1146
+ const inside = () => inText > 0 && skip === 0;
1147
+ const n = xml.length;
1148
+ let i = 0;
1149
+ while (i < n) {
1150
+ const lt = await pace.find(xml, '<', i);
1151
+ if (lt < 0) {
1152
+ if (inside())
1153
+ out.push(await decodeEntities(xml.slice(i), XML_ENTITIES, pace));
1154
+ break;
1155
+ }
1156
+ if (lt > i && inside())
1157
+ out.push(await decodeEntities(xml.slice(i, lt), XML_ENTITIES, pace));
1158
+ if (xml.startsWith('<![CDATA[', lt)) {
1159
+ const end = await pace.find(xml, ']]>', lt + 9);
1160
+ if (end < 0)
1161
+ break;
1162
+ if (inside())
1163
+ out.push(xml.slice(lt + 9, end));
1164
+ i = end + 3;
1165
+ continue;
1166
+ }
1167
+ if (xml.startsWith('<!--', lt)) {
1168
+ const end = await pace.find(xml, '-->', lt + 4);
1169
+ if (end < 0)
1170
+ break;
1171
+ i = end + 3;
1172
+ continue;
1173
+ }
1174
+ if (xml.startsWith('<?', lt)) {
1175
+ const end = await pace.find(xml, '?>', lt + 2);
1176
+ if (end < 0)
1177
+ break;
1178
+ i = end + 2;
1179
+ continue;
1180
+ }
1181
+ const gt = await xmlTagEnd(xml, lt + 1, pace);
1182
+ if (gt < 0)
1183
+ break;
1184
+ i = gt + 1;
1185
+ if (xml[lt + 1] === '!')
1186
+ continue; // a declaration
1187
+ const closing = xml[lt + 1] === '/';
1188
+ const selfClosing = xml[gt - 1] === '/';
1189
+ const nameStart = closing ? lt + 2 : lt + 1;
1190
+ const nameLimit = Math.min(gt, nameStart + XML_NAME_MAX + 1);
1191
+ let nameEnd = nameStart;
1192
+ while (nameEnd < nameLimit && !endsName(xml.charCodeAt(nameEnd)))
1193
+ nameEnd += 1;
1194
+ if (nameEnd - nameStart > XML_NAME_MAX)
1195
+ continue;
1196
+ const qname = xml.slice(nameStart, nameEnd);
1197
+ const local = qname.slice(qname.indexOf(':') + 1);
1198
+ if (local === 'rPh') {
1199
+ if (!selfClosing)
1200
+ skip += closing ? -1 : 1;
1201
+ continue;
1202
+ }
1203
+ if (local === 't') {
1204
+ if (!selfClosing)
1205
+ inText += closing ? -1 : 1;
1206
+ continue;
1207
+ }
1208
+ if (local === 'tab' && selfClosing)
1209
+ out.push(' ');
1210
+ else if (BREAK.has(local) && (selfClosing || !closing))
1211
+ out.push('\n');
1212
+ else if (BREAK_AFTER.has(local) && (closing || selfClosing))
1213
+ out.push('\n');
1214
+ }
1215
+ return out.join('');
1216
+ }
1217
+ /** The parts of each format that carry text, in reading order — body first, so a cut keeps it. */
1218
+ function ooxmlParts(extractor, entries) {
1219
+ const numbered = (re) => entries
1220
+ .map((e) => ({ e, n: re.exec(e.name) }))
1221
+ .filter((x) => x.n !== null)
1222
+ .sort((a, b) => Number(a.n[1]) - Number(b.n[1]))
1223
+ .map((x) => x.e);
1224
+ const named = (name) => entries.filter((e) => e.name === name);
1225
+ switch (extractor) {
1226
+ case 'docx':
1227
+ return [
1228
+ ...named('word/document.xml'),
1229
+ ...named('word/footnotes.xml'),
1230
+ ...named('word/endnotes.xml'),
1231
+ ...numbered(/^word\/header(\d+)\.xml$/),
1232
+ ...numbered(/^word\/footer(\d+)\.xml$/),
1233
+ ];
1234
+ case 'xlsx':
1235
+ return [...named('xl/sharedStrings.xml'), ...numbered(/^xl\/worksheets\/sheet(\d+)\.xml$/)];
1236
+ case 'pptx':
1237
+ return [...numbered(/^ppt\/slides\/slide(\d+)\.xml$/), ...numbered(/^ppt\/notesSlides\/notesSlide(\d+)\.xml$/)];
1238
+ }
1239
+ }
1240
+ /** The part every file of the format has — its absence means the file is not that format. */
1241
+ const REQUIRED_PART = {
1242
+ docx: /^word\/document\.xml$/,
1243
+ xlsx: /^xl\/workbook\.xml$/,
1244
+ pptx: /^ppt\/presentation\.xml$/,
1245
+ };
1246
+ async function ooxmlExtract(format, zip, bounds, maxTextBytes, signal) {
1247
+ const pace = new Pace(signal);
1248
+ const entries = await zipEntries(zip, pace);
1249
+ if (!entries.some((e) => REQUIRED_PART[format].test(e.name))) {
1250
+ throw new MalformedInput(`the archive is not a ${format} file`);
1251
+ }
1252
+ const budget = { remaining: bounds.maxInflatedBytes };
1253
+ const pieces = [];
1254
+ // Stop reading parts once the collected text is safely past the kernel's output cap: the
1255
+ // rest could only be cut off again, and inflating it would spend the budget for nothing.
1256
+ // Twice the cap, because the kernel's normalizing can still shrink what was collected.
1257
+ let collected = 0;
1258
+ let stoppedEarly = false;
1259
+ for (const entry of ooxmlParts(format, entries)) {
1260
+ if (collected > maxTextBytes * 2) {
1261
+ stoppedEarly = true;
1262
+ break;
1263
+ }
1264
+ await checkpoint(signal);
1265
+ const text = await ooxmlText(await readEntryText(zip, entry, budget, pace), pace);
1266
+ pieces.push(text, '\n');
1267
+ collected += text.length;
1268
+ }
1269
+ return { text: pieces.join(''), truncated: stoppedEarly };
1270
+ }
1271
+ /**
1272
+ * One extractor's `extract`: the input bound checked again (the kernel judged the recorded
1273
+ * size; this judges the bytes it was actually handed), and everything the FILE did turned
1274
+ * into a `failed` answer. A throw from anywhere else — an inflater the runtime lacks — is
1275
+ * left to the kernel, which records it as a failed extraction.
1276
+ */
1277
+ function extractWith(bounds, read) {
1278
+ return async (input) => {
1279
+ if (input.body.length > bounds.maxInputBytes) {
1280
+ return { failed: `the file is ${input.body.length} bytes, over the ${bounds.maxInputBytes}-byte input bound` };
1281
+ }
1282
+ try {
1283
+ return await read(input);
1284
+ }
1285
+ catch (err) {
1286
+ if (err instanceof MalformedInput || err instanceof ExtractionBoundExceeded || err instanceof ExtractionAborted) {
1287
+ return { failed: err.message };
1288
+ }
1289
+ throw err;
1290
+ }
1291
+ };
1292
+ }
1293
+ /**
1294
+ * Text and HTML decode only a PREFIX: the kernel keeps at most `maxTextBytes`, and markup or
1295
+ * padding shrinks by a few times. Decoding a 30 MB log to keep its first half-megabyte would
1296
+ * hold the whole file as a string for nothing.
1297
+ */
1298
+ async function decodedPrefix(input, scanFactor, toText) {
1299
+ const scan = input.maxTextBytes * scanFactor;
1300
+ const pace = new Pace(input.signal);
1301
+ const decoded = await decodeText(input.body.subarray(0, scan), parseContentType(input.contentType).charset, pace);
1302
+ return { text: await toText(decoded, pace), truncated: input.body.length > scan };
1303
+ }
1304
+ /** `text/*` other than HTML — and `.txt`, `.md`, `.csv`, `.tsv` when the type says nothing. */
1305
+ export function textExtractor(bounds = DEFAULT_EXTRACTOR_BOUNDS) {
1306
+ return {
1307
+ name: 'text',
1308
+ maxInputBytes: bounds.maxInputBytes,
1309
+ accepts: acceptsBy((type) => type.startsWith('text/') && type !== 'text/html', ['txt', 'md', 'markdown', 'csv', 'tsv']),
1310
+ extract: extractWith(bounds, (input) => decodedPrefix(input, 4, (decoded) => decoded)),
1311
+ };
1312
+ }
1313
+ /** HTML and XHTML — the text a reader of the page would see. */
1314
+ export function htmlExtractor(bounds = DEFAULT_EXTRACTOR_BOUNDS) {
1315
+ return {
1316
+ name: 'html',
1317
+ maxInputBytes: bounds.maxInputBytes,
1318
+ accepts: acceptsBy((type) => type === 'text/html' || type === 'application/xhtml+xml', ['html', 'htm']),
1319
+ extract: extractWith(bounds, (input) => decodedPrefix(input, 16, htmlText)),
1320
+ };
1321
+ }
1322
+ function ooxmlExtractor(format, mediaType, bounds) {
1323
+ return {
1324
+ name: format,
1325
+ maxInputBytes: bounds.maxInputBytes,
1326
+ accepts: acceptsBy((type) => type === mediaType, [format]),
1327
+ extract: extractWith(bounds, (input) => ooxmlExtract(format, input.body, bounds, input.maxTextBytes, input.signal)),
1328
+ };
1329
+ }
1330
+ /** Word documents: the body, then footnotes, endnotes, headers and footers. */
1331
+ export const docxExtractor = (bounds = DEFAULT_EXTRACTOR_BOUNDS) => ooxmlExtractor('docx', DOCX, bounds);
1332
+ /** Spreadsheets: shared strings and inline strings — never a cell's number. */
1333
+ export const xlsxExtractor = (bounds = DEFAULT_EXTRACTOR_BOUNDS) => ooxmlExtractor('xlsx', XLSX, bounds);
1334
+ /** Presentations: slides in order, then their speaker notes. */
1335
+ export const pptxExtractor = (bounds = DEFAULT_EXTRACTOR_BOUNDS) => ooxmlExtractor('pptx', PPTX, bounds);
1336
+ /**
1337
+ * Every extractor this package has, in the order a host should try them — what a host is
1338
+ * constructed with when it has no list of its own:
1339
+ *
1340
+ * new SqliteScopeHost({ dir, attachmentExtractors: defaultAttachmentExtractors() })
1341
+ */
1342
+ export function defaultAttachmentExtractors(bounds = DEFAULT_EXTRACTOR_BOUNDS) {
1343
+ return [textExtractor(bounds), htmlExtractor(bounds), docxExtractor(bounds), xlsxExtractor(bounds), pptxExtractor(bounds)];
1344
+ }
1345
+ //# sourceMappingURL=index.js.map