diff --git a/CHANGELOG.md b/CHANGELOG.md index 03e7978f..f659afab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,9 +6,29 @@ > Mostly notable changes from version to version. Some stuff might go undocumented. If you find something that you think > should be documented, please open an [issue](https://github.com/g0dkar/qrcode-kotlin/issues) :) -# 4.5.0 - Latest +# 4.6.0 - Latest ## ✨ New + +- Added a `resizeCanvas()` function to `QRCode` to make it possible to resize only the Canvas. +- You can now have canvas sizes that are NOT squares (e.g.: a `1024x768` canvas) + - By default, they'll be squares. But you can `resizeCanvas()` (or most likely `fitIntoArea()`) with different + `width` and `height` parameters. +- A new example (Example 09) showing this new behaviour. + +## ♻️ Changed + +- After resizing the canvas (either via `resizeCanvas()` or `fitIntoArea()`) the QRCode will be realigned inside the new + canvas. **Default is to stay centered.** + +## 🔧 Fixed + +- _DEVELOPMENT STILL IN PROGRESS_ + +# 4.5.0 + +## ✨ New + - Added (experimental) support for WASM targets (requested via Issues #140 and #167) - Please do let us know if you run into any issues with it <3 @@ -17,15 +37,21 @@ > I'm trying to keep a better CHANGELOG from now on ^^ ## 🔧 Fixed -- **Fixed an issue with rendering the Timing Pattern.** I have known it for a while, but now I finally figured what was the issue and fixed it. + +- **Fixed an issue with rendering the Timing Pattern.** I have known it for a while, but now I finally figured what was + the issue and fixed it. ## ♻️ Changed + - Changed default ECL from `VERY_HIGH` to `LOW` as to stay closer to what other tools seems to use as a default -- Computing the `informationDensity` value now always goes for the **least possible value** _(down from a minimum of 6 set by `QRCodeBuilder`)_ +- Computing the `informationDensity` value now always goes for the **least possible value** _(down from a minimum of 6 + set by `QRCodeBuilder`)_ - Better documentation of methods - this is an ongoing initiative! ## ✨ New -- New `InsufficientInformationDensityException`: instead of an `IllegalArgumentException`, this new exception is thrown with a more helpful message + +- New `InsufficientInformationDensityException`: instead of an `IllegalArgumentException`, this new exception is thrown + with a more helpful message - Added `drawQRCode()` extension function to a Android Compose `DrawScope` to draw QRCodes into modern Android. - Idea/request from Issue #141 by @dgmltn (Thanks!) - Added examples demonstrating what the ECL does (same data, different ECLs) @@ -34,10 +60,13 @@ - Moved example QRCode files to a folder within each language examples, just to reduce clutter :) ## 🚫 Removed -- `forceInformationDensity` was removed. Now the **QRCodeBuilder** class uses `infoDensity = 0` (default value) as a trigger to compute it automatically since it needs to be `>= 1` + +- `forceInformationDensity` was removed. Now the **QRCodeBuilder** class uses `infoDensity = 0` (default value) as a + trigger to compute it automatically since it needs to be `>= 1` - Default value calling `QRCode()` directly is still 6 as to keep a bit of backwards compatibility 😅 ## 👀 Internal + - Renamed "typeNum" to "informationDensity" - Updated dokka and KMP - Fixed dokka always triggering building the whole `docs/dokka/` folder (that is only for GH Pages) diff --git a/build.gradle.kts b/build.gradle.kts index 20820044..2f791221 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -79,21 +79,21 @@ kotlin { } } - wasmJs { - browser { - commonWebpackConfig { - mode = PRODUCTION - sourceMaps = true - } - - testTask { - enabled = false - } - - binaries.library() - generateTypeScriptDefinitions() - } - } +// wasmJs { +// browser { +// commonWebpackConfig { +// mode = PRODUCTION +// sourceMaps = true +// } +// +// testTask { +// enabled = false +// } +// +// binaries.library() +// generateTypeScriptDefinitions() +// } +// } // This is in place just because my main development machine is NOT a macOS :) // iOS Family of targets... since you can't just "ios()" anymore. @@ -131,17 +131,18 @@ kotlin { } } - androidTarget { + androidMain { dependencies { - compileOnly(libs.androidx.compose.ui) + api(libs.androidx.compose.ui) } } - wasmJsMain { - dependencies { - implementation(libs.kotlinx.browser) - } - } +// wasmJsMain { +// dependencies { +// implementation(libs.kotlinx.browser) +// api(libs.jetbrains.compose.ui) +// } +// } } } diff --git a/examples/android/build.gradle.kts b/examples/android/build.gradle.kts index 5a8f1912..5cbdda51 100644 --- a/examples/android/build.gradle.kts +++ b/examples/android/build.gradle.kts @@ -6,7 +6,7 @@ plugins { android { namespace = "io.github.g0dkar.qrcode" - compileSdk = 35 + compileSdk = 36 defaultConfig { applicationId = "io.github.g0dkar.qrcodeKotlin" @@ -29,7 +29,10 @@ android { targetCompatibility = JavaVersion.VERSION_17 } kotlinOptions { - jvmTarget = "17" + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } } buildFeatures { viewBinding = true diff --git a/examples/android/src/main/AndroidManifest.xml b/examples/android/src/main/AndroidManifest.xml index fa1d52d9..a98fdb88 100644 --- a/examples/android/src/main/AndroidManifest.xml +++ b/examples/android/src/main/AndroidManifest.xml @@ -24,7 +24,6 @@ android:name=".NewQRCodeActivity" /> diff --git a/examples/java/build.gradle.kts b/examples/java/build.gradle.kts index d77257cd..f35e2ff8 100644 --- a/examples/java/build.gradle.kts +++ b/examples/java/build.gradle.kts @@ -9,5 +9,5 @@ repositories { dependencies { implementation("io.github.g0dkar:qrcode-kotlin:4.5.0") - implementation("org.jfree:org.jfree.svg:5.0.5") + implementation("org.jfree:org.jfree.svg:5.0.7") } diff --git a/examples/kotlin/build.gradle.kts b/examples/kotlin/build.gradle.kts index cb3f4658..4fc7cbac 100644 --- a/examples/kotlin/build.gradle.kts +++ b/examples/kotlin/build.gradle.kts @@ -8,6 +8,7 @@ repositories { } dependencies { - implementation("io.github.g0dkar:qrcode-kotlin:4.5.0") - implementation("org.jfree:org.jfree.svg:5.0.5") + implementation(project(":")) +// implementation("io.github.g0dkar:qrcode-kotlin:4.6.0") + implementation("org.jfree:org.jfree.svg:5.0.7") } diff --git a/examples/kotlin/examples-results/example00-simple.png b/examples/kotlin/examples-results/example00-simple.png index 1833b39d..8adc3cc7 100644 Binary files a/examples/kotlin/examples-results/example00-simple.png and b/examples/kotlin/examples-results/example00-simple.png differ diff --git a/examples/kotlin/examples-results/example08-caption-customFont.png b/examples/kotlin/examples-results/example08-caption-customFont.png new file mode 100644 index 00000000..9bc9693d Binary files /dev/null and b/examples/kotlin/examples-results/example08-caption-customFont.png differ diff --git a/examples/kotlin/examples-results/example08-caption.png b/examples/kotlin/examples-results/example08-caption.png new file mode 100644 index 00000000..18746965 Binary files /dev/null and b/examples/kotlin/examples-results/example08-caption.png differ diff --git a/examples/kotlin/examples-results/example09-2xW-2xH-fitIntoArea.png b/examples/kotlin/examples-results/example09-2xW-2xH-fitIntoArea.png new file mode 100644 index 00000000..2b44f7ff Binary files /dev/null and b/examples/kotlin/examples-results/example09-2xW-2xH-fitIntoArea.png differ diff --git a/examples/kotlin/examples-results/example09-2xW-2xH-resizeCanvas.png b/examples/kotlin/examples-results/example09-2xW-2xH-resizeCanvas.png new file mode 100644 index 00000000..cd43e413 Binary files /dev/null and b/examples/kotlin/examples-results/example09-2xW-2xH-resizeCanvas.png differ diff --git a/examples/kotlin/examples-results/example09-2xW-3xH-fitIntoArea.png b/examples/kotlin/examples-results/example09-2xW-3xH-fitIntoArea.png new file mode 100644 index 00000000..23d43469 Binary files /dev/null and b/examples/kotlin/examples-results/example09-2xW-3xH-fitIntoArea.png differ diff --git a/examples/kotlin/examples-results/example09-3xW-2xH-fitIntoArea.png b/examples/kotlin/examples-results/example09-3xW-2xH-fitIntoArea.png new file mode 100644 index 00000000..c78bb4bd Binary files /dev/null and b/examples/kotlin/examples-results/example09-3xW-2xH-fitIntoArea.png differ diff --git a/examples/kotlin/src/main/kotlin/Example08-TextCaption.kt b/examples/kotlin/src/main/kotlin/Example08-TextCaption.kt new file mode 100644 index 00000000..2bbe5882 --- /dev/null +++ b/examples/kotlin/src/main/kotlin/Example08-TextCaption.kt @@ -0,0 +1,79 @@ +import qrcode.QRCode +import java.awt.Color +import java.awt.Font +import java.awt.Graphics2D +import java.awt.RenderingHints +import java.awt.geom.Rectangle2D +import java.awt.image.BufferedImage +import java.io.FileOutputStream +import javax.imageio.ImageIO +import kotlin.math.floor +import kotlin.math.max + +fun main() { + val canvasCaption = createQRCodeWithCaption("QRCode with Caption") + + FileOutputStream("examples/kotlin/examples-results/example08-caption.png") + .use { ImageIO.write(canvasCaption, "PNG", it) } + + // ---------------------------------- + + val customFont = loadFont("MozillaHeadline-Regular.ttf") + val canvasCaptionWithCustomFont = createQRCodeWithCaption("Custom Font: Mozilla Headline", customFont) + + FileOutputStream("examples/kotlin/examples-results/example08-caption-customFont.png") + .use { ImageIO.write(canvasCaptionWithCustomFont, "PNG", it) } +} + +fun createQRCodeWithCaption(caption: String, customFont: Font? = null): BufferedImage { + val qrCode = QRCode.ofSquares().build(caption) + val qrCodeCanvas = qrCode.render().nativeImage() as BufferedImage + val qrCodeWidth = qrCodeCanvas.width + val qrCodeHeight = qrCodeCanvas.height + val captionTextGraphics2d = qrCodeCanvas.createGraphics() + .apply { + if (customFont != null) { + font = customFont + } + } + val (captionTextWidth, captionTextHeight) = fitText(qrCode.data, qrCodeWidth, captionTextGraphics2d) + + // Generate the QRCode + Caption into a BufferedImage and return it + return BufferedImage( + max(qrCodeWidth, captionTextWidth), + qrCodeHeight + captionTextHeight * 2, + BufferedImage.TYPE_INT_ARGB, + ).also { + it.createGraphics().apply { + drawImage(qrCodeCanvas, 0, 0, null) + + font = captionTextGraphics2d.font + paint = Color.BLACK + background = Color.BLACK + setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_ON) + drawString( + caption, + qrCodeWidth / 2 - captionTextWidth / 2, + qrCodeHeight + captionTextHeight / 2 + fontMetrics.ascent, + ) + } + } +} + +fun loadFont(fontFile: String): Font = + ClassLoader.getSystemResourceAsStream(fontFile) + .use { Font.createFont(Font.TRUETYPE_FONT, it) } + +fun fitText(text: String, width: Int, graphics: Graphics2D, fontSizeIncrease: Float = 1.0f): Pair { + val targetWidth = floor(width * 0.9) + var textSize = graphics.font.size.toFloat() + var textBounds: Rectangle2D + + do { + graphics.font = graphics.font.deriveFont(textSize) + textBounds = graphics.fontMetrics.getStringBounds(text, graphics) + textSize += fontSizeIncrease + } while (textBounds.width <= targetWidth) + + return textBounds.width.toInt() to textBounds.height.toInt() +} diff --git a/examples/kotlin/src/main/kotlin/Example09-Resizing.kt b/examples/kotlin/src/main/kotlin/Example09-Resizing.kt new file mode 100644 index 00000000..2bfa7c2f --- /dev/null +++ b/examples/kotlin/src/main/kotlin/Example09-Resizing.kt @@ -0,0 +1,37 @@ +import qrcode.QRCode +import java.io.FileOutputStream + +fun main() { + // Calling fitIntoArea() + val qrCode_2xW_2xH_Size = QRCode.ofSquares() + .build("Resizing to 2x the original size") + val qrCode_2xW_2xH_SizeCanvasSize = qrCode_2xW_2xH_Size.canvasSize + qrCode_2xW_2xH_Size.fitIntoArea(qrCode_2xW_2xH_SizeCanvasSize * 2, qrCode_2xW_2xH_SizeCanvasSize * 2) + val qrCode_2xW_2xH_SizePngData = qrCode_2xW_2xH_Size.renderToBytes() + + val qrCode_3xW_2xH_Size = QRCode.ofSquares() + .build("Resizing to 3x Width, 2x Height the original size") + val qrCode_3xW_2xH_SizeCanvasSize = qrCode_3xW_2xH_Size.canvasSize + qrCode_3xW_2xH_Size.fitIntoArea(qrCode_3xW_2xH_SizeCanvasSize * 3, qrCode_3xW_2xH_SizeCanvasSize * 2) + val qrCode_3xW_2xH_SizePngData = qrCode_3xW_2xH_Size.renderToBytes() + + val qrCode_2xW_3xH_Size = QRCode.ofSquares() + .build("Resizing to 2x Width, 3x Height the original size") + val qrCode_2xW_3xH_SizeCanvasSize = qrCode_2xW_3xH_Size.canvasSize + qrCode_2xW_3xH_Size.fitIntoArea(qrCode_2xW_3xH_SizeCanvasSize * 2, qrCode_2xW_3xH_SizeCanvasSize * 3) + val qrCode_2xW_3xH_SizePngData = qrCode_2xW_3xH_Size.renderToBytes() + + FileOutputStream("examples/kotlin/examples-results/example09-2xW-2xH-fitIntoArea.png").use { it.write(qrCode_2xW_2xH_SizePngData) } + FileOutputStream("examples/kotlin/examples-results/example09-3xW-2xH-fitIntoArea.png").use { it.write(qrCode_3xW_2xH_SizePngData) } + FileOutputStream("examples/kotlin/examples-results/example09-2xW-3xH-fitIntoArea.png").use { it.write(qrCode_2xW_3xH_SizePngData) } + + // ------------------------------------------ + // Calling resize() + val qrCode2xResize = QRCode.ofSquares() + .build("Resizing to 2x the original size") + val qrCode2xResizeCanvasSize = qrCode2xResize.canvasSize + qrCode2xResize.resizeCanvas(qrCode2xResizeCanvasSize * 2) + val qrCode2xResizePngData = qrCode2xResize.renderToBytes() + + FileOutputStream("examples/kotlin/examples-results/example09-2xW-2xH-resizeCanvas.png").use { it.write(qrCode2xResizePngData) } +} diff --git a/examples/kotlin/src/main/resources/MozillaHeadline-Regular.ttf b/examples/kotlin/src/main/resources/MozillaHeadline-Regular.ttf new file mode 100644 index 00000000..955455da Binary files /dev/null and b/examples/kotlin/src/main/resources/MozillaHeadline-Regular.ttf differ diff --git a/gradle.properties b/gradle.properties index fd903547..d4936491 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,4 +1,4 @@ -version=4.5.0 +version=4.6.0 kotlin.code.style=official # JS diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 30d7a79a..e0d613cb 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,21 +1,22 @@ [versions] annotation = "1.9.1" -kotlin = "2.1.21" -dokka = "2.0.0" -kotest = "6.0.0.M3" -lifecycleLivedataKtx = "2.9.1" -lifecycleViewmodelKtx = "2.9.1" +kotlin = "2.2.21" +dokka = "2.1.0" +kotest = "6.0.4" +kotestPlugin = "6.0.0.M3" +lifecycleLivedataKtx = "2.9.4" +lifecycleViewmodelKtx = "2.9.4" mavenNexus = "2.0.0" -androidPlugin = "8.10.1" +androidPlugin = "8.12.0" npmPublish = "3.5.3" -core-ktx = "1.16.0" -appcompat = "1.7.0" -material = "1.12.0" +core-ktx = "1.17.0" +appcompat = "1.7.1" +material = "1.13.0" constraintlayout = "2.2.1" -navigation-fragment-ktx = "2.9.0" -navigation-ui-ktx = "2.9.0" -androidx-compose = "1.8.2" -kotlinx-browser = "0.3" +navigation-fragment-ktx = "2.9.6" +navigation-ui-ktx = "2.9.6" +jetbrains-compose = "1.9.4" +kotlinx-browser = "0.5.0" [libraries] androidx-annotation = { module = "androidx.annotation:annotation", version.ref = "annotation" } @@ -32,13 +33,14 @@ material = { group = "com.google.android.material", name = "material", version.r constraintlayout = { group = "androidx.constraintlayout", name = "constraintlayout", version.ref = "constraintlayout" } navigation-fragment-ktx = { group = "androidx.navigation", name = "navigation-fragment-ktx", version.ref = "navigation-fragment-ktx" } navigation-ui-ktx = { group = "androidx.navigation", name = "navigation-ui-ktx", version.ref = "navigation-ui-ktx" } -androidx-compose-ui = { group = "androidx.compose.ui", name = "ui", version.ref = "androidx-compose" } +androidx-compose-ui = { group = "androidx.compose.ui", name = "ui", version.ref = "jetbrains-compose" } +jetbrains-compose-ui = { group = "org.jetbrains.compose.ui", name = "ui", version.ref = "jetbrains-compose" } kotlinx-browser = { module = "org.jetbrains.kotlinx:kotlinx-browser", version.ref = "kotlinx-browser" } [plugins] dokka = { id = "org.jetbrains.dokka", version.ref = "dokka" } kotlin-multiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" } -kotest-multiplatform = { id = "io.kotest.multiplatform", version.ref = "kotest" } +kotest-multiplatform = { id = "io.kotest.multiplatform", version.ref = "kotestPlugin" } android-library = { id = "com.android.library", version.ref = "androidPlugin" } nexus = { id = "io.github.gradle-nexus.publish-plugin", version.ref = "mavenNexus" } npmPublish = { id = "dev.petuska.npm.publish", version.ref = "npmPublish" } diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 002b867c..d4081da4 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.1-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME diff --git a/settings.gradle.kts b/settings.gradle.kts index b177acb4..494443d7 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -1,13 +1,18 @@ -dependencyResolutionManagement { +pluginManagement { repositories { + google { + content { + includeGroupByRegex("com\\.android.*") + includeGroupByRegex("com\\.google.*") + includeGroupByRegex("androidx.*") + } + } mavenCentral() - google() gradlePluginPortal() - mavenLocal() } } -pluginManagement { +dependencyResolutionManagement { repositories { mavenCentral() google() diff --git a/src/androidMain/kotlin/qrcode/render/QRCodeGraphics.android.kt b/src/androidMain/kotlin/qrcode/render/QRCodeGraphics.android.kt index 7875f986..c8481563 100644 --- a/src/androidMain/kotlin/qrcode/render/QRCodeGraphics.android.kt +++ b/src/androidMain/kotlin/qrcode/render/QRCodeGraphics.android.kt @@ -100,7 +100,8 @@ actual open class QRCodeGraphics actual constructor( actual open fun availableFormats(): Array = AVAILABLE_FORMATS /** Returns the [Bitmap] or [DrawScope] (if Jetpack Compose is available) object being worked upon. */ - actual open fun nativeImage(): Any = drawingInterface?.nativeImage() ?: throw NotImplementedError("Native image not supported") + actual open fun nativeImage(): Any = + drawingInterface?.nativeImage() ?: throw NotImplementedError("Native image not supported") /** Draw a straight line from point `(x1,y1)` to `(x2,y2)`. */ actual open fun drawLine(x1: Int, y1: Int, x2: Int, y2: Int, color: Int, thickness: Double) { diff --git a/src/androidMain/kotlin/qrcode/render/extensions/AndroidComposeExtensions.kt b/src/androidMain/kotlin/qrcode/render/extensions/AndroidComposeExtensions.kt index 430716b0..2e6ec5e4 100644 --- a/src/androidMain/kotlin/qrcode/render/extensions/AndroidComposeExtensions.kt +++ b/src/androidMain/kotlin/qrcode/render/extensions/AndroidComposeExtensions.kt @@ -9,7 +9,7 @@ import qrcode.QRCode import qrcode.render.graphics.DrawScopeGraphics /** - * Extension function to make it easier to draw a [QRCode] into a modern Jetpack Compose [Canvas]. + * Extension function to make it easier to draw a [QRCode] into a modern Compose [Canvas]. * * Usage example: * diff --git a/src/commonMain/kotlin/qrcode/QRCode.kt b/src/commonMain/kotlin/qrcode/QRCode.kt index 2a21b720..1de0b4cb 100644 --- a/src/commonMain/kotlin/qrcode/QRCode.kt +++ b/src/commonMain/kotlin/qrcode/QRCode.kt @@ -6,6 +6,15 @@ import qrcode.QRCode.Companion.EMPTY_FN import qrcode.QRCode.Companion.ofCircles import qrcode.QRCode.Companion.ofRoundedSquares import qrcode.QRCode.Companion.ofSquares +import qrcode.QRCodeAlignment.BOTTOM_CENTER +import qrcode.QRCodeAlignment.BOTTOM_LEFT +import qrcode.QRCodeAlignment.BOTTOM_RIGHT +import qrcode.QRCodeAlignment.MIDDLE_CENTER +import qrcode.QRCodeAlignment.MIDDLE_LEFT +import qrcode.QRCodeAlignment.MIDDLE_RIGHT +import qrcode.QRCodeAlignment.TOP_CENTER +import qrcode.QRCodeAlignment.TOP_LEFT +import qrcode.QRCodeAlignment.TOP_RIGHT import qrcode.QRCodeShapesEnum.CIRCLE import qrcode.QRCodeShapesEnum.CUSTOM import qrcode.QRCodeShapesEnum.ROUNDED_SQUARE @@ -60,9 +69,9 @@ class QRCode @JvmOverloads constructor( /** Size in pixels of the whole QR Code canvas - Defaults to [DEFAULT_QRCODE_SIZE] (0 = compute size automatically) */ canvasSize: Int = DEFAULT_QRCODE_SIZE, /** Offset drawing the QRCode by this amount on the X axis (horizontal) - Defaults to `0` (zero) */ - val xOffset: Int = DEFAULT_X_OFFSET, + var xOffset: Int = DEFAULT_X_OFFSET, /** Offset drawing the QRCode by this amount on the Y axis (vertical) - Defaults to `0` (zero) */ - val yOffset: Int = DEFAULT_Y_OFFSET, + var yOffset: Int = DEFAULT_Y_OFFSET, /** Function that will handle color processing (which color is "light" and which is "dark") - Defaults to [DefaultColorFunction]. */ val colorFn: QRCodeColorFunction = DefaultColorFunction(), /** Function that will handle drawing the shapes of each square - Defaults to [DefaultShapeFunction] with `innerSpace = 0`. */ @@ -133,6 +142,9 @@ class QRCode @JvmOverloads constructor( QRCodeBuilder(CUSTOM, customShapeFunction) } + private var internalYOffset: Int = 0 + private var internalXOffset: Int = 0 + var squareSize: Int = squareSize private set @@ -150,14 +162,25 @@ class QRCode @JvmOverloads constructor( /** * Size of the canvas where the QRCode will be drawn into (the final image will be a square of `canvasSize` by `canvasSize`) - * - * - * */ var canvasSize: Int = if (canvasSize > DEFAULT_QRCODE_SIZE) canvasSize else qrCodeProcessor.computeImageSize(squareSize, rawData) private set + /** + * Width of the canvas where the QRCode will be drawn into + * + * @see canvasSize + */ + val width: Int = this.canvasSize + + /** + * Height of the canvas where the QRCode will be drawn into + * + * @see canvasSize + */ + val height: Int = this.canvasSize + /** Size of the canvas where the QRCode will be drawn into. */ @Deprecated("Please use canvasSize instead.") val computedSize: Int @@ -183,8 +206,8 @@ class QRCode @JvmOverloads constructor( if (!actualSquare.rendered) { when (currentSquare.squareInfo.type) { POSITION_PROBE, POSITION_ADJUST -> shapeFn.renderControlSquare( - xOffset, - yOffset, + xOffset + internalXOffset, + yOffset + internalYOffset, colorFn, actualSquare, canvas, @@ -192,8 +215,8 @@ class QRCode @JvmOverloads constructor( ) else -> shapeFn.renderSquare( - xOffset + x, - yOffset + y, + xOffset + internalXOffset + x, + yOffset + internalYOffset + y, colorFn, currentSquare, canvas, @@ -205,25 +228,130 @@ class QRCode @JvmOverloads constructor( } } - /** - * Computes a [squareSize] to make sure the QRCode can fit into an area of width by height pixels - */ + @Deprecated("Please use resizeCanvas or fitIntoArea instead.") fun resize(size: Int): QRCode { - canvasSize = size - graphics = graphicsFactory.newGraphicsSquare(canvasSize) - - return this + return resizeCanvas( + width = size, + height = size, + resizeCanvasOnly = true, + qrCodeAlignmentAfterResize = MIDDLE_CENTER, + ) } /** - * Computes a [squareSize] to make sure the QRCode can fit into an area of width by height pixels + * Resizes _**the Canvas**_ where the QRCode will be drawn. + * + * By default, it resizes the Canvas to a square of [width] by [width] size. + * + * If the [height] parameter is specified, resizes the canvas to [width] by [height]. + * + * Optionally also resize the QRCode as well, making it as big as possible while + * fitting into the new Canvas Size **(default is `true`, resizing only the canvas)**. + * + * Calling this function with [resizeCanvasOnly] = `false` is the same as calling [fitIntoArea]. + * + * Lastly, [qrCodeAlignmentAfterResize] decides where the QRCode will be placed on the resized canvas. + * Defaults to [MIDDLE_CENTER] (aka "centered"). + * + * You probably want to use [fitIntoArea] instead. + * + * > _Context on why have both: Sometimes you want to resize only the canvas so you'll draw something else + * > there before drawing the QRCode. This isn't the most common use-case. The most common one is resizing + * > the QRCode so it fits into a given area, thus the [fitIntoArea]. Because of the naming confusion and + * > bad documentation (my bad, sorry!) this function was expanded into what it is today (v4.6.0)_ + * + * @param width Width, in pixels, of the canvas + * @param height Height, in pixels, of the canvas (default: same as [width], making it a square) + * @param resizeCanvasOnly If `true` resize ONLY the canvas, leaving the QRCode size intact (default: `true`) + * @param qrCodeAlignmentAfterResize Where will the QRCode be placed after resizing? (default: [MIDDLE_CENTER]) + * + * @see fitIntoArea You likely want this one ;) */ - fun fitIntoArea(width: Int, height: Int): QRCode { + fun resizeCanvas( + width: Int, + height: Int = width, + resizeCanvasOnly: Boolean = true, + qrCodeAlignmentAfterResize: QRCodeAlignment = MIDDLE_CENTER, + ): QRCode = + if (resizeCanvasOnly) { + canvasSize = min(width, height) + graphics = graphicsFactory.newGraphics(width, height) + alignQRCode(width, height, qrCodeAlignmentAfterResize) + } else { + fitIntoArea(width, height, qrCodeAlignmentAfterResize) + } + + /** + * Resizes the Canvas **AND** the QRCode accordingly. + * + * The QRCode will be resized as best as possible to fit into the new Canvas. + * + * After resizing, the QRCode might have to be realigned. The realignment can + * be customized via the optional [qrCodeAlignmentAfterFit] parameter. + * + * By default, the QRCode will be drawn at the middle-center (aka "centered") + * of the Canvas. + */ + @JvmOverloads + fun fitIntoArea(width: Int, height: Int, qrCodeAlignmentAfterFit: QRCodeAlignment = MIDDLE_CENTER): QRCode { val reference = min(width, height) squareSize = floor(reference / rawData.size.toDouble()).toInt() shapeFn.resize(squareSize) canvasSize = reference - graphics = graphicsFactory.newGraphicsSquare(canvasSize) + graphics = graphicsFactory.newGraphics(width, height) + + return alignQRCode(width, height, qrCodeAlignmentAfterFit) + } + + private fun alignQRCode(width: Int, height: Int, qrCodeAlignment: QRCodeAlignment = MIDDLE_CENTER): QRCode { + val qrcodeSize = squareSize * rawData.size + + when (qrCodeAlignment) { + TOP_LEFT -> { + internalXOffset = 0 + internalYOffset = 0 + } + + TOP_RIGHT -> { + internalXOffset = width - qrcodeSize + internalYOffset = 0 + } + + TOP_CENTER -> { + internalXOffset = ((width / 2.0) - (qrcodeSize / 2.0)).toInt() + internalYOffset = 0 + } + + MIDDLE_LEFT -> { + internalXOffset = 0 + internalYOffset = ((height / 2.0) - (qrcodeSize / 2.0)).toInt() + } + + MIDDLE_RIGHT -> { + internalXOffset = width - qrcodeSize + internalYOffset = ((height / 2.0) - (qrcodeSize / 2.0)).toInt() + } + + MIDDLE_CENTER -> { + internalXOffset = ((width / 2.0) - (qrcodeSize / 2.0)).toInt() + internalYOffset = ((height / 2.0) - (qrcodeSize / 2.0)).toInt() + } + + BOTTOM_LEFT -> { + internalXOffset = 0 + internalYOffset = height - qrcodeSize + } + + BOTTOM_RIGHT -> { + internalXOffset = width - qrcodeSize + internalYOffset = height - qrcodeSize + } + + BOTTOM_CENTER -> { + internalXOffset = ((width / 2.0) - (qrcodeSize / 2.0)).toInt() + internalYOffset = height - qrcodeSize + } + } return this } diff --git a/src/commonMain/kotlin/qrcode/QRCodeAlignment.kt b/src/commonMain/kotlin/qrcode/QRCodeAlignment.kt new file mode 100644 index 00000000..31271a4f --- /dev/null +++ b/src/commonMain/kotlin/qrcode/QRCodeAlignment.kt @@ -0,0 +1,18 @@ +package qrcode + +/** + * QRCode alignment inside the Canvas. Used to place the QRCode after calling [QRCode.fitIntoArea]. + */ +enum class QRCodeAlignment { + TOP_LEFT, + TOP_CENTER, + TOP_RIGHT, + + BOTTOM_LEFT, + BOTTOM_CENTER, + BOTTOM_RIGHT, + + MIDDLE_LEFT, + MIDDLE_CENTER, + MIDDLE_RIGHT, +} diff --git a/src/commonMain/kotlin/qrcode/QRCodeBuilder.kt b/src/commonMain/kotlin/qrcode/QRCodeBuilder.kt index 33ae1401..f4c2a20c 100644 --- a/src/commonMain/kotlin/qrcode/QRCodeBuilder.kt +++ b/src/commonMain/kotlin/qrcode/QRCodeBuilder.kt @@ -47,6 +47,7 @@ class QRCodeBuilder @JvmOverloads constructor( private var xOffset: Int = 0 private var yOffset: Int = 0 private var margin: Int = 0 + private var maxAutoInformationDensity: Int = MAXIMUM_INFO_DENSITY private fun innerSpace() = when (shape) { @@ -327,6 +328,16 @@ class QRCodeBuilder @JvmOverloads constructor( return this } + /** + * Sets a value for the Maximum Information Density if automatically calculated. + * + * @param margin How many extra pixels to add around the QRCode + */ + fun withMaxAutoInformationDensity(maxAutoInformationDensity: Int): QRCodeBuilder { + this.maxAutoInformationDensity = maxAutoInformationDensity + return this + } + private val beforeFn: QRCode.(QRCodeGraphics, Int, Int) -> Unit get() = { canvas, xOffset, yOffset -> drawLogoBeforeAction(canvas, xOffset, yOffset) @@ -374,7 +385,7 @@ class QRCodeBuilder @JvmOverloads constructor( graphicsFactory = graphicsFactory, errorCorrectionLevel = errorCorrectionLevel, informationDensity = when (informationDensity) { - 0 -> QRCodeProcessor.infoDensityForDataAndECL(data, errorCorrectionLevel) + 0 -> QRCodeProcessor.infoDensityForDataAndECL(data, errorCorrectionLevel, maxInfoDensity = maxAutoInformationDensity) else -> informationDensity }, maskPattern = maskPattern, @@ -382,7 +393,7 @@ class QRCodeBuilder @JvmOverloads constructor( doAfter = afterFn, ).apply { if (margin > 0) { - resize(canvasSize + margin * 2) + resizeCanvas(canvasSize + margin * 2) } } } diff --git a/src/commonMain/kotlin/qrcode/raw/QRCodeProcessor.kt b/src/commonMain/kotlin/qrcode/raw/QRCodeProcessor.kt index 223379e7..83721758 100644 --- a/src/commonMain/kotlin/qrcode/raw/QRCodeProcessor.kt +++ b/src/commonMain/kotlin/qrcode/raw/QRCodeProcessor.kt @@ -105,6 +105,7 @@ class QRCodeProcessor @JvmOverloads constructor( data: String, errorCorrectionLevel: ErrorCorrectionLevel, dataType: QRCodeDataType = QRUtil.getDataType(data), + maxInfoDensity: Int = MAXIMUM_INFO_DENSITY, ): Int { val qrCodeData = when (dataType) { NUMBERS -> QRNumber(data) @@ -119,7 +120,7 @@ class QRCodeProcessor @JvmOverloads constructor( } } - return MAXIMUM_INFO_DENSITY + return maxInfoDensity } } diff --git a/src/commonMain/kotlin/qrcode/shape/DefaultShapeFunction.kt b/src/commonMain/kotlin/qrcode/shape/DefaultShapeFunction.kt index d6aad3cb..968889b4 100644 --- a/src/commonMain/kotlin/qrcode/shape/DefaultShapeFunction.kt +++ b/src/commonMain/kotlin/qrcode/shape/DefaultShapeFunction.kt @@ -15,7 +15,7 @@ open class DefaultShapeFunction( squareSize: Int = DEFAULT_CELL_SIZE, val innerSpace: Int = 1, ) : QRCodeShapeFunction { - private var innerSpacing = innerSpace.coerceIn(0..(squareSize / 2)) + private var innerSpacing = innerSpace.coerceIn(0..(squareSize / 2)).takeIf { it < squareSize } ?: 0 var squareSize: Int = squareSize private set @@ -23,7 +23,7 @@ open class DefaultShapeFunction( val sizeRatio: Double = newSquareSize / squareSize.toDouble() squareSize = newSquareSize - innerSpacing = (innerSpace * sizeRatio).toInt().coerceIn(0..(newSquareSize / 2)) + innerSpacing = (innerSpace * sizeRatio).toInt().coerceIn(0..(newSquareSize / 2)).takeIf { it < squareSize } ?: 0 } override fun renderSquare( diff --git a/src/jvmMain/kotlin/qrcode/render/QRCodeGraphics.jvm.kt b/src/jvmMain/kotlin/qrcode/render/QRCodeGraphics.jvm.kt index e51d8b39..6e428efe 100644 --- a/src/jvmMain/kotlin/qrcode/render/QRCodeGraphics.jvm.kt +++ b/src/jvmMain/kotlin/qrcode/render/QRCodeGraphics.jvm.kt @@ -9,6 +9,8 @@ import java.awt.image.BufferedImage import java.io.ByteArrayInputStream import java.io.ByteArrayOutputStream import java.io.OutputStream +import java.math.BigDecimal +import java.math.RoundingMode.FLOOR import java.util.function.Consumer import javax.imageio.ImageIO import kotlin.math.roundToInt @@ -68,12 +70,15 @@ actual open class QRCodeGraphics actual constructor( val jdkColor = colorCache.computeIfAbsent(color) { Color(color, true) } if (strokeThickness != null && strokeThickness > 0) { - graphics.stroke = BasicStroke(strokeThickness.toFloat(), BasicStroke.CAP_ROUND, BasicStroke.JOIN_ROUND) + graphics.stroke = BasicStroke(strokeThickness.toFloat()) } graphics.color = jdkColor graphics.background = jdkColor graphics.paint = jdkColor - graphics.setRenderingHint(KEY_ANTIALIASING, VALUE_ANTIALIAS_ON) + + if (width >= 100) { + graphics.setRenderingHint(KEY_ANTIALIASING, VALUE_ANTIALIAS_ON) + } action(graphics) @@ -153,13 +158,23 @@ actual open class QRCodeGraphics actual constructor( /** Draw the edges of a rectangle starting at point `(x,y)` and having `width` by `height`. */ actual open fun drawRect(x: Int, y: Int, width: Int, height: Int, color: Int, thickness: Double) { draw(color, thickness) { - val halfThickness = (thickness / 2.0).roundToInt().coerceAtLeast(0) - it.drawRect( - x + halfThickness + customImageOffsetX, - y + halfThickness + customImageOffsetY, - width - halfThickness * 2, - height - halfThickness * 2, - ) + if (width >= 100) { + val halfThickness = roundDown(thickness / 2.0) + + it.drawRect( + x + halfThickness + customImageOffsetX, + y + halfThickness + customImageOffsetY, + width - halfThickness * 2, + height - halfThickness * 2, + ) + } else { + it.drawRect( + x + customImageOffsetX, + y + customImageOffsetY, + width, + height, + ) + } } } @@ -204,7 +219,7 @@ actual open class QRCodeGraphics actual constructor( thickness: Double, ) { draw(color, thickness) { - val halfThickness = (thickness / 2.0).roundToInt().coerceAtLeast(0) + val halfThickness = roundDown(thickness / 2.0) it.drawRoundRect( x + halfThickness + customImageOffsetX, y + halfThickness + customImageOffsetY, @@ -305,4 +320,7 @@ actual open class QRCodeGraphics actual constructor( action.accept(it) } } + + private fun roundDown(value: Double): Int = + BigDecimal.valueOf(value).setScale(0, FLOOR).toInt().coerceAtLeast(0) } diff --git a/src/wasmJsMain/kotlin/qrcode/render/QRCodeGraphics.wasmjs.kt b/src/wasmJsMain/kotlin/qrcode/render/QRCodeGraphics.wasmjs.kt index b726364a..8476db46 100644 --- a/src/wasmJsMain/kotlin/qrcode/render/QRCodeGraphics.wasmjs.kt +++ b/src/wasmJsMain/kotlin/qrcode/render/QRCodeGraphics.wasmjs.kt @@ -1,59 +1,21 @@ package qrcode.render -import kotlinx.browser.document -import org.khronos.webgl.Uint8ClampedArray -import org.khronos.webgl.toInt8Array -import org.w3c.dom.CanvasRenderingContext2D import org.w3c.dom.HTMLCanvasElement -import org.w3c.dom.ImageData import org.w3c.files.Blob +import qrcode.render.graphics.HTMLCanvasGraphics +import qrcode.render.graphics.WasmJsDrawingInterface @Suppress("MemberVisibilityCanBePrivate") +@OptIn(ExperimentalWasmJsInterop::class) actual open class QRCodeGraphics actual constructor( val width: Int, val height: Int, ) { - companion object { - private const val CANVAS_UNSUPPORTED = "Canvas seems to not be supported :(" - private const val FULL_CIRCLE = 3.141592653589793 * 2.0 // 2 * PI = Full circle - } + var drawingInterface: WasmJsDrawingInterface? = null - private val canvas: HTMLCanvasElement + /** Whether any drawing operations were done or not. */ private var changed: Boolean = false - init { - val canvas = tryGet { document.createElement("canvas") as HTMLCanvasElement } - - canvas.width = width - canvas.height = height - - this.canvas = canvas - } - - private fun rgba(color: Int): String { - val r = (color shr 16) and 0xFF - val g = (color shr 8) and 0xFF - val b = (color shr 0) and 0xFF - val a = ((color shr 24) and 0xFF) / 255.0 - return "rgba($r,$g,$b,$a)" - } - - private fun draw(color: Int, action: CanvasRenderingContext2D.() -> Unit) { - changed = true - - val context = tryGet { canvas.getContext("2d") as CanvasRenderingContext2D } - - val colorString = rgba(color) - context.fillStyle = colorString.toJsString() - context.strokeStyle = colorString.toJsString() - - val lineWidth = context.lineWidth - - action(context) - - context.lineWidth = lineWidth - } - /** Returns `true` if **any** drawing was performed */ actual open fun changed() = changed @@ -61,24 +23,47 @@ actual open class QRCodeGraphics actual constructor( actual fun reset() { if (changed) { changed = false - draw(0) { clearRect(0.0, 0.0, width.toDouble(), height.toDouble()) } } } + /** + * Make sure we can use the [drawingInterface]. Never mind the name. + */ + private fun useCanvas(): WasmJsDrawingInterface { + if (drawingInterface == null) { + drawingInterface = HTMLCanvasGraphics(width, height) + } + + return drawingInterface!! + } + /** Return the dimensions of this Graphics object as a pair of `width, height` */ actual open fun dimensions() = arrayOf(width, height) /** * Returns a Data URL to this can be shown in an `` tag. */ - open fun toDataURL(format: String = "png"): String = canvas.toDataURL(format) + open fun toDataURL(format: String = "png"): String = + nativeImage().let { + when (it) { + is HTMLCanvasElement -> it.toDataURL(format) + else -> throw Error("Unsupported operation") + } + } /** * Direct access to the `.toBlob()` function of the underlying canvas. * * Syntactic sugar for `nativeImage().toBlob(callback)`. */ - open fun toBlob(callback: (Blob?) -> Unit): Unit = canvas.toBlob(callback) + open fun toBlob(callback: (Blob?) -> Unit) { + nativeImage().let { + when (it) { + is HTMLCanvasElement -> it.toBlob(callback) + else -> throw Error("Unsupported operation") + } + } + } /** Returns this image as a [ByteArray] encoded as PNG. */ actual open fun getBytes(): ByteArray = getBytes("png") @@ -86,7 +71,7 @@ actual open class QRCodeGraphics actual constructor( /** Returns this image as a [ByteArray] encoded as the specified format (e.g. `PNG`, `JPG`, `BMP`, ...). */ @JsName("getBytesForFormat") actual open fun getBytes(format: String): ByteArray = - canvas.toDataURL(format).encodeToByteArray() + useCanvas().getBytes(format) /** Returns the available formats to be passed as parameters to [getBytes]. * @@ -95,35 +80,22 @@ actual open class QRCodeGraphics actual constructor( actual open fun availableFormats(): Array = arrayOf("png") /** Returns the native image object this QRCodeGraphics is working upon. */ - actual open fun nativeImage(): Any = canvas + actual open fun nativeImage(): Any = + drawingInterface?.nativeImage() ?: throw NotImplementedError("Native image not supported") /** Draw a straight line from point `(x1,y1)` to `(x2,y2)`. */ actual open fun drawLine(x1: Int, y1: Int, x2: Int, y2: Int, color: Int, thickness: Double) { - draw(color) { - moveTo(x1.toDouble(), y1.toDouble()) - lineTo(x2.toDouble(), y2.toDouble()) - } + useCanvas().drawLine(x1, y1, x2, y2, color, thickness) } /** Draw the edges of a rectangle starting at point `(x,y)` and having `width` by `height`. */ actual open fun drawRect(x: Int, y: Int, width: Int, height: Int, color: Int, thickness: Double) { - draw(color) { - lineWidth = thickness - val halfThickness = thickness / 2.0 - strokeRect( - x.toDouble() + halfThickness, - y.toDouble() + halfThickness, - width.toDouble() - thickness, - height.toDouble() - thickness, - ) - } + useCanvas().drawRect(x, y, width, height, color, thickness) } /** Fills the rectangle starting at point `(x,y)` and having `width` by `height`. */ actual open fun fillRect(x: Int, y: Int, width: Int, height: Int, color: Int) { - draw(color) { - fillRect(x.toDouble(), y.toDouble(), width.toDouble(), height.toDouble()) - } + useCanvas().fillRect(x, y, width, height, color) } /** Fill the whole area of this canvas with the specified [color]. */ @@ -161,7 +133,7 @@ actual open class QRCodeGraphics actual constructor( color: Int, thickness: Double, ) { - drawRect(x, y, width, height, color, 1.0) + useCanvas().drawRoundRect(x, y, width, height, borderRadius, color, thickness) } /** @@ -193,22 +165,14 @@ actual open class QRCodeGraphics actual constructor( borderRadius: Int, color: Int, ) { - fillRect(x, y, width, height, color) + useCanvas().fillRoundRect(x, y, width, height, borderRadius, color) } /** * Draw the edges of an ellipse (aka "a circle") which occupies the area `(x,y,width,height)` */ actual fun drawEllipse(x: Int, y: Int, width: Int, height: Int, color: Int, thickness: Double) { - draw(color) { - val radiusX = width.toDouble() / 2.0 - val radiusY = height.toDouble() / 2.0 - - lineWidth = thickness - beginPath() - ellipse(radiusX + x.toDouble(), radiusY + y.toDouble(), radiusX, radiusY, 0.0, 0.0, FULL_CIRCLE, false) - stroke() - } + useCanvas().drawEllipse(x, y, width, height, color, thickness) } /** @@ -216,14 +180,7 @@ actual open class QRCodeGraphics actual constructor( * */ actual fun fillEllipse(x: Int, y: Int, width: Int, height: Int, color: Int) { - draw(color) { - val radiusX = width.toDouble() / 2.0 - val radiusY = height.toDouble() / 2.0 - - beginPath() - ellipse(radiusX + x.toDouble(), radiusY + y.toDouble(), radiusX, radiusY, 0.0, 0.0, FULL_CIRCLE, false) - fill() - } + useCanvas().fillEllipse(x, y, width, height, color) } /** @@ -234,19 +191,6 @@ actual open class QRCodeGraphics actual constructor( */ @JsName("drawImageFromBytes") actual fun drawImage(rawData: ByteArray?, x: Int, y: Int) { - if (rawData != null && rawData.isNotEmpty()) { - draw(0) { - val imageDataArray: JsArray = rawData.toInt8Array().unsafeCast() - val imageData = ImageData(Uint8ClampedArray(imageDataArray), width) - putImageData(imageData, x.toDouble(), y.toDouble()) - } - } + useCanvas().drawImage(rawData, x, y) } - - private fun tryGet(what: () -> T): T = - try { - what() - } catch (t: Throwable) { - throw Error(CANVAS_UNSUPPORTED, cause = t) - } } diff --git a/src/wasmJsMain/kotlin/qrcode/render/extensions/WasmJsComposeExtensions.kt b/src/wasmJsMain/kotlin/qrcode/render/extensions/WasmJsComposeExtensions.kt new file mode 100644 index 00000000..6907aa2d --- /dev/null +++ b/src/wasmJsMain/kotlin/qrcode/render/extensions/WasmJsComposeExtensions.kt @@ -0,0 +1,90 @@ +package qrcode.render.extensions + +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Canvas +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.decodeToImageBitmap +import androidx.compose.ui.graphics.drawscope.DrawScope +import androidx.compose.ui.graphics.drawscope.translate +import qrcode.QRCode +import qrcode.render.QRCodeGraphics +import qrcode.render.graphics.DrawScopeGraphics + +/** + * Extension function to make it easier to draw a [QRCode] into a modern Compose [Canvas]. + * + * Usage example: + * + * ```kotlin + * import qrcode.render.extensions.drawQRCode + * + * @Composable + * fun QRCodeKotlinQRCode( + * text: String, + * modifier: Modifier = Modifier, + * ) { + * val qrCode = remember(text) { + * QRCode.ofRoundedSquares() + * .build(text) + * } + * + * Canvas( + * modifier = modifier + * .aspectRatio(1f) + * ) { + * drawQRCode(qrCode) // Draw the QRCode at (0, 0) + * } + * } + * ``` + * + * **Code sample by @dgmltn at GitHub**, used with authors' permission and lightly modified :) + * + * Original code: https://github.com/g0dkar/qrcode-kotlin/issues/141#issuecomment-2722041216 + * + * @param qrCode The [QRCode] that will be drawn into the [DrawScope]. + * @param offsetTopLeft The [Offset] of the top-left corner where the QRCode will be drawn. Defaults to [Offset.Zero]. + * @param sizeToFitInto The area in which to fit the QRCode. Defaults to the `size` of the [DrawScope]. + * + */ +fun DrawScope.drawQRCode( + qrCode: QRCode, + offsetTopLeft: Offset = Offset.Zero, + sizeToFitInto: Size = this.size, +) { + val width = sizeToFitInto.width.toInt() + val height = sizeToFitInto.height.toInt() + + qrCode.fitIntoArea(width, height) + + val qrCodeGraphics = qrCode.graphics + val previousDrawingInterface = qrCodeGraphics.drawingInterface + + val drawScopeGraphics = DrawScopeGraphics(this, qrCodeGraphics.width, qrCodeGraphics.height) + qrCodeGraphics.drawingInterface = drawScopeGraphics + + translate(offsetTopLeft.x, offsetTopLeft.y) { + qrCode.render() + } + + qrCodeGraphics.drawingInterface = previousDrawingInterface +} + +/** + * Extension function to generate a QRCode as a Compose [ImageBitmap]. + * + * Code largely "inspired" (read: copied) from [this](https://github.com/g0dkar/qrcode-kotlin/issues/187#issuecomment-3119959105) comment by `bxkr` + * + * All credits to [@bxkr](https://github.com/bxkr) - thanks! + */ +fun QRCode.renderToPNGImageBitmap( + qrCodeGraphics: QRCodeGraphics = graphics, + xOffset: Int = this.xOffset, + yOffset: Int = this.yOffset, + format: String = "PNG", +): ImageBitmap = + render(qrCodeGraphics, xOffset, yOffset) + .toDataURL(format) + .substringAfter("data:image/png;base64,") + .encodeToByteArray() + .decodeToImageBitmap() diff --git a/src/wasmJsMain/kotlin/qrcode/render/graphics/DrawScopeGraphics.kt b/src/wasmJsMain/kotlin/qrcode/render/graphics/DrawScopeGraphics.kt new file mode 100644 index 00000000..f69fba31 --- /dev/null +++ b/src/wasmJsMain/kotlin/qrcode/render/graphics/DrawScopeGraphics.kt @@ -0,0 +1,179 @@ +package qrcode.render.graphics + +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.drawscope.DrawScope +import androidx.compose.ui.graphics.drawscope.Fill +import androidx.compose.ui.graphics.drawscope.Stroke +import qrcode.color.Colors + +class DrawScopeGraphics( + val drawScope: DrawScope, val width: Int, val height: Int, +) : WasmJsDrawingInterface { + private val paintCache = mutableMapOf() + + private fun composeColor(color: Int): Color { + return paintCache.getOrPut(color) { + val (r, g, b, a) = Colors.getRGBA(color) + Color(r, g, b, a) + } + } + + private fun offset(x: Int, y: Int) = Offset(x.toFloat(), y.toFloat()) + + private fun offsetSize(x: Int, y: Int, width: Int, height: Int): Pair = + Pair(offset(x, y), Size(width.toFloat(), height.toFloat())) + + private fun stroke(thickness: Double) = Stroke(width = thickness.toFloat()) + + override fun drawLine( + x1: Int, + y1: Int, + x2: Int, + y2: Int, + color: Int, + thickness: Double, + ) { + val startXY = offset(x1, y1) + val endXY = offset(x2, y2) + drawScope.drawLine( + color = composeColor(color), + start = startXY, + end = endXY, + strokeWidth = thickness.toFloat(), + ) + } + + override fun drawRect( + x: Int, + y: Int, + width: Int, + height: Int, + color: Int, + thickness: Double, + ) { + val halfThickness = (thickness / 2.0).toInt() + val (topLeft, size) = offsetSize( + x + halfThickness, + y + halfThickness, + width - halfThickness * 2, + height - halfThickness * 2, + ) + + drawScope.drawRect( + color = composeColor(color), + topLeft = topLeft, + size = size, + style = stroke(thickness), + ) + } + + override fun fillRect(x: Int, y: Int, width: Int, height: Int, color: Int) { + val (topLeft, size) = offsetSize(x, y, width, height) + + drawScope.drawRect( + color = composeColor(color), + topLeft = topLeft, + size = size, + style = Fill, + ) + } + + override fun fill(color: Int) { + fillRect(0, 0, width, height, color) + } + + override fun drawRoundRect( + x: Int, + y: Int, + width: Int, + height: Int, + borderRadius: Int, + color: Int, + thickness: Double, + ) { + val halfThickness = (thickness / 2.0).toInt() + val (topLeft, size) = offsetSize( + x + halfThickness, + y + halfThickness, + width - halfThickness * 2, + height - halfThickness * 2, + ) + + drawScope.drawRoundRect( + color = composeColor(color), + topLeft = topLeft, + size = size, + cornerRadius = CornerRadius(borderRadius.toFloat(), borderRadius.toFloat()), + style = stroke(thickness), + ) + } + + override fun fillRoundRect( + x: Int, + y: Int, + width: Int, + height: Int, + borderRadius: Int, + color: Int, + ) { + val (topLeft, size) = offsetSize(x, y, width, height) + val cornerRadius = CornerRadius(borderRadius.toFloat(), borderRadius.toFloat()) + + drawScope.drawRoundRect( + color = composeColor(color), + topLeft = topLeft, + size = size, + cornerRadius = cornerRadius, + style = Fill, + ) + } + + override fun drawEllipse( + x: Int, + y: Int, + width: Int, + height: Int, + color: Int, + thickness: Double, + ) { + val halfThickness = (thickness / 2.0).toInt() + val (topLeft, size) = offsetSize( + x + halfThickness, + y + halfThickness, + width - halfThickness * 2, + height - halfThickness * 2, + ) + + drawScope.drawOval( + color = composeColor(color), + topLeft = topLeft, + size = size, + style = stroke(thickness), + ) + } + + override fun fillEllipse(x: Int, y: Int, width: Int, height: Int, color: Int) { + val (topLeft, size) = offsetSize(x, y, width, height) + + drawScope.drawOval( + color = composeColor(color), + topLeft = topLeft, + size = size, + style = Fill, + ) + } + + override fun drawImage(rawData: ByteArray?, x: Int, y: Int) { + TODO("Unsupported operation") + } + + override fun nativeImage(): Any = drawScope + + override fun getBytes(format: String, quality: Int): ByteArray { + TODO("Unsupported operation") + } + +} diff --git a/src/wasmJsMain/kotlin/qrcode/render/graphics/HTMLCanvasGraphics.kt b/src/wasmJsMain/kotlin/qrcode/render/graphics/HTMLCanvasGraphics.kt new file mode 100644 index 00000000..350ba829 --- /dev/null +++ b/src/wasmJsMain/kotlin/qrcode/render/graphics/HTMLCanvasGraphics.kt @@ -0,0 +1,161 @@ +package qrcode.render.graphics + +import kotlinx.browser.document +import org.khronos.webgl.Uint8ClampedArray +import org.khronos.webgl.toInt8Array +import org.w3c.dom.CanvasRenderingContext2D +import org.w3c.dom.HTMLCanvasElement +import org.w3c.dom.ImageData + +/** + * An [WasmJsDrawingInterface] that uses a [Canvas] (here referred to as "Classic Canvas") to draw into a [Bitmap]. + * + * For a modern Canvas, see [DrawScopeGraphics] which uses Jetpack Compose. + */ +@OptIn(ExperimentalWasmJsInterop::class) +open class HTMLCanvasGraphics( + val width: Int, + val height: Int, +) : WasmJsDrawingInterface { + companion object { + private const val CANVAS_UNSUPPORTED = "Canvas seems to not be supported :(" + private const val FULL_CIRCLE = 3.141592653589793 * 2.0 // 2 * PI = Full circle + } + + private val canvas: HTMLCanvasElement + + init { + val canvas = tryGet { document.createElement("canvas") as HTMLCanvasElement } + + canvas.width = width + canvas.height = height + + this.canvas = canvas + } + + private fun draw(color: Int, action: CanvasRenderingContext2D.() -> Unit) { + val context = tryGet { canvas.getContext("2d") as CanvasRenderingContext2D } + + val colorString = rgba(color) + context.fillStyle = colorString.toJsString() + context.strokeStyle = colorString.toJsString() + + val lineWidth = context.lineWidth + + action(context) + + context.lineWidth = lineWidth + } + + override fun drawLine(x1: Int, y1: Int, x2: Int, y2: Int, color: Int, thickness: Double) { + draw(color) { + moveTo(x1.toDouble(), y1.toDouble()) + lineTo(x2.toDouble(), y2.toDouble()) + } + } + + override fun drawRect(x: Int, y: Int, width: Int, height: Int, color: Int, thickness: Double) { + draw(color) { + lineWidth = thickness + val halfThickness = thickness / 2.0 + strokeRect( + x.toDouble() + halfThickness, + y.toDouble() + halfThickness, + width.toDouble() - thickness, + height.toDouble() - thickness, + ) + } + } + + override fun fillRect(x: Int, y: Int, width: Int, height: Int, color: Int) { + draw(color) { + fillRect(x.toDouble(), y.toDouble(), width.toDouble(), height.toDouble()) + } + } + + override fun fill(color: Int) { + fillRect(0, 0, width, height, color) + } + + override fun drawRoundRect( + x: Int, + y: Int, + width: Int, + height: Int, + borderRadius: Int, + color: Int, + thickness: Double, + ) { + drawRect(x, y, width, height, color, 1.0) + } + + override fun fillRoundRect( + x: Int, + y: Int, + width: Int, + height: Int, + borderRadius: Int, + color: Int, + ) { + fillRect(x, y, width, height, color) + } + + override fun drawEllipse(x: Int, y: Int, width: Int, height: Int, color: Int, thickness: Double) { + draw(color) { + val radiusX = width.toDouble() / 2.0 + val radiusY = height.toDouble() / 2.0 + + lineWidth = thickness + beginPath() + ellipse( + radiusX + x.toDouble(), radiusY + y.toDouble(), radiusX, radiusY, 0.0, 0.0, + FULL_CIRCLE, false, + ) + stroke() + } + } + + override fun fillEllipse(x: Int, y: Int, width: Int, height: Int, color: Int) { + draw(color) { + val radiusX = width.toDouble() / 2.0 + val radiusY = height.toDouble() / 2.0 + + beginPath() + ellipse( + radiusX + x.toDouble(), radiusY + y.toDouble(), radiusX, radiusY, 0.0, 0.0, + FULL_CIRCLE, false, + ) + fill() + } + } + + override fun drawImage(rawData: ByteArray?, x: Int, y: Int) { + if (rawData != null && rawData.isNotEmpty()) { + draw(0) { + val imageDataArray: JsArray = rawData.toInt8Array().unsafeCast() + val imageData = ImageData(Uint8ClampedArray(imageDataArray), width) + putImageData(imageData, x.toDouble(), y.toDouble()) + } + } + } + + override fun nativeImage(): Any = canvas + + override fun getBytes(format: String, quality: Int): ByteArray = + canvas.toDataURL(format).substringAfter("data:image/png;base64,").encodeToByteArray() + + private fun rgba(color: Int): String { + val r = (color shr 16) and 0xFF + val g = (color shr 8) and 0xFF + val b = (color shr 0) and 0xFF + val a = ((color shr 24) and 0xFF) / 255.0 + return "rgba($r,$g,$b,$a)" + } + + private fun tryGet(what: () -> T): T = + try { + what() + } catch (t: Throwable) { + throw Error(CANVAS_UNSUPPORTED, cause = t) + } +} diff --git a/src/wasmJsMain/kotlin/qrcode/render/graphics/WasmJsDrawingInterface.kt b/src/wasmJsMain/kotlin/qrcode/render/graphics/WasmJsDrawingInterface.kt new file mode 100644 index 00000000..860f6fd1 --- /dev/null +++ b/src/wasmJsMain/kotlin/qrcode/render/graphics/WasmJsDrawingInterface.kt @@ -0,0 +1,24 @@ +package qrcode.render.graphics + +interface WasmJsDrawingInterface { + fun drawLine(x1: Int, y1: Int, x2: Int, y2: Int, color: Int, thickness: Double) + fun drawRect(x: Int, y: Int, width: Int, height: Int, color: Int, thickness: Double) + fun fillRect(x: Int, y: Int, width: Int, height: Int, color: Int) + fun fill(color: Int) + fun drawRoundRect( + x: Int, + y: Int, + width: Int, + height: Int, + borderRadius: Int, + color: Int, + thickness: Double, + ) + + fun fillRoundRect(x: Int, y: Int, width: Int, height: Int, borderRadius: Int, color: Int) + fun drawEllipse(x: Int, y: Int, width: Int, height: Int, color: Int, thickness: Double) + fun fillEllipse(x: Int, y: Int, width: Int, height: Int, color: Int) + fun drawImage(rawData: ByteArray?, x: Int, y: Int) + fun nativeImage(): Any + fun getBytes(format: String = "PNG", quality: Int = 100): ByteArray +}