Skip to content

Commit 868b12b

Browse files
committed
feat: add UnicodeRangeAlphabet and CharAlphabet interfaces with tests to enhance character encoding options
1 parent f6f4740 commit 868b12b

2 files changed

Lines changed: 109 additions & 19 deletions

File tree

src/commonMain/kotlin/com/eignex/kencode/BaseRadix.kt

Lines changed: 60 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -18,30 +18,74 @@ object Base62 : BaseRadix(BASE_62)
1818
object Base36 : BaseRadix(BASE_62.take(36))
1919

2020
/**
21-
* Generic base-N encoder/decoder for binary data using arbitrary alphabets and block processing.
21+
* Maps indices to characters (encoding) and characters back to indices (decoding).
22+
* [indexOf] returns -1 for characters not in the alphabet.
2223
*/
23-
open class BaseRadix(private val alphabet: String, val blockSize: Int = 32) :
24-
ByteEncoding {
24+
interface Alphabet {
25+
val size: Int
26+
operator fun get(index: Int): Char
27+
fun indexOf(c: Char): Int
28+
}
2529

30+
/**
31+
* Alphabet backed by an explicit string of characters.
32+
*/
33+
class CharAlphabet(private val chars: String) : Alphabet {
34+
init {
35+
require(chars.length > 1) { "Alphabet must contain at least 2 characters." }
36+
require(chars.toSet().size == chars.length) { "Alphabet must not contain duplicate characters." }
37+
}
38+
39+
override val size: Int = chars.length
40+
private val maxCode: Int = chars.maxOf { it.code }
41+
private val inverse: IntArray = IntArray(maxCode + 1) { -1 }.also { lookup ->
42+
chars.forEachIndexed { index, c -> lookup[c.code] = index }
43+
}
44+
45+
override fun get(index: Int): Char = chars[index]
46+
override fun indexOf(c: Char): Int = if (c.code <= maxCode) inverse[c.code] else -1
47+
}
48+
49+
/**
50+
* Alphabet backed by a contiguous Unicode range starting at [start].
51+
* Defaults to U+0020 – U+D7FF (55,264 characters), the largest BMP range
52+
* that avoids surrogate code points.
53+
*/
54+
class UnicodeRangeAlphabet(
55+
private val start: Int = 0x0020,
56+
override val size: Int = 0xD800 - 0x0020
57+
) : Alphabet {
2658
init {
27-
require(alphabet.length > 1) { "Alphabet must contain at least 2 characters." }
28-
require(alphabet.toSet().size == alphabet.length) { "Alphabet must not contain duplicate characters." }
59+
require(size > 1) { "Alphabet must contain at least 2 characters." }
60+
require(start >= 0) { "Unicode range start must be non-negative." }
61+
require(start + size <= 0xD800 || start >= 0xE000) {
62+
"Unicode range must not overlap surrogate block (U+D800–U+DFFF)."
63+
}
64+
require(start + size <= 0x110000) { "Unicode range out of bounds." }
2965
}
3066

31-
private val alphabetSize: Int = alphabet.length
32-
private val base: BigInteger = BigInteger.fromLong(alphabetSize.toLong())
33-
private val logBase: Double = log2(alphabetSize.toDouble())
67+
override fun get(index: Int): Char = (start + index).toChar()
68+
override fun indexOf(c: Char): Int {
69+
val offset = c.code - start
70+
return if (offset in 0 until size) offset else -1
71+
}
72+
}
73+
74+
/**
75+
* Generic base-N encoder/decoder for binary data using arbitrary alphabets and block processing.
76+
*/
77+
open class BaseRadix(private val alphabet: Alphabet, val blockSize: Int = 32) :
78+
ByteEncoding {
79+
80+
constructor(chars: String, blockSize: Int = 32) : this(CharAlphabet(chars), blockSize)
81+
82+
private val base: BigInteger = BigInteger.fromLong(alphabet.size.toLong())
83+
private val logBase: Double = log2(alphabet.size.toDouble())
3484

3585
private val bigZero = BigInteger.ZERO
3686
private val bigFF = BigInteger.fromLong(0xFFL)
3787
private val zeroChar: Char get() = alphabet[0]
3888

39-
private val maxAlphabetChar: Int = alphabet.maxOf { it.code }
40-
private val inverseAlphabet: IntArray =
41-
IntArray(maxAlphabetChar + 1) { -1 }.also { lookup ->
42-
alphabet.forEachIndexed { index, c -> lookup[c.code] = index }
43-
}
44-
4589
private val lengths: IntArray = IntArray(blockSize) { blockIndex ->
4690
val bytesCount = blockIndex + 1
4791
ceil((bytesCount * 8) / logBase).toInt()
@@ -136,8 +180,7 @@ open class BaseRadix(private val alphabet: String, val blockSize: Int = 32) :
136180
var writeIndex = output.length - 1
137181
while (n > bigZero && writeIndex >= startPos) {
138182
val remainder = n.rem(base)
139-
output[writeIndex--] =
140-
alphabet[remainder.intValue(exactRequired = false)]
183+
output[writeIndex--] = alphabet[remainder.intValue(exactRequired = false)]
141184
n /= base
142185
}
143186
return output
@@ -153,9 +196,7 @@ open class BaseRadix(private val alphabet: String, val blockSize: Int = 32) :
153196
): ByteArray {
154197
var n = bigZero
155198
for (i in inPos until (inPos + inLen)) {
156-
val code = input[i].code
157-
val index =
158-
if (code < inverseAlphabet.size) inverseAlphabet[code] else -1
199+
val index = alphabet.indexOf(input[i])
159200
require(index >= 0) { "Not an encoding char: '${input[i]}'" }
160201
n = n * base + BigInteger.fromLong(index.toLong())
161202
}

src/commonTest/kotlin/com/eignex/kencode/BaseRadixTest.kt

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -214,4 +214,53 @@ class BaseRadixTest {
214214
)
215215
}
216216
}
217+
218+
@Test
219+
fun `UnicodeRangeAlphabet default roundtrips for various lengths`() {
220+
val codec = BaseRadix(UnicodeRangeAlphabet())
221+
val rng = Random(1111L)
222+
for (len in 0..(codec.blockSize * 2)) {
223+
val bytes = ByteArray(len).also { rng.nextBytes(it) }
224+
assertRoundtrip(codec, bytes, "UnicodeRange default roundtrip failed for len=$len")
225+
}
226+
}
227+
228+
@Test
229+
fun `UnicodeRangeAlphabet produces shorter output than Base62`() {
230+
val unicode = BaseRadix(UnicodeRangeAlphabet())
231+
val bytes = ByteArray(32) { it.toByte() }
232+
assertTrue(unicode.encode(bytes).length < Base62.encode(bytes).length)
233+
}
234+
235+
@Test
236+
fun `UnicodeRangeAlphabet custom range roundtrips`() {
237+
// Narrow range: Greek letters U+0391–U+03C9 (57 chars)
238+
val greek = BaseRadix(UnicodeRangeAlphabet(start = 0x0391, size = 57))
239+
val rng = Random(2222L)
240+
for (len in 0..greek.blockSize) {
241+
val bytes = ByteArray(len).also { rng.nextBytes(it) }
242+
assertRoundtrip(greek, bytes, "Greek range roundtrip failed for len=$len")
243+
}
244+
}
245+
246+
@Test
247+
fun `UnicodeRangeAlphabet indexOf returns -1 for out-of-range char`() {
248+
val alpha = UnicodeRangeAlphabet(start = 0x0391, size = 57)
249+
assertEquals(-1, alpha.indexOf('A'))
250+
assertEquals(-1, alpha.indexOf('\u0000'))
251+
}
252+
253+
@Test
254+
fun `UnicodeRangeAlphabet rejects surrogate overlap`() {
255+
assertFailsWith<IllegalArgumentException> {
256+
UnicodeRangeAlphabet(start = 0xD000, size = 0x1000)
257+
}
258+
}
259+
260+
@Test
261+
fun `UnicodeRangeAlphabet rejects size less than 2`() {
262+
assertFailsWith<IllegalArgumentException> {
263+
UnicodeRangeAlphabet(start = 0x0020, size = 1)
264+
}
265+
}
217266
}

0 commit comments

Comments
 (0)