Font Replacer

Replaces specified fonts with alternatives across all page elements

Від 03.04.2025. Дивіться остання версія.

// ==UserScript==
// @name         Font Replacer
// @namespace    https://openuserjs.org/users/pfzim
// @version      0.2
// @description  Replaces specified fonts with alternatives across all page elements
// @author       pfzim
// @copyright    2025, pfzim (https://openuserjs.org/users/pfzim)
// @license      GPL-3.0-or-later
// @match        *://*/*
// @grant        none
// ==/UserScript==

(function ()
{
	'use strict';

	// sFont replacement settings (format: { "target font": "replacement", ... })

	let fontConfig = [
		{
			"pattern_url": "^http[s]?://[^/]*gitlab\\.com/",
			"replacements": {
				"GitLab": "Verdana",
				"GitLab Sans": "Verdana",
				"GitLab Mono": "Courier New"
			}
		},
		{
			"pattern_url": "^http[s]?://[^/]*market\\.yandex\\.ru/",
			"replacements": {
				"YS Text": "Arial"
			},
			"skip_body": true,
			"skip_observer": true,
			"skip_styles": false
		},
		{
			"pattern_url": ".*",
			"replacements": {
				"Barlow": "Verdana",
				"Geist": "Verdana",
				"Geist Mono": "Courier New",
				"Georgia": "Times New Roman",
				"GitLab Mono": "Courier New",
				"GitLab Sans": "Verdana",
				"Golos Text": "Arial",
				"Golos": "Arial",
				"Google Sans": "Verdana",
				"GothamProRegular": "Verdana",
				"Helvetica": "Verdana",
				"Inter": "Arial",
				"Kaspersky Sans": "Verdana",
				"Lato": "Arial",
				"Lato": "Verdana",
				"Manrope": "Verdana",
				"Metropolis": "Verdana",
				"Museo Sans": "Verdana",
				"Open Sans": "Verdana",
				"Optimistic Display": "Verdana",
				"Optimistic Text": "Verdana",
				"Roboto Mono": "Courier New",
				"Roboto": "Verdana",
				"Segoe UI": "Arial",
				"Source Code Pro": "Courier New",
				"Stolzl": "Verdana",
				"Verdana Neue": "Verdana",
				"ui-sans-serif": "Arial"
			},
			"skip_body": false,
			"skip_observer": false,
			"skip_styles": false
		}
		// Add your custom replacements here
	];

	let replacement_rule = null;

	function startObserver() {
		const observer = new MutationObserver(mutations => {
			for (const mutation of mutations) {
				for (const node of mutation.addedNodes) {
					if (node.nodeType === 1) { // Node.ELEMENT_NODE
						processAllElements(node);
					}
				}
			}
		});

		observer.observe(document.body, {
			childList: true,
			subtree: true
		});
	}

	function getReplacementsForCurrentSite() {
		const url = window.location.href;
		for (const rule of fontConfig) {
			try {
				const regex = new RegExp(rule.pattern_url);
				if (regex.test(url)) {
					console.log('Font Replacer: matched pattern: ' + rule.pattern_url);
					return rule || {};
				}
			} catch (e) {
				console.warn(`Invalid regex pattern: ${rule.pattern_url}`, e);
			}
		}
		return null;
	}

	function parseAndReplaceFonts(fontFamilyString, replacements) {
		if (!fontFamilyString) return fontFamilyString;

		const withoutComments = fontFamilyString.replace(/\/\*.*?\*\//g, '');
		const fontList = [];
		let currentFont = '';
		let inQuotes = false;
		let quoteChar = null;
		let inParentheses = false;
		let escapeNext = false;

		for (let i = 0; i < withoutComments.length; i++) {
			const char = withoutComments[i];

			if (escapeNext) {
				currentFont += char;
				escapeNext = false;
				continue;
			}

			if (char === '\\') {
				escapeNext = true;
				currentFont += char;
				continue;
			}

			if ((char === '"' || char === "'") && !inParentheses) {
				if (!inQuotes) {
					inQuotes = true;
					quoteChar = char;
				} else if (char === quoteChar) {
					inQuotes = false;
					quoteChar = null;
				}
				currentFont += char;
			} else if (char === '(' && !inQuotes) {
				inParentheses = true;
				currentFont += char;
			} else if (char === ')' && !inQuotes) {
				inParentheses = false;
				currentFont += char;
			} else if (char === ',' && !inQuotes && !inParentheses) {
				if (currentFont)
					fontList.push(processFont(currentFont, replacements));
				currentFont = '';
			} else {
				currentFont += char;
			}
		}

		if (currentFont)
			fontList.push(processFont(currentFont, replacements));

		return fontList.join(', ');
	}

	function processFont(font, replacements) {
		let unquotedFont = font;

		font = font.trim();
		if (font.startsWith('"') && font.endsWith('"')) {
			unquotedFont = font.slice(1, -1).replace(/\\"/g, '"');
		}
		else if (font.startsWith("'") && font.endsWith("'")) {
			unquotedFont = font.slice(1, -1).replace(/\\'/g, "'");
		}

		const lowerFont = unquotedFont.toLowerCase();

		for (const [original, replacement] of Object.entries(replacements)) {
			if (lowerFont === original.toLowerCase()) {
				return replacement;
			}
		}

		return unquotedFont;
	}

	// // Function to replace fonts in a string
	// function replaceFonts(fontFamily)
	// {
	// 	let newFontFamily = fontFamily;

	// 	for(const [oldFont, newFont] of Object.entries(replacement_rule.replacements))
	// 	{
	// 		newFontFamily = newFontFamily.replace(
	// 			new RegExp(`\\b${oldFont}\\b`, 'gi'),
	// 			newFont
	// 		);
	// 		// Alternative matching approach (commented out):
	// 		// if(newFontFamily.toLowerCase().includes(oldFont.toLowerCase()))
	// 		// {
	// 		// 	return newFont;
	// 		// }
	// 	}

	// 	return newFontFamily;
	// }

	// Main element processing function
	function processElement(element) {
		const computedStyle = window.getComputedStyle(element);
		const originalFont = computedStyle.fontFamily;

		if (!originalFont) return;

		//const newFont = replaceFonts(originalFont);
		const newFont = parseAndReplaceFonts(originalFont, replacement_rule.replacements)

		if (newFont.toLowerCase() !== originalFont.toLowerCase()) {
			element.style.fontFamily = newFont;
			// Debug logging (commented out):
			// console.log('Old font: ' + originalFont + '\nNew font: ' + newFont);
		}
	}

	// Recursive function to check all elements
	function processAllElements(node) {
		processElement(node);

		for (let i = 0; i < node.children.length; i++) {
			processAllElements(node.children[i]);
		}
	}


	// Recursive function to check all styles
	function processAllStyles(node) {
		Array.from(node).forEach(sheet => {
			try {
				Array.from(sheet.cssRules || []).forEach(rule => {
					if ((rule instanceof CSSStyleRule) && rule.style && rule.style.fontFamily) { // not rule instanceof CSSFontFaceRule
						// Removes the !important
						//rule.style.fontFamily = rule.style.fontFamily;
						// if(rule.style.getPropertyPriority('font-family') === 'important')
							// rule.style.setProperty('font-family', rule.style.getPropertyValue('font-family'), null);

						// Replace fonts

						const originalFont = rule.style.getPropertyValue('font-family').trim();
						const newFont = parseAndReplaceFonts(originalFont, replacement_rule.replacements)

						if (newFont.toLowerCase() !== originalFont.toLowerCase()) {
							rule.style.setProperty('font-family', newFont, rule.style.getPropertyPriority('font-family'));
							// Debug logging (commented out):
							// console.log('Old font: ' + originalFont + '\nNew font: ' + newFont);
						}
					}
				});
			}
			catch (e) {
				console.log('Font Replacer: Cannot read rules from', sheet.href, e);
			}
		});
	}

	replacement_rule = getReplacementsForCurrentSite();
	if (replacement_rule && Object.keys(replacement_rule.replacements).length > 0) {
		// Process the entire page
		if(!replacement_rule.skip_styles) processAllStyles(document.styleSheets);
		if(!replacement_rule.skip_body) processAllElements(document.body);
		if(!replacement_rule.skip_observer) startObserver();
	}
	else {
		console.log('Font Replacer: disabled for this url or globally!');
	}

	// Optional: Add @font-face style to force font replacement (commented out)
	// const style = document.createElement('style');
	// style.textContent = `
	//     * {
	//         font-family: ${Object.values(fontReplacements).join(', ')} !important;
	//     }
	// `;
	// document.head.appendChild(style);

})();