merge conflict solve
This commit is contained in:
@@ -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()
|
||||
|
||||
@@ -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).
|
||||
@@ -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
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user