背景
公司要做一个pos收银系统,需要接入打印机、扫码枪、电子秤等这些外设。我想着之前也接入过一些外设,问题不大。一个是读取身份证信息的读卡器,本质是在本机启动了一个服务,web前端直接调本机接口即可,超简单。还有一个是用到了串口,连接通讯啥的,传输的都是二进制数据,稍微有点麻烦
可是
现在问题来了,之前做的都是web前端接入的外设,这次要做的是桌面端开发。会不会不一样呢?查询了electron官网给出了四种方式,还挺全,这不就是熟悉的“味道”吗?
准备
下面经过我这几天的学习,也是对于electron有了初步了解,大概就是各壳子罢了,加上各种通讯方式,就很方便前端同学们开发了
打印
前端第一个想到的方案那就是print() 也就是下面的驱动打印
驱动打印
好处
- 使用简单
- 兼容性高
- ...
坏处
- 自定义差,不能传参
- 会有弹窗 如下图所示
- ...
问题
- 页面写的单位是px打印机小票是58mm 打印尺寸不好控制
- 打印前会有弹窗很丑
- 打印完了需要打开钱箱
electron 打印
其实也算是驱动打印
为什么单独把他拿来讲呢?是因为他支持传参,可以做到静默打印 可以参考这篇文章实现:electron获取本地打印机并且实现打印 主要思想就是创建一个不可见的窗口然后调用
printWindow.webContents.print({ silent: true })
这个跟web前端页面在electron应用中直接调用print是差不多的,只是一个可以传参,一个不能传参的区别
坑
这个路线是最多坑的,里面可以传参有很多,可以参考官网文档 参数一大堆,支持的还挺多。实际上用了才发现,好多参数传了一点效果都没有!问了chat GPT说是因为打印机默认配置影响到了,奇奇怪怪的,也不知道真的还是假的。
致命缺陷
静默打印(也就是不弹框选择打印机等参数)打印出来的内容缩小了两三倍!!! 对比图如下:
当然,不静默打印到勉强可以打印出正常的内容
问题
办公室A4纸普通打印机还OK,热敏打印机响了一秒就停了,什么东西也没打印出来(我是按照上面文章讲的流程做的测试,可能跟iframe有关系吧,链路有点长,没有过多深究原因。直接搞个html用print倒是可以正常打印的)
指令打印
基本知识
热敏打印机基本知识 总而言之,依据具体设备情况而定。主流的还是ESC/POS指令+58mm
连接方式
常见的有:
- 串口连接
- usb连接
- 蓝牙连接
- ...
下面我搞的是串口连接
这里提一下,有个库node-escpos封装的挺好,支持连接方式也多,但是用起来就报错,坑很多。我最后放弃了使用,改用了node-serialport
node-serialport
这个库用起来没问题,但是很多东西需要你自己去封装实现,官网都是英文,自己看示例写法,问GPT啥的,挺痛苦的。GPT3.5提供的写法都是过时的,坑死人了
大概用法如下:
const { SerialPort } = require('serialport')
//这里的串口根据不同的系统略有不同,比如windows中就是COM1
const port = new SerialPort({ path: '/dev/tty-usbserial1', baudRate: 9600 }, () => {
console.log('Port Opened')
})
port.write('main screen turn on', err => {
if (err) {
return console.log('Error: ', err.message)
}
console.log('message written')
})
根据我了解,好像得连接所有的串口然后发送指令才能打印。连接一个串口发送指令是不会打印的!比如我电脑里就有COM1-6个串口,得全部连接了同时发送指令才能正常打印
于是就有了下面这样的代码:
SerialPort.list().then(ports => {
ports.forEach(port => {
const serialPort = new SerialPort({
path: port.path,
baudRate: 9600,
})
serialPort.write('main screen turn on', err => {
if (err) {
return console.log('Error: ', err.message)
}
console.log('message written')
})
})
})
这……看起来一言难尽,用起来也是真的麻烦。而且经过实测不保证顺序发送指令的话,会弹窗报错的,虽然打印内容是出来了,有这么一个报错总是很难受的
技术方案
先说一下我的技术方案,因为ESC/POS指令需要学习成本,用起来有点像写原生canvas的感觉,但是没有canvas好用,就比如说书写状态就没有ctx.save() ctx.restore()这样的API给你使用。每次写完得自己手动重置状态,不然会影响后续的内容输出……
所以我选择的路线是:web前端把内容转为图片,发送给electron,根据图片解析为ESC/POS指令发送给打印机
一劳永逸的事情,不管要打印logo还是二维码条形码,复杂的事情都交给熟悉的前端去做好了
封装
为了方便,我就把使用打印的写成了一个类
const { SerialPort } = require('serialport')
const getPixel = require('get-pixels')
const fs = require('fs')
const rgba2hex = (data, shape) => {
const bitArr = []
for (let i = 0; i < data.length; i = i + 4) {
if (i[3] === 0) {
bitArr.push(0)
continue
}
// 计算平均值判断
const bit = (data[i] + data[i + 1] + data[i + 2]) / 3 > 160 ? 0 : 1
bitArr.push(bit)
}
// bitArr: [1]
// 对bitArr做补0的动作
const newBitArr = []
const width = shape[0]
const isNeed = width % 8 !== 0
const height = shape[1]
if (isNeed) {
for (let i = 0; i < height; i++) {
newBitArr.push(...bitArr.slice(i * width, (i + 1) * width))
for (let j = 0; j < 8 - (width % 8); j++) {
newBitArr.push(0)
}
}
} else {
newBitArr = bitArr
}
// newBitArr: [1, 0, 0, 0, 0, 0, 0, 0]
const byteArr = []
for (let i = 0; i < newBitArr.length; i = i + 8) {
const byte =
(newBitArr[i] << 7) +
(newBitArr[i + 1] << 6) +
(newBitArr[i + 2] << 5) +
(newBitArr[i + 3] << 4) +
(newBitArr[i + 4] << 3) +
(newBitArr[i + 5] << 2) +
(newBitArr[i + 6] << 1) +
newBitArr[i + 7]
byteArr.push(byte)
}
// byteArr: [128] = [0x80];
return new Uint8Array(byteArr)
}
module.exports = class Printer {
constructor(mainWindow, printData, resolve, reject) {
this.mainWindow = mainWindow
this.resolve = resolve
this.reject = reject
this.serialPortList = [] //储存连接后的串口
this.filePath = '_image.jpeg' // 保存的文件路径
// 列出当前计算机上的所有串口
SerialPort.list()
.then(ports => {
ports.forEach(port => {
const serialPort = new SerialPort({
path: port.path,
baudRate: 9600,
}) // 根据需要设置正确的波特率
this.serialPortList.push(serialPort)
})
return this.composeAsync([
this.sendImgCommand,
this.sendWhitespace,
this.sendPartialCutCommand,
this.sendKickCashDrawerCommand,
this.close,
])(printData)
})
.catch(reject)
}
// 发送三行留白函数
sendWhitespace() {
const whitespace = '\n\n\n' // 三个换行符
return this.sendInstructions(whitespace)
}
sendInstructions(instruction) {
const serialPortListLen = this.serialPortList.length
let count = 0
return new Promise((resolve, reject) => {
this.serialPortList.forEach(serialPort => {
serialPort.write(instruction, err => {
if (err) {
const errMsg = 'Error sending kick cash drawer command:' + err
reject(errMsg)
return console.error(errMsg)
}
count++
if (count === serialPortListLen) {
resolve('Kick cash drawer command sent to printer')
}
})
})
})
}
close() {
this.resolve('ok')
// 关闭连接
this.serialPortList.forEach(serialPort => {
serialPort.close()
})
this.serialPortList = []
}
composeAsync(arr) {
return initData =>
arr.reduce(
(pre, cur) => pre.then(res => cur.call(this, res)),
Promise.resolve(initData)
)
}
// 发送图片指令
async sendImgCommand(printData) {
// 没有数据直接返回
if (!printData) return Promise.resolve()
// 设置居中
await this.sendCenterCommand()
// 保存图片
this.saveBase64Image(printData)
// 获取pos指令
const kickCashDrawerCommand = await this.getImgBuffer()
// 删除图片
this.removeImg()
// 分割开来上传打印
const bufferChunkList = []
const bufferLen = kickCashDrawerCommand.length
let start = 0
while (start < bufferLen) {
const bufferChunk = kickCashDrawerCommand.slice(start, (start += 800))
bufferChunkList.push(bufferChunk)
}
return this.composeAsync(
bufferChunkList.map(
bufferChunk => () => this.sendInstructions(bufferChunk)
)
)()
}
// 保存 base64 图片到本地
saveBase64Image(base64Data) {
// 去除头部信息
const base64Image = base64Data.replace(/^data:image\/\w+;base64,/, '')
// 将 base64 数据解码成二进制数据
const buffer = Buffer.from(base64Image, 'base64')
// 将二进制数据写入文件
fs.writeFileSync(this.filePath, buffer)
console.log('图片已保存到:', this.filePath)
}
removeImg() {
fs.unlinkSync(this.filePath)
console.log('图片已删除:', this.filePath)
}
// 居中指令
sendCenterCommand() {
const partialCutCommand = Buffer.from([0x1b, 0x61, 0x01]) // ESC a 1
return this.sendInstructions(partialCutCommand)
}
// 切纸指令(Partial Cut)
sendPartialCutCommand() {
const partialCutCommand = Buffer.from([0x1b, 0x64, 0x02])
return this.sendInstructions(partialCutCommand)
}
// 打开钱箱指令(Kick Cash Drawer #1)
sendKickCashDrawerCommand() {
const kickCashDrawerCommand = Buffer.from([0x1b, 0x70, 0x00, 0x19, 0xfa])
return this.sendInstructions(kickCashDrawerCommand)
}
getImgBuffer() {
return new Promise((resolve, reject) => {
getPixel(this.filePath, (err, { data, shape }) => {
if (err) return reject(err)
// data: [0, 0, 0, 255]
// shape: [1, 1, 4]
const imgData = rgba2hex(data, shape)
const width = shape[0]
const height = shape[1]
const xL = Math.ceil((width / 8) % 256) // 1
const xH = Math.floor(width / 8 / 256) // 0
const yL = height % 256 // 1
const yH = Math.floor(height / 256) // 0
const buffer = Buffer.from([
0x1d,
0x76,
0x30,
0,
xL,
xH,
yL,
yH,
...imgData,
])
resolve(buffer)
})
})
}
}
其中getImgBuffer具体实现要感谢古茗前端团队打印机技术的演进 看了大佬的文章深有感触,虽然我是没看懂,反正CV过来就能用
缺陷
这个方案最大的问题就是打印超级的慢!图片尺寸越大(黑色像素点越多),打印就越慢,主要慢在打印机打印。点击打印后会等5-6s才开始打印,打印过程出纸超级慢,应该是一行所有的像素点都要考虑进去吧。GPT说升级打印机可以提升速度。作为一个合格的程序员,当然不能找这个借口。我采取了两点靠谱的优化:
- 图片传jpeg格式默认0.92的质量输出
- 将Buffer分割成多块传输给打印机
速度略有优化,点击打印后会等3s才开始打印,打印过程出纸一样的慢。分析其中原因,我觉得是图片尺寸过大,增加了打印机的性能消耗。
我有点想放弃这个这个方案了,一张小票大部分都是文字,我却把一整张小票当成了一张超长的图片去打印,打印效率肯定低下了
文字打印
像前面的打印文本的指令,可以说是秒出纸,顺畅的很,所以我又不得不去研究一下ESC/POS指令
痛苦面具
公司只给了我一台pos机,内置打印机,没有其他任何打印机文档,打印机也不能连接到我笔记本上。每次改几行代码,都需要打包,借助微信传文件,下载-安装-运行后查看效果。console也看不到,我不得不借助web控制台看打印
// 没有本地打印机连接,每次都需要打包到另外一台机子运行,所以把打印交给了渲染进程
postMessage2Renderer(...message) {
this.mainWindow.webContents.send('toPcConsole', ...message)
}
渲染进程
ipcRenderer.on('toPcConsole', (e, ...res) => {
console.log(...res)
})
打包调试下来我看了一下微信下载目录里面有百来个exe文件。。。。
ESC/POS指令
没有文档,学习ESC/POS指令真的痛啊。问那个3.5的GPT也是啥啥的,提供的代码不一定正确能用。我总不能报个班去学习吧。。。看视频也还可以,可以是我不想看啊。。。 然后,我就看node-escpos的源码是如何封装的。虽然我用不了你这个库,我借鉴(抄)一下总可以吧!
在文件node_modules\escpos\commands.js中有大量的指令
还好我这点小学英语还够用,勉强能看得懂 哈哈哈
ControlInstruction
封装一个专门写指令的类 一切从简,先保证能实现好用再说,后期可以优化
class ControlInstruction {
constructor() {
this.instructionsList = []
this.encoding = 'GB18030'
this.width = 32
}
clear() {
this.instructionsList = []
}
font(family) {
// if (family.toUpperCase() === 'A') {
// this.width = 32
// } else {
// this.width = 42
// }
this.instructionsList.push(TEXT_FORMAT['TXT_FONT_' + family.toUpperCase()])
return this
}
align(align) {
this.instructionsList.push(TEXT_FORMAT['TXT_ALIGN_' + align.toUpperCase()])
return this
}
text(str, needWarp = true) {
this.instructionsList.push(
iconv.encode(`${str}${needWarp ? EOL : ''}`, this.encoding)
)
return this
}
size(type) {
this.instructionsList.push(TEXT_FORMAT['TXT_SIZE_' + type.toUpperCase()])
return this
}
dashed() {
this.font('a').size('normal').align('lt')
const dashLength = this.width // 虚线长度
const dashCharacter = '-' // 虚线字符
const dashLine = dashCharacter.repeat(dashLength) // 重复字符生成虚线
return this.text(dashLine)
}
print(content) {
this.instructionsList.push(content)
return this
}
feed(n) {
this.instructionsList.push(new Array(n || 1).fill(EOL).join(''))
return this
}
cut(part, feed) {
this.feed(feed || 3)
this.instructionsList.push(
PAPER[part ? 'PAPER_PART_CUT' : 'PAPER_FULL_CUT']
)
return this
}
textLR(leftText, rightText) {
const totalWidth = this.width
const leftEncoded = iconv.encode(`${leftText}`, this.encoding)
const rightEncoded = iconv.encode(`${rightText}`, this.encoding)
const spaceCount = totalWidth - (leftText.length * 2 + rightText.length + 1)
const spaces = ' '.repeat(spaceCount > 0 ? spaceCount : 0)
console.log(totalWidth, leftText.length, rightText.length, spaceCount)
// 将左侧文本、空格和右侧文本合并为一行
const combined = Buffer.concat([
leftEncoded,
Buffer.from(spaces, 'ascii'),
rightEncoded,
Buffer.from(EOL, 'ascii'),
])
this.instructionsList.push(combined)
return this
}
}
坑
- 发送指令中包含中文打印会乱码//iconv 转一下就行了
- 不同的字体一行数量不一样//58mm纸张中a字体32个其他字体42个,经过我实测发现改了字体还是32个,不知道是不是改变字体的指令有问题,算了先不管了
- 打印得空3行留白,不然最后打印的内容还在打印机里面,看不到//具体打印机根据情况而定
- 一行文本分为两部分左右对齐,得根据内容计算所占宽度,中间计算填补空格//文字占两个宽度其他占一个
使用
像呼吸一样自由顺畅,秒出纸 快得很
compileTicketInstructions(printData) {
// 标题-------
this.controlInstruction
.font('a')
.size('normal')
.align('ct')
.text(printData.shopName)
.dashed()
// 日结单-------
this.controlInstruction
.font('a')
.size('large')
.text(printData.dailyStatement.title)
.dashed()
this.controlInstruction.align('lt').size('normal')
for (let i = 0; i < printData.dailyStatement.list.length; i++) {
const current = printData.dailyStatement.list[i]
this.controlInstruction.text(current.label + ':' + current.value)
}
// 打印虚线
this.controlInstruction.dashed()
// 预交现金------------------
for (let i = 0; i < printData.orderList.length; i++) {
const current = printData.orderList[i]
this.controlInstruction
.font('a')
.align('ct')
.size('large')
.feed(1)
.text(current.title)
.feed(1)
for (let i = 0; i < current.list.length; i++) {
const cur = current.list[i]
this.controlInstruction
.align('lt')
.size('normal')
.textLR(cur.label, cur.value)
}
}
// 打印虚线
this.controlInstruction.dashed()
// 签字区
this.controlInstruction.font('c').align('lt').size('large').text('签字区')
}
run() {
return new Promise((resolve, reject) => {
const instructionsAsyncList =
this.controlInstruction.instructionsList.map(
instruction => () => this.sendInstructions(instruction)
)
this.controlInstruction.clear()
this.composeAsync(instructionsAsyncList)().then(resolve).catch(reject)
})
}
文字+图片指令打印
大部分文字用指令去解决了,那么小部分logo图片、二维码什么的,用图片去打印,不就可以实现大部分打印需求了吗?!!!图片尺寸不大的话,打印起来还是很快的
const getPrintImg = () => {
const printImgList = [...printRef.value!.children] as HTMLElement[]
const canvasPromiseList = printImgList.map((children) =>
html2canvas(children, {
scale: window.devicePixelRatio, // 根据设备的像素比增加scale
})
)
return new Promise((resolve, reject) => {
Promise.all(canvasPromiseList)
.then((canvasList) => {
const result: Record<string, string> = {}
canvasList.forEach((canvas, index) => {
const imageData = canvas.toDataURL('image/jpeg')
const key = printImgList[index].dataset.slot!
result[key] = imageData
})
resolve(result)
})
.catch(reject)
})
}
async () => {
const printData = (await getPrintImg()) as Record<string, any>
const json = {
shopName: '新华书店',
dailyStatement: {
title: '日结单',
list: [
{ label: '日期', value: '2022-06-12' },
{ label: '收银员', value: 'qw' },
],
},
orderList: [
{
title: '【预交现金】',
list: [
{ label: '预交现金', value: '¥11.00' },
{ label: '预交现金', value: '¥112.00' },
],
},
{
title: '【收款明细】',
list: [
{ label: '预交现金', value: '¥11.00' },
{ label: '预交金', value: '¥122.00' },
{ label: '预交现金', value: '¥1663.00' },
],
},
],
}
printData.json = json
ipcRenderer.invoke('handle_print', printData).finally(() => {
printing.value = false
})
}
成果
于是我就得到了下面这张小票
奇怪的问题
<div v-if="showSmallTicket" class="smallTicket" ref="printRef">
<img src="@/assets/logo.svg" alt="" data-slot="logo" class="w-25" />
<img src="@/assets/qrCode.png" alt="" data-slot="qrCode" class="w-25" />
</div>
居中
logo图片是svg类型图片,打印出来没有居中,jpg、png打印出来倒是可以居中
宽度mm
宽度直接写58mm打印出来只有一半小票的宽度,直接写100mm倒是基本可以占满小票(58-5*2)mm内容区域
小结
目前只能选用jpg、png类型图片,svg图片会不居中。目前还没有解决,如果有大佬看出问题了 请给我留言,不胜感激
总结
打印方式选择
选择print去打印对于前端来说是最方便的,但是遇到静默打印等定制化打印需求时候,print就显得有点力不从心了。指令打印需要一点学习成本,而且兼容性小,需要针对打印机做一一的开发联调!
生态
目前关于node发送ESC/POS指令的库比较少,基本就一个serialport好使,其他的打印机指令得看厂商文档了,作者没有厂商文档,借鉴了node-escpos实现
serialPort.write
- 可以直接传英文,但是传中文会乱码,需要iconv-lite解码
- 通常传的都是Buffer类型的数据,也就是二进制数据
- 也可以传一些指令'\x1b\x61\x01'居中、'\n'换行等
成果
下面我们欣赏一下最后成果