Files
qianbian/src/ppclock/templates.py
T

195 lines
6.6 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""本地模板渲染(替代 Web 端 canvas 渲染,离线可用)。
渲染产物为 400x300 RGB 图,红字用纯红 (255,0,0),经 image_pipeline 三色量化上屏。
模板集与 Web 端对齐:schedule/businesscard/memo/course/qrcode/custom(moban.js)。
"""
from __future__ import annotations
import datetime
from PIL import Image, ImageDraw, ImageFont
W, H = 400, 300
BLACK = (0, 0, 0)
WHITE = (255, 255, 255)
RED = (255, 0, 0)
_FONT_CANDIDATES = [
"/usr/share/fonts/truetype/wqy/wqy-zenhei.ttc",
"/usr/share/fonts/truetype/noto/NotoSansCJK-Regular.ttc",
]
_cache: dict[int, ImageFont.FreeTypeFont] = {}
def _font(size: int) -> ImageFont.FreeTypeFont:
if size not in _cache:
for path in _FONT_CANDIDATES:
try:
_cache[size] = ImageFont.truetype(path, size)
break
except OSError:
continue
else:
_cache[size] = ImageFont.load_default()
return _cache[size]
def _canvas() -> tuple[Image.Image, ImageDraw.ImageDraw]:
img = Image.new("RGB", (W, H), WHITE)
return img, ImageDraw.Draw(img)
def _wrap_cjk(text: str, max_units: float) -> list[str]:
"""按显示宽度断行:中文=1 单位,其他=0.5(与 moban.js 自定义文本一致)。"""
lines, cur, width = [], "", 0.0
for ch in text:
u = 1.0 if ord(ch) > 0x2E7F else 0.5
if ch == "\n" or width + u > max_units:
lines.append(cur)
cur, width = "", 0.0
if ch == "\n":
continue
cur += ch
width += u
lines.append(cur)
return lines
def _draw_colored(draw: ImageDraw.ImageDraw, xy, text: str, size: int):
"""绘制含 【…】 红字段落的单行文本。返回结束 x。"""
x, y = xy
segs, cur, red = [], "", False
for ch in text:
if ch == "【":
segs.append((cur, red)); cur, red = "", True
elif ch == "】":
segs.append((cur, red)); cur, red = "", False
else:
cur += ch
segs.append((cur, red))
for seg, is_red in segs:
if not seg:
continue
draw.text((x, y), seg, font=_font(size), fill=RED if is_red else BLACK)
x += draw.textlength(seg, font=_font(size))
return x
def render_schedule(p: dict) -> Image.Image:
"""日程:{"date": "2026-07-24"(可省), "items": [["09:00","晨会【重要】"],...]}"""
img, d = _canvas()
date = p.get("date") or datetime.date.today().isoformat()
d.rectangle([0, 0, W, 46], fill=BLACK)
d.text((16, 10), f"日程 {date}", font=_font(24), fill=WHITE)
y = 58
for time_s, event in p.get("items", [])[:8]:
d.text((16, y), time_s, font=_font(20), fill=BLACK)
_draw_colored(d, (110, y), event, 20)
d.line([(16, y + 30), (W - 16, y + 30)], fill=BLACK, width=1)
y += 30
return img
def render_businesscard(p: dict) -> Image.Image:
"""名片:{"name","title","company","phone","email"}"""
img, d = _canvas()
d.rectangle([0, 0, W, H], outline=BLACK, width=3)
d.text((24, 30), p.get("name", ""), font=_font(40), fill=BLACK)
d.text((24, 86), p.get("title", ""), font=_font(20), fill=RED)
d.text((24, 116), p.get("company", ""), font=_font(20), fill=BLACK)
d.line([(24, 150), (W - 24, 150)], fill=BLACK, width=2)
y = 164
for label, key in (("电话", "phone"), ("邮箱", "email"), ("地址", "addr")):
if p.get(key):
d.text((24, y), f"{label}:{p[key]}", font=_font(18), fill=BLACK)
y += 28
return img
def render_memo(p: dict) -> Image.Image:
"""备忘录:{"title": "...", "lines": ["...", ...]}"""
img, d = _canvas()
d.rectangle([0, 0, W, 44], fill=BLACK)
d.text((16, 8), p.get("title", "备忘录"), font=_font(24), fill=WHITE)
y = 56
for line in p.get("lines", [])[:9]:
d.ellipse([16, y + 8, 24, y + 16], fill=BLACK)
_draw_colored(d, (32, y), line, 18)
y += 27
return img
def render_course(p: dict) -> Image.Image:
"""课程表:{"days": ["周一",...](≤7), "periods": 4(2–9),
"grid": [["数学","语文",...], ...] # grid[period][day]"""
days = p.get("days") or ["周一", "周二", "周三", "周四", "周五"]
periods = int(p.get("periods", 4))
grid = p.get("grid", [])
img, d = _canvas()
cols = len(days) + 1
cw, rh = W // cols, H // (periods + 1)
for i in range(cols + 1):
d.line([(i * cw, 0), (i * cw, H)], fill=BLACK)
for j in range(periods + 2):
d.line([(0, j * rh), (W, j * rh)], fill=BLACK)
for c, day in enumerate(days):
d.text((c * cw + cw // 2, rh // 2), day, font=_font(16),
fill=BLACK, anchor="mm")
for r in range(periods):
d.text((cw // 2, (r + 1) * rh + rh // 2), f"{r + 1}", font=_font(16),
fill=BLACK, anchor="mm")
for c in range(len(days)):
name = grid[r][c] if r < len(grid) and c < len(grid[r]) else ""
if name:
d.text(((c + 1) * cw + cw // 2, (r + 1) * rh + rh // 2),
name, font=_font(14), fill=BLACK, anchor="mm")
return img
def render_qrcode(p: dict) -> Image.Image:
"""二维码:{"content": "...", "caption": "..."}(纠错级 M,与 Web 端一致)"""
import qrcode
qr = qrcode.QRCode(error_correction=qrcode.constants.ERROR_CORRECT_M, box_size=8, border=2)
qr.add_data(p.get("content", ""))
qr.make(fit=True)
qimg = qr.make_image(fill_color="black", back_color="white").convert("RGB")
side = min(qimg.width, 240)
qimg = qimg.resize((side, side), Image.NEAREST)
img, d = _canvas()
img.paste(qimg, ((W - side) // 2, (H - side) // 2 - 14))
caption = p.get("caption", "")
if caption:
d.text((W // 2, H - 34), caption, font=_font(20), fill=BLACK, anchor="mm")
return img
def render_custom(p: dict) -> Image.Image:
"""自定义文本:{"text": "支持【红字】与换行", "size": 24(可选)}"""
text = p.get("text", "")
size = int(p.get("size", 24))
img, d = _canvas()
y = 10
for line in _wrap_cjk(text, max_units=(W - 20) / size):
_draw_colored(d, (10, y), line, size)
y += int(size * 1.35)
if y > H - size:
break
return img
RENDERERS = {
"schedule": render_schedule,
"businesscard": render_businesscard,
"memo": render_memo,
"course": render_course,
"qrcode": render_qrcode,
"custom": render_custom,
}
def render_template(name: str, payload: dict) -> Image.Image:
try:
return RENDERERS[name](payload)
except KeyError:
raise ValueError(f"未知模板 {name!r},可选:{sorted(RENDERERS)}") from None