Files
Scoreboard/console/installer/art-gen/gen_art.py
T
AskaEth 877ff15823 feat(0.5.1): 业务逻辑审查 9 条 P0 全修 + 自绘安装器 + 现场反馈四项
## 算分正确性(6 份只读审查 → 收敛成 5 个病根,均先复现失败再修)
- 「人工名次」与「自动名次回写」互斥:原来同一份成绩**发布两次分数不同**
  (团体报名项 dual 通道 10/8/6 → 25/18/14),现在连发多次逐字节一致
- 删小项时在同一事务作废旧流水并推榜:原来分数**永久残留**在总榜、任何重算都打不到
- 规则包导入整体事务化:原来中途失败会清空名次表且补不回(奖牌全丢);
  另加守卫「compare=none 的小项不得配 basis=result/aggregate」
- 末位归零只抹真正落在末位的那一档:原来跨末位的并列组会被整组抹 0
  (7 人三人并列第 5 时第 5、6 名的分也一起没了)
- 模板「分值表档位超出录取名次」现在拒绝保存:原来静默截断成 0 分

## 现场反馈(主人实测报的)
- 已发布成绩可改:行内「修改本条」+ 成绩格/状态格可点(退回草稿→改→重新发布本组),
  与「作废」语义分开;取消路径不产生任何副作用
- 单位积分明细补「运动员 / 来源」列,并显示「已修订 ×N(时间·原因)」:
  管理端、大屏覆盖层、xlsx 备注列三处同源
- 单位奖牌榜改为按奖牌独立排名(金→银→铜→单位编码),与积分榜解耦
- 历史孤儿积分流水清理(迁移 0022,只动 result/record_bonus,人工调整分不受影响)

## 新功能
- 团体项目聚合口径二选一:平均(默认)/ 总和(迁移 0023,通道编辑器可切)

## 安装器
- 自绘五页安装界面:无边框、自绘标题栏、DPI 自适应(4K@200% 实测)、
  安装/卸载逻辑复用 electron-builder 宏不变;默认「为所有用户安装」并自动放行防火墙 19540
- 构建期校验:安装器素材里的版本号必须与 VERSION 一致,否则构建失败

自检:服务端 14 包 ×2 / 前端 vue-tsc + build + 742 例 ×2 / Console 20 文件 + 68 例 ×2,全绿。
端到端对拍:修前 vs 修后,纯团体 / 双计 / 普通个人三形态**逐字节一致**,仅出 bug 的双通道形态按要求改变。
2026-09-13 15:20:18 +08:00

598 lines
24 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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()