From b52890818b209d0ab89ad5dac621910cfd96ad66 Mon Sep 17 00:00:00 2001 From: saqib mir Date: Thu, 11 Jun 2026 18:19:26 +0530 Subject: [PATCH] update the pipiline --- A.pgm | Bin 0 -> 1203 bytes bindings/python/pdfengine_py.cpp | 6 +- engine/include/pdfengine/pdf_document.hpp | 2 + engine/src/parser/pdfium_document.cpp | 456 ++++++++++++++++++- engine/src/parser/pdfium_document.hpp | 7 + engine/tests/document_test.cpp | 507 ++++++++++++++++++++++ frontend/src/viewer/PDFViewer.tsx | 5 +- gateway/app/routers/documents.py | 4 + gateway/app/routers/edits.py | 17 +- gateway/tests/test_replace_text.py | 81 ++++ 10 files changed, 1080 insertions(+), 5 deletions(-) create mode 100644 A.pgm create mode 100644 gateway/tests/test_replace_text.py diff --git a/A.pgm b/A.pgm new file mode 100644 index 0000000000000000000000000000000000000000..dfbd6663308af7d6fbbe842dfb5a99e4cc1fa6d3 GIT binary patch literal 1203 zcmZvcPbdUY9LJwc(j>BLTmK3KiKS!&+T9tO-7XUlOB< zBbSa)LB?E^+bg4yD&#Cf*P-xpeT`%dt{1axQ`ixW6m-C6ze2>F zK&e&mXWT>xdM$d93T%=42EJrC<7!iOpynYIWUXoMAn#OSi$l;W%gj-m9*K|gG~l#F zs;IHWW9V71beRp$;PjyF%=sSdNTD$}8C8}czp p_P*Q)ueOD|6QVzp?@}QasL}6^$s1qaobV-3Nap<4=%KX<-yaCmZmj?S literal 0 HcmV?d00001 diff --git a/bindings/python/pdfengine_py.cpp b/bindings/python/pdfengine_py.cpp index 650acb9..b7fc48e 100644 --- a/bindings/python/pdfengine_py.cpp +++ b/bindings/python/pdfengine_py.cpp @@ -123,7 +123,8 @@ PYBIND11_MODULE(pdfengine, m) { .def_readonly("bbox_y", &pdfengine::Glyph::bboxY) .def_readonly("bbox_w", &pdfengine::Glyph::bboxW) .def_readonly("bbox_h", &pdfengine::Glyph::bboxH) - .def_readonly("angle", &pdfengine::Glyph::angle); + .def_readonly("angle", &pdfengine::Glyph::angle) + .def_readonly("page_object_index", &pdfengine::Glyph::pageObjectIndex); py::class_(m, "TextRun") .def_readonly("text", &pdfengine::TextRun::text) @@ -137,7 +138,8 @@ PYBIND11_MODULE(pdfengine, m) { .def_readonly("x", &pdfengine::TextRun::x) .def_readonly("y", &pdfengine::TextRun::y) .def_readonly("w", &pdfengine::TextRun::w) - .def_readonly("h", &pdfengine::TextRun::h); + .def_readonly("h", &pdfengine::TextRun::h) + .def_readonly("object_indices", &pdfengine::TextRun::objectIndices); py::class_(m, "TextLine") .def_readonly("runs", &pdfengine::TextLine::runs) diff --git a/engine/include/pdfengine/pdf_document.hpp b/engine/include/pdfengine/pdf_document.hpp index 03da3d8..c41c136 100644 --- a/engine/include/pdfengine/pdf_document.hpp +++ b/engine/include/pdfengine/pdf_document.hpp @@ -89,6 +89,7 @@ struct Glyph { double originY = 0.0; double bboxX = 0.0, bboxY = 0.0, bboxW = 0.0, bboxH = 0.0; double angle = 0.0; + int pageObjectIndex = -1; }; struct TextRun { @@ -100,6 +101,7 @@ struct TextRun { bool isEmbedded = false; std::string type; std::vector glyphs; + std::vector objectIndices; double x = 0.0, y = 0.0, w = 0.0, h = 0.0; }; diff --git a/engine/src/parser/pdfium_document.cpp b/engine/src/parser/pdfium_document.cpp index cc4c9af..d13c7be 100644 --- a/engine/src/parser/pdfium_document.cpp +++ b/engine/src/parser/pdfium_document.cpp @@ -12,6 +12,8 @@ #include "fonts/loader/font_resolver.hpp" #include "fonts/pdf_fonts/font.hpp" +#include "fonts/pdf_fonts/font_fallback.hpp" +#include "fonts/shaping/hb_shaper.hpp" #include #include @@ -865,6 +867,12 @@ std::expected PdfiumPage::extractDocumentModel() const { std::vector documentGlyphs; documentGlyphs.reserve(charCount); + std::unordered_map objToIndex; + int objCount = FPDFPage_CountObjects(page_); + for (int i = 0; i < objCount; ++i) { + objToIndex[FPDFPage_GetObject(page_, i)] = i; + } + // Pass 1: Extract all glyphs for (int i = 0; i < charCount; ++i) { unsigned int codeUnit = FPDFText_GetUnicode(textPage_, i); @@ -911,6 +919,14 @@ std::expected PdfiumPage::extractDocumentModel() const { } g.flags = flags; + FPDF_PAGEOBJECT textObj = FPDFText_GetTextObject(textPage_, i); + if (textObj) { + auto it = objToIndex.find(textObj); + if (it != objToIndex.end()) { + g.pageObjectIndex = it->second; + } + } + documentGlyphs.push_back(g); if (cp > 0xFFFF) ++i; // Skip low surrogate @@ -1013,6 +1029,11 @@ std::expected PdfiumPage::extractDocumentModel() const { spaceGlyph.bboxH = currG.bboxH; if (breakRun) { + for (const auto& g : currentRun.glyphs) { + if (g.pageObjectIndex != -1 && std::find(currentRun.objectIndices.begin(), currentRun.objectIndices.end(), g.pageObjectIndex) == currentRun.objectIndices.end()) { + currentRun.objectIndices.push_back(g.pageObjectIndex); + } + } line.runs.push_back(std::move(currentRun)); currentRun = TextRun(); currentRun.fontName = currG.fontName; @@ -1027,6 +1048,11 @@ std::expected PdfiumPage::extractDocumentModel() const { currentRun.glyphs.push_back(spaceGlyph); currentRun.text += spaceGlyph.text; } else if (breakRun) { + for (const auto& g : currentRun.glyphs) { + if (g.pageObjectIndex != -1 && std::find(currentRun.objectIndices.begin(), currentRun.objectIndices.end(), g.pageObjectIndex) == currentRun.objectIndices.end()) { + currentRun.objectIndices.push_back(g.pageObjectIndex); + } + } line.runs.push_back(std::move(currentRun)); currentRun = TextRun(); currentRun.fontName = currG.fontName; @@ -1043,6 +1069,11 @@ std::expected PdfiumPage::extractDocumentModel() const { currentRun.text += currG.text; } if (!currentRun.glyphs.empty()) { + for (const auto& g : currentRun.glyphs) { + if (g.pageObjectIndex != -1 && std::find(currentRun.objectIndices.begin(), currentRun.objectIndices.end(), g.pageObjectIndex) == currentRun.objectIndices.end()) { + currentRun.objectIndices.push_back(g.pageObjectIndex); + } + } line.runs.push_back(std::move(currentRun)); } } @@ -1635,7 +1666,423 @@ std::expected PdfiumDocument::applyEdits(const std::string& e return std::unexpected(EngineError::PageOutOfBounds); } - if (type == "text_overlay" || type == "add_text") { + if (type == "replace_text") { + std::vector objectIndices; + std::string newText = ""; + std::string internalFontId = ""; + double fontSize = -1.0; + + if (op.contains("data") && op["data"].is_object()) { + auto data = op["data"]; + if (data.contains("objectIndices") && data["objectIndices"].is_array()) { + for (auto& idx : data["objectIndices"]) { + objectIndices.push_back(idx.get()); + } + } + newText = data.value("text", ""); + internalFontId = data.value("internalFontId", ""); + if (data.contains("fontSize")) { + fontSize = data["fontSize"].get(); + } + } else { + if (op.contains("objectIndices") && op["objectIndices"].is_array()) { + for (auto& idx : op["objectIndices"]) { + objectIndices.push_back(idx.get()); + } + } + newText = op.value("text", ""); + internalFontId = op.value("internalFontId", ""); + if (op.contains("fontSize")) { + fontSize = op["fontSize"].get(); + } + } + + if (objectIndices.empty()) { + spdlog::warn("replace_text has empty objectIndices, nothing to replace"); + continue; + } + + FPDF_PAGE page = FPDF_LoadPage(doc_, pageIndex); + if (!page) { + spdlog::error("Failed to load page index {} for replace_text", pageIndex); + return std::unexpected(EngineError::Unknown); + } + + // 1. Sort descending to prevent index shift during removal + std::sort(objectIndices.begin(), objectIndices.end(), std::greater()); + + int minIndex = objectIndices.back(); + + // 2. Fetch the lowest indexed original object to copy styling + FPDF_PAGEOBJECT origObj = FPDFPage_GetObject(page, minIndex); + if (!origObj) { + spdlog::error("Failed to get original text object at index {}", minIndex); + FPDF_ClosePage(page); + return std::unexpected(EngineError::Unknown); + } + + double a = 1.0, b = 0.0, c = 0.0, d = 1.0, e = 0.0, f = 0.0; + FS_MATRIX matrix; + if (FPDFPageObj_GetMatrix(origObj, &matrix)) { + a = matrix.a; + b = matrix.b; + c = matrix.c; + d = matrix.d; + e = matrix.e; + f = matrix.f; + } + + unsigned int r = 0, g = 0, b_color = 0, a_color = 255; + FPDFPageObj_GetFillColor(origObj, &r, &g, &b_color, &a_color); + + // Get font size if not provided + if (fontSize < 0.0) { + float sizeVal = 12.0f; + if (FPDFTextObj_GetFontSize(origObj, &sizeVal)) { + fontSize = sizeVal; + } else { + fontSize = 12.0; + } + } + + // Get text rendering mode + FPDF_TEXT_RENDERMODE renderMode = static_cast(FPDFTextObj_GetTextRenderMode(origObj)); + + // Map to a standard 14 font name + std::string fontName = "Helvetica"; + std::string origFontName = ""; + bool bold = false; + bool italic = false; + FPDF_FONT origFont = FPDFTextObj_GetFont(origObj); + if (origFont) { + size_t nameLen = FPDFFont_GetBaseFontName(origFont, nullptr, 0); + if (nameLen > 0) { + std::vector nameBuf(nameLen); + if (FPDFFont_GetBaseFontName(origFont, nameBuf.data(), nameLen) > 0) { + origFontName = nameBuf.data(); + std::string baseName(nameBuf.data()); + std::string lowerName = baseName; + std::transform(lowerName.begin(), lowerName.end(), lowerName.begin(), [](unsigned char c) { + return static_cast(std::tolower(c)); + }); + + bold = (lowerName.find("bold") != std::string::npos); + italic = (lowerName.find("italic") != std::string::npos || lowerName.find("oblique") != std::string::npos); + + if (lowerName.find("times") != std::string::npos) { + if (bold && italic) fontName = "Times-BoldItalic"; + else if (bold) fontName = "Times-Bold"; + else if (italic) fontName = "Times-Italic"; + else fontName = "Times-Roman"; + } else if (lowerName.find("courier") != std::string::npos) { + if (bold && italic) fontName = "Courier-BoldOblique"; + else if (bold) fontName = "Courier-Bold"; + else if (italic) fontName = "Courier-Oblique"; + else fontName = "Courier"; + } else { + if (bold && italic) fontName = "Helvetica-BoldOblique"; + else if (bold) fontName = "Helvetica-Bold"; + else if (italic) fontName = "Helvetica-Oblique"; + else fontName = "Helvetica"; + } + } + } + } + + if (!internalFontId.empty()) { + std::string lowerId = internalFontId; + std::transform(lowerId.begin(), lowerId.end(), lowerId.begin(), [](unsigned char c) { + return static_cast(std::tolower(c)); + }); + if (lowerId.find("bolditalic") != std::string::npos) { bold = true; italic = true; } + else if (lowerId.find("bold") != std::string::npos) { bold = true; } + else if (lowerId.find("italic") != std::string::npos) { italic = true; } + else if (lowerId.find("oblique") != std::string::npos) { italic = true; } + + if (lowerId.find("times") != std::string::npos) { + if (bold && italic) fontName = "Times-BoldItalic"; + else if (bold) fontName = "Times-Bold"; + else if (italic) fontName = "Times-Italic"; + else fontName = "Times-Roman"; + } else if (lowerId.find("courier") != std::string::npos) { + if (bold && italic) fontName = "Courier-BoldOblique"; + else if (bold) fontName = "Courier-Bold"; + else if (italic) fontName = "Courier-Oblique"; + else fontName = "Courier"; + } else if (lowerId.find("helvetica") != std::string::npos) { + if (bold && italic) fontName = "Helvetica-BoldOblique"; + else if (bold) fontName = "Helvetica-Bold"; + else if (italic) fontName = "Helvetica-Oblique"; + else fontName = "Helvetica"; + } + } + + // --- FONT ENGINE INTEGRATION --- + std::optional matchedFontInfo; + auto fontsRes = getFonts(pageIndex, pageIndex); + if (fontsRes.has_value()) { + for (const auto& fontInfoEntry : *fontsRes) { + if ((!internalFontId.empty() && fontInfoEntry.internalFontId == internalFontId) || + (!origFontName.empty() && fontInfoEntry.fontName == origFontName)) { + matchedFontInfo = fontInfoEntry; + break; + } + } + } + + std::shared_ptr resolvedFont = nullptr; + if (matchedFontInfo.has_value()) { + auto resolvedFontRes = getResolvedFont(*matchedFontInfo); + if (resolvedFontRes.has_value()) { + resolvedFont = *resolvedFontRes; + spdlog::info("Font Engine: resolved font '{}'", matchedFontInfo->fontName); + } else { + spdlog::warn("Font Engine: failed to resolve font '{}': {}", matchedFontInfo->fontName, resolvedFontRes.error()); + } + } + + bool fontSupportsAll = true; + double totalWidth = 0.0; + + auto utf16 = utf8_to_utf16le(newText); + std::vector unicodeCodepoints; + for (size_t i = 0; i < utf16.size(); ) { + uint32_t cp = utf16[i]; + if (cp >= 0xD800 && cp <= 0xDBFF && i + 1 < utf16.size()) { + uint32_t low = utf16[i + 1]; + if (low >= 0xDC00 && low <= 0xDFFF) { + cp = 0x10000 + ((cp - 0xD800) << 10) + (low - 0xDC00); + i += 2; + } else { + i += 1; + } + } else { + i += 1; + } + unicodeCodepoints.push_back(cp); + } + + bool isSubsetFont = matchedFontInfo && matchedFontInfo->isSubset; + bool subsetLacksGlyphs = false; + + // Perform glyph check + if (resolvedFont) { + for (uint32_t cp : unicodeCodepoints) { + if (!resolvedFont->hasGlyph(cp)) { + if (isSubsetFont) { + subsetLacksGlyphs = true; + } else { + fontSupportsAll = false; + } + spdlog::warn("Font Engine: Glyph for codepoint {} not found in font {}", cp, matchedFontInfo ? matchedFontInfo->fontName : "Unknown"); + } + } + } else { + fontSupportsAll = false; + } + + // Stage 7: HarfBuzz Shaping + bool shapedSuccessful = false; + if (resolvedFont) { + try { + fonts::HbShaper shaper; + unsigned int uFontSize = static_cast(fontSize > 0.0 ? fontSize : 12.0); + auto shapedGlyphs = shaper.shapeRun(newText, resolvedFont->getFontFace(), uFontSize); + if (!shapedGlyphs.empty()) { + totalWidth = 0.0; + for (const auto& sg : shapedGlyphs) { + totalWidth += sg.advanceX; + } + shapedSuccessful = true; + spdlog::info("Font Engine: HarfBuzz shaped '{}' glyphs, total advance width = {}", shapedGlyphs.size(), totalWidth); + } + } catch (const std::exception& e) { + spdlog::warn("Font Engine: HarfBuzz shaping failed: {}", e.what()); + } catch (...) { + spdlog::warn("Font Engine: HarfBuzz shaping failed with unknown exception"); + } + } + + if (!shapedSuccessful && resolvedFont) { + totalWidth = 0.0; + for (uint32_t cp : unicodeCodepoints) { + double w = resolvedFont->getAdvanceWidth(cp, fontSize); + totalWidth += w; + } + spdlog::info("Font Engine: FreeType fallback total advance width = {}", totalWidth); + } + + // Stage 8: Reflow Engine (bounds calculation and shift) + float origLeft = 999999.0f, origRight = -999999.0f; + float origBottom = 999999.0f, origTop = -999999.0f; + bool hasOrigBounds = false; + for (int idx : objectIndices) { + FPDF_PAGEOBJECT obj = FPDFPage_GetObject(page, idx); + if (obj) { + float left = 0.0f, bottom = 0.0f, right = 0.0f, top = 0.0f; + if (FPDFPageObj_GetBounds(obj, &left, &bottom, &right, &top)) { + if (left < origLeft) origLeft = left; + if (right > origRight) origRight = right; + if (bottom < origBottom) origBottom = bottom; + if (top > origTop) origTop = top; + hasOrigBounds = true; + } + } + } + + double origWidth = 0.0; + double origCenterY = 0.0; + if (hasOrigBounds) { + origWidth = origRight - origLeft; + origCenterY = (origBottom + origTop) / 2.0; + } + + double deltaX = 0.0; + if (hasOrigBounds) { + deltaX = totalWidth - origWidth; + spdlog::info("Reflow Engine: origWidth = {}, newWidth = {}, deltaX = {}", origWidth, totalWidth, deltaX); + } + + if (hasOrigBounds && std::abs(deltaX) > 0.001) { + int pageObjCount = FPDFPage_CountObjects(page); + double tolerance = (std::max)(5.0, fontSize * 0.5); + int reflowedCount = 0; + + for (int k = 0; k < pageObjCount; ++k) { + // Skip if it is one of the replaced objects + if (std::find(objectIndices.begin(), objectIndices.end(), k) != objectIndices.end()) { + continue; + } + + FPDF_PAGEOBJECT otherObj = FPDFPage_GetObject(page, k); + if (otherObj && FPDFPageObj_GetType(otherObj) == FPDF_PAGEOBJ_TEXT) { + float otherLeft = 0.0f, otherBottom = 0.0f, otherRight = 0.0f, otherTop = 0.0f; + if (FPDFPageObj_GetBounds(otherObj, &otherLeft, &otherBottom, &otherRight, &otherTop)) { + double otherCenterY = (otherBottom + otherTop) / 2.0; + // Check if on the same horizontal line + if (std::abs(otherCenterY - origCenterY) <= tolerance) { + // Check if it is to the right of the replaced text run + if (otherLeft >= (origRight - 2.0f)) { + FPDFPageObj_Transform(otherObj, 1.0, 0.0, 0.0, 1.0, deltaX, 0.0); + reflowedCount++; + } + } + } + } + } + spdlog::info("Reflow Engine: shifted {} subsequent text objects on the same line by {}", reflowedCount, deltaX); + } + + // 3. Delete old objects + for (int idx : objectIndices) { + FPDF_PAGEOBJECT objToRemove = FPDFPage_GetObject(page, idx); + if (objToRemove) { + FPDFPage_RemoveObject(page, objToRemove); + FPDFPageObj_Destroy(objToRemove); + } + } + + // 4. Create new text object using standard, embedded, or system font + FPDF_FONT font = nullptr; + std::string cacheKey = ""; + bool useEmbedded = false; + bool useSystem = false; + + if (resolvedFont && matchedFontInfo) { + if (matchedFontInfo->isEmbedded && !isSubsetFont && fontSupportsAll) { + cacheKey = matchedFontInfo->internalFontId; + useEmbedded = true; + } else if (matchedFontInfo->isEmbedded && isSubsetFont && !subsetLacksGlyphs) { + cacheKey = matchedFontInfo->internalFontId; + useEmbedded = true; + } else { + cacheKey = "system_embed_" + matchedFontInfo->fontName + "_" + (bold ? "B" : "") + (italic ? "I" : ""); + useSystem = true; + } + } else { + cacheKey = "standard_" + fontName; + } + + // Check cache first + { + std::lock_guard lock(loadedFontsMutex_); + if (loadedFontsCache_.count(cacheKey)) { + font = loadedFontsCache_[cacheKey]; + spdlog::info("Font Engine: Reusing cached FPDF_FONT for key '{}'", cacheKey); + } + } + + if (!font) { + if (useEmbedded) { + auto fontDataRes = getFontData(matchedFontInfo->internalFontId); + if (fontDataRes.has_value()) { + std::lock_guard lock(loadedFontsMutex_); + loadedFontDataBuffers_[cacheKey] = fontDataRes.value(); + const auto& bytes = loadedFontDataBuffers_[cacheKey]; + font = FPDFText_LoadFont(doc_, bytes.data(), static_cast(bytes.size()), FPDF_FONT_TRUETYPE, false); + if (font) { + spdlog::info("Font Engine: Loaded embedded font '{}' (cache key: {})", matchedFontInfo->fontName, cacheKey); + } + } + } else if (useSystem) { + std::string fontPath = fonts::pdf_fonts::FontFallback::getInstance().getFallbackFontPath( + matchedFontInfo->normalizedFamily.empty() ? matchedFontInfo->fontName : matchedFontInfo->normalizedFamily, + bold, + italic + ); + std::ifstream fs(fontPath, std::ios::binary); + if (fs) { + std::vector fileBytes((std::istreambuf_iterator(fs)), std::istreambuf_iterator()); + if (!fileBytes.empty()) { + std::lock_guard lock(loadedFontsMutex_); + loadedFontDataBuffers_[cacheKey] = std::move(fileBytes); + const auto& bytes = loadedFontDataBuffers_[cacheKey]; + font = FPDFText_LoadFont(doc_, bytes.data(), static_cast(bytes.size()), FPDF_FONT_TRUETYPE, false); + if (font) { + spdlog::info("Font Engine: Embedded system font '{}' from path '{}' (cache key: {})", matchedFontInfo->fontName, fontPath, cacheKey); + } + } + } else { + spdlog::warn("Font Engine: Failed to open system font file '{}' for embedding", fontPath); + } + } + + // Fallback to standard PDF 14 font if loading failed + if (!font) { + spdlog::info("Font Engine: Loading standard PDF font for replace_text: {}", fontName); + font = FPDFText_LoadStandardFont(doc_, fontName.c_str()); + if (!font) { + font = FPDFText_LoadStandardFont(doc_, "Helvetica"); + } + } + + // Cache the loaded font + if (font) { + std::lock_guard lock(loadedFontsMutex_); + loadedFontsCache_[cacheKey] = font; + } + } + + if (font) { + FPDF_PAGEOBJECT newTextObj = FPDFPageObj_CreateTextObj(doc_, font, static_cast(fontSize)); + if (newTextObj) { + FPDFPageObj_SetFillColor(newTextObj, r, g, b_color, a_color); + FPDFTextObj_SetTextRenderMode(newTextObj, renderMode); + FPDFText_SetText(newTextObj, reinterpret_cast(utf16.data())); + FPDFPageObj_Transform(newTextObj, a, b, c, d, e, f); + + FPDFPage_InsertObjectAtIndex(page, newTextObj, minIndex); + } else { + spdlog::error("Failed to create new text object"); + } + } + + if (!FPDFPage_GenerateContent(page)) { + spdlog::error("Failed to generate page content after replace_text"); + } + + FPDF_ClosePage(page); + } else if (type == "text_overlay" || type == "add_text") { if (!op.contains("data") || !op["data"].is_object()) { spdlog::error("text_overlay/add_text operation missing 'data' object"); return std::unexpected(EngineError::InvalidFormat); @@ -2848,6 +3295,13 @@ void PdfiumDocument::invalidateCaches() { std::lock_guard lock(resolvedFontsMutex_); resolvedFontsCache_.clear(); } +#ifdef PDFENGINE_WITH_PDFIUM + { + std::lock_guard lock(loadedFontsMutex_); + loadedFontsCache_.clear(); + loadedFontDataBuffers_.clear(); + } +#endif spdlog::info("Document caches have been invalidated."); } diff --git a/engine/src/parser/pdfium_document.hpp b/engine/src/parser/pdfium_document.hpp index 270b624..30efec4 100644 --- a/engine/src/parser/pdfium_document.hpp +++ b/engine/src/parser/pdfium_document.hpp @@ -110,6 +110,13 @@ private: std::unique_ptr fontResolver_; std::unordered_map> resolvedFontsCache_; std::mutex resolvedFontsMutex_; + + // Loaded PDFium Font Cache for Font Reuse +#ifdef PDFENGINE_WITH_PDFIUM + mutable std::unordered_map loadedFontsCache_; + mutable std::unordered_map> loadedFontDataBuffers_; + mutable std::mutex loadedFontsMutex_; +#endif }; // Exposed for testing diff --git a/engine/tests/document_test.cpp b/engine/tests/document_test.cpp index dc27896..54077eb 100644 --- a/engine/tests/document_test.cpp +++ b/engine/tests/document_test.cpp @@ -11,6 +11,7 @@ #include #include #include "fonts/cache/glyph_cache.hpp" +#include "fonts/pdf_fonts/font.hpp" #ifndef TEST_CORPUS_DIR #define TEST_CORPUS_DIR "../../corpus" #endif @@ -1336,4 +1337,510 @@ TEST(GlyphCacheTest, ConcurrencyBench) { EXPECT_LE(cache.size(), 1000 + 16); // Accommodate shard capacity rounding } +TEST(FontDiagnosticsTest, EmbeddedFontResolutionAndReloadingVerification) { + SKIP_IF_NO_PDFIUM(); + + std::vector testFiles = { + "text_font.pdf", + "embedded_truetype.pdf", + "embedded_cid_font.pdf", + "subset_font.pdf", + "latin_extended.pdf" + }; + + bool foundAnyEmbedded = false; + for (const auto& fileName : testFiles) { + auto path = getCorpusPath("fonts", fileName); + if (!std::filesystem::exists(path)) { + continue; + } + std::cout << "\n========================================\n"; + std::cout << "Testing PDF: " << fileName << "\n"; + std::cout << "========================================\n"; + + auto docRes = PdfDocument::loadFromFile(path.string()); + if (!docRes.has_value()) { + std::cout << "Failed to load document: " << fileName << std::endl; + continue; + } + auto doc = *docRes; + + auto fontsRes = doc->getFonts(); + if (!fontsRes.has_value()) { + std::cout << "Failed to get fonts for: " << fileName << std::endl; + continue; + } + const auto& fonts = *fontsRes; + + for (const auto& fontInfo : fonts) { + std::cout << "Font: " << fontInfo.fontName + << ", type: " << fontInfo.type + << ", isEmbedded: " << (fontInfo.isEmbedded ? "yes" : "no") + << ", flags: " << fontInfo.flags << std::endl; + if (fontInfo.isEmbedded) { + foundAnyEmbedded = true; + + auto resolvedFontRes = doc->getResolvedFont(fontInfo); + if (!resolvedFontRes.has_value()) { + std::cout << " Failed to resolve font: " << resolvedFontRes.error() << std::endl; + continue; + } + auto resolvedFont = *resolvedFontRes; + std::cout << " Resolved font successfully." << std::endl; + + auto face = static_cast(resolvedFont->getFontFace().getFace()); + if (face) { + std::cout << " FreeType Face Num Glyphs: " << face->num_glyphs << std::endl; + std::cout << " FreeType Charmaps Count: " << face->num_charmaps << std::endl; + for (int i = 0; i < face->num_charmaps; ++i) { + FT_CharMap cm = face->charmaps[i]; + std::cout << " Charmap " << i << ": platform_id=" << cm->platform_id + << ", encoding_id=" << cm->encoding_id << std::endl; + + FT_Error err = FT_Set_Charmap(face, cm); + if (err) { + std::cout << " FT_Set_Charmap failed: " << err << std::endl; + continue; + } + + FT_UInt gindex; + FT_ULong charcode = FT_Get_First_Char(face, &gindex); + std::cout << " Mapped characters under charmap " << i << ": "; + int count = 0; + while (gindex != 0 && count < 10) { + std::cout << charcode << "->" << gindex << " "; + charcode = FT_Get_Next_Char(face, charcode, &gindex); + count++; + } + std::cout << std::endl; + } + + // Restore first charmap + if (face->num_charmaps > 0) { + FT_Set_Charmap(face, face->charmaps[0]); + } + + // Print all glyph names in the face + std::cout << " Glyph names: "; + for (int i = 0; i < face->num_glyphs; ++i) { + char nameBuf[64] = {0}; + if (FT_Get_Glyph_Name(face, i, nameBuf, sizeof(nameBuf)) == 0) { + std::cout << i << ":" << nameBuf << " "; + } else { + std::cout << i << ":[unknown] "; + } + } + std::cout << std::endl; + } else { + std::cout << " No FreeType Face available." << std::endl; + } + + EXPECT_TRUE(resolvedFont->isEmbedded()); + + // Let's test a few common characters: 'A' (65), 'a' (97), '0' (48), ' ' (32) + std::vector testChars = {32, 48, 65, 97}; + for (uint32_t cp : testChars) { + bool hasG = resolvedFont->hasGlyph(cp); + double w = resolvedFont->getAdvanceWidth(cp, 12.0); + std::cout << " char(" << cp << "): hasGlyph=" << (hasG ? "yes" : "no") + << ", advanceWidth=" << w << std::endl; + } + + // Verify metrics returned are non-zero/valid + auto metrics = resolvedFont->getMetrics(12.0); + std::cout << " Metrics: ascent=" << metrics.ascent << ", descent=" << metrics.descent << ", capHeight=" << metrics.capHeight << std::endl; + EXPECT_NE(metrics.ascent, 0.0); + EXPECT_NE(metrics.descent, 0.0); + EXPECT_NE(metrics.capHeight, 0.0); + + // Specific verification for text_font.pdf where we mapped charcode 1 -> GID 1 + if (fileName == "text_font.pdf") { + // hasGlyph(1) should return true because the charmap maps 1 -> 1 + EXPECT_TRUE(resolvedFont->hasGlyph(1)); + double w = resolvedFont->getAdvanceWidth(1, 12.0); + EXPECT_GT(w, 0.0); + std::cout << " [VERIFIED] text_font.pdf char(1): hasGlyph=yes, advanceWidth=" << w << std::endl; + } + + // Verify we can load glyphs directly by glyph index (0 to num_glyphs - 1) + if (face->num_glyphs > 1) { + bool foundNonZeroWidth = false; + for (int gid = 1; gid < face->num_glyphs; ++gid) { + FT_Error err = FT_Load_Glyph(face, gid, FT_LOAD_DEFAULT); + if (err == 0) { + double directWidth = static_cast(face->glyph->advance.x) / 64.0; + if (directWidth > 0.0) { + foundNonZeroWidth = true; + std::cout << " [VERIFIED] Direct glyph " << gid << " load: advanceWidth=" << directWidth << std::endl; + break; + } + } + } + EXPECT_TRUE(foundNonZeroWidth) << "Expected to find at least one glyph with a non-zero advance width"; + } + } + } + } + EXPECT_TRUE(foundAnyEmbedded) << "Expected to find at least one embedded font in test files"; } + +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 += ","; + } + + // Flat format payload + 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 += ","; + } + + // JSON payload including internalFontId to resolve + 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; + + // Find the first text run + 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 += ","; + } + + // JSON payload containing replacement using system font embedding + 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()); + + // Save and reload + 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; + + // Get page fonts to verify that Arial was successfully embedded in the new document + 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; + + // Find a line that has at least 2 runs, where the first run uses Roboto-Regular + 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 += ","; + } + + // JSON payload: replacing runA with a very long text to trigger significant shift + 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()); + + // Save and reload + 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; + + // Find runB in the new document model and verify its X coordinate has shifted to the right + 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; +} + +} + + diff --git a/frontend/src/viewer/PDFViewer.tsx b/frontend/src/viewer/PDFViewer.tsx index b8c0cf8..45a1b96 100644 --- a/frontend/src/viewer/PDFViewer.tsx +++ b/frontend/src/viewer/PDFViewer.tsx @@ -244,7 +244,10 @@ export const PDFViewer = React.forwardRef(({ l.runs?.forEach((r: any) => { const isBold = (r.flags & 262144) !== 0 || r.font_name.toLowerCase().includes('bold'); const isItalic = (r.flags & 64) !== 0 || r.font_name.toLowerCase().includes('italic'); - console.log(`Run Text: "${r.text}", Font Name: ${r.font_name}, Font Size: ${r.font_size}, Bold: ${isBold}, Italic: ${isItalic}, Embedded: ${r.is_embedded}, Type: ${r.type}`); + console.log(`Run Text: "${r.text}", Font Name: ${r.font_name}, Font Size: ${r.font_size}, Bold: ${isBold}, Italic: ${isItalic}, Embedded: ${r.is_embedded}, Type: ${r.type}, Object Indices: [${r.object_indices?.join(', ')}]`); + r.glyphs?.forEach((g: any) => { + console.log(` Glyph: "${g.text}", Page Object Index: ${g.page_object_index}`); + }); }); }); }); diff --git a/gateway/app/routers/documents.py b/gateway/app/routers/documents.py index a28a7c8..657ca24 100644 --- a/gateway/app/routers/documents.py +++ b/gateway/app/routers/documents.py @@ -361,6 +361,7 @@ class GlyphModel(BaseModel): bbox_w: float bbox_h: float angle: float + page_object_index: int = -1 class TextRunModel(BaseModel): @@ -376,6 +377,7 @@ class TextRunModel(BaseModel): y: float w: float h: float + object_indices: list[int] = [] class TextLineModel(BaseModel): @@ -441,6 +443,7 @@ def get_page_model(document_id: str, page_index: int) -> PageModelResponse: bbox_w=g.bbox_w, bbox_h=g.bbox_h, angle=g.angle, + page_object_index=g.page_object_index, ) ) runs.append( @@ -457,6 +460,7 @@ def get_page_model(document_id: str, page_index: int) -> PageModelResponse: y=r.y, w=r.w, h=r.h, + object_indices=r.object_indices, ) ) lines.append( diff --git a/gateway/app/routers/edits.py b/gateway/app/routers/edits.py index 45265a3..d2ecd6d 100644 --- a/gateway/app/routers/edits.py +++ b/gateway/app/routers/edits.py @@ -208,6 +208,20 @@ class UpdateAnnotationOperation(BaseModel): data: UpdateAnnotationData +class ReplaceTextData(BaseModel): + objectIndices: list[int] + text: str + internalFontId: str + fontSize: float + + +class ReplaceTextOperation(BaseModel): + id: str + type: Literal["replace_text"] + pageIndex: int = Field(..., ge=0) + data: ReplaceTextData + + EditOperation = Annotated[ TextOverlayOperation | RedactionOperation @@ -221,7 +235,8 @@ EditOperation = Annotated[ | PageReorderOperation | UpdateFieldOperation | DeleteAnnotationOperation - | UpdateAnnotationOperation, + | UpdateAnnotationOperation + | ReplaceTextOperation, Field(discriminator="type"), ] diff --git a/gateway/tests/test_replace_text.py b/gateway/tests/test_replace_text.py new file mode 100644 index 0000000..f3202c4 --- /dev/null +++ b/gateway/tests/test_replace_text.py @@ -0,0 +1,81 @@ +import os +import pytest +from fastapi.testclient import TestClient +from app.main import app + +client = TestClient(app) + +def test_replace_text_operation(): + # 1. Upload hello_world.pdf + filepath = os.path.abspath("../corpus/basic/hello_world.pdf") + if not os.path.exists(filepath): + filepath = os.path.abspath("gateway/../corpus/basic/hello_world.pdf") + + with open(filepath, "rb") as f: + resp = client.post("/documents", files={"file": ("hello_world.pdf", f, "application/pdf")}) + assert resp.status_code == 201 + doc_id = resp.json()["id"] + + # 2. Extract document model + resp = client.get(f"/documents/{doc_id}/pages/0/model") + assert resp.status_code == 200 + model = resp.json() + + # 3. Find a text run to replace (e.g. "Hello, world!" or just any run) + target_run = None + for p in model["paragraphs"]: + for line in p["lines"]: + for run in line["runs"]: + if "hello" in run["text"].lower(): + target_run = run + break + if target_run: + break + if target_run: + break + + assert target_run is not None, "Could not find a text run with 'hello'" + assert "object_indices" in target_run + assert len(target_run["object_indices"]) > 0 + + # Check that glyphs have page_object_index + for g in target_run["glyphs"]: + assert "page_object_index" in g + assert g["page_object_index"] >= 0 + + # 4. Perform replace_text operation + edits_payload = { + "version": "1.0", + "operations": [ + { + "id": "replace_text_op_1", + "type": "replace_text", + "pageIndex": 0, + "data": { + "objectIndices": target_run["object_indices"], + "text": "Greeting, universe!", + "internalFontId": target_run["internal_font_id"], + "fontSize": target_run["font_size"] + } + } + ] + } + + resp = client.post(f"/documents/{doc_id}/edits", json=edits_payload) + assert resp.status_code == 200 + res = resp.json() + assert res["success"] is True + new_doc_id = res["newDocumentId"] + + # 5. Verify text is replaced in new document + resp = client.get(f"/documents/{new_doc_id}/pages/0/text") + assert resp.status_code == 200 + text_data = resp.json() + + assert "Greeting, universe!" in text_data["text"] + assert "hello" not in text_data["text"].lower() + + # 6. Verify page can render visual representation successfully + resp = client.get(f"/documents/{new_doc_id}/pages/0/render") + assert resp.status_code == 200 + assert resp.headers["content-type"] == "image/png"