This commit is contained in:
Furqan-14
2026-06-08 16:54:28 +05:30
50 changed files with 3096 additions and 220 deletions
+55
View File
@@ -111,12 +111,64 @@ PYBIND11_MODULE(pdfengine, m) {
return "FontInfo(font_name='" + self.fontName + "', type='" + self.type + "', is_embedded=" + (self.isEmbedded ? "True" : "False") + ")"; return "FontInfo(font_name='" + self.fontName + "', type='" + self.type + "', is_embedded=" + (self.isEmbedded ? "True" : "False") + ")";
}); });
py::class_<pdfengine::Glyph>(m, "Glyph")
.def_readonly("text", &pdfengine::Glyph::text)
.def_readonly("unicode", &pdfengine::Glyph::unicode)
.def_readonly("font_name", &pdfengine::Glyph::fontName)
.def_readonly("flags", &pdfengine::Glyph::flags)
.def_readonly("font_size", &pdfengine::Glyph::fontSize)
.def_readonly("origin_x", &pdfengine::Glyph::originX)
.def_readonly("origin_y", &pdfengine::Glyph::originY)
.def_readonly("bbox_x", &pdfengine::Glyph::bboxX)
.def_readonly("bbox_y", &pdfengine::Glyph::bboxY)
.def_readonly("bbox_w", &pdfengine::Glyph::bboxW)
.def_readonly("bbox_h", &pdfengine::Glyph::bboxH)
.def_readonly("angle", &pdfengine::Glyph::angle);
py::class_<pdfengine::TextRun>(m, "TextRun")
.def_readonly("text", &pdfengine::TextRun::text)
.def_readonly("font_name", &pdfengine::TextRun::fontName)
.def_readonly("flags", &pdfengine::TextRun::flags)
.def_readonly("font_size", &pdfengine::TextRun::fontSize)
.def_readonly("internal_font_id", &pdfengine::TextRun::internalFontId)
.def_readonly("is_embedded", &pdfengine::TextRun::isEmbedded)
.def_readonly("type", &pdfengine::TextRun::type)
.def_readonly("glyphs", &pdfengine::TextRun::glyphs)
.def_readonly("x", &pdfengine::TextRun::x)
.def_readonly("y", &pdfengine::TextRun::y)
.def_readonly("w", &pdfengine::TextRun::w)
.def_readonly("h", &pdfengine::TextRun::h);
py::class_<pdfengine::TextLine>(m, "TextLine")
.def_readonly("runs", &pdfengine::TextLine::runs)
.def_readonly("baseline_y", &pdfengine::TextLine::baselineY)
.def_readonly("x", &pdfengine::TextLine::x)
.def_readonly("y", &pdfengine::TextLine::y)
.def_readonly("w", &pdfengine::TextLine::w)
.def_readonly("h", &pdfengine::TextLine::h);
py::class_<pdfengine::Paragraph>(m, "Paragraph")
.def_readonly("lines", &pdfengine::Paragraph::lines)
.def_readonly("x", &pdfengine::Paragraph::x)
.def_readonly("y", &pdfengine::Paragraph::y)
.def_readonly("w", &pdfengine::Paragraph::w)
.def_readonly("h", &pdfengine::Paragraph::h);
py::class_<pdfengine::PageModel>(m, "PageModel")
.def_readonly("paragraphs", &pdfengine::PageModel::paragraphs)
.def_readonly("width", &pdfengine::PageModel::width)
.def_readonly("height", &pdfengine::PageModel::height)
.def_readonly("page_index", &pdfengine::PageModel::pageIndex);
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)
.def("render", [](const pdfengine::PdfPage& self, int dpi) { .def("render", [](const pdfengine::PdfPage& self, int dpi) {
return get_or_throw(self.render(dpi)); return get_or_throw(self.render(dpi));
}, py::arg("dpi") = 96) }, py::arg("dpi") = 96)
.def("extract_document_model", [](const pdfengine::PdfPage& self) {
return get_or_throw(self.extractDocumentModel());
})
.def("extract_text", [](const pdfengine::PdfPage& self) { .def("extract_text", [](const pdfengine::PdfPage& self) {
return get_or_throw(self.extractText()); return get_or_throw(self.extractText());
}) })
@@ -141,6 +193,9 @@ PYBIND11_MODULE(pdfengine, m) {
.def("get_fonts", [](const pdfengine::PdfPage& self) { .def("get_fonts", [](const pdfengine::PdfPage& self) {
return get_or_throw(self.getFonts()); return get_or_throw(self.getFonts());
}) })
.def("get_glyph_width", [](const pdfengine::PdfPage& self, const std::string& fontName, uint32_t charcode, double fontSize) {
return get_or_throw(self.getGlyphWidth(fontName, charcode, fontSize));
}, py::arg("font_name"), py::arg("charcode"), py::arg("font_size"))
.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,
+90
View File
@@ -0,0 +1,90 @@
%PDF-1.3
%“Œ‹ž ReportLab Generated PDF document (opensource)
1 0 obj
<<
/F1 2 0 R /F2 3 0 R
>>
endobj
2 0 obj
<<
/BaseFont /Helvetica /Encoding /WinAnsiEncoding /Name /F1 /Subtype /Type1 /Type /Font
>>
endobj
3 0 obj
<<
/BaseFont /MSung-Light /DescendantFonts [ <<
/BaseFont /MSung-Light /CIDSystemInfo <<
/Ordering (CNS1) /Registry (Adobe) /Supplement 1
>> /DW 1000 /FontDescriptor <<
/Ascent 752 /CapHeight 737 /Descent -271 /Flags 6 /FontBBox [ -160 -249 1015 888 ] /FontName /MSung-Light
/ItalicAngle 0 /Leading 148 /MaxWidth 1000 /MissingWidth 500 /StemH 45 /StemV 58
/Type /FontDescriptor /XHeight 553
>> /Subtype /CIDFontType0 /Type /Font
/W [ 1 2 250 3 [ 408 668 490 875 698 250 240 ] 10 [ 240 417 667 250 313 250 520 500 ] 18 26 500
27 28 250 29 31 667 32 [ 396 921 677 615 719 760 625 552 771 802
354 ] 43 [ 354 781 604 927 750 823 563 823 729 542
698 771 729 948 771 677 635 344 520 344
469 500 250 469 521 427 521 438 271 469
531 250 ]
75 [ 250 458 240 802 531 500 521 ] 82 [ 521 365 333 292 521 458 677 479 458 427
480 496 480 667 ] ]
>> ] /Encoding /UniGB-UCS2-H /Name /F2 /Subtype /Type0 /Type /Font
>>
endobj
4 0 obj
<<
/Contents 8 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 7 0 R /Resources <<
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
>> /Rotate 0 /Trans <<
>>
/Type /Page
>>
endobj
5 0 obj
<<
/PageMode /UseNone /Pages 7 0 R /Type /Catalog
>>
endobj
6 0 obj
<<
/Author (anonymous) /CreationDate (D:20260602153802+05'00') /Creator (anonymous) /Keywords () /ModDate (D:20260602153802+05'00') /Producer (ReportLab PDF Library - \(opensource\))
/Subject (unspecified) /Title (untitled) /Trapped /False
>>
endobj
7 0 obj
<<
/Count 1 /Kids [ 4 0 R ] /Type /Pages
>>
endobj
8 0 obj
<<
/Filter [ /ASCII85Decode /FlateDecode ] /Length 107
>>
stream
GapQh0E=F,0U\H3T\pNYT^QKk?tc>IP,;W#U1^23ihPEM_?C]6_CBF/28[_U!/s9cYpe/lM_Qn>nC.&g0fCf=<!^TD#gi_<=5X,[c-mU(~>endstream
endobj
xref
0 9
0000000000 65535 f
0000000061 00000 n
0000000102 00000 n
0000000209 00000 n
0000001155 00000 n
0000001358 00000 n
0000001426 00000 n
0000001687 00000 n
0000001746 00000 n
trailer
<<
/ID
[<01c8dab3d2c3e771bf716fccf2b52ce4><01c8dab3d2c3e771bf716fccf2b52ce4>]
% ReportLab generated PDF document -- digest (opensource)
/Info 6 0 R
/Root 5 0 R
/Size 9
>>
startxref
1943
%%EOF
+4
View File
@@ -17,6 +17,9 @@ add_library(pdfengine STATIC
src/parser/pdfium_loader.cpp src/parser/pdfium_loader.cpp
src/parser/pdfium_document.cpp src/parser/pdfium_document.cpp
src/fonts/face/font_face.cpp src/fonts/face/font_face.cpp
src/fonts/face/free_type_manager.cpp
src/fonts/loader/font_resolver.cpp
src/fonts/pdf_fonts/font_loader.cpp
src/fonts/shaping/hb_shaper.cpp src/fonts/shaping/hb_shaper.cpp
src/fonts/cache/glyph_bitmap.cpp src/fonts/cache/glyph_bitmap.cpp
src/fonts/cache/glyph_cache.cpp src/fonts/cache/glyph_cache.cpp
@@ -47,6 +50,7 @@ target_link_libraries(pdfengine
PRIVATE PRIVATE
freetype freetype
harfbuzz::harfbuzz harfbuzz::harfbuzz
harfbuzz::harfbuzz-subset
PNG::PNG PNG::PNG
nlohmann_json::nlohmann_json nlohmann_json::nlohmann_json
) )
+58
View File
@@ -77,6 +77,52 @@ struct FontInfo {
double capHeight = 0.0; // Font descriptor CapHeight metric double capHeight = 0.0; // Font descriptor CapHeight metric
}; };
struct Glyph {
std::string text;
uint32_t unicode = 0;
std::string fontName;
uint32_t flags = 0;
double fontSize = 0.0;
double originX = 0.0;
double originY = 0.0;
double bboxX = 0.0, bboxY = 0.0, bboxW = 0.0, bboxH = 0.0;
double angle = 0.0;
};
struct TextRun {
std::string text;
std::string fontName;
uint32_t flags = 0;
double fontSize = 0.0;
std::string internalFontId;
bool isEmbedded = false;
std::string type;
std::vector<Glyph> glyphs;
double x = 0.0, y = 0.0, w = 0.0, h = 0.0;
};
struct TextLine {
std::vector<TextRun> runs;
std::vector<Glyph> glyphs;
double angle = 0.0;
double baselineY = 0.0;
double x = 0.0, y = 0.0, w = 0.0, h = 0.0;
};
struct Paragraph {
std::vector<TextLine> lines;
double x = 0.0, y = 0.0, w = 0.0, h = 0.0;
};
struct PageModel {
std::vector<Paragraph> paragraphs;
double width = 0.0;
double height = 0.0;
int pageIndex = 0;
};
class PdfPage { class PdfPage {
public: public:
virtual ~PdfPage() = default; virtual ~PdfPage() = default;
@@ -89,14 +135,20 @@ public:
[[nodiscard]] virtual std::expected<std::string, EngineError> extractText() const = 0; [[nodiscard]] virtual std::expected<std::string, EngineError> extractText() const = 0;
[[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<PageModel, EngineError> extractDocumentModel() const = 0;
[[nodiscard]] virtual std::expected<std::vector<FontInfo>, EngineError> getFonts() const = 0; [[nodiscard]] virtual std::expected<std::vector<FontInfo>, EngineError> getFonts() const = 0;
[[nodiscard]] virtual std::expected<std::vector<std::string>, EngineError> extractAnnotationsText() const = 0; [[nodiscard]] virtual std::expected<std::vector<std::string>, EngineError> extractAnnotationsText() const = 0;
[[nodiscard]] virtual std::expected<double, EngineError> getGlyphWidth(const std::string& fontName, uint32_t charcode, double fontSize) 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;
}; };
namespace fonts::pdf_fonts { class Font; }
class PdfDocument { class PdfDocument {
public: public:
virtual ~PdfDocument() = default; virtual ~PdfDocument() = default;
@@ -116,6 +168,12 @@ public:
[[nodiscard]] virtual std::expected<std::vector<FontInfo>, EngineError> [[nodiscard]] virtual std::expected<std::vector<FontInfo>, EngineError>
getFonts(int startPage = 0, int endPage = -1) const = 0; getFonts(int startPage = 0, int endPage = -1) const = 0;
[[nodiscard]] virtual std::expected<std::vector<uint8_t>, EngineError>
getFontData(const std::string& internalFontId) const = 0;
[[nodiscard]] virtual std::expected<std::shared_ptr<fonts::pdf_fonts::Font>, std::string>
getResolvedFont(const FontInfo& fontInfo) = 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>
+6 -6
View File
@@ -13,12 +13,12 @@ GlyphCache::GlyphCache(std::size_t capacity)
GlyphCache::~GlyphCache() = default; GlyphCache::~GlyphCache() = default;
std::optional<GlyphBitmap> GlyphCache::get(const FontFace& fontFace, unsigned int glyphIndex, unsigned int fontSize) { std::optional<GlyphBitmap> GlyphCache::get(const FontFace& fontFace, unsigned int glyphIndex, unsigned int fontSize) {
FT_Face face = fontFace.getFace(); uint64_t fontId = fontFace.getId();
if (!face) { if (fontId == 0) {
return std::nullopt; return std::nullopt;
} }
GlyphCacheKey key{face, glyphIndex, fontSize}; GlyphCacheKey key{fontId, glyphIndex, fontSize};
std::size_t shard_idx = getShardIndex(key); std::size_t shard_idx = getShardIndex(key);
auto& shard = *shards_[shard_idx]; auto& shard = *shards_[shard_idx];
@@ -37,12 +37,12 @@ std::optional<GlyphBitmap> GlyphCache::get(const FontFace& fontFace, unsigned in
} }
void GlyphCache::insert(const FontFace& fontFace, unsigned int glyphIndex, unsigned int fontSize, const GlyphBitmap& bitmap) { void GlyphCache::insert(const FontFace& fontFace, unsigned int glyphIndex, unsigned int fontSize, const GlyphBitmap& bitmap) {
FT_Face face = fontFace.getFace(); uint64_t fontId = fontFace.getId();
if (!face) { if (fontId == 0) {
return; return;
} }
GlyphCacheKey key{face, glyphIndex, fontSize}; GlyphCacheKey key{fontId, glyphIndex, fontSize};
std::size_t shard_idx = getShardIndex(key); std::size_t shard_idx = getShardIndex(key);
auto& shard = *shards_[shard_idx]; auto& shard = *shards_[shard_idx];
+3 -3
View File
@@ -12,12 +12,12 @@
namespace pdfengine::fonts { namespace pdfengine::fonts {
struct GlyphCacheKey { struct GlyphCacheKey {
FT_Face face; uint64_t fontId;
unsigned int glyphIndex; unsigned int glyphIndex;
unsigned int fontSize; unsigned int fontSize;
bool operator==(const GlyphCacheKey& other) const { bool operator==(const GlyphCacheKey& other) const {
return face == other.face && return fontId == other.fontId &&
glyphIndex == other.glyphIndex && glyphIndex == other.glyphIndex &&
fontSize == other.fontSize; fontSize == other.fontSize;
} }
@@ -25,7 +25,7 @@ struct GlyphCacheKey {
struct GlyphCacheKeyHash { struct GlyphCacheKeyHash {
std::size_t operator()(const GlyphCacheKey& key) const { std::size_t operator()(const GlyphCacheKey& key) const {
std::size_t h1 = std::hash<void*>{}(static_cast<void*>(key.face)); std::size_t h1 = std::hash<uint64_t>{}(key.fontId);
std::size_t h2 = std::hash<unsigned int>{}(key.glyphIndex); std::size_t h2 = std::hash<unsigned int>{}(key.glyphIndex);
std::size_t h3 = std::hash<unsigned int>{}(key.fontSize); std::size_t h3 = std::hash<unsigned int>{}(key.fontSize);
// Combine hashes using standard boost hash_combine algorithm // Combine hashes using standard boost hash_combine algorithm
+25 -19
View File
@@ -1,16 +1,17 @@
#include "fonts/face/font_face.hpp" #include "fonts/face/font_face.hpp"
#include "fonts/face/free_type_manager.hpp"
#include <iostream> #include <iostream>
#include <atomic>
namespace pdfengine::fonts { namespace pdfengine::fonts {
FontFace::FontFace() static std::atomic<uint64_t> g_font_id_counter{1};
: ft_library_(nullptr),
face_(nullptr) {
if (FT_Init_FreeType(&ft_library_)) { FontFace::FontFace()
std::cerr << "Failed to initialize FreeType\n"; : font_id_(g_font_id_counter.fetch_add(1, std::memory_order_relaxed)),
} face_(nullptr),
mutex_(std::make_unique<std::mutex>()) {
} }
FontFace::~FontFace() { FontFace::~FontFace() {
@@ -18,17 +19,14 @@ FontFace::~FontFace() {
if (face_) { if (face_) {
FT_Done_Face(face_); FT_Done_Face(face_);
} }
if (ft_library_) {
FT_Done_FreeType(ft_library_);
}
} }
FontFace::FontFace(FontFace&& other) noexcept FontFace::FontFace(FontFace&& other) noexcept
: ft_library_(other.ft_library_), : font_id_(other.font_id_),
face_(other.face_), face_(other.face_),
mutex_(std::move(other.mutex_)),
font_data_(std::move(other.font_data_)) { font_data_(std::move(other.font_data_)) {
other.ft_library_ = nullptr; other.font_id_ = 0;
other.face_ = nullptr; other.face_ = nullptr;
} }
@@ -37,13 +35,11 @@ FontFace& FontFace::operator=(FontFace&& other) noexcept {
if (face_) { if (face_) {
FT_Done_Face(face_); FT_Done_Face(face_);
} }
if (ft_library_) { font_id_ = other.font_id_;
FT_Done_FreeType(ft_library_);
}
ft_library_ = other.ft_library_;
face_ = other.face_; face_ = other.face_;
mutex_ = std::move(other.mutex_);
font_data_ = std::move(other.font_data_); font_data_ = std::move(other.font_data_);
other.ft_library_ = nullptr; other.font_id_ = 0;
other.face_ = nullptr; other.face_ = nullptr;
} }
return *this; return *this;
@@ -58,7 +54,7 @@ bool FontFace::loadFromFile(const std::string& path) {
font_data_.clear(); font_data_.clear();
if (FT_New_Face( if (FT_New_Face(
ft_library_, FreeTypeManager::instance().getLibrary(),
path.c_str(), path.c_str(),
0, 0,
&face_)) { &face_)) {
@@ -91,7 +87,7 @@ bool FontFace::loadFromMemory(const std::vector<uint8_t>& data) {
font_data_ = data; font_data_ = data;
if (FT_New_Memory_Face( if (FT_New_Memory_Face(
ft_library_, FreeTypeManager::instance().getLibrary(),
font_data_.data(), font_data_.data(),
static_cast<FT_Long>(font_data_.size()), static_cast<FT_Long>(font_data_.size()),
0, 0,
@@ -113,11 +109,21 @@ FT_Face FontFace::getFace() const {
return face_; return face_;
} }
uint64_t FontFace::getId() const {
return font_id_;
}
std::mutex& FontFace::getMutex() const {
return *mutex_;
}
std::optional<GlyphBitmap> FontFace::renderGlyph(unsigned int glyphIndex, unsigned int fontSize) { std::optional<GlyphBitmap> FontFace::renderGlyph(unsigned int glyphIndex, unsigned int fontSize) {
if (!face_) { if (!face_) {
return std::nullopt; return std::nullopt;
} }
std::lock_guard<std::mutex> lock(*mutex_);
// Set font size in pixels. // Set font size in pixels.
if (FT_Set_Pixel_Sizes(face_, 0, fontSize)) { if (FT_Set_Pixel_Sizes(face_, 0, fontSize)) {
return std::nullopt; return std::nullopt;
+6 -1
View File
@@ -5,6 +5,8 @@
#include <string> #include <string>
#include <vector> #include <vector>
#include <cstdint> #include <cstdint>
#include <mutex>
#include <memory>
#include <ft2build.h> #include <ft2build.h>
#include FT_FREETYPE_H #include FT_FREETYPE_H
@@ -26,13 +28,16 @@ public:
bool loadFromMemory(const std::vector<uint8_t>& data); bool loadFromMemory(const std::vector<uint8_t>& data);
FT_Face getFace() const; FT_Face getFace() const;
uint64_t getId() const;
std::mutex& getMutex() const;
// Renders a glyph by index and size, returning a GlyphBitmap on success. // Renders a glyph by index and size, returning a GlyphBitmap on success.
std::optional<GlyphBitmap> renderGlyph(unsigned int glyphIndex, unsigned int fontSize); std::optional<GlyphBitmap> renderGlyph(unsigned int glyphIndex, unsigned int fontSize);
private: private:
FT_Library ft_library_; uint64_t font_id_;
FT_Face face_; FT_Face face_;
std::unique_ptr<std::mutex> mutex_;
std::vector<uint8_t> font_data_; // Keeps the loaded memory buffer alive for FT_Face std::vector<uint8_t> font_data_; // Keeps the loaded memory buffer alive for FT_Face
}; };
@@ -0,0 +1,28 @@
#include "fonts/face/free_type_manager.hpp"
#include <iostream>
namespace pdfengine::fonts {
FreeTypeManager& FreeTypeManager::instance() {
static FreeTypeManager instance;
return instance;
}
FreeTypeManager::FreeTypeManager() : library_(nullptr) {
if (FT_Init_FreeType(&library_)) {
std::cerr << "Failed to initialize FreeType\n";
}
}
FreeTypeManager::~FreeTypeManager() {
if (library_) {
FT_Done_FreeType(library_);
library_ = nullptr;
}
}
FT_Library FreeTypeManager::getLibrary() const {
return library_;
}
} // namespace pdfengine::fonts
@@ -0,0 +1,27 @@
#pragma once
#include <ft2build.h>
#include FT_FREETYPE_H
namespace pdfengine::fonts {
class FreeTypeManager {
public:
static FreeTypeManager& instance();
FT_Library getLibrary() const;
// Delete copy/move constructors and assignment operators for singleton
FreeTypeManager(const FreeTypeManager&) = delete;
FreeTypeManager& operator=(const FreeTypeManager&) = delete;
FreeTypeManager(FreeTypeManager&&) = delete;
FreeTypeManager& operator=(FreeTypeManager&&) = delete;
private:
FreeTypeManager();
~FreeTypeManager();
FT_Library library_;
};
} // namespace pdfengine::fonts
+76
View File
@@ -0,0 +1,76 @@
#include "fonts/loader/font_resolver.hpp"
#include "fonts/pdf_fonts/font_loader.hpp"
#include <spdlog/spdlog.h>
namespace pdfengine::fonts::loader {
FontResolver::FontResolver(std::shared_ptr<PdfDocument> document)
: document_(std::move(document)) {}
std::expected<std::unique_ptr<pdf_fonts::Font>, std::string> FontResolver::resolveFont(const FontInfo& fontInfo) {
if (!document_) {
return std::unexpected("No document attached to FontResolver");
}
if (fontInfo.isEmbedded) {
auto dataRes = document_->getFontData(fontInfo.internalFontId);
if (!dataRes) {
spdlog::error("Failed to extract embedded font data for ID: {}", fontInfo.internalFontId);
return std::unexpected("Failed to extract embedded font data");
}
const auto& bytes = dataRes.value();
// Build FontDescriptor from FontInfo
auto descriptor = std::make_unique<pdf_fonts::FontDescriptor>();
descriptor->setFontName(fontInfo.fontName);
descriptor->setFlags(fontInfo.flags);
descriptor->setAscent(fontInfo.ascent);
descriptor->setDescent(fontInfo.descent);
descriptor->setCapHeight(fontInfo.capHeight);
// Route to the appropriate FontLoader method based on FontInfo type
if (fontInfo.type == "TrueType") {
auto font = pdf_fonts::FontLoader::loadTrueTypeFromMemory(fontInfo.normalizedFamily, bytes, std::move(descriptor));
if (font) return font;
} else if (fontInfo.type == "Type1") {
auto font = pdf_fonts::FontLoader::loadType1FromMemory(fontInfo.normalizedFamily, bytes, std::move(descriptor));
if (font) return font;
} else if (fontInfo.type == "CIDFontType0" || fontInfo.type == "CIDFontType2") {
pdf_fonts::FontType subtype = (fontInfo.type == "CIDFontType0")
? pdf_fonts::FontType::CIDFontType0
: pdf_fonts::FontType::CIDFontType2;
auto font = pdf_fonts::FontLoader::loadCIDFontFromMemory(fontInfo.normalizedFamily, subtype, bytes, std::move(descriptor));
if (font) return font;
}
return std::unexpected("Failed to parse extracted font data");
} else {
// Handle System Fallback
spdlog::info("Resolving system fallback font for: {}", fontInfo.fontName);
// Build FontDescriptor from FontInfo
auto descriptor = std::make_unique<pdf_fonts::FontDescriptor>();
descriptor->setFontName(fontInfo.fontName);
descriptor->setFlags(fontInfo.flags);
descriptor->setAscent(fontInfo.ascent);
descriptor->setDescent(fontInfo.descent);
descriptor->setCapHeight(fontInfo.capHeight);
if (fontInfo.type == "CIDFontType0" || fontInfo.type == "CIDFontType2") {
pdf_fonts::FontType subtype = (fontInfo.type == "CIDFontType0")
? pdf_fonts::FontType::CIDFontType0
: pdf_fonts::FontType::CIDFontType2;
auto font = pdf_fonts::FontLoader::loadCIDFontSystemFallback(fontInfo.normalizedFamily, subtype, std::move(descriptor));
if (font) return font;
} else {
auto font = pdf_fonts::FontLoader::loadType1SystemFallback(fontInfo.normalizedFamily, std::move(descriptor));
if (font) return font;
}
return std::unexpected("Failed to load system fallback font");
}
}
} // namespace pdfengine::fonts::loader
+24
View File
@@ -0,0 +1,24 @@
#pragma once
#include "fonts/pdf_fonts/font.hpp"
#include <pdfengine/pdf_document.hpp>
#include <memory>
#include <string>
#include <expected>
namespace pdfengine::fonts::loader {
class FontResolver {
public:
explicit FontResolver(std::shared_ptr<PdfDocument> document);
// Resolves a FontInfo object into a fully loaded Font ready for shaping.
// If the font is embedded, it extracts the raw bytes from the PdfDocument.
// If it's a system fallback, it uses FontLoader to load it from the OS.
std::expected<std::unique_ptr<pdf_fonts::Font>, std::string> resolveFont(const FontInfo& fontInfo);
private:
std::shared_ptr<PdfDocument> document_;
};
} // namespace pdfengine::fonts::loader
+72 -1
View File
@@ -19,6 +19,19 @@ enum class FontType {
Type3 Type3
}; };
enum class FontSource {
Embedded,
SystemFallback,
Substituted
};
struct FontMetrics {
double ascent = 0.0;
double descent = 0.0;
double lineGap = 0.0;
double capHeight = 0.0;
};
class Font { class Font {
public: public:
virtual ~Font() = default; virtual ~Font() = default;
@@ -37,9 +50,62 @@ public:
// Gets the font encoding (returns nullptr if none exists) // Gets the font encoding (returns nullptr if none exists)
virtual const Encoding* getEncoding() const = 0; virtual const Encoding* getEncoding() const = 0;
// Gets subsetting details (returns nullptr if font is not subsetted)
virtual const FontSubset* getSubsetInfo() const = 0; virtual const FontSubset* getSubsetInfo() const = 0;
// Advanced Layout APIs
virtual FontSource getSourceType() const { return source_type_; }
virtual void setSourceType(FontSource source) { source_type_ = source; }
virtual bool hasGlyph(uint32_t unicode) const {
if (!getFontFace().getFace()) return false;
return FT_Get_Char_Index(getFontFace().getFace(), unicode) != 0;
}
virtual double getAdvanceWidth(uint32_t unicode, double fontSize) const {
if (!getFontFace().getFace()) return 0.0;
std::lock_guard<std::mutex> lock(getFontFace().getMutex());
FT_Face rawFace = getFontFace().getFace();
FT_Set_Pixel_Sizes(rawFace, 0, static_cast<FT_UInt>(fontSize));
FT_UInt glyphIndex = FT_Get_Char_Index(rawFace, unicode);
if (glyphIndex == 0) return 0.0;
if (FT_Load_Glyph(rawFace, glyphIndex, FT_LOAD_DEFAULT) == 0) {
return static_cast<double>(rawFace->glyph->advance.x) / 64.0;
}
return 0.0;
}
virtual FontMetrics getMetrics(double fontSize) const {
if (metrics_cached_ && last_metrics_size_ == fontSize) {
return metrics_cache_;
}
if (getFontFace().getFace()) {
std::lock_guard<std::mutex> lock(getFontFace().getMutex());
FT_Face rawFace = getFontFace().getFace();
FT_Set_Pixel_Sizes(rawFace, 0, static_cast<FT_UInt>(fontSize));
// Convert from 26.6 to double
metrics_cache_.ascent = static_cast<double>(rawFace->size->metrics.ascender) / 64.0;
metrics_cache_.descent = static_cast<double>(rawFace->size->metrics.descender) / 64.0;
metrics_cache_.lineGap = static_cast<double>(rawFace->size->metrics.height - rawFace->size->metrics.ascender + rawFace->size->metrics.descender) / 64.0;
// Heuristic for capHeight: height of 'H'
FT_UInt hIndex = FT_Get_Char_Index(rawFace, 'H');
if (hIndex > 0 && FT_Load_Glyph(rawFace, hIndex, FT_LOAD_DEFAULT) == 0) {
metrics_cache_.capHeight = static_cast<double>(rawFace->glyph->metrics.horiBearingY) / 64.0;
} else {
metrics_cache_.capHeight = metrics_cache_.ascent * 0.7; // Fallback
}
metrics_cached_ = true;
last_metrics_size_ = fontSize;
}
return metrics_cache_;
}
// Translates a raw character code to a Unicode codepoint // Translates a raw character code to a Unicode codepoint
virtual uint32_t decodeToUnicode(uint32_t charCode) const = 0; virtual uint32_t decodeToUnicode(uint32_t charCode) const = 0;
@@ -119,6 +185,11 @@ protected:
uint32_t last_char_ = 0; uint32_t last_char_ = 0;
std::vector<double> widths_; std::vector<double> widths_;
bool has_widths_ = false; bool has_widths_ = false;
FontSource source_type_ = FontSource::Embedded;
mutable FontMetrics metrics_cache_;
mutable bool metrics_cached_ = false;
mutable double last_metrics_size_ = 0.0;
bool is_vertical_ = false; bool is_vertical_ = false;
uint32_t first_vertical_char_ = 0; uint32_t first_vertical_char_ = 0;
+23 -1
View File
@@ -121,6 +121,8 @@ std::string FontFallback::getFallbackFontPath(const std::string& fontName, bool
stylePattern += "-italic"; stylePattern += "-italic";
} }
std::lock_guard<std::mutex> lock(rules_mutex_);
for (const auto& rule : custom_rules_) { for (const auto& rule : custom_rules_) {
if (stylePattern.find(rule.pattern) != std::string::npos || lowerName.find(rule.pattern) != std::string::npos) { if (stylePattern.find(rule.pattern) != std::string::npos || lowerName.find(rule.pattern) != std::string::npos) {
for (const auto& path : rule.preferredPaths) { for (const auto& path : rule.preferredPaths) {
@@ -154,17 +156,37 @@ std::string FontFallback::getFallbackFontPath(const std::string& fontName, bool
} }
} }
return "C:\\Windows\\Fonts\\arial.ttf"; return "C:\\Windows\\Fonts\\arial.ttf";
#elif defined(__APPLE__)
std::vector<std::string> lastResort = {
"/Library/Fonts/Arial.ttf",
"/System/Library/Fonts/Helvetica.ttc",
"/System/Library/Fonts/Supplemental/Arial.ttf"
};
for (const auto& path : lastResort) {
if (std::filesystem::exists(path)) return path;
}
return "/System/Library/Fonts/Helvetica.ttc";
#else #else
return ""; std::vector<std::string> lastResort = {
"/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf",
"/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
"/usr/share/fonts/truetype/freefont/FreeSans.ttf"
};
for (const auto& path : lastResort) {
if (std::filesystem::exists(path)) return path;
}
return "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf";
#endif #endif
} }
void FontFallback::registerFallback(const std::string& pattern, const std::string& systemFontPath) { void FontFallback::registerFallback(const std::string& pattern, const std::string& systemFontPath) {
std::string lowerPattern = toLower(pattern); std::string lowerPattern = toLower(pattern);
std::lock_guard<std::mutex> lock(rules_mutex_);
custom_rules_.insert(custom_rules_.begin(), {lowerPattern, {systemFontPath}}); custom_rules_.insert(custom_rules_.begin(), {lowerPattern, {systemFontPath}});
} }
void FontFallback::resetToDefaults() { void FontFallback::resetToDefaults() {
std::lock_guard<std::mutex> lock(rules_mutex_);
custom_rules_.clear(); custom_rules_.clear();
} }
@@ -2,6 +2,7 @@
#include <string> #include <string>
#include <vector> #include <vector>
#include <mutex>
namespace pdfengine::fonts::pdf_fonts { namespace pdfengine::fonts::pdf_fonts {
@@ -32,6 +33,7 @@ private:
}; };
std::vector<FallbackRule> default_rules_; std::vector<FallbackRule> default_rules_;
std::vector<FallbackRule> custom_rules_; std::vector<FallbackRule> custom_rules_;
mutable std::mutex rules_mutex_;
}; };
} // namespace pdfengine::fonts::pdf_fonts } // namespace pdfengine::fonts::pdf_fonts
@@ -1,9 +1,85 @@
#include "fonts/pdf_fonts/font_subset.hpp" #include "fonts/pdf_fonts/font_subset.hpp"
#include <algorithm> #include <algorithm>
#include <cctype> #include <cctype>
#include <ft2build.h>
#include FT_FREETYPE_H
#include <hb.h>
#include <hb-subset.h>
namespace pdfengine::fonts::pdf_fonts { namespace pdfengine::fonts::pdf_fonts {
void FontSubset::populateFromFace(FontSubset& subset, void* ftFace) {
if (!ftFace) return;
FT_Face face = static_cast<FT_Face>(ftFace);
FT_UInt gindex;
FT_ULong charcode = FT_Get_First_Char(face, &gindex);
while (gindex != 0) {
// In embedded subsets, the TrueType cmap typically maps the
// Original GID or CID (charcode) to the new Subset GID (gindex).
subset.addGlyphMapping(gindex, static_cast<uint32_t>(charcode));
charcode = FT_Get_Next_Char(face, charcode, &gindex);
}
}
std::vector<uint8_t> FontSubset::buildSubset(const std::vector<uint8_t>& originalStream, const std::vector<uint32_t>& glyphIdsToKeep) {
std::vector<uint8_t> result;
if (originalStream.empty() || glyphIdsToKeep.empty()) {
return result;
}
hb_blob_t* blob = hb_blob_create(
reinterpret_cast<const char*>(originalStream.data()),
static_cast<unsigned int>(originalStream.size()),
HB_MEMORY_MODE_READONLY,
nullptr,
nullptr
);
hb_face_t* face = hb_face_create(blob, 0);
hb_blob_destroy(blob);
if (!face) {
return result;
}
hb_subset_input_t* input = hb_subset_input_create_or_fail();
if (!input) {
hb_face_destroy(face);
return result;
}
hb_set_t* glyph_set = hb_subset_input_glyph_set(input);
for (uint32_t gid : glyphIdsToKeep) {
hb_set_add(glyph_set, gid);
}
// Always keep GID 0 (the .notdef glyph)
hb_set_add(glyph_set, 0);
// Retain layout tables for complex text shaping
hb_subset_input_set_flags(input, HB_SUBSET_FLAGS_RETAIN_GIDS);
hb_face_t* subset_face = hb_subset_or_fail(face, input);
hb_subset_input_destroy(input);
hb_face_destroy(face);
if (subset_face) {
hb_blob_t* result_blob = hb_face_reference_blob(subset_face);
if (result_blob) {
unsigned int length = 0;
const char* data = hb_blob_get_data(result_blob, &length);
if (data && length > 0) {
result.assign(data, data + length);
}
hb_blob_destroy(result_blob);
}
hb_face_destroy(subset_face);
}
return result;
}
bool FontSubset::hasSubsetPrefix(const std::string& fontName) { bool FontSubset::hasSubsetPrefix(const std::string& fontName) {
if (fontName.length() < 8) { if (fontName.length() < 8) {
return false; return false;
@@ -14,6 +14,12 @@ public:
static std::string getSubsetPrefix(const std::string& fontName); static std::string getSubsetPrefix(const std::string& fontName);
// Iterates the FT_Face cmap to populate the mapping dictionary
static void populateFromFace(FontSubset& subset, void* ftFace);
// Rebuilds the TTF/CID stream keeping only the specified GIDs using HarfBuzz
static std::vector<uint8_t> buildSubset(const std::vector<uint8_t>& originalStream, const std::vector<uint32_t>& glyphIdsToKeep);
explicit FontSubset(const std::string& fontName); explicit FontSubset(const std::string& fontName);
~FontSubset() = default; ~FontSubset() = default;
+35 -12
View File
@@ -54,7 +54,13 @@ CIDFont::CIDFont(
CIDFont::~CIDFont() = default; CIDFont::~CIDFont() = default;
bool CIDFont::loadFromStream(const std::vector<uint8_t>& streamData) { bool CIDFont::loadFromStream(const std::vector<uint8_t>& streamData) {
return font_face_.loadFromMemory(streamData); if (!font_face_.loadFromMemory(streamData)) {
return false;
}
if (subset_info_) {
FontSubset::populateFromFace(*subset_info_, font_face_.getFace());
}
return true;
} }
bool CIDFont::loadFromFile(const std::string& filePath) { bool CIDFont::loadFromFile(const std::string& filePath) {
@@ -126,7 +132,8 @@ uint32_t CIDFont::decodeToUnicode(uint32_t charCode) const {
if (descriptor_) { if (descriptor_) {
std::string fontName = descriptor_->getFontName(); std::string fontName = descriptor_->getFontName();
std::string lowerName = fontName; std::string lowerName = fontName;
std::transform(lowerName.begin(), lowerName.end(), lowerName.begin(), ::tolower); std::transform(lowerName.begin(), lowerName.end(), lowerName.begin(),
[](unsigned char c) { return static_cast<char>(std::tolower(c)); });
std::string registry = "None"; std::string registry = "None";
if (lowerName.find("simsun") != std::string::npos || if (lowerName.find("simsun") != std::string::npos ||
@@ -177,16 +184,14 @@ uint32_t CIDFont::decodeToUnicode(uint32_t charCode) const {
gid = subset_info_->mapSubsetToOriginal(gid); gid = subset_info_->mapSubsetToOriginal(gid);
} }
FT_Face face = font_face_.getFace(); if (!is_gid_to_unicode_map_built_) {
if (face) { buildGidToUnicodeMap();
FT_UInt gindex; }
FT_ULong charcode = FT_Get_First_Char(face, &gindex);
while (gindex != 0) { std::lock_guard<std::mutex> lock(gid_to_unicode_mutex_);
if (gindex == gid) { auto it = gid_to_unicode_map_.find(gid);
return static_cast<uint32_t>(charcode); if (it != gid_to_unicode_map_.end()) {
} return it->second;
charcode = FT_Get_Next_Char(face, charcode, &gindex);
}
} }
return charCode; return charCode;
@@ -203,4 +208,22 @@ std::string CIDFont::decodeStringToUnicode(const std::vector<uint32_t>& charCode
return result; return result;
} }
void CIDFont::buildGidToUnicodeMap() const {
std::lock_guard<std::mutex> lock(gid_to_unicode_mutex_);
if (is_gid_to_unicode_map_built_) {
return;
}
FT_Face face = font_face_.getFace();
if (face) {
FT_UInt gindex;
FT_ULong charcode = FT_Get_First_Char(face, &gindex);
while (gindex != 0) {
gid_to_unicode_map_[gindex] = static_cast<uint32_t>(charcode);
charcode = FT_Get_Next_Char(face, charcode, &gindex);
}
}
is_gid_to_unicode_map_built_ = true;
}
} // namespace pdfengine::fonts::pdf_fonts } // namespace pdfengine::fonts::pdf_fonts
@@ -8,6 +8,7 @@
#include <memory> #include <memory>
#include <unordered_map> #include <unordered_map>
#include <string> #include <string>
#include <mutex>
namespace pdfengine::fonts::pdf_fonts { namespace pdfengine::fonts::pdf_fonts {
class FontSubset; class FontSubset;
@@ -60,6 +61,11 @@ private:
bool is_identity_map_ = true; bool is_identity_map_ = true;
std::unordered_map<uint32_t, uint32_t> cid_to_gid_map_; std::unordered_map<uint32_t, uint32_t> cid_to_gid_map_;
std::unique_ptr<FontSubset> subset_info_; std::unique_ptr<FontSubset> subset_info_;
mutable bool is_gid_to_unicode_map_built_ = false;
mutable std::mutex gid_to_unicode_mutex_;
mutable std::unordered_map<uint32_t, uint32_t> gid_to_unicode_map_;
void buildGidToUnicodeMap() const;
}; };
} // namespace pdfengine::fonts::pdf_fonts } // namespace pdfengine::fonts::pdf_fonts
@@ -50,7 +50,13 @@ TrueTypeFont::TrueTypeFont(
TrueTypeFont::~TrueTypeFont() = default; TrueTypeFont::~TrueTypeFont() = default;
bool TrueTypeFont::loadFromStream(const std::vector<uint8_t>& streamData) { bool TrueTypeFont::loadFromStream(const std::vector<uint8_t>& streamData) {
return font_face_.loadFromMemory(streamData); if (!font_face_.loadFromMemory(streamData)) {
return false;
}
if (subset_info_) {
FontSubset::populateFromFace(*subset_info_, font_face_.getFace());
}
return true;
} }
std::string TrueTypeFont::getBaseFont() const { std::string TrueTypeFont::getBaseFont() const {
@@ -94,18 +100,8 @@ uint32_t TrueTypeFont::decodeToUnicode(uint32_t charCode) const {
} }
if (subset_info_) { if (subset_info_) {
uint32_t subsetGid = subset_info_->mapSubsetToOriginal(charCode); if (subset_info_->hasGlyphMapping(charCode)) {
return subset_info_->mapSubsetToOriginal(charCode);
FT_Face face = font_face_.getFace();
if (face) {
FT_UInt gindex;
FT_ULong charcode = FT_Get_First_Char(face, &gindex);
while (gindex != 0) {
if (gindex == subsetGid) {
return static_cast<uint32_t>(charcode);
}
charcode = FT_Get_Next_Char(face, charcode, &gindex);
}
} }
} }
@@ -50,7 +50,13 @@ Type1Font::Type1Font(
Type1Font::~Type1Font() = default; Type1Font::~Type1Font() = default;
bool Type1Font::loadFromStream(const std::vector<uint8_t>& streamData) { bool Type1Font::loadFromStream(const std::vector<uint8_t>& streamData) {
return font_face_.loadFromMemory(streamData); if (!font_face_.loadFromMemory(streamData)) {
return false;
}
if (subset_info_) {
FontSubset::populateFromFace(*subset_info_, font_face_.getFace());
}
return true;
} }
bool Type1Font::loadFromFile(const std::string& filePath) { bool Type1Font::loadFromFile(const std::string& filePath) {
@@ -98,18 +104,8 @@ uint32_t Type1Font::decodeToUnicode(uint32_t charCode) const {
} }
if (subset_info_) { if (subset_info_) {
uint32_t subsetGid = subset_info_->mapSubsetToOriginal(charCode); if (subset_info_->hasGlyphMapping(charCode)) {
return subset_info_->mapSubsetToOriginal(charCode);
FT_Face face = font_face_.getFace();
if (face) {
FT_UInt gindex;
FT_ULong charcode = FT_Get_First_Char(face, &gindex);
while (gindex != 0) {
if (gindex == subsetGid) {
return static_cast<uint32_t>(charcode);
}
charcode = FT_Get_Next_Char(face, charcode, &gindex);
}
} }
} }
+21 -1
View File
@@ -2,6 +2,7 @@
#include <hb.h> #include <hb.h>
#include <hb-ft.h> #include <hb-ft.h>
#include <mutex>
namespace pdfengine::fonts { namespace pdfengine::fonts {
@@ -21,6 +22,9 @@ std::vector<ShapedGlyph> HbShaper::shapeRun(
return result; return result;
} }
// Acquire lock to prevent concurrent mutation of FT_Face's active pixel size
std::lock_guard<std::mutex> lock(font.getMutex());
// Set the pixel size on the FreeType face before shaping. // Set the pixel size on the FreeType face before shaping.
// This ensures HarfBuzz measures everything using the requested font size context. // This ensures HarfBuzz measures everything using the requested font size context.
if (FT_Set_Pixel_Sizes(ftFace, 0, fontSize)) { if (FT_Set_Pixel_Sizes(ftFace, 0, fontSize)) {
@@ -53,8 +57,23 @@ std::vector<ShapedGlyph> HbShaper::shapeRun(
hb_buffer_set_direction(hbBuffer, HB_DIRECTION_TTB); hb_buffer_set_direction(hbBuffer, HB_DIRECTION_TTB);
} }
// Enable professional typography features (Kerning and Ligatures)
hb_feature_t features[2];
// Enable kerning
features[0].tag = HB_TAG('k', 'e', 'r', 'n');
features[0].value = 1;
features[0].start = 0;
features[0].end = static_cast<unsigned int>(-1);
// Enable standard ligatures
features[1].tag = HB_TAG('l', 'i', 'g', 'a');
features[1].value = 1;
features[1].start = 0;
features[1].end = static_cast<unsigned int>(-1);
// Shape the text inside the buffer using the font. // Shape the text inside the buffer using the font.
hb_shape(hbFont, hbBuffer, nullptr, 0); hb_shape(hbFont, hbBuffer, features, 2);
// Retrieve the results. // Retrieve the results.
unsigned int glyphCount = 0; unsigned int glyphCount = 0;
@@ -72,6 +91,7 @@ std::vector<ShapedGlyph> HbShaper::shapeRun(
sg.advanceY = static_cast<double>(glyphPositions[i].y_advance) / 64.0; sg.advanceY = static_cast<double>(glyphPositions[i].y_advance) / 64.0;
sg.offsetX = static_cast<double>(glyphPositions[i].x_offset) / 64.0; sg.offsetX = static_cast<double>(glyphPositions[i].x_offset) / 64.0;
sg.offsetY = static_cast<double>(glyphPositions[i].y_offset) / 64.0; sg.offsetY = static_cast<double>(glyphPositions[i].y_offset) / 64.0;
sg.clusterIndex = glyphInfos[i].cluster;
result.push_back(sg); result.push_back(sg);
} }
} }
+1
View File
@@ -13,6 +13,7 @@ struct ShapedGlyph {
double advanceY; double advanceY;
double offsetX; double offsetX;
double offsetY; double offsetY;
uint32_t clusterIndex;
}; };
class HbShaper { class HbShaper {
+493 -22
View File
@@ -1,5 +1,4 @@
#include "parser/pdfium_document.hpp" #include "parser/pdfium_document.hpp"
#ifdef PDFENGINE_WITH_PDFIUM #ifdef PDFENGINE_WITH_PDFIUM
#include <fpdfview.h> #include <fpdfview.h>
#include <fpdf_text.h> #include <fpdf_text.h>
@@ -11,6 +10,9 @@
#include "parser/pdfium_loader.hpp" #include "parser/pdfium_loader.hpp"
#endif #endif
#include "fonts/loader/font_resolver.hpp"
#include "fonts/pdf_fonts/font.hpp"
#include <nlohmann/json.hpp> #include <nlohmann/json.hpp>
#include <spdlog/spdlog.h> #include <spdlog/spdlog.h>
#include <fstream> #include <fstream>
@@ -401,8 +403,13 @@ void deduceFontMetadata(pdfengine::FontInfo& f) {
} }
// 7. Stable Internal Font Identifier // 7. Stable Internal Font Identifier
// For subset fonts, fontName already contains the subset prefix (e.g. "ABCDEF+Arial").
// Use the full fontName directly — it already encodes both the subset tag and
// the base font name, separated by '+'. Concatenating subsetTag + "_" + fontName
// would duplicate the prefix ("ABCDEF_ABCDEF+Arial").
if (f.isSubset && !f.subsetTag.empty()) { if (f.isSubset && !f.subsetTag.empty()) {
f.internalFontId = f.subsetTag + "_" + f.fontName; // fontName is "ABCDEF+Arial"; use it as-is for the stable ID.
f.internalFontId = f.fontName;
} else { } else {
f.internalFontId = f.fontName + "_" + f.type + "_" + std::to_string(f.flags); f.internalFontId = f.fontName + "_" + f.type + "_" + std::to_string(f.flags);
} }
@@ -802,7 +809,7 @@ std::expected<std::vector<GlyphBounds>, EngineError> PdfiumPage::extractTextWith
} }
std::string utf8_char = code_point_to_utf8(cp); std::string utf8_char = code_point_to_utf8(cp);
if (utf8_char.empty()) { if (utf8_char.empty() || cp == '\r' || cp == '\n') {
continue; continue;
} }
@@ -827,6 +834,254 @@ std::expected<std::vector<GlyphBounds>, EngineError> PdfiumPage::extractTextWith
#endif #endif
} }
std::expected<PageModel, EngineError> PdfiumPage::extractDocumentModel() const {
#ifdef PDFENGINE_WITH_PDFIUM
if (!page_) {
return std::unexpected(EngineError::Unknown);
}
ensureTextPageLoaded();
if (!textPage_) {
return std::unexpected(EngineError::Unknown);
}
PageModel model;
model.width = width();
model.height = height();
model.pageIndex = pageIndex_;
int charCount = FPDFText_CountChars(textPage_);
if (charCount <= 0) {
return model;
}
std::unordered_map<std::string, FontInfo> fontMap;
if (auto fontsRes = getFonts()) {
for (const auto& f : *fontsRes) {
fontMap[f.fontName] = f;
}
}
std::vector<Glyph> documentGlyphs;
documentGlyphs.reserve(charCount);
// Pass 1: Extract all glyphs
for (int i = 0; i < charCount; ++i) {
unsigned int codeUnit = FPDFText_GetUnicode(textPage_, i);
unsigned int cp = codeUnit;
// Handle surrogate pairs
if (codeUnit >= 0xD800 && codeUnit <= 0xDBFF && i + 1 < charCount) {
unsigned int nextUnit = FPDFText_GetUnicode(textPage_, i + 1);
if (nextUnit >= 0xDC00 && nextUnit <= 0xDFFF) {
cp = 0x10000 + ((codeUnit - 0xD800) << 10) + (nextUnit - 0xDC00);
}
}
std::string utf8_char = code_point_to_utf8(cp);
if (utf8_char.empty() || cp == '\r' || cp == '\n') {
if (cp > 0xFFFF) ++i; // Skip low surrogate
continue;
}
Glyph g;
g.text = std::move(utf8_char);
g.unicode = cp;
// Geometry
double left, right, bottom, top;
FPDFText_GetCharBox(textPage_, i, &left, &right, &bottom, &top);
g.bboxX = (std::min)(left, right);
g.bboxY = (std::min)(bottom, top);
g.bboxW = std::abs(right - left);
g.bboxH = std::abs(top - bottom);
FPDFText_GetCharOrigin(textPage_, i, &g.originX, &g.originY);
g.angle = FPDFText_GetCharAngle(textPage_, i);
// Style
g.fontSize = FPDFText_GetFontSize(textPage_, i);
int flags = 0;
unsigned long len = FPDFText_GetFontInfo(textPage_, i, nullptr, 0, &flags);
if (len > 0) {
std::vector<char> buf(len);
if (FPDFText_GetFontInfo(textPage_, i, buf.data(), len, &flags) > 0) {
g.fontName = std::string(buf.data());
}
}
g.flags = flags;
documentGlyphs.push_back(g);
if (cp > 0xFFFF) ++i; // Skip low surrogate
}
// Phase 5E: Line Builder
std::sort(documentGlyphs.begin(), documentGlyphs.end(), [](const Glyph& a, const Glyph& b) {
if (std::abs(a.originY - b.originY) > 1.0) {
return a.originY > b.originY; // Top to bottom
}
return a.originX < b.originX; // Left to right
});
std::vector<TextLine> lines;
if (!documentGlyphs.empty()) {
TextLine currentLine;
currentLine.angle = documentGlyphs[0].angle;
double currentOriginY = documentGlyphs[0].originY;
for (const auto& g : documentGlyphs) {
if (currentLine.glyphs.empty()) {
currentLine.glyphs.push_back(g);
continue;
}
if (std::abs(g.angle - currentLine.angle) < 0.1 &&
std::abs(g.originY - currentOriginY) < 1.0) {
currentLine.glyphs.push_back(g);
} else {
lines.push_back(std::move(currentLine));
currentLine = TextLine();
currentLine.angle = g.angle;
currentOriginY = g.originY;
currentLine.glyphs.push_back(g);
}
}
if (!currentLine.glyphs.empty()) {
lines.push_back(std::move(currentLine));
}
}
for (auto& line : lines) {
std::sort(line.glyphs.begin(), line.glyphs.end(), [](const Glyph& a, const Glyph& b) {
return a.originX < b.originX;
});
std::vector<double> gaps;
for (size_t i = 1; i < line.glyphs.size(); ++i) {
double gap = line.glyphs[i].bboxX - (line.glyphs[i-1].bboxX + line.glyphs[i-1].bboxW);
if (gap > 0) {
gaps.push_back(gap);
}
}
double medianGap = 0.0;
if (!gaps.empty()) {
std::sort(gaps.begin(), gaps.end());
medianGap = gaps[gaps.size() / 2];
}
// Phase 5F: Run Builder
TextRun currentRun;
if (!line.glyphs.empty()) {
const Glyph* firstG = &line.glyphs[0];
currentRun.fontName = firstG->fontName;
currentRun.fontSize = firstG->fontSize;
currentRun.flags = firstG->flags;
if (auto it = fontMap.find(currentRun.fontName); it != fontMap.end()) {
currentRun.internalFontId = it->second.internalFontId;
currentRun.isEmbedded = it->second.isEmbedded;
currentRun.type = it->second.type;
}
currentRun.glyphs.push_back(*firstG);
currentRun.text += firstG->text;
for (size_t i = 1; i < line.glyphs.size(); ++i) {
const auto& prevG = line.glyphs[i-1];
const auto& currG = line.glyphs[i];
double gap = currG.bboxX - (prevG.bboxX + prevG.bboxW);
double spaceThreshold = (std::max)(currG.fontSize * 0.25, medianGap * 2.0);
bool addSpace = gap > spaceThreshold && prevG.text != " " && currG.text != " ";
bool breakRun = currG.fontName != currentRun.fontName ||
std::abs(currG.fontSize - currentRun.fontSize) > 0.1 ||
currG.flags != currentRun.flags;
if (addSpace) {
Glyph spaceGlyph;
spaceGlyph.text = " ";
spaceGlyph.unicode = ' ';
spaceGlyph.fontSize = currG.fontSize;
spaceGlyph.fontName = currG.fontName;
spaceGlyph.flags = currG.flags;
spaceGlyph.originX = prevG.bboxX + prevG.bboxW;
spaceGlyph.originY = currG.originY;
spaceGlyph.angle = currG.angle;
spaceGlyph.bboxX = spaceGlyph.originX;
spaceGlyph.bboxY = currG.bboxY;
spaceGlyph.bboxW = gap;
spaceGlyph.bboxH = currG.bboxH;
if (breakRun) {
line.runs.push_back(std::move(currentRun));
currentRun = TextRun();
currentRun.fontName = currG.fontName;
currentRun.fontSize = currG.fontSize;
currentRun.flags = currG.flags;
if (auto it = fontMap.find(currentRun.fontName); it != fontMap.end()) {
currentRun.internalFontId = it->second.internalFontId;
currentRun.isEmbedded = it->second.isEmbedded;
currentRun.type = it->second.type;
}
}
currentRun.glyphs.push_back(spaceGlyph);
currentRun.text += spaceGlyph.text;
} else if (breakRun) {
line.runs.push_back(std::move(currentRun));
currentRun = TextRun();
currentRun.fontName = currG.fontName;
currentRun.fontSize = currG.fontSize;
currentRun.flags = currG.flags;
if (auto it = fontMap.find(currentRun.fontName); it != fontMap.end()) {
currentRun.internalFontId = it->second.internalFontId;
currentRun.isEmbedded = it->second.isEmbedded;
currentRun.type = it->second.type;
}
}
currentRun.glyphs.push_back(currG);
currentRun.text += currG.text;
}
if (!currentRun.glyphs.empty()) {
line.runs.push_back(std::move(currentRun));
}
}
}
// Phase 5G: Paragraph Builder
std::vector<Paragraph> paragraphs;
if (!lines.empty()) {
Paragraph currentPara;
currentPara.lines.push_back(std::move(lines[0]));
for (size_t i = 1; i < lines.size(); ++i) {
auto& prevLine = currentPara.lines.back();
auto& currLine = lines[i];
double prevY = prevLine.glyphs.empty() ? 0 : prevLine.glyphs[0].originY;
double currY = currLine.glyphs.empty() ? 0 : currLine.glyphs[0].originY;
double fontSize = currLine.runs.empty() ? 12.0 : currLine.runs[0].fontSize;
double vGap = std::abs(prevY - currY);
if (vGap > fontSize * 1.5) {
paragraphs.push_back(std::move(currentPara));
currentPara = Paragraph();
}
currentPara.lines.push_back(std::move(currLine));
}
if (!currentPara.lines.empty()) {
paragraphs.push_back(std::move(currentPara));
}
}
model.paragraphs = std::move(paragraphs);
return model;
#else
return std::unexpected(EngineError::Unknown);
#endif
}
std::expected<std::vector<std::string>, EngineError> PdfiumPage::extractAnnotationsText() const { std::expected<std::vector<std::string>, EngineError> PdfiumPage::extractAnnotationsText() const {
#ifdef PDFENGINE_WITH_PDFIUM #ifdef PDFENGINE_WITH_PDFIUM
if (!page_) { if (!page_) {
@@ -884,6 +1139,60 @@ Point2D PdfiumPage::deviceToPage(const DevicePoint& devicePoint, int deviceWidth
#endif #endif
} }
std::expected<double, EngineError> PdfiumPage::getGlyphWidth(const std::string& fontName, uint32_t charcode, double fontSize) const {
#ifdef PDFENGINE_WITH_PDFIUM
if (!page_) {
return std::unexpected(EngineError::Unknown);
}
FPDF_FONT font = nullptr;
{
std::lock_guard<std::mutex> lock(textMutex_);
auto cached = fontHandleCache_.find(fontName);
if (cached != fontHandleCache_.end()) {
font = cached->second;
} else {
int objectCount = FPDFPage_CountObjects(page_);
for (int i = 0; i < objectCount; ++i) {
FPDF_PAGEOBJECT obj = FPDFPage_GetObject(page_, i);
if (!obj || FPDFPageObj_GetType(obj) != FPDF_PAGEOBJ_TEXT) continue;
FPDF_FONT pageFont = FPDFTextObj_GetFont(obj);
if (!pageFont) continue;
size_t nameLen = FPDFFont_GetBaseFontName(pageFont, nullptr, 0);
if (nameLen > 0) {
std::vector<char> nameBuf(nameLen);
if (FPDFFont_GetBaseFontName(pageFont, nameBuf.data(), nameLen) > 0) {
std::string currentName(nameBuf.data());
if (currentName == fontName) {
font = pageFont;
fontHandleCache_[fontName] = font;
break;
}
}
}
}
}
}
if (!font) {
return std::unexpected(EngineError::Unknown);
}
float width = 0.0f;
if (!FPDFFont_GetGlyphWidth(font, charcode, static_cast<float>(fontSize), &width)) {
return std::unexpected(EngineError::Unknown);
}
return static_cast<double>(width);
#else
(void)fontName; (void)charcode; (void)fontSize;
return std::unexpected(EngineError::Unknown);
#endif
}
void PdfiumPage::ensureTextPageLoaded() const { void PdfiumPage::ensureTextPageLoaded() const {
#ifdef PDFENGINE_WITH_PDFIUM #ifdef PDFENGINE_WITH_PDFIUM
std::lock_guard<std::mutex> lock(textMutex_); std::lock_guard<std::mutex> lock(textMutex_);
@@ -962,11 +1271,26 @@ std::expected<std::shared_ptr<PdfPage>, EngineError> PdfiumDocument::getPage(int
if (pageIndex < 0 || pageIndex >= pageCount()) { if (pageIndex < 0 || pageIndex >= pageCount()) {
return std::unexpected(EngineError::PageOutOfBounds); return std::unexpected(EngineError::PageOutOfBounds);
} }
{
std::lock_guard<std::mutex> lock(pageCacheMutex_);
auto it = pageCache_.find(pageIndex);
if (it != pageCache_.end()) {
return it->second;
}
}
FPDF_PAGE page = FPDF_LoadPage(doc_, pageIndex); FPDF_PAGE page = FPDF_LoadPage(doc_, pageIndex);
if (!page) { if (!page) {
return std::unexpected(EngineError::Unknown); return std::unexpected(EngineError::Unknown);
} }
return std::make_shared<PdfiumPage>(page, pageIndex);
auto pageObj = std::make_shared<PdfiumPage>(page, pageIndex);
{
std::lock_guard<std::mutex> lock(pageCacheMutex_);
pageCache_[pageIndex] = pageObj;
}
return pageObj;
#else #else
(void)pageIndex; (void)pageIndex;
return std::unexpected(EngineError::Unknown); return std::unexpected(EngineError::Unknown);
@@ -1332,7 +1656,7 @@ std::expected<void, EngineError> PdfiumDocument::applyEdits(const std::string& e
return std::unexpected(EngineError::Unknown); return std::unexpected(EngineError::Unknown);
} }
invalidateFontCache(); invalidateCaches();
return {}; return {};
#else #else
(void)editsJson; (void)editsJson;
@@ -1553,8 +1877,10 @@ std::expected<std::vector<FontInfo>, EngineError> PdfiumPage::getFonts() const {
// deduceFontMetadata() (SystemFallback or Substituted). // deduceFontMetadata() (SystemFallback or Substituted).
// --- Recalculate stable identifier with corrected data --- // --- Recalculate stable identifier with corrected data ---
// fontName already includes the subset prefix (e.g. "ABCDEF+Arial");
// using it directly avoids the duplicate-prefix bug.
if (f.isSubset && !f.subsetTag.empty()) { if (f.isSubset && !f.subsetTag.empty()) {
f.internalFontId = f.subsetTag + "_" + f.fontName; f.internalFontId = f.fontName;
} else { } else {
f.internalFontId = f.fontName + "_" + f.type + "_" + std::to_string(f.flags); f.internalFontId = f.fontName + "_" + f.type + "_" + std::to_string(f.flags);
} }
@@ -1618,12 +1944,6 @@ std::expected<std::vector<FontInfo>, EngineError> PdfiumDocument::getFonts(int s
return std::vector<FontInfo>(); 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 // Return full document-level cache if available and full range is requested
if (startPage == 0 && endPage == total - 1 && hasCachedFonts_) { if (startPage == 0 && endPage == total - 1 && hasCachedFonts_) {
@@ -1632,15 +1952,13 @@ std::expected<std::vector<FontInfo>, EngineError> PdfiumDocument::getFonts(int s
std::vector<FontInfo> aggregated; std::vector<FontInfo> aggregated;
for (int i = startPage; i <= endPage; ++i) { for (int i = startPage; i <= endPage; ++i) {
FPDF_PAGE page = FPDF_LoadPage(doc_, i); auto pageRes = const_cast<PdfiumDocument*>(this)->getPage(i);
if (!page) { if (!pageRes) {
spdlog::error("Failed to load page index {} for font diagnostics", i); spdlog::error("Failed to load page index {} for font diagnostics", i);
continue; continue;
} }
// Stack-allocated wrapper ensures FPDF handles are closed properly upon destruction auto pageFontsRes = pageRes.value()->getFonts();
PdfiumPage tempPage(page, i);
auto pageFontsRes = tempPage.getFonts();
if (pageFontsRes) { if (pageFontsRes) {
for (const auto& f : *pageFontsRes) { for (const auto& f : *pageFontsRes) {
auto it = std::find_if(aggregated.begin(), aggregated.end(), [&](const FontInfo& existing) { auto it = std::find_if(aggregated.begin(), aggregated.end(), [&](const FontInfo& existing) {
@@ -1681,11 +1999,164 @@ std::expected<std::vector<FontInfo>, EngineError> PdfiumDocument::getFonts(int s
#endif #endif
} }
void PdfiumDocument::invalidateFontCache() { std::expected<std::vector<uint8_t>, EngineError> PdfiumDocument::getFontData(const std::string& internalFontId) const {
std::lock_guard<std::mutex> lock(fontsMutex_); #ifdef PDFENGINE_WITH_PDFIUM
cachedFonts_.clear(); ensure_pdfium_initialized();
hasCachedFonts_ = false; if (!doc_) {
spdlog::info("Document font cache has been invalidated."); return std::unexpected(EngineError::Unknown);
}
// Reconstruct the exact expected font name from the internalFontId.
// Non-subset format: {fontName}_{type}_{flags}
std::string expectedFontName = internalFontId;
size_t lastUnderscore = expectedFontName.rfind('_');
if (lastUnderscore != std::string::npos && lastUnderscore > 0) {
size_t secondLastUnderscore = expectedFontName.rfind('_', lastUnderscore - 1);
if (secondLastUnderscore != std::string::npos) {
std::string typePart = expectedFontName.substr(secondLastUnderscore + 1, lastUnderscore - secondLastUnderscore - 1);
if (typePart == "TrueType" || typePart == "Type1" || typePart == "CIDFontType0" || typePart == "CIDFontType2") {
expectedFontName = expectedFontName.substr(0, secondLastUnderscore);
}
}
}
int numPages = FPDF_GetPageCount(doc_);
// Resume scanning from where we left off
int startPage = 0;
{
std::lock_guard<std::mutex> lock(fontsMutex_);
// Return immediately if already cached
if (fontDataCache_.count(expectedFontName)) {
const auto& cachedBuf = fontDataCache_[expectedFontName];
if (!cachedBuf.empty()) {
return cachedBuf;
} else {
return std::unexpected(EngineError::FileNotFound);
}
}
startPage = fontDataScannedPages_;
}
for (int i = startPage; i < numPages; ++i) {
FPDF_PAGE page = FPDF_LoadPage(doc_, i);
if (!page) {
std::lock_guard<std::mutex> lock(fontsMutex_);
fontDataScannedPages_ = i + 1;
continue;
}
int objectCount = FPDFPage_CountObjects(page);
for (int j = 0; j < objectCount; ++j) {
FPDF_PAGEOBJECT obj = FPDFPage_GetObject(page, j);
if (!obj || FPDFPageObj_GetType(obj) != FPDF_PAGEOBJ_TEXT) continue;
FPDF_FONT font = FPDFTextObj_GetFont(obj);
if (!font) continue;
unsigned long nameLen = FPDFFont_GetBaseFontName(font, nullptr, 0);
if (nameLen > 0) {
std::vector<char> nameBuf(nameLen);
if (FPDFFont_GetBaseFontName(font, nameBuf.data(), nameLen) > 0) {
std::string fontName(nameBuf.data());
std::lock_guard<std::mutex> lock(fontsMutex_);
// Always cache every font we encounter to avoid rescanning
if (fontDataCache_.find(fontName) == fontDataCache_.end()) {
size_t buflen = 0;
FPDFFont_GetFontData(font, nullptr, 0, &buflen);
if (buflen > 0) {
std::vector<uint8_t> buffer(buflen);
size_t actual_len = 0;
if (FPDFFont_GetFontData(font, buffer.data(), buflen, &actual_len)) {
fontDataCache_[fontName] = buffer;
} else {
fontDataCache_[fontName] = std::vector<uint8_t>();
}
} else {
fontDataCache_[fontName] = std::vector<uint8_t>();
}
}
// Use exact match instead of prefix match to avoid false positives
if (fontName == expectedFontName) {
const auto& cachedBuf = fontDataCache_[fontName];
if (!cachedBuf.empty()) {
FPDF_ClosePage(page);
fontDataScannedPages_ = i; // can resume from the same page later
return cachedBuf;
}
}
}
}
}
FPDF_ClosePage(page);
std::lock_guard<std::mutex> lock(fontsMutex_);
fontDataScannedPages_ = i + 1;
}
// We finished scanning all pages and still didn't find it (or extraction failed)
{
std::lock_guard<std::mutex> lock(fontsMutex_);
// Mark as failed so we don't try to rescan for it
if (fontDataCache_.find(expectedFontName) == fontDataCache_.end()) {
fontDataCache_[expectedFontName] = std::vector<uint8_t>();
}
}
return std::unexpected(EngineError::FileNotFound);
#else
(void)internalFontId;
return std::unexpected(EngineError::Unknown);
#endif
}
std::expected<std::shared_ptr<fonts::pdf_fonts::Font>, std::string> PdfiumDocument::getResolvedFont(const FontInfo& fontInfo) {
#ifdef PDFENGINE_WITH_PDFIUM
std::lock_guard<std::mutex> lock(resolvedFontsMutex_);
// Check Cache
if (resolvedFontsCache_.count(fontInfo.internalFontId)) {
return resolvedFontsCache_[fontInfo.internalFontId];
}
if (!fontResolver_) {
// We pass a shared_ptr to 'this'
fontResolver_ = std::make_unique<::pdfengine::fonts::loader::FontResolver>(shared_from_this());
}
// Resolve font
auto result = fontResolver_->resolveFont(fontInfo);
if (!result) {
return std::unexpected(result.error());
}
// Cache and return
std::shared_ptr<fonts::pdf_fonts::Font> sharedFont(std::move(result.value()));
resolvedFontsCache_[fontInfo.internalFontId] = sharedFont;
return sharedFont;
#else
return std::unexpected(std::string("EngineError::Unknown"));
#endif
}
void PdfiumDocument::invalidateCaches() {
{
std::lock_guard<std::mutex> lock(fontsMutex_);
cachedFonts_.clear();
hasCachedFonts_ = false;
}
{
std::lock_guard<std::mutex> lock(pageCacheMutex_);
pageCache_.clear();
}
{
std::lock_guard<std::mutex> lock(resolvedFontsMutex_);
resolvedFontsCache_.clear();
}
spdlog::info("Document caches have been invalidated.");
} }
} }
+25 -2
View File
@@ -11,6 +11,8 @@
#include <string> #include <string>
#include <vector> #include <vector>
#include <mutex> #include <mutex>
#include <unordered_map>
namespace pdfengine::fonts::loader { class FontResolver; }
namespace pdfengine::parser { namespace pdfengine::parser {
@@ -40,9 +42,12 @@ 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<PageModel, EngineError> extractDocumentModel() const override;
std::expected<std::vector<FontInfo>, EngineError> getFonts() const override; std::expected<std::vector<FontInfo>, EngineError> getFonts() const override;
std::expected<std::vector<std::string>, EngineError> extractAnnotationsText() const override; std::expected<std::vector<std::string>, EngineError> extractAnnotationsText() const override;
std::expected<double, EngineError> getGlyphWidth(const std::string& fontName, uint32_t charcode, double fontSize) 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;
@@ -51,11 +56,14 @@ private:
mutable NativeTextHandle textPage_ = nullptr; mutable NativeTextHandle textPage_ = nullptr;
int pageIndex_ = 0; int pageIndex_ = 0;
mutable std::mutex textMutex_; mutable std::mutex textMutex_;
#ifdef PDFENGINE_WITH_PDFIUM
mutable std::unordered_map<std::string, FPDF_FONT> fontHandleCache_;
#endif
void ensureTextPageLoaded() const; void ensureTextPageLoaded() const;
}; };
class PdfiumDocument : public PdfDocument { class PdfiumDocument : public PdfDocument, public std::enable_shared_from_this<PdfiumDocument> {
public: public:
explicit PdfiumDocument(NativeDocHandle docHandle); explicit PdfiumDocument(NativeDocHandle docHandle);
PdfiumDocument(NativeDocHandle docHandle, std::vector<uint8_t> memoryBuffer); PdfiumDocument(NativeDocHandle docHandle, std::vector<uint8_t> memoryBuffer);
@@ -71,7 +79,10 @@ public:
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; std::expected<std::vector<FontInfo>, EngineError> getFonts(int startPage = 0, int endPage = -1) const override;
void invalidateFontCache(); std::expected<std::vector<uint8_t>, EngineError> getFontData(const std::string& internalFontId) const override;
std::expected<std::shared_ptr<fonts::pdf_fonts::Font>, std::string> getResolvedFont(const FontInfo& fontInfo) override;
void invalidateCaches();
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;
@@ -84,6 +95,18 @@ private:
mutable std::vector<FontInfo> cachedFonts_; mutable std::vector<FontInfo> cachedFonts_;
mutable bool hasCachedFonts_ = false; mutable bool hasCachedFonts_ = false;
mutable std::mutex fontsMutex_; mutable std::mutex fontsMutex_;
// Cache to prevent O(N*M) full-document scans for font data extraction
mutable std::unordered_map<std::string, std::vector<uint8_t>> fontDataCache_;
mutable int fontDataScannedPages_ = 0;
mutable std::unordered_map<int, std::shared_ptr<PdfPage>> pageCache_;
mutable std::mutex pageCacheMutex_;
// Font Engine Bridge
std::unique_ptr<pdfengine::fonts::loader::FontResolver> fontResolver_;
std::unordered_map<std::string, std::shared_ptr<fonts::pdf_fonts::Font>> resolvedFontsCache_;
std::mutex resolvedFontsMutex_;
}; };
// Exposed for testing // Exposed for testing
+1 -1
View File
@@ -887,7 +887,7 @@ TEST(FontDiagnosticsTest, DeepIntrospectionAndFontSizeVerification) {
} }
EXPECT_EQ(f.sourceType, "Embedded"); EXPECT_EQ(f.sourceType, "Embedded");
EXPECT_TRUE(f.isEmbedded); EXPECT_TRUE(f.isEmbedded);
EXPECT_EQ(f.internalFontId, f.subsetTag + "_" + f.fontName); EXPECT_EQ(f.internalFontId, f.fontName);
} else { } else {
EXPECT_TRUE(f.subsetTag.empty()); EXPECT_TRUE(f.subsetTag.empty());
EXPECT_EQ(f.internalFontId, f.fontName + "_" + f.type + "_" + std::to_string(f.flags)); EXPECT_EQ(f.internalFontId, f.fontName + "_" + f.type + "_" + std::to_string(f.flags));
+55 -15
View File
@@ -59,7 +59,6 @@
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@babel/code-frame": "^7.29.0", "@babel/code-frame": "^7.29.0",
"@babel/generator": "^7.29.0", "@babel/generator": "^7.29.0",
@@ -269,10 +268,31 @@
"node": ">=6.9.0" "node": ">=6.9.0"
} }
}, },
"node_modules/@emnapi/core": {
"version": "1.11.0",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.0.tgz",
"integrity": "sha512-l9Oo58x0HOP5znGzVhYW9U3e5wVuA4LAZU2AGezTmkhO1CgQRFDhDg4nneHsu/t3WniXg9QrG2nIXL/ZS8ln8Q==",
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/wasi-threads": "1.2.2",
"tslib": "^2.4.0"
}
},
"node_modules/@emnapi/runtime": {
"version": "1.11.0",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.0.tgz",
"integrity": "sha512-55coeOFKHv1ywEcUXJtWU5f+Jr/W5tZDvZig8DLKSwUN1JpROQ4rk/SNOQiFWmaR/VKF4zuFyW1B8JduOSv6Pg==",
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@emnapi/wasi-threads": { "node_modules/@emnapi/wasi-threads": {
"version": "1.2.1", "version": "1.2.2",
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz",
"integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==",
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"dependencies": { "dependencies": {
@@ -755,6 +775,37 @@
"node": "^20.19.0 || >=22.12.0" "node": "^20.19.0 || >=22.12.0"
} }
}, },
"node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/core": {
"version": "1.10.0",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz",
"integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==",
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/wasi-threads": "1.2.1",
"tslib": "^2.4.0"
}
},
"node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/runtime": {
"version": "1.10.0",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz",
"integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==",
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/wasi-threads": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz",
"integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==",
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@rolldown/binding-win32-arm64-msvc": { "node_modules/@rolldown/binding-win32-arm64-msvc": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.1.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.1.tgz",
@@ -1087,7 +1138,6 @@
"integrity": "sha512-GUUEShf+PBCGW2KaXwcIt3Yk+e3pkKwWKb9GSyM9WQVE+ep2jzmHdGsHzu4wgcZy5fN9FBdVzjpBQsYlpfpgLA==", "integrity": "sha512-GUUEShf+PBCGW2KaXwcIt3Yk+e3pkKwWKb9GSyM9WQVE+ep2jzmHdGsHzu4wgcZy5fN9FBdVzjpBQsYlpfpgLA==",
"devOptional": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"undici-types": "~7.16.0" "undici-types": "~7.16.0"
} }
@@ -1098,7 +1148,6 @@
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"csstype": "^3.2.2" "csstype": "^3.2.2"
} }
@@ -1158,7 +1207,6 @@
"integrity": "sha512-HPwA+hVkfcriajbNvTmZv4VRauibay+cWArYUYq7u7W7PmGShMxbPxLvrwDme55a6d5alG3nrYfhyJ/G28XlLg==", "integrity": "sha512-HPwA+hVkfcriajbNvTmZv4VRauibay+cWArYUYq7u7W7PmGShMxbPxLvrwDme55a6d5alG3nrYfhyJ/G28XlLg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@typescript-eslint/scope-manager": "8.59.3", "@typescript-eslint/scope-manager": "8.59.3",
"@typescript-eslint/types": "8.59.3", "@typescript-eslint/types": "8.59.3",
@@ -1389,7 +1437,6 @@
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"bin": { "bin": {
"acorn": "bin/acorn" "acorn": "bin/acorn"
}, },
@@ -1480,7 +1527,6 @@
} }
], ],
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"baseline-browser-mapping": "^2.10.12", "baseline-browser-mapping": "^2.10.12",
"caniuse-lite": "^1.0.30001782", "caniuse-lite": "^1.0.30001782",
@@ -1628,7 +1674,6 @@
"integrity": "sha512-loXy6bWOoP3EP6JA7jo6p5jMpBJmHmsNZM5SFRHLdh1MGOPurMnNBj4ZlAbaqUAaQWbCr7jHV4P7gzAyryZWkQ==", "integrity": "sha512-loXy6bWOoP3EP6JA7jo6p5jMpBJmHmsNZM5SFRHLdh1MGOPurMnNBj4ZlAbaqUAaQWbCr7jHV4P7gzAyryZWkQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.2", "@eslint-community/regexpp": "^4.12.2",
@@ -2524,7 +2569,6 @@
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=12" "node": ">=12"
}, },
@@ -2585,7 +2629,6 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.2.6.tgz", "resolved": "https://registry.npmjs.org/react/-/react-19.2.6.tgz",
"integrity": "sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q==", "integrity": "sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q==",
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=0.10.0" "node": ">=0.10.0"
} }
@@ -2757,7 +2800,6 @@
"integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"bin": { "bin": {
"tsc": "bin/tsc", "tsc": "bin/tsc",
"tsserver": "bin/tsserver" "tsserver": "bin/tsserver"
@@ -2843,7 +2885,6 @@
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.13.tgz", "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.13.tgz",
"integrity": "sha512-MFtjBYgzmSxmgA4RAfjIyXWpGe1oALnjgUTzzV7QLx/TKxCzjtMH6Fd9/eVK+5Fg1qNoz5VAwsmMs/NofrmJvw==", "integrity": "sha512-MFtjBYgzmSxmgA4RAfjIyXWpGe1oALnjgUTzzV7QLx/TKxCzjtMH6Fd9/eVK+5Fg1qNoz5VAwsmMs/NofrmJvw==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"lightningcss": "^1.32.0", "lightningcss": "^1.32.0",
"picomatch": "^4.0.4", "picomatch": "^4.0.4",
@@ -2968,7 +3009,6 @@
"integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"funding": { "funding": {
"url": "https://github.com/sponsors/colinhacks" "url": "https://github.com/sponsors/colinhacks"
} }
+30 -8
View File
@@ -5,7 +5,7 @@ import { PDFViewer } from './viewer/PDFViewer';
import type { PDFViewerRef } from './viewer/PDFViewer'; import type { PDFViewerRef } from './viewer/PDFViewer';
import type { Annotation } from './viewer/AnnotationLayer'; import type { Annotation } from './viewer/AnnotationLayer';
import { gatewayService } from './lib/gatewayService'; import { gatewayService } from './lib/gatewayService';
import type { DocumentInfo } from './lib/gatewayService'; import type { DocumentInfo, SearchResult } from './lib/gatewayService';
import { wasmLoader } from './lib/wasmLoader'; import { wasmLoader } from './lib/wasmLoader';
import './App.css'; import './App.css';
@@ -28,16 +28,30 @@ function App() {
// Search State // Search State
const [searchQuery, setSearchQuery] = useState(''); const [searchQuery, setSearchQuery] = useState('');
const [searchResults, setSearchResults] = useState<SearchResult[]>([]);
const [searchResultCount, setSearchResultCount] = useState(0); const [searchResultCount, setSearchResultCount] = useState(0);
const [searchCurrentMatch, setSearchCurrentMatch] = useState(0); const [searchCurrentMatch, setSearchCurrentMatch] = useState(0);
const handleSearch = (query: string) => { const handleSearch = async (query: string) => {
setSearchQuery(query); setSearchQuery(query);
// Mock search results for Phase 0 if (!query || !selectedDocId) {
if (query) { setSearchResults([]);
setSearchResultCount(5); setSearchResultCount(0);
setSearchCurrentMatch(0); setSearchCurrentMatch(0);
} else { return;
}
try {
const results = await gatewayService.searchDocument(selectedDocId, query);
setSearchResults(results);
setSearchResultCount(results.length);
setSearchCurrentMatch(0);
if (results.length > 0) {
viewerRef.current?.scrollToPage(results[0].pageIndex);
}
} catch (err) {
console.error("Search failed", err);
setSearchResults([]);
setSearchResultCount(0); setSearchResultCount(0);
setSearchCurrentMatch(0); setSearchCurrentMatch(0);
} }
@@ -45,13 +59,17 @@ function App() {
const handleSearchNext = () => { const handleSearchNext = () => {
if (searchResultCount > 0) { if (searchResultCount > 0) {
setSearchCurrentMatch((prev) => (prev + 1) % searchResultCount); const nextMatch = (searchCurrentMatch + 1) % searchResultCount;
setSearchCurrentMatch(nextMatch);
viewerRef.current?.scrollToPage(searchResults[nextMatch].pageIndex);
} }
}; };
const handleSearchPrev = () => { const handleSearchPrev = () => {
if (searchResultCount > 0) { if (searchResultCount > 0) {
setSearchCurrentMatch((prev) => (prev - 1 + searchResultCount) % searchResultCount); const prevMatch = (searchCurrentMatch - 1 + searchResultCount) % searchResultCount;
setSearchCurrentMatch(prevMatch);
viewerRef.current?.scrollToPage(searchResults[prevMatch].pageIndex);
} }
}; };
@@ -362,11 +380,15 @@ function App() {
ref={viewerRef} ref={viewerRef}
documentId={activeDoc.id} documentId={activeDoc.id}
totalPages={activeDoc.totalPages} totalPages={activeDoc.totalPages}
pageWidth={activeDoc.pageWidth}
pageHeight={activeDoc.pageHeight}
zoom={zoom} zoom={zoom}
pagesInfo={activeDoc.pages} pagesInfo={activeDoc.pages}
activeTool={activeTool} activeTool={activeTool}
annotations={annotations} annotations={annotations}
searchQuery={searchQuery} searchQuery={searchQuery}
searchResults={searchResults}
searchCurrentMatch={searchCurrentMatch}
onAnnotationAdded={handleAnnotationAdded} onAnnotationAdded={handleAnnotationAdded}
onPageVisible={setCurrentPage} onPageVisible={setCurrentPage}
onRedactArea={handleRedactArea} onRedactArea={handleRedactArea}
+8
View File
@@ -160,6 +160,14 @@ export const Toolbar: React.FC<ToolbarProps> = ({
Highlight Highlight
</button> </button>
<button
onClick={() => onActiveToolChange('draw')}
className={`tool-btn draw ${activeTool === 'draw' ? 'active' : ''}`}
title="Freehand Ink Pen"
>
Draw
</button>
<button <button
onClick={() => onActiveToolChange('signature')} onClick={() => onActiveToolChange('signature')}
className={`tool-btn signature ${activeTool === 'signature' ? 'active' : ''}`} className={`tool-btn signature ${activeTool === 'signature' ? 'active' : ''}`}
+53 -19
View File
@@ -5,20 +5,23 @@
:root { :root {
--sans: 'Outfit', system-ui, -apple-system, sans-serif; --sans: 'Outfit', system-ui, -apple-system, sans-serif;
--mono: 'JetBrains Mono', monospace; --mono: 'JetBrains Mono', monospace;
/* Color Palette - Premium Royal/Midnight */ /* Color Palette - Premium Royal/Midnight */
--bg-main: #020617; /* Slate 950 */ --bg-main: #020617;
--bg-card: #0f172a; /* Slate 900 */ /* Slate 950 */
--bg-sidebar: #090d16; /* Deep Midnight */ --bg-card: #0f172a;
/* Slate 900 */
--bg-sidebar: #090d16;
/* Deep Midnight */
--bg-accent-indigo: #4f46e5; --bg-accent-indigo: #4f46e5;
--bg-accent-indigo-hover: #4338ca; --bg-accent-indigo-hover: #4338ca;
--border-main: #1e293b; --border-main: #1e293b;
--border-glow: rgba(99, 102, 241, 0.25); --border-glow: rgba(99, 102, 241, 0.25);
--text-main: #f8fafc; --text-main: #f8fafc;
--text-muted: #94a3b8; --text-muted: #94a3b8;
--text-dim: #64748b; --text-dim: #64748b;
/* Status Colors */ /* Status Colors */
--color-success: #10b981; --color-success: #10b981;
--color-success-bg: rgba(16, 185, 129, 0.1); --color-success-bg: rgba(16, 185, 129, 0.1);
@@ -175,9 +178,18 @@ body {
border-radius: 9999px; border-radius: 9999px;
} }
.status-checking .health-dot { background: var(--color-warning); animation: pulse 1.5s infinite; } .status-checking .health-dot {
.status-healthy .health-dot { background: var(--color-success); } background: var(--color-warning);
.status-unhealthy .health-dot { background: var(--color-error); } animation: pulse 1.5s infinite;
}
.status-healthy .health-dot {
background: var(--color-success);
}
.status-unhealthy .health-dot {
background: var(--color-error);
}
.wasm-badge { .wasm-badge {
display: flex; display: flex;
@@ -313,6 +325,12 @@ body {
border: 1px solid rgba(245, 158, 11, 0.4); border: 1px solid rgba(245, 158, 11, 0.4);
} }
.tool-btn.draw.active {
background: rgba(59, 130, 246, 0.2);
color: #60a5fa;
border: 1px solid rgba(59, 130, 246, 0.4);
}
.tool-btn.signature.active { .tool-btn.signature.active {
background: rgba(79, 70, 229, 0.2); background: rgba(79, 70, 229, 0.2);
color: #c7d2fe; color: #c7d2fe;
@@ -422,7 +440,8 @@ body {
padding: 18px; padding: 18px;
} }
.doc-list-container, .annotations-list { .doc-list-container,
.annotations-list {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 12px; gap: 12px;
@@ -605,7 +624,8 @@ body {
flex: 1; flex: 1;
height: 100%; height: 100%;
overflow: auto; overflow: auto;
background: #0b0f19; /* Slightly darker midnight */ background: #0b0f19;
/* Slightly darker midnight */
display: flex; display: flex;
justify-content: center; justify-content: center;
align-items: start; align-items: start;
@@ -698,11 +718,13 @@ body {
} }
.highlight-box.type-highlight { .highlight-box.type-highlight {
background: #fde047; /* Yellow 300 */ background: #fde047;
/* Yellow 300 */
} }
.highlight-box.type-comment { .highlight-box.type-comment {
background: #fecdd3; /* Rose 200 */ background: #fecdd3;
/* Rose 200 */
border-bottom: 2px solid #f43f5e; border-bottom: 2px solid #f43f5e;
} }
@@ -732,7 +754,7 @@ body {
font-weight: 700; font-weight: 700;
padding: 4px 10px; padding: 4px 10px;
border-radius: 6px; border-radius: 6px;
box-shadow: 0 4px 12px rgba(0,0,0,0.25); box-shadow: 0 4px 12px rgba(0, 0, 0, 0.25);
letter-spacing: 0.5px; letter-spacing: 0.5px;
} }
@@ -751,11 +773,23 @@ body {
/* Animations */ /* Animations */
@keyframes spin { @keyframes spin {
0% { transform: rotate(0deg); } 0% {
100% { transform: rotate(360deg); } transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
} }
@keyframes pulse { @keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; } 0%,
} 100% {
opacity: 1;
}
50% {
opacity: 0.5;
}
}
+49 -5
View File
@@ -16,6 +16,8 @@ export interface DocumentInfo {
filename: string; filename: string;
sizeBytes: number; sizeBytes: number;
totalPages: number; totalPages: number;
pageWidth: number;
pageHeight: number;
uploadedAt: string; uploadedAt: string;
status: 'processing' | 'ready' | 'error'; status: 'processing' | 'ready' | 'error';
pages?: PageInfo[]; pages?: PageInfo[];
@@ -30,6 +32,19 @@ export interface RenderParams {
import type { Point } from './coordinateMapping'; import type { Point } from './coordinateMapping';
export interface SearchRect {
x: number;
y: number;
w: number;
h: number;
}
export interface SearchResult {
pageIndex: number;
rects: SearchRect[];
text: string;
}
export interface TextOverlayData { export interface TextOverlayData {
text: string; text: string;
x: number; x: number;
@@ -103,7 +118,7 @@ export interface PageRotationData {
rotation: 0 | 90 | 180 | 270; rotation: 0 | 90 | 180 | 270;
} }
export interface PageDeletionData {} export interface PageDeletionData { }
export interface PageReorderData { export interface PageReorderData {
destPageIndex: number; destPageIndex: number;
@@ -176,7 +191,7 @@ class GatewayService {
method: 'POST', method: 'POST',
body: formData, body: formData,
}); });
if (response.status === 501) { if (response.status === 501) {
// Simulate upload for Phase 0 scaffolding // Simulate upload for Phase 0 scaffolding
return new Promise((resolve) => { return new Promise((resolve) => {
@@ -186,6 +201,8 @@ class GatewayService {
filename: file.name, filename: file.name,
sizeBytes: file.size, sizeBytes: file.size,
totalPages: 5, // Mocked total pages totalPages: 5, // Mocked total pages
pageWidth: 612,
pageHeight: 792,
uploadedAt: new Date().toISOString(), uploadedAt: new Date().toISOString(),
status: 'ready', status: 'ready',
pages: Array.from({ length: 5 }, (_, i) => ({ index: i, width: 612, height: 792 })), pages: Array.from({ length: 5 }, (_, i) => ({ index: i, width: 612, height: 792 })),
@@ -193,7 +210,7 @@ class GatewayService {
}, 1000); }, 1000);
}); });
} }
if (!response.ok) throw new Error(`Upload failed: ${response.statusText}`); if (!response.ok) throw new Error(`Upload failed: ${response.statusText}`);
return response.json(); return response.json();
} }
@@ -235,12 +252,12 @@ class GatewayService {
}).toString(); }).toString();
const url = `${this.baseUrl}/render/${params.documentId}?${query}`; const url = `${this.baseUrl}/render/${params.documentId}?${query}`;
const response = await fetch(url); const response = await fetch(url);
if (response.status === 501) { if (response.status === 501) {
return this.generateMockPage(params.pageIndex); return this.generateMockPage(params.pageIndex);
} }
if (!response.ok) throw new Error(`Page render failed: ${response.statusText}`); if (!response.ok) throw new Error(`Page render failed: ${response.statusText}`);
const blob = await response.blob(); const blob = await response.blob();
return URL.createObjectURL(blob); return URL.createObjectURL(blob);
@@ -249,6 +266,12 @@ class GatewayService {
} }
} }
async getPageModel(documentId: string, pageIndex: number): Promise<any> {
const response = await fetch(`${this.baseUrl}/documents/${documentId}/pages/${pageIndex}/model`);
if (!response.ok) throw new Error(`Failed to get page model: ${response.statusText}`);
return response.json();
}
async applyEdits(documentId: string, operations: EditOperation[]): Promise<{ success: boolean; newDocumentId: string }> { async applyEdits(documentId: string, operations: EditOperation[]): Promise<{ success: boolean; newDocumentId: string }> {
const response = await fetch(`${this.baseUrl}/edits/${documentId}`, { const response = await fetch(`${this.baseUrl}/edits/${documentId}`, {
method: 'POST', method: 'POST',
@@ -264,6 +287,21 @@ class GatewayService {
return response.json(); return response.json();
} }
async searchDocument(documentId: string, query: string): Promise<SearchResult[]> {
if (!query) return [];
const urlParams = new URLSearchParams({ q: query });
const response = await fetch(`${this.baseUrl}/documents/${documentId}/search?${urlParams.toString()}`);
if (response.status === 501) {
// Return mock empty results if backend isn't available
return [];
}
if (!response.ok) throw new Error(`Failed to search document: ${response.statusText}`);
return response.json();
}
private getMockDocuments(): DocumentInfo[] { private getMockDocuments(): DocumentInfo[] {
return [ return [
{ {
@@ -271,6 +309,8 @@ class GatewayService {
filename: 'Quarterly_Financial_Report.pdf', filename: 'Quarterly_Financial_Report.pdf',
sizeBytes: 1024 * 1024 * 3.4, // 3.4MB sizeBytes: 1024 * 1024 * 3.4, // 3.4MB
totalPages: 12, totalPages: 12,
pageWidth: 612,
pageHeight: 792,
uploadedAt: new Date(Date.now() - 1000 * 60 * 60 * 3).toISOString(), uploadedAt: new Date(Date.now() - 1000 * 60 * 60 * 3).toISOString(),
status: 'ready', status: 'ready',
pages: Array.from({ length: 12 }, (_, i) => ({ index: i, width: 612, height: 792 })), pages: Array.from({ length: 12 }, (_, i) => ({ index: i, width: 612, height: 792 })),
@@ -280,6 +320,8 @@ class GatewayService {
filename: 'Engineering_Specification_v4.pdf', filename: 'Engineering_Specification_v4.pdf',
sizeBytes: 1024 * 1024 * 18.2, // 18.2MB sizeBytes: 1024 * 1024 * 18.2, // 18.2MB
totalPages: 54, totalPages: 54,
pageWidth: 612,
pageHeight: 792,
uploadedAt: new Date(Date.now() - 1000 * 60 * 60 * 24 * 2).toISOString(), uploadedAt: new Date(Date.now() - 1000 * 60 * 60 * 24 * 2).toISOString(),
status: 'ready', status: 'ready',
pages: Array.from({ length: 54 }, (_, i) => ({ index: i, width: 612, height: 792 })), pages: Array.from({ length: 54 }, (_, i) => ({ index: i, width: 612, height: 792 })),
@@ -289,6 +331,8 @@ class GatewayService {
filename: 'Tenant_Lease_Agreement_Final.pdf', filename: 'Tenant_Lease_Agreement_Final.pdf',
sizeBytes: 1024 * 245, // 245KB sizeBytes: 1024 * 245, // 245KB
totalPages: 4, totalPages: 4,
pageWidth: 612,
pageHeight: 792,
uploadedAt: new Date(Date.now() - 1000 * 60 * 45).toISOString(), uploadedAt: new Date(Date.now() - 1000 * 60 * 45).toISOString(),
status: 'ready', status: 'ready',
pages: Array.from({ length: 4 }, (_, i) => ({ index: i, width: 612, height: 792 })), pages: Array.from({ length: 4 }, (_, i) => ({ index: i, width: 612, height: 792 })),
+2 -1
View File
@@ -26,7 +26,8 @@ class WasmLoader {
try { try {
// Dynamic import from the public folder / static route // Dynamic import from the public folder / static route
// @ts-ignore // @ts-ignore
const createModule = (await import(/* @vite-ignore */ '/pdfengine.mjs')).default; const moduleUrl = (await import('/pdfengine.mjs?url')).default;
const createModule = (await import(/* @vite-ignore */ moduleUrl)).default;
const Module = await createModule({ const Module = await createModule({
locateFile: (path: string) => { locateFile: (path: string) => {
if (path.endsWith('.wasm')) { if (path.endsWith('.wasm')) {
+19 -1
View File
@@ -3,11 +3,12 @@ import type { Rect } from '../lib/coordinateMapping';
export interface Annotation { export interface Annotation {
id: string; id: string;
type: 'highlight' | 'signature' | 'strikeout' | 'comment'; type: 'highlight' | 'signature' | 'strikeout' | 'comment' | 'ink';
bbox: Rect; bbox: Rect;
color?: string; color?: string;
author: string; author: string;
content?: string; content?: string;
paths?: { x: number; y: number }[][];
} }
interface AnnotationLayerProps { interface AnnotationLayerProps {
@@ -59,6 +60,23 @@ export const AnnotationLayer: React.FC<AnnotationLayerProps> = ({
/> />
); );
})} })}
{/* Ink Annotations */}
<svg
style={{ position: 'absolute', top: 0, left: 0, width: '100%', height: '100%', pointerEvents: 'none' }}
>
{annotations
.filter((anno) => anno.type === 'ink' && anno.paths)
.map((anno) => (
<g key={anno.id} stroke={anno.color || '#3b82f6'} strokeWidth={2 * zoom} fill="none" strokeLinecap="round" strokeLinejoin="round">
{anno.paths!.map((path, i) => {
if (path.length === 0) return null;
const d = path.map((pt, j) => `${j === 0 ? 'M' : 'L'} ${pt.x * zoom} ${pt.y * zoom}`).join(' ');
return <path key={i} d={d} />;
})}
</g>
))}
</svg>
</div> </div>
); );
}; };
+88 -6
View File
@@ -1,11 +1,13 @@
import React from 'react'; import React, { useState, useRef } from 'react';
import type { Annotation } from './AnnotationLayer';
interface OverlayLayerProps { interface OverlayLayerProps {
pageIndex: number; pageIndex: number;
width: number; width: number;
height: number; height: number;
activeTool: string; activeTool: string;
onDrawStroke?: (path: string) => void; zoom: number;
onAnnotationAdded?: (anno: Annotation) => void;
} }
export const OverlayLayer: React.FC<OverlayLayerProps> = ({ export const OverlayLayer: React.FC<OverlayLayerProps> = ({
@@ -13,11 +15,70 @@ export const OverlayLayer: React.FC<OverlayLayerProps> = ({
width, width,
height, height,
activeTool, activeTool,
zoom,
onAnnotationAdded,
}) => { }) => {
const [isDrawing, setIsDrawing] = useState(false);
const [currentPath, setCurrentPath] = useState<{ x: number; y: number }[]>([]);
const svgRef = useRef<SVGSVGElement>(null);
const getCoordinates = (e: React.MouseEvent | MouseEvent) => {
if (!svgRef.current) return { x: 0, y: 0 };
const rect = svgRef.current.getBoundingClientRect();
return {
x: (e.clientX - rect.left) / zoom,
y: (e.clientY - rect.top) / zoom,
};
};
const handlePointerDown = (e: React.PointerEvent) => {
if (activeTool !== 'draw') return;
setIsDrawing(true);
e.target.setPointerCapture?.(e.pointerId);
setCurrentPath([getCoordinates(e)]);
};
const handlePointerMove = (e: React.PointerEvent) => {
if (!isDrawing || activeTool !== 'draw') return;
setCurrentPath((prev) => [...prev, getCoordinates(e)]);
};
const handlePointerUp = (e: React.PointerEvent) => {
if (!isDrawing || activeTool !== 'draw') return;
setIsDrawing(false);
e.target.releasePointerCapture?.(e.pointerId);
if (currentPath.length > 1) {
// Calculate bounding box
const xs = currentPath.map(p => p.x);
const ys = currentPath.map(p => p.y);
const minX = Math.min(...xs);
const maxX = Math.max(...xs);
const minY = Math.min(...ys);
const maxY = Math.max(...ys);
const newAnno: Annotation = {
id: `anno_${Math.random().toString(36).substring(2, 11)}`,
type: 'ink',
bbox: {
x: minX,
y: minY,
width: maxX - minX,
height: maxY - minY,
},
author: 'Current User',
paths: [currentPath],
color: '#3b82f6', // Default blue color for now
};
onAnnotationAdded?.(newAnno);
}
setCurrentPath([]);
};
return ( return (
<div <div
className="overlay-layer" className="overlay-layer"
style={{ width: `${width}px`, height: `${height}px` }} style={{ width: `${width}px`, height: `${height}px`, pointerEvents: activeTool === 'draw' ? 'auto' : 'none' }}
> >
{/* Signature overlay state indicator */} {/* Signature overlay state indicator */}
{activeTool === 'signature' && ( {activeTool === 'signature' && (
@@ -29,9 +90,30 @@ export const OverlayLayer: React.FC<OverlayLayerProps> = ({
)} )}
{activeTool === 'draw' && ( {activeTool === 'draw' && (
<div className="overlay-toast animate-pulse"> <>
Freehand Ink Pen Enabled <div className="overlay-toast animate-pulse" style={{ pointerEvents: 'none' }}>
</div> Freehand Ink Pen Enabled
</div>
<svg
ref={svgRef}
style={{ width: '100%', height: '100%', position: 'absolute', top: 0, left: 0, cursor: 'crosshair', touchAction: 'none' }}
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onPointerCancel={handlePointerUp}
>
{currentPath.length > 0 && (
<path
d={currentPath.map((pt, i) => `${i === 0 ? 'M' : 'L'} ${pt.x * zoom} ${pt.y * zoom}`).join(' ')}
stroke="#3b82f6"
strokeWidth={2 * zoom}
fill="none"
strokeLinecap="round"
strokeLinejoin="round"
/>
)}
</svg>
</>
)} )}
</div> </div>
); );
+49 -7
View File
@@ -7,17 +7,21 @@ import { OverlayLayer } from './OverlayLayer';
import { SearchOverlayLayer } from './SearchOverlayLayer'; import { SearchOverlayLayer } from './SearchOverlayLayer';
import type { Rect } from '../lib/coordinateMapping'; import type { Rect } from '../lib/coordinateMapping';
import { gatewayService } from '../lib/gatewayService'; import { gatewayService } from '../lib/gatewayService';
import type { PageInfo } from '../lib/gatewayService'; import type { SearchResult, PageInfo } from '../lib/gatewayService';
import { RedactionLayer } from './RedactionLayer'; import { RedactionLayer } from './RedactionLayer';
interface PDFViewerProps { interface PDFViewerProps {
documentId: string; documentId: string;
totalPages: number; totalPages: number;
pageWidth: number;
pageHeight: number;
zoom: number; zoom: number;
pagesInfo?: PageInfo[]; pagesInfo?: PageInfo[];
activeTool: string; activeTool: string;
annotations: Annotation[]; annotations: Annotation[];
searchQuery?: string; searchQuery?: string;
searchResults?: SearchResult[];
searchCurrentMatch?: number;
onAnnotationAdded?: (anno: Annotation) => void; onAnnotationAdded?: (anno: Annotation) => void;
onPageVisible?: (pageIndex: number) => void; onPageVisible?: (pageIndex: number) => void;
onRedactArea?: (pageIndex: number, bounds: Rect) => void; onRedactArea?: (pageIndex: number, bounds: Rect) => void;
@@ -39,11 +43,15 @@ export interface PDFViewerRef {
export const PDFViewer = React.forwardRef<PDFViewerRef, PDFViewerProps>(({ export const PDFViewer = React.forwardRef<PDFViewerRef, PDFViewerProps>(({
documentId, documentId,
totalPages, totalPages,
pageWidth,
pageHeight,
zoom, zoom,
pagesInfo, pagesInfo,
activeTool, activeTool,
annotations, annotations,
searchQuery, searchQuery,
searchResults,
searchCurrentMatch,
onAnnotationAdded, onAnnotationAdded,
onPageVisible, onPageVisible,
onRedactArea, onRedactArea,
@@ -58,9 +66,10 @@ export const PDFViewer = React.forwardRef<PDFViewerRef, PDFViewerProps>(({
setRenderedPages([]); setRenderedPages([]);
}, [documentId]); }, [documentId]);
// Standard Page Dimensions: Letter size is 612x792 pt // Use provided dimensions or fallback to Letter size
const basePageWidth = 612; const basePageWidth = pageWidth || 612;
const basePageHeight = 792; const basePageHeight = pageHeight || 792;
const pageGap = 24; // space between pages const pageGap = 24; // space between pages
// Calculate layout coordinates for all pages sequentially // Calculate layout coordinates for all pages sequentially
@@ -145,9 +154,9 @@ export const PDFViewer = React.forwardRef<PDFViewerRef, PDFViewerProps>(({
currentVisiblePageIdx = layout.index; currentVisiblePageIdx = layout.index;
} }
const isInside = (pageBottom >= viewportTop - (layout.height * buffer)) && const isInside = (pageBottom >= viewportTop - (layout.height * buffer)) &&
(pageTop <= viewportBottom + (layout.height * buffer)); (pageTop <= viewportBottom + (layout.height * buffer));
if (isInside) { if (isInside) {
visible.push(layout); visible.push(layout);
} }
@@ -195,6 +204,35 @@ export const PDFViewer = React.forwardRef<PDFViewerRef, PDFViewerProps>(({
}; };
}, [visiblePages, documentId, zoom, renderedPages]); }, [visiblePages, documentId, zoom, renderedPages]);
useEffect(() => {
const verifyPageModel = async () => {
if (visiblePages.length > 0 && documentId && !renderedPages[visiblePages[0].index + '_verified']) {
const pageIndex = visiblePages[0].index;
try {
const model = await gatewayService.getPageModel(documentId, pageIndex);
console.log(`--- Verification for Page ${pageIndex} ---`);
model.paragraphs?.forEach((p: any) => {
p.lines?.forEach((l: any) => {
l.runs?.forEach((r: any) => {
const isBold = (r.flags & 262144) !== 0 || r.font_name.toLowerCase().includes('bold');
const isItalic = (r.flags & 64) !== 0 || r.font_name.toLowerCase().includes('italic');
console.log(`Run Text: "${r.text}", Font Name: ${r.font_name}, Font Size: ${r.font_size}, Bold: ${isBold}, Italic: ${isItalic}, Embedded: ${r.is_embedded}, Type: ${r.type}`);
});
});
});
setRenderedPages((prev) => {
const next = [...prev];
next[pageIndex + '_verified' as any] = 'true';
return next;
});
} catch (e) {
// ignore or log
}
}
};
verifyPageModel();
}, [visiblePages, documentId]);
const handleTextSelection = (text: string, bbox: Rect) => { const handleTextSelection = (text: string, bbox: Rect) => {
if (activeTool === 'highlight') { if (activeTool === 'highlight') {
const newAnno: Annotation = { const newAnno: Annotation = {
@@ -318,6 +356,8 @@ export const PDFViewer = React.forwardRef<PDFViewerRef, PDFViewerProps>(({
width={page.width} width={page.width}
height={page.height} height={page.height}
activeTool={activeTool} activeTool={activeTool}
zoom={zoom}
onAnnotationAdded={onAnnotationAdded}
/> />
{/* Search highlights overlay */} {/* Search highlights overlay */}
@@ -327,6 +367,8 @@ export const PDFViewer = React.forwardRef<PDFViewerRef, PDFViewerProps>(({
height={page.height} height={page.height}
zoom={zoom} zoom={zoom}
searchQuery={searchQuery || ''} searchQuery={searchQuery || ''}
searchResults={searchResults}
searchCurrentMatch={searchCurrentMatch}
/> />
</> </>
) : ( ) : (
+34 -33
View File
@@ -1,4 +1,5 @@
import React from 'react'; import React from 'react';
import type { SearchResult } from '../lib/gatewayService';
interface SearchOverlayLayerProps { interface SearchOverlayLayerProps {
pageIndex: number; pageIndex: number;
@@ -6,6 +7,8 @@ interface SearchOverlayLayerProps {
height: number; height: number;
zoom: number; zoom: number;
searchQuery: string; searchQuery: string;
searchResults?: SearchResult[];
searchCurrentMatch?: number;
} }
export const SearchOverlayLayer: React.FC<SearchOverlayLayerProps> = ({ export const SearchOverlayLayer: React.FC<SearchOverlayLayerProps> = ({
@@ -14,47 +17,45 @@ export const SearchOverlayLayer: React.FC<SearchOverlayLayerProps> = ({
height, height,
zoom, zoom,
searchQuery, searchQuery,
searchResults = [],
searchCurrentMatch = 0,
}) => { }) => {
if (!searchQuery) return null; if (!searchQuery || searchResults.length === 0) return null;
// Mock some search results based on the query for Phase 0/1 // Filter results for this specific page and keep track of global index
// We'll just generate deterministic-looking boxes so it looks like it found something const pageMatches = searchResults
const mockResults = []; .map((result, globalIndex) => ({ ...result, globalIndex }))
const hash = searchQuery.length + pageIndex; .filter((result) => result.pageIndex === pageIndex);
if (hash % 3 !== 0) { if (pageMatches.length === 0) return null;
mockResults.push({
x: 100 * zoom,
y: (150 + hash * 10) * zoom,
width: 120 * zoom,
height: 18 * zoom,
});
}
if (hash % 2 === 0) {
mockResults.push({
x: 300 * zoom,
y: (250 + hash * 5) * zoom,
width: 80 * zoom,
height: 18 * zoom,
});
}
return ( return (
<div <div
className="absolute top-0 left-0 pointer-events-none z-20" className="absolute top-0 left-0 pointer-events-none z-20"
style={{ width: `${width}px`, height: `${height}px` }} style={{ width: `${width}px`, height: `${height}px` }}
> >
{mockResults.map((rect, idx) => ( {pageMatches.map((match) => (
<div <React.Fragment key={match.globalIndex}>
key={idx} {match.rects.map((rect, rectIdx) => {
className="absolute bg-yellow-400/40 border border-yellow-500/60 rounded-sm" const isActive = match.globalIndex === searchCurrentMatch;
style={{ return (
left: `${rect.x}px`, <div
top: `${rect.y}px`, key={rectIdx}
width: `${rect.width}px`, className={`absolute rounded-sm border ${
height: `${rect.height}px`, isActive
}} ? 'bg-orange-500/50 border-orange-600/80 shadow-[0_0_8px_rgba(249,115,22,0.6)] z-30'
/> : 'bg-yellow-400/40 border-yellow-500/60'
}`}
style={{
left: `${rect.x * zoom}px`,
top: `${rect.y * zoom}px`,
width: `${rect.w * zoom}px`,
height: `${rect.h * zoom}px`,
}}
/>
);
})}
</React.Fragment>
))} ))}
</div> </div>
); );
+227 -2
View File
@@ -18,11 +18,12 @@ class DocumentInfoResponse(BaseModel):
filename: str filename: str
sizeBytes: int sizeBytes: int
totalPages: int totalPages: int
pageWidth: float
pageHeight: float
uploadedAt: str uploadedAt: str
status: str status: str
pages: list[PageInfoResponse] = [] pages: list[PageInfoResponse] = []
def make_document_response(d: dict) -> DocumentInfoResponse: def make_document_response(d: dict) -> DocumentInfoResponse:
pages_list = [] pages_list = []
if "doc_instance" in d: if "doc_instance" in d:
@@ -38,6 +39,8 @@ def make_document_response(d: dict) -> DocumentInfoResponse:
filename=d["filename"], filename=d["filename"],
sizeBytes=d["sizeBytes"], sizeBytes=d["sizeBytes"],
totalPages=d["totalPages"], totalPages=d["totalPages"],
pageWidth=d.get("pageWidth", 612.0),
pageHeight=d.get("pageHeight", 792.0),
uploadedAt=d["uploadedAt"], uploadedAt=d["uploadedAt"],
status=d["status"], status=d["status"],
pages=pages_list, pages=pages_list,
@@ -173,7 +176,6 @@ class FontInfoResponse(BaseModel):
descent: float descent: float
capHeight: float capHeight: float
@router.get("/{document_id}/fonts", response_model=list[FontInfoResponse]) @router.get("/{document_id}/fonts", response_model=list[FontInfoResponse])
def get_document_fonts( def get_document_fonts(
document_id: str, start_page: int = 0, end_page: int = -1 document_id: str, start_page: int = 0, end_page: int = -1
@@ -221,3 +223,226 @@ def get_document_fonts(
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
except Exception as e: except Exception as e:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
class SearchRect(BaseModel):
x: float
y: float
w: float
h: float
class SearchMatch(BaseModel):
pageIndex: int
rects: list[SearchRect]
text: str
@router.get("/{document_id}/search", response_model=list[SearchMatch])
def search_document(document_id: str, q: str) -> list[SearchMatch]:
if not engine.is_available():
raise HTTPException(
status_code=status.HTTP_501_NOT_IMPLEMENTED,
detail="Engine bridge (bindings/python) not yet available."
)
if not q:
return []
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"]
matches = []
lower_query = q.lower()
query_len = len(lower_query)
for page_idx in range(doc.page_count):
page = doc.get_page(page_idx)
glyphs = page.extract_text_with_bounds()
if not glyphs:
continue
text_str = ""
char_to_glyph = []
for i, g in enumerate(glyphs):
s = g.get("text", "")
start_len = len(text_str)
text_str += s
for _ in range(len(text_str) - start_len):
char_to_glyph.append(i)
lower_text = text_str.lower()
idx = 0
while True:
idx = lower_text.find(lower_query, idx)
if idx == -1:
break
start_glyph_idx = char_to_glyph[idx]
end_glyph_idx = char_to_glyph[idx + query_len - 1]
rects = []
current_rect = None
for g_idx in range(start_glyph_idx, end_glyph_idx + 1):
g = glyphs[g_idx]
dom_y = page.height - (g["y"] + g["h"])
if current_rect is None:
current_rect = {"x": g["x"], "y": dom_y, "w": g["w"], "h": g["h"]}
else:
if abs(dom_y - current_rect["y"]) < g.get("fontSize", 12) * 0.5:
max_x = max(current_rect["x"] + current_rect["w"], g["x"] + g["w"])
current_rect["w"] = max_x - current_rect["x"]
current_rect["y"] = min(current_rect["y"], dom_y)
current_rect["h"] = max(current_rect["h"], g["h"])
else:
rects.append(SearchRect(**current_rect))
current_rect = {"x": g["x"], "y": dom_y, "w": g["w"], "h": g["h"]}
if current_rect:
rects.append(SearchRect(**current_rect))
matches.append(SearchMatch(
pageIndex=page_idx,
rects=rects,
text=text_str[idx:idx + query_len]
))
idx += 1
return matches
except Exception as e:
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(e))
class GlyphModel(BaseModel):
text: str
unicode: int
font_name: str
flags: int
font_size: float
origin_x: float
origin_y: float
bbox_x: float
bbox_y: float
bbox_w: float
bbox_h: float
angle: float
class TextRunModel(BaseModel):
text: str
font_name: str
flags: int
font_size: float
internal_font_id: str
is_embedded: bool
type: str
glyphs: list[GlyphModel]
x: float
y: float
w: float
h: float
class TextLineModel(BaseModel):
runs: list[TextRunModel]
baseline_y: float
x: float
y: float
w: float
h: float
class ParagraphModel(BaseModel):
lines: list[TextLineModel]
x: float
y: float
w: float
h: float
class PageModelResponse(BaseModel):
paragraphs: list[ParagraphModel]
width: float
height: float
page_index: int
@router.get("/{document_id}/pages/{page_index}/model", response_model=PageModelResponse)
def get_page_model(document_id: str, page_index: int) -> PageModelResponse:
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)
model = page.extract_document_model()
paragraphs = []
for p in model.paragraphs:
lines = []
for l in p.lines:
runs = []
for r in l.runs:
glyphs = []
for g in r.glyphs:
glyphs.append(GlyphModel(
text=g.text,
unicode=g.unicode,
font_name=g.font_name,
flags=g.flags,
font_size=g.font_size,
origin_x=g.origin_x,
origin_y=g.origin_y,
bbox_x=g.bbox_x,
bbox_y=g.bbox_y,
bbox_w=g.bbox_w,
bbox_h=g.bbox_h,
angle=g.angle
))
runs.append(TextRunModel(
text=r.text,
font_name=r.font_name,
flags=r.flags,
font_size=r.font_size,
internal_font_id=r.internal_font_id,
is_embedded=r.is_embedded,
type=r.type,
glyphs=glyphs,
x=r.x,
y=r.y,
w=r.w,
h=r.h
))
lines.append(TextLineModel(
runs=runs,
baseline_y=l.baseline_y,
x=l.x,
y=l.y,
w=l.w,
h=l.h
))
paragraphs.append(ParagraphModel(
lines=lines,
x=p.x,
y=p.y,
w=p.w,
h=p.h
))
return PageModelResponse(
paragraphs=paragraphs,
width=model.width,
height=model.height,
page_index=model.page_index
)
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_500_INTERNAL_SERVER_ERROR, detail=str(e))
+10 -10
View File
@@ -17,7 +17,7 @@ class TextOverlayData(BaseModel):
y: float y: float
width: float width: float
height: float height: float
fontSize: float fontSize: float = Field(..., gt=0)
fontFamily: str fontFamily: str
color: str color: str
@@ -63,7 +63,7 @@ class FreeTextData(BaseModel):
width: float width: float
height: float height: float
text: str text: str
fontSize: float = 12.0 fontSize: float = Field(12.0, gt=0)
color: str = "#000000" color: str = "#000000"
@@ -92,56 +92,56 @@ class PageRotationData(BaseModel):
class TextOverlayOperation(BaseModel): class TextOverlayOperation(BaseModel):
id: str id: str
type: Literal["text_overlay"] type: Literal["text_overlay"]
pageIndex: int pageIndex: int = Field(..., ge=0)
data: TextOverlayData data: TextOverlayData
class RedactionOperation(BaseModel): class RedactionOperation(BaseModel):
id: str id: str
type: Literal["redaction"] type: Literal["redaction"]
pageIndex: int pageIndex: int = Field(..., ge=0)
data: RedactionData data: RedactionData
class ImageOverlayOperation(BaseModel): class ImageOverlayOperation(BaseModel):
id: str id: str
type: Literal["image_overlay"] type: Literal["image_overlay"]
pageIndex: int pageIndex: int = Field(..., ge=0)
data: ImageOverlayData data: ImageOverlayData
class HighlightOperation(BaseModel): class HighlightOperation(BaseModel):
id: str id: str
type: Literal["highlight"] type: Literal["highlight"]
pageIndex: int pageIndex: int = Field(..., ge=0)
data: HighlightData data: HighlightData
class FreeTextOperation(BaseModel): class FreeTextOperation(BaseModel):
id: str id: str
type: Literal["free_text"] type: Literal["free_text"]
pageIndex: int pageIndex: int = Field(..., ge=0)
data: FreeTextData data: FreeTextData
class CommentOperation(BaseModel): class CommentOperation(BaseModel):
id: str id: str
type: Literal["comment"] type: Literal["comment"]
pageIndex: int pageIndex: int = Field(..., ge=0)
data: StickyNoteData data: StickyNoteData
class FreehandOperation(BaseModel): class FreehandOperation(BaseModel):
id: str id: str
type: Literal["freehand"] type: Literal["freehand"]
pageIndex: int pageIndex: int = Field(..., ge=0)
data: FreehandData data: FreehandData
class PageRotationOperation(BaseModel): class PageRotationOperation(BaseModel):
id: str id: str
type: Literal["page_rotation"] type: Literal["page_rotation"]
pageIndex: int pageIndex: int = Field(..., ge=0)
data: PageRotationData data: PageRotationData
+29 -3
View File
@@ -1,3 +1,5 @@
from typing import List, Annotated
from fastapi import APIRouter, HTTPException, status, Response, Path, Query
from fastapi import APIRouter, HTTPException, Response, status from fastapi import APIRouter, HTTPException, Response, status
from app.routers.documents import FontInfoResponse from app.routers.documents import FontInfoResponse
@@ -9,7 +11,7 @@ compat_router = APIRouter(tags=["render"])
@router.get("/{page_index}/render") @router.get("/{page_index}/render")
def render_page(document_id: str, page_index: int, dpi: int = 96) -> Response: def render_page(document_id: str, page_index: Annotated[int, Path(ge=0)], dpi: int = 96) -> Response:
if not engine.is_available(): if not engine.is_available():
raise HTTPException( raise HTTPException(
status_code=status.HTTP_501_NOT_IMPLEMENTED, status_code=status.HTTP_501_NOT_IMPLEMENTED,
@@ -34,7 +36,7 @@ def render_page(document_id: str, page_index: int, dpi: int = 96) -> Response:
@router.get("/{page_index}/text") @router.get("/{page_index}/text")
def extract_page_text(document_id: str, page_index: int): def extract_page_text(document_id: str, page_index: Annotated[int, Path(ge=0)]):
if not engine.is_available(): if not engine.is_available():
raise HTTPException( raise HTTPException(
status_code=status.HTTP_501_NOT_IMPLEMENTED, status_code=status.HTTP_501_NOT_IMPLEMENTED,
@@ -71,7 +73,7 @@ def render_page_compat(
@router.get("/{page_index}") @router.get("/{page_index}")
def get_page_info(document_id: str, page_index: int): def get_page_info(document_id: str, page_index: Annotated[int, Path(ge=0)]):
if not engine.is_available(): if not engine.is_available():
raise HTTPException( raise HTTPException(
status_code=status.HTTP_501_NOT_IMPLEMENTED, status_code=status.HTTP_501_NOT_IMPLEMENTED,
@@ -200,3 +202,27 @@ def get_page_fonts(document_id: str, page_index: int) -> list[FontInfoResponse]:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Page index out of range") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Page index out of range")
except Exception as e: except Exception as e:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
@router.get("/{page_index}/fonts/glyph-width")
def get_page_glyph_width(document_id: str, page_index: Annotated[int, Path(ge=0)], font_name: str, charcode: int, font_size: Annotated[float, Query(gt=0)] = 12.0):
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)
width = page.get_glyph_width(font_name, charcode, font_size)
return {"width": width}
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))
+12 -1
View File
@@ -1,6 +1,6 @@
import threading import threading
import uuid import uuid
from datetime import UTC, datetime from datetime import UTC, datetime,timezone
from typing import Any from typing import Any
@@ -12,12 +12,23 @@ class DocumentStore:
def add_document(self, filename: str, bytes_data: bytes, doc_instance: Any) -> dict[str, Any]: def add_document(self, filename: str, bytes_data: bytes, doc_instance: Any) -> dict[str, Any]:
doc_id = str(uuid.uuid4()) doc_id = str(uuid.uuid4())
uploaded_at = datetime.now(UTC).isoformat().replace("+00:00", "Z") uploaded_at = datetime.now(UTC).isoformat().replace("+00:00", "Z")
page_width = 612.0
page_height = 792.0
if doc_instance.page_count > 0:
try:
page_0 = doc_instance.get_page(0)
page_width = page_0.width
page_height = page_0.height
except Exception:
pass
info = { info = {
"id": doc_id, "id": doc_id,
"filename": filename, "filename": filename,
"sizeBytes": len(bytes_data), "sizeBytes": len(bytes_data),
"totalPages": doc_instance.page_count, "totalPages": doc_instance.page_count,
"pageWidth": page_width,
"pageHeight": page_height,
"uploadedAt": uploaded_at, "uploadedAt": uploaded_at,
"status": "ready", "status": "ready",
"doc_instance": doc_instance, "doc_instance": doc_instance,
+388
View File
@@ -0,0 +1,388 @@
"""
Font Extraction API Validation Script
======================================
Tests font extraction APIs against real PDFs from corpus/fonts/.
Validates: upload, document fonts, page fonts, text extraction, metadata accuracy.
"""
import json
import sys
import os
import time
import httpx
BASE_URL = "http://localhost:8000"
CORPUS_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", "corpus", "fonts"))
# PDFs to test (in priority order)
TARGET_PDFS = [
"utf-8.pdf",
"vertical_text.pdf",
"subset_font.pdf",
]
REQUIRED_FONT_FIELDS = [
"fontName", "type", "isEmbedded", "isSubset", "isVertical",
"encoding", "cmapName", "cidSystemInfo", "subsetTag",
"sourceType", "substitutedFrom", "substitutedTo",
"normalizedFamily", "internalFontId", "flags",
"ascent", "descent", "capHeight", "hasToUnicode",
]
def separator(title: str):
print(f"\n{'='*80}")
print(f" {title}")
print(f"{'='*80}\n")
def sub_separator(title: str):
print(f"\n--- {title} ---\n")
def validate_font_fields(font: dict, pdf_name: str, font_index: int) -> list:
"""Validate that all required fields are present and non-null."""
issues = []
for field in REQUIRED_FONT_FIELDS:
if field not in font:
issues.append(f" [MISSING] Font #{font_index} ({font.get('fontName', '?')}): field '{field}' is missing")
return issues
def check_duplicates(fonts: list) -> list:
"""Check for duplicate font entries."""
seen = set()
dupes = []
for f in fonts:
key = f.get("fontName", "") + "|" + f.get("type", "") + "|" + f.get("internalFontId", "")
if key in seen:
dupes.append(f.get("fontName", "?"))
seen.add(key)
return dupes
def check_empty_names(fonts: list) -> list:
"""Check for empty font names."""
return [i for i, f in enumerate(fonts) if not f.get("fontName", "").strip()]
def validate_pdf(client: httpx.Client, pdf_filename: str) -> dict:
"""Run all validation steps for a single PDF."""
result = {
"filename": pdf_filename,
"doc_id": None,
"upload_status": None,
"upload_response": None,
"doc_fonts": None,
"doc_font_count": 0,
"page_fonts": None,
"page_font_count": 0,
"text_result": None,
"glyph_count": 0,
"font_sizes": set(),
"issues": [],
"errors": [],
}
pdf_path = os.path.join(CORPUS_DIR, pdf_filename)
if not os.path.exists(pdf_path):
result["errors"].append(f"PDF file not found: {pdf_path}")
return result
# ========== STEP 1: Upload ==========
sub_separator(f"Step 1: Upload {pdf_filename}")
try:
with open(pdf_path, "rb") as f:
resp = client.post(
"/documents",
files={"file": (os.path.basename(pdf_path), f, "application/pdf")},
)
result["upload_status"] = resp.status_code
result["upload_response"] = resp.json()
print(f" Status: {resp.status_code}")
print(f" Response: {json.dumps(resp.json(), indent=2)}")
if resp.status_code != 201:
result["errors"].append(f"Upload failed with status {resp.status_code}: {resp.text}")
return result
result["doc_id"] = resp.json()["id"]
print(f" Document ID: {result['doc_id']}")
except Exception as e:
result["errors"].append(f"Upload exception: {e}")
return result
doc_id = result["doc_id"]
# ========== STEP 2: Document Font Extraction ==========
sub_separator("Step 2: Document Font Extraction")
try:
resp = client.get(f"/documents/{doc_id}/fonts")
print(f" Status: {resp.status_code}")
if resp.status_code != 200:
result["errors"].append(f"Document fonts failed: {resp.status_code} - {resp.text}")
else:
fonts = resp.json()
result["doc_fonts"] = fonts
result["doc_font_count"] = len(fonts)
print(f" Font count: {len(fonts)}")
# Print each font in full
for i, f in enumerate(fonts):
print(f"\n Font #{i}:")
print(f" {json.dumps(f, indent=4)}")
# Validate font count > 0
if len(fonts) == 0:
result["issues"].append("Document fonts: count is 0")
# Validate no empty names
empty = check_empty_names(fonts)
if empty:
result["issues"].append(f"Document fonts: empty font names at indices {empty}")
# Validate no duplicates
dupes = check_duplicates(fonts)
if dupes:
result["issues"].append(f"Document fonts: duplicate fonts: {dupes}")
# Validate all fields present
for i, f in enumerate(fonts):
field_issues = validate_font_fields(f, pdf_filename, i)
result["issues"].extend(field_issues)
except Exception as e:
result["errors"].append(f"Document fonts exception: {e}")
# ========== STEP 3: Page Font Extraction ==========
sub_separator("Step 3: Page Font Extraction (page 0)")
try:
resp = client.get(f"/documents/{doc_id}/pages/0/fonts")
print(f" Status: {resp.status_code}")
if resp.status_code != 200:
result["errors"].append(f"Page fonts failed: {resp.status_code} - {resp.text}")
else:
page_fonts = resp.json()
result["page_fonts"] = page_fonts
result["page_font_count"] = len(page_fonts)
print(f" Page font count: {len(page_fonts)}")
for i, f in enumerate(page_fonts):
print(f"\n Page Font #{i}:")
print(f" {json.dumps(f, indent=4)}")
if len(page_fonts) == 0:
result["issues"].append("Page fonts: count is 0")
# Compare with document fonts
if result["doc_fonts"] is not None:
doc_font_names = {f["fontName"] for f in result["doc_fonts"]}
page_font_names = {f["fontName"] for f in page_fonts}
print(f"\n Document font names: {sorted(doc_font_names)}")
print(f" Page font names: {sorted(page_font_names)}")
# Page fonts should be a subset of document fonts
extra_in_page = page_font_names - doc_font_names
if extra_in_page:
result["issues"].append(f"Page fonts not in document fonts: {extra_in_page}")
print(f" [ISSUE] Page has fonts not in document-level: {extra_in_page}")
else:
print(f" [OK] Page fonts are a subset of document fonts")
except Exception as e:
result["errors"].append(f"Page fonts exception: {e}")
# ========== STEP 4: Text Extraction ==========
sub_separator("Step 4: Text Extraction (page 0)")
try:
resp = client.get(f"/documents/{doc_id}/pages/0/text")
print(f" Status: {resp.status_code}")
if resp.status_code != 200:
result["errors"].append(f"Text extraction failed: {resp.status_code} - {resp.text}")
else:
text_data = resp.json()
result["text_result"] = text_data
text_content = text_data.get("text", "")
glyphs = text_data.get("glyphs", [])
result["glyph_count"] = len(glyphs)
print(f" Extracted text: {repr(text_content[:300])}")
print(f" Glyph count: {len(glyphs)}")
if not text_content.strip():
result["issues"].append("Text extraction: empty text")
if len(glyphs) == 0:
result["issues"].append("Text extraction: no glyphs returned")
else:
# Show first 5 glyphs as samples
print(f"\n First 5 glyphs (sample):")
for i, g in enumerate(glyphs[:5]):
print(f" Glyph #{i}: {json.dumps(g, indent=6)}")
# Check glyph structure
for i, g in enumerate(glyphs):
font_size = g.get("fontSize", 0)
if font_size > 0:
result["font_sizes"].add(font_size)
elif font_size == 0 and i < 5:
result["issues"].append(f"Glyph #{i}: fontSize is 0")
# Validate coordinate fields exist
for coord in ["x", "y", "right", "bottom"]:
if coord not in g and i < 3:
result["issues"].append(f"Glyph #{i}: missing coordinate '{coord}'")
print(f"\n Font sizes detected: {sorted(result['font_sizes'])}")
except Exception as e:
result["errors"].append(f"Text extraction exception: {e}")
# ========== STEP 5: Content-Specific Validation ==========
sub_separator("Step 5: Content-Specific Validation")
if "vertical" in pdf_filename.lower() and result["doc_fonts"]:
vertical_fonts = [f for f in result["doc_fonts"] if f.get("isVertical")]
print(f" Vertical text PDF - fonts with isVertical=true: {len(vertical_fonts)}")
if len(vertical_fonts) == 0:
result["issues"].append("vertical_text.pdf: No fonts have isVertical=true")
print(f" [ISSUE] No vertical fonts detected!")
else:
for vf in vertical_fonts:
print(f" - {vf['fontName']} (isVertical=true)")
print(f" [OK] Vertical fonts detected correctly")
if "subset" in pdf_filename.lower() and result["doc_fonts"]:
subset_fonts = [f for f in result["doc_fonts"] if f.get("isSubset")]
print(f" Subset font PDF - fonts with isSubset=true: {len(subset_fonts)}")
if len(subset_fonts) == 0:
result["issues"].append("subset_font.pdf: No fonts have isSubset=true")
print(f" [ISSUE] No subset fonts detected!")
else:
for sf in subset_fonts:
print(f" - {sf['fontName']} (isSubset=true, subsetTag='{sf.get('subsetTag', '')}')")
print(f" [OK] Subset fonts detected correctly")
if "utf" in pdf_filename.lower() and result["text_result"]:
text = result["text_result"].get("text", "")
print(f" UTF-8 PDF - extracted text: {repr(text[:300])}")
# Check for non-ASCII characters
non_ascii = [c for c in text if ord(c) > 127]
if non_ascii:
print(f" Non-ASCII characters found: {len(non_ascii)} chars")
print(f" Sample non-ASCII: {repr(''.join(non_ascii[:30]))}")
print(f" [OK] UTF-8 text extracts with non-ASCII content")
else:
print(f" [INFO] No non-ASCII characters detected - content may be ASCII-only")
return result
def main():
print("=" * 80)
print(" FONT EXTRACTION API VALIDATION")
print(f" Server: {BASE_URL}")
print(f" Corpus: {CORPUS_DIR}")
print(f" Timestamp: {time.strftime('%Y-%m-%d %H:%M:%S')}")
print("=" * 80)
client = httpx.Client(base_url=BASE_URL, timeout=30.0)
# Verify server is up
try:
r = client.get("/")
print(f"\n Server status: OK ({r.status_code})")
except Exception as e:
print(f"\n [FATAL] Cannot connect to server: {e}")
sys.exit(1)
# Verify corpus directory
if not os.path.isdir(CORPUS_DIR):
print(f"\n [FATAL] Corpus directory not found: {CORPUS_DIR}")
sys.exit(1)
available = [f for f in TARGET_PDFS if os.path.exists(os.path.join(CORPUS_DIR, f))]
print(f" PDFs to validate: {available}")
results = []
for pdf in available:
separator(f"VALIDATING: {pdf}")
result = validate_pdf(client, pdf)
results.append(result)
# ========== FINAL REPORT ==========
separator("FINAL REPORT")
for r in results:
print(f"\n{'~'*60}")
print(f" PDF: {r['filename']}")
print(f" Document ID: {r['doc_id']}")
print(f" Upload Status: {r['upload_status']}")
print(f" Document Font Count: {r['doc_font_count']}")
print(f" Page Font Count: {r['page_font_count']}")
print(f" Glyph Count: {r['glyph_count']}")
print(f" Font Sizes: {sorted(r['font_sizes']) if r['font_sizes'] else 'N/A'}")
print(f" Issues: {len(r['issues'])}")
for issue in r['issues']:
print(f" >> {issue}")
print(f" Errors: {len(r['errors'])}")
for err in r['errors']:
print(f" XX {err}")
# Summary Answers
separator("SUMMARY ANSWERS")
total_issues = sum(len(r["issues"]) for r in results)
total_errors = sum(len(r["errors"]) for r in results)
all_fonts_extracted = all(r["doc_font_count"] > 0 for r in results if not r["errors"])
print(f" 1. Are fonts being extracted correctly?")
print(f" {'YES' if all_fonts_extracted else 'NO'} - {sum(r['doc_font_count'] for r in results)} total fonts across {len(results)} PDFs")
page_doc_consistent = all(
not any("not in document" in i for i in r["issues"])
for r in results
)
print(f"\n 2. Are page fonts and document fonts consistent?")
print(f" {'YES' if page_doc_consistent else 'NO'}")
font_sizes_ok = all(r["glyph_count"] > 0 for r in results if not r["errors"])
print(f"\n 3. Are font sizes being extracted correctly?")
print(f" {'YES' if font_sizes_ok else 'NO'}")
# Check vertical/subset
vertical_ok = True
subset_ok = True
for r in results:
if "vertical" in r["filename"] and any("isVertical" in i for i in r["issues"]):
vertical_ok = False
if "subset" in r["filename"] and any("isSubset" in i for i in r["issues"]):
subset_ok = False
print(f"\n 4. Are vertical/subset fonts detected correctly?")
print(f" Vertical: {'YES' if vertical_ok else 'NO'}")
print(f" Subset: {'YES' if subset_ok else 'NO'}")
print(f"\n 5. Are there any metadata inaccuracies?")
if total_issues == 0 and total_errors == 0:
print(f" NO - All {len(results)} PDFs passed validation cleanly")
else:
print(f" YES - {total_issues} issues and {total_errors} errors found")
for r in results:
for issue in r["issues"]:
print(f" - [{r['filename']}] {issue}")
print(f"\n{'='*80}")
print(f" VALIDATION COMPLETE: {total_issues} issues, {total_errors} errors")
print(f"{'='*80}")
client.close()
return 0 if total_errors == 0 else 1
if __name__ == "__main__":
sys.exit(main())
+193 -3
View File
@@ -1,4 +1,11 @@
<<<<<<< HEAD
import re
import pytest
from fastapi.testclient import TestClient
import sys
=======
import contextlib import contextlib
>>>>>>> 2638dfe969874b954502b26656df48ad971e306d
import os import os
import sys import sys
@@ -14,9 +21,15 @@ with contextlib.suppress(ImportError):
client = TestClient(app) client = TestClient(app)
<<<<<<< HEAD
# Regex: six uppercase ASCII letters followed by '+'
SUBSET_PREFIX_RE = re.compile(r'^[A-Z]{6}\+')
=======
>>>>>>> 2638dfe969874b954502b26656df48ad971e306d
def get_doc_id(filename: str) -> str: def get_doc_id(filename: str) -> str:
# First ensure the document is loaded """Upload a PDF from corpus/fonts and return its document ID."""
filepath = os.path.abspath(f"../corpus/fonts/{filename}") filepath = os.path.abspath(f"../corpus/fonts/{filename}")
with open(filepath, "rb") as f: with open(filepath, "rb") as f:
resp = client.post("/documents", files={"file": (filename, f, "application/pdf")}) resp = client.post("/documents", files={"file": (filename, f, "application/pdf")})
@@ -24,8 +37,15 @@ def get_doc_id(filename: str) -> str:
return resp.json()["id"] return resp.json()["id"]
<<<<<<< HEAD
# =========================================================================
# 1. Vertical font regression
# =========================================================================
=======
>>>>>>> 2638dfe969874b954502b26656df48ad971e306d
def test_is_vertical_regression(): def test_is_vertical_regression():
# 1. Verify Identity-V fonts are detected correctly """Identity-V fonts must be flagged isVertical; horizontal fonts must not."""
doc_id = get_doc_id("vertical_text.pdf") doc_id = get_doc_id("vertical_text.pdf")
resp = client.get(f"/documents/{doc_id}/fonts") resp = client.get(f"/documents/{doc_id}/fonts")
assert resp.status_code == 200 assert resp.status_code == 200
@@ -35,7 +55,10 @@ def test_is_vertical_regression():
assert font["isVertical"] is True assert font["isVertical"] is True
assert font["encoding"] == "Identity-V" assert font["encoding"] == "Identity-V"
<<<<<<< HEAD
=======
# 2. Verify horizontal fonts are not falsely detected as vertical # 2. Verify horizontal fonts are not falsely detected as vertical
>>>>>>> 2638dfe969874b954502b26656df48ad971e306d
doc_id_h = get_doc_id("utf-8.pdf") doc_id_h = get_doc_id("utf-8.pdf")
resp_h = client.get(f"/documents/{doc_id_h}/fonts") resp_h = client.get(f"/documents/{doc_id_h}/fonts")
fonts_h = resp_h.json() fonts_h = resp_h.json()
@@ -44,23 +67,134 @@ def test_is_vertical_regression():
assert f["isVertical"] is False assert f["isVertical"] is False
<<<<<<< HEAD
# =========================================================================
# 2. Internal Font ID: no duplicate subset prefix (core regression)
# =========================================================================
def test_internal_font_id_no_duplicate_prefix():
"""
Regression: subset fonts must NOT produce "ABCDEF_ABCDEF+Arial".
The internalFontId for a subset font should be the fontName itself
(e.g. "ABCDEF+Arial"), which already encodes the subset tag.
"""
doc_id = get_doc_id("subset_font.pdf")
resp = client.get(f"/documents/{doc_id}/fonts")
assert resp.status_code == 200
fonts = resp.json()
assert len(fonts) > 0
for font in fonts:
fid = font["internalFontId"]
tag = font.get("subsetTag", "")
if font.get("isSubset") and tag:
# Must NOT start with "TAG_TAG"
assert not fid.startswith(tag + "_" + tag), (
f"Duplicate subset prefix detected: internalFontId='{fid}'"
)
# Must equal fontName directly (e.g. "ABCDEF+Arial")
assert fid == font["fontName"], (
f"Expected internalFontId==fontName for subset font, "
f"got '{fid}' vs '{font['fontName']}'"
)
def test_internal_font_id_subset_format():
"""
For any subset font the internalFontId must match the pattern
ABCDEF+BaseName — exactly the fontName reported by PDFium.
"""
=======
def test_internal_font_id_regression(): def test_internal_font_id_regression():
# Verify subset fonts do not duplicate subset prefixes # Verify subset fonts do not duplicate subset prefixes
>>>>>>> 2638dfe969874b954502b26656df48ad971e306d
doc_id = get_doc_id("text_font.pdf") doc_id = get_doc_id("text_font.pdf")
resp = client.get(f"/documents/{doc_id}/fonts") resp = client.get(f"/documents/{doc_id}/fonts")
assert resp.status_code == 200 assert resp.status_code == 200
fonts = resp.json() fonts = resp.json()
assert len(fonts) > 0 assert len(fonts) > 0
for font in fonts: for font in fonts:
if font.get("isSubset"): if font.get("isSubset"):
assert font["subsetTag"] in font["fontName"] assert font["subsetTag"] in font["fontName"]
<<<<<<< HEAD
# internalFontId == fontName (e.g. "ABCDEF+Arial")
assert font["internalFontId"] == font["fontName"]
# The ID must contain exactly one '+' from the subset tag
assert font["internalFontId"].count("+") == 1
# =========================================================================
# 3. Non-subset font ID format
# =========================================================================
def test_internal_font_id_non_subset_format():
"""
Non-subset fonts must have internalFontId = fontName_type_flags.
Examples: "Helvetica_Type1_32", "Times-Roman_Type1_32".
"""
doc_id = get_doc_id("utf-8.pdf")
resp = client.get(f"/documents/{doc_id}/fonts")
assert resp.status_code == 200
fonts = resp.json()
assert len(fonts) > 0
for font in fonts:
if not font.get("isSubset"):
expected = f"{font['fontName']}_{font['type']}_{font['flags']}"
assert font["internalFontId"] == expected, (
f"Non-subset internalFontId mismatch: "
f"got '{font['internalFontId']}', expected '{expected}'"
)
# Must NOT contain a '+' (no subset prefix)
assert "+" not in font["internalFontId"]
# =========================================================================
# 4. ID stability across document-level and page-level APIs
# =========================================================================
def test_font_id_stable_across_apis():
"""
The internalFontId for the same font must be identical whether queried
from the document-level /fonts endpoint or the page-level /pages/0/fonts.
"""
for pdf in ("subset_font.pdf", "utf-8.pdf", "vertical_text.pdf"):
filepath = os.path.abspath(f"../corpus/fonts/{pdf}")
if not os.path.exists(filepath):
continue
doc_id = get_doc_id(pdf)
doc_resp = client.get(f"/documents/{doc_id}/fonts")
page_resp = client.get(f"/documents/{doc_id}/pages/0/fonts")
assert doc_resp.status_code == 200
assert page_resp.status_code == 200
doc_ids = {f["fontName"]: f["internalFontId"] for f in doc_resp.json()}
page_ids = {f["fontName"]: f["internalFontId"] for f in page_resp.json()}
for name in page_ids:
assert name in doc_ids, f"Page font '{name}' not in doc fonts for {pdf}"
assert page_ids[name] == doc_ids[name], (
f"ID mismatch for '{name}' in {pdf}: "
f"doc='{doc_ids[name]}' vs page='{page_ids[name]}'"
)
# =========================================================================
# 5. CID collection regression
# =========================================================================
=======
assert not font["internalFontId"].startswith( assert not font["internalFontId"].startswith(
font["subsetTag"] + "_" + font["subsetTag"] font["subsetTag"] + "_" + font["subsetTag"]
) )
>>>>>>> 2638dfe969874b954502b26656df48ad971e306d
def test_cid_collection_regression(): def test_cid_collection_regression():
# Verify Adobe collections """Adobe CID collections must use the 'Adobe-' prefix."""
doc_id = get_doc_id("vertical_text.pdf") doc_id = get_doc_id("vertical_text.pdf")
resp = client.get(f"/documents/{doc_id}/fonts") resp = client.get(f"/documents/{doc_id}/fonts")
fonts = resp.json() fonts = resp.json()
@@ -70,6 +204,31 @@ def test_cid_collection_regression():
assert "Adobe-" in font["cidSystemInfo"] assert "Adobe-" in font["cidSystemInfo"]
<<<<<<< HEAD
def test_cns1_regression():
"""Verify Adobe-CNS1 (Traditional Chinese) CID fonts and text extraction."""
doc_id = get_doc_id("cns1_test.pdf")
# 1. Verify font extraction
resp = client.get(f"/documents/{doc_id}/fonts")
assert resp.status_code == 200
fonts = resp.json()
assert len(fonts) > 0
cns1_fonts = [f for f in fonts if f.get("cidSystemInfo") == "Adobe-CNS1"]
assert len(cns1_fonts) > 0, "No Adobe-CNS1 fonts detected"
# 2. Verify text extraction
resp = client.get(f"/documents/{doc_id}/pages/0/text")
assert resp.status_code == 200
text = resp.json()["text"]
assert "\u4e00\u4e2d\u4ed7" in text, "Failed to extract Traditional Chinese text"
# =========================================================================
# 6. UTF-8 corpus regression
# =========================================================================
=======
>>>>>>> 2638dfe969874b954502b26656df48ad971e306d
def test_utf8_corpus_regression(): def test_utf8_corpus_regression():
doc_id = get_doc_id("utf-8.pdf") doc_id = get_doc_id("utf-8.pdf")
resp = client.get(f"/documents/{doc_id}/pages/0/text") resp = client.get(f"/documents/{doc_id}/pages/0/text")
@@ -79,9 +238,40 @@ def test_utf8_corpus_regression():
assert len(data["text"]) > 0 assert len(data["text"]) > 0
<<<<<<< HEAD
# =========================================================================
# 7. Font size regression
# =========================================================================
=======
>>>>>>> 2638dfe969874b954502b26656df48ad971e306d
def test_font_size_regression(): def test_font_size_regression():
doc_id = get_doc_id("utf-8.pdf") doc_id = get_doc_id("utf-8.pdf")
resp = client.get(f"/documents/{doc_id}/pages/0/text") resp = client.get(f"/documents/{doc_id}/pages/0/text")
data = resp.json() data = resp.json()
sizes = set(g["fontSize"] for g in data["glyphs"]) sizes = set(g["fontSize"] for g in data["glyphs"])
assert len(sizes) >= 2 # utf-8.pdf should have multiple font sizes assert len(sizes) >= 2 # utf-8.pdf should have multiple font sizes
<<<<<<< HEAD
# =========================================================================
# 8. ID uniqueness
# =========================================================================
def test_font_ids_unique_within_document():
"""Each font within a document must have a distinct internalFontId."""
for pdf in ("subset_font.pdf", "utf-8.pdf", "vertical_text.pdf"):
filepath = os.path.abspath(f"../corpus/fonts/{pdf}")
if not os.path.exists(filepath):
continue
doc_id = get_doc_id(pdf)
resp = client.get(f"/documents/{doc_id}/fonts")
fonts = resp.json()
ids = [f["internalFontId"] for f in fonts]
assert len(ids) == len(set(ids)), (
f"Duplicate internalFontId values in {pdf}: {ids}"
)
=======
>>>>>>> 2638dfe969874b954502b26656df48ad971e306d
+93
View File
@@ -0,0 +1,93 @@
import pytest
import os
from fastapi.testclient import TestClient
from app.main import app
client = TestClient(app)
def get_doc_id(filename: str) -> str:
"""Upload a PDF from corpus/fonts and return its document ID."""
filepath = os.path.abspath(f"gateway/../corpus/fonts/{filename}")
if not os.path.exists(filepath):
filepath = os.path.abspath(f"../corpus/fonts/{filename}")
with open(filepath, "rb") as f:
resp = client.post(
"/documents",
files={"file": (filename, f, "application/pdf")}
)
assert resp.status_code == 201, f"Failed to load {filename}: {resp.json()}"
return resp.json()["id"]
def test_glyph_width_proportionality():
"""Verify that wide characters return larger glyph widths than narrow characters."""
doc_id = get_doc_id("utf-8.pdf")
# 1. Fetch fonts for the page
resp = client.get(f"/documents/{doc_id}/pages/0/fonts")
assert resp.status_code == 200
fonts = resp.json()
assert len(fonts) > 0, "No fonts extracted from utf-8.pdf"
# Find an embedded or substituted font name
font_name = fonts[0]["fontName"]
# 2. Get width of wide character 'W' (charcode 87)
w_resp = client.get(
f"/documents/{doc_id}/pages/0/fonts/glyph-width",
params={"font_name": font_name, "charcode": 87, "font_size": 12.0}
)
assert w_resp.status_code == 200
w_width = w_resp.json()["width"]
assert w_width > 0.0
# 3. Get width of narrow character 'i' (charcode 105)
i_resp = client.get(
f"/documents/{doc_id}/pages/0/fonts/glyph-width",
params={"font_name": font_name, "charcode": 105, "font_size": 12.0}
)
assert i_resp.status_code == 200
i_width = i_resp.json()["width"]
assert i_width > 0.0
# 'W' must be strictly wider than 'i' in proportional typefaces
assert w_width > i_width, f"Expected width('W') > width('i'), got {w_width} vs {i_width}"
def test_glyph_width_font_size_scaling():
"""Verify that glyph width scales proportionally with font size."""
doc_id = get_doc_id("utf-8.pdf")
resp = client.get(f"/documents/{doc_id}/pages/0/fonts")
fonts = resp.json()
font_name = fonts[0]["fontName"]
# Width at 12pt
resp_12 = client.get(
f"/documents/{doc_id}/pages/0/fonts/glyph-width",
params={"font_name": font_name, "charcode": 65, "font_size": 12.0}
)
assert resp_12.status_code == 200
width_12 = resp_12.json()["width"]
# Width at 24pt
resp_24 = client.get(
f"/documents/{doc_id}/pages/0/fonts/glyph-width",
params={"font_name": font_name, "charcode": 65, "font_size": 24.0}
)
assert resp_24.status_code == 200
width_24 = resp_24.json()["width"]
# Scaling must be linear: width(24pt) = 2.0 * width(12pt)
assert pytest.approx(width_24) == 2.0 * width_12
def test_glyph_width_invalid_font():
"""Verify that querying an invalid font returns a bad request error."""
doc_id = get_doc_id("utf-8.pdf")
resp = client.get(
f"/documents/{doc_id}/pages/0/fonts/glyph-width",
params={"font_name": "NonExistentFontName123", "charcode": 65, "font_size": 12.0}
)
assert resp.status_code == 400
assert "detail" in resp.json()
+5 -1
View File
@@ -137,6 +137,10 @@ def test_extract_page_text(client: TestClient):
first_glyph = glyphs[0] first_glyph = glyphs[0]
for key in ["text", "x", "y", "w", "h", "fontSize"]: for key in ["text", "x", "y", "w", "h", "fontSize"]:
assert key in first_glyph assert key in first_glyph
for g in glyphs:
assert g["fontSize"] != 1.0, f"Fake fontSize 1.0 detected for glyph: {g}"
assert g["text"] not in ["\r", "\n"], f"Control character detected in glyph bounds: {g}"
def test_apply_edits_and_incremental_save(client: TestClient): def test_apply_edits_and_incremental_save(client: TestClient):
@@ -481,7 +485,7 @@ def test_font_size_and_diagnostics_advanced(client: TestClient):
assert font["sourceType"] == "Embedded" assert font["sourceType"] == "Embedded"
assert len(font["subsetTag"]) == 6 assert len(font["subsetTag"]) == 6
assert font["subsetTag"].isupper() assert font["subsetTag"].isupper()
assert font["internalFontId"] == f"{font['subsetTag']}_{font['fontName']}" assert font["internalFontId"] == font["fontName"]
else: else:
assert len(font["subsetTag"]) == 0 assert len(font["subsetTag"]) == 0
assert font["internalFontId"] == f"{font['fontName']}_{font['type']}_{font['flags']}" assert font["internalFontId"] == f"{font['fontName']}_{font['type']}_{font['flags']}"
+554
View File
@@ -0,0 +1,554 @@
================================================================================
FONT EXTRACTION API VALIDATION
Server: http://localhost:8000
Corpus: C:\Users\Maskan\Desktop\pdf_editor\pdf\corpus\fonts
Timestamp: 2026-06-02 12:36:47
================================================================================
Server status: OK (200)
PDFs to validate: ['utf-8.pdf', 'vertical_text.pdf', 'subset_font.pdf']
================================================================================
VALIDATING: utf-8.pdf
================================================================================
--- Step 1: Upload utf-8.pdf ---
Status: 201
Response: {
"id": "6764e35e-3594-4b50-a378-3ea122a54d9d",
"filename": "utf-8.pdf",
"sizeBytes": 1275,
"totalPages": 1,
"uploadedAt": "2026-06-02T07:06:50.429369Z",
"status": "ready"
}
Document ID: 6764e35e-3594-4b50-a378-3ea122a54d9d
--- Step 2: Document Font Extraction ---
Status: 200
Font count: 2
Font #0:
{
"fontName": "Helvetica",
"type": "Type1",
"isEmbedded": false,
"isSubset": false,
"isVertical": false,
"encoding": "WinAnsiEncoding",
"hasToUnicode": true,
"cmapName": "None",
"cidSystemInfo": "None",
"subsetTag": "",
"sourceType": "SystemFallback",
"substitutedFrom": "",
"substitutedTo": "",
"normalizedFamily": "Helvetica",
"internalFontId": "Helvetica_Type1_32",
"flags": 32,
"ascent": 905.0,
"descent": -211.0,
"capHeight": 728.0
}
Font #1:
{
"fontName": "Times-Roman",
"type": "Type1",
"isEmbedded": false,
"isSubset": false,
"isVertical": false,
"encoding": "WinAnsiEncoding",
"hasToUnicode": true,
"cmapName": "None",
"cidSystemInfo": "None",
"subsetTag": "",
"sourceType": "SystemFallback",
"substitutedFrom": "",
"substitutedTo": "",
"normalizedFamily": "Times",
"internalFontId": "Times-Roman_Type1_32",
"flags": 32,
"ascent": 891.0,
"descent": -216.0,
"capHeight": 662.0
}
--- Step 3: Page Font Extraction (page 0) ---
Status: 200
Page font count: 2
Page Font #0:
{
"fontName": "Helvetica",
"type": "Type1",
"isEmbedded": false,
"isSubset": false,
"isVertical": false,
"encoding": "WinAnsiEncoding",
"hasToUnicode": true,
"cmapName": "None",
"cidSystemInfo": "None",
"subsetTag": "",
"sourceType": "SystemFallback",
"substitutedFrom": "",
"substitutedTo": "",
"normalizedFamily": "Helvetica",
"internalFontId": "Helvetica_Type1_32",
"flags": 32,
"ascent": 905.0,
"descent": -211.0,
"capHeight": 728.0
}
Page Font #1:
{
"fontName": "Times-Roman",
"type": "Type1",
"isEmbedded": false,
"isSubset": false,
"isVertical": false,
"encoding": "WinAnsiEncoding",
"hasToUnicode": true,
"cmapName": "None",
"cidSystemInfo": "None",
"subsetTag": "",
"sourceType": "SystemFallback",
"substitutedFrom": "",
"substitutedTo": "",
"normalizedFamily": "Times",
"internalFontId": "Times-Roman_Type1_32",
"flags": 32,
"ascent": 891.0,
"descent": -216.0,
"capHeight": 662.0
}
Document font names: ['Helvetica', 'Times-Roman']
Page font names: ['Helvetica', 'Times-Roman']
[OK] Page fonts are a subset of document fonts
--- Step 4: Text Extraction (page 0) ---
Status: 200
Extracted text: 'Hello World - UTF-8 Test Document\r\nStandard Latin Text for Encoding Verification\r\nFont Size Detection Sample: Small Text 12pt\r\nLARGE TEXT FOR SIZE 18PT DETECTION\r\nMore 18pt content: ABCDEFGHabcdefgh 0123456789\r\nBack to 12pt: The quick brown fox jumps over the lazy dog\r\nSpecial chars: copyright secti'
Glyph count: 312
First 5 glyphs (sample):
Glyph #0: {
"text": "H",
"x": 72.95999908447266,
"y": 720.0,
"w": 6.7440032958984375,
"h": 8.59197998046875,
"fontSize": 12.0
}
Glyph #1: {
"text": "e",
"x": 81.10800170898438,
"y": 719.8679809570312,
"w": 5.736000061035156,
"h": 6.49200439453125,
"fontSize": 12.0
}
Glyph #2: {
"text": "l",
"x": 88.10400390625,
"y": 720.0,
"w": 1.055999755859375,
"h": 8.59197998046875,
"fontSize": 12.0
}
Glyph #3: {
"text": "l",
"x": 90.76799774169922,
"y": 720.0,
"w": 1.055999755859375,
"h": 8.59197998046875,
"fontSize": 12.0
}
Glyph #4: {
"text": "o",
"x": 93.05999755859375,
"y": 719.8679809570312,
"w": 5.832000732421875,
"h": 6.49200439453125,
"fontSize": 12.0
}
Font sizes detected: [1.0, 12.0, 18.0]
--- Step 5: Content-Specific Validation ---
UTF-8 PDF - extracted text: 'Hello World - UTF-8 Test Document\r\nStandard Latin Text for Encoding Verification\r\nFont Size Detection Sample: Small Text 12pt\r\nLARGE TEXT FOR SIZE 18PT DETECTION\r\nMore 18pt content: ABCDEFGHabcdefgh 0123456789\r\nBack to 12pt: The quick brown fox jumps over the lazy dog\r\nSpecial chars: copyright secti'
[INFO] No non-ASCII characters detected - content may be ASCII-only
================================================================================
VALIDATING: vertical_text.pdf
================================================================================
--- Step 1: Upload vertical_text.pdf ---
Status: 201
Response: {
"id": "d28fd4e4-a059-4a9b-bf15-b6cf25caf280",
"filename": "vertical_text.pdf",
"sizeBytes": 3518,
"totalPages": 1,
"uploadedAt": "2026-06-02T07:06:50.465835Z",
"status": "ready"
}
Document ID: d28fd4e4-a059-4a9b-bf15-b6cf25caf280
--- Step 2: Document Font Extraction ---
Status: 200
Font count: 1
Font #0:
{
"fontName": "Test",
"type": "TrueType",
"isEmbedded": false,
"isSubset": false,
"isVertical": true,
"encoding": "Identity-V",
"hasToUnicode": true,
"cmapName": "Identity-V",
"cidSystemInfo": "None",
"subsetTag": "",
"sourceType": "Substituted",
"substitutedFrom": "Test",
"substitutedTo": "Arial",
"normalizedFamily": "Test",
"internalFontId": "Test_TrueType_524320",
"flags": 524320,
"ascent": 905.0,
"descent": -211.0,
"capHeight": 728.0
}
--- Step 3: Page Font Extraction (page 0) ---
Status: 200
Page font count: 1
Page Font #0:
{
"fontName": "Test",
"type": "TrueType",
"isEmbedded": false,
"isSubset": false,
"isVertical": true,
"encoding": "Identity-V",
"hasToUnicode": true,
"cmapName": "Identity-V",
"cidSystemInfo": "None",
"subsetTag": "",
"sourceType": "Substituted",
"substitutedFrom": "Test",
"substitutedTo": "Arial",
"normalizedFamily": "Test",
"internalFontId": "Test_TrueType_524320",
"flags": 524320,
"ascent": 905.0,
"descent": -211.0,
"capHeight": 728.0
}
Document font names: ['Test']
Page font names: ['Test']
[OK] Page fonts are a subset of document fonts
--- Step 4: Text Extraction (page 0) ---
Status: 200
Extracted text: 'Hello World!\r\nHello'
Glyph count: 19
First 5 glyphs (sample):
Glyph #0: {
"text": "H",
"x": 6.832000255584717,
"y": 180.1840057373047,
"w": 6.552000522613525,
"h": 8.699996948242188,
"fontSize": 12.0
}
Glyph #1: {
"text": "e",
"x": 7.324000358581543,
"y": 171.39999389648438,
"w": 5.495999336242676,
"h": 6.756011962890625,
"fontSize": 12.0
}
Glyph #2: {
"text": "l",
"x": 9.687999725341797,
"y": 160.49200439453125,
"w": 1.055999755859375,
"h": 9.251998901367188,
"fontSize": 12.0
}
Glyph #3: {
"text": "l",
"x": 9.687999725341797,
"y": 149.4759979248047,
"w": 1.055999755859375,
"h": 9.251998901367188,
"fontSize": 12.0
}
Glyph #4: {
"text": "o",
"x": 7.324000358581543,
"y": 140.69200134277344,
"w": 5.951999664306641,
"h": 6.7559967041015625,
"fontSize": 12.0
}
Font sizes detected: [1.0, 12.0]
--- Step 5: Content-Specific Validation ---
Vertical text PDF - fonts with isVertical=true: 1
- Test (isVertical=true)
[OK] Vertical fonts detected correctly
================================================================================
VALIDATING: subset_font.pdf
================================================================================
--- Step 1: Upload subset_font.pdf ---
Status: 201
Response: {
"id": "cf3f2218-6ee7-4e34-973d-6e1b61c13cf8",
"filename": "subset_font.pdf",
"sizeBytes": 646,
"totalPages": 1,
"uploadedAt": "2026-06-02T07:06:50.497336Z",
"status": "ready"
}
Document ID: cf3f2218-6ee7-4e34-973d-6e1b61c13cf8
--- Step 2: Document Font Extraction ---
Status: 200
Font count: 1
Font #0:
{
"fontName": "ABCDEF+Arial",
"type": "TrueType",
"isEmbedded": true,
"isSubset": true,
"isVertical": false,
"encoding": "WinAnsiEncoding",
"hasToUnicode": true,
"cmapName": "None",
"cidSystemInfo": "None",
"subsetTag": "ABCDEF",
"sourceType": "Embedded",
"substitutedFrom": "",
"substitutedTo": "",
"normalizedFamily": "Arial",
"internalFontId": "ABCDEF_ABCDEF+Arial",
"flags": 0,
"ascent": 905.0,
"descent": -211.0,
"capHeight": 728.0
}
--- Step 3: Page Font Extraction (page 0) ---
Status: 200
Page font count: 1
Page Font #0:
{
"fontName": "ABCDEF+Arial",
"type": "TrueType",
"isEmbedded": true,
"isSubset": true,
"isVertical": false,
"encoding": "WinAnsiEncoding",
"hasToUnicode": true,
"cmapName": "None",
"cidSystemInfo": "None",
"subsetTag": "ABCDEF",
"sourceType": "Embedded",
"substitutedFrom": "",
"substitutedTo": "",
"normalizedFamily": "Arial",
"internalFontId": "ABCDEF_ABCDEF+Arial",
"flags": 0,
"ascent": 905.0,
"descent": -211.0,
"capHeight": 728.0
}
Document font names: ['ABCDEF+Arial']
Page font names: ['ABCDEF+Arial']
[OK] Page fonts are a subset of document fonts
--- Step 4: Text Extraction (page 0) ---
Status: 200
Extracted text: 'Subset Text'
Glyph count: 11
First 5 glyphs (sample):
Glyph #0: {
"text": "S",
"x": 72.54000091552734,
"y": 719.8679809570312,
"w": 6.839996337890625,
"h": 8.8680419921875,
"fontSize": 12.0
}
Glyph #1: {
"text": "u",
"x": 80.77200317382812,
"y": 719.8679809570312,
"w": 5.0399932861328125,
"h": 6.36004638671875,
"fontSize": 12.0
}
Glyph #2: {
"text": "b",
"x": 87.45600128173828,
"y": 719.8679809570312,
"w": 5.400001525878906,
"h": 8.7239990234375,
"fontSize": 12.0
}
Glyph #3: {
"text": "s",
"x": 93.72000122070312,
"y": 719.8679809570312,
"w": 5.159996032714844,
"h": 6.49200439453125,
"fontSize": 12.0
}
Glyph #4: {
"text": "e",
"x": 99.79199981689453,
"y": 719.8679809570312,
"w": 5.736000061035156,
"h": 6.49200439453125,
"fontSize": 12.0
}
Font sizes detected: [12.0]
--- Step 5: Content-Specific Validation ---
Subset font PDF - fonts with isSubset=true: 1
- ABCDEF+Arial (isSubset=true, subsetTag='ABCDEF')
[OK] Subset fonts detected correctly
================================================================================
FINAL REPORT
================================================================================
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
PDF: utf-8.pdf
Document ID: 6764e35e-3594-4b50-a378-3ea122a54d9d
Upload Status: 201
Document Font Count: 2
Page Font Count: 2
Glyph Count: 312
Font Sizes: [1.0, 12.0, 18.0]
Issues: 6
>> Glyph #0: missing coordinate 'right'
>> Glyph #0: missing coordinate 'bottom'
>> Glyph #1: missing coordinate 'right'
>> Glyph #1: missing coordinate 'bottom'
>> Glyph #2: missing coordinate 'right'
>> Glyph #2: missing coordinate 'bottom'
Errors: 0
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
PDF: vertical_text.pdf
Document ID: d28fd4e4-a059-4a9b-bf15-b6cf25caf280
Upload Status: 201
Document Font Count: 1
Page Font Count: 1
Glyph Count: 19
Font Sizes: [1.0, 12.0]
Issues: 6
>> Glyph #0: missing coordinate 'right'
>> Glyph #0: missing coordinate 'bottom'
>> Glyph #1: missing coordinate 'right'
>> Glyph #1: missing coordinate 'bottom'
>> Glyph #2: missing coordinate 'right'
>> Glyph #2: missing coordinate 'bottom'
Errors: 0
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
PDF: subset_font.pdf
Document ID: cf3f2218-6ee7-4e34-973d-6e1b61c13cf8
Upload Status: 201
Document Font Count: 1
Page Font Count: 1
Glyph Count: 11
Font Sizes: [12.0]
Issues: 6
>> Glyph #0: missing coordinate 'right'
>> Glyph #0: missing coordinate 'bottom'
>> Glyph #1: missing coordinate 'right'
>> Glyph #1: missing coordinate 'bottom'
>> Glyph #2: missing coordinate 'right'
>> Glyph #2: missing coordinate 'bottom'
Errors: 0
================================================================================
SUMMARY ANSWERS
================================================================================
1. Are fonts being extracted correctly?
YES - 4 total fonts across 3 PDFs
2. Are page fonts and document fonts consistent?
YES
3. Are font sizes being extracted correctly?
YES
4. Are vertical/subset fonts detected correctly?
Vertical: YES
Subset: YES
5. Are there any metadata inaccuracies?
YES - 18 issues and 0 errors found
- [utf-8.pdf] Glyph #0: missing coordinate 'right'
- [utf-8.pdf] Glyph #0: missing coordinate 'bottom'
- [utf-8.pdf] Glyph #1: missing coordinate 'right'
- [utf-8.pdf] Glyph #1: missing coordinate 'bottom'
- [utf-8.pdf] Glyph #2: missing coordinate 'right'
- [utf-8.pdf] Glyph #2: missing coordinate 'bottom'
- [vertical_text.pdf] Glyph #0: missing coordinate 'right'
- [vertical_text.pdf] Glyph #0: missing coordinate 'bottom'
- [vertical_text.pdf] Glyph #1: missing coordinate 'right'
- [vertical_text.pdf] Glyph #1: missing coordinate 'bottom'
- [vertical_text.pdf] Glyph #2: missing coordinate 'right'
- [vertical_text.pdf] Glyph #2: missing coordinate 'bottom'
- [subset_font.pdf] Glyph #0: missing coordinate 'right'
- [subset_font.pdf] Glyph #0: missing coordinate 'bottom'
- [subset_font.pdf] Glyph #1: missing coordinate 'right'
- [subset_font.pdf] Glyph #1: missing coordinate 'bottom'
- [subset_font.pdf] Glyph #2: missing coordinate 'right'
- [subset_font.pdf] Glyph #2: missing coordinate 'bottom'
================================================================================
VALIDATION COMPLETE: 18 issues, 0 errors
================================================================================
BIN
View File
Binary file not shown.
+10 -9
View File
@@ -43,19 +43,20 @@ if (-not $vcpkgRoot) {
Write-Host "VCPKG_ROOT defaulted to: $vcpkgRoot" -ForegroundColor Yellow Write-Host "VCPKG_ROOT defaulted to: $vcpkgRoot" -ForegroundColor Yellow
} }
$vcvars = "C:\Program Files (x86)\Microsoft Visual Studio\18\BuildTools\VC\Auxiliary\Build\vcvars64.bat" $vswhere = Join-Path ${env:ProgramFiles(x86)} 'Microsoft Visual Studio\Installer\vswhere.exe'
if (-not (Test-Path $vcvars)) { if (Test-Path $vswhere) {
$vsPath = (& $vswhere -latest -prerelease -products * -property installationPath | Select-Object -First 1)
if ($vsPath) {
$vcvars = Join-Path $vsPath "VC\Auxiliary\Build\vcvars64.bat"
}
}
if (-not $vcvars -or -not (Test-Path $vcvars)) {
$vcvars = "C:\Program Files\Microsoft Visual Studio\2022\Community\VC\Auxiliary\Build\vcvars64.bat" $vcvars = "C:\Program Files\Microsoft Visual Studio\2022\Community\VC\Auxiliary\Build\vcvars64.bat"
} }
if (-not (Test-Path $vcvars)) { if (-not (Test-Path $vcvars)) {
$vswhere = Join-Path ${env:ProgramFiles(x86)} 'Microsoft Visual Studio\Installer\vswhere.exe' $vcvars = "C:\Program Files\Microsoft Visual Studio\18\Community\VC\Auxiliary\Build\vcvars64.bat"
if (Test-Path $vswhere) {
$vsPath = (& $vswhere -latest -prerelease -products * -property installationPath | Select-Object -First 1)
if ($vsPath) {
$vcvars = Join-Path $vsPath "VC\Auxiliary\Build\vcvars64.bat"
}
}
} }
if (-not (Test-Path $vcvars)) { if (-not (Test-Path $vcvars)) {
+1 -1
View File
@@ -10,7 +10,7 @@ if ($Port -le 0) {
if ($envPort) { if ($envPort) {
$Port = $envPort $Port = $envPort
} else { } else {
$Port = 8080 $Port = 8000
} }
} }
+5
View File
@@ -90,6 +90,11 @@ std::expected<std::vector<std::string>, pdfengine::EngineError> WasmMockPage::ex
return std::vector<std::string>(); return std::vector<std::string>();
} }
std::expected<double, pdfengine::EngineError> WasmMockPage::getGlyphWidth(const std::string& fontName, uint32_t charcode, double fontSize) const {
(void)fontName; (void)charcode;
return 6.0 * (fontSize / 12.0);
}
std::expected<std::vector<pdfengine::FontInfo>, pdfengine::EngineError> WasmMockPage::getFonts() const { std::expected<std::vector<pdfengine::FontInfo>, pdfengine::EngineError> WasmMockPage::getFonts() const {
// Simulate a non-embedded Helvetica font being substituted with Liberation Sans. // Simulate a non-embedded Helvetica font being substituted with Liberation Sans.
// This is the canonical Phase 1 font substitution scenario. // This is the canonical Phase 1 font substitution scenario.
+1
View File
@@ -21,6 +21,7 @@ public:
[[nodiscard]] std::expected<std::vector<pdfengine::GlyphBounds>, pdfengine::EngineError> extractTextWithBounds() const override; [[nodiscard]] std::expected<std::vector<pdfengine::GlyphBounds>, pdfengine::EngineError> extractTextWithBounds() const override;
[[nodiscard]] std::expected<std::vector<pdfengine::FontInfo>, pdfengine::EngineError> getFonts() const override; [[nodiscard]] std::expected<std::vector<pdfengine::FontInfo>, pdfengine::EngineError> getFonts() const override;
[[nodiscard]] std::expected<std::vector<std::string>, pdfengine::EngineError> extractAnnotationsText() const override; [[nodiscard]] std::expected<std::vector<std::string>, pdfengine::EngineError> extractAnnotationsText() const override;
[[nodiscard]] std::expected<double, pdfengine::EngineError> getGlyphWidth(const std::string& fontName, uint32_t charcode, double fontSize) const override;
[[nodiscard]] pdfengine::DevicePoint pageToDevice(const pdfengine::Point2D& pagePoint, int deviceWidth, int deviceHeight, int rotate = 0) const noexcept override; [[nodiscard]] pdfengine::DevicePoint pageToDevice(const pdfengine::Point2D& pagePoint, int deviceWidth, int deviceHeight, int rotate = 0) const noexcept override;
[[nodiscard]] pdfengine::Point2D deviceToPage(const pdfengine::DevicePoint& devicePoint, int deviceWidth, int deviceHeight, int rotate = 0) const noexcept override; [[nodiscard]] pdfengine::Point2D deviceToPage(const pdfengine::DevicePoint& devicePoint, int deviceWidth, int deviceHeight, int rotate = 0) const noexcept override;