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