Files
pigway-frame-flow/pigway_frame_flow/renderers/templates.py
T

203 lines
8.1 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import calendar
import json
from datetime import datetime
from io import BytesIO
from pathlib import Path
from zoneinfo import ZoneInfo
from PIL import Image, ImageDraw, ImageFont, ImageOps
from pigway_frame_flow.errors import ErrorCode as E
from pigway_frame_flow.errors import FlowError
class TemplateRenderer:
def __init__(self, db, settings, weather, assets):
self.db, self.settings, self.weather, self.assets = (
db,
settings,
weather,
assets,
)
self.root = Path(__file__).resolve().parent.parent / "templates"
def info(self, template_id):
path = self.root / (template_id + ".json")
if not path.exists() or path.parent != self.root:
raise FlowError(E.NOT_FOUND, "Template not found", 404)
spec = json.loads(path.read_text())
config = self.db.one(
"SELECT payload FROM template_config WHERE template_id=?", (template_id,)
)
if config:
spec["defaults"].update(json.loads(config["payload"]))
return spec
def list(self):
return [self.info(p.stem) for p in sorted(self.root.glob("*.json"))]
def font(self, size):
for path in [
self.settings.font_path,
"/System/Library/Fonts/PingFang.ttc",
"/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
]:
if Path(path).is_file():
return ImageFont.truetype(path, size)
return ImageFont.load_default(size=size)
def resolve(self, template_id, data):
spec = self.info(template_id)
conf = self.db.one(
"SELECT payload FROM template_config WHERE template_id=?", (template_id,)
)
values = dict(spec["defaults"])
if conf:
values.update(json.loads(conf["payload"]))
values.update(data)
current = datetime.now(ZoneInfo(self.settings.timezone))
values.setdefault("date", current.strftime("%Y-%m-%d"))
values.setdefault(
"weekday",
[
"星期一 / Monday",
"星期二 / Tuesday",
"星期三 / Wednesday",
"星期四 / Thursday",
"星期五 / Friday",
"星期六 / Saturday",
"星期日 / Sunday",
][current.weekday()],
)
if "weather" not in values:
values["weather"] = self.weather.get_current()
if (
any(b.get("field") == "forecast" for b in spec["blocks"])
and "forecast" not in values
):
values["forecast"] = self.weather.get_forecast() or [
"尚无天气预报 / No forecast available"
]
values["updated"] = current.strftime("%Y-%m-%d %H:%M")
values["weather_attribution"] = (
values.get("weather", {}).get("attribution", "")
if isinstance(values.get("weather"), dict)
else ""
)
return values
def render(self, template_id, data, profile="color"):
spec = self.info(template_id)
values = self.resolve(template_id, data)
current = datetime.now(ZoneInfo(self.settings.timezone))
img = Image.new("RGB", (1200, 1600), "white")
draw = ImageDraw.Draw(img)
accent = "#184d42" if profile == "color" else "#000000"
def text(value, x, y, width, height, size):
font = self.font(size)
lines, line = [], ""
for char in str(value)[:3000]:
if char == "\n" or draw.textlength(line + char, font=font) > width:
lines.append(line)
line = "" if char == "\n" else char
else:
line += char
lines.append(line)
maxlines = max(1, height // (size + 14))
for n, line in enumerate(lines[:maxlines]):
if n == maxlines - 1 and len(lines) > maxlines:
line = line[:-2] + "…"
draw.text(
(x, y + n * (size + 14)),
line,
font=font,
fill=accent if size >= 60 else "black",
)
for block in spec["blocks"]:
x, y, w, h = block["box"]
if block["kind"] == "rule":
draw.rectangle((x, y, x + w, y + h), fill=accent)
elif block["kind"] == "month":
try:
shown = datetime.strptime(str(values["date"]), "%Y-%m-%d")
except ValueError:
raise FlowError(
E.INVALID_REQUEST, "Calendar date must be YYYY-MM-DD"
) from None
text(shown.strftime("%Y-%m"), x, y, w, 70, 48)
cells = calendar.Calendar(firstweekday=0).monthdayscalendar(
shown.year, shown.month
)
cell_w, cell_h = w // 7, (h - 140) // len(cells)
for col, label in enumerate(
["一 M", "二 T", "三 W", "四 T", "五 F", "六 S", "日 S"]
):
text(label, x + col * cell_w + 10, y + 80, cell_w - 20, 60, 30)
for row, week in enumerate(cells):
for col, day in enumerate(week):
if not day:
continue
left, top = x + col * cell_w, y + 140 + row * cell_h
if (shown.year, shown.month, day) == (
current.year,
current.month,
current.day,
):
draw.rectangle(
(left, top, left + cell_w - 6, top + cell_h - 6),
outline=accent,
width=5,
)
text(day, left + 25, top + 10, cell_w - 35, cell_h, 38)
elif block["kind"] == "photo":
asset_id = values.get("photo_id")
if asset_id:
with Image.open(self.assets.path(asset_id)) as photo:
img.paste(ImageOps.fit(photo.convert("RGB"), (w, h)), (x, y))
else:
draw.rectangle((x, y, x + w, y + h), outline="black", width=5)
text(
"家庭照片 / Family photo",
x + 35,
y + h // 2 - 30,
w - 70,
100,
36,
)
else:
value = values.get(block.get("field"), block.get("text", ""))
if isinstance(value, list):
value = "\n".join(
"• "
+ (
(
f"{v.get('date', '')} · {v.get('summary', '')} · {v.get('minimum_c', '--')}–{v.get('maximum_c', '--')}°C · {v.get('rain_probability', '--')}%"
if block.get("field") == "forecast"
else str(v.get("title", ""))
)
if isinstance(v, dict)
else str(v)
)
for v in value[:10]
)
if isinstance(value, dict):
if block.get("field") == "weather":
value = " · ".join(
str(value.get(k, ""))
for k in ["location", "temperature", "summary"]
)
if values["weather"].get("stale"):
value += " · 数据过期 / Stale"
else:
value = " · ".join(
str(v) for k, v in value.items() if k != "mock"
)
text(value, x, y, w, h, block.get("size", 40))
if profile == "grayscale":
img = ImageOps.grayscale(img).convert("RGB")
out = BytesIO()
img.save(out, "JPEG", quality=93)
return out.getvalue()