support for poetry and epigraphs
This commit is contained in:
parent
316b10d015
commit
de04b9cbed
@ -377,9 +377,15 @@ 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()
|
||||||
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()
|
val readingPosition = db.readingStates.getForBodyCluster(clusterId)?.anchor?.formatHintsJson?.toReadingPosition()
|
||||||
BookInfoExtras(
|
BookInfoExtras(
|
||||||
|
sourceFileName = sourceFileName,
|
||||||
|
sourceFilePath = file.storageUri,
|
||||||
bookmarks = db.bookmarks.listForBodyCluster(clusterId).map {
|
bookmarks = db.bookmarks.listForBodyCluster(clusterId).map {
|
||||||
BookmarkInfo(
|
BookmarkInfo(
|
||||||
title = it.title,
|
title = it.title,
|
||||||
|
|||||||
@ -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 {
|
item {
|
||||||
InfoSection(strings.lastReadingPosition) {
|
InfoSection(strings.lastReadingPosition) {
|
||||||
val position = extras?.lastReadingPosition
|
val position = extras?.lastReadingPosition
|
||||||
|
|||||||
@ -64,6 +64,8 @@ data class ReadingPosition(
|
|||||||
)
|
)
|
||||||
|
|
||||||
data class BookInfoExtras(
|
data class BookInfoExtras(
|
||||||
|
val sourceFileName: String? = null,
|
||||||
|
val sourceFilePath: String? = null,
|
||||||
val bookmarks: List<BookmarkInfo> = emptyList(),
|
val bookmarks: List<BookmarkInfo> = emptyList(),
|
||||||
val notes: List<NoteInfo> = emptyList(),
|
val notes: List<NoteInfo> = emptyList(),
|
||||||
val lastReadingPosition: ReadingPosition? = null,
|
val lastReadingPosition: ReadingPosition? = null,
|
||||||
|
|||||||
@ -117,6 +117,9 @@ internal open class AppStrings {
|
|||||||
open val words = "Words"
|
open val words = "Words"
|
||||||
open val sections = "Sections"
|
open val sections = "Sections"
|
||||||
open val images = "Images"
|
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 lastReadingPosition = "Last Reading Position"
|
||||||
open val noSavedPosition = "No saved position"
|
open val noSavedPosition = "No saved position"
|
||||||
open val listItem = "List item"
|
open val listItem = "List item"
|
||||||
@ -243,7 +246,7 @@ internal object RussianStrings : AppStrings() {
|
|||||||
override val couldNotOpenBook = "Не удалось открыть книгу."
|
override val couldNotOpenBook = "Не удалось открыть книгу."
|
||||||
override val couldNotUpdateBook = "Не удалось обновить книгу."
|
override val couldNotUpdateBook = "Не удалось обновить книгу."
|
||||||
override val bookFileNotAvailable = "Файл книги недоступен."
|
override val bookFileNotAvailable = "Файл книги недоступен."
|
||||||
override val scanFailed = "Сканирование не удалось."
|
override val scanFailed = "Импорт не удался."
|
||||||
override val searchFailed = "Поиск не удался."
|
override val searchFailed = "Поиск не удался."
|
||||||
override val libraryRescanFailed = "Повторное сканирование библиотеки не удалось."
|
override val libraryRescanFailed = "Повторное сканирование библиотеки не удалось."
|
||||||
override val rescanningLibrary = "Пересканируем библиотеку..."
|
override val rescanningLibrary = "Пересканируем библиотеку..."
|
||||||
@ -287,7 +290,7 @@ internal object RussianStrings : AppStrings() {
|
|||||||
override val filterToRead = "К чтению"
|
override val filterToRead = "К чтению"
|
||||||
override val filterRead = "Прочитанные"
|
override val filterRead = "Прочитанные"
|
||||||
|
|
||||||
override val scan = "Сканирование"
|
override val scan = "Импорт"
|
||||||
override val rootFolder = "Корневая папка"
|
override val rootFolder = "Корневая папка"
|
||||||
override val choose = "Выбрать"
|
override val choose = "Выбрать"
|
||||||
override val logPrefix = "Лог"
|
override val logPrefix = "Лог"
|
||||||
@ -309,6 +312,9 @@ internal object RussianStrings : AppStrings() {
|
|||||||
override val words = "Слова"
|
override val words = "Слова"
|
||||||
override val sections = "Разделы"
|
override val sections = "Разделы"
|
||||||
override val images = "Иллюстрации"
|
override val images = "Иллюстрации"
|
||||||
|
override val sourceFile = "Исходный файл"
|
||||||
|
override val fileName = "Имя файла"
|
||||||
|
override val filePath = "Путь"
|
||||||
override val lastReadingPosition = "Последняя позиция чтения"
|
override val lastReadingPosition = "Последняя позиция чтения"
|
||||||
override val noSavedPosition = "Позиция не сохранена"
|
override val noSavedPosition = "Позиция не сохранена"
|
||||||
override val listItem = "Элемент списка"
|
override val listItem = "Элемент списка"
|
||||||
|
|||||||
@ -79,7 +79,12 @@ import androidx.compose.ui.unit.isSpecified
|
|||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import net.sergeych.toread.fb2.Fb2Block
|
import net.sergeych.toread.fb2.Fb2Block
|
||||||
import net.sergeych.toread.fb2.Fb2Book
|
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.Fb2ImageRef
|
||||||
|
import net.sergeych.toread.fb2.Fb2Poem
|
||||||
|
import net.sergeych.toread.fb2.Fb2PoemBlock
|
||||||
import net.sergeych.toread.fb2.Fb2Section
|
import net.sergeych.toread.fb2.Fb2Section
|
||||||
import net.sergeych.toread.fb2.Fb2Text
|
import net.sergeych.toread.fb2.Fb2Text
|
||||||
import net.sergeych.toread.fb2.Fb2TextSpan
|
import net.sergeych.toread.fb2.Fb2TextSpan
|
||||||
@ -210,6 +215,27 @@ internal fun ContinuousBookReader(
|
|||||||
modifier = Modifier.fillMaxWidth().padding(top = 18.dp, bottom = 8.dp),
|
modifier = Modifier.fillMaxWidth().padding(top = 18.dp, bottom = 8.dp),
|
||||||
onTextLayout = { textLineMetricsByItem[itemIndex] = it.toTextLineMetrics() },
|
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),
|
style = readerParagraphTextStyle(book.language),
|
||||||
textAlign = TextAlign.Unspecified,
|
textAlign = TextAlign.Unspecified,
|
||||||
)
|
)
|
||||||
|
is Fb2Block.Poem -> ReaderPoem(
|
||||||
|
poem = block.poem,
|
||||||
|
language = book.language,
|
||||||
|
hyphenation = hyphenation,
|
||||||
|
highlightedRange = null,
|
||||||
|
depth = 0,
|
||||||
|
)
|
||||||
is Fb2Block.Subtitle -> ReaderText(
|
is Fb2Block.Subtitle -> ReaderText(
|
||||||
text = block.content,
|
text = block.content,
|
||||||
language = book.language,
|
language = book.language,
|
||||||
@ -475,6 +508,20 @@ private fun ReaderPane(book: Fb2Book, section: Fb2Section?, modifier: Modifier =
|
|||||||
textAlign = TextAlign.Center,
|
textAlign = TextAlign.Center,
|
||||||
modifier = Modifier.fillMaxWidth().padding(top = 18.dp, bottom = 8.dp),
|
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)) }
|
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
|
@Composable
|
||||||
private fun readerParagraphTextStyle(language: String?): TextStyle =
|
private fun readerParagraphTextStyle(language: String?): TextStyle =
|
||||||
MaterialTheme.typography.bodyLarge.copy(
|
MaterialTheme.typography.bodyLarge.copy(
|
||||||
@ -766,6 +1019,7 @@ internal fun buildReaderContentPlan(book: Fb2Book): ReaderContentPlan {
|
|||||||
fun addTextSentences(
|
fun addTextSentences(
|
||||||
itemIndex: Int,
|
itemIndex: Int,
|
||||||
text: Fb2Text,
|
text: Fb2Text,
|
||||||
|
offset: Int = 0,
|
||||||
pauseBeforeMillis: Long = 0,
|
pauseBeforeMillis: Long = 0,
|
||||||
pauseAfterMillis: Long = 0,
|
pauseAfterMillis: Long = 0,
|
||||||
) {
|
) {
|
||||||
@ -784,8 +1038,8 @@ internal fun buildReaderContentPlan(book: Fb2Book): ReaderContentPlan {
|
|||||||
sentences += ReadAloudSentence(
|
sentences += ReadAloudSentence(
|
||||||
index = sentences.size,
|
index = sentences.size,
|
||||||
itemIndex = itemIndex,
|
itemIndex = itemIndex,
|
||||||
start = range.start,
|
start = offset + range.start,
|
||||||
endExclusive = range.endExclusive,
|
endExclusive = offset + range.endExclusive,
|
||||||
text = sentenceText,
|
text = sentenceText,
|
||||||
spokenText = spokenText,
|
spokenText = spokenText,
|
||||||
pauseBeforeMillis = effectivePauseBefore,
|
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) {
|
fun addSection(section: Fb2Section, depth: Int) {
|
||||||
if (section.title.isNullOrBlank()) {
|
if (section.title.isNullOrBlank()) {
|
||||||
elements += ReaderElement.SectionSeparator
|
elements += ReaderElement.SectionSeparator
|
||||||
@ -821,6 +1112,10 @@ internal fun buildReaderContentPlan(book: Fb2Book): ReaderContentPlan {
|
|||||||
addTextSentences(itemIndex, block.content)
|
addTextSentences(itemIndex, block.content)
|
||||||
elements += ReaderElement.Paragraph(block.content, depth)
|
elements += ReaderElement.Paragraph(block.content, depth)
|
||||||
}
|
}
|
||||||
|
is Fb2Block.Poem -> {
|
||||||
|
addPoemSentences(itemIndex, block.poem)
|
||||||
|
elements += ReaderElement.Poem(block.poem, depth)
|
||||||
|
}
|
||||||
is Fb2Block.Subtitle -> {
|
is Fb2Block.Subtitle -> {
|
||||||
addTextSentences(
|
addTextSentences(
|
||||||
itemIndex = itemIndex,
|
itemIndex = itemIndex,
|
||||||
@ -830,6 +1125,14 @@ internal fun buildReaderContentPlan(book: Fb2Book): ReaderContentPlan {
|
|||||||
)
|
)
|
||||||
elements += ReaderElement.Subtitle(block.content)
|
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) }
|
section.sections.forEach { addSection(it, depth + 1) }
|
||||||
@ -837,6 +1140,11 @@ internal fun buildReaderContentPlan(book: Fb2Book): ReaderContentPlan {
|
|||||||
|
|
||||||
elements += ReaderElement.Cover
|
elements += ReaderElement.Cover
|
||||||
elements += ReaderElement.FixedSpacer(6)
|
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) }
|
book.sections.forEach { addSection(it, 0) }
|
||||||
elements += ReaderElement.FixedSpacer(22)
|
elements += ReaderElement.FixedSpacer(22)
|
||||||
|
|
||||||
@ -866,6 +1174,80 @@ internal sealed interface ReaderElement {
|
|||||||
data class BookImage(val image: Fb2ImageRef) : ReaderElement
|
data class BookImage(val image: Fb2ImageRef) : ReaderElement
|
||||||
data class Paragraph(val text: Fb2Text, val depth: Int) : ReaderElement
|
data class Paragraph(val text: Fb2Text, val depth: Int) : ReaderElement
|
||||||
data class Subtitle(val text: Fb2Text) : 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<ReaderPoemSegment> {
|
||||||
|
val segments = mutableListOf<ReaderPoemSegment>()
|
||||||
|
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(
|
private data class ReaderSentenceRange(
|
||||||
@ -1006,10 +1388,42 @@ internal data class BookStats(
|
|||||||
fun from(book: Fb2Book): BookStats {
|
fun from(book: Fb2Book): BookStats {
|
||||||
val sections = book.sections.flattenSections()
|
val sections = book.sections.flattenSections()
|
||||||
val words = sections.sumOf { entry ->
|
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
|
val bodyImages = sections.sumOf { it.section.images.size } + book.bodyImages.size
|
||||||
return BookStats(words = words, sections = sections.size, images = bodyImages + book.coverImages.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() }
|
||||||
|
|||||||
@ -4,7 +4,12 @@ import kotlin.test.Test
|
|||||||
import kotlin.test.assertEquals
|
import kotlin.test.assertEquals
|
||||||
import net.sergeych.toread.fb2.Fb2Block
|
import net.sergeych.toread.fb2.Fb2Block
|
||||||
import net.sergeych.toread.fb2.Fb2Book
|
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.Fb2Section
|
||||||
|
import net.sergeych.toread.fb2.Fb2Stanza
|
||||||
import net.sergeych.toread.fb2.Fb2Text
|
import net.sergeych.toread.fb2.Fb2Text
|
||||||
import net.sergeych.toread.fb2.Fb2TextSpan
|
import net.sergeych.toread.fb2.Fb2TextSpan
|
||||||
|
|
||||||
@ -146,6 +151,74 @@ class ReadAloudContentPlanTest {
|
|||||||
assertEquals("/б 'z /1.", plan.sentences.single().spokenText)
|
assertEquals("/б 'z /1.", plan.sentences.single().spokenText)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun paragraph(text: String): Fb2Block.Paragraph =
|
@Test
|
||||||
Fb2Block.Paragraph(Fb2Text(listOf(Fb2TextSpan(text))))
|
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<ReaderElement.Poem>().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<ReaderElement.Epigraph>().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(text(text))
|
||||||
|
|
||||||
|
private fun text(text: String): Fb2Text =
|
||||||
|
Fb2Text(listOf(Fb2TextSpan(text)))
|
||||||
}
|
}
|
||||||
|
|||||||
@ -312,9 +312,15 @@ actual suspend fun viewLibraryBookFile(fileId: String): Boolean = withContext(Di
|
|||||||
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()
|
||||||
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()
|
val readingPosition = db.readingStates.getForBodyCluster(clusterId)?.anchor?.formatHintsJson?.toReadingPosition()
|
||||||
BookInfoExtras(
|
BookInfoExtras(
|
||||||
|
sourceFileName = sourceFileName,
|
||||||
|
sourceFilePath = file.storageUri,
|
||||||
bookmarks = db.bookmarks.listForBodyCluster(clusterId).map {
|
bookmarks = db.bookmarks.listForBodyCluster(clusterId).map {
|
||||||
BookmarkInfo(
|
BookmarkInfo(
|
||||||
title = it.title,
|
title = it.title,
|
||||||
|
|||||||
@ -15,6 +15,7 @@ data class Fb2Book(
|
|||||||
val documentInfo: Fb2DocumentInfo = Fb2DocumentInfo(),
|
val documentInfo: Fb2DocumentInfo = Fb2DocumentInfo(),
|
||||||
val bodyTitle: List<String> = emptyList(),
|
val bodyTitle: List<String> = emptyList(),
|
||||||
val bodyImages: List<Fb2ImageRef> = emptyList(),
|
val bodyImages: List<Fb2ImageRef> = emptyList(),
|
||||||
|
val bodyEpigraphs: List<Fb2Epigraph> = emptyList(),
|
||||||
val sections: List<Fb2Section> = emptyList(),
|
val sections: List<Fb2Section> = emptyList(),
|
||||||
val binaries: List<Fb2Binary> = emptyList(),
|
val binaries: List<Fb2Binary> = emptyList(),
|
||||||
) {
|
) {
|
||||||
@ -69,10 +70,50 @@ data class Fb2Binary(
|
|||||||
sealed interface Fb2Block {
|
sealed interface Fb2Block {
|
||||||
data class Paragraph(val content: Fb2Text) : Fb2Block
|
data class Paragraph(val content: Fb2Text) : Fb2Block
|
||||||
data class Subtitle(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 class Image(val image: Fb2ImageRef) : Fb2Block
|
||||||
data object EmptyLine : Fb2Block
|
data object EmptyLine : Fb2Block
|
||||||
}
|
}
|
||||||
|
|
||||||
|
data class Fb2Epigraph(
|
||||||
|
val blocks: List<Fb2EpigraphBlock> = emptyList(),
|
||||||
|
val textAuthors: List<Fb2Text> = emptyList(),
|
||||||
|
)
|
||||||
|
|
||||||
|
data class Fb2Cite(
|
||||||
|
val blocks: List<Fb2EpigraphBlock> = emptyList(),
|
||||||
|
val textAuthors: List<Fb2Text> = 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<Fb2Text> = emptyList(),
|
||||||
|
val epigraphs: List<Fb2Epigraph> = emptyList(),
|
||||||
|
val blocks: List<Fb2PoemBlock> = emptyList(),
|
||||||
|
val textAuthors: List<Fb2Text> = 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<Fb2Text> = emptyList(),
|
||||||
|
val subtitle: Fb2Text? = null,
|
||||||
|
val verses: List<Fb2Text> = emptyList(),
|
||||||
|
)
|
||||||
|
|
||||||
data class Fb2Text(
|
data class Fb2Text(
|
||||||
val spans: List<Fb2TextSpan>,
|
val spans: List<Fb2TextSpan>,
|
||||||
) {
|
) {
|
||||||
|
|||||||
@ -32,6 +32,7 @@ internal object Fb2XmlMapper {
|
|||||||
documentInfo = documentInfoFrom(documentInfo),
|
documentInfo = documentInfoFrom(documentInfo),
|
||||||
bodyTitle = body?.first("title")?.children("p")?.mapNotNull { it.text().ifBlank { null } }.orEmpty(),
|
bodyTitle = body?.first("title")?.children("p")?.mapNotNull { it.text().ifBlank { null } }.orEmpty(),
|
||||||
bodyImages = body?.children("image")?.mapNotNull(::imageRefFrom).orEmpty(),
|
bodyImages = body?.children("image")?.mapNotNull(::imageRefFrom).orEmpty(),
|
||||||
|
bodyEpigraphs = body?.children("epigraph")?.map(::epigraphFrom).orEmpty(),
|
||||||
sections = body?.children("section")?.map(::sectionFrom).orEmpty(),
|
sections = body?.children("section")?.map(::sectionFrom).orEmpty(),
|
||||||
binaries = root.children("binary").mapNotNull(::binaryFrom),
|
binaries = root.children("binary").mapNotNull(::binaryFrom),
|
||||||
)
|
)
|
||||||
@ -124,6 +125,9 @@ internal object Fb2XmlMapper {
|
|||||||
when (child.localName) {
|
when (child.localName) {
|
||||||
"p" -> Fb2Block.Paragraph(textFrom(child))
|
"p" -> Fb2Block.Paragraph(textFrom(child))
|
||||||
"subtitle" -> Fb2Block.Subtitle(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)
|
"image" -> imageRefFrom(child)?.let(Fb2Block::Image)
|
||||||
"empty-line" -> Fb2Block.EmptyLine
|
"empty-line" -> Fb2Block.EmptyLine
|
||||||
else -> null
|
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<Fb2Text> =
|
||||||
|
element?.children("p")?.map(::textFrom).orEmpty()
|
||||||
|
|
||||||
private fun textFrom(element: XmlElement): Fb2Text =
|
private fun textFrom(element: XmlElement): Fb2Text =
|
||||||
Fb2Text(spansFrom(element.nodes).mergeAdjacent())
|
Fb2Text(spansFrom(element.nodes).mergeAdjacent().trimBoundaryWhitespace())
|
||||||
|
|
||||||
private fun spansFrom(nodes: List<XmlNode>, styles: Set<Fb2TextStyle> = emptySet()): List<Fb2TextSpan> =
|
private fun spansFrom(nodes: List<XmlNode>, styles: Set<Fb2TextStyle> = emptySet()): List<Fb2TextSpan> =
|
||||||
nodes.flatMap { node ->
|
nodes.flatMap { node ->
|
||||||
@ -179,6 +239,14 @@ internal object Fb2XmlMapper {
|
|||||||
return merged
|
return merged
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun List<Fb2TextSpan>.trimBoundaryWhitespace(): List<Fb2TextSpan> {
|
||||||
|
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) {
|
private fun StringBuilder.appendSection(section: Fb2Section) {
|
||||||
append("<section>")
|
append("<section>")
|
||||||
section.title?.takeIf { it.isNotBlank() }?.let {
|
section.title?.takeIf { it.isNotBlank() }?.let {
|
||||||
|
|||||||
@ -81,6 +81,41 @@ class Fb2FormatTest {
|
|||||||
assertEquals("pic.png", (section.blocks[3] as Fb2Block.Image).image.binaryId)
|
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 = """
|
private val sampleXml = """
|
||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<FictionBook xmlns="http://www.gribuser.ru/xml/fictionbook/2.0" xmlns:xlink="http://www.w3.org/1999/xlink">
|
<FictionBook xmlns="http://www.gribuser.ru/xml/fictionbook/2.0" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||||
@ -134,6 +169,78 @@ class Fb2FormatTest {
|
|||||||
</FictionBook>
|
</FictionBook>
|
||||||
""".trimIndent()
|
""".trimIndent()
|
||||||
|
|
||||||
|
private val poemXml = """
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<FictionBook xmlns="http://www.gribuser.ru/xml/fictionbook/2.0" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||||
|
<description>
|
||||||
|
<title-info>
|
||||||
|
<author><nickname>A</nickname></author>
|
||||||
|
<book-title>Poetry</book-title>
|
||||||
|
<lang>en</lang>
|
||||||
|
</title-info>
|
||||||
|
<document-info>
|
||||||
|
<author><nickname>Toread</nickname></author>
|
||||||
|
<date>2026-05-12</date>
|
||||||
|
<id>poetry</id>
|
||||||
|
<version>1.0</version>
|
||||||
|
</document-info>
|
||||||
|
</description>
|
||||||
|
<body>
|
||||||
|
<section>
|
||||||
|
<poem>
|
||||||
|
<title><p>Song</p></title>
|
||||||
|
<stanza>
|
||||||
|
<title><p>First stanza</p></title>
|
||||||
|
<subtitle>Softly</subtitle>
|
||||||
|
<v>
|
||||||
|
<emphasis>Line one</emphasis>
|
||||||
|
</v>
|
||||||
|
<v>Line two.</v>
|
||||||
|
</stanza>
|
||||||
|
<text-author>Poet</text-author>
|
||||||
|
<date>1910</date>
|
||||||
|
</poem>
|
||||||
|
</section>
|
||||||
|
</body>
|
||||||
|
</FictionBook>
|
||||||
|
""".trimIndent()
|
||||||
|
|
||||||
|
private val epigraphXml = """
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<FictionBook xmlns="http://www.gribuser.ru/xml/fictionbook/2.0" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||||
|
<description>
|
||||||
|
<title-info>
|
||||||
|
<author><nickname>A</nickname></author>
|
||||||
|
<book-title>Epigraphs</book-title>
|
||||||
|
<lang>en</lang>
|
||||||
|
</title-info>
|
||||||
|
<document-info>
|
||||||
|
<author><nickname>Toread</nickname></author>
|
||||||
|
<date>2026-05-12</date>
|
||||||
|
<id>epigraphs</id>
|
||||||
|
<version>1.0</version>
|
||||||
|
</document-info>
|
||||||
|
</description>
|
||||||
|
<body>
|
||||||
|
<epigraph><p>Body quote</p></epigraph>
|
||||||
|
<section>
|
||||||
|
<epigraph>
|
||||||
|
<p>Section quote</p>
|
||||||
|
<cite>
|
||||||
|
<p>Cited line</p>
|
||||||
|
<text-author>Cited author</text-author>
|
||||||
|
</cite>
|
||||||
|
<text-author>Author</text-author>
|
||||||
|
</epigraph>
|
||||||
|
<poem>
|
||||||
|
<epigraph><p>Poem quote</p></epigraph>
|
||||||
|
<stanza><v>Line.</v></stanza>
|
||||||
|
</poem>
|
||||||
|
</section>
|
||||||
|
</body>
|
||||||
|
</FictionBook>
|
||||||
|
""".trimIndent()
|
||||||
|
|
||||||
private val windows1251Xml = """
|
private val windows1251Xml = """
|
||||||
<?xml version="1.0" encoding="windows-1251"?>
|
<?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">
|
<FictionBook xmlns="http://www.gribuser.ru/xml/fictionbook/2.0" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||||
|
|||||||
@ -2,7 +2,12 @@ package net.sergeych.toread.storage.jdbc
|
|||||||
|
|
||||||
import net.sergeych.toread.fb2.Fb2Block
|
import net.sergeych.toread.fb2.Fb2Block
|
||||||
import net.sergeych.toread.fb2.Fb2Book
|
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.Fb2Format
|
||||||
|
import net.sergeych.toread.fb2.Fb2Poem
|
||||||
|
import net.sergeych.toread.fb2.Fb2PoemBlock
|
||||||
import net.sergeych.toread.fb2.Fb2Section
|
import net.sergeych.toread.fb2.Fb2Section
|
||||||
import net.sergeych.toread.storage.BodyClusterRecord
|
import net.sergeych.toread.storage.BodyClusterRecord
|
||||||
import net.sergeych.toread.storage.BookBodyRecord
|
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"
|
if (endsWith(".zip", ignoreCase = true)) "application/zip" else "application/x-fictionbook+xml"
|
||||||
|
|
||||||
private fun Fb2Book.canonicalText(): String =
|
private fun Fb2Book.canonicalText(): String =
|
||||||
sections.flatMap { it.textBlocks() }
|
(bodyEpigraphs.flatMap { it.textBlocks() } + sections.flatMap { it.textBlocks() })
|
||||||
.joinToString(separator = "\n") { it.normalizeForBodyHash() }
|
.joinToString(separator = "\n") { it.normalizeForBodyHash() }
|
||||||
.trim()
|
.trim()
|
||||||
|
|
||||||
@ -256,8 +261,11 @@ private fun Fb2Section.textBlocks(): List<String> {
|
|||||||
blocks.forEach { block ->
|
blocks.forEach { block ->
|
||||||
when (block) {
|
when (block) {
|
||||||
Fb2Block.EmptyLine -> Unit
|
Fb2Block.EmptyLine -> Unit
|
||||||
|
is Fb2Block.Cite -> addAll(block.cite.textBlocks())
|
||||||
|
is Fb2Block.Epigraph -> addAll(block.epigraph.textBlocks())
|
||||||
is Fb2Block.Image -> Unit
|
is Fb2Block.Image -> Unit
|
||||||
is Fb2Block.Paragraph -> add(block.content.plainText)
|
is Fb2Block.Paragraph -> add(block.content.plainText)
|
||||||
|
is Fb2Block.Poem -> addAll(block.poem.textBlocks())
|
||||||
is Fb2Block.Subtitle -> add(block.content.plainText)
|
is Fb2Block.Subtitle -> add(block.content.plainText)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -266,6 +274,42 @@ private fun Fb2Section.textBlocks(): List<String> {
|
|||||||
return current + sections.flatMap { it.textBlocks() }
|
return current + sections.flatMap { it.textBlocks() }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun Fb2Poem.textBlocks(): List<String> = 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<String> = buildList {
|
||||||
|
blocks.forEach { addAll(it.textBlocks()) }
|
||||||
|
textAuthors.forEach { add(it.plainText) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun Fb2Cite.textBlocks(): List<String> = buildList {
|
||||||
|
blocks.forEach { addAll(it.textBlocks()) }
|
||||||
|
textAuthors.forEach { add(it.plainText) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun Fb2EpigraphBlock.textBlocks(): List<String> =
|
||||||
|
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 =
|
private fun String.normalizeForBodyHash(): String =
|
||||||
lowercase()
|
lowercase()
|
||||||
.replace('\u00ad'.toString(), "")
|
.replace('\u00ad'.toString(), "")
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user