🚀 Improve EPUB parser

Replace EpubLib parser with custom that uses ZipFile. The previous parser had multiple problems with reading and parsing books. This should resolve most errors, improve compatibility and provide better user experience.

Resolves: #42
This commit is contained in:
Acclorite 2024-08-12 18:18:23 +03:00
parent 01173a2b72
commit 3b69b89819
3 changed files with 103 additions and 98 deletions

View file

@ -137,9 +137,6 @@ dependencies {
implementation("com.tom-roush:pdfbox-android:2.0.27.0") implementation("com.tom-roush:pdfbox-android:2.0.27.0")
// Epub parser // Epub parser
implementation("com.positiondev.epublib:epublib-core:3.1") {
exclude("xmlpull")
}
implementation("org.jsoup:jsoup:1.18.1") implementation("org.jsoup:jsoup:1.18.1")
// Fb2 parser // Fb2 parser

View file

@ -1,16 +1,17 @@
package ua.acclorite.book_story.data.parser.epub package ua.acclorite.book_story.data.parser.epub
import android.graphics.Bitmap
import android.graphics.BitmapFactory import android.graphics.BitmapFactory
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import nl.siegmann.epublib.epub.EpubReader import org.jsoup.Jsoup
import ua.acclorite.book_story.data.parser.FileParser import ua.acclorite.book_story.data.parser.FileParser
import ua.acclorite.book_story.domain.model.Book import ua.acclorite.book_story.domain.model.Book
import ua.acclorite.book_story.domain.model.Category import ua.acclorite.book_story.domain.model.Category
import ua.acclorite.book_story.domain.util.CoverImage import ua.acclorite.book_story.domain.util.CoverImage
import ua.acclorite.book_story.domain.util.UIText import ua.acclorite.book_story.domain.util.UIText
import java.io.File import java.io.File
import java.io.FileInputStream import java.util.zip.ZipFile
import javax.inject.Inject import javax.inject.Inject
class EpubFileParser @Inject constructor() : FileParser { class EpubFileParser @Inject constructor() : FileParser {
@ -21,45 +22,83 @@ class EpubFileParser @Inject constructor() : FileParser {
} }
try { try {
val epubReader = EpubReader() var book: Pair<Book, CoverImage?>? = null
val book = withContext(Dispatchers.IO) {
FileInputStream(file).use { withContext(Dispatchers.IO) {
epubReader.readEpub(it) ZipFile(file).use { zip ->
val opfEntry = zip.entries().asSequence().find { entry ->
entry.name.endsWith(".opf")
} ?: return@withContext
val opfContent = zip
.getInputStream(opfEntry)
.bufferedReader()
.use { it.readText() }
val document = Jsoup.parse(opfContent)
val title = document.select("metadata > dc|title").text().trim()
val author = UIText.StringValue(
document.select("metadata > dc|creator").text().trim()
)
val description = Jsoup.parse(
document.select("metadata > dc|description").text()
).text()
var coverImagePath: String? = null
val coverId = document
.select("metadata > meta[name=cover]")
.attr("content")
if (coverId.isNotBlank()) {
coverImagePath = document
.select("manifest > item[id=$coverId]")
.attr("href")
}
if (coverImagePath.isNullOrBlank()) {
coverImagePath = document
.select("manifest > item[media-type*=image]")
.firstOrNull()?.attr("href")
}
if (coverImagePath == null) {
return@withContext
}
val coverImage = extractCoverImageBitmap(file, coverImagePath)
book = Book(
title = title,
author = author,
description = description,
textPath = "",
scrollIndex = 0,
scrollOffset = 0,
progress = 0f,
filePath = file.path,
lastOpened = null,
category = Category.entries[0],
coverImage = null
) to coverImage
} }
} }
return book
val metadata = book.metadata
val author = UIText.StringValue(metadata.authors.joinToString(", "))
val title = metadata.titles.joinToString(", ")
val coverImage = book.coverImage?.let { BitmapFactory.decodeStream(it.inputStream) }
var description: StringBuilder? = StringBuilder()
metadata.descriptions.forEach {
description?.append(it)?.append(" ")
}
if (description != null) {
if (description.isBlank()) {
description = null
}
}
return Book(
title = title,
author = author,
description = description?.toString(),
textPath = "",
scrollIndex = 0,
scrollOffset = 0,
progress = 0f,
filePath = file.path,
lastOpened = null,
category = Category.entries[0],
coverImage = null
) to coverImage
} catch (e: Exception) { } catch (e: Exception) {
e.printStackTrace() e.printStackTrace()
return null return null
} }
} }
}
private fun extractCoverImageBitmap(file: File, coverImagePath: String): Bitmap? {
ZipFile(file).use { zip ->
zip.entries().asSequence().forEach { entry ->
if (entry.name.endsWith(coverImagePath)) {
val imageBytes = zip.getInputStream(entry).readBytes()
return BitmapFactory.decodeByteArray(imageBytes, 0, imageBytes.size)
}
}
}
return null
} }

