support for poetry and epigraphs

This commit is contained in:
Sergey Chernov 2026-05-24 09:31:32 +03:00
parent 316b10d015
commit de04b9cbed
11 changed files with 787 additions and 10 deletions

View File

@ -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,

View File

@ -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

View File

@ -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,

View File

@ -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 = "Элемент списка"

View File

@ -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() }

View File

@ -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)))
} }

View File

@ -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,

View File

@ -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>,
) { ) {

View File

@ -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 {

View File

@ -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">

View File

@ -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(), "")