From e244271bd0a067c451eb8bc7d73f8567546f6ecd Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 19 Apr 2026 15:46:25 +0000 Subject: [PATCH] feat(pdf): zoom-aware hi-res re-render for crisp pinch-zoom Base bitmap stays at 3072 px for the initial render. When the user zooms past 1.2x and scale settles for 200 ms, asynchronously re-render the current page at (VIEWER_MAX_DIM_PX * scale) capped at 6144 px (~100 MB peak for an A4 page). Swap the hi-res bitmap in while zoomed; revert to base when scale drops back under threshold or the page leaves composition. Only the currently-focused page gets the hi-res treatment. Also apply FilterQuality.High to the Image composable in both the viewer and the preview card so any residual GPU upscaling uses bicubic-ish sampling instead of bilinear. Factored the render path into a shared renderPageCatching() helper. --- .../ui/components/pdf/PdfPreviewCard.kt | 2 + .../ui/components/pdf/PdfViewerDialog.kt | 106 +++++++++++++----- 2 files changed, 80 insertions(+), 28 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/pdf/PdfPreviewCard.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/pdf/PdfPreviewCard.kt index 5d01fcb21..277bddafe 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/pdf/PdfPreviewCard.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/pdf/PdfPreviewCard.kt @@ -45,6 +45,7 @@ import androidx.compose.runtime.produceState import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.FilterQuality import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalConfiguration @@ -170,6 +171,7 @@ private fun LoadedPdfPreviewCard( bitmap = current.preview.thumbnail.asImageBitmap(), contentDescription = content.description ?: filename, contentScale = ContentScale.FillWidth, + filterQuality = FilterQuality.High, modifier = Modifier .fillMaxWidth() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/pdf/PdfViewerDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/pdf/PdfViewerDialog.kt index 8f3ef0ef4..c3e0bda95 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/pdf/PdfViewerDialog.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/pdf/PdfViewerDialog.kt @@ -49,14 +49,17 @@ import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.produceState import androidx.compose.runtime.remember import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.FilterQuality import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.window.Dialog @@ -74,17 +77,29 @@ import com.vitorpamplona.amethyst.ui.theme.Size5dp import com.vitorpamplona.quartz.utils.Log import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext import net.engawapg.lib.zoomable.rememberZoomState import net.engawapg.lib.zoomable.zoomable -// Hard ceiling on each rendered page bitmap, in pixels. Higher = sharper when the -// user pinch-zooms inside the dialog, but each page costs ~maxDim^2 * 4 bytes of -// RAM (ARGB_8888). 3072 gives ~26 MB per A4-shaped page. +// Hard ceiling on each base-rendered page bitmap, in pixels. Higher = sharper when +// the user pinch-zooms inside the dialog, but each page costs ~maxDim^2 * 4 bytes +// of RAM (ARGB_8888). 3072 gives ~26 MB per A4-shaped page. private const val VIEWER_MAX_DIM_PX = 3072 +// Hard ceiling for the per-page zoom-aware detail render. When the user zooms in +// past HI_RES_ZOOM_THRESHOLD we re-render the current page at +// (VIEWER_MAX_DIM_PX * scale) capped at this value. 6144 allows up to 2x sharper +// than the base render at peak cost of ~100 MB for one page (ARGB_8888, A4 shape). +private const val HI_RES_MAX_DIM_PX = 6144 +private const val HI_RES_ZOOM_THRESHOLD = 1.2f +private const val HI_RES_DEBOUNCE_MS = 200L + // How many recently-rendered pages to keep around. Pager already pre-composes the // current page plus one neighbor; this just speeds up small back/forward swipes. // At VIEWER_MAX_DIM_PX = 3072 this caps memory at ~80 MB worth of page bitmaps. @@ -285,6 +300,7 @@ private fun PdfViewerContent( } } +@OptIn(FlowPreview::class) @Composable private fun PdfPageView( handle: PdfDocumentHandle, @@ -294,43 +310,50 @@ private fun PdfPageView( val cached = cache.get(pageIndex) @Suppress("ProduceStateDoesNotAssignValue") - val bitmap by produceState(initialValue = cached, key1 = handle, key2 = pageIndex) { + val baseBitmap by produceState(initialValue = cached, key1 = handle, key2 = pageIndex) { if (value != null) return@produceState - val rendered = - try { - handle.mutex.withLock { - if (handle.closed) { - null - } else { - withContext(Dispatchers.IO) { - handle.renderer.openPage(pageIndex).use { page -> - val (width, height) = cappedRenderSize(page.width, page.height, VIEWER_MAX_DIM_PX) - val bmp = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888) - bmp.eraseColor(android.graphics.Color.WHITE) - page.render(bmp, null, null, PdfRenderer.Page.RENDER_MODE_FOR_DISPLAY) - bmp - } - } - } - } - } catch (e: Exception) { - if (e is CancellationException) throw e - Log.w("PdfViewerDialog", "Failed to render page $pageIndex", e) - null - } - + val rendered = renderPageCatching(handle, pageIndex, VIEWER_MAX_DIM_PX) rendered?.let { cache.put(pageIndex, it) } value = rendered } val zoomState = rememberZoomState() - val current = bitmap + + // Re-render the page at a higher resolution once the user zooms in and settles, + // so pinch-zoomed text stays crisp instead of getting GPU-upscaled from the base + // bitmap. Released when zoom drops back under threshold or the page leaves view. + var hiResBitmap by remember(handle, pageIndex) { mutableStateOf(null) } + + LaunchedEffect(handle, pageIndex, baseBitmap) { + if (baseBitmap == null) return@LaunchedEffect + snapshotFlow { zoomState.scale } + .debounce(HI_RES_DEBOUNCE_MS) + .distinctUntilChanged() + .collectLatest { scale -> + if (scale < HI_RES_ZOOM_THRESHOLD) { + hiResBitmap = null + } else { + val target = (VIEWER_MAX_DIM_PX * scale).toInt().coerceAtMost(HI_RES_MAX_DIM_PX) + // Skip if the hi-res render wouldn't beat what we already have. + val base = baseBitmap ?: return@collectLatest + val baseLongest = maxOf(base.width, base.height) + if (target <= baseLongest) { + hiResBitmap = null + } else { + hiResBitmap = renderPageCatching(handle, pageIndex, target) + } + } + } + } + + val current = hiResBitmap ?: baseBitmap Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { if (current != null) { Image( bitmap = current.asImageBitmap(), contentDescription = null, contentScale = ContentScale.Fit, + filterQuality = FilterQuality.High, modifier = Modifier .fillMaxSize() @@ -341,3 +364,30 @@ private fun PdfPageView( } } } + +private suspend fun renderPageCatching( + handle: PdfDocumentHandle, + pageIndex: Int, + maxDim: Int, +): Bitmap? = + try { + handle.mutex.withLock { + if (handle.closed) { + null + } else { + withContext(Dispatchers.IO) { + handle.renderer.openPage(pageIndex).use { page -> + val (width, height) = cappedRenderSize(page.width, page.height, maxDim) + val bmp = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888) + bmp.eraseColor(android.graphics.Color.WHITE) + page.render(bmp, null, null, PdfRenderer.Page.RENDER_MODE_FOR_DISPLAY) + bmp + } + } + } + } + } catch (e: Exception) { + if (e is CancellationException) throw e + Log.w("PdfViewerDialog", "Failed to render page $pageIndex at $maxDim px", e) + null + }