UNPKG

light-print

Version:

Lightweight HTML element printing for browsers.

345 lines (336 loc) 13 kB
/*! * light-print v2.5.1 * (c) 2020-2025 xunmi * Released under the MIT License. */ 'use strict'; var isIE = function () { return /msie|trident/i.test(window.navigator.userAgent); }; var isString = function (val) { return typeof val === 'string'; }; var isElement = function (target) { return target instanceof Element; }; var appendNode = function (parent, child) { return parent.appendChild(child); }; var removeNode = function (node) { var _a; return (_a = node.parentNode) === null || _a === void 0 ? void 0 : _a.removeChild(node); }; function whichElement(node, name) { return node.localName === name; } var SHOW_ELEMENT = window.NodeFilter.SHOW_ELEMENT; function createElementIterator(root, filter) { // IE requires four parameters (entityReferenceExpansion: false) // @ts-expect-error return window.document.createNodeIterator(root, SHOW_ELEMENT, null, false); } function setStyleProperty(target, propertyName, value, priority) { target.style.setProperty(propertyName, String(value), priority); } function normalizeNode(target) { var _a; if (isElement(target)) return target; if (isString(target)) return (_a = window.document.querySelector(target)) !== null && _a !== void 0 ? _a : undefined; } function bindOnceEvent(el, eventName, listener, options) { var wrappedListener = function (event) { listener(event); el.removeEventListener(eventName, wrappedListener); }; el.addEventListener(eventName, wrappedListener, options); } /** * `Promise.withResolvers` polyfill */ function withResolvers() { if (Promise.withResolvers != null) return Promise.withResolvers(); var resolve; var reject; var promise = new Promise(function (_resolve, _reject) { resolve = _resolve; reject = _reject; }); // @ts-expect-error return { promise: promise, resolve: resolve, reject: reject }; } function NOOP() { } function getStyle(element, pseudoElt) { return element.ownerDocument.defaultView.getComputedStyle(element, pseudoElt); } function tryImportFonts(doc) { if (!doc.fonts) return; try { // If `document.fonts.forEach(...)` is used, // the console will still display uncaught exception messages. var iterator = window.document.fonts.values(); while (true) { var font = iterator.next().value; if (!font) break; // Can't add face to FontFaceSet that comes from `@font-face` rules for non-Chromium browsers. doc.fonts.add(font); } } catch (_a) { } } function waitFonts(doc) { var _a; return (_a = doc.fonts) === null || _a === void 0 ? void 0 : _a.ready.then(NOOP); } // `style` and `link` are not needed because of the use of `getComputedStyle` // `source` element is not needed because it depends on other elements. var RESOURCE_ELECTORS = ['img', 'audio', 'video', 'iframe', 'object', 'embed', 'image']; function getResourceURL(node) { if (whichElement(node, 'object')) return node.data; if (whichElement(node, 'iframe') || whichElement(node, 'embed')) return node.src; if (whichElement(node, 'image')) return node.href.baseVal; return node.currentSrc || node.src; } function checkLoaded(node) { if (whichElement(node, 'img') && node.complete) return; var _a = withResolvers(), promise = _a.promise, resolve = _a.resolve, reject = _a.reject; bindOnceEvent(node, 'load', function () { return resolve(); }); bindOnceEvent(node, 'error', function () { return reject(new Error("Failed to load resource (".concat(node.localName, ": ").concat(getResourceURL(node), ")."))); }); return promise; } /** wait for resources loaded */ function waitResources(doc) { var selectors = RESOURCE_ELECTORS.join(','); var resourceNodes = Array.from(doc.querySelectorAll(selectors)); var tasks = resourceNodes .filter(function (node) { return !!getResourceURL(node); }) .map(function (node) { // load the resource as soon as possible. node.setAttribute('loading', 'eager'); return checkLoaded(node); }); tasks.push(waitFonts(doc)); return Promise.all(tasks).then(NOOP); } var PSEUDO_ELECTORS = [ '::before', '::after', '::marker', '::first-letter', '::first-line', '::placeholder', '::file-selector-button', '::details-content', ]; var BLOCK_CONTAINERS = ['block', 'inline-block', 'list-item', 'flow-root', 'table-caption', 'table-cell']; function getStyleTextDiff(targetStyle, originStyle) { var styleText = ''; for (var index = 0; index < originStyle.length; index++) { var property = originStyle[index]; var value = originStyle.getPropertyValue(property); if (value && value !== targetStyle.getPropertyValue(property)) styleText += "".concat(property, ":").concat(value, ";"); } return styleText; } function getElementNonInlineStyle(target, origin) { // identical inline styles are omitted. return getStyleTextDiff(getStyle(target), getStyle(origin)); } function getPseudoElementStyle(target, origin, pseudoElt) { if (pseudoElt === '::placeholder') { if (!whichElement(origin, 'input') && !whichElement(origin, 'textarea')) return; } else if (pseudoElt === '::file-selector-button') { if (!(whichElement(origin, 'input') && origin.type === 'file')) return; } else if (pseudoElt === '::details-content') { if (!whichElement(origin, 'details')) return; } else if (pseudoElt === '::marker') { var display = getStyle(origin).display; if (display !== 'list-item') return; } else if (pseudoElt === '::first-letter' || pseudoElt === '::first-line') { var display = getStyle(origin).display; if (BLOCK_CONTAINERS.indexOf(display) < 0) return; } var originStyle = getStyle(origin, pseudoElt); // replaced elements need to be checked for `content`. if (pseudoElt === '::before' || pseudoElt === '::after') { var content = originStyle.content; if (!content || content === 'normal' || content === 'none') return; } var targetStyle = getStyle(target, pseudoElt); return getStyleTextDiff(targetStyle, originStyle); } /** clone element style */ function cloneElementStyle(target, origin) { var _a; var nonInlineStyle = getElementNonInlineStyle(target, origin); if (!nonInlineStyle) return; var inlineStyle = (_a = target.getAttribute('style')) !== null && _a !== void 0 ? _a : ''; // setting inline styles immediately triggers a layout recalculation, // and subsequently retrieve the correct styles of the child elements. target.setAttribute('style', "".concat(nonInlineStyle).concat(inlineStyle)); } function clonePseudoElementStyle(target, origin, context) { var styleText = ''; for (var _i = 0, PSEUDO_ELECTORS_1 = PSEUDO_ELECTORS; _i < PSEUDO_ELECTORS_1.length; _i++) { var pseudoElt = PSEUDO_ELECTORS_1[_i]; var style = getPseudoElementStyle(target, origin, pseudoElt); if (!style) continue; var selector = context.getSelector(target); styleText += "".concat(selector).concat(pseudoElt, "{").concat(style, "}"); } context.appendStyle(styleText); } /** clone canvas */ function cloneCanvas(target, origin) { target.getContext('2d').drawImage(origin, 0, 0); } function cloneElement(target, origin, context) { cloneElementStyle(target, origin); // clone the associated pseudo-elements only when it's not `SVGElement`. // using `origin` because `target` is not in the current window, and `instanceof` cannot be used for judgment. if (!(origin instanceof SVGElement)) clonePseudoElementStyle(target, origin, context); if (whichElement(target, 'canvas')) cloneCanvas(target, origin); } function cloneDocument(context, hostElement) { var doc = context.document; // clone the `hostElement` structure to `body`, contains inline styles. appendNode(doc.body, doc.importNode(hostElement, true)); var originIterator = createElementIterator(hostElement); // start from `body` node var targetIterator = createElementIterator(doc.body); // skip `body` node targetIterator.nextNode(); while (true) { var targetElement = targetIterator.nextNode(); var originElement = originIterator.nextNode(); if (!targetElement || !originElement) break; cloneElement(targetElement, originElement, context); } } function createContext() { var styleNode; var printId = 1; function markId(node) { var id = node.getAttribute('data-print-id'); if (!id) { id = (printId++).toString(); node.setAttribute('data-print-id', id); } return id; } function getSelector(node) { return "[data-print-id=\"".concat(markId(node), "\"]"); } function appendStyle(text) { if (!text) return; styleNode !== null && styleNode !== void 0 ? styleNode : (styleNode = context.document.createElement('style')); styleNode.textContent += text; } function mountStyle() { if (!styleNode) return; appendNode(context.document.head, styleNode); } var context = { window: undefined, get document() { return this.window.document; }, appendStyle: appendStyle, mountStyle: mountStyle, getSelector: getSelector, }; return context; } function createContainer() { var container = window.document.createElement('iframe'); container.srcdoc = '<!DOCTYPE html>'; setStyleProperty(container, 'position', 'absolute', 'important'); setStyleProperty(container, 'top', '-9999px', 'important'); setStyleProperty(container, 'visibility', 'hidden', 'important'); setStyleProperty(container, 'transform', 'scale(0)', 'important'); return container; } function mount(container, parent) { var _a = withResolvers(), promise = _a.promise, resolve = _a.resolve, reject = _a.reject; bindOnceEvent(container, 'load', function () { return resolve(); }); bindOnceEvent(container, 'error', function () { return reject(new Error('Failed to mount document.')); }); appendNode(parent, container); return promise; } function emitPrint(contentWindow) { var _a = withResolvers(), promise = _a.promise, resolve = _a.resolve; // required for IE contentWindow.focus(); // When the browser's network cache is disabled, // the execution end time of `print()` will be later than the `afterprint` event. // Conversely, the 'afterprint' event will be fired later. // Thus, both need to be completed to indicate that the printing process has ended. bindOnceEvent(contentWindow, 'afterprint', function () { return resolve(); }); if (isIE()) { try { contentWindow.document.execCommand('print', false); } catch (_b) { contentWindow.print(); } } else { contentWindow.print(); } return promise; } /** * Print the HTML element. * @param containerOrSelector An actual HTML element or a CSS selector. * @param options Print options. */ function lightPrint(containerOrSelector, options) { if (options === void 0) { options = {}; } var hostElement = normalizeNode(containerOrSelector); // ensure to return a rejected promise. if (!hostElement) return Promise.reject(new Error('Invalid HTML element.')); var container = createContainer(); var context = createContext(); // must be mounted and loaded before using `contentWindow` for Firefox. return mount(container, window.document.body) .then(function () { var _a, _b; context.window = container.contentWindow; var doc = context.document; doc.title = (_a = options.documentTitle) !== null && _a !== void 0 ? _a : window.document.title; setStyleProperty(doc.documentElement, 'zoom', (_b = options.zoom) !== null && _b !== void 0 ? _b : 1); // remove the default margin. setStyleProperty(doc.body, 'margin', 0); tryImportFonts(doc); cloneDocument(context, hostElement); // style of highest priority. context.appendStyle(options.mediaPrintStyle); // mount after all styles have been generated. context.mountStyle(); }) .then(function () { return waitResources(context.document); }) .then(function () { return emitPrint(context.window); }) .finally(function () { // The container can only be destroyed after the printing process has been completed. return removeNode(container); }); } module.exports = lightPrint;