import { blePairRequiresWriteNoResponse } from '../bleWriteModeRules' import { clearPrinter, ensureBleUartNotifyIfNeeded, getBluetoothConnection, getCurrentPrinterDriverKey, getPrinterType, getStoredUposPrintOptions, isNativeBaseClassicBluetoothTransport, sendToPrinter, setBluetoothConnection, setBuiltinPrinter, } from '../printerConnection' // @ts-ignore - js bridge module (app-plus only) import classicBluetooth from '../bluetoothTool.js' import { rasterizeImageData, rasterizeImageForPrinter } from '../imageRaster' import { buildEscPosImageData, buildEscPosTemplateData } from '../protocols/escPosBuilder' import { buildTscImageData, buildTscTemplateData } from '../protocols/tscProtocol' import type { LabelPrintJobPayload } from '../../labelPreview/buildLabelPrintPayload' import { connectNativeFastPrinter as connectNativeFastPrinterPlugin, disconnectNativeFastPrinter as disconnectNativeFastPrinterPlugin, isNativeFastPrinterAvailable, printNativeFastFromLabelPrintJob, printNativeFastTemplate as printNativeFastTemplatePlugin, } from '../nativeFastPrinter' import { getLabelPrintRasterLayout, renderLabelPreviewCanvasImageDataForPrint, renderLabelPreviewCanvasToTempPathForPrint, } from '../../labelPreview/renderLabelPreviewCanvas' import { storedValueLooksLikeImagePath } from '../../resolveMediaUrl' import { printRunDiag } from '../printRunDiagnostics' import { adaptSystemLabelTemplate } from '../systemTemplateAdapter' import { hydrateSystemTemplateImagesForPrint } from '../hydrateTemplateImagesForPrint' import { TEST_PRINT_SYSTEM_TEMPLATE, TEST_PRINT_TEMPLATE_DATA } from '../templates/testPrintTemplate' import { describePrinterCandidate, getPrinterDriverByKey, resolvePrinterDriver } from './driverRegistry' import type { CurrentPrinterSummary, LabelPrintPayload, LabelTemplateData, RawImageDataSource, PrintImageOptions, PrinterCandidate, PrinterDriver, StructuredLabelTemplate, SystemLabelTemplate, } from '../types/printer' type LastImagePrintDebug = { protocol: string rasterWidth: number rasterHeight: number blackPixels: number totalPixels: number blackRatio: number commandBytes: number updatedAt: number } let lastImagePrintDebug: LastImagePrintDebug | null = null export function getLastImagePrintDebug (): LastImagePrintDebug | null { return lastImagePrintDebug } function getPrinterTypeDisplayName (type: '' | 'bluetooth' | 'builtin'): string { if (type === 'bluetooth') return 'Bluetooth' if (type === 'builtin') return 'Built-in' return '' } function connectClassicBluetooth (device: PrinterCandidate, driver: PrinterDriver): Promise { return new Promise((resolve, reject) => { // #ifdef APP-PLUS /** * 一体机 / Virtual BT / d320fax:在已集成 native-fast-printer(安卓基座 AAR)时优先走佳博 SDK 连接; * 不再强制仅用 JS 经典蓝牙,否则光栅大包易超时,且与「走基座」需求不符。 * gp-d320fx、ESC 等仍走通用 socket。 */ const shouldUseGenericClassicOnly = driver.key === 'gp-d320fx' || driver.key === 'generic-tsc' || driver.protocol === 'esc' const connectClassicSocketFallback = () => { try { if (!classicBluetooth || typeof classicBluetooth.connDevice !== 'function') { reject(new Error('Classic Bluetooth fallback is not available.')) return } classicBluetooth.connDevice(device.deviceId, (ok: boolean) => { if (ok) { setBluetoothConnection({ deviceId: device.deviceId, deviceName: device.name || 'Bluetooth Printer', deviceType: 'classic', transportMode: 'generic', driverKey: driver.key, mtu: driver.preferredBleMtu || 20, }) /** * connDevice 回调 ok 可能早于 socket/outputStream 就绪; * 若立刻测试打印,会出现 state=idle、outputReady=false。 */ const start = Date.now() const poll = () => { const st = typeof classicBluetooth.getDebugState === 'function' ? classicBluetooth.getDebugState() : null const ready = !!st?.outputReady && (!!st?.socketConnected || String(st?.connectionState || '').toLowerCase() === 'connected') if (ready) { resolve() return } if (Date.now() - start > 2500) { reject(new Error('Classic Bluetooth connection is not ready')) return } setTimeout(poll, 120) } poll() return } const message = typeof classicBluetooth.getLastError === 'function' ? classicBluetooth.getLastError() : '' reject(new Error(message || 'Classic Bluetooth connection failed.')) }) } catch (error: any) { reject(error instanceof Error ? error : new Error(String(error || 'Classic Bluetooth connection failed.'))) } } if (!shouldUseGenericClassicOnly && isNativeFastPrinterAvailable()) { connectNativeFastPrinterPlugin({ deviceId: device.deviceId, deviceName: device.name || 'Bluetooth Printer', }).then(() => { setBluetoothConnection({ deviceId: device.deviceId, deviceName: device.name || 'Bluetooth Printer', deviceType: 'classic', transportMode: 'native-plugin', driverKey: driver.key, mtu: driver.preferredBleMtu || 20, }) resolve() }).catch((error: any) => { if (driver.key === 'd320fax') { connectClassicSocketFallback() return } reject(error instanceof Error ? error : new Error(String(error || 'Classic Bluetooth connection failed.'))) }) return } if (driver.key === 'd320fax' || shouldUseGenericClassicOnly) { connectClassicSocketFallback() return } reject(new Error('NATIVE_FAST_PRINTER_PLUGIN_NOT_FOUND. Please rebuild the custom base with native-fast-printer.')) // #endif // #ifndef APP-PLUS reject(new Error('Classic Bluetooth requires the app.')) // #endif }) } /** * 优先带响应 write(与 uni 默认写入方式一致);仅当没有 write 再用 writeNoResponse(需在下发时传 writeType)。 * 若系统 GATT 只声明 write、未声明 writeNoResponse,却强行 writeNoResponse,会报 property not support (10007)。 */ function hasBleWriteProperty (item: any): boolean { const w = item.properties?.write return w === true || w === 'true' } function hasBleWriteNoResponseProperty (item: any): boolean { const p = item.properties || {} return ( p.writeNoResponse === true || p.writeNoResponse === 'true' || p.writeWithoutResponse === true || p.writeWithoutResponse === 'true' ) } /** * 无 writeNoResponse 属性则绝不走 Command 写。 * Nordic 白名单在「同时声明 write + writeNoResponse」时优先无响应写(佳博等实测);否则有 write 时优先带响应。 */ function pickBleWriteUsesNoResponse (serviceId: string, item: any): boolean { const hw = hasBleWriteProperty(item) const hn = hasBleWriteNoResponseProperty(item) if (!hn) return false if (!hw) return true return blePairRequiresWriteNoResponse(serviceId, String(item.uuid || '')) } function findBleWriteCharacteristic (deviceId: string): Promise<{ serviceId: string characteristicId: string bleWriteUsesNoResponse: boolean } | null> { return new Promise((resolve) => { uni.getBLEDeviceServices({ deviceId, success: (serviceRes) => { const services = serviceRes.services || [] const next = (index: number) => { if (index >= services.length) { resolve(null) return } const serviceId = services[index].uuid uni.getBLEDeviceCharacteristics({ deviceId, serviceId, success: (charRes) => { const chars = charRes.characteristics || [] const writable = (item: any) => hasBleWriteProperty(item) || hasBleWriteNoResponseProperty(item) for (const item of chars) { const cid = String(item.uuid || '') if (blePairRequiresWriteNoResponse(serviceId, cid) && writable(item)) { resolve({ serviceId, characteristicId: cid, bleWriteUsesNoResponse: pickBleWriteUsesNoResponse(serviceId, item), }) return } } const withResp = chars.find(hasBleWriteProperty) const noResp = chars.find(hasBleWriteNoResponseProperty) const target = withResp || noResp if (target) { resolve({ serviceId, characteristicId: String(target.uuid || ''), bleWriteUsesNoResponse: pickBleWriteUsesNoResponse(serviceId, target), }) return } next(index + 1) }, fail: () => next(index + 1), }) } next(0) }, fail: () => resolve(null), }) }) } function requestBleMtu (deviceId: string, preferredMtu: number): Promise { return new Promise((resolve) => { const targetMtu = Math.max(20, Math.min(512, Math.round(preferredMtu || 20))) if (targetMtu <= 20 || typeof (uni as any).setBLEMTU !== 'function') { resolve(20) return } let settled = false const done = (value: number) => { if (settled) return settled = true clearTimeout(timer) resolve(Math.max(20, Math.round(value || 20))) } const timer = setTimeout(() => done(20), 3000) ;(uni as any).setBLEMTU({ deviceId, mtu: targetMtu, success: (res: any) => done(Number(res?.mtu) || targetMtu), fail: () => done(20), }) }) } function connectBlePrinter (device: PrinterCandidate, driver: PrinterDriver): Promise { const finalizeExistingBleConnection = async () => { const write = await findBleWriteCharacteristic(device.deviceId) if (!write) { throw new Error('No writable characteristic found. This device may not support printing.') } await ensureBleUartNotifyIfNeeded(device.deviceId, write.serviceId, write.characteristicId) const negotiatedMtu = await requestBleMtu(device.deviceId, driver.preferredBleMtu || 20) setBluetoothConnection({ deviceId: device.deviceId, deviceName: device.name || 'Bluetooth Printer', serviceId: write.serviceId, characteristicId: write.characteristicId, deviceType: 'ble', mtu: negotiatedMtu, driverKey: driver.key, bleWriteUsesNoResponse: write.bleWriteUsesNoResponse, }) } return new Promise((resolve, reject) => { uni.createBLEConnection({ deviceId: device.deviceId, timeout: 10000, success: async () => { try { await finalizeExistingBleConnection() resolve() } catch (e: any) { reject(e) } }, fail: (err: any) => { if (err?.errCode === -1) { finalizeExistingBleConnection().then(() => resolve()).catch(reject) } else { reject(new Error(err?.errMsg || 'BLE connection failed.')) } }, }) }) } export async function connectBluetoothPrinter (device: PrinterCandidate): Promise { const driver = resolvePrinterDriver(device) if (driver.key === 'gp-d320fx') { try { await connectBlePrinter(device, driver) } catch (_) { await connectClassicBluetooth(device, driver) } return driver } const resolvedType = driver.resolveConnectionType(device) if (resolvedType === 'classic') { await connectClassicBluetooth(device, driver) } else { await connectBlePrinter(device, driver) } return driver } export function useBuiltinPrinter (driverKey = 'generic-tsc') { setBuiltinPrinter(driverKey) } export function getCurrentPrinterDriver (): PrinterDriver { const type = getPrinterType() const storedKey = getCurrentPrinterDriverKey() if (storedKey) return getPrinterDriverByKey(storedKey) if (type === 'bluetooth') { const connection = getBluetoothConnection() if (connection) { return resolvePrinterDriver({ deviceId: connection.deviceId, name: connection.deviceName, type: connection.deviceType, }) } } if (type === 'builtin') { return getPrinterDriverByKey('generic-tsc') } return getPrinterDriverByKey('generic-tsc') } export function getCurrentPrinterSummary (): CurrentPrinterSummary { const type = getPrinterType() const driver = getCurrentPrinterDriver() if (type === 'builtin') { return { type, displayName: getPrinterTypeDisplayName(type), deviceId: 'builtin', driverKey: driver.key, driverName: driver.displayName, protocol: driver.protocol, deviceType: '', } } if (type === 'bluetooth') { const connection = getBluetoothConnection() if (connection) { return { type, displayName: getPrinterTypeDisplayName(type), deviceId: connection.deviceId, driverKey: driver.key, driverName: driver.displayName, protocol: driver.protocol, deviceType: connection.deviceType, } } } return { type: '', displayName: '', deviceId: '', driverKey: driver.key, driverName: driver.displayName, protocol: driver.protocol, deviceType: '', } } /** * 内置 UPOS 的 `printNormal` 按「原始字节」打出;多数一体机机芯为 ESC/POS 通道,下发 TSPL 会被当成普通字符印在纸上(与预览 JSON 不符)。 * 内置一律不走原生 TSPL printTemplate,改走预览同源 Canvas → PNG → ESC 光栅(resolveRasterPrintDriver 映射 generic-esc)。 */ function canUseBuiltinNativeFastTemplatePrint (): boolean { return false } function canUseNativeFastTemplatePrint (driver: PrinterDriver): boolean { if (driver.protocol !== 'tsc' || !isNativeFastPrinterAvailable()) return false if (isNativeBaseClassicBluetoothTransport()) return true return canUseBuiltinNativeFastTemplatePrint() } function templateHasQrDataForCommandPrint (template: SystemLabelTemplate): boolean { const elements = template.elements || [] for (const el of elements) { const type = String(el.type || '').toUpperCase() if (type !== 'QRCODE') continue const cfg = (el.config || {}) as Record const raw = String( cfg.data ?? cfg.Data ?? cfg.value ?? cfg.Value ?? cfg.src ?? cfg.Src ?? cfg.url ?? cfg.Url ?? '', ).trim() if (!raw) continue if (storedValueLooksLikeImagePath(raw)) continue return true } return false } function templateHasUnsupportedElementsForCommandPrint (template: SystemLabelTemplate): boolean { const elements = template.elements || [] for (const el of elements) { const type = String(el.type || '').toUpperCase() if (type.startsWith('TEXT_')) continue if ( type === 'WEIGHT' || type === 'DATE' || type === 'TIME' || type === 'DURATION' || type === 'NUTRITION' || type === 'QRCODE' || type === 'BARCODE' || type === 'IMAGE' || type === 'BLANK' ) { continue } return true } return false } /** 预览/业务侧:是否可走 native-fast-printer 的 printTemplate(templateJson + dataJson) */ export function canPrintCurrentLabelViaNativeFastJob (): boolean { return canUseNativeFastTemplatePrint(getCurrentPrinterDriver()) } /** * 整页标签光栅是否一律走 canvasGetImageData(纯 JS 二值化),而不用「PNG → Bitmap.getPixels → readJavaIntArrayValue」。 * 内置 UPOS 一体机在部分 Uni 基座上 int[] 桥接读数恒为 0 → black=0、长时间重试/超时;所有 **打印机类型选内置** 的机型统一走本路径。 * 若以后需按 Android 型号再细分,只改此函数即可。 */ export function shouldRasterPrintViaCanvasImageData (): boolean { return getPrinterType() === 'builtin' } /** * 将 buildLabelPrintJobPayload / getLastLabelPrintJobPayload 同构数据送入原生 printTemplate; * 与 JSON.stringify(payload.template) + JSON.stringify(payload.printInputJson) 一致。 */ export async function printLabelPrintJobPayloadForCurrentPrinter ( payload: LabelPrintJobPayload, options: { printQty?: number } = {}, onProgress?: (percent: number) => void ): Promise { const driver = getCurrentPrinterDriver() const connection = getBluetoothConnection() if ( driver.protocol === 'tsc' && connection?.deviceType === 'classic' && connection?.transportMode === 'native-plugin' && !isNativeFastPrinterAvailable() ) { throw new Error('NATIVE_FAST_PRINTER_PLUGIN_NOT_FOUND. Please rebuild the custom base with native-fast-printer.') } if (!canUseNativeFastTemplatePrint(driver)) { throw new Error('Native fast template print is not available for the current printer.') } const printQty = Math.max(1, options.printQty ?? payload.meta?.printQuantity ?? 1) if (canUseBuiltinNativeFastTemplatePrint()) { const upos = getStoredUposPrintOptions() await printNativeFastFromLabelPrintJob({ deviceId: 'builtin-upos', deviceName: 'Built-in', payload, dpi: driver.imageDpi || 203, printQty, outputTransport: 'upos', uposPrefer: upos.prefer, uposSerialPath: upos.serialPath || '', uposBaudrate: upos.baudrate, }) if (onProgress) onProgress(100) return driver } const nativeConnection = getNativeClassicConnection() if (!nativeConnection) { throw new Error('Native classic Bluetooth connection is not ready.') } await printNativeFastFromLabelPrintJob({ deviceId: nativeConnection.deviceId, deviceName: nativeConnection.deviceName, payload, dpi: driver.imageDpi || 203, printQty, }) if (onProgress) onProgress(100) return driver } function getNativeClassicConnection () { const connection = getBluetoothConnection() if (!connection || connection.deviceType !== 'classic' || connection.transportMode !== 'native-plugin') return null return connection } /** * 连接自检用测试页;**不要**在此处调用 `postUsAppLabelPrint` / 接口 9(仅预览页业务打印落库)。 */ export async function testPrintCurrentPrinter (onProgress?: (percent: number) => void): Promise { const driver = getCurrentPrinterDriver() const connection = getBluetoothConnection() if ( driver.protocol === 'tsc' && connection?.deviceType === 'classic' && connection?.transportMode === 'native-plugin' && !isNativeFastPrinterAvailable() ) { throw new Error('NATIVE_FAST_PRINTER_PLUGIN_NOT_FOUND. Please rebuild the custom base with native-fast-printer.') } if (canUseNativeFastTemplatePrint(driver)) { if (canUseBuiltinNativeFastTemplatePrint()) { const upos = getStoredUposPrintOptions() await printNativeFastTemplatePlugin({ deviceId: 'builtin-upos', deviceName: 'Built-in', template: TEST_PRINT_SYSTEM_TEMPLATE, data: TEST_PRINT_TEMPLATE_DATA, dpi: driver.imageDpi || 203, printQty: 1, outputTransport: 'upos', uposPrefer: upos.prefer, uposSerialPath: upos.serialPath || '', uposBaudrate: upos.baudrate, }) if (onProgress) onProgress(100) return driver } const nativeConnection = getNativeClassicConnection() if (nativeConnection) { await printNativeFastTemplatePlugin({ deviceId: nativeConnection.deviceId, deviceName: nativeConnection.deviceName, template: TEST_PRINT_SYSTEM_TEMPLATE, data: TEST_PRINT_TEMPLATE_DATA, dpi: driver.imageDpi || 203, printQty: 1, }) if (onProgress) onProgress(100) return driver } } // d320fax 在部分安卓设备上经 sendToPrinter Promise 链会无回调卡住;测试页走经典蓝牙直发更稳定。 if (driver.key === 'd320fax' && connection?.deviceType === 'classic') { // #ifdef APP-PLUS try { const payload = driver.buildTestPrintData().map((byte) => { const v = byte & 0xff return v >= 128 ? v - 256 : v }) if (onProgress) onProgress(10) /** * 部分设备会出现“连接状态看似为 connected,但实际输出流不可写”的假连接。 * 测试打印前强制重连一次 classic socket,确保 btOutStream 新鲜可用。 */ await new Promise((resolve, reject) => { if (typeof classicBluetooth?.connDevice !== 'function') { reject(new Error('D320FAX_CLASSIC_CONN_API_MISSING')) return } let done = false const timer = setTimeout(() => { if (done) return done = true reject(new Error('D320FAX_CLASSIC_RECONNECT_TIMEOUT')) }, 8000) try { classicBluetooth.connDevice(connection.deviceId, (ok: boolean) => { if (done) return done = true clearTimeout(timer) if (ok) resolve() else reject(new Error(classicBluetooth.getLastError?.() || 'D320FAX_CLASSIC_RECONNECT_FAILED')) }) } catch (e: any) { if (done) return done = true clearTimeout(timer) reject(e instanceof Error ? e : new Error(String(e || 'D320FAX_CLASSIC_RECONNECT_EXCEPTION'))) } }) if (typeof classicBluetooth?.ensureConnection === 'function') { classicBluetooth.ensureConnection(connection.deviceId) } if (onProgress) onProgress(30) const ok = await new Promise((resolve) => { let done = false const timer = setTimeout(() => { if (done) return done = true resolve(false) }, 10000) try { if (typeof classicBluetooth?.sendByteDataAsync === 'function') { const started = classicBluetooth.sendByteDataAsync(payload, (sendOk: boolean) => { if (done) return done = true clearTimeout(timer) resolve(!!sendOk) }) if (started === false) { done = true clearTimeout(timer) resolve(false) } return } done = true clearTimeout(timer) resolve(false) } catch { done = true clearTimeout(timer) resolve(false) } }) if (!ok) { const err = typeof classicBluetooth?.getLastError === 'function' ? classicBluetooth.getLastError() : '' const debug = typeof classicBluetooth?.getDebugState === 'function' ? classicBluetooth.getDebugState() : null const details = [ `device=${connection.deviceId}`, `state=${String(debug?.connectionState || '-')}`, `connected=${String(!!debug?.socketConnected)}`, `outputReady=${String(!!debug?.outputReady)}`, `sendMode=${String(debug?.lastSendMode || '-')}`, err ? `lastError=${err}` : '', ].filter(Boolean).join('\n') throw new Error(`D320FAX_CLASSIC_SEND_TIMEOUT_OR_FAILED\n${details}`) } if (onProgress) onProgress(100) return driver } catch (e: any) { throw (e instanceof Error ? e : new Error(String(e || 'D320FAX_CLASSIC_SEND_EXCEPTION'))) } // #endif } /** 内置 UPOS 同业务打印:勿发 TSC 自检页(会被当文本打出) */ if (getPrinterType() === 'builtin') { await sendToPrinter(getPrinterDriverByKey('generic-esc').buildTestPrintData(), onProgress) return driver } await sendToPrinter(driver.buildTestPrintData(), onProgress) return driver } export async function printLabelForCurrentPrinter ( payload: LabelPrintPayload, onProgress?: (percent: number) => void ): Promise { const driver = getCurrentPrinterDriver() await sendToPrinter(driver.buildLabelData(payload), onProgress) return driver } /** * 界面仍可存 generic-tsc;内置 UPOS 实际需 ESC/POS 位图,故整页光栅强制 generic-esc(与预览像素一致,避免 TSPL 当文本打出)。 */ export function resolveRasterPrintDriver (driver: PrinterDriver): PrinterDriver { if (getPrinterType() === 'builtin' && driver.protocol === 'tsc') { return getPrinterDriverByKey('generic-esc') } return driver } export async function printImageForCurrentPrinter ( imageSource: string, options: PrintImageOptions = {}, onProgress?: (percent: number) => void ): Promise { const driver = getCurrentPrinterDriver() const rasterDriver = resolveRasterPrintDriver(driver) const printerType = getPrinterType() const startedAt = Date.now() const withStageTimeout = async (promise: Promise, ms: number, stage: string): Promise => { return await new Promise((resolve, reject) => { let settled = false const timer = setTimeout(() => { if (settled) return settled = true reject(new Error(`PRINT_STAGE_TIMEOUT:${stage}:${ms}`)) }, ms) promise.then( (v) => { if (settled) return settled = true clearTimeout(timer) resolve(v) }, (e) => { if (settled) return settled = true clearTimeout(timer) reject(e) } ) }) } console.warn( '[print-image] start' + ' printerType=' + String(printerType || '-') + ' driver=' + String(driver.key || '-') + ' rasterDriver=' + String(rasterDriver.key || '-') + ' source=' + String(imageSource ? 'ok' : 'empty') ) printRunDiag('printImage_start', { printerType, driver: driver.key, rasterDriver: rasterDriver.key, rasterProtocol: rasterDriver.protocol, }) if (onProgress) onProgress(2) const rasterOptions: PrintImageOptions = { ...options } if (onProgress) { const prevRasterCb = options.onRasterizeProgress rasterOptions.onRasterizeProgress = (sub) => { try { prevRasterCb?.(sub) } catch (_) {} onProgress(2 + Math.round((Math.min(100, Math.max(0, sub)) / 100) * 16)) } } const rasterStart = Date.now() let raster = rasterDriver.protocol === 'esc' ? await rasterizeImageForPrinter(imageSource, rasterDriver, rasterOptions) : await withStageTimeout( rasterizeImageForPrinter(imageSource, rasterDriver, rasterOptions), 120000, 'rasterize' ) console.warn( '[print-image] rasterize done' + ' ms=' + String(Date.now() - rasterStart) + ' width=' + String(raster.width) + ' height=' + String(raster.height) ) printRunDiag('printImage_rasterize_done', { ms: Date.now() - rasterStart, w: raster.width, h: raster.height, }) const calcBlackStats = (r: { width: number; height: number; pixels: number[] }) => { const totalPixels = Math.max(1, r.width * r.height) let blackPixels = 0 for (let i = 0; i < r.pixels.length; i++) { if (r.pixels[i]) blackPixels++ } const blackRatio = blackPixels / totalPixels return { totalPixels, blackPixels, blackRatio } } let { totalPixels, blackPixels, blackRatio } = calcBlackStats(raster) if (blackRatio <= 0) { printRunDiag('printImage_raster_retry_black0', { blackPixels, totalPixels, firstPassMs: Date.now() - rasterStart, }) // 某些复杂模板在特定阈值/清顶行下会被二值化成“全白”。 // 为了避免继续抛错,这里做一次失败后重光栅兜底:逐步降低 threshold,并在必要时不再清顶行。 const tried: string[] = [] const baseClearTop = options.clearTopRasterRows const thresholdCandidates = [150, 130, 110, 90] const clearTopCandidates: Array = [ baseClearTop, 0, // 兜底:避免把内容切掉(printSystemTemplateForCurrentPrinter 里默认 clearTopRasterRows=1) ].filter((v, i, arr) => v !== undefined || arr.indexOf(undefined) === i) const rasterAttempts = async () => { for (const clearTop of clearTopCandidates) { for (const threshold of thresholdCandidates) { tried.push(`clearTop=${String(clearTop)};threshold=${threshold}`) /** 重试光栅勿再回调 onRasterizeProgress,否则会从 2% 重新映射,UI 像「15% 突然掉回 5%」 */ const nextRaster = await rasterizeImageForPrinter( imageSource, rasterDriver, { ...rasterOptions, threshold, ...(typeof clearTop === 'number' ? { clearTopRasterRows: clearTop } : {}), onRasterizeProgress: undefined, }, ) const stats = calcBlackStats(nextRaster) console.warn( '[print-image] raster retry stats' + ` clearTop=${String(clearTop)}` + ` threshold=${threshold}` + ` black=${stats.blackPixels}/${stats.totalPixels}` + ` ratio=${stats.blackRatio.toFixed(4)}` ) if (stats.blackRatio > 0) { raster = nextRaster totalPixels = stats.totalPixels blackPixels = stats.blackPixels blackRatio = stats.blackRatio return true } } } return false } const ok = await rasterAttempts() if (!ok) { throw new Error( `RASTER_EMPTY_IMAGE:protocol=${String(driver.protocol || '-')};size=${raster.width}x${raster.height};pixels=${blackPixels}/${totalPixels};tried=${tried.join('|')}` ) } } let data: number[] = [] if (rasterDriver.protocol === 'esc') { data = buildEscPosImageData(raster, options) } else { data = buildTscImageData(raster, options, rasterDriver.imageDpi || 203) } lastImagePrintDebug = { protocol: String(rasterDriver.protocol || '-'), rasterWidth: raster.width, rasterHeight: raster.height, blackPixels, totalPixels, blackRatio, commandBytes: data.length, updatedAt: Date.now(), } console.warn( '[print-image] raster-stats' + ' logicalDriver=' + String(driver.key || '-') + ' protocol=' + String(lastImagePrintDebug.protocol) + ' size=' + String(raster.width) + 'x' + String(raster.height) + ' black=' + String(blackPixels) + '/' + String(totalPixels) + ' ratio=' + String(blackRatio.toFixed(4)) + ' bytes=' + String(data.length) ) console.warn( '[print-image] build bytes done' + ' protocol=' + String(rasterDriver.protocol || '-') + ' bytes=' + String(data.length) + ' elapsed=' + String(Date.now() - startedAt) ) printRunDiag('printImage_commands_built', { protocol: rasterDriver.protocol, commandBytes: data.length, elapsedMs: Date.now() - startedAt, }) if (onProgress) onProgress(22) const sendStart = Date.now() let sendVisualProgress = 22 let lastExternalProgressAt = Date.now() const reportSendProgress = (p: number) => { if (!onProgress) return const u = Math.min(100, Math.max(0, Math.round(p))) const mapped = 22 + Math.round((u / 100) * 78) if (mapped > sendVisualProgress) { sendVisualProgress = mapped onProgress(mapped) } else { lastExternalProgressAt = Date.now() } } const sendProgressKeepAlive = onProgress ? setInterval(() => { // 内置/部分传输链路不会持续回调进度,UI 会停在 22%;这里做轻量保活推进。 if (Date.now() - lastExternalProgressAt < 1200) return if (sendVisualProgress >= 96) return sendVisualProgress += sendVisualProgress < 80 ? 2 : 1 onProgress(sendVisualProgress) }, 800) : null printRunDiag('printImage_send_start', { bytes: data.length }) if (rasterDriver.protocol === 'esc') { try { await sendToPrinter(data, (p) => { lastExternalProgressAt = Date.now() reportSendProgress(p) if (p === 0 || p === 50 || p === 100) { printRunDiag('printImage_send_progress', { p }) } }) } finally { if (sendProgressKeepAlive) clearInterval(sendProgressKeepAlive) } } else { try { await withStageTimeout( sendToPrinter(data, (p) => { lastExternalProgressAt = Date.now() reportSendProgress(p) if (p === 0 || p === 50 || p === 100) { printRunDiag('printImage_send_progress', { p }) } }), 180000, 'sendToPrinter' ) } finally { if (sendProgressKeepAlive) clearInterval(sendProgressKeepAlive) } } console.warn( '[print-image] send done' + ' ms=' + String(Date.now() - sendStart) + ' total=' + String(Date.now() - startedAt) ) printRunDiag('printImage_send_done', { sendMs: Date.now() - sendStart, totalMs: Date.now() - startedAt, }) if (onProgress) onProgress(100) return driver } export async function printImageDataForCurrentPrinter ( imageData: RawImageDataSource, options: PrintImageOptions = {}, onProgress?: (percent: number) => void ): Promise { const driver = getCurrentPrinterDriver() const rasterDriver = resolveRasterPrintDriver(driver) const raster = rasterizeImageData(imageData, options) if (onProgress) onProgress(5) const data = rasterDriver.protocol === 'esc' ? buildEscPosImageData(raster, options) : buildTscImageData(raster, options, rasterDriver.imageDpi || 203) await sendToPrinter(data, onProgress) return driver } export async function printTemplateForCurrentPrinter ( template: StructuredLabelTemplate, data: LabelTemplateData = {}, onProgress?: (percent: number) => void ): Promise { const driver = getCurrentPrinterDriver() const bytes = driver.protocol === 'esc' ? buildEscPosTemplateData(template, data) : buildTscTemplateData(template, data) await sendToPrinter(bytes, onProgress) return driver } /** 与预览页「整页光栅」分支一致:用同一套 canvas 绘制再下发位图(/picture/、中文、¥ 与屏幕一致) */ export type SystemTemplatePrintCanvasRasterOptions = { canvasId: string componentInstance: any /** 绘制前把隐藏 canvas 的 width/height(像素)设为 layout.outW/outH,并 await nextTick */ applyLayout?: (layout: { cw: number ch: number outW: number outH: number scale: number }) => void | Promise } export async function printSystemTemplateForCurrentPrinter ( template: SystemLabelTemplate, data: LabelTemplateData = {}, options: { printQty?: number canvasRaster?: SystemTemplatePrintCanvasRasterOptions } = {}, onProgress?: (percent: number) => void ): Promise { const driver = getCurrentPrinterDriver() const rasterDriver = resolveRasterPrintDriver(driver) const canvasRaster = options.canvasRaster const bypassCanvasRasterForQr = !!canvasRaster && templateHasQrDataForCommandPrint(template) && !templateHasUnsupportedElementsForCommandPrint(template) if (canvasRaster && !bypassCanvasRasterForQr) { if (onProgress) onProgress(1) const maxDots = rasterDriver.imageMaxWidthDots || (rasterDriver.protocol === 'esc' ? 384 : 576) const layout = getLabelPrintRasterLayout(template, maxDots, rasterDriver.imageDpi || 203) if (onProgress) onProgress(4) if (canvasRaster.applyLayout) { await canvasRaster.applyLayout(layout) } if (onProgress) onProgress(7) await new Promise((r) => setTimeout(r, 50)) if (onProgress) onProgress(9) const printOpts: PrintImageOptions = { printQty: options.printQty || 1, clearTopRasterRows: 1, targetWidthDots: layout.outW, targetHeightDots: layout.outH, } const mapRasterProgress = onProgress ? (p: number) => { onProgress(12 + Math.round((Math.min(100, Math.max(0, p)) / 100) * 88)) } : undefined /** 内置一体机:统一 canvas 取像素,避免 Bitmap int[] 桥接假 0 */ if (shouldRasterPrintViaCanvasImageData()) { try { const imageData = await renderLabelPreviewCanvasImageDataForPrint( canvasRaster.canvasId, canvasRaster.componentInstance, template, layout, ) if (onProgress) onProgress(12) await printImageDataForCurrentPrinter(imageData, printOpts, mapRasterProgress) return driver } catch (e) { console.warn('[print] builtin canvasGetImageData failed, fallback PNG→Bitmap raster', e) } } const tmpPath = await renderLabelPreviewCanvasToTempPathForPrint( canvasRaster.canvasId, canvasRaster.componentInstance, template, layout, ) if (onProgress) onProgress(12) await printImageForCurrentPrinter(tmpPath, printOpts, mapRasterProgress) return driver } const connection = getBluetoothConnection() /** * d320fax + classic 历史上存在卡顿风险,这里对图片 hydration 增加总超时兜底: * - 成功:使用本地化后的图片路径,提高 IMAGE/QRCODE 出图概率 * - 超时/失败:自动回退原模板,保证仍可继续出纸 */ if (onProgress) onProgress(1) const preferStableForD320faxClassic = driver.key === 'd320fax' && connection?.deviceType === 'classic' let templateReady = template if (preferStableForD320faxClassic) { try { if (onProgress) onProgress(3) templateReady = await Promise.race([ hydrateSystemTemplateImagesForPrint(template), new Promise((resolve) => setTimeout(() => resolve(template), 8000)), ]) } catch { templateReady = template } } else { if (onProgress) onProgress(3) templateReady = await hydrateSystemTemplateImagesForPrint(template) } if (onProgress) onProgress(12) if ( driver.protocol === 'tsc' && connection?.deviceType === 'classic' && connection?.transportMode === 'native-plugin' && !isNativeFastPrinterAvailable() ) { throw new Error('NATIVE_FAST_PRINTER_PLUGIN_NOT_FOUND. Please rebuild the custom base with native-fast-printer.') } if (canUseNativeFastTemplatePrint(driver)) { if (canUseBuiltinNativeFastTemplatePrint()) { const upos = getStoredUposPrintOptions() await printNativeFastTemplatePlugin({ deviceId: 'builtin-upos', deviceName: 'Built-in', template: templateReady, data, dpi: driver.imageDpi || 203, printQty: options.printQty || 1, outputTransport: 'upos', uposPrefer: upos.prefer, uposSerialPath: upos.serialPath || '', uposBaudrate: upos.baudrate, }) if (onProgress) onProgress(100) return driver } const nativeConnection = getNativeClassicConnection() if (nativeConnection) { await printNativeFastTemplatePlugin({ deviceId: nativeConnection.deviceId, deviceName: nativeConnection.deviceName, template: templateReady, data, dpi: driver.imageDpi || 203, printQty: options.printQty || 1, }) if (onProgress) onProgress(100) return driver } } if (onProgress) onProgress(14) const structuredTemplate = adaptSystemLabelTemplate(templateReady, data, { dpi: driver.imageDpi || 203, printQty: options.printQty || 1, disableBitmapText: driver.key === 'gp-d320fx' || driver.key === 'd320fax', /** * d320fax 内置字库对货币符号兼容差,TEXT_PRICE 容易整段丢失。 * 仅恢复“货币文本位图”兜底(非全量位图文本),兼顾稳定性与 ¥ 展示。 */ allowCurrencyBitmapWhenDisabled: true, }) if (onProgress) onProgress(16) const bytes = driver.protocol === 'esc' ? buildEscPosTemplateData(structuredTemplate) : buildTscTemplateData(structuredTemplate) if (onProgress) onProgress(18) await sendToPrinter(bytes, (p) => { if (!onProgress) return const u = Math.min(100, Math.max(0, Math.round(p))) onProgress(18 + Math.round((u / 100) * 82)) }) if (onProgress) onProgress(100) return driver } export function describeDiscoveredPrinter (device: PrinterCandidate) { return describePrinterCandidate(device) } export function disconnectCurrentPrinter (): Promise { return new Promise((resolve) => { const type = getPrinterType() const connection = getBluetoothConnection() if (type === 'bluetooth' && connection?.deviceType === 'classic') { // #ifdef APP-PLUS if (connection.transportMode === 'native-plugin' && isNativeFastPrinterAvailable()) { disconnectNativeFastPrinterPlugin().catch((e: any) => { console.error('Disconnect native fast printer failed', e) }).finally(() => { clearPrinter() resolve() }) return } try { const classic = classicBluetooth if (classic && classic.disConnDevice) classic.disConnDevice() } catch (e) { console.error('Disconnect classic bluetooth failed', e) } // #endif clearPrinter() resolve() return } clearPrinter() if (type === 'bluetooth' && connection?.deviceId) { uni.closeBLEConnection({ deviceId: connection.deviceId, complete: () => resolve(), }) return } resolve() }) }