电子书阅读器:解析 EPUB 底层原理与实战

前言

当我们想要开发一个电子书阅读器时,不可避免地会遇到 EPUB 格式。

其实,EPUB 并不复杂,只要掌握了 XML 解析和 HTML 处理,就能够写出一个高性能、轻量级的解析引擎。

我们先从最基础的解析工具开始。

XmlPullParser

EPUB 的核心元数据文件(.opf)和容器描述文件(.xml),本质上都是 XML。Android 官方推荐使用 XmlPullParser 来解析它们。

什么是 Pull 解析?

Pull 解析就是一行行往下读取,这种方式内存占用低。

与之相对的有 DOM 解析,它是一次性将整个文件读入内存,它的优点是快,但对内存极度不友好。

事件循环

XmlPullParser 的工作模式其实就是一个 while 循环,不断处理当前的事件类型

  • START_DOCUMENT: 开始
  • START_TAG: 读到了开始标签
  • TEXT: 读到了标签里的文字
  • END_TAG: 读到了结束标签
  • END_DOCUMENT: 结束

实战

了解完以上内容,就可以开始解析一段 XML 了。

以下面这段 XML 为例:

xml 复制代码
<student>
    <name>张三</name>
    <age>18</age>
</student>

作为 student_data.xml 文件存放在 app/src/main/assets 目录下。

MainActivity 中使用 XmlPullParser 来解析:

kotlin 复制代码
data class Student(var name: String = "", var age: Int = 0)

suspend fun parseStudentXml(inputStream: InputStream): Student? = withContext(Dispatchers.IO) { // 将耗时操作放在 IO 线程中
    // 创建 Pull 解析器
    val parser = Xml.newPullParser()
    var student: Student? = null

    try {
        parser.setInput(inputStream, "UTF-8")
        var eventType = parser.eventType

        var currentName = ""
        var currentAge = 0

        Log.d("XmlDemo", "开始解析 XML 文档...")
        while (eventType != XmlPullParser.END_DOCUMENT) {
            when (eventType) {
                XmlPullParser.START_TAG -> {
                    // 获取标签名
                    when (parser.name) {
                        "student" -> {
                            Log.d("XmlDemo", "遇到标签 student")
                        }
                        // 获取标签内容, 并移到标签末位
                        "name" -> {
                            Log.d("XmlDemo", "遇到标签 name")
                            currentName = parser.nextText()
                        }
                        "age" -> {
                            Log.d("XmlDemo", "遇到标签 age")
                            currentAge = parser.nextText().toIntOrNull() ?: 0
                        }
                    }
                }
            }
            // 获取下一个事件
            eventType = parser.next()
        }

        student = Student(currentName, currentAge)
        Log.d("XmlDemo", "解析成功: $student")

    } catch (e: Exception) {
        Log.e("XmlDemo", "解析失败", e)
    } finally {
        inputStream.close()
    }

    return@withContext student
}


// 调用示例
lifecycleScope.launch {
    try {
        val inputStream = assets.open("student_data.xml")
        val student = parseStudentXml(inputStream)
    } catch (e: IOException) {
        e.printStackTrace()
    }
}

运行结果:

ini 复制代码
开始解析 XML 文档...
遇到标签 student
遇到标签 name
遇到标签 age
解析成功: Student(name=张三, age=18)

nextText() 陷阱

nextText() 会读取当前标签的内容,并移动到标签末尾 END_TAG

  • 如果标签内容是空的,那么它会返回空串 ""。

  • 如果标签还嵌套了标签,那么它可能会抛出异常或导致数据丢失。

例如,我们将上述的 XML 内容改为:

xml 复制代码
<student>
    <name>张<br />三
    </name>
    <age>18</age>
</student>

此时运行程序,会报错:

less 复制代码
解析失败                                                        org.xmlpull.v1.XmlPullParserException: END_TAG expected (position:START_TAG (empty) <br>@2:18 in java.io.InputStreamReader@352bfed) 

意思是说在期望找到一个结束标签时,却遇到了一个开始标签 <br。但我们认为的 <br /> 只是一个换行符,应该被当作文本内容进行处理。

