Persian Font Fix (Vazir)

Apply Vazir font to Persian/RTL content across selected websites

目前為 2025-07-04 提交的版本,檢視 最新版本

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

You will need to install an extension such as Tampermonkey to install this script.

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

// ==UserScript==
// @name         Persian Font Fix (Vazir)
// @namespace    https://greasyfork.org/en/scripts/538095-persian-font-fix-vazir
// @version      2.0.5
// @description  Apply Vazir font to Persian/RTL content across selected websites
// @author       TheSina
// @match       *://*.telegram.org/*
// @match       *://*.x.com/*
// @match       *://*.twitter.com/*
// @match       *://*.instagram.com/*
// @match       *://*.facebook.com/*
// @match       *://*.whatsapp.com/*
// @match       *://*.github.com/*
// @match       *://*.youtube.com/*
// @match       *://*.soundcloud.com/*
// @match       *://www.google.com/*
// @match       *://gemini.google.com/*
// @match       *://translate.google.com/*
// @match       *://*.chatgpt.com/*
// @match       *://*.openai.com/*
// @match       *://fa.wikipedia.org/*
// @match       *://app.slack.com/*
// @match       *://*.goodreads.com/*
// @match       *://*.reddit.com/*
// @exclude  *://*.google.*/recaptcha/*
// @grant        GM_addStyle
// @run-at       document-start
// @license      MIT
// ==/UserScript==
/* jshint esversion: 10 */
/* global requestIdleCallback */
(function () {
  'use strict';

  // --- 0. Inject font regardless of performance tweaks ---
  GM_addStyle(`
    @font-face {
      font-family: 'VazirmatnFixed';
      src: local('Vazirmatn');
      font-display: swap;
      unicode-range: U+0600-06FF, U+0750-077F, U+08A0-08FF, U+FB50-FDFF, U+FE70-FEFF;
    }
    body, p, div, h1, h2, h3, h4, h5, h6,
    a, li, td, th, input[type="text"], input[type="search"],
    textarea, select, option, label, button,
    blockquote, summary, details, figcaption, strong, em,
    span[lang^="fa"], span[lang^="ar"], span[dir="rtl"] {
      font-family: 'VazirmatnFixed','Noto Sans','Apple Color Emoji','Noto Color Emoji','Twemoji Mozilla','Google Sans','Helvetica Neue',sans-serif !important;
    }
  `);

  // --- 1. Only look for the two characters we actually replace ---
  const replacementRegex = /[يك]/g;
  const charMap = new Map([
    ['ي', 'ی'],
    ['ك', 'ک']
  ]);

  const fixText = text => text.replace(replacementRegex, c => charMap.get(c) || c);

  // --- 2. Fast node‐by‐node replacement, only when needed ---
  const processed = new WeakSet();
  const walkerFilter = {
    acceptNode(node) {
      // only walk TEXT nodes that contain at least one replaceable char
      return replacementRegex.test(node.nodeValue) ?
        NodeFilter.FILTER_ACCEPT :
        NodeFilter.FILTER_SKIP;
    }
  };

  function fixNode(root) {
    if (processed.has(root) || !replacementRegex.test(root.textContent)) return;
    const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, walkerFilter, false);
    let node, changed = false;
    while ((node = walker.nextNode())) {
      const orig = node.nodeValue;
      const upd = fixText(orig);
      if (orig !== upd) {
        node.nodeValue = upd;
        changed = true;
      }
    }
    if (changed) processed.add(root);
  }

  // --- 3. Input elements: per-element debounce, no full re-scans ---
  function attachInput(el) {
    if (el.dataset.pfixAttached) return;
    el.dataset.pfixAttached = '1';

    const doFix = () => {
      if (!replacementRegex.test(el.value)) return;
      const orig = el.value;
      const upd = fixText(orig);
      if (orig === upd) return;
      const start = el.selectionStart;
      const end = el.selectionEnd;
      el.value = upd;

      if (start != null && end != null) {
        try {
          el.setSelectionRange(start, end);
        }
        catch (err) {
          // Ignore
        }
      }
    };

    let to;
    el.addEventListener('input', () => {
      clearTimeout(to);
      to = setTimeout(doFix, 50);
    });

    // Initial fix
    doFix();
  }

  // --- 4. Throttled, targeted MutationObserver ---
  let pending = new Set(),
    ticking = false;

  function schedule() {
    if (ticking) return;
    ticking = true;
    // run on idle if available
    const exec = () => {
      pending.forEach(node => {
        if (node.nodeType === Node.TEXT_NODE || node.nodeType === Node.ELEMENT_NODE)
          fixNode(node.nodeType === 1 ? node : node.parentElement);
      });
      pending.clear();
      ticking = false;
    };
    if ('requestIdleCallback' in window) requestIdleCallback(exec, {
      timeout: 200
    });
    else setTimeout(exec, 100);
  }

  const obs = new MutationObserver(muts => {
    muts.forEach(m => {
      if (m.type === 'characterData' && replacementRegex.test(m.target.nodeValue)) {
        pending.add(m.target);
      }
      if (m.type === 'childList') {
        m.addedNodes.forEach(n => {
          if (n.nodeType === 3) { // text node
            if (replacementRegex.test(n.nodeValue)) pending.add(n);
          }
          else if (n.nodeType === 1) { // element
            // if it has replaceable text somewhere in subtree
            if (replacementRegex.test(n.textContent)) pending.add(n);
            // if it’s an <input> or <textarea>, attach
            const tag = n.tagName;
            if (tag === 'INPUT' || tag === 'TEXTAREA') attachInput(n);
            // also look for any nested inputs
            n.querySelectorAll('input,textarea').forEach(attachInput);
          }
        });
      }
    });
    if (pending.size) schedule();
  });

  // --- 5. Initialization only after full load, so paint isn’t blocked ---
  function init() {
    // 5a. Initial sweep in idle time
    if ('requestIdleCallback' in window) {
      requestIdleCallback(() => fixNode(document.body), {
        timeout: 500
      });
      requestIdleCallback(() => {
        document.querySelectorAll('input,textarea').forEach(attachInput);
      }, {
        timeout: 500
      });
    }
    else {
      setTimeout(() => fixNode(document.body), 200);
      setTimeout(() => {
        document.querySelectorAll('input,textarea').forEach(attachInput);
      }, 200);
    }

    // 5b. Start observing for dynamic content
    obs.observe(document.body, {
      childList: true,
      subtree: true,
      characterData: true
    });
  }

  if (document.readyState === 'complete') {
    init();
  }
  else {
    window.addEventListener('load', init);
  }

})();