/** * Web Font Vacuum * A tool to find and extract web fonts from any website * * We sort of set up a pipeline where each step processes the data and passes it to the next: * 1. URL Input > 2. Fetch HTML > 3. Parse & Extract > 4. Process CSS > 5. Display Results * */ /** * Proxy List * Proxies are buggy, and temperamental...so why not use a whole lot of them! * List of CORS proxies we can try if the main one fails. * Keeps track of the last working proxy to optimize future requests. * @type {Array<{name: string, url: string, urlFormatter: (url: string) => string}>} */ const CORS_PROXIES = [ { name: 'allorigins', url: 'https://api.allorigins.win/raw?url=', urlFormatter: url => `https://api.allorigins.win/raw?url=${encodeURIComponent(url)}` }, { name: 'corsproxy.io', url: 'https://corsproxy.io/?', urlFormatter: url => `https://corsproxy.io/?${encodeURIComponent(url)}` }, { name: 'cors.sh', url: 'https://cors.sh/', urlFormatter: url => `https://cors.sh/${url}` }, { name: 'corsanywhere', url: 'https://cors-anywhere.herokuapp.com/', // FIXME: pretty certain this one doesn't work without human intervention urlFormatter: url => `https://cors-anywhere.herokuapp.com/${url}` }, { name: 'thingproxy', url: 'https://thingproxy.freeboard.io/fetch/', urlFormatter: url => `https://thingproxy.freeboard.io/fetch/${url}` } ]; // Keep track of which proxy worked last let lastWorkingProxyIndex = 0; let proxyFailureCount = 0; const MAX_PROXY_FAILURES = 3; async function fetchWithProxies(url, attempt = 0, isBinary = false) { // Start with the last working proxy const startIndex = lastWorkingProxyIndex; for (let i = 0; i < CORS_PROXIES.length; i++) { // Calculate the current proxy index, wrapping around if necessary const proxyIndex = (startIndex + i) % CORS_PROXIES.length; const proxy = CORS_PROXIES[proxyIndex]; try { console.log(`Trying proxy: ${proxy.name} for URL: ${url}`); const fetchOptions = { headers: { 'Accept': isBinary ? '*/*' : 'text/html,application/xhtml+xml,text/css', 'Origin': window.location.origin }, mode: 'cors' }; const response = await fetch(proxy.urlFormatter(url), fetchOptions); if (response.ok) { lastWorkingProxyIndex = proxyIndex; proxyFailureCount = 0; return response; } } catch (error) { console.log(`Proxy ${proxy.name} failed:`, error); proxyFailureCount++; // If we've had too many failures, wait a bit before continuing if (proxyFailureCount >= MAX_PROXY_FAILURES) { await new Promise(resolve => setTimeout(resolve, 1000)); proxyFailureCount = 0; } } } throw new Error('All proxies failed to fetch the resource'); } async function downloadFont(url, filename) { try { console.log('Downloading font from:', url); const response = await fetchWithProxies(url, 0, true); const arrayBuffer = await response.arrayBuffer(); if (arrayBuffer.byteLength === 0) { throw new Error('Received empty font file'); } // Convert ArrayBuffer to Blob with proper MIME type const blob = new Blob([arrayBuffer], { type: getFontMimeType(url) }); // Temporary link to trigger the download const objectUrl = URL.createObjectURL(blob); const link = document.createElement('a'); link.href = objectUrl; link.download = filename; document.body.appendChild(link); link.click(); // Small delay to ensure download starts before cleanup setTimeout(() => { document.body.removeChild(link); URL.revokeObjectURL(objectUrl); }, 100); return true; } catch (error) { console.error('Error downloading font:', error); alert(`Error downloading font: ${error.message}`); return false; } } // Assume the MIME type based on the file's extension function getFontMimeType(url) { const extension = url.split('.').pop().toLowerCase().split('?')[0]; switch (extension) { case 'woff': return 'font/woff'; case 'woff2': return 'font/woff2'; case 'ttf': return 'font/ttf'; case 'otf': return 'font/otf'; default: return 'application/octet-stream'; } } /** * Event listeners and initialization. * * 1. Enter URL and analyze * 2. Fetch the HTML through a CORS proxy * 3. Parse HTML to find: * - Direct font links * - Stylesheet links * - Inline styles * 4. Process each CSS source to find @font-face rules * 5. Display results with preview/download options */ document.addEventListener('DOMContentLoaded', () => { const urlInput = document.getElementById('urlInput'); const analyzeBtn = document.getElementById('analyzeBtn'); const resultsDiv = document.getElementById('results'); const errorDiv = document.getElementById('error'); /** * Two different methods to load the font: * - Using a data URL * - Fallback: Using a blob URL if data URL fails * * @param {string} url - The URL of the font file to preview * @param {string} fontFamily - The font-family name to use * @returns {Promise} - Whether the font was successfully loaded */ async function previewFont(url, fontFamily) { try { console.log('Loading font from:', url); const response = await fetchWithProxies(url, 0, true); const arrayBuffer = await response.arrayBuffer(); if (arrayBuffer.byteLength === 0) { throw new Error('Received empty font file'); } // Convert ArrayBuffer to Blob const blob = new Blob([arrayBuffer], { type: getFontMimeType(url) }); // Try using a data URL instead of a blob URL const reader = new FileReader(); return new Promise((resolve, reject) => { reader.onload = async function() { try { const dataUrl = reader.result; const fontFace = new FontFace(fontFamily, `url(${dataUrl})`, { style: 'normal', weight: '400', display: 'swap' }); const loadedFont = await fontFace.load(); document.fonts.add(loadedFont); resolve(true); } catch (loadError) { console.error('Font load error:', loadError); // Try fallback method with blob URL try { const fontUrl = URL.createObjectURL(blob); const fontFace = new FontFace(fontFamily, `url(${fontUrl})`, { style: 'normal', weight: '400', display: 'swap' }); const loadedFont = await fontFace.load(); document.fonts.add(loadedFont); URL.revokeObjectURL(fontUrl); resolve(true); } catch (fallbackError) { console.error('Font fallback load error:', fallbackError); reject(fallbackError); } } }; reader.onerror = function() { reject(new Error('Failed to read font file')); }; reader.readAsDataURL(blob); }); } catch (error) { console.error('Error loading font for preview:', error); return false; } } /** * Click handler for the Analyze button. * * 1. Validate input URL * 2. Fetch and parse webpage * 3. Look for fonts in: * - Direct links (preload, regular links) * - CSS files (external and inline) * - Common font directories * 4. Display results */ analyzeBtn.addEventListener('click', async () => { const url = urlInput.value.trim(); if (!url) { showError('Please enter a valid URL'); return; } try { // Clear previous results and errors resultsDiv.innerHTML = ''; errorDiv.style.display = 'none'; // Show loading state resultsDiv.innerHTML = '

Analyzing webpage. Sometimes this takes a while.

'; // Fetch the target webpage through the proxy system const response = await fetchWithProxies(url); const html = await response.text(); // Create a temporary DOM to parse the HTML const parser = new DOMParser(); const doc = parser.parseFromString(html, 'text/html'); // Find all potential font sources const fontUrls = new Set(); // Get the base URL (domain) const baseUrlObj = new URL(url); const domain = baseUrlObj.origin; // Brute force common font paths, for scenarios where the font is not found in the css const commonFontPaths = [ '/assets/fonts/', '/fonts/', '/assets/', '/css/fonts/', '/wp-content/themes/*/fonts/', '/static/fonts/' ]; console.log('Checking common font paths for:', domain); // Check for direct font links in the HTML extractDirectFontLinks(doc, url, fontUrls); // Check stylesheet links const cssPromises = Array.from(doc.querySelectorAll('link[rel="stylesheet"]')).map(link => { const href = link.getAttribute('href'); if (!href) return Promise.resolve(); const cssUrl = new URL(href, url).href; console.log('Processing CSS URL:', cssUrl); return processCssUrl(cssUrl, fontUrls, url); }); // Wait for all CSS files to be processed await Promise.all(cssPromises.filter(Boolean)); // Check style tags doc.querySelectorAll('style').forEach(style => { extractFontUrlsFromCss(style.textContent, url, fontUrls); }); // If no fonts found, try checking common paths if (fontUrls.size === 0) { console.log('No fonts found in CSS, checking common paths...'); const commonPathPromises = commonFontPaths.map(async path => { try { // Try to access the directory const directoryUrl = new URL(path, domain).href; console.log('Checking directory:', directoryUrl); // We can't list directory contents directly, but we can try common font names const commonFontNames = ['font', 'fonts', 'webfont', 'custom-font', 'main']; const fontExtensions = ['woff', 'woff2', 'ttf', 'otf']; for (const name of commonFontNames) { for (const ext of fontExtensions) { const fontUrl = `${directoryUrl}${name}.${ext}`; try { const fontResponse = await fetchWithProxies(fontUrl, 0, true); if (fontResponse.ok) { console.log('Found font at common path:', fontUrl); fontUrls.add({ url: fontUrl, family: 'Unknown Font', filename: `${name}.${ext}` }); } } catch (error) { // Ignore errors for common path checks } } } } catch (error) { // Ignore errors for common path checks } }); await Promise.all(commonPathPromises); } // Display results if (fontUrls.size === 0) { resultsDiv.innerHTML = '

No web fonts (WOFF/TTF/WOFF2/OTF) were found on this page.

'; } else { displayFontUrls(fontUrls); } } catch (error) { showError(`Error analyzing the webpage: ${error.message}`); console.error('Full error:', error); } }); /** * Processes a the URL of a CSS file to extract font information. * * @param {string} cssUrl - The URL of the CSS file to process * @param {Set} fontUrls - Set to store found font URLs * @param {string} baseUrl - Base URL for resolving relative paths */ async function processCssUrl(cssUrl, fontUrls, baseUrl) { try { console.log('Fetching CSS from:', cssUrl); const response = await fetchWithProxies(cssUrl); const css = await response.text(); // Extract font URLs from the CSS content extractFontUrlsFromCss(css, cssUrl, fontUrls); } catch (error) { console.error(`Error processing CSS from ${cssUrl}:`, error); } } /** * Extract font URLs from CSS content * * @param {string} css - The CSS content to process * @param {string} cssUrl - The URL of the CSS file (for resolving relative paths) * @param {Set} fontUrls - Set to store found font URLs */ function extractFontUrlsFromCss(css, cssUrl, fontUrls) { // Get the base URL for resolving relative paths const baseUrl = new URL(cssUrl).origin; // Match @font-face blocks const fontFaceRegex = /@font-face\s*{[^}]*}/g; const urlRegex = /url\(['"]?([^'"\)]+)['"]?\)/g; const fontFamilyRegex = /font-family\s*:\s*['"]?([^'";]*)['"]?/; let fontFaceMatch; while ((fontFaceMatch = fontFaceRegex.exec(css)) !== null) { const fontFaceBlock = fontFaceMatch[0]; // Extract font-family name const familyMatch = fontFaceBlock.match(fontFamilyRegex); const fontFamily = familyMatch ? familyMatch[1].trim() : 'Unknown Font'; // Clean up the CSS rule for display const cleanRule = fontFaceBlock.replace(/\s+/g, ' ').trim(); // Extract all URLs from this @font-face block let urlMatch; while ((urlMatch = urlRegex.exec(fontFaceBlock)) !== null) { try { let fontUrl = urlMatch[1].trim(); // Skip data: URLs if (fontUrl.startsWith('data:')) { console.log('Skipping data: URL font'); continue; } // Only process known font file types if (!fontUrl.match(/\.(woff2?|ttf|otf|eot)(\?.*)?$/i)) { continue; } // Resolve relative URLs if (fontUrl.startsWith('//')) { fontUrl = 'https:' + fontUrl; } else if (!fontUrl.startsWith('http')) { fontUrl = new URL(fontUrl, cssUrl).href; } const filename = fontUrl.split('/').pop().split('?')[0]; console.log(`Found font in CSS: ${fontUrl} (${fontFamily})`); fontUrls.add({ url: fontUrl, family: fontFamily, filename: filename, cssRule: cleanRule }); } catch (error) { console.error('Error processing font URL:', urlMatch[1], error); } } } } /** * Find direct font links in HTML. * Checks two types of links: * 1. Preload links with as="font" * 2. Regular tags pointing to font files * * @param {Document} doc - Parsed HTML document * @param {string} baseUrl - Base URL for resolving relative paths * @param {Set} fontUrls - Set to store found font URLs */ function extractDirectFontLinks(doc, baseUrl, fontUrls) { // Check for preload links doc.querySelectorAll('link[rel="preload"][as="font"], link[rel="stylesheet"]').forEach(link => { const href = link.getAttribute('href'); if (href && href.match(/\.(woff|woff2|ttf|otf|css)(\?.*)?$/i)) { try { const absoluteUrl = new URL(href, baseUrl).href; if (href.match(/\.css(\?.*)?$/i)) { processCssUrl(absoluteUrl, fontUrls, baseUrl); } else { const filename = href.split('/').pop().split('?')[0]; let fontFamilyName = 'Unknown Font'; if (link.dataset.fontFamily) { fontFamilyName = link.dataset.fontFamily; } console.log(`Found preloaded font: ${absoluteUrl} (${fontFamilyName})`); fontUrls.add({ url: absoluteUrl, family: fontFamilyName, filename: filename, cssRule: `@font-face { font-family: "${fontFamilyName}"; src: url("${absoluteUrl}") format("${getFormatFromFilename(filename)}"); }` }); } } catch (error) { console.error('Error resolving font URL:', href, error); } } }); } /** * Get the format string for a font file based on its filename * @param {string} filename * @returns {string} */ function getFormatFromFilename(filename) { const ext = filename.split('.').pop().toLowerCase(); switch (ext) { case 'woff2': return 'woff2'; case 'woff': return 'woff'; case 'ttf': return 'truetype'; case 'otf': return 'opentype'; default: return ext; } } /** * The V of MVC * * - Auto-preview for 3 or fewer fonts * - Manual preview toggle for 4+ fonts * - Download buttons * - Font information display * - CSS rule display * - Preview with multiple sizes * * @param {Array} urls - Array of font data objects to display */ async function displayFontUrls(urls) { const resultsDiv = document.getElementById('results'); resultsDiv.innerHTML = ''; let fontsArray = Array.isArray(urls) ? urls : Array.from(urls); const fontFamilies = new Map(); fontsArray.forEach(fontData => { if (!fontFamilies.has(fontData.family)) { fontFamilies.set(fontData.family, { variants: [], // Keep the first CSS rule as the base rule cssRule: fontData.cssRule }); } fontFamilies.get(fontData.family).variants.push(fontData); }); fontsArray = Array.from(fontFamilies.entries()).map(([family, data]) => ({ family, variants: data.variants, cssRule: data.cssRule })); if (fontsArray.length === 0) { resultsDiv.innerHTML = '

No fonts found on this webpage.

'; return; } const container = document.createElement('div'); container.style.display = 'flex'; container.style.flexDirection = 'column'; container.style.gap = '2rem'; container.style.maxWidth = '800px'; container.style.margin = '0 auto'; const shouldAutoPreview = fontsArray.length < 4; for (let fontData of fontsArray) { const fontItem = document.createElement('div'); fontItem.style.border = '2px solid var(--dark)'; fontItem.style.padding = '1rem'; fontItem.style.background = 'var(--beige)'; fontItem.style.position = 'relative'; const accentBar = document.createElement('div'); accentBar.style.position = 'absolute'; accentBar.style.top = '0'; accentBar.style.left = '0'; accentBar.style.right = '0'; accentBar.style.height = '4px'; accentBar.style.background = 'var(--accent)'; fontItem.appendChild(accentBar); const fontInfo = document.createElement('div'); fontInfo.style.marginTop = '0.5rem'; const fontName = document.createElement('h3'); fontName.style.margin = '0'; fontName.style.textTransform = 'uppercase'; fontName.textContent = fontData.family; fontInfo.appendChild(fontName); // Show all available formats const formatContainer = document.createElement('div'); formatContainer.style.display = 'flex'; formatContainer.style.gap = '0.5rem'; formatContainer.style.flexWrap = 'wrap'; formatContainer.style.marginTop = '0.5rem'; const uniqueFormats = new Set(fontData.variants.map(v => v.filename.split('.').pop().toUpperCase())); uniqueFormats.forEach(format => { const formatBadge = document.createElement('div'); formatBadge.style.display = 'inline-block'; formatBadge.style.background = 'var(--dark)'; formatBadge.style.color = 'var(--beige)'; formatBadge.style.padding = '0.2rem 0.5rem'; formatBadge.style.fontSize = '0.8rem'; formatBadge.textContent = format; formatContainer.appendChild(formatBadge); }); fontInfo.appendChild(formatContainer); const previewContainer = document.createElement('div'); previewContainer.style.marginTop = '1rem'; previewContainer.style.padding = '1rem'; previewContainer.style.border = '1px dashed var(--dark)'; // Assume hidden if not auto-previewing if (!shouldAutoPreview) { previewContainer.style.display = 'none'; } const previewLabel = document.createElement('div'); previewLabel.style.fontWeight = 'bold'; previewLabel.style.marginBottom = '0.5rem'; previewLabel.textContent = 'Preview'; previewContainer.appendChild(previewLabel); // Create preview for each style variation const previewText = 'Society for me my misery
Since Gift of Thee --'; // Emily Dickinson const styleVariations = document.createElement('div'); styleVariations.style.display = 'flex'; styleVariations.style.flexDirection = 'column'; styleVariations.style.gap = '1rem'; // Preview for each variant fontData.variants.forEach((variant, index) => { const variantPreview = document.createElement('div'); variantPreview.style.marginBottom = '1rem'; variantPreview.id = `preview-${variant.filename}-${index}`; variantPreview.innerHTML = previewText; // Extract style information from CSS rule let styleInfo = 'Regular'; if (variant.cssRule) { const fontStyle = variant.cssRule.match(/font-style:\s*([^;]+)/); const fontWeight = variant.cssRule.match(/font-weight:\s*([^;]+)/); if (fontStyle || fontWeight) { styleInfo = [ fontWeight?.[1] || '', fontStyle?.[1] !== 'normal' ? fontStyle?.[1] : '' ].filter(Boolean).join(' ') || 'Regular'; } } const styleLabel = document.createElement('div'); styleLabel.style.fontSize = '0.8rem'; styleLabel.style.color = 'var(--dark)'; styleLabel.style.marginBottom = '0.25rem'; styleLabel.textContent = styleInfo; const variantContainer = document.createElement('div'); variantContainer.appendChild(styleLabel); variantContainer.appendChild(variantPreview); styleVariations.appendChild(variantContainer); }); previewContainer.appendChild(styleVariations); if (fontData.cssRule) { const cssContainer = document.createElement('div'); cssContainer.style.marginTop = '1rem'; cssContainer.style.marginBottom = '1rem'; cssContainer.style.padding = '1rem'; cssContainer.style.background = 'var(--dark)'; cssContainer.style.color = 'var(--beige)'; cssContainer.style.borderRadius = '4px'; cssContainer.style.position = 'relative'; const cssLabel = document.createElement('div'); cssLabel.style.position = 'absolute'; cssLabel.style.top = '-10px'; cssLabel.style.left = '10px'; cssLabel.style.background = 'var(--accent)'; cssLabel.style.color = 'var(--dark)'; cssLabel.style.padding = '0 0.5rem'; cssLabel.style.fontSize = '0.8rem'; cssLabel.style.fontWeight = 'bold'; cssLabel.textContent = '@font-face'; cssContainer.appendChild(cssLabel); const cssContent = document.createElement('pre'); cssContent.style.margin = '0'; cssContent.style.fontFamily = 'monospace'; cssContent.style.fontSize = '0.9rem'; cssContent.style.whiteSpace = 'pre-wrap'; cssContent.style.wordBreak = 'break-all'; const allCssRules = fontData.variants.map(variant => { if (!variant.cssRule) return ''; return variant.cssRule .replace(/{/, ' {\n ') .replace(/;/g, ';\n ') .replace(/}/g, '\n}') .replace(/\s+}/g, '}') .trim(); }).join('\n\n'); cssContent.textContent = allCssRules; cssContainer.appendChild(cssContent); fontInfo.appendChild(cssContainer); } const sizeVariations = document.createElement('div'); sizeVariations.style.borderTop = '1px solid var(--dark)'; sizeVariations.style.paddingTop = '0.5rem'; sizeVariations.style.marginTop = '0.5rem'; [12, 18, 24].forEach(size => { const sizePreview = document.createElement('div'); sizePreview.style.fontSize = `${size}px`; sizePreview.textContent = `${size}px - The quick brown fox jumps over the lazy dog 0123456789`; sizeVariations.appendChild(sizePreview); }); previewContainer.appendChild(sizeVariations); const buttonContainer = document.createElement('div'); buttonContainer.style.display = 'flex'; buttonContainer.style.gap = '0.5rem'; buttonContainer.style.marginTop = '1rem'; buttonContainer.style.flexWrap = 'wrap'; const uniqueDownloads = new Map(); fontData.variants.forEach(variant => { if (!uniqueDownloads.has(variant.url)) { uniqueDownloads.set(variant.url, { filename: variant.filename, url: variant.url }); } }); uniqueDownloads.forEach(({filename, url}) => { const downloadBtn = document.createElement('button'); downloadBtn.textContent = `⬇ Download ${filename}`; downloadBtn.style.flex = '1'; downloadBtn.addEventListener('click', () => downloadFont(url, filename)); buttonContainer.appendChild(downloadBtn); }); if (!shouldAutoPreview) { const previewBtn = document.createElement('button'); previewBtn.textContent = '👁 Preview'; previewBtn.style.flex = '1'; let isPreviewVisible = false; previewBtn.addEventListener('click', async () => { if (!isPreviewVisible) { previewContainer.style.display = 'block'; const loadPromises = fontData.variants.map(async (variant, index) => { const previewElement = document.getElementById(`preview-${variant.filename}-${index}`); if (await previewFont(variant.url, fontData.family)) { previewElement.style.fontFamily = fontData.family; if (variant.cssRule) { const fontStyle = variant.cssRule.match(/font-style:\s*([^;]+)/); const fontWeight = variant.cssRule.match(/font-weight:\s*([^;]+)/); if (fontStyle) previewElement.style.fontStyle = fontStyle[1]; if (fontWeight) previewElement.style.fontWeight = fontWeight[1]; } } }); await Promise.all(loadPromises); sizeVariations.querySelectorAll('div').forEach(div => { div.style.fontFamily = fontData.family; }); previewBtn.textContent = '👁 Hide Preview'; isPreviewVisible = true; } else { previewContainer.style.display = 'none'; previewBtn.textContent = '👁 Preview'; isPreviewVisible = false; } }); buttonContainer.appendChild(previewBtn); } else { // Auto-preview for all variants setTimeout(async () => { const loadPromises = fontData.variants.map(async (variant, index) => { const previewElement = document.getElementById(`preview-${variant.filename}-${index}`); if (await previewFont(variant.url, fontData.family)) { previewElement.style.fontFamily = fontData.family; if (variant.cssRule) { const fontStyle = variant.cssRule.match(/font-style:\s*([^;]+)/); const fontWeight = variant.cssRule.match(/font-weight:\s*([^;]+)/); if (fontStyle) previewElement.style.fontStyle = fontStyle[1]; if (fontWeight) previewElement.style.fontWeight = fontWeight[1]; } } }); await Promise.all(loadPromises); sizeVariations.querySelectorAll('div').forEach(div => { div.style.fontFamily = fontData.family; }); }, 100); } fontItem.appendChild(fontInfo); fontItem.appendChild(previewContainer); fontItem.appendChild(buttonContainer); container.appendChild(fontItem); } resultsDiv.appendChild(container); } function showError(message) { errorDiv.textContent = message; errorDiv.style.display = 'block'; } });