More formats support (#161)
* Added support for ODT and FODT file formats. * Improved `OdtParser` to support enhanced document formatting and external assets * Added support for viewing CSV, TSV, JSON, XML, logs, and source code files by dynamically converting them to HTML.
This commit is contained in:
parent
6a60aec0ef
commit
291504fd90
8 changed files with 652 additions and 76 deletions
|
|
@ -82,7 +82,7 @@ fun AppNavigation(
|
|||
}
|
||||
}
|
||||
}
|
||||
FileType.EPUB, FileType.MOBI, FileType.MD, FileType.TXT, FileType.HTML, FileType.FB2, FileType.DOCX -> {
|
||||
FileType.EPUB, FileType.MOBI, FileType.MD, FileType.TXT, FileType.HTML, FileType.FB2, FileType.DOCX, FileType.ODT, FileType.FODT -> {
|
||||
if (uiState.selectedEpubBook != null) {
|
||||
if (navController.currentDestination?.route != AppDestinations.EPUB_READER_ROUTE) {
|
||||
navController.navigate(AppDestinations.EPUB_READER_ROUTE) {
|
||||
|
|
|
|||
|
|
@ -624,7 +624,7 @@ fun RecentFileCard(
|
|||
val context = LocalContext.current
|
||||
val placeholder = when (item.type) {
|
||||
FileType.PDF -> R.drawable.pdf_placeholder
|
||||
FileType.EPUB, FileType.MOBI, FileType.FB2, FileType.MD, FileType.TXT, FileType.HTML, FileType.CBZ, FileType.CBR, FileType.CB7, FileType.DOCX -> R.drawable.epub_placeholder
|
||||
FileType.EPUB, FileType.MOBI, FileType.FB2, FileType.MD, FileType.TXT, FileType.HTML, FileType.CBZ, FileType.CBR, FileType.CB7, FileType.DOCX, FileType.ODT, FileType.FODT -> R.drawable.epub_placeholder
|
||||
}
|
||||
val imageModel = remember(item.coverImagePath) {
|
||||
item.coverImagePath?.let { File(it) } ?: placeholder
|
||||
|
|
|
|||
|
|
@ -1301,7 +1301,7 @@ private fun LibraryListItem(
|
|||
val context = LocalContext.current
|
||||
val placeholder = when (item.type) {
|
||||
FileType.PDF -> R.drawable.pdf_placeholder
|
||||
FileType.EPUB, FileType.MOBI, FileType.FB2, FileType.MD, FileType.TXT, FileType.HTML, FileType.CBZ, FileType.CBR, FileType.CB7, FileType.DOCX -> R.drawable.epub_placeholder
|
||||
FileType.EPUB, FileType.MOBI, FileType.FB2, FileType.MD, FileType.TXT, FileType.HTML, FileType.CBZ, FileType.CBR, FileType.CB7, FileType.DOCX, FileType.ODT, FileType.FODT -> R.drawable.epub_placeholder
|
||||
}
|
||||
val imageModel = remember(item.coverImagePath) {
|
||||
item.coverImagePath?.let { File(it) } ?: placeholder
|
||||
|
|
|
|||
|
|
@ -136,7 +136,7 @@ enum class AddBooksSource(val displayName: String) {
|
|||
}
|
||||
|
||||
enum class FileType {
|
||||
PDF, EPUB, MOBI, MD, TXT, HTML, FB2, CBZ, CBR, CB7, DOCX
|
||||
PDF, EPUB, MOBI, MD, TXT, HTML, FB2, CBZ, CBR, CB7, DOCX, ODT, FODT
|
||||
}
|
||||
|
||||
enum class RenderMode {
|
||||
|
|
@ -254,6 +254,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
|
|||
private val epubParser = EpubParser(appContext)
|
||||
private val mobiParser = MobiParser(appContext)
|
||||
private val fb2Parser = com.aryan.reader.epub.Fb2Parser(appContext)
|
||||
private val odtParser = com.aryan.reader.epub.OdtParser(appContext)
|
||||
private val singleFileImporter = SingleFileImporter(appContext)
|
||||
private val bookImporter = BookImporter(appContext)
|
||||
private val prefs: SharedPreferences =
|
||||
|
|
@ -2536,7 +2537,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
|
|||
var author: String? = null
|
||||
var bookForMetadata = epubBook
|
||||
|
||||
if (bookForMetadata == null && (type == FileType.EPUB || type == FileType.MOBI || type == FileType.FB2 || type == FileType.MD || type == FileType.TXT || type == FileType.HTML || type == FileType.DOCX)) {
|
||||
if (bookForMetadata == null && (type == FileType.EPUB || type == FileType.MOBI || type == FileType.FB2 || type == FileType.MD || type == FileType.TXT || type == FileType.HTML || type == FileType.DOCX || type == FileType.ODT || type == FileType.FODT)) {
|
||||
Timber.d("Parsing downloaded book for cover/metadata: $displayName")
|
||||
Timber.tag("FileOpenPerf")
|
||||
.d("[$bookId] addFileToRecent: Starting metadata parsing (no book provided)")
|
||||
|
|
@ -2570,6 +2571,15 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
|
|||
parseContent = false
|
||||
)
|
||||
}
|
||||
FileType.ODT, FileType.FODT -> {
|
||||
odtParser.createOdtBook(
|
||||
inputStream = inputStream,
|
||||
bookId = bookId,
|
||||
originalBookNameHint = displayName,
|
||||
isFlat = type == FileType.FODT,
|
||||
parseContent = false
|
||||
)
|
||||
}
|
||||
else -> {
|
||||
singleFileImporter.importSingleFile(
|
||||
inputStream,
|
||||
|
|
@ -2598,7 +2608,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
|
|||
|
||||
val finalBookMetadata = bookForMetadata
|
||||
|
||||
if ((type == FileType.EPUB || type == FileType.MOBI || type == FileType.FB2 || type == FileType.MD || type == FileType.TXT || type == FileType.HTML || type == FileType.DOCX) && finalBookMetadata != null) {
|
||||
if ((type == FileType.EPUB || type == FileType.MOBI || type == FileType.FB2 || type == FileType.MD || type == FileType.TXT || type == FileType.HTML || type == FileType.DOCX || type == FileType.ODT || type == FileType.FODT) && finalBookMetadata != null) {
|
||||
title =
|
||||
finalBookMetadata.title.takeIf { it.isNotBlank() && it != "content" } ?: displayName
|
||||
|
||||
|
|
@ -3203,7 +3213,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
|
|||
Timber.tag("FileSwitch").d("PDF state updated, suppressing navigation event for smooth transition")
|
||||
}
|
||||
}
|
||||
} else if (type == FileType.EPUB || type == FileType.MOBI || type == FileType.FB2 || type == FileType.MD || type == FileType.TXT || type == FileType.HTML || type == FileType.DOCX) {
|
||||
} else if (type == FileType.EPUB || type == FileType.MOBI || type == FileType.FB2 || type == FileType.MD || type == FileType.TXT || type == FileType.HTML || type == FileType.DOCX || type == FileType.ODT || type == FileType.FODT) {
|
||||
viewModelScope.launch {
|
||||
val recentItem = recentFilesRepository.getFileByBookId(bookId)
|
||||
if (recentItem?.sourceFolderUri != null) {
|
||||
|
|
@ -3251,7 +3261,9 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
|
|||
FileType.FB2 -> {
|
||||
loadFb2(uri, bookId, customDisplayName = originalDisplayName)
|
||||
}
|
||||
|
||||
FileType.ODT, FileType.FODT -> {
|
||||
loadOdt(uri, bookId, type == FileType.FODT, customDisplayName = originalDisplayName)
|
||||
}
|
||||
else -> {
|
||||
loadSingleFile(
|
||||
uri, bookId, type, customDisplayName = originalDisplayName
|
||||
|
|
@ -3299,6 +3311,43 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
|
|||
}
|
||||
}
|
||||
|
||||
private fun loadOdt(uri: Uri, bookId: String, isFlat: Boolean, customDisplayName: String? = null) {
|
||||
val loadStart = System.currentTimeMillis()
|
||||
Timber.tag("FileOpenPerf").d("[$bookId] loadOdt START | isFlat=$isFlat")
|
||||
viewModelScope.launch {
|
||||
if (!_internalState.value.isLoading) {
|
||||
_internalState.update { it.copy(isLoading = true, errorMessage = null) }
|
||||
}
|
||||
Timber.d("Starting ODT parsing for URI: $uri")
|
||||
try {
|
||||
val odtBook = withContext(Dispatchers.IO) {
|
||||
appContext.contentResolver.openInputStream(uri).use { inputStream ->
|
||||
if (inputStream == null) throw Exception("Could not open input stream")
|
||||
odtParser.createOdtBook(
|
||||
inputStream = inputStream,
|
||||
bookId = bookId,
|
||||
originalBookNameHint = customDisplayName ?: getFileNameFromUri(uri, appContext) ?: if (isFlat) "unknown.fodt" else "unknown.odt",
|
||||
isFlat = isFlat
|
||||
)
|
||||
}
|
||||
}
|
||||
Timber.i("ODT parsing successful. Title: ${odtBook.title}")
|
||||
Timber.tag("FileOpenPerf").d("[$bookId] loadOdt completed | chapters=${odtBook.chapters.size} | elapsed=${System.currentTimeMillis() - loadStart}ms")
|
||||
|
||||
addFileToRecent(
|
||||
uri, if (isFlat) FileType.FODT else FileType.ODT, bookId, odtBook, customDisplayName, isRecent = true, sourceFolderUri = null
|
||||
)
|
||||
|
||||
_internalState.update { it.copy(selectedEpubBook = odtBook, isLoading = false) }
|
||||
} catch (e: Exception) {
|
||||
Timber.e(e, "Error parsing ODT for URI: $uri")
|
||||
_internalState.update {
|
||||
it.copy(errorMessage = appContext.getString(R.string.error_load_file, e.message), isLoading = false)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadSingleFile(
|
||||
uri: Uri,
|
||||
bookId: String,
|
||||
|
|
@ -3366,6 +3415,8 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
|
|||
Timber.d("Determining type for: $uri | Mime: $mimeType | Name: $fileName")
|
||||
|
||||
return when (mimeType) {
|
||||
"application/vnd.oasis.opendocument.text" -> FileType.ODT
|
||||
"application/x-vnd.oasis.opendocument.text-flat-xml" -> FileType.FODT
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document" -> FileType.DOCX
|
||||
"application/zip", "application/vnd.comicbook+zip", "application/x-cbz" -> {
|
||||
if (fileName?.endsWith(".cbz", ignoreCase = true) == true) FileType.CBZ else null
|
||||
|
|
@ -3382,13 +3433,26 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
|
|||
"application/x-mobipocket-ebook", "application/vnd.amazon.ebook", "application/vnd.amazon.mobi8-ebook" -> FileType.MOBI
|
||||
"text/markdown", "text/x-markdown" -> FileType.MD
|
||||
"text/html", "application/xhtml+xml" -> FileType.HTML
|
||||
|
||||
"text/csv", "text/comma-separated-values", "text/tab-separated-values",
|
||||
"application/json", "application/xml", "text/xml",
|
||||
"text/x-java-source", "text/x-python", "text/x-kotlin",
|
||||
"text/javascript", "application/javascript",
|
||||
"text/x-c", "text/x-c++", "text/x-csharp", "text/x-ruby", "text/x-go", "text/x-log" -> FileType.HTML
|
||||
|
||||
"text/plain" -> {
|
||||
if (fileName?.endsWith(
|
||||
".md",
|
||||
ignoreCase = true
|
||||
) == true || fileName?.endsWith(".markdown", ignoreCase = true) == true
|
||||
) {
|
||||
if (fileName?.endsWith(".md", ignoreCase = true) == true || fileName?.endsWith(".markdown", ignoreCase = true) == true) {
|
||||
FileType.MD
|
||||
} else if (fileName?.let {
|
||||
it.endsWith(".csv", ignoreCase = true) || it.endsWith(".tsv", ignoreCase = true) ||
|
||||
it.endsWith(".json", ignoreCase = true) || it.endsWith(".xml", ignoreCase = true) ||
|
||||
it.endsWith(".log", ignoreCase = true) || it.endsWith(".java", ignoreCase = true) ||
|
||||
it.endsWith(".kt", ignoreCase = true) || it.endsWith(".py", ignoreCase = true) ||
|
||||
it.endsWith(".js", ignoreCase = true) || it.endsWith(".cpp", ignoreCase = true) ||
|
||||
it.endsWith(".c", ignoreCase = true) || it.endsWith(".cs", ignoreCase = true) ||
|
||||
it.endsWith(".rb", ignoreCase = true) || it.endsWith(".go", ignoreCase = true)
|
||||
} == true) {
|
||||
FileType.HTML
|
||||
} else {
|
||||
FileType.TXT
|
||||
}
|
||||
|
|
@ -3439,6 +3503,22 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
|
|||
ignoreCase = true
|
||||
) == true -> FileType.HTML
|
||||
fileName?.endsWith(".docx", ignoreCase = true) == true -> FileType.DOCX
|
||||
fileName?.endsWith(".odt", ignoreCase = true) == true -> FileType.ODT
|
||||
fileName?.endsWith(".fodt", ignoreCase = true) == true -> FileType.FODT
|
||||
fileName?.endsWith(".csv", ignoreCase = true) == true ||
|
||||
fileName?.endsWith(".tsv", ignoreCase = true) == true ||
|
||||
fileName?.endsWith(".json", ignoreCase = true) == true ||
|
||||
fileName?.endsWith(".xml", ignoreCase = true) == true ||
|
||||
fileName?.endsWith(".log", ignoreCase = true) == true ||
|
||||
fileName?.endsWith(".java", ignoreCase = true) == true ||
|
||||
fileName?.endsWith(".kt", ignoreCase = true) == true ||
|
||||
fileName?.endsWith(".py", ignoreCase = true) == true ||
|
||||
fileName?.endsWith(".js", ignoreCase = true) == true ||
|
||||
fileName?.endsWith(".cpp", ignoreCase = true) == true ||
|
||||
fileName?.endsWith(".c", ignoreCase = true) == true ||
|
||||
fileName?.endsWith(".cs", ignoreCase = true) == true ||
|
||||
fileName?.endsWith(".rb", ignoreCase = true) == true ||
|
||||
fileName?.endsWith(".go", ignoreCase = true) == true -> FileType.HTML
|
||||
|
||||
else -> null
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,6 +22,8 @@ class Fb2Parser(private val context: Context) {
|
|||
originalBookNameHint: String,
|
||||
parseContent: Boolean = true
|
||||
): EpubBook = withContext(Dispatchers.IO) {
|
||||
File(context.cacheDir, "imported_file_$bookId").deleteRecursively()
|
||||
|
||||
val extractionDir = File(context.cacheDir, "imported_file_$bookId").apply {
|
||||
if (!exists()) mkdirs()
|
||||
}
|
||||
|
|
|
|||
430
app/src/main/java/com/aryan/reader/epub/OdtParser.kt
Normal file
430
app/src/main/java/com/aryan/reader/epub/OdtParser.kt
Normal file
|
|
@ -0,0 +1,430 @@
|
|||
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.UUID
|
||||
import java.util.zip.ZipInputStream
|
||||
|
||||
private data class StyleProps(
|
||||
var isBold: Boolean = false,
|
||||
var isItalic: Boolean = false,
|
||||
var isStrikethrough: Boolean = false,
|
||||
var isUnderline: Boolean = false
|
||||
)
|
||||
|
||||
class OdtParser(private val context: Context) {
|
||||
|
||||
suspend fun createOdtBook(
|
||||
inputStream: InputStream,
|
||||
bookId: String,
|
||||
originalBookNameHint: String,
|
||||
isFlat: Boolean,
|
||||
parseContent: Boolean = true
|
||||
): EpubBook = withContext(Dispatchers.IO) {
|
||||
File(context.cacheDir, "imported_file_$bookId").deleteRecursively()
|
||||
val extractionDir = File(context.cacheDir, "imported_file_$bookId").apply {
|
||||
if (!exists()) mkdirs()
|
||||
}
|
||||
|
||||
val mathJaxFileName = "tex-mml-chtml.js"
|
||||
val mathJaxFile = File(extractionDir, mathJaxFileName)
|
||||
if (!mathJaxFile.exists()) {
|
||||
try {
|
||||
context.assets.open("mathjax/$mathJaxFileName").use { input ->
|
||||
FileOutputStream(mathJaxFile).use { output ->
|
||||
input.copyTo(output)
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Timber.e(e, "Failed to copy MathJax local asset")
|
||||
}
|
||||
}
|
||||
|
||||
val parser = Xml.newPullParser()
|
||||
parser.setFeature(XmlPullParser.FEATURE_PROCESS_NAMESPACES, false)
|
||||
|
||||
var title = originalBookNameHint.substringBeforeLast(".")
|
||||
var author = "Unknown"
|
||||
var coverBytes: ByteArray? = null
|
||||
|
||||
val chapters = mutableListOf<EpubChapter>()
|
||||
val images = mutableListOf<EpubImage>()
|
||||
|
||||
var currentChapterHtml = StringBuilder()
|
||||
var chapterCount = 0
|
||||
|
||||
val cssStyle = """
|
||||
body { font-family: sans-serif; line-height: 1.6; padding: 1em; max-width: 800px; margin: 0 auto; }
|
||||
p { margin-bottom: 1em; text-align: justify; }
|
||||
h1, h2, h3, h4, h5, h6 { text-align: center; margin-top: 1.5em; margin-bottom: 1em; }
|
||||
ul, ol { margin-bottom: 1em; padding-left: 2em; }
|
||||
img { max-width: 100%; height: auto; display: block; margin: 1em auto; }
|
||||
table { border-collapse: collapse; width: 100%; margin-bottom: 1em; }
|
||||
th, td { border: 1px solid #ccc; padding: 8px; text-align: left; }
|
||||
.footnote { font-size: 0.85em; background-color: #f9f9f9; padding: 0 4px; border: 1px solid #ddd; border-radius: 3px; }
|
||||
""".trimIndent()
|
||||
|
||||
val mathJaxScript = """
|
||||
<script>
|
||||
MathJax = {
|
||||
tex: {
|
||||
inlineMath: [['\\(', '\\)']],
|
||||
displayMath: [['\\[', '\\]']]
|
||||
}
|
||||
};
|
||||
</script>
|
||||
<script src="$mathJaxFileName"></script>
|
||||
""".trimIndent()
|
||||
|
||||
fun saveChapter() {
|
||||
if (!parseContent || currentChapterHtml.isEmpty()) return
|
||||
chapterCount++
|
||||
val chapterTitle = "Part $chapterCount"
|
||||
val fileName = "chapter_$chapterCount.html"
|
||||
val file = File(extractionDir, fileName)
|
||||
|
||||
val fullHtml = """
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>${chapterTitle}</title>
|
||||
<style>${cssStyle}</style>
|
||||
$mathJaxScript
|
||||
</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 = chapterTitle,
|
||||
htmlFilePath = fileName,
|
||||
plainTextContent = plainText,
|
||||
htmlContent = "",
|
||||
depth = 0,
|
||||
isInToc = true
|
||||
)
|
||||
)
|
||||
currentChapterHtml.clear()
|
||||
}
|
||||
|
||||
val styleMap = mutableMapOf<String, StyleProps>()
|
||||
|
||||
// Helper function to extract styles from styles.xml (or inline FODT)
|
||||
fun extractStyles(inputStream: InputStream) {
|
||||
try {
|
||||
val p = Xml.newPullParser()
|
||||
p.setFeature(XmlPullParser.FEATURE_PROCESS_NAMESPACES, false)
|
||||
p.setInput(inputStream, null)
|
||||
var event = p.eventType
|
||||
var currentStyle: String? = null
|
||||
while (event != XmlPullParser.END_DOCUMENT) {
|
||||
if (event == XmlPullParser.START_TAG) {
|
||||
when (p.name) {
|
||||
"style:style" -> {
|
||||
currentStyle = p.getAttributeValue(null, "style:name")
|
||||
if (currentStyle != null) {
|
||||
styleMap[currentStyle] = StyleProps()
|
||||
}
|
||||
}
|
||||
"style:text-properties" -> {
|
||||
val props = styleMap[currentStyle]
|
||||
if (props != null) {
|
||||
if (p.getAttributeValue(null, "fo:font-weight") == "bold") props.isBold = true
|
||||
if (p.getAttributeValue(null, "fo:font-style") == "italic") props.isItalic = true
|
||||
val strike = p.getAttributeValue(null, "style:text-line-through-style")
|
||||
if (strike != null && strike != "none") props.isStrikethrough = true
|
||||
val under = p.getAttributeValue(null, "style:text-underline-style")
|
||||
if (under != null && under != "none") props.isUnderline = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
event = p.next()
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Timber.e(e, "Failed to parse styles.xml")
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
if (!isFlat) {
|
||||
val zis = ZipInputStream(inputStream)
|
||||
var entry = zis.nextEntry
|
||||
var contentXmlBytes: ByteArray? = null
|
||||
var stylesXmlBytes: ByteArray? = null
|
||||
val ignoredFiles = setOf("meta.xml", "settings.xml", "META-INF/manifest.xml")
|
||||
|
||||
while (entry != null) {
|
||||
if (!entry.isDirectory) {
|
||||
when (entry.name) {
|
||||
"content.xml" -> contentXmlBytes = zis.readBytes()
|
||||
"styles.xml" -> stylesXmlBytes = zis.readBytes()
|
||||
"Thumbnails/thumbnail.png" -> coverBytes = zis.readBytes()
|
||||
else -> {
|
||||
if (entry.name !in ignoredFiles) {
|
||||
val extractedFile = File(extractionDir, entry.name)
|
||||
extractedFile.parentFile?.mkdirs()
|
||||
FileOutputStream(extractedFile).use { out -> zis.copyTo(out) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
entry = zis.nextEntry
|
||||
}
|
||||
|
||||
// Pre-parse styles if available
|
||||
stylesXmlBytes?.let { extractStyles(it.inputStream()) }
|
||||
|
||||
if (contentXmlBytes != null) {
|
||||
parser.setInput(contentXmlBytes.inputStream(), null)
|
||||
} else {
|
||||
throw Exception("content.xml not found in ODT archive.")
|
||||
}
|
||||
} else {
|
||||
parser.setInput(inputStream, null)
|
||||
}
|
||||
|
||||
var eventType = parser.eventType
|
||||
var inOfficeBinaryData = false
|
||||
var currentImageHref: String? = null
|
||||
val base64Builder = java.lang.StringBuilder()
|
||||
|
||||
var currentParsedStyleName: String? = null
|
||||
val spanStack = ArrayDeque<List<String>>()
|
||||
val headerStack = ArrayDeque<String>()
|
||||
var inMath = false
|
||||
|
||||
while (eventType != XmlPullParser.END_DOCUMENT) {
|
||||
when (eventType) {
|
||||
XmlPullParser.START_TAG -> {
|
||||
val name = parser.name
|
||||
if (inMath) {
|
||||
if (name != "math:math") {
|
||||
currentChapterHtml.append("<$name")
|
||||
for (i in 0 until parser.attributeCount) {
|
||||
val attrName = parser.getAttributeName(i)
|
||||
val attrValue = parser.getAttributeValue(i)?.replace("\"", """)
|
||||
currentChapterHtml.append(" $attrName=\"$attrValue\"")
|
||||
}
|
||||
currentChapterHtml.append(">")
|
||||
}
|
||||
} else {
|
||||
when (name) {
|
||||
"dc:title" -> {
|
||||
val t = parser.nextText().trim()
|
||||
if (t.isNotBlank()) title = t
|
||||
}
|
||||
"dc:creator" -> {
|
||||
val a = parser.nextText().trim()
|
||||
if (a.isNotBlank()) author = a
|
||||
}
|
||||
"style:style" -> {
|
||||
currentParsedStyleName = parser.getAttributeValue(null, "style:name")
|
||||
if (currentParsedStyleName != null) {
|
||||
styleMap[currentParsedStyleName] = StyleProps()
|
||||
}
|
||||
}
|
||||
"style:text-properties" -> {
|
||||
val props = styleMap[currentParsedStyleName]
|
||||
if (props != null) {
|
||||
val weight = parser.getAttributeValue(null, "fo:font-weight")
|
||||
if (weight == "bold") props.isBold = true
|
||||
|
||||
val style = parser.getAttributeValue(null, "fo:font-style")
|
||||
if (style == "italic") props.isItalic = true
|
||||
|
||||
val lineThrough = parser.getAttributeValue(null, "style:text-line-through-style")
|
||||
if (lineThrough != null && lineThrough != "none") props.isStrikethrough = true
|
||||
|
||||
val underline = parser.getAttributeValue(null, "style:text-underline-style")
|
||||
if (underline != null && underline != "none") props.isUnderline = true
|
||||
}
|
||||
}
|
||||
"text:h" -> {
|
||||
val levelStr = parser.getAttributeValue(null, "text:outline-level")
|
||||
val level = levelStr?.toIntOrNull() ?: 2
|
||||
val hTag = "h${level.coerceIn(1, 6)}"
|
||||
headerStack.addLast(hTag)
|
||||
currentChapterHtml.append("<$hTag>")
|
||||
}
|
||||
"text:p" -> currentChapterHtml.append("<p>")
|
||||
"text:span" -> {
|
||||
val styleName = parser.getAttributeValue(null, "text:style-name")
|
||||
val props = styleMap[styleName]
|
||||
val openedTags = mutableListOf<String>()
|
||||
if (props != null) {
|
||||
if (props.isBold) { currentChapterHtml.append("<b>"); openedTags.add("b") }
|
||||
if (props.isItalic) { currentChapterHtml.append("<i>"); openedTags.add("i") }
|
||||
if (props.isUnderline) { currentChapterHtml.append("<u>"); openedTags.add("u") }
|
||||
if (props.isStrikethrough) { currentChapterHtml.append("<s>"); openedTags.add("s") }
|
||||
}
|
||||
spanStack.addLast(openedTags)
|
||||
}
|
||||
"text:a" -> {
|
||||
val href = parser.getAttributeValue(null, "xlink:href") ?: ""
|
||||
currentChapterHtml.append("<a href=\"$href\">")
|
||||
}
|
||||
"text:list" -> currentChapterHtml.append("<ul>\n")
|
||||
"text:list-item" -> currentChapterHtml.append("<li>")
|
||||
"table:table" -> currentChapterHtml.append("<table>")
|
||||
"table:table-row" -> currentChapterHtml.append("<tr>")
|
||||
"table:table-cell" -> {
|
||||
val colspan = parser.getAttributeValue(null, "table:number-columns-spanned") ?: "1"
|
||||
val rowspan = parser.getAttributeValue(null, "table:number-rows-spanned") ?: "1"
|
||||
currentChapterHtml.append("<td colspan=\"$colspan\" rowspan=\"$rowspan\">")
|
||||
}
|
||||
"text:line-break" -> currentChapterHtml.append("<br/>")
|
||||
"text:tab" -> currentChapterHtml.append(" ")
|
||||
"text:note" -> currentChapterHtml.append("<span class=\"footnote\">[Note: ")
|
||||
"math:math" -> {
|
||||
inMath = true
|
||||
currentChapterHtml.append("<math xmlns=\"http://www.w3.org/1998/Math/MathML\">")
|
||||
}
|
||||
"draw:image" -> {
|
||||
val href = parser.getAttributeValue(null, "xlink:href") ?: parser.getAttributeValue("http://www.w3.org/1999/xlink", "href")
|
||||
if (href != null) {
|
||||
if (isFlat) {
|
||||
currentImageHref = href.substringAfterLast("/")
|
||||
} else {
|
||||
currentChapterHtml.append("<img src=\"$href\" />")
|
||||
}
|
||||
}
|
||||
}
|
||||
"office:binary-data" -> {
|
||||
if (isFlat) {
|
||||
inOfficeBinaryData = true
|
||||
base64Builder.clear()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
XmlPullParser.TEXT -> {
|
||||
if (inOfficeBinaryData) {
|
||||
base64Builder.append(parser.text)
|
||||
} else {
|
||||
val text = parser.text?.replace("&", "&")?.replace("<", "<")?.replace(">", ">")
|
||||
// isNullOrEmpty correctly preserves single-space characters required for mixing words and inline bold/italic tags
|
||||
if (!text.isNullOrEmpty()) {
|
||||
currentChapterHtml.append(text)
|
||||
}
|
||||
}
|
||||
}
|
||||
XmlPullParser.END_TAG -> {
|
||||
val name = parser.name
|
||||
if (inMath) {
|
||||
if (name == "math:math") {
|
||||
inMath = false
|
||||
currentChapterHtml.append("</math>")
|
||||
} else {
|
||||
currentChapterHtml.append("</$name>")
|
||||
}
|
||||
} else {
|
||||
when (name) {
|
||||
"text:h" -> {
|
||||
val hTag = if (headerStack.isNotEmpty()) headerStack.removeLast() else "h2"
|
||||
currentChapterHtml.append("</$hTag>\n")
|
||||
}
|
||||
"text:p" -> {
|
||||
currentChapterHtml.append("</p>\n")
|
||||
if (currentChapterHtml.length >= 64 * 1024) {
|
||||
saveChapter()
|
||||
}
|
||||
}
|
||||
"text:span" -> {
|
||||
val openedTags = if (spanStack.isNotEmpty()) spanStack.removeLast() else emptyList()
|
||||
for (tag in openedTags.reversed()) {
|
||||
currentChapterHtml.append("</$tag>")
|
||||
}
|
||||
}
|
||||
"text:a" -> currentChapterHtml.append("</a>")
|
||||
"text:list" -> currentChapterHtml.append("</ul>\n")
|
||||
"text:list-item" -> currentChapterHtml.append("</li>\n")
|
||||
"table:table" -> currentChapterHtml.append("</table>\n")
|
||||
"table:table-row" -> currentChapterHtml.append("</tr>\n")
|
||||
"table:table-cell" -> currentChapterHtml.append("</td>\n")
|
||||
"text:note" -> currentChapterHtml.append("]</span>")
|
||||
"office:binary-data" -> {
|
||||
inOfficeBinaryData = false
|
||||
if (isFlat) {
|
||||
try {
|
||||
val bytes = Base64.decode(base64Builder.toString(), Base64.DEFAULT)
|
||||
val imgName = currentImageHref?.substringAfterLast("/") ?: "${UUID.randomUUID()}.png"
|
||||
val imgFile = File(extractionDir, imgName)
|
||||
FileOutputStream(imgFile).use { it.write(bytes) }
|
||||
currentChapterHtml.append("<img src=\"${imgName}\" />")
|
||||
} catch (e: Exception) {
|
||||
Timber.e(e, "Failed to decode FODT image")
|
||||
}
|
||||
currentImageHref = null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
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 ODT file.")
|
||||
}
|
||||
}
|
||||
|
||||
val finalCoverBitmap = coverBytes?.let {
|
||||
try {
|
||||
BitmapFactory.decodeByteArray(it, 0, it.size)
|
||||
} catch (e: Exception) {
|
||||
Timber.e(e, "Failed to decode thumbnail bitmap")
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
return@withContext EpubBook(
|
||||
fileName = originalBookNameHint,
|
||||
title = title,
|
||||
author = author,
|
||||
language = "en",
|
||||
coverImage = finalCoverBitmap,
|
||||
chapters = chapters,
|
||||
chaptersForPagination = chapters,
|
||||
images = images,
|
||||
pageList = emptyList(),
|
||||
extractionBasePath = extractionDir.absolutePath,
|
||||
css = emptyMap()
|
||||
)
|
||||
} finally {
|
||||
try {
|
||||
inputStream.close()
|
||||
} catch (e: Exception) {
|
||||
Timber.e(e, "Error closing ODT stream")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -40,6 +40,7 @@ import timber.log.Timber
|
|||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import java.io.InputStream
|
||||
import java.util.UUID
|
||||
|
||||
class SingleFileImporter(private val context: Context) {
|
||||
|
||||
|
|
@ -52,6 +53,15 @@ class SingleFileImporter(private val context: Context) {
|
|||
bookId: String,
|
||||
parseContent: Boolean = true
|
||||
): EpubBook {
|
||||
|
||||
val lowerHint = originalBookNameHint.lowercase()
|
||||
val isCsv = lowerHint.endsWith(".csv") || lowerHint.endsWith(".tsv")
|
||||
val isCodeOrData = listOf(".json", ".xml", ".log", ".java", ".kt", ".py", ".js", ".cpp", ".c", ".cs", ".rb", ".go").any { lowerHint.endsWith(it) }
|
||||
|
||||
if (type == FileType.HTML && (isCsv || isCodeOrData)) {
|
||||
return parseDynamicContentToHtml(inputStream, originalBookNameHint, bookId, parseContent, isCsv)
|
||||
}
|
||||
|
||||
return when (type) {
|
||||
FileType.MD -> parseMarkdown(inputStream, originalBookNameHint, bookId, parseContent)
|
||||
FileType.TXT -> parsePlainText(inputStream, originalBookNameHint, bookId, parseContent)
|
||||
|
|
@ -61,6 +71,79 @@ class SingleFileImporter(private val context: Context) {
|
|||
}
|
||||
}
|
||||
|
||||
private suspend fun parseDynamicContentToHtml(
|
||||
inputStream: InputStream,
|
||||
originalBookNameHint: String,
|
||||
bookId: String,
|
||||
parseContent: Boolean,
|
||||
isCsv: Boolean
|
||||
): EpubBook = withContext(Dispatchers.IO) {
|
||||
|
||||
val tempFile = File(context.cacheDir, "temp_conv_${UUID.randomUUID()}.html")
|
||||
|
||||
try {
|
||||
FileOutputStream(tempFile).bufferedWriter().use { writer ->
|
||||
writer.write("<!DOCTYPE html>\n<html>\n<head>\n<meta charset=\"UTF-8\">\n<title>${originalBookNameHint}</title>\n")
|
||||
|
||||
if (isCsv) {
|
||||
writer.write("<style>\ntable { border-collapse: collapse; width: 100%; font-family: sans-serif; }\nth, td { border: 1px solid currentColor; padding: 8px; }\n</style>\n")
|
||||
writer.write("</head>\n<body>\n<div style='overflow-x:auto;'>\n<table>\n")
|
||||
} else {
|
||||
writer.write("<style>\npre { padding: 10px; overflow-x: auto; font-family: monospace; white-space: pre-wrap; word-wrap: break-word; }\n</style>\n")
|
||||
writer.write("</head>\n<body>\n<pre><code>\n")
|
||||
}
|
||||
|
||||
val delimiter = if (originalBookNameHint.lowercase().endsWith(".tsv")) '\t' else ','
|
||||
|
||||
inputStream.bufferedReader().use { reader ->
|
||||
var line = reader.readLine()
|
||||
while (line != null) {
|
||||
if (isCsv) {
|
||||
writer.write("<tr>")
|
||||
val current = StringBuilder()
|
||||
var inQuotes = false
|
||||
|
||||
for (char in line) {
|
||||
if (char == '\"') {
|
||||
inQuotes = !inQuotes
|
||||
} else if (char == delimiter && !inQuotes) {
|
||||
val escaped = current.toString().replace("&", "&").replace("<", "<").replace(">", ">")
|
||||
writer.write("<td>$escaped</td>")
|
||||
current.clear()
|
||||
} else {
|
||||
current.append(char)
|
||||
}
|
||||
}
|
||||
val escapedFinal = current.toString().replace("&", "&").replace("<", "<").replace(">", ">")
|
||||
writer.write("<td>$escapedFinal</td></tr>\n")
|
||||
|
||||
} else {
|
||||
// Plain code/log text escaping
|
||||
val escaped = line.replace("&", "&").replace("<", "<").replace(">", ">")
|
||||
writer.write("$escaped\n")
|
||||
}
|
||||
line = reader.readLine()
|
||||
}
|
||||
}
|
||||
|
||||
if (isCsv) {
|
||||
writer.write("</table>\n</div>\n</body>\n</html>")
|
||||
} else {
|
||||
writer.write("</code></pre>\n</body>\n</html>")
|
||||
}
|
||||
}
|
||||
|
||||
tempFile.inputStream().use { tempStream ->
|
||||
return@withContext parseHtml(tempStream, originalBookNameHint, bookId, parseContent)
|
||||
}
|
||||
|
||||
} finally {
|
||||
if (tempFile.exists()) {
|
||||
tempFile.delete()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun parseMarkdown(
|
||||
inputStream: InputStream,
|
||||
originalBookNameHint: String,
|
||||
|
|
@ -82,6 +165,9 @@ class SingleFileImporter(private val context: Context) {
|
|||
css = emptyMap()
|
||||
)
|
||||
}
|
||||
|
||||
File(context.cacheDir, "imported_file_$bookId").deleteRecursively()
|
||||
|
||||
val extractionDir = File(context.cacheDir, "imported_file_$bookId").apply {
|
||||
if (!exists()) mkdirs()
|
||||
}
|
||||
|
|
@ -228,6 +314,9 @@ class SingleFileImporter(private val context: Context) {
|
|||
css = emptyMap()
|
||||
)
|
||||
}
|
||||
|
||||
File(context.cacheDir, "imported_file_$bookId").deleteRecursively()
|
||||
|
||||
val extractionDir = File(context.cacheDir, "imported_file_$bookId").apply {
|
||||
if (!exists()) mkdirs()
|
||||
}
|
||||
|
|
@ -401,6 +490,9 @@ class SingleFileImporter(private val context: Context) {
|
|||
css = emptyMap()
|
||||
)
|
||||
}
|
||||
|
||||
File(context.cacheDir, "imported_file_$bookId").deleteRecursively()
|
||||
|
||||
val extractionDir = File(context.cacheDir, "imported_file_$bookId").apply {
|
||||
if (!exists()) mkdirs()
|
||||
}
|
||||
|
|
@ -573,6 +665,8 @@ class SingleFileImporter(private val context: Context) {
|
|||
)
|
||||
}
|
||||
|
||||
File(context.cacheDir, "imported_file_$bookId").deleteRecursively()
|
||||
|
||||
val extractionDir = File(context.cacheDir, "imported_file_$bookId").apply {
|
||||
if (!exists()) mkdirs()
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue