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="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>
|
||||||
|
|||||||
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(),
|
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
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