* 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:
Aryan 2026-05-04 21:55:38 +05:30 committed by GitHub
parent f42de6b462
commit d7a9cae9e1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
126 changed files with 15287 additions and 3154 deletions

View file

@ -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("&nbsp;", " ")
.replace("&amp;", "&")
.replace("&lt;", "<")
.replace("&gt;", ">")
.replace("&quot;", "\"")
.replace("&#39;", "'")
.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()
}
}
}

View file

@ -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

View file

@ -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
}
}

File diff suppressed because it is too large Load diff