escPosBuilder.ts 7.75 KB
import type {
  LabelPrintPayload,
  LabelTemplateData,
  MonochromeImageData,
  PrintImageOptions,
  StructuredLabelTemplate,
} from '../types/printer'
import { resolveEscTemplate } from '../templateRenderer'
import { createTestPrintTemplate } from '../templates/testPrintTemplate'
import { toEscBarcodeTypeCode } from '../../barcodeFormat'

function normalizePrinterText (str: string): string {
  return String(str || '')
    .normalize('NFKC')
    .replace(/[\u2018\u2019]/g, '\'')
    .replace(/[\u201C\u201D]/g, '"')
    .replace(/[\u2013\u2014]/g, '-')
}

function stringToBytes (str: string): number[] {
  const normalized = normalizePrinterText(str)
  const out: number[] = []
  const cp1252Map: Record<number, number> = {
    0x20ac: 0x80,
    0x201a: 0x82,
    0x0192: 0x83,
    0x201e: 0x84,
    0x2026: 0x85,
    0x2020: 0x86,
    0x2021: 0x87,
    0x02c6: 0x88,
    0x2030: 0x89,
    0x0160: 0x8a,
    0x2039: 0x8b,
    0x0152: 0x8c,
    0x017d: 0x8e,
    0x2018: 0x91,
    0x2019: 0x92,
    0x201c: 0x93,
    0x201d: 0x94,
    0x2022: 0x95,
    0x2013: 0x96,
    0x2014: 0x97,
    0x02dc: 0x98,
    0x2122: 0x99,
    0x0161: 0x9a,
    0x203a: 0x9b,
    0x0153: 0x9c,
    0x017e: 0x9e,
    0x0178: 0x9f,
  }
  for (let i = 0; i < normalized.length; i++) {
    const code = normalized.charCodeAt(i)
    if (code < 0x80) {
      out.push(code)
      continue
    }
    if (code >= 0xa0 && code <= 0xff) {
      out.push(code)
      continue
    }
    if (cp1252Map[code] != null) {
      out.push(cp1252Map[code])
      continue
    }
    out.push(0x3f)
  }
  return out
}

function appendText (out: number[], text: string) {
  const bytes = stringToBytes(text)
  for (let i = 0; i < bytes.length; i++) out.push(bytes[i])
}

function appendLine (out: number[], text = '') {
  appendText(out, text)
  out.push(0x0a)
}

function appendBytes (out: number[], bytes: number[]) {
  for (let i = 0; i < bytes.length; i++) out.push(bytes[i])
}

function appendAlign (out: number[], align: 0 | 1 | 2) {
  out.push(0x1b, 0x61, align)
}

function appendBold (out: number[], bold: boolean) {
  out.push(0x1b, 0x45, bold ? 1 : 0)
}

function appendSize (out: number[], width = 0, height = 0) {
  const value = ((width & 0x07) << 4) | (height & 0x07)
  out.push(0x1d, 0x21, value)
}

function clamp (value: number, min: number, max: number): number {
  return Math.max(min, Math.min(max, Math.round(value)))
}

function normalizeEscBarcodeType (value?: string): number {
  return toEscBarcodeTypeCode(value)
}

function normalizeEscQrLevel (value?: string): number {
  const key = String(value || 'M').trim().toUpperCase()
  const map: Record<string, number> = {
    L: 48,
    M: 49,
    Q: 50,
    H: 51,
  }
  return map[key] || 49
}

function appendHorizontalRule (out: number[], width = 32) {
  appendLine(out, '+' + '-'.repeat(Math.max(width - 2, 0)) + '+')
}

function appendBoxLine (out: number[], text = '', width = 32) {
  const innerWidth = Math.max(width - 4, 0)
  const value = text.length > innerWidth ? text.slice(0, innerWidth) : text.padEnd(innerWidth, ' ')
  appendLine(out, `| ${value} |`)
}

function createEscDocument (builder: (out: number[]) => void): number[] {
  const out: number[] = []
  out.push(0x1b, 0x40)
  out.push(0x1b, 0x74, 16)
  builder(out)
  out.push(0x1b, 0x64, 0x04)
  // 打印完成后执行切刀(GS V 0):适配当前内置小票机,避免长纸不断。
  out.push(0x1d, 0x56, 0x00)
  return out
}

function appendRasterImage (out: number[], image: MonochromeImageData) {
  const bytesPerRow = image.width / 8
  const xL = bytesPerRow & 0xff
  const xH = (bytesPerRow >> 8) & 0xff
  const yL = image.height & 0xff
  const yH = (image.height >> 8) & 0xff
  out.push(0x1d, 0x76, 0x30, 0x00, xL, xH, yL, yH)
  for (let y = 0; y < image.height; y++) {
    for (let byteIndex = 0; byteIndex < bytesPerRow; byteIndex++) {
      let value = 0
      for (let bit = 0; bit < 8; bit++) {
        const x = byteIndex * 8 + bit
        const pixel = image.pixels[y * image.width + x]
        if (pixel) value |= 1 << (7 - bit)
      }
      out.push(value & 0xff)
    }
  }
}

