UNPKG

light-print

Version:

Lightweight HTML element printing for browsers.

600 lines (590 loc) 23.3 kB
/*! * light-print v2.8.0 * (c) 2020-2026 xunmi * Released under the MIT License. */ 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; }; function includes(value, array) { // Need to be compatible with `IE` return array.indexOf(value) >= 0; } function appendNode(parent, child) { parent.appendChild(child); } function removeNode(node) { var _a; (_a = node.parentNode) === null || _a === void 0 ? void 0 : _a.removeChild(node); } function whichElement(el, name) { return el.localName === name; } function isMediaElement(el) { return whichElement(el, 'audio') || whichElement(el, 'video'); } // `slot`, `style`, etc. default to `display: none` but can still be rendered if override their display. var NON_RENDERING_ELEMENTS = ['source', 'track', 'wbr']; function isRenderingElement(el) { return !includes(el.localName, NON_RENDERING_ELEMENTS); } function isHidden(style) { return !style.display || style.display === 'none'; } function isExternalStyleElement(el) { return whichElement(el, 'style') || (whichElement(el, 'link') && el.rel === 'stylesheet'); } /** * @internal * Exporting this constant is solely for the convenience of testing. */ var BLOCK_CONTAINER_DISPLAY = [ 'block', 'inline-block', 'list-item', 'flow-root', 'table-caption', 'table-cell', 'table-column', 'table-column-group', ]; /** Block container * @see https://developer.mozilla.org/docs/Web/CSS/CSS_display/Visual_formatting_model#block_containers */ function isBlockContainer(style) { return includes(style.display, BLOCK_CONTAINER_DISPLAY); } 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(target, eventName, listener, options) { var wrappedListener = function (event) { listener(event); target.removeEventListener(eventName, wrappedListener); }; target.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 getOwnerWindow(element) { return element.ownerDocument.defaultView; } function createElementWalker(root) { // `1` is `NodeFilter.SHOW_ELEMENT` // IE requires four parameters (expandEntityReferences: false) // @ts-expect-error return window.document.createTreeWalker(root, 1, null, false); } function traverse(visitor, target, origin) { var targetWalker = createElementWalker(target); var originWalker = createElementWalker(origin); while (true) { var isNext = visitor(targetWalker.currentNode, originWalker.currentNode); if (isNext) { if (!(targetWalker.nextNode() && originWalker.nextNode())) break; } else { var skippedNode = targetWalker.currentNode; var hasParent = true; while (true) { var hasSibling = targetWalker.nextSibling() && originWalker.nextSibling(); if (hasSibling) break; // If the current element has no next sibling, move to the next sibling of its parent. hasParent = !!(targetWalker.parentNode() && originWalker.parentNode()); if (!hasParent) break; } // Remove the skipped element and its subtree, to prevent any resources from being loaded. removeNode(skippedNode); if (!hasParent) break; } } } 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); } // `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; // load the resource as soon as possible. if (whichElement(node, 'img') || whichElement(node, 'iframe')) node.loading = 'eager'; var _a = withResolvers(), promise = _a.promise, resolve = _a.resolve, reject = _a.reject; if (isMediaElement(node)) { // `2` is `HTMLMediaElement.HAVE_CURRENT_DATA` if (node.readyState >= 2) resolve(); bindOnceEvent(node, 'canplay', function () { return resolve(); }); } else { 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) { return checkLoaded(node); }); tasks.push(waitFonts(doc)); return Promise.all(tasks).then(NOOP); } function getStyle(element, pseudoElt) { return getOwnerWindow(element).getComputedStyle(element, pseudoElt); } function compareRule(selector, text) { return "".concat(selector, "{").concat(text, "}"); } function compare(cssText, property, value) { return value ? cssText + property + ': ' + value + ';' : cssText; } function isBorderChanged(targetStyle, originStyle) { var border = originStyle.borderStyle; return border !== targetStyle.borderStyle && border !== 'none' && border !== 'hidden'; } function isSizeChanged(targetStyle, originStyle) { // Changing `padding`, `margin` or `border` alters the element’s size. return (originStyle.boxSizing === 'border-box' && (originStyle.padding !== targetStyle.padding || originStyle.borderWidth !== targetStyle.borderWidth)); } /** Whether the element has intrinsic aspect ratio */ function isIntrinsicAspectRatio(el, style) { if (whichElement(el, 'img') || whichElement(el, 'video')) return true; // SVG element’s aspect ratio is dictated by its `viewBox` by default. if (whichElement(el, 'svg') && el.getAttribute('viewBox')) return true; return !!style.aspectRatio && style.aspectRatio !== 'auto'; } function diff(cssText, properties, targetStyle, originStyle) { for (var i = 0; i < properties.length; i++) { var property = properties[i]; var value = originStyle.getPropertyValue(property); if (value && value !== targetStyle.getPropertyValue(property)) { cssText = compare(cssText, property, value); } } return cssText; } // When accessing `CSSStyleDeclaration` by index, the property name doesn’t include `counter`. var CSS_PROPERTIES_ADDED = ['counter-reset', 'counter-set', 'counter-increment']; function getCSSText(targetStyle, originStyle, origin) { var cssText = ''; cssText = diff(cssText, originStyle, targetStyle, originStyle); cssText = diff(cssText, CSS_PROPERTIES_ADDED, targetStyle, originStyle); // If `border-style` is neither `none` nor `hidden`, the browser falls back the corresponding `border-width` to its initial value—medium // (3 px per spec, though engines variously resolve it to 2 px or 3 px). if (isBorderChanged(targetStyle, originStyle)) { cssText = compare(cssText, 'border-width', originStyle.borderWidth); } // For elements with an aspect ratio, always supply both width and height // to prevent incorrect auto-sizing based on that ratio. if (isSizeChanged(targetStyle, originStyle) || isIntrinsicAspectRatio(origin, originStyle)) { cssText = compare(cssText, 'width', originStyle.width); cssText = compare(cssText, 'height', originStyle.height); } // The `table` layout is always influenced by content; // whether `table-layout` is `auto` or `fixed`, we must give the table an explicit width to ensure accuracy. else if (originStyle.display === 'table') { cssText = compare(cssText, 'width', originStyle.width); } return cssText; } /** Clone element style; identical inline styles are omitted. */ function getElementStyle(target, origin, originStyle) { return getCSSText(getStyle(target), originStyle, origin); } var PSEUDO_ELECTORS = [ '::before', '::after', '::marker', '::first-letter', '::first-line', '::placeholder', '::file-selector-button', '::details-content', ]; function getPseudoElementStyle(target, origin, originStyle, pseudoElt) { if (pseudoElt === '::placeholder') { if (!((whichElement(origin, 'input') || whichElement(origin, 'textarea')) && origin.placeholder)) 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') { if (originStyle.display !== 'list-item') return; } else if (pseudoElt === '::first-letter' || pseudoElt === '::first-line') { if (!isBlockContainer(originStyle)) return; } var pseudoOriginStyle = getStyle(origin, pseudoElt); // replaced elements need to be checked for `content`. if (pseudoElt === '::before' || pseudoElt === '::after') { var content = pseudoOriginStyle.content; if (!content || content === 'normal' || content === 'none') return; } return getCSSText(getStyle(target, pseudoElt), pseudoOriginStyle, origin); } /** * Attribute name used to mark elements for printing. * @internal * Exporting this constant is solely for the convenience of testing. */ var SELECTOR_NAME = 'data-print-id'; function createContext() { var styleNode; var isMountedStyle = false; var printId = 1; var doc; function bind(ownerDocument) { doc = ownerDocument; } function markId(node) { var id = node.getAttribute(SELECTOR_NAME); if (!id) { id = (printId++).toString(); node.setAttribute(SELECTOR_NAME, id); } return id; } function getSelector(node) { return "[".concat(SELECTOR_NAME, "=\"").concat(markId(node), "\"]"); } function appendStyle(text) { if (!text) return; styleNode !== null && styleNode !== void 0 ? styleNode : (styleNode = doc.createElement('style')); styleNode.textContent += text; } function mountStyle(parent) { if (isMountedStyle || !styleNode) return; appendNode(parent || doc.head, styleNode); isMountedStyle = true; } var tasks = []; function addTask(task) { tasks.push(task); } function flushTasks() { tasks.forEach(function (task) { return task(); }); tasks.length = 0; } return { get document() { return doc; }, bind: bind, appendStyle: appendStyle, mountStyle: mountStyle, getSelector: getSelector, addTask: addTask, flushTasks: flushTasks, }; } function attachShadow(target, origin) { return target.attachShadow({ mode: 'open', delegatesFocus: origin.shadowRoot.delegatesFocus }); } function cloneNode(ownerDocument, shadowRoot) { var fragment = ownerDocument.createDocumentFragment(); shadowRoot.childNodes.forEach(function (node) { return fragment.appendChild(ownerDocument.importNode(node, true)); }); return fragment; } function cloneSheets(target, origin) { var cssText = origin.shadowRoot.adoptedStyleSheets .flatMap(function (sheet) { return Array.from(sheet.cssRules).map(function (rule) { return rule.cssText; }); }) .join('\n'); if (!cssText) return; var ownerWindow = getOwnerWindow(target); var sheet = new ownerWindow.CSSStyleSheet(); sheet.replaceSync(cssText); target.shadowRoot.adoptedStyleSheets.push(sheet); } /** * Clone element with shadow root (mode: 'open'). * Only modern browsers, not IE */ function cloneOpenShadowRoot(target, origin, visitor) { // Should the shadowRoot be clonable, delegate its cloning to the earlier `importNode` for uniform handling. if (!origin.shadowRoot.clonable) { var ownerDocument = target.ownerDocument; var context_1 = createContext(); context_1.bind(ownerDocument); // `happy-dom` BUG in unit tests; clones the `shadowRoot` when cloning a custom element attachShadow(target, origin); var shadowRoot_1 = target.shadowRoot; shadowRoot_1.appendChild(cloneNode(ownerDocument, origin.shadowRoot)); traverse(function (innerTarget, innerOrigin) { if (innerTarget === shadowRoot_1) return true; return visitor(innerTarget, innerOrigin, context_1); }, shadowRoot_1, origin.shadowRoot); context_1.flushTasks(); context_1.mountStyle(shadowRoot_1); } cloneSheets(target, origin); } function isOpenShadowElement(el) { var _a; return ((_a = el.shadowRoot) === null || _a === void 0 ? void 0 : _a.mode) === 'open'; } /** clone element style */ function cloneElementStyle(target, origin, originStyle, context) { // identical inline styles are omitted. var injectionStyle = getElementStyle(target, origin, originStyle); if (!injectionStyle) return; var cssText = origin.style.cssText + injectionStyle; // Inline style trigger an immediate layout reflow, // after which fewer and correct rules have to be resolved for the children; in practice this is measurably faster. // The downside is their sky-high specificity: overriding them with mediaPrintStyle is painful, // We therefore strip the inline declarations once cloning finishes and hand the job over to a clean style sheet. target.setAttribute('style', cssText); var styleRule = compareRule(context.getSelector(target), cssText); context.addTask(function () { // Inline style carry higher specificity; strip them to let the `injectionStyle` (external style) prevail. target.removeAttribute('style'); context.appendStyle(styleRule); }); } function clonePseudoElementStyle(target, origin, originStyle, context) { if (origin instanceof SVGElement) return; var styleRules = ''; var selector; 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, originStyle, pseudoElt); if (!style) continue; selector !== null && selector !== void 0 ? selector : (selector = context.getSelector(target)); styleRules += compareRule(selector + pseudoElt, style); } context.appendStyle(styleRules); } /** clone canvas */ function cloneCanvas(target, origin) { if (origin.width === 0 || origin.height === 0) return; target.getContext('2d').drawImage(origin, 0, 0); } function cloneMedia(target, origin) { if (!origin.currentSrc) return; // In the new document, currentSrc isn’t populated right away and is read-only, // so we explicitly assign src here. target.src = origin.currentSrc; // The precision of `video.currentTime` might get rounded depending on browser settings. // @see https://developer.mozilla.org/docs/Web/API/HTMLMediaElement/currentTime#reduced_time_precision target.currentTime = origin.currentTime; // Printing doesn’t need to play anything. target.autoplay = false; } function setScrollState(target, origin) { var scrollTop = origin.scrollTop; var scrollLeft = origin.scrollLeft; if (scrollTop || scrollLeft) { target.scrollTop = scrollTop; target.scrollLeft = scrollLeft; } } // clone element properties function cloneElementProperties(target, origin) { // The only thing that doesn’t get copied is the `<select> / <option>` ’s current state. // To be safe, we also set the state of some other elements. if (whichElement(target, 'select') || whichElement(target, 'textarea')) { target.value = origin.value; } else if (whichElement(target, 'option')) { target.selected = origin.selected; } else if (whichElement(target, 'input')) { var _origin = origin; target.value = _origin.value; target.checked = _origin.checked; target.indeterminate = _origin.indeterminate; } if (whichElement(target, 'canvas')) cloneCanvas(target, origin); if (isMediaElement(target)) cloneMedia(target, origin); setScrollState(target, origin); } function cloneShadowElement(innerTarget, innerOrigin) { cloneOpenShadowRoot(innerTarget, innerOrigin, function (target, origin, context) { // If an element has the `part` attribute, external `::part()` rules can reach into the shadow tree, // so we must re-clone its styles. (https://developer.mozilla.org/docs/Web/CSS/::part) // Conversely, styles inside the shadow tree are governed by its own <style>, so cloning them is unnecessary. return cloneElement(target, origin, context, !!origin.part.value); }); } function cloneElement(target, origin, context, shouldCloneStyle) { if (!isRenderingElement(target)) return true; if (shouldCloneStyle) { var originStyle = getStyle(origin); // Remove hidden element. if (isHidden(originStyle) && !isExternalStyleElement(origin)) return false; cloneElementStyle(target, origin, originStyle, context); clonePseudoElementStyle(target, origin, originStyle, context); } if (isOpenShadowElement(origin)) cloneShadowElement(target, origin); cloneElementProperties(target, origin); return true; } function cloneDocument(context, hostElement) { var doc = context.document; var clonedElement = doc.importNode(hostElement, true); if (whichElement(hostElement, 'body')) { removeNode(doc.body); appendNode(doc.documentElement, clonedElement); } else { appendNode(doc.body, clonedElement); } traverse(function (target, origin) { return cloneElement(target, origin, context, true); }, clonedElement, hostElement); context.flushTasks(); } 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; var doc = container.contentWindow.document; context.bind(doc); doc.title = (_a = options.documentTitle) !== null && _a !== void 0 ? _a : window.document.title; // remove the default margin. context.appendStyle("html{zoom:".concat((_b = options.zoom) !== null && _b !== void 0 ? _b : 1, "}body{margin:0;print-color-adjust:exact;}")); 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.document.defaultView); }) .finally(function () { // The container can only be destroyed after the printing process has been completed. return removeNode(container); }); } export { lightPrint as default };