#!/usr/bin/env python3 # -*- coding: utf-8 -*- """自绘安装器的位图素材生成器(构建期工具,产物随仓库一起提交)。 为什么要有这个脚本:安装器窗口是 NSIS 自绘的无边框窗口(760×480),界面本身就是一堆 钉死在固定坐标上的位图,NSIS 只负责「把图贴到点上」。窗口尺寸、文案、配色每改一次就要 重出一整套图,手工导图必然会出 1x/2x 不同步、半像素发虚、缺字形变方框这些问题,所以 整套素材全部由代码生成。 用法: python3 console/installer/art-gen/gen_art.py 产出: console/installer/art/1x/ 常规 DPI 用 console/installer/art/2x/ 4K@200% 缩放用(同一套常量乘 2) debug/安装界面预览/06-五页预览.png 人眼验收用的五页联络图(不参与打包) 改了设计就重跑本脚本,不要手改 art/ 里的位图:改图不改脚本,下次重跑就被覆盖, 而且 1x/2x 会悄悄失去同步。 几条硬约定: - 所有坐标/尺寸都按 1x 设计稿书写,2x 由同一套常量乘 2 得到,不写第二套数值; - 「✕ / — / ✓ / 圆点」这类符号在 Noto Sans SC 里没有字形,一律用线段和圆画出来; - 字体只用 noto-sans/sc 目录下的简体字重,tc(繁体)目录缺简体字形,会把字渲染成方框。 """ import os from PIL import Image, ImageDraw, ImageFont # ---------------------------------------------------------------- 路径 HERE = os.path.dirname(os.path.abspath(__file__)) # console/installer/art-gen REPO = os.path.dirname(os.path.dirname(os.path.dirname(HERE))) # 仓库根 ART_DIR = os.path.join(os.path.dirname(HERE), "art") # console/installer/art CONTACT_SHEET = os.path.join(REPO, "debug", "安装界面预览", "06-五页预览.png") # 图标源文件放在仓库根,打包脚本和安装器素材共用同一份,避免出现两个版本的应用图标 ICON_SRC = os.path.join(REPO, "Scoreboard.png") # 字体不在仓库里(三套字重加起来 25MB,装进仓库不划算),所以按本机绝对路径引用; # 换机器构建时把这个常量改掉即可,字体缺失会在 font() 里直接报错而不是悄悄退化 FONT_DIR = "/home/Aska/Fonts/noto-sans/sc" def repo_version(): """读仓库根 VERSION(版本号唯一真相),去掉 dev_ 前缀给安装包显示用。""" with open(os.path.join(REPO, "VERSION"), encoding="utf-8-sig") as fh: return (fh.readline().strip() or "0.0.0").removeprefix("dev_") # ---------------------------------------------------------------- 配色 NAVY = "#0b1e3a" # 标题栏 / 品牌栏渐变起点 NAVY_END = "#16406f" # 品牌栏渐变终点 CONTENT_BG = "#f6f9fd" # 右侧内容区底色 TXT = "#16233a" # 正文深色 TXT_DIM = "#5a6b85" # 次要文字 BRAND_WEAK = "#8ba6c6" # 品牌栏弱文字 BRAND_WEAKER = "#6b86a8" # 品牌栏最弱(版本号) PRIMARY = "#2f9bf0" # 主色 PRIMARY_HOVER = "#3fa9f5" PRIMARY_PRESSED = "#1f86d8" PRIMARY_SOFT = "#f0f6ff" # 次要按钮悬停底色 PRIMARY_SOFT_DOWN = "#dce9fa" # 次要按钮按下底色 CYAN = "#4fd8ff" # 品牌栏字标点缀 BORDER = "#cfdcec" DIVIDER = "#e3ebf5" CLOSE_HOVER = "#e5484d" CLOSE_PRESSED = "#c93b40" SUCCESS = "#34c759" TB_DIVIDER = "#1e3e69" # 标题栏底部分隔线 TB_TEXT = "#cfe3ff" TB_MIN = "#96afcd" TB_MIN_HOVER = "#cfe3ff" TB_CLOSE = "#bed2eb" TB_HOVER = "#1b2d4a" # 白色 8% 叠在海军蓝上的等效色 TB_HOVER_DOWN = "#2d3e56" # 白色 14% 叠在海军蓝上 # ---------------------------------------------------------------- 版面 WIN_W, WIN_H = 760, 480 TITLE_H = 40 SIDE_W = 240 BRAND_H = WIN_H - TITLE_H PAGE_X, PAGE_Y = SIDE_W, TITLE_H PAGE_W, PAGE_H = WIN_W - SIDE_W, WIN_H - TITLE_H PAD_L, PAD_T = 32, 28 CONTENT_W = PAGE_W - PAD_L * 2 # 456 DIVIDER_Y = 376 # 页面内按钮条的上边线 BTN_W, BTN_H = 108, 36 BTN_Y = 416 BTN_PRIMARY_X = 728 - BTN_W # 620,右边缘对齐内容区右边线 BTN_SECONDARY_X = 608 - BTN_W # 500,与主按钮间距 12 TB_MIN_BOX = (672, 0, 44, 40) TB_CLOSE_BOX = (716, 0, 44, 40) SS = 4 # 超采样倍数:PIL 画圆/圆角矩形没有抗锯齿,4 倍渲染再缩回来才有干净边缘 _font_cache = {} def font(weight, size, k): """取字重字体;size 是 1x 设计字号,k 是当前渲染倍率(= 输出倍率 × 超采样倍数)。""" key = (weight, size, k) if key not in _font_cache: path = os.path.join(FONT_DIR, "NotoSansSC-%s.otf" % weight) if not os.path.exists(path): raise SystemExit("字体缺失:%s(本机请确认 noto-sans/sc 三个字重在位)" % path) _font_cache[key] = ImageFont.truetype(path, size * k) return _font_cache[key] def U(k): """返回 1x 设计坐标 → 渲染像素的换算函数。 统一四舍五入:设计稿里存在 12.5、11.5 这类半像素字号和坐标,不取整会让 线条落在半像素上,缩回来变成两行灰边。 """ return lambda v: int(round(v * k)) def down(im): """超采样降采样,得到最终位图。""" return im.resize((im.width // SS, im.height // SS), Image.LANCZOS) def hgrad(w, h, c0, c1): """横向线性渐变。品牌栏底色与品牌栏位图共用本函数,两者叠加才不会露出接缝。""" a, b = ImageColor_rgb(c0), ImageColor_rgb(c1) im = Image.new("RGB", (w, h)) d = ImageDraw.Draw(im) for x in range(w): t = x / max(w - 1, 1) d.line([(x, 0), (x, h)], fill=tuple(round(a[i] + (b[i] - a[i]) * t) for i in range(3))) return im def ImageColor_rgb(s): s = s.lstrip("#") return tuple(int(s[i:i + 2], 16) for i in (0, 2, 4)) def box(p, x, y, w, h): """1x 的 (x, y, 宽, 高) → PIL 的闭区间 bbox(PIL 的右下角是含在内的)。""" return [p(x), p(y), p(x + w) - 1, p(y + h) - 1] def text_top(d, p, x, y, s, f, fill): """按「字形可见顶部」定位。 中英文混排时 ascender 高度不一致(数字/英文比汉字矮),直接给 y 会让同一组的 几行文字看起来忽高忽低,所以按实际墨迹框归位。 """ b = d.textbbox((0, 0), s, font=f, anchor="la") d.text((p(x), p(y) - b[1]), s, font=f, fill=fill, anchor="la") def text_bottom(d, p, x, y, s, f, fill): """按「字形可见底部」定位,用于版本号这种按底边距对齐的文字。""" b = d.textbbox((0, 0), s, font=f, anchor="la") d.text((p(x), p(y) - b[3]), s, font=f, fill=fill, anchor="la") def text_center(d, p, cx, cy, s, f, fill): """按墨迹框居中,用于按钮文字。""" b = d.textbbox((0, 0), s, font=f) d.text((p(cx) - (b[2] + b[0]) / 2, p(cy) - (b[3] + b[1]) / 2), s, font=f, fill=fill) def dot(d, p, cx, cy, r, fill): """画圆点。要点符号 fonts 里没有,只能画。""" d.ellipse([p(cx - r), p(cy - r), p(cx + r), p(cy + r)], fill=fill) def check(d, p, pts, color, width): """对勾用折线画。✓ 字形在 Noto Sans SC 里缺,硬渲染会出方框。""" d.line([(p(x), p(y)) for x, y in pts], fill=color, width=p(width), joint="curve") def paste_icon(im, p, x, y, w, h): """把应用图标等比缩放进 (x, y, w, h) 的方框并居中。 源图自带圆角透明背景,必须带 alpha 贴;另外先裁掉四周透明留白,否则同一张图 在标题栏和品牌栏两处的视觉大小会对不上。 """ src = Image.open(ICON_SRC).convert("RGBA") src = src.crop(src.getchannel("A").getbbox()) tw, th = p(w), p(h) r = min(tw / src.width, th / src.height) nw, nh = max(1, int(round(src.width * r))), max(1, int(round(src.height * r))) src = src.resize((nw, nh), Image.LANCZOS) im.paste(src, (p(x) + (tw - nw) // 2, p(y) + (th - nh) // 2), src) # ---------------------------------------------------------------- 背景与框架 def draw_base(k): """底层:标题栏 + 品牌栏渐变 + 内容区底色。 品牌栏渐变在这里也要画一遍(上面还会贴 brand.bmp),这样底图本身就是完整的, 单独看 base.bmp 也不会露出一块空白。 """ p = U(k) im = Image.new("RGB", (p(WIN_W), p(WIN_H)), CONTENT_BG) im.paste(hgrad(p(SIDE_W), p(BRAND_H), NAVY, NAVY_END), (0, p(TITLE_H))) d = ImageDraw.Draw(im) d.rectangle(box(p, 0, 0, WIN_W, TITLE_H), fill=NAVY) return down(im) def draw_brand(k): """品牌栏内容。底色是同一段渐变,贴在 base 的品牌栏上无缝。""" p = U(k) im = hgrad(p(SIDE_W), p(BRAND_H), NAVY, NAVY_END) d = ImageDraw.Draw(im) # 图标 x 与下方文字的左边距(36)取齐,不留出那 12px 的错位 paste_icon(im, p, 36, 48, 96, 96) text_top(d, p, 36, 172, "赛事积分榜", font("Bold", 26, k), "#ffffff") text_top(d, p, 36, 219, "S C O R E B O A R D", font("Medium", 11, k), CYAN) text_top(d, p, 36, 250, "一拖二 · 实时积分大屏", font("Regular", 12, k), BRAND_WEAK) text_top(d, p, 36, 272, "裁判长管理面板", font("Regular", 12, k), BRAND_WEAK) # 版本号按「左边距 36 / 底边距 40」定位,底部对齐比顶部对齐更稳 # 版本从仓库根 VERSION 读(唯一真相):烘焙进位图意味着发新版必须重跑本脚本, # deploy/prepare-extra.mjs 里有一道校验,版本对不上会直接让构建失败,不会悄悄发错版本 text_bottom(d, p, 36, BRAND_H - 40, f"版本 v{repo_version()}", font("Regular", 11, k), BRAND_WEAKER) return down(im) def draw_titlebar(k): """标题栏:图标 + 标题 + 右侧最小化/关闭。 两个按钮图形按 44×40 按钮框居中画,和 tb-*.bmp 的图形位置严格一致 —— 悬停时 NSIS 用按钮位图盖住这块区域,位置不一致的话图形会跳一下。 """ p = U(k) im = Image.new("RGB", (p(WIN_W), p(TITLE_H)), NAVY) d = ImageDraw.Draw(im) paste_icon(im, p, 14, 10, 20, 20) text_top(d, p, 44, 12, "赛事积分榜 安装程序", font("Medium", 14, k), TB_TEXT) min_cx = TB_MIN_BOX[0] + TB_MIN_BOX[2] / 2 close_cx = TB_CLOSE_BOX[0] + TB_CLOSE_BOX[2] / 2 d.line([(p(min_cx - 7), p(20)), (p(min_cx + 7), p(20))], fill=TB_MIN, width=p(2)) d.line([(p(close_cx - 6), p(14)), (p(close_cx + 6), p(26))], fill=TB_CLOSE, width=p(2)) d.line([(p(close_cx + 6), p(14)), (p(close_cx - 6), p(26))], fill=TB_CLOSE, width=p(2)) # 底部分隔线画在标题栏位图内部(画在 base 上会被标题栏位图盖掉) d.rectangle(box(p, 0, TITLE_H - 1, WIN_W, 1), fill=TB_DIVIDER) return down(im) # ---------------------------------------------------------------- 五个页面 def page_base(k): """页面公共底:内容区底色 + 按钮条上边线。""" p = U(k) im = Image.new("RGB", (p(PAGE_W), p(PAGE_H)), CONTENT_BG) d = ImageDraw.Draw(im) d.rectangle(box(p, 0, DIVIDER_Y, PAGE_W, 1), fill=DIVIDER) return im, d def page_head(d, p, k, title, desc=None): text_top(d, p, PAD_L, PAD_T, title, font("Bold", 20, k), TXT) if desc: text_top(d, p, PAD_L, 62, desc, font("Regular", 13, k), TXT_DIM) def draw_page_welcome(k): p = U(k) im, d = page_base(k) page_head(d, p, k, "欢迎安装", "本向导将把《赛事积分榜》安装到这台计算机。") items = [ "一拖二部署:一个服务端,同时带实时积分大屏与裁判长管理面板", "完全离线运行,不需要外网", "数据与赛事配置存在当前用户目录,覆盖安装不会丢", ] f = font("Regular", 13, k) y = 118 for it in items: b = d.textbbox((p(52), p(y)), it, font=f, anchor="la") # 圆点对齐每行墨迹的垂直中点,行内英文/数字混排时也不会跑偏 dot(d, p, PAD_L + 4, (b[1] + b[3]) / 2 / k, 4, PRIMARY) d.text((p(52), p(y)), it, font=f, fill=TXT, anchor="la") y += 32 return down(im) def draw_page_license(k): p = U(k) im, d = page_base(k) page_head(d, p, k, "许可协议", "请阅读以下条款。继续安装表示你接受这些条款。") lines = [ "本软件以 MIT 许可协议发布,你可以自由使用、修改和分发。", "分发或再发布时,必须保留原始的版权声明与许可声明。", "本软件按「现状」提供,不附带任何形式的明示或默示担保。", "因使用本软件造成的任何损失,作者不承担任何责任。", ] f = font("Regular", 13, k) y = 104 for ln in lines: text_top(d, p, PAD_L, y, ln, f, TXT) y += 22 # 只画外框,里面的协议正文由真控件渲染(位图放不下整篇协议) d.rectangle(box(p, PAD_L, 196, CONTENT_W, 176), outline=BORDER, width=p(1)) return down(im) def draw_page_dir(k): p = U(k) im, d = page_base(k) page_head(d, p, k, "选择安装位置", "安装程序将把《赛事积分榜》装到下面的目录。") # 这一条是「目录输入行」的外框:框内左侧跑真编辑框,右侧 96×34 的「浏览…」 # 按钮位图直接压在外框右端(框高 34 与按钮等高,所以按钮正好卡在框内) d.rectangle(box(p, PAD_L, 110, CONTENT_W, 34), outline=BORDER, width=p(1)) text_top(d, p, PAD_L, 170, "安装模式", font("Medium", 13, k), TXT) # 文字从 56 起排,左边 24px 留给单选钮位图 text_top(d, p, 56, 222, "仅为我安装(不需要管理员权限)", font("Regular", 12.5, k), TXT) text_top(d, p, 56, 262, "为所有用户安装(会弹 UAC,并自动放行防火墙 19540)", font("Regular", 12.5, k), TXT) return down(im) def draw_page_progress(k): p = U(k) im, d = page_base(k) # 进度页正文区留空:阶段文案和进度条都用独立位图贴上去(NSIS 换图=换状态) page_head(d, p, k, "正在安装") d.rounded_rectangle(box(p, PAD_L, 140, CONTENT_W, 8), radius=p(4), fill=DIVIDER) return down(im) def draw_page_finish(k): p = U(k) im, d = page_base(k) page_head(d, p, k, "安装完成", "《赛事积分榜》已安装到这台计算机。") # 成功标记在内容区水平居中:绿实心圆 + 白对勾 cx = PAGE_W / 2 cy = 164 d.ellipse([p(cx - 24), p(cy - 24), p(cx + 24), p(cy + 24)], fill=SUCCESS) check(d, p, [(cx - 11, cy + 0.5), (cx - 3, cy + 8.5), (cx + 12, cy - 8.5)], "#ffffff", 3.5) # 勾选项文字从 56 起排,左边 24px 留给勾选框位图 text_top(d, p, 56, 300, "立即运行 赛事积分榜", font("Regular", 13, k), TXT) text_top(d, p, PAD_L, 336, "安装位置:D:\\Program Files\\Scoreboard_Console", font("Regular", 11.5, k), TXT_DIM) return down(im) # ---------------------------------------------------------------- 按钮 def draw_button(k, label, w, h, kind, state): """圆角按钮三态。圆角外填内容区底色,贴到按钮条上才不会露出白角。""" p = U(k) im = Image.new("RGB", (p(w), p(h)), CONTENT_BG) d = ImageDraw.Draw(im) if kind == "primary": bg = {"normal": PRIMARY, "hover": PRIMARY_HOVER, "pressed": PRIMARY_PRESSED}[state] fg, border = "#ffffff", None else: bg = {"normal": "#ffffff", "hover": PRIMARY_SOFT, "pressed": PRIMARY_SOFT_DOWN}[state] border = BORDER if state == "normal" else PRIMARY fg = TXT d.rounded_rectangle([0, 0, p(w) - 1, p(h) - 1], radius=p(8), fill=bg, outline=border, width=p(1) if border else 0) text_center(d, p, w / 2, h / 2, label, font("Medium", 13, k), fg) return down(im) def draw_tb_button(k, kind, state): """标题栏按钮。常态底色=标题栏底色,贴上去和标题栏融为一体。""" p = U(k) im = Image.new("RGB", (p(44), p(40)), NAVY) d = ImageDraw.Draw(im) if kind == "close": bg = {"normal": NAVY, "hover": CLOSE_HOVER, "pressed": CLOSE_PRESSED}[state] fg = "#ffffff" if state != "normal" else TB_CLOSE else: bg = {"normal": NAVY, "hover": TB_HOVER, "pressed": TB_HOVER_DOWN}[state] fg = {"normal": TB_MIN, "hover": TB_MIN_HOVER, "pressed": "#ffffff"}[state] d.rectangle(box(p, 0, 0, 44, 40), fill=bg) if kind == "min": d.line([(p(15), p(20)), (p(29), p(20))], fill=fg, width=p(2)) else: d.line([(p(16), p(14)), (p(28), p(26))], fill=fg, width=p(2)) d.line([(p(28), p(14)), (p(16), p(26))], fill=fg, width=p(2)) return down(im) # ---------------------------------------------------------------- 控件 def draw_check(k, on): p = U(k) im = Image.new("RGB", (p(16), p(16)), CONTENT_BG) d = ImageDraw.Draw(im) if on: d.rounded_rectangle(box(p, 0, 0, 16, 16), radius=p(4), fill=PRIMARY) check(d, p, [(4.5, 8.5), (7, 11), (11.5, 5.5)], "#ffffff", 2) else: d.rounded_rectangle(box(p, 0, 0, 16, 16), radius=p(4), fill="#ffffff", outline=BORDER, width=p(1)) return down(im) def draw_radio(k, on): p = U(k) im = Image.new("RGB", (p(16), p(16)), CONTENT_BG) d = ImageDraw.Draw(im) if on: d.ellipse(box(p, 0, 0, 16, 16), fill="#ffffff", outline=PRIMARY, width=p(2)) d.ellipse(box(p, 5, 5, 6, 6), fill=PRIMARY) else: d.ellipse(box(p, 0, 0, 16, 16), fill="#ffffff", outline=BORDER, width=p(1)) return down(im) def draw_progress(k, step): """进度条 0..5 档,20% 一格。 进度条底与凹槽位图尺寸完全一致,NSIS 直接叠在凹槽上即可;填充部分左端保留圆角、 右端切齐,符合进度条「推进」的视觉。 """ p = U(k) im = Image.new("RGB", (p(456), p(8)), CONTENT_BG) d = ImageDraw.Draw(im) d.rounded_rectangle(box(p, 0, 0, 456, 8), radius=p(4), fill=DIVIDER) frac = step / 5 if frac > 0: # 用整体圆角矩形做蒙版,再按进度裁掉右半边,这样左端圆角自然、右端是直边 mask = Image.new("L", (p(456), p(8)), 0) ImageDraw.Draw(mask).rounded_rectangle(box(p, 0, 0, 456, 8), radius=p(4), fill=255) fill_im = Image.new("RGB", (p(456), p(8)), PRIMARY) cut = max(1, int(round(p(456) * frac))) region = (0, 0, cut, p(8)) im.paste(fill_im.crop(region), (0, 0), mask.crop(region)) return down(im) def draw_stage(k, text): """阶段文案位图,贴到进度页 (32,116):进度条上方一行,文案换行=状态切换。""" p = U(k) im = Image.new("RGB", (p(456), p(18)), CONTENT_BG) d = ImageDraw.Draw(im) f = font("Regular", 12.5, k) b = d.textbbox((0, 0), text, font=f, anchor="la") d.text((0, (p(18) - (b[3] - b[1])) / 2 - b[1]), text, font=f, fill=TXT_DIM, anchor="la") return down(im) # ---------------------------------------------------------------- 汇总 def all_art(s): """逐张产出某个倍率的全部素材。 为什么用生成器:k=8 时单张底图就有 70MB,一次性把几十张都留在内存里没必要。 """ k = s * SS yield "base.bmp", draw_base(k) yield "brand.bmp", draw_brand(k) yield "titlebar.bmp", draw_titlebar(k) yield "page-welcome.bmp", draw_page_welcome(k) yield "page-license.bmp", draw_page_license(k) yield "page-dir.bmp", draw_page_dir(k) yield "page-progress.bmp", draw_page_progress(k) yield "page-finish.bmp", draw_page_finish(k) for label, name in (("下一步", "next"), ("安装", "install"), ("完成", "finish")): for st in ("normal", "hover", "pressed"): yield "btn-%s-%s.bmp" % (name, st), draw_button(k, label, BTN_W, BTN_H, "primary", st) for label, name in (("取消", "cancel"), ("上一步", "back")): for st in ("normal", "hover", "pressed"): yield "btn-%s-%s.bmp" % (name, st), draw_button(k, label, BTN_W, BTN_H, "secondary", st) for st in ("normal", "hover", "pressed"): yield "btn-browse-%s.bmp" % st, draw_button(k, "浏览…", 96, 34, "secondary", st) for name in ("min", "close"): for st in ("normal", "hover", "pressed"): yield "tb-%s-%s.bmp" % (name, st), draw_tb_button(k, name, st) for st in ("off", "on"): yield "check-%s.bmp" % st, draw_check(k, st == "on") yield "radio-%s.bmp" % st, draw_radio(k, st == "on") for i in range(6): yield "progress-%d.bmp" % i, draw_progress(k, i) for i, t in enumerate(["正在解压程序文件…", "正在写入注册表…", "正在创建快捷方式…", "正在放行防火墙…", "正在收尾…"], start=1): yield "stage-%d.bmp" % i, draw_stage(k, t) # ---------------------------------------------------------------- 联络图 def compose(art, page, overlays): """按真实层级拼出 760×480 的成品观感图:底图 → 品牌栏 → 页面 → 标题栏 → 按钮/控件。""" im = Image.new("RGB", (WIN_W, WIN_H), CONTENT_BG) im.paste(art["base.bmp"], (0, 0)) im.paste(art["brand.bmp"], (0, TITLE_H)) im.paste(art["page-%s.bmp" % page], (PAGE_X, PAGE_Y)) im.paste(art["titlebar.bmp"], (0, 0)) for name, (x, y) in overlays: im.paste(art[name], (x, y)) return im def build_contact_sheet(art): """五页各拼一张成品图,纵向排成长图给人眼过一遍。 单看素材文件看不出层级和对位问题(例如标题栏图形与按钮位图错位、按钮压住正文), 所以验收必须看合成结果,而不是逐个位图看。 """ # 标题栏按钮和底部按钮条按窗口坐标贴,页面内的控件按「页内坐标 + 页面原点」贴, # 两类坐标混用很容易少加一次偏移,所以这里用 loc() 明确区分 def loc(x, y): return x + PAGE_X, y + PAGE_Y tb = [("tb-min-normal.bmp", (TB_MIN_BOX[0], TB_MIN_BOX[1])), ("tb-close-normal.bmp", (TB_CLOSE_BOX[0], TB_CLOSE_BOX[1]))] cancel = ("btn-cancel-normal.bmp", (BTN_SECONDARY_X, BTN_Y)) back = ("btn-back-normal.bmp", (BTN_SECONDARY_X, BTN_Y)) next_ = ("btn-next-normal.bmp", (BTN_PRIMARY_X, BTN_Y)) pages = [ compose(art, "welcome", tb + [cancel, next_]), compose(art, "license", tb + [cancel, next_]), compose(art, "dir", tb + [ ("btn-browse-normal.bmp", loc(392, 110)), ("radio-off.bmp", loc(32, 220)), ("radio-off.bmp", loc(32, 260)), back, next_, ]), # 进度页取中间一档(第 3 段 / 60%),静态图里最能说明「安装中」的样子 compose(art, "progress", tb + [ ("stage-3.bmp", loc(PAD_L, 116)), ("progress-3.bmp", loc(PAD_L, 140)), cancel, ]), compose(art, "finish", tb + [ ("check-on.bmp", loc(32, 298)), ("btn-finish-normal.bmp", (BTN_PRIMARY_X, BTN_Y)), ]), ] gap = 16 sheet = Image.new("RGB", (WIN_W + gap * 2, gap + len(pages) * (WIN_H + gap)), "#202a38") for i, page in enumerate(pages): sheet.paste(page, (gap, gap + i * (WIN_H + gap))) os.makedirs(os.path.dirname(CONTACT_SHEET), exist_ok=True) sheet.save(CONTACT_SHEET) print("写出 %s %s" % (CONTACT_SHEET, sheet.size)) def write_version_marker(): """把烘焙进位图的版本号另写一份纯文本旁证。 为什么需要这个文件:版本号是画在 brand.bmp 像素里的,构建脚本没法从位图反读出 版本,因此「素材是旧版本生成的、但打包用的是新版本号」这种错配完全看不出来, 会悄悄发出一个界面上写着错版本号的安装包。这里把 repo_version() 的结果落到 art/version.txt,构建脚本拿它和仓库根 VERSION 一比,对不上就让构建失败。 """ path = os.path.join(ART_DIR, "version.txt") with open(path, "w", encoding="utf-8", newline="\n") as fh: fh.write(repo_version() + "\n") print("写出 %s" % path) def main(): art1x = {} for s in (1, 2): out = os.path.join(ART_DIR, "%dx" % s) os.makedirs(out, exist_ok=True) n = 0 for name, im in all_art(s): im.save(os.path.join(out, name)) n += 1 if s == 1: art1x[name] = im # 联络图直接用 1x 成品,不要把 2x 缩一半再用(字会发虚) print("%s:%d 张" % (out, n)) write_version_marker() build_contact_sheet(art1x) if __name__ == "__main__": main()