1888 字
9 分钟
morden-escpos:给小票打印机写一套现代点的 TypeScript 工具链

我买了一台容大 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 上,中文界面在这里。

打印流程:JSON 模板经 Receipt Studio 编辑,通过 HTTP 发到 webhook,再用 ESC/POS 打到 RP325

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卡片打印」。

Receipt Studio 中文桌面界面:顶栏是模板名 JIRA卡片打印、语言、80 mm、GB18030、输入 Schema、导入和导出 JSON;左侧组件库,中间小票画布,右侧属性

三栏从左到右是组件库、画布、属性。画布上有两档:「模版编辑」和「打印预览」。组件库里有文本、图片、二维码、等宽表格、自定义表格,还有分隔线和空行。对齐、字号、字形挂在文本和表格上,没有单独的积木。没有单独的条形码按钮。barcode 在驱动和导入白名单里都有,可视化面板没给它做表单。导入这种命令后,属性栏会留一句:此高级命令可保留并排序,但首版不提供属性表单。

变量写成 {{key}}。右边「数据」页的表单是顶栏那份输入 Schema 生成的,只拿来做变量替换和预览。蓝底是 Schema 里定义过的变量,红底是模板用了、Schema 里没有的。类型不对的时候,预览顶部显示:「无法生成打印预览。预览暂时保留变量占位符。」Schema 在顶栏「输入 Schema」里改,字段可以新增或标成必填,也可以拿一份示例 JSON 反推。保存会实时更新变量的蓝红状态。

数据面板:示例表单里工单类型为 BUG、工单编号为 PROJ-123,下面列出蓝色的已定义变量

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

打印预览中的 JIRA 卡片小票:标题 BUG PROJ-123,下面是状态、优先级、负责人和二维码

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

打印对话框选择 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 就行。

morden-escpos:给小票打印机写一套现代点的 TypeScript 工具链
https://blog.acgsteps.com/posts/morden-escpos/
作者
GYlove1994
发布于
2026-10-10
许可协议
CC BY-NC-SA 4.0