Files
Hankan/tools/icons/make-icons.py
MechaCat02 68403d6102 feat(icons): 한칸 on a jade ground
The PWA manifest declared icon-192.png, icon-512.png and favicon.svg. None
of the three existed, so install-to-home-screen and the browser tab both
resolved to nothing. That was a defect in what the last pass claimed as
done.

Drawn procedurally with Pillow rather than authored as SVG: there is no
dependable SVG rasteriser here to convert from, and ImageMagick falls back
to its MSVG renderer, which cannot be trusted with text. Pillow's
FreeType + RAQM stack shapes Hangul correctly, which is the only hard
requirement. The mark uses the app's own register — the serif face already
used for 한칸 on the boot screen, in --jade on --bg.

Two glyphs read well at 180px and up; at favicon sizes 한칸 turns to mush,
so the small sizes carry 한 alone.

  icon-192 · icon-512 · apple-touch-icon (180)   한칸
  icon-512-maskable                              한칸 at 56%, inside
                                                 Android's circular crop
  favicon.ico (16/32/48) · favicon-32            한

Sizing is a binary search on the drawn ink extent, and centring uses the ink
bounds rather than the font's line box — CJK metrics leave asymmetric space
above and below, which would otherwise sit the mark visibly high.

Re-running the generator writes byte-identical files.

Android launcher icons are untouched; Android work is out of scope.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 19:16:55 +02:00

100 lines
3.7 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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()