Files
cmshoppe/scripts/gen_logo.py
T
chengmaandClaude Fable 5 049d4b6bbc feat(brand): add application logo for exe and window icon
新增品牌标识并接入运行时与打包流程。

- `scripts/gen_logo.py` 从单一几何定义同时产出 SVG 母版、多尺寸 ICO 和
  PNG 预览,避免母版与位图漂移;改样式改脚本后重新生成即可。
- 标记为暖橙圆角徽章 + 白色上升箭头(提升并发布)+ 四角星芒(AI 生成),
  刻意避开蝦皮官方购物袋标识,避免暗示官方关联。
- ICO 含 16/20/24/32/40/48/64/128/256 九档,每档从矢量几何独立渲染而非
  由大图缩放,保证任务栏与资源管理器小尺寸清晰。
- `app/assets` 作为可导入包提供无 Qt 依赖的路径解析,兼容源码树、
  PyInstaller onedir 的 _MEIPASS 与 exe 同级目录三种布局。
- `widgets.app_icon()` 在 Qt 缺失或资源未生成时返回 None,调用方容错;
  在 QApplication 上设置图标以便任务栏、对话框和消息框一并继承,
  MainWindow 另行设置以覆盖直接构造窗口的测试路径。
- 两个 spec 均加 `icon=`,主 spec 收集资源到 datas 并在资源缺失时直接
  报错提示先跑生成脚本。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 16:39:18 +08:00

173 lines
5.9 KiB
Python

