light-print
Version:
Lightweight HTML element printing for browsers.
600 lines (590 loc) • 23.3 kB
JavaScript
/*!
* 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 };