// SPDX-License-Identifier: MIT package com.rnthermalprinter.printing import android.graphics.Bitmap import android.graphics.BitmapFactory import android.util.Log import net.posprinter.utils.BitmapProcess import net.posprinter.utils.BitmapToByteData import net.posprinter.utils.DataForSendToPrinterPos58 import net.posprinter.utils.DataForSendToPrinterPos80 import java.io.ByteArrayOutputStream /** * Zywell-compatible image processor using PosPrinterSDK * Ports the proven image processing logic from react-native-zywell-thermal-printer * while keeping thermal-printer's connection management */ object ZywellImageProcessor { private const val TAG = "ZywellImageProcessor" /** * Process image file using Zywell SDK logic * @param imagePath Path to image file * @param widthPx Target width in pixels * @param paperWidthMm Paper width (58 or 80mm) * @param isCutPaper Whether to cut paper after printing * @param align Alignment: "left", "center", or "right" * @param marginMm Margin in mm (will be added to both sides) * @return ESC/POS commands as single ByteArray (will be auto-chunked at connection layer) */ fun processImageFile( imagePath: String, widthPx: Int, paperWidthMm: Int = 80, isCutPaper: Boolean = false, align: String = "left", marginMm: Int = 0 ): ByteArray { Log.d(TAG, "[Native:Android] ZywellImageProcessor.processImageFile - START: path=$imagePath, width=$widthPx, paper=${paperWidthMm}mm, align=$align, margin=${marginMm}mm") // Clean path (remove file:// prefix if present) val cleanPath = when { imagePath.startsWith("file://") -> imagePath.substring(7) imagePath.startsWith("file:") -> imagePath.substring(5) else -> imagePath } Log.d(TAG, "[Native:Android] ZywellImageProcessor.processImageFile - CLEAN PATH: $cleanPath") // 1. Load bitmap from file val originalBitmap = BitmapFactory.decodeFile(cleanPath) ?: throw IllegalArgumentException("Failed to load image from $cleanPath") Log.d(TAG, "[Native:Android] ZywellImageProcessor.processImageFile - LOADED: ${originalBitmap.width}x${originalBitmap.height}") try { // 2. Calculate adaptive threshold from ORIGINAL image (before compress and padding) // to avoid compression artifacts and white padding affecting threshold val adaptiveThreshold = calculateAdaptiveThreshold(originalBitmap) Log.d(TAG, "[Native:Android] ZywellImageProcessor.processImageFile - THRESHOLD: $adaptiveThreshold") // 3. SDK: Compress to target width (zywell logic) var compressedBitmap = BitmapProcess.compressBmpByYourWidth(originalBitmap, widthPx) Log.d(TAG, "[Native:Android] ZywellImageProcessor.processImageFile - COMPRESSED: ${compressedBitmap.width}x${compressedBitmap.height}") // 4. Add margin and/or apply alignment within printable area val marginPx = marginMm * 8 // Convert mm to pixels (~8 pixels per mm) compressedBitmap = addMarginAndAlign(compressedBitmap, marginPx, align, paperWidthMm) Log.d(TAG, "[Native:Android] ZywellImageProcessor.processImageFile - ALIGNED: margin=${marginMm}mm (${marginPx}px), align=$align, paper=${paperWidthMm}mm") // 5. SDK: Convert to grayscale with FIXED threshold (from original image) val greyBitmap = convertGreyImgWithThreshold(compressedBitmap, adaptiveThreshold) Log.d(TAG, "[Native:Android] ZywellImageProcessor.processImageFile - GREY: threshold=$adaptiveThreshold") // 6. SDK: Cut into 50px bands (zywell logic) val bands = BitmapProcess.cutBitmap(50, greyBitmap) Log.d(TAG, "[Native:Android] ZywellImageProcessor.processImageFile - BANDS: ${bands.size} bands") // 7. Generate ESC/POS commands using SDK // Note: Always use Left alignment for SDK since we already applied alignment via padding val rasterData = generatePrintCommands( bands = bands, paperSize = if (paperWidthMm <= 58) 58 else 80, widthPx = if (paperWidthMm <= 58) 384 else 576, // Use printable width isCutPaper = isCutPaper, align = "left" // Already aligned in bitmap ) Log.d(TAG, "[Native:Android] ZywellImageProcessor.processImageFile - SUCCESS: ${rasterData.size} bytes total") return rasterData } finally { originalBitmap.recycle() } } /** * Add margin and/or apply alignment to bitmap * @param source Source bitmap * @param marginPx Margin in pixels (only affects left/right align) * @param align Alignment: "left", "center", or "right" * @param paperWidthMm Paper width in mm (58 or 80) */ private fun addMarginAndAlign(source: Bitmap, marginPx: Int, align: String, paperWidthMm: Int): Bitmap { val originalWidth = source.width val originalHeight = source.height // Standard printable width based on paper size val paperWidthPx = when { paperWidthMm <= 58 -> 384 // 58mm = 384 dots printable else -> 576 // 80mm = 576 dots printable } // Final width = printable width (rounded to multiple of 8) val newWidth = ((paperWidthPx + 7) / 8) * 8 // If no change needed, return original if (newWidth == originalWidth && align == "left" && marginPx == 0) { return source } // Calculate x offset based on alignment within printable area val xOffset = when (align.lowercase()) { "center" -> (newWidth - originalWidth) / 2 // Center in printable area "right" -> newWidth - originalWidth - marginPx // Right with margin else -> marginPx // Left with margin } // Create new bitmap with printable width val paddedBitmap = Bitmap.createBitmap(newWidth, originalHeight, Bitmap.Config.ARGB_8888) val canvas = android.graphics.Canvas(paddedBitmap) // Fill with white background canvas.drawColor(android.graphics.Color.WHITE) // Draw original bitmap with offset canvas.drawBitmap(source, xOffset.toFloat(), 0f, null) return paddedBitmap } /** * Calculate adaptive threshold from bitmap * Returns average grayscale value to use as threshold */ private fun calculateAdaptiveThreshold(source: Bitmap): Int { val width = source.width val height = source.height val pixels = IntArray(width * height) source.getPixels(pixels, 0, width, 0, 0, width, height) // Calculate average RGB var redSum = 0.0 var greenSum = 0.0 var blueSum = 0.0 for (pixel in pixels) { redSum += (pixel and 0x00FF0000 shr 16) greenSum += (pixel and 0x0000FF00 shr 8) blueSum += (pixel and 0x000000FF) } val total = pixels.size.toDouble() return ((redSum + greenSum + blueSum) / (total * 3)).toInt() } /** * Convert bitmap to grayscale with fixed threshold * @param source Source bitmap (may include padding) * @param threshold Fixed threshold value (calculated from original image) */ private fun convertGreyImgWithThreshold(source: Bitmap, threshold: Int): Bitmap { val width = source.width val height = source.height val pixels = IntArray(width * height) source.getPixels(pixels, 0, width, 0, 0, width, height) // Apply threshold for (i in pixels.indices) { val pixel = pixels[i] val red = (pixel and 0x00FF0000 shr 16) val green = (pixel and 0x0000FF00 shr 8) val blue = (pixel and 0x000000FF) val gray = (red + green + blue) / 3 // Apply fixed threshold val newValue = if (gray >= threshold) 255 else 0 pixels[i] = (0xFF shl 24) or (newValue shl 16) or (newValue shl 8) or newValue } return Bitmap.createBitmap(pixels, width, height, Bitmap.Config.ARGB_8888) } /** * Generate ESC/POS print commands merged into single ByteArray * Returns all commands (init + bands + footer) as one ByteArray * Connection layer will automatically chunk into 512-byte packets for Bluetooth */ private fun generatePrintCommands( bands: List, paperSize: Int, widthPx: Int, isCutPaper: Boolean, align: String ): ByteArray { val output = ByteArrayOutputStream() // Map align string to SDK AlignType val alignType = when (align.lowercase()) { "center" -> BitmapToByteData.AlignType.Center "right" -> BitmapToByteData.AlignType.Right else -> BitmapToByteData.AlignType.Left } Log.d(TAG, "[Native:Android] ZywellImageProcessor.generatePrintCommands - ALIGN: $align -> $alignType") // Initialize printer val initCmd = if (paperSize == 58) { DataForSendToPrinterPos58.initializePrinter() } else { DataForSendToPrinterPos80.initializePrinter() } output.write(initCmd) Log.d(TAG, "[Native:Android] ZywellImageProcessor.generatePrintCommands - INIT: ${initCmd.size}b") // Print each band for ((index, band) in bands.withIndex()) { val rasterCmd = if (paperSize == 58) { DataForSendToPrinterPos58.printRasterBmp( 0, band, BitmapToByteData.BmpType.Dithering, alignType, widthPx ) } else { DataForSendToPrinterPos80.printRasterBmp( 0, band, BitmapToByteData.BmpType.Dithering, alignType, widthPx ) } output.write(rasterCmd) Log.d(TAG, "[Native:Android] ZywellImageProcessor.generatePrintCommands - BAND ${index + 1}/${bands.size}: size=${band.width}x${band.height}, cmd=${rasterCmd.size}b") } // Feed and cut if requested if (isCutPaper) { val feedCmd = if (paperSize == 58) { DataForSendToPrinterPos58.printAndFeedLine() } else { DataForSendToPrinterPos80.printAndFeedLine() } output.write(feedCmd) // Cut paper (80mm only) if (paperSize == 80) { val cutCmd = DataForSendToPrinterPos80.selectCutPagerModerAndCutPager(0x42, 0x66) output.write(cutCmd) // Open cash drawer command (optional) val drawerCmd = byteArrayOf(0x1B, 0x70, 0x00, 0x19, 0xFA.toByte()) output.write(drawerCmd) } Log.d(TAG, "[Native:Android] ZywellImageProcessor.generatePrintCommands - FOOTER: added") } val totalBytes = output.size() Log.d(TAG, "[Native:Android] ZywellImageProcessor.generatePrintCommands - TOTAL: ${totalBytes}b") return output.toByteArray() } }