Source: modules/paged-media/counters.js

import Handler from "../handler.js";
import csstree from "css-tree";

class Counters extends Handler {
	/**
	 * Handles CSS counter properties for paged media.
	 * @param {Object} chunker - The chunker instance managing pagination.
	 * @param {Object} polisher - The polisher instance managing CSS and styles.
	 * @param {Object} caller - The caller instance (optional, context info).
	 */
	constructor(chunker, polisher, caller) {
		super(chunker, polisher, caller);

		/** @type {CSSStyleSheet} */
		this.styleSheet = polisher.styleSheet;

		/**
		 * Stores counters keyed by counter name.
		 * Each counter has increments and resets keyed by selector.
		 * @type {Object.<string, {name:string, increments:Object.<string,Object>, resets:Object.<string,Object>}>}
		 */
		this.counters = {};

		/**
		 * Map tracking counters that have been reset by element reference.
		 * @type {Map<string, string>}
		 */
		this.resetCountersMap = new Map();
	}

	/**
	 * Handles a CSS declaration related to counters.
	 * Cleans up declarations once processed.
	 * @param {Object} declaration - The CSS declaration node.
	 * @param {Object} dItem - The item in the declaration list.
	 * @param {Object} dList - The list of declarations.
	 * @param {Object} rule - The CSS rule node containing the declaration.
	 */
	onDeclaration(declaration, dItem, dList, rule) {
		let property = declaration.property;

		if (property === "counter-increment") {
			this.handleIncrement(declaration, rule);
			if (!this.hasNonWhitespaceChildren(declaration.value.children)) {
				dList.remove(dItem);
			}
		} else if (property === "counter-reset") {
			this.handleReset(declaration, rule);
			if (!this.hasNonWhitespaceChildren(declaration.value.children)) {
				dList.remove(dItem);
			}
		}
	}

	/**
	 * Helper to check if node children contain non-whitespace tokens.
	 * @param {Object} children - The children node list.
	 * @returns {boolean} True if any non-whitespace tokens found.
	 */
	hasNonWhitespaceChildren(children) {
		let hasProperties = false;
		children.forEach((data) => {
			if (data.type && data.type !== "WhiteSpace") {
				hasProperties = true;
			}
		});
		return hasProperties;
	}

	/**
	 * Called after the parsed document fragment is ready.
	 * Processes counters and scopes them appropriately.
	 * @param {DocumentFragment} parsed - The parsed DOM fragment.
	 */
	afterParsed(parsed) {
		this.processCounters(parsed, this.counters);
		this.scopeCounters(this.counters);
	}

	/**
	 * Adds a new counter to the counters map or returns existing one.
	 * @param {string} name - The name of the counter.
	 * @returns {Object} The counter object.
	 */
	addCounter(name) {
		if (name in this.counters) {
			return this.counters[name];
		}

		this.counters[name] = {
			name: name,
			increments: {},
			resets: {},
		};

		return this.counters[name];
	}

	/**
	 * Parses and handles counter-increment declarations.
	 * Updates counters with increment info.
	 * @param {Object} declaration - The CSS declaration node.
	 * @param {Object} rule - The CSS rule node.
	 * @returns {Array<Object>} List of increments parsed.
	 */
	handleIncrement(declaration, rule) {
		let increments = [];
		let children = declaration.value.children;

		children.forEach((data, item) => {
			if (data.type && data.type === "Identifier") {
				let name = data.name;

				if (name === "page" || name.indexOf("target-counter-") === 0) {
					return;
				}

				let whitespace, number, value;
				if (item.next && item.next.data.type === "WhiteSpace") {
					whitespace = item.next;
				}
				if (
					whitespace &&
					whitespace.next &&
					whitespace.next.data.type === "Number"
				) {
					number = whitespace.next;
					value = parseInt(number.data.value);
				}

				let selector = csstree.generate(rule.ruleNode.prelude);

				let counter = this.counters[name] || this.addCounter(name);

				let increment = {
					selector: selector,
					number: value || 1,
				};
				counter.increments[selector] = increment;
				increments.push(increment);

				// Remove the parsed increments from children
				children.remove(item);
				if (whitespace) {
					children.remove(whitespace);
				}
				if (number) {
					children.remove(number);
				}
			}
		});

		return increments;
	}

	/**
	 * Parses and handles counter-reset declarations.
	 * Updates counters with reset info.
	 * @param {Object} declaration - The CSS declaration node.
	 * @param {Object} rule - The CSS rule node.
	 */
	handleReset(declaration, rule) {
		let children = declaration.value.children;

		children.forEach((data, item) => {
			if (data.type && data.type === "Identifier") {
				let name = data.name;
				let whitespace, number, value;

				if (item.next && item.next.data.type === "WhiteSpace") {
					whitespace = item.next;
				}

				if (whitespace && whitespace.next) {
					if (whitespace.next.data.type === "Number") {
						number = whitespace.next;
						value = parseInt(number.data.value);
					} else if (
						whitespace.next.data.type === "Function" &&
						whitespace.next.data.name === "var"
					) {
						// CSS variable as reset value
						number = whitespace.next;
						value = whitespace.next.data.children.head.data.name;
					}
				}

				let counter;
				let selector;
				let prelude = rule.ruleNode.prelude;

				if (rule.ruleNode.type === "Atrule" && rule.ruleNode.name === "page") {
					selector = ".pagedjs_page";
				} else {
					selector = csstree.generate(prelude || rule.ruleNode);
				}

				if (name === "footnote") {
					this.addFootnoteMarkerCounter(declaration.value.children);
				}

				counter = this.counters[name] || this.addCounter(name);

				let reset = {
					selector: selector,
					number: value || 0,
				};

				counter.resets[selector] = reset;

				if (selector !== ".pagedjs_page") {
					// Remove parsed resets from children
					children.remove(item);
					if (whitespace) {
						children.remove(whitespace);
					}
					if (number) {
						children.remove(number);
					}
				}
			}
		});
	}

	/**
	 * Processes all counters on the parsed fragment.
	 * Calls handlers for increments, resets, and value assignment.
	 * @param {DocumentFragment} parsed - The parsed DOM fragment.
	 * @param {Object} counters - The counters map.
	 */
	processCounters(parsed, counters) {
		for (let c in counters) {
			let counter = this.counters[c];
			this.processCounterIncrements(parsed, counter);
			this.processCounterResets(parsed, counter);
			if (c !== "page") {
				this.addCounterValues(parsed, counter);
			}
		}
	}

	/**
	 * Adds counter-reset CSS rules scoped on pages to allow cross page scope.
	 * @param {Object} counters - The counters map.
	 */
	scopeCounters(counters) {
		let countersArray = [];
		for (let c in counters) {
			if (c !== "page") {
				countersArray.push(`${counters[c].name} 0`);
			}
		}
		this.insertRule(
			`.pagedjs_pages { counter-reset: ${countersArray.join(" ")} page 0 pages var(--pagedjs-page-count) footnote var(--pagedjs-footnotes-count) footnote-marker var(--pagedjs-footnotes-count)}`,
		);
	}

	/**
	 * Inserts a CSS rule into the stylesheet.
	 * @param {string} rule - The CSS rule string.
	 */
	insertRule(rule) {
		this.styleSheet.insertRule(rule, this.styleSheet.cssRules.length);
	}

