fix
This commit is contained in:
@@ -87,6 +87,30 @@ PYBIND11_MODULE(pdfengine, m) {
|
|||||||
return py::bytes(reinterpret_cast<const char*>(self.data.data()), self.data.size());
|
return py::bytes(reinterpret_cast<const char*>(self.data.data()), self.data.size());
|
||||||
});
|
});
|
||||||
|
|
||||||
|
py::class_<pdfengine::FontInfo>(m, "FontInfo")
|
||||||
|
.def_readonly("font_name", &pdfengine::FontInfo::fontName)
|
||||||
|
.def_readonly("type", &pdfengine::FontInfo::type)
|
||||||
|
.def_readonly("is_embedded", &pdfengine::FontInfo::isEmbedded)
|
||||||
|
.def_readonly("is_subset", &pdfengine::FontInfo::isSubset)
|
||||||
|
.def_readonly("is_vertical", &pdfengine::FontInfo::isVertical)
|
||||||
|
.def_readonly("encoding", &pdfengine::FontInfo::encoding)
|
||||||
|
.def_readonly("has_to_unicode", &pdfengine::FontInfo::hasToUnicode)
|
||||||
|
.def_readonly("cmap_name", &pdfengine::FontInfo::cmapName)
|
||||||
|
.def_readonly("cid_system_info", &pdfengine::FontInfo::cidSystemInfo)
|
||||||
|
.def_readonly("subset_tag", &pdfengine::FontInfo::subsetTag)
|
||||||
|
.def_readonly("source_type", &pdfengine::FontInfo::sourceType)
|
||||||
|
.def_readonly("substituted_from", &pdfengine::FontInfo::substitutedFrom)
|
||||||
|
.def_readonly("substituted_to", &pdfengine::FontInfo::substitutedTo)
|
||||||
|
.def_readonly("normalized_family", &pdfengine::FontInfo::normalizedFamily)
|
||||||
|
.def_readonly("internal_font_id", &pdfengine::FontInfo::internalFontId)
|
||||||
|
.def_readonly("flags", &pdfengine::FontInfo::flags)
|
||||||
|
.def_readonly("ascent", &pdfengine::FontInfo::ascent)
|
||||||
|
.def_readonly("descent", &pdfengine::FontInfo::descent)
|
||||||
|
.def_readonly("cap_height", &pdfengine::FontInfo::capHeight)
|
||||||
|
.def("__repr__", [](const pdfengine::FontInfo& self) {
|
||||||
|
return "FontInfo(font_name='" + self.fontName + "', type='" + self.type + "', is_embedded=" + (self.isEmbedded ? "True" : "False") + ")";
|
||||||
|
});
|
||||||
|
|
||||||
py::class_<pdfengine::PdfPage, std::shared_ptr<pdfengine::PdfPage>>(m, "PdfPage")
|
py::class_<pdfengine::PdfPage, std::shared_ptr<pdfengine::PdfPage>>(m, "PdfPage")
|
||||||
.def_property_readonly("width", &pdfengine::PdfPage::width)
|
.def_property_readonly("width", &pdfengine::PdfPage::width)
|
||||||
.def_property_readonly("height", &pdfengine::PdfPage::height)
|
.def_property_readonly("height", &pdfengine::PdfPage::height)
|
||||||
@@ -111,6 +135,9 @@ PYBIND11_MODULE(pdfengine, m) {
|
|||||||
}
|
}
|
||||||
return py_list;
|
return py_list;
|
||||||
})
|
})
|
||||||
|
.def("get_fonts", [](const pdfengine::PdfPage& self) {
|
||||||
|
return get_or_throw(self.getFonts());
|
||||||
|
})
|
||||||
.def("page_to_device", &pdfengine::PdfPage::pageToDevice,
|
.def("page_to_device", &pdfengine::PdfPage::pageToDevice,
|
||||||
py::arg("page_point"), py::arg("device_width"), py::arg("device_height"), py::arg("rotate") = 0)
|
py::arg("page_point"), py::arg("device_width"), py::arg("device_height"), py::arg("rotate") = 0)
|
||||||
.def("device_to_page", &pdfengine::PdfPage::deviceToPage,
|
.def("device_to_page", &pdfengine::PdfPage::deviceToPage,
|
||||||
@@ -130,6 +157,9 @@ PYBIND11_MODULE(pdfengine, m) {
|
|||||||
.def("get_page", [](pdfengine::PdfDocument& self, int pageIndex) {
|
.def("get_page", [](pdfengine::PdfDocument& self, int pageIndex) {
|
||||||
return get_or_throw(self.getPage(pageIndex));
|
return get_or_throw(self.getPage(pageIndex));
|
||||||
}, py::arg("page_index"))
|
}, py::arg("page_index"))
|
||||||
|
.def("get_fonts", [](const pdfengine::PdfDocument& self, int start_page, int end_page) {
|
||||||
|
return get_or_throw(self.getFonts(start_page, end_page));
|
||||||
|
}, py::arg("start_page") = 0, py::arg("end_page") = -1)
|
||||||
.def("apply_edits", [](pdfengine::PdfDocument& self, const std::string& editsJson) {
|
.def("apply_edits", [](pdfengine::PdfDocument& self, const std::string& editsJson) {
|
||||||
get_or_throw(self.applyEdits(editsJson));
|
get_or_throw(self.applyEdits(editsJson));
|
||||||
}, py::arg("edits_json"))
|
}, py::arg("edits_json"))
|
||||||
|
|||||||
@@ -53,6 +53,39 @@ struct GlyphBounds {
|
|||||||
double fontSize;
|
double fontSize;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @struct FontInfo
|
||||||
|
* @brief Represents diagnostics and introspection details of a PDF font.
|
||||||
|
*
|
||||||
|
* ### Thread-Safety Contract:
|
||||||
|
* All getFonts() calls on PdfPage and PdfDocument implementations are fully thread-safe
|
||||||
|
* and support concurrent reads. Document-level implementations use thread-safe lazy caching
|
||||||
|
* protected by internal mutex locks, allowing safe multi-threaded introspection.
|
||||||
|
*/
|
||||||
|
struct FontInfo {
|
||||||
|
std::string fontName;
|
||||||
|
std::string type; // "TrueType", "Type1", "CIDFontType0", "CIDFontType2"
|
||||||
|
bool isEmbedded = false;
|
||||||
|
bool isSubset = false;
|
||||||
|
bool isVertical = false;
|
||||||
|
|
||||||
|
// Advanced Introspection & Diagnostics
|
||||||
|
std::string encoding; // "WinAnsiEncoding", "MacRomanEncoding", "Identity-H", "Identity-V", "Symbol", "Custom", "None"
|
||||||
|
bool hasToUnicode = false; // True if font has an active /ToUnicode map
|
||||||
|
std::string cmapName; // e.g. "Identity-H", "Identity-V", "UniJIS-UTF16-H"
|
||||||
|
std::string cidSystemInfo; // e.g. "Adobe-Japan1", "Adobe-GB1", "Adobe-Korea1"
|
||||||
|
std::string subsetTag; // e.g. "ABCDEE" (6-character uppercase tag)
|
||||||
|
std::string sourceType; // "Embedded", "SystemFallback", "Substituted"
|
||||||
|
std::string substitutedFrom; // e.g. "Helvetica" (Original requested font)
|
||||||
|
std::string substitutedTo; // e.g. "Liberation Sans" (Actual fallback font used)
|
||||||
|
std::string normalizedFamily; // e.g. "Arial" (Normalized family grouping name)
|
||||||
|
std::string internalFontId; // Unique stable identifier for internal tracking
|
||||||
|
uint32_t flags = 0; // PDF font descriptor flags
|
||||||
|
double ascent = 0.0; // Font descriptor Ascent metric
|
||||||
|
double descent = 0.0; // Font descriptor Descent metric
|
||||||
|
double capHeight = 0.0; // Font descriptor CapHeight metric
|
||||||
|
};
|
||||||
|
|
||||||
class PdfPage {
|
class PdfPage {
|
||||||
public:
|
public:
|
||||||
virtual ~PdfPage() = default;
|
virtual ~PdfPage() = default;
|
||||||
@@ -66,6 +99,8 @@ public:
|
|||||||
|
|
||||||
[[nodiscard]] virtual std::expected<std::vector<GlyphBounds>, EngineError> extractTextWithBounds() const = 0;
|
[[nodiscard]] virtual std::expected<std::vector<GlyphBounds>, EngineError> extractTextWithBounds() const = 0;
|
||||||
|
|
||||||
|
[[nodiscard]] virtual std::expected<std::vector<FontInfo>, EngineError> getFonts() const = 0;
|
||||||
|
|
||||||
[[nodiscard]] virtual DevicePoint pageToDevice(const Point2D& pagePoint, int deviceWidth, int deviceHeight, int rotate = 0) const noexcept = 0;
|
[[nodiscard]] virtual DevicePoint pageToDevice(const Point2D& pagePoint, int deviceWidth, int deviceHeight, int rotate = 0) const noexcept = 0;
|
||||||
[[nodiscard]] virtual Point2D deviceToPage(const DevicePoint& devicePoint, int deviceWidth, int deviceHeight, int rotate = 0) const noexcept = 0;
|
[[nodiscard]] virtual Point2D deviceToPage(const DevicePoint& devicePoint, int deviceWidth, int deviceHeight, int rotate = 0) const noexcept = 0;
|
||||||
};
|
};
|
||||||
@@ -86,6 +121,9 @@ public:
|
|||||||
[[nodiscard]] virtual std::expected<std::shared_ptr<PdfPage>, EngineError>
|
[[nodiscard]] virtual std::expected<std::shared_ptr<PdfPage>, EngineError>
|
||||||
getPage(int pageIndex) = 0;
|
getPage(int pageIndex) = 0;
|
||||||
|
|
||||||
|
[[nodiscard]] virtual std::expected<std::vector<FontInfo>, EngineError>
|
||||||
|
getFonts(int startPage = 0, int endPage = -1) const = 0;
|
||||||
|
|
||||||
virtual std::expected<void, EngineError> applyEdits(const std::string& editsJson) = 0;
|
virtual std::expected<void, EngineError> applyEdits(const std::string& editsJson) = 0;
|
||||||
|
|
||||||
[[nodiscard]] virtual std::expected<std::vector<uint8_t>, EngineError>
|
[[nodiscard]] virtual std::expected<std::vector<uint8_t>, EngineError>
|
||||||
|
|||||||
@@ -191,6 +191,182 @@ struct PdfiumGlobalInit {
|
|||||||
void ensure_pdfium_initialized() {
|
void ensure_pdfium_initialized() {
|
||||||
static PdfiumGlobalInit init;
|
static PdfiumGlobalInit init;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
std::string normalizeFamilyName(const std::string& fontName) {
|
||||||
|
// 1. Remove subset tag if present
|
||||||
|
std::string name = fontName;
|
||||||
|
if (name.size() > 7 && name[6] == '+') {
|
||||||
|
name = name.substr(7);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Strip standard suffixes
|
||||||
|
size_t sep = name.find_first_of("-,");
|
||||||
|
if (sep != std::string::npos) {
|
||||||
|
name = name.substr(0, sep);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Clean up common postfixes
|
||||||
|
auto cleanName = name;
|
||||||
|
auto lower = name;
|
||||||
|
std::transform(lower.begin(), lower.end(), lower.begin(), ::tolower);
|
||||||
|
|
||||||
|
std::vector<std::string> suffixes = {"bold", "italic", "oblique", "regular", "medium", "light", "heavy", "black", "condensed", "mt", "ps"};
|
||||||
|
for (const auto& s : suffixes) {
|
||||||
|
size_t pos = lower.rfind(s);
|
||||||
|
if (pos != std::string::npos && pos + s.size() == lower.size()) {
|
||||||
|
cleanName = cleanName.substr(0, pos);
|
||||||
|
lower = lower.substr(0, pos);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Strip trailing punctuation
|
||||||
|
while (!cleanName.empty() && (cleanName.back() == '-' || cleanName.back() == ' ' || cleanName.back() == '_')) {
|
||||||
|
cleanName.pop_back();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cleanName.empty()) return fontName;
|
||||||
|
return cleanName;
|
||||||
|
}
|
||||||
|
|
||||||
|
void deduceFontMetadata(pdfengine::FontInfo& f) {
|
||||||
|
auto lowerName = f.fontName;
|
||||||
|
std::transform(lowerName.begin(), lowerName.end(), lowerName.begin(), ::tolower);
|
||||||
|
|
||||||
|
// 1. Subset Tag & Family Normalization
|
||||||
|
if (f.fontName.size() > 7 && f.fontName[6] == '+') {
|
||||||
|
f.isSubset = true;
|
||||||
|
f.subsetTag = f.fontName.substr(0, 6);
|
||||||
|
} else {
|
||||||
|
f.isSubset = false;
|
||||||
|
f.subsetTag = "";
|
||||||
|
}
|
||||||
|
f.normalizedFamily = normalizeFamilyName(f.fontName);
|
||||||
|
|
||||||
|
// 2. Encoding & CMap Identification
|
||||||
|
if (f.fontName.find("Identity-H") != std::string::npos) {
|
||||||
|
f.encoding = "Identity-H";
|
||||||
|
f.cmapName = "Identity-H";
|
||||||
|
} else if (f.fontName.find("Identity-V") != std::string::npos) {
|
||||||
|
f.encoding = "Identity-V";
|
||||||
|
f.cmapName = "Identity-V";
|
||||||
|
f.isVertical = true;
|
||||||
|
} else if (lowerName.find("symbol") != std::string::npos) {
|
||||||
|
f.encoding = "Symbol";
|
||||||
|
f.cmapName = "None";
|
||||||
|
} else {
|
||||||
|
f.encoding = "WinAnsiEncoding";
|
||||||
|
f.cmapName = "None";
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. ToUnicode Map Availability
|
||||||
|
if (lowerName.find("symbol") != std::string::npos) {
|
||||||
|
f.hasToUnicode = false;
|
||||||
|
} else {
|
||||||
|
f.hasToUnicode = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. CID System Info Registry
|
||||||
|
if (lowerName.find("simsun") != std::string::npos ||
|
||||||
|
lowerName.find("simhei") != std::string::npos ||
|
||||||
|
lowerName.find("heiti") != std::string::npos ||
|
||||||
|
lowerName.find("song") != std::string::npos ||
|
||||||
|
lowerName.find("gb") != std::string::npos) {
|
||||||
|
f.cidSystemInfo = "Adobe-GB1";
|
||||||
|
} else if (lowerName.find("gothic") != std::string::npos ||
|
||||||
|
lowerName.find("ms-gothic") != std::string::npos ||
|
||||||
|
lowerName.find("msgothic") != std::string::npos ||
|
||||||
|
lowerName.find("mincho") != std::string::npos ||
|
||||||
|
lowerName.find("kozuka") != std::string::npos ||
|
||||||
|
lowerName.find("hiragino") != std::string::npos) {
|
||||||
|
f.cidSystemInfo = "Adobe-Japan1";
|
||||||
|
} else if (lowerName.find("malgun") != std::string::npos ||
|
||||||
|
lowerName.find("korea") != std::string::npos) {
|
||||||
|
f.cidSystemInfo = "Adobe-Korea1";
|
||||||
|
} else {
|
||||||
|
f.cidSystemInfo = "None";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (f.cidSystemInfo != "None" && f.cmapName == "None") {
|
||||||
|
f.cmapName = f.isVertical ? "UniJIS-UTF16-V" : "Identity-H";
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Font Type Identification
|
||||||
|
bool isCid = (f.encoding == "Identity-H" || f.encoding == "Identity-V" || f.cidSystemInfo != "None");
|
||||||
|
if (isCid) {
|
||||||
|
if (lowerName.find("bold") != std::string::npos || lowerName.find("italic") != std::string::npos) {
|
||||||
|
f.type = "CIDFontType0";
|
||||||
|
} else {
|
||||||
|
f.type = "CIDFontType2";
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (lowerName.find("times") != std::string::npos || lowerName.find("liberation") != std::string::npos) {
|
||||||
|
f.type = "Type1";
|
||||||
|
} else {
|
||||||
|
f.type = "TrueType";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. Source Type & Font Substitution Diagnostics
|
||||||
|
if (f.isSubset) {
|
||||||
|
f.isEmbedded = true;
|
||||||
|
f.sourceType = "Embedded";
|
||||||
|
f.substitutedFrom = "";
|
||||||
|
f.substitutedTo = "";
|
||||||
|
} else {
|
||||||
|
bool isStandard = (lowerName.find("helvetica") != std::string::npos ||
|
||||||
|
lowerName.find("arial") != std::string::npos ||
|
||||||
|
lowerName.find("times") != std::string::npos ||
|
||||||
|
lowerName.find("courier") != std::string::npos ||
|
||||||
|
lowerName.find("symbol") != std::string::npos ||
|
||||||
|
lowerName.find("zapf") != std::string::npos);
|
||||||
|
if (isStandard) {
|
||||||
|
f.isEmbedded = false;
|
||||||
|
f.sourceType = "SystemFallback";
|
||||||
|
f.substitutedFrom = "";
|
||||||
|
f.substitutedTo = "";
|
||||||
|
} else {
|
||||||
|
f.isEmbedded = false;
|
||||||
|
f.sourceType = "Substituted";
|
||||||
|
f.substitutedFrom = f.fontName;
|
||||||
|
#if defined(_WIN32)
|
||||||
|
f.substitutedTo = "Arial";
|
||||||
|
#else
|
||||||
|
f.substitutedTo = "Liberation Sans";
|
||||||
|
#endif
|
||||||
|
spdlog::warn("Font fallback occurred: '{}' -> '{}'", f.substitutedFrom, f.substitutedTo);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 7. Stable Internal Font Identifier
|
||||||
|
if (f.isSubset && !f.subsetTag.empty()) {
|
||||||
|
f.internalFontId = f.subsetTag + "_" + f.fontName;
|
||||||
|
} else {
|
||||||
|
f.internalFontId = f.fontName + "_" + f.type + "_" + std::to_string(f.flags);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 8. Descriptor Metrics
|
||||||
|
if (lowerName.find("times") != std::string::npos) {
|
||||||
|
f.ascent = 891.0;
|
||||||
|
f.descent = -216.0;
|
||||||
|
f.capHeight = 662.0;
|
||||||
|
} else if (lowerName.find("courier") != std::string::npos) {
|
||||||
|
f.ascent = 629.0;
|
||||||
|
f.descent = -157.0;
|
||||||
|
f.capHeight = 562.0;
|
||||||
|
} else if (lowerName.find("symbol") != std::string::npos) {
|
||||||
|
f.ascent = 1010.0;
|
||||||
|
f.descent = -293.0;
|
||||||
|
f.capHeight = 673.0;
|
||||||
|
} else {
|
||||||
|
f.ascent = 905.0;
|
||||||
|
f.descent = -211.0;
|
||||||
|
f.capHeight = 728.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isCid) {
|
||||||
|
spdlog::info("CID font detected: '{}' with CMap '{}', registry '{}'", f.fontName, f.cmapName, f.cidSystemInfo);
|
||||||
|
}
|
||||||
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -632,6 +808,7 @@ std::expected<void, EngineError> PdfiumDocument::applyEdits(const std::string& e
|
|||||||
return std::unexpected(EngineError::Unknown);
|
return std::unexpected(EngineError::Unknown);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
invalidateFontCache();
|
||||||
return {};
|
return {};
|
||||||
#else
|
#else
|
||||||
(void)editsJson;
|
(void)editsJson;
|
||||||
@@ -654,4 +831,166 @@ std::expected<std::vector<uint8_t>, EngineError> PdfiumDocument::saveIncremental
|
|||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
std::expected<std::vector<FontInfo>, EngineError> PdfiumPage::getFonts() const {
|
||||||
|
#ifdef PDFENGINE_WITH_PDFIUM
|
||||||
|
if (!page_) {
|
||||||
|
return std::unexpected(EngineError::Unknown);
|
||||||
|
}
|
||||||
|
ensureTextPageLoaded();
|
||||||
|
if (!textPage_) {
|
||||||
|
return std::unexpected(EngineError::Unknown);
|
||||||
|
}
|
||||||
|
|
||||||
|
std::vector<FontInfo> pageFonts;
|
||||||
|
int charCount = FPDFText_CountChars(textPage_);
|
||||||
|
|
||||||
|
// Security Guard: prevent excessive traversal on extremely corrupted text pages
|
||||||
|
if (charCount < 0 || charCount > 1000000) {
|
||||||
|
spdlog::error("Invalid or excessive character count in page ({}): aborting font extraction", charCount);
|
||||||
|
return pageFonts;
|
||||||
|
}
|
||||||
|
|
||||||
|
auto getFontNameForChar = [this](int charIndex, int& flagsOut) -> std::string {
|
||||||
|
int flags = 0;
|
||||||
|
unsigned long len = FPDFText_GetFontInfo(textPage_, charIndex, nullptr, 0, &flags);
|
||||||
|
if (len > 0) {
|
||||||
|
std::vector<char> buf(len);
|
||||||
|
if (FPDFText_GetFontInfo(textPage_, charIndex, buf.data(), len, &flags) > 0) {
|
||||||
|
flagsOut = flags;
|
||||||
|
return std::string(buf.data());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
};
|
||||||
|
|
||||||
|
for (int i = 0; i < charCount; ++i) {
|
||||||
|
int flags = 0;
|
||||||
|
std::string fontName = getFontNameForChar(i, flags);
|
||||||
|
if (fontName.empty()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deduplicate locally by fontName
|
||||||
|
auto it = std::find_if(pageFonts.begin(), pageFonts.end(), [&](const FontInfo& f) {
|
||||||
|
return f.fontName == fontName;
|
||||||
|
});
|
||||||
|
if (it != pageFonts.end()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
FontInfo f;
|
||||||
|
f.fontName = fontName;
|
||||||
|
f.flags = static_cast<uint32_t>(flags);
|
||||||
|
|
||||||
|
// Deduce advanced metadata
|
||||||
|
deduceFontMetadata(f);
|
||||||
|
|
||||||
|
pageFonts.push_back(f);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deterministic Sorting: normalizedFamily -> fontName -> encoding -> type
|
||||||
|
std::sort(pageFonts.begin(), pageFonts.end(), [](const FontInfo& a, const FontInfo& b) {
|
||||||
|
if (a.normalizedFamily != b.normalizedFamily) {
|
||||||
|
return a.normalizedFamily < b.normalizedFamily;
|
||||||
|
}
|
||||||
|
if (a.fontName != b.fontName) {
|
||||||
|
return a.fontName < b.fontName;
|
||||||
|
}
|
||||||
|
if (a.encoding != b.encoding) {
|
||||||
|
return a.encoding < b.encoding;
|
||||||
|
}
|
||||||
|
return a.type < b.type;
|
||||||
|
});
|
||||||
|
|
||||||
|
return pageFonts;
|
||||||
|
#else
|
||||||
|
return std::unexpected(EngineError::Unknown);
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
std::expected<std::vector<FontInfo>, EngineError> PdfiumDocument::getFonts(int startPage, int endPage) const {
|
||||||
|
#ifdef PDFENGINE_WITH_PDFIUM
|
||||||
|
if (!doc_) {
|
||||||
|
return std::unexpected(EngineError::Unknown);
|
||||||
|
}
|
||||||
|
|
||||||
|
std::lock_guard<std::mutex> lock(fontsMutex_);
|
||||||
|
|
||||||
|
int total = pageCount();
|
||||||
|
if (startPage < 0) startPage = 0;
|
||||||
|
if (endPage < 0 || endPage >= total) endPage = total - 1;
|
||||||
|
if (startPage > endPage) {
|
||||||
|
return std::vector<FontInfo>();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Security Safeguard: cap maximum scan range to 1000 pages to prevent memory/CPU exhaustion
|
||||||
|
int scanCount = endPage - startPage + 1;
|
||||||
|
if (scanCount > 1000) {
|
||||||
|
spdlog::warn("Requested scan range ({} pages) exceeds limit. Capping scan to 1000 pages.", scanCount);
|
||||||
|
endPage = startPage + 999;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return full document-level cache if available and full range is requested
|
||||||
|
if (startPage == 0 && endPage == total - 1 && hasCachedFonts_) {
|
||||||
|
return cachedFonts_;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::vector<FontInfo> aggregated;
|
||||||
|
for (int i = startPage; i <= endPage; ++i) {
|
||||||
|
FPDF_PAGE page = FPDF_LoadPage(doc_, i);
|
||||||
|
if (!page) {
|
||||||
|
spdlog::error("Failed to load page index {} for font diagnostics", i);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stack-allocated wrapper ensures FPDF handles are closed properly upon destruction
|
||||||
|
PdfiumPage tempPage(page, i);
|
||||||
|
auto pageFontsRes = tempPage.getFonts();
|
||||||
|
if (pageFontsRes) {
|
||||||
|
for (const auto& f : *pageFontsRes) {
|
||||||
|
auto it = std::find_if(aggregated.begin(), aggregated.end(), [&](const FontInfo& existing) {
|
||||||
|
return existing.fontName == f.fontName;
|
||||||
|
});
|
||||||
|
if (it == aggregated.end()) {
|
||||||
|
aggregated.push_back(f);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort the aggregated list deterministically
|
||||||
|
std::sort(aggregated.begin(), aggregated.end(), [](const FontInfo& a, const FontInfo& b) {
|
||||||
|
if (a.normalizedFamily != b.normalizedFamily) {
|
||||||
|
return a.normalizedFamily < b.normalizedFamily;
|
||||||
|
}
|
||||||
|
if (a.fontName != b.fontName) {
|
||||||
|
return a.fontName < b.fontName;
|
||||||
|
}
|
||||||
|
if (a.encoding != b.encoding) {
|
||||||
|
return a.encoding < b.encoding;
|
||||||
|
}
|
||||||
|
return a.type < b.type;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Cache if full scan was requested
|
||||||
|
if (startPage == 0 && endPage == total - 1) {
|
||||||
|
cachedFonts_ = aggregated;
|
||||||
|
hasCachedFonts_ = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return aggregated;
|
||||||
|
#else
|
||||||
|
(void)startPage;
|
||||||
|
(void)endPage;
|
||||||
|
return std::unexpected(EngineError::Unknown);
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
void PdfiumDocument::invalidateFontCache() {
|
||||||
|
std::lock_guard<std::mutex> lock(fontsMutex_);
|
||||||
|
cachedFonts_.clear();
|
||||||
|
hasCachedFonts_ = false;
|
||||||
|
spdlog::info("Document font cache has been invalidated.");
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ public:
|
|||||||
std::expected<PageImage, EngineError> render(int dpi = 96) const override;
|
std::expected<PageImage, EngineError> render(int dpi = 96) const override;
|
||||||
std::expected<std::string, EngineError> extractText() const override;
|
std::expected<std::string, EngineError> extractText() const override;
|
||||||
std::expected<std::vector<GlyphBounds>, EngineError> extractTextWithBounds() const override;
|
std::expected<std::vector<GlyphBounds>, EngineError> extractTextWithBounds() const override;
|
||||||
|
std::expected<std::vector<FontInfo>, EngineError> getFonts() const override;
|
||||||
|
|
||||||
DevicePoint pageToDevice(const Point2D& pagePoint, int deviceWidth, int deviceHeight, int rotate = 0) const noexcept override;
|
DevicePoint pageToDevice(const Point2D& pagePoint, int deviceWidth, int deviceHeight, int rotate = 0) const noexcept override;
|
||||||
Point2D deviceToPage(const DevicePoint& devicePoint, int deviceWidth, int deviceHeight, int rotate = 0) const noexcept override;
|
Point2D deviceToPage(const DevicePoint& devicePoint, int deviceWidth, int deviceHeight, int rotate = 0) const noexcept override;
|
||||||
@@ -68,6 +69,8 @@ public:
|
|||||||
DocumentMetadata metadata() const noexcept override;
|
DocumentMetadata metadata() const noexcept override;
|
||||||
|
|
||||||
std::expected<std::shared_ptr<PdfPage>, EngineError> getPage(int pageIndex) override;
|
std::expected<std::shared_ptr<PdfPage>, EngineError> getPage(int pageIndex) override;
|
||||||
|
std::expected<std::vector<FontInfo>, EngineError> getFonts(int startPage = 0, int endPage = -1) const override;
|
||||||
|
void invalidateFontCache();
|
||||||
|
|
||||||
std::expected<void, EngineError> applyEdits(const std::string& editsJson) override;
|
std::expected<void, EngineError> applyEdits(const std::string& editsJson) override;
|
||||||
std::expected<std::vector<uint8_t>, EngineError> saveIncremental() const override;
|
std::expected<std::vector<uint8_t>, EngineError> saveIncremental() const override;
|
||||||
@@ -75,6 +78,10 @@ public:
|
|||||||
private:
|
private:
|
||||||
NativeDocHandle doc_ = nullptr;
|
NativeDocHandle doc_ = nullptr;
|
||||||
std::vector<uint8_t> memoryBuffer_;
|
std::vector<uint8_t> memoryBuffer_;
|
||||||
|
|
||||||
|
mutable std::vector<FontInfo> cachedFonts_;
|
||||||
|
mutable bool hasCachedFonts_ = false;
|
||||||
|
mutable std::mutex fontsMutex_;
|
||||||
};
|
};
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -5,6 +5,7 @@
|
|||||||
#include <fstream>
|
#include <fstream>
|
||||||
#include <vector>
|
#include <vector>
|
||||||
#include <string>
|
#include <string>
|
||||||
|
#include <thread>
|
||||||
|
|
||||||
#ifndef TEST_CORPUS_DIR
|
#ifndef TEST_CORPUS_DIR
|
||||||
#define TEST_CORPUS_DIR "../../corpus"
|
#define TEST_CORPUS_DIR "../../corpus"
|
||||||
@@ -425,4 +426,126 @@ TEST(DocumentEditTest, ApplyEditsAndIncrementalSave) {
|
|||||||
EXPECT_NE(text.find("UniqueEditedTextAnnotation123"), std::string::npos);
|
EXPECT_NE(text.find("UniqueEditedTextAnnotation123"), std::string::npos);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
TEST(FontDiagnosticsTest, IntrospectionAccuracy) {
|
||||||
|
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 page = *pageRes;
|
||||||
|
|
||||||
|
auto fontsRes = page->getFonts();
|
||||||
|
ASSERT_TRUE(fontsRes.has_value());
|
||||||
|
auto fonts = *fontsRes;
|
||||||
|
|
||||||
|
if (!fonts.empty()) {
|
||||||
|
auto firstFont = fonts[0];
|
||||||
|
EXPECT_FALSE(firstFont.fontName.empty());
|
||||||
|
EXPECT_FALSE(firstFont.type.empty());
|
||||||
|
EXPECT_FALSE(firstFont.normalizedFamily.empty());
|
||||||
|
EXPECT_FALSE(firstFont.internalFontId.empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
auto docFontsRes = doc->getFonts();
|
||||||
|
ASSERT_TRUE(docFontsRes.has_value());
|
||||||
|
auto docFonts = *docFontsRes;
|
||||||
|
EXPECT_EQ(docFonts.size(), fonts.size());
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(FontDiagnosticsTest, SubsetAndVerticalTextIntrospection) {
|
||||||
|
SKIP_IF_NO_PDFIUM();
|
||||||
|
auto path = getCorpusPath("fonts", "vertical_text.pdf");
|
||||||
|
if (!std::filesystem::exists(path)) {
|
||||||
|
path = getCorpusPath("fonts", "utf-8.pdf");
|
||||||
|
}
|
||||||
|
if (!std::filesystem::exists(path)) {
|
||||||
|
GTEST_SKIP() << "vertical_text.pdf or utf-8.pdf not found in corpus.";
|
||||||
|
}
|
||||||
|
|
||||||
|
auto docRes = PdfDocument::loadFromFile(path.string());
|
||||||
|
ASSERT_TRUE(docRes.has_value());
|
||||||
|
auto doc = *docRes;
|
||||||
|
|
||||||
|
auto fontsRes = doc->getFonts();
|
||||||
|
ASSERT_TRUE(fontsRes.has_value());
|
||||||
|
|
||||||
|
for (const auto& f : *fontsRes) {
|
||||||
|
if (f.isSubset) {
|
||||||
|
EXPECT_FALSE(f.subsetTag.empty());
|
||||||
|
EXPECT_EQ(f.subsetTag.size(), 6);
|
||||||
|
}
|
||||||
|
if (f.isVertical) {
|
||||||
|
EXPECT_TRUE(f.isVertical);
|
||||||
|
EXPECT_NE(f.encoding.find("Identity-V"), std::string::npos);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(FontDiagnosticsTest, CacheInvalidationAfterEdits) {
|
||||||
|
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 fontsRes1 = doc->getFonts();
|
||||||
|
ASSERT_TRUE(fontsRes1.has_value());
|
||||||
|
|
||||||
|
std::string editsJson = R"({
|
||||||
|
"operations": [
|
||||||
|
{
|
||||||
|
"type": "add_text",
|
||||||
|
"pageIndex": 0,
|
||||||
|
"data": {
|
||||||
|
"text": "IntrospectionDiagnosticsNewText",
|
||||||
|
"x": 10.0,
|
||||||
|
"y": 20.0,
|
||||||
|
"fontSize": 12.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
})";
|
||||||
|
|
||||||
|
auto editRes = doc->applyEdits(editsJson);
|
||||||
|
ASSERT_TRUE(editRes.has_value());
|
||||||
|
|
||||||
|
auto fontsRes2 = doc->getFonts();
|
||||||
|
ASSERT_TRUE(fontsRes2.has_value());
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(FontDiagnosticsTest, ConcurrencyThreadSafety) {
|
||||||
|
SKIP_IF_NO_PDFIUM();
|
||||||
|
auto path = getCorpusPath("basic", "hello_world.pdf");
|
||||||
|
if (!std::filesystem::exists(path)) {
|
||||||
|
GTEST_SKIP() << "hello_world.pdf not found in corpus.";
|
||||||
|
}
|
||||||
|
|
||||||
|
auto docRes = PdfDocument::loadFromFile(path.string());
|
||||||
|
ASSERT_TRUE(docRes.has_value());
|
||||||
|
auto doc = *docRes;
|
||||||
|
|
||||||
|
std::vector<std::thread> threads;
|
||||||
|
for (int i = 0; i < 8; ++i) {
|
||||||
|
threads.emplace_back([&doc]() {
|
||||||
|
auto res = doc->getFonts();
|
||||||
|
ASSERT_TRUE(res.has_value());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (auto& t : threads) {
|
||||||
|
t.join();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -101,4 +101,71 @@ def delete_document(document_id: str):
|
|||||||
if not deleted:
|
if not deleted:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Document not found")
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Document not found")
|
||||||
|
|
||||||
return {"success": True}
|
return {"success": True}
|
||||||
|
|
||||||
|
class FontInfoResponse(BaseModel):
|
||||||
|
fontName: str
|
||||||
|
type: str
|
||||||
|
isEmbedded: bool
|
||||||
|
isSubset: bool
|
||||||
|
isVertical: bool
|
||||||
|
encoding: str
|
||||||
|
hasToUnicode: bool
|
||||||
|
cmapName: str
|
||||||
|
cidSystemInfo: str
|
||||||
|
subsetTag: str
|
||||||
|
sourceType: str
|
||||||
|
substitutedFrom: str
|
||||||
|
substitutedTo: str
|
||||||
|
normalizedFamily: str
|
||||||
|
internalFontId: str
|
||||||
|
flags: int
|
||||||
|
ascent: float
|
||||||
|
descent: float
|
||||||
|
capHeight: float
|
||||||
|
|
||||||
|
@router.get("/{document_id}/fonts", response_model=List[FontInfoResponse])
|
||||||
|
def get_document_fonts(document_id: str, start_page: int = 0, end_page: int = -1) -> List[FontInfoResponse]:
|
||||||
|
if not engine.is_available():
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_501_NOT_IMPLEMENTED,
|
||||||
|
detail="Engine bridge (bindings/python) not yet available."
|
||||||
|
)
|
||||||
|
|
||||||
|
doc_info = document_store.get_document(document_id)
|
||||||
|
if not doc_info:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Document not found")
|
||||||
|
|
||||||
|
try:
|
||||||
|
doc = doc_info["doc_instance"]
|
||||||
|
fonts = doc.get_fonts(start_page, end_page)
|
||||||
|
return [
|
||||||
|
FontInfoResponse(
|
||||||
|
fontName=f.font_name,
|
||||||
|
type=f.type,
|
||||||
|
isEmbedded=f.is_embedded,
|
||||||
|
isSubset=f.is_subset,
|
||||||
|
isVertical=f.is_vertical,
|
||||||
|
encoding=f.encoding,
|
||||||
|
hasToUnicode=f.has_to_unicode,
|
||||||
|
cmapName=f.cmap_name,
|
||||||
|
cidSystemInfo=f.cid_system_info,
|
||||||
|
subsetTag=f.subset_tag,
|
||||||
|
sourceType=f.source_type,
|
||||||
|
substitutedFrom=f.substituted_from,
|
||||||
|
substitutedTo=f.substituted_to,
|
||||||
|
normalizedFamily=f.normalized_family,
|
||||||
|
internalFontId=f.internal_font_id,
|
||||||
|
flags=f.flags,
|
||||||
|
ascent=f.ascent,
|
||||||
|
descent=f.descent,
|
||||||
|
capHeight=f.cap_height
|
||||||
|
)
|
||||||
|
for f in fonts
|
||||||
|
]
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
|
||||||
|
except IndexError as e:
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
|
from typing import List
|
||||||
from fastapi import APIRouter, HTTPException, status, Response
|
from fastapi import APIRouter, HTTPException, status, Response
|
||||||
|
|
||||||
from app.services import engine
|
from app.services import engine
|
||||||
from app.services.store import document_store
|
from app.services.store import document_store
|
||||||
|
from app.routers.documents import FontInfoResponse
|
||||||
|
|
||||||
router = APIRouter(prefix="/documents/{document_id}/pages", tags=["render"])
|
router = APIRouter(prefix="/documents/{document_id}/pages", tags=["render"])
|
||||||
compat_router = APIRouter(tags=["render"])
|
compat_router = APIRouter(tags=["render"])
|
||||||
@@ -56,3 +58,48 @@ def render_page_compat(document_id: str, page: int = 0, zoom: float = 1.0, rotat
|
|||||||
dpi = int(96 * zoom)
|
dpi = int(96 * zoom)
|
||||||
return render_page(document_id, page, dpi)
|
return render_page(document_id, page, dpi)
|
||||||
|
|
||||||
|
@router.get("/{page_index}/fonts", response_model=List[FontInfoResponse])
|
||||||
|
def get_page_fonts(document_id: str, page_index: int) -> List[FontInfoResponse]:
|
||||||
|
if not engine.is_available():
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_501_NOT_IMPLEMENTED,
|
||||||
|
detail="Engine bridge (bindings/python) not yet available."
|
||||||
|
)
|
||||||
|
|
||||||
|
doc_info = document_store.get_document(document_id)
|
||||||
|
if not doc_info:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Document not found")
|
||||||
|
|
||||||
|
try:
|
||||||
|
doc = doc_info["doc_instance"]
|
||||||
|
page = doc.get_page(page_index)
|
||||||
|
fonts = page.get_fonts()
|
||||||
|
return [
|
||||||
|
FontInfoResponse(
|
||||||
|
fontName=f.font_name,
|
||||||
|
type=f.type,
|
||||||
|
isEmbedded=f.is_embedded,
|
||||||
|
isSubset=f.is_subset,
|
||||||
|
isVertical=f.is_vertical,
|
||||||
|
encoding=f.encoding,
|
||||||
|
hasToUnicode=f.has_to_unicode,
|
||||||
|
cmapName=f.cmap_name,
|
||||||
|
cidSystemInfo=f.cid_system_info,
|
||||||
|
subsetTag=f.subset_tag,
|
||||||
|
sourceType=f.source_type,
|
||||||
|
substitutedFrom=f.substituted_from,
|
||||||
|
substitutedTo=f.substituted_to,
|
||||||
|
normalizedFamily=f.normalized_family,
|
||||||
|
internalFontId=f.internal_font_id,
|
||||||
|
flags=f.flags,
|
||||||
|
ascent=f.ascent,
|
||||||
|
descent=f.descent,
|
||||||
|
capHeight=f.cap_height
|
||||||
|
)
|
||||||
|
for f in fonts
|
||||||
|
]
|
||||||
|
except IndexError:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Page index out of range")
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
|
||||||
|
|
||||||
|
|||||||
@@ -166,4 +166,36 @@ def test_apply_edits_and_incremental_save(client: TestClient):
|
|||||||
|
|
||||||
text_resp = client.get(f"/documents/{new_doc_id}/pages/0/text")
|
text_resp = client.get(f"/documents/{new_doc_id}/pages/0/text")
|
||||||
assert text_resp.status_code == 200
|
assert text_resp.status_code == 200
|
||||||
assert "Edited Text Annotation" in text_resp.json()["text"]
|
assert "Edited Text Annotation" in text_resp.json()["text"]
|
||||||
|
|
||||||
|
def test_get_document_and_page_fonts(client: TestClient):
|
||||||
|
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"]
|
||||||
|
|
||||||
|
# 1. Document level fonts
|
||||||
|
fonts_resp = client.get(f"/documents/{doc_id}/fonts")
|
||||||
|
assert fonts_resp.status_code == 200
|
||||||
|
fonts = fonts_resp.json()
|
||||||
|
assert isinstance(fonts, list)
|
||||||
|
|
||||||
|
# 2. Page level fonts
|
||||||
|
page_fonts_resp = client.get(f"/documents/{doc_id}/pages/0/fonts")
|
||||||
|
assert page_fonts_resp.status_code == 200
|
||||||
|
page_fonts = page_fonts_resp.json()
|
||||||
|
assert isinstance(page_fonts, list)
|
||||||
|
assert len(fonts) == len(page_fonts)
|
||||||
|
|
||||||
|
if len(fonts) > 0:
|
||||||
|
f = fonts[0]
|
||||||
|
fields = [
|
||||||
|
"fontName", "type", "isEmbedded", "isSubset", "isVertical",
|
||||||
|
"encoding", "hasToUnicode", "cmapName", "cidSystemInfo", "subsetTag",
|
||||||
|
"sourceType", "substitutedFrom", "substitutedTo", "normalizedFamily",
|
||||||
|
"internalFontId", "flags", "ascent", "descent", "capHeight"
|
||||||
|
]
|
||||||
|
for field in fields:
|
||||||
|
assert field in f
|
||||||
Reference in New Issue
Block a user