@delicity/capacitor-thermal-printer 7.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (100) hide show
  1. package/DelicityThermalPrinter.podspec +28 -0
  2. package/LICENSE +21 -0
  3. package/README.md +649 -0
  4. package/android/build.gradle +122 -0
  5. package/android/src/main/AndroidManifest.xml +38 -0
  6. package/android/src/main/java/com/delicity/thermalprinter/Logger.kt +50 -0
  7. package/android/src/main/java/com/delicity/thermalprinter/ThermalPrinterEngine.kt +528 -0
  8. package/android/src/main/java/com/delicity/thermalprinter/ThermalPrinterPlugin.kt +334 -0
  9. package/android/src/main/java/com/delicity/thermalprinter/adapters/BleAdapter.kt +125 -0
  10. package/android/src/main/java/com/delicity/thermalprinter/adapters/BrotherAdapter.kt +206 -0
  11. package/android/src/main/java/com/delicity/thermalprinter/adapters/EpsonAdapter.kt +384 -0
  12. package/android/src/main/java/com/delicity/thermalprinter/adapters/EscPosAdapter.kt +160 -0
  13. package/android/src/main/java/com/delicity/thermalprinter/adapters/EscPosCommands.kt +42 -0
  14. package/android/src/main/java/com/delicity/thermalprinter/adapters/EscPosTextEncoder.kt +138 -0
  15. package/android/src/main/java/com/delicity/thermalprinter/adapters/PrinterAdapter.kt +95 -0
  16. package/android/src/main/java/com/delicity/thermalprinter/adapters/RawTcpAdapter.kt +96 -0
  17. package/android/src/main/java/com/delicity/thermalprinter/adapters/SdkContract.kt +158 -0
  18. package/android/src/main/java/com/delicity/thermalprinter/adapters/SdkReflect.kt +104 -0
  19. package/android/src/main/java/com/delicity/thermalprinter/adapters/StarAdapter.kt +322 -0
  20. package/android/src/main/java/com/delicity/thermalprinter/adapters/UsbAdapter.kt +248 -0
  21. package/android/src/main/java/com/delicity/thermalprinter/adapters/ZebraAdapter.kt +207 -0
  22. package/android/src/main/java/com/delicity/thermalprinter/discovery/AdapterPriority.kt +39 -0
  23. package/android/src/main/java/com/delicity/thermalprinter/discovery/BleScanner.kt +70 -0
  24. package/android/src/main/java/com/delicity/thermalprinter/discovery/BluetoothClassicScanner.kt +112 -0
  25. package/android/src/main/java/com/delicity/thermalprinter/discovery/DiscoveryManager.kt +136 -0
  26. package/android/src/main/java/com/delicity/thermalprinter/discovery/TcpScanner.kt +96 -0
  27. package/android/src/main/java/com/delicity/thermalprinter/image/ImageCache.kt +88 -0
  28. package/android/src/main/java/com/delicity/thermalprinter/image/ImageProcessor.kt +220 -0
  29. package/android/src/main/java/com/delicity/thermalprinter/image/TextRasterizer.kt +99 -0
  30. package/android/src/main/java/com/delicity/thermalprinter/model/Models.kt +206 -0
  31. package/android/src/main/java/com/delicity/thermalprinter/model/PrintItem.kt +100 -0
  32. package/android/src/main/java/com/delicity/thermalprinter/store/PrinterStore.kt +71 -0
  33. package/android/src/main/java/com/delicity/thermalprinter/transport/BleGattClient.kt +201 -0
  34. package/android/src/main/java/com/delicity/thermalprinter/transport/BluetoothSppTransport.kt +110 -0
  35. package/android/src/main/java/com/delicity/thermalprinter/transport/ByteTransport.kt +18 -0
  36. package/android/src/main/java/com/delicity/thermalprinter/transport/TcpTransport.kt +83 -0
  37. package/dist/esm/adapters/dedup.d.ts +26 -0
  38. package/dist/esm/adapters/dedup.js +66 -0
  39. package/dist/esm/adapters/dedup.js.map +1 -0
  40. package/dist/esm/adapters/priority.d.ts +29 -0
  41. package/dist/esm/adapters/priority.js +55 -0
  42. package/dist/esm/adapters/priority.js.map +1 -0
  43. package/dist/esm/core/enums.d.ts +61 -0
  44. package/dist/esm/core/enums.js +25 -0
  45. package/dist/esm/core/enums.js.map +1 -0
  46. package/dist/esm/core/errors.d.ts +16 -0
  47. package/dist/esm/core/errors.js +53 -0
  48. package/dist/esm/core/errors.js.map +1 -0
  49. package/dist/esm/core/escpos-text.d.ts +33 -0
  50. package/dist/esm/core/escpos-text.js +239 -0
  51. package/dist/esm/core/escpos-text.js.map +1 -0
  52. package/dist/esm/core/imaging.d.ts +91 -0
  53. package/dist/esm/core/imaging.js +184 -0
  54. package/dist/esm/core/imaging.js.map +1 -0
  55. package/dist/esm/core/models.d.ts +131 -0
  56. package/dist/esm/core/models.js +2 -0
  57. package/dist/esm/core/models.js.map +1 -0
  58. package/dist/esm/core/options.d.ts +154 -0
  59. package/dist/esm/core/options.js +2 -0
  60. package/dist/esm/core/options.js.map +1 -0
  61. package/dist/esm/core/text.d.ts +138 -0
  62. package/dist/esm/core/text.js +14 -0
  63. package/dist/esm/core/text.js.map +1 -0
  64. package/dist/esm/definitions.d.ts +155 -0
  65. package/dist/esm/definitions.js +2 -0
  66. package/dist/esm/definitions.js.map +1 -0
  67. package/dist/esm/index.d.ts +15 -0
  68. package/dist/esm/index.js +18 -0
  69. package/dist/esm/index.js.map +1 -0
  70. package/dist/esm/web.d.ts +63 -0
  71. package/dist/esm/web.js +112 -0
  72. package/dist/esm/web.js.map +1 -0
  73. package/dist/plugin.cjs.js +224 -0
  74. package/dist/plugin.cjs.js.map +1 -0
  75. package/dist/plugin.js +227 -0
  76. package/dist/plugin.js.map +1 -0
  77. package/ios/Plugin/Adapters/BrotherAdapter.swift +139 -0
  78. package/ios/Plugin/Adapters/EpsonAdapter.swift +131 -0
  79. package/ios/Plugin/Adapters/EscPosAdapter.swift +106 -0
  80. package/ios/Plugin/Adapters/EscPosCommands.swift +32 -0
  81. package/ios/Plugin/Adapters/EscPosTextEncoder.swift +115 -0
  82. package/ios/Plugin/Adapters/PrinterAdapter.swift +44 -0
  83. package/ios/Plugin/Adapters/RawTcpAdapter.swift +70 -0
  84. package/ios/Plugin/Adapters/StarAdapter.swift +305 -0
  85. package/ios/Plugin/Adapters/ZebraAdapter.swift +119 -0
  86. package/ios/Plugin/Discovery/AdapterPriority.swift +21 -0
  87. package/ios/Plugin/Discovery/BonjourScanner.swift +51 -0
  88. package/ios/Plugin/Discovery/DiscoveryManager.swift +86 -0
  89. package/ios/Plugin/Image/ImageCache.swift +73 -0
  90. package/ios/Plugin/Image/ImageProcessor.swift +168 -0
  91. package/ios/Plugin/Image/TextRasterizer.swift +81 -0
  92. package/ios/Plugin/Logger.swift +33 -0
  93. package/ios/Plugin/Model/Models.swift +174 -0
  94. package/ios/Plugin/Model/PrintItem.swift +111 -0
  95. package/ios/Plugin/Store/PrinterStore.swift +51 -0
  96. package/ios/Plugin/ThermalPrinterEngine.swift +395 -0
  97. package/ios/Plugin/ThermalPrinterPlugin.m +22 -0
  98. package/ios/Plugin/ThermalPrinterPlugin.swift +258 -0
  99. package/ios/Plugin/Transport/TcpTransport.swift +89 -0
  100. package/package.json +96 -0