#!/usr/bin/env python3
"""Generate the cmshopee application logo.
Geometry lives here once and is emitted to both the editable SVG master and
the rasterised Windows icon, so the two can never drift apart.
Usage:
py -3.10 scripts/gen_logo.py
Outputs (all under app/assets/):
cmshopee-logo.svg editable master
cmshopee.ico multi-size Windows icon (exe + window icon)
cmshopee-256.png preview / non-Windows fallback
The mark is an upward arrow rising from a base bar: the product listing being
lifted and published. It deliberately avoids Shopee's own bag glyph so the
tool is not mistaken for an official Shopee application.
"""
from __future__ import annotations
import os
import sys
try:
from PIL import Image, ImageDraw
except ImportError: # pragma: no cover - developer tooling only
sys.exit("需要 Pillow 才能生成图标:py -3.10 -m pip install Pillow")
CANVAS = 256
SUPERSAMPLE = 8
ICO_SIZES = (16, 20, 24, 32, 40, 48, 64, 128, 256)
# Badge
BADGE = (10, 10, 246, 246) # x0, y0, x1, y1
BADGE_RADIUS = 54
GRADIENT_FROM = (245, 121, 63) # #F5793F
GRADIENT_TO = (214, 55, 28) # #D6371C
# Mark (white): an arrow lifting a listing off its base, plus an AI sparkle.
ARROW_HEAD = ((112, 58), (56, 116), (168, 116))
ARROW_STEM = (90, 110, 134, 162) # x0, y0, x1, y1
ARROW_STEM_RADIUS = 10
BASE_BAR = (56, 178, 168, 204)
BASE_BAR_RADIUS = 13
MARK_COLOR = (255, 255, 255)
# Four-point sparkle: the AI generation step. Degrades to a small accent dot
# at 16px rather than turning to mush.
SPARKLE_CENTER = (194, 74)
SPARKLE_OUTER = 34
SPARKLE_INNER = 11
ASSETS_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "app", "assets")
def _hex(rgb):
return "#%02X%02X%02X" % rgb
def _gradient(size):
"""Diagonal linear gradient, built small and scaled up (it is smooth)."""
base = Image.new("RGB", (CANVAS, CANVAS))
pixels = base.load()
span = 2 * (CANVAS - 1)
for y in range(CANVAS):
for x in range(CANVAS):
t = (x + y) / span
pixels[x, y] = tuple(
round(GRADIENT_FROM[i] + (GRADIENT_TO[i] - GRADIENT_FROM[i]) * t)
for i in range(3)
)
return base.resize((size, size), Image.LANCZOS)
def _scaled(values, factor):
return [value * factor for value in values]
def _sparkle_points():
"""Eight alternating outer/inner points forming a four-point star."""
cx, cy = SPARKLE_CENTER
o, i = SPARKLE_OUTER, SPARKLE_INNER
diagonal = i * (2 ** 0.5) / 2
return [
(cx, cy - o),
(cx + diagonal, cy - diagonal),
(cx + o, cy),
(cx + diagonal, cy + diagonal),
(cx, cy + o),
(cx - diagonal, cy + diagonal),
(cx - o, cy),
(cx - diagonal, cy - diagonal),
]
def render(size):
"""Render one square icon at the given pixel size."""
work = size * SUPERSAMPLE
factor = work / CANVAS
badge_mask = Image.new("L", (work, work), 0)
draw = ImageDraw.Draw(badge_mask)
draw.rounded_rectangle(_scaled(BADGE, factor), radius=BADGE_RADIUS * factor, fill=255)
mark_mask = Image.new("L", (work, work), 0)
draw = ImageDraw.Draw(mark_mask)
draw.polygon([(x * factor, y * factor) for x, y in ARROW_HEAD], fill=255)
draw.rounded_rectangle(_scaled(ARROW_STEM, factor), radius=ARROW_STEM_RADIUS * factor, fill=255)
draw.rounded_rectangle(_scaled(BASE_BAR, factor), radius=BASE_BAR_RADIUS * factor, fill=255)
draw.polygon([(x * factor, y * factor) for x, y in _sparkle_points()], fill=255)
icon = Image.new("RGBA", (work, work), (0, 0, 0, 0))
icon.paste(_gradient(work), (0, 0), badge_mask)
icon.paste(Image.new("RGBA", (work, work), MARK_COLOR + (255,)), (0, 0), mark_mask)
return icon.resize((size, size), Image.LANCZOS)
def write_svg(path):
head = "M {} {} L {} {} L {} {} Z".format(*[c for point in ARROW_HEAD for c in point])
sparkle = " ".join(f"{x:.1f},{y:.1f}" for x, y in _sparkle_points())
svg = f"""<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 {CANVAS} {CANVAS}" width="{CANVAS}" height="{CANVAS}" role="img" aria-label="蝦皮圈優化助手">
<title>蝦皮圈優化助手</title>
<defs>
<linearGradient id="badge" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="{_hex(GRADIENT_FROM)}"/>
<stop offset="1" stop-color="{_hex(GRADIENT_TO)}"/>
</linearGradient>
</defs>
<rect x="{BADGE[0]}" y="{BADGE[1]}" width="{BADGE[2] - BADGE[0]}" height="{BADGE[3] - BADGE[1]}" rx="{BADGE_RADIUS}" fill="url(#badge)"/>
<path d="{head}" fill="{_hex(MARK_COLOR)}"/>
<rect x="{ARROW_STEM[0]}" y="{ARROW_STEM[1]}" width="{ARROW_STEM[2] - ARROW_STEM[0]}" height="{ARROW_STEM[3] - ARROW_STEM[1]}" rx="{ARROW_STEM_RADIUS}" fill="{_hex(MARK_COLOR)}"/>
<rect x="{BASE_BAR[0]}" y="{BASE_BAR[1]}" width="{BASE_BAR[2] - BASE_BAR[0]}" height="{BASE_BAR[3] - BASE_BAR[1]}" rx="{BASE_BAR_RADIUS}" fill="{_hex(MARK_COLOR)}"/>
<polygon points="{sparkle}" fill="{_hex(MARK_COLOR)}"/>
</svg>
"""
with open(path, "w", encoding="utf-8") as fh:
fh.write(svg)
def main():
os.makedirs(ASSETS_DIR, exist_ok=True)
# Each size is rendered from the vector geometry rather than downscaled from
# one bitmap, so 16px and 20px stay crisp in Explorer and the taskbar.
images = [render(size) for size in ICO_SIZES]
largest = images[-1]
ico_path = os.path.join(ASSETS_DIR, "cmshopee.ico")
largest.save(
ico_path,
format="ICO",
sizes=[(size, size) for size in ICO_SIZES],
append_images=images[:-1],
)
png_path = os.path.join(ASSETS_DIR, "cmshopee-256.png")
largest.save(png_path, format="PNG")
svg_path = os.path.join(ASSETS_DIR, "cmshopee-logo.svg")
write_svg(svg_path)
for path in (svg_path, ico_path, png_path):
print(f"已生成 {os.path.relpath(path, os.path.dirname(ASSETS_DIR))} {os.path.getsize(path)} bytes")
if __name__ == "__main__":
main()