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>
This commit is contained in:
2026-09-14 17:34:41 +08:00
parent 3fa6bb5de6
commit 3656d8d933
3 changed files with 28 additions and 16 deletions

View File

@@ -60,7 +60,7 @@ EPD_HOST=epd400a44.local python3 main.py
- **`_trend_chart`当前隐藏**近30天三指标折线 `_styled_polyline()` solid/dashed/dotted 区分主指标(流水)红线各指标按自身极值独立归一化已保留但 `render()` 未调用想恢复即在 C 前挂回
- **天气图标全矢量手绘**无图片资源`_weather_category()` 把和风 icon 代码归并为 8 sunny/clear_night/cloudy/overcast/thunder/rain/snow/fog识别不了回退 cloudy再由 `_weather_icon()` `_sun`/`_cloud` 等基元按 1bit 描边画出改天气展示从这里动
- **`pusher.py`** 直连局域网墨水屏设备喵喵固件开源https://gitee.com/gxp666111/miaomiao`web/` 即设备自带管理页可浏览器打开 `http://<EPD_HOST>/` 调试同一套接口)。`push_image()` 先调 `GET /images` 拿画廊已存图片名单逐个 `POST /delete_image?name=`清空单张删除失败不阻塞后续 `POST /upload``Content-Type: image/png`body 为原始 PNG 字节 multipart触发设备转换+刷屏。**清空画廊是刻意设计**设备把每次 `/upload` 都追加存一份到画廊`GET /images` 可见不清理会越推越多且设备当前 `current_mode` slideshow 会轮播到旧图先清后传使画廊恒为 1 等价于始终静态展示最新一张。**清理非核心**拉取画廊列表本身失败也不阻塞后续 `/upload`跳过清理直接推)。`_request()` 对连接类异常`urllib.error.URLError` mDNS `.local` 域名解析瞬时失败内置重试`RETRY_TIMES=2`间隔 `RETRY_DELAY=2s`)——mDNS 基于组播 UDP偶发丢包很正常单次失败不该让整轮推送直接跳过历史上曾走 Zectrix 公网 API`POST /devices/{id}/display/image` + 设备 MAC + `ZECTRIX_API_KEY`现统一为局域网直连
- **`renderer_e1002.py`** reTerminal E1002 目标`E1002Renderer(DashboardRenderer)` **k=1** 复用组件字号 12/24/48 与三色版一致只重写版式与配色钩子基类抽出类属性 `PCT_TRACK`宽版置 0——百分比已是整数倍字号不需手动加字距基类另抽 `FPACE` pace 字体与标签 F13 解耦)。四段三道横规各段 y 由上一段实际底部推算A 顶部 090 **大时钟 F84 粗 3** `getbbox` 把墨迹顶沿放到 16底沿 79占满整段且保住原上//下边距其右 14px 起一列三行左缘对齐电池行温度湿度行`_status_battery`/`_status_env`)、星期/日期 F12按日期数字底沿与时钟**基线对齐**三行中心约 30.5/52.5/74.5行距 22天气右 x=790B 用量 `_usage_block`×2 `usage` 10386竖规 x=400 `usage_gpt` 414790`_label` 标题 + 右侧上次更新 HH:MM」(与今日实时同款文案 mock 示例数据」,行用基类紧凑 `_usage_row`行距 31宽版把 `bar["label"]` 长标签代入 `k``label_w` 按本块最长标签自适应C `_realtime` 今日实时五列全宽`mao.cols` 全部 `wide` 列内四行 `ctext`/`_cmp_line` 逐行水平居中—— F12 y0+21 / `_value` F24 小号 ¥/ y0+38 / 环比 y0+72 / 同比 y0+88按墨迹框标题 ~9px——2026-09-14 整列下移 4px ~5px 用户觉得挤 ~9px环比 ~6px竖规 y0+26102区块底 y0+104D `_btc_chart` BTC K 线`data.btc`标题按 `bar` 显示时K/日K」:右侧最新价 F12 + 口径 `chg_label`24h/1d+ 涨跌三角 + 涨跌幅F12 2026-09-14 加粗涨绿跌红每根蜡烛 1px 影线 + 实心实体槽位 60% 取奇数)、 绿 否则 右侧 66px 标签列放最高/最低/最新价最新价横贯一道点规标签避让顶底底部基线 + //末时间标签 `t`无数据居中 `--`)。用量块 `_usage_block` 返回末条进度条底部B/C 分割线取其 +16px用户反馈太挤)。配色钩子覆写`_usage_colors` 三档(≥`USAGE_WARN` |≤`USAGE_OK` 绿其间黑填充边框百分比文字三者同色)、`_pace_color` 三档(≥+`PACE_TOL` |≤−`PACE_TOL` 绿其间黑阈值由 `usage.ok`/`usage.pace_tol`来自 config `_usage_block` 内注入天气图标 `wet=BLUE` 让雨滴/雪花着蓝可选六色测试色条 `_color_strip``E1002_COLOR_STRIP=1` 打开默认关)。`write_page()` 写出固定 800×480 `index.html`零边距`image-rendering: pixelated`PNG 带时间戳参数防缓存)。**为何固定尺寸而非自适应**云端截图视口/DPR 不可控自适应会重排+抗锯齿产生中间色再被平台抖动固定页里的 1:1 PNG 每个像素本就是六色之一截图零损失——已用无头 Chrome `--window-size=800,480` 截图验证与 PNG 逐像素一致设备端 HTML 控件须铺满整个画布历史均已弃用k=2 整体放大版;「左用量/右今日实时/ 30 天折线;「用量大卡片撑满
- **`renderer_e1002.py`** reTerminal E1002 目标`E1002Renderer(DashboardRenderer)` **k=1** 复用组件字号 12/24/48 与三色版一致只重写版式与配色钩子基类抽出类属性 `PCT_TRACK`小屏 F16 百分比的手动字距宽版置 0—— F16 映射到 12px加粗后由 `_track()` 自动加 1px 字距基类另抽 `FPACE` pace 字体与标签 F13 解耦)。四段三道横规各段 y 由上一段实际底部推算A 顶部 090 **大时钟 F84 粗 3** `getbbox` 把墨迹顶沿放到 16底沿 79占满整段且保住原上//下边距其右 14px 起一列三行左缘对齐电池行温度湿度行`_status_battery`/`_status_env`)、星期/日期 F12按日期数字底沿与时钟**基线对齐**三行中心约 30.5/52.5/74.5行距 22天气右 x=790B 用量 `_usage_block`×2 `usage` 10386竖规 x=400 `usage_gpt` 414790`_label` 标题 + 右侧上次更新 HH:MM」(与今日实时同款文案 mock 示例数据」,行用基类紧凑 `_usage_row`行距 31宽版把 `bar["label"]` 长标签代入 `k``label_w` 按本块最长标签自适应C `_realtime` 今日实时五列全宽`mao.cols` 全部 `wide` 列内四行 `ctext`/`_cmp_line` 逐行水平居中—— F12 y0+21 / `_value` F24 小号 ¥/ y0+38 / 环比 y0+72 / 同比 y0+88按墨迹框标题 ~9px——2026-09-14 整列下移 4px ~5px 用户觉得挤 ~9px环比 ~6px竖规 y0+26102区块底 y0+104D `_btc_chart` BTC K 线`data.btc`标题按 `bar` 显示时K/日K」:右侧最新价 F12 + 口径 `chg_label`24h/1d+ 涨跌三角 + 涨跌幅F12 2026-09-14 加粗涨绿跌红每根蜡烛 1px 影线 + 实心实体槽位 60% 取奇数)、 绿 否则 右侧 66px 标签列放最高/最低/最新价最新价横贯一道点规标签避让顶底底部基线 + //末时间标签 `t`无数据居中 `--`)。用量块 `_usage_block` 返回末条进度条底部B/C 分割线取其 +16px用户反馈太挤)。配色钩子覆写`_usage_colors` 三档(≥`USAGE_WARN` |≤`USAGE_OK` 绿其间黑填充边框百分比文字三者同色)、`_pace_color` 三档(≥+`PACE_TOL` |≤−`PACE_TOL` 绿其间黑阈值由 `usage.ok`/`usage.pace_tol`来自 config `_usage_block` 内注入天气图标 `wet=BLUE` 让雨滴/雪花着蓝可选六色测试色条 `_color_strip``E1002_COLOR_STRIP=1` 打开默认关)。`write_page()` 写出固定 800×480 `index.html`零边距`image-rendering: pixelated`PNG 带时间戳参数防缓存)。**为何固定尺寸而非自适应**云端截图视口/DPR 不可控自适应会重排+抗锯齿产生中间色再被平台抖动固定页里的 1:1 PNG 每个像素本就是六色之一截图零损失——已用无头 Chrome `--window-size=800,480` 截图验证与 PNG 逐像素一致设备端 HTML 控件须铺满整个画布历史均已弃用k=2 整体放大版;「左用量/右今日实时/ 30 天折线;「用量大卡片撑满
- **`server.py`** E1002 动态服务stdlib `ThreadingHTTPServer`无第三方依赖)。`build_data()` `(data, 各路状态)`日期按 `config.TZ_NAME` 时区现算四路上游天气/橘喵/BTC/设备遥测 `_cached(key, ttl, fetch)`——`STATE.last_good[key]` 未过期`time - STATE.fetched_at[key] < ttl`直接用过期的**并行**提交到线程池 `_POOL``_refresh` `data.weather_fetch()`/`data.mao_fetch(now, with_trend=False)`/`data.btc_fetch()`成功更新数据与时间戳`build_data` `concurrent.futures.wait` 整体最多等 **`FETCH_BUDGET`** 默认 5`.env` 可调再由 `_settle` 结算完成`fetched(0.27s)`带耗时/失败沿用旧值 `stale`/从未成功占位 `none`**未完成 `slow→stale`用上一次成功值 `slow→none`占位**拉取线程继续跑完写入缓存供下次请求stale-while-revalidate`STATE.inflight[key]` 保证同一路进行中不重复实拉响应上限 预算 + 渲染任一路上游卡死socket 超时和风/橘喵 30sBTC 8s不再拖垮整图。**2026-09-14 前为串行实拉**三路全拉约 1.12.6s单路卡住即整体超时改并行后三路全拉约 0.75s取最慢一路)。日志每行末尾 `[weather:cache(12s) mao:fetched(0.06s) btc:slow→stale]` 可直接看命中/耗时/超预算情况`/health` `cache_age_s`/`ttl_s`/`fetch_budget_s`/`inflight`用量取 `STATE.raw`推送暂存)→ `data._usage(now, raw=…)`/`data._usage_gpt(now, raw=…)`未推送时 Claude `--`ChatGPT mock端点`GET IMAGE_PATH` `render_png_bytes()``_RENDER_LOCK` 串行化Pillow 渲染器非线程安全`GET HTML_PATH` `html_renderer.render_html()``GET HTML_PNG_PATH` `html_shot.screenshot_html()`+`quantize6()``?raw=1` 跳过量化`GET /health` JSON`POST /push/usage|/push/usage_gpt`body=原始快照 JSON,≤64KB`X-Push-Token`==`PUSH_TOKEN`,为空拒绝);`GET /push/*` 查看暂存同令牌)。所有响应 `Cache-Control: no-store`每请求打一行日志 `CF-Connecting-IP`)。`PUSH_STATE_PATH` 非空时推送数据落盘原子替换重启恢复图片永不落盘实测三路全实拉约 1.11.3s天气 0.28s橘喵 0.06sOKX 0.7s 串行全部命中缓存约 0.02s渲染 20ms + 编码 14ms)。
- **`html_shot.py`** 方案三`screenshot_html(html)` 用无头 Chrome`find_chrome()` 自动探测或 `CHROME_BIN`Linux root `CHROME_EXTRA_ARGS=--no-sandbox` HTML 截成 800×480临时目录用后即删`_SHOT_LOCK` 串行化**切勿传 `--user-data-dir` 指向全新目录**——新版 `--headless=new` 对空 profile 首启会卡死到超时实测 A/B/D/E 全超时只有不带它的 C 2 秒完成)。`quantize6(img)`每通道二值化阈值 `HTML_PNG_THRESHOLD` 默认 160偏向保墨迹细笔画浅灰边归黑而非丢白)→ Pillow 调色板 `quantize(dither=NONE)` 映射到 `SPECTRA6`输出保证只含六色服务端 `?raw=1` 返回未量化截图供对照实测单次约 3.3sChrome 启动为主)。
- **`html_renderer.py`** 方案二网页版 HTML+CSS+内联 SVG JS数据服务端写死字体栈 PingFang/微软雅黑/Noto Sans SC + Google Fonts 兜底平台截图机若无中文字体会掉字能否加载取决于其出网能力颜色只用六色纯值配色规则与 PNG 版一致用量/pace 三档涨红跌黑K 线涨绿跌红版式同构K 线标题右侧价格用 `<strong>``h3 b::before` 的方块装饰只给标题)。
@@ -76,7 +76,7 @@ EPD_HOST=epd400a44.local python3 main.py
- **`DashboardRenderer(k=…)` 缩放系数**所有组件`_label`/`_cmp_line`/`_usage_row`/`_pace`/`tri` 的像素常量与伪粗体偏移都乘 `k`字体按 `12*k/24*k/48*k` 创建`k=1` 400×300 原版改组件后务必用固定夹具比对 `k=1` 输出逐像素不变E1002 版当前用 `k=1`k=2 整体放大在 800×480 上字过大已否决k 参数保留供更高分辩率设备使用字体属性名`F12/F24/F48`表达层级而非绝对像素
- 字体固定用 **Fusion Pixel 12px 点阵字体**`fonts/`OFL 协议首选整数倍尺寸 **12/24/48**`F12/F24/F48`最锐利用量区为拿到合适的字号层级另用了 **F13/F16**1.08x/1.33x 非整数倍笔画略不均属有意取舍 `d.fontmode = "1"` 关抗锯齿仍保持纯色不糊跨字号竖直居中统一用 `_mid()` `getbbox` 墨迹中心对齐)。
- 加粗用伪粗体」:`_draw()` 1~bold px 水平偏移叠绘笔画点阵字体只有单一字重)。
- 加粗用伪粗体」:`_draw()` 1~bold px 水平偏移叠绘笔画点阵字体只有单一字重)。**12px 加粗自动加 1px 字距**`_track()`2026-09-1412px 字形之间只有 1 列空隙伪粗体右偏 1px 正好填满相邻字粘连13/24px 空隙 2 列不受影响`text/rtext/ctext/_mid` 内部都走它宽版映射到 12px F13/F16/FPACE 同样命中手动推进 x 或居中时用 `_tw_b(s, ft, bold)` 取含粗体偏移与字距的实际占宽别再手写 `_tw + bold`
- 推送时 `DITHER = False`硬阈值纯黑白图最锐利勿改成 `true`
- 坐标字号均为像素网格上的硬编码常量调布局时注意各区分隔线 `hdash` y 值与下方组件位置联动

