{"id":50720814,"url":"https://github.com/NucleusFramework/ComposePdfReader","last_synced_at":"2026-06-27T00:01:09.377Z","repository":{"id":353544797,"uuid":"1215248910","full_name":"NucleusFramework/ComposePdfReader","owner":"NucleusFramework","description":"A Kotlin Multiplatform PDF rendering and text-extraction library built on top of pdfium and Compose Multiplatform.  Compose-first API, and a sample desktop/mobile reader with thumbnails, progressive rendering, and selectable text.","archived":false,"fork":false,"pushed_at":"2026-05-28T10:55:55.000Z","size":30555,"stargazers_count":50,"open_issues_count":0,"forks_count":3,"subscribers_count":0,"default_branch":"master","last_synced_at":"2026-05-29T04:24:16.145Z","etag":null,"topics":["android","compose","compose-multiplatform","ios","jvm","kotlin-multiplatform","pdf","pdf-reader","pdf-viewer","pdfium","wasm"],"latest_commit_sha":null,"homepage":"https://nucleusframework.github.io/ComposePdfReader/","language":"Kotlin","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"mit","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/NucleusFramework.png","metadata":{"files":{"readme":"README.md","changelog":null,"contributing":null,"funding":null,"license":"LICENSE","code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null,"notice":null,"maintainers":null,"copyright":null,"agents":null,"dco":null,"cla":null}},"created_at":"2026-04-19T17:13:40.000Z","updated_at":"2026-05-28T10:55:59.000Z","dependencies_parsed_at":null,"dependency_job_id":null,"html_url":"https://github.com/NucleusFramework/ComposePdfReader","commit_stats":null,"previous_names":["kdroidfilter/composepdf","nucleusframework/composepdfreader"],"tags_count":4,"template":false,"template_full_name":null,"purl":"pkg:github/NucleusFramework/ComposePdfReader","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/NucleusFramework%2FComposePdfReader","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/NucleusFramework%2FComposePdfReader/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/NucleusFramework%2FComposePdfReader/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/NucleusFramework%2FComposePdfReader/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/NucleusFramework","download_url":"https://codeload.github.com/NucleusFramework/ComposePdfReader/tar.gz/refs/heads/master","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/NucleusFramework%2FComposePdfReader/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":34835785,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-05-26T15:22:16.424Z","status":"online","status_checked_at":"2026-06-26T02:00:06.560Z","response_time":106,"last_error":null,"robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":true,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["android","compose","compose-multiplatform","ios","jvm","kotlin-multiplatform","pdf","pdf-reader","pdf-viewer","pdfium","wasm"],"created_at":"2026-06-10T00:00:28.584Z","updated_at":"2026-06-27T00:01:09.362Z","avatar_url":"https://github.com/NucleusFramework.png","language":"Kotlin","funding_links":[],"categories":["Libraries"],"sub_categories":["🍎 Compose UI"],"readme":"# ComposePdfReader\n\nA Kotlin Multiplatform PDF rendering and text-extraction library built on top of\n[bblanchon/pdfium-binaries](https://github.com/bblanchon/pdfium-binaries) and\nCompose Multiplatform. Zero-copy render pipeline on every target — on the web\nthe transferred pixel `ArrayBuffer` is written straight into Skia's wasm heap,\nno intermediate Kotlin `ByteArray`. A Compose-first API and a sample\ndesktop/mobile reader with thumbnails, progressive rendering, and selectable\ntext round it out.\n\n## Features\n\n- **Compose Multiplatform composables** — drop `PdfPage` or `PdfThumbnail` into\n  any Compose UI.\n- **Zero-copy rendering** on every target. JVM / Android / iOS hand PDFium a\n  raw pixel pointer into Skia / Android `Bitmap` memory. Web allocates the\n  destination buffer inside Skia's wasm heap via `Data.makeUninitialized` and\n  writes the worker's transferred `ArrayBuffer` straight in — no Kotlin\n  `ByteArray` round-trip, no `installPixels` second copy.\n- **Progressive rendering** (preview → full) with a debounced size flow, so\n  scroll and zoom feel instant.\n- **Two-tier LRU cache** (reader bitmaps + thumbnails) with off-screen prefetch.\n- **Text extraction** — per-page UTF-8 text, line-level rectangles, and\n  per-character bounding boxes.\n- **Selectable text overlay** driven by PDFium's per-character boxes, so Ctrl+C\n  and long-press copy return the exact PDF text.\n- **Cross-platform fit/zoom controls** via a plain state holder.\n\n## Supported targets\n\n| Target  | Architectures                                                      | Backend                                           |\n| ------- | ------------------------------------------------------------------ | ------------------------------------------------- |\n| JVM     | linux-x64, linux-arm64, macos-x64, macos-arm64, win-x64, win-arm64 | JNI + Skia (Skiko)                                |\n| Android | arm64-v8a, armeabi-v7a, x86, x86_64                                | JNI (NDK `AndroidBitmap_*`)                       |\n| iOS     | iosArm64, iosSimulatorArm64                                        | Kotlin/Native cinterop + Skia (Skiko)             |\n| Web     | Kotlin/WasmJS, Kotlin/JS (IR)                                      | `pdfium.wasm` in a dedicated Web Worker + Skiko   |\n\nPDFium binaries are fetched automatically at build time from\nbblanchon's GitHub releases (pinned in `gradle/libs.versions.toml` →\n`pdfium-bblanchon`).\n\n## Installation\n\nPublished to Maven Central. Requires Gradle 8.10+ and Kotlin 2.3.20+. The\n`:pdfium` module uses a JVM toolchain of 17.\n\n```kotlin\n// build.gradle.kts\nkotlin {\n    sourceSets {\n        commonMain.dependencies {\n            implementation(\"dev.nucleusframework:pdfium:149.0.7802.0b\")\n        }\n    }\n}\n```\n\n### JVM packaging\n\nWhen packaging your Compose Desktop app, make sure the generated runtime image\nincludes the modules needed by FileKit's native file-picker path (only relevant\nif you use FileKit):\n\n```kotlin\ncompose.desktop {\n    application {\n        nativeDistributions {\n            modules(\"jdk.security.auth\", \"java.management\", \"jdk.unsupported\")\n        }\n    }\n}\n```\n\n### Web packaging (wasmJS / JS)\n\nFor the browser targets, the `pdfium.wasm` + worker assets are published as\nclasspath resources inside the library artifact and served from the module\nroot. If you bundle your app with the default Kotlin/JS webpack pipeline, no\nextra configuration is needed — the `@JsModule(\"./pdfium_glue.mjs\")` imports\nresolve against your webpack output directory. Remember to serve the site over\nHTTPS (or `localhost`): the Web Clipboard API used by text copy only works in\nsecure contexts.\n\n## Getting started — a tour\n\nEvery snippet below is self-contained and drops straight into a Compose\nMultiplatform `commonMain` source set. Paste one, run, then move on to the\nnext.\n\n### 1. Show a PDF in three lines of logic\n\n```kotlin\n@Composable\nfun HelloPdf(bytes: ByteArray) {\n    val reader = rememberPdfReaderState()       // state holder — dispatches on a background worker\n    LaunchedEffect(bytes) { reader.open(bytes) } // parses headers; cancels cleanly if `bytes` changes\n    PdfReader(state = reader, modifier = Modifier.fillMaxSize())\n}\n```\n\n`PdfReader` stacks every page in a `LazyColumn`. `rememberPdfReaderState`\ndisposes native handles automatically when it leaves composition.\n\n### 2. Load the PDF bytes from somewhere real\n\n**From a platform-native file picker (recommended)** — use\n[FileKit](https://github.com/vinceglb/FileKit):\n\n```kotlin\ndependencies {\n    implementation(\"io.github.vinceglb:filekit-dialogs-compose:0.13.0\")\n}\n```\n\n```kotlin\n@Composable\nfun PdfPicker() {\n    val reader = rememberPdfReaderState()\n    val scope = rememberCoroutineScope()\n    val picker = rememberFilePickerLauncher(\n        type = FileKitType.File(extensions = listOf(\"pdf\")),\n    ) { file -\u003e\n        if (file != null) scope.launch { reader.open(file.readBytes()) }\n    }\n    Column(Modifier.fillMaxSize()) {\n        Button(onClick = { picker.launch() }) { Text(\"Open PDF…\") }\n        if (reader.pageCount \u003e 0) {\n            PdfReader(state = reader, modifier = Modifier.fillMaxSize())\n        }\n    }\n}\n```\n\n**From a URL** — use any HTTP client (Ktor here):\n\n```kotlin\nval client = remember { HttpClient() }\nLaunchedEffect(url) {\n    reader.open(client.get(url).readRawBytes())\n}\n```\n\n**From a classpath resource** — use Compose Resources or your platform's\nbundled-asset API. The library only needs a `ByteArray`; how you obtain it is\nup to you.\n\n### 3. React to loading state, metadata, and errors\n\n`PdfReaderState` exposes snapshot state you can observe in any composable:\n\n```kotlin\nBox(Modifier.fillMaxSize()) {\n    when {\n        reader.isLoading -\u003e CircularProgressIndicator(Modifier.align(Alignment.Center))\n\n        reader.error is PdfError.PasswordRequired -\u003e PasswordPrompt { password -\u003e\n            scope.launch { reader.open(bytes, password) }\n        }\n        reader.error is PdfError.InvalidFormat -\u003e Text(\"Not a valid PDF\")\n        reader.error is PdfError.Io -\u003e Text(\"Couldn't read file: ${reader.error?.message}\")\n        reader.error is PdfError.NativeFailure -\u003e Text(\"Render error: ${reader.error?.message}\")\n\n        reader.pageCount \u003e 0 -\u003e {\n            Column {\n                Text(reader.metadata.title ?: \"Untitled\",\n                    style = MaterialTheme.typography.titleLarge)\n                Text(\"by ${reader.metadata.author ?: \"Unknown\"} — ${reader.pageCount} pages\")\n                PdfReader(state = reader, modifier = Modifier.weight(1f))\n            }\n        }\n    }\n}\n```\n\n### 4. Enable copy/paste and text selection\n\nFlip one flag on `PdfPage` and a pixel-precise selection overlay lights up.\nDrag on desktop, long-press on mobile, Ctrl/Cmd+C to copy:\n\n```kotlin\nPdfPage(\n    state = reader,\n    pageIndex = pageIndex,\n    modifier = Modifier.fillMaxWidth(),\n    selectableText = true,   // 👈 all you need\n)\n```\n\nHit-testing uses PDFium's per-character boxes (`FPDFText_GetCharBox`) rather\nthan Compose's own font metrics, so selection tracks the rendered glyphs.\n\n### 5. Extract text programmatically\n\nGrab the full Unicode of a single page:\n\n```kotlin\nval scope = rememberCoroutineScope()\nscope.launch {\n    val text = reader.pageText(pageIndex = 0)\n    println(text)\n}\n```\n\nConcatenate the whole document:\n\n```kotlin\nsuspend fun dumpPdf(reader: PdfReaderState): String =\n    (0 until reader.pageCount).joinToString(\"\\n\\n\") { reader.pageText(it) }\n```\n\n### 6. Search across pages and highlight hits\n\n`pageTextLayout` returns line-level rectangles with their Unicode run — all\nyou need for a search-in-document feature:\n\n```kotlin\ndata class SearchHit(val page: Int, val rect: Rect, val text: String)\n\nsuspend fun search(reader: PdfReaderState, query: String): List\u003cSearchHit\u003e {\n    if (query.length \u003c 2) return emptyList()\n    return buildList {\n        for (page in 0 until reader.pageCount) {\n            val layout = reader.pageTextLayout(page) ?: continue\n            for (i in 0 until layout.rectCount) {\n                val run = layout.text(i)\n                if (run.contains(query, ignoreCase = true)) {\n                    // PDF origin is bottom-left; flip Y to Compose top-left.\n                    val pageH = layout.pageSize.heightPoints\n                    val rect = Rect(\n                        left = layout.left(i),\n                        top = pageH - layout.top(i),\n                        right = layout.right(i),\n                        bottom = pageH - layout.bottom(i),\n                    )\n                    add(SearchHit(page = page, rect = rect, text = run))\n                }\n            }\n        }\n    }\n}\n```\n\nTurn each `SearchHit.rect` (in PDF points) into on-screen pixels with the\nscale formula in the [PageTextLayout coordinate guide](#pagetextlayout).\n\n### 7. Draw your own overlay on top of a page\n\nWant to highlight the current search hit, sign a form, or stamp a\nwatermark? Wrap `PdfPage` in a `Box`, lay a `Canvas` over it, and convert\nyour PDF-point geometry:\n\n```kotlin\n@Composable\nfun HighlightedPage(reader: PdfReaderState, pageIndex: Int, hits: List\u003cRect\u003e) {\n    var pageSize by remember { mutableStateOf\u003cPageSize?\u003e(null) }\n    LaunchedEffect(pageIndex) { pageSize = reader.pageSize(pageIndex) }\n\n    Box(Modifier.fillMaxWidth()) {\n        PdfPage(reader, pageIndex, selectableText = true)\n        val size = pageSize ?: return@Box\n        Canvas(Modifier.matchParentSize()) {\n            val sx = this.size.width  / size.widthPoints\n            val sy = this.size.height / size.heightPoints\n            hits.forEach { r -\u003e\n                drawRect(\n                    color = Color(0x665AB1FF),\n                    topLeft = Offset(r.left * sx, r.top * sy),\n                    size = Size((r.right - r.left) * sx, (r.bottom - r.top) * sy),\n                )\n            }\n        }\n    }\n}\n```\n\n### 8. Add a thumbnail sidebar\n\n`PdfThumbnail` uses `RenderQuality.PREVIEW` and its own LRU, so scrolling a\nhundred-page strip never evicts your reader's full-quality bitmaps:\n\n```kotlin\nRow(Modifier.fillMaxSize()) {\n    LazyColumn(\n        modifier = Modifier.width(160.dp).fillMaxHeight(),\n        verticalArrangement = Arrangement.spacedBy(8.dp),\n        contentPadding = PaddingValues(8.dp),\n    ) {\n        items(reader.pageCount) { i -\u003e\n            PdfThumbnail(\n                state = reader,\n                pageIndex = i,\n                modifier = Modifier.clickable { /* jumpToPage(i) */ },\n            )\n        }\n    }\n    PdfReader(state = reader, modifier = Modifier.weight(1f).fillMaxHeight())\n}\n```\n\n### 9. Zoom, fit-to-width, fit-to-page\n\n`PdfReaderState.renderScale` is a plain `Float` — every `PdfPage` observes\nit, so flipping it re-renders the visible pages at the new size.\n\n```kotlin\nvar scale by remember { mutableStateOf(1f) }\nLaunchedEffect(scale) { reader.renderScale = scale }\n\nColumn {\n    Slider(value = scale, onValueChange = { scale = it }, valueRange = 0.5f..3f)\n    Row {\n        TextButton(onClick = { scale = 1f }) { Text(\"Fit width\") }\n        TextButton(onClick = {\n            // Maths in ReaderScreenState.kt — 3 lines with the viewport size.\n            val vp = viewportPx ; val page = reader.pageSize(0) ?: return@TextButton\n            scale = (vp.height * page.aspectRatio / vp.width).coerceIn(0.1f, 4f)\n        }) { Text(\"Fit height\") }\n    }\n}\n```\n\n\u003e Looking for a ready-made zoom UI? The sample's\n\u003e [`ReaderTopBar.kt`](example/src/commonMain/kotlin/dev/nucleusframework/pdf/reader/ReaderTopBar.kt)\n\u003e wires a Material slider + Fit Width / Height / Page buttons you can copy\n\u003e as-is.\n\n### 10. Add a right-click / long-press context menu\n\nOn desktop, the built-in `ContextMenuArea` works out of the box. Wrap any\npage-level composable:\n\n```kotlin\n@OptIn(ExperimentalFoundationApi::class)\n@Composable\nfun PageWithMenu(reader: PdfReaderState, pageIndex: Int) {\n    val scope = rememberCoroutineScope()\n    val clipboard = LocalClipboard.current\n\n    ContextMenuArea(items = {\n        listOf(\n            ContextMenuItem(\"Copy page text\") {\n                scope.launch {\n                    val text = reader.pageText(pageIndex)\n                    clipboard.setClipEntry(textClipEntry(text))\n                }\n            },\n            ContextMenuItem(\"Jump to next page\") {\n                // your own state holder decides how to advance\n            },\n        )\n    }) {\n        PdfPage(reader, pageIndex, selectableText = true)\n    }\n}\n```\n\n`textClipEntry(text)` is the lib's own cross-platform helper for the new\nCompose `Clipboard.setClipEntry(...)` API (Compose 1.10 deprecated\n`ClipboardManager.setText` — this covers the gap).\n\nOn Android/iOS, `ContextMenuArea` doesn't exist; detect long-press yourself:\n\n```kotlin\nBox(\n    Modifier.pointerInput(pageIndex) {\n        detectTapGestures(\n            onLongPress = { showMenu = true },\n        )\n    }\n) { PdfPage(reader, pageIndex) }\n```\n\n### 11. Password-protected PDFs\n\nPass the password on `open`:\n\n```kotlin\nreader.open(bytes, password = \"hunter2\")\n```\n\nIf you don't have it yet, `reader.error` will transition to\n`PdfError.PasswordRequired` — prompt the user, then re-call `open`:\n\n```kotlin\nvar pending by remember { mutableStateOf\u003cByteArray?\u003e(null) }\nLaunchedEffect(bytes) { pending = bytes ; reader.open(bytes) }\n\nif (reader.error is PdfError.PasswordRequired \u0026\u0026 pending != null) {\n    PasswordDialog(onSubmit = { pw -\u003e\n        scope.launch { reader.open(pending!!, password = pw) }\n    })\n}\n```\n\n### What next?\n\n- The full reader screen (`:example`) wires picker + sidebar + zoom + toast\n  in \u003c 500 lines — a real reference for building on top of this library.\n- The [API reference](#api-reference) below documents every public symbol\n  with its types, defaults, and invariants.\n\n## API reference\n\nAll public API lives under the `dev.nucleusframework.pdfium` package in the\n`:pdfium` library.\n\n### `PdfReaderState`\n\nThe state holder tied to a single PDF document. Hoist it in your screen\ncomposable with `rememberPdfReaderState()`.\n\n```kotlin\n@Stable\nclass PdfReaderState {\n    // --- Snapshot state ---\n    val pageCount: Int        // 0 until a document is open\n    val isLoading: Boolean    // true during open()\n    val error: PdfError?      // last open() error, if any\n    val metadata: PdfMetadata\n    var renderScale: Float    // 1.0 = fit-to-width; scales the size reported to PdfPage\n\n    // --- Intents ---\n    suspend fun open(bytes: ByteArray, password: String? = null)\n    suspend fun pageSize(pageIndex: Int): PageSize?\n    suspend fun pageText(pageIndex: Int): String\n    suspend fun pageTextLayout(pageIndex: Int): PageTextLayout?\n\n    // Render ahead-of-display; best-effort, populates the cache.\n    fun prefetch(pageIndex: Int, widthPx: Int, quality: RenderQuality = RenderQuality.FULL)\n\n    // Release native handles + cached bitmaps. Called automatically by rememberPdfReaderState.\n    fun dispose()\n\n    companion object {\n        /** 64 MB. Reader-page LRU — ±2 full-quality bitmaps around the visible page. */\n        const val DEFAULT_CACHE_BYTES: Long = 64L * 1024 * 1024\n\n        /** 12 MB. Thumbnail LRU — ~40 × 240-px previews; kept separate from the reader cache. */\n        const val DEFAULT_THUMBNAIL_CACHE_BYTES: Long = 12L * 1024 * 1024\n    }\n}\n\n@Composable\nfun rememberPdfReaderState(\n    cacheBytes: Long = PdfReaderState.DEFAULT_CACHE_BYTES,\n    thumbnailCacheBytes: Long = PdfReaderState.DEFAULT_THUMBNAIL_CACHE_BYTES,\n): PdfReaderState\n```\n\nBitmaps are keyed by `(pageIndex, quantized_width)`; both cache budgets are\ntunable on `rememberPdfReaderState(...)`.\n\n### `PdfPage`\n\nComposable that renders a single PDF page. Handles progressive rendering\ninternally (low-res preview → full-quality render on settle) and debounces\nsize changes so scroll/zoom stay smooth.\n\n```kotlin\n@Composable\nfun PdfPage(\n    state: PdfReaderState,\n    pageIndex: Int,\n    modifier: Modifier = Modifier,\n    contentScale: ContentScale = ContentScale.Fit,\n    background: Color = Color.White,\n    selectableText: Boolean = false,\n)\n```\n\n- `modifier` controls the layout width; the composable derives the aspect\n  ratio from the PDF page and sets its own height.\n- `selectableText = true` enables the pointer-driven selection overlay\n  described in [step 4](#4-enable-copypaste-and-text-selection).\n\n### `PdfThumbnail`\n\nA low-resolution preview of a single page. Uses `RenderQuality.PREVIEW`, shares\nthe `PdfReaderState` cache, and sizes itself to the modifier-provided width.\n\n```kotlin\n@Composable\nfun PdfThumbnail(\n    state: PdfReaderState,\n    pageIndex: Int,\n    modifier: Modifier = Modifier,\n    background: Color = Color.White,\n)\n```\n\nTypical use: a `LazyColumn` / `LazyRow` of thumbnails as a sidebar or bottom\nstrip next to the main reader.\n\n### `PdfReader`\n\nA convenience composable — a vertical `LazyColumn` that stacks every page of\nthe document.\n\n```kotlin\n@Composable\nfun PdfReader(\n    state: PdfReaderState,\n    modifier: Modifier = Modifier,\n    contentPadding: PaddingValues = PaddingValues(12.dp),\n    pageSpacing: Dp = 16.dp,\n)\n```\n\nFor anything beyond the basics (zoom, thumbnails, responsive layouts), copy\nthe sample's `ReaderScreen` instead.\n\n### `RenderQuality`\n\n```kotlin\nenum class RenderQuality {\n    /** No annotations, no LCD text. Used for thumbnails and progressive previews. */\n    PREVIEW,\n\n    /** Annotations on, no LCD text. Balanced default for on-screen viewing. */\n    FULL,\n}\n```\n\n### `PageSize`, `PdfMetadata`, `PdfError`\n\n```kotlin\ndata class PageSize(val widthPoints: Float, val heightPoints: Float) {\n    val aspectRatio: Float // widthPoints / heightPoints, or 1f for degenerate pages\n}\n\ndata class PdfMetadata(\n    val title: String? = null,\n    val author: String? = null,\n    val subject: String? = null,\n    val keywords: String? = null,\n    val creator: String? = null,\n    val producer: String? = null,\n)\n\nsealed class PdfError(open val message: String, open val cause: Throwable? = null) {\n    data class InvalidFormat(…) : PdfError(…)\n    data class PasswordRequired(…) : PdfError(…)\n    data class NativeFailure(…) : PdfError(…)\n    data class Io(…) : PdfError(…)\n}\n```\n\n### `PageTextLayout`\n\nReturned by `PdfReaderState.pageTextLayout(…)` for building custom text\noverlays or highlighting tools.\n\n```kotlin\n@Immutable\nclass PageTextLayout {\n    val pageIndex: Int\n    val pageSize: PageSize\n    val rectCount: Int\n    val charCount: Int\n\n    // Rect-level (line-level runs from FPDFText_GetRect)\n    fun left(i: Int): Float       // in PDF points, origin bottom-left\n    fun bottom(i: Int): Float\n    fun right(i: Int): Float\n    fun top(i: Int): Float\n    fun text(i: Int): String      // UTF-8 Unicode\n\n    // Char-level (FPDFText_GetCharBox / FPDFText_GetUnicode)\n    fun codepoint(i: Int): Int\n    fun charLeft(i: Int): Float\n    fun charBottom(i: Int): Float\n    fun charRight(i: Int): Float\n    fun charTop(i: Int): Float\n}\n```\n\nCoordinates are in PDF page points (1 pt = 1/72 inch), origin at the\nbottom-left of the page. To map to a rendered bitmap at pixel dimensions\n`W × H`:\n\n```\nscaleX = W / pageSize.widthPoints\nscaleY = H / pageSize.heightPoints\n\nscreenX     = left  × scaleX\nscreenY     = H - top × scaleY       // flip Y (PDF is bottom-up)\nscreenW     = (right - left)   × scaleX\nscreenH     = (top   - bottom) × scaleY\n```\n\n## Architecture\n\n```\n                                    :pdfium module\n┌──────────────────────────────────────────────────────────────────────────────┐\n│ commonMain                                                                   │\n│   PdfReaderState  ─┐                                                         │\n│   PdfPage         ─┼──► expect class PdfDocument                             │\n│   PdfThumbnail    ─┘                                                         │\n│   PdfRenderCache    PageTextLayout   textClipEntry (expect)                  │\n├──────────────────┬───────────────┬─────────────┬─────────────────────────────┤\n│ jvmMain          │ androidMain   │ iosMain     │ webMain (js + wasmJs)       │\n│   JNI glue       │ JNI + NDK     │ cinterop    │   pdfium.wasm in a Web      │\n│   → Skia Bitmap  │ AndroidBitmap │ libpdfium.a │   Worker; RPC via           │\n│   zero-copy      │ zero-copy     │ + Skia      │   postMessage transferables │\n│                  │               │             │   → Skia heap zero-copy     │\n│                  │               │             │ jsMain / wasmJsMain just    │\n│                  │               │             │ host a small PlatformBridge │\n└──────────────────┴───────────────┴─────────────┴─────────────────────────────┘\n```\n\nKey facts:\n\n- **PDFium is single-threaded.** It relies on FreeType's non-thread-safe\n  singleton `FT_Library`. Each `PdfDocument` runs on its own single-threaded\n  dispatcher, but multiple documents can't be rendered in parallel inside one\n  process (tested: crashes in FreeType). Chromium solves this with a separate\n  process per document — not currently implemented here.\n\n- **Zero-copy render path.** On JVM and iOS, we get a raw pixel pointer from\n  `Bitmap.peekPixels().addr` and pass it to `FPDFBitmap_CreateEx`. PDFium\n  writes BGRA pixels straight into Skia's bitmap memory. On Android we lock\n  the `android.graphics.Bitmap` via `AndroidBitmap_lockPixels` and do the same.\n  On web, the pdfium worker transfers the pixel `ArrayBuffer` to the main\n  thread; we allocate the destination inside Skia's own wasm heap via\n  `Data.makeUninitialized` and copy the transferred buffer directly there with\n  a typed-array `.set()` on the Skia memory view obtained through\n  `org.jetbrains.skiko.wasm.awaitSkiko`. One memcpy into the final\n  destination, no `installPixels` round-trip. Pattern cribbed from\n  [coil3.decode.WebWorker](https://github.com/coil-kt/coil/blob/69b8383a3f95300ddb466afdbe9c54ce2eccb652/coil-core/src/jsCommonMain/kotlin/coil3/decode/WebWorker.kt).\n\n- **Native binary delivery.** `pdfium/build.gradle.kts` registers a set of\n  Gradle tasks that download the bblanchon archives, extract them, and stage\n  them as classpath resources (JVM) / jniLibs (Android) / static libs\n  (iOS cinterop). The JNI glue is rebuilt from `pdfium_jni.cpp` via\n  `build-linux.sh` / `build-macos.sh` / `build-windows.bat`.\n\n- **Shared document buffer.** The JVM/Android path copies the PDF bytes into\n  a native buffer once via `nAllocBuffer`, then hands that buffer address to\n  `nOpenDocumentFromMemory` for the document handle. Closing the document\n  frees the buffer.\n\n## Sample app (`:example`)\n\nThe sample is a full PDF reader with:\n\n- Responsive layout (thumbnail sidebar ≥ 760 dp, bottom strip otherwise)\n- Top bar with file name, page counter, zoom slider, `Fit Width` /\n  `Fit Height` / `Fit Page` buttons\n- Continuous scroll reader with horizontal-scroll when zoomed in, prefetch\n  ±2 pages, selection overlay\n- File picking via [FileKit](https://github.com/vinceglb/FileKit)\n- Compose-Unstyled atoms (no Material 3 dependency)\n\nSource layout:\n\n```\nexample/src/commonMain/kotlin/dev/nucleusframework/pdf/\n├── App.kt                        ─ root composable, wires picker + screen\n├── design/\n│   ├── Theme.kt                  ─ Palette / Typography / Shapes + LocalAppTheme\n│   ├── Atoms.kt                  ─ AppText, PrimaryButton, GhostButton, Spinner…\n│   ├── MinimalSlider.kt\n│   └── ToastOverlay.kt\n└── reader/\n    ├── ReaderScreenState.kt      ─ plain @Stable state holder + intents\n    ├── ReaderScreen.kt           ─ screen + wide/narrow layouts\n    ├── ReaderTopBar.kt           ─ file/page info + zoom + fit controls\n    ├── ReaderThumbnails.kt       ─ LazyColumn / LazyRow of PdfThumbnail\n    ├── ReaderSurface.kt          ─ continuous scroll view + per-page card\n    ├── ReaderEmptyState.kt\n    └── TextSelectionDialog.kt    ─ modal with SelectionContainer fallback\n```\n\n## Build and run\n\nRun requirements: Gradle wrapper, JDK 17+, internet access on first build\n(to download bblanchon archives).\n\n### Desktop (JVM)\n\n```\n./gradlew :example:run\n```\n\nA native runtime image with modules is built by\n`./gradlew :example:createDistributable` and runnable via\n`:example:runDistributable`.\n\n### Android\n\n```\n./gradlew :example:assembleDebug\n./gradlew :example:installDebug\n```\n\nFirst-time Android builds also run `:pdfium:installPdfiumAndroidJniLibs` which\ndrops `libpdfium.so` into `src/androidMain/jniLibs/\u003cabi\u003e/`.\n\n### iOS\n\nOpen `iosApp/` in Xcode and run. The Gradle side has to run on a macOS host\nfor the cinterop + framework link to succeed.\n\n### Smoke test\n\nA headless Linux JVM smoke test renders a PDF, extracts text, and fires 192\nconcurrent render calls to stress the serialised dispatcher:\n\n```\n./gradlew :pdfium:smokeTest -PpdfPath=/absolute/path/to/some.pdf\n```\n\nLeaves out `-PpdfPath` and it falls back to `/usr/share/cups/data/classified.pdf`\nif available.\n\n## Known limitations\n\n- **No cross-process parallel rendering.** PDFium + FreeType is effectively\n  single-threaded per process. Rendering is serialised inside each document.\n- **Selection text precision.** The overlay uses PDFium's per-character\n  bounding boxes, not glyph positioning from the embedded PDF font. Copied\n  text is exact, but highlight rectangles can differ slightly from what\n  Chrome / PDF.js render when they can access the original font metrics.\n- **Web: still one memcpy per render.** \"Zero-copy\" here means \"no\n  intermediate Kotlin `ByteArray`, no `installPixels`\" — the worker's\n  transferred `ArrayBuffer` is written straight into Skia's wasm heap. A true\n  zero-memcpy pipeline would need `SharedArrayBuffer` (which in turn requires\n  COOP/COEP headers) so the pdfium worker and Skia share one linear memory.\n  Not currently implemented.\n- **Licensing.** PDFium is dual-licensed BSD-3-Clause / Apache-2.0 (see\n  PDFium's `LICENSE`). bblanchon's binaries carry that license forward. If\n  you ship this code, include the upstream PDFium notices.\n\n## License\n\nThis repository ships build tooling and Kotlin code that wraps PDFium. The\nPDFium binaries themselves are governed by the upstream BSD-3-Clause /\nApache-2.0 license. No license file is committed here yet — treat the\nwrapper code as unlicensed pending a decision.\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2FNucleusFramework%2FComposePdfReader","html_url":"https://awesome.ecosyste.ms/projects/github.com%2FNucleusFramework%2FComposePdfReader","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2FNucleusFramework%2FComposePdfReader/lists"}