UNPKG

ngx-print

Version:

Plug n' Play Angular directive to print your stuff

494 lines (485 loc) 22.3 kB
import * as i0 from '@angular/core'; import { inject, DOCUMENT, CSP_NONCE, Service, input, output, Directive, NgModule } from '@angular/core'; import { Subject, take } from 'rxjs'; class PrintOptions { printSectionId = ''; printTitle = ''; useExistingCss = false; bodyClass = ''; printMethod = 'window'; previewOnly = false; closeWindow = true; printDelay = 0; constructor(options) { if (options) { Object.assign(this, options); } } } class PrintBase { document = inject(DOCUMENT); nonce = inject(CSP_NONCE, { optional: true }); _iframeElement; _printStyle = []; _styleSheetFile = ''; printComplete = new Subject(); //#region Getters and Setters /** * Sets the print styles based on the provided values. * * @param values - Either a key-value pairs object representing print styles, or a raw CSS string. * @protected */ setPrintStyle(values) { if (typeof values === 'string') { this._printStyle = values ? [values] : []; return; } this._printStyle = []; for (const [selector, declarations] of Object.entries(values)) { // Built declaration-by-declaration (rather than via JSON.stringify + a quote-stripping // regex) so that quotes and commas that are part of a CSS value (e.g. quoted font names, // comma-separated font-family fallback lists) survive instead of being stripped/mangled. const body = Object.entries(declarations) .map(([property, value]) => `${property}:${value}`) .join(';'); this._printStyle.push(`${selector}{${body}}`); } } /** * @returns the string that create the stylesheet which will be injected * later within <style></style> tag. */ returnStyleValues() { const styleNonce = this.nonce ? ` nonce="${this.nonce}"` : ''; return `<style${styleNonce}> ${this._printStyle.join(' ')} </style>`; } /** * @returns string which contains the link tags containing the css which will * be injected later within <head></head> tag. * */ returnStyleSheetLinkTags() { return this._styleSheetFile; } /** * Sets the style sheet file based on the provided CSS list. * * @param {string} cssList - CSS file or list of CSS files. * @protected */ // prettier-ignore setStyleSheetFile(cssList) { if (!cssList) { this._styleSheetFile = ''; return; } const files = cssList.split(',').map(f => f.trim()); const nonceAttr = this.nonce ? ` nonce="${this.nonce}"` : ''; this._styleSheetFile = files .map(url => `<link${nonceAttr} rel="stylesheet" type="text/css" href="${url}">`) .join(''); } //#endregion //#region Private methods used by PrintBase syncFormValues(source, clone) { // Select all form elements const selector = 'input, select, textarea'; const sourceEls = source.querySelectorAll(selector); const cloneEls = clone.querySelectorAll(selector); for (let i = 0; i < sourceEls.length; i++) { const srcNode = sourceEls[i]; const cloneNode = cloneEls[i]; if (srcNode instanceof HTMLInputElement) { if (srcNode.type === 'checkbox' || srcNode.type === 'radio') { if (srcNode.checked) cloneNode.setAttribute('checked', ''); else cloneNode.removeAttribute('checked'); // Remove if unchecked } else if (srcNode.type === 'file') { // File inputs can't be set programmatically for security continue; } else { cloneNode.setAttribute('value', srcNode.value); } } else if (srcNode instanceof HTMLTextAreaElement) { cloneNode.textContent = srcNode.value; // Use textContent, not innerHTML } else if (srcNode instanceof HTMLSelectElement) { Array.from(cloneNode.options).forEach((opt, idx) => { if (idx === srcNode.selectedIndex) { opt.setAttribute('selected', ''); } else { opt.removeAttribute('selected'); // Remove from non-selected } }); } } } /** * Converts a canvas element to an image and returns its HTML string. * * @param {HTMLCanvasElement} canvasElm - The canvas element to convert. * @returns {HTMLImageElement | null} - HTML Element of the image. * @private */ canvasToImageHtml(canvasElm) { try { const dataUrl = canvasElm.toDataURL(); // may throw if canvas is tainted const img = this.document.createElement('img'); img.src = dataUrl; img.style.maxWidth = '100%'; // Preserve displayed size (not just bitmap size) const rect = canvasElm.getBoundingClientRect(); if (rect.width) img.style.width = `${rect.width}px`; if (rect.height) img.style.height = `${rect.height}px`; return img; } catch (err) { console.warn(`Canvas conversion failed for ${canvasElm}. Likely the canvas is tainted:`, err); // If toDataURL() fails (e.g., tainted canvas), keep canvas as-is in print output return null; } } /** * Includes canvas contents in the print section via img tags. * * @private * @param source * @param clone */ updateCanvasToImage(source, clone) { const sourceCanvases = source.querySelectorAll('canvas'); const cloneCanvases = clone.querySelectorAll('canvas'); for (let i = 0; i < sourceCanvases.length; i++) { const srcCanvas = sourceCanvases[i]; const cloneCanvas = cloneCanvases[i]; const img = this.canvasToImageHtml(srcCanvas); if (img) { cloneCanvas.replaceWith(img); } } } /** * Retrieves the HTML content of a specified printing section. * * @param {string} printSectionId - Id of the printing section. * @returns {string | null} - HTML content of the printing section, or null if not found. * @private */ getHtmlContents(printSectionId) { const sourceElm = this.document.getElementById(printSectionId); if (!sourceElm) return null; const cloneElm = sourceElm.cloneNode(true); // cloneNode(true) deep clones subtree this.syncFormValues(sourceElm, cloneElm); this.updateCanvasToImage(sourceElm, cloneElm); return cloneElm.outerHTML; } /** * Retrieves the HTML content of elements with the specified tag. * * @param {keyof HTMLElementTagNameMap} tag - HTML tag name. * @returns {string} - Concatenated outerHTML of elements with the specified tag. * @private */ getElementTag(tag) { const html = []; const elements = this.document.getElementsByTagName(tag); for (const el of Array.from(elements)) { html.push(el.outerHTML); } return html.join('\r\n'); } //#endregion notifyPrintComplete() { this.printComplete.next(); } /** * Prints the specified content using the provided print options. * * @public * @param printOptionInput - Options for printing. */ print(printOptionInput) { const printOptions = new PrintOptions(printOptionInput); if (printOptions.printMethod === 'iframe') { this.printWithIframe(printOptions); } else { this.printWithWindow(printOptions); } } printWithWindow(printOptions) { // If the openNewTab option is set to true, then set the popOut option to an empty string // This will cause the print dialog to open in a new tab. const popOut = printOptions.printMethod === 'tab' ? '' : 'top=0,left=0,height=auto,width=auto'; const popupWin = window.open('', '_blank', popOut); if (!popupWin) { // the popup window could not be opened. console.error('Could not open print window.'); return; } popupWin.document.open(); // Create the HTML structure this.buildPrintDocument(popupWin.document, printOptions); popupWin.document.close(); // Listen for the window closing const checkClosedInterval = setInterval(() => { if (popupWin.closed) { clearInterval(checkClosedInterval); this.notifyPrintComplete(); } }, 500); popupWin.addEventListener('load', () => { if (!printOptions.previewOnly) { setTimeout(() => { popupWin.print(); if (printOptions.closeWindow) popupWin.close(); }, printOptions.printDelay || 0); } }); } printWithIframe(printOptions) { if (this._iframeElement) { this._iframeElement.remove(); } this._iframeElement = this.document.createElement('iframe'); const iframe = this._iframeElement; iframe.id = 'print-iframe-' + new Date().getTime(); iframe.style.position = 'absolute'; iframe.style.left = '-9999px'; iframe.style.top = '-9999px'; iframe.style.width = '0px'; iframe.style.height = '0px'; iframe.ariaHidden = 'true'; this.document.body.appendChild(iframe); const iframeDoc = iframe.contentDocument || iframe.contentWindow?.document; if (!iframeDoc) { console.error('Could not access iframe document.'); this.document.body.removeChild(iframe); return; } iframeDoc.open(); const success = this.buildPrintDocument(iframeDoc, printOptions); if (!success) { iframeDoc.close(); this.document.body.removeChild(iframe); return; } iframeDoc.close(); iframe.onload = () => { const printWindow = iframe.contentWindow; if (!printWindow) { console.error('Could not access iframe window.'); this.document.body.removeChild(iframe); return; } setTimeout(() => { if (printOptions.previewOnly) { return; } printWindow.focus(); printWindow.print(); const mediaQueryList = printWindow.matchMedia('print'); const listener = (mql) => { if (!mql.matches) { this.notifyPrintComplete(); mediaQueryList.removeEventListener('change', listener); } }; mediaQueryList.addEventListener('change', listener); }, printOptions.printDelay || 0); }; } prepareDocumentComponents(printOptions) { let styles = ''; let links = ''; const baseTag = this.getElementTag('base'); if (printOptions.useExistingCss) { styles = this.getElementTag('style'); links = this.getElementTag('link'); } const printContents = this.getHtmlContents(printOptions.printSectionId); return { styles, links, baseTag, printContents }; } buildPrintDocument(doc, printOptions) { const components = this.prepareDocumentComponents(printOptions); if (!components.printContents) { console.error(`Print section with id "${printOptions.printSectionId}" not found.`); return false; } const html = doc.createElement('html'); const head = doc.createElement('head'); const body = doc.createElement('body'); // Set title const title = doc.createElement('title'); title.textContent = printOptions.printTitle || ''; head.appendChild(title); // Add all head content if (components.baseTag) { head.innerHTML += components.baseTag; } head.innerHTML += this.returnStyleValues(); head.innerHTML += this.returnStyleSheetLinkTags(); head.innerHTML += components.styles; head.innerHTML += components.links; // Set body class and content if (printOptions.bodyClass) body.className = printOptions.bodyClass; body.innerHTML += components.printContents; // Assemble document html.appendChild(head); html.appendChild(body); doc.appendChild(html); return true; } static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.2", ngImport: i0, type: PrintBase, deps: [], target: i0.ɵɵFactoryTarget.Service }); static ɵprov = i0.ɵɵngDeclareService({ minVersion: "22.0.0", version: "22.0.2", ngImport: i0, type: PrintBase }); } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.2", ngImport: i0, type: PrintBase, decorators: [{ type: Service }] }); class NgxPrintDirective extends PrintBase { /** * Prevents the print dialog from opening on the window */ previewOnly = input(false, /* @ts-ignore */ ...(ngDevMode ? [{ debugName: "previewOnly" }] : /* istanbul ignore next */ [])); printSectionId = input('', /* @ts-ignore */ ...(ngDevMode ? [{ debugName: "printSectionId" }] : /* istanbul ignore next */ [])); printTitle = input('', /* @ts-ignore */ ...(ngDevMode ? [{ debugName: "printTitle" }] : /* istanbul ignore next */ [])); useExistingCss = input(false, /* @ts-ignore */ ...(ngDevMode ? [{ debugName: "useExistingCss" }] : /* istanbul ignore next */ [])); /** * A delay in milliseconds to force the print dialog to wait before opened. Default: 0 */ printDelay = input(0, /* @ts-ignore */ ...(ngDevMode ? [{ debugName: "printDelay" }] : /* istanbul ignore next */ [])); /** * Whether to close the window after print() returns. */ closeWindow = input(true, /* @ts-ignore */ ...(ngDevMode ? [{ debugName: "closeWindow" }] : /* istanbul ignore next */ [])); /** * Class attribute to apply to the body element. */ bodyClass = input('', /* @ts-ignore */ ...(ngDevMode ? [{ debugName: "bodyClass" }] : /* istanbul ignore next */ [])); /** * Which PrintMethod (iframe/window/tab) to use. */ printMethod = input('window', /* @ts-ignore */ ...(ngDevMode ? [{ debugName: "printMethod" }] : /* istanbul ignore next */ [])); printStyle = input({}, /* @ts-ignore */ ...(ngDevMode ? [{ debugName: "printStyle" }] : /* istanbul ignore next */ [])); styleSheetFile = input('', /* @ts-ignore */ ...(ngDevMode ? [{ debugName: "styleSheetFile" }] : /* istanbul ignore next */ [])); printCompleted = output(); print() { // Inputs carry side effects on PrintBase's internal style state, so they're applied // synchronously here rather than via effect() (effects shouldn't propagate state). super.setPrintStyle(this.printStyle()); super.setStyleSheetFile(this.styleSheetFile()); super.print({ printSectionId: this.printSectionId(), printTitle: this.printTitle(), useExistingCss: this.useExistingCss(), bodyClass: this.bodyClass(), printMethod: this.printMethod(), previewOnly: this.previewOnly(), closeWindow: this.closeWindow(), printDelay: this.printDelay(), }); this.printComplete.pipe(take(1)).subscribe(() => { this.printCompleted.emit(); }); } static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.2", ngImport: i0, type: NgxPrintDirective, deps: null, target: i0.ɵɵFactoryTarget.Directive }); static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.1.0", version: "22.0.2", type: NgxPrintDirective, isStandalone: true, selector: "[ngxPrint]", inputs: { previewOnly: { classPropertyName: "previewOnly", publicName: "previewOnly", isSignal: true, isRequired: false, transformFunction: null }, printSectionId: { classPropertyName: "printSectionId", publicName: "printSectionId", isSignal: true, isRequired: false, transformFunction: null }, printTitle: { classPropertyName: "printTitle", publicName: "printTitle", isSignal: true, isRequired: false, transformFunction: null }, useExistingCss: { classPropertyName: "useExistingCss", publicName: "useExistingCss", isSignal: true, isRequired: false, transformFunction: null }, printDelay: { classPropertyName: "printDelay", publicName: "printDelay", isSignal: true, isRequired: false, transformFunction: null }, closeWindow: { classPropertyName: "closeWindow", publicName: "closeWindow", isSignal: true, isRequired: false, transformFunction: null }, bodyClass: { classPropertyName: "bodyClass", publicName: "bodyClass", isSignal: true, isRequired: false, transformFunction: null }, printMethod: { classPropertyName: "printMethod", publicName: "printMethod", isSignal: true, isRequired: false, transformFunction: null }, printStyle: { classPropertyName: "printStyle", publicName: "printStyle", isSignal: true, isRequired: false, transformFunction: null }, styleSheetFile: { classPropertyName: "styleSheetFile", publicName: "styleSheetFile", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { printCompleted: "printCompleted" }, host: { listeners: { "click": "print()" } }, usesInheritance: true, ngImport: i0 }); } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.2", ngImport: i0, type: NgxPrintDirective, decorators: [{ type: Directive, args: [{ selector: '[ngxPrint]', standalone: true, host: { '(click)': 'print()', }, }] }], propDecorators: { previewOnly: [{ type: i0.Input, args: [{ isSignal: true, alias: "previewOnly", required: false }] }], printSectionId: [{ type: i0.Input, args: [{ isSignal: true, alias: "printSectionId", required: false }] }], printTitle: [{ type: i0.Input, args: [{ isSignal: true, alias: "printTitle", required: false }] }], useExistingCss: [{ type: i0.Input, args: [{ isSignal: true, alias: "useExistingCss", required: false }] }], printDelay: [{ type: i0.Input, args: [{ isSignal: true, alias: "printDelay", required: false }] }], closeWindow: [{ type: i0.Input, args: [{ isSignal: true, alias: "closeWindow", required: false }] }], bodyClass: [{ type: i0.Input, args: [{ isSignal: true, alias: "bodyClass", required: false }] }], printMethod: [{ type: i0.Input, args: [{ isSignal: true, alias: "printMethod", required: false }] }], printStyle: [{ type: i0.Input, args: [{ isSignal: true, alias: "printStyle", required: false }] }], styleSheetFile: [{ type: i0.Input, args: [{ isSignal: true, alias: "styleSheetFile", required: false }] }], printCompleted: [{ type: i0.Output, args: ["printCompleted"] }] } }); class NgxPrintModule { static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.2", ngImport: i0, type: NgxPrintModule, deps: [], target: i0.ɵɵFactoryTarget.NgModule }); static ɵmod = i0.ɵɵngDeclareNgModule({ minVersion: "14.0.0", version: "22.0.2", ngImport: i0, type: NgxPrintModule, imports: [NgxPrintDirective], exports: [NgxPrintDirective] }); static ɵinj = i0.ɵɵngDeclareInjector({ minVersion: "12.0.0", version: "22.0.2", ngImport: i0, type: NgxPrintModule }); } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.2", ngImport: i0, type: NgxPrintModule, decorators: [{ type: NgModule, args: [{ imports: [NgxPrintDirective], exports: [NgxPrintDirective], }] }] }); /** * Service for handling printing functionality in Angular applications. * Extends the base printing class (PrintBase). * * @export * @class NgxPrintService * @extends {PrintBase} */ class NgxPrintService extends PrintBase { printComplete$ = this.printComplete.asObservable(); /** * Initiates the printing process using the provided print options. * * @param {PrintOptions} printOptions - Options for configuring the printing process. * @memberof NgxPrintService * @returns {void} */ print(printOptions) { // Call the print method in the parent class super.print(printOptions); } /** * Sets the print style for the printing process. * * @param values - Either a dictionary representing the print styles, or a raw CSS string. * @memberof NgxPrintService * @setter */ set printStyle(values) { super.setPrintStyle(values); } /** * Sets the stylesheet file for the printing process. * * @param {string} cssList - A string representing the path to the stylesheet file. * @memberof NgxPrintService * @setter */ set styleSheetFile(cssList) { super.setStyleSheetFile(cssList); } static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.2", ngImport: i0, type: NgxPrintService, deps: [], target: i0.ɵɵFactoryTarget.Service }); static ɵprov = i0.ɵɵngDeclareService({ minVersion: "22.0.0", version: "22.0.2", ngImport: i0, type: NgxPrintService }); } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.2", ngImport: i0, type: NgxPrintService, decorators: [{ type: Service }] }); /* * Public API Surface of ngx-print */ /** * Generated bundle index. Do not edit. */ export { NgxPrintDirective, NgxPrintModule, NgxPrintService, PrintOptions }; //# sourceMappingURL=ngx-print.mjs.map