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"] == "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()