Files
HD-Icons/scripts/build.py
T

410 lines
14 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 -*-
"""
HD-Icons 一键构建脚本(本地 / GitHub Actions 通用)
流程:
1. 校验 inbox 新文件命名
2. TinyPNG 无损压缩 inbox 中的 PNGSVG 不压缩)
3. 分发入库:inbox 文件移入对应目录,重名按"同前缀最大序号+1"自动改名
4. 全量重建 icons.json(输出格式与历史版本完全一致)
5. 更新 README.md 中的数量标记 <!--ICONS:xxx-->
6. 重新生成三张合并预览图(_icons-radius.jpg / _icons-circle.jpg / _icons-svg.jpg
用法:
python scripts/build.py --repo .
环境变量:
TINYPNG_KEY TinyPNG API 密钥(inbox 有 PNG 时必需;GitHub 上配置为 Secret
HDICONS_FONT 预览图标签字体路径(可选,默认自动查找)
"""
import argparse
import base64
import json
import os
import re
import shutil
import subprocess
import sys
import tempfile
import urllib.error
import urllib.request
from datetime import datetime
from pathlib import Path
FOLDERS = ["border-radius", "circle", "svg"]
RAW_BASE = "https://raw.githubusercontent.com/xushier/HD-Icons/main"
IMAGE_EXTS = {".png", ".jpg", ".jpeg", ".svg", ".gif"}
NAME_RE = re.compile(r"^[a-z0-9]+(-[a-z0-9]+)*$")
NUM_SUFFIX_RE = re.compile(r"-\d+$")
README_MARKER_RE = re.compile(r"(<!--ICONS:(\w+)-->)(.*?)(<!--/ICONS:\2-->)")
# 预览图排版参数
PREVIEW = {
"icon": 128, # 单个图标缩略尺寸
"cols": 15, # 每行图标数
"gap_x": 30, # 水平间距
"gap_y": 62, # 垂直间距(含文件名文字区域)
"pad": 20, # 四周留白
"text_size": 17, # 文件名字号
"bg": (244, 246, 249),
"fg": (62, 66, 72),
}
def log(msg):
print(msg, flush=True)
def warn(msg):
print(f"[警告] {msg}", file=sys.stderr, flush=True)
def fail(msg):
print(f"[错误] {msg}", file=sys.stderr, flush=True)
sys.exit(1)
# ---------------------------------------------------------------- 1. 校验 inbox
def validate_inbox(repo):
"""检查 inbox 内新文件的类型与命名,返回 (errors, warnings, files)。"""
errors, warnings, files = [], [], {}
for folder in FOLDERS:
inbox_dir = repo / "inbox" / folder
items = []
if not inbox_dir.is_dir():
files[folder] = items
continue
expected = ".svg" if folder == "svg" else ".png"
for f in sorted(inbox_dir.iterdir()):
if f.name == ".gitkeep" or not f.is_file():
continue
if f.suffix.lower() != expected:
errors.append(f"inbox/{folder}/ 只接受 {expected} 文件,发现:{f.name}")
continue
if not NAME_RE.match(f.stem):
errors.append(f"命名不合法(仅允许小写字母/数字/连字符):{f.name}")
continue
if not NUM_SUFFIX_RE.search(f.stem):
warnings.append(f"{f.name} 未以数字结尾,建议形如 name-1 的命名")
items.append(f)
files[folder] = items
return errors, warnings, files
# ---------------------------------------------------------------- 2. TinyPNG 压缩
def tinify(path, key):
"""调用 TinyPNG API 压缩单个 PNG,返回 (压缩前字节数, 压缩后字节数, 已用额度)。"""
data = path.read_bytes()
req = urllib.request.Request("https://api.tinify.com/shrink", data=data)
req.add_header("Authorization",
"Basic " + base64.b64encode(f"api:{key}".encode()).decode())
with urllib.request.urlopen(req, timeout=180) as resp:
info = json.loads(resp.read().decode())
quota = (resp.headers.get("Compression-Count")
or resp.headers.get("X-Compression-Count"))
with urllib.request.urlopen(info["output"]["url"], timeout=180) as r:
out = r.read()
if len(out) < len(data):
path.write_bytes(out)
return len(data), path.stat().st_size, quota
def compress_inbox(files, key):
saved = 0
quota = None
pngs = files["border-radius"] + files["circle"]
for f in pngs:
try:
before, after, quota = tinify(f, key)
except urllib.error.HTTPError as e:
if e.code == 429:
fail("TinyPNG 本月免费额度已用完(500 张/月),本次未做任何改动")
fail(f"压缩失败 {f.name}HTTP {e.code} {e.reason}")
except (urllib.error.URLError, OSError) as e:
fail(f"压缩失败 {f.name}:网络错误 {e}")
saved += before - after
log(f" {f.name}: {before // 1024}KB -> {after // 1024}KB")
msg = f"共压缩 {len(pngs)} 张,节省 {saved // 1024}KB"
if quota:
msg += f"(本月已用 {quota}/500"
log(msg)
# ---------------------------------------------------------------- 3. 分发入库
def available_name(target_dir, name):
"""若目标已存在同名文件,按"同前缀最大序号+1"改名,否则保持原名。"""
if not (target_dir / name).exists():
return name
stem, ext = Path(name).stem, Path(name).suffix
prefix = NUM_SUFFIX_RE.sub("", stem)
max_num = 0
for f in target_dir.iterdir():
m = re.fullmatch(re.escape(prefix) + r"-(\d+)", f.stem)
if m and f.suffix.lower() == ext.lower():
max_num = max(max_num, int(m.group(1)))
n = max_num + 1
while (target_dir / f"{prefix}-{n}{ext}").exists():
n += 1
return f"{prefix}-{n}{ext}"
def dispatch(repo, files):
moved = []
for folder, items in files.items():
if not items:
continue
target = repo / folder
for f in items:
new_name = available_name(target, f.name)
if new_name != f.name:
log(f" 重名处理:{folder}/{f.name} -> {new_name}")
shutil.move(str(f), str(target / new_name))
moved.append(f"{folder}/{new_name}")
return moved
# ---------------------------------------------------------------- 4. icons.json
def build_icons_json(repo):
"""全量重建 icons.json,逻辑与历史脚本一致(含跨目录重名加后缀的去重行为)。"""
icons, used = [], set()
counts = {"radius": 0, "circle": 0, "svg": 0}
for folder in FOLDERS:
d = repo / folder
if not d.is_dir():
warn(f"文件夹不存在,跳过:{folder}")
continue
for file in sorted(d.iterdir()):
if not (file.is_file() and file.suffix.lower() in IMAGE_EXTS):
continue
name = file.stem
original = name
url = f"{RAW_BASE}/{folder}/{file.name}"
counter = 1
while name in used:
name = f"{original}-{counter}"
counter += 1
used.add(name)
icons.append({"name": name, "url": url})
counts[{"border-radius": "radius", "circle": "circle", "svg": "svg"}[folder]] += 1
update_at = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
data = {
"name": "小迪的HD-Icons",
"description": (f"当前数量:{len(icons)},更新于:{update_at}。"
"不定期更新,有需求欢迎提交:github.com/xushier/HD-Icons"),
"total_count": len(icons),
"update_at": update_at,
"icons": icons,
}
(repo / "icons.json").write_text(
json.dumps(data, ensure_ascii=False, indent=4) + "\n", encoding="utf-8")
counts["total"] = len(icons)
return counts
# ---------------------------------------------------------------- 5. README 标记
def update_readme(repo, counts):
readme = repo / "README.md"
if not readme.is_file():
warn("README.md 不存在,跳过数量更新")
return
text = readme.read_text(encoding="utf-8")
def repl(m):
key = m.group(2)
if key in counts:
return m.group(1) + str(counts[key]) + m.group(4)
warn(f"README 中存在未知标记 ICONS:{key}")
return m.group(0)
readme.write_text(README_MARKER_RE.sub(repl, text), encoding="utf-8")
# ---------------------------------------------------------------- 6. 预览图
def load_font(size):
from PIL import ImageFont
candidates = [
os.environ.get("HDICONS_FONT"),
str(Path(__file__).resolve().parent / "fonts" / "DejaVuSans.ttf"),
"/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
"C:/Windows/Fonts/segoeui.ttf",
"C:/Windows/Fonts/arial.ttf",
]
for c in candidates:
if c and Path(c).is_file():
try:
return ImageFont.truetype(c, size)
except OSError:
pass
warn("未找到可用字体,预览图将使用默认位图字体")
return ImageFont.load_default()
def rasterize_svg(svg_path, size, cache_dir):
"""把 SVG 光栅化为 PNG(依次尝试 rsvg-convert / resvg / cairosvg),失败返回 None。"""
out = cache_dir / (svg_path.stem + ".png")
if out.exists():
return out
cmds = []
if shutil.which("rsvg-convert"):
cmds.append(["rsvg-convert", "-w", str(size), "-h", str(size),
"-o", str(out), str(svg_path)])
if shutil.which("resvg"):
cmds.append(["resvg", "--width", str(size), "--height", str(size),
str(svg_path), str(out)])
for cmd in cmds:
try:
if subprocess.run(cmd, capture_output=True, timeout=60).returncode == 0 \
and out.exists():
return out
except (OSError, subprocess.TimeoutExpired):
pass
try:
import cairosvg
cairosvg.svg2png(url=str(svg_path), write_to=str(out),
output_width=size, output_height=size)
if out.exists():
return out
except Exception:
pass
return None
def merge_preview(repo, folder, out_name):
"""把目录内全部图标合并为一张带文件名标注的 JPEG 预览图。"""
from PIL import Image, ImageDraw
import textwrap
src = repo / folder
files = sorted(f for f in src.iterdir()
if f.is_file() and f.suffix.lower() in IMAGE_EXTS)
if not files:
warn(f"{folder}/ 为空,跳过预览图 {out_name}")
return
font = load_font(PREVIEW["text_size"])
cell_w = PREVIEW["icon"] + PREVIEW["gap_x"]
cell_h = PREVIEW["icon"] + PREVIEW["gap_y"]
rows = (len(files) + PREVIEW["cols"] - 1) // PREVIEW["cols"]
width = PREVIEW["cols"] * cell_w - PREVIEW["gap_x"] + 2 * PREVIEW["pad"]
height = rows * cell_h - PREVIEW["gap_y"] + 2 * PREVIEW["pad"]
sheet = Image.new("RGB", (width, height), PREVIEW["bg"])
draw = ImageDraw.Draw(sheet)
cache_dir = Path(tempfile.mkdtemp(prefix="hdicons_"))
skipped = 0
try:
for i, f in enumerate(files):
row, col = divmod(i, PREVIEW["cols"])
x = col * cell_w + PREVIEW["pad"]
y = row * cell_h + PREVIEW["pad"]
if f.suffix.lower() == ".svg":
raster = rasterize_svg(f, 512, cache_dir)
if raster is None:
skipped += 1
continue
f = raster
try:
icon = Image.open(f).convert("RGBA")
except OSError as e:
warn(f"无法读取 {f.name}{e}")
skipped += 1
continue
icon = icon.resize((PREVIEW["icon"], PREVIEW["icon"]),
Image.Resampling.LANCZOS)
sheet.paste(icon, (x, y), icon)
wrapped = "\n".join(textwrap.wrap(f.stem, width=16))
bbox = draw.textbbox((0, 0), wrapped, font=font)
text_w = bbox[2] - bbox[0]
draw.text((x + (PREVIEW["icon"] - text_w) // 2,
y + PREVIEW["icon"] + 4),
wrapped, font=font, fill=PREVIEW["fg"])
finally:
shutil.rmtree(cache_dir, ignore_errors=True)
rendered = len(files) - skipped
if rendered < len(files):
# 预览图必须包含全部图标,有遗漏时不覆盖已有图片,避免生成残缺预览
warn(f"{folder}{skipped} 个文件未能渲染(SVG 光栅化不可用或文件损坏),"
f"本次跳过写入 {out_name},保留现有版本")
return
out_path = repo / out_name
sheet.save(out_path, format="JPEG", quality=78, dpi=(128, 128))
log(f" {out_name}: {len(files)} 个图标,{sheet.width}x{sheet.height}")
# ---------------------------------------------------------------- 主流程
def main():
parser = argparse.ArgumentParser(description="HD-Icons 一键构建")
parser.add_argument("--repo", default=".", help="仓库根目录")
parser.add_argument("--skip-compress", action="store_true",
help="跳过 TinyPNG 压缩(仅调试用)")
args = parser.parse_args()
repo = Path(args.repo).resolve()
log("== 1/6 校验 inbox ==")
errors, warnings, inbox_files = validate_inbox(repo)
for w in warnings:
warn(w)
if errors:
for e in errors:
warn(e)
fail("inbox 存在问题,已中止(所有文件保持原样)")
png_count = len(inbox_files["border-radius"]) + len(inbox_files["circle"])
svg_count = len(inbox_files["svg"])
total_inbox = png_count + svg_count
log(f"inbox 共 {total_inbox} 个新文件(PNG {png_count}SVG {svg_count}")
if png_count and not args.skip_compress:
key = os.environ.get("TINYPNG_KEY", "").strip()
if not key:
fail("inbox 中有 PNG 但未设置 TINYPNG_KEY"
"GitHub 上需在 Settings -> Secrets 中配置)")
log("== 2/6 TinyPNG 无损压缩 ==")
compress_inbox(inbox_files, key)
else:
log("== 2/6 跳过压缩(无 PNG 或指定 --skip-compress==")
log("== 3/6 分发入库 ==")
moved = dispatch(repo, inbox_files)
for m in moved:
log(f" + {m}")
log("== 4/6 重建 icons.json ==")
counts = build_icons_json(repo)
log(f"总数 {counts['total']}(圆角矩形 {counts['radius']} / "
f"圆形 {counts['circle']} / SVG {counts['svg']}")
log("== 5/6 更新 README 数量标记 ==")
update_readme(repo, counts)
log("== 6/6 生成预览图 ==")
merge_preview(repo, "border-radius", "_icons-radius.jpg")
merge_preview(repo, "circle", "_icons-circle.jpg")
merge_preview(repo, "svg", "_icons-svg.jpg")
gh_output = os.environ.get("GITHUB_OUTPUT")
if gh_output:
with open(gh_output, "a", encoding="utf-8") as f:
f.write(f"added={len(moved)}\n")
log(f"完成!本次新增 {len(moved)} 个图标。")
if __name__ == "__main__":
main()