diff --git a/.github/workflows/build-release.yml b/.github/workflows/build-release.yml index e128054..6d28680 100644 --- a/.github/workflows/build-release.yml +++ b/.github/workflows/build-release.yml @@ -166,7 +166,16 @@ jobs: elif [ "$RUNNER_OS" = "macOS" ]; then brew install librsvg else - echo "no rsvg-convert; gen-assets.sh will fall back to Chrome/Edge headless" + # Windows: try to install librsvg (best effort); the fixed Chrome/Edge + # headless HTML-wrapper path in gen-assets.sh is the fallback + # (rant 2026-08-12T17:25:28: raw SVG screenshot was fully transparent). + if command -v choco >/dev/null 2>&1 && choco install -y librsvg >/dev/null 2>&1; then + echo "choco installed librsvg" + elif command -v winget >/dev/null 2>&1 && winget install --silent --accept-package-agreements librsvg >/dev/null 2>&1; then + echo "winget installed librsvg" + else + echo "no rsvg-convert; gen-assets.sh will fall back to Chrome/Edge headless (HTML wrapper)" + fi fi bash packaging/gen-assets.sh # 产物必须存在(GUI 构建引用 ../packaging/assets/*.png/icns/ico) diff --git a/packaging/gen-assets.sh b/packaging/gen-assets.sh index a09d3d2..7cb6928 100644 --- a/packaging/gen-assets.sh +++ b/packaging/gen-assets.sh @@ -37,6 +37,15 @@ render_svg_to_png() { fi # 2) Chrome/Chromium headless --screenshot (full filter support) # Windows (Git Bash): convert /c/... → file:///C:/... so Chrome resolves the URL. + # ⚠️ rant 2026-08-12T17:25:28: two failure modes confirmed by local testing — + # (a) screenshotting the raw file:// SVG yields an unpainted (fully transparent) + # image; (b) `--default-background-color=00000000` alone makes Chrome emit an + # all-transparent PNG even from a working HTML wrapper (verified empirically: + # without the flag the same page paints correctly as opaque RGB). + # Fix: wrap the SVG in a local HTML page with an OPAQUE white background, + # never pass the transparent-background flag, and add --allow-file-access-from-files + # so the file:// img can load from the file:// page. The transparency check below + # then fails loudly if a future change regresses to a blank render. SVG_URL="file://$SVG" if command -v cygpath >/dev/null 2>&1; then SVG_URL="file:///$(cygpath -m "$SVG")" @@ -49,10 +58,18 @@ render_svg_to_png() { "/c/Program Files/Google/Chrome/Application/chrome.exe" \ "/c/Program Files (x86)/Microsoft/Edge/Application/msedge.exe"; do if [ -n "$chrome" ] && command -v "$chrome" >/dev/null 2>&1 || [ -x "$chrome" ] 2>/dev/null; then - echo " renderer: $chrome (headless)" - "$chrome" --headless --disable-gpu --hide-scrollbars \ + echo " renderer: $chrome (headless, HTML wrapper, opaque white bg)" + HTML="$OUT/.icon-render.html" + cat > "$HTML" < + + +HTMLEOF + "$chrome" --headless --disable-gpu --hide-scrollbars --force-device-scale-factor=1 \ + --allow-file-access-from-files \ --screenshot="$OUT/icon.png" --window-size=1024,1024 \ - --default-background-color=00000000 "$SVG_URL" >/dev/null 2>&1 + "file://$HTML" >/dev/null 2>&1 + rm -f "$HTML" return 0 fi done @@ -83,6 +100,49 @@ while pos < len(data): pos += 12 + ln PYEOF +# ⚡ transparency check (rant 2026-08-12T17:25:28): Chrome headless can silently +# produce a fully-transparent PNG (SVG not painted). Fail loudly instead of +# shipping a blank icon. Sample the alpha channel; if >90% of pixels are +# transparent, the renderer failed → exit 1. +python3 - "$OUT/icon.png" <<'PYEOF' +import sys, struct, zlib + +data = open(sys.argv[1], "rb").read() +pos, w, h, idat, colortype = 8, 0, 0, b"", 0 +while pos < len(data): + ln = struct.unpack(">I", data[pos:pos+4])[0] + tag = data[pos+4:pos+8] + chunk = data[pos+8:pos+8+ln] + if tag == b"IHDR": + w, h = struct.unpack(">II", chunk[:8]) + colortype = chunk[9] + elif tag == b"IDAT": + idat += chunk + pos += 12 + ln + +if colortype == 6: # RGBA — only then is transparency meaningful + raw = zlib.decompress(idat) + stride0 = w * 4 + 1 + raw = b"".join(raw[y*stride0+1:(y+1)*stride0] for y in range(h)) + total = opaque = 0 + for y in range(0, h, 8): + for x in range(0, w, 8): + a = raw[y*w*4 + x*4 + 3] + total += 1 + if a > 0: + opaque += 1 + ratio = opaque / total + print(f" icon.png alpha: {ratio:.1%} opaque ({opaque}/{total} sampled)") + if ratio < 0.10: + sys.stderr.write( + f"ERROR: icon.png is {1-ratio:.1%} transparent — renderer failed to paint " + "the SVG. Fix the rsvg-convert/Chrome headless path (see rant 2026-08-12T17:25:28).\n" + ) + sys.exit(1) +else: + print(f" icon.png alpha: colortype={colortype} (no alpha channel) — assumed opaque") +PYEOF + echo "==> resizing to 512/256 (stdlib area-average box filter)" python3 - "$OUT/icon.png" "$OUT" <<'PYEOF' import sys, zlib, struct @@ -92,20 +152,34 @@ src, outdir = sys.argv[1], sys.argv[2] def read_png(path): data = open(path, "rb").read() assert data[:8] == b"\x89PNG\r\n\x1a\n" - pos, w, h, idat = 8, 0, 0, b"" + pos, w, h, idat, ct = 8, 0, 0, b"", 0 while pos < len(data): ln = struct.unpack(">I", data[pos:pos+4])[0] tag = data[pos+4:pos+8] chunk = data[pos+8:pos+8+ln] if tag == b"IHDR": w, h = struct.unpack(">II", chunk[:8]) + ct = chunk[9] elif tag == b"IDAT": idat += chunk pos += 12 + ln raw = zlib.decompress(idat) - # PNG scanlines each have a leading filter byte (0 for None); strip them - stride0 = w * 4 + 1 - raw = b"".join(raw[y*stride0+1:(y+1)*stride0] for y in range(h)) + # PNG scanlines each have a leading filter byte (0 for None); strip them. + # Chrome headless emits RGB (ct=2) when the page bg is opaque; rsvg-convert + # emits RGBA (ct=6). Normalize to RGBA so downstream code can assume 4 bpp. + if ct == 6: + stride0 = w * 4 + 1 + raw = b"".join(raw[y*stride0+1:(y+1)*stride0] for y in range(h)) + elif ct == 2: + stride0 = w * 3 + 1 + rows = [raw[y*stride0+1:(y+1)*stride0] for y in range(h)] + out = bytearray() + for row in rows: + for i in range(0, len(row), 3): + out += row[i:i+3] + b"\xff" + raw = bytes(out) + else: + raise SystemExit(f"unsupported PNG colortype {ct}") return w, h, raw w, h, raw = read_png(src) @@ -155,15 +229,24 @@ if command -v iconutil >/dev/null 2>&1; then python3 - "$OUT/icon.png" "$ICONSET/icon_${s}x${s}.png" "$s" <<'PYEOF' import sys, zlib, struct src, dst, size = sys.argv[1], sys.argv[2], int(sys.argv[3]) -data = open(src, "rb").read(); pos, w, h, idat = 8, 0, 0, b"" +data = open(src, "rb").read(); pos, w, h, idat, ct = 8, 0, 0, b"", 0 while pos < len(data): ln = struct.unpack(">I", data[pos:pos+4])[0]; tag = data[pos+4:pos+8]; chunk = data[pos+8:pos+8+ln] - if tag == b"IHDR": w, h = struct.unpack(">II", chunk[:8]) + if tag == b"IHDR": w, h = struct.unpack(">II", chunk[:8]); ct = chunk[9] elif tag == b"IDAT": idat += chunk pos += 12 + ln raw = zlib.decompress(idat) -stride0 = w*4 + 1 -raw = b"".join(raw[y*stride0+1:(y+1)*stride0] for y in range(h)) +if ct == 6: + stride0 = w*4 + 1 + raw = b"".join(raw[y*stride0+1:(y+1)*stride0] for y in range(h)) +else: # ct==2 RGB → expand to RGBA + stride0 = w*3 + 1 + rows = [raw[y*stride0+1:(y+1)*stride0] for y in range(h)] + out = bytearray() + for row in rows: + for i in range(0, len(row), 3): + out += row[i:i+3] + b"\xff" + raw = bytes(out) stride = w*4 def resize_area(nw, nh): out = bytearray([0,0,0,0])*(nw*nh) @@ -200,15 +283,24 @@ echo "==> icon.ico (multi-size, win)" python3 - "$OUT/icon.png" "$OUT/icon.ico" <<'PYEOF' import sys, zlib, struct src, dst = sys.argv[1], sys.argv[2] -data = open(src, "rb").read(); pos, w, h, idat = 8, 0, 0, b"" +data = open(src, "rb").read(); pos, w, h, idat, ct = 8, 0, 0, b"", 0 while pos < len(data): ln = struct.unpack(">I", data[pos:pos+4])[0]; tag = data[pos+4:pos+8]; chunk = data[pos+8:pos+8+ln] - if tag == b"IHDR": w, h = struct.unpack(">II", chunk[:8]) + if tag == b"IHDR": w, h = struct.unpack(">II", chunk[:8]); ct = chunk[9] elif tag == b"IDAT": idat += chunk pos += 12 + ln raw = zlib.decompress(idat) -stride0 = w*4 + 1 -raw = b"".join(raw[y*stride0+1:(y+1)*stride0] for y in range(h)) +if ct == 6: + stride0 = w*4 + 1 + raw = b"".join(raw[y*stride0+1:(y+1)*stride0] for y in range(h)) +else: # ct==2 RGB → expand to RGBA + stride0 = w*3 + 1 + rows = [raw[y*stride0+1:(y+1)*stride0] for y in range(h)] + out = bytearray() + for row in rows: + for i in range(0, len(row), 3): + out += row[i:i+3] + b"\xff" + raw = bytes(out) stride = w*4 def resize_area(nw, nh):