"],
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 |
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 |
--------------------------------------------------------------------------------