#include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #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(text[i].unicode(), bytes.data() + i * 2); return ""; } QString largeMetadataFixture(QTemporaryDir &directory, int pageCount, int outlineCount, const QString &titlePrefix = {}, const QString &label = {}) { QList 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 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 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 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(); 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(self)->bytes.append(static_cast(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(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(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(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(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(); 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(self)->bytes.append(static_cast(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(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(); 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 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 replies; operation="open"; worker.request(operation,{},[&](QCborMap r){replies<0); worker.stop(); fonts.revoke(); QVERIFY(failed.isEmpty()); } void invalidLinkBordersAreExplicit_data() { QTest::addColumn("name"); QTest::addColumn("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("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("links"); QTest::addColumn("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 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(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(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::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("input"); QTest::addColumn("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 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 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 charsets; worker.setFontRequestHandler([&](const QString &operation, const QCborMap &payload, std::function 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 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 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(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("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("name"); QTest::addColumn("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 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 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 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"