Last active
December 10, 2025 06:49
-
-
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.7 | |
| // @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/[email protected]/dist/web/variable/pretendardvariable-gov.min.css"); | |
| @import url('https://fonts.googleapis.com/css2?family=Noto+Serif+KR:[email protected]&display=swap'); | |
| @font-face { | |
| font-family: 'D2Coding'; | |
| src: url('https://cdn.jsdelivr.net/gh/projectnoonnu/[email protected]/D2Coding.woff') format('woff'); | |
| font-weight: normal; | |
| font-display: swap; | |
| } | |
| `; | |
| const CUSTOM_FONTS = { | |
| sansSerif: "Pretendard GOV Variable", | |
| serif: "Noto Serif KR", | |
| monospace: "D2Coding", | |
| }; | |
| const EXCLUDED_SITES_KEY = "customFallbackFont_excludedSites"; | |
| // ==================== | |
| function getExcludedSites() { | |
| const data = GM_getValue(EXCLUDED_SITES_KEY, null); | |
| if (data === null) { | |
| // 저장된 값이 없으면 기본 예외 사이트를 저장하고 반환 | |
| const defaultSites = ["*.github.com", "*.stackblitz.com"]; | |
| saveExcludedSites(defaultSites); | |
| return defaultSites; | |
| } | |
| try { | |
| return JSON.parse(data); | |
| } catch (e) { | |
| // 파싱 오류 시 기본값으로 복구 | |
| const defaultSites = ["*.github.com", "*.stackblitz.com"]; | |
| saveExcludedSites(defaultSites); | |
| return defaultSites; | |
| } | |
| } | |
| function saveExcludedSites(sites) { | |
| GM_setValue(EXCLUDED_SITES_KEY, JSON.stringify(sites)); | |
| } | |
| function getCurrentDomain() { | |
| return window.location.hostname; | |
| } | |
| function isCurrentSiteExcluded() { | |
| const excludedSites = getExcludedSites(); | |
| const currentDomain = getCurrentDomain(); | |
| return excludedSites.some((site) => { | |
| if (site.includes("*")) { | |
| const regex = new RegExp( | |
| "^" + site.replace(/\./g, "\\.").replace(/\*/g, ".*") + "$" | |
| ); | |
| return regex.test(currentDomain); | |
| } | |
| return site === currentDomain; | |
| }); | |
| } | |
| function toggleCurrentSite() { | |
| const excludedSites = getExcludedSites(); | |
| const currentDomain = getCurrentDomain(); | |
| const index = excludedSites.indexOf(currentDomain); | |
| if (index === -1) { | |
| excludedSites.push(currentDomain); | |
| alert(`"${currentDomain}"이(가) 예외 목록에 추가되었습니다.\n페이지를 새로고침하세요.`); | |
| } else { | |
| excludedSites.splice(index, 1); | |
| alert(`"${currentDomain}"이(가) 예외 목록에서 제거되었습니다.\n페이지를 새로고침하세요.`); | |
| } | |
| saveExcludedSites(excludedSites); | |
| } | |
| function manageExcludedSites() { | |
| const excludedSites = getExcludedSites(); | |
| const newList = prompt( | |
| "예외 사이트 목록 (쉼표로 구분, *를 와일드카드로 사용 가능):\n예: *.github.com, *.example.com", | |
| excludedSites.join(", ") | |
| ); | |
| if (newList !== null) { | |
| const sites = newList | |
| .split(",") | |
| .map((s) => s.trim()) | |
| .filter((s) => s.length > 0); | |
| saveExcludedSites(sites); | |
| alert("예외 사이트 목록이 업데이트되었습니다.\n페이지를 새로고침하세요."); | |
| } | |
| } | |
| // 메뉴 명령어 등록 (항상 사용 가능) | |
| GM_registerMenuCommand("현재 사이트 예외 추가/제거", toggleCurrentSite); | |
| GM_registerMenuCommand("예외 사이트 관리", manageExcludedSites); | |
| // 한국어 지원 폰트 캐시 | |
| const fontSupportCache = new Map(); | |
| let replacedFonts = new Set(); | |
| function showToast(message) { | |
| const toastContainer = | |
| document.getElementById("custom-font-toast-container") || | |
| (() => { | |
| const container = document.createElement("div"); | |
| container.id = "custom-font-toast-container"; | |
| 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); | |
| return container; | |
| })(); | |
| const toast = document.createElement("div"); | |
| toast.textContent = message; | |
| toast.style.cssText = ` | |
| background: rgba(0, 0, 0, 0.8); | |
| color: white; | |
| padding: 12px 20px; | |
| border-radius: 8px; | |
| font-size: 13px; | |
| font-family: sans-serif; | |
| opacity: 0; | |
| transition: opacity 0.3s; | |
| pointer-events: auto; | |
| `; | |
| toastContainer.appendChild(toast); | |
| // 페이드 인 | |
| setTimeout(() => (toast.style.opacity = "1"), 10); | |
| // 3초 후 페이드 아웃 및 제거 | |
| setTimeout(() => { | |
| toast.style.opacity = "0"; | |
| setTimeout(() => { | |
| toast.remove(); | |
| // 컨테이너가 비었으면 제거 | |
| if (toastContainer.children.length === 0) { | |
| toastContainer.remove(); | |
| } | |
| }, 300); | |
| }, 3000); | |
| } | |
| function supportsKorean(fontName) { | |
| if (fontSupportCache.has(fontName)) { | |
| return fontSupportCache.get(fontName); | |
| } | |
| const canvas = document.createElement("canvas"); | |
| const context = canvas.getContext("2d"); | |
| const testChar = "한"; | |
| // fallback 폰트로 렌더링 | |
| context.font = "72px monospace"; | |
| const fallbackWidth = context.measureText(testChar).width; | |
| // 테스트할 폰트로 렌더링 | |
| context.font = `72px "${fontName}", monospace`; | |
| const testWidth = context.measureText(testChar).width; | |
| // 너비가 다르면 해당 폰트가 한글을 지원 | |
| const supports = testWidth !== fallbackWidth; | |
| fontSupportCache.set(fontName, supports); | |
| return supports; | |
| } | |
| function changeFontFamily(element) { | |
| if (!element || element.nodeType !== Node.ELEMENT_NODE) return; | |
| const computedStyle = window.getComputedStyle(element); | |
| const fontFamily = computedStyle.fontFamily | |
| .toLowerCase() | |
| .replace(/['"]/g, ""); | |
| // CSS generic family names + 시스템 기본 폰트 | |
| const systemFonts = [ | |
| "serif", | |
| "sans-serif", | |
| "monospace", | |
| "cursive", | |
| "fantasy", | |
| "system-ui", | |
| "ui-serif", | |
| "ui-sans-serif", | |
| "ui-monospace", | |
| "ui-rounded", | |
| "math", | |
| "fangsong", | |
| "-apple-system", | |
| "blinkmacsystemfont", | |
| "segoe ui", | |
| "malgun gothic", | |
| "dotum", | |
| "arial", | |
| ]; | |
| const fonts = fontFamily.split(",").map((f) => f.trim()); | |
| // 이미 커스텀 폰트가 적용되어 있는지 확인 | |
| const customFontNames = Object.values(CUSTOM_FONTS).map((f) => | |
| f.toLowerCase() | |
| ); | |
| if (fonts.some((font) => customFontNames.includes(font))) { | |
| // 자식 요소만 처리 | |
| for (const child of element.children) { | |
| changeFontFamily(child); | |
| } | |
| return; | |
| } | |
| const hasExplicitFontFamily = | |
| element.style.fontFamily !== "" || | |
| !element.parentElement || | |
| getComputedStyle(element).getPropertyValue("font-family") !== | |
| getComputedStyle(element.parentElement).getPropertyValue("font-family"); | |
| if (!hasExplicitFontFamily) { | |
| // 자식 요소만 처리 | |
| for (const child of element.children) { | |
| changeFontFamily(child); | |
| } | |
| return; | |
| } | |
| // 전체 fontFamily에서 generic family 찾기 (마지막 항목이 보통 generic family) | |
| const genericFamily = fonts[fonts.length - 1]; | |
| let targetGenericFamily = "sans-serif"; // 기본값 | |
| if (systemFonts.includes(genericFamily)) { | |
| targetGenericFamily = genericFamily; | |
| } | |
| // 각 폰트를 분류 | |
| let hasKoreanSupportFont = false; | |
| let firstSystemFontIndex = -1; | |
| let lastUnsupportedFontIndex = -1; | |
| for (let i = 0; i < fonts.length; i++) { | |
| const font = fonts[i]; | |
| if (systemFonts.includes(font)) { | |
| if (firstSystemFontIndex === -1) { | |
| firstSystemFontIndex = i; | |
| } | |
| } else if (supportsKorean(font)) { | |
| hasKoreanSupportFont = true; | |
| break; // 한글 지원 폰트가 있으면 더 이상 확인 불필요 | |
| } else { | |
| // 한글 미지원 폰트 (마지막 인덱스 계속 갱신) | |
| lastUnsupportedFontIndex = i; | |
| } | |
| } | |
| // 한글 지원 폰트가 이미 있으면 대체하지 않음 | |
| if (hasKoreanSupportFont) { | |
| // 자식 요소만 처리 | |
| for (const child of element.children) { | |
| changeFontFamily(child); | |
| } | |
| return; | |
| } | |
| // 삽입할 커스텀 폰트 결정 | |
| let customFont = ""; | |
| const isMonospace = | |
| targetGenericFamily === "monospace" || | |
| targetGenericFamily === "ui-monospace"; | |
| if (isMonospace) { | |
| customFont = CUSTOM_FONTS.monospace; | |
| } else if ( | |
| targetGenericFamily === "serif" || | |
| targetGenericFamily === "ui-serif" | |
| ) { | |
| customFont = CUSTOM_FONTS.serif; | |
| } else { | |
| // sans-serif, ui-sans-serif, system-ui, cursive, fantasy, ui-rounded, math, fangsong 등 | |
| customFont = CUSTOM_FONTS.sansSerif; | |
| } | |
| // 삽입 위치 결정 | |
| let insertIndex = -1; | |
| let triggerFont = ""; | |
| // 모노스페이스 폰트이고 첫 번째 폰트가 한글 미지원인 경우 제일 앞에 삽입 | |
| if ( | |
| isMonospace && | |
| fonts.length > 0 && | |
| !systemFonts.includes(fonts[0]) && | |
| !supportsKorean(fonts[0]) | |
| ) { | |
| insertIndex = 0; | |
| triggerFont = fonts[0]; | |
| } else if (lastUnsupportedFontIndex !== -1 && firstSystemFontIndex !== -1) { | |
| // 한글 미지원 폰트와 시스템 폰트 모두 있는 경우 | |
| // 마지막 한글 미지원 폰트 뒤, 첫 번째 시스템 폰트 앞 중 적절한 위치 | |
| if (lastUnsupportedFontIndex < firstSystemFontIndex) { | |
| // 한글 미지원 폰트가 시스템 폰트보다 앞에 있음 → 마지막 한글 미지원 폰트 뒤에 삽입 | |
| insertIndex = lastUnsupportedFontIndex + 1; | |
| triggerFont = fonts[lastUnsupportedFontIndex]; | |
| } else { | |
| // 시스템 폰트가 한글 미지원 폰트보다 앞에 있음 → 첫 번째 시스템 폰트 앞에 삽입 | |
| insertIndex = firstSystemFontIndex; | |
| triggerFont = fonts[firstSystemFontIndex]; | |
| } | |
| } else if (lastUnsupportedFontIndex !== -1) { | |
| // 한글 미지원 폰트만 있으면 마지막 한글 미지원 폰트 뒤에 삽입 | |
| insertIndex = lastUnsupportedFontIndex + 1; | |
| triggerFont = fonts[lastUnsupportedFontIndex]; | |
| } else if (firstSystemFontIndex !== -1) { | |
| // 시스템 폰트만 있으면 첫 번째 시스템 폰트 앞에 삽입 | |
| insertIndex = firstSystemFontIndex; | |
| triggerFont = fonts[firstSystemFontIndex]; | |
| } | |
| // 커스텀 폰트 삽입 | |
| if (insertIndex !== -1 && customFont) { | |
| const newFonts = [...fonts]; | |
| newFonts.splice(insertIndex, 0, customFont.toLowerCase()); | |
| element.style.fontFamily = newFonts.join(", "); | |
| if (DEBUG_MODE) { | |
| element.style.background = "red"; | |
| } | |
| // 토스트 표시 | |
| if (!replacedFonts.has(triggerFont)) { | |
| replacedFonts.add(triggerFont); | |
| if (SHOW_TOAST) { | |
| showToast(`폰트 대체: ${triggerFont} → ${customFont} fallback 추가`); | |
| } | |
| } | |
| } | |
| // 자식 요소 처리 | |
| for (const child of element.children) { | |
| changeFontFamily(child); | |
| } | |
| } | |
| // 초기 페이지 로드 | |
| function init() { | |
| // 웹 폰트 로드 대기 후 실행 | |
| document.fonts.ready.then(() => { | |
| changeFontFamily(document.body); | |
| }); | |
| } | |
| if (!isCurrentSiteExcluded()) { | |
| // 웹 폰트 로드 | |
| if (WEB_FONTS.trim()) { | |
| const style = document.createElement("style"); | |
| style.textContent = WEB_FONTS; | |
| (document.head || document.documentElement).appendChild(style); | |
| } | |
| // DOM 변경 감지 | |
| const observer = new MutationObserver((mutations) => { | |
| for (const mutation of mutations) { | |
| for (const node of mutation.addedNodes) { | |
| if (node.nodeType === Node.ELEMENT_NODE) { | |
| changeFontFamily(node); | |
| } | |
| } | |
| } | |
| }); | |
| // 스크립트 시작 | |
| if (document.readyState === "loading") { | |
| document.addEventListener("DOMContentLoaded", init); | |
| } else { | |
| init(); | |
| } | |
| // 웹 폰트 로드 후 observer 시작 | |
| document.fonts.ready.then(() => { | |
| observer.observe(document.body || document.documentElement, { | |
| childList: true, | |
| subtree: true, | |
| }); | |
| }); | |
| } else { | |
| if (DEBUG_MODE) { | |
| console.log( | |
| "[Custom Fallback Font] 현재 사이트는 예외 목록에 있어 스크립트가 비활성화되었습니다." | |
| ); | |
| } | |
| } | |
| })(); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment