#!/usr/bin/env python3 """Hankan's app icons: 한칸 on a jade ground. Drawn procedurally with Pillow rather than authored as SVG, because there is no dependable SVG rasteriser to convert from — and an icon that renders differently on the build machine than in the browser is worse than no icon. Pillow's FreeType + RAQM stack shapes Hangul correctly, which is the only hard requirement here. The mark follows the app's own register: the serif face it already uses for 한칸 on the boot screen, in the app's jade and paper colours. Two glyphs read well at 180px and up. At favicon sizes 한칸 turns to mush, so the small sizes carry 한 alone — the same mark, cropped to what survives. python3 tools/icons/make-icons.py Deterministic: re-running writes byte-identical files. """ from pathlib import Path from PIL import Image, ImageDraw, ImageFont ROOT = Path(__file__).resolve().parents[2] OUT = ROOT / "app" / "public" # --serif, matching the boot screen and display numerals. FONT = "/usr/share/fonts/opentype/noto/NotoSerifCJK-Bold.ttc" JADE = (15, 107, 92) # --jade #0F6B5C PAPER = (241, 244, 241) # --bg #F1F4F1 WORDMARK = "한칸" GLYPH = "한" def render(size: int, text: str, *, coverage: float, out: Path) -> None: """Draw `text` centred on a jade square, optically centred by ink bounds. `coverage` is the fraction of the square the ink should span. Android masks icons to a circle and can crop ~10% off each edge, so a maskable icon passes a smaller value to keep the mark inside the safe zone. """ img = Image.new("RGB", (size, size), JADE) draw = ImageDraw.Draw(img) # Binary-search the point size that lands the ink at the target width. # Cheaper than solving it: font metrics include side bearings and CJK # line gap, so the drawn extent is not a fixed ratio of the point size. target = size * coverage lo, hi = 1, size * 2 best = ImageFont.truetype(FONT, 1) while lo <= hi: mid = (lo + hi) // 2 font = ImageFont.truetype(FONT, mid) left, top, right, bottom = draw.textbbox((0, 0), text, font=font) extent = max(right - left, bottom - top) if extent <= target: best, lo = font, mid + 1 else: hi = mid - 1 # Centre on the ink itself, not the font's line box — CJK metrics leave # asymmetric space above and below, which would sit the mark visibly high. left, top, right, bottom = draw.textbbox((0, 0), text, font=best) x = (size - (right - left)) / 2 - left y = (size - (bottom - top)) / 2 - top draw.text((x, y), text, font=best, fill=PAPER) img.save(out, "PNG", optimize=True) print(f" {out.relative_to(ROOT)} {size}×{size} {out.stat().st_size:,}B") def main() -> None: OUT.mkdir(parents=True, exist_ok=True) print("Icons → app/public/") render(192, WORDMARK, coverage=0.74, out=OUT / "icon-192.png") render(512, WORDMARK, coverage=0.74, out=OUT / "icon-512.png") # Android crops to a circle: keep the mark well inside the safe zone. render(512, WORDMARK, coverage=0.56, out=OUT / "icon-512-maskable.png") render(180, WORDMARK, coverage=0.72, out=OUT / "apple-touch-icon.png") # Small sizes: one glyph, so it is still legible in a browser tab. render(32, GLYPH, coverage=0.82, out=OUT / "favicon-32.png") ico = OUT / "favicon.ico" base = Image.new("RGB", (48, 48), JADE) d = ImageDraw.Draw(base) f = ImageFont.truetype(FONT, 44) l, t, r, b = d.textbbox((0, 0), GLYPH, font=f) d.text(((48 - (r - l)) / 2 - l, (48 - (b - t)) / 2 - t), GLYPH, font=f, fill=PAPER) base.save(ico, "ICO", sizes=[(16, 16), (32, 32), (48, 48)]) print(f" {ico.relative_to(ROOT)} 16/32/48 {ico.stat().st_size:,}B") if __name__ == "__main__": main()