Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,10 @@ text round it out.
per-character bounding boxes.
- **Selectable text overlay** driven by PDFium's per-character boxes, so Ctrl+C
and long-press copy return the exact PDF text.
- **Clickable links** — link annotations (URI + internal GoTo) plus URLs and
e-mail addresses auto-detected in the page text (`mailto:` links included).
External links open through the platform handler, internal links scroll the
reader to the destination page.
- **Cross-platform fit/zoom controls** via a plain state holder.

## Supported targets
Expand Down Expand Up @@ -189,6 +193,36 @@ PdfPage(
Hit-testing uses PDFium's per-character boxes (`FPDFText_GetCharBox`) rather
than Compose's own font metrics, so selection tracks the rendered glyphs.

### 4b. Clickable links

Links are enabled by default on `PdfPage` and `PdfReader` — nothing to flip.
Link annotations (`FPDFLink_Enumerate`) and URLs / e-mail addresses detected
in the page text (`FPDFLink_LoadWebLinks`, e-mails become `mailto:`) turn into
clickable regions with a hand cursor on desktop. External URIs open through
`LocalUriHandler`; inside `PdfReader`, internal GoTo links scroll to the
destination page.

Intercept clicks (analytics, custom navigation, blocking) with `onLinkClick` —
return `true` to consume the click and skip the default handling:

```kotlin
PdfReader(
state = reader,
onLinkClick = { link ->
if (link.uri?.startsWith("mailto:") == true) {
openCustomComposer(link.uri)
true // consumed — default handler skipped
} else {
false // fall through to default handling
}
},
)
```

Fetch links programmatically with `reader.pageLinks(pageIndex)` — each
`PdfLink` carries its bounds in PDF points (origin bottom-left), the target
`uri` (or `null`), and the 0-based `destPageIndex` (or `-1`).

### 5. Extract text programmatically

Grab the full Unicode of a single page:
Expand Down Expand Up @@ -465,13 +499,18 @@ fun PdfPage(
contentScale: ContentScale = ContentScale.Fit,
background: Color = Color.White,
selectableText: Boolean = false,
linksEnabled: Boolean = true,
onLinkClick: ((PdfLink) -> Boolean)? = null,
)
```

- `modifier` controls the layout width; the composable derives the aspect
ratio from the PDF page and sets its own height.
- `selectableText = true` enables the pointer-driven selection overlay
described in [step 4](#4-enable-copypaste-and-text-selection).
- `linksEnabled` / `onLinkClick` control the clickable-links overlay described
in [step 4b](#4b-clickable-links). A standalone `PdfPage` has no list to
scroll, so internal GoTo links are only actionable through `onLinkClick`.

### `PdfThumbnail`

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,7 @@ private fun ContinuousReader(state: ReaderScreenState) {
entry = entry,
pageWidth = pageWidth,
isDouble = isDouble,
onNavigateToPage = state::jumpToPage,
)
}
}
Expand Down Expand Up @@ -192,25 +193,26 @@ private fun SpreadRow(
entry: SpreadEntry,
pageWidth: Dp,
isDouble: Boolean,
onNavigateToPage: (Int) -> Unit,
modifier: Modifier = Modifier,
) {
if (!isDouble) {
Column(modifier.fillMaxWidth().wrapContentWidth(Alignment.CenterHorizontally)) {
PageLabel(entry.first)
PageCard(reader = reader, pageIndex = entry.first, width = pageWidth)
PageCard(reader = reader, pageIndex = entry.first, width = pageWidth, onNavigateToPage = onNavigateToPage)
}
return
}
Column(modifier.fillMaxWidth().wrapContentWidth(Alignment.CenterHorizontally)) {
Row(horizontalArrangement = Arrangement.spacedBy(PAGE_GAP)) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
PageLabel(entry.first)
PageCard(reader = reader, pageIndex = entry.first, width = pageWidth)
PageCard(reader = reader, pageIndex = entry.first, width = pageWidth, onNavigateToPage = onNavigateToPage)
}
Column(horizontalAlignment = Alignment.CenterHorizontally) {
if (entry.second != null) {
PageLabel(entry.second)
PageCard(reader = reader, pageIndex = entry.second, width = pageWidth)
PageCard(reader = reader, pageIndex = entry.second, width = pageWidth, onNavigateToPage = onNavigateToPage)
} else {
// Phantom slot for odd last-page: keeps the row width stable.
Box(Modifier.width(pageWidth))
Expand All @@ -234,6 +236,7 @@ private fun PageCard(
reader: PdfReaderState,
pageIndex: Int,
width: Dp,
onNavigateToPage: (Int) -> Unit,
modifier: Modifier = Modifier,
) {
Box(
Expand All @@ -249,6 +252,16 @@ private fun PageCard(
modifier = Modifier.fillMaxWidth(),
background = Color.White,
selectableText = true,
onLinkClick = { link ->
// Internal GoTo links jump within the document; URIs fall through to the
// default handler (platform browser / mail client).
if (link.destPageIndex >= 0) {
onNavigateToPage(link.destPageIndex)
true
} else {
false
}
},
)
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,31 @@ internal actual class PdfDocument internal constructor(
}
}

actual suspend fun pageLinks(pageIndex: Int): PageLinks {
val slot = pickSlot()
return withContext(dispatchers[slot]) {
val handle = handles[slot]
val page = PdfiumBridge.nLoadPage(handle, pageIndex)
if (page == 0L) return@withContext PageLinks.Empty
try {
val size = PageSize(
widthPoints = PdfiumBridge.nGetPageWidth(page),
heightPoints = PdfiumBridge.nGetPageHeight(page),
)
val count = PdfiumBridge.nCountPageLinks(handle, page)
if (count <= 0) return@withContext PageLinks(pageIndex, size, emptyList())
val boxes = FloatArray(count * 4)
val uris = arrayOfNulls<String>(count)
val destPages = IntArray(count)
val isWeb = BooleanArray(count)
val written = PdfiumBridge.nExtractPageLinks(handle, page, boxes, uris, destPages, isWeb)
pageLinksFromArrays(pageIndex, size, boxes, uris, destPages, isWeb, written)
} finally {
PdfiumBridge.nClosePage(page)
}
}
}

actual fun close() {
runBlocking {
for (i in handles.indices) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,21 @@ internal object PdfiumBridge {
outCodepoints: IntArray,
outBoxes: FloatArray,
): Int
/** Count of clickable links (annotations + text-detected web-link rects) on the page. */
@JvmStatic external fun nCountPageLinks(doc: Long, page: Long): Int
/**
* Fill pre-sized arrays with link data: [outBoxes] holds 4 floats per link (left, bottom,
* right, top in points), [outUris] the target URI or null, [outDestPages] the 0-based
* GoTo target page or -1, [outIsWeb] whether the link was text-detected.
*/
@JvmStatic external fun nExtractPageLinks(
doc: Long,
page: Long,
outBoxes: FloatArray,
outUris: Array<String?>,
outDestPages: IntArray,
outIsWeb: BooleanArray,
): Int

@JvmStatic external fun nAllocBuffer(data: ByteArray): Long
@JvmStatic external fun nFreeBuffer(address: Long)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
package dev.nucleusframework.pdfium

import androidx.compose.runtime.Immutable

/**
* A clickable region on a PDF page. Coordinates are in PDF page points with origin at the
* bottom-left of the page (same space as [PageTextLayout]).
*
* Exactly one target is typically set:
* - [uri]: external target (`https://…`, `mailto:…`) from a URI action, or auto-detected
* in the page text by PDFium's web-link extractor (which also recognizes e-mail
* addresses and prefixes them with `mailto:`)
* - [destPageIndex]: 0-based target page of an internal GoTo destination, or -1 if none
*/
@Immutable
data class PdfLink(
val left: Float,
val bottom: Float,
val right: Float,
val top: Float,
val uri: String? = null,
val destPageIndex: Int = -1,
)

/** All clickable links of a single page, from link annotations and text-detected URLs. */
@Immutable
class PageLinks internal constructor(
val pageIndex: Int,
val pageSize: PageSize,
val links: List<PdfLink>,
) {
companion object {
val Empty = PageLinks(pageIndex = -1, pageSize = PageSize(0f, 0f), links = emptyList())
}
}

/**
* Merge annotation links with text-detected web links. A web link whose center falls inside
* an annotation rect is dropped — authors commonly place a link annotation over the printed
* URL, and the annotation's action is authoritative.
*/
internal fun buildPageLinks(
pageIndex: Int,
pageSize: PageSize,
annotationLinks: List<PdfLink>,
webLinks: List<PdfLink>,
): PageLinks {
val merged = if (webLinks.isEmpty()) {
annotationLinks
} else {
annotationLinks + webLinks.filter { web ->
val cx = (web.left + web.right) * 0.5f
val cy = (web.bottom + web.top) * 0.5f
annotationLinks.none { cx in it.left..it.right && cy in it.bottom..it.top }
}
}
return PageLinks(pageIndex, pageSize, merged)
}

/**
* Build [PageLinks] from the flat arrays the JNI bridge fills: 4 floats per link in [boxes]
* (left, bottom, right, top in PDF points), the target URI or null in [uris], the 0-based
* GoTo page or -1 in [destPages], and whether the entry was text-detected in [isWeb].
*/
internal fun pageLinksFromArrays(
pageIndex: Int,
pageSize: PageSize,
boxes: FloatArray,
uris: Array<String?>,
destPages: IntArray,
isWeb: BooleanArray,
count: Int,
): PageLinks {
val annotationLinks = ArrayList<PdfLink>(count)
val webLinks = ArrayList<PdfLink>()
for (i in 0 until count) {
val link = PdfLink(
left = boxes[i * 4],
bottom = boxes[i * 4 + 1],
right = boxes[i * 4 + 2],
top = boxes[i * 4 + 3],
uri = uris[i],
destPageIndex = destPages[i],
)
if (isWeb[i]) webLinks.add(link) else annotationLinks.add(link)
}
return buildPageLinks(pageIndex, pageSize, annotationLinks, webLinks)
}
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,9 @@ internal expect class PdfDocument {
/** Extract line-level text rectangles of [pageIndex] for selection overlays. */
suspend fun pageTextLayout(pageIndex: Int): PageTextLayout

/** Extract clickable links of [pageIndex]: link annotations + URLs detected in the text. */
suspend fun pageLinks(pageIndex: Int): PageLinks

fun close()
}

Expand Down
Loading
Loading