热敏打印机ESC/POS指令踩坑记

1,877 阅读12分钟

背景

公司要做一个pos收银系统,需要接入打印机、扫码枪、电子秤等这些外设。我想着之前也接入过一些外设,问题不大。一个是读取身份证信息的读卡器,本质是在本机启动了一个服务,web前端直接调本机接口即可,超简单。还有一个是用到了串口,连接通讯啥的,传输的都是二进制数据,稍微有点麻烦

可是

现在问题来了,之前做的都是web前端接入的外设,这次要做的是桌面端开发。会不会不一样呢?查询了electron官网给出了四种方式,还挺全,这不就是熟悉的“味道”吗?

image.png

准备

下面经过我这几天的学习,也是对于electron有了初步了解,大概就是各壳子罢了,加上各种通讯方式,就很方便前端同学们开发了

打印

前端第一个想到的方案那就是print() 也就是下面的驱动打印

驱动打印

好处

  • 使用简单
  • 兼容性高
  • ...

image.png

坏处

  • 自定义差,不能传参
  • 会有弹窗 如下图所示
  • ...

image.png image.png

问题

  • 页面写的单位是px打印机小票是58mm 打印尺寸不好控制
  • 打印前会有弹窗很丑
  • 打印完了需要打开钱箱

electron 打印

其实也算是驱动打印

为什么单独把他拿来讲呢?是因为他支持传参,可以做到静默打印 可以参考这篇文章实现:electron获取本地打印机并且实现打印 主要思想就是创建一个不可见的窗口然后调用

printWindow.webContents.print({  silent: true })

这个跟web前端页面在electron应用中直接调用print是差不多的,只是一个可以传参,一个不能传参的区别

这个路线是最多坑的,里面可以传参有很多,可以参考官网文档 参数一大堆,支持的还挺多。实际上用了才发现,好多参数传了一点效果都没有!问了chat GPT说是因为打印机默认配置影响到了,奇奇怪怪的,也不知道真的还是假的。

致命缺陷

静默打印(也就是不弹框选择打印机等参数)打印出来的内容缩小了两三倍!!! 对比图如下:

e4e012bf31232de79e04c34fdc46464.jpg

54d5e8e0afbba69008055fb292c74b7.jpg

当然,不静默打印到勉强可以打印出正常的内容

问题

办公室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')
    })
  })
})

这……看起来一言难尽,用起来也是真的麻烦。而且经过实测不保证顺序发送指令的话,会弹窗报错的,虽然打印内容是出来了,有这么一个报错总是很难受的

7c972e89bc0e3d1c1239acf178e9762.png

技术方案

先说一下我的技术方案,因为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过来就能用

image.png

缺陷

这个方案最大的问题就是打印超级的慢!图片尺寸越大(黑色像素点越多),打印就越慢,主要慢在打印机打印。点击打印后会等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中有大量的指令

image.png 还好我这点小学英语还够用,勉强能看得懂 哈哈哈

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
    })
  }

成果

于是我就得到了下面这张小票

3f39300a68a1cd2b635de339e40d12e.jpg

奇怪的问题

    <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内容区域

image.png

584c69ed6f0650e32d706b6c1c4a6a0.jpg

小结

目前只能选用jpg、png类型图片,svg图片会不居中。目前还没有解决,如果有大佬看出问题了 请给我留言,不胜感激

总结

打印方式选择

选择print去打印对于前端来说是最方便的,但是遇到静默打印等定制化打印需求时候,print就显得有点力不从心了。指令打印需要一点学习成本,而且兼容性小,需要针对打印机做一一的开发联调!

生态

目前关于node发送ESC/POS指令的库比较少,基本就一个serialport好使,其他的打印机指令得看厂商文档了,作者没有厂商文档,借鉴了node-escpos实现

serialPort.write

  • 可以直接传英文,但是传中文会乱码,需要iconv-lite解码
  • 通常传的都是Buffer类型的数据,也就是二进制数据
  • 也可以传一些指令'\x1b\x61\x01'居中、'\n'换行等

成果

下面我们欣赏一下最后成果

b293d2eacecd36f2ce2c414c5c22a87.jpg