Nucleus
PDF reader

Getting started

Load PDF bytes, react to loading state, and enable text selection, links, thumbnails, and zoom.

In this tutorial, you'll open a PDF from a file picker, react to its loading state, and turn on text selection, links, a thumbnail sidebar, and zoom. Every snippet drops into a Compose Multiplatform commonMain source set.

Before you start

Add the dependency described in the PDF reader overview:

build.gradle.kts
commonMain.dependencies {
    implementation("dev.nucleusframework:pdfium:152.0.7947.0")
}

Load the PDF bytes

PdfReaderState.open takes a ByteArray — how you obtain it is up to you. To pick a file with a native dialog, use FileKit:

@Composable
fun PdfPicker() {
    val reader = rememberPdfReaderState()
    val scope = rememberCoroutineScope()
    val picker = rememberFilePickerLauncher(
        type = FileKitType.File(extensions = listOf("pdf")),
    ) { file ->
        if (file != null) scope.launch { reader.open(file.readBytes()) }
    }
    Column(Modifier.fillMaxSize()) {
        Button(onClick = { picker.launch() }) { Text("Open PDF…") }
        if (reader.pageCount > 0) {
            PdfReader(state = reader, modifier = Modifier.fillMaxSize())
        }
    }
}

To load from a URL, pass the bytes from any HTTP client:

val client = remember { HttpClient() }
LaunchedEffect(url) { reader.open(client.get(url).readRawBytes()) }

React to loading state and errors

PdfReaderState exposes snapshot state you can read in any composable — isLoading, error, metadata, and pageCount:

Box(Modifier.fillMaxSize()) {
    when {
        reader.isLoading -> CircularProgressIndicator(Modifier.align(Alignment.Center))

        reader.error is PdfError.PasswordRequired -> PasswordPrompt { password ->
            scope.launch { reader.open(bytes, password) }
        }
        reader.error is PdfError.InvalidFormat -> Text("Not a valid PDF")
        reader.error != null -> Text("Couldn't open: ${reader.error?.message}")

        reader.pageCount > 0 -> {
            Column {
                Text(reader.metadata.title ?: "Untitled")
                PdfReader(state = reader, modifier = Modifier.weight(1f))
            }
        }
    }
}

Enable text selection

Set selectableText = true on PdfPage. Drag on desktop, long-press on mobile, and copy with Ctrl/Cmd+C:

PdfPage(
    state = reader,
    pageIndex = pageIndex,
    modifier = Modifier.fillMaxWidth(),
    selectableText = true,
)

Hit-testing uses PDFium's per-character boxes (FPDFText_GetCharBox), so selection tracks the rendered glyphs rather than Compose's own font metrics.

Links are enabled by default on PdfPage and PdfReader. Link annotations and URLs or e-mail addresses detected in the page text become clickable regions. External URIs open through LocalUriHandler; inside PdfReader, internal GoTo links scroll to the destination page.

Intercept clicks with onLinkClick — return true to consume the click and skip the default handling:

PdfReader(
    state = reader,
    onLinkClick = { link ->
        if (link.uri?.startsWith("mailto:") == true) {
            openCustomComposer(link.uri)
            true
        } else {
            false
        }
    },
)

Add a thumbnail sidebar

PdfThumbnail renders at RenderQuality.PREVIEW and keeps its own LRU, so scrolling a long strip never evicts the reader's full-quality bitmaps:

Row(Modifier.fillMaxSize()) {
    LazyColumn(Modifier.width(160.dp).fillMaxHeight()) {
        items(reader.pageCount) { i ->
            PdfThumbnail(state = reader, pageIndex = i, modifier = Modifier.clickable { /* jumpToPage(i) */ })
        }
    }
    PdfReader(state = reader, modifier = Modifier.weight(1f).fillMaxHeight())
}

Zoom and fit-to-width

PdfReaderState.renderScale is a plain Float that every PdfPage observes. 1.0 is fit-to-width; change it to re-render the visible pages at the new size:

var scale by remember { mutableStateOf(1f) }
LaunchedEffect(scale) { reader.renderScale = scale }

Slider(value = scale, onValueChange = { scale = it }, valueRange = 0.5f..3f)

Extract text programmatically

pageText returns the Unicode of a single page; pageTextLayout returns line-level rectangles for search and highlighting:

scope.launch {
    val text = reader.pageText(pageIndex = 0)
    println(text)
}

The library's sample app wires a picker, sidebar, zoom, and selection into a full reader screen. See the repository's :example module for a complete reference.

What's next