// ==UserScript== // @name 网页字体一键切换 // @namespace http://tampermonkey.net/ // @version 1.0 // @description 超简易的网页字体切换插件 // @author LinLei_Baruch & Gemini 2.5 Pro // @match *://*/* // @grant GM_addStyle // @grant GM_setValue // @grant GM_getValue // @grant GM_registerMenuCommand // @run-at document-start // ==/UserScript== (function() { 'use strict'; const STYLE_ID = 'font-switcher-style-9a8b7c'; const SETTINGS_PANEL_ID = 'font-switcher-settings-panel-ab12cd'; const FILE_INPUT_ID = 'font-switcher-file-input-ef34gh'; const STORAGE_KEY_STATE = 'fontSwitcherState'; const STORAGE_KEY_CUSTOM_FONT = 'fontSwitcherCustomFont'; const STORAGE_KEY_CUSTOM_FONT_NAME = 'fontSwitcherCustomFontName'; const STORAGE_KEY_CUSTOM_FONT_POSTSCRIPT_NAME = 'fontSwitcherCustomFontPostscriptName'; const STORAGE_KEY_IGNORE_RULES = 'fontSwitcherIgnoreRules'; const STORAGE_KEY_OVERRIDE_SANS = 'fontSwitcherOverrideSans'; const STORAGE_KEY_OVERRIDE_SERIF = 'fontSwitcherOverrideSerif'; const STORAGE_KEY_OVERRIDE_MONO = 'fontSwitcherOverrideMono'; const CUSTOM_FONT_FAMILY_NAME = 'CustomUserFont'; const SYSTEM_FONT_STACK = `-apple-system, BlinkMacSystemFont, "Segoe UI", "Microsoft YaHei", "PingFang SC", "Hiragino Sans GB", "Helvetica Neue", Helvetica, Arial, sans-serif`; const DEFAULT_IGNORE_RULES = [ 'head *', 'style *', 'script *', 'mjx-container *', '.katex *', '[class*="glyph"]', '[class*="symbols" i]', '[class*="icon" i]', '[class*="fa-"]', '[class*="vjs-"]', '[class*="material-"]' ].join(',\n'); const NOTIFICATION_MESSAGE_SOURCE = 'font-switcher-notification-request'; const STATE_CHANGE_MESSAGE_SOURCE = 'font-switcher-state-change-broadcast'; let notificationTimeout; let settingsPanel = null; function _displayNotification(message, type = 'info', duration = 3000) { if (!document.body) { document.addEventListener('DOMContentLoaded', () => _displayNotification(message, type, duration)); return; } const existing = document.getElementById('fs-notification'); if (existing) { existing.remove(); clearTimeout(notificationTimeout); } const notification = document.createElement('div'); notification.id = 'fs-notification'; notification.className = `fs-notification-${type}`; notification.textContent = message; document.body.appendChild(notification); setTimeout(() => { notification.style.opacity = '1'; notification.style.transform = 'translateX(-50%) translateY(0)'; }, 10); notificationTimeout = setTimeout(() => { notification.style.opacity = '0'; notification.style.transform = 'translateX(-50%) translateY(-20px)'; setTimeout(() => notification.remove(), 500); }, duration); } function showNotification(message, type = 'info', duration = 3000) { if (window.top === window.self) { _displayNotification(message, type, duration); } else { window.top.postMessage({ source: NOTIFICATION_MESSAGE_SOURCE, details: { message, type, duration } }, '*'); } } function broadcastStateChange(newState) { const iframes = document.querySelectorAll('iframe'); iframes.forEach(iframe => { try { iframe.contentWindow.postMessage({ source: STATE_CHANGE_MESSAGE_SOURCE, newState: newState }, '*'); } catch (e) { // Silently ignore cross-origin errors } }); } async function getFontApplySelector() { const rules = await GM_getValue(STORAGE_KEY_IGNORE_RULES, DEFAULT_IGNORE_RULES); const cleanedRules = rules.replace(/\s*,\s*/g, ',').replace(/\n/g, ''); if (!cleanedRules) { return '*'; } return `*:not(${cleanedRules})`; } async function applyFont(state) { const oldStyle = document.getElementById(STYLE_ID); if (oldStyle) oldStyle.remove(); const selector = await getFontApplySelector(); let css = ''; switch (state) { case 'system': css = `${selector} { font-family: ${SYSTEM_FONT_STACK} !important; }`; break; case 'custom': const fontData = await GM_getValue(STORAGE_KEY_CUSTOM_FONT); if (fontData) { const postscriptName = await GM_getValue(STORAGE_KEY_CUSTOM_FONT_POSTSCRIPT_NAME, null); const fontFamilyToUse = postscriptName || CUSTOM_FONT_FAMILY_NAME; const quotedFontFamily = `'${fontFamilyToUse.replace(/'/g, "\\'")}'`; css = `@font-face { font-family: ${quotedFontFamily}; src: url(${fontData}); } ${selector} { font-family: ${quotedFontFamily}, ${SYSTEM_FONT_STACK} !important; }`; const overrideSans = await GM_getValue(STORAGE_KEY_OVERRIDE_SANS, false); const overrideSerif = await GM_getValue(STORAGE_KEY_OVERRIDE_SERIF, false); const overrideMono = await GM_getValue(STORAGE_KEY_OVERRIDE_MONO, false); if (overrideSans) { css += ` @font-face { font-family: 'sans-serif'; src: url(${fontData}); }`; } if (overrideSerif) { css += ` @font-face { font-family: 'serif'; src: url(${fontData}); }`; } if (overrideMono) { css += ` @font-face { font-family: 'monospace'; src: url(${fontData}); }`; } } else { console.warn('自定义字体未设置,已回退至默认字体。'); showNotification('自定义字体未设置,请先上传。', 'info'); await GM_setValue(STORAGE_KEY_STATE, 'default'); return; } break; case 'default': default: break; } if (css) { const styleEl = GM_addStyle(css); styleEl.id = STYLE_ID; } } async function updateAll() { const state = await GM_getValue(STORAGE_KEY_STATE, 'default'); await applyFont(state); broadcastStateChange(state); } async function parseFontInfo(fontDataUrl) { try { const base64 = fontDataUrl.substring(fontDataUrl.indexOf(',') + 1); const binary_string = atob(base64); const len = binary_string.length; const bytes = new Uint8Array(len); for (let i = 0; i < len; i++) { bytes[i] = binary_string.charCodeAt(i); } const buffer = bytes.buffer; const view = new DataView(buffer); const numTables = view.getUint16(4, false); let nameTableOffset = 0; for (let i = 0; i < numTables; i++) { const offset = 12 + i * 16; let tag = ''; for (let j = 0; j < 4; j++) { tag += String.fromCharCode(view.getUint8(offset + j)); } if (tag === 'name') { nameTableOffset = view.getUint32(offset + 8, false); break; } } if (!nameTableOffset) { return { familyName: null, postscriptName: null }; } const count = view.getUint16(nameTableOffset + 2, false); const stringOffset = nameTableOffset + view.getUint16(nameTableOffset + 4, false); const nameRecordsOffset = nameTableOffset + 6; let familyNames = { zh: null, en: null, other: null }; let postscriptName = null; for (let i = 0; i < count; i++) { const recordOffset = nameRecordsOffset + i * 12; const platformID = view.getUint16(recordOffset, false); const encodingID = view.getUint16(recordOffset + 2, false); const languageID = view.getUint16(recordOffset + 4, false); const nameID = view.getUint16(recordOffset + 6, false); const length = view.getUint16(recordOffset + 8, false); const offset = view.getUint16(recordOffset + 10, false); if (nameID !== 1 && nameID !== 6) continue; const nameDataOffset = stringOffset + offset; const strBytes = new Uint8Array(buffer, nameDataOffset, length); let nameStr = ''; let decoder; if ((platformID === 3 && encodingID === 1) || platformID === 0) { decoder = new TextDecoder('utf-16be'); } else if (platformID === 1 && encodingID === 0) { decoder = new TextDecoder('macroman'); } else { decoder = new TextDecoder('utf-8', { fatal: false }); } try { nameStr = decoder.decode(strBytes); } catch(e) { try { nameStr = new TextDecoder('latin1').decode(strBytes); } catch (e2) { continue; } } nameStr = nameStr.replace(/\u0000/g, '').trim(); if (!nameStr) continue; if (nameID === 1) { if ((platformID === 3 && (languageID === 0x0804 || languageID === 0x0404 || languageID === 0x0C04)) || (platformID === 1 && (languageID === 19 || languageID === 25))) { if (!familyNames.zh) familyNames.zh = nameStr; } else if ((platformID === 3 && languageID === 0x0409) || (platformID === 1 && languageID === 0)) { if (!familyNames.en) familyNames.en = nameStr; } else { if (!familyNames.other) familyNames.other = nameStr; } } else if (nameID === 6) { if (!postscriptName) { const asciiDecoder = new TextDecoder('ascii', { fatal: false }); postscriptName = asciiDecoder.decode(strBytes).replace(/\u0000/g, '').replace(/[^ -~]/g, ''); } } } const familyName = familyNames.zh || familyNames.en || familyNames.other; return { familyName, postscriptName }; } catch (error) { console.error('Error parsing font file:', error); return { familyName: null, postscriptName: null }; } } async function handleFileSelect(event) { const file = event.target.files[0]; if (!file) return; const reader = new FileReader(); reader.onload = async (e) => { const fontDataUrl = e.target.result; const fallbackName = file.name.replace(/\.[^/.]+$/, ""); let displayName = fallbackName; let postscriptName = null; try { const fontInfo = await parseFontInfo(fontDataUrl); displayName = fontInfo.familyName || fallbackName; postscriptName = fontInfo.postscriptName; } catch (err) { console.error("字体解析失败:", err); showNotification('字体信息解析失败,将使用文件名作为名称。', 'info'); } await GM_setValue(STORAGE_KEY_CUSTOM_FONT, fontDataUrl); await GM_setValue(STORAGE_KEY_CUSTOM_FONT_NAME, displayName); await GM_setValue(STORAGE_KEY_CUSTOM_FONT_POSTSCRIPT_NAME, postscriptName); await GM_setValue(STORAGE_KEY_STATE, 'custom'); await updateAll(); showNotification(`自定义字体 '${displayName}' 已设置并应用!`, 'success'); if (settingsPanel) { settingsPanel.querySelector('input[value="custom"]').checked = true; const nameDisplay = settingsPanel.querySelector('#fs-custom-font-name-display'); if (nameDisplay) { nameDisplay.textContent = displayName; } } }; reader.onerror = () => showNotification('读取文件失败!', 'error'); reader.readAsDataURL(file); } function triggerFontUpload() { let fileInput = document.getElementById(FILE_INPUT_ID); if (!fileInput) { fileInput = document.createElement('input'); fileInput.id = FILE_INPUT_ID; fileInput.type = 'file'; fileInput.accept = '.ttf,.otf,.woff,.woff2'; fileInput.style.display = 'none'; fileInput.addEventListener('change', handleFileSelect); document.body.appendChild(fileInput); } fileInput.click(); } async function createSettingsPanel() { if (document.getElementById(SETTINGS_PANEL_ID)) return; settingsPanel = document.createElement('div'); settingsPanel.id = SETTINGS_PANEL_ID; const currentState = await GM_getValue(STORAGE_KEY_STATE, 'default'); const currentIgnoreRules = await GM_getValue(STORAGE_KEY_IGNORE_RULES, DEFAULT_IGNORE_RULES); const customFontName = await GM_getValue(STORAGE_KEY_CUSTOM_FONT_NAME, ''); const overrideSans = await GM_getValue(STORAGE_KEY_OVERRIDE_SANS, false); const overrideSerif = await GM_getValue(STORAGE_KEY_OVERRIDE_SERIF, false); const overrideMono = await GM_getValue(STORAGE_KEY_OVERRIDE_MONO, false); settingsPanel.innerHTML = `
字体切换设置

