#include "app/controller.h" #include "app/pdf_canvas.h" #include "web/web_profile.h" #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include using namespace docview; static QByteArray digest(const QString &path) { QFile file(path); if (!file.open(QIODevice::ReadOnly)) return {}; QCryptographicHash hash(QCryptographicHash::Sha256); return hash.addData(&file) ? hash.result() : QByteArray{}; } class HeavyPdfTest : public QObject { Q_OBJECT private slots: void realHeavyPagesRemainResponsiveAndCancel() { const auto heavy = qEnvironmentVariable("DOCVIEW_HEAVY_PDF"); const auto light = qEnvironmentVariable("DOCVIEW_LIGHT_PDF"); const auto output = qEnvironmentVariable("DOCVIEW_HEAVY_OUTPUT"); QVERIFY(!heavy.isEmpty() && !light.isEmpty() && !output.isEmpty()); const auto beforeHeavy = digest(heavy), beforeLight = digest(light); QVERIFY(!beforeHeavy.isEmpty() && !beforeLight.isEmpty()); QTemporaryDir isolated; QVERIFY(isolated.isValid()); const auto config = isolated.filePath("config.toml"); QFile configuration(config); QVERIFY(configuration.open(QIODevice::WriteOnly)); configuration.write("schema_version=1\n[privacy]\nremember_position=false\nrecent_documents=0\n[performance]\nprefetch_pages=0\n"); configuration.close(); Controller reader; reader.initialize(config, false, {config, isolated.filePath("state.json"), isolated.filePath("cache"), isolated.filePath("logs")}); QQmlApplicationEngine engine; engine.rootContext()->setContextProperty("reader", &reader); engine.load(QUrl("qrc:/qml/Main.qml")); QVERIFY(!engine.rootObjects().isEmpty()); auto *window = qobject_cast(engine.rootObjects().first()); QVERIFY(window); reader.attachWindow(window); window->resize(qMin(1100, window->screen()->availableGeometry().width()), qMin(760, window->screen()->availableGeometry().height())); auto *canvas = window->findChild(); QVERIFY(canvas); QVERIFY(QTest::qWaitForWindowExposed(window, 10000)); window->requestActivate(); QTRY_COMPARE_WITH_TIMEOUT(QGuiApplication::focusWindow(), window, 5000); QElapsedTimer clock; clock.start(); qint64 previous = 0, longestGap = 0; int ticks = 0, peakPressure = 0; QTimer heartbeat; connect(&heartbeat, &QTimer::timeout, this, [&] { const auto now = clock.elapsed(); longestGap = qMax(longestGap, now - previous); peakPressure = qMax(peakPressure, canvas->memoryPressureLevel()); previous = now; ++ticks; }); heartbeat.start(10); QJsonArray phases; bool success = false; const auto save = qScopeGuard([&] { heartbeat.stop(); QJsonObject result{{"success", success}, {"phases", phases}, {"elapsedMs", clock.elapsed()}, {"heartbeatTicks", ticks}, {"longestHeartbeatGapMs", longestGap}, {"maximumMemoryPressureLevel", peakPressure}, {"qtPlatform", QGuiApplication::platformName()}, {"qtVersion", qVersion()}, {"quickBackend", qEnvironmentVariable("QT_QUICK_BACKEND")}, {"windowWidth", window->width()}, {"windowHeight", window->height()}, {"devicePixelRatio", window->devicePixelRatio()}, {"heavySha256", QString::fromLatin1(beforeHeavy.toHex())}, {"lightSha256", QString::fromLatin1(beforeLight.toHex())}, {"heavyUnchanged", digest(heavy) == beforeHeavy}, {"lightUnchanged", digest(light) == beforeLight}, {"scope", "Production Controller/QML/Canvas with canonical sandboxed workers; GUI timer gaps and dispatched RPCs, not physical scanout or direct instrumentation inside PDFium"}}; QSaveFile file(output + "/measurement.json"); if (file.open(QIODevice::WriteOnly)) { file.write(QJsonDocument(result).toJson()); file.commit(); } }); const auto ready = [&] { return reader.state() == "Ready" && canvas->viewportReady(); }; const auto sessionFor = [&](const QString &path) -> Session * { for (auto *child : reader.children()) { auto *session = dynamic_cast(child); if (session && session->source == path) return session; } return nullptr; }; const auto sampleMarker = [&](int page) { const QImage capture = window->grabWindow(); const QPointF logical = canvas->mapToScene(canvas->pageToCanvas(page, {46, 796})); const QPoint point(qRound(logical.x() * window->devicePixelRatio()), qRound(logical.y() * window->devicePixelRatio())); return capture.rect().contains(point) ? capture.pixelColor(point) : QColor(); }; const auto centralPixels = [&](int page) { const QImage capture = window->grabWindow(); const auto point = [&](QPointF pdf) { return canvas->mapToScene(canvas->pageToCanvas(page, pdf)) * window->devicePixelRatio(); }; const QRect area = QRectF(point({80, 220}), point({480, 620})).normalized() .toAlignedRect().intersected(capture.rect()); int dark = 0, paper = 0; for (int y = area.top(); y <= area.bottom(); y += 2) for (int x = area.left(); x <= area.right(); x += 2) { const auto color = capture.pixelColor(x, y); dark += color.lightness() < 80; paper += color.red() > 220 && color.green() > 220 && color.blue() > 220; } return QPair{dark, paper}; }; // Complete both expensive pages before testing interruption: a rejected // document or empty canvas cannot count as a successful heavy workload. auto started = clock.elapsed(); reader.openPath(heavy); QTRY_VERIFY_WITH_TIMEOUT(ready(), 30000); QCOMPARE(reader.pages().size(), 2); QCOMPARE(canvas->currentPage(), 0); canvas->setPrefetchPages(0); canvas->fitPage(); QTRY_VERIFY_WITH_TIMEOUT(canvas->viewportReady(), 30000); const auto blue = sampleMarker(0); QVERIFY2(blue.isValid() && blue.red() < 50 && blue.green() > 50 && blue.green() < 100 && blue.blue() > 130, "High-resolution page did not show its original blue marker"); const auto rasterPixels = centralPixels(0); QVERIFY2(rasterPixels.first > 1000 && rasterPixels.second > 1000, "Decoded scan pixels and paper were not both visible in the central image area"); QVERIFY(window->grabWindow().save(output + "/raster.png")); phases.append(QJsonObject{{"name", "complete-high-resolution-raster"}, {"elapsedMs", clock.elapsed() - started}, {"pageIndex", 0}, {"originalMarkerVisible", true}, {"darkRasterSamples", rasterPixels.first}, {"paperSamples", rasterPixels.second}}); started = clock.elapsed(); reader.execute("nav.page", {{"page", 2}}); QTRY_VERIFY_WITH_TIMEOUT(canvas->currentPage() == 1 && ready(), 30000); const auto magenta = sampleMarker(1); QVERIFY2(magenta.isValid() && magenta.red() > 180 && magenta.green() < 50 && magenta.blue() > 120, "Transparent-vector page did not show its original magenta marker"); const auto vectorPixels = centralPixels(1); QVERIFY2(vectorPixels.first > 1000 && vectorPixels.second == 0, "Dense multiplied vector paths were absent from the central page area"); QVERIFY(window->grabWindow().save(output + "/vector.png")); phases.append(QJsonObject{{"name", "complete-transparent-vectors"}, {"elapsedMs", clock.elapsed() - started}, {"pageIndex", 1}, {"originalMarkerVisible", true}, {"darkVectorSamples", vectorPixels.first}}); // Cancel after the real protected worker has accepted an open RPC. reader.openPath(light); QTRY_VERIFY_WITH_TIMEOUT(ready(), 15000); const auto retainedToken = reader.webToken(); reader.openPath(heavy); QCOMPARE(reader.state(), "Opening"); QTRY_VERIFY_WITH_TIMEOUT(sessionFor(heavy) && sessionFor(heavy)->worker && sessionFor(heavy)->worker->activeRequestCount("open") == 1, 10000); QPointer cancelled = sessionFor(heavy); started = clock.elapsed(); QTest::keyClick(window, Qt::Key_Escape); QCOMPARE(reader.webToken(), retainedToken); QCOMPARE(reader.state(), "Ready"); QTRY_VERIFY_WITH_TIMEOUT(cancelled.isNull(), 5000); QTRY_VERIFY_WITH_TIMEOUT(canvas->viewportReady(), 15000); phases.append(QJsonObject{{"name", "escape-during-open-rpc"}, {"elapsedMs", clock.elapsed() - started}, {"activeOpenBeforeCancel", true}, {"retainedOldDocument", true}, {"oldSessionDestroyed", true}}); // Zoom invalidates old vector tiles. Replace while at least one new // render RPC is outstanding; no fake sleep worker is substituted. reader.openPath(heavy); QTRY_VERIFY_WITH_TIMEOUT(ready(), 30000); reader.execute("nav.page", {{"page", 2}}); QTRY_VERIFY_WITH_TIMEOUT(canvas->currentPage() == 1 && ready(), 30000); QPointer replaced = sessionFor(heavy); QVERIFY(replaced); canvas->setZoom(350); QTRY_VERIFY_WITH_TIMEOUT(reader.benchmarkSnapshot().value("activeRenders").toInt() == 1 && !canvas->viewportReady(), 10000); const auto outstanding = canvas->pendingRenderCount(); const auto replacedToken = replaced->token; started = clock.elapsed(); reader.openPath(light); QTRY_VERIFY_WITH_TIMEOUT(reader.webToken() != replacedToken && ready(), 15000); QTRY_VERIFY_WITH_TIMEOUT(replaced.isNull(), 5000); const auto lightToken = reader.webToken(); QTest::qWait(300); // Allow any already-delivered obsolete Qt callbacks to run. QCOMPARE(reader.webToken(), lightToken); QCOMPARE(canvas->currentPage(), 0); QCOMPARE(reader.title(), QFileInfo(light).fileName()); QVERIFY(ready()); QVERIFY(window->grabWindow().save(output + "/replacement.png")); phases.append(QJsonObject{{"name", "replace-during-vector-render"}, {"elapsedMs", clock.elapsed() - started}, {"activeRenderBeforeReplace", true}, {"pendingRendersBeforeReplace", qint64(outstanding)}, {"oldSessionDestroyed", true}, {"replacementStableAfter300ms", true}}); reader.closeDocument(); QCOMPARE(reader.state(), "Empty"); QCOMPARE(canvas->cacheCostBytes(), 0); QVERIFY2(ticks > 0 && longestGap < 1500, qPrintable(QString("GUI heartbeat gap: %1 ms").arg(longestGap))); QCOMPARE(peakPressure, 0); QCOMPARE(digest(heavy), beforeHeavy); QCOMPARE(digest(light), beforeLight); success = true; } }; int main(int argc, char **argv) { QTemporaryDir isolated; if (!isolated.isValid()) return 2; qputenv("XDG_CONFIG_HOME", isolated.filePath("config").toUtf8()); qputenv("XDG_STATE_HOME", isolated.filePath("state").toUtf8()); qputenv("XDG_CACHE_HOME", isolated.filePath("cache").toUtf8()); registerDocumentScheme(); QtWebEngineQuick::initialize(); QGuiApplication app(argc, argv); app.setQuitOnLastWindowClosed(false); app.setApplicationName("DocView heavy PDF validation"); app.setOrganizationName("DocView"); qmlRegisterType("DocView", 1, 0, "PdfCanvas"); HeavyPdfTest test; return QTest::qExec(&test, argc, argv); } #include "test_heavy_pdf.moc"