Add shared launcher-icon generator for the Flutter apps

Every Flutter app still shipped the stock Flutter "beach" launcher icon, and
none had an adaptive-icon descriptor at all. Rather than hand-drawing five
one-off icons, define the family once and let each app contribute only an
accent colour and a glyph.

The family: a charcoal #1B1D21 field carrying one glyph in that app's own
theme seed colour, at a single stroke weight. Glyphs are optically centred
automatically - render.centre_offset rasterises a glyph alone, measures its
ink box, and translates (never scales) the fragment, so a row of icons lines
up while every icon keeps the exact family stroke weight.

safe_box_overflow warns when a glyph exceeds the 560px adaptive-icon safe
box; it caught three real overflows that a launcher mask would have clipped.

The registry points dufs_client at ~/dufs-cloud/app: ~/dufs_client was a
stale, remote-less checkout with an identical package name, since deleted.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Krzysztof Rudnicki 2026-07-19 16:55:48 +02:00
parent 071daea982
commit 27f44cad4b
19 changed files with 1520 additions and 0 deletions

View File

@ -0,0 +1,18 @@
{
"title": "Shared launcher-icon generator for the Flutter apps",
"objective": "Replace the stock Flutter 'beach' launcher icon on every Flutter/Dart app with an icon from one consistent family, and make the family reproducible so future apps inherit it instead of drifting. The family is a charcoal #1B1D21 field carrying a single glyph in that app's own theme accent, drawn at one stroke weight and optically centred so a row of the icons lines up. Success means the phone launcher shows five distinct, on-style icons and a new app can be given a matching icon by adding one glyph plus one registry entry.",
"acceptance_criteria": [
"python_pkg/app_icons exposes a CLI with list, preview and generate subcommands, runnable as PYTHONPATH=~/testsAndMisc python3 -m python_pkg.app_icons.",
"Glyphs are optically centred automatically: the renderer measures rendered ink and translates (never scales) the fragment, so stroke weight stays identical across the family.",
"generate warns when a glyph's ink exceeds the 560px adaptive-icon safe box, so no icon ships at risk of being clipped by a launcher mask.",
"Each app gets three layers - base icon, adaptive foreground, and Android 13 monochrome - as both SVG source and 1024px PNG.",
"The package passes ruff, mypy, pylint and bandit, and holds 100% line and branch coverage with all external tools mocked.",
"All five apps show their new icon in the phone app drawer, installed with adb install -r and with no app data wiped."
],
"out_of_scope": [
"The per-app repositories (dufs-cloud, screen-locker, wake-alarm, diet-guard, todo) - their generated assets are committed separately in their own repos.",
"iOS, web, Windows and macOS icon targets - none of these apps configure those platforms.",
"Changing any app's android:label or in-app theme colours; this is icons only."
],
"verifier": "pre-commit run --files <staged>; python3 -m pytest python_pkg/app_icons --cov=python_pkg.app_icons --cov-report=term-missing (100% required); plus on-device verification: adb install -r each app then screenshot the launcher."
}

View File

@ -0,0 +1,53 @@
{
"intent": "Give every Flutter/Dart app a launcher icon from one shared family (charcoal field + the app's own theme accent as a glyph) instead of the stock Flutter 'beach' icon, and make future apps inherit that style automatically.",
"scope": [
"python_pkg/app_icons/ (new package: style, glyphs, app registry, renderer, preview, CLI)",
"python_pkg/app_icons/tests/ (new test suite)",
"Non-goal: the per-app repositories themselves (dufs-cloud, screen-locker, wake-alarm, diet-guard, todo) are committed separately",
"Non-goal: iOS/web/Windows/macOS targets - none are configured on these apps"
],
"changes": [
"style.py defines the family once (canvas 1024, charcoal #1B1D21, stroke 72, 560px adaptive safe box); every app contributes only an accent colour and a glyph.",
"render.centre_offset rasterises a glyph alone, measures its ink bounding box with ImageMagick, and translates (never scales) the fragment so all icons share one optical centre - this is what makes a row of icons line up.",
"render.safe_box_overflow warns when a glyph exceeds the adaptive-icon safe box; it caught three real overflows (cloud 646px tall, shield, checklist) that a launcher mask would have clipped.",
"generate.py writes three layers per app (icon, adaptive foreground, Android 13 monochrome) as SVG plus 1024px PNG, then drives flutter_launcher_icons for the Android mipmaps and mipmap-anydpi-v26 descriptor.",
"apps.py registry points dufs_client at ~/dufs-cloud/app: ~/dufs_client was a stale, remote-less checkout with the same package name that has since been deleted."
],
"verification": [
{
"command": "ruff check python_pkg/app_icons",
"result": "pass",
"evidence": "All checks passed!"
},
{
"command": "mypy (pre-commit hook flags) python_pkg/app_icons",
"result": "pass",
"evidence": "exit 0, no diagnostics"
},
{
"command": "pylint python_pkg/app_icons",
"result": "pass",
"evidence": "Your code has been rated at 10.00/10"
},
{
"command": "python3 -m pytest python_pkg/app_icons --cov=python_pkg.app_icons --cov-report=term-missing",
"result": "pass",
"evidence": "39 passed; every app_icons module at 100% line and branch coverage. External tools (rsvg-convert, magick, dart) are mocked, so the suite needs none installed."
},
{
"command": "PYTHONPATH=~/testsAndMisc python3 -m python_pkg.app_icons generate --android (all 5 apps) then adb install -r + launcher screenshot",
"result": "pass",
"evidence": "All five apps show their new icon in the phone app drawer; no stock icon remains. Each install reported plain 'Success' with no 'Uninstalling old version' line, so no app data was wiped."
}
],
"risks": [
"Editing style.py restyles every app at once - that is intended, but a careless change silently affects all five.",
"Glyph negative space below half the stroke weight merges into a blob at launcher size; the fork hit this and had to be redrawn as a filled fork-and-knife silhouette.",
"The generator writes into sibling repositories by absolute path, so a moved or renamed repo makes generate fail (or, worse, target a stale checkout - exactly what happened with dufs_client)."
],
"rollback": [
"git rm -r python_pkg/app_icons and revert this evidence file; nothing else in testsAndMisc depends on the package.",
"Per app repository, git checkout the mipmap PNGs and pubspec.yaml, and delete assets/icon plus the generated drawable-*/mipmap-anydpi-v26 directories.",
"After rollback, rebuild and adb install -r each app and confirm the launcher shows the previous icon."
]
}

View File

