- forecast_days now defaults to 5 (was 3) across default_settings(),
check()'s fallback, and the settings form's pre-selected option.
- New show_date setting (on by default): weekday + date in the same
"Samstag, 25. Juli" format Clock already used. Combined with the
city name into one header line above the icon/temp block when both
are on ("Münster · Samstag, 25. Juli"), each independently
toggleable.
- Moved the weekday/month name lists and the "Weekday, D. Month"
formatting out of clock.py into render_utils.format_date_de(), so
Weather reuses the exact same formatting instead of a second copy
that could drift.
131 lines
4.9 KiB
Python
131 lines
4.9 KiB
Python
"""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
|