Persian Font Fix (Vazir)

Apply Vazir font to Persian/RTL content across selected websites

当前为 2025-07-04 提交的版本,查看 最新版本

您需要先安装一个扩展,例如 篡改猴Greasemonkey暴力猴,之后才能安装此脚本。

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

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴Userscripts ,之后才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。

您需要先安装用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==UserScript==
// @name         Persian Font Fix (Vazir)
// @namespace    https://greasyfork.org/en/scripts/538095-persian-font-fix-vazir
// @version      2
// @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/*
// @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'), local('Noto Sans');
      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','Noto Color Emoji','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);
  }

})();