This commit is contained in:
saqib mir
2026-05-26 15:55:48 +05:30
parent 0685f539da
commit d2a12b4d1d
8 changed files with 686 additions and 3 deletions
+30
View File
@@ -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"))
+38
View File
@@ -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>
+340 -1
View File
@@ -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.");
}
}
+7
View File
@@ -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_;
}; };
} }
+123
View File
@@ -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();
}
}
} }
+68 -1
View File
@@ -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))
+47
View File
@@ -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))
+33 -1
View File
@@ -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