support for poetry and epigraphs
This commit is contained in:
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(), "")
|
||||
|
||||
Reference in new issue
Block a user