UNPKG

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
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