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() 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( "• " + (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()