/* 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 { gBrowser, PrintUtils, Services, AppConstants, } = window.docShell.chromeEventHandler.ownerGlobal; ChromeUtils.defineModuleGetter( this, "DownloadPaths", "resource://gre/modules/DownloadPaths.jsm" ); ChromeUtils.defineModuleGetter( this, "DeferredTask", "resource://gre/modules/DeferredTask.jsm" ); 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 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.import("resource://gre/modules/Console.jsm"); // 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 nameValues; } 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(); ourBrowser.setAttribute("flex", "0"); ourBrowser.classList.add("printSettingsBrowser"); ourBrowser.closest(".dialogBox")?.classList.add("printDialogBox"); }, { once: true } ); window.addEventListener("dialogclosing", () => { PrintEventHandler.unload(); cancelDeferredTasks(); }); window.addEventListener( "unload", e => { document.textContent = ""; }, { once: true } ); var PrintEventHandler = { settings: null, defaultSettings: null, allPaperSizes: {}, previewIsEmpty: false, _delayedChanges: {}, _hasRenderedSelectionPreview: false, _hasRenderedPrimaryPreview: false, _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, }, originalSourceContentTitle: null, originalSourceCurrentURI: null, previewBrowser: null, selectionPreviewBrowser: null, currentPreviewBrowser: null, // 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", "printSelectionOnly", ]), _noPreviewUpdateSettings: new Set(["numCopies", "printDuplex"]), async init() { Services.telemetry.scalarAdd("printing.preview_opened_tm", 1); // 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.getSourceBrowsingContext(); ({ previewBrowser: this.previewBrowser, selectionPreviewBrowser: this.selectionPreviewBrowser, } = PrintUtils.createPreviewBrowsers(sourceBrowsingContext, ourBrowser)); let args = window.arguments[0]; this.printSelectionOnly = args.getProperty("printSelectionOnly"); this.hasSelection = args.getProperty("hasSelection") && this.selectionPreviewBrowser; this.printFrameOnly = args.getProperty("printFrameOnly"); // Get the temporary browser that will previously have been created for the // platform code to generate the static clone printing doc into if this // print is for a window.print() call. In that case we steal the browser's // docshell to get the static clone, then discard it. let existingBrowser = args.getProperty("previewBrowser"); if (existingBrowser) { sourceBrowsingContext = existingBrowser.browsingContext; this.previewBrowser.swapDocShells(existingBrowser); existingBrowser.remove(); } document.querySelector("#print-selection-container").hidden = !this .hasSelection; let sourcePrincipal = sourceBrowsingContext.currentWindowGlobal.documentPrincipal; let sourceIsPdf = !sourcePrincipal.isNullPrincipal && sourcePrincipal.spec == PDF_JS_URI; this.originalSourceContentTitle = sourceBrowsingContext.currentWindowContext.documentTitle; this.originalSourceCurrentURI = sourceBrowsingContext.currentWindowContext.documentURI.spec; this.sourceWindowId = this.printFrameOnly ? sourceBrowsingContext.currentWindowGlobal.outerWindowId : sourceBrowsingContext.top.embedderElement.browsingContext .currentWindowGlobal.outerWindowId; this.selectionWindowId = sourceBrowsingContext.currentWindowGlobal.outerWindowId; // 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; 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.getSourceBrowsingContext().top.embedderElement; let dialogBoxManager = gBrowser .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(); settings.showPrintProgress = false; // 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.previewBrowser.browsingContext.embedderElement.contentTitle; const PRINTPROMPTSVC = Cc[ "@mozilla.org/embedcomp/printingprompt-service;1" ].getService(Ci.nsIPrintingPromptService); try { Services.telemetry.scalarAdd( "printing.dialog_opened_via_preview_tm", 1 ); await this._showPrintDialog(PRINTPROMPTSVC, window, settings); } catch (e) { if (e.result == Cr.NS_ERROR_ABORT) { Services.telemetry.scalarAdd( "printing.dialog_via_preview_cancelled_tm", 1 ); window.close(); return; // user cancelled } throw e; } await this.print(settings); }); let settingsToChange = await this.refreshSettings(selectedPrinter.value); await this.updateSettings(settingsToChange, true); 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, }) ); await document.l10n.translateElements([this.previewBrowser]); if (this.selectionPreviewBrowser) { await document.l10n.translateElements([this.selectionPreviewBrowser]); } document.body.removeAttribute("loading"); await new Promise(resolve => window.requestAnimationFrame(resolve)); // Now that we're showing the form, select the destination select. window.focus(); let fm = Services.focus; fm.setFocus(document.getElementById("printer-picker"), fm.FLAG_SHOWRING); await initialPreviewDone; }, unload() { this.previewBrowser.frameLoader.exitPrintPreview(); if (this.selectionPreviewBrowser) { this.selectionPreviewBrowser.frameLoader.exitPrintPreview(); } }, 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.originalSourceContentTitle, this.originalSourceCurrentURI ); } catch (e) { return false; } } await window._initialized; // This seems like it should be handled automatically but it isn't. Services.prefs.setStringPref("print_printer", settings.printerName); try { // We'll provide our own progress indicator. this.settings.showPrintProgress = false; 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.currentPreviewBrowser.browsingContext; await this._doPrint(bc, settings); } catch (e) { Cu.reportError(e); } window.close(); return true; }, 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.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; 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; if ( parseFloat(this.viewSettings.customMargins.marginTop) + parseFloat(this.viewSettings.customMargins.marginBottom) > height - paperWrapper.unwriteableMarginTop - paperWrapper.unwriteableMarginBottom || this.viewSettings.customMargins.marginTop < 0 || this.viewSettings.customMargins.marginBottom < 0 ) { let { marginTop, marginBottom } = this.viewSettings.defaultMargins; changedSettings.marginTop = changedSettings.customMarginTop = marginTop; changedSettings.marginBottom = changedSettings.customMarginBottom = marginBottom; delete this._userChangedSettings.customMargins; } if ( parseFloat(this.viewSettings.customMargins.marginRight) + parseFloat(this.viewSettings.customMargins.marginLeft) > width - paperWrapper.unwriteableMarginRight - paperWrapper.unwriteableMarginLeft || this.viewSettings.customMargins.marginLeft < 0 || this.viewSettings.customMargins.marginRight < 0 ) { let { marginLeft, marginRight } = this.viewSettings.defaultMargins; changedSettings.marginLeft = changedSettings.customMarginLeft = marginLeft; changedSettings.marginRight = changedSettings.customMarginRight = marginRight; delete this._userChangedSettings.customMargins; } } 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.savePrintSettingsToPrefs(this.settings, true, 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._showRenderingIndicator(); 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; let { printSelectionOnly } = this.viewSettings; if (!this.selectionPreviewBrowser) { printSelectionOnly = false; } // We never want the progress dialog to show settings.showPrintProgress = false; this._showRenderingIndicator(); let sourceWinId; // If it's the first time loading this type of browser, get the stored window id. if (printSelectionOnly && !this._hasRenderedSelectionPreview) { sourceWinId = this.selectionWindowId; this._hasRenderedSelectionPreview = true; } else if (!printSelectionOnly && !this._hasRenderedPrimaryPreview) { sourceWinId = this.sourceWindowId; this._hasRenderedPrimaryPreview = true; } this.previewBrowser.parentElement.setAttribute( "previewtype", printSelectionOnly ? "selection" : "primary" ); this.currentPreviewBrowser = printSelectionOnly ? this.selectionPreviewBrowser : this.previewBrowser; 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; try { // This resolves with a PrintPreviewSuccessInfo dictionary. ({ totalPageCount, sheetCount, isEmpty, } = await this.currentPreviewBrowser.frameLoader.printPreview( settings, sourceWinId )); } catch (e) { this.reportPrintingError("PRINT_PREVIEW"); throw e; } 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(); } // Update the settings print options on whether there is a selection. settings.isPrintSelectionRBEnabled = this.hasSelection; document.dispatchEvent( new CustomEvent("page-count", { detail: { sheetCount, totalPages: totalPageCount }, }) ); this.currentPreviewBrowser.setAttribute("sheet-count", sheetCount); this._hideRenderingIndicator(); if (isFirstCall) { const elapsed = Date.now() - this.printInitiationTime; Services.telemetry .getHistogramById("PRINT_INIT_TO_PREVIEW_DOC_SHOWN_MS") .add(elapsed); } }, _showRenderingIndicator() { let stack = this.previewBrowser.parentElement; stack.setAttribute("rendering", true); document.body.setAttribute("rendering", true); }, _hideRenderingIndicator() { let stack = this.previewBrowser.parentElement; stack.removeAttribute("rendering"); document.body.removeAttribute("rendering"); }, getSourceBrowsingContext() { let params = new URLSearchParams(location.search); let browsingContextId = params.get("browsingContextId"); if (!browsingContextId) { return null; } return BrowsingContext.get(browsingContextId); }, async getPrintDestinations() { const printerList = Cc["@mozilla.org/gfx/printerlist;1"].createInstance( Ci.nsIPrinterList ); let printers; if (Cu.isInAutomation) { printers = await Promise.resolve(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, paper) { switch (marginSize) { case "minimum": return { marginTop: paper.unwriteableMarginTop, marginLeft: paper.unwriteableMarginLeft, marginBottom: paper.unwriteableMarginBottom, marginRight: paper.unwriteableMarginRight, }; 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", paper); 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) { Services.telemetry.keyedScalarAdd("printing.error", aMessage, 1); }, /** * Prints the window. This method has been abstracted into a helper for * testing purposes. */ _doPrint(aBrowsingContext, aSettings) { return aBrowsingContext.top.embedderElement.print( aBrowsingContext.currentWindowGlobal.outerWindowId, aSettings ); }, /** * 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(aPrintingPromptService, aWindow, aSettings) { return aPrintingPromptService.showPrintDialog(aWindow, 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.newPrintSettings; printerInfo.defaultSettings.printerName = printerName; printerInfo.defaultSettings.toFileName = ""; printerInfo.defaultSettings.outputFormat = Ci.nsIPrintSettings.kOutputFormatPDF; printerInfo.defaultSettings.printToFile = true; printerInfo.paperList = this.fallbackPaperList; } printerInfo.settings = printerInfo.defaultSettings.clone(); // Apply any previously persisted user values let flags = printerInfo.settings.kInitSaveAll; if (printerName == PrintUtils.SAVE_TO_PDF_PRINTER) { // Don't apply potentially-bad printToFile setting that may be in some user's prefs. flags ^= 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; 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"); for (let presetName of ["none", "minimum", "default"]) { let marginPresets = allMarginPresets[presetName]; if ( Object.keys(marginSettings).every( name => marginSettings[name].toFixed(2) == marginPresets[name].toFixed(2) ) ) { 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": return target.duplex; 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; } 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; } 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": target.duplex = value ? Ci.nsIPrintSettings.kDuplexHorizontal : Ci.nsIPrintSettings.kSimplex; 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; 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"); if (AppConstants.platform != "win") { // Move the Print button to the end if this isn't Windows. this.printButton.parentElement.append(this.printButton); } this.querySelector("#pages-per-sheet").hidden = !Services.prefs.getBoolPref( "print.pages_per_sheet.enabled", false ); } removeNonPdfSettings() { let selectors = [ "#margins", "#headers-footers", "#backgrounds", "#print-selection-container", ]; 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("#copies").hidden = settings.willSaveToFile; 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("keypress", e => this.handleKeypress(e)); this.addEventListener("paste", e => this.handlePaste(e)); } connectedCallback() { this.type = "number"; this.settingName = this.dataset.settingName; super.connectedCallback(); } update(settings) { if (this.settingName) { this.value = settings[this.settingName]; } } handleKeypress(e) { let char = String.fromCharCode(e.charCode); let acceptedChar = e.target.step.includes(".") ? char.match(/^[0-9.]$/) : 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(); let acceptedChars = e.target.step.includes(".") ? paste.match(/^[0-9.]*$/) : paste.match(/^[0-9]*$/); if (!acceptedChars) { e.preventDefault(); } } handleEvent(e) { switch (e.type) { case "paste": this.handlePaste(); break; case "keypress": this.handleKeypress(); break; 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 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; } update(settings) { if (settings.printerName !== this._printerName) { this._printerName = settings.printerName; this.setOptions(settings.paperSizes); } this.value = settings.paperId; } } customElements.define("paper-size-select", PaperSizePicker, { extends: "select", }); class OrientationInput extends PrintUIControlMixin(HTMLElement) { get templateId() { return "orientation-template"; } update(settings) { for (let input of this.querySelectorAll("input")) { input.checked = settings.orientation == input.value; } } handleEvent(e) { this.dispatchSettingsChange({ orientation: e.target.value, }); } } customElements.define("orientation-input", OrientationInput); 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._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 isAll = this._rangePicker.value == "all"; if (isAll) { this._pagesSet.clear(); if (!this._rangeInput.checkValidity()) { this._rangeInput.setCustomValidity(""); this._rangeInput.value = ""; } } else { this.validateRangeInput(); } 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 || isAll) { 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 == "all") { 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._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 = this._rangePicker.value == "all" ? "" : this._rangeInput.value; this._validateRangeInput(value, this._numPages); } 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; 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 == "all"; this.updatePageRange(); this.dispatchPageRange(); } 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"); } get templateId() { return "margins-template"; } updateCustomMargins() { let newMargins = { marginTop: this._customTopMargin.value, marginBottom: this._customBottomMargin.value, marginLeft: this._customLeftMargin.value, marginRight: this._customRightMargin.value, }; this.dispatchSettingsChange({ margins: "custom", customMargins: newMargins, }); this._marginError.hidden = true; } updateMaxValues() { this._customTopMargin.max = this._maxHeight - this._customBottomMargin.value; this._customBottomMargin.max = this._maxHeight - this._customTopMargin.value; this._customLeftMargin.max = this._maxWidth - this._customRightMargin.value; this._customRightMargin.max = this._maxWidth - this._customLeftMargin.value; } formatMargin(target) { if (target.value.includes(".")) { if (target.value.split(".")[1].length > 2) { let dotIndex = target.value.indexOf("."); target.value = target.value.slice(0, dotIndex + 3); } } } setAllMarginValues(settings) { this._customTopMargin.value = parseFloat( settings.customMargins.marginTop ).toFixed(2); this._customBottomMargin.value = parseFloat( settings.customMargins.marginBottom ).toFixed(2); this._customLeftMargin.value = parseFloat( settings.customMargins.marginLeft ).toFixed(2); this._customRightMargin.value = parseFloat( settings.customMargins.marginRight ).toFixed(2); } update(settings) { // Re-evaluate which margin options should be enabled whenever the printer or paper changes 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; let height = this._orientation == 0 ? settings.paperHeight : settings.paperWidth; let width = this._orientation == 0 ? settings.paperWidth : settings.paperHeight; this._maxHeight = height - settings.unwriteableMarginTop - settings.unwriteableMarginBottom; this._maxWidth = width - settings.unwriteableMarginLeft - settings.unwriteableMarginRight; this._defaultPresets = settings.defaultMargins; // 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; } // 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); this.updateSummary(); } handleEvent(e) { let willOpen = !this.isOpen; this.updateSummary(willOpen); } updateSummary(open = false) { 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.render(); } render() { if (!this.numCopies || !this.sheetCount) { return; } document.l10n.setAttributes(this, "printui-sheets-count", { sheetCount: this.sheetCount * this.numCopies, }); // 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 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; }