feat: implemented Incremental save system
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -47,6 +47,7 @@ target_link_libraries(pdfengine
|
||||
freetype
|
||||
harfbuzz::harfbuzz
|
||||
PNG::PNG
|
||||
nlohmann_json::nlohmann_json
|
||||
)
|
||||
|
||||
if(PDFENGINE_WITH_PDFIUM)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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_;
|
||||
};
|
||||
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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"]
|
||||
@@ -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
@@ -14,7 +14,8 @@
|
||||
"harfbuzz",
|
||||
"spdlog",
|
||||
"gtest",
|
||||
"pybind11"
|
||||
"pybind11",
|
||||
"nlohmann-json"
|
||||
],
|
||||
"builtin-baseline": "495848814af4cc2760e70f7440c2dbe66d3ff196"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user