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:
@@ -5,7 +5,9 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<meta name="theme-color" content="#0F6B5C" />
|
||||
<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>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
BIN
app/public/apple-touch-icon.png
Normal file
BIN
app/public/apple-touch-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.5 KiB |
BIN
app/public/favicon-32.png
Normal file
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
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
BIN
app/public/icon-192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 5.0 KiB |
BIN
app/public/icon-512-maskable.png
Normal file
BIN
app/public/icon-512-maskable.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 11 KiB |
BIN
app/public/icon-512.png
Normal file
BIN
app/public/icon-512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 14 KiB |
@@ -35,7 +35,7 @@ export default defineConfig({
|
||||
react(),
|
||||
VitePWA({
|
||||
registerType: "autoUpdate",
|
||||
includeAssets: ["favicon.svg"],
|
||||
includeAssets: ["favicon.ico", "favicon-32.png", "apple-touch-icon.png"],
|
||||
manifest: {
|
||||
name: "Hankan — 한국어 읽기",
|
||||
short_name: "Hankan",
|
||||
@@ -46,10 +46,12 @@ export default defineConfig({
|
||||
background_color: "#F1F4F1",
|
||||
theme_color: "#0F6B5C",
|
||||
icons: [
|
||||
{ src: "icon-192.png", sizes: "192x192", type: "image/png" },
|
||||
{ src: "icon-512.png", sizes: "512x512", type: "image/png" },
|
||||
{ src: "icon-192.png", sizes: "192x192", type: "image/png", purpose: "any" },
|
||||
{ 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",
|
||||
type: "image/png",
|
||||
purpose: "maskable",
|
||||
|
||||
99
tools/icons/make-icons.py
Normal file
99
tools/icons/make-icons.py
Normal 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()
|
||||
Reference in New Issue
Block a user