全局字体

当前自定义字体:${customFontName || '无'}

自定义字体设置

覆盖通用字体族 (仅自定义模式)

忽略规则 (CSS选择器)

`; document.body.appendChild(settingsPanel); settingsPanel.querySelector('.fs-close-btn').addEventListener('click', () => { settingsPanel.style.display = 'none'; }); settingsPanel.querySelectorAll('input[name="font-choice"]').forEach(radio => { radio.addEventListener('change', async (e) => { const newState = e.target.value; if (newState === 'custom' && !(await GM_getValue(STORAGE_KEY_CUSTOM_FONT))) { showNotification('请先上传自定义字体文件。', 'info'); triggerFontUpload(); radio.checked = false; settingsPanel.querySelector(`input[value="${currentState}"]`).checked = true; return; } const stateMap = { 'default': '网站默认', 'system': '系统', 'custom': '自定义' }; await GM_setValue(STORAGE_KEY_STATE, newState); await updateAll(); showNotification(`已切换为 ${stateMap[newState]} 字体`, 'success'); }); }); settingsPanel.querySelectorAll('input[name="font-override"]').forEach(checkbox => { checkbox.addEventListener('change', async (e) => { const overrideType = e.target.value; const isChecked = e.target.checked; let key; switch(overrideType) { case 'sans-serif': key = STORAGE_KEY_OVERRIDE_SANS; break; case 'serif': key = STORAGE_KEY_OVERRIDE_SERIF; break; case 'monospace': key = STORAGE_KEY_OVERRIDE_MONO; break; } if (key) { await GM_setValue(key, isChecked); await updateAll(); showNotification(`通用字体族覆盖规则已更新`, 'success'); } }); }); settingsPanel.querySelector('#fs-upload-btn').addEventListener('click', triggerFontUpload); settingsPanel.querySelector('#fs-save-rules-btn').addEventListener('click', async () => { const textarea = settingsPanel.querySelector('#fs-ignore-rules-textarea'); const newRules = textarea.value; await GM_setValue(STORAGE_KEY_IGNORE_RULES, newRules); await updateAll(); showNotification('忽略规则已更新并应用!', 'success'); }); } function toggleSettingsPanel() { if (!settingsPanel) { createSettingsPanel().then(() => { settingsPanel.style.display = 'block'; }); } else { const isHidden = settingsPanel.style.display === 'none'; settingsPanel.style.display = isHidden ? 'block' : 'none'; if (isHidden) { updatePanelContents(); } } } async function updatePanelContents() { if (!settingsPanel) return; const currentState = await GM_getValue(STORAGE_KEY_STATE, 'default'); const currentIgnoreRules = await GM_getValue(STORAGE_KEY_IGNORE_RULES, DEFAULT_IGNORE_RULES); const customFontName = await GM_getValue(STORAGE_KEY_CUSTOM_FONT_NAME, ''); const overrideSans = await GM_getValue(STORAGE_KEY_OVERRIDE_SANS, false); const overrideSerif = await GM_getValue(STORAGE_KEY_OVERRIDE_SERIF, false); const overrideMono = await GM_getValue(STORAGE_KEY_OVERRIDE_MONO, false); settingsPanel.querySelector(`input[value="${currentState}"]`).checked = true; settingsPanel.querySelector('#fs-ignore-rules-textarea').value = currentIgnoreRules; settingsPanel.querySelector('input[name="font-override"][value="sans-serif"]').checked = overrideSans; settingsPanel.querySelector('input[name="font-override"][value="serif"]').checked = overrideSerif; settingsPanel.querySelector('input[name="font-override"][value="monospace"]').checked = overrideMono; const nameDisplay = settingsPanel.querySelector('#fs-custom-font-name-display'); if (nameDisplay) { nameDisplay.textContent = customFontName || '无'; } } function injectStyles() { GM_addStyle(` #fs-notification { position: fixed; top: 20px; left: 50%; transform: translateX(-50%) translateY(-20px); padding: 12px 24px; border-radius: 10px; color: #fff; font-size: 15px; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; z-index: 2147483647; box-shadow: 0 6px 20px rgba(0,0,0,0.15); opacity: 0; transition: opacity 0.4s ease, transform 0.4s ease; background-color: #333; } .fs-notification-info { background-color: #007bff; } .fs-notification-success { background-color: #28a745; } .fs-notification-error { background-color: #dc3545; } #${SETTINGS_PANEL_ID} { display: none; position: fixed; top: 15px; right: 15px; width: 340px; background-color: rgba(252, 252, 252, 0.85); -webkit-backdrop-filter: blur(16px) saturate(180%); backdrop-filter: blur(16px) saturate(180%); border: 1px solid rgba(225, 225, 225, 0.5); border-radius: 14px; box-shadow: 0 8px 32px rgba(0, 0, 0, 0.12); z-index: 2147483646; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; font-size: 14px; color: #343a40; } #${SETTINGS_PANEL_ID}[style*="display: block"] { animation: fs-fade-in 0.3s ease forwards; } @keyframes fs-fade-in { from { opacity: 0; transform: translateY(-10px); } to { opacity: 1; transform: translateY(0); } } #${SETTINGS_PANEL_ID} * { box-sizing: border-box; } .fs-panel-header { display: flex; justify-content: space-between; align-items: center; padding: 14px 20px; border-bottom: 1px solid rgba(0, 0, 0, 0.07); font-weight: 600; font-size: 16px; color: #212529; } .fs-close-btn { background: none; border: none; font-size: 26px; line-height: 1; cursor: pointer; padding: 0; color: #6c757d; transition: color 0.2s ease, transform 0.2s ease; } .fs-close-btn:hover { color: #212529; transform: rotate(90deg); } .fs-panel-body { padding: 8px 20px 20px 20px; } .fs-section { margin-top: 18px; padding-top: 18px; border-top: 1px solid rgba(0, 0, 0, 0.07); } .fs-panel-body > .fs-section:first-child { margin-top: 0; padding-top: 12px; border-top: none; } .fs-section-title { margin: 0 0 12px 0; font-weight: 500; font-size: 12px; color: #6c757d; text-transform: uppercase; letter-spacing: 0.5px; } .fs-subsection-title { font-size: 13px; margin: 16px 0 8px 0; color: #495057; } .fs-radio-group, .fs-checkbox-group { display: flex; flex-direction: column; gap: 10px; } .fs-radio-group label, .fs-checkbox-group label { display: inline-flex; align-items: center; cursor: pointer; gap: 10px; } .fs-radio-group input, .fs-checkbox-group input { margin: 0; width: 15px; height: 15px; accent-color: #007bff; } .fs-font-info-display { margin-top: 14px; padding: 10px 14px; font-size: 13px; color: #495057; background-color: rgba(0, 0, 0, 0.03); border: 1px solid rgba(0, 0, 0, 0.05); border-radius: 8px; word-break: break-all; } #fs-custom-font-name-display { font-weight: 600; color: #212529; } textarea#fs-ignore-rules-textarea { width: 100%; height: 100px; padding: 10px; border: 1px solid #ced4da; border-radius: 8px; font-family: 'SF Mono', 'Menlo', 'Monaco', 'Consolas', monospace; font-size: 12px; resize: vertical; margin-bottom: 12px; background-color: #fff; transition: border-color 0.2s ease, box-shadow 0.2s ease; } textarea#fs-ignore-rules-textarea::placeholder { color: #adb5bd; } textarea#fs-ignore-rules-textarea:focus { border-color: #80bdff; box-shadow: 0 0 0 3px rgba(0,123,255,.25); outline: none; } .fs-btn { width: 100%; padding: 10px 12px; border: none; border-radius: 8px; cursor: pointer; font-size: 14px; font-weight: 500; text-align: center; transition: background-color 0.2s ease, transform 0.1s ease; } .fs-btn:active { transform: scale(0.98); } #fs-save-rules-btn { background-color: #007bff; color: white; } #fs-save-rules-btn:hover { background-color: #0069d9; } #fs-upload-btn { background-color: #fff; color: #495057; font-weight: 500; border: 1px solid #ced4da; } #fs-upload-btn:hover { background-color: #f8f9fa; } `); } async function initialize() { if (window.top === window.self) { GM_registerMenuCommand('打开字体设置菜单', toggleSettingsPanel); injectStyles(); } window.addEventListener('message', (event) => { if (!event.data || !event.data.source) return; if (event.data.source === STATE_CHANGE_MESSAGE_SOURCE) { applyFont(event.data.newState); } if (window.top === window.self && event.data.source === NOTIFICATION_MESSAGE_SOURCE) { const { message, type, duration } = event.data.details; _displayNotification(message, type, duration); } }); await updateAll(); } initialize(); })();