diff options
Diffstat (limited to 'toolkit/components/printing/content/print.js')
-rw-r--r-- | toolkit/components/printing/content/print.js | 2847 |
1 files changed, 2847 insertions, 0 deletions
diff --git a/toolkit/components/printing/content/print.js b/toolkit/components/printing/content/print.js new file mode 100644 index 0000000000..d08d790522 --- /dev/null +++ b/toolkit/components/printing/content/print.js @@ -0,0 +1,2847 @@ +/* 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/. */ + +const { PrintUtils, Services, AppConstants } = + window.docShell.chromeEventHandler.ownerGlobal; + +ChromeUtils.defineESModuleGetters(this, { + DeferredTask: "resource://gre/modules/DeferredTask.sys.mjs", + DownloadPaths: "resource://gre/modules/DownloadPaths.sys.mjs", +}); + +const PDF_JS_URI = "resource://pdf.js/web/viewer.html"; +const INPUT_DELAY_MS = Cu.isInAutomation ? 100 : 500; +const MM_PER_POINT = 25.4 / 72; +const INCHES_PER_POINT = 1 / 72; +const INCHES_PER_MM = 1 / 25.4; +const ourBrowser = window.docShell.chromeEventHandler; +const PSSVC = Cc["@mozilla.org/gfx/printsettings-service;1"].getService( + Ci.nsIPrintSettingsService +); + +var logger = (function () { + const getMaxLogLevel = () => + Services.prefs.getBoolPref("print.debug", false) ? "all" : "warn"; + + let { ConsoleAPI } = ChromeUtils.importESModule( + "resource://gre/modules/Console.sys.mjs" + ); + // Create a new instance of the ConsoleAPI so we can control the maxLogLevel with a pref. + let _logger = new ConsoleAPI({ + prefix: "printUI", + maxLogLevel: getMaxLogLevel(), + }); + + function onPrefChange() { + if (_logger) { + _logger.maxLogLevel = getMaxLogLevel(); + } + } + // Watch for pref changes and the maxLogLevel for the logger + Services.prefs.addObserver("print.debug", onPrefChange); + window.addEventListener("unload", () => { + Services.prefs.removeObserver("print.debug", onPrefChange); + }); + return _logger; +})(); + +function serializeSettings(settings, logPrefix) { + let re = /^(k[A-Z]|resolution)/; // accessing settings.resolution throws an exception? + let types = new Set(["string", "boolean", "number", "undefined"]); + let nameValues = {}; + for (let key in settings) { + try { + if (!re.test(key) && types.has(typeof settings[key])) { + nameValues[key] = settings[key]; + } + } catch (e) { + logger.warn("Exception accessing setting: ", key, e); + } + } + return JSON.stringify(nameValues, null, 2); +} + +let printPending = false; +let deferredTasks = []; +function createDeferredTask(fn, timeout) { + let task = new DeferredTask(fn, timeout); + deferredTasks.push(task); + return task; +} + +function cancelDeferredTasks() { + for (let task of deferredTasks) { + task.disarm(); + } + PrintEventHandler._updatePrintPreviewTask?.disarm(); + deferredTasks = []; +} + +document.addEventListener( + "DOMContentLoaded", + e => { + window._initialized = PrintEventHandler.init().catch(e => console.error(e)); + ourBrowser.setAttribute("flex", "0"); + ourBrowser.setAttribute("constrainpopups", "false"); + ourBrowser.classList.add("printSettingsBrowser"); + ourBrowser.closest(".dialogBox")?.classList.add("printDialogBox"); + }, + { once: true } +); + +window.addEventListener("dialogclosing", () => { + cancelDeferredTasks(); +}); + +window.addEventListener( + "unload", + e => { + document.textContent = ""; + }, + { once: true } +); + +var PrintEventHandler = { + settings: null, + defaultSettings: null, + allPaperSizes: {}, + previewIsEmpty: false, + _delayedChanges: {}, + _userChangedSettings: {}, + settingFlags: { + margins: Ci.nsIPrintSettings.kInitSaveMargins, + customMargins: Ci.nsIPrintSettings.kInitSaveMargins, + orientation: Ci.nsIPrintSettings.kInitSaveOrientation, + paperId: + Ci.nsIPrintSettings.kInitSavePaperSize | + Ci.nsIPrintSettings.kInitSaveUnwriteableMargins, + printInColor: Ci.nsIPrintSettings.kInitSaveInColor, + scaling: Ci.nsIPrintSettings.kInitSaveScaling, + shrinkToFit: Ci.nsIPrintSettings.kInitSaveShrinkToFit, + printDuplex: Ci.nsIPrintSettings.kInitSaveDuplex, + printFootersHeaders: + Ci.nsIPrintSettings.kInitSaveHeaderLeft | + Ci.nsIPrintSettings.kInitSaveHeaderCenter | + Ci.nsIPrintSettings.kInitSaveHeaderRight | + Ci.nsIPrintSettings.kInitSaveFooterLeft | + Ci.nsIPrintSettings.kInitSaveFooterCenter | + Ci.nsIPrintSettings.kInitSaveFooterRight, + printBackgrounds: + Ci.nsIPrintSettings.kInitSaveBGColors | + Ci.nsIPrintSettings.kInitSaveBGImages, + }, + + topContentTitle: null, + topCurrentURI: null, + activeContentTitle: null, + activeCurrentURI: null, + + get activeURI() { + return this.viewSettings.sourceVersion == "selection" + ? this.activeCurrentURI + : this.topCurrentURI; + }, + get activeTitle() { + return this.viewSettings.sourceVersion == "selection" + ? this.activeContentTitle + : this.topContentTitle; + }, + + // These settings do not have an associated pref value or flag, but + // changing them requires us to update the print preview. + _nonFlaggedUpdatePreviewSettings: new Set([ + "pageRanges", + "numPagesPerSheet", + "sourceVersion", + ]), + _noPreviewUpdateSettings: new Set(["numCopies", "printDuplex"]), + + async init() { + Services.telemetry.scalarAdd("printing.preview_opened_tm", 1); + + this.printPreviewEl = + ourBrowser.parentElement.querySelector("print-preview"); + + // Do not keep a reference to source browser, it may mutate after printing + // is initiated and the print preview clone must be a snapshot from the + // time that the print was started. + let sourceBrowsingContext = this.printPreviewEl.getSourceBrowsingContext(); + + let args = window.arguments[0]; + this.printFrameOnly = args.getProperty("printFrameOnly"); + this.printSelectionOnly = args.getProperty("printSelectionOnly"); + this.isArticle = args.getProperty("isArticle"); + this.hasSelection = await PrintUtils.checkForSelection( + sourceBrowsingContext + ); + + let sourcePrincipal = + sourceBrowsingContext.currentWindowGlobal.documentPrincipal; + let sourceIsPdf = + !sourcePrincipal.isNullPrincipal && sourcePrincipal.spec == PDF_JS_URI; + this.activeContentTitle = + sourceBrowsingContext.currentWindowContext.documentTitle; + this.activeCurrentURI = + sourceBrowsingContext.currentWindowContext.documentURI.spec; + let topWindowContext = sourceBrowsingContext.top.currentWindowContext; + this.topContentTitle = topWindowContext.documentTitle; + this.topCurrentURI = topWindowContext.documentURI.spec; + this.isReader = this.topCurrentURI.startsWith("about:reader"); + + let canSimplify = !this.isReader && this.isArticle; + if (!this.hasSelection && !canSimplify) { + document.getElementById("source-version-section").hidden = true; + } else { + document.getElementById("source-version-selection").hidden = + !this.hasSelection; + document.getElementById("source-version-simplified").hidden = + !canSimplify; + } + + // We don't need the sourceBrowsingContext anymore, get rid of it. + sourceBrowsingContext = undefined; + + this.printProgressIndicator = document.getElementById("print-progress"); + this.printForm = document.getElementById("print"); + if (sourceIsPdf) { + this.printForm.removeNonPdfSettings(); + } + + // Let the dialog appear before doing any potential main thread work. + await ourBrowser._dialogReady; + + // First check the available destinations to ensure we get settings for an + // accessible printer. + let destinations, + defaultSystemPrinter, + fallbackPaperList, + selectedPrinter, + printersByName; + try { + ({ + destinations, + defaultSystemPrinter, + fallbackPaperList, + selectedPrinter, + printersByName, + } = await this.getPrintDestinations()); + } catch (e) { + this.reportPrintingError("PRINT_DESTINATIONS"); + throw e; + } + PrintSettingsViewProxy.availablePrinters = printersByName; + PrintSettingsViewProxy.fallbackPaperList = fallbackPaperList; + PrintSettingsViewProxy.defaultSystemPrinter = defaultSystemPrinter; + PrintSettingsViewProxy._sourceVersion = + this.hasSelection && this.printSelectionOnly ? "selection" : "source"; + + logger.debug("availablePrinters: ", Object.keys(printersByName)); + logger.debug("defaultSystemPrinter: ", defaultSystemPrinter); + + document.addEventListener("print", async () => { + let cancelButton = document.getElementById("cancel-button"); + document.l10n.setAttributes( + cancelButton, + cancelButton.dataset.closeL10nId + ); + let didPrint = await this.print(); + if (!didPrint) { + // Re-enable elements of the form if the user cancels saving or + // if a deferred task rendered the page invalid. + this.printForm.enable(); + } + // Reset the cancel button regardless of the outcome. + document.l10n.setAttributes( + cancelButton, + cancelButton.dataset.cancelL10nId + ); + }); + this._createDelayedSettingsChangeTask(); + document.addEventListener("update-print-settings", e => { + this.handleSettingsChange(e.detail); + }); + document.addEventListener("cancel-print-settings", e => { + this._delayedSettingsChangeTask.disarm(); + for (let setting of Object.keys(e.detail)) { + delete this._delayedChanges[setting]; + } + }); + document.addEventListener("cancel-print", () => this.cancelPrint()); + document.addEventListener("open-system-dialog", async () => { + // This file in only used if pref print.always_print_silent is false, so + // no need to check that here. + + // Hide the dialog box before opening system dialog + // We cannot close the window yet because the browsing context for the + // print preview browser is needed to print the page. + let sourceBrowser = + this.printPreviewEl.getSourceBrowsingContext().top.embedderElement; + let dialogBoxManager = + PrintUtils.getTabDialogBox(sourceBrowser).getTabDialogManager(); + dialogBoxManager.hideDialog(sourceBrowser); + + // Use our settings to prepopulate the system dialog. + // The system print dialog won't recognize our internal save-to-pdf + // pseudo-printer. We need to pass it a settings object from any + // system recognized printer. + let settings = + this.settings.printerName == PrintUtils.SAVE_TO_PDF_PRINTER + ? PrintUtils.getPrintSettings(this.viewSettings.defaultSystemPrinter) + : this.settings.clone(); + // We set the title so that if the user chooses save-to-PDF from the + // system dialog the title will be used to generate the prepopulated + // filename in the file picker. + settings.title = this.activeTitle; + Services.telemetry.scalarAdd("printing.dialog_opened_via_preview_tm", 1); + const doPrint = await this._showPrintDialog( + window, + this.hasSelection, + settings + ); + if (!doPrint) { + Services.telemetry.scalarAdd( + "printing.dialog_via_preview_cancelled_tm", + 1 + ); + window.close(); + return; + } + await this.print(settings); + }); + + let originalError; + const printersByPriority = [ + selectedPrinter.value, + ...Object.getOwnPropertyNames(printersByName).filter( + name => name != selectedPrinter.value + ), + ]; + + // Try to update settings, falling back to any available printer + for (const printerName of printersByPriority) { + try { + let settingsToChange = await this.refreshSettings(printerName); + await this.updateSettings(settingsToChange, true); + originalError = null; + break; + } catch (e) { + if (!originalError) { + originalError = e; + // Report on how often fetching the last used printer settings fails. + this.reportPrintingError("PRINTER_SETTINGS_LAST_USED"); + } + } + } + + // Only throw original error if no fallback was possible + if (originalError) { + this.reportPrintingError("PRINTER_SETTINGS"); + throw originalError; + } + + let initialPreviewDone = this._updatePrintPreview(); + + // Use a DeferredTask for updating the preview. This will ensure that we + // only have one update running at a time. + this._createUpdatePrintPreviewTask(initialPreviewDone); + + document.dispatchEvent( + new CustomEvent("available-destinations", { + detail: destinations, + }) + ); + + document.dispatchEvent( + new CustomEvent("print-settings", { + detail: this.viewSettings, + }) + ); + + document.body.removeAttribute("loading"); + + await new Promise(resolve => window.requestAnimationFrame(resolve)); + + // Now that we're showing the form, select the destination select. + document.getElementById("printer-picker").focus({ focusVisible: true }); + + await initialPreviewDone; + }, + + async print(systemDialogSettings) { + // Disable the form when a print is in progress + this.printForm.disable(); + + if (Object.keys(this._delayedChanges).length) { + // Make sure any pending changes get saved. + let task = this._delayedSettingsChangeTask; + this._createDelayedSettingsChangeTask(); + await task.finalize(); + } + + if (this.settings.pageRanges.length) { + // Finish any running previews to verify the range is still valid. + let task = this._updatePrintPreviewTask; + this._createUpdatePrintPreviewTask(); + await task.finalize(); + } + + if (!this.printForm.checkValidity() || this.previewIsEmpty) { + return false; + } + + let settings = systemDialogSettings || this.settings; + + if (settings.printerName == PrintUtils.SAVE_TO_PDF_PRINTER) { + try { + settings.toFileName = await pickFileName( + this.activeTitle, + this.activeURI + ); + } catch (e) { + return false; + } + } + + await window._initialized; + + // This seems like it should be handled automatically but it isn't. + PSSVC.maybeSaveLastUsedPrinterNameToPrefs(settings.printerName); + + try { + // We'll provide our own progress indicator. + let l10nId = + settings.printerName == PrintUtils.SAVE_TO_PDF_PRINTER + ? "printui-print-progress-indicator-saving" + : "printui-print-progress-indicator"; + document.l10n.setAttributes(this.printProgressIndicator, l10nId); + this.printProgressIndicator.hidden = false; + let bc = this.printPreviewEl.currentBrowsingContext; + await this._doPrint(bc, settings); + } catch (e) { + console.error(e); + } + + if (settings.printerName == PrintUtils.SAVE_TO_PDF_PRINTER) { + // Clear the file name from the preference value since it may potentially + // contain sensitive information from the page title (Bug 1675965) + let prefName = + "print.printer_" + + settings.printerName.replace(/ /g, "_") + + ".print_to_filename"; + Services.prefs.clearUserPref(prefName); + } + + window.close(); + return true; + }, + + /** + * Prints the window. This method has been abstracted into a helper for + * testing purposes. + */ + _doPrint(aBrowsingContext, aSettings) { + return aBrowsingContext.print(aSettings); + }, + + cancelPrint() { + Services.telemetry.scalarAdd("printing.preview_cancelled_tm", 1); + window.close(); + }, + + async refreshSettings(printerName) { + this.currentPrinterName = printerName; + let currentPrinter; + try { + currentPrinter = await PrintSettingsViewProxy.resolvePropertiesForPrinter( + printerName + ); + } catch (e) { + this.reportPrintingError("PRINTER_PROPERTIES"); + throw e; + } + if (this.currentPrinterName != printerName) { + // Refresh settings could take a while, if the destination has changed + // then we don't want to update the settings after all. + return {}; + } + + this.settings = currentPrinter.settings; + this.defaultSettings = currentPrinter.defaultSettings; + + this.settings.printSelectionOnly = this.printSelectionOnly; + + logger.debug("currentPrinter name: ", printerName); + logger.debug("settings:", serializeSettings(this.settings)); + + // Some settings are only used by the UI + // assigning new values should update the underlying settings + this.viewSettings = new Proxy(this.settings, PrintSettingsViewProxy); + return this.getSettingsToUpdate(); + }, + + getSettingsToUpdate() { + // Get the previously-changed settings we want to try to use on this printer + let settingsToUpdate = Object.assign({}, this._userChangedSettings); + + // Ensure the color option is correct, if either of the supportsX flags are + // false then the user cannot change the value through the UI. + if (!this.viewSettings.supportsColor) { + settingsToUpdate.printInColor = false; + } else if (!this.viewSettings.supportsMonochrome) { + settingsToUpdate.printInColor = true; + } + + if (settingsToUpdate.sourceVersion == "simplified") { + if (this.viewSettings.printBackgrounds) { + // Remember that this was true before so it gets restored if the + // format is changed to something else. + this._userChangedSettings.printBackgrounds = true; + } + // Backgrounds are removed in simplified mode and this setting changes + // the output subtly to be less legible. + settingsToUpdate.printBackgrounds = false; + } + + if ( + settingsToUpdate.printInColor != this._userChangedSettings.printInColor + ) { + delete this._userChangedSettings.printInColor; + } + + // See if the paperId needs to change. + let paperId = settingsToUpdate.paperId || this.viewSettings.paperId; + + logger.debug("Using paperId: ", paperId); + logger.debug( + "Available paper sizes: ", + PrintSettingsViewProxy.availablePaperSizes + ); + let matchedPaper = + paperId && PrintSettingsViewProxy.availablePaperSizes[paperId]; + if (!matchedPaper) { + let paperWidth, paperHeight, paperSizeUnit; + if (settingsToUpdate.paperId) { + // The user changed paperId in this instance and session, + // We should have details on the paper size from the previous printer + paperId = settingsToUpdate.paperId; + let cachedPaperWrapper = this.allPaperSizes[paperId]; + // for the purposes of finding a best-size match, we'll use mm + paperWidth = cachedPaperWrapper.paper.width * MM_PER_POINT; + paperHeight = cachedPaperWrapper.paper.height * MM_PER_POINT; + paperSizeUnit = PrintEventHandler.settings.kPaperSizeMillimeters; + } else { + paperId = this.viewSettings.paperId; + logger.debug( + "No paperId or matchedPaper, get a new default from viewSettings:", + paperId + ); + paperWidth = this.viewSettings.paperWidth; + paperHeight = this.viewSettings.paperHeight; + paperSizeUnit = this.viewSettings.paperSizeUnit; + } + matchedPaper = PrintSettingsViewProxy.getBestPaperMatch( + paperWidth, + paperHeight, + paperSizeUnit + ); + } + if (!matchedPaper) { + // We didn't find a good match. Take the first paper size + matchedPaper = Object.values( + PrintSettingsViewProxy.availablePaperSizes + )[0]; + delete this._userChangedSettings.paperId; + } + if (matchedPaper.id !== paperId) { + // The exact paper id doesn't exist for this printer + logger.log( + `Requested paperId: "${paperId}" missing on this printer, using: ${matchedPaper.id} instead` + ); + delete this._userChangedSettings.paperId; + } + // Always write paper details back to settings + settingsToUpdate.paperId = matchedPaper.id; + + return settingsToUpdate; + }, + + _createDelayedSettingsChangeTask() { + this._delayedSettingsChangeTask = createDeferredTask(async () => { + if (Object.keys(this._delayedChanges).length) { + let changes = this._delayedChanges; + this._delayedChanges = {}; + await this.onUserSettingsChange(changes); + } + }, INPUT_DELAY_MS); + }, + + _createUpdatePrintPreviewTask(initialPreviewDone = null) { + this._updatePrintPreviewTask = new DeferredTask(async () => { + await initialPreviewDone; + await this._updatePrintPreview(); + document.dispatchEvent(new CustomEvent("preview-updated")); + }, 0); + }, + + _scheduleDelayedSettingsChange(changes) { + Object.assign(this._delayedChanges, changes); + this._delayedSettingsChangeTask.disarm(); + this._delayedSettingsChangeTask.arm(); + }, + + handleSettingsChange(changedSettings = {}) { + let delayedChanges = {}; + let instantChanges = {}; + for (let [setting, value] of Object.entries(changedSettings)) { + switch (setting) { + case "pageRanges": + case "scaling": + delayedChanges[setting] = value; + break; + case "customMargins": + delete this._delayedChanges.margins; + changedSettings.margins == "custom" + ? (delayedChanges[setting] = value) + : (instantChanges[setting] = value); + break; + default: + instantChanges[setting] = value; + break; + } + } + if (Object.keys(delayedChanges).length) { + this._scheduleDelayedSettingsChange(delayedChanges); + } + if (Object.keys(instantChanges).length) { + this.onUserSettingsChange(instantChanges); + } + }, + + async onUserSettingsChange(changedSettings = {}) { + let previewableChange = false; + for (let [setting, value] of Object.entries(changedSettings)) { + Services.telemetry.keyedScalarAdd( + "printing.settings_changed", + setting, + 1 + ); + // Update the list of user-changed settings, which we attempt to maintain + // across printer changes. + this._userChangedSettings[setting] = value; + if (!this._noPreviewUpdateSettings.has(setting)) { + previewableChange = true; + } + } + if (changedSettings.printerName) { + logger.debug( + "onUserSettingsChange, changing to printerName:", + changedSettings.printerName + ); + this.printForm.printerChanging = true; + this.printForm.disable(el => el.id != "printer-picker"); + let { printerName } = changedSettings; + // Treat a printerName change separately, because it involves a settings + // object switch and we don't want to set the new name on the old settings. + changedSettings = await this.refreshSettings(printerName); + if (printerName != this.currentPrinterName) { + // Don't continue this update if the printer changed again. + return; + } + this.printForm.printerChanging = false; + this.printForm.enable(); + } else { + changedSettings = this.getSettingsToUpdate(); + } + + let shouldPreviewUpdate = + (await this.updateSettings( + changedSettings, + !!changedSettings.printerName + )) && previewableChange; + + if (shouldPreviewUpdate && !printPending) { + // We do not need to arm the preview task if the user has already printed + // and finalized any deferred tasks. + this.updatePrintPreview(); + } + document.dispatchEvent( + new CustomEvent("print-settings", { + detail: this.viewSettings, + }) + ); + }, + + async updateSettings(changedSettings = {}, printerChanged = false) { + let updatePreviewWithoutFlag = false; + let flags = 0; + logger.debug("updateSettings ", changedSettings, printerChanged); + + if (printerChanged || changedSettings.paperId) { + // The paper's margin properties are async, + // so resolve those now before we update the settings + try { + let paperWrapper = await PrintSettingsViewProxy.fetchPaperMargins( + changedSettings.paperId || this.viewSettings.paperId + ); + + // See if we also need to change the custom margin values + + let paperHeightInInches = paperWrapper.paper.height * INCHES_PER_POINT; + let paperWidthInInches = paperWrapper.paper.width * INCHES_PER_POINT; + let height = + (changedSettings.orientation || this.viewSettings.orientation) == 0 + ? paperHeightInInches + : paperWidthInInches; + let width = + (changedSettings.orientation || this.viewSettings.orientation) == 0 + ? paperWidthInInches + : paperHeightInInches; + + function verticalMarginsInvalid(margins) { + return ( + parseFloat(margins.marginTop) + parseFloat(margins.marginBottom) > + height - + paperWrapper.unwriteableMarginTop - + paperWrapper.unwriteableMarginBottom + ); + } + + function horizontalMarginsInvalid(margins) { + return ( + parseFloat(margins.marginRight) + parseFloat(margins.marginLeft) > + width - + paperWrapper.unwriteableMarginRight - + paperWrapper.unwriteableMarginLeft + ); + } + + let unwriteableMarginsInvalid = false; + if ( + verticalMarginsInvalid(this.viewSettings.customMargins) || + this.viewSettings.customMargins.marginTop < 0 || + this.viewSettings.customMargins.marginBottom < 0 + ) { + let { marginTop, marginBottom } = this.viewSettings.defaultMargins; + if (verticalMarginsInvalid(this.viewSettings.defaultMargins)) { + let marginsNone = this.getMarginPresets("none"); + marginTop = marginsNone.marginTop; + marginBottom = marginsNone.marginBottom; + unwriteableMarginsInvalid = true; + } + changedSettings.marginTop = changedSettings.customMarginTop = + marginTop; + changedSettings.marginBottom = changedSettings.customMarginBottom = + marginBottom; + delete this._userChangedSettings.customMargins; + } + + if ( + horizontalMarginsInvalid(this.viewSettings.customMargins) || + this.viewSettings.customMargins.marginLeft < 0 || + this.viewSettings.customMargins.marginRight < 0 + ) { + let { marginLeft, marginRight } = this.viewSettings.defaultMargins; + if (horizontalMarginsInvalid(this.viewSettings.defaultMargins)) { + let marginsNone = this.getMarginPresets("none"); + marginLeft = marginsNone.marginLeft; + marginRight = marginsNone.marginRight; + unwriteableMarginsInvalid = true; + } + changedSettings.marginLeft = changedSettings.customMarginLeft = + marginLeft; + changedSettings.marginRight = changedSettings.customMarginRight = + marginRight; + delete this._userChangedSettings.customMargins; + } + + if (unwriteableMarginsInvalid) { + changedSettings.ignoreUnwriteableMargins = true; + } + } catch (e) { + this.reportPrintingError("PAPER_MARGINS"); + throw e; + } + } + + for (let [setting, value] of Object.entries(changedSettings)) { + // Always write paper changes back to settings as pref-derived values could be bad + if ( + this.viewSettings[setting] != value || + (printerChanged && setting == "paperId") + ) { + if (setting == "pageRanges") { + // The page range is kept as an array. If the user switches between all + // and custom with no specified range input (which is represented as an + // empty array), we do not want to send an update. + if (!this.viewSettings[setting].length && !value.length) { + continue; + } + } + this.viewSettings[setting] = value; + + if ( + setting in this.settingFlags && + setting in this._userChangedSettings + ) { + flags |= this.settingFlags[setting]; + } + updatePreviewWithoutFlag |= + this._nonFlaggedUpdatePreviewSettings.has(setting); + } + } + + let shouldPreviewUpdate = + flags || printerChanged || updatePreviewWithoutFlag; + logger.debug( + "updateSettings, calculated flags:", + flags, + "shouldPreviewUpdate:", + shouldPreviewUpdate + ); + if (flags) { + this.saveSettingsToPrefs(flags); + } + return shouldPreviewUpdate; + }, + + saveSettingsToPrefs(flags) { + PSSVC.maybeSavePrintSettingsToPrefs(this.settings, flags); + }, + + /** + * Queue a task to update the print preview. It will start immediately or when + * the in progress update completes. + */ + async updatePrintPreview() { + // Make sure the rendering state is set so we don't visibly update the + // sheet count with incomplete data. + this._updatePrintPreviewTask.arm(); + }, + + /** + * Creates a print preview or refreshes the preview with new settings when omitted. + * + * @return {Promise} Resolves when the preview has been updated. + */ + async _updatePrintPreview() { + let { settings } = this; + + const isFirstCall = !this.printInitiationTime; + if (isFirstCall) { + let params = new URLSearchParams(location.search); + this.printInitiationTime = parseInt( + params.get("printInitiationTime"), + 10 + ); + const elapsed = Date.now() - this.printInitiationTime; + Services.telemetry + .getHistogramById("PRINT_INIT_TO_PLATFORM_SENT_SETTINGS_MS") + .add(elapsed); + } + + let totalPageCount, sheetCount, isEmpty, orientation, pageWidth, pageHeight; + try { + // This resolves with a PrintPreviewSuccessInfo dictionary. + let { sourceVersion } = this.viewSettings; + let sourceURI = this.activeURI; + this._lastPrintPreviewSettings = settings; + ({ + totalPageCount, + sheetCount, + isEmpty, + orientation, + pageWidth, + pageHeight, + } = await this.printPreviewEl.printPreview(settings, { + sourceVersion, + sourceURI, + })); + } catch (e) { + this.reportPrintingError("PRINT_PREVIEW"); + console.error(e); + throw e; + } + + // If there is a set orientation, update the settings to use it. In this + // case, the document will already have used this orientation to create + // the print preview. + if (orientation != "unspecified") { + const kIPrintSettings = Ci.nsIPrintSettings; + settings.orientation = + orientation == "landscape" + ? kIPrintSettings.kLandscapeOrientation + : kIPrintSettings.kPortraitOrientation; + document.dispatchEvent(new CustomEvent("hide-orientation")); + } + + // If the page size is set, check whether we should use it as our paper size. + let isUsingPageRuleSizeAsPaperSize = + settings.usePageRuleSizeAsPaperSize && + pageWidth !== null && + pageHeight !== null; + if (isUsingPageRuleSizeAsPaperSize) { + // We canonically represent paper sizes using the width/height of a portrait-oriented sheet, + // with landscape-orientation applied as a supplemental rotation. + // If the page-size is landscape oriented, we flip the pageWidth / pageHeight here + // in order to pass a canonical representation into the paper-size settings. + if (orientation == "landscape") { + [pageHeight, pageWidth] = [pageWidth, pageHeight]; + } + + let matchedPaper = PrintSettingsViewProxy.getBestPaperMatch( + pageWidth, + pageHeight, + settings.kPaperSizeInches + ); + if (matchedPaper) { + settings.paperId = matchedPaper.id; + } + + settings.paperWidth = pageWidth; + settings.paperHeight = pageHeight; + settings.paperSizeUnit = settings.kPaperSizeInches; + document.dispatchEvent(new CustomEvent("hide-paper-size")); + } + + this.previewIsEmpty = isEmpty; + // If the preview is empty, we know our range is greater than the number of pages. + // We have to send a pageRange update to display a non-empty page. + if (this.previewIsEmpty) { + this.viewSettings.pageRanges = []; + this.updatePrintPreview(); + } + + document.dispatchEvent( + new CustomEvent("page-count", { + detail: { sheetCount, totalPages: totalPageCount }, + }) + ); + + if (isFirstCall) { + const elapsed = Date.now() - this.printInitiationTime; + Services.telemetry + .getHistogramById("PRINT_INIT_TO_PREVIEW_DOC_SHOWN_MS") + .add(elapsed); + } + }, + + async getPrintDestinations() { + const printerList = Cc["@mozilla.org/gfx/printerlist;1"].createInstance( + Ci.nsIPrinterList + ); + let printers; + + if (Cu.isInAutomation) { + printers = window._mockPrinters || []; + } else { + try { + printers = await printerList.printers; + } catch (e) { + this.reportPrintingError("PRINTER_LIST"); + throw e; + } + } + + let fallbackPaperList; + try { + fallbackPaperList = await printerList.fallbackPaperList; + } catch (e) { + this.reportPrintingError("FALLBACK_PAPER_LIST"); + throw e; + } + + let lastUsedPrinterName; + try { + lastUsedPrinterName = PSSVC.lastUsedPrinterName; + } catch (e) { + this.reportPrintingError("LAST_USED_PRINTER"); + throw e; + } + const defaultPrinterName = printerList.systemDefaultPrinterName; + const printersByName = {}; + + let lastUsedPrinter; + let defaultSystemPrinter; + + let saveToPdfPrinter = { + nameId: "printui-destination-pdf-label", + value: PrintUtils.SAVE_TO_PDF_PRINTER, + }; + printersByName[PrintUtils.SAVE_TO_PDF_PRINTER] = { + supportsColor: true, + supportsMonochrome: false, + name: PrintUtils.SAVE_TO_PDF_PRINTER, + }; + + if (lastUsedPrinterName == PrintUtils.SAVE_TO_PDF_PRINTER) { + lastUsedPrinter = saveToPdfPrinter; + } + + let destinations = [ + saveToPdfPrinter, + ...printers.map(printer => { + printer.QueryInterface(Ci.nsIPrinter); + const { name } = printer; + printersByName[printer.name] = { printer }; + const destination = { name, value: name }; + + if (name == lastUsedPrinterName) { + lastUsedPrinter = destination; + } + if (name == defaultPrinterName) { + defaultSystemPrinter = destination; + } + + return destination; + }), + ]; + + let selectedPrinter = + lastUsedPrinter || defaultSystemPrinter || saveToPdfPrinter; + + return { + destinations, + fallbackPaperList, + selectedPrinter, + printersByName, + defaultSystemPrinter, + }; + }, + + getMarginPresets(marginSize, paperWrapper) { + switch (marginSize) { + case "minimum": { + let marginSource = paperWrapper || this.defaultSettings; + return { + marginTop: marginSource.unwriteableMarginTop, + marginRight: marginSource.unwriteableMarginRight, + marginBottom: marginSource.unwriteableMarginBottom, + marginLeft: marginSource.unwriteableMarginLeft, + }; + } + case "none": + return { + marginTop: 0, + marginLeft: 0, + marginBottom: 0, + marginRight: 0, + }; + case "custom": + return { + marginTop: + PrintSettingsViewProxy._lastCustomMarginValues.marginTop ?? + this.settings.marginTop, + marginBottom: + PrintSettingsViewProxy._lastCustomMarginValues.marginBottom ?? + this.settings.marginBottom, + marginLeft: + PrintSettingsViewProxy._lastCustomMarginValues.marginLeft ?? + this.settings.marginLeft, + marginRight: + PrintSettingsViewProxy._lastCustomMarginValues.marginRight ?? + this.settings.marginRight, + }; + default: { + let minimum = this.getMarginPresets("minimum", paperWrapper); + return { + marginTop: !isNaN(minimum.marginTop) + ? Math.max(minimum.marginTop, this.defaultSettings.marginTop) + : this.defaultSettings.marginTop, + marginRight: !isNaN(minimum.marginRight) + ? Math.max(minimum.marginRight, this.defaultSettings.marginRight) + : this.defaultSettings.marginRight, + marginBottom: !isNaN(minimum.marginBottom) + ? Math.max(minimum.marginBottom, this.defaultSettings.marginBottom) + : this.defaultSettings.marginBottom, + marginLeft: !isNaN(minimum.marginLeft) + ? Math.max(minimum.marginLeft, this.defaultSettings.marginLeft) + : this.defaultSettings.marginLeft, + }; + } + } + }, + + reportPrintingError(aMessage) { + logger.debug("reportPrintingError:", aMessage); + Services.telemetry.keyedScalarAdd("printing.error", aMessage, 1); + }, + + /** + * Shows the system dialog. This method has been abstracted into a helper for + * testing purposes. The showPrintDialog() call blocks until the dialog is + * closed, so we mark it as async to allow us to reject from the test. + */ + async _showPrintDialog(aWindow, aHaveSelection, aSettings) { + return PrintUtils.handleSystemPrintDialog( + aWindow, + aHaveSelection, + aSettings + ); + }, +}; + +var PrintSettingsViewProxy = { + get defaultHeadersAndFooterValues() { + const defaultBranch = Services.prefs.getDefaultBranch(""); + let settingValues = {}; + for (let [name, pref] of Object.entries(this.headerFooterSettingsPrefs)) { + settingValues[name] = defaultBranch.getStringPref(pref); + } + // We only need to retrieve these defaults once and they will not change + Object.defineProperty(this, "defaultHeadersAndFooterValues", { + value: settingValues, + }); + return settingValues; + }, + + headerFooterSettingsPrefs: { + footerStrCenter: "print.print_footercenter", + footerStrLeft: "print.print_footerleft", + footerStrRight: "print.print_footerright", + headerStrCenter: "print.print_headercenter", + headerStrLeft: "print.print_headerleft", + headerStrRight: "print.print_headerright", + }, + + // Custom margins are not saved by a pref, so we need to keep track of them + // in order to save the value. + _lastCustomMarginValues: { + marginTop: null, + marginBottom: null, + marginLeft: null, + marginRight: null, + }, + + // This list was taken from nsDeviceContextSpecWin.cpp which records telemetry on print target type + knownSaveToFilePrinters: new Set([ + "Microsoft Print to PDF", + "Adobe PDF", + "Bullzip PDF Printer", + "CutePDF Writer", + "doPDF", + "Foxit Reader PDF Printer", + "Nitro PDF Creator", + "novaPDF", + "PDF-XChange", + "PDF24 PDF", + "PDFCreator", + "PrimoPDF", + "Soda PDF", + "Solid PDF Creator", + "Universal Document Converter", + "Microsoft XPS Document Writer", + ]), + + getBestPaperMatch(paperWidth, paperHeight, paperSizeUnit) { + let paperSizes = Object.values(this.availablePaperSizes); + if (!(paperWidth && paperHeight)) { + return null; + } + // first try to match on the paper dimensions using the current units + let unitsPerPoint; + let altUnitsPerPoint; + if (paperSizeUnit == PrintEventHandler.settings.kPaperSizeMillimeters) { + unitsPerPoint = MM_PER_POINT; + altUnitsPerPoint = INCHES_PER_POINT; + } else { + unitsPerPoint = INCHES_PER_POINT; + altUnitsPerPoint = MM_PER_POINT; + } + // equality to 1pt. + const equal = (a, b) => Math.abs(a - b) < 1; + const findMatch = (widthPts, heightPts) => + paperSizes.find(paperWrapper => { + // the dimensions on the nsIPaper object are in points + let result = + equal(widthPts, paperWrapper.paper.width) && + equal(heightPts, paperWrapper.paper.height); + return result; + }); + // Look for a paper with matching dimensions, using the current printer's + // paper size unit, then the alternate unit + let matchedPaper = + findMatch(paperWidth / unitsPerPoint, paperHeight / unitsPerPoint) || + findMatch(paperWidth / altUnitsPerPoint, paperHeight / altUnitsPerPoint); + + if (matchedPaper) { + return matchedPaper; + } + return null; + }, + + async fetchPaperMargins(paperId) { + // resolve any async and computed properties we need on the paper + let paperWrapper = this.availablePaperSizes[paperId]; + if (!paperWrapper) { + throw new Error("Can't fetchPaperMargins: " + paperId); + } + if (paperWrapper._resolved) { + // We've already resolved and calculated these values + return paperWrapper; + } + let margins; + try { + margins = await paperWrapper.paper.unwriteableMargin; + } catch (e) { + this.reportPrintingError("UNWRITEABLE_MARGIN"); + throw e; + } + margins.QueryInterface(Ci.nsIPaperMargin); + + // margin dimensions are given on the paper in points, setting values need to be in inches + paperWrapper.unwriteableMarginTop = margins.top * INCHES_PER_POINT; + paperWrapper.unwriteableMarginRight = margins.right * INCHES_PER_POINT; + paperWrapper.unwriteableMarginBottom = margins.bottom * INCHES_PER_POINT; + paperWrapper.unwriteableMarginLeft = margins.left * INCHES_PER_POINT; + // No need to re-resolve static properties + paperWrapper._resolved = true; + return paperWrapper; + }, + + async resolvePropertiesForPrinter(printerName) { + // resolve any async properties we need on the printer + let printerInfo = this.availablePrinters[printerName]; + if (printerInfo._resolved) { + // Store a convenience reference + this.availablePaperSizes = printerInfo.availablePaperSizes; + return printerInfo; + } + + // Await the async printer data. + if (printerInfo.printer) { + let basePrinterInfo; + try { + [ + printerInfo.supportsDuplex, + printerInfo.supportsColor, + printerInfo.supportsMonochrome, + basePrinterInfo, + ] = await Promise.all([ + printerInfo.printer.supportsDuplex, + printerInfo.printer.supportsColor, + printerInfo.printer.supportsMonochrome, + printerInfo.printer.printerInfo, + ]); + } catch (e) { + this.reportPrintingError("PRINTER_SETTINGS"); + throw e; + } + basePrinterInfo.QueryInterface(Ci.nsIPrinterInfo); + basePrinterInfo.defaultSettings.QueryInterface(Ci.nsIPrintSettings); + + printerInfo.paperList = basePrinterInfo.paperList; + printerInfo.defaultSettings = basePrinterInfo.defaultSettings; + } else if (printerName == PrintUtils.SAVE_TO_PDF_PRINTER) { + // The Mozilla PDF pseudo-printer has no actual nsIPrinter implementation + printerInfo.defaultSettings = PSSVC.createNewPrintSettings(); + printerInfo.defaultSettings.printerName = printerName; + printerInfo.defaultSettings.toFileName = ""; + printerInfo.defaultSettings.outputFormat = + Ci.nsIPrintSettings.kOutputFormatPDF; + printerInfo.defaultSettings.outputDestination = + Ci.nsIPrintSettings.kOutputDestinationFile; + printerInfo.defaultSettings.usePageRuleSizeAsPaperSize = + Services.prefs.getBoolPref( + "print.save_as_pdf.use_page_rule_size_as_paper_size.enabled", + false + ); + printerInfo.paperList = this.fallbackPaperList; + } + printerInfo.settings = printerInfo.defaultSettings.clone(); + // Apply any previously persisted user values + // Don't apply kInitSavePrintToFile though, that should only be true for + // the PDF printer. + printerInfo.settings.outputDestination = + printerName == PrintUtils.SAVE_TO_PDF_PRINTER + ? Ci.nsIPrintSettings.kOutputDestinationFile + : Ci.nsIPrintSettings.kOutputDestinationPrinter; + let flags = + printerInfo.settings.kInitSaveAll ^ + printerInfo.settings.kInitSavePrintToFile; + PSSVC.initPrintSettingsFromPrefs(printerInfo.settings, true, flags); + // We set `isInitializedFromPrinter` to make sure that that's set on the + // SAVE_TO_PDF_PRINTER settings. The naming is poor, but that tells the + // platform code that the settings object is complete. + printerInfo.settings.isInitializedFromPrinter = true; + + printerInfo.settings.toFileName = ""; + + // prepare the available paper sizes for this printer + if (!printerInfo.paperList?.length) { + logger.warn( + "Printer has empty paperList: ", + printerInfo.printer.id, + "using fallbackPaperList" + ); + printerInfo.paperList = this.fallbackPaperList; + } + // don't trust the settings to provide valid paperSizeUnit values + let sizeUnit = + printerInfo.settings.paperSizeUnit == + printerInfo.settings.kPaperSizeMillimeters + ? printerInfo.settings.kPaperSizeMillimeters + : printerInfo.settings.kPaperSizeInches; + let papersById = (printerInfo.availablePaperSizes = {}); + // Store a convenience reference + this.availablePaperSizes = papersById; + + for (let paper of printerInfo.paperList) { + paper.QueryInterface(Ci.nsIPaper); + // Bug 1662239: I'm seeing multiple duplicate entries for each paper size + // so ensure we have one entry per name + if (!papersById[paper.id]) { + papersById[paper.id] = { + paper, + id: paper.id, + name: paper.name, + // XXXsfoster: Eventually we want to get the unit from the nsIPaper object + sizeUnit, + }; + } + } + // Update our cache of all the paper sizes by name + Object.assign(PrintEventHandler.allPaperSizes, papersById); + + // The printer properties don't change, mark this as resolved for next time + printerInfo._resolved = true; + logger.debug("Resolved printerInfo:", printerInfo); + return printerInfo; + }, + + get(target, name) { + switch (name) { + case "currentPaper": { + let paperId = this.get(target, "paperId"); + return paperId && this.availablePaperSizes[paperId]; + } + + case "marginPresets": + let paperWrapper = this.get(target, "currentPaper"); + return { + none: PrintEventHandler.getMarginPresets("none", paperWrapper), + minimum: PrintEventHandler.getMarginPresets("minimum", paperWrapper), + default: PrintEventHandler.getMarginPresets("default", paperWrapper), + custom: PrintEventHandler.getMarginPresets("custom", paperWrapper), + }; + + case "marginOptions": { + let allMarginPresets = this.get(target, "marginPresets"); + let uniqueMargins = new Set(); + let marginsEnabled = {}; + for (let name of ["none", "default", "minimum", "custom"]) { + let { marginTop, marginLeft, marginBottom, marginRight } = + allMarginPresets[name]; + let key = [marginTop, marginLeft, marginBottom, marginRight].join( + "," + ); + // Custom margins are initialized to default margins + marginsEnabled[name] = !uniqueMargins.has(key) || name == "custom"; + uniqueMargins.add(key); + } + return marginsEnabled; + } + + case "margins": + let marginSettings = { + marginTop: target.marginTop, + marginLeft: target.marginLeft, + marginBottom: target.marginBottom, + marginRight: target.marginRight, + }; + // see if they match the none, minimum, or default margin values + let allMarginPresets = this.get(target, "marginPresets"); + const marginsMatch = function (lhs, rhs) { + return Object.keys(marginSettings).every( + name => lhs[name].toFixed(2) == rhs[name].toFixed(2) + ); + }; + const potentialPresets = (function () { + let presets = []; + const minimumIsNone = marginsMatch( + allMarginPresets.none, + allMarginPresets.minimum + ); + // We only attempt to match the serialized values against the "none" + // preset if the unwriteable margins are being ignored or are zero. + if (target.ignoreUnwriteableMargins || minimumIsNone) { + presets.push("none"); + } + if (!minimumIsNone) { + presets.push("minimum"); + } + presets.push("default"); + return presets; + })(); + for (let presetName of potentialPresets) { + let marginPresets = allMarginPresets[presetName]; + if (marginsMatch(marginSettings, marginPresets)) { + return presetName; + } + } + + // Fall back to custom for other values + return "custom"; + + case "defaultMargins": + return PrintEventHandler.getMarginPresets( + "default", + this.get(target, "currentPaper") + ); + + case "customMargins": + return PrintEventHandler.getMarginPresets( + "custom", + this.get(target, "currentPaper") + ); + + case "paperSizes": + return Object.values(this.availablePaperSizes) + .sort((a, b) => a.name.localeCompare(b.name)) + .map(paper => { + return { + name: paper.name, + value: paper.id, + }; + }); + + case "supportsDuplex": + return this.availablePrinters[target.printerName].supportsDuplex; + + case "printDuplex": + switch (target.duplex) { + case Ci.nsIPrintSettings.kDuplexNone: + break; + case Ci.nsIPrintSettings.kDuplexFlipOnLongEdge: + return "long-edge"; + case Ci.nsIPrintSettings.kDuplexFlipOnShortEdge: + return "short-edge"; + default: + logger.warn("Unexpected duplex value: ", target.duplex); + } + return "off"; + case "printBackgrounds": + return target.printBGImages || target.printBGColors; + + case "printFootersHeaders": + // if any of the footer and headers settings have a non-empty string value + // we consider that "enabled" + return Object.keys(this.headerFooterSettingsPrefs).some( + name => !!target[name] + ); + + case "supportsColor": + return this.availablePrinters[target.printerName].supportsColor; + + case "willSaveToFile": + return ( + target.outputFormat == Ci.nsIPrintSettings.kOutputFormatPDF || + this.knownSaveToFilePrinters.has(target.printerName) + ); + case "supportsMonochrome": + return this.availablePrinters[target.printerName].supportsMonochrome; + case "defaultSystemPrinter": + return ( + this.defaultSystemPrinter?.value || + Object.getOwnPropertyNames(this.availablePrinters).find( + name => name != PrintUtils.SAVE_TO_PDF_PRINTER + ) + ); + + case "numCopies": + return this.get(target, "willSaveToFile") ? 1 : target.numCopies; + case "sourceVersion": + return this._sourceVersion; + } + return target[name]; + }, + + set(target, name, value) { + switch (name) { + case "margins": + if (!["default", "minimum", "none", "custom"].includes(value)) { + logger.warn("Unexpected margin preset name: ", value); + value = "default"; + } + let paperWrapper = this.get(target, "currentPaper"); + let marginPresets = PrintEventHandler.getMarginPresets( + value, + paperWrapper + ); + for (let [settingName, presetValue] of Object.entries(marginPresets)) { + target[settingName] = presetValue; + } + target.honorPageRuleMargins = value == "default"; + target.ignoreUnwriteableMargins = value == "none"; + break; + + case "paperId": { + let paperId = value; + let paperWrapper = this.availablePaperSizes[paperId]; + // Dimensions on the paper object are in pts. + // We convert to the printer's specified unit when updating settings + let unitsPerPoint = + paperWrapper.sizeUnit == target.kPaperSizeMillimeters + ? MM_PER_POINT + : INCHES_PER_POINT; + // paperWidth and paperHeight are calculated values that we always treat as suspect and + // re-calculate whenever the paperId changes + target.paperSizeUnit = paperWrapper.sizeUnit; + target.paperWidth = paperWrapper.paper.width * unitsPerPoint; + target.paperHeight = paperWrapper.paper.height * unitsPerPoint; + // Unwriteable margins were pre-calculated from their async values when the paper size + // was selected. They are always in inches + target.unwriteableMarginTop = paperWrapper.unwriteableMarginTop; + target.unwriteableMarginRight = paperWrapper.unwriteableMarginRight; + target.unwriteableMarginBottom = paperWrapper.unwriteableMarginBottom; + target.unwriteableMarginLeft = paperWrapper.unwriteableMarginLeft; + target.paperId = paperWrapper.paper.id; + // pull new margin values for the new paper size + this.set(target, "margins", this.get(target, "margins")); + break; + } + + case "printerName": + // Can't set printerName, settings objects belong to a specific printer. + break; + + case "printBackgrounds": + target.printBGImages = value; + target.printBGColors = value; + break; + + case "printDuplex": { + let duplex = (function () { + switch (value) { + case "off": + break; + case "long-edge": + return Ci.nsIPrintSettings.kDuplexFlipOnLongEdge; + case "short-edge": + return Ci.nsIPrintSettings.kDuplexFlipOnShortEdge; + default: + logger.warn("Unexpected duplex name: ", value); + } + return Ci.nsIPrintSettings.kDuplexNone; + })(); + + target.duplex = duplex; + break; + } + + case "printFootersHeaders": + // To disable header & footers, set them all to empty. + // To enable, restore default values for each of the header & footer settings. + for (let [settingName, defaultValue] of Object.entries( + this.defaultHeadersAndFooterValues + )) { + target[settingName] = value ? defaultValue : ""; + } + break; + + case "customMargins": + if (value != null) { + for (let [settingName, newVal] of Object.entries(value)) { + target[settingName] = newVal; + this._lastCustomMarginValues[settingName] = newVal; + } + } + break; + + case "customMarginTop": + case "customMarginBottom": + case "customMarginLeft": + case "customMarginRight": + let customMarginName = "margin" + name.substring(12); + this.set( + target, + "customMargins", + Object.assign({}, this.get(target, "customMargins"), { + [customMarginName]: value, + }) + ); + break; + + case "sourceVersion": + this._sourceVersion = value; + this.set(target, "printSelectionOnly", value == "selection"); + if (value == "simplified") { + this.set(target, "printBackgrounds", false); + } + break; + + default: + target[name] = value; + } + }, +}; + +/* + * Custom elements ---------------------------------------------------- + */ + +function PrintUIControlMixin(superClass) { + return class PrintUIControl extends superClass { + connectedCallback() { + this.setAttribute("autocomplete", "off"); + this.initialize(); + this.render(); + } + + initialize() { + if (this._initialized) { + return; + } + this._initialized = true; + if (this.templateId) { + let template = this.ownerDocument.getElementById(this.templateId); + let templateContent = template.content; + this.appendChild(templateContent.cloneNode(true)); + } + + document.addEventListener("print-settings", ({ detail: settings }) => { + this.update(settings); + }); + + this.addEventListener("input", this); + } + + render() {} + + update(settings) {} + + dispatchSettingsChange(changedSettings) { + this.dispatchEvent( + new CustomEvent("update-print-settings", { + bubbles: true, + detail: changedSettings, + }) + ); + } + + cancelSettingsChange(changedSettings) { + this.dispatchEvent( + new CustomEvent("cancel-print-settings", { + bubbles: true, + detail: changedSettings, + }) + ); + } + + handleEvent(event) {} + }; +} + +class PrintUIForm extends PrintUIControlMixin(HTMLFormElement) { + initialize() { + super.initialize(); + + this.addEventListener("submit", this); + this.addEventListener("click", this); + this.addEventListener("revalidate", this); + + this._printerDestination = this.querySelector("#destination"); + this.printButton = this.querySelector("#print-button"); + } + + removeNonPdfSettings() { + let selectors = ["#backgrounds", "#source-version-selection"]; + for (let selector of selectors) { + this.querySelector(selector).remove(); + } + let moreSettings = this.querySelector("#more-settings-options"); + if (moreSettings.children.length <= 1) { + moreSettings.remove(); + } + } + + requestPrint() { + this.requestSubmit(this.printButton); + } + + update(settings) { + // If there are no default system printers available and we are not on mac, + // we should hide the system dialog because it won't be populated with + // the correct settings. Mac and Gtk support save to pdf functionality + // in the native dialog, so it can be shown regardless. + this.querySelector("#system-print").hidden = + AppConstants.platform === "win" && !settings.defaultSystemPrinter; + + this.querySelector("#two-sided-printing").hidden = !settings.supportsDuplex; + } + + enable() { + let isValid = this.checkValidity(); + document.body.toggleAttribute("invalid", !isValid); + if (isValid) { + for (let element of this.elements) { + if (!element.hasAttribute("disallowed")) { + element.disabled = false; + } + } + // aria-describedby will usually cause the first value to be reported. + // Unfortunately, screen readers don't pick up description changes from + // dialogs, so we must use a live region. To avoid double reporting of + // the first value, we don't set aria-live initially. We only set it for + // subsequent updates. + // aria-live is set on the parent because sheetCount itself might be + // hidden and then shown, and updates are only reported for live + // regions that were already visible. + document + .querySelector("#sheet-count") + .parentNode.setAttribute("aria-live", "polite"); + } else { + // Find the invalid element + let invalidElement; + for (let element of this.elements) { + if (!element.checkValidity()) { + invalidElement = element; + break; + } + } + let section = invalidElement.closest(".section-block"); + document.body.toggleAttribute("invalid", !isValid); + // We're hiding the sheet count and aria-describedby includes the + // content of hidden elements, so remove aria-describedby. + document.body.removeAttribute("aria-describedby"); + for (let element of this.elements) { + // If we're valid, enable all inputs. + // Otherwise, disable the valid inputs other than the cancel button and the elements + // in the invalid section. + element.disabled = + element.hasAttribute("disallowed") || + (!isValid && + element.validity.valid && + element.name != "cancel" && + element.closest(".section-block") != this._printerDestination && + element.closest(".section-block") != section); + } + } + } + + disable(filterFn) { + for (let element of this.elements) { + if (filterFn && !filterFn(element)) { + continue; + } + element.disabled = element.name != "cancel"; + } + } + + handleEvent(e) { + if (e.target.id == "open-dialog-link") { + this.dispatchEvent(new Event("open-system-dialog", { bubbles: true })); + return; + } + + if (e.type == "submit") { + e.preventDefault(); + if (e.submitter.name == "print" && this.checkValidity()) { + this.dispatchEvent(new Event("print", { bubbles: true })); + } + } else if ( + (e.type == "input" || e.type == "revalidate") && + !this.printerChanging + ) { + this.enable(); + } + } +} +customElements.define("print-form", PrintUIForm, { extends: "form" }); + +class PrintSettingSelect extends PrintUIControlMixin(HTMLSelectElement) { + initialize() { + super.initialize(); + this.addEventListener("keypress", this); + } + + connectedCallback() { + this.settingName = this.dataset.settingName; + super.connectedCallback(); + } + + setOptions(optionValues = []) { + this.textContent = ""; + for (let optionData of optionValues) { + let opt = new Option( + optionData.name, + "value" in optionData ? optionData.value : optionData.name + ); + if (optionData.nameId) { + document.l10n.setAttributes(opt, optionData.nameId); + } + // option selectedness is set via update() and assignment to this.value + this.options.add(opt); + } + } + + update(settings) { + if (this.settingName) { + this.value = settings[this.settingName]; + } + } + + handleEvent(e) { + if (e.type == "input" && this.settingName) { + this.dispatchSettingsChange({ + [this.settingName]: e.target.value, + }); + } else if (e.type == "keypress") { + if ( + e.key == "Enter" && + (!e.metaKey || AppConstants.platform == "macosx") + ) { + this.form.requestPrint(); + } + } + } +} +customElements.define("setting-select", PrintSettingSelect, { + extends: "select", +}); + +class PrintSettingNumber extends PrintUIControlMixin(HTMLInputElement) { + initialize() { + super.initialize(); + this.addEventListener("beforeinput", e => this.preventWhitespaceEntry(e)); + this.addEventListener("paste", e => this.pasteWithoutWhitespace(e)); + } + + connectedCallback() { + this.type = "number"; + this.settingName = this.dataset.settingName; + super.connectedCallback(); + } + + update(settings) { + if (this.settingName) { + this.value = settings[this.settingName]; + } + } + + preventWhitespaceEntry(e) { + if (e.data && !e.data.trim().length) { + e.preventDefault(); + } + } + + pasteWithoutWhitespace(e) { + // Prevent original value from being pasted + e.preventDefault(); + + // Manually update input's value with sanitized clipboard data + let paste = (e.clipboardData || window.clipboardData) + .getData("text") + .trim(); + this.value = paste; + } + + handleEvent(e) { + switch (e.type) { + case "input": + if (this.settingName && this.checkValidity()) { + this.dispatchSettingsChange({ + [this.settingName]: this.value, + }); + } + break; + } + } +} +customElements.define("setting-number", PrintSettingNumber, { + extends: "input", +}); + +class PrintSettingCheckbox extends PrintUIControlMixin(HTMLInputElement) { + connectedCallback() { + this.type = "checkbox"; + this.settingName = this.dataset.settingName; + super.connectedCallback(); + } + + update(settings) { + this.checked = settings[this.settingName]; + } + + handleEvent(e) { + this.dispatchSettingsChange({ + [this.settingName]: this.checked, + }); + } +} +customElements.define("setting-checkbox", PrintSettingCheckbox, { + extends: "input", +}); + +class PrintSettingRadio extends PrintUIControlMixin(HTMLInputElement) { + connectedCallback() { + this.type = "radio"; + this.settingName = this.dataset.settingName; + super.connectedCallback(); + } + + update(settings) { + this.checked = settings[this.settingName] == this.value; + } + + handleEvent(e) { + this.dispatchSettingsChange({ + [this.settingName]: this.value, + }); + } +} +customElements.define("setting-radio", PrintSettingRadio, { + extends: "input", +}); + +class DestinationPicker extends PrintSettingSelect { + initialize() { + super.initialize(); + document.addEventListener("available-destinations", this); + } + + update(settings) { + super.update(settings); + let isPdf = settings.outputFormat == Ci.nsIPrintSettings.kOutputFormatPDF; + this.setAttribute("output", isPdf ? "pdf" : "paper"); + } + + handleEvent(e) { + super.handleEvent(e); + + if (e.type == "available-destinations") { + this.setOptions(e.detail); + } + } +} +customElements.define("destination-picker", DestinationPicker, { + extends: "select", +}); + +class ColorModePicker extends PrintSettingSelect { + update(settings) { + this.value = settings[this.settingName] ? "color" : "bw"; + let canSwitch = settings.supportsColor && settings.supportsMonochrome; + if (this.disablePicker != canSwitch) { + this.toggleAttribute("disallowed", !canSwitch); + this.disabled = !canSwitch; + } + this.disablePicker = canSwitch; + } + + handleEvent(e) { + if (e.type == "input") { + // turn our string value into the expected boolean + this.dispatchSettingsChange({ + [this.settingName]: this.value == "color", + }); + } + } +} +customElements.define("color-mode-select", ColorModePicker, { + extends: "select", +}); + +class PaperSizePicker extends PrintSettingSelect { + initialize() { + super.initialize(); + this._printerName = null; + this._section = this.closest(".section-block"); + document.addEventListener("hide-paper-size", this); + } + + update(settings) { + if (settings.printerName !== this._printerName) { + this._printerName = settings.printerName; + this.setOptions(settings.paperSizes); + } + this.value = settings.paperId; + + // Unhide the paper-size picker, if we've stopped using the page size as paper-size. + if (this._section.hidden && !settings.usePageRuleSizeAsPaperSize) { + this._section.hidden = false; + } + } + + handleEvent(e) { + super.handleEvent(e); + const { type } = e; + if (type == "hide-paper-size") { + this._section.hidden = true; + } + } +} +customElements.define("paper-size-select", PaperSizePicker, { + extends: "select", +}); + +class OrientationInput extends PrintUIControlMixin(HTMLElement) { + initialize() { + super.initialize(); + document.addEventListener("hide-orientation", this); + } + + get templateId() { + return "orientation-template"; + } + + update(settings) { + for (let input of this.querySelectorAll("input")) { + input.checked = settings.orientation == input.value; + } + } + + handleEvent(e) { + if (e.type == "hide-orientation") { + document.getElementById("orientation").hidden = true; + return; + } + this.dispatchSettingsChange({ + orientation: e.target.value, + }); + } +} +customElements.define("orientation-input", OrientationInput); + +class CopiesInput extends PrintUIControlMixin(HTMLElement) { + get templateId() { + return "copy-template"; + } + + initialize() { + super.initialize(); + this._copiesSection = this.closest(".section-block"); + this._copiesInput = this.querySelector("#copies-count"); + this._copiesError = this.querySelector("#error-invalid-copies"); + } + + update(settings) { + this._copiesSection.hidden = settings.willSaveToFile; + this._copiesError.hidden = true; + } + + handleEvent(e) { + this._copiesError.hidden = this._copiesInput.checkValidity(); + } +} +customElements.define("copy-count-input", CopiesInput); + +class ScaleInput extends PrintUIControlMixin(HTMLElement) { + get templateId() { + return "scale-template"; + } + + initialize() { + super.initialize(); + + this._percentScale = this.querySelector("#percent-scale"); + this._shrinkToFitChoice = this.querySelector("#fit-choice"); + this._scaleChoice = this.querySelector("#percent-scale-choice"); + this._scaleError = this.querySelector("#error-invalid-scale"); + } + + updateScale() { + this.dispatchSettingsChange({ + scaling: Number(this._percentScale.value / 100), + }); + } + + update(settings) { + let { scaling, shrinkToFit, printerName } = settings; + this._shrinkToFitChoice.checked = shrinkToFit; + this._scaleChoice.checked = !shrinkToFit; + if (this.disableScale != shrinkToFit) { + this._percentScale.disabled = shrinkToFit; + this._percentScale.toggleAttribute("disallowed", shrinkToFit); + } + this.disableScale = shrinkToFit; + if (!this.printerName) { + this.printerName = printerName; + } + + // If the user had an invalid input and switches back to "fit to page", + // we repopulate the scale field with the stored, valid scaling value. + let isValid = this._percentScale.checkValidity(); + if ( + !this._percentScale.value || + (this._shrinkToFitChoice.checked && !isValid) || + (this.printerName != printerName && !isValid) + ) { + // Only allow whole numbers. 0.14 * 100 would have decimal places, etc. + this._percentScale.value = parseInt(scaling * 100, 10); + this.printerName = printerName; + if (!isValid) { + this.dispatchEvent(new Event("revalidate", { bubbles: true })); + this._scaleError.hidden = true; + } + } + } + + handleEvent(e) { + if (e.target == this._shrinkToFitChoice || e.target == this._scaleChoice) { + if (!this._percentScale.checkValidity()) { + this._percentScale.value = 100; + } + let scale = + e.target == this._shrinkToFitChoice + ? 1 + : Number(this._percentScale.value / 100); + this.dispatchSettingsChange({ + shrinkToFit: this._shrinkToFitChoice.checked, + scaling: scale, + }); + this._scaleError.hidden = true; + } else if (e.type == "input") { + if (this._percentScale.checkValidity()) { + this.updateScale(); + } + } + + window.clearTimeout(this.showErrorTimeoutId); + if (this._percentScale.validity.valid) { + this._scaleError.hidden = true; + } else { + this.cancelSettingsChange({ scaling: true }); + this.showErrorTimeoutId = window.setTimeout(() => { + this._scaleError.hidden = false; + }, INPUT_DELAY_MS); + } + } +} +customElements.define("scale-input", ScaleInput); + +class PageRangeInput extends PrintUIControlMixin(HTMLElement) { + initialize() { + super.initialize(); + + this._rangeInput = this.querySelector("#custom-range"); + this._rangeInput.title = ""; + this._rangePicker = this.querySelector("#range-picker"); + this._rangePickerEvenOption = this._rangePicker.namedItem("even"); + this._rangeError = this.querySelector("#error-invalid-range"); + this._startRangeOverflowError = this.querySelector( + "#error-invalid-start-range-overflow" + ); + + this._pagesSet = new Set(); + + this.addEventListener("keypress", this); + this.addEventListener("paste", this); + document.addEventListener("page-count", this); + } + + get templateId() { + return "page-range-template"; + } + + updatePageRange() { + let isCustom = this._rangePicker.value == "custom"; + let isCurrent = this._rangePicker.value == "current"; + + if (!isCurrent) { + this._currentPage = null; + } + + if (isCustom) { + this.validateRangeInput(); + } else if (isCurrent) { + this._currentPage = this._rangeInput.value = + this._currentPage || this.getCurrentVisiblePageNumber(); + this.validateRangeInput(); + } else { + this._pagesSet.clear(); + + if (this._rangePicker.value == "odd") { + for (let i = 1; i <= this._numPages; i += 2) { + this._pagesSet.add(i); + } + } else if (this._rangePicker.value == "even") { + for (let i = 2; i <= this._numPages; i += 2) { + this._pagesSet.add(i); + } + } + + if (!this._rangeInput.checkValidity()) { + this._rangeInput.setCustomValidity(""); + this._rangeInput.value = ""; + } + } + + this.dispatchEvent(new Event("revalidate", { bubbles: true })); + + document.l10n.setAttributes( + this._rangeError, + "printui-error-invalid-range", + { + numPages: this._numPages, + } + ); + + // If it's valid, update the page range and hide the error messages. + // Otherwise, set the appropriate error message + if (this._rangeInput.validity.valid || !isCustom) { + window.clearTimeout(this.showErrorTimeoutId); + this._startRangeOverflowError.hidden = this._rangeError.hidden = true; + } else { + this._rangeInput.focus(); + } + } + + dispatchPageRange(shouldCancel = true) { + window.clearTimeout(this.showErrorTimeoutId); + if ( + this._rangeInput.validity.valid || + this._rangePicker.value != "custom" + ) { + this.dispatchSettingsChange({ + pageRanges: this.formatPageRange(), + }); + } else { + if (shouldCancel) { + this.cancelSettingsChange({ pageRanges: true }); + } + this.showErrorTimeoutId = window.setTimeout(() => { + this._rangeError.hidden = + this._rangeInput.validationMessage != "invalid"; + this._startRangeOverflowError.hidden = + this._rangeInput.validationMessage != "startRangeOverflow"; + }, INPUT_DELAY_MS); + } + } + + // The platform expects pageRanges to be an array of + // ranges represented by ints. + // Ex: Printing pages 1-3 would return [1,3] + // Ex: Printing page 1 would return [1,1] + // Ex: Printing pages 1-2,4 would return [1,2,4,4] + formatPageRange() { + if ( + this._pagesSet.size == 0 || + (this._rangePicker.value == "custom" && this._rangeInput.value == "") || + this._rangePicker.value == "all" + ) { + // Show all pages. + return []; + } + let pages = Array.from(this._pagesSet).sort((a, b) => a - b); + + let formattedRanges = []; + let startRange = pages[0]; + let endRange = pages[0]; + formattedRanges.push(startRange); + + for (let i = 1; i < pages.length; i++) { + let currentPage = pages[i - 1]; + let nextPage = pages[i]; + if (nextPage > currentPage + 1) { + formattedRanges.push(endRange); + startRange = endRange = nextPage; + formattedRanges.push(startRange); + } else { + endRange = nextPage; + } + } + formattedRanges.push(endRange); + + return formattedRanges; + } + + update(settings) { + let { pageRanges, printerName } = settings; + + this.toggleAttribute("all-pages", !pageRanges.length); + if (!this.printerName) { + this.printerName = printerName; + } + + let isValid = this._rangeInput.checkValidity(); + + if (this.printerName != printerName && !isValid) { + this.printerName = printerName; + this._rangeInput.value = ""; + this.updatePageRange(); + this.dispatchPageRange(); + } + } + + handleKeypress(e) { + let char = String.fromCharCode(e.charCode); + let acceptedChar = char.match(/^[0-9,-]$/); + if (!acceptedChar && !char.match("\x00") && !e.ctrlKey && !e.metaKey) { + e.preventDefault(); + } + } + + handlePaste(e) { + let paste = (e.clipboardData || window.clipboardData) + .getData("text") + .trim(); + if (!paste.match(/^[0-9,-]*$/)) { + e.preventDefault(); + } + } + + // This method has been abstracted into a helper for testing purposes + _validateRangeInput(value, numPages) { + this._pagesSet.clear(); + var ranges = value.split(","); + + for (let range of ranges) { + let rangeParts = range.split("-"); + if (rangeParts.length > 2) { + this._rangeInput.setCustomValidity("invalid"); + this._rangeInput.title = ""; + this._pagesSet.clear(); + return; + } + let startRange = parseInt(rangeParts[0], 10); + let endRange = parseInt( + rangeParts.length == 2 ? rangeParts[1] : rangeParts[0], + 10 + ); + + if (isNaN(startRange) && isNaN(endRange)) { + continue; + } + + // If the startRange was not specified, then we infer this + // to be 1. + if (isNaN(startRange) && rangeParts[0] == "") { + startRange = 1; + } + // If the end range was not specified, then we infer this + // to be the total number of pages. + if (isNaN(endRange) && rangeParts[1] == "") { + endRange = numPages; + } + + // Check the range for errors + if (endRange < startRange) { + this._rangeInput.setCustomValidity("startRangeOverflow"); + this._pagesSet.clear(); + return; + } else if ( + startRange > numPages || + endRange > numPages || + startRange == 0 + ) { + this._rangeInput.setCustomValidity("invalid"); + this._rangeInput.title = ""; + this._pagesSet.clear(); + return; + } + + for (let i = startRange; i <= endRange; i++) { + this._pagesSet.add(i); + } + } + + this._rangeInput.setCustomValidity(""); + } + + validateRangeInput() { + let value = ["custom", "current"].includes(this._rangePicker.value) + ? this._rangeInput.value + : ""; + this._validateRangeInput(value, this._numPages); + } + + getCurrentVisiblePageNumber() { + let pageNum = parseInt( + PrintEventHandler.printPreviewEl.lastPreviewBrowser.getAttribute( + "current-page" + ) + ); + return isNaN(pageNum) ? 1 : pageNum; + } + + handleEvent(e) { + if (e.type == "keypress") { + if (e.target == this._rangeInput) { + this.handleKeypress(e); + } + return; + } + + if (e.type === "paste" && e.target == this._rangeInput) { + this.handlePaste(e); + return; + } + + if (e.type == "page-count") { + let { totalPages } = e.detail; + // This means we have already handled the page count event + // and do not need to dispatch another event. + if (this._numPages == totalPages) { + return; + } + + this._numPages = totalPages; + this._rangeInput.disabled = false; + this._rangePickerEvenOption.disabled = this._numPages < 2; + + let prevPages = Array.from(this._pagesSet); + this.updatePageRange(); + if ( + prevPages.length != this._pagesSet.size || + !prevPages.every(page => this._pagesSet.has(page)) + ) { + // If the calculated set of pages has changed then we need to dispatch + // a new pageRanges setting :( + // Ideally this would be resolved in the settings code since it should + // only happen for the "N-" case where pages N through the end of the + // document are in the range. + this.dispatchPageRange(false); + } + + return; + } + + if (e.target == this._rangePicker) { + this._rangeInput.hidden = e.target.value != "custom"; + this.updatePageRange(); + this.dispatchPageRange(); + if (!this._rangeInput.hidden) { + this._rangeInput.select(); + } + } else if (e.target == this._rangeInput) { + this._rangeInput.focus(); + if (this._numPages) { + this.updatePageRange(); + this.dispatchPageRange(); + } + } + } +} +customElements.define("page-range-input", PageRangeInput); + +class MarginsPicker extends PrintUIControlMixin(HTMLElement) { + initialize() { + super.initialize(); + + this._marginPicker = this.querySelector("#margins-picker"); + this._customTopMargin = this.querySelector("#custom-margin-top"); + this._customBottomMargin = this.querySelector("#custom-margin-bottom"); + this._customLeftMargin = this.querySelector("#custom-margin-left"); + this._customRightMargin = this.querySelector("#custom-margin-right"); + this._marginError = this.querySelector("#error-invalid-margin"); + this._sizeUnit = null; + this._toInchesMultiplier = 1; + } + + get templateId() { + return "margins-template"; + } + + updateCustomMargins() { + let newMargins = { + marginTop: this.toInchValue(this._customTopMargin.value), + marginBottom: this.toInchValue(this._customBottomMargin.value), + marginLeft: this.toInchValue(this._customLeftMargin.value), + marginRight: this.toInchValue(this._customRightMargin.value), + }; + + this.dispatchSettingsChange({ + margins: "custom", + customMargins: newMargins, + }); + this._marginError.hidden = true; + } + + updateMaxValues() { + let maxWidth = this.toCurrentUnitValue(this._maxWidth); + let maxHeight = this.toCurrentUnitValue(this._maxHeight); + this._customTopMargin.max = this.formatMaxAttr( + maxHeight - this._customBottomMargin.value + ); + this._customBottomMargin.max = this.formatMaxAttr( + maxHeight - this._customTopMargin.value + ); + this._customLeftMargin.max = this.formatMaxAttr( + maxWidth - this._customRightMargin.value + ); + this._customRightMargin.max = this.formatMaxAttr( + maxWidth - this._customLeftMargin.value + ); + } + + truncateTwoDecimals(val) { + if (val.split(".")[1].length > 2) { + let dotIndex = val.indexOf("."); + return val.slice(0, dotIndex + 3); + } + return val; + } + + formatMaxAttr(val) { + const strVal = val.toString(); + if (strVal.includes(".")) { + return this.truncateTwoDecimals(strVal); + } + return val; + } + + formatMargin(target) { + if (target.value.includes(".")) { + target.value = this.truncateTwoDecimals(target.value); + } + } + + toCurrentUnitValue(val) { + if (typeof val == "string") { + val = parseFloat(val); + } + return val / this._toInchesMultiplier; + } + + toInchValue(val) { + if (typeof val == "string") { + val = parseFloat(val); + } + return val * this._toInchesMultiplier; + } + + setAllMarginValues(settings) { + this._customTopMargin.value = this.toCurrentUnitValue( + settings.customMargins.marginTop + ).toFixed(2); + this._customBottomMargin.value = this.toCurrentUnitValue( + settings.customMargins.marginBottom + ).toFixed(2); + this._customLeftMargin.value = this.toCurrentUnitValue( + settings.customMargins.marginLeft + ).toFixed(2); + this._customRightMargin.value = this.toCurrentUnitValue( + settings.customMargins.marginRight + ).toFixed(2); + } + + update(settings) { + // Re-evaluate which margin options should be enabled whenever the printer or paper changes + this._toInchesMultiplier = + settings.paperSizeUnit == settings.kPaperSizeMillimeters + ? INCHES_PER_MM + : 1; + if ( + settings.paperId !== this._paperId || + settings.printerName !== this._printerName || + settings.orientation !== this._orientation + ) { + let enabledMargins = settings.marginOptions; + for (let option of this._marginPicker.options) { + option.hidden = !enabledMargins[option.value]; + } + this._paperId = settings.paperId; + this._printerName = settings.printerName; + this._orientation = settings.orientation; + + // Paper dimensions are in the paperSizeUnit. As the margin values are in inches + // we'll normalize to that when storing max dimensions + let height = + this._orientation == 0 ? settings.paperHeight : settings.paperWidth; + let width = + this._orientation == 0 ? settings.paperWidth : settings.paperHeight; + let heightInches = + Math.round(this._toInchesMultiplier * height * 100) / 100; + let widthInches = + Math.round(this._toInchesMultiplier * width * 100) / 100; + + this._maxHeight = + heightInches - + settings.unwriteableMarginTop - + settings.unwriteableMarginBottom; + this._maxWidth = + widthInches - + settings.unwriteableMarginLeft - + settings.unwriteableMarginRight; + + // The values in custom fields should be initialized to custom margin values + // and must be overriden if they are no longer valid. + this.setAllMarginValues(settings); + this.updateMaxValues(); + this.dispatchEvent(new Event("revalidate", { bubbles: true })); + this._marginError.hidden = true; + } + + if (settings.paperSizeUnit !== this._sizeUnit) { + this._sizeUnit = settings.paperSizeUnit; + let unitStr = + this._sizeUnit == settings.kPaperSizeMillimeters ? "mm" : "inches"; + for (let elem of this.querySelectorAll("[data-unit-prefix-l10n-id]")) { + let l10nId = elem.getAttribute("data-unit-prefix-l10n-id") + unitStr; + document.l10n.setAttributes(elem, l10nId); + } + } + + // We need to ensure we don't override the value if the value should be custom. + if (this._marginPicker.value != "custom") { + // Reset the custom margin values if they are not valid and revalidate the form + if ( + !this._customTopMargin.checkValidity() || + !this._customBottomMargin.checkValidity() || + !this._customLeftMargin.checkValidity() || + !this._customRightMargin.checkValidity() + ) { + window.clearTimeout(this.showErrorTimeoutId); + this.setAllMarginValues(settings); + this.updateMaxValues(); + this.dispatchEvent(new Event("revalidate", { bubbles: true })); + this._marginError.hidden = true; + } + if (settings.margins == "custom") { + // Ensure that we display the custom margin boxes + this.querySelector(".margin-group").hidden = false; + } + this._marginPicker.value = settings.margins; + } + } + + handleEvent(e) { + if (e.target == this._marginPicker) { + let customMargin = e.target.value == "custom"; + this.querySelector(".margin-group").hidden = !customMargin; + if (customMargin) { + // Update the custom margin values to ensure consistency + this.updateCustomMargins(); + return; + } + + this.dispatchSettingsChange({ + margins: e.target.value, + customMargins: null, + }); + } + + if ( + e.target == this._customTopMargin || + e.target == this._customBottomMargin || + e.target == this._customLeftMargin || + e.target == this._customRightMargin + ) { + if (e.target.checkValidity()) { + this.updateMaxValues(); + } + if ( + this._customTopMargin.validity.valid && + this._customBottomMargin.validity.valid && + this._customLeftMargin.validity.valid && + this._customRightMargin.validity.valid + ) { + this.formatMargin(e.target); + this.updateCustomMargins(); + } else if (e.target.validity.stepMismatch) { + // If this is the third digit after the decimal point, we should + // truncate the string. + this.formatMargin(e.target); + } + } + + window.clearTimeout(this.showErrorTimeoutId); + if ( + this._customTopMargin.validity.valid && + this._customBottomMargin.validity.valid && + this._customLeftMargin.validity.valid && + this._customRightMargin.validity.valid + ) { + this._marginError.hidden = true; + } else { + this.cancelSettingsChange({ customMargins: true, margins: true }); + this.showErrorTimeoutId = window.setTimeout(() => { + this._marginError.hidden = false; + }, INPUT_DELAY_MS); + } + } +} +customElements.define("margins-select", MarginsPicker); + +class TwistySummary extends PrintUIControlMixin(HTMLElement) { + get isOpen() { + return this.closest("details")?.hasAttribute("open"); + } + + get templateId() { + return "twisty-summary-template"; + } + + initialize() { + if (this._initialized) { + return; + } + super.initialize(); + this.label = this.querySelector(".label"); + + this.addEventListener("click", this); + let shouldOpen = Services.prefs.getBoolPref( + "print.more-settings.open", + false + ); + this.closest("details").open = shouldOpen; + this.updateSummary(shouldOpen); + } + + handleEvent(e) { + let willOpen = !this.isOpen; + Services.prefs.setBoolPref("print.more-settings.open", willOpen); + this.updateSummary(willOpen); + } + + updateSummary(open) { + document.l10n.setAttributes( + this.label, + open + ? this.getAttribute("data-open-l10n-id") + : this.getAttribute("data-closed-l10n-id") + ); + } +} +customElements.define("twisty-summary", TwistySummary); + +class PageCount extends PrintUIControlMixin(HTMLElement) { + initialize() { + super.initialize(); + document.addEventListener("page-count", this); + } + + update(settings) { + this.numCopies = settings.numCopies; + this.duplex = settings.duplex; + this.outputDestination = settings.outputDestination; + this.render(); + } + + render() { + if (!this.numCopies || !this.sheetCount) { + return; + } + + let sheetCount = this.sheetCount; + + // When printing to a printer (not to a file) update + // the sheet count to account for duplex printing. + if ( + this.outputDestination == Ci.nsIPrintSettings.kOutputDestinationPrinter && + this.duplex != Ci.nsIPrintSettings.kDuplexNone + ) { + sheetCount = Math.ceil(sheetCount / 2); + } + + sheetCount *= this.numCopies; + + document.l10n.setAttributes(this, "printui-sheets-count", { + sheetCount, + }); + + // The loading attribute must be removed on first render + if (this.hasAttribute("loading")) { + this.removeAttribute("loading"); + } + + if (this.id) { + // We're showing the sheet count, so let it describe the dialog. + document.body.setAttribute("aria-describedby", this.id); + } + } + + handleEvent(e) { + this.sheetCount = e.detail.sheetCount; + this.render(); + } +} +customElements.define("page-count", PageCount); + +class PrintBackgrounds extends PrintSettingCheckbox { + update(settings) { + super.update(settings); + let isSimplified = settings.sourceVersion == "simplified"; + this.disabled = isSimplified; + this.toggleAttribute("disallowed", isSimplified); + this.checked = !isSimplified && settings.printBackgrounds; + } +} +customElements.define("print-backgrounds", PrintBackgrounds, { + extends: "input", +}); + +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" }); + +class CancelButton extends HTMLButtonElement { + constructor() { + super(); + this.addEventListener("click", () => { + this.dispatchEvent(new Event("cancel-print", { bubbles: true })); + }); + } +} +customElements.define("cancel-button", CancelButton, { extends: "button" }); + +async function pickFileName(contentTitle, currentURI) { + 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 (contentTitle != "") { + filename = contentTitle; + } else { + let url = new URL(currentURI); + let path = decodeURIComponent(url.pathname); + path = path.replace(/\/$/, ""); + filename = path.split("/").pop(); + if (filename == "") { + filename = url.hostname; + } + } + if (!filename.endsWith(".pdf")) { + // macOS and linux don't set the extension based on the default extension. + // Windows won't add the extension a second time, fortunately. + // If it already ends with .pdf though, adding it again isn't needed. + filename += ".pdf"; + } + 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(); + + // Remove the file to reduce the likelihood of the user opening an empty or damaged fle when the + // preview is loading + await IOUtils.remove(picker.file.path); + } catch (e) { + throw new Error({ reason: retval == 0 ? "not_saved" : "not_replaced" }); + } + } + + return picker.file.path; +} |