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:
parent
65e0570d0e
commit
26692d2c05
14 changed files with 747 additions and 664 deletions
|
|
@ -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("\"", """)}</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("\"", """)}</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("&", "&")?.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 && !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("&", "&")?.replace("<", "<")?.replace(">", ">")
|
||||
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()
|
||||
)
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue