🚀 Add and Improve Parsers

Added new supported file extensions: ".zip", ".html" and ".htm". Improved parsers: ".epub".

Partly Resolves(no ".cbz"): #48
This commit is contained in:
Acclorite 2024-08-23 12:51:01 +03:00
parent 5e2cc28c92
commit e1fdac0f39
13 changed files with 987 additions and 536 deletions

View file

@ -2,8 +2,11 @@ package ua.acclorite.book_story.data.parser
import ua.acclorite.book_story.data.parser.epub.EpubFileParser import ua.acclorite.book_story.data.parser.epub.EpubFileParser
import ua.acclorite.book_story.data.parser.fb2.Fb2FileParser import ua.acclorite.book_story.data.parser.fb2.Fb2FileParser
import ua.acclorite.book_story.data.parser.htm.HtmFileParser
import ua.acclorite.book_story.data.parser.html.HtmlFileParser
import ua.acclorite.book_story.data.parser.pdf.PdfFileParser import ua.acclorite.book_story.data.parser.pdf.PdfFileParser
import ua.acclorite.book_story.data.parser.txt.TxtFileParser import ua.acclorite.book_story.data.parser.txt.TxtFileParser
import ua.acclorite.book_story.data.parser.zip.ZipFileParser
import ua.acclorite.book_story.domain.model.Book import ua.acclorite.book_story.domain.model.Book
import ua.acclorite.book_story.domain.util.CoverImage import ua.acclorite.book_story.domain.util.CoverImage
import java.io.File import java.io.File
@ -13,32 +16,48 @@ class FileParserImpl @Inject constructor(
private val txtFileParser: TxtFileParser, private val txtFileParser: TxtFileParser,
private val pdfFileParser: PdfFileParser, private val pdfFileParser: PdfFileParser,
private val epubFileParser: EpubFileParser, private val epubFileParser: EpubFileParser,
private val fb2FileParser: Fb2FileParser private val fb2FileParser: Fb2FileParser,
private val zipFileParser: ZipFileParser,
private val htmlFileParser: HtmlFileParser,
private val htmFileParser: HtmFileParser,
) : FileParser { ) : FileParser {
override suspend fun parse(file: File): Pair<Book, CoverImage?>? { override suspend fun parse(file: File): Pair<Book, CoverImage?>? {
if (!file.exists()) { if (!file.exists()) {
return null return null
} }
val fileFormat = ".${file.name.substringAfterLast(".")}".lowercase().trim() val fileFormat = ".${file.extension}".lowercase().trim()
return when (fileFormat) {
if (fileFormat == ".pdf") { ".pdf" -> {
return pdfFileParser.parse(file) pdfFileParser.parse(file)
} }
if (fileFormat == ".epub") { ".epub" -> {
return epubFileParser.parse(file) epubFileParser.parse(file)
} }
if (fileFormat == ".txt") { ".txt" -> {
return txtFileParser.parse(file) txtFileParser.parse(file)
} }
if (fileFormat == ".fb2") { ".fb2" -> {
return fb2FileParser.parse(file) fb2FileParser.parse(file)
} }
return null ".zip" -> {
zipFileParser.parse(file)
}
".html" -> {
htmlFileParser.parse(file)
}
".htm" -> {
htmFileParser.parse(file)
}
else -> null
}
} }
} }

View file

