Files
eink-push/html_renderer.py
YANG JIANKUAN 3fa6bb5de6 feat: 今日实时 0% 画等号+金额小号 ¥/万,数值与状态栏数字加粗
- 环比/同比增长率恰为 0 时方向记 flat,三角位画等号(与用量行 pace 持平一致)
- 金额拆为「¥ / 主数字 / 万」三段,¥ 与 万 用 F12 小号伪粗体、与 F24 主数字基线对齐;单量列不变
- 环比/同比数值、BTC 涨跌幅、顶部电量/温度/湿度数字改 F12 伪粗体(字号不变),占位 -- 不加粗
- 等号绘制抽为基类 _eq 供 pace 与环比/同比共用;网页版 flat 同样输出等号

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

186 lines
11 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 -*-
"""
方案二800x480 的**现代网页**版仪表盘server.py 的 HTML_PATH 每次请求现生成),供 SenseCraft HTML 控件用无头浏览器截图。
与 PNG 版renderer_e1002的差别
- 不限点阵字体系统中文字体栈PingFang / Microsoft YaHei / Noto Sans SC+ Google Fonts 的 Noto Sans SC 兜底
(平台截图机若无中文字体会掉字,网络字体是保险;能否加载取决于其出网能力)。
- 纯 HTML+CSS+内联 SVG**不依赖 JavaScript**(平台是否执行 JS 未证实),数据全部服务端写死进 HTML。
- 颜色只用六色纯值 #000/#fff/#f00/#0f0黄/蓝未用),抗锯齿边缘交给平台抖动——这正是要和 PNG 版对比的点。
- 版式与 PNG 版同构:顶部时钟/天气;左 Claude Usage、右 Codex Usage橘喵今日实时三列BTC 时 KSVG
- 配色规则与 PNG 版一致:用量条 ≥warn 红 / ≤ok 绿 / 其间黑pace ≥+tol 红 / ≤tol 绿 / 其间黑;环比同比 涨绿 跌红(与 K 线一致K 线 涨绿 跌红。
"""
from html import escape as _e
RED, GREEN, BLACK = "#f00", "#0f0", "#000"
_CSS = """
*{box-sizing:border-box;margin:0;padding:0}
html,body{width:800px;height:480px;overflow:hidden;background:#fff;color:#000;
font-family:"PingFang SC","Microsoft YaHei","Noto Sans SC","Helvetica Neue",Arial,sans-serif;
-webkit-font-smoothing:antialiased}
.page{width:800px;height:480px;padding:12px 16px 10px;display:flex;flex-direction:column;gap:6px}
.hd{display:flex;justify-content:space-between;align-items:flex-end;height:92px;border-bottom:2px solid #000;padding-bottom:6px}
.clock{font-size:72px;font-weight:800;line-height:1;letter-spacing:-2px;font-variant-numeric:tabular-nums}
.date{font-size:15px;margin-top:4px;color:#000}
.wx{text-align:right;display:flex;align-items:flex-end;gap:14px}
.wx .t{font-size:44px;font-weight:800;line-height:1}
.wx .c{font-size:15px;line-height:1.45}
.wx .loc{font-size:13px;color:#000}
.pin{display:inline-block;width:9px;height:9px;border-radius:50% 50% 50% 0;background:#f00;transform:rotate(-45deg);margin-right:4px;vertical-align:-1px}
.row{display:flex;gap:16px}
.card{flex:1;min-width:0}
.card h3{font-size:14px;font-weight:700;display:flex;justify-content:space-between;align-items:baseline;margin-bottom:6px}
.card h3 em{font-style:normal;font-weight:400;font-size:12px}
.card h3 b::before{content:"";display:inline-block;width:9px;height:9px;background:#000;margin-right:6px;vertical-align:-1px}
.u{display:grid;grid-template-columns:64px 1fr 58px 44px;align-items:center;column-gap:8px;height:26px}
.u .k{font-size:13px;font-weight:700;font-family:"SF Mono",Menlo,Consolas,monospace}
.bar{height:11px;border:1.5px solid #000;position:relative;background:#fff}
.bar i{position:absolute;left:0;top:0;bottom:0;background:#000}
.u .p{font-size:19px;font-weight:800;text-align:right;font-variant-numeric:tabular-nums;line-height:1}
.u .pace{font-size:13px;font-weight:700;font-variant-numeric:tabular-nums;white-space:nowrap}
.tri{display:inline-block;width:0;height:0;border-left:5px solid transparent;border-right:5px solid transparent;vertical-align:1px;margin-right:2px}
.tri.up{border-bottom:8px solid currentColor}.tri.dn{border-top:8px solid currentColor}
.rt{border-top:2px solid #000;border-bottom:2px solid #000;padding:6px 0 4px}
.rt .cols{display:flex}
.rt .col{flex:1;text-align:center;border-left:1px dashed #000;padding:2px 0}
.rt .col:first-child{border-left:0}
.rt .name{font-size:13px;margin-bottom:2px}
.rt .grp{display:inline-flex;align-items:center;gap:12px}
.rt .v{font-size:36px;font-weight:800;line-height:1;font-variant-numeric:tabular-nums}
.rt .cmp{text-align:left;font-size:12px;line-height:1.5;font-variant-numeric:tabular-nums}
.btc h3{margin-bottom:2px}
.btc svg{display:block}
"""
def _tier_bar(bar, usage):
"""用量条颜色三档 绿/黑/红(与 renderer_e1002 一致)。返回 (填充/边框色, 百分比文字色),两者同色。"""
pct = bar.get("pct")
if not isinstance(pct, (int, float)):
return BLACK, BLACK
c = RED if pct >= usage.get("warn", 80) else GREEN if pct <= usage.get("ok", 30) else BLACK
return c, c
def _tier_pace(diff, usage):
tol = usage.get("pace_tol", 10)
return RED if diff >= tol else GREEN if diff <= -tol else BLACK
def _usage_card(usage):
note = "示例数据" if usage.get("mock") else (f'上次更新 {usage["updated"]}' if usage.get("updated") else "")
rows = []
for b in usage.get("bars", []):
pct = b.get("pct")
ok = isinstance(pct, (int, float))
fill, ptxt = _tier_bar(b, usage)
width = max(0, min(100, int(pct))) if ok else 0
pace = ""
if ok and "time_pct" in b:
diff = int(pct) - int(b["time_pct"])
col = _tier_pace(diff, usage)
tri = '<i class="tri up"></i>' if diff > 0 else ('<i class="tri dn"></i>' if diff < 0 else "")
pace = f'<span class="pace" style="color:{col}">{tri}{abs(diff):02d}</span>'
else:
pace = '<span class="pace"></span>'
rows.append(
f'<div class="u"><span class="k">{_e(b.get("label") or b["k"])}</span>'
f'<span class="bar" style="border-color:{fill}"><i style="width:{width}%;background:{fill}"></i></span>'
f'<span class="p" style="color:{ptxt}">{f"{int(pct):02d}%" if ok else "--"}</span>{pace}</div>')
return (f'<section class="card"><h3><b>{_e(usage.get("title", "Usage"))}</b><em>{_e(note)}</em></h3>'
+ "".join(rows) + "</section>")
def _cmp(label, pair):
direction, val = pair
col = GREEN if direction == "up" else RED if direction == "down" else BLACK
tri = ('<i class="tri up"></i>' if direction == "up" else '<i class="tri dn"></i>' if direction == "down"
else "= " if direction == "flat" else "") # flat增长率恰为 0等号表示持平与 PNG 版一致)
return f'<div>{_e(label)} <span style="color:{col}">{tri}{_e(val)}</span></div>'
def _realtime(mao):
cols = "".join(
f'<div class="col"><div class="name">{_e(c["k"])}</div><div class="grp"><span class="v">{_e(c["v"])}</span>'
f'<span class="cmp">{_cmp("环比", c["hb"])}{_cmp("同比", c["tb"])}</span></div></div>'
for c in mao["cols"])
return (f'<section class="rt card"><h3><b>橘喵·今日实时</b><em>上次更新 {_e(mao["upd"])}</em></h3>'
f'<div class="cols">{cols}</div></section>')
def _fmt_price(v):
return f"{v:,.0f}" if abs(v) >= 1000 else f"{v:.2f}"
def _btc(btc, W=768, H=118):
"""BTC K 线 → 内联 SVG涨绿跌红坐标逻辑同 renderer_e1002._btc_chart。"""
btc = btc or {}
candles = btc.get("candles") or []
kind = "时K" if btc.get("bar", "1H") == "1H" else "日K"
head = f'<b>{_e(btc.get("symbol") or "BTC/USDT")}·{kind}</b>'
right = ""
if candles and btc.get("last") is not None:
chg = btc.get("chg") or 0.0
col = GREEN if chg >= 0 else RED
tri = '<i class="tri up"></i>' if chg >= 0 else '<i class="tri dn"></i>'
right = (f'<em><strong style="font-size:14px">{_fmt_price(btc["last"])}</strong>&nbsp;&nbsp;{_e(btc.get("chg_label") or "")}'
f'&nbsp;<span style="color:{col};font-weight:700">{tri}{abs(chg):.2f}%</span></em>')
if not candles:
return f'<section class="btc card"><h3>{head}</h3><div style="text-align:center;font-size:24px;padding:30px 0">--</div></section>'
LBL, TOP, BOT = 66, 6, 16
px0, px1 = 0, W - LBL
py0, py1 = TOP, H - BOT
hi_raw, lo_raw = max(c["h"] for c in candles), min(c["l"] for c in candles)
pad = (hi_raw - lo_raw or 1) * 0.04
lo, hi = lo_raw - pad, hi_raw + pad
Y = lambda v: py1 - (v - lo) / (hi - lo) * (py1 - py0)
n = len(candles)
slot = (px1 - px0) / n
bw = max(3, int(slot * 0.6)) | 1
parts = [f'<svg width="{W}" height="{H}" viewBox="0 0 {W} {H}" shape-rendering="crispEdges">',
f'<line x1="{px0}" y1="{py1}" x2="{px1}" y2="{py1}" stroke="#000" stroke-dasharray="2 3"/>']
for i, c in enumerate(candles):
cx = round(px0 + slot * (i + 0.5))
col = GREEN if c["c"] >= c["o"] else RED
top, bot = sorted((round(Y(c["o"])), round(Y(c["c"]))))
parts.append(f'<line x1="{cx}" y1="{round(Y(c["h"]))}" x2="{cx}" y2="{round(Y(c["l"]))}" stroke="{col}"/>')
parts.append(f'<rect x="{cx - bw // 2}" y="{top}" width="{bw}" height="{max(1, bot - top)}" fill="{col}"/>')
last = candles[-1]["c"]
ly = round(Y(last))
parts.append(f'<line x1="{px0}" y1="{ly}" x2="{px1}" y2="{ly}" stroke="#000" stroke-dasharray="1 3"/>')
hi_y, lo_y = py0 + 5, py1 - 1
ty = min(max(ly + 4, hi_y + 13), lo_y - 13)
txt = 'font-size="12" font-family="SF Mono,Menlo,Consolas,monospace" text-anchor="end"'
parts.append(f'<text x="{W}" y="{hi_y}" {txt}>{_fmt_price(hi_raw)}</text>')
parts.append(f'<text x="{W}" y="{lo_y}" {txt}>{_fmt_price(lo_raw)}</text>')
parts.append(f'<text x="{W}" y="{ty}" {txt} font-weight="700">{_fmt_price(last)}</text>')
lab = lambda c: _e(c.get("t") or c.get("d") or "")
ax = 'font-size="11" font-family="SF Mono,Menlo,Consolas,monospace"'
parts.append(f'<text x="{px0}" y="{H - 3}" {ax}>{lab(candles[0])}</text>')
parts.append(f'<text x="{(px0 + px1) / 2}" y="{H - 3}" {ax} text-anchor="middle">{lab(candles[n // 2])}</text>')
parts.append(f'<text x="{px1}" y="{H - 3}" {ax} text-anchor="end">{lab(candles[-1])}</text>')
parts.append("</svg>")
return f'<section class="btc card"><h3>{head}{right}</h3>{"".join(parts)}</section>'
def render_html(d):
"""data dict同 renderer_e1002.render 的输入)→ 完整 HTML 字符串。"""
dt, wt = d["date"], d["weather"]
head = (
f'<header class="hd"><div><div class="clock">{_e(dt["time"])}</div>'
f'<div class="date">{_e(dt["week"])}&nbsp;&nbsp;{_e(dt["greg"])}</div></div>'
f'<div class="wx"><div class="c"><div class="loc"><span class="pin"></span>{_e(wt["loc"])}</div>'
f'{_e(wt["cond"])}<br>{_e(wt["range"])}</div><div class="t">{_e(wt["temp"])}</div></div></header>')
body = (head
+ f'<div class="row">{_usage_card(d["usage"])}{_usage_card(d.get("usage_gpt") or {"title": "Codex Usage"})}</div>'
+ _realtime(d["mao"])
+ _btc(d.get("btc")))
return ("<!doctype html><html lang=\"zh-CN\"><head><meta charset=\"utf-8\">"
"<meta name=\"viewport\" content=\"width=800, initial-scale=1\">"
"<meta http-equiv=\"Cache-Control\" content=\"no-store\">"
"<link rel=\"preconnect\" href=\"https://fonts.gstatic.com\" crossorigin>"
"<link href=\"https://fonts.googleapis.com/css2?family=Noto+Sans+SC:wght@400;700;800&display=swap\" rel=\"stylesheet\">"
f"<title>eink dashboard</title><style>{_CSS}</style></head><body><div class=\"page\">{body}</div></body></html>")