mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2026-07-06 14:01:30 +08:00
248 lines
7.4 KiB
TypeScript
248 lines
7.4 KiB
TypeScript
/**
|
|
* Convert raster images (PNG/JPG) to SVG using @neplex/vectorizer
|
|
*
|
|
* Features:
|
|
* - Normalizes output with proper viewBox (matching icon system)
|
|
* - Auto-deletes original raster file after successful conversion
|
|
* - Outputs kebab-case SVG
|
|
*
|
|
* Usage:
|
|
* tsx scripts/vectorize-logo.ts path/to/logo.png
|
|
* tsx scripts/vectorize-logo.ts path/to/logo.png --preset=photo
|
|
* tsx scripts/vectorize-logo.ts path/to/*.png
|
|
* tsx scripts/vectorize-logo.ts --dir=providers # batch: all PNG/JPG in icons/providers/
|
|
* tsx scripts/vectorize-logo.ts --dir=models --size=48 # batch with custom size
|
|
*/
|
|
import { vectorize } from '@neplex/vectorizer'
|
|
import fs from 'fs/promises'
|
|
import path from 'path'
|
|
|
|
const DEFAULT_ICON_SIZE = 32
|
|
|
|
// Enum values from @neplex/vectorizer (numeric to avoid isolatedModules const enum issue)
|
|
const COLOR_MODE_COLOR = 0
|
|
const HIERARCHICAL_STACKED = 0
|
|
const PATH_SIMPLIFY_SPLINE = 2
|
|
const PRESET_BW = 0
|
|
const PRESET_POSTER = 1
|
|
const PRESET_PHOTO = 2
|
|
|
|
type PresetName = 'poster' | 'photo' | 'bw' | 'custom'
|
|
type SourceDir = 'providers' | 'models'
|
|
|
|
const RASTER_EXTENSIONS = new Set(['.png', '.jpg', '.jpeg'])
|
|
|
|
interface CliArgs {
|
|
inputs: string[]
|
|
outputDir: string
|
|
preset: PresetName
|
|
size: number
|
|
dir?: SourceDir
|
|
}
|
|
|
|
function parseArgs(): CliArgs {
|
|
const args = process.argv.slice(2)
|
|
const inputs: string[] = []
|
|
let outputDir = ''
|
|
let preset: PresetName = 'custom'
|
|
let size = DEFAULT_ICON_SIZE
|
|
let dir: SourceDir | undefined
|
|
|
|
for (const arg of args) {
|
|
if (arg.startsWith('--output=')) {
|
|
outputDir = arg.split('=')[1]
|
|
} else if (arg.startsWith('--preset=')) {
|
|
const value = arg.split('=')[1]
|
|
if (['poster', 'photo', 'bw'].includes(value)) {
|
|
preset = value as PresetName
|
|
} else {
|
|
throw new Error(`Invalid preset: ${value}. Use "poster", "photo", or "bw".`)
|
|
}
|
|
} else if (arg.startsWith('--dir=')) {
|
|
const value = arg.split('=')[1]
|
|
if (value === 'providers' || value === 'models') {
|
|
dir = value
|
|
} else {
|
|
throw new Error(`Invalid --dir value: ${value}. Use "providers" or "models".`)
|
|
}
|
|
} else if (arg.startsWith('--size=')) {
|
|
const value = parseInt(arg.split('=')[1], 10)
|
|
if (isNaN(value) || value <= 0) {
|
|
throw new Error(`Invalid --size value. Must be a positive integer.`)
|
|
}
|
|
size = value
|
|
} else if (!arg.startsWith('--')) {
|
|
inputs.push(arg)
|
|
}
|
|
}
|
|
|
|
// When --dir is provided, resolve outputDir and scan for raster files
|
|
if (dir) {
|
|
if (!outputDir) {
|
|
outputDir = path.join(__dirname, '../icons', dir)
|
|
}
|
|
} else {
|
|
if (!outputDir) {
|
|
outputDir = path.join(__dirname, '../icons/providers')
|
|
}
|
|
if (inputs.length === 0) {
|
|
console.error(
|
|
'Usage: tsx scripts/vectorize-logo.ts <input.png> [--output=<dir>] [--preset=poster|photo|bw]\n' +
|
|
' tsx scripts/vectorize-logo.ts --dir=providers|models [--size=32] [--preset=...]'
|
|
)
|
|
process.exit(1)
|
|
}
|
|
}
|
|
|
|
return { inputs, outputDir, preset, size, dir }
|
|
}
|
|
|
|
function getPresetConfig(preset: PresetName) {
|
|
if (preset === 'poster') return PRESET_POSTER
|
|
if (preset === 'photo') return PRESET_PHOTO
|
|
if (preset === 'bw') return PRESET_BW
|
|
|
|
// Custom config tuned for logo/icon quality
|
|
return {
|
|
colorMode: COLOR_MODE_COLOR,
|
|
hierarchical: HIERARCHICAL_STACKED,
|
|
filterSpeckle: 4,
|
|
colorPrecision: 8,
|
|
layerDifference: 6,
|
|
mode: PATH_SIMPLIFY_SPLINE,
|
|
cornerThreshold: 60,
|
|
lengthThreshold: 4.0,
|
|
maxIterations: 2,
|
|
spliceThreshold: 45,
|
|
pathPrecision: 2
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Normalize vectorizer SVG output:
|
|
* - Add viewBox="0 0 {origW} {origH}"
|
|
* - Set width/height to target size
|
|
* - Remove XML declaration and generator comment
|
|
*/
|
|
function normalizeSvg(svg: string, size: number): string {
|
|
let result = svg
|
|
|
|
// Remove XML declaration and generator comment
|
|
result = result.replace(/<\?xml[^?]*\?>\s*/g, '')
|
|
result = result.replace(/<!--[^>]*-->\s*/g, '')
|
|
|
|
// Parse original width/height
|
|
const wMatch = result.match(/<svg[^>]*\bwidth="(\d+(?:\.\d+)?)"/)
|
|
const hMatch = result.match(/<svg[^>]*\bheight="(\d+(?:\.\d+)?)"/)
|
|
const origW = wMatch ? wMatch[1] : String(size)
|
|
const origH = hMatch ? hMatch[1] : String(size)
|
|
|
|
// Add viewBox if missing
|
|
if (!/viewBox\s*=/.test(result)) {
|
|
result = result.replace(/<svg\b/, `<svg viewBox="0 0 ${origW} ${origH}"`)
|
|
}
|
|
|
|
// Set width/height to icon size
|
|
result = result.replace(/(<svg[^>]*)\bwidth="[^"]*"/, `$1width="${size}"`)
|
|
result = result.replace(/(<svg[^>]*)\bheight="[^"]*"/, `$1height="${size}"`)
|
|
|
|
// Add fill="none" if not present (matches other SVG sources)
|
|
if (!/<svg[^>]*\bfill=/.test(result)) {
|
|
result = result.replace(/<svg\b/, '<svg fill="none"')
|
|
}
|
|
|
|
return result.trim() + '\n'
|
|
}
|
|
|
|
function toKebabCase(name: string): string {
|
|
return name
|
|
.replace(/([a-z0-9])([A-Z])/g, '$1-$2')
|
|
.replace(/([A-Z]+)([A-Z][a-z])/g, '$1-$2')
|
|
.toLowerCase()
|
|
.replace(/[^a-z0-9-]/g, '-')
|
|
.replace(/-+/g, '-')
|
|
.replace(/^-|-$/g, '')
|
|
}
|
|
|
|
/**
|
|
* Scan a directory for raster image files (PNG/JPG/JPEG).
|
|
*/
|
|
async function scanRasterFiles(dir: string): Promise<string[]> {
|
|
try {
|
|
const entries = await fs.readdir(dir)
|
|
return entries
|
|
.filter((f) => RASTER_EXTENSIONS.has(path.extname(f).toLowerCase()))
|
|
.map((f) => path.join(dir, f))
|
|
.sort()
|
|
} catch {
|
|
return []
|
|
}
|
|
}
|
|
|
|
async function main() {
|
|
const { inputs, outputDir, preset, size, dir } = parseArgs()
|
|
const config = getPresetConfig(preset)
|
|
|
|
// Resolve final input list
|
|
let filesToProcess: string[]
|
|
if (dir) {
|
|
filesToProcess = await scanRasterFiles(outputDir)
|
|
if (filesToProcess.length === 0) {
|
|
console.log(`No PNG/JPG files found in icons/${dir}/. Nothing to vectorize.`)
|
|
return
|
|
}
|
|
} else {
|
|
filesToProcess = inputs
|
|
}
|
|
|
|
await fs.mkdir(outputDir, { recursive: true })
|
|
|
|
console.log(`Vectorizing ${filesToProcess.length} file(s) (preset: ${preset}, size: ${size})...\n`)
|
|
|
|
let succeeded = 0
|
|
let failed = 0
|
|
|
|
for (const inputPath of filesToProcess) {
|
|
const baseName = path.basename(inputPath, path.extname(inputPath))
|
|
const kebabName = toKebabCase(baseName)
|
|
const outputPath = path.join(outputDir, `${kebabName}.svg`)
|
|
|
|
try {
|
|
const buffer = await fs.readFile(inputPath)
|
|
const rawSvg = await vectorize(buffer, config)
|
|
const svg = normalizeSvg(rawSvg, size)
|
|
|
|
await fs.writeFile(outputPath, svg, 'utf-8')
|
|
|
|
// Auto-delete source raster file
|
|
await fs.unlink(inputPath)
|
|
|
|
const inputSize = buffer.length
|
|
const outputSize = Buffer.byteLength(svg)
|
|
console.log(
|
|
` ${path.basename(inputPath)} -> ${kebabName}.svg` +
|
|
` (${(inputSize / 1024).toFixed(1)}KB -> ${(outputSize / 1024).toFixed(1)}KB)` +
|
|
` [source deleted]`
|
|
)
|
|
succeeded++
|
|
} catch (error) {
|
|
console.error(` Failed: ${path.basename(inputPath)}: ${error}`)
|
|
failed++
|
|
}
|
|
}
|
|
|
|
console.log(`\nDone! ${succeeded} converted, ${failed} failed.`)
|
|
if (succeeded > 0) {
|
|
const label = dir || 'providers'
|
|
console.log(`Output: ${outputDir}`)
|
|
console.log(`\nNext steps:`)
|
|
console.log(` pnpm ${label}:validate # check SVG quality`)
|
|
console.log(` pnpm ${label}:generate # generate React components`)
|
|
console.log(` pnpm ${label}:generate:mono # generate mono variants`)
|
|
console.log(`\n Or run the full pipeline:`)
|
|
console.log(` pnpm ${label}:pipeline`)
|
|
}
|
|
}
|
|
|
|
void main()
|