@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/README.md +17 -3
- package/dist/index.d.ts +98 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +1345 -0
- package/dist/index.js.map +1 -0
- package/package.json +12 -11
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 `` 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
|
+
* `&` `ä` `ä` → 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('&', '&'));
|
|
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
|