我买了一台容大 RP325(Rongta RP325),一台支持 ESC/POS 的热敏小票打印机。起因是 Coding with Lewis 的视频《I Fixed My ADHD with a Receipt Printer》,他把待办任务一条条用小票机打出来,配套代码也开源在 ReceiptPrinterAgent。我想自己复刻一个,就先从给打印机写驱动开始,于是有了 morden-escpos。
它现在是一个 pnpm monorepo。npm 上的包是 morden-node-escpos,Node 和浏览器都能用。Go 版是 morden-escpos-go,吃同一套 JSON 打印任务。打印机旁边放了个小服务 print-webhook,HTTP 调一下就出票。Receipt Studio 是浏览器里的小票模板编辑器,静态导出后挂在 GitHub Pages 上,中文界面在这里。

ESC/POS 在传什么
热敏小票机走 ESC/POS,一串二进制指令,最早是 Epson 定的。HTML 和 PDF 它都不认。文本、对齐、走纸、切刀,都是往端口写字节。Epson 把命令参考放在这里。
驱动把 text('你好') 编成打印机认识的字节,再交给 USB、串口或者 9100 端口。链式 API 和 JSON 任务最后都落到这一层。
ESC/POS 发的是当前代码页的字节,不是 Unicode。国产机中文多是 GBK 或 GB18030,驱动用 iconv-lite 先转码再下发。
纸宽按列数算:58 mm 是 384 点、32 列,80 mm 是 576 点、48 列,按 Font A 12×24 算。
同样一条切纸或图片指令,Epson、佳博、芯烨、容大不一定画出一样的结果。兼容设备可以设 model: "qsprinter",默认 null 走通用 ESC/POS。高级指令我还是建议在自己的机器上打一张再算数。
架构
我自己用的时候把它分成三层,中间靠同一份 JSON 对齐。
传输层只负责把字节送出去:Node 是 USBAdapter,浏览器入口有 WebUSBAdapter、WebSerialAdapter、TcpSocketAdapter,Go 驱动是 USB、串口和 TCP 9100。WebUSB 要 Chrome 或 Edge,页面得是 HTTPS 或 localhost,选设备必须用户自己点一下。Web Serial 的浏览器和页面要求一样,还要自己选串口和波特率。Direct Sockets 只在 Chrome 的 Isolated Web App 里有,TcpSocketAdapter 才能连局域网打印机的 9100,普通网页标签开不了裸 TCP。图片指令也只收能跨域访问的 http(s) 地址。
指令层是 Printer 的链式调用,还有和它对得上的 JSON 命令,align、text、qrcode、cut 都在这一层,PrinterController 和 BrowserPrinterController 吃的是同一份 PrintJobJSON。
模板层在指令上面,{{variable}} 做替换,inputs 是一份 JSON Schema 子集,渲染前先校验,缺字段或类型不对就抛 TemplateInputValidationError,消息形如 $.key 应为 string,实际为 integer,这样不会打出一张「合计:¥undefined」。
USBAdapter 不传设备的话会自己挑第一台识别到的 USB 打印机。Linux 上找得到但打不开,多半是权限,按设备的 vendor/product 补一条 udev 规则就行。完整 API 见 README。
链式 API 我用来把流程写死。模板要存下来,数据还得换,Go 服务也要吃同一份任务,就改走 JSON。print-webhook 自带的 templates/receipt.json 长这样,必填是 orderNumber 和 total:
{
"name": "订单打印",
"config": { "encoding": "GB18030", "width": 48 },
"inputs": {
"type": "object",
"required": ["orderNumber", "total"],
"properties": {
"orderNumber": { "type": "string" },
"date": { "type": "string" },
"cashier": { "type": "string" },
"total": { "type": "number" }
}
},
"commands": [
{ "type": "align", "value": "ct" },
{ "type": "text", "content": "*** 购物小票 ***" },
{ "type": "text", "content": "订单号: {{orderNumber}}" },
{ "type": "text", "content": "合计: ¥{{total}}" },
{ "type": "cut" }
]
}浏览器里直接打,从 morden-node-escpos/browser 导入,避免把 usb 依赖打进前端包:
import {
BrowserPrinterController,
loadBrowserImage,
WebUSBAdapter,
} from "morden-node-escpos/browser";
const device = await WebUSBAdapter.requestDevice();
const controller = new BrowserPrinterController({
adapter: new WebUSBAdapter(device),
encoding: "GB18030",
width: 32,
imageLoader: loadBrowserImage,
});Receipt Studio
编辑器用 Next.js 做静态导出,推到 main 就部署到 GitHub Pages。纯前端,没有后端,打印数据也不经过我的服务器,草稿存在本机,浏览器直接把字节写到你授权的那台打印机。进来就是一份文档,没有模板列表,也没有「新建」这一页。顶栏写着「所有修改均保存在本机」。清掉草稿之后,内置模板是一张 80 mm 的 JIRA 卡片,名字就叫「JIRA卡片打印」。

