Font Override

Overrides the font specified by web pages and unifies it to your preferred, locally installed font.

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

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

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

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

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

You will need to install a user script manager extension to install this script.

(У мене вже є менеджер скриптів, дайте мені встановити його!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

// ==UserScript==
// @name           Font Override
// @name:ja        Font Override
// @namespace      https://github.com/hakhatz2486/font-override
// @version        0.1.0
// @description    Overrides the font specified by web pages and unifies it to your preferred, locally installed font.
// @description:ja Webページの指定フォントを上書きし、ユーザーの好みのフォント(ローカルインストール済み)に統一する。
// @author         hakhatz2486
// @homepageURL    https://greasyfork.org/ja/scripts/588330-font-override
// @supportURL     https://github.com/hakhatz2486/font-override/issues
// @license        MIT
// @icon           https://fonts.gstatic.com/s/i/short-term/release/materialsymbolsoutlined/text_fields/default/24px.svg
// @match          *://*/*
// @grant          GM_addStyle
// @run-at         document-start
// ==/UserScript==

(function () {
    "use strict";

    // ===========================================================
    // [A] 定数・ルックアップテーブル
    // ===========================================================

    // 使用フォントを変更する場合はこの変数を書き換える
    const FONT_SANS = '"Noto Sans JP"';
    const FONT_SERIF = '"Noto Serif JP"';
    const FONT_MONO = '"PlemolJP"';

    const PUA_RANGES = [
        [0xe000, 0xf8ff],
        [0xf0000, 0xffffd],
        [0x100000, 0x10fffd],
    ];

    const ICON_FONT_NAME_PATTERNS = [
        "font awesome",
        "fontawesome",
        "material icons",
        "material symbols",
        "material design icons",
        "ionicons",
        "glyphicons",
        "glyphicon",
        "bootstrap icons",
        "octicons",
        "remixicon",
        "iconfont",
    ];

    const ICON_CLASS_SELECTOR = [
        ".material-icons",
        ".material-icons-outlined",
        ".material-icons-round",
        ".material-icons-sharp",
        ".material-icons-two-tone",
        ".material-symbols-outlined",
        ".material-symbols-rounded",
        ".material-symbols-sharp",
        ".fa",
        ".fas",
        ".far",
        ".fal",
        ".fab",
        ".fad",
        ".glyphicon",
        ".ionicon",
        ".octicon",
        ".iconfont",
    ].join(", ");

    const EMOJI_FONT_NAMES = new Set([
        "segoe ui emoji",
        "apple color emoji",
        "noto color emoji",
        "segoe ui symbol",
    ]);

    const GENERIC_KEYWORD_CATEGORY = new Map([
        ["sans-serif", "sans"],
        ["serif", "serif"],
        ["monospace", "mono"],
    ]);

    const FONT_NAME_CATEGORY = new Map([
        // --- sans-serif ---
        ["arial", "sans"],
        ["helvetica", "sans"],
        ["helvetica neue", "sans"],
        ["segoe ui", "sans"],
        ["roboto", "sans"],
        ["-apple-system", "sans"],
        ["blinkmacsystemfont", "sans"],
        ["system-ui", "sans"],
        ["ui-sans-serif", "sans"],
        ["tahoma", "sans"],
        ["verdana", "sans"],
        ["calibri", "sans"],
        ["open sans", "sans"],
        ["noto sans", "sans"],
        ["noto sans jp", "sans"],
        ["noto sans cjk jp", "sans"],
        ["source sans pro", "sans"],
        ["lato", "sans"],
        ["oswald", "sans"],
        ["montserrat", "sans"],
        ["ubuntu", "sans"],
        ["droid sans", "sans"],
        ["pingfang sc", "sans"],
        ["pingfang tc", "sans"],
        ["microsoft yahei", "sans"],
        ["microsoft jhenghei", "sans"],
        ["simhei", "sans"],
        ["yu gothic", "sans"],
        ["yu gothic ui", "sans"],
        ["meiryo", "sans"],
        ["meiryo ui", "sans"],
        ["ms pgothic", "sans"],
        ["hiragino sans", "sans"],
        ["hiragino kaku gothic pro", "sans"],
        ["hiragino kaku gothic pron", "sans"],
        ["hiragino maru gothic pro", "sans"],
        ["biz udpgothic", "sans"],
        ["zen kaku gothic new", "sans"],
        ["trebuchet ms", "sans"],

        // --- serif ---
        ["georgia", "serif"],
        ["times new roman", "serif"],
        ["times", "serif"],
        ["cambria", "serif"],
        ["garamond", "serif"],
        ["palatino", "serif"],
        ["palatino linotype", "serif"],
        ["book antiqua", "serif"],
        ["constantia", "serif"],
        ["didot", "serif"],
        ["baskerville", "serif"],
        ["rockwell", "serif"],
        ["yu mincho", "serif"],
        ["ms mincho", "serif"],
        ["ms pmincho", "serif"],
        ["hiragino mincho pro", "serif"],
        ["hiragino mincho pron", "serif"],
        ["biz udpmincho", "serif"],
        ["noto serif", "serif"],
        ["noto serif jp", "serif"],
        ["noto serif cjk jp", "serif"],
        ["source serif pro", "serif"],
        ["pt serif", "serif"],
        ["merriweather", "serif"],
        ["playfair display", "serif"],
        ["simsun", "serif"],
        ["songti sc", "serif"],

        // --- monospace ---
        ["consolas", "mono"],
        ["menlo", "mono"],
        ["courier new", "mono"],
        ["courier", "mono"],
        ["monaco", "mono"],
        ["sf mono", "mono"],
        ["source code pro", "mono"],
        ["fira code", "mono"],
        ["fira mono", "mono"],
        ["jetbrains mono", "mono"],
        ["roboto mono", "mono"],
        ["ubuntu mono", "mono"],
        ["dejavu sans mono", "mono"],
        ["ms gothic", "mono"],
        ["lucida console", "mono"],
        ["cascadia code", "mono"],
        ["cascadia mono", "mono"],
        ["ibm plex mono", "mono"],
        ["inconsolata", "mono"],
        ["andale mono", "mono"],
        ["hack", "mono"],
        ["migu 1m", "mono"],
        ["migu 2m", "mono"],
        ["ricty", "mono"],
        ["ricty diminished", "mono"],
        ["osaka-mono", "mono"],
    ]);

    const SKIP_TAGS = new Set([
        "SCRIPT",
        "STYLE",
        "LINK",
        "META",
        "NOSCRIPT",
        "TEMPLATE",
        "TITLE",
        "BASE",
    ]);

    // ===========================================================
    // [B] スタイル定義 — フォントの実体はここにしか出現しない
    // ===========================================================

    function injectBaseStyles() {
        GM_addStyle(`
      .fo-sans { font-family: ${FONT_SANS}, sans-serif !important; }
      .fo-serif { font-family: ${FONT_SERIF}, serif !important; }
      .fo-mono { font-family: ${FONT_MONO}, monospace !important; }
    `);
    }

    // ===========================================================
    // [C] 判定処理 — 抽象クラス名(sans/serif/mono)のみを扱う
    // ===========================================================

    function codePointIsPUA(cp) {
        return PUA_RANGES.some(([start, end]) => cp >= start && cp <= end);
    }

    function hasPUAOnlyOwnText(el) {
        let hasNonWhitespaceChar = false;
        for (const node of el.childNodes) {
            if (node.nodeType !== Node.TEXT_NODE) continue;
            for (const ch of node.data) {
                if (/\s/.test(ch)) continue;
                hasNonWhitespaceChar = true;
                if (!codePointIsPUA(ch.codePointAt(0))) return false;
            }
        }
        return hasNonWhitespaceChar;
    }

    function isKnownIconElement(el) {
        return el.matches(ICON_CLASS_SELECTOR);
    }

    function isIconFontFamily(fontFamilyValue) {
        const lower = fontFamilyValue.toLowerCase();
        return ICON_FONT_NAME_PATTERNS.some((name) => lower.includes(name));
    }

    function normalizeToken(token) {
        return token
            .trim()
            .replace(/^["']|["']$/g, "")
            .toLowerCase();
    }

    const fontFamilyCategoryCache = new Map();

    function resolveFontCategory(fontFamilyValue) {
        if (fontFamilyCategoryCache.has(fontFamilyValue)) {
            return fontFamilyCategoryCache.get(fontFamilyValue);
        }
        const tokens = fontFamilyValue
            .split(",")
            .map(normalizeToken)
            .filter(Boolean);
        let category = null;
        let lastIndex = tokens.length - 1;
        while (lastIndex >= 0 && EMOJI_FONT_NAMES.has(tokens[lastIndex])) {
            lastIndex--;
        }
        if (lastIndex >= 0) {
            const last = tokens[lastIndex];
            category =
                GENERIC_KEYWORD_CATEGORY.get(last) ||
                FONT_NAME_CATEGORY.get(last) ||
                null;
        }
        fontFamilyCategoryCache.set(fontFamilyValue, category);
        return category;
    }

    function classifyElementReadOnly(el) {
        if (isKnownIconElement(el)) return { el, category: null };
        if (hasPUAOnlyOwnText(el)) return { el, category: null };

        const fontFamilyValue = getComputedStyle(el).fontFamily;
        if (isIconFontFamily(fontFamilyValue)) return { el, category: null };

        return { el, category: resolveFontCategory(fontFamilyValue) };
    }

    // ===========================================================
    // [D] キャッシュ・処理済みマーキング
    // ===========================================================

    const processedElements = new WeakSet();

    function collectUnprocessedElements(root) {
        const results = [];

        const consider = (el) => {
            if (processedElements.has(el)) return;
            processedElements.add(el);
            if (SKIP_TAGS.has(el.tagName)) return;
            results.push(el);
        };

        if (root.nodeType === Node.ELEMENT_NODE) consider(root);
        if (root.querySelectorAll) {
            for (const el of root.querySelectorAll("*")) consider(el);
        }
        return results;
    }

    // ===========================================================
    // [E] 走査・チャンク処理エンジン(read/write分離)
    // ===========================================================

    const scheduleIdle = window.requestIdleCallback
        ? window.requestIdleCallback.bind(window)
        : (cb) =>
              setTimeout(
                  () => cb({ didTimeout: true, timeRemaining: () => 0 }),
                  16,
              );

    function processElementsInChunks(elements) {
        let index = 0;
        function step(deadline) {
            const readResults = [];
            while (
                index < elements.length &&
                (deadline.timeRemaining() > 0 || deadline.didTimeout)
            ) {
                readResults.push(classifyElementReadOnly(elements[index]));
                index++;
            }
            for (const { el, category } of readResults) {
                if (category) el.classList.add("fo-" + category);
            }
            if (index < elements.length) scheduleIdle(step);
        }
        scheduleIdle(step);
    }

    function runInitialScan() {
        const root = document.body || document.documentElement;
        processElementsInChunks(collectUnprocessedElements(root));
    }

    // ===========================================================
    // [F] MutationObserver連携
    // ===========================================================

    let pendingElements = [];
    let flushScheduled = false;

    function scheduleCoalescedScan() {
        if (flushScheduled) return;
        flushScheduled = true;
        scheduleIdle(() => {
            const toProcess = pendingElements;
            pendingElements = [];
            flushScheduled = false;
            processElementsInChunks(toProcess);
        });
    }

    function handleMutations(records) {
        for (const record of records) {
            for (const node of record.addedNodes) {
                if (node.nodeType !== Node.ELEMENT_NODE) continue;
                pendingElements.push(...collectUnprocessedElements(node));
            }
        }
        if (pendingElements.length > 0) scheduleCoalescedScan();
    }

    function startObserving() {
        new MutationObserver(handleMutations).observe(document, {
            childList: true,
            subtree: true,
        });
    }

    // ===========================================================
    // [G] ブートストラップ
    // ===========================================================

    injectBaseStyles();
    startObserving();

    if (document.readyState === "loading") {
        document.addEventListener("DOMContentLoaded", runInitialScan, {
            once: true,
        });
    } else {
        runInitialScan();
    }
})();