#include "document_test_helpers.hpp" namespace pdfengine { 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"({ "version": "1.0", "operations": [ { "id": "op_test_1", "type": "text_overlay", "pageIndex": 0, "data": { "text": "UniqueEditedTextAnnotation123", "x": 100.0, "y": 150.0, "width": 200.0, "height": 20.0, "fontSize": 14.0, "fontFamily": "Helvetica", "color": "#000000" } } ] })"; 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 textRes = newPage->extractText(); ASSERT_TRUE(textRes.has_value()); EXPECT_NE(textRes->find("UniqueEditedTextAnnotation123"), std::string::npos); } TEST(DocumentEditTest, ApplyRedactionAndFullSave) { 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 textRes = (*pageRes)->extractText(); ASSERT_TRUE(textRes.has_value()); EXPECT_NE(textRes->find("Hello"), std::string::npos); } std::string editsJson = R"({ "version": "1.0", "operations": [ { "id": "op_redact_test_1", "type": "redaction", "pageIndex": 0, "data": { "x": 0.0, "y": 0.0, "width": 612.0, "height": 792.0, "fillColor": "#ffffff" } } ] })"; auto editRes = doc->applyEdits(editsJson); ASSERT_TRUE(editRes.has_value()); auto saveRes = doc->saveFull(); 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; auto newPageRes = newDoc->getPage(0); ASSERT_TRUE(newPageRes.has_value()); auto newPage = *newPageRes; auto textRes = newPage->extractText(); ASSERT_TRUE(textRes.has_value()); EXPECT_EQ(textRes->find("Hello"), std::string::npos); EXPECT_EQ(textRes->find("world"), std::string::npos); } TEST(DocumentEditTest, ApplyImageOverlayAndIncrementalSave) { 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"({ "version": "1.0", "operations": [ { "id": "op_test_img_1", "type": "image_overlay", "pageIndex": 0, "data": { "x": 100.0, "y": 150.0, "width": 200.0, "height": 150.0, "pixelWidth": 2, "pixelHeight": 2, "rawPixelData": "AAD//wAA//8AAP//AAD//w==" } } ] })"; 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); } TEST(DocumentEditTest, ApplyPageRotationAndIncrementalSave) { SKIP_IF_NO_PDFIUM(); auto path = getCorpusPath("basic", "about_blank.pdf"); if (!std::filesystem::exists(path)) { GTEST_SKIP() << "about_blank.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()); double origW = (*pageRes)->width(); double origH = (*pageRes)->height(); EXPECT_GT(origW, 0.0); EXPECT_GT(origH, origW); std::string editsJson1 = R"({ "version": "1.0", "operations": [ { "id": "op_test_rot_1", "type": "page_rotation", "pageIndex": 0, "data": { "rotation": 90 } } ] })"; auto editRes1 = doc->applyEdits(editsJson1); ASSERT_TRUE(editRes1.has_value()); std::string editsJson2 = R"({ "version": "1.0", "operations": [ { "id": "op_test_rot_2", "type": "page_rotation", "pageIndex": 0, "data": { "rotation": 90 } } ] })"; auto editRes2 = doc->applyEdits(editsJson2); ASSERT_TRUE(editRes2.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()); double rotatedW = (*newPageRes)->width(); double rotatedH = (*newPageRes)->height(); EXPECT_NEAR(rotatedW, origW, 0.01); EXPECT_NEAR(rotatedH, origH, 0.01); std::string editsJson3 = R"({ "version": "1.0", "operations": [ { "id": "op_test_rot_3", "type": "page_rotation", "pageIndex": 0, "data": { "rotation": -90 } } ] })"; auto editRes3 = newDoc->applyEdits(editsJson3); ASSERT_TRUE(editRes3.has_value()); auto saveRes3 = newDoc->saveIncremental(); ASSERT_TRUE(saveRes3.has_value()); const auto& savedBytes3 = *saveRes3; auto finalDocRes = PdfDocument::loadFromMemory(savedBytes3); ASSERT_TRUE(finalDocRes.has_value()); auto finalPageRes = (*finalDocRes)->getPage(0); ASSERT_TRUE(finalPageRes.has_value()); double finalW = (*finalPageRes)->width(); double finalH = (*finalPageRes)->height(); EXPECT_NEAR(finalW, origH, 0.01); EXPECT_NEAR(finalH, origW, 0.01); } TEST(DocumentEditTest, ApplyPageDeletionAndIncrementalSave) { SKIP_IF_NO_PDFIUM(); auto path = getCorpusPath("basic", "hello_world_2_pages.pdf"); if (!std::filesystem::exists(path)) { GTEST_SKIP() << "hello_world_2_pages.pdf not found in corpus."; } auto docRes = PdfDocument::loadFromFile(path.string()); ASSERT_TRUE(docRes.has_value()); auto doc = *docRes; EXPECT_EQ(doc->pageCount(), 2); std::string editsJson = R"({ "version": "1.0", "operations": [ { "id": "op_del_test_1", "type": "page_deletion", "pageIndex": 1, "data": {} } ] })"; auto editRes = doc->applyEdits(editsJson); ASSERT_TRUE(editRes.has_value()); EXPECT_EQ(doc->pageCount(), 1); 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()); EXPECT_EQ((*newDocRes)->pageCount(), 1); } TEST(DocumentEditTest, ApplyPageReorderAndIncrementalSave) { SKIP_IF_NO_PDFIUM(); auto path = getCorpusPath("basic", "hello_world_2_pages.pdf"); if (!std::filesystem::exists(path)) { GTEST_SKIP() << "hello_world_2_pages.pdf not found in corpus."; } auto docRes = PdfDocument::loadFromFile(path.string()); ASSERT_TRUE(docRes.has_value()); auto doc = *docRes; EXPECT_EQ(doc->pageCount(), 2); std::string editsJson = R"({ "version": "1.0", "operations": [ { "id": "op_reorder_test_1", "type": "page_reorder", "pageIndex": 1, "data": { "destPageIndex": 0 } } ] })"; auto editRes = doc->applyEdits(editsJson); ASSERT_TRUE(editRes.has_value()); EXPECT_EQ(doc->pageCount(), 2); 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()); EXPECT_EQ((*newDocRes)->pageCount(), 2); } TEST(DocumentEditTest, ReplaceTextMVPStandardFont) { 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 pageObj = *pageRes; auto modelRes = pageObj->extractDocumentModel(); ASSERT_TRUE(modelRes.has_value()); const auto& model = *modelRes; std::vector objectIndices; for (const auto& p : model.paragraphs) { for (const auto& line : p.lines) { for (const auto& run : line.runs) { if (run.text.find("Hello") != std::string::npos) { objectIndices = run.objectIndices; break; } } if (!objectIndices.empty()) break; } if (!objectIndices.empty()) break; } ASSERT_FALSE(objectIndices.empty()) << "Could not find a text object in hello_world.pdf"; std::string indicesStr = ""; for (size_t i = 0; i < objectIndices.size(); ++i) { indicesStr += std::to_string(objectIndices[i]); if (i + 1 < objectIndices.size()) indicesStr += ","; } std::string editsJson = R"({ "version": "1.0", "operations": [ { "id": "op_mvp_1", "type": "replace_text", "pageIndex": 0, "objectIndices": [)" + indicesStr + R"(], "text": "Greeting, universe!" } ] })"; 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; auto newPageRes = newDoc->getPage(0); ASSERT_TRUE(newPageRes.has_value()); auto newPage = *newPageRes; auto textRes = newPage->extractText(); ASSERT_TRUE(textRes.has_value()); EXPECT_NE(textRes->find("Greeting, universe!"), std::string::npos); EXPECT_EQ(textRes->find("Hello"), std::string::npos); } TEST(DocumentEditTest, ReplaceTextRuntimeFontEngine) { 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 doc = *docRes; auto pageRes = doc->getPage(0); ASSERT_TRUE(pageRes.has_value()); auto pageObj = *pageRes; auto modelRes = pageObj->extractDocumentModel(); ASSERT_TRUE(modelRes.has_value()); const auto& model = *modelRes; std::vector objectIndices; std::string originalFontId = ""; for (const auto& p : model.paragraphs) { for (const auto& line : p.lines) { for (const auto& run : line.runs) { if (run.fontName.find("Roboto-Regular") != std::string::npos) { objectIndices = run.objectIndices; originalFontId = run.internalFontId; break; } } if (!objectIndices.empty()) break; } if (!objectIndices.empty()) break; } ASSERT_FALSE(objectIndices.empty()) << "Could not find target text run in latin_extended.pdf"; std::string indicesStr = ""; for (size_t i = 0; i < objectIndices.size(); ++i) { indicesStr += std::to_string(objectIndices[i]); if (i + 1 < objectIndices.size()) indicesStr += ","; } std::string editsJson = R"({ "version": "1.0", "operations": [ { "id": "op_engine_1", "type": "replace_text", "pageIndex": 0, "objectIndices": [)" + indicesStr + R"(], "text": "Font Engine Active!", "internalFontId": ")" + originalFontId + R"(" } ] })"; 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; auto newPageRes = newDoc->getPage(0); ASSERT_TRUE(newPageRes.has_value()); auto newPage = *newPageRes; auto textRes = newPage->extractText(); ASSERT_TRUE(textRes.has_value()); EXPECT_NE(textRes->find("Font Engine Active!"), std::string::npos); } TEST(DocumentEditTest, ReplaceTextFontReuseAndEmbedding) { 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 pageObj = *pageRes; auto modelRes = pageObj->extractDocumentModel(); ASSERT_TRUE(modelRes.has_value()); const auto& model = *modelRes; std::vector objectIndices; std::string originalFontId = ""; for (const auto& p : model.paragraphs) { for (const auto& line : p.lines) { for (const auto& run : line.runs) { if (!run.objectIndices.empty()) { objectIndices = run.objectIndices; originalFontId = run.internalFontId; break; } } if (!objectIndices.empty()) break; } if (!objectIndices.empty()) break; } ASSERT_FALSE(objectIndices.empty()) << "Could not find a text run in hello_world.pdf"; std::string indicesStr = ""; for (size_t i = 0; i < objectIndices.size(); ++i) { indicesStr += std::to_string(objectIndices[i]); if (i + 1 < objectIndices.size()) indicesStr += ","; } std::string editsJson = R"({ "version": "1.0", "operations": [ { "id": "op_reuse_1", "type": "replace_text", "pageIndex": 0, "objectIndices": [)" + indicesStr + R"(], "text": "Embedded Arial", "internalFontId": ")" + originalFontId + R"(" } ] })"; 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; auto fontsRes = newDoc->getFonts(0, 0); ASSERT_TRUE(fontsRes.has_value()); bool foundEmbeddedArial = false; for (const auto& f : *fontsRes) { if (f.isEmbedded && (f.fontName.find("Arial") != std::string::npos || f.fontName.find("LiberationSans") != std::string::npos)) { foundEmbeddedArial = true; } } std::cout << "Font Embedding Test: foundEmbeddedArial = " << foundEmbeddedArial << std::endl; } TEST(DocumentEditTest, ReplaceTextHarfBuzzShapingAndReflow) { 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 doc = *docRes; auto pageRes = doc->getPage(0); ASSERT_TRUE(pageRes.has_value()); auto pageObj = *pageRes; auto modelRes = pageObj->extractDocumentModel(); ASSERT_TRUE(modelRes.has_value()); const auto& model = *modelRes; std::vector targetIndices; std::string originalFontId = ""; std::string runBText = ""; double runBOrigX = 0.0; double runBOrigY = 0.0; for (const auto& p : model.paragraphs) { for (const auto& line : p.lines) { if (line.runs.size() >= 2) { const auto& runA = line.runs[0]; const auto& runB = line.runs[1]; if (runA.fontName.find("Roboto-Regular") != std::string::npos && !runA.objectIndices.empty() && runB.x > runA.x) { targetIndices = runA.objectIndices; originalFontId = runA.internalFontId; runBText = runB.text; runBOrigX = runB.x; runBOrigY = runB.y; break; } } } if (!targetIndices.empty()) break; } if (targetIndices.empty()) { GTEST_SKIP() << "Could not find a suitable line with multiple runs to test reflow."; } std::string indicesStr = ""; for (size_t i = 0; i < targetIndices.size(); ++i) { indicesStr += std::to_string(targetIndices[i]); if (i + 1 < targetIndices.size()) indicesStr += ","; } std::string editsJson = R"({ "version": "1.0", "operations": [ { "id": "op_reflow_1", "type": "replace_text", "pageIndex": 0, "objectIndices": [)" + indicesStr + R"(], "text": "This is an extremely long replacement text to force the Reflow Engine to shift subsequent runs!", "internalFontId": ")" + originalFontId + R"(" } ] })"; 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; auto newPageRes = newDoc->getPage(0); ASSERT_TRUE(newPageRes.has_value()); auto newPage = *newPageRes; auto newModelRes = newPage->extractDocumentModel(); ASSERT_TRUE(newModelRes.has_value()); const auto& newModel = *newModelRes; bool foundRunB = false; double runBNewX = 0.0; for (const auto& p : newModel.paragraphs) { for (const auto& line : p.lines) { for (const auto& run : line.runs) { if (run.text == runBText && std::abs(run.y - runBOrigY) < 5.0) { foundRunB = true; runBNewX = run.x; break; } } if (foundRunB) break; } if (foundRunB) break; } ASSERT_TRUE(foundRunB) << "Could not find the subsequent text run '" << runBText << "' in the reflowed document."; EXPECT_GT(runBNewX, runBOrigX + 10.0) << "The subsequent text run did not shift to the right by at least 10 points."; std::cout << "Reflow Engine verified: '" << runBText << "' shifted from X=" << runBOrigX << " to X=" << runBNewX << std::endl; } }