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 000000000..325ad3ec6 Binary files /dev/null and b/app/src/androidTest/assets/test-large.pdf differ diff --git a/app/src/androidTest/kotlin/app/grapheneos/pdfviewer/PdfViewerTestAccessors.kt b/app/src/androidTest/kotlin/app/grapheneos/pdfviewer/PdfViewerTestAccessors.kt index 01895ef45..ecc2d43dc 100644 --- a/app/src/androidTest/kotlin/app/grapheneos/pdfviewer/PdfViewerTestAccessors.kt +++ b/app/src/androidTest/kotlin/app/grapheneos/pdfviewer/PdfViewerTestAccessors.kt @@ -39,6 +39,18 @@ var PdfViewer.documentName: String viewModel.setDocumentNameForTest(value) } +var PdfViewer.pageFitMode: Int + get() = viewModel.pageFitMode.value + set(value) { + viewModel.setPageFitMode(value) + } + +var PdfViewer.continuousMode: Boolean + get() = viewModel.continuousMode.value + set(value) { + viewModel.setContinuousMode(value) + } + var PdfViewer.outlineStatus: PdfViewModel.OutlineStatus get() = viewModel.outline.value 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..dbdd978cf --- /dev/null +++ b/app/src/androidTest/kotlin/app/grapheneos/pdfviewer/test/PdfViewerBigDocTest.kt @@ -0,0 +1,248 @@ +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.RetryableComposeRule +import app.grapheneos.pdfviewer.currentPage +import app.grapheneos.pdfviewer.totalPages +import app.grapheneos.pdfviewer.testrules.OrientationRules +import app.grapheneos.pdfviewer.testrules.RetryRules +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.Before +import org.junit.Rule +import org.junit.Test +import org.junit.rules.RuleChain +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 { + + private val composeRule = RetryableComposeRule() + + @get:Rule + val rules: RuleChain = RuleChain + .outerRule(RetryRules()) + .around(OrientationRules()) + .around(composeRule) + + private val robot = PdfViewerRobot(composeRule) + + @Before + fun setup() { + PdfViewerTestUtils.init(composeRule) + } + + @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 } + + robot.clickContinuousScroll() + PdfViewerTestUtils.pollUntil( + timeout = 5_000, + description = { "single-page mode should show one page" } + ) { displayedCount(scenario) == 1 } + + 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 (must not reuse stale ratio / jump to MIN) + robot.clickFitWidth() + PdfViewerTestUtils.pollUntil(timeout = 8_000, description = { "not fit-width" }) { + robot.getPageFitMode(scenario) == 2 + } + 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..7a41f9819 --- /dev/null +++ b/app/src/androidTest/kotlin/app/grapheneos/pdfviewer/test/PdfViewerContinuousModeTest.kt @@ -0,0 +1,115 @@ +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.RetryableComposeRule +import app.grapheneos.pdfviewer.continuousMode +import app.grapheneos.pdfviewer.currentPage +import app.grapheneos.pdfviewer.testrules.OrientationRules +import app.grapheneos.pdfviewer.testrules.RetryRules +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.Before +import org.junit.Rule +import org.junit.Test +import org.junit.rules.RuleChain +import org.junit.runner.RunWith + +/** + * Continuous vs single-page view toggle. + */ +@RunWith(AndroidJUnit4::class) +class PdfViewerContinuousModeTest { + + private val composeRule = RetryableComposeRule() + + @get:Rule + val rules: RuleChain = RuleChain + .outerRule(RetryRules()) + .around(OrientationRules()) + .around(composeRule) + + private val robot = PdfViewerRobot(composeRule) + + @Before + fun setup() { + PdfViewerTestUtils.init(composeRule) + } + + @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. + 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. + 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..712ec415d --- /dev/null +++ b/app/src/androidTest/kotlin/app/grapheneos/pdfviewer/test/PdfViewerContinuousScrollTest.kt @@ -0,0 +1,163 @@ +package app.grapheneos.pdfviewer.test + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import app.grapheneos.pdfviewer.RetryableComposeRule +import app.grapheneos.pdfviewer.pageFitMode +import app.grapheneos.pdfviewer.testrules.OrientationRules +import app.grapheneos.pdfviewer.testrules.RetryRules +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.Before +import org.junit.Rule +import org.junit.Test +import org.junit.rules.RuleChain +import org.junit.runner.RunWith + +/** + * Page fitting mode and zoom integration tests. + */ +@RunWith(AndroidJUnit4::class) +class PdfViewerPageFitModeTest { + + private val composeRule = RetryableComposeRule() + + @get:Rule + val rules: RuleChain = RuleChain + .outerRule(RetryRules()) + .around(OrientationRules()) + .around(composeRule) + + private val robot = PdfViewerRobot(composeRule) + + @Before + fun setup() { + PdfViewerTestUtils.init(composeRule) + } + + @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) + + 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) + + robot.assertMenuItemVisible( + PdfViewerRobot.AppMenuItem.FitFree, expected = true + ) + robot.assertMenuItemVisible( + PdfViewerRobot.AppMenuItem.FitPage, expected = true + ) + robot.assertMenuItemVisible( + 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 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/test/PdfViewerLandscapeTest.kt b/app/src/androidTest/kotlin/app/grapheneos/pdfviewer/test/PdfViewerLandscapeTest.kt index fa5f937df..00e779003 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 f19136b2b..17a1d67ed 100644 --- a/app/src/androidTest/kotlin/app/grapheneos/pdfviewer/test/PdfViewerMenuStateTest.kt +++ b/app/src/androidTest/kotlin/app/grapheneos/pdfviewer/test/PdfViewerMenuStateTest.kt @@ -41,6 +41,9 @@ class PdfViewerMenuStateTest { fun preLoadState_navigationItemsNotShown() { PdfViewerLauncher.launchDefault().use { robot.assertNavigationNotShown() + robot.assertMenuItemVisible( + PdfViewerRobot.AppMenuItem.FitFree, expected = false + ) } } @@ -108,6 +111,12 @@ class PdfViewerMenuStateTest { robot.assertMenuItemEnabled(PdfViewerRobot.AppMenuItem.Share, expected = false) robot.assertMenuItemEnabled(PdfViewerRobot.AppMenuItem.SaveAs, expected = false) robot.assertMenuItemEnabled(PdfViewerRobot.AppMenuItem.JumpToPage, expected = false) + robot.assertMenuItemEnabled(PdfViewerRobot.AppMenuItem.FitFree, expected = false) + robot.assertMenuItemEnabled(PdfViewerRobot.AppMenuItem.FitPage, expected = false) + robot.assertMenuItemEnabled(PdfViewerRobot.AppMenuItem.FitWidth, expected = false) + robot.assertMenuItemEnabled( + PdfViewerRobot.AppMenuItem.ContinuousScroll, expected = false + ) } } 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..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 -> @@ -120,6 +138,43 @@ 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") + + robot.clickZoomPercentage() + robot.setCustomZoomValue(300) + robot.clickDialogOk() + PdfViewerTestUtils.pollUntil(description = { "Canvas did not become over-wide" }) { + robot.getCanvasCssWidth(scenario) > robot.getViewportWidth(scenario) * 2 + } + + 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) + PdfViewerTestUtils.assertStableCondition( + description = { "Canvas was cleared while its zoomed content was visible" } + ) { + robot.getCanvasWidth(scenario) > 0 && robot.getCanvasHeight(scenario) > 0 + } + } + } + // Outline @Test 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 d37f6d200..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 @@ -96,6 +97,26 @@ class PdfViewerNavigationTest { } } + @Test + fun horizontalFlingAtPageEdge_navigatesToNextPage() { + 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() + + 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 2fc2c4dbb..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 @@ -75,7 +76,11 @@ class PdfViewerRobot(private val composeRule: ComposeTestRule) { Share(R.string.action_share), SaveAs(R.string.action_save_as), Outline(R.string.action_outline), - ViewDocumentProperties(R.string.action_view_document_properties) + ViewDocumentProperties(R.string.action_view_document_properties), + FitFree(R.string.action_fit_free), + FitPage(R.string.action_fit_page), + FitWidth(R.string.action_fit_width), + ContinuousScroll(R.string.action_continuous_scroll) } enum class SnackbarMessage(@StringRes internal val stringRes: Int) { @@ -224,9 +229,25 @@ class PdfViewerRobot(private val composeRule: ComposeTestRule) { fun tapWebView() { onView(isAssignableFrom(WebView::class.java)).perform(click()) } + fun flingToNextPage() { + val device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) + val bounds = findWebViewObject().visibleBounds + val horizontalMargin = maxOf(1, bounds.width() / 10) + device.swipe( + bounds.right - horizontalMargin, + bounds.centerY(), + bounds.left + horizontalMargin, + bounds.centerY(), + 5 + ) + } fun clickRotateClockwise() = click(AppMenuItem.RotateClockwise) fun clickRotateCounterclockwise() = click(AppMenuItem.RotateCounterclockwise) + fun clickFitFree() = click(AppMenuItem.FitFree) + fun clickFitPage() = click(AppMenuItem.FitPage) + fun clickFitWidth() = click(AppMenuItem.FitWidth) + fun clickContinuousScroll() = click(AppMenuItem.ContinuousScroll) // JumpToPage @@ -322,8 +343,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") } @@ -339,28 +360,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() } @@ -453,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 @@ -560,6 +587,30 @@ class PdfViewerRobot(private val composeRule: ComposeTestRule) { 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)" + ) + } + private fun ensureOverflowMenuOpen() { val zoomInDesc = getTargetContext().getString(R.string.zoom_in) val nodes = composeRule.onAllNodesWithContentDescription(zoomInDesc) @@ -634,9 +685,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..2e049d760 100644 --- a/app/src/androidTest/kotlin/app/grapheneos/pdfviewer/util/PdfViewerTestUtils.kt +++ b/app/src/androidTest/kotlin/app/grapheneos/pdfviewer/util/PdfViewerTestUtils.kt @@ -10,6 +10,7 @@ import androidx.test.core.app.ActivityScenario import androidx.test.platform.app.InstrumentationRegistry import androidx.test.uiautomator.UiDevice import app.grapheneos.pdfviewer.PdfViewer +import app.grapheneos.pdfviewer.currentPage import app.grapheneos.pdfviewer.documentProperties import app.grapheneos.pdfviewer.outlineStatus import app.grapheneos.pdfviewer.totalPages @@ -124,10 +125,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 +154,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 +166,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 +252,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 +273,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 +367,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); @@ -391,4 +392,35 @@ 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)" } + ) { + evaluateJs( + scenario, + "document.querySelectorAll('.page-wrapper').length" + ).toIntOrNull() == expectedCount + } + } } diff --git a/app/src/main/java/app/grapheneos/pdfviewer/PdfJsChannel.kt b/app/src/main/java/app/grapheneos/pdfviewer/PdfJsChannel.kt index 7537e4198..679cfb4c7 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.value @@ -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 689c78b97..08024d45e 100644 --- a/app/src/main/java/app/grapheneos/pdfviewer/PdfViewerScreen.kt +++ b/app/src/main/java/app/grapheneos/pdfviewer/PdfViewerScreen.kt @@ -45,6 +45,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 @@ -121,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 @@ -135,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? { @@ -241,6 +250,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() @@ -339,6 +350,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 renderReason = if (zoomRenderEndPending) RENDER_PINCH_END else RENDER_PINCH_UPDATE + zoomRenderPending = false + zoomRenderEndPending = false + zoomRenderInFlight = true + wv.evaluateJavascript("onRenderPage($renderReason)") { + 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 @@ -354,7 +388,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 @@ -375,17 +409,18 @@ fun PdfViewerScreen( } override fun onZoom(scaleFactor: Float, focusX: Float, focusY: Float) { + viewModel.setPageFitMode(FIT_MODE_FREE) viewModel.setZoomRatio( (viewModel.zoomRatio.value * 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 { @@ -506,6 +541,8 @@ fun PdfViewerScreen( enabled = enabled, page = page, numPages = numPages, + pageFitMode = pageFitMode, + continuousMode = continuousMode, hasOutline = viewModel.hasOutline(), hasDocumentProperties = documentProperties != null, hasUri = uri != null, @@ -523,6 +560,12 @@ fun PdfViewerScreen( onFirst = { jumpToPage(viewModel, webView, 1) }, onLast = { jumpToPage(viewModel, webView, numPages) }, onJumpToPage = { showJumpToPage = true }, + 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) + }, onRotateClockwise = { rotateDocument(viewModel, webView, 90) }, onRotateCounterClockwise = { rotateDocument(viewModel, webView, -90) }, zoomRatioFlow = viewModel.zoomRatio, @@ -764,23 +807,37 @@ 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() } } +private fun setPageFitMode(viewModel: PdfViewModel, webView: WebView?, mode: Int) { + webView ?: return + viewModel.setPageFitMode(mode) + viewModel.setZoomRatio(0f) + webView.evaluateJavascript("onRenderPage($RENDER_RELAYOUT)", 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 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(FIT_MODE_FREE) viewModel.setZoomRatio(ratio.coerceIn(MIN_ZOOM_RATIO, MAX_ZOOM_RATIO)) - webView.evaluateJavascript("onRenderPage(1)", null) + webView.evaluateJavascript("onRenderPage($RENDER_MENU_ZOOM)", null) } private fun shareDocument(context: Context, viewModel: PdfViewModel) { @@ -812,6 +869,8 @@ private fun PdfTopAppBar( enabled: Boolean, page: Int, numPages: Int, + pageFitMode: Int, + continuousMode: Boolean, hasOutline: Boolean, hasDocumentProperties: Boolean, hasUri: Boolean, @@ -823,6 +882,10 @@ private fun PdfTopAppBar( onFirst: () -> Unit, onLast: () -> Unit, onJumpToPage: () -> Unit, + onFitFree: () -> Unit, + onFitPage: () -> Unit, + onFitWidth: () -> Unit, + onContinuousModeChange: () -> Unit, onRotateClockwise: () -> Unit, onRotateCounterClockwise: () -> Unit, zoomRatioFlow: StateFlow, @@ -909,6 +972,46 @@ private fun PdfTopAppBar( ) } ) + DropdownMenuItem( + text = { Text(stringResource(R.string.action_fit_free)) }, + onClick = { onMenuToggle(false); onFitFree() }, + enabled = enabled, + leadingIcon = { + if (pageFitMode == FIT_MODE_FREE) { + Icon(Icons.Default.Check, contentDescription = null) + } + } + ) + DropdownMenuItem( + text = { Text(stringResource(R.string.action_fit_page)) }, + onClick = { onMenuToggle(false); onFitPage() }, + enabled = enabled, + leadingIcon = { + if (pageFitMode == FIT_MODE_PAGE) { + Icon(Icons.Default.Check, contentDescription = null) + } + } + ) + DropdownMenuItem( + text = { Text(stringResource(R.string.action_fit_width)) }, + onClick = { onMenuToggle(false); onFitWidth() }, + enabled = enabled, + leadingIcon = { + if (pageFitMode == FIT_MODE_WIDTH) { + 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 39baf6758..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,9 +38,16 @@ 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" + 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 +64,13 @@ class PdfViewModel( savedStateHandle[STATE_DOCUMENT_ORIENTATION_DEGREES] = value } + 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 = + savedStateHandle.getStateFlow(STATE_CONTINUOUS_MODE, true) + fun setContinuousMode(value: Boolean) { savedStateHandle[STATE_CONTINUOUS_MODE] = value } + val documentProperties: StateFlow?> = savedStateHandle.getStateFlow(STATE_DOCUMENT_PROPERTIES, null) @@ -288,6 +302,8 @@ class PdfViewModel( _numPages.value = 0 _zoomRatio.value = 0f setDocumentOrientationDegrees(0) + setPageFitMode(FIT_MODE_WIDTH) + setContinuousMode(true) encryptedDocumentPassword = "" clearOutline() clearDocumentProperties() diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index a4827dabc..cf477fe55 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..ba8350b85 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: flex-start; 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 2e7ba55ce..69422cb9e 100644 --- a/viewer/js/index.js +++ b/viewer/js/index.js @@ -9,296 +9,595 @@ 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. +// +// 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(); -let pageRendering = false; -let renderPending = false; -let renderPendingZoom = 0; -const canvas = document.getElementById("content"); -const container = document.getElementById("container"); + +// 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; -let zoomRatio = 1; -let textLayerDiv = document.getElementById("text"); -let task = null; +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; -let newPageNumber = 0; -let newZoomRatio = 1; -let useRender; +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(), + }; +} -const cache = []; -const maxCached = 6; +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); +} -let isTextLayerVisible = false; -let userZoomed = false; - -function maybeRenderNextPage() { - if (renderPending) { - pageRendering = false; - renderPending = false; - renderPage(channel.getPage(), renderPendingZoom, false); - return true; +function fitMode() { + return channel.getPageFitMode(); +} + +function continuousMode() { + return channel.getContinuousMode(); +} + +// 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"; } - return false; } -function handleRenderingError(error) { - console.log("rendering error: " + error); +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, + }; +} - pageRendering = false; - maybeRenderNextPage(); +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 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 totalRotation(pdfPage) { + return (orientationDegrees + pdfPage.rotate) % 360; } -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); +// 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 !== 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 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); } -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 - }; - layerDiv.style.translate = `${translate.X}px ${translate.Y}px`; +function pageViewport(pdfPage, layout = null) { + return pdfPage.getViewport({ + scale: pageZoom(pdfPage, layout), + rotation: totalRotation(pdfPage), + }); } -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()); +// 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"; } -function renderPage(pageNumber, zoom, prerender, prerenderTrigger = 0) { - pageRendering = true; - useRender = !prerender; +// (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); + const offsetX = Math.max(0, (a.width - vp.width) / 2); + 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"; + // 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"; +} - newPageNumber = pageNumber; - newZoomRatio = channel.getZoomRatio(); - orientationDegrees = channel.getDocumentOrientationDegrees(); - console.log("page: " + pageNumber + ", zoom: " + newZoomRatio + - ", orientationDegrees: " + orientationDegrees + ", prerender: " + prerender); - for (let i = 0; !zoom && 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); - zoomRatio = newZoomRatio; - - textLayerDiv.replaceWith(cached.textLayerDiv); - textLayerDiv = cached.textLayerDiv; - setLayerTransform(cached.pageWidth, cached.pageHeight, textLayerDiv); - container.style.setProperty("--scale-factor", newZoomRatio.toString()); - textLayerDiv.hidden = false; - } +// 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 = 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"; +} - pageRendering = false; - doPrerender(pageNumber, prerenderTrigger); - return; +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; + if (p.renderCanvas) { + p.renderCanvas.width = 0; // free the offscreen render target too + p.renderCanvas.height = 0; + } + p.textLayer.replaceChildren(); +} - pdfDoc.getPage(pageNumber).then(function(page) { - if (maybeRenderNextPage()) { - return; +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). + 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 defaultZoomRatio = getDefaultZoomRatio(page, orientationDegrees); - - if (newZoomRatio === 0) { - zoomRatio = defaultZoomRatio; - newZoomRatio = defaultZoomRatio; - channel.setZoomRatio(defaultZoomRatio); + // 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. + 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); + }); +} - const totalRotation = (orientationDegrees + page.rotate) % 360; - const viewport = page.getViewport({scale: newZoomRatio, rotation: totalRotation}); +// 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 scaleFactor = newZoomRatio / zoomRatio; - const ratio = globalThis.devicePixelRatio; +function relayoutAll() { + const layout = readLayout(); + applyContainerInsets(layout); + for (const p of pages) { + if (!p) continue; + sizeWrapper(p, layout); + alignTextLayer(p, layout); + } + return layout; +} - if (useRender) { - if (newZoomRatio !== zoomRatio) { - canvas.style.height = viewport.height + "px"; - canvas.style.width = viewport.width + "px"; - } - zoomRatio = newZoomRatio; +// 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) { + // 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 { + clearPage(p); + } + } +} - if (zoom === 1 || zoom === 2) { - // Focus point in CSS px, in viewport coordinates. - const focusX = zoom === 2 - ? channel.getZoomFocusX() / ratio - : globalThis.innerWidth / 2; - const focusY = zoom === 2 - ? channel.getZoomFocusY() / ratio - : globalThis.innerHeight / 2; - - // focus relative to page origin, rather than screen origin - const globalFocusX = focusX + globalThis.scrollX; - const globalFocusY = focusY + globalThis.scrollY; - - const translationFactor = scaleFactor - 1; - scrollBy(globalFocusX * translationFactor, globalFocusY * translationFactor); - - if (zoom === 2) { - textLayerDiv.hidden = true; - pageRendering = false; - return; - } +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 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 - }); +// 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; +} - 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 - }); +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}; +} - task.promise.then(function() { - task = null; +function restoreAxisAnchor(anchor, start, size) { + return start + size * anchor.normalized + anchor.offset; +} - 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); - }); +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), + }; } -globalThis.onRenderPage = function (zoom) { - if (zoom === 1 || zoom === 2) { - userZoomed = true; +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 () { + const p = mostVisiblePage(); + return p ? p.canvas : null; +}; + +globalThis.currentPageTextLayer = function () { + const p = mostVisiblePage(); + return p ? p.textLayer : null; +}; - if (pageRendering) { - if (newPageNumber === channel.getPage() && newZoomRatio === channel.getZoomRatio() && - orientationDegrees === channel.getDocumentOrientationDegrees()) { - useRender = true; - return; +// 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]) { + if (!failedPages.has(pendingScrollPage) && !pageBuildComplete) return; + pendingScrollPage = 0; + } else { + if (num !== pendingScrollPage) return; + pendingScrollPage = 0; } + } + const layout = readLayout(); + const m = layout.mode; + 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()); + } 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; + + pageNumber = resolveScrollablePage(pageNumber); + if (pageNumber === 0) { + pendingScrollPage = 0; + return; + } - renderPending = true; - renderPendingZoom = zoom; - if (task !== null) { - task.cancel(); - task = null; + // 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 Android side. +globalThis.onRenderPage = function (renderReason = RENDER_RELAYOUT) { + orientationDegrees = channel.getDocumentOrientationDegrees(); + + 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 = renderReason === RENDER_MENU_ZOOM + ? globalThis.innerWidth / 2 + : channel.getZoomFocusX() / dpr; + const viewportFocusY = renderReason === RENDER_MENU_ZOOM + ? globalThis.innerHeight / 2 + : channel.getZoomFocusY() / dpr; + // 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. + const layout = relayoutAll(); + + // 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. + restoreZoomAnchor(anchor); + + rerenderVisible(layout); + return; + } + + // 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 + // handler report and overwrite a different most-visible page. + 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 || target !== requestedTarget; + + if (target !== 0 && !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 () { @@ -306,17 +605,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); }); }; @@ -326,17 +625,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", @@ -377,29 +687,138 @@ 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. + 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(); + failedPages.clear(); + zoomRatio = 0; + lastReportedPage = 0; + pageBuildComplete = false; + 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}`); + 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; + + 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(); + } } + + pageBuildComplete = true; + const requestedPage = channel.getPage() || startPage; + if (viewerReady && !pages[requestedPage - 1]) { + const fallback = nearestAvailablePageNumber(requestedPage); + if (fallback !== 0) { + globalThis.scrollToPage(fallback); + 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..bca5abc23 --- /dev/null +++ b/viewer/js/index.test.js @@ -0,0 +1,526 @@ +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: "", + marginLeft: "", + translate: "", + setProperty(name, value) { + properties.set(name, value); + }, + getPropertyValue(name) { + return properties.get(name) || ""; + }, + }; +} + +function px(value) { + return Number.parseFloat(value) || 0; +} + +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() {}, drawImage() {} }; + } + + 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") { + top += px(sibling.style.height); + if (sibling.className === "page-wrapper") { + top += this.environment.pageGap; + } + } + } + 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() { + 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, + focusX = 50, + focusY = 20, + pageGap = 0, + pageCount = 3, + getPage, +} = {}) { + vi.resetModules(); + const state = { + continuous, + currentPage, + fitMode, + focusX, + focusY, + zoom: 0.5, + orientation: 0, + renderCalls: [], + scrollCalls: [], + scrolledPages: [], + zoomReports: [], + }; + const environment = { pageGap, 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.innerWidth = 100; + 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: () => state.focusX, + getZoomFocusY: () => state.focusY, + 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("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); + after = canvas.getBoundingClientRect(); + expect(after.left + after.width * normalizedX).toBeCloseTo(state.focusX); + expect(after.top + after.height * normalizedY).toBeCloseTo(state.focusY); + + 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, 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); + + 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 () => { + const { state, pagesElement } = await setupViewer({ + continuous: true, + currentPage: 4, + pageCount: 4, + }); + state.fitMode = 0; + state.zoom = 2; + + globalThis.onRenderPage(2); + + 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"); + }); + + 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("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; + + 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("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) => { + 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); + }); +});