import Handler from "../handler.js";
import { isContainer, isElement, isText, walk } from "../../utils/dom.js";
import Layout from "../../chunker/layout.js";
import csstree from "css-tree";
/**
* Handles the parsing, layout, and rendering of footnotes in paged content.
*
* Manages footnote policies, markers, calls, layout overflow, and alignment.
* Extends the generic Handler class.
*/
class Footnotes extends Handler {
/**
* Creates an instance of Footnotes.
* @param {object} chunker - The chunker instance handling content chunks.
* @param {object} polisher - The polisher instance handling polishing/layout.
* @param {object} caller - The caller instance managing handler orchestration.
*/
constructor(chunker, polisher, caller) {
super(chunker, polisher, caller);
/**
* Stores footnote selectors and their properties.
* @type {Object.<string, {selector: string, policy: string, display: string}>}
*/
this.footnotes = {};
/**
* Array of DOM fragments that need layout recalculation.
* @type {Array<Node>}
*/
this.needsLayout = [];
/**
* Array of footnote nodes that overflowed and are pending reinsertion.
* @type {Array<Element>}
*/
this.overflow = [];
}
/**
* Handles CSS declarations related to footnotes during parsing.
* Detects `float: footnote`, `footnote-policy`, and `footnote-display` properties.
*
* @param {object} declaration - The CSS declaration node.
* @param {object} dItem - Declaration item in the list.
* @param {object} dList - Declaration list.
* @param {object} rule - The CSS rule node.
*/
onDeclaration(declaration, dItem, dList, rule) {
let property = declaration.property;
if (property === "float") {
let identifier =
declaration.value.children && declaration.value.children.first();
let location = identifier && identifier.name;
if (location === "footnote") {
let selector = csstree.generate(rule.ruleNode.prelude);
this.footnotes[selector] = {
selector: selector,
policy: "auto",
display: "block",
};
dList.remove(dItem);
}
}
if (property === "footnote-policy") {
let identifier =
declaration.value.children && declaration.value.children.first();
let policy = identifier && identifier.name;
if (policy) {
let selector = csstree.generate(rule.ruleNode.prelude);
let note = this.footnotes[selector];
if (note) {
note.policy = policy;
}
}
}
if (property === "footnote-display") {
let identifier =
declaration.value.children && declaration.value.children.first();
let display = identifier && identifier.name;
let selector = csstree.generate(rule.ruleNode.prelude);
if (display && this.footnotes[selector]) {
let note = this.footnotes[selector];
if (note) {
note.display = display;
}
}
}
}
/**
* Transforms pseudo selectors `::footnote-marker` and `::footnote-call`
* into attribute selectors with pseudo-elements to enable footnote rendering.
*
* @param {object} pseudoNode - The pseudo selector node.
* @param {object} pItem - The item in pseudo selector list.
* @param {object} pList - The pseudo selector list.
* @param {string} selector - The full selector string.
* @param {object} rule - The CSS rule node.
*/
onPseudoSelector(pseudoNode, pItem, pList, selector, rule) {
let name = pseudoNode.name;
if (name === "footnote-marker") {
let prelude = rule.ruleNode.prelude;
let newPrelude = new csstree.List();
prelude.children.first().children.each((node) => {
if (node.type !== "PseudoElementSelector") {
newPrelude.appendData(node);
}
});
newPrelude.appendData({
type: "AttributeSelector",
name: {
type: "Identifier",
name: "data-footnote-marker",
},
flags: null,
loc: null,
matcher: null,
value: null,
});
newPrelude.appendData({
type: "PseudoElementSelector",
name: "marker",
loc: null,
children: null,
});
prelude.children.first().children = newPrelude;
}
if (name === "footnote-call") {
let prelude = rule.ruleNode.prelude;
let newPrelude = new csstree.List();
prelude.children.first().children.each((node) => {
if (node.type !== "PseudoElementSelector") {
newPrelude.appendData(node);
}
});
newPrelude.appendData({
type: "AttributeSelector",
name: {
type: "Identifier",
name: "data-footnote-call",
},
flags: null,
loc: null,
matcher: null,
value: null,
});
newPrelude.appendData({
type: "PseudoElementSelector",
name: "after",
loc: null,
children: null,
});
prelude.children.first().children = newPrelude;
}
}
/**
* After parsing, processes and applies footnote attributes to matching elements.
*
* @param {Document} parsed - The parsed DOM document or fragment.
*/
afterParsed(parsed) {
this.processFootnotes(parsed, this.footnotes);
}
/**
* Finds elements matching footnote selectors and adds footnote attributes.
* Also marks their container parents with data attributes to indicate presence of notes.
*
* @param {Document|Element} parsed - The root parsed element.
* @param {Object} notes - The footnotes configuration object.
*/
processFootnotes(parsed, notes) {
for (let n in notes) {
let elements = parsed.querySelectorAll(n);
let element;
let note = notes[n];
for (var i = 0; i < elements.length; i++) {
element = elements[i];
element.setAttribute("data-note", "footnote");
element.setAttribute("data-break-before", "avoid");
element.setAttribute("data-note-policy", note.policy || "auto");
element.setAttribute("data-note-display", note.display || "block");
this.processFootnoteContainer(element);
}
}
}
/**
* Walks up the DOM from a footnote element to find its container.
* Marks the closest container or last element with 'data-has-notes' attribute.
*
* @param {Element} node - The footnote element.
*/
processFootnoteContainer(node) {
let element = node.parentElement;
let prevElement = element;
while (element) {
if (isContainer(element)) {
prevElement.setAttribute("data-has-notes", "true");
break;
}
prevElement = element;
element = element.parentElement;
if (!element) {
prevElement.setAttribute("data-has-notes", "true");
}
}
}
/**
* Processes a node during rendering to find and handle footnotes within it.
*
* @param {Node} node - The DOM node to render.
*/
renderNode(node) {
if (node.nodeType == 1) {
let notes;
if (!node.dataset) {
return;
}
if (node.dataset.note === "footnote") {
notes = [node];
} else if (
node.dataset.hasNotes ||
node.querySelectorAll("[data-note='footnote']")
) {
notes = node.querySelectorAll("[data-note='footnote']");
}
if (notes && notes.length) {
this.findVisibleFootnotes(notes, node);
}
}
}
/**
* Finds visible footnotes within a node and moves them into the footnote area.
*
* @param {NodeListOf<Element>} notes - List of footnote elements.
* @param {Element} node - The container node to check visibility against.
*/
findVisibleFootnotes(notes, node) {
let area, size, right;
area = node.closest(".pagedjs_page_content");
size = area.getBoundingClientRect();
right = size.left + size.width;
for (let i = 0; i < notes.length; ++i) {
let currentNote = notes[i];
let bounds = currentNote.getBoundingClientRect();
let left = bounds.left;
if (left < right) {
this.moveFootnote(currentNote, node.closest(".pagedjs_area"), true);
}
}
}
/**
* Recalculates the height of footnote content and adjusts page CSS variables
* to ensure proper layout according to footnote policy and overflow.
*
* @param {Element} node - The footnote node.
* @param {Element} noteContent - The container of footnote content.
* @param {Element} pageArea - The page area element.
* @param {Element|null} noteCall - The footnote call element.
* @param {boolean} needsNoteCall - Whether the footnote call should be rendered.
*/
recalcFootnotesHeight(node, noteContent, pageArea, noteCall, needsNoteCall) {
// Remove empty class
if (noteContent.classList.contains("pagedjs_footnote_empty")) {
noteContent.classList.remove("pagedjs_footnote_empty");
}
// Get note content size
let height = noteContent.scrollHeight;
// Check the noteCall is still on screen
let area = pageArea.querySelector(".pagedjs_page_content");
let size = area.getBoundingClientRect();
let right = size.left + size.width;
// TODO: add a max height in CSS
// Check element sizes
let noteCallBounds = noteCall && noteCall.getBoundingClientRect();
let noteArea = pageArea.querySelector(".pagedjs_footnote_area");
let noteAreaBounds = noteArea.getBoundingClientRect();
// Get the @footnote margins
let noteContentMargins = this.marginsHeight(noteContent);
let noteContentPadding = this.paddingHeight(noteContent);
let noteContentBorders = this.borderHeight(noteContent);
let total = noteContentMargins + noteContentPadding + noteContentBorders;
// Get the top of the @footnote area
let notAreaTop = Math.floor(noteAreaBounds.top);
// If the height isn't set yet, remove the margins from the top
if (noteAreaBounds.height === 0) {
notAreaTop -= this.marginsHeight(noteContent, false);
notAreaTop -= this.paddingHeight(noteContent, false);
notAreaTop -= this.borderHeight(noteContent, false);
}
// Determine the note call position and offset per policy
let notePolicy = node.dataset.notePolicy;
let noteCallPosition = 0;
let noteCallOffset = 0;
if (noteCall) {
// Get the correct line bottom for super or sub styled callouts
let prevSibling = noteCall.previousSibling;
let range = new Range();
if (prevSibling) {
range.setStartBefore(prevSibling);
} else {
range.setStartBefore(noteCall);
}
range.setEndAfter(noteCall);
let rangeBounds = range.getBoundingClientRect();
noteCallPosition = rangeBounds.bottom;
if (!notePolicy || notePolicy === "auto") {
noteCallOffset = Math.ceil(rangeBounds.bottom);
} else if (notePolicy === "line") {
noteCallOffset = Math.ceil(rangeBounds.top);
} else if (notePolicy === "block") {
// Check that there is a previous element on the page
let parentParagraph = noteCall.closest("p").previousElementSibling;
if (parentParagraph) {
noteCallOffset = Math.ceil(
parentParagraph.getBoundingClientRect().bottom,
);
} else {
noteCallOffset = Math.ceil(rangeBounds.bottom);
}
}
}
let contentDelta = height + total - noteAreaBounds.height;
// Space between the top of the footnotes area and the bottom of the footnote call
let noteDelta = noteCallPosition ? notAreaTop - noteCallPosition : 0;
// Space needed for the force a break for the policy of the footnote
let notePolicyDelta = noteCallPosition
? Math.floor(noteAreaBounds.top) - noteCallOffset
: 0;
let hasNotes = noteArea.querySelector("[data-note='footnote']");
if (needsNoteCall && noteCallBounds.left > right) {
// Note is offscreen and will be chunked to the next page on overflow
node.remove();
} else if (!hasNotes && needsNoteCall && total > noteDelta) {
// No space to add even the footnote area
pageArea.style.setProperty("--pagedjs-footnotes-height", "0px");
// Add a wrapper as this div is removed later
let wrapperDiv = document.createElement("div");
wrapperDiv.appendChild(node);
// Push to the layout queue for the next page
this.needsLayout.push(wrapperDiv);
} else if (!needsNoteCall) {
// Call was previously added, force adding footnote
pageArea.style.setProperty(
"--pagedjs-footnotes-height",
`${height + total}px`,
);
} else if (noteCallPosition < noteAreaBounds.top - contentDelta) {
// the current note content will fit without pushing the call to the next page
pageArea.style.setProperty(
"--pagedjs-footnotes-height",
`${height + noteContentMargins + noteContentBorders}px`,
);
} else if (notePolicyDelta > 0) {
// set height to just before note call
pageArea.style.setProperty(
"--pagedjs-footnotes-height",
`${noteAreaBounds.height + notePolicyDelta}px`,
);
let noteInnerContent = noteContent.querySelector(
".pagedjs_footnote_inner_content",
);
noteInnerContent.style.height =
noteAreaBounds.height + notePolicyDelta - total + "px";
}
}
/**
* Moves a footnote node to the footnote area of a given page.
* @param {Element} node - The footnote element to move.
* @param {Element} pageArea - The page container element containing footnotes.
* @param {boolean} needsNoteCall - Whether a footnote call link should be created.
* @returns {void}
*/
moveFootnote(node, pageArea, needsNoteCall) {
// let pageArea = node.closest(".pagedjs_area");
let noteArea = pageArea.querySelector(".pagedjs_footnote_area");
let noteContent = noteArea.querySelector(".pagedjs_footnote_content");
let noteInnerContent = noteContent.querySelector(
".pagedjs_footnote_inner_content",
);
if (!isElement(node)) {
return;
}
// Add call for the note but only if it's not overflow.
// If it is overflow, the parentElement will be null.
let noteCall;
if (needsNoteCall) {
if (node.parentElement) {
noteCall = this.createFootnoteCall(node);
} else {
let ref = node.dataset["ref"];
noteCall = pageArea.querySelector(`[data-ref="${ref}"]`);
}
}
// Remove the break before attribute for future layout
node.removeAttribute("data-break-before");
// Check if note already exists for overflow
let existing = noteInnerContent.querySelector(
`[data-ref="${node.dataset.ref}"]`,
);
if (existing) {
// Remove the note from the flow but no need to render it again
node.remove();
return;
}
// Add the note node
noteInnerContent.appendChild(node);
// Add marker
node.dataset.footnoteMarker = node.dataset.ref;
// Add Id
node.id = `note-${node.dataset.ref}`;
this.recalcFootnotesHeight(
node,
noteContent,
pageArea,
noteCall,
needsNoteCall,
);
}
/**
* Creates a footnote call (link) element that points to the footnote.
* @param {Element} node - The footnote element to create a call for.
* @returns {HTMLAnchorElement} The created footnote call anchor element.
*/
createFootnoteCall(node) {
let parentElement = node.parentElement;
let footnoteCall = document.createElement("a");
for (const className of node.classList) {
footnoteCall.classList.add(`${className}`);
}
footnoteCall.dataset.footnoteCall = node.dataset.ref;
footnoteCall.dataset.ref = node.dataset.ref;
// Increment for counters
footnoteCall.dataset.dataCounterFootnoteIncrement = 1;
// Add link
footnoteCall.href = `#note-${node.dataset.ref}`;
parentElement.insertBefore(footnoteCall, node);
return footnoteCall;
}
/**
* Called after the page layout is complete to handle footnote overflow and layout.
* @param {Element} pageElement - The page's root element in the DOM.
* @param {Object} page - The page object containing footnotes and layout info.
* @param {Object|null} breakToken - The token representing a page break, if any.
* @param {Object} chunker - The chunker instance managing page chunks.
* @returns {void}
*/
afterPageLayout(pageElement, page, breakToken, chunker) {
let pageArea = pageElement.querySelector(".pagedjs_area");
let noteArea = page.footnotesArea;
let noteContent = noteArea.querySelector(".pagedjs_footnote_content");
let noteInnerContent = noteArea.querySelector(
".pagedjs_footnote_inner_content",
);
let noteContentBounds = noteContent.getBoundingClientRect();
let { width } = noteContentBounds;
noteInnerContent.style.columnWidth = Math.round(width) + "px";
noteInnerContent.style.columnGap =
"calc(var(--pagedjs-margin-right) + var(--pagedjs-margin-left))";
// Get overflow
let layout = new Layout(noteArea, undefined, chunker.settings);
let overflow = layout.findOverflow(noteInnerContent, noteContentBounds);
if (overflow) {
let { startContainer, startOffset } = overflow;
let extracted;
let footnoteContainer = isText(startContainer)
? startContainer.parentElement.closest("[data-footnote-marker]")
: startContainer.closest("[data-footnote-marker]");
let notEntireNote = !footnoteContainer || startOffset;
if (!notEntireNote) {
let pos = startContainer;
while (pos && pos !== footnoteContainer) {
pos = pos.previousSibling || pos.parentNode;
if (isText(pos)) {
notEntireNote = true;
break;
}
}
}
if (notEntireNote) {
// Assuming overflow is not multipart.
extracted = overflow.extractContents();
let splitChild = extracted.firstElementChild;
// Add any DOM structure above this node, but remove any text
// content from it.
// Assumes the footnote content is not anything complicated enough
// to need the more complicated handling that we do for the main
// content.
let parentRange = document.createRange();
parentRange.selectNode(footnoteContainer);
parentRange.setEndAfter(footnoteContainer);
let cloned = parentRange.cloneContents();
let walker = walk(cloned.firstChild, cloned);
let toDelete = undefined;
let next, pos, replacePos, done;
while (!done) {
if (isElement(pos)) {
if (pos.dataset.ref == splitChild?.dataset.ref) {
replacePos = pos;
}
if (pos.dataset.footnoteMarker) {
// Make sure counter isn't incremented and no new marker id rendered.
pos.dataset.splitFrom = true;
delete pos.dataset.footnoteMarker;
}
}
next = walker.next();
pos = next.value;
done = next.done;
if (toDelete) {
toDelete.remove();
toDelete = undefined;
}
if (isText(pos)) {
toDelete = pos;
replacePos = pos.parentElement;
}
}
if (splitChild) {
splitChild.dataset.splitFrom = splitChild.dataset.ref;
replacePos.parentNode.replaceChild(extracted, replacePos);
} else {
replacePos.appendChild(extracted);
}
extracted = cloned;
this.handleAlignment(noteInnerContent.lastElementChild);
} else {
// Adjust the range to take the entire footnote.
let range = document.createRange();
range.selectNode(footnoteContainer);
range.setEndAfter(footnoteContainer);
extracted = range.extractContents();
}
this.needsLayout.push(extracted);
noteContent.style.removeProperty("height");
noteInnerContent.style.removeProperty("height");
let noteInnerContentBounds = noteInnerContent.getBoundingClientRect();
let { height } = noteInnerContentBounds;
// Get the @footnote margins
let noteContentMargins = this.marginsHeight(noteContent);
let noteContentPadding = this.paddingHeight(noteContent);
let noteContentBorders = this.borderHeight(noteContent);
pageArea.style.setProperty(
"--pagedjs-footnotes-height",
`${height + noteContentMargins + noteContentBorders + noteContentPadding}px`,
);
// Hide footnote content if empty
if (noteInnerContent.childNodes.length === 0) {
noteContent.classList.add("pagedjs_footnote_empty");
}
if (!breakToken) {
chunker.clonePage(page);
} else {
let breakBefore, previousBreakAfter;
let firstOverflowNode = breakToken.overflow?.node;
if (
firstOverflowNode &&
typeof firstOverflowNode.dataset !== "undefined" &&
typeof firstOverflowNode.dataset.previousBreakAfter !== "undefined"
) {
previousBreakAfter = firstOverflowNode.dataset.previousBreakAfter;
}
if (
firstOverflowNode &&
typeof firstOverflowNode.dataset !== "undefined" &&
typeof firstOverflowNode.dataset.breakBefore !== "undefined"
) {
breakBefore = firstOverflowNode.dataset.breakBefore;
}
if (breakBefore || previousBreakAfter) {
chunker.clonePage(page);
}
}
}
noteInnerContent.style.height = "auto";
}
/**
* Handles alignment properties for the last split footnote element.
* @param {Element} node - The footnote element to apply alignment on.
* @returns {void}
*/
handleAlignment(node) {
let styles = window.getComputedStyle(node);
let alignLast = styles["text-align-last"];
node.dataset.lastSplitElement = "true";
if (alignLast === "auto") {
node.dataset.alignLastSplitElement = "justify";
} else {
node.dataset.alignLastSplitElement = alignLast;
}
}
/**
* Called before laying out a page, to process any pending footnotes that need moving.
* @param {Object} page - The page object containing DOM and layout data.
* @returns {void}
*/
beforePageLayout(page) {
while (this.needsLayout.length) {
let fragment = this.needsLayout.shift();
Array.from(fragment.childNodes).forEach((node) => {
this.moveFootnote(
node,
page.element.querySelector(".pagedjs_area"),
false,
);
});
}
}
/**
* Called after overflow content is removed; updates footnotes accordingly.
* @param {Element} removed - The DOM fragment containing removed overflow nodes.
* @param {Element} rendered - The DOM element where content is currently rendered.
* @returns {void}
*/
afterOverflowRemoved(removed, rendered) {
// Find the page area
let area = rendered.closest(".pagedjs_area");
if (!area) {
return;
}
// Get any rendered footnotes
let notes = area.querySelectorAll(
".pagedjs_footnote_area [data-note='footnote']",
);
for (let n = 0; n < notes.length; n++) {
const note = notes[n];
// Check if the call for that footnote has been removed with the overflow
let call = removed.querySelector(
`[data-footnote-call="${note.dataset.ref}"]`,
);
if (call) {
note.remove();
this.overflow.push(note);
}
}
// Hide footnote content if empty
let noteInnerContent = area.querySelector(
".pagedjs_footnote_inner_content",
);
if (noteInnerContent && noteInnerContent.childNodes.length === 0) {
noteInnerContent.parentElement.classList.add("pagedjs_footnote_empty");
}
}
/**
* Called after overflow content is added; reattaches footnotes and recalculates heights.
* @param {Element} rendered - The DOM element where new content has been rendered.
* @returns {void}
*/
afterOverflowAdded(rendered) {
let notes = rendered.querySelectorAll("[data-note='footnote']");
if (notes && notes.length) {
this.findVisibleFootnotes(notes, rendered);
}
let area = rendered.closest(".pagedjs_area");
let noteContent = area.querySelector(".pagedjs_footnote_content");
let notesInnerContent = area.querySelector(
".pagedjs_footnote_inner_content",
);
if (this.overflow.length) {
this.overflow.forEach((item) => {
notesInnerContent.appendChild(item);
let call = rendered.querySelector(
`[data-ref="${item.dataset["ref"]}"]`,
);
this.recalcFootnotesHeight(item, noteContent, area, call, false);
});
this.overflow = [];
}
}
/**
* Calculates the total vertical margin height of an element.
* @param {Element} element - The DOM element to calculate margin height for.
* @param {boolean} [total=true] - Whether to include bottom margin in the total.
* @returns {number} The sum of the top (and optionally bottom) margin in pixels.
*/
marginsHeight(element, total = true) {
let styles = window.getComputedStyle(element);
let marginTop = parseInt(styles.marginTop);
let marginBottom = parseInt(styles.marginBottom);
let margin = 0;
if (marginTop) {
margin += marginTop;
}
if (marginBottom && total) {
margin += marginBottom;
}
return margin;
}
/**
* Calculates the total vertical padding height of an element.
* @param {Element} element - The DOM element to calculate padding height for.
* @param {boolean} [total=true] - Whether to include bottom padding in the total.
* @returns {number} The sum of the top (and optionally bottom) padding in pixels.
*/
paddingHeight(element, total = true) {
let styles = window.getComputedStyle(element);
let paddingTop = parseInt(styles.paddingTop);
let paddingBottom = parseInt(styles.paddingBottom);
let padding = 0;
if (paddingTop) {
padding += paddingTop;
}
if (paddingBottom && total) {
padding += paddingBottom;
}
return padding;
}
/**
* Calculates the total vertical border height of an element.
* @param {Element} element - The DOM element to calculate border height for.
* @param {boolean} [total=true] - Whether to include bottom border in the total.
* @returns {number} The sum of the top (and optionally bottom) border width in pixels.
*/
borderHeight(element, total = true) {
let styles = window.getComputedStyle(element);
let borderTop = parseInt(styles.borderTop);
let borderBottom = parseInt(styles.borderBottom);
let borders = 0;
if (borderTop) {
borders += borderTop;
}
if (borderBottom && total) {
borders += borderBottom;
}
return borders;
}
}
export default Footnotes;