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());
|
||||
});
|
||||
|
||||
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")
|
||||
.def_property_readonly("width", &pdfengine::PdfPage::width)
|
||||
.def_property_readonly("height", &pdfengine::PdfPage::height)
|
||||
@@ -111,6 +135,9 @@ PYBIND11_MODULE(pdfengine, m) {
|
||||
}
|
||||
return py_list;
|
||||
})
|
||||
.def("get_fonts", [](const pdfengine::PdfPage& self) {
|
||||
return get_or_throw(self.getFonts());
|
||||
})
|
||||
.def("page_to_device", &pdfengine::PdfPage::pageToDevice,
|
||||
py::arg("page_point"), py::arg("device_width"), py::arg("device_height"), py::arg("rotate") = 0)
|
||||
.def("device_to_page", &pdfengine::PdfPage::deviceToPage,
|
||||
@@ -130,6 +157,9 @@ PYBIND11_MODULE(pdfengine, m) {
|
||||
.def("get_page", [](pdfengine::PdfDocument& self, int pageIndex) {
|
||||
return get_or_throw(self.getPage(pageIndex));
|
||||
}, 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) {
|
||||
get_or_throw(self.applyEdits(editsJson));
|
||||
}, py::arg("edits_json"))
|
||||
|
||||
@@ -53,6 +53,39 @@ struct GlyphBounds {
|
||||
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 {
|
||||
public:
|
||||
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<FontInfo>, EngineError> getFonts() const = 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;
|
||||
};
|
||||
@@ -86,6 +121,9 @@ public:
|
||||
[[nodiscard]] virtual std::expected<std::shared_ptr<PdfPage>, EngineError>
|
||||
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;
|
||||
|
||||
[[nodiscard]] virtual std::expected<std::vector<uint8_t>, EngineError>
|
||||
|
||||
@@ -191,6 +191,182 @@ struct PdfiumGlobalInit {
|
||||
void ensure_pdfium_initialized() {
|
||||
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
|
||||
|
||||
}
|
||||
@@ -632,6 +808,7 @@ std::expected<void, EngineError> PdfiumDocument::applyEdits(const std::string& e
|
||||
return std::unexpected(EngineError::Unknown);
|
||||
}
|
||||
|
||||
invalidateFontCache();
|
||||
return {};
|
||||
#else
|
||||
(void)editsJson;
|
||||
@@ -654,4 +831,166 @@ std::expected<std::vector<uint8_t>, EngineError> PdfiumDocument::saveIncremental
|
||||
#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<std::string, EngineError> extractText() 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;
|
||||
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;
|
||||
|
||||
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<std::vector<uint8_t>, EngineError> saveIncremental() const override;
|
||||
@@ -75,6 +78,10 @@ public:
|
||||
private:
|
||||
NativeDocHandle doc_ = nullptr;
|
||||
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 <vector>
|
||||
#include <string>
|
||||
#include <thread>
|
||||
|
||||
#ifndef TEST_CORPUS_DIR
|
||||
#define TEST_CORPUS_DIR "../../corpus"
|
||||
@@ -425,4 +426,126 @@ TEST(DocumentEditTest, ApplyEditsAndIncrementalSave) {
|
||||
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:
|
||||
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 app.services import engine
|
||||
from app.services.store import document_store
|
||||
from app.routers.documents import FontInfoResponse
|
||||
|
||||
router = APIRouter(prefix="/documents/{document_id}/pages", 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)
|
||||
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")
|
||||
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