@ -0,0 +1,67 @@
# app_icons — shared launcher icons for the Flutter apps
One icon family across every Flutter/Dart app: a charcoal `#1B1D21` field with
a single glyph in that app's own theme accent, drawn at one stroke weight and
optically centred so a row of them lines up.
## Commands
```bash
# what is registered
PYTHONPATH=~/testsAndMisc python3 -m python_pkg.app_icons list
# contact sheet for visual review: full size / circle-masked / 48dp
PYTHONPATH=~/testsAndMisc python3 -m python_pkg.app_icons preview -o /tmp/sheet.png
# write assets into every app repo and rebuild the Android mipmaps
PYTHONPATH=~/testsAndMisc python3 -m python_pkg.app_icons generate --android
# one app, plus the hicolor PNGs for a Linux desktop target
PYTHONPATH=~/testsAndMisc python3 -m python_pkg.app_icons \
generate --app todo --android --linux-out ~/todo/linux/icons
```
## What gets written
Per app, into `<repo>/assets/icon/`:
| Layer | Purpose |
| --- | --- |
| `icon` | Charcoal field + glyph. Legacy square launcher icon and Linux icon. |
| `icon_foreground` | Glyph only, transparent. Adaptive-icon foreground layer. |
| `icon_monochrome` | Glyph only, white. Android 13+ themed icons. |
Both an `.svg` source and a 1024px `.png` are written for each, so a build
never needs `rsvg-convert`. `flutter_launcher_icons` then turns the PNGs into
the density buckets and `mipmap-anydpi-v26/ic_launcher.xml`.
## Design constraints that bite
- **Negative space ≥ half the stroke weight.** Strokes closer than that merge
into a solid blob at launcher size. A fork whose tines sat exactly one stroke
width apart rendered as a tulip.
- **Ink must stay inside the 560px safe box.** Android masks adaptive icons to
roughly the inner 66% of the canvas. `generate` warns when a glyph overflows.
- **Stroked outlines can read wrong.** A stroked three-tine fork reads as a
trident; a filled fork-and-knife pair reads as cutlery. Prefer a filled
silhouette when the outline is ambiguous.
- Glyph positioning inside the fragment is irrelevant — `render.centre_offset`
measures the rendered ink and recentres it. Only the extents matter.
## Adding an app
See the `app-icon` skill (`~/.claude/skills/app-icon/SKILL.md`) for the full
procedure. In short: add a glyph to `glyphs.py`, register the app in
`apps.py`, wire `flutter_launcher_icons` into its `pubspec.yaml`, generate,
review the contact sheet, then verify on the phone.
## Tests
```bash
cd ~/testsAndMisc
python3 -m pytest python_pkg/app_icons --cov=python_pkg.app_icons \
--cov-report=term-missing
```
External tools (`rsvg-convert`, `magick`, `dart`) are mocked; the suite needs
none of them installed.

View File

@ -0,0 +1,10 @@
"""Shared launcher-icon generator for kuhy's Flutter/Dart apps.
Every app icon in the family is built from the same template: a charcoal
rounded field with a single accent-coloured glyph, optically centred on the
canvas so a row of icons lines up. See :mod:`python_pkg.app_icons.style` for
the numbers that define the family and :mod:`python_pkg.app_icons.glyphs` for
the per-app artwork.
"""
from __future__ import annotations

View File

@ -0,0 +1,14 @@
"""Module entry point for the shared app-icon generator.
Usage:
PYTHONPATH=~/testsAndMisc python3 -m python_pkg.app_icons --help
"""
from __future__ import annotations
import sys
from python_pkg.app_icons.cli import main
if __name__ == "__main__":
sys.exit(main())

View File

@ -0,0 +1,111 @@
"""Registry of the Flutter apps that use the shared icon family.
Adding a new app here (plus a glyph in :mod:`python_pkg.app_icons.glyphs`) is
all that is needed for it to get an on-style icon. The accent is deliberately
taken from each app's own theme seed so the launcher icon matches the app's
first screen.
"""
from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
from typing import Final
@dataclass(frozen=True)
class AppIcon:
"""One app's icon configuration.
Parameters:
key (str): Short identifier used on the command line.
repo (Path): Flutter project root, i.e. the directory holding pubspec.yaml.
accent (str): Hex accent colour, matching the app's theme seed.
glyph (str): Glyph name from :data:`~python_pkg.app_icons.glyphs.GLYPHS`.
icon_name (str): Basename for Linux hicolor/desktop installation.
linux (bool): Whether the app has a Linux desktop target.
"""
key: str
repo: Path
accent: str
glyph: str
icon_name: str
linux: bool
@property
def asset_dir(self) -> Path:
"""Return the directory holding this app's icon source assets."""
return self.repo / "assets" / "icon"
_HOME = Path.home()
APPS: Final[dict[str, AppIcon]] = {
app.key: app
for app in (
AppIcon(
key="dufs_client",
# The live app: ~/dufs_client is a stale, remote-less checkout that
# was superseded when dufs-cloud absorbed it (see testsAndMisc
# CLAUDE.md). Same package name, so it is easy to icon the wrong one.
repo=_HOME / "dufs-cloud" / "app",
accent="#5C6BC0",
glyph="cloud-down",
icon_name="dufs-client",
linux=False,
),
AppIcon(
key="workout_app",
repo=_HOME / "screen-locker" / "stronglift_replacement" / "workout_app",
accent="#FF4444",
glyph="barbell",
icon_name="workout-app",
linux=False,
),
AppIcon(
key="wake_alarm_sync",
repo=_HOME / "wake-alarm" / "phone_app",
accent="#FFA726",
glyph="clock",
icon_name="wake-alarm-sync",
linux=False,
),
AppIcon(
key="diet_guard_app",
repo=_HOME / "diet-guard" / "app",
accent="#26A69A",
glyph="shield-cutlery",
icon_name="diet-guard-app",
linux=True,
),
AppIcon(
key="todo",
repo=_HOME / "todo",
accent="#7E57C2",
glyph="checklist",
icon_name="todo",
linux=True,
),
)
}
def get_app(key: str) -> AppIcon:
"""Look up an app by key.
Parameters:
key (str): App identifier, e.g. ``"todo"``.
Returns:
AppIcon: The matching configuration.
Raises:
KeyError: If no app with that key is registered.
"""
try:
return APPS[key]
except KeyError:
available = ", ".join(sorted(APPS))
msg = f"unknown app {key!r}; available: {available}"
raise KeyError(msg) from None

191
python_pkg/app_icons/cli.py Normal file
View File

