From de04b9cbed82512131122a937486285efdbe4609 Mon Sep 17 00:00:00 2001 From: sergeych Date: Sun, 24 May 2026 09:31:32 +0300 Subject: [PATCH] support for poetry and epigraphs --- .../sergeych/toread/BookPlatform.android.kt | 8 +- .../net/sergeych/toread/BookInfoScreen.kt | 10 + .../net/sergeych/toread/LibraryPlatform.kt | 2 + .../net/sergeych/toread/Localization.kt | 10 +- .../net/sergeych/toread/ReaderContent.kt | 420 +++++++++++++++++- .../toread/ReadAloudContentPlanTest.kt | 75 +++- .../net/sergeych/toread/BookPlatform.jvm.kt | 8 +- .../kotlin/net/sergeych/toread/fb2/Fb2Book.kt | 41 ++ .../net/sergeych/toread/fb2/Fb2XmlMapper.kt | 70 ++- .../net/sergeych/toread/fb2/Fb2FormatTest.kt | 107 +++++ .../toread/storage/jdbc/LibraryScanner.kt | 46 +- 11 files changed, 787 insertions(+), 10 deletions(-) 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 5fa63af..bcc3abc 100644 --- a/composeApp/src/androidMain/kotlin/net/sergeych/toread/BookPlatform.android.kt +++ b/composeApp/src/androidMain/kotlin/net/sergeych/toread/BookPlatform.android.kt @@ -377,9 +377,15 @@ 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() - val clusterId = file.bodyClusterId ?: return@useLibrary BookInfoExtras() + val sourceFileName = file.originalFilename ?: file.storageUri?.substringAfterLast('/') + val clusterId = file.bodyClusterId ?: return@useLibrary BookInfoExtras( + sourceFileName = sourceFileName, + sourceFilePath = file.storageUri, + ) val readingPosition = db.readingStates.getForBodyCluster(clusterId)?.anchor?.formatHintsJson?.toReadingPosition() BookInfoExtras( + sourceFileName = sourceFileName, + sourceFilePath = file.storageUri, bookmarks = db.bookmarks.listForBodyCluster(clusterId).map { BookmarkInfo( title = it.title, diff --git a/composeApp/src/commonMain/kotlin/net/sergeych/toread/BookInfoScreen.kt b/composeApp/src/commonMain/kotlin/net/sergeych/toread/BookInfoScreen.kt index b3436b3..9cd4864 100644 --- a/composeApp/src/commonMain/kotlin/net/sergeych/toread/BookInfoScreen.kt +++ b/composeApp/src/commonMain/kotlin/net/sergeych/toread/BookInfoScreen.kt @@ -92,6 +92,16 @@ internal fun BookInfoScreen( } } } + item { + InfoSection(strings.sourceFile) { + if (extras == null) { + Text(strings.loading, style = MaterialTheme.typography.bodyMedium) + } else { + DetailLine(strings.fileName, extras?.sourceFileName?.ifBlank { null } ?: strings.notSpecified) + DetailLine(strings.filePath, extras?.sourceFilePath?.ifBlank { null } ?: strings.notSpecified) + } + } + } item { InfoSection(strings.lastReadingPosition) { val position = extras?.lastReadingPosition diff --git a/composeApp/src/commonMain/kotlin/net/sergeych/toread/LibraryPlatform.kt b/composeApp/src/commonMain/kotlin/net/sergeych/toread/LibraryPlatform.kt index 5179e48..dfc74c0 100644 --- a/composeApp/src/commonMain/kotlin/net/sergeych/toread/LibraryPlatform.kt +++ b/composeApp/src/commonMain/kotlin/net/sergeych/toread/LibraryPlatform.kt @@ -64,6 +64,8 @@ data class ReadingPosition( ) data class BookInfoExtras( + val sourceFileName: String? = null, + val sourceFilePath: String? = null, val bookmarks: List = emptyList(), val notes: List = emptyList(), val lastReadingPosition: ReadingPosition? = null, diff --git a/composeApp/src/commonMain/kotlin/net/sergeych/toread/Localization.kt b/composeApp/src/commonMain/kotlin/net/sergeych/toread/Localization.kt index 1a0a3d9..296a597 100644 --- a/composeApp/src/commonMain/kotlin/net/sergeych/toread/Localization.kt +++ b/composeApp/src/commonMain/kotlin/net/sergeych/toread/Localization.kt @@ -117,6 +117,9 @@ internal open class AppStrings { open val words = "Words" open val sections = "Sections" open val images = "Images" + open val sourceFile = "Source file" + open val fileName = "File name" + open val filePath = "Path" open val lastReadingPosition = "Last Reading Position" open val noSavedPosition = "No saved position" open val listItem = "List item" @@ -243,7 +246,7 @@ internal object RussianStrings : AppStrings() { override val couldNotOpenBook = "Не удалось открыть книгу." override val couldNotUpdateBook = "Не удалось обновить книгу." override val bookFileNotAvailable = "Файл книги недоступен." - override val scanFailed = "Сканирование не удалось." + override val scanFailed = "Импорт не удался." override val searchFailed = "Поиск не удался." override val libraryRescanFailed = "Повторное сканирование библиотеки не удалось." override val rescanningLibrary = "Пересканируем библиотеку..." @@ -287,7 +290,7 @@ internal object RussianStrings : AppStrings() { override val filterToRead = "К чтению" override val filterRead = "Прочитанные" - override val scan = "Сканирование" + override val scan = "Импорт" override val rootFolder = "Корневая папка" override val choose = "Выбрать" override val logPrefix = "Лог" @@ -309,6 +312,9 @@ internal object RussianStrings : AppStrings() { override val words = "Слова" override val sections = "Разделы" override val images = "Иллюстрации" + override val sourceFile = "Исходный файл" + override val fileName = "Имя файла" + override val filePath = "Путь" override val lastReadingPosition = "Последняя позиция чтения" override val noSavedPosition = "Позиция не сохранена" override val listItem = "Элемент списка" diff --git a/composeApp/src/commonMain/kotlin/net/sergeych/toread/ReaderContent.kt b/composeApp/src/commonMain/kotlin/net/sergeych/toread/ReaderContent.kt index 53ee16d..8df7117 100644 --- a/composeApp/src/commonMain/kotlin/net/sergeych/toread/ReaderContent.kt +++ b/composeApp/src/commonMain/kotlin/net/sergeych/toread/ReaderContent.kt @@ -79,7 +79,12 @@ import androidx.compose.ui.unit.isSpecified import androidx.compose.ui.unit.sp import net.sergeych.toread.fb2.Fb2Block import net.sergeych.toread.fb2.Fb2Book +import net.sergeych.toread.fb2.Fb2Cite +import net.sergeych.toread.fb2.Fb2Epigraph +import net.sergeych.toread.fb2.Fb2EpigraphBlock import net.sergeych.toread.fb2.Fb2ImageRef +import net.sergeych.toread.fb2.Fb2Poem +import net.sergeych.toread.fb2.Fb2PoemBlock import net.sergeych.toread.fb2.Fb2Section import net.sergeych.toread.fb2.Fb2Text import net.sergeych.toread.fb2.Fb2TextSpan @@ -210,6 +215,27 @@ internal fun ContinuousBookReader( modifier = Modifier.fillMaxWidth().padding(top = 18.dp, bottom = 8.dp), onTextLayout = { textLineMetricsByItem[itemIndex] = it.toTextLineMetrics() }, ) + is ReaderElement.Epigraph -> ReaderEpigraph( + epigraph = element.epigraph, + language = book.language, + hyphenation = hyphenation, + highlightedRange = highlightedRange, + depth = element.depth, + ) + is ReaderElement.Cite -> ReaderCite( + cite = element.cite, + language = book.language, + hyphenation = hyphenation, + highlightedRange = highlightedRange, + depth = element.depth, + ) + is ReaderElement.Poem -> ReaderPoem( + poem = element.poem, + language = book.language, + hyphenation = hyphenation, + highlightedRange = highlightedRange, + depth = element.depth, + ) } } } @@ -467,6 +493,13 @@ private fun ReaderPane(book: Fb2Book, section: Fb2Section?, modifier: Modifier = style = readerParagraphTextStyle(book.language), textAlign = TextAlign.Unspecified, ) + is Fb2Block.Poem -> ReaderPoem( + poem = block.poem, + language = book.language, + hyphenation = hyphenation, + highlightedRange = null, + depth = 0, + ) is Fb2Block.Subtitle -> ReaderText( text = block.content, language = book.language, @@ -475,6 +508,20 @@ private fun ReaderPane(book: Fb2Book, section: Fb2Section?, modifier: Modifier = textAlign = TextAlign.Center, modifier = Modifier.fillMaxWidth().padding(top = 18.dp, bottom = 8.dp), ) + is Fb2Block.Epigraph -> ReaderEpigraph( + epigraph = block.epigraph, + language = book.language, + hyphenation = hyphenation, + highlightedRange = null, + depth = 0, + ) + is Fb2Block.Cite -> ReaderCite( + cite = block.cite, + language = book.language, + hyphenation = hyphenation, + highlightedRange = null, + depth = 0, + ) } } item { Spacer(Modifier.height(22.dp)) } @@ -543,6 +590,212 @@ private fun ReaderText( ) } +@Composable +private fun ReaderPoem( + poem: Fb2Poem, + language: String?, + hyphenation: HyphenationRegistry, + highlightedRange: ReaderSentenceRange?, + depth: Int, + modifier: Modifier = Modifier, +) { + val segments = remember(poem) { poem.readerSegments() } + Column( + modifier = modifier + .fillMaxWidth() + .padding(start = (depth * 8).dp, top = 8.dp, bottom = 8.dp), + ) { + poem.epigraphs.forEachIndexed { index, epigraph -> + ReaderEpigraph( + epigraph = epigraph, + language = language, + hyphenation = hyphenation, + highlightedRange = highlightedRange, + depth = 0, + modifier = Modifier.padding(top = if (index == 0) 0.dp else 8.dp, bottom = 8.dp), + ) + } + segments.forEach { segment -> + if (segment.gapBeforeDp > 0) { + Spacer(Modifier.height(segment.gapBeforeDp.dp)) + } + ReaderText( + text = segment.text, + language = language, + hyphenation = hyphenation, + style = poemTextStyle(segment.kind, language), + highlightedRange = highlightedRange?.forSegment(segment), + textAlign = when (segment.kind) { + ReaderPoemSegmentKind.Title, + ReaderPoemSegmentKind.Subtitle -> TextAlign.Center + ReaderPoemSegmentKind.TextAuthor, + ReaderPoemSegmentKind.Date -> TextAlign.End + ReaderPoemSegmentKind.Verse -> TextAlign.Start + }, + modifier = when (segment.kind) { + ReaderPoemSegmentKind.Verse -> Modifier.padding(start = 22.dp) + else -> Modifier + }, + ) + } + } +} + +@Composable +private fun ReaderEpigraph( + epigraph: Fb2Epigraph, + language: String?, + hyphenation: HyphenationRegistry, + highlightedRange: ReaderSentenceRange?, + depth: Int, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier + .fillMaxWidth() + .padding(start = (depth * 8 + 26).dp, end = 18.dp, top = 8.dp, bottom = 8.dp), + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + epigraph.blocks.forEach { block -> + ReaderEpigraphBlock( + block = block, + language = language, + hyphenation = hyphenation, + highlightedRange = null, + depth = 0, + ) + } + epigraph.textAuthors.forEach { author -> + ReaderText( + text = author, + language = language, + hyphenation = hyphenation, + style = epigraphAuthorTextStyle(language), + highlightedRange = null, + textAlign = TextAlign.End, + modifier = Modifier.fillMaxWidth().padding(top = 2.dp), + ) + } + } +} + +@Composable +private fun ReaderCite( + cite: Fb2Cite, + language: String?, + hyphenation: HyphenationRegistry, + highlightedRange: ReaderSentenceRange?, + depth: Int, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier + .fillMaxWidth() + .padding(start = (depth * 8 + 22).dp, end = 14.dp, top = 8.dp, bottom = 8.dp), + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + cite.blocks.forEach { block -> + ReaderEpigraphBlock( + block = block, + language = language, + hyphenation = hyphenation, + highlightedRange = null, + depth = 0, + ) + } + cite.textAuthors.forEach { author -> + ReaderText( + text = author, + language = language, + hyphenation = hyphenation, + style = epigraphAuthorTextStyle(language), + highlightedRange = null, + textAlign = TextAlign.End, + modifier = Modifier.fillMaxWidth().padding(top = 2.dp), + ) + } + } +} + +@Composable +private fun ReaderEpigraphBlock( + block: Fb2EpigraphBlock, + language: String?, + hyphenation: HyphenationRegistry, + highlightedRange: ReaderSentenceRange?, + depth: Int, +) { + when (block) { + Fb2EpigraphBlock.EmptyLine -> Spacer(Modifier.height(12.dp)) + is Fb2EpigraphBlock.Paragraph -> ReaderText( + text = block.content, + language = language, + hyphenation = hyphenation, + style = epigraphTextStyle(language), + highlightedRange = highlightedRange, + textAlign = TextAlign.Start, + ) + is Fb2EpigraphBlock.Subtitle -> ReaderText( + text = block.content, + language = language, + hyphenation = hyphenation, + style = epigraphTextStyle(language).copy(fontWeight = FontWeight.SemiBold), + highlightedRange = highlightedRange, + textAlign = TextAlign.Center, + ) + is Fb2EpigraphBlock.Poem -> ReaderPoem( + poem = block.poem, + language = language, + hyphenation = hyphenation, + highlightedRange = highlightedRange, + depth = depth, + ) + is Fb2EpigraphBlock.Cite -> ReaderCite( + cite = block.cite, + language = language, + hyphenation = hyphenation, + highlightedRange = highlightedRange, + depth = depth, + ) + } +} + +@Composable +private fun poemTextStyle(kind: ReaderPoemSegmentKind, language: String?): TextStyle = + when (kind) { + ReaderPoemSegmentKind.Title -> MaterialTheme.typography.titleMedium.copy( + fontWeight = FontWeight.SemiBold, + lineHeight = 26.sp, + localeList = language?.takeIf(String::isNotBlank)?.let { LocaleList(Locale(it)) }, + ) + ReaderPoemSegmentKind.Subtitle -> MaterialTheme.typography.bodyLarge.copy( + fontStyle = FontStyle.Italic, + lineHeight = 24.sp, + localeList = language?.takeIf(String::isNotBlank)?.let { LocaleList(Locale(it)) }, + ) + ReaderPoemSegmentKind.Verse -> readerParagraphTextStyle(language).copy( + lineHeight = 24.sp, + ) + ReaderPoemSegmentKind.TextAuthor, + ReaderPoemSegmentKind.Date -> MaterialTheme.typography.bodyMedium.copy( + fontStyle = FontStyle.Italic, + lineHeight = 22.sp, + localeList = language?.takeIf(String::isNotBlank)?.let { LocaleList(Locale(it)) }, + ) + } + +@Composable +private fun epigraphTextStyle(language: String?): TextStyle = + MaterialTheme.typography.bodyMedium.copy( + fontStyle = FontStyle.Italic, + lineHeight = 22.sp, + localeList = language?.takeIf(String::isNotBlank)?.let { LocaleList(Locale(it)) }, + ) + +@Composable +private fun epigraphAuthorTextStyle(language: String?): TextStyle = + epigraphTextStyle(language).copy(fontWeight = FontWeight.SemiBold) + @Composable private fun readerParagraphTextStyle(language: String?): TextStyle = MaterialTheme.typography.bodyLarge.copy( @@ -766,6 +1019,7 @@ internal fun buildReaderContentPlan(book: Fb2Book): ReaderContentPlan { fun addTextSentences( itemIndex: Int, text: Fb2Text, + offset: Int = 0, pauseBeforeMillis: Long = 0, pauseAfterMillis: Long = 0, ) { @@ -784,8 +1038,8 @@ internal fun buildReaderContentPlan(book: Fb2Book): ReaderContentPlan { sentences += ReadAloudSentence( index = sentences.size, itemIndex = itemIndex, - start = range.start, - endExclusive = range.endExclusive, + start = offset + range.start, + endExclusive = offset + range.endExclusive, text = sentenceText, spokenText = spokenText, pauseBeforeMillis = effectivePauseBefore, @@ -795,6 +1049,43 @@ internal fun buildReaderContentPlan(book: Fb2Book): ReaderContentPlan { } } + lateinit var addPoemSentences: (Int, Fb2Poem) -> Unit + lateinit var addCiteSentences: (Int, Fb2Cite) -> Unit + lateinit var addEpigraphSentences: (Int, Fb2Epigraph) -> Unit + + addPoemSentences = { itemIndex, poem -> + poem.epigraphs.forEach { epigraph -> addEpigraphSentences(itemIndex, epigraph) } + poem.readerSegments().forEach { segment -> + addTextSentences(itemIndex, segment.text, offset = segment.startOffset) + } + } + + addCiteSentences = { itemIndex, cite -> + cite.blocks.forEach { block -> + when (block) { + Fb2EpigraphBlock.EmptyLine -> Unit + is Fb2EpigraphBlock.Paragraph -> addTextSentences(itemIndex, block.content) + is Fb2EpigraphBlock.Subtitle -> addTextSentences(itemIndex, block.content) + is Fb2EpigraphBlock.Poem -> addPoemSentences(itemIndex, block.poem) + is Fb2EpigraphBlock.Cite -> addCiteSentences(itemIndex, block.cite) + } + } + cite.textAuthors.forEach { addTextSentences(itemIndex, it) } + } + + addEpigraphSentences = { itemIndex, epigraph -> + epigraph.blocks.forEach { block -> + when (block) { + Fb2EpigraphBlock.EmptyLine -> Unit + is Fb2EpigraphBlock.Paragraph -> addTextSentences(itemIndex, block.content) + is Fb2EpigraphBlock.Subtitle -> addTextSentences(itemIndex, block.content) + is Fb2EpigraphBlock.Poem -> addPoemSentences(itemIndex, block.poem) + is Fb2EpigraphBlock.Cite -> addCiteSentences(itemIndex, block.cite) + } + } + epigraph.textAuthors.forEach { addTextSentences(itemIndex, it) } + } + fun addSection(section: Fb2Section, depth: Int) { if (section.title.isNullOrBlank()) { elements += ReaderElement.SectionSeparator @@ -821,6 +1112,10 @@ internal fun buildReaderContentPlan(book: Fb2Book): ReaderContentPlan { addTextSentences(itemIndex, block.content) elements += ReaderElement.Paragraph(block.content, depth) } + is Fb2Block.Poem -> { + addPoemSentences(itemIndex, block.poem) + elements += ReaderElement.Poem(block.poem, depth) + } is Fb2Block.Subtitle -> { addTextSentences( itemIndex = itemIndex, @@ -830,6 +1125,14 @@ internal fun buildReaderContentPlan(book: Fb2Book): ReaderContentPlan { ) elements += ReaderElement.Subtitle(block.content) } + is Fb2Block.Epigraph -> { + addEpigraphSentences(itemIndex, block.epigraph) + elements += ReaderElement.Epigraph(block.epigraph, depth) + } + is Fb2Block.Cite -> { + addCiteSentences(itemIndex, block.cite) + elements += ReaderElement.Cite(block.cite, depth) + } } } section.sections.forEach { addSection(it, depth + 1) } @@ -837,6 +1140,11 @@ internal fun buildReaderContentPlan(book: Fb2Book): ReaderContentPlan { elements += ReaderElement.Cover elements += ReaderElement.FixedSpacer(6) + book.bodyEpigraphs.forEach { epigraph -> + val itemIndex = elements.size + addEpigraphSentences(itemIndex, epigraph) + elements += ReaderElement.Epigraph(epigraph, 0) + } book.sections.forEach { addSection(it, 0) } elements += ReaderElement.FixedSpacer(22) @@ -866,6 +1174,80 @@ internal sealed interface ReaderElement { data class BookImage(val image: Fb2ImageRef) : ReaderElement data class Paragraph(val text: Fb2Text, val depth: Int) : ReaderElement data class Subtitle(val text: Fb2Text) : ReaderElement + data class Epigraph(val epigraph: Fb2Epigraph, val depth: Int) : ReaderElement + data class Cite(val cite: Fb2Cite, val depth: Int) : ReaderElement + data class Poem(val poem: Fb2Poem, val depth: Int) : ReaderElement +} + +internal data class ReaderPoemSegment( + val text: Fb2Text, + val kind: ReaderPoemSegmentKind, + val startOffset: Int, + val gapBeforeDp: Int, +) + +internal enum class ReaderPoemSegmentKind { + Title, + Subtitle, + Verse, + TextAuthor, + Date, +} + +private fun Fb2Poem.readerSegments(): List { + val segments = mutableListOf() + var offset = 0 + + fun add(text: Fb2Text, kind: ReaderPoemSegmentKind, gapBeforeDp: Int) { + segments += ReaderPoemSegment(text, kind, offset, gapBeforeDp) + offset += text.plainText().length + 1 + } + + title.forEachIndexed { index, text -> + add(text, ReaderPoemSegmentKind.Title, if (index == 0) 0 else 2) + } + blocks.forEach { block -> + when (block) { + is Fb2PoemBlock.Subtitle -> add(block.content, ReaderPoemSegmentKind.Subtitle, 8) + is Fb2PoemBlock.Stanza -> { + val stanza = block.stanza + val startsNewStanza = segments.isNotEmpty() + stanza.title.forEachIndexed { index, text -> + add(text, ReaderPoemSegmentKind.Subtitle, if (startsNewStanza && index == 0) 12 else 4) + } + stanza.subtitle?.let { add(it, ReaderPoemSegmentKind.Subtitle, 4) } + stanza.verses.forEachIndexed { index, verse -> + val gap = when { + index == 0 && startsNewStanza && stanza.title.isEmpty() && stanza.subtitle == null -> 12 + index == 0 -> 4 + else -> 0 + } + add(verse, ReaderPoemSegmentKind.Verse, gap) + } + } + } + } + textAuthors.forEachIndexed { index, text -> + add(text, ReaderPoemSegmentKind.TextAuthor, if (index == 0) 8 else 2) + } + date?.takeIf { it.isNotBlank() }?.let { date -> + add(Fb2Text(listOf(Fb2TextSpan(date))), ReaderPoemSegmentKind.Date, 2) + } + + return segments +} + +private fun ReaderSentenceRange.forSegment(segment: ReaderPoemSegment): ReaderSentenceRange? { + val segmentStart = segment.startOffset + val segmentEnd = segmentStart + segment.text.plainText().length + val overlapStart = max(start, segmentStart) + val overlapEnd = min(endExclusive, segmentEnd) + if (overlapStart >= overlapEnd) return null + return ReaderSentenceRange( + start = overlapStart - segmentStart, + endExclusive = overlapEnd - segmentStart, + pauseAfterMillis = pauseAfterMillis, + ) } private data class ReaderSentenceRange( @@ -1006,10 +1388,42 @@ internal data class BookStats( fun from(book: Fb2Book): BookStats { val sections = book.sections.flattenSections() val words = sections.sumOf { entry -> - entry.section.paragraphs.sumOf { paragraph -> paragraph.split(Regex("\\s+")).count { it.isNotBlank() } } + entry.section.readableBlocks().sumOf { block -> block.wordCount() } } val bodyImages = sections.sumOf { it.section.images.size } + book.bodyImages.size return BookStats(words = words, sections = sections.size, images = bodyImages + book.coverImages.size) } } } + +private fun Fb2Block.wordCount(): Int = + when (this) { + Fb2Block.EmptyLine, + is Fb2Block.Image -> 0 + is Fb2Block.Cite -> cite.wordCount() + is Fb2Block.Epigraph -> epigraph.wordCount() + is Fb2Block.Paragraph -> content.plainText().wordCount() + is Fb2Block.Poem -> poem.wordCount() + is Fb2Block.Subtitle -> content.plainText().wordCount() + } + +private fun Fb2Poem.wordCount(): Int = + epigraphs.sumOf { it.wordCount() } + readerSegments().sumOf { it.text.plainText().wordCount() } + +private fun Fb2Epigraph.wordCount(): Int = + blocks.sumOf { it.wordCount() } + textAuthors.sumOf { it.plainText().wordCount() } + +private fun Fb2Cite.wordCount(): Int = + blocks.sumOf { it.wordCount() } + textAuthors.sumOf { it.plainText().wordCount() } + +private fun Fb2EpigraphBlock.wordCount(): Int = + when (this) { + Fb2EpigraphBlock.EmptyLine -> 0 + is Fb2EpigraphBlock.Cite -> cite.wordCount() + is Fb2EpigraphBlock.Paragraph -> content.plainText().wordCount() + is Fb2EpigraphBlock.Poem -> poem.wordCount() + is Fb2EpigraphBlock.Subtitle -> content.plainText().wordCount() + } + +private fun String.wordCount(): Int = + split(Regex("\\s+")).count { it.isNotBlank() } diff --git a/composeApp/src/commonTest/kotlin/net/sergeych/toread/ReadAloudContentPlanTest.kt b/composeApp/src/commonTest/kotlin/net/sergeych/toread/ReadAloudContentPlanTest.kt index 80c5b6c..0e5c88d 100644 --- a/composeApp/src/commonTest/kotlin/net/sergeych/toread/ReadAloudContentPlanTest.kt +++ b/composeApp/src/commonTest/kotlin/net/sergeych/toread/ReadAloudContentPlanTest.kt @@ -4,7 +4,12 @@ import kotlin.test.Test import kotlin.test.assertEquals import net.sergeych.toread.fb2.Fb2Block import net.sergeych.toread.fb2.Fb2Book +import net.sergeych.toread.fb2.Fb2Epigraph +import net.sergeych.toread.fb2.Fb2EpigraphBlock +import net.sergeych.toread.fb2.Fb2Poem +import net.sergeych.toread.fb2.Fb2PoemBlock import net.sergeych.toread.fb2.Fb2Section +import net.sergeych.toread.fb2.Fb2Stanza import net.sergeych.toread.fb2.Fb2Text import net.sergeych.toread.fb2.Fb2TextSpan @@ -146,6 +151,74 @@ class ReadAloudContentPlanTest { assertEquals("/б 'z /1.", plan.sentences.single().spokenText) } + @Test + fun poemVersesAreIncludedInReadAloudPlan() { + val plan = buildReaderContentPlan( + Fb2Book( + title = "Book", + sections = listOf( + Fb2Section( + blocks = listOf( + Fb2Block.Poem( + Fb2Poem( + blocks = listOf( + Fb2PoemBlock.Stanza( + Fb2Stanza( + verses = listOf( + text("Line one."), + text("Line two."), + ), + ), + ), + ), + ), + ), + ), + ), + ), + ), + ) + + assertEquals(listOf("Line one.", "Line two."), plan.sentences.map { it.text }) + assertEquals(1, plan.elements.filterIsInstance().size) + } + + @Test + fun epigraphsAreIncludedInReaderPlan() { + val plan = buildReaderContentPlan( + Fb2Book( + title = "Book", + bodyEpigraphs = listOf( + Fb2Epigraph( + blocks = listOf(Fb2EpigraphBlock.Paragraph(text("Body quote."))), + textAuthors = listOf(text("Body author.")), + ), + ), + sections = listOf( + Fb2Section( + blocks = listOf( + Fb2Block.Epigraph( + Fb2Epigraph( + blocks = listOf(Fb2EpigraphBlock.Paragraph(text("Section quote."))), + textAuthors = listOf(text("Section author.")), + ), + ), + ), + ), + ), + ), + ) + + assertEquals(2, plan.elements.filterIsInstance().size) + assertEquals( + listOf("Body quote.", "Body author.", "Section quote.", "Section author."), + plan.sentences.map { it.text }, + ) + } + private fun paragraph(text: String): Fb2Block.Paragraph = - Fb2Block.Paragraph(Fb2Text(listOf(Fb2TextSpan(text)))) + Fb2Block.Paragraph(text(text)) + + private fun text(text: String): Fb2Text = + Fb2Text(listOf(Fb2TextSpan(text))) } 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 ec71a33..01a10c5 100644 --- a/composeApp/src/jvmMain/kotlin/net/sergeych/toread/BookPlatform.jvm.kt +++ b/composeApp/src/jvmMain/kotlin/net/sergeych/toread/BookPlatform.jvm.kt @@ -312,9 +312,15 @@ actual suspend fun viewLibraryBookFile(fileId: String): Boolean = withContext(Di actual suspend fun loadBookInfoExtras(fileId: String): BookInfoExtras = withContext(Dispatchers.IO) { openLibraryDatabase().useLibrary { db -> val file = db.files.get(fileId) ?: return@useLibrary BookInfoExtras() - val clusterId = file.bodyClusterId ?: return@useLibrary BookInfoExtras() + val sourceFileName = file.originalFilename ?: file.storageUri?.substringAfterLast(File.separatorChar) + val clusterId = file.bodyClusterId ?: return@useLibrary BookInfoExtras( + sourceFileName = sourceFileName, + sourceFilePath = file.storageUri, + ) val readingPosition = db.readingStates.getForBodyCluster(clusterId)?.anchor?.formatHintsJson?.toReadingPosition() BookInfoExtras( + sourceFileName = sourceFileName, + sourceFilePath = file.storageUri, bookmarks = db.bookmarks.listForBodyCluster(clusterId).map { BookmarkInfo( title = it.title, diff --git a/shared/src/commonMain/kotlin/net/sergeych/toread/fb2/Fb2Book.kt b/shared/src/commonMain/kotlin/net/sergeych/toread/fb2/Fb2Book.kt index 7fc6745..90d537a 100644 --- a/shared/src/commonMain/kotlin/net/sergeych/toread/fb2/Fb2Book.kt +++ b/shared/src/commonMain/kotlin/net/sergeych/toread/fb2/Fb2Book.kt @@ -15,6 +15,7 @@ data class Fb2Book( val documentInfo: Fb2DocumentInfo = Fb2DocumentInfo(), val bodyTitle: List = emptyList(), val bodyImages: List = emptyList(), + val bodyEpigraphs: List = emptyList(), val sections: List = emptyList(), val binaries: List = emptyList(), ) { @@ -69,10 +70,50 @@ data class Fb2Binary( sealed interface Fb2Block { data class Paragraph(val content: Fb2Text) : Fb2Block data class Subtitle(val content: Fb2Text) : Fb2Block + data class Epigraph(val epigraph: Fb2Epigraph) : Fb2Block + data class Cite(val cite: Fb2Cite) : Fb2Block + data class Poem(val poem: Fb2Poem) : Fb2Block data class Image(val image: Fb2ImageRef) : Fb2Block data object EmptyLine : Fb2Block } +data class Fb2Epigraph( + val blocks: List = emptyList(), + val textAuthors: List = emptyList(), +) + +data class Fb2Cite( + val blocks: List = emptyList(), + val textAuthors: List = emptyList(), +) + +sealed interface Fb2EpigraphBlock { + data class Paragraph(val content: Fb2Text) : Fb2EpigraphBlock + data class Subtitle(val content: Fb2Text) : Fb2EpigraphBlock + data class Poem(val poem: Fb2Poem) : Fb2EpigraphBlock + data class Cite(val cite: Fb2Cite) : Fb2EpigraphBlock + data object EmptyLine : Fb2EpigraphBlock +} + +data class Fb2Poem( + val title: List = emptyList(), + val epigraphs: List = emptyList(), + val blocks: List = emptyList(), + val textAuthors: List = emptyList(), + val date: String? = null, +) + +sealed interface Fb2PoemBlock { + data class Subtitle(val content: Fb2Text) : Fb2PoemBlock + data class Stanza(val stanza: Fb2Stanza) : Fb2PoemBlock +} + +data class Fb2Stanza( + val title: List = emptyList(), + val subtitle: Fb2Text? = null, + val verses: List = emptyList(), +) + data class Fb2Text( val spans: List, ) { diff --git a/shared/src/commonMain/kotlin/net/sergeych/toread/fb2/Fb2XmlMapper.kt b/shared/src/commonMain/kotlin/net/sergeych/toread/fb2/Fb2XmlMapper.kt index 25bf04b..97c9297 100644 --- a/shared/src/commonMain/kotlin/net/sergeych/toread/fb2/Fb2XmlMapper.kt +++ b/shared/src/commonMain/kotlin/net/sergeych/toread/fb2/Fb2XmlMapper.kt @@ -32,6 +32,7 @@ internal object Fb2XmlMapper { documentInfo = documentInfoFrom(documentInfo), bodyTitle = body?.first("title")?.children("p")?.mapNotNull { it.text().ifBlank { null } }.orEmpty(), bodyImages = body?.children("image")?.mapNotNull(::imageRefFrom).orEmpty(), + bodyEpigraphs = body?.children("epigraph")?.map(::epigraphFrom).orEmpty(), sections = body?.children("section")?.map(::sectionFrom).orEmpty(), binaries = root.children("binary").mapNotNull(::binaryFrom), ) @@ -124,6 +125,9 @@ internal object Fb2XmlMapper { when (child.localName) { "p" -> Fb2Block.Paragraph(textFrom(child)) "subtitle" -> Fb2Block.Subtitle(textFrom(child)) + "epigraph" -> Fb2Block.Epigraph(epigraphFrom(child)) + "cite" -> Fb2Block.Cite(citeFrom(child)) + "poem" -> Fb2Block.Poem(poemFrom(child)) "image" -> imageRefFrom(child)?.let(Fb2Block::Image) "empty-line" -> Fb2Block.EmptyLine else -> null @@ -140,8 +144,64 @@ internal object Fb2XmlMapper { ) } + private fun poemFrom(element: XmlElement): Fb2Poem = + Fb2Poem( + title = titleFrom(element.first("title")), + epigraphs = element.children("epigraph").map(::epigraphFrom), + blocks = element.nodes.mapNotNull { node -> + val child = (node as? XmlNode.ElementNode)?.element ?: return@mapNotNull null + when (child.localName) { + "subtitle" -> Fb2PoemBlock.Subtitle(textFrom(child)) + "stanza" -> Fb2PoemBlock.Stanza(stanzaFrom(child)) + else -> null + } + }, + textAuthors = element.children("text-author").map(::textFrom), + date = element.first("date")?.text()?.ifBlank { null }, + ) + + private fun epigraphFrom(element: XmlElement): Fb2Epigraph = + Fb2Epigraph( + blocks = element.nodes.mapNotNull { node -> + val child = (node as? XmlNode.ElementNode)?.element ?: return@mapNotNull null + when (child.localName) { + "p" -> Fb2EpigraphBlock.Paragraph(textFrom(child)) + "poem" -> Fb2EpigraphBlock.Poem(poemFrom(child)) + "cite" -> Fb2EpigraphBlock.Cite(citeFrom(child)) + "empty-line" -> Fb2EpigraphBlock.EmptyLine + else -> null + } + }, + textAuthors = element.children("text-author").map(::textFrom), + ) + + private fun citeFrom(element: XmlElement): Fb2Cite = + Fb2Cite( + blocks = element.nodes.mapNotNull { node -> + val child = (node as? XmlNode.ElementNode)?.element ?: return@mapNotNull null + when (child.localName) { + "p" -> Fb2EpigraphBlock.Paragraph(textFrom(child)) + "subtitle" -> Fb2EpigraphBlock.Subtitle(textFrom(child)) + "poem" -> Fb2EpigraphBlock.Poem(poemFrom(child)) + "empty-line" -> Fb2EpigraphBlock.EmptyLine + else -> null + } + }, + textAuthors = element.children("text-author").map(::textFrom), + ) + + private fun stanzaFrom(element: XmlElement): Fb2Stanza = + Fb2Stanza( + title = titleFrom(element.first("title")), + subtitle = element.first("subtitle")?.let(::textFrom), + verses = element.children("v").map(::textFrom), + ) + + private fun titleFrom(element: XmlElement?): List = + element?.children("p")?.map(::textFrom).orEmpty() + private fun textFrom(element: XmlElement): Fb2Text = - Fb2Text(spansFrom(element.nodes).mergeAdjacent()) + Fb2Text(spansFrom(element.nodes).mergeAdjacent().trimBoundaryWhitespace()) private fun spansFrom(nodes: List, styles: Set = emptySet()): List = nodes.flatMap { node -> @@ -179,6 +239,14 @@ internal object Fb2XmlMapper { return merged } + private fun List.trimBoundaryWhitespace(): List { + if (isEmpty()) return emptyList() + val trimmed = toMutableList() + trimmed[0] = trimmed[0].copy(text = trimmed[0].text.trimStart()) + trimmed[trimmed.lastIndex] = trimmed.last().copy(text = trimmed.last().text.trimEnd()) + return trimmed.filter { it.text.isNotEmpty() } + } + private fun StringBuilder.appendSection(section: Fb2Section) { append("
") section.title?.takeIf { it.isNotBlank() }?.let { 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 5e0ce74..a23245b 100644 --- a/shared/src/commonTest/kotlin/net/sergeych/toread/fb2/Fb2FormatTest.kt +++ b/shared/src/commonTest/kotlin/net/sergeych/toread/fb2/Fb2FormatTest.kt @@ -81,6 +81,41 @@ class Fb2FormatTest { assertEquals("pic.png", (section.blocks[3] as Fb2Block.Image).image.binaryId) } + @Test + fun parsesPoemsWithStanzasVersesAndInlineStyles() { + val book = Fb2Format.parseXml(poemXml) + val poem = (book.sections.single().blocks.single() as Fb2Block.Poem).poem + + assertEquals("Song", poem.title.single().plainText) + val stanza = (poem.blocks.single() as Fb2PoemBlock.Stanza).stanza + assertEquals("First stanza", stanza.title.single().plainText) + assertEquals("Softly", stanza.subtitle?.plainText) + assertEquals(listOf("Line one", "Line two."), stanza.verses.map { it.plainText }) + assertEquals(setOf(Fb2TextStyle.Emphasis), stanza.verses[0].spans.single().styles) + assertEquals("Poet", poem.textAuthors.single().plainText) + assertEquals("1910", poem.date) + } + + @Test + fun parsesBodySectionAndPoemEpigraphs() { + val book = Fb2Format.parseXml(epigraphXml) + + assertEquals("Body quote", (book.bodyEpigraphs.single().blocks.single() as Fb2EpigraphBlock.Paragraph).content.plainText) + + val sectionBlocks = book.sections.single().blocks + val sectionEpigraph = (sectionBlocks[0] as Fb2Block.Epigraph).epigraph + assertEquals("Section quote", (sectionEpigraph.blocks[0] as Fb2EpigraphBlock.Paragraph).content.plainText) + assertEquals("Author", sectionEpigraph.textAuthors.single().plainText) + + val cite = (sectionEpigraph.blocks[1] as Fb2EpigraphBlock.Cite).cite + assertEquals("Cited line", (cite.blocks.single() as Fb2EpigraphBlock.Paragraph).content.plainText) + assertEquals("Cited author", cite.textAuthors.single().plainText) + + val poem = (sectionBlocks[1] as Fb2Block.Poem).poem + val poemEpigraph = poem.epigraphs.single() + assertEquals("Poem quote", (poemEpigraph.blocks.single() as Fb2EpigraphBlock.Paragraph).content.plainText) + } + private val sampleXml = """ @@ -134,6 +169,78 @@ class Fb2FormatTest { """.trimIndent() + private val poemXml = """ + + + + + A + Poetry + en + + + Toread + 2026-05-12 + poetry + 1.0 + + + +
+ + <p>Song</p> + + <p>First stanza</p> + Softly + + Line one + + Line two. + + Poet + 1910 + +
+ +
+ """.trimIndent() + + private val epigraphXml = """ + + + + + A + Epigraphs + en + + + Toread + 2026-05-12 + epigraphs + 1.0 + + + +

Body quote

+
+ +

Section quote

+ +

Cited line

+ Cited author +
+ Author +
+ +

Poem quote

+ Line. +
+
+ +
+ """.trimIndent() + private val windows1251Xml = """ diff --git a/shared/src/jdbcMain/kotlin/net/sergeych/toread/storage/jdbc/LibraryScanner.kt b/shared/src/jdbcMain/kotlin/net/sergeych/toread/storage/jdbc/LibraryScanner.kt index 1d68faa..d74ccd6 100644 --- a/shared/src/jdbcMain/kotlin/net/sergeych/toread/storage/jdbc/LibraryScanner.kt +++ b/shared/src/jdbcMain/kotlin/net/sergeych/toread/storage/jdbc/LibraryScanner.kt @@ -2,7 +2,12 @@ package net.sergeych.toread.storage.jdbc import net.sergeych.toread.fb2.Fb2Block import net.sergeych.toread.fb2.Fb2Book +import net.sergeych.toread.fb2.Fb2Cite +import net.sergeych.toread.fb2.Fb2Epigraph +import net.sergeych.toread.fb2.Fb2EpigraphBlock import net.sergeych.toread.fb2.Fb2Format +import net.sergeych.toread.fb2.Fb2Poem +import net.sergeych.toread.fb2.Fb2PoemBlock import net.sergeych.toread.fb2.Fb2Section import net.sergeych.toread.storage.BodyClusterRecord import net.sergeych.toread.storage.BookBodyRecord @@ -246,7 +251,7 @@ private fun String.bookMimeType(): String = if (endsWith(".zip", ignoreCase = true)) "application/zip" else "application/x-fictionbook+xml" private fun Fb2Book.canonicalText(): String = - sections.flatMap { it.textBlocks() } + (bodyEpigraphs.flatMap { it.textBlocks() } + sections.flatMap { it.textBlocks() }) .joinToString(separator = "\n") { it.normalizeForBodyHash() } .trim() @@ -256,8 +261,11 @@ private fun Fb2Section.textBlocks(): List { blocks.forEach { block -> when (block) { Fb2Block.EmptyLine -> Unit + is Fb2Block.Cite -> addAll(block.cite.textBlocks()) + is Fb2Block.Epigraph -> addAll(block.epigraph.textBlocks()) is Fb2Block.Image -> Unit is Fb2Block.Paragraph -> add(block.content.plainText) + is Fb2Block.Poem -> addAll(block.poem.textBlocks()) is Fb2Block.Subtitle -> add(block.content.plainText) } } @@ -266,6 +274,42 @@ private fun Fb2Section.textBlocks(): List { return current + sections.flatMap { it.textBlocks() } } +private fun Fb2Poem.textBlocks(): List = buildList { + title.forEach { add(it.plainText) } + epigraphs.forEach { addAll(it.textBlocks()) } + blocks.forEach { block -> + when (block) { + is Fb2PoemBlock.Stanza -> { + block.stanza.title.forEach { add(it.plainText) } + block.stanza.subtitle?.let { add(it.plainText) } + block.stanza.verses.forEach { add(it.plainText) } + } + is Fb2PoemBlock.Subtitle -> add(block.content.plainText) + } + } + textAuthors.forEach { add(it.plainText) } + date?.let { add(it) } +} + +private fun Fb2Epigraph.textBlocks(): List = buildList { + blocks.forEach { addAll(it.textBlocks()) } + textAuthors.forEach { add(it.plainText) } +} + +private fun Fb2Cite.textBlocks(): List = buildList { + blocks.forEach { addAll(it.textBlocks()) } + textAuthors.forEach { add(it.plainText) } +} + +private fun Fb2EpigraphBlock.textBlocks(): List = + when (this) { + Fb2EpigraphBlock.EmptyLine -> emptyList() + is Fb2EpigraphBlock.Cite -> cite.textBlocks() + is Fb2EpigraphBlock.Paragraph -> listOf(content.plainText) + is Fb2EpigraphBlock.Poem -> poem.textBlocks() + is Fb2EpigraphBlock.Subtitle -> listOf(content.plainText) + } + private fun String.normalizeForBodyHash(): String = lowercase() .replace('\u00ad'.toString(), "")