这时,我们需要手动处理 TEXT 事件。

标签之间的换行符也是一个 TEXT 事件。

手动处理 TEXT 事件

手动处理 TEXT 事件时,为了解决 XML 嵌套导致的状态丢失问题,我们需要引入栈来维护状态。我们推荐使用 ArrayDeque 来替换 Stack,性能更好。

逻辑流程:

  • START_TAG:将当前标签名压入状态栈中。

  • TEXT:检查栈顶元素,如果是目标标签,就提取并拼接文本;如果是目标标签的子标签,根据业务逻辑判断是否需要提取。

  • END_TAG:弹出栈顶元素,回到最近一层的父标签的状态。

代码实现:基于栈的状态机

kotlin 复制代码
suspend fun parseStudentXmlManual(inputStream: InputStream): Student? =
    withContext(Dispatchers.IO) {
        val parser = Xml.newPullParser()
        var student: Student? = null

        // 用 ArrayDeque 代替 Stack
        val stateStack = ArrayDeque<String>()

        try {
            parser.setInput(inputStream, "UTF-8")
            var eventType = parser.eventType
            val sbName = StringBuilder()
            var age = 0

            while (eventType != XmlPullParser.END_DOCUMENT) {
                when (eventType) {
                    XmlPullParser.START_TAG -> {
                        val tagName = parser.name
                        // 存入新状态
                        stateStack.addLast(tagName)
                    }

                    XmlPullParser.TEXT -> {
                        // 获取当前文本内容
                        val text = parser.text
                        if (!text.isNullOrBlank()) {
                            // 查看栈顶,决定了当前文本属于谁
                            if (stateStack.isNotEmpty()) {
                                val currentTag = stateStack.last() // 获取当前所处的最近一层标签

                                when (currentTag) {
                                    "name" -> sbName.append(text.trim())
                                    "br" -> { /* 忽略 */
                                    }

                                    "age" -> age = text.trim().toIntOrNull() ?: 0
                                }
                            }
                        }
                    }

                    XmlPullParser.END_TAG -> {
                        // 恢复到上一个状态
                        if (stateStack.isNotEmpty()) {
                            stateStack.removeLast()
                        }
                    }
                }
                eventType = parser.next()
            }

            val name: String = sbName.toString()
            student = Student(name, age)
            Log.d("XmlDemo", "解析成功: $student")

        } catch (e: Exception) {
            Log.e("XmlDemo", "解析失败", e)
        } finally {
            inputStream.close()
        }

        return@withContext student
    }

运行结果:

ini 复制代码
解析成功: Student(name=张三, age=18)

Jsoup

EPUB 的正文内容其实就是网页(XHTML/HTML)。对于解析 HTML,我们不会去使用 XmlPullParser,因为它嵌套很复杂,并且标签常常不闭合。

这时,我们会使用 Jsoup 来解析。

它能将杂乱的 HTML 修正为标准的 DOM 树,并且有着 CSS 选择器:可以像写 CSS 样式一样查找元素。

CSS 选择器

提取数据非常容易:

  • 选取所有一级标题:doc.select("h1")

  • 选取所有带图片路径的 img 标签:doc.select("img[src]")

实战

假设要解析如下 HTML 文件,我们需要提取标题、作者和正文内容,去除掉正文中的广告。

html 复制代码
<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <title>Android 性能优化实战 - 技术周刊</title>
</head>
<body>
    <header>
        <div id="nav">首页 > Android > 性能优化</div>
    </header>

    <div class="container">
        <article class="post">
            <h1 class="post-title">Android 内存优化:从入门到精通</h1>
            
            <div class="meta-info">
                <span>作者:Android专家</span>
                <span>阅读:1024</span>
            </div>

            <hr>

            <div class="post-content">
                <p>
                    在 Android 开发中,<b>内存泄漏</b>是导致应用卡顿的主要原因之一。
                </p>
                
                <div class="ad-banner">
                    <p>广告:点击领取高薪面试题库!</p>
                </div>

                <p>
                    使用 <a href="https://square.github.io/leakcanary/">LeakCanary</a> 工具,可以帮助我们快速定位 <i>Activity</i> 和 <i>Fragment</i> 的泄漏点。
                </p>
                
                <p>只有深入理解 GC 机制,才能写出健壮的代码。</p>
            </div>
        </article>
    </div>

    <footer>
        <p>Copyright © 2022 技术博客</p>
    </footer>