@ -0,0 +1,191 @@
"""Command-line entry point for the shared app-icon generator.
Usage:
PYTHONPATH=~/testsAndMisc python -m python_pkg.app_icons list
PYTHONPATH=~/testsAndMisc python -m python_pkg.app_icons preview -o sheet.png
PYTHONPATH=~/testsAndMisc python -m python_pkg.app_icons generate --app todo
"""
from __future__ import annotations
import argparse
from pathlib import Path
import sys
from python_pkg.app_icons import apps, generate, glyphs, preview, render, style
def _emit(message: str) -> None:
"""Write a line to stdout.
Parameters:
message (str): Line to write, without a trailing newline.
"""
sys.stdout.write(f"{message}\n")
def _add_app_argument(parser: argparse.ArgumentParser) -> None:
"""Attach the repeatable ``--app`` selector to a subparser.
Parameters:
parser (argparse.ArgumentParser): Subparser to extend.
"""
parser.add_argument(
"--app",
action="append",
dest="app_keys",
choices=sorted(apps.APPS),
help="app to act on; repeatable. Defaults to every registered app.",
)
def build_parser() -> argparse.ArgumentParser:
"""Construct the argument parser.
Returns:
argparse.ArgumentParser: Parser with the ``list``, ``preview`` and
``generate`` subcommands.
"""
parser = argparse.ArgumentParser(
prog="python -m python_pkg.app_icons",
description="Generate on-style launcher icons for kuhy's Flutter apps.",
)
subparsers = parser.add_subparsers(dest="command", required=True)
subparsers.add_parser("list", help="show registered apps and available glyphs")
preview_parser = subparsers.add_parser(
"preview", help="render a contact sheet for visual review"
)
_add_app_argument(preview_parser)
preview_parser.add_argument(
"-o",
"--output",
type=Path,
default=Path("icon_review_sheet.png"),
help="where to write the contact sheet",
)
generate_parser = subparsers.add_parser(
"generate", help="write icon assets into the app repositories"
)
_add_app_argument(generate_parser)
generate_parser.add_argument(
"--android",
action="store_true",
help="also run flutter_launcher_icons to rebuild the Android mipmaps",
)
generate_parser.add_argument(
"--linux-out",
type=Path,
default=None,
help=(
"directory to write hicolor PNGs into for apps with a Linux target; "
"skipped when omitted"
),
)
return parser
def _selected(app_keys: list[str] | None) -> list[str]:
"""Resolve the ``--app`` selection to a concrete ordered list.
Parameters:
app_keys (list[str] | None): Keys given on the command line, if any.
Returns:
list[str]: Keys to act on, in registry order.
"""
if app_keys:
return [key for key in apps.APPS if key in set(app_keys)]
return list(apps.APPS)
def _cmd_list() -> int:
"""Print the app registry and glyph library.
Returns:
int: Process exit status.
"""
_emit("Apps:")
for app in apps.APPS.values():
linux = " +linux" if app.linux else ""
_emit(f" {app.key:16} {app.accent} {app.glyph}{linux} {app.repo}")
_emit("\nGlyphs:")
for glyph in glyphs.GLYPHS.values():
_emit(f" {glyph.name:16} {glyph.description}")
return 0
def _cmd_preview(app_keys: list[str] | None, output: Path) -> int:
"""Render the review contact sheet.
Parameters:
app_keys (list[str] | None): Selection from the command line.
output (Path): Where to write the sheet.
Returns:
int: Process exit status.
"""
sheet = preview.build_contact_sheet(_selected(app_keys), output)
_emit(f"wrote {sheet}")
return 0
def _cmd_generate(
app_keys: list[str] | None,
*,
android: bool,
linux_out: Path | None,
) -> int:
"""Write icon assets, optionally driving the platform generators.
Parameters:
app_keys (list[str] | None): Selection from the command line.
android (bool): Run flutter_launcher_icons afterwards.
linux_out (Path | None): Directory for hicolor PNGs, or None to skip.
Returns:
int: Process exit status.
"""
for key in _selected(app_keys):
app = apps.get_app(key)
written = generate.write_assets(app)
_emit(f"{app.key}: wrote {len(written)} asset files to {app.asset_dir}")
overflow = render.safe_box_overflow(glyphs.get_glyph(app.glyph), app.accent)
if any(overflow):
_emit(
f" warning: glyph exceeds the {style.SAFE_BOX}px safe box "
f"by {overflow[0]}x{overflow[1]}px; a launcher mask may clip it"
)
if linux_out is not None and app.linux:
icons = generate.write_linux_icons(app, linux_out)
_emit(f" wrote {len(icons)} hicolor PNGs to {linux_out}")
if android:
generate.run_flutter_launcher_icons(app)
_emit(" regenerated Android mipmaps")
return 0
def main(argv: list[str] | None = None) -> int:
"""Run the command-line interface.
Parameters:
argv (list[str] | None): Arguments to parse; defaults to ``sys.argv[1:]``.
Returns:
int: Process exit status.
"""
args = build_parser().parse_args(argv)
if args.command == "list":
return _cmd_list()
if args.command == "preview":
return _cmd_preview(args.app_keys, args.output)
return _cmd_generate(
args.app_keys,
android=args.android,
linux_out=args.linux_out,
)

View File

@ -0,0 +1,102 @@
"""Write an app's icon assets and drive flutter_launcher_icons.
Three layers are produced per app:
``icon``
Charcoal field plus accent glyph. Used as the legacy square launcher icon,
as the Linux desktop icon, and as the master for previews.
``icon_foreground``
Glyph only, transparent. The adaptive-icon foreground layer; the background
layer is the flat charcoal declared in pubspec.yaml.
``icon_monochrome``
Glyph only in white. Android 13+ recolours this for themed icons.
Both the SVG source and a 1024px PNG are written for each layer so a build never
needs rsvg-convert installed.
"""
from __future__ import annotations
import subprocess
from typing import TYPE_CHECKING, Final
from python_pkg.app_icons import glyphs, render, style
if TYPE_CHECKING:
from pathlib import Path
from python_pkg.app_icons.apps import AppIcon
# (filename stem, draw the charcoal field, colour override)
_LAYERS: Final[tuple[tuple[str, bool, str | None], ...]] = (
("icon", True, None),
("icon_foreground", False, None),
("icon_monochrome", False, style.MONOCHROME),
)
def write_assets(app: AppIcon) -> list[Path]:
"""Write every icon layer for one app as SVG plus 1024px PNG.
Parameters:
app (AppIcon): App to generate assets for.
Returns:
list[Path]: Every file written, in creation order.
"""
glyph = glyphs.get_glyph(app.glyph)
offset = render.centre_offset(glyph, app.accent)
written: list[Path] = []
for stem, with_background, colour in _LAYERS:
svg_text = render.build_svg(
glyph,
colour or app.accent,
with_background=with_background,
offset=offset,
)
svg_path = app.asset_dir / f"{stem}.svg"
svg_path.parent.mkdir(parents=True, exist_ok=True)
svg_path.write_text(svg_text, encoding="utf-8")
png_path = app.asset_dir / f"{stem}.png"
render.rasterise(svg_text, png_path, style.CANVAS)
written.extend((svg_path, png_path))
return written
def write_linux_icons(app: AppIcon, destination: Path) -> list[Path]:
"""Render the hicolor icon-theme PNGs for a Linux desktop target.
Parameters:
app (AppIcon): App to generate icons for; must have ``linux`` set.
destination (Path): Directory to write ``<size>/<icon_name>.png`` into.
Returns:
list[Path]: Every PNG written.
"""
glyph = glyphs.get_glyph(app.glyph)
offset = render.centre_offset(glyph, app.accent)
svg_text = render.build_svg(glyph, app.accent, with_background=True, offset=offset)
written: list[Path] = []
for size in style.LINUX_ICON_SIZES:
png_path = destination / str(size) / f"{app.icon_name}.png"
render.rasterise(svg_text, png_path, size)
written.append(png_path)
return written
def run_flutter_launcher_icons(app: AppIcon) -> None:
"""Generate the Android mipmap set from the written assets.
flutter_launcher_icons owns the error-prone part: five density buckets, the
round variant, and the ``mipmap-anydpi-v26/ic_launcher.xml`` adaptive
descriptor that none of these repos had before.
Parameters:
app (AppIcon): App whose Flutter project should be processed.
"""
subprocess.run(
[render.resolve_tool("dart"), "run", "flutter_launcher_icons"],
cwd=app.repo,
check=True,
capture_output=True,
)

