single-file-core 1.5.123 → 1.5.125

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/core/helper.js CHANGED
@@ -293,6 +293,9 @@ function preProcessDoc(doc, win, options) {
293
293
  }
294
294
 
295
295
  function markInvalidNesting(doc) {
296
+ if (!doc.body) {
297
+ return;
298
+ }
296
299
  addTrackIds(doc.body);
297
300
  const verificationDoc = parseDocContent(serialize(doc));
298
301
  const markedMap = buildTrackIdMap(doc.body);
@@ -38,9 +38,6 @@ function getDoctypeString(doc) {
38
38
  } else if (docType.systemId) {
39
39
  docTypeString += " SYSTEM \"" + docType.systemId + "\"";
40
40
  }
41
- if (docType.internalSubset) {
42
- docTypeString += " [" + docType.internalSubset + "]";
43
- }
44
41
  docTypeString += ">";
45
42
  }
46
43
  return docTypeString;
@@ -405,7 +405,7 @@ class ProcessorHelperCommon {
405
405
  }
406
406
  sheetIndex++;
407
407
  });
408
- processFontDetails(fontsDetails);
408
+ processFontDetails(fontsDetails, fonts);
409
409
  await Promise.all([...stylesheets].map(async ([, stylesheetInfo], sheetIndex) => {
410
410
  if (stylesheetInfo.stylesheet) {
411
411
  const cssRules = stylesheetInfo.stylesheet.children;
@@ -449,7 +449,10 @@ class ProcessorHelperCommon {
449
449
  removedRules.push(cssRule);
450
450
  } else {
451
451
  fontsDetails.emittedFonts.add(ruleKey);
452
- await this.processFontFaceRule(ruleData, fontInfo, fonts, fontTests, stats);
452
+ const keptRule = await this.processFontFaceRule(ruleData, fontInfo, fonts, fontTests, stats);
453
+ if (!keptRule) {
454
+ removedRules.push(cssRule);
455
+ }
453
456
  }
454
457
  } else {
455
458
  removedRules.push(cssRule);
@@ -648,6 +648,9 @@ function getProcessorHelperClass(utilInstance) {
648
648
  }
649
649
  }
650
650
  stats.fonts.discarded -= fontInfo.length;
651
+ if (!fontInfo.length) {
652
+ return false;
653
+ }
651
654
  fontInfo.reverse();
652
655
  try {
653
656
  srcDeclaration.data.value = cssTree.parse(fontInfo.map(fontSource => fontSource.src).join(","), { context: "value", parseCustomProperty: true });
@@ -656,6 +659,7 @@ function getProcessorHelperClass(utilInstance) {
656
659
  // ignored
657
660
  }
658
661
  }
662
+ return true;
659
663
  }
660
664
  };
661
665
  }
@@ -574,6 +574,9 @@ function getProcessorHelperClass(utilInstance) {
574
574
  removedNodes.forEach(node => ruleData.block.children.remove(node));
575
575
  const srcDeclaration = ruleData.block.children.filter(node => node.property == "src").tail;
576
576
  if (srcDeclaration) {
577
+ if (!fontInfo.length) {
578
+ return false;
579
+ }
577
580
  fontInfo.reverse();
578
581
  try {
579
582
  srcDeclaration.data.value = cssTree.parse(fontInfo.map(fontSource => fontSource.src).join(","), { context: "value", parseCustomProperty: true });
@@ -582,6 +585,7 @@ function getProcessorHelperClass(utilInstance) {
582
585
  // ignored
583
586
  }
584
587
  }
588
+ return true;
585
589
  }
586
590
  };
587
591
  }
package/core/util.js CHANGED
@@ -240,7 +240,7 @@ function getInstance(utilOptions) {
240
240
  startTime = Date.now();
241
241
  log(" // STARTED download url =", resourceURL, "asBinary =", options.asBinary);
242
242
  }
