import Page from "./page.js";
import ContentParser from "./parser.js";
import EventEmitter from "event-emitter";
import Hook from "../utils/hook.js";
import Queue from "../utils/queue.js";
import { requestIdleCallback } from "../utils/utils.js";
const MAX_PAGES = null;
const MAX_LAYOUTS = false;
const TEMPLATE = `
<div class="pagedjs_page">
<div class="pagedjs_sheet">
<div class="pagedjs_bleed pagedjs_bleed-top">
<div class="pagedjs_marks-crop"></div>
<div class="pagedjs_marks-middle">
<div class="pagedjs_marks-cross"></div>
</div>
<div class="pagedjs_marks-crop"></div>
</div>
<div class="pagedjs_bleed pagedjs_bleed-bottom">
<div class="pagedjs_marks-crop"></div>
<div class="pagedjs_marks-middle">
<div class="pagedjs_marks-cross"></div>
</div> <div class="pagedjs_marks-crop"></div>
</div>
<div class="pagedjs_bleed pagedjs_bleed-left">
<div class="pagedjs_marks-crop"></div>
<div class="pagedjs_marks-middle">
<div class="pagedjs_marks-cross"></div>
</div> <div class="pagedjs_marks-crop"></div>
</div>
<div class="pagedjs_bleed pagedjs_bleed-right">
<div class="pagedjs_marks-crop"></div>
<div class="pagedjs_marks-middle">
<div class="pagedjs_marks-cross"></div>
</div>
<div class="pagedjs_marks-crop"></div>
</div>
<div class="pagedjs_pagebox">
<div class="pagedjs_margin-top-left-corner-holder">
<div class="pagedjs_margin pagedjs_margin-top-left-corner"><div class="pagedjs_margin-content"></div></div>
</div>
<div class="pagedjs_margin-top">
<div class="pagedjs_margin pagedjs_margin-top-left"><div class="pagedjs_margin-content"></div></div>
<div class="pagedjs_margin pagedjs_margin-top-center"><div class="pagedjs_margin-content"></div></div>
<div class="pagedjs_margin pagedjs_margin-top-right"><div class="pagedjs_margin-content"></div></div>
</div>
<div class="pagedjs_margin-top-right-corner-holder">
<div class="pagedjs_margin pagedjs_margin-top-right-corner"><div class="pagedjs_margin-content"></div></div>
</div>
<div class="pagedjs_margin-right">
<div class="pagedjs_margin pagedjs_margin-right-top"><div class="pagedjs_margin-content"></div></div>
<div class="pagedjs_margin pagedjs_margin-right-middle"><div class="pagedjs_margin-content"></div></div>
<div class="pagedjs_margin pagedjs_margin-right-bottom"><div class="pagedjs_margin-content"></div></div>
</div>
<div class="pagedjs_margin-left">
<div class="pagedjs_margin pagedjs_margin-left-top"><div class="pagedjs_margin-content"></div></div>
<div class="pagedjs_margin pagedjs_margin-left-middle"><div class="pagedjs_margin-content"></div></div>
<div class="pagedjs_margin pagedjs_margin-left-bottom"><div class="pagedjs_margin-content"></div></div>
</div>
<div class="pagedjs_margin-bottom-left-corner-holder">
<div class="pagedjs_margin pagedjs_margin-bottom-left-corner"><div class="pagedjs_margin-content"></div></div>
</div>
<div class="pagedjs_margin-bottom">
<div class="pagedjs_margin pagedjs_margin-bottom-left"><div class="pagedjs_margin-content"></div></div>
<div class="pagedjs_margin pagedjs_margin-bottom-center"><div class="pagedjs_margin-content"></div></div>
<div class="pagedjs_margin pagedjs_margin-bottom-right"><div class="pagedjs_margin-content"></div></div>
</div>
<div class="pagedjs_margin-bottom-right-corner-holder">
<div class="pagedjs_margin pagedjs_margin-bottom-right-corner"><div class="pagedjs_margin-content"></div></div>
</div>
<div class="pagedjs_area">
<div class="pagedjs_page_content"></div>
<div class="pagedjs_footnote_area">
<div class="pagedjs_footnote_content pagedjs_footnote_empty">
<div class="pagedjs_footnote_inner_content"></div>
</div>
</div>
</div>
</div>
</div>
</div>`;
/**
* The Chunker class is responsible for processing and paginating HTML content into individual page layouts.
* It manages rendering, page flow, break handling, overflow detection, and layout cycles.
*
* @class
*/
class Chunker {
/**
* Create a new Chunker instance.
*
* @param {HTMLElement|Document} content - The DOM content to be paginated.
* @param {HTMLElement} [renderTo] - Optional container element to render pages into.
* @param {Object} [options={}] - Configuration options.
* @property {Object} hooks - Collection of lifecycle hooks.
* @property {Page[]} pages - Array of rendered pages.
* @property {number} total - Total number of pages rendered.
* @property {boolean} stopped - Whether rendering is currently stopped.
* @property {boolean} rendered - Whether rendering has completed.
* @property {Queue} q - Internal render queue.
* @property {HTMLElement|Document} content - The original content passed to the chunker.
* @property {Object} modifiedRules - Map of modified stylesheets during rendering.
* @property {number[]} charsPerBreak - Characters per page break for estimation.
* @property {number} maxChars - Estimated maximum characters per page.
*/
constructor(content, renderTo, options) {
// this.preview = preview;
this.settings = options || {};
this.hooks = {};
this.hooks.beforeParsed = new Hook(this);
this.hooks.filter = new Hook(this);
this.hooks.afterParsed = new Hook(this);
this.hooks.beforePageLayout = new Hook(this);
this.hooks.onPageLayout = new Hook(this);
this.hooks.layout = new Hook(this);
this.hooks.renderNode = new Hook(this);
this.hooks.layoutNode = new Hook(this);
this.hooks.onOverflow = new Hook(this);
this.hooks.afterOverflowRemoved = new Hook(this);
this.hooks.afterOverflowAdded = new Hook(this);
this.hooks.onBreakToken = new Hook();
this.hooks.beforeRenderResult = new Hook(this);
this.hooks.afterPageLayout = new Hook(this);
this.hooks.finalizePage = new Hook(this);
this.hooks.afterRendered = new Hook(this);
this.pages = [];
this.total = 0;
this.q = new Queue(this);
this.stopped = false;
this.rendered = false;
this.content = content;
this.modifiedRules = {};
this.charsPerBreak = [];
this.maxChars;
if (content) {
this.flow(content, renderTo);
}
}
/**
* Sets up the page container and page template structure.
*
* @param {HTMLElement} renderTo - The DOM node to which pages should be rendered.
*/
setup(renderTo) {
this.pagesArea = document.createElement("div");
this.pagesArea.classList.add("pagedjs_pages");
if (renderTo) {
renderTo.appendChild(this.pagesArea);
} else {
document.querySelector("body").appendChild(this.pagesArea);
}
this.pageTemplate = document.createElement("template");
this.pageTemplate.innerHTML = TEMPLATE;
}
/**
* Gathers and records rules that should be disabled during rendering.
*/
rulesToDisable = ["breakInside", "overflow", "overflowX", "overflowY"];
recordRulesToDisable() {
for (var i in document.styleSheets) {
let sheet = document.styleSheets[i];
for (var j in sheet.cssRules) {
let rule = sheet.cssRules.item(j);
if (rule && rule.style) {
for (var k in this.rulesToDisable) {
let skip = false;
let disable = this.rulesToDisable[k];
let attribName = disable;
if (typeof disable == "object") {
attribName = Object.keys(disable)[0];
let value = disable[attribName];
skip =
!rule.style[attribName] || rule.style[attribName] !== value;
} else {
skip = !rule.style[attribName];
}
if (!skip) {
if (!this.modifiedRules[attribName]) {
this.modifiedRules[attribName] = [];
}
if (!this.modifiedRules[attribName][rule.style[attribName]]) {
this.modifiedRules[attribName][rule.style[attribName]] = [];
}
this.modifiedRules[attribName][rule.style[attribName]].push(rule);
}
}
}
}
}
}
/**
* Disables specific CSS rules that may interfere with rendering.
*
* @param {HTMLElement} rendered - The rendered content container.
*/
disableRules(rendered) {
for (var i in this.modifiedRules) {
for (var j in this.modifiedRules[i]) {
for (var k in this.modifiedRules[i][j]) {
let rule = this.modifiedRules[i][j][k];
rule.style[i] = "";
let nodes = rendered.querySelectorAll(rule.selectorText);
nodes.forEach((node) => {
let attribName = i.substring(0, 1).toUpperCase() + i.substring(1);
node.dataset[`original${attribName}`] = j;
});
}
}
}
}
/**
* Re-enables the CSS rules that were previously disabled.
*
* @param {HTMLElement} rendered - The rendered content container.
*/
enableRules(rendered) {
for (var i in this.modifiedRules) {
for (var j in this.modifiedRules[i]) {
for (var k in this.modifiedRules[i][j]) {
let rule = this.modifiedRules[i][j][k];
rule.style[i] = j;
let nodes = rendered.querySelectorAll(rule.selectorText);
nodes.forEach((node) => {
let attribName = i.substring(0, 1).toUpperCase() + i.substring(2);
delete node.dataset[`original${attribName}`];
});
}
}
}
}
/**
* Starts the chunking and rendering process for the given content.
*
* @async
* @param {HTMLElement|Document} content - Content to be paginated.
* @param {HTMLElement} renderTo - Element to render into.
* @returns {Promise<Chunker>} - Returns itself once rendering is complete.
*/
async flow(content, renderTo) {
let parsed;
await this.hooks.beforeParsed.trigger(content, this);
if (content) {
this.recordRulesToDisable();
this.disableRules(content);
}
parsed = new ContentParser(content);
this.hooks.filter.triggerSync(parsed);
this.source = parsed;
this.breakToken = undefined;
if (this.pagesArea && this.pageTemplate) {
this.q.clear();
this.removePages();
} else {
this.setup(renderTo);
}
this.emit("rendering", parsed);
await this.hooks.afterParsed.trigger(parsed, this);
await this.loadFonts();
let rendered = await this.render(parsed, this.breakToken);
while (rendered.canceled) {
this.start();
rendered = await this.render(parsed, this.breakToken);
}
this.rendered = true;
this.pagesArea.style.setProperty("--pagedjs-page-count", this.total);
await this.hooks.afterRendered.trigger(this.pages, this);
this.emit("rendered", this.pages);
this.enableRules(content);
return this;
}
// oversetPages() {
// let overset = [];
// for (let i = 0; i < this.pages.length; i++) {
// let page = this.pages[i];
// if (page.overset) {
// overset.push(page);
// // page.overset = false;
// }
// }
// return overset;
// }
//
// async handleOverset(parsed) {
// let overset = this.oversetPages();
// if (overset.length) {
// console.log("overset", overset);
// let index = this.pages.indexOf(overset[0]) + 1;
// console.log("INDEX", index);
//
// // Remove pages
// // this.removePages(index);
//
// // await this.render(parsed, overset[0].overset);
//
// // return this.handleOverset(parsed);
// }
// }
/**
* Main loop to handle the rendering lifecycle.
*
* @async
* @param {ContentParser} parsed - Parsed content.
* @param {Object} [startAt] - Break token to resume from.
* @returns {Promise<Object>} - Rendering result.
*/
async render(parsed, startAt) {
let renderer = this.layout(parsed, startAt);
let done = false;
let result;
let loops = 0;
while (!done) {
result = await this.q.enqueue(() => {
return this.renderAsync(renderer);
});
done = result.done;
if (MAX_LAYOUTS) {
loops += 1;
if (loops >= MAX_LAYOUTS) {
this.stop();
break;
}
}
}
return result;
}
/**
* Resets the rendering state.
*/
start() {
this.rendered = false;
this.stopped = false;
}
/**
* Stop the rendering process.
*/
stop() {
this.stopped = true;
// this.q.clear();
}
/**
* Renders a chunk of content when the browser is idle.
*
* @param {AsyncGenerator} renderer - The renderer iterator.
* @returns {Promise<Object>} - Result of rendering.
*/
renderOnIdle(renderer) {
return new Promise((resolve) => {
requestIdleCallback(async () => {
if (this.stopped) {
return resolve({ done: true, canceled: true });
}
let result = await renderer.next();
if (this.stopped) {
resolve({ done: true, canceled: true });
} else {
resolve(result);
}
});
});
}
/**
* Performs one asynchronous rendering step.
*
* @param {AsyncGenerator} renderer - The renderer iterator.
* @returns {Promise<Object>} - Result of rendering.
*/
async renderAsync(renderer) {
if (this.stopped) {
return { done: true, canceled: true };
}
let result = await renderer.next();
if (this.stopped) {
return { done: true, canceled: true };
} else {
return result;
}
}
/**
* Handles forced or conditional page breaks based on node metadata.
*
* @async
* @param {Node} node - The node to inspect for break conditions.
* @param {boolean} force - Force page break regardless of content.
*/
async handleBreaks(node, force) {
let currentPage = this.total + 1;
let currentPosition = currentPage % 2 === 0 ? "left" : "right";
// TODO: Recto and Verso should reverse for rtl languages
let currentSide = currentPage % 2 === 0 ? "verso" : "recto";
let previousBreakAfter;
let breakBefore;
let page;
if (currentPage === 1) {
return;
}
if (
node &&
typeof node.dataset !== "undefined" &&
typeof node.dataset.previousBreakAfter !== "undefined"
) {
previousBreakAfter = node.dataset.previousBreakAfter;
}
if (
node &&
typeof node.dataset !== "undefined" &&
typeof node.dataset.breakBefore !== "undefined"
) {
breakBefore = node.dataset.breakBefore;
}
if (force) {
page = this.addPage(true);
} else if (
previousBreakAfter &&
(previousBreakAfter === "left" || previousBreakAfter === "right") &&
previousBreakAfter !== currentPosition
) {
page = this.addPage(true);
} else if (
previousBreakAfter &&
(previousBreakAfter === "verso" || previousBreakAfter === "recto") &&
previousBreakAfter !== currentSide
) {
page = this.addPage(true);
} else if (
breakBefore &&
(breakBefore === "left" || breakBefore === "right") &&
breakBefore !== currentPosition
) {
page = this.addPage(true);
} else if (
breakBefore &&
(breakBefore === "verso" || breakBefore === "recto") &&
breakBefore !== currentSide
) {
page = this.addPage(true);
}
if (page) {
await this.hooks.beforePageLayout.trigger(
page,
undefined,
undefined,
this,
);
this.emit("page", page);
// await this.hooks.layout.trigger(page.element, page, undefined, this);
await this.hooks.afterPageLayout.trigger(
page.element,
page,
undefined,
this,
);
await this.hooks.finalizePage.trigger(
page.element,
page,
undefined,
this,
);
this.emit("renderedPage", page);
}
}
/**
* Generator that performs the layout step-by-step, yielding break tokens.
*
* @async
* @param {Document|HTMLElement} content - The parsed content.
* @param {Object} [startAt] - Optional starting break token.
* @yields {Object} - The current break token.
*/
async *layout(content, startAt) {
let breakToken = startAt || false;
let page, prevPage, prevNumPages;
while (
breakToken !== undefined &&
(MAX_PAGES ? this.total < MAX_PAGES : true)
) {
let range;
if (
page &&
page.area.firstElementChild &&
page.area.firstElementChild.childElementCount
) {
range = document.createRange();
range.selectNode(page.area.firstElementChild.childNodes[0]);
range.setEndAfter(page.area.firstElementChild.lastChild);
}
let addedExtra = false;
let emptyBody = !range || !range.getBoundingClientRect().height;
let emptyFootnotes =
!page ||
!page.footnotesArea.firstElementChild ||
!page.footnotesArea.firstElementChild.childElementCount ||
!page.footnotesArea.firstElementChild.firstElementChild.getBoundingClientRect()
.height;
let emptyPage = emptyBody && emptyFootnotes;
prevNumPages = this.total;
if (!page || !emptyPage) {
if (breakToken) {
if (breakToken.overflow.length && breakToken.overflow[0].node) {
// Overflow.
await this.handleBreaks(breakToken.overflow[0].node);
} else {
await this.handleBreaks(breakToken.node);
}
} else {
await this.handleBreaks(content.firstChild);
}
}
addedExtra = this.total != prevNumPages;
// Don't add a page if we have a forced break now and we just
// did a break due to overflow but have nothing displayed on
// the current page, unless there's overflow and we're finished.
if (!page || addedExtra || !emptyPage) {
this.addPage();
}
page = this.pages[this.total - 1];
await this.hooks.beforePageLayout.trigger(
page,
content,
breakToken,
this,
);
this.emit("page", page);
// Layout content in the page, starting from the breakToken.
breakToken = await page.layout(content, breakToken, prevPage);
await this.hooks.afterPageLayout.trigger(
page.element,
page,
breakToken,
this,
);
await this.hooks.finalizePage.trigger(
page.element,
page,
undefined,
this,
);
this.emit("renderedPage", page);
prevPage = page.wrapper;
this.recoredCharLength(page.wrapper.textContent.length);
yield breakToken;
}
}
/**
* Records the number of characters per page for average calculation.
*
* @param {number} length - Number of characters on the page.
*/
recoredCharLength(length) {
if (length === 0) {
return;
}
this.charsPerBreak.push(length);
// Keep the length of the last few breaks
if (this.charsPerBreak.length > 4) {
this.charsPerBreak.shift();
}
this.maxChars =
this.charsPerBreak.reduce((a, b) => a + b, 0) / this.charsPerBreak.length;
}
/**
* Removes rendered pages starting from the specified index.
*
* @param {number} [fromIndex=0] - Index to start removing pages from.
*/
removePages(fromIndex = 0) {
if (fromIndex >= this.pages.length) {
return;
}
// Remove pages
for (let i = fromIndex; i < this.pages.length; i++) {
this.pages[i].destroy();
}
if (fromIndex > 0) {
this.pages.splice(fromIndex);
} else {
this.pages = [];
}
this.total = this.pages.length;
}
/**
* Adds a new page to the render flow.
*
* @param {boolean} [blank=false] - Whether to add a blank page.
* @returns {Page} - The newly added Page instance.
*/
addPage(blank) {
let lastPage = this.pages[this.pages.length - 1];
// Create a new page from the template
let page = new Page(
this.pagesArea,
this.pageTemplate,
blank,
this.hooks,
this.settings,
);
this.pages.push(page);
// Create the pages
page.create(undefined, lastPage && lastPage.element);
page.index(this.total);
if (!blank) {
// Listen for page overflow
page.onOverflow((overflowToken) => {
console.warn("overflow on", page.id, overflowToken);
// Only reflow while rendering
if (this.rendered) {
return;
}
let index = this.pages.indexOf(page) + 1;
// Stop the rendering
this.stop();
// Set the breakToken to resume at
this.breakToken = overflowToken;
// Remove pages
this.removePages(index);
if (this.rendered === true) {
this.rendered = false;
this.q.enqueue(async () => {
this.start();
await this.render(this.source, this.breakToken);
this.rendered = true;
});
}
});
page.onUnderflow((overflowToken) => {
// console.log("underflow on", page.id, overflowToken);
// page.append(this.source, overflowToken);
});
}
this.total = this.pages.length;
return page;
}
/*
insertPage(index, blank) {
let lastPage = this.pages[index];
// Create a new page from the template
let page = new Page(this.pagesArea, this.pageTemplate, blank, this.hooks);
let total = this.pages.splice(index, 0, page);
// Create the pages
page.create(undefined, lastPage && lastPage.element);
page.index(index + 1);
for (let i = index + 2; i < this.pages.length; i++) {
this.pages[i].index(i);
}
if (!blank) {
// Listen for page overflow
page.onOverflow((overflowToken) => {
if (total < this.pages.length) {
this.pages[total].layout(this.source, overflowToken);
} else {
let newPage = this.addPage();
newPage.layout(this.source, overflowToken);
}
});
page.onUnderflow(() => {
// console.log("underflow on", page.id);
});
}
this.total += 1;
return page;
}
*/
/**
* Clones an existing page and appends it to the document.
*
* @async
* @param {Page} originalPage - The page to clone.
*/
async clonePage(originalPage) {
let lastPage = this.pages[this.pages.length - 1];
let page = new Page(this.pagesArea, this.pageTemplate, false, this.hooks);
this.pages.push(page);
// Create the pages
page.create(undefined, lastPage && lastPage.element);
page.index(this.total);
await this.hooks.beforePageLayout.trigger(page, undefined, undefined, this);
this.emit("page", page);
for (const className of originalPage.element.classList) {
if (
className !== "pagedjs_left_page" &&
className !== "pagedjs_right_page"
) {
page.element.classList.add(className);
}
}
await this.hooks.afterPageLayout.trigger(
page.element,
page,
undefined,
this,
);
await this.hooks.finalizePage.trigger(page.element, page, undefined, this);
this.emit("renderedPage", page);
}
/**
* Waits for all fonts to load before rendering starts.
*
* @returns {Promise<string[]>} - A promise resolving to a list of font families loaded.
*/
loadFonts() {
let fontPromises = [];
(document.fonts || []).forEach((fontFace) => {
if (fontFace.status !== "loaded") {
let fontLoaded = fontFace.load().then(
(r) => {
return fontFace.family;
},
(r) => {
console.warn("Failed to preload font-family:", fontFace.family);
return fontFace.family;
},
);
fontPromises.push(fontLoaded);
}
});
return Promise.all(fontPromises).catch((err) => {
console.warn(err);
});
}
/**
* Cleans up and removes all rendered elements and templates.
*/
destroy() {
this.pagesArea.remove();
this.pageTemplate.remove();
}
}
EventEmitter(Chunker.prototype);
export default Chunker;