diff options
Diffstat (limited to 'html/immoral')
-rw-r--r-- | html/immoral/app.js | 825 | ||||
-rw-r--r-- | html/immoral/bookmarklet.js | 564 | ||||
-rw-r--r-- | html/immoral/index.html | 221 |
3 files changed, 1610 insertions, 0 deletions
diff --git a/html/immoral/app.js b/html/immoral/app.js new file mode 100644 index 0000000..3e6cfa7 --- /dev/null +++ b/html/immoral/app.js @@ -0,0 +1,825 @@ +/** + * 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<boolean>} - 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 = '<p>Analyzing webpage. Sometimes this takes a while.</p>'; + + // 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 = '<p>No web fonts (WOFF/TTF/WOFF2/OTF) were found on this page.</p>'; + } 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 <a> 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 = '<p>No fonts found on this webpage.</p>'; + 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<br>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'; + } +}); \ No newline at end of file diff --git a/html/immoral/bookmarklet.js b/html/immoral/bookmarklet.js new file mode 100644 index 0000000..7f61ec4 --- /dev/null +++ b/html/immoral/bookmarklet.js @@ -0,0 +1,564 @@ +(function() { + // Prevent multiple instances from running at once + if (window.immoralFontVacuum) { + alert('Web Font Vacuum is already running!'); + return; + } + window.immoralFontVacuum = true; + + const logCollector = { + logs: [], + group: function(label) { + this.logs.push(`\n### ${label}`); + }, + groupEnd: function() { + this.logs.push(`### End Group\n`); + }, + log: function(...args) { + this.logs.push(args.map(arg => + typeof arg === 'object' ? JSON.stringify(arg, null, 2) : arg + ).join(' ')); + }, + warn: function(...args) { + this.logs.push(`⚠️ ${args.map(arg => + typeof arg === 'object' ? JSON.stringify(arg, null, 2) : arg + ).join(' ')}`); + }, + error: function(...args) { + this.logs.push(`❌ ${args.map(arg => + typeof arg === 'object' ? JSON.stringify(arg, null, 2) : arg + ).join(' ')}`); + }, + getReport: function() { + return `Font Vacuum Report +================== +Time: ${new Date().toISOString()} +URL: ${window.location.href} + +${this.logs.join('\n')}`; + } + }; + + const styleRoot = document.createElement('div'); + styleRoot.className = 'fv-root'; + styleRoot.style.all = 'initial'; // Reset all styles + + const style = document.createElement('style'); + style.textContent = ` + .fv-root { + font: 16px system-ui, -apple-system, sans-serif; + color: #333333; + line-height: 1.4; + box-sizing: border-box; + } + + .fv-root * { + box-sizing: inherit; + font-family: inherit; + line-height: inherit; + color: inherit; + } + + .fv-container { + position: fixed; + top: 20px; + right: 20px; + width: 400px; + max-height: 90vh; + background: #f5f5f5; + z-index: 999999; + overflow-y: auto; + display: flex; + flex-direction: column; + border: 3px solid #333; + box-shadow: 8px 8px 0 #ff4d00; + } + + .fv-header { + padding: 1rem; + background: #333333; + color: #f5f5f5; + display: flex; + justify-content: space-between; + align-items: center; + cursor: move; + user-select: none; + flex-shrink: 0; + } + + .fv-header h1 { + margin: 0; + font-size: 1.1rem; + line-height: 1; + } + + .fv-close { + background: none; + border: none; + color: #f5f5f5; + cursor: pointer; + font-size: 1.5rem; + padding: 0; + margin: 0; + line-height: 1; + display: flex; + align-items: center; + } + + .fv-content { + padding: 1rem; + overflow-y: auto; + flex-grow: 1; + } + + .fv-footer { + padding: 0.75rem 1rem; + background: #333333; + color: #f5f5f5; + display: flex; + justify-content: flex-end; + align-items: center; + flex-shrink: 0; + } + + .fv-footer-button { + background: #555; + color: #f5f5f5; + border: none; + padding: 0.5rem 1rem; + cursor: pointer; + font-size: 0.9em; + display: flex; + align-items: center; + gap: 0.5rem; + } + + .fv-footer-button:hover { + background: #666; + } + + .fv-font-item { + margin-bottom: 1rem; + padding: 1rem; + border: 1px solid #ddd; + background: #ffffff; + } + + .fv-font-item h3 { + margin: 0 0 1rem 0; + padding: 0; + font-size: 1.1em; + font-weight: 600; + } + + .fv-preview { + margin: 1rem 0; + padding: 1rem; + border: 1px dashed #333; + background: #ffffff; + } + + .fv-button { + background: #333; + color: #f5f5f5; + border: none; + padding: 0.5rem 1rem; + cursor: pointer; + margin: 0.25rem 0.5rem 0.25rem 0; + font-size: 0.9em; + } + + .fv-button:last-child { + margin-right: 0; + } + + .fv-button:hover { + background: #444; + } + `; + styleRoot.appendChild(style); + document.body.appendChild(styleRoot); + + const container = document.createElement('div'); + container.className = 'fv-container'; + styleRoot.appendChild(container); + + let isDragging = false; + let currentX; + let currentY; + let initialX; + let initialY; + let xOffset = 0; + let yOffset = 0; + + const header = document.createElement('header'); + header.className = 'fv-header'; + header.innerHTML = ` + <h1>Web Font Vacuum</h1> + <button class="fv-close">×</button> + `; + + header.addEventListener('mousedown', dragStart); + document.addEventListener('mousemove', drag); + document.addEventListener('mouseup', dragEnd); + + function dragStart(e) { + initialX = e.clientX - xOffset; + initialY = e.clientY - yOffset; + if (e.target === header) { + isDragging = true; + } + } + + function drag(e) { + if (isDragging) { + e.preventDefault(); + currentX = e.clientX - initialX; + currentY = e.clientY - initialY; + xOffset = currentX; + yOffset = currentY; + container.style.transform = `translate(${currentX}px, ${currentY}px)`; + } + } + + function dragEnd() { + isDragging = false; + } + + header.querySelector('.fv-close').addEventListener('click', () => { + document.body.removeChild(styleRoot); + window.immoralFontVacuum = false; + }); + + const content = document.createElement('div'); + content.className = 'fv-content'; + + function extractFontUrls(cssText, baseUrl) { + const fontUrls = []; + const fontFaceRegex = /@font-face\s*{[^}]*}/g; + const urlRegex = /url\(['"]?([^'"\)]+)['"]?\)/g; + const fontFamilyRegex = /font-family\s*:\s*['"]?([^'";]*)['"]?/; + + function resolveUrl(url, base) { + try { + // Protocol-relative URLs + if (url.startsWith('//')) { + return `${location.protocol}${url}`; + } + // Absolute URLs + if (url.match(/^https?:\/\//)) { + return url; + } + // Root-relative URLs + if (url.startsWith('/')) { + return `${location.origin}${url}`; + } + // Relative URLs - use stylesheet URL as base if available + return new URL(url, base || location.href).href; + } catch (e) { + console.warn('Failed to resolve URL:', url, e); + return url; + } + } + + let fontFaceMatch; + while ((fontFaceMatch = fontFaceRegex.exec(cssText)) !== null) { + const fontFaceBlock = fontFaceMatch[0]; + const familyMatch = fontFaceBlock.match(fontFamilyRegex); + const fontFamily = familyMatch ? familyMatch[1].trim() : 'Unknown Font'; + + let urlMatch; + while ((urlMatch = urlRegex.exec(fontFaceBlock)) !== null) { + let fontUrl = urlMatch[1].trim(); + + // Skip data: URLs + if (fontUrl.startsWith('data:')) continue; + + // Only process known font file types + if (!fontUrl.match(/\.(woff2?|ttf|otf|eot)(\?.*)?$/i)) continue; + + // Resolve the URL relative to the stylesheet's URL + fontUrl = resolveUrl(fontUrl, baseUrl); + + fontUrls.push({ + family: fontFamily, + url: fontUrl, + filename: fontUrl.split('/').pop().split('?')[0], + cssRule: fontFaceBlock + }); + } + } + return fontUrls; + } + + function findFonts() { + const fonts = new Map(); + logCollector.group('Font Vacuum: Scanning Stylesheets'); + + logCollector.log(`Found ${document.styleSheets.length} stylesheets`); + + for (const sheet of document.styleSheets) { + try { + const baseUrl = sheet.href; + logCollector.group(`Stylesheet: ${baseUrl || 'inline'}`); + const cssRules = sheet.cssRules || sheet.rules; + logCollector.log(`- Rules found: ${cssRules.length}`); + + let cssText = ''; + let fontFaceCount = 0; + for (const rule of cssRules) { + if (rule.constructor.name === 'CSSFontFaceRule') { + fontFaceCount++; + } + cssText += rule.cssText + '\n'; + } + logCollector.log(`- @font-face rules found: ${fontFaceCount}`); + + const fontUrls = extractFontUrls(cssText, baseUrl); + logCollector.log(`- Font URLs extracted: ${fontUrls.length}`); + fontUrls.forEach(font => { + logCollector.log(` • ${font.family}: ${font.url}`); + if (!fonts.has(font.family)) { + fonts.set(font.family, { + variants: [], + cssRule: font.cssRule + }); + } + fonts.get(font.family).variants.push(font); + }); + logCollector.groupEnd(); + } catch (e) { + logCollector.warn(`Could not access stylesheet:`, sheet.href, e); + logCollector.groupEnd(); + } + } + + const results = Array.from(fonts.entries()).map(([family, data]) => ({ + family, + variants: data.variants, + cssRule: data.cssRule + })); + + logCollector.log('Final Results:', { + totalFamilies: results.length, + families: results.map(f => ({ + family: f.family, + variants: f.variants.length, + urls: f.variants.map(v => v.url) + })) + }); + logCollector.groupEnd(); + + return results; + } + + async function downloadFont(url, filename) { + try { + logCollector.group(`Font Vacuum: Downloading ${filename} from ${url}`); + + logCollector.log('Searching for existing font-face rule...'); + const existingFontRule = Array.from(document.styleSheets) + .flatMap(sheet => { + try { + return Array.from(sheet.cssRules); + } catch (e) { + return []; + } + }) + .find(rule => + rule.constructor.name === 'CSSFontFaceRule' && + rule.cssText.includes(url) + ); + + logCollector.log('Existing font-face rule found:', !!existingFontRule); + let response; + + if (existingFontRule) { + logCollector.log('Attempting to fetch using existing rule credentials...'); + const fontBlob = await fetch(url, { + mode: 'cors', + credentials: 'include', + headers: { + 'Origin': window.location.origin + } + }).then(r => r.blob()); + response = new Response(fontBlob); + } else { + logCollector.log('No existing rule found, attempting direct fetch...'); + response = await fetch(url, { + mode: 'cors', + credentials: 'include', + headers: { + 'Origin': window.location.origin + } + }); + } + + if (!response.ok) { + throw new Error(`Network response was not ok. Status: ${response.status}`); + } + + logCollector.log('Font fetched successfully, preparing download...'); + const blob = await response.blob(); + logCollector.log('Font blob size:', blob.size, 'bytes'); + + const objectUrl = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = objectUrl; + link.download = filename; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + + setTimeout(() => URL.revokeObjectURL(objectUrl), 100); + logCollector.log('Download initiated successfully'); + logCollector.groupEnd(); + return true; + } catch (error) { + logCollector.error('Error downloading font:', error); + logCollector.groupEnd(); + alert(`Error downloading font: ${error.message}\n\nTroubleshooting tips:\n1. Check the console for detailed logs\n2. Try using your browser's developer tools Network tab to find and download the font file directly\n3. Some sites may block direct font downloads`); + return false; + } + } + + async function previewFont(url, fontFamily) { + try { + logCollector.group(`Font Vacuum: Previewing ${fontFamily} from ${url}`); + + const existingFontRule = Array.from(document.styleSheets) + .flatMap(sheet => { + try { + return Array.from(sheet.cssRules); + } catch (e) { + return []; + } + }) + .find(rule => + rule.constructor.name === 'CSSFontFaceRule' && + rule.cssText.includes(url) + ); + + if (existingFontRule) { + logCollector.log('Using existing font-face rule for preview'); + logCollector.groupEnd(); + return true; + } + + logCollector.log('No existing rule found, attempting to load font...'); + const response = await fetch(url, { + mode: 'cors', + credentials: 'include', + headers: { + 'Origin': window.location.origin + } + }); + + if (!response.ok) { + throw new Error(`Network response was not ok. Status: ${response.status}`); + } + + const blob = await response.blob(); + logCollector.log('Font blob size:', blob.size, 'bytes'); + 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); + + logCollector.log('Font loaded successfully'); + logCollector.groupEnd(); + return true; + } catch (error) { + logCollector.error('Error loading font:', error); + logCollector.groupEnd(); + return false; + } + } + + const fonts = findFonts(); + + if (fonts.length === 0) { + content.innerHTML = '<p>No web fonts found on this page.</p>'; + } else { + fonts.forEach(fontData => { + const fontItem = document.createElement('div'); + fontItem.className = 'fv-font-item'; + + const fontName = document.createElement('h3'); + fontName.style.margin = '0 0 1rem 0'; + fontName.textContent = fontData.family; + fontItem.appendChild(fontName); + + const preview = document.createElement('div'); + preview.className = 'fv-preview'; + preview.innerHTML = '0123456789<br><br>Society for me my misery<br>Since Gift of Thee --<br><br>The quick brown fox jumps over the lazy dog!?'; + fontItem.appendChild(preview); + + const uniqueDownloads = new Map(); + fontData.variants.forEach(variant => { + if (!uniqueDownloads.has(variant.url)) { + uniqueDownloads.set(variant.url, { + filename: variant.filename, + url: variant.url + }); + } + }); + + const buttonContainer = document.createElement('div'); + buttonContainer.style.marginTop = '1rem'; + + uniqueDownloads.forEach(({filename, url}) => { + const downloadBtn = document.createElement('button'); + downloadBtn.className = 'fv-button'; + downloadBtn.textContent = `⬇ Download ${filename}`; + downloadBtn.addEventListener('click', () => downloadFont(url, filename)); + buttonContainer.appendChild(downloadBtn); + }); + + fontItem.appendChild(buttonContainer); + + fontData.variants.forEach(async (variant) => { + if (await previewFont(variant.url, fontData.family)) { + preview.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) preview.style.fontStyle = fontStyle[1]; + if (fontWeight) preview.style.fontWeight = fontWeight[1]; + } + } + }); + + content.appendChild(fontItem); + }); + } + + const footer = document.createElement('div'); + footer.className = 'fv-footer'; + + const reportBtn = document.createElement('button'); + reportBtn.className = 'fv-footer-button'; + reportBtn.innerHTML = '<span>📋</span><span>Copy Debug Report</span>'; + reportBtn.addEventListener('click', () => { + const report = logCollector.getReport(); + navigator.clipboard.writeText(report).then(() => { + reportBtn.innerHTML = '<span>✅</span><span>Report Copied!</span>'; + setTimeout(() => { + reportBtn.innerHTML = '<span>📋</span><span>Copy Debug Report</span>'; + }, 2000); + }); + }); + + footer.appendChild(reportBtn); + container.appendChild(header); + container.appendChild(content); + container.appendChild(footer); + styleRoot.appendChild(container); +})(); \ No newline at end of file diff --git a/html/immoral/index.html b/html/immoral/index.html new file mode 100644 index 0000000..b7d3ca4 --- /dev/null +++ b/html/immoral/index.html @@ -0,0 +1,221 @@ +<!DOCTYPE html> +<html lang="en"> +<head> + <meta charset="UTF-8"> + <meta name="viewport" content="width=device-width, initial-scale=1.0"> + <title>An Immoral Web Font Vacuum</title> + <meta name="description" content="Enter a URL to find, preview, and download web fonts (WOFF/TTF/WOFF2/OTF) present on the page."> + <style> + :root { + --beige: #f5f2e9; + --dark: #111111; + --accent: #ff4d00; + --grid-line: #ccbea7; + --container-bg: #ffffff; + --focus-outline: #2563eb; + } + + body { + font-family: 'Iowan Old Style', 'Palatino Linotype', 'URW Palladio L', P052, serif; + max-width: 900px; + margin: 0 auto; + padding: 1rem; + line-height: 1.5; + background: var(--beige); + color: var(--dark); + } + + h1, h2 { + text-transform: uppercase; + letter-spacing: 2px; + border-bottom: 3px solid var(--accent); + padding-bottom: 0.5rem; + font-weight: 900; + } + + .container { + background: var(--container-bg); + padding: 2rem; + border: 3px solid var(--dark); + box-shadow: 8px 8px 0 var(--dark); + margin-top: 2rem; + } + + .input-group { + display: flex; + gap: 1rem; + margin-bottom: 2rem; + border: 2px solid var(--dark); + padding: 1rem; + background: var(--beige); + } + + @media (max-width: 600px) { + .input-group { + flex-direction: column; + gap: 0.75rem; + } + + .input-group button { + width: 100%; + } + } + + .sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; + } + + input[type="url"] { + flex: 1; + padding: 0.75rem; + font-size: 1rem; + border: 2px solid var(--dark); + background: var(--beige); + font-family: 'Courier New', monospace; + } + + input[type="url"]:focus { + outline: 3px solid var(--focus-outline); + outline-offset: 2px; + } + + button { + padding: 0.75rem 1.5rem; + font-size: 1rem; + background: var(--dark); + color: var(--beige); + border: 2px solid var(--dark); + cursor: pointer; + text-transform: uppercase; + font-weight: bold; + font-family: 'Courier New', monospace; + transition: all 0.2s; + } + + button:hover, + button:focus-visible { + background: var(--accent); + transform: translateY(-2px); + outline: 3px solid var(--focus-outline); + outline-offset: 2px; + } + + button:focus:not(:focus-visible) { + outline: none; + } + + .error { + color: var(--accent); + padding: 1rem; + background: rgba(255, 77, 0, 0.1); + border: 2px solid var(--accent); + margin-top: 1rem; + display: none; + font-weight: bold; + } + + #results { + margin-top: 1rem; + } + + .immoral { + color: #cc0000; + text-shadow: + 0 0 1px #fff, + 0 0 3px #fff, + 0 0 5px #ff0000, + 2px 2px 0 #600; + font-weight: 900; + letter-spacing: 2px; + transform: skew(-5deg); + display: inline-block; + position: relative; + padding: 0 4px; + } + + .immoral::first-letter { + font-size: 1.2em; + text-shadow: + 0 0 1px #fff, + 0 0 3px #fff, + 0 0 5px #ff0000, + 2px 2px 0 #800; + } + + footer { + margin-top: 1rem; + margin-bottom: 2rem; + } + + footer abbr { + color: var(--accent); + text-decoration: none; + border-bottom: 1px dotted var(--dark); + } + + footer abbr:hover { + cursor: help; + } + </style> +</head> +<body> + <main class="container"> + <h1><span class="immoral">Immoral</span> Web Font Vacuum</h1> + <p>Enter a URL to find, preview, and download web fonts (WOFF/TTF/WOFF2/OTF) present on the page.</p> + <section id="urlForm"> + <form class="input-group" role="search" aria-label="Website URL search form" onsubmit="event.preventDefault();"> + <label for="urlInput" class="sr-only">Website URL</label> + <input + type="url" + id="urlInput" + name="urlInput" + placeholder="Enter website URL (e.g., https://example.com)" + required + aria-required="true" + aria-describedby="urlHint" + > + <span id="urlHint" class="sr-only">Enter the full website URL including https:// or http://</span> + <button + id="analyzeBtn" + type="submit" + aria-label="Find all web fonts on the website" + >Find Fonts</button> + </form> + </section> + <section id="error" class="error" role="alert" aria-live="polite"></section> + <section id="results" role="region" aria-label="Font analysis results"></section> + <section class="bookmarklet-section" style="margin: 2rem 0; padding: 1rem; border: 2px dashed var(--dark); background: var(--beige);"> + <h2>Web Font Vacuum Bookmarklet</h2> + <p>Drag this link to your bookmarks bar to vacuum web fonts from any webpage:</p> + <p style="text-align: center;"> + <a href="javascript:(function(){ + const script = document.createElement('script'); + script.src = 'https://smallandnearlysilent.com/immoral/bookmarklet.js'; + document.body.appendChild(script); + })()" + class="bookmarklet-link" + style="display: inline-block; padding: 0.5rem 1rem; background: var(--dark); color: var(--beige); text-decoration: none; border-radius: 4px; font-weight: bold;" + onclick="event.preventDefault(); alert('Drag this to your bookmarks bar!');"> + Web Font Vacuum + </a> + </p> + <p> + Use the bookmarklet on any webpage to find and download its fonts directly. + </p> + </section> + </main> + <footer> + <p class="immoral">A note for those among us on the web who don't love having to pipe web traffic through random, mostly unknown services:</p> + <p>Because of <abbr title="Cross-Origin Resource Sharing">CORS</abbr> I've had to funnel requests through a CORS proxy service, and, because I'm too lazy to host my own I'm using a random one I found after 11 minutes of searching...and because that one seemed unreliable I went ahead and found a few more to cycle through at every request. The CORS issue only comes to play when you use the form on this website. The bookmarklet runs in the same scope as the website that you run it against, so, no CORS issues at all. No weird, unknown and untrusted servers in the way. Bookmarklets, ftw.</p> + </footer> + <script src="app.js"></script> +</body> +</html> |