// SPDX-License-Identifier: MIT package com.rnthermalprinter.printing import android.graphics.* import android.util.Log import java.io.ByteArrayOutputStream import java.io.File /** * Image Rasterization for Thermal Printers * Optimized approach with Bayer dithering - simple and reliable */ object ImageRaster { private const val TAG = "ImageRaster" // Paper width standards in pixels (203 DPI) private const val WIDTH_58MM = 384 private const val WIDTH_80MM = 576 private const val WIDTH_112MM = 832 // Max band height - 50 lines to avoid buffer overflow private const val MAX_BAND_HEIGHT = 50 // Gamma correction for better text contrast private const val GAMMA = 1.25 /** * Process image file to ESC/POS raster commands * Main entry point for image printing * @param imagePath Path to image file * @param widthPx Target width in pixels * @param isCutPaper Whether to cut paper after printing * @param marginMm Margin in millimeters for both left and right (default: 0) * @param align Image alignment: 0=left, 1=center, 2=right (default: 0) */ @JvmStatic fun processImageFile( imagePath: String, widthPx: Int = WIDTH_58MM, paperWidthMm: Int? = null, // Optional paper width for margin calc isCutPaper: Boolean = false, marginMm: Double = 0.0, align: Int = 0 // 0=left, 1=center, 2=right ): ByteArray { Log.d(TAG, "[Native:Android] ImageRaster.processImageFile - START: path=$imagePath, width=${widthPx}px, paper=${paperWidthMm}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] ImageRaster.processImageFile - CLEAN PATH: $cleanPath") // ===== VALIDATE FILE EXISTS ===== val imageFile = File(cleanPath) if (!imageFile.exists()) { Log.e(TAG, "[Native:Android] ImageRaster.processImageFile - ERROR: File not found - $cleanPath") throw PrintError(PrintErrorCode.IMAGE_DECODE_ERROR, "File not found: $cleanPath") } if (!imageFile.canRead()) { Log.e(TAG, "[Native:Android] ImageRaster.processImageFile - ERROR: Cannot read file - $cleanPath") throw PrintError(PrintErrorCode.IMAGE_DECODE_ERROR, "Cannot read file: $cleanPath") } var bitmap: Bitmap? = null var scaledBitmap: Bitmap? = null try { // ===== STEP 1: LOAD IMAGE FROM FILE ===== // Use ARGB_8888 for better color precision val options = BitmapFactory.Options().apply { inPreferredConfig = Bitmap.Config.ARGB_8888 inSampleSize = 1 // Can increase for very large images } Log.d(TAG, "[Native:Android] ImageRaster.processImageFile - LOAD: Using ARGB_8888") bitmap = BitmapFactory.decodeFile(cleanPath, options) ?: throw PrintError(PrintErrorCode.IMAGE_DECODE_ERROR, "Cannot decode: $cleanPath") Log.d(TAG, "[Native:Android] ImageRaster.processImageFile - LOADED: ${bitmap.width}x${bitmap.height}") // ===== STEP 2: SCALE TO TARGET WIDTH ===== // Use widthPx directly as target width val targetWidth = widthPx // Calculate scaling factor val scale = targetWidth.toFloat() / bitmap.width val scaledWidth = targetWidth val scaledHeight = (bitmap.height * scale).toInt() // Scale bitmap to target size scaledBitmap = Bitmap.createScaledBitmap(bitmap, scaledWidth, scaledHeight, true) bitmap.recycle() bitmap = null // Clear reference Log.d(TAG, "[Native:Android] ImageRaster.processImageFile - SCALED: ${scaledWidth}x${scaledHeight}") // ===== STEP 3: GENERATE ESC/POS COMMANDS ===== // Direct processing with Bayer dithering (no pre-conversion needed) val raster = toSimpleESCPOSRaster(scaledBitmap, paperWidthMm, isCutPaper, marginMm, align) scaledBitmap.recycle() scaledBitmap = null // Clear reference Log.d(TAG, "[Native:Android] ImageRaster.processImageFile - SUCCESS: ${raster.size} bytes generated") return raster } catch (e: OutOfMemoryError) { // Clean up any allocated bitmaps bitmap?.recycle() scaledBitmap?.recycle() System.gc() // Hint to garbage collector Log.e(TAG, "[Native:Android] ImageRaster.processImageFile - ERROR: Out of memory", e) throw PrintError(PrintErrorCode.IMAGE_DECODE_ERROR, "Out of memory - image too large") } catch (e: Exception) { // Clean up any allocated bitmaps bitmap?.recycle() scaledBitmap?.recycle() Log.e(TAG, "[Native:Android] ImageRaster.processImageFile - ERROR: ${e.message}", e) throw PrintError(PrintErrorCode.IMAGE_DECODE_ERROR, "Failed: ${e.message}") } } /** * Convert bitmap to ESC/POS raster commands with Bayer dithering * Splits into bands of max 50 lines for stability * @param bitmap Source bitmap * @param isCutPaper Whether to cut paper after printing * @param marginMm Margin in millimeters for both sides * @param align Alignment: 0=left, 1=center, 2=right */ private fun toSimpleESCPOSRaster( bitmap: Bitmap, paperWidthMm: Int?, isCutPaper: Boolean, marginMm: Double, align: Int ): ByteArray { val width = bitmap.width val height = bitmap.height val bytesPerRow = (width + 7) / 8 // Round up to nearest byte val commands = ByteArrayOutputStream() // ===== INITIALIZE PRINTER ===== commands.write(byteArrayOf(0x1B, 0x40)) // ESC @ - Reset printer Log.d(TAG, "[Native:Android] ImageRaster.toSimpleESCPOSRaster - INIT: Printer reset") // ===== SET PRINT AREA AND MARGIN ===== // Use paperWidthMm if provided, otherwise no margin commands needed if (paperWidthMm != null) { val DOTS_PER_MM = 8 // Set print area width (GS W) with margins subtracted val printAreaMm = paperWidthMm.toDouble() - (marginMm * 2) val printAreaDots = (printAreaMm * DOTS_PER_MM).toInt() val wL = (printAreaDots and 0xFF).toByte() val wH = ((printAreaDots shr 8) and 0xFF).toByte() commands.write(byteArrayOf(0x1D, 0x57, wL, wH)) // GS W - Set print area width Log.d(TAG, "[Native:Android] ImageRaster.toSimpleESCPOSRaster - PRINT AREA: ${printAreaMm}mm (${printAreaDots} dots) with ${marginMm}mm margins") // Set left margin (GS L) to offset print area from left edge if (marginMm > 0) { val marginDots = (marginMm * DOTS_PER_MM).toInt() val nL = (marginDots and 0xFF).toByte() val nH = ((marginDots shr 8) and 0xFF).toByte() commands.write(byteArrayOf(0x1D, 0x4C, nL, nH)) // GS L - Set left margin Log.d(TAG, "[Native:Android] ImageRaster.toSimpleESCPOSRaster - LEFT MARGIN: ${marginMm}mm (${marginDots} dots)") } } // ===== SET ALIGNMENT ===== val alignByte = align.toByte() // 0=left, 1=center, 2=right commands.write(byteArrayOf(0x1B, 0x61, alignByte)) // ESC a n val alignStr = when(align) { 0 -> "left"; 1 -> "center"; 2 -> "right"; else -> "left" } Log.d(TAG, "[Native:Android] ImageRaster.toSimpleESCPOSRaster - ALIGN: $alignStr") // ===== BAYER 8x8 DITHERING MATRIX ===== val bayer8 = arrayOf( intArrayOf(0, 48, 12, 60, 3, 51, 15, 63), intArrayOf(32, 16, 44, 28, 35, 19, 47, 31), intArrayOf(8, 56, 4, 52, 11, 59, 7, 55), intArrayOf(40, 24, 36, 20, 43, 27, 39, 23), intArrayOf(2, 50, 14, 62, 1, 49, 13, 61), intArrayOf(34, 18, 46, 30, 33, 17, 45, 29), intArrayOf(10, 58, 6, 54, 9, 57, 5, 53), intArrayOf(42, 26, 38, 22, 41, 25, 37, 21) ) Log.d(TAG, "[Native:Android] ImageRaster.toSimpleESCPOSRaster - DITHER: Bayer 8x8, gamma=$GAMMA") // ===== PROCESS IMAGE IN BANDS ===== // Split into 50-line bands to avoid buffer overflow var y = 0 while (y < height) { val bandHeight = minOf(MAX_BAND_HEIGHT, height - y) // Send raster command commands.write(byteArrayOf(0x1D, 0x76, 0x30, 0x00)) // GS v 0 0 // Specify dimensions commands.write(bytesPerRow and 0xFF) // Width low byte commands.write((bytesPerRow shr 8) and 0xFF) // Width high byte commands.write(bandHeight and 0xFF) // Height low byte commands.write((bandHeight shr 8) and 0xFF) // Height high byte // Log first band details only if (y == 0) { Log.d(TAG, "[Native:Android] ImageRaster.toSimpleESCPOSRaster - BAND: width=$bytesPerRow bytes (${width}px), height=$bandHeight lines") } // Get pixels for this band val pixels = IntArray(width * bandHeight) bitmap.getPixels(pixels, 0, width, 0, y, width, bandHeight) // Convert pixels to printer format with Bayer dithering for (row in 0 until bandHeight) { for (byteX in 0 until bytesPerRow) { var byte = 0 // Pack 8 pixels into 1 byte for (bit in 0..7) { val x = byteX * 8 + bit if (x < width) { val pixel = pixels[row * width + x] // Extract RGB values val r = (pixel and 0x00FF0000) shr 16 val g = (pixel and 0x0000FF00) shr 8 val b = pixel and 0x000000FF // Apply Bayer dithering with gamma correction val gray = (r + g + b) / 3 val normalized = gray / 255.0 val corrected = Math.pow(normalized, GAMMA) val thresholdNorm = (bayer8[(y + row) % 8][x % 8] + 0.5) / 64.0 val isDark = corrected < thresholdNorm if (isDark) { byte = byte or (1 shl (7 - bit)) } } } commands.write(byte) } } y += bandHeight } // ===== FINALIZE PRINTING ===== // Do NOT feed/cut here to avoid double cut // Let upper layer (JS) decide feed/cut policy for mixed-content documents return commands.toByteArray() } }