diff --git a/app/src/main/java/io/legado/app/constant/AppPattern.kt b/app/src/main/java/io/legado/app/constant/AppPattern.kt index 5eeeb7cf3..fafe15ccf 100644 --- a/app/src/main/java/io/legado/app/constant/AppPattern.kt +++ b/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) diff --git a/app/src/main/java/io/legado/app/help/book/BookExtensions.kt b/app/src/main/java/io/legado/app/help/book/BookExtensions.kt index c6b4efd25..debb428e2 100644 --- a/app/src/main/java/io/legado/app/help/book/BookExtensions.kt +++ b/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) diff --git a/app/src/main/java/io/legado/app/lib/mobi/KF6Book.kt b/app/src/main/java/io/legado/app/lib/mobi/KF6Book.kt new file mode 100644 index 000000000..740a79870 --- /dev/null +++ b/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 + lateinit var sectionIdMap: LinkedHashMap> + + 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() + 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() + } + +} diff --git a/app/src/main/java/io/legado/app/lib/mobi/KF8Book.kt b/app/src/main/java/io/legado/app/lib/mobi/KF8Book.kt new file mode 100644 index 000000000..8567e3439 --- /dev/null +++ b/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 + private lateinit var fragTable: List + private var kf8 = headers.kf8!! + lateinit var sections: List + lateinit var sectionIdMap: LinkedHashMap> + + 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.. + 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.. 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 ?: emptyList(), + exth["publisher"] as? String ?: "", + exth["language"] as? String ?: mobi.languege, + exth["date"] as? String ?: "", + exth["description"] as? String ?: "", + exth["subject"] as? List ?: emptyList(), + exth["rights"] as? String ?: "" + ) + } + + var toc: List? = null + + private var textRecordOffsets = arrayListOf() + + init { + buildTextRecordOffsets() + } + + private fun buildTextRecordOffsets() { + var offset = 0 + for (i in 0..= 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..? { + 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>() + + 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() + + for (i in 0.., + idxtOffset: Int + ): IndexEntry { + val array = indxBuffer.array() + + val len = indxBuffer.readUInt8(idxtOffset) + val label = indxBuffer.readString(idxtOffset + 1, len) + + val ptagxs = arrayListOf() + 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..() + val tagMap = SparseArray() + + for (ptagx in ptagxs) { + val values = arrayListOf() + if (ptagx.valueCount != null) { + repeat(ptagx.valueCount * ptagx.tagValueCount) { + var v = 0 + for (a in pos.. { + val numTags = (tagx.length - 12) / 4 + val tags = arrayListOf() + tagxBuffer.position(12) + for (i in 0.. { + val cncx = SparseArray() + var cncxRecordOffset = 0 + for (i in 0..= 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 { + val magic = buffer.readString(0, 4) + check(magic == "EXTH") { "Invalid EXTH header" } + val count = buffer.readUInt32(8) + var offset = 12 + val map = HashMap() + for (i in 0..() + } + @Suppress("UNCHECKED_CAST") + val array = map[name] as ArrayList + 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") + ) + } + +} diff --git a/app/src/main/java/io/legado/app/lib/mobi/PDBFile.kt b/app/src/main/java/io/legado/app/lib/mobi/PDBFile.kt new file mode 100644 index 000000000..dfa0c739f --- /dev/null +++ b/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() + } + +} diff --git a/app/src/main/java/io/legado/app/lib/mobi/decompress/CDICData.kt b/app/src/main/java/io/legado/app/lib/mobi/decompress/CDICData.kt new file mode 100644 index 000000000..980dd4fa2 --- /dev/null +++ b/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 +) diff --git a/app/src/main/java/io/legado/app/lib/mobi/decompress/Decompressor.kt b/app/src/main/java/io/legado/app/lib/mobi/decompress/Decompressor.kt new file mode 100644 index 000000000..a19352bb7 --- /dev/null +++ b/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 + +} diff --git a/app/src/main/java/io/legado/app/lib/mobi/decompress/HuffcdicDecompressor.kt b/app/src/main/java/io/legado/app/lib/mobi/decompress/HuffcdicDecompressor.kt new file mode 100644 index 000000000..d7f0a1df0 --- /dev/null +++ b/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() + + 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.. 0 && bytesLeft-- > 0) { + value = value or ((get().toLong() and 0xFF) shl (i * 8)) + } + return value + } + +} diff --git a/app/src/main/java/io/legado/app/lib/mobi/decompress/Lz77Decompressor.kt b/app/src/main/java/io/legado/app/lib/mobi/decompress/Lz77Decompressor.kt new file mode 100644 index 000000000..91ed22b01 --- /dev/null +++ b/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(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) } + } + +} diff --git a/app/src/main/java/io/legado/app/lib/mobi/decompress/PlainDecompressor.kt b/app/src/main/java/io/legado/app/lib/mobi/decompress/PlainDecompressor.kt new file mode 100644 index 000000000..7bc4ce743 --- /dev/null +++ b/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 + } + +} diff --git a/app/src/main/java/io/legado/app/lib/mobi/entities/ExthRecordType.kt b/app/src/main/java/io/legado/app/lib/mobi/entities/ExthRecordType.kt new file mode 100644 index 000000000..b2746d6be --- /dev/null +++ b/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 +) diff --git a/app/src/main/java/io/legado/app/lib/mobi/entities/FdstHeader.kt b/app/src/main/java/io/legado/app/lib/mobi/entities/FdstHeader.kt new file mode 100644 index 000000000..61fb1b63e --- /dev/null +++ b/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 +) diff --git a/app/src/main/java/io/legado/app/lib/mobi/entities/Fragment.kt b/app/src/main/java/io/legado/app/lib/mobi/entities/Fragment.kt new file mode 100644 index 000000000..50e647f6d --- /dev/null +++ b/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 +) diff --git a/app/src/main/java/io/legado/app/lib/mobi/entities/IndexData.kt b/app/src/main/java/io/legado/app/lib/mobi/entities/IndexData.kt new file mode 100644 index 000000000..962d99efe --- /dev/null +++ b/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, + val cncx: SparseArray +) diff --git a/app/src/main/java/io/legado/app/lib/mobi/entities/IndexEntry.kt b/app/src/main/java/io/legado/app/lib/mobi/entities/IndexEntry.kt new file mode 100644 index 000000000..717a51997 --- /dev/null +++ b/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, + val tagMap: SparseArray +) diff --git a/app/src/main/java/io/legado/app/lib/mobi/entities/IndexTag.kt b/app/src/main/java/io/legado/app/lib/mobi/entities/IndexTag.kt new file mode 100644 index 000000000..8de50d2b4 --- /dev/null +++ b/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 +) diff --git a/app/src/main/java/io/legado/app/lib/mobi/entities/IndxHeader.kt b/app/src/main/java/io/legado/app/lib/mobi/entities/IndxHeader.kt new file mode 100644 index 000000000..7c13bcc81 --- /dev/null +++ b/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, +) diff --git a/app/src/main/java/io/legado/app/lib/mobi/entities/KF6Section.kt b/app/src/main/java/io/legado/app/lib/mobi/entities/KF6Section.kt new file mode 100644 index 000000000..b15ccf487 --- /dev/null +++ b/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 +) diff --git a/app/src/main/java/io/legado/app/lib/mobi/entities/KF8Header.kt b/app/src/main/java/io/legado/app/lib/mobi/entities/KF8Header.kt new file mode 100644 index 000000000..c5c639b2e --- /dev/null +++ b/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, +) diff --git a/app/src/main/java/io/legado/app/lib/mobi/entities/KF8Pos.kt b/app/src/main/java/io/legado/app/lib/mobi/entities/KF8Pos.kt new file mode 100644 index 000000000..e317a76ca --- /dev/null +++ b/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 +) diff --git a/app/src/main/java/io/legado/app/lib/mobi/entities/KF8Resource.kt b/app/src/main/java/io/legado/app/lib/mobi/entities/KF8Resource.kt new file mode 100644 index 000000000..eb2b51d79 --- /dev/null +++ b/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 +) diff --git a/app/src/main/java/io/legado/app/lib/mobi/entities/KF8Section.kt b/app/src/main/java/io/legado/app/lib/mobi/entities/KF8Section.kt new file mode 100644 index 000000000..781cf9f33 --- /dev/null +++ b/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, + val fragEnd: Int, + val length: Int, + val totalLength: Int, + val href: String, + var next: KF8Section? = null +) { + val linear get() = frags.isNotEmpty() +} diff --git a/app/src/main/java/io/legado/app/lib/mobi/entities/MobiEntryHeaders.kt b/app/src/main/java/io/legado/app/lib/mobi/entities/MobiEntryHeaders.kt new file mode 100644 index 000000000..0194dbdb9 --- /dev/null +++ b/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, + val kf8: KF8Header? +) diff --git a/app/src/main/java/io/legado/app/lib/mobi/entities/MobiHeader.kt b/app/src/main/java/io/legado/app/lib/mobi/entities/MobiHeader.kt new file mode 100644 index 000000000..827f1eaae --- /dev/null +++ b/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 +) diff --git a/app/src/main/java/io/legado/app/lib/mobi/entities/MobiMetadata.kt b/app/src/main/java/io/legado/app/lib/mobi/entities/MobiMetadata.kt new file mode 100644 index 000000000..5655e2704 --- /dev/null +++ b/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, + val publisher: String, + val language: String, + val published: String, + val description: String, + val subject: List, + val rights: String +) diff --git a/app/src/main/java/io/legado/app/lib/mobi/entities/NCX.kt b/app/src/main/java/io/legado/app/lib/mobi/entities/NCX.kt new file mode 100644 index 000000000..4aed57f83 --- /dev/null +++ b/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?, + val parent: Int?, + val firstChild: Int?, + val lastChild: Int?, + var children: List? = null +) diff --git a/app/src/main/java/io/legado/app/lib/mobi/entities/PalmDocHeader.kt b/app/src/main/java/io/legado/app/lib/mobi/entities/PalmDocHeader.kt new file mode 100644 index 000000000..c985a02fc --- /dev/null +++ b/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 +) diff --git a/app/src/main/java/io/legado/app/lib/mobi/entities/Ptagx.kt b/app/src/main/java/io/legado/app/lib/mobi/entities/Ptagx.kt new file mode 100644 index 000000000..adb2a3d01 --- /dev/null +++ b/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? +) diff --git a/app/src/main/java/io/legado/app/lib/mobi/entities/Skeleton.kt b/app/src/main/java/io/legado/app/lib/mobi/entities/Skeleton.kt new file mode 100644 index 000000000..cd6979687 --- /dev/null +++ b/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 +) diff --git a/app/src/main/java/io/legado/app/lib/mobi/entities/TOC.kt b/app/src/main/java/io/legado/app/lib/mobi/entities/TOC.kt new file mode 100644 index 000000000..d47f31f39 --- /dev/null +++ b/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? = null +) diff --git a/app/src/main/java/io/legado/app/lib/mobi/entities/TagxHeader.kt b/app/src/main/java/io/legado/app/lib/mobi/entities/TagxHeader.kt new file mode 100644 index 000000000..0925ddb27 --- /dev/null +++ b/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 +) diff --git a/app/src/main/java/io/legado/app/lib/mobi/entities/TagxTag.kt b/app/src/main/java/io/legado/app/lib/mobi/entities/TagxTag.kt new file mode 100644 index 000000000..2b02edf7c --- /dev/null +++ b/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, +) diff --git a/app/src/main/java/io/legado/app/lib/mobi/utils/ByteBufferExtensions.kt b/app/src/main/java/io/legado/app/lib/mobi/utils/ByteBufferExtensions.kt new file mode 100644 index 000000000..4c933da85 --- /dev/null +++ b/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() +} + diff --git a/app/src/main/java/io/legado/app/model/ImageProvider.kt b/app/src/main/java/io/legado/app/model/ImageProvider.kt index c0a99fb7f..c86aa6dc9 100644 --- a/app/src/main/java/io/legado/app/model/ImageProvider.kt +++ b/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 diff --git a/app/src/main/java/io/legado/app/model/localBook/LocalBook.kt b/app/src/main/java/io/legado/app/model/localBook/LocalBook.kt index 96e4c787c..f9b779575 100644 --- a/app/src/main/java/io/legado/app/model/localBook/LocalBook.kt +++ b/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) } } diff --git a/app/src/main/java/io/legado/app/model/localBook/MobiFile.kt b/app/src/main/java/io/legado/app/model/localBook/MobiFile.kt new file mode 100644 index 000000000..94afaa912 --- /dev/null +++ b/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 = "]*>".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 { + 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 { + return when (val book = mobiBook) { + is KF8Book -> getChapterListKF8(book) + is KF6Book -> getChapterListKF6(book) + else -> error("impossible condition") + } + } + + private fun getChapterListKF6(kF6Book: KF6Book): ArrayList { + val chapterList = arrayListOf() + 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 { + val chapterList = arrayListOf() + 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) + } + } + } + +} diff --git a/app/src/main/java/io/legado/app/ui/book/read/ReadBookActivity.kt b/app/src/main/java/io/legado/app/ui/book/read/ReadBookActivity.kt index 162025429..0b831b970 100644 --- a/app/src/main/java/io/legado/app/ui/book/read/ReadBookActivity.kt +++ b/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) }