tscLabelBuilder.ts 8.24 KB
/**
 * TSC 标签指令构建(纯 TypeScript,无 require/import 外部 CJS 模块)
 * 使用 UTF-8 编码。对于纯英文/ASCII 内容,UTF-8 与 GB18030 字节一致,佳博打印机可正常打印。
 * 若需中文等非 ASCII,可考虑运行时动态加载 tsc.js(仅 APP 端)。
 */
import type { MonochromeImageData, PrintImageOptions, StructuredTscTemplate } from './types/printer'
import { toTscBarcodeSymbology } from '../barcodeFormat'

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

/** 将字符串转为 Windows-1252 字节数组,优先保证西文特殊字符可打印 */
function stringToPrinterBytes (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 addCommandBytes (out: number[], str: string) {
  const bytes = stringToPrinterBytes(str)
  for (let i = 0; i < bytes.length; i++) out.push(bytes[i])
}

function addTscLine (out: number[], str: string) {
  addCommandBytes(out, str + '\r\n')
}

function escapeTscString (s: string): string {
  return String(s).replace(/\\/g, '\\\\').replace(/"/g, '\\"')
}

function normalizeTscBarcodeType (value?: string): string {
  return toTscBarcodeSymbology(value)
}

/**
 * 构建标签打印指令字节数组(TSC)
 * 与官方 Demo createLabel 格式一致(SIZE 100x30, GAP 0)
 */
export function buildTscLabel (options: {
  productName: string
  labelId: string
  printQty?: number
  widthMm?: number
  heightMm?: number
  category?: string
  extraLine?: string
}): number[] {
  const {
    productName,
    labelId,
    printQty = 1,
    widthMm = 100,
    heightMm = 30,
    category = '',
    extraLine = '',
  } = options

  const out: number[] = []
  const add = (s: string) => addCommandBytes(out, s + '\r\n')
  let y = 10

  add(`SIZE ${widthMm} mm,${heightMm} mm`)
  add('GAP 0 mm,0 mm')
  add('CODEPAGE 1252')
  add('CLS')
  add(`TEXT 50,${y},"TSS24.BF2",0,1,1,"${escapeTscString(productName)}"`)
  y += 35
  if (category) {
    add(`TEXT 50,${y},"TSS24.BF2",0,1,1,"Category: ${escapeTscString(category)}"`)
    y += 35
  }
  add(`TEXT 50,${y},"TSS24.BF2",0,1,1,"ID: ${escapeTscString(labelId)}"`)
  y += 35
  if (extraLine) {
    add(`TEXT 50,${y},"TSS24.BF2",0,1,1,"${escapeTscString(extraLine)}"`)
    y += 35
  }
  add(`QRCODE 50,${y},L,5,A,0,"${escapeTscString(labelId)}"`)
  add(`PRINT 1,${printQty}`)
  add('FEED 1')

  return out
}

/**
 * 食品营养成分标签测试打印(Nutrition Facts 格式)
 */
export function buildTestTscLabel (): number[] {
  const out: number[] = []
  const add = (s: string) => addTscLine(out, s)

  add('SIZE 100 mm,65 mm')
  add('GAP 0 mm,0 mm')
  add('CODEPAGE 1252')
  add('CLS')
  add('BOX 20,20,780,500,3')
  add('BAR 20,90,760,3')
  add('BAR 20,215,760,2')
  add('BAR 20,335,760,2')
  add('BAR 520,90,2,410')

  add('TEXT 180,42,"TSS24.BF2",0,1,1,"IN USE FOOD LABEL"')

  add('TEXT 45,115,"TSS24.BF2",0,1,1,"Product"')
  add('TEXT 45,150,"TSS24.BF2",0,1,1,"Grilled Chicken Breast"')
  add('TEXT 45,182,"TSS24.BF2",0,1,1,"Prepared Protein / Hot Prep"')

  add('TEXT 45,240,"TSS24.BF2",0,1,1,"Prepared"')
  add('TEXT 45,275,"TSS24.BF2",0,1,1,"03/20/2026 10:30 AM"')
  add('TEXT 45,360,"TSS24.BF2",0,1,1,"Use By"')
  add('TEXT 45,395,"TSS24.BF2",0,1,1,"03/23/2026 10:30 AM"')
  add('TEXT 45,430,"TSS24.BF2",0,1,1,"Shelf Life 72 Hours"')

  add('TEXT 545,118,"TSS24.BF2",0,1,1,"Label ID"')
  add('TEXT 545,152,"TSS24.BF2",0,1,1,"TEST-260320-001"')
  add('TEXT 545,330,"TSS24.BF2",0,1,1,"Status: ACTIVE"')
  add('TEXT 545,365,"TSS24.BF2",0,1,1,"Station: LINE 1"')
  add('TEXT 545,400,"TSS24.BF2",0,1,1,"User: TEST USER"')
  add('QRCODE 555,190,L,4,A,0,"TEST-260320-001"')

  add('PRINT 1,1')
  add('FEED 1')

  return out
}

export function buildTscTemplateLabel (template: StructuredTscTemplate): number[] {
  const out: number[] = []
  const add = (s: string) => addTscLine(out, s)

  add(`SIZE ${template.widthMm} mm,${template.heightMm} mm`)
  add(`GAP ${template.gapMm || 0} mm,0 mm`)
  add('CODEPAGE 1252')
  if (template.density != null) add(`DENSITY ${template.density}`)
  if (template.speed != null) add(`SPEED ${template.speed}`)
  add('CLS')

  template.items.forEach((item) => {
    if (item.type === 'box') {
      const right = item.x + item.width
      const bottom = item.y + item.height
      add(`BOX ${item.x},${item.y},${right},${bottom},${item.lineWidth || 1}`)
      return
    }
    if (item.type === 'bar') {
      add(`BAR ${item.x},${item.y},${item.width},${item.height}`)
      return
    }
    if (item.type === 'qrcode') {
      add(`QRCODE ${item.x},${item.y},${item.level || 'L'},${item.cellWidth || 4},${item.mode || 'A'},0,"${escapeTscString(item.value)}"`)
      return
    }
    if (item.type === 'barcode') {
      add(`BARCODE ${item.x},${item.y},"${normalizeTscBarcodeType(item.symbology)}",${Math.max(20, Math.round(item.height || 80))},${item.readable === false ? 0 : 1},${item.rotation || 0},${Math.max(1, Math.round(item.narrow || 2))},${Math.max(2, Math.round(item.wide || 2))},"${escapeTscString(item.value)}"`)
      return
    }
    if (item.type === 'bitmap') {
      const bytesPerRow = item.image.width / 8
      const bitmapBytes = pixelsToTscBitmapBytes(item.image)
      addCommandBytes(out, `BITMAP ${item.x},${item.y},${bytesPerRow},${item.image.height},0,`)
      for (let i = 0; i < bitmapBytes.length; i++) out.push(bitmapBytes[i])
      out.push(0x0d, 0x0a)
      return
    }
    add(`TEXT ${item.x},${item.y},"${item.font || 'TSS24.BF2'}",${item.rotation || 0},${item.xScale || 1},${item.yScale || 1},"${escapeTscString(item.text)}"`)
  })

  add(`PRINT 1,${Math.max(1, Math.round(template.printQty || 1))}`)
  return out
}

function roundMm (value: number): string {
  return (Math.round(value * 10) / 10).toFixed(1)
}

function pixelsToTscBitmapBytes (image: MonochromeImageData): number[] {
  const bytes: number[] = []
  const bytesPerRow = image.width / 8
  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]
        const isWhite = pixel ? 0 : 1
        value |= isWhite << (7 - bit)
      }
      bytes.push(value & 0xff)
    }
  }
  return bytes
}

