support for poetry and epigraphs

This commit is contained in:
sergeych committed 2026-05-24 09:31:32 +03:00
1 parent 316b10d015
commit de04b9cbed
11 files changed
+787 -10

No files matched your search

@@ -15,6 +15,7 @@ data class Fb2Book(
val documentInfo: Fb2DocumentInfo = Fb2DocumentInfo(),
val bodyTitle: List<String> = emptyList(),
val bodyImages: List<Fb2ImageRef> = emptyList(),
val bodyEpigraphs: List<Fb2Epigraph> = emptyList(),
val sections: List<Fb2Section> = emptyList(),
val binaries: List<Fb2Binary> = emptyList(),
) {
@@ -69,10 +70,50 @@ data class Fb2Binary(
sealed interface Fb2Block {
data class Paragraph(val content: Fb2Text) : Fb2Block
data class Subtitle(val content: Fb2Text) : Fb2Block
data class Epigraph(val epigraph: Fb2Epigraph) : Fb2Block
data class Cite(val cite: Fb2Cite) : Fb2Block
data class Poem(val poem: Fb2Poem) : Fb2Block
data class Image(val image: Fb2ImageRef) : Fb2Block
data object EmptyLine : Fb2Block
}
data class Fb2Epigraph(
val blocks: List<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(
val spans: List<Fb2TextSpan>,
) {
@@ -32,6 +32,7 @@ internal object Fb2XmlMapper {
documentInfo = documentInfoFrom(documentInfo),
bodyTitle = body?.first("title")?.children("p")?.mapNotNull { it.text().ifBlank { null } }.orEmpty(),
bodyImages = body?.children("image")?.mapNotNull(::imageRefFrom).orEmpty(),
bodyEpigraphs = body?.children("epigraph")?.map(::epigraphFrom).orEmpty(),
sections = body?.children("section")?.map(::sectionFrom).orEmpty(),
binaries = root.children("binary").mapNotNull(::binaryFrom),
)
@@ -124,6 +125,9 @@ internal object Fb2XmlMapper {
when (child.localName) {
"p" -> Fb2Block.Paragraph(textFrom(child))
"subtitle" -> Fb2Block.Subtitle(textFrom(child))
"epigraph" -> Fb2Block.Epigraph(epigraphFrom(child))
"cite" -> Fb2Block.Cite(citeFrom(child))
"poem" -> Fb2Block.Poem(poemFrom(child))
"image" -> imageRefFrom(child)?.let(Fb2Block::Image)
"empty-line" -> Fb2Block.EmptyLine
else -> null
@@ -140,8 +144,64 @@ internal object Fb2XmlMapper {
)
}
private fun poemFrom(element: XmlElement): Fb2Poem =
Fb2Poem(
title = titleFrom(element.first("title")),
epigraphs = element.children("epigraph").map(::epigraphFrom),
blocks = element.nodes.mapNotNull { node ->
val child = (node as? XmlNode.ElementNode)?.element ?: return@mapNotNull null
when (child.localName) {
"subtitle" -> Fb2PoemBlock.Subtitle(textFrom(child))
"stanza" -> Fb2PoemBlock.Stanza(stanzaFrom(child))
else -> null
}
},
textAuthors = element.children("text-author").map(::textFrom),
date = element.first("date")?.text()?.ifBlank { null },
)
private fun epigraphFrom(element: XmlElement): Fb2Epigraph =
Fb2Epigraph(
blocks = element.nodes.mapNotNull { node ->
val child = (node as? XmlNode.ElementNode)?.element ?: return@mapNotNull null
when (child.localName) {
"p" -> Fb2EpigraphBlock.Paragraph(textFrom(child))
"poem" -> Fb2EpigraphBlock.Poem(poemFrom(child))
"cite" -> Fb2EpigraphBlock.Cite(citeFrom(child))
"empty-line" -> Fb2EpigraphBlock.EmptyLine
else -> null
}
},
textAuthors = element.children("text-author").map(::textFrom),
)
private fun citeFrom(element: XmlElement): Fb2Cite =
Fb2Cite(
blocks = element.nodes.mapNotNull { node ->
val child = (node as? XmlNode.ElementNode)?.element ?: return@mapNotNull null
when (child.localName) {
"p" -> Fb2EpigraphBlock.Paragraph(textFrom(child))
"subtitle" -> Fb2EpigraphBlock.Subtitle(textFrom(child))
"poem" -> Fb2EpigraphBlock.Poem(poemFrom(child))
"empty-line" -> Fb2EpigraphBlock.EmptyLine
else -> null
}
},
textAuthors = element.children("text-author").map(::textFrom),
)
private fun stanzaFrom(element: XmlElement): Fb2Stanza =
Fb2Stanza(
title = titleFrom(element.first("title")),
subtitle = element.first("subtitle")?.let(::textFrom),
verses = element.children("v").map(::textFrom),
)
private fun titleFrom(element: XmlElement?): List<Fb2Text> =
element?.children("p")?.map(::textFrom).orEmpty()
private fun textFrom(element: XmlElement): Fb2Text =
Fb2Text(spansFrom(element.nodes).mergeAdjacent())
Fb2Text(spansFrom(element.nodes).mergeAdjacent().trimBoundaryWhitespace())
private fun spansFrom(nodes: List<XmlNode>, styles: Set<Fb2TextStyle> = emptySet()): List<Fb2TextSpan> =
nodes.flatMap { node ->
@@ -179,6 +239,14 @@ internal object Fb2XmlMapper {
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) {
append("<section>")
section.title?.takeIf { it.isNotBlank() }?.let {
@@ -81,6 +81,41 @@ class Fb2FormatTest {
assertEquals("pic.png", (section.blocks[3] as Fb2Block.Image).image.binaryId)
}
@Test
fun parsesPoemsWithStanzasVersesAndInlineStyles() {
val book = Fb2Format.parseXml(poemXml)
val poem = (book.sections.single().blocks.single() as Fb2Block.Poem).poem
assertEquals("Song", poem.title.single().plainText)
val stanza = (poem.blocks.single() as Fb2PoemBlock.Stanza).stanza
assertEquals("First stanza", stanza.title.single().plainText)
assertEquals("Softly", stanza.subtitle?.plainText)
assertEquals(listOf("Line one", "Line two."), stanza.verses.map { it.plainText })
assertEquals(setOf(Fb2TextStyle.Emphasis), stanza.verses[0].spans.single().styles)
assertEquals("Poet", poem.textAuthors.single().plainText)
assertEquals("1910", poem.date)
}
@Test
fun parsesBodySectionAndPoemEpigraphs() {
val book = Fb2Format.parseXml(epigraphXml)
assertEquals("Body quote", (book.bodyEpigraphs.single().blocks.single() as Fb2EpigraphBlock.Paragraph).content.plainText)
val sectionBlocks = book.sections.single().blocks
val sectionEpigraph = (sectionBlocks[0] as Fb2Block.Epigraph).epigraph
assertEquals("Section quote", (sectionEpigraph.blocks[0] as Fb2EpigraphBlock.Paragraph).content.plainText)
assertEquals("Author", sectionEpigraph.textAuthors.single().plainText)
val cite = (sectionEpigraph.blocks[1] as Fb2EpigraphBlock.Cite).cite
assertEquals("Cited line", (cite.blocks.single() as Fb2EpigraphBlock.Paragraph).content.plainText)
assertEquals("Cited author", cite.textAuthors.single().plainText)
val poem = (sectionBlocks[1] as Fb2Block.Poem).poem
val poemEpigraph = poem.epigraphs.single()
assertEquals("Poem quote", (poemEpigraph.blocks.single() as Fb2EpigraphBlock.Paragraph).content.plainText)
}
private val sampleXml = """
<?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">
@@ -134,6 +169,78 @@ class Fb2FormatTest {
</FictionBook>
""".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 = """
<?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">
@@ -2,7 +2,12 @@ package net.sergeych.toread.storage.jdbc
import net.sergeych.toread.fb2.Fb2Block
import net.sergeych.toread.fb2.Fb2Book
import net.sergeych.toread.fb2.Fb2Cite
import net.sergeych.toread.fb2.Fb2Epigraph
import net.sergeych.toread.fb2.Fb2EpigraphBlock
import net.sergeych.toread.fb2.Fb2Format
import net.sergeych.toread.fb2.Fb2Poem
import net.sergeych.toread.fb2.Fb2PoemBlock
import net.sergeych.toread.fb2.Fb2Section
import net.sergeych.toread.storage.BodyClusterRecord
import net.sergeych.toread.storage.BookBodyRecord
@@ -246,7 +251,7 @@ private fun String.bookMimeType(): String =
if (endsWith(".zip", ignoreCase = true)) "application/zip" else "application/x-fictionbook+xml"
private fun Fb2Book.canonicalText(): String =
sections.flatMap { it.textBlocks() }
(bodyEpigraphs.flatMap { it.textBlocks() } + sections.flatMap { it.textBlocks() })
.joinToString(separator = "\n") { it.normalizeForBodyHash() }
.trim()
@@ -256,8 +261,11 @@ private fun Fb2Section.textBlocks(): List<String> {
blocks.forEach { block ->
when (block) {
Fb2Block.EmptyLine -> Unit
is Fb2Block.Cite -> addAll(block.cite.textBlocks())
is Fb2Block.Epigraph -> addAll(block.epigraph.textBlocks())
is Fb2Block.Image -> Unit
is Fb2Block.Paragraph -> add(block.content.plainText)
is Fb2Block.Poem -> addAll(block.poem.textBlocks())
is Fb2Block.Subtitle -> add(block.content.plainText)
}
}
@@ -266,6 +274,42 @@ private fun Fb2Section.textBlocks(): List<String> {
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 =
lowercase()
.replace('\u00ad'.toString(), "")