Files
computer-playbook/utils/design/logo.py
T
kevinveenbirkenbachandClaude Opus 5.5 63f3631567 fix(design): give lockup text on pages a contrasting color in both color schemes
A lockup slot on a page or panel drew its title in the light
--design-primary with a --design-on-primary outline in both color schemes.
With an orange base that is about 3:1 on the light surface, and the SVG
never changed in dark mode; the Sphinx, Jenkins and Jellyfin passes
reported it.

sys-front-inj-design now passes --design-link with a --design-surface-1
outline for light and their dark values for dark. render_assets() hands
the dark pair to every slot without frame: true, and render_svg() adds a
style rule under prefers-color-scheme: dark that overrides the fill and
stroke attributes. Slots on brand chrome keep the frame colors, and the PNG
keeps the light pair. The design_assets action requires the two new
arguments; its two callers, assets.yml and Keycloak's theme task, pass
them.

A unit test asserts the dark rule on page slots and its absence on frame
and logo-only slots; it fails when the dark pair is not handed on. In
headless Chromium an <img> of the SVG renders the title #825200 on #fff5ea
in light and #ffa500 on #140e07 in dark.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-12 17:48:21 +02:00

302 lines
9.6 KiB
Python

"""Logo lockups for the corporate design, rendered as SVG and PNG plus a favicon."""
from __future__ import annotations
import base64
import io
from xml.sax.saxutils import escape
from PIL import Image, ImageDraw, ImageFont
LAYOUT_LOGO = "logo"
LAYOUT_TITLE = "title"
LAYOUT_LOGO_TITLE = "logo_title"
LAYOUT_LOGO_TITLE_DOMAIN = "logo_title_domain"
LAYOUT_LOGO_STACKED = "logo_stacked"
FAVICON_SIZES = ((16, 16), (32, 32), (48, 48), (64, 64))
_SQUARE_MAX_ASPECT = 1.5
_INLINE_DOMAIN_MIN_ASPECT = 6.0
_STACKED_MIN_HEIGHT = 96
_SVG_RASTER_MAX = 512
def choose_layout(width: int, height: int, text_only: bool, has_title: bool) -> str:
"""Pick the lockup for a slot from its format.
Args:
width: Slot width in pixels.
height: Slot height in pixels.
text_only: Slot accepts text only.
has_title: A title is available.
Returns:
One of the ``LAYOUT_*`` constants.
"""
aspect = width / height
if text_only:
return LAYOUT_TITLE
if not has_title or aspect < _SQUARE_MAX_ASPECT:
return LAYOUT_LOGO
if height >= _STACKED_MIN_HEIGHT:
return LAYOUT_LOGO_STACKED
if aspect >= _INLINE_DOMAIN_MIN_ASPECT:
return LAYOUT_LOGO_TITLE_DOMAIN
return LAYOUT_LOGO_TITLE
def _trimmed_logo(path: str) -> Image.Image:
image = Image.open(path).convert("RGBA")
box = image.getchannel("A").getbbox()
return image.crop(box) if box else image
def _fit(image: Image.Image, width: int, height: int) -> Image.Image:
scale = min(width / image.width, height / image.height)
size = (max(1, round(image.width * scale)), max(1, round(image.height * scale)))
return image.resize(size, Image.LANCZOS)
def _font(size: int) -> ImageFont.FreeTypeFont:
return ImageFont.load_default(size=max(6, size))
def _fit_font(text: str, max_width: int, max_size: int) -> ImageFont.FreeTypeFont:
size = max_size
while size > 6 and _font(size).getlength(text) > max_width:
size -= 1
return _font(size)
def _lines(layout: str, title: str, domain: str) -> list[tuple[str, float]]:
if layout == LAYOUT_LOGO_STACKED:
return [(title, 0.42), (domain, 0.22)]
if layout == LAYOUT_LOGO_TITLE_DOMAIN:
return [(f"{title} · {domain}", 0.5)]
if layout in (LAYOUT_LOGO_TITLE, LAYOUT_TITLE):
return [(title, 0.55)]
return []
def _compose(
logo_path: str | None, title: str, domain: str, width: int, height: int, layout: str
) -> tuple[
Image.Image | None,
tuple[int, int],
list[tuple[str, ImageFont.FreeTypeFont, int, int]],
]:
pad = max(1, round(height * 0.1))
inner_w, inner_h = width - 2 * pad, height - 2 * pad
logo_img, logo_xy, text_x = None, (0, 0), pad
if layout != LAYOUT_TITLE and logo_path:
box_w = inner_w if layout == LAYOUT_LOGO else inner_h
logo_img = _fit(_trimmed_logo(logo_path), box_w, inner_h)
if layout == LAYOUT_LOGO:
logo_xy = ((width - logo_img.width) // 2, (height - logo_img.height) // 2)
else:
logo_xy = (pad, (height - logo_img.height) // 2)
text_x = pad + logo_img.width + pad
text_w = width - text_x - pad
fonts = [
(text, _fit_font(text, text_w, round(inner_h * share)))
for text, share in _lines(layout, title, domain)
]
heights = [font.getbbox(text)[3] for text, font in fonts]
gap = round(inner_h * 0.06)
y = (height - (sum(heights) + gap * max(0, len(fonts) - 1))) // 2
placed = []
for (text, font), line_h in zip(fonts, heights, strict=True):
x = (
(width - round(font.getlength(text))) // 2
if layout == LAYOUT_TITLE
else text_x
)
placed.append((text, font, x, y))
y += line_h + gap
return logo_img, logo_xy, placed
def render_png(
logo_path: str | None,
title: str,
domain: str,
fill: str,
stroke: str,
width: int,
height: int,
layout: str,
) -> bytes:
"""Render one lockup as a transparent PNG.
Args:
logo_path: Logo file, or ``None`` for text-only layouts.
title: Title text.
domain: Domain shown by the domain layouts.
fill: Text color.
stroke: Text outline color.
width: Output width.
height: Output height.
layout: One of the ``LAYOUT_*`` constants.
Returns:
PNG bytes.
"""
canvas = Image.new("RGBA", (width, height), (0, 0, 0, 0))
logo_img, logo_xy, placed = _compose(
logo_path, title, domain, width, height, layout
)
if logo_img is not None:
canvas.alpha_composite(logo_img, logo_xy)
draw = ImageDraw.Draw(canvas)
for text, font, x, y in placed:
draw.text(
(x, y),
text,
font=font,
fill=fill,
stroke_width=max(1, round(font.size * 0.08)),
stroke_fill=stroke,
)
out = io.BytesIO()
canvas.save(out, format="PNG", optimize=True)
return out.getvalue()
def render_svg(
logo_path: str | None,
title: str,
domain: str,
fill: str,
stroke: str,
width: int,
height: int,
layout: str,
dark: tuple[str, str] | None = None,
) -> str:
"""Render one lockup as SVG with the logo embedded as PNG.
``textLength`` pins each line to the width measured with the PNG font, so
the browser's own font cannot overflow the slot.
Args:
logo_path: Logo file, or ``None`` for text-only layouts.
title: Title text.
domain: Domain shown by the domain layouts.
fill: Text color.
stroke: Text outline color.
width: Output width.
height: Output height.
layout: One of the ``LAYOUT_*`` constants.
dark: ``(fill, stroke)`` applied under ``prefers-color-scheme: dark``,
or ``None`` to keep one color pair in both modes.
Returns:
SVG document.
"""
logo_img, logo_xy, placed = _compose(
logo_path, title, domain, width, height, layout
)
parts = [
f'<svg xmlns="http://www.w3.org/2000/svg" width="{width}" height="{height}" viewBox="0 0 {width} {height}">'
]
if dark is not None and placed:
parts.append(
f"<style>@media (prefers-color-scheme: dark){{text{{fill:{dark[0]};stroke:{dark[1]}}}}}</style>"
)
if logo_img is not None:
scale = min(2.0, _SVG_RASTER_MAX / max(logo_img.width, logo_img.height))
crisp = _fit(
_trimmed_logo(logo_path),
round(logo_img.width * scale),
round(logo_img.height * scale),
)
data = io.BytesIO()
crisp.save(data, format="PNG", optimize=True)
href = base64.b64encode(data.getvalue()).decode("ascii")
parts.append(
f'<image x="{logo_xy[0]}" y="{logo_xy[1]}" width="{logo_img.width}" height="{logo_img.height}" '
f'href="data:image/png;base64,{href}"/>'
)
for text, font, x, y in placed:
ascent = font.getmetrics()[0]
parts.append(
f'<text x="{x}" y="{y + ascent}" font-family="system-ui, -apple-system, Segoe UI, Roboto, sans-serif" '
f'font-size="{font.size}" font-weight="600" fill="{fill}" stroke="{stroke}" '
f'stroke-width="{max(1, round(font.size * 0.08))}" stroke-linejoin="round" paint-order="stroke" '
f'textLength="{round(font.getlength(text))}" lengthAdjust="spacingAndGlyphs">{escape(text)}</text>'
)
parts.append("</svg>")
return "".join(parts)
def render_favicon(logo_path: str) -> bytes:
"""Render the logo as a multi-size ICO.
Args:
logo_path: Logo file.
Returns:
ICO bytes.
"""
square = Image.new("RGBA", (256, 256), (0, 0, 0, 0))
logo = _fit(_trimmed_logo(logo_path), 256, 256)
square.alpha_composite(logo, ((256 - logo.width) // 2, (256 - logo.height) // 2))
out = io.BytesIO()
square.save(out, format="ICO", sizes=FAVICON_SIZES)
return out.getvalue()
def render_assets(
logo_path: str,
title: str | bool,
domain: str,
fill: str,
stroke: str,
dark_fill: str,
dark_stroke: str,
frame_fill: str,
frame_stroke: str,
slots: dict[str, dict],
) -> dict[str, bytes]:
"""Render every slot as PNG and SVG plus ``favicon.ico``.
Args:
logo_path: Logo file.
title: Title text, or ``False`` when titles are disabled.
domain: Role domain.
fill: Text color of a slot on a page or panel; the PNG keeps it in
both modes.
stroke: Text outline color of a slot on a page or panel.
dark_fill: Text color of a page or panel slot's SVG in dark mode.
dark_stroke: Text outline color of a page or panel slot's SVG in dark
mode.
frame_fill: Text color of a slot on brand-colored chrome.
frame_stroke: Text outline color of a slot on brand-colored chrome.
slots: ``{name: {"width", "height", "text_only", "frame"}}``.
Returns:
``{filename: content}``.
"""
has_title = bool(title)
assets = {"favicon.ico": render_favicon(logo_path)}
for name, spec in slots.items():
layout = choose_layout(
spec["width"], spec["height"], spec["text_only"], has_title
)
if layout == LAYOUT_TITLE and not has_title:
continue
args = (
logo_path,
str(title),
domain,
frame_fill if spec["frame"] else fill,
frame_stroke if spec["frame"] else stroke,
spec["width"],
spec["height"],
layout,
)
dark = None if spec["frame"] else (dark_fill, dark_stroke)
assets[f"{name}.png"] = render_png(*args)
assets[f"{name}.svg"] = render_svg(*args, dark).encode("utf-8")
return assets