import crypto from 'crypto'; import fs from 'fs'; import path from 'path'; import { fileURLToPath } from 'url'; import { stripCr, compareCodeUnits } from './hash-util.mjs'; // ── Paths ────────────────────────────────────────────────────────────────────── const __dirname = path.dirname(fileURLToPath(import.meta.url)); const iconsDir = path.join(__dirname, '..', 'ds-tokens', 'icons'); const outputDir = path.join( __dirname, '..', 'src', 'main', 'java', 'com', 'tangem', 'core', 'ui', 'res', 'generated', 'icons', ); const PACKAGE = 'com.tangem.core.ui.res.generated.icons'; // Source SVGs use #0F0F0F as a "tint placeholder" — rewrite to Color.Black so // Icon(tint = …) at the call site can re-color the icon. const TINT_PLACEHOLDERS = new Set(['#0f0f0f', '#0F0F0F']); // ── Helpers ──────────────────────────────────────────────────────────────────── function* walkSvgs(dir) { if (!fs.existsSync(dir)) return; for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { const full = path.join(dir, entry.name); if (entry.isDirectory()) yield* walkSvgs(full); else if (entry.name.endsWith('.svg')) yield full; } } /** Find an attribute value in a snippet of XML. */ function attr(snippet, name) { const m = snippet.match(new RegExp(`\\b${name}="([^"]*)"`)); return m ? m[1] : null; } function capitalize(s) { return s.charAt(0).toUpperCase() + s.slice(1); } /** Parse an SVG file into a normalized icon descriptor. */ function parseSvg(filePath) { const src = fs.readFileSync(filePath, 'utf8'); const svgOpen = src.match(/]*>/); if (!svgOpen) throw new Error('No root element'); const svgEl = svgOpen[0]; // Viewport / default size const viewBox = attr(svgEl, 'viewBox'); let viewportW, viewportH; if (viewBox) { const parts = viewBox.split(/\s+/).map(Number); viewportW = parts[2]; viewportH = parts[3]; } const defaultW = parseFloat(attr(svgEl, 'width')) || viewportW; const defaultH = parseFloat(attr(svgEl, 'height')) || viewportH; viewportW = viewportW ?? defaultW; viewportH = viewportH ?? defaultH; if (!viewportW || !viewportH) { throw new Error('Missing viewBox/width/height'); } // Group transforms aren't supported (would need matrix decomposition). if (/]*\btransform=/.test(src)) { throw new Error(' is not supported by the current generator'); } // elements const paths = []; const pathRe = /]*?)\/?>/g; let m; while ((m = pathRe.exec(src)) !== null) { const a = m[1]; paths.push({ d: attr(a, 'd'), fill: attr(a, 'fill'), fillRule: attr(a, 'fill-rule'), fillOpacity: attr(a, 'fill-opacity'), stroke: attr(a, 'stroke'), strokeWidth: attr(a, 'stroke-width'), strokeLinecap: attr(a, 'stroke-linecap'), strokeLinejoin: attr(a, 'stroke-linejoin'), opacity: attr(a, 'opacity'), }); } if (paths.length === 0) throw new Error('No elements found'); for (const p of paths) { if (!p.d) throw new Error('A is missing the "d" attribute'); } return { viewportW, viewportH, defaultW, defaultH, paths }; } /** * ic_arrow_down_24_regular.svg → * { propName: 'ic_arrow_down_24', fileName: 'IcArrowDown24' } */ function deriveNames(svgFile) { const base = path.basename(svgFile, '.svg').replace(/_regular$/, ''); const fileName = base .split('_') .map(part => capitalize(part)) .join(''); if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(base)) { throw new Error(`Icon name "${base}" is not a valid Kotlin identifier`); } return { propName: base, fileName }; } /** Convert an SVG color string into a Compose Color expression, or null to skip. */ function svgColorToKotlin(value) { if (!value || value === 'none') return null; if (TINT_PLACEHOLDERS.has(value.toLowerCase())) return 'Color.Black'; const hex6 = value.match(/^#([0-9a-fA-F]{6})$/); if (hex6) return `Color(0xFF${hex6[1].toUpperCase()})`; const hex3 = value.match(/^#([0-9a-fA-F]{3})$/); if (hex3) { const [r, g, b] = hex3[1].toUpperCase().split(''); return `Color(0xFF${r}${r}${g}${g}${b}${b})`; } const hex8 = value.match(/^#([0-9a-fA-F]{8})$/); if (hex8) return `Color(0x${hex8[1].toUpperCase()})`; const rgba = value.match(/^rgba\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*,\s*([\d.]+)\s*\)$/); if (rgba) { const r = (+rgba[1]).toString(16).padStart(2, '0').toUpperCase(); const g = (+rgba[2]).toString(16).padStart(2, '0').toUpperCase(); const b = (+rgba[3]).toString(16).padStart(2, '0').toUpperCase(); const a = Math.round(parseFloat(rgba[4]) * 255).toString(16).padStart(2, '0').toUpperCase(); return `Color(0x${a}${r}${g}${b})`; } if (value === 'black') return 'Color.Black'; if (value === 'white') return 'Color.White'; if (value === 'transparent') return 'Color.Transparent'; throw new Error(`Unsupported SVG color: "${value}"`); } function renderPath(p, indent) { const pad = ' '.repeat(indent); const pad1 = ' '.repeat(indent + 1); const args = []; // If a path has no fill at all and has stroke, leave fill out. Otherwise default to tintable black. const hasStroke = !!p.stroke && p.stroke !== 'none'; const fillSpecified = p.fill != null; let fillKt = svgColorToKotlin(p.fill); if (!fillSpecified && !hasStroke) fillKt = 'Color.Black'; if (fillKt) args.push(`fill = SolidColor(${fillKt})`); if (p.fillOpacity != null) { args.push(`fillAlpha = ${parseFloat(p.fillOpacity)}f`); } else if (p.opacity != null && fillKt) { args.push(`fillAlpha = ${parseFloat(p.opacity)}f`); } const strokeKt = svgColorToKotlin(p.stroke); if (strokeKt) args.push(`stroke = SolidColor(${strokeKt})`); if (p.strokeWidth != null) args.push(`strokeLineWidth = ${parseFloat(p.strokeWidth)}f`); if (p.strokeLinecap) args.push(`strokeLineCap = StrokeCap.${capitalize(p.strokeLinecap)}`); if (p.strokeLinejoin) args.push(`strokeLineJoin = StrokeJoin.${capitalize(p.strokeLinejoin)}`); args.push(`pathFillType = PathFillType.${p.fillRule === 'evenodd' ? 'EvenOdd' : 'NonZero'}`); args.push(`pathData = addPathNodes(${JSON.stringify(p.d)})`); const lines = [`${pad}addPath(`]; for (const arg of args) lines.push(`${pad1}${arg},`); lines.push(`${pad})`); return lines.join('\n'); } function renderIconFile({ propName, fileName }, icon) { const usesStroke = icon.paths.some(p => p.stroke && p.stroke !== 'none'); const imports = [ 'androidx.compose.material3.Icon', 'androidx.compose.runtime.Composable', 'androidx.compose.ui.graphics.Color', 'androidx.compose.ui.graphics.PathFillType', 'androidx.compose.ui.graphics.SolidColor', 'androidx.compose.ui.graphics.vector.ImageVector', 'androidx.compose.ui.graphics.vector.addPathNodes', 'androidx.compose.ui.tooling.preview.Preview', 'androidx.compose.ui.unit.dp', ]; if (usesStroke) { imports.push('androidx.compose.ui.graphics.StrokeCap'); imports.push('androidx.compose.ui.graphics.StrokeJoin'); } imports.sort(); const pathBlocks = icon.paths.map(p => renderPath(p, 2)).join('\n'); return `@file:Suppress("all") package ${PACKAGE} ${imports.map(i => `import ${i}`).join('\n')} /** * Auto-generated from design tokens. Do not edit manually. */ private var _${propName}: ImageVector? = null val Icons.${propName}: ImageVector get() { if (_${propName} != null) return _${propName}!! _${propName} = ImageVector.Builder( name = ${JSON.stringify(propName)}, defaultWidth = ${icon.defaultW}.dp, defaultHeight = ${icon.defaultH}.dp, viewportWidth = ${icon.viewportW}f, viewportHeight = ${icon.viewportH}f, ).apply { ${pathBlocks.replace(/^/gm, ' ')} }.build() return _${propName}!! } @Composable @Preview(showBackground = true) private fun ${fileName}Preview() { Icon( imageVector = Icons.${propName}, contentDescription = null, ) } `; } const ICONS_NAMESPACE = `@file:Suppress("all") package ${PACKAGE} /** * Auto-generated namespace for design-system icons. * Each icon is provided as an extension property on this object. */ object Icons `; // ── Hash gate ────────────────────────────────────────────────────────────────── function computeIconsHash() { const files = [...walkSvgs(iconsDir)]; // Code-unit order (not localeCompare) to match the Kotlin verifier's invariantSeparatorsPath // sort deterministically across locales/ICU versions — see hash-util.mjs. files.sort((a, b) => compareCodeUnits( path.relative(iconsDir, a).split(path.sep).join('/'), path.relative(iconsDir, b).split(path.sep).join('/'), )); const hash = crypto.createHash('sha256'); for (const file of files) { hash.update(path.relative(iconsDir, file).split(path.sep).join('/')); hash.update('\0'); hash.update(stripCr(fs.readFileSync(file))); hash.update('\0'); } return hash.digest('hex'); } // ── Main ─────────────────────────────────────────────────────────────────────── export async function buildIcons() { console.log('\nBuilding icon vectors...'); const newHash = computeIconsHash(); const hashFile = path.join(outputDir, '.icons-hash'); if (fs.existsSync(hashFile)) { const prev = fs.readFileSync(hashFile, 'utf8').trim(); if (prev === newHash) { console.log(` ✓ icons unchanged (${newHash.substring(0, 12)}…); skipping`); return { hash: newHash }; } } fs.mkdirSync(outputDir, { recursive: true }); // Parse every SVG up-front so we fail fast on errors before writing anything. const icons = []; for (const svgFile of walkSvgs(iconsDir)) { const names = deriveNames(svgFile); let parsed; try { parsed = parseSvg(svgFile); } catch (e) { throw new Error(`${path.relative(iconsDir, svgFile)}: ${e.message}`); } icons.push({ names, parsed }); } // Detect property-name collisions early. const seen = new Map(); for (const { names } of icons) { if (seen.has(names.propName)) { throw new Error( `Duplicate icon property "${names.propName}" (file collision: ` + `${seen.get(names.propName)}.kt vs ${names.fileName}.kt)`, ); } seen.set(names.propName, names.fileName); } // Write namespace + per-icon files. const expectedFiles = new Set(['Icons.kt', '.icons-hash']); fs.writeFileSync(path.join(outputDir, 'Icons.kt'), ICONS_NAMESPACE); for (const { names, parsed } of icons) { const file = `${names.fileName}.kt`; expectedFiles.add(file); fs.writeFileSync(path.join(outputDir, file), renderIconFile(names, parsed)); } // Cleanup stale generated files (icons that no longer have a source SVG). let removed = 0; for (const entry of fs.readdirSync(outputDir)) { if (!expectedFiles.has(entry) && entry.endsWith('.kt')) { fs.unlinkSync(path.join(outputDir, entry)); removed++; } } fs.writeFileSync(hashFile, newHash + '\n'); const removedNote = removed > 0 ? `, removed ${removed} stale` : ''; console.log(` ✓ ${icons.length} icon(s) (${newHash.substring(0, 12)}…${removedNote})`); return { hash: newHash }; } // Run directly when executed as `node build-icons.mjs`. if (process.argv[1] && fileURLToPath(import.meta.url) === path.resolve(process.argv[1])) { await buildIcons(); }