@ -1,10 +1,12 @@
package ua.acclorite.book_story.data.parser package ua.acclorite.book_story.data.parser
import ua.acclorite.book_story.R import ua.acclorite.book_story.R
import ua.acclorite.book_story.data.parser.epub.EpubTextParser
import ua.acclorite.book_story.data.parser.fb2.Fb2TextParser import ua.acclorite.book_story.data.parser.fb2.Fb2TextParser
import ua.acclorite.book_story.data.parser.htm.HtmTextParser
import ua.acclorite.book_story.data.parser.html.HtmlTextParser
import ua.acclorite.book_story.data.parser.pdf.PdfTextParser import ua.acclorite.book_story.data.parser.pdf.PdfTextParser
import ua.acclorite.book_story.data.parser.txt.TxtTextParser import ua.acclorite.book_story.data.parser.txt.TxtTextParser
import ua.acclorite.book_story.data.parser.zip.ZipTextParser
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.File import java.io.File
@ -13,8 +15,11 @@ import javax.inject.Inject
class TextParserImpl @Inject constructor( class TextParserImpl @Inject constructor(
private val txtTextParser: TxtTextParser, private val txtTextParser: TxtTextParser,
private val pdfTextParser: PdfTextParser, private val pdfTextParser: PdfTextParser,
private val epubTextParser: EpubTextParser, private val epubTextParser: HtmlTextParser,
private val fb2TextParser: Fb2TextParser private val fb2TextParser: Fb2TextParser,
private val zipTextParser: ZipTextParser,
private val htmlTextParser: HtmlTextParser,
private val htmTextParser: HtmTextParser,
) : TextParser { ) : TextParser {
override suspend fun parse(file: File): Resource<List<String>> { override suspend fun parse(file: File): Resource<List<String>> {
if (!file.exists()) { if (!file.exists()) {
@ -23,25 +28,38 @@ class TextParserImpl @Inject constructor(
) )
} }
val fileFormat = ".${file.name.substringAfterLast(".")}".lowercase().trim() val fileFormat = ".${file.extension}".lowercase().trim()
return when (fileFormat) {
if (fileFormat == ".pdf") { ".pdf" -> {
return pdfTextParser.parse(file) pdfTextParser.parse(file)
} }
if (fileFormat == ".epub") { ".epub" -> {
return epubTextParser.parse(file) epubTextParser.parse(file)
} }
if (fileFormat == ".txt") { ".txt" -> {
return txtTextParser.parse(file) txtTextParser.parse(file)
} }
if (fileFormat == ".fb2") { ".fb2" -> {
return fb2TextParser.parse(file) fb2TextParser.parse(file)
} }
return Resource.Error(UIText.StringResource(R.string.error_wrong_file_format)) ".zip" -> {
zipTextParser.parse(file)
}
".html" -> {
htmlTextParser.parse(file)
}
".htm" -> {
htmTextParser.parse(file)
}
else -> Resource.Error(UIText.StringResource(R.string.error_wrong_file_format))
}
} }
} }

View file

@ -5,6 +5,7 @@ import android.graphics.BitmapFactory
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.jsoup.Jsoup import org.jsoup.Jsoup
import ua.acclorite.book_story.R
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
@ -36,27 +37,40 @@ class EpubFileParser @Inject constructor() : FileParser {
.use { it.readText() } .use { it.readText() }
val document = Jsoup.parse(opfContent) val document = Jsoup.parse(opfContent)
val title = document.select("metadata > dc|title").text().trim() val title = document.select("metadata > dc|title").text().trim().run {
val author = UIText.StringValue( ifBlank {
document.select("metadata > dc|creator").text().trim() file.nameWithoutExtension.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()) { val author = document.select("metadata > dc|creator").text().trim().run {
coverImagePath = document if (isBlank()) {
UIText.StringResource(R.string.unknown_author)
} else {
UIText.StringValue(this)
}
}
val description = Jsoup.parse(
document.select("metadata > dc|description").text()
).text().run {
ifBlank {
null
}
}
val coverImage = document
.select("metadata > meta[name=cover]")
.attr("content")
.run {
if (isNotBlank()) {
document
.select("manifest > item[id=$this]")
.attr("href")
.apply { if (isNotBlank()) return@run this }
}
document
.select("manifest > item[media-type*=image]") .select("manifest > item[media-type*=image]")
.firstOrNull()?.attr("href") .firstOrNull()?.attr("href")
} }
@ -73,7 +87,7 @@ class EpubFileParser @Inject constructor() : FileParser {
lastOpened = null, lastOpened = null,
category = Category.entries[0], category = Category.entries[0],
coverImage = null coverImage = null
) to extractCoverImageBitmap(file, coverImagePath) ) to extractCoverImageBitmap(file, coverImage)
} }
} }
return book return book

View file

@ -29,7 +29,7 @@ class Fb2FileParser @Inject constructor() : FileParser {
} }
val titleFromFile = extractElementContent(document, "book-title") val titleFromFile = extractElementContent(document, "book-title")
val title = titleFromFile ?: file.name.dropLast(4).trim() val title = titleFromFile ?: file.nameWithoutExtension.trim()
val authorFirstName = extractElementContent(document, "first-name") val authorFirstName = extractElementContent(document, "first-name")
val authorLastName = extractElementContent(document, "last-name") val authorLastName = extractElementContent(document, "last-name")

View file

