single-file-core 1.5.119 → 1.5.120

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.
@@ -27,6 +27,7 @@ import {
27
27
  BlobReader,
28
28
  TextReader,
29
29
  ZipWriter,
30
+ Uint8ArrayReader,
30
31
  Uint8ArrayWriter
31
32
  } from "./../../vendor/zip/zip.js";
32
33
  import {
@@ -41,29 +42,12 @@ import {
41
42
 
42
43
  const { Blob, fetch, TextEncoder, TextDecoder, DOMParser } = globalThis;
43
44
 
44
- // windows-1252 never decodes bytes >= 0x80 into the ASCII range, the scanned patterns are all ASCII
45
45
  const TEXT_DECODER = new TextDecoder("windows-1252");
46
46
 
47
- // the extension is only a guess when it comes from the URL, and a wrong one costs the whole
48
- // gain: a script served as text/javascript from a ".ts" URL was stored uncompressed at 2260
49
- // bytes where the same bytes named ".js" deflate to 70. A textual content type is authoritative
50
- // when the server sent one, the extension list decides everything else
51
47
  const COMPRESSIBLE_CONTENT_TYPES = ["application/javascript", "application/x-javascript", "application/ecmascript", "application/json", "application/ld+json", "application/manifest+json", "application/xml", "application/xhtml+xml", "application/rss+xml", "application/atom+xml", "image/svg+xml"];
52
48
  const TEXT_CONTENT_TYPE_PREFIX = "text/";
53
49
  const NO_COMPRESSION_EXTENSIONS = [".jpg", ".jpeg", ".png", ".apng", ".gif", ".webp", ".avif", ".heif", ".heic", ".jxl", ".pdf", ".woff", ".woff2", ".mp4", ".webm", ".avi", ".mpeg", ".mov", ".ts", ".ogv", ".mp3", ".ogg", ".oga", ".weba", ".m4a", ".aac", ".opus", ".flac"];
54
50
  const SCRIPT_PATH = "/lib/single-file-zip.min.js";
55
- // <noscript> is excluded: it is the only tag whose content is raw text when scripting is
56
- // enabled and markup when it is not, so the archive bytes would be parsed on a page opened
57
- // without scripting
58
- // the script and style rungs come first because text extractors drop their content the way
59
- // they drop a comment: macOS Spotlight indexes the content of every other rung, and textutil
60
- // also reads the last two. Neither is applied nor executed, the type is neither CSS nor
61
- // JavaScript. <plaintext> stays last, it is the only rung that cannot be closed
62
- // the CDATA section sits second to last, and it is the one rung a payload is unlikely to hold
63
- // the terminator of: every other rung ends on a sequence that real documents carry, which is
64
- // also why each level of self-nesting burns one. It is low in the ladder only because text
65
- // extractors read its content; nothing about the parse is weaker. A CDATA section is only a
66
- // CDATA section in foreign content, hence the <svg> element around it
67
51
  const EXTRA_DATA_TAGS = [
68
52
  ["<script type=sfz-data>", "</script>"],
69
53
  ["<style type=sfz-data>", "</style>"],
@@ -78,8 +62,6 @@ const EMBEDDED_DATA_TAGS = [
78
62
  ["<!--", "-->"],
79
63
  ...EXTRA_DATA_TAGS,
80
64
  ];
81
- // the identifier the extractor addresses the zip data with; the faces hidden by the same
82
- // wrapper ladder must never carry it, they are located by byte structure instead
83
65
  const DATA_IDENTIFIER = "sfz-data";
84
66
  const EXTRA_DATA_REGEXPS = [
85
67
  [/<script/i, /<\/script[\t\n\f\r />]/i],
@@ -88,17 +70,9 @@ const EXTRA_DATA_REGEXPS = [
88
70
  [/<noembed/i, /<\/noembed[\t\n\f\r />]/i],
89
71
  [/<iframe/i, /<\/iframe[\t\n\f\r />]/i],
90
72
  [/<xmp/i, /<\/xmp[\t\n\f\r />]/i],
91
- // a CDATA section ends on "]]>" and nothing else, so the terminator is the whole test; the
92
- // start pattern is the same conservatism the raw text rungs get, since a nested "<![CDATA["
93
- // is text like any other. Trailing brackets are safe: a payload ending "]]" against the
94
- // writer's "]]>" gives "]]]]>", and the tokenizer emits the payload's own two before closing
95
73
  [/<!\[CDATA\[/i, /\]\]>/],
96
74
  [/<plaintext/i, /<\/plaintext[\t\n\f\r />]/i]
97
75
  ];
98
- // a comment must also not end with "<!-", the last of the restrictions HTML puts on comment
99
- // text. The remaining one, that it must not start with ">" or "->", is not a matter of what
100
- // the payload contains: the zip data starts with the identifier and the PDF with a signature,
101
- // while the image data starts with a checksum, tested where that checksum is computed
102
76
  const EMBEDDED_DATA_REGEXPS = [
103
77
  [/<!--/i, /--!?>|<!-$/i],
104
78
  ...EXTRA_DATA_REGEXPS,
@@ -117,7 +91,8 @@ const PNG_IHDR_LENGTH = 25;
117
91
  const COMMENT_LENGTH_FIELD_LENGTH = 2;
118
92
  const MAX_APPENDED_DATA_LENGTH = 65535;
119
93
  const PDF_ENTRY_FILENAME = "page.pdf";
120
- const PDF_HEADER_MAX_OFFSET = 1024;
94
+ const PRESCAN_WINDOW_LENGTH = 1024;
95
+ const PNG_TEXT_CHUNK_HEADER_LENGTH = 12;
121
96
  const MINIMAL_DOCTYPE = "<!DOCTYPE html>";
122
97
  const UNHIDDEN_FACE_WARNING_MESSAGE = "SingleFile: the page data contains every HTML tag that could hide an embedded file, the archive was written without its";
123
98
  const EMBEDDED_IMAGE_LABEL = "PNG image";
@@ -127,14 +102,10 @@ const CENTRAL_FILE_HEADER_SIGNATURE = 0x02014b50;
127
102
  const END_OF_CENTRAL_DIR_SIGNATURE = 0x06054b50;
128
103
  const ZIP64_END_OF_CENTRAL_DIR_SIGNATURE = 0x06064b50;
129
104
  const ZIP64_END_OF_CENTRAL_DIR_LOCATOR_SIGNATURE = 0x07064b50;
105
+ const LANGUAGE_ENCODING_FLAG = 0x0800;
130
106
 
131
107
  const browser = globalThis.browser;
132
108
 
133
- // the options process() accepts from its caller. single-file.js builds its argument from this
134
- // list instead of a literal, because an option added here and forgotten there is undefined at
135
- // every call site and the feature silently does nothing: that is how declareAppendedData and
136
- // includeBOM both shipped inert. Options the module sets on itself between passes, and options
137
- // the packager supplies, are deliberately absent
138
109
  const PROCESS_OPTION_NAMES = [
139
110
  "createRootDirectory",
140
111
  "declareAppendedData",
@@ -163,12 +134,6 @@ export {
163
134
 
164
135
  async function process(pageData, options, lastModDate = new Date()) {
165
136
  let script;
166
- // The worker is configured before anything else, and outside the extension it is turned off
167
- // rather than left alone. Given no address, zip.js resolves its default one against the page
168
- // being saved, so the browser asks the CAPTURED SITE for a file that site has never heard of:
169
- // three 404s in the user's own server logs for every archive, and then a fallback to the main
170
- // thread anyway, which is where the work was always going to happen. Choosing the fallback
171
- // costs nothing that was ever gained and asks the site for nothing.
172
137
  const extensionContext = Boolean(browser && browser.runtime && browser.runtime.getURL);
173
138
  if (extensionContext) {
174
139
  configure({ workerURI: "/lib/single-file-z-worker.js" });
@@ -188,15 +153,21 @@ async function process(pageData, options, lastModDate = new Date()) {
188
153
  }
189
154
 
190
155
  async function createArchive(pageData, options, script, writeEntries, lastModDate = new Date()) {
156
+ const zipWriterOptions = { bufferedWrite: true, keepOrder: true, lastModDate, useCompressionStream: true };
157
+ const entriesWriter = new ZipWriter(new Uint8ArrayWriter(), zipWriterOptions);
158
+ await writeEntries(entriesWriter);
159
+ const entriesData = await entriesWriter.close();
160
+ return buildArchive(pageData, options, script, entriesData, zipWriterOptions);
161
+ }
162
+
163
+ async function buildArchive(pageData, options, script, entriesData, zipWriterOptions) {
164
+ const { lastModDate } = zipWriterOptions;
191
165
  const zipDataWriter = new Uint8ArrayWriter();
192
166
  zipDataWriter.init();
193
167
  let extraDataOffset, extraData, embeddedImageDataOffset, endTag, pdfEntry;
194
168
  if (options.embeddedImage) {
195
169
  options.embeddedImage = new Uint8Array(options.embeddedImage);
196
170
  }
197
- // the whole chunk is built before the first byte of the image is written, because building it
198
- // is what settles the rung, and the search can end with no rung at all: the image is then left
199
- // out altogether rather than written unwrapped
200
171
  let imageChunk;
201
172
  if (options.embeddedImage && options.selfExtractingArchive) {
202
173
  imageChunk = getImageHTMLChunk(pageData, options, lastModDate);
@@ -211,8 +182,7 @@ async function createArchive(pageData, options, script, writeEntries, lastModDat
211
182
  endTag = imageChunk.endTag;
212
183
  if (imageChunk.startHTMLData.pdfEntry) {
213
184
  pdfEntry = imageChunk.startHTMLData.pdfEntry;
214
- // the htmlArray starts after the 4-byte length and the 8-byte type of the tEXt chunk
215
- pdfEntry.offset += zipDataWriter.offset + 12;
185
+ pdfEntry.offset += zipDataWriter.offset + PNG_TEXT_CHUNK_HEADER_LENGTH;
216
186
  }
217
187
  await writeData(zipDataWriter.writable, imageChunk.htmlData);
218
188
  await writeData(zipDataWriter.writable, imageChunk.htmlDataCRC);
@@ -236,53 +206,35 @@ async function createArchive(pageData, options, script, writeEntries, lastModDat
236
206
  } else if (!options.embeddedImage && options.embeddedPdf) {
237
207
  await writeData(zipDataWriter.writable, new Uint8Array(options.embeddedPdf));
238
208
  }
239
- // a WritableWriter object is passed instead of the writer so that the ZipWriter
240
- // never takes ownership of the stream: preventClose is only honored when the
241
- // caller owns the writable, and the HTML suffix still gets written after it;
242
- // its size property tells the ZipWriter the offset of the data written so far
243
209
  const startOffset = zipDataWriter.offset;
244
- const zipWriter = new ZipWriter({ writable: zipDataWriter.writable, size: startOffset }, { bufferedWrite: true, keepOrder: true, lastModDate, useCompressionStream: true });
245
- await writeEntries(zipWriter);
210
+ const zipWriter = new ZipWriter({ writable: zipDataWriter.writable, size: startOffset }, zipWriterOptions);
211
+ await zipWriter.appendZip(new Uint8ArrayReader(entriesData));
246
212
  if (pdfEntry) {
247
- // the record is written where the central directory will start so that the PDF is listed
248
- // first; the ZipWriter is unaware of these bytes, so the central directory offset it
249
- // stores in the end of central directory record points here
250
213
  new DataView(pdfEntry.centralRecord.buffer).setUint32(42, pdfEntry.offset, true);
251
214
  await writeData(zipDataWriter.writable, pdfEntry.centralRecord);
252
215
  }
253
216
  await zipWriter.close(undefined, { preventClose: true });
254
217
  if (pdfEntry && !patchEndOfCentralDirectory(zipDataWriter, pdfEntry.centralRecord.length)) {
255
- // the record cannot be declared in the end of central directory record: rebuild the
256
- // archive without it rather than leave a record the directory does not count
257
218
  options.preventEmbeddedPdfEntry = true;
258
- return createArchive(pageData, options, script, writeEntries, lastModDate);
219
+ return buildArchive(pageData, options, script, entriesData, zipWriterOptions);
259
220
  }
260
221
  const data = zipDataWriter.getData();
261
- // the last two bytes of the archive are the comment length field of the end of central
262
- // directory record: they are left out of the data the extraction payload describes, so that
263
- // declaring the appended data as the archive comment cannot invalidate a payload computed
264
- // before that length is known
265
222
  const zipDataEnd = data.length - COMMENT_LENGTH_FIELD_LENGTH;
266
223
  if (options.selfExtractingArchive) {
267
224
  const lfCodes = [];
268
225
  let crc32 = -1;
269
- // the wrapper must not be closed by the zip data itself, whether or not the page
270
- // carries the data: a premature closer parses the rest of the archive as markup
271
226
  if (!options.extractDataFromPageTags || options.extractDataFromPageTags[0] != "<plaintext>") {
272
227
  const textContent = TEXT_DECODER.decode(data.subarray(startOffset));
273
228
  if (options.extractDataFromPageTags) {
274
- // the rung is matched on its start tag, not on the identity of the array holding it:
275
- // the option is set from EXTRA_DATA_TAGS internally, but a caller passing an equal
276
- // pair of its own would otherwise index the regexps with -1
277
229
  const tagIndex = getExtraDataTagIndex(options.extractDataFromPageTags);
278
230
  const regExpsTag = EXTRA_DATA_REGEXPS[tagIndex];
279
231
  if (textContent.match(regExpsTag[0]) || textContent.match(regExpsTag[1])) {
280
- return findExtraDataTags(textContent, pageData, options, script, writeEntries, lastModDate, tagIndex + 1);
232
+ return findExtraDataTags(textContent, pageData, options, script, entriesData, zipWriterOptions, tagIndex + 1);
281
233
  }
282
234
  } else {
283
235
  const [startRegExp, endRegExp] = EMBEDDED_DATA_REGEXPS[0];
284
236
  if (textContent.match(startRegExp) || textContent.match(endRegExp)) {
285
- return findExtraDataTags(textContent, pageData, options, script, writeEntries, lastModDate);
237
+ return findExtraDataTags(textContent, pageData, options, script, entriesData, zipWriterOptions);
286
238
  }
287
239
  }
288
240
  }
@@ -314,35 +266,23 @@ async function createArchive(pageData, options, script, writeEntries, lastModDat
314
266
  }
315
267
  const endTags = options.preventAppendedData || options.embeddedImage ? "" : "</body></html>";
316
268
  if (options.extractDataFromPage) {
317
- // payload layout: [crc32, zip data length, LF codes count, 2-bit codes (0=LF, 1=CR, 2=CRLF) packed LSB-first]
318
- const payload = new Uint32Array(3 + Math.ceil(lfCodes.length / 16));
319
- payload[0] = crc32;
320
- payload[1] = zipDataEnd - startOffset;
321
- payload[2] = lfCodes.length;
322
- lfCodes.forEach((lfCode, indexLFCode) => payload[3 + (indexLFCode >> 4)] |= lfCode << ((indexLFCode & 15) * 2));
323
- extraData = "<sfz-extra-data>" + base64Encode(deflateRaw(new Uint8Array(payload.buffer))) + "</sfz-extra-data>";
324
- // the bytes appended after the EOCD record (wrapper end tag, extra data, end tags
325
- // and, with an embedded image, the tEXt CRC and IEND chunk) must fit the 65535-byte
326
- // window readers scan backward to find the EOCD record
269
+ const words = new Uint32Array(3 + Math.ceil(lfCodes.length / 16));
270
+ words[0] = crc32;
271
+ words[1] = zipDataEnd - startOffset;
272
+ words[2] = lfCodes.length;
273
+ lfCodes.forEach((lfCode, indexLFCode) => words[3 + (indexLFCode >> 4)] |= lfCode << ((indexLFCode & 15) * 2));
274
+ const payload = new Uint8Array(words.length * 4);
275
+ const payloadView = new DataView(payload.buffer);
276
+ words.forEach((word, indexWord) => payloadView.setUint32(indexWord * 4, word, true));
277
+ extraData = "<sfz-extra-data>" + base64Encode(deflateRaw(payload)) + "</sfz-extra-data>";
327
278
  if (options.preventAppendedData || extraData.length > MAX_APPENDED_DATA_LENGTH - pageContent.length - endTags.length - (options.embeddedImage ? PNG_IEND_LENGTH + PNG_CHUNK_CRC_LENGTH : 0)) {
328
279
  if (!options.extraDataSize) {
280
+ options.preventAppendedData = true;
329
281
  options.extraDataSize = getReservationSize(extraData.length);
330
- return createArchive(pageData, options, script, writeEntries, lastModDate);
282
+ return buildArchive(pageData, options, script, entriesData, zipWriterOptions);
331
283
  }
332
284
  } else {
333
- if (options.extraDataSize) {
334
- // dropping the reservation moves the archive back, which changes the payload
335
- // that made it necessary: a payload sitting on the boundary would be too large
336
- // appended and small enough relocated, forever. The reservation is dropped at
337
- // most once, so the build cannot oscillate between the two placements
338
- if (!options.extraDataSizeDropped) {
339
- options.extraDataSizeDropped = true;
340
- options.extraDataSize = undefined;
341
- return createArchive(pageData, options, script, writeEntries, lastModDate);
342
- }
343
- } else {
344
- pageContent += extraData;
345
- }
285
+ pageContent += extraData;
346
286
  }
347
287
  }
348
288
  pageContent += endTags;
@@ -354,14 +294,11 @@ async function createArchive(pageData, options, script, writeEntries, lastModDat
354
294
  if (options.extraDataSize >= extraData.length) {
355
295
  pageContent.set(new TextEncoder().encode(extraData), startOffset - extraDataOffset);
356
296
  } else {
357
- options.extraData = extraData;
358
297
  options.extraDataSize = getReservationSize(extraData.length);
359
- return createArchive(pageData, options, script, writeEntries, lastModDate);
298
+ return buildArchive(pageData, options, script, entriesData, zipWriterOptions);
360
299
  }
361
300
  }
362
301
  if (options.declareAppendedData) {
363
- // readers that reject undeclared bytes after the end of central directory record, notably
364
- // java.util.zip, accept the file when the same bytes are declared as the archive comment
365
302
  const appendedDataLength = pageContent.length - data.length +
366
303
  (options.embeddedImage ? PNG_CHUNK_CRC_LENGTH + PNG_IEND_LENGTH : 0);
367
304
  if (appendedDataLength && appendedDataLength <= MAX_APPENDED_DATA_LENGTH) {
@@ -403,6 +340,7 @@ function getPDFEntry(embeddedPdf, lastModDate = new Date()) {
403
340
  const localHeaderView = new DataView(localHeader.buffer);
404
341
  localHeaderView.setUint32(0, LOCAL_FILE_HEADER_SIGNATURE, true);
405
342
  localHeaderView.setUint16(4, 20, true);
343
+ localHeaderView.setUint16(6, LANGUAGE_ENCODING_FLAG, true);
406
344
  localHeaderView.setUint16(10, dosTime, true);
407
345
  localHeaderView.setUint16(12, dosDate, true);
408
346
  localHeaderView.setUint32(14, crc32, true);
@@ -415,6 +353,7 @@ function getPDFEntry(embeddedPdf, lastModDate = new Date()) {
415
353
  centralRecordView.setUint32(0, CENTRAL_FILE_HEADER_SIGNATURE, true);
416
354
  centralRecordView.setUint16(4, 0x0300, true);
417
355
  centralRecordView.setUint16(6, 20, true);
356
+ centralRecordView.setUint16(8, LANGUAGE_ENCODING_FLAG, true);
418
357
  centralRecordView.setUint16(12, dosTime, true);
419
358
  centralRecordView.setUint16(14, dosDate, true);
420
359
  centralRecordView.setUint32(16, crc32, true);
@@ -438,7 +377,6 @@ function patchEndOfCentralDirectory(zipDataWriter, centralRecordLength) {
438
377
  const offsetLocator = offsetEOCD - 20;
439
378
  let offsetZip64EOCD;
440
379
  if (offsetLocator >= 0 && view.getUint32(offsetLocator, true) == ZIP64_END_OF_CENTRAL_DIR_LOCATOR_SIGNATURE) {
441
- // the offset stored in the locator does not account for the PDF record either
442
380
  offsetZip64EOCD = Number(view.getBigUint64(offsetLocator + 8, true)) + centralRecordLength;
443
381
  if (view.getUint32(offsetZip64EOCD, true) != ZIP64_END_OF_CENTRAL_DIR_SIGNATURE) {
444
382
  return false;
@@ -464,16 +402,12 @@ function patchEndOfCentralDirectory(zipDataWriter, centralRecordLength) {
464
402
  return true;
465
403
  }
466
404
 
467
- // the functions inlined in the archive lose their newlines, so a line comment would swallow
468
- // the rest of the script: whole-line comments are removed before the newlines are
469
405
  function inlineFunction(bootstrapFunction) {
470
406
  return bootstrapFunction.toString().replace(/^[ \t]*\/\/.*$/gm, "").replace(/\n|\t/g, "");
471
407
  }
472
408
 
473
- // the reservation must be strictly larger than the payload it was computed from, otherwise
474
- // the retry loop can be handed the same size again and oscillate instead of converging
475
409
  function getReservationSize(length) {
476
- return Math.max(length + 1, Math.floor(length * 1.001));
410
+ return Math.ceil(length * 1.01) + 32;
477
411
  }
478
412
 
479
413
  function getLength(length) {
@@ -513,14 +447,11 @@ async function prependHTMLData(pageData, zipDataWriter, script, options, lastMod
513
447
  if (pageData.tocContent) {
514
448
  pageContent += pageData.tocContent;
515
449
  }
516
- // the text body repeats the page content outside the archive, where no password reaches it
517
450
  if (options.insertTextBody && !options.password) {
518
451
  const doc = (new DOMParser()).parseFromString(pageData.content, "text/html");
519
452
  doc.body.querySelectorAll("style, script, noscript").forEach(element => element.remove());
520
453
  let textBody = "";
521
454
  if (options.extractDataFromPage) {
522
- // the text body is read as raw bytes by text tools, so the title goes in unencoded;
523
- // the < and > escaping below covers it
524
455
  textBody += (pageData.title || "") + "\n\n";
525
456
  }
526
457
  textBody += doc.body.innerText;
@@ -534,16 +465,12 @@ async function prependHTMLData(pageData, zipDataWriter, script, options, lastMod
534
465
  textBody = textBody.replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/\n +/g, "\n").replace(/\n\n\n+/g, "\n\n").trim();
535
466
  pageContent += "\n<main hidden>\n" + textBody + "\n</main>\n";
536
467
  }
537
- const displayOptions = {
538
- insertEmbeddedImage: Boolean(options.embeddedImage),
539
- insertEmbeddedScreenshotImage: Boolean(options.embeddedScreenshotImage)
540
- };
541
468
  const bootstrapBody = options.multiPageArchive ?
542
469
  "(" + inlineFunction(router) + ")(content,{extract:" +
543
470
  inlineFunction(extract) + ",display:" +
544
471
  inlineFunction(display) + "})" :
545
472
  "(" + inlineFunction(extract) + ")(content,{prompt}).then(({docContent}) => " +
546
- inlineFunction(display) + "(document,docContent," + JSON.stringify(displayOptions) + "))";
473
+ inlineFunction(display) + "(document,docContent))";
547
474
  script = "<script>" +
548
475
  script +
549
476
  "document.currentScript.remove();" +
@@ -571,14 +498,10 @@ async function prependHTMLData(pageData, zipDataWriter, script, options, lastMod
571
498
  return { extraDataOffset, pdfEntry };
572
499
  }
573
500
 
574
- // the extractor finds the zip data by identifier instead of by its position in the tree: an
575
- // element carries it as an attribute, a comment as the first characters of its data
576
501
  function getDataStartTag([startTag]) {
577
502
  if (startTag == "<!--") {
578
503
  return startTag + DATA_IDENTIFIER;
579
504
  }
580
- // the attribute belongs to the element that opens the wrapper, which is not always the whole
581
- // start tag: the CDATA rung opens with an <svg> and then a markup declaration that takes none
582
505
  const tagEnd = startTag.indexOf(">");
583
506
  return startTag.slice(0, tagEnd) + " id=" + DATA_IDENTIFIER + startTag.slice(tagEnd);
584
507
  }
@@ -591,14 +514,13 @@ function getStartHTMLArray(pageData, options, lastModDate, startTag = "") {
591
514
  const doctype = options.embeddedImage ? "" : pageData.doctype;
592
515
  const charset = options.extractDataFromPage ? "windows-1252" : "utf-8";
593
516
  const documentStart = "<html data-sfz><meta charset=" + charset + ">";
594
- const html = bom + doctype + documentStart;
595
- // the comment carries the page URL, whose length is unbounded: it is emitted after the
596
- // declaration of the character encoding, and after the embedded PDF when there is one, so
597
- // that neither the encoding declaration nor the PDF header leaves the first 1024 bytes.
598
- // it is left out of a password-protected archive, like the title below: the same URL is
599
- // in manifest.json, which is encrypted, so emitting it here would publish what the
600
- // password is meant to cover
601
- const comment = pageData.comment && !options.embeddedImage && !options.password ? "<!--" + pageData.comment + "-->" : "";
517
+ const startOffset = options.embeddedImage ?
518
+ PNG_SIGNATURE_LENGTH + PNG_IHDR_LENGTH + PNG_TEXT_CHUNK_HEADER_LENGTH : 0;
519
+ let html = bom + doctype + documentStart;
520
+ if (startOffset + new TextEncoder().encode(html).length > PRESCAN_WINDOW_LENGTH) {
521
+ html = bom + MINIMAL_DOCTYPE + documentStart;
522
+ }
523
+ const comment = pageData.comment && !options.embeddedImage && !options.password ? "<!--" + escapeCommentData(pageData.comment) + "-->" : "";
602
524
  const htmlHeadData = getHTMLHeadData(pageData, options);
603
525
  let htmlArray, pdfEntry;
604
526
  if (options.embeddedPdf) {
@@ -613,10 +535,7 @@ function getStartHTMLArray(pageData, options, lastModDate, startTag = "") {
613
535
  } else {
614
536
  const [pdfStartTag, pdfEndTag] = EMBEDDED_DATA_TAGS[pdfTagIndex];
615
537
  let htmlArray1 = new TextEncoder().encode(html + pdfStartTag);
616
- if (htmlArray1.length + localHeader.length > PDF_HEADER_MAX_OFFSET) {
617
- // PDF readers only scan the start of the file for the %PDF- header, and the page
618
- // doctype is copied verbatim: it is the one part of the prefix with no bound, so a
619
- // long one is replaced rather than pushing the header out of the scan window
538
+ if (startOffset + htmlArray1.length + localHeader.length > PRESCAN_WINDOW_LENGTH) {
620
539
  htmlArray1 = new TextEncoder().encode(bom + MINIMAL_DOCTYPE + documentStart + pdfStartTag);
621
540
  }
622
541
  const htmlArray2 = new TextEncoder().encode(pdfEndTag + comment + htmlHeadData + startTag);
@@ -638,23 +557,19 @@ function getStartHTMLArray(pageData, options, lastModDate, startTag = "") {
638
557
 
639
558
  function getHTMLHeadData(pageData, options) {
640
559
  let pageContent = "";
641
- // the title is left out of a password-protected archive: manifest.json carries it too and
642
- // is encrypted, so emitting it here would publish what the password is meant to cover
643
560
  const title = options.password ? "" : escapeHTML(pageData.title || "");
644
561
  pageContent += "<title>" + title + "</title>";
645
- // the canonical link publishes the URL the archive was saved from, for the same reason
646
- // the title above is left out of a password-protected archive
647
562
  if (options.insertCanonicalLink && !options.password) {
648
- pageContent += "<link rel=canonical href=\"" + options.url + "\">";
563
+ pageContent += "<link rel=canonical href=\"" + escapeHTML(options.url) + "\">";
649
564
  }
650
565
  if (options.insertMetaNoIndex) {
651
566
  pageContent += "<meta name=robots content=noindex>";
652
567
  }
653
568
  if (pageData.viewport) {
654
- pageContent += "<meta name=viewport content=" + JSON.stringify(pageData.viewport) + ">";
569
+ pageContent += "<meta name=viewport content=\"" + escapeHTML(pageData.viewport) + "\">";
655
570
  }
656
571
  if (options.insertMetaCSP) {
657
- const cspContent = "default-src 'none';connect-src 'self' data: blob:;font-src 'self' data: blob:;img-src 'self' data: blob:;style-src 'self' 'unsafe-inline' data: blob:;frame-src 'self' data: blob:;media-src 'self' data: blob:;script-src 'self' 'unsafe-inline' data: blob:;object-src 'self' data: blob:";
572
+ const cspContent = "default-src 'none';connect-src 'self' data: blob:;font-src 'self' data: blob:;img-src 'self' data: blob:;style-src 'self' 'unsafe-inline' data: blob:;frame-src 'self' data: blob:;media-src 'self' data: blob:;script-src 'self' 'unsafe-inline' data: blob:;object-src 'self' data: blob:;form-action 'none';base-uri 'none'";
658
573
  pageContent += `<meta http-equiv=content-security-policy content=${JSON.stringify(cspContent)}>`;
659
574
  }
660
575
  pageContent += "<style>@keyframes display-wait-message{0%{opacity:0}100%{opacity:1}}body{color:transparent}div{color:initial}body>:not(#sfz-wait-message,#sfz-error-message){display:none}</style>";
@@ -662,9 +577,17 @@ function getHTMLHeadData(pageData, options) {
662
577
  return pageContent;
663
578
  }
664
579
 
665
- // every piece of text in the prelude goes through this, wherever it is assembled: the prelude
666
- // declares a single-byte charset, and numeric character references are ASCII bytes, so they
667
- // survive it and any parser decodes them back to the original text
580
+ function escapeCommentData(value) {
581
+ let data = value.replace(/--(!?)>/g, "--$1 >");
582
+ if (data.startsWith(">") || data.startsWith("->")) {
583
+ data = " " + data;
584
+ }
585
+ if (data.endsWith("<!-")) {
586
+ data += " ";
587
+ }
588
+ return data;
589
+ }
590
+
668
591
  function escapeHTML(value) {
669
592
  return Array.from(value).map(character => {
670
593
  const codePoint = character.codePointAt(0);
@@ -681,46 +604,26 @@ function getExtraDataTagIndex(extractDataFromPageTags) {
681
604
  return tagIndex;
682
605
  }
683
606
 
684
- function findExtraDataTags(textContent, pageData, options, script, writeEntries, lastModDate, indexExtractDataFromPageTags = 0) {
607
+ function findExtraDataTags(textContent, pageData, options, script, entriesData, zipWriterOptions, indexExtractDataFromPageTags = 0) {
685
608
  const regExpsTag = EXTRA_DATA_REGEXPS[indexExtractDataFromPageTags];
686
609
  const plaintextTag = EXTRA_DATA_TAGS[indexExtractDataFromPageTags][0] == "<plaintext>";
687
610
  const matchTag = !plaintextTag && (textContent.match(regExpsTag[0]) || textContent.match(regExpsTag[1]));
688
611
  if (matchTag) {
689
- if (indexExtractDataFromPageTags < EXTRA_DATA_TAGS.length - 1) {
690
- return findExtraDataTags(textContent, pageData, options, script, writeEntries, lastModDate, indexExtractDataFromPageTags + 1);
691
- } else {
692
- options.extractDataFromPage = false;
693
- return createArchive(pageData, options, script, writeEntries, lastModDate);
694
- }
612
+ return findExtraDataTags(textContent, pageData, options, script, entriesData, zipWriterOptions, indexExtractDataFromPageTags + 1);
695
613
  } else {
696
614
  options.extractDataFromPageTags = EXTRA_DATA_TAGS[indexExtractDataFromPageTags];
697
615
  if (options.extractDataFromPageTags[0] == "<plaintext>") {
698
- // <plaintext> cannot be closed, the file must end with the zip data
699
616
  options.preventAppendedData = true;
700
617
  }
701
- return createArchive(pageData, options, script, writeEntries, lastModDate);
618
+ return buildArchive(pageData, options, script, entriesData, zipWriterOptions);
702
619
  }
703
620
  }
704
621
 
705
- // a rung is rejected on its end pattern, which terminates the wrapper, and on its start
706
- // pattern: script data has escape states the raw text rungs do not have, where "<!--"
707
- // followed by "<script" leaves "</script>" unable to close the element at all
708
622
  function findEmbeddedDataTagIndex(text, fromIndex = 0) {
709
623
  const tagIndex = EMBEDDED_DATA_REGEXPS.slice(fromIndex, -1).findIndex(([startRegExp, endRegExp]) => !text.match(startRegExp) && !text.match(endRegExp));
710
624
  return tagIndex == -1 ? -1 : tagIndex + fromIndex;
711
625
  }
712
626
 
713
- // a face exists only while a rung can hide it. When the payload names every rung, the older
714
- // fallback emitted it unwrapped, on the grounds that the page still rendered: but the payload's
715
- // markup then joins the document, and a payload that is itself an archive contributes an
716
- // sfz-data node ahead of this file's own. A reader takes that one and extracts it, checksum and
717
- // all, with nothing to say the archive it returned is not the archive the file was built around.
718
- // Dropping the face costs a picture; keeping it costs the archive
719
- // what follows the start tag is the checksum of the chunk carrying it, four bytes only known once
720
- // the tag is chosen: a comment they open with ">" or "->" is closed by the parser there and then,
721
- // leaving the image data to be read as markup. Stepping past the comment rung means searching from
722
- // the next one, not taking it — a rung qualifies on the payload, and the payload had no say in
723
- // which rung the checksum sent the writer to
724
627
  function getImageHTMLChunk(pageData, options, lastModDate) {
725
628
  const embeddedImageText = TEXT_DECODER.decode(getEmbeddedImageData(options.embeddedImage));
726
629
  let tagIndex = findEmbeddedDataTagIndex(embeddedImageText);
@@ -729,7 +632,9 @@ function getImageHTMLChunk(pageData, options, lastModDate) {
729
632
  const startHTMLData = getStartHTMLArray(pageData, options, lastModDate, startTag);
730
633
  const htmlData = new Uint8Array([...getLength(startHTMLData.htmlArray.length + 4), ...[0x74, 0x45, 0x58, 0x74, 0x50, 0x4e, 0x47, 0], ...startHTMLData.htmlArray]);
731
634
  const htmlDataCRC = getCRC32(htmlData, 4);
732
- if (tagIndex == 0 && (htmlDataCRC[0] == 0x3e || (htmlDataCRC[0] == 0x2d && htmlDataCRC[1] == 0x3e))) {
635
+ const wrappedText = TEXT_DECODER.decode(htmlDataCRC) + embeddedImageText;
636
+ if ((tagIndex == 0 && (htmlDataCRC[0] == 0x3e || (htmlDataCRC[0] == 0x2d && htmlDataCRC[1] == 0x3e))) ||
637
+ findEmbeddedDataTagIndex(wrappedText, tagIndex) != tagIndex) {
733
638
  tagIndex = findEmbeddedDataTagIndex(embeddedImageText, tagIndex + 1);
734
639
  } else {
735
640
  return { endTag, startHTMLData, htmlData, htmlDataCRC };
@@ -791,8 +696,6 @@ async function addFile(zipWriter, prefixName, data, disableCompression) {
791
696
  const dataReader = typeof data.content == "string" ? new TextReader(data.content) : new BlobReader(new Blob([new Uint8Array(data.content)]));
792
697
  const options = { password: data.password, bufferedWrite: true };
793
698
  if (!data.password) {
794
- // entry comments are stored in the central directory, which is never encrypted: with a
795
- // password the resource URLs would be readable while the same map in manifest.json is not
796
699
  options.comment = data.url && data.url.startsWith("data:") ? "data:" : data.url;
797
700
  }
798
701
  if (disableCompression || (!isCompressibleContentType(data.contentType) && NO_COMPRESSION_EXTENSIONS.includes(data.extension))) {
@@ -807,7 +710,6 @@ function isCompressibleContentType(contentType) {
807
710
 
808
711
  async function getContent() {
809
712
  const BASE64_TABLE = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
810
- // the function is inlined in the archive as source, it cannot close over the module scope
811
713
  const DATA_IDENTIFIER = "sfz-data";
812
714
  const { Blob, XMLHttpRequest, NodeFilter, document, zip, location } = globalThis;
813
715
  const characterMap = new Map([
@@ -849,8 +751,6 @@ async function getContent() {
849
751
  const xhr = new XMLHttpRequest();
850
752
  xhr.responseType = "blob";
851
753
  xhr.open("GET", "");
852
- // a failure of the full download is recoverable when the page carries the data:
853
- // the wait message left the document intact, so the page text can still be read
854
754
  xhr.onerror = () => extractDataFromDocument();
855
755
  xhr.send();
856
756
  xhr.onreadystatechange = () => {
@@ -869,7 +769,6 @@ async function getContent() {
869
769
  getPageData();
870
770
  }
871
771
  } else {
872
- // an HTTP error status fires no error event; fall back like a network failure
873
772
  xhr.abort();
874
773
  extractDataFromDocument();
875
774
  }
@@ -913,13 +812,12 @@ async function getContent() {
913
812
  const zipDataElement = document.querySelector("sfz-extra-data");
914
813
  if (zipDataElement) {
915
814
  const inflatedPayload = zip.inflateRaw(base64Decode(zipDataElement.textContent));
916
- const payload = new Uint32Array(inflatedPayload.buffer, inflatedPayload.byteOffset, inflatedPayload.length >> 2);
917
- // the zip data is identified, not located: its node can be moved before this runs
815
+ const payload = new DataView(inflatedPayload.buffer, inflatedPayload.byteOffset, inflatedPayload.length & -4);
918
816
  const dataElement = document.getElementById(DATA_IDENTIFIER);
919
817
  if (dataElement) {
920
818
  return decodeZipData(dataElement, payload, 0);
921
819
  }
922
- const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_COMMENT);
820
+ const walker = document.createTreeWalker(document, NodeFilter.SHOW_COMMENT);
923
821
  while (walker.nextNode()) {
924
822
  if (walker.currentNode.data.startsWith(DATA_IDENTIFIER)) {
925
823
  return decodeZipData(walker.currentNode, payload, DATA_IDENTIFIER.length);
@@ -930,12 +828,9 @@ async function getContent() {
930
828
  }
931
829
 
932
830
  function decodeZipData(dataNode, payload, startIndex) {
933
- const expectedCRC32 = payload[0];
934
- const zipDataLength = payload[1];
935
- const lfCodesLength = payload[2];
936
- // the two extra bytes are the comment length field of the end of central directory
937
- // record, which the payload does not describe: left at zero they declare no comment,
938
- // which is what the recovered data holds
831
+ const expectedCRC32 = payload.getUint32(0, true);
832
+ const zipDataLength = payload.getUint32(4, true);
833
+ const lfCodesLength = payload.getUint32(8, true);
939
834
  const zipData = new Uint8Array(zipDataLength + 2);
940
835
  const { textContent } = dataNode;
941
836
  let offset = 0;
@@ -944,9 +839,11 @@ async function getContent() {
944
839
  for (let index = startIndex; index < textContent.length && offset < zipDataLength; index++) {
945
840
  const charCode = textContent.charCodeAt(index);
946
841
  if (charCode == 10) {
947
- const lfCode = (payload[3 + (indexLFCode >> 4)] >>> ((indexLFCode & 15) * 2)) & 3;
842
+ const lfCode = (payload.getUint32(12 + (indexLFCode >> 4) * 4, true) >>> ((indexLFCode & 15) * 2)) & 3;
948
843
  indexLFCode++;
949
- if (lfCode == 0) {
844
+ if (lfCode == 3) {
845
+ throw new Error("Unsupported newline code in the extracted zip data");
846
+ } else if (lfCode == 0) {
950
847
  writeByte(10);
951
848
  } else {
952
849
  writeByte(13);
@@ -322,7 +322,7 @@
322
322
  const rootBounds = getBoundingClientRectDefined ? rootBoundingRect : docBoundingRect;
323
323
  const time = 0;
324
324
  return { target, intersectionRatio, boundingClientRect, intersectionRect: boundingClientRect, isIntersecting, rootBounds, time };
325
- }).filter(params => params.boundingClientRect.width && params.boundingClientRect.height);
325
+ }).filter(params => params.boundingClientRect.width && params.boundingClientRect.height > 1);
326
326
  if (params.length) {
327
327
  observer.callback.call(intersectionObserver, params, intersectionObserver);
328
328
  }
@@ -349,6 +349,7 @@
349
349
  delete globalThis._singleFileImage;
350
350
  }
351
351
  if (!keepZoomLevel) {
352
+ resetScreenSize();
352
353
  dispatchResizeEvent();
353
354
  }
354
355
  }
@@ -33,6 +33,8 @@ const helper = {
33
33
 
34
34
  const MAX_IDLE_TIMEOUT_CALLS = 10;
35
35
  const ATTRIBUTES_MUTATION_TYPE = "attributes";
36
+ const CHILD_LIST_MUTATION_TYPE = "childList";
37
+ const STYLESHEET_TAG_NAMES = ["STYLE", "LINK"];
36
38
 
37
39
  const browser = globalThis.browser;
38
40
  const document = globalThis.document;
@@ -88,24 +90,25 @@ function triggerLazyLoading(options) {
88
90
  let loadingImages;
89
91
  const pendingImages = new Set();
90
92
  const observer = new MutationObserver(async mutations => {
91
- mutations = mutations.filter(mutation => mutation.type == ATTRIBUTES_MUTATION_TYPE);
92
- if (mutations.length) {
93
- const updated = mutations.filter(mutation => {
94
- if (mutation.attributeName == "src") {
95
- mutation.target.setAttribute(helper.LAZY_SRC_ATTRIBUTE_NAME, mutation.target.src);
96
- mutation.target.addEventListener("load", onResourceLoad);
97
- }
98
- if (mutation.attributeName == "src" || mutation.attributeName == "srcset" ||
99
- (mutation.target.tagName && mutation.target.tagName.toUpperCase() == "SOURCE")) {
100
- return !mutation.target.classList || !mutation.target.classList.contains(helper.SINGLE_FILE_UI_ELEMENT_CLASS);
101
- }
102
- });
103
- if (updated.length) {
104
- loadingImages = true;
105
- await deferForceLazyLoadEnd(observer, options, cleanupAndResolve);
106
- if (!pendingImages.size) {
107
- await deferLazyLoadEnd(observer, options, cleanupAndResolve);
108
- }
93
+ const attributeMutations = mutations.filter(mutation => mutation.type == ATTRIBUTES_MUTATION_TYPE);
94
+ const insertedStylesheets = mutations.filter(mutation => mutation.type == CHILD_LIST_MUTATION_TYPE &&
95
+ Array.from(mutation.addedNodes).some(node => node.tagName && STYLESHEET_TAG_NAMES.includes(node.tagName.toUpperCase()) &&
96
+ (!node.classList || !node.classList.contains(helper.SINGLE_FILE_UI_ELEMENT_CLASS))));
97
+ const updated = attributeMutations.filter(mutation => {
98
+ if (mutation.attributeName == "src") {
99
+ mutation.target.setAttribute(helper.LAZY_SRC_ATTRIBUTE_NAME, mutation.target.src);
100
+ mutation.target.addEventListener("load", onResourceLoad);
101
+ }
102
+ if (mutation.attributeName == "src" || mutation.attributeName == "srcset" ||
103
+ (mutation.target.tagName && mutation.target.tagName.toUpperCase() == "SOURCE")) {
104
+ return !mutation.target.classList || !mutation.target.classList.contains(helper.SINGLE_FILE_UI_ELEMENT_CLASS);
105
+ }
106
+ });
107
+ if (updated.length || insertedStylesheets.length) {
108
+ loadingImages = true;
109
+ await deferForceLazyLoadEnd(observer, options, cleanupAndResolve);
110
+ if (!pendingImages.size) {
111
+ await deferLazyLoadEnd(observer, options, cleanupAndResolve);
109
112
  }
110
113
  }
111
114
  });