/** * 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 = { 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 }