Merge pull request 'furqan' (#70) from furqan into dev
Reviewed-on: https://gitea.maskantech.in/gitea_admin/pdf/pulls/70
This commit is contained in:
@@ -13,6 +13,17 @@ add_library(pdfengine STATIC
|
||||
src/core/skia_renderer.cpp
|
||||
src/parser/pdfium_loader.cpp
|
||||
src/parser/pdfium_document.cpp
|
||||
src/parser/pdfium_internal.cpp
|
||||
src/parser/pdfium_reflow.cpp
|
||||
src/parser/pdfium_page.cpp
|
||||
src/parser/pdfium_page_model.cpp
|
||||
src/parser/pdfium_fonts.cpp
|
||||
src/parser/pdfium_edit.cpp
|
||||
src/parser/pdfium_edit_replace.cpp
|
||||
src/parser/pdfium_edit_reflow.cpp
|
||||
src/parser/pdfium_edit_annotations.cpp
|
||||
src/parser/pdfium_edit_pages.cpp
|
||||
src/parser/pdfium_edit_images.cpp
|
||||
src/parser/content_stream_parser.cpp
|
||||
src/parser/decoration_builder.cpp
|
||||
src/text/selection.cpp
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -13,6 +13,7 @@
|
||||
#include <mutex>
|
||||
#include <optional>
|
||||
#include <unordered_map>
|
||||
#include <nlohmann/json_fwd.hpp>
|
||||
#include "fonts/pdf_fonts/embedded_font_reconstructor.hpp"
|
||||
namespace pdfengine::fonts::loader { class FontResolver; }
|
||||
namespace pdfengine::fonts { class FontFace; }
|
||||
@@ -156,6 +157,23 @@ private:
|
||||
#ifdef PDFENGINE_WITH_QPDF
|
||||
const fonts::pdf_fonts::ReconstructedFont* getReconstructedEmbeddedFont(const std::string& internalFontId);
|
||||
#endif
|
||||
|
||||
std::expected<void, EngineError> applyOp_replaceText(const nlohmann::json& op, int pageIndex);
|
||||
std::expected<void, EngineError> applyOp_reflow(const nlohmann::json& op, int pageIndex);
|
||||
std::expected<void, EngineError> applyOp_textOverlay(const nlohmann::json& op, int pageIndex);
|
||||
std::expected<void, EngineError> applyOp_decoration(const nlohmann::json& op, int pageIndex);
|
||||
std::expected<void, EngineError> applyOp_redaction(const nlohmann::json& op, int pageIndex);
|
||||
std::expected<void, EngineError> applyOp_updateField(const nlohmann::json& op, int pageIndex);
|
||||
std::expected<void, EngineError> applyOp_imageOverlay(const nlohmann::json& op, int pageIndex);
|
||||
std::expected<void, EngineError> applyOp_highlight(const nlohmann::json& op, int pageIndex);
|
||||
std::expected<void, EngineError> applyOp_freeText(const nlohmann::json& op, int pageIndex);
|
||||
std::expected<void, EngineError> applyOp_comment(const nlohmann::json& op, int pageIndex);
|
||||
std::expected<void, EngineError> applyOp_freehand(const nlohmann::json& op, int pageIndex);
|
||||
std::expected<void, EngineError> applyOp_pageRotation(const nlohmann::json& op, int pageIndex);
|
||||
std::expected<void, EngineError> applyOp_pageDeletion(const nlohmann::json& op, int pageIndex);
|
||||
std::expected<void, EngineError> applyOp_pageReorder(const nlohmann::json& op, int pageIndex);
|
||||
std::expected<void, EngineError> applyOp_deleteAnnotation(const nlohmann::json& op, int pageIndex);
|
||||
std::expected<void, EngineError> applyOp_updateAnnotation(const nlohmann::json& op, int pageIndex);
|
||||
#endif
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
#include "parser/pdfium_internal.hpp"
|
||||
|
||||
namespace pdfengine::parser {
|
||||
|
||||
std::expected<void, EngineError> PdfiumDocument::applyEdits(const std::string& editsJson) {
|
||||
#ifdef PDFENGINE_WITH_PDFIUM
|
||||
if (!doc_) {
|
||||
return std::unexpected(EngineError::Unknown);
|
||||
}
|
||||
|
||||
lastReflowLayout_.clear();
|
||||
|
||||
try {
|
||||
auto root = nlohmann::json::parse(editsJson);
|
||||
if (!root.contains("version") || root["version"] != "1.0") {
|
||||
spdlog::error("Invalid edits JSON: missing or unsupported version (expected '1.0')");
|
||||
return std::unexpected(EngineError::InvalidFormat);
|
||||
}
|
||||
if (!root.contains("operations") || !root["operations"].is_array()) {
|
||||
spdlog::error("Invalid edits JSON: missing 'operations' array");
|
||||
return std::unexpected(EngineError::InvalidFormat);
|
||||
}
|
||||
|
||||
for (const auto& op : root["operations"]) {
|
||||
std::string type = op.value("type", "");
|
||||
int pageIndex = op.value("pageIndex", -1);
|
||||
if (pageIndex < 0 || pageIndex >= pageCount()) {
|
||||
spdlog::error("Page index {} out of bounds (total pages: {})", pageIndex, pageCount());
|
||||
return std::unexpected(EngineError::PageOutOfBounds);
|
||||
}
|
||||
|
||||
std::expected<void, EngineError> r{};
|
||||
if (type == "replace_text") {
|
||||
r = applyOp_replaceText(op, pageIndex);
|
||||
} else if (type == "reflow_paragraph") {
|
||||
r = applyOp_reflow(op, pageIndex);
|
||||
} else if (type == "text_overlay" || type == "add_text") {
|
||||
r = applyOp_textOverlay(op, pageIndex);
|
||||
} else if (type == "underline" || type == "strikeout" || type == "squiggly") {
|
||||
r = applyOp_decoration(op, pageIndex);
|
||||
} else if (type == "redaction") {
|
||||
r = applyOp_redaction(op, pageIndex);
|
||||
} else if (type == "update_field") {
|
||||
r = applyOp_updateField(op, pageIndex);
|
||||
} else if (type == "image_overlay") {
|
||||
r = applyOp_imageOverlay(op, pageIndex);
|
||||
} else if (type == "highlight") {
|
||||
r = applyOp_highlight(op, pageIndex);
|
||||
} else if (type == "free_text") {
|
||||
r = applyOp_freeText(op, pageIndex);
|
||||
} else if (type == "comment") {
|
||||
r = applyOp_comment(op, pageIndex);
|
||||
} else if (type == "freehand") {
|
||||
r = applyOp_freehand(op, pageIndex);
|
||||
} else if (type == "page_rotation") {
|
||||
r = applyOp_pageRotation(op, pageIndex);
|
||||
} else if (type == "page_deletion") {
|
||||
r = applyOp_pageDeletion(op, pageIndex);
|
||||
} else if (type == "page_reorder") {
|
||||
r = applyOp_pageReorder(op, pageIndex);
|
||||
} else if (type == "delete_annotation") {
|
||||
r = applyOp_deleteAnnotation(op, pageIndex);
|
||||
} else if (type == "update_annotation") {
|
||||
r = applyOp_updateAnnotation(op, pageIndex);
|
||||
} else {
|
||||
spdlog::warn("Unsupported edit operation type: {}", type);
|
||||
}
|
||||
|
||||
if (!r) {
|
||||
return std::unexpected(r.error());
|
||||
}
|
||||
}
|
||||
} catch (const nlohmann::json::parse_error& e) {
|
||||
spdlog::error("JSON parse error in applyEdits: {}", e.what());
|
||||
return std::unexpected(EngineError::InvalidFormat);
|
||||
} catch (const std::exception& e) {
|
||||
spdlog::error("Exception in applyEdits: {}", e.what());
|
||||
return std::unexpected(EngineError::Unknown);
|
||||
}
|
||||
|
||||
invalidateCaches();
|
||||
return {};
|
||||
#else
|
||||
(void)editsJson;
|
||||
return std::unexpected(EngineError::Unknown);
|
||||
#endif
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,572 @@
|
||||
#include "parser/pdfium_internal.hpp"
|
||||
|
||||
namespace pdfengine::parser {
|
||||
|
||||
std::expected<void, EngineError> PdfiumDocument::applyOp_textOverlay(const nlohmann::json& op, int pageIndex) {
|
||||
#ifdef PDFENGINE_WITH_PDFIUM
|
||||
if (!op.contains("data") || !op["data"].is_object()) {
|
||||
spdlog::error("text_overlay/add_text operation missing 'data' object");
|
||||
return std::unexpected(EngineError::InvalidFormat);
|
||||
}
|
||||
auto data = op["data"];
|
||||
std::string text = data.value("text", "");
|
||||
double x = data.value("x", 0.0);
|
||||
double y = data.value("y", 0.0);
|
||||
double fontSize = data.value("fontSize", 12.0);
|
||||
double width = data.value("width", 200.0);
|
||||
double height = data.value("height", fontSize * 1.5);
|
||||
std::string fontFamily = data.value("fontFamily", "Helvetica");
|
||||
std::string color = data.value("color", "#000000");
|
||||
|
||||
if (text.empty()) {
|
||||
return {};
|
||||
}
|
||||
|
||||
FPDF_PAGE page = FPDF_LoadPage(doc_, pageIndex);
|
||||
if (!page) {
|
||||
spdlog::error("Failed to load page index {} for editing", pageIndex);
|
||||
return std::unexpected(EngineError::Unknown);
|
||||
}
|
||||
|
||||
FPDF_PAGEOBJECT rectObj = FPDFPageObj_CreateNewRect(
|
||||
static_cast<float>(x),
|
||||
static_cast<float>(y),
|
||||
static_cast<float>(width),
|
||||
static_cast<float>(height)
|
||||
);
|
||||
if (!rectObj) {
|
||||
spdlog::error("Failed to create background white rectangle object");
|
||||
FPDF_ClosePage(page);
|
||||
return std::unexpected(EngineError::Unknown);
|
||||
}
|
||||
FPDFPageObj_SetFillColor(rectObj, 255, 255, 255, 255);
|
||||
FPDFPath_SetDrawMode(rectObj, FPDF_FILLMODE_WINDING, 0);
|
||||
FPDFPage_InsertObject(page, rectObj);
|
||||
|
||||
std::string standardFontName = "Helvetica";
|
||||
if (fontFamily == "Times New Roman" || fontFamily == "Times-Roman") {
|
||||
standardFontName = "Times-Roman";
|
||||
} else if (fontFamily == "Courier New" || fontFamily == "Courier") {
|
||||
standardFontName = "Courier";
|
||||
} else if (fontFamily == "Helvetica-Bold") {
|
||||
standardFontName = "Helvetica-Bold";
|
||||
} else if (fontFamily == "Helvetica-Oblique") {
|
||||
standardFontName = "Helvetica-Oblique";
|
||||
} else if (fontFamily == "Helvetica-BoldOblique") {
|
||||
standardFontName = "Helvetica-BoldOblique";
|
||||
} else if (fontFamily == "Times-Bold") {
|
||||
standardFontName = "Times-Bold";
|
||||
} else if (fontFamily == "Times-Italic") {
|
||||
standardFontName = "Times-Italic";
|
||||
} else if (fontFamily == "Times-BoldItalic") {
|
||||
standardFontName = "Times-BoldItalic";
|
||||
} else if (fontFamily == "Courier-Bold") {
|
||||
standardFontName = "Courier-Bold";
|
||||
} else if (fontFamily == "Courier-Oblique") {
|
||||
standardFontName = "Courier-Oblique";
|
||||
} else if (fontFamily == "Courier-BoldOblique") {
|
||||
standardFontName = "Courier-BoldOblique";
|
||||
}
|
||||
|
||||
FPDF_FONT font = FPDFText_LoadStandardFont(doc_, standardFontName.c_str());
|
||||
if (!font) {
|
||||
spdlog::warn("Failed to load standard font {}, falling back to Helvetica", standardFontName);
|
||||
font = FPDFText_LoadStandardFont(doc_, "Helvetica");
|
||||
}
|
||||
|
||||
FPDF_PAGEOBJECT textObj = FPDFPageObj_CreateTextObj(doc_, font, static_cast<float>(fontSize));
|
||||
if (!textObj) {
|
||||
spdlog::error("Failed to create text object");
|
||||
FPDF_ClosePage(page);
|
||||
return std::unexpected(EngineError::Unknown);
|
||||
}
|
||||
|
||||
unsigned int r = 0, g = 0, b = 0;
|
||||
parseHexColor(color, r, g, b);
|
||||
FPDFPageObj_SetFillColor(textObj, r, g, b, 255);
|
||||
|
||||
auto utf16 = utf8_to_utf16le(text);
|
||||
if (!FPDFText_SetText(textObj, reinterpret_cast<FPDF_WIDESTRING>(utf16.data()))) {
|
||||
spdlog::error("Failed to set text object text");
|
||||
FPDFPageObj_Destroy(textObj);
|
||||
FPDF_ClosePage(page);
|
||||
return std::unexpected(EngineError::Unknown);
|
||||
}
|
||||
|
||||
FPDFPageObj_Transform(textObj, 1.0, 0.0, 0.0, 1.0, x, y);
|
||||
|
||||
FPDFPage_InsertObject(page, textObj);
|
||||
|
||||
if (!FPDFPage_GenerateContent(page)) {
|
||||
spdlog::error("Failed to generate page content after editing");
|
||||
FPDF_ClosePage(page);
|
||||
return std::unexpected(EngineError::Unknown);
|
||||
}
|
||||
|
||||
FPDF_ClosePage(page);
|
||||
return {};
|
||||
#else
|
||||
(void)op; (void)pageIndex;
|
||||
return std::unexpected(EngineError::Unknown);
|
||||
#endif
|
||||
}
|
||||
|
||||
std::expected<void, EngineError> PdfiumDocument::applyOp_decoration(const nlohmann::json& op, int pageIndex) {
|
||||
#ifdef PDFENGINE_WITH_PDFIUM
|
||||
std::string type = op.value("type", "");
|
||||
if (!op.contains("data") || !op["data"].is_object()) {
|
||||
spdlog::error("{} operation missing 'data' object", type);
|
||||
return std::unexpected(EngineError::InvalidFormat);
|
||||
}
|
||||
auto data = op["data"];
|
||||
|
||||
FPDF_PAGE page = FPDF_LoadPage(doc_, pageIndex);
|
||||
if (!page) {
|
||||
spdlog::error("Failed to load page index {} for decoration", pageIndex);
|
||||
return std::unexpected(EngineError::Unknown);
|
||||
}
|
||||
|
||||
int subtype = FPDF_ANNOT_UNDERLINE;
|
||||
if (type == "strikeout") subtype = FPDF_ANNOT_STRIKEOUT;
|
||||
else if (type == "squiggly") subtype = FPDF_ANNOT_SQUIGGLY;
|
||||
|
||||
FPDF_ANNOTATION annot = FPDFPage_CreateAnnot(page, subtype);
|
||||
if (!annot) {
|
||||
spdlog::error("Failed to create {} annotation", type);
|
||||
FPDF_ClosePage(page);
|
||||
return std::unexpected(EngineError::Unknown);
|
||||
}
|
||||
|
||||
double pageHeight = FPDF_GetPageHeightF(page);
|
||||
unsigned int r = 0, g = 0, b = 0;
|
||||
parseHexColor(data.value("color", "#000000"), r, g, b);
|
||||
FPDFAnnot_SetColor(annot, FPDFANNOT_COLORTYPE_Color, r, g, b, 255);
|
||||
|
||||
if (data.contains("quadPoints") && data["quadPoints"].is_array()) {
|
||||
FS_RECTF boundingBox;
|
||||
boundingBox.left = 99999.0f;
|
||||
boundingBox.right = -99999.0f;
|
||||
boundingBox.top = -99999.0f;
|
||||
boundingBox.bottom = 99999.0f;
|
||||
|
||||
for (const auto& quad : data["quadPoints"]) {
|
||||
FS_QUADPOINTSF points;
|
||||
points.x1 = static_cast<float>(quad.value("x1", 0.0));
|
||||
points.y1 = static_cast<float>(pageHeight - quad.value("y1", 0.0));
|
||||
points.x2 = static_cast<float>(quad.value("x2", 0.0));
|
||||
points.y2 = static_cast<float>(pageHeight - quad.value("y2", 0.0));
|
||||
points.x3 = static_cast<float>(quad.value("x3", 0.0));
|
||||
points.y3 = static_cast<float>(pageHeight - quad.value("y3", 0.0));
|
||||
points.x4 = static_cast<float>(quad.value("x4", 0.0));
|
||||
points.y4 = static_cast<float>(pageHeight - quad.value("y4", 0.0));
|
||||
|
||||
FPDFAnnot_AppendAttachmentPoints(annot, &points);
|
||||
|
||||
float minX = (std::min)({points.x1, points.x2, points.x3, points.x4});
|
||||
float maxX = (std::max)({points.x1, points.x2, points.x3, points.x4});
|
||||
float minY = (std::min)({points.y1, points.y2, points.y3, points.y4});
|
||||
float maxY = (std::max)({points.y1, points.y2, points.y3, points.y4});
|
||||
if (minX < boundingBox.left) boundingBox.left = minX;
|
||||
if (maxX > boundingBox.right) boundingBox.right = maxX;
|
||||
if (minY < boundingBox.bottom) boundingBox.bottom = minY;
|
||||
if (maxY > boundingBox.top) boundingBox.top = maxY;
|
||||
}
|
||||
if (boundingBox.left <= boundingBox.right) {
|
||||
FPDFAnnot_SetRect(annot, &boundingBox);
|
||||
}
|
||||
}
|
||||
|
||||
std::string author = data.value("author", "");
|
||||
if (!author.empty()) {
|
||||
auto utf16 = utf8_to_utf16le(author);
|
||||
FPDFAnnot_SetStringValue(annot, "T", reinterpret_cast<FPDF_WIDESTRING>(utf16.data()));
|
||||
}
|
||||
|
||||
FPDFPage_CloseAnnot(annot);
|
||||
FPDF_ClosePage(page);
|
||||
return {};
|
||||
#else
|
||||
(void)op; (void)pageIndex;
|
||||
return std::unexpected(EngineError::Unknown);
|
||||
#endif
|
||||
}
|
||||
|
||||
std::expected<void, EngineError> PdfiumDocument::applyOp_highlight(const nlohmann::json& op, int pageIndex) {
|
||||
#ifdef PDFENGINE_WITH_PDFIUM
|
||||
if (!op.contains("data") || !op["data"].is_object()) {
|
||||
spdlog::error("highlight operation missing 'data' object");
|
||||
return std::unexpected(EngineError::InvalidFormat);
|
||||
}
|
||||
auto data = op["data"];
|
||||
|
||||
FPDF_PAGE page = FPDF_LoadPage(doc_, pageIndex);
|
||||
if (!page) {
|
||||
spdlog::error("Failed to load page index {} for highlight", pageIndex);
|
||||
return std::unexpected(EngineError::Unknown);
|
||||
}
|
||||
|
||||
FPDF_ANNOTATION annot = FPDFPage_CreateAnnot(page, FPDF_ANNOT_HIGHLIGHT);
|
||||
if (!annot) {
|
||||
spdlog::error("Failed to create highlight annotation");
|
||||
FPDF_ClosePage(page);
|
||||
return std::unexpected(EngineError::Unknown);
|
||||
}
|
||||
|
||||
double pageHeight = FPDF_GetPageHeightF(page);
|
||||
|
||||
std::string colorStr = data.value("color", "#ffff00");
|
||||
unsigned int r = 255, g = 255, b = 0;
|
||||
parseHexColor(colorStr, r, g, b);
|
||||
FPDFAnnot_SetColor(annot, FPDFANNOT_COLORTYPE_Color, r, g, b, 255);
|
||||
|
||||
if (data.contains("quadPoints") && data["quadPoints"].is_array()) {
|
||||
FS_RECTF boundingBox;
|
||||
boundingBox.left = 99999.0f;
|
||||
boundingBox.right = -99999.0f;
|
||||
boundingBox.top = -99999.0f;
|
||||
boundingBox.bottom = 99999.0f;
|
||||
|
||||
for (const auto& quad : data["quadPoints"]) {
|
||||
FS_QUADPOINTSF points;
|
||||
points.x1 = static_cast<float>(quad.value("x1", 0.0));
|
||||
points.y1 = static_cast<float>(pageHeight - quad.value("y1", 0.0));
|
||||
points.x2 = static_cast<float>(quad.value("x2", 0.0));
|
||||
points.y2 = static_cast<float>(pageHeight - quad.value("y2", 0.0));
|
||||
points.x3 = static_cast<float>(quad.value("x3", 0.0));
|
||||
points.y3 = static_cast<float>(pageHeight - quad.value("y3", 0.0));
|
||||
points.x4 = static_cast<float>(quad.value("x4", 0.0));
|
||||
points.y4 = static_cast<float>(pageHeight - quad.value("y4", 0.0));
|
||||
|
||||
FPDFAnnot_AppendAttachmentPoints(annot, &points);
|
||||
|
||||
float minX = (std::min)({points.x1, points.x2, points.x3, points.x4});
|
||||
float maxX = (std::max)({points.x1, points.x2, points.x3, points.x4});
|
||||
float minY = (std::min)({points.y1, points.y2, points.y3, points.y4});
|
||||
float maxY = (std::max)({points.y1, points.y2, points.y3, points.y4});
|
||||
|
||||
if (minX < boundingBox.left) boundingBox.left = minX;
|
||||
if (maxX > boundingBox.right) boundingBox.right = maxX;
|
||||
if (minY < boundingBox.bottom) boundingBox.bottom = minY;
|
||||
if (maxY > boundingBox.top) boundingBox.top = maxY;
|
||||
}
|
||||
|
||||
if (boundingBox.left <= boundingBox.right) {
|
||||
FPDFAnnot_SetRect(annot, &boundingBox);
|
||||
}
|
||||
}
|
||||
|
||||
std::string author = data.value("author", "");
|
||||
if (!author.empty()) {
|
||||
auto utf16 = utf8_to_utf16le(author);
|
||||
FPDFAnnot_SetStringValue(annot, "T", reinterpret_cast<FPDF_WIDESTRING>(utf16.data()));
|
||||
}
|
||||
|
||||
std::string content = data.value("content", "");
|
||||
if (!content.empty()) {
|
||||
auto utf16 = utf8_to_utf16le(content);
|
||||
FPDFAnnot_SetStringValue(annot, "Contents", reinterpret_cast<FPDF_WIDESTRING>(utf16.data()));
|
||||
}
|
||||
|
||||
FPDFPage_CloseAnnot(annot);
|
||||
FPDF_ClosePage(page);
|
||||
return {};
|
||||
#else
|
||||
(void)op; (void)pageIndex;
|
||||
return std::unexpected(EngineError::Unknown);
|
||||
#endif
|
||||
}
|
||||
|
||||
std::expected<void, EngineError> PdfiumDocument::applyOp_freeText(const nlohmann::json& op, int pageIndex) {
|
||||
#ifdef PDFENGINE_WITH_PDFIUM
|
||||
spdlog::info("Parsed free_text edit operation (stub)");
|
||||
return {};
|
||||
#else
|
||||
(void)op; (void)pageIndex;
|
||||
return std::unexpected(EngineError::Unknown);
|
||||
#endif
|
||||
}
|
||||
|
||||
std::expected<void, EngineError> PdfiumDocument::applyOp_comment(const nlohmann::json& op, int pageIndex) {
|
||||
#ifdef PDFENGINE_WITH_PDFIUM
|
||||
if (!op.contains("data") || !op["data"].is_object()) {
|
||||
spdlog::error("comment operation missing 'data' object");
|
||||
return std::unexpected(EngineError::InvalidFormat);
|
||||
}
|
||||
auto data = op["data"];
|
||||
|
||||
FPDF_PAGE page = FPDF_LoadPage(doc_, pageIndex);
|
||||
if (!page) {
|
||||
spdlog::error("Failed to load page index {} for comment", pageIndex);
|
||||
return std::unexpected(EngineError::Unknown);
|
||||
}
|
||||
|
||||
FPDF_ANNOTATION annot = FPDFPage_CreateAnnot(page, FPDF_ANNOT_TEXT);
|
||||
if (!annot) {
|
||||
spdlog::error("Failed to create comment annotation");
|
||||
FPDF_ClosePage(page);
|
||||
return std::unexpected(EngineError::Unknown);
|
||||
}
|
||||
|
||||
double pageHeight = FPDF_GetPageHeightF(page);
|
||||
|
||||
double x = data.value("x", 0.0);
|
||||
double y = data.value("y", 0.0);
|
||||
|
||||
FS_RECTF rect;
|
||||
rect.left = static_cast<float>(x);
|
||||
rect.right = static_cast<float>(x + 24.0);
|
||||
rect.top = static_cast<float>(pageHeight - y);
|
||||
rect.bottom = static_cast<float>(pageHeight - y - 24.0);
|
||||
|
||||
FPDFAnnot_SetRect(annot, &rect);
|
||||
|
||||
std::string colorStr = data.value("color", "#ffeb3b");
|
||||
unsigned int r = 255, g = 235, b = 59;
|
||||
parseHexColor(colorStr, r, g, b);
|
||||
FPDFAnnot_SetColor(annot, FPDFANNOT_COLORTYPE_Color, r, g, b, 255);
|
||||
|
||||
std::string author = data.value("author", "");
|
||||
if (!author.empty()) {
|
||||
auto utf16 = utf8_to_utf16le(author);
|
||||
FPDFAnnot_SetStringValue(annot, "T", reinterpret_cast<FPDF_WIDESTRING>(utf16.data()));
|
||||
}
|
||||
|
||||
std::string content = data.value("content", "");
|
||||
if (!content.empty()) {
|
||||
auto utf16 = utf8_to_utf16le(content);
|
||||
FPDFAnnot_SetStringValue(annot, "Contents", reinterpret_cast<FPDF_WIDESTRING>(utf16.data()));
|
||||
}
|
||||
|
||||
std::string timestamp = data.value("timestamp", "");
|
||||
if (!timestamp.empty()) {
|
||||
auto utf16 = utf8_to_utf16le(timestamp);
|
||||
FPDFAnnot_SetStringValue(annot, "M", reinterpret_cast<FPDF_WIDESTRING>(utf16.data()));
|
||||
}
|
||||
|
||||
FPDFPage_CloseAnnot(annot);
|
||||
FPDF_ClosePage(page);
|
||||
return {};
|
||||
#else
|
||||
(void)op; (void)pageIndex;
|
||||
return std::unexpected(EngineError::Unknown);
|
||||
#endif
|
||||
}
|
||||
|
||||
std::expected<void, EngineError> PdfiumDocument::applyOp_freehand(const nlohmann::json& op, int pageIndex) {
|
||||
#ifdef PDFENGINE_WITH_PDFIUM
|
||||
if (!op.contains("data") || !op["data"].is_object()) {
|
||||
spdlog::error("freehand operation missing 'data' object");
|
||||
return std::unexpected(EngineError::InvalidFormat);
|
||||
}
|
||||
auto data = op["data"];
|
||||
|
||||
FPDF_PAGE page = FPDF_LoadPage(doc_, pageIndex);
|
||||
if (!page) {
|
||||
spdlog::error("Failed to load page index {} for freehand", pageIndex);
|
||||
return std::unexpected(EngineError::Unknown);
|
||||
}
|
||||
|
||||
FPDF_ANNOTATION annot = FPDFPage_CreateAnnot(page, FPDF_ANNOT_INK);
|
||||
if (!annot) {
|
||||
spdlog::error("Failed to create ink annotation");
|
||||
FPDF_ClosePage(page);
|
||||
return std::unexpected(EngineError::Unknown);
|
||||
}
|
||||
|
||||
double pageHeight = FPDF_GetPageHeightF(page);
|
||||
|
||||
std::string colorStr = data.value("color", "#000000");
|
||||
unsigned int r = 0, g = 0, b = 0;
|
||||
parseHexColor(colorStr, r, g, b);
|
||||
FPDFAnnot_SetColor(annot, FPDFANNOT_COLORTYPE_Color, r, g, b, 255);
|
||||
|
||||
float thickness = static_cast<float>(data.value("thickness", 2.0));
|
||||
FPDFAnnot_SetBorder(annot, 0.0f, 0.0f, thickness);
|
||||
|
||||
float minX = 1e9f, minY = 1e9f, maxX = -1e9f, maxY = -1e9f;
|
||||
bool anyPoints = false;
|
||||
|
||||
if (data.contains("paths") && data["paths"].is_array()) {
|
||||
for (const auto& path : data["paths"]) {
|
||||
if (!path.is_array() || path.size() < 2) continue;
|
||||
std::vector<FS_POINTF> pts;
|
||||
pts.reserve(path.size());
|
||||
for (const auto& pt : path) {
|
||||
float px = static_cast<float>(pt.value("x", 0.0));
|
||||
float py = static_cast<float>(pageHeight - pt.value("y", 0.0));
|
||||
pts.push_back(FS_POINTF{px, py});
|
||||
anyPoints = true;
|
||||
minX = (std::min)(minX, px); maxX = (std::max)(maxX, px);
|
||||
minY = (std::min)(minY, py); maxY = (std::max)(maxY, py);
|
||||
}
|
||||
if (pts.size() >= 2) {
|
||||
FPDFAnnot_AddInkStroke(annot, pts.data(), pts.size());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (anyPoints) {
|
||||
float pad = thickness + 1.0f;
|
||||
FS_RECTF rect;
|
||||
rect.left = minX - pad;
|
||||
rect.bottom = minY - pad;
|
||||
rect.right = maxX + pad;
|
||||
rect.top = maxY + pad;
|
||||
FPDFAnnot_SetRect(annot, &rect);
|
||||
}
|
||||
|
||||
FPDFPage_CloseAnnot(annot);
|
||||
FPDF_ClosePage(page);
|
||||
return {};
|
||||
#else
|
||||
(void)op; (void)pageIndex;
|
||||
return std::unexpected(EngineError::Unknown);
|
||||
#endif
|
||||
}
|
||||
|
||||
std::expected<void, EngineError> PdfiumDocument::applyOp_deleteAnnotation(const nlohmann::json& op, int pageIndex) {
|
||||
#ifdef PDFENGINE_WITH_PDFIUM
|
||||
if (!op.contains("data") || !op["data"].is_object()) {
|
||||
spdlog::error("delete_annotation operation missing 'data' object");
|
||||
return std::unexpected(EngineError::InvalidFormat);
|
||||
}
|
||||
std::string targetId = op["data"].value("annotationId", "");
|
||||
if (targetId.empty()) {
|
||||
spdlog::error("delete_annotation missing 'annotationId'");
|
||||
return std::unexpected(EngineError::InvalidFormat);
|
||||
}
|
||||
|
||||
FPDF_PAGE page = FPDF_LoadPage(doc_, pageIndex);
|
||||
if (!page) {
|
||||
spdlog::error("Failed to load page index {} for delete_annotation", pageIndex);
|
||||
return std::unexpected(EngineError::Unknown);
|
||||
}
|
||||
|
||||
int count = FPDFPage_GetAnnotCount(page);
|
||||
int foundIndex = -1;
|
||||
for (int i = 0; i < count; ++i) {
|
||||
FPDF_ANNOTATION annot = FPDFPage_GetAnnot(page, i);
|
||||
if (!annot) continue;
|
||||
|
||||
std::string id;
|
||||
unsigned long len = FPDFAnnot_GetStringValue(annot, "NM", nullptr, 0);
|
||||
if (len > 2) {
|
||||
std::vector<uint8_t> buf(len);
|
||||
FPDFAnnot_GetStringValue(annot, "NM", reinterpret_cast<FPDF_WCHAR*>(buf.data()), len);
|
||||
id = utf16le_to_utf8(reinterpret_cast<const char16_t*>(buf.data()), len / sizeof(char16_t));
|
||||
while (!id.empty() && id.back() == '\0') id.pop_back();
|
||||
}
|
||||
if (id.empty()) {
|
||||
id = "anno_" + std::to_string(pageIndex) + "_" + std::to_string(i);
|
||||
}
|
||||
FPDFPage_CloseAnnot(annot);
|
||||
|
||||
if (id == targetId) { foundIndex = i; break; }
|
||||
}
|
||||
|
||||
if (foundIndex >= 0) {
|
||||
if (!FPDFPage_RemoveAnnot(page, foundIndex)) {
|
||||
spdlog::error("FPDFPage_RemoveAnnot failed for index {}", foundIndex);
|
||||
}
|
||||
} else {
|
||||
spdlog::warn("delete_annotation: annotation '{}' not found on page {}", targetId, pageIndex);
|
||||
}
|
||||
FPDF_ClosePage(page);
|
||||
return {};
|
||||
#else
|
||||
(void)op; (void)pageIndex;
|
||||
return std::unexpected(EngineError::Unknown);
|
||||
#endif
|
||||
}
|
||||
|
||||
std::expected<void, EngineError> PdfiumDocument::applyOp_updateAnnotation(const nlohmann::json& op, int pageIndex) {
|
||||
#ifdef PDFENGINE_WITH_PDFIUM
|
||||
if (!op.contains("data") || !op["data"].is_object()) {
|
||||
spdlog::error("update_annotation operation missing 'data' object");
|
||||
return std::unexpected(EngineError::InvalidFormat);
|
||||
}
|
||||
auto data = op["data"];
|
||||
std::string targetId = data.value("annotationId", "");
|
||||
if (targetId.empty()) {
|
||||
spdlog::error("update_annotation missing 'annotationId'");
|
||||
return std::unexpected(EngineError::InvalidFormat);
|
||||
}
|
||||
|
||||
FPDF_PAGE page = FPDF_LoadPage(doc_, pageIndex);
|
||||
if (!page) {
|
||||
spdlog::error("Failed to load page index {} for update_annotation", pageIndex);
|
||||
return std::unexpected(EngineError::Unknown);
|
||||
}
|
||||
|
||||
int count = FPDFPage_GetAnnotCount(page);
|
||||
FPDF_ANNOTATION targetAnnot = nullptr;
|
||||
for (int i = 0; i < count; ++i) {
|
||||
FPDF_ANNOTATION annot = FPDFPage_GetAnnot(page, i);
|
||||
if (!annot) continue;
|
||||
|
||||
std::string id;
|
||||
unsigned long len = FPDFAnnot_GetStringValue(annot, "NM", nullptr, 0);
|
||||
if (len > 2) {
|
||||
std::vector<uint8_t> buf(len);
|
||||
FPDFAnnot_GetStringValue(annot, "NM", reinterpret_cast<FPDF_WCHAR*>(buf.data()), len);
|
||||
id = utf16le_to_utf8(reinterpret_cast<const char16_t*>(buf.data()), len / sizeof(char16_t));
|
||||
while (!id.empty() && id.back() == '\0') id.pop_back();
|
||||
}
|
||||
if (id.empty()) {
|
||||
id = "anno_" + std::to_string(pageIndex) + "_" + std::to_string(i);
|
||||
}
|
||||
|
||||
if (id == targetId) {
|
||||
targetAnnot = annot;
|
||||
break;
|
||||
}
|
||||
FPDFPage_CloseAnnot(annot);
|
||||
}
|
||||
|
||||
if (targetAnnot) {
|
||||
if (data.contains("x") && data.contains("y") && data.contains("width") && data.contains("height")) {
|
||||
double pageHeight = FPDF_GetPageHeightF(page);
|
||||
float x = static_cast<float>(data["x"].get<double>());
|
||||
float y = static_cast<float>(data["y"].get<double>());
|
||||
float width = static_cast<float>(data["width"].get<double>());
|
||||
float height = static_cast<float>(data["height"].get<double>());
|
||||
|
||||
FS_RECTF rect;
|
||||
rect.left = x;
|
||||
rect.right = x + width;
|
||||
rect.top = static_cast<float>(pageHeight - y);
|
||||
rect.bottom = static_cast<float>(pageHeight - (y + height));
|
||||
FPDFAnnot_SetRect(targetAnnot, &rect);
|
||||
}
|
||||
|
||||
if (data.contains("color")) {
|
||||
std::string colorStr = data["color"].get<std::string>();
|
||||
unsigned int r = 0, g = 0, b = 0;
|
||||
parseHexColor(colorStr, r, g, b);
|
||||
FPDFAnnot_SetColor(targetAnnot, FPDFANNOT_COLORTYPE_Color, r, g, b, 255);
|
||||
}
|
||||
|
||||
if (data.contains("thickness")) {
|
||||
float thickness = static_cast<float>(data["thickness"].get<double>());
|
||||
FPDFAnnot_SetBorder(targetAnnot, 0.0f, 0.0f, thickness);
|
||||
}
|
||||
|
||||
if (data.contains("text")) {
|
||||
std::string text = data["text"].get<std::string>();
|
||||
auto utf16 = utf8_to_utf16le(text);
|
||||
FPDFAnnot_SetStringValue(targetAnnot, "Contents", reinterpret_cast<FPDF_WIDESTRING>(utf16.data()));
|
||||
}
|
||||
|
||||
FPDFPage_CloseAnnot(targetAnnot);
|
||||
} else {
|
||||
spdlog::warn("update_annotation: annotation '{}' not found on page {}", targetId, pageIndex);
|
||||
}
|
||||
FPDF_ClosePage(page);
|
||||
return {};
|
||||
#else
|
||||
(void)op; (void)pageIndex;
|
||||
return std::unexpected(EngineError::Unknown);
|
||||
#endif
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
#include "parser/pdfium_internal.hpp"
|
||||
|
||||
namespace pdfengine::parser {
|
||||
|
||||
std::expected<void, EngineError> PdfiumDocument::applyOp_imageOverlay(const nlohmann::json& op, int pageIndex) {
|
||||
#ifdef PDFENGINE_WITH_PDFIUM
|
||||
if (!op.contains("data") || !op["data"].is_object()) {
|
||||
spdlog::error("image_overlay operation missing 'data' object");
|
||||
return std::unexpected(EngineError::InvalidFormat);
|
||||
}
|
||||
auto data = op["data"];
|
||||
double x = data.value("x", 0.0);
|
||||
double y = data.value("y", 0.0);
|
||||
double width = data.value("width", 100.0);
|
||||
double height = data.value("height", 100.0);
|
||||
int pixelWidth = data.value("pixelWidth", 0);
|
||||
int pixelHeight = data.value("pixelHeight", 0);
|
||||
std::string rawPixelData = data.value("rawPixelData", "");
|
||||
std::string pixelDataPath = data.value("pixelDataPath", "");
|
||||
|
||||
std::vector<uint8_t> decodedBytes;
|
||||
if (!pixelDataPath.empty()) {
|
||||
std::ifstream infile(pixelDataPath, std::ios::binary);
|
||||
if (!infile) {
|
||||
spdlog::error("Failed to open pixel data path: {}", pixelDataPath);
|
||||
return std::unexpected(EngineError::Unknown);
|
||||
}
|
||||
infile.seekg(0, std::ios::end);
|
||||
std::streamsize size = infile.tellg();
|
||||
infile.seekg(0, std::ios::beg);
|
||||
decodedBytes.resize(static_cast<size_t>(size));
|
||||
if (!infile.read(reinterpret_cast<char*>(decodedBytes.data()), size)) {
|
||||
spdlog::error("Failed to read pixel data from path: {}", pixelDataPath);
|
||||
return std::unexpected(EngineError::Unknown);
|
||||
}
|
||||
infile.close();
|
||||
std::error_code ec;
|
||||
std::filesystem::remove(pixelDataPath, ec);
|
||||
} else if (!rawPixelData.empty()) {
|
||||
decodedBytes = base64Decode(rawPixelData);
|
||||
} else {
|
||||
spdlog::warn("image_overlay operation contains invalid raw pixels or dimensions");
|
||||
return {};
|
||||
}
|
||||
|
||||
if (decodedBytes.size() != static_cast<size_t>(pixelWidth * pixelHeight * 4)) {
|
||||
spdlog::error("Decoded image bytes size mismatch. Expected: {}, Got: {}",
|
||||
pixelWidth * pixelHeight * 4, decodedBytes.size());
|
||||
return std::unexpected(EngineError::InvalidFormat);
|
||||
}
|
||||
|
||||
FPDF_PAGE page = FPDF_LoadPage(doc_, pageIndex);
|
||||
if (!page) {
|
||||
spdlog::error("Failed to load page index {} for image insertion", pageIndex);
|
||||
return std::unexpected(EngineError::Unknown);
|
||||
}
|
||||
|
||||
FPDF_PAGEOBJECT imgObj = FPDFPageObj_NewImageObj(doc_);
|
||||
if (!imgObj) {
|
||||
spdlog::error("Failed to create new image object");
|
||||
FPDF_ClosePage(page);
|
||||
return std::unexpected(EngineError::Unknown);
|
||||
}
|
||||
|
||||
FPDF_BITMAP bitmap = FPDFBitmap_Create(pixelWidth, pixelHeight, 4);
|
||||
if (!bitmap) {
|
||||
spdlog::error("Failed to create FPDF_BITMAP");
|
||||
FPDFPageObj_Destroy(imgObj);
|
||||
FPDF_ClosePage(page);
|
||||
return std::unexpected(EngineError::Unknown);
|
||||
}
|
||||
|
||||
uint8_t* dest = static_cast<uint8_t*>(FPDFBitmap_GetBuffer(bitmap));
|
||||
std::memcpy(dest, decodedBytes.data(), decodedBytes.size());
|
||||
|
||||
if (!FPDFImageObj_SetBitmap(&page, 1, imgObj, bitmap)) {
|
||||
spdlog::error("Failed to set bitmap on image object");
|
||||
FPDFBitmap_Destroy(bitmap);
|
||||
FPDFPageObj_Destroy(imgObj);
|
||||
FPDF_ClosePage(page);
|
||||
return std::unexpected(EngineError::Unknown);
|
||||
}
|
||||
|
||||
FPDFPageObj_Transform(imgObj, width, 0.0, 0.0, height, x, y);
|
||||
|
||||
FPDFPage_InsertObject(page, imgObj);
|
||||
|
||||
if (!FPDFPage_GenerateContent(page)) {
|
||||
spdlog::error("Failed to generate page content after image insertion");
|
||||
FPDFBitmap_Destroy(bitmap);
|
||||
FPDF_ClosePage(page);
|
||||
return std::unexpected(EngineError::Unknown);
|
||||
}
|
||||
|
||||
FPDFBitmap_Destroy(bitmap);
|
||||
FPDF_ClosePage(page);
|
||||
return {};
|
||||
#else
|
||||
(void)op; (void)pageIndex;
|
||||
return std::unexpected(EngineError::Unknown);
|
||||
#endif
|
||||
}
|
||||
|
||||
std::expected<void, EngineError> PdfiumDocument::applyOp_redaction(const nlohmann::json& op, int pageIndex) {
|
||||
#ifdef PDFENGINE_WITH_PDFIUM
|
||||
if (!op.contains("data") || !op["data"].is_object()) {
|
||||
spdlog::error("redaction operation missing 'data' object");
|
||||
return std::unexpected(EngineError::InvalidFormat);
|
||||
}
|
||||
auto data = op["data"];
|
||||
double x = data.value("x", 0.0);
|
||||
double y = data.value("y", 0.0);
|
||||
double width = data.value("width", 0.0);
|
||||
double height = data.value("height", 0.0);
|
||||
std::string fillColor = data.value("fillColor", "#ffffff");
|
||||
|
||||
FPDF_PAGE page = FPDF_LoadPage(doc_, pageIndex);
|
||||
if (!page) {
|
||||
spdlog::error("Failed to load page index {} for redaction", pageIndex);
|
||||
return std::unexpected(EngineError::Unknown);
|
||||
}
|
||||
|
||||
int count = FPDFPage_CountObjects(page);
|
||||
for (int i = count - 1; i >= 0; --i) {
|
||||
FPDF_PAGEOBJECT obj = FPDFPage_GetObject(page, i);
|
||||
if (obj) {
|
||||
float left = 0, bottom = 0, right = 0, top = 0;
|
||||
if (FPDFPageObj_GetBounds(obj, &left, &bottom, &right, &top)) {
|
||||
if (!(left > x + width || right < x || bottom > y + height || top < y)) {
|
||||
FPDFPage_RemoveObject(page, obj);
|
||||
FPDFPageObj_Destroy(obj);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
FPDF_PAGEOBJECT rectObj = FPDFPageObj_CreateNewRect(
|
||||
static_cast<float>(x),
|
||||
static_cast<float>(y),
|
||||
static_cast<float>(width),
|
||||
static_cast<float>(height)
|
||||
);
|
||||
if (!rectObj) {
|
||||
spdlog::error("Failed to create redaction cover rectangle object");
|
||||
FPDF_ClosePage(page);
|
||||
return std::unexpected(EngineError::Unknown);
|
||||
}
|
||||
|
||||
unsigned int r = 255, g = 255, b = 255;
|
||||
parseHexColor(fillColor, r, g, b);
|
||||
|
||||
FPDFPageObj_SetFillColor(rectObj, r, g, b, 255);
|
||||
FPDFPath_SetDrawMode(rectObj, FPDF_FILLMODE_WINDING, 0);
|
||||
FPDFPage_InsertObject(page, rectObj);
|
||||
|
||||
if (!FPDFPage_GenerateContent(page)) {
|
||||
spdlog::error("Failed to generate page content after redaction");
|
||||
FPDF_ClosePage(page);
|
||||
return std::unexpected(EngineError::Unknown);
|
||||
}
|
||||
|
||||
FPDF_ClosePage(page);
|
||||
return {};
|
||||
#else
|
||||
(void)op; (void)pageIndex;
|
||||
return std::unexpected(EngineError::Unknown);
|
||||
#endif
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
#include "parser/pdfium_internal.hpp"
|
||||
|
||||
namespace pdfengine::parser {
|
||||
|
||||
std::expected<void, EngineError> PdfiumDocument::applyOp_updateField(const nlohmann::json& op, int pageIndex) {
|
||||
#ifdef PDFENGINE_WITH_PDFIUM
|
||||
if (!op.contains("data") || !op["data"].is_object()) {
|
||||
spdlog::error("update_field operation missing 'data' object");
|
||||
return std::unexpected(EngineError::InvalidFormat);
|
||||
}
|
||||
auto data = op["data"];
|
||||
const bool isBool = data["value"].is_boolean();
|
||||
const bool boolVal = isBool && data["value"].get<bool>();
|
||||
std::string strVal;
|
||||
if (data["value"].is_string()) strVal = data["value"].get<std::string>();
|
||||
else if (!isBool) {
|
||||
spdlog::error("update_field value must be a string or boolean");
|
||||
return std::unexpected(EngineError::InvalidFormat);
|
||||
}
|
||||
|
||||
std::string targetId = data.value("annotationId", op.value("id", ""));
|
||||
|
||||
FPDF_PAGE page = FPDF_LoadPage(doc_, pageIndex);
|
||||
if (!page) {
|
||||
spdlog::error("Failed to load page index {} for update_field", pageIndex);
|
||||
return std::unexpected(EngineError::Unknown);
|
||||
}
|
||||
|
||||
FPDF_FORMFILLINFO formInfo{};
|
||||
formInfo.version = 2;
|
||||
FPDF_FORMHANDLE form = FPDFDOC_InitFormFillEnvironment(doc_, &formInfo);
|
||||
|
||||
int count = FPDFPage_GetAnnotCount(page);
|
||||
FPDF_ANNOTATION target = nullptr;
|
||||
for (int i = 0; i < count; ++i) {
|
||||
FPDF_ANNOTATION annot = FPDFPage_GetAnnot(page, i);
|
||||
if (!annot) continue;
|
||||
std::string id;
|
||||
unsigned long len = FPDFAnnot_GetStringValue(annot, "NM", nullptr, 0);
|
||||
if (len > 2) {
|
||||
std::vector<uint8_t> buf(len);
|
||||
FPDFAnnot_GetStringValue(annot, "NM", reinterpret_cast<FPDF_WCHAR*>(buf.data()), len);
|
||||
id = utf16le_to_utf8(reinterpret_cast<const char16_t*>(buf.data()), len / sizeof(char16_t));
|
||||
while (!id.empty() && id.back() == '\0') id.pop_back();
|
||||
}
|
||||
if (id.empty()) id = "anno_" + std::to_string(pageIndex) + "_" + std::to_string(i);
|
||||
if (id == targetId) { target = annot; break; }
|
||||
FPDFPage_CloseAnnot(annot);
|
||||
}
|
||||
|
||||
if (target) {
|
||||
int fieldType = form ? FPDFAnnot_GetFormFieldType(form, target) : -1;
|
||||
if (form) FORM_OnAfterLoadPage(page, form);
|
||||
|
||||
if (fieldType == 2 || fieldType == 3) {
|
||||
std::string state = boolVal ? "Yes" : "Off";
|
||||
auto u = utf8_to_utf16le(state);
|
||||
FPDFAnnot_SetStringValue(target, "V", reinterpret_cast<FPDF_WIDESTRING>(u.data()));
|
||||
FPDFAnnot_SetStringValue(target, "AS", reinterpret_cast<FPDF_WIDESTRING>(u.data()));
|
||||
} else if (form && FORM_SetFocusedAnnot(form, target)) {
|
||||
if (fieldType == 4 || fieldType == 5) {
|
||||
int optCount = FPDFAnnot_GetOptionCount(form, target);
|
||||
int sel = -1;
|
||||
for (int o = 0; o < optCount; ++o) {
|
||||
unsigned long ol = FPDFAnnot_GetOptionLabel(form, target, o, nullptr, 0);
|
||||
if (ol <= 2) continue;
|
||||
std::vector<FPDF_WCHAR> ob(ol / 2);
|
||||
FPDFAnnot_GetOptionLabel(form, target, o, ob.data(), ol);
|
||||
std::string label = utf16le_to_utf8(reinterpret_cast<const char16_t*>(ob.data()), ob.size());
|
||||
while (!label.empty() && label.back() == '\0') label.pop_back();
|
||||
if (label == strVal) { sel = o; break; }
|
||||
}
|
||||
if (sel >= 0) {
|
||||
FORM_SetIndexSelected(form, page, sel, 1);
|
||||
} else {
|
||||
auto u = utf8_to_utf16le(strVal);
|
||||
FPDFAnnot_SetStringValue(target, "V", reinterpret_cast<FPDF_WIDESTRING>(u.data()));
|
||||
}
|
||||
} else {
|
||||
FORM_SelectAllText(form, page);
|
||||
auto u = utf8_to_utf16le(strVal);
|
||||
FORM_ReplaceSelection(form, page, reinterpret_cast<FPDF_WIDESTRING>(u.data()));
|
||||
}
|
||||
FORM_ForceToKillFocus(form);
|
||||
} else {
|
||||
std::string v = isBool ? (boolVal ? "Yes" : "Off") : strVal;
|
||||
auto u = utf8_to_utf16le(v);
|
||||
FPDFAnnot_SetStringValue(target, "V", reinterpret_cast<FPDF_WIDESTRING>(u.data()));
|
||||
}
|
||||
|
||||
if (form) FORM_OnBeforeClosePage(page, form);
|
||||
FPDFPage_CloseAnnot(target);
|
||||
} else {
|
||||
spdlog::warn("update_field: field '{}' not found on page {}", targetId, pageIndex);
|
||||
}
|
||||
|
||||
if (form) FPDFDOC_ExitFormFillEnvironment(form);
|
||||
FPDF_ClosePage(page);
|
||||
return {};
|
||||
#else
|
||||
(void)op; (void)pageIndex;
|
||||
return std::unexpected(EngineError::Unknown);
|
||||
#endif
|
||||
}
|
||||
|
||||
std::expected<void, EngineError> PdfiumDocument::applyOp_pageRotation(const nlohmann::json& op, int pageIndex) {
|
||||
#ifdef PDFENGINE_WITH_PDFIUM
|
||||
if (!op.contains("data") || !op["data"].is_object()) {
|
||||
spdlog::error("page_rotation operation missing 'data' object");
|
||||
return std::unexpected(EngineError::InvalidFormat);
|
||||
}
|
||||
auto data = op["data"];
|
||||
int rotation = data.value("rotation", 0);
|
||||
|
||||
FPDF_PAGE page = FPDF_LoadPage(doc_, pageIndex);
|
||||
if (!page) {
|
||||
spdlog::error("Failed to load page index {} for page rotation", pageIndex);
|
||||
return std::unexpected(EngineError::Unknown);
|
||||
}
|
||||
|
||||
int currentCode = FPDFPage_GetRotation(page);
|
||||
int currentDegrees = currentCode * 90;
|
||||
int newDegrees = currentDegrees + rotation;
|
||||
newDegrees = (newDegrees % 360 + 360) % 360;
|
||||
int newCode = newDegrees / 90;
|
||||
|
||||
FPDFPage_SetRotation(page, newCode);
|
||||
FPDF_ClosePage(page);
|
||||
return {};
|
||||
#else
|
||||
(void)op; (void)pageIndex;
|
||||
return std::unexpected(EngineError::Unknown);
|
||||
#endif
|
||||
}
|
||||
|
||||
std::expected<void, EngineError> PdfiumDocument::applyOp_pageDeletion(const nlohmann::json& op, int pageIndex) {
|
||||
#ifdef PDFENGINE_WITH_PDFIUM
|
||||
if (pageCount() <= 1) {
|
||||
spdlog::error("Cannot delete the only page in the document");
|
||||
return std::unexpected(EngineError::Unknown);
|
||||
}
|
||||
FPDFPage_Delete(doc_, pageIndex);
|
||||
return {};
|
||||
#else
|
||||
(void)op; (void)pageIndex;
|
||||
return std::unexpected(EngineError::Unknown);
|
||||
#endif
|
||||
}
|
||||
|
||||
std::expected<void, EngineError> PdfiumDocument::applyOp_pageReorder(const nlohmann::json& op, int pageIndex) {
|
||||
#ifdef PDFENGINE_WITH_PDFIUM
|
||||
if (!op.contains("data") || !op["data"].is_object()) {
|
||||
spdlog::error("page_reorder operation missing 'data' object");
|
||||
return std::unexpected(EngineError::InvalidFormat);
|
||||
}
|
||||
auto data = op["data"];
|
||||
if (!data.contains("destPageIndex")) {
|
||||
spdlog::error("page_reorder data missing 'destPageIndex'");
|
||||
return std::unexpected(EngineError::InvalidFormat);
|
||||
}
|
||||
int destPageIndex = data["destPageIndex"];
|
||||
if (destPageIndex < 0 || destPageIndex >= pageCount()) {
|
||||
spdlog::error("Destination page index {} out of bounds (total pages: {})", destPageIndex, pageCount());
|
||||
return std::unexpected(EngineError::PageOutOfBounds);
|
||||
}
|
||||
|
||||
int fromIndex = pageIndex;
|
||||
if (!FPDF_MovePages(doc_, &fromIndex, 1, destPageIndex)) {
|
||||
spdlog::error("FPDF_MovePages failed from {} to {}", fromIndex, destPageIndex);
|
||||
return std::unexpected(EngineError::Unknown);
|
||||
}
|
||||
return {};
|
||||
#else
|
||||
(void)op; (void)pageIndex;
|
||||
return std::unexpected(EngineError::Unknown);
|
||||
#endif
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,472 @@
|
||||
#include "parser/pdfium_internal.hpp"
|
||||
|
||||
namespace pdfengine::parser {
|
||||
|
||||
std::expected<void, EngineError> PdfiumDocument::applyOp_reflow(const nlohmann::json& op, int pageIndex) {
|
||||
#ifdef PDFENGINE_WITH_PDFIUM
|
||||
if (!op.contains("data") || !op["data"].is_object()) {
|
||||
spdlog::error("reflow_paragraph missing 'data'");
|
||||
return std::unexpected(EngineError::InvalidFormat);
|
||||
}
|
||||
auto data = op["data"];
|
||||
|
||||
struct RunStyle { std::string text; std::string internalFontId; double fontSize; unsigned int r, g, b; std::vector<double> advances; };
|
||||
std::vector<RunStyle> runs;
|
||||
auto parseHex = [](const std::string& hex, unsigned int& r, unsigned int& g, unsigned int& b) {
|
||||
r = 0; g = 0; b = 0;
|
||||
if (hex.size() >= 7 && hex[0] == '#') {
|
||||
auto hv = [](char ch) -> int {
|
||||
if (ch >= '0' && ch <= '9') return ch - '0';
|
||||
if (ch >= 'a' && ch <= 'f') return ch - 'a' + 10;
|
||||
if (ch >= 'A' && ch <= 'F') return ch - 'A' + 10;
|
||||
return 0;
|
||||
};
|
||||
r = static_cast<unsigned int>(hv(hex[1]) * 16 + hv(hex[2]));
|
||||
g = static_cast<unsigned int>(hv(hex[3]) * 16 + hv(hex[4]));
|
||||
b = static_cast<unsigned int>(hv(hex[5]) * 16 + hv(hex[6]));
|
||||
}
|
||||
};
|
||||
auto parseRun = [&](const nlohmann::json& rj) {
|
||||
RunStyle rs;
|
||||
rs.text = rj.value("text", "");
|
||||
rs.internalFontId = rj.value("internalFontId", "");
|
||||
rs.fontSize = rj.value("fontSize", 12.0);
|
||||
parseHex(rj.value("color", std::string("#000000")), rs.r, rs.g, rs.b);
|
||||
if (rj.contains("advances") && rj["advances"].is_array()) {
|
||||
for (const auto& a : rj["advances"]) rs.advances.push_back(a.get<double>());
|
||||
}
|
||||
runs.push_back(std::move(rs));
|
||||
};
|
||||
std::vector<std::vector<int>> providedLines;
|
||||
bool hasProvidedLines = data.contains("lines") && data["lines"].is_array() && !data["lines"].empty();
|
||||
if (hasProvidedLines) {
|
||||
for (const auto& lineJson : data["lines"]) {
|
||||
std::vector<int> lineRunIdxs;
|
||||
if (lineJson.is_array()) {
|
||||
for (const auto& rj : lineJson) { lineRunIdxs.push_back(static_cast<int>(runs.size())); parseRun(rj); }
|
||||
}
|
||||
providedLines.push_back(std::move(lineRunIdxs));
|
||||
}
|
||||
} else if (data.contains("runs") && data["runs"].is_array()) {
|
||||
for (const auto& rj : data["runs"]) parseRun(rj);
|
||||
}
|
||||
std::vector<int> objectIndices;
|
||||
if (data.contains("objectIndices") && data["objectIndices"].is_array()) {
|
||||
for (auto& idx : data["objectIndices"]) objectIndices.push_back(idx.get<int>());
|
||||
}
|
||||
double columnLeft = data.value("columnLeft", 0.0);
|
||||
double columnRight = data.value("columnRight", 0.0);
|
||||
double firstBaselineY = data.value("firstBaselineY", 0.0);
|
||||
double leading = data.value("leading", 0.0);
|
||||
int oldLineCount = data.value("oldLineCount", 1);
|
||||
std::string align = data.value("align", std::string("left"));
|
||||
std::vector<double> lineBaselineY, lineX;
|
||||
if (data.contains("lineBaselineY") && data["lineBaselineY"].is_array())
|
||||
for (const auto& v : data["lineBaselineY"]) lineBaselineY.push_back(v.get<double>());
|
||||
if (data.contains("lineX") && data["lineX"].is_array())
|
||||
for (const auto& v : data["lineX"]) lineX.push_back(v.get<double>());
|
||||
double columnWidth = columnRight - columnLeft;
|
||||
double pushColumnLeft = data.value("pushColumnLeft", columnLeft);
|
||||
std::string paraId;
|
||||
if (data.contains("paraId") && data["paraId"].is_string())
|
||||
paraId = data["paraId"].get<std::string>();
|
||||
|
||||
if (runs.empty() || objectIndices.empty() || columnWidth <= 1.0 || leading <= 0.0) {
|
||||
spdlog::warn("reflow_paragraph: insufficient layout data, skipping");
|
||||
return {};
|
||||
}
|
||||
|
||||
FPDF_PAGE page = FPDF_LoadPage(doc_, pageIndex);
|
||||
if (!page) {
|
||||
spdlog::error("Failed to load page {} for reflow_paragraph", pageIndex);
|
||||
return std::unexpected(EngineError::Unknown);
|
||||
}
|
||||
|
||||
PageBand startBand = repagComputeBand(page, pushColumnLeft, columnRight);
|
||||
double bottomLimitY = startBand.valid ? startBand.bottomLimitY : -1e18;
|
||||
std::vector<double> anchoredCentersStart =
|
||||
startBand.valid ? repagAnchoredCenters(page, pushColumnLeft, columnRight, bottomLimitY)
|
||||
: std::vector<double>{};
|
||||
|
||||
if (!paraId.empty()) {
|
||||
int cont = repagRemoveContinuations(doc_, pageIndex, paraId, pushColumnLeft, columnRight);
|
||||
if (cont > 0)
|
||||
spdlog::info("reflow_paragraph: removed continuation of paraId={} on {} page(s)", paraId, cont);
|
||||
}
|
||||
|
||||
std::vector<int> paragraphSet = objectIndices;
|
||||
{
|
||||
float ul = 0, ub = 0, ur = 0, ut = 0; bool haveUnion = false;
|
||||
for (int idx : objectIndices) {
|
||||
FPDF_PAGEOBJECT o = FPDFPage_GetObject(page, idx);
|
||||
if (!o) continue;
|
||||
float l = 0, b = 0, r = 0, t = 0;
|
||||
if (!FPDFPageObj_GetBounds(o, &l, &b, &r, &t)) continue;
|
||||
if (!haveUnion) { ul = l; ub = b; ur = r; ut = t; haveUnion = true; }
|
||||
else {
|
||||
if (l < ul) ul = l; if (b < ub) ub = b;
|
||||
if (r > ur) ur = r; if (t > ut) ut = t;
|
||||
}
|
||||
}
|
||||
if (haveUnion) {
|
||||
const float eps = 0.5f;
|
||||
int nObjs = FPDFPage_CountObjects(page);
|
||||
int adopted = 0;
|
||||
for (int k = 0; k < nObjs; ++k) {
|
||||
if (std::find(objectIndices.begin(), objectIndices.end(), k) != objectIndices.end()) continue;
|
||||
FPDF_PAGEOBJECT o = FPDFPage_GetObject(page, k);
|
||||
if (!o || FPDFPageObj_GetType(o) != FPDF_PAGEOBJ_TEXT) continue;
|
||||
float l = 0, b = 0, r = 0, t = 0;
|
||||
if (!FPDFPageObj_GetBounds(o, &l, &b, &r, &t)) continue;
|
||||
if (l >= ul - eps && b >= ub - eps && r <= ur + eps && t <= ut + eps) {
|
||||
paragraphSet.push_back(k);
|
||||
adopted++;
|
||||
spdlog::debug("reflow_paragraph: adopted leftover text object idx={} inside paragraph bbox (not in objectIndices) -> prevents bulge/merge + font substitution", k);
|
||||
}
|
||||
}
|
||||
if (adopted > 0)
|
||||
spdlog::debug("reflow_paragraph: geometric backstop adopted {} object(s) the model omitted", adopted);
|
||||
}
|
||||
}
|
||||
|
||||
if (!paraId.empty()) {
|
||||
int nObjs = FPDFPage_CountObjects(page);
|
||||
int adoptedById = 0;
|
||||
for (int k = 0; k < nObjs; ++k) {
|
||||
if (std::find(paragraphSet.begin(), paragraphSet.end(), k) != paragraphSet.end()) continue;
|
||||
FPDF_PAGEOBJECT o = FPDFPage_GetObject(page, k);
|
||||
if (o && repagGetParaId(o) == paraId) { paragraphSet.push_back(k); adoptedById++; }
|
||||
}
|
||||
if (adoptedById > 0)
|
||||
spdlog::debug("reflow_paragraph: adopted {} anchor-page object(s) by paraId", adoptedById);
|
||||
}
|
||||
|
||||
{
|
||||
std::unordered_map<std::string, double> exactByBaseName;
|
||||
std::vector<double> exactSizes;
|
||||
for (int idx : paragraphSet) {
|
||||
FPDF_PAGEOBJECT o = FPDFPage_GetObject(page, idx);
|
||||
if (!o || FPDFPageObj_GetType(o) != FPDF_PAGEOBJ_TEXT) continue;
|
||||
FS_MATRIX mtx;
|
||||
if (!FPDFPageObj_GetMatrix(o, &mtx)) continue;
|
||||
float nominal = 0.0f;
|
||||
if (!FPDFTextObj_GetFontSize(o, &nominal)) continue;
|
||||
double scale = std::sqrt(static_cast<double>(mtx.a) * mtx.a +
|
||||
static_cast<double>(mtx.b) * mtx.b);
|
||||
double exact = static_cast<double>(nominal) * scale;
|
||||
if (exact <= 0.1) continue;
|
||||
exactSizes.push_back(exact);
|
||||
FPDF_FONT fo = FPDFTextObj_GetFont(o);
|
||||
if (!fo) continue;
|
||||
size_t nl = FPDFFont_GetBaseFontName(fo, nullptr, 0);
|
||||
if (nl == 0) continue;
|
||||
std::vector<char> nb(nl);
|
||||
if (FPDFFont_GetBaseFontName(fo, nb.data(), nl) == 0) continue;
|
||||
std::string bn(nb.data());
|
||||
if (!exactByBaseName.count(bn)) exactByBaseName[bn] = exact;
|
||||
}
|
||||
double paraExact = 0.0;
|
||||
if (!exactSizes.empty()) {
|
||||
std::sort(exactSizes.begin(), exactSizes.end());
|
||||
paraExact = exactSizes[exactSizes.size() / 2];
|
||||
}
|
||||
if (paraExact > 0.1) {
|
||||
for (auto& rs : runs) {
|
||||
std::string bn = baseNameFromInternalFontId(rs.internalFontId);
|
||||
auto it = exactByBaseName.find(bn);
|
||||
rs.fontSize = (it != exactByBaseName.end() && it->second > 0.1) ? it->second : paraExact;
|
||||
}
|
||||
spdlog::info("reflow_paragraph: size-exact override paraExact={:.2f} ({} font(s))",
|
||||
paraExact, exactByBaseName.size());
|
||||
}
|
||||
}
|
||||
|
||||
std::vector<EmissionFont> runFonts(runs.size());
|
||||
auto toCodepoints = [](const std::string& s) {
|
||||
auto u16 = utf8_to_utf16le(s);
|
||||
std::vector<uint32_t> cps;
|
||||
for (size_t i = 0; i < u16.size();) {
|
||||
uint32_t cp = u16[i];
|
||||
if (cp >= 0xD800 && cp <= 0xDBFF && i + 1 < u16.size()) {
|
||||
uint32_t low = u16[i + 1];
|
||||
if (low >= 0xDC00 && low <= 0xDFFF) { cp = 0x10000 + ((cp - 0xD800) << 10) + (low - 0xDC00); i += 2; }
|
||||
else i += 1;
|
||||
} else i += 1;
|
||||
cps.push_back(cp);
|
||||
}
|
||||
return cps;
|
||||
};
|
||||
{
|
||||
std::unordered_map<std::string, std::vector<uint32_t>> fontCps;
|
||||
for (const auto& rs : runs) {
|
||||
auto cps = toCodepoints(rs.text);
|
||||
auto& dst = fontCps[rs.internalFontId];
|
||||
dst.insert(dst.end(), cps.begin(), cps.end());
|
||||
}
|
||||
auto resolveOrigFont = [&](const std::string& fid) -> FPDF_FONT {
|
||||
const std::string expected = baseNameFromInternalFontId(fid);
|
||||
for (int idx : paragraphSet) {
|
||||
FPDF_PAGEOBJECT o = FPDFPage_GetObject(page, idx);
|
||||
if (!o || FPDFPageObj_GetType(o) != FPDF_PAGEOBJ_TEXT) continue;
|
||||
FPDF_FONT fo = FPDFTextObj_GetFont(o);
|
||||
if (!fo) continue;
|
||||
size_t nl = FPDFFont_GetBaseFontName(fo, nullptr, 0);
|
||||
if (nl == 0) continue;
|
||||
std::vector<char> nb(nl);
|
||||
if (FPDFFont_GetBaseFontName(fo, nb.data(), nl) == 0) continue;
|
||||
if (std::string(nb.data()) == expected) return fo;
|
||||
}
|
||||
return nullptr;
|
||||
};
|
||||
std::unordered_map<std::string, EmissionFont> fontByFid;
|
||||
for (const auto& rs : runs) {
|
||||
if (!fontByFid.count(rs.internalFontId))
|
||||
fontByFid[rs.internalFontId] = loadEmissionFont(pageIndex, rs.internalFontId, rs.fontSize, fontCps[rs.internalFontId], paragraphSet, resolveOrigFont(rs.internalFontId));
|
||||
}
|
||||
for (size_t ri = 0; ri < runs.size(); ++ri) runFonts[ri] = fontByFid[runs[ri].internalFontId];
|
||||
|
||||
for (size_t ri = 0; ri < runs.size(); ++ri) {
|
||||
const fonts::FontFace* face = runFonts[ri].measureFace
|
||||
? runFonts[ri].measureFace.get()
|
||||
: (runFonts[ri].resolved ? &runFonts[ri].resolved->getFontFace() : nullptr);
|
||||
runs[ri].text = normalizeReflowText(runs[ri].text, face);
|
||||
}
|
||||
}
|
||||
|
||||
fonts::HbShaper shaper;
|
||||
constexpr unsigned int kRefSize = 1000;
|
||||
auto perCharAdvances = [&](size_t runIdx, const std::string& text) -> std::vector<double> {
|
||||
std::vector<double> out(text.size(), 0.0);
|
||||
if (text.empty()) return out;
|
||||
auto& rf = runFonts[runIdx];
|
||||
double size = runs[runIdx].fontSize > 0 ? runs[runIdx].fontSize : 12.0;
|
||||
double scale = size / static_cast<double>(kRefSize);
|
||||
fonts::FontFace* face = rf.measureFace ? rf.measureFace.get()
|
||||
: (rf.resolved ? &rf.resolved->getFontFace() : nullptr);
|
||||
if (face) {
|
||||
auto glyphs = shaper.shapeRun(text, *face, kRefSize);
|
||||
if (glyphs.size() == text.size()) {
|
||||
for (size_t i = 0; i < text.size(); ++i) out[i] = glyphs[i].advanceX * scale;
|
||||
return out;
|
||||
}
|
||||
double w = 0.0; for (auto& gph : glyphs) w += gph.advanceX;
|
||||
double per = w * scale / static_cast<double>(text.size());
|
||||
for (auto& v : out) v = per;
|
||||
return out;
|
||||
}
|
||||
for (auto& v : out) v = size * 0.5;
|
||||
return out;
|
||||
};
|
||||
|
||||
std::vector<std::vector<double>> runCharAdv(runs.size());
|
||||
std::vector<char> runPerChar(runs.size(), 0);
|
||||
for (size_t ri = 0; ri < runs.size(); ++ri) {
|
||||
if (runs[ri].advances.size() == runs[ri].text.size() && !runs[ri].text.empty()) {
|
||||
runCharAdv[ri] = runs[ri].advances;
|
||||
auto natural = perCharAdvances(ri, runs[ri].text);
|
||||
bool diverges = natural.size() != runCharAdv[ri].size();
|
||||
for (size_t c = 0; !diverges && c < natural.size(); ++c)
|
||||
if (std::abs(natural[c] - runCharAdv[ri][c]) > 0.05) diverges = true;
|
||||
runPerChar[ri] = diverges ? 1 : 0;
|
||||
} else {
|
||||
runCharAdv[ri] = perCharAdvances(ri, runs[ri].text);
|
||||
}
|
||||
}
|
||||
auto charAdvAt = [&](int ri, size_t off) -> double {
|
||||
const auto& v = runCharAdv[static_cast<size_t>(ri)];
|
||||
return off < v.size() ? v[off] : 0.0;
|
||||
};
|
||||
|
||||
auto isSpace = [](char ch) { return ch == ' ' || ch == '\t' || ch == '\n' || ch == '\r'; };
|
||||
struct Seg { int runIdx; std::string text; double width; size_t off; };
|
||||
struct Word { std::vector<Seg> segs; double width; double spaceAfter; };
|
||||
std::vector<Word> words;
|
||||
|
||||
auto tokenize = [&](const std::vector<int>& runIdxs) -> std::vector<size_t> {
|
||||
std::string ft; std::vector<int> sof; std::vector<size_t> cof;
|
||||
for (int ri : runIdxs) {
|
||||
size_t off = 0;
|
||||
for (char ch : runs[ri].text) { ft.push_back(ch); sof.push_back(ri); cof.push_back(off++); }
|
||||
}
|
||||
std::vector<size_t> out;
|
||||
size_t i = 0;
|
||||
while (i < ft.size()) {
|
||||
if (isSpace(ft[i])) { i++; continue; }
|
||||
Word w; w.width = 0.0; w.spaceAfter = 0.0;
|
||||
while (i < ft.size() && !isSpace(ft[i])) {
|
||||
int st = sof[i]; size_t segOff = cof[i]; std::string frag; double fw = 0.0;
|
||||
while (i < ft.size() && !isSpace(ft[i]) && sof[i] == st) { frag.push_back(ft[i]); fw += charAdvAt(st, cof[i]); i++; }
|
||||
w.segs.push_back({st, frag, fw, segOff}); w.width += fw;
|
||||
}
|
||||
while (i < ft.size() && isSpace(ft[i])) { w.spaceAfter += charAdvAt(sof[i], cof[i]); i++; }
|
||||
out.push_back(words.size());
|
||||
words.push_back(std::move(w));
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
std::vector<std::vector<size_t>> lines;
|
||||
if (hasProvidedLines) {
|
||||
for (const auto& lineRunIdxs : providedLines) {
|
||||
auto wi = tokenize(lineRunIdxs);
|
||||
if (!wi.empty()) lines.push_back(std::move(wi));
|
||||
}
|
||||
} else {
|
||||
std::vector<int> allRuns(runs.size());
|
||||
for (size_t ri = 0; ri < runs.size(); ++ri) allRuns[ri] = static_cast<int>(ri);
|
||||
auto allWords = tokenize(allRuns);
|
||||
std::vector<size_t> cur; double curW = 0.0;
|
||||
for (size_t k = 0; k < allWords.size(); ++k) {
|
||||
size_t wi = allWords[k];
|
||||
double gap = cur.empty() ? 0.0 : words[allWords[k - 1]].spaceAfter;
|
||||
if (!cur.empty() && curW + gap + words[wi].width > columnWidth) {
|
||||
lines.push_back(cur); cur.clear();
|
||||
cur.push_back(wi); curW = words[wi].width;
|
||||
} else {
|
||||
cur.push_back(wi); curW += gap + words[wi].width;
|
||||
}
|
||||
}
|
||||
if (!cur.empty()) lines.push_back(cur);
|
||||
}
|
||||
if (words.empty() || lines.empty()) { FPDF_ClosePage(page); return {}; }
|
||||
int newLineCount = static_cast<int>(lines.size());
|
||||
|
||||
double deltaH = (newLineCount - oldLineCount) * leading;
|
||||
double paragraphBottomBaseline = firstBaselineY - (oldLineCount - 1) * leading;
|
||||
if (std::abs(deltaH) > 0.01) {
|
||||
double threshold = paragraphBottomBaseline - 0.5 * leading;
|
||||
int nObjs = FPDFPage_CountObjects(page);
|
||||
int pushed = 0;
|
||||
for (int k = 0; k < nObjs; ++k) {
|
||||
if (std::find(paragraphSet.begin(), paragraphSet.end(), k) != paragraphSet.end()) continue;
|
||||
FPDF_PAGEOBJECT o = FPDFPage_GetObject(page, k);
|
||||
if (!o) continue;
|
||||
float l = 0, bo = 0, rr = 0, tt = 0;
|
||||
if (!FPDFPageObj_GetBounds(o, &l, &bo, &rr, &tt)) continue;
|
||||
double cY = (bo + tt) / 2.0;
|
||||
if (cY < threshold && cY >= bottomLimitY && rr > pushColumnLeft && l < columnRight) {
|
||||
FPDFPageObj_Transform(o, 1.0, 0.0, 0.0, 1.0, 0.0, -deltaH);
|
||||
pushed++;
|
||||
}
|
||||
}
|
||||
spdlog::info("reflow_paragraph: lines {}->{}, deltaH={}, pushed {} objects", oldLineCount, newLineCount, deltaH, pushed);
|
||||
}
|
||||
|
||||
std::sort(paragraphSet.begin(), paragraphSet.end(), std::greater<int>());
|
||||
int minIndex = paragraphSet.back();
|
||||
for (int idx : paragraphSet) {
|
||||
FPDF_PAGEOBJECT o = FPDFPage_GetObject(page, idx);
|
||||
if (o) { FPDFPage_RemoveObject(page, o); FPDFPageObj_Destroy(o); }
|
||||
}
|
||||
|
||||
nlohmann::json layoutLines = nlohmann::json::array();
|
||||
for (size_t li = 0; li < lines.size(); ++li) {
|
||||
double baselineY = (li < lineBaselineY.size())
|
||||
? lineBaselineY[li] : firstBaselineY - static_cast<double>(li) * leading;
|
||||
auto& lw = lines[li];
|
||||
double naturalW = 0.0;
|
||||
for (size_t k = 0; k < lw.size(); ++k) {
|
||||
naturalW += words[lw[k]].width;
|
||||
if (k > 0) naturalW += words[lw[k - 1]].spaceAfter;
|
||||
}
|
||||
bool justifyThis = (align == "justify") && (li + 1 < lines.size()) && lw.size() > 1;
|
||||
double extraPerGap = 0.0;
|
||||
if (justifyThis) { double slack = columnWidth - naturalW; if (slack > 0) extraPerGap = slack / static_cast<double>(lw.size() - 1); }
|
||||
|
||||
std::string lineText;
|
||||
std::vector<double> adv;
|
||||
double lineFontSize = 0.0;
|
||||
double x = (li < lineX.size()) ? lineX[li] : columnLeft;
|
||||
if (li >= lineX.size()) {
|
||||
if (align == "right") x = columnLeft + (columnWidth - naturalW);
|
||||
else if (align == "center") x = columnLeft + (columnWidth - naturalW) / 2.0;
|
||||
}
|
||||
const double lineStartX = x;
|
||||
for (size_t k = 0; k < lw.size(); ++k) {
|
||||
size_t wi = lw[k];
|
||||
if (k > 0) {
|
||||
double gap = words[lw[k - 1]].spaceAfter + (justifyThis ? extraPerGap : 0.0);
|
||||
x += gap;
|
||||
lineText.push_back(' ');
|
||||
adv.push_back(gap);
|
||||
}
|
||||
double segX = x;
|
||||
for (auto& seg : words[wi].segs) {
|
||||
if (lineFontSize <= 0.0) lineFontSize = runs[seg.runIdx].fontSize;
|
||||
FPDF_FONT font = runFonts[seg.runIdx].font;
|
||||
auto emitObj = [&](const std::string& s, double atX) {
|
||||
if (!font || s.empty()) return;
|
||||
FPDF_PAGEOBJECT obj = FPDFPageObj_CreateTextObj(doc_, font, static_cast<float>(runs[seg.runIdx].fontSize));
|
||||
if (!obj) return;
|
||||
FPDFPageObj_SetFillColor(obj, runs[seg.runIdx].r, runs[seg.runIdx].g, runs[seg.runIdx].b, 255);
|
||||
auto u16 = utf8_to_utf16le(s); u16.push_back(0);
|
||||
FPDFText_SetText(obj, reinterpret_cast<FPDF_WIDESTRING>(u16.data()));
|
||||
FPDFPageObj_Transform(obj, 1.0, 0.0, 0.0, 1.0, atX, baselineY);
|
||||
repagSetParaId(doc_, obj, paraId);
|
||||
FPDFPage_InsertObjectAtIndex(page, obj, minIndex);
|
||||
};
|
||||
if (runPerChar[seg.runIdx]) {
|
||||
double gx = segX;
|
||||
for (size_t c = 0; c < seg.text.size(); ++c) {
|
||||
double a = charAdvAt(seg.runIdx, seg.off + c);
|
||||
if (seg.text[c] != ' ') emitObj(std::string(1, seg.text[c]), gx);
|
||||
lineText.push_back(seg.text[c]); adv.push_back(a);
|
||||
gx += a;
|
||||
}
|
||||
} else {
|
||||
emitObj(seg.text, segX);
|
||||
for (size_t c = 0; c < seg.text.size(); ++c) { lineText.push_back(seg.text[c]); adv.push_back(charAdvAt(seg.runIdx, seg.off + c)); }
|
||||
}
|
||||
segX += seg.width;
|
||||
}
|
||||
x += words[wi].width;
|
||||
}
|
||||
layoutLines.push_back({
|
||||
{"baselineY", baselineY}, {"x0", lineStartX},
|
||||
{"fontSize", lineFontSize > 0 ? lineFontSize : leading / 1.2},
|
||||
{"text", lineText}, {"adv", adv},
|
||||
});
|
||||
}
|
||||
|
||||
{
|
||||
int pageOff = 0; double cur = 0.0; bool flowing = false;
|
||||
for (auto& ln : layoutLines) {
|
||||
double b = ln["baselineY"].get<double>();
|
||||
if (!flowing && startBand.valid && b < startBand.bottomLimitY - 0.01) {
|
||||
flowing = true; pageOff = 1; cur = startBand.placementTopY;
|
||||
}
|
||||
if (flowing) {
|
||||
if (cur < startBand.bottomLimitY - 0.01) { pageOff++; cur = startBand.placementTopY; }
|
||||
ln["baselineY"] = cur; cur -= leading;
|
||||
}
|
||||
ln["pageIndex"] = pageIndex + pageOff;
|
||||
}
|
||||
}
|
||||
lastReflowLayout_ = nlohmann::json{
|
||||
{"columnLeft", columnLeft}, {"anchorPage", pageIndex}, {"lines", layoutLines}}.dump();
|
||||
|
||||
if (!FPDFPage_GenerateContent(page)) {
|
||||
spdlog::error("Failed to generate page content after reflow_paragraph");
|
||||
}
|
||||
FPDF_ClosePage(page);
|
||||
|
||||
lastReflowOverflowed_ = false;
|
||||
if (startBand.valid) {
|
||||
int moved = 0;
|
||||
int added = repaginateForward(doc_, pageIndex, columnLeft, columnRight,
|
||||
pushColumnLeft, leading, anchoredCentersStart, &moved);
|
||||
int removed = repaginateBackward(doc_, pageIndex, columnLeft, columnRight,
|
||||
pushColumnLeft, leading);
|
||||
lastReflowOverflowed_ = (moved > 0 || added > 0 || removed > 0);
|
||||
if (added > 0 || removed > 0)
|
||||
spdlog::info("reflow_paragraph: cross-page reflow added {} / removed {} page(s)",
|
||||
added, removed);
|
||||
}
|
||||
return {};
|
||||
#else
|
||||
(void)op; (void)pageIndex;
|
||||
return std::unexpected(EngineError::Unknown);
|
||||
#endif
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,610 @@
|
||||
#include "parser/pdfium_internal.hpp"
|
||||
|
||||
namespace pdfengine::parser {
|
||||
|
||||
std::expected<void, EngineError> PdfiumDocument::applyOp_replaceText(const nlohmann::json& op, int pageIndex) {
|
||||
#ifdef PDFENGINE_WITH_PDFIUM
|
||||
std::vector<int> objectIndices;
|
||||
std::string newText = "";
|
||||
std::string internalFontId = "";
|
||||
double fontSize = -1.0;
|
||||
bool disableJustify = false;
|
||||
|
||||
if (op.contains("data") && op["data"].is_object()) {
|
||||
auto data = op["data"];
|
||||
if (data.contains("objectIndices") && data["objectIndices"].is_array()) {
|
||||
for (auto& idx : data["objectIndices"]) {
|
||||
objectIndices.push_back(idx.get<int>());
|
||||
}
|
||||
}
|
||||
newText = data.value("text", "");
|
||||
internalFontId = data.value("internalFontId", "");
|
||||
if (data.contains("fontSize")) {
|
||||
fontSize = data["fontSize"].get<double>();
|
||||
}
|
||||
disableJustify = data.value("disableJustify", false);
|
||||
} else {
|
||||
if (op.contains("objectIndices") && op["objectIndices"].is_array()) {
|
||||
for (auto& idx : op["objectIndices"]) {
|
||||
objectIndices.push_back(idx.get<int>());
|
||||
}
|
||||
}
|
||||
newText = op.value("text", "");
|
||||
internalFontId = op.value("internalFontId", "");
|
||||
if (op.contains("fontSize")) {
|
||||
fontSize = op["fontSize"].get<double>();
|
||||
}
|
||||
}
|
||||
|
||||
if (objectIndices.empty()) {
|
||||
spdlog::warn("replace_text has empty objectIndices, nothing to replace");
|
||||
return {};
|
||||
}
|
||||
|
||||
FPDF_PAGE page = FPDF_LoadPage(doc_, pageIndex);
|
||||
if (!page) {
|
||||
spdlog::error("Failed to load page index {} for replace_text", pageIndex);
|
||||
return std::unexpected(EngineError::Unknown);
|
||||
}
|
||||
|
||||
std::sort(objectIndices.begin(), objectIndices.end(), std::greater<int>());
|
||||
|
||||
int minIndex = objectIndices.back();
|
||||
|
||||
FPDF_PAGEOBJECT origObj = FPDFPage_GetObject(page, minIndex);
|
||||
if (!origObj) {
|
||||
spdlog::error("Failed to get original text object at index {}", minIndex);
|
||||
FPDF_ClosePage(page);
|
||||
return std::unexpected(EngineError::Unknown);
|
||||
}
|
||||
|
||||
double a = 1.0, b = 0.0, c = 0.0, d = 1.0, e = 0.0, f = 0.0;
|
||||
FS_MATRIX matrix;
|
||||
if (FPDFPageObj_GetMatrix(origObj, &matrix)) {
|
||||
a = matrix.a;
|
||||
b = matrix.b;
|
||||
c = matrix.c;
|
||||
d = matrix.d;
|
||||
e = matrix.e;
|
||||
f = matrix.f;
|
||||
}
|
||||
|
||||
unsigned int r = 0, g = 0, b_color = 0, a_color = 255;
|
||||
FPDFPageObj_GetFillColor(origObj, &r, &g, &b_color, &a_color);
|
||||
|
||||
if (fontSize < 0.0) {
|
||||
float sizeVal = 12.0f;
|
||||
if (FPDFTextObj_GetFontSize(origObj, &sizeVal)) {
|
||||
fontSize = sizeVal;
|
||||
} else {
|
||||
fontSize = 12.0;
|
||||
}
|
||||
}
|
||||
|
||||
FPDF_TEXT_RENDERMODE renderMode = static_cast<FPDF_TEXT_RENDERMODE>(FPDFTextObj_GetTextRenderMode(origObj));
|
||||
|
||||
std::string fontName = "Helvetica";
|
||||
std::string origFontName = "";
|
||||
bool bold = false;
|
||||
bool italic = false;
|
||||
FPDF_FONT origFont = FPDFTextObj_GetFont(origObj);
|
||||
if (origFont) {
|
||||
size_t nameLen = FPDFFont_GetBaseFontName(origFont, nullptr, 0);
|
||||
if (nameLen > 0) {
|
||||
std::vector<char> nameBuf(nameLen);
|
||||
if (FPDFFont_GetBaseFontName(origFont, nameBuf.data(), nameLen) > 0) {
|
||||
origFontName = nameBuf.data();
|
||||
std::string baseName(nameBuf.data());
|
||||
std::string lowerName = baseName;
|
||||
std::transform(lowerName.begin(), lowerName.end(), lowerName.begin(), [](unsigned char c) {
|
||||
return static_cast<char>(std::tolower(c));
|
||||
});
|
||||
|
||||
bold = (lowerName.find("bold") != std::string::npos);
|
||||
italic = (lowerName.find("italic") != std::string::npos || lowerName.find("oblique") != std::string::npos);
|
||||
|
||||
if (lowerName.find("times") != std::string::npos) {
|
||||
if (bold && italic) fontName = "Times-BoldItalic";
|
||||
else if (bold) fontName = "Times-Bold";
|
||||
else if (italic) fontName = "Times-Italic";
|
||||
else fontName = "Times-Roman";
|
||||
} else if (lowerName.find("courier") != std::string::npos) {
|
||||
if (bold && italic) fontName = "Courier-BoldOblique";
|
||||
else if (bold) fontName = "Courier-Bold";
|
||||
else if (italic) fontName = "Courier-Oblique";
|
||||
else fontName = "Courier";
|
||||
} else {
|
||||
if (bold && italic) fontName = "Helvetica-BoldOblique";
|
||||
else if (bold) fontName = "Helvetica-Bold";
|
||||
else if (italic) fontName = "Helvetica-Oblique";
|
||||
else fontName = "Helvetica";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!internalFontId.empty()) {
|
||||
std::string lowerId = internalFontId;
|
||||
std::transform(lowerId.begin(), lowerId.end(), lowerId.begin(), [](unsigned char c) {
|
||||
return static_cast<char>(std::tolower(c));
|
||||
});
|
||||
if (lowerId.find("bolditalic") != std::string::npos) { bold = true; italic = true; }
|
||||
else if (lowerId.find("bold") != std::string::npos) { bold = true; }
|
||||
else if (lowerId.find("italic") != std::string::npos) { italic = true; }
|
||||
else if (lowerId.find("oblique") != std::string::npos) { italic = true; }
|
||||
|
||||
if (lowerId.find("times") != std::string::npos) {
|
||||
if (bold && italic) fontName = "Times-BoldItalic";
|
||||
else if (bold) fontName = "Times-Bold";
|
||||
else if (italic) fontName = "Times-Italic";
|
||||
else fontName = "Times-Roman";
|
||||
} else if (lowerId.find("courier") != std::string::npos) {
|
||||
if (bold && italic) fontName = "Courier-BoldOblique";
|
||||
else if (bold) fontName = "Courier-Bold";
|
||||
else if (italic) fontName = "Courier-Oblique";
|
||||
else fontName = "Courier";
|
||||
} else if (lowerId.find("helvetica") != std::string::npos) {
|
||||
if (bold && italic) fontName = "Helvetica-BoldOblique";
|
||||
else if (bold) fontName = "Helvetica-Bold";
|
||||
else if (italic) fontName = "Helvetica-Oblique";
|
||||
else fontName = "Helvetica";
|
||||
}
|
||||
}
|
||||
|
||||
std::optional<FontInfo> matchedFontInfo;
|
||||
auto fontsRes = getFonts(pageIndex, pageIndex);
|
||||
if (fontsRes.has_value()) {
|
||||
for (const auto& fontInfoEntry : *fontsRes) {
|
||||
if ((!internalFontId.empty() && fontInfoEntry.internalFontId == internalFontId) ||
|
||||
(!origFontName.empty() && fontInfoEntry.fontName == origFontName)) {
|
||||
matchedFontInfo = fontInfoEntry;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
std::shared_ptr<fonts::pdf_fonts::Font> resolvedFont = nullptr;
|
||||
if (matchedFontInfo.has_value()) {
|
||||
auto resolvedFontRes = getResolvedFont(*matchedFontInfo);
|
||||
if (resolvedFontRes.has_value()) {
|
||||
resolvedFont = *resolvedFontRes;
|
||||
spdlog::info("Font Engine: resolved font '{}'", matchedFontInfo->fontName);
|
||||
} else {
|
||||
spdlog::warn("Font Engine: failed to resolve font '{}': {}", matchedFontInfo->fontName, resolvedFontRes.error());
|
||||
}
|
||||
}
|
||||
|
||||
bool fontSupportsAll = true;
|
||||
double totalWidth = 0.0;
|
||||
|
||||
auto utf16 = utf8_to_utf16le(newText);
|
||||
std::vector<uint32_t> unicodeCodepoints;
|
||||
for (size_t i = 0; i < utf16.size(); ) {
|
||||
uint32_t cp = utf16[i];
|
||||
if (cp >= 0xD800 && cp <= 0xDBFF && i + 1 < utf16.size()) {
|
||||
uint32_t low = utf16[i + 1];
|
||||
if (low >= 0xDC00 && low <= 0xDFFF) {
|
||||
cp = 0x10000 + ((cp - 0xD800) << 10) + (low - 0xDC00);
|
||||
i += 2;
|
||||
} else {
|
||||
i += 1;
|
||||
}
|
||||
} else {
|
||||
i += 1;
|
||||
}
|
||||
unicodeCodepoints.push_back(cp);
|
||||
}
|
||||
|
||||
bool isSubsetFont = matchedFontInfo && matchedFontInfo->isSubset;
|
||||
bool subsetLacksGlyphs = false;
|
||||
|
||||
if (resolvedFont) {
|
||||
for (uint32_t cp : unicodeCodepoints) {
|
||||
if (!resolvedFont->hasGlyph(cp)) {
|
||||
if (isSubsetFont) {
|
||||
subsetLacksGlyphs = true;
|
||||
} else {
|
||||
fontSupportsAll = false;
|
||||
}
|
||||
spdlog::warn("Font Engine: Glyph for codepoint {} not found in font {}", cp, matchedFontInfo ? matchedFontInfo->fontName : "Unknown");
|
||||
}
|
||||
}
|
||||
} else {
|
||||
fontSupportsAll = false;
|
||||
}
|
||||
|
||||
bool shapedSuccessful = false;
|
||||
if (resolvedFont) {
|
||||
try {
|
||||
fonts::HbShaper shaper;
|
||||
unsigned int uFontSize = static_cast<unsigned int>(fontSize > 0.0 ? fontSize : 12.0);
|
||||
auto shapedGlyphs = shaper.shapeRun(newText, resolvedFont->getFontFace(), uFontSize);
|
||||
if (!shapedGlyphs.empty()) {
|
||||
totalWidth = 0.0;
|
||||
for (const auto& sg : shapedGlyphs) {
|
||||
totalWidth += sg.advanceX;
|
||||
}
|
||||
shapedSuccessful = true;
|
||||
spdlog::info("Font Engine: HarfBuzz shaped '{}' glyphs, total advance width = {}", shapedGlyphs.size(), totalWidth);
|
||||
}
|
||||
} catch (const std::exception& e) {
|
||||
spdlog::warn("Font Engine: HarfBuzz shaping failed: {}", e.what());
|
||||
} catch (...) {
|
||||
spdlog::warn("Font Engine: HarfBuzz shaping failed with unknown exception");
|
||||
}
|
||||
}
|
||||
|
||||
if (!shapedSuccessful && resolvedFont) {
|
||||
totalWidth = 0.0;
|
||||
for (uint32_t cp : unicodeCodepoints) {
|
||||
double w = resolvedFont->getAdvanceWidth(cp, fontSize);
|
||||
totalWidth += w;
|
||||
}
|
||||
spdlog::info("Font Engine: FreeType fallback total advance width = {}", totalWidth);
|
||||
}
|
||||
|
||||
float origLeft = 999999.0f, origRight = -999999.0f;
|
||||
float origBottom = 999999.0f, origTop = -999999.0f;
|
||||
bool hasOrigBounds = false;
|
||||
for (int idx : objectIndices) {
|
||||
FPDF_PAGEOBJECT obj = FPDFPage_GetObject(page, idx);
|
||||
if (obj) {
|
||||
float left = 0.0f, bottom = 0.0f, right = 0.0f, top = 0.0f;
|
||||
if (FPDFPageObj_GetBounds(obj, &left, &bottom, &right, &top)) {
|
||||
if (left < origLeft) origLeft = left;
|
||||
if (right > origRight) origRight = right;
|
||||
if (bottom < origBottom) origBottom = bottom;
|
||||
if (top > origTop) origTop = top;
|
||||
hasOrigBounds = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
double origWidth = 0.0;
|
||||
double origCenterY = 0.0;
|
||||
if (hasOrigBounds) {
|
||||
origWidth = origRight - origLeft;
|
||||
origCenterY = (origBottom + origTop) / 2.0;
|
||||
}
|
||||
|
||||
bool axisAligned = (std::abs(b) < 1e-6 && std::abs(c) < 1e-6 && a > 0.0 && d > 0.0);
|
||||
double colRight = origRight;
|
||||
bool sawSibling = false;
|
||||
if (axisAligned && hasOrigBounds) {
|
||||
int nObjForCol = FPDFPage_CountObjects(page);
|
||||
for (int k = 0; k < nObjForCol; ++k) {
|
||||
if (std::find(objectIndices.begin(), objectIndices.end(), k) != objectIndices.end()) continue;
|
||||
FPDF_PAGEOBJECT o = FPDFPage_GetObject(page, k);
|
||||
if (!o || FPDFPageObj_GetType(o) != FPDF_PAGEOBJ_TEXT) continue;
|
||||
float l = 0, bo = 0, rr = 0, tt = 0;
|
||||
if (!FPDFPageObj_GetBounds(o, &l, &bo, &rr, &tt)) continue;
|
||||
if (std::abs(l - origLeft) <= 3.0) { sawSibling = true; if (rr > colRight) colRight = rr; }
|
||||
}
|
||||
}
|
||||
double justifyTol = (std::max)(4.0, (colRight - origLeft) * 0.02);
|
||||
bool wasJustified = !disableJustify && axisAligned && resolvedFont && hasOrigBounds && sawSibling &&
|
||||
(colRight - origLeft) > 20.0 && (origRight >= colRight - justifyTol);
|
||||
|
||||
double deltaX = 0.0;
|
||||
if (hasOrigBounds) {
|
||||
deltaX = totalWidth - origWidth;
|
||||
spdlog::info("Reflow Engine: origWidth = {}, newWidth = {}, deltaX = {}", origWidth, totalWidth, deltaX);
|
||||
}
|
||||
|
||||
if (!wasJustified && !disableJustify && hasOrigBounds && std::abs(deltaX) > 0.001) {
|
||||
int pageObjCount = FPDFPage_CountObjects(page);
|
||||
double tolerance = (std::max)(5.0, fontSize * 0.5);
|
||||
int reflowedCount = 0;
|
||||
|
||||
for (int k = 0; k < pageObjCount; ++k) {
|
||||
if (std::find(objectIndices.begin(), objectIndices.end(), k) != objectIndices.end()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
FPDF_PAGEOBJECT otherObj = FPDFPage_GetObject(page, k);
|
||||
if (otherObj && FPDFPageObj_GetType(otherObj) == FPDF_PAGEOBJ_TEXT) {
|
||||
float otherLeft = 0.0f, otherBottom = 0.0f, otherRight = 0.0f, otherTop = 0.0f;
|
||||
if (FPDFPageObj_GetBounds(otherObj, &otherLeft, &otherBottom, &otherRight, &otherTop)) {
|
||||
double otherCenterY = (otherBottom + otherTop) / 2.0;
|
||||
if (std::abs(otherCenterY - origCenterY) <= tolerance) {
|
||||
if (otherLeft >= (origRight - 2.0f)) {
|
||||
FPDFPageObj_Transform(otherObj, 1.0, 0.0, 0.0, 1.0, deltaX, 0.0);
|
||||
reflowedCount++;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
spdlog::info("Reflow Engine: shifted {} subsequent text objects on the same line by {}", reflowedCount, deltaX);
|
||||
}
|
||||
|
||||
for (int idx : objectIndices) {
|
||||
FPDF_PAGEOBJECT objToRemove = FPDFPage_GetObject(page, idx);
|
||||
if (objToRemove) {
|
||||
FPDFPage_RemoveObject(page, objToRemove);
|
||||
FPDFPageObj_Destroy(objToRemove);
|
||||
}
|
||||
}
|
||||
|
||||
FPDF_FONT font = nullptr;
|
||||
std::string cacheKey = "";
|
||||
bool useEmbedded = false;
|
||||
bool useSystem = false;
|
||||
|
||||
if (resolvedFont && matchedFontInfo) {
|
||||
if (matchedFontInfo->isEmbedded && !isSubsetFont && fontSupportsAll) {
|
||||
cacheKey = matchedFontInfo->internalFontId;
|
||||
useEmbedded = true;
|
||||
} else if (matchedFontInfo->isEmbedded && isSubsetFont && !subsetLacksGlyphs) {
|
||||
cacheKey = matchedFontInfo->internalFontId;
|
||||
useEmbedded = true;
|
||||
} else if (!matchedFontInfo->isEmbedded) {
|
||||
cacheKey = "standard_" + fontName;
|
||||
} else {
|
||||
cacheKey = "system_embed_" + matchedFontInfo->fontName + "_" + (bold ? "B" : "") + (italic ? "I" : "");
|
||||
useSystem = true;
|
||||
}
|
||||
} else {
|
||||
cacheKey = "standard_" + fontName;
|
||||
}
|
||||
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(loadedFontsMutex_);
|
||||
if (loadedFontsCache_.count(cacheKey)) {
|
||||
font = loadedFontsCache_[cacheKey];
|
||||
spdlog::info("Font Engine: Reusing cached FPDF_FONT for key '{}'", cacheKey);
|
||||
}
|
||||
}
|
||||
|
||||
FPDF_FONT reconFont = nullptr;
|
||||
const fonts::pdf_fonts::ReconstructedFont* reconRf = nullptr;
|
||||
if (matchedFontInfo && matchedFontInfo->isEmbedded && classifyFontFidelity(*matchedFontInfo) != "exact") {
|
||||
reconRf = lookupReconFont(matchedFontInfo->internalFontId);
|
||||
if (reconRf && reconRf->ok) {
|
||||
std::lock_guard<std::mutex> lock(loadedFontsMutex_);
|
||||
const std::string rkey = "recon_" + matchedFontInfo->internalFontId;
|
||||
if (loadedFontsCache_.count(rkey)) reconFont = loadedFontsCache_[rkey];
|
||||
else {
|
||||
loadedFontDataBuffers_[rkey] = reconRf->sfnt;
|
||||
const auto& bytes = loadedFontDataBuffers_[rkey];
|
||||
reconFont = FPDFText_LoadFont(doc_, bytes.data(), static_cast<uint32_t>(bytes.size()), FPDF_FONT_TRUETYPE, true);
|
||||
if (reconFont) loadedFontsCache_[rkey] = reconFont;
|
||||
}
|
||||
if (reconFont) {
|
||||
bool fullyCovered = true;
|
||||
for (uint32_t cp : unicodeCodepoints)
|
||||
if (cp >= 0x20 && !reconRf->coveredUnicode.count(cp)) { fullyCovered = false; break; }
|
||||
if (fullyCovered && !font) {
|
||||
font = reconFont;
|
||||
spdlog::info("Tier-2: replace_text exact reconstructed font '{}'", matchedFontInfo->internalFontId);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!font) {
|
||||
if (useEmbedded) {
|
||||
auto fontDataRes = getFontData(matchedFontInfo->internalFontId);
|
||||
if (fontDataRes.has_value()) {
|
||||
std::lock_guard<std::mutex> lock(loadedFontsMutex_);
|
||||
loadedFontDataBuffers_[cacheKey] = fontDataRes.value();
|
||||
const auto& bytes = loadedFontDataBuffers_[cacheKey];
|
||||
font = FPDFText_LoadFont(doc_, bytes.data(), static_cast<uint32_t>(bytes.size()), FPDF_FONT_TRUETYPE, false);
|
||||
if (font) {
|
||||
spdlog::info("Font Engine: Loaded embedded font '{}' (cache key: {})", matchedFontInfo->fontName, cacheKey);
|
||||
}
|
||||
}
|
||||
} else if (useSystem) {
|
||||
std::string fontPath = fonts::pdf_fonts::FontFallback::getInstance().getFallbackFontPath(
|
||||
matchedFontInfo->normalizedFamily.empty() ? matchedFontInfo->fontName : matchedFontInfo->normalizedFamily,
|
||||
bold,
|
||||
italic
|
||||
);
|
||||
std::ifstream fs(fontPath, std::ios::binary);
|
||||
if (fs) {
|
||||
std::vector<uint8_t> fileBytes((std::istreambuf_iterator<char>(fs)), std::istreambuf_iterator<char>());
|
||||
if (!fileBytes.empty()) {
|
||||
const size_t fullSize = fileBytes.size();
|
||||
std::lock_guard<std::mutex> lock(loadedFontsMutex_);
|
||||
loadedFontDataBuffers_[cacheKey] = std::move(fileBytes);
|
||||
const auto& bytes = loadedFontDataBuffers_[cacheKey];
|
||||
font = FPDFText_LoadFont(doc_, bytes.data(), static_cast<uint32_t>(bytes.size()), FPDF_FONT_TRUETYPE, true);
|
||||
if (font) {
|
||||
spdlog::info("Font Engine: Embedded FULL CID system font '{}' ({} bytes) from '{}'",
|
||||
matchedFontInfo->fontName, fullSize, fontPath);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
spdlog::warn("Font Engine: Failed to open system font file '{}' for embedding", fontPath);
|
||||
}
|
||||
}
|
||||
|
||||
if (!font) {
|
||||
spdlog::info("Font Engine: Loading standard PDF font for replace_text: {}", fontName);
|
||||
font = FPDFText_LoadStandardFont(doc_, fontName.c_str());
|
||||
if (!font) {
|
||||
font = FPDFText_LoadStandardFont(doc_, "Helvetica");
|
||||
}
|
||||
}
|
||||
|
||||
if (font) {
|
||||
std::lock_guard<std::mutex> lock(loadedFontsMutex_);
|
||||
loadedFontsCache_[cacheKey] = font;
|
||||
}
|
||||
}
|
||||
|
||||
if (font) {
|
||||
constexpr unsigned int kRef = 1000;
|
||||
double emToPage = (fontSize > 0.0 ? fontSize : 1.0) * a / static_cast<double>(kRef);
|
||||
auto pageWidthOf = [&](const std::string& s) -> double {
|
||||
if (s.empty() || !resolvedFont) return 0.0;
|
||||
double sum = 0.0;
|
||||
try {
|
||||
fonts::HbShaper sh;
|
||||
auto gl = sh.shapeRun(s, resolvedFont->getFontFace(), kRef);
|
||||
for (const auto& gg : gl) sum += gg.advanceX;
|
||||
} catch (...) {
|
||||
for (unsigned char ch : s) sum += resolvedFont->getAdvanceWidth(ch, kRef);
|
||||
}
|
||||
return sum * emToPage;
|
||||
};
|
||||
|
||||
std::vector<std::string> words;
|
||||
if (wasJustified) {
|
||||
std::string cur;
|
||||
for (char ch : newText) {
|
||||
if (ch == ' ') { if (!cur.empty()) { words.push_back(cur); cur.clear(); } }
|
||||
else cur.push_back(ch);
|
||||
}
|
||||
if (!cur.empty()) words.push_back(cur);
|
||||
}
|
||||
|
||||
auto measuredWidth = [&](const std::vector<unsigned short>& u16le) -> double {
|
||||
FPDF_PAGEOBJECT m = FPDFPageObj_CreateTextObj(doc_, font, static_cast<float>(fontSize));
|
||||
if (!m) return 0.0;
|
||||
FPDFText_SetText(m, reinterpret_cast<FPDF_WIDESTRING>(u16le.data()));
|
||||
FPDFPageObj_Transform(m, a, b, c, d, 0.0, 0.0);
|
||||
float l = 0, bo = 0, rr = 0, tt = 0;
|
||||
double w = FPDFPageObj_GetBounds(m, &l, &bo, &rr, &tt) ? (rr - l) : 0.0;
|
||||
FPDFPageObj_Destroy(m);
|
||||
return w;
|
||||
};
|
||||
|
||||
if (wasJustified && words.size() > 1) {
|
||||
std::vector<double> wpx;
|
||||
wpx.reserve(words.size());
|
||||
double estWords = 0.0;
|
||||
for (const auto& w : words) { double ww = pageWidthOf(w); wpx.push_back(ww); estWords += ww; }
|
||||
double estSpace = pageWidthOf(" ");
|
||||
int gaps = static_cast<int>(words.size()) - 1;
|
||||
double estTotal = estWords + gaps * estSpace;
|
||||
double actualFull = measuredWidth(utf16);
|
||||
double k = (estTotal > 1e-6 && actualFull > 1e-6) ? actualFull / estTotal : 1.0;
|
||||
double targetW = colRight - e;
|
||||
double slack = targetW - actualFull;
|
||||
double extraPerGap = (slack > 0.0) ? slack / gaps : 0.0;
|
||||
spdlog::info("replace_text: justify {} words targetW={:.1f} actualW={:.1f} extraPerGap={:.2f}",
|
||||
words.size(), targetW, actualFull, extraPerGap);
|
||||
double penX = e;
|
||||
for (size_t wi = 0; wi < words.size(); ++wi) {
|
||||
FPDF_PAGEOBJECT wobj = FPDFPageObj_CreateTextObj(doc_, font, static_cast<float>(fontSize));
|
||||
if (wobj) {
|
||||
FPDFPageObj_SetFillColor(wobj, r, g, b_color, a_color);
|
||||
FPDFTextObj_SetTextRenderMode(wobj, renderMode);
|
||||
auto wu = utf8_to_utf16le(words[wi]); wu.push_back(0);
|
||||
FPDFText_SetText(wobj, reinterpret_cast<FPDF_WIDESTRING>(wu.data()));
|
||||
FPDFPageObj_Transform(wobj, a, b, c, d, penX, f);
|
||||
FPDFPage_InsertObjectAtIndex(page, wobj, minIndex);
|
||||
}
|
||||
penX += (wpx[wi] + estSpace) * k + extraPerGap;
|
||||
}
|
||||
} else {
|
||||
bool didHybrid = false;
|
||||
if (reconFont && reconRf && font && font != reconFont && !unicodeCodepoints.empty()) {
|
||||
fonts::FontFace reconFace; bool haveReconFace = reconFace.loadFromMemory(reconRf->sfnt);
|
||||
fonts::FontFace subFace; bool haveSubFace = false;
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(loadedFontsMutex_);
|
||||
auto it = loadedFontDataBuffers_.find(cacheKey);
|
||||
if (it != loadedFontDataBuffers_.end() && !it->second.empty())
|
||||
haveSubFace = subFace.loadFromMemory(it->second);
|
||||
}
|
||||
auto segWidthPage = [&](fonts::FontFace* face, const std::string& s) -> double {
|
||||
if (!face || s.empty()) return 0.0;
|
||||
double sum = 0.0;
|
||||
try { fonts::HbShaper sh; for (const auto& gg : sh.shapeRun(s, *face, kRef)) sum += gg.advanceX; }
|
||||
catch (...) { return 0.0; }
|
||||
return sum * emToPage;
|
||||
};
|
||||
auto isCov = [&](uint32_t cp){ return cp < 0x20 || reconRf->coveredUnicode.count(cp) != 0; };
|
||||
auto cpToU16 = [](uint32_t cp, std::vector<unsigned short>& dst){
|
||||
if (cp <= 0xFFFF) dst.push_back(static_cast<unsigned short>(cp));
|
||||
else { cp -= 0x10000; dst.push_back(static_cast<unsigned short>(0xD800 + (cp >> 10)));
|
||||
dst.push_back(static_cast<unsigned short>(0xDC00 + (cp & 0x3FF))); }
|
||||
};
|
||||
auto cpToU8 = [](uint32_t cp, std::string& d){
|
||||
if (cp < 0x80) d += static_cast<char>(cp);
|
||||
else if (cp < 0x800) { d += static_cast<char>(0xC0 | (cp >> 6)); d += static_cast<char>(0x80 | (cp & 0x3F)); }
|
||||
else if (cp < 0x10000) { d += static_cast<char>(0xE0 | (cp >> 12)); d += static_cast<char>(0x80 | ((cp >> 6) & 0x3F)); d += static_cast<char>(0x80 | (cp & 0x3F)); }
|
||||
else { d += static_cast<char>(0xF0 | (cp >> 18)); d += static_cast<char>(0x80 | ((cp >> 12) & 0x3F)); d += static_cast<char>(0x80 | ((cp >> 6) & 0x3F)); d += static_cast<char>(0x80 | (cp & 0x3F)); }
|
||||
};
|
||||
double penX = e;
|
||||
size_t i = 0;
|
||||
while (i < unicodeCodepoints.size()) {
|
||||
bool cov = isCov(unicodeCodepoints[i]);
|
||||
std::vector<unsigned short> seg; std::string seg8;
|
||||
while (i < unicodeCodepoints.size() && isCov(unicodeCodepoints[i]) == cov) {
|
||||
cpToU16(unicodeCodepoints[i], seg); cpToU8(unicodeCodepoints[i], seg8); ++i;
|
||||
}
|
||||
seg.push_back(0);
|
||||
FPDF_FONT segFont = cov ? reconFont : font;
|
||||
fonts::FontFace* segFace = cov ? (haveReconFace ? &reconFace : nullptr)
|
||||
: (haveSubFace ? &subFace : nullptr);
|
||||
FPDF_PAGEOBJECT obj = FPDFPageObj_CreateTextObj(doc_, segFont, static_cast<float>(fontSize));
|
||||
if (obj) {
|
||||
FPDFPageObj_SetFillColor(obj, r, g, b_color, a_color);
|
||||
FPDFTextObj_SetTextRenderMode(obj, renderMode);
|
||||
FPDFText_SetText(obj, reinterpret_cast<FPDF_WIDESTRING>(seg.data()));
|
||||
FPDFPageObj_Transform(obj, a, b, c, d, penX, f);
|
||||
FPDFPage_InsertObjectAtIndex(page, obj, minIndex);
|
||||
}
|
||||
double adv = segWidthPage(segFace, seg8);
|
||||
if (adv <= 0.0) { // shaping unavailable -> fall back to ink bbox
|
||||
if (obj) { float l=0,bo=0,rr=0,tt=0; if (FPDFPageObj_GetBounds(obj,&l,&bo,&rr,&tt)) adv = rr - l; }
|
||||
}
|
||||
penX += adv;
|
||||
}
|
||||
didHybrid = true;
|
||||
spdlog::info("Tier-2: replace_text HYBRID emission for '{}' (mixed embedded/substitute)", internalFontId);
|
||||
}
|
||||
if (!didHybrid)
|
||||
{
|
||||
double aScale = a;
|
||||
if (disableJustify && hasOrigBounds && axisAligned) {
|
||||
double newW = measuredWidth(utf16);
|
||||
double rowTol = (std::max)(5.0, (origTop - origBottom) * 0.5);
|
||||
double nextLeft = 1e18;
|
||||
int nObj = FPDFPage_CountObjects(page);
|
||||
for (int k = 0; k < nObj; ++k) {
|
||||
if (std::find(objectIndices.begin(), objectIndices.end(), k) != objectIndices.end()) continue;
|
||||
FPDF_PAGEOBJECT o = FPDFPage_GetObject(page, k);
|
||||
if (!o || FPDFPageObj_GetType(o) != FPDF_PAGEOBJ_TEXT) continue;
|
||||
float l = 0, bo = 0, rr = 0, tt = 0;
|
||||
if (!FPDFPageObj_GetBounds(o, &l, &bo, &rr, &tt)) continue;
|
||||
if (std::abs((bo + tt) / 2.0 - origCenterY) <= rowTol && l > origRight + 1.0 && l < nextLeft) {
|
||||
nextLeft = l;
|
||||
}
|
||||
}
|
||||
if (nextLeft < 1e17) {
|
||||
double avail = nextLeft - e - 2.0;
|
||||
if (avail > 1.0 && newW > avail) aScale = a * (avail / newW);
|
||||
}
|
||||
}
|
||||
FPDF_PAGEOBJECT newTextObj = FPDFPageObj_CreateTextObj(doc_, font, static_cast<float>(fontSize));
|
||||
if (newTextObj) {
|
||||
FPDFPageObj_SetFillColor(newTextObj, r, g, b_color, a_color);
|
||||
FPDFTextObj_SetTextRenderMode(newTextObj, renderMode);
|
||||
FPDFText_SetText(newTextObj, reinterpret_cast<FPDF_WIDESTRING>(utf16.data()));
|
||||
FPDFPageObj_Transform(newTextObj, aScale, b, c, d, e, f);
|
||||
|
||||
FPDFPage_InsertObjectAtIndex(page, newTextObj, minIndex);
|
||||
} else {
|
||||
spdlog::error("Failed to create new text object");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!FPDFPage_GenerateContent(page)) {
|
||||
spdlog::error("Failed to generate page content after replace_text");
|
||||
}
|
||||
|
||||
FPDF_ClosePage(page);
|
||||
return {};
|
||||
#else
|
||||
(void)op; (void)pageIndex;
|
||||
return std::unexpected(EngineError::Unknown);
|
||||
#endif
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,648 @@
|
||||
#include "parser/pdfium_internal.hpp"
|
||||
|
||||
namespace pdfengine::parser {
|
||||
|
||||
#ifdef PDFENGINE_WITH_PDFIUM
|
||||
void PdfiumDocument::registerAuxFont(const std::string& internalFontId, const std::vector<uint8_t>& sfnt) {
|
||||
fonts::pdf_fonts::ReconstructedFont rf;
|
||||
if (!sfnt.empty()) {
|
||||
rf.ok = true;
|
||||
fonts::FontFace face;
|
||||
if (face.loadFromMemory(sfnt))
|
||||
for (uint32_t u : face.coveredCodepoints()) rf.coveredUnicode.insert(u);
|
||||
rf.sfnt = sfnt;
|
||||
}
|
||||
std::lock_guard<std::mutex> lock(reconstructedFontsMutex_);
|
||||
reconstructedFonts_[internalFontId] = std::move(rf);
|
||||
}
|
||||
|
||||
std::expected<std::vector<uint8_t>, EngineError>
|
||||
PdfiumDocument::getReconstructedFontData(const std::string& internalFontId) {
|
||||
const auto* rf = lookupReconFont(internalFontId);
|
||||
if (rf && rf->ok && !rf->sfnt.empty()) return rf->sfnt;
|
||||
return std::unexpected(EngineError::Unknown);
|
||||
}
|
||||
|
||||
const fonts::pdf_fonts::ReconstructedFont* PdfiumDocument::lookupReconFont(const std::string& internalFontId) {
|
||||
#ifdef PDFENGINE_WITH_QPDF
|
||||
return getReconstructedEmbeddedFont(internalFontId);
|
||||
#else
|
||||
std::lock_guard<std::mutex> lock(reconstructedFontsMutex_);
|
||||
auto it = reconstructedFonts_.find(internalFontId);
|
||||
return it != reconstructedFonts_.end() ? &it->second : nullptr;
|
||||
#endif
|
||||
}
|
||||
#endif
|
||||
|
||||
#if defined(PDFENGINE_WITH_PDFIUM) && defined(PDFENGINE_WITH_QPDF)
|
||||
const fonts::pdf_fonts::ReconstructedFont*
|
||||
PdfiumDocument::getReconstructedEmbeddedFont(const std::string& internalFontId) {
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(reconstructedFontsMutex_);
|
||||
auto it = reconstructedFonts_.find(internalFontId);
|
||||
if (it != reconstructedFonts_.end()) return &it->second;
|
||||
}
|
||||
fonts::pdf_fonts::ReconstructedFont rf;
|
||||
auto progRes = getFontData(internalFontId);
|
||||
if (progRes.has_value() && !progRes.value().empty() && !memoryBuffer_.empty()) {
|
||||
std::string baseName = baseNameFromInternalFontId(internalFontId);
|
||||
qpdf_layer::QpdfFontExtractor fx;
|
||||
auto mapRes = fx.extractMapping(memoryBuffer_, baseName);
|
||||
if (mapRes.has_value() && mapRes->ok) {
|
||||
rf = fonts::pdf_fonts::EmbeddedFontReconstructor::reconstruct(
|
||||
progRes.value(), mapRes->codeToUnicode, mapRes->identityCidToGid, mapRes->codeToGid);
|
||||
spdlog::info("Tier-2: reconstruct '{}' ok={} covered={}", internalFontId, rf.ok, rf.coveredUnicode.size());
|
||||
}
|
||||
}
|
||||
std::lock_guard<std::mutex> lock(reconstructedFontsMutex_);
|
||||
auto res = reconstructedFonts_.emplace(internalFontId, std::move(rf));
|
||||
return &res.first->second;
|
||||
}
|
||||
#endif
|
||||
|
||||
#ifdef PDFENGINE_WITH_PDFIUM
|
||||
PdfiumDocument::EmissionFont PdfiumDocument::loadEmissionFont(
|
||||
int pageIndex, const std::string& internalFontId, double fontSize,
|
||||
const std::vector<uint32_t>& codepoints, const std::vector<int>& srcObjects,
|
||||
FPDF_FONT reuseFont) {
|
||||
EmissionFont out;
|
||||
(void)fontSize;
|
||||
|
||||
std::string fontName = "Helvetica";
|
||||
bool bold = false, italic = false;
|
||||
{
|
||||
std::string lowerId = internalFontId;
|
||||
std::transform(lowerId.begin(), lowerId.end(), lowerId.begin(),
|
||||
[](unsigned char ch) { return static_cast<char>(std::tolower(ch)); });
|
||||
if (lowerId.find("bolditalic") != std::string::npos) { bold = true; italic = true; }
|
||||
else if (lowerId.find("bold") != std::string::npos) { bold = true; }
|
||||
else if (lowerId.find("italic") != std::string::npos) { italic = true; }
|
||||
else if (lowerId.find("oblique") != std::string::npos) { italic = true; }
|
||||
if (lowerId.find("times") != std::string::npos) {
|
||||
fontName = bold && italic ? "Times-BoldItalic" : bold ? "Times-Bold" : italic ? "Times-Italic" : "Times-Roman";
|
||||
} else if (lowerId.find("courier") != std::string::npos) {
|
||||
fontName = bold && italic ? "Courier-BoldOblique" : bold ? "Courier-Bold" : italic ? "Courier-Oblique" : "Courier";
|
||||
} else {
|
||||
fontName = bold && italic ? "Helvetica-BoldOblique" : bold ? "Helvetica-Bold" : italic ? "Helvetica-Oblique" : "Helvetica";
|
||||
}
|
||||
}
|
||||
|
||||
std::optional<FontInfo> matchedFontInfo;
|
||||
auto fontsRes = getFonts(pageIndex, pageIndex);
|
||||
if (fontsRes.has_value()) {
|
||||
for (const auto& fi : *fontsRes) {
|
||||
if (!internalFontId.empty() && fi.internalFontId == internalFontId) { matchedFontInfo = fi; break; }
|
||||
}
|
||||
}
|
||||
if (matchedFontInfo.has_value()) {
|
||||
auto r = getResolvedFont(*matchedFontInfo);
|
||||
if (r.has_value()) out.resolved = *r;
|
||||
}
|
||||
|
||||
bool fontSupportsAll = true, subsetLacksGlyphs = false;
|
||||
bool isSubsetFont = matchedFontInfo && matchedFontInfo->isSubset;
|
||||
if (out.resolved) {
|
||||
for (uint32_t cp : codepoints) {
|
||||
if (!out.resolved->hasGlyph(cp)) {
|
||||
if (isSubsetFont) subsetLacksGlyphs = true; else fontSupportsAll = false;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
fontSupportsAll = false;
|
||||
}
|
||||
|
||||
std::string cacheKey;
|
||||
bool useEmbedded = false, useSystem = false;
|
||||
if (matchedFontInfo && matchedFontInfo->isEmbedded) {
|
||||
bool subsetProvenLacking = out.resolved && isSubsetFont && subsetLacksGlyphs;
|
||||
bool fullProvenLacking = out.resolved && !isSubsetFont && !fontSupportsAll;
|
||||
if (!subsetProvenLacking && !fullProvenLacking) { cacheKey = matchedFontInfo->internalFontId; useEmbedded = true; }
|
||||
else { cacheKey = "system_embed_" + matchedFontInfo->fontName + std::string("_") + (bold ? "B" : "") + (italic ? "I" : ""); useSystem = true; }
|
||||
} else if (matchedFontInfo && !matchedFontInfo->isEmbedded) {
|
||||
cacheKey = "standard_" + fontName;
|
||||
} else {
|
||||
cacheKey = "standard_" + fontName;
|
||||
}
|
||||
|
||||
{
|
||||
uint64_t h = 1469598103934665603ull;
|
||||
for (uint32_t cp : codepoints) { h ^= cp; h *= 1099511628211ull; }
|
||||
cacheKey += "#" + std::to_string(h);
|
||||
}
|
||||
|
||||
if (useEmbedded && reuseFont && !isSubsetFont) {
|
||||
out.font = reuseFont;
|
||||
if (auto perObj = getFontDataFromObjects(pageIndex, srcObjects, internalFontId);
|
||||
perObj.has_value() && !perObj->empty()) {
|
||||
auto mf = std::make_shared<fonts::FontFace>();
|
||||
if (mf->loadFromMemory(*perObj)) out.measureFace = mf;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(loadedFontsMutex_);
|
||||
if (loadedFontsCache_.count(cacheKey)) {
|
||||
out.font = loadedFontsCache_[cacheKey];
|
||||
auto it = loadedMeasureFaces_.find(cacheKey);
|
||||
if (it != loadedMeasureFaces_.end()) out.measureFace = it->second;
|
||||
}
|
||||
}
|
||||
if (out.font) return out;
|
||||
|
||||
if (matchedFontInfo && matchedFontInfo->isEmbedded && classifyFontFidelity(*matchedFontInfo) != "exact") {
|
||||
const auto* rf = lookupReconFont(internalFontId);
|
||||
if (rf && rf->ok) {
|
||||
bool covered = true;
|
||||
for (uint32_t cp : codepoints) {
|
||||
if (cp >= 0x20 && !rf->coveredUnicode.count(cp)) { covered = false; break; }
|
||||
}
|
||||
if (covered) {
|
||||
std::lock_guard<std::mutex> lock(loadedFontsMutex_);
|
||||
const std::string rkey = "recon_" + internalFontId;
|
||||
if (loadedFontsCache_.count(rkey)) {
|
||||
out.font = loadedFontsCache_[rkey];
|
||||
auto mit = loadedMeasureFaces_.find(rkey);
|
||||
if (mit != loadedMeasureFaces_.end()) out.measureFace = mit->second;
|
||||
} else {
|
||||
loadedFontDataBuffers_[rkey] = rf->sfnt;
|
||||
const auto& bytes = loadedFontDataBuffers_[rkey];
|
||||
out.font = FPDFText_LoadFont(doc_, bytes.data(), static_cast<uint32_t>(bytes.size()), FPDF_FONT_TRUETYPE, true);
|
||||
if (out.font) {
|
||||
loadedFontsCache_[rkey] = out.font;
|
||||
auto mf = std::make_shared<fonts::FontFace>();
|
||||
if (mf->loadFromMemory(bytes)) { loadedMeasureFaces_[rkey] = mf; out.measureFace = mf; }
|
||||
}
|
||||
}
|
||||
if (out.font) { spdlog::info("Tier-2: emit reconstructed embedded font '{}'", internalFontId); return out; }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (useEmbedded) {
|
||||
std::vector<uint8_t> sourceBytes;
|
||||
if (auto perObj = getFontDataFromObjects(pageIndex, srcObjects, matchedFontInfo->internalFontId);
|
||||
perObj.has_value() && !perObj->empty()) {
|
||||
sourceBytes = std::move(*perObj);
|
||||
} else if (auto fontDataRes = getFontData(matchedFontInfo->internalFontId);
|
||||
fontDataRes.has_value() && !fontDataRes.value().empty()) {
|
||||
sourceBytes = std::move(fontDataRes.value());
|
||||
}
|
||||
if (!sourceBytes.empty()) {
|
||||
std::lock_guard<std::mutex> lock(loadedFontsMutex_);
|
||||
loadedFontDataBuffers_[cacheKey] = std::move(sourceBytes);
|
||||
const auto& bytes = loadedFontDataBuffers_[cacheKey];
|
||||
out.font = FPDFText_LoadFont(doc_, bytes.data(), static_cast<uint32_t>(bytes.size()), FPDF_FONT_TRUETYPE, true);
|
||||
}
|
||||
} else if (useSystem) {
|
||||
std::string fontPath = fonts::pdf_fonts::FontFallback::getInstance().getFallbackFontPath(
|
||||
matchedFontInfo->normalizedFamily.empty() ? matchedFontInfo->fontName : matchedFontInfo->normalizedFamily, bold, italic);
|
||||
std::ifstream fs(fontPath, std::ios::binary);
|
||||
if (fs) {
|
||||
std::vector<uint8_t> fileBytes((std::istreambuf_iterator<char>(fs)), std::istreambuf_iterator<char>());
|
||||
if (!fileBytes.empty()) {
|
||||
std::lock_guard<std::mutex> lock(loadedFontsMutex_);
|
||||
loadedFontDataBuffers_[cacheKey] = std::move(fileBytes);
|
||||
const auto& bytes = loadedFontDataBuffers_[cacheKey];
|
||||
out.font = FPDFText_LoadFont(doc_, bytes.data(), static_cast<uint32_t>(bytes.size()), FPDF_FONT_TRUETYPE, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!out.font) {
|
||||
out.font = FPDFText_LoadStandardFont(doc_, fontName.c_str());
|
||||
if (!out.font) out.font = FPDFText_LoadStandardFont(doc_, "Helvetica");
|
||||
}
|
||||
if (out.font) {
|
||||
std::lock_guard<std::mutex> lock(loadedFontsMutex_);
|
||||
loadedFontsCache_[cacheKey] = out.font;
|
||||
auto bit = loadedFontDataBuffers_.find(cacheKey);
|
||||
if (bit != loadedFontDataBuffers_.end() && !bit->second.empty()) {
|
||||
auto mf = std::make_shared<fonts::FontFace>();
|
||||
if (mf->loadFromMemory(bit->second)) { loadedMeasureFaces_[cacheKey] = mf; out.measureFace = mf; }
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
#endif
|
||||
|
||||
std::expected<std::vector<FontInfo>, EngineError> PdfiumPage::getFonts() const {
|
||||
#ifdef PDFENGINE_WITH_PDFIUM
|
||||
if (!page_) {
|
||||
return std::unexpected(EngineError::Unknown);
|
||||
}
|
||||
ensureTextPageLoaded();
|
||||
if (!textPage_) {
|
||||
return std::unexpected(EngineError::Unknown);
|
||||
}
|
||||
|
||||
auto fontPdfDataMap = buildFontPdfDataMap(page_, textPage_);
|
||||
|
||||
std::vector<FontInfo> pageFonts;
|
||||
int charCount = FPDFText_CountChars(textPage_);
|
||||
|
||||
if (charCount < 0 || charCount > 1000000) {
|
||||
spdlog::error("Invalid or excessive character count in page ({}): aborting font extraction", charCount);
|
||||
return pageFonts;
|
||||
}
|
||||
|
||||
auto getFontNameForChar = [this](int charIndex, int& flagsOut) -> std::string {
|
||||
int flags = 0;
|
||||
unsigned long len = FPDFText_GetFontInfo(textPage_, charIndex, nullptr, 0, &flags);
|
||||
if (len > 0) {
|
||||
std::vector<char> buf(len);
|
||||
if (FPDFText_GetFontInfo(textPage_, charIndex, buf.data(), len, &flags) > 0) {
|
||||
flagsOut = flags;
|
||||
return std::string(buf.data());
|
||||
}
|
||||
}
|
||||
return "";
|
||||
};
|
||||
|
||||
struct FontPositionStats {
|
||||
int horizontalSteps = 0;
|
||||
int verticalSteps = 0;
|
||||
double totalFilteredDeltaX = 0.0;
|
||||
double totalFilteredDeltaY = 0.0;
|
||||
double prevX = 0.0, prevY = 0.0;
|
||||
bool hasPrev = false;
|
||||
};
|
||||
std::unordered_map<std::string, FontPositionStats> fontStats;
|
||||
|
||||
struct CharInfo {
|
||||
std::string fontName;
|
||||
int flags;
|
||||
};
|
||||
std::vector<CharInfo> charInfos(charCount);
|
||||
|
||||
for (int i = 0; i < charCount; ++i) {
|
||||
int flags = 0;
|
||||
std::string fontName = getFontNameForChar(i, flags);
|
||||
charInfos[i] = {fontName, flags};
|
||||
|
||||
if (!fontName.empty()) {
|
||||
double ox = 0.0, oy = 0.0;
|
||||
if (FPDFText_GetCharOrigin(textPage_, i, &ox, &oy)) {
|
||||
auto& stats = fontStats[fontName];
|
||||
if (stats.hasPrev) {
|
||||
double dx = std::abs(ox - stats.prevX);
|
||||
double dy = std::abs(oy - stats.prevY);
|
||||
double fontSize = FPDFText_GetFontSize(textPage_, i);
|
||||
|
||||
double maxJump = (std::max)(fontSize * 3.0, 30.0);
|
||||
|
||||
if (dx < maxJump && dy < maxJump) {
|
||||
stats.totalFilteredDeltaX += dx;
|
||||
stats.totalFilteredDeltaY += dy;
|
||||
|
||||
if (dy > dx * 1.5) {
|
||||
stats.verticalSteps++;
|
||||
} else if (dx > dy * 1.5) {
|
||||
stats.horizontalSteps++;
|
||||
}
|
||||
}
|
||||
}
|
||||
stats.prevX = ox;
|
||||
stats.prevY = oy;
|
||||
stats.hasPrev = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
std::unordered_map<std::string, bool> fontIsVertical;
|
||||
for (const auto& [fname, stats] : fontStats) {
|
||||
bool vertical = false;
|
||||
|
||||
if (stats.verticalSteps > 0 || stats.horizontalSteps > 0) {
|
||||
vertical = stats.verticalSteps > stats.horizontalSteps;
|
||||
} else {
|
||||
vertical = (stats.totalFilteredDeltaY > 2.0 * stats.totalFilteredDeltaX) &&
|
||||
(stats.totalFilteredDeltaY > 0.5);
|
||||
}
|
||||
|
||||
fontIsVertical[fname] = vertical;
|
||||
}
|
||||
|
||||
for (int i = 0; i < charCount; ++i) {
|
||||
const std::string& fontName = charInfos[i].fontName;
|
||||
if (fontName.empty()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
auto it = std::find_if(pageFonts.begin(), pageFonts.end(), [&](const FontInfo& f) {
|
||||
return f.fontName == fontName;
|
||||
});
|
||||
if (it != pageFonts.end()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
FontInfo f;
|
||||
f.fontName = fontName;
|
||||
f.flags = static_cast<uint32_t>(charInfos[i].flags);
|
||||
|
||||
deduceFontMetadata(f);
|
||||
|
||||
auto pdfDataIt = fontPdfDataMap.find(fontName);
|
||||
if (pdfDataIt != fontPdfDataMap.end()) {
|
||||
const FontPdfData& pd = pdfDataIt->second;
|
||||
|
||||
auto lname = fontName;
|
||||
std::transform(lname.begin(), lname.end(), lname.begin(), [](unsigned char c) { return static_cast<char>(::tolower(c)); });
|
||||
|
||||
f.isEmbedded = pd.isEmbedded;
|
||||
|
||||
f.flags = static_cast<uint32_t>(pd.flags);
|
||||
|
||||
if (pd.ascent != 0.0) f.ascent = pd.ascent;
|
||||
if (pd.descent != 0.0) f.descent = pd.descent;
|
||||
|
||||
if (f.ascent > 0.0) {
|
||||
if (lname.find("times") != std::string::npos) f.capHeight = 662.0;
|
||||
else if (lname.find("courier") != std::string::npos) f.capHeight = 562.0;
|
||||
else if (lname.find("symbol") != std::string::npos) f.capHeight = 673.0;
|
||||
else if (lname.find("helvetica") != std::string::npos) f.capHeight = 728.0;
|
||||
else f.capHeight = f.ascent * 0.71;
|
||||
}
|
||||
|
||||
if (lname.find("symbol") != std::string::npos) {
|
||||
f.hasToUnicode = false;
|
||||
} else {
|
||||
f.hasToUnicode = pd.hasUnicodeMapping;
|
||||
}
|
||||
|
||||
if (f.isEmbedded) {
|
||||
f.sourceType = "Embedded";
|
||||
f.substitutedFrom = "";
|
||||
f.substitutedTo = "";
|
||||
}
|
||||
|
||||
f.internalFontId = makeInternalFontId(f);
|
||||
|
||||
spdlog::debug(
|
||||
"Font '{}': isEmbedded={} type='{}' ascent={:.1f} descent={:.1f} "
|
||||
"capHeight={:.1f} hasToUnicode={} flags={}",
|
||||
fontName, f.isEmbedded, f.type, f.ascent, f.descent,
|
||||
f.capHeight, f.hasToUnicode, f.flags);
|
||||
}
|
||||
|
||||
if (!f.isVertical) {
|
||||
auto vit = fontIsVertical.find(fontName);
|
||||
if (vit != fontIsVertical.end() && vit->second) {
|
||||
f.isVertical = true;
|
||||
if (f.encoding == "WinAnsiEncoding" || f.encoding == "Identity-H") {
|
||||
f.encoding = "Identity-V";
|
||||
f.cmapName = "Identity-V";
|
||||
}
|
||||
spdlog::info("Vertical writing mode detected for font '{}' via character position analysis", fontName);
|
||||
}
|
||||
}
|
||||
|
||||
pageFonts.push_back(f);
|
||||
}
|
||||
|
||||
std::sort(pageFonts.begin(), pageFonts.end(), [](const FontInfo& a, const FontInfo& b) {
|
||||
if (a.normalizedFamily != b.normalizedFamily) {
|
||||
return a.normalizedFamily < b.normalizedFamily;
|
||||
}
|
||||
if (a.fontName != b.fontName) {
|
||||
return a.fontName < b.fontName;
|
||||
}
|
||||
if (a.encoding != b.encoding) {
|
||||
return a.encoding < b.encoding;
|
||||
}
|
||||
return a.type < b.type;
|
||||
});
|
||||
|
||||
return pageFonts;
|
||||
#else
|
||||
return std::unexpected(EngineError::Unknown);
|
||||
#endif
|
||||
}
|
||||
|
||||
std::expected<std::vector<FontInfo>, EngineError> PdfiumDocument::getFonts(int startPage, int endPage) const {
|
||||
#ifdef PDFENGINE_WITH_PDFIUM
|
||||
if (!doc_) {
|
||||
return std::unexpected(EngineError::Unknown);
|
||||
}
|
||||
|
||||
std::lock_guard<std::mutex> lock(fontsMutex_);
|
||||
|
||||
int total = pageCount();
|
||||
if (startPage < 0) startPage = 0;
|
||||
if (endPage < 0 || endPage >= total) endPage = total - 1;
|
||||
if (startPage > endPage) {
|
||||
return std::vector<FontInfo>();
|
||||
}
|
||||
|
||||
|
||||
if (startPage == 0 && endPage == total - 1 && hasCachedFonts_) {
|
||||
return cachedFonts_;
|
||||
}
|
||||
|
||||
std::vector<FontInfo> aggregated;
|
||||
for (int i = startPage; i <= endPage; ++i) {
|
||||
auto pageRes = const_cast<PdfiumDocument*>(this)->getPage(i);
|
||||
if (!pageRes) {
|
||||
spdlog::error("Failed to load page index {} for font diagnostics", i);
|
||||
continue;
|
||||
}
|
||||
|
||||
auto pageFontsRes = pageRes.value()->getFonts();
|
||||
if (pageFontsRes) {
|
||||
for (const auto& f : *pageFontsRes) {
|
||||
auto it = std::find_if(aggregated.begin(), aggregated.end(), [&](const FontInfo& existing) {
|
||||
return existing.fontName == f.fontName;
|
||||
});
|
||||
if (it == aggregated.end()) {
|
||||
aggregated.push_back(f);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
std::sort(aggregated.begin(), aggregated.end(), [](const FontInfo& a, const FontInfo& b) {
|
||||
if (a.normalizedFamily != b.normalizedFamily) {
|
||||
return a.normalizedFamily < b.normalizedFamily;
|
||||
}
|
||||
if (a.fontName != b.fontName) {
|
||||
return a.fontName < b.fontName;
|
||||
}
|
||||
if (a.encoding != b.encoding) {
|
||||
return a.encoding < b.encoding;
|
||||
}
|
||||
return a.type < b.type;
|
||||
});
|
||||
|
||||
if (startPage == 0 && endPage == total - 1) {
|
||||
cachedFonts_ = aggregated;
|
||||
hasCachedFonts_ = true;
|
||||
}
|
||||
|
||||
return aggregated;
|
||||
#else
|
||||
(void)startPage;
|
||||
(void)endPage;
|
||||
return std::unexpected(EngineError::Unknown);
|
||||
#endif
|
||||
}
|
||||
|
||||
std::optional<std::vector<uint8_t>>
|
||||
PdfiumDocument::getFontDataFromObjects(int pageIndex, const std::vector<int>& objectIndices,
|
||||
const std::string& internalFontId) const {
|
||||
#ifdef PDFENGINE_WITH_PDFIUM
|
||||
ensure_pdfium_initialized();
|
||||
if (!doc_ || objectIndices.empty()) return std::nullopt;
|
||||
const std::string expected = baseNameFromInternalFontId(internalFontId);
|
||||
FPDF_PAGE page = FPDF_LoadPage(doc_, pageIndex);
|
||||
if (!page) return std::nullopt;
|
||||
std::optional<std::vector<uint8_t>> result;
|
||||
for (int idx : objectIndices) {
|
||||
FPDF_PAGEOBJECT obj = FPDFPage_GetObject(page, idx);
|
||||
if (!obj || FPDFPageObj_GetType(obj) != FPDF_PAGEOBJ_TEXT) continue;
|
||||
FPDF_FONT font = FPDFTextObj_GetFont(obj);
|
||||
if (!font) continue;
|
||||
size_t nameLen = FPDFFont_GetBaseFontName(font, nullptr, 0);
|
||||
if (nameLen == 0) continue;
|
||||
std::vector<char> nameBuf(nameLen);
|
||||
if (FPDFFont_GetBaseFontName(font, nameBuf.data(), nameLen) == 0) continue;
|
||||
if (std::string(nameBuf.data()) != expected) continue;
|
||||
size_t buflen = 0;
|
||||
FPDFFont_GetFontData(font, nullptr, 0, &buflen);
|
||||
if (buflen == 0) continue;
|
||||
std::vector<uint8_t> buffer(buflen);
|
||||
size_t actualLen = 0;
|
||||
if (FPDFFont_GetFontData(font, buffer.data(), buflen, &actualLen) && actualLen > 0) {
|
||||
result = std::move(buffer);
|
||||
break;
|
||||
}
|
||||
}
|
||||
FPDF_ClosePage(page);
|
||||
return result;
|
||||
#else
|
||||
(void)pageIndex; (void)objectIndices; (void)internalFontId;
|
||||
return std::nullopt;
|
||||
#endif
|
||||
}
|
||||
|
||||
std::expected<std::vector<uint8_t>, EngineError> PdfiumDocument::getFontData(const std::string& internalFontId) const {
|
||||
#ifdef PDFENGINE_WITH_PDFIUM
|
||||
ensure_pdfium_initialized();
|
||||
if (!doc_) {
|
||||
return std::unexpected(EngineError::Unknown);
|
||||
}
|
||||
|
||||
std::string expectedFontName = baseNameFromInternalFontId(internalFontId);
|
||||
|
||||
int numPages = FPDF_GetPageCount(doc_);
|
||||
|
||||
int startPage = 0;
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(fontsMutex_);
|
||||
|
||||
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;
|
||||
|
||||
size_t 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_);
|
||||
|
||||
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>();
|
||||
}
|
||||
}
|
||||
|
||||
if (fontName == expectedFontName) {
|
||||
const auto& cachedBuf = fontDataCache_[fontName];
|
||||
if (!cachedBuf.empty()) {
|
||||
FPDF_ClosePage(page);
|
||||
fontDataScannedPages_ = i;
|
||||
return cachedBuf;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
FPDF_ClosePage(page);
|
||||
|
||||
std::lock_guard<std::mutex> lock(fontsMutex_);
|
||||
fontDataScannedPages_ = i + 1;
|
||||
}
|
||||
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(fontsMutex_);
|
||||
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_);
|
||||
|
||||
if (resolvedFontsCache_.count(fontInfo.internalFontId)) {
|
||||
return resolvedFontsCache_[fontInfo.internalFontId];
|
||||
}
|
||||
|
||||
if (!fontResolver_) {
|
||||
fontResolver_ = std::make_unique<::pdfengine::fonts::loader::FontResolver>(shared_from_this());
|
||||
}
|
||||
|
||||
auto result = fontResolver_->resolveFont(fontInfo);
|
||||
if (!result) {
|
||||
return std::unexpected(result.error());
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -0,0 +1,607 @@
|
||||
#include "parser/pdfium_internal.hpp"
|
||||
|
||||
namespace pdfengine::parser {
|
||||
|
||||
std::vector<unsigned short> utf8_to_utf16le(const std::string& utf8) {
|
||||
std::vector<unsigned short> utf16;
|
||||
utf16.reserve(utf8.size());
|
||||
for (size_t i = 0; i < utf8.size(); ) {
|
||||
unsigned char c = utf8[i];
|
||||
unsigned int cp = 0;
|
||||
size_t extra = 0;
|
||||
if (c < 0x80) { cp = c; extra = 0; }
|
||||
else if ((c & 0xE0) == 0xC0) { cp = c & 0x1F; extra = 1; }
|
||||
else if ((c & 0xF0) == 0xE0) { cp = c & 0x0F; extra = 2; }
|
||||
else if ((c & 0xF8) == 0xF0) { cp = c & 0x07; extra = 3; }
|
||||
else { i++; continue; }
|
||||
|
||||
if (i + extra >= utf8.size()) break;
|
||||
bool invalid = false;
|
||||
for (size_t j = 1; j <= extra; ++j) {
|
||||
unsigned char next = utf8[i + j];
|
||||
if ((next & 0xC0) != 0x80) { invalid = true; break; }
|
||||
cp = (cp << 6) | (next & 0x3F);
|
||||
}
|
||||
if (invalid) { i++; continue; }
|
||||
i += 1 + extra;
|
||||
if (cp < 0x10000) {
|
||||
utf16.push_back(static_cast<unsigned short>(cp));
|
||||
} else {
|
||||
cp -= 0x10000;
|
||||
utf16.push_back(static_cast<unsigned short>((cp >> 10) + 0xD800));
|
||||
utf16.push_back(static_cast<unsigned short>((cp & 0x3FF) + 0xDC00));
|
||||
}
|
||||
}
|
||||
utf16.push_back(0);
|
||||
return utf16;
|
||||
}
|
||||
|
||||
std::string utf16le_to_utf8(const char16_t* utf16, size_t length) {
|
||||
std::string utf8;
|
||||
for (size_t i = 0; i < length; ++i) {
|
||||
char16_t c = utf16[i];
|
||||
if (c == 0) break;
|
||||
|
||||
uint32_t cp = c;
|
||||
if (c >= 0xD800 && c <= 0xDBFF) {
|
||||
if (i + 1 < length) {
|
||||
char16_t low = utf16[i + 1];
|
||||
if (low >= 0xDC00 && low <= 0xDFFF) {
|
||||
cp = 0x10000 + (((c - 0xD800) << 10) | (low - 0xDC00));
|
||||
i++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (cp < 0x80) {
|
||||
utf8 += static_cast<char>(cp);
|
||||
} else if (cp < 0x800) {
|
||||
utf8 += static_cast<char>(0xC0 | (cp >> 6));
|
||||
utf8 += static_cast<char>(0x80 | (cp & 0x3F));
|
||||
} else if (cp < 0x10000) {
|
||||
utf8 += static_cast<char>(0xE0 | (cp >> 12));
|
||||
utf8 += static_cast<char>(0x80 | ((cp >> 6) & 0x3F));
|
||||
utf8 += static_cast<char>(0x80 | (cp & 0x3F));
|
||||
} else {
|
||||
utf8 += static_cast<char>(0xF0 | (cp >> 18));
|
||||
utf8 += static_cast<char>(0x80 | ((cp >> 12) & 0x3F));
|
||||
utf8 += static_cast<char>(0x80 | ((cp >> 6) & 0x3F));
|
||||
utf8 += static_cast<char>(0x80 | (cp & 0x3F));
|
||||
}
|
||||
}
|
||||
return utf8;
|
||||
}
|
||||
|
||||
std::string code_point_to_utf8(unsigned int cp) {
|
||||
std::string utf8;
|
||||
if (cp == 0) return "";
|
||||
if (cp < 0x80) {
|
||||
utf8 += static_cast<char>(cp);
|
||||
} else if (cp < 0x800) {
|
||||
utf8 += static_cast<char>(0xC0 | (cp >> 6));
|
||||
utf8 += static_cast<char>(0x80 | (cp & 0x3F));
|
||||
} else if (cp < 0x10000) {
|
||||
utf8 += static_cast<char>(0xE0 | (cp >> 12));
|
||||
utf8 += static_cast<char>(0x80 | ((cp >> 6) & 0x3F));
|
||||
utf8 += static_cast<char>(0x80 | (cp & 0x3F));
|
||||
} else if (cp < 0x200000) {
|
||||
utf8 += static_cast<char>(0xF0 | (cp >> 18));
|
||||
utf8 += static_cast<char>(0x80 | ((cp >> 12) & 0x3F));
|
||||
utf8 += static_cast<char>(0x80 | ((cp >> 6) & 0x3F));
|
||||
utf8 += static_cast<char>(0x80 | (cp & 0x3F));
|
||||
}
|
||||
return utf8;
|
||||
}
|
||||
|
||||
static int cp1252ToByte(unsigned int cp) {
|
||||
if (cp <= 0x7F) return static_cast<int>(cp);
|
||||
if (cp >= 0xA0 && cp <= 0xFF) return static_cast<int>(cp);
|
||||
switch (cp) {
|
||||
case 0x20AC: return 0x80; case 0x201A: return 0x82; case 0x0192: return 0x83;
|
||||
case 0x201E: return 0x84; case 0x2026: return 0x85; case 0x2020: return 0x86;
|
||||
case 0x2021: return 0x87; case 0x02C6: return 0x88; case 0x2030: return 0x89;
|
||||
case 0x0160: return 0x8A; case 0x2039: return 0x8B; case 0x0152: return 0x8C;
|
||||
case 0x017D: return 0x8E; case 0x2018: return 0x91; case 0x2019: return 0x92;
|
||||
case 0x201C: return 0x93; case 0x201D: return 0x94; case 0x2022: return 0x95;
|
||||
case 0x2013: return 0x96; case 0x2014: return 0x97; case 0x02DC: return 0x98;
|
||||
case 0x2122: return 0x99; case 0x0161: return 0x9A; case 0x203A: return 0x9B;
|
||||
case 0x0153: return 0x9C; case 0x017E: return 0x9E; case 0x0178: return 0x9F;
|
||||
default: return -1;
|
||||
}
|
||||
}
|
||||
|
||||
static std::vector<unsigned int> utf8_to_codepoints(const std::string& s) {
|
||||
std::vector<unsigned int> cps;
|
||||
for (size_t i = 0; i < s.size();) {
|
||||
unsigned char c = static_cast<unsigned char>(s[i]);
|
||||
unsigned int cp = c; int extra = 0;
|
||||
if (c < 0x80) { cp = c; extra = 0; }
|
||||
else if ((c & 0xE0) == 0xC0) { cp = c & 0x1F; extra = 1; }
|
||||
else if ((c & 0xF0) == 0xE0) { cp = c & 0x0F; extra = 2; }
|
||||
else if ((c & 0xF8) == 0xF0) { cp = c & 0x07; extra = 3; }
|
||||
else { cp = c; extra = 0; }
|
||||
if (i + extra >= s.size()) { cps.push_back(c); i++; continue; }
|
||||
bool ok = true;
|
||||
for (int j = 1; j <= extra; ++j) { unsigned char n = static_cast<unsigned char>(s[i + j]); if ((n & 0xC0) != 0x80) { ok = false; break; } cp = (cp << 6) | (n & 0x3F); }
|
||||
if (!ok) { cps.push_back(c); i++; continue; }
|
||||
cps.push_back(cp); i += extra + 1;
|
||||
}
|
||||
return cps;
|
||||
}
|
||||
|
||||
std::string repairMojibake(const std::string& s) {
|
||||
auto cps = utf8_to_codepoints(s);
|
||||
std::string out;
|
||||
auto emit = [&](unsigned int cp) { out += code_point_to_utf8(cp); };
|
||||
for (size_t i = 0; i < cps.size();) {
|
||||
int b0 = cp1252ToByte(cps[i]);
|
||||
int need = 0;
|
||||
if (b0 >= 0xC0 && b0 <= 0xDF) need = 1;
|
||||
else if (b0 >= 0xE0 && b0 <= 0xEF) need = 2;
|
||||
else if (b0 >= 0xF0 && b0 <= 0xF7) need = 3;
|
||||
if (need > 0 && i + need < cps.size()) {
|
||||
std::string bytes; bytes.push_back(static_cast<char>(b0));
|
||||
bool ok = true;
|
||||
for (int j = 1; j <= need; ++j) { int b = cp1252ToByte(cps[i + j]); if (b < 0x80 || b > 0xBF) { ok = false; break; } bytes.push_back(static_cast<char>(b)); }
|
||||
if (ok) { auto rd = utf8_to_codepoints(bytes); if (rd.size() == 1 && rd[0] > 0x7F) { emit(rd[0]); i += need + 1; continue; } }
|
||||
}
|
||||
emit(cps[i]); i++;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
std::string normalizeReflowText(const std::string& s, const fonts::FontFace* face) {
|
||||
auto cps = utf8_to_codepoints(s);
|
||||
std::string out;
|
||||
auto keepIfCovered = [&](unsigned int cp, const char* ascii) {
|
||||
if (face && face->coversUnicode(cp)) out += code_point_to_utf8(cp);
|
||||
else out += ascii;
|
||||
};
|
||||
for (unsigned int cp : cps) {
|
||||
switch (cp) {
|
||||
case 0x2010: case 0x2011: case 0x2012: case 0x2013: case 0x2014: case 0x2015:
|
||||
keepIfCovered(cp, "-"); break;
|
||||
case 0x2018: case 0x2019: case 0x201B: keepIfCovered(cp, "'"); break;
|
||||
case 0x201C: case 0x201D: case 0x201F: keepIfCovered(cp, "\""); break;
|
||||
case 0x2026: keepIfCovered(cp, "..."); break;
|
||||
case 0x00A0: case 0x2002: case 0x2003: case 0x2009: case 0x202F: out += ' '; break;
|
||||
default: out += code_point_to_utf8(cp); break;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
namespace pdfengine::parser {
|
||||
|
||||
|
||||
std::string makeInternalFontId(const pdfengine::FontInfo& f) {
|
||||
if (f.isSubset && !f.subsetTag.empty()) return f.fontName;
|
||||
return f.fontName + "_" + f.type + "_" + std::to_string(f.flags);
|
||||
}
|
||||
|
||||
std::string classifyFontFidelity(const pdfengine::FontInfo& f) {
|
||||
if (!f.isEmbedded) return "exact";
|
||||
std::string fam = f.normalizedFamily;
|
||||
std::transform(fam.begin(), fam.end(), fam.begin(), [](unsigned char c){ return std::tolower(c); });
|
||||
auto has = [&](const char* s){ return fam.find(s) != std::string::npos; };
|
||||
if (fam.empty() || has("times") || has("arial") || has("helvetica") || has("courier") ||
|
||||
has("symbol") || has("zapf"))
|
||||
return "exact";
|
||||
const bool reconstructable =
|
||||
f.hasToUnicode &&
|
||||
(f.type.find("TrueType") != std::string::npos ||
|
||||
f.type.find("Type0") != std::string::npos ||
|
||||
f.type.find("CIDFontType2") != std::string::npos);
|
||||
return reconstructable ? "partial" : "substituted";
|
||||
}
|
||||
|
||||
std::string baseNameFromInternalFontId(const std::string& internalFontId) {
|
||||
std::string expected = internalFontId;
|
||||
size_t lastUnderscore = expected.rfind('_');
|
||||
if (lastUnderscore != std::string::npos && lastUnderscore > 0) {
|
||||
size_t secondLast = expected.rfind('_', lastUnderscore - 1);
|
||||
if (secondLast != std::string::npos) {
|
||||
std::string typePart = expected.substr(secondLast + 1, lastUnderscore - secondLast - 1);
|
||||
if (typePart == "TrueType" || typePart == "Type1" ||
|
||||
typePart == "CIDFontType0" || typePart == "CIDFontType2") {
|
||||
expected = expected.substr(0, secondLast);
|
||||
}
|
||||
}
|
||||
}
|
||||
return expected;
|
||||
}
|
||||
|
||||
#ifdef PDFENGINE_WITH_PDFIUM
|
||||
|
||||
struct PngWriteState {
|
||||
std::vector<uint8_t>* buffer;
|
||||
};
|
||||
|
||||
void pngWriteCallback(png_structp png_ptr, png_bytep data, png_size_t length) {
|
||||
auto* state = reinterpret_cast<PngWriteState*>(png_get_io_ptr(png_ptr));
|
||||
state->buffer->insert(state->buffer->end(), data, data + length);
|
||||
}
|
||||
|
||||
void pngFlushCallback(png_structp png_ptr) {
|
||||
(void)png_ptr;
|
||||
}
|
||||
|
||||
std::vector<uint8_t> encodeBgraToPng(const uint8_t* bgra, int width, int height, int stride) {
|
||||
std::vector<uint8_t> pngBytes;
|
||||
png_structp png = png_create_write_struct(PNG_LIBPNG_VER_STRING, nullptr, nullptr, nullptr);
|
||||
if (!png) return {};
|
||||
|
||||
png_infop info = png_create_info_struct(png);
|
||||
if (!info) {
|
||||
png_destroy_write_struct(&png, nullptr);
|
||||
return {};
|
||||
}
|
||||
|
||||
#pragma warning(push)
|
||||
#pragma warning(disable: 4611)
|
||||
if (setjmp(png_jmpbuf(png))) {
|
||||
png_destroy_write_struct(&png, &info);
|
||||
return {};
|
||||
}
|
||||
#pragma warning(pop)
|
||||
|
||||
PngWriteState state{&pngBytes};
|
||||
png_set_write_fn(png, &state, pngWriteCallback, pngFlushCallback);
|
||||
|
||||
png_set_IHDR(png, info, width, height, 8, PNG_COLOR_TYPE_RGBA, PNG_INTERLACE_NONE,
|
||||
PNG_COMPRESSION_TYPE_DEFAULT, PNG_FILTER_TYPE_DEFAULT);
|
||||
|
||||
png_write_info(png, info);
|
||||
png_set_bgr(png);
|
||||
|
||||
std::vector<png_bytep> rowPointers(height);
|
||||
for (int y = 0; y < height; ++y) {
|
||||
rowPointers[y] = const_cast<png_bytep>(bgra + y * stride);
|
||||
}
|
||||
|
||||
png_write_image(png, rowPointers.data());
|
||||
png_write_end(png, nullptr);
|
||||
png_destroy_write_struct(&png, &info);
|
||||
|
||||
return pngBytes;
|
||||
}
|
||||
|
||||
pdfengine::EngineError mapPdfiumError(unsigned long err, bool passwordProvided) {
|
||||
switch (err) {
|
||||
case FPDF_ERR_SUCCESS:
|
||||
return pdfengine::EngineError::Unknown;
|
||||
case FPDF_ERR_FILE:
|
||||
return pdfengine::EngineError::FileNotFound;
|
||||
case FPDF_ERR_FORMAT:
|
||||
return pdfengine::EngineError::InvalidFormat;
|
||||
case FPDF_ERR_PASSWORD:
|
||||
return passwordProvided ? pdfengine::EngineError::InvalidPassword
|
||||
: pdfengine::EngineError::PasswordRequired;
|
||||
default:
|
||||
return pdfengine::EngineError::Unknown;
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
#ifdef PDFENGINE_WITH_PDFIUM
|
||||
struct PdfiumGlobalInit {
|
||||
PdfiumGlobalInit() {
|
||||
pdfengine::parser::pdfiumInitLibrary();
|
||||
}
|
||||
~PdfiumGlobalInit() {
|
||||
pdfengine::parser::pdfiumDestroyLibrary();
|
||||
}
|
||||
};
|
||||
|
||||
void ensure_pdfium_initialized() {
|
||||
static PdfiumGlobalInit init;
|
||||
}
|
||||
|
||||
std::string normalizeFamilyName(const std::string& fontName) {
|
||||
std::string name = fontName;
|
||||
if (name.size() > 7 && name[6] == '+') {
|
||||
name = name.substr(7);
|
||||
}
|
||||
|
||||
size_t sep = name.find_first_of("-,");
|
||||
if (sep != std::string::npos) {
|
||||
name = name.substr(0, sep);
|
||||
}
|
||||
|
||||
auto cleanName = name;
|
||||
auto lower = name;
|
||||
std::transform(lower.begin(), lower.end(), lower.begin(), [](unsigned char c) { return static_cast<char>(::tolower(c)); });
|
||||
|
||||
std::vector<std::string> suffixes = {"bold", "italic", "oblique", "regular", "medium", "light", "heavy", "black", "condensed", "mt", "ps"};
|
||||
for (const auto& s : suffixes) {
|
||||
size_t pos = lower.rfind(s);
|
||||
if (pos != std::string::npos && pos + s.size() == lower.size()) {
|
||||
cleanName = cleanName.substr(0, pos);
|
||||
lower = lower.substr(0, pos);
|
||||
}
|
||||
}
|
||||
|
||||
while (!cleanName.empty() && (cleanName.back() == '-' || cleanName.back() == ' ' || cleanName.back() == '_')) {
|
||||
cleanName.pop_back();
|
||||
}
|
||||
|
||||
if (cleanName.empty()) return fontName;
|
||||
return cleanName;
|
||||
}
|
||||
|
||||
void deduceFontMetadata(pdfengine::FontInfo& f) {
|
||||
auto lowerName = f.fontName;
|
||||
std::transform(lowerName.begin(), lowerName.end(), lowerName.begin(), [](unsigned char c) { return static_cast<char>(::tolower(c)); });
|
||||
|
||||
if (f.fontName.size() > 7 && f.fontName[6] == '+') {
|
||||
f.isSubset = true;
|
||||
f.subsetTag = f.fontName.substr(0, 6);
|
||||
bool validTag = true;
|
||||
for (int ti = 0; ti < 6; ++ti) {
|
||||
if (!std::isupper(static_cast<unsigned char>(f.fontName[ti]))) {
|
||||
validTag = false; break;
|
||||
}
|
||||
}
|
||||
if (!validTag) { f.isSubset = false; f.subsetTag = ""; }
|
||||
} else {
|
||||
f.isSubset = false;
|
||||
f.subsetTag = "";
|
||||
}
|
||||
f.normalizedFamily = normalizeFamilyName(f.fontName);
|
||||
|
||||
if (f.fontName.find("Identity-H") != std::string::npos) {
|
||||
f.encoding = "Identity-H";
|
||||
f.cmapName = "Identity-H";
|
||||
} else if (f.fontName.find("Identity-V") != std::string::npos) {
|
||||
f.encoding = "Identity-V";
|
||||
f.cmapName = "Identity-V";
|
||||
f.isVertical = true;
|
||||
} else if (lowerName.find("symbol") != std::string::npos) {
|
||||
f.encoding = "Symbol";
|
||||
f.cmapName = "None";
|
||||
} else {
|
||||
f.encoding = "WinAnsiEncoding";
|
||||
f.cmapName = "None";
|
||||
}
|
||||
|
||||
if (lowerName.find("symbol") != std::string::npos) {
|
||||
f.hasToUnicode = false;
|
||||
} else {
|
||||
f.hasToUnicode = true;
|
||||
}
|
||||
|
||||
if (lowerName.find("simsun") != std::string::npos ||
|
||||
lowerName.find("simhei") != std::string::npos ||
|
||||
lowerName.find("heiti") != std::string::npos ||
|
||||
lowerName.find("fangsong") != std::string::npos ||
|
||||
lowerName.find("kaiti") != std::string::npos ||
|
||||
lowerName.find("song") != std::string::npos ||
|
||||
lowerName.find("gb") != std::string::npos) {
|
||||
f.cidSystemInfo = "Adobe-GB1";
|
||||
} else if (lowerName.find("gothic") != std::string::npos ||
|
||||
lowerName.find("ms-gothic") != std::string::npos ||
|
||||
lowerName.find("msgothic") != std::string::npos ||
|
||||
lowerName.find("mincho") != std::string::npos ||
|
||||
lowerName.find("kozuka") != std::string::npos ||
|
||||
lowerName.find("hiragino") != std::string::npos ||
|
||||
lowerName.find("japan") != std::string::npos ||
|
||||
lowerName.find("heisei") != std::string::npos ||
|
||||
lowerName.find("morisawa") != std::string::npos ||
|
||||
lowerName.find("ryumin") != std::string::npos) {
|
||||
f.cidSystemInfo = "Adobe-Japan1";
|
||||
} else if (lowerName.find("malgun") != std::string::npos ||
|
||||
lowerName.find("gulim") != std::string::npos ||
|
||||
lowerName.find("batang") != std::string::npos ||
|
||||
lowerName.find("dotum") != std::string::npos ||
|
||||
lowerName.find("korea") != std::string::npos ||
|
||||
lowerName.find("hangul") != std::string::npos ||
|
||||
lowerName.find("korean") != std::string::npos) {
|
||||
f.cidSystemInfo = "Adobe-Korea1";
|
||||
} else if (lowerName.find("sung") != std::string::npos ||
|
||||
lowerName.find("ming") != std::string::npos ||
|
||||
lowerName.find("cns") != std::string::npos ||
|
||||
lowerName.find("traditional") != std::string::npos) {
|
||||
f.cidSystemInfo = "Adobe-CNS1";
|
||||
} else {
|
||||
f.cidSystemInfo = "None";
|
||||
}
|
||||
|
||||
if (f.cidSystemInfo != "None" && f.cmapName == "None") {
|
||||
f.cmapName = f.isVertical ? "UniJIS-UTF16-V" : "Identity-H";
|
||||
}
|
||||
|
||||
bool isCid = (f.encoding == "Identity-H" || f.encoding == "Identity-V" || f.cidSystemInfo != "None");
|
||||
if (isCid) {
|
||||
if (lowerName.find("bold") != std::string::npos || lowerName.find("italic") != std::string::npos) {
|
||||
f.type = "CIDFontType0";
|
||||
} else {
|
||||
f.type = "CIDFontType2";
|
||||
}
|
||||
} else {
|
||||
static const std::vector<std::string> type1Names = {
|
||||
"times", "helvetica", "courier", "symbol", "zapfdingbats",
|
||||
"liberation", "palatino", "bookman", "new century", "avant garde"
|
||||
};
|
||||
bool isType1 = false;
|
||||
for (const auto& t1 : type1Names) {
|
||||
if (lowerName.find(t1) != std::string::npos) { isType1 = true; break; }
|
||||
}
|
||||
f.type = isType1 ? "Type1" : "TrueType";
|
||||
}
|
||||
|
||||
if (f.isSubset) {
|
||||
f.isEmbedded = true;
|
||||
f.sourceType = "Embedded";
|
||||
f.substitutedFrom = "";
|
||||
f.substitutedTo = "";
|
||||
} else {
|
||||
static const std::vector<std::string> standard14 = {
|
||||
"helvetica", "times", "courier", "symbol", "zapfdingbats"
|
||||
};
|
||||
bool isStandard14 = false;
|
||||
for (const auto& s14 : standard14) {
|
||||
if (lowerName.find(s14) != std::string::npos) { isStandard14 = true; break; }
|
||||
}
|
||||
bool isSystemFont = isStandard14 ||
|
||||
lowerName.find("arial") != std::string::npos ||
|
||||
lowerName.find("liberation") != std::string::npos ||
|
||||
lowerName.find("dejavu") != std::string::npos ||
|
||||
lowerName.find("freefont") != std::string::npos;
|
||||
if (isSystemFont) {
|
||||
f.isEmbedded = false;
|
||||
f.sourceType = "SystemFallback";
|
||||
f.substitutedFrom = "";
|
||||
f.substitutedTo = "";
|
||||
} else {
|
||||
f.isEmbedded = false;
|
||||
f.sourceType = "Substituted";
|
||||
f.substitutedFrom = f.fontName;
|
||||
#if defined(_WIN32)
|
||||
f.substitutedTo = "Arial";
|
||||
#else
|
||||
f.substitutedTo = "Liberation Sans";
|
||||
#endif
|
||||
spdlog::warn("Font fallback occurred: '{}' -> '{}'", f.substitutedFrom, f.substitutedTo);
|
||||
}
|
||||
}
|
||||
|
||||
f.internalFontId = makeInternalFontId(f);
|
||||
|
||||
if (lowerName.find("times") != std::string::npos) {
|
||||
f.ascent = 891.0;
|
||||
f.descent = -216.0;
|
||||
f.capHeight = 662.0;
|
||||
} else if (lowerName.find("courier") != std::string::npos) {
|
||||
f.ascent = 629.0;
|
||||
f.descent = -157.0;
|
||||
f.capHeight = 562.0;
|
||||
} else if (lowerName.find("symbol") != std::string::npos) {
|
||||
f.ascent = 1010.0;
|
||||
f.descent = -293.0;
|
||||
f.capHeight = 673.0;
|
||||
} else if (lowerName.find("helvetica") != std::string::npos) {
|
||||
f.ascent = 905.0;
|
||||
f.descent = -211.0;
|
||||
f.capHeight = 728.0;
|
||||
} else {
|
||||
f.ascent = 905.0;
|
||||
f.descent = -211.0;
|
||||
f.capHeight = 728.0;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
std::unordered_map<std::string, FontPdfData>
|
||||
buildFontPdfDataMap(FPDF_PAGE page, FPDF_TEXTPAGE textPage) {
|
||||
std::unordered_map<std::string, FontPdfData> result;
|
||||
if (!page) return result;
|
||||
|
||||
int objectCount = FPDFPage_CountObjects(page);
|
||||
for (int i = 0; i < objectCount; ++i) {
|
||||
FPDF_PAGEOBJECT obj = FPDFPage_GetObject(page, i);
|
||||
if (!obj) continue;
|
||||
if (FPDFPageObj_GetType(obj) != FPDF_PAGEOBJ_TEXT) continue;
|
||||
|
||||
FPDF_FONT font = FPDFTextObj_GetFont(obj);
|
||||
if (!font) continue;
|
||||
|
||||
size_t nameLen = FPDFFont_GetBaseFontName(font, nullptr, 0);
|
||||
if (nameLen == 0) continue;
|
||||
std::vector<char> nameBuf(nameLen);
|
||||
if (FPDFFont_GetBaseFontName(font, nameBuf.data(), nameLen) == 0) continue;
|
||||
std::string fontName(nameBuf.data());
|
||||
if (fontName.empty()) continue;
|
||||
|
||||
if (result.count(fontName)) continue;
|
||||
|
||||
FontPdfData data;
|
||||
data.valid = true;
|
||||
data.isEmbedded = (FPDFFont_GetIsEmbedded(font) != 0);
|
||||
data.flags = FPDFFont_GetFlags(font);
|
||||
|
||||
float rawAscent = 0.0f;
|
||||
if (FPDFFont_GetAscent(font, 1000.0f, &rawAscent) && rawAscent != 0.0f) {
|
||||
data.ascent = static_cast<double>(rawAscent);
|
||||
}
|
||||
float rawDescent = 0.0f;
|
||||
if (FPDFFont_GetDescent(font, 1000.0f, &rawDescent) && rawDescent != 0.0f) {
|
||||
data.descent = static_cast<double>(rawDescent);
|
||||
}
|
||||
|
||||
result[fontName] = data;
|
||||
}
|
||||
|
||||
if (textPage && !result.empty()) {
|
||||
int charCount = FPDFText_CountChars(textPage);
|
||||
if (charCount > 0 && charCount < 1000000) {
|
||||
for (int ci = 0; ci < charCount; ++ci) {
|
||||
int fi = 0;
|
||||
unsigned long flen =
|
||||
FPDFText_GetFontInfo(textPage, ci, nullptr, 0, &fi);
|
||||
if (flen == 0) continue;
|
||||
std::vector<char> fbuf(flen);
|
||||
if (FPDFText_GetFontInfo(textPage, ci, fbuf.data(), flen, &fi) == 0)
|
||||
continue;
|
||||
std::string fname(fbuf.data());
|
||||
|
||||
auto it = result.find(fname);
|
||||
if (it == result.end() || it->second.hasUnicodeMapping) continue;
|
||||
|
||||
unsigned int cp = FPDFText_GetUnicode(textPage, ci);
|
||||
if (cp > 0x0020 && cp != 0xFFFD) {
|
||||
it->second.hasUnicodeMapping = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
#endif
|
||||
|
||||
void parseHexColor(const std::string& hex, unsigned int& r, unsigned int& g, unsigned int& b) {
|
||||
r = 0; g = 0; b = 0;
|
||||
if (hex.empty()) return;
|
||||
std::string s = hex;
|
||||
if (s[0] == '#') {
|
||||
s = s.substr(1);
|
||||
}
|
||||
if (s.size() == 6) {
|
||||
try {
|
||||
r = std::stoul(s.substr(0, 2), nullptr, 16);
|
||||
g = std::stoul(s.substr(2, 2), nullptr, 16);
|
||||
b = std::stoul(s.substr(4, 2), nullptr, 16);
|
||||
} catch (...) {
|
||||
r = 0; g = 0; b = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
std::vector<uint8_t> base64Decode(const std::string& encoded) {
|
||||
std::vector<uint8_t> decoded;
|
||||
int T[256];
|
||||
std::fill(std::begin(T), std::end(T), -1);
|
||||
for (int i = 0; i < 64; ++i) {
|
||||
T[static_cast<unsigned char>("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"[i])] = i;
|
||||
}
|
||||
|
||||
int val = 0;
|
||||
int valb = -8;
|
||||
for (char c : encoded) {
|
||||
unsigned char uc = static_cast<unsigned char>(c);
|
||||
if (T[uc] == -1) continue;
|
||||
val = (val << 6) + T[uc];
|
||||
valb += 6;
|
||||
if (valb >= 0) {
|
||||
decoded.push_back(static_cast<uint8_t>((val >> valb) & 0xFF));
|
||||
valb -= 8;
|
||||
}
|
||||
}
|
||||
return decoded;
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
#pragma once
|
||||
|
||||
#include "parser/pdfium_document.hpp"
|
||||
#ifdef PDFENGINE_WITH_PDFIUM
|
||||
#include <fpdfview.h>
|
||||
#include <fpdf_text.h>
|
||||
#include <fpdf_save.h>
|
||||
#include <fpdf_doc.h>
|
||||
#include <fpdf_edit.h>
|
||||
#include <fpdf_annot.h>
|
||||
#include <fpdf_formfill.h>
|
||||
#include <png.h>
|
||||
#include "parser/pdfium_loader.hpp"
|
||||
#endif
|
||||
|
||||
#include "fonts/loader/font_resolver.hpp"
|
||||
#include "fonts/pdf_fonts/font.hpp"
|
||||
#include "pdfengine/hardened_limits.h"
|
||||
#include "fonts/pdf_fonts/font_fallback.hpp"
|
||||
#include "fonts/pdf_fonts/font_subset.hpp"
|
||||
#include "fonts/shaping/hb_shaper.hpp"
|
||||
#include "fonts/face/font_face.hpp"
|
||||
#include "decoration_builder.hpp"
|
||||
#include "qpdf/qpdf_extractor.hpp"
|
||||
#include "qpdf/qpdf_font_extractor.hpp"
|
||||
#include "parser/lexer.hpp"
|
||||
#include "parser/parser.hpp"
|
||||
|
||||
#include <nlohmann/json.hpp>
|
||||
#include <spdlog/spdlog.h>
|
||||
#include <fstream>
|
||||
#include <filesystem>
|
||||
#include <csetjmp>
|
||||
#include <algorithm>
|
||||
#include <cctype>
|
||||
#include <cmath>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
#include <unordered_map>
|
||||
#include <cstdint>
|
||||
|
||||
namespace pdfengine::parser {
|
||||
|
||||
std::string makeInternalFontId(const pdfengine::FontInfo& f);
|
||||
std::string classifyFontFidelity(const pdfengine::FontInfo& f);
|
||||
std::string baseNameFromInternalFontId(const std::string& internalFontId);
|
||||
void parseHexColor(const std::string& hex, unsigned int& r, unsigned int& g, unsigned int& b);
|
||||
std::vector<uint8_t> base64Decode(const std::string& encoded);
|
||||
std::string repairMojibake(const std::string& s);
|
||||
std::string normalizeReflowText(const std::string& s, const fonts::FontFace* face);
|
||||
|
||||
#ifdef PDFENGINE_WITH_PDFIUM
|
||||
|
||||
struct VectorWriter : public FPDF_FILEWRITE {
|
||||
std::vector<uint8_t> buffer;
|
||||
|
||||
static int WriteBlockCallback(FPDF_FILEWRITE* pThis, const void* pData, unsigned long size) {
|
||||
auto* self = static_cast<VectorWriter*>(pThis);
|
||||
const auto* bytes = static_cast<const uint8_t*>(pData);
|
||||
self->buffer.insert(self->buffer.end(), bytes, bytes + size);
|
||||
return 1;
|
||||
}
|
||||
|
||||
VectorWriter() {
|
||||
this->version = 1;
|
||||
this->WriteBlock = &VectorWriter::WriteBlockCallback;
|
||||
}
|
||||
};
|
||||
|
||||
struct FontPdfData {
|
||||
bool valid = false;
|
||||
bool isEmbedded = false;
|
||||
int flags = 0;
|
||||
double ascent = 0.0;
|
||||
double descent = 0.0;
|
||||
bool hasUnicodeMapping = false;
|
||||
};
|
||||
|
||||
struct PageBand {
|
||||
double pageW = 0.0, pageH = 0.0;
|
||||
double placementTopY = 0.0;
|
||||
double bottomLimitY = 0.0;
|
||||
bool valid = false;
|
||||
};
|
||||
|
||||
std::vector<uint8_t> encodeBgraToPng(const uint8_t* bgra, int width, int height, int stride);
|
||||
pdfengine::EngineError mapPdfiumError(unsigned long err, bool passwordProvided);
|
||||
void ensure_pdfium_initialized();
|
||||
void deduceFontMetadata(pdfengine::FontInfo& f);
|
||||
std::unordered_map<std::string, FontPdfData> buildFontPdfDataMap(FPDF_PAGE page, FPDF_TEXTPAGE textPage);
|
||||
|
||||
void repagSetParaId(FPDF_DOCUMENT doc, FPDF_PAGEOBJECT obj, const std::string& paraId);
|
||||
std::string repagGetParaId(FPDF_PAGEOBJECT obj);
|
||||
void walkOutline(FPDF_DOCUMENT doc, FPDF_BOOKMARK bm, int level,
|
||||
std::vector<PdfDocument::OutlineItem>& out);
|
||||
PageBand repagComputeBand(FPDF_PAGE page, double pushColLeft, double colRight);
|
||||
std::vector<double> repagAnchoredCenters(FPDF_PAGE page, double pushColLeft, double colRight,
|
||||
double bottomLimitY);
|
||||
int repaginateForward(FPDF_DOCUMENT doc, int startPage,
|
||||
double colLeft, double colRight, double pushColLeft, double leading,
|
||||
const std::vector<double>& anchoredCentersStart, int* movedOut = nullptr);
|
||||
int repaginateBackward(FPDF_DOCUMENT doc, int startPage,
|
||||
double colLeft, double colRight, double pushColLeft, double leading);
|
||||
int repagRemoveContinuations(FPDF_DOCUMENT doc, int anchorPage, const std::string& paraId,
|
||||
double pushColLeft, double colRight);
|
||||
|
||||
#endif
|
||||
|
||||
}
|
||||
@@ -0,0 +1,390 @@
|
||||
#include "parser/pdfium_internal.hpp"
|
||||
|
||||
namespace pdfengine::parser {
|
||||
|
||||
PdfiumPage::PdfiumPage(NativeDocHandle docHandle, NativePageHandle pageHandle, int pageIndex,
|
||||
std::shared_ptr<PdfiumDocument> owner)
|
||||
: doc_(docHandle), page_(pageHandle), pageIndex_(pageIndex), ownerDoc_(std::move(owner)) {
|
||||
}
|
||||
|
||||
PdfiumPage::~PdfiumPage() {
|
||||
#ifdef PDFENGINE_WITH_PDFIUM
|
||||
std::lock_guard<std::mutex> lock(textMutex_);
|
||||
if (textPage_) {
|
||||
FPDFText_ClosePage(textPage_);
|
||||
}
|
||||
if (page_) {
|
||||
FPDF_ClosePage(page_);
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
PdfiumPage::PdfiumPage(PdfiumPage&& other) noexcept {
|
||||
*this = std::move(other);
|
||||
}
|
||||
|
||||
PdfiumPage& PdfiumPage::operator=(PdfiumPage&& other) noexcept {
|
||||
if (this != &other) {
|
||||
#ifdef PDFENGINE_WITH_PDFIUM
|
||||
std::lock_guard<std::mutex> lock(textMutex_);
|
||||
if (textPage_) FPDFText_ClosePage(textPage_);
|
||||
if (page_) FPDF_ClosePage(page_);
|
||||
#endif
|
||||
doc_ = other.doc_;
|
||||
page_ = other.page_;
|
||||
textPage_ = other.textPage_;
|
||||
pageIndex_ = other.pageIndex_;
|
||||
ownerDoc_ = std::move(other.ownerDoc_);
|
||||
|
||||
other.doc_ = nullptr;
|
||||
other.page_ = nullptr;
|
||||
other.textPage_ = nullptr;
|
||||
other.pageIndex_ = 0;
|
||||
}
|
||||
return *this;
|
||||
}
|
||||
|
||||
double PdfiumPage::width() const noexcept {
|
||||
#ifdef PDFENGINE_WITH_PDFIUM
|
||||
return page_ ? FPDF_GetPageWidthF(page_) : 0.0;
|
||||
#else
|
||||
return 0.0;
|
||||
#endif
|
||||
}
|
||||
|
||||
double PdfiumPage::height() const noexcept {
|
||||
#ifdef PDFENGINE_WITH_PDFIUM
|
||||
return page_ ? FPDF_GetPageHeightF(page_) : 0.0;
|
||||
#else
|
||||
return 0.0;
|
||||
#endif
|
||||
}
|
||||
|
||||
std::expected<PageImage, EngineError> PdfiumPage::render(int dpi) const {
|
||||
#ifdef PDFENGINE_WITH_PDFIUM
|
||||
if (!page_) {
|
||||
return std::unexpected(EngineError::Unknown);
|
||||
}
|
||||
|
||||
if (!limits::pageDimensionsOk(width(), height())) {
|
||||
return std::unexpected(EngineError::RenderFailed);
|
||||
}
|
||||
|
||||
if (!limits::objectCountOk(FPDFPage_CountObjects(page_))) {
|
||||
spdlog::error("Refusing to render page: object count exceeds hardened limit ({})",
|
||||
limits::kMaxObjects);
|
||||
return std::unexpected(EngineError::RenderFailed);
|
||||
}
|
||||
|
||||
double scale = dpi / 72.0;
|
||||
int w = static_cast<int>(width() * scale);
|
||||
int h = static_cast<int>(height() * scale);
|
||||
|
||||
if (!limits::rasterSizeOk(w, h)) {
|
||||
return std::unexpected(EngineError::RenderFailed);
|
||||
}
|
||||
|
||||
FPDF_BITMAP bitmap = FPDFBitmap_Create(w, h, 1);
|
||||
if (!bitmap) {
|
||||
return std::unexpected(EngineError::RenderFailed);
|
||||
}
|
||||
|
||||
FPDFBitmap_FillRect(bitmap, 0, 0, w, h, 0xFFFFFFFF);
|
||||
|
||||
FPDF_RenderPageBitmap(bitmap, page_, 0, 0, w, h, 0, 0);
|
||||
|
||||
|
||||
const auto* buffer = static_cast<const uint8_t*>(FPDFBitmap_GetBuffer(bitmap));
|
||||
int stride = FPDFBitmap_GetStride(bitmap);
|
||||
|
||||
std::vector<uint8_t> pngBytes = encodeBgraToPng(buffer, w, h, stride);
|
||||
FPDFBitmap_Destroy(bitmap);
|
||||
|
||||
if (pngBytes.empty()) {
|
||||
return std::unexpected(EngineError::RenderFailed);
|
||||
}
|
||||
|
||||
return PageImage{w, h, std::move(pngBytes)};
|
||||
#else
|
||||
(void)dpi;
|
||||
return std::unexpected(EngineError::Unknown);
|
||||
#endif
|
||||
}
|
||||
|
||||
std::expected<PageImage, EngineError> PdfiumPage::renderRegionRaw(int dpi, double yTopPt, double heightPt) const {
|
||||
#ifdef PDFENGINE_WITH_PDFIUM
|
||||
if (!page_) return std::unexpected(EngineError::Unknown);
|
||||
if (!limits::pageDimensionsOk(width(), height())) return std::unexpected(EngineError::RenderFailed);
|
||||
if (!limits::objectCountOk(FPDFPage_CountObjects(page_))) return std::unexpected(EngineError::RenderFailed);
|
||||
|
||||
const double scale = dpi / 72.0;
|
||||
const int w = static_cast<int>(width() * scale);
|
||||
const int fullH = static_cast<int>(height() * scale);
|
||||
int yTopPx = static_cast<int>(yTopPt * scale);
|
||||
if (yTopPx < 0) yTopPx = 0;
|
||||
if (yTopPx > fullH) yTopPx = fullH;
|
||||
int regionH = heightPt > 0 ? static_cast<int>(heightPt * scale) : (fullH - yTopPx);
|
||||
if (regionH > fullH - yTopPx) regionH = fullH - yTopPx;
|
||||
if (w <= 0 || regionH <= 0) return std::unexpected(EngineError::RenderFailed);
|
||||
if (!limits::rasterSizeOk(w, regionH)) return std::unexpected(EngineError::RenderFailed);
|
||||
|
||||
FPDF_BITMAP bitmap = FPDFBitmap_Create(w, regionH, 1);
|
||||
if (!bitmap) return std::unexpected(EngineError::RenderFailed);
|
||||
FPDFBitmap_FillRect(bitmap, 0, 0, w, regionH, 0xFFFFFFFF);
|
||||
FPDF_RenderPageBitmap(bitmap, page_, 0, -yTopPx, w, fullH, 0, 0);
|
||||
|
||||
const auto* bgra = static_cast<const uint8_t*>(FPDFBitmap_GetBuffer(bitmap));
|
||||
const int stride = FPDFBitmap_GetStride(bitmap);
|
||||
std::vector<uint8_t> rgba(static_cast<size_t>(w) * regionH * 4);
|
||||
for (int y = 0; y < regionH; ++y) {
|
||||
const uint8_t* src = bgra + static_cast<size_t>(y) * stride;
|
||||
uint8_t* dst = rgba.data() + static_cast<size_t>(y) * w * 4;
|
||||
for (int x = 0; x < w; ++x) {
|
||||
dst[x * 4 + 0] = src[x * 4 + 2];
|
||||
dst[x * 4 + 1] = src[x * 4 + 1];
|
||||
dst[x * 4 + 2] = src[x * 4 + 0];
|
||||
dst[x * 4 + 3] = src[x * 4 + 3];
|
||||
}
|
||||
}
|
||||
FPDFBitmap_Destroy(bitmap);
|
||||
return PageImage{w, regionH, std::move(rgba)};
|
||||
#else
|
||||
(void)dpi; (void)yTopPt; (void)heightPt;
|
||||
return std::unexpected(EngineError::Unknown);
|
||||
#endif
|
||||
}
|
||||
|
||||
std::expected<std::string, EngineError> PdfiumPage::extractText() const {
|
||||
#ifdef PDFENGINE_WITH_PDFIUM
|
||||
if (!page_) {
|
||||
return std::unexpected(EngineError::Unknown);
|
||||
}
|
||||
ensureTextPageLoaded();
|
||||
if (!textPage_) {
|
||||
return std::unexpected(EngineError::Unknown);
|
||||
}
|
||||
|
||||
int charCount = FPDFText_CountChars(textPage_);
|
||||
if (charCount <= 0) {
|
||||
return "";
|
||||
}
|
||||
|
||||
std::vector<unsigned short> buffer(charCount + 1, 0);
|
||||
int written = FPDFText_GetText(textPage_, 0, charCount, buffer.data());
|
||||
if (written <= 0) {
|
||||
return "";
|
||||
}
|
||||
|
||||
return utf16le_to_utf8(reinterpret_cast<const char16_t*>(buffer.data()), written);
|
||||
#else
|
||||
return std::unexpected(EngineError::Unknown);
|
||||
#endif
|
||||
}
|
||||
|
||||
std::expected<std::vector<GlyphBounds>, EngineError> PdfiumPage::extractTextWithBounds() const {
|
||||
#ifdef PDFENGINE_WITH_PDFIUM
|
||||
if (!page_) {
|
||||
return std::unexpected(EngineError::Unknown);
|
||||
}
|
||||
ensureTextPageLoaded();
|
||||
if (!textPage_) {
|
||||
return std::unexpected(EngineError::Unknown);
|
||||
}
|
||||
|
||||
int charCount = FPDFText_CountChars(textPage_);
|
||||
std::vector<GlyphBounds> result;
|
||||
if (charCount <= 0) {
|
||||
return result;
|
||||
}
|
||||
|
||||
result.reserve(charCount);
|
||||
|
||||
for (int i = 0; i < charCount; ++i) {
|
||||
unsigned int codeUnit = FPDFText_GetUnicode(textPage_, i);
|
||||
|
||||
unsigned int cp = codeUnit;
|
||||
double left = 0, right = 0, bottom = 0, top = 0;
|
||||
double fontSize = FPDFText_GetFontSize(textPage_, i);
|
||||
|
||||
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);
|
||||
|
||||
double l1 = 0, r1 = 0, b1 = 0, t1 = 0;
|
||||
FPDFText_GetCharBox(textPage_, i, &l1, &r1, &b1, &t1);
|
||||
|
||||
double l2 = 0, r2 = 0, b2 = 0, t2 = 0;
|
||||
FPDFText_GetCharBox(textPage_, i + 1, &l2, &r2, &b2, &t2);
|
||||
|
||||
left = (std::min)(l1, l2);
|
||||
right = (std::max)(r1, r2);
|
||||
bottom = (std::min)(b1, b2);
|
||||
top = (std::max)(t1, t2);
|
||||
|
||||
++i;
|
||||
} else {
|
||||
FPDFText_GetCharBox(textPage_, i, &left, &right, &bottom, &top);
|
||||
}
|
||||
} else {
|
||||
FPDFText_GetCharBox(textPage_, i, &left, &right, &bottom, &top);
|
||||
}
|
||||
|
||||
std::string utf8_char = code_point_to_utf8(cp);
|
||||
if (utf8_char.empty() || cp == '\r' || cp == '\n') {
|
||||
continue;
|
||||
}
|
||||
|
||||
double x = (std::min)(left, right);
|
||||
double y = (std::min)(bottom, top);
|
||||
double w = std::abs(right - left);
|
||||
double h = std::abs(top - bottom);
|
||||
|
||||
GlyphBounds gb;
|
||||
gb.text = std::move(utf8_char);
|
||||
gb.x = x;
|
||||
gb.y = y;
|
||||
gb.w = w;
|
||||
gb.h = h;
|
||||
gb.fontSize = fontSize;
|
||||
result.push_back(gb);
|
||||
}
|
||||
|
||||
return result;
|
||||
#else
|
||||
return std::unexpected(EngineError::Unknown);
|
||||
#endif
|
||||
}
|
||||
DevicePoint PdfiumPage::pageToDevice(const Point2D& pagePoint, int deviceWidth, int deviceHeight, int rotate) const noexcept {
|
||||
#ifdef PDFENGINE_WITH_PDFIUM
|
||||
if (!page_) return {0, 0};
|
||||
int dx = 0, dy = 0;
|
||||
FPDF_PageToDevice(page_, 0, 0, deviceWidth, deviceHeight, rotate, pagePoint.x, pagePoint.y, &dx, &dy);
|
||||
return {dx, dy};
|
||||
#else
|
||||
(void)pagePoint; (void)deviceWidth; (void)deviceHeight; (void)rotate;
|
||||
return {0, 0};
|
||||
#endif
|
||||
}
|
||||
|
||||
Point2D PdfiumPage::deviceToPage(const DevicePoint& devicePoint, int deviceWidth, int deviceHeight, int rotate) const noexcept {
|
||||
#ifdef PDFENGINE_WITH_PDFIUM
|
||||
if (!page_) return {0.0, 0.0};
|
||||
double px = 0.0, py = 0.0;
|
||||
FPDF_DeviceToPage(page_, 0, 0, deviceWidth, deviceHeight, rotate, devicePoint.x, devicePoint.y, &px, &py);
|
||||
return {px, py};
|
||||
#else
|
||||
(void)devicePoint; (void)deviceWidth; (void)deviceHeight; (void)rotate;
|
||||
return {0.0, 0.0};
|
||||
#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 {
|
||||
#ifdef PDFENGINE_WITH_PDFIUM
|
||||
std::lock_guard<std::mutex> lock(textMutex_);
|
||||
if (!textPage_ && page_) {
|
||||
textPage_ = FPDFText_LoadPage(page_);
|
||||
}
|
||||
#endif
|
||||
}
|
||||
std::expected<std::string, EngineError> PdfiumPage::extractDisplayListJson() const {
|
||||
#if defined(PDFENGINE_WITH_PDFIUM) && defined(PDFENGINE_WITH_QPDF)
|
||||
if (!ownerDoc_) return std::unexpected(EngineError::Unknown);
|
||||
const auto& buffer = ownerDoc_->getMemoryBuffer();
|
||||
if (buffer.empty()) return std::unexpected(EngineError::FileNotFound);
|
||||
|
||||
qpdf_layer::QpdfExtractor extractor;
|
||||
auto stream = extractor.extractPageStreamFromMemory(buffer, pageIndex_);
|
||||
if (!stream) return std::unexpected(EngineError::InvalidFormat);
|
||||
|
||||
Lexer lexer(stream->decodedContent);
|
||||
auto tokens = lexer.tokenize();
|
||||
ContentParser parser(tokens);
|
||||
auto operations = parser.parse();
|
||||
|
||||
nlohmann::json j_array = nlohmann::json::array();
|
||||
for (const auto& op : operations) {
|
||||
nlohmann::json j_op = nlohmann::json::object();
|
||||
j_op["op"] = op.op;
|
||||
if (!op.operands.empty()) {
|
||||
nlohmann::json j_args = nlohmann::json::array();
|
||||
for (const auto& arg : op.operands) {
|
||||
if (arg->type == AstNodeType::Number) {
|
||||
j_args.push_back(arg->numberValue);
|
||||
} else if (arg->type == AstNodeType::Name || arg->type == AstNodeType::String) {
|
||||
j_args.push_back(arg->stringValue);
|
||||
} else {
|
||||
j_args.push_back("<other>");
|
||||
}
|
||||
}
|
||||
j_op["args"] = j_args;
|
||||
}
|
||||
j_array.push_back(j_op);
|
||||
}
|
||||
return j_array.dump();
|
||||
#else
|
||||
return std::unexpected(EngineError::Unknown);
|
||||
#endif
|
||||
}
|
||||
|
||||
std::expected<std::vector<uint8_t>, EngineError> PdfiumPage::extractImageXObject(const std::string& name) const {
|
||||
(void)name;
|
||||
return std::unexpected(EngineError::Unknown);
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -0,0 +1,634 @@
|
||||
#include "parser/pdfium_internal.hpp"
|
||||
|
||||
namespace pdfengine::parser {
|
||||
|
||||
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);
|
||||
|
||||
std::unordered_map<FPDF_PAGEOBJECT, int> objToIndex;
|
||||
int objCount = FPDFPage_CountObjects(page_);
|
||||
for (int i = 0; i < objCount; ++i) {
|
||||
objToIndex[FPDFPage_GetObject(page_, i)] = i;
|
||||
}
|
||||
|
||||
for (int i = 0; i < charCount; ++i) {
|
||||
unsigned int codeUnit = FPDFText_GetUnicode(textPage_, i);
|
||||
unsigned int cp = codeUnit;
|
||||
|
||||
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;
|
||||
continue;
|
||||
}
|
||||
|
||||
Glyph g;
|
||||
g.text = std::move(utf8_char);
|
||||
g.unicode = cp;
|
||||
g.srcIndex = i;
|
||||
|
||||
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);
|
||||
|
||||
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;
|
||||
|
||||
FPDF_PAGEOBJECT textObj = FPDFText_GetTextObject(textPage_, i);
|
||||
if (textObj) {
|
||||
auto it = objToIndex.find(textObj);
|
||||
if (it != objToIndex.end()) {
|
||||
g.pageObjectIndex = it->second;
|
||||
}
|
||||
}
|
||||
|
||||
documentGlyphs.push_back(g);
|
||||
|
||||
if (cp > 0xFFFF) ++i;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
return a.originX < b.originX;
|
||||
});
|
||||
|
||||
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) {
|
||||
if (a.originX != b.originX) return a.originX < b.originX;
|
||||
return a.srcIndex < b.srcIndex;
|
||||
});
|
||||
|
||||
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 p25Gap = 0.0;
|
||||
if (!gaps.empty()) {
|
||||
std::sort(gaps.begin(), gaps.end());
|
||||
p25Gap = gaps[gaps.size() / 4];
|
||||
}
|
||||
|
||||
double lineEm = 0.0;
|
||||
{
|
||||
std::vector<double> hs;
|
||||
for (const auto& gg : line.glyphs) if (gg.bboxH > 0.1) hs.push_back(gg.bboxH);
|
||||
if (!hs.empty()) {
|
||||
std::sort(hs.begin(), hs.end());
|
||||
double capH = hs[(hs.size() * 9) / 10];
|
||||
lineEm = capH / 0.7;
|
||||
}
|
||||
}
|
||||
|
||||
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.fontFidelity = classifyFontFidelity(it->second);
|
||||
}
|
||||
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 em = (std::max)(static_cast<double>(currG.fontSize), lineEm);
|
||||
double spaceThreshold = em * 0.2;
|
||||
if (p25Gap > spaceThreshold) {
|
||||
spaceThreshold = (std::min)(p25Gap * 1.5, em * 0.38);
|
||||
}
|
||||
|
||||
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) {
|
||||
for (const auto& g : currentRun.glyphs) {
|
||||
if (g.pageObjectIndex != -1 && std::find(currentRun.objectIndices.begin(), currentRun.objectIndices.end(), g.pageObjectIndex) == currentRun.objectIndices.end()) {
|
||||
currentRun.objectIndices.push_back(g.pageObjectIndex);
|
||||
}
|
||||
}
|
||||
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.fontFidelity = classifyFontFidelity(it->second);
|
||||
}
|
||||
}
|
||||
currentRun.glyphs.push_back(spaceGlyph);
|
||||
currentRun.text += spaceGlyph.text;
|
||||
} else if (breakRun) {
|
||||
for (const auto& g : currentRun.glyphs) {
|
||||
if (g.pageObjectIndex != -1 && std::find(currentRun.objectIndices.begin(), currentRun.objectIndices.end(), g.pageObjectIndex) == currentRun.objectIndices.end()) {
|
||||
currentRun.objectIndices.push_back(g.pageObjectIndex);
|
||||
}
|
||||
}
|
||||
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.fontFidelity = classifyFontFidelity(it->second);
|
||||
}
|
||||
}
|
||||
|
||||
currentRun.glyphs.push_back(currG);
|
||||
currentRun.text += currG.text;
|
||||
}
|
||||
if (!currentRun.glyphs.empty()) {
|
||||
for (const auto& g : currentRun.glyphs) {
|
||||
if (g.pageObjectIndex != -1 && std::find(currentRun.objectIndices.begin(), currentRun.objectIndices.end(), g.pageObjectIndex) == currentRun.objectIndices.end()) {
|
||||
currentRun.objectIndices.push_back(g.pageObjectIndex);
|
||||
}
|
||||
}
|
||||
line.runs.push_back(std::move(currentRun));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (auto& line : lines) {
|
||||
for (auto& run : line.runs) {
|
||||
std::string repaired = pdfengine::parser::repairMojibake(run.text);
|
||||
if (repaired != run.text) run.text = std::move(repaired);
|
||||
}
|
||||
}
|
||||
|
||||
std::vector<Paragraph> paragraphs;
|
||||
auto lineStyleKey = [](const TextLine& ln) -> std::pair<bool,bool> {
|
||||
for (const auto& r : ln.runs) {
|
||||
if (r.text.empty()) continue;
|
||||
std::string n = r.fontName + "|" + r.internalFontId;
|
||||
std::transform(n.begin(), n.end(), n.begin(), [](unsigned char c){ return static_cast<char>(std::tolower(c)); });
|
||||
bool bold = n.find("bold") != std::string::npos;
|
||||
bool sans = n.find("arial") != std::string::npos || n.find("helvetica") != std::string::npos;
|
||||
return {bold, sans};
|
||||
}
|
||||
return {false, false};
|
||||
};
|
||||
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 capH = 0.0;
|
||||
for (const auto& g : currLine.glyphs) if (g.bboxH > capH) capH = g.bboxH;
|
||||
if (capH > 0.0) fontSize = (std::max)(fontSize, capH / 0.7);
|
||||
double vGap = std::abs(prevY - currY);
|
||||
|
||||
bool styleChanged = lineStyleKey(currLine) != lineStyleKey(prevLine);
|
||||
if (vGap > fontSize * 1.5 || styleChanged) {
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
||||
auto computeRunBBox = [](TextRun& r) {
|
||||
if (r.glyphs.empty()) return;
|
||||
double minX = r.glyphs[0].bboxX;
|
||||
double minY = r.glyphs[0].bboxY;
|
||||
double maxX = r.glyphs[0].bboxX + r.glyphs[0].bboxW;
|
||||
double maxY = r.glyphs[0].bboxY + r.glyphs[0].bboxH;
|
||||
for (size_t i = 1; i < r.glyphs.size(); ++i) {
|
||||
minX = (std::min)(minX, r.glyphs[i].bboxX);
|
||||
minY = (std::min)(minY, r.glyphs[i].bboxY);
|
||||
maxX = (std::max)(maxX, r.glyphs[i].bboxX + r.glyphs[i].bboxW);
|
||||
maxY = (std::max)(maxY, r.glyphs[i].bboxY + r.glyphs[i].bboxH);
|
||||
}
|
||||
r.x = minX;
|
||||
r.y = minY;
|
||||
r.w = maxX - minX;
|
||||
r.h = maxY - minY;
|
||||
};
|
||||
|
||||
auto computeLineBBox = [](TextLine& l) {
|
||||
if (l.runs.empty()) return;
|
||||
double minX = l.runs[0].x;
|
||||
double minY = l.runs[0].y;
|
||||
double maxX = l.runs[0].x + l.runs[0].w;
|
||||
double maxY = l.runs[0].y + l.runs[0].h;
|
||||
for (size_t i = 1; i < l.runs.size(); ++i) {
|
||||
minX = (std::min)(minX, l.runs[i].x);
|
||||
minY = (std::min)(minY, l.runs[i].y);
|
||||
maxX = (std::max)(maxX, l.runs[i].x + l.runs[i].w);
|
||||
maxY = (std::max)(maxY, l.runs[i].y + l.runs[i].h);
|
||||
}
|
||||
l.x = minX;
|
||||
l.y = minY;
|
||||
l.w = maxX - minX;
|
||||
l.h = maxY - minY;
|
||||
};
|
||||
|
||||
auto computeParaBBox = [](Paragraph& p) {
|
||||
if (p.lines.empty()) return;
|
||||
double minX = p.lines[0].x;
|
||||
double minY = p.lines[0].y;
|
||||
double maxX = p.lines[0].x + p.lines[0].w;
|
||||
double maxY = p.lines[0].y + p.lines[0].h;
|
||||
for (size_t i = 1; i < p.lines.size(); ++i) {
|
||||
minX = (std::min)(minX, p.lines[i].x);
|
||||
minY = (std::min)(minY, p.lines[i].y);
|
||||
maxX = (std::max)(maxX, p.lines[i].x + p.lines[i].w);
|
||||
maxY = (std::max)(maxY, p.lines[i].y + p.lines[i].h);
|
||||
}
|
||||
p.x = minX;
|
||||
p.y = minY;
|
||||
p.w = maxX - minX;
|
||||
p.h = maxY - minY;
|
||||
};
|
||||
|
||||
auto hex2 = [](unsigned int c) -> std::string {
|
||||
static const char* h = "0123456789abcdef";
|
||||
c &= 0xFF;
|
||||
return std::string{h[(c >> 4) & 0xF], h[c & 0xF]};
|
||||
};
|
||||
auto computeRunColor = [&](TextRun& r) {
|
||||
if (r.objectIndices.empty()) return;
|
||||
std::vector<int> idxs = r.objectIndices;
|
||||
std::sort(idxs.begin(), idxs.end());
|
||||
for (int idx : idxs) {
|
||||
FPDF_PAGEOBJECT obj = FPDFPage_GetObject(page_, idx);
|
||||
if (!obj || FPDFPageObj_GetType(obj) != FPDF_PAGEOBJ_TEXT) continue;
|
||||
unsigned int cr = 0, cg = 0, cb = 0, ca = 0;
|
||||
bool got = (FPDFPageObj_GetFillColor(obj, &cr, &cg, &cb, &ca) && ca != 0) ||
|
||||
(FPDFPageObj_GetStrokeColor(obj, &cr, &cg, &cb, &ca) && ca != 0);
|
||||
if (got) {
|
||||
r.fillColor = "#" + hex2(cr) + hex2(cg) + hex2(cb);
|
||||
}
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
auto computeRunParaId = [&](TextRun& r) {
|
||||
if (r.objectIndices.empty()) return;
|
||||
int minIdx = r.objectIndices[0];
|
||||
for (int idx : r.objectIndices) if (idx < minIdx) minIdx = idx;
|
||||
FPDF_PAGEOBJECT obj = FPDFPage_GetObject(page_, minIdx);
|
||||
if (obj) r.paraId = repagGetParaId(obj);
|
||||
};
|
||||
|
||||
for (auto& p : paragraphs) {
|
||||
for (auto& l : p.lines) {
|
||||
l.baselineY = l.glyphs.empty() ? 0.0 : l.glyphs[0].originY;
|
||||
for (auto& r : l.runs) {
|
||||
computeRunBBox(r);
|
||||
computeRunColor(r);
|
||||
computeRunParaId(r);
|
||||
}
|
||||
computeLineBBox(l);
|
||||
}
|
||||
computeParaBBox(p);
|
||||
}
|
||||
|
||||
model.paragraphs = std::move(paragraphs);
|
||||
|
||||
return model;
|
||||
#else
|
||||
return std::unexpected(EngineError::Unknown);
|
||||
#endif
|
||||
}
|
||||
|
||||
std::expected<std::vector<std::string>, EngineError> PdfiumPage::extractAnnotationsText() const {
|
||||
#ifdef PDFENGINE_WITH_PDFIUM
|
||||
if (!page_) {
|
||||
return std::unexpected(EngineError::Unknown);
|
||||
}
|
||||
std::vector<std::string> result;
|
||||
int count = FPDFPage_GetAnnotCount(page_);
|
||||
for (int i = 0; i < count; ++i) {
|
||||
FPDF_ANNOTATION annot = FPDFPage_GetAnnot(page_, i);
|
||||
if (!annot) continue;
|
||||
|
||||
if (FPDFAnnot_GetSubtype(annot) == FPDF_ANNOT_FREETEXT) {
|
||||
unsigned long len = FPDFAnnot_GetStringValue(annot, "Contents", nullptr, 0);
|
||||
if (len > 2) {
|
||||
std::vector<uint8_t> buf(len);
|
||||
FPDFAnnot_GetStringValue(annot, "Contents", reinterpret_cast<FPDF_WCHAR*>(buf.data()), len);
|
||||
std::string text = utf16le_to_utf8(reinterpret_cast<const char16_t*>(buf.data()), len / sizeof(char16_t));
|
||||
while (!text.empty() && text.back() == '\0') {
|
||||
text.pop_back();
|
||||
}
|
||||
if (!text.empty()) {
|
||||
result.push_back(text);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
#else
|
||||
return std::unexpected(EngineError::Unknown);
|
||||
#endif
|
||||
}
|
||||
|
||||
std::expected<std::vector<PdfPage::AnnotationInfo>, EngineError> PdfiumPage::extractAnnotations() const {
|
||||
#ifdef PDFENGINE_WITH_PDFIUM
|
||||
if (!page_) {
|
||||
return std::unexpected(EngineError::Unknown);
|
||||
}
|
||||
|
||||
FPDF_FORMFILLINFO formInfo;
|
||||
memset(&formInfo, 0, sizeof(formInfo));
|
||||
formInfo.version = 1;
|
||||
FPDF_FORMHANDLE formHandle = FPDFDOC_InitFormFillEnvironment(doc_, &formInfo);
|
||||
|
||||
std::vector<PdfPage::AnnotationInfo> result;
|
||||
int count = FPDFPage_GetAnnotCount(page_);
|
||||
for (int i = 0; i < count; ++i) {
|
||||
FPDF_ANNOTATION annot = FPDFPage_GetAnnot(page_, i);
|
||||
if (!annot) continue;
|
||||
|
||||
PdfPage::AnnotationInfo info;
|
||||
info.pageIndex = pageIndex_;
|
||||
|
||||
unsigned long len = FPDFAnnot_GetStringValue(annot, "NM", nullptr, 0);
|
||||
if (len > 2) {
|
||||
std::vector<uint8_t> buf(len);
|
||||
FPDFAnnot_GetStringValue(annot, "NM", reinterpret_cast<FPDF_WCHAR*>(buf.data()), len);
|
||||
std::string text = utf16le_to_utf8(reinterpret_cast<const char16_t*>(buf.data()), len / sizeof(char16_t));
|
||||
while (!text.empty() && text.back() == '\0') text.pop_back();
|
||||
info.id = text;
|
||||
}
|
||||
if (info.id.empty()) {
|
||||
info.id = "anno_" + std::to_string(pageIndex_) + "_" + std::to_string(i);
|
||||
}
|
||||
|
||||
int subtype = FPDFAnnot_GetSubtype(annot);
|
||||
if (subtype == FPDF_ANNOT_HIGHLIGHT) {
|
||||
info.type = "highlight";
|
||||
} else if (subtype == FPDF_ANNOT_FREETEXT || subtype == FPDF_ANNOT_TEXT) {
|
||||
info.type = "comment";
|
||||
} else if (subtype == FPDF_ANNOT_INK) {
|
||||
info.type = "ink";
|
||||
} else if (subtype == FPDF_ANNOT_STRIKEOUT) {
|
||||
info.type = "strikeout";
|
||||
} else if (subtype == FPDF_ANNOT_UNDERLINE) {
|
||||
info.type = "underline";
|
||||
} else if (subtype == FPDF_ANNOT_SQUIGGLY) {
|
||||
info.type = "squiggly";
|
||||
} else if (subtype == FPDF_ANNOT_WIDGET) {
|
||||
info.type = "widget";
|
||||
|
||||
int fieldType = FPDFAnnot_GetFormFieldType(formHandle, annot);
|
||||
|
||||
if (fieldType == 1 || fieldType == 2 || fieldType == 3) {
|
||||
info.fieldType = "Btn";
|
||||
} else if (fieldType == 4 || fieldType == 5) {
|
||||
info.fieldType = "Ch";
|
||||
} else if (fieldType == 6) {
|
||||
info.fieldType = "Tx";
|
||||
} else if (fieldType == 7) {
|
||||
info.fieldType = "Sig";
|
||||
} else {
|
||||
info.fieldType = "Unknown";
|
||||
}
|
||||
|
||||
info.fieldFlags = FPDFAnnot_GetFormFieldFlags(formHandle, annot);
|
||||
|
||||
unsigned long nameLen = FPDFAnnot_GetFormFieldName(formHandle, annot, nullptr, 0);
|
||||
if (nameLen > 2) {
|
||||
std::vector<FPDF_WCHAR> nameBuf(nameLen / 2);
|
||||
FPDFAnnot_GetFormFieldName(formHandle, annot, nameBuf.data(), nameLen);
|
||||
std::string text = utf16le_to_utf8(reinterpret_cast<const char16_t*>(nameBuf.data()), nameBuf.size());
|
||||
while (!text.empty() && text.back() == '\0') text.pop_back();
|
||||
info.fieldName = text;
|
||||
}
|
||||
|
||||
unsigned long valLen = FPDFAnnot_GetFormFieldValue(formHandle, annot, nullptr, 0);
|
||||
if (valLen > 2) {
|
||||
std::vector<FPDF_WCHAR> valBuf(valLen / 2);
|
||||
FPDFAnnot_GetFormFieldValue(formHandle, annot, valBuf.data(), valLen);
|
||||
std::string text = utf16le_to_utf8(reinterpret_cast<const char16_t*>(valBuf.data()), valBuf.size());
|
||||
while (!text.empty() && text.back() == '\0') text.pop_back();
|
||||
info.fieldValue = text;
|
||||
}
|
||||
|
||||
if (info.fieldType == "Ch") {
|
||||
int optCount = FPDFAnnot_GetOptionCount(formHandle, annot);
|
||||
for (int o = 0; o < optCount; ++o) {
|
||||
unsigned long optLen = FPDFAnnot_GetOptionLabel(formHandle, annot, o, nullptr, 0);
|
||||
if (optLen > 2) {
|
||||
std::vector<FPDF_WCHAR> optBuf(optLen / 2);
|
||||
FPDFAnnot_GetOptionLabel(formHandle, annot, o, optBuf.data(), optLen);
|
||||
std::string optText = utf16le_to_utf8(reinterpret_cast<const char16_t*>(optBuf.data()), optBuf.size());
|
||||
while (!optText.empty() && optText.back() == '\0') optText.pop_back();
|
||||
info.fieldOptions.push_back(optText);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
} else {
|
||||
info.type = "unknown";
|
||||
}
|
||||
|
||||
if (info.type != "unknown") {
|
||||
FS_RECTF rect;
|
||||
if (FPDFAnnot_GetRect(annot, &rect)) {
|
||||
int dw = static_cast<int>(std::round(width()));
|
||||
int dh = static_cast<int>(std::round(height()));
|
||||
DevicePoint topLeft = pageToDevice({rect.left, rect.top}, dw, dh, 0);
|
||||
DevicePoint bottomRight = pageToDevice({rect.right, rect.bottom}, dw, dh, 0);
|
||||
|
||||
info.x = (std::min)(topLeft.x, bottomRight.x);
|
||||
info.y = (std::min)(topLeft.y, bottomRight.y);
|
||||
info.width = std::abs(bottomRight.x - topLeft.x);
|
||||
info.height = std::abs(bottomRight.y - topLeft.y);
|
||||
}
|
||||
|
||||
len = FPDFAnnot_GetStringValue(annot, "T", nullptr, 0);
|
||||
if (len > 2) {
|
||||
std::vector<uint8_t> buf(len);
|
||||
FPDFAnnot_GetStringValue(annot, "T", reinterpret_cast<FPDF_WCHAR*>(buf.data()), len);
|
||||
std::string text = utf16le_to_utf8(reinterpret_cast<const char16_t*>(buf.data()), len / sizeof(char16_t));
|
||||
while (!text.empty() && text.back() == '\0') text.pop_back();
|
||||
info.author = text;
|
||||
}
|
||||
|
||||
len = FPDFAnnot_GetStringValue(annot, "Contents", nullptr, 0);
|
||||
if (len > 2) {
|
||||
std::vector<uint8_t> buf(len);
|
||||
FPDFAnnot_GetStringValue(annot, "Contents", reinterpret_cast<FPDF_WCHAR*>(buf.data()), len);
|
||||
std::string text = utf16le_to_utf8(reinterpret_cast<const char16_t*>(buf.data()), len / sizeof(char16_t));
|
||||
while (!text.empty() && text.back() == '\0') text.pop_back();
|
||||
info.content = text;
|
||||
}
|
||||
|
||||
len = FPDFAnnot_GetStringValue(annot, "M", nullptr, 0);
|
||||
if (len <= 2) {
|
||||
len = FPDFAnnot_GetStringValue(annot, "CreationDate", nullptr, 0);
|
||||
if (len > 2) {
|
||||
std::vector<uint8_t> buf(len);
|
||||
FPDFAnnot_GetStringValue(annot, "CreationDate", reinterpret_cast<FPDF_WCHAR*>(buf.data()), len);
|
||||
std::string text = utf16le_to_utf8(reinterpret_cast<const char16_t*>(buf.data()), len / sizeof(char16_t));
|
||||
while (!text.empty() && text.back() == '\0') text.pop_back();
|
||||
info.timestamp = text;
|
||||
}
|
||||
} else {
|
||||
std::vector<uint8_t> buf(len);
|
||||
FPDFAnnot_GetStringValue(annot, "M", reinterpret_cast<FPDF_WCHAR*>(buf.data()), len);
|
||||
std::string text = utf16le_to_utf8(reinterpret_cast<const char16_t*>(buf.data()), len / sizeof(char16_t));
|
||||
while (!text.empty() && text.back() == '\0') text.pop_back();
|
||||
info.timestamp = text;
|
||||
}
|
||||
|
||||
unsigned int R = 0, G = 0, B = 0, A = 0;
|
||||
if (FPDFAnnot_GetColor(annot, FPDFANNOT_COLORTYPE_Color, &R, &G, &B, &A)) {
|
||||
char hex[10];
|
||||
snprintf(hex, sizeof(hex), "#%02x%02x%02x", R, G, B);
|
||||
info.color = hex;
|
||||
}
|
||||
|
||||
if (subtype == FPDF_ANNOT_INK) {
|
||||
const double pageH = height();
|
||||
unsigned long strokeCount = FPDFAnnot_GetInkListCount(annot);
|
||||
for (unsigned long s = 0; s < strokeCount; ++s) {
|
||||
unsigned long ptCount = FPDFAnnot_GetInkListPath(annot, s, nullptr, 0);
|
||||
if (ptCount == 0) continue;
|
||||
std::vector<FS_POINTF> pts(ptCount);
|
||||
FPDFAnnot_GetInkListPath(annot, s, pts.data(), ptCount);
|
||||
std::vector<Point2D> stroke;
|
||||
stroke.reserve(ptCount);
|
||||
for (const auto& p : pts) {
|
||||
stroke.push_back(Point2D{static_cast<double>(p.x), pageH - static_cast<double>(p.y)});
|
||||
}
|
||||
if (!stroke.empty()) info.paths.push_back(std::move(stroke));
|
||||
}
|
||||
}
|
||||
|
||||
result.push_back(info);
|
||||
}
|
||||
|
||||
FPDFPage_CloseAnnot(annot);
|
||||
}
|
||||
|
||||
FPDFDOC_ExitFormFillEnvironment(formHandle);
|
||||
|
||||
return result;
|
||||
#else
|
||||
return std::unexpected(EngineError::Unknown);
|
||||
#endif
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -0,0 +1,286 @@
|
||||
#include "parser/pdfium_internal.hpp"
|
||||
|
||||
namespace pdfengine::parser {
|
||||
#ifdef PDFENGINE_WITH_PDFIUM
|
||||
|
||||
void repagSetParaId(FPDF_DOCUMENT doc, FPDF_PAGEOBJECT obj, const std::string& paraId) {
|
||||
if (paraId.empty() || !obj) return;
|
||||
FPDF_PAGEOBJECTMARK mark = FPDFPageObj_AddMark(obj, "PDFPARA");
|
||||
if (mark) FPDFPageObjMark_SetStringParam(doc, obj, mark, "id", paraId.c_str());
|
||||
}
|
||||
|
||||
std::string repagGetParaId(FPDF_PAGEOBJECT obj) {
|
||||
if (!obj) return "";
|
||||
int n = FPDFPageObj_CountMarks(obj);
|
||||
for (int i = 0; i < n; ++i) {
|
||||
FPDF_PAGEOBJECTMARK mark = FPDFPageObj_GetMark(obj, i);
|
||||
if (!mark) continue;
|
||||
unsigned long nl = 0;
|
||||
FPDFPageObjMark_GetName(mark, nullptr, 0, &nl);
|
||||
if (nl < 2) continue;
|
||||
std::vector<unsigned short> nb(nl / 2);
|
||||
FPDFPageObjMark_GetName(mark, reinterpret_cast<FPDF_WCHAR*>(nb.data()), nl, &nl);
|
||||
std::string name = utf16le_to_utf8(reinterpret_cast<const char16_t*>(nb.data()), nb.size());
|
||||
while (!name.empty() && name.back() == '\0') name.pop_back();
|
||||
if (name != "PDFPARA") continue;
|
||||
unsigned long vl = 0;
|
||||
if (!FPDFPageObjMark_GetParamStringValue(mark, "id", nullptr, 0, &vl) || vl < 2) continue;
|
||||
std::vector<unsigned short> vb(vl / 2);
|
||||
if (!FPDFPageObjMark_GetParamStringValue(mark, "id", reinterpret_cast<FPDF_WCHAR*>(vb.data()), vl, &vl))
|
||||
continue;
|
||||
std::string val = utf16le_to_utf8(reinterpret_cast<const char16_t*>(vb.data()), vb.size());
|
||||
while (!val.empty() && val.back() == '\0') val.pop_back();
|
||||
return val;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
void walkOutline(FPDF_DOCUMENT doc, FPDF_BOOKMARK bm, int level,
|
||||
std::vector<PdfDocument::OutlineItem>& out) {
|
||||
while (bm && level <= 32 && out.size() < 5000) {
|
||||
PdfDocument::OutlineItem item;
|
||||
item.level = level;
|
||||
|
||||
unsigned long len = FPDFBookmark_GetTitle(bm, nullptr, 0);
|
||||
if (len > 2) {
|
||||
std::vector<unsigned short> buf(len / 2);
|
||||
FPDFBookmark_GetTitle(bm, buf.data(), len);
|
||||
item.title = utf16le_to_utf8(reinterpret_cast<const char16_t*>(buf.data()), buf.size());
|
||||
while (!item.title.empty() && item.title.back() == '\0') item.title.pop_back();
|
||||
}
|
||||
|
||||
FPDF_DEST dest = FPDFBookmark_GetDest(doc, bm);
|
||||
if (!dest) {
|
||||
FPDF_ACTION action = FPDFBookmark_GetAction(bm);
|
||||
if (action) dest = FPDFAction_GetDest(doc, action);
|
||||
}
|
||||
item.pageIndex = dest ? static_cast<int>(FPDFDest_GetDestPageIndex(doc, dest)) : -1;
|
||||
|
||||
out.push_back(std::move(item));
|
||||
|
||||
FPDF_BOOKMARK child = FPDFBookmark_GetFirstChild(doc, bm);
|
||||
if (child) walkOutline(doc, child, level + 1, out);
|
||||
bm = FPDFBookmark_GetNextSibling(doc, bm);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
inline bool repagInColumn(FPDF_PAGEOBJECT o, double pushColLeft, double colRight) {
|
||||
float l = 0, b = 0, r = 0, t = 0;
|
||||
if (!FPDFPageObj_GetBounds(o, &l, &b, &r, &t)) return false;
|
||||
return (r > pushColLeft && l < colRight);
|
||||
}
|
||||
|
||||
PageBand repagComputeBand(FPDF_PAGE page, double pushColLeft, double colRight) {
|
||||
PageBand band;
|
||||
band.pageW = FPDF_GetPageWidthF(page);
|
||||
band.pageH = FPDF_GetPageHeightF(page);
|
||||
double maxTop = -1e18; bool any = false;
|
||||
int n = FPDFPage_CountObjects(page);
|
||||
for (int k = 0; k < n; ++k) {
|
||||
FPDF_PAGEOBJECT o = FPDFPage_GetObject(page, k);
|
||||
if (!o) continue;
|
||||
float l = 0, b = 0, r = 0, t = 0;
|
||||
if (!FPDFPageObj_GetBounds(o, &l, &b, &r, &t)) continue;
|
||||
if (r > pushColLeft && l < colRight) { if (t > maxTop) { maxTop = t; } any = true; }
|
||||
}
|
||||
if (!any) return band;
|
||||
band.placementTopY = maxTop;
|
||||
double limit = band.pageH - maxTop;
|
||||
if (limit < 18.0) limit = 18.0;
|
||||
if (limit > band.pageH * 0.25) limit = band.pageH * 0.25;
|
||||
band.bottomLimitY = limit;
|
||||
band.valid = true;
|
||||
return band;
|
||||
}
|
||||
|
||||
std::vector<double> repagAnchoredCenters(FPDF_PAGE page, double pushColLeft, double colRight,
|
||||
double bottomLimitY) {
|
||||
std::vector<double> out;
|
||||
int n = FPDFPage_CountObjects(page);
|
||||
for (int k = 0; k < n; ++k) {
|
||||
FPDF_PAGEOBJECT o = FPDFPage_GetObject(page, k);
|
||||
if (!o || !repagInColumn(o, pushColLeft, colRight)) continue;
|
||||
float l = 0, b = 0, r = 0, t = 0;
|
||||
if (!FPDFPageObj_GetBounds(o, &l, &b, &r, &t)) continue;
|
||||
double cy = (b + t) / 2.0;
|
||||
if (cy < bottomLimitY) out.push_back(cy);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
inline bool repagIsAnchored(double cy, const std::vector<double>& anchored, double tol = 1.0) {
|
||||
for (double ac : anchored) if (std::abs(ac - cy) < tol) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
int repaginateForward(FPDF_DOCUMENT doc, int startPage,
|
||||
double colLeft, double colRight, double pushColLeft, double leading,
|
||||
const std::vector<double>& anchoredCentersStart, int* movedOut) {
|
||||
(void)colLeft;
|
||||
const int kMaxPagesAdded = 200;
|
||||
int pagesAdded = 0;
|
||||
int movedTotal = 0;
|
||||
int curPage = startPage;
|
||||
std::vector<double> anchored = anchoredCentersStart;
|
||||
|
||||
while (true) {
|
||||
FPDF_PAGE P = FPDF_LoadPage(doc, curPage);
|
||||
if (!P) break;
|
||||
PageBand band = repagComputeBand(P, pushColLeft, colRight);
|
||||
if (!band.valid) { FPDF_ClosePage(P); break; }
|
||||
|
||||
std::vector<FPDF_PAGEOBJECT> overflow;
|
||||
double blockTop = -1e18, blockBottom = 1e18;
|
||||
int n = FPDFPage_CountObjects(P);
|
||||
for (int k = 0; k < n; ++k) {
|
||||
FPDF_PAGEOBJECT o = FPDFPage_GetObject(P, k);
|
||||
if (!o || !repagInColumn(o, pushColLeft, colRight)) continue;
|
||||
float l = 0, b = 0, r = 0, t = 0;
|
||||
if (!FPDFPageObj_GetBounds(o, &l, &b, &r, &t)) continue;
|
||||
double cy = (b + t) / 2.0;
|
||||
if (cy >= band.bottomLimitY) continue;
|
||||
if (repagIsAnchored(cy, anchored)) continue;
|
||||
overflow.push_back(o);
|
||||
if (t > blockTop) blockTop = t;
|
||||
if (b < blockBottom) blockBottom = b;
|
||||
}
|
||||
if (overflow.empty()) { FPDF_ClosePage(P); break; }
|
||||
|
||||
int total = FPDF_GetPageCount(doc);
|
||||
FPDF_PAGE Q = nullptr;
|
||||
bool createdNew = false;
|
||||
if (curPage + 1 >= total) {
|
||||
if (pagesAdded + 1 > kMaxPagesAdded) {
|
||||
spdlog::warn("repaginateForward: hit page-add cap ({}), stopping", kMaxPagesAdded);
|
||||
FPDF_ClosePage(P); break;
|
||||
}
|
||||
Q = FPDFPage_New(doc, curPage + 1, band.pageW, band.pageH);
|
||||
createdNew = true;
|
||||
pagesAdded++;
|
||||
} else {
|
||||
Q = FPDF_LoadPage(doc, curPage + 1);
|
||||
}
|
||||
if (!Q) { FPDF_ClosePage(P); break; }
|
||||
|
||||
PageBand bandQ = createdNew ? band : repagComputeBand(Q, pushColLeft, colRight);
|
||||
double placeTopQ = bandQ.valid ? bandQ.placementTopY : band.placementTopY;
|
||||
double limitQ = bandQ.valid ? bandQ.bottomLimitY : band.bottomLimitY;
|
||||
|
||||
double blockH = blockTop - blockBottom;
|
||||
if (blockH < 0) blockH = 0;
|
||||
|
||||
std::vector<double> anchoredQ =
|
||||
createdNew ? std::vector<double>{}
|
||||
: repagAnchoredCenters(Q, pushColLeft, colRight, limitQ);
|
||||
|
||||
if (!createdNew) {
|
||||
double shift = blockH + leading;
|
||||
int nq = FPDFPage_CountObjects(Q);
|
||||
for (int k = 0; k < nq; ++k) {
|
||||
FPDF_PAGEOBJECT o = FPDFPage_GetObject(Q, k);
|
||||
if (!o || !repagInColumn(o, pushColLeft, colRight)) continue;
|
||||
float l = 0, b = 0, r = 0, t = 0;
|
||||
if (!FPDFPageObj_GetBounds(o, &l, &b, &r, &t)) continue;
|
||||
double cy = (b + t) / 2.0;
|
||||
if (repagIsAnchored(cy, anchoredQ)) continue;
|
||||
FPDFPageObj_Transform(o, 1.0, 0.0, 0.0, 1.0, 0.0, -shift);
|
||||
}
|
||||
}
|
||||
|
||||
double translateDy = placeTopQ - blockTop;
|
||||
for (FPDF_PAGEOBJECT o : overflow) {
|
||||
FPDFPage_RemoveObject(P, o);
|
||||
FPDFPageObj_Transform(o, 1.0, 0.0, 0.0, 1.0, 0.0, translateDy);
|
||||
FPDFPage_InsertObject(Q, o);
|
||||
}
|
||||
|
||||
FPDFPage_GenerateContent(P);
|
||||
FPDFPage_GenerateContent(Q);
|
||||
spdlog::info("repaginateForward: page {} -> {} moved {} object(s), blockH={:.1f}{}",
|
||||
curPage, curPage + 1, overflow.size(), blockH, createdNew ? " (new page)" : "");
|
||||
FPDF_ClosePage(P);
|
||||
FPDF_ClosePage(Q);
|
||||
|
||||
movedTotal += static_cast<int>(overflow.size());
|
||||
|
||||
anchored = anchoredQ;
|
||||
curPage += 1;
|
||||
}
|
||||
if (movedOut) *movedOut = movedTotal;
|
||||
return pagesAdded;
|
||||
}
|
||||
|
||||
int repaginateBackward(FPDF_DOCUMENT doc, int startPage,
|
||||
double colLeft, double colRight, double pushColLeft, double leading) {
|
||||
(void)colLeft; (void)colRight; (void)pushColLeft; (void)leading;
|
||||
int pagesRemoved = 0;
|
||||
for (int pg = FPDF_GetPageCount(doc) - 1; pg > startPage; --pg) {
|
||||
FPDF_PAGE p = FPDF_LoadPage(doc, pg);
|
||||
if (!p) break;
|
||||
int n = FPDFPage_CountObjects(p);
|
||||
FPDF_ClosePage(p);
|
||||
if (n == 0) { FPDFPage_Delete(doc, pg); pagesRemoved++; }
|
||||
else break;
|
||||
}
|
||||
return pagesRemoved;
|
||||
}
|
||||
|
||||
void repagCompactToTop(FPDF_DOCUMENT doc, int pageIdx, double pushColLeft, double colRight) {
|
||||
FPDF_PAGE p = FPDF_LoadPage(doc, pageIdx);
|
||||
if (!p) return;
|
||||
PageBand band = repagComputeBand(p, pushColLeft, colRight);
|
||||
if (!band.valid) { FPDF_ClosePage(p); return; }
|
||||
std::vector<double> anchored = repagAnchoredCenters(p, pushColLeft, colRight, band.bottomLimitY);
|
||||
double curTop = -1e18; bool any = false;
|
||||
int n = FPDFPage_CountObjects(p);
|
||||
for (int k = 0; k < n; ++k) {
|
||||
FPDF_PAGEOBJECT o = FPDFPage_GetObject(p, k);
|
||||
if (!o || !repagInColumn(o, pushColLeft, colRight)) continue;
|
||||
float l=0,b=0,r=0,t=0; if (!FPDFPageObj_GetBounds(o,&l,&b,&r,&t)) continue;
|
||||
double cy=(b+t)/2.0; if (repagIsAnchored(cy, anchored)) continue;
|
||||
if (t > curTop) { curTop = t; any = true; }
|
||||
}
|
||||
if (any) {
|
||||
double up = band.placementTopY - curTop;
|
||||
if (up > 0.01) {
|
||||
for (int k = 0; k < n; ++k) {
|
||||
FPDF_PAGEOBJECT o = FPDFPage_GetObject(p, k);
|
||||
if (!o || !repagInColumn(o, pushColLeft, colRight)) continue;
|
||||
float l=0,b=0,r=0,t=0; if (!FPDFPageObj_GetBounds(o,&l,&b,&r,&t)) continue;
|
||||
double cy=(b+t)/2.0; if (repagIsAnchored(cy, anchored)) continue;
|
||||
FPDFPageObj_Transform(o, 1.0, 0.0, 0.0, 1.0, 0.0, up);
|
||||
}
|
||||
FPDFPage_GenerateContent(p);
|
||||
}
|
||||
}
|
||||
FPDF_ClosePage(p);
|
||||
}
|
||||
|
||||
int repagRemoveContinuations(FPDF_DOCUMENT doc, int anchorPage, const std::string& paraId,
|
||||
double pushColLeft, double colRight) {
|
||||
if (paraId.empty()) return 0;
|
||||
int touched = 0;
|
||||
int total = FPDF_GetPageCount(doc);
|
||||
for (int pg = 0; pg < total; ++pg) {
|
||||
if (pg == anchorPage) continue;
|
||||
FPDF_PAGE p = FPDF_LoadPage(doc, pg);
|
||||
if (!p) continue;
|
||||
std::vector<FPDF_PAGEOBJECT> toDel;
|
||||
int n = FPDFPage_CountObjects(p);
|
||||
for (int k = 0; k < n; ++k) {
|
||||
FPDF_PAGEOBJECT o = FPDFPage_GetObject(p, k);
|
||||
if (o && repagGetParaId(o) == paraId) toDel.push_back(o);
|
||||
}
|
||||
if (toDel.empty()) { FPDF_ClosePage(p); continue; }
|
||||
for (auto o : toDel) { FPDFPage_RemoveObject(p, o); FPDFPageObj_Destroy(o); }
|
||||
FPDFPage_GenerateContent(p);
|
||||
FPDF_ClosePage(p);
|
||||
repagCompactToTop(doc, pg, pushColLeft, colRight);
|
||||
touched++;
|
||||
}
|
||||
return touched;
|
||||
}
|
||||
|
||||
#endif
|
||||
}
|
||||
@@ -1,11 +1,16 @@
|
||||
# Engine test suite. Phase 0: a single smoke test that backs Gate G0.
|
||||
|
||||
add_executable(pdfengine_smoke
|
||||
smoke_test.cpp
|
||||
fonts_test.cpp
|
||||
font_rendering_test.cpp
|
||||
font_loader_descriptor_test.cpp
|
||||
font_encoding_test.cpp
|
||||
font_types_test.cpp
|
||||
graphics_state_test.cpp
|
||||
display_list_test.cpp
|
||||
document_test.cpp
|
||||
document_load_test.cpp
|
||||
page_render_test.cpp
|
||||
document_edit_test.cpp
|
||||
font_diagnostics_test.cpp
|
||||
text_encoding_test.cpp
|
||||
skia_renderer_test.cpp
|
||||
lexer_test.cpp
|
||||
parser_test.cpp
|
||||
@@ -46,7 +51,6 @@ target_include_directories(pdfengine_smoke
|
||||
"${CMAKE_CURRENT_SOURCE_DIR}/../src"
|
||||
)
|
||||
|
||||
# Locate and normalize corpus path
|
||||
set(TEST_CORPUS_DIR "${CMAKE_CURRENT_SOURCE_DIR}/../../corpus" CACHE PATH "Path to test corpus")
|
||||
file(TO_CMAKE_PATH "${TEST_CORPUS_DIR}" TEST_CORPUS_DIR_NORM)
|
||||
target_compile_definitions(pdfengine_smoke PRIVATE TEST_CORPUS_DIR="${TEST_CORPUS_DIR_NORM}")
|
||||
@@ -54,5 +58,4 @@ target_compile_definitions(pdfengine_smoke PRIVATE TEST_CORPUS_DIR="${TEST_CORPU
|
||||
pdfengine_set_warnings(pdfengine_smoke)
|
||||
pdfengine_enable_sanitizers(pdfengine_smoke)
|
||||
|
||||
# Registers each TEST() with CTest so `ctest --preset ...` runs them.
|
||||
gtest_discover_tests(pdfengine_smoke)
|
||||
gtest_discover_tests(pdfengine_smoke)
|
||||
@@ -0,0 +1,689 @@
|
||||
#include "document_test_helpers.hpp"
|
||||
|
||||
namespace pdfengine {
|
||||
|
||||
TEST(DocumentEditTest, ApplyEditsAndIncrementalSave) {
|
||||
SKIP_IF_NO_PDFIUM();
|
||||
auto path = getCorpusPath("basic", "hello_world.pdf");
|
||||
if (!std::filesystem::exists(path)) {
|
||||
GTEST_SKIP() << "hello_world.pdf not found in corpus.";
|
||||
}
|
||||
|
||||
auto docRes = PdfDocument::loadFromFile(path.string());
|
||||
ASSERT_TRUE(docRes.has_value());
|
||||
auto doc = *docRes;
|
||||
|
||||
std::string editsJson = R"({
|
||||
"version": "1.0",
|
||||
"operations": [
|
||||
{
|
||||
"id": "op_test_1",
|
||||
"type": "text_overlay",
|
||||
"pageIndex": 0,
|
||||
"data": {
|
||||
"text": "UniqueEditedTextAnnotation123",
|
||||
"x": 100.0,
|
||||
"y": 150.0,
|
||||
"width": 200.0,
|
||||
"height": 20.0,
|
||||
"fontSize": 14.0,
|
||||
"fontFamily": "Helvetica",
|
||||
"color": "#000000"
|
||||
}
|
||||
}
|
||||
]
|
||||
})";
|
||||
|
||||
auto editRes = doc->applyEdits(editsJson);
|
||||
ASSERT_TRUE(editRes.has_value());
|
||||
|
||||
auto saveRes = doc->saveIncremental();
|
||||
ASSERT_TRUE(saveRes.has_value());
|
||||
const auto& savedBytes = *saveRes;
|
||||
ASSERT_FALSE(savedBytes.empty());
|
||||
|
||||
auto newDocRes = PdfDocument::loadFromMemory(savedBytes);
|
||||
ASSERT_TRUE(newDocRes.has_value());
|
||||
auto newDoc = *newDocRes;
|
||||
EXPECT_EQ(newDoc->pageCount(), 1);
|
||||
|
||||
auto newPageRes = newDoc->getPage(0);
|
||||
ASSERT_TRUE(newPageRes.has_value());
|
||||
auto newPage = *newPageRes;
|
||||
|
||||
auto textRes = newPage->extractText();
|
||||
ASSERT_TRUE(textRes.has_value());
|
||||
EXPECT_NE(textRes->find("UniqueEditedTextAnnotation123"), std::string::npos);
|
||||
}
|
||||
|
||||
TEST(DocumentEditTest, ApplyRedactionAndFullSave) {
|
||||
SKIP_IF_NO_PDFIUM();
|
||||
auto path = getCorpusPath("basic", "hello_world.pdf");
|
||||
if (!std::filesystem::exists(path)) {
|
||||
GTEST_SKIP() << "hello_world.pdf not found in corpus.";
|
||||
}
|
||||
|
||||
auto docRes = PdfDocument::loadFromFile(path.string());
|
||||
ASSERT_TRUE(docRes.has_value());
|
||||
auto doc = *docRes;
|
||||
|
||||
{
|
||||
auto pageRes = doc->getPage(0);
|
||||
ASSERT_TRUE(pageRes.has_value());
|
||||
auto textRes = (*pageRes)->extractText();
|
||||
ASSERT_TRUE(textRes.has_value());
|
||||
EXPECT_NE(textRes->find("Hello"), std::string::npos);
|
||||
}
|
||||
|
||||
std::string editsJson = R"({
|
||||
"version": "1.0",
|
||||
"operations": [
|
||||
{
|
||||
"id": "op_redact_test_1",
|
||||
"type": "redaction",
|
||||
"pageIndex": 0,
|
||||
"data": {
|
||||
"x": 0.0,
|
||||
"y": 0.0,
|
||||
"width": 612.0,
|
||||
"height": 792.0,
|
||||
"fillColor": "#ffffff"
|
||||
}
|
||||
}
|
||||
]
|
||||
})";
|
||||
|
||||
auto editRes = doc->applyEdits(editsJson);
|
||||
ASSERT_TRUE(editRes.has_value());
|
||||
|
||||
auto saveRes = doc->saveFull();
|
||||
ASSERT_TRUE(saveRes.has_value());
|
||||
const auto& savedBytes = *saveRes;
|
||||
ASSERT_FALSE(savedBytes.empty());
|
||||
|
||||
auto newDocRes = PdfDocument::loadFromMemory(savedBytes);
|
||||
ASSERT_TRUE(newDocRes.has_value());
|
||||
auto newDoc = *newDocRes;
|
||||
|
||||
auto newPageRes = newDoc->getPage(0);
|
||||
ASSERT_TRUE(newPageRes.has_value());
|
||||
auto newPage = *newPageRes;
|
||||
|
||||
auto textRes = newPage->extractText();
|
||||
ASSERT_TRUE(textRes.has_value());
|
||||
|
||||
EXPECT_EQ(textRes->find("Hello"), std::string::npos);
|
||||
EXPECT_EQ(textRes->find("world"), std::string::npos);
|
||||
}
|
||||
|
||||
TEST(DocumentEditTest, ApplyImageOverlayAndIncrementalSave) {
|
||||
SKIP_IF_NO_PDFIUM();
|
||||
auto path = getCorpusPath("basic", "hello_world.pdf");
|
||||
if (!std::filesystem::exists(path)) {
|
||||
GTEST_SKIP() << "hello_world.pdf not found in corpus.";
|
||||
}
|
||||
|
||||
auto docRes = PdfDocument::loadFromFile(path.string());
|
||||
ASSERT_TRUE(docRes.has_value());
|
||||
auto doc = *docRes;
|
||||
|
||||
std::string editsJson = R"({
|
||||
"version": "1.0",
|
||||
"operations": [
|
||||
{
|
||||
"id": "op_test_img_1",
|
||||
"type": "image_overlay",
|
||||
"pageIndex": 0,
|
||||
"data": {
|
||||
"x": 100.0,
|
||||
"y": 150.0,
|
||||
"width": 200.0,
|
||||
"height": 150.0,
|
||||
"pixelWidth": 2,
|
||||
"pixelHeight": 2,
|
||||
"rawPixelData": "AAD//wAA//8AAP//AAD//w=="
|
||||
}
|
||||
}
|
||||
]
|
||||
})";
|
||||
|
||||
auto editRes = doc->applyEdits(editsJson);
|
||||
ASSERT_TRUE(editRes.has_value());
|
||||
|
||||
auto saveRes = doc->saveIncremental();
|
||||
ASSERT_TRUE(saveRes.has_value());
|
||||
const auto& savedBytes = *saveRes;
|
||||
ASSERT_FALSE(savedBytes.empty());
|
||||
|
||||
auto newDocRes = PdfDocument::loadFromMemory(savedBytes);
|
||||
ASSERT_TRUE(newDocRes.has_value());
|
||||
auto newDoc = *newDocRes;
|
||||
EXPECT_EQ(newDoc->pageCount(), 1);
|
||||
}
|
||||
|
||||
TEST(DocumentEditTest, ApplyPageRotationAndIncrementalSave) {
|
||||
SKIP_IF_NO_PDFIUM();
|
||||
auto path = getCorpusPath("basic", "about_blank.pdf");
|
||||
if (!std::filesystem::exists(path)) {
|
||||
GTEST_SKIP() << "about_blank.pdf not found in corpus.";
|
||||
}
|
||||
|
||||
auto docRes = PdfDocument::loadFromFile(path.string());
|
||||
ASSERT_TRUE(docRes.has_value());
|
||||
auto doc = *docRes;
|
||||
|
||||
auto pageRes = doc->getPage(0);
|
||||
ASSERT_TRUE(pageRes.has_value());
|
||||
double origW = (*pageRes)->width();
|
||||
double origH = (*pageRes)->height();
|
||||
EXPECT_GT(origW, 0.0);
|
||||
EXPECT_GT(origH, origW);
|
||||
|
||||
std::string editsJson1 = R"({
|
||||
"version": "1.0",
|
||||
"operations": [
|
||||
{
|
||||
"id": "op_test_rot_1",
|
||||
"type": "page_rotation",
|
||||
"pageIndex": 0,
|
||||
"data": {
|
||||
"rotation": 90
|
||||
}
|
||||
}
|
||||
]
|
||||
})";
|
||||
|
||||
auto editRes1 = doc->applyEdits(editsJson1);
|
||||
ASSERT_TRUE(editRes1.has_value());
|
||||
|
||||
std::string editsJson2 = R"({
|
||||
"version": "1.0",
|
||||
"operations": [
|
||||
{
|
||||
"id": "op_test_rot_2",
|
||||
"type": "page_rotation",
|
||||
"pageIndex": 0,
|
||||
"data": {
|
||||
"rotation": 90
|
||||
}
|
||||
}
|
||||
]
|
||||
})";
|
||||
|
||||
auto editRes2 = doc->applyEdits(editsJson2);
|
||||
ASSERT_TRUE(editRes2.has_value());
|
||||
|
||||
auto saveRes = doc->saveIncremental();
|
||||
ASSERT_TRUE(saveRes.has_value());
|
||||
const auto& savedBytes = *saveRes;
|
||||
ASSERT_FALSE(savedBytes.empty());
|
||||
|
||||
auto newDocRes = PdfDocument::loadFromMemory(savedBytes);
|
||||
ASSERT_TRUE(newDocRes.has_value());
|
||||
auto newDoc = *newDocRes;
|
||||
EXPECT_EQ(newDoc->pageCount(), 1);
|
||||
|
||||
auto newPageRes = newDoc->getPage(0);
|
||||
ASSERT_TRUE(newPageRes.has_value());
|
||||
double rotatedW = (*newPageRes)->width();
|
||||
double rotatedH = (*newPageRes)->height();
|
||||
|
||||
EXPECT_NEAR(rotatedW, origW, 0.01);
|
||||
EXPECT_NEAR(rotatedH, origH, 0.01);
|
||||
|
||||
std::string editsJson3 = R"({
|
||||
"version": "1.0",
|
||||
"operations": [
|
||||
{
|
||||
"id": "op_test_rot_3",
|
||||
"type": "page_rotation",
|
||||
"pageIndex": 0,
|
||||
"data": {
|
||||
"rotation": -90
|
||||
}
|
||||
}
|
||||
]
|
||||
})";
|
||||
|
||||
auto editRes3 = newDoc->applyEdits(editsJson3);
|
||||
ASSERT_TRUE(editRes3.has_value());
|
||||
|
||||
auto saveRes3 = newDoc->saveIncremental();
|
||||
ASSERT_TRUE(saveRes3.has_value());
|
||||
const auto& savedBytes3 = *saveRes3;
|
||||
|
||||
auto finalDocRes = PdfDocument::loadFromMemory(savedBytes3);
|
||||
ASSERT_TRUE(finalDocRes.has_value());
|
||||
auto finalPageRes = (*finalDocRes)->getPage(0);
|
||||
ASSERT_TRUE(finalPageRes.has_value());
|
||||
|
||||
double finalW = (*finalPageRes)->width();
|
||||
double finalH = (*finalPageRes)->height();
|
||||
EXPECT_NEAR(finalW, origH, 0.01);
|
||||
EXPECT_NEAR(finalH, origW, 0.01);
|
||||
}
|
||||
|
||||
TEST(DocumentEditTest, ApplyPageDeletionAndIncrementalSave) {
|
||||
SKIP_IF_NO_PDFIUM();
|
||||
auto path = getCorpusPath("basic", "hello_world_2_pages.pdf");
|
||||
if (!std::filesystem::exists(path)) {
|
||||
GTEST_SKIP() << "hello_world_2_pages.pdf not found in corpus.";
|
||||
}
|
||||
|
||||
auto docRes = PdfDocument::loadFromFile(path.string());
|
||||
ASSERT_TRUE(docRes.has_value());
|
||||
auto doc = *docRes;
|
||||
EXPECT_EQ(doc->pageCount(), 2);
|
||||
|
||||
std::string editsJson = R"({
|
||||
"version": "1.0",
|
||||
"operations": [
|
||||
{
|
||||
"id": "op_del_test_1",
|
||||
"type": "page_deletion",
|
||||
"pageIndex": 1,
|
||||
"data": {}
|
||||
}
|
||||
]
|
||||
})";
|
||||
|
||||
auto editRes = doc->applyEdits(editsJson);
|
||||
ASSERT_TRUE(editRes.has_value());
|
||||
EXPECT_EQ(doc->pageCount(), 1);
|
||||
|
||||
auto saveRes = doc->saveIncremental();
|
||||
ASSERT_TRUE(saveRes.has_value());
|
||||
const auto& savedBytes = *saveRes;
|
||||
ASSERT_FALSE(savedBytes.empty());
|
||||
|
||||
auto newDocRes = PdfDocument::loadFromMemory(savedBytes);
|
||||
ASSERT_TRUE(newDocRes.has_value());
|
||||
EXPECT_EQ((*newDocRes)->pageCount(), 1);
|
||||
}
|
||||
|
||||
TEST(DocumentEditTest, ApplyPageReorderAndIncrementalSave) {
|
||||
SKIP_IF_NO_PDFIUM();
|
||||
auto path = getCorpusPath("basic", "hello_world_2_pages.pdf");
|
||||
if (!std::filesystem::exists(path)) {
|
||||
GTEST_SKIP() << "hello_world_2_pages.pdf not found in corpus.";
|
||||
}
|
||||
|
||||
auto docRes = PdfDocument::loadFromFile(path.string());
|
||||
ASSERT_TRUE(docRes.has_value());
|
||||
auto doc = *docRes;
|
||||
EXPECT_EQ(doc->pageCount(), 2);
|
||||
|
||||
std::string editsJson = R"({
|
||||
"version": "1.0",
|
||||
"operations": [
|
||||
{
|
||||
"id": "op_reorder_test_1",
|
||||
"type": "page_reorder",
|
||||
"pageIndex": 1,
|
||||
"data": {
|
||||
"destPageIndex": 0
|
||||
}
|
||||
}
|
||||
]
|
||||
})";
|
||||
|
||||
auto editRes = doc->applyEdits(editsJson);
|
||||
ASSERT_TRUE(editRes.has_value());
|
||||
EXPECT_EQ(doc->pageCount(), 2);
|
||||
|
||||
auto saveRes = doc->saveIncremental();
|
||||
ASSERT_TRUE(saveRes.has_value());
|
||||
const auto& savedBytes = *saveRes;
|
||||
ASSERT_FALSE(savedBytes.empty());
|
||||
|
||||
auto newDocRes = PdfDocument::loadFromMemory(savedBytes);
|
||||
ASSERT_TRUE(newDocRes.has_value());
|
||||
EXPECT_EQ((*newDocRes)->pageCount(), 2);
|
||||
}
|
||||
TEST(DocumentEditTest, ReplaceTextMVPStandardFont) {
|
||||
SKIP_IF_NO_PDFIUM();
|
||||
auto path = getCorpusPath("basic", "hello_world.pdf");
|
||||
if (!std::filesystem::exists(path)) {
|
||||
GTEST_SKIP() << "hello_world.pdf not found in corpus.";
|
||||
}
|
||||
|
||||
auto docRes = PdfDocument::loadFromFile(path.string());
|
||||
ASSERT_TRUE(docRes.has_value());
|
||||
auto doc = *docRes;
|
||||
|
||||
auto pageRes = doc->getPage(0);
|
||||
ASSERT_TRUE(pageRes.has_value());
|
||||
auto pageObj = *pageRes;
|
||||
|
||||
auto modelRes = pageObj->extractDocumentModel();
|
||||
ASSERT_TRUE(modelRes.has_value());
|
||||
const auto& model = *modelRes;
|
||||
|
||||
std::vector<int> objectIndices;
|
||||
for (const auto& p : model.paragraphs) {
|
||||
for (const auto& line : p.lines) {
|
||||
for (const auto& run : line.runs) {
|
||||
if (run.text.find("Hello") != std::string::npos) {
|
||||
objectIndices = run.objectIndices;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!objectIndices.empty()) break;
|
||||
}
|
||||
if (!objectIndices.empty()) break;
|
||||
}
|
||||
|
||||
ASSERT_FALSE(objectIndices.empty()) << "Could not find a text object in hello_world.pdf";
|
||||
|
||||
std::string indicesStr = "";
|
||||
for (size_t i = 0; i < objectIndices.size(); ++i) {
|
||||
indicesStr += std::to_string(objectIndices[i]);
|
||||
if (i + 1 < objectIndices.size()) indicesStr += ",";
|
||||
}
|
||||
|
||||
std::string editsJson = R"({
|
||||
"version": "1.0",
|
||||
"operations": [
|
||||
{
|
||||
"id": "op_mvp_1",
|
||||
"type": "replace_text",
|
||||
"pageIndex": 0,
|
||||
"objectIndices": [)" + indicesStr + R"(],
|
||||
"text": "Greeting, universe!"
|
||||
}
|
||||
]
|
||||
})";
|
||||
|
||||
auto editRes = doc->applyEdits(editsJson);
|
||||
ASSERT_TRUE(editRes.has_value());
|
||||
|
||||
auto saveRes = doc->saveIncremental();
|
||||
ASSERT_TRUE(saveRes.has_value());
|
||||
const auto& savedBytes = *saveRes;
|
||||
ASSERT_FALSE(savedBytes.empty());
|
||||
|
||||
auto newDocRes = PdfDocument::loadFromMemory(savedBytes);
|
||||
ASSERT_TRUE(newDocRes.has_value());
|
||||
auto newDoc = *newDocRes;
|
||||
|
||||
auto newPageRes = newDoc->getPage(0);
|
||||
ASSERT_TRUE(newPageRes.has_value());
|
||||
auto newPage = *newPageRes;
|
||||
|
||||
auto textRes = newPage->extractText();
|
||||
ASSERT_TRUE(textRes.has_value());
|
||||
EXPECT_NE(textRes->find("Greeting, universe!"), std::string::npos);
|
||||
EXPECT_EQ(textRes->find("Hello"), std::string::npos);
|
||||
}
|
||||
|
||||
TEST(DocumentEditTest, ReplaceTextRuntimeFontEngine) {
|
||||
SKIP_IF_NO_PDFIUM();
|
||||
auto path = getCorpusPath("fonts", "latin_extended.pdf");
|
||||
if (!std::filesystem::exists(path)) {
|
||||
GTEST_SKIP() << "latin_extended.pdf not found in corpus.";
|
||||
}
|
||||
|
||||
auto docRes = PdfDocument::loadFromFile(path.string());
|
||||
ASSERT_TRUE(docRes.has_value());
|
||||
auto doc = *docRes;
|
||||
|
||||
auto pageRes = doc->getPage(0);
|
||||
ASSERT_TRUE(pageRes.has_value());
|
||||
auto pageObj = *pageRes;
|
||||
|
||||
auto modelRes = pageObj->extractDocumentModel();
|
||||
ASSERT_TRUE(modelRes.has_value());
|
||||
const auto& model = *modelRes;
|
||||
|
||||
std::vector<int> objectIndices;
|
||||
std::string originalFontId = "";
|
||||
for (const auto& p : model.paragraphs) {
|
||||
for (const auto& line : p.lines) {
|
||||
for (const auto& run : line.runs) {
|
||||
if (run.fontName.find("Roboto-Regular") != std::string::npos) {
|
||||
objectIndices = run.objectIndices;
|
||||
originalFontId = run.internalFontId;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!objectIndices.empty()) break;
|
||||
}
|
||||
if (!objectIndices.empty()) break;
|
||||
}
|
||||
|
||||
ASSERT_FALSE(objectIndices.empty()) << "Could not find target text run in latin_extended.pdf";
|
||||
|
||||
std::string indicesStr = "";
|
||||
for (size_t i = 0; i < objectIndices.size(); ++i) {
|
||||
indicesStr += std::to_string(objectIndices[i]);
|
||||
if (i + 1 < objectIndices.size()) indicesStr += ",";
|
||||
}
|
||||
|
||||
std::string editsJson = R"({
|
||||
"version": "1.0",
|
||||
"operations": [
|
||||
{
|
||||
"id": "op_engine_1",
|
||||
"type": "replace_text",
|
||||
"pageIndex": 0,
|
||||
"objectIndices": [)" + indicesStr + R"(],
|
||||
"text": "Font Engine Active!",
|
||||
"internalFontId": ")" + originalFontId + R"("
|
||||
}
|
||||
]
|
||||
})";
|
||||
|
||||
auto editRes = doc->applyEdits(editsJson);
|
||||
ASSERT_TRUE(editRes.has_value());
|
||||
|
||||
auto saveRes = doc->saveIncremental();
|
||||
ASSERT_TRUE(saveRes.has_value());
|
||||
const auto& savedBytes = *saveRes;
|
||||
ASSERT_FALSE(savedBytes.empty());
|
||||
|
||||
auto newDocRes = PdfDocument::loadFromMemory(savedBytes);
|
||||
ASSERT_TRUE(newDocRes.has_value());
|
||||
auto newDoc = *newDocRes;
|
||||
|
||||
auto newPageRes = newDoc->getPage(0);
|
||||
ASSERT_TRUE(newPageRes.has_value());
|
||||
auto newPage = *newPageRes;
|
||||
|
||||
auto textRes = newPage->extractText();
|
||||
ASSERT_TRUE(textRes.has_value());
|
||||
EXPECT_NE(textRes->find("Font Engine Active!"), std::string::npos);
|
||||
}
|
||||
|
||||
TEST(DocumentEditTest, ReplaceTextFontReuseAndEmbedding) {
|
||||
SKIP_IF_NO_PDFIUM();
|
||||
auto path = getCorpusPath("basic", "hello_world.pdf");
|
||||
if (!std::filesystem::exists(path)) {
|
||||
GTEST_SKIP() << "hello_world.pdf not found in corpus.";
|
||||
}
|
||||
|
||||
auto docRes = PdfDocument::loadFromFile(path.string());
|
||||
ASSERT_TRUE(docRes.has_value());
|
||||
auto doc = *docRes;
|
||||
|
||||
auto pageRes = doc->getPage(0);
|
||||
ASSERT_TRUE(pageRes.has_value());
|
||||
auto pageObj = *pageRes;
|
||||
|
||||
auto modelRes = pageObj->extractDocumentModel();
|
||||
ASSERT_TRUE(modelRes.has_value());
|
||||
const auto& model = *modelRes;
|
||||
|
||||
std::vector<int> objectIndices;
|
||||
std::string originalFontId = "";
|
||||
for (const auto& p : model.paragraphs) {
|
||||
for (const auto& line : p.lines) {
|
||||
for (const auto& run : line.runs) {
|
||||
if (!run.objectIndices.empty()) {
|
||||
objectIndices = run.objectIndices;
|
||||
originalFontId = run.internalFontId;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!objectIndices.empty()) break;
|
||||
}
|
||||
if (!objectIndices.empty()) break;
|
||||
}
|
||||
|
||||
ASSERT_FALSE(objectIndices.empty()) << "Could not find a text run in hello_world.pdf";
|
||||
|
||||
std::string indicesStr = "";
|
||||
for (size_t i = 0; i < objectIndices.size(); ++i) {
|
||||
indicesStr += std::to_string(objectIndices[i]);
|
||||
if (i + 1 < objectIndices.size()) indicesStr += ",";
|
||||
}
|
||||
|
||||
std::string editsJson = R"({
|
||||
"version": "1.0",
|
||||
"operations": [
|
||||
{
|
||||
"id": "op_reuse_1",
|
||||
"type": "replace_text",
|
||||
"pageIndex": 0,
|
||||
"objectIndices": [)" + indicesStr + R"(],
|
||||
"text": "Embedded Arial",
|
||||
"internalFontId": ")" + originalFontId + R"("
|
||||
}
|
||||
]
|
||||
})";
|
||||
|
||||
auto editRes = doc->applyEdits(editsJson);
|
||||
ASSERT_TRUE(editRes.has_value());
|
||||
|
||||
auto saveRes = doc->saveIncremental();
|
||||
ASSERT_TRUE(saveRes.has_value());
|
||||
const auto& savedBytes = *saveRes;
|
||||
ASSERT_FALSE(savedBytes.empty());
|
||||
|
||||
auto newDocRes = PdfDocument::loadFromMemory(savedBytes);
|
||||
ASSERT_TRUE(newDocRes.has_value());
|
||||
auto newDoc = *newDocRes;
|
||||
|
||||
auto fontsRes = newDoc->getFonts(0, 0);
|
||||
ASSERT_TRUE(fontsRes.has_value());
|
||||
|
||||
bool foundEmbeddedArial = false;
|
||||
for (const auto& f : *fontsRes) {
|
||||
if (f.isEmbedded && (f.fontName.find("Arial") != std::string::npos || f.fontName.find("LiberationSans") != std::string::npos)) {
|
||||
foundEmbeddedArial = true;
|
||||
}
|
||||
}
|
||||
|
||||
std::cout << "Font Embedding Test: foundEmbeddedArial = " << foundEmbeddedArial << std::endl;
|
||||
}
|
||||
|
||||
TEST(DocumentEditTest, ReplaceTextHarfBuzzShapingAndReflow) {
|
||||
SKIP_IF_NO_PDFIUM();
|
||||
auto path = getCorpusPath("fonts", "latin_extended.pdf");
|
||||
if (!std::filesystem::exists(path)) {
|
||||
GTEST_SKIP() << "latin_extended.pdf not found in corpus.";
|
||||
}
|
||||
|
||||
auto docRes = PdfDocument::loadFromFile(path.string());
|
||||
ASSERT_TRUE(docRes.has_value());
|
||||
auto doc = *docRes;
|
||||
|
||||
auto pageRes = doc->getPage(0);
|
||||
ASSERT_TRUE(pageRes.has_value());
|
||||
auto pageObj = *pageRes;
|
||||
|
||||
auto modelRes = pageObj->extractDocumentModel();
|
||||
ASSERT_TRUE(modelRes.has_value());
|
||||
const auto& model = *modelRes;
|
||||
|
||||
std::vector<int> targetIndices;
|
||||
std::string originalFontId = "";
|
||||
std::string runBText = "";
|
||||
double runBOrigX = 0.0;
|
||||
double runBOrigY = 0.0;
|
||||
|
||||
for (const auto& p : model.paragraphs) {
|
||||
for (const auto& line : p.lines) {
|
||||
if (line.runs.size() >= 2) {
|
||||
const auto& runA = line.runs[0];
|
||||
const auto& runB = line.runs[1];
|
||||
if (runA.fontName.find("Roboto-Regular") != std::string::npos &&
|
||||
!runA.objectIndices.empty() &&
|
||||
runB.x > runA.x) {
|
||||
targetIndices = runA.objectIndices;
|
||||
originalFontId = runA.internalFontId;
|
||||
runBText = runB.text;
|
||||
runBOrigX = runB.x;
|
||||
runBOrigY = runB.y;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!targetIndices.empty()) break;
|
||||
}
|
||||
|
||||
if (targetIndices.empty()) {
|
||||
GTEST_SKIP() << "Could not find a suitable line with multiple runs to test reflow.";
|
||||
}
|
||||
|
||||
std::string indicesStr = "";
|
||||
for (size_t i = 0; i < targetIndices.size(); ++i) {
|
||||
indicesStr += std::to_string(targetIndices[i]);
|
||||
if (i + 1 < targetIndices.size()) indicesStr += ",";
|
||||
}
|
||||
|
||||
std::string editsJson = R"({
|
||||
"version": "1.0",
|
||||
"operations": [
|
||||
{
|
||||
"id": "op_reflow_1",
|
||||
"type": "replace_text",
|
||||
"pageIndex": 0,
|
||||
"objectIndices": [)" + indicesStr + R"(],
|
||||
"text": "This is an extremely long replacement text to force the Reflow Engine to shift subsequent runs!",
|
||||
"internalFontId": ")" + originalFontId + R"("
|
||||
}
|
||||
]
|
||||
})";
|
||||
|
||||
auto editRes = doc->applyEdits(editsJson);
|
||||
ASSERT_TRUE(editRes.has_value());
|
||||
|
||||
auto saveRes = doc->saveIncremental();
|
||||
ASSERT_TRUE(saveRes.has_value());
|
||||
const auto& savedBytes = *saveRes;
|
||||
ASSERT_FALSE(savedBytes.empty());
|
||||
|
||||
auto newDocRes = PdfDocument::loadFromMemory(savedBytes);
|
||||
ASSERT_TRUE(newDocRes.has_value());
|
||||
auto newDoc = *newDocRes;
|
||||
|
||||
auto newPageRes = newDoc->getPage(0);
|
||||
ASSERT_TRUE(newPageRes.has_value());
|
||||
auto newPage = *newPageRes;
|
||||
|
||||
auto newModelRes = newPage->extractDocumentModel();
|
||||
ASSERT_TRUE(newModelRes.has_value());
|
||||
const auto& newModel = *newModelRes;
|
||||
|
||||
bool foundRunB = false;
|
||||
double runBNewX = 0.0;
|
||||
for (const auto& p : newModel.paragraphs) {
|
||||
for (const auto& line : p.lines) {
|
||||
for (const auto& run : line.runs) {
|
||||
if (run.text == runBText && std::abs(run.y - runBOrigY) < 5.0) {
|
||||
foundRunB = true;
|
||||
runBNewX = run.x;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (foundRunB) break;
|
||||
}
|
||||
if (foundRunB) break;
|
||||
}
|
||||
|
||||
ASSERT_TRUE(foundRunB) << "Could not find the subsequent text run '" << runBText << "' in the reflowed document.";
|
||||
EXPECT_GT(runBNewX, runBOrigX + 10.0) << "The subsequent text run did not shift to the right by at least 10 points.";
|
||||
|
||||
std::cout << "Reflow Engine verified: '" << runBText << "' shifted from X=" << runBOrigX << " to X=" << runBNewX << std::endl;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
#include "document_test_helpers.hpp"
|
||||
|
||||
namespace pdfengine {
|
||||
|
||||
TEST(DocumentLoadTest, NonExistentFileReturnsFileNotFound) {
|
||||
SKIP_IF_NO_PDFIUM();
|
||||
auto result = PdfDocument::loadFromFile("nonexistent_file_12345.pdf");
|
||||
ASSERT_FALSE(result.has_value());
|
||||
EXPECT_EQ(result.error(), EngineError::FileNotFound);
|
||||
}
|
||||
|
||||
TEST(DocumentLoadTest, InvalidFileReturnsInvalidFormat) {
|
||||
SKIP_IF_NO_PDFIUM();
|
||||
std::string path = "invalid_format_test.pdf";
|
||||
{
|
||||
std::ofstream out(path, std::ios::binary);
|
||||
out << "NOT A PDF FILE!";
|
||||
}
|
||||
|
||||
auto result = PdfDocument::loadFromFile(path);
|
||||
std::filesystem::remove(path);
|
||||
|
||||
ASSERT_FALSE(result.has_value());
|
||||
EXPECT_EQ(result.error(), EngineError::InvalidFormat);
|
||||
}
|
||||
|
||||
TEST(DocumentLoadTest, EncryptedPdfRequiresPassword) {
|
||||
SKIP_IF_NO_PDFIUM();
|
||||
auto path = getCorpusPath("edge-cases", "encrypted.pdf");
|
||||
if (!std::filesystem::exists(path)) {
|
||||
GTEST_SKIP() << "encrypted.pdf not found in corpus.";
|
||||
}
|
||||
|
||||
auto result = PdfDocument::loadFromFile(path.string());
|
||||
ASSERT_FALSE(result.has_value());
|
||||
EXPECT_EQ(result.error(), EngineError::PasswordRequired);
|
||||
}
|
||||
|
||||
TEST(DocumentLoadTest, EncryptedPdfInvalidPassword) {
|
||||
SKIP_IF_NO_PDFIUM();
|
||||
auto path = getCorpusPath("edge-cases", "encrypted.pdf");
|
||||
if (!std::filesystem::exists(path)) {
|
||||
GTEST_SKIP() << "encrypted.pdf not found in corpus.";
|
||||
}
|
||||
|
||||
auto result = PdfDocument::loadFromFile(path.string(), "wrong_password");
|
||||
ASSERT_FALSE(result.has_value());
|
||||
EXPECT_EQ(result.error(), EngineError::InvalidPassword);
|
||||
}
|
||||
|
||||
TEST(DocumentLoadTest, EncryptedPdfCorrectPassword) {
|
||||
SKIP_IF_NO_PDFIUM();
|
||||
auto path = getCorpusPath("edge-cases", "encrypted.pdf");
|
||||
if (!std::filesystem::exists(path)) {
|
||||
GTEST_SKIP() << "encrypted.pdf not found in corpus.";
|
||||
}
|
||||
|
||||
std::vector<std::string> candidates = {"tessy", "test", "password", "123456", "1234", "foobar", "user", "owner", ""};
|
||||
bool success = false;
|
||||
for (const auto& pw : candidates) {
|
||||
auto result = PdfDocument::loadFromFile(path.string(), pw);
|
||||
if (result.has_value()) {
|
||||
EXPECT_GT((*result)->pageCount(), 0);
|
||||
success = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
EXPECT_TRUE(success) << "Failed to open encrypted.pdf with any of the candidate passwords.";
|
||||
}
|
||||
|
||||
TEST(DocumentLoadTest, LoadFromMemorySuccess) {
|
||||
SKIP_IF_NO_PDFIUM();
|
||||
auto path = getCorpusPath("basic", "hello_world.pdf");
|
||||
if (!std::filesystem::exists(path)) {
|
||||
GTEST_SKIP() << "hello_world.pdf not found in corpus.";
|
||||
}
|
||||
|
||||
auto buffer = readFile(path);
|
||||
ASSERT_FALSE(buffer.empty());
|
||||
|
||||
auto result = PdfDocument::loadFromMemory(buffer);
|
||||
ASSERT_TRUE(result.has_value());
|
||||
EXPECT_EQ((*result)->pageCount(), 1);
|
||||
}
|
||||
|
||||
TEST(DocumentLoadTest, LoadFromMemoryEmptyBuffer) {
|
||||
SKIP_IF_NO_PDFIUM();
|
||||
std::vector<uint8_t> empty_buf;
|
||||
auto result = PdfDocument::loadFromMemory(empty_buf);
|
||||
ASSERT_FALSE(result.has_value());
|
||||
EXPECT_EQ(result.error(), EngineError::InvalidFormat);
|
||||
}
|
||||
|
||||
TEST(PageCountTest, CorpusPageCounts) {
|
||||
SKIP_IF_NO_PDFIUM();
|
||||
|
||||
struct ExpectedPageCount {
|
||||
std::string folder;
|
||||
std::string file;
|
||||
int count;
|
||||
};
|
||||
|
||||
std::vector<ExpectedPageCount> targets = {
|
||||
{"basic", "about_blank.pdf", 1},
|
||||
{"basic", "black.pdf", 1},
|
||||
{"basic", "hello_world.pdf", 1},
|
||||
{"basic", "hello_world_2_pages.pdf", 2},
|
||||
{"basic", "rectangles_multi_pages.pdf", 5}
|
||||
};
|
||||
|
||||
for (const auto& t : targets) {
|
||||
auto path = getCorpusPath(t.folder, t.file);
|
||||
if (!std::filesystem::exists(path)) {
|
||||
continue;
|
||||
}
|
||||
auto docRes = PdfDocument::loadFromFile(path.string());
|
||||
ASSERT_TRUE(docRes.has_value()) << "Failed to open " << t.file;
|
||||
EXPECT_EQ((*docRes)->pageCount(), t.count) << "Mismatch for " << t.file;
|
||||
}
|
||||
|
||||
auto verifyCorpusFolder = [](const std::string& folder, const std::vector<std::string>& files) {
|
||||
for (const auto& f : files) {
|
||||
auto path = getCorpusPath(folder, f);
|
||||
if (!std::filesystem::exists(path)) {
|
||||
ADD_FAILURE() << "Missing corpus file: " << path.string();
|
||||
continue;
|
||||
}
|
||||
|
||||
std::vector<std::string> passwords = {""};
|
||||
if (f == "encrypted.pdf") {
|
||||
passwords = {"tessy", "test", "password", "123456", "1234", "foobar", "user", "owner"};
|
||||
}
|
||||
|
||||
bool success = false;
|
||||
EngineError lastErr = EngineError::Unknown;
|
||||
for (const auto& pw : passwords) {
|
||||
auto docRes = PdfDocument::loadFromFile(path.string(), pw);
|
||||
if (docRes.has_value()) {
|
||||
EXPECT_GE((*docRes)->pageCount(), 0) << "Page count negative for " << f;
|
||||
success = true;
|
||||
break;
|
||||
}
|
||||
lastErr = docRes.error();
|
||||
}
|
||||
EXPECT_TRUE(success) << "Failed to open " << f << " error: " << static_cast<int>(lastErr);
|
||||
}
|
||||
};
|
||||
|
||||
verifyCorpusFolder("basic", corpus_basic);
|
||||
verifyCorpusFolder("fonts", corpus_fonts);
|
||||
verifyCorpusFolder("edge-cases", corpus_edge);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,64 @@
|
||||
#pragma once
|
||||
|
||||
#include <gtest/gtest.h>
|
||||
#include <pdfengine/pdf_document.hpp>
|
||||
#include <pdfengine/pdf_engine.hpp>
|
||||
#include "parser/pdfium_document.hpp"
|
||||
#include "fonts/pdf_fonts/encoding/cjk_collection_db.hpp"
|
||||
#include <filesystem>
|
||||
#include <fstream>
|
||||
#include <vector>
|
||||
#include <string>
|
||||
#include <thread>
|
||||
#include <atomic>
|
||||
#include <chrono>
|
||||
#include "fonts/cache/glyph_cache.hpp"
|
||||
#include "fonts/pdf_fonts/font.hpp"
|
||||
#ifndef TEST_CORPUS_DIR
|
||||
#define TEST_CORPUS_DIR "../../corpus"
|
||||
#endif
|
||||
|
||||
namespace {
|
||||
|
||||
#define SKIP_IF_NO_PDFIUM() \
|
||||
if (!pdfengine::engineHasPdfium()) { \
|
||||
GTEST_SKIP() << "Skipping PDFium tests because PDFium is not linked."; \
|
||||
}
|
||||
|
||||
std::filesystem::path getCorpusPath(const std::string& subfolder, const std::string& filename) {
|
||||
return std::filesystem::path(TEST_CORPUS_DIR) / subfolder / filename;
|
||||
}
|
||||
|
||||
std::vector<uint8_t> readFile(const std::filesystem::path& path) {
|
||||
std::ifstream file(path, std::ios::binary | std::ios::ate);
|
||||
if (!file.is_open()) {
|
||||
return {};
|
||||
}
|
||||
std::streamsize size = file.tellg();
|
||||
file.seekg(0, std::ios::beg);
|
||||
std::vector<uint8_t> buffer(size);
|
||||
if (file.read(reinterpret_cast<char*>(buffer.data()), size)) {
|
||||
return buffer;
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
const std::vector<std::string> corpus_basic = {
|
||||
"about_blank.pdf", "black.pdf", "clip_path.pdf", "dashed_lines.pdf",
|
||||
"hello_world.pdf", "hello_world_2_pages.pdf", "many_rectangles.pdf",
|
||||
"rectangles.pdf", "rectangles_multi_pages.pdf", "whitespace.pdf"
|
||||
};
|
||||
|
||||
const std::vector<std::string> corpus_fonts = {
|
||||
"latin_extended.pdf", "rotated_text.pdf", "rotated_text_90.pdf",
|
||||
"text_font.pdf", "utf-8.pdf", "vertical_text.pdf", "hebrew_mirrored.pdf"
|
||||
};
|
||||
|
||||
const std::vector<std::string> corpus_edge = {
|
||||
"annots.pdf", "bookmarks.pdf", "combobox_form.pdf", "embedded_attachments.pdf",
|
||||
"empty_xref.pdf", "encrypted.pdf", "linearized.pdf", "listbox_form.pdf",
|
||||
"no_page_count.pdf", "page_labels.pdf", "text_form.pdf", "unsupported_feature.pdf",
|
||||
"zero_length_stream.pdf"
|
||||
};
|
||||
|
||||
}
|
||||
@@ -0,0 +1,601 @@
|
||||
#include "document_test_helpers.hpp"
|
||||
|
||||
namespace pdfengine {
|
||||
|
||||
TEST(FontDiagnosticsTest, IntrospectionAccuracy) {
|
||||
SKIP_IF_NO_PDFIUM();
|
||||
auto path = getCorpusPath("basic", "hello_world.pdf");
|
||||
if (!std::filesystem::exists(path)) {
|
||||
GTEST_SKIP() << "hello_world.pdf not found in corpus.";
|
||||
}
|
||||
|
||||
auto docRes = PdfDocument::loadFromFile(path.string());
|
||||
ASSERT_TRUE(docRes.has_value());
|
||||
auto doc = *docRes;
|
||||
|
||||
auto pageRes = doc->getPage(0);
|
||||
ASSERT_TRUE(pageRes.has_value());
|
||||
auto page = *pageRes;
|
||||
|
||||
auto fontsRes = page->getFonts();
|
||||
ASSERT_TRUE(fontsRes.has_value());
|
||||
auto fonts = *fontsRes;
|
||||
|
||||
if (!fonts.empty()) {
|
||||
auto firstFont = fonts[0];
|
||||
EXPECT_FALSE(firstFont.fontName.empty());
|
||||
EXPECT_FALSE(firstFont.type.empty());
|
||||
EXPECT_FALSE(firstFont.normalizedFamily.empty());
|
||||
EXPECT_FALSE(firstFont.internalFontId.empty());
|
||||
}
|
||||
|
||||
auto docFontsRes = doc->getFonts();
|
||||
ASSERT_TRUE(docFontsRes.has_value());
|
||||
auto docFonts = *docFontsRes;
|
||||
EXPECT_EQ(docFonts.size(), fonts.size());
|
||||
}
|
||||
|
||||
TEST(FontDiagnosticsTest, SubsetAndVerticalTextIntrospection) {
|
||||
SKIP_IF_NO_PDFIUM();
|
||||
auto path = getCorpusPath("fonts", "vertical_text.pdf");
|
||||
if (!std::filesystem::exists(path)) {
|
||||
path = getCorpusPath("fonts", "utf-8.pdf");
|
||||
}
|
||||
if (!std::filesystem::exists(path)) {
|
||||
GTEST_SKIP() << "vertical_text.pdf or utf-8.pdf not found in corpus.";
|
||||
}
|
||||
|
||||
auto docRes = PdfDocument::loadFromFile(path.string());
|
||||
ASSERT_TRUE(docRes.has_value());
|
||||
auto doc = *docRes;
|
||||
|
||||
auto fontsRes = doc->getFonts();
|
||||
ASSERT_TRUE(fontsRes.has_value());
|
||||
|
||||
for (const auto& f : *fontsRes) {
|
||||
if (f.isSubset) {
|
||||
EXPECT_FALSE(f.subsetTag.empty());
|
||||
EXPECT_EQ(f.subsetTag.size(), 6);
|
||||
}
|
||||
if (f.isVertical) {
|
||||
EXPECT_TRUE(f.isVertical);
|
||||
EXPECT_NE(f.encoding.find("Identity-V"), std::string::npos);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
TEST(FontDiagnosticsTest, CacheInvalidationAfterEdits) {
|
||||
SKIP_IF_NO_PDFIUM();
|
||||
auto path = getCorpusPath("basic", "hello_world.pdf");
|
||||
if (!std::filesystem::exists(path)) {
|
||||
GTEST_SKIP() << "hello_world.pdf not found in corpus.";
|
||||
}
|
||||
|
||||
auto docRes = PdfDocument::loadFromFile(path.string());
|
||||
ASSERT_TRUE(docRes.has_value());
|
||||
auto doc = *docRes;
|
||||
|
||||
auto fontsRes1 = doc->getFonts();
|
||||
ASSERT_TRUE(fontsRes1.has_value());
|
||||
|
||||
std::string editsJson = R"({
|
||||
"version": "1.0",
|
||||
"operations": [
|
||||
{
|
||||
"id": "op_test_2",
|
||||
"type": "text_overlay",
|
||||
"pageIndex": 0,
|
||||
"data": {
|
||||
"text": "IntrospectionDiagnosticsNewText",
|
||||
"x": 10.0,
|
||||
"y": 20.0,
|
||||
"width": 200.0,
|
||||
"height": 20.0,
|
||||
"fontSize": 12.0,
|
||||
"fontFamily": "Helvetica",
|
||||
"color": "#000000"
|
||||
}
|
||||
}
|
||||
]
|
||||
})";
|
||||
|
||||
auto editRes = doc->applyEdits(editsJson);
|
||||
ASSERT_TRUE(editRes.has_value());
|
||||
|
||||
auto fontsRes2 = doc->getFonts();
|
||||
ASSERT_TRUE(fontsRes2.has_value());
|
||||
}
|
||||
|
||||
TEST(FontDiagnosticsTest, ConcurrencyThreadSafety) {
|
||||
SKIP_IF_NO_PDFIUM();
|
||||
auto path = getCorpusPath("basic", "hello_world.pdf");
|
||||
if (!std::filesystem::exists(path)) {
|
||||
GTEST_SKIP() << "hello_world.pdf not found in corpus.";
|
||||
}
|
||||
|
||||
auto docRes = PdfDocument::loadFromFile(path.string());
|
||||
ASSERT_TRUE(docRes.has_value());
|
||||
auto doc = *docRes;
|
||||
|
||||
std::vector<std::thread> threads;
|
||||
for (int i = 0; i < 8; ++i) {
|
||||
threads.emplace_back([&doc]() {
|
||||
auto res = doc->getFonts();
|
||||
ASSERT_TRUE(res.has_value());
|
||||
});
|
||||
}
|
||||
|
||||
for (auto& t : threads) {
|
||||
t.join();
|
||||
}
|
||||
}
|
||||
|
||||
TEST(FontDiagnosticsTest, DeepIntrospectionAndFontSizeVerification) {
|
||||
SKIP_IF_NO_PDFIUM();
|
||||
|
||||
{
|
||||
auto path = getCorpusPath("fonts", "utf-8.pdf");
|
||||
if (std::filesystem::exists(path)) {
|
||||
auto docRes = PdfDocument::loadFromFile(path.string());
|
||||
ASSERT_TRUE(docRes.has_value());
|
||||
auto doc = *docRes;
|
||||
|
||||
auto fontsRes = doc->getFonts();
|
||||
ASSERT_TRUE(fontsRes.has_value());
|
||||
auto fonts = *fontsRes;
|
||||
|
||||
for (const auto& f : fonts) {
|
||||
EXPECT_FALSE(f.fontName.empty());
|
||||
EXPECT_FALSE(f.type.empty());
|
||||
EXPECT_FALSE(f.normalizedFamily.empty());
|
||||
EXPECT_FALSE(f.internalFontId.empty());
|
||||
|
||||
if (f.isSubset) {
|
||||
EXPECT_EQ(f.subsetTag.size(), 6);
|
||||
for (char c : f.subsetTag) {
|
||||
EXPECT_TRUE(std::isupper(static_cast<unsigned char>(c)));
|
||||
}
|
||||
EXPECT_EQ(f.sourceType, "Embedded");
|
||||
EXPECT_TRUE(f.isEmbedded);
|
||||
EXPECT_EQ(f.internalFontId, f.fontName);
|
||||
} else {
|
||||
EXPECT_TRUE(f.subsetTag.empty());
|
||||
EXPECT_EQ(f.internalFontId, f.fontName + "_" + f.type + "_" + std::to_string(f.flags));
|
||||
}
|
||||
|
||||
if (f.sourceType == "SystemFallback") {
|
||||
EXPECT_FALSE(f.isEmbedded);
|
||||
EXPECT_TRUE(f.substitutedFrom.empty());
|
||||
EXPECT_TRUE(f.substitutedTo.empty());
|
||||
} else if (f.sourceType == "Substituted") {
|
||||
EXPECT_FALSE(f.isEmbedded);
|
||||
EXPECT_EQ(f.substitutedFrom, f.fontName);
|
||||
#if defined(_WIN32)
|
||||
EXPECT_EQ(f.substitutedTo, "Arial");
|
||||
#else
|
||||
EXPECT_EQ(f.substitutedTo, "Liberation Sans");
|
||||
#endif
|
||||
}
|
||||
|
||||
EXPECT_GT(f.ascent, 0.0);
|
||||
EXPECT_LT(f.descent, 0.0);
|
||||
EXPECT_GT(f.capHeight, 0.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
auto path = getCorpusPath("fonts", "vertical_text.pdf");
|
||||
if (std::filesystem::exists(path)) {
|
||||
auto docRes = PdfDocument::loadFromFile(path.string());
|
||||
ASSERT_TRUE(docRes.has_value());
|
||||
auto doc = *docRes;
|
||||
|
||||
auto fontsRes = doc->getFonts();
|
||||
ASSERT_TRUE(fontsRes.has_value());
|
||||
|
||||
bool foundVertical = false;
|
||||
for (const auto& f : *fontsRes) {
|
||||
if (f.isVertical) {
|
||||
foundVertical = true;
|
||||
EXPECT_TRUE(f.encoding.find("-V") != std::string::npos || f.cmapName.find("-V") != std::string::npos);
|
||||
}
|
||||
}
|
||||
EXPECT_TRUE(foundVertical);
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
auto path1 = getCorpusPath("fonts", "vertical_identity_v.pdf");
|
||||
if (std::filesystem::exists(path1)) {
|
||||
auto docRes = PdfDocument::loadFromFile(path1.string());
|
||||
ASSERT_TRUE(docRes.has_value());
|
||||
auto fontsRes = (*docRes)->getFonts();
|
||||
ASSERT_TRUE(fontsRes.has_value());
|
||||
|
||||
bool foundVertical = false;
|
||||
for (const auto& f : *fontsRes) {
|
||||
if (f.isVertical) foundVertical = true;
|
||||
}
|
||||
EXPECT_TRUE(foundVertical) << "Failed to detect vertical font in vertical_identity_v.pdf";
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
auto path = getCorpusPath("fonts", "utf-8.pdf");
|
||||
if (!std::filesystem::exists(path)) {
|
||||
path = getCorpusPath("basic", "hello_world.pdf");
|
||||
}
|
||||
if (std::filesystem::exists(path)) {
|
||||
auto docRes = PdfDocument::loadFromFile(path.string());
|
||||
ASSERT_TRUE(docRes.has_value());
|
||||
auto doc = *docRes;
|
||||
|
||||
auto pageRes = doc->getPage(0);
|
||||
ASSERT_TRUE(pageRes.has_value());
|
||||
auto page = *pageRes;
|
||||
|
||||
auto boundsRes = page->extractTextWithBounds();
|
||||
ASSERT_TRUE(boundsRes.has_value());
|
||||
|
||||
const auto& glyphs = *boundsRes;
|
||||
ASSERT_FALSE(glyphs.empty());
|
||||
|
||||
std::vector<double> uniqueSizes;
|
||||
for (const auto& glyph : glyphs) {
|
||||
if (glyph.text != " " && glyph.text != "\r" && glyph.text != "\n" && glyph.text != "\t") {
|
||||
EXPECT_GT(glyph.w, 0.0);
|
||||
EXPECT_GT(glyph.h, 0.0);
|
||||
}
|
||||
EXPECT_GT(glyph.fontSize, 0.0);
|
||||
EXPECT_LT(glyph.fontSize, 100.0);
|
||||
|
||||
if (std::find(uniqueSizes.begin(), uniqueSizes.end(), glyph.fontSize) == uniqueSizes.end()) {
|
||||
uniqueSizes.push_back(glyph.fontSize);
|
||||
}
|
||||
}
|
||||
|
||||
if (path.filename().string() == "utf-8.pdf") {
|
||||
EXPECT_GE(uniqueSizes.size(), 2u);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
TEST(FontDiagnosticsTest, RealPDFiumEmbeddingAndTypeAccuracy) {
|
||||
SKIP_IF_NO_PDFIUM();
|
||||
|
||||
auto path = getCorpusPath("fonts", "text_font.pdf");
|
||||
if (!std::filesystem::exists(path)) {
|
||||
GTEST_SKIP() << "text_font.pdf not found in corpus.";
|
||||
}
|
||||
|
||||
auto docRes = PdfDocument::loadFromFile(path.string());
|
||||
ASSERT_TRUE(docRes.has_value()) << "Failed to open text_font.pdf";
|
||||
auto doc = *docRes;
|
||||
|
||||
auto pageRes = doc->getPage(0);
|
||||
ASSERT_TRUE(pageRes.has_value());
|
||||
auto fontsRes = (*pageRes)->getFonts();
|
||||
ASSERT_TRUE(fontsRes.has_value());
|
||||
const auto& fonts = *fontsRes;
|
||||
ASSERT_FALSE(fonts.empty()) << "text_font.pdf must expose at least one font";
|
||||
|
||||
for (const auto& f : fonts) {
|
||||
EXPECT_FALSE(f.fontName.empty());
|
||||
EXPECT_FALSE(f.type.empty());
|
||||
|
||||
static const std::vector<std::string> kValidTypes = {
|
||||
"Type1", "TrueType", "CIDFontType0", "CIDFontType2"
|
||||
};
|
||||
bool typeValid = std::find(kValidTypes.begin(), kValidTypes.end(), f.type)
|
||||
!= kValidTypes.end();
|
||||
EXPECT_TRUE(typeValid) << "Unexpected type '" << f.type << "' for font '" << f.fontName << "'";
|
||||
|
||||
if (f.isSubset) {
|
||||
EXPECT_TRUE(f.isEmbedded)
|
||||
<< "Subset font '" << f.fontName
|
||||
<< "' must be embedded (FPDFFont_GetIsEmbedded should return 1)";
|
||||
EXPECT_EQ(f.sourceType, "Embedded")
|
||||
<< "sourceType must be 'Embedded' when isEmbedded=true";
|
||||
EXPECT_TRUE(f.substitutedFrom.empty());
|
||||
EXPECT_TRUE(f.substitutedTo.empty());
|
||||
}
|
||||
|
||||
if (f.isEmbedded) {
|
||||
EXPECT_EQ(f.sourceType, "Embedded");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
TEST(FontDiagnosticsTest, RealPDFiumMetricsAccuracy) {
|
||||
SKIP_IF_NO_PDFIUM();
|
||||
|
||||
auto path = getCorpusPath("fonts", "text_font.pdf");
|
||||
if (!std::filesystem::exists(path)) {
|
||||
GTEST_SKIP() << "text_font.pdf not found in corpus.";
|
||||
}
|
||||
|
||||
auto docRes = PdfDocument::loadFromFile(path.string());
|
||||
ASSERT_TRUE(docRes.has_value());
|
||||
auto fontsRes = (*docRes)->getFonts();
|
||||
ASSERT_TRUE(fontsRes.has_value());
|
||||
|
||||
for (const auto& f : *fontsRes) {
|
||||
EXPECT_GT(f.ascent, 0.0)
|
||||
<< "ascent must be positive for font '" << f.fontName << "'";
|
||||
EXPECT_LT(f.descent, 0.0)
|
||||
<< "descent must be negative for font '" << f.fontName << "'";
|
||||
EXPECT_GT(f.capHeight, 0.0)
|
||||
<< "capHeight must be positive for font '" << f.fontName << "'";
|
||||
|
||||
EXPECT_LE(f.capHeight, f.ascent + 1.0)
|
||||
<< "capHeight should not exceed ascent for font '" << f.fontName << "'";
|
||||
|
||||
EXPECT_LT(f.ascent, 1500.0) << "Implausibly large ascent for '" << f.fontName << "'";
|
||||
EXPECT_GT(f.descent, -1500.0) << "Implausibly deep descent for '" << f.fontName << "'";
|
||||
}
|
||||
}
|
||||
|
||||
TEST(FontDiagnosticsTest, ToUnicodePresenceAccuracy) {
|
||||
SKIP_IF_NO_PDFIUM();
|
||||
|
||||
{
|
||||
auto path = getCorpusPath("fonts", "with_tounicode.pdf");
|
||||
if (std::filesystem::exists(path)) {
|
||||
auto docRes = PdfDocument::loadFromFile(path.string());
|
||||
if (docRes.has_value()) {
|
||||
auto pageRes = (*docRes)->getPage(0);
|
||||
if (pageRes.has_value()) {
|
||||
auto fontsRes = (*pageRes)->getFonts();
|
||||
if (fontsRes.has_value() && !fontsRes->empty()) {
|
||||
bool anyTrue = false;
|
||||
for (const auto& f : *fontsRes) {
|
||||
if (f.hasToUnicode) { anyTrue = true; break; }
|
||||
}
|
||||
EXPECT_TRUE(anyTrue)
|
||||
<< "At least one font in with_tounicode.pdf must have hasToUnicode=true";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
auto path = getCorpusPath("fonts", "no_tounicode.pdf");
|
||||
if (std::filesystem::exists(path)) {
|
||||
auto docRes = PdfDocument::loadFromFile(path.string());
|
||||
if (docRes.has_value()) {
|
||||
auto pageRes = (*docRes)->getPage(0);
|
||||
if (pageRes.has_value()) {
|
||||
auto fontsRes = (*pageRes)->getFonts();
|
||||
if (fontsRes.has_value() && !fontsRes->empty()) {
|
||||
for (const auto& f : *fontsRes) {
|
||||
EXPECT_FALSE(f.hasToUnicode)
|
||||
<< "Font '" << f.fontName
|
||||
<< "' in no_tounicode.pdf must have hasToUnicode=false";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
auto path = getCorpusPath("fonts", "latin_extended.pdf");
|
||||
if (std::filesystem::exists(path)) {
|
||||
auto docRes = PdfDocument::loadFromFile(path.string());
|
||||
if (docRes.has_value()) {
|
||||
auto pageRes = (*docRes)->getPage(0);
|
||||
if (pageRes.has_value()) {
|
||||
auto fontsRes = (*pageRes)->getFonts();
|
||||
if (fontsRes.has_value() && !fontsRes->empty()) {
|
||||
bool anyTrue = false;
|
||||
for (const auto& f : *fontsRes) {
|
||||
if (f.hasToUnicode) { anyTrue = true; break; }
|
||||
}
|
||||
EXPECT_TRUE(anyTrue)
|
||||
<< "At least one font in latin_extended.pdf must decode to Unicode";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
TEST(GlyphCacheTest, ConcurrencyBench) {
|
||||
using namespace pdfengine::fonts;
|
||||
|
||||
FontFace face;
|
||||
bool loaded = face.loadFromFile("C:\\Windows\\Fonts\\arial.ttf");
|
||||
if (!loaded) {
|
||||
GTEST_SKIP() << "Skipping benchmark: Arial font not found.";
|
||||
}
|
||||
|
||||
GlyphCache cache(1000);
|
||||
|
||||
auto run_benchmark = [&](int num_threads, int ops_per_thread) {
|
||||
std::atomic<int> start_flag{0};
|
||||
std::vector<std::thread> threads;
|
||||
|
||||
for (int i = 0; i < num_threads; ++i) {
|
||||
threads.emplace_back([&, i]() {
|
||||
while (start_flag.load() == 0) { std::this_thread::yield(); }
|
||||
|
||||
for (int op = 0; op < ops_per_thread; ++op) {
|
||||
unsigned int glyphIndex = (op + i) % 2000;
|
||||
unsigned int fontSize = 12 + (op % 5);
|
||||
|
||||
auto hit = cache.get(face, glyphIndex, fontSize);
|
||||
if (!hit) {
|
||||
GlyphBitmap bmp;
|
||||
bmp.width = 10; bmp.height = 10;
|
||||
cache.insert(face, glyphIndex, fontSize, bmp);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
auto start_time = std::chrono::high_resolution_clock::now();
|
||||
start_flag.store(1);
|
||||
for (auto& t : threads) { t.join(); }
|
||||
auto end_time = std::chrono::high_resolution_clock::now();
|
||||
|
||||
std::chrono::duration<double> diff = end_time - start_time;
|
||||
return diff.count();
|
||||
};
|
||||
|
||||
run_benchmark(2, 1000);
|
||||
cache.clear();
|
||||
|
||||
double time_10 = run_benchmark(10, 10000);
|
||||
std::cout << "[ BENCHMARK ] 10 Threads Time: " << time_10 << " seconds (" << (100000.0 / time_10) << " ops/sec)\n";
|
||||
|
||||
cache.clear();
|
||||
|
||||
double time_50 = run_benchmark(50, 10000);
|
||||
std::cout << "[ BENCHMARK ] 50 Threads Time: " << time_50 << " seconds (" << (500000.0 / time_50) << " ops/sec)\n";
|
||||
|
||||
EXPECT_LE(cache.size(), 1000 + 16);
|
||||
}
|
||||
|
||||
TEST(FontDiagnosticsTest, EmbeddedFontResolutionAndReloadingVerification) {
|
||||
SKIP_IF_NO_PDFIUM();
|
||||
|
||||
std::vector<std::string> testFiles = {
|
||||
"text_font.pdf",
|
||||
"embedded_truetype.pdf",
|
||||
"embedded_cid_font.pdf",
|
||||
"subset_font.pdf",
|
||||
"latin_extended.pdf"
|
||||
};
|
||||
|
||||
bool foundAnyEmbedded = false;
|
||||
for (const auto& fileName : testFiles) {
|
||||
auto path = getCorpusPath("fonts", fileName);
|
||||
if (!std::filesystem::exists(path)) {
|
||||
continue;
|
||||
}
|
||||
std::cout << "\n========================================\n";
|
||||
std::cout << "Testing PDF: " << fileName << "\n";
|
||||
std::cout << "========================================\n";
|
||||
|
||||
auto docRes = PdfDocument::loadFromFile(path.string());
|
||||
if (!docRes.has_value()) {
|
||||
std::cout << "Failed to load document: " << fileName << std::endl;
|
||||
continue;
|
||||
}
|
||||
auto doc = *docRes;
|
||||
|
||||
auto fontsRes = doc->getFonts();
|
||||
if (!fontsRes.has_value()) {
|
||||
std::cout << "Failed to get fonts for: " << fileName << std::endl;
|
||||
continue;
|
||||
}
|
||||
const auto& fonts = *fontsRes;
|
||||
|
||||
for (const auto& fontInfo : fonts) {
|
||||
std::cout << "Font: " << fontInfo.fontName
|
||||
<< ", type: " << fontInfo.type
|
||||
<< ", isEmbedded: " << (fontInfo.isEmbedded ? "yes" : "no")
|
||||
<< ", flags: " << fontInfo.flags << std::endl;
|
||||
if (fontInfo.isEmbedded) {
|
||||
foundAnyEmbedded = true;
|
||||
|
||||
auto resolvedFontRes = doc->getResolvedFont(fontInfo);
|
||||
if (!resolvedFontRes.has_value()) {
|
||||
std::cout << " Failed to resolve font: " << resolvedFontRes.error() << std::endl;
|
||||
continue;
|
||||
}
|
||||
auto resolvedFont = *resolvedFontRes;
|
||||
std::cout << " Resolved font successfully." << std::endl;
|
||||
|
||||
auto face = static_cast<FT_Face>(resolvedFont->getFontFace().getFace());
|
||||
if (face) {
|
||||
std::cout << " FreeType Face Num Glyphs: " << face->num_glyphs << std::endl;
|
||||
std::cout << " FreeType Charmaps Count: " << face->num_charmaps << std::endl;
|
||||
for (int i = 0; i < face->num_charmaps; ++i) {
|
||||
FT_CharMap cm = face->charmaps[i];
|
||||
std::cout << " Charmap " << i << ": platform_id=" << cm->platform_id
|
||||
<< ", encoding_id=" << cm->encoding_id << std::endl;
|
||||
|
||||
FT_Error err = FT_Set_Charmap(face, cm);
|
||||
if (err) {
|
||||
std::cout << " FT_Set_Charmap failed: " << err << std::endl;
|
||||
continue;
|
||||
}
|
||||
|
||||
FT_UInt gindex;
|
||||
FT_ULong charcode = FT_Get_First_Char(face, &gindex);
|
||||
std::cout << " Mapped characters under charmap " << i << ": ";
|
||||
int count = 0;
|
||||
while (gindex != 0 && count < 10) {
|
||||
std::cout << charcode << "->" << gindex << " ";
|
||||
charcode = FT_Get_Next_Char(face, charcode, &gindex);
|
||||
count++;
|
||||
}
|
||||
std::cout << std::endl;
|
||||
}
|
||||
|
||||
if (face->num_charmaps > 0) {
|
||||
FT_Set_Charmap(face, face->charmaps[0]);
|
||||
}
|
||||
|
||||
std::cout << " Glyph names: ";
|
||||
for (int i = 0; i < face->num_glyphs; ++i) {
|
||||
char nameBuf[64] = {0};
|
||||
if (FT_Get_Glyph_Name(face, i, nameBuf, sizeof(nameBuf)) == 0) {
|
||||
std::cout << i << ":" << nameBuf << " ";
|
||||
} else {
|
||||
std::cout << i << ":[unknown] ";
|
||||
}
|
||||
}
|
||||
std::cout << std::endl;
|
||||
} else {
|
||||
std::cout << " No FreeType Face available." << std::endl;
|
||||
}
|
||||
|
||||
EXPECT_TRUE(resolvedFont->isEmbedded());
|
||||
|
||||
std::vector<uint32_t> testChars = {32, 48, 65, 97};
|
||||
for (uint32_t cp : testChars) {
|
||||
bool hasG = resolvedFont->hasGlyph(cp);
|
||||
double w = resolvedFont->getAdvanceWidth(cp, 12.0);
|
||||
std::cout << " char(" << cp << "): hasGlyph=" << (hasG ? "yes" : "no")
|
||||
<< ", advanceWidth=" << w << std::endl;
|
||||
}
|
||||
|
||||
auto metrics = resolvedFont->getMetrics(12.0);
|
||||
std::cout << " Metrics: ascent=" << metrics.ascent << ", descent=" << metrics.descent << ", capHeight=" << metrics.capHeight << std::endl;
|
||||
EXPECT_NE(metrics.ascent, 0.0);
|
||||
EXPECT_NE(metrics.descent, 0.0);
|
||||
EXPECT_NE(metrics.capHeight, 0.0);
|
||||
|
||||
if (fileName == "text_font.pdf") {
|
||||
EXPECT_TRUE(resolvedFont->hasGlyph(1));
|
||||
double w = resolvedFont->getAdvanceWidth(1, 12.0);
|
||||
EXPECT_GT(w, 0.0);
|
||||
std::cout << " [VERIFIED] text_font.pdf char(1): hasGlyph=yes, advanceWidth=" << w << std::endl;
|
||||
}
|
||||
|
||||
if (face->num_glyphs > 1) {
|
||||
bool foundNonZeroWidth = false;
|
||||
for (int gid = 1; gid < face->num_glyphs; ++gid) {
|
||||
FT_Error err = FT_Load_Glyph(face, gid, FT_LOAD_DEFAULT);
|
||||
if (err == 0) {
|
||||
double directWidth = static_cast<double>(face->glyph->advance.x) / 64.0;
|
||||
if (directWidth > 0.0) {
|
||||
foundNonZeroWidth = true;
|
||||
std::cout << " [VERIFIED] Direct glyph " << gid << " load: advanceWidth=" << directWidth << std::endl;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
EXPECT_TRUE(foundNonZeroWidth) << "Expected to find at least one glyph with a non-zero advance width";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
EXPECT_TRUE(foundAnyEmbedded) << "Expected to find at least one embedded font in test files";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,351 @@
|
||||
#include "fonts_test_helpers.hpp"
|
||||
|
||||
namespace pdfengine::fonts {
|
||||
|
||||
TEST(EncodingTest, PredefinedEncodingTest) {
|
||||
using namespace pdfengine::fonts::pdf_fonts;
|
||||
|
||||
PredefinedEncoding winAnsi(SimpleEncodingType::WinAnsi);
|
||||
EXPECT_EQ(winAnsi.getType(), SimpleEncodingType::WinAnsi);
|
||||
EXPECT_EQ(winAnsi.decode(65), 65);
|
||||
EXPECT_EQ(winAnsi.decode(128), 0x20AC);
|
||||
EXPECT_EQ(winAnsi.decode(169), 169);
|
||||
EXPECT_EQ(winAnsi.decode(300), 0);
|
||||
|
||||
PredefinedEncoding macRoman(SimpleEncodingType::MacRoman);
|
||||
EXPECT_EQ(macRoman.getType(), SimpleEncodingType::MacRoman);
|
||||
EXPECT_EQ(macRoman.decode(65), 65);
|
||||
EXPECT_EQ(macRoman.decode(128), 0x00C4);
|
||||
EXPECT_EQ(macRoman.decode(300), 0);
|
||||
|
||||
PredefinedEncoding identity(SimpleEncodingType::Identity);
|
||||
EXPECT_EQ(identity.getType(), SimpleEncodingType::Identity);
|
||||
EXPECT_EQ(identity.decode(65), 65);
|
||||
EXPECT_EQ(identity.decode(128), 128);
|
||||
EXPECT_EQ(identity.decode(1000), 1000);
|
||||
}
|
||||
|
||||
TEST(EncodingTest, CustomEncodingWithDifferences) {
|
||||
using namespace pdfengine::fonts::pdf_fonts;
|
||||
|
||||
auto baseEncoding = std::make_unique<PredefinedEncoding>(SimpleEncodingType::WinAnsi);
|
||||
CustomEncoding custom(std::move(baseEncoding));
|
||||
|
||||
EXPECT_EQ(custom.decode(65), 65);
|
||||
|
||||
custom.addDifference(120, "quotesingle");
|
||||
EXPECT_EQ(custom.decode(120), 0x0027);
|
||||
|
||||
custom.addDifference(121, "uni0041");
|
||||
EXPECT_EQ(custom.decode(121), 0x0041);
|
||||
|
||||
custom.addDifference(122, "u0042");
|
||||
EXPECT_EQ(custom.decode(122), 0x0042);
|
||||
|
||||
custom.addDifference(123, "nonexistentglyphname123");
|
||||
EXPECT_EQ(custom.decode(123), 123);
|
||||
}
|
||||
|
||||
TEST(EncodingTest, ToUnicodeCMapbfchar) {
|
||||
using namespace pdfengine::fonts::pdf_fonts;
|
||||
|
||||
ToUnicodeCMap cmap;
|
||||
|
||||
std::string cmapStream =
|
||||
"/CIDInit /ProcSet findresource begin\n"
|
||||
"12 dict begin\n"
|
||||
"begincmap\n"
|
||||
"/CIDSystemInfo << /Registry (Adobe) /Ordering (UCS) /Supplement 0 >> def\n"
|
||||
"/CMapName /Custom-ToUnicode def\n"
|
||||
"1 begincodespacerange\n"
|
||||
"<0000> <FFFF>\n"
|
||||
"endcodespacerange\n"
|
||||
"2 beginbfchar\n"
|
||||
"<0001> <0041>\n"
|
||||
"<0002> <0042>\n"
|
||||
"endbfchar\n"
|
||||
"endcmap\n"
|
||||
"CMapName currentdict /CMap defineresource pop\n"
|
||||
"end\n"
|
||||
"end\n";
|
||||
|
||||
ASSERT_TRUE(cmap.parseCMapStream(cmapStream));
|
||||
EXPECT_EQ(cmap.decode(1), 0x0041);
|
||||
EXPECT_EQ(cmap.decode(2), 0x0042);
|
||||
EXPECT_EQ(cmap.decode(3), 0);
|
||||
}
|
||||
|
||||
TEST(EncodingTest, ToUnicodeCMapbfrange) {
|
||||
using namespace pdfengine::fonts::pdf_fonts;
|
||||
|
||||
ToUnicodeCMap cmap;
|
||||
|
||||
std::string cmapStream =
|
||||
"begincmap\n"
|
||||
"2 beginbfrange\n"
|
||||
"<0001> <0005> <0041>\n"
|
||||
"<0010> <0012> [<0061> <0062> <0063>]\n"
|
||||
"endbfrange\n"
|
||||
"endcmap\n";
|
||||
|
||||
ASSERT_TRUE(cmap.parseCMapStream(cmapStream));
|
||||
|
||||
EXPECT_EQ(cmap.decode(1), 0x0041);
|
||||
EXPECT_EQ(cmap.decode(3), 0x0043);
|
||||
EXPECT_EQ(cmap.decode(5), 0x0045);
|
||||
|
||||
EXPECT_EQ(cmap.decode(0x10), 0x0061);
|
||||
EXPECT_EQ(cmap.decode(0x11), 0x0062);
|
||||
EXPECT_EQ(cmap.decode(0x12), 0x0063);
|
||||
}
|
||||
|
||||
TEST(EncodingTest, ToUnicodeMalformedCMap) {
|
||||
using namespace pdfengine::fonts::pdf_fonts;
|
||||
|
||||
ToUnicodeCMap cmap;
|
||||
|
||||
std::string garbageStream = "This is a garbage string with no valid CMap elements";
|
||||
EXPECT_FALSE(cmap.parseCMapStream(garbageStream));
|
||||
|
||||
std::string partialStream =
|
||||
"begincmap\n"
|
||||
"beginbfchar\n"
|
||||
"<0001> <0041>\n"
|
||||
"<0002> /invalid\n"
|
||||
"<0003> <0043>\n"
|
||||
"endbfchar\n"
|
||||
"endcmap\n";
|
||||
|
||||
EXPECT_TRUE(cmap.parseCMapStream(partialStream));
|
||||
EXPECT_EQ(cmap.decode(1), 0x0041);
|
||||
EXPECT_EQ(cmap.decode(2), 0);
|
||||
EXPECT_EQ(cmap.decode(3), 0x0043);
|
||||
}
|
||||
|
||||
TEST(EncodingTest, FontLoaderWithEncoding) {
|
||||
std::string fontPath = getSystemFontPath();
|
||||
if (fontPath.empty()) {
|
||||
GTEST_SKIP() << "No system font found to run PDF font loader with encoding test.";
|
||||
}
|
||||
|
||||
std::ifstream file(fontPath, std::ios::binary | std::ios::ate);
|
||||
ASSERT_TRUE(file.is_open());
|
||||
std::streamsize size = file.tellg();
|
||||
file.seekg(0, std::ios::beg);
|
||||
std::vector<uint8_t> buffer(size);
|
||||
ASSERT_TRUE(file.read(reinterpret_cast<char*>(buffer.data()), size));
|
||||
|
||||
auto encoding = std::make_unique<pdfengine::fonts::pdf_fonts::PredefinedEncoding>(
|
||||
pdfengine::fonts::pdf_fonts::SimpleEncodingType::WinAnsi
|
||||
);
|
||||
|
||||
auto pdfFont = pdfengine::fonts::pdf_fonts::FontLoader::loadTrueTypeFromMemory(
|
||||
"Arial-With-Encoding", buffer, nullptr, std::move(encoding)
|
||||
);
|
||||
|
||||
ASSERT_NE(pdfFont, nullptr);
|
||||
EXPECT_EQ(pdfFont->getBaseFont(), "Arial-With-Encoding");
|
||||
|
||||
const auto* retrievedEncoding = pdfFont->getEncoding();
|
||||
ASSERT_NE(retrievedEncoding, nullptr);
|
||||
|
||||
EXPECT_EQ(retrievedEncoding->decode(128), 0x20AC);
|
||||
}
|
||||
TEST(FontSubsetTest, SubsetTagParsingAndStripping) {
|
||||
using namespace pdfengine::fonts::pdf_fonts;
|
||||
|
||||
std::string subsetName = "KTJHQO+Arial";
|
||||
|
||||
EXPECT_TRUE(FontSubset::hasSubsetPrefix(subsetName));
|
||||
EXPECT_EQ(FontSubset::getSubsetPrefix(subsetName), "KTJHQO");
|
||||
EXPECT_EQ(FontSubset::stripSubsetPrefix(subsetName), "Arial");
|
||||
|
||||
std::string normalName = "Arial";
|
||||
EXPECT_FALSE(FontSubset::hasSubsetPrefix(normalName));
|
||||
EXPECT_EQ(FontSubset::getSubsetPrefix(normalName), "");
|
||||
EXPECT_EQ(FontSubset::stripSubsetPrefix(normalName), "Arial");
|
||||
}
|
||||
|
||||
TEST(FontSubsetTest, PrefixFormatValidation) {
|
||||
using namespace pdfengine::fonts::pdf_fonts;
|
||||
|
||||
EXPECT_TRUE(FontSubset::hasSubsetPrefix("ABCDEF+Helvetica"));
|
||||
|
||||
EXPECT_FALSE(FontSubset::hasSubsetPrefix("abcDEF+Helvetica"));
|
||||
EXPECT_FALSE(FontSubset::hasSubsetPrefix("ABCdef+Helvetica"));
|
||||
|
||||
EXPECT_FALSE(FontSubset::hasSubsetPrefix("ABC123+Helvetica"));
|
||||
EXPECT_FALSE(FontSubset::hasSubsetPrefix("ABCDE_+Helvetica"));
|
||||
|
||||
EXPECT_FALSE(FontSubset::hasSubsetPrefix("ABCDE+Helvetica"));
|
||||
EXPECT_FALSE(FontSubset::hasSubsetPrefix("ABCDEFG+Helvetica"));
|
||||
}
|
||||
|
||||
TEST(FontSubsetTest, GIDRemappingTranslations) {
|
||||
using namespace pdfengine::fonts::pdf_fonts;
|
||||
|
||||
FontSubset subset("KTJHQO+Arial");
|
||||
EXPECT_TRUE(subset.isSubset());
|
||||
EXPECT_EQ(subset.getFullFontName(), "KTJHQO+Arial");
|
||||
EXPECT_EQ(subset.getBaseFontName(), "Arial");
|
||||
EXPECT_EQ(subset.getPrefix(), "KTJHQO");
|
||||
|
||||
EXPECT_EQ(subset.mapSubsetToOriginal(5), 5);
|
||||
EXPECT_FALSE(subset.hasGlyphMapping(5));
|
||||
|
||||
subset.addGlyphMapping(1, 41);
|
||||
subset.addGlyphMapping(2, 42);
|
||||
subset.addGlyphMapping(3, 43);
|
||||
|
||||
EXPECT_EQ(subset.getMappingCount(), 3u);
|
||||
EXPECT_TRUE(subset.hasGlyphMapping(1));
|
||||
EXPECT_TRUE(subset.hasGlyphMapping(2));
|
||||
EXPECT_EQ(subset.mapSubsetToOriginal(1), 41);
|
||||
EXPECT_EQ(subset.mapSubsetToOriginal(2), 42);
|
||||
EXPECT_EQ(subset.mapSubsetToOriginal(3), 43);
|
||||
EXPECT_EQ(subset.mapSubsetToOriginal(4), 4);
|
||||
}
|
||||
|
||||
TEST(FontSubsetTest, CoreFontSubsettingIntegration) {
|
||||
using namespace pdfengine::fonts::pdf_fonts;
|
||||
|
||||
TrueTypeFont fontTT("KTJHQO+Arial", false);
|
||||
const auto* ttSubset = fontTT.getSubsetInfo();
|
||||
ASSERT_NE(ttSubset, nullptr);
|
||||
EXPECT_TRUE(ttSubset->isSubset());
|
||||
EXPECT_EQ(ttSubset->getBaseFontName(), "Arial");
|
||||
EXPECT_EQ(ttSubset->getPrefix(), "KTJHQO");
|
||||
|
||||
Type1Font fontT1("SUBSET+Courier", false);
|
||||
const auto* t1Subset = fontT1.getSubsetInfo();
|
||||
ASSERT_NE(t1Subset, nullptr);
|
||||
EXPECT_TRUE(t1Subset->isSubset());
|
||||
EXPECT_EQ(t1Subset->getBaseFontName(), "Courier");
|
||||
EXPECT_EQ(t1Subset->getPrefix(), "SUBSET");
|
||||
|
||||
CIDFont fontCID("CJKTAG+SimSun", FontType::CIDFontType2, false);
|
||||
const auto* cidSubset = fontCID.getSubsetInfo();
|
||||
ASSERT_NE(cidSubset, nullptr);
|
||||
EXPECT_TRUE(cidSubset->isSubset());
|
||||
EXPECT_EQ(cidSubset->getBaseFontName(), "SimSun");
|
||||
EXPECT_EQ(cidSubset->getPrefix(), "CJKTAG");
|
||||
}
|
||||
|
||||
TEST(TextExtractionLayerTest, SimpleCharacterDecoding) {
|
||||
using namespace pdfengine::fonts::pdf_fonts;
|
||||
|
||||
auto font = FontLoader::loadType1SystemFallback("Helvetica");
|
||||
ASSERT_NE(font, nullptr);
|
||||
|
||||
EXPECT_EQ(font->decodeToUnicode(65), 65u);
|
||||
EXPECT_EQ(font->decodeToUnicode(97), 97u);
|
||||
EXPECT_EQ(font->decodeToUnicode(48), 48u);
|
||||
}
|
||||
|
||||
TEST(TextExtractionLayerTest, EncodingAndToUnicodeDecoding) {
|
||||
using namespace pdfengine::fonts::pdf_fonts;
|
||||
|
||||
auto baseEncoding = std::make_unique<PredefinedEncoding>(SimpleEncodingType::WinAnsi);
|
||||
auto customEnc = std::make_unique<CustomEncoding>(std::move(baseEncoding));
|
||||
customEnc->addDifference(128, "euro");
|
||||
|
||||
auto font = FontLoader::loadType1SystemFallback("Helvetica", nullptr, std::move(customEnc));
|
||||
ASSERT_NE(font, nullptr);
|
||||
|
||||
EXPECT_EQ(font->decodeToUnicode(128), 0x20ACu);
|
||||
|
||||
auto cmap = std::make_unique<ToUnicodeCMap>();
|
||||
cmap->addMapping(1, 0x0041);
|
||||
cmap->addMapping(2, 0x0042);
|
||||
cmap->addMapping(3, 0x20AC);
|
||||
|
||||
auto fontCMap = FontLoader::loadType1SystemFallback("Helvetica", nullptr, std::move(cmap));
|
||||
ASSERT_NE(fontCMap, nullptr);
|
||||
EXPECT_EQ(fontCMap->decodeToUnicode(1), 0x0041u);
|
||||
EXPECT_EQ(fontCMap->decodeToUnicode(2), 0x0042u);
|
||||
EXPECT_EQ(fontCMap->decodeToUnicode(3), 0x20ACu);
|
||||
}
|
||||
|
||||
TEST(TextExtractionLayerTest, SubsetFontTextExtraction) {
|
||||
using namespace pdfengine::fonts::pdf_fonts;
|
||||
|
||||
auto font = FontLoader::loadType1SystemFallback("KTJHQO+Helvetica");
|
||||
ASSERT_NE(font, nullptr);
|
||||
|
||||
const auto* subsetConst = font->getSubsetInfo();
|
||||
ASSERT_NE(subsetConst, nullptr);
|
||||
auto* subset = const_cast<FontSubset*>(subsetConst);
|
||||
|
||||
FT_Face face = font->getFontFace().getFace();
|
||||
ASSERT_NE(face, nullptr);
|
||||
FT_UInt originalGid = FT_Get_Char_Index(face, 'A');
|
||||
ASSERT_GT(originalGid, 0u);
|
||||
|
||||
subset->addGlyphMapping(5, originalGid);
|
||||
|
||||
EXPECT_EQ(font->decodeToUnicode(5), 65u);
|
||||
}
|
||||
|
||||
TEST(TextExtractionLayerTest, CIDFontTextExtraction) {
|
||||
using namespace pdfengine::fonts::pdf_fonts;
|
||||
|
||||
auto font = FontLoader::loadCIDFontSystemFallback("SimSun", FontType::CIDFontType2);
|
||||
ASSERT_NE(font, nullptr);
|
||||
|
||||
CIDFont* cidFont = dynamic_cast<CIDFont*>(font.get());
|
||||
ASSERT_NE(cidFont, nullptr);
|
||||
|
||||
FT_Face face = cidFont->getFontFace().getFace();
|
||||
ASSERT_NE(face, nullptr);
|
||||
FT_UInt gid65 = 65;
|
||||
FT_ULong expectedChar65 = 0;
|
||||
|
||||
FT_UInt gindex;
|
||||
FT_ULong charcode = FT_Get_First_Char(face, &gindex);
|
||||
while (gindex != 0) {
|
||||
if (gindex == gid65) {
|
||||
expectedChar65 = charcode;
|
||||
break;
|
||||
}
|
||||
charcode = FT_Get_Next_Char(face, charcode, &gindex);
|
||||
}
|
||||
|
||||
if (expectedChar65 != 0) {
|
||||
EXPECT_EQ(cidFont->decodeToUnicode(65), expectedChar65);
|
||||
} else {
|
||||
EXPECT_EQ(cidFont->decodeToUnicode(65), 65u);
|
||||
}
|
||||
|
||||
FT_UInt gidA = FT_Get_Char_Index(face, 'A');
|
||||
if (gidA > 0) {
|
||||
std::unordered_map<uint32_t, uint32_t> cidToGid = {
|
||||
{500, gidA}
|
||||
};
|
||||
cidFont->setCIDToGIDMap(cidToGid);
|
||||
|
||||
EXPECT_EQ(cidFont->decodeToUnicode(500), 65u);
|
||||
}
|
||||
}
|
||||
|
||||
TEST(TextExtractionLayerTest, StringUtf8Conversion) {
|
||||
using namespace pdfengine::fonts::pdf_fonts;
|
||||
|
||||
auto font = FontLoader::loadType1SystemFallback("Helvetica");
|
||||
ASSERT_NE(font, nullptr);
|
||||
|
||||
std::vector<uint32_t> codes = {65, 66, 67};
|
||||
EXPECT_EQ(font->decodeStringToUnicode(codes), "ABC");
|
||||
|
||||
auto cmap = std::make_unique<ToUnicodeCMap>();
|
||||
cmap->addMapping(10, 0x65E5);
|
||||
cmap->addMapping(11, 0x672C);
|
||||
cmap->addMapping(12, 0x8A9E);
|
||||
|
||||
auto fontCMap = FontLoader::loadType1SystemFallback("Helvetica", nullptr, std::move(cmap));
|
||||
ASSERT_NE(fontCMap, nullptr);
|
||||
|
||||
std::vector<uint32_t> cjkCodes = {10, 11, 12};
|
||||
std::string decodedCjk = fontCMap->decodeStringToUnicode(cjkCodes);
|
||||
EXPECT_EQ(decodedCjk, "日本語");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
#include "fonts_test_helpers.hpp"
|
||||
|
||||
namespace pdfengine::fonts {
|
||||
|
||||
TEST(FontLoaderTest, FontFaceLoadFromMemorySuccess) {
|
||||
std::string fontPath = getSystemFontPath();
|
||||
if (fontPath.empty()) {
|
||||
GTEST_SKIP() << "No system font found to run load from memory success test.";
|
||||
}
|
||||
|
||||
std::ifstream file(fontPath, std::ios::binary | std::ios::ate);
|
||||
ASSERT_TRUE(file.is_open());
|
||||
std::streamsize size = file.tellg();
|
||||
file.seekg(0, std::ios::beg);
|
||||
|
||||
std::vector<uint8_t> buffer(size);
|
||||
ASSERT_TRUE(file.read(reinterpret_cast<char*>(buffer.data()), size));
|
||||
|
||||
FontFace face;
|
||||
ASSERT_TRUE(face.loadFromMemory(buffer));
|
||||
ASSERT_NE(face.getFace(), nullptr);
|
||||
|
||||
unsigned int glyphIndex = FT_Get_Char_Index(face.getFace(), 'M');
|
||||
ASSERT_GT(glyphIndex, 0u);
|
||||
|
||||
auto glyph = face.renderGlyph(glyphIndex, 16);
|
||||
ASSERT_TRUE(glyph.has_value());
|
||||
EXPECT_GT(glyph->width, 0);
|
||||
EXPECT_GT(glyph->height, 0);
|
||||
EXPECT_GT(glyph->advance, 0.0);
|
||||
}
|
||||
|
||||
TEST(FontLoaderTest, FontFaceLoadFromMemoryInvalid) {
|
||||
FontFace face;
|
||||
std::vector<uint8_t> emptyData;
|
||||
EXPECT_FALSE(face.loadFromMemory(emptyData));
|
||||
EXPECT_EQ(face.getFace(), nullptr);
|
||||
|
||||
std::vector<uint8_t> corruptData = {0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66};
|
||||
EXPECT_FALSE(face.loadFromMemory(corruptData));
|
||||
EXPECT_EQ(face.getFace(), nullptr);
|
||||
}
|
||||
|
||||
TEST(FontLoaderTest, FontLoaderTrueTypeSuccess) {
|
||||
std::string fontPath = getSystemFontPath();
|
||||
if (fontPath.empty()) {
|
||||
GTEST_SKIP() << "No system font found to run PDF font loader test.";
|
||||
}
|
||||
|
||||
std::ifstream file(fontPath, std::ios::binary | std::ios::ate);
|
||||
ASSERT_TRUE(file.is_open());
|
||||
std::streamsize size = file.tellg();
|
||||
file.seekg(0, std::ios::beg);
|
||||
std::vector<uint8_t> buffer(size);
|
||||
ASSERT_TRUE(file.read(reinterpret_cast<char*>(buffer.data()), size));
|
||||
|
||||
auto pdfFont = pdfengine::fonts::pdf_fonts::FontLoader::loadTrueTypeFromMemory("Arial", buffer);
|
||||
ASSERT_NE(pdfFont, nullptr);
|
||||
EXPECT_EQ(pdfFont->getBaseFont(), "Arial");
|
||||
EXPECT_EQ(pdfFont->getType(), pdfengine::fonts::pdf_fonts::FontType::TrueType);
|
||||
EXPECT_TRUE(pdfFont->isEmbedded());
|
||||
|
||||
HbShaper shaper;
|
||||
auto glyphs = shaper.shapeRun("Test Memory Load", pdfFont->getFontFace(), 16);
|
||||
EXPECT_FALSE(glyphs.empty());
|
||||
}
|
||||
|
||||
TEST(FontDescriptorTest, DescriptorDefaultValues) {
|
||||
pdfengine::fonts::pdf_fonts::FontDescriptor desc;
|
||||
EXPECT_EQ(desc.getFontName(), "");
|
||||
EXPECT_EQ(desc.getFlags(), 0);
|
||||
EXPECT_EQ(desc.getItalicAngle(), 0.0);
|
||||
EXPECT_EQ(desc.getAscent(), 0.0);
|
||||
EXPECT_EQ(desc.getDescent(), 0.0);
|
||||
EXPECT_EQ(desc.getCapHeight(), 0.0);
|
||||
EXPECT_EQ(desc.getStemV(), 0.0);
|
||||
|
||||
pdfengine::fonts::pdf_fonts::FontBBox bbox = desc.getFontBBox();
|
||||
EXPECT_EQ(bbox.llx, 0);
|
||||
EXPECT_EQ(bbox.lly, 0);
|
||||
EXPECT_EQ(bbox.urx, 0);
|
||||
EXPECT_EQ(bbox.ury, 0);
|
||||
|
||||
EXPECT_FALSE(desc.isFixedPitch());
|
||||
EXPECT_FALSE(desc.isSerif());
|
||||
EXPECT_FALSE(desc.isSymbolic());
|
||||
EXPECT_FALSE(desc.isItalic());
|
||||
}
|
||||
|
||||
TEST(FontDescriptorTest, DescriptorParsingSuccess) {
|
||||
std::string dict =
|
||||
"<< /Type /FontDescriptor\n"
|
||||
" /FontName /ArialMT\n"
|
||||
" /Flags 32\n"
|
||||
" /FontBBox [-166 -225 1000 931]\n"
|
||||
" /ItalicAngle 0\n"
|
||||
" /Ascent 905\n"
|
||||
" /Descent -211\n"
|
||||
" /CapHeight 728\n"
|
||||
" /StemV 94\n"
|
||||
">>";
|
||||
|
||||
pdfengine::fonts::pdf_fonts::FontDescriptor desc;
|
||||
ASSERT_TRUE(desc.parseFromDictionaryString(dict));
|
||||
|
||||
EXPECT_EQ(desc.getFontName(), "ArialMT");
|
||||
EXPECT_EQ(desc.getFlags(), 32);
|
||||
|
||||
pdfengine::fonts::pdf_fonts::FontBBox bbox = desc.getFontBBox();
|
||||
EXPECT_EQ(bbox.llx, -166);
|
||||
EXPECT_EQ(bbox.lly, -225);
|
||||
EXPECT_EQ(bbox.urx, 1000);
|
||||
EXPECT_EQ(bbox.ury, 931);
|
||||
|
||||
EXPECT_DOUBLE_EQ(desc.getItalicAngle(), 0.0);
|
||||
EXPECT_DOUBLE_EQ(desc.getAscent(), 905.0);
|
||||
EXPECT_DOUBLE_EQ(desc.getDescent(), -211.0);
|
||||
EXPECT_DOUBLE_EQ(desc.getCapHeight(), 728.0);
|
||||
EXPECT_DOUBLE_EQ(desc.getStemV(), 94.0);
|
||||
|
||||
EXPECT_FALSE(desc.isFixedPitch());
|
||||
EXPECT_TRUE(desc.isNonsymbolic());
|
||||
EXPECT_FALSE(desc.isItalic());
|
||||
}
|
||||
|
||||
TEST(FontDescriptorTest, DescriptorParsingMalformed) {
|
||||
pdfengine::fonts::pdf_fonts::FontDescriptor desc;
|
||||
|
||||
EXPECT_FALSE(desc.parseFromDictionaryString("/Flags 32 >>"));
|
||||
|
||||
EXPECT_FALSE(desc.parseFromDictionaryString("<< /Flags 32"));
|
||||
|
||||
EXPECT_FALSE(desc.parseFromDictionaryString("<< /FontBBox [-166 -225] >>"));
|
||||
|
||||
EXPECT_FALSE(desc.parseFromDictionaryString("<< /Ascent abc >>"));
|
||||
|
||||
EXPECT_FALSE(desc.parseFromDictionaryString("<< /Ascent >>"));
|
||||
}
|
||||
|
||||
TEST(FontDescriptorTest, FontLoaderWithDescriptor) {
|
||||
std::string fontPath = getSystemFontPath();
|
||||
if (fontPath.empty()) {
|
||||
GTEST_SKIP() << "No system font found to run PDF font loader test.";
|
||||
}
|
||||
|
||||
std::ifstream file(fontPath, std::ios::binary | std::ios::ate);
|
||||
ASSERT_TRUE(file.is_open());
|
||||
std::streamsize size = file.tellg();
|
||||
file.seekg(0, std::ios::beg);
|
||||
std::vector<uint8_t> buffer(size);
|
||||
ASSERT_TRUE(file.read(reinterpret_cast<char*>(buffer.data()), size));
|
||||
|
||||
auto descriptor = std::make_unique<pdfengine::fonts::pdf_fonts::FontDescriptor>();
|
||||
descriptor->setFontName("Arial-BoldMT");
|
||||
descriptor->setFlags(96);
|
||||
descriptor->setAscent(905.0);
|
||||
descriptor->setDescent(-211.0);
|
||||
|
||||
auto pdfFont = pdfengine::fonts::pdf_fonts::FontLoader::loadTrueTypeFromMemory("Arial-Bold", buffer, std::move(descriptor));
|
||||
ASSERT_NE(pdfFont, nullptr);
|
||||
EXPECT_EQ(pdfFont->getBaseFont(), "Arial-Bold");
|
||||
EXPECT_TRUE(pdfFont->isEmbedded());
|
||||
|
||||
const auto* retrievedDesc = pdfFont->getDescriptor();
|
||||
ASSERT_NE(retrievedDesc, nullptr);
|
||||
EXPECT_EQ(retrievedDesc->getFontName(), "Arial-BoldMT");
|
||||
EXPECT_EQ(retrievedDesc->getFlags(), 96);
|
||||
EXPECT_TRUE(retrievedDesc->isItalic());
|
||||
EXPECT_TRUE(retrievedDesc->isNonsymbolic());
|
||||
EXPECT_DOUBLE_EQ(retrievedDesc->getAscent(), 905.0);
|
||||
EXPECT_DOUBLE_EQ(retrievedDesc->getDescent(), -211.0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,454 @@
|
||||
#include "fonts_test_helpers.hpp"
|
||||
|
||||
namespace pdfengine::fonts {
|
||||
|
||||
TEST(FontTest, FontFaceInitialization) {
|
||||
FontFace face;
|
||||
EXPECT_EQ(face.getFace(), nullptr);
|
||||
}
|
||||
|
||||
TEST(FontTest, FontFaceLoadNonExistentFile) {
|
||||
FontFace face;
|
||||
EXPECT_FALSE(face.loadFromFile("this_file_does_not_exist_12345.ttf"));
|
||||
EXPECT_EQ(face.getFace(), nullptr);
|
||||
}
|
||||
|
||||
TEST(FontTest, FontFaceMoveSemantics) {
|
||||
std::string fontPath = getSystemFontPath();
|
||||
if (fontPath.empty()) {
|
||||
GTEST_SKIP() << "No system font found to run move semantics test.";
|
||||
}
|
||||
|
||||
FontFace face1;
|
||||
ASSERT_TRUE(face1.loadFromFile(fontPath));
|
||||
FT_Face rawFace = face1.getFace();
|
||||
ASSERT_NE(rawFace, nullptr);
|
||||
|
||||
FontFace face2(std::move(face1));
|
||||
EXPECT_EQ(face1.getFace(), nullptr);
|
||||
EXPECT_EQ(face2.getFace(), rawFace);
|
||||
|
||||
FontFace face3;
|
||||
face3 = std::move(face2);
|
||||
EXPECT_EQ(face2.getFace(), nullptr);
|
||||
EXPECT_EQ(face3.getFace(), rawFace);
|
||||
}
|
||||
|
||||
TEST(FontTest, HbShaperEmptyInput) {
|
||||
std::string fontPath = getSystemFontPath();
|
||||
if (fontPath.empty()) {
|
||||
GTEST_SKIP() << "No system font found to run empty input shaper test.";
|
||||
}
|
||||
|
||||
FontFace face;
|
||||
ASSERT_TRUE(face.loadFromFile(fontPath));
|
||||
|
||||
HbShaper shaper;
|
||||
auto glyphs = shaper.shapeRun("", face, 16);
|
||||
EXPECT_TRUE(glyphs.empty());
|
||||
}
|
||||
|
||||
TEST(FontTest, HbShaperNullFace) {
|
||||
FontFace face;
|
||||
HbShaper shaper;
|
||||
auto glyphs = shaper.shapeRun("Hello", face, 16);
|
||||
EXPECT_TRUE(glyphs.empty());
|
||||
}
|
||||
|
||||
TEST(FontTest, HbShaperShapeTextSuccess) {
|
||||
std::string fontPath = getSystemFontPath();
|
||||
if (fontPath.empty()) {
|
||||
std::cout << "[ WARNING ] Skipping shape success test: no system font found." << std::endl;
|
||||
GTEST_SKIP() << "No system font found to run text shaping test.";
|
||||
}
|
||||
|
||||
FontFace face;
|
||||
ASSERT_TRUE(face.loadFromFile(fontPath));
|
||||
ASSERT_NE(face.getFace(), nullptr);
|
||||
|
||||
HbShaper shaper;
|
||||
std::string testText = "Hello World!";
|
||||
auto glyphs = shaper.shapeRun(testText, face, 16);
|
||||
|
||||
EXPECT_FALSE(glyphs.empty());
|
||||
|
||||
for (const auto& g : glyphs) {
|
||||
EXPECT_GT(g.advanceX, 0.0);
|
||||
}
|
||||
}
|
||||
|
||||
TEST(FontTest, FontFaceRenderGlyphNullFace) {
|
||||
FontFace face;
|
||||
auto glyph = face.renderGlyph(0, 16);
|
||||
EXPECT_FALSE(glyph.has_value());
|
||||
}
|
||||
|
||||
TEST(FontTest, FontFaceRenderGlyphSuccess) {
|
||||
std::string fontPath = getSystemFontPath();
|
||||
if (fontPath.empty()) {
|
||||
GTEST_SKIP() << "No system font found to run render glyph success test.";
|
||||
}
|
||||
|
||||
FontFace face;
|
||||
ASSERT_TRUE(face.loadFromFile(fontPath));
|
||||
ASSERT_NE(face.getFace(), nullptr);
|
||||
|
||||
unsigned int glyphIndex = FT_Get_Char_Index(face.getFace(), 'A');
|
||||
ASSERT_GT(glyphIndex, 0u);
|
||||
|
||||
auto glyphOpt = face.renderGlyph(glyphIndex, 24);
|
||||
ASSERT_TRUE(glyphOpt.has_value());
|
||||
|
||||
const auto& glyph = *glyphOpt;
|
||||
EXPECT_GT(glyph.width, 0);
|
||||
EXPECT_GT(glyph.height, 0);
|
||||
EXPECT_EQ(glyph.pixels.size(), static_cast<size_t>(glyph.width * glyph.height));
|
||||
EXPECT_GT(glyph.advance, 0.0);
|
||||
}
|
||||
|
||||
TEST(FontTest, GlyphCacheBasicGetInsert) {
|
||||
std::string fontPath = getSystemFontPath();
|
||||
if (fontPath.empty()) {
|
||||
GTEST_SKIP() << "No system font found to run cache test.";
|
||||
}
|
||||
|
||||
FontFace face;
|
||||
ASSERT_TRUE(face.loadFromFile(fontPath));
|
||||
|
||||
GlyphCache cache(10);
|
||||
EXPECT_EQ(cache.capacity(), 10u);
|
||||
EXPECT_EQ(cache.size(), 0u);
|
||||
|
||||
auto miss = cache.get(face, 12, 16);
|
||||
EXPECT_FALSE(miss.has_value());
|
||||
|
||||
GlyphBitmap bitmap;
|
||||
bitmap.width = 10;
|
||||
bitmap.height = 12;
|
||||
bitmap.pixels = std::vector<unsigned char>(120, 255);
|
||||
bitmap.advance = 8.5;
|
||||
|
||||
cache.insert(face, 12, 16, bitmap);
|
||||
EXPECT_EQ(cache.size(), 1u);
|
||||
|
||||
auto hit = cache.get(face, 12, 16);
|
||||
ASSERT_TRUE(hit.has_value());
|
||||
EXPECT_EQ(hit->width, 10);
|
||||
EXPECT_EQ(hit->height, 12);
|
||||
EXPECT_EQ(hit->advance, 8.5);
|
||||
EXPECT_EQ(hit->pixels.size(), 120u);
|
||||
}
|
||||
|
||||
TEST(FontTest, GlyphCacheEvictionPolicy) {
|
||||
std::string fontPath = getSystemFontPath();
|
||||
if (fontPath.empty()) {
|
||||
GTEST_SKIP() << "No system font found to run cache eviction test.";
|
||||
}
|
||||
|
||||
FontFace face;
|
||||
ASSERT_TRUE(face.loadFromFile(fontPath));
|
||||
|
||||
GlyphCache cache(2);
|
||||
|
||||
GlyphBitmap bmp1{ .width = 1 };
|
||||
GlyphBitmap bmp2{ .width = 2 };
|
||||
GlyphBitmap bmp3{ .width = 3 };
|
||||
|
||||
cache.insert(face, 1, 16, bmp1);
|
||||
cache.insert(face, 2, 16, bmp2);
|
||||
EXPECT_EQ(cache.size(), 2u);
|
||||
|
||||
cache.insert(face, 3, 16, bmp3);
|
||||
EXPECT_EQ(cache.size(), 2u);
|
||||
|
||||
EXPECT_FALSE(cache.get(face, 1, 16).has_value());
|
||||
EXPECT_TRUE(cache.get(face, 2, 16).has_value());
|
||||
EXPECT_TRUE(cache.get(face, 3, 16).has_value());
|
||||
}
|
||||
|
||||
TEST(FontTest, GlyphCacheLRUPolicy) {
|
||||
std::string fontPath = getSystemFontPath();
|
||||
if (fontPath.empty()) {
|
||||
GTEST_SKIP() << "No system font found to run cache LRU test.";
|
||||
}
|
||||
|
||||
FontFace face;
|
||||
ASSERT_TRUE(face.loadFromFile(fontPath));
|
||||
|
||||
GlyphCache cache(2);
|
||||
|
||||
GlyphBitmap bmp1{ .width = 1 };
|
||||
GlyphBitmap bmp2{ .width = 2 };
|
||||
GlyphBitmap bmp3{ .width = 3 };
|
||||
|
||||
cache.insert(face, 1, 16, bmp1);
|
||||
cache.insert(face, 2, 16, bmp2);
|
||||
|
||||
auto hit = cache.get(face, 1, 16);
|
||||
ASSERT_TRUE(hit.has_value());
|
||||
|
||||
cache.insert(face, 3, 16, bmp3);
|
||||
EXPECT_EQ(cache.size(), 2u);
|
||||
|
||||
EXPECT_TRUE(cache.get(face, 1, 16).has_value());
|
||||
EXPECT_FALSE(cache.get(face, 2, 16).has_value());
|
||||
EXPECT_TRUE(cache.get(face, 3, 16).has_value());
|
||||
}
|
||||
|
||||
TEST(FontTest, FontPipelineIntegration) {
|
||||
std::string fontPath = getSystemFontPath();
|
||||
if (fontPath.empty()) {
|
||||
GTEST_SKIP() << "No system font found to run pipeline integration test.";
|
||||
}
|
||||
|
||||
FontFace face;
|
||||
ASSERT_TRUE(face.loadFromFile(fontPath));
|
||||
|
||||
HbShaper shaper;
|
||||
GlyphCache cache(100);
|
||||
|
||||
std::vector<std::string> testTexts = {"Hello World", "office", "سلام"};
|
||||
unsigned int fontSize = 16;
|
||||
|
||||
for (const auto& text : testTexts) {
|
||||
auto shapedGlyphs = shaper.shapeRun(text, face, fontSize);
|
||||
EXPECT_FALSE(shapedGlyphs.empty());
|
||||
|
||||
for (const auto& sg : shapedGlyphs) {
|
||||
auto cachedBmp = cache.get(face, sg.glyphIndex, fontSize);
|
||||
if (!cachedBmp.has_value()) {
|
||||
auto renderedOpt = face.renderGlyph(sg.glyphIndex, fontSize);
|
||||
ASSERT_TRUE(renderedOpt.has_value());
|
||||
cache.insert(face, sg.glyphIndex, fontSize, *renderedOpt);
|
||||
EXPECT_EQ(renderedOpt->pixels.size(), static_cast<size_t>(renderedOpt->width * renderedOpt->height));
|
||||
}
|
||||
|
||||
auto hitBmp = cache.get(face, sg.glyphIndex, fontSize);
|
||||
ASSERT_TRUE(hitBmp.has_value());
|
||||
EXPECT_EQ(hitBmp->pixels.size(), static_cast<size_t>(hitBmp->width * hitBmp->height));
|
||||
EXPECT_GE(hitBmp->advance, 0.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
TEST(FontTest, UnicodeAndRtlShaping) {
|
||||
std::string fontPath = getSystemFontPath();
|
||||
if (fontPath.empty()) {
|
||||
GTEST_SKIP() << "No system font found to run Unicode and RTL shaping test.";
|
||||
}
|
||||
|
||||
FontFace face;
|
||||
ASSERT_TRUE(face.loadFromFile(fontPath));
|
||||
|
||||
HbShaper shaper;
|
||||
unsigned int fontSize = 16;
|
||||
|
||||
{
|
||||
std::string arabicText = "سلام";
|
||||
auto glyphs = shaper.shapeRun(arabicText, face, fontSize);
|
||||
EXPECT_FALSE(glyphs.empty());
|
||||
for (const auto& g : glyphs) {
|
||||
EXPECT_TRUE(g.advanceX != 0.0 || g.advanceY != 0.0 || g.offsetX != 0.0 || g.offsetY != 0.0 || g.glyphIndex != 999999u);
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
std::string hindiText = "नमस्ते";
|
||||
auto glyphs = shaper.shapeRun(hindiText, face, fontSize);
|
||||
EXPECT_FALSE(glyphs.empty());
|
||||
for (const auto& g : glyphs) {
|
||||
EXPECT_TRUE(g.advanceX != 0.0 || g.advanceY != 0.0 || g.glyphIndex != 999999u);
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
std::string ligatureText = "office";
|
||||
auto glyphs = shaper.shapeRun(ligatureText, face, fontSize);
|
||||
EXPECT_FALSE(glyphs.empty());
|
||||
EXPECT_LE(glyphs.size(), ligatureText.length());
|
||||
for (const auto& g : glyphs) {
|
||||
EXPECT_TRUE(g.advanceX != 0.0 || g.advanceY != 0.0 || g.glyphIndex != 999999u);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
TEST(FontTest, CachePerformanceTest) {
|
||||
std::string fontPath = getSystemFontPath();
|
||||
if (fontPath.empty()) {
|
||||
GTEST_SKIP() << "No system font found to run cache performance test.";
|
||||
}
|
||||
|
||||
FontFace face;
|
||||
ASSERT_TRUE(face.loadFromFile(fontPath));
|
||||
|
||||
HbShaper shaper;
|
||||
GlyphCache cache(100);
|
||||
|
||||
std::string text = "Hello Hello Hello Hello";
|
||||
unsigned int fontSize = 16;
|
||||
|
||||
{
|
||||
auto glyphs = shaper.shapeRun(text, face, fontSize);
|
||||
for (const auto& g : glyphs) {
|
||||
auto cached = cache.get(face, g.glyphIndex, fontSize);
|
||||
if (!cached.has_value()) {
|
||||
auto rendered = face.renderGlyph(g.glyphIndex, fontSize);
|
||||
if (rendered.has_value()) {
|
||||
cache.insert(face, g.glyphIndex, fontSize, *rendered);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
cache.resetStats();
|
||||
|
||||
for (int i = 0; i < 1000; ++i) {
|
||||
auto glyphs = shaper.shapeRun(text, face, fontSize);
|
||||
for (const auto& g : glyphs) {
|
||||
auto cached = cache.get(face, g.glyphIndex, fontSize);
|
||||
if (!cached.has_value()) {
|
||||
auto rendered = face.renderGlyph(g.glyphIndex, fontSize);
|
||||
if (rendered.has_value()) {
|
||||
cache.insert(face, g.glyphIndex, fontSize, *rendered);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
double hitRateVal = cache.hitRate();
|
||||
std::cout << "[ INFO ] Cache Hit Rate for repetitive text: " << (hitRateVal * 100.0) << "%" << std::endl;
|
||||
EXPECT_GT(hitRateVal, 0.90);
|
||||
}
|
||||
|
||||
TEST(FontTest, EngineStressTest) {
|
||||
std::string fontPath = getSystemFontPath();
|
||||
if (fontPath.empty()) {
|
||||
GTEST_SKIP() << "No system font found to run engine stress test.";
|
||||
}
|
||||
|
||||
FontFace face;
|
||||
ASSERT_TRUE(face.loadFromFile(fontPath));
|
||||
|
||||
HbShaper shaper;
|
||||
GlyphCache cache(32);
|
||||
|
||||
std::string base = "The quick brown fox jumps over the lazy dog. 1234567890!@#$%^&*() ";
|
||||
std::string longText;
|
||||
longText.reserve(10000);
|
||||
while (longText.length() < 10000) {
|
||||
longText += base;
|
||||
}
|
||||
|
||||
unsigned int fontSize = 16;
|
||||
|
||||
auto glyphs = shaper.shapeRun(longText, face, fontSize);
|
||||
EXPECT_FALSE(glyphs.empty());
|
||||
|
||||
for (const auto& g : glyphs) {
|
||||
auto cached = cache.get(face, g.glyphIndex, fontSize);
|
||||
if (!cached.has_value()) {
|
||||
auto rendered = face.renderGlyph(g.glyphIndex, fontSize);
|
||||
if (rendered.has_value()) {
|
||||
cache.insert(face, g.glyphIndex, fontSize, *rendered);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
EXPECT_LE(cache.size(), cache.capacity());
|
||||
EXPECT_GT(cache.size(), 0u);
|
||||
}
|
||||
|
||||
TEST(FontTest, VisualBitmapDebugging) {
|
||||
std::string fontPath = getSystemFontPath();
|
||||
if (fontPath.empty()) {
|
||||
GTEST_SKIP() << "No system font found to run visual bitmap debugging test.";
|
||||
}
|
||||
|
||||
FontFace face;
|
||||
ASSERT_TRUE(face.loadFromFile(fontPath));
|
||||
|
||||
unsigned int glyphIndex = FT_Get_Char_Index(face.getFace(), 'A');
|
||||
ASSERT_GT(glyphIndex, 0u);
|
||||
|
||||
auto renderedOpt = face.renderGlyph(glyphIndex, 48);
|
||||
ASSERT_TRUE(renderedOpt.has_value());
|
||||
|
||||
std::string filename = "A.pgm";
|
||||
std::filesystem::remove(filename);
|
||||
|
||||
ASSERT_TRUE(saveGlyphAsPGM(*renderedOpt, filename));
|
||||
EXPECT_TRUE(std::filesystem::exists(filename));
|
||||
EXPECT_GT(std::filesystem::file_size(filename), 0u);
|
||||
}
|
||||
|
||||
TEST(FontTest, MetricsValidation) {
|
||||
std::string fontPath = getSystemFontPath();
|
||||
if (fontPath.empty()) {
|
||||
GTEST_SKIP() << "No system font found to run metrics validation test.";
|
||||
}
|
||||
|
||||
FontFace face;
|
||||
ASSERT_TRUE(face.loadFromFile(fontPath));
|
||||
FT_Face ftFace = face.getFace();
|
||||
ASSERT_NE(ftFace, nullptr);
|
||||
|
||||
unsigned int glyphIndex = FT_Get_Char_Index(ftFace, 'B');
|
||||
ASSERT_GT(glyphIndex, 0u);
|
||||
|
||||
unsigned int fontSize = 24;
|
||||
auto renderedOpt = face.renderGlyph(glyphIndex, fontSize);
|
||||
ASSERT_TRUE(renderedOpt.has_value());
|
||||
|
||||
ASSERT_EQ(FT_Set_Pixel_Sizes(ftFace, 0, fontSize), 0);
|
||||
ASSERT_EQ(FT_Load_Glyph(ftFace, glyphIndex, FT_LOAD_RENDER), 0);
|
||||
|
||||
FT_GlyphSlot slot = ftFace->glyph;
|
||||
EXPECT_EQ(renderedOpt->width, static_cast<int>(slot->bitmap.width));
|
||||
EXPECT_EQ(renderedOpt->height, static_cast<int>(slot->bitmap.rows));
|
||||
EXPECT_EQ(renderedOpt->bearingX, slot->bitmap_left);
|
||||
EXPECT_EQ(renderedOpt->bearingY, slot->bitmap_top);
|
||||
EXPECT_DOUBLE_EQ(renderedOpt->advance, static_cast<double>(slot->advance.x) / 64.0);
|
||||
}
|
||||
|
||||
TEST(FontTest, CacheRecencyStress) {
|
||||
std::string fontPath = getSystemFontPath();
|
||||
if (fontPath.empty()) {
|
||||
GTEST_SKIP() << "No system font found to run cache recency stress test.";
|
||||
}
|
||||
|
||||
FontFace face;
|
||||
ASSERT_TRUE(face.loadFromFile(fontPath));
|
||||
|
||||
GlyphCache cache(5);
|
||||
|
||||
std::vector<GlyphBitmap> bmps;
|
||||
for (int i = 0; i < 10; ++i) {
|
||||
GlyphBitmap b;
|
||||
b.width = i;
|
||||
bmps.push_back(b);
|
||||
}
|
||||
|
||||
for (unsigned int i = 0; i < 5; ++i) {
|
||||
cache.insert(face, i, 16, bmps[i]);
|
||||
}
|
||||
EXPECT_EQ(cache.size(), 5u);
|
||||
|
||||
ASSERT_TRUE(cache.get(face, 0, 16).has_value());
|
||||
ASSERT_TRUE(cache.get(face, 2, 16).has_value());
|
||||
|
||||
cache.insert(face, 5, 16, bmps[5]);
|
||||
EXPECT_FALSE(cache.get(face, 1, 16).has_value());
|
||||
EXPECT_TRUE(cache.get(face, 5, 16).has_value());
|
||||
|
||||
ASSERT_TRUE(cache.get(face, 3, 16).has_value());
|
||||
|
||||
cache.insert(face, 6, 16, bmps[6]);
|
||||
EXPECT_FALSE(cache.get(face, 4, 16).has_value());
|
||||
|
||||
EXPECT_TRUE(cache.get(face, 0, 16).has_value());
|
||||
EXPECT_TRUE(cache.get(face, 2, 16).has_value());
|
||||
EXPECT_TRUE(cache.get(face, 3, 16).has_value());
|
||||
EXPECT_TRUE(cache.get(face, 5, 16).has_value());
|
||||
EXPECT_TRUE(cache.get(face, 6, 16).has_value());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,277 @@
|
||||
#include "fonts_test_helpers.hpp"
|
||||
|
||||
namespace pdfengine::fonts {
|
||||
|
||||
TEST(Type1FontTest, EmbeddedType1FontLoading) {
|
||||
std::string fontPath = getSystemFontPath();
|
||||
if (fontPath.empty()) {
|
||||
GTEST_SKIP() << "No system font found to run embedded Type1 test.";
|
||||
}
|
||||
|
||||
std::ifstream file(fontPath, std::ios::binary | std::ios::ate);
|
||||
ASSERT_TRUE(file.is_open());
|
||||
std::streamsize size = file.tellg();
|
||||
file.seekg(0, std::ios::beg);
|
||||
std::vector<uint8_t> buffer(size);
|
||||
ASSERT_TRUE(file.read(reinterpret_cast<char*>(buffer.data()), size));
|
||||
|
||||
auto font = pdfengine::fonts::pdf_fonts::FontLoader::loadType1FromMemory("LegacyType1", buffer);
|
||||
ASSERT_NE(font, nullptr);
|
||||
EXPECT_EQ(font->getBaseFont(), "LegacyType1");
|
||||
EXPECT_EQ(font->getType(), pdfengine::fonts::pdf_fonts::FontType::Type1);
|
||||
EXPECT_TRUE(font->isEmbedded());
|
||||
}
|
||||
|
||||
TEST(Type1FontTest, NonEmbeddedType1SystemFallback) {
|
||||
auto fontHelv = pdfengine::fonts::pdf_fonts::FontLoader::loadType1SystemFallback("Helvetica");
|
||||
ASSERT_NE(fontHelv, nullptr);
|
||||
EXPECT_EQ(fontHelv->getBaseFont(), "Helvetica");
|
||||
EXPECT_EQ(fontHelv->getType(), pdfengine::fonts::pdf_fonts::FontType::Type1);
|
||||
EXPECT_FALSE(fontHelv->isEmbedded());
|
||||
|
||||
auto fontTimes = pdfengine::fonts::pdf_fonts::FontLoader::loadType1SystemFallback("Times-BoldItalic");
|
||||
ASSERT_NE(fontTimes, nullptr);
|
||||
EXPECT_EQ(fontTimes->getBaseFont(), "Times-BoldItalic");
|
||||
EXPECT_FALSE(fontTimes->isEmbedded());
|
||||
|
||||
auto fontCourier = pdfengine::fonts::pdf_fonts::FontLoader::loadType1SystemFallback("Courier-Oblique");
|
||||
ASSERT_NE(fontCourier, nullptr);
|
||||
EXPECT_EQ(fontCourier->getBaseFont(), "Courier-Oblique");
|
||||
EXPECT_FALSE(fontCourier->isEmbedded());
|
||||
|
||||
auto fontUnknown = pdfengine::fonts::pdf_fonts::FontLoader::loadType1SystemFallback("UnknownLegacyFont");
|
||||
ASSERT_NE(fontUnknown, nullptr);
|
||||
EXPECT_EQ(fontUnknown->getBaseFont(), "UnknownLegacyFont");
|
||||
EXPECT_FALSE(fontUnknown->isEmbedded());
|
||||
}
|
||||
|
||||
TEST(CIDFontTest, CompositeFontInitializationAndTypes) {
|
||||
using namespace pdfengine::fonts::pdf_fonts;
|
||||
|
||||
auto desc = std::make_unique<FontDescriptor>();
|
||||
desc->setFontName("SimSun-Descriptor");
|
||||
|
||||
CIDFont font0("SimSun", FontType::CIDFontType0, false, std::move(desc));
|
||||
EXPECT_EQ(font0.getBaseFont(), "SimSun");
|
||||
EXPECT_EQ(font0.getType(), FontType::CIDFontType0);
|
||||
EXPECT_FALSE(font0.isEmbedded());
|
||||
EXPECT_EQ(font0.getDescriptor()->getFontName(), "SimSun-Descriptor");
|
||||
|
||||
CIDFont font2("MS-Gothic", FontType::CIDFontType2, true);
|
||||
EXPECT_EQ(font2.getBaseFont(), "MS-Gothic");
|
||||
EXPECT_EQ(font2.getType(), FontType::CIDFontType2);
|
||||
EXPECT_TRUE(font2.isEmbedded());
|
||||
}
|
||||
|
||||
TEST(CIDFontTest, CIDToGIDTranslations) {
|
||||
using namespace pdfengine::fonts::pdf_fonts;
|
||||
|
||||
CIDFont font("SimSun", FontType::CIDFontType2, false);
|
||||
|
||||
EXPECT_TRUE(font.isIdentityMap());
|
||||
EXPECT_EQ(font.mapCIDToGID(100), 100);
|
||||
EXPECT_EQ(font.mapCIDToGID(5000), 5000);
|
||||
|
||||
std::unordered_map<uint32_t, uint32_t> customMap = {
|
||||
{10, 100},
|
||||
{20, 200},
|
||||
{30, 300}
|
||||
};
|
||||
font.setCIDToGIDMap(customMap);
|
||||
EXPECT_FALSE(font.isIdentityMap());
|
||||
EXPECT_EQ(font.mapCIDToGID(10), 100);
|
||||
EXPECT_EQ(font.mapCIDToGID(20), 200);
|
||||
EXPECT_EQ(font.mapCIDToGID(30), 300);
|
||||
EXPECT_EQ(font.mapCIDToGID(40), 0);
|
||||
|
||||
font.setIdentityCIDToGIDMap();
|
||||
EXPECT_TRUE(font.isIdentityMap());
|
||||
EXPECT_EQ(font.mapCIDToGID(10), 10);
|
||||
}
|
||||
|
||||
TEST(CIDFontTest, NonEmbeddedCIDFontSystemFallback) {
|
||||
using namespace pdfengine::fonts::pdf_fonts;
|
||||
|
||||
auto fontSimSun = FontLoader::loadCIDFontSystemFallback("SimSun", FontType::CIDFontType2);
|
||||
ASSERT_NE(fontSimSun, nullptr);
|
||||
EXPECT_EQ(fontSimSun->getBaseFont(), "SimSun");
|
||||
EXPECT_EQ(fontSimSun->getType(), FontType::CIDFontType2);
|
||||
EXPECT_FALSE(fontSimSun->isEmbedded());
|
||||
|
||||
auto fontGothic = FontLoader::loadCIDFontSystemFallback("HeiseiMin-W3", FontType::CIDFontType0);
|
||||
ASSERT_NE(fontGothic, nullptr);
|
||||
EXPECT_EQ(fontGothic->getBaseFont(), "HeiseiMin-W3");
|
||||
EXPECT_EQ(fontGothic->getType(), FontType::CIDFontType0);
|
||||
EXPECT_FALSE(fontGothic->isEmbedded());
|
||||
|
||||
HbShaper shaper;
|
||||
auto glyphs = shaper.shapeRun("日本語漢字", fontGothic->getFontFace(), 16);
|
||||
EXPECT_FALSE(glyphs.empty());
|
||||
}
|
||||
|
||||
TEST(FontFallbackTest, SingletonInstanceIsUnique) {
|
||||
using namespace pdfengine::fonts::pdf_fonts;
|
||||
|
||||
auto& instance1 = FontFallback::getInstance();
|
||||
auto& instance2 = FontFallback::getInstance();
|
||||
EXPECT_EQ(&instance1, &instance2);
|
||||
}
|
||||
|
||||
TEST(FontFallbackTest, StandardFontFallbacks) {
|
||||
using namespace pdfengine::fonts::pdf_fonts;
|
||||
|
||||
auto& fallback = FontFallback::getInstance();
|
||||
|
||||
std::string path1 = fallback.getFallbackFontPath("Helvetica");
|
||||
EXPECT_FALSE(path1.empty());
|
||||
EXPECT_TRUE(std::filesystem::exists(path1));
|
||||
#if defined(_WIN32)
|
||||
EXPECT_TRUE(containsCI(path1, "arial") || containsCI(path1, "liberationsans"));
|
||||
#elif defined(__APPLE__)
|
||||
EXPECT_TRUE(containsCI(path1, "arial") || containsCI(path1, "helvetica") ||
|
||||
containsCI(path1, "liberationsans"));
|
||||
#else
|
||||
EXPECT_TRUE(containsCI(path1, "liberationsans") || containsCI(path1, "dejavusans"));
|
||||
#endif
|
||||
|
||||
std::string path2 = fallback.getFallbackFontPath("Times-Roman");
|
||||
EXPECT_FALSE(path2.empty());
|
||||
EXPECT_TRUE(std::filesystem::exists(path2));
|
||||
#if defined(_WIN32)
|
||||
EXPECT_TRUE(containsCI(path2, "times") || containsCI(path2, "liberationserif") ||
|
||||
containsCI(path2, "tinos"));
|
||||
#elif defined(__APPLE__)
|
||||
EXPECT_TRUE(containsCI(path2, "times") || containsCI(path2, "liberationserif") ||
|
||||
containsCI(path2, "tinos"));
|
||||
#else
|
||||
EXPECT_TRUE(containsCI(path2, "liberationserif") || containsCI(path2, "dejavuserif") ||
|
||||
containsCI(path2, "tinos"));
|
||||
#endif
|
||||
}
|
||||
|
||||
TEST(FontFallbackTest, StyleModifierResolutions) {
|
||||
using namespace pdfengine::fonts::pdf_fonts;
|
||||
|
||||
auto& fallback = FontFallback::getInstance();
|
||||
|
||||
std::string pathBold = fallback.getFallbackFontPath("Helvetica", true, false);
|
||||
EXPECT_FALSE(pathBold.empty());
|
||||
EXPECT_TRUE(std::filesystem::exists(pathBold));
|
||||
#if defined(_WIN32)
|
||||
EXPECT_TRUE(containsCI(pathBold, "arialbd") || containsCI(pathBold, "liberationsans-bold"));
|
||||
#endif
|
||||
|
||||
std::string pathBoldItalic = fallback.getFallbackFontPath("Times", true, true);
|
||||
EXPECT_FALSE(pathBoldItalic.empty());
|
||||
EXPECT_TRUE(std::filesystem::exists(pathBoldItalic));
|
||||
#if defined(_WIN32)
|
||||
EXPECT_TRUE(containsCI(pathBoldItalic, "timesbi") ||
|
||||
containsCI(pathBoldItalic, "liberationserif-bolditalic") ||
|
||||
containsCI(pathBoldItalic, "tinos-bolditalic"));
|
||||
#endif
|
||||
}
|
||||
|
||||
TEST(FontFallbackTest, CustomFallbackRegistration) {
|
||||
using namespace pdfengine::fonts::pdf_fonts;
|
||||
|
||||
auto& fallback = FontFallback::getInstance();
|
||||
fallback.resetToDefaults();
|
||||
|
||||
std::string standardPath = fallback.getFallbackFontPath("Helvetica");
|
||||
|
||||
std::filesystem::path overrideFont =
|
||||
std::filesystem::temp_directory_path() / "pdfengine_custom_fallback.ttf";
|
||||
{ std::ofstream(overrideFont) << "stub-font"; }
|
||||
|
||||
fallback.registerFallback("helvetica", overrideFont.string());
|
||||
|
||||
std::string overridenPath = fallback.getFallbackFontPath("Helvetica");
|
||||
EXPECT_EQ(overridenPath, overrideFont.string());
|
||||
|
||||
fallback.resetToDefaults();
|
||||
std::string restoredPath = fallback.getFallbackFontPath("Helvetica");
|
||||
EXPECT_EQ(restoredPath, standardPath);
|
||||
|
||||
std::filesystem::remove(overrideFont);
|
||||
}
|
||||
TEST(FontSubstitutionAndWidthsTest, WidthMatchingAndSubstitutionVerification) {
|
||||
using namespace pdfengine::fonts::pdf_fonts;
|
||||
|
||||
auto font = FontLoader::loadType1SystemFallback("Helvetica");
|
||||
ASSERT_NE(font, nullptr);
|
||||
|
||||
EXPECT_FALSE(font->hasWidths());
|
||||
|
||||
std::vector<double> pdfWidths = { 600.0, 500.0, 550.0, 400.0 };
|
||||
font->setWidths(65, 68, pdfWidths);
|
||||
|
||||
EXPECT_TRUE(font->hasWidths());
|
||||
|
||||
double fontSize = 12.0;
|
||||
|
||||
double expectedWidthA = (600.0 / 1000.0) * fontSize;
|
||||
double expectedWidthB = (500.0 / 1000.0) * fontSize;
|
||||
double expectedWidthC = (550.0 / 1000.0) * fontSize;
|
||||
double expectedWidthD = (400.0 / 1000.0) * fontSize;
|
||||
|
||||
EXPECT_NEAR(font->getCharWidth(65, fontSize), expectedWidthA, 1e-5);
|
||||
EXPECT_NEAR(font->getCharWidth(66, fontSize), expectedWidthB, 1e-5);
|
||||
EXPECT_NEAR(font->getCharWidth(67, fontSize), expectedWidthC, 1e-5);
|
||||
EXPECT_NEAR(font->getCharWidth(68, fontSize), expectedWidthD, 1e-5);
|
||||
|
||||
EXPECT_EQ(font->getCharWidth(999, fontSize), 0.0);
|
||||
}
|
||||
|
||||
TEST(CIDAdvancedMappingTest, IdentityVSupport) {
|
||||
using namespace pdfengine::fonts::pdf_fonts;
|
||||
PredefinedEncoding identityV(SimpleEncodingType::Identity_V);
|
||||
EXPECT_EQ(identityV.getType(), SimpleEncodingType::Identity_V);
|
||||
EXPECT_EQ(identityV.decode(65), 65);
|
||||
EXPECT_EQ(identityV.decode(1000), 1000);
|
||||
}
|
||||
|
||||
TEST(CIDAdvancedMappingTest, VerticalMetricsResolution) {
|
||||
using namespace pdfengine::fonts::pdf_fonts;
|
||||
|
||||
auto font = FontLoader::loadType1SystemFallback("Helvetica");
|
||||
ASSERT_NE(font, nullptr);
|
||||
|
||||
double fontSize = 12.0;
|
||||
EXPECT_EQ(font->isVertical(), false);
|
||||
EXPECT_EQ(font->getCharHeight(65, fontSize), fontSize);
|
||||
|
||||
font->setVertical(true);
|
||||
EXPECT_EQ(font->isVertical(), true);
|
||||
|
||||
std::vector<double> verticalAdvances = { 1000.0, 800.0, 900.0 };
|
||||
font->setVerticalMetrics(65, 67, verticalAdvances);
|
||||
EXPECT_TRUE(font->hasVerticalMetrics());
|
||||
|
||||
EXPECT_NEAR(font->getCharHeight(65, fontSize), 12.0, 1e-5);
|
||||
EXPECT_NEAR(font->getCharHeight(66, fontSize), 9.6, 1e-5);
|
||||
EXPECT_NEAR(font->getCharHeight(67, fontSize), 10.8, 1e-5);
|
||||
EXPECT_NEAR(font->getCharHeight(999, fontSize), 12.0, 1e-5);
|
||||
}
|
||||
|
||||
TEST(CIDAdvancedMappingTest, CjkCollectionResolutionDB) {
|
||||
using namespace pdfengine::fonts::pdf_fonts;
|
||||
|
||||
EXPECT_EQ(CjkCollectionDB::resolveCID("Adobe-Japan1", 1010), 0x3041);
|
||||
EXPECT_EQ(CjkCollectionDB::resolveCID("Adobe-Japan1", 1092), 0x3093);
|
||||
|
||||
EXPECT_EQ(CjkCollectionDB::resolveCID("Adobe-Japan1", 1125), 0x30A1);
|
||||
EXPECT_EQ(CjkCollectionDB::resolveCID("Adobe-Japan1", 1205), 0x30F6);
|
||||
|
||||
EXPECT_EQ(CjkCollectionDB::resolveCID("Adobe-Japan1", 1206), 0x4E00);
|
||||
|
||||
EXPECT_EQ(CjkCollectionDB::resolveCID("Adobe-GB1", 1), 0x3000);
|
||||
EXPECT_EQ(CjkCollectionDB::resolveCID("Adobe-GB1", 2), 0x3001);
|
||||
}
|
||||
|
||||
TEST(CIDAdvancedMappingTest, HarfBuzzVerticalShapingSignature) {
|
||||
using namespace pdfengine::fonts;
|
||||
|
||||
EXPECT_EQ(static_cast<int>(HbShaper::WritingMode::Horizontal), 0);
|
||||
EXPECT_EQ(static_cast<int>(HbShaper::WritingMode::Vertical), 1);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,80 @@
|
||||
#pragma once
|
||||
|
||||
#include "fonts/face/font_face.hpp"
|
||||
#include "fonts/shaping/hb_shaper.hpp"
|
||||
#include "fonts/cache/glyph_cache.hpp"
|
||||
#include "fonts/pdf_fonts/font.hpp"
|
||||
#include "fonts/pdf_fonts/types/truetype_font.hpp"
|
||||
#include "fonts/pdf_fonts/types/type1_font.hpp"
|
||||
#include "fonts/pdf_fonts/types/cid_font.hpp"
|
||||
#include "fonts/pdf_fonts/font_loader.hpp"
|
||||
#include "fonts/pdf_fonts/encoding/encoding.hpp"
|
||||
#include "fonts/pdf_fonts/encoding/cjk_collection_db.hpp"
|
||||
#include "fonts/pdf_fonts/font_fallback.hpp"
|
||||
#include "fonts/pdf_fonts/font_subset.hpp"
|
||||
|
||||
#include <gtest/gtest.h>
|
||||
#include <algorithm>
|
||||
#include <cctype>
|
||||
#include <filesystem>
|
||||
#include <fstream>
|
||||
#include <iostream>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace {
|
||||
|
||||
bool containsCI(const std::string& haystack, const std::string& needle) {
|
||||
auto it = std::search(
|
||||
haystack.begin(), haystack.end(), needle.begin(), needle.end(),
|
||||
[](char a, char b) {
|
||||
return std::tolower(static_cast<unsigned char>(a)) ==
|
||||
std::tolower(static_cast<unsigned char>(b));
|
||||
});
|
||||
return it != haystack.end();
|
||||
}
|
||||
|
||||
bool saveGlyphAsPGM(const pdfengine::fonts::GlyphBitmap& bitmap, const std::string& filename) {
|
||||
if (bitmap.width == 0 || bitmap.height == 0 || bitmap.pixels.empty()) {
|
||||
return false;
|
||||
}
|
||||
std::ofstream out(filename, std::ios::binary);
|
||||
if (!out) {
|
||||
return false;
|
||||
}
|
||||
out << "P5\n" << bitmap.width << " " << bitmap.height << "\n255\n";
|
||||
out.write(reinterpret_cast<const char*>(bitmap.pixels.data()), bitmap.pixels.size());
|
||||
return true;
|
||||
}
|
||||
|
||||
std::string getSystemFontPath() {
|
||||
#if defined(_WIN32)
|
||||
std::vector<std::string> paths = {
|
||||
"C:\\Windows\\Fonts\\arial.ttf",
|
||||
"C:\\Windows\\Fonts\\consola.ttf",
|
||||
"C:\\Windows\\Fonts\\tahoma.ttf"
|
||||
};
|
||||
#elif defined(__APPLE__)
|
||||
std::vector<std::string> paths = {
|
||||
"/Library/Fonts/Arial.ttf",
|
||||
"/System/Library/Fonts/Geneva.ttf",
|
||||
"/System/Library/Fonts/Helvetica.ttc",
|
||||
"/System/Library/Fonts/Supplemental/Arial.ttf"
|
||||
};
|
||||
#else
|
||||
std::vector<std::string> paths = {
|
||||
"/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
|
||||
"/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf",
|
||||
"/usr/share/fonts/truetype/freefont/FreeSans.ttf"
|
||||
};
|
||||
#endif
|
||||
|
||||
for (const auto& path : paths) {
|
||||
if (std::filesystem::exists(path)) {
|
||||
return path;
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
#include "document_test_helpers.hpp"
|
||||
|
||||
namespace pdfengine {
|
||||
|
||||
TEST(PageRenderTest, RenderAtDifferentDPI) {
|
||||
SKIP_IF_NO_PDFIUM();
|
||||
auto path = getCorpusPath("basic", "hello_world.pdf");
|
||||
if (!std::filesystem::exists(path)) {
|
||||
GTEST_SKIP() << "hello_world.pdf not found in corpus.";
|
||||
}
|
||||
|
||||
auto docRes = PdfDocument::loadFromFile(path.string());
|
||||
ASSERT_TRUE(docRes.has_value());
|
||||
auto pageRes = (*docRes)->getPage(0);
|
||||
ASSERT_TRUE(pageRes.has_value());
|
||||
|
||||
auto page = *pageRes;
|
||||
double ptWidth = page->width();
|
||||
double ptHeight = page->height();
|
||||
EXPECT_GT(ptWidth, 0.0);
|
||||
EXPECT_GT(ptHeight, 0.0);
|
||||
|
||||
std::vector<int> dpis = {72, 144, 288};
|
||||
for (int dpi : dpis) {
|
||||
auto imgRes = page->render(dpi);
|
||||
ASSERT_TRUE(imgRes.has_value()) << "Failed to render at DPI " << dpi;
|
||||
|
||||
double scale = dpi / 72.0;
|
||||
int expectedW = static_cast<int>(ptWidth * scale);
|
||||
int expectedH = static_cast<int>(ptHeight * scale);
|
||||
|
||||
EXPECT_EQ(imgRes->width, expectedW);
|
||||
EXPECT_EQ(imgRes->height, expectedH);
|
||||
|
||||
ASSERT_GE(imgRes->data.size(), 8U);
|
||||
EXPECT_EQ(imgRes->data[0], 0x89);
|
||||
EXPECT_EQ(imgRes->data[1], 'P');
|
||||
EXPECT_EQ(imgRes->data[2], 'N');
|
||||
EXPECT_EQ(imgRes->data[3], 'G');
|
||||
EXPECT_EQ(imgRes->data[4], 0x0D);
|
||||
EXPECT_EQ(imgRes->data[5], 0x0A);
|
||||
EXPECT_EQ(imgRes->data[6], 0x1A);
|
||||
EXPECT_EQ(imgRes->data[7], 0x0A);
|
||||
}
|
||||
}
|
||||
|
||||
TEST(PageRenderTest, InvalidPageIndexReturnsPageOutOfBounds) {
|
||||
SKIP_IF_NO_PDFIUM();
|
||||
auto path = getCorpusPath("basic", "hello_world.pdf");
|
||||
if (!std::filesystem::exists(path)) {
|
||||
GTEST_SKIP() << "hello_world.pdf not found in corpus.";
|
||||
}
|
||||
|
||||
auto docRes = PdfDocument::loadFromFile(path.string());
|
||||
ASSERT_TRUE(docRes.has_value());
|
||||
|
||||
auto pageRes = (*docRes)->getPage(1);
|
||||
ASSERT_FALSE(pageRes.has_value());
|
||||
EXPECT_EQ(pageRes.error(), EngineError::PageOutOfBounds);
|
||||
|
||||
auto pageResNeg = (*docRes)->getPage(-1);
|
||||
ASSERT_FALSE(pageResNeg.has_value());
|
||||
EXPECT_EQ(pageResNeg.error(), EngineError::PageOutOfBounds);
|
||||
}
|
||||
|
||||
TEST(CoordinateTransformTest, PageToDeviceAndBackRoundtrip) {
|
||||
SKIP_IF_NO_PDFIUM();
|
||||
auto path = getCorpusPath("basic", "hello_world.pdf");
|
||||
if (!std::filesystem::exists(path)) {
|
||||
GTEST_SKIP() << "hello_world.pdf not found in corpus.";
|
||||
}
|
||||
|
||||
auto docRes = PdfDocument::loadFromFile(path.string());
|
||||
ASSERT_TRUE(docRes.has_value());
|
||||
auto pageRes = (*docRes)->getPage(0);
|
||||
ASSERT_TRUE(pageRes.has_value());
|
||||
|
||||
auto page = *pageRes;
|
||||
|
||||
int deviceW = 1024;
|
||||
int deviceH = 768;
|
||||
std::vector<int> rotations = {0, 90, 180, 270};
|
||||
|
||||
std::vector<Point2D> testPoints = {
|
||||
{0.0, 0.0},
|
||||
{50.0, 50.0},
|
||||
{100.0, 200.0},
|
||||
{page->width() / 2.0, page->height() / 2.0},
|
||||
{page->width() - 10.0, page->height() - 10.0}
|
||||
};
|
||||
|
||||
for (int rotation : rotations) {
|
||||
for (const auto& pt : testPoints) {
|
||||
auto devPt = page->pageToDevice(pt, deviceW, deviceH, rotation);
|
||||
auto pagePt = page->deviceToPage(devPt, deviceW, deviceH, rotation);
|
||||
|
||||
EXPECT_NEAR(pt.x, pagePt.x, 2.0) << "Failed roundtrip for x at rotation " << rotation;
|
||||
EXPECT_NEAR(pt.y, pagePt.y, 2.0) << "Failed roundtrip for y at rotation " << rotation;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
TEST(TextExtractionTest, ExtractSimpleText) {
|
||||
SKIP_IF_NO_PDFIUM();
|
||||
auto path = getCorpusPath("basic", "hello_world.pdf");
|
||||
if (!std::filesystem::exists(path)) {
|
||||
GTEST_SKIP() << "hello_world.pdf not found in corpus.";
|
||||
}
|
||||
|
||||
auto docRes = PdfDocument::loadFromFile(path.string());
|
||||
ASSERT_TRUE(docRes.has_value());
|
||||
auto pageRes = (*docRes)->getPage(0);
|
||||
ASSERT_TRUE(pageRes.has_value());
|
||||
|
||||
auto textRes = (*pageRes)->extractText();
|
||||
ASSERT_TRUE(textRes.has_value());
|
||||
|
||||
std::string text = *textRes;
|
||||
EXPECT_NE(text.find("Hello"), std::string::npos);
|
||||
EXPECT_NE(text.find("world"), std::string::npos);
|
||||
}
|
||||
|
||||
TEST(TextExtractionTest, ExtractUtf8ExtendedText) {
|
||||
SKIP_IF_NO_PDFIUM();
|
||||
auto path = getCorpusPath("fonts", "latin_extended.pdf");
|
||||
if (!std::filesystem::exists(path)) {
|
||||
GTEST_SKIP() << "latin_extended.pdf not found in corpus.";
|
||||
}
|
||||
|
||||
auto docRes = PdfDocument::loadFromFile(path.string());
|
||||
ASSERT_TRUE(docRes.has_value());
|
||||
auto pageRes = (*docRes)->getPage(0);
|
||||
ASSERT_TRUE(pageRes.has_value());
|
||||
|
||||
auto textRes = (*pageRes)->extractText();
|
||||
ASSERT_TRUE(textRes.has_value());
|
||||
|
||||
std::string text = *textRes;
|
||||
EXPECT_FALSE(text.empty());
|
||||
}
|
||||
|
||||
TEST(TextExtractionTest, ExtractTextWithBounds) {
|
||||
SKIP_IF_NO_PDFIUM();
|
||||
auto path = getCorpusPath("basic", "hello_world.pdf");
|
||||
if (!std::filesystem::exists(path)) {
|
||||
GTEST_SKIP() << "hello_world.pdf not found in corpus.";
|
||||
}
|
||||
|
||||
auto docRes = PdfDocument::loadFromFile(path.string());
|
||||
ASSERT_TRUE(docRes.has_value());
|
||||
auto pageRes = (*docRes)->getPage(0);
|
||||
ASSERT_TRUE(pageRes.has_value());
|
||||
|
||||
auto boundsRes = (*pageRes)->extractTextWithBounds();
|
||||
ASSERT_TRUE(boundsRes.has_value());
|
||||
|
||||
const auto& glyphs = *boundsRes;
|
||||
ASSERT_FALSE(glyphs.empty());
|
||||
|
||||
bool found_hello = false;
|
||||
for (const auto& glyph : glyphs) {
|
||||
EXPECT_FALSE(glyph.text.empty());
|
||||
if (glyph.text != " " && glyph.text != "\r" && glyph.text != "\n" && glyph.text != "\t") {
|
||||
EXPECT_GT(glyph.w, 0.0);
|
||||
EXPECT_GT(glyph.h, 0.0);
|
||||
}
|
||||
EXPECT_GT(glyph.fontSize, 0.0);
|
||||
if (glyph.text == "H" || glyph.text == "e" || glyph.text == "l" || glyph.text == "o") {
|
||||
found_hello = true;
|
||||
}
|
||||
}
|
||||
EXPECT_TRUE(found_hello);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
#include "document_test_helpers.hpp"
|
||||
|
||||
namespace pdfengine {
|
||||
|
||||
TEST(UtfConversionTest, EmojiSurrogatePairs) {
|
||||
std::string utf8_grinning = "\xF0\x9F\x98\x80";
|
||||
auto utf16 = pdfengine::parser::utf8_to_utf16le(utf8_grinning);
|
||||
ASSERT_EQ(utf16.size(), 3);
|
||||
EXPECT_EQ(utf16[0], 0xD83D);
|
||||
EXPECT_EQ(utf16[1], 0xDE00);
|
||||
EXPECT_EQ(utf16[2], 0x0000);
|
||||
|
||||
std::string utf8_out = pdfengine::parser::utf16le_to_utf8(reinterpret_cast<const char16_t*>(utf16.data()), utf16.size() - 1);
|
||||
EXPECT_EQ(utf8_out, utf8_grinning);
|
||||
|
||||
std::string utf8_rocket = "\xF0\x9F\x9A\x80";
|
||||
utf16 = pdfengine::parser::utf8_to_utf16le(utf8_rocket);
|
||||
ASSERT_EQ(utf16.size(), 3);
|
||||
EXPECT_EQ(utf16[0], 0xD83D);
|
||||
EXPECT_EQ(utf16[1], 0xDE80);
|
||||
EXPECT_EQ(utf16[2], 0x0000);
|
||||
|
||||
utf8_out = pdfengine::parser::utf16le_to_utf8(reinterpret_cast<const char16_t*>(utf16.data()), utf16.size() - 1);
|
||||
EXPECT_EQ(utf8_out, utf8_rocket);
|
||||
}
|
||||
|
||||
TEST(UtfConversionTest, CJKExtensionB) {
|
||||
std::string utf8_cjk = "\xF0\xA0\x80\x80";
|
||||
auto utf16 = pdfengine::parser::utf8_to_utf16le(utf8_cjk);
|
||||
ASSERT_EQ(utf16.size(), 3);
|
||||
EXPECT_EQ(utf16[0], 0xD840);
|
||||
EXPECT_EQ(utf16[1], 0xDC00);
|
||||
EXPECT_EQ(utf16[2], 0x0000);
|
||||
|
||||
std::string utf8_out = pdfengine::parser::utf16le_to_utf8(reinterpret_cast<const char16_t*>(utf16.data()), utf16.size() - 1);
|
||||
EXPECT_EQ(utf8_out, utf8_cjk);
|
||||
}
|
||||
|
||||
TEST(UtfConversionTest, RoundtripMixed) {
|
||||
std::string mixed = "A\xF0\x9F\x98\x80""B\xF0\xA0\x80\x80""C";
|
||||
auto utf16 = pdfengine::parser::utf8_to_utf16le(mixed);
|
||||
std::string mixed_out = pdfengine::parser::utf16le_to_utf8(reinterpret_cast<const char16_t*>(utf16.data()), utf16.size() - 1);
|
||||
EXPECT_EQ(mixed_out, mixed);
|
||||
}
|
||||
|
||||
|
||||
TEST(CjkResolutionTest, AdobeCNS1) {
|
||||
using pdfengine::fonts::pdf_fonts::CjkCollectionDB;
|
||||
|
||||
EXPECT_EQ(CjkCollectionDB::resolveCID("Adobe-CNS1", 100), 0x4E00);
|
||||
EXPECT_EQ(CjkCollectionDB::resolveCID("Adobe-CNS1", 112), 0x4E2D);
|
||||
EXPECT_EQ(CjkCollectionDB::resolveCID("Adobe-CNS1", 130), 0x4ED7);
|
||||
|
||||
EXPECT_EQ(CjkCollectionDB::resolveCID("Adobe-CNS1", 131), 0x4ED8);
|
||||
EXPECT_EQ(CjkCollectionDB::resolveCID("Adobe-CNS1", 135), 0x4EDF);
|
||||
EXPECT_EQ(CjkCollectionDB::resolveCID("Adobe-CNS1", 140), 0x4F01);
|
||||
|
||||
EXPECT_EQ(CjkCollectionDB::resolveCID("Identity-H-CNS1", 137), 0x4EE3);
|
||||
}
|
||||
|
||||
TEST(CjkResolutionTest, AdobeKorea1) {
|
||||
using pdfengine::fonts::pdf_fonts::CjkCollectionDB;
|
||||
|
||||
EXPECT_EQ(CjkCollectionDB::resolveCID("Adobe-Korea1", 101), 0xAC00);
|
||||
EXPECT_EQ(CjkCollectionDB::resolveCID("Adobe-Korea1", 119), 0xAC1C);
|
||||
EXPECT_EQ(CjkCollectionDB::resolveCID("Adobe-Korea1", 150), 0xAC90);
|
||||
|
||||
EXPECT_EQ(CjkCollectionDB::resolveCID("Adobe-Korea1", 151), 0xAC94);
|
||||
EXPECT_EQ(CjkCollectionDB::resolveCID("Adobe-Korea1", 156), 0xACA9);
|
||||
EXPECT_EQ(CjkCollectionDB::resolveCID("Adobe-Korea1", 160), 0xACBD);
|
||||
|
||||
EXPECT_EQ(CjkCollectionDB::resolveCID("UniKS-UTF16-H-Korea1", 153), 0xACA0);
|
||||
}
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
Binary file not shown.
@@ -18,7 +18,7 @@ function getModule(): Promise<PdfiumModule | null> {
|
||||
if (!modulePromise) {
|
||||
modulePromise = (async () => {
|
||||
try {
|
||||
const V = '20260625-tier2b';
|
||||
const V = '20260626-auxfont';
|
||||
const resp = await fetch(`/pdfium-engine.mjs?v=${V}`, { cache: 'no-store' });
|
||||
if (!resp.ok) throw new Error(`pdfium-engine.mjs ${resp.status}`);
|
||||
const blobUrl = URL.createObjectURL(new Blob([await resp.text()], { type: 'text/javascript' }));
|
||||
|
||||
@@ -1,900 +0,0 @@
|
||||
import hashlib
|
||||
|
||||
from fastapi import APIRouter, File, HTTPException, Response, UploadFile, status
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.services import engine
|
||||
from app.services.store import document_store
|
||||
|
||||
router = APIRouter(prefix="/documents", tags=["documents"])
|
||||
|
||||
|
||||
class PageInfoResponse(BaseModel):
|
||||
index: int
|
||||
width: float
|
||||
height: float
|
||||
|
||||
|
||||
class PermissionsResponse(BaseModel):
|
||||
isEncrypted: bool = False
|
||||
encryption: str = "None"
|
||||
securityRevision: int = -1
|
||||
ownerUnlocked: bool = False
|
||||
canPrint: bool = True
|
||||
canPrintHighRes: bool = True
|
||||
canModify: bool = True
|
||||
canCopy: bool = True
|
||||
canAnnotate: bool = True
|
||||
canFillForms: bool = True
|
||||
canExtractForAccessibility: bool = True
|
||||
canAssemble: bool = True
|
||||
|
||||
|
||||
class DocumentInfoResponse(BaseModel):
|
||||
id: str
|
||||
filename: str
|
||||
sizeBytes: int
|
||||
totalPages: int
|
||||
pageWidth: float
|
||||
pageHeight: float
|
||||
uploadedAt: str
|
||||
status: str
|
||||
pages: list[PageInfoResponse] = []
|
||||
permissions: PermissionsResponse = PermissionsResponse()
|
||||
|
||||
|
||||
def make_document_response(d: dict) -> DocumentInfoResponse:
|
||||
pages_list = []
|
||||
if "doc_instance" in d:
|
||||
doc = d["doc_instance"]
|
||||
for i in range(doc.page_count):
|
||||
try:
|
||||
page = doc.get_page(i)
|
||||
pages_list.append(PageInfoResponse(index=i, width=page.width, height=page.height))
|
||||
except Exception:
|
||||
pass
|
||||
perms = d.get("permissions")
|
||||
return DocumentInfoResponse(
|
||||
id=d["id"],
|
||||
filename=d["filename"],
|
||||
sizeBytes=d["sizeBytes"],
|
||||
totalPages=d["totalPages"],
|
||||
pageWidth=d.get("pageWidth", 612.0),
|
||||
pageHeight=d.get("pageHeight", 792.0),
|
||||
uploadedAt=d["uploadedAt"],
|
||||
status=d["status"],
|
||||
pages=pages_list,
|
||||
permissions=PermissionsResponse(**perms) if perms else PermissionsResponse(),
|
||||
)
|
||||
|
||||
|
||||
@router.post("", response_model=DocumentInfoResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def upload_document(file: UploadFile = File(...), password: str = "") -> DocumentInfoResponse:
|
||||
if not engine.is_available():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_501_NOT_IMPLEMENTED,
|
||||
detail="Engine bridge (bindings/python) not yet available.",
|
||||
)
|
||||
|
||||
bytes_data = await file.read()
|
||||
try:
|
||||
pdfengine = engine.require()
|
||||
doc = pdfengine.PdfDocument.load_from_memory(bytes_data, password)
|
||||
info = document_store.add_document(file.filename, bytes_data, doc)
|
||||
return make_document_response(info)
|
||||
except ValueError as e:
|
||||
detail = str(e)
|
||||
if "Password required" in detail:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED, detail="Password required"
|
||||
)
|
||||
elif "Invalid password" in detail:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid password")
|
||||
else:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=detail)
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST, detail=f"Failed to load PDF: {e!s}"
|
||||
)
|
||||
|
||||
|
||||
@router.get("", response_model=list[DocumentInfoResponse])
|
||||
def list_documents() -> list[DocumentInfoResponse]:
|
||||
if not engine.is_available():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_501_NOT_IMPLEMENTED,
|
||||
detail="Engine bridge (bindings/python) not yet available.",
|
||||
)
|
||||
|
||||
docs = document_store.list_documents()
|
||||
return [make_document_response(d) for d in docs]
|
||||
|
||||
|
||||
@router.get("/{document_id}", response_model=DocumentInfoResponse)
|
||||
def get_document(document_id: str) -> DocumentInfoResponse:
|
||||
if not engine.is_available():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_501_NOT_IMPLEMENTED,
|
||||
detail="Engine bridge (bindings/python) not yet available.",
|
||||
)
|
||||
|
||||
d = document_store.get_document(document_id)
|
||||
if not d:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Document not found")
|
||||
|
||||
return make_document_response(d)
|
||||
|
||||
|
||||
@router.delete("/{document_id}")
|
||||
def delete_document(document_id: str):
|
||||
if not engine.is_available():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_501_NOT_IMPLEMENTED,
|
||||
detail="Engine bridge (bindings/python) not yet available.",
|
||||
)
|
||||
|
||||
deleted = document_store.delete_document(document_id)
|
||||
if not deleted:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Document not found")
|
||||
|
||||
return {"success": True}
|
||||
|
||||
|
||||
class DocumentMetadataResponse(BaseModel):
|
||||
title: str
|
||||
author: str
|
||||
creator: str
|
||||
producer: str
|
||||
creation_date: str
|
||||
modification_date: str
|
||||
|
||||
|
||||
@router.get("/{document_id}/metadata", response_model=DocumentMetadataResponse)
|
||||
def get_document_metadata(document_id: str) -> DocumentMetadataResponse:
|
||||
if not engine.is_available():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_501_NOT_IMPLEMENTED,
|
||||
detail="Engine bridge (bindings/python) not yet available.",
|
||||
)
|
||||
|
||||
d = document_store.get_document(document_id)
|
||||
if not d:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Document not found")
|
||||
|
||||
try:
|
||||
doc = d["doc_instance"]
|
||||
meta = doc.metadata
|
||||
return DocumentMetadataResponse(
|
||||
title=meta.title,
|
||||
author=meta.author,
|
||||
creator=meta.creator,
|
||||
producer=meta.producer,
|
||||
creation_date=meta.creation_date,
|
||||
modification_date=meta.modification_date,
|
||||
)
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(e))
|
||||
|
||||
|
||||
class FontInfoResponse(BaseModel):
|
||||
fontName: str
|
||||
type: str
|
||||
isEmbedded: bool
|
||||
isSubset: bool
|
||||
isVertical: bool
|
||||
encoding: str
|
||||
hasToUnicode: bool
|
||||
cmapName: str
|
||||
cidSystemInfo: str
|
||||
subsetTag: str
|
||||
sourceType: str
|
||||
substitutedFrom: str
|
||||
substitutedTo: str
|
||||
normalizedFamily: str
|
||||
internalFontId: str
|
||||
flags: int
|
||||
ascent: float
|
||||
descent: float
|
||||
capHeight: float
|
||||
|
||||
|
||||
@router.get("/{document_id}/fonts", response_model=list[FontInfoResponse])
|
||||
def get_document_fonts(
|
||||
document_id: str, start_page: int = 0, end_page: int = -1
|
||||
) -> list[FontInfoResponse]:
|
||||
if not engine.is_available():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_501_NOT_IMPLEMENTED,
|
||||
detail="Engine bridge (bindings/python) not yet available.",
|
||||
)
|
||||
|
||||
doc_info = document_store.get_document(document_id)
|
||||
if not doc_info:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Document not found")
|
||||
|
||||
try:
|
||||
doc = doc_info["doc_instance"]
|
||||
fonts = doc.get_fonts(start_page, end_page)
|
||||
return [
|
||||
FontInfoResponse(
|
||||
fontName=f.font_name,
|
||||
type=f.type,
|
||||
isEmbedded=f.is_embedded,
|
||||
isSubset=f.is_subset,
|
||||
isVertical=f.is_vertical,
|
||||
encoding=f.encoding,
|
||||
hasToUnicode=f.has_to_unicode,
|
||||
cmapName=f.cmap_name,
|
||||
cidSystemInfo=f.cid_system_info,
|
||||
subsetTag=f.subset_tag,
|
||||
sourceType=f.source_type,
|
||||
substitutedFrom=f.substituted_from,
|
||||
substitutedTo=f.substituted_to,
|
||||
normalizedFamily=f.normalized_family,
|
||||
internalFontId=f.internal_font_id,
|
||||
flags=f.flags,
|
||||
ascent=f.ascent,
|
||||
descent=f.descent,
|
||||
capHeight=f.cap_height,
|
||||
)
|
||||
for f in fonts
|
||||
]
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
|
||||
except IndexError as e:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
|
||||
|
||||
|
||||
_SFNT_TTF_MAGIC = (b"\x00\x01\x00\x00", b"true", b"ttcf")
|
||||
_SFNT_OTF_MAGIC = b"OTTO"
|
||||
|
||||
|
||||
@router.get("/{document_id}/font")
|
||||
def get_font_bytes(document_id: str, internal_font_id: str) -> Response:
|
||||
"""Raw embedded font bytes for an in-place-editing preview.
|
||||
|
||||
Returns the font only when it's a browser-loadable sfnt (TrueType / OpenType-CFF).
|
||||
Type1/PFB, non-embedded, and unknown fonts return 404 so the frontend falls back to
|
||||
a base-14 CSS font. The lookup is sandboxed to fonts inside the loaded document.
|
||||
"""
|
||||
if not engine.is_available():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_501_NOT_IMPLEMENTED,
|
||||
detail="Engine bridge (bindings/python) not yet available.",
|
||||
)
|
||||
|
||||
if not internal_font_id or len(internal_font_id) > 256:
|
||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
doc_info = document_store.get_document(document_id)
|
||||
if not doc_info:
|
||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
try:
|
||||
data = bytes(doc_info["doc_instance"].get_font_data(internal_font_id))
|
||||
except Exception:
|
||||
data = b""
|
||||
if not data:
|
||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
magic = data[:4]
|
||||
if magic in _SFNT_TTF_MAGIC:
|
||||
media_type = "font/ttf"
|
||||
elif magic == _SFNT_OTF_MAGIC:
|
||||
media_type = "font/otf"
|
||||
else:
|
||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
etag = '"' + hashlib.sha256(data).hexdigest()[:32] + '"'
|
||||
return Response(
|
||||
content=data,
|
||||
media_type=media_type,
|
||||
headers={"Cache-Control": "public, max-age=31536000, immutable", "ETag": etag},
|
||||
)
|
||||
|
||||
@router.get("/{document_id}/font-reconstructed")
|
||||
def get_reconstructed_font_bytes(document_id: str, internal_font_id: str) -> Response:
|
||||
"""Tier-2: a cmap-augmented copy of an embedded font (original glyph program + synthesized
|
||||
Unicode cmap) so the WASM live preview can reuse the document's real glyphs and match the
|
||||
saved result. 204 when reconstruction isn't possible -> frontend falls back to Tier-1."""
|
||||
if not engine.is_available():
|
||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||
if not internal_font_id or len(internal_font_id) > 256:
|
||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||
doc_info = document_store.get_document(document_id)
|
||||
if not doc_info:
|
||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||
try:
|
||||
data = bytes(doc_info["doc_instance"].get_reconstructed_font_data(internal_font_id))
|
||||
except Exception:
|
||||
data = b""
|
||||
if not data or data[:4] not in _SFNT_TTF_MAGIC:
|
||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||
etag = '"' + hashlib.sha256(data).hexdigest()[:32] + '"'
|
||||
return Response(
|
||||
content=data,
|
||||
media_type="font/ttf",
|
||||
headers={"Cache-Control": "public, max-age=31536000, immutable", "ETag": etag},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{document_id}/raw")
|
||||
def get_document_raw(document_id: str) -> Response:
|
||||
"""Raw PDF bytes of the current version — loaded into the in-browser WASM engine for the
|
||||
pixel-identical live-edit preview. Gated on copy permission (same as export)."""
|
||||
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")
|
||||
perms = doc_info.get("permissions") or {}
|
||||
if perms.get("canCopy", True) is False:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Not permitted (canCopy).")
|
||||
data = doc_info.get("bytes_data")
|
||||
if not data:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Document bytes unavailable")
|
||||
return Response(content=bytes(data), media_type="application/pdf")
|
||||
|
||||
|
||||
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,
|
||||
case_sensitive: bool = False,
|
||||
whole_words: bool = False,
|
||||
) -> 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")
|
||||
|
||||
def _is_word_char(ch: str) -> bool:
|
||||
return ch.isalnum() or ch == "_"
|
||||
|
||||
def _find_all(haystack: str, needle: str) -> list[int]:
|
||||
"""Return start indices of all non-overlapping occurrences of needle in haystack."""
|
||||
results: list[int] = []
|
||||
start = 0
|
||||
needle_len = len(needle)
|
||||
while True:
|
||||
pos = haystack.find(needle, start)
|
||||
if pos == -1:
|
||||
break
|
||||
if whole_words:
|
||||
before_ok = pos == 0 or not _is_word_char(haystack[pos - 1])
|
||||
after_ok = (pos + needle_len) >= len(haystack) or not _is_word_char(
|
||||
haystack[pos + needle_len]
|
||||
)
|
||||
if before_ok and after_ok:
|
||||
results.append(pos)
|
||||
else:
|
||||
results.append(pos)
|
||||
start = pos + 1
|
||||
return results
|
||||
|
||||
try:
|
||||
doc = doc_info["doc_instance"]
|
||||
matches = []
|
||||
search_needle = q if case_sensitive else q.lower()
|
||||
query_len = len(search_needle)
|
||||
|
||||
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: list[int] = []
|
||||
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)
|
||||
|
||||
search_text = text_str if case_sensitive else text_str.lower()
|
||||
|
||||
for idx in _find_all(search_text, search_needle):
|
||||
if idx + query_len - 1 >= len(char_to_glyph):
|
||||
continue
|
||||
|
||||
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]
|
||||
)
|
||||
)
|
||||
|
||||
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
|
||||
page_object_index: int = -1
|
||||
|
||||
|
||||
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
|
||||
object_indices: list[int] = []
|
||||
color: str = "#000000"
|
||||
font_fidelity: str = "exact"
|
||||
|
||||
|
||||
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 line in p.lines:
|
||||
runs = []
|
||||
for r in line.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,
|
||||
page_object_index=g.page_object_index,
|
||||
)
|
||||
)
|
||||
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,
|
||||
object_indices=r.object_indices,
|
||||
color=getattr(r, "fill_color", "#000000") or "#000000",
|
||||
font_fidelity=getattr(r, "font_fidelity", "exact") or "exact",
|
||||
)
|
||||
)
|
||||
lines.append(
|
||||
TextLineModel(
|
||||
runs=runs,
|
||||
baseline_y=line.baseline_y,
|
||||
x=line.x,
|
||||
y=line.y,
|
||||
w=line.w,
|
||||
h=line.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))
|
||||
|
||||
class AnnotationResponse(BaseModel):
|
||||
id: str
|
||||
type: str
|
||||
x: float
|
||||
y: float
|
||||
width: float
|
||||
height: float
|
||||
color: str
|
||||
author: str
|
||||
content: str
|
||||
timestamp: str | None = None
|
||||
pageIndex: int
|
||||
paths: list[list[dict[str, float]]] = []
|
||||
|
||||
fieldName: str | None = None
|
||||
fieldValue: str | None = None
|
||||
fieldType: str | None = None
|
||||
fieldFlags: int | None = None
|
||||
fieldOptions: list[str] | None = None
|
||||
|
||||
@router.get("/{document_id}/annotations", response_model=list[AnnotationResponse])
|
||||
def get_document_annotations(document_id: str) -> list[AnnotationResponse]:
|
||||
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"]
|
||||
all_annots = []
|
||||
for i in range(doc.page_count):
|
||||
try:
|
||||
page = doc.get_page(i)
|
||||
annots = page.extract_annotations()
|
||||
for a in annots:
|
||||
all_annots.append(AnnotationResponse(
|
||||
id=a.id,
|
||||
type=a.type,
|
||||
x=a.x,
|
||||
y=a.y,
|
||||
width=a.width,
|
||||
height=a.height,
|
||||
color=a.color,
|
||||
author=a.author,
|
||||
content=a.content,
|
||||
timestamp=getattr(a, "timestamp", None),
|
||||
pageIndex=a.page_index,
|
||||
paths=[[{"x": p.x, "y": p.y} for p in stroke] for stroke in getattr(a, "paths", [])],
|
||||
fieldName=getattr(a, "field_name", None),
|
||||
fieldValue=getattr(a, "field_value", None),
|
||||
fieldType=getattr(a, "field_type", None),
|
||||
fieldFlags=getattr(a, "field_flags", None),
|
||||
fieldOptions=getattr(a, "field_options", None),
|
||||
))
|
||||
except Exception:
|
||||
pass
|
||||
return all_annots
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(e))
|
||||
|
||||
|
||||
class OutlineItemResponse(BaseModel):
|
||||
title: str
|
||||
pageIndex: int
|
||||
level: int
|
||||
|
||||
|
||||
@router.get("/{document_id}/outline", response_model=list[OutlineItemResponse])
|
||||
def get_document_outline(document_id: str) -> list[OutlineItemResponse]:
|
||||
if not engine.is_available():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_501_NOT_IMPLEMENTED,
|
||||
detail="Engine bridge (bindings/python) not yet available.",
|
||||
)
|
||||
|
||||
d = document_store.get_document(document_id)
|
||||
if not d:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Document not found")
|
||||
|
||||
try:
|
||||
doc = d["doc_instance"]
|
||||
items = doc.extract_outline()
|
||||
return [
|
||||
OutlineItemResponse(title=it["title"], pageIndex=it["pageIndex"], level=it["level"])
|
||||
for it in items
|
||||
]
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/{document_id}/export")
|
||||
def export_document(document_id: str):
|
||||
if not engine.is_available():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_501_NOT_IMPLEMENTED,
|
||||
detail="Engine bridge (bindings/python) not yet available.",
|
||||
)
|
||||
|
||||
d = document_store.get_document(document_id)
|
||||
if not d:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Document not found")
|
||||
|
||||
perms = d.get("permissions") or {}
|
||||
if perms.get("canCopy", True) is False:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Exporting is not permitted by this document's restrictions (canCopy).",
|
||||
)
|
||||
|
||||
try:
|
||||
doc = d["doc_instance"]
|
||||
bytes_data = doc.save_full()
|
||||
filename = d["filename"]
|
||||
if not filename.endswith(".pdf"):
|
||||
filename += ".pdf"
|
||||
|
||||
import sys
|
||||
import os
|
||||
roaming_path = os.path.join(os.environ.get("APPDATA", "C:\\Users\\azeem\\AppData\\Roaming"), "Python", "Python312", "site-packages")
|
||||
if roaming_path not in sys.path:
|
||||
sys.path.append(roaming_path)
|
||||
|
||||
import pypdf
|
||||
import io
|
||||
|
||||
reader = pypdf.PdfReader(io.BytesIO(bytes_data))
|
||||
writer = pypdf.PdfWriter()
|
||||
writer.append(reader)
|
||||
|
||||
acro_form = writer.root_object.get("/AcroForm")
|
||||
if acro_form is not None:
|
||||
acro_form_dict = acro_form.get_object()
|
||||
acro_form_dict[pypdf.generic.NameObject("/NeedAppearances")] = pypdf.generic.BooleanObject(True)
|
||||
|
||||
out_stream = io.BytesIO()
|
||||
writer.write(out_stream)
|
||||
bytes_data = out_stream.getvalue()
|
||||
|
||||
return Response(
|
||||
content=bytes_data,
|
||||
media_type="application/pdf",
|
||||
headers={
|
||||
"Content-Disposition": f'attachment; filename="{filename}"',
|
||||
"Content-Length": str(len(bytes_data)),
|
||||
},
|
||||
)
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(e))
|
||||
|
||||
class TextObjectResponse(BaseModel):
|
||||
text: str
|
||||
fontName: str
|
||||
fontSize: float
|
||||
tm: list[float]
|
||||
|
||||
@router.get("/{document_id}/pages/{page_index}/text_objects", response_model=list[TextObjectResponse])
|
||||
def get_text_objects(document_id: str, page_index: int) -> list[TextObjectResponse]:
|
||||
if not engine.is_available():
|
||||
raise HTTPException(status_code=501, detail="Engine not available")
|
||||
|
||||
doc_info = document_store.get_document(document_id)
|
||||
if not doc_info:
|
||||
raise HTTPException(status_code=404, detail="Document not found")
|
||||
|
||||
_perms = doc_info.get("permissions") or {}
|
||||
if _perms.get("canModify", True) is False:
|
||||
raise HTTPException(status_code=403, detail="Raw Text editing is not permitted by this document's restrictions (canModify).")
|
||||
|
||||
pdfengine = engine.require()
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
|
||||
with tempfile.NamedTemporaryFile(delete=False, suffix=".pdf") as tmp:
|
||||
tmp.write(doc_info["bytes_data"])
|
||||
tmp_path = tmp.name
|
||||
|
||||
try:
|
||||
editor = pdfengine.StreamEditor(tmp_path)
|
||||
objects = editor.extract_text_objects(page_index)
|
||||
|
||||
result = []
|
||||
for obj in objects:
|
||||
text_str = obj["text"].decode("latin-1") if isinstance(obj["text"], bytes) else obj["text"]
|
||||
result.append(TextObjectResponse(
|
||||
text=text_str,
|
||||
fontName=obj["fontName"],
|
||||
fontSize=obj["fontSize"],
|
||||
tm=obj["tm"]
|
||||
))
|
||||
return result
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
finally:
|
||||
if os.path.exists(tmp_path):
|
||||
os.remove(tmp_path)
|
||||
|
||||
|
||||
class UpdateTextObjectRequest(BaseModel):
|
||||
new_text: str
|
||||
|
||||
|
||||
@router.put("/{document_id}/pages/{page_index}/text_objects/{object_index}")
|
||||
def replace_text_object(document_id: str, page_index: int, object_index: int, req: UpdateTextObjectRequest) -> dict:
|
||||
if not engine.is_available():
|
||||
raise HTTPException(status_code=501, detail="Engine not available")
|
||||
|
||||
doc_info = document_store.get_document(document_id)
|
||||
if not doc_info:
|
||||
raise HTTPException(status_code=404, detail="Document not found")
|
||||
|
||||
_perms = doc_info.get("permissions") or {}
|
||||
if _perms.get("canModify", True) is False:
|
||||
raise HTTPException(status_code=403, detail="Raw Text editing is not permitted by this document's restrictions (canModify).")
|
||||
|
||||
pdfengine = engine.require()
|
||||
|
||||
import tempfile
|
||||
import os
|
||||
|
||||
with tempfile.NamedTemporaryFile(delete=False, suffix=".pdf") as tmp:
|
||||
tmp.write(doc_info["bytes_data"])
|
||||
tmp_path = tmp.name
|
||||
|
||||
out_path = tmp_path + ".out.pdf"
|
||||
|
||||
try:
|
||||
editor = pdfengine.StreamEditor(tmp_path)
|
||||
try:
|
||||
new_text_bytes = req.new_text.encode("latin-1")
|
||||
except UnicodeEncodeError as enc_err:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Some characters can't be encoded in this run's font. Raw Text supports same-charset edits only — use Edit text to add new characters.",
|
||||
) from enc_err
|
||||
success = editor.replace_text_object(page_index, object_index, new_text_bytes, out_path)
|
||||
if not success:
|
||||
raise HTTPException(status_code=400, detail="Failed to replace text object (not found or identical)")
|
||||
|
||||
with open(out_path, "rb") as f:
|
||||
new_bytes = f.read()
|
||||
new_doc = pdfengine.PdfDocument.load_from_memory(new_bytes, "")
|
||||
new_info = document_store.add_document(
|
||||
filename=doc_info["filename"], bytes_data=new_bytes, doc_instance=new_doc,
|
||||
permissions=doc_info.get("permissions"),
|
||||
)
|
||||
return {"success": True, "newDocumentId": new_info["id"]}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e)) from e
|
||||
finally:
|
||||
if os.path.exists(tmp_path):
|
||||
os.remove(tmp_path)
|
||||
if os.path.exists(out_path):
|
||||
os.remove(out_path)
|
||||
|
||||
@router.get("/{document_id}/pages/{page_index}/display_list")
|
||||
def get_display_list(document_id: str, page_index: int):
|
||||
if not engine.is_available():
|
||||
raise HTTPException(status_code=501, detail="Engine not available")
|
||||
|
||||
doc_info = document_store.get_document(document_id)
|
||||
if not doc_info:
|
||||
raise HTTPException(status_code=404, detail="Document not found")
|
||||
|
||||
try:
|
||||
doc = doc_info["doc_instance"]
|
||||
page = doc.get_page(page_index)
|
||||
json_str = page.extract_display_list()
|
||||
return Response(content=json_str, media_type="application/json")
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@router.get("/{document_id}/pages/{page_index}/xobjects/{name}")
|
||||
def get_image_xobject(document_id: str, page_index: int, name: str):
|
||||
if not engine.is_available():
|
||||
raise HTTPException(status_code=501, detail="Engine not available")
|
||||
|
||||
doc_info = document_store.get_document(document_id)
|
||||
if not doc_info:
|
||||
raise HTTPException(status_code=404, detail="Document not found")
|
||||
|
||||
try:
|
||||
doc = doc_info["doc_instance"]
|
||||
page = doc.get_page(page_index)
|
||||
img_bytes = page.extract_image_xobject(name)
|
||||
if not img_bytes:
|
||||
raise HTTPException(status_code=404, detail="Image not found")
|
||||
return Response(content=img_bytes, media_type="image/png")
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
@@ -0,0 +1,20 @@
|
||||
"""Documents resource router.
|
||||
|
||||
Previously a single 900-line ``documents.py``. Split into focused sub-routers,
|
||||
all mounted under the unchanged ``/documents`` prefix so every public URL is
|
||||
preserved exactly. ``main.py`` still does ``app.include_router(documents.router)``.
|
||||
|
||||
The ``crud`` module owns the base router (it holds the prefix), and the other
|
||||
sub-routers mount into it.
|
||||
"""
|
||||
|
||||
from app.schemas.font import FontInfoResponse
|
||||
|
||||
from . import content, export, fonts, metadata, text_objects
|
||||
from .crud import router
|
||||
|
||||
router.include_router(metadata.router)
|
||||
router.include_router(fonts.router)
|
||||
router.include_router(content.router)
|
||||
router.include_router(export.router)
|
||||
router.include_router(text_objects.router)
|
||||
@@ -0,0 +1,163 @@
|
||||
from fastapi import APIRouter, HTTPException, Response, status
|
||||
|
||||
from app.schemas.annotation import AnnotationResponse
|
||||
from app.schemas.page_model import PageModelResponse
|
||||
from app.schemas.search import SearchMatch
|
||||
from app.services import engine
|
||||
from app.services.page_model import build_page_model_response
|
||||
from app.services.search import compute_search_matches
|
||||
from app.services.store import document_store
|
||||
|
||||
router = APIRouter(tags=["documents"])
|
||||
|
||||
|
||||
@router.get("/{document_id}/raw")
|
||||
def get_document_raw(document_id: str) -> Response:
|
||||
"""Raw PDF bytes of the current version — loaded into the in-browser WASM engine for the
|
||||
pixel-identical live-edit preview. Gated on copy permission (same as export)."""
|
||||
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")
|
||||
perms = doc_info.get("permissions") or {}
|
||||
if perms.get("canCopy", True) is False:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Not permitted (canCopy).")
|
||||
data = doc_info.get("bytes_data")
|
||||
if not data:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Document bytes unavailable")
|
||||
return Response(content=bytes(data), media_type="application/pdf")
|
||||
|
||||
|
||||
@router.get("/{document_id}/search", response_model=list[SearchMatch])
|
||||
def search_document(
|
||||
document_id: str,
|
||||
q: str,
|
||||
case_sensitive: bool = False,
|
||||
whole_words: bool = False,
|
||||
) -> 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"]
|
||||
return compute_search_matches(doc, q, case_sensitive, whole_words)
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(e))
|
||||
|
||||
|
||||
@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()
|
||||
return build_page_model_response(model)
|
||||
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))
|
||||
|
||||
|
||||
@router.get("/{document_id}/annotations", response_model=list[AnnotationResponse])
|
||||
def get_document_annotations(document_id: str) -> list[AnnotationResponse]:
|
||||
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"]
|
||||
all_annots = []
|
||||
for i in range(doc.page_count):
|
||||
try:
|
||||
page = doc.get_page(i)
|
||||
annots = page.extract_annotations()
|
||||
for a in annots:
|
||||
all_annots.append(AnnotationResponse(
|
||||
id=a.id,
|
||||
type=a.type,
|
||||
x=a.x,
|
||||
y=a.y,
|
||||
width=a.width,
|
||||
height=a.height,
|
||||
color=a.color,
|
||||
author=a.author,
|
||||
content=a.content,
|
||||
timestamp=getattr(a, "timestamp", None),
|
||||
pageIndex=a.page_index,
|
||||
paths=[[{"x": p.x, "y": p.y} for p in stroke] for stroke in getattr(a, "paths", [])],
|
||||
fieldName=getattr(a, "field_name", None),
|
||||
fieldValue=getattr(a, "field_value", None),
|
||||
fieldType=getattr(a, "field_type", None),
|
||||
fieldFlags=getattr(a, "field_flags", None),
|
||||
fieldOptions=getattr(a, "field_options", None),
|
||||
))
|
||||
except Exception:
|
||||
pass
|
||||
return all_annots
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/{document_id}/pages/{page_index}/display_list")
|
||||
def get_display_list(document_id: str, page_index: int):
|
||||
if not engine.is_available():
|
||||
raise HTTPException(status_code=501, detail="Engine not available")
|
||||
|
||||
doc_info = document_store.get_document(document_id)
|
||||
if not doc_info:
|
||||
raise HTTPException(status_code=404, detail="Document not found")
|
||||
|
||||
try:
|
||||
doc = doc_info["doc_instance"]
|
||||
page = doc.get_page(page_index)
|
||||
json_str = page.extract_display_list()
|
||||
return Response(content=json_str, media_type="application/json")
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/{document_id}/pages/{page_index}/xobjects/{name}")
|
||||
def get_image_xobject(document_id: str, page_index: int, name: str):
|
||||
if not engine.is_available():
|
||||
raise HTTPException(status_code=501, detail="Engine not available")
|
||||
|
||||
doc_info = document_store.get_document(document_id)
|
||||
if not doc_info:
|
||||
raise HTTPException(status_code=404, detail="Document not found")
|
||||
|
||||
try:
|
||||
doc = doc_info["doc_instance"]
|
||||
page = doc.get_page(page_index)
|
||||
img_bytes = page.extract_image_xobject(name)
|
||||
if not img_bytes:
|
||||
raise HTTPException(status_code=404, detail="Image not found")
|
||||
return Response(content=img_bytes, media_type="image/png")
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
@@ -0,0 +1,107 @@
|
||||
from fastapi import APIRouter, File, HTTPException, UploadFile, status
|
||||
|
||||
from app.schemas.document import (
|
||||
DocumentInfoResponse,
|
||||
PageInfoResponse,
|
||||
PermissionsResponse,
|
||||
)
|
||||
from app.services import engine
|
||||
from app.services.store import document_store
|
||||
|
||||
router = APIRouter(prefix="/documents", tags=["documents"])
|
||||
|
||||
def make_document_response(d: dict) -> DocumentInfoResponse:
|
||||
pages_list = []
|
||||
if "doc_instance" in d:
|
||||
doc = d["doc_instance"]
|
||||
for i in range(doc.page_count):
|
||||
try:
|
||||
page = doc.get_page(i)
|
||||
pages_list.append(PageInfoResponse(index=i, width=page.width, height=page.height))
|
||||
except Exception:
|
||||
pass
|
||||
perms = d.get("permissions")
|
||||
return DocumentInfoResponse(
|
||||
id=d["id"],
|
||||
filename=d["filename"],
|
||||
sizeBytes=d["sizeBytes"],
|
||||
totalPages=d["totalPages"],
|
||||
pageWidth=d.get("pageWidth", 612.0),
|
||||
pageHeight=d.get("pageHeight", 792.0),
|
||||
uploadedAt=d["uploadedAt"],
|
||||
status=d["status"],
|
||||
pages=pages_list,
|
||||
permissions=PermissionsResponse(**perms) if perms else PermissionsResponse(),
|
||||
)
|
||||
|
||||
|
||||
@router.post("", response_model=DocumentInfoResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def upload_document(file: UploadFile = File(...), password: str = "") -> DocumentInfoResponse:
|
||||
if not engine.is_available():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_501_NOT_IMPLEMENTED,
|
||||
detail="Engine bridge (bindings/python) not yet available.",
|
||||
)
|
||||
|
||||
bytes_data = await file.read()
|
||||
try:
|
||||
pdfengine = engine.require()
|
||||
doc = pdfengine.PdfDocument.load_from_memory(bytes_data, password)
|
||||
info = document_store.add_document(file.filename, bytes_data, doc)
|
||||
return make_document_response(info)
|
||||
except ValueError as e:
|
||||
detail = str(e)
|
||||
if "Password required" in detail:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED, detail="Password required"
|
||||
)
|
||||
elif "Invalid password" in detail:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid password")
|
||||
else:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=detail)
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST, detail=f"Failed to load PDF: {e!s}"
|
||||
)
|
||||
|
||||
|
||||
@router.get("", response_model=list[DocumentInfoResponse])
|
||||
def list_documents() -> list[DocumentInfoResponse]:
|
||||
if not engine.is_available():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_501_NOT_IMPLEMENTED,
|
||||
detail="Engine bridge (bindings/python) not yet available.",
|
||||
)
|
||||
|
||||
docs = document_store.list_documents()
|
||||
return [make_document_response(d) for d in docs]
|
||||
|
||||
|
||||
@router.get("/{document_id}", response_model=DocumentInfoResponse)
|
||||
def get_document(document_id: str) -> DocumentInfoResponse:
|
||||
if not engine.is_available():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_501_NOT_IMPLEMENTED,
|
||||
detail="Engine bridge (bindings/python) not yet available.",
|
||||
)
|
||||
|
||||
d = document_store.get_document(document_id)
|
||||
if not d:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Document not found")
|
||||
|
||||
return make_document_response(d)
|
||||
|
||||
|
||||
@router.delete("/{document_id}")
|
||||
def delete_document(document_id: str):
|
||||
if not engine.is_available():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_501_NOT_IMPLEMENTED,
|
||||
detail="Engine bridge (bindings/python) not yet available.",
|
||||
)
|
||||
|
||||
deleted = document_store.delete_document(document_id)
|
||||
if not deleted:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Document not found")
|
||||
|
||||
return {"success": True}
|
||||
@@ -0,0 +1,46 @@
|
||||
from fastapi import APIRouter, HTTPException, Response, status
|
||||
|
||||
from app.services import engine
|
||||
from app.services.export import apply_need_appearances
|
||||
from app.services.store import document_store
|
||||
|
||||
router = APIRouter(tags=["documents"])
|
||||
|
||||
@router.get("/{document_id}/export")
|
||||
def export_document(document_id: str):
|
||||
if not engine.is_available():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_501_NOT_IMPLEMENTED,
|
||||
detail="Engine bridge (bindings/python) not yet available.",
|
||||
)
|
||||
|
||||
d = document_store.get_document(document_id)
|
||||
if not d:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Document not found")
|
||||
|
||||
perms = d.get("permissions") or {}
|
||||
if perms.get("canCopy", True) is False:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Exporting is not permitted by this document's restrictions (canCopy).",
|
||||
)
|
||||
|
||||
try:
|
||||
doc = d["doc_instance"]
|
||||
bytes_data = doc.save_full()
|
||||
filename = d["filename"]
|
||||
if not filename.endswith(".pdf"):
|
||||
filename += ".pdf"
|
||||
|
||||
bytes_data = apply_need_appearances(bytes_data)
|
||||
|
||||
return Response(
|
||||
content=bytes_data,
|
||||
media_type="application/pdf",
|
||||
headers={
|
||||
"Content-Disposition": f'attachment; filename="{filename}"',
|
||||
"Content-Length": str(len(bytes_data)),
|
||||
},
|
||||
)
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(e))
|
||||
@@ -0,0 +1,109 @@
|
||||
import hashlib
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Response, status
|
||||
|
||||
from app.schemas.font import FontInfoResponse
|
||||
from app.services import engine
|
||||
from app.services.font import font_info_to_response
|
||||
from app.services.store import document_store
|
||||
|
||||
router = APIRouter(tags=["documents"])
|
||||
|
||||
_SFNT_TTF_MAGIC = (b"\x00\x01\x00\x00", b"true", b"ttcf")
|
||||
_SFNT_OTF_MAGIC = b"OTTO"
|
||||
|
||||
|
||||
@router.get("/{document_id}/fonts", response_model=list[FontInfoResponse])
|
||||
def get_document_fonts(
|
||||
document_id: str, start_page: int = 0, end_page: int = -1
|
||||
) -> list[FontInfoResponse]:
|
||||
if not engine.is_available():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_501_NOT_IMPLEMENTED,
|
||||
detail="Engine bridge (bindings/python) not yet available.",
|
||||
)
|
||||
|
||||
doc_info = document_store.get_document(document_id)
|
||||
if not doc_info:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Document not found")
|
||||
|
||||
try:
|
||||
doc = doc_info["doc_instance"]
|
||||
fonts = doc.get_fonts(start_page, end_page)
|
||||
return [font_info_to_response(f) for f in fonts]
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
|
||||
except IndexError as e:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/{document_id}/font")
|
||||
def get_font_bytes(document_id: str, internal_font_id: str) -> Response:
|
||||
"""Raw embedded font bytes for an in-place-editing preview.
|
||||
|
||||
Returns the font only when it's a browser-loadable sfnt (TrueType / OpenType-CFF).
|
||||
Type1/PFB, non-embedded, and unknown fonts return 404 so the frontend falls back to
|
||||
a base-14 CSS font. The lookup is sandboxed to fonts inside the loaded document.
|
||||
"""
|
||||
if not engine.is_available():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_501_NOT_IMPLEMENTED,
|
||||
detail="Engine bridge (bindings/python) not yet available.",
|
||||
)
|
||||
|
||||
if not internal_font_id or len(internal_font_id) > 256:
|
||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
doc_info = document_store.get_document(document_id)
|
||||
if not doc_info:
|
||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
try:
|
||||
data = bytes(doc_info["doc_instance"].get_font_data(internal_font_id))
|
||||
except Exception:
|
||||
data = b""
|
||||
if not data:
|
||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
magic = data[:4]
|
||||
if magic in _SFNT_TTF_MAGIC:
|
||||
media_type = "font/ttf"
|
||||
elif magic == _SFNT_OTF_MAGIC:
|
||||
media_type = "font/otf"
|
||||
else:
|
||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
etag = '"' + hashlib.sha256(data).hexdigest()[:32] + '"'
|
||||
return Response(
|
||||
content=data,
|
||||
media_type=media_type,
|
||||
headers={"Cache-Control": "public, max-age=31536000, immutable", "ETag": etag},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{document_id}/font-reconstructed")
|
||||
def get_reconstructed_font_bytes(document_id: str, internal_font_id: str) -> Response:
|
||||
"""Tier-2: a cmap-augmented copy of an embedded font (original glyph program + synthesized
|
||||
Unicode cmap) so the WASM live preview can reuse the document's real glyphs and match the
|
||||
saved result. 204 when reconstruction isn't possible -> frontend falls back to Tier-1."""
|
||||
if not engine.is_available():
|
||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||
if not internal_font_id or len(internal_font_id) > 256:
|
||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||
doc_info = document_store.get_document(document_id)
|
||||
if not doc_info:
|
||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||
try:
|
||||
data = bytes(doc_info["doc_instance"].get_reconstructed_font_data(internal_font_id))
|
||||
except Exception:
|
||||
data = b""
|
||||
if not data or data[:4] not in _SFNT_TTF_MAGIC:
|
||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||
etag = '"' + hashlib.sha256(data).hexdigest()[:32] + '"'
|
||||
return Response(
|
||||
content=data,
|
||||
media_type="font/ttf",
|
||||
headers={"Cache-Control": "public, max-age=31536000, immutable", "ETag": etag},
|
||||
)
|
||||
@@ -0,0 +1,56 @@
|
||||
from fastapi import APIRouter, HTTPException, status
|
||||
|
||||
from app.schemas.document import DocumentMetadataResponse, OutlineItemResponse
|
||||
from app.services import engine
|
||||
from app.services.store import document_store
|
||||
|
||||
router = APIRouter(tags=["documents"])
|
||||
|
||||
@router.get("/{document_id}/metadata", response_model=DocumentMetadataResponse)
|
||||
def get_document_metadata(document_id: str) -> DocumentMetadataResponse:
|
||||
if not engine.is_available():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_501_NOT_IMPLEMENTED,
|
||||
detail="Engine bridge (bindings/python) not yet available.",
|
||||
)
|
||||
|
||||
d = document_store.get_document(document_id)
|
||||
if not d:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Document not found")
|
||||
|
||||
try:
|
||||
doc = d["doc_instance"]
|
||||
meta = doc.metadata
|
||||
return DocumentMetadataResponse(
|
||||
title=meta.title,
|
||||
author=meta.author,
|
||||
creator=meta.creator,
|
||||
producer=meta.producer,
|
||||
creation_date=meta.creation_date,
|
||||
modification_date=meta.modification_date,
|
||||
)
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/{document_id}/outline", response_model=list[OutlineItemResponse])
|
||||
def get_document_outline(document_id: str) -> list[OutlineItemResponse]:
|
||||
if not engine.is_available():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_501_NOT_IMPLEMENTED,
|
||||
detail="Engine bridge (bindings/python) not yet available.",
|
||||
)
|
||||
|
||||
d = document_store.get_document(document_id)
|
||||
if not d:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Document not found")
|
||||
|
||||
try:
|
||||
doc = d["doc_instance"]
|
||||
items = doc.extract_outline()
|
||||
return [
|
||||
OutlineItemResponse(title=it["title"], pageIndex=it["pageIndex"], level=it["level"])
|
||||
for it in items
|
||||
]
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(e))
|
||||
@@ -0,0 +1,106 @@
|
||||
import os
|
||||
import tempfile
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
|
||||
from app.schemas.text_object import TextObjectResponse, UpdateTextObjectRequest
|
||||
from app.services import engine
|
||||
from app.services.store import document_store
|
||||
|
||||
router = APIRouter(tags=["documents"])
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{document_id}/pages/{page_index}/text_objects",
|
||||
response_model=list[TextObjectResponse],
|
||||
)
|
||||
def get_text_objects(document_id: str, page_index: int) -> list[TextObjectResponse]:
|
||||
if not engine.is_available():
|
||||
raise HTTPException(status_code=501, detail="Engine not available")
|
||||
|
||||
doc_info = document_store.get_document(document_id)
|
||||
if not doc_info:
|
||||
raise HTTPException(status_code=404, detail="Document not found")
|
||||
|
||||
_perms = doc_info.get("permissions") or {}
|
||||
if _perms.get("canModify", True) is False:
|
||||
raise HTTPException(status_code=403, detail="Raw Text editing is not permitted by this document's restrictions (canModify).")
|
||||
|
||||
pdfengine = engine.require()
|
||||
|
||||
with tempfile.NamedTemporaryFile(delete=False, suffix=".pdf") as tmp:
|
||||
tmp.write(doc_info["bytes_data"])
|
||||
tmp_path = tmp.name
|
||||
|
||||
try:
|
||||
editor = pdfengine.StreamEditor(tmp_path)
|
||||
objects = editor.extract_text_objects(page_index)
|
||||
|
||||
result = []
|
||||
for obj in objects:
|
||||
text_str = obj["text"].decode("latin-1") if isinstance(obj["text"], bytes) else obj["text"]
|
||||
result.append(TextObjectResponse(
|
||||
text=text_str,
|
||||
fontName=obj["fontName"],
|
||||
fontSize=obj["fontSize"],
|
||||
tm=obj["tm"]
|
||||
))
|
||||
return result
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
finally:
|
||||
if os.path.exists(tmp_path):
|
||||
os.remove(tmp_path)
|
||||
|
||||
|
||||
@router.put("/{document_id}/pages/{page_index}/text_objects/{object_index}")
|
||||
def replace_text_object(document_id: str, page_index: int, object_index: int, req: UpdateTextObjectRequest) -> dict:
|
||||
if not engine.is_available():
|
||||
raise HTTPException(status_code=501, detail="Engine not available")
|
||||
|
||||
doc_info = document_store.get_document(document_id)
|
||||
if not doc_info:
|
||||
raise HTTPException(status_code=404, detail="Document not found")
|
||||
|
||||
_perms = doc_info.get("permissions") or {}
|
||||
if _perms.get("canModify", True) is False:
|
||||
raise HTTPException(status_code=403, detail="Raw Text editing is not permitted by this document's restrictions (canModify).")
|
||||
|
||||
pdfengine = engine.require()
|
||||
|
||||
with tempfile.NamedTemporaryFile(delete=False, suffix=".pdf") as tmp:
|
||||
tmp.write(doc_info["bytes_data"])
|
||||
tmp_path = tmp.name
|
||||
|
||||
out_path = tmp_path + ".out.pdf"
|
||||
|
||||
try:
|
||||
editor = pdfengine.StreamEditor(tmp_path)
|
||||
try:
|
||||
new_text_bytes = req.new_text.encode("latin-1")
|
||||
except UnicodeEncodeError as enc_err:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Some characters can't be encoded in this run's font. Raw Text supports same-charset edits only — use Edit text to add new characters.",
|
||||
) from enc_err
|
||||
success = editor.replace_text_object(page_index, object_index, new_text_bytes, out_path)
|
||||
if not success:
|
||||
raise HTTPException(status_code=400, detail="Failed to replace text object (not found or identical)")
|
||||
|
||||
with open(out_path, "rb") as f:
|
||||
new_bytes = f.read()
|
||||
new_doc = pdfengine.PdfDocument.load_from_memory(new_bytes, "")
|
||||
new_info = document_store.add_document(
|
||||
filename=doc_info["filename"], bytes_data=new_bytes, doc_instance=new_doc,
|
||||
permissions=doc_info.get("permissions"),
|
||||
)
|
||||
return {"success": True, "newDocumentId": new_info["id"]}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e)) from e
|
||||
finally:
|
||||
if os.path.exists(tmp_path):
|
||||
os.remove(tmp_path)
|
||||
if os.path.exists(out_path):
|
||||
os.remove(out_path)
|
||||
@@ -2,8 +2,9 @@ from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Path, Query, Response, status
|
||||
|
||||
from app.routers.documents import FontInfoResponse
|
||||
from app.schemas.font import FontInfoResponse
|
||||
from app.services import engine
|
||||
from app.services.font import font_info_to_response
|
||||
from app.services.store import document_store
|
||||
|
||||
router = APIRouter(prefix="/documents/{document_id}/pages", tags=["render"])
|
||||
@@ -179,30 +180,7 @@ def get_page_fonts(document_id: str, page_index: int) -> list[FontInfoResponse]:
|
||||
doc = doc_info["doc_instance"]
|
||||
page = doc.get_page(page_index)
|
||||
fonts = page.get_fonts()
|
||||
return [
|
||||
FontInfoResponse(
|
||||
fontName=f.font_name,
|
||||
type=f.type,
|
||||
isEmbedded=f.is_embedded,
|
||||
isSubset=f.is_subset,
|
||||
isVertical=f.is_vertical,
|
||||
encoding=f.encoding,
|
||||
hasToUnicode=f.has_to_unicode,
|
||||
cmapName=f.cmap_name,
|
||||
cidSystemInfo=f.cid_system_info,
|
||||
subsetTag=f.subset_tag,
|
||||
sourceType=f.source_type,
|
||||
substitutedFrom=f.substituted_from,
|
||||
substitutedTo=f.substituted_to,
|
||||
normalizedFamily=f.normalized_family,
|
||||
internalFontId=f.internal_font_id,
|
||||
flags=f.flags,
|
||||
ascent=f.ascent,
|
||||
descent=f.descent,
|
||||
capHeight=f.cap_height,
|
||||
)
|
||||
for f in fonts
|
||||
]
|
||||
return [font_info_to_response(f) for f in fonts]
|
||||
except IndexError:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Page index out of range")
|
||||
except Exception as e:
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
"""Pydantic request/response schemas for the gateway API.
|
||||
|
||||
Extracted from the router modules so they can be shared across routers without
|
||||
forcing cross-router imports (which previously created an import cycle between
|
||||
``render`` and ``documents``).
|
||||
"""
|
||||
@@ -0,0 +1,22 @@
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class AnnotationResponse(BaseModel):
|
||||
id: str
|
||||
type: str
|
||||
x: float
|
||||
y: float
|
||||
width: float
|
||||
height: float
|
||||
color: str
|
||||
author: str
|
||||
content: str
|
||||
timestamp: str | None = None
|
||||
pageIndex: int
|
||||
paths: list[list[dict[str, float]]] = []
|
||||
|
||||
fieldName: str | None = None
|
||||
fieldValue: str | None = None
|
||||
fieldType: str | None = None
|
||||
fieldFlags: int | None = None
|
||||
fieldOptions: list[str] | None = None
|
||||
@@ -0,0 +1,50 @@
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class PageInfoResponse(BaseModel):
|
||||
index: int
|
||||
width: float
|
||||
height: float
|
||||
|
||||
|
||||
class PermissionsResponse(BaseModel):
|
||||
isEncrypted: bool = False
|
||||
encryption: str = "None"
|
||||
securityRevision: int = -1
|
||||
ownerUnlocked: bool = False
|
||||
canPrint: bool = True
|
||||
canPrintHighRes: bool = True
|
||||
canModify: bool = True
|
||||
canCopy: bool = True
|
||||
canAnnotate: bool = True
|
||||
canFillForms: bool = True
|
||||
canExtractForAccessibility: bool = True
|
||||
canAssemble: bool = True
|
||||
|
||||
|
||||
class DocumentInfoResponse(BaseModel):
|
||||
id: str
|
||||
filename: str
|
||||
sizeBytes: int
|
||||
totalPages: int
|
||||
pageWidth: float
|
||||
pageHeight: float
|
||||
uploadedAt: str
|
||||
status: str
|
||||
pages: list[PageInfoResponse] = []
|
||||
permissions: PermissionsResponse = PermissionsResponse()
|
||||
|
||||
|
||||
class DocumentMetadataResponse(BaseModel):
|
||||
title: str
|
||||
author: str
|
||||
creator: str
|
||||
producer: str
|
||||
creation_date: str
|
||||
modification_date: str
|
||||
|
||||
|
||||
class OutlineItemResponse(BaseModel):
|
||||
title: str
|
||||
pageIndex: int
|
||||
level: int
|
||||
@@ -0,0 +1,23 @@
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class FontInfoResponse(BaseModel):
|
||||
fontName: str
|
||||
type: str
|
||||
isEmbedded: bool
|
||||
isSubset: bool
|
||||
isVertical: bool
|
||||
encoding: str
|
||||
hasToUnicode: bool
|
||||
cmapName: str
|
||||
cidSystemInfo: str
|
||||
subsetTag: str
|
||||
sourceType: str
|
||||
substitutedFrom: str
|
||||
substitutedTo: str
|
||||
normalizedFamily: str
|
||||
internalFontId: str
|
||||
flags: int
|
||||
ascent: float
|
||||
descent: float
|
||||
capHeight: float
|
||||
@@ -0,0 +1,59 @@
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
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
|
||||
page_object_index: int = -1
|
||||
|
||||
|
||||
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
|
||||
object_indices: list[int] = []
|
||||
color: str = "#000000"
|
||||
font_fidelity: str = "exact"
|
||||
|
||||
|
||||
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
|
||||
@@ -0,0 +1,13 @@
|
||||
from pydantic import BaseModel
|
||||
|
||||
class SearchRect(BaseModel):
|
||||
x: float
|
||||
y: float
|
||||
w: float
|
||||
h: float
|
||||
|
||||
|
||||
class SearchMatch(BaseModel):
|
||||
pageIndex: int
|
||||
rects: list[SearchRect]
|
||||
text: str
|
||||
@@ -0,0 +1,12 @@
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class TextObjectResponse(BaseModel):
|
||||
text: str
|
||||
fontName: str
|
||||
fontSize: float
|
||||
tm: list[float]
|
||||
|
||||
|
||||
class UpdateTextObjectRequest(BaseModel):
|
||||
new_text: str
|
||||
@@ -0,0 +1,44 @@
|
||||
"""Export pipeline: full-save + a pypdf pass that sets /NeedAppearances.
|
||||
|
||||
NOTE: ``pypdf`` is intentionally imported lazily inside the function. It is not a
|
||||
declared gateway dependency and may only be resolvable via the roaming
|
||||
site-packages path appended below, so importing it at module load would prevent
|
||||
the whole app from starting on machines without it. Behavior here is preserved
|
||||
verbatim from the original ``routers.documents.export_document``; if pypdf is
|
||||
later added to ``pyproject.toml`` dependencies, the sys.path augmentation can be
|
||||
removed and the import hoisted to module scope.
|
||||
"""
|
||||
|
||||
|
||||
def apply_need_appearances(bytes_data: bytes) -> bytes:
|
||||
"""Round-trip the PDF through pypdf to set AcroForm /NeedAppearances=true."""
|
||||
import os
|
||||
import sys
|
||||
|
||||
roaming_path = os.path.join(
|
||||
os.environ.get("APPDATA", "C:\\Users\\azeem\\AppData\\Roaming"),
|
||||
"Python",
|
||||
"Python312",
|
||||
"site-packages",
|
||||
)
|
||||
if roaming_path not in sys.path:
|
||||
sys.path.append(roaming_path)
|
||||
|
||||
import io
|
||||
|
||||
import pypdf
|
||||
|
||||
reader = pypdf.PdfReader(io.BytesIO(bytes_data))
|
||||
writer = pypdf.PdfWriter()
|
||||
writer.append(reader)
|
||||
|
||||
acro_form = writer.root_object.get("/AcroForm")
|
||||
if acro_form is not None:
|
||||
acro_form_dict = acro_form.get_object()
|
||||
acro_form_dict[pypdf.generic.NameObject("/NeedAppearances")] = pypdf.generic.BooleanObject(
|
||||
True
|
||||
)
|
||||
|
||||
out_stream = io.BytesIO()
|
||||
writer.write(out_stream)
|
||||
return out_stream.getvalue()
|
||||
@@ -0,0 +1,32 @@
|
||||
"""Font helpers shared by the documents and render routers."""
|
||||
|
||||
from app.schemas.font import FontInfoResponse
|
||||
|
||||
|
||||
def font_info_to_response(f) -> FontInfoResponse:
|
||||
"""Map an engine font-info object to the API response model.
|
||||
|
||||
Previously this mapping was duplicated verbatim in both
|
||||
``routers.documents.get_document_fonts`` and ``routers.render.get_page_fonts``.
|
||||
"""
|
||||
return FontInfoResponse(
|
||||
fontName=f.font_name,
|
||||
type=f.type,
|
||||
isEmbedded=f.is_embedded,
|
||||
isSubset=f.is_subset,
|
||||
isVertical=f.is_vertical,
|
||||
encoding=f.encoding,
|
||||
hasToUnicode=f.has_to_unicode,
|
||||
cmapName=f.cmap_name,
|
||||
cidSystemInfo=f.cid_system_info,
|
||||
subsetTag=f.subset_tag,
|
||||
sourceType=f.source_type,
|
||||
substitutedFrom=f.substituted_from,
|
||||
substitutedTo=f.substituted_to,
|
||||
normalizedFamily=f.normalized_family,
|
||||
internalFontId=f.internal_font_id,
|
||||
flags=f.flags,
|
||||
ascent=f.ascent,
|
||||
descent=f.descent,
|
||||
capHeight=f.cap_height,
|
||||
)
|
||||
@@ -0,0 +1,77 @@
|
||||
"""Map an engine page model to the API ``PageModelResponse``.
|
||||
|
||||
Extracted verbatim from ``routers.documents.get_page_model``.
|
||||
"""
|
||||
|
||||
from app.schemas.page_model import (
|
||||
GlyphModel,
|
||||
PageModelResponse,
|
||||
ParagraphModel,
|
||||
TextLineModel,
|
||||
TextRunModel,
|
||||
)
|
||||
|
||||
|
||||
def build_page_model_response(model) -> PageModelResponse:
|
||||
paragraphs = []
|
||||
for p in model.paragraphs:
|
||||
lines = []
|
||||
for line in p.lines:
|
||||
runs = []
|
||||
for r in line.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,
|
||||
page_object_index=g.page_object_index,
|
||||
)
|
||||
)
|
||||
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,
|
||||
object_indices=r.object_indices,
|
||||
color=getattr(r, "fill_color", "#000000") or "#000000",
|
||||
font_fidelity=getattr(r, "font_fidelity", "exact") or "exact",
|
||||
)
|
||||
)
|
||||
lines.append(
|
||||
TextLineModel(
|
||||
runs=runs,
|
||||
baseline_y=line.baseline_y,
|
||||
x=line.x,
|
||||
y=line.y,
|
||||
w=line.w,
|
||||
h=line.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,
|
||||
)
|
||||
@@ -0,0 +1,97 @@
|
||||
"""Full-text search over a loaded document's glyph bounds.
|
||||
|
||||
Extracted verbatim from ``routers.documents.search_document`` so the router stays
|
||||
a thin HTTP wrapper. The router remains responsible for the engine-availability /
|
||||
document-existence checks and for translating exceptions into HTTP errors.
|
||||
"""
|
||||
|
||||
from app.schemas.search import SearchMatch, SearchRect
|
||||
|
||||
|
||||
def compute_search_matches(
|
||||
doc,
|
||||
q: str,
|
||||
case_sensitive: bool = False,
|
||||
whole_words: bool = False,
|
||||
) -> list[SearchMatch]:
|
||||
def _is_word_char(ch: str) -> bool:
|
||||
return ch.isalnum() or ch == "_"
|
||||
|
||||
def _find_all(haystack: str, needle: str) -> list[int]:
|
||||
"""Return start indices of all non-overlapping occurrences of needle in haystack."""
|
||||
results: list[int] = []
|
||||
start = 0
|
||||
needle_len = len(needle)
|
||||
while True:
|
||||
pos = haystack.find(needle, start)
|
||||
if pos == -1:
|
||||
break
|
||||
if whole_words:
|
||||
before_ok = pos == 0 or not _is_word_char(haystack[pos - 1])
|
||||
after_ok = (pos + needle_len) >= len(haystack) or not _is_word_char(
|
||||
haystack[pos + needle_len]
|
||||
)
|
||||
if before_ok and after_ok:
|
||||
results.append(pos)
|
||||
else:
|
||||
results.append(pos)
|
||||
start = pos + 1
|
||||
return results
|
||||
|
||||
matches = []
|
||||
search_needle = q if case_sensitive else q.lower()
|
||||
query_len = len(search_needle)
|
||||
|
||||
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: list[int] = []
|
||||
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)
|
||||
|
||||
search_text = text_str if case_sensitive else text_str.lower()
|
||||
|
||||
for idx in _find_all(search_text, search_needle):
|
||||
if idx + query_len - 1 >= len(char_to_glyph):
|
||||
continue
|
||||
|
||||
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]
|
||||
)
|
||||
)
|
||||
|
||||
return matches
|
||||
@@ -1,461 +0,0 @@
|
||||
#include "pdf_engine_facade.hpp"
|
||||
#include "wasm_rasterizer.hpp"
|
||||
#include <string_view>
|
||||
#include <iostream>
|
||||
#include <cmath>
|
||||
#include <algorithm>
|
||||
|
||||
|
||||
WasmMockPage::WasmMockPage(int pageIndex) : m_pageIndex(pageIndex) {
|
||||
m_displayList.fillRect(0.0f, 0.0f, 800.0f, 1100.0f);
|
||||
|
||||
float gridSpacing = 50.0f;
|
||||
for (float x = gridSpacing; x < 800.0f; x += gridSpacing) {
|
||||
m_displayList.fillRect(x, 0.0f, 1.0f, 1100.0f);
|
||||
}
|
||||
for (float y = gridSpacing; y < 1100.0f; y += gridSpacing) {
|
||||
m_displayList.fillRect(0.0f, y, 800.0f, 1.0f);
|
||||
}
|
||||
|
||||
float borderWidth = 4.0f;
|
||||
m_displayList.fillRect(0.0f, 0.0f, 800.0f, borderWidth);
|
||||
m_displayList.fillRect(0.0f, 1100.0f - borderWidth, 800.0f, borderWidth);
|
||||
m_displayList.fillRect(0.0f, 0.0f, borderWidth, 1100.0f);
|
||||
m_displayList.fillRect(800.0f - borderWidth, 0.0f, borderWidth, 1100.0f);
|
||||
|
||||
float centerX = 400.0f;
|
||||
float centerY = 550.0f;
|
||||
float size = 120.0f;
|
||||
|
||||
if (m_pageIndex == 0) {
|
||||
m_displayList.fillRect(centerX - size / 2.0f, centerY - size / 2.0f, size, size);
|
||||
} else {
|
||||
m_displayList.saveState();
|
||||
|
||||
m_displayList.setTransform(pdfengine::Matrix(1.0f, 0.0f, 0.0f, 1.0f, centerX, centerY));
|
||||
|
||||
float angle = 45.0f * 3.14159265f / 180.0f;
|
||||
float cosVal = std::cos(angle);
|
||||
float sinVal = std::sin(angle);
|
||||
m_displayList.setTransform(pdfengine::Matrix(cosVal, sinVal, -sinVal, cosVal, 0.0f, 0.0f));
|
||||
|
||||
m_displayList.fillRect(-size / 2.0f, -size / 2.0f, size, size);
|
||||
|
||||
m_displayList.restoreState();
|
||||
}
|
||||
|
||||
m_displayList.drawText("Page " + std::to_string(m_pageIndex + 1), 60.0f, 80.0f);
|
||||
}
|
||||
|
||||
std::expected<pdfengine::PageImage, pdfengine::EngineError> WasmMockPage::render(int dpi) const {
|
||||
(void)dpi;
|
||||
return std::unexpected(pdfengine::EngineError::Unknown);
|
||||
}
|
||||
|
||||
std::expected<std::string, pdfengine::EngineError> WasmMockPage::extractText() const {
|
||||
return "Mock text on page " + std::to_string(m_pageIndex + 1);
|
||||
}
|
||||
|
||||
std::expected<std::vector<pdfengine::GlyphBounds>, pdfengine::EngineError> WasmMockPage::extractTextWithBounds() const {
|
||||
std::vector<pdfengine::GlyphBounds> glyphs;
|
||||
std::string text = "Page " + std::to_string(m_pageIndex + 1);
|
||||
|
||||
double startX = 60.0;
|
||||
double startY = 80.0;
|
||||
for (size_t i = 0; i < text.length(); ++i) {
|
||||
pdfengine::GlyphBounds gb;
|
||||
gb.text = std::string(1, text[i]);
|
||||
gb.x = startX + i * 6.0;
|
||||
gb.y = startY;
|
||||
gb.w = 6.0;
|
||||
gb.h = 8.0;
|
||||
gb.fontSize = 12.0;
|
||||
glyphs.push_back(gb);
|
||||
}
|
||||
return glyphs;
|
||||
}
|
||||
|
||||
std::expected<pdfengine::PageModel, pdfengine::EngineError> WasmMockPage::extractDocumentModel() const {
|
||||
pdfengine::PageModel model;
|
||||
model.width = width();
|
||||
model.height = height();
|
||||
model.pageIndex = m_pageIndex;
|
||||
|
||||
pdfengine::Paragraph p;
|
||||
p.x = 60.0;
|
||||
p.y = 80.0;
|
||||
p.w = 100.0;
|
||||
p.h = 20.0;
|
||||
|
||||
pdfengine::TextLine line;
|
||||
line.x = 60.0;
|
||||
line.y = 80.0;
|
||||
line.w = 100.0;
|
||||
line.h = 20.0;
|
||||
line.baselineY = 80.0;
|
||||
|
||||
pdfengine::TextRun run;
|
||||
run.text = "Page " + std::to_string(m_pageIndex + 1);
|
||||
run.fontName = "Helvetica";
|
||||
run.fontSize = 12.0;
|
||||
run.x = 60.0;
|
||||
run.y = 80.0;
|
||||
run.w = 100.0;
|
||||
run.h = 20.0;
|
||||
|
||||
double startX = 60.0;
|
||||
double startY = 80.0;
|
||||
for (size_t i = 0; i < run.text.length(); ++i) {
|
||||
pdfengine::Glyph g;
|
||||
g.text = std::string(1, run.text[i]);
|
||||
g.unicode = run.text[i];
|
||||
g.fontName = "Helvetica";
|
||||
g.fontSize = 12.0;
|
||||
g.originX = startX + i * 6.0;
|
||||
g.originY = startY;
|
||||
g.bboxX = g.originX;
|
||||
g.bboxY = g.originY;
|
||||
g.bboxW = 6.0;
|
||||
g.bboxH = 8.0;
|
||||
run.glyphs.push_back(g);
|
||||
}
|
||||
|
||||
line.runs.push_back(run);
|
||||
p.lines.push_back(line);
|
||||
model.paragraphs.push_back(p);
|
||||
|
||||
return model;
|
||||
}
|
||||
|
||||
std::expected<std::vector<std::string>, pdfengine::EngineError> WasmMockPage::extractAnnotationsText() const {
|
||||
return std::vector<std::string>();
|
||||
}
|
||||
|
||||
std::expected<std::vector<pdfengine::PdfPage::AnnotationInfo>, pdfengine::EngineError> WasmMockPage::extractAnnotations() const {
|
||||
return std::vector<pdfengine::PdfPage::AnnotationInfo>();
|
||||
}
|
||||
|
||||
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 {
|
||||
pdfengine::FontInfo info;
|
||||
info.fontName = "Helvetica";
|
||||
info.type = "Type1";
|
||||
info.isEmbedded = false;
|
||||
info.isSubset = false;
|
||||
info.isVertical = false;
|
||||
info.encoding = "WinAnsiEncoding";
|
||||
info.hasToUnicode = false;
|
||||
info.sourceType = "Substituted";
|
||||
info.substitutedFrom = "Helvetica";
|
||||
info.substitutedTo = "Liberation Sans Regular";
|
||||
info.normalizedFamily= "Arial";
|
||||
info.internalFontId = "mock-page-" + std::to_string(m_pageIndex) + "-helvetica";
|
||||
info.flags = 32;
|
||||
info.ascent = 718.0;
|
||||
info.descent = -207.0;
|
||||
info.capHeight = 718.0;
|
||||
return std::vector<pdfengine::FontInfo>{info};
|
||||
}
|
||||
|
||||
pdfengine::DevicePoint WasmMockPage::pageToDevice(const pdfengine::Point2D& pagePoint, int deviceWidth, int deviceHeight, int rotate) const noexcept {
|
||||
(void)rotate;
|
||||
double scaleX = static_cast<double>(deviceWidth) / width();
|
||||
double scaleY = static_cast<double>(deviceHeight) / height();
|
||||
int dx = static_cast<int>(pagePoint.x * scaleX);
|
||||
int dy = static_cast<int>((height() - pagePoint.y) * scaleY);
|
||||
return {dx, dy};
|
||||
}
|
||||
|
||||
pdfengine::Point2D WasmMockPage::deviceToPage(const pdfengine::DevicePoint& devicePoint, int deviceWidth, int deviceHeight, int rotate) const noexcept {
|
||||
(void)rotate;
|
||||
double scaleX = width() / static_cast<double>(deviceWidth);
|
||||
double scaleY = height() / static_cast<double>(deviceHeight);
|
||||
double px = devicePoint.x * scaleX;
|
||||
double py = height() - (devicePoint.y * scaleY);
|
||||
return {px, py};
|
||||
}
|
||||
|
||||
|
||||
WasmMockDocument::WasmMockDocument(int pageCount, std::vector<uint8_t> data)
|
||||
: m_pageCount(pageCount), m_data(std::move(data)) {}
|
||||
|
||||
pdfengine::DocumentMetadata WasmMockDocument::metadata() const noexcept {
|
||||
pdfengine::DocumentMetadata meta;
|
||||
meta.title = "WASM Mock Document";
|
||||
meta.author = "Emscripten Engine";
|
||||
meta.creator = "PdfEngine SDK";
|
||||
meta.producer = "WebAssembly Facade";
|
||||
meta.creationDate = "D:20260601090000";
|
||||
meta.modificationDate = "D:20260601090000";
|
||||
return meta;
|
||||
}
|
||||
|
||||
std::expected<std::shared_ptr<pdfengine::PdfPage>, pdfengine::EngineError> WasmMockDocument::getPage(int pageIndex) {
|
||||
if (pageIndex < 0 || pageIndex >= m_pageCount) {
|
||||
return std::unexpected(pdfengine::EngineError::PageOutOfBounds);
|
||||
}
|
||||
|
||||
auto it = m_pages.find(pageIndex);
|
||||
if (it == m_pages.end()) {
|
||||
auto page = std::make_shared<WasmMockPage>(pageIndex);
|
||||
m_pages[pageIndex] = page;
|
||||
return page;
|
||||
}
|
||||
return it->second;
|
||||
}
|
||||
|
||||
std::expected<std::vector<pdfengine::FontInfo>, pdfengine::EngineError> WasmMockDocument::getFonts(int startPage, int endPage) const {
|
||||
int last = (endPage < 0) ? m_pageCount - 1 : std::min(endPage, m_pageCount - 1);
|
||||
std::vector<pdfengine::FontInfo> result;
|
||||
|
||||
for (int i = startPage; i <= last; ++i) {
|
||||
bool found = false;
|
||||
for (const auto& existing : result) {
|
||||
if (existing.fontName == "Helvetica") { found = true; break; }
|
||||
}
|
||||
if (!found) {
|
||||
pdfengine::FontInfo info;
|
||||
info.fontName = "Helvetica";
|
||||
info.type = "Type1";
|
||||
info.isEmbedded = false;
|
||||
info.isSubset = false;
|
||||
info.isVertical = false;
|
||||
info.encoding = "WinAnsiEncoding";
|
||||
info.hasToUnicode = false;
|
||||
info.sourceType = "Substituted";
|
||||
info.substitutedFrom = "Helvetica";
|
||||
info.substitutedTo = "Liberation Sans Regular";
|
||||
info.normalizedFamily= "Arial";
|
||||
info.internalFontId = "mock-doc-helvetica";
|
||||
info.flags = 32;
|
||||
info.ascent = 718.0;
|
||||
info.descent = -207.0;
|
||||
info.capHeight = 718.0;
|
||||
result.push_back(info);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
std::expected<void, pdfengine::EngineError> WasmMockDocument::applyEdits(const std::string& editsJson) {
|
||||
(void)editsJson;
|
||||
return {};
|
||||
}
|
||||
|
||||
std::expected<std::vector<uint8_t>, pdfengine::EngineError> WasmMockDocument::saveIncremental() const {
|
||||
return m_data;
|
||||
}
|
||||
|
||||
std::expected<std::vector<uint8_t>, pdfengine::EngineError> WasmMockDocument::getFontData(const std::string& internalFontId) const {
|
||||
(void)internalFontId;
|
||||
return std::unexpected(pdfengine::EngineError::Unknown);
|
||||
}
|
||||
|
||||
std::expected<std::shared_ptr<pdfengine::fonts::pdf_fonts::Font>, std::string> WasmMockDocument::getResolvedFont(const pdfengine::FontInfo& fontInfo) {
|
||||
(void)fontInfo;
|
||||
return std::unexpected("Not implemented in WASM mock");
|
||||
}
|
||||
|
||||
std::expected<std::vector<uint8_t>, pdfengine::EngineError> WasmMockDocument::saveFull() const {
|
||||
return m_data;
|
||||
}
|
||||
|
||||
|
||||
PdfEngineFacade::PdfEngineFacade() : m_nextHandle(1) {}
|
||||
|
||||
PdfEngineFacade::~PdfEngineFacade() = default;
|
||||
|
||||
int PdfEngineFacade::loadDocument(const uint8_t* buffer, int size) {
|
||||
if (!buffer || size <= 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
int pages = 0;
|
||||
if (size > 4 && buffer[0] == '%' && buffer[1] == 'P' && buffer[2] == 'D' && buffer[3] == 'F') {
|
||||
std::string_view sv(reinterpret_cast<const char*>(buffer), size);
|
||||
size_t pos = 0;
|
||||
while ((pos = sv.find("/Type /Page", pos)) != std::string_view::npos) {
|
||||
if (pos + 11 < sv.size() && sv[pos + 11] != 's') {
|
||||
pages++;
|
||||
}
|
||||
pos += 11;
|
||||
}
|
||||
}
|
||||
|
||||
if (pages == 0) {
|
||||
pages = 3;
|
||||
}
|
||||
|
||||
std::vector<uint8_t> docBytes(buffer, buffer + size);
|
||||
auto doc = std::make_shared<WasmMockDocument>(pages, std::move(docBytes));
|
||||
|
||||
int handle = m_nextHandle++;
|
||||
m_documents[handle] = std::move(doc);
|
||||
return handle;
|
||||
}
|
||||
|
||||
bool PdfEngineFacade::renderPage(int docHandle, int pageIndex, float scale, uint8_t* outputBuffer, int width, int height) {
|
||||
auto it = m_documents.find(docHandle);
|
||||
if (it == m_documents.end()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const auto& doc = it->second;
|
||||
auto pageRes = doc->getPage(pageIndex);
|
||||
if (!pageRes.has_value()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
auto page = *pageRes;
|
||||
auto mockPage = std::dynamic_pointer_cast<WasmMockPage>(page);
|
||||
if (!mockPage) {
|
||||
return false;
|
||||
}
|
||||
|
||||
for (int y = 0; y < height; ++y) {
|
||||
for (int x = 0; x < width; ++x) {
|
||||
int idx = (y * width + x) * 4;
|
||||
outputBuffer[idx + 0] = 250;
|
||||
outputBuffer[idx + 1] = 250;
|
||||
outputBuffer[idx + 2] = 245;
|
||||
outputBuffer[idx + 3] = 255;
|
||||
}
|
||||
}
|
||||
|
||||
WasmRasterizer rasterizer(outputBuffer, width, height, scale);
|
||||
mockPage->getDisplayList().replay(rasterizer);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
void PdfEngineFacade::freeDocument(int docHandle) {
|
||||
m_documents.erase(docHandle);
|
||||
}
|
||||
|
||||
const char* PdfEngineFacade::buildInfo() {
|
||||
return "PdfEngine WASM Facade (Phase 1/2 Enabled)";
|
||||
}
|
||||
|
||||
bool PdfEngineFacade::hasSkia() {
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
namespace {
|
||||
std::string fontInfosToJsonLocal(const std::vector<pdfengine::FontInfo>& fonts) {
|
||||
std::string json = "[";
|
||||
for (size_t i = 0; i < fonts.size(); ++i) {
|
||||
const auto& f = fonts[i];
|
||||
if (i > 0) json += ",";
|
||||
json += "{";
|
||||
json += "\"fontName\":\"" + f.fontName + "\",";
|
||||
json += "\"type\":\"" + f.type + "\",";
|
||||
json += "\"isEmbedded\":" + std::string(f.isEmbedded ? "true" : "false") + ",";
|
||||
json += "\"isSubset\":" + std::string(f.isSubset ? "true" : "false") + ",";
|
||||
json += "\"isVertical\":" + std::string(f.isVertical ? "true" : "false") + ",";
|
||||
json += "\"encoding\":\"" + f.encoding + "\",";
|
||||
json += "\"hasToUnicode\":" + std::string(f.hasToUnicode ? "true" : "false") + ",";
|
||||
json += "\"cmapName\":\"" + f.cmapName + "\",";
|
||||
json += "\"cidSystemInfo\":\"" + f.cidSystemInfo + "\",";
|
||||
json += "\"subsetTag\":\"" + f.subsetTag + "\",";
|
||||
json += "\"sourceType\":\"" + f.sourceType + "\",";
|
||||
json += "\"substitutedFrom\":\"" + f.substitutedFrom + "\",";
|
||||
json += "\"substitutedTo\":\"" + f.substitutedTo + "\",";
|
||||
json += "\"normalizedFamily\":\"" + f.normalizedFamily + "\",";
|
||||
json += "\"internalFontId\":\"" + f.internalFontId + "\",";
|
||||
json += "\"ascent\":" + std::to_string(f.ascent) + ",";
|
||||
json += "\"descent\":" + std::to_string(f.descent) + ",";
|
||||
json += "\"capHeight\":" + std::to_string(f.capHeight);
|
||||
json += "}";
|
||||
}
|
||||
json += "]";
|
||||
return json;
|
||||
}
|
||||
|
||||
std::string escapeJsonString(const std::string& input) {
|
||||
std::string output;
|
||||
for (char c : input) {
|
||||
if (c == '"') output += "\\\"";
|
||||
else if (c == '\\') output += "\\\\";
|
||||
else if (c == '\b') output += "\\b";
|
||||
else if (c == '\f') output += "\\f";
|
||||
else if (c == '\n') output += "\\n";
|
||||
else if (c == '\r') output += "\\r";
|
||||
else if (c == '\t') output += "\\t";
|
||||
else if (static_cast<unsigned char>(c) < 32) {
|
||||
char buf[16];
|
||||
snprintf(buf, sizeof(buf), "\\u%04x", c);
|
||||
output += buf;
|
||||
} else {
|
||||
output += c;
|
||||
}
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
std::string glyphBoundsToJsonLocal(const std::vector<pdfengine::GlyphBounds>& glyphs) {
|
||||
std::string json = "[";
|
||||
for (size_t i = 0; i < glyphs.size(); ++i) {
|
||||
const auto& g = glyphs[i];
|
||||
if (i > 0) json += ",";
|
||||
json += "{";
|
||||
json += "\"text\":\"" + escapeJsonString(g.text) + "\",";
|
||||
json += "\"x\":" + std::to_string(g.x) + ",";
|
||||
json += "\"y\":" + std::to_string(g.y) + ",";
|
||||
json += "\"w\":" + std::to_string(g.w) + ",";
|
||||
json += "\"h\":" + std::to_string(g.h) + ",";
|
||||
json += "\"fontSize\":" + std::to_string(g.fontSize);
|
||||
json += "}";
|
||||
}
|
||||
json += "]";
|
||||
return json;
|
||||
}
|
||||
}
|
||||
|
||||
std::string PdfEngineFacade::getDocumentFonts(int docHandle, int startPage, int endPage) {
|
||||
auto it = m_documents.find(docHandle);
|
||||
if (it == m_documents.end()) {
|
||||
return "[]";
|
||||
}
|
||||
auto result = it->second->getFonts(startPage, endPage);
|
||||
if (!result.has_value()) {
|
||||
return "[]";
|
||||
}
|
||||
return fontInfosToJsonLocal(*result);
|
||||
}
|
||||
|
||||
std::string PdfEngineFacade::getPageFonts(int docHandle, int pageIndex) {
|
||||
auto it = m_documents.find(docHandle);
|
||||
if (it == m_documents.end()) {
|
||||
return "[]";
|
||||
}
|
||||
auto pageRes = it->second->getPage(pageIndex);
|
||||
if (!pageRes.has_value()) {
|
||||
return "[]";
|
||||
}
|
||||
auto result = (*pageRes)->getFonts();
|
||||
if (!result.has_value()) {
|
||||
return "[]";
|
||||
}
|
||||
return fontInfosToJsonLocal(*result);
|
||||
}
|
||||
|
||||
std::string PdfEngineFacade::getPageTextJson(int docHandle, int pageIndex) {
|
||||
auto it = m_documents.find(docHandle);
|
||||
if (it == m_documents.end()) {
|
||||
return "[]";
|
||||
}
|
||||
auto pageRes = it->second->getPage(pageIndex);
|
||||
if (!pageRes.has_value()) {
|
||||
return "[]";
|
||||
}
|
||||
auto result = (*pageRes)->extractTextWithBounds();
|
||||
if (!result.has_value()) {
|
||||
return "[]";
|
||||
}
|
||||
return glyphBoundsToJsonLocal(*result);
|
||||
}
|
||||
@@ -1,80 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
#include <pdfengine/pdf_document.hpp>
|
||||
#include <pdfengine/display_list.hpp>
|
||||
#include <vector>
|
||||
#include <unordered_map>
|
||||
#include <memory>
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
|
||||
class WasmMockPage : public pdfengine::PdfPage {
|
||||
public:
|
||||
explicit WasmMockPage(int pageIndex);
|
||||
~WasmMockPage() override = default;
|
||||
|
||||
[[nodiscard]] double width() const noexcept override { return 800.0; }
|
||||
[[nodiscard]] double height() const noexcept override { return 1100.0; }
|
||||
|
||||
[[nodiscard]] std::expected<pdfengine::PageImage, pdfengine::EngineError> render(int dpi = 96) const override;
|
||||
[[nodiscard]] std::expected<std::string, pdfengine::EngineError> extractText() const override;
|
||||
[[nodiscard]] std::expected<std::vector<pdfengine::GlyphBounds>, pdfengine::EngineError> extractTextWithBounds() const override;
|
||||
[[nodiscard]] std::expected<pdfengine::PageModel, pdfengine::EngineError> extractDocumentModel() 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<pdfengine::PdfPage::AnnotationInfo>, pdfengine::EngineError> extractAnnotations() 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::Point2D deviceToPage(const pdfengine::DevicePoint& devicePoint, int deviceWidth, int deviceHeight, int rotate = 0) const noexcept override;
|
||||
|
||||
[[nodiscard]] const pdfengine::DisplayList& getDisplayList() const { return m_displayList; }
|
||||
|
||||
private:
|
||||
int m_pageIndex;
|
||||
pdfengine::DisplayList m_displayList;
|
||||
};
|
||||
|
||||
class WasmMockDocument : public pdfengine::PdfDocument {
|
||||
public:
|
||||
WasmMockDocument(int pageCount, std::vector<uint8_t> data);
|
||||
~WasmMockDocument() override = default;
|
||||
|
||||
[[nodiscard]] int pageCount() const noexcept override { return m_pageCount; }
|
||||
[[nodiscard]] pdfengine::DocumentMetadata metadata() const noexcept override;
|
||||
[[nodiscard]] std::expected<std::vector<OutlineItem>, pdfengine::EngineError> extractOutline() const override { return std::vector<OutlineItem>{}; }
|
||||
[[nodiscard]] std::expected<std::shared_ptr<pdfengine::PdfPage>, pdfengine::EngineError> getPage(int pageIndex) override;
|
||||
[[nodiscard]] std::expected<std::vector<pdfengine::FontInfo>, pdfengine::EngineError> getFonts(int startPage = 0, int endPage = -1) const override;
|
||||
|
||||
std::expected<void, pdfengine::EngineError> applyEdits(const std::string& editsJson) override;
|
||||
[[nodiscard]] std::expected<std::vector<uint8_t>, pdfengine::EngineError> saveIncremental() const override;
|
||||
[[nodiscard]] std::expected<std::vector<uint8_t>, pdfengine::EngineError> getFontData(const std::string& internalFontId) const override;
|
||||
[[nodiscard]] std::expected<std::shared_ptr<pdfengine::fonts::pdf_fonts::Font>, std::string> getResolvedFont(const pdfengine::FontInfo& fontInfo) override;
|
||||
[[nodiscard]] std::expected<std::vector<uint8_t>, pdfengine::EngineError> saveFull() const override;
|
||||
|
||||
private:
|
||||
int m_pageCount;
|
||||
std::vector<uint8_t> m_data;
|
||||
std::unordered_map<int, std::shared_ptr<WasmMockPage>> m_pages;
|
||||
};
|
||||
|
||||
class PdfEngineFacade {
|
||||
public:
|
||||
PdfEngineFacade();
|
||||
~PdfEngineFacade();
|
||||
|
||||
int loadDocument(const uint8_t* buffer, int size);
|
||||
bool renderPage(int docHandle, int pageIndex, float scale, uint8_t* outputBuffer, int width, int height);
|
||||
void freeDocument(int docHandle);
|
||||
|
||||
std::string getDocumentFonts(int docHandle, int startPage, int endPage);
|
||||
std::string getPageFonts(int docHandle, int pageIndex);
|
||||
std::string getPageTextJson(int docHandle, int pageIndex);
|
||||
|
||||
static const char* buildInfo();
|
||||
static bool hasSkia();
|
||||
|
||||
private:
|
||||
int m_nextHandle;
|
||||
std::unordered_map<int, std::shared_ptr<pdfengine::PdfDocument>> m_documents;
|
||||
};
|
||||
@@ -10,11 +10,16 @@
|
||||
namespace {
|
||||
struct DocEntry {
|
||||
std::shared_ptr<pdfengine::PdfDocument> doc;
|
||||
std::vector<uint8_t> bytes;
|
||||
std::vector<uint8_t> bytes;
|
||||
std::unordered_map<std::string, std::vector<uint8_t>> auxFonts;
|
||||
};
|
||||
std::unordered_map<int, DocEntry> g_docs;
|
||||
int g_nextHandle = 1;
|
||||
|
||||
void applyAuxFonts(pdfengine::PdfDocument& fresh, const DocEntry& entry) {
|
||||
for (const auto& [fid, bytes] : entry.auxFonts) fresh.registerAuxFont(fid, bytes);
|
||||
}
|
||||
|
||||
std::vector<uint8_t> g_lastPng;
|
||||
int g_lastW = 0, g_lastH = 0;
|
||||
std::string g_lastLayout;
|
||||
@@ -63,6 +68,7 @@ EMSCRIPTEN_KEEPALIVE int previewRender(int handle, int pageIndex, int dpi, const
|
||||
if (it == g_docs.end()) return -1;
|
||||
auto fresh = pdfengine::PdfDocument::loadFromMemory(it->second.bytes, "");
|
||||
if (!fresh) return -1;
|
||||
applyAuxFonts(**fresh, it->second);
|
||||
g_lastLayout.clear();
|
||||
if (editsJson && editsJson[0]) {
|
||||
auto r = (*fresh)->applyEdits(editsJson);
|
||||
@@ -78,6 +84,7 @@ EMSCRIPTEN_KEEPALIVE int previewRenderRegion(int handle, int pageIndex, int dpi,
|
||||
if (it == g_docs.end()) return -1;
|
||||
auto fresh = pdfengine::PdfDocument::loadFromMemory(it->second.bytes, "");
|
||||
if (!fresh) return -1;
|
||||
applyAuxFonts(**fresh, it->second);
|
||||
g_lastLayout.clear();
|
||||
if (editsJson && editsJson[0]) {
|
||||
auto r = (*fresh)->applyEdits(editsJson);
|
||||
@@ -100,6 +107,7 @@ EMSCRIPTEN_KEEPALIVE int previewRenderPaginated(int handle, int pageIndex, int d
|
||||
if (it == g_docs.end()) return -1;
|
||||
auto fresh = pdfengine::PdfDocument::loadFromMemory(it->second.bytes, "");
|
||||
if (!fresh) return -1;
|
||||
applyAuxFonts(**fresh, it->second);
|
||||
g_lastLayout.clear();
|
||||
g_regions.clear();
|
||||
bool overflowed = false;
|
||||
@@ -149,10 +157,11 @@ EMSCRIPTEN_KEEPALIVE void registerAuxFont(int handle, const char* fid, const uin
|
||||
if (it == g_docs.end() || !fid || !data || size <= 0) return;
|
||||
std::vector<uint8_t> bytes(data, data + size);
|
||||
it->second.doc->registerAuxFont(std::string(fid), bytes);
|
||||
it->second.auxFonts[std::string(fid)] = std::move(bytes);
|
||||
}
|
||||
|
||||
EMSCRIPTEN_KEEPALIVE void freeDocument(int handle) { g_docs.erase(handle); }
|
||||
|
||||
EMSCRIPTEN_KEEPALIVE const char* engineBuildInfo() { return "pdfengine-wasm+pdfium"; }
|
||||
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user