pagedjs
Version:
Chunks up a document into paged media flows and applies print styles
791 lines (641 loc) • 20.3 kB
JavaScript
import { getBoundingClientRect, getClientRects } from "../utils/utils.js";
import {
breakInsideAvoidParentNode,
child,
cloneNode,
findElement,
hasContent,
indexOf,
indexOfTextNode,
isContainer,
isElement,
isText,
letters,
needsBreakBefore,
needsPageBreak,
needsPreviousBreakAfter,
nodeAfter,
nodeBefore,
parentOf,
prevValidNode,
rebuildAncestors,
validNode,
walk,
words
} from "../utils/dom.js";
import BreakToken from "./breaktoken.js";
import RenderResult, { OverflowContentError } from "./renderresult.js";
import EventEmitter from "event-emitter";
import Hook from "../utils/hook.js";
const MAX_CHARS_PER_BREAK = 1500;
/**
* Layout
* @class
*/
class Layout {
constructor(element, hooks, options) {
this.element = element;
this.bounds = this.element.getBoundingClientRect();
if (hooks) {
this.hooks = hooks;
} else {
this.hooks = {};
this.hooks.layout = new Hook();
this.hooks.renderNode = new Hook();
this.hooks.layoutNode = new Hook();
this.hooks.beforeOverflow = new Hook();
this.hooks.onOverflow = new Hook();
this.hooks.afterOverflowRemoved = new Hook();
this.hooks.onBreakToken = new Hook();
}
this.settings = options || {};
this.maxChars = this.settings.maxChars || MAX_CHARS_PER_BREAK;
this.forceRenderBreak = false;
}
async renderTo(wrapper, source, breakToken, bounds = this.bounds) {
let start = this.getStart(source, breakToken);
let walker = walk(start, source);
let node;
let prevNode;
let done;
let next;
let hasRenderedContent = false;
let newBreakToken;
let length = 0;
let prevBreakToken = breakToken || new BreakToken(start);
while (!done && !newBreakToken) {
next = walker.next();
prevNode = node;
node = next.value;
done = next.done;
if (!node) {
this.hooks && this.hooks.layout.trigger(wrapper, this);
let imgs = wrapper.querySelectorAll("img");
if (imgs.length) {
await this.waitForImages(imgs);
}
newBreakToken = this.findBreakToken(wrapper, source, bounds, prevBreakToken);
if (newBreakToken && newBreakToken.equals(prevBreakToken)) {
console.warn("Unable to layout item: ", prevNode);
return new RenderResult(undefined, new OverflowContentError("Unable to layout item", [prevNode]));
}
this.rebuildTableFromBreakToken(newBreakToken, wrapper);
return new RenderResult(newBreakToken);
}
this.hooks && this.hooks.layoutNode.trigger(node);
// Check if the rendered element has a break set
if (hasRenderedContent && this.shouldBreak(node, start)) {
this.hooks && this.hooks.layout.trigger(wrapper, this);
let imgs = wrapper.querySelectorAll("img");
if (imgs.length) {
await this.waitForImages(imgs);
}
newBreakToken = this.findBreakToken(wrapper, source, bounds, prevBreakToken);
if (!newBreakToken) {
newBreakToken = this.breakAt(node);
}
if (newBreakToken && newBreakToken.equals(prevBreakToken)) {
console.warn("Unable to layout item: ", node);
return new RenderResult(undefined, new OverflowContentError("Unable to layout item", [node]));
}
this.rebuildTableFromBreakToken(newBreakToken, wrapper);
length = 0;
break;
}
if (node.dataset && node.dataset.page) {
let named = node.dataset.page;
let page = this.element.closest(".pagedjs_page");
page.classList.add("pagedjs_named_page");
page.classList.add("pagedjs_" + named + "_page");
if (!node.dataset.splitFrom) {
page.classList.add("pagedjs_" + named + "_first_page");
}
}
// Should the Node be a shallow or deep clone
let shallow = isContainer(node);
let rendered = this.append(node, wrapper, breakToken, shallow);
length += rendered.textContent.length;
// Check if layout has content yet
if (!hasRenderedContent) {
hasRenderedContent = hasContent(node);
}
// Skip to the next node if a deep clone was rendered
if (!shallow) {
walker = walk(nodeAfter(node, source), source);
}
if (this.forceRenderBreak) {
this.hooks && this.hooks.layout.trigger(wrapper, this);
newBreakToken = this.findBreakToken(wrapper, source, bounds, prevBreakToken);
if (!newBreakToken) {
newBreakToken = this.breakAt(node);
}
this.rebuildTableFromBreakToken(newBreakToken, wrapper);
length = 0;
this.forceRenderBreak = false;
break;
}
// Only check x characters
if (length >= this.maxChars) {
this.hooks && this.hooks.layout.trigger(wrapper, this);
let imgs = wrapper.querySelectorAll("img");
if (imgs.length) {
await this.waitForImages(imgs);
}
newBreakToken = this.findBreakToken(wrapper, source, bounds, prevBreakToken);
if (newBreakToken && newBreakToken.equals(prevBreakToken)) {
console.warn("Unable to layout item: ", node);
return new RenderResult(undefined, new OverflowContentError("Unable to layout item", [node]));
}
if (newBreakToken) {
length = 0;
this.rebuildTableFromBreakToken(newBreakToken, wrapper);
}
}
}
return new RenderResult(newBreakToken);
}
breakAt(node, offset = 0) {
let newBreakToken = new BreakToken(
node,
offset
);
let breakHooks = this.hooks.onBreakToken.triggerSync(newBreakToken, undefined, node, this);
breakHooks.forEach((newToken) => {
if (typeof newToken != "undefined") {
newBreakToken = newToken;
}
});
return newBreakToken;
}
shouldBreak(node, limiter) {
let previousNode = nodeBefore(node, limiter);
let parentNode = node.parentNode;
let parentBreakBefore = needsBreakBefore(node) && parentNode && !previousNode && needsBreakBefore(parentNode);
let doubleBreakBefore;
if (parentBreakBefore) {
doubleBreakBefore = node.dataset.breakBefore === parentNode.dataset.breakBefore;
}
return !doubleBreakBefore && needsBreakBefore(node) || needsPreviousBreakAfter(node) || needsPageBreak(node, previousNode);
}
forceBreak() {
this.forceRenderBreak = true;
}
getStart(source, breakToken) {
let start;
let node = breakToken && breakToken.node;
if (node) {
start = node;
} else {
start = source.firstChild;
}
return start;
}
append(node, dest, breakToken, shallow = true, rebuild = true) {
let clone = cloneNode(node, !shallow);
if (node.parentNode && isElement(node.parentNode)) {
let parent = findElement(node.parentNode, dest);
// Rebuild chain
if (parent) {
parent.appendChild(clone);
} else if (rebuild) {
let fragment = rebuildAncestors(node);
parent = findElement(node.parentNode, fragment);
if (!parent) {
dest.appendChild(clone);
} else if (breakToken && isText(breakToken.node) && breakToken.offset > 0) {
clone.textContent = clone.textContent.substring(breakToken.offset);
parent.appendChild(clone);
} else {
parent.appendChild(clone);
}
dest.appendChild(fragment);
} else {
dest.appendChild(clone);
}
} else {
dest.appendChild(clone);
}
if (clone.dataset && clone.dataset.ref) {
if (!dest.indexOfRefs) {
dest.indexOfRefs = {};
}
dest.indexOfRefs[clone.dataset.ref] = clone;
}
let nodeHooks = this.hooks.renderNode.triggerSync(clone, node, this);
nodeHooks.forEach((newNode) => {
if (typeof newNode != "undefined") {
clone = newNode;
}
});
return clone;
}
rebuildTableFromBreakToken(breakToken, dest) {
if (!breakToken || !breakToken.node) {
return;
}
let node = breakToken.node;
let td = isElement(node) ? node.closest("td") : node.parentElement.closest("td");
if (td) {
let rendered = findElement(td, dest, true);
if (!rendered) {
return;
}
while ((td = td.nextElementSibling)) {
this.append(td, dest, null, true);
}
}
}
async waitForImages(imgs) {
let results = Array.from(imgs).map(async (img) => {
return this.awaitImageLoaded(img);
});
await Promise.all(results);
}
async awaitImageLoaded(image) {
return new Promise(resolve => {
if (image.complete !== true) {
image.onload = function () {
let {width, height} = window.getComputedStyle(image);
resolve(width, height);
};
image.onerror = function (e) {
let {width, height} = window.getComputedStyle(image);
resolve(width, height, e);
};
} else {
let {width, height} = window.getComputedStyle(image);
resolve(width, height);
}
});
}
avoidBreakInside(node, limiter) {
let breakNode;
if (node === limiter) {
return;
}
while (node.parentNode) {
node = node.parentNode;
if (node === limiter) {
break;
}
if (window.getComputedStyle(node)["break-inside"] === "avoid") {
breakNode = node;
break;
}
}
return breakNode;
}
createBreakToken(overflow, rendered, source) {
let container = overflow.startContainer;
let offset = overflow.startOffset;
let node, renderedNode, parent, index, temp;
if (isElement(container)) {
temp = child(container, offset);
if (isElement(temp)) {
renderedNode = findElement(temp, rendered);
if (!renderedNode) {
// Find closest element with data-ref
let prevNode = prevValidNode(temp);
if (!isElement(prevNode)) {
prevNode = prevNode.parentElement;
}
renderedNode = findElement(prevNode, rendered);
// Check if temp is the last rendered node at its level.
if (!temp.nextSibling) {
// We need to ensure that the previous sibling of temp is fully rendered.
const renderedNodeFromSource = findElement(renderedNode, source);
const walker = document.createTreeWalker(renderedNodeFromSource, NodeFilter.SHOW_ELEMENT);
const lastChildOfRenderedNodeFromSource = walker.lastChild();
const lastChildOfRenderedNodeMatchingFromRendered = findElement(lastChildOfRenderedNodeFromSource, rendered);
// Check if we found that the last child in source
if (!lastChildOfRenderedNodeMatchingFromRendered) {
// Pending content to be rendered before virtual break token
return;
}
// Otherwise we will return a break token as per below
}
// renderedNode is actually the last unbroken box that does not overflow.
// Break Token is therefore the next sibling of renderedNode within source node.
node = findElement(renderedNode, source).nextSibling;
offset = 0;
} else {
node = findElement(renderedNode, source);
offset = 0;
}
} else {
renderedNode = findElement(container, rendered);
if (!renderedNode) {
renderedNode = findElement(prevValidNode(container), rendered);
}
parent = findElement(renderedNode, source);
index = indexOfTextNode(temp, parent);
// No seperatation for the first textNode of an element
if(index === 0) {
node = parent;
offset = 0;
} else {
node = child(parent, index);
offset = 0;
}
}
} else {
renderedNode = findElement(container.parentNode, rendered);
if (!renderedNode) {
renderedNode = findElement(prevValidNode(container.parentNode), rendered);
}
parent = findElement(renderedNode, source);
index = indexOfTextNode(container, parent);
if (index === -1) {
return;
}
node = child(parent, index);
offset += node.textContent.indexOf(container.textContent);
}
if (!node) {
return;
}
return new BreakToken(
node,
offset
);
}
findBreakToken(rendered, source, bounds = this.bounds, prevBreakToken, extract = true) {
let overflow = this.findOverflow(rendered, bounds);
let breakToken, breakLetter;
let overflowHooks = this.hooks.onOverflow.triggerSync(overflow, rendered, bounds, this);
overflowHooks.forEach((newOverflow) => {
if (typeof newOverflow != "undefined") {
overflow = newOverflow;
}
});
if (overflow) {
breakToken = this.createBreakToken(overflow, rendered, source);
// breakToken is nullable
let breakHooks = this.hooks.onBreakToken.triggerSync(breakToken, overflow, rendered, this);
breakHooks.forEach((newToken) => {
if (typeof newToken != "undefined") {
breakToken = newToken;
}
});
// Stop removal if we are in a loop
if (breakToken && breakToken.equals(prevBreakToken)) {
return breakToken;
}
if (breakToken && breakToken["node"] && breakToken["offset"] && breakToken["node"].textContent) {
breakLetter = breakToken["node"].textContent.charAt(breakToken["offset"]);
} else {
breakLetter = undefined;
}
if (breakToken && breakToken.node && extract) {
let removed = this.removeOverflow(overflow, breakLetter);
this.hooks && this.hooks.afterOverflowRemoved.trigger(removed, rendered, this);
}
}
return breakToken;
}
hasOverflow(element, bounds = this.bounds) {
let constrainingElement = element && element.parentNode; // this gets the element, instead of the wrapper for the width workaround
let {width} = element.getBoundingClientRect();
let scrollWidth = constrainingElement ? constrainingElement.scrollWidth : 0;
return Math.max(Math.floor(width), scrollWidth) > Math.round(bounds.width);
}
findOverflow(rendered, bounds = this.bounds) {
if (!this.hasOverflow(rendered, bounds)) return;
let start = Math.round(bounds.left);
let end = Math.round(bounds.right);
let range;
let walker = walk(rendered.firstChild, rendered);
// Find Start
let next, done, node, offset, skip, breakAvoid, prev, br;
while (!done) {
next = walker.next();
done = next.done;
node = next.value;
skip = false;
breakAvoid = false;
prev = undefined;
br = undefined;
if (node) {
let pos = getBoundingClientRect(node);
let left = Math.round(pos.left);
let right = Math.floor(pos.right);
if (!range && left >= end) {
// Check if it is a float
let isFloat = false;
// Check if the node is inside a break-inside: avoid table cell
const insideTableCell = parentOf(node, "TD", rendered);
if (insideTableCell && window.getComputedStyle(insideTableCell)["break-inside"] === "avoid") {
// breaking inside a table cell produces unexpected result, as a workaround, we forcibly avoid break inside in a cell.
prev = insideTableCell;
} else if (isElement(node)) {
let styles = window.getComputedStyle(node);
isFloat = styles.getPropertyValue("float") !== "none";
skip = styles.getPropertyValue("break-inside") === "avoid";
breakAvoid = node.dataset.breakBefore === "avoid" || node.dataset.previousBreakAfter === "avoid";
prev = breakAvoid && nodeBefore(node, rendered);
br = node.tagName === "BR" || node.tagName === "WBR";
}
let tableRow;
if (node.nodeName === "TR") {
tableRow = node;
} else {
tableRow = parentOf(node, "TR", rendered);
}
if (tableRow) {
// honor break-inside="avoid" in parent tbody/thead
let container = tableRow.parentElement;
if (["TBODY", "THEAD"].includes(container.nodeName)) {
let styles = window.getComputedStyle(container);
if (styles.getPropertyValue("break-inside") === "avoid") prev = container;
}
// Check if the node is inside a row with a rowspan
const table = parentOf(tableRow, "TABLE", rendered);
const rowspan = table.querySelector("[colspan]");
if (table && rowspan) {
let columnCount = 0;
for (const cell of Array.from(table.rows[0].cells)) {
columnCount += parseInt(cell.getAttribute("colspan") || "1");
}
if (tableRow.cells.length !== columnCount) {
let previousRow = tableRow.previousElementSibling;
let previousRowColumnCount;
while (previousRow !== null) {
previousRowColumnCount = 0;
for (const cell of Array.from(previousRow.cells)) {
previousRowColumnCount += parseInt(cell.getAttribute("colspan") || "1");
}
if (previousRowColumnCount === columnCount) {
break;
}
previousRow = previousRow.previousElementSibling;
}
if (previousRowColumnCount === columnCount) {
prev = previousRow;
}
}
}
}
if (prev) {
range = document.createRange();
range.selectNode(prev);
break;
}
if (!br && !isFloat && isElement(node)) {
range = document.createRange();
range.selectNode(node);
break;
}
if (isText(node) && node.textContent.trim().length) {
range = document.createRange();
range.selectNode(node);
break;
}
}
if (!range && isText(node) &&
node.textContent.trim().length &&
!breakInsideAvoidParentNode(node.parentNode)) {
let rects = getClientRects(node);
let rect;
left = 0;
for (var i = 0; i != rects.length; i++) {
rect = rects[i];
if (rect.width > 0 && (!left || rect.left > left)) {
left = rect.left;
}
}
if (left >= end) {
range = document.createRange();
offset = this.textBreak(node, start, end);
if (!offset) {
range = undefined;
} else {
range.setStart(node, offset);
}
break;
}
}
// Skip children
if (skip || right <= end) {
next = nodeAfter(node, rendered);
if (next) {
walker = walk(next, rendered);
}
}
}
}
// Find End
if (range) {
range.setEndAfter(rendered.lastChild);
return range;
}
}
findEndToken(rendered, source, bounds = this.bounds) {
if (rendered.childNodes.length === 0) {
return;
}
let lastChild = rendered.lastChild;
let lastNodeIndex;
while (lastChild && lastChild.lastChild) {
if (!validNode(lastChild)) {
// Only get elements with refs
lastChild = lastChild.previousSibling;
} else if (!validNode(lastChild.lastChild)) {
// Deal with invalid dom items
lastChild = prevValidNode(lastChild.lastChild);
break;
} else {
lastChild = lastChild.lastChild;
}
}
if (isText(lastChild)) {
if (lastChild.parentNode.dataset.ref) {
lastNodeIndex = indexOf(lastChild);
lastChild = lastChild.parentNode;
} else {
lastChild = lastChild.previousSibling;
}
}
let original = findElement(lastChild, source);
if (lastNodeIndex) {
original = original.childNodes[lastNodeIndex];
}
let after = nodeAfter(original);
return this.breakAt(after);
}
textBreak(node, start, end) {
let wordwalker = words(node);
let left = 0;
let right = 0;
let word, next, done, pos;
let offset;
while (!done) {
next = wordwalker.next();
word = next.value;
done = next.done;
if (!word) {
break;
}
pos = getBoundingClientRect(word);
left = Math.floor(pos.left);
right = Math.floor(pos.right);
if (left >= end) {
offset = word.startOffset;
break;
}
if (right > end) {
let letterwalker = letters(word);
let letter, nextLetter, doneLetter;
while (!doneLetter) {
nextLetter = letterwalker.next();
letter = nextLetter.value;
doneLetter = nextLetter.done;
if (!letter) {
break;
}
pos = getBoundingClientRect(letter);
left = Math.floor(pos.left);
if (left >= end) {
offset = letter.startOffset;
done = true;
break;
}
}
}
}
return offset;
}
removeOverflow(overflow, breakLetter) {
let {startContainer} = overflow;
let extracted = overflow.extractContents();
this.hyphenateAtBreak(startContainer, breakLetter);
return extracted;
}
hyphenateAtBreak(startContainer, breakLetter) {
if (isText(startContainer)) {
let startText = startContainer.textContent;
let prevLetter = startText[startText.length - 1];
// Add a hyphen if previous character is a letter or soft hyphen
if (
(breakLetter && /^\w|\u00AD$/.test(prevLetter) && /^\w|\u00AD$/.test(breakLetter)) ||
(!breakLetter && /^\w|\u00AD$/.test(prevLetter))
) {
startContainer.parentNode.classList.add("pagedjs_hyphen");
startContainer.textContent += this.settings.hyphenGlyph || "\u2011";
}
}
}
equalTokens(a, b) {
if (!a || !b) {
return false;
}
if (a["node"] && b["node"] && a["node"] !== b["node"]) {
return false;
}
if (a["offset"] && b["offset"] && a["offset"] !== b["offset"]) {
return false;
}
return true;
}
}
EventEmitter(Layout.prototype);
export default Layout;