v1.0.47 (#279)
* Add performance and stylus debugging logs * Refactor and decouple UI models from `MainViewModel` * Refactor library state management and projection logic * Implement desktop shell using Compose Multiplatform * Implement desktop shell using Compose Multiplatform * Implement desktop shell using Compose Multiplatform * Introduce ReaderEngine and enhance EPUB reader features in windows app * Move core paginated reader logic to a Kotlin Multiplatform `shared` module and introduce experimental desktop support. * Implement PDF rendering and text extraction for desktop using Pdfium * Add `NonReaderScreens.kt` and UI dependencies * Refactor and centralize library state management and models to improve cross-platform consistency * Implement JSON persistence for desktop library and enhance library management features including shelf CRUD, tagging, and metadata editing * Implement PDF annotation system and enhanced zoom controls for the desktop viewer * Implement WebView-based EPUB rendering for desktop using CEF and embedded resources * Optimize UI state projection, navigation state handling, and main screen pager performance * Implement Bring Your Own Key (BYOK) support for AI features in OSS version * Support Gemini-based Cloud TTS with BYOK support for OSS builds * Refactor table cell image sizing in `PaginatedReader` and improve `MobiParser` native library loading and error handling. * crash fixes * Enhance navigation stability with lifecycle-aware safety checks and update `navigation-compose` to 2.9.6 * Implement dynamic bottom padding for the page info bar to account for device rounded corners * Implement bidirectional jump history navigation and replace the jump-back pill with a dedicated `PdfJumpHistoryBar` * Optimize PDF tiling performance and refine pan-and-fling gesture handling * Implement customizable toolbars with drag-and-drop reordering and placement for PDF and EPUB readers * Updated UI for customize toolbar * Refine drag-and-drop reordering and section assignment for PDF and EPUB reader controls * restructure PDF viewer UI component hierarchy to fix verifier crash * Implement separate text dimming factors for light and dark themes * Synchronize Pdfium access and improve resource lifecycle safety across Kotlin and native layers * Enhance image alignment in paginated and EPUB readers through anchor detection and style-based positioning * Centralize file type resolution logic and implement HTML sanitization during import * Introduce vertical margin customization and configurable progress bar positioning * texture support in epub reader * Enhance TTS session management, progress tracking, and diagnostic logging * Optimize library state projection and folder synchronization performance by refactoring collection lookups and refining metadata extraction logic. * Refine TTS page mapping for PDF and overhaul TTS control UI * Implement natural session completion logic in `TtsPlaybackManager` for cloud tts * Replace Snackbar with `CustomTopBanner` for notifications in `PdfViewerScreen` * Refine TTS playback continuity across PDF pages and improve state management for session transitions * Implement global texture transparency and enhance textured theme support across PDF and EPUB readers. * Update reader themes and improve texture rendering in page animations, EPUB UI, and immersive mode * Add Support Project screen * Optimize library performance via projection caching, batch database updates, and scoped folder synchronization. * Enhance folder synchronization with fallback query mechanisms and refactor annotation sidecar importing logic * Bump version to 1.0.47 (51)
This commit is contained in:
parent
f42de6b462
commit
d7a9cae9e1
126 changed files with 15287 additions and 3154 deletions
|
|
@ -0,0 +1,282 @@
|
|||
package com.aryan.reader.desktop
|
||||
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.unit.Constraints
|
||||
import androidx.compose.ui.unit.Density
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.aryan.reader.paginatedreader.CssParser
|
||||
import com.aryan.reader.paginatedreader.OptimizedCssRules
|
||||
import com.aryan.reader.paginatedreader.UserAgentStylesheet
|
||||
import com.aryan.reader.paginatedreader.htmlToSemanticBlocks
|
||||
import com.aryan.reader.shared.reader.SharedEpubBook
|
||||
import com.aryan.reader.shared.reader.SharedEpubChapter
|
||||
import java.io.File
|
||||
import java.util.Base64
|
||||
import java.util.UUID
|
||||
import java.util.zip.ZipFile
|
||||
|
||||
object DesktopEpubLoader {
|
||||
fun load(file: File): SharedEpubBook {
|
||||
ZipFile(file).use { zip ->
|
||||
val container = zip.readText("META-INF/container.xml")
|
||||
val opfPath = container
|
||||
.substringAfter("full-path=\"", missingDelimiterValue = "")
|
||||
.substringBefore("\"")
|
||||
.ifBlank { error("EPUB container does not point to an OPF package.") }
|
||||
val opf = zip.readText(opfPath)
|
||||
val basePath = opfPath.substringBeforeLast('/', missingDelimiterValue = "")
|
||||
.let { if (it.isBlank()) "" else "$it/" }
|
||||
|
||||
val title = opf.tagText("title").ifBlank { file.nameWithoutExtension }
|
||||
val author = opf.tagText("creator").ifBlank { null }
|
||||
val manifest = parseManifest(opf)
|
||||
val cssByPath = loadCss(zip, manifest, basePath)
|
||||
val cssRules = parseCssRules(cssByPath)
|
||||
val spine = Regex("<itemref[^>]*idref=[\"']([^\"']+)[\"'][^>]*/?>")
|
||||
.findAll(opf)
|
||||
.mapNotNull { match -> manifest[match.groupValues[1]] }
|
||||
.toList()
|
||||
|
||||
val chapterPaths = spine.ifEmpty {
|
||||
manifest.values.filter { it.endsWith(".xhtml", ignoreCase = true) || it.endsWith(".html", ignoreCase = true) }
|
||||
}
|
||||
|
||||
val chapters = chapterPaths.mapIndexedNotNull { index, href ->
|
||||
val path = normalizeZipPath(basePath + href)
|
||||
val html = zip.readTextOrNull(path) ?: return@mapIndexedNotNull null
|
||||
val resourceReadyHtml = html.sanitizeReaderHtml().withEmbeddedResources(zip, path)
|
||||
val text = htmlToText(html)
|
||||
val semanticBlocks = runCatching {
|
||||
htmlToSemanticBlocks(
|
||||
html = resourceReadyHtml,
|
||||
cssRules = cssRules,
|
||||
textStyle = TextStyle(fontSize = 18.sp),
|
||||
chapterAbsPath = path,
|
||||
extractionBasePath = "",
|
||||
density = Density(1f),
|
||||
fontFamilyMap = emptyMap(),
|
||||
constraints = Constraints(maxWidth = 980, maxHeight = 720)
|
||||
)
|
||||
}.getOrElse { emptyList() }
|
||||
if (text.isBlank()) {
|
||||
null
|
||||
} else {
|
||||
SharedEpubChapter(
|
||||
id = "chapter_$index",
|
||||
title = html.tagText("h1")
|
||||
.ifBlank { html.tagText("h2") }
|
||||
.ifBlank { html.tagText("title") }
|
||||
.ifBlank { "Chapter ${index + 1}" },
|
||||
plainText = text,
|
||||
semanticBlocks = semanticBlocks,
|
||||
htmlContent = resourceReadyHtml.extractBodyOrSelf(),
|
||||
baseHref = path.substringBeforeLast('/', missingDelimiterValue = "")
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return SharedEpubBook(
|
||||
id = file.absolutePath,
|
||||
fileName = file.name,
|
||||
title = title,
|
||||
author = author,
|
||||
css = cssByPath,
|
||||
chapters = chapters.ifEmpty {
|
||||
listOf(
|
||||
SharedEpubChapter(
|
||||
id = UUID.randomUUID().toString(),
|
||||
title = title,
|
||||
plainText = "This EPUB opened, but no readable spine text was found by the lightweight desktop loader."
|
||||
)
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseManifest(opf: String): Map<String, String> {
|
||||
return Regex("<item\\s+[^>]*>").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 id to href
|
||||
}.toMap()
|
||||
}
|
||||
|
||||
private fun loadCss(zip: ZipFile, manifest: Map<String, String>, basePath: String): Map<String, String> {
|
||||
return manifest.values
|
||||
.filter { it.endsWith(".css", ignoreCase = true) }
|
||||
.mapNotNull { href ->
|
||||
val path = normalizeZipPath(basePath + href)
|
||||
val css = zip.readTextOrNull(path)?.withEmbeddedCssResources(zip, path).orEmpty()
|
||||
if (css.isBlank()) null else path to css
|
||||
}
|
||||
.toMap()
|
||||
}
|
||||
|
||||
private fun parseCssRules(cssByPath: Map<String, String>): OptimizedCssRules {
|
||||
val constraints = Constraints(maxWidth = 980, maxHeight = 720)
|
||||
val baseRules = CssParser.parse(
|
||||
cssContent = UserAgentStylesheet.default,
|
||||
cssPath = null,
|
||||
baseFontSizeSp = 18f,
|
||||
density = 1f,
|
||||
constraints = constraints,
|
||||
isDarkTheme = false
|
||||
).rules
|
||||
|
||||
return cssByPath.entries
|
||||
.fold(baseRules) { rules, (path, css) ->
|
||||
if (css.isBlank()) {
|
||||
rules
|
||||
} else {
|
||||
rules.merge(
|
||||
CssParser.parse(
|
||||
cssContent = css,
|
||||
cssPath = path,
|
||||
baseFontSizeSp = 18f,
|
||||
density = 1f,
|
||||
constraints = constraints,
|
||||
isDarkTheme = false
|
||||
).rules
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun ZipFile.readText(path: String): String {
|
||||
val entry = getEntry(path) ?: error("Missing EPUB entry: $path")
|
||||
return getInputStream(entry).bufferedReader().use { it.readText() }
|
||||
}
|
||||
|
||||
private fun ZipFile.readTextOrNull(path: String): String? {
|
||||
val entry = getEntry(path) ?: return null
|
||||
return getInputStream(entry).bufferedReader().use { it.readText() }
|
||||
}
|
||||
|
||||
private fun String.attr(name: String): String {
|
||||
return Regex("""\b$name=["']([^"']+)["']""").find(this)?.groupValues?.get(1).orEmpty()
|
||||
}
|
||||
|
||||
private fun String.tagText(tag: String): String {
|
||||
return Regex("<(?:[^:>]+:)?$tag\\b[^>]*>(.*?)</(?:[^:>]+:)?$tag>", RegexOption.IGNORE_CASE)
|
||||
.find(this)
|
||||
?.groupValues
|
||||
?.get(1)
|
||||
?.let(::htmlToText)
|
||||
.orEmpty()
|
||||
}
|
||||
|
||||
private fun normalizeZipPath(path: String): String {
|
||||
val parts = ArrayDeque<String>()
|
||||
path.split('/').forEach { part ->
|
||||
when (part) {
|
||||
"", "." -> Unit
|
||||
".." -> if (parts.isNotEmpty()) parts.removeLast()
|
||||
else -> parts.addLast(part)
|
||||
}
|
||||
}
|
||||
return parts.joinToString("/")
|
||||
}
|
||||
|
||||
private fun String.withEmbeddedResources(zip: ZipFile, chapterPath: String): String {
|
||||
return replace(Regex("""(?i)\b(src|href)=["']([^"']+)["']""")) { match ->
|
||||
val attr = match.groupValues[1]
|
||||
val raw = match.groupValues[2]
|
||||
if (attr.equals("href", ignoreCase = true) && !raw.looksLikeEmbeddableResource()) {
|
||||
return@replace match.value
|
||||
}
|
||||
val dataUri = zip.toDataUri(raw, chapterPath)
|
||||
if (dataUri != null) "$attr=\"$dataUri\"" else match.value
|
||||
}
|
||||
}
|
||||
|
||||
private fun String.looksLikeEmbeddableResource(): Boolean {
|
||||
return substringBefore('#')
|
||||
.substringBefore('?')
|
||||
.substringAfterLast('.', "")
|
||||
.lowercase() in setOf("css", "jpg", "jpeg", "png", "gif", "svg", "webp", "ttf", "otf", "woff", "woff2")
|
||||
}
|
||||
|
||||
private fun String.sanitizeReaderHtml(): String {
|
||||
return replace(Regex("(?is)<script\\b.*?</script>"), "")
|
||||
.replace(Regex("(?is)<object\\b.*?</object>"), "")
|
||||
.replace(Regex("(?is)<embed\\b[^>]*>"), "")
|
||||
.replace(Regex("""(?i)\s+on[a-z]+\s*=\s*(['"]).*?\1"""), "")
|
||||
}
|
||||
|
||||
private fun String.withEmbeddedCssResources(zip: ZipFile, cssPath: String): String {
|
||||
return replace(Regex("""url\((['"]?)([^)'"]+)\1\)""", RegexOption.IGNORE_CASE)) { match ->
|
||||
val raw = match.groupValues[2].trim()
|
||||
val dataUri = zip.toDataUri(raw, cssPath)
|
||||
if (dataUri != null) "url('$dataUri')" else match.value
|
||||
}
|
||||
}
|
||||
|
||||
private fun ZipFile.toDataUri(rawRef: String, ownerPath: String): String? {
|
||||
val ref = rawRef.substringBefore('#').trim()
|
||||
if (ref.isBlank() || ref.startsWith("data:", ignoreCase = true)) return null
|
||||
if (ref.startsWith("http://", ignoreCase = true) || ref.startsWith("https://", ignoreCase = true)) return null
|
||||
val base = ownerPath.substringBeforeLast('/', missingDelimiterValue = "")
|
||||
val path = normalizeZipPath(if (base.isBlank()) ref else "$base/$ref")
|
||||
val entry = getEntry(path) ?: return null
|
||||
val bytes = getInputStream(entry).use { it.readBytes() }
|
||||
return "data:${mimeType(path)};base64,${Base64.getEncoder().encodeToString(bytes)}"
|
||||
}
|
||||
|
||||
private fun mimeType(path: String): String {
|
||||
return when (path.substringAfterLast('.', "").lowercase()) {
|
||||
"jpg", "jpeg" -> "image/jpeg"
|
||||
"png" -> "image/png"
|
||||
"gif" -> "image/gif"
|
||||
"svg" -> "image/svg+xml"
|
||||
"webp" -> "image/webp"
|
||||
"ttf" -> "font/ttf"
|
||||
"otf" -> "font/otf"
|
||||
"woff" -> "font/woff"
|
||||
"woff2" -> "font/woff2"
|
||||
"css" -> "text/css"
|
||||
"js" -> "text/javascript"
|
||||
else -> "application/octet-stream"
|
||||
}
|
||||
}
|
||||
|
||||
private fun String.extractBodyOrSelf(): String {
|
||||
return Regex("(?is)<body\\b[^>]*>(.*?)</body>")
|
||||
.find(this)
|
||||
?.groupValues
|
||||
?.get(1)
|
||||
?.trim()
|
||||
?: this
|
||||
}
|
||||
|
||||
private fun htmlToText(html: String): String {
|
||||
return html
|
||||
.replace(Regex("(?is)<script.*?</script>"), "")
|
||||
.replace(Regex("(?is)<style.*?</style>"), "")
|
||||
.replace(Regex("(?i)<br\\s*/?>"), "\n")
|
||||
.replace(Regex("(?i)</p\\s*>"), "\n\n")
|
||||
.replace(Regex("(?i)</h[1-6]\\s*>"), "\n\n")
|
||||
.replace(Regex("<[^>]+>"), " ")
|
||||
.decodeEntities()
|
||||
.replace(Regex("[ \\t\\x0B\\f\\r]+"), " ")
|
||||
.replace(Regex(" *\\n *"), "\n")
|
||||
.replace(Regex("\\n{3,}"), "\n\n")
|
||||
.trim()
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,205 @@
|
|||
package com.aryan.reader.desktop
|
||||
|
||||
import com.aryan.reader.shared.BookItem
|
||||
import com.aryan.reader.shared.BookShelfRef
|
||||
import com.aryan.reader.shared.FileType
|
||||
import com.aryan.reader.shared.ShelfRecord
|
||||
import com.aryan.reader.shared.Tag
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.JsonNull
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.booleanOrNull
|
||||
import kotlinx.serialization.json.doubleOrNull
|
||||
import kotlinx.serialization.json.floatOrNull
|
||||
import kotlinx.serialization.json.jsonArray
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import kotlinx.serialization.json.longOrNull
|
||||
import java.io.File
|
||||
|
||||
data class DesktopLibrarySnapshot(
|
||||
val books: List<BookItem> = emptyList(),
|
||||
val shelfRecords: List<ShelfRecord> = emptyList(),
|
||||
val shelfRefs: List<BookShelfRef> = emptyList(),
|
||||
val tags: List<Tag> = emptyList()
|
||||
)
|
||||
|
||||
class DesktopLibraryDatabase(
|
||||
private val databaseFile: File = defaultDatabaseFile()
|
||||
) {
|
||||
private val json = Json {
|
||||
prettyPrint = true
|
||||
ignoreUnknownKeys = true
|
||||
}
|
||||
|
||||
fun load(): DesktopLibrarySnapshot {
|
||||
if (!databaseFile.exists()) return DesktopLibrarySnapshot()
|
||||
val root = runCatching {
|
||||
json.parseToJsonElement(databaseFile.readText()).jsonObject
|
||||
}.getOrNull() ?: return DesktopLibrarySnapshot()
|
||||
|
||||
return DesktopLibrarySnapshot(
|
||||
books = root.array("books").mapNotNull { it.asBookItemOrNull() },
|
||||
shelfRecords = root.array("shelves").mapNotNull { it.asShelfRecordOrNull() },
|
||||
shelfRefs = root.array("bookShelfRefs").mapNotNull { it.asBookShelfRefOrNull() },
|
||||
tags = root.array("tags").mapNotNull { it.asTagOrNull() }
|
||||
)
|
||||
}
|
||||
|
||||
fun save(snapshot: DesktopLibrarySnapshot) {
|
||||
databaseFile.parentFile?.mkdirs()
|
||||
val root = JsonObject(
|
||||
mapOf(
|
||||
"schemaVersion" to JsonPrimitive(1),
|
||||
"books" to JsonArray(snapshot.books.map { it.toJsonObject() }),
|
||||
"shelves" to JsonArray(snapshot.shelfRecords.map { it.toJsonObject() }),
|
||||
"bookShelfRefs" to JsonArray(snapshot.shelfRefs.map { it.toJsonObject() }),
|
||||
"tags" to JsonArray(snapshot.tags.map { it.toJsonObject() })
|
||||
)
|
||||
)
|
||||
databaseFile.writeText(root.toString())
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun defaultDatabaseFile(): File {
|
||||
val baseDir = System.getenv("APPDATA")?.takeIf { it.isNotBlank() }
|
||||
?: File(System.getProperty("user.home"), "AppData/Roaming").absolutePath
|
||||
return File(baseDir, "Episteme/library.json")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun JsonObject.array(name: String): List<JsonElement> {
|
||||
return runCatching { this[name]?.jsonArray?.toList().orEmpty() }.getOrDefault(emptyList())
|
||||
}
|
||||
|
||||
private fun JsonObject.string(name: String): String? {
|
||||
return this[name]?.takeUnless { it is JsonNull }?.jsonPrimitive?.content
|
||||
}
|
||||
|
||||
private fun JsonObject.long(name: String, fallback: Long = 0L): Long {
|
||||
return this[name]?.jsonPrimitive?.longOrNull ?: fallback
|
||||
}
|
||||
|
||||
private fun JsonObject.float(name: String): Float? {
|
||||
return this[name]?.jsonPrimitive?.floatOrNull
|
||||
}
|
||||
|
||||
private fun JsonObject.double(name: String): Double? {
|
||||
return this[name]?.jsonPrimitive?.doubleOrNull
|
||||
}
|
||||
|
||||
private fun JsonObject.boolean(name: String, fallback: Boolean): Boolean {
|
||||
return this[name]?.jsonPrimitive?.booleanOrNull ?: fallback
|
||||
}
|
||||
|
||||
private fun JsonElement.asBookItemOrNull(): BookItem? {
|
||||
val obj = runCatching { jsonObject }.getOrNull() ?: return null
|
||||
val id = obj.string("id") ?: return null
|
||||
val displayName = obj.string("displayName") ?: return null
|
||||
val type = obj.string("type")?.let { runCatching { FileType.valueOf(it) }.getOrNull() } ?: FileType.UNKNOWN
|
||||
return BookItem(
|
||||
id = id,
|
||||
path = obj.string("path"),
|
||||
type = type,
|
||||
displayName = displayName,
|
||||
timestamp = obj.long("timestamp"),
|
||||
title = obj.string("title"),
|
||||
author = obj.string("author"),
|
||||
progressPercentage = obj.float("progressPercentage"),
|
||||
isRecent = obj.boolean("isRecent", true),
|
||||
fileSize = obj.long("fileSize"),
|
||||
sourceFolder = obj.string("sourceFolder"),
|
||||
seriesName = obj.string("seriesName"),
|
||||
seriesIndex = obj.double("seriesIndex"),
|
||||
tags = obj.array("tags").mapNotNull { it.asTagOrNull() }
|
||||
)
|
||||
}
|
||||
|
||||
private fun JsonElement.asShelfRecordOrNull(): ShelfRecord? {
|
||||
val obj = runCatching { jsonObject }.getOrNull() ?: return null
|
||||
return ShelfRecord(
|
||||
id = obj.string("id") ?: return null,
|
||||
name = obj.string("name") ?: return null,
|
||||
isSmart = obj.boolean("isSmart", false),
|
||||
smartRulesJson = obj.string("smartRulesJson")
|
||||
)
|
||||
}
|
||||
|
||||
private fun JsonElement.asBookShelfRefOrNull(): BookShelfRef? {
|
||||
val obj = runCatching { jsonObject }.getOrNull() ?: return null
|
||||
return BookShelfRef(
|
||||
bookId = obj.string("bookId") ?: return null,
|
||||
shelfId = obj.string("shelfId") ?: return null,
|
||||
addedAt = obj.long("addedAt")
|
||||
)
|
||||
}
|
||||
|
||||
private fun JsonElement.asTagOrNull(): Tag? {
|
||||
val obj = runCatching { jsonObject }.getOrNull() ?: return null
|
||||
return Tag(
|
||||
id = obj.string("id") ?: return null,
|
||||
name = obj.string("name") ?: return null,
|
||||
color = obj["color"]?.takeUnless { it is JsonNull }?.jsonPrimitive?.content?.toIntOrNull()
|
||||
)
|
||||
}
|
||||
|
||||
private fun BookItem.toJsonObject(): JsonObject {
|
||||
return JsonObject(
|
||||
mapOf(
|
||||
"id" to JsonPrimitive(id),
|
||||
"path" to path.asJson(),
|
||||
"type" to JsonPrimitive(type.name),
|
||||
"displayName" to JsonPrimitive(displayName),
|
||||
"timestamp" to JsonPrimitive(timestamp),
|
||||
"title" to title.asJson(),
|
||||
"author" to author.asJson(),
|
||||
"progressPercentage" to progressPercentage.asJson(),
|
||||
"isRecent" to JsonPrimitive(isRecent),
|
||||
"fileSize" to JsonPrimitive(fileSize),
|
||||
"sourceFolder" to sourceFolder.asJson(),
|
||||
"seriesName" to seriesName.asJson(),
|
||||
"seriesIndex" to seriesIndex.asJson(),
|
||||
"tags" to JsonArray(tags.map { it.toJsonObject() })
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private fun ShelfRecord.toJsonObject(): JsonObject {
|
||||
return JsonObject(
|
||||
mapOf(
|
||||
"id" to JsonPrimitive(id),
|
||||
"name" to JsonPrimitive(name),
|
||||
"isSmart" to JsonPrimitive(isSmart),
|
||||
"smartRulesJson" to smartRulesJson.asJson()
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private fun BookShelfRef.toJsonObject(): JsonObject {
|
||||
return JsonObject(
|
||||
mapOf(
|
||||
"bookId" to JsonPrimitive(bookId),
|
||||
"shelfId" to JsonPrimitive(shelfId),
|
||||
"addedAt" to JsonPrimitive(addedAt)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private fun Tag.toJsonObject(): JsonObject {
|
||||
return JsonObject(
|
||||
mapOf(
|
||||
"id" to JsonPrimitive(id),
|
||||
"name" to JsonPrimitive(name),
|
||||
"color" to color.asJson()
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private fun String?.asJson(): JsonElement = this?.let { JsonPrimitive(it) } ?: JsonNull
|
||||
private fun Float?.asJson(): JsonElement = this?.let { JsonPrimitive(it) } ?: JsonNull
|
||||
private fun Double?.asJson(): JsonElement = this?.let { JsonPrimitive(it) } ?: JsonNull
|
||||
private fun Int?.asJson(): JsonElement = this?.let { JsonPrimitive(it) } ?: JsonNull
|
||||
|
|
@ -0,0 +1,239 @@
|
|||
package com.aryan.reader.desktop
|
||||
|
||||
import androidx.compose.ui.graphics.ImageBitmap
|
||||
import androidx.compose.ui.graphics.toComposeImageBitmap
|
||||
import com.aryan.reader.shared.pdf.PdfZoomSpec
|
||||
import com.sun.jna.Library
|
||||
import com.sun.jna.Memory
|
||||
import com.sun.jna.Native
|
||||
import com.sun.jna.Pointer
|
||||
import java.awt.image.BufferedImage
|
||||
import java.io.File
|
||||
import java.nio.ByteOrder
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
data class DesktopPdfDocument(
|
||||
val path: String,
|
||||
val title: String,
|
||||
val pageCount: Int,
|
||||
val pageSizes: List<DesktopPdfPageSize>,
|
||||
val textPages: List<String>
|
||||
) {
|
||||
fun close() {
|
||||
DesktopPdfium.closeDocument(path)
|
||||
}
|
||||
}
|
||||
|
||||
data class DesktopPdfPageSize(
|
||||
val width: Float,
|
||||
val height: Float
|
||||
)
|
||||
|
||||
data class DesktopPdfPageRender(
|
||||
val image: ImageBitmap,
|
||||
val width: Int,
|
||||
val height: Int
|
||||
)
|
||||
|
||||
object DesktopPdfium {
|
||||
private const val FPDF_ANNOT = 0x01
|
||||
private const val FPDF_LCD_TEXT = 0x02
|
||||
private const val FPDF_RENDER_NO_SMOOTHTEXT = 0x1000
|
||||
private const val FPDF_BITMAP_BGRA = 4
|
||||
|
||||
private val pdfiumDll: File by lazy(::resolvePdfiumDll)
|
||||
private val zoomSpec = PdfZoomSpec()
|
||||
private val api: PdfiumLibrary by lazy {
|
||||
require(pdfiumDll.exists()) {
|
||||
"Missing Pdfium DLL. Expected pdfium-v8-win-x64 under third_party/pdfium/win-x64-v8/bin/pdfium.dll."
|
||||
}
|
||||
Native.load(pdfiumDll.absolutePath, PdfiumLibrary::class.java)
|
||||
}
|
||||
|
||||
private var initialized = false
|
||||
private val openDocuments = LinkedHashMap<String, Pointer>()
|
||||
|
||||
fun isAvailable(): Boolean = pdfiumDll.exists()
|
||||
|
||||
fun load(file: File, password: String? = null): DesktopPdfDocument {
|
||||
initLibrary()
|
||||
val document = api.FPDF_LoadDocument(file.absolutePath, password)
|
||||
?: error("Pdfium could not open ${file.name}. It may be encrypted or unsupported.")
|
||||
val pageCount = api.FPDF_GetPageCount(document)
|
||||
openDocuments[file.absolutePath] = document
|
||||
|
||||
val pageSizes = (0 until pageCount).map { pageIndex ->
|
||||
loadPage(document, pageIndex).usePointer { page ->
|
||||
DesktopPdfPageSize(
|
||||
width = api.FPDF_GetPageWidthF(page),
|
||||
height = api.FPDF_GetPageHeightF(page)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val textPages = (0 until pageCount).map { pageIndex ->
|
||||
extractPageText(document, pageIndex)
|
||||
}
|
||||
|
||||
return DesktopPdfDocument(
|
||||
path = file.absolutePath,
|
||||
title = file.nameWithoutExtension,
|
||||
pageCount = pageCount,
|
||||
pageSizes = pageSizes,
|
||||
textPages = textPages
|
||||
)
|
||||
}
|
||||
|
||||
fun closeDocument(path: String) {
|
||||
openDocuments.remove(path)?.let(api::FPDF_CloseDocument)
|
||||
}
|
||||
|
||||
fun renderPage(
|
||||
document: DesktopPdfDocument,
|
||||
pageIndex: Int,
|
||||
scale: Float,
|
||||
renderAnnotations: Boolean = true
|
||||
): DesktopPdfPageRender {
|
||||
val nativeDocument = openDocuments[document.path] ?: error("PDF document is not open.")
|
||||
val pageSize = document.pageSizes.getOrNull(pageIndex) ?: error("Invalid PDF page index $pageIndex.")
|
||||
val safeScale = zoomSpec.safeRenderScale(pageSize.width, pageSize.height, scale)
|
||||
val width = (pageSize.width * safeScale).roundToInt().coerceAtLeast(1)
|
||||
val height = (pageSize.height * safeScale).roundToInt().coerceAtLeast(1)
|
||||
val stride = width * 4
|
||||
val memory = Memory((stride * height).toLong())
|
||||
memory.clear(memory.size())
|
||||
|
||||
val bitmap = api.FPDFBitmap_CreateEx(width, height, FPDF_BITMAP_BGRA, memory, stride)
|
||||
?: error("Pdfium could not allocate render bitmap.")
|
||||
|
||||
try {
|
||||
api.FPDFBitmap_FillRect(bitmap, 0, 0, width, height, -1)
|
||||
loadPage(nativeDocument, pageIndex).usePointer { page ->
|
||||
val flags = FPDF_LCD_TEXT or
|
||||
(if (renderAnnotations) FPDF_ANNOT else FPDF_RENDER_NO_SMOOTHTEXT)
|
||||
api.FPDF_RenderPageBitmap(bitmap, page, 0, 0, width, height, 0, flags)
|
||||
}
|
||||
return DesktopPdfPageRender(
|
||||
image = memory.toBufferedImage(width, height, stride).toComposeImageBitmap(),
|
||||
width = width,
|
||||
height = height
|
||||
)
|
||||
} finally {
|
||||
api.FPDFBitmap_Destroy(bitmap)
|
||||
}
|
||||
}
|
||||
|
||||
private fun extractPageText(document: Pointer, pageIndex: Int): String {
|
||||
return runCatching {
|
||||
loadPage(document, pageIndex).usePointer { page ->
|
||||
val textPage = api.FPDFText_LoadPage(page) ?: return@usePointer ""
|
||||
try {
|
||||
val charCount = api.FPDFText_CountChars(textPage)
|
||||
if (charCount <= 0) return@usePointer ""
|
||||
val buffer = Memory(((charCount + 1) * 2L))
|
||||
val written = api.FPDFText_GetText(textPage, 0, charCount, buffer)
|
||||
if (written <= 0) {
|
||||
""
|
||||
} else {
|
||||
buffer.getCharArray(0, written).concatToString().trimEnd('\u0000')
|
||||
}
|
||||
} finally {
|
||||
api.FPDFText_ClosePage(textPage)
|
||||
}
|
||||
}
|
||||
}.getOrDefault("")
|
||||
}
|
||||
|
||||
private fun loadPage(document: Pointer, pageIndex: Int): PointerResource {
|
||||
val page = api.FPDF_LoadPage(document, pageIndex)
|
||||
?: error("Pdfium could not open page ${pageIndex + 1}.")
|
||||
return PointerResource(page, api::FPDF_ClosePage)
|
||||
}
|
||||
|
||||
private fun initLibrary() {
|
||||
if (!initialized) {
|
||||
api.FPDF_InitLibrary()
|
||||
initialized = true
|
||||
}
|
||||
}
|
||||
|
||||
private fun resolvePdfiumDll(): File {
|
||||
val overridePath = System.getProperty("reader.pdfium.dll")
|
||||
?: System.getenv("READER_PDFIUM_DLL")
|
||||
if (!overridePath.isNullOrBlank()) {
|
||||
return File(overridePath).absoluteFile
|
||||
}
|
||||
|
||||
val relativePath = listOf("third_party", "pdfium", "win-x64-v8", "bin", "pdfium.dll")
|
||||
.joinToString(File.separator)
|
||||
val roots = generateSequence(File(System.getProperty("user.dir")).absoluteFile) { it.parentFile }
|
||||
.take(6)
|
||||
.toList()
|
||||
|
||||
return roots
|
||||
.map { File(it, relativePath).absoluteFile }
|
||||
.firstOrNull { it.exists() }
|
||||
?: File(File(System.getProperty("user.dir")).absoluteFile, relativePath).absoluteFile
|
||||
}
|
||||
|
||||
private fun Memory.toBufferedImage(width: Int, height: Int, stride: Int): BufferedImage {
|
||||
val image = BufferedImage(width, height, BufferedImage.TYPE_INT_ARGB)
|
||||
val buffer = getByteBuffer(0, size()).order(ByteOrder.LITTLE_ENDIAN)
|
||||
val pixels = IntArray(width * height)
|
||||
for (y in 0 until height) {
|
||||
buffer.position(y * stride)
|
||||
for (x in 0 until width) {
|
||||
val b = buffer.get().toInt() and 0xFF
|
||||
val g = buffer.get().toInt() and 0xFF
|
||||
val r = buffer.get().toInt() and 0xFF
|
||||
val a = buffer.get().toInt() and 0xFF
|
||||
pixels[y * width + x] = (a shl 24) or (r shl 16) or (g shl 8) or b
|
||||
}
|
||||
}
|
||||
image.setRGB(0, 0, width, height, pixels, 0, width)
|
||||
return image
|
||||
}
|
||||
|
||||
private class PointerResource(
|
||||
private val pointer: Pointer,
|
||||
private val closer: (Pointer) -> Unit
|
||||
) {
|
||||
fun <T> usePointer(block: (Pointer) -> T): T {
|
||||
try {
|
||||
return block(pointer)
|
||||
} finally {
|
||||
closer(pointer)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("FunctionName")
|
||||
private interface PdfiumLibrary : Library {
|
||||
fun FPDF_InitLibrary()
|
||||
fun FPDF_LoadDocument(filePath: String, password: String?): Pointer?
|
||||
fun FPDF_CloseDocument(document: Pointer)
|
||||
fun FPDF_GetPageCount(document: Pointer): Int
|
||||
fun FPDF_LoadPage(document: Pointer, pageIndex: Int): Pointer?
|
||||
fun FPDF_ClosePage(page: Pointer)
|
||||
fun FPDF_GetPageWidthF(page: Pointer): Float
|
||||
fun FPDF_GetPageHeightF(page: Pointer): Float
|
||||
fun FPDFBitmap_CreateEx(width: Int, height: Int, format: Int, firstScan: Pointer, stride: Int): Pointer?
|
||||
fun FPDFBitmap_FillRect(bitmap: Pointer, left: Int, top: Int, width: Int, height: Int, color: Int)
|
||||
fun FPDFBitmap_Destroy(bitmap: Pointer)
|
||||
fun FPDF_RenderPageBitmap(
|
||||
bitmap: Pointer,
|
||||
page: Pointer,
|
||||
startX: Int,
|
||||
startY: Int,
|
||||
sizeX: Int,
|
||||
sizeY: Int,
|
||||
rotate: Int,
|
||||
flags: Int
|
||||
)
|
||||
|
||||
fun FPDFText_LoadPage(page: Pointer): Pointer?
|
||||
fun FPDFText_ClosePage(textPage: Pointer)
|
||||
fun FPDFText_CountChars(textPage: Pointer): Int
|
||||
fun FPDFText_GetText(textPage: Pointer, startIndex: Int, count: Int, result: Pointer): Int
|
||||
}
|
||||
}
|
||||
2429
desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/Main.kt
Normal file
2429
desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/Main.kt
Normal file
File diff suppressed because it is too large
Load diff
Loading…
Add table
Add a link
Reference in a new issue