#!/usr/bin/env python3 """生成 TfsExcelImporter 的应用图标(app.ico / 预览图)。 纯标准库实现(本机没有 Pillow / numpy):用有向距离场(SDF)解析求覆盖率做 抗锯齿,再手写 PNG / ICO 容器,不依赖任何第三方包。 设计:蓝色渐变圆角方块 + 白色线性「上升箭头」字形(导入语义, 方向与界面「开始导入」按钮的 ArrowUp 一致)。按尺寸分档: - 大尺寸:加顶部高光与 1px 内描边(立体感) - 小尺寸(<=24):关掉高光/内描边、加粗描边并略微放大字形,避免糊成一团 用法: python tools/make_icon.py # 生成 app.ico + tools/icon_preview.png python tools/make_icon.py --only-preview # 只出预览图 """ import argparse import math import os import struct import zlib # ----------------------------------------------------------------- 设计参数 SIZES = [16, 20, 24, 32, 40, 48, 64, 128, 256] COLOR_TOP = (0x44, 0x8E, 0xFA) # 顶部亮蓝 COLOR_BOT = (0x18, 0x42, 0xC4) # 底部深蓝 WHITE = (255, 255, 255) TILE_MARGIN = 0.045 TILE_RADIUS = 0.235 def style(size): """按尺寸分档的视觉参数。""" if size <= 20: return dict(stroke=0.150, gloss=0.00, rim=0.00, ring=True, grow=1.08) if size <= 24: return dict(stroke=0.138, gloss=0.00, rim=0.00, ring=True, grow=1.06) if size <= 40: return dict(stroke=0.118, gloss=0.09, rim=0.14, ring=False, grow=1.02) return dict(stroke=0.100, gloss=0.10, rim=0.17, ring=False, grow=1.00) # 字形骨架(归一化坐标,单位方格)。全部是圆头胶囊,端点已按描边半径内缩过, # 这样描边加粗时外形不会溢出画布。 # 返回 (箭头胶囊组, 副元素胶囊组, 副元素描边系数);副元素为空表示只画箭头。 def glyph_capsules(stroke, variant="arrow"): if variant == "arrow": # 只有箭头:上下留白对称(0.14~0.85),比例比带盒子时更大更饱满 arrow = [ (0.500, 0.270, 0.500, 0.790), (0.262, 0.418, 0.500, 0.196), (0.500, 0.196, 0.738, 0.418), ] return arrow, [], 1.0 shaft_end = 0.700 if variant == "tray" else 0.650 arrow = [ # 箭杆 + 箭头两撇(V 形,圆头在顶点自然并成箭头) (0.500, 0.235, 0.500, shaft_end), (0.330, 0.375, 0.500, 0.205), (0.500, 0.205, 0.670, 0.375), ] if variant == "slot": # 单根加粗横条("槽/落点"),无竖壁,小尺寸更干净 return arrow, [(0.250, 0.780, 0.750, 0.780)], 1.30 # 托盘:底边 + 两侧竖壁;底边端点落在竖壁中心线上,角部由此自然圆接 return arrow, [ (0.300, 0.800, 0.700, 0.800), (0.300, 0.640, 0.300, 0.800), (0.700, 0.640, 0.700, 0.800), ], 1.0 # ----------------------------------------------------------------- SDF 基础 def sd_round_rect(x, y, cx, cy, hw, hh, r): dx = abs(x - cx) - (hw - r) dy = abs(y - cy) - (hh - r) ox = dx if dx > 0.0 else 0.0 oy = dy if dy > 0.0 else 0.0 inner = dx if dx > dy else dy if inner > 0.0: inner = 0.0 return math.hypot(ox, oy) + inner - r def sd_seg(x, y, ax, ay, bx, by): pax, pay = x - ax, y - ay bax, bay = bx - ax, by - ay den = bax * bax + bay * bay t = 0.0 if den == 0.0 else max(0.0, min(1.0, (pax * bax + pay * bay) / den)) return math.hypot(pax - bax * t, pay - bay * t) def clamp01(v): return 0.0 if v < 0.0 else (1.0 if v > 1.0 else v) def cov(d, size): """距离场 -> 覆盖率(1px 过渡带)。""" return clamp01(0.5 - d * size) # ----------------------------------------------------------------- 单尺寸渲染 def render(size, variant="tray"): st = style(size) # 只有箭头时没有「托盘」当配重,线条要更粗一点才站得住 stroke = st["stroke"] + (0.016 if variant == "arrow" else 0.0) grow = st["grow"] arrow, tray, tray_w = glyph_capsules(stroke, variant) gr = stroke / 2.0 # 大尺寸下托盘描边略细于箭头:用「粗细」而不是「透明度」做主次(透明度压在深蓝上会发灰) tray_k = tray_w * (1.0 if size <= 32 else 0.94) tray_tone = 1.0 hw = 0.5 - TILE_MARGIN rad = TILE_RADIUS inner_shrink = 1.0 / size # 内描边宽度 = 1px buf = bytearray(size * size * 4) span = 1.0 - 2.0 * TILE_MARGIN for py in range(size): v = (py + 0.5) / size # 字形整体缩放:把坐标拉回以 0.5 为心 gv = 0.5 + (v - 0.5) / grow row_off = py * size * 4 for px in range(size): u = (px + 0.5) / size a = cov(sd_round_rect(u, v, 0.5, 0.5, hw, hw, rad), size) if a <= 0.0: continue # 渐变(略偏对角线,避免纯竖条的死板) t = clamp01(((v - TILE_MARGIN) * 0.82 + (u - TILE_MARGIN) * 0.18) / span) cr = COLOR_TOP[0] + (COLOR_BOT[0] - COLOR_TOP[0]) * t cg = COLOR_TOP[1] + (COLOR_BOT[1] - COLOR_TOP[1]) * t cb = COLOR_TOP[2] + (COLOR_BOT[2] - COLOR_TOP[2]) * t # 顶部高光 if st["gloss"] > 0.0: g = st["gloss"] * clamp01(1.0 - (v - TILE_MARGIN) / 0.38) cr += (255.0 - cr) * g cg += (255.0 - cg) * g cb += (255.0 - cb) * g # 1px 内描边(沿边缘的细亮线,让方块边界更挺) if st["rim"] > 0.0: d_in = sd_round_rect(u, v, 0.5, 0.5, hw - inner_shrink, hw - inner_shrink, rad - inner_shrink) band = a - cov(d_in, size) if band > 0.0: k = st["rim"] * band cr += (255.0 - cr) * k cg += (255.0 - cg) * k cb += (255.0 - cb) * k # 字形:托盘(略细)+ 箭头(主调) gu = 0.5 + (u - 0.5) / grow for group, tone, k in ((tray, tray_tone, tray_k), (arrow, 1.0, 1.0)): d = 1e9 rg = gr * k for (ax, ay, bx, by) in group: dd = sd_seg(gu, gv, ax, ay, bx, by) - rg if dd < d: d = dd ag = cov(d, size) * a * tone if ag > 0.0: cr += (WHITE[0] - cr) * ag cg += (WHITE[1] - cg) * ag cb += (WHITE[2] - cb) * ag i = row_off + px * 4 buf[i] = int(cr + 0.5) buf[i + 1] = int(cg + 0.5) buf[i + 2] = int(cb + 0.5) buf[i + 3] = int(a * 255.0 + 0.5) return buf # ----------------------------------------------------------------- PNG / ICO def png_bytes(w, h, rgba): raw = bytearray() for y in range(h): raw.append(0) raw += rgba[y * w * 4:(y + 1) * w * 4] def chunk(tag, data): return (struct.pack(">I", len(data)) + tag + data + struct.pack(">I", zlib.crc32(tag + data) & 0xFFFFFFFF)) ihdr = struct.pack(">IIBBBBB", w, h, 8, 6, 0, 0, 0) return (b"\x89PNG\r\n\x1a\n" + chunk(b"IHDR", ihdr) + chunk(b"IDAT", zlib.compress(bytes(raw), 9)) + chunk(b"IEND", b"")) def dib_bytes(w, h, rgba): """32bpp 自下而上的 BMP(ICO 里的 DIB,含 AND 掩码)。""" xor = bytearray() for y in range(h - 1, -1, -1): for x in range(w): i = (y * w + x) * 4 xor += bytes((rgba[i + 2], rgba[i + 1], rgba[i], rgba[i + 3])) and_stride = ((w + 31) // 32) * 4 and_mask = bytes(and_stride * h) hdr = struct.pack("= 256 else size out.append(struct.pack("= dh: break for x in range(sw * scale): sx = x // scale dx = ox + x if dx >= dw: break si = (sy * sw + sx) * 4 di = (dy * dw + dx) * 4 dst[di:di + 4] = src[si:si + 4] def preview(renders, path): pad = 10 big = renders[256] strip = SIZES[:-1] # 16..128 zoom = 4 strip_w = sum(s * zoom + pad for s in strip) w = max(256 + 2 * pad, strip_w + pad) h = 256 + strip_w * 0 + 64 + 128 * zoom + 3 * pad h = 256 + pad * 3 + 128 * zoom canvas = bytearray(w * h * 4) for i in range(w * h): canvas[i * 4 + 0] = 0xF3 canvas[i * 4 + 1] = 0xF4 canvas[i * 4 + 2] = 0xF6 canvas[i * 4 + 3] = 0xFF blit(canvas, w, h, big, 256, 256, pad, pad, 1) x = pad y = 256 + pad * 2 for s in strip: blit(canvas, w, h, renders[s], s, s, x, y + (128 - s) * zoom, zoom) x += s * zoom + pad with open(path, "wb") as f: f.write(png_bytes(w, h, canvas)) return w, h # ----------------------------------------------------------------- main def main(): ap = argparse.ArgumentParser() ap.add_argument("--out", default="TfsExcelImporter/app.ico") ap.add_argument("--preview", default="tools/icon_preview.png") ap.add_argument("--only-preview", action="store_true") ap.add_argument("--variant", default="arrow", choices=["arrow", "tray", "slot"]) args = ap.parse_args() root = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) out_ico = args.out if os.path.isabs(args.out) else os.path.join(root, args.out) out_png = (args.preview if os.path.isabs(args.preview) else os.path.join(root, args.preview)) if args.variant != "arrow": base, ext = os.path.splitext(out_png) out_png = "%s_%s%s" % (base, args.variant, ext) renders = {} for s in SIZES: renders[s] = render(s, args.variant) print("rendered %dx%d" % (s, s)) frames = [(s, png_bytes(s, s, renders[s]) if s >= 256 else dib_bytes(s, s, renders[s])) for s in SIZES] if not args.only_preview: with open(out_ico, "wb") as f: f.write(ico_bytes(frames)) print("wrote %s (%d bytes)" % (out_ico, os.path.getsize(out_ico))) with open(os.path.splitext(out_png)[0] + "_256.png", "wb") as f: f.write(png_bytes(256, 256, renders[256])) w, h = preview(renders, out_png) print("wrote %s (%dx%d)" % (out_png, w, h)) if __name__ == "__main__": main()