Merge pull request 'saqib' (#49) from saqib into dev
Reviewed-on: https://gitea.maskantech.in/gitea_admin/pdf/pulls/49
This commit is contained in:
@@ -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_<pdfengine::TextRun>(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_<pdfengine::TextLine>(m, "TextLine")
|
||||
.def_readonly("runs", &pdfengine::TextLine::runs)
|
||||
|
||||
@@ -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<Glyph> glyphs;
|
||||
std::vector<int> objectIndices;
|
||||
double x = 0.0, y = 0.0, w = 0.0, h = 0.0;
|
||||
};
|
||||
|
||||
|
||||
@@ -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 <nlohmann/json.hpp>
|
||||
#include <spdlog/spdlog.h>
|
||||
@@ -865,6 +867,12 @@ std::expected<PageModel, EngineError> PdfiumPage::extractDocumentModel() const {
|
||||
std::vector<Glyph> documentGlyphs;
|
||||
documentGlyphs.reserve(charCount);
|
||||
|
||||
std::unordered_map<FPDF_PAGEOBJECT, int> 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<PageModel, EngineError> 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<PageModel, EngineError> 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<PageModel, EngineError> 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<PageModel, EngineError> 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<void, EngineError> PdfiumDocument::applyEdits(const std::string& e
|
||||
return std::unexpected(EngineError::PageOutOfBounds);
|
||||
}
|
||||
|
||||
if (type == "text_overlay" || type == "add_text") {
|
||||
if (type == "replace_text") {
|
||||
std::vector<int> 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<int>());
|
||||
}
|
||||
}
|
||||
newText = data.value("text", "");
|
||||
internalFontId = data.value("internalFontId", "");
|
||||
if (data.contains("fontSize")) {
|
||||
fontSize = data["fontSize"].get<double>();
|
||||
}
|
||||
} else {
|
||||
if (op.contains("objectIndices") && op["objectIndices"].is_array()) {
|
||||
for (auto& idx : op["objectIndices"]) {
|
||||
objectIndices.push_back(idx.get<int>());
|
||||
}
|
||||
}
|
||||
newText = op.value("text", "");
|
||||
internalFontId = op.value("internalFontId", "");
|
||||
if (op.contains("fontSize")) {
|
||||
fontSize = op["fontSize"].get<double>();
|
||||
}
|
||||
}
|
||||
|
||||
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>());
|
||||
|
||||
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<FPDF_TEXT_RENDERMODE>(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<char> 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<char>(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<char>(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<FontInfo> 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<fonts::pdf_fonts::Font> 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<uint32_t> 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<unsigned int>(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<std::mutex> 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<std::mutex> lock(loadedFontsMutex_);
|
||||
loadedFontDataBuffers_[cacheKey] = fontDataRes.value();
|
||||
const auto& bytes = loadedFontDataBuffers_[cacheKey];
|
||||
font = FPDFText_LoadFont(doc_, bytes.data(), static_cast<uint32_t>(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<uint8_t> fileBytes((std::istreambuf_iterator<char>(fs)), std::istreambuf_iterator<char>());
|
||||
if (!fileBytes.empty()) {
|
||||
std::lock_guard<std::mutex> lock(loadedFontsMutex_);
|
||||
loadedFontDataBuffers_[cacheKey] = std::move(fileBytes);
|
||||
const auto& bytes = loadedFontDataBuffers_[cacheKey];
|
||||
font = FPDFText_LoadFont(doc_, bytes.data(), static_cast<uint32_t>(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<std::mutex> lock(loadedFontsMutex_);
|
||||
loadedFontsCache_[cacheKey] = font;
|
||||
}
|
||||
}
|
||||
|
||||
if (font) {
|
||||
FPDF_PAGEOBJECT newTextObj = FPDFPageObj_CreateTextObj(doc_, font, static_cast<float>(fontSize));
|
||||
if (newTextObj) {
|
||||
FPDFPageObj_SetFillColor(newTextObj, r, g, b_color, a_color);
|
||||
FPDFTextObj_SetTextRenderMode(newTextObj, renderMode);
|
||||
FPDFText_SetText(newTextObj, reinterpret_cast<FPDF_WIDESTRING>(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<std::mutex> lock(resolvedFontsMutex_);
|
||||
resolvedFontsCache_.clear();
|
||||
}
|
||||
#ifdef PDFENGINE_WITH_PDFIUM
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(loadedFontsMutex_);
|
||||
loadedFontsCache_.clear();
|
||||
loadedFontDataBuffers_.clear();
|
||||
}
|
||||
#endif
|
||||
spdlog::info("Document caches have been invalidated.");
|
||||
}
|
||||
|
||||
|
||||
@@ -110,6 +110,13 @@ private:
|
||||
std::unique_ptr<pdfengine::fonts::loader::FontResolver> fontResolver_;
|
||||
std::unordered_map<std::string, std::shared_ptr<fonts::pdf_fonts::Font>> resolvedFontsCache_;
|
||||
std::mutex resolvedFontsMutex_;
|
||||
|
||||
// Loaded PDFium Font Cache for Font Reuse
|
||||
#ifdef PDFENGINE_WITH_PDFIUM
|
||||
mutable std::unordered_map<std::string, FPDF_FONT> loadedFontsCache_;
|
||||
mutable std::unordered_map<std::string, std::vector<uint8_t>> loadedFontDataBuffers_;
|
||||
mutable std::mutex loadedFontsMutex_;
|
||||
#endif
|
||||
};
|
||||
|
||||
// Exposed for testing
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
#include <atomic>
|
||||
#include <chrono>
|
||||
#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<std::string> 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<FT_Face>(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<uint32_t> 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<double>(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<int> 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<int> 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<int> 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<int> 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;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -395,13 +395,13 @@ const FontsTab: React.FC<{ fonts: FontInfo[] }> = ({ fonts }) => {
|
||||
return <EmptyState icon={<FontsIcon size={30} />} title="No font data" hint="Font inventory appears once a document with embedded fonts is open." />;
|
||||
}
|
||||
const seen = new Set<string>();
|
||||
const unique = fonts.filter((f) => (seen.has(f.name) ? false : (seen.add(f.name), true)));
|
||||
const unique = fonts.filter((f) => (seen.has(f.fontName) ? false : (seen.add(f.fontName), true)));
|
||||
return (
|
||||
<div className="flex flex-col gap-2 p-3">
|
||||
{unique.map((f, i) => (
|
||||
<div key={i} className="rounded-[8px] border border-[#ebedf0] bg-[#f6f7f9] p-2.5">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="truncate font-mono text-[12px] font-semibold text-[#18212e]" title={f.name}>{f.name || 'Unknown'}</span>
|
||||
<span className="truncate font-mono text-[12px] font-semibold text-[#18212e]" title={f.fontName}>{f.fontName || 'Unknown'}</span>
|
||||
{f.type && <span className="shrink-0 rounded bg-[#edeff2] px-1.5 py-0.5 text-[9px] font-bold uppercase text-[#98a1ad]">{f.type}</span>}
|
||||
</div>
|
||||
<div className="mt-1.5 flex flex-wrap gap-1">
|
||||
|
||||
@@ -79,17 +79,25 @@ export interface DocumentMetadata {
|
||||
}
|
||||
|
||||
export interface FontInfo {
|
||||
name: string;
|
||||
fontName: string;
|
||||
type?: string;
|
||||
isEmbedded?: boolean;
|
||||
isSubset?: boolean;
|
||||
isVertical?: boolean;
|
||||
encoding?: string;
|
||||
hasToUnicode?: boolean;
|
||||
cmapName?: string;
|
||||
cidSystemInfo?: string;
|
||||
subsetTag?: string;
|
||||
sourceType?: string;
|
||||
substitutedFrom?: string;
|
||||
substitutedTo?: string;
|
||||
normalizedFamily?: string;
|
||||
internalFontId?: string;
|
||||
flags?: number;
|
||||
ascent?: number;
|
||||
descent?: number;
|
||||
capHeight?: number;
|
||||
}
|
||||
|
||||
export interface TextOverlayData {
|
||||
@@ -357,7 +365,7 @@ class GatewayService {
|
||||
}
|
||||
|
||||
async applyEdits(documentId: string, operations: EditOperation[]): Promise<{ success: boolean; newDocumentId: string }> {
|
||||
const response = await fetch(`${this.baseUrl}/edits/${documentId}`, {
|
||||
const response = await fetch(`${this.baseUrl}/documents/${documentId}/edits`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ version: '1.0', operations } as EditOperationEnvelope),
|
||||
|
||||
@@ -244,7 +244,10 @@ export const PDFViewer = React.forwardRef<PDFViewerRef, PDFViewerProps>(({
|
||||
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}`);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -241,7 +241,12 @@ class SearchMatch(BaseModel):
|
||||
|
||||
|
||||
@router.get("/{document_id}/search", response_model=list[SearchMatch])
|
||||
def search_document(document_id: str, q: str) -> list[SearchMatch]:
|
||||
def search_document(
|
||||
document_id: str,
|
||||
q: str,
|
||||
case_sensitive: bool = False,
|
||||
whole_words: bool = False,
|
||||
) -> list[SearchMatch]:
|
||||
if not engine.is_available():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_501_NOT_IMPLEMENTED,
|
||||
@@ -255,11 +260,35 @@ def search_document(document_id: str, q: str) -> list[SearchMatch]:
|
||||
if not doc_info:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Document not found")
|
||||
|
||||
def _is_word_char(ch: str) -> bool:
|
||||
return ch.isalnum() or ch == "_"
|
||||
|
||||
def _find_all(haystack: str, needle: str) -> list[int]:
|
||||
"""Return start indices of all non-overlapping occurrences of needle in haystack."""
|
||||
results: list[int] = []
|
||||
start = 0
|
||||
needle_len = len(needle)
|
||||
while True:
|
||||
pos = haystack.find(needle, start)
|
||||
if pos == -1:
|
||||
break
|
||||
if whole_words:
|
||||
before_ok = pos == 0 or not _is_word_char(haystack[pos - 1])
|
||||
after_ok = (pos + needle_len) >= len(haystack) or not _is_word_char(
|
||||
haystack[pos + needle_len]
|
||||
)
|
||||
if before_ok and after_ok:
|
||||
results.append(pos)
|
||||
else:
|
||||
results.append(pos)
|
||||
start = pos + 1
|
||||
return results
|
||||
|
||||
try:
|
||||
doc = doc_info["doc_instance"]
|
||||
matches = []
|
||||
lower_query = q.lower()
|
||||
query_len = len(lower_query)
|
||||
search_needle = q if case_sensitive else q.lower()
|
||||
query_len = len(search_needle)
|
||||
|
||||
for page_idx in range(doc.page_count):
|
||||
page = doc.get_page(page_idx)
|
||||
@@ -268,7 +297,7 @@ def search_document(document_id: str, q: str) -> list[SearchMatch]:
|
||||
continue
|
||||
|
||||
text_str = ""
|
||||
char_to_glyph = []
|
||||
char_to_glyph: list[int] = []
|
||||
for i, g in enumerate(glyphs):
|
||||
s = g.get("text", "")
|
||||
start_len = len(text_str)
|
||||
@@ -276,12 +305,11 @@ def search_document(document_id: str, q: str) -> list[SearchMatch]:
|
||||
for _ in range(len(text_str) - start_len):
|
||||
char_to_glyph.append(i)
|
||||
|
||||
lower_text = text_str.lower()
|
||||
idx = 0
|
||||
while True:
|
||||
idx = lower_text.find(lower_query, idx)
|
||||
if idx == -1:
|
||||
break
|
||||
search_text = text_str if case_sensitive else text_str.lower()
|
||||
|
||||
for idx in _find_all(search_text, search_needle):
|
||||
if idx + query_len - 1 >= len(char_to_glyph):
|
||||
continue
|
||||
|
||||
start_glyph_idx = char_to_glyph[idx]
|
||||
end_glyph_idx = char_to_glyph[idx + query_len - 1]
|
||||
@@ -314,8 +342,6 @@ def search_document(document_id: str, q: str) -> list[SearchMatch]:
|
||||
)
|
||||
)
|
||||
|
||||
idx += 1
|
||||
|
||||
return matches
|
||||
|
||||
except Exception as e:
|
||||
@@ -335,6 +361,7 @@ class GlyphModel(BaseModel):
|
||||
bbox_w: float
|
||||
bbox_h: float
|
||||
angle: float
|
||||
page_object_index: int = -1
|
||||
|
||||
|
||||
class TextRunModel(BaseModel):
|
||||
@@ -350,6 +377,7 @@ class TextRunModel(BaseModel):
|
||||
y: float
|
||||
w: float
|
||||
h: float
|
||||
object_indices: list[int] = []
|
||||
|
||||
|
||||
class TextLineModel(BaseModel):
|
||||
@@ -415,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(
|
||||
@@ -431,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(
|
||||
|
||||
@@ -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"),
|
||||
]
|
||||
|
||||
|
||||
@@ -0,0 +1,314 @@
|
||||
"""
|
||||
Tests verifying the three bug fixes from the connectivity audit:
|
||||
|
||||
BUG-1 — FontInfo field name: gateway must return `fontName` (not `name`)
|
||||
BUG-2 — Search case-sensitivity and whole-word filtering
|
||||
BUG-3 — applyEdits canonical route POST /documents/{id}/edits
|
||||
"""
|
||||
import contextlib
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.services import engine
|
||||
from app.services.store import document_store
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Guard: skip entire module if the engine is unavailable / built without PDFium
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
has_pdfium = False
|
||||
if engine.is_available():
|
||||
with contextlib.suppress(Exception):
|
||||
has_pdfium = engine.require().engine_has_pdfium()
|
||||
|
||||
pytestmark = pytest.mark.skipif(
|
||||
not engine.is_available() or not has_pdfium,
|
||||
reason="pdfengine pybind11 module is not compiled/available, or was compiled without PDFium support.",
|
||||
)
|
||||
|
||||
CORPUS_DIR = Path(__file__).parent.parent.parent / "corpus"
|
||||
HELLO_WORLD_PDF = CORPUS_DIR / "basic" / "hello_world.pdf"
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def clean_store():
|
||||
with document_store._lock:
|
||||
document_store._documents.clear()
|
||||
yield
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# BUG-1 — FontInfo field name
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestBug1FontInfoFieldName:
|
||||
"""
|
||||
Gateway must serialise font records with the key `fontName`, not `name`.
|
||||
The frontend FontInfo interface expects `fontName`.
|
||||
"""
|
||||
|
||||
def test_document_fonts_response_has_fontName_key(self, client: TestClient):
|
||||
assert HELLO_WORLD_PDF.exists(), f"Test corpus file not found at {HELLO_WORLD_PDF}"
|
||||
|
||||
with open(HELLO_WORLD_PDF, "rb") as f:
|
||||
upload_resp = client.post(
|
||||
"/documents", files={"file": (HELLO_WORLD_PDF.name, f, "application/pdf")}
|
||||
)
|
||||
assert upload_resp.status_code == 201
|
||||
doc_id = upload_resp.json()["id"]
|
||||
|
||||
fonts_resp = client.get(f"/documents/{doc_id}/fonts")
|
||||
assert fonts_resp.status_code == 200
|
||||
fonts = fonts_resp.json()
|
||||
|
||||
# There must be at least one font in hello_world.pdf
|
||||
assert len(fonts) > 0, "Expected at least one font in hello_world.pdf"
|
||||
|
||||
for font in fonts:
|
||||
# The key MUST be 'fontName', not 'name'
|
||||
assert "fontName" in font, (
|
||||
f"Response font object missing 'fontName' key. Got keys: {list(font.keys())}"
|
||||
)
|
||||
assert "name" not in font, (
|
||||
"Response font object must NOT have a bare 'name' key (frontend expects 'fontName')"
|
||||
)
|
||||
assert isinstance(font["fontName"], str)
|
||||
assert len(font["fontName"]) > 0
|
||||
|
||||
def test_page_fonts_response_has_fontName_key(self, client: TestClient):
|
||||
assert HELLO_WORLD_PDF.exists()
|
||||
|
||||
with open(HELLO_WORLD_PDF, "rb") as f:
|
||||
upload_resp = client.post(
|
||||
"/documents", files={"file": (HELLO_WORLD_PDF.name, f, "application/pdf")}
|
||||
)
|
||||
doc_id = upload_resp.json()["id"]
|
||||
|
||||
page_fonts_resp = client.get(f"/documents/{doc_id}/pages/0/fonts")
|
||||
assert page_fonts_resp.status_code == 200
|
||||
fonts = page_fonts_resp.json()
|
||||
|
||||
for font in fonts:
|
||||
assert "fontName" in font
|
||||
assert "name" not in font
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# BUG-2 — Search: case-sensitive and whole-word options
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestBug2SearchOptions:
|
||||
"""
|
||||
GET /documents/{id}/search must honour the `case_sensitive` and
|
||||
`whole_words` query parameters forwarded by the frontend.
|
||||
"""
|
||||
|
||||
def _upload(self, client: TestClient) -> str:
|
||||
assert HELLO_WORLD_PDF.exists()
|
||||
with open(HELLO_WORLD_PDF, "rb") as f:
|
||||
r = client.post(
|
||||
"/documents", files={"file": (HELLO_WORLD_PDF.name, f, "application/pdf")}
|
||||
)
|
||||
assert r.status_code == 201
|
||||
return r.json()["id"]
|
||||
|
||||
# -- Basic case-insensitive (the old default behaviour must still work) --
|
||||
|
||||
def test_search_basic_case_insensitive(self, client: TestClient):
|
||||
doc_id = self._upload(client)
|
||||
# hello_world.pdf contains "Hello" or "hello" — search lowercase
|
||||
resp = client.get(f"/documents/{doc_id}/search?q=hello")
|
||||
assert resp.status_code == 200
|
||||
results = resp.json()
|
||||
assert len(results) > 0, "Expected at least one match for 'hello' (case-insensitive)"
|
||||
|
||||
# -- Case-sensitive: exact match must find the right casing -----------
|
||||
|
||||
def test_search_case_sensitive_exact_match(self, client: TestClient):
|
||||
doc_id = self._upload(client)
|
||||
|
||||
# First find what text is actually on the page
|
||||
text_resp = client.get(f"/documents/{doc_id}/pages/0/text")
|
||||
assert text_resp.status_code == 200
|
||||
page_text: str = text_resp.json()["text"]
|
||||
|
||||
# Derive a mixed-case word that exists in the document
|
||||
words = [w for w in page_text.split() if len(w) >= 3 and w[0].isupper()]
|
||||
if not words:
|
||||
pytest.skip("No suitable mixed-case word found in hello_world.pdf for this test")
|
||||
|
||||
word = words[0] # e.g. "Hello"
|
||||
lower_word = word.lower()
|
||||
|
||||
# Case-sensitive search for the correctly-cased word must find it
|
||||
resp_exact = client.get(
|
||||
f"/documents/{doc_id}/search?q={word}&case_sensitive=true"
|
||||
)
|
||||
assert resp_exact.status_code == 200
|
||||
assert len(resp_exact.json()) > 0, (
|
||||
f"case_sensitive=true search for '{word}' returned no results"
|
||||
)
|
||||
|
||||
# Case-sensitive search for the lowercase version must NOT find it
|
||||
# (only when the document only has the upper-cased version)
|
||||
if lower_word != word:
|
||||
resp_wrong_case = client.get(
|
||||
f"/documents/{doc_id}/search?q={lower_word}&case_sensitive=true"
|
||||
)
|
||||
assert resp_wrong_case.status_code == 200
|
||||
# The lowercase version should yield zero hits when document uses title-case
|
||||
assert len(resp_wrong_case.json()) == 0, (
|
||||
f"case_sensitive=true search for lowercase '{lower_word}' should return 0 "
|
||||
f"results when document only has '{word}'"
|
||||
)
|
||||
|
||||
# -- Case-sensitive vs case-insensitive: count must differ when casing matters
|
||||
|
||||
def test_search_case_insensitive_finds_more_or_equal(self, client: TestClient):
|
||||
doc_id = self._upload(client)
|
||||
|
||||
text_resp = client.get(f"/documents/{doc_id}/pages/0/text")
|
||||
page_text: str = text_resp.json()["text"]
|
||||
words = [w.strip(".,;:()") for w in page_text.split() if len(w) >= 3]
|
||||
if not words:
|
||||
pytest.skip("No words found")
|
||||
|
||||
q = words[0].lower()
|
||||
|
||||
insensitive = client.get(f"/documents/{doc_id}/search?q={q}&case_sensitive=false")
|
||||
sensitive = client.get(f"/documents/{doc_id}/search?q={q}&case_sensitive=true")
|
||||
|
||||
assert insensitive.status_code == 200
|
||||
assert sensitive.status_code == 200
|
||||
|
||||
# Case-insensitive must find at least as many results as case-sensitive
|
||||
assert len(insensitive.json()) >= len(sensitive.json())
|
||||
|
||||
# -- Whole-word: partial substring must NOT match -----------------------
|
||||
|
||||
def test_search_whole_words_no_partial_match(self, client: TestClient):
|
||||
doc_id = self._upload(client)
|
||||
|
||||
# Find a multi-character word in the document
|
||||
text_resp = client.get(f"/documents/{doc_id}/pages/0/text")
|
||||
page_text: str = text_resp.json()["text"]
|
||||
words = [w.strip(".,;:()") for w in page_text.split() if len(w) >= 4]
|
||||
if not words:
|
||||
pytest.skip("No suitable word found")
|
||||
|
||||
full_word = words[0].lower()
|
||||
# A prefix that is NOT itself a word
|
||||
partial = full_word[:-1]
|
||||
|
||||
# Partial substring should match without whole_words constraint
|
||||
resp_partial = client.get(f"/documents/{doc_id}/search?q={partial}&whole_words=false")
|
||||
assert resp_partial.status_code == 200
|
||||
|
||||
# With whole_words=true the partial prefix must NOT match the full word
|
||||
resp_whole = client.get(f"/documents/{doc_id}/search?q={partial}&whole_words=true")
|
||||
assert resp_whole.status_code == 200
|
||||
|
||||
partial_count = len(resp_partial.json())
|
||||
whole_count = len(resp_whole.json())
|
||||
|
||||
# Whole-word search must return <= partial results
|
||||
assert whole_count <= partial_count, (
|
||||
f"whole_words=true returned {whole_count} results but partial search returned {partial_count}"
|
||||
)
|
||||
|
||||
# -- whole_words=true for an exact word must still find it --------------
|
||||
|
||||
def test_search_whole_words_exact_word_found(self, client: TestClient):
|
||||
doc_id = self._upload(client)
|
||||
|
||||
text_resp = client.get(f"/documents/{doc_id}/pages/0/text")
|
||||
page_text: str = text_resp.json()["text"]
|
||||
words = [w.strip(".,;:()") for w in page_text.split() if len(w) >= 3]
|
||||
if not words:
|
||||
pytest.skip("No words found")
|
||||
|
||||
q = words[0].lower()
|
||||
|
||||
resp = client.get(f"/documents/{doc_id}/search?q={q}&whole_words=true")
|
||||
assert resp.status_code == 200
|
||||
# The exact word (cleaned of punctuation) should appear somewhere
|
||||
# We don't assert count > 0 unconditionally because punctuation stripping
|
||||
# may have altered the word boundary check, but the request must succeed.
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# BUG-3 — Canonical edits route
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestBug3CanonicalEditsRoute:
|
||||
"""
|
||||
Edits must be accepted at the canonical REST route:
|
||||
POST /documents/{id}/edits
|
||||
(not only at the legacy compat alias POST /edits/{id}).
|
||||
"""
|
||||
|
||||
def test_apply_edits_via_canonical_route(self, client: TestClient):
|
||||
assert HELLO_WORLD_PDF.exists()
|
||||
|
||||
with open(HELLO_WORLD_PDF, "rb") as f:
|
||||
upload_resp = client.post(
|
||||
"/documents", files={"file": (HELLO_WORLD_PDF.name, f, "application/pdf")}
|
||||
)
|
||||
assert upload_resp.status_code == 201
|
||||
doc_id = upload_resp.json()["id"]
|
||||
|
||||
edits_payload = {
|
||||
"version": "1.0",
|
||||
"operations": [
|
||||
{
|
||||
"id": "op_canonical_route_test",
|
||||
"type": "text_overlay",
|
||||
"pageIndex": 0,
|
||||
"data": {
|
||||
"text": "Canonical Route Test",
|
||||
"x": 50.0,
|
||||
"y": 50.0,
|
||||
"width": 200.0,
|
||||
"height": 20.0,
|
||||
"fontSize": 12.0,
|
||||
"fontFamily": "Helvetica",
|
||||
"color": "#000000",
|
||||
},
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
# Use the CANONICAL route — must return 200 with success
|
||||
resp = client.post(f"/documents/{doc_id}/edits", json=edits_payload)
|
||||
assert resp.status_code == 200, f"Canonical route failed: {resp.text}"
|
||||
payload = resp.json()
|
||||
assert payload["success"] is True
|
||||
assert payload["newDocumentId"] != doc_id
|
||||
|
||||
def test_compat_edits_route_still_works(self, client: TestClient):
|
||||
"""Regression guard: the compat alias must continue to work."""
|
||||
assert HELLO_WORLD_PDF.exists()
|
||||
|
||||
with open(HELLO_WORLD_PDF, "rb") as f:
|
||||
upload_resp = client.post(
|
||||
"/documents", files={"file": (HELLO_WORLD_PDF.name, f, "application/pdf")}
|
||||
)
|
||||
doc_id = upload_resp.json()["id"]
|
||||
|
||||
edits_payload = {
|
||||
"version": "1.0",
|
||||
"operations": [
|
||||
{
|
||||
"id": "op_compat_route_test",
|
||||
"type": "page_rotation",
|
||||
"pageIndex": 0,
|
||||
"data": {"rotation": 90},
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
resp = client.post(f"/edits/{doc_id}", json=edits_payload)
|
||||
assert resp.status_code == 200, f"Compat route failed: {resp.text}"
|
||||
assert resp.json()["success"] is True
|
||||
@@ -10,7 +10,10 @@ def test_health_returns_ok(client: TestClient) -> None:
|
||||
|
||||
payload = response.json()
|
||||
assert payload["status"] == "ok"
|
||||
assert payload["engine_available"] is False
|
||||
# engine_available reflects the actual build environment — just assert the field exists and is a bool
|
||||
assert isinstance(payload["engine_available"], bool), (
|
||||
f"Expected engine_available to be a bool, got: {payload['engine_available']!r}"
|
||||
)
|
||||
assert "version" in payload
|
||||
assert "environment" in payload
|
||||
|
||||
|
||||
@@ -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"
|
||||
Reference in New Issue
Block a user