diff --git a/docs/superpowers/contracts/app-icons-launcher-unification-2026-10-23.json b/docs/superpowers/contracts/app-icons-launcher-unification-2026-10-23.json new file mode 100644 index 00000000..9df05b02 --- /dev/null +++ b/docs/superpowers/contracts/app-icons-launcher-unification-2026-10-23.json @@ -0,0 +1,15 @@ +{ + "title": "Shared icon generator fits every glyph to one ink circle and enforces launcher-name rules", + "objective": "python_pkg/app_icons renders every glyph centred with its furthest ink at INK_RADIUS = 340px (stroke width compensated), exposes the same radius in dp for vector consumers, holds a LABELS registry whose names meet kuhy's launcher-name rules, and registers the sufler (prompt-bubble) and macro_cam (camera) glyphs.", + "acceptance_criteria": [ + "For every registered app, fit.placement yields a render whose ink box is centred and whose furthest inked pixel is INK_RADIUS from the centre within tolerance (test_fit.py)", + "Every LABELS entry starts with a capital, holds only ASCII letters, is <= 10 chars and <= 522 wide in the Pixel launcher font (test_labels.py, which also requires every registered app to have a unique label)", + "centre_offset, safe_box_overflow and the CLI safe-box warning are gone and nothing references them", + "pytest over python_pkg/app_icons reports 100% line and branch coverage for every module" + ], + "out_of_scope": [ + "Regenerated icon assets inside the app repos", + "repo_contract's android:label gate in kuhyx/utils" + ], + "verifier": "python3 -m pytest -q -p no:cacheprovider python_pkg/app_icons --cov=python_pkg.app_icons --cov-branch; scripts/test_changed.sh; pre-commit run --files ; python3 -m python_pkg.app_icons preview" +} diff --git a/docs/superpowers/evidence/app-icons-launcher-unification-2026-10-23.json b/docs/superpowers/evidence/app-icons-launcher-unification-2026-10-23.json new file mode 100644 index 00000000..4a4c0072 --- /dev/null +++ b/docs/superpowers/evidence/app-icons-launcher-unification-2026-10-23.json @@ -0,0 +1,45 @@ +{ + "intent": "Every launcher icon in the shared family occupies the same circle (glyph auto-centred, furthest ink at INK_RADIUS = 340px, stroke width compensated) and every registered app carries a launcher name that the Pixel launcher never truncates.", + "scope": [ + "python_pkg/app_icons only: fit.py, labels.py, _label_widths.py (new); render.py, style.py, cli.py, generate.py, preview.py, _app_icon.py, _apps_more.py, glyphs.py, _glyph_art_extra.py, README.md; their tests", + "Non-goal: regenerating assets inside the app repos (each app repo commits its own icons); repo_contract label gate in kuhyx/utils is committed separately" + ], + "changes": [ + "fit.py: render, measure ink, derive a Placement that centres the ink box and scales it so the furthest inked pixel sits at style.INK_RADIUS, iterating because stroke width does not scale", + "style.py: INK_RADIUS = 340px plus ADAPTIVE_CANVAS_DP / INK_RADIUS_DP for vector-drawable consumers (phone-focus-mode)", + "labels.py + _label_widths.py: LABELS registry and rules (capital first letter, ASCII letters only, <= 10 chars, <= 522 wide in the Pixel launcher font); AppIcon.label reads it", + "render.py/cli.py: centre_offset, safe_box_overflow and the CLI safe-box warning removed (superseded by the fit)", + "glyphs/_glyph_art_extra/_apps_more: prompt-bubble (sufler) and camera (macro_cam) glyphs and registry entries", + "_app_icon.py: labels import moved to module top level (no cycle: labels imports only _label_widths) to satisfy PLC0415/C0415" + ], + "verification": [ + { + "command": "python3 -m pytest -q -p no:cacheprovider python_pkg/app_icons --cov=python_pkg.app_icons --cov-branch --cov-report=term-missing --cov-fail-under=0", + "result": "pass", + "evidence": "126 passed in 7.24s; every python_pkg/app_icons/*.py file at 100% line+branch (fit.py 37 stmts/8 branches, labels.py 21/10, cli.py 66/20, render.py 36/2)" + }, + { + "command": "scripts/test_changed.sh", + "result": "pass", + "evidence": "198 passed in 6.07s" + }, + { + "command": "pre-commit run --files ", + "result": "pass", + "evidence": "first run: no-noqa, ruff PLC0415 and pylint C0415 failed on the lazy import in _app_icon.py; after moving it top-level, 38 hooks Passed, 0 failed" + }, + { + "command": "python3 -m python_pkg.app_icons list && python3 -m python_pkg.app_icons preview -o /dev/shm/appicons_prev/sheet.png", + "result": "pass", + "evidence": "list shows sufler -> prompt-bubble +linux and macro_cam -> camera; preview wrote sheet.png with all 25 glyphs at visually equal size on the charcoal field" + } + ], + "risks": [ + "Glyph sizes change for every app, so each app repo shows an icon diff the next time it regenerates", + "Label width table is measured for the Pixel launcher font; other launchers may truncate differently" + ], + "rollback": [ + "git revert this commit", + "Rerun the app_icons pytest command above and the preview to confirm the old sizes return" + ] +} diff --git a/python_pkg/app_icons/README.md b/python_pkg/app_icons/README.md index 910faf69..1081a6f6 100644 --- a/python_pkg/app_icons/README.md +++ b/python_pkg/app_icons/README.md @@ -21,7 +21,8 @@ PYTHONPATH=~/src/testsAndMisc python3 -m python_pkg.app_icons \ generate --app todo --android --linux-out ~/src/todo/linux/icons # overwrite web/favicon.png + web/icons/Icon-*.png (apps with a web/ dir); -# the maskable ones are safe because SAFE_BOX fits the 40% maskable circle +# the maskable ones are safe because INK_RADIUS (340px) sits inside the 40% +# (410px) maskable circle PYTHONPATH=~/src/testsAndMisc python3 -m python_pkg.app_icons \ generate --app tutor --android --web ``` @@ -45,13 +46,18 @@ the density buckets and `mipmap-anydpi-v26/ic_launcher.xml`. - **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. +- **Every glyph fills the same circle.** `fit.placement` centres the ink and + scales it so the furthest inked pixel sits `style.INK_RADIUS` (340px, 24.4dp) + from the centre; the stroke width is divided by the scale so strokes stay + 72px. Check negative space at that final scale, since big drawings shrink. - **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. +- Glyph position and size inside the fragment are irrelevant. Only the shape + matters. +- **Launcher names** live in `labels.py`: a capital first letter, ASCII letters + only, ≤ 10 chars and ≤ 522 wide in the launcher font (`_label_widths.py`), + because the Pixel app drawer cuts wider names. ## Adding an app diff --git a/python_pkg/app_icons/_app_icon.py b/python_pkg/app_icons/_app_icon.py index 0393135f..8aa6d661 100644 --- a/python_pkg/app_icons/_app_icon.py +++ b/python_pkg/app_icons/_app_icon.py @@ -5,6 +5,8 @@ from __future__ import annotations from dataclasses import dataclass from typing import TYPE_CHECKING +from python_pkg.app_icons.labels import LABELS + if TYPE_CHECKING: from pathlib import Path @@ -39,3 +41,8 @@ class AppIcon: def web_dir(self) -> Path: """Return the Flutter ``web/`` directory holding favicon and PWA icons.""" return self.repo / "web" + + @property + def label(self) -> str: + """Return the launcher name from :data:`~python_pkg.app_icons.labels.LABELS`.""" + return LABELS[self.key] diff --git a/python_pkg/app_icons/_apps_more.py b/python_pkg/app_icons/_apps_more.py index ebc5746f..d06fbb48 100644 --- a/python_pkg/app_icons/_apps_more.py +++ b/python_pkg/app_icons/_apps_more.py @@ -33,4 +33,23 @@ MORE_APPS: Final[tuple[AppIcon, ...]] = ( # window (see the repo's SPEC.md), so it gets the hicolor set too. linux=True, ), + AppIcon( + key="sufler", + repo=_HOME / "src/sufler", + accent="#B8862E", + glyph="prompt-bubble", + icon_name="sufler", + # packaging/sufler.desktop (Icon=sufler) installs the hicolor set + # from packaging/icons via the PKGBUILD. + linux=True, + ), + AppIcon( + key="macro_cam", + repo=_HOME / "src/macro-cam", + accent="#B8862E", + glyph="camera", + icon_name="macro-cam", + # Android only: no linux/ target and no desktop entry. + linux=False, + ), ) diff --git a/python_pkg/app_icons/_glyph_art_extra.py b/python_pkg/app_icons/_glyph_art_extra.py index bf2d51d5..c6a5a28a 100644 --- a/python_pkg/app_icons/_glyph_art_extra.py +++ b/python_pkg/app_icons/_glyph_art_extra.py @@ -41,3 +41,42 @@ _HEADPHONES = """\ fill="{{ACCENT}}" stroke="none"/> """ + +# A speech bubble with two text lines -- sufler, a prompter that reads the +# partner's lines aloud: the prompt being spoken. Ported verbatim from +# ~/src/sufler/assets/icon/icon_foreground.svg (commit 5d38160), drawn before +# the glyph was registered here; only its translate(0,-4) is dropped, since the +# renderer recentres every fragment anyway. +# +# Geometry, ink inside SAFE_BOX (x=260..764 = 504 wide, y=248..784 = 536 tall): +# bubble rounded rect x=296..728, y=284..636, corner r=64, with a tail from +# (480,636) down to a point at (360,748) and back up at x=384. +# lines x=408..616 at y=398 and a shorter x=408..520 at y=522. Gaps +# bubble->line1, line1->line2 and line2->bubble are 42 / 52 / 42px, +# clear of MIN_NEGATIVE_SPACE=36; the fit scales this glyph up +# (authored ink radius 326 < 340), which only widens them. +_PROMPT_BUBBLE = """\ + + + """ + +# A camera -- macro-cam, which estimates a meal's macros from a photo: the +# photo being taken. Stroked body with a viewfinder hump over a stroked lens. +# The food itself is already taken (``shield-cutlery``, ``bowl-star``), so the +# camera carries the meaning. +# +# Geometry, ink x=220..804 (584 wide), y=236..788 (552 tall): +# body rounded rect x=256..768, y=336..752, corner r=64, with a hump +# rising to y=272 between x=416..528. The corner arcs set the ink +# radius (~360), so the fit shrinks the glyph to ~0.94. +# lens circle r=88 about (512,544). Centreline gaps lens->body are 120px +# above and below, so after the ~0.94 fit the ink gap is ~41px, and +# the lens hole is ~94px across: both clear of MIN_NEGATIVE_SPACE=36 +# at the FINAL scale (tests/test_registry.py measures it). +_CAMERA = """\ + + """ diff --git a/python_pkg/app_icons/_label_widths.py b/python_pkg/app_icons/_label_widths.py new file mode 100644 index 00000000..730bf35c --- /dev/null +++ b/python_pkg/app_icons/_label_widths.py @@ -0,0 +1,30 @@ +"""Advance widths of the ASCII letters in the Pixel launcher's label font. + +Measured from ``GoogleSansFlex-Regular.ttf`` pulled off kuhy's Pixel 6a +(``/product/fonts``) at a 100px em, rounded. Only the numbers are kept, not +the font, which is not redistributable. Kerning is ignored; on every label +measured it moved the total by under 1%. +""" + +from __future__ import annotations + +from typing import Final + +ADVANCE: Final[dict[str, int]] = { + "a": 54, "b": 60, "c": 54, "d": 60, "e": 56, "f": 38, "g": 60, "h": 56, + "i": 23, "j": 23, "k": 50, "l": 21, "m": 88, "n": 56, "o": 60, "p": 60, + "q": 60, "r": 38, "s": 47, "t": 36, "u": 56, "v": 51, "w": 78, "x": 49, + "y": 51, "z": 48, "A": 67, "B": 61, "C": 74, "D": 70, "E": 55, "F": 53, + "G": 79, "H": 70, "I": 24, "J": 53, "K": 63, "L": 51, "M": 87, "N": 71, + "O": 82, "P": 58, "Q": 82, "R": 59, "S": 55, "T": 54, "U": 65, "V": 63, + "W": 94, "X": 64, "Y": 60, "Z": 57, +} # fmt: skip + +# The widest label seen whole in the app drawer is WakeAlarm (522), and +# FocusOwner (580) lost its last letters there (kuhy, 2026-10-23). +MAX_WIDTH: Final[int] = 522 + + +def label_width(name: str) -> int: + """Return a label's width in font units; only call on ASCII letters.""" + return sum(ADVANCE[char] for char in name) diff --git a/python_pkg/app_icons/cli.py b/python_pkg/app_icons/cli.py index 076e1f9a..b67330ef 100644 --- a/python_pkg/app_icons/cli.py +++ b/python_pkg/app_icons/cli.py @@ -12,7 +12,7 @@ import argparse from pathlib import Path import sys -from python_pkg.app_icons import apps, generate, glyphs, preview, render, style +from python_pkg.app_icons import apps, generate, glyphs, preview def _emit(message: str) -> None: @@ -173,13 +173,6 @@ def _cmd_generate( 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}") diff --git a/python_pkg/app_icons/fit.py b/python_pkg/app_icons/fit.py new file mode 100644 index 00000000..051d02c8 --- /dev/null +++ b/python_pkg/app_icons/fit.py @@ -0,0 +1,95 @@ +"""Fit every glyph to the same circle: centred, with ink radius ``INK_RADIUS``. + +Hand-authored glyphs span anything from 250 to 400px, so a row of icons looked +like different sizes (kuhy, 2026-10-23). Each glyph is rendered, its ink is +measured, and a :class:`~python_pkg.app_icons.render.Placement` is derived that +centres the ink box and scales it so the furthest inked pixel sits exactly +``style.INK_RADIUS`` from the centre. + +The stroke weight does not scale with the glyph (see :func:`render.build_svg`), +so the radius is not linear in the scale; a few measure-and-correct rounds +converge well inside the tolerance. +""" + +from __future__ import annotations + +from pathlib import Path +import tempfile +from typing import TYPE_CHECKING, Final + +import numpy as np +from PIL import Image + +from python_pkg.app_icons import render, style + +if TYPE_CHECKING: + from python_pkg.app_icons.glyphs import Glyph + +# Alpha above this counts as ink; the anti-aliased fringe below it does not. +_INK_ALPHA: Final[int] = 127 +_MAX_ROUNDS: Final[int] = 8 +_TOLERANCE: Final[float] = 0.002 + + +def ink_geometry(png: Path) -> tuple[float, float, float]: + """Measure a rendered glyph. + + Parameters: + png (Path): Transparent render of the glyph alone. + + Returns: + tuple[float, float, float]: ``(radius, cx, cy)`` -- the ink box centre and + the distance from it to the furthest inked pixel. All zero for no ink. + """ + with Image.open(png) as image: + alpha = np.asarray(image.convert("RGBA"))[:, :, 3] + ys, xs = np.nonzero(alpha > _INK_ALPHA) + if xs.size == 0: + return 0.0, 0.0, 0.0 + cx = (int(xs.min()) + int(xs.max()) + 1) / 2 + cy = (int(ys.min()) + int(ys.max()) + 1) / 2 + radius = float(np.hypot(xs + 0.5 - cx, ys + 0.5 - cy).max()) + return radius, cx, cy + + +def _measure( + glyph: Glyph, accent: str, where: render.Placement +) -> tuple[float, float, float]: + """Render ``glyph`` at ``where`` and measure its ink.""" + svg = render.build_svg(glyph, accent, with_background=False, offset=where) + with tempfile.TemporaryDirectory() as tmp: + png = Path(tmp) / "probe.png" + render.rasterise(svg, png, style.CANVAS) + return ink_geometry(png) + + +def placement(glyph: Glyph, accent: str) -> render.Placement: + """Find the placement that centres a glyph on the family ink circle. + + Parameters: + glyph (Glyph): Artwork to fit. + accent (str): Hex colour to render with; it does not affect the result, + but rsvg-convert needs a concrete value. + + Returns: + render.Placement: Transform to pass to :func:`render.build_svg`. + + Raises: + render.EmptyGlyphError: If the glyph renders no visible ink. + """ + where = render.IDENTITY + for _ in range(_MAX_ROUNDS): + radius, cx, cy = _measure(glyph, accent, where) + if radius == 0: + raise render.EmptyGlyphError(glyph.name) + factor = style.INK_RADIUS / radius + # Move the ink centre onto the canvas centre, then scale about it: + # p -> C + factor * (scale*p + d - c). + where = render.Placement( + dx=style.CENTRE + factor * (where.dx - cx), + dy=style.CENTRE + factor * (where.dy - cy), + scale=where.scale * factor, + ) + if abs(factor - 1) <= _TOLERANCE: + break + return where diff --git a/python_pkg/app_icons/generate.py b/python_pkg/app_icons/generate.py index 1bfc6858..bd5e7118 100644 --- a/python_pkg/app_icons/generate.py +++ b/python_pkg/app_icons/generate.py @@ -21,7 +21,7 @@ import re import subprocess from typing import TYPE_CHECKING, Final -from python_pkg.app_icons import glyphs, render, style +from python_pkg.app_icons import fit, glyphs, render, style # Matches a `flutter_launcher_icons:` block's `adaptive_icon_background:` line # so it can be kept in sync with style.BACKGROUND without a full YAML @@ -65,7 +65,7 @@ def write_assets(app: AppIcon) -> list[Path]: list[Path]: Every file written, in creation order. """ glyph = glyphs.get_glyph(app.glyph) - offset = render.centre_offset(glyph, app.accent) + offset = fit.placement(glyph, app.accent) written: list[Path] = [] for stem, with_background, colour in _LAYERS: svg_text = render.build_svg( @@ -94,7 +94,7 @@ def write_linux_icons(app: AppIcon, destination: Path) -> list[Path]: list[Path]: Every PNG written. """ glyph = glyphs.get_glyph(app.glyph) - offset = render.centre_offset(glyph, app.accent) + offset = fit.placement(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: @@ -108,7 +108,7 @@ def write_web_icons(app: AppIcon) -> list[Path]: """Overwrite the Flutter web template's favicon and PWA manifest icons. The maskable variants are the same full-bleed render as the plain ones: - every glyph is kept inside ``style.SAFE_BOX``, whose corners sit inside + every glyph is fitted to ``style.INK_RADIUS`` (340px of 1024), inside the maskable safe zone (the inner circle of radius 40% of the icon), so a circle mask never clips the glyph. @@ -119,7 +119,7 @@ def write_web_icons(app: AppIcon) -> list[Path]: list[Path]: Every PNG written. """ glyph = glyphs.get_glyph(app.glyph) - offset = render.centre_offset(glyph, app.accent) + offset = fit.placement(glyph, app.accent) svg_text = render.build_svg(glyph, app.accent, with_background=True, offset=offset) written: list[Path] = [] for relative, size in _WEB_ICONS: diff --git a/python_pkg/app_icons/glyphs.py b/python_pkg/app_icons/glyphs.py index 4c00634e..9df97513 100644 --- a/python_pkg/app_icons/glyphs.py +++ b/python_pkg/app_icons/glyphs.py @@ -29,7 +29,12 @@ from python_pkg.app_icons._glyph_art import ( _SHIELD_CUTLERY, _STORAGE_BOX, ) -from python_pkg.app_icons._glyph_art_extra import _HEADPHONES, _LIGHTBULB +from python_pkg.app_icons._glyph_art_extra import ( + _CAMERA, + _HEADPHONES, + _LIGHTBULB, + _PROMPT_BUBBLE, +) from python_pkg.app_icons._glyph_art_more import ( _BOWL_STAR, _HOURGLASS, @@ -101,6 +106,12 @@ GLYPHS: Final[dict[str, Glyph]] = { ), Glyph("lightbulb", "Filled lightbulb over a two-bar screw base", _LIGHTBULB), Glyph("headphones", "Headband arc over two filled ear cups", _HEADPHONES), + Glyph( + "prompt-bubble", + "Speech bubble with a tail and two text lines", + _PROMPT_BUBBLE, + ), + Glyph("camera", "Camera body with a viewfinder hump and a lens", _CAMERA), ) } diff --git a/python_pkg/app_icons/labels.py b/python_pkg/app_icons/labels.py new file mode 100644 index 00000000..38e7bd43 --- /dev/null +++ b/python_pkg/app_icons/labels.py @@ -0,0 +1,84 @@ +"""Launcher names for every registered app, and the rules they must meet. + +kuhy's rules (2026-10-23): a name starts with a capital, holds only ASCII +letters -- no spaces, underscores or punctuation -- and is short enough that +the Pixel launcher never truncates it with an ellipsis. ``signalbot_remote`` +showed as ``signalbot_r...`` while the 12-character ``Daily limits`` fitted. +A character cap alone was not enough: the 10-letter ``FocusOwner`` was still +cut in the app drawer, so the rendered width in the launcher font is capped +too (:mod:`_label_widths`). + +``repo_contract`` (kuhyx/utils) applies the same rules to every Flutter repo's +``android:label``, so an app that is not registered here is still held to them. +""" + +from __future__ import annotations + +import re +from typing import Final + +from python_pkg.app_icons._label_widths import MAX_WIDTH, label_width + +MAX_LENGTH: Final[int] = 10 + +_ASCII_LETTERS: Final[re.Pattern[str]] = re.compile(r"[A-Za-z]+") + +LABELS: Final[dict[str, str]] = { + "dufs_client": "Cloud", + "focus_owner": "Focuser", + "workout_app": "Workout", + "wake_alarm_sync": "WakeAlarm", + "diet_guard_app": "DietGuard", + "todo": "Todo", + "untools": "Untools", + "punchme": "PunchMe", + "restaurant_rater": "Rater", + "habit_stack": "HabitStack", + "home_inventory": "Inventory", + "epopeja_karta": "Epopeja", + "octoforge": "OctoForge", + "kuhylog": "KuhyLog", + "lyricanki": "LyricAnki", + "betting_sim": "BettingSim", + "home_guard_app": "Homer", + "signalbot_remote": "SignalBot", + "umbrella": "Umbrella", + "book_guard_app": "BookGuard", + "daily_limits": "DailyLimit", + "tutor": "Tutor", + "tunedeck": "TuneDeck", + "sufler": "Sufler", + "macro_cam": "MacroCam", +} + + +def label_problems(name: str) -> list[str]: + """List every launcher-name rule a name breaks. + + Parameters: + name (str): Candidate launcher label. + + Returns: + list[str]: One message per broken rule; empty when the name is valid. + """ + if not name: + return ["label is empty"] + problems: list[str] = [] + letters_only = _ASCII_LETTERS.fullmatch(name) is not None + if not letters_only: + problems.append( + f"{name!r} must contain only ASCII letters (no spaces, _ or symbols)" + ) + if not name[0].isupper(): + problems.append(f"{name!r} must start with a capital letter") + # The width table only knows ASCII letters; other names already failed. + if letters_only and label_width(name) > MAX_WIDTH: + problems.append( + f"{name!r} is {label_width(name)} wide in the launcher font; " + f"at most {MAX_WIDTH} fit" + ) + if len(name) > MAX_LENGTH: + problems.append( + f"{name!r} has {len(name)} characters; at most {MAX_LENGTH} fit" + ) + return problems diff --git a/python_pkg/app_icons/preview.py b/python_pkg/app_icons/preview.py index aa3aa898..b2e84095 100644 --- a/python_pkg/app_icons/preview.py +++ b/python_pkg/app_icons/preview.py @@ -12,7 +12,7 @@ from pathlib import Path import subprocess import tempfile -from python_pkg.app_icons import apps, glyphs, render, style +from python_pkg.app_icons import apps, fit, glyphs, render, style # Size each icon is drawn at in the contact sheet. _TILE = 512 @@ -122,7 +122,7 @@ def build_contact_sheet(keys: list[str], destination: Path) -> Path: for key in keys: app = apps.get_app(key) glyph = glyphs.get_glyph(app.glyph) - offset = render.centre_offset(glyph, app.accent) + offset = fit.placement(glyph, app.accent) svg_text = render.build_svg( glyph, app.accent, with_background=True, offset=offset ) diff --git a/python_pkg/app_icons/render.py b/python_pkg/app_icons/render.py index 9d48025a..e2b82fd4 100644 --- a/python_pkg/app_icons/render.py +++ b/python_pkg/app_icons/render.py @@ -1,30 +1,52 @@ """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. +Glyphs are hand-authored, so their ink never lands on the canvas centre and +never spans the same circle. :mod:`python_pkg.app_icons.fit` measures each one +and returns a :class:`Placement`; :func:`build_svg` applies it. The fragment is +translated and scaled, but the group's stroke width is divided by the scale, +so every stroked icon keeps exactly the family stroke weight. A fragment with +its own inner ``scale(k)`` writes ``stroke-width="{{STROKE/k}}"`` and gets the +same compensation. Filled shapes have no stroke and simply scale. """ from __future__ import annotations -from pathlib import Path +from dataclasses import dataclass +import re import shutil import subprocess -import tempfile from typing import TYPE_CHECKING from python_pkg.app_icons import style if TYPE_CHECKING: + from pathlib import Path + from python_pkg.app_icons.glyphs import Glyph +# ``{{STROKE/k}}``: the family stroke weight inside a group already scaled by k. +_STROKE_MARKER = re.compile(r"\{\{STROKE/(\d+(?:\.\d+)?)\}\}") + + +@dataclass(frozen=True) +class Placement: + """Where a glyph fragment lands: ``translate(dx, dy) scale(scale)``. + + Parameters: + dx (float): Horizontal translation in canvas pixels. + dy (float): Vertical translation in canvas pixels. + scale (float): Uniform scale applied after the translation. + """ + + dx: float = 0.0 + dy: float = 0.0 + scale: float = 1.0 + + +IDENTITY = Placement() + + class ToolMissingError(RuntimeError): """Raised when a required external rasterisation tool is not installed.""" @@ -74,7 +96,7 @@ def build_svg( accent: str, *, with_background: bool, - offset: tuple[float, float] = (0.0, 0.0), + offset: Placement = IDENTITY, ) -> str: """Assemble a complete SVG document for one icon layer. @@ -83,14 +105,17 @@ def build_svg( 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`. + offset (Placement): Transform applied to the fragment, normally the + result of :func:`python_pkg.app_icons.fit.placement`. Returns: str: A standalone SVG document. """ body = glyph.body.replace(style.ACCENT_MARKER, accent) - dx, dy = offset + body = _STROKE_MARKER.sub( + lambda m: f"{style.STROKE_WIDTH / (offset.scale * float(m[1])):g}", body + ) + stroke = style.STROKE_WIDTH / offset.scale field = ( f'\n ' @@ -101,9 +126,10 @@ def build_svg( f'' f"{field}\n" - f' \n' + f'transform="translate({offset.dx:g},{offset.dy:g}) ' + f'scale({offset.scale:g})">\n' f"{body}\n" f" \n" f"\n" @@ -133,71 +159,3 @@ def rasterise(svg_text: str, destination: Path, size: int) -> None: 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) diff --git a/python_pkg/app_icons/style.py b/python_pkg/app_icons/style.py index b95a279b..ff61e4f9 100644 --- a/python_pkg/app_icons/style.py +++ b/python_pkg/app_icons/style.py @@ -19,11 +19,24 @@ CENTRE: Final[float] = CANVAS / 2 # cool-tinted #1B1D21, so relative lightness is unchanged. BACKGROUND: Final[str] = "#211D1B" -# 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. +# Authoring box for glyph fragments. The renderer rescales every glyph to +# INK_RADIUS anyway, so this only keeps hand-written coordinates comparable. SAFE_BOX: Final[int] = 560 +# Every glyph is scaled so its furthest inked pixel sits exactly this far from +# the centre, so all icons occupy the same circle (kuhy, 2026-10-23: glyphs had +# drifted from 250 to 394px, and focus_owner filled the whole mask). 340px is +# 24.4dp on the adaptive canvas (flutter_launcher_icons' default 16% inset maps +# 1024px onto 73.44dp), well inside Android's 33dp guaranteed-visible circle +# and the 40% (410px) maskable-web safe zone. +INK_RADIUS: Final[int] = 340 + +# The same circle in dp, for routes that emit Android vectors instead of PNGs +# (phone-focus-mode's focus_owner): flutter_launcher_icons insets the adaptive +# foreground 16% per side, so the 1024px canvas covers 108 * 0.68 = 73.44dp. +ADAPTIVE_CANVAS_DP: Final[float] = 108 * (1 - 2 * 0.16) +INK_RADIUS_DP: Final[float] = INK_RADIUS / CANVAS * ADAPTIVE_CANVAS_DP + # 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. diff --git a/python_pkg/app_icons/tests/test_cli.py b/python_pkg/app_icons/tests/test_cli.py index 2783f305..d1e8f13f 100644 --- a/python_pkg/app_icons/tests/test_cli.py +++ b/python_pkg/app_icons/tests/test_cli.py @@ -41,48 +41,31 @@ class TestPreviewCommand: 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: @@ -103,12 +86,10 @@ class TestGenerateCommand: class TestWebFlag: @patch(f"{MOD}.generate.write_web_icons", return_value=[Path("w")]) - @patch(f"{MOD}.render.safe_box_overflow", return_value=(0, 0)) @patch(f"{MOD}.generate.write_assets", return_value=[]) def test_writes_into_an_existing_web_dir( self, mock_write: MagicMock, - mock_overflow: MagicMock, web: MagicMock, capsys: pytest.CaptureFixture[str], ) -> None: @@ -118,12 +99,10 @@ class TestWebFlag: assert "wrote 1 web icons" in capsys.readouterr().out @patch(f"{MOD}.generate.write_web_icons") - @patch(f"{MOD}.render.safe_box_overflow", return_value=(0, 0)) @patch(f"{MOD}.generate.write_assets", return_value=[]) def test_skips_apps_without_a_web_dir( self, mock_write: MagicMock, - mock_overflow: MagicMock, web: MagicMock, capsys: pytest.CaptureFixture[str], ) -> None: diff --git a/python_pkg/app_icons/tests/test_fit.py b/python_pkg/app_icons/tests/test_fit.py new file mode 100644 index 00000000..b13f3780 --- /dev/null +++ b/python_pkg/app_icons/tests/test_fit.py @@ -0,0 +1,84 @@ +"""Tests for app_icons.fit: every glyph fills exactly the same circle.""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from python_pkg.app_icons import apps, fit, glyphs, render, style +from python_pkg.app_icons.glyphs import Glyph + +SMALL = Glyph("small", "a short bar", ' ') +NESTED = Glyph( + "nested", + "inner group with its own scale", + ' ' + '', +) + + +def _rendered_radius(glyph: Glyph, tmp_path: Path) -> tuple[float, float, float]: + placement = fit.placement(glyph, "#FFFFFF") + png = tmp_path / f"{glyph.name}.png" + svg = render.build_svg(glyph, "#FFFFFF", with_background=False, offset=placement) + render.rasterise(svg, png, style.CANVAS) + return fit.ink_geometry(png) + + +class TestPlacement: + @pytest.mark.parametrize("key", sorted(apps.APPS)) + def test_every_app_hits_the_family_radius(self, key: str, tmp_path: Path) -> None: + glyph = glyphs.get_glyph(apps.get_app(key).glyph) + radius, cx, cy = _rendered_radius(glyph, tmp_path) + assert radius == pytest.approx(style.INK_RADIUS, rel=0.01) + assert cx == pytest.approx(style.CENTRE, abs=2) + assert cy == pytest.approx(style.CENTRE, abs=2) + + def test_tiny_glyph_is_scaled_up(self, tmp_path: Path) -> None: + radius, _cx, _cy = _rendered_radius(SMALL, tmp_path) + assert radius == pytest.approx(style.INK_RADIUS, rel=0.01) + + def test_stroke_weight_is_not_scaled(self) -> None: + placement = fit.placement(SMALL, "#FFFFFF") + svg = render.build_svg( + SMALL, "#FFFFFF", with_background=False, offset=placement + ) + assert placement.scale > 1 + assert f'stroke-width="{style.STROKE_WIDTH / placement.scale:g}"' in svg + + def test_nested_scale_marker_is_compensated(self) -> None: + placement = render.Placement(0, 0, 2) + svg = render.build_svg( + NESTED, "#FFFFFF", with_background=False, offset=placement + ) + assert f'stroke-width="{style.STROKE_WIDTH / 20:g}"' in svg + assert "{{STROKE" not in svg + + +class TestInkGeometry: + def test_blank_image_is_zero(self, tmp_path: Path) -> None: + png = tmp_path / "blank.png" + render.rasterise( + render.build_svg(Glyph("none", "", ""), "#FFFFFF", with_background=False), + png, + 64, + ) + assert fit.ink_geometry(png) == (0.0, 0.0, 0.0) + + def test_empty_glyph_raises(self) -> None: + with pytest.raises(render.EmptyGlyphError) as excinfo: + fit.placement(Glyph("none", "", ""), "#FFFFFF") + assert excinfo.value.name == "none" + + def test_round_cap_returns_best_effort( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.setattr(fit, "_MAX_ROUNDS", 1) + assert fit.placement(SMALL, "#FFFFFF").scale > 1 + + +def test_ink_circle_is_inside_android_visible_zone() -> None: + # Android guarantees a 66dp circle (radius 33dp) survives every mask. + assert pytest.approx(24.39, abs=0.01) == style.INK_RADIUS_DP + assert style.INK_RADIUS_DP < 33 diff --git a/python_pkg/app_icons/tests/test_generate.py b/python_pkg/app_icons/tests/test_generate.py index 4d086949..3bd3d5f9 100644 --- a/python_pkg/app_icons/tests/test_generate.py +++ b/python_pkg/app_icons/tests/test_generate.py @@ -6,7 +6,7 @@ import math from typing import TYPE_CHECKING from unittest.mock import MagicMock, patch -from python_pkg.app_icons import generate, style +from python_pkg.app_icons import generate, render, style from python_pkg.app_icons.apps import AppIcon if TYPE_CHECKING: @@ -28,7 +28,7 @@ def _app(tmp_path: Path, *, linux: bool = False) -> AppIcon: class TestWriteAssets: @patch(f"{MOD}.render.rasterise") - @patch(f"{MOD}.render.centre_offset", return_value=(1.0, 2.0)) + @patch(f"{MOD}.fit.placement", return_value=render.Placement(1.0, 2.0)) def test_writes_three_svg_png_pairs( self, mock_offset: MagicMock, raster: MagicMock, tmp_path: Path ) -> None: @@ -39,7 +39,7 @@ class TestWriteAssets: assert raster.call_count == 3 @patch(f"{MOD}.render.rasterise") - @patch(f"{MOD}.render.centre_offset", return_value=(0.0, 0.0)) + @patch(f"{MOD}.fit.placement", return_value=render.IDENTITY) def test_only_the_base_layer_has_the_field( self, mock_offset: MagicMock, mock_raster: MagicMock, tmp_path: Path ) -> None: @@ -51,7 +51,7 @@ class TestWriteAssets: assert style.BACKGROUND not in foreground @patch(f"{MOD}.render.rasterise") - @patch(f"{MOD}.render.centre_offset", return_value=(0.0, 0.0)) + @patch(f"{MOD}.fit.placement", return_value=render.IDENTITY) def test_monochrome_layer_is_white( self, mock_offset: MagicMock, mock_raster: MagicMock, tmp_path: Path ) -> None: @@ -64,7 +64,7 @@ class TestWriteAssets: class TestWriteLinuxIcons: @patch(f"{MOD}.render.rasterise") - @patch(f"{MOD}.render.centre_offset", return_value=(0.0, 0.0)) + @patch(f"{MOD}.fit.placement", return_value=render.IDENTITY) def test_one_png_per_hicolor_size( self, mock_offset: MagicMock, raster: MagicMock, tmp_path: Path ) -> None: @@ -76,7 +76,7 @@ class TestWriteLinuxIcons: class TestWriteWebIcons: @patch(f"{MOD}.render.rasterise") - @patch(f"{MOD}.render.centre_offset", return_value=(0.0, 0.0)) + @patch(f"{MOD}.fit.placement", return_value=render.IDENTITY) def test_replaces_the_flutter_web_template_icons( self, mock_offset: MagicMock, raster: MagicMock, tmp_path: Path ) -> None: diff --git a/python_pkg/app_icons/tests/test_labels.py b/python_pkg/app_icons/tests/test_labels.py new file mode 100644 index 00000000..bc1011a0 --- /dev/null +++ b/python_pkg/app_icons/tests/test_labels.py @@ -0,0 +1,42 @@ +"""Tests for app_icons.labels: the launcher-name rules every app must meet.""" + +from __future__ import annotations + +import pytest + +from python_pkg.app_icons import apps, labels + + +class TestLabelProblems: + @pytest.mark.parametrize("name", ["Todo", "BookGuard", "WakeAlarm", "Focuser", "A"]) + def test_valid(self, name: str) -> None: + assert labels.label_problems(name) == [] + + @pytest.mark.parametrize( + ("name", "fragment"), + [ + ("", "empty"), + ("todo", "capital"), + ("Book Guard", "ASCII letters"), + ("diet_guard", "ASCII letters"), + ("Umbrella?", "ASCII letters"), + ("Żółw", "ASCII letters"), + ("SignalBotRemote", "characters"), + ("FocusOwner", "launcher font"), + ("WakeAlarmX", "launcher font"), + ], + ) + def test_invalid(self, name: str, fragment: str) -> None: + assert any(fragment in p for p in labels.label_problems(name)) + + +class TestRegistryLabels: + def test_every_app_has_a_label(self) -> None: + assert set(labels.LABELS) == set(apps.APPS) + + @pytest.mark.parametrize("key", sorted(apps.APPS)) + def test_label_is_valid(self, key: str) -> None: + assert labels.label_problems(apps.get_app(key).label) == [] + + def test_labels_are_unique(self) -> None: + assert len(set(labels.LABELS.values())) == len(labels.LABELS) diff --git a/python_pkg/app_icons/tests/test_preview.py b/python_pkg/app_icons/tests/test_preview.py index f5a822b3..a114288b 100644 --- a/python_pkg/app_icons/tests/test_preview.py +++ b/python_pkg/app_icons/tests/test_preview.py @@ -5,7 +5,7 @@ from __future__ import annotations from typing import TYPE_CHECKING from unittest.mock import MagicMock, patch -from python_pkg.app_icons import preview +from python_pkg.app_icons import preview, render if TYPE_CHECKING: from pathlib import Path @@ -16,7 +16,7 @@ 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}.fit.placement", return_value=render.IDENTITY) @patch(f"{MOD}.render.resolve_tool", return_value="/usr/bin/magick") def test_renders_one_tile_per_app_and_three_rows( self, @@ -37,7 +37,7 @@ 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}.fit.placement", return_value=render.IDENTITY) @patch(f"{MOD}.render.resolve_tool", return_value="/usr/bin/magick") def test_final_append_is_vertical( self, diff --git a/python_pkg/app_icons/tests/test_registry.py b/python_pkg/app_icons/tests/test_registry.py index 41f7c6e1..d7bb326c 100644 --- a/python_pkg/app_icons/tests/test_registry.py +++ b/python_pkg/app_icons/tests/test_registry.py @@ -2,9 +2,34 @@ from __future__ import annotations +from typing import TYPE_CHECKING + +import numpy as np +from PIL import Image import pytest -from python_pkg.app_icons import apps, glyphs, style +from python_pkg.app_icons import apps, fit, glyphs, render, style + +if TYPE_CHECKING: + from pathlib import Path + + +def _centre_column_gaps(glyph_name: str, tmp_path: Path) -> list[int]: + """Render a glyph at its FINAL fitted scale and list the transparent runs + between inked runs down the centre column, in pixels of the 1024 canvas. + """ + glyph = glyphs.get_glyph(glyph_name) + placement = fit.placement(glyph, "#FFFFFF") + png = tmp_path / f"{glyph_name}.png" + svg = render.build_svg(glyph, "#FFFFFF", with_background=False, offset=placement) + render.rasterise(svg, png, style.CANVAS) + with Image.open(png) as image: + alpha = np.asarray(image.convert("RGBA"))[:, int(style.CENTRE), 3] + ink = alpha > 127 + edges = np.flatnonzero(np.diff(ink.astype(np.int8))) + # edges alternate ink-end / ink-start once past the first ink run. + ends, starts = edges[1::2], edges[2::2] + return [int(start - end) for end, start in zip(ends, starts, strict=False)] class TestGlyphLookup: @@ -106,3 +131,31 @@ class TestHeadphonesGlyph: body = glyphs.get_glyph("headphones").body assert f'fill="{style.ACCENT_MARKER}"' in body assert 'stroke="none"' in body + + +class TestPromptBubbleGlyph: + def test_sufler_uses_it(self) -> None: + app = apps.get_app("sufler") + assert app.glyph == "prompt-bubble" + assert app.linux + + def test_negative_space_at_final_scale(self, tmp_path: Path) -> None: + """Bubble top, two text lines, bubble bottom: three gaps, none merging.""" + gaps = _centre_column_gaps("prompt-bubble", tmp_path) + assert len(gaps) == 3 + assert min(gaps) >= style.MIN_NEGATIVE_SPACE + + +class TestCameraGlyph: + def test_macro_cam_uses_it(self) -> None: + app = apps.get_app("macro_cam") + assert app.glyph == "camera" + assert not app.linux + + def test_negative_space_at_final_scale(self, tmp_path: Path) -> None: + """Hump->lens, lens hole, lens->body bottom, measured after the fit + shrinks the glyph: the authoring-space gaps alone would overstate them. + """ + gaps = _centre_column_gaps("camera", tmp_path) + assert len(gaps) == 3 + assert min(gaps) >= style.MIN_NEGATIVE_SPACE diff --git a/python_pkg/app_icons/tests/test_render.py b/python_pkg/app_icons/tests/test_render.py index 4c943d02..de118f17 100644 --- a/python_pkg/app_icons/tests/test_render.py +++ b/python_pkg/app_icons/tests/test_render.py @@ -3,7 +3,6 @@ from __future__ import annotations from pathlib import Path -import subprocess from unittest.mock import MagicMock, patch import pytest @@ -41,8 +40,11 @@ class TestBuildSvg: 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 + svg = render.build_svg( + DOT, "#123456", with_background=False, offset=render.Placement(3.5, -7, 2) + ) + assert 'transform="translate(3.5,-7) scale(2)"' in svg + assert f'stroke-width="{style.STROKE_WIDTH / 2:g}"' in svg def test_accent_token_is_substituted(self) -> None: svg = render.build_svg(FILLED, "#ABCDEF", with_background=False) @@ -63,47 +65,3 @@ class TestRasterise: assert argv[0] == "/usr/bin/rsvg-convert" assert "256" in argv assert run.call_args.kwargs["input"] == b"" - - -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)