Build Pigway Frame Flow with bilingual UI, complete KoKonna driver and safe automation

This commit is contained in:
Pigway
2026-10-02 00:03:48 +08:00
commit 6652590b84
79 changed files with 13773 additions and 0 deletions
+132
View File
@@ -0,0 +1,132 @@
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()