Source: polyfill/previewer.js

import EventEmitter from "event-emitter";

import Hook from "../utils/hook.js";
import Chunker from "../chunker/chunker.js";
import Polisher from "../polisher/polisher.js";

import { initializeHandlers, registerHandlers } from "../utils/handlers.js";

/**
 * The main class responsible for preparing, chunking, styling, and rendering content into paginated previews.
 *
 * Emits events:
 * - `page`: when a page is rendered
 * - `rendering`: when rendering starts
 * - `rendered`: when rendering finishes
 * - `size`: when page size is set
 * - `atpages`: when @page rules are processed
 */
class Previewer {
	/**
	 * Create a new Previewer instance.
	 * @param {Object} [options] - Optional configuration settings for rendering.
	 */
	constructor(options) {
		this.settings = options || {};

		this.polisher = new Polisher(false);

		this.chunker = new Chunker(undefined, undefined, this.settings);

		this.hooks = {
			beforePreview: new Hook(this),
			afterPreview: new Hook(this),
		};

		this.size = {
			width: {
				value: 8.5,
				unit: "in",
			},
			height: {
				value: 11,
				unit: "in",
			},
			format: undefined,
			orientation: undefined,
		};

		this.chunker.on("page", (page) => {
			this.emit("page", page);
		});

		this.chunker.on("rendering", () => {
			this.emit("rendering", this.chunker);
		});
	}

	/**
	 * Initializes handler modules (like footnotes, counters, etc.) and sets up relevant events.
	 * @returns {EventEmitter} - The handler system that manages internal processing hooks.
	 */
	initializeHandlers() {
		let handlers = initializeHandlers(this.chunker, this.polisher, this);

		handlers.on("size", (size) => {
			this.size = size;
			this.emit("size", size);
		});

		handlers.on("atpages", (pages) => {
			this.atpages = pages;
			this.emit("atpages", pages);
		});

		return handlers;
	}

	/**
	 * Registers handlers with custom logic or extensions.
	 * @returns {*} - The result of the registerHandlers function.
	 */
	registerHandlers() {
		return registerHandlers.apply(registerHandlers, arguments);
	}

	/**
	 * Retrieve a query parameter from the current URL.
	 * @param {string} name - Name of the parameter.
	 * @returns {string} - Value of the parameter if found.
	 */
	getParams(name) {
		let param;
		let url = new URL(window.location);
		let params = new URLSearchParams(url.search);
		for (var pair of params.entries()) {
			if (pair[0] === name) {
				param = pair[1];
			}
		}
		return param;
	}

	/**
	 * Wraps the contents of the `<body>` in a `<template>` element if not already present.
	 * This is used to preserve the original content for chunking and layout.
	 *
	 * @returns {DocumentFragment} - The wrapped content.
	 */
	wrapContent() {
		let body = document.querySelector("body");
		let template = body.querySelector(
			":scope > template[data-ref='pagedjs-content']",
		);

		if (!template) {
			template = document.createElement("template");
			template.dataset.ref = "pagedjs-content";
			template.innerHTML = body.innerHTML;
			body.innerHTML = "";
			body.appendChild(template);
		}

		return template.content;
	}

	/**
	 * Removes stylesheets and inline `<style>` elements that should not be processed.
	 * Also returns the list of removed styles for reprocessing later.
	 *
	 * @param {Document} [doc=document] - The document to process styles from.
	 * @returns {Array} - Array of stylesheet hrefs or inline style objects.
	 */
	removeStyles(doc = document) {
		const stylesheets = Array.from(
			doc.querySelectorAll(
				"link[rel='stylesheet']:not([data-pagedjs-ignore], [media~='screen'])",
			),
		);
		const inlineStyles = Array.from(
			doc.querySelectorAll(
				"style:not([data-pagedjs-inserted-styles], [data-pagedjs-ignore], [media~='screen'])",
			),
		);
		const elements = [...stylesheets, ...inlineStyles];

		return elements
			.sort((a, b) => {
				const position = a.compareDocumentPosition(b);
				if (position === Node.DOCUMENT_POSITION_PRECEDING) return 1;
				if (position === Node.DOCUMENT_POSITION_FOLLOWING) return -1;
				return 0;
			})
			.map((element) => {
				if (element.nodeName === "STYLE") {
					const obj = {};
					obj[window.location.href] = element.textContent;
					element.remove();
					return obj;
				}
				if (element.nodeName === "LINK") {
					element.remove();
					return element.href;
				}
				console.warn(`Unable to process: ${element}, ignoring.`);
			});
	}

	/**
	 * Main method for rendering content into paged preview.
	 * Triggers hooks and events, applies stylesheets, chunks the content, and returns the flow result.
	 *
	 * @param {HTMLElement|DocumentFragment|string} [content] - The content to render.
	 * @param {Array<string|Object>} [stylesheets] - List of stylesheet hrefs or inline styles to apply.
	 * @param {HTMLElement|string} [renderTo] - Element or selector where rendered content will be inserted.
	 * @returns {Promise<Object>} - Resolves to the rendered flow object with performance and size metadata.
	 */
	async preview(content, stylesheets, renderTo) {
		await this.hooks.beforePreview.trigger(content, renderTo);

		if (!content) {
			content = this.wrapContent();
		}

		if (!stylesheets) {
			stylesheets = this.removeStyles();
		}

		this.polisher.setup();
		this.handlers = this.initializeHandlers();

		await this.polisher.add(...stylesheets);

		let startTime = performance.now();

		let flow = await this.chunker.flow(content, renderTo);

		let endTime = performance.now();

		flow.performance = endTime - startTime;
		flow.size = this.size;

		this.emit("rendered", flow);

		await this.hooks.afterPreview.trigger(flow.pages);

		return flow;
	}
}

// Add event emitter behavior to the Previewer prototype
EventEmitter(Previewer.prototype);

export default Previewer;