"""本地模板渲染(替代 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