General improvements (#151)

* Updated folder synchronization logic and redesigned the file type filter dialog.

* Fixed CFI generation and navigation stability in the EPUB reader.

* Improved CFI scrolling and position calculation in the EPUB reader by implementing text node traversal using `TreeWalker`. This ensures accurate positioning and scrolling when a CFI offset spans multiple fragmented text nodes.

* fix fb2 multiline titles, retain footnotes, and prevent stream leaks

- Fix FB2 titles with multiple paragraphs by inserting breaks/spaces
- Prevent resource leaks by properly closing InputStreams in all importers
- Retain "notes" and "comments" sections in FB2 instead of skipping them
- Add support for FB2 poem, stanza, cite, and link tags

* Implement persistence for zoom and pan states when pan lock is enabled in the PDF reader.

* Added `FileTypeBadge` to home and library screens

* Bump version to 1.0.41(42)
This commit is contained in:
Aryan 2026-04-05 10:36:44 +05:30 committed by GitHub
parent 65e0570d0e
commit 26692d2c05
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 747 additions and 664 deletions

View file

@ -21,263 +21,305 @@ class Fb2Parser(private val context: Context) {
bookId: String,
originalBookNameHint: String,
parseContent: Boolean = true
): EpubBook {
): EpubBook = withContext(Dispatchers.IO) {
val extractionDir = File(context.cacheDir, "imported_file_$bookId").apply {
if (!exists()) mkdirs()
}
var streamToParse = inputStream
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
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.")
}
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)
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
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
val chapters = mutableListOf<EpubChapter>()
val images = mutableListOf<EpubImage>() // Keep track of extracted images
var currentChapterHtml = StringBuilder()
var currentChapterTitle = "Chapter"
var chapterCount = 0
var inSection = false
var inBody = false
var inTitle = false
var skipElement = false
val titleBuilder = java.lang.StringBuilder() // Buffer to handle <p> tags inside <title>
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; }
""".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("\"", "&quot;")}</title>
<style>${cssStyle}</style>
</head>
<body>
$currentChapterHtml
</body>
</html>
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()
FileOutputStream(file).use { it.write(fullHtml.toByteArray()) }
val plainText = Jsoup.parse(fullHtml).text()
fun saveChapter() {
if (!parseContent || currentChapterHtml.isEmpty()) return
chapterCount++
val fileName = "chapter_$chapterCount.html"
val file = File(extractionDir, fileName)
chapters.add(
EpubChapter(
chapterId = "${bookId}_${chapterCount}",
absPath = fileName,
title = currentChapterTitle,
htmlFilePath = fileName,
plainTextContent = plainText,
htmlContent = "",
depth = 0,
isInToc = true
val fullHtml = """
<!DOCTYPE html>
<html>
<head>
<title>${currentChapterTitle.replace("\"", "&quot;")}</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}"
}
currentChapterHtml.clear()
currentChapterTitle = "Chapter ${chapterCount + 1}"
}
var eventType = parser.eventType
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"
while (eventType != XmlPullParser.END_DOCUMENT) {
when (eventType) {
XmlPullParser.START_TAG -> {
val name = parser.name.lowercase()
when (name) {
"book-title" -> {
title = parser.nextText().trim()
}
}
"body" -> {
val nameAttr = parser.getAttributeValue(null, "name")
if (nameAttr == "notes" || nameAttr == "comments") {
skipElement = true
} else {
"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 && !skipElement) {
if (currentChapterHtml.isNotBlank()) {
saveChapter()
"section" -> {
if (inBody) {
if (currentChapterHtml.isNotBlank()) {
saveChapter()
}
inSection = true
}
inSection = true
}
}
"title" -> {
if (inSection && currentChapterHtml.isEmpty()) {
inTitle = true
titleBuilder.clear()
"title" -> {
if (inSection && currentChapterHtml.isEmpty()) {
inTitle = true
titleBuilder.clear()
}
currentChapterHtml.append("<h2>")
}
currentChapterHtml.append("<h2>")
}
"p" -> if (!inTitle) currentChapterHtml.append("<p>")
"v" -> if (!inTitle) currentChapterHtml.append("<p style='text-indent: 0;'>")
"subtitle" -> currentChapterHtml.append("<h3>")
"empty-line" -> currentChapterHtml.append("<div class='empty-line'></div>")
"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'>")
"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")
"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) {
coverImageId = id
} else {
currentChapterHtml.append("<img src=\"$id\" />")
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)
withContext(Dispatchers.IO) {
"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))
images.add(EpubImage(absPath = id))
if (id == coverImageId || (coverImageId == null && id.contains("cover", ignoreCase = true))) {
coverBytes = bytes
coverImageId = 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")
}
} catch (e: Exception) {
Timber.e(e, "Failed to decode binary image $id")
}
}
}
}
}
XmlPullParser.TEXT -> {
val text = parser.text?.replace("&", "&amp;")?.replace("<", "&lt;")?.replace(">", "&gt;")
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 && !skipElement) {
currentChapterHtml.append(text)
}
}
}
XmlPullParser.END_TAG -> {
val name = parser.name.lowercase()
when (name) {
"body" -> {
skipElement = false
inBody = false
}
"title" -> {
XmlPullParser.TEXT -> {
val text = parser.text?.replace("&", "&amp;")?.replace("<", "&lt;")?.replace(">", "&gt;")
if (!text.isNullOrBlank()) {
if (inTitle) {
currentChapterTitle = titleBuilder.toString().trim()
inTitle = false
titleBuilder.append(text) // Append to buffer since it could be split by <p> tags
currentChapterHtml.append(text)
} else if (inBody) {
currentChapterHtml.append(text)
}
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")
}
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()
}
}
// Calling nextText() moves the parser directly to END_TAG.
// We ensure we don't accidentally read past the EOF.
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.")
}
}
}
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
}
}
}
val coverBitmap = coverBytes?.let {
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 {
BitmapFactory.decodeByteArray(it, 0, it.size)
streamToParse.close()
} catch (e: Exception) {
Timber.e(e, "Failed to decode cover bitmap for FB2")
null
Timber.e(e, "Error closing FB2 stream")
}
}
return EpubBook(
fileName = originalBookNameHint,
title = title,
author = author,
language = "en",
coverImage = coverBitmap,
chapters = chapters,
chaptersForPagination = chapters,
images = images, // Extracted images attached!
pageList = emptyList(),
tableOfContents = emptyList(),
extractionBasePath = extractionDir.absolutePath,
css = emptyMap()
)
}
}