diff --git a/src/content.js b/src/content.js new file mode 100644 index 0000000..6279141 --- /dev/null +++ b/src/content.js @@ -0,0 +1,492 @@ +/* ============================================================================ + * Persian RTL Fixer — content script (Manifest V3) + * + * Approach (non-destructive — NEVER restructures the page's DOM): + * - Find block elements proven to contain Persian text. + * - Set the block's base direction with the `dir` attribute: + * · Persian-leading block -> dir="rtl" (right-aligned) + * · Mixed / English-leading block -> dir="auto" (English stays LTR; + * Persian words still flow RTL inline via the native Unicode bidi algo) + * - Apply Vazirmatn only to author-unstyled Persian text using an @font-face + * `unicode-range` limited to the Arabic blocks. The browser then renders + * Persian in Vazirmatn and leaves English in the site's own font — per + * character, automatically, with NO span wrapping. + * + * Why no wrapping? Wrapping text in s corrupts React/Vue/Angular pages: + * the framework owns the DOM and re-renders against its virtual DOM, so injected + * nodes cause duplicated/mangled text. Setting attributes/inline-style on + * existing elements is safe and reversible; if the framework strips them, the + * MutationObserver simply re-applies. + * + * Loop-safety: the observer watches childList + characterData only (never + * attributes), so our own dir/style writes can't trigger a re-process loop. + * ========================================================================== */ +(() => { + "use strict"; + + if (window.__RTLX_LOADED__) return; + window.__RTLX_LOADED__ = true; + + // --- Character classes ---------------------------------------------------- + // Persian-only trigger characters. Shared Arabic letters are considered only + // after one of these characters proves the text is Persian. + const PERSIAN_RE = /[\u067E\u0686\u0698\u06A9\u06AF\u06CC\u06C0\u06C1\u06BE\u06D2\u06D3]/; + const ARABIC_SCRIPT_RE = /[\u0621-\u063A\u0641-\u064A\u066E-\u06D3]/; + const LTR_RE = /[A-Za-zÀ-ʯͰ-ϿЀ-ӿ]/; + + const SKIP_TAGS = new Set([ + "SCRIPT", "STYLE", "NOSCRIPT", "TEXTAREA", "CODE", "PRE", "KBD", "SAMP", + "XMP", "TT", "OPTION", "SELECT", "TITLE" + ]); + const SKIP_SELECTOR = + "script,style,noscript,textarea,code,pre,kbd,samp,xmp,tt,option,select"; + const INPUT_SELECTOR = "input,textarea,[contenteditable=''],[contenteditable='true']"; + const SKIP_INPUT_TYPES = new Set([ + "checkbox", "radio", "range", "color", "date", "datetime-local", "month", + "week", "time", "file", "number", "button", "submit", "reset", "image", + "hidden", "password" + ]); + const XHTML = "http://www.w3.org/1999/xhtml"; + // Block-level elements that control line flow / text-align. + // On SPAs text is often inside nested s — we walk UP to the nearest + // block element so direction + text-align actually take effect. + const BLOCK_TAGS_SET = new Set([ + "ADDRESS","ARTICLE","ASIDE","BLOCKQUOTE","CAPTION","DD","DETAILS","DIALOG", + "DIV","DL","DT","FIELDSET","FIGCAPTION","FIGURE","FOOTER","FORM", + "H1","H2","H3","H4","H5","H6","HEADER","HGROUP","LI","MAIN","NAV", + "OL","P","SECTION","SUMMARY","TABLE","TD","TH","TR","UL" + ]); + + const DEFAULTS = { + enabled: true, // master switch + fixDirection: true, // conservatively correct Persian direction + useFont: true, // use Vazirmatn for author-unstyled Persian text + fixInputs: true, // give inputs/textareas dir="auto" + Vazirmatn + disabledSites: [] // hostnames where the extension is off + }; + let settings = Object.assign({}, DEFAULTS); + let active = false; + let booted = false; + + let observer = null; + const observedShadowRoots = new Set(); + const shadowObservers = []; + + // --------------------------------------------------------------------------- + // Helpers + // --------------------------------------------------------------------------- + function inSkip(el) { + if (!el || !el.closest) return true; + if (el.namespaceURI && el.namespaceURI !== XHTML) return true; // skip SVG/MathML + if (el.isContentEditable) return true; + return !!el.closest(SKIP_SELECTOR); + } + + // The first strong script decides mixed content. A Persian-specific character + // must occur somewhere, otherwise Arabic-only text is deliberately ignored. + function dirForText(text) { + if (!PERSIAN_RE.test(text)) return null; + for (const char of text) { + if (LTR_RE.test(char)) return "ltr"; + if (ARABIC_SCRIPT_RE.test(char)) return "rtl"; + } + return "rtl"; + } + + // Walk up to the nearest block-level ancestor (stops before ) so the + // direction/alignment lands on an element that actually controls line flow. + function nearestBlock(el) { + let node = el; + while (node && node !== document.body && node !== document.documentElement) { + if (node.nodeType === 1 && BLOCK_TAGS_SET.has(node.tagName)) return node; + node = node.parentElement; + } + return el; + } + + function setDir(el, value) { + // Do not claim ownership of a direction the site already set sensibly. + const existing = el.getAttribute("dir"); + if (!el.__rtlxSetDir && (existing === value || existing === "auto")) return; + if (!el.__rtlxSetDir) { + el.__rtlxSetDir = true; + el.setAttribute("data-rtlx-setdir", "1"); + el.setAttribute("data-rtlx-dir", el.hasAttribute("dir") ? el.getAttribute("dir") : ""); + } + if (el.getAttribute("dir") !== value) el.setAttribute("dir", value); + // Only forced-RTL blocks get the alignment hook; "auto" blocks must NOT be + // right-aligned (that's the over-aggressive behavior we're avoiding). + if (value === "rtl") el.setAttribute("data-rtlx-rtl", "1"); + else el.removeAttribute("data-rtlx-rtl"); + } + + function hasAuthorFont(el) { + let node = el; + while (node && node.nodeType === 1) { + if (node.style && node.style.getPropertyValue("font-family").trim()) return true; + for (let i = 0; i < document.styleSheets.length; i++) { + let rules; + try { rules = document.styleSheets[i].cssRules; } catch (e) { continue; } + if (rulesSetFont(node, rules)) return true; + } + node = node.parentElement; + } + return false; + } + + function rulesSetFont(el, rules) { + if (!rules) return false; + for (let i = 0; i < rules.length; i++) { + const rule = rules[i]; + if (rule.selectorText && rule.style && rule.style.getPropertyValue("font-family").trim()) { + try { if (el.matches(rule.selectorText)) return true; } catch (e) {} + } + let nested; + try { nested = rule.cssRules; } catch (e) { nested = null; } + if (nested && rulesSetFont(el, nested)) return true; + } + return false; + } + + // Apply Vazirmatn only when neither the element nor an ancestor has an + // author-provided inline font. Stylesheet rules are handled below as part of + // the same author-font policy. + function applyFont(el) { + if (!settings.useFont || !el || el.nodeType !== 1 || el.__rtlxFont) return; + if (hasAuthorFont(el)) return; + el.__rtlxFont = true; + el.setAttribute("data-rtlx-font", "1"); + el.setAttribute("data-rtlx-ff", el.style.fontFamily || ""); + el.style.fontFamily = "'Vazirmatn'"; + } + + function restoreDirection(el) { + if (!el || !el.hasAttribute("data-rtlx-setdir")) return; + const original = el.getAttribute("data-rtlx-dir"); + if (original) el.setAttribute("dir", original); else el.removeAttribute("dir"); + el.removeAttribute("data-rtlx-setdir"); + el.removeAttribute("data-rtlx-dir"); + el.removeAttribute("data-rtlx-rtl"); + el.__rtlxSetDir = false; + } + + function restoreFont(el) { + if (!el || !el.hasAttribute("data-rtlx-font")) return; + const original = el.getAttribute("data-rtlx-ff"); + if (original) el.style.fontFamily = original; + else el.style.removeProperty("font-family"); + el.removeAttribute("data-rtlx-font"); + el.removeAttribute("data-rtlx-ff"); + el.__rtlxFont = false; + } + + // --------------------------------------------------------------------------- + // Inputs / textareas / contenteditables + // --------------------------------------------------------------------------- + function applyInputs(root) { + if (!settings.fixInputs) return; + const handle = (el) => { + if (!el) return; + if (el.tagName === "INPUT") { + const t = (el.getAttribute("type") || "text").toLowerCase(); + if (SKIP_INPUT_TYPES.has(t)) return; + } + if (!el.__rtlxInput) { + el.__rtlxInput = true; + el.setAttribute("data-rtlx-input", "1"); + if (!el.hasAttribute("dir")) { + if (!el.__rtlxSetDir) { + el.__rtlxSetDir = true; + el.setAttribute("data-rtlx-setdir", "1"); + el.setAttribute("data-rtlx-dir", ""); + } + el.setAttribute("dir", "auto"); + } + if (!el.__rtlxInputHandler) { + el.__rtlxInputHandler = () => updateInputFont(el); + el.addEventListener("input", el.__rtlxInputHandler); + } + } + updateInputFont(el); + }; + if (root.matches && root.matches(INPUT_SELECTOR)) handle(root); + if (root.querySelectorAll) { + let list; + try { list = root.querySelectorAll(INPUT_SELECTOR); } catch (e) { return; } + for (let i = 0; i < list.length; i++) handle(list[i]); + } + } + + function updateInputFont(el) { + if (!active || !settings.fixInputs) return; + const sample = (el.value || "") + " " + (el.getAttribute("placeholder") || ""); + if (PERSIAN_RE.test(sample)) applyFont(el); + else restoreFont(el); + } + + // --------------------------------------------------------------------------- + // Core: tag a Persian text node's block (direction) + element (font) + // `votedBlocks` dedupes the (costly) direction vote within a single pass. + // --------------------------------------------------------------------------- + function processTextNode(node, votedBlocks) { + const parent = node.parentElement; + if (!parent || SKIP_TAGS.has(parent.tagName) || inSkip(parent)) return; + if (!PERSIAN_RE.test(node.nodeValue || "")) { + const oldBlock = nearestBlock(parent); + if (!PERSIAN_RE.test(oldBlock.textContent || "")) restoreDirection(oldBlock); + if (!PERSIAN_RE.test(parent.textContent || "")) restoreFont(parent); + return; + } + + // Font goes on the immediate parent so Persian inside styled spans is also + // covered; unicode-range keeps English in that element untouched. + applyFont(parent); + + // Direction goes on the nearest block, voted by ALL of the block's text. + const block = nearestBlock(parent); + if (inSkip(block)) return; + if (votedBlocks) { + if (votedBlocks.has(block)) return; + votedBlocks.add(block); + } + let text = ""; + try { text = block.textContent || ""; } catch (e) {} + if (settings.fixDirection) { + const d = dirForText(text); + if (d === "rtl") setDir(block, "rtl"); + else if (d === "ltr") setDir(block, "auto"); + } + } + + // --------------------------------------------------------------------------- + // Scanning + // --------------------------------------------------------------------------- + function scan(root) { + if (!root) return; + let walker; + try { + walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, { + acceptNode(n) { + const v = n.nodeValue; + if (!v || !PERSIAN_RE.test(v)) return NodeFilter.FILTER_SKIP; + const p = n.parentElement; + if (!p || SKIP_TAGS.has(p.tagName) || inSkip(p)) return NodeFilter.FILTER_REJECT; + return NodeFilter.FILTER_ACCEPT; + } + }); + } catch (e) { return; } + + const nodes = []; + let n; + while ((n = walker.nextNode())) nodes.push(n); + const votedBlocks = new Set(); + for (let i = 0; i < nodes.length; i++) { + try { processTextNode(nodes[i], votedBlocks); } catch (e) {} + } + applyInputs(root); + } + + function scanShadow(root) { + let w; + try { w = document.createTreeWalker(root, NodeFilter.SHOW_ELEMENT, null); } + catch (e) { return; } + let el; + while ((el = w.nextNode())) { + const sr = el.shadowRoot; + if (sr) { observeShadow(sr); scan(sr); scanShadow(sr); } + } + } + + // --------------------------------------------------------------------------- + // Real-time updates + // --------------------------------------------------------------------------- + let pendingNodes = new Set(); + let pendingRoots = new Set(); + let scheduled = false; + + function schedule() { + if (scheduled) return; + scheduled = true; + const run = () => { scheduled = false; flush(); }; + if (window.requestIdleCallback) requestIdleCallback(run, { timeout: 500 }); + else if (window.requestAnimationFrame) requestAnimationFrame(run); + else setTimeout(run, 16); + } + + function flush() { + if (!active) { pendingNodes.clear(); pendingRoots.clear(); return; } + const nodes = pendingNodes; pendingNodes = new Set(); + const roots = pendingRoots; pendingRoots = new Set(); + const votedBlocks = new Set(); + nodes.forEach((node) => { + try { if (node.isConnected) processTextNode(node, votedBlocks); } catch (e) {} + }); + roots.forEach((r) => { + try { + if (!r.isConnected) return; + scan(r); + if (r.shadowRoot) { observeShadow(r.shadowRoot); scan(r.shadowRoot); } + scanShadow(r); + } catch (e) {} + }); + } + + function onMutations(muts) { + if (!active) return; + for (let i = 0; i < muts.length; i++) { + const m = muts[i]; + if (m.type === "characterData") { + const node = m.target; + if (node.parentElement) pendingNodes.add(node); + } else { // childList + const added = m.addedNodes; + for (let j = 0; j < added.length; j++) { + const node = added[j]; + if (node.nodeType === 3) { + if (node.parentElement) pendingNodes.add(node); + } else if (node.nodeType === 1) { + pendingRoots.add(node); + } + } + } + } + if (pendingNodes.size || pendingRoots.size) schedule(); + } + + function observeShadow(sr) { + if (observedShadowRoots.has(sr)) return; + observedShadowRoots.add(sr); + try { + const mo = new MutationObserver(onMutations); + mo.observe(sr, { childList: true, subtree: true, characterData: true }); + shadowObservers.push(mo); + } catch (e) {} + } + + // --------------------------------------------------------------------------- + // Vazirmatn @font-face — unicode-range covers the glyphs Persian requires, + // so it never touches Latin text. Loaded from chrome-extension:// (bypasses + // page CSP font-src). + // --------------------------------------------------------------------------- + function injectFont() { + if (!settings.useFont) return; + if (document.getElementById("rtlx-font-face")) return; + const url = (p) => chrome.runtime.getURL(p); + // Arabic, Arabic Supplement, Arabic Extended-A, Presentation Forms A & B, + // plus ZWNJ/ZWJ used inside Persian words. + const range = + "U+0600-06FF,U+0750-077F,U+08A0-08FF,U+FB50-FDFF,U+FE70-FEFF,U+200C-200D"; + const face = (w, file) => + "@font-face{font-family:'Vazirmatn';font-style:normal;font-weight:" + w + + ";font-display:swap;unicode-range:" + range + + ";src:url('" + url(file) + "') format('woff2');}"; + const css = + face(400, "fonts/Vazirmatn-Regular.woff2") + + face(500, "fonts/Vazirmatn-Medium.woff2") + + face(700, "fonts/Vazirmatn-Bold.woff2") + + // Force right-alignment on forced-RTL blocks, beating site text-align:left. + // Only applies to data-rtlx-rtl (Persian-dominant), never to "auto" blocks. + "[data-rtlx-rtl='1']{direction:rtl!important;text-align:right!important;}"; + const style = document.createElement("style"); + style.id = "rtlx-font-face"; + style.textContent = css; + (document.head || document.documentElement).appendChild(style); + } + + function removeFont() { + const s = document.getElementById("rtlx-font-face"); + if (s) s.remove(); + } + + // --------------------------------------------------------------------------- + // Undo (clean restore without reload) + // --------------------------------------------------------------------------- + function restoreEl(el) { + restoreDirection(el); + restoreFont(el); + el.removeAttribute("data-rtlx-input"); + el.__rtlxInput = false; + } + + function undoIn(root) { + try { + root.querySelectorAll("[data-rtlx-setdir],[data-rtlx-font],[data-rtlx-input]") + .forEach(restoreEl); + } catch (e) {} + } + + function undoAll() { + undoIn(document); + observedShadowRoots.forEach((sr) => undoIn(sr)); + } + + // --------------------------------------------------------------------------- + // Lifecycle + // --------------------------------------------------------------------------- + function fullScan() { + scan(document.body || document); + scanShadow(document.documentElement || document); + } + + function boot() { + if (booted) return; + booted = true; + injectFont(); + observer = new MutationObserver(onMutations); + observer.observe(document.documentElement || document, { + childList: true, subtree: true, characterData: true + }); + if (document.body) fullScan(); + if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", () => { if (active) fullScan(); }, { once: true }); + } + window.addEventListener("load", () => { if (active) fullScan(); }, { once: true }); + } + + function teardown() { + booted = false; + if (observer) { observer.disconnect(); observer = null; } + while (shadowObservers.length) { try { shadowObservers.pop().disconnect(); } catch (e) {} } + pendingNodes.clear(); + pendingRoots.clear(); + undoAll(); + observedShadowRoots.clear(); + removeFont(); + } + + function computeActive() { + return !!settings.enabled && settings.disabledSites.indexOf(location.hostname) === -1; + } + + function reapply() { + if (observer) { observer.disconnect(); observer = null; } + while (shadowObservers.length) { try { shadowObservers.pop().disconnect(); } catch (e) {} } + undoAll(); + observedShadowRoots.clear(); + removeFont(); + booted = false; + boot(); + } + + try { + chrome.storage.local.get(DEFAULTS, (s) => { + if (chrome.runtime.lastError) s = {}; + settings = Object.assign({}, DEFAULTS, s); + active = computeActive(); + if (active) boot(); + }); + + chrome.storage.onChanged.addListener((changes, area) => { + if (area !== "local") return; + let touched = false; + for (const k in changes) { + if (k in DEFAULTS) { settings[k] = changes[k].newValue; touched = true; } + } + if (!touched) return; + const now = computeActive(); + if (now && !active) { active = true; boot(); } + else if (!now && active) { active = false; teardown(); } + else if (now && active) { reapply(); } + }); + } catch (e) { /* chrome.* unavailable */ } +})();