tianwen / ui /summary_image.py
liuyd-dev's picture
v0: zero is infinite
07c10da verified
Raw
History Blame Contribute Delete
10.5 kB
"""每日结缘图: 海报风格 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()