Horis 2 лет назад
Родитель
Сommit
13b26b7b20
39 измененных файлов с 1972 добавлено и 15 удалено
  1. 1 1
      app/src/main/java/io/legado/app/constant/AppPattern.kt
  2. 5 0
      app/src/main/java/io/legado/app/help/book/BookExtensions.kt
  3. 140 0
      app/src/main/java/io/legado/app/lib/mobi/KF6Book.kt
  4. 273 0
      app/src/main/java/io/legado/app/lib/mobi/KF8Book.kt
  5. 446 0
      app/src/main/java/io/legado/app/lib/mobi/MobiBook.kt
  6. 232 0
      app/src/main/java/io/legado/app/lib/mobi/MobiReader.kt
  7. 49 0
      app/src/main/java/io/legado/app/lib/mobi/PDBFile.kt
  8. 6 0
      app/src/main/java/io/legado/app/lib/mobi/decompress/CDICData.kt
  9. 7 0
      app/src/main/java/io/legado/app/lib/mobi/decompress/Decompressor.kt
  10. 133 0
      app/src/main/java/io/legado/app/lib/mobi/decompress/HuffcdicDecompressor.kt
  11. 49 0
      app/src/main/java/io/legado/app/lib/mobi/decompress/Lz77Decompressor.kt
  12. 9 0
      app/src/main/java/io/legado/app/lib/mobi/decompress/PlainDecompressor.kt
  13. 7 0
      app/src/main/java/io/legado/app/lib/mobi/entities/ExthRecordType.kt
  14. 6 0
      app/src/main/java/io/legado/app/lib/mobi/entities/FdstHeader.kt
  15. 9 0
      app/src/main/java/io/legado/app/lib/mobi/entities/Fragment.kt
  16. 8 0
      app/src/main/java/io/legado/app/lib/mobi/entities/IndexData.kt
  17. 9 0
      app/src/main/java/io/legado/app/lib/mobi/entities/IndexEntry.kt
  18. 6 0
      app/src/main/java/io/legado/app/lib/mobi/entities/IndexTag.kt
  19. 16 0
      app/src/main/java/io/legado/app/lib/mobi/entities/IndxHeader.kt
  20. 10 0
      app/src/main/java/io/legado/app/lib/mobi/entities/KF6Section.kt
  21. 9 0
      app/src/main/java/io/legado/app/lib/mobi/entities/KF8Header.kt
  22. 6 0
      app/src/main/java/io/legado/app/lib/mobi/entities/KF8Pos.kt
  23. 7 0
      app/src/main/java/io/legado/app/lib/mobi/entities/KF8Resource.kt
  24. 14 0
      app/src/main/java/io/legado/app/lib/mobi/entities/KF8Section.kt
  25. 8 0
      app/src/main/java/io/legado/app/lib/mobi/entities/MobiEntryHeaders.kt
  26. 22 0
      app/src/main/java/io/legado/app/lib/mobi/entities/MobiHeader.kt
  27. 13 0
      app/src/main/java/io/legado/app/lib/mobi/entities/MobiMetadata.kt
  28. 14 0
      app/src/main/java/io/legado/app/lib/mobi/entities/NCX.kt
  29. 8 0
      app/src/main/java/io/legado/app/lib/mobi/entities/PalmDocHeader.kt
  30. 8 0
      app/src/main/java/io/legado/app/lib/mobi/entities/Ptagx.kt
  31. 9 0
      app/src/main/java/io/legado/app/lib/mobi/entities/Skeleton.kt
  32. 7 0
      app/src/main/java/io/legado/app/lib/mobi/entities/TOC.kt
  33. 7 0
      app/src/main/java/io/legado/app/lib/mobi/entities/TagxHeader.kt
  34. 8 0
      app/src/main/java/io/legado/app/lib/mobi/entities/TagxTag.kt
  35. 73 0
      app/src/main/java/io/legado/app/lib/mobi/utils/ByteBufferExtensions.kt
  36. 14 14
      app/src/main/java/io/legado/app/model/ImageProvider.kt
  37. 9 0
      app/src/main/java/io/legado/app/model/localBook/LocalBook.kt
  38. 310 0
      app/src/main/java/io/legado/app/model/localBook/MobiFile.kt
  39. 5 0
      app/src/main/java/io/legado/app/ui/book/read/ReadBookActivity.kt

+ 1 - 1
app/src/main/java/io/legado/app/constant/AppPattern.kt

