feat: implemented Incremental save system

This commit is contained in:
Furqan-14
2026-05-26 11:23:29 +05:30
parent c3447bb793
commit bb1b2dcd50
8 changed files with 196 additions and 9 deletions
+1
View File
@@ -57,6 +57,7 @@ include(skia) # defines skia::skia when PDFENGINE_WITH_SKIA is ON
find_package(freetype CONFIG REQUIRED)
find_package(harfbuzz CONFIG REQUIRED)
find_package(spdlog CONFIG REQUIRED)
find_package(nlohmann_json CONFIG REQUIRED)
if(PDFENGINE_BUILD_TESTS)
find_package(GTest CONFIG REQUIRED)
+1
View File
@@ -47,6 +47,7 @@ target_link_libraries(pdfengine
freetype
harfbuzz::harfbuzz
PNG::PNG
nlohmann_json::nlohmann_json
)
if(PDFENGINE_WITH_PDFIUM)
+130 -3
View File
@@ -5,15 +5,51 @@
#include <fpdf_text.h>
#include <fpdf_save.h>
#include <fpdf_doc.h>
#include <fpdf_edit.h>
#include <png.h>
#include "parser/pdfium_loader.hpp"
#endif
#include <nlohmann/json.hpp>
#include <spdlog/spdlog.h>
#include <csetjmp>
namespace {
std::vector<unsigned short> utf8_to_utf16le(const std::string& utf8) {
std::vector<unsigned short> utf16;
utf16.reserve(utf8.size());
for (size_t i = 0; i < utf8.size(); ) {
unsigned char c = utf8[i];
unsigned int cp = 0;
size_t extra = 0;
if (c < 0x80) { cp = c; extra = 0; }
else if ((c & 0xE0) == 0xC0) { cp = c & 0x1F; extra = 1; }
else if ((c & 0xF0) == 0xE0) { cp = c & 0x0F; extra = 2; }
else if ((c & 0xF8) == 0xF0) { cp = c & 0x07; extra = 3; }
else { i++; continue; }
if (i + extra >= utf8.size()) break;
bool invalid = false;
for (size_t j = 1; j <= extra; ++j) {
unsigned char next = utf8[i + j];
if ((next & 0xC0) != 0x80) { invalid = true; break; }
cp = (cp << 6) | (next & 0x3F);
}
if (invalid) { i++; continue; }
i += 1 + extra;
if (cp < 0x10000) {
utf16.push_back(static_cast<unsigned short>(cp));
} else {
cp -= 0x10000;
utf16.push_back(static_cast<unsigned short>((cp >> 10) + 0xD800));
utf16.push_back(static_cast<unsigned short>((cp & 0x3FF) + 0xDC00));
}
}
utf16.push_back(0);
return utf16;
}
std::string utf16le_to_utf8(const char16_t* utf16, size_t length) {
std::string utf8;
for (size_t i = 0; i < length; ++i) {
@@ -187,14 +223,15 @@ PdfDocument::loadFromMemory(const std::vector<uint8_t>& data, const std::string&
if (data.empty()) {
return std::unexpected(EngineError::InvalidFormat);
}
FPDF_DOCUMENT doc = FPDF_LoadMemDocument(data.data(), static_cast<int>(data.size()),
std::vector<uint8_t> buffer_copy = data;
FPDF_DOCUMENT doc = FPDF_LoadMemDocument(buffer_copy.data(), static_cast<int>(buffer_copy.size()),
password.empty() ? nullptr : password.c_str());
if (!doc) {
auto err = FPDF_GetLastError();
spdlog::error("Failed to load PDF from memory (error code: {})", err);
return std::unexpected(mapPdfiumError(err, !password.empty()));
}
return std::make_shared<parser::PdfiumDocument>(doc);
return std::make_shared<parser::PdfiumDocument>(doc, std::move(buffer_copy));
#else
(void)data;
(void)password;
@@ -434,6 +471,9 @@ void PdfiumPage::ensureTextPageLoaded() const {
PdfiumDocument::PdfiumDocument(NativeDocHandle docHandle)
: doc_(docHandle) {}
PdfiumDocument::PdfiumDocument(NativeDocHandle docHandle, std::vector<uint8_t> memoryBuffer)
: doc_(docHandle), memoryBuffer_(std::move(memoryBuffer)) {}
PdfiumDocument::~PdfiumDocument() {
#ifdef PDFENGINE_WITH_PDFIUM
if (doc_) {
@@ -453,6 +493,7 @@ PdfiumDocument& PdfiumDocument::operator=(PdfiumDocument&& other) noexcept {
#endif
doc_ = other.doc_;
other.doc_ = nullptr;
memoryBuffer_ = std::move(other.memoryBuffer_);
}
return *this;
}
@@ -508,8 +549,94 @@ std::expected<std::shared_ptr<PdfPage>, EngineError> PdfiumDocument::getPage(int
}
std::expected<void, EngineError> PdfiumDocument::applyEdits(const std::string& editsJson) {
(void)editsJson;
#ifdef PDFENGINE_WITH_PDFIUM
if (!doc_) {
return std::unexpected(EngineError::Unknown);
}
try {
auto root = nlohmann::json::parse(editsJson);
if (!root.contains("operations") || !root["operations"].is_array()) {
spdlog::error("Invalid edits JSON: missing 'operations' array");
return std::unexpected(EngineError::InvalidFormat);
}
for (const auto& op : root["operations"]) {
std::string type = op.value("type", "");
int pageIndex = op.value("pageIndex", -1);
if (pageIndex < 0 || pageIndex >= pageCount()) {
spdlog::error("Page index {} out of bounds (total pages: {})", pageIndex, pageCount());
return std::unexpected(EngineError::PageOutOfBounds);
}
if (type == "add_text") {
if (!op.contains("data") || !op["data"].is_object()) {
spdlog::error("add_text operation missing 'data' object");
return std::unexpected(EngineError::InvalidFormat);
}
auto data = op["data"];
std::string text = data.value("text", "");
double x = data.value("x", 0.0);
double y = data.value("y", 0.0);
double fontSize = data.value("fontSize", 12.0);
if (text.empty()) {
continue;
}
FPDF_PAGE page = FPDF_LoadPage(doc_, pageIndex);
if (!page) {
spdlog::error("Failed to load page index {} for editing", pageIndex);
return std::unexpected(EngineError::Unknown);
}
// Create text object
FPDF_PAGEOBJECT textObj = FPDFPageObj_NewTextObj(doc_, "Helvetica", static_cast<float>(fontSize));
if (!textObj) {
spdlog::error("Failed to create PDF text object");
FPDF_ClosePage(page);
return std::unexpected(EngineError::Unknown);
}
// Convert text to UTF-16LE and set it
auto utf16 = utf8_to_utf16le(text);
if (!FPDFText_SetText(textObj, reinterpret_cast<FPDF_WIDESTRING>(utf16.data()))) {
spdlog::error("Failed to set text content on page object");
FPDFPageObj_Destroy(textObj);
FPDF_ClosePage(page);
return std::unexpected(EngineError::Unknown);
}
// Translate the text object to (x, y)
FPDFPageObj_Transform(textObj, 1.0, 0.0, 0.0, 1.0, x, y);
// Insert into the page and generate content stream
FPDFPage_InsertObject(page, textObj);
if (!FPDFPage_GenerateContent(page)) {
spdlog::error("Failed to generate page content after editing");
FPDF_ClosePage(page);
return std::unexpected(EngineError::Unknown);
}
FPDF_ClosePage(page);
} else {
spdlog::warn("Unsupported edit operation type: {}", type);
}
}
} catch (const nlohmann::json::parse_error& e) {
spdlog::error("JSON parse error in applyEdits: {}", e.what());
return std::unexpected(EngineError::InvalidFormat);
} catch (const std::exception& e) {
spdlog::error("Exception in applyEdits: {}", e.what());
return std::unexpected(EngineError::Unknown);
}
return {};
#else
(void)editsJson;
return std::unexpected(EngineError::Unknown);
#endif
}
std::expected<std::vector<uint8_t>, EngineError> PdfiumDocument::saveIncremental() const {
+2
View File
@@ -56,6 +56,7 @@ private:
class PdfiumDocument : public PdfDocument {
public:
explicit PdfiumDocument(NativeDocHandle docHandle);
PdfiumDocument(NativeDocHandle docHandle, std::vector<uint8_t> memoryBuffer);
~PdfiumDocument() override;
PdfiumDocument(const PdfiumDocument&) = delete;
@@ -73,6 +74,7 @@ public:
private:
NativeDocHandle doc_ = nullptr;
std::vector<uint8_t> memoryBuffer_;
};
}
+53 -2
View File
@@ -364,8 +364,10 @@ TEST(TextExtractionTest, ExtractTextWithBounds) {
bool found_hello = false;
for (const auto& glyph : glyphs) {
EXPECT_FALSE(glyph.text.empty());
EXPECT_GT(glyph.w, 0.0);
EXPECT_GT(glyph.h, 0.0);
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;
@@ -374,4 +376,53 @@ TEST(TextExtractionTest, ExtractTextWithBounds) {
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 textRes = newPage->extractText();
ASSERT_TRUE(textRes.has_value());
std::string text = *textRes;
EXPECT_NE(text.find("UniqueEditedTextAnnotation123"), std::string::npos);
}
}
+5 -1
View File
@@ -162,4 +162,8 @@ def test_apply_edits_and_incremental_save(client: TestClient):
render_resp = client.get(f"/documents/{new_doc_id}/pages/0/render")
assert render_resp.status_code == 200
assert render_resp.headers["content-type"] == "image/png"
assert render_resp.headers["content-type"] == "image/png"
text_resp = client.get(f"/documents/{new_doc_id}/pages/0/text")
assert text_resp.status_code == 200
assert "Edited Text Annotation" in text_resp.json()["text"]
+2 -2
View File
@@ -28,8 +28,8 @@ if ($LASTEXITCODE -ne 0) {
exit $LASTEXITCODE
}
Write-Host "Building target 'pdfengine_py'..." -ForegroundColor Cyan
cmake --build --preset win-local-pdfium --target pdfengine_py
Write-Host "Building all targets..." -ForegroundColor Cyan
cmake --build --preset win-local-pdfium
if ($LASTEXITCODE -ne 0) {
Write-Error "Build failed."
exit $LASTEXITCODE
+2 -1
View File
@@ -14,7 +14,8 @@
"harfbuzz",
"spdlog",
"gtest",
"pybind11"
"pybind11",
"nlohmann-json"
],
"builtin-baseline": "495848814af4cc2760e70f7440c2dbe66d3ff196"
}