	/**
	 * Adds data attributes for counter increments to matching elements.
	 * @param {DocumentFragment} parsed - The parsed DOM fragment.
	 * @param {Object} counter - The counter object.
	 */
	processCounterIncrements(parsed, counter) {
		for (let inc in counter.increments) {
			let increment = counter.increments[inc];
			let incrementElements = parsed.querySelectorAll(increment.selector);
			for (let i = 0; i < incrementElements.length; i++) {
				incrementElements[i].setAttribute(
					`data-counter-${counter.name}-increment`,
					increment.number,
				);
				if (incrementElements[i].getAttribute("data-counter-increment")) {
					incrementElements[i].setAttribute(
						"data-counter-increment",
						incrementElements[i].getAttribute("data-counter-increment") +
							" " +
							counter.name,
					);
				} else {
					incrementElements[i].setAttribute(
						"data-counter-increment",
						counter.name,
					);
				}
			}
		}
	}

	/**
	 * Adds data attributes for counter resets to matching elements.
	 * Resolves CSS variables when possible.
	 * @param {DocumentFragment} parsed - The parsed DOM fragment.
	 * @param {Object} counter - The counter object.
	 */
	processCounterResets(parsed, counter) {
		for (let r in counter.resets) {
			let reset = counter.resets[r];
			let resetElements = parsed.querySelectorAll(reset.selector);
			for (var i = 0; i < resetElements.length; i++) {
				let value = reset.number;
				if (typeof value === "string" && value.startsWith("--")) {
					// Attempt to get value from inline style
					value = resetElements[i].style.getPropertyValue(value) || 0;
				}
				resetElements[i].setAttribute(
					`data-counter-${counter.name}-reset`,
					value,
				);
				if (resetElements[i].getAttribute("data-counter-reset")) {
					resetElements[i].setAttribute(
						"data-counter-reset",
						resetElements[i].getAttribute("data-counter-reset") +
							" " +
							counter.name,
					);
				} else {
					resetElements[i].setAttribute("data-counter-reset", counter.name);
				}
			}
		}
	}

	/**
	 * Calculates and adds counter values on elements.
	 * @param {DocumentFragment} parsed - The parsed DOM fragment.
	 * @param {Object} counter - The counter object.
	 */
	addCounterValues(parsed, counter) {
		let counterName = counter.name;

		if (counterName === "page" || counterName === "footnote") {
			return;
		}

		let elements = parsed.querySelectorAll(
			"[data-counter-" +
				counterName +
				"-reset], [data-counter-" +
				counterName +
				"-increment]",
		);

		let count = 0;
		let element;
		let increment, reset;
		let resetValue, incrementValue, resetDelta;
		let incrementArray;

		for (let i = 0; i < elements.length; i++) {
			element = elements[i];
			resetDelta = 0;
			incrementArray = [];

			if (element.hasAttribute("data-counter-" + counterName + "-reset")) {
				reset = element.getAttribute("data-counter-" + counterName + "-reset");
				resetValue = parseInt(reset);

				// Use negative increment value inplace of reset
				resetDelta = resetValue - count;
				incrementArray.push(`${counterName} ${resetDelta}`);

				count = resetValue;
			}

			if (element.hasAttribute("data-counter-" + counterName + "-increment")) {
				increment = element.getAttribute(
					"data-counter-" + counterName + "-increment",
				);
				incrementValue = parseInt(increment);

				count += incrementValue;

				element.setAttribute("data-counter-" + counterName + "-value", count);

				incrementArray.push(`${counterName} ${incrementValue}`);
			}

			if (incrementArray.length > 0) {
				this.incrementCounterForElement(element, incrementArray);
			}
		}
	}
	/**
	 * Ensures the footnote marker counter is included in the counter list.
	 * If "footnote-maker" is already present, it does nothing.
	 *
	 * @param {Object} list - The CSS AST list node to modify.
	 */
	addFootnoteMarkerCounter(list) {
		let markers = [];
		csstree.walk(list, {
			visit: "Identifier",
			enter: (identNode, iItem, iList) => {
				markers.push(identNode.name);
			},
		});

		// Already added
		if (markers.includes("footnote-maker")) {
			return;
		}

		list.insertData({
			type: "WhiteSpace",
			value: " ",
		});

		list.insertData({
			type: "Identifier",
			name: "footnote-marker",
		});

		list.insertData({
			type: "WhiteSpace",
			value: " ",
		});

		list.insertData({
			type: "Number",
			value: 0,
		});
	}