243
- if (options.blockMixedContent && /^https:/i.test(options.baseURI) && !/^https:/i.test(resourceURL)) {
243
+ if (options.blockMixedContent && /^https:/i.test(options.baseURI) && !/^https:/i.test(resourceURL) && !/^blob:https:/i.test(resourceURL)) {
244
244
  return getFetchResponse(resourceURL, options);
245
245
  }
246
246
  if (options.networkTimeout) {
@@ -390,13 +390,13 @@ function guessMIMEType(expectedType, buffer) {
390
390
  if (compareBytes([255, 255, 255, 255], [0, 0, 2, 0])) {
391
391
  return "image/x-icon";
392
392
  }
393
- if (compareBytes([255, 255], [78, 77])) {
393
+ if (compareBytes([255, 255], [66, 77])) {
394
394
  return "image/bmp";
395
395
  }
396
396
  if (compareBytes([255, 255, 255, 255, 255, 255], [71, 73, 70, 56, 57, 97])) {
397
397
  return "image/gif";
398
398
  }
399
- if (compareBytes([255, 255, 255, 255, 255, 255], [71, 73, 70, 56, 59, 97])) {
399
+ if (compareBytes([255, 255, 255, 255, 255, 255], [71, 73, 70, 56, 55, 97])) {
400
400
  return "image/gif";
401
401
  }
402
402
  if (compareBytes([255, 255, 255, 255, 0, 0, 0, 0, 255, 255, 255, 255, 255, 255], [82, 73, 70, 70, 0, 0, 0, 0, 87, 69, 66, 80, 86, 80])) {
@@ -434,7 +434,7 @@ function guessMIMEType(expectedType, buffer) {
434
434
  if (compareBytes([0, 0, 0, 0, 255, 255, 255, 255, 255, 255, 255, 255], [0, 0, 0, 0, 102, 116, 121, 112, 105, 115, 111, 109])) {
435
435
  return "video/mp4";
436
436
  }
437
- if (compareBytes([255, 255, 255, 255, 0, 0, 0, 0, 255, 255, 255, 255], [82, 73, 70, 70, 0, 0, 0, 0, 87, 65, 86, 69])) {
437
+ if (compareBytes([255, 255, 255, 255, 0, 0, 0, 0, 255, 255, 255, 255], [82, 73, 70, 70, 0, 0, 0, 0, 65, 86, 73, 32])) {
438
438
  return "video/x-msvideo";
439
439
  }
440
440
  if (compareBytes([255, 255, 255, 255], [0, 0, 1, 179]) || compareBytes([255, 255, 255, 255], [0, 0, 1, 186])) {
@@ -443,18 +443,18 @@ function guessMIMEType(expectedType, buffer) {
443
443
  if (compareBytes([255, 255, 255, 255], [79, 103, 103, 83])) {
444
444
  return "video/ogg";
445
445
  }
446
- if (compareBytes([255], [71])) {
447
- return "video/mp2t";
448
- }
449
446
  if (compareBytes([255, 255, 255, 255], [26, 69, 223, 163])) {
450
447
  return "video/webm";
451
448
  }
452
449
  if (compareBytes([0, 0, 0, 0, 255, 255, 255, 255, 255, 255], [0, 0, 0, 0, 102, 116, 121, 112, 51, 103])) {
453
450
  return "video/3gpp";
454
451
  }
452
+ if (compareBytes([255], [71])) {
453
+ return "video/mp2t";
454
+ }
455
455
  }
456
456
  if (expectedType == "audio") {
457
- if (compareBytes([255, 255], [255, 249]) || compareBytes([255, 255], [255, 254])) {
457
+ if (compareBytes([255, 255], [255, 241]) || compareBytes([255, 255], [255, 249]) || compareBytes([255, 255], [255, 254])) {
458
458
  return "audio/aac";
459
459
  }
460
460
  if (compareBytes([255, 255, 255, 255], [77, 84, 104, 100])) {
@@ -735,6 +735,23 @@ zip64 locator states an absolute offset in the original file, so it needs the sh
735
735
  this formula produces and cannot be used to find it. A reader that instead uses the
736
736
  sentinels arithmetically gets a shift in the billions, with no diagnostic.
737
737
 
738
+ Which leaves the record itself to be located without the offset that normally points at
739
+ it. Scan backward from the locator for the `PK\x06\x06` signature and confirm each
740
+ candidate against the record's own size field, the 8 bytes at `p + 4`, which by
741
+ definition excludes the leading 12:
742
+
743
+ ```
744
+ p + 12 + size == locatorPosition
745
+ ```
746
+
747
+ A well-formed archive puts the record immediately before the locator, where it is 56
748
+ bytes long if it carries no extensible data sector, so `locatorPosition - 56` is worth
749
+ testing before scanning at all. The confirmation matters on the archives that miss:
750
+ past the record the scan walks back through the central directory, whose file names and
751
+ extra fields are arbitrary bytes, and past that through entry data, and a four-byte
752
+ signature turns up in bytes nothing constrains. The test settles each candidate against
753
+ the record's own field, so it needs no offset it does not already have.
754
+
738
755
  ### 4.6 Text tools
739
756
 
740
757
  The optional text body (`insertTextBody`) addresses one more consumer: software that
@@ -972,22 +989,24 @@ limit of what the CDATA rung buys here — its value is that real payloads rarel
972
989
  above states a MUST rather than a quality-of-implementation preference — exhaustion is
973
990
  reachable by construction, not only by a payload built to provoke it.
974
991
 
975
- The check MUST be made against the payload's final bytes, for every payload the
976
- writer hides and in every variant that hides one. Every rejection restarts the build
977
- (§6): the wrapper choice changes the bytes preceding the archive, so the archive must
978
- be rewritten at its new position.
979
-
980
- Two fields are patched after that check. The EOCD comment-length field sits at the
981
- end of the ZIP region and is patched under the declared form (§6.1, step 11); the
982
- writer tests the bytes around it again with the final value in place and keeps the raw
983
- form when that value would complete a pattern, since the raw form is always valid. The
984
- `tEXt "ZIP"` length field sits inside the pixel-data wrapper, with the fixed `tEXt`
985
- type and `ZIP` keyword after it, and is written last (step 12). The header is tested
986
- with the rest of the payload, the length as zeros, which cannot join a pattern; the
987
- real length is big-endian, so a pattern byte in it would have to be the most
988
- significant byte of the chunk's size, and the smallest byte any pattern contains, `-`
989
- at 0x2D, puts that size at 0x2D000000 bytes, about 755 MB. The writer refuses to
990
- build a self-extracting PNG variant whose chunk reaches that size rather than
992
+ The selection test above both patterns, on every rung the writer considers — MUST
993
+ be applied to the payload's bytes in their final form, for every payload the writer
994
+ hides and in every variant that hides one. *Final* is the whole of the requirement:
995
+ bytes the writer has yet to settle have not been tested. Every rejection restarts the build (§6):
996
+ the wrapper choice changes the bytes preceding the archive, so the archive must be
997
+ rewritten at its new position.
998
+
999
+ Two fields are patched after that test, and each needs one of its own. The EOCD
1000
+ comment-length field sits at the end of the ZIP region and is patched under the
1001
+ declared form (§6.1, step 11); the writer tests the bytes around it again with the
1002
+ final value in place and keeps the raw form when that value would complete a pattern,
1003
+ since the raw form is always valid. The `tEXt "ZIP"` length field sits inside the pixel-data wrapper,
1004
+ with the fixed `tEXt` type and `ZIP` keyword after it, and is written last (step 12).
1005
+ The header is tested with the rest of the payload, the length as zeros, which cannot
1006
+ join a pattern; the real length is big-endian, so a pattern byte in it would have to be
1007
+ the most significant byte of the chunk's size, and the smallest byte any pattern
1008
+ contains, `-` at 0x2D, puts that size at 0x2D000000 bytes, about 755 MB. The writer
1009
+ refuses to build a self-extracting PNG variant whose chunk reaches that size rather than
991
1010
  re-check the field.
992
1011
 
993
1012
  ### 5.2 The appended-data budget
@@ -1043,6 +1062,15 @@ self-consistent:
1043
1062
  file positions (§4.2), so a reader of the *whole file* never needs prepended-data
1044
1063
  compensation — the repair by which a reader recomputes offsets that disagree with
1045
1064
  the file size. A reader of the recovered ZIP region alone does need it (§4.5).
1065
+
1066
+ The alternative, offsets relative to the start of the region, is not a compatibility
1067
+ problem in itself: a reader that compensates arrives at the same entries, and 7-Zip
1068
+ opens such a file when told the type. What absolute offsets buy is the step before
1069
+ that. The file is a valid archive read as it stands, so it survives format
1070
+ auto-detection — 7-Zip reports a base of 0 and a physical size covering the whole
1071
+ file — and the compensation is confined to the one path that cannot avoid it,
1072
+ universal-mode recovery. Nothing in the format depends on the choice; a writer using
1073
+ the other form produces files this document's readers still open.
1046
1074
  - **PDF offsets are header-relative.** The document's own cross-reference offsets are
1047
1075
  interpreted from the `%PDF-` header, so embedding it needs no rewriting; the writer
1048
1076
  only MUST keep the header inside the scan window (§4.3).
@@ -1766,7 +1794,10 @@ of §5.7.
1766
1794
 
1767
1795
  ### 8.4 The charset round trip, measured
1768
1796
 
1769
- Two claims of §2.1 were verified.
1797
+ Two claims of §2.1 were verified. The first is re-derived on every run by
1798
+ `test/sfz-harness/charset-round-trip.js`, which reads the tables below out of the
1799
+ runtime's own decoders rather than trusting this section, and checks the reverse table
1800
+ the extractor ships against the one the rule of §5.5 produces.
1770
1801
 
1771
1802
  **Which encodings qualify.** Decoding all 256 byte values through each encoding
1772
1803
  defined by the WHATWG standard shows 20 that are injective and never produce U+FFFD:
package/package.json CHANGED
@@ -1,11 +1,11 @@
1
1
  {
2
2
  "name": "single-file-core",
3
- "version": "1.5.123",
3
+ "version": "1.5.125",
4
4
  "description": "SingleFile Core",
5
5
  "author": "Gildas Lormeau",
6
6
  "license": "AGPL-3.0-or-later",
7
7
  "scripts": {
8
- "test": "deno run --allow-read test/sfz-harness/format-rules.js && deno run --allow-read test/sfz-harness/stored-trigger.js && deno run --allow-read test/sfz-harness/check-determinism.js && deno run --allow-read test/sfz-harness/option-wiring.js && deno run --allow-read test/sfz-harness/css-property-filter.js && deno run --allow-read test/sfz-harness/adopted-stylesheets-hook.js && deno run --allow-read test/sfz-harness/css-fonts-minifier.js && deno run --allow-read test/sfz-harness/inlined-functions.js && deno run --allow-read test/sfz-harness/pages-archive.js && deno run --allow-read test/sfz-harness/filename-max-length.js && deno run --allow-read test/sfz-harness/entry-compression.js && deno run --allow-read test/sfz-harness/filename-characters.js && deno run --allow-read test/sfz-harness/byte-map.js && deno run --allow-read test/sfz-harness/zip64.js",
8
+ "test": "deno run --allow-read test/sfz-harness/format-rules.js && deno run --allow-read test/sfz-harness/stored-trigger.js && deno run --allow-read test/sfz-harness/check-determinism.js && deno run --allow-read test/sfz-harness/option-wiring.js && deno run --allow-read test/sfz-harness/css-property-filter.js && deno run --allow-read test/sfz-harness/adopted-stylesheets-hook.js && deno run --allow-read test/sfz-harness/css-fonts-minifier.js && deno run --allow-read test/sfz-harness/inlined-functions.js && deno run --allow-read test/sfz-harness/pages-archive.js && deno run --allow-read test/sfz-harness/filename-max-length.js && deno run --allow-read test/sfz-harness/entry-compression.js && deno run --allow-read test/sfz-harness/filename-characters.js && deno run --allow-read test/sfz-harness/byte-map.js && deno run --allow-read test/sfz-harness/zip64.js && deno run --allow-read test/sfz-harness/charset-round-trip.js",
9
9
  "bump-patch": "npm version patch --no-git-tag-version && npm run bump-commit",
10
10
  "bump-minor": "npm version minor --no-git-tag-version && npm run bump-commit",
11
11
  "bump-major": "npm version major --no-git-tag-version && npm run bump-commit",
@@ -54,7 +54,9 @@ async function display(document, docContent, { disableFramePointerEvents, inPlac
54
54
  if (disableFramePointerEvents) {
55
55
  doc.querySelectorAll("iframe").forEach(element => {
56
56
  const pointerEvents = "pointer-events";
57
- element.style.setProperty("-sf-" + pointerEvents, element.style.getPropertyValue(pointerEvents), element.style.getPropertyPriority(pointerEvents));
57
+ if (element.style.getPropertyValue(pointerEvents) != "none" || element.style.getPropertyPriority(pointerEvents) != "important") {
58
+ element.style.setProperty("--sf-" + pointerEvents, element.style.getPropertyValue(pointerEvents), element.style.getPropertyPriority(pointerEvents));
59
+ }
58
60
  element.style.setProperty(pointerEvents, "none", "important");
59
61
  });
60
62
  }
@@ -177,7 +177,7 @@ function initRequestSync(message) {
177
177
  if (!TOP_WINDOW) {
178
178
  windowId = globalThis.frameId = message.windowId;
179
179
  }
180
- processFrames(document, message.options, windowId, sessionId);
180
+ processFrames(document, message.options, windowId, sessionId, false);
181
181
  if (!TOP_WINDOW) {
182
182
  sendInitResponse({ frames: [getFrameData(document, globalThis, windowId, message.options, message.scrolling)], sessionId, requestedFrameId: document.documentElement.dataset.requestedFrameId && windowId });
183
183
  delete document.documentElement.dataset.requestedFrameId;
@@ -190,7 +190,7 @@ async function initRequestAsync(message) {
190
190
  if (!TOP_WINDOW) {
191
191
  windowId = globalThis.frameId = message.windowId;
192
192
  }
193
- processFrames(document, message.options, windowId, sessionId);
193
+ processFrames(document, message.options, windowId, sessionId, message.waitForFrames !== false);
194
194
  if (!TOP_WINDOW) {
195
195
  sendInitResponse({ frames: [getFrameData(document, globalThis, windowId, message.options, message.scrolling)], sessionId, requestedFrameId: document.documentElement.dataset.requestedFrameId && windowId });
196
196
  delete document.documentElement.dataset.requestedFrameId;
@@ -254,15 +254,15 @@ function initResponse(message) {
254
254
  }
255
255
  }
256
256
  }
257
- function processFrames(doc, options, parentWindowId, sessionId) {
257
+ function processFrames(doc, options, parentWindowId, sessionId, waitForFrames) {
258
258
  const frameElements = getFrames(doc);
259
- processFramesAsync(doc, frameElements, options, parentWindowId, sessionId);
259
+ processFramesAsync(doc, frameElements, options, parentWindowId, sessionId, waitForFrames);
260
260
  if (frameElements.length) {
261
- processFramesSync(doc, frameElements, options, parentWindowId, sessionId);
261
+ processFramesSync(doc, frameElements, options, parentWindowId, sessionId, waitForFrames);
262
262
  }
263
263
  }
264
264
 
265
- function processFramesAsync(doc, frameElements, options, parentWindowId, sessionId) {
265
+ function processFramesAsync(doc, frameElements, options, parentWindowId, sessionId, waitForFrames) {
266
266
  const frames = [];
267
267
  let requestTimeouts;
268
268
  if (sessions.get(sessionId)) {
@@ -280,17 +280,18 @@ function processFramesAsync(doc, frameElements, options, parentWindowId, session
280
280
  frameElements.forEach((frameElement, frameIndex) => {
281
281
  const windowId = parentWindowId + WINDOW_ID_SEPARATOR + frameIndex;
282
282
  try {
283
- sendMessage(frameElement.contentWindow, { method: INIT_REQUEST_MESSAGE, windowId, sessionId, options, scrolling: frameElement.scrolling });
283
+ sendMessage(frameElement.contentWindow, { method: INIT_REQUEST_MESSAGE, windowId, sessionId, options, scrolling: frameElement.scrolling, waitForFrames });
284
284
  // eslint-disable-next-line no-unused-vars
285
285
  } catch (error) {
286
286
  // ignored
287
287
  }
288
- requestTimeouts[windowId] = globalThis.setTimeout(() => sendInitResponse({ frames: [{ windowId, processed: true }], sessionId }), TIMEOUT_INIT_REQUEST_MESSAGE);
288
+ setFrameFallback(sessionId, windowId, () => getSrcdocFrameData(frameElement, windowId, options, sessionId));
289
+ requestTimeouts[windowId] = globalThis.setTimeout(() => sendInitResponse({ frames: [getFrameFallback(sessionId, windowId) || { windowId, processed: true }], sessionId }), TIMEOUT_INIT_REQUEST_MESSAGE);
289
290
  });
290
291
  delete doc.documentElement.dataset.requestedFrameId;
291
292
  }
292
293
 
293
- function processFramesSync(doc, frameElements, options, parentWindowId, sessionId) {
294
+ function processFramesSync(doc, frameElements, options, parentWindowId, sessionId, waitForFrames) {
294
295
  const frames = [];
295
296
  frameElements.forEach((frameElement, frameIndex) => {
296
297
  const windowId = parentWindowId + WINDOW_ID_SEPARATOR + frameIndex;
@@ -303,21 +304,25 @@ function processFramesSync(doc, frameElements, options, parentWindowId, sessionI
303
304
  } catch (error) {
304
305
  // ignored
305
306
  }
306
- const srcdoc = frameElement.getAttribute("srcdoc");
307
- if (!frameDoc && srcdoc) {
308
- const doc = new DOMParser().parseFromString(srcdoc, "text/html");
309
- frameDoc = doc;
310
- frameWindow = globalThis;
311
- }
312
307
  if (frameDoc) {
313
308
  try {
314
309
  clearFrameTimeout("requestTimeouts", sessionId, windowId);
315
- processFrames(frameDoc, options, windowId, sessionId);
310
+ processFrames(frameDoc, options, windowId, sessionId, waitForFrames);
316
311
  frames.push(getFrameData(frameDoc, frameWindow, windowId, options, frameElement.scrolling));
317
312
  // eslint-disable-next-line no-unused-vars
318
313
  } catch (error) {
319
314
  frames.push({ windowId, processed: true });
320
315
  }
316
+ } else if (!waitForFrames) {
317
+ // the frame is cross-origin or sandboxed, so its document is out of reach. Re-parsing
318
+ // srcdoc is the only source left here, and it is markup only: no script has run and
319
+ // nothing is rendered. When there is time to wait, the fallback is kept for the frames
320
+ // that never answer instead, so a frame that does answer wins with its rendered data
321
+ const fallbackFrameData = getFrameFallback(sessionId, windowId);
322
+ if (fallbackFrameData) {
323
+ clearFrameTimeout("requestTimeouts", sessionId, windowId);
324
+ frames.push(fallbackFrameData);
325
+ }
321
326
  }
322
327
  });
323
328
  sendInitResponse({ frames, sessionId, requestedFrameId: doc.documentElement.dataset.requestedFrameId && parentWindowId });
@@ -338,7 +343,39 @@ function clearFrameTimeout(type, sessionId, windowId) {
338
343
  function createFrameResponseTimeout(sessionId, windowId) {
339
344
  const session = sessions.get(sessionId);
340
345
  if (session && session.responseTimeouts) {
341
- session.responseTimeouts[windowId] = globalThis.setTimeout(() => sendInitResponse({ frames: [{ windowId: windowId, processed: true }], sessionId: sessionId }), TIMEOUT_INIT_RESPONSE_MESSAGE);
346
+ session.responseTimeouts[windowId] = globalThis.setTimeout(() => sendInitResponse({ frames: [getFrameFallback(sessionId, windowId) || { windowId, processed: true }], sessionId: sessionId }), TIMEOUT_INIT_RESPONSE_MESSAGE);
347
+ }
348
+ }
349
+
350
+ function setFrameFallback(sessionId, windowId, getFallbackFrameData) {
351
+ const session = sessions.get(sessionId);
352
+ if (session) {
353
+ if (!session.frameFallbacks) {
354
+ session.frameFallbacks = {};
355
+ }
356
+ session.frameFallbacks[windowId] = getFallbackFrameData;
357
+ }
358
+ }
359
+
360
+ function getFrameFallback(sessionId, windowId) {
361
+ const session = sessions.get(sessionId);
362
+ const getFallbackFrameData = session && session.frameFallbacks && session.frameFallbacks[windowId];
363
+ if (getFallbackFrameData) {
364
+ return getFallbackFrameData();
365
+ }
366
+ }
367
+
368
+ function getSrcdocFrameData(frameElement, windowId, options, sessionId) {
369
+ const srcdoc = frameElement.getAttribute("srcdoc");
370
+ if (srcdoc) {
371
+ try {
372
+ const frameDoc = new DOMParser().parseFromString(srcdoc, "text/html");
373
+ processFrames(frameDoc, options, windowId, sessionId, false);
374
+ return getFrameData(frameDoc, globalThis, windowId, options, frameElement.scrolling);
375
+ // eslint-disable-next-line no-unused-vars
376
+ } catch (error) {
377
+ // ignored
378
+ }
342
379
  }
343
380
  }
344
381
 
@@ -412,6 +412,7 @@
412
412
  }
413
413
  };
414
414
  document.fonts.delete.toString = function () { return "function delete() { [native code] }"; };
415
+ setFunctionName(document.fonts.delete, "delete");
415
416
  const clearFonts = document.fonts.clear;
416
417
  document.fonts.clear = function () {
417
418
  try {
@@ -423,16 +424,16 @@
423
424
  }
424
425
  };
425
426
  document.fonts.clear.toString = function () { return "function clear() { [native code] }"; };
427
+ setFunctionName(document.fonts.clear, "clear");
426
428
  }
427
429
 
428
430
  if (globalThis.IntersectionObserver) {
429
431
  const origIntersectionObserver = globalThis.IntersectionObserver;
430
- globalThis.IntersectionObserver = function IntersectionObserver() {
432
+ globalThis.IntersectionObserver = function IntersectionObserver(callback) {
431
433
  try {
432
434
  const intersectionObserver = new origIntersectionObserver(...arguments);
433
435
  const observeIntersection = origIntersectionObserver.prototype.observe || intersectionObserver.observe;
434
436
  const unobserveIntersection = origIntersectionObserver.prototype.unobserve || intersectionObserver.unobserve;
435
- const callback = arguments[0];
436
437
  const options = arguments[1];
437
438
  if (observeIntersection) {
438
439
  intersectionObserver.observe = function (targetElement) {
@@ -450,6 +451,7 @@
450
451
  }
451
452
  };
452
453
  intersectionObserver.observe.toString = function () { return "function observe() { [native code] }"; };
454
+ setFunctionName(intersectionObserver.observe, "observe");
453
455
  }
454
456
  if (unobserveIntersection) {
455
457
  intersectionObserver.unobserve = function (targetElement) {
@@ -471,6 +473,7 @@
471
473
  }
472
474
  };
473
475
  intersectionObserver.unobserve.toString = function () { return "function unobserve() { [native code] }"; };
476
+ setFunctionName(intersectionObserver.unobserve, "unobserve");
474
477
  }
475
478
  observers.set(intersectionObserver, { callback, options });
476
479
  return intersectionObserver;
@@ -496,6 +499,7 @@
496
499
  }
497
500
  };
498
501
  CSSStyleSheet.prototype.replaceSync.toString = function () { return "function replaceSync() { [native code] }"; };
502
+ setFunctionName(CSSStyleSheet.prototype.replaceSync, "replaceSync");
499
503
  const orginalReplace = CSSStyleSheet.prototype.replace;
500
504
  CSSStyleSheet.prototype.replace = async function (text) {
501
505
  try {
@@ -508,10 +512,11 @@
508
512
  }
509
513
  };
510
514
  CSSStyleSheet.prototype.replace.toString = function () { return "function replace() { [native code] }"; };
515
+ setFunctionName(CSSStyleSheet.prototype.replace, "replace");
511
516
  const originalInsertRule = CSSStyleSheet.prototype.insertRule;
512
- CSSStyleSheet.prototype.insertRule = function (rule, index) {
517
+ CSSStyleSheet.prototype.insertRule = function (rule) {
513
518
  try {
514
- const result = originalInsertRule.apply(this, [rule, index]);
519
+ const result = originalInsertRule.apply(this, [rule, arguments[1]]);
515
520
  adoptedStylesheetsData.delete(this);
516
521
  return result;
517
522
  } catch (error) {
@@ -520,6 +525,7 @@
520
525
  }
521
526
  };
522
527
  CSSStyleSheet.prototype.insertRule.toString = function () { return "function insertRule() { [native code] }"; };
528
+ setFunctionName(CSSStyleSheet.prototype.insertRule, "insertRule");
523
529
  const originalDeleteRule = CSSStyleSheet.prototype.deleteRule;
524
530
  CSSStyleSheet.prototype.deleteRule = function (index) {
525
531
  try {
@@ -532,6 +538,7 @@
532
538
  }
533
539
  };
534
540
  CSSStyleSheet.prototype.deleteRule.toString = function () { return "function deleteRule() { [native code] }"; };
541
+ setFunctionName(CSSStyleSheet.prototype.deleteRule, "deleteRule");
535
542
 
536
543
  // the listener below is reached through the host element, and a closed shadow root is
537
544
  // not reachable from it, so the roots are recorded as they are created
@@ -27,7 +27,7 @@ any check failed.
27
27
 
28
28
  | Script | What it covers |
29
29
  |---|---|
30
- | `format-rules.js` | The rules of the format: charset round trip, the wrapper-tag ladder and its selection tests, the identifier, appended-data placement and declaration, password scope, the PDF and PNG faces. |
30
+ | `format-rules.js` | The rules of the format: the charset declaration and the doctype cap that keeps it inside the scan window, the wrapper-tag ladder and its selection tests, the identifier, appended-data placement and declaration, password scope, the PDF and PNG faces. |
31
31
  | `stored-trigger.js` | That a stored (uncompressed) entry whose bytes contain a rung's pattern moves the writer to the right rung. |
32
32
  | `check-determinism.js` | That the same inputs produce the same bytes, and that the levers which should change the output do. |
33
33
  | `option-wiring.js` | That every option `compression.js` reads is either declared as a caller option or classified as internal, and that `single-file.js` still builds its argument from that declaration. Guards the layer the other suites sit below. |
@@ -40,6 +40,7 @@ any check failed.
40
40
  | `filename-characters.js` | That `getValidFilename` maps a full-width lookalike one character at a time — `C++` used to be saved as `C+` — while a run of characters with no lookalike still collapses to a single replacement. |
41
41
  | `zip64.js` | That the `page.pdf` record injection accounts for the zip64 end of central directory record (§5.7): all four EOCD fields left at their sentinels, the entry counts and directory size carried in the zip64 record, the directory offset pointing at the injected record, and the archive still readable. The branch runs only past 4 GiB or 65535 entries, so nothing reached it before; the suite forces zip64 through `zipWriter.options` from inside the `writeEntries` callback, with no production lever. |
42
42
  | `byte-map.js` | That the byte offsets §8.2 of the specification prints still describe what the writer emits: the prologue order, the doctype and root tag with nothing between them, the identifier's length ahead of the region, absolute EOCD offsets, and the entry order. The specimen §8.2 documents is saved from a live URL and has never been in this repository, so none of its numbers could be checked; three of them were wrong. This builds an equivalent with no network. |
43
+ | `charset-round-trip.js` | That the encoding tables §8.4 prints still describe the WHATWG index: which 20 of the 38 encodings carry all 256 byte values through a decode injectively, the sizes of the reverse tables they need, and the five windows-1252 positions a platform codec of the same name leaves undefined. It also re-derives the reverse table the extractor ships as a literal, which no build step checks and which corrupts one byte per occurrence when wrong. |
43
44
  | `css-fonts-minifier.js` | That `removeUnusedFonts` reads the font families it prunes on correctly: a `var()` family resolved from the values the document declares and not only from the ones the body inherits, every font kept when the value is genuinely undetermined, and a multi-word family name that does not also claim a font named after its own tail. |
44
45
 
45
46
  ## The tools
@@ -0,0 +1,161 @@
1
+ /*
2
+ * Copyright 2010-2026 Gildas Lormeau
3
+ * contact : gildas.lormeau <at> gmail.com
4
+ *
5
+ * This file is part of SingleFile.
6
+ *
7
+ * The code in this file is free software: you can redistribute it and/or
8
+ * modify it under the terms of the GNU Affero General Public License
9
+ * (GNU AGPL) as published by the Free Software Foundation, either version 3
10
+ * of the License, or (at your option) any later version.
11
+ *
12
+ * The code in this file is distributed in the hope that it will be useful,
13
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
14
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero
15
+ * General Public License for more details.
16
+ *
17
+ * As additional permission under GNU AGPL version 3 section 7, you may
18
+ * distribute UNMODIFIED VERSIONS OF THIS file without the copy of the GNU
19
+ * AGPL normally required by section 4, provided you include this license
20
+ * notice and a URL through which recipients can access the Corresponding
21
+ * Source.
22
+ */
23
+
24
+ // Universal mode recovers the ZIP region from the characters the HTML parser produced, so the
25
+ // declared charset has to carry all 256 byte values through a decode injectively (§2.1). Which
26
+ // encodings do is a property of the WHATWG index, not of this repository, and §8.4 prints the
27
+ // answer as a table: 20 qualify, 18 do not, and each qualifying one needs a reverse table of a
28
+ // stated size. Nothing re-derived that table -- it was measured once, by hand, outside the repo,
29
+ // and would go stale silently if an index changed or the prose were edited.
30
+ //
31
+ // The last check is the one with teeth. §5.5 requires the reverse table to be derived from the
32
+ // WHATWG index and NOT from a platform codec of the same name, because most platform codecs
33
+ // leave five windows-1252 positions undefined and those bytes occur in ordinary compressed data.
34
+ // The extractor ships that table as a literal, so it is derived once at authoring time and never
35
+ // again; here it is re-derived from the runtime's own decoder and compared entry by entry.
36
+
37
+ const BYTES = new Uint8Array(256).map((_, index) => index);
38
+
39
+ // the 20 of §8.4, in the order the section lists them
40
+ const QUALIFYING = [
41
+ "windows-1252", "iso-8859-2", "iso-8859-4", "iso-8859-5", "iso-8859-10", "iso-8859-13",
42
+ "iso-8859-14", "iso-8859-15", "iso-8859-16", "koi8-r", "koi8-u", "macintosh", "windows-1250",
43
+ "windows-1251", "windows-1254", "windows-1256", "windows-1258", "x-mac-cyrillic", "ibm866",
44
+ "x-user-defined"
45
+ ];
46
+ // the 18 that do not: eight single-byte encodings with undefined positions in their index, then
47
+ // the multi-byte ones, which decode a lone byte sequence to U+FFFD or to fewer than 256 characters
48
+ const DISQUALIFIED = [
49
+ "iso-8859-3", "iso-8859-6", "iso-8859-7", "iso-8859-8", "windows-874", "windows-1253",
50
+ "windows-1255", "windows-1257",
51
+ "utf-8", "utf-16le", "utf-16be", "gbk", "gb18030", "big5", "euc-jp", "shift_jis", "euc-kr",
52
+ "iso-2022-jp"
53
+ ];
54
+
55
+ let failures = 0;
56
+
57
+ function describe(label) {
58
+ const points = Array.from(new TextDecoder(label).decode(BYTES));
59
+ if (points.length != 256) {
60
+ return { qualifies: false, reason: points.length + " characters" };
61
+ }
62
+ const table = new Map();
63
+ const seen = new Set();
64
+ let identity = 0;
65
+ for (let byte = 0; byte < 256; byte++) {
66
+ const codePoint = points[byte].codePointAt(0);
67
+ if (codePoint == 0xFFFD) {
68
+ return { qualifies: false, reason: "U+FFFD at 0x" + byte.toString(16) };
69
+ }
70
+ if (seen.has(codePoint)) {
71
+ return { qualifies: false, reason: "collision at 0x" + byte.toString(16) };
72
+ }
73
+ seen.add(codePoint);
74
+ if (codePoint == byte) {
75
+ identity++;
76
+ } else {
77
+ table.set(codePoint, byte);
78
+ }
79
+ }
80
+ return { qualifies: true, identity, table, points };
81
+ }
82
+
83
+ const described = new Map([...QUALIFYING, ...DISQUALIFIED].map(label => [label, describe(label)]));
84
+
85
+ check("§8.4 covers the whole standard: 20 qualifying + 18 disqualified",
86
+ QUALIFYING.length + DISQUALIFIED.length == 38 && new Set([...QUALIFYING, ...DISQUALIFIED]).size == 38);
87
+
88
+ const wrongVerdict = [...described].filter(([label, result]) =>
89
+ result.qualifies != QUALIFYING.includes(label));
90
+ check("every encoding falls on the side of the table §8.4 puts it on", wrongVerdict.length == 0,
91
+ wrongVerdict.map(([label, result]) => label + " " + (result.reason || "qualifies")).join(", "));
92
+
93
+ // §8.4 quotes the extremes of the reverse-table sizes; they bound what an implementation has to
94
+ // carry to support any qualifying charset rather than only the one the reference writer declares
95
+ const qualifying = QUALIFYING.filter(label => described.get(label).qualifies);
96
+ const sizes = qualifying.map(label => [label, described.get(label).table.size]);
97
+ const smallest = Math.min(...sizes.map(([, size]) => size));
98
+ const largest = Math.max(...sizes.map(([, size]) => size));
99
+ check("the smallest reverse table is 8 entries, iso-8859-15", smallest == 8 &&
100
+ sizes.filter(([, size]) => size == smallest).map(([label]) => label).join() == "iso-8859-15");
101
+ check("the largest is 128, for koi8-r, koi8-u, ibm866 and x-user-defined", largest == 128 &&
102
+ sizes.filter(([, size]) => size == largest).map(([label]) => label).sort().join() ==
103
+ "ibm866,koi8-r,koi8-u,x-user-defined");
104
+
105
+ const windows1252 = described.get("windows-1252");
106
+ check("windows-1252 decodes 229 of the 256 values to themselves (§5.5 rule 1)",
107
+ windows1252.identity == 229, String(windows1252.identity));
108
+ check("its reverse table is the remaining 27 (§5.5 rule 2)", windows1252.table.size == 27,
109
+ String(windows1252.table.size));
110
+
111
+ // the trap of §5.5: iso-8859-1 is a LABEL of windows-1252, not the identity mapping its name
112
+ // suggests, so a reader that treats it as latin-1 builds a table with no entries at all
113
+ check("iso-8859-1 is a label of windows-1252, not a separate identity encoding",
114
+ new TextDecoder("iso-8859-1").encoding == "windows-1252" &&
115
+ new TextDecoder("latin1").encoding == "windows-1252");
116
+
117
+ // the five positions of the §5.5 table: the WHATWG index assigns them, most platform codecs do not
118
+ check("the WHATWG index assigns 0x81, 0x8D, 0x8F, 0x90 and 0x9D (§5.5)",
119
+ [0x81, 0x8D, 0x8F, 0x90, 0x9D].every(byte =>
120
+ windows1252.points[byte].codePointAt(0) == byte && !windows1252.table.has(byte)));
121
+
122
+ // §8.4's caveat on x-user-defined: it qualifies on the criterion and is still a poor choice
123
+ check("x-user-defined maps 0x80-0xFF into the Private Use Area, U+F780-U+F7FF",
124
+ [...Array(128).keys()].every(index =>
125
+ described.get("x-user-defined").points[128 + index].codePointAt(0) == 0xF780 + index));
126
+
127
+ // the round trip of §5.5 rules 1 and 2, on every byte value and every qualifying charset. NUL and
128
+ // the newlines need rules 3 and 4 in a browser, where the parser has replaced and normalized them;
129
+ // through a decoder alone they arrive intact, so the mapping is exact for all 256 values here
130
+ const broken = qualifying.filter(label => {
131
+ const { table, points } = described.get(label);
132
+ return points.some((character, byte) => {
133
+ const codePoint = character.codePointAt(0);
134
+ return (table.has(codePoint) ? table.get(codePoint) : codePoint) != byte;
135
+ });
136
+ });
137
+ check("all 256 byte values survive decode and reverse mapping, under every qualifying charset",
138
+ broken.length == 0, broken.join(", "));
139
+
140
+ // the extractor's own table, re-derived. It is inlined into every archive, so an error here is
141
+ // not caught by any build step and corrupts one byte per occurrence in the recovered region
142
+ const compression = await Deno.readTextFile(new URL("../../processors/compression/compression.js", import.meta.url));
143
+ const literal = compression.slice(compression.indexOf("const characterMap = new Map(["));
144
+ const shipped = new Map([...literal.slice(0, literal.indexOf("]);")).matchAll(/\[(\d+),\s*(\d+)\]/g)]
145
+ .map(([, codePoint, byte]) => [Number(codePoint), Number(byte)]));
146
+ const expected = new Map([[0xFFFD, 0], ...windows1252.table]);
147
+ const wrongEntries = [...expected].filter(([codePoint, byte]) => shipped.get(codePoint) !== byte);
148
+ const extraEntries = [...shipped].filter(([codePoint]) => !expected.has(codePoint));
149
+ check("the extractor's characterMap is the derived windows-1252 table plus U+FFFD (28 entries)",
150
+ shipped.size == 28 && wrongEntries.length == 0 && extraEntries.length == 0,
151
+ "missing/wrong " + JSON.stringify(wrongEntries) + " extra " + JSON.stringify(extraEntries));
152
+
153
+ console.log(failures ? `\n${failures} check(s) FAILED` : "\nall checks passed");
154
+ Deno.exit(failures ? 1 : 0);
155
+
156
+ function check(label, condition, detail) {
157
+ if (!condition) {
158
+ failures++;
159
+ }
160
+ console.log((condition ? "PASS" : "FAIL") + " " + label + (condition || !detail ? "" : ": " + detail));
161
+ }