mirror of
https://github.com/kuhyx/testsAndMisc.git
synced 2026-11-10 19:07:04 +01:00
app_icons: fit every glyph to one ink circle, launcher-name rules
Some checks failed
dependency freshness / dependencies on newest stable (push) Has been cancelled
md-naming / markdown naming convention (push) Has been cancelled
Pre-commit checks / pre-commit (${{ matrix.group }}) (mypy, mypy) (push) Has been cancelled
Pre-commit checks / pre-commit (${{ matrix.group }}) (pylint, pylint) (push) Has been cancelled
Pre-commit checks / pre-commit (${{ matrix.group }}) (rest, ) (push) Has been cancelled
Pre-commit checks / pre-commit (${{ matrix.group }}) (shellcheck, shellcheck) (push) Has been cancelled
Python tests / test (push) Has been cancelled
repo-contract / repo contract (push) Has been cancelled
Shell tests / shell-tests (push) Has been cancelled
Shell tests / lib-tests (push) Has been cancelled
Shell tests / arch-tests (push) Has been cancelled
Some checks failed
dependency freshness / dependencies on newest stable (push) Has been cancelled
md-naming / markdown naming convention (push) Has been cancelled
Pre-commit checks / pre-commit (${{ matrix.group }}) (mypy, mypy) (push) Has been cancelled
Pre-commit checks / pre-commit (${{ matrix.group }}) (pylint, pylint) (push) Has been cancelled
Pre-commit checks / pre-commit (${{ matrix.group }}) (rest, ) (push) Has been cancelled
Pre-commit checks / pre-commit (${{ matrix.group }}) (shellcheck, shellcheck) (push) Has been cancelled
Python tests / test (push) Has been cancelled
repo-contract / repo contract (push) Has been cancelled
Shell tests / shell-tests (push) Has been cancelled
Shell tests / lib-tests (push) Has been cancelled
Shell tests / arch-tests (push) Has been cancelled
- fit.py: centre each glyph and scale it so its furthest ink sits at INK_RADIUS = 340px, compensating the unscaled stroke width - style.py: INK_RADIUS plus INK_RADIUS_DP for vector-drawable consumers - labels.py/_label_widths.py: LABELS registry; names start with a capital, ASCII letters only, <= 10 chars, <= 522 wide in the Pixel launcher font - drop centre_offset, safe_box_overflow and the CLI safe-box warning - add prompt-bubble (sufler) and camera (macro_cam) glyphs and apps Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012kQ5PCKqBHAkAVkPSvp6dk
This commit is contained in:
parent
92aa2f971d
commit
35b497b409
@ -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 <changed files>; python3 -m python_pkg.app_icons preview"
|
||||||
|
}
|
||||||
@ -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 <the 20 app_icons 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"
|
||||||
|
]
|
||||||
|
}
|
||||||
@ -21,7 +21,8 @@ PYTHONPATH=~/src/testsAndMisc python3 -m python_pkg.app_icons \
|
|||||||
generate --app todo --android --linux-out ~/src/todo/linux/icons
|
generate --app todo --android --linux-out ~/src/todo/linux/icons
|
||||||
|
|
||||||
# overwrite web/favicon.png + web/icons/Icon-*.png (apps with a web/ dir);
|
# 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 \
|
PYTHONPATH=~/src/testsAndMisc python3 -m python_pkg.app_icons \
|
||||||
generate --app tutor --android --web
|
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
|
- **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
|
into a solid blob at launcher size. A fork whose tines sat exactly one stroke
|
||||||
width apart rendered as a tulip.
|
width apart rendered as a tulip.
|
||||||
- **Ink must stay inside the 560px safe box.** Android masks adaptive icons to
|
- **Every glyph fills the same circle.** `fit.placement` centres the ink and
|
||||||
roughly the inner 66% of the canvas. `generate` warns when a glyph overflows.
|
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
|
- **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
|
trident; a filled fork-and-knife pair reads as cutlery. Prefer a filled
|
||||||
silhouette when the outline is ambiguous.
|
silhouette when the outline is ambiguous.
|
||||||
- Glyph positioning inside the fragment is irrelevant — `render.centre_offset`
|
- Glyph position and size inside the fragment are irrelevant. Only the shape
|
||||||
measures the rendered ink and recentres it. Only the extents matter.
|
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
|
## Adding an app
|
||||||
|
|
||||||
|
|||||||
@ -5,6 +5,8 @@ from __future__ import annotations
|
|||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from python_pkg.app_icons.labels import LABELS
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
@ -39,3 +41,8 @@ class AppIcon:
|
|||||||
def web_dir(self) -> Path:
|
def web_dir(self) -> Path:
|
||||||
"""Return the Flutter ``web/`` directory holding favicon and PWA icons."""
|
"""Return the Flutter ``web/`` directory holding favicon and PWA icons."""
|
||||||
return self.repo / "web"
|
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]
|
||||||
|
|||||||
@ -33,4 +33,23 @@ MORE_APPS: Final[tuple[AppIcon, ...]] = (
|
|||||||
# window (see the repo's SPEC.md), so it gets the hicolor set too.
|
# window (see the repo's SPEC.md), so it gets the hicolor set too.
|
||||||
linux=True,
|
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,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|||||||
@ -41,3 +41,42 @@ _HEADPHONES = """\
|
|||||||
fill="{{ACCENT}}" stroke="none"/>
|
fill="{{ACCENT}}" stroke="none"/>
|
||||||
<rect x="656" y="520" width="112" height="216" rx="40" \
|
<rect x="656" y="520" width="112" height="216" rx="40" \
|
||||||
fill="{{ACCENT}}" stroke="none"/>"""
|
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 = """\
|
||||||
|
<path d="M 360 284 L 664 284 A 64 64 0 0 1 728 348 L 728 572 \
|
||||||
|
A 64 64 0 0 1 664 636 L 480 636 L 360 748 L 384 636 L 360 636 \
|
||||||
|
A 64 64 0 0 1 296 572 L 296 348 A 64 64 0 0 1 360 284 Z"/>
|
||||||
|
<path d="M 408 398 L 616 398"/>
|
||||||
|
<path d="M 408 522 L 520 522"/>"""
|
||||||
|
|
||||||
|
# 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 = """\
|
||||||
|
<path d="M 320 336 L 384 336 L 416 272 L 528 272 L 560 336 L 704 336 \
|
||||||
|
A 64 64 0 0 1 768 400 L 768 688 A 64 64 0 0 1 704 752 L 320 752 \
|
||||||
|
A 64 64 0 0 1 256 688 L 256 400 A 64 64 0 0 1 320 336 Z"/>
|
||||||
|
<circle cx="512" cy="544" r="88"/>"""
|
||||||
|
|||||||
30
python_pkg/app_icons/_label_widths.py
Normal file
30
python_pkg/app_icons/_label_widths.py
Normal file
@ -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)
|
||||||
@ -12,7 +12,7 @@ import argparse
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import sys
|
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:
|
def _emit(message: str) -> None:
|
||||||
@ -173,13 +173,6 @@ def _cmd_generate(
|
|||||||
written = generate.write_assets(app)
|
written = generate.write_assets(app)
|
||||||
_emit(f"{app.key}: wrote {len(written)} asset files to {app.asset_dir}")
|
_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:
|
if linux_out is not None and app.linux:
|
||||||
icons = generate.write_linux_icons(app, linux_out)
|
icons = generate.write_linux_icons(app, linux_out)
|
||||||
_emit(f" wrote {len(icons)} hicolor PNGs to {linux_out}")
|
_emit(f" wrote {len(icons)} hicolor PNGs to {linux_out}")
|
||||||
|
|||||||
95
python_pkg/app_icons/fit.py
Normal file
95
python_pkg/app_icons/fit.py
Normal file
@ -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
|
||||||
@ -21,7 +21,7 @@ import re
|
|||||||
import subprocess
|
import subprocess
|
||||||
from typing import TYPE_CHECKING, Final
|
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
|
# 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
|
# 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.
|
list[Path]: Every file written, in creation order.
|
||||||
"""
|
"""
|
||||||
glyph = glyphs.get_glyph(app.glyph)
|
glyph = glyphs.get_glyph(app.glyph)
|
||||||
offset = render.centre_offset(glyph, app.accent)
|
offset = fit.placement(glyph, app.accent)
|
||||||
written: list[Path] = []
|
written: list[Path] = []
|
||||||
for stem, with_background, colour in _LAYERS:
|
for stem, with_background, colour in _LAYERS:
|
||||||
svg_text = render.build_svg(
|
svg_text = render.build_svg(
|
||||||
@ -94,7 +94,7 @@ def write_linux_icons(app: AppIcon, destination: Path) -> list[Path]:
|
|||||||
list[Path]: Every PNG written.
|
list[Path]: Every PNG written.
|
||||||
"""
|
"""
|
||||||
glyph = glyphs.get_glyph(app.glyph)
|
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)
|
svg_text = render.build_svg(glyph, app.accent, with_background=True, offset=offset)
|
||||||
written: list[Path] = []
|
written: list[Path] = []
|
||||||
for size in style.LINUX_ICON_SIZES:
|
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.
|
"""Overwrite the Flutter web template's favicon and PWA manifest icons.
|
||||||
|
|
||||||
The maskable variants are the same full-bleed render as the plain ones:
|
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
|
the maskable safe zone (the inner circle of radius 40% of the icon), so a
|
||||||
circle mask never clips the glyph.
|
circle mask never clips the glyph.
|
||||||
|
|
||||||
@ -119,7 +119,7 @@ def write_web_icons(app: AppIcon) -> list[Path]:
|
|||||||
list[Path]: Every PNG written.
|
list[Path]: Every PNG written.
|
||||||
"""
|
"""
|
||||||
glyph = glyphs.get_glyph(app.glyph)
|
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)
|
svg_text = render.build_svg(glyph, app.accent, with_background=True, offset=offset)
|
||||||
written: list[Path] = []
|
written: list[Path] = []
|
||||||
for relative, size in _WEB_ICONS:
|
for relative, size in _WEB_ICONS:
|
||||||
|
|||||||
@ -29,7 +29,12 @@ from python_pkg.app_icons._glyph_art import (
|
|||||||
_SHIELD_CUTLERY,
|
_SHIELD_CUTLERY,
|
||||||
_STORAGE_BOX,
|
_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 (
|
from python_pkg.app_icons._glyph_art_more import (
|
||||||
_BOWL_STAR,
|
_BOWL_STAR,
|
||||||
_HOURGLASS,
|
_HOURGLASS,
|
||||||
@ -101,6 +106,12 @@ GLYPHS: Final[dict[str, Glyph]] = {
|
|||||||
),
|
),
|
||||||
Glyph("lightbulb", "Filled lightbulb over a two-bar screw base", _LIGHTBULB),
|
Glyph("lightbulb", "Filled lightbulb over a two-bar screw base", _LIGHTBULB),
|
||||||
Glyph("headphones", "Headband arc over two filled ear cups", _HEADPHONES),
|
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),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
84
python_pkg/app_icons/labels.py
Normal file
84
python_pkg/app_icons/labels.py
Normal file
@ -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
|
||||||
@ -12,7 +12,7 @@ from pathlib import Path
|
|||||||
import subprocess
|
import subprocess
|
||||||
import tempfile
|
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.
|
# Size each icon is drawn at in the contact sheet.
|
||||||
_TILE = 512
|
_TILE = 512
|
||||||
@ -122,7 +122,7 @@ def build_contact_sheet(keys: list[str], destination: Path) -> Path:
|
|||||||
for key in keys:
|
for key in keys:
|
||||||
app = apps.get_app(key)
|
app = apps.get_app(key)
|
||||||
glyph = glyphs.get_glyph(app.glyph)
|
glyph = glyphs.get_glyph(app.glyph)
|
||||||
offset = render.centre_offset(glyph, app.accent)
|
offset = fit.placement(glyph, app.accent)
|
||||||
svg_text = render.build_svg(
|
svg_text = render.build_svg(
|
||||||
glyph, app.accent, with_background=True, offset=offset
|
glyph, app.accent, with_background=True, offset=offset
|
||||||
)
|
)
|
||||||
|
|||||||
@ -1,30 +1,52 @@
|
|||||||
"""SVG assembly and rasterisation for the shared icon family.
|
"""SVG assembly and rasterisation for the shared icon family.
|
||||||
|
|
||||||
The interesting part here is :func:`centre_offset`. Glyphs are hand-authored, so
|
Glyphs are hand-authored, so their ink never lands on the canvas centre and
|
||||||
their ink never lands exactly on the canvas centre; a barbell is wide and short,
|
never spans the same circle. :mod:`python_pkg.app_icons.fit` measures each one
|
||||||
a cloud sits high. Rather than nudging coordinates by hand, the glyph is
|
and returns a :class:`Placement`; :func:`build_svg` applies it. The fragment is
|
||||||
rendered once on its own, its ink bounding box is measured, and the whole
|
translated and scaled, but the group's stroke width is divided by the scale,
|
||||||
fragment is translated so that box is centred. That is what makes a row of
|
so every stroked icon keeps exactly the family stroke weight. A fragment with
|
||||||
icons line up at the same optical height.
|
its own inner ``scale(k)`` writes ``stroke-width="{{STROKE/k}}"`` and gets the
|
||||||
|
same compensation. Filled shapes have no stroke and simply scale.
|
||||||
Only the fragment is translated -- never scaled -- so every icon keeps exactly
|
|
||||||
the family stroke weight.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from pathlib import Path
|
from dataclasses import dataclass
|
||||||
|
import re
|
||||||
import shutil
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
import tempfile
|
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
from python_pkg.app_icons import style
|
from python_pkg.app_icons import style
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
from python_pkg.app_icons.glyphs import Glyph
|
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):
|
class ToolMissingError(RuntimeError):
|
||||||
"""Raised when a required external rasterisation tool is not installed."""
|
"""Raised when a required external rasterisation tool is not installed."""
|
||||||
|
|
||||||
@ -74,7 +96,7 @@ def build_svg(
|
|||||||
accent: str,
|
accent: str,
|
||||||
*,
|
*,
|
||||||
with_background: bool,
|
with_background: bool,
|
||||||
offset: tuple[float, float] = (0.0, 0.0),
|
offset: Placement = IDENTITY,
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Assemble a complete SVG document for one icon layer.
|
"""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"``.
|
accent (str): Hex colour for the glyph, e.g. ``"#26A69A"``.
|
||||||
with_background (bool): Draw the shared charcoal field behind the glyph.
|
with_background (bool): Draw the shared charcoal field behind the glyph.
|
||||||
False produces the transparent adaptive-icon layers.
|
False produces the transparent adaptive-icon layers.
|
||||||
offset (tuple[float, float]): ``(dx, dy)`` translation applied to the
|
offset (Placement): Transform applied to the fragment, normally the
|
||||||
fragment, normally the result of :func:`centre_offset`.
|
result of :func:`python_pkg.app_icons.fit.placement`.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
str: A standalone SVG document.
|
str: A standalone SVG document.
|
||||||
"""
|
"""
|
||||||
body = glyph.body.replace(style.ACCENT_MARKER, accent)
|
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 = (
|
field = (
|
||||||
f'\n <rect width="{style.CANVAS}" height="{style.CANVAS}" '
|
f'\n <rect width="{style.CANVAS}" height="{style.CANVAS}" '
|
||||||
f'fill="{style.BACKGROUND}"/>'
|
f'fill="{style.BACKGROUND}"/>'
|
||||||
@ -101,9 +126,10 @@ def build_svg(
|
|||||||
f'<svg xmlns="http://www.w3.org/2000/svg" width="{style.CANVAS}" '
|
f'<svg xmlns="http://www.w3.org/2000/svg" width="{style.CANVAS}" '
|
||||||
f'height="{style.CANVAS}" viewBox="0 0 {style.CANVAS} {style.CANVAS}">'
|
f'height="{style.CANVAS}" viewBox="0 0 {style.CANVAS} {style.CANVAS}">'
|
||||||
f"{field}\n"
|
f"{field}\n"
|
||||||
f' <g fill="none" stroke="{accent}" stroke-width="{style.STROKE_WIDTH}" '
|
f' <g fill="none" stroke="{accent}" stroke-width="{stroke:g}" '
|
||||||
f'stroke-linecap="round" stroke-linejoin="round" '
|
f'stroke-linecap="round" stroke-linejoin="round" '
|
||||||
f'transform="translate({dx:g},{dy:g})">\n'
|
f'transform="translate({offset.dx:g},{offset.dy:g}) '
|
||||||
|
f'scale({offset.scale:g})">\n'
|
||||||
f"{body}\n"
|
f"{body}\n"
|
||||||
f" </g>\n"
|
f" </g>\n"
|
||||||
f"</svg>\n"
|
f"</svg>\n"
|
||||||
@ -133,71 +159,3 @@ def rasterise(svg_text: str, destination: Path, size: int) -> None:
|
|||||||
check=True,
|
check=True,
|
||||||
capture_output=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)
|
|
||||||
|
|||||||
@ -19,11 +19,24 @@ CENTRE: Final[float] = CANVAS / 2
|
|||||||
# cool-tinted #1B1D21, so relative lightness is unchanged.
|
# cool-tinted #1B1D21, so relative lightness is unchanged.
|
||||||
BACKGROUND: Final[str] = "#211D1B"
|
BACKGROUND: Final[str] = "#211D1B"
|
||||||
|
|
||||||
# Android masks an adaptive icon down to roughly the inner 66% of the canvas.
|
# Authoring box for glyph fragments. The renderer rescales every glyph to
|
||||||
# Keeping every glyph inside this box means no launcher mask (circle, squircle,
|
# INK_RADIUS anyway, so this only keeps hand-written coordinates comparable.
|
||||||
# teardrop, ...) can clip it.
|
|
||||||
SAFE_BOX: Final[int] = 560
|
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
|
# 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
|
# or above STROKE_WIDTH / 2, otherwise adjacent strokes merge into a blob when
|
||||||
# the icon is scaled down to launcher size.
|
# the icon is scaled down to launcher size.
|
||||||
|
|||||||
@ -41,48 +41,31 @@ class TestPreviewCommand:
|
|||||||
|
|
||||||
|
|
||||||
class TestGenerateCommand:
|
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")])
|
@patch(f"{MOD}.generate.write_assets", return_value=[Path("a"), Path("b")])
|
||||||
def test_writes_assets_only_by_default(
|
def test_writes_assets_only_by_default(
|
||||||
self,
|
self,
|
||||||
write: MagicMock,
|
write: MagicMock,
|
||||||
mock_overflow: MagicMock,
|
|
||||||
capsys: pytest.CaptureFixture[str],
|
capsys: pytest.CaptureFixture[str],
|
||||||
) -> None:
|
) -> None:
|
||||||
assert cli.main(["generate", "--app", "todo"]) == 0
|
assert cli.main(["generate", "--app", "todo"]) == 0
|
||||||
assert write.call_count == 1
|
assert write.call_count == 1
|
||||||
assert "wrote 2 asset files" in capsys.readouterr().out
|
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}.generate.run_flutter_launcher_icons")
|
||||||
@patch(f"{MOD}.render.safe_box_overflow", return_value=(0, 0))
|
|
||||||
@patch(f"{MOD}.generate.write_assets", return_value=[])
|
@patch(f"{MOD}.generate.write_assets", return_value=[])
|
||||||
def test_android_flag_runs_generator(
|
def test_android_flag_runs_generator(
|
||||||
self,
|
self,
|
||||||
mock_write: MagicMock,
|
mock_write: MagicMock,
|
||||||
mock_overflow: MagicMock,
|
|
||||||
launcher: MagicMock,
|
launcher: MagicMock,
|
||||||
) -> None:
|
) -> None:
|
||||||
cli.main(["generate", "--app", "todo", "--android"])
|
cli.main(["generate", "--app", "todo", "--android"])
|
||||||
assert launcher.call_count == 1
|
assert launcher.call_count == 1
|
||||||
|
|
||||||
@patch(f"{MOD}.generate.write_linux_icons", return_value=[Path("x")])
|
@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=[])
|
@patch(f"{MOD}.generate.write_assets", return_value=[])
|
||||||
def test_linux_out_only_for_linux_apps(
|
def test_linux_out_only_for_linux_apps(
|
||||||
self,
|
self,
|
||||||
mock_write: MagicMock,
|
mock_write: MagicMock,
|
||||||
mock_overflow: MagicMock,
|
|
||||||
linux: MagicMock,
|
linux: MagicMock,
|
||||||
tmp_path: Path,
|
tmp_path: Path,
|
||||||
) -> None:
|
) -> None:
|
||||||
@ -103,12 +86,10 @@ class TestGenerateCommand:
|
|||||||
|
|
||||||
class TestWebFlag:
|
class TestWebFlag:
|
||||||
@patch(f"{MOD}.generate.write_web_icons", return_value=[Path("w")])
|
@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=[])
|
@patch(f"{MOD}.generate.write_assets", return_value=[])
|
||||||
def test_writes_into_an_existing_web_dir(
|
def test_writes_into_an_existing_web_dir(
|
||||||
self,
|
self,
|
||||||
mock_write: MagicMock,
|
mock_write: MagicMock,
|
||||||
mock_overflow: MagicMock,
|
|
||||||
web: MagicMock,
|
web: MagicMock,
|
||||||
capsys: pytest.CaptureFixture[str],
|
capsys: pytest.CaptureFixture[str],
|
||||||
) -> None:
|
) -> None:
|
||||||
@ -118,12 +99,10 @@ class TestWebFlag:
|
|||||||
assert "wrote 1 web icons" in capsys.readouterr().out
|
assert "wrote 1 web icons" in capsys.readouterr().out
|
||||||
|
|
||||||
@patch(f"{MOD}.generate.write_web_icons")
|
@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=[])
|
@patch(f"{MOD}.generate.write_assets", return_value=[])
|
||||||
def test_skips_apps_without_a_web_dir(
|
def test_skips_apps_without_a_web_dir(
|
||||||
self,
|
self,
|
||||||
mock_write: MagicMock,
|
mock_write: MagicMock,
|
||||||
mock_overflow: MagicMock,
|
|
||||||
web: MagicMock,
|
web: MagicMock,
|
||||||
capsys: pytest.CaptureFixture[str],
|
capsys: pytest.CaptureFixture[str],
|
||||||
) -> None:
|
) -> None:
|
||||||
|
|||||||
84
python_pkg/app_icons/tests/test_fit.py
Normal file
84
python_pkg/app_icons/tests/test_fit.py
Normal file
@ -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", ' <path d="M 480 512 L 544 512"/>')
|
||||||
|
NESTED = Glyph(
|
||||||
|
"nested",
|
||||||
|
"inner group with its own scale",
|
||||||
|
' <g transform="translate(400,400) scale(10)" stroke-width="{{STROKE/10}}">'
|
||||||
|
'<path d="M 0 0 L 20 0"/></g>',
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
@ -6,7 +6,7 @@ import math
|
|||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
from unittest.mock import MagicMock, patch
|
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
|
from python_pkg.app_icons.apps import AppIcon
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
@ -28,7 +28,7 @@ def _app(tmp_path: Path, *, linux: bool = False) -> AppIcon:
|
|||||||
|
|
||||||
class TestWriteAssets:
|
class TestWriteAssets:
|
||||||
@patch(f"{MOD}.render.rasterise")
|
@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(
|
def test_writes_three_svg_png_pairs(
|
||||||
self, mock_offset: MagicMock, raster: MagicMock, tmp_path: Path
|
self, mock_offset: MagicMock, raster: MagicMock, tmp_path: Path
|
||||||
) -> None:
|
) -> None:
|
||||||
@ -39,7 +39,7 @@ class TestWriteAssets:
|
|||||||
assert raster.call_count == 3
|
assert raster.call_count == 3
|
||||||
|
|
||||||
@patch(f"{MOD}.render.rasterise")
|
@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(
|
def test_only_the_base_layer_has_the_field(
|
||||||
self, mock_offset: MagicMock, mock_raster: MagicMock, tmp_path: Path
|
self, mock_offset: MagicMock, mock_raster: MagicMock, tmp_path: Path
|
||||||
) -> None:
|
) -> None:
|
||||||
@ -51,7 +51,7 @@ class TestWriteAssets:
|
|||||||
assert style.BACKGROUND not in foreground
|
assert style.BACKGROUND not in foreground
|
||||||
|
|
||||||
@patch(f"{MOD}.render.rasterise")
|
@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(
|
def test_monochrome_layer_is_white(
|
||||||
self, mock_offset: MagicMock, mock_raster: MagicMock, tmp_path: Path
|
self, mock_offset: MagicMock, mock_raster: MagicMock, tmp_path: Path
|
||||||
) -> None:
|
) -> None:
|
||||||
@ -64,7 +64,7 @@ class TestWriteAssets:
|
|||||||
|
|
||||||
class TestWriteLinuxIcons:
|
class TestWriteLinuxIcons:
|
||||||
@patch(f"{MOD}.render.rasterise")
|
@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(
|
def test_one_png_per_hicolor_size(
|
||||||
self, mock_offset: MagicMock, raster: MagicMock, tmp_path: Path
|
self, mock_offset: MagicMock, raster: MagicMock, tmp_path: Path
|
||||||
) -> None:
|
) -> None:
|
||||||
@ -76,7 +76,7 @@ class TestWriteLinuxIcons:
|
|||||||
|
|
||||||
class TestWriteWebIcons:
|
class TestWriteWebIcons:
|
||||||
@patch(f"{MOD}.render.rasterise")
|
@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(
|
def test_replaces_the_flutter_web_template_icons(
|
||||||
self, mock_offset: MagicMock, raster: MagicMock, tmp_path: Path
|
self, mock_offset: MagicMock, raster: MagicMock, tmp_path: Path
|
||||||
) -> None:
|
) -> None:
|
||||||
|
|||||||
42
python_pkg/app_icons/tests/test_labels.py
Normal file
42
python_pkg/app_icons/tests/test_labels.py
Normal file
@ -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)
|
||||||
@ -5,7 +5,7 @@ from __future__ import annotations
|
|||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
from unittest.mock import MagicMock, patch
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
from python_pkg.app_icons import preview
|
from python_pkg.app_icons import preview, render
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@ -16,7 +16,7 @@ MOD = "python_pkg.app_icons.preview"
|
|||||||
class TestBuildContactSheet:
|
class TestBuildContactSheet:
|
||||||
@patch(f"{MOD}.subprocess.run")
|
@patch(f"{MOD}.subprocess.run")
|
||||||
@patch(f"{MOD}.render.rasterise")
|
@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")
|
@patch(f"{MOD}.render.resolve_tool", return_value="/usr/bin/magick")
|
||||||
def test_renders_one_tile_per_app_and_three_rows(
|
def test_renders_one_tile_per_app_and_three_rows(
|
||||||
self,
|
self,
|
||||||
@ -37,7 +37,7 @@ class TestBuildContactSheet:
|
|||||||
|
|
||||||
@patch(f"{MOD}.subprocess.run")
|
@patch(f"{MOD}.subprocess.run")
|
||||||
@patch(f"{MOD}.render.rasterise")
|
@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")
|
@patch(f"{MOD}.render.resolve_tool", return_value="/usr/bin/magick")
|
||||||
def test_final_append_is_vertical(
|
def test_final_append_is_vertical(
|
||||||
self,
|
self,
|
||||||
|
|||||||
@ -2,9 +2,34 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
from PIL import Image
|
||||||
import pytest
|
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:
|
class TestGlyphLookup:
|
||||||
@ -106,3 +131,31 @@ class TestHeadphonesGlyph:
|
|||||||
body = glyphs.get_glyph("headphones").body
|
body = glyphs.get_glyph("headphones").body
|
||||||
assert f'fill="{style.ACCENT_MARKER}"' in body
|
assert f'fill="{style.ACCENT_MARKER}"' in body
|
||||||
assert 'stroke="none"' 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
|
||||||
|
|||||||
@ -3,7 +3,6 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import subprocess
|
|
||||||
from unittest.mock import MagicMock, patch
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
@ -41,8 +40,11 @@ class TestBuildSvg:
|
|||||||
assert style.BACKGROUND not in svg
|
assert style.BACKGROUND not in svg
|
||||||
|
|
||||||
def test_offset_is_applied(self) -> None:
|
def test_offset_is_applied(self) -> None:
|
||||||
svg = render.build_svg(DOT, "#123456", with_background=False, offset=(3.5, -7))
|
svg = render.build_svg(
|
||||||
assert 'transform="translate(3.5,-7)"' in 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:
|
def test_accent_token_is_substituted(self) -> None:
|
||||||
svg = render.build_svg(FILLED, "#ABCDEF", with_background=False)
|
svg = render.build_svg(FILLED, "#ABCDEF", with_background=False)
|
||||||
@ -63,47 +65,3 @@ class TestRasterise:
|
|||||||
assert argv[0] == "/usr/bin/rsvg-convert"
|
assert argv[0] == "/usr/bin/rsvg-convert"
|
||||||
assert "256" in argv
|
assert "256" in argv
|
||||||
assert run.call_args.kwargs["input"] == b"<svg/>"
|
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)
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user