"""每日结缘图: 海报风格 PIL 合成。 设计语言: 近白底、大留白、生肖水墨插画为主视觉、极简信息层级、红印章收尾。 风格 (style) 仅微调底色与点缀色; 心情 (mood) 在底色上做极轻的色温偏移。 """ from __future__ import annotations import io import os from functools import lru_cache from pathlib import Path from typing import Any from PIL import Image, ImageDraw, ImageFilter, ImageFont FONT_CANDIDATES = ( r"C:\Windows\Fonts\NotoSerifSC-VF.ttf", r"C:\Windows\Fonts\msyh.ttc", r"C:\Windows\Fonts\simhei.ttf", r"C:\Windows\Fonts\simsun.ttc", r"C:\Windows\Fonts\simfang.ttf", r"C:\Windows\Fonts\NotoSansSC-VF.ttf", ) @lru_cache(maxsize=32) def _get_font(size: int) -> ImageFont.FreeTypeFont: for p in FONT_CANDIDATES: if not os.path.exists(p): continue try: return ImageFont.truetype(p, size=size, index=0) except Exception: continue return ImageFont.load_default() def _mix(a: tuple[int, int, int], b: tuple[int, int, int], t: float) -> tuple[int, int, int]: return tuple(int(x * (1 - t) + y * t) for x, y in zip(a, b)) def _wrap_text(draw: ImageDraw.ImageDraw, text: str, font: ImageFont.FreeTypeFont, max_w: int) -> list[str]: lines: list[str] = [] cur = "" for ch in text: cand = cur + ch bbox = draw.textbbox((0, 0), cand, font=font) if bbox[2] - bbox[0] > max_w and cur: lines.append(cur) cur = ch else: cur = cand if cur: lines.append(cur) return lines def _first_sentence(text: str, limit: int = 36) -> str: if not text: return "" for sep in ("。", "!", "?", "!", "?", "\n"): if sep in text: text = text.split(sep)[0] break return text.strip()[:limit] def _strip_action_prefix(text: str) -> str: import re text = re.sub(r"^Today[''']?s small step[::]\s*", "", text, flags=re.IGNORECASE) return text.replace("今日可行的一小步:", "").replace("今日可行的一小步:", "").strip(" 。.") def _draw_hexagram(draw: ImageDraw.ImageDraw, code: int, x: int, y: int, color: tuple[int, int, int], bar_w: int = 56) -> None: bar_h, gap = 5, 9 for i in range(6): yi = y + (5 - i) * gap if (code >> i) & 1: draw.rectangle([x, yi, x + bar_w, yi + bar_h], fill=color) else: mid = x + bar_w / 2 draw.rectangle([x, yi, mid - 4, yi + bar_h], fill=color) draw.rectangle([mid + 4, yi, x + bar_w, yi + bar_h], fill=color) # 五行 → 传统色 (Ritie.md 规范): 中心气场色块与气场点用色 TRAD_COLOR = { "木": (115, 151, 141), # 苍青 "火": (171, 88, 70), # 朱砂偏暖 "土": (140, 112, 83), # 枯藤 "金": (164, 158, 162), # 丁香灰 "水": (113, 128, 172), # 霁雨 } # 风格 → 纸底色调 (玉白/浅绢/黛墨; 严禁纯白) STYLE_BG = { "rain": (244, 244, 241), "sun": (246, 243, 236), "wood": (243, 245, 240), "metal": (246, 246, 243), "ink": (36, 38, 43), } POETIC = { "金": "守静如玉 藏锋于鞘", "木": "万物生长 各自向阳", "水": "上善若水 静水流深", "火": "心有暖阳 步履生光", "土": "厚土载物 步步生根", "墨": "云在青天 水在瓶", } POETIC_EN = { "金": "Still as jade — edge held within", "木": "Each thing grows toward its own light", "水": "Water finds peace at the lowest place", "火": "Warmth in the heart lights the way ahead", "土": "Deep roots hold the weight of seasons", "墨": "Cloud in the blue sky — water in the jar", } _ELEMENT_EN = {"金": "Metal", "木": "Wood", "水": "Water", "火": "Fire", "土": "Earth"} _WEEK_ZH = ["周一", "周二", "周三", "周四", "周五", "周六", "周日"] _WEEK_EN = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"] def _draw_logo(draw: ImageDraw.ImageDraw, cx: float, cy: float, R: float, color: tuple[int, int, int]) -> None: """同心半环品牌标 (替代红印): 三层错落弧 + 中心点。PIL arc 角度顺时针自 3 点起。""" lw = max(2, int(R * 0.13)) draw.arc([cx - R, cy - R, cx + R, cy + R], -64, -64 + 122, fill=color, width=lw) r2 = R * 0.64 draw.arc([cx - r2, cy - r2, cx + r2, cy + r2], 108, 108 + 180, fill=color, width=lw) r3 = R * 0.31 draw.arc([cx - r3, cy - r3, cx + r3, cy + r3], 205, 205 + 259, fill=color, width=lw) rd = R * 0.13 draw.ellipse([cx - rd, cy - rd, cx + rd, cy + rd], fill=color) def _paper_texture(size: tuple[int, int], dark: bool) -> Image.Image: """宣纸肌理: 低不透明度噪点 (3%-5%), 打破电子屏的冰冷。""" import random w, h = size sw, sh = w // 3, h // 3 noise = Image.new("L", (sw, sh)) px = noise.load() for y in range(sh): for x in range(sw): px[x, y] = random.randint(118, 138) noise = noise.resize(size, Image.BILINEAR).filter(ImageFilter.GaussianBlur(0.6)) tint = (255, 255, 255) if dark else (90, 84, 72) layer = Image.new("RGB", size, tint) layer.putalpha(noise.point(lambda v: int(abs(v - 128) * (0.10 if dark else 0.16)))) return layer def _draw_spaced(draw: ImageDraw.ImageDraw, text: str, font: ImageFont.FreeTypeFont, cx: float, cy: float, fill: tuple[int, int, int], spacing: int) -> None: """字间距加宽的居中绘制 (营造旷远呼吸感; PIL 无原生 letter-spacing)。""" widths = [draw.textbbox((0, 0), ch, font=font)[2] for ch in text] total = sum(widths) + spacing * (len(text) - 1) x = cx - total / 2 for ch, wch in zip(text, widths): draw.text((x, cy), ch, font=font, fill=fill, anchor="lm") x += wch + spacing def render_daily_image( reading: dict[str, Any], mood: str = "", divination: dict[str, Any] | None = None, size: tuple[int, int] = (1024, 1448), lang: str = "zh", ) -> Image.Image: """「观气·知心」极简日签 (docs/Ritie.md): 玉白纸底 + 中心五行气场晕染 + 极简信息架构。 mood 形参此处复用为风格 (rain/sun/wood/metal/ink), 决定纸底色调。lang 控制文字语言。""" import datetime as _dt w, h = size riyun = (reading or {}).get("riyun", {}) or {} day_el = riyun.get("day_element", "土") style = mood if mood in STYLE_BG else "metal" bg = STYLE_BG.get(style, STYLE_BG["metal"]) is_dark = sum(bg) < 300 qi = TRAD_COLOR.get(day_el, (164, 158, 162)) ink = (228, 222, 208) if is_dark else (44, 42, 41) dim = _mix(ink, bg, 0.5) M = 104 img = Image.new("RGB", size, bg) # ---- 中心: 抽象五行气场色块 (单色, 高斯晕染, 无硬边, ~35%) ---- cx, cy = w / 2, h * 0.46 overlay = Image.new("RGBA", size, (0, 0, 0, 0)) od = ImageDraw.Draw(overlay) base_r = w * 0.30 import random as _rnd for k, alpha in ((1.0, 40), (0.74, 60), (0.5, 78), (0.3, 96)): rr = base_r * k jx = cx + (_rnd.random() - 0.5) * base_r * 0.12 jy = cy + (_rnd.random() - 0.5) * base_r * 0.12 od.ellipse([jx - rr, jy - rr * 1.05, jx + rr, jy + rr * 1.05], fill=(*qi, alpha)) overlay = overlay.filter(ImageFilter.GaussianBlur(28)) img.paste(overlay, (0, 0), overlay) # ---- 宣纸肌理 ---- tex = _paper_texture(size, is_dark) img.paste(tex, (0, 0), tex) draw = ImageDraw.Draw(img) # ---- 顶左: 客观时间锚点 (低视觉权重, 无衬线小字) ---- date_str = riyun.get("date", "") week = "" try: d = _dt.date.fromisoformat(date_str) week = (_WEEK_EN if lang == "en" else _WEEK_ZH)[d.weekday()] except Exception: pass header_text = "Tianwen Daily" if lang == "en" else "天问 日签" draw.text((M, M - 6), header_text, font=_get_font(22), fill=dim) draw.text((M, M + 30), f"{date_str} {week}".strip(), font=_get_font(17), fill=_mix(ink, bg, 0.55)) # ---- 顶右: 命理时空坐标 (干支大字 + 气场) ---- day_ganzhi = riyun.get("day_ganzhi", "—") gx = w - M draw.text((gx, M - 12), day_ganzhi[:2], font=_get_font(96), fill=ink, anchor="ra") # 气场点与文本水平居中对齐 (同一中线) f_qi = _get_font(19) line_y = M + 126 label = f"{_ELEMENT_EN.get(day_el, day_el)} in season" if lang == "en" else f"{day_el}气当令" draw.text((gx, line_y), label, font=f_qi, fill=dim, anchor="rm") lw_box = draw.textbbox((0, 0), label, font=f_qi) label_w = lw_box[2] - lw_box[0] dotc_x = gx - label_w - 14 draw.ellipse([dotc_x - 6, line_y - 6, dotc_x + 6, line_y + 6], fill=qi) # ---- 底部: 心理觉察主文案 (字距加宽) + 正念行动 ---- narrative = (reading or {}).get("_narrative", "") if lang == "en": motto = POETIC_EN.get(day_el, POETIC_EN.get("墨", "")) action_key = f"_actions_{lang}" else: motto = _first_sentence(narrative) if not motto or len(motto) > 22 or any(k in motto for k in ("干支", "日主", "节气", "得分")): motto = POETIC.get(day_el, POETIC["土"]) action_key = "_actions" my = h * 0.76 draw.line([(cx - 22, my - 50), (cx + 22, my - 50)], fill=_mix(qi, bg, 0.2), width=1) _draw_spaced(draw, motto, _get_font(34), cx, my, ink, 14) actions = (reading or {}).get(action_key) or (reading or {}).get("_actions", []) or [] step = _strip_action_prefix(actions[0]) if actions else "" if step: step_prefix = "One step: " if lang == "en" else "今日一步 " line = _wrap_text(draw, step_prefix + step, _get_font(20), w - M * 2)[0] draw.text((cx, my + 66), line, font=_get_font(20), fill=dim, anchor="mm") # ---- 左下: 卦象小记 (若有占卜) ---- if divination and isinstance(divination.get("ben"), dict): ben = divination["ben"] code = ben.get("code", divination.get("ben_code", 0)) or 0 _draw_hexagram(draw, code, M, h - M - 92, _mix(ink, bg, 0.3)) draw.text((M + 74, h - M - 90), ben.get("name", ""), font=_get_font(19), fill=dim) # ---- 右下角: 黑白同心半环品牌标 (替代红印) ---- _draw_logo(draw, w - M - 28, h - M - 28, 28, _mix(ink, bg, 0.12)) return img def save_png_bytes(img: Image.Image) -> bytes: buf = io.BytesIO() img.save(buf, format="PNG", optimize=True) return buf.getvalue()