diff --git a/app/index.html b/app/index.html index 6d43959..74799ee 100644 --- a/app/index.html +++ b/app/index.html @@ -5,7 +5,9 @@ Hankan — 한국어 읽기 - + + +
diff --git a/app/public/apple-touch-icon.png b/app/public/apple-touch-icon.png new file mode 100644 index 0000000..a9c612b Binary files /dev/null and b/app/public/apple-touch-icon.png differ diff --git a/app/public/favicon-32.png b/app/public/favicon-32.png new file mode 100644 index 0000000..1289c14 Binary files /dev/null and b/app/public/favicon-32.png differ diff --git a/app/public/favicon.ico b/app/public/favicon.ico new file mode 100644 index 0000000..62475e4 Binary files /dev/null and b/app/public/favicon.ico differ diff --git a/app/public/icon-192.png b/app/public/icon-192.png new file mode 100644 index 0000000..e85e53a Binary files /dev/null and b/app/public/icon-192.png differ diff --git a/app/public/icon-512-maskable.png b/app/public/icon-512-maskable.png new file mode 100644 index 0000000..9ccc4bf Binary files /dev/null and b/app/public/icon-512-maskable.png differ diff --git a/app/public/icon-512.png b/app/public/icon-512.png new file mode 100644 index 0000000..d7cdf57 Binary files /dev/null and b/app/public/icon-512.png differ diff --git a/app/vite.config.ts b/app/vite.config.ts index aaa10c4..5b268e6 100644 --- a/app/vite.config.ts +++ b/app/vite.config.ts @@ -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", diff --git a/tools/icons/make-icons.py b/tools/icons/make-icons.py new file mode 100644 index 0000000..ab83c09 --- /dev/null +++ b/tools/icons/make-icons.py @@ -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()