[ Web Proxy ]
URL:
Viewing: https://raw.githubusercontent.com/pythonnative/pythonnative/main/src/pythonnative/project/icons.py [Back]  [Original]

"""App icon and splash image generation.

Given a single high-resolution source image per asset (a 1024x1024 icon
and an optional splash image), this module renders the per-platform,
per-density variants each toolchain expects:

- iOS: a single-size ``AppIcon.appiconset`` (Xcode resizes at build time)
  and a ``Splash`` image set referenced by the generated launch screen.
- Android: ``mipmap-*`` launcher PNGs at every density (mdpixxxhdpi),
  a circular round-icon variant, and a centered splash icon used by the
  Android 12+ splash screen.

Image resizing uses [Pillow](https://python-pillow.org/), declared as the
``[build]`` optional dependency. When Pillow isn't installed every
function degrades gracefully: it returns ``False`` (and the caller keeps
the template's default assets) so a missing optional dependency never
breaks a build. ``pn doctor`` reports whether Pillow is available.
"""

from __future__ import annotations

import json
import os
import shutil
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple

# Android launcher icon sizes (px) per density bucket.
ANDROID_LAUNCHER_DENSITIES: Dict[str, int] = {
    "mdpi": 48,
    "hdpi": 72,
    "xhdpi": 96,
    "xxhdpi": 144,
    "xxxhdpi": 192,
}


def pillow_available() -> bool:
    """Return whether Pillow can be imported.

    Returns:
        ``True`` if ``PIL.Image`` imports, else ``False``.
    """
    try:
        import PIL.Image  # noqa: F401
    except Exception:
        return False
    return True


def _open_rgba(source: Path) -> Any:
    from PIL import Image

    img = Image.open(source).convert("RGBA")
    return img


def _resized(img: Any, size: int) -> Any:
    from PIL import Image

    return img.resize((size, size), Image.Resampling.LANCZOS)


def _circular(img: Any) -> Any:
    """Return a copy of a square image masked to a circle."""
    from PIL import Image, ImageDraw

    size = img.size[0]
    mask = Image.new("L", (size, size), 0)
    draw = ImageDraw.Draw(mask)
    draw.ellipse((0, 0, size, size), fill=255)
    out = Image.new("RGBA", (size, size), (0, 0, 0, 0))
    out.paste(img, (0, 0), mask)
    return out


def generate_ios_icons(source: Path, appiconset_dir: Path) -> bool:
    """Generate a single-size iOS ``AppIcon.appiconset``.

    Writes ``icon-1024.png`` (a flattened, opaque 1024x1024 image; the
    App Store rejects icons with alpha) and a ``Contents.json`` that
    declares it as the universal iOS app icon. Xcode derives every other
    size at build time.

    Args:
        source: Path to the source icon image.
        appiconset_dir: The ``AppIcon.appiconset`` directory to populate.

    Returns:
        ``True`` if icons were written, ``False`` if Pillow is missing.
    """
    if not pillow_available():
        return False
    from PIL import Image

    appiconset_dir.mkdir(parents=True, exist_ok=True)
    img = _open_rgba(source)
    icon = _resized(img, 1024)
    flattened = Image.new("RGB", (1024, 1024), (255, 255, 255))
    flattened.paste(icon, (0, 0), icon)
    flattened.save(appiconset_dir / "icon-1024.png", format="PNG")

    contents = {
        "images": [
            {
                "idiom": "universal",
                "platform": "ios",
                "size": "1024x1024",
                "filename": "icon-1024.png",
            }
        ],
        "info": {"author": "pythonnative", "version": 1},
    }
    (appiconset_dir / "Contents.json").write_text(json.dumps(contents, indent=2) + "\n", encoding="utf-8")
    return True


