mirror of
https://github.com/kevinveenbirkenbach/computer-playbook.git
synced 2026-11-10 12:47:04 +00:00
A logo whose solid pixels share one colour (alpha >= 128, every channel within 24) is detected once per file and tinted to the text colour of each slot: frame_fill on frame slots, fill elsewhere, plus a prefers-color-scheme: dark variant in the SVG of page slots and the page text colour for the favicon. Edge pixels below half opacity are ignored so antialiasing does not count as a second colour. Multi-colour logos render byte-identical to before. make design-logos (alias i8delo) renders every role with a design entry from one logo and base colour into an HTML page with day and night tiles, without a deploy. Slots render in four threads and roles in one process per CPU: the full preview went from 287.6s to 33.2s with identical output (diff -rq over 1229 PNGs). A GPU does not pay off for these PIL resizes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
223 lines
8.4 KiB
Python
223 lines
8.4 KiB
Python
import io
|
|
import re
|
|
import tempfile
|
|
import unittest
|
|
from pathlib import Path
|
|
from typing import ClassVar
|
|
|
|
from PIL import Image, ImageColor, ImageDraw
|
|
|
|
from utils.design.logo import (
|
|
LAYOUT_LOGO,
|
|
LAYOUT_LOGO_STACKED,
|
|
LAYOUT_LOGO_TITLE,
|
|
LAYOUT_LOGO_TITLE_DOMAIN,
|
|
LAYOUT_TITLE,
|
|
choose_layout,
|
|
is_monochrome,
|
|
render_assets,
|
|
)
|
|
|
|
from . import PROJECT_ROOT
|
|
|
|
LOGO = str(PROJECT_ROOT / "assets" / "img" / "logo.png")
|
|
TEXT_RE = re.compile(r"<text ([^>]*)>([^<]*)</text>")
|
|
|
|
|
|
class TestChooseLayout(unittest.TestCase):
|
|
def test_slot_format_picks_the_lockup(self) -> None:
|
|
cases = {
|
|
(512, 512, False): LAYOUT_LOGO,
|
|
(320, 64, False): LAYOUT_LOGO_TITLE,
|
|
(720, 64, False): LAYOUT_LOGO_TITLE_DOMAIN,
|
|
(960, 160, False): LAYOUT_LOGO_STACKED,
|
|
(320, 64, True): LAYOUT_TITLE,
|
|
}
|
|
for (width, height, text_only), expected in cases.items():
|
|
with self.subTest(width=width, height=height, text_only=text_only):
|
|
self.assertEqual(
|
|
choose_layout(width, height, text_only, True), expected
|
|
)
|
|
|
|
def test_without_title_every_logo_slot_shows_the_logo_only(self) -> None:
|
|
self.assertEqual(choose_layout(960, 160, False, False), LAYOUT_LOGO)
|
|
|
|
|
|
class TestRenderAssets(unittest.TestCase):
|
|
SLOTS: ClassVar[dict] = {
|
|
"icon": {"width": 512, "height": 512, "text_only": False, "frame": False},
|
|
"header": {"width": 320, "height": 64, "text_only": False, "frame": False},
|
|
"banner": {"width": 960, "height": 160, "text_only": False, "frame": False},
|
|
"wordmark": {"width": 320, "height": 64, "text_only": True, "frame": False},
|
|
"topbar": {"width": 320, "height": 64, "text_only": False, "frame": True},
|
|
}
|
|
COLORS: ClassVar[tuple] = (
|
|
"#001f3f",
|
|
"#ffffff",
|
|
"#7fb2ff",
|
|
"#0b1220",
|
|
"#fefefe",
|
|
"#00152c",
|
|
)
|
|
|
|
def test_every_slot_renders_png_and_svg_at_its_size(self) -> None:
|
|
assets = render_assets(
|
|
LOGO, "Gitea", "git.example.org", *self.COLORS, self.SLOTS
|
|
)
|
|
for name, spec in self.SLOTS.items():
|
|
with self.subTest(slot=name):
|
|
image = Image.open(io.BytesIO(assets[f"{name}.png"]))
|
|
self.assertEqual(image.size, (spec["width"], spec["height"]))
|
|
self.assertEqual(image.mode, "RGBA")
|
|
self.assertEqual(image.getpixel((0, 0))[3], 0)
|
|
svg = assets[f"{name}.svg"].decode("utf-8")
|
|
self.assertIn(f'width="{spec["width"]}" height="{spec["height"]}"', svg)
|
|
self.assertTrue(svg.endswith("</svg>"))
|
|
|
|
def test_text_carries_fill_and_outline(self) -> None:
|
|
assets = render_assets(
|
|
LOGO, "Gitea", "git.example.org", *self.COLORS, self.SLOTS
|
|
)
|
|
texts = TEXT_RE.findall(assets["banner.svg"].decode("utf-8"))
|
|
self.assertEqual(
|
|
[content for _, content in texts], ["Gitea", "git.example.org"]
|
|
)
|
|
for attrs, _ in texts:
|
|
self.assertIn('fill="#001f3f"', attrs)
|
|
self.assertIn('stroke="#ffffff"', attrs)
|
|
self.assertIn("textLength=", attrs)
|
|
|
|
def test_frame_slot_takes_the_frame_colors(self) -> None:
|
|
assets = render_assets(
|
|
LOGO, "Gitea", "git.example.org", *self.COLORS, self.SLOTS
|
|
)
|
|
texts = TEXT_RE.findall(assets["topbar.svg"].decode("utf-8"))
|
|
self.assertEqual([content for _, content in texts], ["Gitea"])
|
|
for attrs, _ in texts:
|
|
self.assertIn('fill="#fefefe"', attrs)
|
|
self.assertIn('stroke="#00152c"', attrs)
|
|
self.assertNotEqual(assets["topbar.png"], assets["header.png"])
|
|
|
|
def test_page_slots_switch_text_colors_in_dark_mode(self) -> None:
|
|
assets = render_assets(
|
|
LOGO, "Gitea", "git.example.org", *self.COLORS, self.SLOTS
|
|
)
|
|
dark_rule = (
|
|
"@media (prefers-color-scheme: dark){text{fill:#7fb2ff;stroke:#0b1220}}"
|
|
)
|
|
for name in ("header", "banner", "wordmark"):
|
|
with self.subTest(slot=name):
|
|
self.assertIn(dark_rule, assets[f"{name}.svg"].decode("utf-8"))
|
|
for name in ("topbar", "icon"):
|
|
with self.subTest(slot=name):
|
|
self.assertNotIn(b"<style", assets[f"{name}.svg"])
|
|
|
|
def test_favicon_holds_every_size(self) -> None:
|
|
assets = render_assets(
|
|
LOGO, "Gitea", "git.example.org", *self.COLORS, self.SLOTS
|
|
)
|
|
icon = Image.open(io.BytesIO(assets["favicon.ico"]))
|
|
self.assertEqual(icon.info["sizes"], {(16, 16), (32, 32), (48, 48), (64, 64)})
|
|
|
|
def test_disabled_title_drops_text_only_slots_and_text(self) -> None:
|
|
assets = render_assets(LOGO, False, "git.example.org", *self.COLORS, self.SLOTS)
|
|
self.assertNotIn("wordmark.png", assets)
|
|
self.assertNotIn(b"<text", assets["banner.svg"])
|
|
|
|
|
|
def _disc_logo(path: Path, inks: tuple[str, ...]) -> str:
|
|
big = Image.new("RGBA", (400, 400), (0, 0, 0, 0))
|
|
draw = ImageDraw.Draw(big)
|
|
for index, ink in enumerate(inks):
|
|
inset = 20 + index * 90
|
|
draw.ellipse((inset, inset, 400 - inset, 400 - inset), fill=ink)
|
|
big.resize((100, 100), Image.LANCZOS).save(path)
|
|
return str(path)
|
|
|
|
|
|
def _solid_rgbs(image: Image.Image) -> set[tuple[int, int, int]]:
|
|
raw = image.convert("RGBA").tobytes()
|
|
return {
|
|
tuple(raw[index : index + 3])
|
|
for index in range(0, len(raw), 4)
|
|
if raw[index + 3] >= 128
|
|
}
|
|
|
|
|
|
def _near(rgbs: set[tuple[int, int, int]], color: str) -> bool:
|
|
target = ImageColor.getrgb(color)[:3]
|
|
return all(
|
|
all(abs(channel - goal) <= 6 for channel, goal in zip(rgb, target, strict=True))
|
|
for rgb in rgbs
|
|
)
|
|
|
|
|
|
class TestMonochromeLogo(unittest.TestCase):
|
|
SLOTS: ClassVar[dict] = {
|
|
"icon": {"width": 128, "height": 128, "text_only": False, "frame": False},
|
|
"mark": {"width": 128, "height": 128, "text_only": False, "frame": True},
|
|
}
|
|
COLORS = TestRenderAssets.COLORS
|
|
|
|
@classmethod
|
|
def setUpClass(cls) -> None:
|
|
cls._tmp = tempfile.TemporaryDirectory()
|
|
root = Path(cls._tmp.name)
|
|
cls.mono = _disc_logo(root / "mono.png", ("#000000",))
|
|
cls.duo = _disc_logo(root / "duo.png", ("#000000", "#ff8800"))
|
|
|
|
@classmethod
|
|
def tearDownClass(cls) -> None:
|
|
cls._tmp.cleanup()
|
|
|
|
def test_one_ink_on_transparency_is_monochrome(self) -> None:
|
|
self.assertTrue(is_monochrome(self.mono))
|
|
|
|
def test_two_inks_are_not_monochrome(self) -> None:
|
|
self.assertFalse(is_monochrome(self.duo))
|
|
|
|
def test_the_default_logo_is_not_monochrome(self) -> None:
|
|
self.assertFalse(is_monochrome(LOGO))
|
|
|
|
def test_a_monochrome_logo_takes_the_text_color_of_its_slot(self) -> None:
|
|
assets = render_assets(
|
|
self.mono, False, "git.example.org", *self.COLORS, self.SLOTS
|
|
)
|
|
page = _solid_rgbs(Image.open(io.BytesIO(assets["icon.png"])))
|
|
frame = _solid_rgbs(Image.open(io.BytesIO(assets["mark.png"])))
|
|
self.assertTrue(page)
|
|
self.assertTrue(_near(page, "#001f3f"), page)
|
|
self.assertTrue(_near(frame, "#fefefe"), frame)
|
|
|
|
def test_a_monochrome_page_slot_switches_its_logo_in_dark_mode(self) -> None:
|
|
assets = render_assets(
|
|
self.mono, False, "git.example.org", *self.COLORS, self.SLOTS
|
|
)
|
|
page = assets["icon.svg"].decode("utf-8")
|
|
self.assertIn('class="logo-light"', page)
|
|
self.assertIn('class="logo-dark"', page)
|
|
self.assertIn(".logo-light{display:none}.logo-dark{display:inline}", page)
|
|
frame = assets["mark.svg"].decode("utf-8")
|
|
self.assertEqual(frame.count("<image"), 1)
|
|
self.assertNotIn("logo-dark", frame)
|
|
|
|
def test_a_monochrome_favicon_takes_the_page_text_color(self) -> None:
|
|
assets = render_assets(
|
|
self.mono, False, "git.example.org", *self.COLORS, self.SLOTS
|
|
)
|
|
icon = Image.open(io.BytesIO(assets["favicon.ico"]))
|
|
icon.size = (64, 64)
|
|
self.assertTrue(_near(_solid_rgbs(icon), "#001f3f"))
|
|
|
|
def test_a_colored_logo_keeps_its_colors(self) -> None:
|
|
assets = render_assets(
|
|
self.duo, False, "git.example.org", *self.COLORS, self.SLOTS
|
|
)
|
|
page = _solid_rgbs(Image.open(io.BytesIO(assets["icon.png"])))
|
|
self.assertFalse(_near(page, "#001f3f"))
|
|
self.assertEqual(assets["icon.svg"].count(b"<image"), 1)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|