106 lines
3.6 KiB
Python
106 lines
3.6 KiB
Python
"""Text overlay rendering for the e-ink frame.
|
|
|
|
Paints aliased white-on-black-stroke text into the dithered palette index
|
|
array; black/white survive Atkinson dithering so edges stay crisp on e-ink.
|
|
"""
|
|
|
|
import os
|
|
from datetime import datetime, timezone
|
|
|
|
import numpy as np
|
|
from PIL import Image, ImageDraw, ImageFont
|
|
|
|
FONT_CANDIDATES = (
|
|
"/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf",
|
|
"/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
|
|
"/usr/share/fonts/truetype/freefont/FreeSansBold.ttf",
|
|
"/System/Library/Fonts/Helvetica.ttc",
|
|
)
|
|
|
|
PALETTE_BLACK = 0
|
|
PALETTE_WHITE = 1
|
|
|
|
|
|
def _load_font(size: int) -> ImageFont.ImageFont:
|
|
for path in FONT_CANDIDATES:
|
|
if os.path.exists(path):
|
|
return ImageFont.truetype(path, size)
|
|
return ImageFont.load_default()
|
|
|
|
|
|
def format_age(asset: dict) -> str | None:
|
|
"""Photo capture age as 'N days/weeks/months/years ago'."""
|
|
exif = asset.get("exifInfo") or {}
|
|
date_str = exif.get("dateTimeOriginal") or asset.get("fileCreatedAt")
|
|
if not date_str:
|
|
return None
|
|
try:
|
|
dt = datetime.fromisoformat(date_str.replace("Z", "+00:00"))
|
|
except (ValueError, AttributeError):
|
|
return None
|
|
if dt.tzinfo is None:
|
|
dt = dt.replace(tzinfo=timezone.utc)
|
|
days = (datetime.now(timezone.utc) - dt).days
|
|
if days < 0:
|
|
return None
|
|
if days == 0:
|
|
return "Today"
|
|
if days == 1:
|
|
return "Yesterday"
|
|
if days < 7:
|
|
return f"{days} days ago"
|
|
for n, unit in ((365, "year"), (30, "month"), (7, "week")):
|
|
if days >= n:
|
|
count = max(1, days // n)
|
|
return f"{count} {unit}{'s' if count > 1 else ''} ago"
|
|
|
|
|
|
def format_location(asset: dict) -> str | None:
|
|
"""Most specific location available from EXIF."""
|
|
exif = asset.get("exifInfo") or {}
|
|
return exif.get("city") or exif.get("state") or exif.get("country") or None
|
|
|
|
|
|
def render_text_into_indices(indices: np.ndarray,
|
|
left_text: str | None,
|
|
right_text: str | None,
|
|
orientation: int = 0) -> None:
|
|
"""Paint white-on-black-stroke text into a (height, width) palette-index array.
|
|
|
|
Text is laid out viewer-bottom-left/right, then rotated by `orientation`
|
|
so labels land at the viewer's bottom regardless of frame mounting.
|
|
"""
|
|
font_size, margin, stroke_width = 20, 18, 2
|
|
buffer_h, buffer_w = indices.shape
|
|
if orientation in (90, 270):
|
|
view_w, view_h = buffer_h, buffer_w
|
|
else:
|
|
view_w, view_h = buffer_w, buffer_h
|
|
|
|
fill_layer = Image.new("L", (view_w, view_h), 0)
|
|
full_layer = Image.new("L", (view_w, view_h), 0)
|
|
fill_draw = ImageDraw.Draw(fill_layer)
|
|
full_draw = ImageDraw.Draw(full_layer)
|
|
font = _load_font(font_size)
|
|
baseline = view_h - margin
|
|
|
|
if left_text:
|
|
pos = (margin, baseline)
|
|
fill_draw.text(pos, left_text, font=font, fill=255, anchor="lb")
|
|
full_draw.text(pos, left_text, font=font, fill=255, anchor="lb",
|
|
stroke_width=stroke_width, stroke_fill=255)
|
|
|
|
if right_text:
|
|
pos = (view_w - margin, baseline)
|
|
fill_draw.text(pos, right_text, font=font, fill=255, anchor="rb")
|
|
full_draw.text(pos, right_text, font=font, fill=255, anchor="rb",
|
|
stroke_width=stroke_width, stroke_fill=255)
|
|
|
|
if orientation:
|
|
fill_layer = fill_layer.rotate(orientation, expand=True)
|
|
full_layer = full_layer.rotate(orientation, expand=True)
|
|
|
|
fill_mask = np.asarray(fill_layer) >= 128
|
|
stroke_mask = (np.asarray(full_layer) >= 128) & ~fill_mask
|
|
indices[stroke_mask] = PALETTE_BLACK
|
|
indices[fill_mask] = PALETTE_WHITE
|