@@ -0,0 +1,88 @@
1
+ package com.delicity.thermalprinter.image
2
+
3
+ import android.content.Context
4
+ import com.delicity.thermalprinter.Logger
5
+ import com.delicity.thermalprinter.model.ErrorCode
6
+ import com.delicity.thermalprinter.model.PrinterException
7
+ import java.io.File
8
+ import java.net.HttpURLConnection
9
+ import java.net.URL
10
+ import java.security.MessageDigest
11
+
12
+ /**
13
+ * Cache local des images à imprimer.
14
+ *
15
+ * Objectif : éviter de re-télécharger une même URL et fournir un chemin fichier
16
+ * stable au pipeline (le mode fichier local est le plus fiable/performant).
17
+ *
18
+ * Emplacement : context.cacheDir/thermal-images/
19
+ * Clé de cache : SHA-1 de l'URL.
20
+ * Politique : LRU best-effort par date de modification, plafonné à MAX_BYTES.
21
+ */
22
+ class ImageCache(context: Context) {
23
+
24
+ private val dir: File = File(context.cacheDir, "thermal-images").apply { mkdirs() }
25
+
26
+ /** Télécharge l'URL (si absente du cache) et renvoie le fichier local. */
27
+ fun fetch(url: String, timeoutMs: Int = 10000): File {
28
+ val key = sha1(url)
29
+ val cached = File(dir, "$key.img")
30
+ if (cached.exists() && cached.length() > 0) {
31
+ cached.setLastModified(System.currentTimeMillis())
32
+ Logger.log("image", "cache hit", mapOf("url" to url, "bytes" to cached.length()))
33
+ return cached
34
+ }
35
+ return download(url, cached, timeoutMs)
36
+ }
37
+
38
+ private fun download(url: String, dest: File, timeoutMs: Int): File {
39
+ var conn: HttpURLConnection? = null
40
+ try {
41
+ conn = (URL(url).openConnection() as HttpURLConnection).apply {
42
+ connectTimeout = timeoutMs
43
+ readTimeout = timeoutMs
44
+ requestMethod = "GET"
45
+ instanceFollowRedirects = true
46
+ }
47
+ val code = conn.responseCode
48
+ if (code !in 200..299) {
49
+ throw PrinterException(ErrorCode.IMAGE_INVALID, "HTTP $code en téléchargeant $url")
50
+ }
51
+ conn.inputStream.use { input ->
52
+ dest.outputStream().use { out -> input.copyTo(out, 8192) }
53
+ }
54
+ Logger.log("image", "downloaded", mapOf("url" to url, "bytes" to dest.length()))
55
+ enforceQuota()
56
+ return dest
57
+ } catch (e: PrinterException) {
58
+ throw e
59
+ } catch (e: Exception) {
60
+ throw PrinterException(ErrorCode.IMAGE_INVALID, "Téléchargement image échoué", e.message, retryable = true)
61
+ } finally {
62
+ conn?.disconnect()
63
+ }
64
+ }
65
+
66
+ /** Supprime les fichiers les plus anciens si le cache dépasse le quota. */
67
+ private fun enforceQuota() {
68
+ val files = dir.listFiles()?.sortedBy { it.lastModified() } ?: return
69
+ var total = files.sumOf { it.length() }
70
+ var i = 0
71
+ while (total > MAX_BYTES && i < files.size) {
72
+ total -= files[i].length()
73
+ files[i].delete()
74
+ i++
75
+ }
76
+ }
77
+
78
+ fun clear() {
79
+ dir.listFiles()?.forEach { it.delete() }
80
+ }
81
+
82
+ private fun sha1(s: String): String =
83
+ MessageDigest.getInstance("SHA-1").digest(s.toByteArray()).joinToString("") { "%02x".format(it) }
84
+
85
+ companion object {
86
+ private const val MAX_BYTES = 32L * 1024 * 1024 // 32 Mo
87
+ }
88
+ }
@@ -0,0 +1,220 @@
1
+ package com.delicity.thermalprinter.image
2
+
3
+ import android.graphics.Bitmap
4
+ import android.graphics.BitmapFactory
5
+ import android.graphics.Canvas
6
+ import android.graphics.Color
7
+ import android.util.Base64
8
+ import com.delicity.thermalprinter.model.ErrorCode
9
+ import com.delicity.thermalprinter.model.PrinterException
10
+ import com.delicity.thermalprinter.model.RenderOptions
11
+ import java.io.ByteArrayOutputStream
12
+ import java.io.File
13
+
14
+ /**
15
+ * Pipeline de traitement image -> raster thermique.
16
+ *
17
+ * Étapes :
18
+ * 1. decode (fichier local / base64) en Bitmap ARGB
19
+ * 2. resize à la largeur cible (widthDots), hauteur proportionnelle
20
+ * 3. aplatir sur fond blanc (gérer la transparence PNG)
21
+ * 4. niveaux de gris (luminance ITU-R BT.601)
22
+ * 5. binarisation 1-bit (threshold / Floyd-Steinberg / Atkinson)
23
+ * 6. encodage raster ESC/POS GS v 0 (pour les adapters ESC/POS)
24
+ *
25
+ * Garde-fous mémoire : MAX_HEIGHT borne la hauteur pour éviter les OOM /
26
+ * débordements de buffer imprimante.
27
+ */
28
+ object ImageProcessor {
29
+
30
+ /** Hauteur max d'un ticket en points (~ plusieurs mètres @203dpi). */
31
+ private const val MAX_HEIGHT = 20_000
32
+
33
+ // ---------------------------------------------------------------------
34
+ // 1. Décodage
35
+ // ---------------------------------------------------------------------
36
+
37
+ fun decodeFile(path: String): Bitmap {
38
+ val clean = path.removePrefix("file://")
39
+ val file = File(clean)
40
+ if (!file.exists()) {
41
+ throw PrinterException(ErrorCode.IMAGE_INVALID, "Fichier introuvable: $clean")
42
+ }
43
+ // Pré-lecture des bornes pour calculer un inSampleSize si l'image est énorme.
44
+ val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
45
+ BitmapFactory.decodeFile(clean, bounds)
46
+ val opts = BitmapFactory.Options().apply {
47
+ inSampleSize = 1 // le resize final fait le vrai downscale
48
+ inPreferredConfig = Bitmap.Config.ARGB_8888
49
+ }
50
+ return BitmapFactory.decodeFile(clean, opts)
51
+ ?: throw PrinterException(ErrorCode.IMAGE_INVALID, "Décodage impossible: $clean")
52
+ }
53
+
54
+ fun decodeBase64(b64: String): Bitmap {
55
+ val payload = b64.substringAfter("base64,", b64) // tolère le préfixe data:
56
+ val bytes = try {
57
+ Base64.decode(payload, Base64.DEFAULT)
58
+ } catch (e: IllegalArgumentException) {
59
+ throw PrinterException(ErrorCode.IMAGE_INVALID, "Base64 invalide", e.message)
60
+ }
61
+ return BitmapFactory.decodeByteArray(bytes, 0, bytes.size)
62
+ ?: throw PrinterException(ErrorCode.IMAGE_INVALID, "Décodage base64 impossible")
63
+ }
64
+
65
+ // ---------------------------------------------------------------------
66
+ // 2 + 3. Resize sur fond blanc
67
+ // ---------------------------------------------------------------------
68
+
69
+ /** Redimensionne à [targetWidth] px, hauteur proportionnelle, fond blanc opaque. */
70
+ fun resizeToWidth(src: Bitmap, targetWidth: Int): Bitmap {
71
+ val w = targetWidth.coerceAtLeast(8)
72
+ val ratio = w.toDouble() / src.width.toDouble()
73
+ var h = Math.round(src.height * ratio).toInt().coerceAtLeast(1)
74
+ if (h > MAX_HEIGHT) {
75
+ throw PrinterException(
76
+ ErrorCode.IMAGE_TOO_LARGE,
77
+ "Image trop haute après resize: ${h}px (max $MAX_HEIGHT)",
78
+ )
79
+ }
80
+ val scaled = Bitmap.createScaledBitmap(src, w, h, true)
81
+ // Aplatir sur blanc pour neutraliser l'alpha (PNG transparents).
82
+ val flat = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888)
83
+ val canvas = Canvas(flat)
84
+ canvas.drawColor(Color.WHITE)
85
+ canvas.drawBitmap(scaled, 0f, 0f, null)
86
+ if (scaled != src) scaled.recycle()
87
+ return flat
88
+ }
89
+
90
+ // ---------------------------------------------------------------------
91
+ // 4 + 5. Niveaux de gris + binarisation
92
+ // ---------------------------------------------------------------------
93
+
94
+ /**
95
+ * Convertit en niveaux de gris (0=noir..255=blanc) dans un IntArray pixel-par-pixel.
96
+ */
97
+ private fun toGrayscale(bmp: Bitmap): IntArray {
98
+ val w = bmp.width
99
+ val h = bmp.height
100
+ val pixels = IntArray(w * h)
101
+ bmp.getPixels(pixels, 0, w, 0, 0, w, h)
102
+ val gray = IntArray(w * h)
103
+ for (i in pixels.indices) {
104
+ val p = pixels[i]
105
+ val r = (p shr 16) and 0xff
106
+ val g = (p shr 8) and 0xff
107
+ val b = p and 0xff
108
+ // luminance perceptuelle
109
+ gray[i] = ((0.299 * r) + (0.587 * g) + (0.114 * b)).toInt().coerceIn(0, 255)
110
+ }
111
+ return gray
112
+ }
113
+
114
+ /**
115
+ * Produit un MonoBitmap (1 = encre/noir) selon l'option de dithering.
116
+ * Si `options.grayscale == false`, on considère l'image déjà préparée
117
+ * (noir/blanc) : seuil simple, jamais de dithering.
118
+ */
119
+ fun toMono(bmp: Bitmap, options: RenderOptions): MonoBitmap {
120
+ val w = bmp.width
121
+ val h = bmp.height
122
+ val gray = toGrayscale(bmp)
123
+ if (options.invert) {
124
+ for (i in gray.indices) gray[i] = 255 - gray[i]
125
+ }
126
+ val data = when {
127
+ !options.grayscale -> threshold(gray, options.threshold)
128
+ options.dithering == "none" -> threshold(gray, options.threshold)
129
+ options.dithering == "atkinson" -> atkinson(gray, w, h)
130
+ else -> floydSteinberg(gray, w, h)
131
+ }
132
+ return MonoBitmap(w, h, data)
133
+ }
134
+
135
+ private fun threshold(gray: IntArray, t: Int): ByteArray {
136
+ val out = ByteArray(gray.size)
137
+ for (i in gray.indices) out[i] = if (gray[i] < t) 1 else 0
138
+ return out
139
+ }
140
+
141
+ private fun floydSteinberg(grayInput: IntArray, w: Int, h: Int): ByteArray {
142
+ val gray = FloatArray(grayInput.size) { grayInput[it].toFloat() }
143
+ val out = ByteArray(grayInput.size)
144
+ fun at(x: Int, y: Int) = y * w + x
145
+ for (y in 0 until h) {
146
+ for (x in 0 until w) {
147
+ val idx = at(x, y)
148
+ val old = gray[idx]
149
+ val new = if (old < 128f) 0f else 255f
150
+ out[idx] = if (new == 0f) 1 else 0
151
+ val err = old - new
152
+ if (x + 1 < w) gray[at(x + 1, y)] += err * 7f / 16f
153
+ if (x - 1 >= 0 && y + 1 < h) gray[at(x - 1, y + 1)] += err * 3f / 16f
154
+ if (y + 1 < h) gray[at(x, y + 1)] += err * 5f / 16f
155
+ if (x + 1 < w && y + 1 < h) gray[at(x + 1, y + 1)] += err * 1f / 16f
156
+ }
157
+ }
158
+ return out
159
+ }
160
+
161
+ private fun atkinson(grayInput: IntArray, w: Int, h: Int): ByteArray {
162
+ val gray = FloatArray(grayInput.size) { grayInput[it].toFloat() }
163
+ val out = ByteArray(grayInput.size)
164
+ fun at(x: Int, y: Int) = y * w + x
165
+ fun spread(x: Int, y: Int, e: Float) {
166
+ if (x in 0 until w && y in 0 until h) gray[at(x, y)] += e
167
+ }
168
+ for (y in 0 until h) {
169
+ for (x in 0 until w) {
170
+ val idx = at(x, y)
171
+ val old = gray[idx]
172
+ val new = if (old < 128f) 0f else 255f
173
+ out[idx] = if (new == 0f) 1 else 0
174
+ val err = (old - new) / 8f
175
+ spread(x + 1, y, err); spread(x + 2, y, err)
176
+ spread(x - 1, y + 1, err); spread(x, y + 1, err); spread(x + 1, y + 1, err)
177
+ spread(x, y + 2, err)
178
+ }
179
+ }
180
+ return out
181
+ }
182
+
183
+ // ---------------------------------------------------------------------
184
+ // 6. Encodage raster ESC/POS GS v 0
185
+ // ---------------------------------------------------------------------
186
+
187
+ /**
188
+ * Encode un MonoBitmap en commande raster ESC/POS `GS v 0` (mode normal).
189
+ * Largeur paddée au multiple de 8. Voir spec dans src/core/imaging.ts.
190
+ */
191
+ fun encodeEscPosRaster(mono: MonoBitmap): ByteArray {
192
+ val w = mono.width
193
+ val h = mono.height
194
+ val bytesPerRow = (w + 7) / 8
195
+ val xL = bytesPerRow and 0xff
196
+ val xH = (bytesPerRow shr 8) and 0xff
197
+ val yL = h and 0xff
198
+ val yH = (h shr 8) and 0xff
199
+ val header = byteArrayOf(0x1D, 0x76, 0x30, 0x00, xL.toByte(), xH.toByte(), yL.toByte(), yH.toByte())
200
+ val body = ByteArray(bytesPerRow * h)
201
+ for (y in 0 until h) {
202
+ val rowOff = y * bytesPerRow
203
+ val srcOff = y * w
204
+ for (x in 0 until w) {
205
+ if (mono.data[srcOff + x].toInt() == 1) {
206
+ val byteIndex = rowOff + (x shr 3)
207
+ val bit = 7 - (x and 7)
208
+ body[byteIndex] = (body[byteIndex].toInt() or (1 shl bit)).toByte()
209
+ }
210
+ }
211
+ }
212
+ val out = ByteArrayOutputStream(header.size + body.size)
213
+ out.write(header)
214
+ out.write(body)
215
+ return out.toByteArray()
216
+ }
217
+ }
218
+
219
+ /** Image 1-bit : data[i]=1 => point noir/encre. */
220
+ class MonoBitmap(val width: Int, val height: Int, val data: ByteArray)
@@ -0,0 +1,99 @@
1
+ package com.delicity.thermalprinter.image
2
+
3
+ import android.graphics.Bitmap
4
+ import android.graphics.Canvas
5
+ import android.graphics.Color
6
+ import android.graphics.Paint
7
+ import android.graphics.Typeface
8
+ import com.delicity.thermalprinter.model.PrintItem
9
+
10
+ /**
11
+ * Rend une liste d'items texte (`printText`) en **bitmap monochrome**, pour les
12
+ * adapters dont le SDK ne propose pas de builder texte natif (Brother, Zebra).
13
+ * Le moteur appelle ensuite `printBitmap` → le SDK imprime l'image.
14
+ *
15
+ * Police monospace (alignement colonne fiable). Supporte : texte (align, gras,
16
+ * souligné, multiplicateurs de taille), séparateur, saut de ligne. Les items
17
+ * QR/code-barres sont rendus en texte de repli ; image/raw/cut/tiroir sont ignorés
18
+ * (utiliser `printImage` pour un QR/code-barres précis sur ces marques).
19
+ */
20
+ object TextRasterizer {
21
+
22
+ private data class Line(
23
+ val text: String,
24
+ val sizeMul: Int,
25
+ val widthMul: Int,
26
+ val bold: Boolean,
27
+ val underline: Boolean,
28
+ val align: String,
29
+ )
30
+
31
+ fun render(items: List<PrintItem>, widthDots: Int): Bitmap {
32
+ val width = widthDots.coerceAtLeast(128)
33
+ val columns = if (width <= 420) 32 else 48
34
+
35
+ // Taille de base : caler la largeur de N caractères monospace sur la largeur cible.
36
+ val base = Paint(Paint.ANTI_ALIAS_FLAG).apply {
37
+ typeface = Typeface.MONOSPACE
38
+ textSize = 24f
39
+ }
40
+ val charW = base.measureText("M").coerceAtLeast(1f)
41
+ val baseTextSize = (24f * (width.toFloat() / columns) / charW)
42
+ base.textSize = baseTextSize
43
+ val baseLineH = base.fontMetrics.let { it.descent - it.ascent + it.leading }
44
+
45
+ // Pass 1 : construire la liste de lignes + hauteur totale.
46
+ val lines = mutableListOf<Line?>() // null = ligne vide (feed)
47
+ for (item in items) {
48
+ when (item) {
49
+ is PrintItem.Text -> {
50
+ val s = item.style
51
+ item.value.split("\n").forEachIndexed { idx, raw ->
52
+ lines.add(Line(raw, s.heightMultiplier.coerceIn(1, 6), s.widthMultiplier.coerceIn(1, 6), s.bold, s.underline != "none", s.align ?: "left"))
53
+ // newline=false sur le dernier fragment : on garde quand même une ligne (simplifié).
54
+ if (!s.newline && idx == 0) Unit
55
+ }
56
+ }
57
+ is PrintItem.Divider -> {
58
+ val cols = item.columns ?: columns
59
+ lines.add(Line(item.char.take(1).ifEmpty { "-" }.repeat(cols.coerceIn(1, 96)), 1, 1, item.bold, false, item.align ?: "left"))
60
+ }
61
+ is PrintItem.Feed -> repeat(item.lines.coerceIn(1, 20)) { lines.add(null) }
62
+ is PrintItem.QrCode -> lines.add(Line("[QR] ${item.value}", 1, 1, false, false, item.align))
63
+ is PrintItem.Barcode -> lines.add(Line("[${item.symbology}] ${item.value}", 1, 1, false, false, item.align))
64
+ is PrintItem.Cut, is PrintItem.CashDrawer, is PrintItem.Image, is PrintItem.Raw -> Unit
65
+ }
66
+ }
67
+ if (lines.isEmpty()) lines.add(null)
68
+
69
+ val totalHeight = lines.sumOf { line ->
70
+ ((line?.sizeMul ?: 1) * baseLineH).toDouble()
71
+ }.toInt().coerceAtLeast(baseLineH.toInt())
72
+
73
+ // Pass 2 : dessiner.
74
+ val bmp = Bitmap.createBitmap(width, totalHeight + 8, Bitmap.Config.ARGB_8888)
75
+ val canvas = Canvas(bmp)
76
+ canvas.drawColor(Color.WHITE)
77
+ val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply { color = Color.BLACK; typeface = Typeface.MONOSPACE }
78
+
79
+ var y = 0f
80
+ for (line in lines) {
81
+ if (line == null) { y += baseLineH; continue }
82
+ paint.textSize = baseTextSize * line.sizeMul
83
+ paint.textScaleX = line.widthMul.toFloat()
84
+ paint.isFakeBoldText = line.bold
85
+ paint.isUnderlineText = line.underline
86
+ val lineH = baseLineH * line.sizeMul
87
+ val textW = paint.measureText(line.text)
88
+ val x = when (line.align) {
89
+ "center" -> (width - textW) / 2f
90
+ "right" -> width - textW
91
+ else -> 0f
92
+ }.coerceAtLeast(0f)
93
+ // baseline = y - ascent
94
+ canvas.drawText(line.text, x, y - paint.fontMetrics.ascent, paint)
95
+ y += lineH
96
+ }
97
+ return bmp
98
+ }
99
+ }
@@ -0,0 +1,206 @@
1
+ package com.delicity.thermalprinter.model
2
+
3
+ import org.json.JSONArray
4
+ import org.json.JSONObject
5
+
6
+ /** Transports physiques. Doit rester aligné avec PrinterTransport (TypeScript). */
7
+ enum class Transport(val value: String) {
8
+ WIFI("wifi"),
9
+ ETHERNET("ethernet"),
10
+ BLUETOOTH("bluetooth"),
11
+ BLE("ble"),
12
+ USB("usb");
13
+
14
+ companion object {
15
+ fun from(v: String?): Transport =
16
+ entries.firstOrNull { it.value == v } ?: WIFI
17
+ }
18
+ }
19
+
20
+ /** Identifiants d'adapter. Aligné avec PrinterAdapterId (TypeScript). */
21
+ enum class AdapterId(val value: String) {
22
+ ESCPOS("escpos"),
23
+ EPSON("epson"),
24
+ STAR("star"),
25
+ BROTHER("brother"),
26
+ ZEBRA("zebra"),
27
+ RAW_TCP("rawTcp");
28
+
29
+ companion object {
30
+ fun from(v: String?): AdapterId =
31
+ entries.firstOrNull { it.value == v } ?: ESCPOS
32
+ }
33
+ }
34
+
35
+ /** Codes d'erreur normalisés. Aligné avec PrintErrorCode (TypeScript). */
36
+ enum class ErrorCode {
37
+ PRINTER_NOT_FOUND, PRINTER_OFFLINE, CONNECTION_FAILED, PERMISSION_DENIED,
38
+ BLUETOOTH_DISABLED, WIFI_NOT_CONNECTED, PAIRING_REQUIRED, UNSUPPORTED_TRANSPORT,
39
+ UNSUPPORTED_PRINTER, IMAGE_INVALID, IMAGE_TOO_LARGE, PRINT_FAILED, PAPER_EMPTY,
40
+ COVER_OPEN, SDK_NOT_AVAILABLE, TIMEOUT, UNKNOWN
41
+ }
42
+
43
+ /** Exception interne portant un code normalisé, convertie en rejet Capacitor. */
44
+ class PrinterException(
45
+ val code: ErrorCode,
46
+ message: String,
47
+ val detail: String? = null,
48
+ val retryable: Boolean = false,
49
+ ) : Exception(message)
50
+
51
+ data class Capabilities(
52
+ val paperWidthMm: Int = 80,
53
+ val printableDots: Int = 576,
54
+ val dpi: Int = 203,
55
+ val supportsCut: Boolean = true,
56
+ val supportsCashDrawer: Boolean = false,
57
+ val supportsStatus: Boolean = false,
58
+ val supportsRasterImage: Boolean = true,
59
+ val supportsQrCode: Boolean = false,
60
+ val supportsBarcode: Boolean = false,
61
+ ) {
62
+ fun toJson(): JSONObject = JSONObject()
63
+ .put("paperWidthMm", paperWidthMm)
64
+ .put("printableDots", printableDots)
65
+ .put("dpi", dpi)
66
+ .put("supportsCut", supportsCut)
67
+ .put("supportsCashDrawer", supportsCashDrawer)
68
+ .put("supportsStatus", supportsStatus)
69
+ .put("supportsRasterImage", supportsRasterImage)
70
+ .put("supportsQrCode", supportsQrCode)
71
+ .put("supportsBarcode", supportsBarcode)
72
+
73
+ companion object {
74
+ fun fromJson(o: JSONObject?): Capabilities {
75
+ if (o == null) return Capabilities()
76
+ return Capabilities(
77
+ paperWidthMm = o.optInt("paperWidthMm", 80),
78
+ printableDots = o.optInt("printableDots", 576),
79
+ dpi = o.optInt("dpi", 203),
80
+ supportsCut = o.optBoolean("supportsCut", true),
81
+ supportsCashDrawer = o.optBoolean("supportsCashDrawer", false),
82
+ supportsStatus = o.optBoolean("supportsStatus", false),
83
+ supportsRasterImage = o.optBoolean("supportsRasterImage", true),
84
+ supportsQrCode = o.optBoolean("supportsQrCode", false),
85
+ supportsBarcode = o.optBoolean("supportsBarcode", false),
86
+ )
87
+ }
88
+ }
89
+ }
90
+
91
+ /** Imprimante découverte/normalisée. */
92
+ data class DiscoveredPrinter(
93
+ val id: String,
94
+ val name: String,
95
+ val brand: String? = null,
96
+ val model: String? = null,
97
+ val transport: Transport,
98
+ val adapter: AdapterId,
99
+ val address: String,
100
+ val capabilities: Capabilities? = null,
101
+ val discoveredBy: MutableSet<AdapterId> = mutableSetOf(),
102
+ var lastSeenAt: Long = System.currentTimeMillis(),
103
+ var isDefault: Boolean = false,
104
+ var isConnected: Boolean = false,
105
+ ) {
106
+ fun toJson(): JSONObject = JSONObject().apply {
107
+ put("id", id)
108
+ put("name", name)
109
+ brand?.let { put("brand", it) }
110
+ model?.let { put("model", it) }
111
+ put("transport", transport.value)
112
+ put("adapter", adapter.value)
113
+ put("address", address)
114
+ capabilities?.let { put("capabilities", it.toJson()) }
115
+ put("discoveredBy", JSONArray(discoveredBy.map { it.value }))
116
+ put("lastSeenAt", lastSeenAt)
117
+ put("isDefault", isDefault)
118
+ put("isConnected", isConnected)
119
+ }
120
+ }
121
+
122
+ /** Profil persistant. */
123
+ data class PrinterProfile(
124
+ val id: String,
125
+ val adapter: AdapterId,
126
+ val transport: Transport,
127
+ val address: String,
128
+ val brand: String?,
129
+ val model: String?,
130
+ val name: String,
131
+ val capabilities: Capabilities,
132
+ val adapterMeta: JSONObject = JSONObject(),
133
+ var isDefault: Boolean = false,
134
+ val createdAt: Long = System.currentTimeMillis(),
135
+ var updatedAt: Long = System.currentTimeMillis(),
136
+ ) {
137
+ fun toJson(): JSONObject = JSONObject().apply {
138
+ put("id", id)
139
+ put("adapter", adapter.value)
140
+ put("transport", transport.value)
141
+ put("address", address)
142
+ brand?.let { put("brand", it) }
143
+ model?.let { put("model", it) }
144
+ put("name", name)
145
+ put("capabilities", capabilities.toJson())
146
+ put("adapterMeta", adapterMeta)
147
+ put("isDefault", isDefault)
148
+ put("createdAt", createdAt)
149
+ put("updatedAt", updatedAt)
150
+ }
151
+
152
+ companion object {
153
+ fun fromJson(o: JSONObject): PrinterProfile = PrinterProfile(
154
+ id = o.getString("id"),
155
+ adapter = AdapterId.from(o.optString("adapter")),
156
+ transport = Transport.from(o.optString("transport")),
157
+ address = o.getString("address"),
158
+ brand = o.optString("brand").ifEmpty { null },
159
+ model = o.optString("model").ifEmpty { null },
160
+ name = o.optString("name"),
161
+ capabilities = Capabilities.fromJson(o.optJSONObject("capabilities")),
162
+ adapterMeta = o.optJSONObject("adapterMeta") ?: JSONObject(),
163
+ isDefault = o.optBoolean("isDefault", false),
164
+ createdAt = o.optLong("createdAt", System.currentTimeMillis()),
165
+ updatedAt = o.optLong("updatedAt", System.currentTimeMillis()),
166
+ )
167
+ }
168
+ }
169
+
170
+ /** Statut temps réel. */
171
+ data class PrinterStatus(
172
+ val id: String,
173
+ val connection: String, // disconnected|connecting|connected|error
174
+ val online: Boolean,
175
+ val paper: String, // ok|near_end|empty|unknown
176
+ val coverOpen: Boolean? = null,
177
+ val errorCode: ErrorCode? = null,
178
+ val rawStatus: String? = null,
179
+ val checkedAt: Long = System.currentTimeMillis(),
180
+ ) {
181
+ fun toJson(): JSONObject = JSONObject().apply {
182
+ put("id", id)
183
+ put("connection", connection)
184
+ put("online", online)
185
+ put("paper", paper)
186
+ coverOpen?.let { put("coverOpen", it) }
187
+ errorCode?.let { put("errorCode", it.name) }
188
+ rawStatus?.let { put("rawStatus", it) }
189
+ put("checkedAt", checkedAt)
190
+ }
191
+ }
192
+
193
+ /** Options de rendu résolues passées à l'adapter. */
194
+ data class RenderOptions(
195
+ val widthDots: Int,
196
+ val resize: Boolean = true,
197
+ val grayscale: Boolean = true,
198
+ val threshold: Int = 128,
199
+ val dithering: String = "floyd_steinberg", // none|floyd_steinberg|atkinson
200
+ val align: String = "center", // left|center|right
201
+ val invert: Boolean = false,
202
+ val cut: Boolean = true,
203
+ val feedLines: Int = 3,
204
+ val openCashDrawer: Boolean = false,
205
+ val copies: Int = 1,
206
+ )