summaryrefslogtreecommitdiffstats
path: root/toolkit/components/thumbnails/PageThumbUtils.sys.mjs
diff options
context:
space:
mode:
Diffstat (limited to 'toolkit/components/thumbnails/PageThumbUtils.sys.mjs')
-rw-r--r--toolkit/components/thumbnails/PageThumbUtils.sys.mjs434
1 files changed, 434 insertions, 0 deletions
diff --git a/toolkit/components/thumbnails/PageThumbUtils.sys.mjs b/toolkit/components/thumbnails/PageThumbUtils.sys.mjs
new file mode 100644
index 0000000000..61b81518cd
--- /dev/null
+++ b/toolkit/components/thumbnails/PageThumbUtils.sys.mjs
@@ -0,0 +1,434 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/*
+ * Common thumbnailing routines used by various consumers, including
+ * PageThumbs and BackgroundPageThumbs.
+ */
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ BrowserUtils: "resource://gre/modules/BrowserUtils.sys.mjs",
+});
+
+export var PageThumbUtils = {
+ // The default thumbnail size for images
+ THUMBNAIL_DEFAULT_SIZE: 448,
+ // The default background color for page thumbnails.
+ THUMBNAIL_BG_COLOR: "#fff",
+ // The namespace for thumbnail canvas elements.
+ HTML_NAMESPACE: "http://www.w3.org/1999/xhtml",
+
+ /**
+ * Creates a new canvas element in the context of aWindow.
+ *
+ * @param aWindow The document of this window will be used to
+ * create the canvas.
+ * @param aWidth (optional) width of the canvas to create
+ * @param aHeight (optional) height of the canvas to create
+ * @return The newly created canvas.
+ */
+ createCanvas(aWindow, aWidth = 0, aHeight = 0) {
+ let doc = aWindow.document;
+ let canvas = doc.createElementNS(this.HTML_NAMESPACE, "canvas");
+ canvas.mozOpaque = true;
+ canvas.imageSmoothingEnabled = true;
+ let [thumbnailWidth, thumbnailHeight] = this.getThumbnailSize(aWindow);
+ canvas.width = aWidth ? aWidth : thumbnailWidth;
+ canvas.height = aHeight ? aHeight : thumbnailHeight;
+ return canvas;
+ },
+
+ /**
+ * Calculates a preferred initial thumbnail size based based on newtab.css
+ * sizes or a preference for other applications. The sizes should be the same
+ * as set for the tile sizes in newtab.
+ *
+ * @param aWindow (optional) aWindow that is used to calculate the scaling size.
+ * @return The calculated thumbnail size or a default if unable to calculate.
+ */
+ getThumbnailSize(aWindow = null) {
+ if (!this._thumbnailWidth || !this._thumbnailHeight) {
+ let screenManager = Cc["@mozilla.org/gfx/screenmanager;1"].getService(
+ Ci.nsIScreenManager
+ );
+ let left = {},
+ top = {},
+ screenWidth = {},
+ screenHeight = {};
+ screenManager.primaryScreen.GetRectDisplayPix(
+ left,
+ top,
+ screenWidth,
+ screenHeight
+ );
+
+ /**
+ * The primary monitor default scale might be different than
+ * what is reported by the window on mixed-DPI systems.
+ * To get the best image quality, query both and take the highest one.
+ */
+ let primaryScale = screenManager.primaryScreen.defaultCSSScaleFactor;
+ let windowScale = aWindow ? aWindow.devicePixelRatio : primaryScale;
+ let scale = Math.max(primaryScale, windowScale);
+
+ /** *
+ * THESE VALUES ARE DEFINED IN newtab.css and hard coded.
+ * If you change these values from the prefs,
+ * ALSO CHANGE THEM IN newtab.css
+ */
+ let prefWidth = Services.prefs.getIntPref("toolkit.pageThumbs.minWidth");
+ let prefHeight = Services.prefs.getIntPref(
+ "toolkit.pageThumbs.minHeight"
+ );
+ let divisor = Services.prefs.getIntPref(
+ "toolkit.pageThumbs.screenSizeDivisor"
+ );
+
+ prefWidth *= scale;
+ prefHeight *= scale;
+
+ this._thumbnailWidth = Math.max(
+ Math.round(screenWidth.value / divisor),
+ prefWidth
+ );
+ this._thumbnailHeight = Math.max(
+ Math.round(screenHeight.value / divisor),
+ prefHeight
+ );
+ }
+
+ return [this._thumbnailWidth, this._thumbnailHeight];
+ },
+
+ /** *
+ * Given a browser window, return the size of the content
+ * minus the scroll bars.
+ */
+ getContentSize(aWindow) {
+ let utils = aWindow.windowUtils;
+ let sbWidth = {};
+ let sbHeight = {};
+
+ try {
+ utils.getScrollbarSize(false, sbWidth, sbHeight);
+ } catch (e) {
+ // This might fail if the window does not have a presShell.
+ console.error("Unable to get scrollbar size in determineCropSize.");
+ sbWidth.value = sbHeight.value = 0;
+ }
+
+ // Even in RTL mode, scrollbars are always on the right.
+ // So there's no need to determine a left offset.
+ let width = aWindow.innerWidth - sbWidth.value;
+ let height = aWindow.innerHeight - sbHeight.value;
+
+ return [width, height];
+ },
+
+ /**
+ * Renders an image onto a new canvas of a given width and proportional
+ * height. Uses an image that exists in the window and is loaded, or falls
+ * back to loading the url into a new image element.
+ */
+ async createImageThumbnailCanvas(
+ window,
+ url,
+ targetWidth = 448,
+ backgroundColor = this.THUMBNAIL_BG_COLOR
+ ) {
+ // 224px is the width of cards in ActivityStream; capture thumbnails at 2x
+ const doc = (window || Services.appShell.hiddenDOMWindow).document;
+
+ let image = doc.querySelector("img");
+ if (!image) {
+ image = doc.createElementNS(this.HTML_NAMESPACE, "img");
+ await new Promise((resolve, reject) => {
+ image.onload = () => resolve();
+ image.onerror = () => reject(new Error("LOAD_FAILED"));
+ image.src = url;
+ });
+ }
+
+ // <img src="*.svg"> has width/height but not naturalWidth/naturalHeight
+ const imageWidth = image.naturalWidth || image.width;
+ const imageHeight = image.naturalHeight || image.height;
+ if (imageWidth === 0 || imageHeight === 0) {
+ throw new Error("IMAGE_ZERO_DIMENSION");
+ }
+ const width = Math.min(targetWidth, imageWidth);
+ const height = (imageHeight * width) / imageWidth;
+
+ // As we're setting the width and maintaining the aspect ratio, if an image
+ // is very tall we might get a very large thumbnail. Restricting the canvas
+ // size to {width}x{width} solves this problem. Here we choose to clip the
+ // image at the bottom rather than centre it vertically, based on an
+ // estimate that the focus of a tall image is most likely to be near the top
+ // (e.g., the face of a person).
+ const canvasHeight = Math.min(height, width);
+ const canvas = this.createCanvas(window, width, canvasHeight);
+ const context = canvas.getContext("2d");
+ context.fillStyle = backgroundColor;
+ context.fillRect(0, 0, width, canvasHeight);
+ context.drawImage(image, 0, 0, width, height);
+
+ return {
+ width,
+ height: canvasHeight,
+ imageData: canvas.toDataURL(),
+ };
+ },
+
+ /**
+ * Given a browser, this creates a snapshot of the content
+ * and returns a canvas with the resulting snapshot of the content
+ * at the thumbnail size. It has to do this through a two step process:
+ *
+ * 1) Render the content at the window size to a canvas that is 2x the thumbnail size
+ * 2) Downscale the canvas from (1) down to the thumbnail size
+ *
+ * This is because the thumbnail size is too small to render at directly,
+ * causing pages to believe the browser is a small resolution. Also,
+ * at that resolution, graphical artifacts / text become very jagged.
+ * It's actually better to the eye to have small blurry text than sharp
+ * jagged pixels to represent text.
+ *
+ * @params aBrowser - the browser to create a snapshot of.
+ * @params aDestCanvas destination canvas to draw the final
+ * snapshot to. Can be null.
+ * @param aArgs (optional) Additional named parameters:
+ * fullScale - request that a non-downscaled image be returned.
+ * @return Canvas with a scaled thumbnail of the window.
+ */
+ async createSnapshotThumbnail(aBrowser, aDestCanvas, aArgs) {
+ const aWindow = aBrowser.contentWindow;
+ let backgroundColor = aArgs
+ ? aArgs.backgroundColor
+ : PageThumbUtils.THUMBNAIL_BG_COLOR;
+ let fullScale = aArgs ? aArgs.fullScale : false;
+ let [contentWidth, contentHeight] = this.getContentSize(aWindow);
+ let [thumbnailWidth, thumbnailHeight] = aDestCanvas
+ ? [aDestCanvas.width, aDestCanvas.height]
+ : this.getThumbnailSize(aWindow);
+
+ // If the caller wants a fullscale image, set the desired thumbnail dims
+ // to the dims of content and (if provided) size the incoming canvas to
+ // support our results.
+ if (fullScale) {
+ thumbnailWidth = contentWidth;
+ thumbnailHeight = contentHeight;
+ if (aDestCanvas) {
+ aDestCanvas.width = contentWidth;
+ aDestCanvas.height = contentHeight;
+ }
+ }
+
+ let intermediateWidth = thumbnailWidth * 2;
+ let intermediateHeight = thumbnailHeight * 2;
+ let skipDownscale = false;
+
+ // If the intermediate thumbnail is larger than content dims (hiDPI
+ // devices can experience this) or a full preview is requested render
+ // at the final thumbnail size.
+ if (
+ intermediateWidth >= contentWidth ||
+ intermediateHeight >= contentHeight ||
+ fullScale
+ ) {
+ intermediateWidth = thumbnailWidth;
+ intermediateHeight = thumbnailHeight;
+ skipDownscale = true;
+ }
+
+ // Create an intermediate surface
+ let snapshotCanvas = this.createCanvas(
+ aWindow,
+ intermediateWidth,
+ intermediateHeight
+ );
+
+ // Step 1: capture the image at the intermediate dims. For thumbnails
+ // this is twice the thumbnail size, for fullScale images this is at
+ // content dims.
+ // Also by default, canvas does not draw the scrollbars, so no need to
+ // remove the scrollbar sizes.
+ let scale = Math.min(
+ Math.max(
+ intermediateWidth / contentWidth,
+ intermediateHeight / contentHeight
+ ),
+ 1
+ );
+
+ let snapshotCtx = snapshotCanvas.getContext("2d");
+ snapshotCtx.save();
+ snapshotCtx.scale(scale, scale);
+ const image = await aBrowser.drawSnapshot(
+ 0,
+ 0,
+ contentWidth,
+ contentHeight,
+ scale,
+ backgroundColor
+ );
+ snapshotCtx.drawImage(image, 0, 0, contentWidth, contentHeight);
+ snapshotCtx.restore();
+
+ // Part 2: Downscale from our intermediate dims to the final thumbnail
+ // dims and copy the result to aDestCanvas. If the caller didn't
+ // provide a target canvas, create a new canvas and return it.
+ let finalCanvas =
+ aDestCanvas ||
+ this.createCanvas(aWindow, thumbnailWidth, thumbnailHeight);
+
+ let finalCtx = finalCanvas.getContext("2d");
+ finalCtx.save();
+ if (!skipDownscale) {
+ finalCtx.scale(0.5, 0.5);
+ }
+ finalCtx.drawImage(snapshotCanvas, 0, 0);
+ finalCtx.restore();
+
+ return finalCanvas;
+ },
+
+ /**
+ * Determine a good thumbnail crop size and scale for a given content
+ * window.
+ *
+ * @param aWindow The content window.
+ * @param aCanvas The target canvas.
+ * @return An array containing width, height and scale.
+ */
+ determineCropSize(aWindow, aCanvas) {
+ let utils = aWindow.windowUtils;
+ let sbWidth = {};
+ let sbHeight = {};
+
+ try {
+ utils.getScrollbarSize(false, sbWidth, sbHeight);
+ } catch (e) {
+ // This might fail if the window does not have a presShell.
+ console.error("Unable to get scrollbar size in determineCropSize.");
+ sbWidth.value = sbHeight.value = 0;
+ }
+
+ // Even in RTL mode, scrollbars are always on the right.
+ // So there's no need to determine a left offset.
+ let width = aWindow.innerWidth - sbWidth.value;
+ let height = aWindow.innerHeight - sbHeight.value;
+
+ let { width: thumbnailWidth, height: thumbnailHeight } = aCanvas;
+ let scale = Math.min(
+ Math.max(thumbnailWidth / width, thumbnailHeight / height),
+ 1
+ );
+ let scaledWidth = width * scale;
+ let scaledHeight = height * scale;
+
+ if (scaledHeight > thumbnailHeight) {
+ height -= Math.floor(Math.abs(scaledHeight - thumbnailHeight) * scale);
+ }
+
+ if (scaledWidth > thumbnailWidth) {
+ width -= Math.floor(Math.abs(scaledWidth - thumbnailWidth) * scale);
+ }
+
+ return [width, height, scale];
+ },
+
+ shouldStoreContentThumbnail(aDocument, aDocShell) {
+ if (lazy.BrowserUtils.isFindbarVisible(aDocShell)) {
+ return false;
+ }
+
+ // FIXME Bug 720575 - Don't capture thumbnails for SVG or XML documents as
+ // that currently regresses Talos SVG tests.
+ if (ChromeUtils.getClassName(aDocument) === "XMLDocument") {
+ return false;
+ }
+
+ let webNav = aDocShell.QueryInterface(Ci.nsIWebNavigation);
+
+ // Don't take screenshots of about: pages.
+ if (webNav.currentURI.schemeIs("about")) {
+ return false;
+ }
+
+ // There's no point in taking screenshot of loading pages.
+ if (aDocShell.busyFlags != Ci.nsIDocShell.BUSY_FLAGS_NONE) {
+ return false;
+ }
+
+ let channel = aDocShell.currentDocumentChannel;
+
+ // No valid document channel. We shouldn't take a screenshot.
+ if (!channel) {
+ return false;
+ }
+
+ // Don't take screenshots of internally redirecting about: pages.
+ // This includes error pages.
+ let uri = channel.originalURI;
+ if (uri.schemeIs("about")) {
+ return false;
+ }
+
+ let httpChannel;
+ try {
+ httpChannel = channel.QueryInterface(Ci.nsIHttpChannel);
+ } catch (e) {
+ /* Not an HTTP channel. */
+ }
+
+ if (httpChannel) {
+ // Continue only if we have a 2xx status code.
+ try {
+ if (Math.floor(httpChannel.responseStatus / 100) != 2) {
+ return false;
+ }
+ } catch (e) {
+ // Can't get response information from the httpChannel
+ // because mResponseHead is not available.
+ return false;
+ }
+
+ // Cache-Control: no-store.
+ if (httpChannel.isNoStoreResponse()) {
+ return false;
+ }
+
+ // Don't capture HTTPS pages unless the user explicitly enabled it.
+ if (
+ uri.schemeIs("https") &&
+ !Services.prefs.getBoolPref("browser.cache.disk_cache_ssl")
+ ) {
+ return false;
+ }
+ } // httpChannel
+ return true;
+ },
+
+ /**
+ * Given a channel, returns true if it should be considered an "error
+ * response", false otherwise.
+ */
+ isChannelErrorResponse(channel) {
+ // No valid document channel sounds like an error to me!
+ if (!channel) {
+ return true;
+ }
+ if (!(channel instanceof Ci.nsIHttpChannel)) {
+ // it might be FTP etc, so assume it's ok.
+ return false;
+ }
+ try {
+ return !channel.requestSucceeded;
+ } catch (_) {
+ // not being able to determine success is surely failure!
+ return true;
+ }
+ },
+};