"""Ermittlung der Markenfarbe aus einem Logo. Bei SVG wird die häufigste Nicht-Graustufe aus den `fill`-Attributen genommen, bei Rastergrafiken entscheidet ein k-Means über die Pixel. Zusätzlich wird eine aufgehellte Variante berechnet, die auf dem dunklen Hintergrund der Oberfläche den WCAG-Kontrast von 4,5:1 erreicht. """ import colorsys import hashlib import io import logging import re from collections import Counter from dataclasses import dataclass from PIL import Image, UnidentifiedImageError logger = logging.getLogger(__name__) # Hintergrund der dunklen Oberfläche – gegen ihn wird der Kontrast geprüft. DARK_BACKGROUND = "#0f1115" MIN_CONTRAST = 4.5 # Ab diesem Sättigungswert gilt eine Farbe nicht mehr als Graustufe. MIN_SATURATION = 0.12 # Cluster unterhalb dieses Anteils sind Ausreißer und werden verworfen. MIN_CLUSTER_SHARE = 0.05 # Deterministische Ersatzfarben, wenn ein Logo rein grau ist. FALLBACK_PALETTE = ( "#ef4444", "#f97316", "#eab308", "#22c55e", "#14b8a6", "#3b82f6", "#6366f1", "#a855f7", "#ec4899", ) _FILL_PATTERN = re.compile(r'fill\s*[:=]\s*["\']?\s*(#[0-9a-fA-F]{3,8}|rgb\([^)]+\))', re.I) _STOP_COLOR_PATTERN = re.compile(r'stop-color\s*[:=]\s*["\']?\s*(#[0-9a-fA-F]{3,8})', re.I) _RGB_PATTERN = re.compile(r"rgb\(\s*(\d+)[,\s]+(\d+)[,\s]+(\d+)", re.I) @dataclass(frozen=True, slots=True) class BrandColors: """Markenfarbe und die für dunkle Oberflächen aufgehellte Variante.""" color: str color_dark: str # --- Umrechnungen -------------------------------------------------------------- def normalize_hex(value: str) -> str | None: """Bringt eine Farbangabe auf `#rrggbb`. Ungültiges ergibt None.""" text = value.strip() if not text.startswith("#"): text = f"#{text}" digits = text[1:] if len(digits) in (4, 8): # Alphakanal abschneiden digits = digits[:3] if len(digits) == 4 else digits[:6] if len(digits) == 3: digits = "".join(char * 2 for char in digits) if len(digits) != 6 or not all(char in "0123456789abcdefABCDEF" for char in digits): return None return f"#{digits.lower()}" def hex_to_rgb(value: str) -> tuple[int, int, int]: normalized = normalize_hex(value) or "#000000" return tuple(int(normalized[index : index + 2], 16) for index in (1, 3, 5)) # type: ignore[return-value] def rgb_to_hex(rgb: tuple[int, int, int]) -> str: red, green, blue = (max(0, min(255, round(channel))) for channel in rgb) return f"#{red:02x}{green:02x}{blue:02x}" def relative_luminance(rgb: tuple[int, int, int]) -> float: """Relative Leuchtdichte nach WCAG 2.1.""" channels = [] for value in rgb: srgb = value / 255 channels.append(srgb / 12.92 if srgb <= 0.04045 else ((srgb + 0.055) / 1.055) ** 2.4) red, green, blue = channels return 0.2126 * red + 0.7152 * green + 0.0722 * blue def contrast_ratio(first: str, second: str) -> float: """Kontrastverhältnis zweier Farben nach WCAG (1:1 bis 21:1).""" light = relative_luminance(hex_to_rgb(first)) dark = relative_luminance(hex_to_rgb(second)) if light < dark: light, dark = dark, light return (light + 0.05) / (dark + 0.05) def saturation_of(rgb: tuple[int, int, int]) -> float: _, _, saturation = colorsys.rgb_to_hls(*(channel / 255 for channel in rgb)) return saturation def lightness_of(rgb: tuple[int, int, int]) -> float: _, lightness, _ = colorsys.rgb_to_hls(*(channel / 255 for channel in rgb)) return lightness def is_grayscale(rgb: tuple[int, int, int]) -> bool: return saturation_of(rgb) < MIN_SATURATION def lighten_for_dark_background(color: str, background: str = DARK_BACKGROUND) -> str: """Hellt eine Farbe im HSL-Raum auf, bis der Kontrast mindestens 4,5:1 beträgt.""" normalized = normalize_hex(color) if normalized is None: return "#e2e8f0" if contrast_ratio(normalized, background) >= MIN_CONTRAST: return normalized hue, lightness, saturation = colorsys.rgb_to_hls( *(channel / 255 for channel in hex_to_rgb(normalized)) ) step = 0.02 while lightness < 0.98: lightness = min(lightness + step, 0.98) channels = colorsys.hls_to_rgb(hue, lightness, saturation) candidate = rgb_to_hex(tuple(round(channel * 255) for channel in channels)) if contrast_ratio(candidate, background) >= MIN_CONTRAST: return candidate # Selbst bei maximaler Helligkeit nicht erreichbar (sehr dunkler Farbton): neutral aufhellen. return "#e2e8f0" def deterministic_color(name: str) -> str: """Feste Farbe aus dem Namens-Hash – gleicher Name ergibt immer dieselbe Farbe.""" digest = hashlib.sha256(name.strip().lower().encode("utf-8")).digest() return FALLBACK_PALETTE[digest[0] % len(FALLBACK_PALETTE)] def brand_colors(color: str | None, *, fallback_name: str = "") -> BrandColors: """Baut das Farbpaar; ohne brauchbare Farbe greift der Namens-Hash.""" normalized = normalize_hex(color) if color else None if normalized is None: normalized = deterministic_color(fallback_name) return BrandColors(color=normalized, color_dark=lighten_for_dark_background(normalized)) # --- SVG ----------------------------------------------------------------------- def color_from_svg(content: bytes) -> str | None: """Häufigste Nicht-Graustufe aus `fill`-Attributen und Verlaufsstopps.""" try: text = content.decode("utf-8", errors="ignore") except Exception: # pragma: no cover - decode mit errors="ignore" wirft nicht return None counter: Counter[str] = Counter() for match in _FILL_PATTERN.finditer(text): value = match.group(1) if value.lower().startswith("rgb("): numbers = _RGB_PATTERN.match(value) if numbers is None: continue value = rgb_to_hex(tuple(int(part) for part in numbers.groups())) # type: ignore[arg-type] normalized = normalize_hex(value) if normalized and not is_grayscale(hex_to_rgb(normalized)): counter[normalized] += 1 for match in _STOP_COLOR_PATTERN.finditer(text): normalized = normalize_hex(match.group(1)) if normalized and not is_grayscale(hex_to_rgb(normalized)): counter[normalized] += 1 if not counter: return None return counter.most_common(1)[0][0] # --- Rastergrafiken ------------------------------------------------------------ def _kmeans(pixels: list[tuple[int, int, int]], k: int = 4, iterations: int = 20): """Schlanker k-Means über RGB-Tripel. Liefert (Zentrum, Anzahl) je Cluster.""" if not pixels: return [] unique = list(dict.fromkeys(pixels)) if len(unique) <= k: counts = Counter(pixels) return [(color, counts[color]) for color in unique] # Deterministische Startpunkte: gleichmäßig über die sortierten Farben verteilt. ordered = sorted(unique, key=lambda rgb: (relative_luminance(rgb), rgb)) centers = [ordered[round(index * (len(ordered) - 1) / (k - 1))] for index in range(k)] for _ in range(iterations): buckets: list[list[tuple[int, int, int]]] = [[] for _ in centers] for pixel in pixels: best = min( range(len(centers)), key=lambda index: sum( (pixel[channel] - centers[index][channel]) ** 2 for channel in range(3) ), ) buckets[best].append(pixel) moved = False for index, bucket in enumerate(buckets): if not bucket: continue center = tuple(round(sum(p[c] for p in bucket) / len(bucket)) for c in range(3)) if center != centers[index]: centers[index] = center # type: ignore[call-overload] moved = True if not moved: break result = [(centers[index], len(bucket)) for index, bucket in enumerate(buckets) if bucket] return sorted(result, key=lambda item: item[1], reverse=True) def color_from_raster(content: bytes) -> str | None: """Dominante Farbe einer Rastergrafik über k-Means (k=4) auf 64×64 Pixeln.""" try: with Image.open(io.BytesIO(content)) as image: image = image.convert("RGBA") image.thumbnail((64, 64), Image.Resampling.LANCZOS) pixels = [ (red, green, blue) for red, green, blue, alpha in image.getdata() if alpha >= 128 # durchscheinende Ränder verfälschen die Farbe ] except (UnidentifiedImageError, OSError, ValueError): logger.debug("Rastergrafik konnte nicht gelesen werden.", exc_info=True) return None if not pixels: return None clusters = _kmeans(pixels, k=4) total = sum(count for _, count in clusters) if not total: return None # Nur Cluster mit nennenswertem Anteil, davon der sattteste; bei ähnlicher # Sättigung gewinnt der hellere. relevant = [ (color, count) for color, count in clusters if count / total >= MIN_CLUSTER_SHARE ] or clusters farbig = [(color, count) for color, count in relevant if not is_grayscale(color)] if not farbig: return None best = max(farbig, key=lambda item: saturation_of(item[0]) * 2 + lightness_of(item[0])) return rgb_to_hex(best[0]) def extract_color(content: bytes, mime: str) -> str | None: """Ermittelt die Markenfarbe passend zum Dateityp.""" if mime == "image/svg+xml": return color_from_svg(content) return color_from_raster(content)