def generate_android_icons(source: Path, res_dir: Path) -> bool:
    """Generate Android launcher icons at every density.

    Writes ``mipmap-/ic_launcher.png`` and a circular
    ``ic_launcher_round.png`` for each density bucket, and removes the
    adaptive ``mipmap-anydpi-v26`` definitions so the generated PNGs are
    used directly (otherwise the template's vector adaptive icon would
    win on API 26+).

    Args:
        source: Path to the source icon image.
        res_dir: The Android ``res`` directory.

    Returns:
        ``True`` if icons were written, ``False`` if Pillow is missing.
    """
    if not pillow_available():
        return False

    img = _open_rgba(source)
    for density, size in ANDROID_LAUNCHER_DENSITIES.items():
        mip_dir = res_dir / f"mipmap-{density}"
        mip_dir.mkdir(parents=True, exist_ok=True)
        square = _resized(img, size)
        square.save(mip_dir / "ic_launcher.png", format="PNG")
        round_icon = _circular(square)
        round_icon.save(mip_dir / "ic_launcher_round.png", format="PNG")

    # Drop adaptive XML icons so the raster mipmaps above are authoritative.
    anydpi = res_dir / "mipmap-anydpi-v26"
    if anydpi.is_dir():
        shutil.rmtree(anydpi, ignore_errors=True)
    return True


def generate_ios_splash(source: Path, imageset_dir: Path) -> bool:
    """Generate an iOS ``Splash`` image set from a source image.

    Args:
        source: Path to the splash image.
        imageset_dir: The ``Splash.imageset`` directory to populate.

    Returns:
        ``True`` if the image set was written, ``False`` if Pillow is
        missing.
    """
    if not pillow_available():
        return False

    imageset_dir.mkdir(parents=True, exist_ok=True)
    img = _open_rgba(source)
    img.save(imageset_dir / "splash.png", format="PNG")
    contents = {
        "images": [{"idiom": "universal", "filename": "splash.png"}],
        "info": {"author": "pythonnative", "version": 1},
    }
    (imageset_dir / "Contents.json").write_text(json.dumps(contents, indent=2) + "\n", encoding="utf-8")
    return True


def generate_android_splash_icon(source: Path, dest: Path, size: int = 288) -> bool:
    """Render the centered icon used by the Android 12+ splash screen.

    The Android splash screen draws this image centered on the splash
    background color. A transparent square is recommended; the default
    size (288 dp at xxxhdpi  ~864 px) matches Google's guidance.

    Args:
        source: Path to the source splash (or icon) image.
        dest: Destination PNG path.
        size: Output edge length in pixels.

    Returns:
        ``True`` if the image was written, ``False`` if Pillow is missing.
    """
    if not pillow_available():
        return False

    dest.parent.mkdir(parents=True, exist_ok=True)
    img = _open_rgba(source)
    _resized(img, size).save(dest, format="PNG")
    return True


def dominant_background_color(source: Path) -> Optional[str]:
    """Best-effort estimate of a splash background color from an image.

    Samples the image's corner pixels and returns the most common one as
    a ``#RRGGBB`` hex string. Used as a default splash background when the
    config doesn't specify one.

    Args:
        source: Path to the splash image.

    Returns:
        A hex color string, or ``None`` if Pillow is missing or the
        sampling fails.
    """
    if not pillow_available():
        return None
    try:
        img = _open_rgba(source)
        width, height = img.size
        corners: List[Tuple[int, int]] = [
            (0, 0),
            (width - 1, 0),
            (0, height - 1),
            (width - 1, height - 1),
        ]
        counts: Dict[Tuple[int, int, int], int] = {}
        for x, y in corners:
            r, g, b, _a = img.getpixel((x, y))
            key = (r, g, b)
            counts[key] = counts.get(key, 0) + 1
        r, g, b = max(counts, key=lambda k: counts[k])
        return f"#{r:02X}{g:02X}{b:02X}"
    except Exception:
        return None


def has_source(path: Optional[Path]) -> bool:
    """Return whether ``path`` is a readable existing file.

    Args:
        path: A candidate asset path, or ``None``.

    Returns:
        ``True`` when the path is a file that exists on disk.
    """
    return bool(path and os.path.isfile(path))

Web Proxy Viewer  |  New URL  |  Original Page