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

619 lines (540 loc) 23.7 kB
import { Pangu } from '../shared/index.js'; import { decideBoundarySpacing, decideTextRunSpacing, respaceCurrentTail } from './boundary-spacing.js'; import { DomWalker } from './dom-walker.js'; import { TaskScheduler } from './task-scheduler.js'; import { VisibilityDetector } from './visibility-detector.js'; export interface AutoSpacingPageConfig { pageDelayMs?: number; nodeDelayMs?: number; nodeMaxWaitMs?: number; } // Any whitespace at a text run's edge already separates it from the neighboring run, matching the /\s/ that scanBetweenTextRuns uses on the nodes in the gap. // \s covers NBSP, which spacingText never rewrites, so an author's NBSP reaches this check intact and must still count as a space const TRAILING_WHITESPACE = /\s$/; const LEADING_WHITESPACE = /^\s/; // eslint-disable-next-line @typescript-eslint/no-explicit-any function once<T extends (...args: any[]) => any>(func: T) { let executed = false; return function (...args: Parameters<T>) { if (executed) { return undefined; } executed = true; return func(...args); }; } // eslint-disable-next-line @typescript-eslint/no-explicit-any function debounce<T extends (...args: any[]) => void>(func: T, delay: number, mustRunDelay: number = Infinity) { let timer: number | null = null; let startTime: number | null = null; return function (...args: Parameters<T>) { 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); } }; } // Main call flows from autoSpacingPage() to requestIdleCallback(): // // Entry A: initial page sweep Entry B: dynamic content (MutationObserver) // // 1a. autoSpacingPage() 1b. autoSpacingPage() // ↓ waitForVideosToLoad(pageDelayMs) ↓ setupAutoSpacingPageObserver() // 2a. spacingPage() 2b. observer fires on characterData/childList // ├─ spacingNode(<head><title>) ↓ push affected nodes onto queue // └─ spacingNode(document.body) ↓ debounce(nodeDelayMs, max nodeMaxWaitMs) // ↓ 3b. sort queued nodes into document order, // 3a. spacingNode(node) dedupe, merge all their text runs via // - DomWalker.collectTextNodes(node, true) DomWalker.collectTextNodes(), reverse // (reverse document order, skips ↓ // whitespace-only and ignored tags) (title changes take their own debounce // ↓ → spacingNode(<title>)) // └──────────────────┬────────────────────────┘ // ↓ // 4. schedule(textNodes) // - Decision point: taskScheduler.config.enabled && requestIdleCallback supported? // ├─ NO → spacingTextNodes(textNodes) (synchronous, no requestIdleCallback) // └─ YES (default) → taskScheduler.queue.add(() => spacingTextNodes(textNodes)) // (always ONE task holding the whole list, never chunked) // ↓ // 5. TaskQueue.add() → scheduleProcessing() → requestIdleCallback(process, { timeout: 5000 }) // - process(deadline): pops and runs queued tasks while deadline.timeRemaining() > 0 // - if tasks remain when the slice ends, re-arms requestIdleCallback for the rest // ↓ // 6. spacingTextNodes(textNodes) // - per text run: decideTextRunSpacing() → trim-leading-space / prepend-space / // apply-text-spacing (spacingText) // - per adjacent run pair: decideBoundarySpacing() → prepend-next / append-current / // insert <pangu> element / none // - visibility detection happens here: always on, consulted lazily per boundary // (hiddenBoundaryBefore / hiddenBoundaryAfter), not a scheduling decision // // Summary of paths to requestIdleCallback(): // - taskScheduler.enabled=true + requestIdleCallback available → one batch task per // schedule() call, drained in idle slices // - taskScheduler.enabled=false, or no requestIdleCallback (stock Safari) → never // (fully synchronous processing) export class BrowserPangu extends Pangu { // Pre-paint re-space stays bounded: subtrees with more text runs than this fall back to the queue private static readonly maxSyncTextNodes = 256; private isAutoSpacingPageExecuted = false; private autoSpacingPageObserver: MutationObserver | null = null; // Last data pangu wrote per text node: distinguishes pangu's own mutation records // (data still equals the entry, drop them) from external rewrites of spaced content // (data differs, re-space before the next paint) private readonly lastWrittenData = new WeakMap<Text, string>(); public readonly taskScheduler = new TaskScheduler(); public readonly visibilityDetector = new VisibilityDetector(); // PUBLIC public autoSpacingPage({ pageDelayMs = 1000, nodeDelayMs = 500, nodeMaxWaitMs = 2000 }: AutoSpacingPageConfig = {}) { if (!(document.body instanceof Node)) { return; } if (this.isAutoSpacingPageExecuted) { return; } this.isAutoSpacingPageExecuted = true; // prettier-ignore this.waitForVideosToLoad(pageDelayMs, once(() => this.spacingPage())); this.setupAutoSpacingPageObserver(nodeDelayMs, nodeMaxWaitMs); } public spacingPage() { // Page title const title = document.querySelector('head > title'); if (title) { this.spacingNode(title); } // Page body this.spacingNode(document.body); } public spacingNode(contextNode: Node) { // Only process nodes with actual content (excluding text nodes that contain only whitespace) const textNodes = DomWalker.collectTextNodes(contextNode, true); this.schedule(textNodes); } public stopAutoSpacingPage() { if (this.autoSpacingPageObserver) { this.autoSpacingPageObserver.disconnect(); this.autoSpacingPageObserver = null; } this.isAutoSpacingPageExecuted = false; } public isElementVisuallyHidden(element: Element) { return this.visibilityDetector.isElementVisuallyHidden(element); } // INTERNAL private isSpaceLikeSibling(node: Node | null) { return !!node && DomWalker.spaceLikeTags.test(node.nodeName); } private isGridOrFlexContainer(node: Node): boolean { if (node.nodeType !== Node.ELEMENT_NODE) { return false; } const style = window.getComputedStyle(node as Element); const display = style.display; return display === 'grid' || display === 'inline-grid' || display === 'flex' || display === 'inline-flex'; } private spacingTextNodes(textNodes: Node[]) { // Visibility verdicts are memoized per batch; styles may change between batches this.visibilityDetector.clearCache(); let currentTextNode: Node | undefined; let nextTextNode: Node | null = null; // Process nodes in the order provided for (let i = 0; i < textNodes.length; i++) { currentTextNode = textNodes[i]; if (!currentTextNode) { continue; } if (currentTextNode instanceof Text) { this.applyTextRunSpacing(currentTextNode); } // Handle nested tag text processing 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); // Stable bindings for the lazy facts: the loop variables are reassigned across iterations 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), }); // A junction space can come with a second space that belongs inside the current run's tail (蒸馏/ + 训 reads 蒸馏 / 训): write the respaced tail back before placing the junction space 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; } } private applyTextRunSpacing(textNode: Text) { 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; } } } } // Same processing as the queued paths, but synchronous, for pre-paint re-spacing // inside the MutationObserver callback. Returns false when the subtree exceeds // maxTextNodes, so the caller can fall back to the debounced queue private spacingNodeSync(contextNode: Node, maxTextNodes: number) { const textNodes = DomWalker.collectTextNodes(contextNode, true); if (textNodes.length > maxTextNodes) { return false; } this.spacingTextNodes(textNodes); return true; } private hasSpacedTextInSubtree(node: 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 as Text)) { return true; } } return false; } private insertPanguElement(nextBoundaryNode: Node) { const panguSpace = document.createElement('pangu'); panguSpace.innerHTML = ' '; if (nextBoundaryNode.parentNode) { nextBoundaryNode.parentNode.insertBefore(panguSpace, nextBoundaryNode); } // Clean up orphaned space element if (!panguSpace.previousElementSibling) { if (panguSpace.parentNode) { panguSpace.parentNode.removeChild(panguSpace); } } } private findPreviousElementLastChar(textNode: Node) { const previousNode = textNode.previousSibling; if (previousNode && previousNode.nodeType === Node.ELEMENT_NODE && previousNode.textContent) { return previousNode.textContent.slice(-1); } return null; } private scanBetweenTextRuns(currentBoundaryNode: Node, nextBoundaryNode: Node) { // Scan the document-order gap between the two boundary nodes. Whitespace // text means the runs are already separated. Collectable text (checked // through the same DomWalker rules that build the runs, so ignored islands // like <code> do not count) means the runs are not adjacent at all let whitespaceBetween = false; let contentBetween = false; const scan = (node: 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)) { // Descend so wrapped whitespace counts too. Ignored islands like <code> // stay invisible, matching how the runs themselves are collected for (let child = node.firstChild; child; child = child.nextSibling) { scan(child); } } }; // Climb from the current boundary, scanning the following siblings at each // level until one is or holds the next boundary. The climb never escapes // the common ancestor because the next boundary is found below it first let containerOfNext: Node | null = null; let node: Node | null = currentBoundaryNode; while (node && !containerOfNext) { let sibling = node.nextSibling; while (sibling && !sibling.contains(nextBoundaryNode)) { scan(sibling); sibling = sibling.nextSibling; } containerOfNext = sibling; node = node.parentNode; } // Descend to the next boundary, scanning the children before its path at // each level. Nothing past the boundary is ever visited while (containerOfNext && containerOfNext !== nextBoundaryNode) { let child: Node | null = containerOfNext.firstChild; while (child && !child.contains(nextBoundaryNode)) { scan(child); child = child.nextSibling; } containerOfNext = child; } return { whitespaceBetween, contentBetween }; } private isHiddenBoundaryBefore(node: Node) { return this.visibilityDetector.shouldSkipSpacingBeforeNode(node); } private isHiddenBoundaryAfter(node: Node) { return this.visibilityDetector.shouldSkipSpacingAfterNode(node); } // The single seam that decides how spacing work is executed: synchronously // or as one idle-time batch. Boundary spacing needs adjacent-run context, so // the node list is never split across calls private schedule(textNodes: Node[]) { // Stock Safari ships requestIdleCallback behind a preference flag, so fall // back to synchronous spacing instead of throwing in TaskQueue if (!this.taskScheduler.config.enabled || typeof requestIdleCallback !== 'function') { this.spacingTextNodes(textNodes); return; } this.taskScheduler.queue.add(() => { this.spacingTextNodes(textNodes); }); } private waitForVideosToLoad(delayMs: number, onLoaded: () => void) { // Wait for videos to load before spacing to avoid layout shifts // See: https://github.com/vinta/pangu.js/issues/117 const videos = Array.from(document.getElementsByTagName('video')); if (videos.length === 0) { // No videos, proceed with normal delay setTimeout(onLoaded, delayMs); } else { // Check if all videos are already loaded const allVideosLoaded = videos.every((video) => video.readyState >= 3); if (allVideosLoaded) { // All videos loaded, proceed with normal delay setTimeout(onLoaded, delayMs); } else { // Wait for all videos to load 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 }); } } // Fallback timeout in case videos never load setTimeout(onLoaded, delayMs + 5000); } } } private setupAutoSpacingPageObserver(nodeDelayMs: number, nodeMaxWaitMs: number) { // Disconnect any existing auto-spacing observer if (this.autoSpacingPageObserver) { this.autoSpacingPageObserver.disconnect(); this.autoSpacingPageObserver = null; } const queue: Node[] = []; const debouncedSpacingTitle = debounce( () => { const titleElement = document.querySelector('head > title'); if (titleElement) { this.spacingNode(titleElement); } }, nodeDelayMs, nodeMaxWaitMs, ); const debouncedSpacingNode = debounce( () => { // NOTE: a single node could be very big which contains a lot of child nodes const nodesToProcess = [...queue]; queue.length = 0; // Clear the queue if (nodesToProcess.length === 0) { return; } // Merge all queued nodes' text runs into one reverse-document-order pass, // so boundary spacing sees pairs that span separately queued nodes. // Sort into document order first (mutation order is not document order) // and drop duplicate runs (a parent and its child can both be queued) nodesToProcess.sort((a, b) => { if (a === b) { return 0; } return a.compareDocumentPosition(b) & Node.DOCUMENT_POSITION_FOLLOWING ? -1 : 1; }); const seenTextNodes = new Set<Node>(); const allTextNodes: Node[] = []; 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, ); // https://developer.mozilla.org/en-US/docs/Web/API/MutationObserver this.autoSpacingPageObserver = new MutationObserver((mutations) => { let titleChanged = false; // When this batch removed content pangu already spaced, the page is re-rendering // over processed DOM (e.g. a framework's second render pass): added subtrees are // then re-spaced synchronously below, before the browser paints the reverted text let removedSpacedContent = false; for (const mutation of mutations) { for (const node of mutation.removedNodes) { if (this.hasSpacedTextInSubtree(node)) { removedSpacedContent = true; break; } } if (removedSpacedContent) { break; } } // Element: https://developer.mozilla.org/en-US/docs/Web/API/Element // Text: https://developer.mozilla.org/en-US/docs/Web/API/Text for (const mutation of mutations) { // Skip to avoid double processing - title handled separately by debouncedSpacingTitle() if (mutation.target.parentNode?.nodeName === 'TITLE' || mutation.target.nodeName === 'TITLE') { titleChanged = true; continue; } // Queue parent elements for spacing processing switch (mutation.type) { case 'characterData': { // Text content changed (e.g., textContent = '新文字new text') const { target: node } = mutation; if (node instanceof Text && node.parentNode) { const lastWritten = this.lastWrittenData.get(node); if (lastWritten !== undefined) { if (node.data === lastWritten) { // pangu's own write: nothing to reprocess break; } // External rewrite of a node pangu already spaced is a revert: // re-space it before the next paint so the revert never renders if (this.spacingNodeSync(node.parentNode, BrowserPangu.maxSyncTextNodes)) { break; } } // <p>Hello 世界</p> // "Hello 世界" is the text node, <p> is the parent element queue.push(node.parentNode); // Queue parent element, not text node } break; } case 'childList': { // New nodes added to DOM (e.g., innerHTML change, appendChild) for (const node of mutation.addedNodes) { if (node.nodeType === Node.ELEMENT_NODE) { if (removedSpacedContent && this.spacingNodeSync(node, BrowserPangu.maxSyncTextNodes)) { continue; } queue.push(node); // Element added, process its text content } else if (node.nodeType === Node.TEXT_NODE && node.parentNode) { if (removedSpacedContent && this.spacingNodeSync(node.parentNode, BrowserPangu.maxSyncTextNodes)) { continue; } queue.push(node.parentNode); // Text node added, process its parent } } break; } default: break; } } if (titleChanged) { debouncedSpacingTitle(); } debouncedSpacingNode(); }); // NOTE: A single MutationObserver can observe multiple targets simultaneously // https://developer.mozilla.org/en-US/docs/Web/API/MutationObserver/observe: // Observe page title changes this.autoSpacingPageObserver.observe(document.head, { characterData: true, childList: true, subtree: true, // Need subtree to observe text node changes inside title }); // Observe page content changes this.autoSpacingPageObserver.observe(document.body, { characterData: true, childList: true, subtree: true, }); } } export const pangu = new BrowserPangu(); export default pangu;