222 lines
8.9 KiB
Python
222 lines
8.9 KiB
Python
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 template_id == "daily-poetry" and not values.get("poem_lines"):
|
||
from pigway_frame_flow.providers.poetry import get_poem
|
||
|
||
try:
|
||
poem = get_poem(values["date"], values.get("poem_id"))
|
||
except ValueError:
|
||
raise FlowError(
|
||
E.INVALID_REQUEST, "Invalid poetry date or poem selection"
|
||
) from None
|
||
values.update(
|
||
poem_id=poem["id"],
|
||
poem_title=poem["title"],
|
||
poem_author=poem["author"],
|
||
poem_lines=poem["lines"],
|
||
poem_source=poem["source"],
|
||
poem_edition=poem["edition"],
|
||
)
|
||
if "weather" not in values and any(
|
||
b.get("field") in ("weather", "forecast") for b in spec["blocks"]
|
||
):
|
||
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()
|