diff options
Diffstat (limited to 'toolkit/components/thumbnails/PageThumbUtils.sys.mjs')
-rw-r--r-- | toolkit/components/thumbnails/PageThumbUtils.sys.mjs | 434 |
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; + } + }, +}; |