f20de12fa2
图标: - 添加白色圆角矩形底板,占画布 80%(四周留透明呼吸边距) - M 内容占底板 65%,裁剪透明边距后居中 - 底板微妙渐变(#FAFAFA → #F0F0F0) - 清理原始图标幽灵半透明像素 - 全平台图标重新生成(PNG / ICNS / ICO / Android / iOS) 运维: - docker-compose.prod.yml & test.yml 添加 json-file 日志轮转 max-size: 100m, max-file: 5 - scripts/admin-ops.sql: 新增用户、积分赠送、积分补偿、批量补偿 - scripts/generate-rounded-icon.py: 可复用的图标生成脚本 其他: - prompts 文件重命名为语义化文件名 - .gitignore 移除 binaries/ 忽略(FFmpeg sidecar 需提交)
304 lines
9.4 KiB
Python
304 lines
9.4 KiB
Python
#!/usr/bin/env python3
|
||
"""生成 macOS Big Sur 风格圆角矩形图标"""
|
||
|
||
import os
|
||
from PIL import Image, ImageDraw, ImageFilter
|
||
|
||
ICONS_DIR = "/Users/0fun/work/meijiaka-zy/tauri-app/src-tauri/icons"
|
||
SOURCE_PNG = "/tmp/original-source-icon.png"
|
||
|
||
# macOS Big Sur 圆角比例 ≈ 22.6%
|
||
CORNER_RATIO = 0.226
|
||
|
||
# 输出尺寸列表 (文件名, 尺寸)
|
||
PNG_SIZES = [
|
||
("icon.png", 512),
|
||
("128x128@2x.png", 256),
|
||
("128x128.png", 128),
|
||
("32x32.png", 32),
|
||
("64x64.png", 64),
|
||
]
|
||
|
||
# Windows Store/Square 尺寸
|
||
SQUARE_SIZES = [
|
||
("Square310x310Logo.png", 310),
|
||
("Square284x284Logo.png", 284),
|
||
("Square150x150Logo.png", 150),
|
||
("Square142x142Logo.png", 142),
|
||
("Square107x107Logo.png", 107),
|
||
("Square89x89Logo.png", 89),
|
||
("Square71x71Logo.png", 71),
|
||
("Square44x44Logo.png", 44),
|
||
("Square30x30Logo.png", 30),
|
||
("StoreLogo.png", 50),
|
||
]
|
||
|
||
|
||
def create_rounded_rect_mask(size: int, radius: int) -> Image.Image:
|
||
"""创建圆角矩形蒙版"""
|
||
mask = Image.new("L", (size, size), 0)
|
||
draw = ImageDraw.Draw(mask)
|
||
draw.rounded_rectangle((0, 0, size, size), radius=radius, fill=255)
|
||
return mask
|
||
|
||
|
||
def create_big_sur_background(size: int) -> Image.Image:
|
||
"""创建 macOS Big Sur 风格圆角矩形底板(微妙渐变)"""
|
||
radius = int(size * CORNER_RATIO)
|
||
|
||
# 顶部稍亮、底部稍暗的微妙渐变
|
||
bg = Image.new("RGBA", (size, size), (255, 255, 255, 0))
|
||
draw = ImageDraw.Draw(bg)
|
||
for y in range(size):
|
||
val = int(250 - (y / size) * 10)
|
||
draw.line([(0, y), (size, y)], fill=(val, val, val, 255))
|
||
|
||
# 圆角裁剪
|
||
mask = create_rounded_rect_mask(size, radius)
|
||
masked = Image.new("RGBA", (size, size), (255, 255, 255, 0))
|
||
masked.paste(bg, mask=mask)
|
||
|
||
return masked
|
||
|
||
|
||
def create_shadow_layer(size: int, radius: int) -> Image.Image:
|
||
"""创建底板外部阴影(用于大尺寸)"""
|
||
pad = int(size * 0.06)
|
||
canvas_size = size + pad * 2
|
||
shadow = Image.new("RGBA", (canvas_size, canvas_size), (0, 0, 0, 0))
|
||
draw = ImageDraw.Draw(shadow)
|
||
draw.rounded_rectangle(
|
||
(pad, pad, pad + size, pad + size),
|
||
radius=radius,
|
||
fill=(0, 0, 0, 40),
|
||
)
|
||
shadow = shadow.filter(ImageFilter.GaussianBlur(radius=pad // 2))
|
||
return shadow, pad
|
||
|
||
|
||
def clean_source_icon(source: Image.Image) -> Image.Image:
|
||
"""清理原始图标中的幽灵半透明像素(如内层圆角矩形轮廓)"""
|
||
rgba = source.convert("RGBA")
|
||
pixels = rgba.load()
|
||
width, height = rgba.size
|
||
for y in range(height):
|
||
for x in range(width):
|
||
r, g, b, a = pixels[x, y]
|
||
if a < 250:
|
||
pixels[x, y] = (0, 0, 0, 0)
|
||
else:
|
||
pixels[x, y] = (r, g, b, 255)
|
||
return rgba
|
||
|
||
|
||
def _prepare_m_icon(source: Image.Image, plate_size: int) -> Image.Image:
|
||
"""裁剪 M 的透明边距,并缩放到合适的视觉比例"""
|
||
bbox = source.getbbox()
|
||
if bbox:
|
||
m_cropped = source.crop(bbox)
|
||
else:
|
||
m_cropped = source
|
||
|
||
# 参考 macOS 有底板图标(Xcode、系统设置等),内容占底板约 65%
|
||
target_size = int(plate_size * 0.65)
|
||
m_cropped.thumbnail((target_size, target_size), Image.LANCZOS)
|
||
return m_cropped
|
||
|
||
|
||
def compose_icon(size: int, source: Image.Image, add_shadow: bool = False) -> Image.Image:
|
||
"""将 M 图标合成到圆角矩形底板上(底板占画布80%,四周留透明边距)"""
|
||
# macOS 图标底板占画布约 80%,四周留透明呼吸边距
|
||
plate_size = int(size * 0.80)
|
||
plate_offset = (size - plate_size) // 2
|
||
|
||
if add_shadow and size >= 128:
|
||
shadow, pad = create_shadow_layer(plate_size, int(plate_size * CORNER_RATIO))
|
||
canvas = Image.new("RGBA", (size + pad * 2, size + pad * 2), (0, 0, 0, 0))
|
||
canvas.paste(shadow, (0, 0), shadow)
|
||
|
||
bg = create_big_sur_background(plate_size)
|
||
canvas.paste(bg, (pad + plate_offset, pad + plate_offset), bg)
|
||
|
||
m_img = _prepare_m_icon(source, plate_size)
|
||
offset_x = (plate_size - m_img.width) // 2
|
||
offset_y = (plate_size - m_img.height) // 2
|
||
canvas.paste(m_img, (pad + plate_offset + offset_x, pad + plate_offset + offset_y), m_img)
|
||
|
||
return canvas
|
||
else:
|
||
canvas = Image.new("RGBA", (size, size), (0, 0, 0, 0))
|
||
|
||
bg = create_big_sur_background(plate_size)
|
||
canvas.paste(bg, (plate_offset, plate_offset), bg)
|
||
|
||
m_img = _prepare_m_icon(source, plate_size)
|
||
offset_x = (plate_size - m_img.width) // 2
|
||
offset_y = (plate_size - m_img.height) // 2
|
||
canvas.paste(m_img, (plate_offset + offset_x, plate_offset + offset_y), m_img)
|
||
|
||
return canvas
|
||
|
||
|
||
def generate_icns(source: Image.Image, output_path: str):
|
||
"""生成 macOS .icns 文件"""
|
||
import tempfile
|
||
import subprocess
|
||
|
||
sizes = [16, 32, 64, 128, 256, 512, 1024]
|
||
iconset_dir = tempfile.mkdtemp(suffix=".iconset")
|
||
|
||
for sz in sizes:
|
||
img = compose_icon(sz, source)
|
||
img.save(os.path.join(iconset_dir, f"icon_{sz}x{sz}.png"))
|
||
if sz <= 512:
|
||
img2x = compose_icon(sz * 2, source)
|
||
img2x.save(os.path.join(iconset_dir, f"icon_{sz}x{sz}@2x.png"))
|
||
|
||
subprocess.run(
|
||
["iconutil", "-c", "icns", iconset_dir, "-o", output_path],
|
||
check=True,
|
||
)
|
||
|
||
# 清理
|
||
import shutil
|
||
shutil.rmtree(iconset_dir)
|
||
|
||
|
||
def generate_ico(source: Image.Image, output_path: str):
|
||
"""生成 Windows .ico 文件(手动组装,支持多分辨率 PNG 嵌入)"""
|
||
import struct
|
||
import io
|
||
|
||
sizes = [16, 24, 32, 48, 64, 128, 256]
|
||
png_datas = []
|
||
entries = []
|
||
|
||
for sz in sizes:
|
||
img = compose_icon(sz, source)
|
||
buf = io.BytesIO()
|
||
img.save(buf, format="PNG")
|
||
data = buf.getvalue()
|
||
png_datas.append(data)
|
||
entries.append((sz, len(data)))
|
||
|
||
# ICO 文件头: Reserved(2) + Type(2) + Count(2)
|
||
ico = struct.pack("<HHH", 0, 1, len(sizes))
|
||
|
||
# 计算数据偏移量: 文件头(6) + 目录项(16 * count)
|
||
data_offset = 6 + 16 * len(sizes)
|
||
|
||
# ICONDIRENTRY
|
||
for sz, size_bytes in entries:
|
||
width = sz if sz < 256 else 0
|
||
height = sz if sz < 256 else 0
|
||
ico += struct.pack(
|
||
"<BBBBHHII",
|
||
width, # Width
|
||
height, # Height
|
||
0, # Colors (0 for >256)
|
||
0, # Reserved
|
||
1, # Color planes
|
||
32, # Bits per pixel
|
||
size_bytes, # Size in bytes
|
||
data_offset, # Offset
|
||
)
|
||
data_offset += size_bytes
|
||
|
||
# 追加图像数据
|
||
for data in png_datas:
|
||
ico += data
|
||
|
||
with open(output_path, "wb") as f:
|
||
f.write(ico)
|
||
|
||
|
||
def generate_android(source: Image.Image, android_dir: str):
|
||
"""生成 Android 图标"""
|
||
android_sizes = {
|
||
"mipmap-hdpi": 72,
|
||
"mipmap-mdpi": 48,
|
||
"mipmap-xhdpi": 96,
|
||
"mipmap-xxhdpi": 144,
|
||
"mipmap-xxxhdpi": 192,
|
||
}
|
||
for folder, sz in android_sizes.items():
|
||
folder_path = os.path.join(android_dir, folder)
|
||
os.makedirs(folder_path, exist_ok=True)
|
||
img = compose_icon(sz, source)
|
||
img.save(os.path.join(folder_path, "ic_launcher.png"))
|
||
# foreground / background / round / monochrome 等可能不需要更新
|
||
|
||
|
||
def generate_ios(source: Image.Image, ios_dir: str):
|
||
"""生成 iOS 图标"""
|
||
ios_sizes = [
|
||
("AppIcon-20x20@1x.png", 20),
|
||
("AppIcon-20x20@2x.png", 40),
|
||
("AppIcon-20x20@3x.png", 60),
|
||
("AppIcon-29x29@1x.png", 29),
|
||
("AppIcon-29x29@2x.png", 58),
|
||
("AppIcon-29x29@3x.png", 87),
|
||
("AppIcon-40x40@1x.png", 40),
|
||
("AppIcon-40x40@2x.png", 80),
|
||
("AppIcon-40x40@3x.png", 120),
|
||
("AppIcon-512@2x~ipad.png", 1024),
|
||
("AppIcon-512x512@1x.png", 512),
|
||
("AppIcon-512x512@2x.png", 1024),
|
||
("AppIcon-60x60@2x.png", 120),
|
||
("AppIcon-60x60@3x.png", 180),
|
||
("AppIcon-76x76@1x.png", 76),
|
||
("AppIcon-76x76@2x.png", 152),
|
||
("AppIcon-83.5x83.5@2x.png", 167),
|
||
("AppIcon-Notification@3x.png", 60),
|
||
]
|
||
for filename, sz in ios_sizes:
|
||
img = compose_icon(sz, source)
|
||
img.save(os.path.join(ios_dir, filename))
|
||
|
||
|
||
def main():
|
||
source_raw = Image.open(SOURCE_PNG).convert("RGBA")
|
||
source = clean_source_icon(source_raw)
|
||
|
||
print(f"源图标尺寸: {source.size}")
|
||
|
||
# 生成基础 PNG
|
||
for filename, size in PNG_SIZES:
|
||
path = os.path.join(ICONS_DIR, filename)
|
||
img = compose_icon(size, source)
|
||
img.save(path)
|
||
print(f"已生成: {filename} ({size}x{size})")
|
||
|
||
# 生成 Square Logo(Windows)
|
||
for filename, size in SQUARE_SIZES:
|
||
path = os.path.join(ICONS_DIR, filename)
|
||
img = compose_icon(size, source)
|
||
img.save(path)
|
||
print(f"已生成: {filename} ({size}x{size})")
|
||
|
||
# 生成 .icns
|
||
icns_path = os.path.join(ICONS_DIR, "icon.icns")
|
||
generate_icns(source, icns_path)
|
||
print(f"已生成: icon.icns")
|
||
|
||
# 生成 .ico
|
||
ico_path = os.path.join(ICONS_DIR, "icon.ico")
|
||
generate_ico(source, ico_path)
|
||
print(f"已生成: icon.ico")
|
||
|
||
# 生成 Android
|
||
android_dir = os.path.join(ICONS_DIR, "android")
|
||
generate_android(source, android_dir)
|
||
print(f"已生成: Android 图标")
|
||
|
||
# 生成 iOS
|
||
ios_dir = os.path.join(ICONS_DIR, "ios")
|
||
generate_ios(source, ios_dir)
|
||
print(f"已生成: iOS 图标")
|
||
|
||
print("\n全部完成!")
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|