</body>
</html>

首先引入依赖:

kotlin 复制代码
dependencies {
    implementation("org.jsoup:jsoup:1.21.2")
}

代码如下:

kotlin 复制代码
data class Blog(
    val title: String,
    val author: String,
    val content: String,
)

suspend fun parseHtmlContent(
    inputStream: InputStream
) = withContext(Dispatchers.IO) {
    // 解析 HTML 为 Document 对象
    val doc: Document = Jsoup.parse(inputStream, "UTF-8", "")

    // 提取标题
    val title = doc.select("h1.post-title").text()

    // 提取作者
    val author = doc.selectFirst("div.meta-info")?.text()
            ?.substringAfter("作者:")
            ?.substringBefore("阅读:")
            ?.trim()
            ?: "未知"

    val contentDiv = doc.select("div.post-content")

    // 先去除广告节点
    contentDiv.select(".ad-banner").remove()

    // 获取内容
    val content = contentDiv.text()

    return@withContext Blog(title, author, content)
}

// 使用示例
lifecycleScope.launch(Dispatchers.IO) {
    try {
        val inputStream = assets.open("blog.html")
        val blog = parseHtmlContent(inputStream)
        Log.d("EpubTest", "Blog: $blog")
    } catch (e: IOException) {
        e.printStackTrace()
    }
}

运行结果:

ini 复制代码
Blog: Blog(title=Android 内存优化:从入门到精通, author=Android专家, content=在 Android 开发中,内存泄漏是导致应用卡顿的主要原因之一。 使用 LeakCanary 工具,可以帮助我们快速定位 Activity 和 Fragment 的泄漏点。 只有深入理解 GC 机制,才能写出健壮的代码。)

DOM 遍历与节点操作

直接使用 text(),确实很方便,它能自动处理换行。但它也会将 <b>, <a> 等标签剥离,导致加粗、斜体等样式也都丢失了。

这时,我们可以手动遍历 DOM 树来手动处理特定标签,代码如下:

kotlin 复制代码
suspend fun parseHtmlContent(
    inputStream: InputStream
) = withContext(Dispatchers.IO) {
    val doc: Document = Jsoup.parse(inputStream, "UTF-8", "")
    val title = doc.select("h1.post-title").text()

    // 提取作者
    val author = doc.selectFirst("div.meta-info")?.text()
        ?.substringAfter("作者:")
        ?.substringBefore("阅读:")
        ?.trim()
        ?: "未知"

    val sbContent = StringBuilder()

    val contentDiv = doc.select("div.post-content").first()
    // 移除广告节点
    contentDiv?.select(".ad-banner")?.remove()

    contentDiv?.let {
        // 遍历容器的子节点
        for (node in it.childNodes()) {
            traverseNode(node, sbContent)
        }
    }
    val content = sbContent.toString().trim()

    return@withContext Blog(title, author, content)
}

/**
 * 递归遍历节点树
 */
fun traverseNode(
    node: Node,
    sb: StringBuilder
) {
    when (node) {
        // 纯文本节点
        is TextNode -> {
            val text = node.text().trim()
            if (text.isNotBlank()) {
                sb.append(text)
            }
        }
        // 元素节点
        is Element -> {
            when (node.tagName()) {
                "p" -> { // 段落
                    sb.append("\n")
                    node.childNodes().forEach { traverseNode(it, sb) }
                }

                "div", "span" -> { // 容器
                    node.childNodes().forEach { traverseNode(it, sb) }
                }

                "b", "strong" -> {
                    sb.append("[加粗: ")
                    node.childNodes().forEach { traverseNode(it, sb) }
                    sb.append("]")
                }

                "i", "em" -> {
                    sb.append("[斜体: ")
                    node.childNodes().forEach { traverseNode(it, sb) }
                    sb.append("]")
                }

                "a" -> {
                    val href = node.attr("href")
                    sb.append("[链接($href): ")
                    node.childNodes().forEach { traverseNode(it, sb) }
                    sb.append("]")
                }

                else -> {}
            }
        }
    }
}

