1/* This Source Code Form is subject to the terms of the Mozilla Public 2 * License, v. 2.0. If a copy of the MPL was not distributed with this 3 * file, you can obtain one at http://mozilla.org/MPL/2.0/. */ 4 5/* 6 * Common thumbnailing routines used by various consumers, including 7 * PageThumbs and BackgroundPageThumbs. 8 */ 9 10var EXPORTED_SYMBOLS = ["PageThumbUtils"]; 11 12const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm"); 13 14ChromeUtils.defineModuleGetter( 15 this, 16 "BrowserUtils", 17 "resource://gre/modules/BrowserUtils.jsm" 18); 19 20var PageThumbUtils = { 21 // The default thumbnail size for images 22 THUMBNAIL_DEFAULT_SIZE: 448, 23 // The default background color for page thumbnails. 24 THUMBNAIL_BG_COLOR: "#fff", 25 // The namespace for thumbnail canvas elements. 26 HTML_NAMESPACE: "http://www.w3.org/1999/xhtml", 27 28 /** 29 * Creates a new canvas element in the context of aWindow. 30 * 31 * @param aWindow The document of this window will be used to 32 * create the canvas. 33 * @param aWidth (optional) width of the canvas to create 34 * @param aHeight (optional) height of the canvas to create 35 * @return The newly created canvas. 36 */ 37 createCanvas(aWindow, aWidth = 0, aHeight = 0) { 38 let doc = aWindow.document; 39 let canvas = doc.createElementNS(this.HTML_NAMESPACE, "canvas"); 40 canvas.mozOpaque = true; 41 canvas.imageSmoothingEnabled = true; 42 let [thumbnailWidth, thumbnailHeight] = this.getThumbnailSize(aWindow); 43 canvas.width = aWidth ? aWidth : thumbnailWidth; 44 canvas.height = aHeight ? aHeight : thumbnailHeight; 45 return canvas; 46 }, 47 48 /** 49 * Calculates a preferred initial thumbnail size based based on newtab.css 50 * sizes or a preference for other applications. The sizes should be the same 51 * as set for the tile sizes in newtab. 52 * 53 * @param aWindow (optional) aWindow that is used to calculate the scaling size. 54 * @return The calculated thumbnail size or a default if unable to calculate. 55 */ 56 getThumbnailSize(aWindow = null) { 57 if (!this._thumbnailWidth || !this._thumbnailHeight) { 58 let screenManager = Cc["@mozilla.org/gfx/screenmanager;1"].getService( 59 Ci.nsIScreenManager 60 ); 61 let left = {}, 62 top = {}, 63 screenWidth = {}, 64 screenHeight = {}; 65 screenManager.primaryScreen.GetRectDisplayPix( 66 left, 67 top, 68 screenWidth, 69 screenHeight 70 ); 71 72 /** 73 * The primary monitor default scale might be different than 74 * what is reported by the window on mixed-DPI systems. 75 * To get the best image quality, query both and take the highest one. 76 */ 77 let primaryScale = screenManager.primaryScreen.defaultCSSScaleFactor; 78 let windowScale = aWindow ? aWindow.devicePixelRatio : primaryScale; 79 let scale = Math.max(primaryScale, windowScale); 80 81 /** * 82 * THESE VALUES ARE DEFINED IN newtab.css and hard coded. 83 * If you change these values from the prefs, 84 * ALSO CHANGE THEM IN newtab.css 85 */ 86 let prefWidth = Services.prefs.getIntPref("toolkit.pageThumbs.minWidth"); 87 let prefHeight = Services.prefs.getIntPref( 88 "toolkit.pageThumbs.minHeight" 89 ); 90 let divisor = Services.prefs.getIntPref( 91 "toolkit.pageThumbs.screenSizeDivisor" 92 ); 93 94 prefWidth *= scale; 95 prefHeight *= scale; 96 97 this._thumbnailWidth = Math.max( 98 Math.round(screenWidth.value / divisor), 99 prefWidth 100 ); 101 this._thumbnailHeight = Math.max( 102 Math.round(screenHeight.value / divisor), 103 prefHeight 104 ); 105 } 106 107 return [this._thumbnailWidth, this._thumbnailHeight]; 108 }, 109 110 /** * 111 * Given a browser window, return the size of the content 112 * minus the scroll bars. 113 */ 114 getContentSize(aWindow) { 115 let utils = aWindow.windowUtils; 116 let sbWidth = {}; 117 let sbHeight = {}; 118 119 try { 120 utils.getScrollbarSize(false, sbWidth, sbHeight); 121 } catch (e) { 122 // This might fail if the window does not have a presShell. 123 Cu.reportError("Unable to get scrollbar size in determineCropSize."); 124 sbWidth.value = sbHeight.value = 0; 125 } 126 127 // Even in RTL mode, scrollbars are always on the right. 128 // So there's no need to determine a left offset. 129 let width = aWindow.innerWidth - sbWidth.value; 130 let height = aWindow.innerHeight - sbHeight.value; 131 132 return [width, height]; 133 }, 134 135 /** 136 * Renders an image onto a new canvas of a given width and proportional 137 * height. Uses an image that exists in the window and is loaded, or falls 138 * back to loading the url into a new image element. 139 */ 140 async createImageThumbnailCanvas( 141 window, 142 url, 143 targetWidth = 448, 144 backgroundColor = this.THUMBNAIL_BG_COLOR 145 ) { 146 // 224px is the width of cards in ActivityStream; capture thumbnails at 2x 147 const doc = (window || Services.appShell.hiddenDOMWindow).document; 148 149 let image = doc.querySelector("img"); 150 if (!image) { 151 image = doc.createElementNS(this.HTML_NAMESPACE, "img"); 152 await new Promise((resolve, reject) => { 153 image.onload = () => resolve(); 154 image.onerror = () => reject(new Error("LOAD_FAILED")); 155 image.src = url; 156 }); 157 } 158 159 // <img src="*.svg"> has width/height but not naturalWidth/naturalHeight 160 const imageWidth = image.naturalWidth || image.width; 161 const imageHeight = image.naturalHeight || image.height; 162 if (imageWidth === 0 || imageHeight === 0) { 163 throw new Error("IMAGE_ZERO_DIMENSION"); 164 } 165 const width = Math.min(targetWidth, imageWidth); 166 const height = (imageHeight * width) / imageWidth; 167 168 // As we're setting the width and maintaining the aspect ratio, if an image 169 // is very tall we might get a very large thumbnail. Restricting the canvas 170 // size to {width}x{width} solves this problem. Here we choose to clip the 171 // image at the bottom rather than centre it vertically, based on an 172 // estimate that the focus of a tall image is most likely to be near the top 173 // (e.g., the face of a person). 174 const canvasHeight = Math.min(height, width); 175 const canvas = this.createCanvas(window, width, canvasHeight); 176 const context = canvas.getContext("2d"); 177 context.fillStyle = backgroundColor; 178 context.fillRect(0, 0, width, canvasHeight); 179 context.drawImage(image, 0, 0, width, height); 180 181 return { 182 width, 183 height: canvasHeight, 184 imageData: canvas.toDataURL(), 185 }; 186 }, 187 188 /** 189 * Given a browser, this creates a snapshot of the content 190 * and returns a canvas with the resulting snapshot of the content 191 * at the thumbnail size. It has to do this through a two step process: 192 * 193 * 1) Render the content at the window size to a canvas that is 2x the thumbnail size 194 * 2) Downscale the canvas from (1) down to the thumbnail size 195 * 196 * This is because the thumbnail size is too small to render at directly, 197 * causing pages to believe the browser is a small resolution. Also, 198 * at that resolution, graphical artifacts / text become very jagged. 199 * It's actually better to the eye to have small blurry text than sharp 200 * jagged pixels to represent text. 201 * 202 * @params aBrowser - the browser to create a snapshot of. 203 * @params aDestCanvas destination canvas to draw the final 204 * snapshot to. Can be null. 205 * @param aArgs (optional) Additional named parameters: 206 * fullScale - request that a non-downscaled image be returned. 207 * @return Canvas with a scaled thumbnail of the window. 208 */ 209 async createSnapshotThumbnail(aBrowser, aDestCanvas, aArgs) { 210 const aWindow = aBrowser.contentWindow; 211 let backgroundColor = aArgs 212 ? aArgs.backgroundColor 213 : PageThumbUtils.THUMBNAIL_BG_COLOR; 214 let fullScale = aArgs ? aArgs.fullScale : false; 215 let [contentWidth, contentHeight] = this.getContentSize(aWindow); 216 let [thumbnailWidth, thumbnailHeight] = aDestCanvas 217 ? [aDestCanvas.width, aDestCanvas.height] 218 : this.getThumbnailSize(aWindow); 219 220 // If the caller wants a fullscale image, set the desired thumbnail dims 221 // to the dims of content and (if provided) size the incoming canvas to 222 // support our results. 223 if (fullScale) { 224 thumbnailWidth = contentWidth; 225 thumbnailHeight = contentHeight; 226 if (aDestCanvas) { 227 aDestCanvas.width = contentWidth; 228 aDestCanvas.height = contentHeight; 229 } 230 } 231 232 let intermediateWidth = thumbnailWidth * 2; 233 let intermediateHeight = thumbnailHeight * 2; 234 let skipDownscale = false; 235 236 // If the intermediate thumbnail is larger than content dims (hiDPI 237 // devices can experience this) or a full preview is requested render 238 // at the final thumbnail size. 239 if ( 240 intermediateWidth >= contentWidth || 241 intermediateHeight >= contentHeight || 242 fullScale 243 ) { 244 intermediateWidth = thumbnailWidth; 245 intermediateHeight = thumbnailHeight; 246 skipDownscale = true; 247 } 248 249 // Create an intermediate surface 250 let snapshotCanvas = this.createCanvas( 251 aWindow, 252 intermediateWidth, 253 intermediateHeight 254 ); 255 256 // Step 1: capture the image at the intermediate dims. For thumbnails 257 // this is twice the thumbnail size, for fullScale images this is at 258 // content dims. 259 // Also by default, canvas does not draw the scrollbars, so no need to 260 // remove the scrollbar sizes. 261 let scale = Math.min( 262 Math.max( 263 intermediateWidth / contentWidth, 264 intermediateHeight / contentHeight 265 ), 266 1 267 ); 268 269 let snapshotCtx = snapshotCanvas.getContext("2d"); 270 snapshotCtx.save(); 271 snapshotCtx.scale(scale, scale); 272 const image = await aBrowser.drawSnapshot( 273 0, 274 0, 275 contentWidth, 276 contentHeight, 277 scale, 278 backgroundColor 279 ); 280 snapshotCtx.drawImage(image, 0, 0, contentWidth, contentHeight); 281 snapshotCtx.restore(); 282 283 // Part 2: Downscale from our intermediate dims to the final thumbnail 284 // dims and copy the result to aDestCanvas. If the caller didn't 285 // provide a target canvas, create a new canvas and return it. 286 let finalCanvas = 287 aDestCanvas || 288 this.createCanvas(aWindow, thumbnailWidth, thumbnailHeight); 289 290 let finalCtx = finalCanvas.getContext("2d"); 291 finalCtx.save(); 292 if (!skipDownscale) { 293 finalCtx.scale(0.5, 0.5); 294 } 295 finalCtx.drawImage(snapshotCanvas, 0, 0); 296 finalCtx.restore(); 297 298 return finalCanvas; 299 }, 300 301 /** 302 * Determine a good thumbnail crop size and scale for a given content 303 * window. 304 * 305 * @param aWindow The content window. 306 * @param aCanvas The target canvas. 307 * @return An array containing width, height and scale. 308 */ 309 determineCropSize(aWindow, aCanvas) { 310 let utils = aWindow.windowUtils; 311 let sbWidth = {}; 312 let sbHeight = {}; 313 314 try { 315 utils.getScrollbarSize(false, sbWidth, sbHeight); 316 } catch (e) { 317 // This might fail if the window does not have a presShell. 318 Cu.reportError("Unable to get scrollbar size in determineCropSize."); 319 sbWidth.value = sbHeight.value = 0; 320 } 321 322 // Even in RTL mode, scrollbars are always on the right. 323 // So there's no need to determine a left offset. 324 let width = aWindow.innerWidth - sbWidth.value; 325 let height = aWindow.innerHeight - sbHeight.value; 326 327 let { width: thumbnailWidth, height: thumbnailHeight } = aCanvas; 328 let scale = Math.min( 329 Math.max(thumbnailWidth / width, thumbnailHeight / height), 330 1 331 ); 332 let scaledWidth = width * scale; 333 let scaledHeight = height * scale; 334 335 if (scaledHeight > thumbnailHeight) { 336 height -= Math.floor(Math.abs(scaledHeight - thumbnailHeight) * scale); 337 } 338 339 if (scaledWidth > thumbnailWidth) { 340 width -= Math.floor(Math.abs(scaledWidth - thumbnailWidth) * scale); 341 } 342 343 return [width, height, scale]; 344 }, 345 346 shouldStoreContentThumbnail(aDocument, aDocShell) { 347 if (BrowserUtils.isFindbarVisible(aDocShell)) { 348 return false; 349 } 350 351 // FIXME Bug 720575 - Don't capture thumbnails for SVG or XML documents as 352 // that currently regresses Talos SVG tests. 353 if (ChromeUtils.getClassName(aDocument) === "XMLDocument") { 354 return false; 355 } 356 357 let webNav = aDocShell.QueryInterface(Ci.nsIWebNavigation); 358 359 // Don't take screenshots of about: pages. 360 if (webNav.currentURI.schemeIs("about")) { 361 return false; 362 } 363 364 // There's no point in taking screenshot of loading pages. 365 if (aDocShell.busyFlags != Ci.nsIDocShell.BUSY_FLAGS_NONE) { 366 return false; 367 } 368 369 let channel = aDocShell.currentDocumentChannel; 370 371 // No valid document channel. We shouldn't take a screenshot. 372 if (!channel) { 373 return false; 374 } 375 376 // Don't take screenshots of internally redirecting about: pages. 377 // This includes error pages. 378 let uri = channel.originalURI; 379 if (uri.schemeIs("about")) { 380 return false; 381 } 382 383 let httpChannel; 384 try { 385 httpChannel = channel.QueryInterface(Ci.nsIHttpChannel); 386 } catch (e) { 387 /* Not an HTTP channel. */ 388 } 389 390 if (httpChannel) { 391 // Continue only if we have a 2xx status code. 392 try { 393 if (Math.floor(httpChannel.responseStatus / 100) != 2) { 394 return false; 395 } 396 } catch (e) { 397 // Can't get response information from the httpChannel 398 // because mResponseHead is not available. 399 return false; 400 } 401 402 // Cache-Control: no-store. 403 if (httpChannel.isNoStoreResponse()) { 404 return false; 405 } 406 407 // Don't capture HTTPS pages unless the user explicitly enabled it. 408 if ( 409 uri.schemeIs("https") && 410 !Services.prefs.getBoolPref("browser.cache.disk_cache_ssl") 411 ) { 412 return false; 413 } 414 } // httpChannel 415 return true; 416 }, 417 418 /** 419 * Given a channel, returns true if it should be considered an "error 420 * response", false otherwise. 421 */ 422 isChannelErrorResponse(channel) { 423 // No valid document channel sounds like an error to me! 424 if (!channel) { 425 return true; 426 } 427 if (!(channel instanceof Ci.nsIHttpChannel)) { 428 // it might be FTP etc, so assume it's ok. 429 return false; 430 } 431 try { 432 return !channel.requestSucceeded; 433 } catch (_) { 434 // not being able to determine success is surely failure! 435 return true; 436 } 437 }, 438}; 439