library UI + reader mode
This commit is contained in:
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("&")
|
||||
'<' -> append("<")
|
||||
'>' -> append(">")
|
||||
else -> append(char)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun escapeAttribute(value: String): String = buildString(value.length) {
|
||||
value.forEach { char ->
|
||||
when (char) {
|
||||
'&' -> append("&")
|
||||
'<' -> append("<")
|
||||
'"' -> append(""")
|
||||
'\'' -> append("'")
|
||||
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 "бвгджзйклмнпрстфхцчшщ"
|
||||
Reference in new issue
Block a user