This commit is contained in:
Horis
2024-07-12 10:17:58 +08:00
parent ea17860c3d
commit d6e146b862
39 changed files with 1972 additions and 15 deletions
@@ -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)
@@ -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)
@@ -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()
}
}
@@ -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()
}
}
@@ -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))
}
}
@@ -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")
)
}
}
@@ -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()
}
}
@@ -0,0 +1,6 @@
package io.legado.app.lib.mobi.decompress
class CDICEntry(
var data: ByteArray,
var decompressed: Boolean
)
@@ -0,0 +1,7 @@
package io.legado.app.lib.mobi.decompress
interface Decompressor {
fun decompress(data: ByteArray): ByteArray
}
@@ -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
}
}
@@ -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) }
}
}
@@ -0,0 +1,9 @@
package io.legado.app.lib.mobi.decompress
class PlainDecompressor : Decompressor {
override fun decompress(data: ByteArray): ByteArray {
return data
}
}
@@ -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
)
@@ -0,0 +1,6 @@
package io.legado.app.lib.mobi.entities
data class FdstHeader(
val magic: String,
val numEntries: Int
)
@@ -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
)
@@ -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>
)
@@ -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>
)
@@ -0,0 +1,6 @@
package io.legado.app.lib.mobi.entities
data class IndexTag(
val tagId: Int,
val tagValues: List<Int>
)
@@ -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,
)
@@ -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
)
@@ -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,
)
@@ -0,0 +1,6 @@
package io.legado.app.lib.mobi.entities
data class KF8Pos(
val fid: Int,
val offset: Int
)
@@ -0,0 +1,7 @@
package io.legado.app.lib.mobi.entities
data class KF8Resource(
val resourceType: String,
val id: Int,
val type: String
)
@@ -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()
}
@@ -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?
)
@@ -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
)
@@ -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
)
@@ -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
)
@@ -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
)
@@ -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?
)
@@ -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
)
@@ -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
)
@@ -0,0 +1,7 @@
package io.legado.app.lib.mobi.entities
data class TagxHeader(
val magic: String,
val length: Int,
val numControlBytes: Int
)
@@ -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,
)
@@ -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()
}
@@ -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
@@ -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)
}
}
@@ -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)
}
}
}
}
@@ -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)
}