diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index b580b1c..e04d604 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -109,25 +109,6 @@ - - - - - - - - - - - - - - - - - - - @@ -182,50 +163,15 @@ + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + @@ -234,19 +180,43 @@ - - - - + + + + - + + + + + + + + + + + + + + + + + + + + + + + + + { + 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) { diff --git a/app/src/main/java/com/aryan/reader/HomeScreen.kt b/app/src/main/java/com/aryan/reader/HomeScreen.kt index f623e88..408fef1 100644 --- a/app/src/main/java/com/aryan/reader/HomeScreen.kt +++ b/app/src/main/java/com/aryan/reader/HomeScreen.kt @@ -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 diff --git a/app/src/main/java/com/aryan/reader/LibraryScreen.kt b/app/src/main/java/com/aryan/reader/LibraryScreen.kt index d5990cc..05a8e02 100644 --- a/app/src/main/java/com/aryan/reader/LibraryScreen.kt +++ b/app/src/main/java/com/aryan/reader/LibraryScreen.kt @@ -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 diff --git a/app/src/main/java/com/aryan/reader/MainViewModel.kt b/app/src/main/java/com/aryan/reader/MainViewModel.kt index 88f481a..7faf048 100644 --- a/app/src/main/java/com/aryan/reader/MainViewModel.kt +++ b/app/src/main/java/com/aryan/reader/MainViewModel.kt @@ -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 } diff --git a/app/src/main/java/com/aryan/reader/epub/Fb2Parser.kt b/app/src/main/java/com/aryan/reader/epub/Fb2Parser.kt index e2bf647..a465ecd 100644 --- a/app/src/main/java/com/aryan/reader/epub/Fb2Parser.kt +++ b/app/src/main/java/com/aryan/reader/epub/Fb2Parser.kt @@ -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() } diff --git a/app/src/main/java/com/aryan/reader/epub/OdtParser.kt b/app/src/main/java/com/aryan/reader/epub/OdtParser.kt new file mode 100644 index 0000000..55cb3e8 --- /dev/null +++ b/app/src/main/java/com/aryan/reader/epub/OdtParser.kt @@ -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() + val images = mutableListOf() + + 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 = """ + + + """.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 = """ + + + + + ${chapterTitle} + + $mathJaxScript + + + $currentChapterHtml + + + """.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() + + // 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>() + val headerStack = ArrayDeque() + 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("

") + "text:span" -> { + val styleName = parser.getAttributeValue(null, "text:style-name") + val props = styleMap[styleName] + val openedTags = mutableListOf() + if (props != null) { + if (props.isBold) { currentChapterHtml.append(""); openedTags.add("b") } + if (props.isItalic) { currentChapterHtml.append(""); openedTags.add("i") } + if (props.isUnderline) { currentChapterHtml.append(""); openedTags.add("u") } + if (props.isStrikethrough) { currentChapterHtml.append(""); openedTags.add("s") } + } + spanStack.addLast(openedTags) + } + "text:a" -> { + val href = parser.getAttributeValue(null, "xlink:href") ?: "" + currentChapterHtml.append("") + } + "text:list" -> currentChapterHtml.append("