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:
Aryan 2026-04-10 17:35:31 +05:30 committed by GitHub
parent 6a60aec0ef
commit 291504fd90
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 652 additions and 76 deletions

View file

@ -109,25 +109,6 @@
<data android:mimeType="application/octet-stream" />
</intent-filter>
<!-- pathPattern fallback for file:// URIs -->
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="file" />
<data android:host="*" />
<data android:mimeType="*/*" />
<data android:pathPattern=".*\\.azw3" />
<data android:pathPattern=".*\\..*\\.azw3" />
<data android:pathPattern=".*\\..*\\..*\\.azw3" />
<data android:pathPattern=".*\\.fb2" />
<data android:pathPattern=".*\\..*\\.fb2" />
<data android:pathPattern=".*\\..*\\..*\\.fb2" />
<data android:pathPattern=".*\\.mobi" />
<data android:pathPattern=".*\\..*\\.mobi" />
<data android:pathPattern=".*\\..*\\..*\\.mobi" />
</intent-filter>
<!-- Markdown/Text -->
<intent-filter>
<action android:name="android.intent.action.VIEW" />
@ -182,50 +163,15 @@
<data android:mimeType="application/vnd.openxmlformats-officedocument.wordprocessingml.document" />
</intent-filter>
<!-- ODT & FODT -->
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="content" />
<data android:scheme="file" />
<data android:host="*" />
<data android:mimeType="*/*" />
<data android:pathPattern=".*\\.docx" />
<data android:pathPattern=".*\\..*\\.docx" />
<data android:pathPattern=".*\\..*\\..*\\.docx" />
</intent-filter>
<!-- pathPattern fallback for file:// URIs (comic books) -->
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="file" />
<data android:host="*" />
<data android:mimeType="*/*" />
<data android:pathPattern=".*\\.cbz" />
<data android:pathPattern=".*\\..*\\.cbz" />
<data android:pathPattern=".*\\..*\\..*\\.cbz" />
<data android:pathPattern=".*\\.cbr" />
<data android:pathPattern=".*\\..*\\.cbr" />
<data android:pathPattern=".*\\..*\\..*\\.cbr" />
<data android:pathPattern=".*\\.cb7" />
<data android:pathPattern=".*\\..*\\.cb7" />
<data android:pathPattern=".*\\..*\\..*\\.cb7" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="file" />
<data android:host="*" />
<data android:mimeType="*/*" />
<data android:pathPattern=".*\\.md" />
<data android:pathPattern=".*\\..*\\.md" />
<data android:pathPattern=".*\\..*\\..*\\.md" />
<data android:pathPattern=".*\\.markdown" />
<data android:pathPattern=".*\\..*\\.markdown" />
<data android:pathPattern=".*\\..*\\..*\\.markdown" />
<data android:mimeType="application/vnd.oasis.opendocument.text" />
<data android:mimeType="application/x-vnd.oasis.opendocument.text-flat-xml" />
</intent-filter>
<intent-filter>
@ -234,19 +180,43 @@
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="content" />
<data android:scheme="file" />
<data android:host="*" />
<data android:mimeType="*/*" />
<data android:pathPattern=".*\\.md" />
<data android:pathPattern=".*\\.markdown" />
<data android:mimeType="text/csv" />
<data android:mimeType="text/comma-separated-values" />
<data android:mimeType="text/tab-separated-values" />
</intent-filter>
<!-- Structured Data (JSON / XML) -->
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="content" />
<data android:mimeType="application/octet-stream" />
<data android:scheme="file" />
<data android:mimeType="application/json" />
<data android:mimeType="application/xml" />
<data android:mimeType="text/xml" />
</intent-filter>
<!-- Source Code & Logs -->
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="content" />
<data android:scheme="file" />
<data android:mimeType="text/x-java-source" />
<data android:mimeType="text/x-python" />
<data android:mimeType="text/x-kotlin" />
<data android:mimeType="text/javascript" />
<data android:mimeType="application/javascript" />
<data android:mimeType="text/x-c" />
<data android:mimeType="text/x-c++" />
<data android:mimeType="text/x-csharp" />
<data android:mimeType="text/x-ruby" />
<data android:mimeType="text/x-go" />
<data android:mimeType="text/x-log" />
</intent-filter>
</activity>
<service

View file

@ -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) {

View file

@ -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

View file

@ -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

View file

@ -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
}

View file

@ -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()
}

View 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("\"", "&quot;")
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("&nbsp;&nbsp;&nbsp;&nbsp;")
"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("&", "&amp;")?.replace("<", "&lt;")?.replace(">", "&gt;")
// 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")
}
}
}
}

View file

@ -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("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
writer.write("<td>$escaped</td>")
current.clear()
} else {
current.append(char)
}
}
val escapedFinal = current.toString().replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
writer.write("<td>$escapedFinal</td></tr>\n")
} else {
// Plain code/log text escaping
val escaped = line.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
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()
}