@ -0,0 +1,47 @@
package ua.acclorite.book_story.data.parser.htm
import org.jsoup.Jsoup
import ua.acclorite.book_story.R
import ua.acclorite.book_story.data.parser.FileParser
import ua.acclorite.book_story.domain.model.Book
import ua.acclorite.book_story.domain.model.Category
import ua.acclorite.book_story.domain.util.CoverImage
import ua.acclorite.book_story.domain.util.UIText
import java.io.File
import javax.inject.Inject
class HtmFileParser @Inject constructor() : FileParser {
override suspend fun parse(file: File): Pair<Book, CoverImage?>? {
if (!file.name.endsWith(".htm", true) || !file.exists()) {
return null
}
try {
val document = Jsoup.parse(file)
val title = document.select("head > title").text().trim().run {
ifBlank {
file.nameWithoutExtension.trim()
}
}
return Book(
title = title,
author = UIText.StringResource(R.string.unknown_author),
description = null,
textPath = "",
scrollIndex = 0,
scrollOffset = 0,
progress = 0f,
filePath = file.path,
lastOpened = null,
category = Category.entries[0],
coverImage = null
) to null
} catch (e: Exception) {
e.printStackTrace()
return null
}
}
}

View file

@ -0,0 +1,48 @@
package ua.acclorite.book_story.data.parser.htm
import org.jsoup.Jsoup
import ua.acclorite.book_story.R
import ua.acclorite.book_story.data.parser.TextParser
import ua.acclorite.book_story.domain.util.Resource
import ua.acclorite.book_story.domain.util.UIText
import java.io.File
import javax.inject.Inject
class HtmTextParser @Inject constructor() : TextParser {
override suspend fun parse(file: File): Resource<List<String>> {
if (!file.name.endsWith(".htm", true) || !file.exists()) {
return Resource.Error(UIText.StringResource(R.string.error_wrong_file_format))
}
try {
val lines = mutableListOf<String>()
val document = Jsoup.parse(file)
document.select("p").append("\n")
document
.wholeText()
.lines()
.forEach { line ->
if (line.isNotBlank()) {
lines.add(line.trim())
}
}
if (lines.isEmpty()) {
return Resource.Error(UIText.StringResource(R.string.error_file_empty))
}
return Resource.Success(lines)
} catch (e: Exception) {
e.printStackTrace()
return Resource.Error(
UIText.StringResource(
R.string.error_query,
e.message?.take(40)?.trim() ?: ""
)
)
}
}
}

View file

@ -0,0 +1,47 @@
package ua.acclorite.book_story.data.parser.html
import org.jsoup.Jsoup
import ua.acclorite.book_story.R
import ua.acclorite.book_story.data.parser.FileParser
import ua.acclorite.book_story.domain.model.Book
import ua.acclorite.book_story.domain.model.Category
import ua.acclorite.book_story.domain.util.CoverImage
import ua.acclorite.book_story.domain.util.UIText
import java.io.File
import javax.inject.Inject
class HtmlFileParser @Inject constructor() : FileParser {
override suspend fun parse(file: File): Pair<Book, CoverImage?>? {
if (!file.name.endsWith(".html", true) || !file.exists()) {
return null
}
try {
val document = Jsoup.parse(file)
val title = document.select("head > title").text().trim().run {
ifBlank {
file.nameWithoutExtension.trim()
}
}
return Book(
title = title,
author = UIText.StringResource(R.string.unknown_author),
description = null,
textPath = "",
scrollIndex = 0,
scrollOffset = 0,
progress = 0f,
filePath = file.path,
lastOpened = null,
category = Category.entries[0],
coverImage = null
) to null
} catch (e: Exception) {
e.printStackTrace()
return null
}
}
}

View file

@ -0,0 +1,48 @@
package ua.acclorite.book_story.data.parser.html
import org.jsoup.Jsoup
import ua.acclorite.book_story.R
import ua.acclorite.book_story.data.parser.TextParser
import ua.acclorite.book_story.domain.util.Resource
import ua.acclorite.book_story.domain.util.UIText
import java.io.File
import javax.inject.Inject
class HtmlTextParser @Inject constructor() : TextParser {
override suspend fun parse(file: File): Resource<List<String>> {
if (!file.name.endsWith(".html", true) || !file.exists()) {
return Resource.Error(UIText.StringResource(R.string.error_wrong_file_format))
}
try {
val lines = mutableListOf<String>()
val document = Jsoup.parse(file)
document.select("p").append("\n")
document
.wholeText()
.lines()
.forEach { line ->
if (line.isNotBlank()) {
lines.add(line.trim())
}
}
if (lines.isEmpty()) {
return Resource.Error(UIText.StringResource(R.string.error_file_empty))
}
return Resource.Success(lines)
} catch (e: Exception) {
e.printStackTrace()
return Resource.Error(
UIText.StringResource(
R.string.error_query,
e.message?.take(40)?.trim() ?: ""
)
)
}
}
}

View file

