diff --git a/bindings/python/pdfengine_py.cpp b/bindings/python/pdfengine_py.cpp index 26943e1..4f971a5 100644 --- a/bindings/python/pdfengine_py.cpp +++ b/bindings/python/pdfengine_py.cpp @@ -87,6 +87,30 @@ PYBIND11_MODULE(pdfengine, m) { return py::bytes(reinterpret_cast(self.data.data()), self.data.size()); }); + py::class_(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_>(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")) diff --git a/engine/include/pdfengine/pdf_document.hpp b/engine/include/pdfengine/pdf_document.hpp index 2fddfd9..3f8370e 100644 --- a/engine/include/pdfengine/pdf_document.hpp +++ b/engine/include/pdfengine/pdf_document.hpp @@ -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, EngineError> extractTextWithBounds() const = 0; + [[nodiscard]] virtual std::expected, 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, EngineError> getPage(int pageIndex) = 0; + [[nodiscard]] virtual std::expected, EngineError> + getFonts(int startPage = 0, int endPage = -1) const = 0; + virtual std::expected applyEdits(const std::string& editsJson) = 0; [[nodiscard]] virtual std::expected, EngineError> diff --git a/engine/src/parser/pdfium_document.cpp b/engine/src/parser/pdfium_document.cpp index 66b1986..c46151a 100644 --- a/engine/src/parser/pdfium_document.cpp +++ b/engine/src/parser/pdfium_document.cpp @@ -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 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 PdfiumDocument::applyEdits(const std::string& e return std::unexpected(EngineError::Unknown); } + invalidateFontCache(); return {}; #else (void)editsJson; @@ -654,4 +831,166 @@ std::expected, EngineError> PdfiumDocument::saveIncremental #endif } -} \ No newline at end of file +std::expected, 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 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 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(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, EngineError> PdfiumDocument::getFonts(int startPage, int endPage) const { +#ifdef PDFENGINE_WITH_PDFIUM + if (!doc_) { + return std::unexpected(EngineError::Unknown); + } + + std::lock_guard lock(fontsMutex_); + + int total = pageCount(); + if (startPage < 0) startPage = 0; + if (endPage < 0 || endPage >= total) endPage = total - 1; + if (startPage > endPage) { + return std::vector(); + } + + // 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 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 lock(fontsMutex_); + cachedFonts_.clear(); + hasCachedFonts_ = false; + spdlog::info("Document font cache has been invalidated."); +} + +} diff --git a/engine/src/parser/pdfium_document.hpp b/engine/src/parser/pdfium_document.hpp index 8eec6d6..fc9f8c3 100644 --- a/engine/src/parser/pdfium_document.hpp +++ b/engine/src/parser/pdfium_document.hpp @@ -40,6 +40,7 @@ public: std::expected render(int dpi = 96) const override; std::expected extractText() const override; std::expected, EngineError> extractTextWithBounds() const override; + std::expected, 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, EngineError> getPage(int pageIndex) override; + std::expected, EngineError> getFonts(int startPage = 0, int endPage = -1) const override; + void invalidateFontCache(); std::expected applyEdits(const std::string& editsJson) override; std::expected, EngineError> saveIncremental() const override; @@ -75,6 +78,10 @@ public: private: NativeDocHandle doc_ = nullptr; std::vector memoryBuffer_; + + mutable std::vector cachedFonts_; + mutable bool hasCachedFonts_ = false; + mutable std::mutex fontsMutex_; }; } \ No newline at end of file diff --git a/engine/tests/document_test.cpp b/engine/tests/document_test.cpp index 62a39d9..e95993e 100644 --- a/engine/tests/document_test.cpp +++ b/engine/tests/document_test.cpp @@ -5,6 +5,7 @@ #include #include #include +#include #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 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(); + } +} + } diff --git a/gateway/app/routers/documents.py b/gateway/app/routers/documents.py index 5e32454..b480786 100644 --- a/gateway/app/routers/documents.py +++ b/gateway/app/routers/documents.py @@ -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} \ No newline at end of file + 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)) \ No newline at end of file diff --git a/gateway/app/routers/render.py b/gateway/app/routers/render.py index 6eb6332..be3e775 100644 --- a/gateway/app/routers/render.py +++ b/gateway/app/routers/render.py @@ -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)) + diff --git a/gateway/tests/test_routes.py b/gateway/tests/test_routes.py index a226d91..715f43d 100644 --- a/gateway/tests/test_routes.py +++ b/gateway/tests/test_routes.py @@ -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"] \ No newline at end of file + 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 \ No newline at end of file