解析出的内容:

css 复制代码
在 Android 开发中,[加粗: 内存泄漏]是导致应用卡顿的主要原因之一。
使用[链接(https://square.github.io/leakcanary/): LeakCanary]工具,可以帮助我们快速定位[斜体: Activity]和[斜体: Fragment]的泄漏点。
只有深入理解 GC 机制,才能写出健壮的代码。

在实际开发中,我们会使用 SpannableStringBuilder 来拼接结果,比如遇到 <b> 标签时应用 StyleSpan(Typeface.BOLD),这样可以直接在 UI 组件中显示富文本效果。

EPUB 的组成

EPUB 电子书本质上就是一个 ZIP 压缩包,你可以将其后缀改为 .zip 并解压,查看其目录结构:

less 复制代码
eBook/
├── META-INF/
│   └── container.xml      (入口)
├── OEBPS/                 
│   ├── content.opf        (核心)
│   ├── nav.xhtml          (导航)  
│   ├── toc.ncx            (目录)
│   ├── Styles/            (样式)
│   ├── Text/              (正文)
│   └── Images/            (图片)
└── mimetype               (身份标识)

关键文件:META-INF/container.xml

它的作用是告诉我们核心描述文件(.opf)的位置。

xml 复制代码
<?xml version="1.0" encoding="UTF-8"?>
<container version="1.0" xmlns="urn:oasis:names:tc:opendocument:xmlns:container">
    <rootfiles>
        <rootfile full-path="OEBPS/content.opf" media-type="application/oebps-package+xml"/>
   </rootfiles>
</container>

我们只需使用 XmlPullParser 读取 full-path 属性,就能拿到文件路径 "OEBPS/content.opf"。

kotlin 复制代码
suspend fun parseContainer(inputStream: InputStream): String? = withContext(Dispatchers.IO) {
    val xmlPullParser = Xml.newPullParser()

    xmlPullParser.setInput(inputStream, "UTF-8")
    var eventType = xmlPullParser.eventType

    while (eventType != XmlPullParser.END_DOCUMENT) {
        when (eventType) {
            XmlPullParser.START_TAG -> {
                val tagName = xmlPullParser.name
                if (tagName == "rootfile") {
                    val rootFilePath = xmlPullParser.getAttributeValue(null, "full-path")
                    Log.d("ContainerRootFilePath", "rootFilePath: $rootFilePath")
                    return@withContext rootFilePath
                }
            }
        }
        eventType = xmlPullParser.next()
    }
    return@withContext null
}

运行结果:

bash 复制代码
rootFilePath: OEBPS/content.opf

关键文件:opf 文件

OPF 文件也是一个 XML,它存着书籍的重要信息:

  • <metadata>:元数据。书名、作者、标识符、简介、语言、简介、出版社、分类、封面等。
  • <mainfest> :资源清单。书中的所有文件都要在此进行注册,每一项(item)都有着 hrefidmedia-type
  • spine:书脊。它引用了资源清单里面的 id,定义了书的阅读顺序。

OPF 示例

xml 复制代码
<package xmlns="http://www.idpf.org/2007/opf" unique-identifier="BookId" version="3.0">
  <metadata xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:opf="http://www.idpf.org/2007/opf">
    <dc:title id="id">男女之间存在纯友情吗?(不,不存在!) 第一卷 Flag1.不然到三十岁还单身就跟我在一起吧?</dc:title>
    <dc:creator id="id-2">七菜なな</dc:creator>
    <dc:creator id="id-3">Parum</dc:creator>
    <dc:identifier>calibre:19525</dc:identifier>
    <dc:identifier>uuid:77842cae-a0d7-4144-9f4d-9ca1a73b2129</dc:identifier>
    <dc:identifier id="BookId" opf:scheme="UUID">urn:uuid:52746820-f83f-11ee-bf6c-08bfb888ec3a</dc:identifier>
    <dc:source>https://www.wenku8.cc/book/3103.htm</dc:source>
    <dc:language>zh</dc:language>
    <dc:contributor id="id-1">calibre (7.0.0) [https://calibre-ebook.com]</dc:contributor>
    <dc:description>在某一所乡村国中,一对男女向彼此立下永恒友情的誓言。
朝着同一个梦想前进,成为命运共同体的两人之间──
关系并没有任何特别的发展,就这么度过了两年的岁月......!
至今都还没谈过初恋的 High 咖女子──犬冢日葵,
以及热爱花卉的植物男子──夏目悠宇,
就算升上高中二年级,还是一样在只有两人的园艺社中,平和地当着挚友。
然而这时,悠宇跟初恋对象重逢,使得两人之间的关系开始失控?
究竟「已经懂得恋慕之心」的日葵,能不能摆脱「理想挚友」的身份呢?</dc:description>
    <dc:publisher>电击文库</dc:publisher>
    <dc:subject>校园</dc:subject>
    <dc:subject>青春</dc:subject>
    <dc:subject>恋爱</dc:subject>
    <dc:subject>后宫</dc:subject>
    <dc:subject>欢乐向</dc:subject>
    <opf:meta refines="#id" property="title-type">main</opf:meta>
    <opf:meta refines="#id" property="file-as">男女之间存在纯友情吗?(不,不存在!) 第一卷 Flag1.不然到三十岁还单身就跟我在一起吧?</opf:meta>
    <meta name="cover" content="img157909.jpg"/>
    <opf:meta refines="#id-1" property="role" scheme="marc:relators">bkp</opf:meta>
    <opf:meta refines="#id-2" property="role" scheme="marc:relators">aut</opf:meta>
    <opf:meta refines="#id-2" property="file-as">七菜なな</opf:meta>
    <opf:meta refines="#id-3" property="role" scheme="marc:relators">aut</opf:meta>
    <opf:meta refines="#id-3" property="file-as">Parum</opf:meta>
    <opf:meta property="belongs-to-collection" id="id-4">男女之间存在纯友情吗?(不,不存在!)</opf:meta>
    <opf:meta refines="#id-4" property="collection-type">series</opf:meta>
    <opf:meta refines="#id-4" property="group-position">1</opf:meta>
  </metadata>
  <manifest>
    <item href="Text/Cover.xhtml" id="Cover.xhtml" media-type="application/xhtml+xml"/>
    <item href="Text/chapter0.xhtml" id="chapter0.xhtml" media-type="application/xhtml+xml"/>
    <item href="Text/chapter1.xhtml" id="chapter1.xhtml" media-type="application/xhtml+xml"/>
    <item href="Text/Credits.xhtml" id="Credits.xhtml" media-type="application/xhtml+xml"/>
    <item id="nav" href="nav.xhtml" media-type="application/xhtml+xml" properties="nav"/>
    <item href="toc.ncx" id="ncxuks" media-type="application/x-dtbncx+xml"/>
    <item href="Styles/style.css" id="style.css" media-type="text/css"/>
    <item href="Images/157909.jpg" id="img157909.jpg" media-type="image/jpeg"/>
    <item href="Images/157910.jpg" id="img157910.jpg" media-type="image/jpeg"/>
  </manifest>
  <spine toc="ncxuks">
    <itemref idref="Cover.xhtml"/>
    <itemref idref="chapter0.xhtml"/>
    <itemref idref="chapter1.xhtml"/>
    <itemref idref="Credits.xhtml"/>
  </spine>
</package>

定义数据类:

kotlin 复制代码
/**
 * 书籍元数据
 */
data class BookMetadata(
    val title: String = "",
    val authors: List<String> = emptyList(),
    val description: String = "",
    val language: String = "",
    val cover: String = "",
    val publisher: String = "",
    val categories: List<String> = emptyList(),
)

/**
 * 书籍目录顺序
 */
data class BookSpineItem(
    val id: String,
    val href: String,
    val mediaType: String,
)

// 临时存储 manifest 数据
private data class ManifestItem(val href: String, val mediaType: String)

因为元数据封面和书脊都使用了 ID 引用,所以我们可以先解析 Manifest,构建出一个映射表,再来解析 MetadataSpine,这样更加清晰。

kotlin 复制代码
fun parseOpf(inputStream: InputStream): Pair<BookMetadata, List<BookSpineItem>> {
    val parser = Xml.newPullParser()
    parser.setInput(inputStream, "UTF-8")
    // 开启命名空间处理,这样 parser.name 会返回 "title" 而不是 "dc:title",不包含 dc: 前缀
    parser.setFeature(XmlPullParser.FEATURE_PROCESS_NAMESPACES, true)

    var eventType = parser.eventType

    // 映射表:ID -> ManifestItem
    val manifestMap = mutableMapOf<String, ManifestItem>()

    // 书籍元数据
    var title = ""
    val authors = mutableListOf<String>()
    var description = ""
    var language = ""
    var publisher = ""
    val categories = mutableListOf<String>()
    var coverId = "" // 封面 ID

    // 脊骨列表:只存 ID 引用,稍后解析
    val spineIdRefs = mutableListOf<String>()

    while (eventType != XmlPullParser.END_DOCUMENT) {
        when (eventType) {
            XmlPullParser.START_TAG -> {
                val tagName = parser.name

                when (tagName) {
                    // 处理元数据 (Metadata)
                    "title" -> title = safeNextText(parser)
                    "creator" -> authors.add(safeNextText(parser))
                    "description" -> description = safeNextText(parser)
                    "language" -> language = safeNextText(parser)
                    "publisher" -> publisher = safeNextText(parser)
                    "subject" -> categories.add(safeNextText(parser))

                    "meta" -> {
                        // 处理封面:<meta name="cover" content="img157909.jpg"/>
                        val nameAttr = parser.getAttributeValue(null, "name")
                        if (nameAttr == "cover") {
                            coverId = parser.getAttributeValue(null, "content")
                        }
                    }

                    // 处理清单 (Manifest)
                    "item" -> {
                        // <item id="..." href="..." media-type="..."/>
                        val id = parser.getAttributeValue(null, "id")
                        val href = parser.getAttributeValue(null, "href")
                        val mediaType = parser.getAttributeValue(null, "media-type")

                        if (id != null && href != null && mediaType != null) {
                            manifestMap[id] = ManifestItem(href, mediaType)
                        }
                    }

                    // --- 3. 处理脊骨 (Spine) ---
                    "itemref" -> {
                        // <itemref idref="..."/>
                        val idref = parser.getAttributeValue(null, "idref")
                        if (idref != null) {
                            spineIdRefs.add(idref)
                        }
                    }
                }
            }
        }
        eventType = parser.next()
    }


    // 解析封面路径:利用 coverId 从 manifestMap 查找真实路径
    val finalCoverPath = coverId.let { id ->
        manifestMap[id]?.href
    } ?: ""

    val metadata = BookMetadata(
        title = title,
        authors = authors,
        description = description,
        language = language,
        cover = finalCoverPath, // 真实路径
        publisher = publisher,
        categories = categories
    )

    val bookSpineItems = spineIdRefs.mapNotNull { idref ->
        val item = manifestMap[idref]
        if (item != null) {
            BookSpineItem(
                id = idref,
                href = item.href,
                mediaType = item.mediaType
            )
        } else {
            Log.w("OpfParser", "Spine item '$idref' not found in manifest.")
            null
        }
    }

    return Pair(metadata, bookSpineItems)
}

// 防止空标签导致异常
private fun safeNextText(parser: XmlPullParser): String {
    var result = ""
    if (parser.next() == XmlPullParser.TEXT) {
        result = parser.text
        parser.nextTag() // 移至 END_TAG
    }
    return result
}

测试:

kotlin 复制代码
lifecycleScope.launch(Dispatchers.IO) {
    try {
        val inputStream = assets.open("OEBPS/content.opf")

        val (metadata, spine) = parseOpf(inputStream)

        println("=== 书籍信息 ===")
        println("标题: ${metadata.title}")
        println("作者: ${metadata.authors}")
        println("封面路径: ${metadata.cover}")
        println("简介: ${metadata.description.take(100)}...")

        println("\n=== 章节列表 (Spine) ===")
        spine.forEach { item ->
            println("ID: ${item.id.padEnd(15)} | Type: ${item.mediaType.padEnd(25)} | Path: ${item.href}")
        }
    } catch (e: IOException) {
        e.printStackTrace()
    }
}

运行结果:

yaml 复制代码
=== 书籍信息 ===
标题: 男女之间存在纯友情吗?(不,不存在!) 第一卷 Flag1.不然到三十岁还单身就跟我在一起吧?
作者: [七菜なな, Parum]
封面路径: Images/157909.jpg
简介: 在某一所乡村国中,一对男女向彼此立下永恒友情的誓言。
朝着同一个梦想前进,成为命运共同体的两人之间──
关系并没有任何特别的发展,就这么度过了两年的岁月......!
至今都还没谈过初恋的 High 咖女子─...
=== 章节列表 (Spine) ===
ID: Cover.xhtml     | Type: application/xhtml+xml     | Path: Text/Cover.xhtml
ID: chapter0.xhtml  | Type: application/xhtml+xml     | Path: Text/chapter0.xhtml
ID: chapter1.xhtml  | Type: application/xhtml+xml     | Path: Text/chapter1.xhtml
ID: chapter2.xhtml  | Type: application/xhtml+xml     | Path: Text/chapter2.xhtml
ID: chapter3.xhtml  | Type: application/xhtml+xml     | Path: Text/chapter3.xhtml
ID: chapter4.xhtml  | Type: application/xhtml+xml     | Path: Text/chapter4.xhtml
ID: chapter5.xhtml  | Type: application/xhtml+xml     | Path: Text/chapter5.xhtml
ID: chapter6.xhtml  | Type: application/xhtml+xml     | Path: Text/chapter6.xhtml
ID: chapter7.xhtml  | Type: application/xhtml+xml     | Path: Text/chapter7.xhtml
ID: Credits.xhtml   | Type: application/xhtml+xml     | Path: Text/Credits.xhtml

总结

关于解析的内容就到这里,我们来总结一下 EPUB 阅读器的解析流程:

  1. 获取 EPUB 电子书的 Uri。
  2. 使用 ZipInputStream 读取这个压缩文件(节省空间)。
  3. 在 Zip 流中找到 META-INF/container.xml 文件,解析出 .opf 文件的相对路径。
  4. 从 OPF 文件中解析出书籍元数据,以及阅读顺序(书籍目录)。
  5. 最后,在渲染内容时,根据路径找到内容文件,使用 Jsoup 解析 HTML 即可。
相关推荐
g***B7389 分钟前
Kotlin协程在Android中的使用
android·开发语言·kotlin
A***279521 分钟前
Kotlin反射机制
android·开发语言·kotlin
2501_9160074723 分钟前
iOS 应用性能测试的工程化流程,构建从指标采集到问题归因的多工具协同测试体系
android·ios·小程序·https·uni-app·iphone·webview
源码_V_saaskw24 分钟前
JAVA国际版同城跑腿源码快递代取帮买帮送同城服务源码支持Android+IOS+H5
android·java·ios·微信小程序
q***d1731 小时前
Kotlin在后台服务中的框架
android·开发语言·kotlin
我要添砖java1 小时前
<JAVAEE> 多线程4-wait和notify方法
android·java·java-ee
Mr_万能胶1 小时前
到底原研药,来瞧瞧 Google 官方《Android API 设计指南》
android·架构·android studio
BINGCHN2 小时前
NSSCTF每日一练 SWPUCTF2021 include--web
android·前端·android studio
fundroid2 小时前
Androidify:谷歌官方 AI + Android 开源示例应用
android·人工智能·开源