@@ -24,7 +24,7 @@ object AppPattern {
     val debugMessageSymbolRegex = Regex("[⇒◇┌└≡]")
 
     //本地书籍支持类型
-    val bookFileRegex = Regex(".*\\.(txt|epub|umd|pdf)", RegexOption.IGNORE_CASE)
+    val bookFileRegex = Regex(".*\\.(txt|epub|umd|pdf|mobi|azw3|azw)", RegexOption.IGNORE_CASE)
     //压缩文件支持类型
     val archiveFileRegex = Regex(".*\\.(zip|rar|7z)$", RegexOption.IGNORE_CASE)
 

+ 5 - 0
app/src/main/java/io/legado/app/help/book/BookExtensions.kt

@@ -57,6 +57,11 @@ val Book.isUmd: Boolean
 val Book.isPdf: Boolean
     get() = isLocal && originName.endsWith(".pdf", true)
 
+val Book.isMobi: Boolean
+    get() = isLocal && (originName.endsWith(".mobi", true) ||
+            originName.endsWith(".azw3", true) ||
+            originName.endsWith(".azw", true))
+
 val Book.isOnLineTxt: Boolean
     get() = !isLocal && isType(BookType.text)
 

+ 140 - 0
app/src/main/java/io/legado/app/lib/mobi/KF6Book.kt

@@ -0,0 +1,140 @@
+package io.legado.app.lib.mobi
+
+import io.legado.app.lib.mobi.entities.KF6Section
+import io.legado.app.lib.mobi.entities.MobiEntryHeaders
+import io.legado.app.lib.mobi.entities.NCX
+import io.legado.app.lib.mobi.entities.TOC
+import java.nio.CharBuffer
+
+
+/**
+ * Kindle Format 6 Book
+ */
+class KF6Book(
+    pdbFile: PDBFile,
+    headers: MobiEntryHeaders,
+    kf8BoundaryOffset: Int,
+    resourceStart: Int
+) : MobiBook(pdbFile, headers, kf8BoundaryOffset, resourceStart) {
+
+    lateinit var sections: List<KF6Section>
+    lateinit var sectionIdMap: LinkedHashMap<Int, ArrayList<TOC>>
+
+    init {
+        processSections()
+        processNCX()
+        processSectionsMap()
+    }
+
+    fun getResourceByHref(href: String): ByteArray? {
+        val recindex = href.substringAfter("recindex:").toIntOrNull() ?: return null
+        return getResource(recindex - 1).array()
+    }
+
+    fun getSectionText(section: KF6Section): String {
+        val inputStream = getTextRecordInputStream()
+        val byteArray = ByteArray(section.length)
+        inputStream.skip(section.start.toLong())
+        inputStream.read(byteArray)
+        return String(byteArray, charset)
+    }
+
+    fun getSectionByHref(href: String): KF6Section? {
+        val index = getIndexByHref(href)
+        return sections.getOrNull(index)
+    }
+
+    private fun processSectionsMap() {
+        sectionIdMap = linkedMapOf()
+        if (toc == null) {
+            return
+        }
+        fun fmap(item: TOC) {
+            val index = getIndexByHref(item.href)
+            if (index == -1) return
+            val array = sectionIdMap.getOrPut(index) { arrayListOf() }
+            array.add(item)
+            item.subitems?.forEach(::fmap)
+        }
+        toc!!.forEach(::fmap)
+    }
+
+    private fun getIndexByHref(href: String): Int {
+        val filepos = href.substringAfter("filepos:").toIntOrNull() ?: return -1
+        return sections.indexOfFirst { it.end > filepos }
+    }
+
+    private fun processNCX() {
+        val ncx = getNCX() ?: return
+        fun fmap(item: NCX): TOC {
+            val filepos = item.offset!!
+            val href = "filepos:${filepos.toString().padStart(10, '0')}"
+            return TOC(item.label, href, item.children?.map(::fmap))
+        }
+        toc = ncx.map(::fmap)
+    }
+
+    private fun processSections() {
+        val sections = arrayListOf<KF6Section>()
+        val pattern = mbpPagebreakRegex.toPattern()
+        val inputStream = getTextRecordInputStream()
+        val available = inputStream.available()
+        val reader = inputStream.reader(Charsets.ISO_8859_1)
+        var buffer = CharBuffer.allocate(4096)
+        reader.read(buffer)
+        buffer.flip()
+        val matcher = pattern.matcher(buffer)
+        var droppedOffset = 0
+        var nextStart = 0
+        var position = 0
+        while (true) {
+            if (!matcher.find()) {
+                buffer.position(position)
+                if (buffer.limit() == buffer.capacity()) {
+                    if (position > 0) {
+                        buffer.compact()
+                    } else {
+                        val newBuf = CharBuffer.allocate(buffer.capacity() * 2)
+                        newBuf.put(buffer)
+                        buffer = newBuf
+                    }
+                    droppedOffset += position
+                    position = 0
+                }
+                if (reader.read(buffer) == -1) {
+                    break
+                }
+                buffer.flip()
+                matcher.reset(buffer)
+            } else {
+                val last = sections.lastOrNull()
+                val index = sections.size
+                val start = nextStart
+                val end = matcher.start() + droppedOffset
+                nextStart = matcher.end() + droppedOffset
+                position = matcher.end()
+                val length = end - start
+                val href = "filepos:${start.toString().padStart(10, '0')}"
+                val section = KF6Section(index, start, end, length, href)
+                last?.next = section
+                sections.add(section)
+            }
+        }
+        if (nextStart > 0) {
+            val last = sections.lastOrNull()
+            val index = sections.size
+            val start = nextStart
+            val length = available - start
+            val href = "filepos:${start.toString().padStart(10, '0')}"
+            val section = KF6Section(index, start, available, length, href)
+            last?.next = section
+            sections.add(section)
+        }
+        this.sections = sections
+    }
+
+    companion object {
+        val mbpPagebreakRegex = "(?i)<\\s*(?:mbp:)?pagebreak[^>]*>".toRegex()
+    }
+
+}

+ 273 - 0
app/src/main/java/io/legado/app/lib/mobi/KF8Book.kt

@@ -0,0 +1,273 @@
+package io.legado.app.lib.mobi
+
+import io.legado.app.lib.mobi.entities.FdstHeader
+import io.legado.app.lib.mobi.entities.Fragment
+import io.legado.app.lib.mobi.entities.KF8Pos
+import io.legado.app.lib.mobi.entities.KF8Resource
+import io.legado.app.lib.mobi.entities.KF8Section
+import io.legado.app.lib.mobi.entities.MobiEntryHeaders
+import io.legado.app.lib.mobi.entities.NCX
+import io.legado.app.lib.mobi.entities.Skeleton
+import io.legado.app.lib.mobi.entities.TOC
+import io.legado.app.lib.mobi.utils.readString
+import io.legado.app.lib.mobi.utils.readUInt32
+import java.nio.ByteBuffer
+import java.util.Locale
+
+/**
+ * Kindle Format 8 Book
+ */
+@Suppress("SpellCheckingInspection")
+class KF8Book(
+    pdbFile: PDBFile,
+    headers: MobiEntryHeaders,
+    kf8BoundaryOffset: Int,
+    resourceStart: Int
+) : MobiBook(pdbFile, headers, kf8BoundaryOffset, resourceStart) {
+
+    private var fdstTableStarts: IntArray? = null
+    private var fdstTableEnds: IntArray? = null
+    private lateinit var skelTable: List<Skeleton>
+    private lateinit var fragTable: List<Fragment>
+    private var kf8 = headers.kf8!!
+    lateinit var sections: List<KF8Section>
+    lateinit var sectionIdMap: LinkedHashMap<Int, ArrayList<TOC>>
+
+    init {
+        readFdstTable()
+        readSkelTable()
+        readFragTable()
+        processSections()
+        processNCX()
+        processSectionsMap()
+    }
+
+    /**
+     * 建立 section id -> toc 的 map
+     */
+    private fun processSectionsMap() {
+        sectionIdMap = linkedMapOf()
+        if (toc == null) {
+            return
+        }
+        fun fmap(item: TOC) {
+            val index = getIndexByHref(item.href)
+            if (index == -1) return
+            val array = sectionIdMap.getOrPut(index) { arrayListOf() }
+            array.add(item)
+            item.subitems?.forEach(::fmap)
+        }
+        toc!!.forEach(::fmap)
+    }
+
+    fun parsePosURI(href: String): KF8Pos? {
+        val match = kindlePosRegex.find(href) ?: return null
+        val fid = match.groupValues[1].toInt(32)
+        val off = match.groupValues[2].toInt(32)
+        return KF8Pos(fid, off)
+    }
+
+    private fun parseResourceURI(href: String): KF8Resource? {
+        val match = kindleResourceRegex.find(href) ?: return null
+        val resourceType = match.groupValues[1]
+        val id = match.groupValues[2].toInt(32)
+        val type = match.groupValues[3]
+        return KF8Resource(resourceType, id, type)
+    }
+
+    fun getResourceByHref(href: String): ByteArray? {
+        val resource = parseResourceURI(href) ?: return null
+        if (resource.resourceType == "flow") return null
+        return getResource(resource.id - 1).array()
+    }
+
+    fun getSectionByHref(href: String): KF8Section? {
+        val index = getIndexByHref(href)
+        return sections.getOrNull(index)
+    }
+
+    private fun getIndexByHref(href: String): Int {
+        val pos = parsePosURI(href) ?: return -1
+        return getIndexByFID(pos.fid)
+    }
+
+    private fun getIndexByFID(fid: Int): Int {
+        return sections.indexOfFirst {
+            it.frags.any { frag ->
+                frag.index == fid
+            }
+        }
+    }
+
+    fun getTextByHref(href: String, nextHref: String): String {
+        val pos = parsePosURI(href) ?: return ""
+        val nextPos = parsePosURI(nextHref) ?: return ""
+        val index = getIndexByFID(pos.fid)
+        val nextIndex = getIndexByFID(nextPos.fid)
+        val startFid = pos.fid
+        val endFid = if (index == nextIndex) nextPos.fid else Int.MAX_VALUE
+        val section = sections[index]
+        val skel = section.skeleton
+        val droppedFrags = section.frags.filter { it.index < startFid }
+        var droppedFragsLength = droppedFrags.sumOf { it.length }
+        val frags = section.frags.filter { it.index in startFid..endFid }
+        val length = skel.length + frags.sumOf { it.length }
+        val raw = getRaw(skel.offset, section.length)
+        val lastFragDroppedLength =
+            if (index == nextIndex) frags.last().length - nextPos.offset else 0
+        val skeleton = ByteArray(length - pos.offset - lastFragDroppedLength)
+        var leftBytes = skeleton.size
+        raw.copyInto(skeleton, 0, 0, skel.length)
+        leftBytes -= skel.length
+        for ((i, frag) in frags.withIndex()) {
+            val isFirstFrag = i == 0
+            val isLastFrag = i == frags.lastIndex
+            val insertOffset = frag.insertOffset - skel.offset - droppedFragsLength
+            val offset = skel.length + frag.offset
+            skeleton.copyInto(
+                skeleton,
+                insertOffset + frag.length -
+                        (if (isFirstFrag) pos.offset else 0) -
+                        (if (isLastFrag) lastFragDroppedLength else 0),
+                insertOffset,
+                skeleton.size - leftBytes
+            )
+            raw.copyInto(
+                skeleton,
+                insertOffset,
+                offset + if (isFirstFrag) pos.offset else 0,
+                offset + frag.length - if (isLastFrag) lastFragDroppedLength else 0
+            )
+            leftBytes -= frag.length -
+                    (if (isFirstFrag) pos.offset else 0) -
+                    (if (isLastFrag && index == nextIndex) nextPos.offset else 0)
+            if (isFirstFrag) {
+                droppedFragsLength += pos.offset
+            }
+        }
+        return String(skeleton, charset)
+    }
+
+    fun getSectionText(section: KF8Section): String {
+        val skel = section.skeleton
+        val frags = section.frags
+        val length = section.length
+        //val raw = getRaw(skel.offset, skel.offset + length)
+        val raw = getRaw(skel.offset, length)
+        val skeleton = ByteArray(raw.size)
+        var leftBytes = raw.size
+        raw.copyInto(skeleton, 0, 0, skel.length)
+        leftBytes -= skel.length
+        for (frag in frags) {
+            val insertOffset = frag.insertOffset - skel.offset
+            val offset = skel.length + frag.offset
+            skeleton.copyInto(
+                skeleton,
+                insertOffset + frag.length,
+                insertOffset,
+                skeleton.size - leftBytes
+            )
+            raw.copyInto(skeleton, insertOffset, offset, offset + frag.length)
+            leftBytes -= frag.length
+        }
+        return String(skeleton, charset)
+    }
+
+    private fun getRaw(offset: Int, len: Int): ByteArray {
+        val inputStream = getTextRecordInputStream()
+        val byteArray = ByteArray(len)
+        inputStream.skip(offset.toLong())
+        inputStream.read(byteArray)
+        return byteArray
+    }
+
+    private fun processNCX() {
+        val ncx = getNCX() ?: return
+        fun fmap(item: NCX): TOC {
+            val (fid, off) = item.pos!!
+            val href = makePosURI(fid, off)
+            return TOC(item.label, href, item.children?.map(::fmap))
+        }
+        toc = ncx.map(::fmap)
+    }
+
+    private fun makePosURI(fid: Int, off: Int): String {
+        val encodedFid = fid.toString(32).uppercase(Locale.ROOT).padStart(4, '0')
+        val encodedOff = off.toString(32).uppercase(Locale.ROOT).padStart(10, '0')
+        return "kindle:pos:fid:$encodedFid:off:$encodedOff"
+    }
+
+    private fun processSections() {
+        sections = skelTable.fold(arrayListOf()) { arr, skel ->
+            val last = arr.lastOrNull()
+            val index = arr.size
+            val fragStart = last?.fragEnd ?: 0
+            val fragEnd = fragStart + skel.numFrag
+            val frags = fragTable.slice(fragStart..<fragEnd)
+            val length = skel.length + frags.sumOf { it.length }
+            val totalLength = (last?.totalLength ?: 0) + length
+            val href = frags.firstOrNull()?.let { makePosURI(it.index, 0) } ?: ""
+            val section = KF8Section(index, skel, frags, fragEnd, length, totalLength, href)
+            last?.next = section
+            arr.add(section)
+            arr
+        }
+    }
+
+    private fun readFragTable() {
+        val fragData = getIndexData(kf8.frag)
+        fragTable = fragData.table.map { indexEntry ->
+            val tagMap = indexEntry.tagMap
+            Fragment(
+                indexEntry.label.toInt(),
+                fragData.cncx[tagMap[2].tagValues[0]],
+                tagMap[4].tagValues[0],
+                tagMap[6].tagValues[0],
+                tagMap[6].tagValues[1]
+            )
+        }
+    }
+
+    private fun readSkelTable() {
+        skelTable = getIndexData(kf8.skel).table.mapIndexed { index, indexEntry ->
+            val tagMap = indexEntry.tagMap
+            Skeleton(
+                index,
+                indexEntry.label,
+                tagMap[1].tagValues[0],
+                tagMap[6].tagValues[0],
+                tagMap[6].tagValues[1],
+            )
+        }
+    }
+
+    private fun readFdstTable() {
+        try {
+            val fdstBuffer = getRecord(kf8.fdst)
+            val fdstHeader = readFdstHeader(fdstBuffer)
+            fdstTableStarts = IntArray(fdstHeader.numEntries)
+            fdstTableEnds = IntArray(fdstHeader.numEntries)
+            fdstBuffer.position(12)
+            for (i in 0..<fdstHeader.numEntries) {
+                fdstTableStarts!![i] = fdstBuffer.readUInt32()
+                fdstTableEnds!![i] = fdstBuffer.readUInt32()
+            }
+        } catch (e: Exception) {
+            e.printStackTrace()
+        }
+    }
+
+    private fun readFdstHeader(buffer: ByteBuffer): FdstHeader {
+        val magic = buffer.readString(0, 4)
+        if (magic != "FDST") error("Missing FDST record")
+        val numEntries = buffer.readUInt32(8)
+        return FdstHeader(magic, numEntries)
+    }
+
+    companion object {
+        val kindlePosRegex = "kindle:pos:fid:(\\w+):off:(\\w+)".toRegex()
+        val kindleResourceRegex =
+            "kindle:(flow|embed):(\\w+)(?:\\?mime=(\\w+/[-+.\\w]+))?".toRegex()
+    }
+
+}

+ 446 - 0
app/src/main/java/io/legado/app/lib/mobi/MobiBook.kt

@@ -0,0 +1,446 @@
+package io.legado.app.lib.mobi
+
+import android.util.SparseArray
+import io.legado.app.lib.mobi.decompress.Decompressor
+import io.legado.app.lib.mobi.decompress.HuffcdicDecompressor
+import io.legado.app.lib.mobi.decompress.Lz77Decompressor
+import io.legado.app.lib.mobi.decompress.PlainDecompressor
+import io.legado.app.lib.mobi.entities.IndexData
+import io.legado.app.lib.mobi.entities.IndexEntry
+import io.legado.app.lib.mobi.entities.IndexTag
+import io.legado.app.lib.mobi.entities.IndxHeader
+import io.legado.app.lib.mobi.entities.MobiEntryHeaders
+import io.legado.app.lib.mobi.entities.MobiMetadata
+import io.legado.app.lib.mobi.entities.NCX
+import io.legado.app.lib.mobi.entities.Ptagx
+import io.legado.app.lib.mobi.entities.TOC
+import io.legado.app.lib.mobi.entities.TagxHeader
+import io.legado.app.lib.mobi.entities.TagxTag
+import io.legado.app.lib.mobi.utils.readString
+import io.legado.app.lib.mobi.utils.readUInt16Array
+import io.legado.app.lib.mobi.utils.readUInt32
+import io.legado.app.lib.mobi.utils.readUInt8
+import okhttp3.internal.and
+import java.io.ByteArrayInputStream
+import java.io.InputStream
+import java.nio.ByteBuffer
+import java.nio.charset.Charset
+import kotlin.math.abs
+import kotlin.math.max
+import kotlin.math.min
+
+@Suppress("SpellCheckingInspection")
+abstract class MobiBook(
+    private val pdbFile: PDBFile,
+    val headers: MobiEntryHeaders,
+    private val kf8BoundaryOffset: Int,
+    private val resourceStart: Int,
+) {
+    val mobi = headers.mobi
+    val palmdoc = headers.palmdoc
+    private val exth = headers.exth
+    private val trailingFlags = mobi.trailingFlags
+
+    val charset: Charset = when (val charset = mobi.encoding) {
+        65001 -> Charsets.UTF_8
+        1252 -> Charset.forName("windows-1252")
+        else -> error("unknown charset $charset")
+    }
+
+    private val decompressor: Decompressor = when (val compression = palmdoc.compression) {
+        1 -> PlainDecompressor()
+        2 -> Lz77Decompressor(max(4096, palmdoc.recordSize))
+        17480 -> HuffcdicDecompressor(this, mobi)
+        else -> error("unknown compression $charset")
+    }
+
+    @Suppress("UNCHECKED_CAST")
+    val metadata: MobiMetadata by lazy {
+        MobiMetadata(
+            mobi.uid.toString(),
+            exth["title"] as? String ?: mobi.title,
+            exth["creator"] as? List<String> ?: emptyList(),
+            exth["publisher"] as? String ?: "",
+            exth["language"] as? String ?: mobi.languege,
+            exth["date"] as? String ?: "",
+            exth["description"] as? String ?: "",
+            exth["subject"] as? List<String> ?: emptyList(),
+            exth["rights"] as? String ?: ""
+        )
+    }
+
+    var toc: List<TOC>? = null
+
+    private var textRecordOffsets = arrayListOf<Int>()
+
+    init {
+        buildTextRecordOffsets()
+    }
+
+    private fun buildTextRecordOffsets() {
+        var offset = 0
+        for (i in 0..<palmdoc.numTextRecords) {
+            offset += getTextRecord(i).size
+            textRecordOffsets.add(offset)
+        }
+    }
+
+    fun getRecord(index: Int): ByteBuffer {
+        return pdbFile.getRecordData(kf8BoundaryOffset + index)
+    }
+
+    fun getTextRecord(index: Int): ByteArray {
+        if (index < 0 && index >= palmdoc.numTextRecords) {
+            throw IndexOutOfBoundsException("Text record index out of bounds")
+        }
+        var content = getRecord(index + 1).array()
+        content = removeTrailingEntries(content)
+        return decompressor.decompress(content)
+    }
+
+    fun getTextRecordInputStream(): InputStream {
+        return object : InputStream() {
+
+            private var index = -1
+            private var bis: ByteArrayInputStream = emptyByteArrayInputStream
+            private var available = textRecordOffsets.last()
+            private var pos = 0
+
+            override fun read(): Int {
+                if (index >= palmdoc.numTextRecords) {
+                    return -1
+                }
+
+                if (bis.available() == 0) {
+                    if (++index >= palmdoc.numTextRecords) {
+                        return -1
+                    }
+                    bis = getTextRecord(index).inputStream()
+                }
+
+                val b = bis.read()
+
+                available--
+                pos++
+
+                return b
+            }
+
+            override fun skip(n: Long): Long {
+                if (n == 0L) return 0L
+
+                val n1 = min(available, n.toInt())
+
+                if (n1 < bis.available()) {
+                    bis.skip(n1.toLong())
+
+                    available -= n1
+                    pos += n1
+
+                    return n1.toLong()
+                }
+
+                val bIndex = textRecordOffsets.binarySearch(pos + n1)
+                index = abs(bIndex + 1)
+
+                bis = getTextRecord(index).inputStream()
+
+                val offset = textRecordOffsets.getOrNull(index - 1) ?: 0
+                bis.skip((pos + n1 - offset).toLong())
+
+                available -= n1
+                pos += n1
+
+                return n1.toLong()
+            }
+
+            override fun available(): Int {
+                return available
+            }
+
+        }
+    }
+
+    private fun removeTrailingEntries(byteArray: ByteArray): ByteArray {
+        if (trailingFlags == 0) return byteArray
+        val multibyte = trailingFlags and 1 != 0
+        val numTrailingEntries = (trailingFlags shr 1).countOneBits()
+        val lastIndex = byteArray.lastIndex
+        var extraSize = 0
+        for (i in 0..<numTrailingEntries) {
+            var value = 0
+            for (j in max(0, lastIndex - 4 - extraSize)..max(0, lastIndex - extraSize)) {
+                val byte = byteArray[j]
+                if (byte and 0b1000_0000 != 0) {
+                    value = 0
+                }
+                value = (value shl 7) or (byte and 0b111_1111)
+            }
+            extraSize += value
+        }
+        if (multibyte) {
+            val byte = byteArray[byteArray.lastIndex - extraSize]
+            extraSize += (byte and 0b11) + 1
+        }
+        return byteArray.copyOfRange(0, byteArray.size - extraSize)
+    }
+
+    fun getResource(index: Int): ByteBuffer {
+        return pdbFile.getRecordData(resourceStart + index)
+    }
+
+    fun getNCX(): List<NCX>? {
+        val indxIndex = mobi.indx
+        if (indxIndex == -1) {
+            return null
+        }
+        val indexData = getIndexData(indxIndex)
+        val items = indexData.table.mapIndexed { index, indexEntry ->
+            val tagMap = indexEntry.tagMap
+            NCX(
+                index,
+                tagMap[1]?.tagValues?.getOrNull(0),
+                tagMap[2]?.tagValues?.getOrNull(0),
+                indexData.cncx[tagMap[3].tagValues[0]],
+                tagMap[4]?.tagValues?.getOrNull(0),
+                tagMap[6]?.tagValues,
+                tagMap[21]?.tagValues?.getOrNull(0),
+                tagMap[22]?.tagValues?.getOrNull(0),
+                tagMap[23]?.tagValues?.getOrNull(0),
+            )
+        }
+
+        val parentItemMap = hashMapOf<Int, ArrayList<NCX>>()
+
+        items.forEach {
+            val parent = it.parent ?: return@forEach
+            val array = parentItemMap.getOrPut(parent) { arrayListOf() }
+            array.add(it)
+        }
+
+        fun getChildren(item: NCX): NCX {
+            if (item.firstChild == null) return item
+            item.children = parentItemMap[item.index]?.map(::getChildren)
+            return item
+        }
+
+        return items.filter { it.headingLevel == 0 }.map(::getChildren)
+    }
+
+    fun getCover(): ByteArray? {
+        val coverOffset = exth["coverOffset"] as? Int
+        val thumbnailOffset = exth["thumbnailOffset"] as? Int
+
+        if (coverOffset != null && coverOffset != -1) {
+            return getResource(coverOffset).array()
+        }
+
+        if (thumbnailOffset != null && thumbnailOffset != -1) {
+            return getResource(thumbnailOffset).array()
+        }
+
+        return null
+    }
+
+    fun getIndexData(indxIndex: Int): IndexData {
+        val indxRecord = getRecord(indxIndex)
+        val indx = readIndxHeader(indxRecord)
+        indxRecord.position(indx.length)
+        val tagxBuffer = indxRecord.slice()
+        val tagx = readTagxHeader(tagxBuffer)
+        val tagTable = readTagxTags(tagx, tagxBuffer)
+        val cncx = readCncx(indxIndex, indx)
+
+        val table = arrayListOf<IndexEntry>()
+
+        for (i in 0..<indx.numRecords) {
+            val indxBuffer = getRecord(indxIndex + 1 + i)
+            val indxHeader = readIndxHeader(indxBuffer)
+            val idxt = readIdxt(indxBuffer, indxHeader)
+            for (j in 0..<indxHeader.numRecords) {
+                val idxtOffset = idxt[j]
+                val entry = readIndexEntry(indxBuffer, tagx, tagTable, idxtOffset)
+                table.add(entry)
+            }
+        }
+
+        return IndexData(table, cncx)
+    }
+
+    private fun readIndexEntry(
+        indxBuffer: ByteBuffer,
+        tagx: TagxHeader,
+        tagTable: List<TagxTag>,
+        idxtOffset: Int
+    ): IndexEntry {
+        val array = indxBuffer.array()
+
+        val len = indxBuffer.readUInt8(idxtOffset)
+        val label = indxBuffer.readString(idxtOffset + 1, len)
+
+        val ptagxs = arrayListOf<Ptagx>()
+        val startPos = idxtOffset + 1 + len
+        var controlByteIndex = 0
+        var pos = startPos + tagx.numControlBytes
+
+        for (tag in tagTable) {
+            if (tag.controlByte == 1) {
+                controlByteIndex++
+                continue
+            }
+            val offset = startPos + controlByteIndex
+            var value = indxBuffer.readUInt8(offset) and tag.bitmask
+            if (value == tag.bitmask) {
+                if (tag.bitmask.countOneBits() > 1) {
+                    var v = 0
+                    for (a in pos..<min(pos + 4, array.size)) {
+                        val byte = array[a]
+                        v = (v shl 7) or (byte and 0b111_1111)
+                        pos++
+                        if (byte and 0b1000_0000 != 0) break
+                    }
+                    ptagxs.add(Ptagx(tag.tag, tag.numValues, null, v))
+                } else {
+                    ptagxs.add(Ptagx(tag.tag, tag.numValues, 1, null))
+                }
+            } else {
+                var mask = tag.bitmask
+                while ((mask and 1) == 0) {
+                    mask = mask shr 1
+                    value = value shr 1
+                }
+                ptagxs.add(Ptagx(tag.tag, tag.numValues, value, null))
+            }
+        }
+
+        val tags = arrayListOf<IndexTag>()
+        val tagMap = SparseArray<IndexTag>()
+
+        for (ptagx in ptagxs) {
+            val values = arrayListOf<Int>()
+            if (ptagx.valueCount != null) {
+                repeat(ptagx.valueCount * ptagx.tagValueCount) {
+                    var v = 0
+                    for (a in pos..<min(pos + 4, array.size)) {
+                        val byte = array[a]
+                        v = (v shl 7) or (byte and 0b111_1111)
+                        pos++
+                        if (byte and 0b1000_0000 != 0) break
+                    }
+                    values.add(v)
+                }
+            } else {
+                var count = 0
+                while (count < ptagx.valueBytes!!) {
+                    var v = 0
+                    for (a in pos..<min(pos + 4, array.size)) {
+                        val byte = array[a]
+                        v = (v shl 7) or (byte and 0b111_1111)
+                        pos++
+                        count++
+                        if (byte and 0b1000_0000 != 0) break
+                    }
+                    values.add(v)
+                }
+            }
+            val tag = IndexTag(ptagx.tag, values)
+            tags.add(tag)
+            tagMap[tag.tagId] = tag
+        }
+
+        return IndexEntry(label, tags, tagMap)
+    }
+
+    private fun readIdxt(buffer: ByteBuffer, indxHeader: IndxHeader): IntArray {
+        return buffer.readUInt16Array(indxHeader.idxt + 4, indxHeader.numRecords)
+    }
+
+    private fun readTagxTags(tagx: TagxHeader, tagxBuffer: ByteBuffer): List<TagxTag> {
+        val numTags = (tagx.length - 12) / 4
+        val tags = arrayListOf<TagxTag>()
+        tagxBuffer.position(12)
+        for (i in 0..<numTags) {
+            val tag = tagxBuffer.readUInt8()
+            val numValues = tagxBuffer.readUInt8()
+            val bitmask = tagxBuffer.readUInt8()
+            val controlByte = tagxBuffer.readUInt8()
+            tags.add(TagxTag(tag, numValues, bitmask, controlByte))
+        }
+        return tags
+    }
+
+    private fun readCncx(indxIndex: Int, indx: IndxHeader): SparseArray<String> {
+        val cncx = SparseArray<String>()
+        var cncxRecordOffset = 0
+        for (i in 0..<indx.numCncx) {
+            val record = getRecord(indxIndex + indx.numRecords + i + 1)
+            val array = record.array()
+            var pos = 0
+            while (pos < array.size) {
+                val index = pos
+                var value = 0
+                var length = 0
+                for (a in pos..<min(pos + 4, array.size)) {
+                    val byte = array[a]
+                    value = (value shl 7) or (byte and 0b111_1111)
+                    length++
+                    if (byte and 0b1000_0000 != 0) break
+                }
+                pos += length
+                val result = record.readString(pos, value, charset)
+                pos += value
+                cncx[cncxRecordOffset + index] = result
+            }
+            cncxRecordOffset += 0x10000
+        }
+
+        return cncx
+    }
+
+    private fun readIndxHeader(indx: ByteBuffer): IndxHeader {
+        val magic = indx.readString(0, 4)
+        if (magic != "INDX") {
+            error("Invalid INDX record")
+        }
+        val length = indx.readUInt32(4)
+        val type = indx.readUInt32(8)
+        val idxt = indx.readUInt32(20)
+        val numRecords = indx.readUInt32(24)
+        val encoding = indx.readUInt32(28)
+        val language = indx.readUInt32(32)
+        val total = indx.readUInt32(36)
+        val ordt = indx.readUInt32(40)
+        val ligt = indx.readUInt32(44)
+        val numLigt = indx.readUInt32(48)
+        val numCncx = indx.readUInt32(52)
+
+        return IndxHeader(
+            magic, length, type, idxt, numRecords, encoding, language, total, ordt,
+            ligt, numLigt, numCncx
+        )
+    }
+
+    private fun readTagxHeader(buffer: ByteBuffer): TagxHeader {
+        val magic = buffer.readString(0, 4)
+        if (magic != "TAGX") {
+            error("Invalid INDX record")
+        }
+        val length = buffer.readUInt32(4)
+        val numControlBytes = buffer.readUInt32(8)
+
+        return TagxHeader(magic, length, numControlBytes)
+    }
+
+    fun close() {
+        pdbFile.close()
+    }
+
+    protected fun finalize() {
+        close()
+    }
+
+    companion object {
+        private val emptyByteArrayInputStream = ByteArrayInputStream(ByteArray(0))
+    }
+
+}

+ 232 - 0
app/src/main/java/io/legado/app/lib/mobi/MobiReader.kt

@@ -0,0 +1,232 @@
+package io.legado.app.lib.mobi
+
+import android.os.ParcelFileDescriptor
+import io.legado.app.lib.mobi.entities.ExthRecordType
+import io.legado.app.lib.mobi.entities.KF8Header
+import io.legado.app.lib.mobi.entities.MobiEntryHeaders
+import io.legado.app.lib.mobi.entities.MobiHeader
+import io.legado.app.lib.mobi.entities.PalmDocHeader
+import io.legado.app.lib.mobi.utils.readString
+import io.legado.app.lib.mobi.utils.readUInt16
+import io.legado.app.lib.mobi.utils.readUInt32
+import io.legado.app.lib.mobi.utils.readUInt8
+import java.nio.ByteBuffer
+import java.nio.charset.Charset
+
+class MobiReader {
+
+    fun readMobi(pfd: ParcelFileDescriptor): MobiBook {
+        val pdbFile = PDBFile(pfd)
+        val record0 = pdbFile.getRecordData(0)
+
+        var mobiEntryHeaders = readMobiEntryHeaders(record0)
+        val mobi = mobiEntryHeaders.mobi
+        val exth = mobiEntryHeaders.exth
+        val resourceStart = mobi.resourceStart
+
+        var isKF8 = mobi.version >= 8
+
+        var kf8BoundaryOffset = 0
+
+        if (!isKF8) {
+            val boundary = exth["boundary"] as? Int
+            if (boundary != null && boundary != -1) {
+                try {
+                    val buffer = pdbFile.getRecordData(boundary)
+                    mobiEntryHeaders = readMobiEntryHeaders(buffer)
+                    kf8BoundaryOffset = boundary
+                    isKF8 = true
+                } catch (e: Exception) {
+                    e.printStackTrace()
+                }
+            }
+        }
+
+        return if (isKF8) {
+            KF8Book(pdbFile, mobiEntryHeaders, kf8BoundaryOffset, resourceStart)
+        } else {
+            KF6Book(pdbFile, mobiEntryHeaders, kf8BoundaryOffset, resourceStart)
+        }
+    }
+
+    private fun readMobiEntryHeaders(buffer: ByteBuffer): MobiEntryHeaders {
+        val palmDocHeader = readPalmDocHeader(buffer)
+        val mobiHeader = readMobiHeader(buffer)
+        val exth = if (mobiHeader.exthFlag and 0b100_0000 != 0) {
+            buffer.position(mobiHeader.length + 16)
+            readExth(buffer.slice())
+        } else {
+            emptyMap()
+        }
+        val kF8Header = if (mobiHeader.version >= 8) {
+            readKF8Header(buffer)
+        } else {
+            null
+        }
+        return MobiEntryHeaders(palmDocHeader, mobiHeader, exth, kF8Header)
+    }
+
+    private fun readExth(buffer: ByteBuffer): Map<String, Any> {
+        val magic = buffer.readString(0, 4)
+        check(magic == "EXTH") { "Invalid EXTH header" }
+        val count = buffer.readUInt32(8)
+        var offset = 12
+        val map = HashMap<String, Any>()
+        for (i in 0..<count) {
+            val type = buffer.readUInt32(offset)
+            val length = buffer.readUInt32(offset + 4)
+            if (type in exthRecordTypeMap) {
+                val exthRecordType = exthRecordTypeMap[type]!!
+                val name = exthRecordType.name
+                val data: Any = if (exthRecordType.type == "uint") {
+                    buffer.readUInt32(offset + 8)
+                } else {
+                    buffer.readString(offset + 8, length - 8)
+                }
+                if (exthRecordType.many) {
+                    if (!map.contains(name)) {
+                        map[name] = arrayListOf<String>()
+                    }
+                    @Suppress("UNCHECKED_CAST")
+                    val array = map[name] as ArrayList<String>
+                    array.add(data as String)
+                } else {
+                    map[name] = data
+                }
+            }
+            offset += length
+        }
+        return map
+    }
+
+    private fun readPalmDocHeader(content: ByteBuffer): PalmDocHeader {
+
+        val compression = content.readUInt16(0)
+        val numTextRecords = content.readUInt16(8)
+        val recordSize = content.readUInt16(10)
+        val encryption = content.readUInt16(12)
+
+        return PalmDocHeader(compression, numTextRecords, recordSize, encryption)
+    }
+
+    private fun readMobiHeader(content: ByteBuffer): MobiHeader {
+
+        val identifier = content.readString(16, 4)
+
+        check(identifier == "MOBI") { "Missing MOBI header" }
+
+        val length = content.readUInt32(20)
+        val type = content.readUInt32(24)
+        val encoding = content.readUInt32(28)
+        val uid = content.readUInt32(32)
+        val version = content.readUInt32(36)
+        val titleOffset = content.readUInt32(84)
+        val titleLength = content.readUInt32(88)
+        val localeRegion = content.readUInt8(94)
+        val localeLanguage = content.readUInt8(95)
+        val resourceStar = content.readUInt32(108)
+        val huffcdic = content.readUInt32(112)
+        val numHuffcdic = content.readUInt32(116)
+        val exthFlag = content.readUInt32(128)
+        val trailingFlags = content.readUInt32(240)
+        val indx = content.readUInt32(244)
+        val charset: Charset = when (encoding) {
+            65001 -> Charsets.UTF_8
+            1252 -> Charset.forName("windows-1252")
+            else -> error("unknown charset $encoding")
+        }
+        val title = content.readString(titleOffset, titleLength, charset)
+
+        val lang = mobiLangMap[localeLanguage]
+        val language = lang?.getOrNull(localeRegion shr 2) ?: lang?.first() ?: ""
+
+        return MobiHeader(
+            identifier, length, type, encoding, uid, version, titleOffset, titleLength,
+            localeRegion, localeLanguage, resourceStar, huffcdic, numHuffcdic, exthFlag,
+            trailingFlags, indx, title, language
+        )
+    }
+
+    private fun readKF8Header(content: ByteBuffer): KF8Header {
+        val fdst = content.readUInt32(192)
+        val numFdst = content.readUInt32(196)
+        val frag = content.readUInt32(248)
+        val skel = content.readUInt32(252)
+        val guide = content.readUInt32(260)
+
+        return KF8Header(fdst, numFdst, frag, skel, guide)
+    }
+
+    companion object {
+        val exthRecordTypeMap = mapOf(
+            100 to ExthRecordType("creator", "string", true),
+            101 to ExthRecordType("publisher"),
+            103 to ExthRecordType("description"),
+            104 to ExthRecordType("isbn"),
+            105 to ExthRecordType("subject", "string", true),
+            106 to ExthRecordType("date"),
+            108 to ExthRecordType("contributor", "string", true),
+            109 to ExthRecordType("rights"),
+            110 to ExthRecordType("subjectCode", "string", true),
+            112 to ExthRecordType("source", "string", true),
+            113 to ExthRecordType("asin"),
+            121 to ExthRecordType("boundary", "uint"),
+            122 to ExthRecordType("fixedLayout"),
+            125 to ExthRecordType("numResources", "uint"),
+            126 to ExthRecordType("originalResolution"),
+            127 to ExthRecordType("zeroGutter"),
+            128 to ExthRecordType("zeroMargin"),
+            129 to ExthRecordType("coverURI"),
+            132 to ExthRecordType("regionMagnification"),
+            201 to ExthRecordType("coverOffset", "uint"),
+            202 to ExthRecordType("thumbnailOffset", "uint"),
+            204 to ExthRecordType("creatorSoftware", "uint"),
+            503 to ExthRecordType("title"),
+            524 to ExthRecordType("language", "string", true),
+            527 to ExthRecordType("pageProgressionDirection"),
+        )
+
+        val mobiLangMap = mapOf(
+            1 to listOf(
+                "ar", "ar-SA", "ar-IQ", "ar-EG", "ar-LY", "ar-DZ", "ar-MA", "ar-TN", "ar-OM",
+                "ar-YE", "ar-SY", "ar-JO", "ar-LB", "ar-KW", "ar-AE", "ar-BH", "ar-QA"
+            ),
+            2 to listOf("bg"), 3 to listOf("ca"),
+            4 to listOf("zh", "zh-TW", "zh-CN", "zh-HK", "zh-SG"),
+            5 to listOf("cs"), 6 to listOf("da"),
+            7 to listOf("de", "de-DE", "de-CH", "de-AT", "de-LU", "de-LI"), 8 to listOf("el"),
+            9 to listOf(
+                "en", "en-US", "en-GB", "en-AU", "en-CA", "en-NZ", "en-IE", "en-ZA",
+                "en-JM", null, "en-BZ", "en-TT", "en-ZW", "en-PH"
+            ),
+            10 to listOf(
+                "es", "es-ES", "es-MX", null, "es-GT", "es-CR", "es-PA", "es-DO",
+                "es-VE", "es-CO", "es-PE", "es-AR", "es-EC", "es-CL", "es-UY", "es-PY",
+                "es-BO", "es-SV", "es-HN", "es-NI", "es-PR"
+            ),
+            11 to listOf("fi"),
+            12 to listOf("fr", "fr-FR", "fr-BE", "fr-CA", "fr-CH", "fr-LU", "fr-MC"),
+            13 to listOf("he"), 14 to listOf("hu"), 15 to listOf("is"),
+            16 to listOf("it", "it-IT", "it-CH"), 17 to listOf("ja"), 18 to listOf("ko"),
+            19 to listOf("nl", "nl-NL", "nl-BE"), 20 to listOf("no", "nb", "nn"),
+            21 to listOf("pl"), 22 to listOf("pt", "pt-BR", "pt-PT"), 23 to listOf("rm"),
+            24 to listOf("ro"), 25 to listOf("ru"), 26 to listOf("hr", null, "sr"),
+            27 to listOf("sk"), 28 to listOf("sq"), 29 to listOf("sv", "sv-SE", "sv-FI"),
+            30 to listOf("th"), 31 to listOf("tr"), 32 to listOf("ur"), 33 to listOf("id"),
+            34 to listOf("uk"), 35 to listOf("be"), 36 to listOf("sl"), 37 to listOf("et"),
+            38 to listOf("lv"), 39 to listOf("lt"), 41 to listOf("fa"), 42 to listOf("vi"),
+            43 to listOf("hy"), 44 to listOf("az"), 45 to listOf("eu"), 46 to listOf("hsb"),
+            47 to listOf("mk"), 48 to listOf("st"), 49 to listOf("ts"), 50 to listOf("tn"),
+            52 to listOf("xh"), 53 to listOf("zu"), 54 to listOf("af"), 55 to listOf("ka"),
+            56 to listOf("fo"), 57 to listOf("hi"), 58 to listOf("mt"), 59 to listOf("se"),
+            62 to listOf("ms"), 63 to listOf("kk"), 65 to listOf("sw"),
+            67 to listOf("uz", null, "uz-UZ"), 68 to listOf("tt"), 69 to listOf("bn"),
+            70 to listOf("pa"), 71 to listOf("gu"), 72 to listOf("or"), 73 to listOf("ta"),
+            74 to listOf("te"), 75 to listOf("kn"), 76 to listOf("ml"), 77 to listOf("as"),
+            78 to listOf("mr"), 79 to listOf("sa"), 82 to listOf("cy", "cy-GB"),
+            83 to listOf("gl", "gl-ES"), 87 to listOf("kok"), 97 to listOf("ne"),
+            98 to listOf("fy")
+        )
+    }
+
+}

+ 49 - 0
app/src/main/java/io/legado/app/lib/mobi/PDBFile.kt

@@ -0,0 +1,49 @@
+package io.legado.app.lib.mobi
+
+import android.os.ParcelFileDescriptor
+import io.legado.app.lib.mobi.utils.readString
+import io.legado.app.lib.mobi.utils.readUInt16
+import io.legado.app.lib.mobi.utils.readUInt32
+import java.io.FileInputStream
+import java.nio.ByteBuffer
+import java.nio.channels.FileChannel
+
+class PDBFile(private val pfd: ParcelFileDescriptor) {
+    private val fc: FileChannel = FileInputStream(pfd.fileDescriptor).channel
+    private val offsets: IntArray
+    val name: String
+    val type: String
+    val creator: String
+    val recordCount: Int
+
+    init {
+        var buffer = ByteBuffer.allocate(79)
+        fc.read(buffer)
+        name = buffer.readString(0, 32)
+        type = buffer.readString(60, 4)
+        creator = buffer.readString(64, 4)
+        recordCount = buffer.readUInt16(76)
+
+        buffer = ByteBuffer.allocate(recordCount * 8)
+        fc.read(buffer, 78)
+        offsets = IntArray(recordCount) {
+            buffer.readUInt32(it * 8)
+        }
+    }
+
+    fun getRecordData(index: Int): ByteBuffer {
+        if (index < 0 || index >= recordCount) {
+            throw IndexOutOfBoundsException("Record index out of bounds")
+        }
+        val len = offsets.getOrElse(index + 1) { fc.size().toInt() } - offsets[index]
+        val buffer = ByteBuffer.allocate(len)
+        fc.read(buffer, offsets[index].toLong())
+        return buffer
+    }
+
+    fun close() {
+        fc.close()
+        pfd.close()
+    }
+
+}

+ 6 - 0
app/src/main/java/io/legado/app/lib/mobi/decompress/CDICData.kt

@@ -0,0 +1,6 @@
+package io.legado.app.lib.mobi.decompress
+
+class CDICEntry(
+    var data: ByteArray,
+    var decompressed: Boolean
+)

+ 7 - 0
app/src/main/java/io/legado/app/lib/mobi/decompress/Decompressor.kt

@@ -0,0 +1,7 @@
+package io.legado.app.lib.mobi.decompress
+
+interface Decompressor {
+
+    fun decompress(data: ByteArray): ByteArray
+
+}

+ 133 - 0
app/src/main/java/io/legado/app/lib/mobi/decompress/HuffcdicDecompressor.kt

@@ -0,0 +1,133 @@
+package io.legado.app.lib.mobi.decompress
+
+import io.legado.app.lib.mobi.MobiBook
+import io.legado.app.lib.mobi.entities.MobiHeader
+import io.legado.app.lib.mobi.utils.readIntArray
+import io.legado.app.lib.mobi.utils.readString
+import io.legado.app.lib.mobi.utils.readUInt16
+import io.legado.app.lib.mobi.utils.readUInt32
+import java.io.ByteArrayOutputStream
+import java.nio.ByteBuffer
+import kotlin.math.min
+
+@Suppress("SpellCheckingInspection")
+class HuffcdicDecompressor(
+    mobiBook: MobiBook,
+    mobiHeader: MobiHeader,
+) : Decompressor {
+
+    private val magic: String
+    private val offset1: Int
+    private val offset2: Int
+    private val table1: IntArray
+    private val mincodeTable = LongArray(33)
+    private val maxcodeTable = LongArray(33)
+    private val dictionary = arrayListOf<CDICEntry>()
+
+    init {
+        val huff = mobiBook.getRecord(mobiHeader.huffcdic)
+        magic = huff.readString(0, 4)
+        if (magic != "HUFF") error("Invalid HUFF record")
+        offset1 = huff.readUInt32(8)
+        offset2 = huff.readUInt32(12)
+
+        table1 = huff.readIntArray(offset1, 256)
+
+        huff.position(offset2)
+
+        for (i in 1..32) {
+            val mincode = huff.readUInt32().toLong()
+            val maxcode = huff.readUInt32().toLong()
+            mincodeTable[i] = mincode shl (32 - i)
+            maxcodeTable[i] = ((maxcode + 1) shl (32 - i)) - 1
+        }
+
+        for (i in 1..<mobiHeader.numHuffcdic) {
+            val record = mobiBook.getRecord(mobiHeader.huffcdic + i)
+            val magic = record.readString(0, 4)
+            if (magic != "CDIC") error("Invalid CDIC record")
+            val length = record.readUInt32(4)
+            val numEntries = record.readUInt32(8)
+            val codeLength = record.readUInt32(12)
+
+            val n = min(1 shl codeLength, numEntries - dictionary.size)
+
+            record.position(length)
+            val buffer = record.slice()
+            for (j in 0..<n) {
+                val offset = buffer.readUInt16(j * 2)
+                val x = buffer.readUInt16(offset)
+                val len = x and 0x7fff
+                val decompressed = x and 0x8000 != 0
+                val data = ByteArray(len)
+                buffer.position(offset + 2)
+                buffer.get(data)
+                dictionary.add(CDICEntry(data, decompressed))
+            }
+
+        }
+
+    }
+
+    override fun decompress(data: ByteArray): ByteArray {
+        val bos = ByteArrayOutputStream()
+
+        val buffer = ByteBuffer.wrap(data)
+
+        var bitsleft = data.size * 8
+        var pos = 0
+        var x = buffer.readUIntX(pos, 8)
+        var bitcount = 32
+
+        while (true) {
+            if (bitcount <= 0) {
+                pos += 4
+                x = buffer.readUIntX(pos, 8)
+                bitcount += 32
+            }
+
+            val code = (x shr bitcount) and ((1L shl 32) - 1)
+            val t1 = table1[(code shr 24).toInt()]
+            var codelen = t1 and 0x1f
+            var maxcode = (((t1.toLong() shr 8) + 1) shl ((32L - codelen).toInt())) - 1
+
+            if (t1 and 0x80 == 0) {
+                while (code < mincodeTable[codelen]) {
+                    codelen++
+                }
+                maxcode = maxcodeTable[codelen]
+            }
+
+            bitcount -= codelen
+            bitsleft -= codelen
+
+            if (bitsleft < 0) {
+                break
+            }
+
+            val index = (maxcode - code) shr ((32 - codelen))
+            val entry = dictionary[index.toInt()]
+            if (!entry.decompressed) {
+                entry.data = decompress(entry.data)
+                entry.decompressed = true
+            }
+
+            bos.write(entry.data)
+
+        }
+
+        return bos.toByteArray()
+    }
+
+    private fun ByteBuffer.readUIntX(offset: Int, maxlen: Int): Long {
+        position(offset)
+        var value = 0L
+        var i = maxlen
+        var bytesLeft = limit() - position()
+        while (i-- > 0 && bytesLeft-- > 0) {
+            value = value or ((get().toLong() and 0xFF) shl (i * 8))
+        }
+        return value
+    }
+
+}

+ 49 - 0
app/src/main/java/io/legado/app/lib/mobi/decompress/Lz77Decompressor.kt

@@ -0,0 +1,49 @@
+package io.legado.app.lib.mobi.decompress
+
+import androidx.core.util.Pools.SynchronizedPool
+
+class Lz77Decompressor(private val textRecordSize: Int) : Decompressor {
+
+    val pool = SynchronizedPool<ByteArray>(2)
+
+    override fun decompress(data: ByteArray): ByteArray {
+        val out = pool.acquire() ?: ByteArray(textRecordSize)
+
+        var i = 0
+        var o = 0
+        while (i < data.size) {
+            var c = data[i++].toInt() and 0x00FF
+            if (c in 0x01..0x08) {
+                var j = 0
+                while (j < c && i + j < data.size) {
+                    out[o++] = data[i + j]
+                    j++
+                }
+                i += c
+            } else if (c <= 0x7f) {
+                out[o++] = c.toByte()
+            } else if (c >= 0xC0) {
+                out[o++] = ' '.code.toByte()
+                out[o++] = (c xor 0x80).toByte()
+            } else {
+                if (i < data.size) {
+                    c = c shl 8 or (data[i++].toInt() and 0xFF)
+                    val length = (c and 0x0007) + 3
+                    val location = (c shr 3) and 0x7FF
+
+                    if (location in 1..o) {
+                        for (j in 0 until length) {
+                            val idx = o - location
+                            out[o++] = out[idx]
+                        }
+                    }
+                }
+            }
+        }
+
+        val result = ByteArray(o)
+        System.arraycopy(out, 0, result, 0, o)
+        return result.also { pool.release(out) }
+    }
+
+}

+ 9 - 0
app/src/main/java/io/legado/app/lib/mobi/decompress/PlainDecompressor.kt

@@ -0,0 +1,9 @@
+package io.legado.app.lib.mobi.decompress
+
+class PlainDecompressor : Decompressor {
+
+    override fun decompress(data: ByteArray): ByteArray {
+        return data
+    }
+
+}

+ 7 - 0
app/src/main/java/io/legado/app/lib/mobi/entities/ExthRecordType.kt

@@ -0,0 +1,7 @@
+package io.legado.app.lib.mobi.entities
+
+data class ExthRecordType(
+    val name: String,
+    val type: String = "string",
+    val many: Boolean = false
+)

+ 6 - 0
app/src/main/java/io/legado/app/lib/mobi/entities/FdstHeader.kt

@@ -0,0 +1,6 @@
+package io.legado.app.lib.mobi.entities
+
+data class FdstHeader(
+    val magic: String,
+    val numEntries: Int
+)

+ 9 - 0
app/src/main/java/io/legado/app/lib/mobi/entities/Fragment.kt

@@ -0,0 +1,9 @@
+package io.legado.app.lib.mobi.entities
+
+data class Fragment(
+    val insertOffset: Int,
+    val selector: String,
+    val index: Int,
+    val offset: Int,
+    val length: Int
+)

+ 8 - 0
app/src/main/java/io/legado/app/lib/mobi/entities/IndexData.kt

@@ -0,0 +1,8 @@
+package io.legado.app.lib.mobi.entities
+
+import android.util.SparseArray
+
+data class IndexData(
+    val table: List<IndexEntry>,
+    val cncx: SparseArray<String>
+)

+ 9 - 0
app/src/main/java/io/legado/app/lib/mobi/entities/IndexEntry.kt

@@ -0,0 +1,9 @@
+package io.legado.app.lib.mobi.entities
+
+import android.util.SparseArray
+
+data class IndexEntry(
+    val label: String,
+    val tags: List<IndexTag>,
+    val tagMap: SparseArray<IndexTag>
+)

+ 6 - 0
app/src/main/java/io/legado/app/lib/mobi/entities/IndexTag.kt

@@ -0,0 +1,6 @@
+package io.legado.app.lib.mobi.entities
+
+data class IndexTag(
+    val tagId: Int,
+    val tagValues: List<Int>
+)

+ 16 - 0
app/src/main/java/io/legado/app/lib/mobi/entities/IndxHeader.kt

@@ -0,0 +1,16 @@
+package io.legado.app.lib.mobi.entities
+
+data class IndxHeader(
+    val magic: String,
+    val length: Int,
+    val type: Int,
+    val idxt: Int,
+    val numRecords: Int,
+    val encoding: Int,
+    val language: Int,
+    val total: Int,
+    val ordt: Int,
+    val ligt: Int,
+    val numLigt: Int,
+    val numCncx: Int,
+)

+ 10 - 0
app/src/main/java/io/legado/app/lib/mobi/entities/KF6Section.kt

@@ -0,0 +1,10 @@
+package io.legado.app.lib.mobi.entities
+
+data class KF6Section(
+    val index: Int,
+    val start: Int,
+    val end: Int,
+    val length: Int,
+    val href: String,
+    var next: KF6Section? = null
+)

+ 9 - 0
app/src/main/java/io/legado/app/lib/mobi/entities/KF8Header.kt

@@ -0,0 +1,9 @@
+package io.legado.app.lib.mobi.entities
+
+data class KF8Header(
+    val fdst: Int,
+    val numFdst: Int,
+    val frag: Int,
+    val skel: Int,
+    val guide: Int,
+)

+ 6 - 0
app/src/main/java/io/legado/app/lib/mobi/entities/KF8Pos.kt

@@ -0,0 +1,6 @@
+package io.legado.app.lib.mobi.entities
+
+data class KF8Pos(
+    val fid: Int,
+    val offset: Int
+)

+ 7 - 0
app/src/main/java/io/legado/app/lib/mobi/entities/KF8Resource.kt

@@ -0,0 +1,7 @@
+package io.legado.app.lib.mobi.entities
+
+data class KF8Resource(
+    val resourceType: String,
+    val id: Int,
+    val type: String
+)

+ 14 - 0
app/src/main/java/io/legado/app/lib/mobi/entities/KF8Section.kt

@@ -0,0 +1,14 @@
+package io.legado.app.lib.mobi.entities
+
+data class KF8Section(
+    val index: Int,
+    val skeleton: Skeleton,
+    val frags: List<Fragment>,
+    val fragEnd: Int,
+    val length: Int,
+    val totalLength: Int,
+    val href: String,
+    var next: KF8Section? = null
+) {
+    val linear get() = frags.isNotEmpty()
+}

+ 8 - 0
app/src/main/java/io/legado/app/lib/mobi/entities/MobiEntryHeaders.kt

@@ -0,0 +1,8 @@
+package io.legado.app.lib.mobi.entities
+
+data class MobiEntryHeaders(
+    val palmdoc: PalmDocHeader,
+    val mobi: MobiHeader,
+    val exth: Map<String, Any>,
+    val kf8: KF8Header?
+)

+ 22 - 0
app/src/main/java/io/legado/app/lib/mobi/entities/MobiHeader.kt

@@ -0,0 +1,22 @@
+package io.legado.app.lib.mobi.entities
+
+data class MobiHeader(
+    val identifier: String,
+    val length: Int,
+    val type: Int,
+    val encoding: Int,
+    val uid: Int,
+    val version: Int,
+    val titleOffset: Int,
+    val titleLength: Int,
+    val localeRegion: Int,
+    val localeLanguage: Int,
+    val resourceStart: Int,
+    val huffcdic: Int,
+    val numHuffcdic: Int,
+    val exthFlag: Int,
+    val trailingFlags: Int,
+    val indx: Int,
+    val title: String,
+    val languege: String
+)

+ 13 - 0
app/src/main/java/io/legado/app/lib/mobi/entities/MobiMetadata.kt

@@ -0,0 +1,13 @@
+package io.legado.app.lib.mobi.entities
+
+data class MobiMetadata(
+    val identifier: String,
+    val title: String,
+    val author: List<String>,
+    val publisher: String,
+    val language: String,
+    val published: String,
+    val description: String,
+    val subject: List<String>,
+    val rights: String
+)

+ 14 - 0
app/src/main/java/io/legado/app/lib/mobi/entities/NCX.kt

@@ -0,0 +1,14 @@
+package io.legado.app.lib.mobi.entities
+
+data class NCX(
+    val index: Int,
+    val offset: Int?,
+    val size: Int?,
+    val label: String,
+    val headingLevel: Int?,
+    val pos: List<Int>?,
+    val parent: Int?,
+    val firstChild: Int?,
+    val lastChild: Int?,
+    var children: List<NCX>? = null
+)

+ 8 - 0
app/src/main/java/io/legado/app/lib/mobi/entities/PalmDocHeader.kt

@@ -0,0 +1,8 @@
+package io.legado.app.lib.mobi.entities
+
+data class PalmDocHeader(
+    val compression: Int,
+    val numTextRecords: Int,
+    val recordSize: Int,
+    val encryption: Int
+)

+ 8 - 0
app/src/main/java/io/legado/app/lib/mobi/entities/Ptagx.kt

@@ -0,0 +1,8 @@
+package io.legado.app.lib.mobi.entities
+
+data class Ptagx(
+    val tag: Int,
+    val tagValueCount: Int,
+    val valueCount: Int?,
+    val valueBytes: Int?
+)

+ 9 - 0
app/src/main/java/io/legado/app/lib/mobi/entities/Skeleton.kt

@@ -0,0 +1,9 @@
+package io.legado.app.lib.mobi.entities
+
+data class Skeleton(
+    val index: Int,
+    val name: String,
+    val numFrag: Int,
+    val offset: Int,
+    val length: Int
+)

+ 7 - 0
app/src/main/java/io/legado/app/lib/mobi/entities/TOC.kt

@@ -0,0 +1,7 @@
+package io.legado.app.lib.mobi.entities
+
+data class TOC(
+    val label: String,
+    val href: String,
+    val subitems: List<TOC>? = null
+)

+ 7 - 0
app/src/main/java/io/legado/app/lib/mobi/entities/TagxHeader.kt

@@ -0,0 +1,7 @@
+package io.legado.app.lib.mobi.entities
+
+data class TagxHeader(
+    val magic: String,
+    val length: Int,
+    val numControlBytes: Int
+)

+ 8 - 0
app/src/main/java/io/legado/app/lib/mobi/entities/TagxTag.kt

@@ -0,0 +1,8 @@
+package io.legado.app.lib.mobi.entities
+
+data class TagxTag(
+    val tag: Int,
+    val numValues: Int,
+    val bitmask: Int,
+    val controlByte: Int,
+)

+ 73 - 0
app/src/main/java/io/legado/app/lib/mobi/utils/ByteBufferExtensions.kt

@@ -0,0 +1,73 @@
+package io.legado.app.lib.mobi.utils
+
+import okhttp3.internal.and
+import java.nio.ByteBuffer
+import java.nio.charset.Charset
+
+fun ByteBuffer.readByteArray(offset: Int, len: Int): ByteArray {
+    position(offset)
+    val b = ByteArray(len)
+    get(b)
+    return b
+}
+
+fun ByteBuffer.readByteArray(len: Int): ByteArray {
+    val b = ByteArray(len)
+    get(b)
+    return b
+}
+
+fun ByteBuffer.readIntArray(offset: Int, len: Int): IntArray {
+    position(offset)
+    return IntArray(len) {
+        getInt()
+    }
+}
+
+fun ByteBuffer.readUInt16Array(offset: Int, len: Int): IntArray {
+    position(offset)
+    return IntArray(len) {
+        getShort() and 0xFFFF
+    }
+}
+
+fun ByteBuffer.readString(len: Int): String {
+    return String(readByteArray(len))
+}
+
+fun ByteBuffer.readString(offset: Int, len: Int): String {
+    return String(readByteArray(offset, len))
+}
+
+fun ByteBuffer.readString(offset: Int, len: Int, charset: Charset): String {
+    return String(readByteArray(offset, len), charset)
+}
+
+fun ByteBuffer.readUInt8(offset: Int): Int {
+    position(offset)
+    return get() and 0xFF
+}
+
+fun ByteBuffer.readUInt8(): Int {
+    return get() and 0xFF
+}
+
+fun ByteBuffer.readUInt16(offset: Int): Int {
+    position(offset)
+    return getShort() and 0xFFFF
+}
+
+fun ByteBuffer.readUInt32(offset: Int): Int {
+    position(offset)
+    return getInt()
+}
+
+fun ByteBuffer.readUInt32(): Int {
+    return getInt()
+}
+
+fun ByteBuffer.readUInt64(offset: Int): Long {
+    position(offset)
+    return getLong()
+}
+

+ 14 - 14
app/src/main/java/io/legado/app/model/ImageProvider.kt

@@ -11,9 +11,11 @@ import io.legado.app.data.entities.BookSource
 import io.legado.app.exception.NoStackTraceException
 import io.legado.app.help.book.BookHelp
 import io.legado.app.help.book.isEpub
+import io.legado.app.help.book.isMobi
 import io.legado.app.help.book.isPdf
 import io.legado.app.help.config.AppConfig
 import io.legado.app.model.localBook.EpubFile
+import io.legado.app.model.localBook.MobiFile
 import io.legado.app.model.localBook.PdfFile
 import io.legado.app.utils.BitmapUtils
 import io.legado.app.utils.FileUtils
@@ -93,22 +95,20 @@ object ImageProvider {
         return withContext(IO) {
             val vFile = BookHelp.getImage(book, src)
             if (!vFile.exists()) {
-                if (book.isEpub) {
-                    EpubFile.getImage(book, src)?.use { input ->
-                        val newFile = FileUtils.createFileIfNotExist(vFile.absolutePath)
-                        FileOutputStream(newFile).use { output ->
-                            input.copyTo(output)
-                        }
+                val inputStream = when {
+                    book.isEpub -> EpubFile.getImage(book, src)
+                    book.isPdf -> PdfFile.getImage(book, src)
+                    book.isMobi -> MobiFile.getImage(book, src)
+                    else -> {
+                        BookHelp.saveImage(bookSource, book, src)
+                        null
                     }
-                } else if (book.isPdf) {
-                    PdfFile.getImage(book, src)?.use { input ->
-                        val newFile = FileUtils.createFileIfNotExist(vFile.absolutePath)
-                        FileOutputStream(newFile).use { output ->
-                            input.copyTo(output)
-                        }
+                }
+                inputStream?.use { input ->
+                    val newFile = FileUtils.createFileIfNotExist(vFile.absolutePath)
+                    FileOutputStream(newFile).use { output ->
+                        input.copyTo(output)
                     }
-                } else {
-                    BookHelp.saveImage(bookSource, book, src)
                 }
             }
             return@withContext vFile

+ 9 - 0
app/src/main/java/io/legado/app/model/localBook/LocalBook.kt

@@ -97,6 +97,10 @@ object LocalBook {
                 PdfFile.getChapterList(book)
             }
 
+            book.isMobi -> {
+                MobiFile.getChapterList(book)
+            }
+
             else -> {
                 TextFile.getChapterList(book)
             }
@@ -127,6 +131,10 @@ object LocalBook {
                     PdfFile.getContent(book, chapter)
                 }
 
+                book.isMobi -> {
+                    MobiFile.getContent(book, chapter)
+                }
+
                 else -> {
                     TextFile.getContent(book, chapter)
                 }
@@ -208,6 +216,7 @@ object LocalBook {
             book.isEpub -> EpubFile.upBookInfo(book)
             book.isUmd -> UmdFile.upBookInfo(book)
             book.isPdf -> UmdFile.upBookInfo(book)
+            book.isMobi -> MobiFile.upBookInfo(book)
         }
     }
 

+ 310 - 0
app/src/main/java/io/legado/app/model/localBook/MobiFile.kt

@@ -0,0 +1,310 @@
+package io.legado.app.model.localBook
+
+import android.graphics.Bitmap
+import android.graphics.BitmapFactory
+import android.os.ParcelFileDescriptor
+import io.legado.app.constant.AppLog
+import io.legado.app.data.entities.Book
+import io.legado.app.data.entities.BookChapter
+import io.legado.app.help.book.BookHelp
+import io.legado.app.lib.mobi.KF6Book
+import io.legado.app.lib.mobi.KF8Book
+import io.legado.app.lib.mobi.MobiBook
+import io.legado.app.lib.mobi.MobiReader
+import io.legado.app.lib.mobi.entities.TOC
+import io.legado.app.utils.FileUtils
+import io.legado.app.utils.HtmlFormatter
+import io.legado.app.utils.printOnDebug
+import org.jsoup.Jsoup
+import java.io.FileOutputStream
+import java.io.InputStream
+
+class MobiFile(var book: Book) {
+
+    companion object : BaseLocalBookParse {
+        private var mFile: MobiFile? = null
+        private val xmlDeclarationRegex = "<\\?xml[^>]*>".toRegex()
+        private val doctypeDeclarationRegex = "<!DOCTYPE[^>]*>".toRegex()
+
+        @Synchronized
+        private fun getMFile(book: Book): MobiFile {
+            if (mFile == null || mFile?.book?.bookUrl != book.bookUrl) {
+                mFile = MobiFile(book)
+                return mFile!!
+            }
+            mFile?.book = book
+            return mFile!!
+        }
+
+        @Synchronized
+        override fun getChapterList(book: Book): ArrayList<BookChapter> {
+            return getMFile(book).getChapterList()
+        }
+
+        @Synchronized
+        override fun getContent(book: Book, chapter: BookChapter): String? {
+            return getMFile(book).getContent(chapter)
+        }
+
+        @Synchronized
+        override fun getImage(book: Book, href: String): InputStream? {
+            return getMFile(book).getImage(href)
+        }
+
+        @Synchronized
+        override fun upBookInfo(book: Book) {
+            return getMFile(book).upBookInfo()
+        }
+
+        fun clear() {
+            mFile = null
+        }
+    }
+
+    private var fileDescriptor: ParcelFileDescriptor? = null
+    private var mobiBook: MobiBook? = null
+        get() {
+            if (field == null || fileDescriptor == null) {
+                field = readMobi()
+            }
+            return field
+        }
+
+    private fun readMobi(): MobiBook? {
+        return kotlin.runCatching {
+            BookHelp.getBookPFD(book)?.let {
+                fileDescriptor = it
+                MobiReader().readMobi(it)
+            }
+        }.onFailure {
+            AppLog.put("读取Mobi文件失败\n${it.localizedMessage}", it)
+            it.printOnDebug()
+        }.getOrThrow()
+    }
+
+    private fun getChapterList(): ArrayList<BookChapter> {
+        return when (val book = mobiBook) {
+            is KF8Book -> getChapterListKF8(book)
+            is KF6Book -> getChapterListKF6(book)
+            else -> error("impossible condition")
+        }
+    }
+
+    private fun getChapterListKF6(kF6Book: KF6Book): ArrayList<BookChapter> {
+        val chapterList = arrayListOf<BookChapter>()
+        val toc = kF6Book.toc
+
+        if (kF6Book.sectionIdMap[0] == null) {
+            val section = kF6Book.sections.firstOrNull()
+            if (section != null) {
+                val chapter = BookChapter()
+                val content = kF6Book.getSectionText(section)
+                val soup = Jsoup.parse(content)
+                val title = soup.getElementsByTag("title").first()?.text() ?: "卷首"
+                chapter.bookUrl = book.bookUrl
+                chapter.title = title
+                chapter.url = "0:" + section.href
+                chapterList.add(chapter)
+            }
+        }
+
+        fun append(ref: TOC) {
+            val chapter = BookChapter()
+            chapter.bookUrl = book.bookUrl
+            chapter.title = ref.label
+            chapter.url = "${chapterList.size}:${ref.href}"
+            chapter.isVolume = ref.subitems != null
+            val lastChapter = chapterList.lastOrNull()
+            if (lastChapter != null &&
+                lastChapter.isVolume &&
+                lastChapter.url.substringAfter(":") == chapter.url.substringAfter(":")
+            ) {
+                lastChapter.url = "skip:" + lastChapter.url
+            }
+            lastChapter?.putVariable("nextUrl", chapter.url)
+            chapterList.add(chapter)
+            ref.subitems?.forEach(::append)
+        }
+
+        toc?.forEach(::append)
+
+        return chapterList
+    }
+
+    private fun getChapterListKF8(kf8Book: KF8Book): ArrayList<BookChapter> {
+        val chapterList = arrayListOf<BookChapter>()
+        val toc = kf8Book.toc
+
+        if (kf8Book.sectionIdMap[0] == null) {
+            val section = kf8Book.sections.firstOrNull { it.href.isNotEmpty() }
+            if (section != null) {
+                val chapter = BookChapter()
+                val content = kf8Book.getSectionText(section)
+                val soup = Jsoup.parse(content)
+                val title = soup.getElementsByTag("title").first()?.text() ?: "卷首"
+                chapter.bookUrl = book.bookUrl
+                chapter.title = title
+                chapter.url = "0:" + section.href
+                chapterList.add(chapter)
+            }
+        }
+
+        fun append(ref: TOC) {
+            val chapter = BookChapter()
+            chapter.bookUrl = book.bookUrl
+            chapter.title = ref.label
+            chapter.url = "${chapterList.size}:${ref.href}"
+            chapter.isVolume = ref.subitems != null
+            val lastChapter = chapterList.lastOrNull()
+            if (lastChapter != null &&
+                lastChapter.isVolume &&
+                lastChapter.url.substringAfter(":") == chapter.url.substringAfter(":")
+            ) {
+                lastChapter.url = "skip:" + lastChapter.url
+            }
+            lastChapter?.putVariable("nextUrl", chapter.url)
+            chapterList.add(chapter)
+            ref.subitems?.forEach(::append)
+        }
+
+        toc?.forEach(::append)
+
+        return chapterList
+    }
+
+    private fun getContent(chapter: BookChapter): String? {
+        return when (val book = mobiBook) {
+            is KF8Book -> getContentKF8(book, chapter)
+            is KF6Book -> getContentKF6(book, chapter)
+            else -> error("impossible condition")
+        }
+    }
+
+    private fun getContentKF6(kf6Book: KF6Book, chapter: BookChapter): String? {
+        if (chapter.isVolume && chapter.url.startsWith("skip:")) return ""
+        var section = kf6Book.getSectionByHref(chapter.url) ?: return null
+        val nextSectionHref = chapter.getVariable("nextUrl")
+
+        val sb = StringBuilder()
+        sb.append(kf6Book.getSectionText(section))
+        while (true) {
+            section = section.next ?: break
+            if (section.href == nextSectionHref) {
+                break
+            }
+            if (kf6Book.sectionIdMap[section.index] != null) {
+                break
+            }
+            sb.append(kf6Book.getSectionText(section))
+        }
+
+        val soup = Jsoup.parse(sb.toString())
+
+        soup.select("title").remove()
+        soup.select("[style*=display:none]").remove()
+        soup.select("img[recindex]").forEach {
+            val recindex = it.attr("recindex")
+            it.clearAttributes()
+            it.attr("src", "recindex:$recindex")
+        }
+
+        return format(soup.outerHtml())
+    }
+
+    private fun getContentKF8(kf8Book: KF8Book, chapter: BookChapter): String? {
+        if (chapter.isVolume && chapter.url.startsWith("skip:")) return ""
+        var section = kf8Book.getSectionByHref(chapter.url) ?: return null
+        val nextSectionHref = chapter.getVariable("nextUrl")
+        val nextPos = kf8Book.parsePosURI(nextSectionHref)
+
+        val sb = StringBuilder()
+        sb.append(kf8Book.getTextByHref(chapter.url, nextSectionHref))
+        while (true) {
+            if (nextPos != null && section.frags.any { it.index == nextPos.fid }) {
+                break
+            }
+            section = section.next ?: break
+            if (section.linear) {
+                continue
+            }
+            if (section.href == nextSectionHref) {
+                break
+            }
+            if (kf8Book.sectionIdMap[section.index] != null) {
+                break
+            }
+            sb.append(kf8Book.getSectionText(section))
+        }
+
+        val soup = Jsoup.parse(sb.toString())
+
+        soup.select("title").remove()
+        soup.select("[style*=display:none]").remove()
+
+        return format(soup.outerHtml())
+    }
+
+    private fun format(html: String): String {
+        return HtmlFormatter.formatKeepImg(html)
+            .replace(xmlDeclarationRegex, "")
+            .replace(doctypeDeclarationRegex, "")
+    }
+
+    private fun getImage(href: String): InputStream? {
+        return when (val book = mobiBook) {
+            is KF8Book -> getImageKF8(book, href)
+            is KF6Book -> getImageKF6(book, href)
+            else -> error("impossible condition")
+        }
+    }
+
+    private fun getImageKF6(kf6Book: KF6Book, href: String): InputStream? {
+        return kf6Book.getResourceByHref(href)?.inputStream()
+    }
+
+    private fun getImageKF8(kf8Book: KF8Book, href: String): InputStream? {
+        return kf8Book.getResourceByHref(href)?.inputStream()
+    }
+
+    private fun upBookCover() {
+        try {
+            mobiBook?.let {
+                if (book.coverUrl.isNullOrEmpty()) {
+                    book.coverUrl = LocalBook.getCoverPath(book)
+                }
+                it.getCover()?.let { bytes ->
+                    val bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.size)
+                    val file = FileUtils.createFileIfNotExist(book.coverUrl!!)
+                    FileOutputStream(file).use { out ->
+                        bitmap.compress(Bitmap.CompressFormat.JPEG, 90, out)
+                        out.flush()
+                    }
+                }
+            }
+        } catch (e: Exception) {
+            AppLog.put("加载书籍封面失败\n${e.localizedMessage}", e)
+            e.printOnDebug()
+        }
+    }
+
+    private fun upBookInfo() {
+        if (mobiBook == null) {
+            mFile = null
+            book.intro = "书籍导入异常"
+        } else {
+            upBookCover()
+            val metadata = mobiBook!!.metadata
+            book.name = metadata.title
+            if (book.name.isEmpty()) {
+                book.name = book.originName.replace("(?i)\\.(mobi|azw3)$".toRegex(), "")
+            }
+            if (metadata.author.isNotEmpty()) {
+                book.author = metadata.author.first()
+            }
+            if (metadata.description.isNotBlank()) {
+                book.intro = HtmlFormatter.format(metadata.description)
+            }
+        }
+    }
+
+}

+ 5 - 0
app/src/main/java/io/legado/app/ui/book/read/ReadBookActivity.kt

@@ -45,6 +45,7 @@ import io.legado.app.help.book.isAudio
 import io.legado.app.help.book.isEpub
 import io.legado.app.help.book.isLocal
 import io.legado.app.help.book.isLocalTxt
+import io.legado.app.help.book.isMobi
 import io.legado.app.help.book.removeType
 import io.legado.app.help.config.AppConfig
 import io.legado.app.help.config.ReadBookConfig
@@ -59,6 +60,7 @@ import io.legado.app.model.ReadAloud
 import io.legado.app.model.ReadBook
 import io.legado.app.model.analyzeRule.AnalyzeRule
 import io.legado.app.model.localBook.EpubFile
+import io.legado.app.model.localBook.MobiFile
 import io.legado.app.receiver.TimeBatteryReceiver
 import io.legado.app.service.BaseReadAloudService
 import io.legado.app.ui.about.AppLogDialog
@@ -480,6 +482,9 @@ class ReadBookActivity : BaseReadBookActivity(),
                     BookHelp.clearCache(it)
                     EpubFile.clear()
                 }
+                if (it.isMobi) {
+                    MobiFile.clear()
+                }
                 loadChapterList(it)
             }