export function buildTscImageLabel (
  image: MonochromeImageData,
  options: PrintImageOptions = {},
  dpi = 203
): number[] {
  const out: number[] = []
  const add = (s: string) => addCommandBytes(out, s + '\r\n')
  const widthMm = options.widthMm || (image.width * 25.4 / dpi)
  const heightMm = options.heightMm || (image.height * 25.4 / dpi)
  const x = Math.max(0, Math.round(options.x || 0))
  const y = Math.max(0, Math.round(options.y || 0))
  const printQty = Math.max(1, Math.round(options.printQty || 1))
  const bytesPerRow = image.width / 8
  const bitmapBytes = pixelsToTscBitmapBytes(image)

  add(`SIZE ${roundMm(widthMm)} mm,${roundMm(heightMm)} mm`)
  add('GAP 0 mm,0 mm')
  add('CODEPAGE 1252')
  add('DENSITY 14')
  add('SPEED 5')
  add('CLS')
  addCommandBytes(out, `BITMAP ${x},${y},${bytesPerRow},${image.height},0,`)
  for (let i = 0; i < bitmapBytes.length; i++) out.push(bitmapBytes[i])
  out.push(0x0d, 0x0a)
  add(`PRINT 1,${printQty}`)
  add('FEED 1')

  return out
}