@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.
- package/DelicityThermalPrinter.podspec +28 -0
- package/LICENSE +21 -0
- package/README.md +649 -0
- package/android/build.gradle +122 -0
- package/android/src/main/AndroidManifest.xml +38 -0
- package/android/src/main/java/com/delicity/thermalprinter/Logger.kt +50 -0
- package/android/src/main/java/com/delicity/thermalprinter/ThermalPrinterEngine.kt +528 -0
- package/android/src/main/java/com/delicity/thermalprinter/ThermalPrinterPlugin.kt +334 -0
- package/android/src/main/java/com/delicity/thermalprinter/adapters/BleAdapter.kt +125 -0
- package/android/src/main/java/com/delicity/thermalprinter/adapters/BrotherAdapter.kt +206 -0
- package/android/src/main/java/com/delicity/thermalprinter/adapters/EpsonAdapter.kt +384 -0
- package/android/src/main/java/com/delicity/thermalprinter/adapters/EscPosAdapter.kt +160 -0
- package/android/src/main/java/com/delicity/thermalprinter/adapters/EscPosCommands.kt +42 -0
- package/android/src/main/java/com/delicity/thermalprinter/adapters/EscPosTextEncoder.kt +138 -0
- package/android/src/main/java/com/delicity/thermalprinter/adapters/PrinterAdapter.kt +95 -0
- package/android/src/main/java/com/delicity/thermalprinter/adapters/RawTcpAdapter.kt +96 -0
- package/android/src/main/java/com/delicity/thermalprinter/adapters/SdkContract.kt +158 -0
- package/android/src/main/java/com/delicity/thermalprinter/adapters/SdkReflect.kt +104 -0
- package/android/src/main/java/com/delicity/thermalprinter/adapters/StarAdapter.kt +322 -0
- package/android/src/main/java/com/delicity/thermalprinter/adapters/UsbAdapter.kt +248 -0
- package/android/src/main/java/com/delicity/thermalprinter/adapters/ZebraAdapter.kt +207 -0
- package/android/src/main/java/com/delicity/thermalprinter/discovery/AdapterPriority.kt +39 -0
- package/android/src/main/java/com/delicity/thermalprinter/discovery/BleScanner.kt +70 -0
- package/android/src/main/java/com/delicity/thermalprinter/discovery/BluetoothClassicScanner.kt +112 -0
- package/android/src/main/java/com/delicity/thermalprinter/discovery/DiscoveryManager.kt +136 -0
- package/android/src/main/java/com/delicity/thermalprinter/discovery/TcpScanner.kt +96 -0
- package/android/src/main/java/com/delicity/thermalprinter/image/ImageCache.kt +88 -0
- package/android/src/main/java/com/delicity/thermalprinter/image/ImageProcessor.kt +220 -0
- package/android/src/main/java/com/delicity/thermalprinter/image/TextRasterizer.kt +99 -0
- package/android/src/main/java/com/delicity/thermalprinter/model/Models.kt +206 -0
- package/android/src/main/java/com/delicity/thermalprinter/model/PrintItem.kt +100 -0
- package/android/src/main/java/com/delicity/thermalprinter/store/PrinterStore.kt +71 -0
- package/android/src/main/java/com/delicity/thermalprinter/transport/BleGattClient.kt +201 -0
- package/android/src/main/java/com/delicity/thermalprinter/transport/BluetoothSppTransport.kt +110 -0
- package/android/src/main/java/com/delicity/thermalprinter/transport/ByteTransport.kt +18 -0
- package/android/src/main/java/com/delicity/thermalprinter/transport/TcpTransport.kt +83 -0
- package/dist/esm/adapters/dedup.d.ts +26 -0
- package/dist/esm/adapters/dedup.js +66 -0
- package/dist/esm/adapters/dedup.js.map +1 -0
- package/dist/esm/adapters/priority.d.ts +29 -0
- package/dist/esm/adapters/priority.js +55 -0
- package/dist/esm/adapters/priority.js.map +1 -0
- package/dist/esm/core/enums.d.ts +61 -0
- package/dist/esm/core/enums.js +25 -0
- package/dist/esm/core/enums.js.map +1 -0
- package/dist/esm/core/errors.d.ts +16 -0
- package/dist/esm/core/errors.js +53 -0
- package/dist/esm/core/errors.js.map +1 -0
- package/dist/esm/core/escpos-text.d.ts +33 -0
- package/dist/esm/core/escpos-text.js +239 -0
- package/dist/esm/core/escpos-text.js.map +1 -0
- package/dist/esm/core/imaging.d.ts +91 -0
- package/dist/esm/core/imaging.js +184 -0
- package/dist/esm/core/imaging.js.map +1 -0
- package/dist/esm/core/models.d.ts +131 -0
- package/dist/esm/core/models.js +2 -0
- package/dist/esm/core/models.js.map +1 -0
- package/dist/esm/core/options.d.ts +154 -0
- package/dist/esm/core/options.js +2 -0
- package/dist/esm/core/options.js.map +1 -0
- package/dist/esm/core/text.d.ts +138 -0
- package/dist/esm/core/text.js +14 -0
- package/dist/esm/core/text.js.map +1 -0
- package/dist/esm/definitions.d.ts +155 -0
- package/dist/esm/definitions.js +2 -0
- package/dist/esm/definitions.js.map +1 -0
- package/dist/esm/index.d.ts +15 -0
- package/dist/esm/index.js +18 -0
- package/dist/esm/index.js.map +1 -0
- package/dist/esm/web.d.ts +63 -0
- package/dist/esm/web.js +112 -0
- package/dist/esm/web.js.map +1 -0
- package/dist/plugin.cjs.js +224 -0
- package/dist/plugin.cjs.js.map +1 -0
- package/dist/plugin.js +227 -0
- package/dist/plugin.js.map +1 -0
- package/ios/Plugin/Adapters/BrotherAdapter.swift +139 -0
- package/ios/Plugin/Adapters/EpsonAdapter.swift +131 -0
- package/ios/Plugin/Adapters/EscPosAdapter.swift +106 -0
- package/ios/Plugin/Adapters/EscPosCommands.swift +32 -0
- package/ios/Plugin/Adapters/EscPosTextEncoder.swift +115 -0
- package/ios/Plugin/Adapters/PrinterAdapter.swift +44 -0
- package/ios/Plugin/Adapters/RawTcpAdapter.swift +70 -0
- package/ios/Plugin/Adapters/StarAdapter.swift +305 -0
- package/ios/Plugin/Adapters/ZebraAdapter.swift +119 -0
- package/ios/Plugin/Discovery/AdapterPriority.swift +21 -0
- package/ios/Plugin/Discovery/BonjourScanner.swift +51 -0
- package/ios/Plugin/Discovery/DiscoveryManager.swift +86 -0
- package/ios/Plugin/Image/ImageCache.swift +73 -0
- package/ios/Plugin/Image/ImageProcessor.swift +168 -0
- package/ios/Plugin/Image/TextRasterizer.swift +81 -0
- package/ios/Plugin/Logger.swift +33 -0
- package/ios/Plugin/Model/Models.swift +174 -0
- package/ios/Plugin/Model/PrintItem.swift +111 -0
- package/ios/Plugin/Store/PrinterStore.swift +51 -0
- package/ios/Plugin/ThermalPrinterEngine.swift +395 -0
- package/ios/Plugin/ThermalPrinterPlugin.m +22 -0
- package/ios/Plugin/ThermalPrinterPlugin.swift +258 -0
- package/ios/Plugin/Transport/TcpTransport.swift +89 -0
- 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
|
+
)
|