merge conflict solve

This commit is contained in:
saqib mir
2026-06-11 19:36:38 +05:30
80 changed files with 5511 additions and 164 deletions
+37
View File
@@ -18,6 +18,7 @@ add_library(pdfengine STATIC
src/parser/pdfium_document.cpp
src/parser/content_stream_parser.cpp
src/parser/decoration_builder.cpp
src/text/selection.cpp
src/fonts/face/font_face.cpp
src/fonts/face/free_type_manager.cpp
src/fonts/loader/font_resolver.cpp
@@ -73,3 +74,39 @@ pdfengine_enable_sanitizers(pdfengine)
if(PDFENGINE_BUILD_TESTS)
add_subdirectory(tests)
endif()
# --- Fuzzing -----------------------------------------------------------------
# Coverage-instrument the engine and build the libFuzzer harness. Requires Clang
# (enforced at the top level). PDFENGINE_FUZZ_SANITIZERS lets callers drop ASan
# when the PDFium static lib isn't ASan-compatible (coverage-only fuzzing).
if(PDFENGINE_FUZZING)
set(PDFENGINE_FUZZ_SANITIZERS "fuzzer,address,undefined" CACHE STRING
"Sanitizer set for fuzzing (e.g. 'fuzzer,address,undefined' or just 'fuzzer')")
# Split the set into the libFuzzer driver ('fuzzer', linked only into the
# harness exe) and the runtime sanitizers (address/undefined/...), which must
# instrument the engine library itself to catch bugs in its code.
string(REPLACE "," ";" _fuzz_sans "${PDFENGINE_FUZZ_SANITIZERS}")
set(_fuzz_runtime_sans "")
foreach(_s IN LISTS _fuzz_sans)
if(NOT _s STREQUAL "fuzzer")
list(APPEND _fuzz_runtime_sans "${_s}")
endif()
endforeach()
list(JOIN _fuzz_runtime_sans "," _fuzz_runtime_str)
# Coverage-instrument the engine; apply ASan/UBSan to it too so its own code
# is checked, not just the harness.
target_compile_options(pdfengine PRIVATE -fsanitize=fuzzer-no-link -fno-omit-frame-pointer)
if(_fuzz_runtime_str)
target_compile_options(pdfengine PRIVATE -fsanitize=${_fuzz_runtime_str})
target_link_options(pdfengine PUBLIC -fsanitize=${_fuzz_runtime_str})
endif()
add_executable(pdfengine_fuzz fuzz/fuzz_load.cpp)
target_link_libraries(pdfengine_fuzz PRIVATE pdfengine)
target_compile_options(pdfengine_fuzz PRIVATE
-fsanitize=${PDFENGINE_FUZZ_SANITIZERS} -fno-omit-frame-pointer)
target_link_options(pdfengine_fuzz PRIVATE
-fsanitize=${PDFENGINE_FUZZ_SANITIZERS})
endif()
+65
View File
@@ -0,0 +1,65 @@
# Fuzzing the PDF engine
`fuzz_load.cpp` is a libFuzzer harness that drives the full
**load → metadata → outline → render → text → annotations → hit-test → select**
path with arbitrary bytes. Combined with AddressSanitizer it surfaces crashes,
OOMs, and undefined behaviour in the parsing and rendering code.
Resource ceilings from `pdfengine/hardened_limits.h` keep the fuzzer focused on
logic bugs instead of trivial out-of-memory inputs (and those same ceilings now
guard the production render path against integer-overflow / OOM).
## Linux (primary)
Clang + libFuzzer + ASan is best supported on Linux. PDFium must be built with
the same Clang toolchain (so ASan is consistent across the static lib).
```bash
# Full ASan + coverage fuzzer
cmake --preset fuzz-linux
cmake --build --preset fuzz-linux
# If your PDFium static lib is NOT ASan-instrumented, use coverage-only:
cmake --preset fuzz-linux-nosan
cmake --build --preset fuzz-linux-nosan
# Run it against the downloaded corpus as a seed set
python scripts/fetch_corpus.py # populates corpus/fuzz/ (gitignored)
mkdir -p engine/fuzz/artifacts
./out/build/fuzz-linux/bin/pdfengine_fuzz \
-artifact_prefix=engine/fuzz/artifacts/ \
corpus/fuzz/ corpus/
```
`corpus/fuzz/` and `corpus/` are passed as seed corpora; new coverage-expanding
inputs are written back into the first directory. Crashes land in
`engine/fuzz/artifacts/` (gitignored).
## Windows (clang-cl)
Native Windows fuzzing needs a Clang toolchain *and* a PDFium static lib built
with the matching runtime. Configure with clang-cl and the existing
`x64-windows-static` triplet, then enable fuzzing:
```powershell
cmake -S . -B C:/Users/<you>/pdfeng-build/fuzz-win -G Ninja `
-DCMAKE_C_COMPILER=clang-cl -DCMAKE_CXX_COMPILER=clang-cl `
-DVCPKG_TARGET_TRIPLET=x64-windows-static `
-DPDFENGINE_FUZZING=ON -DPDFENGINE_WITH_PDFIUM=ON `
-DPDFENGINE_FUZZ_SANITIZERS=fuzzer `
--toolchain "$env:VCPKG_ROOT/scripts/buildsystems/vcpkg.cmake"
cmake --build C:/Users/<you>/pdfeng-build/fuzz-win
```
Use `PDFENGINE_FUZZ_SANITIZERS=fuzzer` (coverage-only) on Windows unless the
whole dependency chain — including PDFium — is ASan-built, since mixing an
ASan binary with a non-ASan MSVC static lib does not link cleanly.
## Reproducing a crash
```bash
./pdfengine_fuzz engine/fuzz/artifacts/crash-<hash>
```
The ASan report points at the offending allocation/access; the input file is the
minimal reproducer (run with `-minimize_crash=1` to shrink further).
+50
View File
@@ -0,0 +1,50 @@
// libFuzzer entry point: drive the full load → inspect → render → select path
// with arbitrary bytes, so the fuzzer can find crashes, OOMs, and UB in the
// PDF parsing and rendering code.
//
// Build with a Clang toolchain via the `fuzz-linux` preset (see engine/fuzz/
// README.md). Every operation is wrapped so a clean error never aborts the run —
// only a real crash (caught by the sanitizer) should stop the fuzzer.
#include "pdfengine/hardened_limits.h"
#include "pdfengine/pdf_document.hpp"
#include <cstddef>
#include <cstdint>
#include <vector>
extern "C" int LLVMFuzzerTestOneInput(const uint8_t* data, size_t size) {
using namespace pdfengine;
if (!limits::documentSizeOk(size)) return 0;
std::vector<uint8_t> bytes(data, data + size);
auto doc = PdfDocument::loadFromMemory(bytes, "");
if (!doc) return 0;
PdfDocument& d = **doc;
const int pages = d.pageCount();
if (!limits::pageCountOk(pages)) return 0;
(void)d.metadata();
(void)d.extractOutline();
const int limit = pages < 3 ? pages : 3; // bound work per input
for (int i = 0; i < limit; ++i) {
auto page = d.getPage(i);
if (!page) continue;
PdfPage& p = **page;
(void)p.render(72);
(void)p.extractText();
(void)p.extractAnnotations();
auto glyphs = p.orderedGlyphs();
if (glyphs && !glyphs->empty()) {
const auto& g = glyphs->front();
(void)p.hitGlyph(g.x, g.y);
(void)p.selectRange(g.x, g.y, g.x + 50.0, g.y + 20.0);
}
}
return 0;
}
@@ -0,0 +1,67 @@
// Resource limits for hardening against malicious / malformed PDFs.
//
// These guard the allocation-sizing arithmetic in the load and render paths
// against integer overflow and pathological out-of-memory inputs (a 2-billion-pt
// page, a million-page document, a multi-gigabyte raster). The ceilings are set
// far above anything a legitimate document needs, so enforcing them never
// rejects real files — they exist purely to turn "crash / OOM" into a clean,
// recoverable error, which is exactly what a fuzzer needs to make progress.
//
// Header-only and dependency-free so the fuzz harness and the engine share one
// source of truth.
#ifndef PDFENGINE_HARDENED_LIMITS_H
#define PDFENGINE_HARDENED_LIMITS_H
#include <cstdint>
namespace pdfengine::limits {
// Largest input document we will even attempt to parse (1 GiB).
inline constexpr std::uint64_t kMaxDocumentBytes = 1ull << 30;
// PDF hard-caps a page at 14,400 user units (200 in) per side; allow a very
// generous multiple of that to tolerate odd-but-real documents.
inline constexpr double kMaxPageDimensionPt = 200'000.0; // ~2,777 inches
// No legitimate document has this many pages; stops runaway iteration.
inline constexpr int kMaxPageCount = 100'000;
// MAX_OBJECTS: ceiling on the number of content objects on a single page. Guards
// against content-stream "object bombs" that would explode parsing/rendering
// time and memory. No real page comes near this.
inline constexpr int kMaxObjects = 5'000'000;
// Cap a single rasterised page at ~256 megapixels (≈1 GiB at 4 bytes/px). At
// 96 dpi that is roughly a 16k × 16k page — well beyond any real render.
inline constexpr std::int64_t kMaxRasterPixels = 256ll * 1024 * 1024;
// True if a raw page size (in points) is sane to render.
inline constexpr bool pageDimensionsOk(double widthPt, double heightPt) noexcept {
return widthPt > 0.0 && heightPt > 0.0 && widthPt <= kMaxPageDimensionPt &&
heightPt <= kMaxPageDimensionPt;
}
// True if a target raster (in pixels) fits the pixel budget without overflowing
// the width*height*4 byte computation.
inline constexpr bool rasterSizeOk(std::int64_t widthPx, std::int64_t heightPx) noexcept {
if (widthPx <= 0 || heightPx <= 0) return false;
if (widthPx > kMaxRasterPixels || heightPx > kMaxRasterPixels) return false;
return widthPx * heightPx <= kMaxRasterPixels;
}
inline constexpr bool documentSizeOk(std::uint64_t bytes) noexcept {
return bytes > 0 && bytes <= kMaxDocumentBytes;
}
inline constexpr bool pageCountOk(int pages) noexcept {
return pages >= 0 && pages <= kMaxPageCount;
}
inline constexpr bool objectCountOk(int objects) noexcept {
return objects >= 0 && objects <= kMaxObjects;
}
} // namespace pdfengine::limits
#endif // PDFENGINE_HARDENED_LIMITS_H
+32 -1
View File
@@ -53,6 +53,23 @@ struct GlyphBounds {
double fontSize;
};
// Result of a point hit-test against a page's glyphs (page-point space, top-left
// origin — the same space GlyphBounds use).
struct HitResult {
int glyphIndex = -1; // glyph directly under the point in reading order, -1 if none
int caret = 0; // nearest caret position (0..N) for selection anchoring
int line = -1; // line band the point resolved to, -1 if the page has no text
};
// A resolved text selection: a half-open glyph range plus its reconstructed text
// and per-line union rectangles (for drawing the selection).
struct TextSelection {
int startGlyph = 0; // inclusive, reading order
int endGlyph = 0; // exclusive
std::string text;
std::vector<GlyphBounds> rects; // per-line union rects (text field left empty)
};
struct FontInfo {
std::string fontName;
std::string type; // "TrueType", "Type1", "CIDFontType0", "CIDFontType2"
@@ -137,7 +154,21 @@ public:
[[nodiscard]] virtual std::expected<std::string, EngineError> extractText() const = 0;
[[nodiscard]] virtual std::expected<std::vector<GlyphBounds>, EngineError> extractTextWithBounds() const = 0;
// Adobe-grade hit-testing & selection, layered on extractTextWithBounds().
// Concrete (non-virtual) so every page implementation gets them for free.
// Coordinates are in page-point space (top-left origin), matching GlyphBounds.
// Glyphs in reading order: clustered into lines top-to-bottom, left-to-right.
[[nodiscard]] std::expected<std::vector<GlyphBounds>, EngineError> orderedGlyphs() const;
// Nearest glyph/caret to a point.
[[nodiscard]] std::expected<HitResult, EngineError> hitGlyph(double x, double y) const;
// Reading-order selection between two points (e.g. drag anchor → focus).
[[nodiscard]] std::expected<TextSelection, EngineError>
selectRange(double ax, double ay, double bx, double by) const;
[[nodiscard]] virtual std::expected<PageModel, EngineError> extractDocumentModel() const = 0;
[[nodiscard]] virtual std::expected<std::vector<FontInfo>, EngineError> getFonts() const = 0;
+315 -34
View File
@@ -6,12 +6,14 @@
#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/shaping/hb_shaper.hpp"
#include "decoration_builder.hpp"
@@ -625,14 +627,28 @@ PdfDocument::loadFromMemory(const std::vector<uint8_t>& data, const std::string&
if (data.empty()) {
return std::unexpected(EngineError::InvalidFormat);
}
// Hardened load-path guard (MAX_STREAM_SIZE): refuse absurdly large inputs
// before handing them to the parser.
if (!limits::documentSizeOk(data.size())) {
spdlog::error("Refusing to load PDF: {} bytes exceeds hardened limit ({} bytes)",
data.size(), limits::kMaxDocumentBytes);
return std::unexpected(EngineError::InvalidFormat);
}
std::vector<uint8_t> buffer_copy = data;
FPDF_DOCUMENT doc = FPDF_LoadMemDocument(buffer_copy.data(), static_cast<int>(buffer_copy.size()),
FPDF_DOCUMENT doc = FPDF_LoadMemDocument(buffer_copy.data(), static_cast<int>(buffer_copy.size()),
password.empty() ? nullptr : password.c_str());
if (!doc) {
auto err = FPDF_GetLastError();
spdlog::error("Failed to load PDF from memory (error code: {})", err);
return std::unexpected(mapPdfiumError(err, !password.empty()));
}
// Reject documents with an implausible page count (runaway iteration guard).
if (!limits::pageCountOk(FPDF_GetPageCount(doc))) {
spdlog::error("Refusing to load PDF: page count exceeds hardened limit ({})",
limits::kMaxPageCount);
FPDF_CloseDocument(doc);
return std::unexpected(EngineError::InvalidFormat);
}
return std::make_shared<parser::PdfiumDocument>(doc, std::move(buffer_copy));
#else
(void)data;
@@ -646,8 +662,9 @@ PdfDocument::loadFromMemory(const std::vector<uint8_t>& data, const std::string&
namespace pdfengine::parser {
PdfiumPage::PdfiumPage(NativeDocHandle docHandle, NativePageHandle pageHandle, int pageIndex)
: doc_(docHandle), page_(pageHandle), pageIndex_(pageIndex) {
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() {
@@ -673,10 +690,13 @@ PdfiumPage& PdfiumPage::operator=(PdfiumPage&& other) noexcept {
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;
@@ -706,10 +726,27 @@ std::expected<PageImage, EngineError> PdfiumPage::render(int dpi) const {
return std::unexpected(EngineError::Unknown);
}
// Reject pathological page sizes before sizing the raster, so a malicious
// page can't overflow w*h*4 or trigger a multi-gigabyte allocation.
if (!limits::pageDimensionsOk(width(), height())) {
return std::unexpected(EngineError::RenderFailed);
}
// MAX_OBJECTS guard: refuse to render a content-stream "object bomb".
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);
@@ -719,6 +756,11 @@ std::expected<PageImage, EngineError> PdfiumPage::render(int dpi) const {
FPDF_RenderPageBitmap(bitmap, page_, 0, 0, w, h, 0, 0);
// Note: form-field widgets are rendered as an interactive HTML overlay in the
// frontend (AnnotationLayer), not baked here — keeps them editable and avoids
// double-rendering. `update_field` regenerates the field /AP so exported PDFs
// (and external viewers) still show filled values.
const auto* buffer = static_cast<const uint8_t*>(FPDFBitmap_GetBuffer(bitmap));
int stride = FPDFBitmap_GetStride(bitmap);
@@ -1621,7 +1663,9 @@ std::expected<std::shared_ptr<PdfPage>, EngineError> PdfiumDocument::getPage(int
std::lock_guard<std::mutex> lock(pageCacheMutex_);
auto it = pageCache_.find(pageIndex);
if (it != pageCache_.end()) {
return it->second;
if (auto cached = it->second.lock()) {
return cached;
}
}
}
@@ -1630,7 +1674,11 @@ std::expected<std::shared_ptr<PdfPage>, EngineError> PdfiumDocument::getPage(int
return std::unexpected(EngineError::Unknown);
}
auto pageObj = std::make_shared<PdfiumPage>(doc_, pageHandle, pageIndex);
// The page co-owns this document (shared_from_this) so the native
// FPDF_DOCUMENT outlives every page derived from it.
auto pageObj = std::make_shared<PdfiumPage>(
doc_, pageHandle, pageIndex,
std::static_pointer_cast<PdfiumDocument>(shared_from_this()));
{
std::lock_guard<std::mutex> lock(pageCacheMutex_);
pageCache_[pageIndex] = pageObj;
@@ -2327,6 +2375,182 @@ std::expected<void, EngineError> PdfiumDocument::applyEdits(const std::string& e
return std::unexpected(EngineError::Unknown);
}
FPDF_ClosePage(page);
} else if (type == "edit_text") {
// Rewrite an EXISTING text object in place (true content editing).
// Locate the target text object by bbox (no stable object id exists),
// replace its text, and horizontally squeeze it to stay within the
// original line bounds (line-level reflow only).
if (!op.contains("data") || !op["data"].is_object()) {
spdlog::error("edit_text 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 newText = data.value("newText", "");
double fontSize = data.value("fontSize", 0.0);
const bool hasColor = data.contains("color") && data["color"].is_string();
std::string color = hasColor ? data["color"].get<std::string>() : "#000000";
std::string fallbackFont = data.value("fallbackFont", "");
if (newText.empty()) {
spdlog::warn("edit_text: empty newText — skipping (use redaction to delete text)");
continue;
}
if (width <= 0.0 || height <= 0.0) {
spdlog::error("edit_text: invalid target bbox ({}x{})", width, height);
return std::unexpected(EngineError::InvalidFormat);
}
FPDF_PAGE page = FPDF_LoadPage(doc_, pageIndex);
if (!page) {
spdlog::error("Failed to load page index {} for edit_text", pageIndex);
return std::unexpected(EngineError::Unknown);
}
// --- locate best-matching text object by bbox overlap ---
const double tl = x, tb = y, tr = x + width, tt = y + height;
const double targetArea = width * height;
FPDF_PAGEOBJECT best = nullptr;
double bestScore = 0.0, secondScore = 0.0;
float bL = 0, bB = 0, bR = 0, bT = 0;
const int count = FPDFPage_CountObjects(page);
for (int i = 0; i < count; ++i) {
FPDF_PAGEOBJECT obj = FPDFPage_GetObject(page, i);
if (!obj || FPDFPageObj_GetType(obj) != FPDF_PAGEOBJ_TEXT) continue;
float l = 0, b = 0, r = 0, t = 0;
if (!FPDFPageObj_GetBounds(obj, &l, &b, &r, &t)) continue;
const double ix = (std::max)(0.0, (std::min)(static_cast<double>(r), tr) - (std::max)(static_cast<double>(l), tl));
const double iy = (std::max)(0.0, (std::min)(static_cast<double>(t), tt) - (std::max)(static_cast<double>(b), tb));
const double inter = ix * iy;
if (inter <= 0.0) continue;
const double objArea = (std::max)(1e-6, static_cast<double>(r - l) * static_cast<double>(t - b));
const double score = (std::max)(inter / (targetArea + objArea - inter), inter / objArea);
if (score > bestScore) {
secondScore = bestScore;
bestScore = score;
best = obj; bL = l; bB = b; bR = r; bT = t;
} else if (score > secondScore) {
secondScore = score;
}
}
if (!best || bestScore < 0.30) {
spdlog::error("edit_text: no text object matches the target bbox (best score {:.3f})", bestScore);
FPDF_ClosePage(page);
return std::unexpected(EngineError::InvalidFormat);
}
if (bestScore - secondScore < 0.10) {
spdlog::error("edit_text: ambiguous target — overlapping text objects (best {:.3f}, second {:.3f})",
bestScore, secondScore);
FPDF_ClosePage(page);
return std::unexpected(EngineError::InvalidFormat);
}
// --- capture original geometry BEFORE mutating ---
const double origLeft = bL, origBottom = bB, origWidth = static_cast<double>(bR - bL);
if (fontSize <= 0.0) fontSize = static_cast<double>(bT - bB);
if (fontSize <= 0.0) fontSize = 12.0;
FS_MATRIX m0{1, 0, 0, 1, 0, 0};
FPDFPageObj_GetMatrix(best, &m0);
const bool axisAligned = (std::abs(m0.b) < 1e-6 && std::abs(m0.c) < 1e-6);
// --- glyph-coverage check on the object's own font ---
auto decodeUtf8 = [](const std::string& s) {
std::vector<uint32_t> cps;
for (size_t i = 0; i < s.size();) {
unsigned char c = s[i];
uint32_t 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 >= s.size()) break;
for (size_t j = 1; j <= extra; ++j) cp = (cp << 6) | (s[i + j] & 0x3F);
cps.push_back(cp); i += extra + 1;
}
return cps;
};
bool needFallback = false;
FPDF_FONT objFont = FPDFTextObj_GetFont(best);
if (!fallbackFont.empty() && objFont) {
for (uint32_t cp : decodeUtf8(newText)) {
if (cp == ' ' || cp == '\t' || cp == '\n' || cp == '\r') continue;
float w = 0.0f;
if (!FPDFFont_GetGlyphWidth(objFont, cp, static_cast<float>(fontSize), &w) || w <= 0.0f) {
needFallback = true;
break;
}
}
}
FPDF_PAGEOBJECT target = best;
auto utf16 = utf8_to_utf16le(newText);
if (needFallback) {
// Original font can't render the new glyphs — recreate in a
// standard font at the same position (Acrobat-style substitution).
spdlog::info("edit_text: glyph coverage gap, substituting font '{}'", fallbackFont);
FPDFPage_RemoveObject(page, best);
FPDFPageObj_Destroy(best);
FPDF_FONT font = FPDFText_LoadStandardFont(doc_, fallbackFont.c_str());
if (!font) font = FPDFText_LoadStandardFont(doc_, "Helvetica");
target = FPDFPageObj_CreateTextObj(doc_, font, static_cast<float>(fontSize));
if (!target) {
FPDF_ClosePage(page);
return std::unexpected(EngineError::Unknown);
}
unsigned int r = 0, g = 0, b = 0;
parseHexColor(color, r, g, b);
FPDFPageObj_SetFillColor(target, r, g, b, 255);
if (!FPDFText_SetText(target, reinterpret_cast<FPDF_WIDESTRING>(utf16.data()))) {
FPDFPageObj_Destroy(target);
FPDF_ClosePage(page);
return std::unexpected(EngineError::Unknown);
}
FPDFPageObj_Transform(target, 1.0, 0.0, 0.0, 1.0, origLeft, origBottom);
FPDFPage_InsertObject(page, target);
} else {
if (hasColor) {
unsigned int r = 0, g = 0, b = 0;
parseHexColor(color, r, g, b);
FPDFPageObj_SetFillColor(target, r, g, b, 255);
}
if (!FPDFText_SetText(target, reinterpret_cast<FPDF_WIDESTRING>(utf16.data()))) {
spdlog::error("edit_text: FPDFText_SetText failed on the existing object");
FPDF_ClosePage(page);
return std::unexpected(EngineError::Unknown);
}
}
// Regenerate so the new text's bounds are accurate, then squeeze to fit.
if (!FPDFPage_GenerateContent(page)) {
spdlog::error("edit_text: FPDFPage_GenerateContent failed");
FPDF_ClosePage(page);
return std::unexpected(EngineError::Unknown);
}
if (axisAligned && origWidth > 0.0) {
float nl = 0, nb = 0, nr = 0, nt = 0;
if (FPDFPageObj_GetBounds(target, &nl, &nb, &nr, &nt)) {
const double newWidth = static_cast<double>(nr - nl);
if (newWidth > origWidth && newWidth > 0.0) {
const double scaleX = origWidth / newWidth;
// Horizontal compression about the original left edge —
// keeps the line start, baseline, and font size, never overflows.
FPDFPageObj_Transform(target, scaleX, 0.0, 0.0, 1.0,
origLeft * (1.0 - scaleX), 0.0);
if (!FPDFPage_GenerateContent(page)) {
spdlog::error("edit_text: FPDFPage_GenerateContent failed after squeeze");
FPDF_ClosePage(page);
return std::unexpected(EngineError::Unknown);
}
}
}
}
FPDF_ClosePage(page);
} else if (type == "update_field") {
if (!op.contains("data") || !op["data"].is_object()) {
@@ -2334,43 +2558,100 @@ std::expected<void, EngineError> PdfiumDocument::applyEdits(const std::string& e
return std::unexpected(EngineError::InvalidFormat);
}
auto data = op["data"];
std::string value;
if (data["value"].is_boolean()) {
value = data["value"].get<bool>() ? "Yes" : "Off";
} else if (data["value"].is_string()) {
value = data["value"].get<std::string>();
} else {
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 id = op.value("id", "");
int annotIndex = -1;
size_t lastUnderscore = id.find_last_of('_');
if (lastUnderscore != std::string::npos) {
try {
annotIndex = std::stoi(id.substr(lastUnderscore + 1));
} catch (...) {
annotIndex = -1;
}
// Field identity: prefer data.annotationId (NM or anno_<page>_<idx>),
// fall back to the op id (legacy) for compatibility.
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);
}
if (annotIndex >= 0) {
FPDF_PAGE page = FPDF_LoadPage(doc_, pageIndex);
if (!page) {
spdlog::error("Failed to load page index {} for field update", 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();
}
FPDF_ANNOTATION annot = FPDFPage_GetAnnot(page, annotIndex);
if (annot) {
auto utf16 = utf8_to_utf16le(value);
FPDFAnnot_SetStringValue(annot, "V", reinterpret_cast<FPDF_WIDESTRING>(utf16.data()));
FPDFPage_GenerateContent(page);
FPDFPage_CloseAnnot(annot);
}
FPDF_ClosePage(page);
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) {
// Checkbox / radio: /V + /AS (predefined on/off appearances render directly).
// NB: "Yes" is the common on-state; custom export values are a known v1 limitation.
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) {
// Choice (combo/listbox): select the option whose label matches the value.
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 {
// Text field: select-all + replace → form module regenerates the appearance.
FORM_SelectAllText(form, page);
auto u = utf8_to_utf16le(strVal);
FORM_ReplaceSelection(form, page, reinterpret_cast<FPDF_WIDESTRING>(u.data()));
}
FORM_ForceToKillFocus(form);
} else {
// Fallback (no form env): best-effort value set.
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);
} else if (type == "image_overlay") {
if (!op.contains("data") || !op["data"].is_object()) {
spdlog::error("image_overlay operation missing 'data' object");
+12 -2
View File
@@ -16,6 +16,8 @@ namespace pdfengine::fonts::loader { class FontResolver; }
namespace pdfengine::parser {
class PdfiumDocument; // a page keeps its owning document alive (see ownerDoc_)
#ifdef PDFENGINE_WITH_PDFIUM
using NativeDocHandle = FPDF_DOCUMENT;
using NativePageHandle = FPDF_PAGE;
@@ -28,7 +30,8 @@ using NativeTextHandle = void*;
class PdfiumPage : public PdfPage {
public:
PdfiumPage(NativeDocHandle docHandle, NativePageHandle pageHandle, int pageIndex);
PdfiumPage(NativeDocHandle docHandle, NativePageHandle pageHandle, int pageIndex,
std::shared_ptr<PdfiumDocument> owner = nullptr);
~PdfiumPage() override;
PdfiumPage(const PdfiumPage&) = delete;
@@ -58,6 +61,10 @@ private:
mutable NativeTextHandle textPage_ = nullptr;
int pageIndex_ = 0;
mutable std::mutex textMutex_;
// Keeps the owning document (and thus the native FPDF_DOCUMENT) alive for as
// long as this page exists, so page_/textPage_ can never dangle. Declared
// here (destroyed last) so it outlives the handle teardown in the destructor.
std::shared_ptr<PdfiumDocument> ownerDoc_;
#ifdef PDFENGINE_WITH_PDFIUM
mutable std::unordered_map<std::string, FPDF_FONT> fontHandleCache_;
#endif
@@ -103,7 +110,10 @@ private:
mutable std::unordered_map<std::string, std::vector<uint8_t>> fontDataCache_;
mutable int fontDataScannedPages_ = 0;
mutable std::unordered_map<int, std::shared_ptr<PdfPage>> pageCache_;
// Weak so the document never co-owns its pages: ownership runs page → document
// only. A cached entry is reused while a page is still referenced elsewhere,
// and lazily rebuilt once it expires.
mutable std::unordered_map<int, std::weak_ptr<PdfPage>> pageCache_;
mutable std::mutex pageCacheMutex_;
// Font Engine Bridge
+349
View File
@@ -0,0 +1,349 @@
// Adobe-grade hit-testing & text selection for PdfPage.
//
// Concrete implementations of PdfPage::orderedGlyphs / hitGlyph / selectRange,
// built on top of the (virtual) extractTextWithBounds(). All maths is in
// page-point space with a top-left origin — identical to the frontend
// TextSelectionModel, so engine and browser selections agree.
#include "pdfengine/pdf_document.hpp"
#include <algorithm>
#include <cctype>
#include <cmath>
#include <limits>
#include <map>
namespace pdfengine {
namespace {
struct OGlyph {
GlyphBounds g;
int line = 0;
double right = 0.0;
double bottom = 0.0;
double mid = 0.0; // horizontal centre
};
struct LineBand {
double top = 0.0;
double bottom = 0.0;
double mid = 0.0; // vertical centre of the first glyph
int start = 0; // inclusive glyph index
int end = 0; // exclusive glyph index
};
// Uniform 2D grid over glyph bounding boxes — a true spatial index for the
// point→glyph query, independent of line assignment. This is what makes
// hit-testing exact on overlapping/dense content (diacritics, multi-column,
// rotated runs) where a line-bucketed search would miss.
struct Grid {
double minX = 0.0, minY = 0.0, cell = 1.0;
int cols = 0, rows = 0;
std::vector<std::vector<int>> cells; // size cols*rows; glyph indices per cell
[[nodiscard]] bool empty() const { return cols == 0 || rows == 0; }
[[nodiscard]] int at(int c, int r) const { return r * cols + c; }
};
struct SelIndex {
std::vector<OGlyph> glyphs; // reading order
std::vector<LineBand> lines;
Grid grid;
};
bool isSpace(const std::string& t) {
if (t.empty()) return true;
for (unsigned char c : t)
if (!std::isspace(c)) return false;
return true;
}
// Cluster raw glyphs into lines (by vertical overlap) then sort each line L→R,
// flattening into a single reading-order array with cached line bands.
SelIndex buildIndex(const std::vector<GlyphBounds>& raw) {
SelIndex idx;
std::vector<GlyphBounds> clean;
clean.reserve(raw.size());
for (const auto& g : raw)
if (g.w >= 0 && g.h > 0) clean.push_back(g);
if (clean.empty()) return idx;
std::sort(clean.begin(), clean.end(), [](const GlyphBounds& a, const GlyphBounds& b) {
return (a.y + a.h / 2) < (b.y + b.h / 2);
});
std::vector<std::vector<GlyphBounds>> rows;
for (const auto& g : clean) {
const double gMid = g.y + g.h / 2;
bool placed = false;
if (!rows.empty()) {
auto& row = rows.back();
double top = std::numeric_limits<double>::max();
double bottom = std::numeric_limits<double>::lowest();
for (const auto& r : row) {
top = std::min(top, r.y);
bottom = std::max(bottom, r.y + r.h);
}
const double tol = g.h * 0.25;
if (gMid >= top - tol && gMid <= bottom + tol) {
row.push_back(g);
placed = true;
}
}
if (!placed) rows.push_back({g});
}
int running = 0;
for (auto& row : rows) {
std::sort(row.begin(), row.end(),
[](const GlyphBounds& a, const GlyphBounds& b) { return a.x < b.x; });
LineBand band;
band.start = running;
const int lineNo = static_cast<int>(idx.lines.size());
double top = std::numeric_limits<double>::max();
double bottom = std::numeric_limits<double>::lowest();
for (const auto& g : row) {
OGlyph og;
og.g = g;
og.line = lineNo;
og.right = g.x + g.w;
og.bottom = g.y + g.h;
og.mid = g.x + g.w / 2;
idx.glyphs.push_back(og);
top = std::min(top, g.y);
bottom = std::max(bottom, g.y + g.h);
running++;
}
band.end = running;
band.top = top;
band.bottom = bottom;
band.mid = row.front().y + row.front().h / 2;
idx.lines.push_back(band);
}
// Build the spatial grid over the final glyph set.
Grid& grid = idx.grid;
double minX = std::numeric_limits<double>::max();
double minY = std::numeric_limits<double>::max();
double maxX = std::numeric_limits<double>::lowest();
double maxY = std::numeric_limits<double>::lowest();
double sumH = 0.0;
for (const auto& og : idx.glyphs) {
minX = std::min(minX, og.g.x);
minY = std::min(minY, og.g.y);
maxX = std::max(maxX, og.right);
maxY = std::max(maxY, og.bottom);
sumH += og.g.h;
}
// Cell ≈ average glyph height (~one text line), so most cells hold a handful
// of glyphs. Grow the cell if the grid would otherwise be unreasonably large.
double cell = std::max(1.0, sumH / static_cast<double>(idx.glyphs.size()));
auto dim = [&](double lo, double hi) {
return std::max(1, static_cast<int>((hi - lo) / cell) + 1);
};
int gCols = dim(minX, maxX);
int gRows = dim(minY, maxY);
while (static_cast<long long>(gCols) * gRows > 2'000'000 && cell < 1e9) {
cell *= 2.0;
gCols = dim(minX, maxX);
gRows = dim(minY, maxY);
}
grid.minX = minX;
grid.minY = minY;
grid.cell = cell;
grid.cols = gCols;
grid.rows = gRows;
grid.cells.assign(static_cast<size_t>(gCols) * gRows, {});
auto col = [&](double x) { return std::clamp(static_cast<int>((x - minX) / cell), 0, gCols - 1); };
auto rowOf = [&](double y) { return std::clamp(static_cast<int>((y - minY) / cell), 0, gRows - 1); };
for (int i = 0; i < static_cast<int>(idx.glyphs.size()); ++i) {
const OGlyph& og = idx.glyphs[i];
const int c0 = col(og.g.x), c1 = col(og.right);
const int r0 = rowOf(og.g.y), r1 = rowOf(og.bottom);
for (int r = r0; r <= r1; ++r)
for (int c = c0; c <= c1; ++c)
grid.cells[grid.at(c, r)].push_back(i);
}
return idx;
}
// --- spatial index queries (Adobe-grade, fast on dense/overlapping content) --
//
// Point→glyph uses the 2D grid (exact regardless of line layout). Caret/line
// positioning uses the line bands via binary search (lines are in ascending
// vertical order; each line's glyphs are contiguous and x-sorted). Both stay
// cheap on pages with tens of thousands of glyphs.
// Exact glyph under a point via the spatial grid — never misses a containing
// glyph, even where text lines overlap.
int glyphAt(const SelIndex& idx, double x, double y) {
const Grid& grid = idx.grid;
if (grid.empty() || x < grid.minX || y < grid.minY) return -1;
const int c = static_cast<int>((x - grid.minX) / grid.cell);
const int r = static_cast<int>((y - grid.minY) / grid.cell);
if (c < 0 || c >= grid.cols || r < 0 || r >= grid.rows) return -1;
for (const int i : grid.cells[grid.at(c, r)]) {
const OGlyph& g = idx.glyphs[i];
if (x >= g.g.x && x <= g.right && y >= g.g.y && y <= g.bottom) return i;
}
return -1;
}
int lineAt(const SelIndex& idx, double y) {
const auto& lines = idx.lines;
if (lines.empty()) return -1;
// Binary search for the last line whose top <= y.
int lo = 0;
int hi = static_cast<int>(lines.size());
while (lo < hi) {
const int m = (lo + hi) / 2;
if (lines[m].top <= y) lo = m + 1;
else hi = m;
}
const int cand = lo - 1; // -1 when y is above every line
// Check the immediate neighbourhood for true containment, else take the
// nearest band by centre. Bands can overlap slightly (sub/superscript
// tolerance), so a ±1 window around the search boundary is enough.
int best = -1;
double bestDist = std::numeric_limits<double>::max();
for (int i = cand - 1; i <= cand + 1; ++i) {
if (i < 0 || i >= static_cast<int>(lines.size())) continue;
if (y >= lines[i].top && y <= lines[i].bottom) return i;
const double d = std::abs(y - lines[i].mid);
if (d < bestDist) {
bestDist = d;
best = i;
}
}
if (best >= 0) return best;
return cand < 0 ? 0 : static_cast<int>(lines.size()) - 1;
}
// Index of the last glyph in [start,end) whose left edge x <= queryX, or
// start-1 if queryX is left of the whole line. Glyphs are sorted by x.
static int lastGlyphLeftOf(const SelIndex& idx, const LineBand& line, double x) {
int lo = line.start;
int hi = line.end;
while (lo < hi) {
const int m = (lo + hi) / 2;
if (idx.glyphs[m].g.x <= x) lo = m + 1;
else hi = m;
}
return lo - 1;
}
int caretAt(const SelIndex& idx, double x, double y) {
// If the point lands on a glyph, the caret sits on its near or far side.
const int hit = glyphAt(idx, x, y);
if (hit >= 0) {
const OGlyph& g = idx.glyphs[hit];
return x < g.mid ? hit : hit + 1;
}
// Otherwise position within the nearest line (gaps / between lines / margins).
const int li = lineAt(idx, y);
if (li < 0) return 0;
const LineBand& line = idx.lines[li];
if (x <= idx.glyphs[line.start].g.x) return line.start;
if (x >= idx.glyphs[line.end - 1].right) return line.end;
const int cand = lastGlyphLeftOf(idx, line, x);
if (cand < line.start) return line.start;
const OGlyph& g = idx.glyphs[cand];
if (x <= g.right) return x < g.mid ? cand : cand + 1; // inside the glyph
return cand + 1; // in the gap after it
}
std::string textOfRange(const SelIndex& idx, int start, int end) {
std::string out;
const OGlyph* prev = nullptr;
for (int i = start; i < end; ++i) {
const OGlyph& g = idx.glyphs[i];
if (prev) {
if (g.line != prev->line) {
out += '\n';
} else {
const double gap = g.g.x - prev->right;
if (!isSpace(g.g.text) && !isSpace(prev->g.text) && gap > g.g.h * 0.25)
out += ' ';
}
}
out += g.g.text;
prev = &g;
}
return out;
}
std::vector<GlyphBounds> rectsOfRange(const SelIndex& idx, int start, int end) {
std::vector<GlyphBounds> rects;
if (start >= end) return rects;
// Group selected glyphs by line, preserving first-seen order.
std::map<int, std::pair<double, double>> spanByLine; // line -> {minX, maxRight}
std::vector<int> order;
for (int i = start; i < end; ++i) {
const OGlyph& g = idx.glyphs[i];
auto it = spanByLine.find(g.line);
if (it == spanByLine.end()) {
spanByLine[g.line] = {g.g.x, g.right};
order.push_back(g.line);
} else {
it->second.first = std::min(it->second.first, g.g.x);
it->second.second = std::max(it->second.second, g.right);
}
}
for (int line : order) {
const auto& span = spanByLine[line];
const LineBand& band = idx.lines[line];
GlyphBounds r;
r.text = "";
r.x = span.first;
r.y = band.top;
r.w = span.second - span.first;
r.h = band.bottom - band.top;
r.fontSize = 0.0;
rects.push_back(r);
}
return rects;
}
} // namespace
std::expected<std::vector<GlyphBounds>, EngineError> PdfPage::orderedGlyphs() const {
auto raw = extractTextWithBounds();
if (!raw) return std::unexpected(raw.error());
const SelIndex idx = buildIndex(*raw);
std::vector<GlyphBounds> out;
out.reserve(idx.glyphs.size());
for (const auto& og : idx.glyphs) out.push_back(og.g);
return out;
}
std::expected<HitResult, EngineError> PdfPage::hitGlyph(double x, double y) const {
auto raw = extractTextWithBounds();
if (!raw) return std::unexpected(raw.error());
const SelIndex idx = buildIndex(*raw);
HitResult hit;
hit.line = lineAt(idx, y);
hit.caret = caretAt(idx, x, y);
hit.glyphIndex = glyphAt(idx, x, y);
return hit;
}
std::expected<TextSelection, EngineError>
PdfPage::selectRange(double ax, double ay, double bx, double by) const {
auto raw = extractTextWithBounds();
if (!raw) return std::unexpected(raw.error());
const SelIndex idx = buildIndex(*raw);
int a = caretAt(idx, ax, ay);
int b = caretAt(idx, bx, by);
if (a > b) std::swap(a, b);
TextSelection sel;
sel.startGlyph = a;
sel.endGlyph = b;
sel.text = textOfRange(idx, a, b);
sel.rects = rectsOfRange(idx, a, b);
return sel;
}
} // namespace pdfengine
+70 -24
View File
@@ -12,6 +12,8 @@
#include "fonts/pdf_fonts/font_subset.hpp"
#include <gtest/gtest.h>
#include <algorithm>
#include <cctype>
#include <filesystem>
#include <fstream>
#include <iostream>
@@ -20,6 +22,19 @@
namespace {
// Case-insensitive substring check. System font filenames differ in case
// across platforms (e.g. macOS ships "Times.ttc", Windows "times.ttf"), so the
// font-fallback assertions match without regard to case.
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;
@@ -1020,57 +1035,88 @@ TEST(FontFallbackTest, SingletonInstanceIsUnique) {
EXPECT_EQ(&instance1, &instance2);
}
TEST(FontFallbackTest, StandardFontFallbacksOnWindows) {
TEST(FontFallbackTest, StandardFontFallbacks) {
using namespace pdfengine::fonts::pdf_fonts;
auto& fallback = FontFallback::getInstance();
// Test Helvetica to Arial
// Helvetica resolves to its sans-serif substitute for the host platform.
std::string path1 = fallback.getFallbackFontPath("Helvetica");
EXPECT_FALSE(path1.empty());
EXPECT_TRUE(std::filesystem::exists(path1));
EXPECT_TRUE(path1.find("arial") != std::string::npos || path1.find("ARIAL") != std::string::npos);
#if defined(_WIN32)
EXPECT_TRUE(containsCI(path1, "arial") || containsCI(path1, "liberationsans"));
#elif defined(__APPLE__)
// Arial may not be installed; the resolver then falls back to Helvetica.
EXPECT_TRUE(containsCI(path1, "arial") || containsCI(path1, "helvetica") ||
containsCI(path1, "liberationsans"));
#else
EXPECT_TRUE(containsCI(path1, "liberationsans") || containsCI(path1, "dejavusans"));
#endif
// Test Times to Times New Roman
// Times resolves to its serif substitute for the host platform.
std::string path2 = fallback.getFallbackFontPath("Times-Roman");
EXPECT_FALSE(path2.empty());
EXPECT_TRUE(std::filesystem::exists(path2));
EXPECT_TRUE(path2.find("times") != std::string::npos || path2.find("TIMES") != std::string::npos);
#if defined(_WIN32)
EXPECT_TRUE(containsCI(path2, "times") || containsCI(path2, "liberationserif"));
#elif defined(__APPLE__)
EXPECT_TRUE(containsCI(path2, "times") || containsCI(path2, "liberationserif"));
#else
EXPECT_TRUE(containsCI(path2, "liberationserif") || containsCI(path2, "dejavuserif"));
#endif
}
TEST(FontFallbackTest, StyleModifierResolutions) {
using namespace pdfengine::fonts::pdf_fonts;
auto& fallback = FontFallback::getInstance();
// Bold Helvetica should map to Arial Bold
// Bold Helvetica should map to a bold sans-serif substitute.
std::string pathBold = fallback.getFallbackFontPath("Helvetica", true, false);
EXPECT_TRUE(pathBold.find("arialbd") != std::string::npos);
// Bold Italic Times should map to Times New Roman Bold Italic
EXPECT_FALSE(pathBold.empty());
EXPECT_TRUE(std::filesystem::exists(pathBold));
#if defined(_WIN32)
// Windows ships the styled variants, so assert the exact bold face.
EXPECT_TRUE(containsCI(pathBold, "arialbd") || containsCI(pathBold, "liberationsans-bold"));
#endif
// Bold-italic Times should map to a bold-italic serif substitute.
std::string pathBoldItalic = fallback.getFallbackFontPath("Times", true, true);
EXPECT_TRUE(pathBoldItalic.find("timesbi") != std::string::npos);
EXPECT_FALSE(pathBoldItalic.empty());
EXPECT_TRUE(std::filesystem::exists(pathBoldItalic));
#if defined(_WIN32)
EXPECT_TRUE(containsCI(pathBoldItalic, "timesbi") ||
containsCI(pathBoldItalic, "liberationserif-bolditalic"));
#endif
}
TEST(FontFallbackTest, CustomFallbackRegistration) {
using namespace pdfengine::fonts::pdf_fonts;
auto& fallback = FontFallback::getInstance();
fallback.resetToDefaults();
// Lookup standard Arial path
// Lookup the default substitute path for Helvetica.
std::string standardPath = fallback.getFallbackFontPath("Helvetica");
// Register custom override for "helvetica" pointing to times.ttf
fallback.registerFallback("helvetica", "C:\\Windows\\Fonts\\times.ttf");
// The resolver only returns an override whose file actually exists on disk,
// so register a real temp file rather than a hardcoded OS-specific path.
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, "C:\\Windows\\Fonts\\times.ttf");
// Reset back to defaults
EXPECT_EQ(overridenPath, overrideFont.string());
// Reset back to defaults and confirm the original substitute returns.
fallback.resetToDefaults();
std::string restoredPath = fallback.getFallbackFontPath("Helvetica");
EXPECT_EQ(restoredPath, standardPath);
std::filesystem::remove(overrideFont);
}
TEST(FontSubsetTest, SubsetTagParsingAndStripping) {