有赞零售跨平台打印库方案

有赞零售跨平台打印库方案

作者: 鱼干(林昊)
团队:有赞零售前端

前言

之前我们介绍了有赞零售小票打印跨平台解决方案,详情请见有赞零售小票打印跨平台解决方案。其中涉及到打印库只是做了简单的介绍。从上次文章至今,打印库也经历了从 1.0 到 2.0 的变迁,本文将对打印库的设计与变更有更详细的讲述。

背景

打印是商家在日常经营中不可缺失的行为。打印从实际业务中划分可以分为:小票打印、标签打印、电子面单打印等细分业务。小票打印在实际场景中又可以扩展出:购物小票、退货小票、换货小票、拣货小票、发货小票、交班小票、核销小票、取件小票、存件小票等等;这些小票对应着商家交易履约中的各个环节。 在 JS 打印库出来之前,有赞零售已经实现了小票的原生打印库,但在实践遇到了不少痛点。引用之前说的三大痛点:

1. 每个端各自实现一套打印流程,方案不统一。导致每次修改都会三端修改,而且 iOS 和 Android 必须依赖发版才可上线,不具有动态性,而且研发效率比较低。
2. 打印小票的业务场景比较多,每个业务都自己实现模板封装及打印逻辑,模板及逻辑不统一,维护成本大。
3. 多种小票设备的适配,对于每个端来说都要适配一遍。

因此原生的打印库不能满足快速发展的打印需求,急需一套能跨平台通用的打印库。

挑战

  • 打印库能够跨端运行
  • 一套能够描绘小票的模板
  • 不同小票打印机的指令解析

跨端语言选择

经过调研,iOS、Android、 Java 都有 JavaScript 运行环境库。iOS 使用 JavaScriptCore 框架,Android 使用 J2V8 框架,Java 中 JDK8 自带 Nashorn 引擎。后续有赞零售 PC 收银采用的是 Electron 框架,自带 V8 执行环境。综上所述,JavaScript 这门语言成了跨平台的首选项。

打印库的业务边界

正常的打印流程如下:

  1. 业务触发打印需求
  2. SDK 容器接收订单数据与模板数据
  3. 将订单数据与模板数据融合得到融合数据
  4. 融合数据翻译成对应打印机指令
  5. 客户端传送打印机指令给打印机
  6. 打印机接收指令完成打印操作

其中步骤 3 与步骤 4 的功能就是打印库所负责的功能。订单数据再抽象就是业务数据,从而可以得到以下公式:

  • 模板数据 + 业务数据 = 融合数据
  • 融合数据 + 打印机信息 = 指令数据
模板数据 = 包含占位符的模板
业务数据 = 需要填入模板里的数据
融合数据 = 占位符已被填充的模板

打印库的设计

根据业务边界,我们可以将打印库进行分层:

  • 模板渲染层:业务数据与模板的拼接融合
  • 翻译层:将融合数据解析为打印指令

模板设计

模板元素

要设计一套模板语言,首先要确认模板元素有几种。我们从实际的一张全功能的小票入手进行拆解。 以下为常见的一张小票示例:

weixin.qq.com/r/BENIUO3 (二维码自动识别)

分析以上小票我们可以整理出一张完整小票包含以下内容:

  • 元素 1. 文本 2. 图片 3. 二维码 4. 条形码 5. 换行
  • 布局 1. 单行单列 2. 一行多列
  • 排版 1. 居左 2. 局中 3. 居右

模板语言的设计

打印库的模板语言在 V1 版本的是 JSON ,而在 V2 版本的里替换成了 HTML 。 以下是 V1 模板语言与 V2 模板语言的对比:

一行右对齐的中等字号的有赞

V1 模板:

[
  {
    "content": "有赞",
    "contentType": "text",
    "textAlign": "right",
    "fontSize": "middle",
    "pagerWeight": 1
  }
]

V2 模板:

<span style="font-size:24px;text-align:right">
  有赞
</span>

V1 模板采取 JSON 的背景考虑在于模板直接写成 JSON ,对后续的翻译层的代码逻辑友好,能够直接一对一的进行翻译。在初期小票业务不复杂的情况下,JSON 能够较好地承载这块业务。后续随着小票业务的发展,小票内容复杂度提高,JSON 作为模板语言的缺陷也暴露了出来,举个例子: 以下是发货小票商品详情的效果图:

V1 模板(JSON)的写法是这样的:

