Bug 1658043 - Save to PDF option when printing r=sfoster,fluent-reviewers,flod
authorMark Striemer <mstriemer@mozilla.com>
Sat, 15 Aug 2020 05:40:18 +0000
changeset 544869 9d3ade989dabfcdf2a63e1c9b71edcf27ea48c50
parent 544868 94982363dfa4b5bde758cf1442a9166dc07c16fb
child 544870 d6ae04588d3425c058afc56a47ba5345167ea04b
push id37701
push userrmaries@mozilla.com
push dateSat, 15 Aug 2020 21:17:39 +0000
treeherdermozilla-central@ffc01c0f13a8 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewerssfoster, fluent-reviewers, flod
bugs1658043
milestone81.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 1658043 - Save to PDF option when printing r=sfoster,fluent-reviewers,flod Differential Revision: https://phabricator.services.mozilla.com/D87120
toolkit/actors/PrintingChild.jsm
toolkit/components/printing/content/print.css
toolkit/components/printing/content/print.html
toolkit/components/printing/content/print.js
toolkit/components/printing/content/printUtils.js
toolkit/locales/en-US/toolkit/printing/printUI.ftl
--- a/toolkit/actors/PrintingChild.jsm
+++ b/toolkit/actors/PrintingChild.jsm
@@ -81,17 +81,18 @@ class PrintingChild extends ActorChild {
   receiveMessage(message) {
     let data = message.data;
     switch (message.name) {
       case "Printing:Preview:Enter": {
         this.enterPrintPreview(
           Services.wm.getOuterWindowWithId(data.windowID),
           data.simplifiedMode,
           data.changingBrowsers,
-          data.lastUsedPrinterName
+          data.lastUsedPrinterName,
+          data.outputFormat
         );
         break;
       }
 
       case "Printing:Preview:Exit": {
         this.exitPrintPreview();
         break;
       }
@@ -305,28 +306,33 @@ class PrintingChild extends ActorChild {
       }
     });
   }
 
   enterPrintPreview(
     contentWindow,
     simplifiedMode,
     changingBrowsers,
-    lastUsedPrinterName
+    lastUsedPrinterName,
+    outputFormat
   ) {
     const { docShell } = this;
     try {
       let printSettings = this.getPrintSettings(lastUsedPrinterName);
 
       // Disable the progress dialog for generating previews.
       printSettings.showPrintProgress = !Services.prefs.getBoolPref(
         "print.tab_modal.enabled",
         false
       );
 
+      if (outputFormat == printSettings.kOutputFormatPDF) {
+        printSettings.outputFormat = printSettings.kOutputFormatPDF;
+      }
+
       // If we happen to be on simplified mode, we need to set docURL in order
       // to generate header/footer content correctly, since simplified tab has
       // "about:blank" as its URI.
       if (printSettings && simplifiedMode) {
         printSettings.docURL = contentWindow.document.baseURI;
       }
 
       // The print preview docshell will be in a different TabGroup, so
--- a/toolkit/components/printing/content/print.css
+++ b/toolkit/components/printing/content/print.css
@@ -111,16 +111,20 @@ select:not([size]):not([multiple])[iconi
   background-position: right 3px center, left 8px center;
   width: 100%;
 }
 
 #printer-picker:dir(rtl) {
   background-position-x: left 3px, right 8px;
 }
 
+#printer-picker[output="pdf"] {
+  background-image: url("chrome://global/skin/icons/arrow-dropdown-12.svg"), url("chrome://global/content/portrait.svg");
+}
+
 input[type="checkbox"] {
   margin-inline-end: 8px;
 }
 
 input[type="checkbox"]:checked {
   fill: #3485ff !important;
 }
 
--- a/toolkit/components/printing/content/print.html
+++ b/toolkit/components/printing/content/print.html
@@ -114,15 +114,15 @@
 
         <section id="system-print" class="section-block">
           <a href="#" id="open-dialog-link" data-l10n-id="printui-system-dialog-link"></a>
         </section>
       </section>
 
       <footer class="footer-container" id="print-footer">
         <section id="button-container" class="section-block">
-          <button class="primary" name="print" data-l10n-id="printui-primary-button"></button>
+          <button class="primary" name="print" data-l10n-id="printui-primary-button" is="print-button"></button>
           <button name="cancel" data-l10n-id="printui-cancel-button"></button>
         </section>
       </footer>
     </form>
   </body>
 </html>
--- a/toolkit/components/printing/content/print.js
+++ b/toolkit/components/printing/content/print.js
@@ -3,16 +3,22 @@
  * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
 
 const {
   gBrowser,
   PrintUtils,
   Services,
 } = window.docShell.chromeEventHandler.ownerGlobal;
 
+ChromeUtils.defineModuleGetter(
+  this,
+  "DownloadPaths",
+  "resource://gre/modules/DownloadPaths.jsm"
+);
+
 const INVALID_INPUT_DELAY_MS = 500;
 
 document.addEventListener(
   "DOMContentLoaded",
   e => {
     PrintEventHandler.init();
   },
   { once: true }
@@ -22,45 +28,48 @@ window.addEventListener(
   "unload",
   e => {
     document.textContent = "";
   },
   { once: true }
 );
 
 var PrintEventHandler = {
-  init() {
+  async init() {
     this.sourceBrowser = this.getSourceBrowser();
     this.previewBrowser = this.getPreviewBrowser();
     this.settings = PrintUtils.getPrintSettings();
     this.updatePrintPreview();
 
     document.addEventListener("print", e => this.print({ silent: true }));
     document.addEventListener("update-print-settings", e =>
       this.updateSettings(e.detail)
     );
     document.addEventListener("cancel-print", () => this.cancelPrint());
     document.addEventListener("open-system-dialog", () =>
       this.print({ silent: false })
     );
-    this.getPrintDestinations().then(destinations => {
-      document.dispatchEvent(
-        new CustomEvent("available-destinations", {
-          detail: destinations,
-        })
-      );
-    });
+
+    let destinations = await this.getPrintDestinations();
+    document.dispatchEvent(
+      new CustomEvent("available-destinations", {
+        detail: destinations,
+      })
+    );
 
     // Some settings are only used by the UI
     // assigning new values should update the underlying settings
     this.viewSettings = new Proxy(this.settings, PrintSettingsViewProxy);
 
+    // Ensure the output format is set properly.
+    this.viewSettings.printerName = this.settings.printerName;
+
     this.settingFlags = {
       orientation: Ci.nsIPrintSettings.kInitSaveOrientation,
-      printerName: Ci.nsIPrintSettings.kInitSaveAll,
+      printerName: Ci.nsIPrintSettings.kInitSavePrinterName,
       scaling: Ci.nsIPrintSettings.kInitSaveScaling,
       shrinkToFit: Ci.nsIPrintSettings.kInitSaveShrinkToFit,
       printFootersHeaders:
         Ci.nsIPrintSettings.kInitSaveHeaderLeft |
         Ci.nsIPrintSettings.kInitSaveHeaderCenter |
         Ci.nsIPrintSettings.kInitSaveHeaderRight |
         Ci.nsIPrintSettings.kInitSaveFooterLeft |
         Ci.nsIPrintSettings.kInitSaveFooterCenter |
@@ -72,23 +81,34 @@ var PrintEventHandler = {
 
     document.dispatchEvent(
       new CustomEvent("print-settings", {
         detail: this.viewSettings,
       })
     );
   },
 
-  print({ printerName, silent } = {}) {
+  async print({ silent } = {}) {
     let settings = this.settings;
     settings.printSilent = silent;
 
-    if (printerName) {
-      settings.printerName = printerName;
+    if (settings.printerName == PrintUtils.SAVE_TO_PDF_PRINTER) {
+      try {
+        settings.toFileName = await pickFileName(this.sourceBrowser, settings);
+      } catch (e) {
+        // Don't care why just yet.
+        return;
+      }
     }
+
+    if (silent) {
+      // This seems like it should be handled automatically but it isn't.
+      Services.prefs.setStringPref("print_printer", settings.printerName);
+    }
+
     PrintUtils.printWindow(this.previewBrowser.browsingContext, settings);
   },
 
   cancelPrint() {
     window.close();
   },
 
   updateSettings(changedSettings = {}) {
@@ -183,33 +203,41 @@ var PrintEventHandler = {
     const printerList = Cc["@mozilla.org/gfx/printerlist;1"].createInstance(
       Ci.nsIPrinterList
     );
 
     const lastUsedPrinterName = PrintUtils._getLastUsedPrinterName();
     const defaultPrinterName = printerList.systemDefaultPrinterName;
     const printers = await printerList.printers;
 
+    let saveToPdfPrinter = {
+      nameId: "printui-destination-pdf-label",
+      value: PrintUtils.SAVE_TO_PDF_PRINTER,
+      selected: lastUsedPrinterName == PrintUtils.SAVE_TO_PDF_PRINTER,
+    };
+
     let defaultIndex = 0;
-    let foundSelected = false;
-    let i = 0;
-    let destinations = printers.map(printer => {
-      printer.QueryInterface(Ci.nsIPrinter);
-      const name = printer.name;
-      const value = name;
-      const selected = name == lastUsedPrinterName;
-      if (selected) {
-        foundSelected = true;
-      }
-      if (name == defaultPrinterName) {
-        defaultIndex = i;
-      }
-      ++i;
-      return { name, value, selected };
-    });
+    let foundSelected = saveToPdfPrinter.selected;
+
+    let destinations = [
+      saveToPdfPrinter,
+      ...printers.map((printer, i) => {
+        printer.QueryInterface(Ci.nsIPrinter);
+        const name = printer.name;
+        const value = name;
+        const selected = name == lastUsedPrinterName;
+        if (selected) {
+          foundSelected = true;
+        }
+        if (name == defaultPrinterName) {
+          defaultIndex = i + 1; // Account for the PDF option.
+        }
+        return { name, value, selected };
+      }),
+    ];
 
     // If there's no valid last selected printer, select the system default, or
     // the first on the list otherwise.
     if (destinations.length && !foundSelected) {
       destinations[defaultIndex].selected = true;
     }
 
     return destinations;
@@ -280,16 +308,26 @@ const PrintSettingsViewProxy = {
         target.printRange =
           value == "all"
             ? Ci.nsIPrintSettings.kRangeAllPages
             : Ci.nsIPrintSettings.kRangeSpecifiedPageRange;
         // TODO: There's also kRangeSelection, which should come into play
         // once we have a text box where the user can specify a range
         break;
 
+      case "printerName":
+        target.printerName = value;
+        target.toFileName = "";
+        if (value == PrintUtils.SAVE_TO_PDF_PRINTER) {
+          target.outputFormat = Ci.nsIPrintSettings.kOutputFormatPDF;
+        } else {
+          target.outputFormat = Ci.nsIPrintSettings.kOutputFormatNative;
+        }
+        break;
+
       default:
         target[name] = value;
     }
   },
 };
 
 /*
  * Custom elements ----------------------------------------------------
@@ -334,39 +372,45 @@ function PrintUIControlMixin(superClass)
     }
 
     handleEvent(event) {}
   };
 }
 
 class DestinationPicker extends PrintUIControlMixin(HTMLSelectElement) {
   initialize() {
-    this.addEventListener("change", this);
+    super.initialize();
     document.addEventListener("available-destinations", this);
   }
 
   setOptions(optionValues = []) {
     this._options = optionValues;
     this.textContent = "";
     for (let optionData of this._options) {
       let opt = new Option(
         optionData.name,
         "value" in optionData ? optionData.value : optionData.name
       );
+      if (optionData.nameId) {
+        document.l10n.setAttributes(opt, optionData.nameId);
+      }
       if (optionData.selected) {
-        this._currentPrinter = optionData.value;
         opt.selected = true;
       }
       this.options.add(opt);
     }
   }
 
+  update(settings) {
+    let isPdf = settings.outputFormat == Ci.nsIPrintSettings.kOutputFormatPDF;
+    this.setAttribute("output", isPdf ? "pdf" : "paper");
+  }
+
   handleEvent(e) {
     if (e.type == "change") {
-      this._currentPrinter = e.target.value;
       this.dispatchSettingsChange({
         printerName: e.target.value,
       });
     }
 
     if (e.type == "available-destinations") {
       this.setOptions(e.detail);
     }
@@ -665,8 +709,72 @@ class PageCount extends PrintUIControlMi
 
   handleEvent(e) {
     let { numPages } = e.detail;
     this.numPages = numPages;
     this.render();
   }
 }
 customElements.define("page-count", PageCount);
+
+class PrintButton extends PrintUIControlMixin(HTMLButtonElement) {
+  update(settings) {
+    let l10nId =
+      settings.printerName == PrintUtils.SAVE_TO_PDF_PRINTER
+        ? "printui-primary-button-save"
+        : "printui-primary-button";
+    document.l10n.setAttributes(this, l10nId);
+  }
+}
+customElements.define("print-button", PrintButton, { extends: "button" });
+
+async function pickFileName(sourceBrowser, pageSettings) {
+  let picker = Cc["@mozilla.org/filepicker;1"].createInstance(Ci.nsIFilePicker);
+  let [title] = await document.l10n.formatMessages([
+    { id: "printui-save-to-pdf-title" },
+  ]);
+  title = title.value;
+
+  let filename;
+  if (sourceBrowser.contentTitle != "") {
+    filename = sourceBrowser.contentTitle;
+  } else {
+    let url = new URL(sourceBrowser.currentURI.spec);
+    let path = decodeURIComponent(url.pathname);
+    path = path.replace(/\/$/, "");
+    filename = path.split("/").pop();
+    if (filename == "") {
+      filename = url.hostname;
+    }
+  }
+  filename = DownloadPaths.sanitize(filename);
+
+  picker.init(
+    window.docShell.chromeEventHandler.ownerGlobal,
+    title,
+    Ci.nsIFilePicker.modeSave
+  );
+  picker.appendFilter("PDF", "*.pdf");
+  picker.defaultExtension = "pdf";
+  picker.defaultString = filename;
+
+  let retval = await new Promise(resolve => picker.open(resolve));
+
+  if (retval == 1) {
+    throw new Error({ reason: "cancelled" });
+  } else {
+    // OK clicked (retval == 0) or replace confirmed (retval == 2)
+
+    // Workaround: When trying to replace an existing file that is open in another application (i.e. a locked file),
+    // the print progress listener is never called. This workaround ensures that a correct status is always returned.
+    try {
+      let fstream = Cc[
+        "@mozilla.org/network/file-output-stream;1"
+      ].createInstance(Ci.nsIFileOutputStream);
+      fstream.init(picker.file, 0x2a, 0o666, 0); // ioflags = write|create|truncate, file permissions = rw-rw-rw-
+      fstream.close();
+    } catch (e) {
+      throw new Error({ reason: retval == 0 ? "not_saved" : "not_replaced" });
+    }
+  }
+
+  return picker.file.path;
+}
--- a/toolkit/components/printing/content/printUtils.js
+++ b/toolkit/components/printing/content/printUtils.js
@@ -63,16 +63,18 @@ XPCOMUtils.defineLazyPreferenceGetter(
   "PRINT_TAB_MODAL",
   "print.tab_modal.enabled",
   false
 );
 
 var gFocusedElement = null;
 
 var PrintUtils = {
+  SAVE_TO_PDF_PRINTER: "Mozilla Save to PDF",
+
   init() {
     window.messageManager.addMessageListener("Printing:Error", this);
   },
 
   get _bundle() {
     delete this._bundle;
     return (this._bundle = Services.strings.createBundle(
       "chrome://global/locale/printing.properties"
@@ -219,16 +221,17 @@ var PrintUtils = {
 
       printPreviewBrowser.messageManager.sendAsyncMessage(
         "Printing:Preview:Enter",
         {
           changingBrowsers: false,
           lastUsedPrinterName: printSettings.printerName,
           simplifiedMode: false,
           windowID: sourceBrowser.outerWindowID,
+          outputFormat: printSettings.outputFormat,
         }
       );
     });
   },
 
   /**
    * Initialize a print, this will open the tab modal UI if it is enabled or
    * defer to the native dialog/silent print.
@@ -545,21 +548,25 @@ var PrintUtils = {
     return undefined;
   },
 
   _setPrinterDefaultsForSelectedPrinter(aPSSVC, aPrintSettings) {
     if (!aPrintSettings.printerName) {
       aPrintSettings.printerName = aPSSVC.lastUsedPrinterName;
     }
 
-    // First get any defaults from the printer
-    aPSSVC.initPrintSettingsFromPrinter(
-      aPrintSettings.printerName,
-      aPrintSettings
-    );
+    // First get any defaults from the printer. We want to skip this for Save to
+    // PDF since it isn't a real printer and will throw.
+    if (aPrintSettings.printerName != this.SAVE_TO_PDF_PRINTER) {
+      aPSSVC.initPrintSettingsFromPrinter(
+        aPrintSettings.printerName,
+        aPrintSettings
+      );
+    }
+
     // now augment them with any values from last time
     aPSSVC.initPrintSettingsFromPrefs(
       aPrintSettings,
       true,
       aPrintSettings.kInitSaveAll
     );
   },
 
--- a/toolkit/locales/en-US/toolkit/printing/printUI.ftl
+++ b/toolkit/locales/en-US/toolkit/printing/printUI.ftl
@@ -1,13 +1,15 @@
 # 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/.
 
 printui-title = Print
+# Dialog title to prompt the user for a filename to save print to PDF.
+printui-save-to-pdf-title = Save As
 
 # Variables
 # $sheetCount (integer) - Number of paper sheets
 printui-sheets-count =
     { $sheetCount ->
         [one] { $sheetCount } sheet of paper
        *[other] { $sheetCount } sheets of paper
     }
@@ -24,16 +26,17 @@ printui-page-custom-range =
 printui-copies-label = Copies
 
 printui-orientation = Orientation
 printui-landscape = Landscape
 printui-portrait = Portrait
 
 # Section title for the printer or destination device to target
 printui-destination-label = Destination
+printui-destination-pdf-label = Save to PDF
 
 printui-more-settings = More settings
 printui-less-settings = Fewer settings
 
 # Section title (noun) for the print scaling options
 printui-scale = Scale
 printui-scale-fit-to-page = Fit to page
 # Label for input control where user can set the scale percentage
@@ -42,11 +45,12 @@ printui-scale-pcent = Scale
 # Section title for miscellaneous print options
 printui-options = Options
 printui-headers-footers-checkbox = Print headers and footers
 printui-backgrounds-checkbox = Print backgrounds
 
 printui-system-dialog-link = Print using the system dialog…
 
 printui-primary-button = Print
+printui-primary-button-save = Save
 printui-cancel-button = Cancel
 
 printui-loading = Preparing Preview