light-print
Version:
Lightweight HTML element printing for browsers.
343 lines (335 loc) • 13 kB
JavaScript
/*!
* light-print v2.5.1
* (c) 2020-2025 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; };
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);
});
}
export { lightPrint as default };