三栏从左到右是组件库、画布、属性。画布上有两档:「模版编辑」和「打印预览」。组件库里有文本、图片、二维码、等宽表格、自定义表格,还有分隔线和空行。对齐、字号、字形挂在文本和表格上,没有单独的积木。没有单独的条形码按钮。barcode 在驱动和导入白名单里都有,可视化面板没给它做表单。导入这种命令后,属性栏会留一句:此高级命令可保留并排序,但首版不提供属性表单。
变量写成 {{key}}。右边「数据」页的表单是顶栏那份输入 Schema 生成的,只拿来做变量替换和预览。蓝底是 Schema 里定义过的变量,红底是模板用了、Schema 里没有的。类型不对的时候,预览顶部显示:「无法生成打印预览。预览暂时保留变量占位符。」Schema 在顶栏「输入 Schema」里改,字段可以新增或标成必填,也可以拿一份示例 JSON 反推。保存会实时更新变量的蓝红状态。

切到「打印预览」之后,中间换成按点阵画出来的小票。默认数据会把变量换成 BUG、PROJ-123、张三这些示例。这一步是编辑器自己用 canvas 光栅化的。预览时左边组件库变成只读。

导入会先解析 JSON,再按命令白名单检查。不认识的命令会拒掉,合法文件会再问一次要不要覆盖当前模板,覆盖后仍能撤销。打印按钮打开的是「打印小票」,连接方式有 USB、串口、网络(TCP)。

Go 驱动和 print-webhook
JSON 任务和模板约定跟 TS 版一致。我用 TS 驱动生成了一批 golden 字节放在 packages/morden-escpos-go/testdata/golden,Go 的测试要求渲染结果逐字节相同。
print-webhook 跑在连着打印机的那台机器上,从目录里加载模板,POST /print 同步出票。
curl -X POST http://127.0.0.1:8787/print \
-H 'Authorization: Bearer change-me' \
-H 'Idempotency-Key: order-1' \
-H 'Content-Type: application/json' \
-d '{"template":"receipt","data":{"orderNumber":"ORD-1","total":17.5}}'带 Bearer token 和幂等键。同一个 Idempotency-Key 再来一次只会返回 duplicate: true,不会再打一张。仓库里附了 systemd 的用户服务安装脚本。
2025 年 10 月发了第一版 npm 包,12 月补上测试,放了大半年,今年 8 月重新捡起来,迁成 pnpm monorepo,加了模板编辑器,部署到 GitHub Pages,做了中英日界面,9 月初补上 Go 驱动和 print-webhook,仓库是 MIT。
手头有小票机的话,可以先打开 Receipt Studio 把模板排出来。要用浏览器直接打,用 Chrome 或 Edge,走 HTTPS,再在打印对话框里选 USB 或串口。代码在 GitHub,npm 包名是 morden-node-escpos。不同牌子的固件差挺大,高级指令先在自己的机器上试,遇到问题提 issue 就行。

