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, extractionDirOverride: File? = null ): EpubBook = withContext(Dispatchers.IO) { val extractionDir = extractionDirOverride?.let(ImportedFileCache::prepareDirectory) ?: if (parseContent) { ImportedFileCache.prepareActiveBookDir(context, bookId) } else { ImportedFileCache.createTemporaryBookDir(context, bookId, "metadata") } 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 = safeFileInRoot(extractionDir, entry.name) if (extractedFile != null) { extractedFile.parentFile?.mkdirs() FileOutputStream(extractedFile).use { out -> zis.copyTo(out) } } else { Timber.w("Skipping unsafe ODT entry outside extraction root: ${entry.name}") } } } } } 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("