// ==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 = `
全局字体
自定义字体设置
覆盖通用字体族 (仅自定义模式)
忽略规则 (CSS选择器)