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

166 lines
6.6 KiB
Python
Raw Normal View History

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 render(self, template_id, data, profile="color"):
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()],
)
values.setdefault("weather", self.weather.get_current())
values["updated"] = current.strftime("%Y-%m-%d %H:%M")
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(
"• "
+ (str(v.get("title", "")) if isinstance(v, dict) else str(v))
for v in value[:10]
)
if isinstance(value, dict):
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()