Files
pdf/engine/tests/document_test.cpp
T
2026-06-02 11:16:49 +05:30

1041 lines
38 KiB
C++

#include <gtest/gtest.h>
#include <pdfengine/pdf_document.hpp>
#include <pdfengine/pdf_engine.hpp>
#include "parser/pdfium_document.hpp"
#include "fonts/pdf_fonts/encoding/cjk_collection_db.hpp"
#include <filesystem>
#include <fstream>
#include <vector>
#include <string>
#include <thread>
#include <atomic>
#include <chrono>
#include "fonts/cache/glyph_cache.hpp"
#ifndef TEST_CORPUS_DIR
#define TEST_CORPUS_DIR "../../corpus"
#endif
namespace {
#define SKIP_IF_NO_PDFIUM() \
if (!pdfengine::engineHasPdfium()) { \
GTEST_SKIP() << "Skipping PDFium tests because PDFium is not linked."; \
}
std::filesystem::path getCorpusPath(const std::string& subfolder, const std::string& filename) {
return std::filesystem::path(TEST_CORPUS_DIR) / subfolder / filename;
}
std::vector<uint8_t> readFile(const std::filesystem::path& path) {
std::ifstream file(path, std::ios::binary | std::ios::ate);
if (!file.is_open()) {
return {};
}
std::streamsize size = file.tellg();
file.seekg(0, std::ios::beg);
std::vector<uint8_t> buffer(size);
if (file.read(reinterpret_cast<char*>(buffer.data()), size)) {
return buffer;
}
return {};
}
const std::vector<std::string> corpus_basic = {
"about_blank.pdf", "black.pdf", "clip_path.pdf", "dashed_lines.pdf",
"hello_world.pdf", "hello_world_2_pages.pdf", "many_rectangles.pdf",
"rectangles.pdf", "rectangles_multi_pages.pdf", "whitespace.pdf"
};
const std::vector<std::string> corpus_fonts = {
"latin_extended.pdf", "rotated_text.pdf", "rotated_text_90.pdf",
"text_font.pdf", "utf-8.pdf", "vertical_text.pdf", "hebrew_mirrored.pdf"
};
const std::vector<std::string> corpus_edge = {
"annots.pdf", "bookmarks.pdf", "combobox_form.pdf", "embedded_attachments.pdf",
"empty_xref.pdf", "encrypted.pdf", "linearized.pdf", "listbox_form.pdf",
"no_page_count.pdf", "page_labels.pdf", "text_form.pdf", "unsupported_feature.pdf",
"zero_length_stream.pdf"
};
}
namespace pdfengine {
TEST(DocumentLoadTest, NonExistentFileReturnsFileNotFound) {
SKIP_IF_NO_PDFIUM();
auto result = PdfDocument::loadFromFile("nonexistent_file_12345.pdf");
ASSERT_FALSE(result.has_value());
EXPECT_EQ(result.error(), EngineError::FileNotFound);
}
TEST(DocumentLoadTest, InvalidFileReturnsInvalidFormat) {
SKIP_IF_NO_PDFIUM();
std::string path = "invalid_format_test.pdf";
{
std::ofstream out(path, std::ios::binary);
out << "NOT A PDF FILE!";
}
auto result = PdfDocument::loadFromFile(path);
std::filesystem::remove(path);
ASSERT_FALSE(result.has_value());
EXPECT_EQ(result.error(), EngineError::InvalidFormat);
}
TEST(DocumentLoadTest, EncryptedPdfRequiresPassword) {
SKIP_IF_NO_PDFIUM();
auto path = getCorpusPath("edge-cases", "encrypted.pdf");
if (!std::filesystem::exists(path)) {
GTEST_SKIP() << "encrypted.pdf not found in corpus.";
}
auto result = PdfDocument::loadFromFile(path.string());
ASSERT_FALSE(result.has_value());
EXPECT_EQ(result.error(), EngineError::PasswordRequired);
}
TEST(DocumentLoadTest, EncryptedPdfInvalidPassword) {
SKIP_IF_NO_PDFIUM();
auto path = getCorpusPath("edge-cases", "encrypted.pdf");
if (!std::filesystem::exists(path)) {
GTEST_SKIP() << "encrypted.pdf not found in corpus.";
}
auto result = PdfDocument::loadFromFile(path.string(), "wrong_password");
ASSERT_FALSE(result.has_value());
EXPECT_EQ(result.error(), EngineError::InvalidPassword);
}
TEST(DocumentLoadTest, EncryptedPdfCorrectPassword) {
SKIP_IF_NO_PDFIUM();
auto path = getCorpusPath("edge-cases", "encrypted.pdf");
if (!std::filesystem::exists(path)) {
GTEST_SKIP() << "encrypted.pdf not found in corpus.";
}
std::vector<std::string> candidates = {"tessy", "test", "password", "123456", "1234", "foobar", "user", "owner", ""};
bool success = false;
for (const auto& pw : candidates) {
auto result = PdfDocument::loadFromFile(path.string(), pw);
if (result.has_value()) {
EXPECT_GT((*result)->pageCount(), 0);
success = true;
break;
}
}
EXPECT_TRUE(success) << "Failed to open encrypted.pdf with any of the candidate passwords.";
}
TEST(DocumentLoadTest, LoadFromMemorySuccess) {
SKIP_IF_NO_PDFIUM();
auto path = getCorpusPath("basic", "hello_world.pdf");
if (!std::filesystem::exists(path)) {
GTEST_SKIP() << "hello_world.pdf not found in corpus.";
}
auto buffer = readFile(path);
ASSERT_FALSE(buffer.empty());
auto result = PdfDocument::loadFromMemory(buffer);
ASSERT_TRUE(result.has_value());
EXPECT_EQ((*result)->pageCount(), 1);
}
TEST(DocumentLoadTest, LoadFromMemoryEmptyBuffer) {
SKIP_IF_NO_PDFIUM();
std::vector<uint8_t> empty_buf;
auto result = PdfDocument::loadFromMemory(empty_buf);
ASSERT_FALSE(result.has_value());
EXPECT_EQ(result.error(), EngineError::InvalidFormat);
}
TEST(PageCountTest, CorpusPageCounts) {
SKIP_IF_NO_PDFIUM();
struct ExpectedPageCount {
std::string folder;
std::string file;
int count;
};
std::vector<ExpectedPageCount> targets = {
{"basic", "about_blank.pdf", 1},
{"basic", "black.pdf", 1},
{"basic", "hello_world.pdf", 1},
{"basic", "hello_world_2_pages.pdf", 2},
{"basic", "rectangles_multi_pages.pdf", 5}
};
for (const auto& t : targets) {
auto path = getCorpusPath(t.folder, t.file);
if (!std::filesystem::exists(path)) {
continue;
}
auto docRes = PdfDocument::loadFromFile(path.string());
ASSERT_TRUE(docRes.has_value()) << "Failed to open " << t.file;
EXPECT_EQ((*docRes)->pageCount(), t.count) << "Mismatch for " << t.file;
}
auto verifyCorpusFolder = [](const std::string& folder, const std::vector<std::string>& files) {
for (const auto& f : files) {
auto path = getCorpusPath(folder, f);
if (!std::filesystem::exists(path)) {
ADD_FAILURE() << "Missing corpus file: " << path.string();
continue;
}
std::vector<std::string> passwords = {""};
if (f == "encrypted.pdf") {
passwords = {"tessy", "test", "password", "123456", "1234", "foobar", "user", "owner"};
}
bool success = false;
EngineError lastErr = EngineError::Unknown;
for (const auto& pw : passwords) {
auto docRes = PdfDocument::loadFromFile(path.string(), pw);
if (docRes.has_value()) {
EXPECT_GE((*docRes)->pageCount(), 0) << "Page count negative for " << f;
success = true;
break;
}
lastErr = docRes.error();
}
EXPECT_TRUE(success) << "Failed to open " << f << " error: " << static_cast<int>(lastErr);
}
};
verifyCorpusFolder("basic", corpus_basic);
verifyCorpusFolder("fonts", corpus_fonts);
verifyCorpusFolder("edge-cases", corpus_edge);
}
TEST(PageRenderTest, RenderAtDifferentDPI) {
SKIP_IF_NO_PDFIUM();
auto path = getCorpusPath("basic", "hello_world.pdf");
if (!std::filesystem::exists(path)) {
GTEST_SKIP() << "hello_world.pdf not found in corpus.";
}
auto docRes = PdfDocument::loadFromFile(path.string());
ASSERT_TRUE(docRes.has_value());
auto pageRes = (*docRes)->getPage(0);
ASSERT_TRUE(pageRes.has_value());
auto page = *pageRes;
double ptWidth = page->width();
double ptHeight = page->height();
EXPECT_GT(ptWidth, 0.0);
EXPECT_GT(ptHeight, 0.0);
std::vector<int> dpis = {72, 144, 288};
for (int dpi : dpis) {
auto imgRes = page->render(dpi);
ASSERT_TRUE(imgRes.has_value()) << "Failed to render at DPI " << dpi;
double scale = dpi / 72.0;
int expectedW = static_cast<int>(ptWidth * scale);
int expectedH = static_cast<int>(ptHeight * scale);
EXPECT_EQ(imgRes->width, expectedW);
EXPECT_EQ(imgRes->height, expectedH);
ASSERT_GE(imgRes->data.size(), 8U);
EXPECT_EQ(imgRes->data[0], 0x89);
EXPECT_EQ(imgRes->data[1], 'P');
EXPECT_EQ(imgRes->data[2], 'N');
EXPECT_EQ(imgRes->data[3], 'G');
EXPECT_EQ(imgRes->data[4], 0x0D);
EXPECT_EQ(imgRes->data[5], 0x0A);
EXPECT_EQ(imgRes->data[6], 0x1A);
EXPECT_EQ(imgRes->data[7], 0x0A);
}
}
TEST(PageRenderTest, InvalidPageIndexReturnsPageOutOfBounds) {
SKIP_IF_NO_PDFIUM();
auto path = getCorpusPath("basic", "hello_world.pdf");
if (!std::filesystem::exists(path)) {
GTEST_SKIP() << "hello_world.pdf not found in corpus.";
}
auto docRes = PdfDocument::loadFromFile(path.string());
ASSERT_TRUE(docRes.has_value());
auto pageRes = (*docRes)->getPage(1); // Page index 1 is out of bounds for 1-page doc
ASSERT_FALSE(pageRes.has_value());
EXPECT_EQ(pageRes.error(), EngineError::PageOutOfBounds);
auto pageResNeg = (*docRes)->getPage(-1);
ASSERT_FALSE(pageResNeg.has_value());
EXPECT_EQ(pageResNeg.error(), EngineError::PageOutOfBounds);
}
TEST(CoordinateTransformTest, PageToDeviceAndBackRoundtrip) {
SKIP_IF_NO_PDFIUM();
auto path = getCorpusPath("basic", "hello_world.pdf");
if (!std::filesystem::exists(path)) {
GTEST_SKIP() << "hello_world.pdf not found in corpus.";
}
auto docRes = PdfDocument::loadFromFile(path.string());
ASSERT_TRUE(docRes.has_value());
auto pageRes = (*docRes)->getPage(0);
ASSERT_TRUE(pageRes.has_value());
auto page = *pageRes;
int deviceW = 1024;
int deviceH = 768;
std::vector<int> rotations = {0, 90, 180, 270};
std::vector<Point2D> testPoints = {
{0.0, 0.0},
{50.0, 50.0},
{100.0, 200.0},
{page->width() / 2.0, page->height() / 2.0},
{page->width() - 10.0, page->height() - 10.0}
};
for (int rotation : rotations) {
for (const auto& pt : testPoints) {
auto devPt = page->pageToDevice(pt, deviceW, deviceH, rotation);
auto pagePt = page->deviceToPage(devPt, deviceW, deviceH, rotation);
EXPECT_NEAR(pt.x, pagePt.x, 2.0) << "Failed roundtrip for x at rotation " << rotation;
EXPECT_NEAR(pt.y, pagePt.y, 2.0) << "Failed roundtrip for y at rotation " << rotation;
}
}
}
TEST(TextExtractionTest, ExtractSimpleText) {
SKIP_IF_NO_PDFIUM();
auto path = getCorpusPath("basic", "hello_world.pdf");
if (!std::filesystem::exists(path)) {
GTEST_SKIP() << "hello_world.pdf not found in corpus.";
}
auto docRes = PdfDocument::loadFromFile(path.string());
ASSERT_TRUE(docRes.has_value());
auto pageRes = (*docRes)->getPage(0);
ASSERT_TRUE(pageRes.has_value());
auto textRes = (*pageRes)->extractText();
ASSERT_TRUE(textRes.has_value());
std::string text = *textRes;
EXPECT_NE(text.find("Hello"), std::string::npos);
EXPECT_NE(text.find("world"), std::string::npos);
}
TEST(TextExtractionTest, ExtractUtf8ExtendedText) {
SKIP_IF_NO_PDFIUM();
auto path = getCorpusPath("fonts", "latin_extended.pdf");
if (!std::filesystem::exists(path)) {
GTEST_SKIP() << "latin_extended.pdf not found in corpus.";
}
auto docRes = PdfDocument::loadFromFile(path.string());
ASSERT_TRUE(docRes.has_value());
auto pageRes = (*docRes)->getPage(0);
ASSERT_TRUE(pageRes.has_value());
auto textRes = (*pageRes)->extractText();
ASSERT_TRUE(textRes.has_value());
std::string text = *textRes;
EXPECT_FALSE(text.empty());
}
TEST(TextExtractionTest, ExtractTextWithBounds) {
SKIP_IF_NO_PDFIUM();
auto path = getCorpusPath("basic", "hello_world.pdf");
if (!std::filesystem::exists(path)) {
GTEST_SKIP() << "hello_world.pdf not found in corpus.";
}
auto docRes = PdfDocument::loadFromFile(path.string());
ASSERT_TRUE(docRes.has_value());
auto pageRes = (*docRes)->getPage(0);
ASSERT_TRUE(pageRes.has_value());
auto boundsRes = (*pageRes)->extractTextWithBounds();
ASSERT_TRUE(boundsRes.has_value());
const auto& glyphs = *boundsRes;
ASSERT_FALSE(glyphs.empty());
bool found_hello = false;
for (const auto& glyph : glyphs) {
EXPECT_FALSE(glyph.text.empty());
if (glyph.text != " " && glyph.text != "\r" && glyph.text != "\n" && glyph.text != "\t") {
EXPECT_GT(glyph.w, 0.0);
EXPECT_GT(glyph.h, 0.0);
}
EXPECT_GT(glyph.fontSize, 0.0);
if (glyph.text == "H" || glyph.text == "e" || glyph.text == "l" || glyph.text == "o") {
found_hello = true;
}
}
EXPECT_TRUE(found_hello);
}
TEST(DocumentEditTest, ApplyEditsAndIncrementalSave) {
SKIP_IF_NO_PDFIUM();
auto path = getCorpusPath("basic", "hello_world.pdf");
if (!std::filesystem::exists(path)) {
GTEST_SKIP() << "hello_world.pdf not found in corpus.";
}
auto docRes = PdfDocument::loadFromFile(path.string());
ASSERT_TRUE(docRes.has_value());
auto doc = *docRes;
std::string editsJson = R"({
"operations": [
{
"type": "add_text",
"pageIndex": 0,
"data": {
"text": "UniqueEditedTextAnnotation123",
"x": 100.0,
"y": 150.0,
"fontSize": 14.0
}
}
]
})";
auto editRes = doc->applyEdits(editsJson);
ASSERT_TRUE(editRes.has_value());
auto saveRes = doc->saveIncremental();
ASSERT_TRUE(saveRes.has_value());
const auto& savedBytes = *saveRes;
ASSERT_FALSE(savedBytes.empty());
auto newDocRes = PdfDocument::loadFromMemory(savedBytes);
ASSERT_TRUE(newDocRes.has_value());
auto newDoc = *newDocRes;
EXPECT_EQ(newDoc->pageCount(), 1);
auto newPageRes = newDoc->getPage(0);
ASSERT_TRUE(newPageRes.has_value());
auto newPage = *newPageRes;
auto annotsRes = newPage->extractAnnotationsText();
ASSERT_TRUE(annotsRes.has_value());
bool found = false;
for (const auto& annotText : *annotsRes) {
if (annotText.find("UniqueEditedTextAnnotation123") != std::string::npos) {
found = true;
break;
}
}
EXPECT_TRUE(found);
}
TEST(FontDiagnosticsTest, IntrospectionAccuracy) {
SKIP_IF_NO_PDFIUM();
auto path = getCorpusPath("basic", "hello_world.pdf");
if (!std::filesystem::exists(path)) {
GTEST_SKIP() << "hello_world.pdf not found in corpus.";
}
auto docRes = PdfDocument::loadFromFile(path.string());
ASSERT_TRUE(docRes.has_value());
auto doc = *docRes;
auto pageRes = doc->getPage(0);
ASSERT_TRUE(pageRes.has_value());
auto page = *pageRes;
auto fontsRes = page->getFonts();
ASSERT_TRUE(fontsRes.has_value());
auto fonts = *fontsRes;
if (!fonts.empty()) {
auto firstFont = fonts[0];
EXPECT_FALSE(firstFont.fontName.empty());
EXPECT_FALSE(firstFont.type.empty());
EXPECT_FALSE(firstFont.normalizedFamily.empty());
EXPECT_FALSE(firstFont.internalFontId.empty());
}
auto docFontsRes = doc->getFonts();
ASSERT_TRUE(docFontsRes.has_value());
auto docFonts = *docFontsRes;
EXPECT_EQ(docFonts.size(), fonts.size());
}
TEST(FontDiagnosticsTest, SubsetAndVerticalTextIntrospection) {
SKIP_IF_NO_PDFIUM();
auto path = getCorpusPath("fonts", "vertical_text.pdf");
if (!std::filesystem::exists(path)) {
path = getCorpusPath("fonts", "utf-8.pdf");
}
if (!std::filesystem::exists(path)) {
GTEST_SKIP() << "vertical_text.pdf or utf-8.pdf not found in corpus.";
}
auto docRes = PdfDocument::loadFromFile(path.string());
ASSERT_TRUE(docRes.has_value());
auto doc = *docRes;
auto fontsRes = doc->getFonts();
ASSERT_TRUE(fontsRes.has_value());
for (const auto& f : *fontsRes) {
if (f.isSubset) {
EXPECT_FALSE(f.subsetTag.empty());
EXPECT_EQ(f.subsetTag.size(), 6);
}
if (f.isVertical) {
EXPECT_TRUE(f.isVertical);
EXPECT_NE(f.encoding.find("Identity-V"), std::string::npos);
}
}
}
TEST(FontDiagnosticsTest, CacheInvalidationAfterEdits) {
SKIP_IF_NO_PDFIUM();
auto path = getCorpusPath("basic", "hello_world.pdf");
if (!std::filesystem::exists(path)) {
GTEST_SKIP() << "hello_world.pdf not found in corpus.";
}
auto docRes = PdfDocument::loadFromFile(path.string());
ASSERT_TRUE(docRes.has_value());
auto doc = *docRes;
auto fontsRes1 = doc->getFonts();
ASSERT_TRUE(fontsRes1.has_value());
std::string editsJson = R"({
"operations": [
{
"type": "add_text",
"pageIndex": 0,
"data": {
"text": "IntrospectionDiagnosticsNewText",
"x": 10.0,
"y": 20.0,
"fontSize": 12.0
}
}
]
})";
auto editRes = doc->applyEdits(editsJson);
ASSERT_TRUE(editRes.has_value());
auto fontsRes2 = doc->getFonts();
ASSERT_TRUE(fontsRes2.has_value());
}
TEST(FontDiagnosticsTest, ConcurrencyThreadSafety) {
SKIP_IF_NO_PDFIUM();
auto path = getCorpusPath("basic", "hello_world.pdf");
if (!std::filesystem::exists(path)) {
GTEST_SKIP() << "hello_world.pdf not found in corpus.";
}
auto docRes = PdfDocument::loadFromFile(path.string());
ASSERT_TRUE(docRes.has_value());
auto doc = *docRes;
std::vector<std::thread> threads;
for (int i = 0; i < 8; ++i) {
threads.emplace_back([&doc]() {
auto res = doc->getFonts();
ASSERT_TRUE(res.has_value());
});
}
for (auto& t : threads) {
t.join();
}
}
TEST(FontDiagnosticsTest, DeepIntrospectionAndFontSizeVerification) {
SKIP_IF_NO_PDFIUM();
// Test Part 1: Introspection & Metadata verification using utf-8.pdf
{
auto path = getCorpusPath("fonts", "utf-8.pdf");
if (std::filesystem::exists(path)) {
auto docRes = PdfDocument::loadFromFile(path.string());
ASSERT_TRUE(docRes.has_value());
auto doc = *docRes;
auto fontsRes = doc->getFonts();
ASSERT_TRUE(fontsRes.has_value());
auto fonts = *fontsRes;
for (const auto& f : fonts) {
// Core fields
EXPECT_FALSE(f.fontName.empty());
EXPECT_FALSE(f.type.empty());
EXPECT_FALSE(f.normalizedFamily.empty());
EXPECT_FALSE(f.internalFontId.empty());
// Subset tagging consistency
if (f.isSubset) {
EXPECT_EQ(f.subsetTag.size(), 6);
for (char c : f.subsetTag) {
EXPECT_TRUE(std::isupper(static_cast<unsigned char>(c)));
}
EXPECT_EQ(f.sourceType, "Embedded");
EXPECT_TRUE(f.isEmbedded);
EXPECT_EQ(f.internalFontId, f.subsetTag + "_" + f.fontName);
} else {
EXPECT_TRUE(f.subsetTag.empty());
EXPECT_EQ(f.internalFontId, f.fontName + "_" + f.type + "_" + std::to_string(f.flags));
}
// Source Type / Fallbacks & Substitutions consistency
if (f.sourceType == "SystemFallback") {
EXPECT_FALSE(f.isEmbedded);
EXPECT_TRUE(f.substitutedFrom.empty());
EXPECT_TRUE(f.substitutedTo.empty());
} else if (f.sourceType == "Substituted") {
EXPECT_FALSE(f.isEmbedded);
EXPECT_EQ(f.substitutedFrom, f.fontName);
#if defined(_WIN32)
EXPECT_EQ(f.substitutedTo, "Arial");
#else
EXPECT_EQ(f.substitutedTo, "Liberation Sans");
#endif
}
// Check descriptor metrics are non-zero / reasonably set
EXPECT_GT(f.ascent, 0.0);
EXPECT_LT(f.descent, 0.0);
EXPECT_GT(f.capHeight, 0.0);
}
}
}
// Test Part 2: Vertical Writing Mode Detection
{
auto path = getCorpusPath("fonts", "vertical_text.pdf");
if (std::filesystem::exists(path)) {
auto docRes = PdfDocument::loadFromFile(path.string());
ASSERT_TRUE(docRes.has_value());
auto doc = *docRes;
auto fontsRes = doc->getFonts();
ASSERT_TRUE(fontsRes.has_value());
bool foundVertical = false;
for (const auto& f : *fontsRes) {
if (f.isVertical) {
foundVertical = true;
EXPECT_TRUE(f.encoding.find("-V") != std::string::npos || f.cmapName.find("-V") != std::string::npos);
}
}
// Ensure at least one vertical font is found in vertical_text.pdf
EXPECT_TRUE(foundVertical);
}
}
// Test Part 2b: Vertical Font Detection Heuristic explicit validation
{
auto path1 = getCorpusPath("fonts", "vertical_identity_v.pdf");
if (std::filesystem::exists(path1)) {
auto docRes = PdfDocument::loadFromFile(path1.string());
ASSERT_TRUE(docRes.has_value());
auto fontsRes = (*docRes)->getFonts();
ASSERT_TRUE(fontsRes.has_value());
bool foundVertical = false;
for (const auto& f : *fontsRes) {
if (f.isVertical) foundVertical = true;
}
EXPECT_TRUE(foundVertical) << "Failed to detect vertical font in vertical_identity_v.pdf";
}
}
// Test Part 3: Font Size and Glyph Bounds Handling
{
auto path = getCorpusPath("fonts", "utf-8.pdf");
if (!std::filesystem::exists(path)) {
path = getCorpusPath("basic", "hello_world.pdf");
}
if (std::filesystem::exists(path)) {
auto docRes = PdfDocument::loadFromFile(path.string());
ASSERT_TRUE(docRes.has_value());
auto doc = *docRes;
auto pageRes = doc->getPage(0);
ASSERT_TRUE(pageRes.has_value());
auto page = *pageRes;
auto boundsRes = page->extractTextWithBounds();
ASSERT_TRUE(boundsRes.has_value());
const auto& glyphs = *boundsRes;
ASSERT_FALSE(glyphs.empty());
std::vector<double> uniqueSizes;
for (const auto& glyph : glyphs) {
// Ensure glyph bounding box and font sizes are valid positive numbers
if (glyph.text != " " && glyph.text != "\r" && glyph.text != "\n" && glyph.text != "\t") {
EXPECT_GT(glyph.w, 0.0);
EXPECT_GT(glyph.h, 0.0);
}
EXPECT_GT(glyph.fontSize, 0.0);
EXPECT_LT(glyph.fontSize, 100.0); // No absurdly large font sizes
if (std::find(uniqueSizes.begin(), uniqueSizes.end(), glyph.fontSize) == uniqueSizes.end()) {
uniqueSizes.push_back(glyph.fontSize);
}
}
// If it's utf-8.pdf, it should have multiple distinct font sizes
if (path.filename().string() == "utf-8.pdf") {
EXPECT_GE(uniqueSizes.size(), 2u);
}
}
}
}
// =========================================================================
// Tests validating real PDFium font dictionary introspection.
// These tests verify that isEmbedded, type, ascent, descent, capHeight, and
// hasToUnicode are now derived from actual PDF font objects rather than from
// font-name heuristics (the behaviour that predated this change).
// =========================================================================
// Verify that embedded fonts report isEmbedded=true and that sourceType is
// set to "Embedded" from the real FPDFFont_GetIsEmbedded() result.
// A subset-embedded font (ABCDEF+FontName prefix) is the clearest case
// because the old heuristic relied solely on the prefix tag for embedding
// detection, while real PDFium checks for the /FontFile stream.
TEST(FontDiagnosticsTest, RealPDFiumEmbeddingAndTypeAccuracy) {
SKIP_IF_NO_PDFIUM();
// text_font.pdf has an embedded subset TrueType font \u2014 best candidate for
// verifying that isEmbedded comes from the PDF font stream, not the name tag.
auto path = getCorpusPath("fonts", "text_font.pdf");
if (!std::filesystem::exists(path)) {
GTEST_SKIP() << "text_font.pdf not found in corpus.";
}
auto docRes = PdfDocument::loadFromFile(path.string());
ASSERT_TRUE(docRes.has_value()) << "Failed to open text_font.pdf";
auto doc = *docRes;
auto pageRes = doc->getPage(0);
ASSERT_TRUE(pageRes.has_value());
auto fontsRes = (*pageRes)->getFonts();
ASSERT_TRUE(fontsRes.has_value());
const auto& fonts = *fontsRes;
ASSERT_FALSE(fonts.empty()) << "text_font.pdf must expose at least one font";
for (const auto& f : fonts) {
// Core invariant: every font must have a non-empty name and type.
EXPECT_FALSE(f.fontName.empty());
EXPECT_FALSE(f.type.empty());
// type must be one of the four valid PDF font subtypes.
static const std::vector<std::string> kValidTypes = {
"Type1", "TrueType", "CIDFontType0", "CIDFontType2"
};
bool typeValid = std::find(kValidTypes.begin(), kValidTypes.end(), f.type)
!= kValidTypes.end();
EXPECT_TRUE(typeValid) << "Unexpected type '" << f.type << "' for font '" << f.fontName << "'";
// Subset-prefixed fonts MUST be reported as embedded by PDFium
// (the /FontFile stream is required by the PDF spec when a subset tag is present).
if (f.isSubset) {
EXPECT_TRUE(f.isEmbedded)
<< "Subset font '" << f.fontName
<< "' must be embedded (FPDFFont_GetIsEmbedded should return 1)";
EXPECT_EQ(f.sourceType, "Embedded")
<< "sourceType must be 'Embedded' when isEmbedded=true";
EXPECT_TRUE(f.substitutedFrom.empty());
EXPECT_TRUE(f.substitutedTo.empty());
}
// isEmbedded=true and sourceType="Embedded" must be consistent.
if (f.isEmbedded) {
EXPECT_EQ(f.sourceType, "Embedded");
}
}
}
// Verify that font descriptor metrics (ascent, descent, capHeight) come from
// the real PDF FontDescriptor via FPDFFont_GetAscent/Descent(), not from the
// hardcoded fallback table. The critical invariant is sign correctness:
// ascent must be positive, descent must be negative.
TEST(FontDiagnosticsTest, RealPDFiumMetricsAccuracy) {
SKIP_IF_NO_PDFIUM();
// Use the largest font corpus file; it contains the most diverse fonts.
auto path = getCorpusPath("fonts", "text_font.pdf");
if (!std::filesystem::exists(path)) {
GTEST_SKIP() << "text_font.pdf not found in corpus.";
}
auto docRes = PdfDocument::loadFromFile(path.string());
ASSERT_TRUE(docRes.has_value());
auto fontsRes = (*docRes)->getFonts();
ASSERT_TRUE(fontsRes.has_value());
for (const auto& f : *fontsRes) {
// ascent and descent from FPDFFont_GetAscent/Descent(font, 1000.0f, …)
// are in PDF 1000-unit space. ascent is above the baseline (positive),
// descent is below (negative).
EXPECT_GT(f.ascent, 0.0)
<< "ascent must be positive for font '" << f.fontName << "'";
EXPECT_LT(f.descent, 0.0)
<< "descent must be negative for font '" << f.fontName << "'";
EXPECT_GT(f.capHeight, 0.0)
<< "capHeight must be positive for font '" << f.fontName << "'";
// capHeight must not exceed ascent (sanity: caps never taller than ascender).
EXPECT_LE(f.capHeight, f.ascent + 1.0) // +1 for float rounding
<< "capHeight should not exceed ascent for font '" << f.fontName << "'";
// Values must be in a plausible PDF 1000-unit-space range.
// Standard fonts typically have ascent in [400, 1200].
EXPECT_LT(f.ascent, 1500.0) << "Implausibly large ascent for '" << f.fontName << "'";
EXPECT_GT(f.descent, -1500.0) << "Implausibly deep descent for '" << f.fontName << "'";
}
}
// Verify that hasToUnicode reflects actual Unicode decode capability rather
// than the old always-true heuristic.
//
// with_tounicode.pdf \u2014 PDF containing a font that has a /ToUnicode stream;
// PDFium should decode characters successfully.
// no_tounicode.pdf \u2014 PDF containing a font with no /ToUnicode stream and no
// standard encoding; PDFium cannot map char codes to Unicode.
// latin_extended.pdf \u2014 Standard Latin font; must decode to Unicode via built-in
// encoding (WinAnsiEncoding or similar).
TEST(FontDiagnosticsTest, ToUnicodePresenceAccuracy) {
SKIP_IF_NO_PDFIUM();
// Case 1: font WITH ToUnicode \u2014 hasToUnicode must be true
{
auto path = getCorpusPath("fonts", "with_tounicode.pdf");
if (std::filesystem::exists(path)) {
auto docRes = PdfDocument::loadFromFile(path.string());
if (docRes.has_value()) {
auto pageRes = (*docRes)->getPage(0);
if (pageRes.has_value()) {
auto fontsRes = (*pageRes)->getFonts();
if (fontsRes.has_value() && !fontsRes->empty()) {
bool anyTrue = false;
for (const auto& f : *fontsRes) {
if (f.hasToUnicode) { anyTrue = true; break; }
}
EXPECT_TRUE(anyTrue)
<< "At least one font in with_tounicode.pdf must have hasToUnicode=true";
}
}
}
}
}
// Case 2: font WITHOUT ToUnicode or decodable encoding \u2014 hasToUnicode must be false
{
auto path = getCorpusPath("fonts", "no_tounicode.pdf");
if (std::filesystem::exists(path)) {
auto docRes = PdfDocument::loadFromFile(path.string());
if (docRes.has_value()) {
auto pageRes = (*docRes)->getPage(0);
if (pageRes.has_value()) {
auto fontsRes = (*pageRes)->getFonts();
if (fontsRes.has_value() && !fontsRes->empty()) {
// All fonts in a no-tounicode document should fail Unicode decode.
for (const auto& f : *fontsRes) {
EXPECT_FALSE(f.hasToUnicode)
<< "Font '" << f.fontName
<< "' in no_tounicode.pdf must have hasToUnicode=false";
}
}
}
}
}
}
// Case 3: standard Latin font \u2014 must decode to Unicode via built-in encoding
{
auto path = getCorpusPath("fonts", "latin_extended.pdf");
if (std::filesystem::exists(path)) {
auto docRes = PdfDocument::loadFromFile(path.string());
if (docRes.has_value()) {
auto pageRes = (*docRes)->getPage(0);
if (pageRes.has_value()) {
auto fontsRes = (*pageRes)->getFonts();
if (fontsRes.has_value() && !fontsRes->empty()) {
bool anyTrue = false;
for (const auto& f : *fontsRes) {
if (f.hasToUnicode) { anyTrue = true; break; }
}
EXPECT_TRUE(anyTrue)
<< "At least one font in latin_extended.pdf must decode to Unicode";
}
}
}
}
}
}
// =========================================================================
// Tests for UTF-16 Surrogate Pairs (Emoji and CJK Ext-B)
// =========================================================================
TEST(UtfConversionTest, EmojiSurrogatePairs) {
// 😀 U+1F600 -> UTF-8: F0 9F 98 80
std::string utf8_grinning = "\xF0\x9F\x98\x80";
auto utf16 = pdfengine::parser::utf8_to_utf16le(utf8_grinning);
// Should be D83D DE00 + null terminator
ASSERT_EQ(utf16.size(), 3);
EXPECT_EQ(utf16[0], 0xD83D);
EXPECT_EQ(utf16[1], 0xDE00);
EXPECT_EQ(utf16[2], 0x0000);
// Convert back to UTF-8
std::string utf8_out = pdfengine::parser::utf16le_to_utf8(reinterpret_cast<const char16_t*>(utf16.data()), utf16.size() - 1);
EXPECT_EQ(utf8_out, utf8_grinning);
// 🚀 U+1F680 -> UTF-8: F0 9F 9A 80
std::string utf8_rocket = "\xF0\x9F\x9A\x80";
utf16 = pdfengine::parser::utf8_to_utf16le(utf8_rocket);
ASSERT_EQ(utf16.size(), 3);
EXPECT_EQ(utf16[0], 0xD83D);
EXPECT_EQ(utf16[1], 0xDE80);
EXPECT_EQ(utf16[2], 0x0000);
utf8_out = pdfengine::parser::utf16le_to_utf8(reinterpret_cast<const char16_t*>(utf16.data()), utf16.size() - 1);
EXPECT_EQ(utf8_out, utf8_rocket);
}
TEST(UtfConversionTest, CJKExtensionB) {
// 𠀀 U+20000 -> UTF-8: F0 A0 80 80
std::string utf8_cjk = "\xF0\xA0\x80\x80";
auto utf16 = pdfengine::parser::utf8_to_utf16le(utf8_cjk);
// Should be D840 DC00 + null terminator
ASSERT_EQ(utf16.size(), 3);
EXPECT_EQ(utf16[0], 0xD840);
EXPECT_EQ(utf16[1], 0xDC00);
EXPECT_EQ(utf16[2], 0x0000);
// Convert back to UTF-8
std::string utf8_out = pdfengine::parser::utf16le_to_utf8(reinterpret_cast<const char16_t*>(utf16.data()), utf16.size() - 1);
EXPECT_EQ(utf8_out, utf8_cjk);
}
TEST(UtfConversionTest, RoundtripMixed) {
// "A😀B𠀀C" -> 41 F0 9F 98 80 42 F0 A0 80 80 43
std::string mixed = "A\xF0\x9F\x98\x80""B\xF0\xA0\x80\x80""C";
auto utf16 = pdfengine::parser::utf8_to_utf16le(mixed);
std::string mixed_out = pdfengine::parser::utf16le_to_utf8(reinterpret_cast<const char16_t*>(utf16.data()), utf16.size() - 1);
EXPECT_EQ(mixed_out, mixed);
}
// =========================================================================
// Tests for CJK CID Resolution
// =========================================================================
TEST(CjkResolutionTest, AdobeCNS1) {
// Basic mapping checks for the core Adobe-CNS1 block (Traditional Chinese)
using pdfengine::fonts::pdf_fonts::CjkCollectionDB;
// Test existing block (100-130)
EXPECT_EQ(CjkCollectionDB::resolveCID("Adobe-CNS1", 100), 0x4E00); // 一
EXPECT_EQ(CjkCollectionDB::resolveCID("Adobe-CNS1", 112), 0x4E2D); // 中
EXPECT_EQ(CjkCollectionDB::resolveCID("Adobe-CNS1", 130), 0x4ED7); // 仗
// Test the newly expanded block (131-140)
EXPECT_EQ(CjkCollectionDB::resolveCID("Adobe-CNS1", 131), 0x4ED8); // 付
EXPECT_EQ(CjkCollectionDB::resolveCID("Adobe-CNS1", 135), 0x4EDF); // 仟
EXPECT_EQ(CjkCollectionDB::resolveCID("Adobe-CNS1", 140), 0x4F01); // 企
// Test alternative naming
EXPECT_EQ(CjkCollectionDB::resolveCID("Identity-H-CNS1", 137), 0x4EE3); // 代
}
TEST(CjkResolutionTest, AdobeKorea1) {
using pdfengine::fonts::pdf_fonts::CjkCollectionDB;
// Test existing Korean block (101-150)
EXPECT_EQ(CjkCollectionDB::resolveCID("Adobe-Korea1", 101), 0xAC00); // 가
EXPECT_EQ(CjkCollectionDB::resolveCID("Adobe-Korea1", 119), 0xAC1C); // 개
EXPECT_EQ(CjkCollectionDB::resolveCID("Adobe-Korea1", 150), 0xAC90); // 겔
// Test newly added Hangul block (151-160)
EXPECT_EQ(CjkCollectionDB::resolveCID("Adobe-Korea1", 151), 0xAC94); // 겝
EXPECT_EQ(CjkCollectionDB::resolveCID("Adobe-Korea1", 156), 0xACA9); // 결
EXPECT_EQ(CjkCollectionDB::resolveCID("Adobe-Korea1", 160), 0xACBD); // 겼
// Test alternative naming
EXPECT_EQ(CjkCollectionDB::resolveCID("UniKS-UTF16-H-Korea1", 153), 0xACA0); // 겠
}
// =========================================================================
// GlyphCache Concurrency & Benchmark Test
// =========================================================================
TEST(GlyphCacheTest, ConcurrencyBench) {
using namespace pdfengine::fonts;
FontFace face;
// Load a common system font for testing cache keys
bool loaded = face.loadFromFile("C:\\Windows\\Fonts\\arial.ttf");
if (!loaded) {
GTEST_SKIP() << "Skipping benchmark: Arial font not found.";
}
GlyphCache cache(1000);
auto run_benchmark = [&](int num_threads, int ops_per_thread) {
std::atomic<int> start_flag{0};
std::vector<std::thread> threads;
for (int i = 0; i < num_threads; ++i) {
threads.emplace_back([&, i]() {
while (start_flag.load() == 0) { std::this_thread::yield(); }
for (int op = 0; op < ops_per_thread; ++op) {
unsigned int glyphIndex = (op + i) % 2000;
unsigned int fontSize = 12 + (op % 5);
auto hit = cache.get(face, glyphIndex, fontSize);
if (!hit) {
GlyphBitmap bmp;
bmp.width = 10; bmp.height = 10;
cache.insert(face, glyphIndex, fontSize, bmp);
}
}
});
}
auto start_time = std::chrono::high_resolution_clock::now();
start_flag.store(1);
for (auto& t : threads) { t.join(); }
auto end_time = std::chrono::high_resolution_clock::now();
std::chrono::duration<double> diff = end_time - start_time;
return diff.count();
};
run_benchmark(2, 1000); // warmup
cache.clear();
double time_10 = run_benchmark(10, 10000);
std::cout << "[ BENCHMARK ] 10 Threads Time: " << time_10 << " seconds (" << (100000.0 / time_10) << " ops/sec)\n";
cache.clear();
double time_50 = run_benchmark(50, 10000);
std::cout << "[ BENCHMARK ] 50 Threads Time: " << time_50 << " seconds (" << (500000.0 / time_50) << " ops/sec)\n";
EXPECT_LE(cache.size(), 1000 + 16); // Accommodate shard capacity rounding
}
}