| """每日结缘图: 海报风格 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) |
|
|
|
|
| |
| 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) |
|
|
| |
| 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() |
|
|