Build Pigway Frame Flow with bilingual UI, complete KoKonna driver and safe automation
This commit is contained in:
@@ -0,0 +1,162 @@
|
||||
import base64
|
||||
import ipaddress
|
||||
import socket
|
||||
from io import BytesIO
|
||||
from urllib.parse import urlsplit
|
||||
from uuid import uuid4
|
||||
|
||||
from PIL import Image, ImageFilter, ImageOps, ImageStat
|
||||
|
||||
from pigway_frame_flow.errors import ErrorCode as E
|
||||
from pigway_frame_flow.errors import FlowError
|
||||
from pigway_frame_flow.storage.database import now_iso
|
||||
|
||||
Image.MAX_IMAGE_PIXELS = 25_000_000
|
||||
|
||||
|
||||
def convert(data, fit="contain", profile="color"):
|
||||
try:
|
||||
with Image.open(BytesIO(data)) as source:
|
||||
if source.format not in {"JPEG", "PNG", "WEBP"}:
|
||||
raise ValueError()
|
||||
if source.width * source.height > 25_000_000:
|
||||
raise ValueError()
|
||||
img = ImageOps.exif_transpose(source).convert("RGBA")
|
||||
canvas = Image.new("RGBA", img.size, "white")
|
||||
canvas.alpha_composite(img)
|
||||
img = canvas.convert("RGB")
|
||||
if fit == "contain":
|
||||
result = ImageOps.pad(
|
||||
img, (1200, 1600), method=Image.Resampling.LANCZOS, color="white"
|
||||
)
|
||||
else:
|
||||
center = (0.5, 0.5)
|
||||
if fit == "smart":
|
||||
# Lightweight edge saliency: slide an aspect-correct window
|
||||
# over a thumbnail; retain the region with most detail.
|
||||
thumb = img.copy()
|
||||
thumb.thumbnail((240, 240))
|
||||
edges = thumb.convert("L").filter(ImageFilter.FIND_EDGES)
|
||||
w, h = edges.size
|
||||
cw, ch = (int(h * 0.75), h) if w / h > 0.75 else (w, int(w / 0.75))
|
||||
choices = []
|
||||
for step in range(11):
|
||||
x, y = int((w - cw) * step / 10), int((h - ch) * step / 10)
|
||||
score = ImageStat.Stat(edges.crop((x, y, x + cw, y + ch))).sum[
|
||||
0
|
||||
]
|
||||
# Favor centered crops in ties, suppress border artifacts.
|
||||
choices.append((score - abs(step - 5) * 0.01, x, y))
|
||||
_, x, y = max(choices)
|
||||
center = (
|
||||
(x / (w - cw)) if w != cw else 0.5,
|
||||
(y / (h - ch)) if h != ch else 0.5,
|
||||
)
|
||||
result = ImageOps.fit(
|
||||
img, (1200, 1600), method=Image.Resampling.LANCZOS, centering=center
|
||||
)
|
||||
if profile == "grayscale":
|
||||
result = ImageOps.grayscale(result).convert("RGB")
|
||||
out = BytesIO()
|
||||
result.save(out, "JPEG", quality=93, optimize=True)
|
||||
return out.getvalue()
|
||||
except (OSError, ValueError, Image.DecompressionBombError):
|
||||
raise FlowError(E.INVALID_REQUEST, "Unsupported or invalid image") from None
|
||||
|
||||
|
||||
class Assets:
|
||||
def __init__(self, db, settings):
|
||||
self.db, self.settings = db, settings
|
||||
|
||||
def put(self, data, name="image.jpg", kind="images"):
|
||||
from pathlib import Path
|
||||
|
||||
name = Path(name).stem[:110] + ".jpg"
|
||||
asset_id = uuid4().hex
|
||||
(self.settings.data_dir / kind / (asset_id + ".jpg")).write_bytes(data)
|
||||
self.db.execute(
|
||||
"INSERT INTO assets VALUES(?,?,?,?)", (asset_id, name, kind, now_iso())
|
||||
)
|
||||
return self.info(asset_id)
|
||||
|
||||
def info(self, asset_id):
|
||||
row = self.db.one("SELECT * FROM assets WHERE id=?", (asset_id,))
|
||||
if not row:
|
||||
raise FlowError(E.NOT_FOUND, "Asset not found", 404)
|
||||
return {
|
||||
"asset_id": row["id"],
|
||||
"name": row["name"],
|
||||
"url": f"/api/v1/assets/{row['id']}",
|
||||
"created_at": row["created_at"],
|
||||
}
|
||||
|
||||
def path(self, asset_id):
|
||||
row = self.db.one("SELECT * FROM assets WHERE id=?", (asset_id,))
|
||||
if not row:
|
||||
raise FlowError(E.NOT_FOUND, "Asset not found", 404)
|
||||
return self.settings.data_dir / row["kind"] / (row["id"] + ".jpg")
|
||||
|
||||
def decode(self, value):
|
||||
try:
|
||||
data = base64.b64decode(value, validate=True)
|
||||
except ValueError:
|
||||
raise FlowError(E.INVALID_REQUEST, "Invalid base64 image") from None
|
||||
if len(data) > self.settings.max_bytes:
|
||||
raise FlowError(E.INVALID_REQUEST, "Image exceeds 20 MiB")
|
||||
return data
|
||||
|
||||
def fetch_url(self, url):
|
||||
parsed = urlsplit(url)
|
||||
if (
|
||||
parsed.scheme != "https"
|
||||
or not parsed.hostname
|
||||
or parsed.username
|
||||
or parsed.password
|
||||
or parsed.port not in (None, 443)
|
||||
):
|
||||
raise FlowError(E.INVALID_REQUEST, "Image URL requires HTTPS on port 443")
|
||||
if parsed.hostname.lower() not in self.settings.image_url_hosts:
|
||||
raise FlowError(E.FORBIDDEN, "Image host is not allowed", 403)
|
||||
try:
|
||||
addresses = socket.getaddrinfo(
|
||||
parsed.hostname, 443, type=socket.SOCK_STREAM
|
||||
)
|
||||
if not addresses or any(
|
||||
not ipaddress.ip_address(x[4][0]).is_global for x in addresses
|
||||
):
|
||||
raise FlowError(
|
||||
E.FORBIDDEN, "Private image addresses are forbidden", 403
|
||||
)
|
||||
# Pin the validated resolution for this request, including TLS SNI,
|
||||
# instead of performing a second DNS lookup (rebinding).
|
||||
import http.client
|
||||
import ssl
|
||||
|
||||
ip = str(addresses[0][4][0])
|
||||
|
||||
class PinnedHTTPS(http.client.HTTPSConnection):
|
||||
def connect(self):
|
||||
sock = socket.create_connection((ip, 443), timeout=10)
|
||||
self.sock = ssl.create_default_context().wrap_socket(
|
||||
sock, server_hostname=parsed.hostname
|
||||
)
|
||||
|
||||
connection = PinnedHTTPS(parsed.hostname, timeout=10)
|
||||
try:
|
||||
connection.request(
|
||||
"GET",
|
||||
(parsed.path or "/") + ("?" + parsed.query if parsed.query else ""),
|
||||
)
|
||||
response = connection.getresponse()
|
||||
if response.status != 200:
|
||||
raise FlowError(
|
||||
E.INVALID_REQUEST, "Image URL returned an invalid response"
|
||||
)
|
||||
data = response.read(self.settings.max_bytes + 1)
|
||||
if len(data) > self.settings.max_bytes:
|
||||
raise FlowError(E.INVALID_REQUEST, "Image exceeds 20 MiB")
|
||||
return data
|
||||
finally:
|
||||
connection.close()
|
||||
except (OSError, ValueError):
|
||||
raise FlowError(E.INVALID_REQUEST, "Unable to fetch image URL") from None
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user