[{{#each itemList}}
   [{
        "content": "{{titleSkuDesc}}",
        "contentType": "text",
        "textAlign": "left",
        "fontSize": "default",
        "pagerWeight": 4
    },
    {
        "content": "",
        "contentType": "text",
        "textAlign": "left",
        "fontSize": "default",
        "pagerWeight": 1
    }],[{
        "content": "{{toFixed (divide unitPrice 100) 2}}",
        "contentType": "text",
        "textAlign": "left",
        "fontSize": "default",
        "pagerWeight": 1
    },
    {
        "content": "{{quantityDesc}}",
        "contentType": "text",
        "textAlign": "center",
        "fontSize": "default",
        "pagerWeight": 1
    },
    {
        "content": "{{toFixed (divide itemAmount 100) 2}}",
        "contentType": "text",
        "textAlign": "right",
        "fontSize": "default",
        "pagerWeight": 1
    }]
{{#each priceDiffInfoList}}
{{#if @first}}
   ,[{
        "content": "",
        "contentType": "text",
        "textAlign": "left",
        "fontSize": "default",
        "pagerWeight": 1
    },
    {
        "content": "实发重量",
        "contentType": "text",
        "textAlign": "center",
        "fontSize": "default",
        "pagerWeight": 1
    },
    {
        "content": "应退差价",
        "contentType": "text",
        "textAlign": "right",
        "fontSize": "default",
        "pagerWeight": 1
    }],[
    {
        "content": "",
        "contentType": "text",
        "textAlign": "left",
        "fontSize": "default",
        "pagerWeight": 1
    },
    {
        "content": "实发重量",
        "contentType": "text",
        "textAlign": "center",
        "fontSize": "default",
        "pagerWeight": 1
    },
    {
        "content": "应退差价",
        "contentType": "text",
        "textAlign": "right",
        "fontSize": "default",
        "pagerWeight": 1
    }]
{{/if}}
    ,[{
        "content": "",
        "contentType": "text",
        "textAlign": "left",
        "fontSize": "default",
        "pagerWeight": 1
    },
    {
        "content": "{{divide realWeight 1000}}",
        "contentType": "text",
        "textAlign": "center",
        "fontSize": "default",
        "pagerWeight": 1
    },
    {
        "content": "-{{toFixed (divide diffAmount 100) 2}}",
        "contentType": "text",
        "textAlign": "right",
        "fontSize": "default",
        "pagerWeight": 1
    }]
{{/each}}
{{#if @last}}
{{else}}
,{
    "content": "",
    "contentType": "newline"
},
{{/if}}
{{/each}}]

从上面的例子可以看出 JSON + Handlebars语法 作为模板语言:

  1. 可读性低
  2. 维护成本高

所以在打印库 V2 版本设计的时候决定将模板语言进行替换,经过多方面调研,最终选定了 HTML 作为 V2 版本的模板语言。HTML 作为模板语言不仅解决了 JSON 模板语言两个缺陷,同时提供了后续的可扩展性。还是以上面发货小票的为例:

V2 模板(HTML)写法:

{{each itemInfoList}}
<p>
  <span style="font-size: 12px; text-align: left; width: 100%;">
    {{ $value.titleSkuDesc }}
  </span>
</p>
<p>
  <span style="font-size: 12px; text-align: left; width: 33.33%;">
    {{$value.unitPrice}}
  </span>
  <span style="font-size: 12px; text-align: center; width: 33.33%;">
    {{$value.quantityDesc}}
  </span>
  <span style="font-size: 12px; text-align: right; width: 33.33%;">
    {{$value.itemAmount}}
  </span>
</p>
<p>
  <span style="font-size: 12px; text-align: right; width: 33.33%;"> </span>
  <span style="font-size: 12px; text-align: center; width: 33.33%;">
    实发重量
  </span>
  <span style="font-size: 12px; text-align: right; width: 33.33%;">
    应退差价
  </span>
</p>
{{each $value.priceDiffInfoList}}
<p>
  <span style="font-size: 12px; text-align: right; width: 33.33%;"> </span>
  <span style="font-size: 12px; text-align: center; width: 33.33%;">
    {{$value.realWeight}}
  </span>
  <span style="font-size: 12px; text-align: right; width: 33.33%;">
    {{$value.diffAmount}}
  </span>
</p>
{{/each}} {{if $index!=itemInfoList.length-1}}
<br />
{{/if}} {{/each}

另一大原因是原有的 JSON 模板只能描绘小票这种自上而下的一维信息,而标签打印,杯贴打印等其它打印都是基于坐标的二维打印,原有的模板无法支撑相关的业务,而采用 HTML 之后,借助 CSS 的能力,我们能够轻松地描绘小票、杯贴、价签、条码的打印需求。

模板引擎

在实际小票打印中,一套小票模板样式是固定的,但是里面的实际内容是可变的,所以我们需要使用模板引擎来实现相关的替换工作。打印库 V1 版本中模板引擎为 Handlebars,而在打印库 V2 版本里我们替换成 art-template 这款模板引擎。对比 V1 的模板引擎,它拥有以下特性:

  • 调试友好:语法、运行时错误日志精确到模板所在行
  • 支持原生语法和标准语法,更强的表达能力。

在 V1 的模板引擎中,要实现判断值是否存在,需要注册一个 Helper 方法,才能使用相关能力,而在 V2 的模板引擎中天然支持。

{{if user}}
<h2>{{user.name}}</h2>
{{/if}}

配合模板引擎,我们可以得到第一个公式:

模板数据 + 业务数据 = 融合数据

模板数据

<p>储值编号:{{orderNo}}</p>

业务数据

{
  orderNo: 'E1278909900990';
}

融合数据

<p>储值编号:E1278909900990</p>

完整的 HTML 模板

<p>储值编号:{{orderNo}}</p>
<p>储值时间:{{createTime | formatDate}}</p>
<p>操作人员:{{operator}}</p>
<hr />
<p>
  <span style="text-align:left;width:50%;">
    活动名称
  </span>
  <span style="text-align:right;width:50%;">
    {{ruleName}}
  </span>
</p>
<p>
  <span style="text-align:left;width:50%;">
    支付方式:
  </span>
  <span style="text-align:right;width:50%;">
    {{payDesc}}
  </span>
</p>
<p>
  <span style="text-align:left;width:50%;">
    储值金额:
  </span>
  <span style="text-align:right;width:50%;">
    {{rechargeAmount}}
  </span>
</p>
<p>
  <span style="text-align:left;font-size:24px;width:50%;">
    账户余额:
  </span>
  <span style="text-align:right;font-size:36px;width:50%;">
    {{balance}}
  </span>
</p>
<hr />
<p>会员:{{buyerName}}</p>
<p>电话:{{mobile}}</p>
<qrcode>www.youzan.com</qrcode>
<br />
<p style="text-align:center">扫码关注店铺公众号</p>
<hr />
<p style="text-align:center">本次储值赠送白金卡,5张10元优惠券</p>

至此一套描绘打印模板的模板语言已经设计完成。

翻译层

模板渲染层帮助我们实现了对打印业务的描绘,打印模板语言与打印机型号协议无关,只与打印业务的类型(小票、标签)有关。而到了翻译层,这一层负责将模板翻译成打印机指令。而要实现相关能力,我们需要对打印机协议有进一步了解。打印机协议从业务形态上分可以分为两大类:票据(小票)打印机与标签打印机。

票据打印机协议

目前市面上票据(小票)打印机协议可以分为以下二种。

  1. ESC/POS协议
  2. 基于ESC/POS封装的上层协议

目前市面上的 99% 的票据打印机都支持 ESC/POS 协议,是票据打印机的事实标准。而第二种基本都是为了方便开发者使用的二次包装,多存在于云打印机厂商。故我们如果能够实现模板ESC/POS 指令的功能,我们可以做到快速对接大部分票据打印机。而针对第二种情况,打印库提供单独的适配,

ESC/POS 协议 该打印控制命令(WPSON StandardCode for Printer)是 EPSON 公司自己制定的针式打印机的标准化指令集,现在已成为针式打印机控制语言事实上的工业标准。ESC/POS 打印命令集是 ESC 打印控制命令的简化版本,现在大多数票据打印都采用 ESC/POS 指令集。

标签打印机协议

目前市面上标签打印机协议没有类似ESC/POS的通用协议,根据打印库对接的几款标签打印机来看,打印机厂商的提供的协议文档都是对底层协议进行了封装。该协议的特点在于,每一个元素都需要提供 x, y 的坐标以进行定位。这边打印库则提供了 Point 坐标打印协议进行映射标签打印机协议。

HTML 到 ESC/POS 协议指令示范

HTML:

<p style="font-size:24px;">
  <span style="text-align:right;">
    有赞
  </span>
</p>

等于

一行右对齐的中等字号的有赞

等于

右对齐指令 + 中等字号指令 + 文本16进制编码 + 打印指令

打印机指令:

1B6102 + 1D2111     + D3D0 + D4DE + 0A
右对齐  + 加宽加粗两倍 + 有   +  赞   + 打印并换行

以上为 HTML 到 ESC/POS 指令的解析过程。不同于 v1 的 JSON 模板能够方便实现 1 对 1 的映射。v2 的 HTML 模板转化到指令中间,需要解析成 AST 以方便我们进行翻译,因为我们需要一个解析 HTML 的库。

HTML 解析库

要完成 HTML 模板到打印机指令的过程,我们需要类似于 Babylon 的处理工具。经过调研与比对,这里选择了 unified 这个库。unified 是一个用于处理带有语法树的文本并在它们之间进行转换。选择这个库的原因在于它的生态比较丰富,提供的插件也能较好的满足我们打印库的需求。 最终我们的处理流程图如下:

rehype.js 是针对 HTML 语言的处理库,通过它我们能够实现对模板的压缩,格式化处理。我们利用它的 Parser 进行 AST 的构建,而 Compiler 则需要我们自己去编写。

编译器

Compiler 编译器中,我们实现抽象语法树打印机指令。大体上流程如下:

编写一个 Compiler ,首先需要对语法树进行解析,语法树的数据结构标准可以从HTML 语法树格式这里查询。通过解析语法树,我们解析出模板里对应的文本、图片、条形码、二维码等元素。然后我们在代码中实现对应元素到打印机指令的翻译, 最终生成完整的打印指令输出。

在打印库中,针对不同打印机协议编写对应的 Compiler 实现 AST 到不同打印指令的输出。这样完成了输入同一份模板与打印机信息,输出相对应的打印机指令。

实例

模板:

<p style="text-align:left;width:50%;font-size:24px;">有你有赞</p>

输出 ESC/POS协议

1C43001B61001B21001D2100D2BBB6FEC8FDCBC4CEE5C1F9C6DFB0CBBEC5CAAE2020202020202020202020200A1D564200

某 A 云打印机协议

<B>有你有赞</B>

某 B 云打印机协议

<html>
  <body>
    <label style="font-size:48px;text-align:left;">有你有赞</label>
  </body>
</html>

典型难点

在开发打印库过程中,实际会遇上不少难题。接下来我会介绍两个典型难题:图片与小票排版问题

图片问题

图片是小票中的重要元素,在之前文章中介绍过打印库本身不处理图片,交于外部处理。原因是打印库运行在模拟的 JS 运行环境库中,没有能力处理图片。

下面是一张图片模板示例:

<img src="https://tech.youzan.com/content/images/2019/03/banner.png" />

我们要翻译一张图片要经过以下步骤:

  1. 下载图片
  2. 图片灰度二值化处理
  3. 翻译打印机指令

步骤一,依赖网络连接进行下载图片。 步骤二,JavaScript 需要依赖 Canvas 这个对象进行处理。 而在 iOS、 Android、Java 的 JavaScript 运行环境库中没有提供这两个能力,这也必然导致了打印库在处理图片中需要交与外部调用者完成步骤一和二。

部分自定义协议的打印机自身会处理步骤一与步骤二,打印库就可以直接翻译到对应协议。

为什么图片需要进行灰度二值化处理?

因为对于票据打印机来说,图片像素点只有打与不打,所以不支持灰度与彩色图片。而我们的图片大多数都是灰度或者彩色图片,因此我们需要进行二值化处理。在 ESC/POS 协议中,打印图片的指令如下:

其中d1~dk就是图片的数据块,并且值只有01,1 表示打印该点,0 为不打印该点。

图片二值化方案:这部分内容可以参考我们另一篇文章 有赞零售小票打印图片二值化方案

一行多列排版问题

票据打印机原生不支持一行多列的排版,我们需要自己处理一行多列的排版问题。举个例子。如下图:

对于打印机来说,这里只有两行数据。如果我们这边不对小票排版进行优化的话,输出实际结果大概如下

品名单价数量金额
商品名称(规格)¥5.002份¥10.00

所以一行多列的排版需要打印库实现。这里可以通过塞入空格进行排版填充。那么理论上应该塞入多少空格呢,不同纸张类型(58/80mm)大小也是不一样的?这里有一份数据:

58mm 能够打印 32 个英文字符,16 个中文字符 80mm 能够打印 48 个英文字符,24 个中文字符

根据以上数据,我们可以正确的插入空格保证排版。

品名            单价    数量     金额
商品名称(规格)¥5.00    2份  ¥10.00

还有一种情况,单列塞不下对应内容,比如 80mm 纸张能正常排满的小票,在 58mm 的纸则显示不正常。如下:

品名   单价    数量     金额
商品名称(规格)¥5.00    2
份  ¥10.00

分析原因本质在于,品名这一列只占据了 25%的空间,在商品名称过长的时候,挤压了后续的空间。所以针对这种情况,我们需要进行内容切割。 最终排版调整为:

品名   单价    数量     金额
商品名 ¥5.00  2份  ¥10.00
称(规
格)

总结与展望

目前在有赞零售中,PC 客户端、Java 端、iOS 端、Android 端都已经完成该打印库的接入,100% 的小票都经过 JS 打印库输出到打印机,已经稳定运行 2 年有余。价签条码、杯贴打印也统一接入了 JS 打印库,同时支撑了有赞零售自定义价签、自定义小票等一系列复杂的商家需求。 在未来的规划里,有赞零售打印库将会对目前业务实践中的痛点进行解决。

  1. 搭建 Node 打印服务,对外提供相关打印接口,降低业务方的接入成本。
  2. 统一有赞打印标准,方便 ISV 进行接入有赞打印,利用生态的能力支持更多品牌的打印机。
发布于 04-21

文章被以下专栏收录