single-file-core 1.6.4 → 1.6.6

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
@@ -67,7 +67,12 @@ const INVALID_ELEMENT_ATTRIBUTE_NAME = "data-" + SINGLE_FILE_PREFIX + "invalid-e
67
67
  const ASYNC_SCRIPT_ATTRIBUTE_NAME = "data-" + SINGLE_FILE_PREFIX + "async-script";
68
68
  const FLOW_ELEMENTS_SELECTOR = "*:not(base):not(link):not(meta):not(noscript):not(script):not(style):not(template):not(title)";
69
69
  const KEPT_TAG_NAMES = ["NOSCRIPT", "DISABLED-NOSCRIPT", "META", "LINK", "STYLE", "TITLE", "TEMPLATE", "SOURCE", "OBJECT", "SCRIPT", "HEAD", "BODY"];
70
+ const INVOKABLE_ELEMENTS_SELECTOR = "[popover][id], dialog[id]";
71
+ const INVOKER_ATTRIBUTE_NAMES = ["popovertarget", "commandfor"];
72
+ const INVOKERS_SELECTOR = INVOKER_ATTRIBUTE_NAMES.map(attributeName => "[" + attributeName + "]").join(",");
70
73
  const IGNORED_TAG_NAMES = ["SCRIPT", "NOSCRIPT", "META", "LINK", "TEMPLATE"];
74
+ const ROOT_PSEUDO_ELEMENT_NAMES = [":before", ":after"];
75
+ const BLOCK_CONTAINER_DISPLAY_VALUES = ["block", "flow-root", "list-item", "inline-block", "table-cell", "table-caption"];
71
76
  const REGEXP_SIMPLE_QUOTES_STRING = /^'(.*?)'$/;
72
77
  const REGEXP_DOUBLE_QUOTES_STRING = /^"(.*?)"$/;
73
78
  const FONT_WEIGHTS = {
@@ -78,6 +83,11 @@ const FONT_WEIGHTS = {
78
83
  lighter: "100"
79
84
  };
80
85
  const COMMENT_HEADER_LEGACY = "Archive processed by SingleFile";
86
+ const ELEMENT_NODE_TYPE = 1;
87
+ const TEXT_NODE_TYPE = 3;
88
+ const REGEXP_QUOTED_STRING = /"((?:[^"\\]|\\.)*)"|'((?:[^'\\]|\\.)*)'/g;
89
+ const REGEXP_GENERATED_CONTENT_IMAGE = /(url|image-set|-webkit-image-set|linear-gradient|radial-gradient|conic-gradient)\([^)]*\)/g;
90
+ const REGEXP_BACKSLASH = /\\/;
81
91
  const SINGLE_FILE_UI_ELEMENT_CLASS = "single-file-ui-element";
82
92
  const INFOBAR_TAGNAME = infobar.INFOBAR_TAGNAME;
83
93
  const EMPTY_RESOURCE = "data:,";
@@ -164,10 +174,6 @@ export {
164
174
  getPosterDataURI
165
175
  };
166
176
 
167
- // a poster is a still of a video the reader cannot play, so it is stored in a
168
- // lossy format: the same frame costs an order of magnitude less than as a PNG.
169
- // toDataURL falls back to PNG without reporting it when it does not support the
170
- // format, so the type it returned is tested instead of assumed
171
177
  function normalizeOptions(options) {
172
178
  let normalizedOptions = options;
173
179
  if (options) {
@@ -185,13 +191,18 @@ function normalizeOptions(options) {
185
191
  }
186
192
 
187
193
  function getPosterDataURI(canvasElement) {
188
- for (const contentType of POSTER_CONTENT_TYPES) {
189
- const dataURI = canvasElement.toDataURL(contentType, POSTER_QUALITY);
190
- if (dataURI.startsWith("data:" + contentType)) {
194
+ if (canvasElement.width && canvasElement.height) {
195
+ for (const contentType of POSTER_CONTENT_TYPES) {
196
+ const dataURI = canvasElement.toDataURL(contentType, POSTER_QUALITY);
197
+ if (dataURI.startsWith("data:" + contentType)) {
198
+ return dataURI;
199
+ }
200
+ }
201
+ const dataURI = canvasElement.toDataURL("image/png");
202
+ if (dataURI != EMPTY_RESOURCE) {
191
203
  return dataURI;
192
204
  }
193
205
  }
194
- return canvasElement.toDataURL("image/png");
195
206
  }
196
207
 
197
208
  let userScriptHandlerObserver;
@@ -276,6 +287,9 @@ function preProcessDoc(doc, win, options) {
276
287
  if (win && doc.documentElement) {
277
288
  markInvalidNesting(doc);
278
289
  elementsInfo = getElementsInfo(win, doc, doc.documentElement, options);
290
+ if (options.removeUnusedFonts && doc.defaultView) {
291
+ getRootElementUsedFonts(win, doc.documentElement, elementsInfo);
292
+ }
279
293
  if (options.moveStylesInHead) {
280
294
  doc.querySelectorAll("body style, body ~ style").forEach(element => {
281
295
  const computedStyle = getComputedStyle(win, element);
@@ -292,6 +306,7 @@ function preProcessDoc(doc, win, options) {
292
306
  posters: [],
293
307
  videos: [],
294
308
  usedFonts: [],
309
+ usedFontsCharacters: new Map(),
295
310
  shadowRoots: [],
296
311
  markedElements: []
297
312
  };
@@ -314,6 +329,7 @@ function preProcessDoc(doc, win, options) {
314
329
  posters: elementsInfo.posters,
315
330
  videos: elementsInfo.videos,
316
331
  usedFonts: Array.from(elementsInfo.usedFonts.values()),
332
+ usedFontsCharacters: serializeUsedFontsCharacters(elementsInfo.usedFontsCharacters),
317
333
  shadowRoots: elementsInfo.shadowRoots,
318
334
  referrer,
319
335
  markedElements: elementsInfo.markedElements,
@@ -414,16 +430,19 @@ function fixInvalidNesting(document, NESTING_TRACK_ID_ATTRIBUTE_NAME, preventCle
414
430
  }
415
431
  }
416
432
 
417
- function getElementsInfo(win, doc, element, options, data = { usedFonts: new Map(), canvases: [], images: [], posters: [], videos: [], shadowRoots: [], markedElements: [] }, adoptedStyleSheetsCache = new Map(), ascendantHidden) {
433
+ function getElementsInfo(win, doc, element, options, data = { usedFonts: new Map(), usedFontsCharacters: new Map(), canvases: [], images: [], posters: [], videos: [], shadowRoots: [], markedElements: [] }, adoptedStyleSheetsCache = new Map(), ascendantHidden) {
418
434
  if (element.childNodes) {
419
435
  const elements = Array.from(element.childNodes).filter(node => (node instanceof win.HTMLElement) || (node instanceof win.SVGElement) || (node instanceof globalThis.HTMLElement) || (node instanceof globalThis.SVGElement));
420
436
  elements.forEach(element => {
421
- let elementHidden, elementKept, computedStyle;
437
+ let elementHidden, elementKept, computedStyle, headChild;
422
438
  if (!options.autoSaveExternalSave && (options.removeHiddenElements || options.removeUnusedFonts || options.compressHTML)) {
423
439
  computedStyle = getComputedStyle(win, element);
440
+ if (options.removeHiddenElements || options.removeUnusedFonts) {
441
+ headChild = Boolean(element.closest("html > head"));
442
+ }
424
443
  if ((element instanceof win.HTMLElement) || (element instanceof globalThis.HTMLElement)) {
425
444
  if (options.removeHiddenElements) {
426
- elementKept = ((ascendantHidden || element.closest("html > head")) && KEPT_TAG_NAMES.includes(element.tagName.toUpperCase())) || element.closest("details");
445
+ elementKept = ((ascendantHidden || headChild) && KEPT_TAG_NAMES.includes(element.tagName.toUpperCase())) || element.closest("details") || testInvokedElement(element, doc, data);
427
446
  if (!elementKept) {
428
447
  elementHidden = ascendantHidden || testHiddenElement(element, computedStyle);
429
448
  if (elementHidden && !IGNORED_TAG_NAMES.includes(element.tagName.toUpperCase())) {
@@ -441,11 +460,28 @@ function getElementsInfo(win, doc, element, options, data = { usedFonts: new Map
441
460
  data.markedElements.push(element);
442
461
  }
443
462
  }
444
- if (options.removeUnusedFonts && doc.defaultView) {
445
- getUsedFont(computedStyle, data.usedFonts);
446
- getUsedFont(getComputedStyle(win, element, ":first-letter"), data.usedFonts);
447
- getUsedFont(getComputedStyle(win, element, ":before"), data.usedFonts);
448
- getUsedFont(getComputedStyle(win, element, ":after"), data.usedFonts);
463
+ if (options.removeUnusedFonts && doc.defaultView && !headChild) {
464
+ const elementCharacters = getElementCharacters(win, element);
465
+ getUsedFont(computedStyle, data.usedFonts, data.usedFontsCharacters, elementCharacters);
466
+ getUsedFont(getComputedStyle(win, element, ":first-letter"), data.usedFonts, data.usedFontsCharacters, elementCharacters);
467
+ const beforeStyle = getComputedStyle(win, element, ":before");
468
+ getUsedFont(beforeStyle, data.usedFonts, data.usedFontsCharacters, getPseudoElementCharacters(beforeStyle));
469
+ const afterStyle = getComputedStyle(win, element, ":after");
470
+ getUsedFont(afterStyle, data.usedFonts, data.usedFontsCharacters, getPseudoElementCharacters(afterStyle));
471
+ const display = computedStyle ? computedStyle.getPropertyValue("display") : "";
472
+ const tagName = element.tagName.toUpperCase();
473
+ if (display == "list-item") {
474
+ getUsedFont(getComputedStyle(win, element, "::marker"), data.usedFonts);
475
+ }
476
+ if (BLOCK_CONTAINER_DISPLAY_VALUES.includes(display)) {
477
+ getUsedFont(getComputedStyle(win, element, "::first-line"), data.usedFonts, data.usedFontsCharacters, elementCharacters);
478
+ }
479
+ if ((tagName == "INPUT" || tagName == "TEXTAREA") && element.getAttribute("placeholder")) {
480
+ getUsedFont(getComputedStyle(win, element, "::placeholder"), data.usedFonts, data.usedFontsCharacters, { characters: element.getAttribute("placeholder") });
481
+ }
482
+ if (tagName == "INPUT" && element.type == "file") {
483
+ getUsedFont(getComputedStyle(win, element, "::file-selector-button"), data.usedFonts);
484
+ }
449
485
  }
450
486
  }
451
487
  }
@@ -458,15 +494,15 @@ function getElementsInfo(win, doc, element, options, data = { usedFonts: new Map
458
494
  data.shadowRoots.push(shadowRootInfo);
459
495
  try {
460
496
  if (shadowRoot.adoptedStyleSheets) {
461
-
462
- const listener = event => shadowRootInfo.adoptedStyleSheets = event.detail.adoptedStyleSheets;
463
- shadowRoot.addEventListener(GET_ADOPTED_STYLESHEETS_RESPONSE_EVENT, listener);
464
- shadowRoot.dispatchEvent(new CustomEvent(GET_ADOPTED_STYLESHEETS_REQUEST_EVENT, { bubbles: true }));
465
- if (!shadowRootInfo.adoptedStyleSheets) {
466
- element.dispatchEvent(new CustomEvent(GET_ADOPTED_STYLESHEETS_REQUEST_EVENT, { bubbles: true }));
467
- }
468
- shadowRoot.removeEventListener(GET_ADOPTED_STYLESHEETS_RESPONSE_EVENT, listener);
469
-
497
+
498
+ const listener = event => shadowRootInfo.adoptedStyleSheets = event.detail.adoptedStyleSheets;
499
+ shadowRoot.addEventListener(GET_ADOPTED_STYLESHEETS_RESPONSE_EVENT, listener);
500
+ shadowRoot.dispatchEvent(new CustomEvent(GET_ADOPTED_STYLESHEETS_REQUEST_EVENT, { bubbles: true }));
501
+ if (!shadowRootInfo.adoptedStyleSheets) {
502
+ element.dispatchEvent(new CustomEvent(GET_ADOPTED_STYLESHEETS_REQUEST_EVENT, { bubbles: true }));
503
+ }
504
+ shadowRoot.removeEventListener(GET_ADOPTED_STYLESHEETS_RESPONSE_EVENT, listener);
505
+
470
506
  }
471
507
  // eslint-disable-next-line no-unused-vars
472
508
  } catch (error) {
@@ -527,8 +563,6 @@ function getStylesheetsContent(styleSheets, adoptedStyleSheetsCache = new Map())
527
563
  }
528
564
  }
529
565
 
530
- // an untouched canvas is fully transparent, so it encodes exactly like a blank one of the same
531
- // size: comparing the two is cheaper than reading the pixels back and needs no drawing context
532
566
  function isBlankCanvas(doc, element, dataURI) {
533
567
  const blankElement = doc.createElement("canvas");
534
568
  blankElement.width = element.width;
@@ -545,9 +579,6 @@ function getResourcesInfo(win, doc, element, options, data, elementHidden, compu
545
579
  };
546
580
  try {
547
581
  const dataURI = element.toDataURL("image/png");
548
- // a canvas in a page SingleFile has already saved is empty, no script ran to draw into
549
- // it, and the picture it displays is the background image the previous save left
550
- // behind. An empty bitmap must not overwrite it
551
582
  const backgroundImage = canvasComputedStyle ? canvasComputedStyle.getPropertyValue("background-image") : element.style.getPropertyValue("background-image");
552
583
  if (backgroundImage && backgroundImage != "none" && isBlankCanvas(doc, element, dataURI)) {
553
584
  canvasData.blank = true;
@@ -605,8 +636,6 @@ function getResourcesInfo(win, doc, element, options, data, elementHidden, compu
605
636
  });
606
637
  element.setAttribute(VIDEO_ATTRIBUTE_NAME, data.videos.length - 1);
607
638
  }
608
- // the posters are only inserted when the videos are blocked, capturing them
609
- // otherwise encodes a frame per video for nothing
610
639
  if (options.blockVideos && !element.getAttribute("poster")) {
611
640
  const canvasElement = doc.createElement("canvas");
612
641
  const context = canvasElement.getContext("2d");
@@ -614,9 +643,12 @@ function getResourcesInfo(win, doc, element, options, data, elementHidden, compu
614
643
  canvasElement.height = element.videoHeight;
615
644
  try {
616
645
  context.drawImage(element, 0, 0, canvasElement.width, canvasElement.height);
617
- data.posters.push(getPosterDataURI(canvasElement));
618
- element.setAttribute(POSTER_ATTRIBUTE_NAME, data.posters.length - 1);
619
- data.markedElements.push(element);
646
+ const posterDataURI = getPosterDataURI(canvasElement);
647
+ if (posterDataURI) {
648
+ data.posters.push(posterDataURI);
649
+ element.setAttribute(POSTER_ATTRIBUTE_NAME, data.posters.length - 1);
650
+ data.markedElements.push(element);
651
+ }
620
652
  // eslint-disable-next-line no-unused-vars
621
653
  } catch (error) {
622
654
  // ignored
@@ -660,7 +692,7 @@ function getResourcesInfo(win, doc, element, options, data, elementHidden, compu
660
692
  }
661
693
  }
662
694
 
663
- function getUsedFont(computedStyle, usedFonts) {
695
+ function getUsedFont(computedStyle, usedFonts, usedFontsCharacters, drawnCharacters) {
664
696
  if (computedStyle) {
665
697
  const fontStyle = computedStyle.getPropertyValue("font-style") || "normal";
666
698
  computedStyle.getPropertyValue("font-family").split(",").forEach(fontFamilyName => {
@@ -670,9 +702,97 @@ function getUsedFont(computedStyle, usedFonts) {
670
702
  const fontVariant = computedStyle.getPropertyValue("font-variant") || "normal";
671
703
  const value = [fontFamilyName, fontWeight, fontStyle, fontVariant];
672
704
  usedFonts.set(JSON.stringify(value), [fontFamilyName, fontWeight, fontStyle, fontVariant]);
705
+ if (usedFontsCharacters && drawnCharacters) {
706
+ addUsedFontCharacters(usedFontsCharacters, fontFamilyName, fontStyle, drawnCharacters);
707
+ }
708
+ }
709
+ });
710
+ }
711
+ }
712
+
713
+ function addUsedFontCharacters(usedFontsCharacters, fontFamilyName, fontStyle, drawnCharacters) {
714
+ const key = fontFamilyName + "|" + fontStyle;
715
+ let bucket = usedFontsCharacters.get(key);
716
+ if (!bucket) {
717
+ bucket = { fontFamily: fontFamilyName, fontStyle, charCodes: new Set(), unknown: false };
718
+ usedFontsCharacters.set(key, bucket);
719
+ }
720
+ if (drawnCharacters.unknown) {
721
+ bucket.unknown = true;
722
+ }
723
+ for (const character of drawnCharacters.characters) {
724
+ bucket.charCodes.add(character.codePointAt(0));
725
+ }
726
+ }
727
+
728
+ function getElementCharacters(win, element) {
729
+ let characters = "";
730
+ const tagName = element.tagName && element.tagName.toUpperCase();
731
+ if (tagName == "INPUT" || tagName == "TEXTAREA" || tagName == "BUTTON") {
732
+ characters += (element.value || "") + (element.getAttribute("placeholder") || "");
733
+ }
734
+ if (element.childNodes) {
735
+ Array.from(element.childNodes).forEach(node => {
736
+ if (node.nodeType == TEXT_NODE_TYPE) {
737
+ characters += node.data;
738
+ } else if (node.nodeType == ELEMENT_NODE_TYPE &&
739
+ !((node instanceof win.HTMLElement) || (node instanceof win.SVGElement) ||
740
+ (node instanceof globalThis.HTMLElement) || (node instanceof globalThis.SVGElement))) {
741
+ characters += node.textContent;
673
742
  }
674
743
  });
675
744
  }
745
+ return { characters };
746
+ }
747
+
748
+ // getElementsInfo iterates the children of the element it is given, so the root is never visited.
749
+ // An inherited property costs nothing there, since every descendant carries the root's value, but a
750
+ // pseudo-element on the root has no descendant to speak for it: html::before renders a box with a
751
+ // font of its own and nothing else records it. Only a pseudo that generates content is recorded,
752
+ // because the computed style of a pseudo that draws nothing still reports the inherited family and
753
+ // registering that would mark the whole root font stack as used.
754
+ function getRootElementUsedFonts(win, element, data) {
755
+ ROOT_PSEUDO_ELEMENT_NAMES.forEach(pseudoElementName => {
756
+ const computedStyle = getComputedStyle(win, element, pseudoElementName);
757
+ const drawnCharacters = getPseudoElementCharacters(computedStyle);
758
+ if (drawnCharacters.characters || drawnCharacters.unknown) {
759
+ getUsedFont(computedStyle, data.usedFonts, data.usedFontsCharacters, drawnCharacters);
760
+ }
761
+ });
762
+ }
763
+
764
+ function getPseudoElementCharacters(computedStyle) {
765
+ const content = computedStyle && computedStyle.getPropertyValue("content");
766
+ if (!content || content == "none" || content == "normal") {
767
+ return { characters: "" };
768
+ }
769
+ let characters = "";
770
+ REGEXP_QUOTED_STRING.lastIndex = 0;
771
+ let match = REGEXP_QUOTED_STRING.exec(content);
772
+ while (match) {
773
+ characters += match[1] === undefined ? match[2] : match[1];
774
+ match = REGEXP_QUOTED_STRING.exec(content);
775
+ }
776
+ const remainder = content
777
+ .replace(REGEXP_QUOTED_STRING, "")
778
+ .replace(REGEXP_GENERATED_CONTENT_IMAGE, "")
779
+ .trim();
780
+ return { characters, unknown: Boolean(remainder) || REGEXP_BACKSLASH.test(characters) };
781
+ }
782
+
783
+ function serializeUsedFontsCharacters(usedFontsCharacters) {
784
+ return Array.from(usedFontsCharacters.values()).map(bucket => {
785
+ const ranges = [];
786
+ Array.from(bucket.charCodes).sort((charCode1, charCode2) => charCode1 - charCode2).forEach(charCode => {
787
+ const lastRange = ranges[ranges.length - 1];
788
+ if (lastRange && charCode == lastRange[1] + 1) {
789
+ lastRange[1] = charCode;
790
+ } else {
791
+ ranges.push([charCode, charCode]);
792
+ }
793
+ });
794
+ return [bucket.fontFamily, bucket.fontStyle, ranges, bucket.unknown ? 1 : 0];
795
+ });
676
796
  }
677
797
 
678
798
  function getShadowRoot(element) {
@@ -699,13 +819,30 @@ function normalizeFontFamily(fontFamilyName = "") {
699
819
  return removeQuotes(cssUnescape.process(fontFamilyName.trim())).toLowerCase();
700
820
  }
701
821
 
822
+ function testInvokedElement(element, doc, data) {
823
+ const invokableElement = element.closest(INVOKABLE_ELEMENTS_SELECTOR);
824
+ if (invokableElement) {
825
+ if (!data.invokedIds) {
826
+ data.invokedIds = new Set();
827
+ doc.querySelectorAll(INVOKERS_SELECTOR).forEach(invoker => INVOKER_ATTRIBUTE_NAMES.forEach(attributeName => {
828
+ if (invoker.hasAttribute(attributeName)) {
829
+ data.invokedIds.add(invoker.getAttribute(attributeName));
830
+ }
831
+ }));
832
+ }
833
+ return data.invokedIds.has(invokableElement.id);
834
+ }
835
+ return false;
836
+ }
837
+
702
838
  function testHiddenElement(element, computedStyle) {
703
839
  let hidden = false;
704
840
  if (computedStyle) {
705
841
  const display = computedStyle.getPropertyValue("display");
706
842
  const opacity = computedStyle.getPropertyValue("opacity");
707
843
  const visibility = computedStyle.getPropertyValue("visibility");
708
- hidden = display == "none";
844
+ const tagName = element.tagName && element.tagName.toUpperCase();
845
+ hidden = display == "none" || (visibility == "hidden" && tagName == "IFRAME");
709
846
  if (!hidden && (opacity == "0" || visibility == "hidden") && element.getBoundingClientRect) {
710
847
  const boundingRect = element.getBoundingClientRect();
711
848
  hidden = !boundingRect.width && !boundingRect.height;
package/core/index.js CHANGED
@@ -145,7 +145,8 @@ const STAGES = [{
145
145
  ]
146
146
  }, {
147
147
  sequential: [
148
- { option: "removeAlternativeImages", action: "removeAlternativeImages" }
148
+ { option: "removeAlternativeImages", action: "removeAlternativeImages" },
149
+ { action: "groupDuplicateFonts" }
149
150
  ],
150
151
  parallel: [
151
152
  { option: "removeAlternativeFonts", action: "removeAlternativeFonts" },
@@ -199,6 +200,7 @@ class Runner {
199
200
  this.options.posters = docData.posters;
200
201
  this.options.videos = docData.videos;
201
202
  this.options.usedFonts = docData.usedFonts;
203
+ this.options.usedFontsCharacters = docData.usedFontsCharacters;
202
204
  this.options.shadowRoots = docData.shadowRoots;
203
205
  this.options.referrer = docData.referrer;
204
206
  this.options.adoptedStyleSheets = docData.adoptedStyleSheets;
@@ -222,6 +224,7 @@ class Runner {
222
224
  await this.onprogress(new ProgressEvent(RESOURCES_INITIALIZING, { pageURL: this.options.url, options: this.options }));
223
225
  await this.executeStage(RESOLVE_URLS_STAGE);
224
226
  this.pendingPromises = this.executeStage(REPLACE_DATA_STAGE);
227
+ this.pendingPromises.catch(() => { });
225
228
  if (this.root && this.options.doc) {
226
229
  util.postProcessDoc(this.options.doc, this.markedElements, this.invalidElements);
227
230
  }
@@ -282,7 +285,7 @@ class Runner {
282
285
  log("**** STARTED STAGE", step, "****");
283
286
  }
284
287
  const frame = !this.root;
285
- await this.onprogress(new ProgressEvent(STAGE_STARTED, { pageURL: this.options.url, step, frame, options: this.options }));
288
+ const stageStartedPromise = this.onprogress(new ProgressEvent(STAGE_STARTED, { pageURL: this.options.url, step, frame, options: this.options }));
286
289
  for (const task of STAGES[step].sequential) {
287
290
  let startTime;
288
291
  if (DEBUG) {
@@ -314,6 +317,7 @@ class Runner {
314
317
  } else {
315
318
  parallelTasksPromise = Promise.resolve();
316
319
  }
320
+ await stageStartedPromise;
317
321
  await this.onprogress(new ProgressEvent(STAGE_ENDED, { pageURL: this.options.url, step, frame, options: this.options }));
318
322
  if (DEBUG) {
319
323
  log("**** ENDED STAGE", step, "****");
@@ -341,19 +345,25 @@ class BatchRequest {
341
345
  return new Promise((resolve, reject) => {
342
346
  const requestKey = JSON.stringify([resourceURL, asBinary, expectedType, baseURI, blockMixedContent, contentType]);
343
347
  let resourceRequests = this.requests.get(requestKey);
344
- if (!resourceRequests) {
345
- resourceRequests = [];
346
- this.requests.set(requestKey, resourceRequests);
347
- }
348
- const callbacks = { resolve, reject };
349
- resourceRequests.push(callbacks);
350
- if (groupDuplicates) {
351
- let duplicateRequests = this.duplicates.get(requestKey);
352
- if (!duplicateRequests) {
353
- duplicateRequests = [];
354
- this.duplicates.set(requestKey, duplicateRequests);
348
+ if (this.cancelled) {
349
+ reject();
350
+ } else if (this.running && !resourceRequests) {
351
+ reject(new Error("Resource requested after the batch started: " + resourceURL));
352
+ } else {
353
+ if (!resourceRequests) {
354
+ resourceRequests = [];
355
+ this.requests.set(requestKey, resourceRequests);
356
+ }
357
+ const callbacks = { resolve, reject };
358
+ resourceRequests.push(callbacks);
359
+ if (groupDuplicates) {
360
+ let duplicateRequests = this.duplicates.get(requestKey);
361
+ if (!duplicateRequests) {
362
+ duplicateRequests = [];
363
+ this.duplicates.set(requestKey, duplicateRequests);
364
+ }
365
+ duplicateRequests.push(callbacks);
355
366
  }
356
- duplicateRequests.push(callbacks);
357
367
  }
358
368
  });
359
369
  }
@@ -363,6 +373,7 @@ class BatchRequest {
363
373
  }
364
374
 
365
375
  run(onloadListener, options) {
376
+ this.running = true;
366
377
  const resourceURLs = [...this.requests.keys()];
367
378
  let indexResource = 0;
368
379
  return Promise.all(resourceURLs.map(async requestKey => {
@@ -385,7 +396,6 @@ class BatchRequest {
385
396
  acceptHeaders: options.acceptHeaders,
386
397
  networkTimeout: options.networkTimeout
387
398
  });
388
- await onloadListener({ url: resourceURL });
389
399
  if (!this.cancelled) {
390
400
  const extension = util.getContentTypeExtension(content.contentType) || util.getFilenameExtension(resourceURL, options.filenameReplacedCharacters, options.filenameReplacementCharacter, options.filenameReplacementCharacters);
391
401
  resourceRequests.forEach(callbacks => {
@@ -395,11 +405,10 @@ class BatchRequest {
395
405
  });
396
406
  }
397
407
  } catch (error) {
398
- indexResource = indexResource + 1;
399
- await onloadListener({ url: resourceURL });
400
408
  resourceRequests.forEach(resourceRequest => resourceRequest.reject(error));
401
409
  }
402
410
  this.requests.delete(requestKey);
411
+ await onloadListener({ url: resourceURL });
403
412
  }));
404
413
  }
405
414
 
@@ -427,6 +436,7 @@ const DISABLED_SCRIPT_URI = "javascript:void(0)";
427
436
  const SCRIPT_URI_ATTRIBUTE_NAMES = ["href", "src", "action", "formaction", "data"];
428
437
  const UTF8_CHARSET = "utf-8";
429
438
  const TAINTED_CANVAS_WARNING_MESSAGE = "SingleFile: canvas elements tainted by a cross-origin resource, dropped from the page:";
439
+ const EMPTY_RESOURCE = "data:,";
430
440
 
431
441
  class Processor {
432
442
  constructor(options, processorHelper, batchRequest) {
@@ -457,7 +467,7 @@ class Processor {
457
467
  }
458
468
  this.maxResources = this.batchRequest.getMaxResources();
459
469
  if (!this.options.removeFrames && this.options.frames) {
460
- this.options.frames.forEach(frameData => this.maxResources += frameData.maxResources || 0);
470
+ this.options.frames.forEach(frameData => this.maxResources += frameData.runner ? frameData.runner.batchRequest.getMaxResources() : 0);
461
471
  }
462
472
  this.stats.set("processed", "resources", this.maxResources);
463
473
  }
@@ -785,7 +795,7 @@ class Processor {
785
795
  const attributeValue = element.getAttribute(util.POSTER_ATTRIBUTE_NAME);
786
796
  if (attributeValue) {
787
797
  const posterURL = this.options.posters[Number(attributeValue)];
788
- if (!videoElement.getAttribute("poster") && posterURL) {
798
+ if (!videoElement.getAttribute("poster") && posterURL && posterURL != EMPTY_RESOURCE) {
789
799
  videoElement.setAttribute("poster", posterURL);
790
800
  }
791
801
  }
@@ -1204,12 +1214,15 @@ class Processor {
1204
1214
  } else {
1205
1215
  videoElement = element.parentElement;
1206
1216
  }
1207
- if (!videoElement.poster) {
1217
+ if (!videoElement.poster || videoElement.poster == EMPTY_RESOURCE) {
1208
1218
  const attributeValue = videoElement.getAttribute(util.VIDEO_ATTRIBUTE_NAME);
1209
1219
  if (attributeValue) {
1210
1220
  const videoData = this.options.videos[Number(attributeValue)];
1211
1221
  const src = videoData.src || videoElement.src;
1212
1222
  if (src) {
1223
+ const blankPosterURI = "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='" +
1224
+ (videoData.size.videoWidth || videoData.size.pxWidth) + "' height='" +
1225
+ (videoData.size.videoHeight || videoData.size.pxHeight) + "'%3E%3C/svg%3E";
1213
1226
  const temporaryVideoElement = this.doc.createElement("video");
1214
1227
  temporaryVideoElement.src = src;
1215
1228
  temporaryVideoElement.style.setProperty("width", videoData.size.pxWidth + "px", "important");
@@ -1222,21 +1235,21 @@ class Processor {
1222
1235
  return new Promise(resolve => {
1223
1236
  temporaryVideoElement.currentTime = videoData.currentTime;
1224
1237
  temporaryVideoElement.oncanplay = () => {
1225
- canvasElement.width = videoData.size.videoWidth;
1226
- canvasElement.height = videoData.size.videoHeight;
1238
+ canvasElement.width = temporaryVideoElement.videoWidth || videoData.size.videoWidth;
1239
+ canvasElement.height = temporaryVideoElement.videoHeight || videoData.size.videoHeight;
1227
1240
  context.drawImage(temporaryVideoElement, 0, 0, canvasElement.width, canvasElement.height);
1228
1241
  try {
1229
- videoElement.poster = util.getPosterDataURI(canvasElement);
1242
+ videoElement.poster = util.getPosterDataURI(canvasElement) || blankPosterURI;
1230
1243
  // eslint-disable-next-line no-unused-vars
1231
1244
  } catch (error) {
1232
- videoElement.poster = "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='" + videoData.size.videoWidth + "' height='" + videoData.size.videoHeight + "'%3E%3C/svg%3E";
1245
+ videoElement.poster = blankPosterURI;
1233
1246
  // ignored
1234
1247
  }
1235
1248
  temporaryVideoElement.remove();
1236
1249
  resolve();
1237
1250
  };
1238
1251
  temporaryVideoElement.onerror = () => {
1239
- videoElement.poster = "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='" + videoData.size.videoWidth + "' height='" + videoData.size.videoHeight + "'%3E%3C/svg%3E";
1252
+ videoElement.poster = blankPosterURI;
1240
1253
  temporaryVideoElement.remove();
1241
1254
  resolve();
1242
1255
  };
@@ -1346,19 +1359,20 @@ class Processor {
1346
1359
  stylesheets: [],
1347
1360
  url,
1348
1361
  usedFonts: [],
1362
+ usedFontsCharacters: [],
1349
1363
  videos: [],
1350
1364
  worklets: []
1351
1365
  };
1352
1366
  this.options.frames.push(frameData);
1353
1367
  frameData.windowId = (this.options.windowId || "0") + "." + this.options.frames.length;
1354
1368
  frameElement.setAttribute(util.WIN_ID_ATTRIBUTE_NAME, frameData.windowId);
1355
- await initializeProcessor(frameData, frameElement, null, this.batchRequest, Object.assign({}, this.options));
1369
+ await initializeProcessor(frameData, frameElement, null, Object.assign({}, this.options));
1356
1370
  } else {
1357
1371
  const frameWindowId = frameElement.getAttribute(util.WIN_ID_ATTRIBUTE_NAME);
1358
1372
  if (this.options.frames && frameWindowId) {
1359
1373
  const frameData = this.options.frames.find(frame => frame.windowId == frameWindowId);
1360
1374
  if (frameData && frameData.content) {
1361
- await initializeProcessor(frameData, frameElement, frameWindowId, this.batchRequest, Object.assign({}, this.options));
1375
+ await initializeProcessor(frameData, frameElement, frameWindowId, Object.assign({}, this.options));
1362
1376
  }
1363
1377
  }
1364
1378
  }
@@ -1369,7 +1383,7 @@ class Processor {
1369
1383
  }
1370
1384
  });
1371
1385
 
1372
- async function initializeProcessor(frameData, frameElement, frameWindowId, batchRequest, options) {
1386
+ async function initializeProcessor(frameData, frameElement, frameWindowId, options) {
1373
1387
  options.insertSingleFileComment = false;
1374
1388
  options.insertCanonicalLink = false;
1375
1389
  options.insertMetaNoIndex = false;
@@ -1390,6 +1404,7 @@ class Processor {
1390
1404
  options.posters = frameData.posters;
1391
1405
  options.videos = frameData.videos;
1392
1406
  options.usedFonts = frameData.usedFonts;
1407
+ options.usedFontsCharacters = frameData.usedFontsCharacters;
1393
1408
  options.shadowRoots = frameData.shadowRoots;
1394
1409
  options.scrollPosition = frameData.scrollPosition;
1395
1410
  options.scrolling = frameData.scrolling;
@@ -1398,7 +1413,6 @@ class Processor {
1398
1413
  frameData.frameElement = frameElement;
1399
1414
  await frameData.runner.loadPage();
1400
1415
  await frameData.runner.initialize();
1401
- frameData.maxResources = batchRequest.getMaxResources();
1402
1416
  }
1403
1417
  }
1404
1418
 
@@ -1538,6 +1552,10 @@ class Processor {
1538
1552
  util.removeAlternativeImages(this.doc);
1539
1553
  }
1540
1554
 
1555
+ groupDuplicateFonts() {
1556
+ this.processorHelper.groupDuplicateFonts(this.stylesheets, this.resources.fonts, this.options);
1557
+ }
1558
+
1541
1559
  async removeAlternativeFonts() {
1542
1560
  await this.processorHelper.removeAlternativeFonts(this.doc, this.stylesheets, this.resources.fonts, this.options.fontTests);
1543
1561
  }
@@ -1545,7 +1563,7 @@ class Processor {
1545
1563
  async processFrames() {
1546
1564
  if (this.options.frames) {
1547
1565
  const frameElements = Array.from(this.doc.querySelectorAll("iframe, frame, object[type=\"text/html\"][data]"));
1548
- await Promise.all(frameElements.map(async frameElement => {
1566
+ const capturedFrames = await Promise.all(frameElements.map(async frameElement => {
1549
1567
  const frameWindowId = frameElement.getAttribute(util.WIN_ID_ATTRIBUTE_NAME);
1550
1568
  if (frameWindowId) {
1551
1569
  const frameData = this.options.frames.find(frame => frame.windowId == frameWindowId);
@@ -1556,8 +1574,7 @@ class Processor {
1556
1574
  await frameData.runner.run();
1557
1575
  const pageData = await frameData.runner.getPageData();
1558
1576
  frameElement.removeAttribute(util.WIN_ID_ATTRIBUTE_NAME);
1559
- this.processorHelper.processFrame(frameElement, pageData, this.options, this.resources, frameWindowId, frameData);
1560
- this.stats.addAll(pageData);
1577
+ return { frameElement, pageData, frameWindowId, frameData };
1561
1578
  } else {
1562
1579
  frameElement.removeAttribute(util.WIN_ID_ATTRIBUTE_NAME);
1563
1580
  this.stats.add("discarded", "frames", 1);
@@ -1565,6 +1582,13 @@ class Processor {
1565
1582
  }
1566
1583
  }
1567
1584
  }));
1585
+ capturedFrames.forEach(capturedFrame => {
1586
+ if (capturedFrame) {
1587
+ const { frameElement, pageData, frameWindowId, frameData } = capturedFrame;
1588
+ this.processorHelper.processFrame(frameElement, pageData, this.options, this.resources, frameWindowId, frameData);
1589
+ this.stats.addAll(pageData);
1590
+ }
1591
+ });
1568
1592
  }
1569
1593
  }
1570
1594