import type { MonochromeImageData, PrintImageOptions, PrinterDriver, RawImageDataSource } from './types/printer' import { printRunDiag } from './printRunDiagnostics' const DEFAULT_IMAGE_THRESHOLD = 180 function yieldToUi (): Promise { return new Promise((resolve) => { setTimeout(resolve, 0) }) } function ensureMultipleOf8 (value: number, maxValue?: number): number { const safeMax = maxValue && maxValue > 0 ? Math.max(8, Math.floor(maxValue)) : 0 let width = Math.max(8, Math.round(value || 0)) if (safeMax && width > safeMax) width = safeMax if (width % 8 !== 0) { width += 8 - (width % 8) if (safeMax && width > safeMax) { width = safeMax - (safeMax % 8) if (width <= 0) width = 8 } } return width } function normalizeArgbComponent (value: number): number { return Math.max(0, Math.min(255, Math.round(value || 0))) } /** * 将像素按 alpha 与白底混合后再取灰度: * - 解决“透明背景被当作纯黑”导致整块发黑 * - 也避免“alpha 异常全透明”直接白纸 */ function grayOnWhiteBg (red: number, green: number, blue: number, alpha: number): number { const r = normalizeArgbComponent(red) const g = normalizeArgbComponent(green) const b = normalizeArgbComponent(blue) const a = normalizeArgbComponent(alpha) const gray = r * 0.299 + g * 0.587 + b * 0.114 return (gray * a + 255 * (255 - a)) / 255 } function toIntColor (raw: any): number { const n = Number(raw) if (Number.isFinite(n)) return (n | 0) try { const v = (plus as any).android.invoke(raw, 'intValue') const iv = Number(v) if (Number.isFinite(iv)) return (iv | 0) } catch (_) {} const s = String(raw ?? '').trim() if (s) { const sv = Number(s) if (Number.isFinite(sv)) return (sv | 0) } return 0 } function readJavaIntArrayValue (arr: any, index: number): number { const i = Math.max(0, Math.floor(index || 0)) // 优先普通下标读取;部分基座可直接桥接为可索引数组。 try { const v = arr?.[i] if (v != null) return toIntColor(v) } catch (_) {} // 某些基座 Java int[] 不能直接下标访问,走反射读取。 try { const ReflectArray = (plus as any).android.importClass('java.lang.reflect.Array') const v = ReflectArray.getInt(arr, i) return toIntColor(v) } catch (_) {} // 兜底走 get(index)(兼容少量 List/Array 包装) try { const v = (plus as any).android.invoke(arr, 'get', i) return toIntColor(v) } catch (_) {} return 0 } function normalizeBase64Payload (source: string): string { const value = String(source || '').trim() if (!value) return '' if (value.startsWith('data:image/')) { const index = value.indexOf(',') return index >= 0 ? value.slice(index + 1) : '' } if (/^[A-Za-z0-9+/=\r\n]+$/.test(value) && value.length > 128) { return value.replace(/\s+/g, '') } return '' } function resolveLocalImagePath (source: string): string { let path = String(source || '').trim() if (!path) return '' if (path.startsWith('file://')) { path = path.replace(/^file:\/\//, '') } // #ifdef APP-PLUS try { const converted = plus.io.convertLocalFileSystemURL(path) if (converted) path = converted } catch (_) {} // #endif try { path = decodeURIComponent(path) } catch (_) {} return path } function getDefaultMaxWidthDots (driver: PrinterDriver): number { if (driver.imageMaxWidthDots && driver.imageMaxWidthDots > 0) return driver.imageMaxWidthDots return driver.protocol === 'esc' ? 384 : 800 } function callBitmapGetPixels ( bitmap: any, pixels: any, offset: number, stride: number, x: number, y: number, width: number, height: number ) { // 优先走对象方法直调,部分机型对 invoke 的重载匹配不稳定。 if (bitmap && typeof bitmap.getPixels === 'function') { bitmap.getPixels( pixels, Math.floor(offset), Math.floor(stride), Math.floor(x), Math.floor(y), Math.floor(width), Math.floor(height) ) return } // 回退 invoke(兼容历史机型) ;(plus as any).android.invoke( bitmap, 'getPixels', pixels, Math.floor(offset), Math.floor(stride), Math.floor(x), Math.floor(y), Math.floor(width), Math.floor(height) ) } function createJavaIntArray (length: number): any { const size = Math.max(0, Math.floor(length || 0)) const androidBridge = (plus as any)?.android if (!androidBridge || size <= 0) { throw new Error('JAVA_INT_ARRAY_CREATE_FAILED:invalid-args') } if (typeof androidBridge.newArray === 'function') { return androidBridge.newArray('int', size) } // 兼容无 newArray 的基座:用反射创建 int[] const ReflectArray = androidBridge.importClass('java.lang.reflect.Array') const IntegerClass = androidBridge.importClass('java.lang.Integer') const intType = IntegerClass?.TYPE if (!intType) { throw new Error('JAVA_INT_ARRAY_CREATE_FAILED:int-type-missing') } return ReflectArray.newInstance(intType, size) } export function rasterizeImageData ( source: RawImageDataSource, options: PrintImageOptions = {} ): MonochromeImageData { const sourceWidth = Math.max(1, Math.round(source.width || 0)) const sourceHeight = Math.max(1, Math.round(source.height || 0)) const targetWidth = ensureMultipleOf8(sourceWidth) const threshold = options.threshold != null ? Number(options.threshold) : DEFAULT_IMAGE_THRESHOLD const pixels: number[] = new Array(targetWidth * sourceHeight).fill(0) for (let y = 0; y < sourceHeight; y++) { for (let x = 0; x < sourceWidth; x++) { const index = (y * sourceWidth + x) * 4 const red = Number(source.data[index] ?? 255) const green = Number(source.data[index + 1] ?? 255) const blue = Number(source.data[index + 2] ?? 255) const alpha = Number(source.data[index + 3] ?? 255) const gray = grayOnWhiteBg(red, green, blue, alpha) pixels[y * targetWidth + x] = gray > threshold ? 0 : 1 } } const clearTop = Math.max(0, Math.min(8, Math.floor(Number(options.clearTopRasterRows) || 0))) for (let row = 0; row < clearTop && row < sourceHeight; row++) { for (let x = 0; x < targetWidth; x++) { pixels[row * targetWidth + x] = 0 } } return { width: targetWidth, height: sourceHeight, pixels, } } export async function rasterizeImageForPrinter ( source: string, driver: PrinterDriver, options: PrintImageOptions = {} ): Promise { // #ifdef APP-PLUS const rawSource = String(source || '').trim() if (!rawSource) { throw new Error('IMAGE_SOURCE_EMPTY') } const BitmapFactory = plus.android.importClass('android.graphics.BitmapFactory') const Bitmap = plus.android.importClass('android.graphics.Bitmap') const Base64 = plus.android.importClass('android.util.Base64') const base64Payload = normalizeBase64Payload(rawSource) let sourceBitmap: any = null if (base64Payload) { const bytes = Base64.decode(base64Payload, 0) sourceBitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.length) } else { const localPath = resolveLocalImagePath(rawSource) sourceBitmap = BitmapFactory.decodeFile(localPath) } if (!sourceBitmap) { throw new Error('IMAGE_DECODE_FAILED') } printRunDiag('raster_decode_ok', { src: base64Payload ? 'base64' : 'file', }) const notifyRaster = (pct: number) => { try { const cb = options.onRasterizeProgress if (typeof cb === 'function') { cb(Math.max(0, Math.min(100, Math.round(pct)))) } } catch (_) {} } notifyRaster(4) const sourceWidth = Number(sourceBitmap.getWidth ? sourceBitmap.getWidth() : 0) const sourceHeight = Number(sourceBitmap.getHeight ? sourceBitmap.getHeight() : 0) if (!sourceWidth || !sourceHeight) { try { sourceBitmap.recycle && sourceBitmap.recycle() } catch (_) {} throw new Error('IMAGE_SIZE_INVALID') } const protocolMaxWidth = getDefaultMaxWidthDots(driver) const maxWidthDots = options.maxWidthDots && options.maxWidthDots > 0 ? options.maxWidthDots : protocolMaxWidth const targetWidth = ensureMultipleOf8(options.targetWidthDots || sourceWidth, maxWidthDots) const hasFixedTargetBox = options.targetWidthDots != null && options.targetWidthDots > 0 && options.targetHeightDots != null && options.targetHeightDots > 0 let targetHeight: number if (hasFixedTargetBox) { /** 与 getLabelPrintRasterLayout 的 outW×outH 一致,避免 PNG 实际像素与画布布局偏差导致 TSC 错位 */ targetHeight = Math.max(1, Math.round(Number(options.targetHeightDots))) } else { const aspectRatio = sourceHeight / sourceWidth targetHeight = Math.max( 1, Math.round(options.targetHeightDots || (targetWidth * aspectRatio)) ) } const threshold = options.threshold != null ? Number(options.threshold) : DEFAULT_IMAGE_THRESHOLD const useBilinear = options.bilinearImageScale === true printRunDiag('raster_target', { sw: sourceWidth, sh: sourceHeight, tw: targetWidth, th: targetHeight, driver: driver.key, protocol: driver.protocol, threshold, bilinear: useBilinear, pixels: targetWidth * targetHeight, }) const scaledBitmap = Bitmap.createScaledBitmap(sourceBitmap, targetWidth, targetHeight, useBilinear) printRunDiag('raster_scaled_bitmap') notifyRaster(14) const rasterPixels: number[] = new Array(targetWidth * targetHeight) /** * 关键优化:避免逐像素调用 getPixel(x,y)(跨 JS/Java 桥开销极大)。 * 小图可尝试一次 getPixels 拉满 int[];大图在部分一体机/Uni 桥接上单次拷贝可达数十秒(见日志 raster_try_full_getPixels → 首行间隔), * 故超过像素阈值时直接走逐行 getPixels(行数=高,每行宽个点,实测远快)。 */ const totalRasterPixels = targetWidth * targetHeight /** 384×384=147456 会触发逐行路径 */ const FULL_GET_PIXELS_MAX_PIXELS = 50000 const skipFullBitmapGetPixels = totalRasterPixels > FULL_GET_PIXELS_MAX_PIXELS let usedBatchPixels = false let usedRowBatchPixels = false let rasterPath = 'none' let rasterErr = '' if (skipFullBitmapGetPixels) { printRunDiag('raster_skip_full_getPixels', { pixels: totalRasterPixels, cap: FULL_GET_PIXELS_MAX_PIXELS, reason: 'uni-bridge-large-buffer-slow', }) } else { try { printRunDiag('raster_try_full_getPixels', { tw: targetWidth, th: targetHeight }) const argbArray: any = createJavaIntArray(targetWidth * targetHeight) callBitmapGetPixels( scaledBitmap, argbArray, 0, targetWidth, 0, 0, targetWidth, targetHeight ) printRunDiag('raster_full_getPixels_returned', { tw: targetWidth, th: targetHeight }) const progressStride = Math.max(1, Math.floor(targetHeight / 25)) for (let y = 0; y < targetHeight; y++) { if (y > 0 && y % 48 === 0) { await yieldToUi() } if (y === 0 || y === targetHeight - 1 || (y + 1) % progressStride === 0) { notifyRaster(14 + Math.round(((y + 1) / targetHeight) * 82)) printRunDiag('raster_rows_progress', { y: y + 1, h: targetHeight, pctApprox: Math.round(((y + 1) / targetHeight) * 100), }) } const rowOffset = y * targetWidth for (let x = 0; x < targetWidth; x++) { const color = readJavaIntArrayValue(argbArray, rowOffset + x) const alpha = (color >>> 24) & 0xff const red = (color >>> 16) & 0xff const green = (color >>> 8) & 0xff const blue = color & 0xff const gray = grayOnWhiteBg(red, green, blue, alpha) rasterPixels[rowOffset + x] = gray > threshold ? 0 : 1 } } usedBatchPixels = true rasterPath = 'full-getPixels' printRunDiag('raster_path_ok', { path: rasterPath }) } catch (e: any) { usedBatchPixels = false rasterErr = String(e?.message || e || 'full-getPixels-failed') printRunDiag('raster_full_getPixels_fail', { err: rasterErr.slice(0, 200) }) } } /** * 兼容兜底 1:分条带 getPixels(比整图小、比逐行少次 JNI;逐行在 rk3568+Uni 上仍要 384×readInt/行,极慢)。 * 单条像素数上限低于「整图慢」阈值,避免再次踩桥接大包。 */ if (!usedBatchPixels) { try { const stripMaxPixels = 8192 const stripH = Math.max( 1, Math.min(targetHeight, Math.floor(stripMaxPixels / Math.max(1, targetWidth))) ) const stripCount = Math.ceil(targetHeight / stripH) printRunDiag('raster_try_strip_getPixels', { tw: targetWidth, th: targetHeight, stripH, strips: stripCount, pixelsPerStrip: targetWidth * stripH, }) let stripIndex = 0 for (let y0 = 0; y0 < targetHeight; y0 += stripH) { stripIndex += 1 const h = Math.min(stripH, targetHeight - y0) const stripLen = targetWidth * h const stripBuf: any = createJavaIntArray(stripLen) callBitmapGetPixels( scaledBitmap, stripBuf, 0, targetWidth, 0, y0, targetWidth, h ) if (stripIndex === 1 || stripIndex === stripCount || stripIndex % Math.max(1, Math.floor(stripCount / 20)) === 0) { printRunDiag('raster_strip_done', { strip: stripIndex, of: stripCount, y0, h }) } const rowProgressStride = Math.max(1, Math.floor(targetHeight / 25)) for (let ly = 0; ly < h; ly++) { const y = y0 + ly if (y > 0 && y % 64 === 0) { await yieldToUi() } if (y === 0 || y === targetHeight - 1 || (y + 1) % rowProgressStride === 0) { notifyRaster(14 + Math.round(((y + 1) / targetHeight) * 82)) } const rowOffset = y * targetWidth const base = ly * targetWidth for (let x = 0; x < targetWidth; x++) { const color = readJavaIntArrayValue(stripBuf, base + x) const alpha = (color >>> 24) & 0xff const red = (color >>> 16) & 0xff const green = (color >>> 8) & 0xff const blue = color & 0xff const gray = grayOnWhiteBg(red, green, blue, alpha) rasterPixels[rowOffset + x] = gray > threshold ? 0 : 1 } } } usedRowBatchPixels = true rasterPath = 'strip-getPixels' printRunDiag('raster_path_ok', { path: rasterPath }) } catch (e: any) { usedRowBatchPixels = false const msg = String(e?.message || e || 'strip-getPixels-failed') rasterErr = rasterErr ? `${rasterErr} | ${msg}` : msg printRunDiag('raster_strip_getPixels_fail', { err: msg.slice(0, 200) }) } } // 兼容兜底 2:若 getPixels/newArray 都不兼容,直接快速失败,避免退回逐像素导致分钟级卡死。 if (!usedBatchPixels && !usedRowBatchPixels) { try { if (scaledBitmap !== sourceBitmap && sourceBitmap?.recycle) sourceBitmap.recycle() } catch (_) {} try { scaledBitmap?.recycle && scaledBitmap.recycle() } catch (_) {} throw new Error( `RASTERIZE_GET_PIXELS_UNSUPPORTED:path=${rasterPath};w=${targetWidth};h=${targetHeight};err=${rasterErr || '-'}` ) } // 某些机型上 getPixels 能返回但值异常(全 0),会导致 blackPixels=0 -> 白纸。 // 这里对更大图也做逐点重算兜底(分批 yield,避免长时间阻塞 UI)。 // 现场问题样本:384x922(~35 万像素)在部分基座会命中全白误判。 let blackCount = 0 for (let i = 0; i < rasterPixels.length; i++) { if (rasterPixels[i]) blackCount++ } const area = targetWidth * targetHeight /** * getPixel 逐点兜底在大图上 = 宽×高 次 JNI,一体机上可达数分钟;仅对小图启用。 * 大图若仍全白请查模板/阈值,勿走此分支。 */ const PIXEL_FALLBACK_MAX_AREA = 20000 if (blackCount === 0 && area <= PIXEL_FALLBACK_MAX_AREA) { printRunDiag('raster_pixel_fallback_getPixel', { area }) const fbStride = Math.max(1, Math.floor(targetHeight / 20)) for (let y = 0; y < targetHeight; y++) { if (y > 0 && y % 24 === 0) { await yieldToUi() } if (y === 0 || y === targetHeight - 1 || (y + 1) % fbStride === 0) { notifyRaster(20 + Math.round(((y + 1) / targetHeight) * 75)) } const rowOffset = y * targetWidth for (let x = 0; x < targetWidth; x++) { const color = Number(scaledBitmap.getPixel(x, y)) | 0 const alpha = (color >>> 24) & 0xff const red = (color >>> 16) & 0xff const green = (color >>> 8) & 0xff const blue = color & 0xff const gray = grayOnWhiteBg(red, green, blue, alpha) rasterPixels[rowOffset + x] = gray > threshold ? 0 : 1 } } rasterPath = `${rasterPath}+pixel-fallback` } else if (blackCount === 0 && area > PIXEL_FALLBACK_MAX_AREA) { printRunDiag('raster_skip_pixel_fallback_area_cap', { area, cap: PIXEL_FALLBACK_MAX_AREA, path: rasterPath, }) } const clearTop = Math.max(0, Math.min(8, Math.floor(Number(options.clearTopRasterRows) || 0))) for (let row = 0; row < clearTop && row < targetHeight; row++) { for (let x = 0; x < targetWidth; x++) { rasterPixels[row * targetWidth + x] = 0 } } try { if (scaledBitmap !== sourceBitmap && sourceBitmap?.recycle) sourceBitmap.recycle() } catch (_) {} try { scaledBitmap?.recycle && scaledBitmap.recycle() } catch (_) {} notifyRaster(100) printRunDiag('raster_finished', { w: targetWidth, h: targetHeight, path: rasterPath }) return { width: targetWidth, height: targetHeight, pixels: rasterPixels, } // #endif // #ifndef APP-PLUS throw new Error('IMAGE_PRINT_APP_ONLY') // #endif }