@ -24,10 +24,11 @@ class PdfFileParser @Inject constructor(private val application: Application) :
val document = PDDocument.load(file) val document = PDDocument.load(file)
val title = document.documentInformation.title ?: file.name.dropLast(4).trim() val title = document.documentInformation.title ?: file.nameWithoutExtension.trim()
val fileAuthor = document.documentInformation.author val author = document.documentInformation.author.run {
val author = if (fileAuthor != null) UIText.StringValue(fileAuthor) if (isNullOrBlank()) UIText.StringResource(R.string.unknown_author)
else UIText.StringResource(R.string.unknown_author) else UIText.StringValue(this)
}
val description = document.documentInformation.subject ?: null val description = document.documentInformation.subject ?: null
document.close() document.close()

View file

@ -17,7 +17,7 @@ class TxtFileParser @Inject constructor() : FileParser {
} }
try { try {
val title = file.name.trim().dropLast(4) val title = file.nameWithoutExtension.trim()
val author = UIText.StringResource(R.string.unknown_author) val author = UIText.StringResource(R.string.unknown_author)
return Book( return Book(

View file

@ -0,0 +1,116 @@
package ua.acclorite.book_story.data.parser.zip
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.jsoup.Jsoup
import ua.acclorite.book_story.R
import ua.acclorite.book_story.data.parser.FileParser
import ua.acclorite.book_story.domain.model.Book
import ua.acclorite.book_story.domain.model.Category
import ua.acclorite.book_story.domain.util.CoverImage
import ua.acclorite.book_story.domain.util.UIText
import java.io.File
import java.util.zip.ZipFile
import javax.inject.Inject
class ZipFileParser @Inject constructor() : FileParser {
override suspend fun parse(file: File): Pair<Book, CoverImage?>? {
if (!file.name.endsWith(".zip", true) || !file.exists()) {
return null
}
try {
var book: Pair<Book, CoverImage?>? = null
withContext(Dispatchers.IO) {
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().run {
ifBlank {
file.nameWithoutExtension.trim()
}
}
val author = document.select("metadata > dc|creator").text().trim().run {
if (isBlank()) {
UIText.StringResource(R.string.unknown_author)
} else {
UIText.StringValue(this)
}
}
val description = Jsoup.parse(
document.select("metadata > dc|description").text()
).text().run {
ifBlank {
null
}
}
val coverImage = document
.select("metadata > meta[name=cover]")
.attr("content")
.run {
if (isNotBlank()) {
document
.select("manifest > item[id=$this]")
.attr("href")
.apply { if (isNotBlank()) return@run this }
}
document
.select("manifest > item[media-type*=image]")
.firstOrNull()?.attr("href")
}
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 extractCoverImageBitmap(file, coverImage)
}
}
return book
} catch (e: Exception) {
e.printStackTrace()
return null
}
}
}
private fun extractCoverImageBitmap(file: File, coverImagePath: String?): Bitmap? {
if (coverImagePath.isNullOrBlank()) {
return null
}
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

@ -0,0 +1,69 @@
package ua.acclorite.book_story.data.parser.zip
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.jsoup.Jsoup
import ua.acclorite.book_story.R
import ua.acclorite.book_story.data.parser.TextParser
import ua.acclorite.book_story.domain.util.Resource
import ua.acclorite.book_story.domain.util.UIText
import java.io.File
import java.util.zip.ZipFile
import javax.inject.Inject
class ZipTextParser @Inject constructor() : TextParser {
override suspend fun parse(file: File): Resource<List<String>> {
if (!file.name.endsWith(".zip", true) || !file.exists()) {
return Resource.Error(UIText.StringResource(R.string.error_wrong_file_format))
}
try {
val lines = mutableListOf<String>()
withContext(Dispatchers.IO) {
ZipFile(file).use { zip ->
zip.entries().asSequence().forEach { entry ->
if (
entry.name.endsWith(".xhtml")
|| entry.name.endsWith(".html")
|| entry.name.endsWith(".xml")
|| entry.name.endsWith(".htm")
) {
val content = zip.getInputStream(entry).bufferedReader()
.use {
it.readText()
}
val document = Jsoup.parse(content)
document.select("p").append("\n")
document
.wholeText()
.lines()
.forEach { line ->
if (line.isNotBlank()) {
lines.add(line.trim())
}
}
}
}
}
}
if (lines.isEmpty()) {
return Resource.Error(UIText.StringResource(R.string.error_file_empty))
}
return Resource.Success(lines)
} catch (e: Exception) {
e.printStackTrace()
return Resource.Error(
UIText.StringResource(
R.string.error_query,
e.message?.take(40)?.trim() ?: ""
)
)
}
}
}

View file

@ -27,16 +27,46 @@ object Constants {
const val MAIN_STATE = "main_state" const val MAIN_STATE = "main_state"
// Supported file extensions // Supported file extensions
val EXTENSIONS = listOf(".epub", ".pdf", ".fb2", ".txt") val EXTENSIONS = provideSupportedExtensions()
// Supported languages // Supported languages
val LANGUAGES = listOf( val LANGUAGES = provideLanguages()
// Navigation items for NavigationBars.
val NAVIGATION_ITEMS = provideNavigationItems()
// Supported themes
val THEMES = provideThemes()
// Credits
val CREDITS = provideCredits()
// Help Tips
val HELP_TIPS = provideHelpTips()
// About Badges
val ABOUT_BADGES = provideAboutBadges()
// Fonts for Reader
val FONTS = provideFonts()
// Empty Book (Used for Default Screen values)
val EMPTY_BOOK = provideEmptyBook()
// Default Color Preset (Used when creating new color presets)
val DEFAULT_COLOR_PRESET = provideDefaultColorPreset()
}
private fun provideSupportedExtensions() = listOf(
".epub", ".pdf", ".fb2", ".txt", ".zip", ".html", ".htm"
)
private fun provideLanguages() = listOf(
Pair("en", "English"), Pair("en", "English"),
Pair("uk", "Українська"), Pair("uk", "Українська"),
) )
// Navigation items for NavigationBars. private fun provideNavigationItems() = listOf(
val NAVIGATION_ITEMS = listOf(
NavigationItem( NavigationItem(
screen = Screen.Library, screen = Screen.Library,
title = R.string.library_screen, title = R.string.library_screen,
@ -60,8 +90,7 @@ object Constants {
) )
) )
// Supported themes private fun provideThemes() = listOf(
val THEMES = listOf(
Pair(Theme.DYNAMIC, UIText.StringResource(R.string.dynamic_theme)), Pair(Theme.DYNAMIC, UIText.StringResource(R.string.dynamic_theme)),
Pair(Theme.BLUE, UIText.StringResource(R.string.blue_theme)), Pair(Theme.BLUE, UIText.StringResource(R.string.blue_theme)),
Pair(Theme.GREEN, UIText.StringResource(R.string.green_theme)), Pair(Theme.GREEN, UIText.StringResource(R.string.green_theme)),
@ -72,8 +101,7 @@ object Constants {
Pair(Theme.AQUA, UIText.StringResource(R.string.aqua_theme)), Pair(Theme.AQUA, UIText.StringResource(R.string.aqua_theme)),
) )
// Credits private fun provideCredits() = listOf(
val CREDITS = listOf(
Credit( Credit(
name = "Tachiyomi (Mihon)", name = "Tachiyomi (Mihon)",
source = "GitHub", source = "GitHub",
@ -101,8 +129,7 @@ object Constants {
), ),
) )
// Help Tips private fun provideHelpTips() = listOf(
val HELP_TIPS = listOf(
HelpTip( HelpTip(
title = R.string.help_title_how_to_find_books, title = R.string.help_title_how_to_find_books,
description = { _, _ -> description = { _, _ ->
@ -331,8 +358,7 @@ object Constants {
), ),
) )
// About Badges private fun provideAboutBadges() = listOf(
val ABOUT_BADGES = listOf(
Badge( Badge(
id = "x", id = "x",
drawable = R.drawable.x_logo, drawable = R.drawable.x_logo,
@ -370,8 +396,7 @@ object Constants {
), ),
) )
// Fonts for Reader private fun provideFonts() = listOf(
val FONTS = listOf(
FontWithName( FontWithName(
"default", "default",
UIText.StringResource(R.string.default_font), UIText.StringResource(R.string.default_font),
@ -498,7 +523,7 @@ object Constants {
) )
) )
val EMPTY_BOOK = Book( private fun provideEmptyBook() = Book(
id = -1, id = -1,
title = "", title = "",
author = UIText.StringValue(""), author = UIText.StringValue(""),
@ -513,11 +538,10 @@ object Constants {
category = Category.READING category = Category.READING
) )
val DEFAULT_COLOR_PRESET = ColorPreset( private fun provideDefaultColorPreset() = ColorPreset(
id = -1, id = -1,
name = null, name = null,
backgroundColor = Color.DarkGray, backgroundColor = Color.DarkGray,
fontColor = Color.LightGray, fontColor = Color.LightGray,
isSelected = false isSelected = false
) )
}