195 lines
6.6 KiB
Python
195 lines
6.6 KiB
Python
"""本地模板渲染(替代 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
|