feat(ppclock): 命令层/传输层/CLI/本地模板渲染,90 测试全绿
This commit is contained in:
1 parent
6f5b0c60be
commit
2b01e5f589
60 files changed
+16869
No files matched your search
@@ -0,0 +1,194 @@
|
||||
"""本地模板渲染(替代 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
|
||||
Reference in new issue
Block a user