library UI + reader mode

This commit is contained in:
sergeych committed 2026-05-17 00:53:18 +03:00
commit 8f8b466e89
81 files changed
+7502

No files matched your search

@@ -0,0 +1,3 @@
package net.sergeych.toread
const val SERVER_PORT = 8080
@@ -0,0 +1,9 @@
package net.sergeych.toread
class Greeting {
private val platform = getPlatform()
fun greet(): String {
return "Hello, ${platform.name}!"
}
}
@@ -0,0 +1,7 @@
package net.sergeych.toread
interface Platform {
val name: String
}
expect fun getPlatform(): Platform
@@ -0,0 +1,97 @@
package net.sergeych.toread.fb2
data class Fb2Book(
val title: String,
val authors: List<Fb2Author> = emptyList(),
val language: String? = null,
val sourceLanguage: String? = null,
val genres: List<String> = emptyList(),
val annotation: String? = null,
val keywords: String? = null,
val date: String? = null,
val translators: List<Fb2Author> = emptyList(),
val sequences: List<Fb2Sequence> = emptyList(),
val coverImages: List<Fb2ImageRef> = emptyList(),
val documentInfo: Fb2DocumentInfo = Fb2DocumentInfo(),
val bodyTitle: List<String> = emptyList(),
val bodyImages: List<Fb2ImageRef> = emptyList(),
val sections: List<Fb2Section> = emptyList(),
val binaries: List<Fb2Binary> = emptyList(),
) {
fun binaryFor(image: Fb2ImageRef): Fb2Binary? =
binaries.firstOrNull { it.id == image.binaryId }
}
data class Fb2Author(
val firstName: String? = null,
val middleName: String? = null,
val lastName: String? = null,
val nickname: String? = null,
) {
val displayName: String
get() = listOfNotNull(firstName, middleName, lastName).joinToString(" ")
.ifBlank { nickname.orEmpty() }
}
data class Fb2Sequence(
val name: String,
val number: String? = null,
)
data class Fb2DocumentInfo(
val authors: List<Fb2Author> = emptyList(),
val date: String? = null,
val id: String? = null,
val version: String? = null,
)
data class Fb2Section(
val title: String? = null,
val paragraphs: List<String> = emptyList(),
val images: List<Fb2ImageRef> = emptyList(),
val sections: List<Fb2Section> = emptyList(),
val blocks: List<Fb2Block> = emptyList(),
)
data class Fb2ImageRef(
val href: String,
val alt: String? = null,
) {
val binaryId: String = href.removePrefix("#")
}
data class Fb2Binary(
val id: String,
val contentType: String,
val base64: String,
)
sealed interface Fb2Block {
data class Paragraph(val content: Fb2Text) : Fb2Block
data class Subtitle(val content: Fb2Text) : Fb2Block
data class Image(val image: Fb2ImageRef) : Fb2Block
data object EmptyLine : Fb2Block
}
data class Fb2Text(
val spans: List<Fb2TextSpan>,
) {
val plainText: String
get() = spans.joinToString(separator = "") { it.text }.trim()
}
data class Fb2TextSpan(
val text: String,
val styles: Set<Fb2TextStyle> = emptySet(),
)
enum class Fb2TextStyle {
Emphasis,
Strong,
Code,
Strikethrough,
Superscript,
Subscript,
}
class Fb2ParseException(message: String) : IllegalArgumentException(message)
@@ -0,0 +1,31 @@
package net.sergeych.toread.fb2
object Fb2Format {
const val Namespace = "http://www.gribuser.ru/xml/fictionbook/2.0"
const val XLinkNamespace = "http://www.w3.org/1999/xlink"
fun parseXml(xml: String): Fb2Book = Fb2XmlMapper.fromXml(xml)
fun parse(input: ByteArray, fileName: String? = null): Fb2Book {
val xml = if (looksLikeZip(input) || fileName?.endsWith(".zip", ignoreCase = true) == true) {
Fb2Zip.extractFb2Xml(input)
} else {
input.decodeToString()
}
return parseXml(xml)
}
fun exportXml(book: Fb2Book): String = Fb2XmlMapper.toXml(book)
fun exportZip(book: Fb2Book, entryName: String = "book.fb2"): ByteArray {
require(entryName.endsWith(".fb2", ignoreCase = true)) { "FB2 ZIP entry name should end with .fb2" }
return Fb2Zip.createStoredZip(entryName, exportXml(book).encodeToByteArray())
}
private fun looksLikeZip(input: ByteArray): Boolean =
input.size >= 4 &&
input[0] == 'P'.code.toByte() &&
input[1] == 'K'.code.toByte() &&
input[2] == 3.toByte() &&
input[3] == 4.toByte()
}
@@ -0,0 +1,256 @@
package net.sergeych.toread.fb2
internal object Fb2XmlMapper {
fun fromXml(xml: String): Fb2Book {
val root = SimpleXml.parse(xml)
if (root.localName != "FictionBook") {
throw Fb2ParseException("Expected FictionBook root element, got ${root.name}")
}
val description = root.first("description")
?: throw Fb2ParseException("FB2 document is missing description")
val titleInfo = description.first("title-info")
?: throw Fb2ParseException("FB2 document is missing description/title-info")
val documentInfo = description.first("document-info")
val body = root.children("body").firstOrNull { it.attributes["name"] != "notes" }
?: root.first("body")
return Fb2Book(
title = titleInfo.firstText("book-title")
?: throw Fb2ParseException("FB2 document is missing book-title"),
authors = titleInfo.children("author").map(::authorFrom),
language = titleInfo.firstText("lang"),
sourceLanguage = titleInfo.firstText("src-lang"),
genres = titleInfo.children("genre").mapNotNull { it.text().ifBlank { null } },
annotation = titleInfo.first("annotation")?.text()?.ifBlank { null },
keywords = titleInfo.firstText("keywords"),
date = titleInfo.first("date")?.text()?.ifBlank { null },
translators = titleInfo.children("translator").map(::authorFrom),
sequences = titleInfo.children("sequence").mapNotNull(::sequenceFrom),
coverImages = titleInfo.first("coverpage")?.children("image")?.mapNotNull(::imageRefFrom).orEmpty(),
documentInfo = documentInfoFrom(documentInfo),
bodyTitle = body?.first("title")?.children("p")?.mapNotNull { it.text().ifBlank { null } }.orEmpty(),
bodyImages = body?.children("image")?.mapNotNull(::imageRefFrom).orEmpty(),
sections = body?.children("section")?.map(::sectionFrom).orEmpty(),
binaries = root.children("binary").mapNotNull(::binaryFrom),
)
}
fun toXml(book: Fb2Book): String = buildString {
appendLine("""<?xml version="1.0" encoding="UTF-8"?>""")
append("""<FictionBook xmlns="${Fb2Format.Namespace}" xmlns:xlink="${Fb2Format.XLinkNamespace}">""")
append("<description><title-info>")
appendRepeated("genre", book.genres)
val titleAuthors = book.authors.ifEmpty { listOf(Fb2Author(nickname = "Unknown")) }
titleAuthors.forEach { appendAuthor(it) }
appendElement("book-title", book.title)
book.annotation?.takeIf { it.isNotBlank() }?.let { appendElement("annotation", it) }
book.keywords?.takeIf { it.isNotBlank() }?.let { appendElement("keywords", it) }
book.date?.takeIf { it.isNotBlank() }?.let { appendElement("date", it) }
if (book.coverImages.isNotEmpty()) {
append("<coverpage>")
book.coverImages.forEach { appendImage(it) }
append("</coverpage>")
}
book.language?.takeIf { it.isNotBlank() }?.let { appendElement("lang", it) }
book.sourceLanguage?.takeIf { it.isNotBlank() }?.let { appendElement("src-lang", it) }
book.translators.forEach { translator ->
append("<translator>")
appendAuthorFields(translator)
append("</translator>")
}
book.sequences.forEach { sequence ->
append("""<sequence name="${escapeAttribute(sequence.name)}"""")
sequence.number?.takeIf { it.isNotBlank() }?.let { append(""" number="${escapeAttribute(it)}"""") }
append("/>")
}
append("</title-info><document-info>")
val documentAuthors = book.documentInfo.authors.ifEmpty { titleAuthors }
documentAuthors.forEach { appendAuthor(it) }
appendElement("date", book.documentInfo.date?.takeIf { it.isNotBlank() } ?: "1970-01-01")
appendElement("id", book.documentInfo.id?.takeIf { it.isNotBlank() } ?: "toread-generated")
appendElement("version", book.documentInfo.version?.takeIf { it.isNotBlank() } ?: "1.0")
append("</document-info></description><body>")
book.bodyImages.forEach { appendImage(it) }
if (book.bodyTitle.isNotEmpty()) {
append("<title>")
book.bodyTitle.forEach { appendElement("p", it) }
append("</title>")
}
book.sections.forEach { appendSection(it) }
append("</body>")
book.binaries.forEach { binary ->
append("""<binary id="${escapeAttribute(binary.id)}" content-type="${escapeAttribute(binary.contentType)}">""")
append(binary.base64)
append("</binary>")
}
append("</FictionBook>")
}
private fun documentInfoFrom(element: XmlElement?): Fb2DocumentInfo = Fb2DocumentInfo(
authors = element?.children("author")?.map(::authorFrom).orEmpty(),
date = element?.first("date")?.text()?.ifBlank { null },
id = element?.firstText("id"),
version = element?.firstText("version"),
)
private fun authorFrom(element: XmlElement): Fb2Author = Fb2Author(
firstName = element.firstText("first-name"),
middleName = element.firstText("middle-name"),
lastName = element.firstText("last-name"),
nickname = element.firstText("nickname"),
)
private fun sequenceFrom(element: XmlElement): Fb2Sequence? {
val name = element.attributes["name"]?.takeIf { it.isNotBlank() } ?: return null
return Fb2Sequence(name = name, number = element.attributes["number"]?.takeIf { it.isNotBlank() })
}
private fun binaryFrom(element: XmlElement): Fb2Binary? {
val id = element.attributes["id"]?.takeIf { it.isNotBlank() } ?: return null
val contentType = element.attributes["content-type"]?.takeIf { it.isNotBlank() } ?: return null
return Fb2Binary(id = id, contentType = contentType, base64 = element.text().filterNot(Char::isWhitespace))
}
private fun imageRefFrom(element: XmlElement): Fb2ImageRef? {
val href = element.attributeLocal("href")?.takeIf { it.isNotBlank() } ?: return null
return Fb2ImageRef(href = href, alt = element.attributes["alt"]?.takeIf { it.isNotBlank() })
}
private fun sectionFrom(element: XmlElement): Fb2Section {
val blocks = element.nodes.mapNotNull { node ->
val child = (node as? XmlNode.ElementNode)?.element ?: return@mapNotNull null
when (child.localName) {
"p" -> Fb2Block.Paragraph(textFrom(child))
"subtitle" -> Fb2Block.Subtitle(textFrom(child))
"image" -> imageRefFrom(child)?.let(Fb2Block::Image)
"empty-line" -> Fb2Block.EmptyLine
else -> null
}
}
return Fb2Section(
title = element.first("title")?.text()?.ifBlank { null },
paragraphs = blocks.filterIsInstance<Fb2Block.Paragraph>()
.mapNotNull { it.content.plainText.ifBlank { null } },
images = blocks.filterIsInstance<Fb2Block.Image>().map { it.image },
sections = element.children("section").map(::sectionFrom),
blocks = blocks,
)
}
private fun textFrom(element: XmlElement): Fb2Text =
Fb2Text(spansFrom(element.nodes).mergeAdjacent())
private fun spansFrom(nodes: List<XmlNode>, styles: Set<Fb2TextStyle> = emptySet()): List<Fb2TextSpan> =
nodes.flatMap { node ->
when (node) {
is XmlNode.TextNode -> listOfNotNull(
node.text.takeIf { it.isNotEmpty() }?.let { Fb2TextSpan(it, styles) }
)
is XmlNode.ElementNode -> {
val element = node.element
val nestedStyles = when (element.localName) {
"emphasis" -> styles + Fb2TextStyle.Emphasis
"strong" -> styles + Fb2TextStyle.Strong
"code" -> styles + Fb2TextStyle.Code
"strikethrough" -> styles + Fb2TextStyle.Strikethrough
"sup" -> styles + Fb2TextStyle.Superscript
"sub" -> styles + Fb2TextStyle.Subscript
else -> styles
}
spansFrom(element.nodes, nestedStyles)
}
}
}
private fun List<Fb2TextSpan>.mergeAdjacent(): List<Fb2TextSpan> {
if (isEmpty()) return emptyList()
val merged = mutableListOf<Fb2TextSpan>()
forEach { span ->
val previous = merged.lastOrNull()
if (previous != null && previous.styles == span.styles) {
merged[merged.lastIndex] = previous.copy(text = previous.text + span.text)
} else {
merged += span
}
}
return merged
}
private fun StringBuilder.appendSection(section: Fb2Section) {
append("<section>")
section.title?.takeIf { it.isNotBlank() }?.let {
append("<title>")
appendElement("p", it)
append("</title>")
}
section.images.forEach { appendImage(it) }
section.paragraphs.forEach { appendElement("p", it) }
section.sections.forEach { appendSection(it) }
append("</section>")
}
private fun StringBuilder.appendAuthor(author: Fb2Author) {
append("<author>")
appendAuthorFields(author)
append("</author>")
}
private fun StringBuilder.appendAuthorFields(author: Fb2Author) {
author.firstName?.takeIf { it.isNotBlank() }?.let { appendElement("first-name", it) }
author.middleName?.takeIf { it.isNotBlank() }?.let { appendElement("middle-name", it) }
author.lastName?.takeIf { it.isNotBlank() }?.let { appendElement("last-name", it) }
author.nickname?.takeIf { it.isNotBlank() }?.let { appendElement("nickname", it) }
if (author.displayName.isBlank()) appendElement("nickname", "Unknown")
}
private fun StringBuilder.appendImage(image: Fb2ImageRef) {
append("""<image xlink:href="${escapeAttribute(image.href)}"""")
image.alt?.takeIf { it.isNotBlank() }?.let { append(""" alt="${escapeAttribute(it)}"""") }
append("/>")
}
private fun StringBuilder.appendRepeated(tag: String, values: List<String>) {
values.forEach { value ->
if (value.isNotBlank()) appendElement(tag, value)
}
}
private fun StringBuilder.appendElement(tag: String, text: String) {
append('<')
append(tag)
append('>')
append(escapeText(text))
append("</")
append(tag)
append('>')
}
private fun escapeText(value: String): String = buildString(value.length) {
value.forEach { char ->
when (char) {
'&' -> append("&amp;")
'<' -> append("&lt;")
'>' -> append("&gt;")
else -> append(char)
}
}
}
private fun escapeAttribute(value: String): String = buildString(value.length) {
value.forEach { char ->
when (char) {
'&' -> append("&amp;")
'<' -> append("&lt;")
'"' -> append("&quot;")
'\'' -> append("&apos;")
else -> append(char)
}
}
}
}
private fun XmlElement.attributeLocal(localName: String): String? =
attributes.entries.firstOrNull { it.key.substringAfter(':') == localName }?.value
@@ -0,0 +1,182 @@
package net.sergeych.toread.fb2
internal object Fb2Zip {
private const val LocalFileHeader = 0x04034b50
private const val CentralDirectoryHeader = 0x02014b50
private const val EndOfCentralDirectory = 0x06054b50
private const val Stored = 0
private const val Deflated = 8
fun extractFb2Xml(zip: ByteArray): String {
val entries = readCentralDirectory(zip)
val entry = entries.firstOrNull { it.name.endsWith(".fb2", ignoreCase = true) }
?: entries.firstOrNull { !it.name.endsWith("/") }
?: throw Fb2ParseException("ZIP archive does not contain an FB2 entry")
return readEntry(zip, entry).decodeToString()
}
fun createStoredZip(entryName: String, content: ByteArray): ByteArray {
val nameBytes = entryName.encodeToByteArray()
val crc = crc32(content)
val local = ByteWriter()
local.i32(LocalFileHeader)
local.i16(20)
local.i16(0)
local.i16(Stored)
local.i16(0)
local.i16(0)
local.i32(crc)
local.i32(content.size)
local.i32(content.size)
local.i16(nameBytes.size)
local.i16(0)
local.bytes(nameBytes)
local.bytes(content)
val centralOffset = local.size
val central = ByteWriter()
central.i32(CentralDirectoryHeader)
central.i16(20)
central.i16(20)
central.i16(0)
central.i16(Stored)
central.i16(0)
central.i16(0)
central.i32(crc)
central.i32(content.size)
central.i32(content.size)
central.i16(nameBytes.size)
central.i16(0)
central.i16(0)
central.i16(0)
central.i16(0)
central.i32(0)
central.i32(0)
central.bytes(nameBytes)
val end = ByteWriter()
end.i32(EndOfCentralDirectory)
end.i16(0)
end.i16(0)
end.i16(1)
end.i16(1)
end.i32(central.size)
end.i32(centralOffset)
end.i16(0)
return local.toByteArray() + central.toByteArray() + end.toByteArray()
}
private fun readEntry(zip: ByteArray, entry: ZipEntry): ByteArray {
if (zip.i32(entry.localHeaderOffset) != LocalFileHeader) {
throw Fb2ParseException("Invalid ZIP local header for ${entry.name}")
}
val nameSize = zip.i16(entry.localHeaderOffset + 26)
val extraSize = zip.i16(entry.localHeaderOffset + 28)
val dataOffset = entry.localHeaderOffset + 30 + nameSize + extraSize
if (dataOffset + entry.compressedSize > zip.size) {
throw Fb2ParseException("ZIP entry ${entry.name} is truncated")
}
val compressed = zip.copyOfRange(dataOffset, dataOffset + entry.compressedSize)
return when (entry.method) {
Stored -> compressed
Deflated -> inflateRaw(compressed, entry.uncompressedSize)
else -> throw Fb2ParseException("Unsupported ZIP compression method ${entry.method} for ${entry.name}")
}
}
private fun readCentralDirectory(zip: ByteArray): List<ZipEntry> {
val eocd = findEndOfCentralDirectory(zip)
val entryCount = zip.i16(eocd + 10)
val centralSize = zip.i32(eocd + 12)
val centralOffset = zip.i32(eocd + 16)
if (centralOffset < 0 || centralSize < 0 || centralOffset + centralSize > zip.size) {
throw Fb2ParseException("Invalid ZIP central directory")
}
val entries = mutableListOf<ZipEntry>()
var offset = centralOffset
repeat(entryCount) {
if (zip.i32(offset) != CentralDirectoryHeader) {
throw Fb2ParseException("Invalid ZIP central directory header")
}
val method = zip.i16(offset + 10)
val compressedSize = zip.i32(offset + 20)
val uncompressedSize = zip.i32(offset + 24)
val nameSize = zip.i16(offset + 28)
val extraSize = zip.i16(offset + 30)
val commentSize = zip.i16(offset + 32)
val localHeaderOffset = zip.i32(offset + 42)
val nameStart = offset + 46
val name = zip.copyOfRange(nameStart, nameStart + nameSize).decodeToString()
entries += ZipEntry(name, method, compressedSize, uncompressedSize, localHeaderOffset)
offset = nameStart + nameSize + extraSize + commentSize
}
return entries
}
private fun findEndOfCentralDirectory(zip: ByteArray): Int {
val min = maxOf(0, zip.size - 65_557)
var offset = zip.size - 22
while (offset >= min) {
if (zip.i32(offset) == EndOfCentralDirectory) return offset
offset--
}
throw Fb2ParseException("ZIP end of central directory was not found")
}
private data class ZipEntry(
val name: String,
val method: Int,
val compressedSize: Int,
val uncompressedSize: Int,
val localHeaderOffset: Int,
)
private class ByteWriter {
private val bytes = mutableListOf<Byte>()
val size: Int get() = bytes.size
fun i16(value: Int) {
bytes += (value and 0xff).toByte()
bytes += ((value ushr 8) and 0xff).toByte()
}
fun i32(value: Int) {
i16(value and 0xffff)
i16((value ushr 16) and 0xffff)
}
fun bytes(value: ByteArray) {
value.forEach { bytes += it }
}
fun toByteArray(): ByteArray = ByteArray(bytes.size) { bytes[it] }
}
}
internal expect fun inflateRaw(input: ByteArray, expectedSize: Int): ByteArray
private fun ByteArray.i16(offset: Int): Int {
if (offset + 2 > size) throw Fb2ParseException("Unexpected end of ZIP data")
return (this[offset].toInt() and 0xff) or ((this[offset + 1].toInt() and 0xff) shl 8)
}
private fun ByteArray.i32(offset: Int): Int {
if (offset + 4 > size) throw Fb2ParseException("Unexpected end of ZIP data")
return i16(offset) or (i16(offset + 2) shl 16)
}
private fun crc32(input: ByteArray): Int {
var crc = -1
input.forEach { byte ->
crc = crc xor (byte.toInt() and 0xff)
repeat(8) {
crc = if ((crc and 1) != 0) {
(crc ushr 1) xor 0xedb88320.toInt()
} else {
crc ushr 1
}
}
}
return crc.inv()
}
@@ -0,0 +1,233 @@
package net.sergeych.toread.fb2
internal data class XmlElement(
val name: String,
val attributes: Map<String, String>,
val nodes: List<XmlNode>,
) {
val localName: String = name.substringAfter(':')
fun children(localName: String): List<XmlElement> =
nodes.filterIsInstance<XmlNode.ElementNode>().map { it.element }.filter { it.localName == localName }
fun first(localName: String): XmlElement? = children(localName).firstOrNull()
fun firstText(localName: String): String? = first(localName)?.text()?.ifBlank { null }
fun text(): String = buildString {
nodes.forEach { node ->
when (node) {
is XmlNode.ElementNode -> append(node.element.text())
is XmlNode.TextNode -> append(node.text)
}
}
}.trim()
}
internal sealed interface XmlNode {
data class ElementNode(val element: XmlElement) : XmlNode
data class TextNode(val text: String) : XmlNode
}
internal object SimpleXml {
fun parse(xml: String): XmlElement = Parser(xml).parse()
private class Parser(private val xml: String) {
private var index = 0
fun parse(): XmlElement {
skipBom()
skipMisc()
val root = readElement()
skipMisc()
if (index < xml.length) {
throw error("Unexpected content after root element")
}
return root
}
private fun readElement(): XmlElement {
expect('<')
if (peek() == '/') throw error("Unexpected closing tag")
if (startsWith("!--")) return skipCommentAndReadNext()
if (startsWith("?")) {
skipProcessingInstruction()
skipMisc()
return readElement()
}
val name = readName()
val attributes = linkedMapOf<String, String>()
while (true) {
skipWhitespace()
when {
startsWith("/>") -> {
index += 2
return XmlElement(name, attributes, emptyList())
}
startsWith(">") -> {
index++
break
}
else -> {
val attributeName = readName()
skipWhitespace()
expect('=')
skipWhitespace()
attributes[attributeName] = readAttributeValue()
}
}
}
val nodes = mutableListOf<XmlNode>()
while (index < xml.length) {
when {
startsWith("</") -> {
index += 2
val closingName = readName()
if (closingName != name) throw error("Expected closing tag for $name, got $closingName")
skipWhitespace()
expect('>')
return XmlElement(name, attributes, nodes)
}
startsWith("<!--") -> skipComment()
startsWith("<![CDATA[") -> nodes += XmlNode.TextNode(readCData())
startsWith("<?") -> skipProcessingInstruction()
peek() == '<' -> nodes += XmlNode.ElementNode(readElement())
else -> nodes += XmlNode.TextNode(decodeEntities(readText()))
}
}
throw error("Unclosed tag $name")
}
private fun skipCommentAndReadNext(): XmlElement {
index--
skipComment()
skipMisc()
return readElement()
}
private fun readName(): String {
val start = index
while (index < xml.length) {
val char = xml[index]
if (char.isWhitespace() || char == '/' || char == '>' || char == '=') break
index++
}
if (start == index) throw error("Expected XML name")
return xml.substring(start, index)
}
private fun readAttributeValue(): String {
val quote = peek()
if (quote != '"' && quote != '\'') throw error("Expected quoted attribute value")
index++
val start = index
while (index < xml.length && xml[index] != quote) index++
if (index >= xml.length) throw error("Unclosed attribute value")
val value = xml.substring(start, index)
index++
return decodeEntities(value)
}
private fun readText(): String {
val start = index
while (index < xml.length && xml[index] != '<') index++
return xml.substring(start, index)
}
private fun readCData(): String {
index += "<![CDATA[".length
val end = xml.indexOf("]]>", index)
if (end < 0) throw error("Unclosed CDATA section")
val value = xml.substring(index, end)
index = end + 3
return value
}
private fun skipMisc() {
while (true) {
skipWhitespace()
when {
startsWith("<?") -> skipProcessingInstruction()
startsWith("<!--") -> skipComment()
else -> return
}
}
}
private fun skipProcessingInstruction() {
val end = xml.indexOf("?>", index)
if (end < 0) throw error("Unclosed processing instruction")
index = end + 2
}
private fun skipComment() {
val end = xml.indexOf("-->", index)
if (end < 0) throw error("Unclosed XML comment")
index = end + 3
}
private fun skipWhitespace() {
while (index < xml.length && xml[index].isWhitespace()) index++
}
private fun skipBom() {
if (xml.startsWith("\uFEFF")) index = 1
}
private fun startsWith(value: String): Boolean = xml.startsWith(value, index)
private fun peek(): Char {
if (index >= xml.length) throw error("Unexpected end of XML")
return xml[index]
}
private fun expect(char: Char) {
if (peek() != char) throw error("Expected '$char'")
index++
}
private fun error(message: String): Fb2ParseException =
Fb2ParseException("$message at offset $index")
}
}
private fun decodeEntities(value: String): String {
if ('&' !in value) return value
return buildString(value.length) {
var index = 0
while (index < value.length) {
val char = value[index]
if (char != '&') {
append(char)
index++
continue
}
val end = value.indexOf(';', index + 1)
if (end < 0) {
append(char)
index++
continue
}
val entity = value.substring(index + 1, end)
append(
when {
entity == "lt" -> '<'
entity == "gt" -> '>'
entity == "amp" -> '&'
entity == "quot" -> '"'
entity == "apos" -> '\''
entity.startsWith("#x") -> entity.drop(2).toIntOrNull(16)?.toChar() ?: '&'
entity.startsWith("#") -> entity.drop(1).toIntOrNull()?.toChar() ?: '&'
else -> '&'
}
)
if (entity.startsWith("#").not() && entity !in setOf("lt", "gt", "amp", "quot", "apos")) {
append(entity)
append(';')
}
index = end + 1
}
}
}
@@ -0,0 +1,196 @@
package net.sergeych.toread.storage
enum class BookFileStorageKind {
EXTERNAL_URI,
MANAGED_FILE,
DATABASE_BLOB,
INDEXEDDB_BLOB,
REMOTE_URL,
MISSING,
}
enum class BookImportPolicy {
LINK,
COPY,
STORE_BLOB,
}
data class BookRecord(
val id: String,
val title: String? = null,
val subtitle: String? = null,
val language: String? = null,
val description: String? = null,
val coverImage: ByteArray? = null,
val coverImageMimeType: String? = null,
val createdAt: Long,
val updatedAt: Long,
) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is BookRecord) return false
return id == other.id &&
title == other.title &&
subtitle == other.subtitle &&
language == other.language &&
description == other.description &&
coverImage.contentEquals(other.coverImage) &&
coverImageMimeType == other.coverImageMimeType &&
createdAt == other.createdAt &&
updatedAt == other.updatedAt
}
override fun hashCode(): Int {
var result = id.hashCode()
result = 31 * result + (title?.hashCode() ?: 0)
result = 31 * result + (subtitle?.hashCode() ?: 0)
result = 31 * result + (language?.hashCode() ?: 0)
result = 31 * result + (description?.hashCode() ?: 0)
result = 31 * result + (coverImage?.contentHashCode() ?: 0)
result = 31 * result + (coverImageMimeType?.hashCode() ?: 0)
result = 31 * result + createdAt.hashCode()
result = 31 * result + updatedAt.hashCode()
return result
}
}
data class BookBodyRecord(
val id: String,
val exactTextHash: String,
val canonicalizationVersion: Int,
val nearSignature: String? = null,
val wordCount: Int? = null,
val language: String? = null,
val createdAt: Long,
)
data class BodyClusterRecord(
val id: String,
val representativeBodyId: String,
val createdAt: Long,
)
data class BookFileRecord(
val id: String,
val bookId: String? = null,
val bodyId: String? = null,
val bodyClusterId: String? = null,
val rawSha256: String,
val format: String? = null,
val mimeType: String? = null,
val sizeBytes: Long? = null,
val originalFilename: String? = null,
val storageKind: BookFileStorageKind,
val storageUri: String? = null,
val contentObjectId: String? = null,
val lastModifiedMillis: Long? = null,
val lastSeenAt: Long? = null,
val createdAt: Long,
val updatedAt: Long,
)
data class ContentAnchor(
val version: Int = 1,
val canonicalCharOffset: Long? = null,
val canonicalTokenOffset: Long? = null,
val progress: Double? = null,
val exact: String? = null,
val prefix: String? = null,
val suffix: String? = null,
val fingerprintsJson: String? = null,
val formatHintsJson: String? = null,
)
data class ReadingStateRecord(
val id: String,
val bodyClusterId: String,
val bodyId: String? = null,
val anchor: ContentAnchor,
val updatedAt: Long,
)
data class BookmarkRecord(
val id: String,
val bodyClusterId: String,
val bodyId: String? = null,
val anchor: ContentAnchor,
val title: String? = null,
val selectedTextSnapshot: String? = null,
val color: String? = null,
val createdAt: Long,
val updatedAt: Long,
)
data class NoteRecord(
val id: String,
val bookmarkId: String? = null,
val bodyClusterId: String,
val bodyId: String? = null,
val anchor: ContentAnchor,
val text: String,
val createdAt: Long,
val updatedAt: Long,
)
interface BookRepository {
fun upsert(book: BookRecord)
fun get(id: String): BookRecord?
fun list(limit: Int = 100, offset: Int = 0): List<BookRecord>
fun delete(id: String): Boolean
}
interface BookBodyRepository {
fun upsert(body: BookBodyRecord)
fun get(id: String): BookBodyRecord?
fun findByExactTextHash(exactTextHash: String, canonicalizationVersion: Int): BookBodyRecord?
}
interface BodyClusterRepository {
fun upsert(cluster: BodyClusterRecord)
fun get(id: String): BodyClusterRecord?
fun findByRepresentativeBodyId(bodyId: String): BodyClusterRecord?
}
interface BookFileRepository {
fun upsert(file: BookFileRecord)
fun get(id: String): BookFileRecord?
fun findByRawSha256(rawSha256: String): List<BookFileRecord>
fun list(limit: Int = 500, offset: Int = 0): List<BookFileRecord>
fun listForBook(bookId: String): List<BookFileRecord>
fun delete(id: String): Boolean
}
interface ReadingStateRepository {
fun upsert(state: ReadingStateRecord)
fun get(id: String): ReadingStateRecord?
fun getForBodyCluster(bodyClusterId: String): ReadingStateRecord?
}
interface BookmarkRepository {
fun upsert(bookmark: BookmarkRecord)
fun get(id: String): BookmarkRecord?
fun listForBodyCluster(bodyClusterId: String): List<BookmarkRecord>
fun delete(id: String): Boolean
}
interface NoteRepository {
fun upsert(note: NoteRecord)
fun get(id: String): NoteRecord?
fun listForBookmark(bookmarkId: String): List<NoteRecord>
fun listForBodyCluster(bodyClusterId: String): List<NoteRecord>
fun delete(id: String): Boolean
}
interface LibraryDatabase {
val books: BookRepository
val bodies: BookBodyRepository
val clusters: BodyClusterRepository
val files: BookFileRepository
val readingStates: ReadingStateRepository
val bookmarks: BookmarkRepository
val notes: NoteRepository
fun <T> transaction(block: LibraryDatabase.() -> T): T
fun close()
}
@@ -0,0 +1,110 @@
package net.sergeych.toread.text
interface HyphenationPlugin {
val languageTags: Set<String>
fun hyphenateWord(word: String): String
}
class HyphenationRegistry(
plugins: List<HyphenationPlugin> = listOf(
EnglishHyphenationPlugin,
RussianHyphenationPlugin,
),
) {
private val byLanguage = plugins
.flatMap { plugin -> plugin.languageTags.map { it.lowercase() to plugin } }
.toMap()
fun pluginFor(languageTag: String?): HyphenationPlugin? {
val normalized = languageTag?.lowercase()?.takeIf { it.isNotBlank() } ?: return null
return byLanguage[normalized] ?: byLanguage[normalized.substringBefore('-')]
}
fun hyphenate(text: String, languageTag: String?): String {
val plugin = pluginFor(languageTag) ?: return text
return hyphenate(text, plugin)
}
private fun hyphenate(text: String, plugin: HyphenationPlugin): String = buildString(text.length + text.length / 12) {
var wordStart = -1
fun flushWord(end: Int) {
if (wordStart >= 0) {
append(plugin.hyphenateWord(text.substring(wordStart, end)))
wordStart = -1
}
}
text.forEachIndexed { index, char ->
if (char.isLetter()) {
if (wordStart < 0) wordStart = index
} else {
flushWord(index)
append(char)
}
}
flushWord(text.length)
}
}
object EnglishHyphenationPlugin : HyphenationPlugin {
override val languageTags: Set<String> = setOf("en", "eng")
override fun hyphenateWord(word: String): String {
if (word.length < 7 || SoftHyphen in word) return word
val breaks = mutableListOf<Int>()
for (index in 2 until word.lastIndex - 1) {
val prev = word[index - 1]
val current = word[index]
val next = word[index + 1]
if (prev.isVowel() && current.isConsonant() && next.isVowel()) breaks += index
if (current.isConsonant() && next.isConsonant() && word.getOrNull(index + 2)?.isVowel() == true) {
breaks += index + 1
}
}
return insertBreaks(word, breaks, minPrefix = 3, minSuffix = 3)
}
}
object RussianHyphenationPlugin : HyphenationPlugin {
override val languageTags: Set<String> = setOf("ru", "rus")
override fun hyphenateWord(word: String): String {
if (word.length < 6 || SoftHyphen in word) return word
val breaks = mutableListOf<Int>()
for (index in 2 until word.lastIndex) {
val prev = word[index - 1]
val current = word[index]
val next = word[index + 1]
if (current.isRussianVowel() && next.isRussianConsonant()) breaks += index + 1
if (prev.isRussianVowel() && current.isRussianConsonant() && next.isRussianVowel()) breaks += index
}
return insertBreaks(word, breaks, minPrefix = 2, minSuffix = 2)
}
}
const val SoftHyphen: Char = '\u00AD'
private fun insertBreaks(word: String, breaks: List<Int>, minPrefix: Int, minSuffix: Int): String {
val legalBreaks = breaks.distinct()
.filter { it >= minPrefix && word.length - it >= minSuffix }
.toSet()
if (legalBreaks.isEmpty()) return word
return buildString(word.length + legalBreaks.size) {
word.forEachIndexed { index, char ->
if (index in legalBreaks) append(SoftHyphen)
append(char)
}
}
}
private fun Char.isVowel(): Boolean = lowercaseChar() in "aeiouy"
private fun Char.isConsonant(): Boolean = isLetter() && !isVowel()
private fun Char.isRussianVowel(): Boolean = lowercaseChar() in "аеёиоуыэюя"
private fun Char.isRussianConsonant(): Boolean =
lowercaseChar() in "бвгджзйклмнпрстфхцчшщ"