Files
Paperwall/screens/render_utils.py
T
Honey bfe4616f20 Add Picture screen: upload a file or point at a URL
New screen alongside clock/weather/navidrome. Upload goes through the web
UI (validated with PIL before accepting, stored under web/static/uploads/,
gitignored), or point it at a URL re-fetched every 30 minutes. Cover/contain
fit modes (new fit_cover/fit_contain helpers in render_utils.py, generalized
from the square-only cover-art cropping already used by the Navidrome
screen), plus the same contrast-boost/grayscale options that screen has.
2026-07-21 22:56:32 +02:00

103 lines
3.8 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.
"""
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] = {}
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]
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