Files
2026-09-21 13:41:40 +09:00

3270 lines
179 KiB
C++
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#include "app/controller.h"
#include "app/pdf_canvas.h"
#include "common/contracts.h"
#include "common/memory_pressure.h"
#include "core/state.h"
#include "web/web_profile.h"
#include "font_test_fixture.h"
#include "interaction_measurement.h"
#include <QCryptographicHash>
#include <QElapsedTimer>
#include <QFile>
#include <QGuiApplication>
#include <QInputMethodEvent>
#include <QJsonArray>
#include <QJsonDocument>
#include <QJsonObject>
#include <QProcess>
#include <QQmlApplicationEngine>
#include <QQmlContext>
#include <QQmlExpression>
#include <QSignalSpy>
#include <QSaveFile>
#include <QScreen>
#include <QSysInfo>
#include <QScopeGuard>
#include <QTemporaryDir>
#include <QtTest>
#include <QtWebEngineQuick>
#include <limits>
#include <cmath>
#include <memory>
#include <zip.h>
#ifdef Q_OS_LINUX
#include <unistd.h>
#include <sys/socket.h>
#include <signal.h>
#include <QScopeGuard>
#endif
using namespace docview;
class ScriptProbe : public QObject {
Q_OBJECT
Q_PROPERTY(QVariant value MEMBER value NOTIFY updated)
public:
QVariant value;
signals:
void updated();
};
#ifndef Q_MOC_RUN
namespace {
bool writeBytes(const QString &path, const QByteArray &bytes)
{
QFile file(path);
return file.open(QIODevice::WriteOnly | QIODevice::Truncate) && file.write(bytes) == bytes.size();
}
QByteArray fileHash(const QString &path)
{
QFile file(path);
if (!file.open(QIODevice::ReadOnly))
return {};
QCryptographicHash hash(QCryptographicHash::Sha256);
return hash.addData(&file) ? hash.result() : QByteArray{};
}
QByteArray pagedPdf(int pageCount, int outlineCount = 0, int outlinePageStride = 0, int externalOutlines = 0)
{
QList<QByteArray> objects;
objects << "<< /Type /Catalog /Pages 2 0 R >>" << QByteArray{};
QByteArray kids;
for (int page = 0; page < pageCount; ++page) {
const auto pageObject = QByteArray::number(3 + page * 2);
const auto streamObject = QByteArray::number(4 + page * 2);
kids += pageObject + " 0 R ";
objects << "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Contents "
+ streamObject + " 0 R /Resources << >> >>";
const QByteArray color = pageCount >= 5000 && page == pageCount - 1 ? "0.1 0.7 0.3" : "0.1 0.3 0.7";
const QByteArray stream = color + " rg 50 50 495 742 re f\n1 1 1 rg 80 100 435 "
+ QByteArray::number(20 + (page % 50) * 8) + " re f\n";
objects << "<< /Length " + QByteArray::number(stream.size()) + " >>\nstream\n" + stream + "endstream";
}
objects[1] = "<< /Type /Pages /Count " + QByteArray::number(pageCount) + " /Kids [" + kids + "] >>";
if (outlineCount) {
const int root = objects.size() + 1;
objects[0] = "<< /Type /Catalog /Pages 2 0 R /Outlines " + QByteArray::number(root) + " 0 R >>";
objects << "<< /Type /Outlines /First " + QByteArray::number(root + 1) + " 0 R /Last "
+ QByteArray::number(root + outlineCount) + " 0 R /Count " + QByteArray::number(outlineCount) + " >>";
for (int index = 0; index < outlineCount; ++index) {
const int id = objects.size() + 1;
QByteArray item = "<< /Title (Chapter " + QByteArray::number(index) + ") /Parent "
+ QByteArray::number(root) + " 0 R";
if (index < externalOutlines)
item += index % 2 ? " /A << /S /Launch /F (blocked-program) >>"
: " /A << /S /URI /URI (https://example.invalid/outline) >>";
else
item += " /Dest [" + QByteArray::number(3 + 2 * qMin(pageCount - 1, index * outlinePageStride)) + " 0 R /Fit]";
if (index) item += " /Prev " + QByteArray::number(id - 1) + " 0 R";
if (index + 1 < outlineCount) item += " /Next " + QByteArray::number(id + 1) + " 0 R";
objects << item + " >>";
}
}
QByteArray document = "%PDF-1.7\n";
QList<qsizetype> offsets{0};
for (qsizetype i = 0; i < objects.size(); ++i) {
offsets << document.size();
document += QByteArray::number(i + 1) + " 0 obj\n" + objects[i] + "\nendobj\n";
}
const auto xref = document.size();
document += "xref\n0 " + QByteArray::number(offsets.size()) + "\n0000000000 65535 f \n";
for (qsizetype i = 1; i < offsets.size(); ++i)
document += QByteArray::number(offsets[i]).rightJustified(10, '0') + " 00000 n \n";
document += "trailer\n<< /Size " + QByteArray::number(offsets.size()) + " /Root 1 0 R >>\nstartxref\n"
+ QByteArray::number(xref) + "\n%%EOF\n";
return document;
}
bool makeZip(const QString &path, const QList<QPair<QByteArray, QByteArray>> &files)
{
int error = 0;
zip_t *archive = zip_open(QFile::encodeName(path).constData(), ZIP_CREATE | ZIP_TRUNCATE, &error);
if (!archive)
return false;
for (const auto &[name, bytes] : files) {
auto *source = zip_source_buffer(archive, bytes.constData(), static_cast<zip_uint64_t>(bytes.size()), 0);
if (!source) { zip_discard(archive); return false; }
const auto index = zip_file_add(archive, name.constData(), source, ZIP_FL_ENC_UTF_8);
if (index < 0) { zip_source_free(source); zip_discard(archive); return false; }
if (zip_set_file_compression(archive, static_cast<zip_uint64_t>(index), ZIP_CM_STORE, 0) < 0
|| zip_file_set_external_attributes(archive, static_cast<zip_uint64_t>(index), 0, ZIP_OPSYS_UNIX, 0100600u << 16) < 0) {
zip_discard(archive);
return false;
}
}
if (zip_close(archive) < 0) { zip_discard(archive); return false; }
return true;
}
int greenPagePixels(const QImage &image)
{
int count = 0;
for (int y = 0; y < image.height(); y += 2) {
for (int x = 0; x < image.width(); x += 2) {
const auto color = image.pixelColor(x, y);
if (color.red() < 45 && color.green() >= 160 && color.green() <= 200
&& color.blue() >= 60 && color.blue() <= 95)
++count;
}
}
return count;
}
QByteArray htmlFixture()
{
return R"(<!doctype html><html lang="en"><head><meta charset="utf-8"><title>Fixture</title>
<link rel="stylesheet" href="style.css"></head><body>
<h1 id="first">First heading</h1><p>Readable document</p><div style="height:1200px"></div>
<h2 id="second">Second heading</h2><a id="local" href="other.html#destination">Local target</a>
<div style="height:1200px"></div>
<script>document.body.setAttribute('data-document-script', 'executed');</script>
</body></html>)";
}
QList<QPair<QByteArray, QByteArray>> epubFixture()
{
return {
{"mimetype", "application/epub+zip"},
{"META-INF/container.xml", R"(<container xmlns="urn:oasis:names:tc:opendocument:xmlns:container" version="1.0"><rootfiles><rootfile full-path="OPS/book.opf" media-type="application/oebps-package+xml"/></rootfiles></container>)"},
{"OPS/book.opf", R"(<package xmlns="http://www.idpf.org/2007/opf" version="3.0" unique-identifier="id"><metadata xmlns:dc="http://purl.org/dc/elements/1.1/"><dc:identifier id="id">urn:docview:integration</dc:identifier><dc:title>Integration book</dc:title><dc:language>en</dc:language></metadata><manifest><item id="one" href="one.xhtml" media-type="application/xhtml+xml"/><item id="aside" href="aside.xhtml" media-type="application/xhtml+xml"/><item id="two" href="two.xhtml" media-type="application/xhtml+xml"/><item id="nav" href="nav.xhtml" media-type="application/xhtml+xml" properties="nav"/></manifest><spine><itemref idref="one"/><itemref idref="aside" linear="no"/><itemref idref="two"/></spine></package>)"},
{"OPS/nav.xhtml", R"(<html xmlns="http://www.w3.org/1999/xhtml" xmlns:epub="http://www.idpf.org/2007/ops"><head><title>Contents</title></head><body><nav epub:type="toc"><ol><li><a href="two.xhtml#two">Second in contents</a></li><li><a href="one.xhtml#one">First in reading order</a></li></ol></nav></body></html>)"},
{"OPS/one.xhtml", R"(<html xmlns="http://www.w3.org/1999/xhtml"><head><title>One</title></head><body><h1 id="one">Chapter One</h1><p>First spine chapter.</p></body></html>)"},
{"OPS/aside.xhtml", R"(<html xmlns="http://www.w3.org/1999/xhtml"><head><title>Aside</title></head><body><h1>Nonlinear aside</h1></body></html>)"},
{"OPS/two.xhtml", R"(<html xmlns="http://www.w3.org/1999/xhtml"><head><title>Two</title></head><body><h1 id="two">Chapter Two</h1><p>Second linear chapter.</p></body></html>)"}
};
}
} // namespace
#endif
class AppTest : public QObject {
Q_OBJECT
QTemporaryDir documents_;
std::unique_ptr<Controller> reader_;
std::unique_ptr<QQmlApplicationEngine> engine_;
QQuickWindow *window_ = nullptr;
PdfCanvas *canvas_ = nullptr;
ScriptProbe probe_;
QString manyPages_, stressPages_, html_, zip_, epub_, configuration_;
StoragePaths testStorage() const
{
// XDG variables do not redirect Windows Known Folders. Inject all
// writable locations so history-clear tests never touch real state.
return {configuration_, documents_.filePath("state/state.json"),
documents_.filePath("cache"), documents_.filePath("logs")};
}
Session *activeSession() const
{
for (auto *object : reader_->children()) {
if (auto *session = dynamic_cast<Session *>(object); session && session->token == reader_->webToken())
return session;
}
return nullptr;
}
QObject *activeWeb() const
{
if (!window_)
return nullptr;
for (auto *object : window_->findChildren<QObject *>()) {
if (object->property("documentToken").toString() == reader_->webToken()
&& object->property("isCurrent").toBool())
return object;
}
return nullptr;
}
bool evaluateWeb(const QString &javascript, QVariant *result)
{
auto *view = activeWeb();
if (!view)
return false;
const auto json = QJsonDocument(QJsonArray{javascript}).toJson(QJsonDocument::Compact);
const auto encoded = QString::fromUtf8(json.mid(1, json.size() - 2));
QSignalSpy completed(&probe_, &ScriptProbe::updated);
probe_.value = {};
QQmlExpression expression(engine_->rootContext(), view,
"runJavaScript(" + encoded + ", 1, function(value) { testProbe.value = value; })");
expression.evaluate();
if (expression.hasError()) {
qWarning() << expression.error();
return false;
}
QElapsedTimer timer;
timer.start();
while (completed.isEmpty() && timer.elapsed() < 10000)
QTest::qWait(5);
if (completed.isEmpty())
return false;
*result = probe_.value;
return true;
}
void key(Qt::Key key, Qt::KeyboardModifiers modifiers = {})
{
// QtTest's Qt::Key overload leaves the event text lowercase even with
// Shift; the character overload models the layout-produced text.
if (key >= Qt::Key_A && key <= Qt::Key_Z && modifiers.testFlag(Qt::ShiftModifier))
QTest::keyClick(window_, static_cast<char>(key), modifiers);
else
QTest::keyClick(window_, key, modifiers);
QCoreApplication::processEvents();
}
void startUi(bool readOnly = false)
{
reader_ = std::make_unique<Controller>();
reader_->initialize(configuration_, readOnly, testStorage());
engine_ = std::make_unique<QQmlApplicationEngine>();
engine_->rootContext()->setContextProperty("reader", reader_.get());
engine_->rootContext()->setContextProperty("testProbe", &probe_);
engine_->load(QUrl("qrc:/qml/Main.qml"));
QVERIFY2(!engine_->rootObjects().isEmpty(), "Main.qml failed to instantiate");
window_ = qobject_cast<QQuickWindow *>(engine_->rootObjects().first());
QVERIFY(window_);
reader_->attachWindow(window_);
canvas_ = window_->findChild<PdfCanvas *>();
QVERIFY(canvas_);
QVERIFY(QTest::qWaitForWindowExposed(window_, 10000));
window_->requestActivate();
QTRY_COMPARE_WITH_TIMEOUT(QGuiApplication::focusWindow(), window_, 5000);
}
bool resizeTestWindow(const QSize &size)
{
if (QGuiApplication::platformName() != "wayland" || qEnvironmentVariable("DOCVIEW_PRIVATE_WAYLAND_TEST") != "1") {
window_->resize(size);
return true;
}
// This opt-in belongs exclusively to run_wayland_validation.py's
// private compositor. Select its socket and only this process's window.
const auto socket = qEnvironmentVariable("SWAYSOCK");
const auto runtime = qEnvironmentVariable("XDG_RUNTIME_DIR");
if (socket.isEmpty() || !QDir::isAbsolutePath(socket) || !QFileInfo::exists(socket)
|| QFileInfo(socket).absolutePath() != runtime
|| !QFileInfo(runtime).fileName().startsWith("docview-wayland-")) return false;
QProcess command;
const auto resize = QString("[pid=%1] resize set width %2 px height %3 px")
.arg(QCoreApplication::applicationPid()).arg(size.width()).arg(size.height());
command.start("swaymsg", {"-s", socket, "-r", "-t", "command", resize});
if (!command.waitForStarted(2000) || !command.waitForFinished(3000)) {
command.kill(); command.waitForFinished(1000); return false;
}
const auto bytes = command.readAllStandardOutput();
if (command.exitStatus() != QProcess::NormalExit || command.exitCode() != 0 || bytes.size() > 65536) return false;
QJsonParseError error;
const auto response = QJsonDocument::fromJson(bytes, &error);
if (error.error != QJsonParseError::NoError || !response.isArray() || response.array().isEmpty()) return false;
for (const auto &value : response.array())
if (!value.isObject() || !value.toObject().value("success").isBool() || !value.toObject().value("success").toBool()) return false;
// The client configure alone can precede Sway's transaction commit.
// Wait for both client and compositor geometry before another resize.
QElapsedTimer deadline; deadline.start();
while (deadline.elapsed() < 5000) {
QTest::qWait(10);
QProcess treeCommand;
treeCommand.start("swaymsg", {"-s", socket, "-r", "-t", "get_tree"});
if (!treeCommand.waitForStarted(1000) || !treeCommand.waitForFinished(1000)) {
treeCommand.kill(); treeCommand.waitForFinished(1000); return false;
}
const auto treeBytes = treeCommand.readAllStandardOutput();
if (treeCommand.exitCode() != 0 || treeBytes.size() > 1024 * 1024) return false;
const auto tree = QJsonDocument::fromJson(treeBytes, &error);
if (error.error != QJsonParseError::NoError || !tree.isObject()) return false;
int visited = 0;
std::function<bool(const QJsonObject &, int)> matches = [&](const QJsonObject &node, int depth) {
if (++visited > 4096 || depth > 32) return false;
if (node.value("pid").toDouble() == double(QCoreApplication::applicationPid())) {
const auto rect = node.value("rect").toObject();
return rect.value("width").toInt() == size.width() && rect.value("height").toInt() == size.height();
}
for (const auto *key : {"nodes", "floating_nodes"})
for (const auto &child : node.value(key).toArray())
if (matches(child.toObject(), depth + 1)) return true;
return false;
};
if (window_->size() == size && matches(tree.object(), 0)) return true;
}
return false;
}
bool captureRecentScreen(const QString &name)
{
const auto directory = qEnvironmentVariable("DOCVIEW_RECENT_SCREENSHOTS");
if (directory.isEmpty())
return true;
if (!QDir().mkpath(directory))
return false;
window_->update();
QTest::qWait(100);
const auto image = window_->grabWindow();
return !image.isNull() && image.save(QDir(directory).filePath(name + ".png"));
}
private slots:
void initTestCase()
{
QVERIFY(documents_.isValid());
manyPages_ = documents_.filePath("50 pages 日本語.pdf");
stressPages_ = documents_.filePath("5000 pages.pdf");
html_ = documents_.filePath("index.html");
zip_ = documents_.filePath("book.zip");
epub_ = documents_.filePath("book.epub");
configuration_ = documents_.filePath("config.toml");
QVERIFY(writeBytes(manyPages_, pagedPdf(50)));
QVERIFY(writeBytes(stressPages_, pagedPdf(5000)));
QVERIFY(writeBytes(html_, htmlFixture()));
QVERIFY(writeBytes(documents_.filePath("style.css"), "body{background:rgb(240,245,250)}h1{color:rgb(10,20,30)}"));
QVERIFY(writeBytes(documents_.filePath("other.html"), "<!doctype html><h1 id='destination'>Linked destination</h1>"));
QVERIFY(writeBytes(configuration_, "[privacy]\nremember_position=false\nrecent_documents=0\n"));
QVERIFY(makeZip(zip_, {{"index.html", htmlFixture()}, {"style.css", "h1{color:rgb(40,50,60)}"},
{"other.html", "<!doctype html><h1 id='destination'>Archive linked destination</h1>"}}));
QVERIFY(makeZip(epub_, epubFixture()));
#ifdef Q_OS_WIN
const QString suffix = ".exe";
#else
const QString suffix;
#endif
QVERIFY(QFileInfo::exists(QCoreApplication::applicationDirPath() + "/docview-pdf-worker" + suffix));
QVERIFY(QFileInfo::exists(QCoreApplication::applicationDirPath() + "/docview-archive-worker" + suffix));
}
void init()
{
QByteArray settings = "[privacy]\nremember_position=false\nrecent_documents=0\n";
if (QByteArray(QTest::currentTestFunction()) == "visibleSidebarKeepsDocumentUsable")
settings += "[ui]\npages_visible=true\n";
QVERIFY(writeBytes(configuration_, settings));
startUi();
}
void cleanup()
{
if (reader_)
reader_->cancel();
engine_.reset();
window_ = nullptr;
canvas_ = nullptr;
reader_.reset();
QCoreApplication::sendPostedEvents(nullptr, QEvent::DeferredDelete);
QCoreApplication::processEvents();
}
void recentDocumentsRespectPrivacyCapAndClear()
{
reader_->clearHistory();
QVERIFY(writeBytes(configuration_, "[privacy]\nremember_position=false\nrecent_documents=20\n"));
QVERIFY(reader_->command("reload"));
auto *store = reader_->findChild<StateStore *>();
QVERIFY(store);
QList<QPair<QString, QByteArray>> originals;
for (int i = 0; i < 8; ++i) {
const auto path = documents_.filePath(QString("recent %1 日本語.pdf").arg(i));
QVERIFY(writeBytes(path, pagedPdf(1)));
originals.append({path, fileHash(path)});
QVERIFY(store->remember(path, {}));
}
QVERIFY(store->flush());
cleanup();
QVERIFY(writeBytes(configuration_, "[privacy]\nremember_position=false\nrecent_documents=3\n"));
startUi();
auto *list = window_->findChild<QQuickItem *>("recentDocumentsList");
auto *clear = window_->findChild<QQuickItem *>("clearRecentDocuments");
QVERIFY(list && clear);
QCOMPARE(reader_->historyCount(), 8);
QCOMPARE(reader_->recentDocuments().size(), 3);
QCOMPARE(reader_->recentDocuments().first().toMap().value("title").toString(), QStringLiteral("recent 7 日本語.pdf"));
QCOMPARE(list->property("count").toInt(), 3);
QVERIFY(list->isVisible());
QTRY_VERIFY(list->hasActiveFocus());
window_->resize(1100, 760);
QVERIFY(captureRecentScreen("recent-dark-1100x760"));
const auto oldId = reader_->recentDocuments().first().toMap().value("documentId").toString();
QVERIFY(writeBytes(configuration_, "[privacy]\nremember_position=false\nrecent_documents=0\n"));
QVERIFY(reader_->command("reload"));
QVERIFY(reader_->recentDocuments().isEmpty());
QVERIFY(!list->isVisible());
QVERIFY(!clear->isVisible());
reader_->openRecent(oldId);
QCOMPARE(reader_->state(), "Empty");
QVERIFY(reader_->notice().startsWith("E_OPEN_FAILED"));
QVERIFY(writeBytes(configuration_, "[privacy]\nremember_position=false\nrecent_documents=100\n"));
QVERIFY(reader_->command("reload"));
QCOMPARE(list->property("count").toInt(), 8);
window_->resize(480, 360);
QTRY_VERIFY(list->height() > 0 && list->height() <= 150);
auto *screen = window_->findChild<QQuickItem *>("initialScreen");
QVERIFY(screen && screen->height() < window_->height());
auto *notice = window_->findChild<QQuickItem *>("noticeBanner");
QVERIFY(notice && notice->isVisible());
QTRY_VERIFY(clear->mapToScene(QPointF(0, clear->height())).y() <= notice->mapToScene(QPointF()).y());
QVERIFY(captureRecentScreen("recent-dark-480x360"));
QVERIFY(writeBytes(configuration_, "[privacy]\nremember_position=false\nrecent_documents=100\n[ui]\ntheme=\"light\"\n"));
QVERIFY(reader_->command("reload"));
window_->resize(1100, 760);
QVERIFY(captureRecentScreen("recent-light-1100x760"));
QTRY_VERIFY(list->hasActiveFocus());
key(Qt::Key_G, Qt::ShiftModifier);
QCOMPARE(list->property("currentIndex").toInt(), 7);
key(Qt::Key_Tab);
QVERIFY(clear->hasActiveFocus());
key(Qt::Key_Return);
QCOMPARE(reader_->mode(), "dialog");
auto *dialog = window_->findChild<QObject *>("historyDialog");
auto *confirmation = window_->findChild<QObject *>("historyConfirmation");
QVERIFY(dialog && confirmation);
QVERIFY(confirmation->property("text").toString().startsWith("8 "));
key(Qt::Key_Escape);
QTRY_COMPARE(reader_->mode(), "normal");
QCOMPARE(reader_->historyCount(), 8);
key(Qt::Key_Tab);
key(Qt::Key_Return);
QCOMPARE(reader_->mode(), "dialog");
QVERIFY(QMetaObject::invokeMethod(dialog, "accept"));
QTRY_COMPARE(reader_->historyCount(), 0);
QCOMPARE(list->property("count").toInt(), 0);
QVERIFY(!list->isVisible());
for (const auto &[path, hash] : originals)
QCOMPARE(fileHash(path), hash);
cleanup();
startUi();
QVERIFY(reader_->recentDocuments().isEmpty());
QCOMPARE(reader_->historyCount(), 0);
}
void recentDocumentKeyboardReopenRestoresMatchingOriginal()
{
reader_->clearHistory();
QVERIFY(writeBytes(configuration_, "[privacy]\nremember_position=true\nrecent_documents=5\n"));
QVERIFY(reader_->command("reload"));
auto *store = reader_->findChild<StateStore *>();
QVERIFY(store);
QVERIFY(store->remember(manyPages_, {{"pageIndex", 41}, {"zoom", 100.0}}));
QVERIFY(store->remember(html_, {}));
QVERIFY(store->flush());
const auto pdfHash = fileHash(manyPages_), htmlHash = fileHash(html_);
cleanup();
startUi();
auto *list = window_->findChild<QQuickItem *>("recentDocumentsList");
QVERIFY(list);
QCOMPARE(list->property("count").toInt(), 2);
QTRY_VERIFY(list->hasActiveFocus());
key(Qt::Key_J);
QCOMPARE(list->property("currentIndex").toInt(), 1);
key(Qt::Key_Up);
QCOMPARE(list->property("currentIndex").toInt(), 0);
key(Qt::Key_Down);
QCOMPARE(list->property("currentIndex").toInt(), 1);
key(Qt::Key_Return);
QTRY_COMPARE_WITH_TIMEOUT(reader_->state(), "Ready", 15000);
QCOMPARE(reader_->format(), "pdf");
QCOMPARE(reader_->title(), QFileInfo(manyPages_).fileName());
QCOMPARE(canvas_->currentPage(), 41);
QTRY_VERIFY_WITH_TIMEOUT(canvas_->viewportReady(), 15000);
QVERIFY(!list->isVisible());
QCOMPARE(fileHash(manyPages_), pdfHash);
QCOMPARE(fileHash(html_), htmlHash);
}
void recentDocumentMissingOrReplaced_data()
{
QTest::addColumn<bool>("replaced");
QTest::newRow("missing") << false;
QTest::newRow("replaced-with-identical-bytes-and-mtime") << true;
}
void recentDocumentMissingOrReplaced()
{
QFETCH(bool, replaced);
reader_->clearHistory();
QVERIFY(writeBytes(configuration_, "[privacy]\nremember_position=true\nrecent_documents=5\n"));
QVERIFY(reader_->command("reload"));
const auto path = documents_.filePath(replaced ? "replaced recent.pdf" : "missing recent.pdf");
const auto bytes = pagedPdf(50);
QVERIFY(writeBytes(path, bytes));
const auto identity = StateStore::fileIdentity(path);
auto *store = reader_->findChild<StateStore *>();
QVERIFY(store);
QVERIFY(store->remember(path, {{"pageIndex", 41}, {"zoom", 100.0}}));
const auto hash = fileHash(path);
QVERIFY(QFile::rename(path, path + ".original"));
if (replaced) {
QVERIFY(writeBytes(path, bytes));
QFile newFile(path);
QVERIFY(newFile.open(QIODevice::ReadWrite));
QVERIFY(newFile.setFileTime(QDateTime::fromMSecsSinceEpoch(identity.value("mtime").toLongLong()), QFileDevice::FileModificationTime));
newFile.close();
const auto current = StateStore::fileIdentity(path);
QCOMPARE(current.value("size"), identity.value("size"));
QCOMPARE(current.value("mtime"), identity.value("mtime"));
QVERIFY(current.value("fileIdentity") != identity.value("fileIdentity"));
}
QTRY_COMPARE(reader_->recentDocuments().size(), 1);
key(Qt::Key_Return);
QCOMPARE(reader_->state(), "Empty");
QVERIFY(reader_->webToken().isEmpty());
QVERIFY(reader_->notice().startsWith("E_OPEN_FAILED"));
QVERIFY(reader_->notice().contains("Ctrl+O"));
QVERIFY(reader_->notice().contains(replaced ? QStringLiteral("変更または置換") : QStringLiteral("見つからない")));
QCOMPARE(fileHash(path + ".original"), hash);
if (replaced) {
QCOMPARE(fileHash(path), hash);
reader_->openPath(path); // Explicit file selection may open the new original, without the old position.
QTRY_COMPARE_WITH_TIMEOUT(reader_->state(), "Ready", 15000);
QCOMPARE(canvas_->currentPage(), 0);
QCOMPARE(fileHash(path), hash);
}
}
void initialUiAndCommandEntry()
{
QCOMPARE(reader_->state(), "Empty");
QCOMPARE(reader_->title(), "DocView");
QCOMPARE(window_->property("tocVisible").toBool(), false);
QCOMPARE(window_->property("pagesVisible").toBool(), false);
QCOMPARE(window_->property("statusVisible").toBool(), true);
key(Qt::Key_Colon);
QCOMPARE(reader_->mode(), "command");
key(Qt::Key_J);
key(Qt::Key_4);
QCOMPARE(reader_->mode(), "command");
QVERIFY(reader_->pending().isEmpty());
key(Qt::Key_Escape);
QCOMPARE(reader_->mode(), "normal");
key(Qt::Key_Colon);
QVERIFY(QGuiApplication::focusObject());
QVERIFY(QGuiApplication::focusObject()->setProperty("text", "help"));
key(Qt::Key_Return);
QCOMPARE(reader_->mode(), "dialog");
key(Qt::Key_Escape);
QTRY_COMPARE(reader_->mode(), "normal");
key(Qt::Key_Z); key(Qt::Key_S);
QCOMPARE(window_->property("statusVisible").toBool(), false);
key(Qt::Key_Z); key(Qt::Key_S);
QCOMPARE(window_->property("statusVisible").toBool(), true);
QVERIFY(!reader_->command("page -1"));
QCOMPARE(reader_->state(), "Empty");
}
void pdfNumericNavigationRenderingAndSourceIntegrity()
{
const auto originalHash = fileHash(manyPages_);
QSignalSpy presented(reader_.get(), &Controller::framePresented);
reader_->openPath(manyPages_);
QCOMPARE(reader_->state(), "Opening");
QTRY_COMPARE_WITH_TIMEOUT(reader_->state(), "Ready", 15000);
QCOMPARE(reader_->format(), "pdf");
QCOMPARE(reader_->pages().size(), 50);
QTRY_VERIFY_WITH_TIMEOUT(!presented.isEmpty(), 15000);
key(Qt::Key_4); key(Qt::Key_2); key(Qt::Key_G, Qt::ShiftModifier);
QTRY_COMPARE_WITH_TIMEOUT(canvas_->currentPage(), 41, 10000);
QVERIFY(reader_->position().startsWith("42 / 50"));
const auto before = canvas_->currentPage();
key(Qt::Key_9); key(Qt::Key_9); key(Qt::Key_G, Qt::ShiftModifier);
QCOMPARE(canvas_->currentPage(), before);
QVERIFY(reader_->notice().contains("範囲外"));
key(Qt::Key_G); key(Qt::Key_G);
QTRY_COMPARE(canvas_->currentPage(), 0);
const auto zoom = canvas_->zoom();
reader_->execute("zoom.in");
QVERIFY(canvas_->zoom() > zoom);
reader_->execute("view.rotate", {{"degrees", 90}});
QTRY_VERIFY_WITH_TIMEOUT(canvas_->viewportReady(), 15000);
const QImage frame = window_->grabWindow();
QVERIFY(!frame.isNull());
int bluePixels = 0;
for (int y = 0; y < frame.height(); y += 2) {
for (int x = 0; x < frame.width(); x += 2) {
const auto color = frame.pixelColor(x, y);
if (color.red() < 45 && color.green() >= 60 && color.green() <= 95
&& color.blue() >= 160 && color.blue() <= 200)
++bluePixels;
}
}
QVERIFY2(bluePixels > 500, "Rendered frame did not contain the fixture's original blue page content");
QCOMPARE(fileHash(manyPages_), originalHash);
}
void initialPageAppliesOnlyToRequestedDocument()
{
reader_->openPath(manyPages_, {}, 42);
QTRY_COMPARE_WITH_TIMEOUT(reader_->state(), "Ready", 15000);
QTRY_COMPARE_WITH_TIMEOUT(canvas_->currentPage(), 41, 10000);
QTRY_VERIFY_WITH_TIMEOUT(canvas_->viewportReady(), 15000);
reader_->openPath(QFINDTESTDATA("fixtures/pdf/navigation.pdf"));
QTRY_COMPARE_WITH_TIMEOUT(reader_->state(), "Ready", 15000);
QTRY_COMPARE_WITH_TIMEOUT(canvas_->currentPage(), 0, 10000);
const auto previousTitle = reader_->title();
reader_->openPath(QFINDTESTDATA("fixtures/pdf/corrupt.pdf"), {}, 2);
QCOMPARE(reader_->state(), "Opening");
QTRY_COMPARE_WITH_TIMEOUT(reader_->state(), "Ready", 15000);
QCOMPARE(reader_->title(), previousTitle);
QCOMPARE(canvas_->currentPage(), 0);
reader_->openPath(manyPages_);
QTRY_COMPARE_WITH_TIMEOUT(reader_->state(), "Ready", 15000);
QTRY_VERIFY_WITH_TIMEOUT(canvas_->viewportReady(), 15000);
QCOMPARE(canvas_->currentPage(), 0);
}
void largePdfFarJumpsRemainResponsiveAndCancel()
{
const auto originalHash = fileHash(stressPages_);
QElapsedTimer elapsed;
elapsed.start();
qint64 previousTick = 0, longestGap = 0;
int ticks = 0;
QTimer heartbeat;
connect(&heartbeat, &QTimer::timeout, this, [&] {
const auto now = elapsed.elapsed();
longestGap = qMax(longestGap, now - previousTick);
previousTick = now;
++ticks;
});
heartbeat.start(10);
QSignalSpy requested(canvas_, &PdfCanvas::tileRequested);
QSignalSpy metadata(canvas_, &PdfCanvas::pageNeeded);
reader_->openPath(stressPages_);
QTRY_COMPARE_WITH_TIMEOUT(reader_->state(), "Ready", 20000);
QCOMPARE(reader_->pages().size(), 5000);
QTRY_VERIFY_WITH_TIMEOUT(canvas_->viewportReady(), 20000);
QVERIFY(ticks > 0);
const int firstPageTicks = ticks;
requested.clear();
reader_->execute("nav.page", {{"page", 5000}});
QTRY_COMPARE_WITH_TIMEOUT(canvas_->currentPage(), 4999, 10000);
QTRY_VERIFY_WITH_TIMEOUT(canvas_->viewportReady(), 20000);
QVERIFY(reader_->position().startsWith("5000 / 5000"));
QVERIFY(ticks > firstPageTicks);
QVERIFY(!metadata.isEmpty());
QVERIFY(!requested.isEmpty());
// A far jump must request the destination rather than render all
// intervening pages. Both the visible page and nearby prefetch count.
QSet<int> renderedPages;
for (const auto &request : requested)
renderedPages.insert(request[0].toInt());
QVERIFY(renderedPages.contains(4999));
QVERIFY2(renderedPages.size() < 10, "Far jump rendered intervening PDF pages");
requested.clear();
// Let each jump dispatch before changing direction, so the test
// exercises worker replies from obsolete render revisions.
for (const int page : {2500, 42, 4999, 1250}) {
const auto previousRequests = requested.size();
reader_->execute("nav.page", {{"page", page}});
QTRY_VERIFY_WITH_TIMEOUT(requested.size() > previousRequests, 10000);
}
reader_->execute("zoom.in");
reader_->execute("zoom.out");
reader_->execute("nav.page", {{"page", 5000}});
QTRY_COMPARE_WITH_TIMEOUT(canvas_->currentPage(), 4999, 10000);
QTRY_VERIFY_WITH_TIMEOUT(canvas_->viewportReady(), 20000);
QSet<qint64> revisions;
for (const auto &request : requested)
revisions.insert(request[2].toLongLong());
QVERIFY2(revisions.size() >= 2, "Rapid jumps did not dispatch different render revisions");
QTest::qWait(100);
QCOMPARE(canvas_->currentPage(), 4999);
QVERIFY2(greenPagePixels(window_->grabWindow()) > 500,
"The final viewport did not show the distinct green last page");
const auto stressTitle = reader_->title();
reader_->openPath(epub_);
QCOMPARE(reader_->state(), "Opening");
reader_->cancel();
QCOMPARE(reader_->state(), "Ready");
QCOMPARE(reader_->title(), stressTitle);
QCOMPARE(canvas_->currentPage(), 4999);
// Replace while far-away work is queued and ensure no old frame or
// callback can reinstate the large document after the commit.
reader_->execute("nav.page", {{"page", 1234}});
QTest::qWait(22);
reader_->openPath(manyPages_);
QTRY_COMPARE_WITH_TIMEOUT(reader_->state(), "Ready", 20000);
QTRY_VERIFY_WITH_TIMEOUT(canvas_->viewportReady(), 20000);
QCOMPARE(reader_->pages().size(), 50);
QCOMPARE(canvas_->currentPage(), 0);
QTest::qWait(200);
QCOMPARE(reader_->pages().size(), 50);
QCOMPARE(canvas_->currentPage(), 0);
QCOMPARE(greenPagePixels(window_->grabWindow()), 0);
heartbeat.stop();
// Detect event-loop starvation with a generous CI bound. The
// separate performance harness measures the specified p95 targets.
QVERIFY2(longestGap < 1500, qPrintable(QString("GUI stalled for %1 ms").arg(longestGap)));
qInfo().nospace() << "T03 synthetic 5000 pages: " << elapsed.elapsed() << " ms, "
<< ticks << " event-loop ticks, longest gap " << longestGap << " ms";
QCOMPARE(fileHash(stressPages_), originalHash);
}
void configuredNontextLogicalKeys()
{
reader_->openPath(manyPages_);
QTRY_COMPARE_WITH_TIMEOUT(reader_->state(), "Ready", 15000);
QVERIFY(writeBytes(configuration_, R"([privacy]
remember_position=false
recent_documents=0
[[keybindings]]
mode='normal'
context='global'
keys=['F2']
command='panel.status.toggle'
[[keybindings]]
mode='normal'
context='content'
keys=['Home']
command='nav.document_start'
[[keybindings]]
mode='normal'
context='content'
keys=['Ctrl+Enter']
command='nav.page'
args={page=42}
[[keybindings]]
mode='normal'
context='content'
keys=['Shift+Tab']
command='nav.page'
args={page=2}
)"));
QVERIFY(reader_->command("reload"));
QVERIFY2(!reader_->notice().startsWith("E_CONFIG"), qPrintable(reader_->notice()));
key(Qt::Key_F2);
QCOMPARE(window_->property("statusVisible").toBool(), false);
key(Qt::Key_Return, Qt::ControlModifier);
QTRY_COMPARE(canvas_->currentPage(), 41);
key(Qt::Key_Home);
QTRY_COMPARE(canvas_->currentPage(), 0);
key(Qt::Key_Tab, Qt::ShiftModifier);
QTRY_COMPARE(canvas_->currentPage(), 1);
key(Qt::Key_Home);
key(Qt::Key_Backtab);
QTRY_COMPARE(canvas_->currentPage(), 1);
key(Qt::Key_Colon);
QCOMPARE(reader_->mode(), "command");
key(Qt::Key_F2);
key(Qt::Key_Home);
QCOMPARE(window_->property("statusVisible").toBool(), false);
QCOMPARE(canvas_->currentPage(), 1);
key(Qt::Key_Escape);
key(Qt::Key_F2);
QCOMPARE(window_->property("statusVisible").toBool(), true);
}
void reloadingHiddenFocusedPanelsReturnsToContent_data()
{
QTest::addColumn<QString>("panel");
QTest::newRow("toc") << QString("toc");
QTest::newRow("pages") << QString("pages");
}
void visibleSidebarKeepsDocumentUsable()
{
QVERIFY(window_->property("pagesVisible").toBool());
reader_->openPath(QFINDTESTDATA("fixtures/pdf/navigation.pdf"));
QTRY_COMPARE_WITH_TIMEOUT(reader_->state(), "Ready", 15000);
QVERIFY(!reader_->outline().isEmpty());
auto *sidebar = window_->findChild<QQuickItem *>("navigationSidebar");
auto *content = window_->findChild<QQuickItem *>("documentContent");
QVERIFY(sidebar && content);
for (const int width : {1100, 480, 1100}) {
window_->resize(width, 800);
QTRY_COMPARE(window_->width(), width);
const QStringList transitions{{}, "panel.pages.toggle", "panel.toc.toggle",
"panel.pages.toggle", "panel.toc.toggle",
"panel.pages.toggle", "panel.pages.toggle"};
for (const auto &command : transitions) {
if (!command.isEmpty())
reader_->execute(command);
QTRY_VERIFY_WITH_TIMEOUT(content->width() >= window_->width() * .6 - 2, 5000);
if (sidebar->isVisible()) {
QTRY_VERIFY_WITH_TIMEOUT(sidebar->width() > 0, 5000);
QTRY_VERIFY_WITH_TIMEOUT(sidebar->width() <= window_->width() * .4 + 1, 5000);
} else {
QTRY_VERIFY_WITH_TIMEOUT(content->width() >= window_->width() - 2, 5000);
}
QTRY_VERIFY_WITH_TIMEOUT(canvas_->viewportReady(), 15000);
QVERIFY(canvas_->width() >= window_->width() * .6 - 2);
}
}
}
void invalidCapabilitiesCannotDispatchCommands_data()
{
QTest::addColumn<QString>("fault");
for (const auto *fault : {"missing", "unknown-state", "state-type", "unknown-name", "missing-reason"})
QTest::newRow(fault) << QString::fromLatin1(fault);
}
void invalidCapabilitiesCannotDispatchCommands()
{
QFETCH(QString, fault);
reader_->openPath(QFINDTESTDATA("fixtures/pdf/navigation.pdf"));
QTRY_COMPARE_WITH_TIMEOUT(reader_->state(), "Ready", 15000);
QTRY_VERIFY_WITH_TIMEOUT(canvas_->viewportReady(), 15000);
auto *session = activeSession();
QVERIFY(session);
const auto original = session->meta.value("capabilities").toMap();
const auto originalReasons = session->meta.value("capabilityReasons").toMap();
QVERIFY(validateCapabilities(original, originalReasons));
auto altered = original;
if (fault == "missing")
altered.remove("pageNavigation");
else if (fault == "unknown-state")
altered["pageNavigation"] = "enabled";
else if (fault == "state-type")
altered["pageNavigation"] = true;
else if (fault == "unknown-name")
altered["futureCapability"] = "supported";
else if (fault == "missing-reason")
session->meta["capabilityReasons"] = QVariantMap{};
session->meta["capabilities"] = altered;
QSignalSpy ui(reader_.get(), &Controller::uiCommand);
reader_->execute("nav.page", {{"page", 2}});
reader_->execute("search.open");
reader_->execute("panel.toc.toggle");
QCOMPARE(canvas_->currentPage(), 0);
QCOMPARE(ui.size(), 0);
QCOMPARE(reader_->mode(), "normal");
QVERIFY(reader_->notice().startsWith("E_WORKER_CRASH"));
session->meta["capabilities"] = original;
session->meta["capabilityReasons"] = originalReasons;
reader_->execute("nav.chapter_next");
QVERIFY(reader_->notice().startsWith("E_CHAPTER_NAVIGATION_UNSUPPORTED"));
reader_->execute("nav.page", {{"page", 2}});
QTRY_COMPARE_WITH_TIMEOUT(canvas_->currentPage(), 1, 10000);
QTRY_VERIFY_WITH_TIMEOUT(canvas_->viewportReady(), 15000);
}
void reloadingHiddenFocusedPanelsReturnsToContent()
{
QFETCH(QString, panel);
const QByteArray binding = "\n[[keybindings]]\nmode='normal'\ncontext='global'\nkeys=['F5']\ncommand='config.reload'\n";
const QByteArray privacy = "[privacy]\nremember_position=false\nrecent_documents=0\n";
QVERIFY(writeBytes(configuration_, privacy + "[ui]\n" + panel.toUtf8() + "_visible=true\n" + binding));
reader_->execute("config.reload");
reader_->openPath(QFINDTESTDATA("fixtures/pdf/navigation.pdf"));
QTRY_COMPARE_WITH_TIMEOUT(reader_->state(), "Ready", 15000);
key(Qt::Key_W, Qt::ControlModifier); key(Qt::Key_W);
QCOMPARE(reader_->context(), panel);
QVERIFY(window_->property(qPrintable(panel + "Visible")).toBool());
QVERIFY(writeBytes(configuration_, privacy + "[ui]\n" + panel.toUtf8() + "_visible=false\n" + binding));
// Invoke the configured normal-mode binding; command entry's focus
// restoration would hide this regression.
key(Qt::Key_F5);
QVERIFY(!window_->property(qPrintable(panel + "Visible")).toBool());
QCOMPARE(reader_->context(), "content");
QVERIFY(canvas_->hasActiveFocus());
QTRY_VERIFY_WITH_TIMEOUT(canvas_->viewportReady(), 15000);
reader_->execute("nav.document_start");
const auto before = canvas_->offset();
key(Qt::Key_J);
QVERIFY(canvas_->offset() > before);
}
void explicitCloseClearsResourcesAndRejectsPendingOpen()
{
const auto path = QFINDTESTDATA("fixtures/pdf/navigation.pdf");
const auto original = fileHash(path);
reader_->openPath(path);
QTRY_VERIFY_WITH_TIMEOUT(reader_->state() == "Ready" && canvas_->viewportReady(), 15000);
QPointer<Session> previous = activeSession(); QVERIFY(previous);
const auto token = previous->token;
reader_->openPath(html_); // Closing also cancels a staged replacement.
reader_->closeDocument();
QCOMPARE(reader_->state(), "Empty"); QCOMPARE(reader_->format(), QString{});
QVERIFY(reader_->outline().isEmpty()); QVERIFY(reader_->pages().isEmpty());
QVERIFY(reader_->benchmarkSnapshot().isEmpty());
QCOMPARE(canvas_->cacheCostBytes(), qint64(0)); QCOMPARE(canvas_->pendingRenderCount(), qsizetype(0));
reader_->webLoaded(token, true); reader_->webIndex(token, {{"headings", QVariantList{}}});
QTest::qWait(200); QCOMPARE(reader_->state(), "Empty");
QTRY_VERIFY(previous.isNull());
reader_->closeDocument(); // Idempotent while already empty.
reader_->openPath(path);
QTRY_VERIFY_WITH_TIMEOUT(reader_->state() == "Ready" && canvas_->viewportReady(), 15000);
QVERIFY(activeSession()->token != token); QCOMPARE(fileHash(path), original);
reader_->openPath(html_);
QTRY_VERIFY_WITH_TIMEOUT(reader_->format() == "html" && reader_->state() == "Ready", 15000);
QPointer<Session> web = activeSession();
reader_->closeDocument();
QTRY_VERIFY(web.isNull());
QCOMPARE(reader_->format(), QString{}); QCOMPARE(reader_->state(), "Empty");
}
void formOnlyStructureProvidesExactHeadingNavigation()
{
const auto path = QFINDTESTDATA("fixtures/pdf/headings/form-only-structure.pdf");
const auto original = fileHash(path);
reader_->openPath(path);
QTRY_COMPARE_WITH_TIMEOUT(reader_->state(), "Ready", 15000);
auto *session = activeSession(); QVERIFY(session);
QTRY_COMPARE_WITH_TIMEOUT(session->meta.value("capabilities").toMap().value("headings").toString(), "supported", 10000);
QVERIFY(!session->meta.value("capabilityReasons").toMap().contains("headings"));
QVERIFY(!session->meta.value("warnings").toStringList().join('\n').contains("E_PDF_STRUCTURE_LIMITED"));
canvas_->setZoom(800); canvas_->goTo(0);
reader_->execute("nav.heading_next");
const auto location = canvas_->location();
QCOMPARE(location.value("pageIndex").toInt(), 0);
// Authored baseline is 130 pt, with a 16 pt glyph height after the Form translation.
QVERIFY(location.value("yPt").toDouble() > 130.0 && location.value("yPt").toDouble() < 147.0);
QVERIFY(!reader_->notice().contains("E_PDF_STRUCTURE_LIMITED"));
QTRY_VERIFY_WITH_TIMEOUT(canvas_->viewportReady(), 15000);
QCOMPARE(reader_->state(), "Ready");
QCOMPARE(fileHash(path), original);
}
void pagePrecisionHeadingDoesNotReselectItsOwnStart()
{
const auto path = QFINDTESTDATA("fixtures/pdf/headings/vector-heading.pdf");
const auto original = fileHash(path);
reader_->openPath(path);
QTRY_COMPARE_WITH_TIMEOUT(reader_->state(), "Ready", 15000);
auto *session = activeSession(); QVERIFY(session);
QTRY_COMPARE_WITH_TIMEOUT(session->meta.value("capabilities").toMap().value("headings").toString(), "supported", 10000);
canvas_->setZoom(800); canvas_->goTo(0); canvas_->scroll(0, 300);
QVERIFY(canvas_->location().value("progression").toDouble() > .01);
reader_->execute("nav.heading_previous");
QCOMPARE(canvas_->currentPage(), 0);
QVERIFY(canvas_->location().value("progression").toDouble() < .0001);
const auto atStart = canvas_->offset();
reader_->execute("nav.heading_previous");
QCOMPARE(canvas_->offset(), atStart);
QVERIFY(reader_->notice().contains("見出しの端"));
reader_->execute("nav.heading_next");
QCOMPARE(canvas_->offset(), atStart);
QVERIFY(reader_->notice().contains("見出しの端"));
QTRY_VERIFY_WITH_TIMEOUT(canvas_->viewportReady(), 15000);
QCOMPARE(fileHash(path), original);
}
void rotatedPdfHeadingsFollowDisplayedReadingPosition()
{
const auto path = QFINDTESTDATA("fixtures/pdf/headings/rotated-tags.pdf");
const auto original = fileHash(path);
reader_->openPath(path);
QTRY_COMPARE_WITH_TIMEOUT(reader_->state(), "Ready", 15000);
auto *session = activeSession(); QVERIFY(session);
QTRY_COMPARE_WITH_TIMEOUT(session->meta.value("capabilities").toMap().value("headings").toString(), "supported", 10000);
canvas_->setZoom(800); canvas_->goTo(0);
const double start = canvas_->offset();
reader_->execute("nav.heading_next");
const double first = canvas_->offset(); QVERIFY(first > start + 100);
// Intrinsic Rotate 90 makes the heading's PDF x the reading-axis
// coordinate. Its original y must not be compared to the top's y.
auto location = canvas_->location();
QVERIFY(location.value("xPt").toDouble() >= 50 && location.value("xPt").toDouble() <= 54);
reader_->execute("nav.heading_next");
const double second = canvas_->offset(); QVERIFY(second > first + 40);
reader_->execute("nav.heading_previous");
QVERIFY(std::abs(canvas_->offset() - first) < 1);
reader_->execute("nav.heading_previous");
QVERIFY(reader_->notice().contains("見出しの端"));
QVERIFY(std::abs(canvas_->offset() - first) < 1);
QTRY_VERIFY_WITH_TIMEOUT(canvas_->viewportReady(), 15000);
QCOMPARE(fileHash(path), original);
}
void pdfIndexBudgetKeepsDocumentReady_data()
{
QTest::addColumn<bool>("outline");
QTest::newRow("outline-pagination") << true;
QTest::newRow("tagged-headings") << false;
}
void pdfIndexBudgetKeepsDocumentReady()
{
QFETCH(bool, outline);
const QString path = outline ? documents_.filePath("bounded-outline.pdf")
: QFINDTESTDATA("fixtures/pdf/navigation.pdf");
if (outline)
QVERIFY(writeBytes(path, pagedPdf(4, 160)));
bool injected = false;
QObject injectionLifetime;
connect(reader_.get(), &Controller::documentChanged, &injectionLifetime, [&] {
if (auto *session = activeSession()) {
// Exercise the real next worker reply at the budget boundary
// without allocating a 32 MiB synthetic index in every run.
session->navigationBytes = MaxNavigationBytes - 1;
injected = true;
}
});
reader_->openPath(path);
QTRY_COMPARE_WITH_TIMEOUT(reader_->state(), "Ready", 15000);
QVERIFY(injected);
auto *session = activeSession();
QVERIFY(session && session->worker);
QSignalSpy failures(session->worker, &WorkerProcess::failed);
QTRY_VERIFY_WITH_TIMEOUT(session->meta.value("warnings").toStringList().join('\n').contains("E_PDF_INDEX_PARTIAL"), 10000);
QCOMPARE(reader_->state(), "Ready");
QVERIFY(!session->resourcesStopped);
if (outline) {
QCOMPARE(session->meta.value("nextOutlineCursor").toInt(), -1);
QCOMPARE(reader_->outline().size(), 64);
}
bool pong = false;
session->worker->request("ping", {}, [&](const QCborMap &reply) {
pong = reply.value("result").toMap().value("ready").toBool();
});
QTRY_VERIFY_WITH_TIMEOUT(pong, 5000);
reader_->execute("nav.page", {{"page", reader_->pages().size()}});
QTRY_COMPARE_WITH_TIMEOUT(canvas_->currentPage(), reader_->pages().size() - 1, 10000);
QTRY_VERIFY_WITH_TIMEOUT(canvas_->viewportReady(), 15000);
QCOMPARE(reader_->state(), "Ready");
QCOMPARE(failures.size(), 0);
}
void archiveFailureCompletesPendingResourceRequests()
{
reader_->openPath(zip_);
QTRY_COMPARE_WITH_TIMEOUT(reader_->state(), "Ready", 25000);
QTRY_VERIFY_WITH_TIMEOUT(activeWeb() && activeWeb()->property("documentReady").toBool(), 10000);
auto *session = activeSession();
QVERIFY(session && session->worker && session->handler);
QList<ResourceFailure> completions;
session->extractWaiters["pending-a"] << [&](ResourceFailure failure) { completions << failure; };
session->extractWaiters["pending-a"] << [&](ResourceFailure failure) { completions << failure; };
session->extractWaiters["pending-b"] << [&](ResourceFailure failure) { completions << failure; };
QVERIFY(QMetaObject::invokeMethod(session->worker, "failed", Qt::DirectConnection,
Q_ARG(QString, "E_WORKER_CRASH"), Q_ARG(QString, "Injected active-worker failure")));
QCOMPARE(reader_->state(), "Recovering");
QCOMPARE(completions, QList<ResourceFailure>({ResourceFailure::WorkerFailed, ResourceFailure::WorkerFailed, ResourceFailure::WorkerFailed}));
QVERIFY(session->extractWaiters.isEmpty());
QVERIFY(session->resourcesStopped);
QSignalSpy blocked(session->handler, &ResourceHandler::blocked);
QVariant result;
QVERIFY(evaluateWeb("(()=>{const image=document.createElement('img');image.id='after-revoke';image.src='other.html';document.body.append(image);return true;})()", &result));
QTRY_VERIFY_WITH_TIMEOUT(evaluateWeb("document.getElementById('after-revoke').complete && document.getElementById('after-revoke').naturalWidth===0", &result) && result.toBool(), 5000);
QCOMPARE(blocked.size(), 0); // Revoked sessions must not issue new warnings.
QVERIFY(session->extractWaiters.isEmpty());
reader_->openPath(manyPages_);
QTRY_COMPARE_WITH_TIMEOUT(reader_->state(), "Ready", 15000);
QTest::qWait(100);
QCOMPARE(completions.size(), 3); // Disposal must not complete them twice.
}
void latePdfFontFailureCanReopenFreshWorker_data()
{
QTest::addColumn<int>("operation");
QTest::newRow("prefetch") << 0;
QTest::newRow("search") << 1;
QTest::newRow("cancelled-search") << 2;
QTest::newRow("page-metadata") << 3;
}
void latePdfFontFailureCanReopenFreshWorker()
{
QFETCH(int, operation);
const auto path = documents_.filePath("late-font-recovery.pdf");
QVERIFY(writeBytes(path, deferredFontPdf())); const auto original = fileHash(path);
reader_->openPath(path); canvas_->setPrefetchPages(0);
QTRY_COMPARE_WITH_TIMEOUT(reader_->state(), "Ready", 15000);
auto *session = activeSession(); QVERIFY(session && session->worker);
const auto previousToken = session->token; const auto previousGeneration = session->generation;
if (operation == 0) canvas_->updatePages({QVariantMap{{"pageIndex", 256}, {"width", 595.0}, {"height", 842.0}}});
canvas_->setZoom(300); canvas_->goTo(255);
QTRY_VERIFY_WITH_TIMEOUT(canvas_->viewportReady() && session->worker->idle(), 15000);
const auto cached = canvas_->cacheCostBytes(); QVERIFY(cached > 0);
int maps = 0;
session->worker->setFontRequestHandler([&](const QString &fontOperation, const QCborMap &, std::function<void(QCborMap)> done) {
QCOMPARE(fontOperation, "font.map"); ++maps;
if (operation == 2) reader_->cancel(); // Obsolete search still poisoned this worker's font cache.
done({{"error", QCborMap{{"code", "E_FONT_FAILED"}, {"message", "Injected late font failure"}}}});
});
if (operation == 0) canvas_->setPrefetchPages(1);
else if (operation == 3) canvas_->goTo(256);
else reader_->search("Late");
QTRY_COMPARE_WITH_TIMEOUT(reader_->state(), "Recovering", 15000);
QVERIFY(maps > 0); QVERIFY(session->resourcesStopped);
if (operation == 1 || operation == 2)
QCOMPARE(session->meta.value("capabilities").toMap().value("textSearch").toString(), "loading");
QVERIFY(reader_->notice().contains("E_FONT_FAILED")); QVERIFY(reader_->notice().contains(QStringLiteral("開き直")));
QVERIFY(canvas_->cacheCostBytes() >= cached);
if (operation != 3) QVERIFY(canvas_->viewportReady());
reader_->openPath(path); canvas_->setPrefetchPages(0);
QTRY_COMPARE_WITH_TIMEOUT(reader_->state(), "Ready", 15000);
session = activeSession(); QVERIFY(session);
QVERIFY(session->token != previousToken); QVERIFY(session->generation > previousGeneration);
canvas_->goTo(256); QTRY_VERIFY_WITH_TIMEOUT(canvas_->viewportReady(), 15000);
QCOMPARE(reader_->state(), "Ready"); QVERIFY(!session->resourcesStopped);
QCOMPARE(fileHash(path), original);
}
void latePdfFontWarningPersistsInDocumentInfo()
{
const auto path = documents_.filePath("late-font-warning.pdf");
QVERIFY(writeBytes(path, deferredFontPdf()));
reader_->openPath(path); canvas_->setPrefetchPages(0);
QTRY_COMPARE_WITH_TIMEOUT(reader_->state(), "Ready", 15000);
auto *session = activeSession(); QVERIFY(session && session->worker);
canvas_->updatePages({QVariantMap{{"pageIndex", 256}, {"width", 595.0}, {"height", 842.0}}});
canvas_->setZoom(300); canvas_->goTo(255);
QTRY_VERIFY_WITH_TIMEOUT(canvas_->viewportReady() && session->worker->idle(), 15000);
session->worker->setFontRequestHandler([](const QString &, const QCborMap &, std::function<void(QCborMap)> done) {
done({{"found", false}}); // A genuine absence permits PDFium's built-in alternative.
});
canvas_->setPrefetchPages(1);
QTRY_VERIFY_WITH_TIMEOUT(session->meta.value("warnings").toStringList().join('\n').contains(QStringLiteral("内蔵")), 15000);
QCOMPARE(reader_->state(), "Ready"); QVERIFY(!session->resourcesStopped);
QSignalSpy commands(reader_.get(), &Controller::uiCommand);
reader_->execute("document.info"); QCOMPARE(commands.size(), 1);
QVERIFY(commands[0][1].toMap().value("text").toString().contains(QStringLiteral("内蔵")));
const auto warnings = session->meta.value("warnings").toStringList();
QVERIFY(warnings.size() <= 32); for (const auto &warning : warnings) QVERIFY(warning.size() <= 1024);
}
void initialPdfFontErrorPreservesCodeAndOldDocument()
{
reader_->openPath(manyPages_); QTRY_COMPARE_WITH_TIMEOUT(reader_->state(), "Ready", 15000);
QTRY_VERIFY_WITH_TIMEOUT(canvas_->viewportReady(), 15000);
const auto previous = reader_->webToken();
const auto path = QFINDTESTDATA("fixtures/pdf/navigation.pdf");
reader_->openPath(path);
Session *pending = nullptr;
for (auto *object : reader_->children())
if (auto *candidate = dynamic_cast<Session *>(object); candidate && candidate->token != previous && !candidate->resourcesStopped)
pending = candidate;
QVERIFY(pending && pending->worker);
pending->worker->setFontRequestHandler([](const QString &, const QCborMap &, std::function<void(QCborMap)> done) {
done({{"error", QCborMap{{"code", "E_FONT_FAILED"}, {"message", "Injected initial font failure"}}}});
});
QTRY_VERIFY_WITH_TIMEOUT(reader_->notice().contains("E_FONT_FAILED"), 15000);
QCOMPARE(reader_->webToken(), previous); QCOMPARE(reader_->state(), "Ready");
QVERIFY(canvas_->viewportReady()); QVERIFY(!reader_->notice().contains("E_PDF_CORRUPT"));
}
void activeFontFailureSurvivesStagingRollback_data()
{
QTest::addColumn<bool>("cancelStaging");
QTest::newRow("staging-fails") << false;
QTest::newRow("staging-cancelled") << true;
}
void activeFontFailureSurvivesStagingRollback()
{
QFETCH(bool, cancelStaging);
const auto path = documents_.filePath("active-font-rollback.pdf");
QVERIFY(writeBytes(path, deferredFontPdf()));
reader_->openPath(path); canvas_->setPrefetchPages(0);
QTRY_COMPARE_WITH_TIMEOUT(reader_->state(), "Ready", 15000);
auto *active = activeSession(); QVERIFY(active && active->worker);
const auto token = active->token;
canvas_->updatePages({QVariantMap{{"pageIndex", 256}, {"width", 595.0}, {"height", 842.0}}});
canvas_->setZoom(300); canvas_->goTo(255);
QTRY_VERIFY_WITH_TIMEOUT(canvas_->viewportReady() && active->worker->idle(), 15000);
std::function<void(QCborMap)> oldReply, newReply;
active->worker->setFontRequestHandler([&](const QString &, const QCborMap &, std::function<void(QCborMap)> done) { oldReply = std::move(done); });
canvas_->setPrefetchPages(1); QTRY_VERIFY_WITH_TIMEOUT(bool(oldReply), 15000);
reader_->openPath(QFINDTESTDATA("fixtures/pdf/navigation.pdf"));
QCOMPARE(reader_->state(), "Opening");
Session *staging = nullptr;
for (auto *object : reader_->children())
if (auto *candidate = dynamic_cast<Session *>(object); candidate && candidate != active && !candidate->resourcesStopped) staging = candidate;
QVERIFY(staging && staging->worker);
staging->worker->setFontRequestHandler([&](const QString &, const QCborMap &, std::function<void(QCborMap)> done) { newReply = std::move(done); });
QTRY_VERIFY_WITH_TIMEOUT(bool(newReply), 15000);
QCOMPARE(reader_->state(), "Opening"); // The old worker is still allowed to report a fatal error.
oldReply({{"error", QCborMap{{"code", "E_FONT_FAILED"}, {"message", "Active font failure"}}}});
QTRY_COMPARE_WITH_TIMEOUT(reader_->state(), "Recovering", 15000);
QVERIFY(active->resourcesStopped); QCOMPARE(reader_->webToken(), token);
if (cancelStaging) reader_->cancel();
else {
QPointer<Session> failedAttempt = staging;
newReply({{"error", QCborMap{{"code", "E_FONT_FAILED"}, {"message", "Staging font failure"}}}});
QTRY_VERIFY_WITH_TIMEOUT(failedAttempt.isNull(), 15000);
QVERIFY(reader_->notice().contains("E_FONT_FAILED"));
QVERIFY(reader_->notice().contains("navigation.pdf"));
QVERIFY(reader_->notice().contains(QStringLiteral("開き直してください")));
QVERIFY(!reader_->notice().contains("Staging font failure"));
}
QCOMPARE(reader_->state(), "Recovering"); QCOMPARE(reader_->webToken(), token);
QVERIFY(active->resourcesStopped); QVERIFY(canvas_->viewportReady());
}
void clearedHistorySurvivesCloseAndReopen()
{
reader_->clearHistory();
QVERIFY(writeBytes(configuration_, "[privacy]\nremember_position=true\nrecent_documents=20\n"));
QVERIFY(reader_->command("reload"));
reader_->openPath(manyPages_);
QTRY_COMPARE_WITH_TIMEOUT(reader_->state(), "Ready", 15000);
QTRY_VERIFY_WITH_TIMEOUT(canvas_->viewportReady(), 15000);
QTRY_COMPARE_WITH_TIMEOUT(reader_->historyCount(), 1, 5000);
auto *store = reader_->findChild<StateStore *>();
QVERIFY(store);
QVERIFY(!store->readOnly());
const auto stateFile = store->filePath();
// Exercise the actual debounce and on-disk state, not only count().
auto diskCount = [&] {
QFile file(stateFile);
if (!file.open(QIODevice::ReadOnly))
return -1;
return int(QJsonDocument::fromJson(file.readAll()).object().value("documents").toArray().size());
};
QTRY_COMPARE_WITH_TIMEOUT(diskCount(), 1, 5000);
reader_->clearHistory();
QCOMPARE(reader_->historyCount(), 0);
QCOMPARE(diskCount(), 0);
// A repaint at the same location must not repopulate cleared state.
window_->update();
QTest::qWait(100);
QCOMPARE(reader_->historyCount(), 0);
cleanup();
QCOMPARE(diskCount(), 0);
Controller reopened;
reopened.initialize(configuration_, false, testStorage());
QCOMPARE(reopened.historyCount(), 0);
}
void unpresentedJumpDoesNotReplaceSavedPosition()
{
reader_->clearHistory();
QVERIFY(writeBytes(configuration_, "[privacy]\nremember_position=true\nrecent_documents=20\n"));
QVERIFY(reader_->command("reload"));
reader_->openPath(manyPages_);
QTRY_COMPARE_WITH_TIMEOUT(reader_->state(), "Ready", 15000);
QTRY_VERIFY_WITH_TIMEOUT(canvas_->viewportReady(), 15000);
QTRY_COMPARE_WITH_TIMEOUT(reader_->historyCount(), 1, 5000);
auto *store = reader_->findChild<StateStore *>();
QVERIFY(store);
QCOMPARE(store->restore(manyPages_).value("location").toMap().value("pageIndex").toInt(), 0);
const auto stateFile = store->filePath();
reader_->execute("nav.page", {{"page", 42}});
QCOMPARE(canvas_->currentPage(), 41);
QVERIFY(!canvas_->viewportReady());
// Close synchronously before the newly requested tiles can arrive.
cleanup();
StateStore reopened(stateFile);
const auto restored = reopened.restore(manyPages_).value("location").toMap();
QVERIFY(!restored.isEmpty());
QCOMPARE(restored.value("pageIndex").toInt(), 0);
QVERIFY(reopened.clearHistory());
}
void panelFocusAndInputProtection()
{
reader_->openPath(QFINDTESTDATA("fixtures/pdf/navigation.pdf"));
QTRY_COMPARE_WITH_TIMEOUT(reader_->state(), "Ready", 15000);
QVERIFY(!reader_->outline().isEmpty());
const auto page = canvas_->currentPage();
key(Qt::Key_T);
QCOMPARE(window_->property("tocVisible").toBool(), true);
QCOMPARE(reader_->context(), "content");
QCOMPARE(canvas_->currentPage(), page);
key(Qt::Key_P);
QCOMPARE(window_->property("pagesVisible").toBool(), true);
key(Qt::Key_W, Qt::ControlModifier); key(Qt::Key_W);
QCOMPARE(reader_->context(), "toc");
key(Qt::Key_J);
QCOMPARE(canvas_->currentPage(), page);
key(Qt::Key_W, Qt::ControlModifier); key(Qt::Key_W);
QCOMPARE(reader_->context(), "pages");
key(Qt::Key_P);
QCOMPARE(reader_->context(), "content");
QCOMPARE(window_->property("pagesVisible").toBool(), false);
reader_->execute("nav.page", {{"page", 1}});
const auto offset = canvas_->offset();
reader_->setMode("command");
key(Qt::Key_J); key(Qt::Key_2); key(Qt::Key_G, Qt::ShiftModifier);
QCOMPARE(canvas_->offset(), offset);
reader_->setMode("normal");
QInputMethodEvent composition(QString::fromUtf8("変換中"), {});
QCoreApplication::sendEvent(window_, &composition);
key(Qt::Key_J); key(Qt::Key_2); key(Qt::Key_G, Qt::ShiftModifier);
QCOMPARE(canvas_->offset(), offset);
QVERIFY(reader_->pending().isEmpty());
QInputMethodEvent finished;
QCoreApplication::sendEvent(window_, &finished);
}
void pdfSearchCapabilityFollowsCompletedEvidence_data()
{
QTest::addColumn<bool>("hasText");
QTest::addColumn<QString>("query");
QTest::newRow("text-with-match") << true << QStringLiteral("Needle");
QTest::newRow("text-without-match") << true << QStringLiteral("DefinitelyAbsentSearchTerm");
QTest::newRow("no-text") << false << QStringLiteral("Needle");
}
void pdfSearchCapabilityFollowsCompletedEvidence()
{
QFETCH(bool, hasText);
QFETCH(QString, query);
const auto empty = documents_.filePath("search-without-text.pdf");
QVERIFY(writeBytes(empty, pagedPdf(3)));
reader_->openPath(hasText ? QFINDTESTDATA("fixtures/pdf/navigation.pdf") : empty);
QTRY_COMPARE_WITH_TIMEOUT(reader_->state(), "Ready", 15000);
auto *session = activeSession(); QVERIFY(session && session->worker);
QTRY_VERIFY_WITH_TIMEOUT(canvas_->viewportReady() && session->worker->idle(), 15000);
const auto capability = [&] { return session->meta.value("capabilities").toMap().value("textSearch").toString(); };
QCOMPARE(capability(), "loading");
// The request is sent, but no event loop can consume its response
// before cancel changes the revision. Even an empty document stays
// unclassified when that in-flight response arrives.
reader_->search(query);
QVERIFY(!session->worker->idle());
reader_->cancel();
QTRY_VERIFY_WITH_TIMEOUT(session->worker->idle(), 15000);
QCOMPARE(capability(), "loading");
reader_->search(query);
QTRY_COMPARE_WITH_TIMEOUT(capability(), hasText ? "supported" : "unavailable", 15000);
QTRY_VERIFY_WITH_TIMEOUT(session->worker->idle(), 15000);
if (!hasText) QVERIFY(reader_->notice().contains(QStringLiteral("検索できる文字情報がありません")));
if (hasText && query == "Needle") QVERIFY(reader_->notice().contains(QStringLiteral("一致する箇所")));
if (hasText && query != "Needle") QVERIFY(reader_->notice().contains(QStringLiteral("検索結果がありません")));
// A new document must start with its own evidence, even after a
// supported or unavailable capability was established previously.
const auto token = session->token;
reader_->openPath(manyPages_);
QTRY_VERIFY_WITH_TIMEOUT(reader_->state() == "Ready" && reader_->webToken() != token, 15000);
session = activeSession(); QVERIFY(session && session->worker);
QTRY_VERIFY_WITH_TIMEOUT(canvas_->viewportReady() && session->worker->idle(), 15000);
QCOMPARE(capability(), "loading");
}
void tinyPdfCommentRemainsKeyboardAccessible()
{
reader_->openPath(QFINDTESTDATA("fixtures/pdf/tiny-comments.pdf"));
QTRY_COMPARE_WITH_TIMEOUT(reader_->state(), "Ready", 15000);
canvas_->setZoom(25);
QTRY_VERIFY_WITH_TIMEOUT(canvas_->viewportReady(), 15000);
QSignalSpy comments(canvas_, &PdfCanvas::annotationRequested);
key(Qt::Key_Tab); key(Qt::Key_Return);
QTRY_COMPARE(comments.size(), 1);
QCOMPARE(comments.first().first().toString(), "<b>Literal comment</b>");
QVERIFY(std::isfinite(canvas_->offset()));
QCOMPARE(canvas_->currentPage(), 0);
key(Qt::Key_Escape);
QTRY_COMPARE(reader_->mode(), "normal");
}
void pdfCommentsAreReadableWithKeyboard_data()
{
QTest::addColumn<bool>("outside");
QTest::newRow("links-and-comment") << false;
QTest::newRow("comments-only-outside-crop") << true;
}
void pdfCommentsAreReadableWithKeyboard()
{
QFETCH(bool, outside);
const auto path = outside ? QFINDTESTDATA("fixtures/pdf/comments.pdf") : QFINDTESTDATA("fixtures/pdf/navigation.pdf");
const auto original = fileHash(path); QVERIFY(!original.isEmpty());
reader_->openPath(path);
QTRY_COMPARE_WITH_TIMEOUT(reader_->state(), "Ready", 15000);
QTRY_VERIFY_WITH_TIMEOUT(canvas_->viewportReady(), 15000);
QSignalSpy comments(canvas_, &PdfCanvas::annotationRequested);
auto *dialog = window_->findChild<QObject *>("infoDialog");
auto *body = window_->findChild<QObject *>("infoText");
QVERIFY(dialog); QVERIFY(body);
// Reverse tab starts at the last comment, after the unchanged link order.
key(Qt::Key_Tab, Qt::ShiftModifier); key(Qt::Key_Return);
QTRY_COMPARE_WITH_TIMEOUT(comments.size(), 1, 3000);
QCOMPARE(body->property("text").toString(), outside ? "Outside CropBox comment" : "Read-only comment");
QCOMPARE(dialog->property("title").toString(), QString::fromUtf8("注釈"));
QCOMPARE(body->property("textFormat").toInt(), 0); // Qt.PlainText; contents never become markup
QCOMPARE(reader_->mode(), "dialog");
QVERIFY(dialog->property("visible").toBool());
const auto screenshots = qEnvironmentVariable("DOCVIEW_COMMENT_SCREENSHOTS");
if (!screenshots.isEmpty()) {
QVERIFY(QDir().mkpath(screenshots));
QTest::qWait(100);
QVERIFY(window_->grabWindow().save(QDir(screenshots).filePath(outside ? "outside-crop.png" : "comment.png")));
}
key(Qt::Key_Escape);
QTRY_COMPARE(reader_->mode(), "normal");
QCOMPARE(canvas_->currentPage(), 0);
if (outside) {
key(Qt::Key_Tab); key(Qt::Key_Return);
QTRY_COMPARE(comments.size(), 2);
QCOMPARE(body->property("text").toString(), "<b>Literal comment</b>");
key(Qt::Key_Escape);
QTRY_COMPARE(reader_->mode(), "normal");
} else {
// Forward tab wraps to the URI link and then to the blocked Launch
// link, before arriving back at the comment.
key(Qt::Key_Tab); key(Qt::Key_Tab); key(Qt::Key_Return);
QVERIFY(reader_->notice().contains("E_LINK_BLOCKED"));
QCOMPARE(comments.size(), 1);
key(Qt::Key_Tab); key(Qt::Key_Return);
QTRY_COMPARE(comments.size(), 2);
key(Qt::Key_Escape);
QTRY_COMPARE(reader_->mode(), "normal");
}
// Rotations and zoom preserve the selected comment and its read-only body.
for (int rotation : {90, 180, 270, 0}) {
canvas_->goToLocation({{"pageIndex", 0}, {"viewRotation", rotation}, {"zoom", 200.0}, {"fitMode", "percent"}});
const int before = comments.size();
canvas_->activateLink();
QTRY_COMPARE(comments.size(), before + 1);
key(Qt::Key_Escape);
QTRY_COMPARE(reader_->mode(), "normal");
}
key(Qt::Key_Escape); // In normal mode Esc clears the PDF target selection.
const int beforeClear = comments.size(); key(Qt::Key_Return);
QCOMPARE(comments.size(), beforeClear);
QVERIFY(reader_->notice().contains(QString::fromUtf8("選択してください")));
QCOMPARE(fileHash(path), original);
reader_->execute("document.info");
QCOMPARE(dialog->property("title").toString(), QString::fromUtf8("文書情報"));
key(Qt::Key_Escape);
QTRY_COMPARE(reader_->mode(), "normal");
reader_->openPath(html_);
QTRY_COMPARE_WITH_TIMEOUT(reader_->state(), "Ready", 20000);
const int before = comments.size();
canvas_->activateLink();
QCOMPARE(comments.size(), before); // old PDF selection was retired
}
void htmlStyleHeadingsAndScriptIsolation()
{
reader_->openPath(html_);
QTRY_COMPARE_WITH_TIMEOUT(reader_->state(), "Ready", 20000);
QCOMPARE(reader_->format(), "html");
QTRY_VERIFY_WITH_TIMEOUT(reader_->outline().size() >= 2, 10000);
QVERIFY(reader_->pages().isEmpty());
QVariant result;
QVERIFY(evaluateWeb("({title:document.querySelector('h1').textContent,color:getComputedStyle(document.querySelector('h1')).color,executed:document.body.hasAttribute('data-document-script')})", &result));
QCOMPARE(result.toMap().value("title").toString(), "First heading");
QCOMPARE(result.toMap().value("color").toString(), "rgb(10, 20, 30)");
QCOMPARE(result.toMap().value("executed").toBool(), false);
reader_->execute("nav.heading_next");
QTest::qWait(150);
QVERIFY(evaluateWeb("document.querySelector('h1').getBoundingClientRect().top", &result));
QVERIFY(qAbs(result.toDouble()) < 3);
reader_->execute("nav.heading_next");
QTest::qWait(150);
QVERIFY(evaluateWeb("({scroll:window.scrollY,total:document.documentElement.scrollHeight,viewport:innerHeight,first:document.querySelector('h1').getBoundingClientRect().top,second:document.querySelector('h2').getBoundingClientRect().top,writing:getComputedStyle(document.body).writingMode})", &result));
QVERIFY2(result.toMap().value("scroll").toDouble() > 500
&& qAbs(result.toMap().value("second").toDouble()) < 3,
QJsonDocument::fromVariant(result).toJson(QJsonDocument::Compact).constData());
const auto token = reader_->webToken();
QVERIFY(!reader_->navigationAllowed(QUrl("https://example.org/"), 5, token));
QCOMPARE(reader_->webToken(), token);
reader_->execute("nav.page", {{"page", 42}});
QVERIFY2(reader_->notice().contains("利用できません"), qPrintable(reader_->notice()));
QCOMPARE(reader_->webToken(), token);
}
void zipRelativeStylesAndInternalNavigation()
{
const auto originalHash = fileHash(zip_);
reader_->openPath(zip_);
QTRY_COMPARE_WITH_TIMEOUT(reader_->state(), "Ready", 25000);
QCOMPARE(reader_->format(), "htmlzip");
QTRY_VERIFY_WITH_TIMEOUT(reader_->outline().size() >= 2, 10000);
QVariant result;
QVERIFY(evaluateWeb("getComputedStyle(document.querySelector('h1')).color", &result));
QCOMPARE(result.toString(), "rgb(40, 50, 60)");
const auto token = reader_->webToken();
reader_->activateItem({{"href", "other.html#destination"}});
QTRY_VERIFY_WITH_TIMEOUT(activeWeb() && activeWeb()->property("url").toUrl().path().endsWith("other.html"), 15000);
QTRY_VERIFY_WITH_TIMEOUT(reader_->outline().size() == 1, 10000);
QVERIFY(evaluateWeb("document.querySelector('h1').textContent", &result));
QCOMPARE(result.toString(), "Archive linked destination");
QCOMPARE(reader_->webToken(), token);
QCOMPARE(fileHash(zip_), originalHash);
}
void epubSpineOrderAndNonlinearSkipping()
{
reader_->openPath(epub_);
QTRY_COMPARE_WITH_TIMEOUT(reader_->state(), "Ready", 25000);
QCOMPARE(reader_->format(), "epub");
QCOMPARE(reader_->outline().first().toMap().value("title").toString(), "Second in contents");
QTRY_VERIFY_WITH_TIMEOUT(activeWeb() && activeWeb()->property("documentReady").toBool(), 10000);
QVariant result;
QVERIFY(evaluateWeb("document.querySelector('h1').textContent", &result));
QCOMPARE(result.toString(), "Chapter One");
reader_->execute("nav.chapter_next");
QTRY_VERIFY_WITH_TIMEOUT(activeWeb() && activeWeb()->property("url").toUrl().path().endsWith("two.xhtml"), 15000);
QTRY_VERIFY_WITH_TIMEOUT(activeWeb()->property("documentReady").toBool(), 10000);
QVERIFY(evaluateWeb("document.querySelector('h1').textContent", &result));
QCOMPARE(result.toString(), "Chapter Two");
QTRY_VERIFY_WITH_TIMEOUT(reader_->position().contains("3 / 3"), 10000);
reader_->execute("nav.chapter_previous");
QTRY_VERIFY_WITH_TIMEOUT(activeWeb() && activeWeb()->property("url").toUrl().path().endsWith("one.xhtml"), 15000);
}
void resourceWarningsAggregateWithoutRetainingReferences_data()
{
QTest::addColumn<QString>("kind");
QTest::newRow("html") << QString("html");
QTest::newRow("zip") << QString("zip");
QTest::newRow("epub") << QString("epub");
}
void resourceWarningsAggregateWithoutRetainingReferences()
{
QFETCH(QString, kind);
const auto path = documents_.filePath("resource-warnings." + kind);
const QByteArray html = "<!DOCTYPE html><html xmlns='http://www.w3.org/1999/xhtml'><head><meta charset='utf-8'/><title>Resource checks</title>"
"<link rel='stylesheet' href='absent-secret-style.css'/><link rel='stylesheet' href='https://invalid.example/secret-external.css'/>"
"<style>@font-face{font-family:missing;src:url(absent-secret-font.woff)}p{font-family:missing}</style>"
"</head><body><h1>Readable despite missing resources</h1><p>Local font check</p><img src='absent-secret-image.png'/>"
"<img src='https://invalid.example/secret-external.png'/></body></html>";
if (kind == "html") QVERIFY(writeBytes(path, html));
else if (kind == "zip") QVERIFY(makeZip(path, {{"index.html", html}}));
else {
auto files = epubFixture();
for (auto &file : files) if (file.first == "OPS/one.xhtml") file.second = html;
QVERIFY(makeZip(path, files));
}
const auto original = fileHash(path);
reader_->openPath(path);
QTRY_COMPARE_WITH_TIMEOUT(reader_->state(), "Ready", 20000);
auto *session = activeSession();
QVERIFY(session);
QTRY_VERIFY_WITH_TIMEOUT(session->resourceIssues.value("broker.missing") >= 3, 10000);
QTRY_VERIFY_WITH_TIMEOUT(session->resourceIssues.value("csp.style") >= 1, 10000);
QTRY_VERIFY_WITH_TIMEOUT(session->resourceIssues.value("csp.image") >= 1, 10000);
const auto firstWarnings = session->meta.value("warnings").toStringList();
QCOMPARE(firstWarnings.filter(":info").size(), 1);
const int missing = session->resourceIssues.value("broker.missing");
QVariant result;
QVERIFY(evaluateWeb("(()=>{const i=document.createElementNS('http://www.w3.org/1999/xhtml','img');i.src='absent-late-image.png';document.body.append(i);return document.querySelector('h1').textContent})()", &result));
QCOMPARE(result.toString(), "Readable despite missing resources");
QTRY_COMPARE_WITH_TIMEOUT(session->resourceIssues.value("broker.missing"), missing + 1, 10000);
QCOMPARE(session->meta.value("warnings").toStringList(), firstWarnings);
reader_->notify(""); // Details outlive the transient notification.
QSignalSpy information(reader_.get(), &Controller::uiCommand);
reader_->execute("document.info");
QVERIFY(!information.isEmpty());
const auto text = information.last().at(1).toMap().value("text").toString();
QVERIFY(text.contains("E_RESOURCE_MISSING"));
QVERIFY(text.contains("E_RESOURCE_BLOCKED"));
QVERIFY(text.contains(QString::number(missing + 1) + "件"));
QVERIFY(text.contains("スタイルシート"));
QVERIFY(!text.contains("secret"));
QVERIFY(!text.contains("invalid.example"));
QVERIFY(!text.contains(session->token));
QVERIFY(!text.contains(documents_.path()));
QCOMPARE(fileHash(path), original);
const auto output = qEnvironmentVariable("DOCVIEW_RESOURCE_SCREENSHOT");
if (!output.isEmpty() && kind == "html") {
QTest::qWait(200);
QVERIFY(window_->grabWindow().save(output));
}
}
void resourceFailureReasonsStayDistinct()
{
const auto root = documents_.filePath("resource-classification");
QVERIFY(QDir().mkpath(root));
const auto page = root + "/index.html";
QVERIFY(writeBytes(page, "<!doctype html><h1>Readable resource classes</h1>"));
QVERIFY(writeBytes(root + "/unsupported.bin", "body{}"));
QVERIFY(writeBytes(root + "/denied.css", "body{}"));
QFile large(root + "/large.css"); QVERIFY(large.open(QIODevice::WriteOnly));
QVERIFY(large.resize(256ll * 1024 * 1024 + 1)); large.close();
#ifdef Q_OS_LINUX
QVERIFY(::symlink("denied.css", QFile::encodeName(root + "/symbolic.css").constData()) == 0);
QVERIFY(::link(QFile::encodeName(root + "/denied.css").constData(), QFile::encodeName(root + "/hard.css").constData()) == 0);
QVERIFY(writeBytes(root + "/private.css", "body{}"));
QVERIFY(QFile::setPermissions(root + "/private.css", {}));
#endif
reader_->openPath(page);
QTRY_COMPARE_WITH_TIMEOUT(reader_->state(), "Ready", 20000);
QTRY_VERIFY(activeWeb() && activeWeb()->property("documentReady").toBool());
auto *session = activeSession(); QVERIFY(session);
QStringList files{"absent.css", "unsupported.bin", "large.css"};
#ifdef Q_OS_LINUX
files << "symbolic.css" << "hard.css";
if (::geteuid() != 0) files << "private.css";
#endif
for (const auto &name : files) {
QVariant result;
QVERIFY(evaluateWeb("(()=>{const e=document.createElement('link');e.rel='stylesheet';e.href='" + name + "';document.head.append(e);return true})()", &result));
}
QTRY_COMPARE_WITH_TIMEOUT(session->resourceIssues.value("broker.missing"), 1, 10000);
QTRY_COMPARE_WITH_TIMEOUT(session->resourceIssues.value("broker.type"), 1, 10000);
QTRY_COMPARE_WITH_TIMEOUT(session->resourceIssues.value("broker.limit"), 1, 10000);
#ifdef Q_OS_LINUX
QTRY_COMPARE_WITH_TIMEOUT(session->resourceIssues.value("broker.blocked"), 2, 10000);
if (::geteuid() != 0) QTRY_COMPARE_WITH_TIMEOUT(session->resourceIssues.value("broker.denied"), 1, 10000);
QVERIFY(QFile::setPermissions(root + "/private.css", QFile::ReadOwner | QFile::WriteOwner));
#endif
QSignalSpy info(reader_.get(), &Controller::uiCommand); reader_->execute("document.info");
const auto text = info.last().at(1).toMap().value("text").toString();
QVERIFY(text.contains("E_RESOURCE_MISSING")); QVERIFY(text.contains("E_RESOURCE_BLOCKED"));
QVERIFY(text.contains("E_RESOURCE_LIMIT")); QVERIFY(!text.contains(root));
QVERIFY(!text.contains("symbolic.css")); QVERIFY(!text.contains(session->token));
QCOMPARE(reader_->state(), "Ready");
}
void archiveResourceFailureKeepsItsReason_data()
{
QTest::addColumn<QString>("kind"); QTest::addColumn<QString>("reason"); QTest::addColumn<QString>("code");
QTest::newRow("actual-crc") << QString("crc") << QString("broker.corrupt") << QString("E_ARCHIVE_CORRUPT");
QTest::newRow("expanded-budget") << QString("limit") << QString("broker.archive_limit") << QString("E_ARCHIVE_LIMIT");
QTest::newRow("unavailable-storage") << QString("storage") << QString("broker.storage_failed") << QString("E_STORAGE_FAILED");
}
void archiveResourceFailureKeepsItsReason()
{
QFETCH(QString, kind); QFETCH(QString, reason); QFETCH(QString, code);
const auto archive = documents_.filePath("classified-" + kind + ".zip");
const QByteArray asset = "body{color:rgb(12,34,56)}/*unique-resource-crc*/";
QVERIFY(makeZip(archive, {{"index.html", "<!doctype html><h1>Still readable</h1>"}, {"late.css", asset}}));
if (kind == "crc") {
QFile file(archive); QVERIFY(file.open(QIODevice::ReadWrite)); auto bytes = file.readAll();
const auto offset = bytes.indexOf(asset); QVERIFY(offset >= 0); bytes[offset] = 'X';
QVERIFY(file.seek(0)); QCOMPARE(file.write(bytes), bytes.size()); file.close();
}
const auto original = fileHash(archive);
reader_->openPath(archive); QTRY_COMPARE_WITH_TIMEOUT(reader_->state(), "Ready", 20000);
QTRY_VERIFY(activeWeb() && activeWeb()->property("documentReady").toBool());
auto *session = activeSession(); QVERIFY(session && session->worker);
const auto resourceRoot = session->root;
if (kind == "limit") session->expandedBytes = 2ll * 1024 * 1024 * 1024 - 1;
if (kind == "storage") session->root += "/unavailable-storage";
QVariant value;
QVERIFY(evaluateWeb("(()=>{const e=document.createElement('link');e.rel='stylesheet';e.href='late.css';document.head.append(e);return true})()", &value));
QTRY_COMPARE_WITH_TIMEOUT(session->resourceIssues.value(reason), 1, 10000);
QCOMPARE(session->resourceIssues.value("broker.missing"), 0);
QVERIFY(!QFile::exists(resourceRoot + "/late.css"));
if (kind != "storage") { QCOMPARE(reader_->state(), "Recovering"); QVERIFY(session->resourcesStopped); }
QVERIFY(evaluateWeb("document.querySelector('h1').textContent", &value)); QCOMPARE(value.toString(), "Still readable");
QSignalSpy info(reader_.get(), &Controller::uiCommand); reader_->execute("document.info");
const auto text = info.last().at(1).toMap().value("text").toString();
QVERIFY(text.contains(code)); QVERIFY(!text.contains("E_RESOURCE_MISSING"));
QVERIFY(!text.contains("late.css")); QVERIFY(!text.contains(resourceRoot));
QCOMPARE(fileHash(archive), original);
session->root = resourceRoot;
}
#ifdef Q_OS_LINUX
void pendingArchiveWorkerFailure_data()
{
QTest::addColumn<QString>("kind");
QTest::newRow("real-killed-worker") << QString("crash");
QTest::newRow("timeout-signal") << QString("timeout");
QTest::newRow("explicit-document-replacement") << QString("replace");
}
void pendingArchiveWorkerFailure()
{
QFETCH(QString, kind);
const auto path = documents_.filePath("pending-worker-" + kind + ".zip");
QVERIFY(makeZip(path, {{"index.html", "<!doctype html><h1>Preserved content</h1>"}, {"late.css", "body{color:black}"}}));
reader_->openPath(path); QTRY_COMPARE_WITH_TIMEOUT(reader_->state(), "Ready", 20000);
QTRY_VERIFY(activeWeb() && activeWeb()->property("documentReady").toBool());
QPointer<Session> session = activeSession(); QVERIFY(session && session->worker);
auto *socket = session->worker->findChild<QLocalSocket *>(); QVERIFY(socket);
struct ucred peer{}; socklen_t length = sizeof(peer);
QCOMPARE(::getsockopt(int(socket->socketDescriptor()), SOL_SOCKET, SO_PEERCRED, &peer, &length), 0);
QVERIFY(peer.pid > 1 && peer.pid != ::getpid()); QCOMPARE(peer.uid, ::geteuid());
QCOMPARE(::kill(peer.pid, SIGSTOP), 0);
bool completed = false;
const auto cleanup = qScopeGuard([&] { if (!completed) ::kill(peer.pid, SIGKILL); });
QVariant value;
QVERIFY(evaluateWeb("(()=>{const e=document.createElement('link');e.rel='stylesheet';e.href='late.css';document.head.append(e);return true})()", &value));
QTRY_VERIFY_WITH_TIMEOUT(session->extractWaiters.contains("late.css"), 5000);
auto outcomes = std::make_shared<QList<ResourceFailure>>();
session->extractWaiters["late.css"].append([outcomes](ResourceFailure failure) { *outcomes << failure; });
QSignalSpy issues(session->handler, &ResourceHandler::blocked);
if (kind == "replace") {
reader_->openPath(html_); QTRY_COMPARE_WITH_TIMEOUT(reader_->format(), "html", 10000);
QTRY_COMPARE_WITH_TIMEOUT(reader_->state(), "Ready", 10000);
QCOMPARE(*outcomes, QList<ResourceFailure>{ResourceFailure::Cancelled});
QCOMPARE(issues.size(), 0);
} else {
if (kind == "crash") QCOMPARE(::kill(peer.pid, SIGKILL), 0);
else QVERIFY(QMetaObject::invokeMethod(session->worker, "failed", Qt::DirectConnection,
Q_ARG(QString, "E_WORKER_TIMEOUT"), Q_ARG(QString, "Injected deadline notification")));
QTRY_COMPARE_WITH_TIMEOUT(reader_->state(), "Recovering", 10000);
const auto failure = kind == "crash" ? ResourceFailure::WorkerFailed : ResourceFailure::WorkerTimeout;
QCOMPARE(*outcomes, QList<ResourceFailure>{failure});
QCOMPARE(session->resourceIssues.value(resourceFailureKey(failure)), 1);
QCOMPARE(session->resourceIssues.value("broker.missing"), 0);
QVERIFY(session->extractWaiters.isEmpty());
QVERIFY(evaluateWeb("document.querySelector('h1').textContent", &value)); QCOMPARE(value.toString(), "Preserved content");
QTest::qWait(50); QCOMPARE(outcomes->size(), 1); QCOMPARE(issues.size(), 1);
}
completed = true;
}
#endif
void resourceWarningReportsValidateWholeBatchAndSession()
{
reader_->clearHistory();
QVERIFY(writeBytes(configuration_, "[privacy]\nremember_position=true\nrecent_documents=5\n"));
reader_->execute("config.reload");
reader_->openPath(html_);
QTRY_COMPARE_WITH_TIMEOUT(reader_->state(), "Ready", 20000);
QTRY_VERIFY_WITH_TIMEOUT(activeWeb() && activeWeb()->property("documentReady").toBool(), 10000);
auto *session = activeSession();
QVERIFY(session);
const auto token = session->token;
reader_->webResourceIssues(token, {{"csp.image", 3}, {"csp.font", 2.0}});
const auto counts = session->resourceIssues;
const QList<QVariantMap> invalid{
{{"csp.image", 4}, {"csp.url", "https://secret.invalid/"}},
{{"csp.image", true}}, {{"csp.image", "3"}}, {{"csp.image", 0}},
{{"csp.image", -1}}, {{"csp.image", 1.5}}, {{"csp.image", 1000000}},
{{"csp.image", std::numeric_limits<double>::infinity()}},
{{"csp.image", std::numeric_limits<double>::quiet_NaN()}},
{{"broker.missing", 10}}, {{"network.blocked", 10}}};
for (const auto &report : invalid) {
reader_->webResourceIssues(token, report);
QCOMPARE(session->resourceIssues, counts);
}
reader_->webResourceIssues("stale-document", {{"csp.image", 7}});
QCOMPARE(session->resourceIssues, counts);
reader_->webResourceIssues(token, {{"csp.image", 999999}});
QCOMPARE(session->resourceIssues.value("csp.image"), 999999);
reader_->webResourceIssues(token, {{"csp.image", 999999}});
QCOMPARE(session->resourceIssues.value("csp.image"), 999999);
for (const auto &kind : {"style", "font", "script", "frame", "connect", "form", "base", "object", "media", "other"})
reader_->webResourceIssues(token, {{"csp." + QString::fromLatin1(kind), 1}});
window_->resize(480, 360);
reader_->execute("document.info");
auto *dialog = window_->findChild<QObject *>("infoDialog");
auto *scroll = window_->findChild<QObject *>("infoScroll");
QVERIFY(dialog && scroll);
QTRY_VERIFY(dialog->property("visible").toBool());
QVERIFY(dialog->property("height").toDouble() <= 300);
QVERIFY(scroll->property("contentHeight").toDouble() > scroll->property("availableHeight").toDouble());
key(Qt::Key_Escape);
QTRY_VERIFY(!dialog->property("visible").toBool());
QCOMPARE(reader_->mode(), "normal");
auto *store = reader_->findChild<StateStore *>();
QVERIFY(store && store->flush());
QFile state(testStorage().stateFile);
QVERIFY(state.open(QIODevice::ReadOnly));
const auto saved = state.readAll();
QVERIFY(!saved.contains("csp.image"));
QVERIFY(!saved.contains("resourceIssues"));
QVERIFY(!saved.contains("secret.invalid"));
reader_->openPath(documents_.filePath("other.html"));
QTRY_COMPARE_WITH_TIMEOUT(reader_->state(), "Ready", 20000);
QTRY_VERIFY_WITH_TIMEOUT(reader_->webToken() != token, 10000);
auto *next = activeSession();
QVERIFY(next);
const auto nextCounts = next->resourceIssues;
reader_->webResourceIssues(token, {{"csp.image", 100}});
QCOMPARE(next->resourceIssues, nextCounts);
}
void webLocationPersistsOnlyValidatedLogicalFields()
{
QVERIFY(writeBytes(configuration_, "[privacy]\nremember_position=true\nrecent_documents=5\nstore_text_anchor=false\n"));
reader_->execute("config.reload");
reader_->clearHistory();
reader_->openPath(html_);
QTRY_COMPARE_WITH_TIMEOUT(reader_->state(), "Ready", 20000);
QTRY_VERIFY_WITH_TIMEOUT(activeWeb() && activeWeb()->property("documentReady").toBool(), 10000);
auto *session = activeSession();
QVERIFY(session);
const auto token = reader_->webToken();
QVariantMap valid{{"url", "doc://" + token + "/index.html"}, {"cfi", "epubcfi(/4/2/1:0)"},
{"fragment", "first"}, {"progression", .25}, {"offsetX", .1}, {"offsetY", .2},
{"textQuote", "short visible text"}, {"arbitrary", QVariantMap{{"token", token}}}};
QSignalSpy positioned(reader_.get(), &Controller::positionChanged);
reader_->webLocation(token, valid);
QCOMPARE(positioned.size(), 1);
QVERIFY(QMetaObject::invokeMethod(window_, "frameSwapped", Qt::DirectConnection));
QCoreApplication::processEvents();
QCOMPARE(session->lastPresented.value("resourcePath").toString(), "index.html");
QVERIFY(!session->lastPresented.contains("url"));
QVERIFY(!session->lastPresented.contains("arbitrary"));
auto *store = reader_->findChild<StateStore *>();
QVERIFY(store);
QVERIFY(store->flush());
const auto saved = store->restore(html_).value("location").toMap();
QCOMPARE(saved.value("resourcePath").toString(), "index.html");
QVERIFY(!saved.contains("textQuote"));
QFile state(testStorage().stateFile);
QVERIFY(state.open(QIODevice::ReadOnly));
const auto bytes = state.readAll();
QVERIFY(!bytes.contains(token.toUtf8()));
QVERIFY(!bytes.contains("doc://"));
// Wrong types, limits, origin and old generations cannot replace the
// last validated position; unknown data is never serialized.
QList<QVariantMap> bad;
auto changed = [&](const QString &key, const QVariant &value) {
auto next = valid; next[key] = value; bad.append(next);
};
changed("progression", "0.5");
changed("progression", true);
changed("progression", -0.1);
changed("progression", 1.1);
changed("progression", std::numeric_limits<double>::quiet_NaN());
changed("cfi", QVariantList{1, 2});
changed("cfi", QString(4097, 'a'));
changed("fragment", QString(1025, 'a'));
changed("textQuote", QString(161, 'a'));
changed("offsetX", std::numeric_limits<double>::infinity());
changed("url", "doc://different-session/index.html");
changed("url", "file:///etc/passwd");
changed("url", "doc://" + token + "/%2e%2e/secret.html");
for (const auto &value : bad) {
positioned.clear();
reader_->webLocation(token, value);
QCOMPARE(positioned.size(), 0);
QVERIFY(reader_->notice().startsWith("E_RESOURCE_BLOCKED"));
}
positioned.clear();
reader_->webLocation("old-session", valid);
QCOMPARE(positioned.size(), 0);
}
void epubRestoresStableSpineIdentityAndRejectsForeignPackage()
{
QVERIFY(writeBytes(configuration_, "[privacy]\nremember_position=true\nrecent_documents=5\n"));
reader_->execute("config.reload");
reader_->clearHistory();
auto *store = reader_->findChild<StateStore *>();
QVERIFY(store);
// The stable ID wins over an outdated href. CFI stays local to the
// resolved spine resource; a legacy transient URL is discarded.
const QVariantMap location{{"packagePath", "OPS/book.opf"}, {"spineIdref", "two"},
{"href", "OPS/one.xhtml"}, {"url", "doc://expired/OPS/one.xhtml"},
{"fragment", "two"}, {"progression", 0.0}};
QVERIFY(store->remember(epub_, location));
QSignalSpy commands(reader_.get(), &Controller::webCommand);
reader_->openPath(epub_);
QTRY_COMPARE_WITH_TIMEOUT(reader_->state(), "Ready", 25000);
QTRY_VERIFY_WITH_TIMEOUT(activeWeb() && activeWeb()->property("url").toUrl().path().endsWith("two.xhtml")
&& activeWeb()->property("documentReady").toBool(), 15000);
QTRY_COMPARE_WITH_TIMEOUT(activeSession()->lastPresented.value("spineIdref").toString(), "two", 10000);
QCOMPARE(activeSession()->lastPresented.value("packagePath").toString(), "OPS/book.opf");
QCOMPARE(activeSession()->lastPresented.value("resourcePath").toString(), "OPS/two.xhtml");
QVERIFY(!activeSession()->lastPresented.contains("url"));
bool restored = false;
for (const auto &call : commands) if (call[0].toString() == "location.restore") {
restored = true;
const auto applied = call[1].toMap();
QCOMPARE(applied.value("spineIdref").toString(), "two");
QVERIFY(applied.value("href").toString().startsWith("OPS/two.xhtml"));
QVERIFY(!applied.contains("url"));
}
QVERIFY(restored);
// A different package or unresolved spine must not silently use href.
reader_->openPath(html_);
QTRY_COMPARE_WITH_TIMEOUT(reader_->format(), "html", 20000);
auto invalid = location;
invalid["packagePath"] = "OPS/foreign.opf";
QVERIFY(store->remember(epub_, invalid));
commands.clear();
reader_->openPath(epub_);
QTRY_COMPARE_WITH_TIMEOUT(reader_->format(), "epub", 20000);
QTRY_VERIFY_WITH_TIMEOUT(reader_->notice().startsWith("E_LOCATOR_UNRESOLVED"), 10000);
QCOMPARE(activeWeb()->property("url").toUrl().path(), "/OPS/one.xhtml");
for (const auto &call : commands) QVERIFY(call[0].toString() != "location.restore");
}
void legacyHtmlLocatorRestoresWithoutSessionUrl_data()
{
QTest::addColumn<bool>("resourceOnly");
QTest::newRow("legacy-href") << false;
QTest::newRow("logical-resource-path") << true;
}
void legacyHtmlLocatorRestoresWithoutSessionUrl()
{
QFETCH(bool, resourceOnly);
QVERIFY(writeBytes(configuration_, "[privacy]\nremember_position=true\nrecent_documents=5\n"));
reader_->execute("config.reload");
reader_->clearHistory();
auto *store = reader_->findChild<StateStore *>();
QVERIFY(store);
QVariantMap savedLocation{{"href", "other.html#destination"},
{"url", "doc://expired-capability/other.html#destination"},
{"fragment", "destination"}, {"progression", 0.0}};
if (resourceOnly) {
savedLocation.remove("href"); savedLocation.remove("url");
savedLocation["resourcePath"] = "other.html";
}
QVERIFY(store->remember(html_, savedLocation));
reader_->openPath(html_);
QTRY_COMPARE_WITH_TIMEOUT(reader_->state(), "Ready", 20000);
QTRY_VERIFY_WITH_TIMEOUT(activeWeb() && activeWeb()->property("url").toUrl().path().endsWith("other.html")
&& activeWeb()->property("documentReady").toBool(), 15000);
QTRY_COMPARE_WITH_TIMEOUT(activeSession()->lastPresented.value("resourcePath").toString(), "other.html", 10000);
QVERIFY(store->flush());
const auto saved = store->restore(html_).value("location").toMap();
QVERIFY(!saved.contains("url"));
QCOMPARE(saved.value("resourcePath").toString(), "other.html");
}
void failedReplacementCancelAndStaleGeneration()
{
reader_->openPath(manyPages_);
QTRY_COMPARE_WITH_TIMEOUT(reader_->state(), "Ready", 15000);
reader_->execute("nav.page", {{"page", 42}});
QTRY_COMPARE(canvas_->currentPage(), 41);
const auto title = reader_->title();
reader_->openPath(QFINDTESTDATA("fixtures/pdf/corrupt.pdf"));
QCOMPARE(reader_->state(), "Opening");
QCOMPARE(reader_->title(), title);
QTRY_COMPARE_WITH_TIMEOUT(reader_->state(), "Ready", 15000);
QCOMPARE(reader_->title(), title);
QCOMPARE(canvas_->currentPage(), 41);
QVERIFY(reader_->notice().startsWith("E_"));
reader_->openPath(epub_);
QCOMPARE(reader_->state(), "Opening");
reader_->cancel();
QCOMPARE(reader_->state(), "Ready");
QVERIFY(reader_->notice().contains("取り消しました"));
QTest::qWait(200);
QCOMPARE(reader_->title(), title);
QCOMPARE(canvas_->currentPage(), 41);
reader_->openPath(epub_);
reader_->openPath(html_);
QTRY_COMPARE_WITH_TIMEOUT(reader_->state(), "Ready", 20000);
QCOMPARE(reader_->format(), "html");
const auto token = reader_->webToken();
reader_->webLoaded("obsolete-generation", true);
reader_->webIndex("obsolete-generation", {{"headings", QVariantList{QVariantMap{{"title", "stale"}}}}});
QCOMPARE(reader_->webToken(), token);
QTest::qWait(200);
QCOMPARE(reader_->format(), "html");
}
void epubDocumentEndpoints_data()
{
QTest::addColumn<QString>("writingMode");
QTest::addColumn<QString>("direction");
QTest::newRow("horizontal-ltr") << "horizontal-tb" << "ltr";
QTest::newRow("horizontal-rtl") << "horizontal-tb" << "rtl";
QTest::newRow("vertical-rl") << "vertical-rl" << "rtl";
}
void epubDocumentEndpoints()
{
QFETCH(QString, writingMode);
QFETCH(QString, direction);
auto files = epubFixture();
files[2].second.replace("</manifest>", "<item id=\"pre\" href=\"pre.xhtml\" media-type=\"application/xhtml+xml\"/><item id=\"post\" href=\"post.xhtml\" media-type=\"application/xhtml+xml\"/></manifest>");
files[2].second.replace("<spine>", "<spine page-progression-direction=\"rtl\"><itemref idref=\"pre\" linear=\"no\"/>");
files[2].second.replace("</spine>", "<itemref idref=\"post\" linear=\"no\"/></spine>");
const auto style = QString("<body style=\"margin:0;writing-mode:%1;direction:%2;%3\">")
.arg(writingMode, direction, writingMode == "vertical-rl" ? "min-width:4000px" : "min-height:4000px").toUtf8();
files[4].second.replace("<body>", style);
files[6].second.replace("<body>", style);
files.append(QPair<QByteArray, QByteArray>{"OPS/pre.xhtml", "<html xmlns=\"http://www.w3.org/1999/xhtml\"><body><h1>Nonlinear front</h1></body></html>"});
files.append(QPair<QByteArray, QByteArray>{"OPS/post.xhtml", "<html xmlns=\"http://www.w3.org/1999/xhtml\"><body><h1>Nonlinear end</h1></body></html>"});
const auto path = documents_.filePath("endpoints-" + writingMode + "-" + direction + ".epub");
QVERIFY(makeZip(path, files));
reader_->openPath(path);
QTRY_COMPARE_WITH_TIMEOUT(reader_->state(), "Ready", 25000);
QTRY_VERIFY_WITH_TIMEOUT(activeWeb() && activeWeb()->property("documentReady").toBool(), 10000);
const auto at = [&](const QString &name, double expected) {
if (!activeWeb() || !activeWeb()->property("documentReady").toBool()
|| !activeWeb()->property("url").toUrl().path().endsWith(name)) return false;
QVariant result;
return evaluateWeb("__docviewReader.location().progression", &result)
&& std::abs(result.toDouble() - expected) < .002;
};
// The first/last spine rows are nonlinear; document endpoints belong
// to normal reading order and include the final chapter's actual end.
QTRY_VERIFY_WITH_TIMEOUT(at("one.xhtml", 0), 10000);
reader_->execute("nav.document_end");
QTRY_VERIFY_WITH_TIMEOUT(at("two.xhtml", 1), 10000);
reader_->execute("nav.document_start");
QTRY_VERIFY_WITH_TIMEOUT(at("one.xhtml", 0), 10000);
// No load event is required when already in the endpoint resource.
QVariant result;
QVERIFY(evaluateWeb("__docviewReader.command('nav.document_end',{})", &result));
QTRY_VERIFY_WITH_TIMEOUT(at("one.xhtml", 1), 5000);
reader_->execute("nav.document_start");
QTRY_VERIFY_WITH_TIMEOUT(at("one.xhtml", 0), 5000);
reader_->execute("nav.document_end");
QTRY_VERIFY_WITH_TIMEOUT(at("two.xhtml", 1), 10000);
QVERIFY(evaluateWeb("__docviewReader.command('nav.document_start',{})", &result));
QTRY_VERIFY_WITH_TIMEOUT(at("two.xhtml", 0), 5000);
reader_->execute("nav.document_end");
QTRY_VERIFY_WITH_TIMEOUT(at("two.xhtml", 1), 5000);
}
void zipEntryChoiceSurvivesRestart_data()
{
QTest::addColumn<bool>("rememberPosition");
QTest::newRow("recent-history-without-reading-position") << false;
QTest::newRow("reading-position-without-recent-list") << true;
}
void zipEntryChoiceSurvivesRestart()
{
QFETCH(bool, rememberPosition);
reader_->clearHistory();
const auto privacy = rememberPosition
? QByteArray("[privacy]\nremember_position=true\nrecent_documents=0\n")
: QByteArray("[privacy]\nremember_position=false\nrecent_documents=3\n");
QVERIFY(writeBytes(configuration_, privacy));
reader_->execute("config.reload");
const auto path = documents_.filePath("remember-entry.zip");
QVERIFY(makeZip(path, {{"a/first.html", "<!doctype html><h1>First entry</h1>"},
{"b/second.html", "<!doctype html><h1>Chosen entry</h1>"}}));
const auto original = fileHash(path);
QSignalSpy choices(reader_.get(), &Controller::entryNeeded);
reader_->openPath(path);
QTRY_COMPARE_WITH_TIMEOUT(choices.size(), 1, 10000);
QCOMPARE(reader_->mode(), "dialog");
auto *list = window_->findChild<QQuickItem *>("entryCandidates");
QVERIFY(list);
QCOMPARE(list->property("currentIndex").toInt(), 0);
key(Qt::Key_Down);
QCOMPARE(list->property("currentIndex").toInt(), 1);
key(Qt::Key_Return);
QTRY_COMPARE_WITH_TIMEOUT(reader_->state(), "Ready", 20000);
QTRY_VERIFY_WITH_TIMEOUT(activeWeb() && activeWeb()->property("documentReady").toBool(), 10000);
QVERIFY(activeWeb()->property("url").toUrl().path().endsWith("b/second.html"));
auto *store = reader_->findChild<StateStore *>();
QVERIFY(store);
const QStringList candidates{"a/first.html", "b/second.html"};
QCOMPARE(store->archiveEntry(path, candidates), "b/second.html");
if (!rememberPosition) QVERIFY(store->restore(path).isEmpty());
QVERIFY(store->flush());
cleanup();
QProcess freshProcess;
freshProcess.setProcessChannelMode(QProcess::MergedChannels);
freshProcess.start(QCoreApplication::applicationFilePath(), {"--zip-entry-probe", documents_.path()});
QVERIFY(freshProcess.waitForStarted(5000));
QVERIFY(freshProcess.waitForFinished(30000));
QVERIFY2(freshProcess.exitStatus() == QProcess::NormalExit && freshProcess.exitCode() == 0,
freshProcess.readAll().constData());
startUi();
QSignalSpy reopened(reader_.get(), &Controller::entryNeeded);
reader_->openPath(path);
QTRY_COMPARE_WITH_TIMEOUT(reader_->state(), "Ready", 20000);
QTRY_VERIFY_WITH_TIMEOUT(activeWeb() && activeWeb()->property("documentReady").toBool(), 10000);
QCOMPARE(reopened.size(), 0);
QCOMPARE(reader_->mode(), "normal");
QVERIFY(activeWeb()->property("url").toUrl().path().endsWith("b/second.html"));
cleanup();
const auto savedHash = fileHash(testStorage().stateFile);
startUi(true);
QVERIFY(reader_->findChild<StateStore *>()->readOnly());
QSignalSpy readOnlyChoices(reader_.get(), &Controller::entryNeeded);
reader_->openPath(path);
QTRY_COMPARE_WITH_TIMEOUT(reader_->state(), "Ready", 20000);
QCOMPARE(readOnlyChoices.size(), 0);
QVERIFY(activeWeb()->property("url").toUrl().path().endsWith("b/second.html"));
cleanup();
QCOMPARE(fileHash(testStorage().stateFile), savedHash);
QCOMPARE(fileHash(path), original);
}
void zipEntryChoiceDisabledOrCleared_data()
{
QTest::addColumn<bool>("disabled");
QTest::newRow("fully-disabled-privacy") << true;
QTest::newRow("history-cleared") << false;
}
void zipEntryChoiceDisabledOrCleared()
{
QFETCH(bool, disabled);
reader_->clearHistory();
QVERIFY(writeBytes(configuration_, disabled
? "[privacy]\nremember_position=false\nrecent_documents=0\n"
: "[privacy]\nremember_position=false\nrecent_documents=3\n"));
reader_->execute("config.reload");
const auto path = documents_.filePath("private-entry.zip");
QVERIFY(makeZip(path, {{"one.html", "<h1>One</h1>"}, {"two.html", "<h1>Two</h1>"}}));
QSignalSpy choices(reader_.get(), &Controller::entryNeeded);
reader_->openPath(path);
QTRY_COMPARE_WITH_TIMEOUT(choices.size(), 1, 10000);
key(Qt::Key_Down);
key(Qt::Key_Return);
QTRY_COMPARE_WITH_TIMEOUT(reader_->state(), "Ready", 20000);
QTRY_VERIFY_WITH_TIMEOUT(activeSession() && !activeSession()->lastPresented.isEmpty(), 10000);
if (!disabled) {
QCOMPARE(reader_->findChild<StateStore *>()->archiveEntry(path, {"one.html", "two.html"}), "two.html");
reader_->clearHistory();
}
QCOMPARE(reader_->historyCount(), 0);
cleanup();
startUi();
QSignalSpy reopened(reader_.get(), &Controller::entryNeeded);
reader_->openPath(path);
QTRY_COMPARE_WITH_TIMEOUT(reopened.size(), 1, 10000);
QCOMPARE(reader_->state(), "Opening");
QVERIFY(reader_->webToken().isEmpty());
key(Qt::Key_Escape);
QTRY_COMPARE(reader_->state(), "Empty");
QCOMPARE(reader_->historyCount(), 0);
}
void zipEntryChoiceInvalidated_data()
{
QTest::addColumn<QString>("change");
QTest::newRow("replacement-same-bytes-size-mtime") << "replacement";
QTest::newRow("archive-candidate-removed") << "changed";
QTest::newRow("stored-candidate-no-longer-present") << "missing";
QTest::newRow("unsafe-saved-candidate") << "unsafe";
}
void zipEntryChoiceInvalidated()
{
QFETCH(QString, change);
reader_->clearHistory();
QVERIFY(writeBytes(configuration_, "[privacy]\nremember_position=false\nrecent_documents=3\n"));
reader_->execute("config.reload");
const auto path = documents_.filePath("changed-entry-" + change + ".zip");
QVERIFY(makeZip(path, {{"first.html", "<h1>First</h1>"}, {"second.html", "<h1>Second</h1>"}}));
const auto identity = StateStore::fileIdentity(path);
const auto originalHash = fileHash(path);
QSignalSpy choices(reader_.get(), &Controller::entryNeeded);
reader_->openPath(path);
QTRY_COMPARE_WITH_TIMEOUT(choices.size(), 1, 10000);
key(Qt::Key_Down);
key(Qt::Key_Return);
QTRY_COMPARE_WITH_TIMEOUT(reader_->state(), "Ready", 20000);
QCOMPARE(reader_->findChild<StateStore *>()->archiveEntry(path, {"first.html", "second.html"}), "second.html");
cleanup();
if (change == "replacement") {
QVERIFY(QFile::rename(path, path + ".original"));
QVERIFY(QFile::copy(path + ".original", path));
QFile replaced(path);
QVERIFY(replaced.open(QIODevice::ReadWrite));
QVERIFY(replaced.setFileTime(QDateTime::fromMSecsSinceEpoch(identity.value("mtime").toLongLong()), QFileDevice::FileModificationTime));
replaced.close();
const auto current = StateStore::fileIdentity(path);
QCOMPARE(current.value("size"), identity.value("size"));
QCOMPARE(current.value("mtime"), identity.value("mtime"));
QVERIFY(current.value("fileIdentity") != identity.value("fileIdentity"));
QCOMPARE(fileHash(path), originalHash);
} else if (change == "changed") {
QVERIFY(QFile::remove(path));
QVERIFY(makeZip(path, {{"first.html", "<h1>Changed original</h1>"}, {"third.html", "<h1>New candidate</h1>"}}));
} else {
QFile state(testStorage().stateFile);
QVERIFY(state.open(QIODevice::ReadOnly));
auto json = QJsonDocument::fromJson(state.readAll()).object();
state.close();
auto entries = json.value("documents").toArray();
QCOMPARE(entries.size(), 1);
auto record = entries.first().toObject();
record["archiveEntry"] = change == "unsafe" ? "../second.html" : "absent.html";
entries[0] = record;
json["documents"] = entries;
QVERIFY(writeBytes(testStorage().stateFile, QJsonDocument(json).toJson()));
}
startUi();
QSignalSpy reopened(reader_.get(), &Controller::entryNeeded);
reader_->openPath(path);
QTRY_COMPARE_WITH_TIMEOUT(reopened.size(), 1, 10000);
QCOMPARE(reader_->state(), "Opening");
QVERIFY(reader_->webToken().isEmpty());
const auto candidates = reopened.first().first().toStringList();
QVERIFY(candidates.contains("first.html"));
QVERIFY(!candidates.contains("../second.html"));
key(Qt::Key_Escape);
QTRY_COMPARE(reader_->state(), "Empty");
if (change != "changed") QCOMPARE(fileHash(path), originalHash);
}
void cancelledZipEntryChoiceIsNotSaved()
{
reader_->clearHistory();
QVERIFY(writeBytes(configuration_, "[privacy]\nremember_position=false\nrecent_documents=3\n"));
reader_->execute("config.reload");
const auto path = documents_.filePath("cancelled-choice.zip");
QVERIFY(makeZip(path, {{"first.html", "<h1>First</h1>"}, {"second.html", "<h1>Second</h1>"}}));
QSignalSpy choices(reader_.get(), &Controller::entryNeeded);
reader_->openPath(path);
QTRY_COMPARE_WITH_TIMEOUT(choices.size(), 1, 10000);
auto *dialog = window_->findChild<QObject *>("entryDialog");
QVERIFY(dialog);
QVERIFY(QMetaObject::invokeMethod(dialog, "accept"));
reader_->cancel(); // No event loop turn: the selected document has not committed.
QCOMPARE(reader_->state(), "Empty");
QCOMPARE(reader_->historyCount(), 0);
QVERIFY(reader_->findChild<StateStore *>()->archiveEntry(path, {"first.html", "second.html"}).isEmpty());
QTest::qWait(100);
QCOMPARE(reader_->historyCount(), 0);
cleanup();
startUi();
QSignalSpy reopened(reader_.get(), &Controller::entryNeeded);
reader_->openPath(path);
QTRY_COMPARE_WITH_TIMEOUT(reopened.size(), 1, 10000);
key(Qt::Key_Escape);
}
void epubFixedSpreads_data()
{
QTest::addColumn<QString>("policy");
QTest::addColumn<bool>("rtl");
QTest::newRow("both-ltr") << "both" << false;
QTest::newRow("both-rtl") << "both" << true;
QTest::newRow("landscape") << "landscape" << false;
QTest::newRow("auto") << "auto" << false;
QTest::newRow("none") << "none" << false;
}
void epubFixedSpreads()
{
QFETCH(QString, policy); QFETCH(bool, rtl);
auto files = epubFixture();
files = files.mid(0, 2);
const QStringList sides = rtl ? QStringList{"right", "left"} : QStringList{"left", "right"};
QByteArray package = "<package xmlns=\"http://www.idpf.org/2007/opf\" version=\"3.0\"><metadata xmlns:dc=\"http://purl.org/dc/elements/1.1/\"><dc:title>Spread proof</dc:title><meta property=\"rendition:layout\">pre-paginated</meta><meta property=\"rendition:spread\">" + policy.toUtf8() + "</meta></metadata><manifest>";
QByteArray spine;
for (int i = 1; i <= 7; ++i) {
const auto number = QByteArray::number(i);
package += "<item id=\"p" + number + "\" href=\"p" + number + ".xhtml\" media-type=\"application/xhtml+xml\"/>";
QByteArray properties;
if (i <= 2) properties = "page-spread-" + sides[i-1].toUtf8();
if (i == 3) properties = "rendition:page-spread-center rendition:spread-both";
if (i == 4) properties = "rendition:spread-none";
if (i == 5) properties = "rendition:layout-reflowable";
if (i >= 6) properties = "rendition:spread-both page-spread-" + sides[i-6].toUtf8();
spine += "<itemref idref=\"p" + number + "\" properties=\"" + properties + "\"/>";
const QByteArray color = i % 2 ? "#e7edff" : "#fff0d4";
const QByteArray label = i == 3 ? "CENTER" : i == 4 ? "SPREAD NONE" : i == 5 ? "REFLOW" : "PAGE " + number;
files.append({"OPS/p" + number + ".xhtml", "<html xmlns=\"http://www.w3.org/1999/xhtml\"><head><meta name=\"viewport\" content=\"width=400,height=600\"/><style>html,body{margin:0;padding:0}body{background:" + color + ";color:#102040;font:24px sans-serif}article{box-sizing:border-box;width:400px;height:600px;border:6px solid #284a70;padding:20px}h1{font-size:42px}svg{width:348px;height:280px}</style></head><body><article><h1>" + label + "</h1><p>Fixed-layout spread proof</p><svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 348 280\"><rect width=\"348\" height=\"280\" fill=\"#457c96\"/><circle cx=\"174\" cy=\"140\" r=\"95\" fill=\"#f4b54d\"/></svg><a href=\"p3.xhtml\">Center page</a></article></body></html>"});
}
package += "</manifest><spine page-progression-direction=\"" + QByteArray(rtl ? "rtl" : "ltr") + "\">" + spine + "</spine></package>";
files.append({"OPS/book.opf", package});
const auto path = documents_.filePath("spread-" + policy + (rtl ? "-rtl" : "-ltr") + ".epub");
QVERIFY(makeZip(path, files));
QVERIFY(resizeTestWindow(QSize(1100, 760)));
QTRY_COMPARE_WITH_TIMEOUT(window_->size(), QSize(1100, 760), 5000);
reader_->openPath(path);
QTRY_COMPARE_WITH_TIMEOUT(reader_->state(), "Ready", 25000);
const auto companion = [&]() -> QQuickItem * {
for (auto *object : window_->findChildren<QQuickItem *>())
if (object->property("documentToken").toString() == reader_->webToken()
&& object->property("isCompanion").toBool() && object->property("alive").toBool()) return object;
return nullptr;
};
const auto loadedPage = [&](int number) {
return activeWeb() && activeWeb()->property("documentReady").toBool()
&& activeWeb()->property("url").toUrl().path().endsWith(QString("p%1.xhtml").arg(number));
};
const auto readyPair = [&]() {
return companion() && companion()->property("documentReady").toBool()
&& companion()->property("fixedViewport").toMap().value("width").toInt() == 400;
};
const auto spreadDiagnostic = [&](const char *phase) {
if (!qEnvironmentVariableIsSet("DOCVIEW_SPREAD_DIAGNOSTICS") || !activeWeb()) return;
auto *pane = activeWeb()->parent();
QQmlExpression expression(engine_->rootContext(), pane,
"JSON.stringify({width:width,height:height,selection:spreadSelection,failed:failedCompanionUrl,"
"pending:!!pendingView,activeReady:activeView.documentReady,fixed:activeView.fixedLayout,"
"active:resourcePath(activeView.url),companion:companionView?resourcePath(companionView.url):null,"
"companionReady:companionView?companionView.documentReady:false,options:optionsFor(activeView)})");
qInfo().noquote() << "Spread diagnostic" << phase << "window" << window_->size() << expression.evaluate().toString();
};
const auto diagnosticAtExit = qScopeGuard([&] { spreadDiagnostic("exit"); });
const auto verifyGeometry = [&]() {
auto *primary = qobject_cast<QQuickItem *>(activeWeb()); auto *other = companion();
if (!primary || !other) return false;
auto *left = primary->x() < other->x() ? primary : other;
auto *right = left == primary ? other : primary;
return std::abs(left->x() + left->width() * left->scale() - right->x()) < 1.1
&& std::abs(primary->height() * primary->scale() - other->height() * other->scale()) < 1.1;
};
QTRY_VERIFY_WITH_TIMEOUT(loadedPage(1), 10000);
if (policy != "none") {
QTRY_VERIFY_WITH_TIMEOUT(readyPair(), 15000);
QTRY_VERIFY_WITH_TIMEOUT(verifyGeometry(), 5000);
QCOMPARE(companion()->property("url").toUrl().path(), "/OPS/p2.xhtml");
QCOMPARE(qobject_cast<QQuickItem *>(activeWeb())->x() > companion()->x(), rtl);
QVariant viewport; QVERIFY(evaluateWeb("[innerWidth,innerHeight]", &viewport));
QCOMPARE(viewport.toList().at(0).toInt(), 400); QCOMPARE(viewport.toList().at(1).toInt(), 600);
} else QTRY_VERIFY_WITH_TIMEOUT(!companion(), 3000);
const auto capture = [&](const QString &suffix) {
const auto directory = qEnvironmentVariable("DOCVIEW_SPREAD_SCREENSHOTS");
if (directory.isEmpty()) return true;
if (!QDir().mkpath(directory)) return false;
QTest::qWait(150);
return window_->grabWindow().save(QDir(directory).filePath(policy + (rtl ? "-rtl-" : "-ltr-") + suffix + ".png"));
};
QVERIFY(capture("landscape"));
if (policy == "both") {
// Zoom scales the complete pair and physical keys can pan it.
reader_->execute("zoom.in"); reader_->execute("zoom.in"); reader_->execute("zoom.in");
QTRY_VERIFY_WITH_TIMEOUT(verifyGeometry(), 5000);
auto *primary = qobject_cast<QQuickItem *>(activeWeb());
const auto beforePan = primary->x();
reader_->execute("scroll.right");
QTRY_VERIFY_WITH_TIMEOUT(primary->x() < beforePan, 5000);
reader_->execute("zoom.fit_width");
QTRY_VERIFY_WITH_TIMEOUT(verifyGeometry(), 5000);
// Cancelling a staged replacement reconstructs the prior spread.
const auto token = reader_->webToken();
QSignalSpy staged(reader_.get(), &Controller::stageWeb);
connect(reader_.get(), &Controller::stageWeb, reader_.get(), &Controller::cancel,
Qt::ConnectionType(Qt::QueuedConnection | Qt::SingleShotConnection));
reader_->openPath(epub_);
QTRY_COMPARE_WITH_TIMEOUT(staged.count(), 1, 10000);
QTRY_COMPARE_WITH_TIMEOUT(reader_->state(), "Ready", 10000);
QCOMPARE(reader_->webToken(), token);
QTRY_VERIFY_WITH_TIMEOUT(readyPair(), 10000);
QVERIFY(!reader_->notice().contains("読込中"));
}
QVERIFY(resizeTestWindow(QSize(600, 960)));
QTRY_COMPARE_WITH_TIMEOUT(window_->size(), QSize(600, 960), 5000);
QTRY_VERIFY(qobject_cast<QQuickItem *>(activeWeb())->parentItem()->width() < qobject_cast<QQuickItem *>(activeWeb())->parentItem()->height());
if (policy == "both") { QTRY_VERIFY_WITH_TIMEOUT(readyPair(), 10000); QTRY_VERIFY_WITH_TIMEOUT(verifyGeometry(), 5000); }
else QTRY_VERIFY_WITH_TIMEOUT(!companion(), 5000);
QVERIFY(capture("portrait"));
QVERIFY(resizeTestWindow(QSize(1100, 760)));
QTRY_COMPARE_WITH_TIMEOUT(window_->size(), QSize(1100, 760), 5000);
QTRY_VERIFY(qobject_cast<QQuickItem *>(activeWeb())->parentItem()->width() > qobject_cast<QQuickItem *>(activeWeb())->parentItem()->height());
if (qEnvironmentVariableIsSet("DOCVIEW_SPREAD_DIAGNOSTICS")) {
spreadDiagnostic("landscape-immediate");
}
if (policy != "none") QTRY_VERIFY_WITH_TIMEOUT(readyPair(), 10000);
reader_->execute("nav.chapter_next");
QTRY_VERIFY_WITH_TIMEOUT(loadedPage(2), 10000);
if (policy != "none") {
QTRY_VERIFY_WITH_TIMEOUT(readyPair(), 10000);
QCOMPARE(companion()->property("url").toUrl().path(), "/OPS/p1.xhtml");
}
reader_->execute("nav.chapter_next");
QTRY_VERIFY_WITH_TIMEOUT(loadedPage(3), 10000);
QTRY_VERIFY_WITH_TIMEOUT(!companion(), 5000);
QVERIFY(capture("center"));
reader_->execute("nav.chapter_next");
QTRY_VERIFY_WITH_TIMEOUT(loadedPage(4), 10000);
QTRY_VERIFY_WITH_TIMEOUT(!companion(), 5000);
reader_->execute("nav.chapter_next");
QTRY_VERIFY_WITH_TIMEOUT(loadedPage(5), 10000);
QTRY_COMPARE_WITH_TIMEOUT(activeWeb()->property("fixedLayout").toBool(), false, 5000);
QTRY_VERIFY_WITH_TIMEOUT(!companion(), 5000);
auto *reflow = qobject_cast<QQuickItem *>(activeWeb());
QTRY_VERIFY(std::abs(reflow->width() - reflow->parentItem()->width()) < 1);
reader_->execute("nav.chapter_next");
QTRY_VERIFY_WITH_TIMEOUT(loadedPage(6), 10000);
QTRY_VERIFY_WITH_TIMEOUT(readyPair(), 10000);
QTRY_VERIFY_WITH_TIMEOUT(verifyGeometry(), 5000);
QCOMPARE(companion()->property("url").toUrl().path(), "/OPS/p7.xhtml");
// Only the commanded spine item becomes the logical position.
QTRY_COMPARE_WITH_TIMEOUT(activeSession()->lastPresented.value("resourcePath").toString(), "OPS/p6.xhtml", 10000);
reader_->openPath(html_);
QTRY_COMPARE_WITH_TIMEOUT(reader_->format(), "html", 15000);
QTRY_VERIFY_WITH_TIMEOUT(!companion(), 5000);
}
void outlineCurrentPositionIsSeparateFromKeyboardSelection()
{
const auto capture = [&](const QString &name) {
const auto directory = qEnvironmentVariable("DOCVIEW_NAVIGATION_SCREENSHOTS");
if (directory.isEmpty()) return true;
if (!QDir().mkpath(directory)) return false;
QTest::qWait(60);
return window_->grabWindow().save(QDir(directory).filePath(name + ".png"));
};
const auto path = documents_.filePath("current-outline.html");
QVERIFY(writeBytes(path, R"(<html><head><style>section{height:1000px}body{margin:0}</style></head><body>
<nav role="doc-toc"><ol><li><a href="#one">One</a><ol><li><a href="#two%2Fpart%23%25">Two</a></li></ol></li><li><a href="#three">Three</a></li></ol></nav>
<section><h1 id="one">One</h1><p>First section</p></section><section><h2 id="two/part#%">Two</h2><p>Second section</p></section>
<section><h1 id="three">Three</h1><p>Third section</p></section></body></html>)"));
const auto original = fileHash(path);
reader_->openPath(path);
QTRY_COMPARE_WITH_TIMEOUT(reader_->outline().size(), 3, 10000);
QCOMPARE(reader_->outlineTitle(), QString::fromUtf8("目次"));
reader_->activateItem(reader_->outline()[0].toMap());
QTRY_COMPARE_WITH_TIMEOUT(reader_->currentOutline(), 0, 10000);
reader_->execute("panel.toc.toggle");
reader_->setContext("toc");
auto *list = window_->findChild<QObject *>("tocList"); QVERIFY(list);
list->setProperty("currentIndex", 0);
QVariant before; QVERIFY(evaluateWeb("scrollY", &before));
key(Qt::Key_J);
QCOMPARE(list->property("currentIndex").toInt(), 1);
QVERIFY(QMetaObject::invokeMethod(reader_.get(), "navigationChanged"));
QCOMPARE(list->property("currentIndex").toInt(), 1);
QTRY_COMPARE(reader_->currentOutline(), 0);
QCOMPARE(reader_->currentOutline(), 0);
QVariant after; QVERIFY(evaluateWeb("scrollY", &after)); QCOMPARE(after, before);
QVERIFY(capture("current-and-selected"));
key(Qt::Key_Return);
QTRY_COMPARE_WITH_TIMEOUT(reader_->currentOutline(), 1, 10000);
reader_->setContext("toc"); list->setProperty("currentIndex", 0); key(Qt::Key_H);
QTRY_COMPARE(window_->property("visibleCurrentOutline").toInt(), 0);
QCOMPARE(reader_->currentOutline(), 1); // Collapsed parent represents its current child.
QTRY_VERIFY(list->property("currentItem").value<QQuickItem *>());
QTRY_VERIFY(list->property("currentItem").value<QQuickItem *>()->property("readingCurrent").toBool());
QVERIFY(list->property("currentItem").value<QQuickItem *>()->property("keyboardSelected").toBool());
QVERIFY(capture("collapsed-current"));
const auto staleToken = reader_->webToken();
const auto staleTargets = reader_->webNavigationTargets(staleToken, activeWeb()->property("url").toUrl());
const auto noToc = documents_.filePath("headings-only.html");
QVERIFY(writeBytes(noToc, "<html><body><h1>First</h1><p style='height:1200px'>Body</p><h2>Second</h2><p style='height:1200px'>End</p></body></html>"));
reader_->openPath(noToc);
QTRY_VERIFY_WITH_TIMEOUT(reader_->webToken() != staleToken && reader_->outline().size() == 2, 10000);
QCOMPARE(reader_->outlineTitle(), QString::fromUtf8("見出し一覧"));
reader_->activateItem(reader_->outline()[0].toMap());
QTRY_COMPARE_WITH_TIMEOUT(reader_->currentOutline(), 0, 10000);
reader_->activateItem(reader_->outline()[1].toMap());
QTRY_COMPARE_WITH_TIMEOUT(reader_->currentOutline(), 1, 10000);
key(Qt::Key_Left, Qt::AltModifier);
QTRY_COMPARE_WITH_TIMEOUT(reader_->currentOutline(), 0, 10000);
key(Qt::Key_Right, Qt::AltModifier);
QTRY_COMPARE_WITH_TIMEOUT(reader_->currentOutline(), 1, 10000);
reader_->webLocation(staleToken, {{"url", "doc://" + staleToken + "/current-outline.html"},
{"progression", 0.}, {"currentOutline", 0}, {"navigationRevision", staleTargets.value("revision")}});
QCOMPARE(reader_->currentOutline(), 1);
QVERIFY(!activeSession()->lastPresented.contains("currentOutline"));
QVERIFY(!activeSession()->lastPresented.contains("navigationRevision"));
QCOMPARE(fileHash(path), original);
}
void pdfStatusShowsLabelAndPhysicalPage()
{
reader_->openPath(QFINDTESTDATA("fixtures/pdf/navigation.pdf"));
QTRY_COMPARE_WITH_TIMEOUT(reader_->state(), "Ready", 10000);
canvas_->setZoom(400); canvas_->goTo(0);
QTRY_VERIFY(reader_->position().contains(QString::fromUtf8("i(実ページ 1)")));
QVERIFY(reader_->position().contains(" / 2"));
const auto outline = reader_->outline(); QVERIFY(!outline.isEmpty());
reader_->activateItem(outline.first().toMap());
QTRY_VERIFY(reader_->currentOutline() >= 0);
}
void outlineCurrentPageDoesNotRequireEarlierPageGeometry()
{
const auto path = documents_.filePath("lazy-outline.pdf");
QVERIFY(writeBytes(path, pagedPdf(500, 2, 300)));
reader_->openPath(path);
QTRY_COMPARE_WITH_TIMEOUT(reader_->state(), "Ready", 10000);
QTRY_COMPARE(reader_->outline().size(), 2);
canvas_->goTo(450);
QTRY_COMPARE_WITH_TIMEOUT(canvas_->currentPage(), 450, 10000);
QTRY_COMPARE_WITH_TIMEOUT(reader_->currentOutline(), 1, 10000);
}
void measureInteractionWorkload()
{
const auto output = qEnvironmentVariable("DOCVIEW_INTERACTION_OUTPUT");
if (output.isEmpty()) QSKIP("Opt-in T20 workload: run tests/measure_interaction.py; this is measured separately from normal app CTest.");
QDir directory(output);
QVERIFY(QDir().mkpath(directory.filePath("corpus")));
QJsonArray corpus;
const QStringList sources{stressPages_, manyPages_, html_, documents_.filePath("style.css"),
documents_.filePath("other.html"), epub_};
QHash<QString, QByteArray> originalHashes;
for (const auto &source : sources) {
const auto name = QFileInfo(source).fileName(); const auto hash = fileHash(source);
QVERIFY(!hash.isEmpty()); originalHashes[source] = hash;
QVERIFY(QFile::copy(source, directory.filePath("corpus/" + name)));
corpus.append(QJsonObject{{"path", name}, {"sha256", QString::fromLatin1(hash.toHex())},
{"size", QFileInfo(source).size()}});
}
InteractionMeasurement measurement(reader_.get(), canvas_, window_);
bool success = false;
const auto save = qScopeGuard([&] {
auto result = measurement.result(success);
result["corpus"] = corpus;
result["corpusDescription"] = "Deterministic generated 5000-page and 50-page vector PDFs; 5000th page green; simple HTML/CSS and two-linear-chapter EPUB. No large scan/complex-vector/font corpus claim.";
result["qtVersion"] = qVersion(); result["os"] = QSysInfo::prettyProductName();
result["kernel"] = QSysInfo::kernelVersion(); result["architecture"] = QSysInfo::currentCpuArchitecture();
result["display"] = QJsonObject{{"width", window_->screen()->size().width()}, {"height", window_->screen()->size().height()},
{"reportedRefreshHz", window_->screen()->refreshRate()}, {"devicePixelRatio", window_->devicePixelRatio()},
{"qtPlatform", QGuiApplication::platformName()},
{"quickBackend", qEnvironmentVariable("QT_QUICK_BACKEND")}, {"chromiumFlags", qEnvironmentVariable("QTWEBENGINE_CHROMIUM_FLAGS")}};
result["cacheCondition"] = "Application starts fresh; OS file cache is not cleared. Later phases reuse the application cache.";
result["standardCtest"] = "Intentionally QSKIP without DOCVIEW_INTERACTION_OUTPUT; dedicated runner executes this slot and preserves its result.";
result["sourceUnchanged"] = std::all_of(sources.begin(), sources.end(), [&](const auto &path) { return fileHash(path) == originalHashes[path]; });
result["finalState"] = QJsonObject{{"state", reader_->state()}, {"format", reader_->format()}, {"notice", reader_->notice()},
{"context", reader_->context()}, {"mode", reader_->mode()}, {"windowFocused", QGuiApplication::focusWindow() == window_},
{"currentPage", canvas_->currentPage()}, {"viewportReady", canvas_->viewportReady()}};
QSaveFile file(directory.filePath("measurement.json"));
if (!file.open(QIODevice::WriteOnly) || file.write(QJsonDocument(result).toJson(QJsonDocument::Indented)) < 0 || !file.commit())
qWarning() << "Failed to write interaction measurement";
});
auto waitUntil = [](const std::function<bool()> &predicate, int timeout = 30000) {
QElapsedTimer timer; timer.start();
while (!predicate() && timer.elapsed() < timeout) QTest::qWait(2);
return predicate();
};
auto presented = [&](const std::function<bool()> &ready) {
if (!waitUntil(ready)) return false;
// Two requested presentations after validated readiness avoid
// accepting an old queued frame notification as the target frame.
for (int frame = 0; frame < 2; ++frame) {
const auto before = measurement.frameSerial;
const auto requiredNs = InteractionMeasurement::nowNs();
window_->update();
if (!waitUntil([&] { return measurement.frameSerial > before && measurement.lastFrameNs >= requiredNs && ready(); })) return false;
}
measurement.sample(); return true;
};
auto pdfReady = [&](int count, int page) { return reader_->state() == "Ready" && reader_->format() == "pdf"
&& reader_->pages().size() == count && canvas_->currentPage() == page && canvas_->viewportReady(); };
bool inputFocusMaintained = true;
auto press = [&](Qt::Key code, const QString &text, Qt::KeyboardModifiers modifiers = {}, bool repeat = false, bool release = true) {
inputFocusMaintained = inputFocusMaintained && QGuiApplication::focusWindow() == window_;
QKeyEvent down(QEvent::KeyPress, code, modifiers, text, repeat);
QCoreApplication::sendEvent(window_, &down);
if (release) { QKeyEvent up(QEvent::KeyRelease, code, modifiers, text); QCoreApplication::sendEvent(window_, &up); }
measurement.sample();
};
auto jump = [&](int physicalPage) {
measurement.input({{"kind", "numeric-page-keys"}, {"text", QString::number(physicalPage) + "G"}, {"physicalPage", physicalPage}});
for (const auto digit : QString::number(physicalPage)) press(Qt::Key(Qt::Key_0 + digit.digitValue()), QString(digit));
press(Qt::Key_G, "G", Qt::ShiftModifier);
};
window_->resize(1100, 760);
measurement.begin("initial-open");
measurement.input({{"kind", "open-path"}, {"source", QFileInfo(stressPages_).fileName()}});
reader_->openPath(stressPages_);
QVERIFY2(presented([&] { return pdfReady(5000, 0); }), qPrintable(reader_->notice()));
measurement.finish({{"targetPage", 0}, {"viewportReady", true}});
measurement.begin("held-j-key");
int noOps = 0;
for (int index = 0; index < 120; ++index) {
const auto before = canvas_->offset();
measurement.input({{"kind", "key-press"}, {"key", "j"}, {"autoRepeat", index > 0}, {"index", index}, {"scheduledIntervalMs", 16}});
press(Qt::Key_J, "j", {}, index > 0, false);
const bool moved = std::abs(canvas_->offset() - before) > .01;
if (!moved) ++noOps;
measurement.inputOutcome({{"moved", moved}, {"offsetAfter", canvas_->offset()}, {"pageAfter", canvas_->currentPage()},
{"context", reader_->context()}, {"mode", reader_->mode()}, {"windowFocused", QGuiApplication::focusWindow() == window_}});
QTest::qWait(16);
}
QKeyEvent release(QEvent::KeyRelease, Qt::Key_J, {}, "j"); QCoreApplication::sendEvent(window_, &release);
const int heldPage = canvas_->currentPage(); const double heldOffset = canvas_->offset();
QVERIFY(presented([&] { return pdfReady(5000, heldPage) && std::abs(canvas_->offset() - heldOffset) < 1.; }));
measurement.finish({{"keyPressCount", 120}, {"boundaryOrRejectedNoOpCount", noOps}, {"movedCount", 120 - noOps}, {"targetPage", heldPage}});
measurement.begin("rapid-far-jumps");
const QList<int> jumps{5000, 42, 3750, 150, 2500, 1, 4900, 1250, 4999, 20, 3200, 750,
4700, 81, 2100, 300, 4100, 1000, 4800, 10, 3500, 500, 2500, 5000};
for (const auto page : jumps) { jump(page); QTest::qWait(8); }
QVERIFY(presented([&] { return pdfReady(5000, 4999); }));
QVERIFY(greenPagePixels(window_->grabWindow()) > 500);
measurement.finish({{"jumpCount", jumps.size()}, {"targetPage", 4999}, {"greenEndpointVerified", true}});
measurement.begin("resize-burst");
const QList<QSize> sizes{{480,360},{1100,760},{760,540},{900,600},{480,760},
{1100,360},{640,480},{1000,700},{480,360},{1100,760}};
for (const auto size : sizes) {
measurement.input({{"kind", "window-resize"}, {"width", size.width()}, {"height", size.height()}, {"scheduledIntervalMs", 40}});
window_->resize(size); QTest::qWait(40);
}
QVERIFY(presented([&] { return pdfReady(5000, 4999); }));
QVERIFY(greenPagePixels(window_->grabWindow()) > 500);
measurement.finish({{"resizeCount", sizes.size()}, {"targetPage", 4999}, {"greenEndpointVerified", true}});
measurement.begin("cancel-staged-document");
const auto retainedToken = reader_->webToken();
measurement.input({{"kind", "open-path"}, {"source", QFileInfo(epub_).fileName()}});
reader_->openPath(epub_); QCOMPARE(reader_->state(), "Opening");
measurement.input({{"kind", "key-press"}, {"key", "Escape"}});
press(Qt::Key_Escape, {});
QVERIFY(presented([&] { return reader_->webToken() == retainedToken && pdfReady(5000, 4999); }));
measurement.finish({{"cancelledBeforeCommit", true}, {"oldDocumentRetained", true}, {"targetPage", 4999}});
const QList<QPair<QString, QString>> replacements{{html_, "html"}, {epub_, "epub"}, {manyPages_, "pdf"}, {stressPages_, "pdf"}};
for (int index = 0; index < replacements.size(); ++index) {
const auto source = replacements[index].first, format = replacements[index].second;
measurement.begin("document-switch-" + QString::number(index + 1));
if (reader_->format() == "pdf") { jump(reader_->pages().size() > 50 ? 1234 : 42); QTest::qWait(20); }
const auto oldToken = reader_->webToken();
measurement.input({{"kind", "open-path"}, {"source", QFileInfo(source).fileName()}});
reader_->openPath(source);
QVERIFY2(presented([&] {
if (reader_->state() != "Ready" || reader_->webToken() == oldToken || reader_->format() != format) return false;
if (format == "pdf") return pdfReady(source == stressPages_ ? 5000 : 50, 0);
auto *view = activeWeb();
return view && view->property("documentReady").toBool()
&& !reader_->benchmarkSnapshot().value("location").toMap().value("resourcePath").toString().isEmpty();
}), qPrintable(reader_->notice()));
if (format != "pdf") {
QVariant body; QVERIFY(evaluateWeb("document.body.innerText", &body));
QVERIFY(body.toString().contains(format == "html" ? "Readable document" : "Chapter One"));
}
measurement.finish({{"format", format}, {"source", QFileInfo(source).fileName()}, {"newSessionVerified", true}});
}
measurement.begin("final-pdf-endpoint"); jump(5000);
QVERIFY(presented([&] { return pdfReady(5000, 4999); }));
const auto screenshot = window_->grabWindow();
QVERIFY(greenPagePixels(screenshot) > 500); QVERIFY(screenshot.save(directory.filePath("final-target.png")));
measurement.finish({{"targetPage", 4999}, {"greenEndpointVerified", true}});
for (const auto &source : sources) QCOMPARE(fileHash(source), originalHashes[source]);
QVERIFY2(inputFocusMaintained, "The host changed window focus during measured input; this run is not a valid interaction measurement");
success = true;
}
void memoryPressurePdfStagesPreserveLogicalPosition()
{
auto *monitor = reader_->findChild<MemoryPressureMonitor *>("docviewMemoryPressureMonitor");
QVERIFY(monitor); monitor->stop(); QVERIFY(!monitor->running());
const MemorySample low{8ull * 1024 * 1024 * 1024, 100ull * 1024 * 1024, true};
const MemorySample healthy{8ull * 1024 * 1024 * 1024, 4ull * 1024 * 1024 * 1024, true};
for (int i = 0; i < 5; ++i) monitor->observeSample(healthy);
QCOMPARE(monitor->level(), MemoryPressureLevel::Normal);
const auto original = fileHash(manyPages_);
reader_->openPath(manyPages_);
QTRY_COMPARE_WITH_TIMEOUT(reader_->state(), "Ready", 15000);
QTRY_VERIFY_WITH_TIMEOUT(canvas_->viewportReady(), 15000);
canvas_->setZoom(150);
canvas_->goTo(20, .25);
QTRY_COMPARE_WITH_TIMEOUT(canvas_->currentPage(), 20, 15000);
QTRY_VERIFY_WITH_TIMEOUT(canvas_->viewportReady(), 15000);
const auto initial = canvas_->location();
auto samePosition = [&](const QVariantMap &before) {
const auto after = canvas_->location();
return after.value("pageIndex") == before.value("pageIndex")
&& std::abs(after.value("xPt").toDouble() - before.value("xPt").toDouble()) < .01
&& std::abs(after.value("yPt").toDouble() - before.value("yPt").toDouble()) < .01
&& after.value("zoom") == before.value("zoom") && after.value("fitMode") == before.value("fitMode");
};
monitor->observeSample(low);
QCOMPARE(monitor->level(), MemoryPressureLevel::NoPrefetch);
QCOMPARE(canvas_->memoryPressureLevel(), 1);
QCOMPARE(canvas_->rasterResolutionScale(), 1.);
QVERIFY(samePosition(initial));
QSignalSpy requests(canvas_, &PdfCanvas::tileRequested);
canvas_->goTo(40, .25);
QTRY_COMPARE_WITH_TIMEOUT(canvas_->currentPage(), 40, 15000);
QTRY_VERIFY_WITH_TIMEOUT(canvas_->viewportReady(), 15000);
QVERIFY(!requests.isEmpty());
for (const auto &request : requests) QVERIFY2(!request[1].toBool(), "Memory pressure dispatched a new speculative render");
const auto location = canvas_->location();
const auto populatedCache = canvas_->cacheCostBytes();
const auto configuredBudget = canvas_->cacheBudgetBytes();
QVERIFY(populatedCache > 0);
monitor->observeSample(low);
QCOMPARE(monitor->level(), MemoryPressureLevel::Trim);
QCOMPARE(canvas_->memoryPressureLevel(), 2);
QVERIFY2(canvas_->cacheCostBytes() < populatedCache, "Trim did not release the previously visited, now invisible PDF pages");
QCOMPARE(canvas_->cacheBudgetBytes(), configuredBudget);
QVERIFY(samePosition(location));
monitor->observeSample(low);
QCOMPARE(monitor->level(), MemoryPressureLevel::Reduced);
QCOMPARE(canvas_->memoryPressureLevel(), 3);
QCOMPARE(canvas_->rasterResolutionScale(), .5);
QVERIFY(reader_->notice().contains(QString::fromUtf8("描画解像度")));
QVERIFY(activeSession());
QVERIFY(activeSession()->meta.value("warnings").toStringList().join('\n').contains(QString::fromUtf8("描画解像度")));
QTRY_VERIFY_WITH_TIMEOUT(canvas_->viewportReady(), 15000);
QVERIFY(samePosition(location));
QVERIFY(canvas_->cacheCostBytes() <= configuredBudget);
for (int i = 0; i < 4; ++i) monitor->observeSample(healthy);
QCOMPARE(monitor->level(), MemoryPressureLevel::Reduced);
monitor->observeSample(healthy);
QCOMPARE(monitor->level(), MemoryPressureLevel::Normal);
QCOMPARE(canvas_->memoryPressureLevel(), 0);
QCOMPARE(canvas_->rasterResolutionScale(), 1.);
QTRY_VERIFY_WITH_TIMEOUT(canvas_->viewportReady(), 15000);
QVERIFY(samePosition(location));
QCOMPARE(reader_->state(), "Ready");
QCOMPARE(fileHash(manyPages_), original);
}
void memoryPressureStopCancelsPendingDocumentAndRequiresReopen()
{
auto *monitor = reader_->findChild<MemoryPressureMonitor *>("docviewMemoryPressureMonitor");
QVERIFY(monitor); monitor->stop();
const MemorySample low{8ull * 1024 * 1024 * 1024, 100ull * 1024 * 1024, true};
const MemorySample healthy{8ull * 1024 * 1024 * 1024, 4ull * 1024 * 1024 * 1024, true};
for (int i = 0; i < 5; ++i) monitor->observeSample(healthy);
const auto pdfHash = fileHash(manyPages_), epubHash = fileHash(epub_);
reader_->openPath(manyPages_);
QTRY_COMPARE_WITH_TIMEOUT(reader_->state(), "Ready", 15000);
canvas_->goTo(41);
QTRY_COMPARE_WITH_TIMEOUT(canvas_->currentPage(), 41, 15000);
QTRY_VERIFY_WITH_TIMEOUT(canvas_->viewportReady(), 15000);
QTRY_VERIFY(activeSession() && activeSession()->lastPresented.value("pageIndex").toInt() == 41);
const auto previousToken = reader_->webToken();
QPointer<Session> active = activeSession(); QVERIFY(active);
QPointer<WorkerProcess> worker = active->worker; QVERIFY(worker);
const auto presentedLocation = active->lastPresented;
reader_->openPath(epub_);
QCOMPARE(reader_->state(), "Opening");
QPointer<Session> pending;
for (auto *object : reader_->children())
if (auto *session = dynamic_cast<Session *>(object); session && session != active) pending = session;
QVERIFY(pending);
for (int i = 0; i < 4; ++i) monitor->observeSample(low);
QCOMPARE(monitor->level(), MemoryPressureLevel::Stop);
QCOMPARE(canvas_->memoryPressureLevel(), 4);
QCOMPARE(reader_->state(), "Recovering");
QVERIFY(reader_->notice().startsWith("E_RESOURCE_LIMIT:"));
QVERIFY(active && active->resourcesStopped);
QVERIFY(!worker->isConnected()); QVERIFY(worker->idle());
QCOMPARE(canvas_->cacheCostBytes(), 0); QCOMPARE(canvas_->pendingRenderCount(), 0);
QVERIFY(!canvas_->viewportReady());
QCOMPARE(active->lastPresented, presentedLocation);
QSignalSpy newTiles(canvas_, &PdfCanvas::tileRequested), newPageRequests(canvas_, &PdfCanvas::pageNeeded);
const auto stoppedRenders = canvas_->visibleRenderRequestCount();
reader_->activateItem({{"page", 41}, {"precision", "page"}});
reader_->search("request after memory stop");
QTest::qWait(30);
QCOMPARE(reader_->state(), "Recovering");
QCOMPARE(worker->queuedRequestCount(), 0); QCOMPARE(worker->activeRequestCount(), 0);
QCOMPARE(canvas_->visibleRenderRequestCount(), stoppedRenders);
QVERIFY(newTiles.isEmpty()); QVERIFY(newPageRequests.isEmpty());
QTRY_VERIFY(!pending);
reader_->openPath(epub_);
QCOMPARE(reader_->state(), "Recovering");
QCOMPARE(reader_->webToken(), previousToken);
QVERIFY(reader_->notice().startsWith("E_RESOURCE_LIMIT:"));
QCOMPARE(reader_->findChildren<WorkerProcess *>().size(), 1);
for (int i = 0; i < 4; ++i) monitor->observeSample(healthy);
QCOMPARE(monitor->level(), MemoryPressureLevel::Stop);
monitor->observeSample(healthy);
QCOMPARE(monitor->level(), MemoryPressureLevel::Normal);
QTest::qWait(100);
QCOMPARE(reader_->state(), "Recovering");
QCOMPARE(reader_->webToken(), previousToken);
QVERIFY(!worker->isConnected()); QVERIFY(!canvas_->viewportReady());
reader_->openPath(manyPages_);
QTRY_COMPARE_WITH_TIMEOUT(reader_->state(), "Ready", 15000);
QTRY_VERIFY_WITH_TIMEOUT(canvas_->viewportReady(), 15000);
QVERIFY(reader_->webToken() != previousToken);
QTRY_VERIFY(!worker);
QCOMPARE(canvas_->memoryPressureLevel(), 0);
QCOMPARE(fileHash(manyPages_), pdfHash); QCOMPARE(fileHash(epub_), epubHash);
}
void memoryPressureStopDisposesWebViewAndRecoversExplicitly()
{
auto *monitor = reader_->findChild<MemoryPressureMonitor *>("docviewMemoryPressureMonitor");
QVERIFY(monitor); monitor->stop();
const MemorySample low{8ull * 1024 * 1024 * 1024, 100ull * 1024 * 1024, true};
const MemorySample healthy{8ull * 1024 * 1024 * 1024, 4ull * 1024 * 1024 * 1024, true};
for (int i = 0; i < 5; ++i) monitor->observeSample(healthy);
const auto epubHash = fileHash(epub_), htmlHash = fileHash(html_);
reader_->openPath(epub_);
QTRY_COMPARE_WITH_TIMEOUT(reader_->state(), "Ready", 15000);
QTRY_VERIFY_WITH_TIMEOUT(activeWeb() && activeWeb()->property("documentReady").toBool(), 15000);
QPointer<QObject> oldView = activeWeb();
const auto oldToken = reader_->webToken();
QPointer<Session> session = activeSession(); QVERIFY(session);
QPointer<QQuickWebEngineProfile> oldProfile = session->profile; QVERIFY(oldProfile);
QPointer<WorkerProcess> worker = session->worker; QVERIFY(worker);
QSignalSpy disposed(reader_.get(), &Controller::disposeWeb);
for (int i = 0; i < 4; ++i) monitor->observeSample(low);
QCOMPARE(monitor->level(), MemoryPressureLevel::Stop);
QCOMPARE(reader_->state(), "Recovering");
QVERIFY(session->resourcesStopped); QVERIFY(!worker->isConnected()); QVERIFY(worker->idle());
QVERIFY(!disposed.isEmpty()); QCOMPARE(disposed.last()[0].toString(), oldToken);
QTRY_VERIFY(!oldView); QVERIFY(!activeWeb());
QCOMPARE(reader_->beginRendererProbe(oldToken, 0), 0u);
const auto stoppedOutline = reader_->outline(); const auto stoppedMeta = session->meta;
const QVariantMap lateHeading{{"title", "Late stale heading"}, {"index", 0}, {"href", "OPS/two.xhtml#two"},
{"source", "html-heading"}, {"precision", "exact"}, {"level", 1}, {"depth", 0}};
reader_->webIndex(oldToken, {{"title", "Late stale index"}, {"headings", QVariantList{lateHeading}},
{"outline", QVariantList{lateHeading}}});
QCOMPARE(reader_->outline(), stoppedOutline); QCOMPARE(session->meta, stoppedMeta);
for (int i = 0; i < 5; ++i) monitor->observeSample(healthy);
QTest::qWait(100);
QCOMPARE(reader_->state(), "Recovering"); QVERIFY(!activeWeb());
reader_->openPath(html_);
QTRY_COMPARE_WITH_TIMEOUT(reader_->state(), "Ready", 15000);
QTRY_VERIFY_WITH_TIMEOUT(activeWeb() && activeWeb()->property("documentReady").toBool(), 15000);
QVERIFY(reader_->webToken() != oldToken);
QTRY_VERIFY(!session); QTRY_VERIFY(!oldProfile); QTRY_VERIFY(!worker);
QVariant text; QVERIFY(evaluateWeb("document.body.innerText", &text));
QVERIFY(text.toString().contains("Readable document"));
const auto htmlToken = reader_->webToken();
QPointer<QObject> htmlView = activeWeb();
QSignalSpy staged(reader_.get(), &Controller::stageWeb);
for (int i = 0; i < 4; ++i) monitor->observeSample(low);
QCOMPARE(monitor->level(), MemoryPressureLevel::Stop);
QCOMPARE(reader_->state(), "Recovering");
QTRY_VERIFY(!htmlView);
// A folder dialog may return after the memory-pressure stop.
reader_->setHtmlRoot(QUrl::fromLocalFile(QFileInfo(html_).absolutePath()));
QCOMPARE(reader_->state(), "Recovering");
QCOMPARE(reader_->webToken(), htmlToken);
QVERIFY(reader_->notice().startsWith("E_RESOURCE_LIMIT:"));
QVERIFY(staged.isEmpty()); QVERIFY(!activeWeb());
QCOMPARE(fileHash(epub_), epubHash); QCOMPARE(fileHash(html_), htmlHash);
}
void xhtmlEpubHeadingNavigationStaysWithinChapter()
{
const auto path = QStringLiteral(DOCVIEW_SOURCE_DIR "/tests/fixtures/web/reflow-rtl.epub");
const auto original = fileHash(path);
QVERIFY(!original.isEmpty());
reader_->openPath(path);
QTRY_COMPARE_WITH_TIMEOUT(reader_->state(), "Ready", 15000);
QTRY_VERIFY_WITH_TIMEOUT(activeWeb() && activeWeb()->property("documentReady").toBool(), 10000);
QCOMPARE(reader_->format(), "epub");
const auto token = reader_->webToken();
QCOMPARE(activeWeb()->property("url").toUrl().path(), "/OPS/first.xhtml");
QVERIFY(!reader_->outline().isEmpty());
QVERIFY(reader_->outline().first().toMap().value("href").toString().startsWith("OPS/second.xhtml"));
QVariant result;
QVERIFY(evaluateWeb("({mime:document.contentType,tag:document.querySelector('h1').tagName,headings:__docviewReader.index().headings})", &result));
const auto index = result.toMap();
QCOMPARE(index.value("mime").toString(), "application/xhtml+xml");
QCOMPARE(index.value("tag").toString(), "h1");
const auto headings = index.value("headings").toList();
QCOMPARE(headings.size(), 2);
QCOMPARE(headings[0].toMap().value("level").toInt(), 1);
QCOMPARE(headings[1].toMap().value("level").toInt(), 2);
QVERIFY(headings[1].toMap().value("href").toString().isEmpty()); // The h2 has no author ID.
QVariant position;
auto atHeading = [this, &position](const QString &tag) {
if (!activeWeb() || !evaluateWeb("({top:document.querySelector('" + tag + "').getBoundingClientRect().top,scroll:scrollY,height:innerHeight,total:document.documentElement.scrollHeight,path:location.pathname})", &position))
return false;
const auto values = position.toMap();
const double top = values.value("top").toDouble(), height = values.value("height").toDouble();
// This short chapter may clamp its final heading above the bottom
// scroll limit, so require the closest attainable alignment.
const double expectedTop = qMax(0.0, values.value("scroll").toDouble() + top
- qMax(0.0, values.value("total").toDouble() - height));
return values.value("path").toString() == "/OPS/first.xhtml"
&& top < height && qAbs(top - expectedTop) < 3;
};
reader_->activateItem({{"index", 0}});
QTRY_VERIFY_WITH_TIMEOUT(atHeading("h1"), 5000);
for (int i = 0; i < 2; ++i) {
reader_->execute("nav.heading_next");
QTRY_VERIFY2_WITH_TIMEOUT(atHeading("h2"), QJsonDocument::fromVariant(position).toJson(QJsonDocument::Compact).constData(), 5000);
QCOMPARE(reader_->webToken(), token);
reader_->execute("nav.heading_previous");
QTRY_VERIFY_WITH_TIMEOUT(atHeading("h1"), 5000);
QCOMPARE(reader_->webToken(), token);
}
QCOMPARE(fileHash(path), original);
}
void clampedEpubHeadingsReachChapterBoundary()
{
const auto path = QStringLiteral(DOCVIEW_SOURCE_DIR "/tests/fixtures/performance/standard-500-clamped.epub");
const auto original = fileHash(path);
QCOMPARE(original.toHex(), QByteArray("d4136e2c7e5e67bc10e640589c34fc629e80e66edd2ab2bd25dda238c7a2314d"));
reader_->openPath(path);
QTRY_COMPARE_WITH_TIMEOUT(reader_->state(), "Ready", 15000);
QTRY_VERIFY_WITH_TIMEOUT(activeWeb() && activeWeb()->property("documentReady").toBool(), 10000);
const auto token = reader_->webToken();
QVariant position;
auto sample = [this, &position] {
return activeWeb() && activeWeb()->property("documentReady").toBool()
&& evaluateWeb("({path:location.pathname,x:scrollX,y:scrollY,progression:__docviewReader.location().progression,first:document.querySelector('h1').getBoundingClientRect().top})", &position);
};
reader_->activateItem({{"index", 0}});
QTRY_VERIFY_WITH_TIMEOUT(sample() && qAbs(position.toMap().value("first").toDouble()) < 3, 5000);
QCOMPARE(position.toMap().value("path").toString(), "/chapter-0.xhtml");
int moves = 0;
while (position.toMap().value("path").toString() == "/chapter-0.xhtml" && moves < 13) {
const auto before = position.toMap();
reader_->execute("nav.heading_next");
QTRY_VERIFY2_WITH_TIMEOUT(sample() && (position.toMap().value("path") != before.value("path")
|| position.toMap().value("x") != before.value("x") || position.toMap().value("y") != before.value("y")),
QJsonDocument::fromVariant(position).toJson(QJsonDocument::Compact).constData(), 5000);
++moves;
}
QCOMPARE(position.toMap().value("path").toString(), "/chapter-1.xhtml");
QCOMPARE(reader_->webToken(), token);
QTRY_VERIFY_WITH_TIMEOUT(sample() && qAbs(position.toMap().value("first").toDouble()) < 3, 5000);
reader_->execute("nav.heading_previous");
QTRY_VERIFY_WITH_TIMEOUT(sample() && position.toMap().value("path").toString() == "/chapter-0.xhtml"
&& position.toMap().value("progression").toDouble() > .999, 5000);
reader_->execute("nav.heading_previous");
QTRY_VERIFY_WITH_TIMEOUT(sample() && position.toMap().value("path").toString() == "/chapter-0.xhtml"
&& position.toMap().value("progression").toDouble() < .999, 5000);
QCOMPARE(reader_->webToken(), token);
QCOMPARE(fileHash(path), original);
}
void replacingOpenDocumentDoesNotTransferSavedPosition_data()
{
QTest::addColumn<QString>("kind");
for (const auto &kind : {"pdf", "html", "zip", "epub"}) QTest::newRow(kind) << QString(kind);
}
void replacingOpenDocumentDoesNotTransferSavedPosition()
{
QFETCH(QString, kind);
reader_->clearHistory();
QVERIFY(writeBytes(configuration_, "[privacy]\nremember_position=true\nrecent_documents=20\n"));
QVERIFY(reader_->command("reload"));
const QString original = kind == "pdf" ? manyPages_ : kind == "html" ? html_ : kind == "zip" ? zip_ : epub_;
const QString path = documents_.filePath("changed-during-reading." + kind);
QVERIFY(QFile::copy(original, path));
reader_->openPath(path);
QTRY_COMPARE_WITH_TIMEOUT(reader_->state(), "Ready", 15000);
auto *session = activeSession(); QVERIFY(session);
auto *store = reader_->findChild<StateStore *>(); QVERIFY(store);
if (kind == "pdf") {
reader_->execute("nav.page", {{"page", 42}});
QTRY_VERIFY_WITH_TIMEOUT(canvas_->viewportReady(), 15000);
}
QTRY_VERIFY_WITH_TIMEOUT(!session->lastPresented.isEmpty(), 10000);
QVERIFY(store->flush());
const auto prior = store->restore(path); QVERIFY(!prior.isEmpty());
const auto identity = StateStore::fileIdentity(path);
QVERIFY(QFile::rename(path, path + ".original"));
QVERIFY(QFile::copy(original, path));
QFile replacement(path); QVERIFY(replacement.open(QIODevice::ReadWrite));
QVERIFY(replacement.setFileTime(QDateTime::fromMSecsSinceEpoch(identity.value("mtime").toLongLong()), QFileDevice::FileModificationTime));
replacement.close();
QVERIFY(StateStore::fileIdentity(path).value("fileIdentity") != identity.value("fileIdentity"));
// Closing is an unconditional save point while the renderer still owns A.
reader_->closeDocument();
QVERIFY(store->flush());
QVERIFY2(store->restore(path).isEmpty(), "The old document's location was rebound to the replacement's identity");
const auto disk = QJsonDocument::fromJson([&] { QFile file(store->filePath()); if (!file.open(QIODevice::ReadOnly)) return QByteArray{}; return file.readAll(); }()).object();
QCOMPARE(disk.value("documents").toArray().first().toObject().value("identity").toObject().toVariantMap(), identity);
QStringList notices;
const auto connection = connect(reader_.get(), &Controller::noticeChanged, reader_.get(), [&] { notices << reader_->notice(); });
reader_->openPath(path);
QTRY_COMPARE_WITH_TIMEOUT(reader_->state(), "Ready", 15000);
QTRY_VERIFY_WITH_TIMEOUT(notices.join('\n').contains("E_LOCATOR_UNRESOLVED"), 5000);
if (kind == "pdf") QCOMPARE(canvas_->currentPage(), 0);
disconnect(connection);
QCOMPARE(fileHash(path), fileHash(original));
QCOMPARE(fileHash(path + ".original"), fileHash(original));
}
void htmlOutlineLoadingIsNotAbsence()
{
bool observed = false, loading = false, toggled = false, placeholderVisible = false;
QString notice;
const auto connection = connect(reader_.get(), &Controller::stateChanged, reader_.get(), [&] {
if (reader_->state() != "Ready" || reader_->format() != "html" || observed) return;
observed = true;
loading = activeSession()->meta.value("capabilities").toMap().value("outline").toString() == "loading";
reader_->execute("panel.toc.toggle");
toggled = window_->property("tocVisible").toBool();
notice = reader_->notice();
const auto *placeholder = window_->findChild<QQuickItem *>("outlineLoading");
placeholderVisible = placeholder && placeholder->isVisible()
&& placeholder->property("text").toString().contains(QStringLiteral("読み込"));
});
reader_->openPath(html_);
QTRY_VERIFY_WITH_TIMEOUT(observed, 15000);
QVERIFY(loading);
QVERIFY2(toggled, "An outline request during DOM indexing must retain the panel request");
QVERIFY(placeholderVisible);
QVERIFY(!notice.contains(QStringLiteral("目次がありません")));
QTRY_VERIFY_WITH_TIMEOUT(!reader_->outline().isEmpty(), 10000);
QVERIFY(window_->property("tocVisible").toBool());
QVERIFY(!window_->findChild<QQuickItem *>("outlineLoading")->isVisible());
disconnect(connection);
}
void pdfHeadingFallbackUsesOnlyInternalOutline_data()
{
QTest::addColumn<bool>("mixed");
QTest::newRow("external-and-blocked-only") << false;
QTest::newRow("mixed-internal-external-blocked") << true;
}
void pdfHeadingFallbackUsesOnlyInternalOutline()
{
QFETCH(bool, mixed);
const auto path = documents_.filePath(mixed ? "mixed-outline-actions.pdf" : "external-outline-actions.pdf");
QVERIFY(writeBytes(path, pagedPdf(4, mixed ? 4 : 2, 1, 2)));
const auto original = fileHash(path);
QSignalSpy external(reader_.get(), &Controller::externalLinkRequested);
reader_->openPath(path);
QTRY_COMPARE_WITH_TIMEOUT(reader_->state(), "Ready", 15000);
auto *session = activeSession(); QVERIFY(session);
QTRY_VERIFY_WITH_TIMEOUT(session->meta.value("capabilities").toMap().value("headings").toString() != "loading", 15000);
QCOMPARE(session->meta.value("capabilities").toMap().value("headings").toString(), mixed ? "supported" : "unavailable");
QCOMPARE(reader_->outline().size(), mixed ? 4 : 2); // Preserve all original TOC rows.
QCOMPARE(reader_->benchmarkSnapshot().value("headingCount").toInt(), mixed ? 2 : 0);
reader_->execute("nav.heading_next");
if (mixed) {
QTRY_COMPARE_WITH_TIMEOUT(canvas_->currentPage(), 2, 10000);
QTRY_VERIFY_WITH_TIMEOUT(canvas_->viewportReady(), 15000);
reader_->execute("nav.heading_next");
QTRY_COMPARE_WITH_TIMEOUT(canvas_->currentPage(), 3, 10000);
QTRY_VERIFY_WITH_TIMEOUT(canvas_->viewportReady(), 15000);
reader_->execute("nav.heading_previous");
QTRY_COMPARE_WITH_TIMEOUT(canvas_->currentPage(), 2, 10000);
} else {
QCOMPARE(canvas_->currentPage(), 0);
QVERIFY(reader_->notice().contains(QStringLiteral("対象の情報がありません")));
}
QVERIFY(external.isEmpty());
QCOMPARE(fileHash(path), original);
}
void epubFailedHeadingBoundaryDoesNotLeakIntoChapterNavigation()
{
auto files = epubFixture();
for (auto &file : files) if (file.first == "OPS/one.xhtml" || file.first == "OPS/two.xhtml")
file.second = "<html xmlns='http://www.w3.org/1999/xhtml'><head><title>Boundary</title></head>"
"<body style='margin:0'><h1>First heading</h1><div style='height:1400px'></div>"
"<h2>Last heading</h2><div style='height:1000px'></div></body></html>";
const auto path = documents_.filePath("failed-heading-boundary.epub");
QVERIFY(makeZip(path, files));
const auto original = fileHash(path);
reader_->openPath(path);
QTRY_COMPARE_WITH_TIMEOUT(reader_->state(), "Ready", 15000);
QTRY_VERIFY_WITH_TIMEOUT(activeWeb() && activeWeb()->property("documentReady").toBool(), 10000);
QVariant location;
auto position = [&] { return evaluateWeb("({path:location.pathname,y:scrollY})", &location); };
QTRY_VERIFY(position() && location.toMap().value("path").toString() == "/OPS/one.xhtml");
reader_->execute("nav.heading_previous");
QTRY_VERIFY_WITH_TIMEOUT(reader_->notice().contains(QStringLiteral("章の端")), 5000);
reader_->execute("nav.chapter_next");
QTRY_VERIFY_WITH_TIMEOUT(activeWeb() && activeWeb()->property("documentReady").toBool()
&& position() && location.toMap().value("path").toString() == "/OPS/two.xhtml", 10000);
QTest::qWait(200); QVERIFY(position());
QVERIFY2(location.toMap().value("y").toDouble() < 1, "A failed previous-heading request must not select the next chapter's last heading");
// A successful heading boundary still deliberately resolves the previous chapter's last heading.
reader_->execute("nav.heading_previous");
QTRY_VERIFY_WITH_TIMEOUT(activeWeb() && activeWeb()->property("documentReady").toBool()
&& position() && location.toMap().value("path").toString() == "/OPS/one.xhtml"
&& location.toMap().value("y").toDouble() > 1000, 10000);
QCOMPARE(fileHash(path), original);
}
void htmlEscapeClearsSelectedLink()
{
reader_->openPath(html_);
QTRY_COMPARE_WITH_TIMEOUT(reader_->state(), "Ready", 15000);
QTRY_VERIFY_WITH_TIMEOUT(activeWeb() && activeWeb()->property("documentReady").toBool(), 10000);
QVariant result;
key(Qt::Key_Tab);
QTRY_VERIFY_WITH_TIMEOUT(evaluateWeb("document.activeElement.id", &result) && result.toString() == "local", 5000);
key(Qt::Key_Escape);
QTRY_VERIFY_WITH_TIMEOUT(evaluateWeb("({active:document.activeElement.id,outline:document.getElementById('local').style.outline})", &result)
&& result.toMap().value("active").toString() != "local"
&& result.toMap().value("outline").toString().isEmpty(), 5000);
QSignalSpy applied(reader_.get(), &Controller::webActionApplied);
const auto url = activeWeb()->property("url").toUrl();
key(Qt::Key_Return);
QTRY_VERIFY_WITH_TIMEOUT(!applied.isEmpty(), 5000);
QCOMPARE(activeWeb()->property("url").toUrl(), url);
}
void temporaryUiRestoresPanelFocus_data()
{
QTest::addColumn<QString>("panel"); QTest::addColumn<QString>("temporary");
for (const auto &panel : {QString("toc"), QString("pages")})
for (const auto &temporary : {QString("command"), QString("help"), QString("info"), QString("file")})
QTest::newRow(qPrintable(panel + "-" + temporary)) << panel << temporary;
}
void temporaryUiRestoresPanelFocus()
{
QFETCH(QString, panel); QFETCH(QString, temporary);
const auto path = documents_.filePath("focus-outline.pdf");
QVERIFY(writeBytes(path, pagedPdf(8, 4)));
reader_->openPath(path);
QTRY_COMPARE_WITH_TIMEOUT(reader_->state(), "Ready", 15000);
reader_->execute("panel." + panel + ".toggle");
reader_->execute("focus.next");
QCOMPARE(reader_->context(), panel);
if (temporary == "command") key(Qt::Key_Colon);
else if (temporary == "file") reader_->execute("file.open");
else reader_->execute(temporary == "help" ? "help.toggle" : "document.info");
QTRY_VERIFY(reader_->mode() != "normal");
QTest::qWait(100);
if (temporary == "file") {
QObject *dialog = nullptr;
for (auto *object : window_->findChildren<QObject *>())
if (object->property("title").toString() == QStringLiteral("文書を開く") && object->metaObject()->indexOfMethod("reject()") >= 0) { dialog = object; break; }
QVERIFY(dialog); QVERIFY(QMetaObject::invokeMethod(dialog, "reject"));
} else key(Qt::Key_Escape);
QTRY_COMPARE(reader_->mode(), "normal");
QCOMPARE(reader_->context(), panel);
auto *focus = qobject_cast<QQuickItem *>(QGuiApplication::focusObject());
QVERIFY(focus && focus->hasActiveFocus());
// A bare Xvfb server has no window manager to reactivate the parent
// after a separate platform file-dialog window closes.
if (temporary == "file") window_->requestActivate();
QTRY_COMPARE_WITH_TIMEOUT(QGuiApplication::focusWindow(), window_, 5000);
// An explicit successful command takes precedence over the saved origin.
key(Qt::Key_Colon);
QVERIFY(QGuiApplication::focusObject()->setProperty("text", "cancel"));
key(Qt::Key_Return);
QTRY_COMPARE(reader_->mode(), "normal");
QCOMPARE(reader_->context(), "content");
QVERIFY(canvas_->hasActiveFocus());
reader_->execute("focus.next");
QCOMPARE(reader_->context(), panel);
// A normal command may deliberately hide its origin. Fall back to content.
key(Qt::Key_Colon);
QVERIFY(QGuiApplication::focusObject()->setProperty("text", panel == "toc" ? "toctoggle" : "pagestoggle"));
key(Qt::Key_Return);
QCOMPARE(reader_->mode(), "normal");
QCOMPARE(reader_->context(), "content");
QVERIFY(canvas_->hasActiveFocus());
}
void webZoomPersistsAcrossReopen_data()
{
QTest::addColumn<QString>("kind");
for (const auto &kind : {QString("html"), QString("zip"), QString("epub"), QString("fixed")})
QTest::newRow(qPrintable(kind)) << kind;
}
void webZoomPersistsAcrossReopen()
{
QFETCH(QString, kind);
reader_->clearHistory();
QVERIFY(writeBytes(configuration_, "[privacy]\nremember_position=true\nrecent_documents=0\n"));
QVERIFY(reader_->command("reload"));
const auto path = kind == "html" ? html_ : kind == "zip" ? zip_ : kind == "epub" ? epub_
: QString(DOCVIEW_SOURCE_DIR "/tests/fixtures/web/fixed-mixed.epub");
const auto original = fileHash(path);
reader_->openPath(path);
QTRY_COMPARE_WITH_TIMEOUT(reader_->state(), "Ready", 15000);
QTRY_VERIFY_WITH_TIMEOUT(activeWeb() && activeWeb()->property("documentReady").toBool(), 10000);
QTest::qWait(250);
reader_->execute("zoom.in"); reader_->execute("zoom.in");
QTest::qWait(700);
auto *store = reader_->findChild<StateStore *>(); QVERIFY(store);
QVERIFY(store->flush());
const auto saved = store->restore(path);
QVERIFY2(std::abs(saved.value("zoom").toDouble() - 132.25) < .1,
qPrintable("Persisted zoom: " + saved.value("zoom").toString()));
QVERIFY(reader_->position().contains("132%"));
cleanup(); startUi();
reader_->openPath(path);
QTRY_COMPARE_WITH_TIMEOUT(reader_->state(), "Ready", 15000);
QTRY_VERIFY_WITH_TIMEOUT(activeWeb() && activeWeb()->property("documentReady").toBool(), 10000);
QTRY_VERIFY_WITH_TIMEOUT(reader_->position().contains("132%"), 10000);
const auto actualZoom = kind == "fixed" ? activeWeb()->parent()->property("spreadZoom").toDouble()
: activeWeb()->property("zoomFactor").toDouble();
QVERIFY(std::abs(actualZoom - 1.3225) < .01);
if (kind == "fixed") {
QVERIFY(captureRecentScreen("fixed-epub-restored-zoom"));
reader_->execute("zoom.fit_width");
QTRY_VERIFY(reader_->position().contains(QStringLiteral("100%(幅合わせ)")));
QTRY_VERIFY(activeSession()->lastPresented.value("fitWidth").toBool());
QVERIFY(reader_->findChild<StateStore *>()->flush());
cleanup(); startUi();
reader_->openPath(path);
QTRY_COMPARE_WITH_TIMEOUT(reader_->state(), "Ready", 15000);
QTRY_VERIFY_WITH_TIMEOUT(reader_->position().contains(QStringLiteral("100%(幅合わせ)")), 10000);
QVERIFY(activeWeb()->parent()->property("spreadFitWidth").toBool());
}
QCOMPARE(fileHash(path), original);
}
void webZoomLocationSchemaRejectsInvalidDisplayState()
{
reader_->openPath(html_);
QTRY_COMPARE_WITH_TIMEOUT(reader_->state(), "Ready", 15000);
QTRY_VERIFY_WITH_TIMEOUT(activeWeb() && activeWeb()->property("documentReady").toBool(), 10000);
QVariant raw;
QVERIFY(evaluateWeb("globalThis.__docviewReader.command('location',{})", &raw));
const auto token = reader_->webToken();
const QList<QVariantMap> invalid{{{"zoom", 24.99}}, {{"zoom", 500.01}}, {{"zoom", "125"}},
{{"zoom", true}}, {{"zoom", std::numeric_limits<double>::quiet_NaN()}},
{{"fitWidth", "true"}}, {{"panX", -0.1}}, {{"panY", 1.1}}};
for (const auto &fields : invalid) {
auto location = raw.toMap();
for (auto it = fields.begin(); it != fields.end(); ++it) location[it.key()] = it.value();
const auto before = reader_->benchmarkSnapshot().value("location");
reader_->webLocation(token, location);
QVERIFY(reader_->notice().startsWith("E_RESOURCE_BLOCKED"));
QCOMPARE(reader_->benchmarkSnapshot().value("location"), before);
}
for (const auto zoom : {25., 500.}) {
auto location = raw.toMap(); location["zoom"] = zoom;
reader_->webLocation(token, location);
QCOMPARE(reader_->benchmarkSnapshot().value("location").toMap().value("zoom").toDouble(), zoom);
}
}
void failedOpenIdentifiesAttemptWithoutChangingActiveWarnings()
{
reader_->openPath(manyPages_);
QTRY_COMPARE_WITH_TIMEOUT(reader_->state(), "Ready", 15000);
const auto token = reader_->webToken(), title = reader_->title();
const auto warnings = activeSession()->meta.value("warnings");
const auto failed = documents_.filePath("broken-target-B.pdf");
QVERIFY(writeBytes(failed, "%PDF-1.7\ninvalid"));
reader_->openPath(failed);
QTRY_COMPARE_WITH_TIMEOUT(reader_->state(), "Ready", 15000);
QCOMPARE(reader_->webToken(), token); QCOMPARE(reader_->title(), title);
QCOMPARE(activeSession()->meta.value("warnings"), warnings);
QVERIFY(reader_->notice().contains("broken-target-B.pdf"));
QVERIFY(reader_->notice().contains(QStringLiteral("確認")) || reader_->notice().contains(QStringLiteral("開き直")));
reader_->notify({});
QSignalSpy info(reader_.get(), &Controller::uiCommand);
reader_->execute("document.info");
QVERIFY(!info.isEmpty());
const auto text = info.last()[1].toMap().value("text").toString();
QVERIFY(text.contains("broken-target-B.pdf"));
QVERIFY(text.contains("E_PDF_CORRUPT"));
QVERIFY(text.contains(QStringLiteral("最後に開こうとした文書")));
QVERIFY(captureRecentScreen("open-failure-with-active-document"));
QCOMPARE(activeSession()->meta.value("warnings"), warnings);
key(Qt::Key_Escape);
const auto missing = documents_.filePath("missing-target-C.html");
reader_->openPath(missing);
QCOMPARE(reader_->webToken(), token);
QVERIFY(reader_->notice().startsWith("E_OPEN_FAILED"));
QVERIFY(reader_->notice().contains("missing-target-C.html"));
QVERIFY(reader_->notice().contains("Ctrl+O"));
reader_->openPath(html_);
QTRY_COMPARE_WITH_TIMEOUT(reader_->state(), "Ready", 15000);
info.clear(); reader_->execute("document.info");
QVERIFY(!info.last()[1].toMap().value("text").toString().contains("missing-target-C.html"));
}
};
// A separate GUI process reads the persisted choice using the production
// Controller, workers and Main.qml. No in-memory cache survives this boundary.
static int probePersistedZipEntry(const QString &root)
{
const QDir directory(root);
const StoragePaths paths{directory.filePath("config.toml"), directory.filePath("state/state.json"),
directory.filePath("cache"), directory.filePath("logs")};
Controller reader;
reader.initialize(paths.configFile, false, paths);
QQmlApplicationEngine engine;
engine.rootContext()->setContextProperty("reader", &reader);
engine.load(QUrl("qrc:/qml/Main.qml"));
if (engine.rootObjects().isEmpty()) return 21;
auto *window = qobject_cast<QQuickWindow *>(engine.rootObjects().first());
if (!window) return 22;
reader.attachWindow(window);
int choices = 0;
QObject::connect(&reader, &Controller::entryNeeded, &reader, [&] { ++choices; });
reader.openPath(directory.filePath("remember-entry.zip"));
QElapsedTimer elapsed;
elapsed.start();
while (elapsed.elapsed() < 15000) {
QCoreApplication::processEvents();
if (choices) return 23;
if (reader.state() == "Ready") {
for (auto *object : window->findChildren<QObject *>()) {
if (object->property("documentToken").toString() == reader.webToken()
&& object->property("isCurrent").toBool() && object->property("documentReady").toBool())
return object->property("url").toUrl().path().endsWith("b/second.html") ? 0 : 24;
}
}
QTest::qWait(5);
}
qWarning() << "ZIP entry restart probe failed:" << reader.state() << reader.notice();
return 25;
}
int main(int argc, char **argv)
{
QTemporaryDir isolatedState;
if (!isolatedState.isValid())
return 2;
qputenv("XDG_CONFIG_HOME", isolatedState.filePath("config").toUtf8());
qputenv("XDG_STATE_HOME", isolatedState.filePath("state").toUtf8());
qputenv("XDG_CACHE_HOME", isolatedState.filePath("cache").toUtf8());
registerDocumentScheme();
QtWebEngineQuick::initialize();
QGuiApplication application(argc, argv);
application.setQuitOnLastWindowClosed(false);
application.setApplicationName("DocView integration tests");
application.setOrganizationName("DocView");
qmlRegisterType<PdfCanvas>("DocView", 1, 0, "PdfCanvas");
if (argc == 3 && QByteArray(argv[1]) == "--zip-entry-probe")
return probePersistedZipEntry(QString::fromLocal8Bit(argv[2]));
AppTest test;
return QTest::qExec(&test, argc, argv);
}
#include "test_app.moc"