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>
This commit is contained in:
MechaCat02
2026-09-08 19:16:55 +02:00
parent 8f7f2fe568
commit 68403d6102
9 changed files with 108 additions and 5 deletions

View File

@@ -5,7 +5,9 @@
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" /> <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta name="theme-color" content="#0F6B5C" /> <meta name="theme-color" content="#0F6B5C" />
<title>Hankan — 한국어 읽기</title> <title>Hankan — 한국어 읽기</title>
<link rel="icon" href="/favicon.svg" type="image/svg+xml" /> <link rel="icon" href="/favicon.ico" sizes="16x16 32x32 48x48" />
<link rel="icon" href="/favicon-32.png" type="image/png" sizes="32x32" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

BIN
app/public/favicon-32.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

BIN
app/public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

BIN
app/public/icon-192.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

BIN
app/public/icon-512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

View File

@@ -35,7 +35,7 @@ export default defineConfig({
react(), react(),
VitePWA({ VitePWA({
registerType: "autoUpdate", registerType: "autoUpdate",
includeAssets: ["favicon.svg"], includeAssets: ["favicon.ico", "favicon-32.png", "apple-touch-icon.png"],
manifest: { manifest: {
name: "Hankan — 한국어 읽기", name: "Hankan — 한국어 읽기",
short_name: "Hankan", short_name: "Hankan",
@@ -46,10 +46,12 @@ export default defineConfig({
background_color: "#F1F4F1", background_color: "#F1F4F1",
theme_color: "#0F6B5C", theme_color: "#0F6B5C",
icons: [ icons: [
{ src: "icon-192.png", sizes: "192x192", type: "image/png" }, { src: "icon-192.png", sizes: "192x192", type: "image/png", purpose: "any" },
{ src: "icon-512.png", sizes: "512x512", type: "image/png" }, { src: "icon-512.png", sizes: "512x512", type: "image/png", purpose: "any" },
// Android masks icons to a circle; this one keeps 한칸 inside the
// safe zone so the crop cannot clip it.
{ {
src: "icon-512.png", src: "icon-512-maskable.png",
sizes: "512x512", sizes: "512x512",
type: "image/png", type: "image/png",
purpose: "maskable", purpose: "maskable",

99
tools/icons/make-icons.py Normal file
View File

@@ -0,0 +1,99 @@
#!/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()