Font Replacer

Replaces specified fonts with alternatives across all page elements

  1. // ==UserScript==
  2. // @name Font Replacer
  3. // @version 0.4
  4. // @description Replaces specified fonts with alternatives across all page elements
  5. // @author pfzim
  6. // @copyright 2025, pfzim (https://github.com/pfzim/font-replacer)
  7. // @license GPL-3.0-or-later
  8. // @match *://*/*
  9. // @grant none
  10. // @namespace https://openuserjs.org/users/pfzim
  11. // ==/UserScript==
  12.  
  13. (function () {
  14. 'use strict';
  15.  
  16. // Font replacement settings (format: { "target font": "replacement", ... })
  17.  
  18. let fontConfig = [
  19. {
  20. "pattern_url": "^http[s]?://[^/]*gitlab\\.com/",
  21. "replacements": {
  22. "GitLab": "Verdana",
  23. "GitLab Sans": "Verdana",
  24. "GitLab Mono": "Courier New"
  25. }
  26. },
  27. {
  28. "pattern_url": "^http[s]?://[^/]*market\\.yandex\\.ru/",
  29. "replacements": {
  30. "YS Text": "Arial"
  31. },
  32. "skip_body": true,
  33. "skip_observer": true,
  34. "skip_observer_css": false,
  35. "skip_styles": false,
  36. "delay_ms": 0,
  37. "debug": false
  38. },
  39. {
  40. "pattern_url": ".*",
  41. "replacements": {
  42. "Barlow": "Verdana",
  43. "Geist": "Verdana",
  44. "Geist Mono": "Courier New",
  45. "Georgia": "Times New Roman",
  46. "GitLab Mono": "Courier New",
  47. "GitLab Sans": "Verdana",
  48. "Golos Text": "Arial",
  49. "Golos": "Arial",
  50. "Google Sans": "Verdana",
  51. "GothamProRegular": "Verdana",
  52. "Helvetica": "Verdana",
  53. "Inter": "Arial",
  54. "Kaspersky Sans": "Verdana",
  55. "Lato": "Arial",
  56. "Lato": "Verdana",
  57. "Manrope": "Verdana",
  58. "Metropolis": "Verdana",
  59. "Museo Sans": "Verdana",
  60. "Open Sans": "Verdana",
  61. "Optimistic Display": "Verdana",
  62. "Optimistic Text": "Verdana",
  63. "Roboto Mono": "Courier New",
  64. "Roboto": "Verdana",
  65. "Segoe UI": "Arial",
  66. "Source Code Pro": "Courier New",
  67. "Stolzl": "Verdana",
  68. "Verdana Neue": "Verdana",
  69. "ui-sans-serif": "Arial"
  70. },
  71. "skip_body": false,
  72. "skip_observer": false,
  73. "skip_styles": false,
  74. "skip_observer_css": false,
  75. "delay_ms": 0,
  76. "debug": false
  77. }
  78. // Add your custom replacements here
  79. ];
  80.  
  81. let replacement_rule = null;
  82. let sheet_count = 0;
  83. let debug = false;
  84.  
  85. replacement_rule = getReplacementsForCurrentSite();
  86. if (replacement_rule && Object.keys(replacement_rule.replacements).length > 0) {
  87. if (replacement_rule.debug) {
  88. debug = true;
  89. console.log('Font Replacer rules:', replacement_rule);
  90. }
  91. // Process the entire page
  92. setTimeout(() => {
  93. if (!replacement_rule.skip_styles) processAllStyles(document.styleSheets);
  94. if (!replacement_rule.skip_observer_css) startObserverCSS();
  95. if (!replacement_rule.skip_body) processAllElements(document.body);
  96. if (!replacement_rule.skip_observer) startObserver();
  97. }, replacement_rule.delay_ms || 0);
  98. }
  99. else {
  100. console.log('Font Replacer: disabled for this url or globally!');
  101. }
  102.  
  103. function startObserver() {
  104. const observer = new MutationObserver(mutations => {
  105. for (const mutation of mutations) {
  106. for (const node of mutation.addedNodes) {
  107. if (node.nodeType === 1) { // Node.ELEMENT_NODE
  108. processAllElements(node);
  109. }
  110. }
  111. }
  112. });
  113.  
  114. observer.observe(document.body, {
  115. childList: true,
  116. subtree: true
  117. });
  118. }
  119.  
  120. function startObserverCSS() {
  121. const observer = new MutationObserver((mutations) => {
  122. mutations.forEach((mutation) => {
  123. mutation.addedNodes.forEach((node) => {
  124. if (node.nodeName === 'STYLE') {
  125. if (debug) console.log('Added node <style>:', node);
  126. // setTimeout(() => {
  127. processSheetStyles(node.sheet);
  128. // }, 1000);
  129. } else if (node.nodeName === 'LINK' && node.rel === 'stylesheet') {
  130. if (debug) console.log('Added CSS file:', node);
  131. node.addEventListener('load', () => {
  132. try {
  133. if (debug) console.log('CSS file loaded');
  134. processSheetStyles(node.sheet);
  135.  
  136. } catch (e) {
  137. console.warn('Failed access to CSS rules (CORS):', e);
  138. }
  139. });
  140. }
  141. });
  142. });
  143. });
  144.  
  145. observer.observe(document.head, {
  146. childList: true,
  147. subtree: true
  148. });
  149. }
  150.  
  151.  
  152. function getReplacementsForCurrentSite() {
  153. const url = window.location.href;
  154. for (const rule of fontConfig) {
  155. try {
  156. const regex = new RegExp(rule.pattern_url);
  157. if (regex.test(url)) {
  158. console.log('Font Replacer: matched pattern: ' + rule.pattern_url);
  159. return rule || {};
  160. }
  161. } catch (e) {
  162. console.warn(`Invalid regex pattern: ${rule.pattern_url}`, e);
  163. }
  164. }
  165. return null;
  166. }
  167.  
  168. function parseAndReplaceFonts(fontFamilyString, replacements) {
  169. const regex = new RegExp(/^(\s*var\s*\()(.*)(\)\s*)$/i);
  170.  
  171. const replaceFont = (fontStr) => {
  172. // console.log('replaceFont: ' + fontStr);
  173.  
  174. const matches = fontStr.match(regex)
  175. if (matches) {
  176. return matches[1] + parse(matches[2]) + matches[3];
  177. }
  178. else {
  179. let unquotedFont = fontStr;
  180.  
  181. unquotedFont = unquotedFont.trim();
  182. if (unquotedFont.startsWith('"') && unquotedFont.endsWith('"')) {
  183. unquotedFont = unquotedFont.slice(1, -1).replace(/\\"/g, '"');
  184. }
  185. else if (unquotedFont.startsWith("'") && unquotedFont.endsWith("'")) {
  186. unquotedFont = unquotedFont.slice(1, -1).replace(/\\'/g, "'");
  187. }
  188.  
  189. const lowerFont = unquotedFont.toLowerCase();
  190.  
  191. for (const [original, replacement] of Object.entries(replacements)) {
  192. if (lowerFont === original.toLowerCase()) {
  193. return replacement;
  194. }
  195. }
  196. }
  197. return fontStr;
  198. };
  199.  
  200. const parse = (str) => {
  201. // console.log('parse: ' + str);
  202. let result = '';
  203. let current = '';
  204. let inQuotes = false;
  205. let inFunction = 0;
  206. let quoteChar = null;
  207.  
  208. for (let i = 0; i < str.length; i++) {
  209. let ch = str[i];
  210.  
  211. if (!inQuotes) {
  212. if ((ch === '"' || ch === "'")) {
  213. inQuotes = true;
  214. quoteChar = ch;
  215. }
  216. else if (ch === '(') {
  217. inFunction++;
  218. }
  219. else if (ch === ')' && inFunction > 0) {
  220. inFunction--;
  221. }
  222. else if (ch === ',' && inFunction === 0) {
  223. const processed = replaceFont(current);
  224. result += processed + ch;
  225. current = '';
  226. continue;
  227. }
  228. }
  229. else {
  230. if (ch === quoteChar) {
  231. inQuotes = false;
  232. quoteChar = null;
  233. }
  234. else if (ch === '\\') {
  235. current += ch;
  236. i++;
  237. ch = str[i];
  238. }
  239. }
  240.  
  241. current += ch;
  242. }
  243.  
  244. if (current) {
  245. result += replaceFont(current);
  246. }
  247.  
  248. return result;
  249. };
  250.  
  251. return parse(fontFamilyString);
  252. }
  253.  
  254. // Main element processing function
  255. function processElement(element) {
  256. const computedStyle = window.getComputedStyle(element);
  257. const originalFont = computedStyle.fontFamily;
  258.  
  259. if (!originalFont) return;
  260.  
  261. //const newFont = replaceFonts(originalFont);
  262. const newFont = parseAndReplaceFonts(originalFont, replacement_rule.replacements)
  263.  
  264. if (newFont.toLowerCase() !== originalFont.toLowerCase()) {
  265. element.style.fontFamily = newFont;
  266. // Debug logging (commented out):
  267. if (debug) console.log('Old font: ' + originalFont + '\nNew font: ' + newFont);
  268. }
  269. }
  270.  
  271. // Recursive function to check all elements
  272. function processAllElements(node) {
  273. processElement(node);
  274.  
  275. for (let i = 0; i < node.children.length; i++) {
  276. processAllElements(node.children[i]);
  277. }
  278. }
  279.  
  280. function processSheetStyles(sheet) {
  281. try {
  282. if (debug) {
  283. console.log('Processing CSS node:', sheet.ownerNode);
  284. if (!sheet.cssRules) {
  285. console.log('CSS rules not accessible - possible CORS issue');
  286. }
  287. if (sheet.disabled) {
  288. console.log('Stylesheet is currently disabled');
  289. }
  290. }
  291. Array.from(sheet.cssRules || []).forEach(rule => {
  292. if ((rule instanceof CSSStyleRule) && rule.style) { // not rule instanceof CSSFontFaceRule
  293. //console.log('Rule:');
  294. // Доступ к свойствам:
  295. //console.log('Selector:', rule.selectorText);
  296. for (let k = rule.style.length; k--;) {
  297. const var_name = rule.style[k];
  298. if (var_name.startsWith('--')) {
  299. const originalFont = rule.style.getPropertyValue(var_name).trim();
  300. const newFont = parseAndReplaceFonts(originalFont, replacement_rule.replacements)
  301.  
  302. if (newFont.toLowerCase() !== originalFont.toLowerCase()) {
  303. rule.style.setProperty(var_name, newFont, rule.style.getPropertyPriority(var_name));
  304. // Debug logging (commented out):
  305. if (debug) console.log('Var: ' + var_name + '\nOld font: ' + originalFont + '\nNew font: ' + newFont);
  306. }
  307. }
  308. }
  309. if (rule.style.fontFamily) { // not rule instanceof CSSFontFaceRule
  310. // Removes the !important
  311. //rule.style.fontFamily = rule.style.fontFamily;
  312. // if(rule.style.getPropertyPriority('font-family') === 'important')
  313. // rule.style.setProperty('font-family', rule.style.getPropertyValue('font-family'), null);
  314.  
  315. // Replace fonts
  316.  
  317. const originalFont = rule.style.getPropertyValue('font-family').trim();
  318. const newFont = parseAndReplaceFonts(originalFont, replacement_rule.replacements)
  319.  
  320. if (newFont.toLowerCase() !== originalFont.toLowerCase()) {
  321. rule.style.setProperty('font-family', newFont, rule.style.getPropertyPriority('font-family'));
  322. // Debug logging (commented out):
  323. if (debug) console.log('Old font: ' + originalFont + '\nNew font: ' + newFont);
  324. }
  325. }
  326. }
  327. });
  328. sheet_count++;
  329. if (debug) console.log('Font Replacer: CSS sheets processed: ' + sheet_count + ' (+1)');
  330. }
  331. catch (e) {
  332. console.warn('Font Replacer: Failed access to CSS rules (CORS)', e);
  333. //console.log('sheet.ownerNode.textContent:', sheet.ownerNode);
  334. }
  335. }
  336.  
  337. // Recursive function to check all styles
  338. function processAllStyles(node) {
  339. if (debug) console.log('Font Replacer: Process all styles...');
  340. Array.from(node).forEach(sheet => {
  341. processSheetStyles(sheet);
  342. });
  343. }
  344.  
  345. // Optional: Add @font-face style to force font replacement (commented out)
  346. // const style = document.createElement('style');
  347. // style.textContent = `
  348. // * {
  349. // font-family: ${Object.values(fontReplacements).join(', ')} !important;
  350. // }
  351. // `;
  352. // document.head.appendChild(style);
  353.  
  354. })();