pangu
Version:
Paranoid text spacing for good readability, to automatically insert whitespace between CJK (Chinese, Japanese, Korean) and half-width characters (alphabetical letters, numerical digits and symbols).
539 lines (538 loc) • 20.9 kB
JavaScript
import { Wt as pangu$1, u as ANY_CJK, wt as Pangu } from "../shared-D5DWhyd7.js";
//#region src/browser/boundary-spacing.ts
var QUOTE = /["\u201c\u201d]/;
function decideBoundarySpacing(context) {
if (context.spaceLikeSiblingAfterCurrent) return "none";
if (context.currentEndsWithSpace || context.nextStartsWithSpace || context.whitespaceBetween) return "none";
if (context.contentBetween) return "none";
if (!needsBoundarySpace(context.currentTail, context.nextFirst)) return "none";
if (context.spaceLikeSiblingAfterCurrentBoundary || context.currentBoundaryIsBlock) return "none";
if (!context.nextBoundaryIsSpaceSensitive) {
if (context.nextBoundaryIsIgnored || context.nextBoundaryIsBlock || context.spaceLikeSiblingBeforeNext || context.hiddenBoundaryBefore()) return "none";
return "prepend-next";
}
if (!context.currentBoundaryIsSpaceSensitive) {
if (context.hiddenBoundaryAfter()) return "none";
return "append-current";
}
if (context.spaceLikeSiblingBeforeNextBoundary || context.hiddenBoundaryAfter()) return "none";
if (context.inGridOrFlexContainer()) return "none";
return "insert-element";
}
function decideTextRunSpacing(context) {
const verdicts = [];
let { text } = context;
if (text.startsWith(" ") && context.hiddenBoundaryBefore()) {
verdicts.push("trim-leading-space");
text = text.substring(1);
}
if (isStandaloneQuote(text)) {
if (context.previousElementLastChar !== null && ANY_CJK.test(context.previousElementLastChar)) verdicts.push("prepend-space");
} else verdicts.push("apply-text-spacing");
return verdicts;
}
var spacedJunctionCache = /* @__PURE__ */ new Map();
var SPACED_JUNCTION_CACHE_MAX = 4096;
function spaceJunction(currentTail, nextFirst) {
const junction = `${currentTail}${nextFirst}`;
const cached = spacedJunctionCache.get(junction);
if (cached !== void 0) return cached;
const spacedJunction = pangu$1.spacingText(junction);
if (spacedJunctionCache.size >= SPACED_JUNCTION_CACHE_MAX) spacedJunctionCache.clear();
spacedJunctionCache.set(junction, spacedJunction);
return spacedJunction;
}
function needsBoundarySpace(currentTail, nextFirst) {
return spaceJunction(currentTail, nextFirst).endsWith(` ${nextFirst}`) && !isQuoteNextToCjk(currentTail.slice(-1), nextFirst);
}
function respaceCurrentTail(currentTail, nextFirst) {
const spacedJunction = spaceJunction(currentTail, nextFirst);
if (!spacedJunction.endsWith(` ${nextFirst}`)) return null;
const spacedTail = spacedJunction.slice(0, -2);
return spacedTail === currentTail ? null : spacedTail;
}
function isQuoteNextToCjk(currentLast, nextFirst) {
return QUOTE.test(currentLast) && ANY_CJK.test(nextFirst) || ANY_CJK.test(currentLast) && QUOTE.test(nextFirst);
}
function isStandaloneQuote(text) {
return text.length === 1 && QUOTE.test(text);
}
//#endregion
//#region src/browser/dom-walker.ts
var DomWalker = class {
static blockTags = /^(div|p|h1|h2|h3|h4|h5|h6)$/i;
static ignoredTags = /^(code|pre|script|style|textarea|iframe|input)$/i;
static spaceLikeTags = /^(br|hr|i|img|pangu)$/i;
static spaceSensitiveTags = /^(a|del|pre|s|strike|u)$/i;
static ignoredClass = "no-pangu-spacing";
static collectTextNodes(contextNode, reverse = false) {
const nodes = [];
if (!contextNode || contextNode instanceof DocumentFragment) return nodes;
const walker = document.createTreeWalker(contextNode, NodeFilter.SHOW_TEXT, { acceptNode: (node) => {
if (!node.nodeValue || !/\S/.test(node.nodeValue)) return NodeFilter.FILTER_REJECT;
let currentNode = node;
while (currentNode) {
if (currentNode instanceof Element && this.isIgnoredElement(currentNode)) return NodeFilter.FILTER_REJECT;
currentNode = currentNode.parentNode;
}
return NodeFilter.FILTER_ACCEPT;
} });
while (walker.nextNode()) nodes.push(walker.currentNode);
return reverse ? nodes.reverse() : nodes;
}
static findBoundaryNode(textNode, edge) {
let node = textNode;
while (node.parentNode && !this.spaceSensitiveTags.test(node.nodeName) && (edge === "first" ? this.isFirstTextChild(node.parentNode, node) : this.isLastTextChild(node.parentNode, node))) node = node.parentNode;
return node;
}
static isFirstTextChild(parentNode, targetNode) {
const { childNodes } = parentNode;
for (const childNode of childNodes) if (childNode.nodeType !== Node.COMMENT_NODE && childNode.textContent) return childNode === targetNode;
return false;
}
static isLastTextChild(parentNode, targetNode) {
const { childNodes } = parentNode;
for (let i = childNodes.length - 1; i > -1; i--) {
const childNode = childNodes[i];
if (childNode.nodeType !== Node.COMMENT_NODE && childNode.textContent) return childNode === targetNode;
}
return false;
}
static isIgnoredElement(element) {
return this.ignoredTags.test(element.nodeName) || this.isContentEditable(element) || element.classList.contains(this.ignoredClass);
}
static isContentEditable(node) {
return node instanceof HTMLElement && (node.isContentEditable || node.getAttribute("g_editable") === "true");
}
};
//#endregion
//#region src/browser/task-scheduler.ts
var TaskQueue = class {
queue = [];
isProcessing = false;
add(task) {
this.queue.push(task);
this.scheduleProcessing();
}
clear() {
this.queue.length = 0;
}
get length() {
return this.queue.length;
}
scheduleProcessing() {
if (!this.isProcessing && this.queue.length > 0) {
this.isProcessing = true;
requestIdleCallback((deadline) => this.process(deadline), { timeout: 5e3 });
}
}
process(deadline) {
try {
while (deadline.timeRemaining() > 0 && this.queue.length > 0) this.queue.shift()?.();
} finally {
this.isProcessing = false;
if (this.queue.length > 0) this.scheduleProcessing();
}
}
};
/**
* Runs queued text spacing work during browser idle time to avoid blocking the UI.
* Tasks execute via requestIdleCallback when the browser has spare time,
* ensuring smooth user experience even when processing large amounts of text.
*/
var TaskScheduler = class {
config = {
enabled: true,
timeout: 2e3
};
taskQueue = new TaskQueue();
get queue() {
return this.taskQueue;
}
};
//#endregion
//#region src/browser/visibility-detector.ts
var VisibilityDetector = class {
verdictCache = /* @__PURE__ */ new WeakMap();
clearCache() {
this.verdictCache = /* @__PURE__ */ new WeakMap();
}
isElementVisuallyHidden(element) {
const style = getComputedStyle(element);
if (style.display === "none") return true;
if (style.visibility === "hidden") return true;
if (parseFloat(style.opacity) === 0) return true;
const clip = style.clip;
if (clip && (clip.includes("rect(1px, 1px, 1px, 1px)") || clip.includes("rect(0px, 0px, 0px, 0px)") || clip.includes("rect(0, 0, 0, 0)"))) return true;
if (style.overflow === "hidden" && style.position === "absolute") {
const height = parseInt(style.height, 10);
const width = parseInt(style.width, 10);
if (height === 1 && width === 1) return true;
}
return false;
}
shouldSkipSpacingAfterNode(node) {
let elementToCheck = null;
if (node instanceof Element) elementToCheck = node;
else if (node.parentElement) elementToCheck = node.parentElement;
if (elementToCheck && this.isElementVisuallyHiddenCached(elementToCheck)) return true;
let currentElement = elementToCheck?.parentElement;
while (currentElement) {
if (this.isElementVisuallyHiddenCached(currentElement)) return true;
currentElement = currentElement.parentElement;
}
return false;
}
shouldSkipSpacingBeforeNode(node) {
let previousNode = node.previousSibling;
if (!previousNode && node.parentElement) {
let parent = node.parentElement;
while (parent && !previousNode) {
previousNode = parent.previousSibling;
if (!previousNode) parent = parent.parentElement;
}
}
if (previousNode) {
if (previousNode instanceof Element && this.isElementVisuallyHiddenCached(previousNode)) return true;
else if (previousNode instanceof Text && previousNode.parentElement && this.isElementVisuallyHiddenCached(previousNode.parentElement)) return true;
}
return false;
}
isElementVisuallyHiddenCached(element) {
const cached = this.verdictCache.get(element);
if (cached !== void 0) return cached;
const verdict = this.isElementVisuallyHidden(element);
this.verdictCache.set(element, verdict);
return verdict;
}
};
//#endregion
//#region src/browser/pangu.ts
var TRAILING_WHITESPACE = /\s$/;
var LEADING_WHITESPACE = /^\s/;
function once(func) {
let executed = false;
return function(...args) {
if (executed) return;
executed = true;
return func(...args);
};
}
function debounce(func, delay, mustRunDelay = Infinity) {
let timer = null;
let startTime = null;
return function(...args) {
const currentTime = Date.now();
if (timer) clearTimeout(timer);
if (!startTime) startTime = currentTime;
if (currentTime - startTime >= mustRunDelay) {
func(...args);
startTime = currentTime;
} else timer = window.setTimeout(() => {
func(...args);
}, delay);
};
}
var BrowserPangu = class BrowserPangu extends Pangu {
static maxSyncTextNodes = 256;
isAutoSpacingPageExecuted = false;
autoSpacingPageObserver = null;
lastWrittenData = /* @__PURE__ */ new WeakMap();
taskScheduler = new TaskScheduler();
visibilityDetector = new VisibilityDetector();
autoSpacingPage({ pageDelayMs = 1e3, nodeDelayMs = 500, nodeMaxWaitMs = 2e3 } = {}) {
if (!(document.body instanceof Node)) return;
if (this.isAutoSpacingPageExecuted) return;
this.isAutoSpacingPageExecuted = true;
this.waitForVideosToLoad(pageDelayMs, once(() => this.spacingPage()));
this.setupAutoSpacingPageObserver(nodeDelayMs, nodeMaxWaitMs);
}
spacingPage() {
const title = document.querySelector("head > title");
if (title) this.spacingNode(title);
this.spacingNode(document.body);
}
spacingNode(contextNode) {
const textNodes = DomWalker.collectTextNodes(contextNode, true);
this.schedule(textNodes);
}
stopAutoSpacingPage() {
if (this.autoSpacingPageObserver) {
this.autoSpacingPageObserver.disconnect();
this.autoSpacingPageObserver = null;
}
this.isAutoSpacingPageExecuted = false;
}
isElementVisuallyHidden(element) {
return this.visibilityDetector.isElementVisuallyHidden(element);
}
isSpaceLikeSibling(node) {
return !!node && DomWalker.spaceLikeTags.test(node.nodeName);
}
isGridOrFlexContainer(node) {
if (node.nodeType !== Node.ELEMENT_NODE) return false;
const display = window.getComputedStyle(node).display;
return display === "grid" || display === "inline-grid" || display === "flex" || display === "inline-flex";
}
spacingTextNodes(textNodes) {
this.visibilityDetector.clearCache();
let currentTextNode;
let nextTextNode = null;
for (let i = 0; i < textNodes.length; i++) {
currentTextNode = textNodes[i];
if (!currentTextNode) continue;
if (currentTextNode instanceof Text) this.applyTextRunSpacing(currentTextNode);
if (nextTextNode) {
if (!(currentTextNode instanceof Text) || !(nextTextNode instanceof Text)) continue;
const currentBoundaryNode = DomWalker.findBoundaryNode(currentTextNode, "last");
const nextBoundaryNode = DomWalker.findBoundaryNode(nextTextNode, "first");
const { whitespaceBetween, contentBetween } = this.scanBetweenTextRuns(currentBoundaryNode, nextBoundaryNode);
const currentRun = currentTextNode;
const nextRun = nextTextNode;
const currentTail = currentTextNode.data.slice(-3);
const nextFirst = nextTextNode.data.slice(0, 1);
const verdict = decideBoundarySpacing({
currentTail,
nextFirst,
currentEndsWithSpace: TRAILING_WHITESPACE.test(currentTextNode.data),
nextStartsWithSpace: LEADING_WHITESPACE.test(nextTextNode.data),
whitespaceBetween,
contentBetween,
spaceLikeSiblingAfterCurrent: this.isSpaceLikeSibling(currentTextNode.nextSibling),
spaceLikeSiblingAfterCurrentBoundary: this.isSpaceLikeSibling(currentBoundaryNode.nextSibling),
spaceLikeSiblingBeforeNext: this.isSpaceLikeSibling(nextTextNode.previousSibling),
spaceLikeSiblingBeforeNextBoundary: this.isSpaceLikeSibling(nextBoundaryNode.previousSibling),
currentBoundaryIsBlock: DomWalker.blockTags.test(currentBoundaryNode.nodeName),
currentBoundaryIsSpaceSensitive: DomWalker.spaceSensitiveTags.test(currentBoundaryNode.nodeName),
nextBoundaryIsBlock: DomWalker.blockTags.test(nextBoundaryNode.nodeName),
nextBoundaryIsIgnored: DomWalker.ignoredTags.test(nextBoundaryNode.nodeName),
nextBoundaryIsSpaceSensitive: DomWalker.spaceSensitiveTags.test(nextBoundaryNode.nodeName),
hiddenBoundaryBefore: () => this.isHiddenBoundaryBefore(nextRun),
hiddenBoundaryAfter: () => this.isHiddenBoundaryAfter(currentRun),
inGridOrFlexContainer: () => !!nextBoundaryNode.parentNode && this.isGridOrFlexContainer(nextBoundaryNode.parentNode)
});
if (verdict !== "none") {
const respacedTail = respaceCurrentTail(currentTail, nextFirst);
if (respacedTail !== null) {
currentTextNode.data = currentTextNode.data.slice(0, currentTextNode.data.length - currentTail.length) + respacedTail;
this.lastWrittenData.set(currentTextNode, currentTextNode.data);
}
}
switch (verdict) {
case "prepend-next":
nextTextNode.data = ` ${nextTextNode.data}`;
this.lastWrittenData.set(nextTextNode, nextTextNode.data);
break;
case "append-current":
currentTextNode.data = `${currentTextNode.data} `;
this.lastWrittenData.set(currentTextNode, currentTextNode.data);
break;
case "insert-element":
this.insertPanguElement(nextBoundaryNode);
break;
case "none": break;
}
}
nextTextNode = currentTextNode;
}
}
applyTextRunSpacing(textNode) {
const verdicts = decideTextRunSpacing({
text: textNode.data,
previousElementLastChar: this.findPreviousElementLastChar(textNode),
hiddenBoundaryBefore: () => this.isHiddenBoundaryBefore(textNode)
});
for (const verdict of verdicts) switch (verdict) {
case "trim-leading-space":
textNode.data = textNode.data.substring(1);
this.lastWrittenData.set(textNode, textNode.data);
break;
case "prepend-space":
textNode.data = ` ${textNode.data}`;
this.lastWrittenData.set(textNode, textNode.data);
break;
case "apply-text-spacing": {
const newText = this.spacingText(textNode.data);
if (textNode.data !== newText) {
textNode.data = newText;
this.lastWrittenData.set(textNode, textNode.data);
}
break;
}
}
}
spacingNodeSync(contextNode, maxTextNodes) {
const textNodes = DomWalker.collectTextNodes(contextNode, true);
if (textNodes.length > maxTextNodes) return false;
this.spacingTextNodes(textNodes);
return true;
}
hasSpacedTextInSubtree(node) {
if (node instanceof Text) return this.lastWrittenData.has(node);
const walker = document.createTreeWalker(node, NodeFilter.SHOW_TEXT);
while (walker.nextNode()) if (this.lastWrittenData.has(walker.currentNode)) return true;
return false;
}
insertPanguElement(nextBoundaryNode) {
const panguSpace = document.createElement("pangu");
panguSpace.innerHTML = " ";
if (nextBoundaryNode.parentNode) nextBoundaryNode.parentNode.insertBefore(panguSpace, nextBoundaryNode);
if (!panguSpace.previousElementSibling) {
if (panguSpace.parentNode) panguSpace.parentNode.removeChild(panguSpace);
}
}
findPreviousElementLastChar(textNode) {
const previousNode = textNode.previousSibling;
if (previousNode && previousNode.nodeType === Node.ELEMENT_NODE && previousNode.textContent) return previousNode.textContent.slice(-1);
return null;
}
scanBetweenTextRuns(currentBoundaryNode, nextBoundaryNode) {
let whitespaceBetween = false;
let contentBetween = false;
const scan = (node) => {
if (node.nodeType === Node.TEXT_NODE && node.textContent) {
if (/\s/.test(node.textContent)) whitespaceBetween = true;
if (/\S/.test(node.textContent)) contentBetween = true;
} else if (node instanceof Element && !DomWalker.isIgnoredElement(node)) for (let child = node.firstChild; child; child = child.nextSibling) scan(child);
};
let containerOfNext = null;
let node = currentBoundaryNode;
while (node && !containerOfNext) {
let sibling = node.nextSibling;
while (sibling && !sibling.contains(nextBoundaryNode)) {
scan(sibling);
sibling = sibling.nextSibling;
}
containerOfNext = sibling;
node = node.parentNode;
}
while (containerOfNext && containerOfNext !== nextBoundaryNode) {
let child = containerOfNext.firstChild;
while (child && !child.contains(nextBoundaryNode)) {
scan(child);
child = child.nextSibling;
}
containerOfNext = child;
}
return {
whitespaceBetween,
contentBetween
};
}
isHiddenBoundaryBefore(node) {
return this.visibilityDetector.shouldSkipSpacingBeforeNode(node);
}
isHiddenBoundaryAfter(node) {
return this.visibilityDetector.shouldSkipSpacingAfterNode(node);
}
schedule(textNodes) {
if (!this.taskScheduler.config.enabled || typeof requestIdleCallback !== "function") {
this.spacingTextNodes(textNodes);
return;
}
this.taskScheduler.queue.add(() => {
this.spacingTextNodes(textNodes);
});
}
waitForVideosToLoad(delayMs, onLoaded) {
const videos = Array.from(document.getElementsByTagName("video"));
if (videos.length === 0) setTimeout(onLoaded, delayMs);
else if (videos.every((video) => video.readyState >= 3)) setTimeout(onLoaded, delayMs);
else {
let loadedCount = 0;
const videoCount = videos.length;
const checkAllLoaded = () => {
loadedCount++;
if (loadedCount >= videoCount) setTimeout(onLoaded, delayMs);
};
for (const video of videos) if (video.readyState >= 3) checkAllLoaded();
else video.addEventListener("loadeddata", checkAllLoaded, { once: true });
setTimeout(onLoaded, delayMs + 5e3);
}
}
setupAutoSpacingPageObserver(nodeDelayMs, nodeMaxWaitMs) {
if (this.autoSpacingPageObserver) {
this.autoSpacingPageObserver.disconnect();
this.autoSpacingPageObserver = null;
}
const queue = [];
const debouncedSpacingTitle = debounce(() => {
const titleElement = document.querySelector("head > title");
if (titleElement) this.spacingNode(titleElement);
}, nodeDelayMs, nodeMaxWaitMs);
const debouncedSpacingNode = debounce(() => {
const nodesToProcess = [...queue];
queue.length = 0;
if (nodesToProcess.length === 0) return;
nodesToProcess.sort((a, b) => {
if (a === b) return 0;
return a.compareDocumentPosition(b) & Node.DOCUMENT_POSITION_FOLLOWING ? -1 : 1;
});
const seenTextNodes = /* @__PURE__ */ new Set();
const allTextNodes = [];
for (const node of nodesToProcess) for (const textNode of DomWalker.collectTextNodes(node)) if (!seenTextNodes.has(textNode)) {
seenTextNodes.add(textNode);
allTextNodes.push(textNode);
}
allTextNodes.reverse();
this.schedule(allTextNodes);
}, nodeDelayMs, nodeMaxWaitMs);
this.autoSpacingPageObserver = new MutationObserver((mutations) => {
let titleChanged = false;
let removedSpacedContent = false;
for (const mutation of mutations) {
for (const node of mutation.removedNodes) if (this.hasSpacedTextInSubtree(node)) {
removedSpacedContent = true;
break;
}
if (removedSpacedContent) break;
}
for (const mutation of mutations) {
if (mutation.target.parentNode?.nodeName === "TITLE" || mutation.target.nodeName === "TITLE") {
titleChanged = true;
continue;
}
switch (mutation.type) {
case "characterData": {
const { target: node } = mutation;
if (node instanceof Text && node.parentNode) {
const lastWritten = this.lastWrittenData.get(node);
if (lastWritten !== void 0) {
if (node.data === lastWritten) break;
if (this.spacingNodeSync(node.parentNode, BrowserPangu.maxSyncTextNodes)) break;
}
queue.push(node.parentNode);
}
break;
}
case "childList":
for (const node of mutation.addedNodes) if (node.nodeType === Node.ELEMENT_NODE) {
if (removedSpacedContent && this.spacingNodeSync(node, BrowserPangu.maxSyncTextNodes)) continue;
queue.push(node);
} else if (node.nodeType === Node.TEXT_NODE && node.parentNode) {
if (removedSpacedContent && this.spacingNodeSync(node.parentNode, BrowserPangu.maxSyncTextNodes)) continue;
queue.push(node.parentNode);
}
break;
default: break;
}
}
if (titleChanged) debouncedSpacingTitle();
debouncedSpacingNode();
});
this.autoSpacingPageObserver.observe(document.head, {
characterData: true,
childList: true,
subtree: true
});
this.autoSpacingPageObserver.observe(document.body, {
characterData: true,
childList: true,
subtree: true
});
}
};
var pangu = new BrowserPangu();
//#endregion
export { BrowserPangu, pangu as default, pangu };
//# sourceMappingURL=pangu.js.map