fixed wrong left padding in the books with different and sometimes wrong inner structure
This commit is contained in:
parent
65925f2a07
commit
3e83185e4f
1
.gitignore
vendored
1
.gitignore
vendored
@ -18,3 +18,4 @@ captures
|
||||
**/xcshareddata/WorkspaceSettings.xcsettings
|
||||
node_modules/
|
||||
/composeApp/release/
|
||||
/test_books/
|
||||
|
||||
@ -118,7 +118,7 @@ internal fun ContinuousBookReader(
|
||||
val hyphenation = remember { HyphenationRegistry() }
|
||||
val scope = rememberCoroutineScope()
|
||||
val textLineMetricsByItem = remember(contentPlan) { mutableStateMapOf<Int, TextLineMetrics>() }
|
||||
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
|
||||
|
||||
@ -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<ReaderElement.Paragraph>().single().depth)
|
||||
assertEquals(
|
||||
listOf(0, 0),
|
||||
plan.elements.filterIsInstance<ReaderElement.SectionTitle>().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<ReaderElement.Paragraph>().map { it.depth },
|
||||
)
|
||||
assertEquals(
|
||||
listOf(0, 0, 0),
|
||||
plan.elements.filterIsInstance<ReaderElement.SectionTitle>().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<ReaderElement.Paragraph>().map { it.depth },
|
||||
)
|
||||
assertEquals(
|
||||
listOf(0, 1),
|
||||
plan.elements.filterIsInstance<ReaderElement.SectionTitle>().map { it.depth },
|
||||
)
|
||||
}
|
||||
|
||||
private fun paragraph(text: String): Fb2Block.Paragraph =
|
||||
Fb2Block.Paragraph(text(text))
|
||||
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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'
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
</FictionBook>
|
||||
""".trimIndent()
|
||||
|
||||
private val windows1252Xml = """
|
||||
<?xml version="1.0" encoding="windows-1252"?>
|
||||
<FictionBook xmlns="http://www.gribuser.ru/xml/fictionbook/2.0" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||
<description>
|
||||
<title-info>
|
||||
<author><nickname>Author</nickname></author>
|
||||
<book-title>The “Test” Book</book-title>
|
||||
<lang>en</lang>
|
||||
</title-info>
|
||||
<document-info>
|
||||
<author><nickname>Toread</nickname></author>
|
||||
<date>2026-05-12</date>
|
||||
<id>legacy-1252</id>
|
||||
<version>1.0</version>
|
||||
</document-info>
|
||||
</description>
|
||||
<body><section><p>It’s 25€ for café — OK.</p></section></body>
|
||||
</FictionBook>
|
||||
""".trimIndent()
|
||||
|
||||
private val notesXml = """
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<FictionBook xmlns="http://www.gribuser.ru/xml/fictionbook/2.0" xmlns:l="http://www.w3.org/1999/xlink">
|
||||
@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user