View File

@ -0,0 +1,112 @@
"""Per-app glyph artwork for the shared icon family.
A glyph is a raw SVG fragment drawn in the 1024x1024 master canvas. It does not
need to be perfectly positioned: :mod:`python_pkg.app_icons.render` measures the
rendered ink and recentres the fragment, so every icon in the family sits at the
same optical height.
Fragments inherit ``fill="none"``, ``stroke``, ``stroke-width`` and round caps
from the wrapping group. A fragment that needs a filled silhouette instead
overrides those locally and uses :data:`~python_pkg.app_icons.style.ACCENT_MARKER`
for its colour.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Final
@dataclass(frozen=True)
class Glyph:
"""One app's artwork.
Parameters:
name (str): Stable identifier, used as the ``--glyph`` CLI value.
description (str): What the glyph depicts, for ``--list`` output.
body (str): SVG fragment drawn in the 1024x1024 master canvas.
"""
name: str
description: str
body: str
# A cloud silhouette outline with a download arrow underneath: a client that
# pulls files off a remote dufs server.
_CLOUD_DOWN = """\
<g transform="translate(296,196) scale(18)" stroke-width="4">
<path d="M19.35 10.04C18.67 6.59 15.64 4 12 4 9.11 4 6.6 5.64 5.35 8.04 \
2.34 8.36 0 10.91 0 14c0 3.31 2.69 6 6 6h13c2.76 0 5-2.24 5-5 \
0-2.64-2.05-4.78-4.65-4.96z"/>
</g>
<path d="M 512 628 L 512 744"/>
<path d="M 444 676 L 512 744 L 580 676"/>"""
# Bar, two plates and two collars. Plate spacing is well above the minimum
# negative space so the bar does not fill in at launcher size.
_BARBELL = """\
<path d="M 268 512 L 756 512"/>
<path d="M 364 356 L 364 668"/>
<path d="M 660 356 L 660 668"/>
<path d="M 268 432 L 268 592"/>
<path d="M 756 432 L 756 592"/>"""
# Plain clock face. Deliberately no bell "ears": at launcher size they read as
# noise rather than as an alarm clock.
_CLOCK = """\
<circle cx="512" cy="512" r="244"/>
<path d="M 512 512 L 512 350"/>
<path d="M 512 512 L 636 512"/>"""
# Shield outline guarding filled cutlery. The fork is paired with a knife on
# purpose: a lone three-tine fork reads as a trident at small sizes.
_SHIELD_CUTLERY = """\
<path d="M 512 275 L 752 366 L 752 544 C 752 655 646 723 512 753 \
C 378 723 272 655 272 544 L 272 366 Z"/>
<g transform="translate(356,362) scale(13)" fill="{{ACCENT}}" stroke="none">
<path d="M11 9H9V2H7v7H5V2H3v7c0 2.12 1.66 3.84 3.75 3.97V22h2.5v-9.03\
C11.34 12.84 13 11.12 13 9V2h-2v7z"/>
<path d="M16 12h2.5v10H21V2c-2.76 0-5 2.24-5 5v5z"/>
</g>"""
# Two ticked-off list rows. More specific to a notes/todo app than a lone
# checkmark, and still legible at 48dp.
_CHECKLIST = """\
<path d="M 272 372 L 346 446 L 478 314"/>
<path d="M 594 400 L 752 400"/>
<path d="M 272 620 L 346 694 L 478 562"/>
<path d="M 594 648 L 752 648"/>"""
GLYPHS: Final[dict[str, Glyph]] = {
glyph.name: glyph
for glyph in (
Glyph("cloud-down", "Cloud with a download arrow", _CLOUD_DOWN),
Glyph("barbell", "Barbell with plates and collars", _BARBELL),
Glyph("clock", "Clock face with hands", _CLOCK),
Glyph("shield-cutlery", "Shield guarding a fork and knife", _SHIELD_CUTLERY),
Glyph("checklist", "Two ticked-off list rows", _CHECKLIST),
)
}
def get_glyph(name: str) -> Glyph:
"""Look up a glyph by name.
Parameters:
name (str): Glyph identifier, e.g. ``"barbell"``.
Returns:
Glyph: The matching glyph.
Raises:
KeyError: If no glyph with that name exists. The message lists the
available names, since this is usually a CLI typo.
"""
try:
return GLYPHS[name]
except KeyError:
available = ", ".join(sorted(GLYPHS))
msg = f"unknown glyph {name!r}; available: {available}"
raise KeyError(msg) from None

View File

@ -0,0 +1,158 @@
"""Build a contact sheet of the whole icon family for eyeballing.
Three rows are produced: full size, circle-masked (what a round launcher mask
does to the art), and a 48dp downscale (legibility at real home-screen size).
Reviewing all three together is the cheapest way to catch a glyph that only
works at poster size.
"""
from __future__ import annotations
from pathlib import Path
import subprocess
import tempfile
from python_pkg.app_icons import apps, glyphs, render, style
# Size each icon is drawn at in the contact sheet.
_TILE = 512
# Roughly a 48dp launcher icon on a high-density screen, then blown back up
# with nearest-neighbour so the pixel grid stays visible.
_SMALL = 96
_SMALL_ZOOM = 200
def _row(tiles: list[Path], destination: Path) -> None:
"""Montage a list of PNGs into a single horizontal strip.
Parameters:
tiles (list[Path]): Images to place, left to right.
destination (Path): PNG path to write.
"""
subprocess.run(
[
render.resolve_tool("magick"),
"montage",
*[str(tile) for tile in tiles],
"-tile",
f"{len(tiles)}x1",
"-geometry",
"+14+14",
"-background",
"#3a3a3a",
str(destination),
],
check=True,
capture_output=True,
)
def _circle_mask(source: Path, destination: Path) -> None:
"""Apply a round launcher mask to an icon.
Parameters:
source (Path): Full-size icon PNG.
destination (Path): PNG path to write.
"""
radius = _TILE // 2
subprocess.run(
[
render.resolve_tool("magick"),
str(source),
"(",
"-size",
f"{_TILE}x{_TILE}",
"xc:none",
"-fill",
"white",
"-draw",
f"circle {radius},{radius} {radius},0",
")",
"-alpha",
"set",
"-compose",
"DstIn",
"-composite",
str(destination),
],
check=True,
capture_output=True,
)
def _downscale(source: Path, destination: Path) -> None:
"""Shrink an icon to launcher size and blow it back up for inspection.
Parameters:
source (Path): Full-size icon PNG.
destination (Path): PNG path to write.
"""
subprocess.run(
[
render.resolve_tool("magick"),
str(source),
"-resize",
f"{_SMALL}x{_SMALL}",
"-filter",
"point",
"-resize",
f"{_SMALL_ZOOM}x{_SMALL_ZOOM}",
str(destination),
],
check=True,
capture_output=True,
)
def build_contact_sheet(keys: list[str], destination: Path) -> Path:
"""Render a three-row contact sheet for the given apps.
Parameters:
keys (list[str]): App keys to include, in display order.
destination (Path): PNG path to write the sheet to.
Returns:
Path: The written sheet, for convenience.
"""
with tempfile.TemporaryDirectory() as tmp:
work = Path(tmp)
full: list[Path] = []
masked: list[Path] = []
small: list[Path] = []
for key in keys:
app = apps.get_app(key)
glyph = glyphs.get_glyph(app.glyph)
offset = render.centre_offset(glyph, app.accent)
svg_text = render.build_svg(
glyph, app.accent, with_background=True, offset=offset
)
tile = work / f"{key}.png"
render.rasterise(svg_text, tile, _TILE)
full.append(tile)
masked_tile = work / f"{key}_masked.png"
_circle_mask(tile, masked_tile)
masked.append(masked_tile)
small_tile = work / f"{key}_small.png"
_downscale(tile, small_tile)
small.append(small_tile)
rows = [work / "row_full.png", work / "row_masked.png", work / "row_small.png"]
for tiles, row_path in zip((full, masked, small), rows, strict=True):
_row(tiles, row_path)
destination.parent.mkdir(parents=True, exist_ok=True)
subprocess.run(
[
render.resolve_tool("magick"),
*[str(row) for row in rows],
"-background",
style.BACKGROUND,
"-gravity",
"center",
"-append",
str(destination),
],
check=True,
capture_output=True,
)
return destination

View File

@ -0,0 +1,203 @@
"""SVG assembly and rasterisation for the shared icon family.
The interesting part here is :func:`centre_offset`. Glyphs are hand-authored, so
their ink never lands exactly on the canvas centre; a barbell is wide and short,
a cloud sits high. Rather than nudging coordinates by hand, the glyph is
rendered once on its own, its ink bounding box is measured, and the whole
fragment is translated so that box is centred. That is what makes a row of
icons line up at the same optical height.
Only the fragment is translated -- never scaled -- so every icon keeps exactly
the family stroke weight.
"""
from __future__ import annotations
from pathlib import Path
import shutil
import subprocess
import tempfile
from typing import TYPE_CHECKING
from python_pkg.app_icons import style
if TYPE_CHECKING:
from python_pkg.app_icons.glyphs import Glyph
class ToolMissingError(RuntimeError):
"""Raised when a required external rasterisation tool is not installed."""
def __init__(self, tool: str) -> None:
"""Record which tool was missing.
Parameters:
tool (str): Executable name that could not be found on PATH.
"""
super().__init__(f"required tool {tool!r} is not installed")
self.tool = tool
class EmptyGlyphError(ValueError):
"""Raised when a glyph renders to nothing, so it cannot be centred."""
def __init__(self, name: str) -> None:
"""Record which glyph produced no ink.
Parameters:
name (str): Glyph identifier that rendered empty.
"""
super().__init__(f"glyph {name!r} rendered no visible ink")
self.name = name
def resolve_tool(name: str) -> str:
"""Resolve an external tool to an absolute path.
Parameters:
name (str): Executable name, e.g. ``"rsvg-convert"``.
Returns:
str: Absolute path to the executable.
Raises:
ToolMissingError: If the executable is not on PATH.
"""
resolved = shutil.which(name)
if resolved is None:
raise ToolMissingError(name)
return resolved
def build_svg(
glyph: Glyph,
accent: str,
*,
with_background: bool,
offset: tuple[float, float] = (0.0, 0.0),
) -> str:
"""Assemble a complete SVG document for one icon layer.
Parameters:
glyph (Glyph): Artwork to draw.
accent (str): Hex colour for the glyph, e.g. ``"#26A69A"``.
with_background (bool): Draw the shared charcoal field behind the glyph.
False produces the transparent adaptive-icon layers.
offset (tuple[float, float]): ``(dx, dy)`` translation applied to the
fragment, normally the result of :func:`centre_offset`.
Returns:
str: A standalone SVG document.
"""
body = glyph.body.replace(style.ACCENT_MARKER, accent)
dx, dy = offset
field = (
f'\n <rect width="{style.CANVAS}" height="{style.CANVAS}" '
f'fill="{style.BACKGROUND}"/>'
if with_background
else ""
)
return (
f'<svg xmlns="http://www.w3.org/2000/svg" width="{style.CANVAS}" '
f'height="{style.CANVAS}" viewBox="0 0 {style.CANVAS} {style.CANVAS}">'
f"{field}\n"
f' <g fill="none" stroke="{accent}" stroke-width="{style.STROKE_WIDTH}" '
f'stroke-linecap="round" stroke-linejoin="round" '
f'transform="translate({dx:g},{dy:g})">\n'
f"{body}\n"
f" </g>\n"
f"</svg>\n"
)
def rasterise(svg_text: str, destination: Path, size: int) -> None:
"""Render an SVG document to a square PNG.
Parameters:
svg_text (str): Complete SVG document.
destination (Path): PNG path to write; parent directories are created.
size (int): Output width and height in pixels.
"""
destination.parent.mkdir(parents=True, exist_ok=True)
subprocess.run(
[
resolve_tool("rsvg-convert"),
"-w",
str(size),
"-h",
str(size),
"-o",
str(destination),
],
input=svg_text.encode(),
check=True,
capture_output=True,
)
def _ink_bbox(png: Path) -> tuple[int, int, int, int]:
"""Measure the non-transparent bounding box of a PNG.
Parameters:
png (Path): Image to measure.
Returns:
tuple[int, int, int, int]: ``(width, height, x, y)`` of the ink box.
"""
result = subprocess.run(
[resolve_tool("magick"), str(png), "-trim", "-format", "%w %h %X %Y", "info:"],
check=True,
capture_output=True,
text=True,
)
width, height, x, y = result.stdout.split()
return int(width), int(height), int(x), int(y)
def centre_offset(glyph: Glyph, accent: str) -> tuple[float, float]:
"""Compute the translation that optically centres a glyph.
The glyph is rendered on a transparent canvas at full resolution, its ink
box is measured, and the offset from that box's centre to the canvas centre
is returned.
Parameters:
glyph (Glyph): Artwork to measure.
accent (str): Hex colour to render with. Colour does not affect the box,
but rsvg-convert needs a concrete value.
Returns:
tuple[float, float]: ``(dx, dy)`` to pass to :func:`build_svg`.
Raises:
EmptyGlyphError: If the glyph renders no visible ink.
"""
probe = build_svg(glyph, accent, with_background=False)
with tempfile.TemporaryDirectory() as tmp:
png = Path(tmp) / "probe.png"
rasterise(probe, png, style.CANVAS)
width, height, x, y = _ink_bbox(png)
if width == 0 or height == 0:
raise EmptyGlyphError(glyph.name)
return style.CENTRE - (x + width / 2), style.CENTRE - (y + height / 2)
def safe_box_overflow(glyph: Glyph, accent: str) -> tuple[int, int]:
"""Measure how far a centred glyph exceeds the adaptive-icon safe box.
Anything above zero risks being clipped by a launcher mask.
Parameters:
glyph (Glyph): Artwork to check.
accent (str): Hex colour to render with.
Returns:
tuple[int, int]: Horizontal and vertical overflow in pixels, clamped at
zero.
"""
probe = build_svg(glyph, accent, with_background=False)
with tempfile.TemporaryDirectory() as tmp:
png = Path(tmp) / "probe.png"
rasterise(probe, png, style.CANVAS)
width, height, _x, _y = _ink_bbox(png)
return max(0, width - style.SAFE_BOX), max(0, height - style.SAFE_BOX)

View File

@ -0,0 +1,39 @@
"""The numbers that define the shared icon family.
Changing anything here changes *every* app's icon, which is the point: the
family is defined once and each app only contributes an accent colour and a
glyph. Regenerate all apps after editing this module.
"""
from __future__ import annotations
from typing import Final
# Master canvas. 1024 is what flutter_launcher_icons wants as a source image.
CANVAS: Final[int] = 1024
CENTRE: Final[float] = CANVAS / 2
# Charcoal field shared by all icons. Also used as adaptive_icon_background so
# the adaptive foreground and background layers stay seamless.
BACKGROUND: Final[str] = "#1B1D21"
# Android masks an adaptive icon down to roughly the inner 66% of the canvas.
# Keeping every glyph inside this box means no launcher mask (circle, squircle,
# teardrop, ...) can clip it.
SAFE_BOX: Final[int] = 560
# Single stroke weight for the whole family. Glyph negative space must stay at
# or above STROKE_WIDTH / 2, otherwise adjacent strokes merge into a blob when
# the icon is scaled down to launcher size.
STROKE_WIDTH: Final[int] = 72
MIN_NEGATIVE_SPACE: Final[float] = STROKE_WIDTH / 2
# Colour used for the Android 13 themed ("monochrome") layer, which the system
# recolours itself, so the glyph must be drawn in flat white.
MONOCHROME: Final[str] = "#FFFFFF"
# Placeholder replaced with an app's accent colour inside a glyph fragment.
ACCENT_MARKER: Final[str] = "{{ACCENT}}"
# Sizes installed into the hicolor icon theme for Linux desktop targets.
LINUX_ICON_SIZES: Final[tuple[int, ...]] = (16, 24, 32, 48, 64, 128, 256, 512)

View File

@ -0,0 +1,3 @@
"""Tests for the shared app-icon generator."""
from __future__ import annotations

View File

@ -0,0 +1,107 @@
"""Tests for app_icons.cli."""
from __future__ import annotations
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
from python_pkg.app_icons import apps, cli
MOD = "python_pkg.app_icons.cli"
class TestSelected:
def test_defaults_to_every_app(self) -> None:
assert cli._selected(None) == list(apps.APPS)
def test_keeps_registry_order(self) -> None:
assert cli._selected(["todo", "dufs_client"]) == ["dufs_client", "todo"]
class TestListCommand:
def test_prints_apps_and_glyphs(self, capsys: pytest.CaptureFixture[str]) -> None:
assert cli.main(["list"]) == 0
out = capsys.readouterr().out
assert "todo" in out
assert "checklist" in out
class TestPreviewCommand:
@patch(f"{MOD}.preview.build_contact_sheet")
def test_passes_output_path(
self, sheet: MagicMock, capsys: pytest.CaptureFixture[str], tmp_path: Path
) -> None:
target = tmp_path / "sheet.png"
sheet.return_value = target
assert cli.main(["preview", "--app", "todo", "-o", str(target)]) == 0
assert sheet.call_args.args == (["todo"], target)
assert str(target) in capsys.readouterr().out
class TestGenerateCommand:
@patch(f"{MOD}.render.safe_box_overflow", return_value=(0, 0))
@patch(f"{MOD}.generate.write_assets", return_value=[Path("a"), Path("b")])
def test_writes_assets_only_by_default(
self,
write: MagicMock,
mock_overflow: MagicMock,
capsys: pytest.CaptureFixture[str],
) -> None:
assert cli.main(["generate", "--app", "todo"]) == 0
assert write.call_count == 1
assert "wrote 2 asset files" in capsys.readouterr().out
@patch(f"{MOD}.render.safe_box_overflow", return_value=(12, 0))
@patch(f"{MOD}.generate.write_assets", return_value=[])
def test_warns_on_safe_box_overflow(
self,
mock_write: MagicMock,
mock_overflow: MagicMock,
capsys: pytest.CaptureFixture[str],
) -> None:
cli.main(["generate", "--app", "todo"])
assert "safe box" in capsys.readouterr().out
@patch(f"{MOD}.generate.run_flutter_launcher_icons")
@patch(f"{MOD}.render.safe_box_overflow", return_value=(0, 0))
@patch(f"{MOD}.generate.write_assets", return_value=[])
def test_android_flag_runs_generator(
self,
mock_write: MagicMock,
mock_overflow: MagicMock,
launcher: MagicMock,
) -> None:
cli.main(["generate", "--app", "todo", "--android"])
assert launcher.call_count == 1
@patch(f"{MOD}.generate.write_linux_icons", return_value=[Path("x")])
@patch(f"{MOD}.render.safe_box_overflow", return_value=(0, 0))
@patch(f"{MOD}.generate.write_assets", return_value=[])
def test_linux_out_only_for_linux_apps(
self,
mock_write: MagicMock,
mock_overflow: MagicMock,
linux: MagicMock,
tmp_path: Path,
) -> None:
cli.main(
[
"generate",
"--app",
"todo",
"--app",
"dufs_client",
"--linux-out",
str(tmp_path),
]
)
# The todo app has a Linux desktop target; dufs_client does not.
assert linux.call_count == 1
class TestParser:
def test_requires_a_subcommand(self) -> None:
with pytest.raises(SystemExit):
cli.main([])

View File

@ -0,0 +1,89 @@
"""Tests for app_icons.generate."""
from __future__ import annotations
from typing import TYPE_CHECKING
from unittest.mock import MagicMock, patch
from python_pkg.app_icons import generate, style
from python_pkg.app_icons.apps import AppIcon
if TYPE_CHECKING:
from pathlib import Path
MOD = "python_pkg.app_icons.generate"
def _app(tmp_path: Path, *, linux: bool = False) -> AppIcon:
return AppIcon(
key="demo",
repo=tmp_path / "demo",
accent="#26A69A",
glyph="barbell",
icon_name="demo",
linux=linux,
)
class TestWriteAssets:
@patch(f"{MOD}.render.rasterise")
@patch(f"{MOD}.render.centre_offset", return_value=(1.0, 2.0))
def test_writes_three_svg_png_pairs(
self, mock_offset: MagicMock, raster: MagicMock, tmp_path: Path
) -> None:
written = generate.write_assets(_app(tmp_path))
stems = sorted({path.stem for path in written})
assert stems == ["icon", "icon_foreground", "icon_monochrome"]
assert len(written) == 6
assert raster.call_count == 3
@patch(f"{MOD}.render.rasterise")
@patch(f"{MOD}.render.centre_offset", return_value=(0.0, 0.0))
def test_only_the_base_layer_has_the_field(
self, mock_offset: MagicMock, mock_raster: MagicMock, tmp_path: Path
) -> None:
app = _app(tmp_path)
generate.write_assets(app)
base = (app.asset_dir / "icon.svg").read_text(encoding="utf-8")
foreground = (app.asset_dir / "icon_foreground.svg").read_text(encoding="utf-8")
assert style.BACKGROUND in base
assert style.BACKGROUND not in foreground
@patch(f"{MOD}.render.rasterise")
@patch(f"{MOD}.render.centre_offset", return_value=(0.0, 0.0))
def test_monochrome_layer_is_white(
self, mock_offset: MagicMock, mock_raster: MagicMock, tmp_path: Path
) -> None:
app = _app(tmp_path)
generate.write_assets(app)
mono = (app.asset_dir / "icon_monochrome.svg").read_text(encoding="utf-8")
assert f'stroke="{style.MONOCHROME}"' in mono
assert app.accent not in mono
class TestWriteLinuxIcons:
@patch(f"{MOD}.render.rasterise")
@patch(f"{MOD}.render.centre_offset", return_value=(0.0, 0.0))
def test_one_png_per_hicolor_size(
self, mock_offset: MagicMock, raster: MagicMock, tmp_path: Path
) -> None:
written = generate.write_linux_icons(_app(tmp_path, linux=True), tmp_path / "h")
assert len(written) == len(style.LINUX_ICON_SIZES)
assert raster.call_count == len(style.LINUX_ICON_SIZES)
assert written[0] == tmp_path / "h" / "16" / "demo.png"
class TestRunFlutterLauncherIcons:
@patch(f"{MOD}.subprocess.run")
@patch(f"{MOD}.render.resolve_tool", return_value="/usr/bin/dart")
def test_invokes_dart_in_the_repo(
self, mock_tool: MagicMock, run: MagicMock, tmp_path: Path
) -> None:
app = _app(tmp_path)
generate.run_flutter_launcher_icons(app)
assert run.call_args.args[0] == [
"/usr/bin/dart",
"run",
"flutter_launcher_icons",
]
assert run.call_args.kwargs["cwd"] == app.repo

View File

@ -0,0 +1,34 @@
"""Tests for app_icons.__main__ module."""
from __future__ import annotations
import importlib
import runpy
import sys
from unittest.mock import MagicMock, patch
import pytest
class TestMain:
def test_main_called_when_run_as_module(self) -> None:
"""python3 -m python_pkg.app_icons runs main() and exits with its status."""
mock_main = MagicMock(return_value=0)
# runpy warns if __main__ is already imported, and the suite runs tests
# in a random order, so another test may have imported it first.
sys.modules.pop("python_pkg.app_icons.__main__", None)
with (
patch("python_pkg.app_icons.cli.main", mock_main),
pytest.raises(SystemExit) as excinfo,
):
runpy.run_module("python_pkg.app_icons", run_name="__main__")
assert excinfo.value.code == 0
mock_main.assert_called_once()
def test_main_not_called_on_plain_import(self) -> None:
"""Importing __main__ must not generate icons as a side effect."""
mock_main = MagicMock()
with patch("python_pkg.app_icons.cli.main", mock_main):
sys.modules.pop("python_pkg.app_icons.__main__", None)
importlib.import_module("python_pkg.app_icons.__main__")
mock_main.assert_not_called()

View File

@ -0,0 +1,51 @@
"""Tests for app_icons.preview."""
from __future__ import annotations
from typing import TYPE_CHECKING
from unittest.mock import MagicMock, patch
from python_pkg.app_icons import preview
if TYPE_CHECKING:
from pathlib import Path
MOD = "python_pkg.app_icons.preview"
class TestBuildContactSheet:
@patch(f"{MOD}.subprocess.run")
@patch(f"{MOD}.render.rasterise")
@patch(f"{MOD}.render.centre_offset", return_value=(0.0, 0.0))
@patch(f"{MOD}.render.resolve_tool", return_value="/usr/bin/magick")
def test_renders_one_tile_per_app_and_three_rows(
self,
mock_tool: MagicMock,
mock_offset: MagicMock,
raster: MagicMock,
run: MagicMock,
tmp_path: Path,
) -> None:
destination = tmp_path / "out" / "sheet.png"
assert preview.build_contact_sheet(["todo", "workout_app"], destination) == (
destination
)
assert raster.call_count == 2
# per app: circle mask + downscale; then 3 row montages + 1 append.
assert run.call_count == 2 * 2 + 3 + 1
assert destination.parent.is_dir()
@patch(f"{MOD}.subprocess.run")
@patch(f"{MOD}.render.rasterise")
@patch(f"{MOD}.render.centre_offset", return_value=(0.0, 0.0))
@patch(f"{MOD}.render.resolve_tool", return_value="/usr/bin/magick")
def test_final_append_is_vertical(
self,
mock_tool: MagicMock,
mock_offset: MagicMock,
mock_raster: MagicMock,
run: MagicMock,
tmp_path: Path,
) -> None:
preview.build_contact_sheet(["todo"], tmp_path / "sheet.png")
assert "-append" in run.call_args.args[0]

View File

@ -0,0 +1,49 @@
"""Tests for the app registry, glyph library and family style invariants."""
from __future__ import annotations
import pytest
from python_pkg.app_icons import apps, glyphs, style
class TestGlyphLookup:
def test_known_glyph(self) -> None:
assert glyphs.get_glyph("barbell").name == "barbell"
def test_unknown_glyph_lists_alternatives(self) -> None:
with pytest.raises(KeyError, match="barbell"):
glyphs.get_glyph("nope")
class TestAppLookup:
def test_known_app(self) -> None:
assert apps.get_app("todo").glyph == "checklist"
def test_unknown_app_lists_alternatives(self) -> None:
with pytest.raises(KeyError, match="todo"):
apps.get_app("nope")
def test_asset_dir_is_under_repo(self) -> None:
app = apps.get_app("todo")
assert app.asset_dir == app.repo / "assets" / "icon"
class TestFamilyInvariants:
def test_every_app_references_a_real_glyph(self) -> None:
for app in apps.APPS.values():
assert app.glyph in glyphs.GLYPHS
def test_accents_are_unique(self) -> None:
accents = [app.accent for app in apps.APPS.values()]
assert len(set(accents)) == len(accents)
def test_accents_are_hex_triplets(self) -> None:
for app in apps.APPS.values():
assert app.accent.startswith("#")
assert len(app.accent) == 7
int(app.accent[1:], 16)
def test_safe_box_fits_inside_canvas(self) -> None:
assert style.SAFE_BOX < style.CANVAS
assert style.MIN_NEGATIVE_SPACE == style.STROKE_WIDTH / 2

View File

@ -0,0 +1,109 @@
"""Tests for app_icons.render."""
from __future__ import annotations
from pathlib import Path
import subprocess
from unittest.mock import MagicMock, patch
import pytest
from python_pkg.app_icons import render, style
from python_pkg.app_icons.glyphs import Glyph
MOD = "python_pkg.app_icons.render"
DOT = Glyph("dot", "a dot", ' <path d="M 100 100 L 200 200"/>')
FILLED = Glyph("filled", "a filled shape", ' <rect fill="{{ACCENT}}" />')
class TestResolveTool:
@patch(f"{MOD}.shutil.which", return_value="/usr/bin/magick")
def test_found(self, mock_which: MagicMock) -> None:
assert render.resolve_tool("magick") == "/usr/bin/magick"
@patch(f"{MOD}.shutil.which", return_value=None)
def test_missing(self, mock_which: MagicMock) -> None:
with pytest.raises(render.ToolMissingError) as excinfo:
render.resolve_tool("nope")
assert excinfo.value.tool == "nope"
class TestBuildSvg:
def test_background_layer_has_field(self) -> None:
svg = render.build_svg(DOT, "#123456", with_background=True)
assert style.BACKGROUND in svg
assert 'stroke="#123456"' in svg
assert f'stroke-width="{style.STROKE_WIDTH}"' in svg
def test_transparent_layer_has_no_field(self) -> None:
svg = render.build_svg(DOT, "#123456", with_background=False)
assert style.BACKGROUND not in svg
def test_offset_is_applied(self) -> None:
svg = render.build_svg(DOT, "#123456", with_background=False, offset=(3.5, -7))
assert 'transform="translate(3.5,-7)"' in svg
def test_accent_token_is_substituted(self) -> None:
svg = render.build_svg(FILLED, "#ABCDEF", with_background=False)
assert style.ACCENT_MARKER not in svg
assert 'fill="#ABCDEF"' in svg
class TestRasterise:
@patch(f"{MOD}.subprocess.run")
@patch(f"{MOD}.resolve_tool", return_value="/usr/bin/rsvg-convert")
def test_invokes_rsvg_and_creates_parent(
self, mock_tool: MagicMock, run: MagicMock, tmp_path: Path
) -> None:
destination = tmp_path / "nested" / "out.png"
render.rasterise("<svg/>", destination, 256)
assert destination.parent.is_dir()
argv = run.call_args.args[0]
assert argv[0] == "/usr/bin/rsvg-convert"
assert "256" in argv
assert run.call_args.kwargs["input"] == b"<svg/>"
class TestInkBbox:
@patch(f"{MOD}.subprocess.run")
@patch(f"{MOD}.resolve_tool", return_value="/usr/bin/magick")
def test_parses_identify_output(self, mock_tool: MagicMock, run: MagicMock) -> None:
run.return_value = subprocess.CompletedProcess([], 0, stdout="400 300 +100 +50")
assert render._ink_bbox(Path("x.png")) == (400, 300, 100, 50)
class TestCentreOffset:
@patch(f"{MOD}._ink_bbox", return_value=(400, 300, 112, 262))
@patch(f"{MOD}.rasterise")
def test_centres_ink_box(
self, mock_raster: MagicMock, mock_bbox: MagicMock
) -> None:
dx, dy = render.centre_offset(DOT, "#FFFFFF")
# ink centre is (312, 412); canvas centre is 512.
assert (dx, dy) == (200, 100)
@patch(f"{MOD}._ink_bbox", return_value=(0, 0, 0, 0))
@patch(f"{MOD}.rasterise")
def test_empty_glyph_raises(
self, mock_raster: MagicMock, mock_bbox: MagicMock
) -> None:
with pytest.raises(render.EmptyGlyphError) as excinfo:
render.centre_offset(DOT, "#FFFFFF")
assert excinfo.value.name == "dot"
class TestSafeBoxOverflow:
@patch(f"{MOD}._ink_bbox", return_value=(600, 500, 0, 0))
@patch(f"{MOD}.rasterise")
def test_reports_horizontal_overflow(
self, mock_raster: MagicMock, mock_bbox: MagicMock
) -> None:
assert render.safe_box_overflow(DOT, "#FFFFFF") == (600 - style.SAFE_BOX, 0)
@patch(f"{MOD}._ink_bbox", return_value=(100, 100, 0, 0))
@patch(f"{MOD}.rasterise")
def test_no_overflow_is_zero(
self, mock_raster: MagicMock, mock_bbox: MagicMock
) -> None:
assert render.safe_box_overflow(DOT, "#FFFFFF") == (0, 0)