├── manifest.json ├── popup.html ├── README.md ├── popup.js └── content_script.js /manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "manifest_version": 3, 3 | "name": "Font Analyzer - Page Fonts", 4 | "version": "0.1.0", 5 | "description": "Lists fonts used on the current page. Select and copy font-families from the popup.", 6 | "action": { 7 | "default_title": "Page Fonts", 8 | "default_popup": "popup.html" 9 | }, 10 | "permissions": [ 11 | "activeTab", 12 | "scripting", 13 | "clipboardWrite" 14 | ], 15 | "host_permissions": [ 16 | "" 17 | ], 18 | "content_scripts": [ 19 | { 20 | "matches": [""], 21 | "js": ["content_script.js"], 22 | "run_at": "document_idle" 23 | } 24 | ] 25 | } 26 | -------------------------------------------------------------------------------- /popup.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Page Fonts 7 | 75 | 76 | 77 |

Fonts on this page

78 | 79 |
80 |
81 |
Family
82 |
Sample
83 |
Source
84 |
URL
85 |
Weight / Style
86 |
87 |
Loading…
88 |
89 |
90 | 91 | 92 |
93 | 94 | 95 | 96 | 97 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Font Analyzer — Chrome Extension 2 | 3 | A Chrome extension that detects and lists all fonts used on the current page, distinguishing between fonts loaded from the site's origin versus external/system fonts. The extension displays font families, source information, URLs, and font weights/styles in an easy-to-read table. 4 | 5 | ## Features 6 | 7 | - **Comprehensive Font Detection**: Finds fonts from multiple sources: 8 | - Computed styles (applied to DOM elements) 9 | - @font-face declarations from accessible stylesheets 10 | - Loaded font resources (from Network tab) 11 | 12 | - **Source Classification**: 13 | - **Local** (green): Fonts loaded from the current domain 14 | - **Remote** (orange): Fonts from external CDNs or third-party sources 15 | - **System** (gray): Fallback system fonts without external URLs 16 | 17 | - **Rich UI**: 18 | - Wide table view (1500px) showing Family, Sample, Source, URL, Weight/Style 19 | - Filter by font family name 20 | - Select all fonts, or select only local fonts 21 | - Copy selected font names to clipboard 22 | 23 | - **OAuth2-Protected Pages**: Works on pages requiring authentication; just log in first, then open the extension popup 24 | 25 | ## Installation 26 | 27 | ### For Development (Unpacked Extension) 28 | 29 | 1. Open Chrome and go to `chrome://extensions` 30 | 2. Enable "Developer mode" (top-right toggle) 31 | 3. Click "Load unpacked" 32 | 4. Select the `FontAnalyzer` folder: `c:\Projects\FontAnalyzer` 33 | 5. The extension icon will appear in your Chrome toolbar 34 | 35 | ### Using the Extension 36 | 37 | 1. Navigate to any webpage (log in if the page requires authentication) 38 | 2. Click the **Font Analyzer** icon in the Chrome toolbar 39 | 3. The popup will display all detected fonts in a table with columns: 40 | - **Family**: Font name 41 | - **Sample**: Preview text rendered with the font 42 | - **Source**: Badge showing Local, Remote, or System 43 | - **URL**: Link to the font file (if available) 44 | - **Weight / Style**: Font weight and style (e.g., "700 / italic") 45 | 46 | 4. Use the controls: 47 | - **Filter**: Type to search by font family name 48 | - **Select all**: Check all fonts 49 | - **Select local only**: Check only fonts loaded from this site 50 | - **Copy selected**: Copy selected font names to clipboard (one per line) 51 | 52 | ## Technical Details 53 | 54 | ### How It Works 55 | 56 | 1. **Content Script** (`content_script.js`): 57 | - Scans the DOM tree for computed font-family styles 58 | - Reads accessible @font-face rules from stylesheets 59 | - Detects loaded font resources from the performance API 60 | - Combines all sources and classifies fonts by origin 61 | 62 | 2. **Popup** (`popup.html` + `popup.js`): 63 | - Requests font data from the content script via messaging 64 | - Renders a responsive table with filtering and selection 65 | - Supports clipboard copy of selected fonts 66 | 67 | 3. **Manifest** (`manifest.json`): 68 | - Defines the extension (MV3 - Manifest V3) 69 | - Registers the content script to run on all pages 70 | - Requests permissions: activeTab, scripting, clipboardWrite, and 71 | 72 | ### Limitations 73 | 74 | - **Cross-origin Stylesheets**: Cannot read @font-face rules from cross-origin stylesheets due to CORS restrictions. Only accessible stylesheets are inspected. 75 | - **Font File Parsing**: Cannot automatically determine font family names from unmatched font files. Files that don't match @font-face declarations are displayed by their filename/URL. 76 | - **Performance**: Large pages (5000+ elements) are limited to scanning the first 5000 elements to avoid slowdown. 77 | 78 | ## Files 79 | 80 | - `manifest.json` — Extension configuration (MV3) 81 | - `popup.html` — Popup UI layout 82 | - `popup.js` — Popup logic and event handlers 83 | - `content_script.js` — Font detection logic 84 | - `README.md` — This file 85 | 86 | ## Troubleshooting 87 | 88 | ### No fonts appear in the popup 89 | - Ensure the page has fully loaded before opening the popup 90 | - Check the popup console (right-click in popup → Inspect → Console tab) for debug logs 91 | - Pages with dynamically-injected fonts may require waiting a moment after page load 92 | 93 | ### Some fonts are missing 94 | - Fonts loaded via JavaScript frameworks or dynamic CSS injection may not be detected 95 | - @font-face rules in cross-origin stylesheets (CDNs) won't be read due to CORS 96 | - Fonts in iframes or shadow DOM may not be visible 97 | 98 | ### Extension not working on a specific page 99 | - Some pages block extensions; try a different page to test 100 | - If the page uses strict CSP (Content Security Policy), the content script may be blocked 101 | - Verify the extension is enabled on the page (check chrome://extensions) 102 | 103 | ## Future Enhancements 104 | 105 | - Parse font file metadata to extract family names when @font-face is unavailable 106 | - Support for iframes and shadow DOM fonts 107 | - Export font data in JSON or CSS format 108 | - Caching of detected fonts per page 109 | - Integration with font analysis server (backend) 110 | -------------------------------------------------------------------------------- /popup.js: -------------------------------------------------------------------------------- 1 | // popup.js 2 | // Render font table with family, sample, source, URL, and weight/style columns 3 | 4 | 'use strict'; 5 | 6 | function el(tag, attrs = {}, ...children) { 7 | const e = document.createElement(tag); 8 | for (const k in attrs) { 9 | if (k === 'class') e.className = attrs[k]; 10 | else e.setAttribute(k, attrs[k]); 11 | } 12 | for (const c of children) { 13 | if (typeof c === 'string') e.appendChild(document.createTextNode(c)); 14 | else if (c) e.appendChild(c); 15 | } 16 | return e; 17 | } 18 | 19 | async function getFontsFromTab() { 20 | const tabs = await chrome.tabs.query({ active: true, currentWindow: true }); 21 | if (!tabs || !tabs[0]) return []; 22 | try { 23 | const resp = await chrome.tabs.sendMessage(tabs[0].id, { type: 'getFonts' }); 24 | return resp && resp.fonts ? resp.fonts : []; 25 | } catch (err) { 26 | try { 27 | await chrome.scripting.executeScript({ 28 | target: { tabId: tabs[0].id }, 29 | files: ['content_script.js'] 30 | }); 31 | const resp2 = await chrome.tabs.sendMessage(tabs[0].id, { type: 'getFonts' }); 32 | return resp2 && resp2.fonts ? resp2.fonts : []; 33 | } catch (err2) { 34 | console.error('Failed to get fonts from tab', err2); 35 | return []; 36 | } 37 | } 38 | } 39 | 40 | function renderFonts(fontObjects) { 41 | const container = document.getElementById('fonts'); 42 | container.innerHTML = ''; 43 | 44 | if (!Array.isArray(fontObjects) || fontObjects.length === 0) { 45 | container.textContent = 'No fonts found on this page.'; 46 | return; 47 | } 48 | 49 | const local = fontObjects.filter(f => f && f.source === 'local'); 50 | const remote = fontObjects.filter(f => f && f.source !== 'local'); 51 | 52 | function renderGroup(title, arr) { 53 | const frag = document.createDocumentFragment(); 54 | const header = el('div', { class: 'group-header' }, title + ` (${arr.length})`); 55 | frag.appendChild(header); 56 | 57 | arr.forEach((f) => { 58 | const row = el('div', { class: 'font-row' }); 59 | 60 | const family = el('div', { class: 'family family-col' }, f.family || '(unknown)'); 61 | row.appendChild(family); 62 | 63 | const sample = el('div', { class: 'sample sample-col', style: `font-family: "${f.family}";` }, 'The quick brown fox'); 64 | row.appendChild(sample); 65 | 66 | const source = el('div', { class: `source source-col source-${f.source || 'system'}` }, (f.source || 'system').toUpperCase()); 67 | row.appendChild(source); 68 | 69 | const url = el('div', { class: 'url url-col', title: f.url || '(no URL)' }, f.url || '(system font)'); 70 | row.appendChild(url); 71 | 72 | const weight = el('div', { class: 'weight-style weight-col' }, (f.weight || 'normal') + (f.style && f.style !== 'normal' ? ' / ' + f.style : '')); 73 | row.appendChild(weight); 74 | 75 | frag.appendChild(row); 76 | }); 77 | return frag; 78 | } 79 | 80 | if (local.length) { 81 | container.appendChild(renderGroup('Loaded from this site', local)); 82 | } 83 | if (remote.length) { 84 | container.appendChild(renderGroup('Other fonts', remote)); 85 | } 86 | 87 | const filter = document.getElementById('filter'); 88 | filter.addEventListener('input', () => { 89 | const q = filter.value.trim().toLowerCase(); 90 | Array.from(container.querySelectorAll('.font-row')).forEach(row => { 91 | const familyEl = row.querySelector('.family'); 92 | const family = familyEl ? familyEl.textContent.toLowerCase() : ''; 93 | row.style.display = family.includes(q) ? '' : 'none'; 94 | }); 95 | Array.from(container.querySelectorAll('.group-header')).forEach(h => { 96 | let foundVisible = false; 97 | let node = h.nextSibling; 98 | while (node) { 99 | if (node.classList && node.classList.contains('group-header')) break; 100 | if (node.classList && node.classList.contains('font-row') && node.style.display !== 'none') { foundVisible = true; break; } 101 | node = node.nextSibling; 102 | } 103 | h.style.display = foundVisible ? '' : 'none'; 104 | }); 105 | }); 106 | 107 | window._fontData = fontObjects; 108 | } 109 | 110 | async function generateMarkdownTable(fonts, pageUrl) { 111 | const lines = []; 112 | lines.push(`**Site:** ${pageUrl}`); 113 | lines.push(''); 114 | lines.push('| Font Name | Source | URL |'); 115 | lines.push('|-----------|--------|-----|'); 116 | 117 | fonts.forEach(f => { 118 | const fontName = f.family || '(unknown)'; 119 | const source = (f.source || 'system').toUpperCase(); 120 | const url = f.url || '(system font)'; 121 | lines.push(`| ${fontName} | ${source} | ${url} |`); 122 | }); 123 | 124 | return lines.join('\n'); 125 | } 126 | 127 | async function copyFontsAsMarkdown(fonts, btn) { 128 | const tabs = await chrome.tabs.query({ active: true, currentWindow: true }); 129 | const pageUrl = tabs && tabs[0] ? tabs[0].url : 'unknown'; 130 | const markdown = await generateMarkdownTable(fonts, pageUrl); 131 | try { 132 | await navigator.clipboard.writeText(markdown); 133 | const orig = btn.textContent; 134 | btn.textContent = 'Copied!'; 135 | setTimeout(() => btn.textContent = orig, 1200); 136 | } catch (err) { 137 | console.error('Clipboard write failed', err); 138 | alert('Copy failed: ' + err.message); 139 | } 140 | } 141 | 142 | document.addEventListener('DOMContentLoaded', async () => { 143 | try { 144 | const raw = await getFontsFromTab(); 145 | console.log('Got raw fonts from tab:', raw); 146 | 147 | if (!Array.isArray(raw) || raw.length === 0) { 148 | console.log('No fonts returned'); 149 | renderFonts([]); 150 | return; 151 | } 152 | 153 | const fontObjects = Array.isArray(raw) ? raw.filter(Boolean) : []; 154 | console.log('Final fontObjects:', fontObjects); 155 | renderFonts(fontObjects); 156 | 157 | const copyAllBtn = document.getElementById('copyAll'); 158 | const copyLocalsBtn = document.getElementById('copyLocals'); 159 | 160 | copyAllBtn.onclick = () => copyFontsAsMarkdown(fontObjects, copyAllBtn); 161 | copyLocalsBtn.onclick = () => { 162 | const locals = fontObjects.filter(f => f && f.source === 'local'); 163 | copyFontsAsMarkdown(locals, copyLocalsBtn); 164 | }; 165 | } catch (err) { 166 | console.error('Error in DOMContentLoaded:', err); 167 | renderFonts([]); 168 | } 169 | }); 170 | -------------------------------------------------------------------------------- /content_script.js: -------------------------------------------------------------------------------- 1 | // content_script.js 2 | // Comprehensive font detection from multiple sources: computed styles, @font-face, loaded resources 3 | 4 | function unquote(s) { 5 | s = (s || '').trim(); 6 | if ((s.startsWith('"') && s.endsWith('"')) || (s.startsWith("'") && s.endsWith("'"))) { 7 | return s.slice(1, -1); 8 | } 9 | return s; 10 | } 11 | 12 | function extractFontsFromComputedStyles() { 13 | const set = new Set(); 14 | 15 | // Traverse all elements and check computed styles 16 | try { 17 | const walker = document.createTreeWalker( 18 | document.body || document.documentElement, 19 | NodeFilter.SHOW_ELEMENT, 20 | null, 21 | false 22 | ); 23 | 24 | let el; 25 | let count = 0; 26 | while ((el = walker.nextNode()) && count < 5000) { 27 | count++; 28 | try { 29 | const cs = window.getComputedStyle(el); 30 | if (!cs) continue; 31 | const fam = cs.getPropertyValue('font-family'); 32 | if (!fam) continue; 33 | 34 | fam.split(',').forEach(part => { 35 | const s = unquote(part); 36 | if (s && s.length > 0) set.add(s); 37 | }); 38 | } catch (err) { 39 | // ignore problematic elements 40 | } 41 | } 42 | } catch (err) { 43 | console.warn('Error traversing computed styles:', err); 44 | } 45 | 46 | // Check common text elements directly 47 | try { 48 | const selectors = ['body', 'p', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'span', 'div', 'a', 'button', 'input', 'label']; 49 | for (const selector of selectors) { 50 | try { 51 | const els = document.querySelectorAll(selector); 52 | for (let i = 0; i < Math.min(els.length, 100); i++) { 53 | try { 54 | const cs = window.getComputedStyle(els[i]); 55 | const fam = cs.getPropertyValue('font-family'); 56 | if (fam) { 57 | fam.split(',').forEach(part => { 58 | const s = unquote(part); 59 | if (s && s.length > 0) set.add(s); 60 | }); 61 | } 62 | } catch (err) { 63 | // skip 64 | } 65 | } 66 | } catch (err) { 67 | // skip selector 68 | } 69 | } 70 | } catch (err) { 71 | console.warn('Error checking common elements:', err); 72 | } 73 | 74 | return Array.from(set).sort(); 75 | } 76 | 77 | function collectFontFaceMap() { 78 | // Map font family name -> { urls, weights, styles } 79 | const map = new Map(); 80 | try { 81 | for (const sheet of Array.from(document.styleSheets)) { 82 | let rules; 83 | try { 84 | rules = sheet.cssRules; 85 | } catch (err) { 86 | // cross-origin stylesheet or CORS issue 87 | continue; 88 | } 89 | if (!rules) continue; 90 | 91 | for (const rule of Array.from(rules)) { 92 | // CSSRule.FONT_FACE_RULE = 5 93 | if (rule.type === 5 || (rule.cssText && rule.cssText.toLowerCase().includes('@font-face'))) { 94 | try { 95 | const style = rule.style; 96 | if (!style) continue; 97 | 98 | const ff = style.getPropertyValue('font-family'); 99 | if (!ff) continue; 100 | 101 | const family = unquote(ff); 102 | const src = style.getPropertyValue('src'); 103 | const weight = style.getPropertyValue('font-weight') || '400'; 104 | const fontStyle = style.getPropertyValue('font-style') || 'normal'; 105 | 106 | const urls = []; 107 | if (src) { 108 | const re = /url\((?:"|')?([^"')]+)(?:"|')?\)/g; 109 | let m; 110 | while ((m = re.exec(src)) !== null) { 111 | if (m[1]) { 112 | try { 113 | const abs = new URL(m[1], location.href).href; 114 | urls.push(abs); 115 | } catch (err) { 116 | urls.push(m[1]); 117 | } 118 | } 119 | } 120 | } 121 | 122 | if (!map.has(family)) { 123 | map.set(family, { urls: [], weights: new Set(), styles: new Set() }); 124 | } 125 | const entry = map.get(family); 126 | entry.urls.push(...urls); 127 | entry.weights.add(weight); 128 | entry.styles.add(fontStyle); 129 | } catch (err) { 130 | console.warn('Error parsing @font-face rule:', err); 131 | } 132 | } 133 | } 134 | } 135 | } catch (err) { 136 | console.warn('Error collecting @font-face rules:', err); 137 | } 138 | 139 | // Normalize to simple structure 140 | const result = new Map(); 141 | for (const [fam, data] of map.entries()) { 142 | result.set(fam, { 143 | urls: Array.from(new Set(data.urls)), 144 | weights: Array.from(data.weights).join(', '), 145 | styles: Array.from(data.styles).join(', ') 146 | }); 147 | } 148 | return result; 149 | } 150 | 151 | function getLoadedFontResources() { 152 | try { 153 | const entries = performance.getEntriesByType('resource') || []; 154 | const fonts = entries.filter(e => { 155 | if (e.initiatorType === 'font') return true; 156 | return /\.woff2?$|\.ttf$|\.otf$|\.eot(\?|$)/i.test(e.name || ''); 157 | }).map(e => e.name || e.uri || ''); 158 | return Array.from(new Set(fonts)); 159 | } catch (err) { 160 | return []; 161 | } 162 | } 163 | 164 | function buildFontEntries() { 165 | const families = extractFontsFromComputedStyles(); 166 | const ffMap = collectFontFaceMap(); 167 | const loadedResources = getLoadedFontResources(); 168 | 169 | console.log('Families:', families.length); 170 | console.log('FontFace map:', ffMap.size); 171 | console.log('Loaded resources:', loadedResources.length); 172 | 173 | const results = new Map(); 174 | 175 | // Add all families from computed styles 176 | families.forEach(family => { 177 | if (!results.has(family)) { 178 | results.set(family, { 179 | family, 180 | source: 'system', 181 | url: '', 182 | weight: '', 183 | style: '' 184 | }); 185 | } 186 | }); 187 | 188 | // Enrich with @font-face info 189 | for (const [fam, ffData] of ffMap.entries()) { 190 | const entry = results.get(fam) || { 191 | family: fam, 192 | source: 'system', 193 | url: '', 194 | weight: '', 195 | style: '' 196 | }; 197 | 198 | if (ffData.urls && ffData.urls.length > 0) { 199 | entry.url = ffData.urls[0]; 200 | try { 201 | entry.source = (new URL(ffData.urls[0], location.href)).origin === location.origin ? 'local' : 'remote'; 202 | } catch (err) { 203 | entry.source = 'remote'; 204 | } 205 | } 206 | entry.weight = ffData.weights || ''; 207 | entry.style = ffData.styles || ''; 208 | results.set(fam, entry); 209 | } 210 | 211 | // Add unmatched loaded resources 212 | const usedUrls = new Set(); 213 | for (const data of results.values()) { 214 | if (data.url) usedUrls.add(data.url); 215 | } 216 | 217 | for (const url of loadedResources) { 218 | if (!usedUrls.has(url)) { 219 | const basename = url.split('/').pop() || url; 220 | try { 221 | const isLocal = (new URL(url, location.href)).origin === location.origin; 222 | results.set(`[${basename}]`, { 223 | family: basename, 224 | source: isLocal ? 'local' : 'remote', 225 | url, 226 | weight: '', 227 | style: '' 228 | }); 229 | } catch (err) { 230 | results.set(`[${basename}]`, { 231 | family: basename, 232 | source: 'remote', 233 | url, 234 | weight: '', 235 | style: '' 236 | }); 237 | } 238 | } 239 | } 240 | 241 | return Array.from(results.values()); 242 | } 243 | 244 | chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => { 245 | if (msg && msg.type === 'getFonts') { 246 | try { 247 | const fonts = buildFontEntries(); 248 | console.log('Sending fonts:', fonts.length); 249 | sendResponse({ fonts }); 250 | } catch (err) { 251 | console.error('Error in getFonts handler:', err); 252 | sendResponse({ fonts: [] }); 253 | } 254 | return true; 255 | } 256 | }); 257 | 258 | window._fontAnalyzer_getFonts = buildFontEntries; 259 | --------------------------------------------------------------------------------