better UI, +windows1251 encoding support

This commit is contained in:
sergeych committed 2026-05-18 00:14:34 +03:00
1 parent ddf68c1e6a
commit da88a6e221
15 files changed
+407 -14

No files matched your search

@@ -10,7 +10,7 @@ object Fb2Format {
val xml = if (looksLikeZip(input) || fileName?.endsWith(".zip", ignoreCase = true) == true) {
Fb2Zip.extractFb2Xml(input)
} else {
input.decodeToString()
Fb2XmlEncoding.decodeXml(input)
}
return parseXml(xml)
}
@@ -0,0 +1,103 @@
package net.sergeych.toread.fb2
internal object Fb2XmlEncoding {
private val EncodingPattern = Regex("""encoding\s*=\s*["']([^"']+)["']""", RegexOption.IGNORE_CASE)
fun decodeXml(bytes: ByteArray): String =
when (declaredEncoding(bytes)?.lowercase()) {
"windows-1251" -> decodeWindows1251(bytes)
else -> bytes.decodeToString()
}
private fun declaredEncoding(bytes: ByteArray): String? {
val probe = bytes
.copyOfRange(0, minOf(bytes.size, 256))
.map { byte ->
val value = byte.toInt() and 0xff
if (value in 0x20..0x7e || value == '\n'.code || value == '\r'.code || value == '\t'.code) {
value.toChar()
} else {
' '
}
}
.joinToString("")
return EncodingPattern.find(probe)?.groupValues?.getOrNull(1)?.trim()
}
private fun decodeWindows1251(bytes: ByteArray): String = buildString(bytes.size) {
bytes.forEach { byte ->
append(windows1251Char(byte.toInt() and 0xff))
}
}
private fun windows1251Char(value: Int): Char =
when (value) {
in 0x00..0x7f -> value.toChar()
0x80 -> '\u0402'
0x81 -> '\u0403'
0x82 -> '\u201a'
0x83 -> '\u0453'
0x84 -> '\u201e'
0x85 -> '\u2026'
0x86 -> '\u2020'
0x87 -> '\u2021'
0x88 -> '\u20ac'
0x89 -> '\u2030'
0x8a -> '\u0409'
0x8b -> '\u2039'
0x8c -> '\u040a'
0x8d -> '\u040c'
0x8e -> '\u040b'
0x8f -> '\u040f'
0x90 -> '\u0452'
0x91 -> '\u2018'
0x92 -> '\u2019'
0x93 -> '\u201c'
0x94 -> '\u201d'
0x95 -> '\u2022'
0x96 -> '\u2013'
0x97 -> '\u2014'
0x98 -> '\uFFFD'
0x99 -> '\u2122'
0x9a -> '\u0459'
0x9b -> '\u203a'
0x9c -> '\u045a'
0x9d -> '\u045c'
0x9e -> '\u045b'
0x9f -> '\u045f'
0xa0 -> '\u00a0'
0xa1 -> '\u040e'
0xa2 -> '\u045e'
0xa3 -> '\u0408'
0xa4 -> '\u00a4'
0xa5 -> '\u0490'
0xa6 -> '\u00a6'
0xa7 -> '\u00a7'
0xa8 -> '\u0401'
0xa9 -> '\u00a9'
0xaa -> '\u0404'
0xab -> '\u00ab'
0xac -> '\u00ac'
0xad -> '\u00ad'
0xae -> '\u00ae'
0xaf -> '\u0407'
0xb0 -> '\u00b0'
0xb1 -> '\u00b1'
0xb2 -> '\u0406'
0xb3 -> '\u0456'
0xb4 -> '\u0491'
0xb5 -> '\u00b5'
0xb6 -> '\u00b6'
0xb7 -> '\u00b7'
0xb8 -> '\u0451'
0xb9 -> '\u2116'
0xba -> '\u0454'
0xbb -> '\u00bb'
0xbc -> '\u0458'
0xbd -> '\u0405'
0xbe -> '\u0455'
0xbf -> '\u0457'
in 0xc0..0xff -> (0x0410 + value - 0xc0).toChar()
else -> '\uFFFD'
}
}
@@ -12,7 +12,7 @@ internal object Fb2Zip {
val entry = entries.firstOrNull { it.name.endsWith(".fb2", ignoreCase = true) }
?: entries.firstOrNull { !it.name.endsWith("/") }
?: throw Fb2ParseException("ZIP archive does not contain an FB2 entry")
return readEntry(zip, entry).decodeToString()
return Fb2XmlEncoding.decodeXml(readEntry(zip, entry))
}
fun createStoredZip(entryName: String, content: ByteArray): ByteArray {
@@ -43,6 +43,31 @@ class Fb2FormatTest {
assertTrue(zip.copyOfRange(0, 4).contentEquals(byteArrayOf(0x50, 0x4b, 0x03, 0x04)))
}
@Test
fun parsesWindows1251PlainXml() {
val book = Fb2Format.parse(windows1251Xml.encodeWindows1251(), "legacy.fb2")
assertEquals("Тестовая книга", book.title)
assertEquals("Привет, мир.", book.sections.single().paragraphs.single())
}
@Test
fun parsesWindows1251StoredZip() {
val zip = Fb2Zip.createStoredZip("legacy.fb2", windows1251Xml.encodeWindows1251())
val book = Fb2Format.parse(zip, "legacy.fb2.zip")
assertEquals("Тестовая книга", book.title)
assertEquals("Привет, мир.", book.sections.single().paragraphs.single())
}
@Test
fun fallsBackToUtf8ForUnknownEncoding() {
val xml = sampleXml.replace("encoding=\"UTF-8\"", "encoding=\"KOI8-R\"")
val book = Fb2Format.parse(xml.encodeToByteArray(), "unknown.fb2")
assertEquals("The Test Book", book.title)
}
@Test
fun preservesReadableBlocksAndInlineStyles() {
val book = Fb2Format.parseXml(richXml)
@@ -108,4 +133,37 @@ class Fb2FormatTest {
</body>
</FictionBook>
""".trimIndent()
private val windows1251Xml = """
<?xml version="1.0" encoding="windows-1251"?>
<FictionBook xmlns="http://www.gribuser.ru/xml/fictionbook/2.0" xmlns:xlink="http://www.w3.org/1999/xlink">
<description>
<title-info>
<author><nickname>Автор</nickname></author>
<book-title>Тестовая книга</book-title>
<lang>ru</lang>
</title-info>
<document-info>
<author><nickname>Toread</nickname></author>
<date>2026-05-12</date>
<id>legacy</id>
<version>1.0</version>
</document-info>
</description>
<body><section><p>Привет, мир.</p></section></body>
</FictionBook>
""".trimIndent()
private fun String.encodeWindows1251(): ByteArray =
ByteArray(length) { index ->
val char = this[index]
val value = when {
char.code <= 0x7f -> char.code
char in 'А'..'я' -> 0xc0 + char.code - 'А'.code
char == 'Ё' -> 0xa8
char == 'ё' -> 0xb8
else -> error("Test character $char is not mapped to windows-1251")
}
value.toByte()
}
}