Bug 1563746 - [remote] Implement Page.captureScreenshot. r=remote-protocol-reviewers,maja_zf,ato
authorHenrik Skupin <mail@hskupin.info>
Wed, 16 Oct 2019 19:36:56 +0000
changeset 559261 52be0848e5a18173063143b1a3342fa05faad11c
parent 559260 4f934e73d44c0c4ee0e09aa5e4d463b435f4f322
child 559262 56f934c9a812107bf18ec9ce71a5966e23e5b587
push id12175
push userccoroiu@mozilla.com
push dateThu, 17 Oct 2019 19:29:09 +0000
treeherdermozilla-beta@d333b6ef1fd3 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersremote-protocol-reviewers, maja_zf, ato
bugs1563746
milestone71.0a1
first release with
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
last release without
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
Bug 1563746 - [remote] Implement Page.captureScreenshot. r=remote-protocol-reviewers,maja_zf,ato Differential Revision: https://phabricator.services.mozilla.com/D49203
remote/domains/content/Page.jsm
remote/domains/parent/Page.jsm
remote/test/browser/browser.ini
remote/test/browser/browser_page_captureScreenshot.js
--- a/remote/domains/content/Page.jsm
+++ b/remote/domains/content/Page.jsm
@@ -53,16 +53,25 @@ class Page extends ContentProcessDomain 
       });
       this.chromeEventHandler.removeEventListener("pageshow", this, {
         mozSystemGroup: true,
       });
       this.enabled = false;
     }
   }
 
