From 0287e9d0324d0c6d4004e6a93a4b62788e92377b Mon Sep 17 00:00:00 2001 From: Elie Gambache Date: Sun, 12 Jul 2026 08:46:31 +0300 Subject: [PATCH] feat(pdfium): clickable link support (annotations + text-detected URLs) Extract link annotations (URI + internal GoTo) via FPDFLink_Enumerate and auto-detected web links (FPDFLink_LoadWebLinks, e-mails as mailto:) on all five backends (JVM/Android JNI, iOS cinterop, JS/wasmJs worker). PdfPage gains a clickable link overlay (hand cursor, accessibility semantics); external URIs open via LocalUriHandler, internal GoTo links scroll PdfReader to the destination page, and onLinkClick allows interception. --- README.md | 39 +++++ .../pdf/reader/ReaderSurface.kt | 19 ++- .../pdfium/PdfDocument.android.kt | 25 +++ .../pdfium/jvm/PdfiumBridge.android.kt | 15 ++ .../dev/nucleusframework/pdfium/PageLinks.kt | 88 +++++++++++ .../nucleusframework/pdfium/PdfDocument.kt | 3 + .../dev/nucleusframework/pdfium/PdfPage.kt | 80 ++++++++++ .../dev/nucleusframework/pdfium/PdfReader.kt | 23 +++ .../nucleusframework/pdfium/PdfReaderState.kt | 7 + .../pdfium/PdfDocument.ios.kt | 116 ++++++++++++++ .../pdfium/PdfDocument.jvm.kt | 30 ++++ .../pdfium/jvm/PdfiumBridge.kt | 15 ++ .../nucleusframework/pdfium/jvm/SmokeTest.kt | 8 + pdfium/src/jvmMain/native/pdfium_jni.cpp | 148 ++++++++++++++++++ .../pdfium/PdfDocument.web.kt | 21 +++ .../dev/nucleusframework/pdfium/PdfiumGlue.kt | 11 ++ .../webMain/resources/pdfium/pdfium_glue.mjs | 4 + .../resources/pdfium/pdfium_worker.mjs | 131 ++++++++++++++++ 18 files changed, 780 insertions(+), 3 deletions(-) create mode 100644 pdfium/src/commonMain/kotlin/dev/nucleusframework/pdfium/PageLinks.kt diff --git a/README.md b/README.md index 9f84ac2..ff5ccd4 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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: @@ -465,6 +499,8 @@ fun PdfPage( contentScale: ContentScale = ContentScale.Fit, background: Color = Color.White, selectableText: Boolean = false, + linksEnabled: Boolean = true, + onLinkClick: ((PdfLink) -> Boolean)? = null, ) ``` @@ -472,6 +508,9 @@ fun PdfPage( 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` diff --git a/example/src/commonMain/kotlin/dev/nucleusframework/pdf/reader/ReaderSurface.kt b/example/src/commonMain/kotlin/dev/nucleusframework/pdf/reader/ReaderSurface.kt index d866265..b7518f2 100644 --- a/example/src/commonMain/kotlin/dev/nucleusframework/pdf/reader/ReaderSurface.kt +++ b/example/src/commonMain/kotlin/dev/nucleusframework/pdf/reader/ReaderSurface.kt @@ -133,6 +133,7 @@ private fun ContinuousReader(state: ReaderScreenState) { entry = entry, pageWidth = pageWidth, isDouble = isDouble, + onNavigateToPage = state::jumpToPage, ) } } @@ -192,12 +193,13 @@ 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 } @@ -205,12 +207,12 @@ private fun SpreadRow( 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)) @@ -234,6 +236,7 @@ private fun PageCard( reader: PdfReaderState, pageIndex: Int, width: Dp, + onNavigateToPage: (Int) -> Unit, modifier: Modifier = Modifier, ) { Box( @@ -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 + } + }, ) } } diff --git a/pdfium/src/androidMain/kotlin/dev/nucleusframework/pdfium/PdfDocument.android.kt b/pdfium/src/androidMain/kotlin/dev/nucleusframework/pdfium/PdfDocument.android.kt index abdfe65..1e5e3e4 100644 --- a/pdfium/src/androidMain/kotlin/dev/nucleusframework/pdfium/PdfDocument.android.kt +++ b/pdfium/src/androidMain/kotlin/dev/nucleusframework/pdfium/PdfDocument.android.kt @@ -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(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) { diff --git a/pdfium/src/androidMain/kotlin/dev/nucleusframework/pdfium/jvm/PdfiumBridge.android.kt b/pdfium/src/androidMain/kotlin/dev/nucleusframework/pdfium/jvm/PdfiumBridge.android.kt index 5ca16a6..0ac65ad 100644 --- a/pdfium/src/androidMain/kotlin/dev/nucleusframework/pdfium/jvm/PdfiumBridge.android.kt +++ b/pdfium/src/androidMain/kotlin/dev/nucleusframework/pdfium/jvm/PdfiumBridge.android.kt @@ -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, + outDestPages: IntArray, + outIsWeb: BooleanArray, + ): Int @JvmStatic external fun nAllocBuffer(data: ByteArray): Long @JvmStatic external fun nFreeBuffer(address: Long) diff --git a/pdfium/src/commonMain/kotlin/dev/nucleusframework/pdfium/PageLinks.kt b/pdfium/src/commonMain/kotlin/dev/nucleusframework/pdfium/PageLinks.kt new file mode 100644 index 0000000..e7c3129 --- /dev/null +++ b/pdfium/src/commonMain/kotlin/dev/nucleusframework/pdfium/PageLinks.kt @@ -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, +) { + 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, + webLinks: List, +): 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, + destPages: IntArray, + isWeb: BooleanArray, + count: Int, +): PageLinks { + val annotationLinks = ArrayList(count) + val webLinks = ArrayList() + 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) +} diff --git a/pdfium/src/commonMain/kotlin/dev/nucleusframework/pdfium/PdfDocument.kt b/pdfium/src/commonMain/kotlin/dev/nucleusframework/pdfium/PdfDocument.kt index e2cac09..c447701 100644 --- a/pdfium/src/commonMain/kotlin/dev/nucleusframework/pdfium/PdfDocument.kt +++ b/pdfium/src/commonMain/kotlin/dev/nucleusframework/pdfium/PdfDocument.kt @@ -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() } diff --git a/pdfium/src/commonMain/kotlin/dev/nucleusframework/pdfium/PdfPage.kt b/pdfium/src/commonMain/kotlin/dev/nucleusframework/pdfium/PdfPage.kt index bc9160f..1c0c045 100644 --- a/pdfium/src/commonMain/kotlin/dev/nucleusframework/pdfium/PdfPage.kt +++ b/pdfium/src/commonMain/kotlin/dev/nucleusframework/pdfium/PdfPage.kt @@ -2,14 +2,18 @@ package dev.nucleusframework.pdfium import androidx.compose.foundation.Image import androidx.compose.foundation.background +import androidx.compose.foundation.clickable import androidx.compose.foundation.focusable import androidx.compose.foundation.gestures.awaitEachGesture import androidx.compose.foundation.gestures.awaitFirstDown import androidx.compose.foundation.gestures.drag +import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.size import androidx.compose.runtime.Composable import androidx.compose.runtime.Immutable import androidx.compose.runtime.LaunchedEffect @@ -35,12 +39,16 @@ import androidx.compose.ui.input.key.isMetaPressed import androidx.compose.ui.input.key.key import androidx.compose.ui.input.key.onPreviewKeyEvent import androidx.compose.ui.input.key.type +import androidx.compose.ui.input.pointer.PointerIcon import androidx.compose.ui.input.pointer.PointerType +import androidx.compose.ui.input.pointer.pointerHoverIcon import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.platform.LocalClipboard import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.IntSize import kotlin.math.max import kotlin.math.min @@ -61,6 +69,13 @@ import kotlinx.coroutines.launch * selection here is driven directly by PDFium's per-character bounding boxes * (`FPDFText_GetCharBox`), so the hit region of every glyph matches the rendered pixels * pixel-for-pixel — the same approach Chrome and PDF.js use. + * + * When [linksEnabled] is true (the default), the page's links — link annotations plus URLs + * and e-mail addresses detected in the text — become clickable. [onLinkClick] intercepts + * every activation first; returning `true` consumes the click. Otherwise external links + * ([PdfLink.uri]) open through [LocalUriHandler]. Internal GoTo links ([PdfLink.destPageIndex]) + * are only actionable through [onLinkClick] — [PdfReader] wires them to scroll to the target + * page; standalone [PdfPage] usages must handle them in the callback. */ @OptIn(FlowPreview::class) @Composable @@ -71,6 +86,8 @@ fun PdfPage( contentScale: ContentScale = ContentScale.Fit, background: Color = Color.White, selectableText: Boolean = false, + linksEnabled: Boolean = true, + onLinkClick: ((PdfLink) -> Boolean)? = null, ) { var size by remember { mutableStateOf(IntSize.Zero) } var pageSize by remember(pageIndex, state.pageCount) { mutableStateOf(null) } @@ -89,6 +106,13 @@ fun PdfPage( } } + var links by remember(pageIndex, linksEnabled, state.pageCount) { mutableStateOf(null) } + if (linksEnabled) { + LaunchedEffect(pageIndex, state.pageCount) { + links = state.pageLinks(pageIndex) + } + } + LaunchedEffect(pageIndex, state.pageCount) { snapshotFlow { size } .filter { it.width > 0 && it.height > 0 } @@ -140,6 +164,62 @@ fun PdfPage( if (selectableText && pl != null && pl.charCount > 0) { TextSelectionLayer(layout = pl, modifier = Modifier.fillMaxSize()) } + val lks = links + if (linksEnabled && lks != null && lks.links.isNotEmpty()) { + // Above the selection layer so link clicks win over selection anchoring. + LinkLayer(links = lks, onLinkClick = onLinkClick, modifier = Modifier.fillMaxSize()) + } + } +} + +/** + * Clickable regions over the page's links. Each link is an invisible [Box] positioned at + * the link rect (PDF points → container px), with a hand cursor on desktop and click + * semantics for accessibility. Using `clickable` (instead of raw pointer handling) keeps + * the scroll interplay correct: a touch scroll started on a link still scrolls the list. + */ +@Composable +private fun LinkLayer( + links: PageLinks, + onLinkClick: ((PdfLink) -> Boolean)?, + modifier: Modifier = Modifier, +) { + val uriHandler = LocalUriHandler.current + BoxWithConstraints(modifier) { + val density = LocalDensity.current + val containerW = with(density) { maxWidth.roundToPx() }.toFloat() + val containerH = with(density) { maxHeight.roundToPx() }.toFloat() + val pageW = links.pageSize.widthPoints + val pageH = links.pageSize.heightPoints + if (pageW <= 0f || pageH <= 0f || containerW <= 0f || containerH <= 0f) return@BoxWithConstraints + val scaleX = containerW / pageW + val scaleY = containerH / pageH + + for (link in links.links) { + val leftPx = link.left * scaleX + val topPx = containerH - link.top * scaleY + val widthPx = (link.right - link.left) * scaleX + val heightPx = (link.top - link.bottom) * scaleY + if (widthPx <= 0f || heightPx <= 0f) continue + Box( + Modifier + .offset { IntOffset(leftPx.roundToInt(), topPx.roundToInt()) } + .size( + width = with(density) { widthPx.toDp() }, + height = with(density) { heightPx.toDp() }, + ) + .pointerHoverIcon(PointerIcon.Hand) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + onClickLabel = link.uri, + ) { + if (onLinkClick?.invoke(link) != true) { + link.uri?.let { uri -> runCatching { uriHandler.openUri(uri) } } + } + }, + ) + } } } diff --git a/pdfium/src/commonMain/kotlin/dev/nucleusframework/pdfium/PdfReader.kt b/pdfium/src/commonMain/kotlin/dev/nucleusframework/pdfium/PdfReader.kt index 8c16ae1..31d4c51 100644 --- a/pdfium/src/commonMain/kotlin/dev/nucleusframework/pdfium/PdfReader.kt +++ b/pdfium/src/commonMain/kotlin/dev/nucleusframework/pdfium/PdfReader.kt @@ -7,17 +7,27 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.runtime.Composable +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp +import kotlinx.coroutines.launch +/** + * Vertical list of pages. Links are clickable by default: external URIs open through the + * platform handler, internal GoTo links scroll to the destination page. [onLinkClick] runs + * first for every activated link — return `true` to consume it and skip default handling. + */ @Composable fun PdfReader( state: PdfReaderState, modifier: Modifier = Modifier, contentPadding: PaddingValues = PaddingValues(12.dp), pageSpacing: androidx.compose.ui.unit.Dp = 16.dp, + linksEnabled: Boolean = true, + onLinkClick: ((PdfLink) -> Boolean)? = null, ) { val listState = rememberLazyListState() + val scope = rememberCoroutineScope() val pages = (0 until state.pageCount).toList() LazyColumn( modifier = modifier, @@ -30,6 +40,19 @@ fun PdfReader( state = state, pageIndex = pageIndex, modifier = Modifier.padding(horizontal = 0.dp), + linksEnabled = linksEnabled, + onLinkClick = { link -> + when { + onLinkClick?.invoke(link) == true -> true + link.destPageIndex in 0 until state.pageCount -> { + // scrollToItem teleports; animateScrollToItem would render every + // intermediate page on the way (slow for large jumps). + scope.launch { listState.scrollToItem(link.destPageIndex) } + true + } + else -> false + } + }, ) } } diff --git a/pdfium/src/commonMain/kotlin/dev/nucleusframework/pdfium/PdfReaderState.kt b/pdfium/src/commonMain/kotlin/dev/nucleusframework/pdfium/PdfReaderState.kt index 84dab42..476a665 100644 --- a/pdfium/src/commonMain/kotlin/dev/nucleusframework/pdfium/PdfReaderState.kt +++ b/pdfium/src/commonMain/kotlin/dev/nucleusframework/pdfium/PdfReaderState.kt @@ -138,6 +138,13 @@ class PdfReaderState internal constructor( return doc.pageTextLayout(pageIndex) } + /** Clickable links of a page (annotations + text-detected URLs), or null if not open. */ + suspend fun pageLinks(pageIndex: Int): PageLinks? { + val doc = document ?: return null + if (pageIndex !in 0 until pageCount) return null + return doc.pageLinks(pageIndex) + } + fun dispose() { scope.launch { openMutex.withLock { diff --git a/pdfium/src/iosMain/kotlin/dev/nucleusframework/pdfium/PdfDocument.ios.kt b/pdfium/src/iosMain/kotlin/dev/nucleusframework/pdfium/PdfDocument.ios.kt index 466a18d..3d023ef 100644 --- a/pdfium/src/iosMain/kotlin/dev/nucleusframework/pdfium/PdfDocument.ios.kt +++ b/pdfium/src/iosMain/kotlin/dev/nucleusframework/pdfium/PdfDocument.ios.kt @@ -38,6 +38,27 @@ import dev.nucleusframework.pdfium.native.FPDF_LoadPage import dev.nucleusframework.pdfium.native.FPDF_RenderPageBitmap import cnames.structs.fpdf_document_t__ import cnames.structs.fpdf_form_handle_t__ +import cnames.structs.fpdf_link_t__ +import dev.nucleusframework.pdfium.native.FPDFAction_GetDest +import dev.nucleusframework.pdfium.native.FPDFAction_GetType +import dev.nucleusframework.pdfium.native.FPDFAction_GetURIPath +import dev.nucleusframework.pdfium.native.FPDFDest_GetDestPageIndex +import dev.nucleusframework.pdfium.native.FPDFLink_CloseWebLinks +import dev.nucleusframework.pdfium.native.FPDFLink_CountRects +import dev.nucleusframework.pdfium.native.FPDFLink_CountWebLinks +import dev.nucleusframework.pdfium.native.FPDFLink_Enumerate +import dev.nucleusframework.pdfium.native.FPDFLink_GetAction +import dev.nucleusframework.pdfium.native.FPDFLink_GetAnnotRect +import dev.nucleusframework.pdfium.native.FPDFLink_GetDest +import dev.nucleusframework.pdfium.native.FPDFLink_GetRect +import dev.nucleusframework.pdfium.native.FPDFLink_GetURL +import dev.nucleusframework.pdfium.native.FPDFLink_LoadWebLinks +import dev.nucleusframework.pdfium.native.FS_RECTF +import dev.nucleusframework.pdfium.native.PDFACTION_GOTO +import dev.nucleusframework.pdfium.native.PDFACTION_UNSUPPORTED +import dev.nucleusframework.pdfium.native.PDFACTION_URI +import kotlinx.cinterop.CPointerVar +import kotlinx.cinterop.IntVar import kotlinx.cinterop.nativeHeap import kotlin.concurrent.AtomicReference import kotlinx.cinterop.ByteVar @@ -257,6 +278,101 @@ internal actual class PdfDocument( } } + actual suspend fun pageLinks(pageIndex: Int): PageLinks = withContext(pdfiumDispatcher) { + val page = FPDF_LoadPage(handle, pageIndex) ?: return@withContext PageLinks.Empty + try { + val size = PageSize( + widthPoints = FPDF_GetPageWidthF(page), + heightPoints = FPDF_GetPageHeightF(page), + ) + val annotationLinks = ArrayList() + val webLinks = ArrayList() + memScoped { + // Link annotations. + val pos = alloc() + pos.value = 0 + val linkVar = alloc>() + while (FPDFLink_Enumerate(page, pos.ptr, linkVar.ptr) != 0) { + val link = linkVar.value ?: continue + val rect = alloc() + if (FPDFLink_GetAnnotRect(link, rect.ptr) == 0) continue + val action = FPDFLink_GetAction(link) + val type = if (action != null) FPDFAction_GetType(action).toInt() else PDFACTION_UNSUPPORTED + var uri: String? = null + var destPage = -1 + if (type == PDFACTION_URI) { + // URI path is a NUL-terminated byte string (7-bit ASCII per PDF spec). + val len = FPDFAction_GetURIPath(handle, action, null, 0.convert()).toInt() + if (len > 1) { + val buf = allocArray(len) + FPDFAction_GetURIPath(handle, action, buf, len.convert()) + val bytes = ByteArray(len - 1) + for (i in bytes.indices) bytes[i] = buf[i] + uri = bytes.decodeToString().trimEnd('\u0000').ifEmpty { null } + } + } else { + var dest = FPDFLink_GetDest(handle, link) + if (dest == null && type == PDFACTION_GOTO && action != null) { + dest = FPDFAction_GetDest(handle, action) + } + if (dest != null) destPage = FPDFDest_GetDestPageIndex(handle, dest) + } + if (uri == null && destPage < 0) continue // nothing actionable + annotationLinks.add( + PdfLink(rect.left, rect.bottom, rect.right, rect.top, uri, destPage), + ) + } + // Web links: URLs + e-mails (as mailto:) detected in the page text. + val textPage = FPDFText_LoadPage(page) + if (textPage != null) { + try { + val pageLink = FPDFLink_LoadWebLinks(textPage) + if (pageLink != null) { + try { + val l = alloc() + val t = alloc() + val r = alloc() + val b = alloc() + val count = FPDFLink_CountWebLinks(pageLink) + for (i in 0 until count) { + val len = FPDFLink_GetURL(pageLink, i, null, 0) + if (len <= 1) continue + val buf = allocArray(len) + FPDFLink_GetURL(pageLink, i, buf, len) + val chars = CharArray(len - 1) + for (j in chars.indices) chars[j] = buf[j].toInt().toChar() + val uri = chars.concatToString().trim('\u0000') + if (uri.isEmpty()) continue + // A URL wrapping across lines yields one rect per line. + val rects = FPDFLink_CountRects(pageLink, i) + for (ri in 0 until rects) { + if (FPDFLink_GetRect(pageLink, i, ri, l.ptr, t.ptr, r.ptr, b.ptr) == 0) continue + webLinks.add( + PdfLink( + left = l.value.toFloat(), + bottom = b.value.toFloat(), + right = r.value.toFloat(), + top = t.value.toFloat(), + uri = uri, + ), + ) + } + } + } finally { + FPDFLink_CloseWebLinks(pageLink) + } + } + } finally { + FPDFText_ClosePage(textPage) + } + } + } + buildPageLinks(pageIndex, size, annotationLinks, webLinks) + } finally { + FPDF_ClosePage(page) + } + } + actual fun close() { // Form-fill env must be torn down BEFORE the document — PDFium dereferences the // document inside FPDFDOC_ExitFormFillEnvironment. diff --git a/pdfium/src/jvmMain/kotlin/dev/nucleusframework/pdfium/PdfDocument.jvm.kt b/pdfium/src/jvmMain/kotlin/dev/nucleusframework/pdfium/PdfDocument.jvm.kt index 3061659..d17425f 100644 --- a/pdfium/src/jvmMain/kotlin/dev/nucleusframework/pdfium/PdfDocument.jvm.kt +++ b/pdfium/src/jvmMain/kotlin/dev/nucleusframework/pdfium/PdfDocument.jvm.kt @@ -157,6 +157,36 @@ 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 = try { + PdfiumBridge.nCountPageLinks(handle, page) + } catch (_: UnsatisfiedLinkError) { + // Prebuilt JNI glue for this OS predates the link API — degrade to no links. + return@withContext PageLinks(pageIndex, size, emptyList()) + } + if (count <= 0) return@withContext PageLinks(pageIndex, size, emptyList()) + val boxes = FloatArray(count * 4) + val uris = arrayOfNulls(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) { diff --git a/pdfium/src/jvmMain/kotlin/dev/nucleusframework/pdfium/jvm/PdfiumBridge.kt b/pdfium/src/jvmMain/kotlin/dev/nucleusframework/pdfium/jvm/PdfiumBridge.kt index 8fdcb43..12f26a9 100644 --- a/pdfium/src/jvmMain/kotlin/dev/nucleusframework/pdfium/jvm/PdfiumBridge.kt +++ b/pdfium/src/jvmMain/kotlin/dev/nucleusframework/pdfium/jvm/PdfiumBridge.kt @@ -69,6 +69,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, + outDestPages: IntArray, + outIsWeb: BooleanArray, + ): Int // Shared-buffer document pool support: /** Allocate a native buffer holding [data]. Returns a raw pointer; free with [nFreeBuffer]. */ diff --git a/pdfium/src/jvmMain/kotlin/dev/nucleusframework/pdfium/jvm/SmokeTest.kt b/pdfium/src/jvmMain/kotlin/dev/nucleusframework/pdfium/jvm/SmokeTest.kt index b2ff5e3..ad09e42 100644 --- a/pdfium/src/jvmMain/kotlin/dev/nucleusframework/pdfium/jvm/SmokeTest.kt +++ b/pdfium/src/jvmMain/kotlin/dev/nucleusframework/pdfium/jvm/SmokeTest.kt @@ -38,6 +38,14 @@ fun main(args: Array) = runBlocking { println("[kotlin] ImageBitmap ${bmp.width}x${bmp.height}") val text = doc.pageText(0) println("[kotlin] page0 text length=${text.length} preview=${text.take(60).replace("\n", "⏎")}") + val links = doc.pageLinks(0) + println("[kotlin] page0 links=${links.links.size}") + for (link in links.links) { + println( + " uri=${link.uri} dest=${link.destPageIndex} " + + "rect=(${link.left}, ${link.bottom}, ${link.right}, ${link.top})", + ) + } // Concurrency stress: fan out N parallel render+text+size calls, mirroring the sample app. println("[stress] launching 64 parallel render+text+size calls…") diff --git a/pdfium/src/jvmMain/native/pdfium_jni.cpp b/pdfium/src/jvmMain/native/pdfium_jni.cpp index e99dc85..409ce01 100644 --- a/pdfium/src/jvmMain/native/pdfium_jni.cpp +++ b/pdfium/src/jvmMain/native/pdfium_jni.cpp @@ -9,6 +9,7 @@ #include #include #include +#include #include "fpdfview.h" #include "fpdf_doc.h" @@ -66,6 +67,103 @@ jstring toJString(JNIEnv* env, const std::string& s) { return env->NewStringUTF(s.c_str()); } +// UTF-16 → UTF-8 (BMP + surrogate pair support). +std::string utf16ToUtf8(const std::u16string& in) { + std::string utf8; + utf8.reserve(in.size()); + for (size_t i = 0; i < in.size(); ++i) { + char16_t c = in[i]; + if (c < 0x80) { + utf8.push_back(static_cast(c)); + } else if (c < 0x800) { + utf8.push_back(static_cast(0xC0 | (c >> 6))); + utf8.push_back(static_cast(0x80 | (c & 0x3F))); + } else if (c >= 0xD800 && c <= 0xDBFF && i + 1 < in.size() + && in[i + 1] >= 0xDC00 && in[i + 1] <= 0xDFFF) { + uint32_t cp = 0x10000 + ((c - 0xD800u) << 10) + (in[i + 1] - 0xDC00u); + utf8.push_back(static_cast(0xF0 | (cp >> 18))); + utf8.push_back(static_cast(0x80 | ((cp >> 12) & 0x3F))); + utf8.push_back(static_cast(0x80 | ((cp >> 6) & 0x3F))); + utf8.push_back(static_cast(0x80 | (cp & 0x3F))); + ++i; + } else { + utf8.push_back(static_cast(0xE0 | (c >> 12))); + utf8.push_back(static_cast(0x80 | ((c >> 6) & 0x3F))); + utf8.push_back(static_cast(0x80 | (c & 0x3F))); + } + } + return utf8; +} + +// One clickable region on a page. `web` marks entries produced by PDFium's text scanner +// (FPDFLink_LoadWebLinks) as opposed to real link annotations. +struct LinkEntry { + float left, bottom, right, top; + std::string uri; // UTF-8, empty if none + int destPage; // 0-based target page for GoTo links, -1 if none + bool web; +}; + +// Collect link annotations, then text-detected web links (URLs + mailto: for e-mails). +// De-duplication of overlapping entries is done on the Kotlin side. +void collectPageLinks(FPDF_DOCUMENT doc, FPDF_PAGE page, std::vector& out) { + int pos = 0; + FPDF_LINK link = nullptr; + while (FPDFLink_Enumerate(page, &pos, &link)) { + if (!link) continue; + FS_RECTF rect{}; + if (!FPDFLink_GetAnnotRect(link, &rect)) continue; + LinkEntry e{rect.left, rect.bottom, rect.right, rect.top, {}, -1, false}; + FPDF_ACTION action = FPDFLink_GetAction(link); + unsigned long type = action ? FPDFAction_GetType(action) : PDFACTION_UNSUPPORTED; + if (type == PDFACTION_URI) { + unsigned long len = FPDFAction_GetURIPath(doc, action, nullptr, 0); + if (len > 1) { + // URI path is a NUL-terminated byte string (7-bit ASCII per PDF spec). + std::string buf(len, '\0'); + FPDFAction_GetURIPath(doc, action, buf.data(), len); + while (!buf.empty() && buf.back() == '\0') buf.pop_back(); + e.uri = buf; + } + } else { + FPDF_DEST dest = FPDFLink_GetDest(doc, link); + if (!dest && type == PDFACTION_GOTO) dest = FPDFAction_GetDest(doc, action); + if (dest) e.destPage = FPDFDest_GetDestPageIndex(doc, dest); + } + if (e.uri.empty() && e.destPage < 0) continue; // nothing actionable + out.push_back(std::move(e)); + } + + FPDF_TEXTPAGE tp = FPDFText_LoadPage(page); + if (!tp) return; + FPDF_PAGELINK webLinks = FPDFLink_LoadWebLinks(tp); + if (webLinks) { + int n = FPDFLink_CountWebLinks(webLinks); + for (int i = 0; i < n; ++i) { + int len = FPDFLink_GetURL(webLinks, i, nullptr, 0); + if (len <= 1) continue; + std::u16string buf(static_cast(len), u'\0'); + FPDFLink_GetURL(webLinks, i, reinterpret_cast(buf.data()), len); + while (!buf.empty() && buf.back() == u'\0') buf.pop_back(); + std::string uri = utf16ToUtf8(buf); + if (uri.empty()) continue; + // A URL wrapping across lines yields one rect per line — each is clickable. + int rects = FPDFLink_CountRects(webLinks, i); + for (int r = 0; r < rects; ++r) { + double left = 0, top = 0, right = 0, bottom = 0; + if (!FPDFLink_GetRect(webLinks, i, r, &left, &top, &right, &bottom)) continue; + out.push_back(LinkEntry{ + static_cast(left), static_cast(bottom), + static_cast(right), static_cast(top), + uri, -1, true, + }); + } + } + FPDFLink_CloseWebLinks(webLinks); + } + FPDFText_ClosePage(tp); +} + } // namespace extern "C" { @@ -488,4 +586,54 @@ Java_dev_nucleusframework_pdfium_jvm_PdfiumBridge_nExtractCharBoxes( return count; } +/** + * Count the clickable link entries on [page]: link annotations plus one entry per rect of + * every text-detected web link. Requires [doc] to resolve GoTo destinations. + */ +JNIEXPORT jint JNICALL +Java_dev_nucleusframework_pdfium_jvm_PdfiumBridge_nCountPageLinks( + JNIEnv*, jclass, jlong doc, jlong page) { + if (doc == 0 || page == 0) return 0; + std::vector links; + collectPageLinks(reinterpret_cast(doc), reinterpret_cast(page), links); + return static_cast(links.size()); +} + +/** + * Fill caller-sized arrays with link data: [outBoxes] holds 4 floats per link (left, bottom, + * right, top in PDF page points), [outUris] the UTF-8 target URI or null, [outDestPages] the + * 0-based GoTo target page or -1, and [outIsWeb] whether the entry was text-detected rather + * than a link annotation. Returns the number of links written. + */ +JNIEXPORT jint JNICALL +Java_dev_nucleusframework_pdfium_jvm_PdfiumBridge_nExtractPageLinks( + JNIEnv* env, jclass, jlong doc, jlong page, + jfloatArray outBoxes, jobjectArray outUris, jintArray outDestPages, jbooleanArray outIsWeb) { + if (doc == 0 || page == 0) return 0; + std::vector links; + collectPageLinks(reinterpret_cast(doc), reinterpret_cast(page), links); + int capacity = env->GetArrayLength(outBoxes) / 4; + int count = static_cast(links.size()) < capacity ? static_cast(links.size()) : capacity; + if (count == 0) return 0; + jfloat* boxes = env->GetFloatArrayElements(outBoxes, nullptr); + jint* destPages = env->GetIntArrayElements(outDestPages, nullptr); + jboolean* isWeb = env->GetBooleanArrayElements(outIsWeb, nullptr); + for (int i = 0; i < count; ++i) { + const LinkEntry& e = links[i]; + boxes[i * 4 + 0] = e.left; + boxes[i * 4 + 1] = e.bottom; + boxes[i * 4 + 2] = e.right; + boxes[i * 4 + 3] = e.top; + destPages[i] = e.destPage; + isWeb[i] = e.web ? JNI_TRUE : JNI_FALSE; + jstring js = toJString(env, e.uri); + env->SetObjectArrayElement(outUris, i, js); + if (js) env->DeleteLocalRef(js); + } + env->ReleaseFloatArrayElements(outBoxes, boxes, 0); + env->ReleaseIntArrayElements(outDestPages, destPages, 0); + env->ReleaseBooleanArrayElements(outIsWeb, isWeb, 0); + return count; +} + } // extern "C" diff --git a/pdfium/src/webMain/kotlin/dev/nucleusframework/pdfium/PdfDocument.web.kt b/pdfium/src/webMain/kotlin/dev/nucleusframework/pdfium/PdfDocument.web.kt index 0c71307..029ae6f 100644 --- a/pdfium/src/webMain/kotlin/dev/nucleusframework/pdfium/PdfDocument.web.kt +++ b/pdfium/src/webMain/kotlin/dev/nucleusframework/pdfium/PdfDocument.web.kt @@ -58,6 +58,27 @@ internal actual class PdfDocument internal constructor( return PageTextLayout(pageIndex, size, rectBoxes, rectTexts, charCodepoints, charBoxes) } + actual suspend fun pageLinks(pageIndex: Int): PageLinks { + val r = pageLinks(docPtr, pageIndex).awaitTyped() + val size = PageSize(r.widthPoints, r.heightPoints) + val boxes = r.boxes.toSharedFloatArray() + val destPages = r.destPages.toSharedIntArray() + val annotationLinks = ArrayList(r.annotCount) + val webLinks = ArrayList() + for (i in 0 until destPages.size) { + val link = PdfLink( + left = boxes[i * 4], + bottom = boxes[i * 4 + 1], + right = boxes[i * 4 + 2], + top = boxes[i * 4 + 3], + uri = r.uris[i]?.toString(), + destPageIndex = destPages[i], + ) + if (i < r.annotCount) annotationLinks.add(link) else webLinks.add(link) + } + return buildPageLinks(pageIndex, size, annotationLinks, webLinks) + } + actual fun close() { // Fire-and-forget; the worker frees both the doc handle and the buffer ptr. closeDocument(docPtr) diff --git a/pdfium/src/webMain/kotlin/dev/nucleusframework/pdfium/PdfiumGlue.kt b/pdfium/src/webMain/kotlin/dev/nucleusframework/pdfium/PdfiumGlue.kt index 02acb08..4f16676 100644 --- a/pdfium/src/webMain/kotlin/dev/nucleusframework/pdfium/PdfiumGlue.kt +++ b/pdfium/src/webMain/kotlin/dev/nucleusframework/pdfium/PdfiumGlue.kt @@ -52,9 +52,20 @@ internal external interface TextLayoutResult : JsAny { val charBoxes: Float32Array } +internal external interface PageLinksResult : JsAny { + val widthPoints: Float + val heightPoints: Float + /** Entries below this index are link annotations; the rest are text-detected web links. */ + val annotCount: Int + val boxes: Float32Array + val uris: JsArray + val destPages: Int32Array +} + internal external fun openDocument(buffer: ArrayBuffer, password: String?): Promise internal external fun closeDocument(doc: Int): Promise internal external fun pageSize(doc: Int, pageIndex: Int): Promise internal external fun renderPage(doc: Int, pageIndex: Int, w: Int, h: Int, flags: Int): Promise internal external fun pageText(doc: Int, pageIndex: Int): Promise internal external fun pageTextLayout(doc: Int, pageIndex: Int): Promise +internal external fun pageLinks(doc: Int, pageIndex: Int): Promise diff --git a/pdfium/src/webMain/resources/pdfium/pdfium_glue.mjs b/pdfium/src/webMain/resources/pdfium/pdfium_glue.mjs index 82d33c4..6b19a76 100644 --- a/pdfium/src/webMain/resources/pdfium/pdfium_glue.mjs +++ b/pdfium/src/webMain/resources/pdfium/pdfium_glue.mjs @@ -77,3 +77,7 @@ export function pageText(doc, pageIndex) { export function pageTextLayout(doc, pageIndex) { return rpc('layout', { doc, pageIndex }); } + +export function pageLinks(doc, pageIndex) { + return rpc('links', { doc, pageIndex }); +} diff --git a/pdfium/src/webMain/resources/pdfium/pdfium_worker.mjs b/pdfium/src/webMain/resources/pdfium/pdfium_worker.mjs index d91196e..53abf5c 100644 --- a/pdfium/src/webMain/resources/pdfium/pdfium_worker.mjs +++ b/pdfium/src/webMain/resources/pdfium/pdfium_worker.mjs @@ -293,6 +293,136 @@ function pageTextLayout({ doc, pageIndex }) { } } +// PDFACTION_* types from fpdf_doc.h. +const PDFACTION_GOTO = 1; +const PDFACTION_URI = 3; + +function pageLinks({ doc, pageIndex }) { + const page = M._FPDF_LoadPage(doc, pageIndex); + if (!page) return { result: emptyLinks() }; + try { + const widthPoints = M._FPDF_GetPageWidthF(page); + const heightPoints = M._FPDF_GetPageHeightF(page); + // Flat accumulators: 4 floats per link (left, bottom, right, top in PDF points). + // Annotation links are pushed first, then text-detected web links — `annotCount` + // lets the Kotlin side keep the two apart for overlap de-duplication. + const boxes = []; + const uris = []; + const destPages = []; + let annotCount = 0; + const scratch = M._malloc(32); // pos+link ptrs / FS_RECTF / 4 × f64 web rect + try { + // ---- link annotations ---- + const posPtr = scratch; // int + const linkPtr = scratch + 4; // FPDF_LINK* + M.HEAP32[posPtr >> 2] = 0; + while (M._FPDFLink_Enumerate(page, posPtr, linkPtr)) { + const link = M.HEAPU32[linkPtr >> 2]; + if (!link) continue; + const rectPtr = scratch + 8; // FS_RECTF: left, top, right, bottom (4 × f32) + if (!M._FPDFLink_GetAnnotRect(link, rectPtr)) continue; + const left = M.HEAPF32[rectPtr >> 2]; + const top = M.HEAPF32[(rectPtr >> 2) + 1]; + const right = M.HEAPF32[(rectPtr >> 2) + 2]; + const bottom = M.HEAPF32[(rectPtr >> 2) + 3]; + let uri = null; + let destPage = -1; + const action = M._FPDFLink_GetAction(link); + const type = action ? M._FPDFAction_GetType(action) : 0; + if (type === PDFACTION_URI) { + const len = M._FPDFAction_GetURIPath(doc, action, 0, 0); + if (len > 1) { + const buf = M._malloc(len); + try { + M._FPDFAction_GetURIPath(doc, action, buf, len); + // NUL-terminated byte string (7-bit ASCII per PDF spec). + const bytes = M.HEAPU8.subarray(buf, buf + len); + let end = bytes.indexOf(0); + if (end < 0) end = len; + uri = new TextDecoder().decode(bytes.subarray(0, end)) || null; + } finally { + M._free(buf); + } + } + } else { + let dest = M._FPDFLink_GetDest(doc, link); + if (!dest && type === PDFACTION_GOTO && action) dest = M._FPDFAction_GetDest(doc, action); + if (dest) destPage = M._FPDFDest_GetDestPageIndex(doc, dest); + } + if (uri == null && destPage < 0) continue; // nothing actionable + boxes.push(left, bottom, right, top); + uris.push(uri); + destPages.push(destPage); + } + annotCount = uris.length; + // ---- web links: URLs + e-mails (as mailto:) detected in the page text ---- + const tp = M._FPDFText_LoadPage(page); + if (tp) { + try { + const pageLink = M._FPDFLink_LoadWebLinks(tp); + if (pageLink) { + try { + const n = M._FPDFLink_CountWebLinks(pageLink); + for (let i = 0; i < n; i++) { + const len = M._FPDFLink_GetURL(pageLink, i, 0, 0); + if (len <= 1) continue; + const buf = M._malloc(len * 2); + let uri; + try { + M._FPDFLink_GetURL(pageLink, i, buf, len); + uri = readUtf16LEFromPtr(buf, len - 1).replace(/\0+$/, ''); + } finally { + M._free(buf); + } + if (!uri) continue; + // A URL wrapping across lines yields one rect per line. + const rects = M._FPDFLink_CountRects(pageLink, i); + for (let r = 0; r < rects; r++) { + if (!M._FPDFLink_GetRect(pageLink, i, r, scratch, scratch + 8, scratch + 16, scratch + 24)) continue; + const dv = new DataView(M.HEAPU8.buffer, scratch, 32); + boxes.push( + dv.getFloat64(0, true), // left + dv.getFloat64(24, true), // bottom + dv.getFloat64(16, true), // right + dv.getFloat64(8, true), // top + ); + uris.push(uri); + destPages.push(-1); + } + } + } finally { + M._FPDFLink_CloseWebLinks(pageLink); + } + } + } finally { + M._FPDFText_ClosePage(tp); + } + } + } finally { + M._free(scratch); + } + const boxesArr = new Float32Array(boxes); + const destArr = new Int32Array(destPages); + return { + result: { widthPoints, heightPoints, annotCount, boxes: boxesArr, uris, destPages: destArr }, + transfer: [boxesArr.buffer, destArr.buffer], + }; + } finally { + M._FPDF_ClosePage(page); + } +} + +function emptyLinks(widthPoints = 0, heightPoints = 0) { + return { + widthPoints, + heightPoints, + annotCount: 0, + boxes: new Float32Array(0), + uris: [], + destPages: new Int32Array(0), + }; +} + function emptyLayout(widthPoints = 0, heightPoints = 0) { return { widthPoints, @@ -313,6 +443,7 @@ const handlers = { render: renderPage, text: pageText, layout: pageTextLayout, + links: pageLinks, }; self.onmessage = (e) => {