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