From 3e83185e4f7325888144f5debdc7544cc1e24ef9 Mon Sep 17 00:00:00 2001 From: sergeych Date: Mon, 25 May 2026 09:13:05 +0300 Subject: [PATCH] fixed wrong left padding in the books with different and sometimes wrong inner structure --- .gitignore | 1 + .../net/sergeych/toread/ReaderContent.kt | 20 +++-- .../toread/ReadAloudContentPlanTest.kt | 89 +++++++++++++++++++ docs/fb2-import-export.md | 2 +- .../net/sergeych/toread/fb2/Fb2XmlEncoding.kt | 46 ++++++++++ .../net/sergeych/toread/fb2/Fb2FormatTest.kt | 56 ++++++++++++ 6 files changed, 204 insertions(+), 10 deletions(-) diff --git a/.gitignore b/.gitignore index 14eaa7e..a9749b1 100644 --- a/.gitignore +++ b/.gitignore @@ -18,3 +18,4 @@ captures **/xcshareddata/WorkspaceSettings.xcsettings node_modules/ /composeApp/release/ +/test_books/ diff --git a/composeApp/src/commonMain/kotlin/net/sergeych/toread/ReaderContent.kt b/composeApp/src/commonMain/kotlin/net/sergeych/toread/ReaderContent.kt index ffa8b9f..8e586c2 100644 --- a/composeApp/src/commonMain/kotlin/net/sergeych/toread/ReaderContent.kt +++ b/composeApp/src/commonMain/kotlin/net/sergeych/toread/ReaderContent.kt @@ -118,7 +118,7 @@ internal fun ContinuousBookReader( val hyphenation = remember { HyphenationRegistry() } val scope = rememberCoroutineScope() val textLineMetricsByItem = remember(contentPlan) { mutableStateMapOf() } - val contentPadding = PaddingValues(top=6.dp, bottom = 6.dp, start = 0.dp, end = 6.dp) + val contentPadding = PaddingValues(top=6.dp, bottom = 6.dp, start = 4.dp, end = 6.dp) val userScrollConnection = remember(onUserScroll) { object : NestedScrollConnection { override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset { @@ -1346,12 +1346,12 @@ internal fun buildReaderContentPlan(book: Fb2Book): ReaderContentPlan { epigraph.textAuthors.forEach { addTextSentences(itemIndex, it) } } - fun addSection(section: Fb2Section, depth: Int) { + fun addSection(section: Fb2Section, readerDepth: Int) { if (section.title.isNullOrBlank()) { elements += ReaderElement.SectionSeparator } else { val itemIndex = elements.size - elements += ReaderElement.SectionTitle(section.title!!, depth) + elements += ReaderElement.SectionTitle(section.title!!, readerDepth) sentences += ReadAloudSentence( index = sentences.size, itemIndex = itemIndex, @@ -1363,18 +1363,19 @@ internal fun buildReaderContentPlan(book: Fb2Book): ReaderContentPlan { ) pendingPauseBeforeMillis = 0L } - section.readableBlocks().forEach { block -> + val blocks = section.readableBlocks() + blocks.forEach { block -> val itemIndex = elements.size when (block) { Fb2Block.EmptyLine -> elements += ReaderElement.FixedSpacer(16) is Fb2Block.Image -> elements += ReaderElement.BookImage(block.image) is Fb2Block.Paragraph -> { addTextSentences(itemIndex, block.content) - elements += ReaderElement.Paragraph(block.content, depth) + elements += ReaderElement.Paragraph(block.content, readerDepth) } is Fb2Block.Poem -> { addPoemSentences(itemIndex, block.poem) - elements += ReaderElement.Poem(block.poem, depth) + elements += ReaderElement.Poem(block.poem, readerDepth) } is Fb2Block.Subtitle -> { addTextSentences( @@ -1387,15 +1388,16 @@ internal fun buildReaderContentPlan(book: Fb2Book): ReaderContentPlan { } is Fb2Block.Epigraph -> { addEpigraphSentences(itemIndex, block.epigraph) - elements += ReaderElement.Epigraph(block.epigraph, depth) + elements += ReaderElement.Epigraph(block.epigraph, readerDepth) } is Fb2Block.Cite -> { addCiteSentences(itemIndex, block.cite) - elements += ReaderElement.Cite(block.cite, depth) + elements += ReaderElement.Cite(block.cite, readerDepth) } } } - section.sections.forEach { addSection(it, depth + 1) } + val childDepth = if (blocks.isEmpty()) readerDepth else readerDepth + 1 + section.sections.forEach { addSection(it, childDepth) } } elements += ReaderElement.Cover diff --git a/composeApp/src/commonTest/kotlin/net/sergeych/toread/ReadAloudContentPlanTest.kt b/composeApp/src/commonTest/kotlin/net/sergeych/toread/ReadAloudContentPlanTest.kt index 0e5c88d..763714d 100644 --- a/composeApp/src/commonTest/kotlin/net/sergeych/toread/ReadAloudContentPlanTest.kt +++ b/composeApp/src/commonTest/kotlin/net/sergeych/toread/ReadAloudContentPlanTest.kt @@ -216,6 +216,95 @@ class ReadAloudContentPlanTest { ) } + @Test + fun wrapperOnlyTopSectionDoesNotIndentChildParagraphs() { + val plan = buildReaderContentPlan( + Fb2Book( + title = "Book", + sections = listOf( + Fb2Section( + title = "Wrapper title", + sections = listOf( + Fb2Section( + title = "Chapter", + blocks = listOf(paragraph("Chapter text.")), + ), + ), + ), + ), + ), + ) + + assertEquals(0, plan.elements.filterIsInstance().single().depth) + assertEquals( + listOf(0, 0), + plan.elements.filterIsInstance().map { it.depth }, + ) + } + + @Test + fun titleOnlyPartSectionDoesNotIndentChaptersAfterFrontMatter() { + val plan = buildReaderContentPlan( + Fb2Book( + title = "Book", + sections = listOf( + Fb2Section( + title = "Copyright", + blocks = listOf(paragraph("Copyright text.")), + ), + Fb2Section( + title = "Part one", + sections = listOf( + Fb2Section( + title = "Chapter one", + blocks = listOf(paragraph("Chapter text.")), + ), + ), + ), + ), + ), + ) + + assertEquals( + listOf(0, 0), + plan.elements.filterIsInstance().map { it.depth }, + ) + assertEquals( + listOf(0, 0, 0), + plan.elements.filterIsInstance().map { it.depth }, + ) + } + + @Test + fun realNestedSectionsKeepRelativeIndent() { + val plan = buildReaderContentPlan( + Fb2Book( + title = "Book", + sections = listOf( + Fb2Section( + title = "Part", + blocks = listOf(paragraph("Part text.")), + sections = listOf( + Fb2Section( + title = "Nested chapter", + blocks = listOf(paragraph("Nested text.")), + ), + ), + ), + ), + ), + ) + + assertEquals( + listOf(0, 1), + plan.elements.filterIsInstance().map { it.depth }, + ) + assertEquals( + listOf(0, 1), + plan.elements.filterIsInstance().map { it.depth }, + ) + } + private fun paragraph(text: String): Fb2Block.Paragraph = Fb2Block.Paragraph(text(text)) diff --git a/docs/fb2-import-export.md b/docs/fb2-import-export.md index d142828..b7ec795 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 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. +- 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` and `windows-1252` are 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/Fb2XmlEncoding.kt b/shared/src/commonMain/kotlin/net/sergeych/toread/fb2/Fb2XmlEncoding.kt index 6a425e9..280ce00 100644 --- a/shared/src/commonMain/kotlin/net/sergeych/toread/fb2/Fb2XmlEncoding.kt +++ b/shared/src/commonMain/kotlin/net/sergeych/toread/fb2/Fb2XmlEncoding.kt @@ -6,6 +6,7 @@ internal object Fb2XmlEncoding { fun decodeXml(bytes: ByteArray): String = when (declaredEncoding(bytes)?.lowercase()) { "windows-1251" -> decodeWindows1251(bytes) + "windows-1252" -> decodeWindows1252(bytes) else -> bytes.decodeToString() } @@ -30,6 +31,12 @@ internal object Fb2XmlEncoding { } } + private fun decodeWindows1252(bytes: ByteArray): String = buildString(bytes.size) { + bytes.forEach { byte -> + append(windows1252Char(byte.toInt() and 0xff)) + } + } + private fun windows1251Char(value: Int): Char = when (value) { in 0x00..0x7f -> value.toChar() @@ -100,4 +107,43 @@ internal object Fb2XmlEncoding { in 0xc0..0xff -> (0x0410 + value - 0xc0).toChar() else -> '\uFFFD' } + + private fun windows1252Char(value: Int): Char = + when (value) { + in 0x00..0x7f -> value.toChar() + 0x80 -> '\u20ac' + 0x81 -> '\uFFFD' + 0x82 -> '\u201a' + 0x83 -> '\u0192' + 0x84 -> '\u201e' + 0x85 -> '\u2026' + 0x86 -> '\u2020' + 0x87 -> '\u2021' + 0x88 -> '\u02c6' + 0x89 -> '\u2030' + 0x8a -> '\u0160' + 0x8b -> '\u2039' + 0x8c -> '\u0152' + 0x8d -> '\uFFFD' + 0x8e -> '\u017d' + 0x8f -> '\uFFFD' + 0x90 -> '\uFFFD' + 0x91 -> '\u2018' + 0x92 -> '\u2019' + 0x93 -> '\u201c' + 0x94 -> '\u201d' + 0x95 -> '\u2022' + 0x96 -> '\u2013' + 0x97 -> '\u2014' + 0x98 -> '\u02dc' + 0x99 -> '\u2122' + 0x9a -> '\u0161' + 0x9b -> '\u203a' + 0x9c -> '\u0153' + 0x9d -> '\uFFFD' + 0x9e -> '\u017e' + 0x9f -> '\u0178' + in 0xa0..0xff -> value.toChar() + else -> '\uFFFD' + } } 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 bcb0fde..9bac2d7 100644 --- a/shared/src/commonTest/kotlin/net/sergeych/toread/fb2/Fb2FormatTest.kt +++ b/shared/src/commonTest/kotlin/net/sergeych/toread/fb2/Fb2FormatTest.kt @@ -60,6 +60,23 @@ class Fb2FormatTest { assertEquals("Привет, мир.", book.sections.single().paragraphs.single()) } + @Test + fun parsesWindows1252PlainXml() { + val book = Fb2Format.parse(windows1252Xml.encodeWindows1252(), "legacy.fb2") + + assertEquals("The “Test” Book", book.title) + assertEquals("It’s 25€ for café — OK.", book.sections.single().paragraphs.single()) + } + + @Test + fun parsesWindows1252StoredZip() { + val zip = Fb2Zip.createStoredZip("legacy.fb2", windows1252Xml.encodeWindows1252()) + val book = Fb2Format.parse(zip, "legacy.fb2.zip") + + assertEquals("The “Test” Book", book.title) + assertEquals("It’s 25€ for café — OK.", book.sections.single().paragraphs.single()) + } + @Test fun fallsBackToUtf8ForUnknownEncoding() { val xml = sampleXml.replace("encoding=\"UTF-8\"", "encoding=\"KOI8-R\"") @@ -273,6 +290,26 @@ class Fb2FormatTest { """.trimIndent() + private val windows1252Xml = """ + + + + + Author + The “Test” Book + en + + + Toread + 2026-05-12 + legacy-1252 + 1.0 + + +

It’s 25€ for café — OK.

+
+ """.trimIndent() + private val notesXml = """ @@ -314,4 +351,23 @@ class Fb2FormatTest { } value.toByte() } + + private fun String.encodeWindows1252(): ByteArray = + ByteArray(length) { index -> + val char = this[index] + val value = when { + char.code <= 0x7f -> char.code + char.code in 0xa0..0xff -> char.code + char == '€' -> 0x80 + char == '‘' -> 0x91 + char == '’' -> 0x92 + char == '“' -> 0x93 + char == '”' -> 0x94 + char == '–' -> 0x96 + char == '—' -> 0x97 + char == '™' -> 0x99 + else -> error("Test character $char is not mapped to windows-1252") + } + value.toByte() + } }