* Implement build profiles and feature policy for offline desktop builds * Introduce unified cross-platform Settings Hub * Refactor main settings into a hierarchical page-based navigation model * Refactor library projection to use shared multiplatform logic * Refactor UI state consumption by removing intermediate screen models * Introduce AndroidSharedStateBridge to centralize state mapping and reduction logic * Refactor state management for tabs, selection, and pinning to use shared bridge logic * Refactor file type management and validation into a centralized shared module * Centralize file type resolution and improve handling of unknown types * Centralize book import logic with SharedImportPlanner * Refactor magnifier geometry logic and coordinate mapping * Properly handle orientation changes in scroll-locked PDF reader * Add screen orientation controls to EPUB and PDF readers * Implement right-to-left (RTL) pagination support and refactor reader menus * Separate right-to-left pagination settings for PDF and EPUB * Ensure PDF page data is scoped by document key for multi tab support * Implement theme-aware link styling for the epub reader * Implement jump history for back and forward navigation in the epub reader * Improve locator handling and navigation logic in paginated reader mode * Implement stable pagination navigation and location tracking * Centralize banner message management and auto-dismiss logic in MainViewModel * Implement zoom and pan state preservation for PDF pan lock mode * Enhance reader navigation UI and workspace layout management in desktop app * Refactor reader navigation sidebar and relocate search controls in desktop app * Enhance reader UI with redesigned selection menus and bottom sheet overlays * Implement custom highlight palettes and reader theme customization in desktop app * Implement cross-platform modal layer and refine reader UI styling * Improve highlight accuracy and implement metadata enrichment on book open in desktop app * Implement two-page spread layout for paginated reader on desktop * Implement persistent caching for book loading and pagination in desktop app * Implement persistent caching for book loading and pagination in desktop app * Optimize reader settings updates by separating layout and appearance changes in desktop app * Improve desktop window branding and native Windows styling * Enhance reader selection interactions and UI across EPUB and PDF viewers in desktop app * Refine selection handle positioning and interaction logic * Implement EPUB selection debug logging and improve handle targeting * Optimize desktop book loading performance and UI responsiveness * Implement anchored zoom gestures and rendering optimizations for the Desktop PDF viewer. * Implement smooth zoom preview for the PDF reader in desktop app * Optimize PDF rendering performance and responsiveness in the desktop reader * Implement conditional diagnostic logging and update desktop build configuration * Implemented hierarchical TOC, custom scrollbars, and improved desktop modal handling * Added management options for annotations and highlights in the sidebar in desktop app * Implemented `SharedStableOutlinedTextField` and updated text input fields to use `TextFieldValue` for improved cursor and selection stability. * Refined library filters and enhanced OPDS functionality in desktop app * Improved EPUB pagination measurement and implemented layout diagnostic logging for desktop app * Added PPTX support including document parsing, rendering, and indexing * Improved PPTX rendering and layout accuracy * Implemented text autofit support for PPTX rendering * Enhanced PPTX rendering with support for custom geometry, automatic numbering, table styles, and image opacity * Improved EPUB pagination accuracy and added layout telemetry in desktop app * Improved folder synchronization with metadata-only mode and hashed sidecar management in desktop app * Implemented rich text font scaling and migrated desktop ink tools to custom pointer input handling * Implemented billing account obfuscation * Implemented hierarchical folder navigation and improved library selection functionality in desktop app * Implemented platform-aware directory resolution and multi-platform native library support for desktop * Added full-screen mode for the reader workspace * Added PDF zoom indicator and interactive vertical scrollbar with page tooltips * Refactored speech bubble prefetching to use a limited radius and improved ML detector initialization and lifecycle management * Updated PDF indexing to replace existing page text and removed search result item keys * Implemented "preparing" foreground notification for TTS service * Optimized PDF rendering performance by pre-calculating page-specific annotations * Refactored desktop packaging tasks and improved distribution configuration * Optimized EPUB parser memory usage and added path traversal protection * Refactored WorkManager monitoring logic and added work pruning * Implemented comprehensive resource cleanup and memory management for WebView-based components to prevent memory leaks * Implemented bitmap size limits and scaling to prevent canvas rendering errors * Split long text paragraphs into multiple semantic blocks during HTML parsing * Implemented local ActionMode for text selection to prevent platform crashes * Refactored PPTX text layout, optimized HtmlParser block detection, and improved banner dismissal logic * Added desktop startup splash screen and deferred WebView initialization * Reorganized settings hub and added separate PDF reader defaults * Implemented embedded cover extraction and metadata support for MOBI and FB2 formats * Implemented batching for MetadataExtractionWorker and optimized EPUB metadata extraction performance. * Implemented procedurally generated book covers and replaced static placeholders * Redesigned search UI with a top bar and results overlay in desktop app * Added PDF page gap and overlay visibility options and implemented DesktopBookImporter * Refactored PDF reader UI with tabbed inspector and improved theme background handling in desktop * Implemented PDF viewport persistence for zoom and scroll positions in desktop app * Improved desktop fullscreen implementation and state restoration * Implemented desktop window state persistence * Implemented flavor-based branding and ProGuard configuration for desktop builds * Implemented precise reader positioning and improved highlight rendering logic in desktop app * Added support for user-editable book metadata * Enhanced book metadata support and integrated info/edit dialogs * Implemented embedded EPUB metadata editing * Improved highlight mapping and added custom scrollbar styling for the reader. * Reduced desktop WebView bundle size by excluding unused locales and runtime files * Added neutral pan mode as the default PDF interaction state. * Refactored library empty states and updated primary navigation tabs in desktop app * Implemented native paginated reader and unified content rendering architecture in desktop epub reader * Implemented native EPUB image rendering for desktop and improved block layout spacing with margin collapsing. * Improved pagination overflow detection in desktop * Implemented multi-block text selection with interactive handles and CFI support in desktop epub pagination
728 lines
27 KiB
Kotlin
728 lines
27 KiB
Kotlin
package com.aryan.reader
|
|
|
|
import android.util.Xml
|
|
import org.xmlpull.v1.XmlPullParser
|
|
import java.io.InputStream
|
|
import java.net.URLDecoder
|
|
import java.nio.charset.Charset
|
|
import java.util.Base64
|
|
import java.util.zip.ZipInputStream
|
|
|
|
internal data class EmbeddedEbookMetadata(
|
|
val title: String? = null,
|
|
val author: String? = null,
|
|
val description: String? = null,
|
|
val seriesName: String? = null,
|
|
val seriesIndex: Double? = null,
|
|
val cover: EmbeddedEbookCover? = null
|
|
)
|
|
|
|
internal data class EmbeddedEbookCover(
|
|
val bytes: ByteArray,
|
|
val extension: String
|
|
)
|
|
|
|
internal object EmbeddedEbookMetadataExtractor {
|
|
private const val MAX_XML_ENTRY_BYTES = 512 * 1024
|
|
private const val MAX_COVER_BYTES = 24 * 1024 * 1024
|
|
private const val MAX_MOBI_HEADER_RECORD_BYTES = 4 * 1024 * 1024
|
|
private const val MAX_MOBI_RECORDS = 65_535
|
|
private const val MAX_MOBI_EXTH_RECORDS = 10_000
|
|
private const val MOBI_NOT_SET = -1
|
|
|
|
private val ebookCoverTypes = setOf(FileType.EPUB, FileType.MOBI, FileType.FB2)
|
|
private val rasterCoverExtensions = setOf("jpg", "jpeg", "png", "gif", "webp", "bmp")
|
|
|
|
fun canExtractEmbeddedCover(type: FileType): Boolean = type in ebookCoverTypes
|
|
|
|
fun extract(
|
|
type: FileType,
|
|
displayName: String,
|
|
openStream: () -> InputStream?,
|
|
extractCover: Boolean = true
|
|
): EmbeddedEbookMetadata {
|
|
return when (type) {
|
|
FileType.EPUB -> extractEpub(openStream, extractCover)
|
|
FileType.MOBI -> extractMobi(openStream, extractCover)
|
|
FileType.FB2 -> extractFb2(displayName, openStream, extractCover)
|
|
else -> EmbeddedEbookMetadata()
|
|
}
|
|
}
|
|
|
|
private fun extractEpub(openStream: () -> InputStream?, extractCover: Boolean): EmbeddedEbookMetadata {
|
|
val containerXml = openStream()?.use { input ->
|
|
readFirstZipTextEntry(input, MAX_XML_ENTRY_BYTES) { name ->
|
|
name.equals("META-INF/container.xml", ignoreCase = true)
|
|
}?.text
|
|
}
|
|
val declaredOpfPath = containerXml
|
|
?.let(::parseEpubRootfilePath)
|
|
?.let(::normalizeZipPath)
|
|
?.takeIf { it.isNotBlank() }
|
|
|
|
val opfEntry = declaredOpfPath
|
|
?.let { path ->
|
|
openStream()?.use { input ->
|
|
readFirstZipTextEntry(input, MAX_XML_ENTRY_BYTES) { name ->
|
|
name.equals(path, ignoreCase = true)
|
|
}
|
|
}
|
|
}
|
|
?: openStream()?.use { input ->
|
|
readFirstZipTextEntry(input, MAX_XML_ENTRY_BYTES) { name ->
|
|
name.endsWith(".opf", ignoreCase = true)
|
|
}
|
|
}
|
|
?: return EmbeddedEbookMetadata()
|
|
val opfPath = opfEntry.path
|
|
val opf = opfEntry.text
|
|
|
|
val basePath = opfPath.substringBeforeLast('/', missingDelimiterValue = "")
|
|
.let { if (it.isBlank()) "" else "$it/" }
|
|
val manifest = parseEpubManifest(opf)
|
|
val cover = if (extractCover) {
|
|
val coverItem = findExplicitEpubCover(opf, manifest)
|
|
coverItem
|
|
?.takeIf { it.rasterExtension != null }
|
|
?.let { item ->
|
|
val rawPath = resolveEpubZipPath(basePath, item.href)
|
|
val decodedPath = resolveEpubZipPath(basePath, item.href.percentDecodedOrSelf())
|
|
listOf(decodedPath, rawPath).distinct().firstNotNullOfOrNull { path ->
|
|
readZipEntryBytes(openStream, path, MAX_COVER_BYTES)?.let { bytes ->
|
|
EmbeddedEbookCover(bytes = bytes, extension = item.rasterExtension ?: "png")
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
null
|
|
}
|
|
|
|
return EmbeddedEbookMetadata(
|
|
title = opf.tagText("title"),
|
|
author = opf.tagText("creator"),
|
|
description = opf.tagInnerContent("description"),
|
|
seriesName = opf.metaContent("calibre:series"),
|
|
seriesIndex = opf.metaContent("calibre:series_index")?.toDoubleOrNull(),
|
|
cover = cover
|
|
)
|
|
}
|
|
|
|
private fun readFirstZipTextEntry(
|
|
input: InputStream,
|
|
maxBytes: Int,
|
|
matches: (String) -> Boolean
|
|
): ZipTextEntry? {
|
|
var result: ZipTextEntry? = null
|
|
ZipInputStream(input.buffered()).use { zip ->
|
|
while (true) {
|
|
val entry = zip.nextEntry ?: break
|
|
try {
|
|
if (entry.isDirectory) continue
|
|
val name = normalizeZipPath(entry.name)
|
|
if (matches(name)) {
|
|
result = zip.readBytesLimited(maxBytes)
|
|
?.toString(Charsets.UTF_8)
|
|
?.let { ZipTextEntry(path = name, text = it) }
|
|
break
|
|
}
|
|
} finally {
|
|
zip.closeEntry()
|
|
}
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
private fun readZipEntryBytes(
|
|
openStream: () -> InputStream?,
|
|
targetPath: String,
|
|
maxBytes: Int
|
|
): ByteArray? {
|
|
return openStream()?.use { input ->
|
|
var result: ByteArray? = null
|
|
ZipInputStream(input.buffered()).use { zip ->
|
|
while (true) {
|
|
val entry = zip.nextEntry ?: break
|
|
try {
|
|
if (!entry.isDirectory && normalizeZipPath(entry.name).equals(targetPath, ignoreCase = true)) {
|
|
result = zip.readBytesLimited(maxBytes)
|
|
break
|
|
}
|
|
} finally {
|
|
zip.closeEntry()
|
|
}
|
|
}
|
|
}
|
|
result
|
|
}
|
|
}
|
|
|
|
private fun parseEpubRootfilePath(containerXml: String): String? {
|
|
return Regex("""<rootfile\b[^>]*\bfull-path=["']([^"']+)["'][^>]*>""", RegexOption.IGNORE_CASE)
|
|
.find(containerXml)
|
|
?.groupValues
|
|
?.get(1)
|
|
?.decodeEntities()
|
|
?.takeIf { it.isNotBlank() }
|
|
}
|
|
|
|
private fun parseEpubManifest(opf: String): List<EpubManifestItem> {
|
|
return Regex("""<item\s+[^>]*>""", RegexOption.IGNORE_CASE)
|
|
.findAll(opf)
|
|
.mapNotNull { match ->
|
|
val item = match.value
|
|
val id = item.attr("id")
|
|
val href = item.attr("href")
|
|
if (id.isBlank() || href.isBlank()) {
|
|
null
|
|
} else {
|
|
EpubManifestItem(
|
|
id = id,
|
|
href = href.decodeEntities(),
|
|
mediaType = item.attr("media-type"),
|
|
properties = item.attr("properties")
|
|
)
|
|
}
|
|
}
|
|
.toList()
|
|
}
|
|
|
|
private fun findExplicitEpubCover(opf: String, manifest: List<EpubManifestItem>): EpubManifestItem? {
|
|
val coverId = Regex("""<meta\s+[^>]*>""", RegexOption.IGNORE_CASE)
|
|
.findAll(opf)
|
|
.firstOrNull { it.value.attr("name").equals("cover", ignoreCase = true) }
|
|
?.value
|
|
?.attr("content")
|
|
?.takeIf { it.isNotBlank() }
|
|
|
|
return manifest.firstOrNull { it.id == coverId }
|
|
?: manifest.firstOrNull { item ->
|
|
item.properties.split(Regex("\\s+")).any { it.equals("cover-image", ignoreCase = true) }
|
|
}
|
|
}
|
|
|
|
private fun extractFb2(
|
|
displayName: String,
|
|
openStream: () -> InputStream?,
|
|
extractCover: Boolean
|
|
): EmbeddedEbookMetadata {
|
|
return openStream()?.use { input ->
|
|
if (displayName.endsWith(".zip", ignoreCase = true)) {
|
|
ZipInputStream(input.buffered()).use { zip ->
|
|
var metadata: EmbeddedEbookMetadata? = null
|
|
while (true) {
|
|
val entry = zip.nextEntry ?: break
|
|
try {
|
|
if (!entry.isDirectory && entry.name.endsWith(".fb2", ignoreCase = true)) {
|
|
metadata = parseFb2Xml(zip, extractCover)
|
|
break
|
|
}
|
|
} finally {
|
|
zip.closeEntry()
|
|
}
|
|
}
|
|
metadata ?: EmbeddedEbookMetadata()
|
|
}
|
|
} else {
|
|
parseFb2Xml(input, extractCover)
|
|
}
|
|
} ?: EmbeddedEbookMetadata()
|
|
}
|
|
|
|
private fun parseFb2Xml(input: InputStream, extractCover: Boolean): EmbeddedEbookMetadata {
|
|
val parser = Xml.newPullParser()
|
|
parser.setFeature(XmlPullParser.FEATURE_PROCESS_NAMESPACES, false)
|
|
parser.setInput(input, null)
|
|
|
|
var title: String? = null
|
|
val authors = mutableListOf<String>()
|
|
var inAuthor = false
|
|
var inBody = false
|
|
var inCoverPage = false
|
|
val authorParts = mutableListOf<String>()
|
|
var coverImageId: String? = null
|
|
var cover: EmbeddedEbookCover? = null
|
|
|
|
var event = parser.eventType
|
|
while (event != XmlPullParser.END_DOCUMENT) {
|
|
when (event) {
|
|
XmlPullParser.START_TAG -> {
|
|
when (parser.name.localXmlName()) {
|
|
"body" -> inBody = true
|
|
"coverpage" -> {
|
|
if (!inBody) inCoverPage = true
|
|
}
|
|
"author" -> {
|
|
if (!inBody) {
|
|
inAuthor = true
|
|
authorParts.clear()
|
|
}
|
|
}
|
|
"book-title" -> {
|
|
if (title == null) {
|
|
title = parser.nextTextOrNull()
|
|
}
|
|
}
|
|
"first-name", "middle-name", "last-name", "nickname" -> {
|
|
if (inAuthor) {
|
|
parser.nextTextOrNull()?.let(authorParts::add)
|
|
}
|
|
}
|
|
"image" -> {
|
|
if (inCoverPage && coverImageId == null) {
|
|
coverImageId = parser.hrefAttr()?.removePrefix("#")?.takeIf { it.isNotBlank() }
|
|
}
|
|
}
|
|
"binary" -> {
|
|
val id = parser.attrValue("id")
|
|
val contentType = parser.attrValue("content-type")
|
|
val isExplicitCover = id != null && id == coverImageId
|
|
if (extractCover && cover == null && isExplicitCover) {
|
|
val encoded = parser.nextTextOrNull()
|
|
val decoded = encoded
|
|
?.takeIf { it.length <= MAX_COVER_BYTES * 2 }
|
|
?.decodeBase64OrNull()
|
|
val extension = extensionFromMimeType(contentType)
|
|
?: id?.rasterExtension()
|
|
?: decoded?.rasterExtensionFromMagic()
|
|
?: "jpg"
|
|
if (decoded != null && decoded.size <= MAX_COVER_BYTES && extension in rasterCoverExtensions) {
|
|
cover = EmbeddedEbookCover(bytes = decoded, extension = extension)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
XmlPullParser.END_TAG -> {
|
|
when (parser.name.localXmlName()) {
|
|
"body" -> inBody = false
|
|
"coverpage" -> inCoverPage = false
|
|
"author" -> {
|
|
if (inAuthor && authorParts.isNotEmpty()) {
|
|
authors += authorParts.joinToString(" ").replace(Regex("\\s+"), " ").trim()
|
|
}
|
|
inAuthor = false
|
|
authorParts.clear()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
event = parser.next()
|
|
}
|
|
|
|
return EmbeddedEbookMetadata(
|
|
title = title,
|
|
author = authors.distinct().joinToString(", ").takeIf { it.isNotBlank() },
|
|
cover = cover
|
|
)
|
|
}
|
|
|
|
private fun extractMobi(openStream: () -> InputStream?, extractCover: Boolean): EmbeddedEbookMetadata {
|
|
val scan = openStream()?.use { readMobiRecordScan(it) } ?: return EmbeddedEbookMetadata()
|
|
val header = scan.headerRecord
|
|
val headerInfo = parseMobiHeaderInfo(header)
|
|
val charset = when (headerInfo.encoding ?: 1252) {
|
|
65001 -> Charsets.UTF_8
|
|
1200 -> Charsets.UTF_16
|
|
1252 -> Charset.forName("windows-1252")
|
|
else -> Charsets.UTF_8
|
|
}
|
|
val exth = parseMobiExth(header, charset)
|
|
val cover = if (extractCover) {
|
|
headerInfo.imageIndex
|
|
?.let { imageIndex -> exth.coverOffset?.let { imageIndex + it } }
|
|
?.takeIf { it > 0 }
|
|
?.let { recordIndex -> readMobiRecord(openStream, scan.offsets, recordIndex, MAX_COVER_BYTES) }
|
|
?.takeIf { it.size <= MAX_COVER_BYTES }
|
|
?.let { imageBytes ->
|
|
imageBytes.rasterExtensionFromMagic()?.let { extension ->
|
|
EmbeddedEbookCover(bytes = imageBytes, extension = extension)
|
|
}
|
|
}
|
|
} else {
|
|
null
|
|
}
|
|
|
|
return EmbeddedEbookMetadata(
|
|
title = exth.title,
|
|
author = exth.author,
|
|
cover = cover
|
|
)
|
|
}
|
|
|
|
private fun readMobiRecordScan(input: InputStream): MobiRecordScan? {
|
|
val buffered = input.buffered()
|
|
val palmHeader = buffered.readExactBytesOrNull(78) ?: return null
|
|
val recordCount = palmHeader.u16(76)
|
|
if (recordCount <= 0 || recordCount > MAX_MOBI_RECORDS) return null
|
|
|
|
val recordTable = buffered.readExactBytesOrNull(recordCount * 8) ?: return null
|
|
val offsets = (0 until recordCount).map { index ->
|
|
recordTable.u32(index * 8).toInt()
|
|
}
|
|
val record0Start = offsets.getOrNull(0) ?: return null
|
|
val record0End = offsets.getOrNull(1)
|
|
if (record0Start < 78 + recordTable.size) return null
|
|
|
|
val headerRecord = readRecordFromCurrentStream(
|
|
input = buffered,
|
|
currentOffset = 78 + recordTable.size,
|
|
recordStart = record0Start,
|
|
recordEnd = record0End,
|
|
maxBytes = MAX_MOBI_HEADER_RECORD_BYTES
|
|
) ?: return null
|
|
|
|
return MobiRecordScan(offsets = offsets, headerRecord = headerRecord)
|
|
}
|
|
|
|
private fun readMobiRecord(
|
|
openStream: () -> InputStream?,
|
|
offsets: List<Int>,
|
|
recordIndex: Int,
|
|
maxBytes: Int
|
|
): ByteArray? {
|
|
val recordStart = offsets.getOrNull(recordIndex) ?: return null
|
|
val recordEnd = offsets.getOrNull(recordIndex + 1)
|
|
if (recordStart < 0) return null
|
|
|
|
return openStream()?.use { input ->
|
|
val buffered = input.buffered()
|
|
readRecordFromCurrentStream(
|
|
input = buffered,
|
|
currentOffset = 0,
|
|
recordStart = recordStart,
|
|
recordEnd = recordEnd,
|
|
maxBytes = maxBytes
|
|
)
|
|
}
|
|
}
|
|
|
|
private fun readRecordFromCurrentStream(
|
|
input: InputStream,
|
|
currentOffset: Int,
|
|
recordStart: Int,
|
|
recordEnd: Int?,
|
|
maxBytes: Int
|
|
): ByteArray? {
|
|
if (recordStart < currentOffset) return null
|
|
if (!input.skipFully((recordStart - currentOffset).toLong())) return null
|
|
|
|
val length = recordEnd?.minus(recordStart)
|
|
return if (length != null) {
|
|
if (length <= 0 || length > maxBytes) return null
|
|
input.readExactBytesOrNull(length)
|
|
} else {
|
|
input.readBytesLimited(maxBytes)
|
|
}
|
|
}
|
|
|
|
private fun parseMobiHeaderInfo(header: ByteArray): MobiHeaderInfo {
|
|
if (header.size < 32 || header.asciiAt(16, 4) != "MOBI") return MobiHeaderInfo()
|
|
val mobiHeaderLength = header.u32(20).toInt()
|
|
|
|
fun u32InHeader(offset: Int): Int? {
|
|
if (mobiHeaderLength < offset + 4 || 16 + offset + 4 > header.size) return null
|
|
return header.u32(16 + offset).toInt()
|
|
.takeIf { it >= 0 && it != MOBI_NOT_SET }
|
|
}
|
|
|
|
return MobiHeaderInfo(
|
|
encoding = u32InHeader(12),
|
|
imageIndex = u32InHeader(92)
|
|
)
|
|
}
|
|
|
|
private fun parseMobiExth(header: ByteArray, charset: Charset): MobiExthMetadata {
|
|
if (header.size < 92 || header.asciiAt(16, 4) != "MOBI") return MobiExthMetadata()
|
|
val mobiHeaderLength = header.u32(20).toInt()
|
|
val fullNameOffset = header.u32(16 + 68).toInt()
|
|
val fullNameLength = header.u32(16 + 72).toInt()
|
|
val fullName = header.safeString(fullNameOffset, fullNameLength, charset)
|
|
var exthTitle: String? = null
|
|
var author: String? = null
|
|
var coverOffset: Int? = null
|
|
val exthOffsetLong = 16L + mobiHeaderLength
|
|
if (mobiHeaderLength <= 0 || exthOffsetLong > Int.MAX_VALUE - 12L) {
|
|
return MobiExthMetadata(title = fullName.takeUnlessBlank())
|
|
}
|
|
val exthOffset = exthOffsetLong.toInt()
|
|
|
|
if (exthOffset + 12 <= header.size && header.asciiAt(exthOffset, 4) == "EXTH") {
|
|
val recordCount = header.u32(exthOffset + 8).toInt()
|
|
var offset = exthOffset + 12
|
|
repeat(recordCount.coerceIn(0, MAX_MOBI_EXTH_RECORDS)) {
|
|
if (offset + 8 > header.size) return@repeat
|
|
val type = header.u32(offset).toInt()
|
|
val size = header.u32(offset + 4).toInt()
|
|
if (size < 8 || offset + size > header.size) return@repeat
|
|
val dataOffset = offset + 8
|
|
val dataSize = size - 8
|
|
when (type) {
|
|
99 -> exthTitle = exthTitle ?: header.safeString(dataOffset, dataSize, charset)
|
|
100 -> author = author ?: header.safeString(dataOffset, dataSize, charset)
|
|
201 -> coverOffset = coverOffset ?: header.u32(dataOffset).toInt().takeIf { dataSize >= 4 }
|
|
503 -> exthTitle = exthTitle ?: header.safeString(dataOffset, dataSize, charset)
|
|
}
|
|
offset += size
|
|
}
|
|
}
|
|
|
|
return MobiExthMetadata(
|
|
title = exthTitle.takeUnlessBlank() ?: fullName.takeUnlessBlank(),
|
|
author = author.takeUnlessBlank(),
|
|
coverOffset = coverOffset
|
|
)
|
|
}
|
|
|
|
private fun XmlPullParser.nextTextOrNull(): String? {
|
|
return try {
|
|
nextText()?.trim()?.takeIf { it.isNotBlank() }
|
|
} catch (_: Exception) {
|
|
null
|
|
}
|
|
}
|
|
|
|
private fun XmlPullParser.attrValue(localName: String): String? {
|
|
for (index in 0 until attributeCount) {
|
|
val name = getAttributeName(index).localXmlName()
|
|
if (name.equals(localName, ignoreCase = true)) {
|
|
return getAttributeValue(index)?.takeIf { it.isNotBlank() }
|
|
}
|
|
}
|
|
return null
|
|
}
|
|
|
|
private fun XmlPullParser.hrefAttr(): String? {
|
|
return attrValue("href")
|
|
?: getAttributeValue("http://www.w3.org/1999/xlink", "href")?.takeIf { it.isNotBlank() }
|
|
}
|
|
|
|
private fun InputStream.readBytesLimited(maxBytes: Int): ByteArray? {
|
|
val output = java.io.ByteArrayOutputStream()
|
|
val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
|
|
var total = 0
|
|
while (true) {
|
|
val read = read(buffer)
|
|
if (read == -1) break
|
|
total += read
|
|
if (total > maxBytes) return null
|
|
output.write(buffer, 0, read)
|
|
}
|
|
return output.toByteArray()
|
|
}
|
|
|
|
private fun InputStream.readExactBytesOrNull(size: Int): ByteArray? {
|
|
if (size < 0) return null
|
|
val bytes = ByteArray(size)
|
|
var offset = 0
|
|
while (offset < size) {
|
|
val read = read(bytes, offset, size - offset)
|
|
if (read == -1) return null
|
|
offset += read
|
|
}
|
|
return bytes
|
|
}
|
|
|
|
private fun InputStream.skipFully(bytes: Long): Boolean {
|
|
var remaining = bytes
|
|
val scratch = ByteArray(DEFAULT_BUFFER_SIZE)
|
|
while (remaining > 0L) {
|
|
val skipped = skip(remaining)
|
|
if (skipped > 0L) {
|
|
remaining -= skipped
|
|
continue
|
|
}
|
|
|
|
val read = read(scratch, 0, minOf(scratch.size.toLong(), remaining).toInt())
|
|
if (read == -1) return false
|
|
remaining -= read
|
|
}
|
|
return true
|
|
}
|
|
|
|
private fun String.decodeBase64OrNull(): ByteArray? {
|
|
return runCatching {
|
|
Base64.getMimeDecoder().decode(this)
|
|
}.getOrNull()
|
|
}
|
|
|
|
private fun String.attr(name: String): String {
|
|
return Regex("""\b$name=["']([^"']+)["']""", RegexOption.IGNORE_CASE)
|
|
.find(this)
|
|
?.groupValues
|
|
?.get(1)
|
|
.orEmpty()
|
|
}
|
|
|
|
private fun String.tagText(tag: String): String? {
|
|
return Regex(
|
|
"<(?:[^:>]+:)?$tag\\b[^>]*>(.*?)</(?:[^:>]+:)?$tag>",
|
|
setOf(RegexOption.IGNORE_CASE, RegexOption.DOT_MATCHES_ALL)
|
|
)
|
|
.find(this)
|
|
?.groupValues
|
|
?.get(1)
|
|
?.replace(Regex("<[^>]+>"), " ")
|
|
?.decodeEntities()
|
|
?.replace(Regex("\\s+"), " ")
|
|
?.trim()
|
|
?.takeIf { it.isNotBlank() }
|
|
}
|
|
|
|
private fun String.tagInnerContent(tag: String): String? {
|
|
return Regex(
|
|
"<(?:[^:>]+:)?$tag\\b[^>]*>(.*?)</(?:[^:>]+:)?$tag>",
|
|
setOf(RegexOption.IGNORE_CASE, RegexOption.DOT_MATCHES_ALL)
|
|
)
|
|
.find(this)
|
|
?.groupValues
|
|
?.get(1)
|
|
?.decodeEntities()
|
|
?.trim()
|
|
?.takeIf { it.isNotBlank() }
|
|
}
|
|
|
|
private fun String.metaContent(name: String): String? {
|
|
return Regex("""<meta\s+[^>]*>""", RegexOption.IGNORE_CASE)
|
|
.findAll(this)
|
|
.firstOrNull { it.value.attr("name").equals(name, ignoreCase = true) }
|
|
?.value
|
|
?.attr("content")
|
|
?.decodeEntities()
|
|
?.trim()
|
|
?.takeIf { it.isNotBlank() }
|
|
}
|
|
|
|
private fun String.decodeEntities(): String {
|
|
return replace(" ", " ")
|
|
.replace("&", "&")
|
|
.replace("<", "<")
|
|
.replace(">", ">")
|
|
.replace(""", "\"")
|
|
.replace("'", "'")
|
|
.replace(Regex("&#x([0-9a-fA-F]+);")) { match ->
|
|
match.groupValues[1].toIntOrNull(16)?.toChar()?.toString().orEmpty()
|
|
}
|
|
.replace(Regex("&#(\\d+);")) { match ->
|
|
match.groupValues[1].toIntOrNull()?.toChar()?.toString().orEmpty()
|
|
}
|
|
}
|
|
|
|
private fun normalizeZipPath(path: String): String {
|
|
val parts = ArrayDeque<String>()
|
|
path.replace('\\', '/').trimStart('/').split('/').forEach { part ->
|
|
when (part) {
|
|
"", "." -> Unit
|
|
".." -> if (parts.isNotEmpty()) parts.removeLast()
|
|
else -> parts.addLast(part)
|
|
}
|
|
}
|
|
return parts.joinToString("/")
|
|
}
|
|
|
|
private fun resolveEpubZipPath(basePath: String, href: String): String {
|
|
return normalizeZipPath(if (href.startsWith('/')) href else basePath + href)
|
|
}
|
|
|
|
private fun String.percentDecodedOrSelf(): String {
|
|
return runCatching { URLDecoder.decode(this, Charsets.UTF_8.name()) }.getOrDefault(this)
|
|
}
|
|
|
|
private fun String.localXmlName(): String = substringAfter(':').lowercase()
|
|
|
|
private fun String.rasterExtension(): String? {
|
|
val extension = substringBefore('?')
|
|
.substringBefore('#')
|
|
.substringAfterLast('.', missingDelimiterValue = "")
|
|
.lowercase()
|
|
return extension.takeIf { it in rasterCoverExtensions }
|
|
}
|
|
|
|
private fun extensionFromMimeType(mimeType: String?): String? {
|
|
return when (mimeType?.lowercase()) {
|
|
"image/jpeg", "image/jpg" -> "jpg"
|
|
"image/png" -> "png"
|
|
"image/gif" -> "gif"
|
|
"image/webp" -> "webp"
|
|
"image/bmp" -> "bmp"
|
|
else -> null
|
|
}
|
|
}
|
|
|
|
private fun ByteArray.rasterExtensionFromMagic(): String? {
|
|
return when {
|
|
size >= 3 &&
|
|
(this[0].toInt() and 0xFF) == 0xFF &&
|
|
(this[1].toInt() and 0xFF) == 0xD8 &&
|
|
(this[2].toInt() and 0xFF) == 0xFF -> "jpg"
|
|
size >= 8 && asciiAt(1, 3) == "PNG" -> "png"
|
|
size >= 6 && (asciiAt(0, 6) == "GIF87a" || asciiAt(0, 6) == "GIF89a") -> "gif"
|
|
size >= 12 && asciiAt(0, 4) == "RIFF" && asciiAt(8, 4) == "WEBP" -> "webp"
|
|
size >= 2 && asciiAt(0, 2) == "BM" -> "bmp"
|
|
else -> null
|
|
}
|
|
}
|
|
|
|
private fun ByteArray.u16(offset: Int): Int {
|
|
if (offset + 2 > size) return 0
|
|
return ((this[offset].toInt() and 0xFF) shl 8) or (this[offset + 1].toInt() and 0xFF)
|
|
}
|
|
|
|
private fun ByteArray.u32(offset: Int): Long {
|
|
if (offset + 4 > size) return 0
|
|
return ((this[offset].toLong() and 0xFF) shl 24) or
|
|
((this[offset + 1].toLong() and 0xFF) shl 16) or
|
|
((this[offset + 2].toLong() and 0xFF) shl 8) or
|
|
(this[offset + 3].toLong() and 0xFF)
|
|
}
|
|
|
|
private fun ByteArray.asciiAt(offset: Int, length: Int): String {
|
|
if (offset < 0 || offset + length > size) return ""
|
|
return copyOfRange(offset, offset + length).toString(Charsets.US_ASCII)
|
|
}
|
|
|
|
private fun ByteArray.safeString(offset: Int, length: Int, charset: Charset): String? {
|
|
if (offset < 0 || length <= 0 || offset + length > size) return null
|
|
return copyOfRange(offset, offset + length).toString(charset)
|
|
.trim('\u0000', ' ', '\n', '\r', '\t')
|
|
.takeUnlessBlank()
|
|
}
|
|
|
|
private fun String?.takeUnlessBlank(): String? {
|
|
return this?.trim()?.takeIf { it.isNotBlank() }
|
|
}
|
|
|
|
private val EpubManifestItem.rasterExtension: String?
|
|
get() {
|
|
href.rasterExtension()?.let { return it }
|
|
return extensionFromMimeType(mediaType)
|
|
}
|
|
|
|
private data class ZipTextEntry(
|
|
val path: String,
|
|
val text: String
|
|
)
|
|
|
|
private data class EpubManifestItem(
|
|
val id: String,
|
|
val href: String,
|
|
val mediaType: String,
|
|
val properties: String
|
|
)
|
|
|
|
private data class MobiHeaderInfo(
|
|
val encoding: Int? = null,
|
|
val imageIndex: Int? = null
|
|
)
|
|
|
|
private data class MobiRecordScan(
|
|
val offsets: List<Int>,
|
|
val headerRecord: ByteArray
|
|
)
|
|
|
|
private data class MobiExthMetadata(
|
|
val title: String? = null,
|
|
val author: String? = null,
|
|
val coverOffset: Int? = null
|
|
)
|
|
}
|