book-reader/desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/DesktopEpubBridgeParsing.kt
Aryan 056485a140
General improvements (#316)
* Added support for right text alignment in epub reader

* Added tabs management to PDF navigation drawer

* Implemented unified selection menu placement logic

* Added reset functionality for toolbar customization

* Improved highlight filtering in epub pagination

* Migrated hardcoded UI strings to string resources

* Extracted desktop application logic from Main.kt into modular files

* Refactored Main.kt by extracting PDF and EPUB logic into specialized files

* Added Spanish language support

* Added system default option to app language selection
2026-05-16 16:19:31 +05:30

335 lines
12 KiB
Kotlin

package com.aryan.reader.desktop
import com.aryan.reader.shared.ReaderLocator
import com.aryan.reader.shared.ui.SharedNativeReaderLinkClick
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonNull
import kotlinx.serialization.json.contentOrNull
import kotlinx.serialization.json.intOrNull
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import java.awt.event.KeyEvent as AwtKeyEvent
import java.net.URLDecoder
internal data class DesktopReaderPosition(
val pageIndex: Int,
val locator: ReaderLocator?
)
internal data class DesktopReaderHighlightClick(
val highlightId: String
)
internal data class DesktopEpubLinkClick(
val href: String,
val chapterIndex: Int?,
val text: String? = null,
val chapterId: String? = null,
val chapterHref: String? = null,
val source: String = "bridge"
)
internal fun SharedNativeReaderLinkClick.toDesktopEpubLinkClick(): DesktopEpubLinkClick {
return DesktopEpubLinkClick(
href = href,
chapterIndex = chapterIndex,
text = text,
source = "native"
)
}
internal data class DesktopEpubHandledLink(
val href: String,
val handledAtMs: Long
)
internal enum class DesktopReaderSelectionAction {
DEFINE,
SPEAK,
SEARCH
}
internal enum class DesktopReaderKeyNavigation {
NEXT,
PREVIOUS,
FIRST,
LAST,
SEARCH,
NEXT_SEARCH,
EXIT_FULLSCREEN
}
internal fun AwtKeyEvent.desktopReaderKeyNavigationOrNull(fullscreen: Boolean): DesktopReaderKeyNavigation? {
if (id != AwtKeyEvent.KEY_PRESSED) return null
if (fullscreen && keyCode == AwtKeyEvent.VK_ESCAPE) {
return DesktopReaderKeyNavigation.EXIT_FULLSCREEN
}
if (isControlDown && keyCode == AwtKeyEvent.VK_F) {
return DesktopReaderKeyNavigation.SEARCH
}
if (isControlDown && keyCode == AwtKeyEvent.VK_G) {
return DesktopReaderKeyNavigation.NEXT_SEARCH
}
return when (keyCode) {
AwtKeyEvent.VK_RIGHT,
AwtKeyEvent.VK_PAGE_DOWN -> DesktopReaderKeyNavigation.NEXT
AwtKeyEvent.VK_LEFT,
AwtKeyEvent.VK_PAGE_UP -> DesktopReaderKeyNavigation.PREVIOUS
AwtKeyEvent.VK_HOME -> DesktopReaderKeyNavigation.FIRST
AwtKeyEvent.VK_END -> DesktopReaderKeyNavigation.LAST
else -> null
}
}
internal data class DesktopReaderSelectionActionPayload(
val action: DesktopReaderSelectionAction,
val text: String
)
internal fun String.readerHighlightClickOrNull(): DesktopReaderHighlightClick? {
fun parse(rawJson: String): DesktopReaderHighlightClick? = runCatching {
val obj = Json.parseToJsonElement(rawJson).jsonObject
val highlightId = obj["id"]
?.takeUnless { it is JsonNull }
?.jsonPrimitive
?.contentOrNull
?.takeIf { it.isNotBlank() }
?: obj["highlightId"]
?.takeUnless { it is JsonNull }
?.jsonPrimitive
?.contentOrNull
?.takeIf { it.isNotBlank() }
?: return@runCatching null
DesktopReaderHighlightClick(highlightId)
}.getOrNull()
parse(this)?.let { return it }
return runCatching {
Json.parseToJsonElement(this).jsonPrimitive.contentOrNull
}.getOrNull()?.let { parse(it) }
}
internal fun String.readerSelectionActionOrNull(): DesktopReaderSelectionActionPayload? {
fun parse(rawJson: String): DesktopReaderSelectionActionPayload? = runCatching {
val obj = Json.parseToJsonElement(rawJson).jsonObject
val text = obj["text"]
?.takeUnless { it is JsonNull }
?.jsonPrimitive
?.contentOrNull
?.takeIf { it.isNotBlank() }
?: return@runCatching null
val action = when (
obj["action"]
?.takeUnless { it is JsonNull }
?.jsonPrimitive
?.contentOrNull
?.lowercase()
) {
"define" -> DesktopReaderSelectionAction.DEFINE
"speak" -> DesktopReaderSelectionAction.SPEAK
"web-search", "search" -> DesktopReaderSelectionAction.SEARCH
else -> return@runCatching null
}
DesktopReaderSelectionActionPayload(action, text)
}.getOrNull()
parse(this)?.let { return it }
return runCatching {
Json.parseToJsonElement(this).jsonPrimitive.contentOrNull
}.getOrNull()?.let { parse(it) }
}
internal fun String.readerSelectionDebugMessageOrNull(): String? {
fun parse(rawJson: String): String? = runCatching {
Json.parseToJsonElement(rawJson)
.jsonObject["message"]
?.takeUnless { it is JsonNull }
?.jsonPrimitive
?.contentOrNull
?.takeIf { it.isNotBlank() }
}.getOrNull()
parse(this)?.let { return it }
return runCatching {
Json.parseToJsonElement(this).jsonPrimitive.contentOrNull
}.getOrNull()?.let { parse(it) }
}
internal fun String.readerPaginationLogMessageOrNull(): String? {
fun parse(rawJson: String): String? = runCatching {
Json.parseToJsonElement(rawJson)
.jsonObject["message"]
?.takeUnless { it is JsonNull }
?.jsonPrimitive
?.contentOrNull
?.takeIf { it.isNotBlank() }
}.getOrNull()
parse(this)?.let { return it }
return runCatching {
Json.parseToJsonElement(this).jsonPrimitive.contentOrNull
}.getOrNull()?.let { parse(it) }
}
internal fun String.readerPositionOrNull(): DesktopReaderPosition? {
fun parse(rawJson: String): DesktopReaderPosition? = runCatching {
val obj = Json.parseToJsonElement(rawJson).jsonObject
val pageIndex = obj["pageIndex"]
?.takeUnless { it is JsonNull }
?.jsonPrimitive
?.intOrNull
?: return@runCatching null
val locator = ReaderLocator(
chapterIndex = obj["chapterIndex"]?.takeUnless { it is JsonNull }?.jsonPrimitive?.intOrNull,
pageIndex = pageIndex,
startOffset = obj["startOffset"]?.takeUnless { it is JsonNull }?.jsonPrimitive?.intOrNull,
endOffset = obj["endOffset"]?.takeUnless { it is JsonNull }?.jsonPrimitive?.intOrNull,
textQuote = obj["textQuote"]?.takeUnless { it is JsonNull }?.jsonPrimitive?.contentOrNull,
cfi = obj["cfi"]?.takeUnless { it is JsonNull }?.jsonPrimitive?.contentOrNull
)
DesktopReaderPosition(pageIndex, locator)
}.getOrNull()
parse(this)?.let { return it }
return runCatching {
Json.parseToJsonElement(this).jsonPrimitive.contentOrNull
}.getOrNull()?.let { parse(it) }
}
internal fun String.readerKeyNavigationOrNull(): DesktopReaderKeyNavigation? {
fun parse(rawJson: String): DesktopReaderKeyNavigation? = runCatching {
val action = Json.parseToJsonElement(rawJson)
.jsonObject["action"]
?.takeUnless { it is JsonNull }
?.jsonPrimitive
?.contentOrNull
?: return@runCatching null
when (action) {
"next" -> DesktopReaderKeyNavigation.NEXT
"previous" -> DesktopReaderKeyNavigation.PREVIOUS
"first" -> DesktopReaderKeyNavigation.FIRST
"last" -> DesktopReaderKeyNavigation.LAST
"search" -> DesktopReaderKeyNavigation.SEARCH
"nextSearch" -> DesktopReaderKeyNavigation.NEXT_SEARCH
"exitFullscreen" -> DesktopReaderKeyNavigation.EXIT_FULLSCREEN
else -> null
}
}.getOrNull()
parse(this)?.let { return it }
return runCatching {
Json.parseToJsonElement(this).jsonPrimitive.contentOrNull
}.getOrNull()?.let { parse(it) }
}
internal fun String.readerLinkClickOrNull(): DesktopEpubLinkClick? {
fun parse(rawJson: String): DesktopEpubLinkClick? = runCatching {
val obj = Json.parseToJsonElement(rawJson).jsonObject
val href = obj["href"]
?.takeUnless { it is JsonNull }
?.jsonPrimitive
?.contentOrNull
?.takeIf { it.isNotBlank() }
?: return@runCatching null
DesktopEpubLinkClick(
href = href,
chapterIndex = obj["chapterIndex"]?.takeUnless { it is JsonNull }?.jsonPrimitive?.intOrNull,
text = obj["text"]?.takeUnless { it is JsonNull }?.jsonPrimitive?.contentOrNull,
chapterId = obj["chapterId"]?.takeUnless { it is JsonNull }?.jsonPrimitive?.contentOrNull,
chapterHref = obj["chapterHref"]?.takeUnless { it is JsonNull }?.jsonPrimitive?.contentOrNull
)
}.getOrNull()
parse(this)?.let { return it }
return runCatching {
Json.parseToJsonElement(this).jsonPrimitive.contentOrNull
}.getOrNull()?.let { parse(it) }
}
internal fun String.readerLinkClickFromIntercept(): DesktopEpubLinkClick? {
val trimmed = trim()
if (trimmed.startsWith("readerlink:", ignoreCase = true)) {
logEpubLink("request_intercept_readerlink raw=\"${trimmed.logPreview()}\"")
val payload = trimmed.substringAfter("?", missingDelimiterValue = "")
.split('&')
.firstOrNull { it.substringBefore("=").equals("payload", ignoreCase = true) }
?.substringAfter("=", missingDelimiterValue = "")
?.takeIf { it.isNotBlank() }
if (payload == null) {
logEpubLink("request_intercept_readerlink_ignored reason=missing_payload")
return null
}
val decoded = runCatching {
URLDecoder.decode(payload, Charsets.UTF_8.name())
}.getOrElse {
logEpubLink("request_intercept_payload_decode_failed error=\"${it.message.orEmpty().logPreview()}\"")
return null
}
val link = decoded.readerLinkClickOrNull()?.copy(source = "request")
if (link == null) {
logEpubLink("request_intercept_readerlink_ignored reason=parse_failed payload=\"${decoded.logPreview()}\"")
}
return link
}
return readerHrefFromIntercept()?.let { href ->
DesktopEpubLinkClick(
href = href,
chapterIndex = null,
source = "request"
)
}
}
private fun String.readerHrefFromIntercept(): String? {
val trimmed = trim()
if (trimmed.isBlank()) return null
if (trimmed.equals("about:blank", ignoreCase = true)) return null
if (trimmed.startsWith("file:///kcefbrowser/", ignoreCase = true)) return null
if (trimmed.startsWith("file:/kcefbrowser/", ignoreCase = true)) return null
if (trimmed.startsWith("file://", ignoreCase = true)) return null
if (trimmed.startsWith("about:blank#", ignoreCase = true)) return "#${trimmed.substringAfter('#')}"
if (trimmed.startsWith("data:", ignoreCase = true)) return null
if (trimmed.startsWith("blob:", ignoreCase = true)) return null
return trimmed
}
internal fun ReaderLocator.toReaderLocatorJson(): String {
return buildString {
append("{")
val values = buildList {
chapterIndex?.let { add("\"chapterIndex\":$it") }
pageIndex?.let { add("\"pageIndex\":$it") }
startOffset?.let { add("\"startOffset\":$it") }
endOffset?.let { add("\"endOffset\":$it") }
cfi?.let { add("\"cfi\":${it.toJsonStringLiteral()}") }
textQuote?.let { add("\"textQuote\":${it.toJsonStringLiteral()}") }
}
append(values.joinToString(","))
append("}")
}
}
private fun String.toJsonStringLiteral(): String {
val builder = StringBuilder("\"")
forEach { char ->
when (char) {
'\\' -> builder.append("\\\\")
'"' -> builder.append("\\\"")
'\n' -> builder.append("\\n")
'\r' -> builder.append("\\r")
'\t' -> builder.append("\\t")
'\b' -> builder.append("\\b")
'\u000C' -> builder.append("\\f")
else -> {
if (char.code < 0x20) {
builder.append("\\u")
builder.append(char.code.toString(16).padStart(4, '0'))
} else {
builder.append(char)
}
}
}
}
builder.append('"')
return builder.toString()
}