initial commit

This commit is contained in:
2026-09-21 13:41:40 +09:00
commit 855c7328df
411 changed files with 85352 additions and 0 deletions
+509
View File
@@ -0,0 +1,509 @@
#include <QtTest>
#include <QGuiApplication>
#include <QCborArray>
#include "app/pdf_canvas.h"
#include "broker/font_broker.h"
#include <QPainter>
#include <QQuickWindow>
#include <QSGRendererInterface>
#include <QMouseEvent>
#include "pdf/pdf_document.h"
#include "font_test_fixture.h"
#include <QTemporaryDir>
#include <QCryptographicHash>
using namespace docview;
static void provideFonts(WorkerProcess &worker, FontBroker &broker) {
worker.setFontRequestHandler([&broker](const QString &operation, const QCborMap &payload,
std::function<void(QCborMap)> 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<double>::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<int>("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<void(QCborMap)> held; int maps = 0;
worker.setFontRequestHandler([&](const QString &operation, const QCborMap &, std::function<void(QCborMap)> 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"