+  _viewportRect() {
+    return new DOMRect(
+      this.content.pageXOffset,
+      this.content.pageYOffset,
+      this.content.innerWidth,
+      this.content.innerHeight
+    );
+  }
+
   async navigate({ url, referrer, transitionType, frameId } = {}) {
     if (frameId && frameId != this.content.windowUtils.outerWindowID) {
       throw new UnsupportedError("frameId not supported");
     }
 
     const opts = {
       loadFlags: transitionToLoadFlag(transitionType),
       referrerURI: referrer,
--- a/remote/domains/parent/Page.jsm
+++ b/remote/domains/parent/Page.jsm
@@ -7,16 +7,19 @@
 var EXPORTED_SYMBOLS = ["Page"];
 
 const { DialogHandler } = ChromeUtils.import(
   "chrome://remote/content/domains/parent/page/DialogHandler.jsm"
 );
 const { Domain } = ChromeUtils.import(
   "chrome://remote/content/domains/Domain.jsm"
 );
+const { UnsupportedError } = ChromeUtils.import(
+  "chrome://remote/content/Error.jsm"
+);
 
 class Page extends Domain {
   constructor(session) {
     super(session);
 
     this._onDialogLoaded = this._onDialogLoaded.bind(this);
 
     this.enabled = false;
@@ -27,16 +30,98 @@ class Page extends Domain {
     this._isDestroyed = false;
     this.disable();
 
     super.destructor();
   }
 
   // commands
 
+  /**
+   * Capture page screenshot.
+   *
+   * @param {Object} options
+   * @param {Viewport=} options.clip (not supported)
+   *     Capture the screenshot of a given region only.
+   * @param {string=} options.format (not supported)
+   *     Image compression format. Defaults to "png".
+   * @param {number=} options.quality (not supported)
+   *     Compression quality from range [0..100] (jpeg only). Defaults to 100.
+   *
+   * @return {string}
+   *     Base64-encoded image data.
+   */
+  async captureScreenshot(options = {}) {
+    if (options.clip) {
+      throw new UnsupportedError("clip not supported");
+    }
+    if (options.format) {
+      throw new UnsupportedError("format not supported");
+    }
+    if (options.fromSurface) {
+      throw new UnsupportedError("fromSurface not supported");
+    }
+    if (options.quality) {
+      throw new UnsupportedError("quality not supported");
+    }
+
+    const MAX_CANVAS_DIMENSION = 32767;
+    const MAX_CANVAS_AREA = 472907776;
+
+    // Retrieve the browsing context of the content browser
+    const { browsingContext, window } = this.session.target;
+    const scale = window.devicePixelRatio;
+
+    const rect = await this.executeInChild("_viewportRect");
+
+    let canvasWidth = rect.width * scale;
+    let canvasHeight = rect.height * scale;
+
+    // Cap the screenshot size based on maximum allowed canvas sizes.
+    // Using higher dimensions would trigger exceptions in Gecko.
+    //
+    // See: https://developer.mozilla.org/en-US/docs/Web/HTML/Element/canvas#Maximum_canvas_size
+    if (canvasWidth > MAX_CANVAS_DIMENSION) {
+      rect.width = Math.floor(MAX_CANVAS_DIMENSION / scale);
+      canvasWidth = rect.width * scale;
+    }
+    if (canvasHeight > MAX_CANVAS_DIMENSION) {
+      rect.height = Math.floor(MAX_CANVAS_DIMENSION / scale);
+      canvasHeight = rect.height * scale;
+    }
+    // If the area is larger, reduce the height to keep the full width.
+    if (canvasWidth * canvasHeight > MAX_CANVAS_AREA) {
+      rect.height = Math.floor(MAX_CANVAS_AREA / (canvasWidth * scale));
+      canvasHeight = rect.height * scale;
+    }
+
+    const snapshot = await browsingContext.currentWindowGlobal.drawSnapshot(
+      rect,
+      scale,
+      "rgb(255,255,255)"
+    );
+
+    const canvas = window.document.createElementNS(
+      "http://www.w3.org/1999/xhtml",
+      "canvas"
+    );
+    canvas.width = canvasWidth;
+    canvas.height = canvasHeight;
+
+    const ctx = canvas.getContext("2d");
+    ctx.drawImage(snapshot, 0, 0);
+
+    // Bug 1574935 - Huge dimensions can trigger an OOM because multiple copies
+    // of the bitmap will exist in memory. Force the removal of the snapshot
+    // because it is no longer needed.
+    snapshot.close();
+
+    return canvas.toDataURL();
+  }
+
   async enable() {
     if (this.enabled) {
       return;
     }
 
     this.enabled = true;
 
     const { browser } = this.session.target;
--- a/remote/test/browser/browser.ini
+++ b/remote/test/browser/browser.ini
@@ -11,16 +11,17 @@ support-files =
 
 [browser_cdp.js]
 [browser_input_dispatchKeyEvent.js]
 [browser_input_dispatchKeyEvent_race.js]
 [browser_input_dispatchMouseEvent.js]
 [browser_main_target.js]
 [browser_network_requestWillBeSent.js]
 [browser_page_bringToFront.js]
+[browser_page_captureScreenshot.js]
 [browser_page_frameNavigated.js]
 [browser_page_frameNavigated_iframe.js]
 [browser_page_javascriptDialog_alert.js]
 [browser_page_javascriptDialog_beforeunload.js]
 [browser_page_javascriptDialog_confirm.js]
 [browser_page_javascriptDialog_otherTarget.js]
 [browser_page_javascriptDialog_prompt.js]
 [browser_page_runtime_events.js]
new file mode 100644
--- /dev/null
+++ b/remote/test/browser/browser_page_captureScreenshot.js
@@ -0,0 +1,89 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+async function getDevicePixelRatio() {
+  return ContentTask.spawn(gBrowser.selectedBrowser, null, function() {
+    return content.devicePixelRatio;
+  });
+}
+
+async function getImageDetails(client, image) {
+  return ContentTask.spawn(gBrowser.selectedBrowser, image, async function(
+    image
+  ) {
+    let infoPromise = new Promise(resolve => {
+      const img = new content.Image();
+      img.addEventListener(
+        "load",
+        () => {
+          resolve({
+            width: img.width,
+            height: img.height,
+          });
+        },
+        { once: true }
+      );
+      img.src = image;
+    });
+    return infoPromise;
+  });
+}
+
+async function getViewportRect() {
+  return ContentTask.spawn(gBrowser.selectedBrowser, null, function() {
+    return {
+      left: content.pageXOffset,
+      top: content.pageYOffset,
+      width: content.innerWidth,
+      height: content.innerHeight,
+    };
+  });
+}
+
+add_task(async function testScreenshotWithDocumentSmallerThanViewport() {
+  const doc = toDataURL("<div>Hello world");
+  const { client, tab } = await setupForURL(doc);
+
+  const { Page } = client;
+  info("Check that captureScreenshot() captures the viewport by default");
+  const screenshot = await Page.captureScreenshot();
+
+  const scale = await getDevicePixelRatio();
+  const viewportRect = await getViewportRect();
+  const { width, height } = await getImageDetails(client, screenshot);
+
+  is(width, (viewportRect.width - viewportRect.left) * scale);
+  is(height, (viewportRect.height - viewportRect.top) * scale);
+
+  await client.close();
+  ok(true, "The client is closed");
+
+  BrowserTestUtils.removeTab(tab);
+
+  await RemoteAgent.close();
+});
+
+add_task(async function testScreenshotWithDocumentLargerThanViewport() {
+  const doc = toDataURL("<div style='margin: 100vh 100vw'>Hello world");
+  const { client, tab } = await setupForURL(doc);
+
+  const { Page } = client;
+  info("Check that captureScreenshot() captures the viewport by default");
+  const screenshot = await Page.captureScreenshot();
+
+  const scale = await getDevicePixelRatio();
+  const viewportRect = await getViewportRect();
+  const { width, height } = await getImageDetails(client, screenshot);
+
+  is(width, (viewportRect.width - viewportRect.left) * scale);
+  is(height, (viewportRect.height - viewportRect.top) * scale);
+
+  await client.close();
+  ok(true, "The client is closed");
+
+  BrowserTestUtils.removeTab(tab);
+
+  await RemoteAgent.close();
+});