97 lines
3.5 KiB
Python
97 lines
3.5 KiB
Python
#!/usr/bin/env python3
|
|||
|
|
"""Rasterise icon.svg into the PNG sizes ui.py loads for the window icon.
|
||
|
|
|
||
|
|
Tk 8.6 reads PNG but not SVG, so the icon ships pre-rendered in assets/. The
|
||
|
|
glyph is laid on a rounded plate in the app's own colours: light on dark keeps
|
||
|
|
it legible against both a light and a dark dock, which the bare SVG (slate grey
|
||
|
|
on nothing) is not.
|
||
|
|
|
||
|
|
Needs inkscape for the render and Pillow for the downscales. Run it after
|
||
|
|
editing icon.svg, then ./install_desktop.sh to refresh the dock's copies.
|
||
|
|
"""
|
||
|
|
import re
|
||
|
|
import shutil
|
||
|
|
import subprocess
|
||
|
|
import sys
|
||
|
|
from pathlib import Path
|
||
|
|
|
||
|
|
HERE = Path(__file__).resolve().parent
|
||
|
|
SRC = HERE / "icon.svg"
|
||
|
|
OUT = HERE / "assets"
|
||
|
|
SIZES = (256, 128, 64, 48, 32, 16)
|
||
|
|
|
||
|
|
PLATE = 64 # plate side, in the units the composite is drawn in
|
||
|
|
GLYPH = 44 # how much of that side the glyph's longest edge covers
|
||
|
|
RADIUS = 14 # corner rounding of the plate
|
||
|
|
BG = "#2b2b2b" # ui.BAR
|
||
|
|
FG = "#ededed" # ui.FG
|
||
|
|
|
||
|
|
|
||
|
|
def glyph_box(svg):
|
||
|
|
"""Bounding box of the drawing, in viewBox units.
|
||
|
|
|
||
|
|
inkscape answers in the file's own pixel units, which are viewBox units
|
||
|
|
only when width and viewBox agree — so convert.
|
||
|
|
"""
|
||
|
|
out = subprocess.run(["inkscape", "--query-all", str(svg)],
|
||
|
|
capture_output=True, text=True, check=True).stdout
|
||
|
|
line = next(l for l in out.splitlines() if l.startswith("svg"))
|
||
|
|
x, y, w, h = (float(v) for v in line.split(",")[1:5])
|
||
|
|
|
||
|
|
text = svg.read_text()
|
||
|
|
width = float(re.search(r'\swidth="([\d.]+)"', text).group(1))
|
||
|
|
vb_w = float(re.search(r'viewBox="[\d.\-]+ [\d.\-]+ ([\d.]+)', text).group(1))
|
||
|
|
u = vb_w / width
|
||
|
|
return x * u, y * u, w * u, h * u
|
||
|
|
|
||
|
|
|
||
|
|
def main():
|
||
|
|
if not SRC.is_file():
|
||
|
|
sys.exit(f"{SRC.name} not found")
|
||
|
|
if not shutil.which("inkscape"):
|
||
|
|
sys.exit("inkscape not found — needed to rasterise the SVG")
|
||
|
|
try:
|
||
|
|
from PIL import Image
|
||
|
|
except ImportError:
|
||
|
|
sys.exit("Pillow not found — needed to downscale the render")
|
||
|
|
|
||
|
|
d = re.search(r'\sd="([^"]+)"', SRC.read_text()).group(1)
|
||
|
|
x, y, w, h = glyph_box(SRC)
|
||
|
|
|
||
|
|
# Scale the glyph to GLYPH along its longest edge, then centre what that
|
||
|
|
# leaves. The offsets cancel the bbox origin, which is not at 0,0.
|
||
|
|
s = GLYPH / max(w, h)
|
||
|
|
tx = (PLATE - w * s) / 2 - x * s
|
||
|
|
ty = (PLATE - h * s) / 2 - y * s
|
||
|
|
|
||
|
|
OUT.mkdir(exist_ok=True)
|
||
|
|
composite = OUT / "_composite.svg"
|
||
|
|
composite.write_text(
|
||
|
|
f'<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" '
|
||
|
|
f'viewBox="0 0 {PLATE} {PLATE}">\n'
|
||
|
|
f' <rect width="{PLATE}" height="{PLATE}" rx="{RADIUS}" ry="{RADIUS}" '
|
||
|
|
f'fill="{BG}"/>\n'
|
||
|
|
f' <g transform="translate({tx:.4f} {ty:.4f}) scale({s:.6f})">\n'
|
||
|
|
f' <path fill="{FG}" fill-rule="evenodd" clip-rule="evenodd" '
|
||
|
|
f'd="{d}"/>\n'
|
||
|
|
f' </g>\n</svg>\n')
|
||
|
|
|
||
|
|
master = OUT / "_master.png"
|
||
|
|
subprocess.run(["inkscape", "--export-type=png",
|
||
|
|
f"--export-filename={master}", "-w", "512", "-h", "512",
|
||
|
|
str(composite)], check=True, capture_output=True)
|
||
|
|
|
||
|
|
img = Image.open(master).convert("RGBA")
|
||
|
|
for size in SIZES:
|
||
|
|
img.resize((size, size), Image.LANCZOS).save(OUT / f"icon-{size}.png")
|
||
|
|
|
||
|
|
composite.unlink()
|
||
|
|
master.unlink()
|
||
|
|
print(f"glyph bbox {w:.2f}x{h:.2f} at ({x:.2f}, {y:.2f}) "
|
||
|
|
f"-> scale {s:.3f}, offset ({tx:.2f}, {ty:.2f})")
|
||
|
|
print("wrote " + ", ".join(f"icon-{s}.png" for s in SIZES) + f" in {OUT}")
|
||
|
|
|
||
|
|
|
||
|
|
if __name__ == "__main__":
|
||
|
|
main()
|