200 lines
7.3 KiB
Swift
200 lines
7.3 KiB
Swift
#!/usr/bin/env swift
|
|
//
|
|
// make-icon.swift — 生成 TinyTTS 应用图标
|
|
//
|
|
// 设计:白色麦克风由三段「文字条」镂空而成 —— 声音落成文字。
|
|
// 深靛蓝渐变底 + 纯白图形,16pt 到 1024pt 都保持清晰轮廓。
|
|
//
|
|
// 用法:swift scripts/make-icon.swift <输出目录>
|
|
//
|
|
|
|
import AppKit
|
|
import Foundation
|
|
|
|
let BASE: CGFloat = 1024
|
|
|
|
/// 超椭圆(squircle),n 越大越接近方形;n=4 接近 Apple 图标的连续曲率
|
|
func squirclePath(in rect: NSRect, n: CGFloat = 4.0, samples: Int = 720) -> NSBezierPath {
|
|
let path = NSBezierPath()
|
|
let a = rect.width / 2
|
|
let b = rect.height / 2
|
|
for i in 0...samples {
|
|
let t = CGFloat(i) / CGFloat(samples) * 2 * .pi
|
|
let ct = cos(t)
|
|
let st = sin(t)
|
|
let x = rect.midX + a * (ct < 0 ? -1 : 1) * pow(abs(ct), 2 / n)
|
|
let y = rect.midY + b * (st < 0 ? -1 : 1) * pow(abs(st), 2 / n)
|
|
if i == 0 { path.move(to: NSPoint(x: x, y: y)) } else { path.line(to: NSPoint(x: x, y: y)) }
|
|
}
|
|
path.close()
|
|
return path
|
|
}
|
|
|
|
func drawIcon(size: CGFloat) -> NSBitmapImageRep {
|
|
let px = Int(size.rounded())
|
|
let rep = NSBitmapImageRep(
|
|
bitmapDataPlanes: nil, pixelsWide: px, pixelsHigh: px,
|
|
bitsPerSample: 8, samplesPerPixel: 4, hasAlpha: true,
|
|
isPlanar: false, colorSpaceName: .deviceRGB,
|
|
bytesPerRow: 0, bitsPerPixel: 0
|
|
)!
|
|
rep.size = NSSize(width: px, height: px)
|
|
|
|
NSGraphicsContext.saveGraphicsState()
|
|
guard let ctx = NSGraphicsContext(bitmapImageRep: rep) else { return rep }
|
|
NSGraphicsContext.current = ctx
|
|
ctx.imageInterpolation = .high
|
|
|
|
let s = CGFloat(px) / BASE
|
|
// 小尺寸线条会糊,做一点加粗补偿
|
|
let bold: CGFloat = px <= 32 ? 1.16 : (px <= 64 ? 1.06 : 1.0)
|
|
// 尺寸分级:越小越简化,否则 16/32px 会糊成一团
|
|
let isTiny = px <= 32
|
|
let isSmall = px <= 64
|
|
let plateInset: CGFloat = isTiny ? 52 : (isSmall ? 72 : 100)
|
|
let glyphScale: CGFloat = isTiny ? 1.34 : (isSmall ? 1.20 : 1.10)
|
|
let showHoles = px >= 128 // 镂空文字在小图上会糊成一格一格
|
|
let showGlyphShadow = px >= 128
|
|
let showPlateShadow = px >= 128
|
|
|
|
func len(_ v: CGFloat) -> CGFloat { v * s }
|
|
/// 1024 基准坐标,y 按「距顶部」度量
|
|
func P(_ x: CGFloat, _ yTop: CGFloat) -> NSPoint { NSPoint(x: x * s, y: (BASE - yTop) * s) }
|
|
/// 矩形(x, 距顶 y, 宽, 高)
|
|
func R(_ x: CGFloat, _ yTop: CGFloat, _ w: CGFloat, _ h: CGFloat) -> NSRect {
|
|
NSRect(x: len(x), y: (BASE - yTop - h) * s, width: len(w), height: len(h))
|
|
}
|
|
func roundRect(_ x: CGFloat, _ yTop: CGFloat, _ w: CGFloat, _ h: CGFloat, _ radius: CGFloat) -> NSBezierPath {
|
|
NSBezierPath(roundedRect: R(x, yTop, w, h), xRadius: len(radius), yRadius: len(radius))
|
|
}
|
|
|
|
// MARK: 底板
|
|
|
|
let inset = len(plateInset)
|
|
let plate = NSRect(x: inset, y: inset,
|
|
width: CGFloat(px) - inset * 2,
|
|
height: CGFloat(px) - inset * 2)
|
|
let shape = squirclePath(in: plate)
|
|
|
|
let shadow = NSShadow()
|
|
shadow.shadowColor = NSColor.black.withAlphaComponent(0.30)
|
|
shadow.shadowBlurRadius = len(24)
|
|
shadow.shadowOffset = NSSize(width: 0, height: -len(9))
|
|
if showPlateShadow { shadow.set() }
|
|
|
|
let topColor = NSColor(srgbRed: 0.38, green: 0.50, blue: 1.00, alpha: 1) // #617FFF
|
|
let bottomColor = NSColor(srgbRed: 0.21, green: 0.19, blue: 0.75, alpha: 1) // #3630BF
|
|
NSGradient(starting: topColor, ending: bottomColor)?.draw(in: shape, angle: -90)
|
|
|
|
NSShadow().set() // 之后不再带投影
|
|
|
|
// 顶部柔光:整块区域线性渐变,避免出现横向硬边
|
|
NSGraphicsContext.saveGraphicsState()
|
|
shape.addClip()
|
|
NSGradient(colors: [
|
|
NSColor.white.withAlphaComponent(0.20),
|
|
NSColor.white.withAlphaComponent(0.0),
|
|
])?.draw(in: plate, angle: -90)
|
|
NSGraphicsContext.restoreGraphicsState()
|
|
|
|
NSColor.white.withAlphaComponent(0.10).setStroke()
|
|
shape.lineWidth = max(len(2), 0.5)
|
|
shape.stroke()
|
|
|
|
// MARK: 麦克风
|
|
|
|
let glyphShadow = NSShadow()
|
|
glyphShadow.shadowColor = NSColor.black.withAlphaComponent(0.20)
|
|
glyphShadow.shadowBlurRadius = len(14)
|
|
glyphShadow.shadowOffset = NSSize(width: 0, height: -len(7))
|
|
if showGlyphShadow { glyphShadow.set() }
|
|
|
|
NSColor.white.setFill()
|
|
NSColor.white.setStroke()
|
|
|
|
// 腔体:胶囊上挖出三条文字
|
|
let capX: CGFloat = 412, capW: CGFloat = 200
|
|
let capY: CGFloat = 225, capH: CGFloat = 300
|
|
NSGraphicsContext.current?.cgContext.concatenate(CGAffineTransform(a: glyphScale, b: 0, c: 0, d: glyphScale, tx: len(512) * (1 - glyphScale), ty: len(512) * (1 - glyphScale)))
|
|
let body = roundRect(capX, capY, capW, capH, capW / 2)
|
|
|
|
let lineX: CGFloat = 452
|
|
let lineH: CGFloat = 24 * bold
|
|
let lineY: [CGFloat] = [305, 363, 421]
|
|
let linePath = NSBezierPath()
|
|
for (i, y) in lineY.enumerated() {
|
|
let w: CGFloat = [120, 88, 104][i]
|
|
linePath.append(roundRect(lineX, y, w, lineH, lineH / 2))
|
|
}
|
|
|
|
let bodyWithHoles = NSBezierPath()
|
|
bodyWithHoles.append(body)
|
|
if showHoles { bodyWithHoles.append(linePath) }
|
|
bodyWithHoles.windingRule = .evenOdd
|
|
bodyWithHoles.fill()
|
|
|
|
// 托架
|
|
let arc = NSBezierPath()
|
|
arc.appendArc(withCenter: P(512, 525), radius: len(148),
|
|
startAngle: 180, endAngle: 360, clockwise: false)
|
|
arc.lineWidth = len(54 * bold)
|
|
arc.lineCapStyle = .round
|
|
arc.stroke()
|
|
|
|
// 立柱
|
|
roundRect(512 - 27, 660, 54, 92, 27).fill()
|
|
|
|
// 底座
|
|
roundRect(402, 745, 220, 54 * bold, 27 * bold).fill()
|
|
|
|
NSShadow().set()
|
|
NSGraphicsContext.restoreGraphicsState()
|
|
return rep
|
|
}
|
|
|
|
// MARK: - 输出
|
|
|
|
let outDir = CommandLine.arguments.count > 1 ? CommandLine.arguments[1] : "Resources"
|
|
let fm = FileManager.default
|
|
let iconset = (outDir as NSString).appendingPathComponent("AppIcon.iconset")
|
|
|
|
try? fm.removeItem(atPath: iconset)
|
|
try fm.createDirectory(atPath: iconset, withIntermediateDirectories: true)
|
|
|
|
let variants: [(String, Int)] = [
|
|
("icon_16x16", 16), ("icon_16x16@2x", 32),
|
|
("icon_32x32", 32), ("icon_32x32@2x", 64),
|
|
("icon_128x128", 128), ("icon_128x128@2x", 256),
|
|
("icon_256x256", 256), ("icon_256x256@2x", 512),
|
|
("icon_512x512", 512), ("icon_512x512@2x", 1024),
|
|
]
|
|
|
|
for (name, px) in variants {
|
|
let rep = drawIcon(size: CGFloat(px))
|
|
guard let data = rep.representation(using: .png, properties: [:]) else {
|
|
FileHandle.standardError.write("生成 \(name) 失败\n".data(using: .utf8)!)
|
|
exit(1)
|
|
}
|
|
try data.write(to: URL(fileURLWithPath: iconset).appendingPathComponent("\(name).png"))
|
|
}
|
|
|
|
let icns = (outDir as NSString).appendingPathComponent("AppIcon.icns")
|
|
let proc = Process()
|
|
proc.executableURL = URL(fileURLWithPath: "/usr/bin/iconutil")
|
|
proc.arguments = ["-c", "icns", iconset, "-o", icns]
|
|
try proc.run()
|
|
proc.waitUntilExit()
|
|
|
|
// 留一份 1024 PNG,供文档 / 网页 / 预览使用
|
|
try? fm.removeItem(atPath: (outDir as NSString).appendingPathComponent("AppIcon-1024.png"))
|
|
try? fm.copyItem(
|
|
atPath: (iconset as NSString).appendingPathComponent("icon_512x512@2x.png"),
|
|
toPath: (outDir as NSString).appendingPathComponent("AppIcon-1024.png")
|
|
)
|
|
|
|
guard proc.terminationStatus == 0 else {
|
|
FileHandle.standardError.write("iconutil 失败:\(proc.terminationStatus)\n".data(using: .utf8)!)
|
|
exit(1)
|
|
}
|
|
print("已生成 \(icns)")
|