Last active
September 22, 2026 09:07
-
-
Save rishubil/65221c1da6e384e67ae9cb3739ae5f90 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // ==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