single-file-core 1.6.5 → 1.6.7

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,9 @@ const STAGES = [{
145
145
  ]
146
146
  }, {
147
147
  sequential: [
148
- { option: "removeAlternativeImages", action: "removeAlternativeImages" }
148
+ { option: "removeAlternativeImages", action: "removeAlternativeImages" },
149
+ { action: "groupDuplicateFonts" },
150
+ { action: "groupDuplicateImages" }
149
151
  ],
150
152
  parallel: [
151
153
  { option: "removeAlternativeFonts", action: "removeAlternativeFonts" },
@@ -199,6 +201,7 @@ class Runner {
199
201
  this.options.posters = docData.posters;
200
202
  this.options.videos = docData.videos;
201
203
  this.options.usedFonts = docData.usedFonts;
204
+ this.options.usedFontsCharacters = docData.usedFontsCharacters;
202
205
  this.options.shadowRoots = docData.shadowRoots;
203
206
  this.options.referrer = docData.referrer;
204
207
  this.options.adoptedStyleSheets = docData.adoptedStyleSheets;
@@ -222,6 +225,7 @@ class Runner {
222
225
  await this.onprogress(new ProgressEvent(RESOURCES_INITIALIZING, { pageURL: this.options.url, options: this.options }));
223
226
  await this.executeStage(RESOLVE_URLS_STAGE);
224
227
  this.pendingPromises = this.executeStage(REPLACE_DATA_STAGE);
228
+ this.pendingPromises.catch(() => { });
225
229
  if (this.root && this.options.doc) {
226
230
  util.postProcessDoc(this.options.doc, this.markedElements, this.invalidElements);
227
231
  }
@@ -282,7 +286,7 @@ class Runner {
282
286
  log("**** STARTED STAGE", step, "****");
283
287
  }
284
288
  const frame = !this.root;
285
- await this.onprogress(new ProgressEvent(STAGE_STARTED, { pageURL: this.options.url, step, frame, options: this.options }));
289
+ const stageStartedPromise = this.onprogress(new ProgressEvent(STAGE_STARTED, { pageURL: this.options.url, step, frame, options: this.options }));
286
290
  for (const task of STAGES[step].sequential) {
287
291
  let startTime;
288
292
  if (DEBUG) {
@@ -314,6 +318,7 @@ class Runner {
314
318
  } else {
315
319
  parallelTasksPromise = Promise.resolve();
316
320
  }
321
+ await stageStartedPromise;
317
322
  await this.onprogress(new ProgressEvent(STAGE_ENDED, { pageURL: this.options.url, step, frame, options: this.options }));
318
323
  if (DEBUG) {
319
324
  log("**** ENDED STAGE", step, "****");
@@ -341,19 +346,25 @@ class BatchRequest {
341
346
  return new Promise((resolve, reject) => {
342
347
  const requestKey = JSON.stringify([resourceURL, asBinary, expectedType, baseURI, blockMixedContent, contentType]);
343
348
  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);
349
+ if (this.cancelled) {
350
+ reject();
351
+ } else if (this.running && !resourceRequests) {
352
+ reject(new Error("Resource requested after the batch started: " + resourceURL));
353
+ } else {
354
+ if (!resourceRequests) {
355
+ resourceRequests = [];
356
+ this.requests.set(requestKey, resourceRequests);
357
+ }
358
+ const callbacks = { resolve, reject };
359
+ resourceRequests.push(callbacks);
360
+ if (groupDuplicates) {
361
+ let duplicateRequests = this.duplicates.get(requestKey);
362
+ if (!duplicateRequests) {
363
+ duplicateRequests = [];
364
+ this.duplicates.set(requestKey, duplicateRequests);
365
+ }
366
+ duplicateRequests.push(callbacks);
355
367
  }
356
- duplicateRequests.push(callbacks);
357
368
  }
358
369
  });
359
370
  }
@@ -363,6 +374,7 @@ class BatchRequest {
363
374
  }
364
375
 
365
376
  run(onloadListener, options) {
377
+ this.running = true;
366
378
  const resourceURLs = [...this.requests.keys()];
367
379
  let indexResource = 0;
368
380
  return Promise.all(resourceURLs.map(async requestKey => {
@@ -385,7 +397,6 @@ class BatchRequest {
385
397
  acceptHeaders: options.acceptHeaders,
386
398
  networkTimeout: options.networkTimeout
387
399
  });
388
- await onloadListener({ url: resourceURL });
389
400
  if (!this.cancelled) {
390
401
  const extension = util.getContentTypeExtension(content.contentType) || util.getFilenameExtension(resourceURL, options.filenameReplacedCharacters, options.filenameReplacementCharacter, options.filenameReplacementCharacters);
391
402
  resourceRequests.forEach(callbacks => {
@@ -395,11 +406,10 @@ class BatchRequest {
395
406
  });
396
407
  }
397
408
  } catch (error) {
398
- indexResource = indexResource + 1;
399
- await onloadListener({ url: resourceURL });
400
409
  resourceRequests.forEach(resourceRequest => resourceRequest.reject(error));
401
410
  }
402
411
  this.requests.delete(requestKey);
412
+ await onloadListener({ url: resourceURL });
403
413
  }));
404
414
  }
405
415
 
@@ -427,6 +437,8 @@ const DISABLED_SCRIPT_URI = "javascript:void(0)";
427
437
  const SCRIPT_URI_ATTRIBUTE_NAMES = ["href", "src", "action", "formaction", "data"];
428
438
  const UTF8_CHARSET = "utf-8";
429
439
  const TAINTED_CANVAS_WARNING_MESSAGE = "SingleFile: canvas elements tainted by a cross-origin resource, dropped from the page:";
440
+ const EMPTY_RESOURCE = "data:,";
441
+ const ORDER_DEPENDENT_RULE = /@(layer|import)\b/i;
430
442
 
431
443
  class Processor {
432
444
  constructor(options, processorHelper, batchRequest) {
@@ -457,7 +469,7 @@ class Processor {
457
469
  }
458
470
  this.maxResources = this.batchRequest.getMaxResources();
459
471
  if (!this.options.removeFrames && this.options.frames) {
460
- this.options.frames.forEach(frameData => this.maxResources += frameData.maxResources || 0);
472
+ this.options.frames.forEach(frameData => this.maxResources += frameData.runner ? frameData.runner.batchRequest.getMaxResources() : 0);
461
473
  }
462
474
  this.stats.set("processed", "resources", this.maxResources);
463
475
  }
@@ -785,7 +797,7 @@ class Processor {
785
797
  const attributeValue = element.getAttribute(util.POSTER_ATTRIBUTE_NAME);
786
798
  if (attributeValue) {
787
799
  const posterURL = this.options.posters[Number(attributeValue)];
788
- if (!videoElement.getAttribute("poster") && posterURL) {
800
+ if (!videoElement.getAttribute("poster") && posterURL && posterURL != EMPTY_RESOURCE) {
789
801
  videoElement.setAttribute("poster", posterURL);
790
802
  }
791
803
  }
@@ -1204,12 +1216,15 @@ class Processor {
1204
1216
  } else {
1205
1217
  videoElement = element.parentElement;
1206
1218
  }
1207
- if (!videoElement.poster) {
1219
+ if (!videoElement.poster || videoElement.poster == EMPTY_RESOURCE) {
1208
1220
  const attributeValue = videoElement.getAttribute(util.VIDEO_ATTRIBUTE_NAME);
1209
1221
  if (attributeValue) {
1210
1222
  const videoData = this.options.videos[Number(attributeValue)];
1211
1223
  const src = videoData.src || videoElement.src;
1212
1224
  if (src) {
1225
+ const blankPosterURI = "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='" +
1226
+ (videoData.size.videoWidth || videoData.size.pxWidth) + "' height='" +
1227
+ (videoData.size.videoHeight || videoData.size.pxHeight) + "'%3E%3C/svg%3E";
1213
1228
  const temporaryVideoElement = this.doc.createElement("video");
1214
1229
  temporaryVideoElement.src = src;
1215
1230
  temporaryVideoElement.style.setProperty("width", videoData.size.pxWidth + "px", "important");
@@ -1222,21 +1237,21 @@ class Processor {
1222
1237
  return new Promise(resolve => {
1223
1238
  temporaryVideoElement.currentTime = videoData.currentTime;
1224
1239
  temporaryVideoElement.oncanplay = () => {
1225
- canvasElement.width = videoData.size.videoWidth;
1226
- canvasElement.height = videoData.size.videoHeight;
1240
+ canvasElement.width = temporaryVideoElement.videoWidth || videoData.size.videoWidth;
1241
+ canvasElement.height = temporaryVideoElement.videoHeight || videoData.size.videoHeight;
1227
1242
  context.drawImage(temporaryVideoElement, 0, 0, canvasElement.width, canvasElement.height);
1228
1243
  try {
1229
- videoElement.poster = util.getPosterDataURI(canvasElement);
1244
+ videoElement.poster = util.getPosterDataURI(canvasElement) || blankPosterURI;
1230
1245
  // eslint-disable-next-line no-unused-vars
1231
1246
  } 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";
1247
+ videoElement.poster = blankPosterURI;
1233
1248
  // ignored
1234
1249
  }
1235
1250
  temporaryVideoElement.remove();
1236
1251
  resolve();
1237
1252
  };
1238
1253
  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";
1254
+ videoElement.poster = blankPosterURI;
1240
1255
  temporaryVideoElement.remove();
1241
1256
  resolve();
1242
1257
  };
@@ -1261,23 +1276,32 @@ class Processor {
1261
1276
  }
1262
1277
 
1263
1278
  async resolveStylesheetsURLs() {
1264
- const stylesContents = [];
1279
+ const styleElementGroups = new Map();
1265
1280
  this.options.inlineStylesheets = new Map();
1266
1281
  this.options.inlineStylesheetsRefs = new Map();
1267
1282
  this.doc.querySelectorAll("style").forEach(styleElement => {
1268
- if (styleElement.textContent) {
1269
- const indexContent = stylesContents.indexOf(styleElement.textContent);
1270
- if (indexContent == -1) {
1271
- this.options.inlineStylesheets.set(stylesContents.length, {
1272
- styleElement,
1273
- content: styleElement.textContent
1274
- });
1275
- stylesContents.push(styleElement.textContent);
1276
- } else {
1277
- this.options.inlineStylesheetsRefs.set(styleElement, indexContent);
1283
+ const content = styleElement.textContent;
1284
+ if (content) {
1285
+ if (!styleElementGroups.has(content)) {
1286
+ styleElementGroups.set(content, []);
1278
1287
  }
1288
+ styleElementGroups.get(content).push(styleElement);
1279
1289
  }
1280
1290
  });
1291
+ let indexContent = 0;
1292
+ styleElementGroups.forEach((styleElements, content) => {
1293
+ const masterIndex = ORDER_DEPENDENT_RULE.test(content) ? 0 : styleElements.length - 1;
1294
+ this.options.inlineStylesheets.set(indexContent, {
1295
+ styleElement: styleElements[masterIndex],
1296
+ content
1297
+ });
1298
+ styleElements.forEach((styleElement, index) => {
1299
+ if (index != masterIndex) {
1300
+ this.options.inlineStylesheetsRefs.set(styleElement, indexContent);
1301
+ }
1302
+ });
1303
+ indexContent++;
1304
+ });
1281
1305
  await Promise.all(Array.from(this.doc.querySelectorAll("style, link[rel*=stylesheet]:not([disabled])")).map(async element => {
1282
1306
  const options = Object.assign({}, this.options, { charset: this.charset });
1283
1307
  let mediaText;
@@ -1346,19 +1370,20 @@ class Processor {
1346
1370
  stylesheets: [],
1347
1371
  url,
1348
1372
  usedFonts: [],
1373
+ usedFontsCharacters: [],
1349
1374
  videos: [],
1350
1375
  worklets: []
1351
1376
  };
1352
1377
  this.options.frames.push(frameData);
1353
1378
  frameData.windowId = (this.options.windowId || "0") + "." + this.options.frames.length;
1354
1379
  frameElement.setAttribute(util.WIN_ID_ATTRIBUTE_NAME, frameData.windowId);
1355
- await initializeProcessor(frameData, frameElement, null, this.batchRequest, Object.assign({}, this.options));
1380
+ await initializeProcessor(frameData, frameElement, null, Object.assign({}, this.options));
1356
1381
  } else {
1357
1382
  const frameWindowId = frameElement.getAttribute(util.WIN_ID_ATTRIBUTE_NAME);
1358
1383
  if (this.options.frames && frameWindowId) {
1359
1384
  const frameData = this.options.frames.find(frame => frame.windowId == frameWindowId);
1360
1385
  if (frameData && frameData.content) {
1361
- await initializeProcessor(frameData, frameElement, frameWindowId, this.batchRequest, Object.assign({}, this.options));
1386
+ await initializeProcessor(frameData, frameElement, frameWindowId, Object.assign({}, this.options));
1362
1387
  }
1363
1388
  }
1364
1389
  }
@@ -1369,7 +1394,7 @@ class Processor {
1369
1394
  }
1370
1395
  });
1371
1396
 
1372
- async function initializeProcessor(frameData, frameElement, frameWindowId, batchRequest, options) {
1397
+ async function initializeProcessor(frameData, frameElement, frameWindowId, options) {
1373
1398
  options.insertSingleFileComment = false;
1374
1399
  options.insertCanonicalLink = false;
1375
1400
  options.insertMetaNoIndex = false;
@@ -1390,6 +1415,7 @@ class Processor {
1390
1415
  options.posters = frameData.posters;
1391
1416
  options.videos = frameData.videos;
1392
1417
  options.usedFonts = frameData.usedFonts;
1418
+ options.usedFontsCharacters = frameData.usedFontsCharacters;
1393
1419
  options.shadowRoots = frameData.shadowRoots;
1394
1420
  options.scrollPosition = frameData.scrollPosition;
1395
1421
  options.scrolling = frameData.scrolling;
@@ -1398,7 +1424,6 @@ class Processor {
1398
1424
  frameData.frameElement = frameElement;
1399
1425
  await frameData.runner.loadPage();
1400
1426
  await frameData.runner.initialize();
1401
- frameData.maxResources = batchRequest.getMaxResources();
1402
1427
  }
1403
1428
  }
1404
1429
 
@@ -1538,6 +1563,14 @@ class Processor {
1538
1563
  util.removeAlternativeImages(this.doc);
1539
1564
  }
1540
1565
 
1566
+ groupDuplicateFonts() {
1567
+ this.processorHelper.groupDuplicateFonts(this.stylesheets, this.resources.fonts, this.options);
1568
+ }
1569
+
1570
+ groupDuplicateImages() {
1571
+ this.processorHelper.groupDuplicateImages(this.doc, this.stylesheets, this.styles, this.resources.images);
1572
+ }
1573
+
1541
1574
  async removeAlternativeFonts() {
1542
1575
  await this.processorHelper.removeAlternativeFonts(this.doc, this.stylesheets, this.resources.fonts, this.options.fontTests);
1543
1576
  }
@@ -1545,7 +1578,7 @@ class Processor {
1545
1578
  async processFrames() {
1546
1579
  if (this.options.frames) {
1547
1580
  const frameElements = Array.from(this.doc.querySelectorAll("iframe, frame, object[type=\"text/html\"][data]"));
1548
- await Promise.all(frameElements.map(async frameElement => {
1581
+ const capturedFrames = await Promise.all(frameElements.map(async frameElement => {
1549
1582
  const frameWindowId = frameElement.getAttribute(util.WIN_ID_ATTRIBUTE_NAME);
1550
1583
  if (frameWindowId) {
1551
1584
  const frameData = this.options.frames.find(frame => frame.windowId == frameWindowId);
@@ -1556,8 +1589,7 @@ class Processor {
1556
1589
  await frameData.runner.run();
1557
1590
  const pageData = await frameData.runner.getPageData();
1558
1591
  frameElement.removeAttribute(util.WIN_ID_ATTRIBUTE_NAME);
1559
- this.processorHelper.processFrame(frameElement, pageData, this.options, this.resources, frameWindowId, frameData);
1560
- this.stats.addAll(pageData);
1592
+ return { frameElement, pageData, frameWindowId, frameData };
1561
1593
  } else {
1562
1594
  frameElement.removeAttribute(util.WIN_ID_ATTRIBUTE_NAME);
1563
1595
  this.stats.add("discarded", "frames", 1);
@@ -1565,6 +1597,13 @@ class Processor {
1565
1597
  }
1566
1598
  }
1567
1599
  }));
1600
+ capturedFrames.forEach(capturedFrame => {
1601
+ if (capturedFrame) {
1602
+ const { frameElement, pageData, frameWindowId, frameData } = capturedFrame;
1603
+ this.processorHelper.processFrame(frameElement, pageData, this.options, this.resources, frameWindowId, frameData);
1604
+ this.stats.addAll(pageData);
1605
+ }
1606
+ });
1568
1607
  }
1569
1608
  }
1570
1609