1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
#!/usr/bin/env -S uv run --script
# /// script
# requires-python = ">=3.11"
# dependencies = [
# "cairosvg>=2.7",
# "pillow>=10",
# ]
# ///
"""Render the AT-IRC SVG app icons into the asset catalog.
Usage:
uv run Scripts/render_app_icon.py
The SVG masters live in Icon/. iOS uses Xcode's single-size app icon
format, so this writes three 1024x1024 PNGs (default, dark, tinted) and the
matching Contents.json; Xcode derives every device size from them.
The default icon is flattened to RGB because App Store icons must not have
an alpha channel. The dark and tinted variants intentionally keep their
transparent backgrounds, as Apple recommends for those appearances.
Rendering prefers cairosvg. If cairo is unavailable (common on macOS
without a matching library), it falls back to Homebrew's rsvg-convert.
"""
from __future__ import annotations
import json
import shutil
import subprocess
import sys
import tempfile
from io import BytesIO
from pathlib import Path
from PIL import Image
SIZE = 1024
REPO_ROOT = Path(__file__).resolve().parent.parent
SOURCE_DIR = REPO_ROOT / "Icon"
ASSET_DIR = REPO_ROOT / "AT-IRC" / "Assets.xcassets" / "AppIcon.appiconset"
# (source SVG, output PNG, must be fully opaque)
ICONS = (
("AppIcon.svg", "AppIcon-1024.png", True),
("AppIcon-dark.svg", "AppIcon-dark-1024.png", False),
("AppIcon-tinted.svg", "AppIcon-tinted-1024.png", False),
)
CONTENTS = {
"images": [
{
"filename": "AppIcon-1024.png",
"idiom": "universal",
"platform": "ios",
"size": "1024x1024",
},
{
"appearances": [{"appearance": "luminosity", "value": "dark"}],
"filename": "AppIcon-dark-1024.png",
"idiom": "universal",
"platform": "ios",
"size": "1024x1024",
},
{
"appearances": [{"appearance": "luminosity", "value": "tinted"}],
"filename": "AppIcon-tinted-1024.png",
"idiom": "universal",
"platform": "ios",
"size": "1024x1024",
},
],
"info": {"author": "xcode", "version": 1},
}
def render_with_cairosvg(svg_path: Path) -> bytes | None:
try:
import cairosvg
except Exception:
return None
try:
return cairosvg.svg2png(
url=str(svg_path), output_width=SIZE, output_height=SIZE
)
except Exception:
return None
def render_with_rsvg(svg_path: Path) -> bytes:
rsvg = shutil.which("rsvg-convert")
if rsvg is None:
raise SystemExit(
"No SVG renderer available. Install cairo (for cairosvg) or "
"librsvg with `brew install librsvg`."
)
with tempfile.TemporaryDirectory() as tmp:
output = Path(tmp) / "icon.png"
subprocess.run(
[rsvg, "-w", str(SIZE), "-h", str(SIZE), "-o", str(output), str(svg_path)],
check=True,
capture_output=True,
text=True,
)
return output.read_bytes()
def render(svg_path: Path) -> tuple[bytes, str]:
data = render_with_cairosvg(svg_path)
if data is not None:
return data, "cairosvg"
return render_with_rsvg(svg_path), "rsvg-convert"
def write_png(data: bytes, destination: Path, opaque: bool) -> None:
with Image.open(BytesIO(data)) as image:
image.load()
if image.size != (SIZE, SIZE):
raise SystemExit(
f"{destination.name}: expected {SIZE}x{SIZE}, got {image.size[0]}x{image.size[1]}"
)
if opaque:
rgba = image.convert("RGBA")
if rgba.getchannel("A").getextrema()[0] < 255:
raise SystemExit(
f"{destination.name}: default icons must be fully opaque"
)
image = rgba.convert("RGB")
elif image.mode != "RGBA":
image = image.convert("RGBA")
image.save(destination, "PNG", optimize=True)
def main() -> int:
if not SOURCE_DIR.is_dir():
raise SystemExit(f"Missing icon source directory: {SOURCE_DIR}")
ASSET_DIR.mkdir(parents=True, exist_ok=True)
for source_name, output_name, opaque in ICONS:
source = SOURCE_DIR / source_name
if not source.is_file():
raise SystemExit(f"Missing icon source: {source}")
data, renderer = render(source)
destination = ASSET_DIR / output_name
write_png(data, destination, opaque)
print(f"{source_name} -> {output_name} ({renderer})")
contents_path = ASSET_DIR / "Contents.json"
contents_path.write_text(json.dumps(CONTENTS, indent=2) + "\n", encoding="utf-8")
print(f"wrote {contents_path.relative_to(REPO_ROOT)}")
return 0
if __name__ == "__main__":
sys.exit(main())