* Centralize library management logic and introduce support for plain text and HTML formats * Centralize library management logic and introduce support for plain text and HTML formats * Expand unit test coverage for library state management, UI models, and MainViewModel features. * Add comprehensive unit tests for PDF reader core logic, preferences, and data persistence * Add unit tests for EPUB parsing, content loading, search functionality, and reader JavaScript bridges. * Add unit tests for OPDS parsing and Smart Collection engine, and integrate Kover plugin * Add comprehensive unit tests * Centralize library snapshot serialization in the `shared` module and improve filtering and sorting logic. * Implement text selection, highlighting, and reading state persistence for PDF and EPUB engines in desktop version * Folder import support for desktop app * Introduce Smart Shelves with rule-based filtering in desktop version * Implement shared EPUB annotation serialization and highlight rendering * Centralize file type capabilities and platform-specific support logic * Refactor reader state management to use a central reducer * Implement customizable reader toolbar and advanced formatting settings in shared * Implement locator-based navigation and customizable highlight palette for desktop app * Enhance reader customization and expand search functionality in desktop app * Redesign reader settings and tools into a tabbed control panel in desktop app * Enhance reader navigation and highlight precision in desktop app * Implement bidirectional position synchronization and dynamic highlights in the desktop reader * Implement shared state management and enhanced search for the PDF reader in desktop app * Add vertical scroll support to the desktop PDF reader * Implement ink, text, and eraser annotation support in desktop PDF viewer * Implement PDF bookmarks, Table of Contents, and annotation editing in desktop app * Implement link handling and navigation for PDF and EPUB readers in desktop app * Implement PDF jump history for navigation in desktop app * Enhance PDF ink rendering and annotation capabilities in desktop app * Implement advanced PDF text annotations with inline editing and rich styling in desktop app * Add move handle and movement logic for PDF text annotations in desktop app * Implement local folder synchronization and metadata sidecar support in desktop app * Implement book metadata extraction and drag-and-drop import for Desktop * Implement dynamic and custom app theme management for desktop * Introduce canonical PDF annotation codec and support for multi-segment highlights * Implement rich text editing and pagination support for the PDF reader in desktop app * Improve PDF rich text pagination, synchronization, and observability in desktop * Hide trailing structural page breaks in rich text editor * Implement a unified JVM book loader and expand supported formats on Desktop * Add comic archive support for Desktop and enhance MOBI parsing * Implement shared OPDS catalog support and UI for Android and Desktop * Improve native WebView lifecycle and surface transition management on Desktop * Enable Compose Swing interop blending and simplify Desktop WebView management * Integrate BYOK AI features and Cloud TTS for desktop * Enhance Desktop TTS with streaming audio and improved secure storage for AI key * Implement scoped Cloud TTS with synchronized highlighting for EPUB and PDF in desktop app * Implement custom font management and utility screens in desktop app * Implement PDFium-based PDF annotation export * Remove PdfBox dependency and standardize PDF export via Pdfium * Implement local audio caching and playback controls for Gemini Cloud TTS in desktop app * Implement reader themes and custom texture support in desktop app * Redesign non-reader UI with responsive navigation and enhanced library management in desktop app * Introduce ReaderWorkspaceShell to unify EPUB and PDF reader layouts in desktop app * Exclude manual-only files from automated sync and import * Implement customizable Text-to-Speech (TTS) word replacements * Optimize reader performance with persistent layout caching and decoupled theme rendering * Improve position restoration during reader reconfiguration in epub pagination * Use independent thickness for eraser tool and stylus override
329 lines
15 KiB
Kotlin
329 lines
15 KiB
Kotlin
package com.aryan.reader.epub
|
|
|
|
import android.content.Context
|
|
import android.graphics.BitmapFactory
|
|
import android.util.Base64
|
|
import android.util.Xml
|
|
import kotlinx.coroutines.Dispatchers
|
|
import kotlinx.coroutines.withContext
|
|
import org.jsoup.Jsoup
|
|
import org.xmlpull.v1.XmlPullParser
|
|
import timber.log.Timber
|
|
import java.io.File
|
|
import java.io.FileOutputStream
|
|
import java.io.InputStream
|
|
import java.util.zip.ZipInputStream
|
|
|
|
class Fb2Parser(private val context: Context) {
|
|
|
|
suspend fun createFb2Book(
|
|
inputStream: InputStream,
|
|
bookId: String,
|
|
originalBookNameHint: String,
|
|
parseContent: Boolean = true,
|
|
extractionDirOverride: File? = null
|
|
): EpubBook = withContext(Dispatchers.IO) {
|
|
val extractionDir = extractionDirOverride?.let(ImportedFileCache::prepareDirectory)
|
|
?: if (parseContent) {
|
|
ImportedFileCache.prepareActiveBookDir(context, bookId)
|
|
} else {
|
|
ImportedFileCache.createTemporaryBookDir(context, bookId, "metadata")
|
|
}
|
|
|
|
var streamToParse = inputStream
|
|
try {
|
|
if (originalBookNameHint.endsWith(".zip", ignoreCase = true)) {
|
|
val zis = ZipInputStream(inputStream)
|
|
var entry = zis.nextEntry
|
|
while (entry != null) {
|
|
if (entry.name.endsWith(".fb2", ignoreCase = true)) {
|
|
break
|
|
}
|
|
entry = zis.nextEntry
|
|
}
|
|
if (entry != null) {
|
|
streamToParse = zis
|
|
} else {
|
|
throw Exception("No .fb2 file found inside the ZIP archive.")
|
|
}
|
|
}
|
|
|
|
val parser = Xml.newPullParser()
|
|
parser.setFeature(XmlPullParser.FEATURE_PROCESS_NAMESPACES, false)
|
|
parser.setInput(streamToParse, null)
|
|
|
|
var title = originalBookNameHint.substringBeforeLast(".")
|
|
var author = "Unknown"
|
|
var coverImageId: String? = null
|
|
var coverBytes: ByteArray? = null
|
|
|
|
val chapters = mutableListOf<EpubChapter>()
|
|
val images = mutableListOf<EpubImage>() // Keep track of extracted images
|
|
|
|
var currentChapterHtml = StringBuilder()
|
|
var currentChapterTitle = "Chapter 1"
|
|
var chapterCount = 0
|
|
var inSection = false
|
|
var inBody = false
|
|
var inTitle = false
|
|
val titleBuilder = java.lang.StringBuilder() // Buffer to handle <p> tags inside <title>
|
|
|
|
val cssStyle = """
|
|
body { font-family: sans-serif; line-height: 1.6; padding: 1em; max-width: 800px; margin: 0 auto; }
|
|
p { margin-bottom: 1em; text-indent: 1.5em; text-align: justify; }
|
|
h1, h2, h3, h4 { text-align: center; margin-top: 1.5em; margin-bottom: 1em; }
|
|
.empty-line { height: 1.5em; }
|
|
img { max-width: 100%; height: auto; display: block; margin: 1em auto; }
|
|
.epigraph { margin-left: 2em; font-style: italic; margin-bottom: 1.5em; }
|
|
.cite { border-left: 4px solid currentColor; padding-left: 1em; margin-left: 0; opacity: 0.8; font-style: italic; }
|
|
.poem { margin: 1.5em 0; padding-left: 2em; }
|
|
.stanza { margin-bottom: 1em; }
|
|
""".trimIndent()
|
|
|
|
fun saveChapter() {
|
|
if (!parseContent || currentChapterHtml.isEmpty()) return
|
|
chapterCount++
|
|
val fileName = "chapter_$chapterCount.html"
|
|
val file = File(extractionDir, fileName)
|
|
|
|
val fullHtml = """
|
|
<!DOCTYPE html>
|
|
<html>
|
|
<head>
|
|
<title>${currentChapterTitle.replace("\"", """)}</title>
|
|
<style>${cssStyle}</style>
|
|
</head>
|
|
<body>
|
|
$currentChapterHtml
|
|
</body>
|
|
</html>
|
|
""".trimIndent()
|
|
|
|
FileOutputStream(file).use { it.write(fullHtml.toByteArray()) }
|
|
val plainText = Jsoup.parse(fullHtml).text()
|
|
|
|
chapters.add(
|
|
EpubChapter(
|
|
chapterId = "${bookId}_${chapterCount}",
|
|
absPath = fileName,
|
|
title = currentChapterTitle,
|
|
htmlFilePath = fileName,
|
|
plainTextContent = plainText,
|
|
htmlContent = "",
|
|
depth = 0,
|
|
isInToc = true
|
|
)
|
|
)
|
|
currentChapterHtml.clear()
|
|
currentChapterTitle = "Chapter ${chapterCount + 1}"
|
|
}
|
|
|
|
var eventType = parser.eventType
|
|
|
|
while (eventType != XmlPullParser.END_DOCUMENT) {
|
|
when (eventType) {
|
|
XmlPullParser.START_TAG -> {
|
|
val name = parser.name.lowercase()
|
|
when (name) {
|
|
"book-title" -> {
|
|
title = parser.nextText().trim()
|
|
}
|
|
"first-name", "last-name", "middle-name" -> {
|
|
val namePart = parser.nextText().trim()
|
|
if (namePart.isNotBlank()) {
|
|
if (author == "Unknown") author = namePart else author += " $namePart"
|
|
}
|
|
}
|
|
"body" -> {
|
|
inBody = true
|
|
}
|
|
"section" -> {
|
|
if (inBody) {
|
|
if (currentChapterHtml.isNotBlank()) {
|
|
saveChapter()
|
|
}
|
|
inSection = true
|
|
}
|
|
}
|
|
"title" -> {
|
|
if (inSection && currentChapterHtml.isEmpty()) {
|
|
inTitle = true
|
|
titleBuilder.clear()
|
|
}
|
|
currentChapterHtml.append("<h2>")
|
|
}
|
|
"p" -> {
|
|
if (!inTitle) {
|
|
currentChapterHtml.append("<p>")
|
|
} else if (titleBuilder.isNotEmpty()) {
|
|
titleBuilder.append(" ")
|
|
currentChapterHtml.append("<br>")
|
|
}
|
|
}
|
|
"v" -> {
|
|
if (!inTitle) {
|
|
currentChapterHtml.append("<p style='text-indent: 0; text-align: left;'>")
|
|
} else if (titleBuilder.isNotEmpty()) {
|
|
titleBuilder.append(" ")
|
|
currentChapterHtml.append("<br>")
|
|
}
|
|
}
|
|
"subtitle" -> currentChapterHtml.append("<h3>")
|
|
"empty-line" -> {
|
|
if (!inTitle) {
|
|
currentChapterHtml.append("<div class='empty-line'></div>")
|
|
} else if (titleBuilder.isNotEmpty()) {
|
|
titleBuilder.append(" ")
|
|
currentChapterHtml.append("<br>")
|
|
}
|
|
}
|
|
"strong" -> currentChapterHtml.append("<b>")
|
|
"emphasis" -> currentChapterHtml.append("<i>")
|
|
"strikethrough" -> currentChapterHtml.append("<s>")
|
|
"sup" -> currentChapterHtml.append("<sup>")
|
|
"sub" -> currentChapterHtml.append("<sub>")
|
|
"epigraph" -> currentChapterHtml.append("<div class='epigraph'>")
|
|
"cite" -> currentChapterHtml.append("<blockquote class='cite'>")
|
|
"poem" -> currentChapterHtml.append("<div class='poem'>")
|
|
"stanza" -> currentChapterHtml.append("<div class='stanza'>")
|
|
"a" -> {
|
|
val href = parser.getAttributeValue(null, "l:href")
|
|
?: parser.getAttributeValue(null, "xlink:href")
|
|
?: parser.getAttributeValue("http://www.w3.org/1999/xlink", "href")
|
|
if (!inTitle) {
|
|
if (href != null) {
|
|
currentChapterHtml.append("<a href=\"$href\">")
|
|
} else {
|
|
currentChapterHtml.append("<a>")
|
|
}
|
|
}
|
|
}
|
|
"image" -> {
|
|
// Safely extract href checking all possible namespace stripped versions
|
|
val href = parser.getAttributeValue(null, "l:href")
|
|
?: parser.getAttributeValue(null, "xlink:href")
|
|
?: parser.getAttributeValue("http://www.w3.org/1999/xlink", "href")
|
|
?: parser.getAttributeValue(null, "href")
|
|
|
|
if (href != null) {
|
|
val id = href.removePrefix("#")
|
|
if (!inBody) {
|
|
if (coverImageId == null) coverImageId = id
|
|
} else {
|
|
currentChapterHtml.append("<img src=\"$id\" />")
|
|
}
|
|
}
|
|
}
|
|
"binary" -> {
|
|
val id = parser.getAttributeValue(null, "id")
|
|
if (id != null) {
|
|
val base64Data = parser.nextText()
|
|
try {
|
|
val bytes = Base64.decode(base64Data, Base64.DEFAULT)
|
|
if (parseContent) {
|
|
val imgFile = File(extractionDir, id)
|
|
FileOutputStream(imgFile).use { it.write(bytes) }
|
|
}
|
|
|
|
images.add(EpubImage(absPath = id))
|
|
|
|
if (id == coverImageId || (coverImageId == null && id.contains("cover", ignoreCase = true))) {
|
|
coverBytes = bytes
|
|
coverImageId = id
|
|
}
|
|
} catch (e: Exception) {
|
|
Timber.e(e, "Failed to decode binary image $id")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
XmlPullParser.TEXT -> {
|
|
val text = parser.text?.replace("&", "&")?.replace("<", "<")?.replace(">", ">")
|
|
if (!text.isNullOrBlank()) {
|
|
if (inTitle) {
|
|
titleBuilder.append(text) // Append to buffer since it could be split by <p> tags
|
|
currentChapterHtml.append(text)
|
|
} else if (inBody) {
|
|
currentChapterHtml.append(text)
|
|
}
|
|
}
|
|
}
|
|
XmlPullParser.END_TAG -> {
|
|
val name = parser.name.lowercase()
|
|
when (name) {
|
|
"body" -> {
|
|
inBody = false
|
|
}
|
|
"title" -> {
|
|
if (inTitle) {
|
|
currentChapterTitle = titleBuilder.toString().replace("\\s+".toRegex(), " ").trim()
|
|
if (currentChapterTitle.isBlank()) {
|
|
currentChapterTitle = "Chapter ${chapterCount + 1}"
|
|
}
|
|
inTitle = false
|
|
}
|
|
currentChapterHtml.append("</h2>\n")
|
|
}
|
|
"p", "v" -> if (!inTitle) currentChapterHtml.append("</p>\n")
|
|
"subtitle" -> currentChapterHtml.append("</h3>\n")
|
|
"strong" -> currentChapterHtml.append("</b>")
|
|
"emphasis" -> currentChapterHtml.append("</i>")
|
|
"strikethrough" -> currentChapterHtml.append("</s>")
|
|
"sup" -> currentChapterHtml.append("</sup>")
|
|
"sub" -> currentChapterHtml.append("</sub>")
|
|
"epigraph" -> currentChapterHtml.append("</div>\n")
|
|
"cite" -> currentChapterHtml.append("</blockquote>\n")
|
|
"poem", "stanza" -> currentChapterHtml.append("</div>\n")
|
|
"a" -> if (!inTitle) currentChapterHtml.append("</a>")
|
|
}
|
|
}
|
|
}
|
|
|
|
if (eventType != XmlPullParser.END_DOCUMENT) {
|
|
eventType = parser.next()
|
|
}
|
|
}
|
|
|
|
saveChapter()
|
|
|
|
if (chapters.isEmpty() && parseContent) {
|
|
if (currentChapterHtml.isNotBlank()) {
|
|
saveChapter()
|
|
} else {
|
|
throw Exception("No valid content found in FB2 file.")
|
|
}
|
|
}
|
|
|
|
val coverBitmap = coverBytes?.let {
|
|
try {
|
|
BitmapFactory.decodeByteArray(it, 0, it.size)
|
|
} catch (e: Exception) {
|
|
Timber.e(e, "Failed to decode cover bitmap for FB2")
|
|
null
|
|
}
|
|
}
|
|
|
|
return@withContext EpubBook(
|
|
fileName = originalBookNameHint,
|
|
title = title,
|
|
author = author,
|
|
language = "en",
|
|
coverImage = coverBitmap,
|
|
chapters = chapters,
|
|
chaptersForPagination = chapters,
|
|
images = images,
|
|
pageList = emptyList(),
|
|
tableOfContents = emptyList(),
|
|
extractionBasePath = extractionDir.absolutePath,
|
|
css = emptyMap()
|
|
)
|
|
} finally {
|
|
try {
|
|
streamToParse.close()
|
|
} catch (e: Exception) {
|
|
Timber.e(e, "Error closing FB2 stream")
|
|
}
|
|
}
|
|
}
|
|
}
|