"""Shared PIL rendering helpers: font loading and text layout. The panel driver dithers whatever RGB image we hand it down to its 6-color palette (see epd4in0e.getbuffer, which quantizes with Floyd-Steinberg dithering by default) -- so screens can draw with normal anti-aliased RGB text and full-color cover art without worrying about the palette themselves. """ import time from pathlib import Path from PIL import Image, ImageFont FONTS_DIR = Path(__file__).resolve().parent.parent / "fonts" _font_cache: dict[tuple[str, int], ImageFont.FreeTypeFont] = {} WEEKDAYS_DE = ["Montag", "Dienstag", "Mittwoch", "Donnerstag", "Freitag", "Samstag", "Sonntag"] MONTHS_DE = [ "Januar", "Februar", "März", "April", "Mai", "Juni", "Juli", "August", "September", "Oktober", "November", "Dezember", ] def format_date_de(t: time.struct_time | None = None) -> str: t = t or time.localtime() return f"{WEEKDAYS_DE[t.tm_wday]}, {t.tm_mday}. {MONTHS_DE[t.tm_mon - 1]}" def load_font(size: int, bold: bool = False) -> ImageFont.FreeTypeFont: key = ("bold" if bold else "regular", size) if key not in _font_cache: filename = "DejaVuSans-Bold.ttf" if bold else "DejaVuSans.ttf" _font_cache[key] = ImageFont.truetype(str(FONTS_DIR / filename), size) return _font_cache[key] # Font Awesome Free (Solid), vendored for real pictogram icons (train, bus, # etc.) where a hand-drawn shape or a plain letter would be less clear at a # glance than the real thing. SIL OFL licensed -- see fonts/FONT-AWESOME-LICENSE.txt. def load_icon_font(size: int) -> ImageFont.FreeTypeFont: key = ("fa-solid", size) if key not in _font_cache: _font_cache[key] = ImageFont.truetype(str(FONTS_DIR / "fa-solid-900.ttf"), size) return _font_cache[key] # Font Awesome Free Solid codepoints used by screens -- named here so callers # don't need to embed raw Private-Use-Area unicode literals. ICON_BUS = "" ICON_TRAIN = "" def text_width(draw, text: str, font: ImageFont.FreeTypeFont) -> int: left, _, right, _ = draw.textbbox((0, 0), text, font=font) return right - left def truncate_to_width(draw, text: str, font: ImageFont.FreeTypeFont, max_width: int) -> str: """Single-line ellipsis truncation, mirroring the CSS text-overflow: ellipsis look.""" if text_width(draw, text, font) <= max_width: return text ellipsis = "…" lo, hi = 0, len(text) while lo < hi: mid = (lo + hi + 1) // 2 candidate = text[:mid].rstrip() + ellipsis if text_width(draw, candidate, font) <= max_width: lo = mid else: hi = mid - 1 return text[:lo].rstrip() + ellipsis if lo else ellipsis def wrap_to_lines(draw, text: str, font: ImageFont.FreeTypeFont, max_width: int, max_lines: int) -> list[str]: """Word-wrap up to `max_lines`, ellipsis-truncating the last line if it still overflows -- mirrors the CSS -webkit-line-clamp behavior used in the old design.""" words = text.split() if not words: return [] lines: list[str] = [] current = words[0] next_index = 1 while next_index < len(words): word = words[next_index] candidate = f"{current} {word}" if text_width(draw, candidate, font) <= max_width: current = candidate next_index += 1 continue lines.append(current) current = word next_index += 1 if len(lines) == max_lines - 1: break if next_index < len(words): # Ran out of lines with words still remaining -- fold the rest into # the last line and ellipsis-truncate it. current = current + " " + " ".join(words[next_index:]) current = truncate_to_width(draw, current, font, max_width) lines.append(current) return lines[:max_lines] def fit_cover(image: Image.Image, width: int, height: int) -> Image.Image: """Resize+crop to fill exactly width x height, mirroring CSS's object-fit: cover -- no letterboxing, but edges may be cropped off.""" src_w, src_h = image.size scale = max(width / src_w, height / src_h) scaled = image.resize((round(src_w * scale), round(src_h * scale)), Image.LANCZOS) left = (scaled.width - width) // 2 top = (scaled.height - height) // 2 return scaled.crop((left, top, left + width, top + height)) def fit_contain(image: Image.Image, width: int, height: int, background=(255, 255, 255)) -> Image.Image: """Resize to fit entirely within width x height, centered on a solid background -- mirrors CSS's object-fit: contain, full image always visible, letterboxed instead of cropped.""" src_w, src_h = image.size scale = min(width / src_w, height / src_h) scaled = image.resize((round(src_w * scale), round(src_h * scale)), Image.LANCZOS) canvas = Image.new("RGB", (width, height), background) canvas.paste(scaled, ((width - scaled.width) // 2, (height - scaled.height) // 2)) return canvas