diff --git a/.github/workflows/develop.yml b/.github/workflows/develop.yml
deleted file mode 100644
index f981574..0000000
--- a/.github/workflows/develop.yml
+++ /dev/null
@@ -1,69 +0,0 @@
-name: Android CI - Develop
-
-on:
- push:
- branches: [ "develop" ]
-
-jobs:
- ui-tests:
- name: Run android tests and build staging
- runs-on: ubuntu-latest
- timeout-minutes: 60
-
- steps:
-
- - name: Checking out branch
- uses: actions/checkout@v4
-
- - name: set up JDK 17
- uses: actions/setup-java@v4
- with:
- java-version: '17'
- distribution: 'temurin'
- cache: gradle
-
- - name: Setup Android SDK
- uses: android-actions/setup-android@v3
-
- # Hardware accelerated Android virtualization on Actions Linux larger hosted runners
- - name: Enable KVM
- run: |
- echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules
- sudo udevadm control --reload-rules
- sudo udevadm trigger --name-match=kvm
-
- # Significantly reduce emulator startup time by setting up AVD snapshot caching
- - name: Gradle cache
- uses: gradle/actions/setup-gradle@v3
-
- - name: AVD cache
- uses: actions/cache@v4
- id: avd-cache
- with:
- path: |
- ~/.android/avd/*
- ~/.android/adb*
- key: avd-30
-
- - name: Create AVD and generate snapshot for caching
- if: steps.avd-cache.outputs.cache-hit != 'true'
- uses: ReactiveCircus/android-emulator-runner@v2.33.0
- with:
- api-level: 30
- target: google_atd
- arch: x86
- force-avd-creation: false
- emulator-options: -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none
- disable-animations: false
- script: echo "Generated AVD snapshot for caching."
-
- - name: Run Android tests
- uses: ReactiveCircus/android-emulator-runner@v2.33.0
- with:
- api-level: 30
- target: google_atd
- arch: x86
- force-avd-creation: false
- emulator-options: -no-snapshot-save -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none
- disable-animations: true
- script: ./gradlew :print-engine:connectedAndroidTest
diff --git a/.github/workflows/master.yml b/.github/workflows/master.yml
new file mode 100644
index 0000000..bc1b638
--- /dev/null
+++ b/.github/workflows/master.yml
@@ -0,0 +1,52 @@
+name: Android CI - Master
+
+on:
+ push:
+ branches: [ "master" ]
+
+jobs:
+ ci-checks:
+ name: Run code checks and android tests
+ runs-on: ubuntu-latest
+ timeout-minutes: 60
+
+ steps:
+
+ - name: Checking out branch
+ uses: actions/checkout@v4
+
+ - name: set up JDK 17
+ uses: actions/setup-java@v4
+ with:
+ java-version: '17'
+ distribution: 'temurin'
+ cache: gradle
+
+ - name: Setup Android SDK
+ uses: android-actions/setup-android@v3
+
+ # Hardware accelerated Android virtualization on Actions Linux larger hosted runners
+ - name: Enable KVM
+ run: |
+ echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules
+ sudo udevadm control --reload-rules
+ sudo udevadm trigger --name-match=kvm
+
+ - name: Gradle cache
+ uses: gradle/actions/setup-gradle@v3
+
+ - name: Run Spotless Code Analysis
+ run: ./gradlew spotlessCheck
+
+ - name: Run Unit tests
+ run: ./gradlew testDebug
+
+ - name: Run Android tests
+ uses: ReactiveCircus/android-emulator-runner@v2.33.0
+ with:
+ api-level: 30
+ target: google_atd
+ arch: x86
+ emulator-options: -no-snapshot-save -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none
+ disable-animations: true
+ script: ./gradlew :print-engine:connectedAndroidTest
diff --git a/.github/workflows/pr-checks.yml b/.github/workflows/pr-checks.yml
index d87baab..e8d7e58 100644
--- a/.github/workflows/pr-checks.yml
+++ b/.github/workflows/pr-checks.yml
@@ -1,29 +1,29 @@
name: Android CI - PR checks
on:
- pull_request
+ pull_request:
jobs:
- basic-tests:
- name: Run code analysis and unit tests
- runs-on: ubuntu-latest
+ pr-checks:
+ name: Run full PR check
+ runs-on: ubuntu-latest
- steps:
- - name: Checking out branch
- uses: actions/checkout@v4
+ steps:
+ - name: Checking out branch
+ uses: actions/checkout@v4
- - name: set up JDK 17
- uses: actions/setup-java@v4
- with:
- java-version: '17'
- distribution: 'temurin'
- cache: gradle
+ - name: set up JDK 17
+ uses: actions/setup-java@v4
+ with:
+ java-version: '17'
+ distribution: 'temurin'
+ cache: gradle
- - name: Setup Android SDK
- uses: android-actions/setup-android@v3
+ - name: Setup Android SDK
+ uses: android-actions/setup-android@v3
- - name: Run Spotless Code Analysis
- run: ./gradlew spotlessCheck
+ - name: Run Spotless Code Analysis
+ run: ./gradlew spotlessCheck
- - name: Run Unit tests
- run: ./gradlew testDebug
+ - name: Run Unit tests
+ run: ./gradlew testDebug
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 3de1260..aca5c76 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -2,7 +2,8 @@ name: Publish to Maven Central
on:
push:
- branches: [ "master" ]
+ tags:
+ - '[0-9]+.[0-9]+.[0-9]+'
jobs:
publish:
@@ -14,6 +15,15 @@ jobs:
- name: Checkout code
uses: actions/checkout@v4
+ # The published version comes from libs.versions.toml, not the tag - keep them in sync
+ - name: Verify tag matches library version
+ run: |
+ VERSION=$(grep -m1 '^print-engine = ' gradle/libs.versions.toml | cut -d'"' -f2)
+ if [ "${GITHUB_REF_NAME}" != "${VERSION}" ]; then
+ echo "Tag ${GITHUB_REF_NAME} does not match libs.versions.toml version ${VERSION}"
+ exit 1
+ fi
+
- name: Setup Java
uses: actions/setup-java@v4
with:
@@ -50,4 +60,4 @@ jobs:
- name: Publish To Maven Central
run: |
- ./gradlew publishAndReleaseToMavenCentral --no-configuration-cache
\ No newline at end of file
+ ./gradlew publishAndReleaseToMavenCentral --no-configuration-cache
diff --git a/README.md b/README.md
index 01e24c8..1df8604 100644
--- a/README.md
+++ b/README.md
@@ -32,16 +32,16 @@ dependencyResolutionManagement {
// build.gradle.kts
dependencies {
// Core library (required)
- implementation("io.github.tillhub:print-engine-core:3.0.5")
+ implementation("io.github.tillhub:print-engine-core:3.1.0")
// Built-in printer plugins (Android only)
- implementation("io.github.tillhub:print-engine-pax:3.0.5")
- implementation("io.github.tillhub:print-engine-sunmi:3.0.5")
- implementation("io.github.tillhub:print-engine-verifone:3.0.5")
+ implementation("io.github.tillhub:print-engine-pax:3.1.0")
+ implementation("io.github.tillhub:print-engine-sunmi:3.1.0")
+ implementation("io.github.tillhub:print-engine-verifone:3.1.0")
// External printer plugins (Android & iOS)
- implementation("io.github.tillhub:print-engine-star:3.0.5")
- implementation("io.github.tillhub:print-engine-epson:3.0.5")
+ implementation("io.github.tillhub:print-engine-star:3.1.0")
+ implementation("io.github.tillhub:print-engine-epson:3.1.0")
}
```
@@ -255,7 +255,7 @@ Supports Android and iOS.
#### Installation
```kotlin
-implementation("io.github.tillhub:print-engine-star:3.0.5")
+implementation("io.github.tillhub:print-engine-star:3.1.0")
```
#### Setup & Usage
@@ -350,7 +350,7 @@ Supports Android and iOS.
#### Installation
```kotlin
-implementation("io.github.tillhub:print-engine-epson:3.0.5")
+implementation("io.github.tillhub:print-engine-epson:3.1.0")
```
#### Setup & Usage
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 7a69554..5a3e144 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -1,5 +1,5 @@
[versions]
-print-engine = "3.0.5"
+print-engine = "3.1.0"
# Build tools
kotlin = "2.2.0"
diff --git a/print-engine/build.gradle.kts b/print-engine/build.gradle.kts
index 9c641fa..6f4da52 100644
--- a/print-engine/build.gradle.kts
+++ b/print-engine/build.gradle.kts
@@ -148,16 +148,6 @@ mavenPublishing {
name.set("Đorđe Hrnjez")
email.set("dorde.hrnjez@unzer.com")
}
- developer {
- id.set("SloInfinity")
- name.set("Martin Sirok")
- email.set("m.sirok.ext@unzer.com")
- }
- developer {
- id.set("shekar-allam")
- name.set("Chandrashekar Allam")
- email.set("chandrashekar.allam@unzer.com")
- }
}
// Specify SCM information
diff --git a/print-engine/src/androidMain/kotlin/de/tillhub/printengine/data/ImageBitmap.android.kt b/print-engine/src/androidMain/kotlin/de/tillhub/printengine/data/ImageBitmap.android.kt
index 22c14cf..7edb0ed 100644
--- a/print-engine/src/androidMain/kotlin/de/tillhub/printengine/data/ImageBitmap.android.kt
+++ b/print-engine/src/androidMain/kotlin/de/tillhub/printengine/data/ImageBitmap.android.kt
@@ -4,12 +4,22 @@ import android.graphics.Bitmap
import android.util.Base64
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.asAndroidBitmap
+import androidx.compose.ui.graphics.asImageBitmap
+import androidx.core.graphics.createBitmap
import java.io.ByteArrayOutputStream
actual fun ImageBitmap.encodeToBase64(): String = ByteArrayOutputStream().let { stream ->
this.asAndroidBitmap().compress(Bitmap.CompressFormat.PNG, PNG_QUALITY, stream)
val b = stream.toByteArray()
- Base64.encodeToString(b, Base64.DEFAULT)
+ Base64.encodeToString(b, Base64.NO_WRAP)
}
+internal actual fun argbToImageBitmap(
+ pixels: IntArray,
+ width: Int,
+ height: Int,
+): ImageBitmap = createBitmap(width, height).apply {
+ setPixels(pixels, 0, width, 0, 0, width, height)
+}.asImageBitmap()
+
private const val PNG_QUALITY = 100
diff --git a/print-engine/src/androidUnitTest/kotlin/de/tillhub/printengine/data/ImageBitmapExtTest.kt b/print-engine/src/androidUnitTest/kotlin/de/tillhub/printengine/data/ImageBitmapExtTest.kt
new file mode 100644
index 0000000..ecbe600
--- /dev/null
+++ b/print-engine/src/androidUnitTest/kotlin/de/tillhub/printengine/data/ImageBitmapExtTest.kt
@@ -0,0 +1,110 @@
+package de.tillhub.printengine.data
+
+import android.graphics.Bitmap
+import androidx.compose.ui.graphics.ImageBitmap
+import androidx.compose.ui.graphics.asImageBitmap
+import io.kotest.core.spec.style.FunSpec
+import io.kotest.extensions.robolectric.RobolectricTest
+import io.kotest.matchers.ints.shouldBeLessThan
+import io.kotest.matchers.shouldBe
+import io.kotest.matchers.string.shouldNotContain
+
+@RobolectricTest
+internal class ImageBitmapExtTest :
+ FunSpec({
+ test("normalizeForPrintHead downscales an oversized image to the head width") {
+ val image = gradientImage(width = 800, height = 400)
+
+ val normalized = image.normalizeForPrintHead(HEAD_WIDTH_PX)
+
+ normalized.width shouldBe HEAD_WIDTH_PX
+ normalized.height shouldBe 192
+ }
+
+ test("normalizeForPrintHead does not upscale an image that already fits") {
+ val image = gradientImage(width = 120, height = 60)
+
+ val normalized = image.normalizeForPrintHead(HEAD_WIDTH_PX)
+
+ normalized.width shouldBe 120
+ normalized.height shouldBe 60
+ }
+
+ test("normalizeForPrintHead reduces the image to one bit monochrome") {
+ val image = gradientImage(width = 200, height = 100)
+
+ val pixels = IntArray(200 * 100)
+ image.normalizeForPrintHead(HEAD_WIDTH_PX).readPixels(pixels)
+
+ pixels.distinct().sorted() shouldBe listOf(BLACK, WHITE).sorted()
+ }
+
+ test("normalizeForPrintHead leaves a degenerate image alone") {
+ val image = gradientImage(width = 4, height = 4)
+
+ image.normalizeForPrintHead(headWidthPx = 0) shouldBe image
+ }
+
+ test("normalizeForPrintHead shrinks the encoded payload") {
+ // The point of the whole exercise: a full colour photo is what overflows the IPC
+ // transaction, and normalising it is what brings the receipt back under the limit.
+ // Photographic detail is what PNG cannot compress away, so that is what is measured
+ // here - a flat logo would understate the difference.
+ val image = photoLikeImage(width = 800, height = 400)
+
+ val normalizedSize = image.normalizeForPrintHead(HEAD_WIDTH_PX).encodeToBase64().length
+
+ normalizedSize shouldBeLessThan image.encodeToBase64().length
+ }
+
+ test("encodeToBase64 does not wrap lines") {
+ // Line breaks are ~2.7% of wasted payload inside a data URI, and iOS never emitted
+ // them - so neither should Android.
+ val image = gradientImage(width = 64, height = 64)
+
+ image.encodeToBase64() shouldNotContain "\n"
+ }
+ })
+
+/** A horizontal grey ramp: enough tone that dithering has something to do. */
+private fun gradientImage(
+ width: Int,
+ height: Int,
+): ImageBitmap = imageOf(width, height) { index ->
+ val value = (index % width) * 255 / (width - 1)
+ OPAQUE_ALPHA or (value shl 16) or (value shl 8) or value
+}
+
+/**
+ * Deterministic per-pixel colour noise, standing in for a photo: like a photograph and unlike a
+ * gradient, there is no redundancy for PNG to squeeze out.
+ */
+private fun photoLikeImage(
+ width: Int,
+ height: Int,
+): ImageBitmap {
+ var state = 1
+ return imageOf(width, height) {
+ state = state * LCG_MULTIPLIER + LCG_INCREMENT
+ OPAQUE_ALPHA or (state ushr 8 and 0xFFFFFF)
+ }
+}
+
+private fun imageOf(
+ width: Int,
+ height: Int,
+ pixel: (index: Int) -> Int,
+): ImageBitmap {
+ val pixels = IntArray(width * height) { pixel(it) }
+ return Bitmap
+ .createBitmap(width, height, Bitmap.Config.ARGB_8888)
+ .apply { setPixels(pixels, 0, width, 0, 0, width, height) }
+ .asImageBitmap()
+}
+
+private const val HEAD_WIDTH_PX = 384
+private const val LCG_MULTIPLIER = 1664525
+private const val LCG_INCREMENT = 1013904223
+private const val OPAQUE_ALPHA = 0xFF shl 24
+private const val BLACK = 0xFF000000.toInt()
+private const val WHITE = 0xFFFFFFFF.toInt()
diff --git a/print-engine/src/androidUnitTest/kotlin/de/tillhub/printengine/html/HtmlUtilsImageTest.kt b/print-engine/src/androidUnitTest/kotlin/de/tillhub/printengine/html/HtmlUtilsImageTest.kt
index ea17128..dc5d420 100644
--- a/print-engine/src/androidUnitTest/kotlin/de/tillhub/printengine/html/HtmlUtilsImageTest.kt
+++ b/print-engine/src/androidUnitTest/kotlin/de/tillhub/printengine/html/HtmlUtilsImageTest.kt
@@ -16,7 +16,7 @@ internal class HtmlUtilsImageTest :
result shouldBe "
" +
"

