diff --git a/README-zh.md b/README-zh.md index e77e5e9..ff8cdb5 100644 --- a/README-zh.md +++ b/README-zh.md @@ -1,11 +1,12 @@ # Diglol Encoding -Diglol Encoding 为 Kotlin Multiplatform 提供了 Hex/Base16、Base32、Base64 编码。 +Diglol Encoding 为 Kotlin Multiplatform 提供了 Hex/Base16、Base32、Base45、Base64 编码。 当前支持的编码: - Hex (Base16) - Base32 [Std, Hex] +- Base45 - Base64 [Std, Url] ### 发布 @@ -44,6 +45,16 @@ println(base32HexString) assert(data.contentEquals(base32HexString.decodeBase32HexToBytes())) ``` +##### [Base45][rfc9285] + +```kotlin +val data = "base-45".encodeToByteArray() +val base45String = data.encodeBase45ToString() +println(base45String) +// 输出:UJCLQE7W581 +assert(data.contentEquals(base45String.decodeBase45ToBytes())) +``` + ##### [Base64][rfc4648] ```kotlin @@ -75,3 +86,4 @@ assert(data.contentEquals(base64UrlString.decodeBase64UrlToBytes())) limitations under the License. [rfc4648]: https://datatracker.ietf.org/doc/html/rfc4648 +[rfc9285]: https://datatracker.ietf.org/doc/html/rfc9285 diff --git a/README.md b/README.md index 00843e9..3cf4dae 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,13 @@ # Diglol Encoding -Diglol Encoding provides Hex/Base16, Base32, Base64 encodings for Kotlin Multiplatform. +Diglol Encoding provides Hex/Base16, Base32, Base45, Base64 encodings for Kotlin Multiplatform. Translations: [中文](README-zh.md) Currently supported encodings: - Hex(Base16) - Base32 (Std, Hex) +- Base45 - Base64 (Std, Url) ### Releases @@ -45,6 +46,16 @@ println(base32HexString) assert(data.contentEquals(base32HexString.decodeBase32HexToBytes())) ``` +##### [Base45][rfc9285] + +```kotlin +val data = "base-45".encodeToByteArray() +val base45String = data.encodeBase45ToString() +println(base45String) +// 输出:UJCLQE7W581 +assert(data.contentEquals(base45String.decodeBase45ToBytes())) +``` + ##### [Base64][rfc4648] ```kotlin @@ -76,3 +87,4 @@ assert(data.contentEquals(base64UrlString.decodeBase64UrlToBytes())) limitations under the License. [rfc4648]: https://datatracker.ietf.org/doc/html/rfc4648 +[rfc9285]: https://datatracker.ietf.org/doc/html/rfc9285 diff --git a/benchmark/src/commonMain/kotlin/diglol/encoding/Base45Benchmark.kt b/benchmark/src/commonMain/kotlin/diglol/encoding/Base45Benchmark.kt new file mode 100644 index 0000000..3a81404 --- /dev/null +++ b/benchmark/src/commonMain/kotlin/diglol/encoding/Base45Benchmark.kt @@ -0,0 +1,44 @@ +package diglol.encoding + +import kotlin.random.Random +import kotlinx.benchmark.Benchmark +import kotlinx.benchmark.BenchmarkMode +import kotlinx.benchmark.BenchmarkTimeUnit +import kotlinx.benchmark.Measurement +import kotlinx.benchmark.Mode +import kotlinx.benchmark.OutputTimeUnit +import kotlinx.benchmark.Param +import kotlinx.benchmark.Scope +import kotlinx.benchmark.Setup +import kotlinx.benchmark.State + +@State(Scope.Benchmark) +@Measurement(iterations = 5, time = 1) +@BenchmarkMode(Mode.AverageTime) +@OutputTimeUnit(BenchmarkTimeUnit.MICROSECONDS) +class Base45Benchmark { + @Param("1024", "10240", "102400") + var dataSize = 0 + private lateinit var data: ByteArray + private lateinit var base45: ByteArray + private lateinit var base45String: String + + @Setup + fun setup() { + data = Random.nextBytes(dataSize) + base45 = data.encodeBase45() + base45String = data.encodeBase45ToString() + } + + @Benchmark + fun encodeBase45() = data.encodeBase45() + + @Benchmark + fun decodeBase45() = base45.decodeBase45() + + @Benchmark + fun encodeBase45ToString() = data.encodeBase45ToString() + + @Benchmark + fun decodeBase45ToBytes() = base45String.decodeBase45ToBytes() +} diff --git a/build.gradle.kts b/build.gradle.kts index f3f659b..821acfc 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -136,7 +136,7 @@ allprojects { publishToMavenCentral(SonatypeHost.S01) signAllPublications() pom { - description.set("Diglol Encoding provides Hex/Base16, Base32, Base64 encodings for Kotlin Multiplatform.") + description.set("Diglol Encoding provides Hex/Base16, Base32, Base45, Base64 encodings for Kotlin Multiplatform.") name.set(project.name) url.set("https://github.com/diglol/encoding/") licenses { diff --git a/encoding/src/androidMain/kotlin/diglol/encoding/base45.kt b/encoding/src/androidMain/kotlin/diglol/encoding/base45.kt new file mode 100644 index 0000000..bef8f0f --- /dev/null +++ b/encoding/src/androidMain/kotlin/diglol/encoding/base45.kt @@ -0,0 +1,12 @@ +@file:JvmName("Base45") + +package diglol.encoding + +import diglol.encoding.internal.commonDecodeBase45 +import diglol.encoding.internal.commonEncodeBase45 + +actual fun ByteArray.encodeBase45(): ByteArray = commonEncodeBase45() +actual fun ByteArray.decodeBase45(): ByteArray? = commonDecodeBase45() + +actual fun ByteArray.encodeBase45ToString(): String = commonEncodeBase45().decodeToString() +actual fun String.decodeBase45ToBytes(): ByteArray? = encodeToByteArray().decodeBase45() diff --git a/encoding/src/commonMain/kotlin/diglol/encoding/base45.kt b/encoding/src/commonMain/kotlin/diglol/encoding/base45.kt new file mode 100644 index 0000000..acb03e6 --- /dev/null +++ b/encoding/src/commonMain/kotlin/diglol/encoding/base45.kt @@ -0,0 +1,7 @@ +package diglol.encoding + +expect fun ByteArray.encodeBase45(): ByteArray +expect fun ByteArray.decodeBase45(): ByteArray? + +expect fun ByteArray.encodeBase45ToString(): String +expect fun String.decodeBase45ToBytes(): ByteArray? diff --git a/encoding/src/commonMain/kotlin/diglol/encoding/internal/commonBase45.kt b/encoding/src/commonMain/kotlin/diglol/encoding/internal/commonBase45.kt new file mode 100644 index 0000000..ea10f9f --- /dev/null +++ b/encoding/src/commonMain/kotlin/diglol/encoding/internal/commonBase45.kt @@ -0,0 +1,121 @@ +package diglol.encoding.internal + +import kotlin.native.concurrent.SharedImmutable + +/** + * Implementation of Base45 encoding/decoding. + * + * Basically, Base45 is just encode 2 bytes based on 256 into 3 bytes based on 45. + * + * It will result in a encoded string which will generate a compat QR code when using alphanumeric mode. + * + * @see [RFC 9285](https://datatracker.ietf.org/doc/rfc9285/) + */ + +@SharedImmutable +private val BASE45 = byteArrayOf( + 48, 49, 50, 51, 52, 53, 54, 55, 56, 57, // 0-9 + 65, 66, 67, 68, 69, 70, 71, 72, 73, 74, // A-J + 75, 76, 77, 78, 79, 80, 81, 82, 83, 84, // K-T + 85, 86, 87, 88, 89, 90, // U-Z + // SP, $, %, *, +, -, ., /, : + 32, 36, 37, 42, 43, 45, 46, 47, 58 +) + +internal fun ByteArray.commonEncodeBase45(): ByteArray { + val outSize = when { + isEmpty() -> 0 + size % 2 == 0 -> size * 3 / 2 + else -> (size - 1) * 3 / 2 + 2 + } + val outBytes = ByteArray(outSize) + val limit = if (size % 2 == 0) size else size - 1 + + var i = 0 + var j = 0 + + while (i < limit) { + val a = this[i].toInt() and 0xff + val b = this[i + 1].toInt() and 0xff + + val n = (a shl 8) + b + + val c = n % 45 + val d = (n / 45) % 45 + val e = ((n / 45) / 45) % 45 + + outBytes[j] = BASE45[c] + outBytes[j + 1] = BASE45[d] + outBytes[j + 2] = BASE45[e] + + i += 2 + j += 3 + } + + if (limit < size) { + val a = this[limit].toInt() and 0xff + val c = a % 45 + val d = (a / 45) % 45 + outBytes[j] = BASE45[c] + outBytes[j + 1] = BASE45[d] + } + + return outBytes +} + +internal fun ByteArray.commonDecodeBase45(): ByteArray? { + if (size % 3 == 1) { + return null + } + + val limit = if (size % 3 == 0) size else size - 2 + val outSize = if (size % 3 == 0) size / 3 * 2 else (size - 2) / 3 * 2 + 1 + + val outBytes = ByteArray(outSize) + + var i = 0 + var j = 0 + + while (i < limit) { + + val c = this[i].toBase45Code() ?: return null + val d = this[i + 1].toBase45Code() ?: return null + val e = this[i + 2].toBase45Code() ?: return null + + val n = c + d * 45 + e * 45 * 45 + + val a = n ushr 8 + val b = n and 0xff + + outBytes[j] = a.toByte() + outBytes[j + 1] = b.toByte() + + i += 3 + j += 2 + } + + if (limit < size) { + val c = this[limit].toBase45Code() ?: return null + val d = this[limit + 1].toBase45Code() ?: return null + val a = c + d * 45 + outBytes[j] = a.toByte() + } + + return outBytes +} + +/** + * Converts a char's byte code to a Base45 lookup table index. + */ +private fun Byte.toBase45Code(): Int? { + return when (this.toInt() and 0xff) { + 32 -> 36 + 58 -> 44 + 36, 37 -> this + 1 + 42, 43 -> this - 3 + 45, 46, 47 -> this - 4 + in '0'.code..'9'.code -> this - '0'.code + in 'A'.code..'Z'.code -> this - 'A'.code + 10 + else -> null + } +} diff --git a/encoding/src/commonTest/kotlin/diglol/encoding/Base45Test.kt b/encoding/src/commonTest/kotlin/diglol/encoding/Base45Test.kt new file mode 100644 index 0000000..1a8fd0c --- /dev/null +++ b/encoding/src/commonTest/kotlin/diglol/encoding/Base45Test.kt @@ -0,0 +1,71 @@ +package diglol.encoding + +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals + +class Base45Test { + + private val sample = mapOf( + "AB" to "BB8", + "Hello!!" to "%69 VD92EX0", + "base-45" to "UJCLQE7W581", + "ietf!" to "QED8WEX0", + """the quick brown fox jumps over the lazy dog THE QUICK BROWN FOX JUMPS OVER THE LAZY DOG 1234567890-=[];',./\!@#$%^&*()_+{}|:"<>?""" to + """AWEDZCKFEOEDJOD2KC54EM-DX.CH8FSKDQ${'$'}D.OE44E5${'$'}CS44+8DK44OEC3EFGVCU1DLTABX8VCAZB9HM9DH8G1AK*9:*8F6B*H9${'$'}Y9+MAF1AGY8-34669X34ZB81CBRS8S:8*96DL6WW66:6FA7GW5YOBNL7FQ5J:5794-J4QW45${'$'}4L35I1CNRFWVFYE45*7""", + ) + + private val sampleBytes = sample.map { (origin, encoded) -> + origin.encodeToByteArray() to encoded.encodeToByteArray() + }.toMap() + + private val invalidSample = mapOf( + "|~{}" to null, + 0x0.toChar().toString() to null, + ) + + @Test + fun testEncodeBase45() { + sampleBytes.forEach { (origin, expectedEncoded) -> + ignoreNpe { + assertContentEquals(expectedEncoded, origin.encodeBase45()) + } + } + } + + @Test + fun testEncodedBase45String() { + sample.forEach { (origin, expected) -> + ignoreNpe { + assertEquals(expected, origin.encodeToByteArray().encodeBase45ToString()) + } + } + } + + @Test + fun testDecodeBase45() { + sampleBytes.forEach { (expectedOrigin, encoded) -> + ignoreNpe { + assertContentEquals(expectedOrigin, encoded.decodeBase45()) + } + } + } + + @Test + fun testDecodeBase45ToBytes() { + sample.forEach { (expectedOrigin, encoded) -> + ignoreNpe { + assertContentEquals(expectedOrigin.encodeToByteArray(), encoded.decodeBase45ToBytes()) + } + } + } + + @Test + fun testInvalidDecodeBase45() { + invalidSample.forEach { (encoded, expectedOrigin) -> + ignoreNpe { + assertContentEquals(expectedOrigin, encoded.decodeBase45ToBytes()) + } + } + } +} diff --git a/encoding/src/jsMain/kotlin/diglol/encoding/base45.kt b/encoding/src/jsMain/kotlin/diglol/encoding/base45.kt new file mode 100644 index 0000000..020eb5a --- /dev/null +++ b/encoding/src/jsMain/kotlin/diglol/encoding/base45.kt @@ -0,0 +1,15 @@ +@file:OptIn(InternalApi::class) + +package diglol.encoding + +import diglol.encoding.internal.commonDecodeBase45 +import diglol.encoding.internal.commonEncodeBase45 +import diglol.encoding.internal.jsDecodeToString +import diglol.encoding.internal.jsEncodeToByteArray + +actual fun ByteArray.encodeBase45(): ByteArray = commonEncodeBase45() + +actual fun ByteArray.encodeBase45ToString(): String = commonEncodeBase45().jsDecodeToString() + +actual fun ByteArray.decodeBase45(): ByteArray? = commonDecodeBase45() +actual fun String.decodeBase45ToBytes(): ByteArray? = jsEncodeToByteArray().decodeBase45() diff --git a/encoding/src/jvmMain/kotlin/diglol/encoding/base45.kt b/encoding/src/jvmMain/kotlin/diglol/encoding/base45.kt new file mode 100644 index 0000000..bef8f0f --- /dev/null +++ b/encoding/src/jvmMain/kotlin/diglol/encoding/base45.kt @@ -0,0 +1,12 @@ +@file:JvmName("Base45") + +package diglol.encoding + +import diglol.encoding.internal.commonDecodeBase45 +import diglol.encoding.internal.commonEncodeBase45 + +actual fun ByteArray.encodeBase45(): ByteArray = commonEncodeBase45() +actual fun ByteArray.decodeBase45(): ByteArray? = commonDecodeBase45() + +actual fun ByteArray.encodeBase45ToString(): String = commonEncodeBase45().decodeToString() +actual fun String.decodeBase45ToBytes(): ByteArray? = encodeToByteArray().decodeBase45() diff --git a/encoding/src/nativeMain/kotlin/diglol/encoding/base45.kt b/encoding/src/nativeMain/kotlin/diglol/encoding/base45.kt new file mode 100644 index 0000000..33a7755 --- /dev/null +++ b/encoding/src/nativeMain/kotlin/diglol/encoding/base45.kt @@ -0,0 +1,10 @@ +package diglol.encoding + +import diglol.encoding.internal.commonDecodeBase45 +import diglol.encoding.internal.commonEncodeBase45 + +actual fun ByteArray.encodeBase45(): ByteArray = commonEncodeBase45() +actual fun ByteArray.decodeBase45(): ByteArray? = commonDecodeBase45() + +actual fun ByteArray.encodeBase45ToString(): String = commonEncodeBase45().decodeToString() +actual fun String.decodeBase45ToBytes(): ByteArray? = encodeToByteArray().decodeBase45()