better UI, +windows1251 encoding support

This commit is contained in:
Sergey Chernov 2026-05-18 00:14:34 +03:00
parent ddf68c1e6a
commit da88a6e221
15 changed files with 407 additions and 14 deletions

View File

@ -268,6 +268,37 @@ actual suspend fun markLibraryReadingStatus(fileId: String, status: BookReadingS
} }
} }
actual suspend fun shareLibraryBookFile(fileId: String): Boolean = withContext(Dispatchers.IO) {
runCatching {
val shareFile = openLibraryDatabase().useLibrary { db ->
val file = db.files.getLibraryFile(fileId) ?: return@useLibrary null
val bytes = readStorageUriBytes(file.storageUri ?: return@useLibrary null) ?: return@useLibrary null
val shareDir = File(appContext.cacheDir, "shared-books").also { it.mkdirs() }
File(shareDir, file.shareFileName()).also { it.writeBytes(bytes) }
} ?: return@withContext false
val uri = FileProvider.getUriForFile(
appContext,
"${appContext.packageName}.imageviewer.fileprovider",
shareFile,
)
val mimeType = shareFile.bookMimeType()
val intent = Intent(Intent.ACTION_SEND).apply {
type = mimeType
putExtra(Intent.EXTRA_STREAM, uri)
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
val chooser = Intent.createChooser(intent, "Share book").apply {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
appContext.startActivity(chooser)
true
}.getOrDefault(false)
}
actual suspend fun viewLibraryBookFile(fileId: String): Boolean = false
actual suspend fun loadBookInfoExtras(fileId: String): BookInfoExtras = withContext(Dispatchers.IO) { actual suspend fun loadBookInfoExtras(fileId: String): BookInfoExtras = withContext(Dispatchers.IO) {
openLibraryDatabase().useLibrary { db -> openLibraryDatabase().useLibrary { db ->
val file = db.files.get(fileId) ?: return@useLibrary BookInfoExtras() val file = db.files.get(fileId) ?: return@useLibrary BookInfoExtras()
@ -528,6 +559,26 @@ private fun String.requiresExternalFileAccess(): Boolean {
private fun String.isSupportedBookFile(): Boolean = private fun String.isSupportedBookFile(): Boolean =
endsWith(".fb2", ignoreCase = true) || endsWith(".fb2.zip", ignoreCase = true) endsWith(".fb2", ignoreCase = true) || endsWith(".fb2.zip", ignoreCase = true)
private fun LibraryFileRecord.shareFileName(): String {
val raw = originalFilename?.takeIf { it.isNotBlank() }
?: title?.takeIf { it.isNotBlank() }?.let { title ->
val extension = when {
format.equals("fb2.zip", ignoreCase = true) || storageUri?.endsWith(".fb2.zip", ignoreCase = true) == true -> ".fb2.zip"
else -> ".fb2"
}
"$title$extension"
}
?: "book.fb2"
return raw.replace(Regex("""[\\/:*?"<>|]+"""), "_")
}
private fun File.bookMimeType(): String =
if (name.endsWith(".zip", ignoreCase = true)) {
"application/zip"
} else {
"application/x-fictionbook+xml"
}
private fun displayNameFor(uri: Uri): String = private fun displayNameFor(uri: Uri): String =
appContext.contentResolver.query(uri, arrayOf(OpenableColumns.DISPLAY_NAME), null, null, null)?.use { cursor -> appContext.contentResolver.query(uri, arrayOf(OpenableColumns.DISPLAY_NAME), null, null, null)?.use { cursor ->
if (cursor.moveToFirst()) cursor.getString(0) else null if (cursor.moveToFirst()) cursor.getString(0) else null

View File

@ -1,4 +1,5 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<paths xmlns:android="http://schemas.android.com/apk/res/android"> <paths xmlns:android="http://schemas.android.com/apk/res/android">
<cache-path name="clipboard_images" path="clipboard-images/"/> <cache-path name="clipboard_images" path="clipboard-images/"/>
<cache-path name="shared_books" path="shared-books/"/>
</paths> </paths>

View File

@ -166,6 +166,9 @@ private fun BookReaderApp(onThemeToggle: () -> Unit) {
message = current.message, message = current.message,
) )
}, },
onDeleted = { message ->
state = AppState.Library(emptyList(), current.scanPath, message)
},
onBack = { onBack = {
state = AppState.Library(emptyList(), current.scanPath, current.message) state = AppState.Library(emptyList(), current.scanPath, current.message)
}, },

View File

@ -0,0 +1,14 @@
package net.sergeych.toread
internal data class LibraryDeleteResult(
val deleted: Boolean,
val message: String,
)
internal suspend fun deleteLibraryBook(fileId: String, title: String): LibraryDeleteResult {
val deleted = runCatching { deleteLibraryItem(fileId) }.getOrDefault(false)
return LibraryDeleteResult(
deleted = deleted,
message = if (deleted) "Removed $title." else "Could not remove $title.",
)
}

View File

@ -108,6 +108,10 @@ expect suspend fun saveLibraryReadingPosition(fileId: String, position: ReadingP
expect suspend fun markLibraryReadingStatus(fileId: String, status: BookReadingStatus): Boolean expect suspend fun markLibraryReadingStatus(fileId: String, status: BookReadingStatus): Boolean
expect suspend fun shareLibraryBookFile(fileId: String): Boolean
expect suspend fun viewLibraryBookFile(fileId: String): Boolean
expect suspend fun loadBookInfoExtras(fileId: String): BookInfoExtras expect suspend fun loadBookInfoExtras(fileId: String): BookInfoExtras
expect suspend fun loadActiveReadingFileId(): String? expect suspend fun loadActiveReadingFileId(): String?

View File

@ -364,9 +364,9 @@ internal fun LibraryScreen(
scope.launch { scope.launch {
busy = true busy = true
try { try {
val deleted = runCatching { deleteLibraryItem(item.fileId) }.getOrDefault(false) val result = deleteLibraryBook(item.fileId, item.title)
message = if (deleted) "Removed ${item.title}." else "Could not remove ${item.title}." message = result.message
if (deleted) { if (result.deleted) {
items = items.filterNot { it.fileId == item.fileId } items = items.filterNot { it.fileId == item.fileId }
searchResults = searchResults.filterNot { it.fileId == item.fileId } searchResults = searchResults.filterNot { it.fileId == item.fileId }
coverCache.remove(item.fileId) coverCache.remove(item.fileId)

View File

@ -90,9 +90,9 @@ internal fun ContinuousBookReader(
val hyphenation = remember { HyphenationRegistry() } val hyphenation = remember { HyphenationRegistry() }
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val contentPadding = if (isAndroidPlatform()) { val contentPadding = if (isAndroidPlatform()) {
PaddingValues(start = 6.dp, top = 6.dp, end = 0.dp, bottom = 6.dp) PaddingValues(start = 0.dp, top = 6.dp, end = 0.dp, bottom = 6.dp)
} else { } else {
PaddingValues(horizontal = 8.dp, vertical = 6.dp) PaddingValues(horizontal = 4.dp, vertical = 6.dp)
} }
LazyColumn( LazyColumn(
@ -461,8 +461,8 @@ private fun ReaderText(
@Composable @Composable
private fun readerParagraphTextStyle(language: String?): TextStyle = private fun readerParagraphTextStyle(language: String?): TextStyle =
MaterialTheme.typography.bodyLarge.copy( MaterialTheme.typography.bodyLarge.copy(
fontWeight = FontWeight(350), fontWeight = if( isAndroidPlatform()) FontWeight(350) else FontWeight.Normal,
fontSize = 21.sp, fontSize = if( isAndroidPlatform()) 21.sp else 18.sp,
lineHeight = 28.sp, lineHeight = 28.sp,
hyphens = if (isAndroidPlatform()) Hyphens.Auto else Hyphens.Unspecified, hyphens = if (isAndroidPlatform()) Hyphens.Auto else Hyphens.Unspecified,
lineBreak = if (isAndroidPlatform()) LineBreak.Paragraph else LineBreak.Unspecified, lineBreak = if (isAndroidPlatform()) LineBreak.Paragraph else LineBreak.Unspecified,

View File

@ -12,13 +12,19 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.VolumeUp import androidx.compose.material.icons.automirrored.filled.VolumeUp
import androidx.compose.material.icons.filled.Info import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.Palette import androidx.compose.material.icons.filled.Palette
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarDuration
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
@ -47,13 +53,36 @@ internal fun BookView(
onImageOpen: (ViewedBookImage) -> Unit, onImageOpen: (ViewedBookImage) -> Unit,
onThemeToggle: () -> Unit, onThemeToggle: () -> Unit,
onBookInfo: () -> Unit, onBookInfo: () -> Unit,
onDeleted: (String) -> Unit,
onBack: () -> Unit, onBack: () -> Unit,
) { ) {
val stats = remember(book) { BookStats.from(book) } val stats = remember(book) { BookStats.from(book) }
val listState = rememberLazyListState() val listState = rememberLazyListState()
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val snackbarHostState = remember { SnackbarHostState() }
var restored by remember(fileId) { mutableStateOf(false) } var restored by remember(fileId) { mutableStateOf(false) }
var markedRead by remember(fileId) { mutableStateOf(false) } var markedRead by remember(fileId) { mutableStateOf(false) }
val platformName = getPlatform().name
val showShareAction = platformName.startsWith("Android")
val showViewFileAction = platformName.startsWith("Java")
fun showMessage(message: String) {
scope.launch {
snackbarHostState.showSnackbar(message, duration = SnackbarDuration.Short)
}
}
fun setReadingStatus(status: BookReadingStatus, successMessage: String) {
scope.launch {
if (markLibraryReadingStatus(fileId, status)) {
if (status == BookReadingStatus.READ) markedRead = true
if (status == BookReadingStatus.NEW) markedRead = false
showMessage(successMessage)
} else {
showMessage("Could not update book.")
}
}
}
LaunchedEffect(fileId) { LaunchedEffect(fileId) {
markLibraryReadingStatus(fileId, BookReadingStatus.READING) markLibraryReadingStatus(fileId, BookReadingStatus.READING)
@ -91,6 +120,7 @@ internal fun BookView(
Scaffold( Scaffold(
contentWindowInsets = WindowInsets(0, 0, 0, 0), contentWindowInsets = WindowInsets(0, 0, 0, 0),
snackbarHost = { SnackbarHost(snackbarHostState) },
topBar = { topBar = {
CompactReaderTopBar( CompactReaderTopBar(
title = book.title, title = book.title,
@ -104,6 +134,40 @@ internal fun BookView(
onBookInfo() onBookInfo()
} }
}, },
onMarkAsRead = {
setReadingStatus(BookReadingStatus.READ, "Marked as read.")
},
onNotInterested = {
setReadingStatus(BookReadingStatus.NOT_INTERESTED, "Marked as not interested.")
},
onClearMarks = {
setReadingStatus(BookReadingStatus.NEW, "Cleared marks.")
},
showShareAction = showShareAction,
onShare = {
scope.launch {
val shared = shareLibraryBookFile(fileId)
showMessage(if (shared) "Share opened." else "Could not share book.")
}
},
showViewFileAction = showViewFileAction,
onViewFile = {
scope.launch {
val opened = viewLibraryBookFile(fileId)
showMessage(if (opened) "Opened file location." else "Could not open file location.")
}
},
onDelete = {
scope.launch {
val result = deleteLibraryBook(fileId, book.title)
if (result.deleted) {
saveActiveReadingFileId(null)
onDeleted(result.message)
} else {
showMessage(result.message)
}
}
},
onBack = { onBack = {
scope.launch { scope.launch {
saveLibraryReadingPosition( saveLibraryReadingPosition(
@ -139,8 +203,18 @@ private fun CompactReaderTopBar(
title: String, title: String,
onThemeToggle: () -> Unit, onThemeToggle: () -> Unit,
onBookInfo: () -> Unit, onBookInfo: () -> Unit,
onMarkAsRead: () -> Unit,
onNotInterested: () -> Unit,
onClearMarks: () -> Unit,
showShareAction: Boolean,
onShare: () -> Unit,
showViewFileAction: Boolean,
onViewFile: () -> Unit,
onDelete: () -> Unit,
onBack: () -> Unit, onBack: () -> Unit,
) { ) {
var menuOpen by remember { mutableStateOf(false) }
ThemedTopBarSurface { ThemedTopBarSurface {
Row( Row(
modifier = Modifier.fillMaxWidth().height(48.dp), modifier = Modifier.fillMaxWidth().height(48.dp),
@ -158,12 +232,74 @@ private fun CompactReaderTopBar(
IconButton(onClick = onThemeToggle) { IconButton(onClick = onThemeToggle) {
Icon(Icons.Filled.Palette, contentDescription = "Theme") Icon(Icons.Filled.Palette, contentDescription = "Theme")
} }
IconButton(onClick = onBookInfo) {
Icon(Icons.Filled.Info, contentDescription = "Properties")
}
IconButton(onClick = { }) { IconButton(onClick = { }) {
Icon(Icons.AutoMirrored.Filled.VolumeUp, contentDescription = "Read aloud") Icon(Icons.AutoMirrored.Filled.VolumeUp, contentDescription = "Read aloud")
} }
Box {
IconButton(onClick = { menuOpen = true }) {
Icon(Icons.Filled.MoreVert, contentDescription = "Book reader menu")
}
DropdownMenu(expanded = menuOpen, onDismissRequest = { menuOpen = false }) {
DropdownMenuItem(
text = { Text("Info...") },
onClick = {
menuOpen = false
onBookInfo()
},
)
HorizontalDivider()
DropdownMenuItem(
text = { Text("Mark as read") },
onClick = {
menuOpen = false
onMarkAsRead()
},
)
DropdownMenuItem(
text = { Text("Not interested") },
onClick = {
menuOpen = false
onNotInterested()
},
)
DropdownMenuItem(
text = { Text("Clear marks") },
onClick = {
menuOpen = false
onClearMarks()
},
)
if (showShareAction || showViewFileAction) {
HorizontalDivider()
}
if (showShareAction) {
DropdownMenuItem(
text = { Text("Share") },
onClick = {
menuOpen = false
onShare()
},
)
}
if (showViewFileAction) {
DropdownMenuItem(
text = { Text("View file") },
onClick = {
menuOpen = false
onViewFile()
},
)
}
HorizontalDivider()
DropdownMenuItem(
text = { Text("Delete") },
onClick = {
menuOpen = false
onDelete()
},
)
}
}
} }
} }
} }

View File

@ -10,6 +10,7 @@ import net.sergeych.toread.storage.ReadingStateRecord
import net.sergeych.toread.storage.jdbc.H2LibraryDatabase import net.sergeych.toread.storage.jdbc.H2LibraryDatabase
import net.sergeych.toread.storage.jdbc.LibraryScanner import net.sergeych.toread.storage.jdbc.LibraryScanner
import org.jetbrains.skia.Image import org.jetbrains.skia.Image
import java.awt.Desktop
import java.awt.Toolkit import java.awt.Toolkit
import java.awt.datatransfer.DataFlavor import java.awt.datatransfer.DataFlavor
import java.awt.datatransfer.Transferable import java.awt.datatransfer.Transferable
@ -218,6 +219,24 @@ actual suspend fun markLibraryReadingStatus(fileId: String, status: BookReadingS
} }
} }
actual suspend fun shareLibraryBookFile(fileId: String): Boolean = false
actual suspend fun viewLibraryBookFile(fileId: String): Boolean = withContext(Dispatchers.IO) {
runCatching {
if (!Desktop.isDesktopSupported()) return@withContext false
val file = openLibraryDatabase().useLibrary { db ->
db.files.get(fileId)?.storageUri?.let(::File)?.takeIf { it.isFile }
} ?: return@withContext false
val desktop = Desktop.getDesktop()
if (desktop.isSupported(Desktop.Action.BROWSE_FILE_DIR)) {
desktop.browseFileDirectory(file)
} else {
desktop.open(file.parentFile ?: file)
}
true
}.getOrDefault(false)
}
actual suspend fun loadBookInfoExtras(fileId: String): BookInfoExtras = withContext(Dispatchers.IO) { actual suspend fun loadBookInfoExtras(fileId: String): BookInfoExtras = withContext(Dispatchers.IO) {
openLibraryDatabase().useLibrary { db -> openLibraryDatabase().useLibrary { db ->
val file = db.files.get(fileId) ?: return@useLibrary BookInfoExtras() val file = db.files.get(fileId) ?: return@useLibrary BookInfoExtras()

View File

@ -46,6 +46,10 @@ actual suspend fun saveLibraryReadingPosition(fileId: String, position: ReadingP
actual suspend fun markLibraryReadingStatus(fileId: String, status: BookReadingStatus): Boolean = false actual suspend fun markLibraryReadingStatus(fileId: String, status: BookReadingStatus): Boolean = false
actual suspend fun shareLibraryBookFile(fileId: String): Boolean = false
actual suspend fun viewLibraryBookFile(fileId: String): Boolean = false
actual suspend fun loadBookInfoExtras(fileId: String): BookInfoExtras = BookInfoExtras() actual suspend fun loadBookInfoExtras(fileId: String): BookInfoExtras = BookInfoExtras()
actual suspend fun loadActiveReadingFileId(): String? = null actual suspend fun loadActiveReadingFileId(): String? = null

View File

@ -20,7 +20,7 @@ The common API is `Fb2Format.parse(input: ByteArray, fileName: String? = null)`.
Import detection: Import detection:
- A file is treated as ZIP when its bytes start with the ZIP local-file signature `PK\003\004` or the provided filename ends with `.zip`. - A file is treated as ZIP when its bytes start with the ZIP local-file signature `PK\003\004` or the provided filename ends with `.zip`.
- Otherwise bytes are decoded as UTF-8 XML. - Otherwise bytes are decoded according to the XML declaration when supported. UTF-8 is the default, and unsupported or missing encodings fall back to UTF-8. `windows-1251` is supported for legacy FB2 files.
- In ZIP archives, the first entry ending with `.fb2` is used. If no such entry exists, the first non-directory entry is used. - In ZIP archives, the first entry ending with `.fb2` is used. If no such entry exists, the first non-directory entry is used.
ZIP support: ZIP support:

View File

@ -10,7 +10,7 @@ object Fb2Format {
val xml = if (looksLikeZip(input) || fileName?.endsWith(".zip", ignoreCase = true) == true) { val xml = if (looksLikeZip(input) || fileName?.endsWith(".zip", ignoreCase = true) == true) {
Fb2Zip.extractFb2Xml(input) Fb2Zip.extractFb2Xml(input)
} else { } else {
input.decodeToString() Fb2XmlEncoding.decodeXml(input)
} }
return parseXml(xml) return parseXml(xml)
} }

View File

@ -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'
}
}

View File

@ -12,7 +12,7 @@ internal object Fb2Zip {
val entry = entries.firstOrNull { it.name.endsWith(".fb2", ignoreCase = true) } val entry = entries.firstOrNull { it.name.endsWith(".fb2", ignoreCase = true) }
?: entries.firstOrNull { !it.name.endsWith("/") } ?: entries.firstOrNull { !it.name.endsWith("/") }
?: throw Fb2ParseException("ZIP archive does not contain an FB2 entry") ?: 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 { fun createStoredZip(entryName: String, content: ByteArray): ByteArray {

View File

@ -43,6 +43,31 @@ class Fb2FormatTest {
assertTrue(zip.copyOfRange(0, 4).contentEquals(byteArrayOf(0x50, 0x4b, 0x03, 0x04))) 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 @Test
fun preservesReadableBlocksAndInlineStyles() { fun preservesReadableBlocksAndInlineStyles() {
val book = Fb2Format.parseXml(richXml) val book = Fb2Format.parseXml(richXml)
@ -108,4 +133,37 @@ class Fb2FormatTest {
</body> </body>
</FictionBook> </FictionBook>
""".trimIndent() """.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()
}
} }