View file

@ -2,19 +2,13 @@ package ua.acclorite.book_story.data.parser.epub
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import nl.siegmann.epublib.epub.EpubReader
import org.jsoup.Jsoup import org.jsoup.Jsoup
import org.jsoup.nodes.Document.OutputSettings
import org.jsoup.safety.Safelist
import ua.acclorite.book_story.R import ua.acclorite.book_story.R
import ua.acclorite.book_story.data.parser.TextParser import ua.acclorite.book_story.data.parser.TextParser
import ua.acclorite.book_story.domain.util.Resource import ua.acclorite.book_story.domain.util.Resource
import ua.acclorite.book_story.domain.util.UIText import ua.acclorite.book_story.domain.util.UIText
import java.io.BufferedReader
import java.io.File import java.io.File
import java.io.FileInputStream import java.util.zip.ZipFile
import java.io.InputStreamReader
import java.nio.charset.Charset
import javax.inject.Inject import javax.inject.Inject
@ -26,66 +20,41 @@ class EpubTextParser @Inject constructor() : TextParser {
} }
try { try {
val book = withContext(Dispatchers.IO) { val lines = mutableListOf<String>()
EpubReader().readEpub(FileInputStream(file))
}
val unformattedText = StringBuilder()
for (spineReference in book.spine.spineReferences) { withContext(Dispatchers.IO) {
val resource = spineReference.resource ZipFile(file).use { zip ->
val inputStream = resource.inputStream zip.entries().asSequence().forEach { entry ->
val reader = BufferedReader( if (
InputStreamReader( entry.name.endsWith(".xhtml")
inputStream, || entry.name.endsWith(".html")
Charset.forName("UTF-8") || entry.name.endsWith(".xml")
) || entry.name.endsWith(".htm")
) ) {
var line: String? val content = zip.getInputStream(entry).bufferedReader()
.use {
it.readText()
}
withContext(Dispatchers.IO) { val document = Jsoup.parse(content)
inputStream.close() document
} .wholeText()
.lines()
while (withContext(Dispatchers.IO) { reader.readLine() } .forEach { element ->
.also { line = it } != null) { if (element.isNotBlank()) {
unformattedText.append(line).append("\n") lines.add(element.trim())
} }
}
withContext(Dispatchers.IO) { }
reader.close()
}
}
val strings = mutableListOf<String>()
val parsedText = Jsoup.parse(unformattedText.toString())
parsedText.outputSettings(OutputSettings().prettyPrint(false))
parsedText.select("br").append("\n")
parsedText.select("p").prepend("\n")
parsedText.select("em").append(" ").prepend("")
val formattedText = Jsoup.clean(
parsedText.html(),
"",
Safelist.none(),
OutputSettings().prettyPrint(false)
)
formattedText
.replace("&nbsp;", "")
.replace("\u00a0", "")
.split("\n")
.forEach {
if (it.isNotBlank()) {
strings.add(it.trim())
} }
} }
}
if (strings.isEmpty()) { if (lines.isEmpty()) {
return Resource.Error(UIText.StringResource(R.string.error_file_empty)) return Resource.Error(UIText.StringResource(R.string.error_file_empty))
} }
return Resource.Success(strings) return Resource.Success(lines)
} catch (e: Exception) { } catch (e: Exception) {
e.printStackTrace() e.printStackTrace()
return Resource.Error( return Resource.Error(