From e38cae2b111f5564889bea87a65e2ac4703ccce9 Mon Sep 17 00:00:00 2001 From: Aljaz Ceru Date: Wed, 15 Jul 2026 13:34:42 +0200 Subject: [PATCH 01/11] Add continuous scrolling for multi-page documents --- .../pdfviewer/test/PdfViewerEdgeToEdgeTest.kt | 2 +- .../pdfviewer/util/PdfViewerRobot.kt | 16 +- .../pdfviewer/util/PdfViewerTestUtils.kt | 20 +- .../app/grapheneos/pdfviewer/PdfJsChannel.kt | 12 + .../grapheneos/pdfviewer/PdfViewerScreen.kt | 98 ++- .../pdfviewer/viewModel/PdfViewModel.kt | 11 + app/src/main/res/values/strings.xml | 4 + viewer/css/pdf_viewer.css | 36 +- viewer/index.html | 3 +- viewer/js/index.js | 807 ++++++++++++------ viewer/js/index.test.js | 337 ++++++++ 11 files changed, 1050 insertions(+), 296 deletions(-) create mode 100644 viewer/js/index.test.js diff --git a/app/src/androidTest/kotlin/app/grapheneos/pdfviewer/test/PdfViewerEdgeToEdgeTest.kt b/app/src/androidTest/kotlin/app/grapheneos/pdfviewer/test/PdfViewerEdgeToEdgeTest.kt index 2e122fbe1..8e5467da6 100644 --- a/app/src/androidTest/kotlin/app/grapheneos/pdfviewer/test/PdfViewerEdgeToEdgeTest.kt +++ b/app/src/androidTest/kotlin/app/grapheneos/pdfviewer/test/PdfViewerEdgeToEdgeTest.kt @@ -173,7 +173,7 @@ class PdfViewerEdgeToEdgeTest { scenario, """ (function() { - const canvas = document.getElementById('content'); + const canvas = document.getElementById('container'); const value = parseFloat(getComputedStyle(canvas)['$propertyName']) || 0; return value * globalThis.devicePixelRatio; })() diff --git a/app/src/androidTest/kotlin/app/grapheneos/pdfviewer/util/PdfViewerRobot.kt b/app/src/androidTest/kotlin/app/grapheneos/pdfviewer/util/PdfViewerRobot.kt index f357bf01d..b998c878b 100644 --- a/app/src/androidTest/kotlin/app/grapheneos/pdfviewer/util/PdfViewerRobot.kt +++ b/app/src/androidTest/kotlin/app/grapheneos/pdfviewer/util/PdfViewerRobot.kt @@ -321,8 +321,8 @@ class PdfViewerRobot(private val composeRule: ComposeTestRule) { fun assertCanvasRendered(scenario: ActivityScenario) { val result = PdfViewerTestUtils.evaluateJs(scenario, - "parseInt(document.getElementById('content').style.width) > 0 " + - "&& parseInt(document.getElementById('content').style.height) > 0" + "parseInt(globalThis.currentPageCanvas().style.width) > 0 " + + "&& parseInt(globalThis.currentPageCanvas().style.height) > 0" ) assertTrue("Canvas should have non-zero CSS dimensions after rendering", result == "true") } @@ -338,28 +338,28 @@ class PdfViewerRobot(private val composeRule: ComposeTestRule) { fun getCanvasWidth(scenario: ActivityScenario): Int { val result = PdfViewerTestUtils.evaluateJs(scenario, - "document.getElementById('content').width" + "globalThis.currentPageCanvas().width" ) return result.toInt() } fun getCanvasHeight(scenario: ActivityScenario): Int { val result = PdfViewerTestUtils.evaluateJs(scenario, - "document.getElementById('content').height" + "globalThis.currentPageCanvas().height" ) return result.toInt() } fun getCanvasCssWidth(scenario: ActivityScenario): Int { val result = PdfViewerTestUtils.evaluateJs(scenario, - "parseInt(document.getElementById('content').style.width) || 0" + "parseInt(globalThis.currentPageCanvas().style.width) || 0" ) return result.toInt() } fun getCanvasCssHeight(scenario: ActivityScenario): Int { val result = PdfViewerTestUtils.evaluateJs(scenario, - "parseInt(document.getElementById('content').style.height) || 0" + "parseInt(globalThis.currentPageCanvas().style.height) || 0" ) return result.toInt() } @@ -564,9 +564,9 @@ class PdfViewerRobot(private val composeRule: ComposeTestRule) { fun assertTextLayerAligned(scenario: ActivityScenario) { val result = PdfViewerTestUtils.evaluateJs(scenario, """ (function() { - var text = document.getElementById('text'); + var text = globalThis.currentPageTextLayer(); var container = document.getElementById('container'); - var canvas = document.getElementById('content'); + var canvas = globalThis.currentPageCanvas(); if (!text || !container || !canvas) return 'missing_elements'; if (text.hidden) return 'text_hidden'; var scaleFactor = container.style.getPropertyValue('--scale-factor'); diff --git a/app/src/androidTest/kotlin/app/grapheneos/pdfviewer/util/PdfViewerTestUtils.kt b/app/src/androidTest/kotlin/app/grapheneos/pdfviewer/util/PdfViewerTestUtils.kt index 1dcfbadbb..1d931f551 100644 --- a/app/src/androidTest/kotlin/app/grapheneos/pdfviewer/util/PdfViewerTestUtils.kt +++ b/app/src/androidTest/kotlin/app/grapheneos/pdfviewer/util/PdfViewerTestUtils.kt @@ -124,10 +124,10 @@ object PdfViewerTestUtils { ) { try { val result = evaluateJs(scenario, - "parseInt(document.getElementById('content').style.width) > 0 " + - "&& parseInt(document.getElementById('content').style.height) > 0 " + + "parseInt(globalThis.currentPageCanvas().style.width) > 0 " + + "&& parseInt(globalThis.currentPageCanvas().style.height) > 0 " + "&& parseFloat(document.getElementById('container').style.getPropertyValue('--scale-factor')) > 0 " + - "&& !document.getElementById('text').hidden" + "&& !globalThis.currentPageTextLayer().hidden" ) Log.d("WAIT", " canvas check result=$result, elapsed=${System.currentTimeMillis() - start}ms") result == "true" @@ -153,7 +153,7 @@ object PdfViewerTestUtils { ) { try { val result = evaluateJs(scenario, - "document.getElementById('text').textContent" + "globalThis.currentPageTextLayer().textContent" ) Log.d("WAIT", " text layer result=${result.take(80)}, elapsed=${System.currentTimeMillis() - start}ms") result.contains(expected) @@ -165,7 +165,7 @@ object PdfViewerTestUtils { Log.d("WAIT", "assertTextLayerContent: done in ${System.currentTimeMillis() - start}ms") } catch (_: AssertionError) { val actual = try { - evaluateJs(scenario, "document.getElementById('text').textContent") + evaluateJs(scenario, "globalThis.currentPageTextLayer().textContent") } catch (e: Throwable) { "JS evaluation failed: ${e.message}" } @@ -251,8 +251,8 @@ object PdfViewerTestUtils { "within ${timeout}ms" } ) { - val w = evaluateJs(scenario, "document.getElementById('content').width").toIntOrNull() - val h = evaluateJs(scenario, "document.getElementById('content').height").toIntOrNull() + val w = evaluateJs(scenario, "globalThis.currentPageCanvas().width").toIntOrNull() + val h = evaluateJs(scenario, "globalThis.currentPageCanvas().height").toIntOrNull() w != null && h != null && (w != previousWidth || h != previousHeight) } } @@ -272,11 +272,11 @@ object PdfViewerTestUtils { ) { val w = evaluateJs( scenario, - "parseInt(document.getElementById('content').style.width) || 0" + "parseInt(globalThis.currentPageCanvas().style.width) || 0" ).toIntOrNull() val h = evaluateJs( scenario, - "parseInt(document.getElementById('content').style.height) || 0" + "parseInt(globalThis.currentPageCanvas().style.height) || 0" ).toIntOrNull() w != null && h != null && (w != previousWidth || h != previousHeight) } @@ -366,7 +366,7 @@ object PdfViewerTestUtils { scenario, """ (function() { var range = document.createRange(); - range.selectNodeContents(document.getElementById('text')); + range.selectNodeContents(globalThis.currentPageTextLayer()); var sel = window.getSelection(); sel.removeAllRanges(); sel.addRange(range); diff --git a/app/src/main/java/app/grapheneos/pdfviewer/PdfJsChannel.kt b/app/src/main/java/app/grapheneos/pdfviewer/PdfJsChannel.kt index b192837e2..edd4e38cf 100644 --- a/app/src/main/java/app/grapheneos/pdfviewer/PdfJsChannel.kt +++ b/app/src/main/java/app/grapheneos/pdfviewer/PdfJsChannel.kt @@ -24,6 +24,12 @@ class PdfJsChannel(private val viewModel: PdfViewModel) { @JavascriptInterface fun getPage(): Int = viewModel.page.value + @JavascriptInterface + fun setCurrentPage(page: Int) { + viewModel.setPage(page) + viewModel.showPageIndicator() + } + @JavascriptInterface fun getZoomRatio(): Float = viewModel.zoomRatio @@ -62,6 +68,12 @@ class PdfJsChannel(private val viewModel: PdfViewModel) { @JavascriptInterface fun getDocumentOrientationDegrees(): Int = viewModel.documentOrientationDegrees.value + @JavascriptInterface + fun getPageFitMode(): Int = viewModel.pageFitMode.value + + @JavascriptInterface + fun getContinuousMode(): Boolean = viewModel.continuousMode.value + @JavascriptInterface fun setNumPages(numPages: Int) { viewModel.setNumPages(numPages) diff --git a/app/src/main/java/app/grapheneos/pdfviewer/PdfViewerScreen.kt b/app/src/main/java/app/grapheneos/pdfviewer/PdfViewerScreen.kt index b96c5ceed..c5484a7cd 100644 --- a/app/src/main/java/app/grapheneos/pdfviewer/PdfViewerScreen.kt +++ b/app/src/main/java/app/grapheneos/pdfviewer/PdfViewerScreen.kt @@ -41,6 +41,7 @@ import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material.icons.filled.Visibility import androidx.compose.material.icons.filled.VisibilityOff @@ -216,6 +217,8 @@ fun PdfViewerScreen( val webViewCrashed by viewModel.webViewCrashed.collectAsStateWithLifecycle() val numPages by viewModel.numPages.collectAsStateWithLifecycle() val page by viewModel.page.collectAsStateWithLifecycle() + val pageFitMode by viewModel.pageFitMode.collectAsStateWithLifecycle() + val continuousMode by viewModel.continuousMode.collectAsStateWithLifecycle() val documentName by viewModel.documentName.collectAsStateWithLifecycle() val documentProperties by viewModel.documentProperties.collectAsStateWithLifecycle() val outlineStatus by viewModel.outline.collectAsStateWithLifecycle() @@ -313,6 +316,29 @@ fun PdfViewerScreen( DisposableEffect(webView) { val wv = webView ?: return@DisposableEffect onDispose {} + var zoomRenderInFlight = false + var zoomRenderPending = false + var zoomRenderEndPending = false + + fun dispatchPendingZoomRender() { + if (zoomRenderInFlight || !zoomRenderPending) return + + val zoom = if (zoomRenderEndPending) 1 else 2 + zoomRenderPending = false + zoomRenderEndPending = false + zoomRenderInFlight = true + wv.evaluateJavascript("onRenderPage($zoom)") { + zoomRenderInFlight = false + dispatchPendingZoomRender() + } + } + + fun requestZoomRender(end: Boolean) { + zoomRenderPending = true + zoomRenderEndPending = zoomRenderEndPending || end + dispatchPendingZoomRender() + } + GestureHelper.attach(context, wv, object : GestureHelper.GestureListener { override fun onTapUp(): Boolean { if (viewModel.uri.value == null) return false @@ -349,15 +375,16 @@ fun PdfViewerScreen( } override fun onZoom(scaleFactor: Float, focusX: Float, focusY: Float) { + viewModel.setPageFitMode(0) viewModel.zoomRatio = (viewModel.zoomRatio * scaleFactor) .coerceIn(MIN_ZOOM_RATIO, MAX_ZOOM_RATIO) viewModel.zoomFocusX = focusX viewModel.zoomFocusY = focusY - wv.evaluateJavascript("onRenderPage(2)", null) + requestZoomRender(end = false) } override fun onZoomEnd() { - wv.evaluateJavascript("onRenderPage(1)", null) + requestZoomRender(end = true) } }) onDispose { @@ -478,6 +505,8 @@ fun PdfViewerScreen( enabled = enabled, page = page, numPages = numPages, + pageFitMode = pageFitMode, + continuousMode = continuousMode, hasOutline = viewModel.hasOutline(), hasDocumentProperties = documentProperties != null, hasUri = uri != null, @@ -495,6 +524,12 @@ fun PdfViewerScreen( onFirst = { jumpToPage(viewModel, webView, 1) }, onLast = { jumpToPage(viewModel, webView, numPages) }, onJumpToPage = { showJumpToPage = true }, + onFitFree = { setPageFitMode(viewModel, webView, 0) }, + onFitPage = { setPageFitMode(viewModel, webView, 1) }, + onFitWidth = { setPageFitMode(viewModel, webView, 2) }, + onContinuousModeChange = { + setContinuousMode(viewModel, webView, !continuousMode) + }, onRotateClockwise = { rotateDocument(viewModel, webView, 90) }, onRotateCounterClockwise = { rotateDocument(viewModel, webView, -90) }, onOutline = { showOutline = true }, @@ -722,6 +757,19 @@ internal fun jumpToPage(viewModel: PdfViewModel, webView: WebView?, selectedPage } } +private fun setPageFitMode(viewModel: PdfViewModel, webView: WebView?, mode: Int) { + webView ?: return + viewModel.setPageFitMode(mode) + viewModel.zoomRatio = 0f + webView.evaluateJavascript("onRenderPage(0)", null) +} + +private fun setContinuousMode(viewModel: PdfViewModel, webView: WebView?, enabled: Boolean) { + webView ?: return + viewModel.setContinuousMode(enabled) + webView.evaluateJavascript("setContinuousMode($enabled)", null) +} + private fun rotateDocument(viewModel: PdfViewModel, webView: WebView?, offset: Int) { webView ?: return var degrees = (viewModel.documentOrientationDegrees.value + offset) % 360 @@ -759,6 +807,8 @@ private fun PdfTopAppBar( enabled: Boolean, page: Int, numPages: Int, + pageFitMode: Int, + continuousMode: Boolean, hasOutline: Boolean, hasDocumentProperties: Boolean, hasUri: Boolean, @@ -770,6 +820,10 @@ private fun PdfTopAppBar( onFirst: () -> Unit, onLast: () -> Unit, onJumpToPage: () -> Unit, + onFitFree: () -> Unit, + onFitPage: () -> Unit, + onFitWidth: () -> Unit, + onContinuousModeChange: () -> Unit, onRotateClockwise: () -> Unit, onRotateCounterClockwise: () -> Unit, onOutline: () -> Unit, @@ -852,6 +906,46 @@ private fun PdfTopAppBar( ) } ) + DropdownMenuItem( + text = { Text(stringResource(R.string.action_fit_free)) }, + onClick = { onMenuToggle(false); onFitFree() }, + enabled = enabled, + leadingIcon = { + if (pageFitMode == 0) { + Icon(Icons.Default.Check, contentDescription = null) + } + } + ) + DropdownMenuItem( + text = { Text(stringResource(R.string.action_fit_page)) }, + onClick = { onMenuToggle(false); onFitPage() }, + enabled = enabled, + leadingIcon = { + if (pageFitMode == 1) { + Icon(Icons.Default.Check, contentDescription = null) + } + } + ) + DropdownMenuItem( + text = { Text(stringResource(R.string.action_fit_width)) }, + onClick = { onMenuToggle(false); onFitWidth() }, + enabled = enabled, + leadingIcon = { + if (pageFitMode == 2) { + Icon(Icons.Default.Check, contentDescription = null) + } + } + ) + DropdownMenuItem( + text = { Text(stringResource(R.string.action_continuous_scroll)) }, + onClick = { onMenuToggle(false); onContinuousModeChange() }, + enabled = enabled, + leadingIcon = { + if (continuousMode) { + Icon(Icons.Default.Check, contentDescription = null) + } + } + ) } DropdownMenuItem( text = { Text(stringResource(R.string.action_rotate_clockwise)) }, diff --git a/app/src/main/java/app/grapheneos/pdfviewer/viewModel/PdfViewModel.kt b/app/src/main/java/app/grapheneos/pdfviewer/viewModel/PdfViewModel.kt index 64d30d8c2..9013e8342 100644 --- a/app/src/main/java/app/grapheneos/pdfviewer/viewModel/PdfViewModel.kt +++ b/app/src/main/java/app/grapheneos/pdfviewer/viewModel/PdfViewModel.kt @@ -41,6 +41,8 @@ class PdfViewModel( private const val STATE_URI: String = "uri" private const val STATE_PAGE: String = "page" private const val STATE_DOCUMENT_ORIENTATION_DEGREES: String = "documentOrientationDegrees" + private const val STATE_PAGE_FIT_MODE: String = "pageFitMode" + private const val STATE_CONTINUOUS_MODE: String = "continuousMode" private const val STATE_DOCUMENT_PROPERTIES = "documentProperties" private const val STATE_DOCUMENT_NAME = "documentName" } @@ -57,6 +59,13 @@ class PdfViewModel( savedStateHandle[STATE_DOCUMENT_ORIENTATION_DEGREES] = value } + val pageFitMode: StateFlow = savedStateHandle.getStateFlow(STATE_PAGE_FIT_MODE, 1) + fun setPageFitMode(value: Int) { savedStateHandle[STATE_PAGE_FIT_MODE] = value } + + val continuousMode: StateFlow = + savedStateHandle.getStateFlow(STATE_CONTINUOUS_MODE, true) + fun setContinuousMode(value: Boolean) { savedStateHandle[STATE_CONTINUOUS_MODE] = value } + val documentProperties: StateFlow?> = savedStateHandle.getStateFlow(STATE_DOCUMENT_PROPERTIES, null) @@ -285,6 +294,8 @@ class PdfViewModel( _numPages.value = 0 zoomRatio = 0f setDocumentOrientationDegrees(0) + setPageFitMode(1) + setContinuousMode(true) encryptedDocumentPassword = "" clearOutline() clearDocumentProperties() diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index a680b81f1..cead4ff67 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -8,6 +8,10 @@ First page Last page Jump to page + Free zoom + Fit page + Fit width + Continuous scrolling Rotate clockwise Rotate counterclockwise Share diff --git a/viewer/css/pdf_viewer.css b/viewer/css/pdf_viewer.css index 0a170a152..06ea14365 100644 --- a/viewer/css/pdf_viewer.css +++ b/viewer/css/pdf_viewer.css @@ -20,22 +20,38 @@ body { --scale-round-x: 1px; --scale-round-y: 1px; + min-height: 100%; width: 100%; - height: 100%; - display: grid; - place-items: center; + box-sizing: border-box; } -#container canvas, -#container .textLayer { - /* overlay child elements on top of each other */ - grid-row-start: 1; - grid-column-start: 1; +#pages { + display: block; } -canvas { - display: inline-block; +/* + * Continuous vertical paging: each page is a centered wrapper holding its + * canvas and text layer. The wrapper's height is set from JS to the page's + * viewport height so the document is as tall as the sum of its pages and + * scrolls naturally. + */ +.page-wrapper { + display: flex; + justify-content: center; position: relative; + padding: 0; + margin: 14px 0; +} + +.page-wrapper canvas { + display: block; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3); +} + +.page-wrapper .textLayer { + position: absolute; + top: 0; + left: 0; } [data-main-rotation="90"] { diff --git a/viewer/index.html b/viewer/index.html index 65eefa764..139f8c61d 100644 --- a/viewer/index.html +++ b/viewer/index.html @@ -8,8 +8,7 @@
- -
+
diff --git a/viewer/js/index.js b/viewer/js/index.js index 545f6fbaa..9bb16467e 100644 --- a/viewer/js/index.js +++ b/viewer/js/index.js @@ -9,288 +9,458 @@ import { getSimplifiedOutline } from "./outline.js"; GlobalWorkerOptions.workerSrc = "/viewer/js/worker.js"; +// Continuous vertical paging. +// +// One wrapper per page is laid out in a vertical stack so the document scrolls +// naturally. Pages are rendered lazily (IntersectionObserver) only when near +// the viewport and cleared when far away, so memory stays bounded on large +// documents. +// +// CSP note (style-src 'self', no unsafe-inline): every dynamic style is set +// via an IDL property write (el.style.x = ...) or a CSS custom property +// (setProperty). We never set the style attribute directly, never assign cssText, +// never create a style element, and never use an inline style attribute in HTML +// — those are exactly what CSP blocks here. See check-csp.mjs which enforces it. + let pdfDoc = null; let outlineAbort = new AbortController(); -let pageRendering = false; -let renderPending = false; -let renderPendingZoom = 0; -const canvas = document.getElementById("content"); -const container = document.getElementById("container"); -let orientationDegrees = 0; -let zoomRatio = 1; -let textLayerDiv = document.getElementById("text"); -let task = null; -let newPageNumber = 0; -let newZoomRatio = 1; -let useRender; +// pages[i] describes page (i+1): +// { wrapper, canvas, textLayer, pdfPage, viewport, rendered, rendering, task } +const pages = []; -const cache = []; -const maxCached = 6; +let zoomRatio = 0; // free-zoom ratio (0 = derive from fit mode) +let orientationDegrees = 0; +let lastReportedPage = 0; // last page pushed back to the ViewModel +let renderObserver = null; +let scrollTimer = null; +let pageBuildGeneration = 0; +let pendingScrollPage = 0; -let isTextLayerVisible = false; -let userZoomed = false; - -function maybeRenderNextPage() { - if (renderPending) { - pageRendering = false; - renderPending = false; - renderPage(channel.getPage(), renderPendingZoom, false); - return true; - } - return false; +const container = document.getElementById("container"); +const pagesEl = document.getElementById("pages"); + +function readLayout() { + const i = insets(); + return { + insets: i, + available: { + width: document.body.clientWidth - i.left - i.right, + height: document.body.clientHeight - i.top - i.bottom, + }, + mode: fitMode(), + minZoom: channel.getMinZoomRatio(), + maxZoom: channel.getMaxZoomRatio(), + }; } -function handleRenderingError(error) { - console.log("rendering error: " + error); +function clampZoom(value, layout = null) { + const min = layout ? layout.minZoom : channel.getMinZoomRatio(); + const max = layout ? layout.maxZoom : channel.getMaxZoomRatio(); + return Math.max(Math.min(value, max), min); +} - pageRendering = false; - maybeRenderNextPage(); +function fitMode() { + // 0 = free zoom, 1 = fit page, 2 = fit width + return channel.getPageFitMode(); } -function doPrerender(pageNumber, prerenderTrigger) { - if (useRender) { - if (pageNumber + 1 <= pdfDoc.numPages) { - renderPage(pageNumber + 1, false, true, pageNumber); - } else if (pageNumber - 1 > 0) { - renderPage(pageNumber - 1, false, true, pageNumber); - } - } else if (pageNumber === prerenderTrigger + 1) { - if (prerenderTrigger - 1 > 0) { - renderPage(prerenderTrigger - 1, false, true, prerenderTrigger); - } - } +function continuousMode() { + return channel.getContinuousMode(); } -function display(newCanvas, zoom) { - canvas.height = newCanvas.height; - canvas.width = newCanvas.width; - canvas.style.height = newCanvas.style.height; - canvas.style.width = newCanvas.style.width; - canvas.getContext("2d", { alpha: false }).drawImage(newCanvas, 0, 0); - if (!zoom) { - scrollTo(0, 0); +// In single-page mode only the current page's wrapper is displayed, so the +// document is exactly one page tall (no continuous flow) and navigation is via +// next/previous/jump/fling — the original behaviour. In continuous mode every +// wrapper is displayed and the document scrolls vertically through all pages. +function applyContinuousMode() { + const cont = continuousMode(); + const current = channel.getPage(); + for (const p of pages) { + if (!p) continue; + const num = Number(p.wrapper.dataset.page); + p.wrapper.style.display = (cont || num === current) ? "" : "none"; } } -function setLayerTransform(pageWidth, pageHeight, layerDiv) { - const cs = globalThis.getComputedStyle(canvas); - const insetLeft = parseFloat(cs.paddingLeft) || 0; - const insetTop = parseFloat(cs.paddingTop) || 0; - const insetRight = parseFloat(cs.paddingRight) || 0; - const insetBottom = parseFloat(cs.paddingBottom) || 0; - - const isOverflownY = canvas.clientHeight > document.body.clientHeight; - const isOverflownX = canvas.clientWidth > document.body.clientWidth; - // Translate the text layer to stay aligned with the rendered page including canvas insets and - // grid centering effects. - const translate = { - X: isOverflownX - ? insetLeft - (document.body.clientWidth - pageWidth) / 2 - : (insetLeft - insetRight) / 2, - Y: isOverflownY - ? insetTop - (document.body.clientHeight - pageHeight) / 2 - : (insetTop - insetBottom) / 2 +function insets() { + const ratio = globalThis.devicePixelRatio; + return { + ratio, + left: (channel.getInsetLeft() / ratio) || 0, + right: (channel.getInsetRight() / ratio) || 0, + top: (channel.getInsetTop() / ratio) || 0, + bottom: (channel.getInsetBottom() / ratio) || 0, }; - layerDiv.style.translate = `${translate.X}px ${translate.Y}px`; } -function getDefaultZoomRatio(page, degrees) { - const totalRotation = (degrees + page.rotate) % 360; - const viewport = page.getViewport({scale: 1, rotation: totalRotation}); - const widthZoomRatio = document.body.clientWidth / viewport.width; - const heightZoomRatio = document.body.clientHeight / viewport.height; - return Math.max(Math.min(widthZoomRatio, heightZoomRatio, channel.getMaxZoomRatio()), channel.getMinZoomRatio()); +function availSize(layout = null) { + if (layout) return layout.available; + const i = insets(); + return { + width: document.body.clientWidth - i.left - i.right, + height: document.body.clientHeight - i.top - i.bottom, + }; } -function renderPage(pageNumber, zoom, prerender, prerenderTrigger = 0) { - pageRendering = true; - useRender = !prerender; - - newPageNumber = pageNumber; - newZoomRatio = channel.getZoomRatio(); - orientationDegrees = channel.getDocumentOrientationDegrees(); - console.log("page: " + pageNumber + ", zoom: " + newZoomRatio + - ", orientationDegrees: " + orientationDegrees + ", prerender: " + prerender); - for (let i = 0; i < cache.length; i++) { - const cached = cache[i]; - if (cached.pageNumber === pageNumber && cached.zoomRatio === newZoomRatio && - cached.orientationDegrees === orientationDegrees) { - if (useRender) { - cache.splice(i, 1); - cache.push(cached); - - display(cached.canvas, zoom); - - textLayerDiv.replaceWith(cached.textLayerDiv); - textLayerDiv = cached.textLayerDiv; - setLayerTransform(cached.pageWidth, cached.pageHeight, textLayerDiv); - container.style.setProperty("--scale-factor", newZoomRatio.toString()); - textLayerDiv.hidden = false; - } +function totalRotation(pdfPage) { + return (orientationDegrees + pdfPage.rotate) % 360; +} - pageRendering = false; - doPrerender(pageNumber, prerenderTrigger); - return; - } +// Zoom ratio for a page under the active fit mode, or the free-zoom ratio. +function pageZoom(pdfPage, layout = null) { + const m = layout ? layout.mode : fitMode(); + if (m !== 0 || zoomRatio === 0) { + const vp1 = pdfPage.getViewport({scale: 1, rotation: totalRotation(pdfPage)}); + const a = availSize(layout); + const wZoom = a.width / vp1.width; + const hZoom = a.height / vp1.height; + // fit width (2) fills width; fit page (1) and free-default both fit page. + const z = (m === 2) ? wZoom : Math.min(wZoom, hZoom); + return clampZoom(z, layout); } + return clampZoom(zoomRatio, layout); +} - pdfDoc.getPage(pageNumber).then(function(page) { - if (maybeRenderNextPage()) { - return; - } +function pageViewport(pdfPage, layout = null) { + return pdfPage.getViewport({ + scale: pageZoom(pdfPage, layout), + rotation: totalRotation(pdfPage), + }); +} - const defaultZoomRatio = getDefaultZoomRatio(page, orientationDegrees); +// Keep content out from under the system bars by padding the scroll host. +function applyContainerInsets(layout = null) { + const i = layout ? layout.insets : insets(); + container.style.paddingLeft = i.left + "px"; + container.style.paddingRight = i.right + "px"; + container.style.paddingTop = i.top + "px"; + container.style.paddingBottom = i.bottom + "px"; +} - if (newZoomRatio === 0) { - zoomRatio = defaultZoomRatio; - newZoomRatio = defaultZoomRatio; - channel.setZoomRatio(defaultZoomRatio); - } +// (Re)size a wrapper to its viewport. CSP-safe: property writes only. +function sizeWrapper(p, layout = null) { + const vp = pageViewport(p.pdfPage, layout); + p.viewport = vp; + const a = availSize(layout); + p.wrapper.style.width = a.width + "px"; + p.wrapper.style.height = vp.height + "px"; + p.canvas.style.width = vp.width + "px"; + p.canvas.style.height = vp.height + "px"; +} - const totalRotation = (orientationDegrees + page.rotate) % 360; - const viewport = page.getViewport({scale: newZoomRatio, rotation: totalRotation}); +// Overlay the text layer exactly on the (horizontally-centered) canvas. +function alignTextLayer(p, layout = null) { + if (!p.viewport) return; + const a = availSize(layout); + const offsetX = (a.width - p.viewport.width) / 2; + p.textLayer.style.translate = offsetX + "px 0px"; + p.textLayer.style.width = p.viewport.width + "px"; + p.textLayer.style.height = p.viewport.height + "px"; +} - const scaleFactor = newZoomRatio / zoomRatio; - const ratio = globalThis.devicePixelRatio; +function clearPage(p) { + if (p.task) { + try { + p.task.cancel(); + } catch { + // cancellation may throw if the task already completed + } + p.task = null; + } + p.rendering = false; + p.rendered = false; + p.canvas.width = 0; // free the backing store + p.canvas.height = 0; + p.textLayer.replaceChildren(); +} - if (useRender) { - if (newZoomRatio !== zoomRatio) { - canvas.style.height = viewport.height + "px"; - canvas.style.width = viewport.width + "px"; - } - zoomRatio = newZoomRatio; +function renderPageContent(p, layout = null) { + if (p.rendered) return; + // Cancel any in-flight render so we restart at the current viewport: during + // a multi-event pinch the viewport changes on every event, and an unchecked + // in-flight task would otherwise complete with a stale viewport (stretched + // bitmap, misaligned text) — see review P1. + if (p.task) { + try { + p.task.cancel(); + } catch { + // task already settled } + p.task = null; + } + p.rendering = true; + p.rendered = false; + // Sync wrapper/canvas layout to the viewport we render at, so a rotation or + // zoom change can never leave a stale size. + sizeWrapper(p, layout); + // Generation tag: callbacks from a cancelled/superseded render no-op. + const gen = (p.renderGen = (p.renderGen || 0) + 1); + + const ratio = layout ? layout.insets.ratio : insets().ratio; + const vp = p.viewport; + const renderedZoom = pageZoom(p.pdfPage, layout); + const renderPixels = (vp.width * ratio) * (vp.height * ratio); + + let renderVp = vp; + const maxRenderPixels = channel.getMaxRenderPixels(); + if (renderPixels > maxRenderPixels) { + const adjusted = Math.sqrt(maxRenderPixels / renderPixels); + renderVp = p.pdfPage.getViewport({ + scale: renderedZoom * adjusted, + rotation: totalRotation(p.pdfPage), + }); + } - if (zoom === 2) { - textLayerDiv.hidden = true; - pageRendering = false; + p.canvas.width = Math.floor(renderVp.width * ratio); + p.canvas.height = Math.floor(renderVp.height * ratio); + const ctx = p.canvas.getContext("2d", {alpha: false}); + ctx.scale(ratio, ratio); + + const renderTask = p.pdfPage.render({canvasContext: ctx, viewport: renderVp}); + p.task = renderTask; + renderTask.promise.then(() => { + if (gen !== p.renderGen) return; + p.zoom = renderedZoom; + // pdf.js TextLayer reads --scale-factor from its container; set it before + // construction so each page's text layer uses its own zoom. + p.textLayer.style.setProperty("--scale-factor", renderedZoom.toString()); + const textLayer = new TextLayer({ + textContentSource: p.pdfPage.streamTextContent(), + container: p.textLayer, + viewport: vp, + }); + p.task = {promise: textLayer.render(), cancel: () => textLayer.cancel()}; + return p.task.promise; + }).then(() => { + if (gen !== p.renderGen) return; + p.rendered = true; + p.rendering = false; + alignTextLayer(p); + p.textLayer.hidden = false; + }).catch((err) => { + if (gen !== p.renderGen) return; // expected when cancelled by a newer render + p.rendering = false; + console.log("render error: " + err); + }); +} - // zoom focus relative to page origin, rather than screen origin - const globalFocusX = channel.getZoomFocusX() / ratio + globalThis.scrollX; - const globalFocusY = channel.getZoomFocusY() / ratio + globalThis.scrollY; +// Render pages near the viewport, clear far ones. +function setupObserver() { + if (renderObserver) renderObserver.disconnect(); + renderObserver = new IntersectionObserver((entries) => { + for (const entry of entries) { + const p = pages[Number(entry.target.dataset.page) - 1]; + if (!p) continue; + if (entry.isIntersecting && p.wrapper.style.display !== "none") { + renderPageContent(p); + } else { + clearPage(p); + } + } + }, {root: null, rootMargin: "150% 0px", threshold: 0}); + for (const p of pages) { + if (p) renderObserver.observe(p.wrapper); + } +} - const translationFactor = scaleFactor - 1; - const scrollX = globalFocusX * translationFactor; - const scrollY = globalFocusY * translationFactor; - scrollBy(scrollX, scrollY); +function relayoutAll() { + const layout = readLayout(); + applyContainerInsets(layout); + for (const p of pages) { + if (!p) continue; + sizeWrapper(p, layout); + alignTextLayer(p, layout); + } + return layout; +} - return; +// Re-render every currently-visible page (e.g. after zoom / rotation / resize). +function rerenderVisible(layout = null) { + const currentLayout = layout || readLayout(); + for (const p of pages) { + if (!p) continue; + if (p.wrapper.style.display === "none") { + clearPage(p); + continue; + } + const rect = p.wrapper.getBoundingClientRect(); + const near = rect.bottom > -window.innerHeight * 1.5 && + rect.top < window.innerHeight * 2.5; + if (near) { + if (p.rendered) clearPage(p); + sizeWrapper(p, currentLayout); + renderPageContent(p, currentLayout); + } else { + clearPage(p); } + } +} - const resolutionY = viewport.height * ratio; - const resolutionX = viewport.width * ratio; - const renderPixels = resolutionY * resolutionX; - - let newViewport = viewport; - const maxRenderPixels = channel.getMaxRenderPixels(); - if (renderPixels > maxRenderPixels) { - console.log(`resolution ${renderPixels} exceeds maximum allowed ${maxRenderPixels}`); - const adjustedScale = Math.sqrt(maxRenderPixels / renderPixels); - newViewport = page.getViewport({ - scale: newZoomRatio * adjustedScale, - rotation: totalRotation - }); +function mostVisiblePage() { + let best = null; + let bestArea = 0; + const vh = window.innerHeight; + for (const p of pages) { + if (!p || p.wrapper.style.display === "none") continue; + const rect = p.wrapper.getBoundingClientRect(); + const top = Math.max(rect.top, 0); + const bottom = Math.min(rect.bottom, vh); + const area = Math.max(0, bottom - top); + if (area > bestArea) { + bestArea = area; + best = p; } + } + return best; +} - const newCanvas = document.createElement("canvas"); - newCanvas.height = newViewport.height * ratio; - newCanvas.width = newViewport.width * ratio; - // use original viewport height for CSS zoom - newCanvas.style.height = viewport.height + "px"; - newCanvas.style.width = viewport.width + "px"; - const newContext = newCanvas.getContext("2d", { alpha: false }); - newContext.scale(ratio, ratio); - - // Add padding to the canvas to allow the page to be scrolled bellow/above any - // system/app ui that might be visible. - canvas.style.paddingLeft = (channel.getInsetLeft() / ratio) + "px"; - canvas.style.paddingTop = (channel.getInsetTop() / ratio) + "px"; - canvas.style.paddingRight = (channel.getInsetRight() / ratio) + "px"; - canvas.style.paddingBottom = (channel.getInsetBottom() / ratio) + "px"; - - task = page.render({ - canvasContext: newContext, - viewport: newViewport - }); +// Exposed for instrumentation tests: the canvas / text layer of the page +// currently most in view (continuous scroll has one per page). +globalThis.currentPageCanvas = function () { + const p = mostVisiblePage(); + return p ? p.canvas : null; +}; - task.promise.then(function() { - task = null; +globalThis.currentPageTextLayer = function () { + const p = mostVisiblePage(); + return p ? p.textLayer : null; +}; - let rendered = false; - function render() { - if (!useRender || rendered) { - return; - } - display(newCanvas, zoom); - rendered = true; - } - render(); - - const newTextLayerDiv = textLayerDiv.cloneNode(); - const textLayer = new TextLayer({ - textContentSource: page.streamTextContent(), - container: newTextLayerDiv, - viewport: viewport - }); - task = { - promise: textLayer.render(), - cancel: () => textLayer.cancel() - }; - task.promise.then(function() { - task = null; - - render(); - - setLayerTransform(viewport.width, viewport.height, newTextLayerDiv); - if (useRender) { - textLayerDiv.replaceWith(newTextLayerDiv); - textLayerDiv = newTextLayerDiv; - container.style.setProperty("--scale-factor", newZoomRatio.toString()); - textLayerDiv.hidden = false; - } - - if (cache.length === maxCached) { - cache.shift(); - } - cache.push({ - pageNumber: pageNumber, - zoomRatio: newZoomRatio, - orientationDegrees: orientationDegrees, - canvas: newCanvas, - textLayerDiv: newTextLayerDiv, - pageWidth: viewport.width, - pageHeight: viewport.height - }); - - pageRendering = false; - doPrerender(pageNumber, prerenderTrigger); - }).catch(handleRenderingError); - }).catch(handleRenderingError); - }); +// Report the most-visible page back to the ViewModel (drives the page indicator +// and next/previous enablement). +function updateCurrentPage() { + const best = mostVisiblePage(); + if (!best) return; + const num = Number(best.wrapper.dataset.page); + if (pendingScrollPage !== 0) { + if (!pages[pendingScrollPage - 1] || num !== pendingScrollPage) return; + pendingScrollPage = 0; + } + const layout = readLayout(); + const m = layout.mode; + if (m === 0 && zoomRatio !== 0) { + // Free zoom: the ViewModel zoom (driven by the pinch handler) is + // authoritative — reflect it on the container, never overwrite it. + container.style.setProperty("--scale-factor", zoomRatio.toString()); + } else { + // Fit mode: each page's fit zoom is authoritative — push it to the VM + // so the page indicator / tests read the right value. + // p.zoom records the last completed render and may still describe the + // previous fit mode or rotation. Publish the current layout ratio. + const z = pageZoom(best.pdfPage, layout); + container.style.setProperty("--scale-factor", z.toString()); + channel.setZoomRatio(z); + } + if (num !== lastReportedPage) { + lastReportedPage = num; + channel.setCurrentPage(num); + } } +globalThis.scrollToPage = function (pageNumber) { + if (!Number.isInteger(pageNumber) || pageNumber < 1 || + (pdfDoc && pageNumber > pdfDoc.numPages)) return; + + // Publish the requested page immediately, even if progressive page setup + // has not reached it yet. Scroll tracking must not replace that request + // while its wrapper is still being built. + pendingScrollPage = pageNumber; + lastReportedPage = pageNumber; + channel.setCurrentPage(pageNumber); + + const p = pages[pageNumber - 1]; + if (!p) return; + const layout = readLayout(); + sizeWrapper(p, layout); + alignTextLayer(p, layout); + if (!continuousMode()) { + // single-page mode: show only the target page + for (const q of pages) { + if (!q) continue; + q.wrapper.style.display = (q === p) ? "" : "none"; + } + p.wrapper.scrollIntoView({block: "start"}); + } else { + // continuous mode: centre the page in the visible band (below the app bar, + // above the nav bar) so next/previous lands squarely on the page rather + // than top-aligning it under the floating toolbar. + const dpr = globalThis.devicePixelRatio; + const visibleTop = channel.getInsetTop() / dpr; + const visibleH = window.innerHeight - visibleTop - channel.getInsetBottom() / dpr; + const rect = p.wrapper.getBoundingClientRect(); + const desiredTop = visibleTop + Math.max(0, (visibleH - rect.height) / 2); + globalThis.scrollBy(0, rect.top - desiredTop); + } + const best = mostVisiblePage(); + if (best && Number(best.wrapper.dataset.page) === pageNumber) { + pendingScrollPage = 0; + } +}; + +// Driven from the Java side (former single-page render entry point). +// zoom: 0 = full re-layout (fit/orientation/page jump), 1 = zoom end, 2 = zooming globalThis.onRenderPage = function (zoom) { - if (zoom === 1 || zoom === 2) { - userZoomed = true; + orientationDegrees = channel.getDocumentOrientationDegrees(); + + if (zoom === 2 || zoom === 1) { + // pinch: adopt the new free-zoom ratio and re-render visible pages while + // keeping the focal point under the user's fingers (review P2 focal). + const dpr = globalThis.devicePixelRatio; + const best = mostVisiblePage(); + // Rendering is asynchronous and is commonly cancelled by the next + // pinch event, so p.zoom can lag behind the ratio already requested. + const prevZoom = zoomRatio || (best ? (best.zoom || pageZoom(best.pdfPage)) : 1); + const newZoom = channel.getZoomRatio(); + zoomRatio = newZoom; + container.style.setProperty("--scale-factor", newZoom.toString()); + + // Focal point in document coordinates, captured before re-layout. + const focusX = channel.getZoomFocusX() / dpr + globalThis.scrollX; + const focusY = channel.getZoomFocusY() / dpr + globalThis.scrollY; + + // Placeholder geometry belongs to the requested zoom even when its + // canvas is far enough away to remain unrendered. + const layout = relayoutAll(); + rerenderVisible(layout); + + const translationFactor = (newZoom / prevZoom) - 1; + globalThis.scrollBy(focusX * translationFactor, focusY * translationFactor); + return; } - if (pageRendering) { - if (newPageNumber === channel.getPage() && newZoomRatio === channel.getZoomRatio() && - orientationDegrees === channel.getDocumentOrientationDegrees()) { - useRender = true; - return; - } + // zoom === 0: a fit-mode / orientation / page change. + if (fitMode() !== 0) { + // a fit mode owns the zoom now; drop stale free-zoom state so that + // re-entering Free zoom re-derives instead of reusing it (review P2). + zoomRatio = 0; + } + // Read the Java-side target before geometry changes can make the scroll + // handler report and overwrite a different most-visible page. + const target = channel.getPage(); + const targetPage = pages[target - 1]; + const anchorTop = targetPage && targetPage.wrapper.style.display !== "none" + ? targetPage.wrapper.getBoundingClientRect().top + : null; + const isPageNavigation = target !== lastReportedPage; + + if (!targetPage && isPageNavigation) { + pendingScrollPage = target; + lastReportedPage = target; + } - renderPending = true; - renderPendingZoom = zoom; - if (task !== null) { - task.cancel(); - task = null; - } - } else { - renderPage(channel.getPage(), zoom, false); + const layout = relayoutAll(); + if (targetPage && isPageNavigation) { + // next/prev/jump-to-page from the menu — scroll the target into view. + globalThis.scrollToPage(target); + } else if (targetPage && anchorTop !== null) { + // Preserve the target wrapper's viewport anchor when the heights of + // earlier pages change due to fitting or rotation. + const newTop = targetPage.wrapper.getBoundingClientRect().top; + globalThis.scrollBy(0, newTop - anchorTop); } + updateCurrentPage(); + rerenderVisible(layout); }; globalThis.isTextSelected = function () { @@ -298,17 +468,17 @@ globalThis.isTextSelected = function () { }; globalThis.getDocumentOutline = function () { - pdfDoc.getOutline().then(function(outline) { - getSimplifiedOutline(outline, outlineAbort, pdfDoc).then(function(outlineEntries) { - if (outlineEntries !== null) { - channel.setDocumentOutline(JSON.stringify(outlineEntries)); + pdfDoc.getOutline().then(function (outline) { + getSimplifiedOutline(outline, outlineAbort, pdfDoc).then(function (entries) { + if (entries !== null) { + channel.setDocumentOutline(JSON.stringify(entries)); } else { channel.setDocumentOutline(null); } - }).catch(function(error) { + }).catch(function (error) { console.log("getSimplifiedOutline error: " + error); }); - }).catch(function(error) { + }).catch(function (error) { console.log("pdfDoc.getOutline error: " + error); }); }; @@ -318,17 +488,28 @@ globalThis.abortDocumentOutline = function () { outlineAbort = new AbortController(); }; +let isTextLayerVisible = false; globalThis.toggleTextLayerVisibility = function () { - let textLayerForeground = "red"; - if (isTextLayerVisible) { - textLayerForeground = "transparent"; - } - document.documentElement.style.setProperty("--text-layer-foreground", textLayerForeground); + const foreground = isTextLayerVisible ? "transparent" : "red"; + document.documentElement.style.setProperty("--text-layer-foreground", foreground); isTextLayerVisible = !isTextLayerVisible; }; +globalThis.getPageFitMode = function () { + return channel.getPageFitMode(); +}; + +globalThis.setContinuousMode = function () { + // The ViewModel was already updated by the Java caller; reflect it in the DOM. + const target = channel.getPage(); + applyContinuousMode(); + relayoutAll(); + // Showing or hiding preceding wrappers changes this page's document offset. + globalThis.scrollToPage(target); + rerenderVisible(); +}; + globalThis.loadDocument = function () { - userZoomed = false; const pdfPassword = channel.getPassword(); const loadingTask = getDocument({ url: "https://localhost/placeholder.pdf", @@ -369,29 +550,129 @@ globalThis.loadDocument = function () { }).catch(function (error) { console.log("getMetadata error: " + error); }); - pdfDoc.getOutline().then(function(outline) { + pdfDoc.getOutline().then(function (outline) { channel.setHasDocumentOutline(outline && outline.length > 0); - }).catch(function(error) { + }).catch(function (error) { console.log("getOutline error: " + error); }); - renderPage(channel.getPage(), false, false); + + // Apply the saved document rotation before sizing any page, otherwise + // every wrapper is built at rotation 0 and only nearby pages get + // corrected later (review P2 rotation). + orientationDegrees = channel.getDocumentOrientationDegrees(); + + // Reset continuous-scroll state — loadDocument runs again when opening a + // second document or re-entering a password, so the old pages must go. + for (const old of pages) { + if (old) clearPage(old); + } + if (renderObserver) { + renderObserver.disconnect(); + renderObserver = null; + } + pages.length = 0; + pagesEl.replaceChildren(); + zoomRatio = 0; + lastReportedPage = 0; + pendingScrollPage = 0; + const buildGeneration = ++pageBuildGeneration; + const startPage = channel.getPage() || 1; + + buildPages(startPage, buildGeneration).catch((error) => { + console.error("buildPages error: " + error); + }); }, function (reason) { console.error(reason.name + ": " + reason.message); channel.onLoadError(); }); }; -globalThis.onresize = () => { - setLayerTransform(canvas.clientWidth, canvas.clientHeight, textLayerDiv); - if (pdfDoc !== null && !userZoomed) { - const pageNumber = channel.getPage(); - pdfDoc.getPage(pageNumber).then(function(page) { - const degrees = channel.getDocumentOrientationDegrees(); - const newDefaultZoom = getDefaultZoomRatio(page, degrees); - channel.setZoomRatio(newDefaultZoom); - globalThis.onRenderPage(0); - }).catch(function(err) { - console.log("onresize error: " + err); - }); +function createPageEntry(pdfPage, pageNumber) { + const wrapper = document.createElement("div"); + wrapper.className = "page-wrapper"; + wrapper.dataset.page = String(pageNumber); + + const canvas = document.createElement("canvas"); + const textLayer = document.createElement("div"); + textLayer.className = "textLayer"; + textLayer.hidden = true; + + wrapper.appendChild(canvas); + wrapper.appendChild(textLayer); + + return { + wrapper, canvas, textLayer, pdfPage, + viewport: null, rendered: false, rendering: false, task: null, + }; +} + +// Fetch page metadata in document order with concurrency bounded to one. The +// first readable page can be displayed immediately, and a failed later page +// does not reject initialization of the rest of the document. +async function buildPages(startPage, generation) { + const documentToBuild = pdfDoc; + const total = pdfDoc.numPages; + let viewerReady = false; + + for (let i = 1; i <= total; i++) { + if (generation !== pageBuildGeneration || documentToBuild !== pdfDoc) return; + let pdfPage; + try { + pdfPage = await documentToBuild.getPage(i); + } catch (error) { + if (generation !== pageBuildGeneration || documentToBuild !== pdfDoc) return; + console.error(`getPage(${i}) error: ${error}`); + continue; + } + if (generation !== pageBuildGeneration || documentToBuild !== pdfDoc) return; + + const entry = createPageEntry(pdfPage, i); + sizeWrapper(entry); + pages[i - 1] = entry; + pagesEl.appendChild(entry.wrapper); + + const requestedPage = channel.getPage() || startPage; + entry.wrapper.style.display = (continuousMode() || i === requestedPage) ? "" : "none"; + + if (!viewerReady) { + applyContainerInsets(); + setupObserver(); + viewerReady = true; + // Continuous mode can show useful content while a later restored + // target is still being initialized. + if (i !== requestedPage) rerenderVisible(); + } else if (renderObserver) { + renderObserver.observe(entry.wrapper); + } + + if (i === requestedPage) { + globalThis.scrollToPage(requestedPage); + updateCurrentPage(); + rerenderVisible(); + } + } + + const requestedPage = channel.getPage() || startPage; + if (viewerReady && !pages[requestedPage - 1]) { + const fallback = pages.find((page) => page); + if (fallback) { + globalThis.scrollToPage(Number(fallback.wrapper.dataset.page)); + updateCurrentPage(); + rerenderVisible(); + } } +} + +// Scroll → track current page (throttled); resize → relayout. +globalThis.onscroll = function () { + if (scrollTimer) return; + scrollTimer = setTimeout(() => { + scrollTimer = null; + updateCurrentPage(); + }, 150); +}; + +globalThis.onresize = function () { + relayoutAll(); + rerenderVisible(); }; diff --git a/viewer/js/index.test.js b/viewer/js/index.test.js new file mode 100644 index 000000000..1797b67ba --- /dev/null +++ b/viewer/js/index.test.js @@ -0,0 +1,337 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +const pdfJs = vi.hoisted(() => ({ loadingTask: null })); + +vi.mock("pdfjs-dist", () => ({ + GlobalWorkerOptions: {}, + PasswordResponses: { NEED_PASSWORD: 1, INCORRECT_PASSWORD: 2 }, + TextLayer: class { + render() { + return Promise.resolve(); + } + + cancel() {} + }, + getDocument: () => pdfJs.loadingTask, +})); + +function styleDeclaration() { + const properties = new Map(); + return { + display: "", + height: "", + width: "", + translate: "", + setProperty(name, value) { + properties.set(name, value); + }, + getPropertyValue(name) { + return properties.get(name) || ""; + }, + }; +} + +class FakeElement { + constructor(tagName, environment) { + this.tagName = tagName; + this.environment = environment; + this.children = []; + this.dataset = {}; + this.style = styleDeclaration(); + this.hidden = false; + this.width = 0; + this.height = 0; + } + + appendChild(child) { + child.parentElement = this; + this.children.push(child); + return child; + } + + replaceChildren(...children) { + this.children = []; + for (const child of children) this.appendChild(child); + } + + getContext() { + return { scale() {} }; + } + + getBoundingClientRect() { + if (this.style.display === "none") { + return { top: 0, bottom: 0, width: 0, height: 0 }; + } + const siblings = this.parentElement ? this.parentElement.children : []; + let documentTop = 0; + for (const sibling of siblings) { + if (sibling === this) break; + if (sibling.style.display !== "none") { + documentTop += Number.parseFloat(sibling.style.height) || 0; + } + } + const height = Number.parseFloat(this.style.height) || 0; + const top = documentTop - globalThis.scrollY; + return { top, bottom: top + height, height, width: Number.parseFloat(this.style.width) || 0 }; + } + + scrollIntoView() { + const rect = this.getBoundingClientRect(); + globalThis.scrollY += rect.top; + this.environment.scrolledPages.push(Number(this.dataset.page)); + } +} + +function fakePage(pageNumber, state, { width = 100, height = 200 } = {}) { + return { + rotate: 0, + getViewport({ scale, rotation }) { + const sideways = Math.abs(rotation % 180) === 90; + return { + width: (sideways ? height : width) * scale, + height: (sideways ? width : height) * scale, + }; + }, + render() { + state.renderCalls.push(pageNumber); + return { promise: Promise.resolve(), cancel() {} }; + }, + streamTextContent() { + return {}; + }, + }; +} + +async function flushPromises() { + await new Promise((resolve) => setTimeout(resolve, 0)); + await new Promise((resolve) => setTimeout(resolve, 0)); +} + +function delay(milliseconds) { + return new Promise((resolve) => setTimeout(resolve, milliseconds)); +} + +async function setupViewer({ + continuous = true, + currentPage = 1, + fitMode = 1, + pageCount = 3, + getPage, +} = {}) { + vi.resetModules(); + const state = { + continuous, + currentPage, + fitMode, + zoom: 0.5, + orientation: 0, + renderCalls: [], + scrollCalls: [], + scrolledPages: [], + zoomReports: [], + }; + const environment = { scrolledPages: state.scrolledPages }; + const container = new FakeElement("div", environment); + const pages = new FakeElement("div", environment); + container.appendChild(pages); + const body = new FakeElement("body", environment); + body.clientWidth = 100; + body.clientHeight = 100; + const documentElement = new FakeElement("html", environment); + + globalThis.document = { + body, + documentElement, + createElement: (tagName) => new FakeElement(tagName, environment), + getElementById: (id) => id === "container" ? container : pages, + }; + globalThis.window = globalThis; + globalThis.innerHeight = 100; + globalThis.devicePixelRatio = 1; + globalThis.scrollX = 0; + globalThis.scrollY = 0; + globalThis.scrollBy = (x, y) => { + state.scrollCalls.push([x, y]); + globalThis.scrollX += x; + globalThis.scrollY += y; + }; + globalThis.getSelection = () => ({ toString: () => "" }); + globalThis.IntersectionObserver = class { + observe() {} + disconnect() {} + }; + globalThis.channel = { + getMaxZoomRatio: () => 10, + getMinZoomRatio: () => 0.1, + getPageFitMode: () => state.fitMode, + getContinuousMode: () => state.continuous, + getPage: () => state.currentPage, + getInsetLeft: () => 0, + getInsetRight: () => 0, + getInsetTop: () => 0, + getInsetBottom: () => 0, + getDocumentOrientationDegrees: () => state.orientation, + getMaxRenderPixels: () => 10_000_000, + getZoomRatio: () => state.zoom, + getZoomFocusX: () => 10, + getZoomFocusY: () => 20, + setZoomRatio: (zoom) => { + state.zoom = zoom; + state.zoomReports.push(zoom); + }, + setCurrentPage: (page) => { + state.currentPage = page; + }, + getPassword: () => "", + onLoaded() {}, + setNumPages() {}, + setDocumentProperties() {}, + setHasDocumentOutline() {}, + onLoadError() {}, + }; + + const pdfDocument = { + numPages: pageCount, + getPage: getPage + ? (pageNumber) => getPage(pageNumber, state) + : (pageNumber) => Promise.resolve(fakePage(pageNumber, state)), + getMetadata: () => Promise.resolve({ info: {} }), + getOutline: () => Promise.resolve([]), + }; + pdfJs.loadingTask = { promise: Promise.resolve(pdfDocument) }; + + await import("./index.js"); + globalThis.loadDocument(); + await flushPromises(); + return { state, pagesElement: pages, pdfDocument }; +} + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("continuous page layout", () => { + it("renders only the displayed page in single-page mode", async () => { + const { state } = await setupViewer({ continuous: false, currentPage: 2 }); + + expect(state.renderCalls).toEqual([2]); + }); + + it("compensates each pinch event from the previously requested zoom", async () => { + const { state } = await setupViewer({ continuous: false, currentPage: 1 }); + state.fitMode = 0; + + state.zoom = 0.6; + globalThis.onRenderPage(2); + state.zoom = 0.7; + globalThis.onRenderPage(2); + globalThis.onRenderPage(1); + + expect(state.scrollCalls).toHaveLength(3); + expect(state.scrollCalls[0][0]).toBeCloseTo(2); + expect(state.scrollCalls[0][1]).toBeCloseTo(4); + expect(state.scrollCalls[1][0]).toBeCloseTo(2); + expect(state.scrollCalls[1][1]).toBeCloseTo(4); + expect(state.scrollCalls[2]).toEqual([0, 0]); + }); + + it("resizes far placeholders when free zoom changes", async () => { + const { state, pagesElement } = await setupViewer({ pageCount: 5 }); + state.fitMode = 0; + state.zoom = 1; + + globalThis.onRenderPage(2); + + expect(pagesElement.children[4].style.height).toBe("200px"); + }); + + it("publishes the fit zoom computed for the new layout", async () => { + const { state } = await setupViewer({ pageCount: 1, fitMode: 1 }); + state.fitMode = 2; + + globalThis.onRenderPage(0); + + expect(state.zoomReports.at(-1)).toBe(1); + }); + + it("keeps the requested page anchored across a relayout", async () => { + const { state } = await setupViewer({ + continuous: true, + currentPage: 3, + pageCount: 3, + fitMode: 1, + }); + expect(globalThis.scrollY).toBe(200); + state.fitMode = 2; + + globalThis.onRenderPage(0); + + expect(state.currentPage).toBe(3); + expect(globalThis.scrollY).toBe(400); + }); + + it("re-anchors the current page when continuous mode changes", async () => { + const { state } = await setupViewer({ + continuous: false, + currentPage: 3, + pageCount: 3, + }); + expect(globalThis.scrollY).toBe(0); + + state.continuous = true; + globalThis.setContinuousMode(); + expect(globalThis.scrollY).toBe(200); + + state.continuous = false; + globalThis.setContinuousMode(); + expect(globalThis.scrollY).toBe(0); + expect(state.currentPage).toBe(3); + }); + + it("starts rendering progressively and survives a later page failure", async () => { + let rejectSecondPage; + const secondPage = new Promise((resolve, reject) => { + rejectSecondPage = reject; + }); + const { state, pagesElement } = await setupViewer({ + pageCount: 3, + getPage: (pageNumber, viewerState) => { + if (pageNumber === 2) return secondPage; + return Promise.resolve(fakePage(pageNumber, viewerState)); + }, + }); + + expect(pagesElement.children.map((page) => page.dataset.page)).toEqual(["1"]); + expect(state.renderCalls).toContain(1); + + rejectSecondPage(new Error("damaged page")); + await flushPromises(); + + expect(pagesElement.children.map((page) => page.dataset.page)).toEqual(["1", "3"]); + }); + + it("keeps a late-page request sticky until progressive setup reaches it", async () => { + let resolveSecondPage; + const secondPage = new Promise((resolve) => { + resolveSecondPage = resolve; + }); + const { state } = await setupViewer({ + pageCount: 3, + getPage: (pageNumber, viewerState) => pageNumber === 2 + ? secondPage + : Promise.resolve(fakePage(pageNumber, viewerState)), + }); + + globalThis.scrollToPage(3); + globalThis.onscroll(); + await delay(200); + + expect(state.currentPage).toBe(3); + + resolveSecondPage(fakePage(2, state)); + await flushPromises(); + + expect(globalThis.scrollY).toBeGreaterThan(0); + expect(state.currentPage).toBe(3); + }); +}); From 3ba62c65e97574a8b45b791f954b031471cd8d3e Mon Sep 17 00:00:00 2001 From: Aljaz Ceru Date: Wed, 22 Jul 2026 05:00:23 +0200 Subject: [PATCH 02/11] fix panning issues --- app/src/androidTest/assets/TEST_DOCUMENTS.md | 16 + app/src/androidTest/assets/test-large.pdf | Bin 0 -> 888537 bytes .../pdfviewer/PdfViewerTestAccessors.kt | 12 + .../pdfviewer/test/PdfViewerBigDocTest.kt | 240 ++++++ .../test/PdfViewerContinuousModeTest.kt | 104 +++ .../test/PdfViewerContinuousScrollTest.kt | 153 ++++ .../pdfviewer/test/PdfViewerEdgeToEdgeTest.kt | 4 +- .../pdfviewer/test/PdfViewerLandscapeTest.kt | 10 +- .../pdfviewer/test/PdfViewerMenuStateTest.kt | 7 +- .../pdfviewer/test/PdfViewerNavigationTest.kt | 18 + .../pdfviewer/util/PdfViewerRobot.kt | 65 +- .../pdfviewer/util/PdfViewerTestUtils.kt | 50 +- .../app/grapheneos/pdfviewer/PdfViewer.java | 130 ++- .../pdfviewer/viewModel/PdfViewModel.kt | 19 + app/src/main/res/menu/pdf_viewer.xml | 35 +- app/src/main/res/values/strings.xml | 5 + viewer/css/pdf_viewer.css | 36 +- viewer/index.html | 3 +- viewer/js/index.js | 806 ++++++++++++------ viewer/js/index.test.js | 337 ++++++++ 20 files changed, 1729 insertions(+), 321 deletions(-) create mode 100644 app/src/androidTest/assets/test-large.pdf create mode 100644 app/src/androidTest/kotlin/app/grapheneos/pdfviewer/test/PdfViewerBigDocTest.kt create mode 100644 app/src/androidTest/kotlin/app/grapheneos/pdfviewer/test/PdfViewerContinuousModeTest.kt create mode 100644 app/src/androidTest/kotlin/app/grapheneos/pdfviewer/test/PdfViewerContinuousScrollTest.kt create mode 100644 viewer/js/index.test.js diff --git a/app/src/androidTest/assets/TEST_DOCUMENTS.md b/app/src/androidTest/assets/TEST_DOCUMENTS.md index 919c93741..dcecc87a0 100644 --- a/app/src/androidTest/assets/TEST_DOCUMENTS.md +++ b/app/src/androidTest/assets/TEST_DOCUMENTS.md @@ -59,3 +59,19 @@ The LaTeX files are used to generate test PDFs. ```bash qpdf --encrypt testpass testpass 256 -- test-encrypted.pdf test-encrypted.pdf ``` + +--- + +## 4. `test-large.pdf` + +This 73-page document is generated entirely from `test-simple.pdf`. It avoids +depending on or redistributing a third-party document while providing enough +pages to exercise progressive layout, late-page navigation, and lazy rendering. + +```bash +inputs=() +for _ in {1..73}; do + inputs+=(test-simple.pdf) +done +pdfunite "${inputs[@]}" test-large.pdf +``` diff --git a/app/src/androidTest/assets/test-large.pdf b/app/src/androidTest/assets/test-large.pdf new file mode 100644 index 0000000000000000000000000000000000000000..325ad3ec6190eb81a0f7f47618944d54035bdded GIT binary patch literal 888537 zcmeF(cUaBu{|9hI5h@|8P-Lgh9->ev%4nEriqfJ&sE{35$*5$n>?A9Dj}%483<)6( z4f9ubo%1>8-PiA&uIqGl)%E=2`~7^>Ij{S5zwZ0?*W>f-HrixpU%DQi?8=AA7>X|t+Ede zE&C<;CxfNMBfmE^Ec>F5x3iUtpVlP(vL&?itzG>6v?gnj-#Ge|)zacul(KM{;^sKm z+h1!Et?UOJIzx-gXOw+X-`dB`^MC!^(%a9`&!z0MWwn%>x_C_?4YSIAAn3fB4gFW7 ze0PE(<}O~-{9LuTbS?d%ZXSLvK4t&(C|lUX#o2p`OZnoyem*Xap2|)AGklw7KAOX1 zj5(fqlEq|LH8HcIu}&m2xhzW>lbyz7aBP~Gjal)e$-_9>6VtwW4^!D3&a(X8!&%P8 z31@7L&oof$s`{3+A*lIZPaKh+OtdDIpHSHWjGr)3i_T=|aXCCKE{&~6<7j!!^6;pb z@PA+C{y$%4k}q>OTs=CU_dmZ~ewc!BRNMwKS$(TnPJZNal>e}7K7$>7T`GPv(aPL> z*w{YC7M65c*}TdwhKY->vya;hKW`tcvZE!v4|VhL^)q&L^wHw6wDip#EB`=elznBK z+Y~=nU#+rllaE?^+jzN=dDAO4E_UYSH%Pp{^4scLeqCpkU9hswMz~G!70irWzW+u; zmLT7r#APt_%B~%oM*c^qF}QkVQ?2|Xhfdd{YcaUxKV#EqRsQY2->}(iJ&qQO#nNMG ziT*pY?0h)nyA?mJ_(tVV$yyaZ;W3$d%*t<;uT=4AHu(gTPuJsWG5O`SnLM5zU+lLP z8>sl?WRXWfr;ALe;zHB4=qv`aZ0zKEGqiYY4ny##@)1}5LW|F4mVHX%%@2)Ju#TG4 zvhV8orZbk`>~?0-j5%|bD@L%CS)ub64xG_!$!jk;`73?TC@6pRk_%ScJj^He{xPlq@~lXbGP$ z_dF_U@L&0UgOK~US~qfh!Z`eW1HKykppTbrP@++ev;n;XWd+cfy}VBl?ghww`c4llekzu{}A>>qQ^T)MEJvHYMv zyh#%$8Swi}8k{h&g~7tUO`114aPQ#pYyBM$HyE)Z(R)X?^v56Xyn6JaWuy4GdGf#R zJa4Jhu3A;H5%-2wB4nGFu6gC zo{aJ=epBKedrW&|rxx2UDNJf{{btb|HICN<&2uf*tT2Z|m4X@%7tx z?^?HbBiCSZgMp*m=dXE{IV&M*(Sp^smmZH&FDd02@Edw;4{7jYk;kG@3)&eT-Sl-} zgXW(U3kym|U0X1sovm#Hjdkqi4hnG%2hskl{If?f<4-pu<<3p>8@2N_oVm8y<w)(~2?hR62-MiAfBzOGuk)sYP$Zz?heO9SQ z=l$PPmgoH4`e}jfpTF(?7~K6E`tEP!x+slyzi!w4U9$H`&zyEP;(Cuh=4=*Y;~NZ}Hkxuh^CkSC5)`#VPmjwB4?9HcyyOHV%nz`}zEHw{Wc^ zrlx`8!ha^)YNSmqnm@2f*9rPBr#jh~4Y@;c4*F;LZO zGhzPQ6*m+zSs|_8S*FtlpEn$>_E#Y%;phA-22<{h9!;-tlHm5WyYH0B7$={6M z8I{Hcj&k~S*M05djIljtPQBde%)TpYul$<6cC5YCnzaRHpXW~L`{)%TYtNn83HK(f zaY=1`Q1Qt~#mzpN$2PT!y|>YS%t4*Q1&Psx57oc*zB|V=(Ym?GmV`W?Gi~lV|2;VF z=rhe*oA;YtnA_qGCt;FCs=m5rMqfY2ljnoRjP33+^mz83If1rc)%4gcPVVd4Vne}E zi$1hNFKhM3fpP4EpUEu=qh^TQAG&&t5cWk-2L4kVdzdqmw)u1@)ga_eN{=JO_=3 z#}DUdPhgI+3+Fs-X3PK3dGLl^Lp-xGuea6Uk8bd8Gi{&#q1Fl47etM`?>D~q!MZa$ zLp=R$GN)T!Sp9bdEADf+*?H~$FG5!PpZTgb~7I)9u^s z_oJ5|R~nZ|dvn-uwV6|&0mr5ci_ST^X>5l@R>9#{>3*e6c3jUJ=a{VcF?3ewsoYT( zeazQ)iyq>$vANT|#PqqzxAVH)m>1=`Jf(E3p(5WcWXXZVdJKb@7XN zvX$}1&F&#@KC62a4wv)#Q_58PlX_Fp=s>JSTBoEh!zR1&rX&x$+CgFb-0vkzUM+1) z&njGeiQVnOosfHDc9>bEaE|pmW^ig|MCo29tFPI+^~2{hy727D`aMy7TD$Cz8J-q1 zVOiAnWlk>wOUA7T+h=j~T!SXbx|$9kQCg>83&7{UZBxi`Lr$*FL%a zrOi1H4R))K%i?~mpZk5)$1yAA1HDGYOdiyBx_V@;y2g}|KmSD9&eI(8+wz!B%mZ(g zgZx(gn)^m29Q&wp?3Uh7`H$!CU0S2s!vApJV>2WBh0i|Hp!faU8|q&!4Vd#RalOy4 zUdspT*xp+j%8B$?JoZuUjRmFq0)yHQ)Gj{ETQXkZ>i)n_S4LlrH_AK9uzLA3X8ec7 zd53~-F0t6R^JkkI2J=rvM!0Ram}T3bsIi`j`kb6z!{n!o_H;BbHVXZ8MfH}~%6Y#J zp3b^nnzd%t*up5^&g&+4=^B3BJv&?HsE$wipHFuioPOkf+b6q;)h9EC`-p=xJXURL z`RCoWKH(2G-k)^#euGukB?0cYrtI>JU=Lh&d#6tFjv-eA7dBHXd>Aon_~nluo@hH| zu+of%9^VkqWOUIi%Nwj)kIHaa^a zDf9NiFoT`LW8UWN+GFwJc+k<9e$4K#oc%6MS^c5p0ep}ojeCC+gjb24V z-j2N)bMf;@AC-VUZ%$hGThht;=pExThxYeiM5Zt_4Hx}xetPh>$pd`VuV-}WIi6$D zJ|Ou!Csi*e{LUd8r4DBwYq*^4vrY5RGu1rLd4uQ<-H%TmGVEr; zb(^8DGSb>T7`Jx)Mx9Q63nJSc|J5<--f^q>E@#+Q*LYm{{YFOd>i3hQ@3r64++Rz( z<2|2w)Vj@&CZ2yp6b9Ku_jid z;JsUt&!o9J7vdBz56@o^BWJeq%N>_xF`H@gT`dpx`pvy(V%L-x`EI`dhSwdA-gDm4 zH}B5a=K5c|w|u=}+E#r|_j_q}M?2;!FvbjbT9lJ_bAitU-KOVGY(IP9*ID{7U*GQC zeeQR7Jzz9*10KX&qqtlLM z%#F%x-oa|3_kuSYyG$!iSu?GDYwx#K`cvnJJl$zIIQK&nE#+m8vL4xTUmUm8P|CRH zH0aEUwCRpv>+f#QJlVq}KVyM;_u$3vyta&{P1~LNpi9%tA!E~;dUP_J^|I5|9vd&6 zndIoUvc;Fa_vc=ExVUZbxR4JSc0F|__UZi7eBzTX*53MQL8oTzh>RY{oN03Ovf1ua z^8+<|@D?6r9SxrvwS0e;rLpVK50`sp*z{~3{m^N5RQ^*rKfAvZ$M-$>ddI6-Z^qeM z4hU});z=8OuKm(KmOUGCk2UsEO4sm>^x81#Z=mlCc?-}DS zts0_|=C^Wp^3_AE;U}982++D?QV`f`2=+P}>#F5mG4V_&FWlX-5d8|`{v2oiS_9Gta^2ZK(mvz{2%gsdo%@vvnM?Jn? zd1{=Rx5Q`0&s5)~J#T)RNcZn*v2uNOi0>iVkA%cq?+RzXb(*@!YgznxkDqF$ieuI9 zL~Fl&7wj@^+sHJf-Eu4kpKjp|omA$oJ+Nnk@rso>bZxBd zm%CR_Jk4Pp${KlFb-`8RX5n}HTg55d3wWVfatO@K z)HS+KwLPK7i|U!1kE*YSx}Dhw7fZX@516 zrn9$qL2n}0JHNDjl%fCZP66x8xnI2&HEfdc{MwfOF2nD{ z6fD-xy<*;@)hR#MjTV>X68owQo5nVDk@NpO-(iWqZ`#$*u`Wvn`;^9vd27<`*rd3l zuhOEwZaZ4w)H-1DW#0GGQ?3;^HhO*ZV2@1ohuxJ=hxCXw-S~0Nk!Dl+XsGn`PO&pf zRPGhAL1Fst8I$E*le$OD_?Ei?3=kS}M=Zk{z_-%XRAOV{nX zW3hht&6UkFn{+kqRwCbO)7YC^P3cD;y}dzWo(s-tJf*efRUdngGc5-yz4X`X?(A`L zPDtWfi*aM$uf90);FiX7R?&3EJqr1Hf5ESSrmLOfJHFajF!JPq+(T`XUW|@g7M48L z!L&`FiI-W)%0_<6FDt!KaJhX!oBK5RQ17gd{FAzy_Y~z%f7-%utN+UFZWi0J=dOAnXZ3v{L}iBg(b80Di&*g{-l*MWMcDk z{M1L;%~n4udNU~C;4bZq>&2}Tm%lQc6J@HisnxU{3*vrRx4OM-d++uR9rI?Ze%_;M zzx%FVhgI@R59#lke0)p$#qECZbjN=y308aFtG8}MJM}A1i{%d4q)uYb;a}>`>epB` zV1v(?DSeDIFLan0|D1iTdBmw#;}(Rvt{?N4>3Oa6lj0%U>y&ZfvbCd%wQ9!hbt&%i zXS6rgZvFFJ{P~Q;?)sV+XFvMVF{*vUK8GoTJ0F_=!SI>Nuu_lv^RKP;{~l=_*EVoR z#DjUp9h3UJX}b8gO3L)kW%RM>*=L%1 zZ@;}Q%VjNX4mFsmw63SpwJRCZ`h+^)m}ci4tDD%_HP*hfIwM-wXTC|wOYJt6 zBej&Se0(xS?*wbdv%=#VriZOmId;Rot=94V*z7^UV9P1PgFI&XU)gVI9h2@gIVanF z)YFk|bNilLv)>@2efC3-cE4l4{|Q_F?e@cw#jZi4`lY3%Oq_99^Z2&IsxPm887{vl zwe80gIrS@rP9N4T{~axz+CN={B2g@y{&%oz!J@pJ83>#s_$7Y)HJHQ>gW2 zpyBa7I@j*zy;R&5Gk#f%%pd&bK`+~{JCnF7JwHe3TfoJ@J@(62N1Rho@(nG$tmJs5 z=*rPXt2Dy>w-344`Fdz;_jI-Xdwix^wV(DO>HLuXmhmkbj!$i!GH0Sr_8;3H{a#P( z{qo(PBWKg@zxg#4Al3-4b~JP{G@`QhHuP4QXl%zJbw{BX9*jXJ6YC zNuTM(_IdjD;I7I3id?%9%6qrpY~eM2Vv8SN^g7MGp?t=|;I02EbwicxeTh*)tekef zW90hT>|c;Ppnt)wbGoLc?OL@kaQK>NG{R=#b(bzI_045B>T~JsgvTwjUN3ubsHkw8 z{dq5S#g`Y`Sh9Gdjwkjp>paYMf#!uvfh)P=T(#zp-uP?R_TRTU%=$ULt@5fz!SM}d z|4h(!>DHlCFMs{DtIVd$a(y?PA3k3HUi`I79M0~{2X8O_2tT~(STyaU8wNP_?Xmr9|R?fYSgU1Dr%2zEtG*wf%n;v_z zX~VYPipC5`GMa3!F(xQqWqq$jW)69_@-vP0xqOLvFnk4{w(8TfNggrrOI8hd@J^-w zk7IJ8Q_`I8O@6uM^SYPz5f7Rd+447FJ;NP}#&A=(8y;%yjMvvSv3qm&YhkmXA?B@OQrB&}(d>jlrrdynNlWBT z92~6OX2?jRt1BnA>acx+d|cN~FXraH43j%;an^6r^_^#bCEIuCb7S%S*qNEZNw1!N zY_sQ;KW&`mv&bE#Q z$m;7Mew$^-C(k>nK`cJN)Fe5=7Zi|4+%d_E`pV$W9&o+`F)FSuh-Ex|b=%EDW*IbJa%cU^`}!*;T}s$t ze`K(cwY%NMNAFwBo3H*nPr>jUD{Pz4N5Ok%E%bZJ(Zo3W3cSY zxdADza@LbPdcE=+e9if~<1cmH-G7epj0@&zWyb}`y&PeCH+1{j&3Mn5``?DY=l&S8_gl+= z#Xc!Z3tYdR4jD2a;7EsK_K(JYd$;dbi_qa~?+k5sx7fQue8hxxL9T67mPR{mFJ%2r z{*!ue+ozSSR(ic}k(W4q<(wmN?%xL``8hsvO*#5OIhAF(+P5`(#D=%Z*6CZmy>F~| zWYFDB_G2^E$2c>)oGsX*>2q^VYT(7BC}o?=HmfqiHg;`ubxfrELvNEGp7L5BLgQbx z56~agWxa{NoKI(`9#?-}?`+iGVI?seDCXSeh*&{(nKzV^+@d)0T2*!3fushUyr^;l^i zjpE_+Ck)-0yI{$bq#vqoX15|8-XEW17e1+LcJv0@L6gcxccIY`_x*22H!1FTztp|w z-X`C)t<#eHwx8lc`ChKJR2N#ZI%rE@X`g;stxTzWK`Pd+1tbinjN% zdlAEQAMY(pI}(+o5PE6+*vG1^>|QoKcXt1qUYz(w7V~;po#{RxiGMk4YWrampEXU& z9$>QK_3J&FuB&@~=(#lKOG?`EDc(h$x8Kf8*}&*9h}IfI+tsU1j&mN=b)UL}`md2s#&}+TxOMcg1cRwv1C?K1 z9xyTU@b?#MH5n)8tloU!LcDrvm-8!z%zrdn_nnpA`~FHwrrMbg2HJg{^tjYKcBOnP zn``f%H8?qG;u>#_0hULb(dVjcN<5l1KW438uP3=D8h0O{qTapt-@yIB+* zbl#k5x@676Q3~N~t>bS`9rJ$aGd<|UIIA~vZMxjHTH$`pxWuCATaQJh?T)N8DsJM{ zFH0*VOl9Da!al`M6^1%KPn~k{V$b)Pi<8Hf{gtu4XZWI!r2f51>37)v(M}&NC;ja6 zWOK*%-MZ7baW4|oVvCEO&Y9MpuljJWY1Z2}2fAMUu~MV6mGbIyr_UAz7gX5V)moGm&XLxj* z)}cMu&sXg=Fjdia+0c2LwoV*>N#`Z+#fjOAQr;gwvUF(Z(Yv=-7X?4@E1h7XUM%OT zf2pTI$Tin+Q@Y;xjf(Gv912$Zp4jd6r1&-yKhx!+cbjjGT>f;dgL&e5Phakr^UW?D z-@Rslq3V;3qqVF9m8I|zv!{I9rW}Ma^uDoHJyUt0?g8Wb0cPD#p?5c}>*Tnof&&+pKqCi?j%%n6X{j`EC7jV{*Fbk7c$8zdvR7 zKC{nfr{S}OYG>^2thX7yy&0Q4u!Z|2_lCVqccpZ(TKcioE5CH_ul_ysR@{2m=7@$y zdd{a=1IM_uPwVS#|H4s}}D5v^&e;p$YTC?9Gw)gReD9 zwF|gtZu`~A;EI9zY;VW*SMu`G1}w?oo?Y`uqj9Is>_D5oI)`KP?+;Df*_YS&-1zbB zFOK;6bH>C^x@u?N?QD6X^yJN{2Ai*?U3hPH-m&BP`G>!$t}DFDoNaizb<#LB&nHWU zS~Tic(4+an+>mt7-0Y1 z(B5?=FMH7N#R`e>3bWg*aTl++tE^s9kY(F+M8MSf4FeCBZg*Tc-*ns0c-pED%xNYE zp1VAqvtzY-LED~L-=n?Tyc(?XmDfFC@Tch;_dOcRex}iDqmk-G-n1)+4(lFG*V(H1 zDaH2fhpwKzpBu7wGY=fR-s?@{L!0TF=4glOG&>u1o^y};#a%v`pR{-CJ=H1tdp32s zYTr4uhwIW6TY}#C4d`I5JL2>KxeMJ(g4ew}`B^P+?gtI~-b$OdpZR5%aD3eohb@iO zUV2;W+sU;l{1IOIVU2!I>-S529_4ASnzN+2_IsDu_j%_M)z|xO&)6DawU9N(weM*! zz4>4FsnZwy)KJS!)O@6+@l5W<`DIN$y_nM}Z-;a1d1~gT`i8X*8=?F@di&fDfo2DD zTleZRLA8CnkFv?`1RFJ)lS;aL?&ti`ZCEL)xtGFkY_NZpd9j$~@$lS!U0&p)OJiPM zIXLM`;ngqxrVi07FL#-q!&eK@=zM&?Z9+n;5QEgaCimO%-i=DSH1bntCGB%Zw2Qtw zY*dKt)oOia)Dce_XZ0|HkUgOZ_a2?PGQFVR!S-&)hYXA56lILNzC7b@>|^Vm zHjK?VcS@e_*PN62VzK8{16NEF!r4Re zN2lM8$*{T?Z9mGX;G=E33%2R%o5N|wip=8FypW)|J-Y5awkI;ozpLF{o7N`J*Dl|E z=y+%SNuHqt_vo(jZ2ozz{ed1AOb-ur>1VF>DXZtu=G$p6O_ZA%TR13haUA!T6pR+fFwKDrX&X}aJ2_4f}U?jGa!-|DIm)_VN7MZJ=GuJ?M9rt!%w@sd}- z=d5)RPjuJnA4yC!DZDy7=b6QeRp&fAZ{ofl*lDZsf(L%V3g0Xfk__ZZR0~;h%$URj z_RPl(Oylhe!>?LBSIwPckoBpt+6)!v*i*4)PrA4tb8M#M|L(feE}dP8^Gv-It&XgF z_C8``sqW0kQ_K8^&Pu;CrX1SW%*?51Kf}R2Z{tvF z#^4X{xgS-!ocQ!{w3+4}zlX0fZwGC@XaB3+%K_A`1zfAsMD($Re~tGCKs&rhYNpYUQg=lwi0$2?+2^WU0J zN}4zRuq(umw_|m|CcaIPp~;*ny3TG&8Otujj2gQrS})(*e7)wO#@4r2t<~x|;=tf_ z8(l(&&whV}A2faJ&e9n7{=IrSA1GQAnA&Fh+nn5E&n}$JKREP<(wcQ+AAT-gz>9m* z)%b1q#^JgOXL;|<+s8c&EEv>1cu40Y=R>Q0?9G1Z_W5vXU$e34zhc@aY&ocE*l0*w zv;04;KfY_w`S*_rPp`P`W#^Rky4e5ytu&qH??x3n>~ZO*l9 zlJX^>QI{ttUIq9x8XK0-LwEJGYqlA`8#H>@&&y#;?(429U7qc>-+02cIqUg$MUA+Nhu%HC z>zC9vuFDOTTgp>xJiBQfdapWF)J9P| zt9KW>kB8n^EoWM^(^giSYWykv_PG9H;qem6^KoFAOGjvM-CK z)t6C5+2}MHpP05XSvknpxg20}_+=k8cAR1A;x^5-?2o)M@Kw(B%753FUj9onKSvKY z=Rsc6Jj%VLzLlSg=h(8ZmGQ&M@35F<$gZ67l}pc_`qug``o8*p`u@EtdMM*#E?NYk zS&C~-qWJ`y6i7>ESz(Fb%+tog`wgviLiRX+)$ojlH)fm4+bo%qZT#lAr%lXbJ8k+4 z({(#qsQfO@$?NL>w5UM`qqWsK4?UUcQ2eyF{i(!n4tEa4XKZ(8#$N2_xaKR z2URY-4*PQUsj7uR=Ie{xYcG8+Mox{t={5a+-%^(!Rw<`OnfLd3;JMQ^=di)$YrVGE z`5pgwz#}bg)&jL_@$1?&x$|jX2fePvlRJKPbjbTrz}j*x=EbZ*5wRa5xAj;4z2B;& zg}MEYj6#c_SH600e7f~}ssD400}lPi>rL>S9l!JYp9Fq8&hH1W!}I^lWtpElp=Dz; zE;41N)z+Ytpey6o5%T^<@d;^yDo~U@RdEZU+rq7)wU7m8_ zAno+h<8i!Q!8ZnVXtt_VR?{|(-GcAv7qV1d$=Y}fW39z>Q{j{*{m;Sa6 z?T0z{n%i9Wpo&6&ox_W!Eot_8#q8IY>5ggHu_q@r+h%E+7G{2(ZrE;GP{L(i@!RHa z-*oE7|y+_z`nCUeRuCk3T+zbm3lvW=nzA0@1nC~Rwjj78gfHockLK9Il&<4Yr&q7XZ;W6b4EqH zdT+Ms*xX0??$Lf93ieDh9Mvy>?xPb)VIu>g3!i73=?6r|mHi+i+pPRo#d9AmO$yTs zjAnc-*r>F9zxj{;E+=!gowdLCbMITvs|ND|zsLB#de->ifcURFueC~xd;gy!JNLEnbM*1+rByC{Ewy^iC46%v%xwXH;Tx z83}OG8};v$+Sc2Qa`P-`s*G>bE7iM{e!EQS{y$%rEV#*W2$q)t;bv6${<6QOB@%8% z#TC^5k8m?8_AW-a$sG`H@P8Zb%E3`?ELntnz5XI@hA83(;s)Xd;s)Xd;s)Xd;s)Xd z;s)Xd;s)Xd;s)Xd;s)Xd;s)Xd;s)Xd;s)Xd;s)Xd;s)Xd;s)Xd;s)Xd;s)Xd;s)Xd z;s)Xd;;vD|O`M!KYpdIcn;qX28N2>E*b1=cK4qYq#WU=3gmU=3gm zU=3gmU=3gmU=3gmU=3gmU=3gmU=3gmU=3gmU=3gmU=3gmU=3gmU=3gmU=3gmU=3gm zU=3gmU=3gmU=3hhqrjRtIdRrjw}CZ5XcS;gJfC2b0M-E30M-(KHA`ZGHCs}GHCu>i z^+!ueux86P!J70&eebmmtl6SEwoFKq5tl`POPOHJrX*O4e_b-LCdVO|i41@>TlxfR zc9jHcwh-U{2dv2*5O45*1MAA2Q-C#DgnYgJ0&BLY(Fd>wum-ROum-ROum-ROum-RO zum-ROum-ROum-ROum-ROum-ROum-ROum-ROum-ROum-ROum-ROum-ROum-ROum-RO zum-TMQD9A+oH%Q%+rXM2GzzdLo=>nz0BZni0BZ@rnjwh7jx zH|l$@ZD7q2)v;y3nv7U_V9lWPx=IFUX=uEo)F*v2dv2*5O45*1MAA2Q-C#DgnYgJ z0&AYA(Fd>wum-ROum-ROum-ROum-ROum-ROum-ROum-ROum-ROum-ROum-ROum-RO zum-ROum-ROum-ROum-ROum-ROum-ROum-TMQD9A+oH%Q%+rXM2GzzdLo=>nz0BZni z0BZ@rnlCZInlCBAnlD7Ol7TgyCff*W(j4`{*S4_ci}KhqVNGT%y|Cs}BCN%~E?HQU z^AOBL2Ev+7lRm?mPOB1PO{WO~{(s22;vR@M`M;HQNQ4`&T$ z4QCB!4QCB!4QCB!4QCB!4QCB!4QCB!4QCB!4QCB!4QCB!4QCB!4QCB!4QCB!4QCB! z4QCB!4QCB!4QCB!4QCB!4QE}W&bmU&iOaUS4XrB}8bw-Hs6N3qL9IcpL9HcFYdTG0 zm^Ga)Db1Qr7iwB5v^8C}dDa!}Q6GJ6b89+Xl*yKP>xw~353cESN~SfXfMjxAaVmnr z$iiIHrH{3y)2rlK)9FH!FJi9AZ4htrf17K;{;BAiEJO;_V{}ay)%!r#K-WOmK-WOm zK-WOmK-WOmK-WOmK-WOmK-WOmK-WOmK-WOmK-WOmK-WOmK-WOmK-WOmK-WOmK-WOm zK-WOmK-WOmK-WOmHH)qZmlLOLbsSw2hDJr#g!Kuw33Lr~4RkFDT{9#GT+ZGE23-ww&7j0wQwm6duF0tg1|th}&8$YyHM45a zHB(6QMbI_54dPAy16@}RkczI!LZm=FM%PSH!Vh!}bPaS3bPaS3bPaS3bPaS3bPaS3 zbPaS3bPaS3bPaS3bPaS3bPaS3bPaS3bPaS3bPaS3bPaS3bPaS3bPaS3bPaS3bPaS} zv*?;|IdR%n$I&%mXjF7fSf5~k z*W79(U304@U2}yrUj$u~+aTWLKhSmM{;BAiEJO;_V|2|GCHz3wK-WOmK-WOmK-WOm zK-WOmK-WOmK-WOmK-WOmK-WOmK-WOmK-WOmK-WOmK-WOmK-WOmK-WOmK-WOmK-WOm zK-WOmK-WOmHH)qZmlLOLbsSw2hDJr#g!Kuw33Lr~4RkFDUGpR-UGpR+UGs#TRtj{@ zmvPcHX^;BoYaLzlM44FV4FbKK-WOmlF&6@V$wB(CMoHfK@)OX$>^FvlWo#;CWT6%QNpiz^q#S2JA*A=HC7>o?iHG?L7(lvutCFz<$6Vm+u&~?Ra5O4Ay z=(=+M6m(s&5Ghc9(KUl6O89}Ufv$nBfv$nBfv$nBfv$nBfv$nBfv$nBfv$nBfv$nB zfv$nBfv$nBfv$nBfv$nBfv$nBfv$nBfv$nBfv$nBfv$nBfv$nBYZP5qa5-_>R=3f0 z1w*5t>k8H<*e1|5&^6Gt1a!@yOH8_E&?O~ZGw4E2D+Rh{$TsP^qCM)PuXS`y7iF?# z=(=Li)j-#DO42o@fE4JOoQhyDvOw1i>65M*j4Daj42F>Ai=b%KeWt?=t_kZCY!m1j=o;u+61rwdPP%4E zOuA+XIjt1vnl0m`YtkO|(bqbo~f6j)sc12-bZXK z)two6YMKAgS?L$f?+843Fun28Lw`cmzO6lDV%5s+Y=XYG8f~=Zj4*|^4PCtb1B`AN zsWA60@XWPk55A~9q~G6UjcsGvZ*z72sCpvA(IaF>7tMl#h<9;)_bSRS?tT81y5hI^ zpQelZ%bN$aP%?4ZaC_P*8@uJZ3!1ESP1&+92hXE?a$Z5(RN82sTq z_oGUe6Q4egHq+eW_wZHb?V!!~?0>a;IUrr@`S}SKx)(V+I3$dmv~a4Ed9qQQJ+EKm zrMvv-o?+8+z5`tYUDqtSCR|ROw$*WTO&A&#T@%(P*e1|5&^6GtBy`P|oOI2Rm~_n% za#|_SHAlut*Q7n_qpx*z%@Jj?<>;CWx*F)3LruCCFCYcFCZ{48j4aSKry5DuoT^FJ z93jmYLD%Fqh&TBUbX~cBD!L{MkplG?U2{YUKhQPMHPAKCHPAKCHPAKCHPAKCHPAKC zHPAKCHPAKCHPAKCHPAKCHPAKCHPAKCHPAKCHPAKCHPAKCHPAKCHPAKCHPAKCHPCg< zqHDtC#A#a{N7sa*QPDMFeS&QQT?1VMT}wjOT**n-T!~57Tp_2G0$uZDoODgvqdxjt zN7r0YCR>iK$)Kx&uDR5tYw-e7plfm}g2BiFUGu7ubj_=pbj=ged=Yd_Zi9G}|3KH3 z`=_F7vJfdykI^+xl<)&x16>1M16>1M16>1M16>1M16>1M16>1M16>1M16>1M16>1M z16>1M16>1M16>1M16>1M16>1M16>1M16>1M16>1M16>1M*DSgwTuz*})p2x97#bB_ z6V@l#CeSs|HPE#rbj_2Tbj_ETbj=rXS}D*qU&cw-q&@1RuXS|I7iF^L=$Z_=8t9r& zO}Z8@AO*T6ry>}PEYLN-8cEmus!7*;A1MOF-95 zn#81QCQVY(HIpXfw35*^lP=q&>x%ZMkG{6iHIpXFWXsTX#h|4}*Gw8E>6%hNGP65OR^eRc$OuCTf|A($CZi9G}{~KKk_D@0A6$_C9^%q?;>7s-m=o;u6 z=o;u6=o;u6=o;u6=o;u6=o;u6=o;u6=o;u6=o;u6=o;u6=o;u6=o;u6=o;u6=o;u6 z=o;u6=o;u6=o;u6=o;u6=(ws$O2t6 zq))nLGO8q9GZ{jfFM_VgZ4htrALzPrfK+r%79s`eF}h}m5`Lg-plhIOplhIOplhIO zplhIOplhIOplhIOplhIOplhIOplhIOplhIOplhIOplhIOplhIOplhIOplhIOplhIO zplhIOplhJ(nnl-y%ZbyrI*zUhL!+W=!ukZ;1iA*g2D+Anu9=dPu9*^(u9-qkD+Rh{ z$vEknv`2mPwT`ZtqD;0NU6Vmq16?zzN!Q{9q(Il?R0M;O1-fQcBk7t|HR+lqr1>J~ zn%oBQCjWu1EB8-D*JL45pdO=ZmMGx|x(2!ix(2!ix(2!ix(2!ix(2!ix(2!ix(2!i zx(2!ix(2!ix(2!ix(2!ix(2!ix(2!ix(2!ix(2!ix(2!ix(2!ix(2!ix~^GtO}Lym zZL8zxnlLmfx+bhouuY(AplhION$8p-Iq8}$G3lBu6%+L>6$B~`6B3=+y?O`|ADS6_fJLFWFb6#}o z>6$0xv{ImJo{W>ONqf{sU+d_aC(2~Y(KQ)#HPAJWnshB*KniqCPDL;nS)glPHIlA* zRgZ}K1Lx^n+ibWIi_1?n-n=7|!1plhIOplhIOplhIOplhIOplhIO zplhIOplhIOplhIOplhIOplhIOplhIOplhIOplhIOplhIOplhIOplhIOplhIOplhIO zpzE4N*M!T7)3!Q}t_eelPyQrWYE<>*L-TywRiz3&^0*~!C+*7u30qcldf5`DoNKYnvmxIhpsDb zgLsqwK-ZP~r=aVKg-C(=i>_HTQNj;&4Rj534Rj534Rj534Rj534Rj534Rj534Rj53 z4Rj534Rj534Rj534Rj534Rj534Rj534Rj534Rj534Rj534Rj534Rj53U8Cr_g3F21 zwz`e3D;OFDU01L^!8U=efv$nBC7^2-O=8kDi!LeYnnf3KS}D*qUA9Tr741Og2BiFUDKscx@OUZ}NYm zYr+1h=$b4<3e;nCO&2BnK-WOmK-WOmK-WOmK-WOmK-WOmK-WOmK-WOmK-WOmK-WOm zK-WOmK-WOmK-WOmK-WOmK-WOmK-WOmK-WOmK-WOmK-WOmK-V>kt_hbDr)_l{T@!{z zMc0J&3APDz4Rj53EeTySBqm+67?P5%Sqvekl>%KeWt((e(H`~D*E+gph%(u7bWH|b z4Rp<*BwbSqNP(`&sR#xm3v|t_M$$F2YSJ}RNb^O|HMtGqP5uL2R}PShuE|2AKs`p+ zOi{uQbPaS3bPaS3bPaS3bPaS3bPaS3bPaS3bPaS3bPaS3bPaS3bPaS3bPaS3bPaS3 zbPaS3bPaS3bPaS3bPaS3bPaS3bPaS3bX~LPns7OB+E&NWHDPE}bWK>FV4FbKK-WOm zlF&6%a?&+RV$wBB$Z4fO*DM()U6b~xkG|H?HA|GqmZNJj=xU&A7B%Twynqzwnw*MY zFtR|`tZF1(v#KUtvxGEX1YMKcAl~FZ&~@ehspy(4L<-bnbj=ba{6N=0*Fe`m*Fe`m z*Fe`m*Fe`m*Fe`m*Fe`m*Fe`m*Fe`m*Fe`m*Fe`m*Fe`m*Fe`m*Fe`m*Fe`m*Fe`m z*Fe`m*Fe`m*Fe`bi>?Wm6Q^x;99R>#pbVQ5rzO<12`n?Tn<*Fe{j&^1SL(lu9N(luAeX{A8dTp1@_llG{OzShw- zSCq+?qiZtgYM^T_HR)QsfE4JOoQhyDvOw3|Y9w8At0rA@g*0CTU6b1&-sC^fb>;r4 z=$b4<3e;nC%@rm5K-WOmK-WOmK-WOmK-WOmK-WOmK-WOmK-WOmK-WOmK-WOmK-WOm zK-WOmK-WOmK-WOmK-WOmK-WOmK-WOmK-WOmK-WOmK-V>kt_hbDr)_l{T@!{zMc0J& z3APDz4Rj53EeT!oBqv?-Bqm++gq&6ibj_D>(lu$1`siyNUGqelY&p6ngRTa;=24Tb z#S2J*uF0tg1|th}&96q%HNR@oHD5^cMbI_54dPAy16^0{pNg)@LZm=FM%R2%!Vh!} zbPaS3bPaS3bPaS3bPaS3bPaS3bPaS3bPaS3bPaS3bPaS3bPaS3bPaS3bPaS3bPaS3 zbPaS3bPaS3bPaS3bPaS3bPaS}v*?;|IdR%n$I&%mXjF7fSf5~6%Rwa$3pgnoW~!(sf09)JI?2=$cIvWwPbynhaWcbj_wwldi=JNJiHcry>}P z4A3>3CVkR1n^q<1noSeZ{QuB(#cdF8@*n8Ba{m-`U9k`;P=C=inAIpl>Z7l9bWIm!vSsMHV$jt<*K|tKHKl+Q=$f30U@)>k*9_^C zuGx$#N!M(Kkmie^YjPXJoBZGCTCjgAx+V*e0`(YOGeikL&^6FC&^6FC&^6FC&^6FC z&^6FC&^6FC&^6FC&^6FC&^6FC&^6FC&^6FC&^6FC&^6FC&^6FC&^6FC&^6FC&^6FC z&^6FC&~?qCYr^HkX1MOG4KS$w}8tiAmQ?A*YoBT{C5z zbWPf$KKfcm*Gy3+TaK>DpsRtdnUth!N&zX*H8~Z*U}S-=nbk!tQW(sM(2)ZV> zLA=R-pzF#3QqeV8h!m*D=$a`?_<^p0u7R$Bu7R$Bu7R$Bu7R$Bu7R$Bu7R$Bu7R$B zu7R$Bu7R$Bu7R$Bu7R$Bu7R$Bu7R$Bu7R$Bu7R$Bu7R$Bu7R$Bu7R#=7F`oACr;by zIJzbbjf$=b>l17f=o;u6=vorGW=T%EW=TxCW(hg16zG~QJbj_kBU5gix0$r0+5e!Ba=$c)Pq-%E7q-(a2=8K?favQ{({0F+O+&>jvlZ8lu zdW^2wqJ$sl8t5A68t5A68t5A68t5A68t5A68t5A68t5A68t5A68t5A68t5A68t5A6 z8t5A68t5A68t5A68t5A68t5A68t9t&xPfWBU19iDtLLh@a}2URHCCIU;v9P_*6c|a z_hXLDl>FaaciN@1D{-Ewm!j2?b!s6C|L-(-z#W7=o?i~Gx)2enW#aoKQt+9?~m<+}@-taMG; zvM=M4iprEj` z8Wmj=)+g8|&^6FC(6uCV&6b>W&5@XN%@J~1DbO`X#!1(tJ?f*cb#%=UWwPbynhd%c z=$b=Kx)v`W1-d4uA{dM;&^4zTN!OgJN!J`9%@;w}VA`dUZVTu~-lj;_g|tAVb$)TC?i0#cxBaw>ws z$O2vSs*!ZftD1Dp6ViMUbWLu9c$5D?*OmLHqHD4cDNv8mHBXfA16>1M16>1M16>1M z16>1M16>1M16>1M16>1M16>1M16>1M16>1M16>1M16>1M16>1M16>1M16>1M16>1M z16>1M16>1M16|iFx+YvsoVL|*bWIo<6Z7l9bj=rKvgPQS47wWVnomu-7B3(Lx+bS07>q2?HNP53*ZitU*L)$( z7eUwLHi$R*4|H9*e=52r3y}i#7+v#42|v&^&^6FC&^6FC&^6FC&^6FC&^6FC&^6FC z&^6FC&^6FC&^6FC&^6FC&^6FC&^6FC&^6FC&^6FC&^6FC&^6FC&^6FC&^6F?&7y0< z<-}=Q9Y@!Mp;6H_VSR#a0$l@L16@l%*BqL}q-zdMQqnbtCgik|(KUxI+obD?_Nb4( zw$U|*Cdy>X&~?S2rAOBs8YStPQb01gt~eFJU}S)U32K7gdgY{=o;u6=o;u6=o;u6=o;u6=o;u6=o;u6=o;u6 z=o;u6=o;u6=o;u6=o;u6=o;u6=o;u6=o;u6=o;u6=o;u6=o;u6=o;v{M$vTzmlLOL zbsJq*FfZ}K1Lx^jS2 zbWIi_1?n-nW{47gplhIOplhIOplhIOplhIOplhIOplhIOplhIOplhIOplhIOplhIO zplhIOplhIOplhIOplhIOplhIOplhIOplhIOplhIOpzE4N*M!T7)3!Q}t_ee6)}hee|`Cu9>1twj5oPL01D^GpR|} z;svBY*W^?LgOLTgW>q8UnpHLFnkA(9BIugj2Jt5Ufvzj}Pes>cAyS|oqidEZ;Rm_~ zx(2!ix(2!ix(2!ix(2!ix(2!ix(2!ix(2!ix(2!ix(2!ix(2!ix(2!ix(2!ix(2!i zx(2!ix(2!ix(2!ix(2!ix(2$gS#(XfoH%W(*$&-%4EyYH5qg@&^4QybS+*$3Up0QMKBmyplfzD zlCIfRldjo9nlFN`$!!pC@*n8Ba{p9xO%@^r>M^=zixPgIYoKeOYoKeOYoKeOYoKeO zYoKeOYoKeOYoKeOYoKeOYoKeOYoKeOYoKeOYoKeOYoKeOYoKeOYoKeOYoKeOYoKeO zYoKeO>zYN^gv*K3wmOck2}7fzYr^^j+XT7>x(2$IgswS~ldd@uldd^JPAdhv=E^wf znzToK^tFzzIigIq99@$^R|8#hs7cr21*AaN>yu6b3Hu6aV5FM_VgZ4htrALzPr|5S8M79s`e zF}mi75`Lg-plhIOplhIOplhIOplhIOplhIOplhIOplhIOplhIOplhIOplhIOplhIO zplhIOplhIOplhIOplhIOplhIOplhIOplhJ(nnl-y%ZbyrI*zUhL!+W=!ukZ;1iA*g z2D+AnuKALauK5y^uK7YvD;ZsLX);c_ChbuleXXNwz9^F|N7rP~)j-#LYSOiM0V&Wm zITgWRWPq-@H0hJBxwI-t*Ib&A=KqJTD{g~$lm9^1mHVfl>xzX)f%=QCxinG24|EN5 z4Rj534Rj534Rj534Rj534Rj534Rj534Rj534Rj534Rj534Rj534Rj534Rj534Rj53 z4Rj534Rj534Rj534Rj534Rl?j=(>W-iPN^ajjk&g8Uyfv$nBfvzQ>Yc5S< z(lwVZDe0O^7jjxD&^2AQN!JzaQ6GJ6qiZf*l*yK%>xw~3kFL3NO42o@fMj%CaVmnr z$O2u{rBAx%(yJt0bLm2wFM_VgZ4htrf1_)`{;BAiEJO;_V{}ayCHz3wK-WOmK-WOm zK-WOmK-WOmK-WOmK-WOmK-WOmK-WOmK-WOmK-WOmK-WOmK-WOmK-WOmK-WOmK-WOm zK-WOmK-WOmK-WOmHH)qZmlLOLbsSw2hDJr#g!Kuw33Lr~4RkFDT{9#mU2_?dlCHT7 zA*YoBT{C5ybY0ON_0iWlx@L$n*>ZGE23-ww&7dS*Qwm6duF0tg1|th}&8$Y!HM45c zHB(6QMbI_54dPAy16@}RkczI!LZm=FM%PSH!Vh!}bPaS3bPaS3bPaS3bPaS3bPaS3 zbPaS3bPaS3bPaS3bPaS3bPaS3bPaS3bPaS3bPaS3bPaS3bPaS3bPaS3bPaS3bPaS} zv*?;|IdR%n$I&%mXjF7fSf5~k z*W79(U304@U2}yrUj$u~+aTWLKhSmM{;BAiEJO;_V|2|GCHz3wK-WOmK-WOmK-WOm zK-WOmK-WOmK-WOmK-WOmK-WOmK-WOmK-WOmK-WOmK-WOmK-WOmK-WOmK-WOmK-WOm zK-WOmK-WOmHH)qZmlLOLbsSw2hDJr#g!Kuw33Lr~4RkFDUGpR-UGpR+UGs#TRtj{@ zmvPcHX^;BoYaLzlM44FV4FbKK-WOmlF&6@V$wB_CMoHfM-y^d$>^F#lWo#;CWT6%QNqfwKt#S2JA*A=HC7>o?iHIF8J(lw7(CFz<+6Vm+u&~?Ra5O4Ay z=(=+M6m(s&5Ghc9(KU}IO89}Ufv$nBfv$nBfv$nBfv$nBfv$nBfv$nBfv$nBfv$nB zfv$nBfv$nBfv$nBfv$nBfv$nBfv$nBfv$nBfv$nBfv$nBfv$nBYZP5qa5-_>R=3f0 z1w*5t>k8H<*e1|5&^6Gt1a!@#OH8`v(Iq8a^XNiOD+Rh{$TsP^qCM)PuXS`y7iF?# z=(=Li)j-#DO42o@fE4JOoQhyDvOw1i>65N`j4DajJcf|wi=be{>&Ug*MzPKT@$({bWP}*&^4iJ zLf3?@30)JqCUkwW&~?P+=4jg~hpr=rW})kd^@*B*-5`RbX}#GY=Ew#p^pGvSLUW`Q^6L{b$lygnB#!1t0UZWUHO}?D@F4Y z=sJ#uc_$Bmu15n}=sGqOE4YrK>ncU~30)JqCUi~cn$R_&YeLtAt_fWex+Zi@=$g+DU}wc@l}K-bMN-*g?jXD9vQ z&~=?+vH`k|hCTvxUE7U zU-Dm{e$MkAyXpLUo_Nn)zx;yN|BG{9_NCW8{_kJ=j<0>khu(1KrDvc0XK(y3zx|dU z{QRH&%Wr++Z(h6O@ehCCeQ*EHfB6$1e$My3>;L|p=Y9GI{?+Ygzv9AAy?^KO^LAhL z{Kww(2ma_6?_Iw39WVRom)%tT_;+9Yy}#7l`qWo{Y{-ZRg-`@6pUOK;!%yFc^r z6ZgIPZ~cR3zT|H|@{4bN=SQEu>86L?_0BK5>$RW#N9BXB|I82Gac_C+fBWt36F0r! z-A{kwRd+rAAMXFcum9%HeAnOmPp|nuf9%&@`r*~b-uJB^_*0)f|KNk~f5rXZ^SamG z{qrw-?`!%OKj-`2v0q$#)5D+qUxcm+U7sv;9dWri+IGsJ>xiLQ=sIG3q9&nhLf3?@ zbI^5@zv;To-gMn6PP+wk-5&Ez*Rgwc(k~8Ow<#tYpzCPpBS6=!z3F;h!4}YUd@Ev@ zMe`HrI*x{UCl7$GNBu2y9UF=jT*uILnxj$E(Y8|#T}KSfLe~-N6Ez846S^jJorA8s{7u(g_NMDj zaoR1Q>;9N;x{lqmlYVjNx=S(H09{8z9|5}V>`mA63buf*<69BK90zpWAK|9!-rscH zE1I7`*Kst=J9z+fJ?d|v>)24N;5vq``xN0PbWP}*&^4iJLf3?@30)JqCUi~cn$R_& zYeLtAt_fWex+Zi@=$gl}35=Wn_mvNv51iqmcZT@S~6({=2g zo%D-C*F%cQ2Ix8(`UueVU~jsfSFiLDBpKx{jk^-pK=? z>rsCTUB`xE1=lfjJ){Ufp=(0dgsur)6S^jJP3W4?HKA)l*MzPKT@$({bWP}*&^4iJ zLf3?@30)JqCUi~cn$R_&YeLtAt_fWex+Zi@=$g3Y~zoHh?#55@7`biMALo%BmX*TZg# z$pX4w8+v=_de}8LU7HH>(DnMQh+&Qax*m${Z@L}|chmJyD4IV(*Xz+R@8o9aTJ$&2 z^}3;0!SxGW4@HXb6S^jJP3W4?HKA)l*MzPKT@$({bWP}*&^4iJLf3?@30)JqCUi~c zn$R_&YeLtAt_fWex+Zi@=$g3S#?r`-a&E|2%7>vi|+q+cAmE>lc4K-baG zM}V$NbJMk{U<>Fvz7;XdaX{DQ_BUM*rMu~RC>705pzAmq=AAqMx*iQ^q3hUCtl&C^ zuFDkRCv;8dn$R_&YeLtAt_fWex+Zi@=$gSBlea0bSR}eA9L8o}KiIL)TS`$p+{;8u|#(b!Bh5o>#C1 zbRFM{80I*j>-q>cUDy7m>srzL1iFr+Vcy9DpzBe83th*CVg=VRbX}(iKcQ!)>pFka zb(6j6x>1~V3+TEz=9{i#_w1x!9J+2&Og2E*(a=YLt{Z#P^}K>DpzHWn#4yJJT{lO# z>ALYZT{nv6C(v~q4f9SO09}vzTj)AA6f3xnq3b3^_z7JTx+Zi@=$g=$wJo=mz$$)ryRPD7@CEyBi1Ks61paIP3SrYUAOt0uG{QQ*RA5TTR_*{ zG2e6@yJsi;;?Q-QVzL3cj)p!0bluvUuICkO0bR$pB8E8*=(;AF)iKY^~} zXqb2M0O)$u-$K{1p;*Cn3|)6A!cXX$&^4iJLf3?@30)JqCUi~cn$R_&YeLtAt_fWe zx+Zi@=$gxlJleL- zmMO_k_L}T9*=w@bWUt9ylf5Q;P4=4XHQ8&j*JQ8BUX#5hdrkJ5>^0eIve#s<$zGGa zCVNfxn(Q^%YqHm5ugPAMy(W82_L}T9*=w@bWUo(@y#}?FUY|N)c(?CeJ%8oGWBZq{ ztabv7{fcuJ_f|XKcJ}VO|NJ-qncH4|Pq7R6qvsyJx~kU&ZoT#LwblJM*So8m%Vu|l ziicH)A0Alke8biAdzbcCE%3OV+s-|9=iY^fAHlDfA8Wqv!(m+1t6gv%hogh3h`taRCpG zAey1CAx&9Q;@8BliC<^%>o|vZR~~)E9K@1Zh!qX3cLHSAyZ;M<*?(pn0ItDhpmQmAa-mlR&hOp*hLEW6T~KnO%R(P zHbHEH*aWc&ViUwBh)ociAT~j4g4hJH31Sn(CWuWCn;@ z{-r;0(+7UzP5=F;KJwg`zvMT6=+o z=Pv%(BcJ@IFZnkgeBqm4z5kP+_{Wd_%6lLG?&p2*$v6IyFZtQ0-}#k)?rT2xz@PZk zxd%V<`_I&GCWuWCI}2i$>EVSnq#M}R8HioxZo)2hO9gAU0kNy&y$T!6haTFQzdVRt zro3#VsH3@$0%DiuI&A2=X5M$cUcv5M;%#I91ppCC3tY=YPXu?b=m#3qPM5St)2 zL2QE91hENX6T~KnO%R(PHbHEH*aWc&ViUwBh)ociAT~j4g4hJH31Sn(CWuWCn;Dv z8xXrb?i;aV5ADof9>lIwUN(Z*(cDJ?v1@lD_PmO1KM1qWis#$pxMGl*TMgg-%Sg4hJH31Sn(CWuWCn;?X-i~5kzw!cBK16QG(b6u?b>lLF{IW8?l@Gjo6KXwcCK$?Q!3T9eZeJ z{_-Gplk&0=#E#}Z3W(jf8?onAYy)D)mm}6W77)8V%8l6Vh8wY41@}`BJC2KaM-KwA zM=LlGJ2n=pxSm1mHYNNCViUwBh)ociAT~j4g4hJH31Sn(CWuWCn;<87xM z#Eu}E1F<9BCyElpCWuWCI}2jBTil4<^^@ZcCTRVHX!zJ+&5y!9@?3|Jc!+=yle!qqq&a)V)yPw?0FU2 zfY|Znh;@zy#2$`vBlfW2M(jbs{S?HG<6_>?gFx)j3J%1Mjm0XiXApZx34emv1hENX z6T~KnO%R(PHbHEH*aWc&ViUwBh)ociAT~j4g4hJH31Sn(CWuWCn;`3>Cq6D!CViUy9g4jcV ztTAV9ICEyTvxanUJGX!C(aR67cGeJVu{#8w81(Qf&RyKQx;k@a=k~p;`&UL%Ghe9o5;dzB4O#4k29=f`^e}y9J`;_s+8s9F8-8GqwAJ)(=eh8@PJuCdMW`6O* zx;gx?zE2xJtO0TSu=RTu9A@)Lcl-rPN$X z&85^_O3kJ8xs;kqskxMzOR2e(nk%WflA0^2xssYIskxGxE2+7Xnk%WflA3F&xt5x1 zskxS#YpJ=Gnro@KmYQp+xt5w6skxDw8>zXGnj5LPk(wK+xsjS1skxDwTdBE~n#aF~ zP!#cRJ%m9L{}w|0L27QL=2mKMrRMQ6?V{+`H9xT0x$W}fm-bhi*Ef79+NU$9n)R-+Sf4#f|^tp3D2^_V-YF zu0k;4;VO)y_YfV%LA;a_>&4-4CC0ITh!W!zSWbyBcn?=%oH>UmF;1Z6ln7J$a3#ih zcZd?>^jl7eupAw(M895X4pCw}2eO2P_F{4I446l}s!();(qeeauua=I)Y4lmW&$tSbSJI7LR@_qeea!AC`{A+dSQdLu+|ReRt_-JdXUs z-vfc*`e4>#OjSJ4l~W`ikmEez`Z(HhisTtLoORsdjVv9L#}za8M)E;9 zj&UyhM&g0MoFe(K9LG=>og>$-?7i;B8+LInvMcak`B$vkrok7&@c-l=4s?ZosVp3V zJy3Q@pN+hGESJcF0gbEgNx8D>wA>`?!oI6h@$b%+*cGW??BPxK8kX*ZQg8;Pu!HbjQk5waz=4P9fWa zTT2>DL3wY}vwa9!X6-h}xY)ilL!s|Yo^AU+Fq5-q+ad0rW-|PuYp3WDWE;knj?=W9 z9WU+BhfRxVBVJJvW%6u?u`AC?pS#^M1O>BaA=CFZm8;x5>UJBqzCadsVy3<>+_o{3 zA>AILc_V9kv4OM;H>hi|=n-UM=Ts(bBr+YlBI}FD<;~hfea>I@MLSqq^+oU2cI4Z& z4e2vvIu}$1Gdq3DQgxfk)q$Uy^=~M5ozEL^xXju)=pk*0zCk87O=U9fQ<=0YvY{#h zb~Eb>>eK#BCVfd{x{i-B1ah5Q4^W@_sHt4c?WBFdL@n&Q3^Kb8K~`#g zqYWUdb+4SrbUmG9UC6i2+AX~Q#qBfRHwHa6jH~d$D%O|SC4BvHGBd8iO)23JXe=l1 z5^d<(B(^Hrh;*gp@N8$_C1kyhd(juN@E1tu&th>h?S|+?_tg4qSLq%}WF>$MYrhii zL;O~%$A)pG{x`8zxCvA;$U=8|b=`%A}1{X2(@Ev>`5z z^(AfSIxe=VhF3@n`-l zylVIuoz{n+3ylL38-&YxV!2c%?{|`w;clz6942(-_CCAV3;1%4n;#7(rnIYi6rJ?} zXv6t^$cB(xj(yQ*%Uv5^7ES5)e=xlBwMDzZuLeE*xIz#+w*mSv#UzQXbUhtqXk*ZL zX6$o#R{e~Ys|q(iT5Pk;yTpxA@!Z?Y25sg1KFYQF-o#dr={h;d;wQmbeVzBUcthT= z20di#CN|K0Y%1F@u5`|b4G{h6m`rTt{AGs}fV?BoqpMx4hD|m$t!BJH=KOxwAo6zQ zs&Iqb+%CvI*>?L4zP9&%74?+5hfD0DF<2^-F`mlodeyhcG|c-K+Rz+|*c6}N-TXi} zWA{i!kN8sB6`Nw!b$-8ZUA%y2;}^i0t&piwA0_o+iFLkq@P0MwA@41*fyQ*HY{R(H z`6f0PI-fU#t(?CcdKcpq1G+4>S3P=o*17Fb&inlVgTsy&$n5?e&-OMJ!mi!brMp+53BvqxaCJ6`XM4Lxf(*f~E9d+^oJSD=zjO>i zHm$Lv?IEjm&WH>i%KBPFN6xPXTd9vvY@mC+R3?2%WHx_L!c(}IE^Gy4Y=}*vh0S^3 zS(lfBtg$`-GUxXp>-2r74T|3G?@JiL=1LLyIbR#1LF-pxt4ed%lMSXdF_rmoRq1#Z z8&qYdd$Y06p$(l|Q#lv^mQ_`#pAmf_vpxXjoZpA6));nrw$^t($*^FV@mG6ai;c4L zt5FYmHnEG2`&8z~Ri*nnu>p>~+L$i1Vf|%U*Dn5rth4VDGAvkDHw-`HKgt@LM-wkV zR@nVLWXQgn`Wo+R;f$PLje5wMnAk=4f2mB`NM&{%YT8XXvP$Qr*tBU~{9EFfn9fxh z2awr463RKh4_T{w>gn0e?(b2~ecLHp?`t7*el^%iW68t@ng>Z`@_rMUt_Na+wu7Ud zzqina^_NKay7;$jdt^uE&q5Y&xkVkjE3pA&*6*WSWxq`Xna%4#*6Q9->cgsGeJxfJ z=U1a18^+a!e0!zyBe9jfbFo!7O!IB*OK3y$G*dYj|CZg*+MFt6o$~>RaCDtieFyA_ zjGcQlEaP6v^*|l${OvdR+QIwPsK+!w4A)R zQ3eOv+L#V))H)8PaxVVGvxD6uRl!Z0IUQuy??VPRU~PaQZq|F0Yi!;YnOx^fy%~60KvqlU-`vVCYe{9&?kGbWz0TFdR=RFzIZPR|Z^uR% zTSa3l$m|{o<(%J#3<0ovwy}9Vlkb`PE=6%?TzpP=A!lq%Vog<_{{Y z;=1olY^7sEY=xAEn;#XH2s>vgTyAaW7G%2qslNDtB=2pKVV7>~htCx@Z;LM?&euZb z{A#e(hJ1Uiaam#)-NU6a>7Ur39*|C(_b;?z{bg0-qm9gc(G63^?vYTgu|5DY=l3BC zH-@9BX>S1;f@dScwo_v|X%|@}>uUjnIKLY8kUl3iP+ykHHjFFv(_$-3tqq(^ZT%%S zTrU2t+5$nM(G4=YM?yL0_aTe-{mg6yncd%`ocm^7fqJmM7Bc5ogROK;OzfgDT`Chj zQkflBU0K_hF0`R>yx0`@ftw#4J~7)l)8T`Kv?1dFCoi4f?{Mp$ymKuVJe;w8hn2{- z0kXpR+TQ!ssE4$X*g$iesmzZnjOej1VuKz?k=S(XGumjJzwCj(*!h8_ME$Sm3z_o) zNH;mZ-vbgc?P6)_?EW4yOfF+TcuQSRrCsb^bPT0>i0vo4An2UiAnGf9Nz2JQ7h4St zNQHS{!m~Etj<32d{>3Mth}5FI#xsz`KgD5X&hI0GqJBp8MSLm!n`9_B?;qZ-^R+ZZKxjOvnc1{U&!Fl=g&gcSswtI^ZSqu8f%Pp z!G_TBNRUmrO(}=ZLe|#;&~$z^>LLA0Y@oTrRJLJU+57?87;H=z+E9Nkw!%)?&JQGW zD!WGtGP_5D%=vxDu#y`)LuU8)C^zVyT5MH%UkgX!{A$!=!?@D5B(ar#|4n4NPKph% z#M+oHw4rPERL;e}H6jwVspwH*>tp704Vm@(kd?aknVzj|UJvCOeZNu;cLQ2qi!7Y; zt5J^)<4V`l#4h@6HkHYGAU42`-^O&I4UJuVV*jYGV z8?xEzkCJz;eNJq!A>ZEWx|@{KwM1;tb%l-Tf~}mt?C@dS&Lfap`aZ-~kU1aF1x1Wq zx(-32lf?tmXnz`~UB7Gdw)lYIeJ#!?TfZ9h*f6d(*M>>i%GCH?*Pq?5UiKf5R6lyGIH# z>jNNjejhSG@5WY;X}f9zeAYGd7YnTOwU9Z#8ui#PuJrvTcF`POBD48}@Kw#mbfFFP zM`EiIDR_BDqDP64#LJMFH#UGtt>4Foj!NgkRG;H9Q4Wx%sSoL5>uYgmrt_;&4|z7R zf$nosnT+vNX2%s)Y@7E+8-Py6{)w$BuswEuR9MEn48f4qt#N)IUr6HtX0)a2IEY!t zV+M*=-?`WnTMO%J8+_{3xiyxHddPd5Y%q^PG9C9~1Dq4FFv zY^0!#T_Dpjsk#C3l0Hu|ka+G{$77-mBqL3^#{1f)@qRVxA#Ef!&^=NrllPm-q+PKA zwn)0SNZyC_mpByT;$O&+@U(gi0C~L3`h5`A$n?8%j>kkfYyeHU*85s~o_2mU*lI(* z9R%Q5Ut$-XGl@)nyx0n)kd5g=8_r+iNRrKaBWT1X)9BH`huXCmqu%;`JlktNQN|iF z`s#mGRv@}JZQ#_M^R-C9TfZ9h*f6ft_K96|-AHA2T;ZIEu64<{viWu#adPo*IEAL~ zO?2ylnM?l?ne+QNGNpUMshs06(ME6cwiuG$*M=>e@vFgB8doGX(7vQH8RLme_Xnd4 zt7Kziy3mI6mpBt*<6k`6+jj{Wq-GopKt$|V18Hi{DdCP=$74cP>)vOyDLjDlwU9Z# z8ugI(me@djaw^-fUTK^>mIEaN@OrLWXhY}6RL;e}cy`J)s~%WoW$aEe=lAg}pvrl> zL1y>&III!>Oe*@M{u05e^RMvwk1X;zPE3*72AqH?74|A5H>TUyGfE^Q%!0KdvSpmDokU zb)+(R=VGfed|;UOIkcgBfvKE}e?!*Wo>PJ>-g|~Rrg2araFljcUxYoTU7+@k$Ak=r zs^|T~)xpl!LgxHx)WeUf>6~U_mks&$=^U=u085GP`A0oM8*0C)oQr?)C84%^q#(07 z(D21UeUI1*=`hWWBr;t~RJOhdW@h`w`&xY1aeg)Gv0+@P4^3>PV?33~`xP4it*|j& zXhZiAQ#lv^whdAWGS5Xf93i(p0Og$Dhb;biq`3_c44M4_%1t={X#+^U^|eTSIlmh9 z*f6d%Z=2Xf^+;s8e-RrXb7Nz=(1!JwE$|J^(`cV@oJ4Ft$vSOgk~zPR`T(4nxfKSW z=%&x&2!d?`%a!%DfaaWEje1BMi48X7+b4gXmXmhHRyZeOW4h3W_D{><^PAZ}BN^e& zss@?*8POLf-JIV?n4)XMRL=33sBh4iM9N{*J73#-zZ&%r+b1^IkZ+%I6iGStA7TS! zfpr}p`+_zG=Pz+A!Oahx39)-55Uv1P&3M61KK!Pf2C6?j|yuKR_EGRp)&PZD{;9J{v}c zjej9?XH_Aq?6=P#vwj~kOxAh3kYW4nWB{v-41fC8`dTDLoL`N4Y#3J>TP1eUHd2`x zR~XGm5zYG&+R$}eY=tu+YQOQF<5_oB6|(7BEr+X+q|cMAuv}0FO3>!@Aj8M3dH;|J za=sQa=U1a1qFZ7E-9M+Y4dcq@4?r`(2=l&#Hq@7itw7D$`GIHMS=Au3J^(W3_aW;et&V=ZgOl)QSC0x9Ve<5>cRYS(o?vWsKe!mL|EE7vY=6FoVFaVA1 zadgxA+74vA^{c^FI@S^!XbvisZ5UU&cNAM8bha@aj-P{Q82dMs!;uKvXQbRV$n19m zkViQ6KX_KdAI+^K_dj`8ZS8`2!Nv>UD|5`A@kKo8L^ zv4QSwQ<)oAc-D>=oC)#!b~t%o4w-qQ8&06weG?`nGDna0=Dvi*i`; z=jHG^@ZzitPE`7w*h+nTVi)amDw8%6nav;I^-lNAC2i=sJ3fnYZZ8E{W#+G6?a)YiLWB=elTbuib43}cf>kIQkW8K6Cn)66y8^)E*AM}_py5~=9 zrF|A#0jIWcO1KV6`=WZ_XqvSFF0Qa+63^m`<6Jk$rt=?DeMmK!a!4uIoGKD-Zf_fG zr8(ckF1q(gWzy$Fru#avK{)?m*5}ZMu0vBf1gy5ta7faI=!S!ay5E>&?ye>z+;9<< ztq&i3OkYsWooDJXByB9-18}#oP}D=lcwz$`f2nN4xYB)&*r3AfH|tPnL&yD8&fTHb zgACR2Cw9S#tue$T!{*G`1^4IJxEu9N-}9zt2OS$CLxiKgXOi{$-D;GDJ(;#Edf*I) z-FqXrba(#Xka}%>1!PDi89k6=v_2lEJ^_*1asldavgr(r=z-54%Ir)scPASzFmMbw zWPY6l4E%}(*t*qI6bhwxc3pFXR8Milui#oi%t(P?{;lRy3Xdak-~HLTR`S`8ps?^4VjDI za4-$KdE4i30icuNi-42492Dwvd^RSfiw7aYZ5yUOWL2zR4OuRmABSxE{wD7n=c8R~Rie-xRLdaeFvqMKxy9`Vh|8afOdhmMg&R zW=P6dTZLqSlev8?o^|JwAw#CnJ_~ls)mOXyGeA?2DDK&r9{X3$U3hfw%8fUiIkR*2 z!W;HhJx0#XUAM39|N0;MxyOI{{cmY*e$k~@e*4qk^R};e;cA2E0o=rY`SSh>o)5iT l-Lt}F^*7#db^qLz{q-f^=mY}L8*li!ulvSV-f`m%{~tF)OSAw0 literal 0 HcmV?d00001 diff --git a/app/src/androidTest/kotlin/app/grapheneos/pdfviewer/PdfViewerTestAccessors.kt b/app/src/androidTest/kotlin/app/grapheneos/pdfviewer/PdfViewerTestAccessors.kt index fcec3409d..babb6e1be 100644 --- a/app/src/androidTest/kotlin/app/grapheneos/pdfviewer/PdfViewerTestAccessors.kt +++ b/app/src/androidTest/kotlin/app/grapheneos/pdfviewer/PdfViewerTestAccessors.kt @@ -43,6 +43,18 @@ var PdfViewer.documentName: String viewModel.setDocumentNameForTest(value) } +var PdfViewer.pageFitMode: Int + get() = viewModel.pageFitMode + set(value) { + viewModel.pageFitMode = value + } + +var PdfViewer.continuousMode: Boolean + get() = viewModel.continuousMode + set(value) { + viewModel.continuousMode = value + } + var PdfViewer.outlineStatus: PdfViewModel.OutlineStatus get() = viewModel.outline.value ?: PdfViewModel.OutlineStatus.NotLoaded set(value) { diff --git a/app/src/androidTest/kotlin/app/grapheneos/pdfviewer/test/PdfViewerBigDocTest.kt b/app/src/androidTest/kotlin/app/grapheneos/pdfviewer/test/PdfViewerBigDocTest.kt new file mode 100644 index 000000000..0f54f0688 --- /dev/null +++ b/app/src/androidTest/kotlin/app/grapheneos/pdfviewer/test/PdfViewerBigDocTest.kt @@ -0,0 +1,240 @@ +package app.grapheneos.pdfviewer.test + +import androidx.test.core.app.ActivityScenario +import androidx.test.ext.junit.runners.AndroidJUnit4 +import app.grapheneos.pdfviewer.PdfViewer +import app.grapheneos.pdfviewer.RetryRules +import app.grapheneos.pdfviewer.continuousMode +import app.grapheneos.pdfviewer.currentPage +import app.grapheneos.pdfviewer.refreshMenuSync +import app.grapheneos.pdfviewer.totalPages +import app.grapheneos.pdfviewer.util.PdfViewerLauncher +import app.grapheneos.pdfviewer.util.PdfViewerRobot +import app.grapheneos.pdfviewer.util.PdfViewerTestUtils +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +/** + * In-depth exercise of the continuous-scroll pipeline against a generated + * 73-page PDF. The tests cover lazy rendering, document order, late-page jumps, + * pinch focal behavior, rotation, continuous/single toggle, and free-zoom reset. + */ +@RunWith(AndroidJUnit4::class) +class PdfViewerBigDocTest { + + @get:Rule + val retryRules = RetryRules() + + private val robot = PdfViewerRobot() + + @Test + fun loadsAndLaysOutAllPagesInOrder() { + PdfViewerLauncher.launchWithTestAsset("test-large.pdf").use { scenario -> + PdfViewerTestUtils.waitForDocumentFullyLoaded(scenario) + PdfViewerTestUtils.waitForCanvasRendered(scenario) + + val total = scenario.onActivityAndReturn { it.totalPages } + assertTrue("big doc should have many pages", total > 20) + + // buildPages appends wrappers asynchronously after onLoaded; wait until + // all of them exist before asserting order. + PdfViewerTestUtils.pollUntil( + timeout = 20_000, + description = { "wrappers not all created (expected $total) breakdown=" + wrapperBreakdown(scenario) } + ) { wrapperCount(scenario) == total } + + // Wrappers must appear in document order regardless of async resolution. + // evaluateJavascript returns strings JSON-quoted, so strip the quotes. + val order = eval(scenario, + "Array.from(document.querySelectorAll('.page-wrapper')).map(w => w.dataset.page).join(',')").trim('"') + val nums = order.split(",").mapNotNull { it.trim().toIntOrNull() } + assertEquals("expected $total wrappers", total, nums.size) + assertEquals("wrappers not in document order", + (1..total).toList(), nums) + } + } + + @Test + fun continuousScrollAdvancesCurrentPage() { + PdfViewerLauncher.launchWithTestAsset("test-large.pdf").use { scenario -> + PdfViewerTestUtils.waitForDocumentFullyLoaded(scenario) + PdfViewerTestUtils.waitForCanvasRendered(scenario) + + assertEquals("starts on page 1", 1, scenario.onActivityAndReturn { it.currentPage }) + + // Scroll down several viewports; the reported page must advance. + var lastSeen = 1 + repeat(8) { + robot.scrollDown(scenario) + PdfViewerTestUtils.pollUntil( + timeout = 10_000, + description = { "page did not advance after scroll (last=$lastSeen)" } + ) { + val p = scenario.onActivityAndReturn { it.currentPage } + p >= lastSeen + 1 + } + lastSeen = scenario.onActivityAndReturn { it.currentPage } + } + assertTrue("should have scrolled well into the document", lastSeen > 4) + } + } + + @Test + fun jumpToLatePageRendersInOrder() { + PdfViewerLauncher.launchWithTestAsset("test-large.pdf").use { scenario -> + PdfViewerTestUtils.waitForDocumentFullyLoaded(scenario) + PdfViewerTestUtils.waitForCanvasRendered(scenario) + val total = scenario.onActivityAndReturn { it.totalPages } + val target = total - 2 + + robot.scrollToPageJs(scenario, target) + PdfViewerTestUtils.waitForScrollToPage(scenario, target) + + // The target page must actually render (canvas sized) after the jump. + PdfViewerTestUtils.pollUntil( + timeout = 15_000, + description = { "late page $target did not render after jump" } + ) { + robot.getCanvasCssHeight(scenario) > 0 + } + assertEquals(target, scenario.onActivityAndReturn { it.currentPage }) + } + } + + @Test + fun pinchZoomSwitchesToFreeAndEnlarges() { + PdfViewerLauncher.launchWithTestAsset("test-large.pdf").use { scenario -> + PdfViewerTestUtils.waitForDocumentFullyLoaded(scenario) + PdfViewerTestUtils.waitForCanvasRendered(scenario) + val before = robot.getCanvasCssHeight(scenario) + assertTrue("baseline canvas height > 0", before > 0) + + robot.performPinchZoomIn(scenario) + + PdfViewerTestUtils.pollUntil( + timeout = 10_000, + description = { "pinch did not switch to free zoom" } + ) { robot.getPageFitMode(scenario) == 0 } + + PdfViewerTestUtils.pollUntil( + timeout = 10_000, + description = { "canvas did not enlarge after pinch" } + ) { robot.getCanvasCssHeight(scenario) > before + 10 } + } + } + + @Test + fun rotationAppliesAndReRenders() { + PdfViewerLauncher.launchWithTestAsset("test-large.pdf").use { scenario -> + PdfViewerTestUtils.waitForDocumentFullyLoaded(scenario) + PdfViewerTestUtils.waitForCanvasRendered(scenario) + val before = robot.getCanvasCssHeight(scenario) + + robot.clickRotateClockwise() + PdfViewerTestUtils.pollUntil( + timeout = 10_000, + description = { "document did not rotate to 90" } + ) { robot.getDocumentRotationDegrees(scenario) == 90 } + + // Page must re-render at the new orientation (height should change). + PdfViewerTestUtils.pollUntil( + timeout = 10_000, + description = { "page did not re-render after rotation" } + ) { + val h = robot.getCanvasCssHeight(scenario) + h > 0 && h != before + } + } + } + + @Test + fun continuousToggleHidesAndRestoresPages() { + PdfViewerLauncher.launchWithTestAsset("test-large.pdf").use { scenario -> + PdfViewerTestUtils.waitForDocumentFullyLoaded(scenario) + PdfViewerTestUtils.waitForCanvasRendered(scenario) + val total = scenario.onActivityAndReturn { it.totalPages } + + PdfViewerTestUtils.pollUntil( + timeout = 5_000, + description = { "all pages visible initially" } + ) { displayedCount(scenario) == total } + + scenario.onActivity { it.refreshMenuSync() } + robot.clickContinuousScroll() + PdfViewerTestUtils.pollUntil( + timeout = 5_000, + description = { "single-page mode should show one page" } + ) { displayedCount(scenario) == 1 } + + scenario.onActivity { it.refreshMenuSync() } + robot.clickContinuousScroll() + PdfViewerTestUtils.pollUntil( + timeout = 5_000, + description = { "all pages should reappear" } + ) { displayedCount(scenario) == total } + } + } + + @Test + fun freeZoomReDerivesAfterFitModeCycle() { + PdfViewerLauncher.launchWithTestAsset("test-large.pdf").use { scenario -> + PdfViewerTestUtils.waitForDocumentFullyLoaded(scenario) + PdfViewerTestUtils.waitForCanvasRendered(scenario) + + // pinch -> free zoom + robot.performPinchZoomIn(scenario) + PdfViewerTestUtils.pollUntil(timeout = 8_000, description = { "not free" }) { + robot.getPageFitMode(scenario) == 0 + } + + // fit width -> free again (review P2: must not reuse stale ratio / jump to MIN) + scenario.onActivity { it.refreshMenuSync() } + robot.clickFitWidth() + PdfViewerTestUtils.pollUntil(timeout = 8_000, description = { "not fit-width" }) { + robot.getPageFitMode(scenario) == 2 + } + scenario.onActivity { it.refreshMenuSync() } + robot.clickFitFree() + PdfViewerTestUtils.pollUntil(timeout = 8_000, description = { "not free again" }) { + robot.getPageFitMode(scenario) == 0 + } + + // Re-derived free zoom must be well above MIN_ZOOM_RATIO (0.2), i.e. not a + // jump-to-min caused by a stale/zero ratio disagreement. + val zoom = eval(scenario, "channel.getZoomRatio()").toFloatOrNull() ?: 0f + assertTrue("free zoom $zoom should re-derive above MIN (0.2)", zoom > 0.2f) + PdfViewerTestUtils.waitForCanvasRendered(scenario) + } + } + + private fun displayedCount(scenario: ActivityScenario): Int { + val r = eval(scenario, + "Array.from(document.querySelectorAll('.page-wrapper'))" + + ".filter(w => getComputedStyle(w).display !== 'none').length") + return r.toIntOrNull() ?: 0 + } + + private fun wrapperCount(scenario: ActivityScenario): Int { + val r = eval(scenario, "document.querySelectorAll('.page-wrapper').length") + return r.toIntOrNull() ?: 0 + } + + private fun wrapperBreakdown(scenario: ActivityScenario): String = + eval(scenario, "JSON.stringify({" + + "all:document.querySelectorAll('.page-wrapper').length," + + "pagesChildren:document.getElementById('pages').children.length," + + "containerChildren:document.getElementById('container').children.length})") + + private fun eval(scenario: ActivityScenario, js: String): String = + PdfViewerTestUtils.evaluateJs(scenario, js) ?: "" + + private fun ActivityScenario.onActivityAndReturn(block: (PdfViewer) -> T): T { + var v: T? = null + onActivity { v = block(it) } + @Suppress("UNCHECKED_CAST") + return v as T + } +} diff --git a/app/src/androidTest/kotlin/app/grapheneos/pdfviewer/test/PdfViewerContinuousModeTest.kt b/app/src/androidTest/kotlin/app/grapheneos/pdfviewer/test/PdfViewerContinuousModeTest.kt new file mode 100644 index 000000000..415b6f9e8 --- /dev/null +++ b/app/src/androidTest/kotlin/app/grapheneos/pdfviewer/test/PdfViewerContinuousModeTest.kt @@ -0,0 +1,104 @@ +package app.grapheneos.pdfviewer.test + +import androidx.test.core.app.ActivityScenario +import androidx.test.ext.junit.runners.AndroidJUnit4 +import app.grapheneos.pdfviewer.PdfViewer +import app.grapheneos.pdfviewer.RetryRules +import app.grapheneos.pdfviewer.continuousMode +import app.grapheneos.pdfviewer.currentPage +import app.grapheneos.pdfviewer.refreshMenuSync +import app.grapheneos.pdfviewer.util.PdfViewerLauncher +import app.grapheneos.pdfviewer.util.PdfViewerRobot +import app.grapheneos.pdfviewer.util.PdfViewerTestUtils +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +/** + * Continuous vs single-page view toggle. + */ +@RunWith(AndroidJUnit4::class) +class PdfViewerContinuousModeTest { + + @get:Rule + val retryRules = RetryRules() + + private val robot = PdfViewerRobot() + + @Test + fun toggle_hidesOtherPagesAndRestoresOnReEnable() { + PdfViewerLauncher.launchWithTestAsset("test-multipage.pdf").use { scenario -> + PdfViewerTestUtils.waitForDocumentFullyLoaded(scenario) + PdfViewerTestUtils.waitForCanvasRendered(scenario) + + // Default: continuous scrolling on, so all pages are laid out. + scenario.onActivity { assertTrue(it.continuousMode) } + PdfViewerTestUtils.pollUntil( + timeout = 5_000, + description = { "all pages visible in continuous mode" } + ) { displayedCount(scenario) == 4 } + + // Switch to single-page mode via the menu. + scenario.onActivity { it.refreshMenuSync() } + robot.clickContinuousScroll() + + PdfViewerTestUtils.pollUntil( + timeout = 5_000, + description = { "single-page mode shows only the current page" } + ) { + var mode = true + scenario.onActivity { mode = it.continuousMode } + !mode && displayedCount(scenario) == 1 + } + + // Navigation in single-page mode keeps exactly one page shown. + PdfViewerTestUtils.evaluateJs(scenario, "globalThis.scrollToPage(3)") + PdfViewerTestUtils.pollUntil( + timeout = 5_000, + description = { "page 3 visible alone in single-page mode" } + ) { + var page = 0 + scenario.onActivity { page = it.currentPage } + page == 3 && displayedCount(scenario) == 1 + } + + // Re-enable continuous mode: all pages come back. + scenario.onActivity { it.refreshMenuSync() } + robot.clickContinuousScroll() + PdfViewerTestUtils.pollUntil( + timeout = 5_000, + description = { "all pages visible again after re-enabling" } + ) { + var mode = false + scenario.onActivity { mode = it.continuousMode } + mode && displayedCount(scenario) == 4 + } + } + } + + @Test + fun singlePageMode_survivesRecreation() { + PdfViewerLauncher.launchWithTestAsset("test-multipage.pdf").use { scenario -> + PdfViewerTestUtils.waitForDocumentFullyLoaded(scenario) + PdfViewerTestUtils.waitForCanvasRendered(scenario) + + scenario.onActivity { it.continuousMode = false } + PdfViewerTestUtils.evaluateJs(scenario, "setContinuousMode()") + + scenario.recreate() + + scenario.onActivity { assertFalse(it.continuousMode) } + } + } + + private fun displayedCount(scenario: ActivityScenario): Int { + val result = PdfViewerTestUtils.evaluateJs( + scenario, + "Array.from(document.querySelectorAll('.page-wrapper'))" + + ".filter(w => getComputedStyle(w).display !== 'none').length" + ) + return result.toIntOrNull() ?: 0 + } +} diff --git a/app/src/androidTest/kotlin/app/grapheneos/pdfviewer/test/PdfViewerContinuousScrollTest.kt b/app/src/androidTest/kotlin/app/grapheneos/pdfviewer/test/PdfViewerContinuousScrollTest.kt new file mode 100644 index 000000000..e87cf2365 --- /dev/null +++ b/app/src/androidTest/kotlin/app/grapheneos/pdfviewer/test/PdfViewerContinuousScrollTest.kt @@ -0,0 +1,153 @@ +package app.grapheneos.pdfviewer.test + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import app.grapheneos.pdfviewer.RetryRules +import app.grapheneos.pdfviewer.pageFitMode +import app.grapheneos.pdfviewer.refreshMenuSync +import app.grapheneos.pdfviewer.util.PdfViewerLauncher +import app.grapheneos.pdfviewer.util.PdfViewerRobot +import app.grapheneos.pdfviewer.util.PdfViewerTestUtils +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +/** + * Page fitting mode and zoom integration tests. + */ +@RunWith(AndroidJUnit4::class) +class PdfViewerPageFitModeTest { + + @get:Rule + val retryRules = RetryRules() + + private val robot = PdfViewerRobot() + + @Test + fun fitWidthMode_isDefaultForNewDocument() { + PdfViewerLauncher.launchWithTestAsset("test-simple.pdf").use { scenario -> + PdfViewerTestUtils.waitForDocumentFullyLoaded(scenario) + + scenario.onActivity { + assertEquals(2, it.pageFitMode) + } + } + } + + @Test + fun fitPageMode_canvasFitsWithinViewport() { + PdfViewerLauncher.launchWithTestAsset("test-simple.pdf").use { scenario -> + PdfViewerTestUtils.waitForDocumentFullyLoaded(scenario) + PdfViewerTestUtils.waitForCanvasRendered(scenario) + + scenario.onActivity { it.refreshMenuSync() } + robot.clickFitPage() + + PdfViewerTestUtils.pollUntil( + timeout = 10_000, + description = { "Canvas should re-render in fit-page mode" } + ) { + val cssWidth = robot.getCanvasCssWidth(scenario) + val cssHeight = robot.getCanvasCssHeight(scenario) + val viewportWidth = robot.getViewportWidth(scenario) + val viewportHeight = robot.getViewportHeight(scenario) + cssWidth > 0 && cssHeight > 0 && + cssWidth <= viewportWidth + 2 && cssHeight <= viewportHeight + 2 + } + + val cssWidth = robot.getCanvasCssWidth(scenario) + val cssHeight = robot.getCanvasCssHeight(scenario) + val viewportWidth = robot.getViewportWidth(scenario) + val viewportHeight = robot.getViewportHeight(scenario) + + assertTrue( + "Canvas width ($cssWidth) should fit viewport ($viewportWidth)", + cssWidth <= viewportWidth + 2 + ) + assertTrue( + "Canvas height ($cssHeight) should fit viewport ($viewportHeight)", + cssHeight <= viewportHeight + 2 + ) + } + } + + @Test + fun fitWidthMode_canvasFillsViewportWidth() { + PdfViewerLauncher.launchWithTestAsset("test-simple.pdf").use { scenario -> + PdfViewerTestUtils.waitForDocumentFullyLoaded(scenario) + PdfViewerTestUtils.waitForCanvasRendered(scenario) + + val cssWidth = robot.getCanvasCssWidth(scenario) + val viewportWidth = robot.getViewportWidth(scenario) + + assertTrue( + "Canvas width ($cssWidth) should fill viewport width ($viewportWidth)", + cssWidth >= viewportWidth - 2 + ) + } + } + + @Test + fun fitModeMenuItems_areVisibleAfterLoad() { + PdfViewerLauncher.launchWithTestAsset("test-simple.pdf").use { scenario -> + PdfViewerTestUtils.waitForDocumentFullyLoaded(scenario) + + scenario.onActivity { it.refreshMenuSync() } + + robot.assertMenuItemVisible( + scenario, PdfViewerRobot.AppMenuItem.FitFree, expected = true + ) + robot.assertMenuItemVisible( + scenario, PdfViewerRobot.AppMenuItem.FitPage, expected = true + ) + robot.assertMenuItemVisible( + scenario, PdfViewerRobot.AppMenuItem.FitWidth, expected = true + ) + } + } + + @Test + fun pageFitMode_survivesRecreation() { + PdfViewerLauncher.launchWithTestAsset("test-simple.pdf").use { scenario -> + PdfViewerTestUtils.waitForDocumentFullyLoaded(scenario) + + scenario.onActivity { + it.pageFitMode = 1 // fit page + } + + scenario.recreate() + + scenario.onActivity { + assertEquals(1, it.pageFitMode) + } + } + } + + @Test + fun pinchZoom_switchesToFreeMode() { + PdfViewerLauncher.launchWithTestAsset("test-simple.pdf").use { scenario -> + PdfViewerTestUtils.waitForDocumentFullyLoaded(scenario) + PdfViewerTestUtils.waitForCanvasRendered(scenario) + + assertEquals( + "Should start in fit-width mode", + 2, robot.getPageFitMode(scenario) + ) + + robot.performPinchZoomIn(scenario) + + PdfViewerTestUtils.pollUntil( + timeout = 5_000, + description = { "Should switch to free zoom mode after pinch" } + ) { + robot.getPageFitMode(scenario) == 0 + } + + assertEquals( + "Pinch zoom should switch to free mode", + 0, robot.getPageFitMode(scenario) + ) + } + } +} diff --git a/app/src/androidTest/kotlin/app/grapheneos/pdfviewer/test/PdfViewerEdgeToEdgeTest.kt b/app/src/androidTest/kotlin/app/grapheneos/pdfviewer/test/PdfViewerEdgeToEdgeTest.kt index 43f5468f6..462d72bb5 100644 --- a/app/src/androidTest/kotlin/app/grapheneos/pdfviewer/test/PdfViewerEdgeToEdgeTest.kt +++ b/app/src/androidTest/kotlin/app/grapheneos/pdfviewer/test/PdfViewerEdgeToEdgeTest.kt @@ -162,8 +162,8 @@ class PdfViewerEdgeToEdgeTest { scenario, """ (function() { - const canvas = document.getElementById('content'); - const value = parseFloat(getComputedStyle(canvas)['$propertyName']) || 0; + const el = document.getElementById('container'); + const value = parseFloat(getComputedStyle(el)['$propertyName']) || 0; return value * globalThis.devicePixelRatio; })() """.trimIndent() diff --git a/app/src/androidTest/kotlin/app/grapheneos/pdfviewer/test/PdfViewerLandscapeTest.kt b/app/src/androidTest/kotlin/app/grapheneos/pdfviewer/test/PdfViewerLandscapeTest.kt index 352d8ed71..e0e8e0850 100644 --- a/app/src/androidTest/kotlin/app/grapheneos/pdfviewer/test/PdfViewerLandscapeTest.kt +++ b/app/src/androidTest/kotlin/app/grapheneos/pdfviewer/test/PdfViewerLandscapeTest.kt @@ -47,18 +47,16 @@ class PdfViewerLandscapeTest { val cssWidth = robot.getCanvasCssWidth(scenario) val cssHeight = robot.getCanvasCssHeight(scenario) val viewportWidth = robot.getViewportWidth(scenario) - val viewportHeight = robot.getViewportHeight(scenario) assertTrue("Canvas CSS width ($cssWidth) should be > 0", cssWidth > 0) assertTrue("Canvas CSS height ($cssHeight) should be > 0", cssHeight > 0) + // Default fit mode is fit-width: the canvas fills the available width and must + // not overflow horizontally. Height is unconstrained (a portrait page in + // landscape overflows vertically), so only width is bounded here. assertTrue( - "Canvas CSS width ($cssWidth) should fit viewport ($viewportWidth)", + "Canvas CSS width ($cssWidth) should not overflow viewport ($viewportWidth)", cssWidth <= viewportWidth + 2 ) - assertTrue( - "Canvas CSS height ($cssHeight) should fit viewport ($viewportHeight)", - cssHeight <= viewportHeight + 2 - ) } } diff --git a/app/src/androidTest/kotlin/app/grapheneos/pdfviewer/test/PdfViewerMenuStateTest.kt b/app/src/androidTest/kotlin/app/grapheneos/pdfviewer/test/PdfViewerMenuStateTest.kt index 0ed968ae7..599c13008 100644 --- a/app/src/androidTest/kotlin/app/grapheneos/pdfviewer/test/PdfViewerMenuStateTest.kt +++ b/app/src/androidTest/kotlin/app/grapheneos/pdfviewer/test/PdfViewerMenuStateTest.kt @@ -30,8 +30,12 @@ class PdfViewerMenuStateTest { @Test fun preLoadState_navigationItemsNotShown() { - PdfViewerLauncher.launchDefault().use { + PdfViewerLauncher.launchDefault().use { scenario -> robot.assertNavigationNotShown() + scenario.onActivity { it.refreshMenuSync() } + robot.assertMenuItemVisible( + scenario, PdfViewerRobot.AppMenuItem.PageFit, expected = false + ) } } @@ -107,6 +111,7 @@ class PdfViewerMenuStateTest { robot.assertMenuItemEnabled(scenario, PdfViewerRobot.AppMenuItem.Share, expected = false) robot.assertMenuItemEnabled(scenario, PdfViewerRobot.AppMenuItem.SaveAs, expected = false) robot.assertMenuItemEnabled(scenario, PdfViewerRobot.AppMenuItem.JumpToPage, expected = false) + robot.assertMenuItemEnabled(scenario, PdfViewerRobot.AppMenuItem.PageFit, expected = false) } } diff --git a/app/src/androidTest/kotlin/app/grapheneos/pdfviewer/test/PdfViewerNavigationTest.kt b/app/src/androidTest/kotlin/app/grapheneos/pdfviewer/test/PdfViewerNavigationTest.kt index 928a8c497..98a0cb4d0 100644 --- a/app/src/androidTest/kotlin/app/grapheneos/pdfviewer/test/PdfViewerNavigationTest.kt +++ b/app/src/androidTest/kotlin/app/grapheneos/pdfviewer/test/PdfViewerNavigationTest.kt @@ -74,6 +74,24 @@ class PdfViewerNavigationTest { } } + @Test + fun horizontalFlingAtPageEdge_navigatesToNextPage() { + PdfViewerLauncher.launchWithTestAsset("test-multipage.pdf").use { scenario -> + PdfViewerTestUtils.waitForDocumentFullyLoaded(scenario) + PdfViewerTestUtils.waitForCanvasRendered(scenario) + + robot.flingToNextPage() + + PdfViewerTestUtils.pollUntil( + description = { "Horizontal fling did not navigate to page 2" } + ) { + var page = 0 + scenario.onActivity { page = it.currentPage } + page == 2 + } + } + } + @Test fun tapNext_updatesMenuState() { PdfViewerLauncher.launchWithTestAsset("test-multipage.pdf").use { scenario -> diff --git a/app/src/androidTest/kotlin/app/grapheneos/pdfviewer/util/PdfViewerRobot.kt b/app/src/androidTest/kotlin/app/grapheneos/pdfviewer/util/PdfViewerRobot.kt index a81782b02..bd059bf39 100644 --- a/app/src/androidTest/kotlin/app/grapheneos/pdfviewer/util/PdfViewerRobot.kt +++ b/app/src/androidTest/kotlin/app/grapheneos/pdfviewer/util/PdfViewerRobot.kt @@ -18,6 +18,7 @@ import androidx.test.espresso.ViewAction import androidx.test.espresso.action.ViewActions.clearText import androidx.test.espresso.action.ViewActions.click import androidx.test.espresso.action.ViewActions.closeSoftKeyboard +import androidx.test.espresso.action.ViewActions.swipeLeft import androidx.test.espresso.action.ViewActions.typeText import androidx.test.espresso.assertion.ViewAssertions.doesNotExist import androidx.test.espresso.assertion.ViewAssertions.matches @@ -82,6 +83,7 @@ class PdfViewerRobot { First(R.id.action_first, R.string.action_first), Last(R.id.action_last, R.string.action_last), JumpToPage(R.id.action_jump_to_page, R.string.action_jump_to_page), + PageFit(R.id.action_page_fit_group, R.string.action_page_fit), RotateClockwise( R.id.action_rotate_clockwise, R.string.action_rotate_clockwise ), @@ -95,7 +97,11 @@ class PdfViewerRobot { ViewDocumentProperties( R.id.action_view_document_properties, R.string.action_view_document_properties - ) + ), + FitFree(R.id.action_fit_free, R.string.action_fit_free), + FitPage(R.id.action_fit_page, R.string.action_fit_page), + FitWidth(R.id.action_fit_width, R.string.action_fit_width), + ContinuousScroll(R.id.action_continuous_scroll, R.string.action_continuous_scroll) } enum class SnackbarMessage(@StringRes internal val stringRes: Int) { @@ -217,9 +223,26 @@ class PdfViewerRobot { fun tapWebView() { onView(withId(R.id.webview)).perform(click()) } + fun flingToNextPage() { + onView(withId(R.id.webview)).perform(swipeLeft()) + } fun clickRotateClockwise() = click(AppMenuItem.RotateClockwise) fun clickRotateCounterclockwise() = click(AppMenuItem.RotateCounterclockwise) + fun clickFitFree() = clickInSubmenu(R.string.action_fit_free) + fun clickFitPage() = clickInSubmenu(R.string.action_fit_page) + fun clickFitWidth() = clickInSubmenu(R.string.action_fit_width) + + fun clickContinuousScroll() = click(AppMenuItem.ContinuousScroll) + + /** Fit items live inside the "Page fitting" submenu, so expand it first. */ + private fun clickInSubmenu(@StringRes childTitleRes: Int) { + Espresso.openActionBarOverflowOrOptionsMenu( + ApplicationProvider.getApplicationContext() + ) + onView(withText(R.string.action_page_fit)).perform(click()) + onView(withText(childTitleRes)).perform(click()) + } /** * The broad catch is intentional to catch: 1. The view doesn't exist; @@ -371,8 +394,8 @@ class PdfViewerRobot { fun assertCanvasRendered(scenario: ActivityScenario) { val result = PdfViewerTestUtils.evaluateJs(scenario, - "parseInt(document.getElementById('content').style.width) > 0 " + - "&& parseInt(document.getElementById('content').style.height) > 0" + "parseInt(globalThis.currentPageCanvas().style.width) > 0 " + + "&& parseInt(globalThis.currentPageCanvas().style.height) > 0" ) assertTrue("Canvas should have non-zero CSS dimensions after rendering", result == "true") } @@ -388,28 +411,28 @@ class PdfViewerRobot { fun getCanvasWidth(scenario: ActivityScenario): Int { val result = PdfViewerTestUtils.evaluateJs(scenario, - "document.getElementById('content').width" + "globalThis.currentPageCanvas().width" ) return result.toInt() } fun getCanvasHeight(scenario: ActivityScenario): Int { val result = PdfViewerTestUtils.evaluateJs(scenario, - "document.getElementById('content').height" + "globalThis.currentPageCanvas().height" ) return result.toInt() } fun getCanvasCssWidth(scenario: ActivityScenario): Int { val result = PdfViewerTestUtils.evaluateJs(scenario, - "parseInt(document.getElementById('content').style.width) || 0" + "parseInt(globalThis.currentPageCanvas().style.width) || 0" ) return result.toInt() } fun getCanvasCssHeight(scenario: ActivityScenario): Int { val result = PdfViewerTestUtils.evaluateJs(scenario, - "parseInt(document.getElementById('content').style.height) || 0" + "parseInt(globalThis.currentPageCanvas().style.height) || 0" ) return result.toInt() } @@ -615,14 +638,38 @@ class PdfViewerRobot { return result.toFloat() } + fun getPageFitMode(scenario: ActivityScenario): Int { + val result = PdfViewerTestUtils.evaluateJs(scenario, "globalThis.getPageFitMode()") + return result.toInt() + } + + fun getNumberOfRenderedPages(scenario: ActivityScenario): Int { + val result = PdfViewerTestUtils.evaluateJs(scenario, + "document.querySelectorAll('.page-wrapper').length" + ) + return result.toIntOrNull() ?: 0 + } + + fun scrollDown(scenario: ActivityScenario) { + PdfViewerTestUtils.evaluateJs(scenario, + "window.scrollBy(0, window.innerHeight * 0.8)" + ) + } + + fun scrollToPageJs(scenario: ActivityScenario, page: Int) { + PdfViewerTestUtils.evaluateJs(scenario, + "globalThis.scrollToPage($page)" + ) + } + // Text layer alignment fun assertTextLayerAligned(scenario: ActivityScenario) { val result = PdfViewerTestUtils.evaluateJs(scenario, """ (function() { - var text = document.getElementById('text'); + var text = globalThis.currentPageTextLayer(); var container = document.getElementById('container'); - var canvas = document.getElementById('content'); + var canvas = globalThis.currentPageCanvas(); if (!text || !container || !canvas) return 'missing_elements'; if (text.hidden) return 'text_hidden'; var scaleFactor = container.style.getPropertyValue('--scale-factor'); diff --git a/app/src/androidTest/kotlin/app/grapheneos/pdfviewer/util/PdfViewerTestUtils.kt b/app/src/androidTest/kotlin/app/grapheneos/pdfviewer/util/PdfViewerTestUtils.kt index f36e1ff3f..e5d8e2f15 100644 --- a/app/src/androidTest/kotlin/app/grapheneos/pdfviewer/util/PdfViewerTestUtils.kt +++ b/app/src/androidTest/kotlin/app/grapheneos/pdfviewer/util/PdfViewerTestUtils.kt @@ -14,6 +14,7 @@ import androidx.test.platform.app.InstrumentationRegistry import androidx.test.uiautomator.UiDevice import app.grapheneos.pdfviewer.PdfViewer import app.grapheneos.pdfviewer.R +import app.grapheneos.pdfviewer.currentPage import app.grapheneos.pdfviewer.documentProperties import app.grapheneos.pdfviewer.outlineStatus import app.grapheneos.pdfviewer.totalPages @@ -147,10 +148,10 @@ object PdfViewerTestUtils { ) { try { val result = evaluateJs(scenario, - "parseInt(document.getElementById('content').style.width) > 0 " + - "&& parseInt(document.getElementById('content').style.height) > 0 " + + "parseInt(globalThis.currentPageCanvas().style.width) > 0 " + + "&& parseInt(globalThis.currentPageCanvas().style.height) > 0 " + "&& parseFloat(document.getElementById('container').style.getPropertyValue('--scale-factor')) > 0 " + - "&& !document.getElementById('text').hidden" + "&& !globalThis.currentPageTextLayer().hidden" ) Log.d("WAIT", " canvas check result=$result, elapsed=${System.currentTimeMillis() - start}ms") result == "true" @@ -176,7 +177,7 @@ object PdfViewerTestUtils { ) { try { val result = evaluateJs(scenario, - "document.getElementById('text').textContent" + "globalThis.currentPageTextLayer().textContent" ) Log.d("WAIT", " text layer result=${result.take(80)}, elapsed=${System.currentTimeMillis() - start}ms") result.contains(expected) @@ -188,7 +189,7 @@ object PdfViewerTestUtils { Log.d("WAIT", "assertTextLayerContent: done in ${System.currentTimeMillis() - start}ms") } catch (_: AssertionError) { val actual = try { - evaluateJs(scenario, "document.getElementById('text').textContent") + evaluateJs(scenario, "globalThis.currentPageTextLayer().textContent") } catch (e: Throwable) { "JS evaluation failed: ${e.message}" } @@ -265,8 +266,8 @@ object PdfViewerTestUtils { "within ${timeout}ms" } ) { - val w = evaluateJs(scenario, "document.getElementById('content').width").toIntOrNull() - val h = evaluateJs(scenario, "document.getElementById('content').height").toIntOrNull() + val w = evaluateJs(scenario, "globalThis.currentPageCanvas().width").toIntOrNull() + val h = evaluateJs(scenario, "globalThis.currentPageCanvas().height").toIntOrNull() w != null && h != null && (w != previousWidth || h != previousHeight) } } @@ -286,11 +287,11 @@ object PdfViewerTestUtils { ) { val w = evaluateJs( scenario, - "parseInt(document.getElementById('content').style.width) || 0" + "parseInt(globalThis.currentPageCanvas().style.width) || 0" ).toIntOrNull() val h = evaluateJs( scenario, - "parseInt(document.getElementById('content').style.height) || 0" + "parseInt(globalThis.currentPageCanvas().style.height) || 0" ).toIntOrNull() w != null && h != null && (w != previousWidth || h != previousHeight) } @@ -379,7 +380,7 @@ object PdfViewerTestUtils { scenario, """ (function() { var range = document.createRange(); - range.selectNodeContents(document.getElementById('text')); + range.selectNodeContents(globalThis.currentPageTextLayer()); var sel = window.getSelection(); sel.removeAllRanges(); sel.addRange(range); @@ -404,4 +405,33 @@ object PdfViewerTestUtils { actual == expectedVisible } } + + fun waitForScrollToPage( + scenario: ActivityScenario, + expectedPage: Int, + timeout: Long = 10_000 + ) { + pollUntil( + timeout = timeout, + description = { "Page did not change to $expectedPage after scroll" } + ) { + var page = 0 + scenario.onActivity { page = it.currentPage } + page == expectedPage + } + } + + fun waitForPageWrappersCreated( + scenario: ActivityScenario, + expectedCount: Int, + timeout: Long = 10_000 + ) { + pollUntil( + timeout = timeout, + description = { "Page wrappers not created (expected $expectedCount)" } + ) { + val robot = PdfViewerRobot() + robot.getNumberOfRenderedPages(scenario) == expectedCount + } + } } diff --git a/app/src/main/java/app/grapheneos/pdfviewer/PdfViewer.java b/app/src/main/java/app/grapheneos/pdfviewer/PdfViewer.java index 42a35d553..06f5d73d0 100644 --- a/app/src/main/java/app/grapheneos/pdfviewer/PdfViewer.java +++ b/app/src/main/java/app/grapheneos/pdfviewer/PdfViewer.java @@ -69,8 +69,6 @@ public class PdfViewer extends AppCompatActivity { "frame-ancestors 'none'; " + "base-uri 'none'"; - // Workers need a separate set of CSP. - // MDN reference: https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Content-Security-Policy#csp_in_workers private static final String WORKER_CONTENT_SECURITY_POLICY = "default-src 'none'; " + "script-src 'self' 'wasm-unsafe-eval'; " + @@ -117,6 +115,9 @@ public class PdfViewer extends AppCompatActivity { private volatile float zoomFocusX = 0f; private volatile float zoomFocusY = 0f; + private boolean zoomRenderInFlight; + private boolean zoomRenderPending; + private boolean zoomRenderEndPending; private int swipeThreshold; private int swipeVelocityThreshold; private volatile float insetLeft = 0f; @@ -181,6 +182,15 @@ public int getPage() { return viewModel.getPage(); } + @JavascriptInterface + public void setCurrentPage(final int page) { + viewModel.setPage(page); + runOnUiThread(() -> { + showPageNumber(); + invalidateOptionsMenu(); + }); + } + @JavascriptInterface public float getZoomRatio() { return viewModel.getZoomRatio(); @@ -241,6 +251,16 @@ public int getDocumentOrientationDegrees() { return viewModel.getDocumentOrientationDegrees(); } + @JavascriptInterface + public int getPageFitMode() { + return viewModel.getPageFitMode(); + } + + @JavascriptInterface + public boolean getContinuousMode() { + return viewModel.getContinuousMode(); + } + @JavascriptInterface public void setNumPages(int numPages) { viewModel.setNumPages(numPages); @@ -349,8 +369,6 @@ protected void onCreate(Bundle savedInstanceState) { } }); - // Margins for the toolbar are needed, so that content of the toolbar - // is not covered by a system button navigation bar when in landscape. KtUtilsKt.applySystemBarMargins(binding.toolbar, false); ViewCompat.setOnApplyWindowInsetsListener( binding.webview, new OnApplyWindowInsetsListener() { @@ -361,8 +379,6 @@ binding.webview, new OnApplyWindowInsetsListener() { | WindowInsetsCompat.Type.displayCutout()); insetLeft = allInsets.left; insetRight = allInsets.right; - // Only set the bottom inset. The top will use the height of the app bar layout - // which includes the status bar/display cutout. insetBottom = allInsets.bottom; return insets; } @@ -444,8 +460,6 @@ public WebResourceResponse shouldInterceptRequest(WebView view, WebResourceReque if ("/viewer/js/worker.js".equals(path)) { final WebResourceResponse response = fromAsset("application/javascript", path); response.getResponseHeaders().put("Content-Security-Policy", WORKER_CONTENT_SECURITY_POLICY); - // Permissions-Policy does not apply to workers. - // See: https://github.com/w3c/webappsec-permissions-policy/issues/207 return response; } @@ -463,6 +477,10 @@ public WebResourceResponse shouldInterceptRequest(WebView view, WebResourceReque return fromAsset("application/wasm", path); } + if (path != null && path.matches("^/viewer/iccs/.*\\.ICC$")) { + return fromAsset("application/vnd.iccprofile", path); + } + if (path != null && path.matches("^/viewer/iccs/.*\\.icc$")) { return fromAsset("application/vnd.iccprofile", path); } @@ -537,15 +555,11 @@ public boolean onFling(@Nullable MotionEvent e1, @NonNull MotionEvent e2, float float absDeltaX = Math.abs(deltaX); float absDeltaY = Math.abs(deltaY); - // Check primarily horizontal if (absDeltaX > absDeltaY && absDeltaX > swipeThreshold && Math.abs(velocityX) > swipeVelocityThreshold) { - boolean swipeLeft = deltaX < 0; boolean swipeRight = deltaX > 0; - - // Edge detection boolean atLeftEdge = !binding.webview.canScrollHorizontally(-1); boolean atRightEdge = !binding.webview.canScrollHorizontally(1); @@ -666,7 +680,6 @@ protected void onResume() { super.onResume(); if (!viewModel.getWebViewCrashed()) { - // The user could have left the activity to update the WebView invalidateOptionsMenu(); if (getWebViewRelease() >= MIN_WEBVIEW_RELEASE) { binding.webviewAlertLayout.setVisibility(View.GONE); @@ -704,6 +717,20 @@ private void renderPage(final int zoom) { binding.webview.evaluateJavascript("onRenderPage(" + zoom + ")", null); } + private void setPageFitMode(final int mode) { + viewModel.setPageFitMode(mode); + // Reset zoom ratio so JS computes the new fit ratio + viewModel.setZoomRatio(0f); + renderPage(0); + invalidateOptionsMenu(); + } + + private void setContinuousMode(final boolean enabled) { + viewModel.setContinuousMode(enabled); + binding.webview.evaluateJavascript("setContinuousMode(" + enabled + ")", null); + invalidateOptionsMenu(); + } + private void documentOrientationChanged(final int orientationDegreesOffset) { int degrees = (viewModel.getDocumentOrientationDegrees() + orientationDegreesOffset) % 360; if (degrees < 0) { @@ -733,15 +760,45 @@ private void shareDocument() { } private void zoom(float scaleFactor, float focusX, float focusY, boolean end) { + // Switch to free zoom mode when user pinches + viewModel.setPageFitMode(0); viewModel.setZoomRatio(Math.min(Math.max(viewModel.getZoomRatio() * scaleFactor, MIN_ZOOM_RATIO), MAX_ZOOM_RATIO)); zoomFocusX = focusX; zoomFocusY = focusY; - renderPage(end ? 1 : 2); + requestZoomRender(end); invalidateOptionsMenu(); } private void zoomEnd() { - renderPage(1); + requestZoomRender(true); + } + + /** + * Keep at most one pinch render queued in the WebView. ScaleGestureDetector + * can produce events faster than the JavaScript viewer can relayout a large + * document; queueing every event makes later JavaScript calls wait behind + * stale zoom work. The ViewModel and focus fields always hold the newest + * values, so intermediate renders can be safely coalesced. + */ + private void requestZoomRender(final boolean end) { + zoomRenderPending = true; + zoomRenderEndPending |= end; + dispatchPendingZoomRender(); + } + + private void dispatchPendingZoomRender() { + if (zoomRenderInFlight || !zoomRenderPending) { + return; + } + + final int zoom = zoomRenderEndPending ? 1 : 2; + zoomRenderPending = false; + zoomRenderEndPending = false; + zoomRenderInFlight = true; + binding.webview.evaluateJavascript("onRenderPage(" + zoom + ")", unused -> { + zoomRenderInFlight = false; + dispatchPendingZoomRender(); + }); } private static void setMenuItemState(MenuItem item, boolean visible, boolean enabled) { @@ -752,6 +809,10 @@ private static void setMenuItemState(MenuItem item, boolean visible, boolean ena } } + private static void setMenuItemChecked(MenuItem item, boolean checked) { + item.setChecked(checked); + } + public void onJumpToPageInDocument(final int selected_page) { if (selected_page >= 1 && selected_page <= viewModel.getNumPages() && viewModel.getPage() != selected_page) { viewModel.setPage(selected_page); @@ -816,6 +877,31 @@ public boolean onPrepareOptionsMenu(@NonNull Menu menu) { setMenuItemState(menu.findItem(R.id.action_outline), loaded && viewModel.hasOutline(), enabled); + // Page fit mode checkable items + setMenuItemState(menu.findItem(R.id.action_page_fit_group), loaded, enabled); + final int fitMode = viewModel.getPageFitMode(); + MenuItem fitPage = menu.findItem(R.id.action_fit_page); + MenuItem fitWidth = menu.findItem(R.id.action_fit_width); + MenuItem fitFree = menu.findItem(R.id.action_fit_free); + if (fitPage != null) { + setMenuItemState(fitPage, loaded, enabled); + setMenuItemChecked(fitPage, fitMode == 1); + } + if (fitWidth != null) { + setMenuItemState(fitWidth, loaded, enabled); + setMenuItemChecked(fitWidth, fitMode == 2); + } + if (fitFree != null) { + setMenuItemState(fitFree, loaded, enabled); + setMenuItemChecked(fitFree, fitMode == 0); + } + + final MenuItem continuousScroll = menu.findItem(R.id.action_continuous_scroll); + if (continuousScroll != null) { + setMenuItemState(continuousScroll, loaded, enabled); + continuousScroll.setChecked(viewModel.getContinuousMode()); + } + if (BuildConfig.DEBUG) { setMenuItemState(menu.findItem(R.id.debug_action_toggle_text_layer_visibility), loaded, enabled); @@ -855,7 +941,6 @@ public boolean onOptionsItemSelected(MenuItem item) { OutlineFragment.newInstance(viewModel.getPage(), getCurrentDocumentName()); getSupportFragmentManager().beginTransaction() .setTransition(FragmentTransaction.TRANSIT_FRAGMENT_OPEN) - // fullscreen fragment, since content root view == activity's root view .add(android.R.id.content, outlineFragment) .addToBackStack(null) .commit(); @@ -875,6 +960,18 @@ public boolean onOptionsItemSelected(MenuItem item) { } else if (itemId == R.id.action_save_as) { saveDocument(); return true; + } else if (itemId == R.id.action_fit_page) { + setPageFitMode(1); + return true; + } else if (itemId == R.id.action_fit_width) { + setPageFitMode(2); + return true; + } else if (itemId == R.id.action_fit_free) { + setPageFitMode(0); + return true; + } else if (itemId == R.id.action_continuous_scroll) { + setContinuousMode(!viewModel.getContinuousMode()); + return true; } else if (itemId == R.id.debug_action_toggle_text_layer_visibility) { binding.webview.evaluateJavascript("toggleTextLayerVisibility()", null); return true; @@ -910,6 +1007,7 @@ private void resetDocumentState() { viewModel.setZoomRatio(0f); viewModel.setDocumentOrientationDegrees(0); viewModel.setEncryptedDocumentPassword(""); + viewModel.setPageFitMode(2); // Default to fit width viewModel.clearOutline(); viewModel.clearDocumentProperties(); } diff --git a/app/src/main/java/app/grapheneos/pdfviewer/viewModel/PdfViewModel.kt b/app/src/main/java/app/grapheneos/pdfviewer/viewModel/PdfViewModel.kt index 58076d3dd..f7fb8cd8f 100644 --- a/app/src/main/java/app/grapheneos/pdfviewer/viewModel/PdfViewModel.kt +++ b/app/src/main/java/app/grapheneos/pdfviewer/viewModel/PdfViewModel.kt @@ -34,6 +34,8 @@ class PdfViewModel( private const val STATE_PAGE: String = "page" private const val STATE_ZOOM_RATIO: String = "zoomRatio" private const val STATE_DOCUMENT_ORIENTATION_DEGREES: String = "documentOrientationDegrees" + private const val STATE_PAGE_FIT_MODE: String = "pageFitMode" + private const val STATE_CONTINUOUS_MODE: String = "continuousMode" private const val STATE_DOCUMENT_PROPERTIES = "documentProperties" private const val STATE_DOCUMENT_NAME = "documentName" } @@ -62,6 +64,23 @@ class PdfViewModel( savedStateHandle[STATE_DOCUMENT_ORIENTATION_DEGREES] = value } + // Page fit mode: 0 = free zoom, 1 = fit page, 2 = fit width + @Volatile + var pageFitMode: Int = savedStateHandle[STATE_PAGE_FIT_MODE] ?: 2 + set(value) { + field = value + savedStateHandle[STATE_PAGE_FIT_MODE] = value + } + + // Continuous scrolling: true = all pages stacked and vertically scrollable; + // false = single page at a time (the original behaviour). + @Volatile + var continuousMode: Boolean = savedStateHandle[STATE_CONTINUOUS_MODE] ?: true + set(value) { + field = value + savedStateHandle[STATE_CONTINUOUS_MODE] = value + } + @Volatile var numPages: Int = 0 diff --git a/app/src/main/res/menu/pdf_viewer.xml b/app/src/main/res/menu/pdf_viewer.xml index 16cd9b289..c1fecd461 100644 --- a/app/src/main/res/menu/pdf_viewer.xml +++ b/app/src/main/res/menu/pdf_viewer.xml @@ -1,12 +1,6 @@ - - - - - - + + + + + + + + + + + + First page Last page Jump to page + Page fitting + Free zoom + Fit page + Fit width + Continuous scrolling Rotate clockwise Rotate counterclockwise Share diff --git a/viewer/css/pdf_viewer.css b/viewer/css/pdf_viewer.css index d4b7e4770..509675e18 100644 --- a/viewer/css/pdf_viewer.css +++ b/viewer/css/pdf_viewer.css @@ -20,22 +20,38 @@ body { --scale-round-x: 1px; --scale-round-y: 1px; + min-height: 100%; width: 100%; - height: 100%; - display: grid; - place-items: center; + box-sizing: border-box; } -#container canvas, -#container .textLayer { - /* overlay child elements on top of each other */ - grid-row-start: 1; - grid-column-start: 1; +#pages { + display: block; } -canvas { - display: inline-block; +/* + * Continuous vertical paging: each page is a centered wrapper holding its + * canvas and text layer. The wrapper's height is set from JS to the page's + * viewport height so the document is as tall as the sum of its pages and + * scrolls naturally. + */ +.page-wrapper { + display: flex; + justify-content: center; position: relative; + padding: 0; + margin: 14px 0; +} + +.page-wrapper canvas { + display: block; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3); +} + +.page-wrapper .textLayer { + position: absolute; + top: 0; + left: 0; } [data-main-rotation="90"] { diff --git a/viewer/index.html b/viewer/index.html index 65eefa764..139f8c61d 100644 --- a/viewer/index.html +++ b/viewer/index.html @@ -8,8 +8,7 @@
- -
+
diff --git a/viewer/js/index.js b/viewer/js/index.js index 2d0c5ab1b..86bbf99d9 100644 --- a/viewer/js/index.js +++ b/viewer/js/index.js @@ -9,283 +9,458 @@ import { getSimplifiedOutline } from "./outline.js"; GlobalWorkerOptions.workerSrc = "/viewer/js/worker.js"; +// Continuous vertical paging. +// +// One wrapper per page is laid out in a vertical stack so the document scrolls +// naturally. Pages are rendered lazily (IntersectionObserver) only when near +// the viewport and cleared when far away, so memory stays bounded on large +// documents. +// +// CSP note (style-src 'self', no unsafe-inline): every dynamic style is set +// via an IDL property write (el.style.x = ...) or a CSS custom property +// (setProperty). We never set the style attribute directly, never assign cssText, +// never create a style element, and never use an inline style attribute in HTML +// — those are exactly what CSP blocks here. See check-csp.mjs which enforces it. + let pdfDoc = null; let outlineAbort = new AbortController(); -let pageRendering = false; -let renderPending = false; -let renderPendingZoom = 0; -const canvas = document.getElementById("content"); -const container = document.getElementById("container"); -let orientationDegrees = 0; -let zoomRatio = 1; -let textLayerDiv = document.getElementById("text"); -let task = null; -let newPageNumber = 0; -let newZoomRatio = 1; -let useRender; +// pages[i] describes page (i+1): +// { wrapper, canvas, textLayer, pdfPage, viewport, rendered, rendering, task } +const pages = []; -const cache = []; -const maxCached = 6; +let zoomRatio = 0; // free-zoom ratio (0 = derive from fit mode) +let orientationDegrees = 0; +let lastReportedPage = 0; // last page pushed back to the ViewModel +let renderObserver = null; +let scrollTimer = null; +let pageBuildGeneration = 0; +let pendingScrollPage = 0; -let isTextLayerVisible = false; +const container = document.getElementById("container"); +const pagesEl = document.getElementById("pages"); + +function readLayout() { + const i = insets(); + return { + insets: i, + available: { + width: document.body.clientWidth - i.left - i.right, + height: document.body.clientHeight - i.top - i.bottom, + }, + mode: fitMode(), + minZoom: channel.getMinZoomRatio(), + maxZoom: channel.getMaxZoomRatio(), + }; +} -function maybeRenderNextPage() { - if (renderPending) { - pageRendering = false; - renderPending = false; - renderPage(channel.getPage(), renderPendingZoom, false); - return true; - } - return false; +function clampZoom(value, layout = null) { + const min = layout ? layout.minZoom : channel.getMinZoomRatio(); + const max = layout ? layout.maxZoom : channel.getMaxZoomRatio(); + return Math.max(Math.min(value, max), min); } -function handleRenderingError(error) { - console.log("rendering error: " + error); +function fitMode() { + // 0 = free zoom, 1 = fit page, 2 = fit width + return channel.getPageFitMode(); +} - pageRendering = false; - maybeRenderNextPage(); +function continuousMode() { + return channel.getContinuousMode(); } -function doPrerender(pageNumber, prerenderTrigger) { - if (useRender) { - if (pageNumber + 1 <= pdfDoc.numPages) { - renderPage(pageNumber + 1, false, true, pageNumber); - } else if (pageNumber - 1 > 0) { - renderPage(pageNumber - 1, false, true, pageNumber); - } - } else if (pageNumber === prerenderTrigger + 1) { - if (prerenderTrigger - 1 > 0) { - renderPage(prerenderTrigger - 1, false, true, prerenderTrigger); - } +// In single-page mode only the current page's wrapper is displayed, so the +// document is exactly one page tall (no continuous flow) and navigation is via +// next/previous/jump/fling — the original behaviour. In continuous mode every +// wrapper is displayed and the document scrolls vertically through all pages. +function applyContinuousMode() { + const cont = continuousMode(); + const current = channel.getPage(); + for (const p of pages) { + if (!p) continue; + const num = Number(p.wrapper.dataset.page); + p.wrapper.style.display = (cont || num === current) ? "" : "none"; } } -function display(newCanvas, zoom) { - canvas.height = newCanvas.height; - canvas.width = newCanvas.width; - canvas.style.height = newCanvas.style.height; - canvas.style.width = newCanvas.style.width; - canvas.getContext("2d", { alpha: false }).drawImage(newCanvas, 0, 0); - if (!zoom) { - scrollTo(0, 0); - } +function insets() { + const ratio = globalThis.devicePixelRatio; + return { + ratio, + left: (channel.getInsetLeft() / ratio) || 0, + right: (channel.getInsetRight() / ratio) || 0, + top: (channel.getInsetTop() / ratio) || 0, + bottom: (channel.getInsetBottom() / ratio) || 0, + }; } -function setLayerTransform(pageWidth, pageHeight, layerDiv) { - const cs = globalThis.getComputedStyle(canvas); - const insetLeft = parseFloat(cs.paddingLeft) || 0; - const insetTop = parseFloat(cs.paddingTop) || 0; - const insetRight = parseFloat(cs.paddingRight) || 0; - const insetBottom = parseFloat(cs.paddingBottom) || 0; - - const isOverflownY = canvas.clientHeight > document.body.clientHeight; - const isOverflownX = canvas.clientWidth > document.body.clientWidth; - // Translate the text layer to stay aligned with the rendered page including canvas insets and - // grid centering effects. - const translate = { - X: isOverflownX - ? insetLeft - (document.body.clientWidth - pageWidth) / 2 - : (insetLeft - insetRight) / 2, - Y: isOverflownY - ? insetTop - (document.body.clientHeight - pageHeight) / 2 - : (insetTop - insetBottom) / 2 +function availSize(layout = null) { + if (layout) return layout.available; + const i = insets(); + return { + width: document.body.clientWidth - i.left - i.right, + height: document.body.clientHeight - i.top - i.bottom, }; - layerDiv.style.translate = `${translate.X}px ${translate.Y}px`; } -function getDefaultZoomRatio(page, degrees) { - const totalRotation = (degrees + page.rotate) % 360; - const viewport = page.getViewport({scale: 1, rotation: totalRotation}); - const widthZoomRatio = document.body.clientWidth / viewport.width; - const heightZoomRatio = document.body.clientHeight / viewport.height; - return Math.max(Math.min(widthZoomRatio, heightZoomRatio, channel.getMaxZoomRatio()), channel.getMinZoomRatio()); +function totalRotation(pdfPage) { + return (orientationDegrees + pdfPage.rotate) % 360; } -function renderPage(pageNumber, zoom, prerender, prerenderTrigger = 0) { - pageRendering = true; - useRender = !prerender; +// Zoom ratio for a page under the active fit mode, or the free-zoom ratio. +function pageZoom(pdfPage, layout = null) { + const m = layout ? layout.mode : fitMode(); + if (m !== 0 || zoomRatio === 0) { + const vp1 = pdfPage.getViewport({scale: 1, rotation: totalRotation(pdfPage)}); + const a = availSize(layout); + const wZoom = a.width / vp1.width; + const hZoom = a.height / vp1.height; + // fit width (2) fills width; fit page (1) and free-default both fit page. + const z = (m === 2) ? wZoom : Math.min(wZoom, hZoom); + return clampZoom(z, layout); + } + return clampZoom(zoomRatio, layout); +} - newPageNumber = pageNumber; - newZoomRatio = channel.getZoomRatio(); - orientationDegrees = channel.getDocumentOrientationDegrees(); - console.log("page: " + pageNumber + ", zoom: " + newZoomRatio + - ", orientationDegrees: " + orientationDegrees + ", prerender: " + prerender); - for (let i = 0; i < cache.length; i++) { - const cached = cache[i]; - if (cached.pageNumber === pageNumber && cached.zoomRatio === newZoomRatio && - cached.orientationDegrees === orientationDegrees) { - if (useRender) { - cache.splice(i, 1); - cache.push(cached); - - display(cached.canvas, zoom); - - textLayerDiv.replaceWith(cached.textLayerDiv); - textLayerDiv = cached.textLayerDiv; - setLayerTransform(cached.pageWidth, cached.pageHeight, textLayerDiv); - container.style.setProperty("--scale-factor", newZoomRatio.toString()); - textLayerDiv.hidden = false; - } +function pageViewport(pdfPage, layout = null) { + return pdfPage.getViewport({ + scale: pageZoom(pdfPage, layout), + rotation: totalRotation(pdfPage), + }); +} - pageRendering = false; - doPrerender(pageNumber, prerenderTrigger); - return; - } - } +// Keep content out from under the system bars by padding the scroll host. +function applyContainerInsets(layout = null) { + const i = layout ? layout.insets : insets(); + container.style.paddingLeft = i.left + "px"; + container.style.paddingRight = i.right + "px"; + container.style.paddingTop = i.top + "px"; + container.style.paddingBottom = i.bottom + "px"; +} - pdfDoc.getPage(pageNumber).then(function(page) { - if (maybeRenderNextPage()) { - return; - } +// (Re)size a wrapper to its viewport. CSP-safe: property writes only. +function sizeWrapper(p, layout = null) { + const vp = pageViewport(p.pdfPage, layout); + p.viewport = vp; + const a = availSize(layout); + p.wrapper.style.width = a.width + "px"; + p.wrapper.style.height = vp.height + "px"; + p.canvas.style.width = vp.width + "px"; + p.canvas.style.height = vp.height + "px"; +} - const defaultZoomRatio = getDefaultZoomRatio(page, orientationDegrees); +// Overlay the text layer exactly on the (horizontally-centered) canvas. +function alignTextLayer(p, layout = null) { + if (!p.viewport) return; + const a = availSize(layout); + const offsetX = (a.width - p.viewport.width) / 2; + p.textLayer.style.translate = offsetX + "px 0px"; + p.textLayer.style.width = p.viewport.width + "px"; + p.textLayer.style.height = p.viewport.height + "px"; +} - if (newZoomRatio === 0) { - zoomRatio = defaultZoomRatio; - newZoomRatio = defaultZoomRatio; - channel.setZoomRatio(defaultZoomRatio); +function clearPage(p) { + if (p.task) { + try { + p.task.cancel(); + } catch { + // cancellation may throw if the task already completed } + p.task = null; + } + p.rendering = false; + p.rendered = false; + p.canvas.width = 0; // free the backing store + p.canvas.height = 0; + p.textLayer.replaceChildren(); +} - const totalRotation = (orientationDegrees + page.rotate) % 360; - const viewport = page.getViewport({scale: newZoomRatio, rotation: totalRotation}); +function renderPageContent(p, layout = null) { + if (p.rendered) return; + // Cancel any in-flight render so we restart at the current viewport: during + // a multi-event pinch the viewport changes on every event, and an unchecked + // in-flight task would otherwise complete with a stale viewport (stretched + // bitmap, misaligned text) — see review P1. + if (p.task) { + try { + p.task.cancel(); + } catch { + // task already settled + } + p.task = null; + } + p.rendering = true; + p.rendered = false; + // Sync wrapper/canvas layout to the viewport we render at, so a rotation or + // zoom change can never leave a stale size. + sizeWrapper(p, layout); + // Generation tag: callbacks from a cancelled/superseded render no-op. + const gen = (p.renderGen = (p.renderGen || 0) + 1); + + const ratio = layout ? layout.insets.ratio : insets().ratio; + const vp = p.viewport; + const renderedZoom = pageZoom(p.pdfPage, layout); + const renderPixels = (vp.width * ratio) * (vp.height * ratio); + + let renderVp = vp; + const maxRenderPixels = channel.getMaxRenderPixels(); + if (renderPixels > maxRenderPixels) { + const adjusted = Math.sqrt(maxRenderPixels / renderPixels); + renderVp = p.pdfPage.getViewport({ + scale: renderedZoom * adjusted, + rotation: totalRotation(p.pdfPage), + }); + } - const scaleFactor = newZoomRatio / zoomRatio; - const ratio = globalThis.devicePixelRatio; + p.canvas.width = Math.floor(renderVp.width * ratio); + p.canvas.height = Math.floor(renderVp.height * ratio); + const ctx = p.canvas.getContext("2d", {alpha: false}); + ctx.scale(ratio, ratio); + + const renderTask = p.pdfPage.render({canvasContext: ctx, viewport: renderVp}); + p.task = renderTask; + renderTask.promise.then(() => { + if (gen !== p.renderGen) return; + p.zoom = renderedZoom; + // pdf.js TextLayer reads --scale-factor from its container; set it before + // construction so each page's text layer uses its own zoom. + p.textLayer.style.setProperty("--scale-factor", renderedZoom.toString()); + const textLayer = new TextLayer({ + textContentSource: p.pdfPage.streamTextContent(), + container: p.textLayer, + viewport: vp, + }); + p.task = {promise: textLayer.render(), cancel: () => textLayer.cancel()}; + return p.task.promise; + }).then(() => { + if (gen !== p.renderGen) return; + p.rendered = true; + p.rendering = false; + alignTextLayer(p); + p.textLayer.hidden = false; + }).catch((err) => { + if (gen !== p.renderGen) return; // expected when cancelled by a newer render + p.rendering = false; + console.log("render error: " + err); + }); +} - if (useRender) { - if (newZoomRatio !== zoomRatio) { - canvas.style.height = viewport.height + "px"; - canvas.style.width = viewport.width + "px"; +// Render pages near the viewport, clear far ones. +function setupObserver() { + if (renderObserver) renderObserver.disconnect(); + renderObserver = new IntersectionObserver((entries) => { + for (const entry of entries) { + const p = pages[Number(entry.target.dataset.page) - 1]; + if (!p) continue; + if (entry.isIntersecting && p.wrapper.style.display !== "none") { + renderPageContent(p); + } else { + clearPage(p); } - zoomRatio = newZoomRatio; } + }, {root: null, rootMargin: "150% 0px", threshold: 0}); + for (const p of pages) { + if (p) renderObserver.observe(p.wrapper); + } +} - if (zoom === 2) { - textLayerDiv.hidden = true; - pageRendering = false; - - // zoom focus relative to page origin, rather than screen origin - const globalFocusX = channel.getZoomFocusX() / ratio + globalThis.scrollX; - const globalFocusY = channel.getZoomFocusY() / ratio + globalThis.scrollY; - - const translationFactor = scaleFactor - 1; - const scrollX = globalFocusX * translationFactor; - const scrollY = globalFocusY * translationFactor; - scrollBy(scrollX, scrollY); +function relayoutAll() { + const layout = readLayout(); + applyContainerInsets(layout); + for (const p of pages) { + if (!p) continue; + sizeWrapper(p, layout); + alignTextLayer(p, layout); + } + return layout; +} - return; +// Re-render every currently-visible page (e.g. after zoom / rotation / resize). +function rerenderVisible(layout = null) { + const currentLayout = layout || readLayout(); + for (const p of pages) { + if (!p) continue; + if (p.wrapper.style.display === "none") { + clearPage(p); + continue; } + const rect = p.wrapper.getBoundingClientRect(); + const near = rect.bottom > -window.innerHeight * 1.5 && + rect.top < window.innerHeight * 2.5; + if (near) { + if (p.rendered) clearPage(p); + sizeWrapper(p, currentLayout); + renderPageContent(p, currentLayout); + } else { + clearPage(p); + } + } +} - const resolutionY = viewport.height * ratio; - const resolutionX = viewport.width * ratio; - const renderPixels = resolutionY * resolutionX; - - let newViewport = viewport; - const maxRenderPixels = channel.getMaxRenderPixels(); - if (renderPixels > maxRenderPixels) { - console.log(`resolution ${renderPixels} exceeds maximum allowed ${maxRenderPixels}`); - const adjustedScale = Math.sqrt(maxRenderPixels / renderPixels); - newViewport = page.getViewport({ - scale: newZoomRatio * adjustedScale, - rotation: totalRotation - }); +function mostVisiblePage() { + let best = null; + let bestArea = 0; + const vh = window.innerHeight; + for (const p of pages) { + if (!p || p.wrapper.style.display === "none") continue; + const rect = p.wrapper.getBoundingClientRect(); + const top = Math.max(rect.top, 0); + const bottom = Math.min(rect.bottom, vh); + const area = Math.max(0, bottom - top); + if (area > bestArea) { + bestArea = area; + best = p; } + } + return best; +} - const newCanvas = document.createElement("canvas"); - newCanvas.height = newViewport.height * ratio; - newCanvas.width = newViewport.width * ratio; - // use original viewport height for CSS zoom - newCanvas.style.height = viewport.height + "px"; - newCanvas.style.width = viewport.width + "px"; - const newContext = newCanvas.getContext("2d", { alpha: false }); - newContext.scale(ratio, ratio); - - // Add padding to the canvas to allow the page to be scrolled bellow/above any - // system/app ui that might be visible. - canvas.style.paddingLeft = (channel.getInsetLeft() / ratio) + "px"; - canvas.style.paddingTop = (channel.getInsetTop() / ratio) + "px"; - canvas.style.paddingRight = (channel.getInsetRight() / ratio) + "px"; - canvas.style.paddingBottom = (channel.getInsetBottom() / ratio) + "px"; - - task = page.render({ - canvasContext: newContext, - viewport: newViewport - }); +// Exposed for instrumentation tests: the canvas / text layer of the page +// currently most in view (continuous scroll has one per page). +globalThis.currentPageCanvas = function () { + const p = mostVisiblePage(); + return p ? p.canvas : null; +}; - task.promise.then(function() { - task = null; +globalThis.currentPageTextLayer = function () { + const p = mostVisiblePage(); + return p ? p.textLayer : null; +}; - let rendered = false; - function render() { - if (!useRender || rendered) { - return; - } - display(newCanvas, zoom); - rendered = true; - } - render(); - - const newTextLayerDiv = textLayerDiv.cloneNode(); - const textLayer = new TextLayer({ - textContentSource: page.streamTextContent(), - container: newTextLayerDiv, - viewport: viewport - }); - task = { - promise: textLayer.render(), - cancel: () => textLayer.cancel() - }; - task.promise.then(function() { - task = null; - - render(); - - setLayerTransform(viewport.width, viewport.height, newTextLayerDiv); - if (useRender) { - textLayerDiv.replaceWith(newTextLayerDiv); - textLayerDiv = newTextLayerDiv; - container.style.setProperty("--scale-factor", newZoomRatio.toString()); - textLayerDiv.hidden = false; - } - - if (cache.length === maxCached) { - cache.shift(); - } - cache.push({ - pageNumber: pageNumber, - zoomRatio: newZoomRatio, - orientationDegrees: orientationDegrees, - canvas: newCanvas, - textLayerDiv: newTextLayerDiv, - pageWidth: viewport.width, - pageHeight: viewport.height - }); - - pageRendering = false; - doPrerender(pageNumber, prerenderTrigger); - }).catch(handleRenderingError); - }).catch(handleRenderingError); - }); +// Report the most-visible page back to the ViewModel (drives the page indicator +// and next/previous enablement). +function updateCurrentPage() { + const best = mostVisiblePage(); + if (!best) return; + const num = Number(best.wrapper.dataset.page); + if (pendingScrollPage !== 0) { + if (!pages[pendingScrollPage - 1] || num !== pendingScrollPage) return; + pendingScrollPage = 0; + } + const layout = readLayout(); + const m = layout.mode; + if (m === 0 && zoomRatio !== 0) { + // Free zoom: the ViewModel zoom (driven by the pinch handler) is + // authoritative — reflect it on the container, never overwrite it. + container.style.setProperty("--scale-factor", zoomRatio.toString()); + } else { + // Fit mode: each page's fit zoom is authoritative — push it to the VM + // so the page indicator / tests read the right value. + // p.zoom records the last completed render and may still describe the + // previous fit mode or rotation. Publish the current layout ratio. + const z = pageZoom(best.pdfPage, layout); + container.style.setProperty("--scale-factor", z.toString()); + channel.setZoomRatio(z); + } + if (num !== lastReportedPage) { + lastReportedPage = num; + channel.setCurrentPage(num); + } } -globalThis.onRenderPage = function (zoom) { - if (pageRendering) { - if (newPageNumber === channel.getPage() && newZoomRatio === channel.getZoomRatio() && - orientationDegrees === channel.getDocumentOrientationDegrees()) { - useRender = true; - return; - } - - renderPending = true; - renderPendingZoom = zoom; - if (task !== null) { - task.cancel(); - task = null; +globalThis.scrollToPage = function (pageNumber) { + if (!Number.isInteger(pageNumber) || pageNumber < 1 || + (pdfDoc && pageNumber > pdfDoc.numPages)) return; + + // Publish the requested page immediately, even if progressive page setup + // has not reached it yet. Scroll tracking must not replace that request + // while its wrapper is still being built. + pendingScrollPage = pageNumber; + lastReportedPage = pageNumber; + channel.setCurrentPage(pageNumber); + + const p = pages[pageNumber - 1]; + if (!p) return; + const layout = readLayout(); + sizeWrapper(p, layout); + alignTextLayer(p, layout); + if (!continuousMode()) { + // single-page mode: show only the target page + for (const q of pages) { + if (!q) continue; + q.wrapper.style.display = (q === p) ? "" : "none"; } + p.wrapper.scrollIntoView({block: "start"}); } else { - renderPage(channel.getPage(), zoom, false); + // continuous mode: centre the page in the visible band (below the app bar, + // above the nav bar) so next/previous lands squarely on the page rather + // than top-aligning it under the floating toolbar. + const dpr = globalThis.devicePixelRatio; + const visibleTop = channel.getInsetTop() / dpr; + const visibleH = window.innerHeight - visibleTop - channel.getInsetBottom() / dpr; + const rect = p.wrapper.getBoundingClientRect(); + const desiredTop = visibleTop + Math.max(0, (visibleH - rect.height) / 2); + globalThis.scrollBy(0, rect.top - desiredTop); + } + const best = mostVisiblePage(); + if (best && Number(best.wrapper.dataset.page) === pageNumber) { + pendingScrollPage = 0; + } +}; + +// Driven from the Java side (former single-page render entry point). +// zoom: 0 = full re-layout (fit/orientation/page jump), 1 = zoom end, 2 = zooming +globalThis.onRenderPage = function (zoom) { + orientationDegrees = channel.getDocumentOrientationDegrees(); + + if (zoom === 2 || zoom === 1) { + // pinch: adopt the new free-zoom ratio and re-render visible pages while + // keeping the focal point under the user's fingers (review P2 focal). + const dpr = globalThis.devicePixelRatio; + const best = mostVisiblePage(); + // Rendering is asynchronous and is commonly cancelled by the next + // pinch event, so p.zoom can lag behind the ratio already requested. + const prevZoom = zoomRatio || (best ? (best.zoom || pageZoom(best.pdfPage)) : 1); + const newZoom = channel.getZoomRatio(); + zoomRatio = newZoom; + container.style.setProperty("--scale-factor", newZoom.toString()); + + // Focal point in document coordinates, captured before re-layout. + const focusX = channel.getZoomFocusX() / dpr + globalThis.scrollX; + const focusY = channel.getZoomFocusY() / dpr + globalThis.scrollY; + + // Placeholder geometry belongs to the requested zoom even when its + // canvas is far enough away to remain unrendered. + const layout = relayoutAll(); + rerenderVisible(layout); + + const translationFactor = (newZoom / prevZoom) - 1; + globalThis.scrollBy(focusX * translationFactor, focusY * translationFactor); + return; + } + + // zoom === 0: a fit-mode / orientation / page change. + if (fitMode() !== 0) { + // a fit mode owns the zoom now; drop stale free-zoom state so that + // re-entering Free zoom re-derives instead of reusing it (review P2). + zoomRatio = 0; + } + // Read the Java-side target before geometry changes can make the scroll + // handler report and overwrite a different most-visible page. + const target = channel.getPage(); + const targetPage = pages[target - 1]; + const anchorTop = targetPage && targetPage.wrapper.style.display !== "none" + ? targetPage.wrapper.getBoundingClientRect().top + : null; + const isPageNavigation = target !== lastReportedPage; + + if (!targetPage && isPageNavigation) { + pendingScrollPage = target; + lastReportedPage = target; + } + + const layout = relayoutAll(); + if (targetPage && isPageNavigation) { + // next/prev/jump-to-page from the menu — scroll the target into view. + globalThis.scrollToPage(target); + } else if (targetPage && anchorTop !== null) { + // Preserve the target wrapper's viewport anchor when the heights of + // earlier pages change due to fitting or rotation. + const newTop = targetPage.wrapper.getBoundingClientRect().top; + globalThis.scrollBy(0, newTop - anchorTop); } + updateCurrentPage(); + rerenderVisible(layout); }; globalThis.isTextSelected = function () { @@ -293,17 +468,17 @@ globalThis.isTextSelected = function () { }; globalThis.getDocumentOutline = function () { - pdfDoc.getOutline().then(function(outline) { - getSimplifiedOutline(outline, outlineAbort, pdfDoc).then(function(outlineEntries) { - if (outlineEntries !== null) { - channel.setDocumentOutline(JSON.stringify(outlineEntries)); + pdfDoc.getOutline().then(function (outline) { + getSimplifiedOutline(outline, outlineAbort, pdfDoc).then(function (entries) { + if (entries !== null) { + channel.setDocumentOutline(JSON.stringify(entries)); } else { channel.setDocumentOutline(null); } - }).catch(function(error) { + }).catch(function (error) { console.log("getSimplifiedOutline error: " + error); }); - }).catch(function(error) { + }).catch(function (error) { console.log("pdfDoc.getOutline error: " + error); }); }; @@ -313,15 +488,27 @@ globalThis.abortDocumentOutline = function () { outlineAbort = new AbortController(); }; +let isTextLayerVisible = false; globalThis.toggleTextLayerVisibility = function () { - let textLayerForeground = "red"; - if (isTextLayerVisible) { - textLayerForeground = "transparent"; - } - document.documentElement.style.setProperty("--text-layer-foreground", textLayerForeground); + const foreground = isTextLayerVisible ? "transparent" : "red"; + document.documentElement.style.setProperty("--text-layer-foreground", foreground); isTextLayerVisible = !isTextLayerVisible; }; +globalThis.getPageFitMode = function () { + return channel.getPageFitMode(); +}; + +globalThis.setContinuousMode = function () { + // The ViewModel was already updated by the Java caller; reflect it in the DOM. + const target = channel.getPage(); + applyContinuousMode(); + relayoutAll(); + // Showing or hiding preceding wrappers changes this page's document offset. + globalThis.scrollToPage(target); + rerenderVisible(); +}; + globalThis.loadDocument = function () { const pdfPassword = channel.getPassword(); const loadingTask = getDocument({ @@ -330,19 +517,9 @@ globalThis.loadDocument = function () { cMapPacked: true, password: pdfPassword, iccUrl: "https://localhost/viewer/iccs/", - // This flag controls jpx/icc and PostScript Calculator function compiler at the same time. - // See https://github.com/GrapheneOS/PdfViewer/issues/634#issuecomment-4356820142 - // for security justifications. - // - // Note that CSP is only applied to index.html, not workers where WASM runs useWasm: true, - // If a font isn't embedded, the viewer falls back to default system fonts. On Android, - // there often isn't a good substitution provided by the OS, so we need to bundle standard - // fonts to improve the rendering of certain PDFs: - // - // https://github.com/mozilla/pdf.js/pull/18465 - // https://bugzilla.mozilla.org/show_bug.cgi?id=1882613 useSystemFonts: false, + disableFontFace: true, standardFontDataUrl: "https://localhost/viewer/standard_fonts/", wasmUrl: "https://localhost/viewer/wasm/" }); @@ -363,18 +540,129 @@ globalThis.loadDocument = function () { }).catch(function (error) { console.log("getMetadata error: " + error); }); - pdfDoc.getOutline().then(function(outline) { + pdfDoc.getOutline().then(function (outline) { channel.setHasDocumentOutline(outline && outline.length > 0); - }).catch(function(error) { + }).catch(function (error) { console.log("getOutline error: " + error); }); - renderPage(channel.getPage(), false, false); + + // Apply the saved document rotation before sizing any page, otherwise + // every wrapper is built at rotation 0 and only nearby pages get + // corrected later (review P2 rotation). + orientationDegrees = channel.getDocumentOrientationDegrees(); + + // Reset continuous-scroll state — loadDocument runs again when opening a + // second document or re-entering a password, so the old pages must go. + for (const old of pages) { + if (old) clearPage(old); + } + if (renderObserver) { + renderObserver.disconnect(); + renderObserver = null; + } + pages.length = 0; + pagesEl.replaceChildren(); + zoomRatio = 0; + lastReportedPage = 0; + pendingScrollPage = 0; + const buildGeneration = ++pageBuildGeneration; + const startPage = channel.getPage() || 1; + + buildPages(startPage, buildGeneration).catch((error) => { + console.error("buildPages error: " + error); + }); }, function (reason) { console.error(reason.name + ": " + reason.message); channel.onLoadError(); }); }; -globalThis.onresize = () => { - setLayerTransform(canvas.clientWidth, canvas.clientHeight, textLayerDiv); +function createPageEntry(pdfPage, pageNumber) { + const wrapper = document.createElement("div"); + wrapper.className = "page-wrapper"; + wrapper.dataset.page = String(pageNumber); + + const canvas = document.createElement("canvas"); + const textLayer = document.createElement("div"); + textLayer.className = "textLayer"; + textLayer.hidden = true; + + wrapper.appendChild(canvas); + wrapper.appendChild(textLayer); + + return { + wrapper, canvas, textLayer, pdfPage, + viewport: null, rendered: false, rendering: false, task: null, + }; +} + +// Fetch page metadata in document order with concurrency bounded to one. The +// first readable page can be displayed immediately, and a failed later page +// does not reject initialization of the rest of the document. +async function buildPages(startPage, generation) { + const documentToBuild = pdfDoc; + const total = pdfDoc.numPages; + let viewerReady = false; + + for (let i = 1; i <= total; i++) { + if (generation !== pageBuildGeneration || documentToBuild !== pdfDoc) return; + let pdfPage; + try { + pdfPage = await documentToBuild.getPage(i); + } catch (error) { + if (generation !== pageBuildGeneration || documentToBuild !== pdfDoc) return; + console.error(`getPage(${i}) error: ${error}`); + continue; + } + if (generation !== pageBuildGeneration || documentToBuild !== pdfDoc) return; + + const entry = createPageEntry(pdfPage, i); + sizeWrapper(entry); + pages[i - 1] = entry; + pagesEl.appendChild(entry.wrapper); + + const requestedPage = channel.getPage() || startPage; + entry.wrapper.style.display = (continuousMode() || i === requestedPage) ? "" : "none"; + + if (!viewerReady) { + applyContainerInsets(); + setupObserver(); + viewerReady = true; + // Continuous mode can show useful content while a later restored + // target is still being initialized. + if (i !== requestedPage) rerenderVisible(); + } else if (renderObserver) { + renderObserver.observe(entry.wrapper); + } + + if (i === requestedPage) { + globalThis.scrollToPage(requestedPage); + updateCurrentPage(); + rerenderVisible(); + } + } + + const requestedPage = channel.getPage() || startPage; + if (viewerReady && !pages[requestedPage - 1]) { + const fallback = pages.find((page) => page); + if (fallback) { + globalThis.scrollToPage(Number(fallback.wrapper.dataset.page)); + updateCurrentPage(); + rerenderVisible(); + } + } +} + +// Scroll → track current page (throttled); resize → relayout. +globalThis.onscroll = function () { + if (scrollTimer) return; + scrollTimer = setTimeout(() => { + scrollTimer = null; + updateCurrentPage(); + }, 150); +}; + +globalThis.onresize = function () { + relayoutAll(); + rerenderVisible(); }; diff --git a/viewer/js/index.test.js b/viewer/js/index.test.js new file mode 100644 index 000000000..1797b67ba --- /dev/null +++ b/viewer/js/index.test.js @@ -0,0 +1,337 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +const pdfJs = vi.hoisted(() => ({ loadingTask: null })); + +vi.mock("pdfjs-dist", () => ({ + GlobalWorkerOptions: {}, + PasswordResponses: { NEED_PASSWORD: 1, INCORRECT_PASSWORD: 2 }, + TextLayer: class { + render() { + return Promise.resolve(); + } + + cancel() {} + }, + getDocument: () => pdfJs.loadingTask, +})); + +function styleDeclaration() { + const properties = new Map(); + return { + display: "", + height: "", + width: "", + translate: "", + setProperty(name, value) { + properties.set(name, value); + }, + getPropertyValue(name) { + return properties.get(name) || ""; + }, + }; +} + +class FakeElement { + constructor(tagName, environment) { + this.tagName = tagName; + this.environment = environment; + this.children = []; + this.dataset = {}; + this.style = styleDeclaration(); + this.hidden = false; + this.width = 0; + this.height = 0; + } + + appendChild(child) { + child.parentElement = this; + this.children.push(child); + return child; + } + + replaceChildren(...children) { + this.children = []; + for (const child of children) this.appendChild(child); + } + + getContext() { + return { scale() {} }; + } + + getBoundingClientRect() { + if (this.style.display === "none") { + return { top: 0, bottom: 0, width: 0, height: 0 }; + } + const siblings = this.parentElement ? this.parentElement.children : []; + let documentTop = 0; + for (const sibling of siblings) { + if (sibling === this) break; + if (sibling.style.display !== "none") { + documentTop += Number.parseFloat(sibling.style.height) || 0; + } + } + const height = Number.parseFloat(this.style.height) || 0; + const top = documentTop - globalThis.scrollY; + return { top, bottom: top + height, height, width: Number.parseFloat(this.style.width) || 0 }; + } + + scrollIntoView() { + const rect = this.getBoundingClientRect(); + globalThis.scrollY += rect.top; + this.environment.scrolledPages.push(Number(this.dataset.page)); + } +} + +function fakePage(pageNumber, state, { width = 100, height = 200 } = {}) { + return { + rotate: 0, + getViewport({ scale, rotation }) { + const sideways = Math.abs(rotation % 180) === 90; + return { + width: (sideways ? height : width) * scale, + height: (sideways ? width : height) * scale, + }; + }, + render() { + state.renderCalls.push(pageNumber); + return { promise: Promise.resolve(), cancel() {} }; + }, + streamTextContent() { + return {}; + }, + }; +} + +async function flushPromises() { + await new Promise((resolve) => setTimeout(resolve, 0)); + await new Promise((resolve) => setTimeout(resolve, 0)); +} + +function delay(milliseconds) { + return new Promise((resolve) => setTimeout(resolve, milliseconds)); +} + +async function setupViewer({ + continuous = true, + currentPage = 1, + fitMode = 1, + pageCount = 3, + getPage, +} = {}) { + vi.resetModules(); + const state = { + continuous, + currentPage, + fitMode, + zoom: 0.5, + orientation: 0, + renderCalls: [], + scrollCalls: [], + scrolledPages: [], + zoomReports: [], + }; + const environment = { scrolledPages: state.scrolledPages }; + const container = new FakeElement("div", environment); + const pages = new FakeElement("div", environment); + container.appendChild(pages); + const body = new FakeElement("body", environment); + body.clientWidth = 100; + body.clientHeight = 100; + const documentElement = new FakeElement("html", environment); + + globalThis.document = { + body, + documentElement, + createElement: (tagName) => new FakeElement(tagName, environment), + getElementById: (id) => id === "container" ? container : pages, + }; + globalThis.window = globalThis; + globalThis.innerHeight = 100; + globalThis.devicePixelRatio = 1; + globalThis.scrollX = 0; + globalThis.scrollY = 0; + globalThis.scrollBy = (x, y) => { + state.scrollCalls.push([x, y]); + globalThis.scrollX += x; + globalThis.scrollY += y; + }; + globalThis.getSelection = () => ({ toString: () => "" }); + globalThis.IntersectionObserver = class { + observe() {} + disconnect() {} + }; + globalThis.channel = { + getMaxZoomRatio: () => 10, + getMinZoomRatio: () => 0.1, + getPageFitMode: () => state.fitMode, + getContinuousMode: () => state.continuous, + getPage: () => state.currentPage, + getInsetLeft: () => 0, + getInsetRight: () => 0, + getInsetTop: () => 0, + getInsetBottom: () => 0, + getDocumentOrientationDegrees: () => state.orientation, + getMaxRenderPixels: () => 10_000_000, + getZoomRatio: () => state.zoom, + getZoomFocusX: () => 10, + getZoomFocusY: () => 20, + setZoomRatio: (zoom) => { + state.zoom = zoom; + state.zoomReports.push(zoom); + }, + setCurrentPage: (page) => { + state.currentPage = page; + }, + getPassword: () => "", + onLoaded() {}, + setNumPages() {}, + setDocumentProperties() {}, + setHasDocumentOutline() {}, + onLoadError() {}, + }; + + const pdfDocument = { + numPages: pageCount, + getPage: getPage + ? (pageNumber) => getPage(pageNumber, state) + : (pageNumber) => Promise.resolve(fakePage(pageNumber, state)), + getMetadata: () => Promise.resolve({ info: {} }), + getOutline: () => Promise.resolve([]), + }; + pdfJs.loadingTask = { promise: Promise.resolve(pdfDocument) }; + + await import("./index.js"); + globalThis.loadDocument(); + await flushPromises(); + return { state, pagesElement: pages, pdfDocument }; +} + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("continuous page layout", () => { + it("renders only the displayed page in single-page mode", async () => { + const { state } = await setupViewer({ continuous: false, currentPage: 2 }); + + expect(state.renderCalls).toEqual([2]); + }); + + it("compensates each pinch event from the previously requested zoom", async () => { + const { state } = await setupViewer({ continuous: false, currentPage: 1 }); + state.fitMode = 0; + + state.zoom = 0.6; + globalThis.onRenderPage(2); + state.zoom = 0.7; + globalThis.onRenderPage(2); + globalThis.onRenderPage(1); + + expect(state.scrollCalls).toHaveLength(3); + expect(state.scrollCalls[0][0]).toBeCloseTo(2); + expect(state.scrollCalls[0][1]).toBeCloseTo(4); + expect(state.scrollCalls[1][0]).toBeCloseTo(2); + expect(state.scrollCalls[1][1]).toBeCloseTo(4); + expect(state.scrollCalls[2]).toEqual([0, 0]); + }); + + it("resizes far placeholders when free zoom changes", async () => { + const { state, pagesElement } = await setupViewer({ pageCount: 5 }); + state.fitMode = 0; + state.zoom = 1; + + globalThis.onRenderPage(2); + + expect(pagesElement.children[4].style.height).toBe("200px"); + }); + + it("publishes the fit zoom computed for the new layout", async () => { + const { state } = await setupViewer({ pageCount: 1, fitMode: 1 }); + state.fitMode = 2; + + globalThis.onRenderPage(0); + + expect(state.zoomReports.at(-1)).toBe(1); + }); + + it("keeps the requested page anchored across a relayout", async () => { + const { state } = await setupViewer({ + continuous: true, + currentPage: 3, + pageCount: 3, + fitMode: 1, + }); + expect(globalThis.scrollY).toBe(200); + state.fitMode = 2; + + globalThis.onRenderPage(0); + + expect(state.currentPage).toBe(3); + expect(globalThis.scrollY).toBe(400); + }); + + it("re-anchors the current page when continuous mode changes", async () => { + const { state } = await setupViewer({ + continuous: false, + currentPage: 3, + pageCount: 3, + }); + expect(globalThis.scrollY).toBe(0); + + state.continuous = true; + globalThis.setContinuousMode(); + expect(globalThis.scrollY).toBe(200); + + state.continuous = false; + globalThis.setContinuousMode(); + expect(globalThis.scrollY).toBe(0); + expect(state.currentPage).toBe(3); + }); + + it("starts rendering progressively and survives a later page failure", async () => { + let rejectSecondPage; + const secondPage = new Promise((resolve, reject) => { + rejectSecondPage = reject; + }); + const { state, pagesElement } = await setupViewer({ + pageCount: 3, + getPage: (pageNumber, viewerState) => { + if (pageNumber === 2) return secondPage; + return Promise.resolve(fakePage(pageNumber, viewerState)); + }, + }); + + expect(pagesElement.children.map((page) => page.dataset.page)).toEqual(["1"]); + expect(state.renderCalls).toContain(1); + + rejectSecondPage(new Error("damaged page")); + await flushPromises(); + + expect(pagesElement.children.map((page) => page.dataset.page)).toEqual(["1", "3"]); + }); + + it("keeps a late-page request sticky until progressive setup reaches it", async () => { + let resolveSecondPage; + const secondPage = new Promise((resolve) => { + resolveSecondPage = resolve; + }); + const { state } = await setupViewer({ + pageCount: 3, + getPage: (pageNumber, viewerState) => pageNumber === 2 + ? secondPage + : Promise.resolve(fakePage(pageNumber, viewerState)), + }); + + globalThis.scrollToPage(3); + globalThis.onscroll(); + await delay(200); + + expect(state.currentPage).toBe(3); + + resolveSecondPage(fakePage(2, state)); + await flushPromises(); + + expect(globalThis.scrollY).toBeGreaterThan(0); + expect(state.currentPage).toBe(3); + }); +}); From 8d2aa4ba4b42f02955c9843983bd3def10eb6d01 Mon Sep 17 00:00:00 2001 From: Aljaz Ceru Date: Wed, 22 Jul 2026 07:16:43 +0200 Subject: [PATCH 03/11] Fix horizontal panning for zoomed pages --- .../test/PdfViewerMultiPageRenderTest.kt | 32 +++++++++++++++++++ viewer/css/pdf_viewer.css | 2 +- viewer/js/index.js | 7 +++- viewer/js/index.test.js | 18 +++++++++++ 4 files changed, 57 insertions(+), 2 deletions(-) diff --git a/app/src/androidTest/kotlin/app/grapheneos/pdfviewer/test/PdfViewerMultiPageRenderTest.kt b/app/src/androidTest/kotlin/app/grapheneos/pdfviewer/test/PdfViewerMultiPageRenderTest.kt index 53de9214d..1a7699b16 100644 --- a/app/src/androidTest/kotlin/app/grapheneos/pdfviewer/test/PdfViewerMultiPageRenderTest.kt +++ b/app/src/androidTest/kotlin/app/grapheneos/pdfviewer/test/PdfViewerMultiPageRenderTest.kt @@ -120,6 +120,38 @@ class PdfViewerMultiPageRenderTest { } } + @Test + fun zoomedLastPage_canPanToBothHorizontalEdges() { + PdfViewerLauncher.launchWithTestAsset("test-multipage.pdf").use { scenario -> + PdfViewerTestUtils.waitForDocumentFullyLoaded(scenario) + PdfViewerTestUtils.waitForCanvasRendered(scenario) + scenario.onActivity { it.onJumpToPageInDocument(4) } + PdfViewerTestUtils.assertTextLayerContent(scenario, "Page Four Content") + + val initialZoom = robot.getZoomRatio(scenario) + robot.performPinchZoomIn(scenario, percent = 0.5f, speed = 500) + PdfViewerTestUtils.pollUntil(description = { "Canvas did not become over-wide" }) { + robot.getZoomRatio(scenario) > initialZoom && + robot.getCanvasCssWidth(scenario) > robot.getViewportWidth(scenario) + } + + val result = PdfViewerTestUtils.evaluateJs(scenario, """ + (() => { + const canvas = globalThis.currentPageCanvas(); + const verticalOffset = globalThis.scrollY; + globalThis.scrollTo(0, verticalOffset); + const leftEdge = canvas.getBoundingClientRect().left; + globalThis.scrollTo(document.documentElement.scrollWidth, verticalOffset); + const rightEdge = canvas.getBoundingClientRect().right; + return leftEdge >= -1 && + rightEdge <= document.documentElement.clientWidth + 1; + })() + """.trimIndent()) + + assertEquals("Both horizontal page edges should be reachable", "true", result) + } + } + // Outline @Test diff --git a/viewer/css/pdf_viewer.css b/viewer/css/pdf_viewer.css index 06ea14365..ba8350b85 100644 --- a/viewer/css/pdf_viewer.css +++ b/viewer/css/pdf_viewer.css @@ -37,7 +37,7 @@ body { */ .page-wrapper { display: flex; - justify-content: center; + justify-content: flex-start; position: relative; padding: 0; margin: 14px 0; diff --git a/viewer/js/index.js b/viewer/js/index.js index 9bb16467e..0f0d35a41 100644 --- a/viewer/js/index.js +++ b/viewer/js/index.js @@ -143,17 +143,22 @@ function sizeWrapper(p, layout = null) { const vp = pageViewport(p.pdfPage, layout); p.viewport = vp; const a = availSize(layout); + const offsetX = Math.max(0, (a.width - vp.width) / 2); p.wrapper.style.width = a.width + "px"; p.wrapper.style.height = vp.height + "px"; p.canvas.style.width = vp.width + "px"; p.canvas.style.height = vp.height + "px"; + // Center pages which fit, but start over-wide pages at x=0. Flex centering + // puts half of an over-wide canvas at a negative (unscrollable) coordinate, + // making its left edge impossible to reach while panning. + p.canvas.style.marginLeft = offsetX + "px"; } // Overlay the text layer exactly on the (horizontally-centered) canvas. function alignTextLayer(p, layout = null) { if (!p.viewport) return; const a = availSize(layout); - const offsetX = (a.width - p.viewport.width) / 2; + const offsetX = Math.max(0, (a.width - p.viewport.width) / 2); p.textLayer.style.translate = offsetX + "px 0px"; p.textLayer.style.width = p.viewport.width + "px"; p.textLayer.style.height = p.viewport.height + "px"; diff --git a/viewer/js/index.test.js b/viewer/js/index.test.js index 1797b67ba..6e23bd9ea 100644 --- a/viewer/js/index.test.js +++ b/viewer/js/index.test.js @@ -21,6 +21,7 @@ function styleDeclaration() { display: "", height: "", width: "", + marginLeft: "", translate: "", setProperty(name, value) { properties.set(name, value); @@ -235,6 +236,23 @@ describe("continuous page layout", () => { expect(state.scrollCalls[2]).toEqual([0, 0]); }); + it("keeps over-wide pages within the horizontally scrollable area", async () => { + const { state, pagesElement } = await setupViewer({ + continuous: true, + currentPage: 4, + pageCount: 4, + }); + state.fitMode = 0; + state.zoom = 2; + + globalThis.onRenderPage(2); + + const [canvas, textLayer] = pagesElement.children[3].children; + expect(canvas.style.width).toBe("200px"); + expect(canvas.style.marginLeft).toBe("0px"); + expect(textLayer.style.translate).toBe("0px 0px"); + }); + it("resizes far placeholders when free zoom changes", async () => { const { state, pagesElement } = await setupViewer({ pageCount: 5 }); state.fitMode = 0; From 3ca712860c5503f8d757142b64ff67788317d656 Mon Sep 17 00:00:00 2001 From: Aljaz Ceru Date: Wed, 19 Aug 2026 07:46:52 +0200 Subject: [PATCH 04/11] Remove duplicate StateFlow declarations after merge --- .../app/grapheneos/pdfviewer/viewModel/PdfViewModel.kt | 7 ------- 1 file changed, 7 deletions(-) diff --git a/app/src/main/java/app/grapheneos/pdfviewer/viewModel/PdfViewModel.kt b/app/src/main/java/app/grapheneos/pdfviewer/viewModel/PdfViewModel.kt index 54493e7d6..9aabc291f 100644 --- a/app/src/main/java/app/grapheneos/pdfviewer/viewModel/PdfViewModel.kt +++ b/app/src/main/java/app/grapheneos/pdfviewer/viewModel/PdfViewModel.kt @@ -69,13 +69,6 @@ class PdfViewModel( val documentProperties: StateFlow?> = savedStateHandle.getStateFlow(STATE_DOCUMENT_PROPERTIES, null) - val pageFitMode: StateFlow = savedStateHandle.getStateFlow(STATE_PAGE_FIT_MODE, 2) - fun setPageFitMode(value: Int) { savedStateHandle[STATE_PAGE_FIT_MODE] = value } - - val continuousMode: StateFlow = - savedStateHandle.getStateFlow(STATE_CONTINUOUS_MODE, true) - fun setContinuousMode(value: Boolean) { savedStateHandle[STATE_CONTINUOUS_MODE] = value } - val documentName: StateFlow = savedStateHandle.getStateFlow(STATE_DOCUMENT_NAME, "") From 0036f7e1202fd847e318e30f010755f60d86aacc Mon Sep 17 00:00:00 2001 From: Aljaz Ceru Date: Wed, 19 Aug 2026 07:49:55 +0200 Subject: [PATCH 05/11] Fix PdfViewerRobot merge seam: close scrollToPageJs helper --- .../kotlin/app/grapheneos/pdfviewer/util/PdfViewerRobot.kt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/src/androidTest/kotlin/app/grapheneos/pdfviewer/util/PdfViewerRobot.kt b/app/src/androidTest/kotlin/app/grapheneos/pdfviewer/util/PdfViewerRobot.kt index 8deb259e2..82162ef68 100644 --- a/app/src/androidTest/kotlin/app/grapheneos/pdfviewer/util/PdfViewerRobot.kt +++ b/app/src/androidTest/kotlin/app/grapheneos/pdfviewer/util/PdfViewerRobot.kt @@ -602,6 +602,8 @@ class PdfViewerRobot(private val composeRule: ComposeTestRule) { PdfViewerTestUtils.evaluateJs(scenario, "globalThis.scrollToPage($page)" ) + } + private fun ensureOverflowMenuOpen() { val zoomInDesc = getTargetContext().getString(R.string.zoom_in) val nodes = composeRule.onAllNodesWithContentDescription(zoomInDesc) From 49c2f536625c23a967a23d1bf34576eade04585d Mon Sep 17 00:00:00 2001 From: Aljaz Ceru Date: Wed, 19 Aug 2026 08:13:13 +0200 Subject: [PATCH 06/11] Port uncommitted upstream-port worktree changes onto merged main Recovered from the stale .worktrees/upstream-port working tree (never committed to any branch): - index.js: expand page-wrapper to canvas width for over-wide (zoomed) pages so the whole canvas area is scrollable - index.test.js: assert wrapper width tracks over-wide canvas - PdfViewerScreen: disable fling page-jump while in continuous mode - Robot: performSwipeLeft helper (Direction import) - MultiPageRenderTest: horizontalFling_continuousModeDoesNotChangePage; drive zoom test through the custom-zoom menu UI (300%) - NavigationTest: run the fling-navigation test in single-page mode, since continuous mode now pans instead of jumping pages --- .../test/PdfViewerMultiPageRenderTest.kt | 31 ++++++++++++++++--- .../pdfviewer/test/PdfViewerNavigationTest.kt | 3 ++ .../pdfviewer/util/PdfViewerRobot.kt | 7 +++++ .../grapheneos/pdfviewer/PdfViewerScreen.kt | 2 +- viewer/js/index.js | 2 +- viewer/js/index.test.js | 4 ++- 6 files changed, 42 insertions(+), 7 deletions(-) diff --git a/app/src/androidTest/kotlin/app/grapheneos/pdfviewer/test/PdfViewerMultiPageRenderTest.kt b/app/src/androidTest/kotlin/app/grapheneos/pdfviewer/test/PdfViewerMultiPageRenderTest.kt index 1a7699b16..673ee4496 100644 --- a/app/src/androidTest/kotlin/app/grapheneos/pdfviewer/test/PdfViewerMultiPageRenderTest.kt +++ b/app/src/androidTest/kotlin/app/grapheneos/pdfviewer/test/PdfViewerMultiPageRenderTest.kt @@ -105,6 +105,24 @@ class PdfViewerMultiPageRenderTest { } } + @Test + fun horizontalFling_continuousModeDoesNotChangePage() { + PdfViewerLauncher.launchWithTestAsset("test-multipage.pdf").use { scenario -> + PdfViewerTestUtils.waitForDocumentFullyLoaded(scenario) + PdfViewerTestUtils.waitForCanvasRendered(scenario) + scenario.onActivity { it.onJumpToPageInDocument(2) } + PdfViewerTestUtils.assertTextLayerContent(scenario, "Page Two Content") + + robot.performSwipeLeft(scenario) + + PdfViewerTestUtils.assertStableCondition( + description = { "Horizontal fling changed page in continuous mode" } + ) { + PdfViewerTestUtils.evaluateJs(scenario, "channel.getPage()") == "2" + } + } + } + @Test fun navigationButtonStates_updateAfterRenderedNavigation() { PdfViewerLauncher.launchWithTestAsset("test-multipage.pdf").use { scenario -> @@ -128,11 +146,11 @@ class PdfViewerMultiPageRenderTest { scenario.onActivity { it.onJumpToPageInDocument(4) } PdfViewerTestUtils.assertTextLayerContent(scenario, "Page Four Content") - val initialZoom = robot.getZoomRatio(scenario) - robot.performPinchZoomIn(scenario, percent = 0.5f, speed = 500) + robot.clickZoomPercentage() + robot.setCustomZoomValue(300) + robot.clickDialogOk() PdfViewerTestUtils.pollUntil(description = { "Canvas did not become over-wide" }) { - robot.getZoomRatio(scenario) > initialZoom && - robot.getCanvasCssWidth(scenario) > robot.getViewportWidth(scenario) + robot.getCanvasCssWidth(scenario) > robot.getViewportWidth(scenario) * 2 } val result = PdfViewerTestUtils.evaluateJs(scenario, """ @@ -149,6 +167,11 @@ class PdfViewerMultiPageRenderTest { """.trimIndent()) assertEquals("Both horizontal page edges should be reachable", "true", result) + PdfViewerTestUtils.assertStableCondition( + description = { "Canvas was cleared while its zoomed content was visible" } + ) { + robot.getCanvasWidth(scenario) > 0 && robot.getCanvasHeight(scenario) > 0 + } } } diff --git a/app/src/androidTest/kotlin/app/grapheneos/pdfviewer/test/PdfViewerNavigationTest.kt b/app/src/androidTest/kotlin/app/grapheneos/pdfviewer/test/PdfViewerNavigationTest.kt index 2c900a215..85b37b04c 100644 --- a/app/src/androidTest/kotlin/app/grapheneos/pdfviewer/test/PdfViewerNavigationTest.kt +++ b/app/src/androidTest/kotlin/app/grapheneos/pdfviewer/test/PdfViewerNavigationTest.kt @@ -16,6 +16,7 @@ import app.grapheneos.pdfviewer.PdfJsChannel.Companion.MIN_ZOOM_RATIO import app.grapheneos.pdfviewer.PdfViewer import app.grapheneos.pdfviewer.RetryableComposeRule import app.grapheneos.pdfviewer.TestTags +import app.grapheneos.pdfviewer.continuousMode import app.grapheneos.pdfviewer.currentPage import app.grapheneos.pdfviewer.documentName import app.grapheneos.pdfviewer.testrules.OrientationRules @@ -101,6 +102,8 @@ class PdfViewerNavigationTest { PdfViewerLauncher.launchWithTestAsset("test-multipage.pdf").use { scenario -> PdfViewerTestUtils.waitForDocumentFullyLoaded(scenario) PdfViewerTestUtils.waitForCanvasRendered(scenario) + // Fling navigation applies to single-page mode; continuous mode pans instead. + scenario.onActivity { it.continuousMode = false } robot.flingToNextPage() diff --git a/app/src/androidTest/kotlin/app/grapheneos/pdfviewer/util/PdfViewerRobot.kt b/app/src/androidTest/kotlin/app/grapheneos/pdfviewer/util/PdfViewerRobot.kt index 82162ef68..096404a8f 100644 --- a/app/src/androidTest/kotlin/app/grapheneos/pdfviewer/util/PdfViewerRobot.kt +++ b/app/src/androidTest/kotlin/app/grapheneos/pdfviewer/util/PdfViewerRobot.kt @@ -30,6 +30,7 @@ import androidx.test.espresso.matcher.ViewMatchers.isAssignableFrom import androidx.test.espresso.matcher.ViewMatchers.isDisplayed import androidx.test.platform.app.InstrumentationRegistry import androidx.test.uiautomator.By +import androidx.test.uiautomator.Direction import androidx.test.uiautomator.UiDevice import androidx.test.uiautomator.UiObject2 import androidx.test.uiautomator.Until @@ -473,6 +474,12 @@ class PdfViewerRobot(private val composeRule: ComposeTestRule) { webView.pinchClose(percent, speed) } + fun performSwipeLeft(scenario: ActivityScenario) { + val webView = findWebViewObject() + applyContentGestureMargins(webView, scenario) + webView.swipe(Direction.LEFT, 0.75f, 1_000) + } + private fun findWebViewObject(): UiObject2 { val device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) // Using class name because UiAutomator cannot find WebView reliably because diff --git a/app/src/main/java/app/grapheneos/pdfviewer/PdfViewerScreen.kt b/app/src/main/java/app/grapheneos/pdfviewer/PdfViewerScreen.kt index 7f7242c3f..cd8ac0119 100644 --- a/app/src/main/java/app/grapheneos/pdfviewer/PdfViewerScreen.kt +++ b/app/src/main/java/app/grapheneos/pdfviewer/PdfViewerScreen.kt @@ -380,7 +380,7 @@ fun PdfViewerScreen( e1: MotionEvent?, e2: MotionEvent, velocityX: Float, velocityY: Float ): Boolean { - if (e1 == null) return false + if (e1 == null || viewModel.continuousMode.value) return false val deltaX = e2.x - e1.x val deltaY = e2.y - e1.y diff --git a/viewer/js/index.js b/viewer/js/index.js index f1be75b1c..586451be0 100644 --- a/viewer/js/index.js +++ b/viewer/js/index.js @@ -144,7 +144,7 @@ function sizeWrapper(p, layout = null) { p.viewport = vp; const a = availSize(layout); const offsetX = Math.max(0, (a.width - vp.width) / 2); - p.wrapper.style.width = a.width + "px"; + p.wrapper.style.width = Math.max(a.width, vp.width) + "px"; p.wrapper.style.height = vp.height + "px"; p.canvas.style.width = vp.width + "px"; p.canvas.style.height = vp.height + "px"; diff --git a/viewer/js/index.test.js b/viewer/js/index.test.js index 30b459c77..8f04b58e1 100644 --- a/viewer/js/index.test.js +++ b/viewer/js/index.test.js @@ -258,7 +258,9 @@ describe("continuous page layout", () => { globalThis.onRenderPage(2); - const [canvas, textLayer] = pagesElement.children[3].children; + const wrapper = pagesElement.children[3]; + const [canvas, textLayer] = wrapper.children; + expect(wrapper.style.width).toBe("200px"); expect(canvas.style.width).toBe("200px"); expect(canvas.style.marginLeft).toBe("0px"); expect(textLayer.style.translate).toBe("0px 0px"); From a805f55f7feab6e35d31800080eaf9fc4347abbf Mon Sep 17 00:00:00 2001 From: Aljaz Ceru Date: Wed, 19 Aug 2026 09:23:42 +0200 Subject: [PATCH 07/11] Default to fit-width mode for new documents Restores the product decision from 'fix panning issues' (3ba62c6): new documents open in fit-width (mode 2), not fit-page (mode 1). Caught by PdfViewerPageFitModeTest.fitWidthMode_isDefaultForNewDocument failing on the Pixel 9a during the manual device test pass. --- .../java/app/grapheneos/pdfviewer/viewModel/PdfViewModel.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/app/grapheneos/pdfviewer/viewModel/PdfViewModel.kt b/app/src/main/java/app/grapheneos/pdfviewer/viewModel/PdfViewModel.kt index 9aabc291f..166015b39 100644 --- a/app/src/main/java/app/grapheneos/pdfviewer/viewModel/PdfViewModel.kt +++ b/app/src/main/java/app/grapheneos/pdfviewer/viewModel/PdfViewModel.kt @@ -59,7 +59,7 @@ class PdfViewModel( savedStateHandle[STATE_DOCUMENT_ORIENTATION_DEGREES] = value } - val pageFitMode: StateFlow = savedStateHandle.getStateFlow(STATE_PAGE_FIT_MODE, 1) + val pageFitMode: StateFlow = savedStateHandle.getStateFlow(STATE_PAGE_FIT_MODE, 2) fun setPageFitMode(value: Int) { savedStateHandle[STATE_PAGE_FIT_MODE] = value } val continuousMode: StateFlow = @@ -297,7 +297,7 @@ class PdfViewModel( _numPages.value = 0 _zoomRatio.value = 0f setDocumentOrientationDegrees(0) - setPageFitMode(1) + setPageFitMode(2) setContinuousMode(true) encryptedDocumentPassword = "" clearOutline() From a46c91e3dafd12ef35a79358d56c3e100794f41f Mon Sep 17 00:00:00 2001 From: Aljaz Ceru Date: Fri, 21 Aug 2026 06:32:23 +0200 Subject: [PATCH 08/11] Keep the previous frame on screen while a page re-render is in flight MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Zoom changes and other re-renders cleared the visible canvas before the asynchronous pdf.js render produced the new bitmap, leaving a blank page for the duration of the render. On slower devices (CI emulator) that window is long enough to be clearly visible — the exact 'suddenly a blank page' symptom from the PR review when panning a zoomed page. Render into an offscreen canvas and swap the finished bitmap into the visible canvas in one step instead: - renderPageContent renders into p.renderCanvas and copies it to the visible canvas when the render task completes - rerenderVisible no longer clears near pages; it just invalidates the rendered flag so the old frame stays visible until the swap - clearPage frees the offscreen target along with the visible canvas - new JS test: the visible canvas must never drop to width 0 while a re-render is in flight Fixes the CI failure in zoomedLastPage_canPanToBothHorizontalEdges ('Canvas was cleared while its zoomed content was visible') and the blank-page panning report on the upstream PR. --- viewer/js/index.js | 28 ++++++++++++++++++++++++---- viewer/js/index.test.js | 22 +++++++++++++++++++++- 2 files changed, 45 insertions(+), 5 deletions(-) diff --git a/viewer/js/index.js b/viewer/js/index.js index 586451be0..f5e250751 100644 --- a/viewer/js/index.js +++ b/viewer/js/index.js @@ -177,6 +177,10 @@ function clearPage(p) { p.rendered = false; p.canvas.width = 0; // free the backing store p.canvas.height = 0; + if (p.renderCanvas) { + p.renderCanvas.width = 0; // free the offscreen render target too + p.renderCanvas.height = 0; + } p.textLayer.replaceChildren(); } @@ -217,15 +221,28 @@ function renderPageContent(p, layout = null) { }); } - p.canvas.width = Math.floor(renderVp.width * ratio); - p.canvas.height = Math.floor(renderVp.height * ratio); - const ctx = p.canvas.getContext("2d", {alpha: false}); + // Double-buffer: render into an offscreen canvas and swap the finished + // bitmap into the visible canvas in one step. Allocating or clearing the + // visible canvas first blanks the page for the whole (asynchronous) render + // — very noticeable on slower devices when a zoomed page re-renders while + // panning. Keeping the previous frame until the swap avoids that. + const off = p.renderCanvas || (p.renderCanvas = document.createElement("canvas")); + off.width = Math.floor(renderVp.width * ratio); + off.height = Math.floor(renderVp.height * ratio); + const ctx = off.getContext("2d", {alpha: false}); ctx.scale(ratio, ratio); + p.textLayer.replaceChildren(); + const renderTask = p.pdfPage.render({canvasContext: ctx, viewport: renderVp}); p.task = renderTask; renderTask.promise.then(() => { if (gen !== p.renderGen) return; + if (p.canvas.width !== off.width || p.canvas.height !== off.height) { + p.canvas.width = off.width; + p.canvas.height = off.height; + } + p.canvas.getContext("2d", {alpha: false}).drawImage(off, 0, 0); p.zoom = renderedZoom; // pdf.js TextLayer reads --scale-factor from its container; set it before // construction so each page's text layer uses its own zoom. @@ -293,7 +310,10 @@ function rerenderVisible(layout = null) { const near = rect.bottom > -window.innerHeight * 1.5 && rect.top < window.innerHeight * 2.5; if (near) { - if (p.rendered) clearPage(p); + // Force a re-render at the new layout but keep the previous frame + // on screen: renderPageContent swaps in the new bitmap only after + // the render completes, so zoom changes never blank the page. + p.rendered = false; sizeWrapper(p, currentLayout); renderPageContent(p, currentLayout); } else { diff --git a/viewer/js/index.test.js b/viewer/js/index.test.js index 8f04b58e1..616b633f9 100644 --- a/viewer/js/index.test.js +++ b/viewer/js/index.test.js @@ -56,7 +56,7 @@ class FakeElement { } getContext() { - return { scale() {} }; + return { scale() {}, drawImage() {} }; } getBoundingClientRect() { @@ -276,6 +276,26 @@ describe("continuous page layout", () => { expect(pagesElement.children[4].style.height).toBe("200px"); }); + it("keeps the previous frame on screen while a re-render is in flight", async () => { + const { state, pagesElement } = await setupViewer({ pageCount: 2 }); + await flushPromises(); + const [canvas] = pagesElement.children[0].children; + expect(canvas.width).toBeGreaterThan(0); + + state.fitMode = 0; + state.zoom = 1; + globalThis.onRenderPage(2); + + // The new render has only just started; clearing the visible canvas + // now would blank the page until the render completes (upstream + // review: "viewer would very often suddenly become a blank page"). + expect(canvas.width).toBeGreaterThan(0); + + await flushPromises(); + expect(canvas.width).toBeGreaterThan(0); + expect(state.renderCalls.length).toBeGreaterThanOrEqual(2); + }); + it("publishes the fit zoom computed for the new layout", async () => { const { state } = await setupViewer({ pageCount: 1, fitMode: 1 }); state.fitMode = 2; From 95c8cb890bf986248b3289ef8cd9d6616ffcb352 Mon Sep 17 00:00:00 2001 From: Aljaz Ceru Date: Fri, 21 Aug 2026 07:22:50 +0200 Subject: [PATCH 09/11] Re-anchor scroll before re-rendering on zoom changes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In onRenderPage's zoom path, relayoutAll() resized every page wrapper and rerenderVisible() ran while the scroll position still described the old layout. The page under the viewport therefore measured as 'far' in the new geometry and was cleared, while unrelated pages nearer the stale offset were rendered instead — the viewport went blank exactly when a zoomed page re-rendered. On fast devices the follow-up render hides the gap; on slower ones (CI emulator, and per the upstream review report, real devices while panning) the blank page is clearly visible. Move the focus-preserving scrollBy ahead of rerenderVisible so near/far decisions are made against the re-anchored scroll position. Reproduced and verified on a local API 36 x86_64 emulator: zoomedLastPage_canPanToBothHorizontalEdges now passes, full instrumented suite green. --- viewer/js/index.js | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/viewer/js/index.js b/viewer/js/index.js index f5e250751..027ee6f3c 100644 --- a/viewer/js/index.js +++ b/viewer/js/index.js @@ -454,10 +454,17 @@ globalThis.onRenderPage = function (zoom) { // Placeholder geometry belongs to the requested zoom even when its // canvas is far enough away to remain unrendered. const layout = relayoutAll(); - rerenderVisible(layout); + // Re-anchor the scroll BEFORE re-rendering: relayoutAll() has just + // resized every wrapper, so the content that was under the viewport is + // now at a different offset. Measuring "near" pages against the stale + // scroll position makes rerenderVisible clear the very page being + // zoomed (it looks far away) and render unrelated ones instead — the + // viewer blanks exactly when a zoomed page re-renders. const translationFactor = (newZoom / prevZoom) - 1; globalThis.scrollBy(focusX * translationFactor, focusY * translationFactor); + + rerenderVisible(layout); return; } From f106a2ba752b1675edf3e02c58b5bd9ca93ae8c7 Mon Sep 17 00:00:00 2001 From: Aljaz Ceru Date: Tue, 25 Aug 2026 11:23:31 +0200 Subject: [PATCH 10/11] Preserve zoom focus and recover from unreadable pages --- viewer/js/index.js | 141 +++++++++++++++++++++++++----- viewer/js/index.test.js | 188 ++++++++++++++++++++++++++++++++++------ 2 files changed, 283 insertions(+), 46 deletions(-) diff --git a/viewer/js/index.js b/viewer/js/index.js index 027ee6f3c..7acdc9e04 100644 --- a/viewer/js/index.js +++ b/viewer/js/index.js @@ -28,6 +28,7 @@ let outlineAbort = new AbortController(); // pages[i] describes page (i+1): // { wrapper, canvas, textLayer, pdfPage, viewport, rendered, rendering, task } const pages = []; +const failedPages = new Set(); let zoomRatio = 0; // free-zoom ratio (0 = derive from fit mode) let orientationDegrees = 0; @@ -35,6 +36,7 @@ let lastReportedPage = 0; // last page pushed back to the ViewModel let renderObserver = null; let scrollTimer = null; let pageBuildGeneration = 0; +let pageBuildComplete = false; let pendingScrollPage = 0; const container = document.getElementById("container"); @@ -340,6 +342,85 @@ function mostVisiblePage() { return best; } +// Pick the displayed page containing the zoom focus, or the closest displayed +// page when the focus falls in the fixed gap between two wrappers. +function pageNearestViewportY(viewportY) { + let nearest = null; + let nearestDistance = Number.POSITIVE_INFINITY; + for (const p of pages) { + if (!p || p.wrapper.style.display === "none") continue; + const rect = p.wrapper.getBoundingClientRect(); + if (viewportY >= rect.top && viewportY <= rect.bottom) return p; + const distance = viewportY < rect.top + ? rect.top - viewportY + : viewportY - rect.bottom; + if (distance < nearestDistance) { + nearest = p; + nearestDistance = distance; + } + } + return nearest; +} + +function captureAxisAnchor(position, start, size) { + const end = start + size; + if (position < start) return {normalized: 0, offset: position - start}; + if (position > end) return {normalized: 1, offset: position - end}; + return {normalized: (position - start) / size, offset: 0}; +} + +function restoreAxisAnchor(anchor, start, size) { + return start + size * anchor.normalized + anchor.offset; +} + +function captureZoomAnchor(p, viewportX, viewportY) { + if (!p) return null; + const rect = p.canvas.getBoundingClientRect(); + if (rect.width <= 0 || rect.height <= 0) return null; + return { + page: p, + viewportX, + viewportY, + x: captureAxisAnchor(viewportX, rect.left, rect.width), + y: captureAxisAnchor(viewportY, rect.top, rect.height), + }; +} + +function restoreZoomAnchor(anchor) { + if (!anchor) return; + const rect = anchor.page.canvas.getBoundingClientRect(); + const anchoredX = restoreAxisAnchor(anchor.x, rect.left, rect.width); + const anchoredY = restoreAxisAnchor(anchor.y, rect.top, rect.height); + globalThis.scrollBy( + anchoredX - anchor.viewportX, + anchoredY - anchor.viewportY + ); +} + +function nearestAvailablePageNumber(pageNumber) { + let nearest = 0; + let nearestDistance = Number.POSITIVE_INFINITY; + for (const p of pages) { + if (!p) continue; + const candidate = Number(p.wrapper.dataset.page); + const distance = Math.abs(candidate - pageNumber); + if (distance < nearestDistance) { + nearest = candidate; + nearestDistance = distance; + } + } + return nearest; +} + +// A missing wrapper is normally just still being built. Once its page has +// failed (or setup has completed), redirect navigation to the nearest readable +// page instead of leaving scroll tracking locked on an impossible target. +function resolveScrollablePage(pageNumber) { + if (pages[pageNumber - 1]) return pageNumber; + if (!failedPages.has(pageNumber) && !pageBuildComplete) return pageNumber; + return nearestAvailablePageNumber(pageNumber); +} + // Exposed for instrumentation tests: the canvas / text layer of the page // currently most in view (continuous scroll has one per page). globalThis.currentPageCanvas = function () { @@ -359,8 +440,13 @@ function updateCurrentPage() { if (!best) return; const num = Number(best.wrapper.dataset.page); if (pendingScrollPage !== 0) { - if (!pages[pendingScrollPage - 1] || num !== pendingScrollPage) return; - pendingScrollPage = 0; + if (!pages[pendingScrollPage - 1]) { + if (!failedPages.has(pendingScrollPage) && !pageBuildComplete) return; + pendingScrollPage = 0; + } else { + if (num !== pendingScrollPage) return; + pendingScrollPage = 0; + } } const layout = readLayout(); const m = layout.mode; @@ -387,6 +473,12 @@ globalThis.scrollToPage = function (pageNumber) { if (!Number.isInteger(pageNumber) || pageNumber < 1 || (pdfDoc && pageNumber > pdfDoc.numPages)) return; + pageNumber = resolveScrollablePage(pageNumber); + if (pageNumber === 0) { + pendingScrollPage = 0; + return; + } + // Publish the requested page immediately, even if progressive page setup // has not reached it yet. Scroll tracking must not replace that request // while its wrapper is still being built. @@ -432,24 +524,22 @@ globalThis.onRenderPage = function (zoom) { // Adopt the new free-zoom ratio and re-render visible pages while // preserving the relevant focus point. const dpr = globalThis.devicePixelRatio; - const best = mostVisiblePage(); - // Rendering is asynchronous and is commonly cancelled by the next - // pinch event, so p.zoom can lag behind the ratio already requested. - const prevZoom = zoomRatio || (best ? (best.zoom || pageZoom(best.pdfPage)) : 1); - const newZoom = channel.getZoomRatio(); - zoomRatio = newZoom; - container.style.setProperty("--scale-factor", newZoom.toString()); - // Pinch zoom keeps the touch focus; menu zoom keeps the viewport center. - // Capture the focus in document coordinates before re-layout. const viewportFocusX = zoom === 3 ? globalThis.innerWidth / 2 : channel.getZoomFocusX() / dpr; const viewportFocusY = zoom === 3 ? globalThis.innerHeight / 2 : channel.getZoomFocusY() / dpr; - const focusX = viewportFocusX + globalThis.scrollX; - const focusY = viewportFocusY + globalThis.scrollY; + // Preserve a point on the actual focused canvas. Measuring it before + // and after layout accounts for fixed page gaps, horizontal centering, + // and preceding pages whose fit ratios differ from this page's ratio. + const anchorPage = pageNearestViewportY(viewportFocusY); + const anchor = captureZoomAnchor(anchorPage, viewportFocusX, viewportFocusY); + + const newZoom = channel.getZoomRatio(); + zoomRatio = newZoom; + container.style.setProperty("--scale-factor", newZoom.toString()); // Placeholder geometry belongs to the requested zoom even when its // canvas is far enough away to remain unrendered. @@ -461,8 +551,7 @@ globalThis.onRenderPage = function (zoom) { // scroll position makes rerenderVisible clear the very page being // zoomed (it looks far away) and render unrelated ones instead — the // viewer blanks exactly when a zoomed page re-renders. - const translationFactor = (newZoom / prevZoom) - 1; - globalThis.scrollBy(focusX * translationFactor, focusY * translationFactor); + restoreZoomAnchor(anchor); rerenderVisible(layout); return; @@ -476,14 +565,15 @@ globalThis.onRenderPage = function (zoom) { } // Read the Java-side target before geometry changes can make the scroll // handler report and overwrite a different most-visible page. - const target = channel.getPage(); + const requestedTarget = channel.getPage(); + const target = resolveScrollablePage(requestedTarget); const targetPage = pages[target - 1]; const anchorTop = targetPage && targetPage.wrapper.style.display !== "none" ? targetPage.wrapper.getBoundingClientRect().top : null; - const isPageNavigation = target !== lastReportedPage; + const isPageNavigation = target !== lastReportedPage || target !== requestedTarget; - if (!targetPage && isPageNavigation) { + if (target !== 0 && !targetPage && isPageNavigation) { pendingScrollPage = target; lastReportedPage = target; } @@ -611,8 +701,10 @@ globalThis.loadDocument = function () { } pages.length = 0; pagesEl.replaceChildren(); + failedPages.clear(); zoomRatio = 0; lastReportedPage = 0; + pageBuildComplete = false; pendingScrollPage = 0; const buildGeneration = ++pageBuildGeneration; const startPage = channel.getPage() || 1; @@ -661,6 +753,12 @@ async function buildPages(startPage, generation) { } catch (error) { if (generation !== pageBuildGeneration || documentToBuild !== pdfDoc) return; console.error(`getPage(${i}) error: ${error}`); + failedPages.add(i); + const requestedPage = channel.getPage() || startPage; + if (requestedPage === i) { + const fallback = nearestAvailablePageNumber(i); + if (fallback !== 0) globalThis.scrollToPage(fallback); + } continue; } if (generation !== pageBuildGeneration || documentToBuild !== pdfDoc) return; @@ -691,11 +789,12 @@ async function buildPages(startPage, generation) { } } + pageBuildComplete = true; const requestedPage = channel.getPage() || startPage; if (viewerReady && !pages[requestedPage - 1]) { - const fallback = pages.find((page) => page); - if (fallback) { - globalThis.scrollToPage(Number(fallback.wrapper.dataset.page)); + const fallback = nearestAvailablePageNumber(requestedPage); + if (fallback !== 0) { + globalThis.scrollToPage(fallback); updateCurrentPage(); rerenderVisible(); } diff --git a/viewer/js/index.test.js b/viewer/js/index.test.js index 616b633f9..bca5abc23 100644 --- a/viewer/js/index.test.js +++ b/viewer/js/index.test.js @@ -32,6 +32,10 @@ function styleDeclaration() { }; } +function px(value) { + return Number.parseFloat(value) || 0; +} + class FakeElement { constructor(tagName, environment) { this.tagName = tagName; @@ -59,21 +63,41 @@ class FakeElement { return { scale() {}, drawImage() {} }; } - getBoundingClientRect() { - if (this.style.display === "none") { - return { top: 0, bottom: 0, width: 0, height: 0 }; - } - const siblings = this.parentElement ? this.parentElement.children : []; - let documentTop = 0; - for (const sibling of siblings) { + documentTop() { + if (!this.parentElement) return 0; + const parentTop = this.parentElement.documentTop(); + if (this.parentElement.className === "page-wrapper") return parentTop; + + let top = parentTop; + for (const sibling of this.parentElement.children) { if (sibling === this) break; if (sibling.style.display !== "none") { - documentTop += Number.parseFloat(sibling.style.height) || 0; + top += px(sibling.style.height); + if (sibling.className === "page-wrapper") { + top += this.environment.pageGap; + } } } - const height = Number.parseFloat(this.style.height) || 0; - const top = documentTop - globalThis.scrollY; - return { top, bottom: top + height, height, width: Number.parseFloat(this.style.width) || 0 }; + return top; + } + + documentLeft() { + if (!this.parentElement) return 0; + const parentLeft = this.parentElement.documentLeft(); + return this.parentElement.className === "page-wrapper" + ? parentLeft + px(this.style.marginLeft) + : parentLeft; + } + + getBoundingClientRect() { + if (this.style.display === "none") { + return { top: 0, bottom: 0, left: 0, right: 0, width: 0, height: 0 }; + } + const height = px(this.style.height); + const width = px(this.style.width); + const top = this.documentTop() - globalThis.scrollY; + const left = this.documentLeft() - globalThis.scrollX; + return { top, bottom: top + height, left, right: left + width, height, width }; } scrollIntoView() { @@ -116,6 +140,9 @@ async function setupViewer({ continuous = true, currentPage = 1, fitMode = 1, + focusX = 50, + focusY = 20, + pageGap = 0, pageCount = 3, getPage, } = {}) { @@ -124,6 +151,8 @@ async function setupViewer({ continuous, currentPage, fitMode, + focusX, + focusY, zoom: 0.5, orientation: 0, renderCalls: [], @@ -131,7 +160,7 @@ async function setupViewer({ scrolledPages: [], zoomReports: [], }; - const environment = { scrolledPages: state.scrolledPages }; + const environment = { pageGap, scrolledPages: state.scrolledPages }; const container = new FakeElement("div", environment); const pages = new FakeElement("div", environment); container.appendChild(pages); @@ -175,8 +204,8 @@ async function setupViewer({ getDocumentOrientationDegrees: () => state.orientation, getMaxRenderPixels: () => 10_000_000, getZoomRatio: () => state.zoom, - getZoomFocusX: () => 10, - getZoomFocusY: () => 20, + getZoomFocusX: () => state.focusX, + getZoomFocusY: () => state.focusY, setZoomRatio: (zoom) => { state.zoom = zoom; state.zoomReports.push(zoom); @@ -219,32 +248,116 @@ describe("continuous page layout", () => { expect(state.renderCalls).toEqual([2]); }); - it("compensates each pinch event from the previously requested zoom", async () => { - const { state } = await setupViewer({ continuous: false, currentPage: 1 }); + it("keeps the focused canvas point fixed across pinch events", async () => { + const { state, pagesElement } = await setupViewer({ + continuous: false, + currentPage: 1, + }); + const canvas = pagesElement.children[0].children[0]; + const before = canvas.getBoundingClientRect(); + const normalizedX = (state.focusX - before.left) / before.width; + const normalizedY = (state.focusY - before.top) / before.height; state.fitMode = 0; state.zoom = 0.6; globalThis.onRenderPage(2); + let after = canvas.getBoundingClientRect(); + expect(after.left + after.width * normalizedX).toBeCloseTo(state.focusX); + expect(after.top + after.height * normalizedY).toBeCloseTo(state.focusY); + state.zoom = 0.7; globalThis.onRenderPage(2); - globalThis.onRenderPage(1); + after = canvas.getBoundingClientRect(); + expect(after.left + after.width * normalizedX).toBeCloseTo(state.focusX); + expect(after.top + after.height * normalizedY).toBeCloseTo(state.focusY); - expect(state.scrollCalls).toHaveLength(3); - expect(state.scrollCalls[0][0]).toBeCloseTo(2); - expect(state.scrollCalls[0][1]).toBeCloseTo(4); - expect(state.scrollCalls[1][0]).toBeCloseTo(2); - expect(state.scrollCalls[1][1]).toBeCloseTo(4); - expect(state.scrollCalls[2]).toEqual([0, 0]); + globalThis.onRenderPage(1); + after = canvas.getBoundingClientRect(); + expect(after.left + after.width * normalizedX).toBeCloseTo(state.focusX); + expect(after.top + after.height * normalizedY).toBeCloseTo(state.focusY); }); it("keeps menu zoom focused on the viewport center", async () => { - const { state } = await setupViewer({ continuous: false, currentPage: 1 }); + const { state, pagesElement } = await setupViewer({ + continuous: false, + currentPage: 1, + }); + const canvas = pagesElement.children[0].children[0]; + const before = canvas.getBoundingClientRect(); + const normalizedX = (globalThis.innerWidth / 2 - before.left) / before.width; + const normalizedY = (globalThis.innerHeight / 2 - before.top) / before.height; state.fitMode = 0; state.zoom = 1; globalThis.onRenderPage(3); - expect(state.scrollCalls.at(-1)).toEqual([50, 50]); + const after = canvas.getBoundingClientRect(); + expect(after.left + after.width * normalizedX).toBeCloseTo(globalThis.innerWidth / 2); + expect(after.top + after.height * normalizedY).toBeCloseTo(globalThis.innerHeight / 2); + }); + + it("keeps a deep mixed-size page point fixed across zoom", async () => { + const pagePatterns = [ + {width: 100, height: 100}, + {width: 200, height: 100}, + {width: 50, height: 200}, + {width: 100, height: 200}, + ]; + const dimensions = Array.from( + {length: 12}, + (_, index) => pagePatterns[index % pagePatterns.length] + ); + const { state, pagesElement } = await setupViewer({ + continuous: true, + currentPage: dimensions.length, + fitMode: 2, + pageCount: dimensions.length, + pageGap: 14, + getPage: (pageNumber, viewerState) => Promise.resolve( + fakePage(pageNumber, viewerState, dimensions[pageNumber - 1]) + ), + }); + const canvas = pagesElement.children.at(-1).children[0]; + const before = canvas.getBoundingClientRect(); + const normalizedX = (state.focusX - before.left) / before.width; + const normalizedY = (state.focusY - before.top) / before.height; + state.fitMode = 0; + state.zoom = 2; + + globalThis.onRenderPage(2); + + const after = canvas.getBoundingClientRect(); + expect(after.left + after.width * normalizedX).toBeCloseTo(state.focusX); + expect(after.top + after.height * normalizedY).toBeCloseTo(state.focusY); + }); + + it("keeps a focus in a fixed page gap at the same edge distance", async () => { + const { state, pagesElement } = await setupViewer({ + continuous: true, + currentPage: 2, + fitMode: 2, + focusY: 18, + pageCount: 2, + pageGap: 14, + getPage: (pageNumber, viewerState) => Promise.resolve( + fakePage(pageNumber, viewerState, {width: 100, height: 50}) + ), + }); + const firstCanvas = pagesElement.children[0].children[0]; + const secondCanvas = pagesElement.children[1].children[0]; + const firstBefore = firstCanvas.getBoundingClientRect(); + const secondBefore = secondCanvas.getBoundingClientRect(); + const distanceFromFirst = state.focusY - firstBefore.bottom; + const distanceFromSecond = secondBefore.top - state.focusY; + state.fitMode = 0; + state.zoom = 2; + + globalThis.onRenderPage(2); + + const firstAfter = firstCanvas.getBoundingClientRect(); + const secondAfter = secondCanvas.getBoundingClientRect(); + expect(state.focusY - firstAfter.bottom).toBeCloseTo(distanceFromFirst); + expect(secondAfter.top - state.focusY).toBeCloseTo(distanceFromSecond); }); it("keeps over-wide pages within the horizontally scrollable area", async () => { @@ -361,6 +474,31 @@ describe("continuous page layout", () => { expect(pagesElement.children.map((page) => page.dataset.page)).toEqual(["1", "3"]); }); + it("redirects a failed page request and keeps scroll tracking live", async () => { + const { state } = await setupViewer({ + pageCount: 3, + getPage: (pageNumber, viewerState) => pageNumber === 2 + ? Promise.reject(new Error("damaged page")) + : Promise.resolve(fakePage(pageNumber, viewerState)), + }); + + globalThis.scrollToPage(3); + expect(state.currentPage).toBe(3); + + // Android publishes the requested page before invoking onRenderPage. + // The failed target must redirect by page proximity, not merely keep + // whichever readable page happens to be visible. + state.currentPage = 2; + globalThis.onRenderPage(0); + expect(state.currentPage).toBe(1); + + globalThis.scrollBy(0, 100); + globalThis.onscroll(); + await delay(200); + + expect(state.currentPage).toBe(3); + }); + it("keeps a late-page request sticky until progressive setup reaches it", async () => { let resolveSecondPage; const secondPage = new Promise((resolve) => { From 4864d6e87e2dacc4726ae1b75ca4b9b0de672a09 Mon Sep 17 00:00:00 2001 From: Aljaz Ceru Date: Sat, 12 Sep 2026 18:39:03 +0200 Subject: [PATCH 11/11] cleanup of comments, adding named constants, addressing other comments --- .../pdfviewer/test/PdfViewerBigDocTest.kt | 2 +- .../grapheneos/pdfviewer/PdfViewerScreen.kt | 36 ++++++++----- .../pdfviewer/viewModel/PdfViewModel.kt | 9 +++- viewer/js/index.js | 52 +++++++++++-------- 4 files changed, 60 insertions(+), 39 deletions(-) diff --git a/app/src/androidTest/kotlin/app/grapheneos/pdfviewer/test/PdfViewerBigDocTest.kt b/app/src/androidTest/kotlin/app/grapheneos/pdfviewer/test/PdfViewerBigDocTest.kt index 592f2a548..dbdd978cf 100644 --- a/app/src/androidTest/kotlin/app/grapheneos/pdfviewer/test/PdfViewerBigDocTest.kt +++ b/app/src/androidTest/kotlin/app/grapheneos/pdfviewer/test/PdfViewerBigDocTest.kt @@ -200,7 +200,7 @@ class PdfViewerBigDocTest { robot.getPageFitMode(scenario) == 0 } - // fit width -> free again (review P2: must not reuse stale ratio / jump to MIN) + // fit width -> free again (must not reuse stale ratio / jump to MIN) robot.clickFitWidth() PdfViewerTestUtils.pollUntil(timeout = 8_000, description = { "not fit-width" }) { robot.getPageFitMode(scenario) == 2 diff --git a/app/src/main/java/app/grapheneos/pdfviewer/PdfViewerScreen.kt b/app/src/main/java/app/grapheneos/pdfviewer/PdfViewerScreen.kt index cd8ac0119..08024d45e 100644 --- a/app/src/main/java/app/grapheneos/pdfviewer/PdfViewerScreen.kt +++ b/app/src/main/java/app/grapheneos/pdfviewer/PdfViewerScreen.kt @@ -122,6 +122,9 @@ import app.grapheneos.pdfviewer.outline.OutlineScreen import app.grapheneos.pdfviewer.properties.DocumentProperty import app.grapheneos.pdfviewer.ui.darkTopAppBarColors import app.grapheneos.pdfviewer.viewModel.PdfViewModel +import app.grapheneos.pdfviewer.viewModel.PdfViewModel.Companion.FIT_MODE_FREE +import app.grapheneos.pdfviewer.viewModel.PdfViewModel.Companion.FIT_MODE_PAGE +import app.grapheneos.pdfviewer.viewModel.PdfViewModel.Companion.FIT_MODE_WIDTH import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow @@ -136,6 +139,11 @@ import kotlin.math.roundToInt private const val TAG = "PdfViewerScreen" private const val MIN_WEBVIEW_RELEASE = 133 +// Keep these values in sync with the render-reason constants in viewer/js/index.js. +private const val RENDER_RELAYOUT = 0 +private const val RENDER_PINCH_END = 1 +private const val RENDER_PINCH_UPDATE = 2 +private const val RENDER_MENU_ZOOM = 3 private val ZOOM_PRESETS = intArrayOf(25, 50, 75, 100, 125, 150, 200, 300, 500, 750, 1000) private fun nextZoomPreset(ratio: Float): Float? { @@ -349,11 +357,11 @@ fun PdfViewerScreen( fun dispatchPendingZoomRender() { if (zoomRenderInFlight || !zoomRenderPending) return - val zoom = if (zoomRenderEndPending) 1 else 2 + val renderReason = if (zoomRenderEndPending) RENDER_PINCH_END else RENDER_PINCH_UPDATE zoomRenderPending = false zoomRenderEndPending = false zoomRenderInFlight = true - wv.evaluateJavascript("onRenderPage($zoom)") { + wv.evaluateJavascript("onRenderPage($renderReason)") { zoomRenderInFlight = false dispatchPendingZoomRender() } @@ -401,7 +409,7 @@ fun PdfViewerScreen( } override fun onZoom(scaleFactor: Float, focusX: Float, focusY: Float) { - viewModel.setPageFitMode(0) + viewModel.setPageFitMode(FIT_MODE_FREE) viewModel.setZoomRatio( (viewModel.zoomRatio.value * scaleFactor) .coerceIn(MIN_ZOOM_RATIO, MAX_ZOOM_RATIO) @@ -552,9 +560,9 @@ fun PdfViewerScreen( onFirst = { jumpToPage(viewModel, webView, 1) }, onLast = { jumpToPage(viewModel, webView, numPages) }, onJumpToPage = { showJumpToPage = true }, - onFitFree = { setPageFitMode(viewModel, webView, 0) }, - onFitPage = { setPageFitMode(viewModel, webView, 1) }, - onFitWidth = { setPageFitMode(viewModel, webView, 2) }, + onFitFree = { setPageFitMode(viewModel, webView, FIT_MODE_FREE) }, + onFitPage = { setPageFitMode(viewModel, webView, FIT_MODE_PAGE) }, + onFitWidth = { setPageFitMode(viewModel, webView, FIT_MODE_WIDTH) }, onContinuousModeChange = { setContinuousMode(viewModel, webView, !continuousMode) }, @@ -799,7 +807,7 @@ internal fun jumpToPage(viewModel: PdfViewModel, webView: WebView?, selectedPage val num = viewModel.numPages.value if (selectedPage in 1..num && viewModel.page.value != selectedPage) { viewModel.setPage(selectedPage) - webView.evaluateJavascript("onRenderPage(0)", null) + webView.evaluateJavascript("onRenderPage($RENDER_RELAYOUT)", null) viewModel.showPageIndicator() } } @@ -808,7 +816,7 @@ private fun setPageFitMode(viewModel: PdfViewModel, webView: WebView?, mode: Int webView ?: return viewModel.setPageFitMode(mode) viewModel.setZoomRatio(0f) - webView.evaluateJavascript("onRenderPage(0)", null) + webView.evaluateJavascript("onRenderPage($RENDER_RELAYOUT)", null) } private fun setContinuousMode(viewModel: PdfViewModel, webView: WebView?, enabled: Boolean) { @@ -822,14 +830,14 @@ private fun rotateDocument(viewModel: PdfViewModel, webView: WebView?, offset: I var degrees = (viewModel.documentOrientationDegrees.value + offset) % 360 if (degrees < 0) degrees += 360 viewModel.setDocumentOrientationDegrees(degrees) - webView.evaluateJavascript("onRenderPage(0)", null) + webView.evaluateJavascript("onRenderPage($RENDER_RELAYOUT)", null) } private fun zoomDocument(viewModel: PdfViewModel, webView: WebView?, ratio: Float) { webView ?: return - viewModel.setPageFitMode(0) + viewModel.setPageFitMode(FIT_MODE_FREE) viewModel.setZoomRatio(ratio.coerceIn(MIN_ZOOM_RATIO, MAX_ZOOM_RATIO)) - webView.evaluateJavascript("onRenderPage(3)", null) + webView.evaluateJavascript("onRenderPage($RENDER_MENU_ZOOM)", null) } private fun shareDocument(context: Context, viewModel: PdfViewModel) { @@ -969,7 +977,7 @@ private fun PdfTopAppBar( onClick = { onMenuToggle(false); onFitFree() }, enabled = enabled, leadingIcon = { - if (pageFitMode == 0) { + if (pageFitMode == FIT_MODE_FREE) { Icon(Icons.Default.Check, contentDescription = null) } } @@ -979,7 +987,7 @@ private fun PdfTopAppBar( onClick = { onMenuToggle(false); onFitPage() }, enabled = enabled, leadingIcon = { - if (pageFitMode == 1) { + if (pageFitMode == FIT_MODE_PAGE) { Icon(Icons.Default.Check, contentDescription = null) } } @@ -989,7 +997,7 @@ private fun PdfTopAppBar( onClick = { onMenuToggle(false); onFitWidth() }, enabled = enabled, leadingIcon = { - if (pageFitMode == 2) { + if (pageFitMode == FIT_MODE_WIDTH) { Icon(Icons.Default.Check, contentDescription = null) } } diff --git a/app/src/main/java/app/grapheneos/pdfviewer/viewModel/PdfViewModel.kt b/app/src/main/java/app/grapheneos/pdfviewer/viewModel/PdfViewModel.kt index 166015b39..d5cb9dce7 100644 --- a/app/src/main/java/app/grapheneos/pdfviewer/viewModel/PdfViewModel.kt +++ b/app/src/main/java/app/grapheneos/pdfviewer/viewModel/PdfViewModel.kt @@ -38,6 +38,11 @@ class PdfViewModel( ) : AndroidViewModel(application) { companion object { + // Keep these values in sync with the fit-mode constants in viewer/js/index.js. + const val FIT_MODE_FREE = 0 + const val FIT_MODE_PAGE = 1 + const val FIT_MODE_WIDTH = 2 + private const val STATE_URI: String = "uri" private const val STATE_PAGE: String = "page" private const val STATE_DOCUMENT_ORIENTATION_DEGREES: String = "documentOrientationDegrees" @@ -59,7 +64,7 @@ class PdfViewModel( savedStateHandle[STATE_DOCUMENT_ORIENTATION_DEGREES] = value } - val pageFitMode: StateFlow = savedStateHandle.getStateFlow(STATE_PAGE_FIT_MODE, 2) + val pageFitMode: StateFlow = savedStateHandle.getStateFlow(STATE_PAGE_FIT_MODE, FIT_MODE_WIDTH) fun setPageFitMode(value: Int) { savedStateHandle[STATE_PAGE_FIT_MODE] = value } val continuousMode: StateFlow = @@ -297,7 +302,7 @@ class PdfViewModel( _numPages.value = 0 _zoomRatio.value = 0f setDocumentOrientationDegrees(0) - setPageFitMode(2) + setPageFitMode(FIT_MODE_WIDTH) setContinuousMode(true) encryptedDocumentPassword = "" clearOutline() diff --git a/viewer/js/index.js b/viewer/js/index.js index 7acdc9e04..69422cb9e 100644 --- a/viewer/js/index.js +++ b/viewer/js/index.js @@ -16,11 +16,20 @@ GlobalWorkerOptions.workerSrc = "/viewer/js/worker.js"; // the viewport and cleared when far away, so memory stays bounded on large // documents. // -// CSP note (style-src 'self', no unsafe-inline): every dynamic style is set -// via an IDL property write (el.style.x = ...) or a CSS custom property -// (setProperty). We never set the style attribute directly, never assign cssText, -// never create a style element, and never use an inline style attribute in HTML -// — those are exactly what CSP blocks here. See check-csp.mjs which enforces it. +// Set dynamic styles through individual DOM style properties to comply with CSP. + +// Keep these values in sync with PdfViewModel's page-fit constants. +const FitMode = Object.freeze({ + FREE: 0, + PAGE: 1, + WIDTH: 2, +}); + +// Keep these values in sync with PdfViewerScreen's render-reason constants. +const RENDER_RELAYOUT = 0; +const RENDER_PINCH_END = 1; +const RENDER_PINCH_UPDATE = 2; +const RENDER_MENU_ZOOM = 3; let pdfDoc = null; let outlineAbort = new AbortController(); @@ -63,7 +72,6 @@ function clampZoom(value, layout = null) { } function fitMode() { - // 0 = free zoom, 1 = fit page, 2 = fit width return channel.getPageFitMode(); } @@ -112,13 +120,13 @@ function totalRotation(pdfPage) { // Zoom ratio for a page under the active fit mode, or the free-zoom ratio. function pageZoom(pdfPage, layout = null) { const m = layout ? layout.mode : fitMode(); - if (m !== 0 || zoomRatio === 0) { + if (m !== FitMode.FREE || zoomRatio === 0) { const vp1 = pdfPage.getViewport({scale: 1, rotation: totalRotation(pdfPage)}); const a = availSize(layout); const wZoom = a.width / vp1.width; const hZoom = a.height / vp1.height; - // fit width (2) fills width; fit page (1) and free-default both fit page. - const z = (m === 2) ? wZoom : Math.min(wZoom, hZoom); + // Fit width fills the width; fit page and the free-zoom default fit the page. + const z = (m === FitMode.WIDTH) ? wZoom : Math.min(wZoom, hZoom); return clampZoom(z, layout); } return clampZoom(zoomRatio, layout); @@ -191,7 +199,7 @@ function renderPageContent(p, layout = null) { // Cancel any in-flight render so we restart at the current viewport: during // a multi-event pinch the viewport changes on every event, and an unchecked // in-flight task would otherwise complete with a stale viewport (stretched - // bitmap, misaligned text) — see review P1. + // bitmap, misaligned text). if (p.task) { try { p.task.cancel(); @@ -450,7 +458,7 @@ function updateCurrentPage() { } const layout = readLayout(); const m = layout.mode; - if (m === 0 && zoomRatio !== 0) { + if (m === FitMode.FREE && zoomRatio !== 0) { // Free zoom: the ViewModel zoom (driven by the pinch handler) is // authoritative — reflect it on the container, never overwrite it. container.style.setProperty("--scale-factor", zoomRatio.toString()); @@ -515,20 +523,20 @@ globalThis.scrollToPage = function (pageNumber) { } }; -// Driven from the Java side (former single-page render entry point). -// zoom: 0 = full re-layout, 1 = pinch end, 2 = pinching, 3 = menu zoom -globalThis.onRenderPage = function (zoom) { +// Driven from the Android side. +globalThis.onRenderPage = function (renderReason = RENDER_RELAYOUT) { orientationDegrees = channel.getDocumentOrientationDegrees(); - if (zoom === 1 || zoom === 2 || zoom === 3) { + if (renderReason === RENDER_PINCH_END || renderReason === RENDER_PINCH_UPDATE || + renderReason === RENDER_MENU_ZOOM) { // Adopt the new free-zoom ratio and re-render visible pages while // preserving the relevant focus point. const dpr = globalThis.devicePixelRatio; // Pinch zoom keeps the touch focus; menu zoom keeps the viewport center. - const viewportFocusX = zoom === 3 + const viewportFocusX = renderReason === RENDER_MENU_ZOOM ? globalThis.innerWidth / 2 : channel.getZoomFocusX() / dpr; - const viewportFocusY = zoom === 3 + const viewportFocusY = renderReason === RENDER_MENU_ZOOM ? globalThis.innerHeight / 2 : channel.getZoomFocusY() / dpr; // Preserve a point on the actual focused canvas. Measuring it before @@ -557,10 +565,10 @@ globalThis.onRenderPage = function (zoom) { return; } - // zoom === 0: a fit-mode / orientation / page change. - if (fitMode() !== 0) { - // a fit mode owns the zoom now; drop stale free-zoom state so that - // re-entering Free zoom re-derives instead of reusing it (review P2). + // RENDER_RELAYOUT handles fit-mode, orientation, and page changes. + if (fitMode() !== FitMode.FREE) { + // An active fit mode owns the zoom. Clear the cached free-zoom ratio + // so returning to free zoom derives a fresh ratio. zoomRatio = 0; } // Read the Java-side target before geometry changes can make the scroll @@ -687,7 +695,7 @@ globalThis.loadDocument = function () { // Apply the saved document rotation before sizing any page, otherwise // every wrapper is built at rotation 0 and only nearby pages get - // corrected later (review P2 rotation). + // corrected later. orientationDegrees = channel.getDocumentOrientationDegrees(); // Reset continuous-scroll state — loadDocument runs again when opening a