Webページの指定フォントを上書きし、ユーザーの好みのフォント(ローカルインストール済み)に統一する。
// ==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();
}
})();