From 021f0bcb23b28cae0d2626d92636e027256f1811 Mon Sep 17 00:00:00 2001 From: lashman Date: Sun, 26 Apr 2026 17:34:06 +0300 Subject: [PATCH] fix dash style and wording in icon files --- cinch-icon-final.html | 81 +++++++++++ cinch-icon-variants.html | 151 +++++++++++++++++++ export-icon.cjs | 305 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 537 insertions(+) create mode 100644 cinch-icon-final.html create mode 100644 cinch-icon-variants.html create mode 100644 export-icon.cjs diff --git a/cinch-icon-final.html b/cinch-icon-final.html new file mode 100644 index 0000000..a48b73c --- /dev/null +++ b/cinch-icon-final.html @@ -0,0 +1,81 @@ + + + + + +Cinch Icon - Final + + + + + + + + + + + + + +
+
+ +
+
+ +
Cinch - 512x512px
+ + + diff --git a/cinch-icon-variants.html b/cinch-icon-variants.html new file mode 100644 index 0000000..20e6ea6 --- /dev/null +++ b/cinch-icon-variants.html @@ -0,0 +1,151 @@ + + + + + +Cinch Icon Variants + + + + + + + + + + + + + +

Cinch - Icon Variants

+ +
+
+
A - Compress
+
+
+ +
+
+
Arrows pointing inward. Direct, literal representation of compression.
+
+ +
+
B - Bolt
+
+
+ +
+
+
Lightning bolt. Speed, power, effortless — "it's a cinch."
+
+ +
+
C - Film
+
+
+ +
+
+
Film strip. Clear video identity. Instantly recognizable.
+
+
+ + + diff --git a/export-icon.cjs b/export-icon.cjs new file mode 100644 index 0000000..083a2c8 --- /dev/null +++ b/export-icon.cjs @@ -0,0 +1,305 @@ +#!/usr/bin/env node + +/** + * App Icon Export Script + * + * Takes an HTML icon preview file and exports it as: + * - Multiple PNG sizes (default: 512, 256, 192, 128, 64, 48, 32, 16) + * - Optionally an ICO file containing 16, 32, 48, 256px variants + * + * Because headless Chrome in this environment cannot access external CDNs, + * this script downloads icon font CSS and font files via curl, converts + * them to base64 data URIs, and inlines everything into a self-contained + * HTML before screenshotting. + * + * Usage: + * node export-icon.js --input icon.html --output ./out --name my-icon --sizes 512,256,128 --ico + * + * Dependencies: puppeteer-core, sharp, png-to-ico + * npm install puppeteer-core sharp png-to-ico + */ + +const puppeteer = require('puppeteer-core'); +const sharp = require('sharp'); +const fs = require('fs'); +const path = require('path'); +const { execSync } = require('child_process'); + +// Find a Chrome/Chromium binary on the system +function findChrome() { + const candidates = [ + '/usr/bin/google-chrome-stable', + '/usr/bin/google-chrome', + '/usr/bin/chromium-browser', + '/usr/bin/chromium', + '/opt/google/chrome/chrome', + '/opt/pw-browsers/chromium-1194/chrome-linux/chrome', + ]; + // Also check common Puppeteer cache locations + const home = process.env.HOME || '/root'; + try { + const cacheDir = path.join(home, '.cache', 'puppeteer', 'chrome'); + if (fs.existsSync(cacheDir)) { + const versions = fs.readdirSync(cacheDir).sort().reverse(); + for (const v of versions) { + const chromePath = path.join(cacheDir, v, 'chrome-linux64', 'chrome'); + if (fs.existsSync(chromePath)) candidates.unshift(chromePath); + } + } + } catch (e) { /* ignore */ } + for (const c of candidates) { + if (fs.existsSync(c)) { + console.log(`Using Chrome: ${c}`); + return c; + } + } + throw new Error('No Chrome/Chromium binary found. Tried:\n ' + candidates.join('\n ') + '\nSet CHROME_PATH env variable.'); +} +const CHROME_PATH = process.env.CHROME_PATH || findChrome(); + +// Download a URL using curl (bypasses Chrome network restrictions in container) +function curlDownload(url) { + try { + return execSync(`curl -sL --max-time 15 "${url}"`, { maxBuffer: 50 * 1024 * 1024 }); + } catch (e) { + console.warn(` Warning: Failed to download: ${url}`); + return null; + } +} + +// Convert a buffer to a base64 data URI +function toDataUri(buffer, mimeType) { + return `data:${mimeType};base64,${buffer.toString('base64')}`; +} + +// Inline all external CDN resources in the HTML so Chrome can render offline +function inlineExternalResources(html) { + console.log('Inlining external resources...'); + + // Find all tags (both orderings of attributes) + const linkPattern = /]*?href=["']([^"']+)["'][^>]*?rel=["']stylesheet["'][^>]*?>|]*?rel=["']stylesheet["'][^>]*?href=["']([^"']+)["'][^>]*?>/gi; + let match; + const stylesheetEntries = []; + + while ((match = linkPattern.exec(html)) !== null) { + const href = match[1] || match[2]; + if (href && (href.startsWith('http://') || href.startsWith('https://'))) { + stylesheetEntries.push({ fullMatch: match[0], url: href }); + } + } + + for (const { fullMatch, url } of stylesheetEntries) { + console.log(` Downloading CSS: ${url.split('/').slice(-2).join('/')}`); + const cssBuffer = curlDownload(url); + if (!cssBuffer) continue; + + let css = cssBuffer.toString('utf8'); + + // Resolve relative URLs in the CSS to absolute + const baseUrl = url.substring(0, url.lastIndexOf('/') + 1); + css = css.replace(/url\(["']?(\.\.\/[^"')]+)["']?\)/g, (full, relPath) => { + try { + const absUrl = new URL(relPath, url).href; + return `url("${absUrl}")`; + } catch { + return full; + } + }); + + // Find all absolute url() references in the CSS + const urlRegex = /url\(["']?(https?:\/\/[^"')]+)["']?\)/g; + let urlMatch; + const fontUrls = new Set(); + while ((urlMatch = urlRegex.exec(css)) !== null) { + fontUrls.add(urlMatch[1]); + } + + // Download and inline each font/resource file + for (const fontUrl of fontUrls) { + const filename = fontUrl.split('/').pop().split('?')[0]; + console.log(` Downloading: ${filename}`); + const fontBuffer = curlDownload(fontUrl); + if (!fontBuffer) continue; + + let mimeType = 'application/octet-stream'; + if (fontUrl.includes('.woff2')) mimeType = 'font/woff2'; + else if (fontUrl.includes('.woff')) mimeType = 'font/woff'; + else if (fontUrl.includes('.ttf')) mimeType = 'font/ttf'; + else if (fontUrl.includes('.eot')) mimeType = 'application/vnd.ms-fontobject'; + else if (fontUrl.includes('.svg')) mimeType = 'image/svg+xml'; + + const dataUri = toDataUri(fontBuffer, mimeType); + css = css.split(fontUrl).join(dataUri); + } + + // Replace tag with inline `); + } + + return html; +} + +// Parse CLI arguments +function parseArgs() { + const args = process.argv.slice(2); + const opts = { + input: null, + output: null, + name: 'app-icon', + sizes: [512, 256, 192, 128, 64, 48, 32, 16], + ico: false, + shadowPadding: 40, + }; + + for (let i = 0; i < args.length; i++) { + switch (args[i]) { + case '--input': opts.input = args[++i]; break; + case '--output': opts.output = args[++i]; break; + case '--name': opts.name = args[++i]; break; + case '--sizes': opts.sizes = args[++i].split(',').map(Number).filter(n => n > 0); break; + case '--preset': + const preset = args[++i]; + if (preset === 'ios') opts.sizes = [1024, 512, 180, 167, 152, 120, 87, 80, 76, 60, 40, 29, 20]; + else if (preset === 'android') opts.sizes = [512, 432, 324, 216, 162, 108, 81, 48]; + else if (preset === 'favicon') opts.sizes = [512, 192, 180, 32, 16]; + else if (preset === 'all') opts.sizes = [1024, 512, 432, 324, 256, 216, 192, 180, 167, 152, 128, 120, 108, 96, 87, 81, 80, 76, 64, 60, 48, 40, 32, 29, 20, 16]; + break; + case '--ico': opts.ico = true; break; + case '--shadow-padding': opts.shadowPadding = parseInt(args[++i], 10); break; + } + } + + if (!opts.input || !opts.output) { + console.error('Usage: node export-icon.js --input --output [--name ] [--sizes ] [--ico] [--shadow-padding ]'); + process.exit(1); + } + return opts; +} + +async function exportIcon(opts) { + const { input, output, name, sizes, ico, shadowPadding } = opts; + + if (!fs.existsSync(output)) fs.mkdirSync(output, { recursive: true }); + + console.log(`\nExporting icon from: ${input}`); + console.log(`Output: ${output}`); + console.log(`Sizes: ${sizes.join(', ')}px`); + console.log(`Name: ${name}`); + if (ico) console.log(`ICO: enabled\n`); + + // Read and inline all external resources + let html = fs.readFileSync(path.resolve(input), 'utf8'); + html = inlineExternalResources(html); + + // Write self-contained HTML to temp file + const tempHtml = path.join(output, '_export_temp.html'); + fs.writeFileSync(tempHtml, html, 'utf8'); + + // Launch headless Chrome + const browser = await puppeteer.launch({ + headless: 'new', + executablePath: CHROME_PATH, + args: ['--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage', '--disable-gpu', '--allow-file-access-from-files'], + }); + + try { + const page = await browser.newPage(); + const vpSize = 512 + (shadowPadding * 2) + 100; + await page.setViewport({ width: vpSize, height: vpSize, deviceScaleFactor: 2 }); + + await page.goto(`file://${path.resolve(tempHtml)}`, { waitUntil: 'networkidle0', timeout: 15000 }); + + // Wait for inlined fonts to render - try fonts.ready then fallback to timeout + await page.evaluate(() => document.fonts.ready); + // Additional wait for icon fonts which may load async after fonts.ready + await new Promise(r => setTimeout(r, 3000)); + + const fontCount = await page.evaluate(() => document.fonts.size); + const fontsLoaded = await page.evaluate(() => + Array.from(document.fonts).filter(f => f.status === 'loaded').length + ); + console.log(`Fonts: ${fontsLoaded} loaded of ${fontCount} total`); + + // Remove preview checkerboard background + await page.evaluate(() => { + document.body.style.background = 'transparent'; + document.body.style.backgroundImage = 'none'; + document.body.style.backgroundColor = 'transparent'; + document.documentElement.style.background = 'transparent'; + }); + + // Find target element + let el = await page.$('.icon-wrapper') || await page.$('#icon-target') || await page.$('.icon-container'); + if (!el) throw new Error('No .icon-wrapper, #icon-target, or .icon-container found'); + + // Screenshot with transparent background + const screenshot = await el.screenshot({ type: 'png', omitBackground: true }); + console.log(`Screenshot captured`); + + const meta = await sharp(screenshot).metadata(); + console.log(`Raw: ${meta.width}x${meta.height}px\n`); + + // Generate all sizes + const files = []; + const icoBufs = {}; + + for (const size of sizes.sort((a, b) => b - a)) { + const fn = `${name}-${size}x${size}.png`; + const fp = path.join(output, fn); + await sharp(screenshot).resize(size, size, { fit: 'contain', background: { r: 0, g: 0, b: 0, alpha: 0 } }).png().toFile(fp); + files.push(fp); + console.log(` ${fn}`); + if ([16, 24, 32, 48, 64, 128, 256].includes(size)) { + icoBufs[size] = await sharp(screenshot).resize(size, size, { fit: 'contain', background: { r: 0, g: 0, b: 0, alpha: 0 } }).png().toBuffer(); + } + } + + // Main icon (largest size, no size suffix) + const mainFp = path.join(output, `${name}.png`); + const largest = Math.max(...sizes); + await sharp(screenshot).resize(largest, largest, { fit: 'contain', background: { r: 0, g: 0, b: 0, alpha: 0 } }).png().toFile(mainFp); + files.unshift(mainFp); + console.log(` ${name}.png (main)`); + + // ICO generation + if (ico) { + const pngToIcoMod = require('png-to-ico'); + const pngToIco = pngToIcoMod.default || pngToIcoMod.imagesToIco || pngToIcoMod; + const icoFp = path.join(output, `${name}.ico`); + const icoSizes = [256, 48, 32, 16]; + const bufs = []; + + for (const s of icoSizes) { + bufs.push(icoBufs[s] || await sharp(screenshot).resize(s, s, { fit: 'contain', background: { r: 0, g: 0, b: 0, alpha: 0 } }).png().toBuffer()); + } + + try { + fs.writeFileSync(icoFp, await pngToIco(bufs)); + files.push(icoFp); + console.log(` ${name}.ico`); + } catch (e) { + console.warn(` ICO failed: ${e.message}, trying 32px only...`); + try { + const b = await sharp(screenshot).resize(32, 32, { fit: 'contain', background: { r: 0, g: 0, b: 0, alpha: 0 } }).png().toBuffer(); + fs.writeFileSync(icoFp, await pngToIco([b])); + files.push(icoFp); + console.log(` ${name}.ico (32px only)`); + } catch (e2) { + console.error(` ICO failed completely: ${e2.message}`); + } + } + } + + // Cleanup + try { fs.unlinkSync(tempHtml); } catch (e) { /* ok */ } + + console.log(`\nDone! ${files.length} files exported.`); + return files; + + } finally { + await browser.close(); + } +} + +const opts = parseArgs(); +exportIcon(opts).catch(err => { console.error('Export failed:', err.message); process.exit(1); });