View File

@@ -75,14 +75,25 @@ class DashboardRenderer:
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._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(s, ft) - bold, y, s, ft, fill, bold)
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(s, ft) + bold) / 2, y, s, ft, fill, bold)
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
@@ -108,8 +119,8 @@ class DashboardRenderer:
"""环比/同比一行的总宽度(标签 + 间隙 + 三角 + 数值),供居中/右对齐排布。"""
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(val, self.F12) + vb
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 时不画三角。"""
@@ -151,9 +162,9 @@ class DashboardRenderer:
k = self.k
GAP = 1 * k # 小号段与主数字的间距
sb = bold # 小号段同样伪粗体,否则挨着粗体主数字显得单薄
wp = self._tw(pre, self.F12) + sb + GAP if pre else 0
ws = GAP + self._tw(suf, self.F12) + sb if suf else 0
wn = self._tw(num, self.F24) + 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])
@@ -182,6 +193,7 @@ class DashboardRenderer:
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
@@ -196,7 +208,7 @@ class DashboardRenderer:
up = diff > 0
color = self._pace_color(diff)
txt = f"{abs(int(diff)):02d}"
tw = self._tw(txt, self.FPACE)
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
@@ -234,7 +246,7 @@ class DashboardRenderer:
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, PCT_TRACK) - G1
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

View File

@@ -111,7 +111,7 @@ class E1002Renderer(DashboardRenderer):
txt = f"{lvl}%" if lvl is not None else "--"
b = 1 if lvl is not None else 0 # 数字伪粗体、字号不变2026-09-14 用户要求);占位不加粗
self._mid(x, cy, txt, F, bold=b)
return x + self._tw(txt, F) + b
return x + self._tw_b(txt, F, b)
def _status_env(self, x, cy, dev):
"""状态项·环境F12数字伪粗体墨迹中心 cy温度计细管 3×8 + 底部球 7×7管内水银线温度 蓝色水滴(上尖下圆)+湿度,
@@ -125,7 +125,7 @@ class E1002Renderer(DashboardRenderer):
t = dev.get("temp")
txt = f"{t:.1f}" if t is not None else "--"
b = 1 if t is not None else 0 # 数字伪粗体、字号不变2026-09-14 用户要求);占位不加粗
self._mid(x, cy, txt, F, bold=b); x += self._tw(txt, F) + b + GAP
self._mid(x, cy, txt, F, bold=b); x += self._tw_b(txt, F, b) + GAP
self.d.polygon([(x + 4, cy - 6), (x, cy + 2), (x + 8, cy + 2)], fill=BLUE)
self.d.ellipse([x, cy - 1, x + 8, cy + 6], fill=BLUE)
x += 9 + GAP_I
@@ -133,7 +133,7 @@ class E1002Renderer(DashboardRenderer):
txt = f"{h:.0f}%" if h is not None else "--"
b = 1 if h is not None else 0
self._mid(x, cy, txt, F, bold=b)
return x + self._tw(txt, F) + b
return x + self._tw_b(txt, F, b)
def _color_strip(self, y0, y1, x0=None, x1=None):
"""六色测试色条:六块等宽实心色块,白块加 1px 黑框以便可见。真机校准用。"""
@@ -239,7 +239,7 @@ class E1002Renderer(DashboardRenderer):
col = GREEN if chg >= 0 else RED
xr = x1
pct_txt = f"{abs(chg):.2f}%"
self.rtext(xr, y0, pct_txt, self.F12, fill=col, bold=1); xr -= self._tw(pct_txt, self.F12) + 1 + 4 # 涨跌幅伪粗体+1 为粗体偏移)
self.rtext(xr, y0, pct_txt, self.F12, fill=col, bold=1); xr -= self._tw_b(pct_txt, self.F12, 1) + 4 # 涨跌幅伪粗体
self.tri(xr - 4, y0 + 8.5, 7, up=chg >= 0, fill=col); xr -= 8 + 6
if btc.get("chg_label"): # 涨跌幅口径24h / 1d
self.rtext(xr, y0, btc["chg_label"], self.F12); xr -= self._tw(btc["chg_label"], self.F12) + 10