book-reader/app/src/main/java/com/aryan/reader/epub/OdtParser.kt
Aryan 625a4d5d2e
Linux support (#381)
* Add desktop release CI and support for Arch Linux packaging

* Make Gradle wrapper executable in desktop-release workflow

* Make Gradle wrapper executable in desktop-release workflow

* Make Gradle wrapper executable in desktop-release workflow

* Configure Gradle and update Java environment in desktop-release workflow

* Update Java setup and AUR packaging in desktop release workflow

* Update Java setup and AUR packaging in desktop release workflow

* Add MSIX packaging support for Windows desktop distribution

* Update AUR packaging metadata and validation

* Use spine toc attribute for NCX resolution

* crash fixes

* Implement automatic discovery and injection of EPUB font face siblings

* Enhance custom font support with family grouping and variable font handling

* Optimize metadata loading and improve TTS highlighting

* Add keyboard navigation support for EPUB reader

* Refine PDF spread page sizing to respect aspect ratios

* Implement responsive maximum height for reader popups and sheets

* Handle TTS generation failures by skipping problematic chunks

* Refactor PDF tile rendering logic and zoom indicator behavior

* Prefer block and offset locators over page index in native vertical flow

* Implement save and share actions for original book files

* Add Estonian language support

* Implement temporary viewing mode for external files

* Implement direct opening for temporary external files without library persistence

* fix failing tests

* Import SharedFileCapabilities in DesktopLibraryUi

* Improve native vertical reader progress, persistence, and image support

* Center target in viewport for native vertical reader and support animated scrolling
2026-06-14 13:43:49 +05:30

446 lines
22 KiB
Kotlin

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 (parseContent && !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) {
var contentXmlBytes: ByteArray? = null
var stylesXmlBytes: ByteArray? = null
val ignoredFiles = setOf("meta.xml", "settings.xml", "META-INF/manifest.xml")
ZipInputStream(inputStream).use { zis ->
var entry = zis.nextEntry
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<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("/")
?.substringAfterLast("\\")
?.takeIf { it.isNotBlank() }
?: "${UUID.randomUUID()}.png"
val imgFile = safeFileInRoot(extractionDir, imgName)
if (imgFile != null) {
FileOutputStream(imgFile).use { it.write(bytes) }
currentChapterHtml.append("<img src=\"${imgName}\" />")
} else {
Timber.w("Skipping unsafe FODT image path: $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")
}
}
}
}