Files
eink-push/renderer.py
2026-06-29 09:43:20 +08:00

331 lines
15 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
渲染层 —— 将数据绘制成 400x300 纯黑白墨水屏仪表盘。
设计要点:
- 纯黑白(设备无真灰阶,灰阶只能抖动成网点,弃用);用构图 + 字号 + 留白建立层级。
- Fusion Pixel 点阵字体,按整数倍尺寸(12/24/48)渲染fontmode='1' 关闭抗锯齿 → 像素锐利。
- 重点数据用「伪粗体」(偏移叠绘)加粗,仍对齐像素网格。
布局:上=大时钟+日期 / 天气;中=橘喵今日经营三列;下=近30天三指标趋势折线图。
"""
import math
from PIL import Image, ImageDraw, ImageFont
import config
BLACK, WHITE = 0, 255
class DashboardRenderer:
def __init__(self, font_path=config.FONT_PATH, size=(config.WIDTH, config.HEIGHT)):
self.W, self.H = size
self.F12 = ImageFont.truetype(font_path, 12)
self.F24 = ImageFont.truetype(font_path, 24)
self.F48 = ImageFont.truetype(font_path, 48)
# ---------- 基础绘制工具 ----------
def _tw(self, s, ft):
return self.d.textlength(s, font=ft)
def _draw(self, x, y, s, ft, fill, bold):
# 伪粗体:点阵字体单一字重,按 1~bold px 偏移叠绘加粗笔画
self.d.text((x, y), s, font=ft, fill=fill)
for dx in range(1, bold + 1):
self.d.text((x + dx, y), s, font=ft, fill=fill)
def text(self, x, y, s, ft, fill=BLACK, bold=0):
self._draw(x, y, s, ft, fill, bold)
def rtext(self, xr, y, s, ft, fill=BLACK, bold=0):
self._draw(xr - self._tw(s, ft) - bold, y, s, ft, fill, bold)
def ctext(self, cx, y, s, ft, fill=BLACK, bold=0):
self._draw(cx - (self._tw(s, ft) + bold) / 2, y, s, ft, fill, bold)
def hdash(self, x0, x1, y, dash=4, gap=4, fill=BLACK):
x = x0
while x < x1:
self.d.line([x, y, min(x + dash, x1), y], fill=fill, width=1)
x += dash + gap
def vdash(self, x, y0, y1, dash=4, gap=4, fill=BLACK):
y = y0
while y < y1:
self.d.line([x, y, x, min(y + dash, y1)], fill=fill, width=1)
y += dash + gap
def tri(self, cx, cy, sz, up=True, fill=BLACK):
if up:
pts = [(cx, cy - sz / 2), (cx - sz / 2, cy + sz / 2), (cx + sz / 2, cy + sz / 2)]
else:
pts = [(cx, cy + sz / 2), (cx - sz / 2, cy - sz / 2), (cx + sz / 2, cy - sz / 2)]
self.d.polygon(pts, fill=fill)
# ---------- 组件 ----------
def _cmp_line(self, cx, y, label, pair, fg=BLACK):
"""环比/同比一行:标签 + 涨跌三角 + 数值整体居中。direction 为 None 时不画三角。"""
direction, val = pair
lw = self._tw(label, self.F12); gap = 5; nw = self._tw(val, self.F12)
trw, tgap = (8, 4) if direction else (0, 0)
x = cx - (lw + gap + trw + tgap + nw) / 2
self.text(x, y, label, self.F12, fill=fg); x += lw + gap
if direction:
# 三角中心对齐 F12 数字墨迹竖直中心(y+8.5),否则箭头偏高
self.tri(x + trw / 2, y + 8.5, 7, up=(direction == "up"), fill=fg); x += trw + tgap
self.text(x, y, val, self.F12, fill=fg)
def _title_bar(self, y, title, upd):
"""标题栏:■ 标题(左) + 上次更新 HH:mm"""
sq, sq_y = 6, y + 4
self.d.rectangle([10, sq_y, 10 + sq, sq_y + sq], fill=BLACK)
self.text(10 + sq + 6, y, title, self.F12)
self.rtext(self.W - 10, y, f"上次更新 {upd}", self.F12)
def _loc_pin(self, x, y, w=8, fg=BLACK, bg=WHITE):
"""定位图标:实心水滴(圆头+尖尾) + 白色挖孔1-bit 下轮廓清晰。
(x,y) 为头部圆左上角w 为头径,总高约 w*1.5。"""
cx, cy = x + w / 2, y + w / 2 # 头部圆心
self.d.ellipse([x, y, x + w, y + w], fill=fg) # 头部实心圆
self.d.polygon([(x, cy), (x + w, cy), (cx, y + w + w * 0.5)], fill=fg) # 尾部尖
hr = w * 0.2
self.d.ellipse([cx - hr, cy - hr, cx + hr, cy + hr], fill=bg) # 头部白孔
@staticmethod
def _weather_category(icon):
"""和风天气 icon 代码 → 可枚举类别。无法识别时回退 'cloudy'
参考 https://dev.qweather.com/docs/resource/icons/
100=晴日 150=晴夜 / 101~103,151~153=多云 / 104,154=阴 /
302~304=雷阵雨 / 300~399=雨 / 400~499=雪 / 500~599=雾霾沙尘。"""
try:
c = int(icon)
except (TypeError, ValueError):
return "cloudy"
if c == 100:
return "sunny"
if c == 150:
return "clear_night"
if c in (101, 102, 103, 151, 152, 153):
return "cloudy"
if c in (104, 154):
return "overcast"
if c in (302, 303, 304):
return "thunder"
if 300 <= c <= 399:
return "rain"
if 400 <= c <= 499:
return "snow"
if 500 <= c <= 599:
return "fog"
return "cloudy"
def _sun(self, cx, cy, r, s, fg, rays=True):
"""太阳:圆 + 8 道光芒(光芒可关)。"""
S = lambda v: v * s
self.d.ellipse([cx - r, cy - r, cx + r, cy + r], outline=fg, width=2)
if rays:
for a in range(0, 360, 45):
rad = math.radians(a)
self.d.line([cx + math.cos(rad) * (r + S(3)), cy + math.sin(rad) * (r + S(3)),
cx + math.cos(rad) * (r + S(7)), cy + math.sin(rad) * (r + S(7))], fill=fg, width=2)
def _cloud(self, x, cy, s, fg, bg):
"""一朵云cy 为云体中线高度bg 填充内部以盖住其后的太阳光芒。"""
S = lambda v: v * s
self.d.ellipse([x + S(4), cy - S(2), x + S(20), cy + S(12)], fill=bg)
self.d.ellipse([x + S(14), cy - S(8), x + S(34), cy + S(12)], fill=bg)
self.d.rectangle([x + S(6), cy + S(4), x + S(34), cy + S(12)], fill=bg)
self.d.arc([x + S(2), cy - S(4), x + S(22), cy + S(14)], 90, 270, fill=fg, width=2)
self.d.arc([x + S(12), cy - S(10), x + S(36), cy + S(14)], 270, 90, fill=fg, width=2)
self.d.line([x + S(9), cy + S(13), x + S(29), cy + S(13)], fill=fg, width=2)
def _weather_icon(self, x, y, category, s=1.0, fg=BLACK, bg=WHITE):
"""按天气类别画图标s 为缩放系数,原生约 36x38左上角 x,y"""
S = lambda v: v * s
if category == "sunny":
self._sun(x + S(18), y + S(19), S(11), s, fg)
return
if category == "clear_night": # 月牙:实心圆挖去偏上右的背景圆
mx, my, r = x + S(18), y + S(19), S(13)
self.d.ellipse([mx - r, my - r, mx + r, my + r], fill=fg)
ox, oy, r2 = mx + S(6), my - S(5), S(12)
self.d.ellipse([ox - r2, oy - r2, ox + r2, oy + r2], fill=bg)
return
if category == "overcast": # 阴:单独一朵云,居中偏下
self._cloud(x, y + S(20), s, fg, bg)
return
if category == "fog": # 雾/霾/沙尘:横向雾线,长短交错
for i, dy in enumerate((9, 17, 25, 33)):
inset = S(5) if i % 2 else 0
self.d.line([x + inset, y + S(dy), x + S(34) - inset, y + S(dy)], fill=fg, width=2)
return
if category != "rain" and category != "snow" and category != "thunder": # 多云及未识别:太阳 + 云
self._sun(x + S(10), y + S(9), S(6), s, fg)
self._cloud(x, y + S(24), s, fg, bg)
return
# 以下三类:云在上,降水/天气符号在云下方
cb = y + S(15) + S(13) # 云底 y
self._cloud(x, y + S(15), s, fg, bg)
if category == "rain":
for dx in (9, 18, 27):
self.d.line([x + S(dx) + S(3), cb + S(3), x + S(dx) - S(1), cb + S(10)], fill=fg, width=2)
elif category == "snow":
for dx in (10, 19, 28):
fx, fy, rr = x + S(dx), cb + S(7), S(4)
for a in (0, 60, 120):
rad = math.radians(a)
self.d.line([fx - math.cos(rad) * rr, fy - math.sin(rad) * rr,
fx + math.cos(rad) * rr, fy + math.sin(rad) * rr], fill=fg, width=1)
elif category == "thunder":
bx = x + S(18)
self.d.polygon([(bx - S(1), cb + S(2)), (bx + S(5), cb + S(2)), (bx + S(1), cb + S(8)),
(bx + S(6), cb + S(8)), (bx - S(4), cb + S(18)), (bx, cb + S(9)),
(bx - S(4), cb + S(9))], fill=fg)
# 线型样式:(实线段长, 间隙长);实线用 dash=0 表示连续
_STYLES = {"solid": (0, 0), "dashed": (7, 4), "dotted": (1, 2)}
def _styled_polyline(self, pts, style, fill=BLACK):
"""按线型(solid/dashed/dotted)沿折线绘制。点线/虚线靠等距步进采样。"""
dash, gap = self._STYLES.get(style, (0, 0))
if dash == 0: # 实线:直接连线
for (x0, y0), (x1, y1) in zip(pts, pts[1:]):
self.d.line([x0, y0, x1, y1], fill=fill, width=1)
return
period, drawn = dash + gap, 0.0 # drawn: 当前所在周期内已走过的距离
for (x0, y0), (x1, y1) in zip(pts, pts[1:]):
seg = math.hypot(x1 - x0, y1 - y0)
if seg == 0:
continue
ux, uy = (x1 - x0) / seg, (y1 - y0) / seg
t = 0.0
while t < seg:
phase = drawn % period
if phase < dash: # 处于“实”阶段 → 画一小段
step = min(dash - phase, seg - t)
sx, sy = x0 + ux * t, y0 + uy * t
ex, ey = x0 + ux * (t + step), y0 + uy * (t + step)
if dash <= 1: # 点线:画方点更醒目
self.d.point((round(sx), round(sy)), fill=fill)
else:
self.d.line([sx, sy, ex, ey], fill=fill, width=1)
t += step; drawn += step
else: # 处于“虚”阶段 → 跳过
step = min(period - phase, seg - t)
t += step; drawn += step
def _style_sample(self, x, y, style, w=22, fill=BLACK):
"""图例用的线型样例(一小段水平线)。"""
self._styled_polyline([(x, y), (x + w, y)], style, fill=fill)
def _trend_chart(self, y0, trend):
"""近30天三指标折线图标题(左) + 紧凑图例(右) + 折线 + X轴(仅月/日)。
各指标按自身极值独立归一化,故只表达趋势形状,不可横向比绝对值。"""
dates, series = trend["dates"], trend["series"]
leg_y = y0
# ---- 标题(左):■ 近30天趋势 ----
sq, sq_y = 6, leg_y + 4
self.d.rectangle([10, sq_y, 10 + sq, sq_y + sq], fill=BLACK)
self.text(10 + sq + 6, leg_y, "橘喵·30天趋势", self.F12)
# 无数据:画占位符 "--",跳过图例/折线/X轴
if not series or not dates or not any(s.get("data") for s in series):
self.ctext(self.W / 2, leg_y + 36, "--", self.F24)
return
# ---- 图例(右):紧凑、整体右对齐,从右往左逐项排布 ----
SAMP, G1, GAP = 16, 8, 10 # 样例线长 / 线与名间隙 / 图例项间隙
x = self.W - 10
for s in reversed(series):
self.rtext(x, leg_y, s["k"], self.F12) # 指标名右端对齐 x
x -= self._tw(s["k"], self.F12) + G1
self._style_sample(x - SAMP, leg_y + 7, s["style"])
x -= SAMP + GAP
# ---- 绘图区 ----
px0, px1 = 14, self.W - 10
py0, py1 = leg_y + 22, leg_y + 22 + 56 # 顶/底
self.hdash(px0, px1, py1, dash=2, gap=3) # 基线
n = len(dates)
xstep = (px1 - px0) / (n - 1) if n > 1 else 0
for s in series:
data = s["data"]
lo, hi = min(data), max(data)
span = (hi - lo) or 1
pad = 4 # 上下留白,避免贴边
pts = []
for i, v in enumerate(data):
x = px0 + xstep * i
yv = py1 - pad - (v - lo) / span * (py1 - py0 - 2 * pad)
pts.append((x, yv))
self._styled_polyline(pts, s["style"])
# ---- X 轴标签:仅首 / 中 / 末三个日期 ----
xl_y = py1 + 3
self.text(px0, xl_y, dates[0], self.F12)
self.ctext((px0 + px1) / 2, xl_y, dates[n // 2], self.F12)
self.rtext(px1, xl_y, dates[-1], self.F12)
# ---------- 入口 ----------
def render(self, data):
"""根据 data 渲染并返回 PIL.Image'L' 模式,纯 0/255"""
self.img = Image.new("L", (self.W, self.H), WHITE)
self.d = ImageDraw.Draw(self.img)
self.d.fontmode = "1" # 关闭抗锯齿,点阵锐利
dt, wt, mao = data["date"], data["weather"], data["mao"]
# A. Hero大时钟 + 日期(时钟上移使「顶部留白 ≈ 与日期间距」对称)
self.text(10, 0, dt["time"], self.F48, bold=2)
self.text(12, 64, f'{dt["week"]} {dt["greg"]}', self.F12)
# B. 天气:右对齐文字 + 居中的 28° + 放大右靠的图标
DIV_Y = 90 # 天气区底部分割线
loc, temp, cr = wt["loc"], wt["temp"], f'{wt["cond"]} {wt["range"]}'
self.rtext(390, 8, loc, self.F12)
self.rtext(390, 30, temp, self.F24, bold=1)
self.rtext(390, 64, cr, self.F12)
# 定位图标:置于 loc 文字左侧,留间距并与文字竖直居中
PIN_W, PIN_GAP = 8, 6
loc_pin_x = 390 - self._tw(loc, self.F12) - PIN_GAP - PIN_W
self._loc_pin(loc_pin_x, 10, PIN_W)
s = 1.5
iw, ih = 36 * s, 38 * s # 图标原生约 36x38
text_left = min(loc_pin_x,
390 - self._tw(temp, self.F24) - 1,
390 - self._tw(cr, self.F12))
icon_y = (DIV_Y - ih) / 2 # 顶部(0)与分割线正中:上下留白相等
self._weather_icon(text_left - 12 - iw, icon_y, self._weather_category(wt.get("icon")), s=s)
self.hdash(8, 392, DIV_Y)
# C. 橘喵今日经营:三列(数值 + 环比/同比)
self._title_bar(96, "橘喵·今日实时", mao["upd"])
cols = mao["cols"]; n = len(cols)
ax0, ax1 = 8, 392
cw = (ax1 - ax0) / n
for i in range(1, n):
self.vdash(ax0 + cw * i, 128, 192)
for i, c in enumerate(cols):
cx = ax0 + cw * (i + 0.5)
self.ctext(cx, 114, c["k"], self.F12)
self.ctext(cx, 130, c["v"], self.F24, bold=1)
self._cmp_line(cx, 160, "环比", c["hb"])
self._cmp_line(cx, 178, "同比", c["tb"])
self.hdash(8, 392, 200)
# D. 近30天三指标趋势折线图
self._trend_chart(204, mao["trend"])
return self.img
def render_to_file(self, data, path):
img = self.render(data)
import os
os.makedirs(os.path.dirname(path), exist_ok=True)
img.save(path)
return path