Files
docview/tests/test_pdf.cpp
T
2026-09-21 13:41:40 +09:00

1070 lines
75 KiB
C++

#include <QtTest>
#include <QEventLoop>
#include <QPointer>
#include <QTimer>
#include <QCborArray>
#include <QTemporaryDir>
#include <QJsonArray>
#include <QJsonDocument>
#include <QJsonObject>
#include <QCryptographicHash>
#include <algorithm>
#include <QtEndian>
#include <QScopeGuard>
#include <fpdf_annot.h>
#include <fpdf_save.h>
#include <qpdf/QPDFObjectHandle.hh>
#include "pdf/pdf_document.h"
#include "pdf/link_border_reader.h"
#include "pdf/pdf_metadata_context.h"
#include "common/worker_process.h"
#include "broker/font_broker.h"
using namespace docview;
#ifndef Q_MOC_RUN
namespace {
QByteArray pdfString(const QString &text) {
QByteArray bytes(text.size() * 2, '\0');
for (qsizetype i = 0; i < text.size(); ++i) qToBigEndian<quint16>(text[i].unicode(), bytes.data() + i * 2);
return "<feff" + bytes.toHex() + ">";
}
QString largeMetadataFixture(QTemporaryDir &directory, int pageCount, int outlineCount,
const QString &titlePrefix = {}, const QString &label = {}) {
QList<QByteArray> objects{QByteArray{}, QByteArray{}};
QByteArray kids;
for (int page = 0; page < pageCount; ++page) {
const int id = objects.size() + 1;
kids += QByteArray::number(id) + " 0 R ";
objects << "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << >> >>";
}
const int root = outlineCount ? objects.size() + 1 : 0;
if (outlineCount) {
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;
const auto title = titlePrefix.isEmpty() ? QString("Heading %1").arg(index) : titlePrefix + QString::number(index);
QByteArray item = "<< /Title " + pdfString(title) + " /Parent " + QByteArray::number(root)
+ " 0 R /Dest [3 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 + " >>";
}
}
objects[0] = "<< /Type /Catalog /Pages 2 0 R";
if (root) objects[0] += " /Outlines " + QByteArray::number(root) + " 0 R";
if (!label.isEmpty()) objects[0] += " /PageLabels << /Nums [0 << /P " + pdfString(label) + " >>] >>";
objects[0] += " >>";
objects[1] = "<< /Type /Pages /Count " + QByteArray::number(pageCount) + " /Kids [" + kids + "] >>";
QByteArray result = "%PDF-1.7\n";
QList<qsizetype> offsets{0};
for (qsizetype i = 0; i < objects.size(); ++i) {
offsets << result.size();
result += QByteArray::number(i + 1) + " 0 obj\n" + objects[i] + "\nendobj\n";
}
const auto xref = result.size();
result += "xref\n0 " + QByteArray::number(offsets.size()) + "\n0000000000 65535 f \n";
for (qsizetype i = 1; i < offsets.size(); ++i) result += QByteArray::number(offsets[i]).rightJustified(10, '0') + " 00000 n \n";
result += "trailer\n<< /Size " + QByteArray::number(offsets.size()) + " /Root 1 0 R >>\nstartxref\n"
+ QByteArray::number(xref) + "\n%%EOF\n";
const auto path = directory.filePath("metadata.pdf");
QFile file(path);
if (!file.open(QIODevice::WriteOnly) || file.write(result) != result.size()) return {};
return path;
}
QString annotationMetadataFixture(QTemporaryDir &directory, int count, const QString &contents, bool links = false) {
QList<QByteArray> objects{
"<< /Type /Catalog /Pages 2 0 R >>",
"<< /Type /Pages /Count 1 /Kids [3 0 R] >>", {}};
QByteArray refs;
for (int i = 0; i < count; ++i) {
refs += QByteArray::number(objects.size() + 1) + " 0 R ";
objects << "<< /Type /Annot /Subtype /" + QByteArray(links ? "Link" : "Square")
+ " /Rect [10 10 30 30] "
+ (links ? QByteArray("/Dest [3 0 R /Fit] ") : "/Contents " + pdfString(contents)) + " >>";
}
objects[2] = "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Resources << >> /Annots [" + refs + "] >>";
QByteArray bytes = "%PDF-1.7\n";
QList<qsizetype> offsets{0};
for (qsizetype i = 0; i < objects.size(); ++i) {
offsets << bytes.size();
bytes += QByteArray::number(i + 1) + " 0 obj\n" + objects[i] + "\nendobj\n";
}
const auto xref = bytes.size();
bytes += "xref\n0 " + QByteArray::number(offsets.size()) + "\n0000000000 65535 f \n";
for (qsizetype i = 1; i < offsets.size(); ++i)
bytes += QByteArray::number(offsets[i]).rightJustified(10, '0') + " 00000 n \n";
bytes += "trailer\n<< /Size " + QByteArray::number(offsets.size()) + " /Root 1 0 R >>\nstartxref\n"
+ QByteArray::number(xref) + "\n%%EOF\n";
const auto path = directory.filePath("annotation-metadata.pdf");
QFile file(path);
if (!file.open(QIODevice::WriteOnly) || file.write(bytes) != bytes.size()) return {};
return path;
}
}
#endif
class PdfTest : public QObject {
Q_OBJECT
QString fixture() { return QFINDTESTDATA("fixtures/pdf/navigation.pdf"); }
static QCborMap request(int rotation = 0, int x = 0, int y = 0, int w = 200, int h = 200) {
return {{QStringLiteral("page"), 0}, {QStringLiteral("scale"), 1.0}, {QStringLiteral("rotation"), rotation},
{QStringLiteral("x"), x}, {QStringLiteral("y"), y}, {QStringLiteral("width"), w}, {QStringLiteral("height"), h}, {QStringLiteral("renderRevision"), 7}};
}
private slots:
void generatedAppearancesDoNotAccumulateAcrossTiles() {
const auto path = QFINDTESTDATA("fixtures/pdf/link-borders/styles.pdf");
QFile source(path); QVERIFY(source.open(QIODevice::ReadOnly));
const QByteArray bytes = source.readAll();
FPDF_InitLibrary(); const auto libraryGuard = qScopeGuard([] { FPDF_DestroyLibrary(); });
auto document = FPDF_LoadMemDocument64(bytes.constData(), size_t(bytes.size()), nullptr); QVERIFY(document);
const auto documentGuard = qScopeGuard([&] { FPDF_CloseDocument(document); });
auto context = std::make_shared<PdfMetadataContext>(); QString code, error;
QVERIFY2(context->open(&source, {}, 4, &code, &error), qPrintable(error));
LinkBorderReader reader(context);
const auto prepare = [&](int index) {
auto page = FPDF_LoadPage(document, index);
if (!page) return false;
{ auto scope = reader.prepare(index, page, &code, &error); if (!scope) { FPDF_ClosePage(page); return false; } }
FPDF_ClosePage(page); return true;
};
// This test-only serializer detects retained orphan indirect AP streams
// in the PDFium document. It never writes or replaces the source file.
struct Writer : FPDF_FILEWRITE { QByteArray bytes; Writer() { version = 1; WriteBlock = [](FPDF_FILEWRITE *self, const void *data, unsigned long size) {
static_cast<Writer *>(self)->bytes.append(static_cast<const char *>(data), qsizetype(size)); return 1; }; } };
QVERIFY2(prepare(0), qPrintable(error));
Writer first; QVERIFY(FPDF_SaveAsCopy(document, &first, FPDF_NO_INCREMENTAL));
for (int index = 0; index < 200; ++index) QVERIFY2(prepare(index % 4), qPrintable(error));
Writer repeated; QVERIFY(FPDF_SaveAsCopy(document, &repeated, FPDF_NO_INCREMENTAL));
QCOMPARE(repeated.bytes.size(), first.bytes.size());
QCOMPARE(repeated.bytes.count("/Subtype/Form"), first.bytes.count("/Subtype/Form"));
QVERIFY(source.seek(0)); QCOMPARE(source.readAll(), bytes);
}
void linkBorderStylesAreRenderedAndSourceIsUnchanged() {
const auto path = QFINDTESTDATA("fixtures/pdf/link-borders/styles.pdf");
QFile source(path); QVERIFY(source.open(QIODevice::ReadOnly));
const auto before = QCryptographicHash::hash(source.readAll(), QCryptographicHash::Sha256);
PdfDocument doc; QString code, error; QVERIFY2(doc.openFile(&source, {}, &code, &error), qPrintable(error));
const auto originalLinks = doc.links(0).value("links").toArray(); QCOMPARE(originalLinks.size(), 18); // Hidden/NoView are not interactive.
for (int page = 0; page < 4; ++page) {
const auto metadata = doc.pages(page, 1).value("pages").toArray().first().toMap();
for (int rotation : {0, 90, 180, 270}) {
int width = int(metadata.value("width").toDouble()), height = int(metadata.value("height").toDouble());
if (rotation % 180) std::swap(width, height);
auto render = request(rotation, 0, 0, width, height); render.insert(QStringLiteral("page"), page);
const auto image = doc.render(render, &code, &error).value("data").toByteArray();
QVERIFY2(code.isEmpty(), qPrintable(code + ": " + error)); QCOMPARE(image.size(), width * height * 4);
const auto pixel = [&](double x, double y, int component) {
auto point = request(rotation); point.insert(QStringLiteral("page"), page);
point.insert(QStringLiteral("x"), x); point.insert(QStringLiteral("y"), y);
const auto device = doc.mapPoint(point, &code, &error);
const int px = std::clamp(int(device.value("x").toDouble()), 0, width - 1);
const int py = std::clamp(int(device.value("y").toDouble()), 0, height - 1);
return int(quint8(image[(py * width + px) * 4 + component]));
};
for (int index : {7, 8, 9, 10, 18}) { // Zero, transparent, Hidden, NoView, saved empty AP.
const int x = 40 + (index % 4) * 120, y = 50 + (index / 4) * 75;
for (int c = 0; c < 3; ++c) QCOMPARE(pixel(x + 50, y, c), 255);
}
QVERIFY(pixel(90, 50, 0) < 150); // Absent /Border means one point black.
QVERIFY(pixel(210, 50, 2) > 150); QVERIFY(pixel(210, 50, 0) < 100); // RGB.
QVERIFY(pixel(210, 125, 0) < 230); // Underline bottom.
QCOMPARE(pixel(210, 155, 0), 255); // Underline has no top edge.
QVERIFY(pixel(210, 350 + 15, 1) > 180); // Supplied green AP.
QVERIFY(pixel(210, 350 + 15, 2) < 10);
if (page == 0 && rotation == 0) {
QVERIFY(pixel(90, 275, 0) >= 120 && pixel(90, 275, 0) <= 135); // Gray.
QVERIFY(pixel(210, 275, 0) > 200 && pixel(210, 275, 2) < 40); // Device CMYK cyan.
QCOMPARE(pixel(280, 275, 0), 255); // Rounded corner is open.
QVERIFY(pixel(330, 275, 0) < 100); // Rounded bottom edge remains colored.
QVERIFY(pixel(450, 350, 0) > 100 && pixel(450, 350, 2) > 100); // Alpha over white.
// Bevel/inset expose opposite light and dark facets.
QVERIFY(pixel(450, 305, 0) > pixel(450, 275, 0));
QVERIFY(pixel(90, 380, 0) < pixel(90, 350, 0));
int ink = 0, gaps = 0;
for (int x = 290; x < 370; ++x) { if (pixel(x, 50, 2) < 150) ++ink; else ++gaps; }
QVERIFY(ink > 20); QVERIFY(gaps > 10); // /Border dash is not flattened.
}
// Same annotation's original rectangle survives rendering and reopening pages.
QCOMPARE(doc.links(page).value("links").toArray(), originalLinks);
// Independently rendered tiles must reconstruct the same pixels,
// including the centered half-width outside the annotation rect.
for (int y = 0; y < height; y += 127) for (int x = 0; x < width; x += 173) {
auto tileRequest = request(rotation, x, y, std::min(173, width - x), std::min(127, height - y));
tileRequest.insert(QStringLiteral("page"), page);
const auto tile = doc.render(tileRequest, &code, &error).value("data").toByteArray();
QVERIFY2(code.isEmpty(), qPrintable(error));
const int w = int(tileRequest.value("width").toInteger()), h = int(tileRequest.value("height").toInteger());
for (int row = 0; row < h; ++row) QCOMPARE(tile.mid(row * w * 4, w * 4), image.mid(((y + row) * width + x) * 4, w * 4));
}
}
}
QVERIFY(source.seek(0)); QCOMPARE(QCryptographicHash::hash(source.readAll(), QCryptographicHash::Sha256), before);
doc.close();
QVERIFY(source.isOpen()); // Borrowed inherited input retains caller ownership.
}
void annotationFlagsPreserveGeometryAndAppearance() {
const auto path = QFINDTESTDATA("fixtures/pdf/annotation-flags/flags.pdf");
QFile source(path); QVERIFY(source.open(QIODevice::ReadOnly));
const auto bytes = source.readAll();
PdfDocument doc; QString code, error; QVERIFY2(doc.openFile(&source, {}, &code, &error), qPrintable(error));
QCOMPARE(doc.pageCount(), 38);
const QString evidence = qEnvironmentVariable("DOCVIEW_ANNOTATION_FLAGS_EVIDENCE");
if (!evidence.isEmpty()) QVERIFY(QDir().mkpath(evidence));
for (int page = 0; page < 36; ++page) {
const int kind = page / 12, flags = (page % 12) / 4 == 0 ? 8 : (page % 12) / 4 == 1 ? 16 : 24;
const int intrinsic = page % 4;
const auto originalMetadata = doc.links(page);
const auto rows = originalMetadata.value(kind == 2 ? QStringLiteral("annotations") : QStringLiteral("links")).toArray();
QCOMPARE(rows.size(), 1); QCOMPARE(rows.first().toMap().value("flags").toInteger(), flags);
const auto originalPages = doc.pages(page, 1);
for (int rotation : {0, 90, 180, 270}) for (double zoom : {.5, 1., 2.}) for (double dpr : {1., 2.}) {
const double scale = zoom * dpr;
const int total = (intrinsic + rotation / 90) % 4;
const int width = int((total % 2 ? 104 : 112) * scale), height = int((total % 2 ? 112 : 104) * scale);
auto render = request(rotation, 0, 0, width, height);
render.insert(QStringLiteral("page"), page); render.insert(QStringLiteral("scale"), scale);
render.insert(QStringLiteral("devicePixelRatio"), dpr);
const auto image = doc.render(render, &code, &error).value("data").toByteArray();
const QByteArray context = QString("page=%1 flags=%2 total=%3 zoom=%4 dpr=%5: %6").arg(page).arg(flags).arg(total).arg(zoom).arg(dpr).arg(error).toUtf8();
QVERIFY2(code.isEmpty(), context.constData()); QCOMPARE(image.size(), width * height * 4);
const auto map = [&](double x, double y) {
if (total == 0) return QPointF((x - 8) * scale, (116 - y) * scale);
if (total == 1) return QPointF((y - 12) * scale, (x - 8) * scale);
if (total == 2) return QPointF((120 - x) * scale, (y - 12) * scale);
return QPointF((116 - y) * scale, (120 - x) * scale);
};
const QPointF anchor = map(42, 64);
const auto annotationPoint = [&](double x, double y) {
if (flags & 16) return anchor + QPointF(x - 42, 64 - y) * (flags & 8 ? dpr : scale);
return anchor + (map(x, y) - anchor) * (flags & 8 ? 1 / zoom : 1);
};
const double margin = kind == 0 ? 1 : 0;
const QPointF p = annotationPoint(42 - margin, 48 - margin), q = annotationPoint(74 + margin, 64 + margin);
const QRectF expected = QRectF(p, q).normalized().intersected(QRectF(0, 0, width, height));
int left = width, top = height, right = -1, bottom = -1;
for (int y = 0; y < height; ++y) for (int x = 0; x < width; ++x) {
const auto *pixel = reinterpret_cast<const quint8 *>(image.constData() + (y * width + x) * 4);
if (pixel[1] < 80 && ((pixel[2] > 180 && pixel[0] < 80) || (pixel[0] > 180 && pixel[2] < 80))) {
left = std::min(left, x); top = std::min(top, y); right = std::max(right, x); bottom = std::max(bottom, y);
}
}
QVERIFY2(right >= left && bottom >= top, context.constData());
QVERIFY2(std::abs(left - expected.left()) <= 1.1 && std::abs(top - expected.top()) <= 1.1 &&
std::abs(right + 1 - expected.right()) <= 1.1 && std::abs(bottom + 1 - expected.bottom()) <= 1.1,
(context + " actual=" + QByteArray::number(left) + ',' + QByteArray::number(top) + ',' + QByteArray::number(right + 1) + ',' + QByteArray::number(bottom + 1)
+ " expected=" + QByteArray::number(expected.left()) + ',' + QByteArray::number(expected.top()) + ',' + QByteArray::number(expected.right()) + ',' + QByteArray::number(expected.bottom())).constData());
if (kind != 0) for (int half : {0, 1}) {
const auto point = annotationPoint(50 + 16 * half, 56);
const int x = int(point.x()), y = int(point.y());
if (x >= 0 && x < width && y >= 0 && y < height) {
const auto *pixel = reinterpret_cast<const quint8 *>(image.constData() + (y * width + x) * 4);
QVERIFY2(pixel[half ? 0 : 2] > 220 && pixel[half ? 2 : 0] < 30 && pixel[1] < 30, context.constData());
}
}
QCOMPARE(doc.links(page), originalMetadata); QCOMPARE(doc.pages(page, 1), originalPages);
if (zoom == 2 && dpr == 2 && rotation == 90) {
for (int y = 0; y < height; y += 127) for (int x = 0; x < width; x += 173) {
auto tileRequest = render; const int w = std::min(173, width - x), h = std::min(127, height - y);
tileRequest.insert(QStringLiteral("x"), x); tileRequest.insert(QStringLiteral("y"), y);
tileRequest.insert(QStringLiteral("width"), w); tileRequest.insert(QStringLiteral("height"), h);
const auto tile = doc.render(tileRequest, &code, &error).value("data").toByteArray();
QVERIFY2(code.isEmpty(), context.constData());
for (int row = 0; row < h; ++row) {
const auto actual = tile.mid(row * w * 4, w * 4), expectedRow = image.mid(((y + row) * width + x) * 4, w * 4);
// PDFium's rotated, scaled AP matrix uses floats:
// translating the same edge between tile origins may
// differ by two 8-bit antialiasing steps, never geometry.
for (qsizetype at = 0; at < actual.size(); ++at)
QVERIFY2(std::abs(int(quint8(actual[at])) - int(quint8(expectedRow[at]))) <= 2,
(context + " tile=" + QByteArray::number(x) + ',' + QByteArray::number(y) + " difference=" + QByteArray::number(x + at / 4) + ',' + QByteArray::number(y + row)
+ " actual=" + QByteArray::number(quint8(actual[at])) + " expected=" + QByteArray::number(quint8(expectedRow[at]))).constData());
}
}
}
if (!evidence.isEmpty() && page % 4 == 1 && rotation == 90 && dpr == 2) {
QFile output(evidence + QString("/kind%1-flags%2-intrinsic90-user90-zoom%3-dpr2.ppm").arg(kind).arg(flags).arg(zoom));
QVERIFY(output.open(QIODevice::WriteOnly));
QByteArray ppm = "P6\n" + QByteArray::number(width) + ' ' + QByteArray::number(height) + "\n255\n";
for (qsizetype i = 0; i < image.size(); i += 4) { ppm += image[i + 2]; ppm += image[i + 1]; ppm += image[i]; }
QCOMPARE(output.write(ppm), ppm.size());
}
}
}
QVERIFY(source.seek(0)); QCOMPARE(source.readAll(), bytes);
}
void annotationFlagsKeepVisibilityZOrderAndCropClipping() {
PdfDocument doc; QString code, error; QVERIFY(doc.open(QFINDTESTDATA("fixtures/pdf/annotation-flags/flags.pdf"), {}, &code, &error));
const auto visible = doc.links(36);
QCOMPARE(visible.value("links").toArray().size(), 2);
QCOMPARE(visible.value("annotations").toArray().size(), 0);
for (double scale : {.5, 1., 2.}) {
auto render = request(0, 0, 0, int(112 * scale), int(104 * scale));
render.insert(QStringLiteral("page"), 36); render.insert(QStringLiteral("scale"), scale);
const auto image = doc.render(render, &code, &error).value("data").toByteArray(); QVERIFY2(code.isEmpty(), qPrintable(error));
int green = 0;
for (qsizetype i = 0; i < image.size(); i += 4) {
const auto *pixel = reinterpret_cast<const quint8 *>(image.constData() + i);
QVERIFY(!(pixel[1] < 50 && pixel[0] > 200 && pixel[2] < 50)); // No blue from either hidden AP.
if (pixel[1] > 200 && pixel[0] < 50 && pixel[2] < 50) ++green;
}
QVERIFY(green > 200);
const int center = (int(52 * scale + 8) * int(112 * scale) + int(34 * scale + 16)) * 4;
QVERIFY(quint8(image[center + 1]) > 200 && quint8(image[center]) < 50 && quint8(image[center + 2]) < 50);
render.insert(QStringLiteral("page"), 37);
const auto clipped = doc.render(render, &code, &error).value("data").toByteArray(); QVERIFY2(code.isEmpty(), qPrintable(error));
const int w = int(112 * scale), h = int(104 * scale); int ink = 0;
for (int y = 0; y < h; ++y) for (int x = 0; x < w; ++x) {
const auto *pixel = reinterpret_cast<const quint8 *>(clipped.constData() + (y * w + x) * 4);
if (pixel[1] < 50) { QVERIFY(x >= int(100 * scale)); ++ink; }
}
QVERIFY(ink > 0);
}
}
void generatedTextIconsUseOneCapturedAppearance() {
const auto path = QFINDTESTDATA("fixtures/pdf/annotation-flags/text-icons.pdf");
QFile source(path); QVERIFY(source.open(QIODevice::ReadOnly)); const auto bytes = source.readAll();
FPDF_InitLibrary(); const auto libraryGuard = qScopeGuard([] { FPDF_DestroyLibrary(); });
auto document = FPDF_LoadMemDocument64(bytes.constData(), size_t(bytes.size()), nullptr); QVERIFY(document);
const auto documentGuard = qScopeGuard([&] { FPDF_CloseDocument(document); });
auto context = std::make_shared<PdfMetadataContext>(); QString code, error;
QVERIFY(context->open(&source, {}, 12, &code, &error)); LinkBorderReader reader(context);
const auto prepare = [&](int index, double scale) {
auto page = FPDF_LoadPage(document, index); if (!page) return false;
{ auto scope = reader.prepare(index, page, &code, &error, scale, 2); if (!scope) { FPDF_ClosePage(page); return false; } }
auto annotation = FPDFPage_GetAnnot(page, 0); FS_RECTF rect{};
const bool original = FPDFAnnot_GetRect(annotation, &rect) && rect.left == 42 && rect.bottom == 48 && rect.right == 74 && rect.top == 64;
FPDFPage_CloseAnnot(annotation);
auto ink = FPDFPage_GetAnnot(page, 1); FS_RECTF inkRect{};
const bool inkUnchanged = FPDFAnnot_GetRect(ink, &inkRect) && inkRect.left == 80 && inkRect.bottom == 24 && inkRect.right == 100 && inkRect.top == 34 &&
FPDFAnnot_GetFlags(ink) == 8 && FPDFAnnot_GetAP(ink, FPDF_ANNOT_APPEARANCEMODE_NORMAL, nullptr, 0) <= 2;
FPDFPage_CloseAnnot(ink); FPDF_ClosePage(page); return original && inkUnchanged;
};
for (int page = 0; page < 12; ++page) QVERIFY2(prepare(page, 1), qPrintable(error));
struct Writer : FPDF_FILEWRITE { QByteArray bytes; Writer() { version = 1; WriteBlock = [](FPDF_FILEWRITE *self, const void *data, unsigned long size) {
static_cast<Writer *>(self)->bytes.append(static_cast<const char *>(data), qsizetype(size)); return 1; }; } };
Writer first; QVERIFY(FPDF_SaveAsCopy(document, &first, FPDF_NO_INCREMENTAL));
for (int i = 0; i < 200; ++i) QVERIFY2(prepare(i % 12, i % 2 ? .5 : 2), qPrintable(error));
Writer repeated; QVERIFY(FPDF_SaveAsCopy(document, &repeated, FPDF_NO_INCREMENTAL));
QCOMPARE(repeated.bytes.size(), first.bytes.size());
QCOMPARE(repeated.bytes.count("/Subtype/Form"), first.bytes.count("/Subtype/Form"));
QVERIFY(source.seek(0)); QCOMPARE(source.readAll(), bytes);
}
void generatedTextIconGeometryMatchesFlags() {
PdfDocument doc; QString code, error; QVERIFY(doc.open(QFINDTESTDATA("fixtures/pdf/annotation-flags/text-icons.pdf"), {}, &code, &error));
for (int page = 0; page < 12; ++page) {
const int flags = page / 4 == 0 ? 8 : page / 4 == 1 ? 16 : 24;
const auto original = doc.links(page); const auto icon = original.value("annotations").toArray().first().toMap();
QCOMPARE(icon.value("flags").toInteger(), flags); QCOMPARE(icon.value("rect").toArray(), QCborArray({42.,48.,62.,68.}));
QCOMPARE(original.value("annotations").toArray()[1].toMap().value("flags").toInteger(),0);
QVERIFY(!original.value("warnings").toArray().isEmpty());
for (int rotation : {0,90,180,270}) for (double zoom : {.5,2.}) for (double dpr : {1.,2.}) {
const int total = (page % 4 + rotation / 90) % 4; const double scale = zoom * dpr;
const int w = int((total % 2 ? 104 : 112) * scale), h = int((total % 2 ? 112 : 104) * scale);
auto render = request(rotation, 0, 0, w, h); render.insert(QStringLiteral("page"), page);
render.insert(QStringLiteral("scale"), scale); render.insert(QStringLiteral("devicePixelRatio"), dpr);
const auto data = doc.render(render, &code, &error).value("data").toByteArray(); QVERIFY2(code.isEmpty(), qPrintable(error));
const auto map = [&](double x, double y) {
if (total == 0) return QPointF((x - 8) * scale, (116 - y) * scale);
if (total == 1) return QPointF((y - 12) * scale, (x - 8) * scale);
if (total == 2) return QPointF((120 - x) * scale, (y - 12) * scale);
return QPointF((116 - y) * scale, (120 - x) * scale);
};
const auto anchor = map(42,68); const double sizeScale = flags & 8 ? dpr : scale;
const auto opposite = flags & 16 ? anchor + QPointF(20,20) * sizeScale : anchor + (map(62,48) - anchor) * (flags & 8 ? 1/zoom : 1);
const auto expected = QRectF(anchor,opposite).normalized().intersected(QRectF(0,0,w,h));
QRect red;
for (int y=0;y<h;++y) for (int x=0;x<w;++x) {
const auto *p = reinterpret_cast<const quint8 *>(data.constData()+(y*w+x)*4);
if (p[2]>150 && p[1]>150 && p[0]<100) red = red.isNull() ? QRect(x,y,1,1) : red.united(QRect(x,y,1,1));
}
QVERIFY2(!red.isNull(),qPrintable(QString("page=%1 rotation=%2 zoom=%3 dpr=%4").arg(page).arg(rotation).arg(zoom).arg(dpr)));
QVERIFY(expected.adjusted(-1,-1,1,1).contains(red));
QVERIFY2(red.width() >= expected.width() - 4 * sizeScale - 2 && red.height() >= expected.height() - 4 * sizeScale - 2,
qPrintable(QString("page=%1 rotation=%2 zoom=%3 dpr=%4 actual=%5x%6 expected=%7x%8").arg(page).arg(rotation).arg(zoom).arg(dpr).arg(red.width()).arg(red.height()).arg(expected.width()).arg(expected.height())));
const auto after = doc.links(page);
QCOMPARE(after.value("annotations").toArray().first().toMap(),icon);
QCOMPARE(after.value("annotations").toArray()[1].toMap().value("flags").toInteger(),0);
}
}
}
void nativeIconFailureRemainsStickyAcrossSharedPages() {
QFile source(QFINDTESTDATA("fixtures/pdf/annotation-flags/text-icons.pdf")); QVERIFY(source.open(QIODevice::ReadOnly));
const auto bytes=source.readAll(); FPDF_InitLibrary(); const auto libraryGuard=qScopeGuard([]{FPDF_DestroyLibrary();});
auto document=FPDF_LoadMemDocument64(bytes.constData(),size_t(bytes.size()),nullptr); QVERIFY(document);
const auto documentGuard=qScopeGuard([&]{FPDF_CloseDocument(document);});
auto context=std::make_shared<PdfMetadataContext>(); QString code,error; QVERIFY(context->open(&source,{},12,&code,&error));
LinkBorderReader reader(context,1); // Lower-only quota reaches a failure after AP creation.
auto first=FPDF_LoadPage(document,0); QVERIFY(reader.prepareNativeIcons(0,first,&code,&error)); FPDF_ClosePage(first);
for(int pageIndex:{4,5,0}) {
auto page=FPDF_LoadPage(document,pageIndex); QVERIFY(!reader.prepareNativeIcons(pageIndex,page,&code,&error));
QCOMPARE(code,"E_LIMIT_EXCEEDED"); auto annotation=FPDFPage_GetAnnot(page,0); FS_RECTF rect{}; QVERIFY(FPDFAnnot_GetRect(annotation,&rect));
QCOMPARE(rect.left,42.f); QCOMPARE(rect.bottom,48.f); QCOMPARE(rect.right,74.f); QCOMPARE(rect.top,64.f);
FPDFPage_CloseAnnot(annotation); FPDF_ClosePage(page);
}
}
void nativeIconMetadataCanBrokerFontsInLinksRequest() {
FontBroker fonts; WorkerProcess worker("native-text-icon-fonts", 1);
int maps = 0; QString operation;
worker.setFontRequestHandler([&](const QString &op, const QCborMap &payload, std::function<void(QCborMap)> done) {
if (op == "font.map" && operation == "links") ++maps;
fonts.request(op, payload, std::move(done));
});
QSignalSpy ready(&worker, &WorkerProcess::ready), failed(&worker, &WorkerProcess::failed);
QVERIFY(worker.start(QCoreApplication::applicationDirPath()+"/docview-pdf-worker", {"--source",QFINDTESTDATA("fixtures/pdf/annotation-flags/text-icons.pdf")}));
QTRY_VERIFY_WITH_TIMEOUT(!ready.isEmpty() || !failed.isEmpty(),10000); QVERIFY(failed.isEmpty());
QList<QCborMap> replies;
operation="open"; worker.request(operation,{},[&](QCborMap r){replies<<r;}); QTRY_COMPARE_WITH_TIMEOUT(replies.size(),1,10000);
QVERIFY(!replies.takeFirst().contains(QStringLiteral("error")));
operation="links"; worker.request(operation,{{QStringLiteral("page"),0}},[&](QCborMap r){replies<<r;}); QTRY_COMPARE_WITH_TIMEOUT(replies.size(),1,15000);
const auto reply = replies.takeFirst(); QVERIFY2(!reply.contains(QStringLiteral("error")),qPrintable(reply.value("error").toMap().value("message").toString()));
QCOMPARE(reply.value("result").toMap().value("annotations").toArray().first().toMap().value("rect").toArray(),QCborArray({42.,48.,62.,68.}));
QVERIFY(maps>0); worker.stop(); fonts.revoke(); QVERIFY(failed.isEmpty());
}
void invalidLinkBordersAreExplicit_data() {
QTest::addColumn<QString>("name"); QTest::addColumn<QString>("expected");
QTest::newRow("unknown-style") << "unknown-style" << "E_PDF_ANNOT_UNSUPPORTED";
QTest::newRow("zero-dash") << "invalid-dash" << "E_PDF_CORRUPT";
QTest::newRow("bounded-dash") << "oversized-dash" << "E_LIMIT_EXCEEDED";
QTest::newRow("color") << "invalid-color" << "E_PDF_CORRUPT";
QTest::newRow("appearance") << "invalid-ap" << "E_PDF_CORRUPT";
}
void invalidLinkBordersAreExplicit() {
QFETCH(QString, name); QFETCH(QString, expected);
const auto directory = QFileInfo(QFINDTESTDATA("fixtures/pdf/link-borders/manifest.json")).absoluteDir();
PdfDocument document; QString code, message;
QVERIFY2(document.open(directory.filePath(name + ".pdf"), {}, &code, &message), qPrintable(message));
QVERIFY(document.render(request(), &code, &message).isEmpty()); QCOMPARE(code, expected);
QVERIFY(!message.contains(directory.path()));
}
void failedBorderPagesDoNotConsumeTheAppearanceBudget() {
PdfDocument document; QString code, message;
QVERIFY(document.open(QFINDTESTDATA("fixtures/pdf/link-borders/retry-budget.pdf"), {}, &code, &message));
for (int retry = 0; retry < 40; ++retry) {
QVERIFY(document.render(request(), &code, &message).isEmpty());
QCOMPARE(code, "E_PDF_ANNOT_UNSUPPORTED");
}
auto validPage = request(); validPage.insert(QStringLiteral("page"), 1);
const auto result = document.render(validPage, &code, &message);
QVERIFY2(code.isEmpty(), qPrintable(code + ": " + message));
QCOMPARE(result.value("data").toByteArray().size(), 200 * 200 * 4);
}
void outlinePaginationBeyondOneThousand() {
QTemporaryDir directory; PdfDocument doc; QString code, error;
QVERIFY(doc.open(largeMetadataFixture(directory, 1, 1200), {}, &code, &error));
const auto metadata = doc.metadata();
QCOMPARE(metadata.value("outlineCount").toInteger(), 1200);
QVERIFY(!metadata.value("isTagged").toBool());
QCOMPARE(metadata.value("outline").toArray().size(), 64);
QCOMPARE(metadata.value("nextOutlineCursor").toInteger(), 64);
QVERIFY(!metadata.value("outlineTruncated").toBool());
int cursor = 0, seen = 0;
while (cursor >= 0) {
const auto result = doc.outline(cursor, 128);
QVERIFY(!result.contains(QStringLiteral("error")));
QVERIFY(QCborValue(result).toCbor().size() <= 512 * 1024);
QCOMPARE(result.value("outlineCount").toInteger(), 1200);
QVERIFY(!result.value("truncated").toBool());
const auto rows = result.value("outline").toArray();
QVERIFY(!rows.isEmpty());
for (const auto &value : rows) {
const auto row = value.toMap();
QCOMPARE(row.value("id").toString(), QString("outline-%1").arg(seen));
QCOMPARE(row.value("title").toString(), QString("Heading %1").arg(seen));
QCOMPARE(row.value("page").toInteger(), 0);
++seen;
}
cursor = int(result.value("nextOutlineCursor").toInteger());
QVERIFY(cursor < 0 || cursor == seen);
}
QCOMPARE(seen, 1200);
QCOMPARE(doc.outline(1024, 1).value("outline").toArray()[0].toMap().value("id").toString(), "outline-1024");
QVERIFY(doc.outline(1201, 1).contains(QStringLiteral("error")));
QVERIFY(doc.outline(0, 0).contains(QStringLiteral("error")));
}
void outlineAndPageBatchesRespectEncodedByteLimits() {
QTemporaryDir directory; PdfDocument doc; QString code, error;
const QString longText(4050, QChar(0x3042));
QVERIFY(doc.open(largeMetadataFixture(directory, 300, 180, longText, longText), {}, &code, &error));
const auto metadata = doc.metadata();
QVERIFY(!metadata.contains(QStringLiteral("error")));
QVERIFY(QCborValue(metadata).toCbor().size() <= 512 * 1024);
QVERIFY(metadata.value("outline").toArray().size() < 64);
QVERIFY(metadata.value("pages").toArray().size() < 256);
int cursor = 0, pages = 0;
while (cursor >= 0) {
const auto batch = doc.pages(cursor, 256);
QVERIFY(QCborValue(batch).toCbor().size() <= 256 * 1024);
const auto values = batch.value("pages").toArray(); QVERIFY(!values.isEmpty());
for (const auto &value : values) {
QCOMPARE(value.toMap().value("pageIndex").toInteger(), pages++);
QCOMPARE(value.toMap().value("label").toString(), longText);
}
cursor = int(batch.value("nextPageCursor").toInteger());
}
QCOMPARE(pages, 300);
cursor = 0; int outlines = 0;
while (cursor >= 0) {
const auto batch = doc.outline(cursor, 256);
QVERIFY(QCborValue(batch).toCbor().size() <= 512 * 1024);
const auto values = batch.value("outline").toArray(); QVERIFY(!values.isEmpty());
for (const auto &value : values) QCOMPARE(value.toMap().value("title").toString(), longText + QString::number(outlines++));
cursor = int(batch.value("nextOutlineCursor").toInteger());
}
QCOMPARE(outlines, 180);
}
void outlineSafetyLimitIsExplicit() {
QTemporaryDir directory; PdfDocument doc; QString code, error;
QVERIFY(doc.open(largeMetadataFixture(directory, 1, 20002), {}, &code, &error));
const auto metadata = doc.metadata();
QCOMPARE(metadata.value("outlineCount").toInteger(), 20000);
QVERIFY(metadata.value("outlineTruncated").toBool());
QVERIFY(metadata.value("warnings").toArray().toVariantList().first().toString().contains("20000"));
const auto last = doc.outline(19999, 128);
QCOMPARE(last.value("outline").toArray().size(), 1);
QCOMPARE(last.value("nextOutlineCursor").toInteger(), -1);
QVERIFY(last.value("truncated").toBool());
QVERIFY(!last.value("warnings").toArray().isEmpty());
}
void oversizedStringsProduceWarnings() {
QTemporaryDir directory; PdfDocument doc; QString code, error;
const QString tooLong(5000, 'x');
QVERIFY(doc.open(largeMetadataFixture(directory, 1, 2, tooLong, tooLong), {}, &code, &error));
const auto metadata = doc.metadata();
QVERIFY(!metadata.contains(QStringLiteral("error")));
QCOMPARE(metadata.value("warnings").toArray().size(), 2);
QVERIFY(metadata.value("pages").toArray()[0].toMap().value("label").toString().isEmpty());
QVERIFY(metadata.value("outline").toArray()[0].toMap().value("title").toString().contains("長さ制限"));
QCOMPARE(metadata.value("outlineCount").toInteger(), 2);
}
void metadataAndDestinations() {
PdfDocument doc; QString code, error; QVERIFY2(doc.open(fixture(), {}, &code, &error), qPrintable(error));
auto meta = doc.metadata(); QCOMPARE(meta.value("pageCount").toInteger(), 2);
QVERIFY(meta.value("isTagged").toBool());
const auto capabilities = meta.value("capabilities").toMap();
QCOMPARE(capabilities.size(), 8);
QCOMPARE(capabilities.value("pageNavigation").toString(), "supported");
QCOMPARE(capabilities.value("headings").toString(), "loading");
QCOMPARE(capabilities.value("textSearch").toString(), "loading");
QCOMPARE(capabilities.value("reflow").toString(), "unsupported");
QCOMPARE(meta.value("capabilityReasons").toMap().value("reflow").toString(), "E_REFLOW_UNSUPPORTED");
QCOMPARE(capabilities.value("password").toString(), "unavailable");
QCOMPARE(meta.value("title").toString(), "DocView PDF fixture");
auto pages = meta.value("pages").toArray(); QCOMPARE(pages.size(), 2);
QCOMPARE(pages[0].toMap().value("width").toDouble(), 200.0);
QCOMPARE(pages[0].toMap().value("label").toString(), "i");
QCOMPARE(pages[1].toMap().value("label").toString(), "1");
QCOMPARE(pages[1].toMap().value("rotation").toInteger(), 90);
QCOMPARE(pages[1].toMap().value("width").toDouble(), 200.0);
auto outline = meta.value("outline").toArray(); QCOMPARE(outline.size(), 2);
QCOMPARE(outline[0].toMap().value("page").toInteger(), 0);
QCOMPARE(outline[0].toMap().value("y").toDouble(), 180.0);
QCOMPARE(outline[1].toMap().value("page").toInteger(), 1);
QCOMPARE(doc.metadata(1, 1).value("nextPageCursor").toInteger(), -1);
}
void tagsRoleMapAndMultipleMcid() {
PdfDocument doc; QString code, error; QVERIFY(doc.open(fixture(), {}, &code, &error));
auto headings = doc.headings(0).value("headings").toArray(); QCOMPARE(headings.size(), 2);
QCOMPARE(headings[0].toMap().value("level").toInteger(), 1);
QCOMPARE(headings[0].toMap().value("title").toString(), "Heading One");
QCOMPARE(headings[0].toMap().value("precision").toString(), "exact");
QVERIFY(headings[0].toMap().value("y").toDouble() > 180);
QCOMPARE(headings[1].toMap().value("title").toString(), "NeedleSecond MCID");
QCOMPARE(doc.headings(1).value("headings").toArray().size(), 0);
}
void taggedBoundaryCorpus_data() {
QTest::addColumn<QString>("fileName");
QFile manifest(QFINDTESTDATA("fixtures/pdf/headings/manifest.json")); QVERIFY(manifest.open(QIODevice::ReadOnly));
for (const auto &value : QJsonDocument::fromJson(manifest.readAll()).object().value("documents").toArray()) {
const auto name = value.toObject().value("file").toString(); QTest::newRow(qPrintable(name)) << name;
}
}
void taggedBoundaryCorpus() {
QFETCH(QString, fileName);
const auto directory = QFileInfo(QFINDTESTDATA("fixtures/pdf/headings/manifest.json")).absoluteDir();
QFile manifest(directory.filePath("manifest.json")); QVERIFY(manifest.open(QIODevice::ReadOnly));
const auto records = QJsonDocument::fromJson(manifest.readAll()).object().value("documents").toArray();
QJsonObject record;
for (const auto &entry : records) if (entry.toObject().value("file").toString() == fileName) record = entry.toObject();
QVERIFY(!record.isEmpty());
QFile bytes(directory.filePath(fileName)); QVERIFY(bytes.open(QIODevice::ReadOnly));
QCOMPARE(QString::fromLatin1(QCryptographicHash::hash(bytes.readAll(), QCryptographicHash::Sha256).toHex()), record.value("sha256").toString());
PdfDocument document; QString code, error;
QVERIFY2(document.open(directory.filePath(fileName), {}, &code, &error), qPrintable(error));
const auto expected = record.value("expected").toObject();
QCOMPARE(document.pageCount(), expected.value("pages").toInt());
const auto metadata = document.metadata();
QVERIFY(metadata.value("isTagged").toBool());
QVERIFY(metadata.value("outline").toArray().isEmpty()); // Tags must work without bookmarks.
QCborArray allHeadings;
for (int page = 0; page < document.pageCount(); ++page) {
const auto result = document.headings(page);
if (expected.value("response_limited").toBool()) {
QVERIFY(result.value("structureLimited").toBool()); QVERIFY(result.value("truncated").toBool());
QVERIFY(!result.value("complete").toBool()); QVERIFY(!result.value("headings").toArray().isEmpty());
QVERIFY(result.value("headings").toArray().size() < 70); QVERIFY(QCborValue(result).toCbor().size() <= 512 * 1024);
continue;
}
if (expected.value("limited").toBool()) {
QVERIFY2(result.value("structureLimited").toBool(), qPrintable(QCborValue(result).toDiagnosticNotation()));
QVERIFY(!result.value("unavailableReason").toString().isEmpty());
QCOMPARE(result.value("truncated").toBool(), expected.value("truncated").toBool());
QVERIFY(result.value("headings").toArray().isEmpty()); continue;
}
QVERIFY2(result.value("complete").toBool(), qPrintable(QCborValue(result).toDiagnosticNotation())); QVERIFY(!result.value("truncated").toBool());
QCOMPARE(result.value("page").toInteger(), page);
if (expected.value("unavailable_form_structure").toBool()) {
QVERIFY(result.value("structureLimited").toBool());
QVERIFY(result.value("unavailableReason").toString().contains("Form XObject"));
QVERIFY(result.value("hasText").toBool());
} else {
QVERIFY2(!result.value("structureLimited").toBool(), qPrintable(QCborValue(result).toDiagnosticNotation()));
}
for (const auto &heading : result.value("headings").toArray()) allHeadings.append(heading);
}
const auto targets = expected.value("headings").toArray();
QCOMPARE(allHeadings.size(), targets.size());
for (qsizetype index = 0; index < targets.size(); ++index) {
const auto actual = allHeadings[index].toMap(); const auto target = targets[index].toObject();
QCOMPARE(actual.value("title").toString(), target.value("title").toString());
QCOMPARE(actual.value("page").toInteger(), target.value("page").toInt());
QCOMPARE(actual.value("level").toInteger(), target.value("level").toInt());
QCOMPARE(actual.value("source").toString(), "pdf-tag");
QCOMPARE(actual.value("precision").toString(), target.value("precision").toString());
if (target.value("precision").toString() == "page") {
QVERIFY(actual.value("reason").toString().contains(target.value("reason_contains").toString()));
QVERIFY(!actual.contains(QStringLiteral("x"))); QVERIFY(!actual.contains(QStringLiteral("y")));
QVERIFY(!actual.contains(QStringLiteral("rect")));
continue;
}
const auto region = target.value("text_region_pt").toArray();
const auto rect = actual.value("rect").toArray(); QCOMPARE(rect.size(), 4);
QVERIFY(rect[0].toDouble() >= region[0].toDouble()); QVERIFY(rect[1].toDouble() >= region[1].toDouble());
QVERIFY(rect[2].toDouble() <= region[2].toDouble()); QVERIFY(rect[3].toDouble() <= region[3].toDouble());
QVERIFY(rect[2].toDouble() > rect[0].toDouble()); QVERIFY(rect[3].toDouble() > rect[1].toDouble());
QCOMPARE(actual.value("x").toDouble(), rect[0].toDouble());
QCOMPARE(actual.value("y").toDouble(), rect[3].toDouble());
// The returned PDF point must survive document CropBox/Rotate and
// each user rotation. A correct title alone does not pass this test.
for (int rotation : {0, 90, 180, 270}) {
QCborMap point{{QStringLiteral("page"), target.value("page").toInt()}, {QStringLiteral("scale"), 2.0},
{QStringLiteral("rotation"), rotation}, {QStringLiteral("x"), actual.value("x")}, {QStringLiteral("y"), actual.value("y")}};
code.clear(); const auto mapped = document.mapPoint(point, &code, &error); QVERIFY2(code.isEmpty(), qPrintable(error));
QVERIFY(mapped.value("x").toDouble() >= 0); QVERIFY(mapped.value("y").toDouble() >= 0);
const auto pageMeta = metadata.value("pages").toArray()[target.value("page").toInt()].toMap();
double width = pageMeta.value("width").toDouble(), height = pageMeta.value("height").toDouble();
if (rotation % 180) std::swap(width, height);
QVERIFY(mapped.value("x").toDouble() <= width * 2);
QVERIFY(mapped.value("y").toDouble() <= height * 2);
point.insert(QStringLiteral("direction"), "deviceToPage");
point.insert(QStringLiteral("x"), mapped.value("x")); point.insert(QStringLiteral("y"), mapped.value("y"));
const auto restored = document.mapPoint(point, &code, &error);
QVERIFY(qAbs(restored.value("x").toDouble() - actual.value("x").toDouble()) <= 0.5);
QVERIFY(qAbs(restored.value("y").toDouble() - actual.value("y").toDouble()) <= 0.5);
}
}
}
void linksCommentsAndSearch() {
PdfDocument doc; QString code, error; QVERIFY(doc.open(fixture(), {}, &code, &error));
auto result = doc.links(0); auto links = result.value("links").toArray(); QCOMPARE(links.size(), 2);
QCOMPARE(links[0].toMap().value("kind").toString(), "external");
QCOMPARE(links[1].toMap().value("kind").toString(), "blocked");
QCOMPARE(result.value("annotations").toArray()[0].toMap().value("text").toString(), "Read-only comment");
auto matches = doc.search("Needle", 0).value("matches").toArray(); QCOMPARE(matches.size(), 1);
QVERIFY(!matches[0].toMap().value("rects").toArray().isEmpty());
QCOMPARE(doc.search("needle", 1).value("matches").toArray().size(), 1);
QCOMPARE(doc.search("missing", 0).value("matches").toArray().size(), 0);
}
void annotationContentsLengthBoundary() {
QTemporaryDir directory; PdfDocument doc; QString code, error;
// Keep a complete surrogate pair at the finite UTF-16 boundary.
const QString atLimit = QString(4093, QChar('x')) + QString::fromUcs4(U"\U0001f4d6");
QCOMPARE(atLimit.size(), 4095);
auto path = annotationMetadataFixture(directory, 1, atLimit);
QVERIFY(!path.isEmpty()); QVERIFY(doc.open(path, {}, &code, &error));
auto result = doc.links(0);
QCOMPARE(result.value("annotations").toArray().size(), 1);
QCOMPARE(result.value("annotations").toArray()[0].toMap().value("text").toString(), atLimit);
QVERIFY(result.value("warnings").toArray().isEmpty());
doc.close();
const QString overLimit = QString(4094, QChar('x')) + QString::fromUcs4(U"\U0001f4d6");
path = annotationMetadataFixture(directory, 1, overLimit);
QFile source(path); QVERIFY(source.open(QIODevice::ReadOnly)); const auto original = source.readAll(); source.close();
QVERIFY(doc.open(path, {}, &code, &error)); result = doc.links(0);
QCOMPARE(result.value("annotations").toArray().size(), 1);
const auto note = result.value("annotations").toArray()[0].toMap();
QVERIFY(note.value("text").toString().contains(QStringLiteral("長さ制限")));
QCOMPARE(note.value("rect").toArray(), QCborArray({10., 10., 30., 30.}));
const auto warnings = result.value("warnings").toArray(); QCOMPARE(warnings.size(), 1);
QVERIFY(warnings[0].toString().startsWith("E_PDF_METADATA_LIMIT:"));
QVERIFY(warnings[0].toString().contains(QStringLiteral("注釈本文")));
doc.close(); QVERIFY(source.open(QIODevice::ReadOnly)); QCOMPARE(source.readAll(), original);
}
void annotationAndLinkCountLimitsAreExplicit_data() {
QTest::addColumn<bool>("links"); QTest::addColumn<int>("count");
for (bool links : {false, true}) for (int count : {1000, 1001})
QTest::newRow(qPrintable(QString("%1-%2").arg(links ? "links" : "comments").arg(count))) << links << count;
}
void annotationAndLinkCountLimitsAreExplicit() {
QFETCH(bool, links); QFETCH(int, count);
QTemporaryDir directory; PdfDocument doc; QString code, error;
QVERIFY(doc.open(annotationMetadataFixture(directory, count, QStringLiteral("Comment"), links), {}, &code, &error));
const auto result = doc.links(0);
QVERIFY(!result.contains(QStringLiteral("error")));
QCOMPARE(result.value(links ? QStringLiteral("links") : QStringLiteral("annotations")).toArray().size(), 1000);
const auto warnings = result.value("warnings").toArray();
QCOMPARE(warnings.isEmpty(), count == 1000);
if (count > 1000) {
bool found = false;
for (const auto &warning : warnings) {
QVERIFY(warning.toString().startsWith("E_PDF_METADATA_LIMIT:"));
if (warning.toString().contains(links ? QStringLiteral("リンク") : QStringLiteral("コメント"))) found = true;
}
QVERIFY(found);
}
QVERIFY(QCborValue(result).toCbor().size() <= 512 * 1024);
}
void annotationResponsePreservesBoundedPrefixThroughWorker() {
QTemporaryDir directory;
const QString contents(4095, QChar(0x65e5)); // UTF-8 exceeds its UTF-16 code-unit count.
const auto path = annotationMetadataFixture(directory, 100, contents);
QVERIFY(!path.isEmpty());
QFile source(path); QVERIFY(source.open(QIODevice::ReadOnly)); const auto original = source.readAll(); source.close();
WorkerProcess worker("annotation-budget", 1);
FontBroker broker;
worker.setFontRequestHandler([&broker](const QString &op, const QCborMap &payload, auto completion) {
broker.request(op, payload, std::move(completion));
});
QSignalSpy ready(&worker, &WorkerProcess::ready), failed(&worker, &WorkerProcess::failed);
QList<QCborMap> replies;
QVERIFY(worker.start(QCoreApplication::applicationDirPath() + "/docview-pdf-worker", {"--source", path}));
QTRY_VERIFY_WITH_TIMEOUT(!ready.isEmpty() || !failed.isEmpty(), 10000); QVERIFY(failed.isEmpty());
worker.request("open", {}, [&](const QCborMap &reply) { replies.append(reply); });
QTRY_COMPARE_WITH_TIMEOUT(replies.size(), 1, 10000); QVERIFY(!replies.takeFirst().contains(QStringLiteral("error")));
worker.request("links", {{"page", 0}}, [&](const QCborMap &reply) { replies.append(reply); });
QTRY_COMPARE_WITH_TIMEOUT(replies.size(), 1, 10000); const auto reply = replies.takeFirst();
QVERIFY2(!reply.contains(QStringLiteral("error")), qPrintable(QCborValue(reply).toDiagnosticNotation()));
const auto result = reply.value("result").toMap(); const auto annotations = result.value("annotations").toArray();
QVERIFY(!annotations.isEmpty()); QVERIFY(annotations.size() < 100);
for (const auto &annotation : annotations) QCOMPARE(annotation.toMap().value("text").toString(), contents);
bool warningFound = false;
for (const auto &warning : result.value("warnings").toArray())
if (warning.toString().contains(QStringLiteral("応答サイズ")) && warning.toString().startsWith("E_PDF_METADATA_LIMIT:")) warningFound = true;
QVERIFY(warningFound); QVERIFY(QCborValue(result).toCbor().size() <= 512 * 1024);
worker.stop(); QVERIFY(failed.isEmpty());
QVERIFY(source.open(QIODevice::ReadOnly)); QCOMPARE(source.readAll(), original);
}
void cropRotationTilesAndFormAppearance() {
PdfDocument doc; QString code, error; QVERIFY(doc.open(fixture(), {}, &code, &error));
auto full = doc.render(request(), &code, &error); QVERIFY2(code.isEmpty(), qPrintable(error));
auto data = full.value("data").toByteArray(); QCOMPARE(data.size(), 200 * 200 * 4);
const auto pixel = [&](int x, int y) { return reinterpret_cast<const uchar *>(data.constData() + (y * 200 + x) * 4); };
QVERIFY(pixel(30, 160)[0] < 5); // black square in cropped device space
QVERIFY(pixel(110, 150)[0] < 5); QVERIFY(pixel(110, 150)[2] > 250); // saved widget AP is red
auto tile = doc.render(request(0, 100, 100, 100, 100), &code, &error).value("data").toByteArray();
// A 16-pixel render margin preserves clipped glyph antialiasing at
// tile boundaries. Every pixel must equal the full-page crop.
for (int row = 0; row < 100; ++row) QCOMPARE(tile.mid(row * 400, 400), data.mid(((row + 100) * 200 + 100) * 4, 400));
auto rotated = doc.render(request(180), &code, &error).value("data").toByteArray();
auto red = reinterpret_cast<const uchar *>(rotated.constData() + (49 * 200 + 89) * 4);
QVERIFY(red[0] < 5 && red[2] > 250);
auto pointRequest = request(); pointRequest.insert(QStringLiteral("x"), 60.0); pointRequest.insert(QStringLiteral("y"), 180.0);
auto mapped = doc.mapPoint(pointRequest, &code, &error); QCOMPARE(mapped.value("x").toDouble(), 40.0); QCOMPARE(mapped.value("y").toDouble(), 50.0);
pointRequest.insert(QStringLiteral("direction"), "deviceToPage"); pointRequest.insert(QStringLiteral("x"), mapped.value("x")); pointRequest.insert(QStringLiteral("y"), mapped.value("y"));
auto inverse = doc.mapPoint(pointRequest, &code, &error); QCOMPARE(inverse.value("x").toDouble(), 60.0); QCOMPARE(inverse.value("y").toDouble(), 180.0);
}
void passwordCorruptionAndLimits() {
PdfDocument doc; QString code, error; const auto path = QFINDTESTDATA("fixtures/pdf/password.pdf");
QVERIFY(!doc.open(path, {}, &code, &error)); QCOMPARE(code, "E_PASSWORD_REQUIRED");
QVERIFY(!doc.open(path, "wrong", &code, &error)); QCOMPARE(code, "E_PASSWORD_INVALID");
QVERIFY(doc.open(path, "reader", &code, &error)); QCOMPARE(doc.pageCount(), 2);
const auto encrypted = doc.render(request(), &code, &error).value("data").toByteArray();
QVERIFY2(code.isEmpty(), qPrintable(error)); QCOMPARE(encrypted.size(), 200 * 200 * 4);
QVERIFY(doc.open(fixture(), {}, &code, &error));
QCOMPARE(doc.render(request(), &code, &error).value("data").toByteArray(), encrypted);
QVERIFY(!doc.open(QFINDTESTDATA("fixtures/pdf/corrupt.pdf"), {}, &code, &error)); QCOMPARE(code, "E_PDF_CORRUPT");
QVERIFY(doc.open(fixture(), {}, &code, &error));
auto invalid = request(); invalid.insert(QStringLiteral("width"), 515); code.clear();
QVERIFY(doc.render(invalid, &code, &error).isEmpty()); QCOMPARE(code, "E_PROTOCOL_INVALID");
invalid = request(); invalid.insert(QStringLiteral("scale"), std::numeric_limits<double>::infinity());
QVERIFY(doc.render(invalid, &code, &error).isEmpty());
invalid = request(); invalid.insert(QStringLiteral("page"), qint64(1) << 32);
QVERIFY(doc.render(invalid, &code, &error).isEmpty()); QCOMPARE(code, "E_PROTOCOL_INVALID");
invalid = request(); invalid.insert(QStringLiteral("rotation"), qint64(1) << 32);
QVERIFY(doc.mapPoint(invalid, &code, &error).isEmpty()); QCOMPARE(code, "E_PROTOCOL_INVALID");
doc.close(); doc.close(); QCOMPARE(doc.pageCount(), 0);
}
void missingAppearanceAndAmbiguousFormMcid() {
PdfDocument doc; QString code, error;
QVERIFY(doc.open(QFINDTESTDATA("fixtures/pdf/missing-appearance.pdf"), {}, &code, &error));
const auto warnings = doc.links(0).value("warnings").toArray();
bool missing = false;
for (const auto &warning : warnings) if (warning.toString().contains(QStringLiteral("フォーム外観"))) missing = true;
QVERIFY(missing);
QVERIFY(doc.open(QFINDTESTDATA("fixtures/pdf/reused-form-mcid.pdf"), {}, &code, &error));
const auto headings = doc.headings(0).value("headings").toArray();
QVERIFY(!headings.isEmpty());
QCOMPARE(headings[0].toMap().value("title").toString(), "Heading One");
QCOMPARE(headings[0].toMap().value("precision").toString(), "exact");
QVERIFY(!headings[0].toMap().value("title").toString().contains("Different Form"));
}
void missingFirstPageRejectsMetadata() {
PdfDocument document; QString code, error;
QVERIFY(document.open(QFINDTESTDATA("fixtures/pdf/missing-first-page.pdf"), {}, &code, &error));
const auto result = document.metadata();
QVERIFY(result.contains(QStringLiteral("error")));
QCOMPARE(result.value("error").toMap().value("code").toString(), "E_PDF_CORRUPT");
QVERIFY(!result.contains(QStringLiteral("pages")));
const auto pageResult = document.metadata(0, 1);
QCOMPARE(pageResult.value("error").toMap().value("code").toString(), "E_PDF_CORRUPT");
}
void sandboxedWorker_data() {
QTest::addColumn<QString>("input"); QTest::addColumn<bool>("broken");
QTest::newRow("valid") << fixture() << false;
QTest::newRow("missing-first-page") << QFINDTESTDATA("fixtures/pdf/missing-first-page.pdf") << true;
}
void sandboxedWorker() {
QFETCH(QString, input); QFETCH(bool, broken);
FontBroker fonts;
WorkerProcess worker("pdf-test-session", 1);
int fontMaps = 0, fontReads = 0;
worker.setFontRequestHandler([&](const QString &operation, const QCborMap &payload, std::function<void(QCborMap)> done) {
if (operation == "font.map") ++fontMaps;
if (operation == "font.read") ++fontReads;
fonts.request(operation, payload, std::move(done));
});
QSignalSpy ready(&worker, &WorkerProcess::ready), failed(&worker, &WorkerProcess::failed);
QVERIFY(worker.start(QCoreApplication::applicationDirPath() + "/docview-pdf-worker", {"--source", input}));
QTRY_VERIFY_WITH_TIMEOUT(!ready.isEmpty() || !failed.isEmpty(), 10000);
QVERIFY2(failed.isEmpty(), failed.isEmpty() ? "" : qPrintable(failed.first()[1].toString()));
QVERIFY(!ready.isEmpty());
QList<QCborMap> responses;
const auto send = [&](const QString &operation, const QCborMap &payload = QCborMap{}) {
worker.request(operation, payload, [&](const QCborMap &response) { responses.append(response); });
};
send("open"); QTRY_COMPARE_WITH_TIMEOUT(responses.size(), 1, 10000);
auto response = responses.takeFirst();
if (broken) {
QCOMPARE(response.value("error").toMap().value("code").toString(), "E_PDF_CORRUPT");
send("pages"); QTRY_COMPARE_WITH_TIMEOUT(responses.size(), 1, 10000);
response = responses.takeFirst();
QCOMPARE(response.value("error").toMap().value("code").toString(), "E_NOT_OPEN");
} else {
QVERIFY2(!response.contains(QStringLiteral("error")), qPrintable(response.value("error").toMap().value("message").toString()));
QCOMPARE(response.value("result").toMap().value("pageCount").toInteger(), 2);
send("render", request()); QTRY_COMPARE_WITH_TIMEOUT(responses.size(), 1, 10000);
response = responses.takeFirst(); QCOMPARE(response.value("result").toMap().value("data").toByteArray().size(), 160000);
QVERIFY(fontMaps > 0); QVERIFY(fontReads > 0); // Real reverse RPC, with no worker font-directory grants.
send("outline", {{QStringLiteral("cursor"), 1}, {QStringLiteral("limit"), 1}}); QTRY_COMPARE_WITH_TIMEOUT(responses.size(), 1, 10000);
response = responses.takeFirst();
auto outline = response.value("result").toMap();
QCOMPARE(outline.value("outlineCount").toInteger(), 2);
QCOMPARE(outline.value("outline").toArray().size(), 1);
QCOMPARE(outline.value("outline").toArray()[0].toMap().value("id").toString(), "outline-1");
QCOMPARE(outline.value("nextOutlineCursor").toInteger(), -1);
QVERIFY(!outline.contains(QStringLiteral("pages")));
send("pages", {{QStringLiteral("cursor"), 1}, {QStringLiteral("limit"), 1}}); QTRY_COMPARE_WITH_TIMEOUT(responses.size(), 1, 10000);
response = responses.takeFirst();
const auto pages = response.value("result").toMap();
QCOMPARE(pages.value("pages").toArray().size(), 1);
QCOMPARE(pages.value("pages").toArray()[0].toMap().value("pageIndex").toInteger(), 1);
QCOMPARE(pages.value("nextPageCursor").toInteger(), -1);
QVERIFY(!pages.contains(QStringLiteral("outline")));
}
worker.stop();
fonts.revoke();
QVERIFY(failed.isEmpty());
}
void sandboxedJapaneseFontBroker() {
FontBroker fonts;
WorkerProcess worker("pdf-japanese-font-test", 1);
int maps = 0, reads = 0, closes = 0; QSet<int> charsets;
worker.setFontRequestHandler([&](const QString &operation, const QCborMap &payload, std::function<void(QCborMap)> done) {
if (operation == "font.map") { ++maps; charsets.insert(int(payload.value("charset").toInteger())); }
if (operation == "font.read") { ++reads; QVERIFY(payload.value("length").toInteger() <= 65536); }
if (operation == "font.close") ++closes;
fonts.request(operation, payload, std::move(done));
});
QSignalSpy ready(&worker, &WorkerProcess::ready), failed(&worker, &WorkerProcess::failed);
QVERIFY(worker.start(QCoreApplication::applicationDirPath() + "/docview-pdf-worker",
{"--source", QFINDTESTDATA("fixtures/pdf/fonts/unembedded-japanese.pdf")}));
QTRY_VERIFY_WITH_TIMEOUT(!ready.isEmpty() || !failed.isEmpty(), 10000);
QVERIFY2(failed.isEmpty(), failed.isEmpty() ? "" : qPrintable(failed.first()[1].toString()));
QList<QCborMap> replies;
const auto send = [&](const QString &operation, const QCborMap &payload = QCborMap{}) {
// Large CJK collections require many 64 KiB reverse-RPC reads.
// Keep the real event loop running instead of adding QTRY's sleep
// between chunks. The operation still has the same 15 s deadline.
QEventLoop loop;
QPointer<QEventLoop> waiting(&loop);
QTimer deadline;
deadline.setSingleShot(true);
connect(&deadline, &QTimer::timeout, &loop, &QEventLoop::quit);
worker.request(operation, payload, [&, waiting](const QCborMap &reply) {
replies.append(reply);
if (waiting) waiting->quit();
});
if (replies.isEmpty()) {
deadline.start(15000);
loop.exec();
}
return replies.size() == 1;
};
QVERIFY(send("open"));
auto reply = replies.takeFirst(); QVERIFY2(!reply.contains(QStringLiteral("error")), qPrintable(reply.value("error").toMap().value("message").toString()));
QCOMPARE(reply.value("result").toMap().value("pageCount").toInteger(), 2);
for (int page = 0; page < 2; ++page) {
auto render = request(0, 0, 80, 514, 514); render.insert(QStringLiteral("page"), page);
QVERIFY(send("render", render));
reply = replies.takeFirst(); QVERIFY2(!reply.contains(QStringLiteral("error")), qPrintable(reply.value("error").toMap().value("message").toString()));
const auto pixels = reply.value("result").toMap().value("data").toByteArray(); QCOMPARE(pixels.size(), 514 * 514 * 4);
QVERIFY(std::any_of(pixels.cbegin(), pixels.cend(), [](char c) { return static_cast<uchar>(c) < 128; }));
QVERIFY(send("search", {{QStringLiteral("page"), page}, {QStringLiteral("query"), QStringLiteral("日本語")}}));
reply = replies.takeFirst();
QVERIFY(!reply.contains(QStringLiteral("error")));
const auto matches = reply.value("result").toMap().value("matches").toArray();
QCOMPARE(matches.size(), 2); // Mincho and Gothic, horizontal then native vertical page.
for (const auto &match : matches) QVERIFY(!match.toMap().value("rects").toArray().empty());
}
QVERIFY(maps > 0); QVERIFY(reads > 1); QVERIFY(closes > 0); QVERIFY(charsets.contains(128));
worker.stop(); fonts.revoke(); QVERIFY(failed.isEmpty());
}
void fontFailureCannotSucceedThroughBuiltInFallback_data() {
QTest::addColumn<QString>("errorCode");
QTest::newRow("broker-timeout") << QString("E_WORKER_TIMEOUT");
QTest::newRow("invalid-font") << QString("E_FONT_FAILED");
}
void fontFailureCannotSucceedThroughBuiltInFallback() {
QFETCH(QString, errorCode); int calls = 0;
PdfDocument document([&](const QString &, const QCborMap &, int) -> QCborMap {
++calls; return {{QStringLiteral("error"), QCborMap{{QStringLiteral("code"), errorCode}, {QStringLiteral("message"), QStringLiteral("fixture font failure")}}}};
});
QString code, error; QVERIFY(document.open(fixture(), {}, &code, &error));
const auto result = document.render(request(), &code, &error);
QVERIFY(calls > 0); QVERIFY(result.empty()); QCOMPARE(code, errorCode);
QCOMPARE(document.fontError().value("code").toString(), errorCode);
const int previous = calls;
QVERIFY(document.render(request(), &code, &error).empty()); QCOMPARE(code, errorCode); QCOMPARE(calls, previous);
}
void encryptedFormStructureUsesTheBorrowedSource() {
QTemporaryDir directory; QVERIFY(directory.isValid());
const auto qpdf = QStandardPaths::findExecutable("qpdf");
if (qpdf.isEmpty()) QSKIP("The qpdf CLI is required to generate this temporary encrypted fixture.");
const auto path = directory.filePath("encrypted-form.pdf");
QCOMPARE(QProcess::execute(qpdf, {"--encrypt", "reader", "owner-test", "256", "--",
QFINDTESTDATA("fixtures/pdf/headings/form-only-structure.pdf"), path}), 0);
QFile file(path); QVERIFY(file.open(QIODevice::ReadOnly));
const auto source = file.readAll(); PdfDocument document; QString code, error;
QVERIFY(!document.openFile(&file, "wrong", &code, &error)); QCOMPARE(code, "E_PASSWORD_INVALID");
QVERIFY2(document.openFile(&file, "reader", &code, &error), qPrintable(error));
const auto result = document.headings(0); QVERIFY(!result.value("structureLimited").toBool());
const auto items = result.value("headings").toArray(); QCOMPARE(items.size(), 1);
QCOMPARE(items.first().toMap().value("title").toString(), "Form only heading");
QCOMPARE(items.first().toMap().value("precision").toString(), "exact");
QVERIFY(items.first().toMap().value("y").toDouble() > 130);
document.close(); QVERIFY(file.isOpen()); QVERIFY(file.seek(0)); QCOMPARE(file.readAll(), source);
}
void failedMetadataPageWalkCannotShiftPageIndices() {
QFile source(QFINDTESTDATA("fixtures/pdf/headings/context-deep-tree.pdf")); QVERIFY(source.open(QIODevice::ReadOnly));
PdfMetadataContext context; QString code, error; QVERIFY(context.open(&source, {}, 2, &code, &error));
for (int retry=0;retry<3;++retry) {
context.beginOperation(); bool rejected=false;
try { auto page=context.page(0); Q_UNUSED(page); } catch(const PdfMetadataLimit &) { rejected=true; }
QVERIFY(rejected); // The valid second page must never become page zero.
}
}
void malformedMetadataPageTreeIsNotRepairedBeforeValidation() {
QFile source(QFINDTESTDATA("fixtures/pdf/missing-first-page.pdf")); QVERIFY(source.open(QIODevice::ReadOnly));
const auto original = source.readAll();
PdfMetadataContext context; QString code, error; QVERIFY(context.open(&source, {}, 2, &code, &error));
// Setting qpdf's warning limit before processInputSource implicitly
// repairs this tree in qpdf 12.4.1. The raw null must remain visible.
auto kids = context.root().getKey("/Pages").getKey("/Kids");
QCOMPARE(kids.getArrayNItems(), 2); QVERIFY(kids.getArrayItem(0).isNull());
for (int retry=0;retry<3;++retry) {
context.beginOperation(); bool rejected=false;
try { auto page=context.page(0); Q_UNUSED(page); } catch(const PdfMetadataInvalid &) { rejected=true; }
QVERIFY(rejected);
}
QVERIFY(source.seek(0)); QCOMPARE(source.readAll(), original);
}
void metadataWarningsRemainBounded() {
for (int count : {32, 33}) {
QFile source(QFINDTESTDATA(qPrintable(QString("fixtures/pdf/headings/context-warnings-%1.pdf").arg(count))));
QVERIFY(source.open(QIODevice::ReadOnly));
PdfMetadataContext context; QString code, error;
QVERIFY(context.open(&source, {}, 1, &code, &error));
auto probes = context.root().getKey("/Probe");
bool limited = false;
try {
for (int i=0; i<probes.getArrayNItems(); ++i) { auto value = probes.getArrayItem(i).getKey("/Duplicate"); Q_UNUSED(value); context.check(); }
} catch (const PdfMetadataLimit &) { limited = true; }
QCOMPARE(limited, count == 33);
if (!limited) QVERIFY(context.page(0).getKey("/Type").isNameAndEquals("/Page"));
else {
context.beginOperation(); bool stillLimited = false;
try { context.check(); } catch (const PdfMetadataLimit &) { stillLimited = true; }
QVERIFY(stillLimited);
}
}
QFile source(QFINDTESTDATA("fixtures/pdf/headings/context-warning-bytes.pdf")); QVERIFY(source.open(QIODevice::ReadOnly));
PdfMetadataContext context; QString code, error;
QVERIFY(!context.open(&source, {}, 1, &code, &error)); QCOMPARE(code, "E_LIMIT_EXCEEDED");
}
void sandboxedFormStructure_data() {
QTest::addColumn<QString>("name"); QTest::addColumn<QString>("precision");
QTest::newRow("form-only") << "form-only-structure.pdf" << "exact";
QTest::newRow("nested") << "nested-form-matrix.pdf" << "exact";
QTest::newRow("ambiguous") << "ambiguous-form-matrix.pdf" << "page";
QTest::newRow("repeated") << "repeated-form.pdf" << "page";
QTest::newRow("quota") << "stream-quota-over-limit.pdf" << "limited";
QTest::newRow("partial-response") << "bounded-heading-response.pdf" << "partial";
}
void sandboxedFormStructure() {
QFETCH(QString, name); QFETCH(QString, precision);
const auto directory = QFileInfo(QFINDTESTDATA("fixtures/pdf/headings/manifest.json")).absoluteDir();
QFile source(directory.filePath(name)); QVERIFY(source.open(QIODevice::ReadOnly));
const auto before = QCryptographicHash::hash(source.readAll(), QCryptographicHash::Sha256);
FontBroker fonts; WorkerProcess worker("structure-worker-test", 1);
worker.setFontRequestHandler([&](const QString &op, const QCborMap &payload, std::function<void(QCborMap)> done) { fonts.request(op, payload, std::move(done)); });
QSignalSpy ready(&worker, &WorkerProcess::ready), failed(&worker, &WorkerProcess::failed);
QVERIFY(worker.start(QCoreApplication::applicationDirPath()+"/docview-pdf-worker", {"--source", source.fileName()}));
QTRY_VERIFY_WITH_TIMEOUT(!ready.isEmpty() || !failed.isEmpty(), 10000); QVERIFY(failed.isEmpty());
QList<QCborMap> replies;
worker.request("open", {}, [&](const QCborMap &reply) { replies.append(reply); });
QTRY_COMPARE_WITH_TIMEOUT(replies.size(), 1, 10000); QVERIFY2(!replies.takeFirst().contains(QStringLiteral("error")), "open failed");
worker.request("headings", {{QStringLiteral("page"), 0}}, [&](const QCborMap &reply) { replies.append(reply); });
QTRY_COMPARE_WITH_TIMEOUT(replies.size(), 1, 10000);
const auto reply = replies.takeFirst(); QVERIFY(!reply.contains(QStringLiteral("error")));
const auto result = reply.value("result").toMap();
if (precision == "limited" || precision == "partial") {
QVERIFY(result.value("structureLimited").toBool()); QVERIFY(result.value("truncated").toBool());
QVERIFY(!result.value("complete").toBool()); QVERIFY(QCborValue(result).toCbor().size()<=512*1024);
if (precision == "partial") QVERIFY(!result.value("headings").toArray().isEmpty());
}
else {
QVERIFY(!result.value("structureLimited").toBool()); const auto headings = result.value("headings").toArray(); QVERIFY(!headings.isEmpty());
for (const auto &value : headings) { const auto heading=value.toMap(); QCOMPARE(heading.value("precision").toString(), precision); QCOMPARE(heading.value("page").toInteger(), 0); }
}
worker.stop(); fonts.revoke(); QVERIFY(failed.isEmpty());
QVERIFY(source.seek(0)); QCOMPARE(QCryptographicHash::hash(source.readAll(), QCryptographicHash::Sha256), before);
}
void cancellingStructureWorkDiscardsItsCallback() {
FontBroker fonts; WorkerProcess worker("structure-cancel-test", 1);
worker.setFontRequestHandler([&](const QString &op, const QCborMap &payload, std::function<void(QCborMap)> done) { fonts.request(op, payload, std::move(done)); });
QSignalSpy ready(&worker, &WorkerProcess::ready), failed(&worker, &WorkerProcess::failed);
QVERIFY(worker.start(QCoreApplication::applicationDirPath()+"/docview-pdf-worker",
{"--source", QFINDTESTDATA("fixtures/pdf/headings/stream-quota-at-limit.pdf")}));
QTRY_VERIFY_WITH_TIMEOUT(!ready.isEmpty() || !failed.isEmpty(), 10000); QVERIFY(failed.isEmpty());
bool opened=false;
worker.request("open", {}, [&](const QCborMap &reply) { opened=!reply.contains(QStringLiteral("error")); });
QTRY_VERIFY_WITH_TIMEOUT(opened, 10000);
bool delivered=false; QElapsedTimer elapsed; elapsed.start();
worker.request("headings", {{QStringLiteral("page"), 0}}, [&](const QCborMap &) { delivered=true; });
QVERIFY(!worker.idle());
QTimer::singleShot(1, &worker, [&] { worker.stop(); fonts.revoke(); });
QTRY_VERIFY_WITH_TIMEOUT(!worker.isConnected(), 1000);
QVERIFY(elapsed.elapsed()<1000); QTest::qWait(100); QVERIFY(!delivered); QVERIFY(failed.isEmpty());
}
};
QTEST_GUILESS_MAIN(PdfTest)
#include "test_pdf.moc"