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>
100 lines
3.7 KiB
Python
100 lines
3.7 KiB
Python
#!/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()
|