Support more formats (#112)

* Added support for FB2 book format

* Add support for CBZ files by introducing a `ReaderDocument` abstraction.

Key changes:
- Defined `ReaderDocument`, `ReaderPage`, and `ReaderTextPage` interfaces to provide a unified API for different document types.
- Implemented `PdfDocumentWrapper` and `CbzDocumentWrapper` to handle PDF and CBZ files respectively.
- Updated `PdfViewerScreen`, `PdfPageComposable`, and `MainViewModel` to use the new unified document interfaces.
- Added CBZ file type detection and cover generation logic.

* Add support for CBR and CB7 comic book formats

- Add `me.zhanghai.android.libarchive` dependency to support RAR and 7z archives.
- Implement `ArchiveDocumentWrapper` using `libarchive` to handle CBZ, CBR, and CB7 files uniformly, replacing the previous CBZ-only implementation.
- Update `MainViewModel` and `DocumentFactory` to recognize and process `.cbr` and `.cb7` extensions and their associated MIME types.
- Add support for extracting and caching cover images from CBR and CB7 archives.
- Update UI components (`HomeScreen`, `LibraryScreen`, `AppNavigation`) to handle the new comic book file types.
This commit is contained in:
Aryan 2026-03-26 12:27:56 +05:30 committed by GitHub
parent 32e29dfc07
commit 4db2c97a30
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 871 additions and 93 deletions

View file

@ -0,0 +1,282 @@
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,
originalBookNameHint: String
): EpubBook {
val bookId = originalBookNameHint.hashCode().toString()
val extractionDir = File(context.cacheDir, "imported_file_$bookId").apply {
if (!exists()) mkdirs()
}
// Seamless ZIP extraction for .fb2.zip extensions
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
}
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"
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>
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 (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>
""".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" -> {
val nameAttr = parser.getAttributeValue(null, "name")
if (nameAttr == "notes" || nameAttr == "comments") {
skipElement = true
} else {
inBody = true
}
}
"section" -> {
if (inBody && !skipElement) {
if (currentChapterHtml.isNotBlank()) {
saveChapter()
}
inSection = true
}
}
"title" -> {
if (inSection && currentChapterHtml.isEmpty()) {
inTitle = true
titleBuilder.clear()
}
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")
if (href != null) {
val id = href.removePrefix("#")
if (!inBody) {
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)
val imgFile = File(extractionDir, id)
withContext(Dispatchers.IO) {
FileOutputStream(imgFile).use { it.write(bytes) }
}
// Add the image to the EpubBook image index
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("&", "&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" -> {
if (inTitle) {
currentChapterTitle = titleBuilder.toString().trim()
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")
}
}
}
// 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() // Save the final chunk of content
if (chapters.isEmpty()) {
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 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()
)
}
}