"
+ "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAEElEQVR4AQEFAPr/AAAAAAAABQAB" +
+ "ZHiVOAAAAABJRU5ErkJggg==\"/>"
}
})
diff --git a/print-engine/src/commonMain/kotlin/de/tillhub/printengine/PrinterImpl.kt b/print-engine/src/commonMain/kotlin/de/tillhub/printengine/PrinterImpl.kt
index 6e4e635..31ce9c5 100644
--- a/print-engine/src/commonMain/kotlin/de/tillhub/printengine/PrinterImpl.kt
+++ b/print-engine/src/commonMain/kotlin/de/tillhub/printengine/PrinterImpl.kt
@@ -40,8 +40,8 @@ internal class PrinterImpl(
info.serviceVersion,
)
}
- }.doOnError {
- logWarning("getting printer info")
+ }.doOnError { error ->
+ logWarning(error, "getting printer info")
}
}
@@ -72,8 +72,8 @@ internal class PrinterImpl(
controller.start()
analytics?.logPrintReceipt(job.description)
}
- }.doOnError {
- logWarning("printing job '${job.description}'")
+ }.doOnError { error ->
+ logWarning(error, "printing job '${job.description}'")
analytics?.logErrorPrintReceipt("printing text '${job.description}'")
}
}
@@ -82,7 +82,13 @@ internal class PrinterImpl(
Logger.i("printing: $message")
}
- private fun logWarning(reason: String) {
- Logger.w("Printer not connected for: $reason")
+ private fun logWarning(
+ error: PrinterResult.Error,
+ reason: String,
+ ) {
+ when (error) {
+ PrinterResult.Error.PrinterNotConnected -> Logger.w("Printer not connected for: $reason")
+ is PrinterResult.Error.WithException -> Logger.w("Failed for: $reason", error.error)
+ }
}
}
diff --git a/print-engine/src/commonMain/kotlin/de/tillhub/printengine/data/ImageBitmapExt.kt b/print-engine/src/commonMain/kotlin/de/tillhub/printengine/data/ImageBitmapExt.kt
index 90d70e9..b2a08f7 100644
--- a/print-engine/src/commonMain/kotlin/de/tillhub/printengine/data/ImageBitmapExt.kt
+++ b/print-engine/src/commonMain/kotlin/de/tillhub/printengine/data/ImageBitmapExt.kt
@@ -3,3 +3,41 @@ package de.tillhub.printengine.data
import androidx.compose.ui.graphics.ImageBitmap
expect fun ImageBitmap.encodeToBase64(): String
+
+/**
+ * Builds an [ImageBitmap] from opaque ARGB [pixels]. The only platform specific step of
+ * [normalizeForPrintHead]; everything above it is shared Kotlin.
+ */
+internal expect fun argbToImageBitmap(
+ pixels: IntArray,
+ width: Int,
+ height: Int,
+): ImageBitmap
+
+/**
+ * Returns this image prepared for a monochrome thermal print head that is [headWidthPx]
+ * dots wide: downscaled to that width when wider, and reduced to 1 bit black and white by
+ * Floyd-Steinberg error diffusion.
+ *
+ * Both steps are lossless as far as the device is concerned - the head prints a dot or no
+ * dot, at its own resolution - while typically shrinking the PNG behind a data URI by an
+ * order of magnitude. On the HTML printers that is the difference between a receipt that
+ * fits into a single IPC transaction and one that is silently dropped.
+ *
+ * Images at or below [headWidthPx] keep their size: upscaling adds no printable detail and
+ * would only grow the payload.
+ */
+fun ImageBitmap.normalizeForPrintHead(headWidthPx: Int): ImageBitmap {
+ if (width <= 0 || height <= 0 || headWidthPx <= 0) return this
+
+ val pixels = IntArray(width * height)
+ readPixels(pixels)
+
+ val normalized = MonochromeImage.normalize(
+ pixels = pixels,
+ sourceWidth = width,
+ sourceHeight = height,
+ headWidthPx = headWidthPx,
+ )
+ return argbToImageBitmap(normalized.pixels, normalized.width, normalized.height)
+}
diff --git a/print-engine/src/commonMain/kotlin/de/tillhub/printengine/data/MonochromeImage.kt b/print-engine/src/commonMain/kotlin/de/tillhub/printengine/data/MonochromeImage.kt
new file mode 100644
index 0000000..1be4503
--- /dev/null
+++ b/print-engine/src/commonMain/kotlin/de/tillhub/printengine/data/MonochromeImage.kt
@@ -0,0 +1,193 @@
+package de.tillhub.printengine.data
+
+/**
+ * Pixel level preparation of images for a monochrome thermal print head.
+ *
+ * Deliberately free of any platform bitmap type: the whole algorithm is shared and unit
+ * tested in commonTest, and only the final "pixels to platform bitmap" step needs an
+ * expect/actual (see [argbToImageBitmap]).
+ */
+internal object MonochromeImage {
+ /**
+ * The result of [normalize]: opaque ARGB pixels that are already pure black or white,
+ * together with the size they describe.
+ */
+ class Pixels(
+ val pixels: IntArray,
+ val width: Int,
+ val height: Int,
+ )
+
+ /**
+ * Downscales the [sourceWidth] x [sourceHeight] ARGB [pixels] so their width fits
+ * [headWidthPx], then reduces them to pure black and white.
+ *
+ * Images at or below [headWidthPx] keep their size: a print head cannot render detail
+ * beyond its own width, so upscaling would inflate the payload without adding anything
+ * printable.
+ */
+ fun normalize(
+ pixels: IntArray,
+ sourceWidth: Int,
+ sourceHeight: Int,
+ headWidthPx: Int,
+ ): Pixels {
+ val targetWidth = targetWidth(sourceWidth, headWidthPx)
+ val targetHeight = targetHeight(sourceWidth, sourceHeight, targetWidth)
+
+ val gray = toGrayscale(pixels)
+ val scaled = if (targetWidth == sourceWidth && targetHeight == sourceHeight) {
+ gray
+ } else {
+ downscale(gray, sourceWidth, sourceHeight, targetWidth, targetHeight)
+ }
+
+ return Pixels(
+ pixels = toOpaqueArgb(dither(scaled, targetWidth, targetHeight)),
+ width = targetWidth,
+ height = targetHeight,
+ )
+ }
+
+ /** Never wider than the head, never upscaled. */
+ private fun targetWidth(
+ sourceWidth: Int,
+ headWidthPx: Int,
+ ): Int = minOf(sourceWidth, headWidthPx)
+
+ /** Height that preserves the aspect ratio of [sourceWidth] x [sourceHeight] at [targetWidth]. */
+ private fun targetHeight(
+ sourceWidth: Int,
+ sourceHeight: Int,
+ targetWidth: Int,
+ ): Int = when {
+ targetWidth == sourceWidth -> sourceHeight
+ else -> maxOf(1, (sourceHeight.toLong() * targetWidth / sourceWidth).toInt())
+ }
+
+ /**
+ * Converts ARGB pixels to 0..255 luminance, compositing over white first so that
+ * transparent regions come out as unprinted paper rather than as black.
+ */
+ private fun toGrayscale(pixels: IntArray): IntArray = IntArray(pixels.size) { index ->
+ val pixel = pixels[index]
+ val alpha = (pixel ushr ALPHA_SHIFT) and CHANNEL_MASK
+ val red = (pixel ushr RED_SHIFT) and CHANNEL_MASK
+ val green = (pixel ushr GREEN_SHIFT) and CHANNEL_MASK
+ val blue = pixel and CHANNEL_MASK
+
+ val luminance = (RED_WEIGHT * red + GREEN_WEIGHT * green + BLUE_WEIGHT * blue) shr WEIGHT_SHIFT
+ when (alpha) {
+ CHANNEL_MASK -> luminance
+ else -> (luminance * alpha + CHANNEL_MASK * (CHANNEL_MASK - alpha)) / CHANNEL_MASK
+ }
+ }
+
+ /**
+ * Box filter downscale of a luminance plane: every target pixel is the average of the
+ * source pixels it covers. Averaging rather than sampling matters here, because dropping
+ * pixels from a barcode-like pattern loses strokes that averaging preserves as grey - and
+ * grey is what [dither] turns back into detail.
+ */
+ @Suppress("NestedBlockDepth")
+ private fun downscale(
+ gray: IntArray,
+ sourceWidth: Int,
+ sourceHeight: Int,
+ targetWidth: Int,
+ targetHeight: Int,
+ ): IntArray {
+ val scaled = IntArray(targetWidth * targetHeight)
+ for (targetY in 0 until targetHeight) {
+ val startY = targetY * sourceHeight / targetHeight
+ val endY = maxOf(startY + 1, (targetY + 1) * sourceHeight / targetHeight)
+ for (targetX in 0 until targetWidth) {
+ val startX = targetX * sourceWidth / targetWidth
+ val endX = maxOf(startX + 1, (targetX + 1) * sourceWidth / targetWidth)
+
+ var sum = 0
+ var count = 0
+ for (sourceY in startY until endY) {
+ val rowOffset = sourceY * sourceWidth
+ for (sourceX in startX until endX) {
+ sum += gray[rowOffset + sourceX]
+ count++
+ }
+ }
+ scaled[targetY * targetWidth + targetX] = sum / count
+ }
+ }
+ return scaled
+ }
+
+ /**
+ * Floyd-Steinberg error diffusion: each pixel is pushed to black or white and the
+ * rounding error is spread over the neighbours not yet visited. That is what keeps
+ * photographic gradients readable on a head that can only print "dot" or "no dot".
+ *
+ * [gray] is consumed as the error accumulator, so the caller must not reuse it.
+ */
+ private fun dither(
+ gray: IntArray,
+ width: Int,
+ height: Int,
+ ): IntArray {
+ val output = IntArray(gray.size)
+ for (y in 0 until height) {
+ val rowOffset = y * width
+ for (x in 0 until width) {
+ val index = rowOffset + x
+ val value = gray[index]
+ val quantized = if (value < THRESHOLD) BLACK else WHITE
+ output[index] = quantized
+
+ val error = value - quantized
+ if (error == 0) continue
+
+ if (x + 1 < width) {
+ gray[index + 1] += error * RIGHT_WEIGHT / ERROR_DIVISOR
+ }
+ if (y + 1 < height) {
+ val belowOffset = index + width
+ if (x > 0) {
+ gray[belowOffset - 1] += error * BELOW_LEFT_WEIGHT / ERROR_DIVISOR
+ }
+ gray[belowOffset] += error * BELOW_WEIGHT / ERROR_DIVISOR
+ if (x + 1 < width) {
+ gray[belowOffset + 1] += error * BELOW_RIGHT_WEIGHT / ERROR_DIVISOR
+ }
+ }
+ }
+ }
+ return output
+ }
+
+ /** Expands a luminance plane back into opaque ARGB pixels. */
+ private fun toOpaqueArgb(gray: IntArray): IntArray = IntArray(gray.size) { index ->
+ val value = gray[index].coerceIn(BLACK, WHITE)
+ OPAQUE_ALPHA or (value shl RED_SHIFT) or (value shl GREEN_SHIFT) or value
+ }
+
+ private const val ALPHA_SHIFT = 24
+ private const val RED_SHIFT = 16
+ private const val GREEN_SHIFT = 8
+ private const val CHANNEL_MASK = 0xFF
+ private const val OPAQUE_ALPHA = 0xFF shl ALPHA_SHIFT
+
+ // Integer approximation of the Rec. 601 luma coefficients (0.299, 0.587, 0.114).
+ private const val RED_WEIGHT = 77
+ private const val GREEN_WEIGHT = 151
+ private const val BLUE_WEIGHT = 28
+ private const val WEIGHT_SHIFT = 8
+
+ private const val BLACK = 0
+ private const val WHITE = 255
+ private const val THRESHOLD = 128
+
+ // Floyd-Steinberg diffusion kernel.
+ private const val RIGHT_WEIGHT = 7
+ private const val BELOW_LEFT_WEIGHT = 3
+ private const val BELOW_WEIGHT = 5
+ private const val BELOW_RIGHT_WEIGHT = 1
+ private const val ERROR_DIVISOR = 16
+}
diff --git a/print-engine/src/commonMain/kotlin/de/tillhub/printengine/data/PrinterInfo.kt b/print-engine/src/commonMain/kotlin/de/tillhub/printengine/data/PrinterInfo.kt
index e596383..adad7f8 100644
--- a/print-engine/src/commonMain/kotlin/de/tillhub/printengine/data/PrinterInfo.kt
+++ b/print-engine/src/commonMain/kotlin/de/tillhub/printengine/data/PrinterInfo.kt
@@ -50,33 +50,56 @@ class PrinterInfo(
/**
* Defines the width of the printing paper.
+ *
+ * [characterCount] describes the paper in text terms, [printHeadWidthPx] in dots. Both are
+ * needed: text is laid out per character, while images have to be scaled to what the head can
+ * physically resolve.
*/
sealed class PrintingPaperSpec(
open val characterCount: Int,
+ open val printHeadWidthPx: Int,
) {
- data object PaxPaper56mm : PrintingPaperSpec(PAX_PAPER_56MM_CHAR_COUNT)
+ data object PaxPaper56mm : PrintingPaperSpec(
+ characterCount = PAX_PAPER_56MM_CHAR_COUNT,
+ printHeadWidthPx = PAPER_56MM_HEAD_WIDTH_PX,
+ )
- data object SunmiPaper56mm : PrintingPaperSpec(SUNMI_PAPER_56MM_CHAR_COUNT)
+ data object SunmiPaper56mm : PrintingPaperSpec(
+ characterCount = SUNMI_PAPER_56MM_CHAR_COUNT,
+ printHeadWidthPx = PAPER_56MM_HEAD_WIDTH_PX,
+ )
- data object VerifonePaper56mm : PrintingPaperSpec(VERIFONE_PAPER_56MM_CHAR_COUNT)
+ data object VerifonePaper56mm : PrintingPaperSpec(
+ characterCount = VERIFONE_PAPER_56MM_CHAR_COUNT,
+ printHeadWidthPx = PAPER_56MM_HEAD_WIDTH_PX,
+ )
class External(
override val characterCount: Int,
- ) : PrintingPaperSpec(characterCount) {
+ override val printHeadWidthPx: Int = EXTERNAL_HEAD_WIDTH_PX,
+ ) : PrintingPaperSpec(characterCount, printHeadWidthPx) {
override fun toString() = "PrintingPaperSpec.External(" +
- "characterCount=$characterCount" +
+ "characterCount=$characterCount, " +
+ "printHeadWidthPx=$printHeadWidthPx" +
")"
override fun equals(other: Any?) = other is External &&
- characterCount == other.characterCount
+ characterCount == other.characterCount &&
+ printHeadWidthPx == other.printHeadWidthPx
- override fun hashCode() = HashHelper.hash(characterCount)
+ override fun hashCode() = HashHelper.hash(characterCount, printHeadWidthPx)
}
companion object {
const val PAX_PAPER_56MM_CHAR_COUNT = 35
const val SUNMI_PAPER_56MM_CHAR_COUNT = 38
const val VERIFONE_PAPER_56MM_CHAR_COUNT = 32
+
+ /** 56mm of printable width at the 203 dpi every built-in head in this library runs at. */
+ const val PAPER_56MM_HEAD_WIDTH_PX = 384
+
+ /** 80mm at 203 dpi, the common width of the external receipt printers we support. */
+ const val EXTERNAL_HEAD_WIDTH_PX = 576
}
}
diff --git a/print-engine/src/commonMain/kotlin/de/tillhub/printengine/html/HtmlPrinterController.kt b/print-engine/src/commonMain/kotlin/de/tillhub/printengine/html/HtmlPrinterController.kt
index 81962bc..2786a92 100644
--- a/print-engine/src/commonMain/kotlin/de/tillhub/printengine/html/HtmlPrinterController.kt
+++ b/print-engine/src/commonMain/kotlin/de/tillhub/printengine/html/HtmlPrinterController.kt
@@ -5,7 +5,9 @@ import de.tillhub.printengine.PrinterController
import de.tillhub.printengine.barcode.BarcodeEncoder
import de.tillhub.printengine.barcode.BarcodeType
import de.tillhub.printengine.data.PrinterState
+import de.tillhub.printengine.data.PrintingPaperSpec
import de.tillhub.printengine.data.RawPrinterData
+import de.tillhub.printengine.data.normalizeForPrintHead
import de.tillhub.printengine.html.HtmlUtils.generateImageHtml
import de.tillhub.printengine.html.HtmlUtils.monospaceText
import de.tillhub.printengine.html.HtmlUtils.singleLineCenteredText
@@ -16,6 +18,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
abstract class HtmlPrinterController(
private val printerState: MutableStateFlow,
private val barcodeEncoder: BarcodeEncoder,
+ val paperSpec: PrintingPaperSpec,
val barcodeSize: BarcodeSize,
val qrCodeSize: QrCodeSize,
val fontSize: FontSize,
@@ -39,8 +42,12 @@ abstract class HtmlPrinterController(
batchSB.appendLine(monospaceText(text, fontSize.value))
}
+ /**
+ * Normalised for the print head first: inlined into the receipt HTML at full colour and
+ * resolution, an image is unprintable detail and big enough to overflow the IPC transaction.
+ */
override fun printImage(image: ImageBitmap) {
- batchSB.append(generateImageHtml(image))
+ appendImage(image.normalizeForPrintHead(paperSpec.printHeadWidthPx))
}
override fun printBarcode(barcode: String) {
@@ -51,7 +58,7 @@ abstract class HtmlPrinterController(
imgWidth = barcodeSize.width,
imgHeight = barcodeSize.height,
)?.let { image ->
- printImage(image)
+ appendImage(image)
printText(singleLineCenteredText(barcode))
}
}
@@ -64,7 +71,7 @@ abstract class HtmlPrinterController(
imgWidth = qrCodeSize.value,
imgHeight = qrCodeSize.value,
)?.let { image ->
- printImage(image)
+ appendImage(image)
printText(singleLineCenteredText(qrData))
}
}
@@ -74,9 +81,20 @@ abstract class HtmlPrinterController(
}
override fun start() {
- if (batchSB.isNotEmpty()) {
- printContent(transformToHtml(batchSB.toString(), includeStyleTag))
- batchSB.clear()
- }
+ if (batchSB.isEmpty()) return
+
+ val content = transformToHtml(batchSB.toString(), includeStyleTag)
+ // Cleared before the content is handed over, so that a receipt the transport rejects
+ // cannot leak into the next print job.
+ batchSB.clear()
+
+ printContent(content)
+ }
+
+ /**
+ * Appends an image that is already fit for the print head, skipping normalisation.
+ */
+ private fun appendImage(image: ImageBitmap) {
+ batchSB.append(generateImageHtml(image))
}
}
diff --git a/print-engine/src/commonTest/kotlin/de/tillhub/printengine/data/MonochromeImageTest.kt b/print-engine/src/commonTest/kotlin/de/tillhub/printengine/data/MonochromeImageTest.kt
new file mode 100644
index 0000000..2359a7e
--- /dev/null
+++ b/print-engine/src/commonTest/kotlin/de/tillhub/printengine/data/MonochromeImageTest.kt
@@ -0,0 +1,145 @@
+package de.tillhub.printengine.data
+
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertTrue
+
+/**
+ * Exercises [MonochromeImage.normalize], the entry point the print path uses. The scaling and
+ * dithering steps are covered through it rather than individually, so the tests describe what a
+ * caller can rely on instead of how the pixels get there.
+ */
+internal class MonochromeImageTest {
+ @Test
+ fun `downscales to the head width and preserves the aspect ratio`() {
+ val normalized = MonochromeImage.normalize(
+ pixels = gradient(width = 800, height = 400),
+ sourceWidth = 800,
+ sourceHeight = 400,
+ headWidthPx = HEAD_WIDTH_PX,
+ )
+
+ assertEquals(HEAD_WIDTH_PX, normalized.width)
+ assertEquals(192, normalized.height)
+ assertEquals(HEAD_WIDTH_PX * 192, normalized.pixels.size)
+ }
+
+ @Test
+ fun `does not upscale an image that already fits`() {
+ val normalized = MonochromeImage.normalize(
+ pixels = gradient(width = 120, height = 60),
+ sourceWidth = 120,
+ sourceHeight = 60,
+ headWidthPx = HEAD_WIDTH_PX,
+ )
+
+ assertEquals(120, normalized.width)
+ assertEquals(60, normalized.height)
+ }
+
+ @Test
+ fun `never collapses a wide flat image to no height at all`() {
+ val normalized = MonochromeImage.normalize(
+ pixels = gradient(width = 1000, height = 2),
+ sourceWidth = 1000,
+ sourceHeight = 2,
+ headWidthPx = 10,
+ )
+
+ assertEquals(10, normalized.width)
+ assertEquals(1, normalized.height)
+ }
+
+ @Test
+ fun `emits only opaque black or white pixels`() {
+ val normalized = MonochromeImage.normalize(
+ pixels = gradient(width = 200, height = 100),
+ sourceWidth = 200,
+ sourceHeight = 100,
+ headWidthPx = HEAD_WIDTH_PX,
+ )
+
+ assertTrue(
+ normalized.pixels.all { it == BLACK || it == WHITE },
+ "normalised output must be 1 bit monochrome",
+ )
+ }
+
+ @Test
+ fun `prints a transparent pixel as unprinted paper`() {
+ // Transparent is black in ARGB terms; on paper it has to come out white.
+ val normalized = normalizeSinglePixel(TRANSPARENT)
+
+ assertEquals(WHITE, normalized)
+ }
+
+ @Test
+ fun `weights the colour channels by luminance`() {
+ // Green carries most of the perceived brightness, blue almost none, so at full saturation
+ // green lands on the white side of the threshold while red and blue land on the black side.
+ assertEquals(WHITE, normalizeSinglePixel(GREEN))
+ assertEquals(BLACK, normalizeSinglePixel(RED))
+ assertEquals(BLACK, normalizeSinglePixel(BLUE))
+ }
+
+ @Test
+ fun `dithers flat mid grey into a mix of black and white`() {
+ val midGrey = IntArray(SIZE * SIZE) { MID_GREY }
+
+ val normalized = MonochromeImage.normalize(
+ pixels = midGrey,
+ sourceWidth = SIZE,
+ sourceHeight = SIZE,
+ headWidthPx = HEAD_WIDTH_PX,
+ )
+
+ assertTrue(normalized.pixels.any { it == BLACK }, "expected some black dots")
+ assertTrue(normalized.pixels.any { it == WHITE }, "expected some white dots")
+ }
+
+ @Test
+ fun `averages the pixels it merges instead of sampling them`() {
+ // One dark pixel among three white ones, halved in width. Sampling every other pixel would
+ // read white twice and lose the dark one outright; averaging carries it into the merged
+ // pixel, which then dithers to black.
+ val normalized = MonochromeImage.normalize(
+ pixels = intArrayOf(WHITE, BLACK, WHITE, WHITE),
+ sourceWidth = 4,
+ sourceHeight = 1,
+ headWidthPx = 2,
+ )
+
+ assertEquals(listOf(BLACK, WHITE), normalized.pixels.toList())
+ }
+
+ private fun normalizeSinglePixel(pixel: Int): Int = MonochromeImage
+ .normalize(
+ pixels = intArrayOf(pixel),
+ sourceWidth = 1,
+ sourceHeight = 1,
+ headWidthPx = HEAD_WIDTH_PX,
+ ).pixels
+ .single()
+
+ /** A horizontal grey ramp, the shape that shows whether tone survives normalisation. */
+ private fun gradient(
+ width: Int,
+ height: Int,
+ ): IntArray = IntArray(width * height) { index ->
+ val value = (index % width) * 255 / (width - 1)
+ OPAQUE_ALPHA or (value shl 16) or (value shl 8) or value
+ }
+
+ private companion object {
+ const val HEAD_WIDTH_PX = 384
+ const val SIZE = 8
+ const val OPAQUE_ALPHA = 0xFF shl 24
+ const val BLACK = 0xFF000000.toInt()
+ const val WHITE = 0xFFFFFFFF.toInt()
+ const val MID_GREY = 0xFF7F7F7F.toInt()
+ const val TRANSPARENT = 0x00000000
+ const val RED = 0xFFFF0000.toInt()
+ const val GREEN = 0xFF00FF00.toInt()
+ const val BLUE = 0xFF0000FF.toInt()
+ }
+}
diff --git a/print-engine/src/commonTest/kotlin/de/tillhub/printengine/html/HtmlPrinterControllerTest.kt b/print-engine/src/commonTest/kotlin/de/tillhub/printengine/html/HtmlPrinterControllerTest.kt
new file mode 100644
index 0000000..e501876
--- /dev/null
+++ b/print-engine/src/commonTest/kotlin/de/tillhub/printengine/html/HtmlPrinterControllerTest.kt
@@ -0,0 +1,130 @@
+package de.tillhub.printengine.html
+
+import androidx.compose.ui.graphics.ImageBitmap
+import de.tillhub.printengine.PrintService
+import de.tillhub.printengine.PrinterController
+import de.tillhub.printengine.barcode.BarcodeEncoder
+import de.tillhub.printengine.barcode.BarcodeType
+import de.tillhub.printengine.data.PrinterInfo
+import de.tillhub.printengine.data.PrinterResult
+import de.tillhub.printengine.data.PrinterServiceVersion
+import de.tillhub.printengine.data.PrinterState
+import de.tillhub.printengine.data.PrintingFontType
+import de.tillhub.printengine.data.PrintingIntensity
+import de.tillhub.printengine.data.PrintingPaperSpec
+import de.tillhub.printengine.withPrinterCatching
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlin.test.Test
+import kotlin.test.assertNull
+import kotlin.test.assertTrue
+
+internal class HtmlPrinterControllerTest {
+ @Test
+ fun `start sends the assembled receipt`() {
+ val controller = TestHtmlPrinterController()
+
+ controller.printText("receipt")
+ controller.start()
+
+ assertTrue(controller.printedContent.orEmpty().contains("receipt"))
+ }
+
+ @Test
+ fun `start sends nothing when there is nothing batched`() {
+ val controller = TestHtmlPrinterController()
+
+ controller.start()
+
+ assertNull(controller.printedContent)
+ }
+
+ @Test
+ fun `a receipt the transport rejects does not leak into the next print job`() {
+ val controller = TestHtmlPrinterController(failOnPrint = true)
+
+ controller.printText("first")
+ runCatching { controller.start() }
+ controller.failOnPrint = false
+
+ controller.printText("second")
+ controller.start()
+
+ val printed = controller.printedContent.orEmpty()
+ assertTrue(printed.contains("second"))
+ assertTrue(!printed.contains("first"), "stale batch was resent")
+ }
+
+ @Test
+ fun `a transport failure is reported as an error rather than as success`() {
+ val controller = TestHtmlPrinterController(failOnPrint = true)
+ val service = TestPrintService(controller)
+
+ controller.printText("receipt")
+ val result = service.withPrinterCatching { it.start() }
+
+ assertTrue(
+ result is PrinterResult.Error.WithException,
+ "expected a failed transport to surface as an error but was $result",
+ )
+ }
+
+ private class TestPrintService(
+ controller: PrinterController,
+ ) : PrintService() {
+ override var printController: PrinterController? = controller
+ override val printerState: Flow = MutableStateFlow(PrinterState.Connected)
+ }
+
+ private class TestHtmlPrinterController(
+ var failOnPrint: Boolean = false,
+ ) : HtmlPrinterController(
+ printerState = MutableStateFlow(PrinterState.Connected),
+ barcodeEncoder = NoBarcodeEncoder,
+ paperSpec = PrintingPaperSpec.PaxPaper56mm,
+ barcodeSize = BarcodeSize(height = 140, width = 435),
+ qrCodeSize = QrCodeSize(220),
+ fontSize = FontSize(13),
+ includeStyleTag = false,
+ feedString = FeedString("
"),
+ ) {
+ var printedContent: String? = null
+ private set
+
+ override fun printContent(
+ content: String,
+ cutAfterPrint: Boolean,
+ ) {
+ if (failOnPrint) throw IllegalStateException("transport failed")
+ printedContent = content
+ }
+
+ override fun setFontSize(fontSize: PrintingFontType) = Unit
+
+ override fun setIntensity(intensity: PrintingIntensity) = Unit
+
+ override fun printImage(image: ImageBitmap) = Unit
+
+ override fun cutPaper() = Unit
+
+ override suspend fun getPrinterInfo(): PrinterInfo = PrinterInfo(
+ serialNumber = "n/a",
+ deviceModel = "test",
+ printerVersion = "n/a",
+ printerPaperSpec = PrintingPaperSpec.PaxPaper56mm,
+ printingFontType = PrintingFontType.DEFAULT_FONT_SIZE,
+ printerHead = "n/a",
+ printedDistance = 0,
+ serviceVersion = PrinterServiceVersion.Unknown,
+ )
+ }
+
+ private object NoBarcodeEncoder : BarcodeEncoder {
+ override fun encodeAsBitmap(
+ content: String,
+ type: BarcodeType,
+ imgWidth: Int,
+ imgHeight: Int,
+ ): ImageBitmap? = null
+ }
+}
diff --git a/print-engine/src/iosMain/kotlin/de/tillhub/printengine/data/ImageBitmap.ios.kt b/print-engine/src/iosMain/kotlin/de/tillhub/printengine/data/ImageBitmap.ios.kt
index c8527ee..1162601 100644
--- a/print-engine/src/iosMain/kotlin/de/tillhub/printengine/data/ImageBitmap.ios.kt
+++ b/print-engine/src/iosMain/kotlin/de/tillhub/printengine/data/ImageBitmap.ios.kt
@@ -1,6 +1,7 @@
package de.tillhub.printengine.data
import androidx.compose.ui.graphics.ImageBitmap
+import androidx.compose.ui.graphics.toComposeImageBitmap
import kotlinx.cinterop.ExperimentalForeignApi
import kotlinx.cinterop.addressOf
import kotlinx.cinterop.usePinned
@@ -13,32 +14,42 @@ import org.jetbrains.skia.Bitmap as SkiaBitmap
import org.jetbrains.skia.Image as SkiaImage
/**
- * Encodes a Compose [ImageBitmap] to PNG bytes via Skia.
- * Handles pixel extraction, Skia bitmap/image lifecycle, and cleanup.
- * Returns null if PNG encoding fails.
+ * Wraps ARGB [pixels] in a Skia bitmap.
+ *
+ * On little-endian (all Apple platforms) the raw bytes of an ARGB int are already in BGRA
+ * order, which is what Skia's N32 native format expects - so the pixels can be memcpy'd
+ * across without any per-channel shuffling.
*/
@OptIn(ExperimentalForeignApi::class)
-fun ImageBitmap.encodeToPngBytes(): ByteArray? {
- val w = width
- val h = height
- val buffer = IntArray(w * h)
- readPixels(buffer)
-
- // On little-endian (all Apple platforms), raw int bytes are already in
- // BGRA order which matches Skia's N32 native format — memcpy directly.
- val bytes = ByteArray(buffer.size * 4)
- buffer.usePinned { src ->
+private fun skiaBitmapOf(
+ pixels: IntArray,
+ width: Int,
+ height: Int,
+): SkiaBitmap {
+ val bytes = ByteArray(pixels.size * BYTES_PER_PIXEL)
+ pixels.usePinned { src ->
bytes.usePinned { dst ->
memcpy(dst.addressOf(0), src.addressOf(0), bytes.size.toULong())
}
}
- val imageInfo = ImageInfo.makeN32Premul(w, h)
- val skiaBitmap = SkiaBitmap().apply {
+ val imageInfo = ImageInfo.makeN32Premul(width, height)
+ return SkiaBitmap().apply {
allocPixels(imageInfo)
- installPixels(imageInfo, bytes, w * 4)
+ installPixels(imageInfo, bytes, width * BYTES_PER_PIXEL)
}
+}
+/**
+ * Encodes a Compose [ImageBitmap] to PNG bytes via Skia.
+ * Handles pixel extraction, Skia bitmap/image lifecycle, and cleanup.
+ * Returns null if PNG encoding fails.
+ */
+fun ImageBitmap.encodeToPngBytes(): ByteArray? {
+ val buffer = IntArray(width * height)
+ readPixels(buffer)
+
+ val skiaBitmap = skiaBitmapOf(buffer, width, height)
return try {
val skiaImage = SkiaImage.makeFromBitmap(skiaBitmap)
try {
@@ -57,3 +68,23 @@ actual fun ImageBitmap.encodeToBase64(): String {
?: error("Failed to encode image to PNG")
return Base64.encode(pngBytes)
}
+
+internal actual fun argbToImageBitmap(
+ pixels: IntArray,
+ width: Int,
+ height: Int,
+): ImageBitmap {
+ val skiaBitmap = skiaBitmapOf(pixels, width, height)
+ return try {
+ val skiaImage = SkiaImage.makeFromBitmap(skiaBitmap)
+ try {
+ skiaImage.toComposeImageBitmap()
+ } finally {
+ skiaImage.close()
+ }
+ } finally {
+ skiaBitmap.close()
+ }
+}
+
+private const val BYTES_PER_PIXEL = 4
diff --git a/print-engine/src/iosTest/kotlin/de/tillhub/printengine/data/ImageBitmapExtTest.kt b/print-engine/src/iosTest/kotlin/de/tillhub/printengine/data/ImageBitmapExtTest.kt
new file mode 100644
index 0000000..cf0b3cb
--- /dev/null
+++ b/print-engine/src/iosTest/kotlin/de/tillhub/printengine/data/ImageBitmapExtTest.kt
@@ -0,0 +1,132 @@
+package de.tillhub.printengine.data
+
+import androidx.compose.ui.graphics.ImageBitmap
+import androidx.compose.ui.graphics.toComposeImageBitmap
+import org.jetbrains.skia.ImageInfo
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertTrue
+import org.jetbrains.skia.Bitmap as SkiaBitmap
+import org.jetbrains.skia.Image as SkiaImage
+
+/**
+ * Covers the Skia backed half of [normalizeForPrintHead]: the shared algorithm is tested in
+ * [MonochromeImageTest], this checks that reading pixels out of a Compose [ImageBitmap] and
+ * building one back up works on iOS.
+ */
+internal class ImageBitmapExtTest {
+ @Test
+ fun `normalizeForPrintHead downscales an oversized image to the head width`() {
+ val normalized = gradientImage(width = 800, height = 400)
+ .normalizeForPrintHead(HEAD_WIDTH_PX)
+
+ assertEquals(HEAD_WIDTH_PX, normalized.width)
+ assertEquals(192, normalized.height)
+ }
+
+ @Test
+ fun `normalizeForPrintHead does not upscale an image that already fits`() {
+ val normalized = gradientImage(width = 120, height = 60)
+ .normalizeForPrintHead(HEAD_WIDTH_PX)
+
+ assertEquals(120, normalized.width)
+ assertEquals(60, normalized.height)
+ }
+
+ @Test
+ fun `normalizeForPrintHead reduces the image to one bit monochrome`() {
+ val normalized = gradientImage(width = 200, height = 100)
+ .normalizeForPrintHead(HEAD_WIDTH_PX)
+
+ val pixels = IntArray(normalized.width * normalized.height)
+ normalized.readPixels(pixels)
+
+ assertTrue(
+ pixels.all { it == BLACK || it == WHITE },
+ "normalized output must be 1 bit monochrome",
+ )
+ }
+
+ @Test
+ fun `normalizeForPrintHead shrinks the encoded payload`() {
+ // Photographic detail is what PNG cannot compress away, so that is what is measured
+ // here - a flat logo or a smooth ramp would understate the difference.
+ val image = photoLikeImage(width = 800, height = 400)
+
+ val normalizedSize = image.normalizeForPrintHead(HEAD_WIDTH_PX).encodeToBase64().length
+
+ assertTrue(
+ normalizedSize < image.encodeToBase64().length,
+ "normalising should shrink the payload",
+ )
+ }
+
+ /** A horizontal grey ramp: enough tone that dithering has something to do. */
+ private fun gradientImage(
+ width: Int,
+ height: Int,
+ ): ImageBitmap = imageOf(width, height) { index ->
+ val value = ((index % width) * 255 / (width - 1)).toByte()
+ // Neutral grey, so all three colour channels carry the same value.
+ byteArrayOf(value, value, value, OPAQUE_ALPHA)
+ }
+
+ /**
+ * Deterministic per-pixel colour noise, standing in for a photo: like a photograph and
+ * unlike a gradient, there is no redundancy for PNG to squeeze out.
+ */
+ private fun photoLikeImage(
+ width: Int,
+ height: Int,
+ ): ImageBitmap {
+ var state = 1
+ return imageOf(width, height) {
+ state = state * LCG_MULTIPLIER + LCG_INCREMENT
+ byteArrayOf(
+ (state ushr 8).toByte(),
+ (state ushr 16).toByte(),
+ (state ushr 24).toByte(),
+ OPAQUE_ALPHA,
+ )
+ }
+ }
+
+ /** Builds an [ImageBitmap] straight through Skia, independent of production code. */
+ private fun imageOf(
+ width: Int,
+ height: Int,
+ // N32 on little-endian Apple platforms is BGRA.
+ bgraPixel: (index: Int) -> ByteArray,
+ ): ImageBitmap {
+ val bytes = ByteArray(width * height * BYTES_PER_PIXEL)
+ for (index in 0 until width * height) {
+ bgraPixel(index).copyInto(bytes, index * BYTES_PER_PIXEL)
+ }
+
+ val imageInfo = ImageInfo.makeN32Premul(width, height)
+ val skiaBitmap = SkiaBitmap().apply {
+ allocPixels(imageInfo)
+ installPixels(imageInfo, bytes, width * BYTES_PER_PIXEL)
+ }
+ return try {
+ val skiaImage = SkiaImage.makeFromBitmap(skiaBitmap)
+ try {
+ skiaImage.toComposeImageBitmap()
+ } finally {
+ skiaImage.close()
+ }
+ } finally {
+ skiaBitmap.close()
+ }
+ }
+
+ private companion object {
+ const val HEAD_WIDTH_PX = 384
+ const val LCG_MULTIPLIER = 1664525
+ const val LCG_INCREMENT = 1013904223
+ const val BYTES_PER_PIXEL = 4
+ const val OPAQUE_ALPHA = 0xFF.toByte()
+ const val BLACK = 0xFF000000.toInt()
+ const val WHITE = 0xFFFFFFFF.toInt()
+ }
+}
diff --git a/print-plugins/pax/src/main/java/de/tillhub/printengine/pax/DirectPrintService.kt b/print-plugins/pax/src/main/java/de/tillhub/printengine/pax/DirectPrintService.kt
index 379bf6a..2d1160b 100644
--- a/print-plugins/pax/src/main/java/de/tillhub/printengine/pax/DirectPrintService.kt
+++ b/print-plugins/pax/src/main/java/de/tillhub/printengine/pax/DirectPrintService.kt
@@ -33,7 +33,9 @@ internal class DirectPrintServiceImpl(
.apply {
replyTo = Messenger(PrintResponseHandler(listener))
}.also { message ->
- sendMessage(message, listener)
+ // A failed status check only means the state is unknown, so it is reported and
+ // swallowed - this runs from the controller's constructor.
+ sendMessage(message, listener, rethrowOnFailure = false)
}
}
@@ -53,18 +55,28 @@ internal class DirectPrintServiceImpl(
MSG_PRINT_INTENSITY to printingIntensity,
)
}.also { message ->
- sendMessage(message, listener)
+ sendMessage(message, listener, rethrowOnFailure = true)
}
}
+ /**
+ * @throws RemoteException if [rethrowOnFailure] and the transaction fails.
+ *
+ * [DirectPrintListener.onFailed] only moves the printer state; it does not tell the caller of
+ * `startPrintJob` anything. Rethrowing is what turns a failed transaction - including the
+ * `TransactionTooLargeException` an oversized receipt triggers - into a `PrinterResult.Error`
+ * instead of a `Success` for a receipt that was never printed.
+ */
private fun sendMessage(
message: Message,
listener: DirectPrintListener,
+ rethrowOnFailure: Boolean,
) {
try {
requestMessenger.send(message)
} catch (e: RemoteException) {
listener.onFailed(e)
+ if (rethrowOnFailure) throw e
}
}
diff --git a/print-plugins/pax/src/main/java/de/tillhub/printengine/pax/PaxPrinterController.kt b/print-plugins/pax/src/main/java/de/tillhub/printengine/pax/PaxPrinterController.kt
index 4858bff..cbc08b2 100644
--- a/print-plugins/pax/src/main/java/de/tillhub/printengine/pax/PaxPrinterController.kt
+++ b/print-plugins/pax/src/main/java/de/tillhub/printengine/pax/PaxPrinterController.kt
@@ -22,6 +22,7 @@ internal class PaxPrinterController(
) : HtmlPrinterController(
printerState = printerState,
barcodeEncoder = barcodeEncoder,
+ paperSpec = PAX_PAPER_SPEC,
barcodeSize = PAX_BARCODE_SIZE,
qrCodeSize = PAX_QR_CODE_SIZE,
fontSize = PAX_FONT_SIZE,
@@ -49,7 +50,7 @@ internal class PaxPrinterController(
serialNumber = "n/a",
deviceModel = "A920",
printerVersion = "n/a",
- printerPaperSpec = PrintingPaperSpec.PaxPaper56mm,
+ printerPaperSpec = PAX_PAPER_SPEC,
printingFontType = PrintingFontType.DEFAULT_FONT_SIZE,
printerHead = "n/a",
printedDistance = 0,
@@ -89,6 +90,7 @@ internal class PaxPrinterController(
}
companion object {
+ private val PAX_PAPER_SPEC = PrintingPaperSpec.PaxPaper56mm
private const val DEFAULT_INTENSITY = 50
private const val LIGHT_INTENSITY = 25
private const val DARK_INTENSITY = 70
diff --git a/print-plugins/pax/src/test/java/de/tillhub/printengine/pax/DirectPrintServiceImplTest.kt b/print-plugins/pax/src/test/java/de/tillhub/printengine/pax/DirectPrintServiceImplTest.kt
index 8b51c00..f8909f8 100644
--- a/print-plugins/pax/src/test/java/de/tillhub/printengine/pax/DirectPrintServiceImplTest.kt
+++ b/print-plugins/pax/src/test/java/de/tillhub/printengine/pax/DirectPrintServiceImplTest.kt
@@ -4,6 +4,7 @@ import android.os.Message
import android.os.Messenger
import android.os.RemoteException
import de.tillhub.printengine.pax.DirectPrintService.DirectPrintListener
+import io.kotest.assertions.throwables.shouldThrow
import io.kotest.core.spec.style.FunSpec
import io.kotest.extensions.robolectric.RobolectricTest
import io.kotest.matchers.shouldBe
@@ -60,7 +61,25 @@ class DirectPrintServiceImplTest :
val ex = RemoteException()
every { requestMessenger.send(any()) } throws ex
- target.print("payload", 50, listener)
+ // Reported to the listener for the printer state, and rethrown so the caller of
+ // startPrintJob does not get a Success for a receipt that was never sent.
+ val thrown = shouldThrow {
+ target.print("payload", 50, listener)
+ }
+
+ thrown shouldBe ex
+ verify {
+ listener.onFailed(ex)
+ }
+ }
+
+ test("checkStatus + exception") {
+ val ex = RemoteException()
+ every { requestMessenger.send(any()) } throws ex
+
+ // A failed status check only leaves the state unknown; it runs from the controller's
+ // constructor, so it must not throw.
+ target.checkStatus(listener)
verify {
listener.onFailed(ex)
diff --git a/print-plugins/pax/src/test/java/de/tillhub/printengine/pax/PaxPrinterControllerTest.kt b/print-plugins/pax/src/test/java/de/tillhub/printengine/pax/PaxPrinterControllerTest.kt
index a42e14e..019aea3 100644
--- a/print-plugins/pax/src/test/java/de/tillhub/printengine/pax/PaxPrinterControllerTest.kt
+++ b/print-plugins/pax/src/test/java/de/tillhub/printengine/pax/PaxPrinterControllerTest.kt
@@ -1,6 +1,7 @@
package de.tillhub.printengine.pax
import android.graphics.Bitmap
+import android.os.RemoteException
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.asImageBitmap
import de.tillhub.printengine.barcode.BarcodeEncoder
@@ -12,7 +13,9 @@ import de.tillhub.printengine.data.PrintingFontType
import de.tillhub.printengine.data.PrintingIntensity
import de.tillhub.printengine.data.PrintingPaperSpec
import de.tillhub.printengine.data.RawPrinterData
+import de.tillhub.printengine.data.normalizeForPrintHead
import de.tillhub.printengine.html.HtmlUtils
+import io.kotest.assertions.throwables.shouldThrow
import io.kotest.core.spec.style.DescribeSpec
import io.kotest.extensions.robolectric.RobolectricTest
import io.kotest.matchers.shouldBe
@@ -31,6 +34,10 @@ internal class PaxPrinterControllerTest :
lateinit var androidBitmap: Bitmap
lateinit var bitmap: ImageBitmap
+
+ // What printImage inlines: normalised for the print head. Barcodes and QR codes are
+ // generated for this printer already, so they are inlined unchanged.
+ lateinit var normalizedBitmap: ImageBitmap
lateinit var printerState: MutableStateFlow
lateinit var printService: DirectPrintService
lateinit var barcodeEncoder: BarcodeEncoder
@@ -39,6 +46,9 @@ internal class PaxPrinterControllerTest :
beforeSpec {
androidBitmap = Bitmap.createBitmap(200, 200, Bitmap.Config.ARGB_8888)
bitmap = androidBitmap.asImageBitmap()
+ normalizedBitmap = bitmap.normalizeForPrintHead(
+ PrintingPaperSpec.PaxPaper56mm.printHeadWidthPx,
+ )
}
beforeTest {
@@ -165,7 +175,8 @@ internal class PaxPrinterControllerTest :
}
it("printImage") {
- val payload = HtmlUtils.transformToHtml(HtmlUtils.generateImageHtml(bitmap), true)
+ val payload =
+ HtmlUtils.transformToHtml(HtmlUtils.generateImageHtml(normalizedBitmap), true)
target.printImage(bitmap)
target.start()
@@ -196,7 +207,7 @@ internal class PaxPrinterControllerTest :
appendLine(HtmlUtils.monospaceText(HtmlUtils.singleLineCenteredText("barcode"), 13))
append(HtmlUtils.generateImageHtml(bitmap))
appendLine(HtmlUtils.monospaceText(HtmlUtils.singleLineCenteredText("qr_code"), 13))
- append(HtmlUtils.generateImageHtml(bitmap))
+ append(HtmlUtils.generateImageHtml(normalizedBitmap))
appendLine(HtmlUtils.monospaceText("end line", 13))
append("
")
}.toString(),
@@ -215,4 +226,15 @@ internal class PaxPrinterControllerTest :
printService.print(payload, 50, any())
}
}
+
+ it("a transport failure propagates out of start") {
+ val ex = RemoteException()
+ every { printService.print(any(), any(), any()) } throws ex
+
+ target.printText("text_to_print")
+
+ shouldThrow {
+ target.start()
+ }
+ }
})
diff --git a/print-plugins/star/src/androidMain/kotlin/de/tillhub/printengine/star/StarPrinterController.android.kt b/print-plugins/star/src/androidMain/kotlin/de/tillhub/printengine/star/StarPrinterController.android.kt
index c0d38c7..b43dfad 100644
--- a/print-plugins/star/src/androidMain/kotlin/de/tillhub/printengine/star/StarPrinterController.android.kt
+++ b/print-plugins/star/src/androidMain/kotlin/de/tillhub/printengine/star/StarPrinterController.android.kt
@@ -123,7 +123,10 @@ internal actual class StarPrinterController(
serialNumber = "n/a",
deviceModel = starPrinter.information?.model?.name ?: "Unknown",
printerVersion = "n/a",
- printerPaperSpec = PrintingPaperSpec.External(characterCount = 32),
+ printerPaperSpec = PrintingPaperSpec.External(
+ characterCount = 32,
+ printHeadWidthPx = IMAGE_WIDTH,
+ ),
printingFontType = PrintingFontType.DEFAULT_FONT_SIZE,
printerHead = "n/a",
printedDistance = 0,
diff --git a/print-plugins/star/src/androidMain/kotlin/de/tillhub/printengine/star/StarPrinterDiscovery.android.kt b/print-plugins/star/src/androidMain/kotlin/de/tillhub/printengine/star/StarPrinterDiscovery.android.kt
index b5792f4..6faeede 100644
--- a/print-plugins/star/src/androidMain/kotlin/de/tillhub/printengine/star/StarPrinterDiscovery.android.kt
+++ b/print-plugins/star/src/androidMain/kotlin/de/tillhub/printengine/star/StarPrinterDiscovery.android.kt
@@ -23,6 +23,9 @@ actual class StarPrinterDiscovery(
) : PrinterDiscovery {
companion object {
private const val CHARACTER_COUNT = 52
+
+ /** Matches the width [StarPrinterController] renders images at. */
+ private const val IMAGE_WIDTH = 406
private const val MANUFACTURER_STAR = "STAR"
}
@@ -79,7 +82,10 @@ actual class StarPrinterDiscovery(
serialNumber = "n/a",
deviceModel = printer.information?.model?.name ?: "Unknown",
printerVersion = "n/a",
- printerPaperSpec = PrintingPaperSpec.External(characterCount = CHARACTER_COUNT),
+ printerPaperSpec = PrintingPaperSpec.External(
+ characterCount = CHARACTER_COUNT,
+ printHeadWidthPx = IMAGE_WIDTH,
+ ),
printingFontType = PrintingFontType.DEFAULT_FONT_SIZE,
printerHead = "n/a",
printedDistance = 0,
diff --git a/print-plugins/verifone/src/main/java/de/tillhub/printengine/verifone/VerifonePrintController.kt b/print-plugins/verifone/src/main/java/de/tillhub/printengine/verifone/VerifonePrintController.kt
index 8b8ae68..639887c 100644
--- a/print-plugins/verifone/src/main/java/de/tillhub/printengine/verifone/VerifonePrintController.kt
+++ b/print-plugins/verifone/src/main/java/de/tillhub/printengine/verifone/VerifonePrintController.kt
@@ -1,5 +1,6 @@
package de.tillhub.printengine.verifone
+import android.os.RemoteException
import com.verifone.peripherals.DirectPrintManager
import com.verifone.peripherals.Printer
import de.tillhub.printengine.barcode.BarcodeEncoder
@@ -23,6 +24,7 @@ internal class VerifonePrintController(
) : HtmlPrinterController(
printerState = printerState,
barcodeEncoder = barcodeEncoder,
+ paperSpec = VERIFONE_PAPER_SPEC,
barcodeSize = VERIFONE_BARCODE_SIZE,
qrCodeSize = VERIFONE_QR_CODE_SIZE,
fontSize = VERIFONE_FONT_SIZE,
@@ -67,20 +69,34 @@ internal class VerifonePrintController(
}
}
+ /**
+ * @throws RemoteException if handing the receipt to the print service fails.
+ *
+ * [printListener] only reports failures the service itself reports back, and only by moving
+ * the printer state - it never sees a transaction that failed on the way out, and the caller
+ * of `startPrintJob` would get a `Success` for a receipt that was never printed. So a
+ * transport failure - `TransactionTooLargeException` among them - is reflected in the state
+ * and rethrown for `withPrinterCatching` to turn into a `PrinterResult.Error`.
+ */
override fun printContent(
content: String,
cutAfterPrint: Boolean,
) {
- printManager.printString(
- printListener,
- content,
- when {
- cutAfterPrint || useCutter -> Printer.PRINTER_FULL_CUT
- else -> Printer.PRINTER_NO_CUTTER_LINE_FEED
- },
- )
-
- useCutter = false
+ try {
+ printManager.printString(
+ printListener,
+ content,
+ when {
+ cutAfterPrint || useCutter -> Printer.PRINTER_FULL_CUT
+ else -> Printer.PRINTER_NO_CUTTER_LINE_FEED
+ },
+ )
+ } catch (e: RemoteException) {
+ printerState.value = PrinterState.Error.ConnectionLost
+ throw e
+ } finally {
+ useCutter = false
+ }
}
override fun setFontSize(fontSize: PrintingFontType) = Unit // Not supported
@@ -95,7 +111,7 @@ internal class VerifonePrintController(
serialNumber = "n/a",
deviceModel = "Verifone T630c",
printerVersion = "n/a",
- printerPaperSpec = PrintingPaperSpec.VerifonePaper56mm,
+ printerPaperSpec = VERIFONE_PAPER_SPEC,
printingFontType = PrintingFontType.DEFAULT_FONT_SIZE,
printerHead = "n/a",
printedDistance = 0,
@@ -103,6 +119,7 @@ internal class VerifonePrintController(
)
companion object {
+ private val VERIFONE_PAPER_SPEC = PrintingPaperSpec.VerifonePaper56mm
private val VERIFONE_FEED_STRING = FeedString("
")
private val VERIFONE_FONT_SIZE = FontSize(20)
private val VERIFONE_QR_CODE_SIZE = QrCodeSize(420)
diff --git a/print-plugins/verifone/src/test/java/de/tillhub/printengine/verifone/VerifonePrintControllerTest.kt b/print-plugins/verifone/src/test/java/de/tillhub/printengine/verifone/VerifonePrintControllerTest.kt
index c9906f4..86d5159 100644
--- a/print-plugins/verifone/src/test/java/de/tillhub/printengine/verifone/VerifonePrintControllerTest.kt
+++ b/print-plugins/verifone/src/test/java/de/tillhub/printengine/verifone/VerifonePrintControllerTest.kt
@@ -1,6 +1,7 @@
package de.tillhub.printengine.verifone
import android.graphics.Bitmap
+import android.os.RemoteException
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.asImageBitmap
import com.verifone.peripherals.DirectPrintManager
@@ -14,7 +15,9 @@ import de.tillhub.printengine.data.PrinterState
import de.tillhub.printengine.data.PrintingFontType
import de.tillhub.printengine.data.PrintingPaperSpec
import de.tillhub.printengine.data.RawPrinterData
+import de.tillhub.printengine.data.normalizeForPrintHead
import de.tillhub.printengine.html.HtmlUtils
+import io.kotest.assertions.throwables.shouldThrow
import io.kotest.core.spec.style.DescribeSpec
import io.kotest.extensions.robolectric.RobolectricTest
import io.kotest.matchers.shouldBe
@@ -33,6 +36,10 @@ internal class VerifonePrintControllerTest :
lateinit var androidBitmap: Bitmap
lateinit var bitmap: ImageBitmap
+
+ // What printImage inlines: normalised for the print head. Barcodes and QR codes are
+ // generated for this printer already, so they are inlined unchanged.
+ lateinit var normalizedBitmap: ImageBitmap
lateinit var printManager: DirectPrintManager
lateinit var printerState: MutableStateFlow
lateinit var barcodeEncoder: BarcodeEncoder
@@ -41,6 +48,9 @@ internal class VerifonePrintControllerTest :
beforeSpec {
androidBitmap = Bitmap.createBitmap(1, 1, Bitmap.Config.ARGB_8888)
bitmap = androidBitmap.asImageBitmap()
+ normalizedBitmap = bitmap.normalizeForPrintHead(
+ PrintingPaperSpec.VerifonePaper56mm.printHeadWidthPx,
+ )
}
beforeTest {
@@ -169,7 +179,7 @@ internal class VerifonePrintControllerTest :
}
it("printImage") {
- val payload = HtmlUtils.transformToHtml(HtmlUtils.generateImageHtml(bitmap))
+ val payload = HtmlUtils.transformToHtml(HtmlUtils.generateImageHtml(normalizedBitmap))
printerController.printImage(bitmap)
printerController.start()
@@ -209,7 +219,7 @@ internal class VerifonePrintControllerTest :
20,
),
)
- append(HtmlUtils.generateImageHtml(bitmap))
+ append(HtmlUtils.generateImageHtml(normalizedBitmap))
appendLine(HtmlUtils.monospaceText("end line", 20))
append("
")
}.toString(),
@@ -248,7 +258,7 @@ internal class VerifonePrintControllerTest :
20,
),
)
- append(HtmlUtils.generateImageHtml(bitmap))
+ append(HtmlUtils.generateImageHtml(normalizedBitmap))
appendLine(HtmlUtils.monospaceText("end line", 20))
append("
")
}.toString(),
@@ -267,4 +277,18 @@ internal class VerifonePrintControllerTest :
printManager.printString(any(), payload, Printer.PRINTER_FULL_CUT)
}
}
+
+ it("a transport failure is reflected in the state and propagates out of start") {
+ // The print listener never sees a transaction that failed on the way out, so without
+ // this the caller would get a Success for a receipt that was never printed.
+ every { printManager.printString(any(), any(), any()) } throws RemoteException()
+
+ printerController.printText("text_to_print")
+
+ shouldThrow {
+ printerController.start()
+ }
+
+ printerState.value shouldBe PrinterState.Error.ConnectionLost
+ }
})
diff --git a/sample/src/androidMain/kotlin/de/tillhub/printengine/sample/MainActivity.kt b/sample/src/androidMain/kotlin/de/tillhub/printengine/sample/MainActivity.kt
index 78c7fb5..1190939 100644
--- a/sample/src/androidMain/kotlin/de/tillhub/printengine/sample/MainActivity.kt
+++ b/sample/src/androidMain/kotlin/de/tillhub/printengine/sample/MainActivity.kt
@@ -2,6 +2,7 @@ package de.tillhub.printengine.sample
import android.Manifest
import android.content.pm.PackageManager
+import android.graphics.BitmapFactory
import android.os.Build
import android.os.Bundle
import android.widget.Toast
@@ -9,6 +10,7 @@ import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts
+import androidx.annotation.DrawableRes
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
@@ -16,6 +18,8 @@ import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.ImageBitmap
+import androidx.compose.ui.graphics.asImageBitmap
import androidx.lifecycle.lifecycleScope
import de.tillhub.printengine.PrintEngine
import de.tillhub.printengine.data.DiscoveryState
@@ -39,6 +43,32 @@ class MainActivity : ComponentActivity() {
}
private val printers = mutableStateListOf()
+ private val printJob: PrintJob by lazy {
+ PrintJob(
+ listOf(
+ // A branch header image, printed before anything else.
+ PrintCommand.Image(decodeDrawable(R.drawable.apex)),
+ PrintCommand.Text(RECEIPT_TEXT),
+ PrintCommand.Text("This is a another line"),
+ PrintCommand.Text("-------"),
+ PrintCommand.Text("Barcode working:"),
+ PrintCommand.Barcode("RTC6093739"),
+ PrintCommand.Text("Barcode broken:"),
+ PrintCommand.Barcode("RTB183648B"),
+ PrintCommand.Text("Barcode more broken:"),
+ PrintCommand.Barcode("RTABCDEFAB"),
+ PrintCommand.Text("QR code:"),
+ PrintCommand.QrCode("123ABC"),
+ PrintCommand.Text("40 char line:"),
+ PrintCommand.Text("1234567890123456789012345678901234567890"),
+ // A branch footer image, printed last before the paper is fed and cut.
+ PrintCommand.Image(decodeDrawable(R.drawable.photoshoppng)),
+ PrintCommand.FeedPaper,
+ PrintCommand.CutPaper,
+ ),
+ )
+ }
+
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
@@ -170,69 +200,58 @@ class MainActivity : ComponentActivity() {
}
}
+ /**
+ * The drawables live in `drawable-nodpi`, so they are decoded at their authored pixel size
+ * rather than being density scaled for the screen - a print head has its own resolution and
+ * scaling for the display would only distort it.
+ */
+ private fun decodeDrawable(
+ @DrawableRes resId: Int,
+ ): ImageBitmap = BitmapFactory.decodeResource(resources, resId).asImageBitmap()
+
private fun showToast(message: String) {
Toast.makeText(this, message, Toast.LENGTH_LONG).show()
}
companion object {
- private val printJob =
- PrintJob(
- listOf(
- PrintCommand.Text(
- " receipt-header \n" +
- "------------------------------------------\n" +
- "Receipt: 1\n" +
- "Salesperson: staff name\n" +
- "Date and Time: 20.5.2020 20:00\n" +
- "Branch: 1\n" +
- "Register: 123\n" +
- "------------------------------------------\n" +
- "Customer number: 654321\n" +
- "------------------------------------------\n" +
- " receipt-note \n" +
- "------------------------------------------\n" +
- "0011 \n" +
- "multiline long product item name for \n" +
- "receipt att-desc \n" +
- "- addon_name \n" +
- " 10.00 € 1 10.00 €\n" +
- " note \n" +
- "voucher-code \n" +
- " \n" +
- "------------------------------------------\n" +
- "Tips 3.00 €\n" +
- " \n" +
- "******************************************\n" +
- "Total (gross): 10.00 €\n" +
- "******************************************\n" +
- "Given: \n" +
- "Cash 10.00 €\n" +
- "- including tip: 3.00 €\n" +
- "------------------------------------------\n" +
- "Change: 10.00 €\n" +
- "------------------------------------------\n" +
- "Vat \n" +
- " 19 % 1.00 €\n" +
- "------------------------------------------\n" +
- "Net: 10.00 €\n" +
- "------------------------------------------\n" +
- " receipt-footer \n",
- ),
- PrintCommand.Text("This is a another line"),
- PrintCommand.Text("-------"),
- PrintCommand.Text("Barcode working:"),
- PrintCommand.Barcode("RTC6093739"),
- PrintCommand.Text("Barcode broken:"),
- PrintCommand.Barcode("RTB183648B"),
- PrintCommand.Text("Barcode more broken:"),
- PrintCommand.Barcode("RTABCDEFAB"),
- PrintCommand.Text("QR code:"),
- PrintCommand.QrCode("123ABC"),
- PrintCommand.Text("40 char line:"),
- PrintCommand.Text("1234567890123456789012345678901234567890"),
- PrintCommand.FeedPaper,
- PrintCommand.CutPaper,
- ),
- )
+ private const val RECEIPT_TEXT =
+ " receipt-header \n" +
+ "------------------------------------------\n" +
+ "Receipt: 1\n" +
+ "Salesperson: staff name\n" +
+ "Date and Time: 20.5.2020 20:00\n" +
+ "Branch: 1\n" +
+ "Register: 123\n" +
+ "------------------------------------------\n" +
+ "Customer number: 654321\n" +
+ "------------------------------------------\n" +
+ " receipt-note \n" +
+ "------------------------------------------\n" +
+ "0011 \n" +
+ "multiline long product item name for \n" +
+ "receipt att-desc \n" +
+ "- addon_name \n" +
+ " 10.00 € 1 10.00 €\n" +
+ " note \n" +
+ "voucher-code \n" +
+ " \n" +
+ "------------------------------------------\n" +
+ "Tips 3.00 €\n" +
+ " \n" +
+ "******************************************\n" +
+ "Total (gross): 10.00 €\n" +
+ "******************************************\n" +
+ "Given: \n" +
+ "Cash 10.00 €\n" +
+ "- including tip: 3.00 €\n" +
+ "------------------------------------------\n" +
+ "Change: 10.00 €\n" +
+ "------------------------------------------\n" +
+ "Vat \n" +
+ " 19 % 1.00 €\n" +
+ "------------------------------------------\n" +
+ "Net: 10.00 €\n" +
+ "------------------------------------------\n" +
+ " receipt-footer \n"
}
}
diff --git a/sample/src/androidMain/res/drawable-nodpi/apex.png b/sample/src/androidMain/res/drawable-nodpi/apex.png
new file mode 100644
index 0000000..b7b8fec
Binary files /dev/null and b/sample/src/androidMain/res/drawable-nodpi/apex.png differ
diff --git a/sample/src/androidMain/res/drawable-nodpi/photoshoppng.png b/sample/src/androidMain/res/drawable-nodpi/photoshoppng.png
new file mode 100644
index 0000000..8cdb2cc
Binary files /dev/null and b/sample/src/androidMain/res/drawable-nodpi/photoshoppng.png differ