在 250×122 像素的墨水屏上写 JSX
目录
起因
上一篇 把 Pi Zero 2W 翻出来玩起来之后,墨水屏在桌角稳稳跑着——每分钟看一眼时间和电量挺顺。但代码我一直没翻开过:那是一坨 imperative PIL,逐行 draw.text((x, y), ...)、逐个图标用几何函数自己画。
不是它不能用,是我不熟。我本职写 web 多年,最近公司业务才拓展到 Electron 和 RN——脑子里对”画界面”这件事有强烈的前端肌肉记忆:盒子、flex、padding、gap。看着 PIL 那种”先算坐标再下笔”的写法,每次想动点东西都得在脑子里跑一遍坐标计算。
某天睡前躺床上看着桌角的屏,掏手机给 Hermes(我自己搭的 AI 助手,卧床用 DeepSeek,平时用手机喂它)发消息。
Hermes 起头
“给墨水屏做 UI,有没有现成的 UI 库?”
Hermes 说没有成熟的 Python widget 库,列了 LVGL 那些 C 嵌入式方向、Arduino 生态的东西,都不合适——大家做 Pi 墨水屏基本都是手撸 PIL。
我躺在床上看着这条回复,忽然冒出来一句:
“布局排版这事不就是前端吗?前端画界面 → 推到墨水屏行不行?”
Hermes 推无头浏览器:Playwright 起 Chromium、截图、抖动成 1-bit。我问”能不能做局刷?“——它说要做复杂的 vdom diff。太重,否了——Pi Zero 2W 512MB RAM,跑个 Chromium 头都凉。
我接着问:“有没有不截图的前端方案?”
Hermes 推 Satori——Vercel 那个 HTML+CSS → SVG 的引擎,本来是给 OG 图用的,输入是 JSX,输出是 SVG。配合 sharp 灰度化 → 阈值化,理论上能拿到 1-bit PNG。
(说起来 Satori 我并不是头一次听说,我这个博客的 OG 图就是 Satori 画的——之前让 AI 改造主题时见过一眼,有种”哦~,原来是这个”的感觉。这次又冒出来,挺意外的复用。)
让 Hermes 介绍 + 写个 demo,挂着等结果,等着等着我睡着了。
第二天傍晚让 Hermes 接续,把 demo 落进 home-pi 仓库,开了 explore/satori-eink 分支(833801d)。Hermes 给我搭了一个完整的 VPS 端 Satori 管线:JSX + Tailwind → Satori → SVG → sharp 灰度 → Floyd-Steinberg 抖动 → 1-bit PNG。
第一拐点:浏览器预览就糊了
我卧床用 Hermes,面对屏幕都是 Claude(DeepSeek 比 Claude 模型能力弱一截,分场景用就好)。Hermes 起完头我切到电脑,Claude 接力。
我让 Claude 把预览页跑起来看看,浏览器里 1-bit 输出就是糊的。还没上 Pi,光看预览图小字像有一层灰雾。
我跟 Claude 说”模糊”。它说是字体渲染问题。我顺着想:那干脆别走 SVG 这一步——既然 PIL 已经在 Pi 上跑得好好的(上一篇做的 eink-status 就是 PIL imperative),不如保留 PIL 当底层,上层用 vnode 树描述布局,自己写个胶水把 vnode 转成 PIL 能吃的绘制指令。
布局这一层我顺手把 Yoga 拉进来——Yoga 是 React Native 的底层布局引擎,Facebook 开源的 C++ flexbox 实现。我从 RN 业务里听说过这个名字,仅限于”知道它是干嘛的”——这次现学现卖。
凌晨 02:03
commit 6829a15 —— pivot 完毕,目录从 vps-satori-render/ 改名 eink-render/。睡了。
事后看 Claude 写的 EXPLORATION.md 才知道,SVG 光栅化 + 1-bit 是个死结:Satori 用 opentype.js 读字形轮廓,每个字符在 SVG 里是浮点坐标的 Bezier path,任何 SVG 光栅化器(resvg / librsvg / Cairo)在 250×122 像素网格上画浮点曲线,没 hinting 就只能给灰边——一根 1px 宽的笔画落在 x=10.3 时,70% 给像素 10、30% 给像素 11,两边都是灰,阈值化要么糊一边要么断笔。我那晚没想这么多,反正给了 Claude 方向,它换了实现,跑通了。到今天 FreeType MONO 这词儿我还说不清。
第二拐点:上班空闲,把 satori-html 也扔了
副业项目的节奏,全是工作缝隙 30 分钟一段。
第二天白天上班,工作 Claude 一个 session、树莓派 Claude 另一个 session,挂着用。下午某个空档我看代码(好吧承认我并不真的逐行看代码,我看的是总结文档),意识到第一版 pivot 时保留的 satori-html 这个包——本来是为了”保留 HTML+CSS 编写体验”——现在其实只剩 parser 这一个功能,Satori 整个生态都没用了,纯历史包袱。
让 Claude 改成 JSX 直接渲染(d180c66),下午 16:16
commit。砍掉一层运行时 parse,连依赖关系都干净一截。
严格说不是真 CSS
顶上那张图就是成品——6 页 PNG,每张 250×122,跨进程从 JSX 一路渲染下来。
标题写的是”在墨水屏上写 JSX”,但要澄清下:我们写的不是真 CSS,是 JSX + camelCase 内联 style,flexbox 子集。一个页面长这样:
function Overview({ p }) { return ( <Page> <div style={{ display: "flex", flexDirection: "column", flex: 1, justifyContent: "center", alignItems: "center", rowGap: 2, }}> <div style={{ fontSize: 42, fontWeight: 700 }}>{p.time}</div> <div style={{ display: "flex", columnGap: 6 }}> <div style={{ fontSize: 13 }}>{dateStr}</div> <div style={{ fontSize: 13 }}>{wdStr}</div> </div> </div> </Page> );}底下走的管线:
JSX → vnode (React 自动运行时,{type, props}) → normalizeTree() 摊平函数组件 + Fragment → Yoga calculateLayout() → emit ops JSON: [{op: "text", x, y, text, font, size}, ...] → stdin 喂给 Python daemon → PIL ImageDraw on mode='1' canvas (FreeType MONO 自动 hint) → 真 1-bit PNG(250×122,~1KB/页) → HTTP 返给 eink-status,丢给 e-paper 驱动上屏写起来跟 RN 没两样:flex / flexDirection / justifyContent / padding / gap / fontSize,加几个 e-ink 特有的(背景色只认黑白,灰色当黑处理)。
工程细节,几个 cool 的
Python daemon 化。第一版每次渲染 spawn 一个新 Python 进程,冷启 ~370ms(字体加载占大头)。改成 daemon 模式后,Node 端建一条长连接:请求一行 JSON、响应 OK <len>\n + 字节 PNG。热路径从 400ms 掉到 10ms 一页。
Node 版本锁 v22.22.2。Pi Zero 2W 是 armv7l(32 位)。Node 24+ 把 armv7 从 Tier 1 降到实验级、不再发预编译包,v22.22.2 是最后一个有官方 armv7l LTS 包的版本。bootstrap.sh 第 3 步固化了这个版本,新机一键复盘不会踩。
逐像素一致。开发机(Windows + Python 3.14)和 Pi(armv7l + Python 3.9.2)渲染出来的 6 页 PNG 逐像素一致——FreeType MONO 的 hint 行为跨平台稳定,Yoga 布局结果一致,字体度量一致。这意味着我可以在开发机上看预览改样式、上 Pi 不会出”开发机看着好上屏花”的惊喜。
唯一一个”诶?“的瞬间
是这次探索里最有体感的一刻:让 Claude 自己看 web 预览面板调视觉。
我开着 Vite 预览页,Claude 改完代码、贴一张当前页 PNG 回来,“你看这里图标位置对不对、间距要不要调”——它真的会去看那张图、给一轮修改建议、再贴下一版回来。它在自己的画布上自己迭代。
唯一遗憾的是 Claude 不是原生多模态,识别图像细节一般,有时贴 PNG 它说”我看不清你截的图的具体像素”。不影响用,但跟那种能直接”读像素”的模型比,差着一截。
顺手开了个 dashboard
几天后又冒出来的想法:dev 预览面板既然已经在了,干脆做个独立 SPA,proxy 到 Pi 的 /api/render——挂在我家飞牛 NAS 上,远程能随时看屏幕镜像和数据。
工程直觉拆成独立项目 projects/eink-dashboard/,React + Vite + Tailwind 4 + shadcn。还没部署,CI 还在规划——但拆开的好处是 Pi 端能独立先跑通,dashboard 上不上线无所谓。
设计上有一条原则:Pi 自治。Dashboard 是”可选远程工具”,断网照常刷屏,本机不依赖 dashboard 运行。Pi 也不存历史数据——想看趋势 dashboard 自己在浏览器内存里存。这条原则让架构边界很清晰。
几个老实话
- EXPLORATION.md 那份”探索日志 + 死路记录”完全是 Claude 写的,我没改一个字、说实话也没逐字读过。当时让它写就是想到可以写博客、怕自己回头记不清细节,它是我特意为这篇博客准备的素材。
- 代码我也没逐行审过。Claude 改完跑通了我就过——边角项目,行就行,不行回滚。
回头审视:这是不是为情怀绕的弯
诚实说,部分是。
- 代码量:从 566 行单文件 PIL(删掉的
render.py)涨到 ~1500 行 + 一整个 Node 栈 - 资源:Pi Zero 2W 512MB RAM 上多塞了 Node 进程 + Python daemon + Hono server
- 链路:数据要走 HTTP 才能变 PNG,多了一条故障线——所以才加了 3 次重试 + 白屏兜底
我也没认真考虑过另一条路:Python 一站式。Yoga 有官方 Python binding (yoga-py),完全可以在 Python 里写个 declarative DSL:
Box(direction="column", justify="center", children=[ Text(p.time, size=42, weight=700), Box(direction="row", gap=6, children=[Text(date_str), Text(week_str)]),])单语言、单进程、零 IPC、Pi 上不用装 Node。Yoga 还是同一个 Yoga,跨平台一致。代价是没 JSX 语法糖、不能复用 React 生态、Web preview 要另起 Flask。
为什么没走这条?因为我就是想用 JSX——这是 ergonomics 偏好,不是技术需要。一旦 JSX 是硬要求,Node 就被锁进来了。
到底值不值,本质上取决于这块小屏幕未来会变成什么。说实话我也没那么需要一个桌面小屏幕——这事从一开始就是为玩而玩,6 页内容也是嫌单调随手加的。后续如果只是这 6 页一直挂着,那 Node 栈完全是为情怀绕的;但如果哪天它真变成”家里智能家居状态板”或者”Agent 任务面板”(这是我对它的想象,没规划好),那 HTTP + dashboard 这条铺垫就是天然的。
yoga-py + Python DSL 那条路我记一笔,留给下次再折腾——折腾本身就是这个项目的目的。
体感
上一篇 我说”AI 让我敢做边角项目”。这一次的体感更进一步:边角项目也能搞出像模像样的工程——独立的渲染管线、daemon 化、跨进程协议、HTTP API、systemd 部署、CLI 工具、Web dashboard、探索日志。整个东西从睡前一个念头到 squash 进 main,前后两个晚上 + 一些工作缝隙。
但跟”自己写”完全是两种节奏:我没读过 vdom-to-ops.js 那 346 行代码,没看过 Yoga 怎么 calc 的 layout,PIL FreeType MONO 是啥到现在我也只能跟你复述”反正有 hinting,文字会落到像素整数格上”。我有方向、有判断、有否决权,但下面那一层我交出去了。
——这种交付边界,跟两年前完全不一样。两年前如果我说”前端渲染到墨水屏”,得自己去翻 Yoga 文档、自己读 PIL 源码、自己排 SVG 光栅化为啥糊。现在我说一句话、AI 给出方案、我选一个、它实施。23 个 commit 里大多数我只看 commit message 和 diff 上下文。
最后老实交代:这篇博客也是 Claude 写的。我做了一轮采访(它做编辑,问我”睡前为啥起念”、“切到 Claude 那 7 小时具体怎么发现糊的”、“两个 AI 怎么分工”),过了一遍大纲,给了写作调子(“不教育、不端着,让自己回头看觉得酷、让读者觉得我酷”),然后由它落地成你看到的这些字。我做了几处事实校正,定了标题。
写博客也是 AI 应用嘛。
入口提交:2302f24 — feat: eink-render 渲染管线 + eink-dashboard 拆分 + eink-status HTTP 集成
探索日志:projects/eink-render/EXPLORATION.md — 一份 Claude 替我写的死路记录。