Files
eink-push/renderer.py
YANG JIANKUAN 3656d8d933 fix: 12px 伪粗体文字自动加 1px 字距,消除相邻字粘连
- 12px 点阵字形间只有 1 列空隙,伪粗体右偏 1px 正好填满导致左右重叠;13/24px 空隙 2 列不受影响
- 基类新增 _track():12px 且加粗时自动加 k 字距,text/rtext/ctext/_mid 统一走它,宽版映射到 12px 的 F13/F16/FPACE 同样命中
- 新增 _tw_b() 取含粗体偏移与字距的实际占宽,替换环比/同比居中、金额前后缀、状态栏推进、BTC 涨跌幅、用量行进度条右端的手写宽度
- 夹具回归:小屏 F13/F16 部分逐像素不变,各行进度条右端仍齐平

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-14 17:34:41 +08:00

531 lines
30 KiB
Python
Raw 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 三色(黑/白/红)墨水屏仪表盘。
设计要点:
- 三色 BWR仍无真灰阶灰阶只能抖动成网点弃用底色黑白靠留白 + 大字号 + 一处大红块建层级。
克制而有分量:全图只一个「大红」焦点(用量超阈值的整条红进度条),其余红为小面积语义点缀。
- 关键墨水屏配色铁律:红与黑同属「深色」,红叠黑对比极低 → 红只出现在**白底**(红字/红三角/红块),
**绝不红字压黑底**;白底红块 + 白字最稳。
- Fusion Pixel 点阵字体,按整数倍尺寸(12/24/48)渲染fontmode='1' 关闭抗锯齿 → 像素锐利。
- 重点数据用「伪粗体」(偏移叠绘)加粗,仍对齐像素网格。
布局(自上而下三段,两道横规分隔):
A 顶部:大时钟+日期(左)/ 天气(右),白底黑字 + 红点缀(定位红水滴、太阳/闪电红);
B 中部主角Claude Usage——每条单行 = 左标签(5h/7d,F13) + 进度条 + 百分比(F16,主数字) + 时间维度对比 pace(F13)
pct ≥ 阈值(warn) 时该条进度条(含边框)与百分比整体转红;
pace = 用量% 时间% ,用上/下三角(替代 +/)表示超前/节余,超前(↑)红、节余(↓)黑;
C 底部:橘喵今日实时三列,环比/同比「涨=红·跌=黑」。
近30天趋势图暂隐藏`_trend_chart` 及其依赖 `_styled_polyline`/`_style_sample` 保留、未调用,随时可再挂回)。
红出现处:① 定位水滴 ② 天气太阳/闪电 ③ 用量进度条与百分比(≥warn) ④ pace 超前(↑) ⑤ 环比/同比「涨」的三角与数值。
"""
import math
from PIL import Image, ImageDraw, ImageFont
import config
# 三色墨水屏(BWR)调色板:黑 / 白 / 纯正红。
# RED 用 (255,0,0) 作为设备红通道的明确信号——硬阈值量化下会干净地落到红色,不会被误判为黑。
# 设计准则:红是稀缺强调色,仅用于「方向/状态、警示、寻路锚点」,占墨比例应很小(≈全图 <10%),其余一律黑白。
BLACK, WHITE, RED = (0, 0, 0), (255, 255, 255), (255, 0, 0)
class DashboardRenderer:
# 百分比数字的字符间距(px乘 k)F16 为 16/12x 非整数倍缩放,数字墨迹无侧边距、相邻紧贴,需手动加距;
# 子类若把百分比换成原生 12px 字号(宽版),字形自带侧边距,应覆写为 0。
PCT_TRACK = 2
# 用量行左标签的字重与对齐:三色版 粗体、左对齐(贴 x0宽版覆写为 常规字重、右对齐(贴进度条左侧留 LABEL_GAP
LABEL_BOLD = True
LABEL_ALIGN = "l"
LABEL_GAP = 8
def __init__(self, font_path=config.FONT_PATH, size=(config.WIDTH, config.HEIGHT), k=1):
"""k整体缩放系数整数。k=1 为 400x300 原版k=2 供 800x480 设备reTerminal E1002复用组件
字体按 k 倍创建(点阵字体整数倍最锐利),组件内的像素常量一律乘 k故各组件在两种尺寸下形态一致。
字体属性名仍按 k=1 的字号命名F12/F24/…),表达「层级」而非绝对像素。"""
self.W, self.H = size
self.k = k
self.F12 = ImageFont.truetype(font_path, 12 * k)
self.F13 = ImageFont.truetype(font_path, 13 * k) # 5h/7d 与 pace 用;接近原生 12px畸变很小
self.F16 = ImageFont.truetype(font_path, 16 * k) # 非整数倍(1.33x):靠 fontmode='1' 保持纯色,笔画略不均可接受
self.FPACE = self.F13 # pace 数值字体;与标签解耦,宽版把它换成 24px 而标签仍 12px
self.F24 = ImageFont.truetype(font_path, 24 * k)
self.F36 = ImageFont.truetype(font_path, 36 * k) # 3 倍整数缩放,仍锐利;宽版今日实时数值用
self.F48 = ImageFont.truetype(font_path, 48 * k)
# ---------- 基础绘制工具 ----------
def _tw(self, s, ft, track=0):
# track额外字符间距(px)。非整数倍缩放字号(如 F16=16/12x)下,数字字形墨迹宽度
# 等于步进宽度(无侧边距),相邻字符几乎零间隙,需手动加距才不会紧贴。
if track:
return sum(self.d.textlength(c, font=ft) for c in s) + track * (len(s) - 1)
return self.d.textlength(s, font=ft)
def _draw(self, x, y, s, ft, fill, bold, track=0):
# 伪粗体:点阵字体单一字重,按 1~bold px 偏移叠绘加粗笔画
if not track:
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)
return
cx = x
for ch in s:
self.d.text((cx, y), ch, font=ft, fill=fill)
for dx in range(1, bold + 1):
self.d.text((cx + dx, y), ch, font=ft, fill=fill)
cx += self.d.textlength(ch, font=ft) + track
def _track(self, ft, bold, track=0):
"""实际字距:显式 track 优先否则「12px 且加粗」自动加 k——12px 点阵字形之间只有 1 列空隙,伪粗体右偏 1px 正好填满、
相邻字左右粘连13/24px 空隙有 2 列所以正常2026-09-14 用户要求拉开一点点。宽版 F13/F16/FPACE 都映射到 12px同样命中。"""
if track or not bold:
return track
return self.k if getattr(ft, "size", 0) == 12 * self.k else 0
def _tw_b(self, s, ft, bold=0, track=0):
"""含伪粗体偏移与自动字距的实际占宽(手动推进 x / 居中时用它,别再手写 _tw + bold"""
return self._tw(s, ft, self._track(ft, bold, track)) + bold
def text(self, x, y, s, ft, fill=BLACK, bold=0):
self._draw(x, y, s, ft, fill, bold, self._track(ft, bold))
def rtext(self, xr, y, s, ft, fill=BLACK, bold=0):
self._draw(xr - self._tw_b(s, ft, bold), y, s, ft, fill, bold, self._track(ft, bold))
def ctext(self, cx, y, s, ft, fill=BLACK, bold=0):
self._draw(cx - self._tw_b(s, ft, bold) / 2, y, s, ft, fill, bold, self._track(ft, bold))
def hdash(self, x0, x1, y, dash=4, gap=4, fill=BLACK, width=1):
x = x0
while x < x1:
self.d.line([x, y, min(x + dash, x1), y], fill=fill, width=width)
x += dash + gap
def vdash(self, x, y0, y1, dash=4, gap=4, fill=BLACK, width=1):
y = y0
while y < y1:
self.d.line([x, y, x, min(y + dash, y1)], fill=fill, width=width)
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_w(self, label, pair):
"""环比/同比一行的总宽度(标签 + 间隙 + 三角 + 数值),供居中/右对齐排布。"""
direction, val = pair
k = self.k
trw, tgap, vb = (8 * k, 4 * k, k) if direction else (0, 0, 0) # vb数值伪粗体占位 -- 不加粗)
return self._tw(label, self.F12) + 5 * k + trw + tgap + self._tw_b(val, self.F12, vb)
def _cmp_line(self, cx, y, label, pair, fg=BLACK):
"""环比/同比一行:标签 + 涨跌三角 + 数值,整体居中于 cx。direction 为 None 时不画三角。"""
self._cmp_at(cx - self._cmp_w(label, pair) / 2, y, label, pair, fg)
def _cmp_color(self, direction, fg=BLACK):
"""环比/同比「三角 + 数值」的颜色钩子:三色版 涨红、其余沿用 fg。宽版覆写。"""
return RED if direction == "up" else fg
def _eq(self, cx, cy, w, fill=BLACK):
"""等号「=」:两道横杠各宽 w、厚 k中间隔 2k整体以 (cx, cy) 为中心——占三角的位子表示「持平」。
用量行 pacediff=0与今日实时环比/同比(增长率恰为 0共用。"""
k = self.k
for top in (cy - 2 * k, cy + k):
self.d.rectangle([cx - w / 2, top, cx + w / 2 - 1, top + k - 1], fill=fill)
def _cmp_at(self, x, y, label, pair, fg=BLACK):
"""环比/同比一行,左对齐于 x宽版把它放在数值右侧时用
direction: up 上三角 / down 下三角 / flat 等号(增长率恰为 02026-09-14 用户要求,原来 0 也画上三角)/ None 不画。"""
direction, val = pair
k = self.k
lw = self._tw(label, self.F12); gap = 5 * k
trw, tgap = (8 * k, 4 * k) if direction else (0, 0)
self.text(x, y, label, self.F12, fill=fg); x += lw + gap
# 方向语义配色由 _cmp_color 决定(三色版:红涨、跌/无/持平 → 黑;宽版覆写为 涨绿跌红 与 K 线一致);标签始终黑
val_color = self._cmp_color(direction, fg)
if direction == "flat":
self._eq(x + trw / 2, y + 8 * k, 7 * k, fill=val_color); x += trw + tgap # 横杠落在第 6/9 行,中心 8 ≈ 数字墨迹中心 8.5
elif direction:
# 三角中心对齐 F12 数字墨迹竖直中心(y+8.5),否则箭头偏高
self.tri(x + trw / 2, y + 8.5 * k, 7 * k, up=direction == "up", fill=val_color); x += trw + tgap
# 数值伪粗体、字号不变2026-09-14 用户要求);标签常规字重;占位「--」不加粗(加粗会粘成一条长横)
self.text(x, y, val, self.F12, fill=val_color, bold=k if direction else 0)
def _value(self, cx, y, col, bold=1):
"""今日实时的数值:主数字 F24 粗,前缀(¥)/后缀F12 小号、与主数字基线对齐2026-09-14 用户要求「¥ 与 万 字号小一点」),
整体水平居中于 cx。col["vp"] 为 (前缀, 主数字, 后缀),缺失时退回整串 col["v"] 按 F24 画。"""
pre, num, suf = col.get("vp") or ("", col["v"], "")
k = self.k
GAP = 1 * k # 小号段与主数字的间距
sb = bold # 小号段同样伪粗体,否则挨着粗体主数字显得单薄
wp = self._tw_b(pre, self.F12, sb) + GAP if pre else 0
ws = GAP + self._tw_b(suf, self.F12, sb) if suf else 0
wn = self._tw_b(num, self.F24, bold)
x = cx - (wp + wn + ws) / 2.0
# 基线对齐F12/F24 数字墨迹底沿分别为 13k/26kgetbbox小号段下移其差值
ys = y + (self.F24.getbbox("0")[3] - self.F12.getbbox("0")[3])
if pre:
self.text(x, ys, pre, self.F12, bold=sb); x += wp
self.text(x, y, num, self.F24, bold=bold); x += wn
if suf:
self.text(x + GAP, ys, suf, self.F12, bold=sb)
def _label(self, y, title, right=None, x0=None, x1=None):
"""区块标题(两区块统一,对齐主分支 _title_bar 风格):
■ 方块装饰(8px与 F12 标题墨迹竖直居中) + 标题(F12,常规字重) + 可选右侧信息。
x0/x1标题起点 / 右侧信息右端,默认为页边距 10宽版并排两块时可各自指定"""
k = self.k
SQ, M = 8 * k, 10 * k # 方块边长 / 左右页边距
x0 = M if x0 is None else x0
x1 = self.W - M if x1 is None else x1
self.d.rectangle([x0, y + 4 * k, x0 + SQ, y + 4 * k + SQ], fill=BLACK) # 中心≈y+8对齐 F12 墨迹中心(≈y+8.5)
self.text(x0 + SQ + 6 * k, y, title, self.F12)
if right:
self.rtext(x1, y, right, self.F12)
def _mid(self, x, cy, s, ft, fill=BLACK, bold=0, align="l", track=0):
"""按字符串「墨迹竖直中心」对齐到 cy 绘制——跨字号(F12/F24)也能真正居中,
避免不同字号按顶部对齐时的视觉错位。align: l 左对齐 / r 右对齐 / c 居中于 x。
track见 _tw用于给紧贴的字符手动加间距。"""
_, t, _, b = ft.getbbox(s) # 该串墨迹在字身框内的上/下沿
y = cy - (t + b) / 2.0
track = self._track(ft, bold, track)
w = self._tw(s, ft, track)
if align == "r":
x -= w + bold
elif align == "c":
x -= (w + bold) / 2.0
self._draw(x, y, s, ft, fill, bold, track)
def _pace(self, xr, cy, diff):
"""时间维度对比pace = 用量% 时间%>0 超前↑红;<0 节余↓黑;=0 持平。
数值(FPACE默认 F13)两位补零、右端对齐 xr小三角(6)紧贴其左;均竖直居中 cy。返回整体宽度供左侧定位。
diff=0持平/无数据)三角位画一个与三角等宽的等号「=」2026-09-14 用户要求,原为留空,后曾用「-」——pace 列每行等宽,各行进度条右端齐平。"""
up = diff > 0
color = self._pace_color(diff)
txt = f"{abs(int(diff)):02d}"
tw = self._tw(txt, self.FPACE, self._track(self.FPACE, self.k)) # 宽版 FPACE=12px 加粗 → 含自动字距
self._mid(xr, cy, txt, self.FPACE, fill=color, bold=self.k, align="r")
TRI_GAP, TRI = 4 * self.k, 6 * self.k
cx = xr - tw - TRI_GAP - TRI / 2
if diff != 0:
self.tri(cx, cy, TRI, up=up, fill=color)
else:
self._eq(cx, cy, TRI, fill=color) # 等号与三角等宽
return tw + TRI_GAP + TRI
def _usage_row(self, x0, x1, y, bar, warn, label_w=None):
"""一条用量(单行):左标签(5h/7d, F13) + 进度条 + 百分比(F16, 主数字) + 时间维度对比 pace(F13)。
右侧从右往左排布显式控制两处横向间距G1 进度条↔百分比、G2 百分比↔pace全部竖直居中 cy。
pct ≥ warn → 进度条(含整条边框)与百分比转红;边框随条色,红条即红框,无黑白错位。
label_w标签列宽默认 26*k够放 5h/7d宽版有 "Fable 7d" 这类长标签时由调用方按最长标签算好传入。"""
raw = bar.get("pct")
ok = isinstance(raw, (int, float)) # None/缺失 → 显示 "--"
pct = max(0, min(100, int(raw))) if ok else 0
fill, outline, color = self._usage_colors(ok, pct, warn, bar)
k = self.k
LABEL_W = 26 * k if label_w is None else label_w
G1, G2, H = 13 * k, 8 * k, 15 * k # G1 进度条↔百分比、G2 百分比↔pace视觉约 12/11
PCT_TRACK = self.PCT_TRACK * k # 见类属性 PCT_TRACK
cy = y + 10 * k # 行竖直中心
lb = k if self.LABEL_BOLD else 0 # 左标签 F13
if self.LABEL_ALIGN == "r":
self._mid(x0 + LABEL_W - self.LABEL_GAP * k, cy, bar["k"], self.F13, bold=lb, align="r")
else:
self._mid(x0, cy, bar["k"], self.F13, bold=lb)
# 右侧从右往左pace(最右) → 百分比 → 进度条
pct_r = x1
if ok:
# 有百分比就必画 pace 列time_pct 缺失(窗口无重置时刻)按 0 差值画 "00",不能整列省掉——否则该行进度条会变长、右端错位
pace_w = self._pace(x1, cy, pct - int(bar["time_pct"]) if "time_pct" in bar else 0)
pct_r = x1 - pace_w - G2
pct_txt = f"{pct:02d}%" if ok else "--"
self._mid(pct_r, cy, pct_txt, self.F16, fill=color, bold=k, align="r", track=PCT_TRACK) # 百分比 F16
bx0 = x0 + LABEL_W
bx1 = pct_r - self._tw(pct_txt, self.F16, self._track(self.F16, k, PCT_TRACK)) - G1
by = cy - H // 2
self.d.rectangle([bx0, by, bx1, by + H], outline=outline, width=k) # 轨道边框随条色
fw = (bx1 - bx0) * pct / 100.0
if fw >= 2:
self.d.rectangle([bx0, by, bx0 + fw, by + H], fill=fill)
return bx0, bx1, by, H # 进度条几何(宽版合并行据此在边框内重绘上下两条实心填充)
def _usage_colors(self, ok, pct, warn, bar):
"""用量行配色钩子 → 进度条填充轨道边框百分比文字。三色版≥warn 整体红,否则黑。"""
c = RED if (ok and pct >= warn) else BLACK
return c, c, c
def _pace_color(self, diff):
"""pace用量% 时间%)配色钩子。三色版:>0 超前红,否则黑。"""
return RED if diff > 0 else BLACK
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, accent=None, wet=None):
"""按天气类别画图标s 为缩放系数,原生约 36x38左上角 x,y
accent 用于「有能量」的元素——太阳、闪电——着红wet 用于雨滴/雪花(六色版给蓝),默认随 fg
云/雾/月保持 fg"""
S = lambda v: v * s
accent = accent or fg
wet = wet or fg
if category == "sunny":
self._sun(x + S(18), y + S(19), S(11), s, accent)
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, accent) # 探出的太阳着红
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=wet, 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=wet, 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=accent) # 闪电着红作警示
# 线型样式:(实线段长, 间隙长);实线用 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):
c = RED if s.get("k") == "流水" else BLACK # 主指标(流水)图例同步着红
self.rtext(x, leg_y, s["k"], self.F12, fill=c) # 指标名右端对齐 x
x -= self._tw(s["k"], self.F12) + G1
self._style_sample(x - SAMP, leg_y + 7, s["style"], fill=c)
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:
color = RED if s.get("k") == "流水" else BLACK # 主指标(流水)为主角,着红领读
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"], fill=color)
if color == RED and pts: # 主指标最新值标红点:“今日 · 你在此处”
ex, ey = pts[-1]
self.d.ellipse([ex - 2.5, ey - 2.5, ex + 2.5, ey + 2.5], fill=RED)
# ---- 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'RGB' 模式,仅黑/白/红三色)。
版式(自上而下三段,两道横规分隔):
A 顶部:大时钟+日期(左)/ 天气(右)——白底黑字,红点缀(定位/太阳闪电)
B 中部主角Claude Code 用量——每条=标题行 + 一条粗进度条pct≥阈值转红预警
C 底部:橘喵今日实时三列(涨=红·跌=黑。近30天趋势图暂隐藏(_trend_chart 保留)。
"""
self.img = Image.new("RGB", (self.W, self.H), WHITE)
self.d = ImageDraw.Draw(self.img)
self.d.fontmode = "1" # 关闭抗锯齿点阵锐利RGB 上仍生效,纯色描边不发糊)
dt, wt, mao, usage = data["date"], data["weather"], data["mao"], data["usage"]
# ---- A. 顶部:大时钟+日期(左)/ 天气(右)。白底黑字,红点缀 ----
self.text(10, 0, dt["time"], self.F48, bold=2)
self.text(12, 64, f'{dt["week"]} {dt["greg"]}', self.F12)
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)
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, fg=RED) # 定位红水滴
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))
self._weather_icon(text_left - 12 - iw, (DIV_Y - ih) / 2,
self._weather_category(wt.get("icon")), s=s, accent=RED)
self.hdash(8, 392, DIV_Y)
# ---- B. 中部主角Claude Usage单行标签 + 进度条 + 百分比 + pace≥阈值转红----
# 标题贴近上分割线(与今日实时一致的 6px 间距);两条进度条在余下空间内等距(≈19px)
self._label(96, usage.get("title", "Claude Usage"))
warn = usage.get("warn", 80)
ry = 123
for b in usage.get("bars", []):
if b.get("scoped"): # 按模型限定的额度Fable 7d只在 800x480 宽版展示,小屏放不下第三行
continue
self._usage_row(10, 390, ry, b, warn)
ry += 31 # 略压缩两条间距,给底部让出下边距
self.hdash(8, 392, 187) # B/C 分割线上移,最终给底部今日实时留出与整图下边的间距
# ---- C. 底部:橘喵·今日实时(三列,版式对齐主分支:标题+18=名/+34=值/+64=环比/+82=同比)----
self._label(193, "橘喵·今日实时", right=f'上次更新 {mao["upd"]}')
cols = [c for c in mao["cols"] if not c.get("wide")]; n = len(cols) # 仅宽版的列(团购)小屏放不下,跳过
ax0, ax1 = 8, 392
cw = (ax1 - ax0) / n
for i in range(1, n):
self.vdash(ax0 + cw * i, 225, 289)
for i, c in enumerate(cols):
cx = ax0 + cw * (i + 0.5)
self.ctext(cx, 211, c["k"], self.F12) # 指标名(标题+18
self._value(cx, 227, c, bold=1) # 数值 F24 伪粗体 + 小号 ¥/万(+34
self._cmp_line(cx, 257, "环比", c["hb"]) # +64
self._cmp_line(cx, 275, "同比", c["tb"]) # +82墨迹止于≈289底部留≈10px
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