function appendBarcode (out: number[], item: {
  value: string
  align?: 0 | 1 | 2
  symbology?: string
  height?: number
  width?: number
  showText?: boolean
}) {
  const value = String(item.value || '')
  if (!value) return
  appendAlign(out, item.align ?? 1)
  out.push(0x1d, 0x48, item.showText === false ? 0 : 2)
  out.push(0x1d, 0x68, clamp(item.height || 96, 1, 255))
  out.push(0x1d, 0x77, clamp(item.width || 3, 2, 6))

  const type = normalizeEscBarcodeType(item.symbology)
  const bytes = stringToBytes(value)

  if (type >= 73) {
    out.push(0x1d, 0x6b, type, clamp(bytes.length, 0, 255))
    appendBytes(out, bytes)
  } else {
    out.push(0x1d, 0x6b, type)
    appendBytes(out, bytes)
    out.push(0x00)
  }
  out.push(0x0a)
}

function appendQrCode (out: number[], item: {
  value: string
  align?: 0 | 1 | 2
  size?: number
  level?: 'L' | 'M' | 'Q' | 'H'
}) {
  const value = String(item.value || '')
  if (!value) return
  const bytes = stringToBytes(value)
  const storeLength = bytes.length + 3
  const pL = storeLength & 0xff
  const pH = (storeLength >> 8) & 0xff

  appendAlign(out, item.align ?? 1)
  out.push(0x1d, 0x28, 0x6b, 0x04, 0x00, 0x31, 0x41, 0x32, 0x00)
  out.push(0x1d, 0x28, 0x6b, 0x03, 0x00, 0x31, 0x43, clamp(item.size || 5, 1, 16))
  out.push(0x1d, 0x28, 0x6b, 0x03, 0x00, 0x31, 0x45, normalizeEscQrLevel(item.level))
  out.push(0x1d, 0x28, 0x6b, pL, pH, 0x31, 0x50, 0x30)
  appendBytes(out, bytes)
  out.push(0x1d, 0x28, 0x6b, 0x03, 0x00, 0x31, 0x51, 0x30)
  out.push(0x0a)
}

export function buildEscPosTestPrintData (): number[] {
  return buildEscPosTemplateData(createTestPrintTemplate())
}

export function buildEscPosLabelData (payload: LabelPrintPayload): number[] {
  const {
    productName,
    labelId,
    printQty = 1,
    category = '',
    extraLine = '',
  } = payload

  return createEscDocument((out) => {
    appendAlign(out, 1)
    appendBold(out, true)
    appendSize(out, 1, 1)
    appendLine(out, 'FOOD LABEL')
    appendBold(out, false)
    appendSize(out, 0, 0)
    appendLine(out, '-----------------------------')
    appendLine(out, 'Product: ' + productName)
    if (category) appendLine(out, 'Category: ' + category)
    appendLine(out, 'Label ID: ' + labelId)
    if (extraLine) appendLine(out, extraLine)
    appendLine(out, 'Qty: ' + String(printQty))
    appendLine(out, '-----------------------------')
  })
}

export function buildEscPosImageData (
  image: MonochromeImageData,
  options: PrintImageOptions = {}
): number[] {
  const printQty = Math.max(1, Math.round(options.printQty || 1))
  return createEscDocument((out) => {
    for (let i = 0; i < printQty; i++) {
      appendAlign(out, 1)
      appendRasterImage(out, image)
      appendLine(out)
      appendLine(out)
    }
  })
}

export function buildEscPosTemplateData (
  template: StructuredLabelTemplate,
  data: LabelTemplateData = {}
): number[] {
  const resolved = resolveEscTemplate(template, data)
  const printQty = Math.max(1, Math.round(resolved.printQty || 1))
  const feedLines = Math.max(1, Math.round(resolved.feedLines || 4))

  return createEscDocument((out) => {
    for (let i = 0; i < printQty; i++) {
      resolved.items.forEach((item) => {
        if (item.type === 'rule') {
          appendHorizontalRule(out, item.width || 32)
          return
        }
        if (item.type === 'qrcode') {
          appendQrCode(out, item)
          return
        }
        if (item.type === 'barcode') {
          appendBarcode(out, item)
          return
        }
        appendAlign(out, item.align ?? 0)
        appendBold(out, !!item.bold)
        appendSize(out, item.widthScale || 0, item.heightScale || 0)
        appendLine(out, item.text)
        appendBold(out, false)
        appendSize(out, 0, 0)
      })
      out.push(0x1b, 0x64, feedLines)
    }
  })
}