Merge branch 'dev' of https://gitea.maskantech.in/gitea_admin/pdf into furqan
This commit is contained in:
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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.");
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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));
|
||||||
|
|||||||
Generated
+55
-15
@@ -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
@@ -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}
|
||||||
|
|||||||
@@ -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
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 })),
|
||||||
|
|||||||
@@ -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')) {
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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())
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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']}"
|
||||||
|
|||||||
@@ -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
|
||||||
|
================================================================================
|
||||||
Binary file not shown.
+10
-9
@@ -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)) {
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ if ($Port -le 0) {
|
|||||||
if ($envPort) {
|
if ($envPort) {
|
||||||
$Port = $envPort
|
$Port = $envPort
|
||||||
} else {
|
} else {
|
||||||
$Port = 8080
|
$Port = 8000
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user