From da88a6e221369ea962a3c0bb98133295953aea41 Mon Sep 17 00:00:00 2001 From: sergeych Date: Mon, 18 May 2026 00:14:34 +0300 Subject: [PATCH] better UI, +windows1251 encoding support --- .../sergeych/toread/BookPlatform.android.kt | 51 +++++++ .../res/xml/image_clipboard_paths.xml | 1 + .../kotlin/net/sergeych/toread/App.kt | 3 + .../net/sergeych/toread/LibraryDelete.kt | 14 ++ .../net/sergeych/toread/LibraryPlatform.kt | 4 + .../net/sergeych/toread/LibraryScreen.kt | 6 +- .../net/sergeych/toread/ReaderContent.kt | 8 +- .../net/sergeych/toread/ReaderScreen.kt | 144 +++++++++++++++++- .../net/sergeych/toread/BookPlatform.jvm.kt | 19 +++ .../net/sergeych/toread/BookPlatform.web.kt | 4 + docs/fb2-import-export.md | 2 +- .../net/sergeych/toread/fb2/Fb2Format.kt | 2 +- .../net/sergeych/toread/fb2/Fb2XmlEncoding.kt | 103 +++++++++++++ .../kotlin/net/sergeych/toread/fb2/Fb2Zip.kt | 2 +- .../net/sergeych/toread/fb2/Fb2FormatTest.kt | 58 +++++++ 15 files changed, 407 insertions(+), 14 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/net/sergeych/toread/LibraryDelete.kt create mode 100644 shared/src/commonMain/kotlin/net/sergeych/toread/fb2/Fb2XmlEncoding.kt diff --git a/composeApp/src/androidMain/kotlin/net/sergeych/toread/BookPlatform.android.kt b/composeApp/src/androidMain/kotlin/net/sergeych/toread/BookPlatform.android.kt index 4b96ce0..47b238a 100644 --- a/composeApp/src/androidMain/kotlin/net/sergeych/toread/BookPlatform.android.kt +++ b/composeApp/src/androidMain/kotlin/net/sergeych/toread/BookPlatform.android.kt @@ -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) { openLibraryDatabase().useLibrary { db -> val file = db.files.get(fileId) ?: return@useLibrary BookInfoExtras() @@ -528,6 +559,26 @@ private fun String.requiresExternalFileAccess(): Boolean { private fun String.isSupportedBookFile(): Boolean = 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 = appContext.contentResolver.query(uri, arrayOf(OpenableColumns.DISPLAY_NAME), null, null, null)?.use { cursor -> if (cursor.moveToFirst()) cursor.getString(0) else null diff --git a/composeApp/src/androidMain/res/xml/image_clipboard_paths.xml b/composeApp/src/androidMain/res/xml/image_clipboard_paths.xml index 9a88aa2..6f90ca0 100644 --- a/composeApp/src/androidMain/res/xml/image_clipboard_paths.xml +++ b/composeApp/src/androidMain/res/xml/image_clipboard_paths.xml @@ -1,4 +1,5 @@ + diff --git a/composeApp/src/commonMain/kotlin/net/sergeych/toread/App.kt b/composeApp/src/commonMain/kotlin/net/sergeych/toread/App.kt index d1c15ac..0230295 100644 --- a/composeApp/src/commonMain/kotlin/net/sergeych/toread/App.kt +++ b/composeApp/src/commonMain/kotlin/net/sergeych/toread/App.kt @@ -166,6 +166,9 @@ private fun BookReaderApp(onThemeToggle: () -> Unit) { message = current.message, ) }, + onDeleted = { message -> + state = AppState.Library(emptyList(), current.scanPath, message) + }, onBack = { state = AppState.Library(emptyList(), current.scanPath, current.message) }, diff --git a/composeApp/src/commonMain/kotlin/net/sergeych/toread/LibraryDelete.kt b/composeApp/src/commonMain/kotlin/net/sergeych/toread/LibraryDelete.kt new file mode 100644 index 0000000..68f5e5b --- /dev/null +++ b/composeApp/src/commonMain/kotlin/net/sergeych/toread/LibraryDelete.kt @@ -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.", + ) +} diff --git a/composeApp/src/commonMain/kotlin/net/sergeych/toread/LibraryPlatform.kt b/composeApp/src/commonMain/kotlin/net/sergeych/toread/LibraryPlatform.kt index 04e2841..8d6d37a 100644 --- a/composeApp/src/commonMain/kotlin/net/sergeych/toread/LibraryPlatform.kt +++ b/composeApp/src/commonMain/kotlin/net/sergeych/toread/LibraryPlatform.kt @@ -108,6 +108,10 @@ expect suspend fun saveLibraryReadingPosition(fileId: String, position: ReadingP 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 loadActiveReadingFileId(): String? diff --git a/composeApp/src/commonMain/kotlin/net/sergeych/toread/LibraryScreen.kt b/composeApp/src/commonMain/kotlin/net/sergeych/toread/LibraryScreen.kt index c98cfea..1568658 100644 --- a/composeApp/src/commonMain/kotlin/net/sergeych/toread/LibraryScreen.kt +++ b/composeApp/src/commonMain/kotlin/net/sergeych/toread/LibraryScreen.kt @@ -364,9 +364,9 @@ internal fun LibraryScreen( scope.launch { busy = true try { - val deleted = runCatching { deleteLibraryItem(item.fileId) }.getOrDefault(false) - message = if (deleted) "Removed ${item.title}." else "Could not remove ${item.title}." - if (deleted) { + val result = deleteLibraryBook(item.fileId, item.title) + message = result.message + if (result.deleted) { items = items.filterNot { it.fileId == item.fileId } searchResults = searchResults.filterNot { it.fileId == item.fileId } coverCache.remove(item.fileId) diff --git a/composeApp/src/commonMain/kotlin/net/sergeych/toread/ReaderContent.kt b/composeApp/src/commonMain/kotlin/net/sergeych/toread/ReaderContent.kt index a4fac77..24b2df9 100644 --- a/composeApp/src/commonMain/kotlin/net/sergeych/toread/ReaderContent.kt +++ b/composeApp/src/commonMain/kotlin/net/sergeych/toread/ReaderContent.kt @@ -90,9 +90,9 @@ internal fun ContinuousBookReader( val hyphenation = remember { HyphenationRegistry() } val scope = rememberCoroutineScope() 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 { - PaddingValues(horizontal = 8.dp, vertical = 6.dp) + PaddingValues(horizontal = 4.dp, vertical = 6.dp) } LazyColumn( @@ -461,8 +461,8 @@ private fun ReaderText( @Composable private fun readerParagraphTextStyle(language: String?): TextStyle = MaterialTheme.typography.bodyLarge.copy( - fontWeight = FontWeight(350), - fontSize = 21.sp, + fontWeight = if( isAndroidPlatform()) FontWeight(350) else FontWeight.Normal, + fontSize = if( isAndroidPlatform()) 21.sp else 18.sp, lineHeight = 28.sp, hyphens = if (isAndroidPlatform()) Hyphens.Auto else Hyphens.Unspecified, lineBreak = if (isAndroidPlatform()) LineBreak.Paragraph else LineBreak.Unspecified, diff --git a/composeApp/src/commonMain/kotlin/net/sergeych/toread/ReaderScreen.kt b/composeApp/src/commonMain/kotlin/net/sergeych/toread/ReaderScreen.kt index c0b1e97..cdbde20 100644 --- a/composeApp/src/commonMain/kotlin/net/sergeych/toread/ReaderScreen.kt +++ b/composeApp/src/commonMain/kotlin/net/sergeych/toread/ReaderScreen.kt @@ -12,13 +12,19 @@ import androidx.compose.foundation.layout.padding import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack 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.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme 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.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -47,13 +53,36 @@ internal fun BookView( onImageOpen: (ViewedBookImage) -> Unit, onThemeToggle: () -> Unit, onBookInfo: () -> Unit, + onDeleted: (String) -> Unit, onBack: () -> Unit, ) { val stats = remember(book) { BookStats.from(book) } val listState = rememberLazyListState() val scope = rememberCoroutineScope() + val snackbarHostState = remember { SnackbarHostState() } var restored 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) { markLibraryReadingStatus(fileId, BookReadingStatus.READING) @@ -91,6 +120,7 @@ internal fun BookView( Scaffold( contentWindowInsets = WindowInsets(0, 0, 0, 0), + snackbarHost = { SnackbarHost(snackbarHostState) }, topBar = { CompactReaderTopBar( title = book.title, @@ -104,6 +134,40 @@ internal fun BookView( 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 = { scope.launch { saveLibraryReadingPosition( @@ -139,8 +203,18 @@ private fun CompactReaderTopBar( title: String, onThemeToggle: () -> Unit, onBookInfo: () -> Unit, + onMarkAsRead: () -> Unit, + onNotInterested: () -> Unit, + onClearMarks: () -> Unit, + showShareAction: Boolean, + onShare: () -> Unit, + showViewFileAction: Boolean, + onViewFile: () -> Unit, + onDelete: () -> Unit, onBack: () -> Unit, ) { + var menuOpen by remember { mutableStateOf(false) } + ThemedTopBarSurface { Row( modifier = Modifier.fillMaxWidth().height(48.dp), @@ -158,12 +232,74 @@ private fun CompactReaderTopBar( IconButton(onClick = onThemeToggle) { Icon(Icons.Filled.Palette, contentDescription = "Theme") } - IconButton(onClick = onBookInfo) { - Icon(Icons.Filled.Info, contentDescription = "Properties") - } IconButton(onClick = { }) { 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() + }, + ) + } + } } } } diff --git a/composeApp/src/jvmMain/kotlin/net/sergeych/toread/BookPlatform.jvm.kt b/composeApp/src/jvmMain/kotlin/net/sergeych/toread/BookPlatform.jvm.kt index edfbf03..b900b01 100644 --- a/composeApp/src/jvmMain/kotlin/net/sergeych/toread/BookPlatform.jvm.kt +++ b/composeApp/src/jvmMain/kotlin/net/sergeych/toread/BookPlatform.jvm.kt @@ -10,6 +10,7 @@ import net.sergeych.toread.storage.ReadingStateRecord import net.sergeych.toread.storage.jdbc.H2LibraryDatabase import net.sergeych.toread.storage.jdbc.LibraryScanner import org.jetbrains.skia.Image +import java.awt.Desktop import java.awt.Toolkit import java.awt.datatransfer.DataFlavor 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) { openLibraryDatabase().useLibrary { db -> val file = db.files.get(fileId) ?: return@useLibrary BookInfoExtras() diff --git a/composeApp/src/webMain/kotlin/net/sergeych/toread/BookPlatform.web.kt b/composeApp/src/webMain/kotlin/net/sergeych/toread/BookPlatform.web.kt index 19d3f17..379144c 100644 --- a/composeApp/src/webMain/kotlin/net/sergeych/toread/BookPlatform.web.kt +++ b/composeApp/src/webMain/kotlin/net/sergeych/toread/BookPlatform.web.kt @@ -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 shareLibraryBookFile(fileId: String): Boolean = false + +actual suspend fun viewLibraryBookFile(fileId: String): Boolean = false + actual suspend fun loadBookInfoExtras(fileId: String): BookInfoExtras = BookInfoExtras() actual suspend fun loadActiveReadingFileId(): String? = null diff --git a/docs/fb2-import-export.md b/docs/fb2-import-export.md index bea4147..d142828 100644 --- a/docs/fb2-import-export.md +++ b/docs/fb2-import-export.md @@ -20,7 +20,7 @@ The common API is `Fb2Format.parse(input: ByteArray, fileName: String? = null)`. 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`. -- 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. ZIP support: diff --git a/shared/src/commonMain/kotlin/net/sergeych/toread/fb2/Fb2Format.kt b/shared/src/commonMain/kotlin/net/sergeych/toread/fb2/Fb2Format.kt index ef090af..36957dd 100644 --- a/shared/src/commonMain/kotlin/net/sergeych/toread/fb2/Fb2Format.kt +++ b/shared/src/commonMain/kotlin/net/sergeych/toread/fb2/Fb2Format.kt @@ -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) } diff --git a/shared/src/commonMain/kotlin/net/sergeych/toread/fb2/Fb2XmlEncoding.kt b/shared/src/commonMain/kotlin/net/sergeych/toread/fb2/Fb2XmlEncoding.kt new file mode 100644 index 0000000..6a425e9 --- /dev/null +++ b/shared/src/commonMain/kotlin/net/sergeych/toread/fb2/Fb2XmlEncoding.kt @@ -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' + } +} diff --git a/shared/src/commonMain/kotlin/net/sergeych/toread/fb2/Fb2Zip.kt b/shared/src/commonMain/kotlin/net/sergeych/toread/fb2/Fb2Zip.kt index 8768619..3518131 100644 --- a/shared/src/commonMain/kotlin/net/sergeych/toread/fb2/Fb2Zip.kt +++ b/shared/src/commonMain/kotlin/net/sergeych/toread/fb2/Fb2Zip.kt @@ -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 { diff --git a/shared/src/commonTest/kotlin/net/sergeych/toread/fb2/Fb2FormatTest.kt b/shared/src/commonTest/kotlin/net/sergeych/toread/fb2/Fb2FormatTest.kt index 9a64abb..5e0ce74 100644 --- a/shared/src/commonTest/kotlin/net/sergeych/toread/fb2/Fb2FormatTest.kt +++ b/shared/src/commonTest/kotlin/net/sergeych/toread/fb2/Fb2FormatTest.kt @@ -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 { """.trimIndent() + + private val windows1251Xml = """ + + + + + Автор + Тестовая книга + ru + + + Toread + 2026-05-12 + legacy + 1.0 + + +

Привет, мир.

+
+ """.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() + } }