#include #include #include #include "app/pdf_canvas.h" #include "broker/font_broker.h" #include #include #include #include #include "pdf/pdf_document.h" #include "font_test_fixture.h" #include #include using namespace docview; static void provideFonts(WorkerProcess &worker, FontBroker &broker) { worker.setFontRequestHandler([&broker](const QString &operation, const QCborMap &payload, std::function completion) { broker.request(operation, payload, std::move(completion)); }); } class PdfCanvasTest : public QObject { Q_OBJECT private slots: void initTestCase() { qInfo().noquote() << "Runtime platform:" << QGuiApplication::platformName(); QCOMPARE(QGuiApplication::platformName(), qEnvironmentVariable("QT_QPA_PLATFORM").section(':', 0, 0)); } void sceneGraphPresentsPdfPixelsAndReplacesOldPage() { FontBroker broker; WorkerProcess worker("canvas-scene-graph", 1); provideFonts(worker, broker); QSignalSpy ready(&worker, &WorkerProcess::ready), failed(&worker, &WorkerProcess::failed); QVERIFY(worker.start(QCoreApplication::applicationDirPath() + "/docview-pdf-worker", {"--source", QFINDTESTDATA("fixtures/pdf/navigation.pdf")})); QTRY_VERIFY_WITH_TIMEOUT(!ready.empty() || !failed.empty(), 10000); QVERIFY(failed.empty()); QCborMap metadata; bool opened = false; worker.request("open", {}, [&](const QCborMap &reply) { metadata = reply.value("result").toMap(); opened = true; }); QTRY_VERIFY_WITH_TIMEOUT(opened, 10000); QCOMPARE(metadata.value("pageCount").toInteger(), 2); QQuickWindow window; window.resize(512, 512); PdfCanvas canvas(window.contentItem()); canvas.setWidth(512); canvas.setHeight(512); canvas.setDocument(&worker, metadata.value("pages").toArray().toVariantList(), 2); canvas.setZoom(200); canvas.setPrefetchPages(0); window.show(); QVERIFY(QTest::qWaitForWindowExposed(&window)); QTRY_VERIFY_WITH_TIMEOUT(canvas.viewportReady() && worker.idle(), 10000); qInfo() << "Scene graph graphics API:" << window.rendererInterface()->graphicsApi(); const auto pixel = [&](const QImage &image, QPointF pdfPoint) { const auto point = canvas.pageToCanvas(0, pdfPoint) * window.devicePixelRatio(); const QPoint physical(qRound(point.x()), qRound(point.y())); return image.rect().contains(physical) ? image.pixelColor(physical) : QColor(); }; const auto red = [](QColor color) { return color.red() > 220 && color.green() < 40 && color.blue() < 40; }; QImage capture; const auto firstPageVisible = [&] { capture = window.grabWindow(); return !capture.isNull() && red(pixel(capture, {140, 75})) && pixel(capture, {60, 70}).lightness() < 40 && pixel(capture, {90, 210}).lightness() > 220; }; // The fixture has a black rectangle and a saved red widget appearance. // Read pixels from the actual window, rather than calling paint() directly. QTRY_VERIFY_WITH_TIMEOUT(firstPageVisible(), 5000); QCOMPARE(capture.size(), QSize(qRound(window.width() * window.devicePixelRatio()), qRound(window.height() * window.devicePixelRatio()))); canvas.goTo(1); QTRY_VERIFY_WITH_TIMEOUT(canvas.viewportReady() && worker.idle(), 10000); QCOMPARE(canvas.currentPage(), 1); const auto secondPageVisible = [&] { capture = window.grabWindow(); if (capture.isNull()) return false; int dark = 0; for (int y = 0; y < capture.height(); ++y) for (int x = 0; x < capture.width(); ++x) { const auto color = capture.pixelColor(x, y); if (red(color)) return false; if (color.red() < 12 && color.green() < 12 && color.blue() < 12) ++dark; } return dark > 20; }; QTRY_VERIFY_WITH_TIMEOUT(secondPageVisible(), 5000); canvas.goTo(0); QTRY_VERIFY_WITH_TIMEOUT(canvas.viewportReady() && worker.idle(), 10000); QTRY_VERIFY_WITH_TIMEOUT(firstPageVisible(), 5000); canvas.setDocument(nullptr, {}, 0); const auto closedVisible = [&] { capture = window.grabWindow(); return !capture.isNull() && capture.pixelColor(256, 256) == canvas.backgroundColor(); }; QTRY_VERIFY_WITH_TIMEOUT(closedVisible(), 5000); worker.stop(); QVERIFY(failed.empty()); } void memoryPressureStagesRetainLocationAndReleaseInvisiblePixels() { const QString source = QFINDTESTDATA("fixtures/pdf/navigation.pdf"); const auto hash = [&] { QFile file(source); if (!file.open(QIODevice::ReadOnly)) return QByteArray(); return QCryptographicHash::hash(file.readAll(), QCryptographicHash::Sha256); }; const auto originalHash = hash(); QVERIFY(!originalHash.isEmpty()); FontBroker broker; WorkerProcess worker("canvas-memory-pressure", 1); provideFonts(worker, broker); QSignalSpy ready(&worker, &WorkerProcess::ready), failed(&worker, &WorkerProcess::failed); QVERIFY(worker.start(QCoreApplication::applicationDirPath() + "/docview-pdf-worker", {"--source", source})); QTRY_VERIFY_WITH_TIMEOUT(!ready.empty() || !failed.empty(), 10000); QVERIFY(failed.empty()); QCborMap metadata; bool opened = false; worker.request("open", {}, [&](const QCborMap &reply) { metadata = reply.value("result").toMap(); opened = true; }); QTRY_VERIFY_WITH_TIMEOUT(opened, 10000); PdfCanvas canvas; canvas.setWidth(250); canvas.setHeight(180); canvas.setDocument(&worker, metadata.value("pages").toArray().toVariantList(), 2); canvas.setZoom(300); QTRY_VERIFY_WITH_TIMEOUT(canvas.viewportReady() && worker.idle(), 10000); const auto location = canvas.location(); const auto normalCost = canvas.cacheCostBytes(); const auto paint = [&] { QImage image(250, 180, QImage::Format_ARGB32); image.fill(Qt::transparent); QPainter painter(&image); canvas.paint(&painter); return image; }; const auto normalPixels = paint(); bool interrupted = false; auto interrupt = connect(&canvas, &PdfCanvas::tileRequested, &canvas, [&](int, bool prefetch, qint64) { if (prefetch && !interrupted) { interrupted = true; QTimer::singleShot(0, &canvas, [&] { canvas.setMemoryPressureLevel(1); }); } }); canvas.setPrefetchPages(1); QTRY_VERIFY_WITH_TIMEOUT(interrupted && canvas.memoryPressureLevel() == 1, 10000); QTRY_VERIFY_WITH_TIMEOUT(canvas.viewportReady() && worker.idle(), 10000); disconnect(interrupt); QVERIFY(canvas.staleRenderResponseCount() > 0); const auto prefetchCount = canvas.prefetchRenderRequestCount(); QTest::qWait(80); QCOMPARE(canvas.prefetchRenderRequestCount(), prefetchCount); QCOMPARE(canvas.location(), location); QCOMPARE(paint(), normalPixels); canvas.setMemoryPressureLevel(0); QTRY_VERIFY_WITH_TIMEOUT(canvas.cacheCostBytes() > normalCost && worker.idle(), 10000); canvas.setMemoryPressureLevel(2); QCOMPARE(canvas.cacheCostBytes(), normalCost); QVERIFY(canvas.viewportReady()); QCOMPARE(canvas.location(), location); QCOMPARE(paint(), normalPixels); canvas.setMemoryPressureLevel(3); QCOMPARE(canvas.rasterResolutionScale(), .5); QCOMPARE(canvas.zoom(), 300.); QCOMPARE(canvas.location(), location); QVERIFY(!canvas.viewportReady()); QCOMPARE(paint(), normalPixels); // Old visible pixels survive until replacement is ready. QTRY_VERIFY_WITH_TIMEOUT(canvas.viewportReady() && worker.idle(), 10000); QVERIFY(canvas.cacheCostBytes() < normalCost); QCOMPARE(canvas.location(), location); canvas.setPrefetchPages(0); canvas.setMemoryPressureLevel(0); QCOMPARE(canvas.rasterResolutionScale(), 1.); QTRY_VERIFY_WITH_TIMEOUT(canvas.viewportReady() && worker.idle(), 10000); QCOMPARE(paint(), normalPixels); QCOMPARE(canvas.location(), location); // Recovery while reduced work is active cannot admit its stale pixels. canvas.setZoom(400); QTRY_VERIFY_WITH_TIMEOUT(canvas.viewportReady() && worker.idle(), 10000); const auto restoredPixels = paint(); const auto staleBefore = canvas.staleRenderResponseCount(); bool recovered = false; auto recover = connect(&canvas, &PdfCanvas::tileRequested, &canvas, [&](int, bool, qint64) { if (canvas.memoryPressureLevel() == 3 && !recovered) { recovered = true; QTimer::singleShot(0, &canvas, [&] { canvas.setMemoryPressureLevel(0); }); } }); canvas.setMemoryPressureLevel(3); QTRY_VERIFY_WITH_TIMEOUT(recovered && canvas.memoryPressureLevel() == 0 && worker.idle(), 10000); disconnect(recover); QVERIFY(canvas.staleRenderResponseCount() > staleBefore); QVERIFY(canvas.viewportReady()); QCOMPARE(paint(), restoredPixels); canvas.setMemoryPressureLevel(4); const auto renders = canvas.visibleRenderRequestCount(); canvas.goTo(1); QTest::qWait(80); QCOMPARE(canvas.visibleRenderRequestCount(), renders); QVERIFY(!canvas.viewportReady()); QCOMPARE(canvas.pendingRenderCount(), 0); canvas.setDocument(nullptr, {}, 0); QCOMPARE(canvas.cacheCostBytes(), 0); QCOMPARE(canvas.memoryPressureLevel(), 4); // Global pressure survives document replacement. canvas.setMemoryPressureLevel(0); QCOMPARE(hash(), originalHash); QVERIFY(failed.empty()); worker.stop(); } void reducedRasterHonorsWorkerMinimumWithoutMovingGeometry() { PdfCanvas canvas; canvas.setWidth(100); canvas.setHeight(100); canvas.setDocument(nullptr, {QVariantMap{{"pageIndex", 0}, {"width", 100000.}, {"height", 100000.}}}, 1); canvas.fitWidth(); QCOMPARE(canvas.zoom(), 5.); const auto location = canvas.location(); const auto point = canvas.pageToCanvas(0, {50., 70.}); canvas.setMemoryPressureLevel(3); QCOMPARE(canvas.rasterResolutionScale(), 1.); // Physical scale must remain at least .05. QCOMPARE(canvas.location(), location); QCOMPARE(canvas.pageToCanvas(0, {50., 70.}), point); canvas.setMemoryPressureLevel(-1); QCOMPARE(canvas.memoryPressureLevel(), 0); canvas.setMemoryPressureLevel(10); QCOMPARE(canvas.memoryPressureLevel(), 4); } void currentPageUsesCentreAndReadingLocationUsesLeadingEdge() { QVariantList pages; for (int i = 0; i < 5; ++i) pages.append(QVariantMap{{"pageIndex", i}, {"width", 100.}, {"height", 100.}, {"cropBox", QVariantList{0., 0., 100., 100.}}, {"rotation", 0}, {"label", QString::number(i + 1)}}); PdfCanvas canvas; canvas.setWidth(132); canvas.setHeight(180); canvas.setDocument(nullptr, pages, 5); canvas.setZoom(100); canvas.goTo(0); QCOMPARE(canvas.currentPage(), 0); canvas.scroll(0, 34); // Centre 124: equidistant from page 0 bottom 116 and page 1 top 132. QCOMPARE(canvas.currentPage(), 0); QCOMPARE(canvas.location().value("pageIndex").toInt(), 0); canvas.scroll(0, .25); QCOMPARE(canvas.currentPage(), 1); QCOMPARE(canvas.location().value("pageIndex").toInt(), 0); canvas.scroll(0, 82); QCOMPARE(canvas.currentPage(), 1); QCOMPARE(canvas.location().value("pageIndex").toInt(), 1); } void zoomPreservesViewportCentre() { PdfDocument document; QString code, error; QVERIFY(document.open(QFINDTESTDATA("fixtures/pdf/navigation.pdf"), {}, &code, &error)); PdfCanvas canvas; canvas.setWidth(280); canvas.setHeight(200); canvas.setDocument(nullptr, document.metadata().value("pages").toArray().toVariantList(), 2); canvas.setZoom(300); canvas.goTo(0); canvas.scroll(80, 150); const int page = canvas.currentPage(); const QPointF centre(canvas.width() / 2, canvas.height() / 2); const auto point = canvas.canvasToPage(page, centre); canvas.setZoom(400); const auto after = canvas.canvasToPage(page, centre); QVERIFY(QLineF(point, after).length() < .001); } void transformsAgreeWithPdfium() { PdfDocument document; QString code, error; QVERIFY(document.open(QFINDTESTDATA("fixtures/pdf/navigation.pdf"), {}, &code, &error)); PdfCanvas canvas; canvas.setWidth(600); canvas.setHeight(400); canvas.setDocument(nullptr, document.metadata().value("pages").toArray().toVariantList(), 2); canvas.setZoom(200); for (int rotation : {0, 90, 180, 270}) { for (int page : {0, 1}) { canvas.goToLocation({{"pageIndex", page}, {"rotation", rotation}, {"fitMode", "percent"}, {"zoom", 200.0}}); const QRectF crop = page == 0 ? QRectF(20, 30, 200, 200) : QRectF(0, 0, 300, 200); const auto corners = {crop.topLeft(), crop.topRight(), crop.bottomLeft(), crop.bottomRight()}; double left = 1e9, top = 1e9; for (auto corner : corners) { auto p = canvas.pageToCanvas(page, corner); left = std::min(left, p.x()); top = std::min(top, p.y()); } for (const QPointF point : {crop.topLeft(), crop.center(), crop.bottomRight(), QPointF(55, 85)}) { auto actual = canvas.pageToCanvas(page, point); QCborMap request{{"page", page}, {"scale", 2.0}, {"rotation", rotation}, {"x", point.x()}, {"y", point.y()}}; auto expected = document.mapPoint(request, &code, &error); QVERIFY(code.isEmpty()); QVERIFY(std::abs(actual.x() - left - expected.value("x").toDouble()) < 1.01); QVERIFY(std::abs(actual.y() - top - expected.value("y").toDouble()) < 1.01); auto original = canvas.canvasToPage(page, actual); QVERIFY(std::abs(original.x() - point.x()) < .0001); QVERIFY(std::abs(original.y() - point.y()) < .0001); } } } } void pointLocationRestoresAfterZoomAndPan() { PdfDocument document; QString code, error; QVERIFY(document.open(QFINDTESTDATA("fixtures/pdf/navigation.pdf"), {}, &code, &error)); PdfCanvas canvas; canvas.setWidth(250); canvas.setHeight(180); canvas.setDocument(nullptr, document.metadata().value("pages").toArray().toVariantList(), 2); canvas.setZoom(300); canvas.scroll(80, 90); const auto saved = canvas.location(); QVERIFY(saved.contains("xPt")); QVERIFY(saved.contains("yPt")); QCOMPARE(saved.value("pageLabel").toString(), "i"); canvas.goTo(1); canvas.setZoom(100); canvas.goToLocation(saved); const auto restored = canvas.location(); QCOMPARE(restored.value("pageIndex"), saved.value("pageIndex")); QVERIFY(std::abs(restored.value("xPt").toDouble() - saved.value("xPt").toDouble()) < .001); QVERIFY(std::abs(restored.value("yPt").toDouble() - saved.value("yPt").toDouble()) < .001); QCOMPARE(canvas.zoom(), 300.0); } void headingTargetsAndInvalidCoordinates() { PdfDocument document; QString code, error; QVERIFY(document.open(QFINDTESTDATA("fixtures/pdf/navigation.pdf"), {}, &code, &error)); PdfCanvas canvas; canvas.setWidth(250); canvas.setHeight(180); canvas.setDocument(nullptr, document.metadata().value("pages").toArray().toVariantList(), 2); canvas.setZoom(300); const auto headings = document.headings(0).value("headings").toArray(); auto first = headings[0].toMap().toVariantMap(), second = headings[1].toMap().toVariantMap(); canvas.goToLocation(first); const double firstOffset = canvas.offset(); auto target = canvas.pageToCanvas(0, QPointF(first.value("x").toDouble(), first.value("y").toDouble())); QVERIFY(std::abs(target.y() - 16) < .001); canvas.goToLocation(second); QVERIFY(canvas.offset() > firstOffset + 100); QSignalSpy errors(&canvas, &PdfCanvas::renderFailed); canvas.goToLocation({{"pageIndex", -1}}); QCOMPARE(errors.size(), 1); canvas.goToLocation({{"pageIndex", 0}, {"xPt", 20.0}, {"yPt", 10000.0}}); QCOMPARE(errors.size(), 2); auto clipped = canvas.location(); QVERIFY(clipped.value("yPt").toDouble() <= 230.0); } void searchJumpsToTextAndZoomIsBounded() { PdfDocument document; QString code, error; QVERIFY(document.open(QFINDTESTDATA("fixtures/pdf/navigation.pdf"), {}, &code, &error)); PdfCanvas canvas; canvas.setWidth(250); canvas.setHeight(180); canvas.setDocument(nullptr, document.metadata().value("pages").toArray().toVariantList(), 2); canvas.setZoom(100000); QCOMPARE(canvas.zoom(), 800.0); canvas.setZoom(-10); QCOMPARE(canvas.zoom(), 25.0); canvas.setZoom(300); canvas.setZoom(std::numeric_limits::infinity()); QCOMPARE(canvas.zoom(), 300.0); auto match = document.search("Needle", 0).value("matches").toArray()[0].toMap().toVariantMap(); canvas.showSearchResult(match); auto rect = match.value("rects").toList()[0].toList(); const auto target = canvas.pageToCanvas(0, QPointF(rect[0].toDouble(), rect[3].toDouble())); QVERIFY(std::abs(target.y() - 16) < .001); QVERIFY(!canvas.viewportReady()); // no worker and no raster pixels yet canvas.showSearchResult({}); } void prefetchIsOptionalAndWarmsOnlyAdjacentViewport() { FontBroker broker; WorkerProcess worker("canvas-prefetch-test", 1); provideFonts(worker, broker); QSignalSpy ready(&worker, &WorkerProcess::ready), failed(&worker, &WorkerProcess::failed); QVERIFY(worker.start(QCoreApplication::applicationDirPath() + "/docview-pdf-worker", {"--source", QFINDTESTDATA("fixtures/pdf/navigation.pdf")})); QTRY_VERIFY_WITH_TIMEOUT(!ready.isEmpty() || !failed.isEmpty(), 10000); QVERIFY(failed.isEmpty()); bool opened = false; QCborMap metadata; worker.request("open", {}, [&](const QCborMap &reply) { metadata = reply.value("result").toMap(); opened = true; }); QTRY_VERIFY_WITH_TIMEOUT(opened, 10000); QCOMPARE(metadata.value("pageCount").toInteger(), 2); PdfCanvas canvas; canvas.setWidth(250); canvas.setHeight(180); canvas.setPrefetchPages(0); QCOMPARE(canvas.cacheBudgetBytes(), 256ll * 1024 * 1024); canvas.setCacheBudget(-1); QCOMPARE(canvas.cacheBudgetBytes(), 64ll * 1024 * 1024); canvas.setCacheBudget(1024); QCOMPARE(canvas.cacheBudgetBytes(), 512ll * 1024 * 1024); canvas.setCacheBudget(64); QCOMPARE(canvas.cacheCostBytes(), 0); QCOMPARE(canvas.cacheEntryCount(), 0); QSignalSpy requests(&canvas, &PdfCanvas::tileRequested); canvas.setDocument(&worker, metadata.value("pages").toArray().toVariantList(), 2); canvas.setZoom(300); QTRY_VERIFY_WITH_TIMEOUT(canvas.viewportReady() && worker.idle(), 10000); QVERIFY(canvas.cacheCostBytes() > 0); QVERIFY(canvas.cacheEntryCount() > 0); QVERIFY(canvas.cacheCostBytes() <= canvas.cacheBudgetBytes()); QCOMPARE(canvas.prefetchRenderRequestCount(), quint64(0)); QCOMPARE(canvas.visibleRenderRequestCount(), quint64(requests.size())); const auto visibleRequests = canvas.visibleRenderRequestCount(); for (int check = 0; check < 100; ++check) QVERIFY(canvas.viewportReady()); QCOMPARE(canvas.visibleRenderRequestCount(), visibleRequests); for (const auto &request : requests) { QCOMPARE(request[0].toInt(), 0); QVERIFY(!request[1].toBool()); } const int disabledCount = requests.size(); QTest::qWait(80); QCOMPARE(requests.size(), disabledCount); canvas.setPrefetchPages(1); QTRY_VERIFY_WITH_TIMEOUT(requests.size() > disabledCount, 10000); QTRY_VERIFY_WITH_TIMEOUT(worker.idle(), 10000); QTest::qWait(80); int prefetched = 0; for (const auto &request : requests) if (request[1].toBool()) { QCOMPARE(request[0].toInt(), 1); ++prefetched; } QVERIFY(prefetched > 0 && prefetched <= 8); QVERIFY(canvas.viewportReady()); QCOMPARE(canvas.prefetchRenderRequestCount(), quint64(prefetched)); QVERIFY(canvas.cacheCostBytes() <= canvas.cacheBudgetBytes()); canvas.setPrefetchPages(0); const int beforeJump = requests.size(); canvas.goTo(1); QVERIFY(canvas.viewportReady()); // adjacent visible strip was already cached QTest::qWait(80); for (int i = beforeJump; i < requests.size(); ++i) QVERIFY2(requests[i][1].toBool(), "A visible render was needed despite the prefetched page strip"); QCOMPARE(canvas.visibleRenderRequestCount(), visibleRequests); QCOMPARE(canvas.staleRenderResponseCount(), quint64(0)); canvas.setDocument(nullptr, {}, 0); QCOMPARE(canvas.cacheCostBytes(), 0); QCOMPARE(canvas.cacheEntryCount(), 0); QCOMPARE(canvas.visibleRenderRequestCount(), visibleRequests); // counters are lifetime samples worker.stop(); } void zoomDiscardsInFlightPrefetchAndKeepsOldVisiblePixels() { FontBroker broker; WorkerProcess worker("canvas-prefetch-cancel", 1); provideFonts(worker, broker); QSignalSpy ready(&worker, &WorkerProcess::ready), failed(&worker, &WorkerProcess::failed); QVERIFY(worker.start(QCoreApplication::applicationDirPath() + "/docview-pdf-worker", {"--source", QFINDTESTDATA("fixtures/pdf/navigation.pdf")})); QTRY_VERIFY_WITH_TIMEOUT(!ready.isEmpty() || !failed.isEmpty(), 10000); QVERIFY(failed.isEmpty()); bool opened = false; QCborMap metadata; worker.request("open", {}, [&](const QCborMap &reply) { metadata = reply.value("result").toMap(); opened = true; }); QTRY_VERIFY_WITH_TIMEOUT(opened, 10000); PdfCanvas canvas; canvas.setWidth(250); canvas.setHeight(180); canvas.setPrefetchPages(0); canvas.setDocument(&worker, metadata.value("pages").toArray().toVariantList(), 2); canvas.setZoom(300); QTRY_VERIFY_WITH_TIMEOUT(canvas.viewportReady() && worker.idle(), 10000); bool changed = false; qint64 prefetchRevision = -1; QSignalSpy requests(&canvas, &PdfCanvas::tileRequested); connect(&canvas, &PdfCanvas::tileRequested, &canvas, [&](int, bool prefetch, qint64 revision) { if (!prefetch || changed) return; prefetchRevision = revision; QTimer::singleShot(0, &canvas, [&] { changed = true; canvas.setPrefetchPages(0); canvas.setZoom(200); }); }); canvas.setPrefetchPages(1); QTRY_VERIFY_WITH_TIMEOUT(changed, 10000); QTRY_VERIFY_WITH_TIMEOUT(canvas.viewportReady() && worker.idle(), 10000); QCOMPARE(canvas.zoom(), 200.0); QVERIFY(prefetchRevision >= 0); QCOMPARE(canvas.prefetchRenderRequestCount(), quint64(1)); QCOMPARE(canvas.staleRenderResponseCount(), quint64(1)); bool currentRender = false; for (const auto &request : requests) if (!request[1].toBool() && request[2].toLongLong() > prefetchRevision) currentRender = true; QVERIFY(currentRender); canvas.setZoom(300); QVERIFY(canvas.viewportReady()); // earlier visible resolution retained canvas.goTo(1); QVERIFY(!canvas.viewportReady()); // stale prefetch was not admitted worker.stop(); } void lateFontReplyRespectsDocumentAndWorkerLifetime_data() { QTest::addColumn("mode"); QTest::newRow("visible-fatal") << 0; QTest::newRow("prefetch-fatal") << 1; QTest::newRow("old-revision-fatal") << 2; QTest::newRow("old-document-ignored") << 3; QTest::newRow("prefetch-warning") << 4; } void lateFontReplyRespectsDocumentAndWorkerLifetime() { QFETCH(int, mode); QTemporaryDir directory; QFile file(directory.filePath("late-font.pdf")); QVERIFY(file.open(QIODevice::WriteOnly)); QVERIFY(file.write(deferredFontPdf()) > 0); file.close(); WorkerProcess worker("canvas-late-font", 1); std::function held; int maps = 0; worker.setFontRequestHandler([&](const QString &operation, const QCborMap &, std::function done) { QCOMPARE(operation, "font.map"); ++maps; if (mode == 4) done({{"found", false}}); else held = std::move(done); }); QSignalSpy ready(&worker, &WorkerProcess::ready), crashed(&worker, &WorkerProcess::failed); QVERIFY(worker.start(QCoreApplication::applicationDirPath() + "/docview-pdf-worker", {"--source", file.fileName()})); QTRY_VERIFY_WITH_TIMEOUT(!ready.empty() || !crashed.empty(), 10000); QVERIFY(crashed.empty()); QCborMap metadata; bool opened = false; worker.request("open", {}, [&](const QCborMap &reply) { metadata = reply.value("result").toMap(); opened = true; }); QTRY_VERIFY_WITH_TIMEOUT(opened, 10000); QCOMPARE(metadata.value("pageCount").toInteger(), 257); QCOMPARE(maps, 0); PdfCanvas canvas; canvas.setWidth(250); canvas.setHeight(180); canvas.setPrefetchPages(0); canvas.setDocument(&worker, metadata.value("pages").toArray().toVariantList(), 257); canvas.updatePages({QVariantMap{{"pageIndex", 256}, {"width", 595.0}, {"height", 842.0}}}); canvas.setZoom(100); canvas.goTo(255); QTRY_VERIFY_WITH_TIMEOUT(canvas.viewportReady() && worker.idle(), 10000); const auto cached = canvas.cacheCostBytes(); QVERIFY(cached > 0); QSignalSpy fatal(&canvas, &PdfCanvas::fatalWorkerError), warnings(&canvas, &PdfCanvas::documentWarning); connect(&canvas, &PdfCanvas::fatalWorkerError, &worker, [&](const QString &, const QString &) { worker.stop(); }); if (mode == 0) canvas.goTo(256); else canvas.setPrefetchPages(1); if (mode == 4) { QTRY_VERIFY_WITH_TIMEOUT(!warnings.empty(), 10000); QTRY_VERIFY_WITH_TIMEOUT(canvas.viewportReady() && worker.idle(), 10000); QVERIFY(fatal.empty()); QVERIFY(maps > 0); } else { QTRY_VERIFY_WITH_TIMEOUT(bool(held), 10000); if (mode == 2) canvas.setZoom(200); if (mode == 3) canvas.setDocument(nullptr, {}, 0); held({{"error", QCborMap{{"code", "E_FONT_FAILED"}, {"message", "late font fixture failure"}}}}); if (mode == 3) { QTRY_VERIFY_WITH_TIMEOUT(worker.idle(), 10000); QVERIFY(fatal.empty()); QVERIFY(canvas.staleRenderResponseCount() > 0); } else { QTRY_COMPARE_WITH_TIMEOUT(fatal.size(), 1, 10000); QCOMPARE(fatal[0][0].toString(), "E_FONT_FAILED"); QVERIFY(canvas.cacheCostBytes() >= cached); // Existing pixels survive a fatal fetch. if (mode == 1) QVERIFY(canvas.viewportReady()); } } worker.stop(); QVERIFY(crashed.empty()); } void latePageMetadataRetainsDestination() { PdfDocument document; QString code, error; QVERIFY(document.open(QFINDTESTDATA("fixtures/pdf/navigation.pdf"), {}, &code, &error)); auto pages = document.metadata().value("pages").toArray().toVariantList(); PdfCanvas canvas; canvas.setWidth(250); canvas.setHeight(180); canvas.setDocument(nullptr, {pages[0]}, 2); canvas.setZoom(300); QSignalSpy needed(&canvas, &PdfCanvas::pageNeeded); canvas.goToLocation({{"pageIndex", 1}, {"xPt", 50.0}, {"yPt", 100.0}}); QVERIFY(!needed.isEmpty()); canvas.updatePages({pages[1]}); QCOMPARE(canvas.currentPage(), 1); auto point = canvas.pageToCanvas(1, QPointF(50, 100)); QVERIFY(std::abs(point.y() - 16) < .001); } void latePageMetadataCannotRestoreCancelledJump() { PdfDocument document; QString code, error; QVERIFY(document.open(QFINDTESTDATA("fixtures/pdf/navigation.pdf"), {}, &code, &error)); const auto pages = document.metadata().value("pages").toArray().toVariantList(); for (int action = 0; action < 4; ++action) { PdfCanvas canvas; canvas.setWidth(250); canvas.setHeight(180); canvas.setDocument(nullptr, {pages[0]}, 2); canvas.setZoom(300); canvas.goToLocation({{"pageIndex", 1}, {"xPt", 50.0}, {"yPt", 100.0}, {"zoom", 400.0}}); if (action == 0) canvas.goTo(0); if (action == 1) canvas.goToLocation({{"pageIndex", 0}, {"xPt", 40.0}, {"yPt", 180.0}}); if (action == 2) canvas.setZoom(200); if (action == 3) canvas.cancelPendingNavigation(); canvas.updatePages({pages[1]}); QCOMPARE(canvas.zoom(), action == 2 ? 200.0 : 300.0); if (action < 2) QCOMPARE(canvas.currentPage(), 0); } } void annotationFlagsHitTargetsMatchRenderedPixels() { FontBroker broker; WorkerProcess worker("canvas-annotation-flags", 1); provideFonts(worker, broker); QSignalSpy ready(&worker, &WorkerProcess::ready), failed(&worker, &WorkerProcess::failed); QVERIFY(worker.start(QCoreApplication::applicationDirPath() + "/docview-pdf-worker", {"--source", QFINDTESTDATA("fixtures/pdf/annotation-flags/flags.pdf")})); QTRY_VERIFY_WITH_TIMEOUT(!ready.isEmpty() || !failed.isEmpty(), 10000); QVERIFY(failed.isEmpty()); bool opened = false; QCborMap metadata; worker.request("open", {}, [&](const QCborMap &reply) { metadata = reply.value("result").toMap(); opened = true; }); QTRY_VERIFY_WITH_TIMEOUT(opened, 10000); QCOMPARE(metadata.value("pageCount").toInteger(), 38); QQuickWindow window; window.resize(512, 240); PdfCanvas canvas(window.contentItem()); canvas.setWidth(512); canvas.setHeight(240); canvas.setDocument(&worker, metadata.value("pages").toArray().toVariantList(), 38); QSignalSpy links(&canvas, &PdfCanvas::externalLinkRequested), comments(&canvas, &PdfCanvas::annotationRequested); for (int page : {12, 15, 17, 18, 20, 23, 24, 27, 29, 30, 32, 35}) { QCborMap details; bool received = false; worker.request("links", {{"page", page}}, [&](const QCborMap &reply) { details = reply.value("result").toMap(); received = true; }); QTRY_VERIFY_WITH_TIMEOUT(received, 10000); const auto targets = details.value(page < 24 ? QStringLiteral("links") : QStringLiteral("annotations")).toArray(); QVERIFY(!targets.isEmpty()); const auto target = targets.first().toMap().toVariantMap(); for (int rotation : {0, 90, 180, 270}) for (double zoom : {200., 400.}) for (int pressure : {0, 3}) { canvas.setMemoryPressureLevel(pressure); canvas.goToLocation({{"pageIndex", page}, {"viewRotation", rotation}, {"fitMode", "percent"}, {"zoom", zoom}}); // Keep the annotation in the viewport even at 400%. const auto center = canvas.annotationRectToCanvas(page, target).center(); canvas.scroll(center.x() - 256, center.y() - 120); QTRY_VERIFY_WITH_TIMEOUT(canvas.viewportReady() && worker.idle(), 10000); const auto expected = canvas.annotationRectToCanvas(page, target); QImage image(512, 240, QImage::Format_ARGB32); image.fill(Qt::transparent); QPainter painter(&image); canvas.paint(&painter); painter.end(); QRect colored; QPoint click; int count = 0; const auto clip = expected.adjusted(-3, -3, 3, 3).toAlignedRect().intersected(image.rect()); for (int y = clip.top(); y <= clip.bottom(); ++y) for (int x = clip.left(); x <= clip.right(); ++x) { const auto color = image.pixelColor(x, y); if ((color.red() > 220 && color.green() < 50 && color.blue() < 50) || (color.blue() > 220 && color.green() < 50 && color.red() < 50)) { colored = colored.united(QRect(x, y, 1, 1)); click = {x, y}; ++count; } } QVERIFY2(count > 30, qPrintable(QString("No saved appearance at page %1 rotation %2 zoom %3 DPR %4").arg(page).arg(rotation).arg(zoom).arg(window.devicePixelRatio()))); QVERIFY(std::abs(colored.left() - expected.left()) <= 2); QVERIFY(std::abs(colored.top() - expected.top()) <= 2); QVERIFY(std::abs(colored.right() + 1 - expected.right()) <= 2); QVERIFY(std::abs(colored.bottom() + 1 - expected.bottom()) <= 2); const int beforeLinks = links.size(), beforeComments = comments.size(); QMouseEvent event(QEvent::MouseButtonRelease, QPointF(click), QPointF(click), Qt::LeftButton, Qt::NoButton, Qt::NoModifier); QCoreApplication::sendEvent(&canvas, &event); QCOMPARE(links.size(), beforeLinks + (page < 24)); QCOMPARE(comments.size(), beforeComments + (page >= 24)); } } worker.stop(); } void backgroundThemePreservesWhitePage() { PdfCanvas canvas; canvas.setWidth(250); canvas.setHeight(180); const QColor background("#e0e5e8"); canvas.setBackgroundColor(background); canvas.setDocument(nullptr, {QVariantMap{{"pageIndex", 0}, {"width", 100.0}, {"height", 100.0}}}, 1); QImage image(250, 180, QImage::Format_ARGB32); image.fill(Qt::transparent); QPainter painter(&image); canvas.paint(&painter); painter.end(); QCOMPARE(image.pixelColor(0, 0), background); QCOMPARE(image.pixelColor(200, 150), QColor(Qt::white)); canvas.setBackgroundColor({}); QCOMPARE(canvas.backgroundColor(), background); } }; int main(int argc, char **argv) { if (qEnvironmentVariableIsEmpty("QT_QPA_PLATFORM")) qputenv("QT_QPA_PLATFORM", "offscreen"); QGuiApplication application(argc, argv); PdfCanvasTest test; return QTest::qExec(&test, argc, argv); } #include "test_pdf_canvas.moc"