	/**
	 * Increment the CSS counters for a specific element, merging with existing increments.
	 *
	 * @param {HTMLElement} element - The element to update.
	 * @param {string[]} incrementArray - Array of counter-increment strings, e.g. ['c1 1', 'c2 -3'].
	 */

	incrementCounterForElement(element, incrementArray) {
		if (!element || !incrementArray || incrementArray.length === 0) return;

		const ref = element.dataset.ref;
		const increments = Array.from(this.styleSheet.cssRules)
			.filter((rule) => {
				return (
					rule.selectorText ===
						`[data-ref="${element.dataset.ref}"]:not([data-split-from])` &&
					rule.style[0] === "counter-increment"
				);
			})
			.map((rule) => rule.style.counterIncrement);

		// Merge the current increments by summing the values because we generate both a decrement and an increment when the
		// element resets and increments the counter at the same time. E.g. ['c1 -7', 'c1 1'] should lead to 'c1 -6'.
		increments.push(
			this.mergeIncrements(
				incrementArray,
				(prev, next) => (parseInt(prev) || 0) + (parseInt(next) || 0),
			),
		);

		// Keep the last value for each counter when merging with the previous increments. E.g. ['c1 -7 c2 3', 'c1 1']
		// should lead to 'c1 1 c2 3'.
		const counterIncrement = this.mergeIncrements(
			increments,
			(prev, next) => next,
		);
		this.insertRule(
			`[data-ref="${ref}"]:not([data-split-from]) { counter-increment: ${counterIncrement} }`,
		);
	}

	/**
	 * Merge multiple values of a counter-increment CSS rule, using the specified operator.
	 *
	 * @param {Array} incrementArray the values to merge, e.g. ['c1 1', 'c1 -7 c2 1']
	 * @param {Function} operator the function used to merge counter values (e.g. keep the last value of a counter or sum
	 *					the counter values)
	 * @return {string} the merged value of the counter-increment CSS rule
	 */
	mergeIncrements(incrementArray, operator) {
		const increments = {};
		incrementArray.forEach((increment) => {
			let values = increment.split(" ");
			for (let i = 0; i < values.length; i += 2) {
				increments[values[i]] = operator(increments[values[i]], values[i + 1]);
			}
		});

		return Object.entries(increments)
			.map(([key, value]) => `${key} ${value}`)
			.join(" ");
	}

	/**
	 * Called after page layout to apply counter-reset and counter-increment CSS rules based on page and footnote resets.
	 *
	 * @param {HTMLElement} pageElement - The page element after layout.
	 * @param {Object} page - The page metadata (not used directly here).
	 */
	afterPageLayout(pageElement, page) {
		let resets = [];

		let pgreset = pageElement.querySelectorAll(
			"[data-counter-page-reset]:not([data-split-from])",
		);
		pgreset.forEach((reset) => {
			const ref = reset.dataset && reset.dataset.ref;
			if (ref && this.resetCountersMap.has(ref)) {
				// ignoring, the counter-reset directive has already been taken into account.
			} else {
				if (ref) {
					this.resetCountersMap.set(ref, "");
				}
				let value = reset.dataset.counterPageReset;
				resets.push(`page ${value}`);
			}
		});

		let notereset = pageElement.querySelectorAll(
			"[data-counter-footnote-reset]:not([data-split-from])",
		);
		notereset.forEach((reset) => {
			let value = reset.dataset.counterFootnoteReset;
			resets.push(`footnote ${value}`);
			resets.push(`footnote-marker ${value}`);
		});

		if (resets.length) {
			this.styleSheet.insertRule(
				`[data-page-number="${pageElement.dataset.pageNumber}"] { counter-increment: none; counter-reset: ${resets.join(" ")} }`,
				this.styleSheet.cssRules.length,
			);
		}
	}
}

export default Counters;