mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2026-07-06 22:55:56 +08:00
328 lines
11 KiB
TypeScript
328 lines
11 KiB
TypeScript
/**
|
|
* Generate Avatar components for all icon directories
|
|
*
|
|
* This script creates avatar.tsx for each icon that has a color.tsx,
|
|
* then updates the per-icon index.ts to include the Avatar export.
|
|
*
|
|
* Smart background detection:
|
|
* - SVGs with a detected background shape → full-bleed Color icon (100% fill)
|
|
* - SVGs without background → padded Color icon (75%) on neutral bg-background
|
|
*
|
|
* Usage:
|
|
* pnpm tsx scripts/generate-avatars.ts --type=providers
|
|
* pnpm tsx scripts/generate-avatars.ts --type=models
|
|
*/
|
|
|
|
import * as fs from 'fs'
|
|
import * as path from 'path'
|
|
import { type Config, optimize } from 'svgo'
|
|
|
|
import {
|
|
generateAvatar as codegenAvatar,
|
|
generateBarrelIndex as codegenBarrelIndex,
|
|
generateCatalog as codegenCatalog,
|
|
generateIconIndex as codegenIconIndex
|
|
} from './codegen'
|
|
import {
|
|
buildSvgMap,
|
|
collectIconDirs,
|
|
ensureViewBox,
|
|
getComponentName,
|
|
isImageBased,
|
|
isWhiteFill,
|
|
OUTPUT_DIR_MAP,
|
|
parseLogoTypeArg,
|
|
parseSvgPathBounds,
|
|
readColorPrimary
|
|
} from './svg-utils'
|
|
import { createRemoveBackgroundPlugin } from './svgo-remove-background'
|
|
|
|
/**
|
|
* Extract CSS fill values from <style> blocks for class-based styling.
|
|
* Returns a map of className → fill color.
|
|
*/
|
|
function extractCssFills(svgCode: string): Map<string, string> {
|
|
const map = new Map<string, string>()
|
|
const styleMatch = svgCode.match(/<style[^>]*>([\s\S]*?)<\/style>/)
|
|
if (!styleMatch) return map
|
|
for (const rule of styleMatch[1].matchAll(/\.([a-zA-Z0-9_-]+)\s*\{[^}]*?\bfill\s*:\s*([^;}\s]+)/g)) {
|
|
map.set(rule[1], rule[2])
|
|
}
|
|
return map
|
|
}
|
|
|
|
/**
|
|
* Get the fill value of an SVG element from its attribute string,
|
|
* checking both inline fill= and CSS class-based fills.
|
|
*/
|
|
function getElementFill(attrs: string, cssFills: Map<string, string>): string | null {
|
|
const fillMatch = attrs.match(/\bfill="([^"]+)"/)
|
|
if (fillMatch && fillMatch[1] !== 'none') return fillMatch[1]
|
|
const classMatch = attrs.match(/\bclass="([^"]+)"/)
|
|
if (classMatch) {
|
|
const fill = cssFills.get(classMatch[1])
|
|
if (fill && fill !== 'none') return fill
|
|
}
|
|
return null
|
|
}
|
|
|
|
/**
|
|
* Parse a numeric attribute value from an element's attribute string.
|
|
*/
|
|
function parseAttrFloat(attrs: string, name: string): number {
|
|
const match = attrs.match(new RegExp(`\\b${name}="([^"]+)"`))
|
|
return match ? parseFloat(match[1]) : NaN
|
|
}
|
|
|
|
/**
|
|
* Supplementary background detection: checks if the first visual element
|
|
* covers most of the viewBox, regardless of fill color or luminance.
|
|
*
|
|
* Handles cases the svgo plugin misses:
|
|
* - Vectorized rects using C curves instead of H/V commands
|
|
* - Medium-luminance backgrounds (e.g. red, blue)
|
|
* - CSS class-based fills (e.g. z-ai)
|
|
* - Non-zero-origin rounded rects (e.g. youdao)
|
|
*/
|
|
function detectBackgroundByArea(svgCode: string): boolean {
|
|
// Parse viewBox
|
|
const vbMatch = svgCode.match(/viewBox\s*=\s*"([^"]+)"/)
|
|
if (!vbMatch) return false
|
|
const vbParts = vbMatch[1].split(/[\s,]+/).map(Number)
|
|
if (vbParts.length !== 4 || !vbParts.every(isFinite)) return false
|
|
const [vbX, vbY, vbW, vbH] = vbParts
|
|
|
|
// Extract CSS fills for class-based styling
|
|
const cssFills = extractCssFills(svgCode)
|
|
|
|
// Strip non-visual sections
|
|
const stripped = svgCode
|
|
.replace(/<defs[\s\S]*?<\/defs>/gi, '')
|
|
.replace(/<mask[\s\S]*?<\/mask>/gi, '')
|
|
.replace(/<clipPath[\s\S]*?<\/clipPath>/gi, '')
|
|
|
|
// Collect all <path> and <rect> elements
|
|
const pathMatches = [...stripped.matchAll(/<path\b([^>]*)>/g)]
|
|
const rectMatches = [...stripped.matchAll(/<rect\b([^>]*)>/g)]
|
|
const totalVisual = pathMatches.length + rectMatches.length
|
|
if (totalVisual < 2) return false
|
|
|
|
// Determine which element comes first in document order
|
|
const firstPathPos = pathMatches.length > 0 ? stripped.indexOf(pathMatches[0][0]) : Infinity
|
|
const firstRectPos = rectMatches.length > 0 ? stripped.indexOf(rectMatches[0][0]) : Infinity
|
|
|
|
let bounds: { minX: number; minY: number; maxX: number; maxY: number } | null = null
|
|
|
|
if (firstPathPos < firstRectPos && pathMatches.length > 0) {
|
|
const attrs = pathMatches[0][1]
|
|
const fill = getElementFill(attrs, cssFills)
|
|
if (!fill) return false
|
|
|
|
// Skip white/near-white fills — those are already handled by the svgo plugin (Rule 4)
|
|
if (isWhiteFill(fill)) return false
|
|
|
|
const dMatch = attrs.match(/\bd="([^"]+)"/)
|
|
if (!dMatch) return false
|
|
|
|
// Background shapes use a single closed subpath (1 M command).
|
|
// Multiple M commands indicate scattered shapes (e.g. dots, letters).
|
|
const mCount = (dMatch[1].match(/[Mm]/g) || []).length
|
|
if (mCount > 1) return false
|
|
|
|
bounds = parseSvgPathBounds(dMatch[1])
|
|
if (!isFinite(bounds.minX)) return false
|
|
|
|
// Apply translate transform if present
|
|
const txMatch = attrs.match(/\btransform="[^"]*translate\(\s*(-?[\d.]+)[\s,]+(-?[\d.]+)\s*\)/)
|
|
if (txMatch) {
|
|
const dx = parseFloat(txMatch[1]) || 0
|
|
const dy = parseFloat(txMatch[2]) || 0
|
|
bounds.minX += dx
|
|
bounds.maxX += dx
|
|
bounds.minY += dy
|
|
bounds.maxY += dy
|
|
}
|
|
} else if (rectMatches.length > 0) {
|
|
const attrs = rectMatches[0][1]
|
|
const fill = getElementFill(attrs, cssFills)
|
|
if (!fill) return false
|
|
|
|
if (isWhiteFill(fill)) return false
|
|
|
|
const w = parseAttrFloat(attrs, 'width')
|
|
const h = parseAttrFloat(attrs, 'height')
|
|
if (!isFinite(w) || !isFinite(h)) return false
|
|
const x = parseAttrFloat(attrs, 'x') || 0
|
|
const y = parseAttrFloat(attrs, 'y') || 0
|
|
bounds = { minX: x, minY: y, maxX: x + w, maxY: y + h }
|
|
}
|
|
|
|
if (!bounds) return false
|
|
|
|
// Check if bounds cover the viewBox edges (within 15% tolerance)
|
|
const margin = Math.max(vbW, vbH) * 0.15
|
|
return (
|
|
bounds.minX <= vbX + margin &&
|
|
bounds.minY <= vbY + margin &&
|
|
bounds.maxX >= vbX + vbW - margin &&
|
|
bounds.maxY >= vbY + vbH - margin
|
|
)
|
|
}
|
|
|
|
/**
|
|
* Detect whether a source SVG has a background shape.
|
|
*
|
|
* Strategy 1: svgo removeBackground plugin in detectOnly mode (handles
|
|
* rects, rounded-rect paths, dark backgrounds, white backgrounds).
|
|
* Strategy 2: Area-based heuristic fallback (handles vectorized rects,
|
|
* medium-luminance backgrounds, CSS-styled fills).
|
|
*/
|
|
function detectHasBackground(svgPath: string): boolean {
|
|
const svgCode = fs.readFileSync(svgPath, 'utf-8')
|
|
if (isImageBased(svgCode)) return false
|
|
|
|
const processedSvg = ensureViewBox(svgCode)
|
|
|
|
// Strategy 1: svgo plugin detection
|
|
const bgPlugin = createRemoveBackgroundPlugin({ detectOnly: true })
|
|
const svgoConfig: Config = {
|
|
plugins: [bgPlugin.plugin]
|
|
}
|
|
optimize(processedSvg, svgoConfig)
|
|
if (bgPlugin.wasRemoved()) return true
|
|
|
|
// Strategy 2: Area-based fallback
|
|
return detectBackgroundByArea(processedSvg)
|
|
}
|
|
|
|
/**
|
|
* Generate avatar.tsx with full-bleed rendering (for SVGs with background).
|
|
*/
|
|
function generateFullBleedAvatar(baseDir: string, dirName: string): void {
|
|
const colorName = getComponentName(baseDir, dirName)
|
|
const hasDark = fs.existsSync(path.join(baseDir, dirName, 'dark.tsx'))
|
|
codegenAvatar({
|
|
outPath: path.join(baseDir, dirName, 'avatar.tsx'),
|
|
colorName,
|
|
variant: 'full-bleed',
|
|
hasDark
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Generate avatar.tsx with padded rendering (for SVGs without background).
|
|
*/
|
|
function generatePaddedAvatar(baseDir: string, dirName: string): void {
|
|
const colorName = getComponentName(baseDir, dirName)
|
|
const hasDark = fs.existsSync(path.join(baseDir, dirName, 'dark.tsx'))
|
|
codegenAvatar({
|
|
outPath: path.join(baseDir, dirName, 'avatar.tsx'),
|
|
colorName,
|
|
variant: 'padded',
|
|
hasDark
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Generate per-icon index.tsx with compound export (variant prop + Avatar).
|
|
*/
|
|
function generateIconIndex(baseDir: string, dirName: string): void {
|
|
const colorName = getComponentName(baseDir, dirName)
|
|
const colorPrimary = readColorPrimary(baseDir, dirName)
|
|
const hasDark = fs.existsSync(path.join(baseDir, dirName, 'dark.tsx'))
|
|
const lightContent = fs.readFileSync(path.join(baseDir, dirName, 'light.tsx'), 'utf-8')
|
|
const usesCurrentColor = lightContent.includes('currentColor')
|
|
|
|
codegenIconIndex({
|
|
outPath: path.join(baseDir, dirName, 'index.tsx'),
|
|
colorName,
|
|
hasAvatar: true,
|
|
hasDark,
|
|
usesCurrentColor,
|
|
colorPrimary
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Generate the barrel index.ts that re-exports all compound icons.
|
|
*/
|
|
function generateBarrelIndex(baseDir: string, iconDirs: string[]): void {
|
|
const entries = iconDirs.map((dirName) => ({
|
|
dirName,
|
|
colorName: getComponentName(baseDir, dirName)
|
|
}))
|
|
|
|
const headerLines = [
|
|
'Auto-generated compound icon exports',
|
|
'Each icon supports: <Icon /> (auto light/dark), <Icon variant="light" />, <Icon variant="dark" />, <Icon.Avatar />, Icon.colorPrimary',
|
|
'Do not edit manually',
|
|
'',
|
|
`Generated at: ${new Date().toISOString()}`,
|
|
`Total icons: ${iconDirs.length}`
|
|
]
|
|
|
|
codegenBarrelIndex({
|
|
outPath: path.join(baseDir, 'index.ts'),
|
|
entries,
|
|
header: headerLines.join('\n')
|
|
})
|
|
console.log(`\nGenerated barrel index.ts with ${iconDirs.length} compound exports`)
|
|
}
|
|
|
|
// ──────────────────────────────────────────────────────────
|
|
// Main
|
|
// ──────────────────────────────────────────────────────────
|
|
|
|
function main() {
|
|
const iconType = parseLogoTypeArg()
|
|
const baseDir = OUTPUT_DIR_MAP[iconType]
|
|
const svgMap = buildSvgMap(iconType)
|
|
|
|
console.log(`Generating avatars (type: ${iconType})...\n`)
|
|
|
|
const iconDirs = collectIconDirs(baseDir)
|
|
let fullBleedCount = 0
|
|
let paddedCount = 0
|
|
|
|
for (const dirName of iconDirs) {
|
|
const colorName = getComponentName(baseDir, dirName)
|
|
const avatarName = `${colorName}Avatar`
|
|
|
|
const svgPath = svgMap.get(dirName)
|
|
const hasBackground = svgPath && fs.existsSync(svgPath) ? detectHasBackground(svgPath) : false
|
|
|
|
if (hasBackground) {
|
|
generateFullBleedAvatar(baseDir, dirName)
|
|
console.log(` ${dirName}/ -> ${avatarName} (full-bleed)`)
|
|
fullBleedCount++
|
|
} else {
|
|
generatePaddedAvatar(baseDir, dirName)
|
|
console.log(` ${dirName}/ -> ${avatarName} (padded)`)
|
|
paddedCount++
|
|
}
|
|
|
|
generateIconIndex(baseDir, dirName)
|
|
}
|
|
|
|
generateBarrelIndex(baseDir, iconDirs)
|
|
|
|
// Generate catalog.ts for runtime icon lookup
|
|
const catalogName = iconType === 'models' ? 'MODEL_ICON_CATALOG' : 'PROVIDER_ICON_CATALOG'
|
|
const catalogEntries = iconDirs.map((dirName) => ({
|
|
dirName,
|
|
colorName: getComponentName(baseDir, dirName)
|
|
}))
|
|
codegenCatalog({
|
|
outPath: path.join(baseDir, 'catalog.ts'),
|
|
entries: catalogEntries,
|
|
catalogName
|
|
})
|
|
console.log(`Generated catalog.ts (${catalogName}) with ${catalogEntries.length} entries`)
|
|
|
|
console.log(
|
|
`\nDone! Generated ${fullBleedCount + paddedCount} avatar components (${fullBleedCount} full-bleed, ${paddedCount} padded)`
|
|
)
|
|
}
|
|
|
|
main()
|