3.7 KiB · text History 0fc5cce
#!/usr/bin/env python3
"""The Android app's icon, from one description, in every form it needs.

    tools/icons.py

writes, under droid/:

  app/src/main/res/drawable/ic_foreground.xml
      the adaptive icon's foreground, as a vector (Android 8+ launchers)
  app/src/main/res/mipmap-{mdpi..xxxhdpi}/ic_launcher.png
      the same icon as PNGs, 48 to 192 px. F-Droid takes an app's icon
      from its APK and cannot render adaptive icons, so without these
      the app is listed with a blank one.
  fastlane/metadata/android/en-US/images/icon.png
      512 px, where F-Droid's metadata tooling looks for a store icon

The icon is two ring families with their centres slightly apart: a
small moire, in the Her theme's accent on its base.
"""

from pathlib import Path

from PIL import Image, ImageDraw

ROOT = Path(__file__).resolve().parent.parent
RES = ROOT / "droid/app/src/main/res"

BACKGROUND = "#661e1e"
FOREGROUND = "#fdf2c6"

# In the adaptive icon's 108-unit canvas, whose central 72 units are
# what a launcher shows, and whose central 66 are safe from any mask.
CENTRES = [(52.5, 54.0), (55.5, 54.0)]
RADII = range(4, 31, 4)
STROKE = 1.4

DENSITIES = {"mdpi": 48, "hdpi": 72, "xhdpi": 96, "xxhdpi": 144, "xxxhdpi": 192}


def vector():
    paths = []
    for cx, cy in CENTRES:
        for r in RADII:
            paths.append(
                "    <path\n"
                f'        android:pathData="M{cx - r},{cy} a{r},{r} 0 1,0 {2 * r},0 a{r},{r} 0 1,0 {-2 * r},0"\n'
                f'        android:strokeColor="{FOREGROUND}"\n'
                f'        android:strokeWidth="{STROKE}"\n'
                '        android:fillColor="#00000000" />'
            )
    return (
        '<?xml version="1.0" encoding="utf-8"?>\n'
        "<!-- Generated by tools/icons.py; do not edit. Two ring families\n"
        "     slightly apart: a small moire, within the 66dp safe zone. -->\n"
        '<vector xmlns:android="http://schemas.android.com/apk/res/android"\n'
        '    android:width="108dp"\n'
        '    android:height="108dp"\n'
        '    android:viewportWidth="108"\n'
        '    android:viewportHeight="108">\n' + "\n".join(paths) + "\n</vector>\n"
    )


def raster(size):
    """The icon as a launcher shows it: the adaptive canvas's central 72
    units, in a rounded square, drawn at 8x and downsampled so the thin
    rings stay smooth at 48 px."""
    ss = 8
    big = size * ss
    unit = big / 72  # pixels per canvas unit, over the visible 72
    offset = 18      # the visible area starts 18 units in

    art = Image.new("RGBA", (big, big), BACKGROUND)
    draw = ImageDraw.Draw(art)
    width = max(1, round(STROKE * unit))
    for cx, cy in CENTRES:
        for r in RADII:
            x, y = (cx - offset) * unit, (cy - offset) * unit
            rr = r * unit
            draw.ellipse((x - rr, y - rr, x + rr, y + rr), outline=FOREGROUND, width=width)

    mask = Image.new("L", (big, big), 0)
    ImageDraw.Draw(mask).rounded_rectangle((0, 0, big - 1, big - 1), radius=round(big * 0.22), fill=255)
    art.putalpha(mask)
    return art.resize((size, size), Image.LANCZOS)


def main():
    out = RES / "drawable/ic_foreground.xml"
    out.write_text(vector())
    print(f"wrote {out.relative_to(ROOT)}")

    for density, size in DENSITIES.items():
        out = RES / f"mipmap-{density}/ic_launcher.png"
        out.parent.mkdir(parents=True, exist_ok=True)
        raster(size).save(out, optimize=True)
        print(f"wrote {out.relative_to(ROOT)} ({size}px)")

    out = ROOT / "droid/fastlane/metadata/android/en-US/images/icon.png"
    out.parent.mkdir(parents=True, exist_ok=True)
    raster(512).save(out, optimize=True)
    print(f"wrote {out.relative_to(ROOT)} (512px)")


if __name__ == "__main__":
    main()