Skip to content

Instantly share code, notes, and snippets.

@rishubil
Last active September 22, 2026 09:07
Show Gist options
  • Select an option

  • Save rishubil/65221c1da6e384e67ae9cb3739ae5f90 to your computer and use it in GitHub Desktop.

Select an option

Save rishubil/65221c1da6e384e67ae9cb3739ae5f90 to your computer and use it in GitHub Desktop.
// ==UserScript==
// @name Custom Fallback Font
// @version 0.14
// @description Replace system fallback fonts with custom fonts
// @match *://*/*
// @grant GM_registerMenuCommand
// @grant GM_getValue
// @grant GM_setValue
// @run-at document-start
// @updateURL https://gist.github.com/rishubil/65221c1da6e384e67ae9cb3739ae5f90/raw/custom-fallback-font.user.js
// @downloadURL https://gist.github.com/rishubil/65221c1da6e384e67ae9cb3739ae5f90/raw/custom-fallback-font.user.js
// ==/UserScript==
(function () {
"use strict";
// ===== 설정 영역 =====
const SHOW_TOAST = false; // 토스트 알림 표시 여부
const DEBUG_MODE = false; // 디버그 모드
const WEB_FONTS = `
@import url("https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/variable/pretendardvariable-gov-dynamic-subset.min.css");
@font-face {
font-family: 'D2Coding';
src: url('https://cdn.jsdelivr.net/gh/projectnoonnu/noonfonts_three@1.0/D2Coding.woff') format('woff');
font-weight: normal;
font-display: swap;
}
/* noto-serif-kr-korean-200-normal */
@font-face {
font-family: 'Noto Serif KR';
font-style: normal;
font-display: swap;
font-weight: 200;
src: url(https://cdn.jsdelivr.net/fontsource/fonts/noto-serif-kr@5.2.9/korean-200-normal.woff2) format('woff2'), url(https://cdn.jsdelivr.net/fontsource/fonts/noto-serif-kr@5.2.9/korean-200-normal.woff) format('woff');
}
/* noto-serif-kr-korean-300-normal */
@font-face {
font-family: 'Noto Serif KR';
font-style: normal;
font-display: swap;
font-weight: 300;
src: url(https://cdn.jsdelivr.net/fontsource/fonts/noto-serif-kr@5.2.9/korean-300-normal.woff2) format('woff2'), url(https://cdn.jsdelivr.net/fontsource/fonts/noto-serif-kr@5.2.9/korean-300-normal.woff) format('woff');
}
/* noto-serif-kr-korean-400-normal */
@font-face {
font-family: 'Noto Serif KR';
font-style: normal;
font-display: swap;
font-weight: 400;
src: url(https://cdn.jsdelivr.net/fontsource/fonts/noto-serif-kr@5.2.9/korean-400-normal.woff2) format('woff2'), url(https://cdn.jsdelivr.net/fontsource/fonts/noto-serif-kr@5.2.9/korean-400-normal.woff) format('woff');
}
/* noto-serif-kr-korean-500-normal */
@font-face {
font-family: 'Noto Serif KR';
font-style: normal;
font-display: swap;
font-weight: 500;
src: url(https://cdn.jsdelivr.net/fontsource/fonts/noto-serif-kr@5.2.9/korean-500-normal.woff2) format('woff2'), url(https://cdn.jsdelivr.net/fontsource/fonts/noto-serif-kr@5.2.9/korean-500-normal.woff) format('woff');
}
/* noto-serif-kr-korean-600-normal */
@font-face {
font-family: 'Noto Serif KR';
font-style: normal;
font-display: swap;
font-weight: 600;
src: url(https://cdn.jsdelivr.net/fontsource/fonts/noto-serif-kr@5.2.9/korean-600-normal.woff2) format('woff2'), url(https://cdn.jsdelivr.net/fontsource/fonts/noto-serif-kr@5.2.9/korean-600-normal.woff) format('woff');
}
/* noto-serif-kr-korean-700-normal */
@font-face {
font-family: 'Noto Serif KR';
font-style: normal;
font-display: swap;
font-weight: 700;
src: url(https://cdn.jsdelivr.net/fontsource/fonts/noto-serif-kr@5.2.9/korean-700-normal.woff2) format('woff2'), url(https://cdn.jsdelivr.net/fontsource/fonts/noto-serif-kr@5.2.9/korean-700-normal.woff) format('woff');
}
/* noto-serif-kr-korean-800-normal */
@font-face {
font-family: 'Noto Serif KR';
font-style: normal;
font-display: swap;
font-weight: 800;
src: url(https://cdn.jsdelivr.net/fontsource/fonts/noto-serif-kr@5.2.9/korean-800-normal.woff2) format('woff2'), url(https://cdn.jsdelivr.net/fontsource/fonts/noto-serif-kr@5.2.9/korean-800-normal.woff) format('woff');
}
/* noto-serif-kr-korean-900-normal */
@font-face {
font-family: 'Noto Serif KR';
font-style: normal;
font-display: swap;
font-weight: 900;
src: url(https://cdn.jsdelivr.net/fontsource/fonts/noto-serif-kr@5.2.9/korean-900-normal.woff2) format('woff2'), url(https://cdn.jsdelivr.net/fontsource/fonts/noto-serif-kr@5.2.9/korean-900-normal.woff) format('woff');
}
/* noto-serif-kr-latin-200-normal */
@font-face {
font-family: 'Noto Serif KR';
font-style: normal;
font-display: swap;
font-weight: 200;
src: url(https://cdn.jsdelivr.net/fontsource/fonts/noto-serif-kr@5.2.9/latin-200-normal.woff2) format('woff2'), url(https://cdn.jsdelivr.net/fontsource/fonts/noto-serif-kr@5.2.9/latin-200-normal.woff) format('woff');
unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD;
}
/* noto-serif-kr-latin-300-normal */
@font-face {
font-family: 'Noto Serif KR';
font-style: normal;
font-display: swap;
font-weight: 300;
src: url(https://cdn.jsdelivr.net/fontsource/fonts/noto-serif-kr@5.2.9/latin-300-normal.woff2) format('woff2'), url(https://cdn.jsdelivr.net/fontsource/fonts/noto-serif-kr@5.2.9/latin-300-normal.woff) format('woff');
unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD;
}
/* noto-serif-kr-latin-400-normal */
@font-face {
font-family: 'Noto Serif KR';
font-style: normal;
font-display: swap;
font-weight: 400;
src: url(https://cdn.jsdelivr.net/fontsource/fonts/noto-serif-kr@5.2.9/latin-400-normal.woff2) format('woff2'), url(https://cdn.jsdelivr.net/fontsource/fonts/noto-serif-kr@5.2.9/latin-400-normal.woff) format('woff');
unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD;
}
/* noto-serif-kr-latin-500-normal */
@font-face {
font-family: 'Noto Serif KR';
font-style: normal;
font-display: swap;
font-weight: 500;
src: url(https://cdn.jsdelivr.net/fontsource/fonts/noto-serif-kr@5.2.9/latin-500-normal.woff2) format('woff2'), url(https://cdn.jsdelivr.net/fontsource/fonts/noto-serif-kr@5.2.9/latin-500-normal.woff) format('woff');
unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD;
}
/* noto-serif-kr-latin-600-normal */
@font-face {
font-family: 'Noto Serif KR';
font-style: normal;
font-display: swap;
font-weight: 600;
src: url(https://cdn.jsdelivr.net/fontsource/fonts/noto-serif-kr@5.2.9/latin-600-normal.woff2) format('woff2'), url(https://cdn.jsdelivr.net/fontsource/fonts/noto-serif-kr@5.2.9/latin-600-normal.woff) format('woff');
unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD;
}
/* noto-serif-kr-latin-700-normal */
@font-face {
font-family: 'Noto Serif KR';
font-style: normal;
font-display: swap;
font-weight: 700;
src: url(https://cdn.jsdelivr.net/fontsource/fonts/noto-serif-kr@5.2.9/latin-700-normal.woff2) format('woff2'), url(https://cdn.jsdelivr.net/fontsource/fonts/noto-serif-kr@5.2.9/latin-700-normal.woff) format('woff');
unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD;
}
/* noto-serif-kr-latin-800-normal */
@font-face {
font-family: 'Noto Serif KR';
font-style: normal;
font-display: swap;
font-weight: 800;
src: url(https://cdn.jsdelivr.net/fontsource/fonts/noto-serif-kr@5.2.9/latin-800-normal.woff2) format('woff2'), url(https://cdn.jsdelivr.net/fontsource/fonts/noto-serif-kr@5.2.9/latin-800-normal.woff) format('woff');
unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD;
}
/* noto-serif-kr-latin-900-normal */
@font-face {
font-family: 'Noto Serif KR';
font-style: normal;
font-display: swap;
font-weight: 900;
src: url(https://cdn.jsdelivr.net/fontsource/fonts/noto-serif-kr@5.2.9/latin-900-normal.woff2) format('woff2'), url(https://cdn.jsdelivr.net/fontsource/fonts/noto-serif-kr@5.2.9/latin-900-normal.woff) format('woff');
unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD;
}
/* noto-serif-kr-latin-ext-200-normal */
@font-face {
font-family: 'Noto Serif KR';
font-style: normal;
font-display: swap;
font-weight: 200;
src: url(https://cdn.jsdelivr.net/fontsource/fonts/noto-serif-kr@5.2.9/latin-ext-200-normal.woff2) format('woff2'), url(https://cdn.jsdelivr.net/fontsource/fonts/noto-serif-kr@5.2.9/latin-ext-200-normal.woff) format('woff');
unicode-range: U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF;
}
/* noto-serif-kr-latin-ext-300-normal */
@font-face {
font-family: 'Noto Serif KR';
font-style: normal;
font-display: swap;
font-weight: 300;
src: url(https://cdn.jsdelivr.net/fontsource/fonts/noto-serif-kr@5.2.9/latin-ext-300-normal.woff2) format('woff2'), url(https://cdn.jsdelivr.net/fontsource/fonts/noto-serif-kr@5.2.9/latin-ext-300-normal.woff) format('woff');
unicode-range: U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF;
}
/* noto-serif-kr-latin-ext-400-normal */
@font-face {
font-family: 'Noto Serif KR';
font-style: normal;
font-display: swap;
font-weight: 400;
src: url(https://cdn.jsdelivr.net/fontsource/fonts/noto-serif-kr@5.2.9/latin-ext-400-normal.woff2) format('woff2'), url(https://cdn.jsdelivr.net/fontsource/fonts/noto-serif-kr@5.2.9/latin-ext-400-normal.woff) format('woff');
unicode-range: U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF;
}
/* noto-serif-kr-latin-ext-500-normal */
@font-face {
font-family: 'Noto Serif KR';
font-style: normal;
font-display: swap;
font-weight: 500;
src: url(https://cdn.jsdelivr.net/fontsource/fonts/noto-serif-kr@5.2.9/latin-ext-500-normal.woff2) format('woff2'), url(https://cdn.jsdelivr.net/fontsource/fonts/noto-serif-kr@5.2.9/latin-ext-500-normal.woff) format('woff');
unicode-range: U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF;
}
/* noto-serif-kr-latin-ext-600-normal */
@font-face {
font-family: 'Noto Serif KR';
font-style: normal;
font-display: swap;
font-weight: 600;
src: url(https://cdn.jsdelivr.net/fontsource/fonts/noto-serif-kr@5.2.9/latin-ext-600-normal.woff2) format('woff2'), url(https://cdn.jsdelivr.net/fontsource/fonts/noto-serif-kr@5.2.9/latin-ext-600-normal.woff) format('woff');
unicode-range: U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF;
}
/* noto-serif-kr-latin-ext-700-normal */
@font-face {
font-family: 'Noto Serif KR';
font-style: normal;
font-display: swap;
font-weight: 700;
src: url(https://cdn.jsdelivr.net/fontsource/fonts/noto-serif-kr@5.2.9/latin-ext-700-normal.woff2) format('woff2'), url(https://cdn.jsdelivr.net/fontsource/fonts/noto-serif-kr@5.2.9/latin-ext-700-normal.woff) format('woff');
unicode-range: U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF;
}
/* noto-serif-kr-latin-ext-800-normal */
@font-face {
font-family: 'Noto Serif KR';
font-style: normal;
font-display: swap;
font-weight: 800;
src: url(https://cdn.jsdelivr.net/fontsource/fonts/noto-serif-kr@5.2.9/latin-ext-800-normal.woff2) format('woff2'), url(https://cdn.jsdelivr.net/fontsource/fonts/noto-serif-kr@5.2.9/latin-ext-800-normal.woff) format('woff');
unicode-range: U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF;
}
/* noto-serif-kr-latin-ext-900-normal */
@font-face {
font-family: 'Noto Serif KR';
font-style: normal;
font-display: swap;
font-weight: 900;
src: url(https://cdn.jsdelivr.net/fontsource/fonts/noto-serif-kr@5.2.9/latin-ext-900-normal.woff2) format('woff2'), url(https://cdn.jsdelivr.net/fontsource/fonts/noto-serif-kr@5.2.9/latin-ext-900-normal.woff) format('woff');
unicode-range: U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF;
}
`;
// Each entry is a fallback chain. The first name is the web font from
// WEB_FONTS; the rest are locally installed fallbacks for hosts whose CSP
// blocks the CDN (Discord, Ferdium services), where the web font never
// loads and a single unresolvable name would leave the page unchanged.
const CUSTOM_FONTS = {
sansSerif: ["Pretendard GOV Variable", "Pretendard GOV"],
serif: ["Noto Serif KR"],
monospace: ["D2Coding"],
};
const EXCLUDED_SITES_KEY = "customFallbackFont_excludedSites";
const DEFAULT_EXCLUDED_SITES = ["*.github.com", "*.stackblitz.com"];
const STYLE_ID = "custom-fallback-font-web-fonts";
const UI_ATTRIBUTE = "data-custom-fallback-font-ui";
const MAX_FONT_FAMILY_LENGTH = 8192;
const MAX_FONT_TOKENS = 32;
const MAX_STORED_SETTINGS_LENGTH = 100000;
const MAX_SITE_PATTERNS = 500;
const MAX_INSPECTED_SITE_VALUES = 2000;
const MAX_SETTINGS_TEXT_LENGTH = 50000;
// The time budget is the effective per-flush bound. The element and
// operation counts are only a safety cap for the case where
// performance.now() is coarse or frozen, and they are kept large so that a
// flush is normally ended by the time budget rather than by a count. Flushes
// yield through scheduler.postTask at background priority, which runs
// between the page's own tasks, so the 6 ms budget is honoured with a high
// duty cycle. (Earlier versions yielded through requestIdleCallback, whose
// ~50 ms cadence on a static page gave a duty cycle of roughly 12%.)
const SCHEDULER_ELEMENT_BUDGET = 5000;
const SCHEDULER_OPERATION_BUDGET = 20000;
const SCHEDULER_TIME_BUDGET_MS = 6;
const GENERIC_FAMILIES = new Set([
"serif", "sans-serif", "monospace", "cursive", "fantasy", "system-ui",
"ui-serif", "ui-sans-serif", "ui-monospace", "ui-rounded", "math", "fangsong",
]);
const SYSTEM_FONT_NAMES = new Set([
"-apple-system", "blinkmacsystemfont", "segoe ui", "malgun gothic", "dotum", "arial",
]);
const CUSTOM_FONT_NAMES = new Set(
Object.values(CUSTOM_FONTS).flat().map((font) => font.toLowerCase())
);
const fontSupportCache = new Map();
const replacedFonts = new Set();
const inlineFontStates = new WeakMap();
const settingsElements = new WeakSet();
function parseFontFamily(value) {
if (typeof value !== "string" || value.length === 0 || value.length > MAX_FONT_FAMILY_LENGTH) {
return [];
}
const tokens = [];
let start = 0;
let quote = "";
let escaped = false;
for (let index = 0; index <= value.length; index += 1) {
const character = value[index];
if (index < value.length && escaped) {
escaped = false;
continue;
}
if (index < value.length && character === "\\") {
escaped = true;
continue;
}
if (index < value.length && quote) {
if (character === quote) quote = "";
continue;
}
if (index < value.length && (character === "\"" || character === "'")) {
quote = character;
continue;
}
if (index === value.length || character === ",") {
const raw = value.slice(start, index).trim();
if (raw) {
const first = raw[0];
const quoted = (first === "\"" || first === "'") && raw.at(-1) === first;
const body = quoted ? raw.slice(1, -1) : raw;
let unescaped = "";
let bodyEscaped = false;
for (const bodyCharacter of body) {
if (bodyEscaped) {
unescaped += bodyCharacter;
bodyEscaped = false;
} else if (bodyCharacter === "\\") {
bodyEscaped = true;
} else {
unescaped += bodyCharacter;
}
}
if (bodyEscaped) unescaped += "\\";
tokens.push({ raw, value: unescaped, quoted });
if (tokens.length >= MAX_FONT_TOKENS && index < value.length) return [];
}
start = index + 1;
}
}
return quote ? [] : tokens;
}
function serializeFontFamily(tokens) {
return tokens.map((token) => token.raw).join(", ");
}
function quoteFontFamily(value) {
const escaped = String(value).replace(/\\/g, "\\\\").replace(/\"/g, '\\"');
return { raw: `"${escaped}"`, value: String(value), quoted: true };
}
function normalizedFontName(token) {
return token.value.trim().toLowerCase();
}
function isSystemFont(token) {
const name = normalizedFontName(token);
return (!token.quoted && GENERIC_FAMILIES.has(name)) || SYSTEM_FONT_NAMES.has(name);
}
function planFontFamily(fontFamily, supportsFont) {
const fonts = parseFontFamily(fontFamily);
if (fonts.length === 0) return null;
if (fonts.some((font) => CUSTOM_FONT_NAMES.has(normalizedFontName(font)))) return null;
const finalFont = fonts.at(-1);
const finalName = normalizedFontName(finalFont);
const generic = !finalFont.quoted && GENERIC_FAMILIES.has(finalName)
? finalName
: "sans-serif";
let customFont;
if (generic === "monospace" || generic === "ui-monospace") {
customFont = CUSTOM_FONTS.monospace;
} else if (generic === "serif" || generic === "ui-serif") {
customFont = CUSTOM_FONTS.serif;
} else if (["sans-serif", "ui-sans-serif", "system-ui"].includes(generic)) {
customFont = CUSTOM_FONTS.sansSerif;
} else if (!finalFont.quoted && GENERIC_FAMILIES.has(finalName)) {
return null;
} else {
customFont = CUSTOM_FONTS.sansSerif;
}
let firstSystemFontIndex = -1;
let lastUnsupportedFontIndex = -1;
for (let index = 0; index < fonts.length; index += 1) {
const font = fonts[index];
if (isSystemFont(font)) {
if (firstSystemFontIndex === -1) firstSystemFontIndex = index;
} else if (supportsFont(font.value)) {
return null;
} else {
lastUnsupportedFontIndex = index;
}
}
let insertIndex = -1;
let triggerFont = "";
if (lastUnsupportedFontIndex !== -1 && firstSystemFontIndex !== -1) {
insertIndex = lastUnsupportedFontIndex < firstSystemFontIndex
? lastUnsupportedFontIndex + 1
: firstSystemFontIndex;
triggerFont = fonts[Math.min(lastUnsupportedFontIndex, firstSystemFontIndex)].value;
} else if (lastUnsupportedFontIndex !== -1) {
insertIndex = lastUnsupportedFontIndex + 1;
triggerFont = fonts[lastUnsupportedFontIndex].value;
} else if (firstSystemFontIndex !== -1) {
insertIndex = firstSystemFontIndex;
triggerFont = fonts[firstSystemFontIndex].value;
}
if (insertIndex === -1) return null;
const replacement = fonts.slice();
replacement.splice(insertIndex, 0, ...customFont.map(quoteFontFamily));
return { value: serializeFontFamily(replacement), customFont: customFont.join(", "), triggerFont };
}
function isValidSitePattern(value) {
if (typeof value !== "string") return false;
const pattern = value.trim().toLowerCase();
if (!pattern || pattern.length > 253 || pattern.startsWith(".") || pattern.endsWith(".")) return false;
if (!/^[a-z0-9.*-]+$/.test(pattern) || pattern.includes("..")) return false;
return pattern.split(".").every((label) =>
label.length > 0 && label.length <= 63 && !label.startsWith("-") && !label.endsWith("-")
);
}
function normalizeExcludedSites(value) {
let source = value;
if (typeof source === "string") {
if (source.length > MAX_STORED_SETTINGS_LENGTH) return [];
try {
source = JSON.parse(source);
} catch {
return [];
}
}
if (!Array.isArray(source)) return [];
const result = [];
const seen = new Set();
try {
const inspectedCount = Math.min(source.length, MAX_INSPECTED_SITE_VALUES);
for (let index = 0; index < inspectedCount; index += 1) {
const item = source[index];
if (typeof item !== "string") continue;
const pattern = item.trim().toLowerCase();
if (!isValidSitePattern(pattern) || seen.has(pattern)) continue;
seen.add(pattern);
result.push(pattern);
if (result.length >= MAX_SITE_PATTERNS) break;
}
} catch (error) {
if (DEBUG_MODE) console.warn("[Custom Fallback Font] Invalid settings were ignored", error);
}
return result;
}
function escapeRegularExpression(value) {
return value.replace(/[\\^$.*+?()[\]{}|]/g, "\\$&");
}
function sitePatternMatches(patternValue, hostnameValue) {
const pattern = typeof patternValue === "string" ? patternValue.trim().toLowerCase() : "";
const hostname = typeof hostnameValue === "string" ? hostnameValue.trim().toLowerCase() : "";
if (!hostname || !isValidSitePattern(pattern)) return false;
if (pattern.startsWith("*.") && pattern.indexOf("*", 1) === -1) {
const suffix = pattern.slice(2);
return hostname === suffix || hostname.endsWith(`.${suffix}`);
}
if (!pattern.includes("*")) return hostname === pattern;
const expression = pattern.split("*").map(escapeRegularExpression).join(".*");
return new RegExp(`^${expression}$`, "u").test(hostname);
}
function readExcludedSites() {
let stored = null;
try {
stored = GM_getValue(EXCLUDED_SITES_KEY, null);
} catch (error) {
if (DEBUG_MODE) console.warn("[Custom Fallback Font] Could not read settings", error);
}
const sites = stored === null
? DEFAULT_EXCLUDED_SITES.slice()
: normalizeExcludedSites(stored);
let alreadyNormalized = false;
try {
alreadyNormalized = Array.isArray(stored)
&& stored.length === sites.length
&& stored.every((item, index) => item === sites[index]);
} catch (error) {
if (DEBUG_MODE) console.warn("[Custom Fallback Font] Invalid settings were ignored", error);
}
if (stored === null || !alreadyNormalized) saveExcludedSites(sites);
return sites;
}
function saveExcludedSites(sites) {
const normalized = normalizeExcludedSites(sites);
try {
GM_setValue(EXCLUDED_SITES_KEY, normalized);
return { ok: true, sites: normalized };
} catch (error) {
if (DEBUG_MODE) console.warn("[Custom Fallback Font] Could not save settings", error);
return { ok: false, sites: normalized };
}
}
function createWorkScheduler({
processElement,
getChildren = (element) => element.children,
shouldSkip = () => false,
// `scheduler` is shadowed inside this IIFE by the work scheduler instance,
// so the platform Prioritized Task Scheduling API must be reached through
// globalThis. Background priority runs between the page's own tasks
// instead of waiting for an idle period, so flushes keep a high duty
// cycle; requestIdleCallback is deliberately not used.
schedule = (callback) => {
const postTask = globalThis.scheduler?.postTask;
if (typeof postTask === "function") {
const result = globalThis.scheduler.postTask(callback, { priority: "background" });
if (result && typeof result.catch === "function") result.catch(() => {});
} else {
setTimeout(callback, 0);
}
},
now = () => performance.now(),
maxElements = SCHEDULER_ELEMENT_BUDGET,
maxOperations = SCHEDULER_OPERATION_BUDGET,
maxMilliseconds = SCHEDULER_TIME_BUDGET_MS,
}) {
const queue = [];
let head = 0;
let scheduled = false;
let flushing = false;
let processedCount = 0;
function createBatch() {
return { seen: new WeakSet() };
}
function compactQueue() {
if (head > 1024 && head * 2 > queue.length) {
queue.splice(0, head);
head = 0;
}
}
function requestFlush() {
if (scheduled || flushing) return;
scheduled = true;
schedule(flush);
}
// Visit modes: `mode` is how this element is handed to processElement and
// `childMode` is inherited by every descendant the scheduler reaches from
// it (as both their mode and their own childMode). A direct enqueue is a
// full visit whose descendants are light by default; callers that need a
// complete rescan pass childMode: "full". The scheduler attaches no
// meaning to the strings; processElement decides what a light visit skips.
function enqueue(element, batch = createBatch(), { mode = "full", childMode = "light" } = {}) {
if (!element || shouldSkip(element)) return false;
const activeBatch = batch?.seen instanceof WeakSet ? batch : createBatch();
if (activeBatch.seen.has(element)) return false;
activeBatch.seen.add(element);
queue.push({ type: "element", value: element, batch: activeBatch, mode, childMode });
requestFlush();
return true;
}
function enqueueIterable(iterable, visit) {
if (!iterable || typeof iterable[Symbol.iterator] !== "function") return false;
try {
queue.push({ type: "iterator", value: iterable[Symbol.iterator](), visit });
} catch (error) {
if (DEBUG_MODE) console.warn("[Custom Fallback Font] Could not queue iterable work", error);
return false;
}
requestFlush();
return true;
}
function flush() {
scheduled = false;
flushing = true;
const startedAt = now();
let processedElements = 0;
let operations = 0;
while (
head < queue.length
&& processedElements < maxElements
&& operations < maxOperations
&& now() - startedAt < maxMilliseconds
) {
const task = queue[head++];
operations += 1;
if (task.type === "iterator") {
try {
const next = task.value.next();
if (!next.done) {
queue.push(task);
task.visit(next.value);
}
} catch (error) {
if (DEBUG_MODE) console.warn("[Custom Fallback Font] Iterable work failed", error);
}
continue;
}
if (shouldSkip(task.value)) continue;
let inspectChildren = true;
try {
inspectChildren = processElement(task.value, { mode: task.mode }) !== false;
} catch (error) {
if (DEBUG_MODE) console.warn("[Custom Fallback Font] Element processing failed", error);
}
processedElements += 1;
processedCount += 1;
if (inspectChildren) {
const childOptions = { mode: task.childMode, childMode: task.childMode };
try {
enqueueIterable(getChildren(task.value), (child) => enqueue(child, task.batch, childOptions));
} catch (error) {
if (DEBUG_MODE) console.warn("[Custom Fallback Font] Could not inspect child elements", error);
}
}
}
compactQueue();
flushing = false;
if (head < queue.length) requestFlush();
else {
queue.length = 0;
head = 0;
}
return { processedElements, operations, pending: queue.length - head };
}
return {
createBatch,
enqueue,
enqueueIterable,
flush,
get pending() { return queue.length - head; },
get processedCount() { return processedCount; },
};
}
if (globalThis.__CUSTOM_FALLBACK_FONT_TEST__ === true) {
globalThis.__customFallbackFontTestHooks = {
createWorkScheduler,
isSettingsElement,
isValidSitePattern,
markSettingsElement,
normalizeExcludedSites,
parseFontFamily,
planFontFamily,
readExcludedSites,
saveExcludedSites,
serializeFontFamily,
sitePatternMatches,
supportsKorean,
};
return;
}
function isSettingsElement(element) {
return Boolean(element && (
settingsElements.has(element) || element.hasAttribute?.(UI_ATTRIBUTE)
));
}
function markSettingsElement(element, value = "settings") {
settingsElements.add(element);
element.setAttribute?.(UI_ATTRIBUTE, value);
return element;
}
function getCurrentDomain() {
return window.location.hostname.toLowerCase();
}
function isCurrentSiteExcluded() {
const hostname = getCurrentDomain();
return readExcludedSites().some((pattern) => sitePatternMatches(pattern, hostname));
}
function appendTextElement(parent, tagName, text) {
const element = markSettingsElement(document.createElement(tagName));
element.textContent = text;
parent.appendChild(element);
return element;
}
function openSettingsDialog() {
const existing = document.querySelector(`dialog[${UI_ATTRIBUTE}]`);
if (existing) {
if (!existing.open) existing.showModal();
existing.querySelector("textarea")?.focus();
return;
}
const mount = document.body || document.documentElement;
if (!mount) {
document.addEventListener("DOMContentLoaded", openSettingsDialog, { once: true });
return;
}
const hostname = getCurrentDomain();
const dialog = markSettingsElement(document.createElement("dialog"));
dialog.setAttribute("aria-labelledby", "custom-fallback-font-settings-title");
dialog.style.cssText = "box-sizing:border-box;width:min(36rem,calc(100vw - 2rem));max-height:calc(100vh - 2rem);padding:1.25rem;border:1px solid #777;border-radius:.75rem;color:CanvasText;background:Canvas;font:14px/1.5 system-ui,sans-serif;";
const title = appendTextElement(dialog, "h2", "Custom Fallback Font settings");
title.id = "custom-fallback-font-settings-title";
title.style.cssText = "margin:0 0 .5rem;font:600 1.25rem/1.4 system-ui,sans-serif;";
const current = appendTextElement(dialog, "p", `Current site: ${hostname}`);
current.style.margin = "0 0 1rem";
const label = appendTextElement(dialog, "label", "Excluded site patterns (one per line)");
label.htmlFor = "custom-fallback-font-excluded-sites";
label.style.display = "block";
const textarea = markSettingsElement(document.createElement("textarea"));
textarea.id = "custom-fallback-font-excluded-sites";
textarea.rows = 9;
textarea.maxLength = MAX_SETTINGS_TEXT_LENGTH;
textarea.value = readExcludedSites().join("\n");
textarea.setAttribute("aria-describedby", "custom-fallback-font-settings-help custom-fallback-font-settings-status");
textarea.style.cssText = "box-sizing:border-box;width:100%;margin:.4rem 0;padding:.6rem;resize:vertical;color:CanvasText;background:Canvas;border:1px solid #777;border-radius:.35rem;font:13px/1.5 ui-monospace,monospace;";
dialog.appendChild(textarea);
const help = appendTextElement(dialog, "p", "Use hostnames such as example.com or *.example.com. A leading *. also covers the apex domain.");
help.id = "custom-fallback-font-settings-help";
help.style.margin = "0 0 .5rem";
const status = appendTextElement(dialog, "p", "Changes take effect after reloading the page.");
status.id = "custom-fallback-font-settings-status";
status.setAttribute("role", "status");
status.style.cssText = "min-height:1.5em;margin:0 0 .75rem;color:CanvasText;";
const siteActions = markSettingsElement(document.createElement("div"));
siteActions.style.cssText = "display:flex;flex-wrap:wrap;gap:.5rem;margin-bottom:1rem;";
const addCurrent = appendTextElement(siteActions, "button", "Add current site");
addCurrent.type = "button";
const removeCurrent = appendTextElement(siteActions, "button", "Remove matching patterns");
removeCurrent.type = "button";
dialog.appendChild(siteActions);
const formActions = markSettingsElement(document.createElement("div"));
formActions.style.cssText = "display:flex;justify-content:flex-end;gap:.5rem;";
const cancel = appendTextElement(formActions, "button", "Cancel");
cancel.type = "button";
const save = appendTextElement(formActions, "button", "Save");
save.type = "button";
dialog.appendChild(formActions);
for (const button of [addCurrent, removeCurrent, cancel, save]) {
button.style.cssText = "padding:.45rem .75rem;border:1px solid #777;border-radius:.35rem;color:CanvasText;background:ButtonFace;font:inherit;cursor:pointer;";
}
function lines() {
if (textarea.value.length > MAX_SETTINGS_TEXT_LENGTH) return [];
return textarea.value.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
}
function validate() {
if (textarea.value.length > MAX_SETTINGS_TEXT_LENGTH) {
save.disabled = true;
addCurrent.disabled = true;
removeCurrent.disabled = true;
textarea.setAttribute("aria-invalid", "true");
status.textContent = `The list must be ${MAX_SETTINGS_TEXT_LENGTH.toLocaleString()} characters or fewer.`;
status.style.color = "#b00020";
return false;
}
const entries = lines();
const invalid = entries.filter((pattern) => !isValidSitePattern(pattern));
const tooMany = entries.length > MAX_SITE_PATTERNS;
save.disabled = invalid.length > 0 || tooMany;
addCurrent.disabled = tooMany;
removeCurrent.disabled = false;
textarea.setAttribute("aria-invalid", String(invalid.length > 0 || tooMany));
status.textContent = tooMany
? `Use no more than ${MAX_SITE_PATTERNS} patterns.`
: invalid.length > 0
? `Invalid pattern${invalid.length === 1 ? "" : "s"}: ${invalid.join(", ")}`
: "Changes take effect after reloading the page.";
status.style.color = invalid.length > 0 || tooMany ? "#b00020" : "CanvasText";
return invalid.length === 0 && !tooMany;
}
textarea.addEventListener("input", validate);
addCurrent.addEventListener("click", () => {
const entries = normalizeExcludedSites(lines());
if (entries.some((pattern) => sitePatternMatches(pattern, hostname))) {
status.textContent = "The current site is already covered by the list.";
return;
}
textarea.value = [...entries, hostname].join("\n");
validate();
textarea.focus();
});
removeCurrent.addEventListener("click", () => {
const entries = normalizeExcludedSites(lines());
const remaining = entries.filter((pattern) => !sitePatternMatches(pattern, hostname));
textarea.value = remaining.join("\n");
validate();
status.textContent = entries.length === remaining.length
? "The current site was not covered by the list."
: "Matching patterns were removed. Save and reload to apply.";
textarea.focus();
});
cancel.addEventListener("click", () => dialog.close());
save.addEventListener("click", () => {
if (!validate()) return;
const result = saveExcludedSites(lines());
textarea.value = result.sites.join("\n");
status.textContent = result.ok
? "Saved. Reload the page to apply these changes."
: "Could not save settings. Check the userscript manager and try again.";
status.style.color = result.ok ? "CanvasText" : "#b00020";
textarea.focus();
});
dialog.addEventListener("click", (event) => {
if (event.target === dialog) dialog.close();
});
dialog.addEventListener("close", () => dialog.remove());
mount.appendChild(dialog);
validate();
dialog.showModal();
textarea.focus();
}
// Not every host provides the GM_* API (Ferdium user.js, userscript-proxy):
// a bare call would throw a ReferenceError and abort the whole script.
if (typeof GM_registerMenuCommand === "function") {
GM_registerMenuCommand("예외 사이트 설정", openSettingsDialog);
}
function showToast(message) {
if (!document.body) return;
let container = document.getElementById("custom-font-toast-container");
if (!container) {
container = markSettingsElement(document.createElement("div"), "toast");
container.id = "custom-font-toast-container";
container.setAttribute(UI_ATTRIBUTE, "toast");
container.style.cssText = "position:fixed;bottom:20px;right:20px;z-index:999999;display:flex;flex-direction:column;gap:8px;pointer-events:none;";
document.body.appendChild(container);
}
const toast = markSettingsElement(document.createElement("div"), "toast");
toast.textContent = message;
toast.style.cssText = "background:rgba(0,0,0,.8);color:white;padding:12px 20px;border-radius:8px;font:13px sans-serif;opacity:0;transition:opacity .3s;pointer-events:auto;";
container.appendChild(toast);
setTimeout(() => { toast.style.opacity = "1"; }, 10);
setTimeout(() => {
toast.style.opacity = "0";
setTimeout(() => {
toast.remove();
if (container.children.length === 0) container.remove();
}, 300);
}, 3000);
}
function supportsKorean(fontName) {
const key = String(fontName).trim().toLowerCase();
if (fontSupportCache.has(key)) return fontSupportCache.get(key);
let supports = true;
try {
const context = document.createElement("canvas").getContext("2d");
if (context) {
// Hangul only: a space or Latin letter is drawn by the tested font even
// when it has no Hangul, which changes the width and reads as support.
const samples = ["한글테스트", "가나다라마바사", "힣값꽃"];
const fallbacks = ["monospace", "serif", "sans-serif"];
const quotedFont = quoteFontFamily(fontName).raw;
supports = fallbacks.every((fallback) => samples.some((sample) => {
context.font = `72px ${fallback}`;
const fallbackWidth = context.measureText(sample).width;
context.font = `72px ${quotedFont}, ${fallback}`;
return context.measureText(sample).width !== fallbackWidth;
}));
}
} catch (error) {
supports = true;
if (DEBUG_MODE) console.warn("[Custom Fallback Font] Font detection failed", error);
}
fontSupportCache.set(key, supports);
return supports;
}
function inlineFont(element) {
return {
value: element.style.getPropertyValue("font-family"),
priority: element.style.getPropertyPriority("font-family"),
};
}
function sameInlineFont(left, right) {
return left.value === right.value && left.priority === right.priority;
}
function writeInlineFont(element, font) {
if (font.value) element.style.setProperty("font-family", font.value, font.priority);
else element.style.removeProperty("font-family");
}
function processElement(element, { mode = "full" } = {}) {
if (!element || element.nodeType !== Node.ELEMENT_NODE || isSettingsElement(element)) return;
const current = inlineFont(element);
let state = inlineFontStates.get(element);
// Light visits are descendants reached from a mutated ancestor. When the
// replacement this script wrote is still in place, the element is left
// alone: restoring the author value, reading computed style (a forced
// recalc on a freshly dirtied element) and writing the same replacement
// back is what made draining a class toggle on a large container slow.
// The scheduler still descends into the children (no `false` returned).
// Accepted trade-off: a descendant selector keyed on the ancestor's
// toggled class that changes this element's own font-family is not
// picked up until the element itself mutates or a full rescan runs.
if (mode === "light" && state?.applied === true && sameInlineFont(current, state.expected)) return;
if (!state) {
state = { author: current, expected: current, applied: false };
} else if (!sameInlineFont(current, state.expected)) {
state.author = current;
state.expected = current;
state.applied = false;
}
if (state.applied) writeInlineFont(element, state.author);
const computedStyle = window.getComputedStyle(element);
const computedFont = computedStyle.getPropertyValue("font-family");
const parent = element.parentElement;
// Compare with the parent's *current* font, not its author font: parents
// are processed first, so a child that inherits already shows the
// parent's replacement, while a child that re-declares the same stack
// (Discord's `font-family: var(--font-primary)` on nearly everything)
// still shows the original and must be processed on its own.
const parentFont = parent && !isSettingsElement(parent)
? window.getComputedStyle(parent).getPropertyValue("font-family")
: null;
const hasExplicitFontFamily = state.author.value !== "" || !parent || computedFont !== parentFont;
const replacement = hasExplicitFontFamily
? planFontFamily(computedFont, supportsKorean)
: null;
if (replacement) {
const applied = { value: replacement.value, priority: state.author.priority };
writeInlineFont(element, applied);
state.expected = applied;
state.applied = true;
if (!replacedFonts.has(replacement.triggerFont)) {
replacedFonts.add(replacement.triggerFont);
if (SHOW_TOAST) showToast(`Font fallback added: ${replacement.triggerFont} → ${replacement.customFont}`);
}
} else {
// No write is needed: the inline value already equals state.author on
// every path that reaches here (restored above when a replacement had
// been applied, otherwise never changed by this script).
state.expected = state.author;
state.applied = false;
}
inlineFontStates.set(element, state);
}
const scheduler = createWorkScheduler({
processElement,
shouldSkip: isSettingsElement,
});
if (globalThis.__CUSTOM_FALLBACK_FONT_RUNTIME_TEST__ === true) {
globalThis.__customFallbackFontRuntimeTest = { scheduler };
}
function ensureFontStyle() {
if (!WEB_FONTS.trim() || document.getElementById(STYLE_ID)) return;
const parent = document.head;
if (!parent) return;
const style = document.createElement("style");
style.id = STYLE_ID;
style.textContent = WEB_FONTS;
parent.appendChild(style);
}
function enqueueDocumentRoot() {
ensureFontStyle();
// A document-root scan (startup, or a changed Korean-support answer) must
// re-evaluate every element, so full mode propagates all the way down.
scheduler.enqueue(document.body || document.documentElement, undefined, {
mode: "full",
childMode: "full",
});
}
function startRuntime() {
ensureFontStyle();
const enqueueAddedNode = (node, batch) => {
if (node.nodeType === Node.ELEMENT_NODE) scheduler.enqueue(node, batch);
};
const processMutation = (mutation, batch) => {
ensureFontStyle();
if (mutation.type === "childList") {
scheduler.enqueueIterable(mutation.addedNodes, (node) => enqueueAddedNode(node, batch));
} else if (mutation.type === "attributes") {
if (mutation.attributeName === "style") {
const state = inlineFontStates.get(mutation.target);
if (state && sameInlineFont(inlineFont(mutation.target), state.expected)) return;
}
scheduler.enqueue(mutation.target, batch);
}
};
const observer = new MutationObserver((mutations) => {
const batch = scheduler.createBatch();
scheduler.enqueueIterable(mutations, (mutation) => processMutation(mutation, batch));
});
observer.observe(document.documentElement || document, {
attributes: true,
attributeFilter: ["class", "style"],
childList: true,
subtree: true,
});
// The observer already covers every element parsed after this point, so
// no DOMContentLoaded rescan is registered.
enqueueDocumentRoot();
try {
const fonts = document.fonts;
if (fonts) {
// Font events fire repeatedly with dynamic-subset web fonts. Rescan
// only when a cached Korean-support answer actually changed; each
// rescan of an already-replaced element costs two inline writes.
const refreshFonts = () => {
if (fontSupportCache.size === 0) return;
const previous = new Map(fontSupportCache);
fontSupportCache.clear();
let changed = false;
for (const [key, supported] of previous) {
// Keys are trimmed lowercase names; canvas font matching is
// case-insensitive, so re-measuring by key yields the same key.
if (supportsKorean(key) !== supported) changed = true;
}
if (changed) enqueueDocumentRoot();
};
fonts.addEventListener?.("loadingdone", refreshFonts);
fonts.addEventListener?.("loadingerror", refreshFonts);
Promise.resolve(fonts.ready).then(refreshFonts).catch(() => {});
}
} catch (error) {
if (DEBUG_MODE) console.warn("[Custom Fallback Font] Font events are unavailable", error);
}
}
if (!isCurrentSiteExcluded()) {
startRuntime();
} else if (DEBUG_MODE) {
console.log("[Custom Fallback Font] This site is excluded.");
}
})();
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment