feat: implemented page rotation and deletion
This commit is contained in:
@@ -13,6 +13,8 @@
|
||||
|
||||
#include <nlohmann/json.hpp>
|
||||
#include <spdlog/spdlog.h>
|
||||
#include <fstream>
|
||||
#include <filesystem>
|
||||
#include <csetjmp>
|
||||
|
||||
namespace pdfengine::parser {
|
||||
@@ -560,6 +562,29 @@ void parseHexColor(const std::string& hex, unsigned int& r, unsigned int& g, uns
|
||||
}
|
||||
}
|
||||
|
||||
std::vector<uint8_t> base64Decode(const std::string& encoded) {
|
||||
std::vector<uint8_t> decoded;
|
||||
int T[256];
|
||||
std::fill(std::begin(T), std::end(T), -1);
|
||||
for (int i = 0; i < 64; ++i) {
|
||||
T[static_cast<unsigned char>("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"[i])] = i;
|
||||
}
|
||||
|
||||
int val = 0;
|
||||
int valb = -8;
|
||||
for (char c : encoded) {
|
||||
unsigned char uc = static_cast<unsigned char>(c);
|
||||
if (T[uc] == -1) continue;
|
||||
val = (val << 6) + T[uc];
|
||||
valb += 6;
|
||||
if (valb >= 0) {
|
||||
decoded.push_back(static_cast<uint8_t>((val >> valb) & 0xFF));
|
||||
valb -= 8;
|
||||
}
|
||||
}
|
||||
return decoded;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
namespace pdfengine {
|
||||
@@ -1085,7 +1110,99 @@ std::expected<void, EngineError> PdfiumDocument::applyEdits(const std::string& e
|
||||
} else if (type == "redaction") {
|
||||
spdlog::info("Parsed redaction edit operation (stub)");
|
||||
} else if (type == "image_overlay") {
|
||||
spdlog::info("Parsed image_overlay edit operation (stub)");
|
||||
if (!op.contains("data") || !op["data"].is_object()) {
|
||||
spdlog::error("image_overlay operation missing 'data' object");
|
||||
return std::unexpected(EngineError::InvalidFormat);
|
||||
}
|
||||
auto data = op["data"];
|
||||
double x = data.value("x", 0.0);
|
||||
double y = data.value("y", 0.0);
|
||||
double width = data.value("width", 100.0);
|
||||
double height = data.value("height", 100.0);
|
||||
int pixelWidth = data.value("pixelWidth", 0);
|
||||
int pixelHeight = data.value("pixelHeight", 0);
|
||||
std::string rawPixelData = data.value("rawPixelData", "");
|
||||
std::string pixelDataPath = data.value("pixelDataPath", "");
|
||||
|
||||
std::vector<uint8_t> decodedBytes;
|
||||
if (!pixelDataPath.empty()) {
|
||||
std::ifstream infile(pixelDataPath, std::ios::binary);
|
||||
if (!infile) {
|
||||
spdlog::error("Failed to open pixel data path: {}", pixelDataPath);
|
||||
return std::unexpected(EngineError::Unknown);
|
||||
}
|
||||
infile.seekg(0, std::ios::end);
|
||||
std::streamsize size = infile.tellg();
|
||||
infile.seekg(0, std::ios::beg);
|
||||
decodedBytes.resize(static_cast<size_t>(size));
|
||||
if (!infile.read(reinterpret_cast<char*>(decodedBytes.data()), size)) {
|
||||
spdlog::error("Failed to read pixel data from path: {}", pixelDataPath);
|
||||
return std::unexpected(EngineError::Unknown);
|
||||
}
|
||||
infile.close();
|
||||
std::error_code ec;
|
||||
std::filesystem::remove(pixelDataPath, ec);
|
||||
} else if (!rawPixelData.empty()) {
|
||||
decodedBytes = base64Decode(rawPixelData);
|
||||
} else {
|
||||
spdlog::warn("image_overlay operation contains invalid raw pixels or dimensions");
|
||||
continue;
|
||||
}
|
||||
|
||||
if (decodedBytes.size() != static_cast<size_t>(pixelWidth * pixelHeight * 4)) {
|
||||
spdlog::error("Decoded image bytes size mismatch. Expected: {}, Got: {}",
|
||||
pixelWidth * pixelHeight * 4, decodedBytes.size());
|
||||
return std::unexpected(EngineError::InvalidFormat);
|
||||
}
|
||||
|
||||
FPDF_PAGE page = FPDF_LoadPage(doc_, pageIndex);
|
||||
if (!page) {
|
||||
spdlog::error("Failed to load page index {} for image insertion", pageIndex);
|
||||
return std::unexpected(EngineError::Unknown);
|
||||
}
|
||||
|
||||
FPDF_PAGEOBJECT imgObj = FPDFPageObj_NewImageObj(doc_);
|
||||
if (!imgObj) {
|
||||
spdlog::error("Failed to create new image object");
|
||||
FPDF_ClosePage(page);
|
||||
return std::unexpected(EngineError::Unknown);
|
||||
}
|
||||
|
||||
FPDF_BITMAP bitmap = FPDFBitmap_Create(pixelWidth, pixelHeight, 4); // 4 = FPDFBitmap_BGRA
|
||||
if (!bitmap) {
|
||||
spdlog::error("Failed to create FPDF_BITMAP");
|
||||
FPDFPageObj_Destroy(imgObj);
|
||||
FPDF_ClosePage(page);
|
||||
return std::unexpected(EngineError::Unknown);
|
||||
}
|
||||
|
||||
uint8_t* dest = static_cast<uint8_t*>(FPDFBitmap_GetBuffer(bitmap));
|
||||
std::memcpy(dest, decodedBytes.data(), decodedBytes.size());
|
||||
|
||||
if (!FPDFImageObj_SetBitmap(&page, 1, imgObj, bitmap)) {
|
||||
spdlog::error("Failed to set bitmap on image object");
|
||||
FPDFBitmap_Destroy(bitmap);
|
||||
FPDFPageObj_Destroy(imgObj);
|
||||
FPDF_ClosePage(page);
|
||||
return std::unexpected(EngineError::Unknown);
|
||||
}
|
||||
|
||||
// Apply positioning and scaling to the image object
|
||||
FPDFPageObj_Transform(imgObj, width, 0.0, 0.0, height, x, y);
|
||||
|
||||
// Insert into page
|
||||
FPDFPage_InsertObject(page, imgObj);
|
||||
|
||||
// Regenerate page contents
|
||||
if (!FPDFPage_GenerateContent(page)) {
|
||||
spdlog::error("Failed to generate page content after image insertion");
|
||||
FPDFBitmap_Destroy(bitmap);
|
||||
FPDF_ClosePage(page);
|
||||
return std::unexpected(EngineError::Unknown);
|
||||
}
|
||||
|
||||
FPDFBitmap_Destroy(bitmap);
|
||||
FPDF_ClosePage(page);
|
||||
} else if (type == "highlight") {
|
||||
spdlog::info("Parsed highlight edit operation (stub)");
|
||||
} else if (type == "free_text") {
|
||||
@@ -1095,7 +1212,54 @@ std::expected<void, EngineError> PdfiumDocument::applyEdits(const std::string& e
|
||||
} else if (type == "freehand") {
|
||||
spdlog::info("Parsed freehand edit operation (stub)");
|
||||
} else if (type == "page_rotation") {
|
||||
spdlog::info("Parsed page_rotation edit operation (stub)");
|
||||
if (!op.contains("data") || !op["data"].is_object()) {
|
||||
spdlog::error("page_rotation operation missing 'data' object");
|
||||
return std::unexpected(EngineError::InvalidFormat);
|
||||
}
|
||||
auto data = op["data"];
|
||||
int rotation = data.value("rotation", 0);
|
||||
|
||||
FPDF_PAGE page = FPDF_LoadPage(doc_, pageIndex);
|
||||
if (!page) {
|
||||
spdlog::error("Failed to load page index {} for page rotation", pageIndex);
|
||||
return std::unexpected(EngineError::Unknown);
|
||||
}
|
||||
|
||||
int currentCode = FPDFPage_GetRotation(page);
|
||||
int currentDegrees = currentCode * 90;
|
||||
int newDegrees = currentDegrees + rotation;
|
||||
newDegrees = (newDegrees % 360 + 360) % 360;
|
||||
int newCode = newDegrees / 90;
|
||||
|
||||
FPDFPage_SetRotation(page, newCode);
|
||||
FPDF_ClosePage(page);
|
||||
} else if (type == "page_deletion") {
|
||||
if (pageCount() <= 1) {
|
||||
spdlog::error("Cannot delete the only page in the document");
|
||||
return std::unexpected(EngineError::Unknown);
|
||||
}
|
||||
FPDFPage_Delete(doc_, pageIndex);
|
||||
} else if (type == "page_reorder") {
|
||||
if (!op.contains("data") || !op["data"].is_object()) {
|
||||
spdlog::error("page_reorder operation missing 'data' object");
|
||||
return std::unexpected(EngineError::InvalidFormat);
|
||||
}
|
||||
auto data = op["data"];
|
||||
if (!data.contains("destPageIndex")) {
|
||||
spdlog::error("page_reorder data missing 'destPageIndex'");
|
||||
return std::unexpected(EngineError::InvalidFormat);
|
||||
}
|
||||
int destPageIndex = data["destPageIndex"];
|
||||
if (destPageIndex < 0 || destPageIndex >= pageCount()) {
|
||||
spdlog::error("Destination page index {} out of bounds (total pages: {})", destPageIndex, pageCount());
|
||||
return std::unexpected(EngineError::PageOutOfBounds);
|
||||
}
|
||||
|
||||
int fromIndex = pageIndex;
|
||||
if (!FPDF_MovePages(doc_, &fromIndex, 1, destPageIndex)) {
|
||||
spdlog::error("FPDF_MovePages failed from {} to {}", fromIndex, destPageIndex);
|
||||
return std::unexpected(EngineError::Unknown);
|
||||
}
|
||||
} else {
|
||||
spdlog::warn("Unsupported edit operation type: {}", type);
|
||||
}
|
||||
|
||||
@@ -435,6 +435,237 @@ TEST(DocumentEditTest, ApplyEditsAndIncrementalSave) {
|
||||
EXPECT_NE(textRes->find("UniqueEditedTextAnnotation123"), 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);
|
||||
|
||||
// 1. Rotate by 90 degrees (90 total)
|
||||
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());
|
||||
|
||||
// 2. Rotate by another 90 degrees (180 total)
|
||||
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());
|
||||
|
||||
// Save and load back to verify 180 degree rotation (dimensions should be original again)
|
||||
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);
|
||||
|
||||
// 3. Now rotate by -90 degrees (back to 90 total)
|
||||
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);
|
||||
|
||||
// Swap the pages: move page 1 (index 1) to page 0 (index 0)
|
||||
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(FontDiagnosticsTest, IntrospectionAccuracy) {
|
||||
SKIP_IF_NO_PDFIUM();
|
||||
auto path = getCorpusPath("basic", "hello_world.pdf");
|
||||
|
||||
Reference in New Issue
Block a user