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
text/typescript
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;