Skip to content

Instantly share code, notes, and snippets.

@lekoOwO
Created May 31, 2026 09:01
Show Gist options
  • Select an option

  • Save lekoOwO/87cd98cb7bd5951039b07a71b62abe01 to your computer and use it in GitHub Desktop.

Select an option

Save lekoOwO/87cd98cb7bd5951039b07a71b62abe01 to your computer and use it in GitHub Desktop.
// ==UserScript==
// @name CJK font replacer
// @namespace moe.leko.cjk-font-fix
// @version 2.0.0
// @description Make selected Latin font families use local CJK font only for CJK glyphs, without rewriting page elements.
// @match *://*/*
// @allFrames true
// @run-at document-start
// @grant GM_addStyle
// ==/UserScript==
(function () {
'use strict';
/**
* ============================================================
* Centralized font config
* ============================================================
*/
const FONT_CONFIG = {
/**
* 你真正想讓中文使用的本機字體。
* local() 會由上到下嘗試。
*/
cjkLocalNames: [
'Noto Sans CJK TC',
'Noto Sans CJK TC Regular',
'NotoSansCJKtc-Regular',
'NotoSansCJKtc',
'Noto Sans CJK TC DemiLight',
'Noto Sans CJK TC Medium',
'Noto Sans CJK TC Bold',
'Noto Sans CJK TC Black',
],
/**
* 要覆蓋的 font-family 名稱。
*
* 網站如果寫:
* font-family: Arial, sans-serif;
*
* 這份 script 會替 Arial 建立 CJK-only @font-face。
* 英文仍然使用原本 Arial,中文才會使用 Noto Sans CJK TC。
*/
targetFamilies: [
'Arial',
'Helvetica',
'Helvetica Neue',
'Roboto',
'Inter',
'Segoe UI',
'Verdana',
'Tahoma',
'Trebuchet MS',
'Open Sans',
'Lato',
'Montserrat',
'Poppins',
'Ubuntu',
'Noto Sans',
'Source Sans Pro',
'Source Sans 3',
'SF Pro Text',
'SF Pro Display',
'system-ui',
'-apple-system',
'BlinkMacSystemFont',
'Public Sans', // avemujica.moe
'Roboto', // YouTube
],
/**
* 另外建立一個你可以手動使用的 alias。
* 例如之後你自己的 Stylus / CSS 可以寫:
* font-family: "User CJK Sans", sans-serif;
*/
aliasFamily: 'User CJK Sans',
};
/**
* ============================================================
* Flags
* ============================================================
*/
const ENABLE_SCRIPT = true;
/**
* true:
* 持續把 style tag 搬到 head 最後,避免被網站後載入的 @font-face 蓋過。
*/
const KEEP_STYLE_LAST = true;
/**
* true:
* 多輪重新注入,對 SPA / late CSS 比較穩。
*/
const REINJECT_MULTIPLE_ROUNDS = true;
const DEBUG = false;
/**
* ============================================================
* Unicode ranges
* ============================================================
*
* 不包含基本 Latin。
* 所以不會接管英文字母。
*/
const CJK_UNICODE_RANGES = [
'U+2E80-2EFF',
'U+2F00-2FDF',
'U+3000-303F',
'U+3040-309F',
'U+30A0-30FF',
'U+3100-312F',
'U+31A0-31BF',
'U+31C0-31EF',
'U+3400-4DBF',
'U+4E00-9FFF',
'U+F900-FAFF',
'U+FE10-FE1F',
'U+FE30-FE4F',
'U+FF00-FFEF',
'U+20000-2A6DF',
'U+2A700-2B73F',
'U+2B740-2B81F',
'U+2B820-2CEAF',
'U+2CEB0-2EBEF',
'U+30000-3134F',
];
if (!ENABLE_SCRIPT) return;
const STYLE_ID = 'user-cjk-font-face-alias-override';
let styleEl = null;
/**
* ============================================================
* CSS generation
* ============================================================
*/
function buildLocalSrc() {
return FONT_CONFIG.cjkLocalNames
.map(name => `local("${escapeCssString(name)}")`)
.join(',\n ');
}
function buildFontFaceForFamily(family) {
const escapedFamily = escapeCssString(family);
return `
@font-face {
font-family: "${escapedFamily}";
src:
${buildLocalSrc()};
font-style: normal;
font-weight: 100 900;
unicode-range: ${CJK_UNICODE_RANGES.join(', ')};
font-display: block;
}
`;
}
function buildCss() {
const families = unique([
FONT_CONFIG.aliasFamily,
...FONT_CONFIG.targetFamilies,
]);
return `
${families.map(buildFontFaceForFamily).join('\n')}
`;
}
/**
* ============================================================
* Injection
* ============================================================
*/
function injectStyle() {
const css = buildCss();
styleEl = document.getElementById(STYLE_ID);
if (!styleEl) {
styleEl = document.createElement('style');
styleEl.id = STYLE_ID;
styleEl.setAttribute('data-user-cjk-font-face-override', 'true');
}
if (styleEl.textContent !== css) {
styleEl.textContent = css;
}
const target = document.head || document.documentElement || document.body;
if (target && !styleEl.isConnected) {
target.appendChild(styleEl);
}
moveStyleToEnd();
debugLog('style injected');
}
function moveStyleToEnd() {
if (!KEEP_STYLE_LAST) return;
if (!styleEl || !styleEl.isConnected) return;
const parent = document.head || document.documentElement;
if (!parent) return;
if (styleEl.parentNode !== parent || parent.lastElementChild !== styleEl) {
parent.appendChild(styleEl);
}
}
function startStyleKeeper() {
if (!KEEP_STYLE_LAST) return;
const target = document.head || document.documentElement;
if (!target) return;
const observer = new MutationObserver(() => {
injectStyle();
moveStyleToEnd();
});
observer.observe(target, {
childList: true,
subtree: false,
});
}
function reinjectRounds() {
if (!REINJECT_MULTIPLE_ROUNDS) return;
[0, 50, 250, 1000, 3000, 7000].forEach(delay => {
setTimeout(() => {
injectStyle();
moveStyleToEnd();
refreshFonts();
}, delay);
});
}
/**
* ============================================================
* Refresh
* ============================================================
*/
function refreshFonts() {
if (!document.documentElement) return;
// 模擬輕量 style invalidation,不改元素字體。
document.documentElement.dataset.userCjkFontRefresh =
String(Date.now());
void document.documentElement.offsetHeight;
}
async function warmupFonts() {
if (!document.fonts || !document.fonts.load) return;
try {
await Promise.all(
unique([
FONT_CONFIG.aliasFamily,
...FONT_CONFIG.targetFamilies,
]).map(family =>
document.fonts.load(`16px "${family}"`, '中文測試')
)
);
await document.fonts.ready;
refreshFonts();
debugLog('fonts warmed up');
} catch (error) {
debugLog('font warmup failed', error);
}
}
/**
* ============================================================
* Helpers
* ============================================================
*/
function escapeCssString(value) {
return String(value)
.replace(/\\/g, '\\\\')
.replace(/"/g, '\\"');
}
function unique(items) {
const seen = new Set();
const result = [];
for (const item of items) {
const key = String(item).trim().toLowerCase();
if (!key || seen.has(key)) continue;
seen.add(key);
result.push(item);
}
return result;
}
function debugLog(...args) {
if (DEBUG) {
console.log('[CJK Font Face Override]', ...args);
}
}
/**
* ============================================================
* Start
* ============================================================
*/
function start() {
injectStyle();
startStyleKeeper();
reinjectRounds();
warmupFonts();
if (document.fonts && document.fonts.ready) {
document.fonts.ready.then(() => {
injectStyle();
moveStyleToEnd();
refreshFonts();
});
}
}
if (document.documentElement) {
start();
} else {
document.addEventListener('DOMContentLoaded', start, { once: true });
}
})();
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment