Merge branch 'dev' of https://gitea.maskantech.in/gitea_admin/pdf into azeem
@@ -0,0 +1,104 @@
|
||||
# Extended robustness: corpus render-stability sweep + libFuzzer run.
|
||||
#
|
||||
# DELIBERATELY ISOLATED from the main CI gate:
|
||||
# - never triggers on push or pull_request, so it can NEVER block a merge,
|
||||
# push, or pull;
|
||||
# - the whole job is continue-on-error, so a crash finding or a build/runner
|
||||
# problem reports red here but does not fail any required check;
|
||||
# - runs on a weekly schedule and on manual dispatch only.
|
||||
#
|
||||
# A full 24h fuzz run needs a self-hosted runner (GitHub-hosted runners cap a job
|
||||
# at 6h). Use the workflow_dispatch `duration_seconds` input for that; the weekly
|
||||
# schedule does a short smoke instead.
|
||||
|
||||
name: Fuzz & corpus sweep
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: "0 3 * * 0" # Sundays 03:00 UTC — short smoke
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
duration_seconds:
|
||||
description: "libFuzzer -max_total_time (e.g. 1800 smoke, 86400 for 24h on a self-hosted runner)"
|
||||
default: "1800"
|
||||
sanitizers:
|
||||
description: "Sanitizer set (fuzzer,address,undefined needs an ASan/UBSan-built PDFium; fuzzer = coverage-only)"
|
||||
default: "fuzzer,address,undefined"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: fuzz-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
fuzz:
|
||||
# Non-blocking by construction: nothing depends on this job and it is allowed to fail.
|
||||
continue-on-error: true
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 1500 # permits a 24h dispatch on a self-hosted runner; hosted runners stop at 6h
|
||||
env:
|
||||
VCPKG_DEFAULT_BINARY_CACHE: ${{ github.workspace }}/.vcpkg-cache
|
||||
FUZZ_SANITIZERS: ${{ github.event.inputs.sanitizers || 'fuzzer' }}
|
||||
FUZZ_DURATION: ${{ github.event.inputs.duration_seconds || '600' }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install Ninja + Clang
|
||||
shell: bash
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y ninja-build clang
|
||||
|
||||
- name: Locate vcpkg
|
||||
shell: bash
|
||||
run: |
|
||||
echo "VCPKG_ROOT=$VCPKG_INSTALLATION_ROOT" >> "$GITHUB_ENV"
|
||||
mkdir -p "$VCPKG_DEFAULT_BINARY_CACHE"
|
||||
|
||||
- name: Cache vcpkg artifacts
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ${{ env.VCPKG_DEFAULT_BINARY_CACHE }}
|
||||
key: vcpkg-fuzz-${{ hashFiles('vcpkg.json') }}
|
||||
restore-keys: vcpkg-fuzz-
|
||||
|
||||
- name: Pin vcpkg dependency baseline
|
||||
shell: bash
|
||||
run: |
|
||||
if ! grep -q '"builtin-baseline"' vcpkg.json; then
|
||||
"$VCPKG_ROOT/vcpkg" x-update-baseline --add-initial-baseline
|
||||
fi
|
||||
|
||||
- name: Configure (fuzz-linux)
|
||||
run: cmake --preset fuzz-linux -DPDFENGINE_FUZZ_SANITIZERS="$FUZZ_SANITIZERS"
|
||||
|
||||
- name: Build fuzzer
|
||||
run: cmake --build --preset fuzz-linux --target pdfengine_fuzz
|
||||
|
||||
- name: Fetch corpus (pinned + hash-verified)
|
||||
shell: bash
|
||||
run: python3 scripts/fetch_corpus.py --manifest tests/regression/corpus-manifest.json || echo "corpus fetch failed (network) — continuing with committed corpus"
|
||||
|
||||
- name: Render-stability sweep (replay every corpus PDF once)
|
||||
shell: bash
|
||||
run: |
|
||||
BIN=out/build/fuzz-linux/bin/pdfengine_fuzz
|
||||
mkdir -p engine/fuzz/artifacts
|
||||
"$BIN" -runs=0 -artifact_prefix=engine/fuzz/artifacts/ corpus/ corpus/fuzz/ || true
|
||||
|
||||
- name: Fuzz run
|
||||
shell: bash
|
||||
run: |
|
||||
BIN=out/build/fuzz-linux/bin/pdfengine_fuzz
|
||||
"$BIN" -max_total_time="$FUZZ_DURATION" -print_final_stats=1 \
|
||||
-artifact_prefix=engine/fuzz/artifacts/ corpus/fuzz/ corpus/ || true
|
||||
|
||||
- name: Upload crash artifacts
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: fuzz-artifacts
|
||||
path: engine/fuzz/artifacts/
|
||||
if-no-files-found: ignore
|
||||
@@ -67,5 +67,15 @@ gateway/*.pyd
|
||||
gateway/*.so
|
||||
gateway/*.dylib
|
||||
|
||||
# Downloaded fuzzing corpus (large; fetched via scripts/fetch_corpus.py)
|
||||
corpus/fuzz/
|
||||
# Large-corpus regression baseline (derived from the gitignored corpus above)
|
||||
tests/regression/baseline-large/
|
||||
# Fuzzer working artifacts (crashes, leaks, coverage)
|
||||
engine/fuzz/artifacts/
|
||||
crash-*
|
||||
leak-*
|
||||
timeout-*
|
||||
|
||||
PDF Editor Timeline.xlsx
|
||||
# Local environment config / secrets
|
||||
|
||||
@@ -40,6 +40,16 @@ option(PDFENGINE_ENABLE_SANITIZERS "Build with AddressSanitizer/UBSan"
|
||||
option(PDFENGINE_WARNINGS_AS_ERRORS "Treat compiler warnings as errors" OFF)
|
||||
option(PDFENGINE_WITH_PDFIUM "Link the PDFium static lib (build it first)" OFF)
|
||||
option(PDFENGINE_WITH_SKIA "Link the Skia static lib (build it first)" OFF)
|
||||
option(PDFENGINE_FUZZING "Build libFuzzer harnesses (requires Clang)" OFF)
|
||||
|
||||
if(PDFENGINE_FUZZING)
|
||||
# The fuzz harness instruments the engine; tests/bindings are not part of it.
|
||||
set(PDFENGINE_BUILD_TESTS OFF CACHE BOOL "Build engine unit/smoke tests" FORCE)
|
||||
if(NOT CMAKE_CXX_COMPILER_ID MATCHES "Clang")
|
||||
message(FATAL_ERROR "PDFENGINE_FUZZING requires a Clang/clang-cl toolchain "
|
||||
"(set in the 'fuzz-*' preset).")
|
||||
endif()
|
||||
endif()
|
||||
|
||||
if(PDFENGINE_WASM)
|
||||
set(PDFENGINE_BUILD_TESTS OFF CACHE BOOL "Build engine unit/smoke tests" FORCE)
|
||||
@@ -80,7 +90,9 @@ else()
|
||||
endif()
|
||||
|
||||
add_subdirectory(engine)
|
||||
add_subdirectory(bindings)
|
||||
if(NOT PDFENGINE_FUZZING)
|
||||
add_subdirectory(bindings)
|
||||
endif()
|
||||
endif()
|
||||
|
||||
message(STATUS "PdfEngine ${PROJECT_VERSION} configured")
|
||||
|
||||
@@ -108,6 +108,27 @@
|
||||
"PDFENGINE_BUILD_TESTS": "OFF",
|
||||
"PDFENGINE_WITH_PDFIUM": "OFF"
|
||||
}
|
||||
},
|
||||
|
||||
{
|
||||
"name": "fuzz-linux",
|
||||
"displayName": "Linux • libFuzzer (Clang + ASan)",
|
||||
"inherits": "base",
|
||||
"condition": { "type": "equals", "lhs": "${hostSystemName}", "rhs": "Linux" },
|
||||
"cacheVariables": {
|
||||
"CMAKE_BUILD_TYPE": "RelWithDebInfo",
|
||||
"CMAKE_C_COMPILER": "clang",
|
||||
"CMAKE_CXX_COMPILER": "clang++",
|
||||
"PDFENGINE_FUZZING": "ON",
|
||||
"PDFENGINE_WITH_PDFIUM": "ON",
|
||||
"PDFENGINE_FUZZ_SANITIZERS": "fuzzer,address,undefined"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "fuzz-linux-nosan",
|
||||
"displayName": "Linux • libFuzzer (Clang, coverage-only — for non-ASan PDFium)",
|
||||
"inherits": "fuzz-linux",
|
||||
"cacheVariables": { "PDFENGINE_FUZZ_SANITIZERS": "fuzzer" }
|
||||
}
|
||||
],
|
||||
|
||||
@@ -121,7 +142,9 @@
|
||||
{ "name": "macos-debug", "configurePreset": "macos-debug" },
|
||||
{ "name": "macos-release", "configurePreset": "macos-release" },
|
||||
{ "name": "macos-asan", "configurePreset": "macos-asan" },
|
||||
{ "name": "wasm", "configurePreset": "wasm" }
|
||||
{ "name": "wasm", "configurePreset": "wasm" },
|
||||
{ "name": "fuzz-linux", "configurePreset": "fuzz-linux" },
|
||||
{ "name": "fuzz-linux-nosan", "configurePreset": "fuzz-linux-nosan" }
|
||||
],
|
||||
|
||||
"testPresets": [
|
||||
|
||||
@@ -123,7 +123,8 @@ PYBIND11_MODULE(pdfengine, m) {
|
||||
.def_readonly("bbox_y", &pdfengine::Glyph::bboxY)
|
||||
.def_readonly("bbox_w", &pdfengine::Glyph::bboxW)
|
||||
.def_readonly("bbox_h", &pdfengine::Glyph::bboxH)
|
||||
.def_readonly("angle", &pdfengine::Glyph::angle);
|
||||
.def_readonly("angle", &pdfengine::Glyph::angle)
|
||||
.def_readonly("page_object_index", &pdfengine::Glyph::pageObjectIndex);
|
||||
|
||||
py::class_<pdfengine::TextRun>(m, "TextRun")
|
||||
.def_readonly("text", &pdfengine::TextRun::text)
|
||||
@@ -137,7 +138,8 @@ PYBIND11_MODULE(pdfengine, m) {
|
||||
.def_readonly("x", &pdfengine::TextRun::x)
|
||||
.def_readonly("y", &pdfengine::TextRun::y)
|
||||
.def_readonly("w", &pdfengine::TextRun::w)
|
||||
.def_readonly("h", &pdfengine::TextRun::h);
|
||||
.def_readonly("h", &pdfengine::TextRun::h)
|
||||
.def_readonly("object_indices", &pdfengine::TextRun::objectIndices);
|
||||
|
||||
py::class_<pdfengine::TextLine>(m, "TextLine")
|
||||
.def_readonly("runs", &pdfengine::TextLine::runs)
|
||||
@@ -212,6 +214,40 @@ PYBIND11_MODULE(pdfengine, m) {
|
||||
}
|
||||
return py_list;
|
||||
})
|
||||
.def("ordered_glyphs", [](const pdfengine::PdfPage& self) {
|
||||
auto res = get_or_throw(self.orderedGlyphs());
|
||||
py::list py_list;
|
||||
for (const auto& g : res) {
|
||||
py::dict d;
|
||||
d["text"] = g.text; d["x"] = g.x; d["y"] = g.y;
|
||||
d["w"] = g.w; d["h"] = g.h; d["fontSize"] = g.fontSize;
|
||||
py_list.append(d);
|
||||
}
|
||||
return py_list;
|
||||
})
|
||||
.def("hit_glyph", [](const pdfengine::PdfPage& self, double x, double y) {
|
||||
auto hit = get_or_throw(self.hitGlyph(x, y));
|
||||
py::dict d;
|
||||
d["glyphIndex"] = hit.glyphIndex;
|
||||
d["caret"] = hit.caret;
|
||||
d["line"] = hit.line;
|
||||
return d;
|
||||
}, py::arg("x"), py::arg("y"))
|
||||
.def("select_range", [](const pdfengine::PdfPage& self, double ax, double ay, double bx, double by) {
|
||||
auto sel = get_or_throw(self.selectRange(ax, ay, bx, by));
|
||||
py::dict d;
|
||||
d["startGlyph"] = sel.startGlyph;
|
||||
d["endGlyph"] = sel.endGlyph;
|
||||
d["text"] = sel.text;
|
||||
py::list rects;
|
||||
for (const auto& r : sel.rects) {
|
||||
py::dict rd;
|
||||
rd["x"] = r.x; rd["y"] = r.y; rd["w"] = r.w; rd["h"] = r.h;
|
||||
rects.append(rd);
|
||||
}
|
||||
d["rects"] = rects;
|
||||
return d;
|
||||
}, py::arg("ax"), py::arg("ay"), py::arg("bx"), py::arg("by"))
|
||||
.def("get_fonts", [](const pdfengine::PdfPage& self) {
|
||||
return get_or_throw(self.getFonts());
|
||||
})
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
# Phase 3 — Dev-1 Plan (Form Filling · Hit-Testing · Test Harness) — v2
|
||||
|
||||
**Status:** Review before build (reworked with your decisions). No code changed. Grounded in the current codebase (verified).
|
||||
**Scope:** the three Phase-3 items not blocked by the content-stream analyser. Build them in parallel with (separate) content-stream work.
|
||||
|
||||
## Locked decisions (from review)
|
||||
1. **Form fill UI → Acrobat-style on-page overlay inputs** (edit fields directly on the page), built now — not the lighter in-panel version.
|
||||
2. **Form rendering → production-grade** (`FPDF_FFLDraw` + proper appearance regeneration; robust per-type).
|
||||
3. **Hit-testing → build BOTH** the engine `hitGlyph`/spatial index (the roadmap Dev-1 deliverable — *not* deferred, since it's not scheduled in any other phase) **and** the frontend real-time selection model. They share one algorithm; the engine is authoritative/tested/SDK-exposed, the frontend is the responsive web layer.
|
||||
4. **Regression gate → freeze PDFium baseline now**, becomes meaningful when the custom renderer lands.
|
||||
5. **Fuzzing → Windows AND Linux** (clang-cl on Windows + clang on Linux).
|
||||
6. **Corpus → download script** into a gitignored dir (500+ public PDFs).
|
||||
|
||||
> None of these need the content-stream analyser. They build on Phase-1 glyphs, the form-field reader, and the existing gtest/ASan infra.
|
||||
|
||||
---
|
||||
|
||||
## Workstream A — Production-Grade Form Filling + Acrobat-Style On-Page Editing
|
||||
|
||||
### Current state (verified)
|
||||
- ✅ `update_field` handler exists ([pdfium_document.cpp:1809](engine/src/parser/pdfium_document.cpp#L1809)) — sets `/V`, bool→checkbox, saves. **~50%.**
|
||||
- ✅ Form-fill env initialized; field reader returns name/value/type/flags/options; Forms tab lists them (read-only).
|
||||
- ❌ `render()` uses `flags=0` ([line 717](engine/src/parser/pdfium_document.cpp#L717)) → form widgets not drawn (filled values invisible).
|
||||
- ❌ No `/AS` for checkboxes, no `FORM_SetIndexSelected` for choices, no appearance regen, no on-page edit UI.
|
||||
|
||||
### Build — Engine (C++)
|
||||
1. **Render forms (production):** retain the form-fill env on the document; in `render()` call **`FPDF_FFLDraw(formHandle, bitmap, page, x, y, w, h, rotate, FPDF_ANNOT)`** after `FPDF_RenderPageBitmap` so field appearances (incl. filled values) composite into the page image.
|
||||
2. **Complete `update_field` per field type:**
|
||||
- **Text (Tx):** set `/V`; regenerate appearance (via form env `FORM_ReplaceSelection`/`FORM_ForceToKillFocus`, or set AcroForm `/NeedAppearances`) so the value renders + exports.
|
||||
- **Checkbox/Radio (Btn):** set `/V` **and** `/AS` to the field's on-state name (read the AP states); radios update the sibling group.
|
||||
- **Choice (Ch):** combo → `/V`; listbox/multi → `FORM_SetIndexSelected(formHandle, page, index, selected)` (+ `/V`).
|
||||
3. **Robust field identity:** match by the same id convention as `delete_annotation` (`NM` else `anno_<page>_<idx>`), not raw index parsing.
|
||||
4. **Save:** incremental (form edits are dict changes); `save_full` on export.
|
||||
|
||||
### Build — Gateway
|
||||
- Extend `UpdateFieldData` if needed: `value: str | bool`, optional `selectedIndices: int[]` (listbox multi-select).
|
||||
- (Optional) a `GET /…/form-fields` convenience endpoint — or keep reading them from `/annotations` (already includes field props).
|
||||
|
||||
### Build — Frontend (Acrobat-style on-page editing)
|
||||
1. **`FormFieldLayer`** (new viewer layer, sibling to AnnotationLayer): for each `widget` annotation on the page, render an **HTML control positioned over the field rect** (×zoom), type-mapped:
|
||||
- Tx → `<input>` / `<textarea>` (multiline per `fieldFlags`)
|
||||
- Btn checkbox → `<input type=checkbox>`; radio → grouped radios
|
||||
- Ch combo → `<select>` (or input+datalist if editable); listbox → `<select multiple>`
|
||||
- Sig → click-to-sign placeholder (reuses Signature flow)
|
||||
2. **Positioning:** field rect comes from `extractAnnotations` (top-left device space) → place + scale with zoom, like the other layers; transparent styling so the PDF field box shows through.
|
||||
3. **Interaction:** edit on the page → on blur/change emit `update_field` → optimistic value + save → page re-renders with baked appearance (so it's consistent on export).
|
||||
4. **Modes:** a "Fill & Sign" affordance to toggle the form layer on (so inputs don't interfere with other tools).
|
||||
|
||||
### Acceptance
|
||||
On a form PDF you can **type into text fields, tick checkboxes, pick dropdowns directly on the page**; values persist after save, **render in the page image**, and survive export. Verified on `text_form` / `combobox_form` / `listbox_form`.
|
||||
|
||||
### Risk / effort
|
||||
PDFium form rendering + appearance regen is the classic gotcha (`/AS` state names vary; multiline/comb fields). On-page overlay positioning across zoom/rotation needs care. **~1.5–2 weeks.**
|
||||
|
||||
---
|
||||
|
||||
## Workstream B — Adobe-Level Hit-Testing & Text Selection (engine + frontend)
|
||||
|
||||
### Current state (verified)
|
||||
- ✅ Glyph bounds (`extractTextWithBounds`).
|
||||
- 🟡 `SelectionLayer` = rectangular marquee (`rectsIntersect`) — not reading-order selection.
|
||||
|
||||
### Build — Engine (the roadmap Dev-1 deliverable; authoritative + SDK)
|
||||
1. **Reading-order model per page:** line detection (cluster by baseline/y), order glyphs L→R within line, top→bottom across lines; assign sequential index. (v1 = LTR single/simple multi-column; note RTL/complex as limitation.)
|
||||
2. **Spatial index:** sorted-vector + binary search (or uniform grid) over glyph rects.
|
||||
3. **APIs (bound to Python/SDK):**
|
||||
- `hit_glyph(x, y) → {glyphIndex, caretSide}` (nearest glyph + before/after).
|
||||
- `select_range(p1, p2) → {glyphIndices[], text}` (ordered range between two points).
|
||||
- `word_at(x,y)`, `line_at(x,y)`.
|
||||
4. **Tests:** corpus-based accuracy (hit/range correctness; reading order sanity).
|
||||
|
||||
### Build — Frontend (Adobe-grade interactive selection)
|
||||
1. **`TextSelectionModel`** (client-side): mirrors the engine's reading-order + spatial index for **real-time** interaction (no HTTP per mouse-move). Shares the algorithm; an automated test compares frontend vs engine selection on the corpus to prevent drift.
|
||||
2. **Interactions (Adobe parity):**
|
||||
- Drag = **anchor→focus reading-order range** (flows across lines, partial first/last line).
|
||||
- **Double-click = word**, **triple-click = line/paragraph**.
|
||||
- **Shift-click / Shift-drag = extend**; caret position; **Ctrl/⌘+A = select page**.
|
||||
- **Copy** = ordered text with correct spaces/newlines.
|
||||
- *(Stretch)* cross-page selection (Adobe does this; v1 may be per-page — flagged).
|
||||
3. **Visual:** selection rendered as highlighted glyph runs (smooth at any zoom).
|
||||
|
||||
### Acceptance
|
||||
Selection behaves like Acrobat/a browser: drag selects a flowing range across lines, double/triple-click select word/line, shift extends, copy yields correctly-ordered text; smooth on a dense/100-page doc. Engine `hit_glyph`/`select_range` exposed + tested. **Also the foundation for "click a glyph to edit it" once content-stream editing lands.**
|
||||
|
||||
### Risk / effort
|
||||
Reading order for columns/RTL is hard — v1 targets LTR; complex layouts flagged. Engine + frontend + parity test. **~1.5 weeks.**
|
||||
|
||||
---
|
||||
|
||||
## Workstream C — Fuzz + Regression Harness (Windows + Linux)
|
||||
|
||||
### Current state (verified)
|
||||
- ✅ 41 corpus PDFs; gtest suite (`engine/tests/`); CTest; ASan/UBSan presets (`windows-asan`/`linux-asan`).
|
||||
|
||||
### Build — Regression suite
|
||||
1. `tests/regression/` — Python harness: render each corpus PDF (engine bindings) at 144 DPI → PNG → **SSIM vs `baseline/`**; fail < 0.95; HTML/JSON report.
|
||||
2. **Freeze the PDFium baseline now** (golden images). When the **custom renderer** lands, switch engine-render to the custom path → gate becomes the real safety net.
|
||||
3. `scripts/fetch_corpus.ps1`/`.sh` — pull 500+ permissive public PDFs (pdf.js / pdfium test resources) into a **gitignored** corpus dir.
|
||||
4. CI: regression job wired as a gate.
|
||||
|
||||
### Build — Fuzzing (Windows + Linux)
|
||||
1. `tests/fuzz/fuzz_load_render.cpp` — `LLVMFuzzerTestOneInput(data,size)` → `loadFromMemory` → page/render/extractText/applyEdits; seed = the 41 PDFs.
|
||||
2. **Toolchain:** **Linux** = clang `-fsanitize=fuzzer,address,undefined`; **Windows** = **clang-cl** `-fsanitize=fuzzer,address` (UBSan partial on Windows — run full UBSan on Linux). New CMake `fuzz` preset(s) (`fuzz-linux`, `fuzz-windows`).
|
||||
3. `engine/include/pdfengine/hardened_limits.h` — `MAX_OBJECTS` / `MAX_STREAM_SIZE` / `MAX_PAGE_COUNT` checked in load/parse paths.
|
||||
4. Runs: nightly fuzz (both OSes), ASan/UBSan over the existing gtest suite via the `*-asan` presets.
|
||||
|
||||
### Acceptance
|
||||
CI runs SSIM regression (green baseline now); fuzz targets **build and run on Windows + Linux**, N hours zero-crash, ASan clean; UBSan clean on Linux. `hardened_limits` guards enforced.
|
||||
|
||||
### Risk / effort
|
||||
clang-cl + libFuzzer on Windows needs LLVM installed (the MSVC `windows-asan` preset stays for unit-test ASan). Corpus licensing → permissive sets only. **~1–1.5 weeks, mostly background/parallel.**
|
||||
|
||||
---
|
||||
|
||||
## Build order & parallelization
|
||||
1. **A — Form filling** first (real Adobe feature; one engine rebuild; production-grade on-page editing).
|
||||
2. **B — Hit-testing/selection** next (engine spatial index + frontend Adobe selection; sets up future text editing).
|
||||
3. **C — Harness** stood up from day 1 in the background (protects later Dev-2/Dev-3 content-stream work).
|
||||
|
||||
All independent of each other and of the content-stream analyser. Each ends with: engine rebuild (A, B-engine) + frontend typecheck/build + end-to-end verification, and the gateway test instance stopped afterward.
|
||||
|
||||
## Revised effort
|
||||
| Workstream | Effort | Engine rebuild? |
|
||||
|---|---|---|
|
||||
| A — Form filling (on-page, production) | ~1.5–2 wk | yes |
|
||||
| B — Hit-testing (engine + frontend Adobe selection) | ~1.5 wk | yes |
|
||||
| C — Fuzz + regression (Win + Linux) | ~1–1.5 wk | new fuzz preset |
|
||||
|
||||
## What this delivers
|
||||
Form **filling** (Acrobat-style, on-page), **Adobe-grade text selection/copy** (engine-authoritative + responsive UI), and a **production quality safety net** (regression + fuzz on Windows & Linux). Still **not** editing existing page text/images — that's the content-stream chain (separate critical path).
|
||||
@@ -17,6 +17,7 @@ add_library(pdfengine STATIC
|
||||
src/parser/pdfium_loader.cpp
|
||||
src/parser/pdfium_document.cpp
|
||||
src/parser/content_stream_parser.cpp
|
||||
src/text/selection.cpp
|
||||
src/fonts/face/font_face.cpp
|
||||
src/fonts/face/free_type_manager.cpp
|
||||
src/fonts/loader/font_resolver.cpp
|
||||
@@ -72,3 +73,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"
|
||||
@@ -89,6 +106,7 @@ struct Glyph {
|
||||
double originY = 0.0;
|
||||
double bboxX = 0.0, bboxY = 0.0, bboxW = 0.0, bboxH = 0.0;
|
||||
double angle = 0.0;
|
||||
int pageObjectIndex = -1;
|
||||
};
|
||||
|
||||
struct TextRun {
|
||||
@@ -100,6 +118,7 @@ struct TextRun {
|
||||
bool isEmbedded = false;
|
||||
std::string type;
|
||||
std::vector<Glyph> glyphs;
|
||||
std::vector<int> objectIndices;
|
||||
double x = 0.0, y = 0.0, w = 0.0, h = 0.0;
|
||||
};
|
||||
|
||||
@@ -135,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,16 @@
|
||||
#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 <nlohmann/json.hpp>
|
||||
#include <spdlog/spdlog.h>
|
||||
@@ -622,14 +626,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;
|
||||
@@ -643,8 +661,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() {
|
||||
@@ -670,10 +689,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;
|
||||
@@ -703,10 +725,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);
|
||||
@@ -716,6 +755,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);
|
||||
|
||||
@@ -865,6 +909,12 @@ std::expected<PageModel, EngineError> PdfiumPage::extractDocumentModel() const {
|
||||
std::vector<Glyph> documentGlyphs;
|
||||
documentGlyphs.reserve(charCount);
|
||||
|
||||
std::unordered_map<FPDF_PAGEOBJECT, int> objToIndex;
|
||||
int objCount = FPDFPage_CountObjects(page_);
|
||||
for (int i = 0; i < objCount; ++i) {
|
||||
objToIndex[FPDFPage_GetObject(page_, i)] = i;
|
||||
}
|
||||
|
||||
// Pass 1: Extract all glyphs
|
||||
for (int i = 0; i < charCount; ++i) {
|
||||
unsigned int codeUnit = FPDFText_GetUnicode(textPage_, i);
|
||||
@@ -911,6 +961,14 @@ std::expected<PageModel, EngineError> PdfiumPage::extractDocumentModel() const {
|
||||
}
|
||||
g.flags = flags;
|
||||
|
||||
FPDF_PAGEOBJECT textObj = FPDFText_GetTextObject(textPage_, i);
|
||||
if (textObj) {
|
||||
auto it = objToIndex.find(textObj);
|
||||
if (it != objToIndex.end()) {
|
||||
g.pageObjectIndex = it->second;
|
||||
}
|
||||
}
|
||||
|
||||
documentGlyphs.push_back(g);
|
||||
|
||||
if (cp > 0xFFFF) ++i; // Skip low surrogate
|
||||
@@ -1013,6 +1071,11 @@ std::expected<PageModel, EngineError> PdfiumPage::extractDocumentModel() const {
|
||||
spaceGlyph.bboxH = currG.bboxH;
|
||||
|
||||
if (breakRun) {
|
||||
for (const auto& g : currentRun.glyphs) {
|
||||
if (g.pageObjectIndex != -1 && std::find(currentRun.objectIndices.begin(), currentRun.objectIndices.end(), g.pageObjectIndex) == currentRun.objectIndices.end()) {
|
||||
currentRun.objectIndices.push_back(g.pageObjectIndex);
|
||||
}
|
||||
}
|
||||
line.runs.push_back(std::move(currentRun));
|
||||
currentRun = TextRun();
|
||||
currentRun.fontName = currG.fontName;
|
||||
@@ -1027,6 +1090,11 @@ std::expected<PageModel, EngineError> PdfiumPage::extractDocumentModel() const {
|
||||
currentRun.glyphs.push_back(spaceGlyph);
|
||||
currentRun.text += spaceGlyph.text;
|
||||
} else if (breakRun) {
|
||||
for (const auto& g : currentRun.glyphs) {
|
||||
if (g.pageObjectIndex != -1 && std::find(currentRun.objectIndices.begin(), currentRun.objectIndices.end(), g.pageObjectIndex) == currentRun.objectIndices.end()) {
|
||||
currentRun.objectIndices.push_back(g.pageObjectIndex);
|
||||
}
|
||||
}
|
||||
line.runs.push_back(std::move(currentRun));
|
||||
currentRun = TextRun();
|
||||
currentRun.fontName = currG.fontName;
|
||||
@@ -1043,6 +1111,11 @@ std::expected<PageModel, EngineError> PdfiumPage::extractDocumentModel() const {
|
||||
currentRun.text += currG.text;
|
||||
}
|
||||
if (!currentRun.glyphs.empty()) {
|
||||
for (const auto& g : currentRun.glyphs) {
|
||||
if (g.pageObjectIndex != -1 && std::find(currentRun.objectIndices.begin(), currentRun.objectIndices.end(), g.pageObjectIndex) == currentRun.objectIndices.end()) {
|
||||
currentRun.objectIndices.push_back(g.pageObjectIndex);
|
||||
}
|
||||
}
|
||||
line.runs.push_back(std::move(currentRun));
|
||||
}
|
||||
}
|
||||
@@ -1589,7 +1662,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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1598,7 +1673,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;
|
||||
@@ -1635,7 +1714,423 @@ std::expected<void, EngineError> PdfiumDocument::applyEdits(const std::string& e
|
||||
return std::unexpected(EngineError::PageOutOfBounds);
|
||||
}
|
||||
|
||||
if (type == "text_overlay" || type == "add_text") {
|
||||
if (type == "replace_text") {
|
||||
std::vector<int> objectIndices;
|
||||
std::string newText = "";
|
||||
std::string internalFontId = "";
|
||||
double fontSize = -1.0;
|
||||
|
||||
if (op.contains("data") && op["data"].is_object()) {
|
||||
auto data = op["data"];
|
||||
if (data.contains("objectIndices") && data["objectIndices"].is_array()) {
|
||||
for (auto& idx : data["objectIndices"]) {
|
||||
objectIndices.push_back(idx.get<int>());
|
||||
}
|
||||
}
|
||||
newText = data.value("text", "");
|
||||
internalFontId = data.value("internalFontId", "");
|
||||
if (data.contains("fontSize")) {
|
||||
fontSize = data["fontSize"].get<double>();
|
||||
}
|
||||
} else {
|
||||
if (op.contains("objectIndices") && op["objectIndices"].is_array()) {
|
||||
for (auto& idx : op["objectIndices"]) {
|
||||
objectIndices.push_back(idx.get<int>());
|
||||
}
|
||||
}
|
||||
newText = op.value("text", "");
|
||||
internalFontId = op.value("internalFontId", "");
|
||||
if (op.contains("fontSize")) {
|
||||
fontSize = op["fontSize"].get<double>();
|
||||
}
|
||||
}
|
||||
|
||||
if (objectIndices.empty()) {
|
||||
spdlog::warn("replace_text has empty objectIndices, nothing to replace");
|
||||
continue;
|
||||
}
|
||||
|
||||
FPDF_PAGE page = FPDF_LoadPage(doc_, pageIndex);
|
||||
if (!page) {
|
||||
spdlog::error("Failed to load page index {} for replace_text", pageIndex);
|
||||
return std::unexpected(EngineError::Unknown);
|
||||
}
|
||||
|
||||
// 1. Sort descending to prevent index shift during removal
|
||||
std::sort(objectIndices.begin(), objectIndices.end(), std::greater<int>());
|
||||
|
||||
int minIndex = objectIndices.back();
|
||||
|
||||
// 2. Fetch the lowest indexed original object to copy styling
|
||||
FPDF_PAGEOBJECT origObj = FPDFPage_GetObject(page, minIndex);
|
||||
if (!origObj) {
|
||||
spdlog::error("Failed to get original text object at index {}", minIndex);
|
||||
FPDF_ClosePage(page);
|
||||
return std::unexpected(EngineError::Unknown);
|
||||
}
|
||||
|
||||
double a = 1.0, b = 0.0, c = 0.0, d = 1.0, e = 0.0, f = 0.0;
|
||||
FS_MATRIX matrix;
|
||||
if (FPDFPageObj_GetMatrix(origObj, &matrix)) {
|
||||
a = matrix.a;
|
||||
b = matrix.b;
|
||||
c = matrix.c;
|
||||
d = matrix.d;
|
||||
e = matrix.e;
|
||||
f = matrix.f;
|
||||
}
|
||||
|
||||
unsigned int r = 0, g = 0, b_color = 0, a_color = 255;
|
||||
FPDFPageObj_GetFillColor(origObj, &r, &g, &b_color, &a_color);
|
||||
|
||||
// Get font size if not provided
|
||||
if (fontSize < 0.0) {
|
||||
float sizeVal = 12.0f;
|
||||
if (FPDFTextObj_GetFontSize(origObj, &sizeVal)) {
|
||||
fontSize = sizeVal;
|
||||
} else {
|
||||
fontSize = 12.0;
|
||||
}
|
||||
}
|
||||
|
||||
// Get text rendering mode
|
||||
FPDF_TEXT_RENDERMODE renderMode = static_cast<FPDF_TEXT_RENDERMODE>(FPDFTextObj_GetTextRenderMode(origObj));
|
||||
|
||||
// Map to a standard 14 font name
|
||||
std::string fontName = "Helvetica";
|
||||
std::string origFontName = "";
|
||||
bool bold = false;
|
||||
bool italic = false;
|
||||
FPDF_FONT origFont = FPDFTextObj_GetFont(origObj);
|
||||
if (origFont) {
|
||||
size_t nameLen = FPDFFont_GetBaseFontName(origFont, nullptr, 0);
|
||||
if (nameLen > 0) {
|
||||
std::vector<char> nameBuf(nameLen);
|
||||
if (FPDFFont_GetBaseFontName(origFont, nameBuf.data(), nameLen) > 0) {
|
||||
origFontName = nameBuf.data();
|
||||
std::string baseName(nameBuf.data());
|
||||
std::string lowerName = baseName;
|
||||
std::transform(lowerName.begin(), lowerName.end(), lowerName.begin(), [](unsigned char c) {
|
||||
return static_cast<char>(std::tolower(c));
|
||||
});
|
||||
|
||||
bold = (lowerName.find("bold") != std::string::npos);
|
||||
italic = (lowerName.find("italic") != std::string::npos || lowerName.find("oblique") != std::string::npos);
|
||||
|
||||
if (lowerName.find("times") != std::string::npos) {
|
||||
if (bold && italic) fontName = "Times-BoldItalic";
|
||||
else if (bold) fontName = "Times-Bold";
|
||||
else if (italic) fontName = "Times-Italic";
|
||||
else fontName = "Times-Roman";
|
||||
} else if (lowerName.find("courier") != std::string::npos) {
|
||||
if (bold && italic) fontName = "Courier-BoldOblique";
|
||||
else if (bold) fontName = "Courier-Bold";
|
||||
else if (italic) fontName = "Courier-Oblique";
|
||||
else fontName = "Courier";
|
||||
} else {
|
||||
if (bold && italic) fontName = "Helvetica-BoldOblique";
|
||||
else if (bold) fontName = "Helvetica-Bold";
|
||||
else if (italic) fontName = "Helvetica-Oblique";
|
||||
else fontName = "Helvetica";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!internalFontId.empty()) {
|
||||
std::string lowerId = internalFontId;
|
||||
std::transform(lowerId.begin(), lowerId.end(), lowerId.begin(), [](unsigned char c) {
|
||||
return static_cast<char>(std::tolower(c));
|
||||
});
|
||||
if (lowerId.find("bolditalic") != std::string::npos) { bold = true; italic = true; }
|
||||
else if (lowerId.find("bold") != std::string::npos) { bold = true; }
|
||||
else if (lowerId.find("italic") != std::string::npos) { italic = true; }
|
||||
else if (lowerId.find("oblique") != std::string::npos) { italic = true; }
|
||||
|
||||
if (lowerId.find("times") != std::string::npos) {
|
||||
if (bold && italic) fontName = "Times-BoldItalic";
|
||||
else if (bold) fontName = "Times-Bold";
|
||||
else if (italic) fontName = "Times-Italic";
|
||||
else fontName = "Times-Roman";
|
||||
} else if (lowerId.find("courier") != std::string::npos) {
|
||||
if (bold && italic) fontName = "Courier-BoldOblique";
|
||||
else if (bold) fontName = "Courier-Bold";
|
||||
else if (italic) fontName = "Courier-Oblique";
|
||||
else fontName = "Courier";
|
||||
} else if (lowerId.find("helvetica") != std::string::npos) {
|
||||
if (bold && italic) fontName = "Helvetica-BoldOblique";
|
||||
else if (bold) fontName = "Helvetica-Bold";
|
||||
else if (italic) fontName = "Helvetica-Oblique";
|
||||
else fontName = "Helvetica";
|
||||
}
|
||||
}
|
||||
|
||||
// --- FONT ENGINE INTEGRATION ---
|
||||
std::optional<FontInfo> matchedFontInfo;
|
||||
auto fontsRes = getFonts(pageIndex, pageIndex);
|
||||
if (fontsRes.has_value()) {
|
||||
for (const auto& fontInfoEntry : *fontsRes) {
|
||||
if ((!internalFontId.empty() && fontInfoEntry.internalFontId == internalFontId) ||
|
||||
(!origFontName.empty() && fontInfoEntry.fontName == origFontName)) {
|
||||
matchedFontInfo = fontInfoEntry;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
std::shared_ptr<fonts::pdf_fonts::Font> resolvedFont = nullptr;
|
||||
if (matchedFontInfo.has_value()) {
|
||||
auto resolvedFontRes = getResolvedFont(*matchedFontInfo);
|
||||
if (resolvedFontRes.has_value()) {
|
||||
resolvedFont = *resolvedFontRes;
|
||||
spdlog::info("Font Engine: resolved font '{}'", matchedFontInfo->fontName);
|
||||
} else {
|
||||
spdlog::warn("Font Engine: failed to resolve font '{}': {}", matchedFontInfo->fontName, resolvedFontRes.error());
|
||||
}
|
||||
}
|
||||
|
||||
bool fontSupportsAll = true;
|
||||
double totalWidth = 0.0;
|
||||
|
||||
auto utf16 = utf8_to_utf16le(newText);
|
||||
std::vector<uint32_t> unicodeCodepoints;
|
||||
for (size_t i = 0; i < utf16.size(); ) {
|
||||
uint32_t cp = utf16[i];
|
||||
if (cp >= 0xD800 && cp <= 0xDBFF && i + 1 < utf16.size()) {
|
||||
uint32_t low = utf16[i + 1];
|
||||
if (low >= 0xDC00 && low <= 0xDFFF) {
|
||||
cp = 0x10000 + ((cp - 0xD800) << 10) + (low - 0xDC00);
|
||||
i += 2;
|
||||
} else {
|
||||
i += 1;
|
||||
}
|
||||
} else {
|
||||
i += 1;
|
||||
}
|
||||
unicodeCodepoints.push_back(cp);
|
||||
}
|
||||
|
||||
bool isSubsetFont = matchedFontInfo && matchedFontInfo->isSubset;
|
||||
bool subsetLacksGlyphs = false;
|
||||
|
||||
// Perform glyph check
|
||||
if (resolvedFont) {
|
||||
for (uint32_t cp : unicodeCodepoints) {
|
||||
if (!resolvedFont->hasGlyph(cp)) {
|
||||
if (isSubsetFont) {
|
||||
subsetLacksGlyphs = true;
|
||||
} else {
|
||||
fontSupportsAll = false;
|
||||
}
|
||||
spdlog::warn("Font Engine: Glyph for codepoint {} not found in font {}", cp, matchedFontInfo ? matchedFontInfo->fontName : "Unknown");
|
||||
}
|
||||
}
|
||||
} else {
|
||||
fontSupportsAll = false;
|
||||
}
|
||||
|
||||
// Stage 7: HarfBuzz Shaping
|
||||
bool shapedSuccessful = false;
|
||||
if (resolvedFont) {
|
||||
try {
|
||||
fonts::HbShaper shaper;
|
||||
unsigned int uFontSize = static_cast<unsigned int>(fontSize > 0.0 ? fontSize : 12.0);
|
||||
auto shapedGlyphs = shaper.shapeRun(newText, resolvedFont->getFontFace(), uFontSize);
|
||||
if (!shapedGlyphs.empty()) {
|
||||
totalWidth = 0.0;
|
||||
for (const auto& sg : shapedGlyphs) {
|
||||
totalWidth += sg.advanceX;
|
||||
}
|
||||
shapedSuccessful = true;
|
||||
spdlog::info("Font Engine: HarfBuzz shaped '{}' glyphs, total advance width = {}", shapedGlyphs.size(), totalWidth);
|
||||
}
|
||||
} catch (const std::exception& e) {
|
||||
spdlog::warn("Font Engine: HarfBuzz shaping failed: {}", e.what());
|
||||
} catch (...) {
|
||||
spdlog::warn("Font Engine: HarfBuzz shaping failed with unknown exception");
|
||||
}
|
||||
}
|
||||
|
||||
if (!shapedSuccessful && resolvedFont) {
|
||||
totalWidth = 0.0;
|
||||
for (uint32_t cp : unicodeCodepoints) {
|
||||
double w = resolvedFont->getAdvanceWidth(cp, fontSize);
|
||||
totalWidth += w;
|
||||
}
|
||||
spdlog::info("Font Engine: FreeType fallback total advance width = {}", totalWidth);
|
||||
}
|
||||
|
||||
// Stage 8: Reflow Engine (bounds calculation and shift)
|
||||
float origLeft = 999999.0f, origRight = -999999.0f;
|
||||
float origBottom = 999999.0f, origTop = -999999.0f;
|
||||
bool hasOrigBounds = false;
|
||||
for (int idx : objectIndices) {
|
||||
FPDF_PAGEOBJECT obj = FPDFPage_GetObject(page, idx);
|
||||
if (obj) {
|
||||
float left = 0.0f, bottom = 0.0f, right = 0.0f, top = 0.0f;
|
||||
if (FPDFPageObj_GetBounds(obj, &left, &bottom, &right, &top)) {
|
||||
if (left < origLeft) origLeft = left;
|
||||
if (right > origRight) origRight = right;
|
||||
if (bottom < origBottom) origBottom = bottom;
|
||||
if (top > origTop) origTop = top;
|
||||
hasOrigBounds = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
double origWidth = 0.0;
|
||||
double origCenterY = 0.0;
|
||||
if (hasOrigBounds) {
|
||||
origWidth = origRight - origLeft;
|
||||
origCenterY = (origBottom + origTop) / 2.0;
|
||||
}
|
||||
|
||||
double deltaX = 0.0;
|
||||
if (hasOrigBounds) {
|
||||
deltaX = totalWidth - origWidth;
|
||||
spdlog::info("Reflow Engine: origWidth = {}, newWidth = {}, deltaX = {}", origWidth, totalWidth, deltaX);
|
||||
}
|
||||
|
||||
if (hasOrigBounds && std::abs(deltaX) > 0.001) {
|
||||
int pageObjCount = FPDFPage_CountObjects(page);
|
||||
double tolerance = (std::max)(5.0, fontSize * 0.5);
|
||||
int reflowedCount = 0;
|
||||
|
||||
for (int k = 0; k < pageObjCount; ++k) {
|
||||
// Skip if it is one of the replaced objects
|
||||
if (std::find(objectIndices.begin(), objectIndices.end(), k) != objectIndices.end()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
FPDF_PAGEOBJECT otherObj = FPDFPage_GetObject(page, k);
|
||||
if (otherObj && FPDFPageObj_GetType(otherObj) == FPDF_PAGEOBJ_TEXT) {
|
||||
float otherLeft = 0.0f, otherBottom = 0.0f, otherRight = 0.0f, otherTop = 0.0f;
|
||||
if (FPDFPageObj_GetBounds(otherObj, &otherLeft, &otherBottom, &otherRight, &otherTop)) {
|
||||
double otherCenterY = (otherBottom + otherTop) / 2.0;
|
||||
// Check if on the same horizontal line
|
||||
if (std::abs(otherCenterY - origCenterY) <= tolerance) {
|
||||
// Check if it is to the right of the replaced text run
|
||||
if (otherLeft >= (origRight - 2.0f)) {
|
||||
FPDFPageObj_Transform(otherObj, 1.0, 0.0, 0.0, 1.0, deltaX, 0.0);
|
||||
reflowedCount++;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
spdlog::info("Reflow Engine: shifted {} subsequent text objects on the same line by {}", reflowedCount, deltaX);
|
||||
}
|
||||
|
||||
// 3. Delete old objects
|
||||
for (int idx : objectIndices) {
|
||||
FPDF_PAGEOBJECT objToRemove = FPDFPage_GetObject(page, idx);
|
||||
if (objToRemove) {
|
||||
FPDFPage_RemoveObject(page, objToRemove);
|
||||
FPDFPageObj_Destroy(objToRemove);
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Create new text object using standard, embedded, or system font
|
||||
FPDF_FONT font = nullptr;
|
||||
std::string cacheKey = "";
|
||||
bool useEmbedded = false;
|
||||
bool useSystem = false;
|
||||
|
||||
if (resolvedFont && matchedFontInfo) {
|
||||
if (matchedFontInfo->isEmbedded && !isSubsetFont && fontSupportsAll) {
|
||||
cacheKey = matchedFontInfo->internalFontId;
|
||||
useEmbedded = true;
|
||||
} else if (matchedFontInfo->isEmbedded && isSubsetFont && !subsetLacksGlyphs) {
|
||||
cacheKey = matchedFontInfo->internalFontId;
|
||||
useEmbedded = true;
|
||||
} else {
|
||||
cacheKey = "system_embed_" + matchedFontInfo->fontName + "_" + (bold ? "B" : "") + (italic ? "I" : "");
|
||||
useSystem = true;
|
||||
}
|
||||
} else {
|
||||
cacheKey = "standard_" + fontName;
|
||||
}
|
||||
|
||||
// Check cache first
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(loadedFontsMutex_);
|
||||
if (loadedFontsCache_.count(cacheKey)) {
|
||||
font = loadedFontsCache_[cacheKey];
|
||||
spdlog::info("Font Engine: Reusing cached FPDF_FONT for key '{}'", cacheKey);
|
||||
}
|
||||
}
|
||||
|
||||
if (!font) {
|
||||
if (useEmbedded) {
|
||||
auto fontDataRes = getFontData(matchedFontInfo->internalFontId);
|
||||
if (fontDataRes.has_value()) {
|
||||
std::lock_guard<std::mutex> lock(loadedFontsMutex_);
|
||||
loadedFontDataBuffers_[cacheKey] = fontDataRes.value();
|
||||
const auto& bytes = loadedFontDataBuffers_[cacheKey];
|
||||
font = FPDFText_LoadFont(doc_, bytes.data(), static_cast<uint32_t>(bytes.size()), FPDF_FONT_TRUETYPE, false);
|
||||
if (font) {
|
||||
spdlog::info("Font Engine: Loaded embedded font '{}' (cache key: {})", matchedFontInfo->fontName, cacheKey);
|
||||
}
|
||||
}
|
||||
} else if (useSystem) {
|
||||
std::string fontPath = fonts::pdf_fonts::FontFallback::getInstance().getFallbackFontPath(
|
||||
matchedFontInfo->normalizedFamily.empty() ? matchedFontInfo->fontName : matchedFontInfo->normalizedFamily,
|
||||
bold,
|
||||
italic
|
||||
);
|
||||
std::ifstream fs(fontPath, std::ios::binary);
|
||||
if (fs) {
|
||||
std::vector<uint8_t> fileBytes((std::istreambuf_iterator<char>(fs)), std::istreambuf_iterator<char>());
|
||||
if (!fileBytes.empty()) {
|
||||
std::lock_guard<std::mutex> lock(loadedFontsMutex_);
|
||||
loadedFontDataBuffers_[cacheKey] = std::move(fileBytes);
|
||||
const auto& bytes = loadedFontDataBuffers_[cacheKey];
|
||||
font = FPDFText_LoadFont(doc_, bytes.data(), static_cast<uint32_t>(bytes.size()), FPDF_FONT_TRUETYPE, false);
|
||||
if (font) {
|
||||
spdlog::info("Font Engine: Embedded system font '{}' from path '{}' (cache key: {})", matchedFontInfo->fontName, fontPath, cacheKey);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
spdlog::warn("Font Engine: Failed to open system font file '{}' for embedding", fontPath);
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to standard PDF 14 font if loading failed
|
||||
if (!font) {
|
||||
spdlog::info("Font Engine: Loading standard PDF font for replace_text: {}", fontName);
|
||||
font = FPDFText_LoadStandardFont(doc_, fontName.c_str());
|
||||
if (!font) {
|
||||
font = FPDFText_LoadStandardFont(doc_, "Helvetica");
|
||||
}
|
||||
}
|
||||
|
||||
// Cache the loaded font
|
||||
if (font) {
|
||||
std::lock_guard<std::mutex> lock(loadedFontsMutex_);
|
||||
loadedFontsCache_[cacheKey] = font;
|
||||
}
|
||||
}
|
||||
|
||||
if (font) {
|
||||
FPDF_PAGEOBJECT newTextObj = FPDFPageObj_CreateTextObj(doc_, font, static_cast<float>(fontSize));
|
||||
if (newTextObj) {
|
||||
FPDFPageObj_SetFillColor(newTextObj, r, g, b_color, a_color);
|
||||
FPDFTextObj_SetTextRenderMode(newTextObj, renderMode);
|
||||
FPDFText_SetText(newTextObj, reinterpret_cast<FPDF_WIDESTRING>(utf16.data()));
|
||||
FPDFPageObj_Transform(newTextObj, a, b, c, d, e, f);
|
||||
|
||||
FPDFPage_InsertObjectAtIndex(page, newTextObj, minIndex);
|
||||
} else {
|
||||
spdlog::error("Failed to create new text object");
|
||||
}
|
||||
}
|
||||
|
||||
if (!FPDFPage_GenerateContent(page)) {
|
||||
spdlog::error("Failed to generate page content after replace_text");
|
||||
}
|
||||
|
||||
FPDF_ClosePage(page);
|
||||
} else if (type == "text_overlay" || type == "add_text") {
|
||||
if (!op.contains("data") || !op["data"].is_object()) {
|
||||
spdlog::error("text_overlay/add_text operation missing 'data' object");
|
||||
return std::unexpected(EngineError::InvalidFormat);
|
||||
@@ -1805,6 +2300,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()) {
|
||||
@@ -1812,43 +2483,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");
|
||||
@@ -2848,6 +3576,13 @@ void PdfiumDocument::invalidateCaches() {
|
||||
std::lock_guard<std::mutex> lock(resolvedFontsMutex_);
|
||||
resolvedFontsCache_.clear();
|
||||
}
|
||||
#ifdef PDFENGINE_WITH_PDFIUM
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(loadedFontsMutex_);
|
||||
loadedFontsCache_.clear();
|
||||
loadedFontDataBuffers_.clear();
|
||||
}
|
||||
#endif
|
||||
spdlog::info("Document caches have been invalidated.");
|
||||
}
|
||||
|
||||
|
||||
@@ -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,13 +110,23 @@ 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
|
||||
std::unique_ptr<pdfengine::fonts::loader::FontResolver> fontResolver_;
|
||||
std::unordered_map<std::string, std::shared_ptr<fonts::pdf_fonts::Font>> resolvedFontsCache_;
|
||||
std::mutex resolvedFontsMutex_;
|
||||
|
||||
// Loaded PDFium Font Cache for Font Reuse
|
||||
#ifdef PDFENGINE_WITH_PDFIUM
|
||||
mutable std::unordered_map<std::string, FPDF_FONT> loadedFontsCache_;
|
||||
mutable std::unordered_map<std::string, std::vector<uint8_t>> loadedFontDataBuffers_;
|
||||
mutable std::mutex loadedFontsMutex_;
|
||||
#endif
|
||||
};
|
||||
|
||||
// Exposed for testing
|
||||
|
||||
@@ -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
|
||||
@@ -11,6 +11,7 @@
|
||||
#include <atomic>
|
||||
#include <chrono>
|
||||
#include "fonts/cache/glyph_cache.hpp"
|
||||
#include "fonts/pdf_fonts/font.hpp"
|
||||
#ifndef TEST_CORPUS_DIR
|
||||
#define TEST_CORPUS_DIR "../../corpus"
|
||||
#endif
|
||||
@@ -1336,4 +1337,510 @@ TEST(GlyphCacheTest, ConcurrencyBench) {
|
||||
EXPECT_LE(cache.size(), 1000 + 16); // Accommodate shard capacity rounding
|
||||
}
|
||||
|
||||
TEST(FontDiagnosticsTest, EmbeddedFontResolutionAndReloadingVerification) {
|
||||
SKIP_IF_NO_PDFIUM();
|
||||
|
||||
std::vector<std::string> testFiles = {
|
||||
"text_font.pdf",
|
||||
"embedded_truetype.pdf",
|
||||
"embedded_cid_font.pdf",
|
||||
"subset_font.pdf",
|
||||
"latin_extended.pdf"
|
||||
};
|
||||
|
||||
bool foundAnyEmbedded = false;
|
||||
for (const auto& fileName : testFiles) {
|
||||
auto path = getCorpusPath("fonts", fileName);
|
||||
if (!std::filesystem::exists(path)) {
|
||||
continue;
|
||||
}
|
||||
std::cout << "\n========================================\n";
|
||||
std::cout << "Testing PDF: " << fileName << "\n";
|
||||
std::cout << "========================================\n";
|
||||
|
||||
auto docRes = PdfDocument::loadFromFile(path.string());
|
||||
if (!docRes.has_value()) {
|
||||
std::cout << "Failed to load document: " << fileName << std::endl;
|
||||
continue;
|
||||
}
|
||||
auto doc = *docRes;
|
||||
|
||||
auto fontsRes = doc->getFonts();
|
||||
if (!fontsRes.has_value()) {
|
||||
std::cout << "Failed to get fonts for: " << fileName << std::endl;
|
||||
continue;
|
||||
}
|
||||
const auto& fonts = *fontsRes;
|
||||
|
||||
for (const auto& fontInfo : fonts) {
|
||||
std::cout << "Font: " << fontInfo.fontName
|
||||
<< ", type: " << fontInfo.type
|
||||
<< ", isEmbedded: " << (fontInfo.isEmbedded ? "yes" : "no")
|
||||
<< ", flags: " << fontInfo.flags << std::endl;
|
||||
if (fontInfo.isEmbedded) {
|
||||
foundAnyEmbedded = true;
|
||||
|
||||
auto resolvedFontRes = doc->getResolvedFont(fontInfo);
|
||||
if (!resolvedFontRes.has_value()) {
|
||||
std::cout << " Failed to resolve font: " << resolvedFontRes.error() << std::endl;
|
||||
continue;
|
||||
}
|
||||
auto resolvedFont = *resolvedFontRes;
|
||||
std::cout << " Resolved font successfully." << std::endl;
|
||||
|
||||
auto face = static_cast<FT_Face>(resolvedFont->getFontFace().getFace());
|
||||
if (face) {
|
||||
std::cout << " FreeType Face Num Glyphs: " << face->num_glyphs << std::endl;
|
||||
std::cout << " FreeType Charmaps Count: " << face->num_charmaps << std::endl;
|
||||
for (int i = 0; i < face->num_charmaps; ++i) {
|
||||
FT_CharMap cm = face->charmaps[i];
|
||||
std::cout << " Charmap " << i << ": platform_id=" << cm->platform_id
|
||||
<< ", encoding_id=" << cm->encoding_id << std::endl;
|
||||
|
||||
FT_Error err = FT_Set_Charmap(face, cm);
|
||||
if (err) {
|
||||
std::cout << " FT_Set_Charmap failed: " << err << std::endl;
|
||||
continue;
|
||||
}
|
||||
|
||||
FT_UInt gindex;
|
||||
FT_ULong charcode = FT_Get_First_Char(face, &gindex);
|
||||
std::cout << " Mapped characters under charmap " << i << ": ";
|
||||
int count = 0;
|
||||
while (gindex != 0 && count < 10) {
|
||||
std::cout << charcode << "->" << gindex << " ";
|
||||
charcode = FT_Get_Next_Char(face, charcode, &gindex);
|
||||
count++;
|
||||
}
|
||||
std::cout << std::endl;
|
||||
}
|
||||
|
||||
// Restore first charmap
|
||||
if (face->num_charmaps > 0) {
|
||||
FT_Set_Charmap(face, face->charmaps[0]);
|
||||
}
|
||||
|
||||
// Print all glyph names in the face
|
||||
std::cout << " Glyph names: ";
|
||||
for (int i = 0; i < face->num_glyphs; ++i) {
|
||||
char nameBuf[64] = {0};
|
||||
if (FT_Get_Glyph_Name(face, i, nameBuf, sizeof(nameBuf)) == 0) {
|
||||
std::cout << i << ":" << nameBuf << " ";
|
||||
} else {
|
||||
std::cout << i << ":[unknown] ";
|
||||
}
|
||||
}
|
||||
std::cout << std::endl;
|
||||
} else {
|
||||
std::cout << " No FreeType Face available." << std::endl;
|
||||
}
|
||||
|
||||
EXPECT_TRUE(resolvedFont->isEmbedded());
|
||||
|
||||
// Let's test a few common characters: 'A' (65), 'a' (97), '0' (48), ' ' (32)
|
||||
std::vector<uint32_t> testChars = {32, 48, 65, 97};
|
||||
for (uint32_t cp : testChars) {
|
||||
bool hasG = resolvedFont->hasGlyph(cp);
|
||||
double w = resolvedFont->getAdvanceWidth(cp, 12.0);
|
||||
std::cout << " char(" << cp << "): hasGlyph=" << (hasG ? "yes" : "no")
|
||||
<< ", advanceWidth=" << w << std::endl;
|
||||
}
|
||||
|
||||
// Verify metrics returned are non-zero/valid
|
||||
auto metrics = resolvedFont->getMetrics(12.0);
|
||||
std::cout << " Metrics: ascent=" << metrics.ascent << ", descent=" << metrics.descent << ", capHeight=" << metrics.capHeight << std::endl;
|
||||
EXPECT_NE(metrics.ascent, 0.0);
|
||||
EXPECT_NE(metrics.descent, 0.0);
|
||||
EXPECT_NE(metrics.capHeight, 0.0);
|
||||
|
||||
// Specific verification for text_font.pdf where we mapped charcode 1 -> GID 1
|
||||
if (fileName == "text_font.pdf") {
|
||||
// hasGlyph(1) should return true because the charmap maps 1 -> 1
|
||||
EXPECT_TRUE(resolvedFont->hasGlyph(1));
|
||||
double w = resolvedFont->getAdvanceWidth(1, 12.0);
|
||||
EXPECT_GT(w, 0.0);
|
||||
std::cout << " [VERIFIED] text_font.pdf char(1): hasGlyph=yes, advanceWidth=" << w << std::endl;
|
||||
}
|
||||
|
||||
// Verify we can load glyphs directly by glyph index (0 to num_glyphs - 1)
|
||||
if (face->num_glyphs > 1) {
|
||||
bool foundNonZeroWidth = false;
|
||||
for (int gid = 1; gid < face->num_glyphs; ++gid) {
|
||||
FT_Error err = FT_Load_Glyph(face, gid, FT_LOAD_DEFAULT);
|
||||
if (err == 0) {
|
||||
double directWidth = static_cast<double>(face->glyph->advance.x) / 64.0;
|
||||
if (directWidth > 0.0) {
|
||||
foundNonZeroWidth = true;
|
||||
std::cout << " [VERIFIED] Direct glyph " << gid << " load: advanceWidth=" << directWidth << std::endl;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
EXPECT_TRUE(foundNonZeroWidth) << "Expected to find at least one glyph with a non-zero advance width";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
EXPECT_TRUE(foundAnyEmbedded) << "Expected to find at least one embedded font in test files";
|
||||
}
|
||||
|
||||
TEST(DocumentEditTest, ReplaceTextMVPStandardFont) {
|
||||
SKIP_IF_NO_PDFIUM();
|
||||
auto path = getCorpusPath("basic", "hello_world.pdf");
|
||||
if (!std::filesystem::exists(path)) {
|
||||
GTEST_SKIP() << "hello_world.pdf not found in corpus.";
|
||||
}
|
||||
|
||||
auto docRes = PdfDocument::loadFromFile(path.string());
|
||||
ASSERT_TRUE(docRes.has_value());
|
||||
auto doc = *docRes;
|
||||
|
||||
auto pageRes = doc->getPage(0);
|
||||
ASSERT_TRUE(pageRes.has_value());
|
||||
auto pageObj = *pageRes;
|
||||
|
||||
auto modelRes = pageObj->extractDocumentModel();
|
||||
ASSERT_TRUE(modelRes.has_value());
|
||||
const auto& model = *modelRes;
|
||||
|
||||
std::vector<int> objectIndices;
|
||||
for (const auto& p : model.paragraphs) {
|
||||
for (const auto& line : p.lines) {
|
||||
for (const auto& run : line.runs) {
|
||||
if (run.text.find("Hello") != std::string::npos) {
|
||||
objectIndices = run.objectIndices;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!objectIndices.empty()) break;
|
||||
}
|
||||
if (!objectIndices.empty()) break;
|
||||
}
|
||||
|
||||
ASSERT_FALSE(objectIndices.empty()) << "Could not find a text object in hello_world.pdf";
|
||||
|
||||
std::string indicesStr = "";
|
||||
for (size_t i = 0; i < objectIndices.size(); ++i) {
|
||||
indicesStr += std::to_string(objectIndices[i]);
|
||||
if (i + 1 < objectIndices.size()) indicesStr += ",";
|
||||
}
|
||||
|
||||
// Flat format payload
|
||||
std::string editsJson = R"({
|
||||
"version": "1.0",
|
||||
"operations": [
|
||||
{
|
||||
"id": "op_mvp_1",
|
||||
"type": "replace_text",
|
||||
"pageIndex": 0,
|
||||
"objectIndices": [)" + indicesStr + R"(],
|
||||
"text": "Greeting, universe!"
|
||||
}
|
||||
]
|
||||
})";
|
||||
|
||||
auto editRes = doc->applyEdits(editsJson);
|
||||
ASSERT_TRUE(editRes.has_value());
|
||||
|
||||
auto saveRes = doc->saveIncremental();
|
||||
ASSERT_TRUE(saveRes.has_value());
|
||||
const auto& savedBytes = *saveRes;
|
||||
ASSERT_FALSE(savedBytes.empty());
|
||||
|
||||
auto newDocRes = PdfDocument::loadFromMemory(savedBytes);
|
||||
ASSERT_TRUE(newDocRes.has_value());
|
||||
auto newDoc = *newDocRes;
|
||||
|
||||
auto newPageRes = newDoc->getPage(0);
|
||||
ASSERT_TRUE(newPageRes.has_value());
|
||||
auto newPage = *newPageRes;
|
||||
|
||||
auto textRes = newPage->extractText();
|
||||
ASSERT_TRUE(textRes.has_value());
|
||||
EXPECT_NE(textRes->find("Greeting, universe!"), std::string::npos);
|
||||
EXPECT_EQ(textRes->find("Hello"), std::string::npos);
|
||||
}
|
||||
|
||||
TEST(DocumentEditTest, ReplaceTextRuntimeFontEngine) {
|
||||
SKIP_IF_NO_PDFIUM();
|
||||
auto path = getCorpusPath("fonts", "latin_extended.pdf");
|
||||
if (!std::filesystem::exists(path)) {
|
||||
GTEST_SKIP() << "latin_extended.pdf not found in corpus.";
|
||||
}
|
||||
|
||||
auto docRes = PdfDocument::loadFromFile(path.string());
|
||||
ASSERT_TRUE(docRes.has_value());
|
||||
auto doc = *docRes;
|
||||
|
||||
auto pageRes = doc->getPage(0);
|
||||
ASSERT_TRUE(pageRes.has_value());
|
||||
auto pageObj = *pageRes;
|
||||
|
||||
auto modelRes = pageObj->extractDocumentModel();
|
||||
ASSERT_TRUE(modelRes.has_value());
|
||||
const auto& model = *modelRes;
|
||||
|
||||
std::vector<int> objectIndices;
|
||||
std::string originalFontId = "";
|
||||
for (const auto& p : model.paragraphs) {
|
||||
for (const auto& line : p.lines) {
|
||||
for (const auto& run : line.runs) {
|
||||
if (run.fontName.find("Roboto-Regular") != std::string::npos) {
|
||||
objectIndices = run.objectIndices;
|
||||
originalFontId = run.internalFontId;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!objectIndices.empty()) break;
|
||||
}
|
||||
if (!objectIndices.empty()) break;
|
||||
}
|
||||
|
||||
ASSERT_FALSE(objectIndices.empty()) << "Could not find target text run in latin_extended.pdf";
|
||||
|
||||
std::string indicesStr = "";
|
||||
for (size_t i = 0; i < objectIndices.size(); ++i) {
|
||||
indicesStr += std::to_string(objectIndices[i]);
|
||||
if (i + 1 < objectIndices.size()) indicesStr += ",";
|
||||
}
|
||||
|
||||
// JSON payload including internalFontId to resolve
|
||||
std::string editsJson = R"({
|
||||
"version": "1.0",
|
||||
"operations": [
|
||||
{
|
||||
"id": "op_engine_1",
|
||||
"type": "replace_text",
|
||||
"pageIndex": 0,
|
||||
"objectIndices": [)" + indicesStr + R"(],
|
||||
"text": "Font Engine Active!",
|
||||
"internalFontId": ")" + originalFontId + R"("
|
||||
}
|
||||
]
|
||||
})";
|
||||
|
||||
auto editRes = doc->applyEdits(editsJson);
|
||||
ASSERT_TRUE(editRes.has_value());
|
||||
|
||||
auto saveRes = doc->saveIncremental();
|
||||
ASSERT_TRUE(saveRes.has_value());
|
||||
const auto& savedBytes = *saveRes;
|
||||
ASSERT_FALSE(savedBytes.empty());
|
||||
|
||||
auto newDocRes = PdfDocument::loadFromMemory(savedBytes);
|
||||
ASSERT_TRUE(newDocRes.has_value());
|
||||
auto newDoc = *newDocRes;
|
||||
|
||||
auto newPageRes = newDoc->getPage(0);
|
||||
ASSERT_TRUE(newPageRes.has_value());
|
||||
auto newPage = *newPageRes;
|
||||
|
||||
auto textRes = newPage->extractText();
|
||||
ASSERT_TRUE(textRes.has_value());
|
||||
EXPECT_NE(textRes->find("Font Engine Active!"), std::string::npos);
|
||||
}
|
||||
|
||||
TEST(DocumentEditTest, ReplaceTextFontReuseAndEmbedding) {
|
||||
SKIP_IF_NO_PDFIUM();
|
||||
auto path = getCorpusPath("basic", "hello_world.pdf");
|
||||
if (!std::filesystem::exists(path)) {
|
||||
GTEST_SKIP() << "hello_world.pdf not found in corpus.";
|
||||
}
|
||||
|
||||
auto docRes = PdfDocument::loadFromFile(path.string());
|
||||
ASSERT_TRUE(docRes.has_value());
|
||||
auto doc = *docRes;
|
||||
|
||||
auto pageRes = doc->getPage(0);
|
||||
ASSERT_TRUE(pageRes.has_value());
|
||||
auto pageObj = *pageRes;
|
||||
|
||||
auto modelRes = pageObj->extractDocumentModel();
|
||||
ASSERT_TRUE(modelRes.has_value());
|
||||
const auto& model = *modelRes;
|
||||
|
||||
// Find the first text run
|
||||
std::vector<int> objectIndices;
|
||||
std::string originalFontId = "";
|
||||
for (const auto& p : model.paragraphs) {
|
||||
for (const auto& line : p.lines) {
|
||||
for (const auto& run : line.runs) {
|
||||
if (!run.objectIndices.empty()) {
|
||||
objectIndices = run.objectIndices;
|
||||
originalFontId = run.internalFontId;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!objectIndices.empty()) break;
|
||||
}
|
||||
if (!objectIndices.empty()) break;
|
||||
}
|
||||
|
||||
ASSERT_FALSE(objectIndices.empty()) << "Could not find a text run in hello_world.pdf";
|
||||
|
||||
std::string indicesStr = "";
|
||||
for (size_t i = 0; i < objectIndices.size(); ++i) {
|
||||
indicesStr += std::to_string(objectIndices[i]);
|
||||
if (i + 1 < objectIndices.size()) indicesStr += ",";
|
||||
}
|
||||
|
||||
// JSON payload containing replacement using system font embedding
|
||||
std::string editsJson = R"({
|
||||
"version": "1.0",
|
||||
"operations": [
|
||||
{
|
||||
"id": "op_reuse_1",
|
||||
"type": "replace_text",
|
||||
"pageIndex": 0,
|
||||
"objectIndices": [)" + indicesStr + R"(],
|
||||
"text": "Embedded Arial",
|
||||
"internalFontId": ")" + originalFontId + R"("
|
||||
}
|
||||
]
|
||||
})";
|
||||
|
||||
auto editRes = doc->applyEdits(editsJson);
|
||||
ASSERT_TRUE(editRes.has_value());
|
||||
|
||||
// Save and reload
|
||||
auto saveRes = doc->saveIncremental();
|
||||
ASSERT_TRUE(saveRes.has_value());
|
||||
const auto& savedBytes = *saveRes;
|
||||
ASSERT_FALSE(savedBytes.empty());
|
||||
|
||||
auto newDocRes = PdfDocument::loadFromMemory(savedBytes);
|
||||
ASSERT_TRUE(newDocRes.has_value());
|
||||
auto newDoc = *newDocRes;
|
||||
|
||||
// Get page fonts to verify that Arial was successfully embedded in the new document
|
||||
auto fontsRes = newDoc->getFonts(0, 0);
|
||||
ASSERT_TRUE(fontsRes.has_value());
|
||||
|
||||
bool foundEmbeddedArial = false;
|
||||
for (const auto& f : *fontsRes) {
|
||||
if (f.isEmbedded && (f.fontName.find("Arial") != std::string::npos || f.fontName.find("LiberationSans") != std::string::npos)) {
|
||||
foundEmbeddedArial = true;
|
||||
}
|
||||
}
|
||||
|
||||
std::cout << "Font Embedding Test: foundEmbeddedArial = " << foundEmbeddedArial << std::endl;
|
||||
}
|
||||
|
||||
TEST(DocumentEditTest, ReplaceTextHarfBuzzShapingAndReflow) {
|
||||
SKIP_IF_NO_PDFIUM();
|
||||
auto path = getCorpusPath("fonts", "latin_extended.pdf");
|
||||
if (!std::filesystem::exists(path)) {
|
||||
GTEST_SKIP() << "latin_extended.pdf not found in corpus.";
|
||||
}
|
||||
|
||||
auto docRes = PdfDocument::loadFromFile(path.string());
|
||||
ASSERT_TRUE(docRes.has_value());
|
||||
auto doc = *docRes;
|
||||
|
||||
auto pageRes = doc->getPage(0);
|
||||
ASSERT_TRUE(pageRes.has_value());
|
||||
auto pageObj = *pageRes;
|
||||
|
||||
auto modelRes = pageObj->extractDocumentModel();
|
||||
ASSERT_TRUE(modelRes.has_value());
|
||||
const auto& model = *modelRes;
|
||||
|
||||
// Find a line that has at least 2 runs, where the first run uses Roboto-Regular
|
||||
std::vector<int> targetIndices;
|
||||
std::string originalFontId = "";
|
||||
std::string runBText = "";
|
||||
double runBOrigX = 0.0;
|
||||
double runBOrigY = 0.0;
|
||||
|
||||
for (const auto& p : model.paragraphs) {
|
||||
for (const auto& line : p.lines) {
|
||||
if (line.runs.size() >= 2) {
|
||||
const auto& runA = line.runs[0];
|
||||
const auto& runB = line.runs[1];
|
||||
if (runA.fontName.find("Roboto-Regular") != std::string::npos &&
|
||||
!runA.objectIndices.empty() &&
|
||||
runB.x > runA.x) {
|
||||
targetIndices = runA.objectIndices;
|
||||
originalFontId = runA.internalFontId;
|
||||
runBText = runB.text;
|
||||
runBOrigX = runB.x;
|
||||
runBOrigY = runB.y;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!targetIndices.empty()) break;
|
||||
}
|
||||
|
||||
if (targetIndices.empty()) {
|
||||
GTEST_SKIP() << "Could not find a suitable line with multiple runs to test reflow.";
|
||||
}
|
||||
|
||||
std::string indicesStr = "";
|
||||
for (size_t i = 0; i < targetIndices.size(); ++i) {
|
||||
indicesStr += std::to_string(targetIndices[i]);
|
||||
if (i + 1 < targetIndices.size()) indicesStr += ",";
|
||||
}
|
||||
|
||||
// JSON payload: replacing runA with a very long text to trigger significant shift
|
||||
std::string editsJson = R"({
|
||||
"version": "1.0",
|
||||
"operations": [
|
||||
{
|
||||
"id": "op_reflow_1",
|
||||
"type": "replace_text",
|
||||
"pageIndex": 0,
|
||||
"objectIndices": [)" + indicesStr + R"(],
|
||||
"text": "This is an extremely long replacement text to force the Reflow Engine to shift subsequent runs!",
|
||||
"internalFontId": ")" + originalFontId + R"("
|
||||
}
|
||||
]
|
||||
})";
|
||||
|
||||
auto editRes = doc->applyEdits(editsJson);
|
||||
ASSERT_TRUE(editRes.has_value());
|
||||
|
||||
// Save and reload
|
||||
auto saveRes = doc->saveIncremental();
|
||||
ASSERT_TRUE(saveRes.has_value());
|
||||
const auto& savedBytes = *saveRes;
|
||||
ASSERT_FALSE(savedBytes.empty());
|
||||
|
||||
auto newDocRes = PdfDocument::loadFromMemory(savedBytes);
|
||||
ASSERT_TRUE(newDocRes.has_value());
|
||||
auto newDoc = *newDocRes;
|
||||
|
||||
auto newPageRes = newDoc->getPage(0);
|
||||
ASSERT_TRUE(newPageRes.has_value());
|
||||
auto newPage = *newPageRes;
|
||||
|
||||
auto newModelRes = newPage->extractDocumentModel();
|
||||
ASSERT_TRUE(newModelRes.has_value());
|
||||
const auto& newModel = *newModelRes;
|
||||
|
||||
// Find runB in the new document model and verify its X coordinate has shifted to the right
|
||||
bool foundRunB = false;
|
||||
double runBNewX = 0.0;
|
||||
for (const auto& p : newModel.paragraphs) {
|
||||
for (const auto& line : p.lines) {
|
||||
for (const auto& run : line.runs) {
|
||||
if (run.text == runBText && std::abs(run.y - runBOrigY) < 5.0) {
|
||||
foundRunB = true;
|
||||
runBNewX = run.x;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (foundRunB) break;
|
||||
}
|
||||
if (foundRunB) break;
|
||||
}
|
||||
|
||||
ASSERT_TRUE(foundRunB) << "Could not find the subsequent text run '" << runBText << "' in the reflowed document.";
|
||||
EXPECT_GT(runBNewX, runBOrigX + 10.0) << "The subsequent text run did not shift to the right by at least 10 points.";
|
||||
|
||||
std::cout << "Reflow Engine verified: '" << runBText << "' shifted from X=" << runBOrigX << " to X=" << runBNewX << std::endl;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -12,6 +12,7 @@ import type { CustomConfirmationOptions } from './components/custom/CustomConfir
|
||||
import { PDFViewer } from './viewer/PDFViewer';
|
||||
import type { PDFViewerRef } from './viewer/PDFViewer';
|
||||
import type { Annotation } from './viewer/AnnotationLayer';
|
||||
import type { EditableRun } from './viewer/TextEditLayer';
|
||||
import { gatewayService } from './lib/gatewayService';
|
||||
import type { DocumentInfo, SearchResult, EditOperation, DocumentMetadata, FontInfo, OutlineItem } from './lib/gatewayService';
|
||||
import { viewportRectToPdf } from './lib/coordinateMapping';
|
||||
@@ -271,6 +272,19 @@ function App() {
|
||||
setActiveTool('select');
|
||||
};
|
||||
|
||||
// Rewrite existing page text in place. `run` is in PDF bottom-left points (the
|
||||
// space getPageModel / the engine use), so no coordinate flip is needed here.
|
||||
const handleEditText = (pageIndex: number, run: EditableRun, newText: string) => {
|
||||
applyOps([{
|
||||
id: rid('edit'), type: 'edit_text', pageIndex,
|
||||
data: {
|
||||
x: run.x, y: run.y, width: run.w, height: run.h,
|
||||
newText, originalText: run.text, fontSize: run.fontSize, fallbackFont: 'Helvetica',
|
||||
},
|
||||
}], 'Text updated');
|
||||
setActiveTool('select');
|
||||
};
|
||||
|
||||
const handlePlaceStamp = (pageIndex: number, point: { x: number; y: number }) => {
|
||||
if (!activeStamp) return;
|
||||
const fontSize = 22;
|
||||
@@ -523,13 +537,14 @@ function App() {
|
||||
searchCurrentMatch={searchCurrentMatch}
|
||||
onAnnotationAdded={handleAnnotationAdded}
|
||||
onAnnotationUpdate={handleUpdateAnnotation}
|
||||
onAnnotationClick={(a) => {
|
||||
onAnnotationClick={() => {
|
||||
setInspectorTab('notes');
|
||||
if (!isInspectorOpen) setIsInspectorOpen(true);
|
||||
}}
|
||||
onPageVisible={setCurrentPage}
|
||||
onRedactArea={handleRedactArea}
|
||||
onPlaceText={handlePlaceText}
|
||||
onEditText={handleEditText}
|
||||
onPlaceStamp={handlePlaceStamp}
|
||||
onPlaceSignature={handlePlaceSignature}
|
||||
/>
|
||||
|
||||
@@ -395,13 +395,13 @@ const FontsTab: React.FC<{ fonts: FontInfo[] }> = ({ fonts }) => {
|
||||
return <EmptyState icon={<FontsIcon size={30} />} title="No font data" hint="Font inventory appears once a document with embedded fonts is open." />;
|
||||
}
|
||||
const seen = new Set<string>();
|
||||
const unique = fonts.filter((f) => (seen.has(f.name) ? false : (seen.add(f.name), true)));
|
||||
const unique = fonts.filter((f) => (seen.has(f.fontName) ? false : (seen.add(f.fontName), true)));
|
||||
return (
|
||||
<div className="flex flex-col gap-2 p-3">
|
||||
{unique.map((f, i) => (
|
||||
<div key={i} className="rounded-[8px] border border-[#ebedf0] bg-[#f6f7f9] p-2.5">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="truncate font-mono text-[12px] font-semibold text-[#18212e]" title={f.name}>{f.name || 'Unknown'}</span>
|
||||
<span className="truncate font-mono text-[12px] font-semibold text-[#18212e]" title={f.fontName}>{f.fontName || 'Unknown'}</span>
|
||||
{f.type && <span className="shrink-0 rounded bg-[#edeff2] px-1.5 py-0.5 text-[9px] font-bold uppercase text-[#98a1ad]">{f.type}</span>}
|
||||
</div>
|
||||
<div className="mt-1.5 flex flex-wrap gap-1">
|
||||
|
||||
@@ -17,6 +17,17 @@ const TOOLS: (ToolDef | 'divider')[] = [
|
||||
{ id: 'draw', label: 'Draw (ink)', shortcut: 'D', icon: <DrawIcon /> },
|
||||
{ id: 'comment', label: 'Comment', shortcut: 'C', icon: <CommentIcon /> },
|
||||
{ id: 'textbox', label: 'Text box', shortcut: 'T', icon: <TextBoxIcon /> },
|
||||
{
|
||||
id: 'edit_text',
|
||||
label: 'Edit text',
|
||||
shortcut: 'E',
|
||||
icon: (
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M12 20h9" />
|
||||
<path d="M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4Z" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{ id: 'signature', label: 'Signature', shortcut: 'S', icon: <SignatureIcon /> },
|
||||
{ id: 'stamp', label: 'Stamp', shortcut: 'M', icon: <StampIcon /> },
|
||||
'divider',
|
||||
|
||||
@@ -25,6 +25,7 @@ const TOOL_META: Record<ToolId, { label: string; icon: React.ReactNode }> = {
|
||||
draw: { label: 'Draw', icon: <DrawIcon size={17} /> },
|
||||
comment: { label: 'Comment', icon: <CommentIcon size={17} /> },
|
||||
textbox: { label: 'Text box', icon: <TextBoxIcon size={17} /> },
|
||||
edit_text: { label: 'Edit text', icon: <TextBoxIcon size={17} /> },
|
||||
signature: { label: 'Signature', icon: <SignatureIcon size={17} /> },
|
||||
stamp: { label: 'Stamp', icon: <StampIcon size={17} /> },
|
||||
redact: { label: 'Redact', icon: <RedactIcon size={17} /> },
|
||||
|
||||
@@ -79,17 +79,25 @@ export interface DocumentMetadata {
|
||||
}
|
||||
|
||||
export interface FontInfo {
|
||||
name: string;
|
||||
fontName: string;
|
||||
type?: string;
|
||||
isEmbedded?: boolean;
|
||||
isSubset?: boolean;
|
||||
isVertical?: boolean;
|
||||
encoding?: string;
|
||||
hasToUnicode?: boolean;
|
||||
cmapName?: string;
|
||||
cidSystemInfo?: string;
|
||||
subsetTag?: string;
|
||||
sourceType?: string;
|
||||
substitutedFrom?: string;
|
||||
substitutedTo?: string;
|
||||
normalizedFamily?: string;
|
||||
internalFontId?: string;
|
||||
flags?: number;
|
||||
ascent?: number;
|
||||
descent?: number;
|
||||
capHeight?: number;
|
||||
}
|
||||
|
||||
export interface TextOverlayData {
|
||||
@@ -185,8 +193,22 @@ export type EditOperationDataMap = {
|
||||
page_reorder: PageReorderData;
|
||||
delete_annotation: DeleteAnnotationData;
|
||||
update_annotation: UpdateAnnotationData;
|
||||
edit_text: EditTextData;
|
||||
};
|
||||
|
||||
export interface EditTextData {
|
||||
// Target run bbox in PDF bottom-left page space (same space getPageModel returns).
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
newText: string;
|
||||
originalText?: string;
|
||||
fontSize?: number;
|
||||
color?: string;
|
||||
fallbackFont?: string;
|
||||
}
|
||||
|
||||
export interface DeleteAnnotationData {
|
||||
annotationId: string;
|
||||
}
|
||||
@@ -357,7 +379,7 @@ class GatewayService {
|
||||
}
|
||||
|
||||
async applyEdits(documentId: string, operations: EditOperation[]): Promise<{ success: boolean; newDocumentId: string }> {
|
||||
const response = await fetch(`${this.baseUrl}/edits/${documentId}`, {
|
||||
const response = await fetch(`${this.baseUrl}/documents/${documentId}/edits`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ version: '1.0', operations } as EditOperationEnvelope),
|
||||
|
||||
@@ -0,0 +1,244 @@
|
||||
// Adobe-grade text-selection model.
|
||||
//
|
||||
// Operates purely in **page-point space** (unzoomed, top-left origin) — the same
|
||||
// space the engine emits glyph bounds in. Callers convert mouse coordinates into
|
||||
// this space (divide by zoom) before querying, and scale the returned rects back
|
||||
// up (multiply by zoom) for rendering.
|
||||
//
|
||||
// A *caret* is a position between glyphs, indexed 0..N (N = glyph count). A
|
||||
// selection is an ordered pair of carets {start, end}; start may be greater than
|
||||
// end for a backward (right-to-left) drag. All range helpers normalise
|
||||
// internally, so the component is free to keep anchor/focus unnormalised.
|
||||
|
||||
import type { Glyph } from './gatewayService';
|
||||
|
||||
export interface OrderedGlyph extends Glyph {
|
||||
index: number; // position in reading order
|
||||
line: number; // line band this glyph belongs to
|
||||
right: number; // x + w (cached)
|
||||
bottom: number; // y + h (cached)
|
||||
mid: number; // x + w/2 (cached)
|
||||
}
|
||||
|
||||
export interface CaretRange {
|
||||
start: number;
|
||||
end: number;
|
||||
}
|
||||
|
||||
export interface SelRect {
|
||||
x: number;
|
||||
y: number;
|
||||
w: number;
|
||||
h: number;
|
||||
}
|
||||
|
||||
interface LineBand {
|
||||
top: number;
|
||||
bottom: number;
|
||||
mid: number;
|
||||
start: number; // first glyph index (inclusive)
|
||||
end: number; // last glyph index + 1 (exclusive)
|
||||
}
|
||||
|
||||
const isSpace = (t: string) => t.length === 0 || /^\s+$/.test(t);
|
||||
|
||||
export class TextSelectionModel {
|
||||
readonly glyphs: OrderedGlyph[] = [];
|
||||
private readonly lines: LineBand[] = [];
|
||||
|
||||
constructor(raw: Glyph[]) {
|
||||
const clean = raw.filter((g) => g.w >= 0 && g.h > 0);
|
||||
if (clean.length === 0) return;
|
||||
|
||||
// Cluster glyphs into lines by vertical overlap. Sort by vertical centre so
|
||||
// rows arrive top-to-bottom even when the source order is arbitrary.
|
||||
const byY = [...clean].sort((a, b) => a.y + a.h / 2 - (b.y + b.h / 2));
|
||||
const rows: Glyph[][] = [];
|
||||
for (const g of byY) {
|
||||
const gMid = g.y + g.h / 2;
|
||||
const row = rows[rows.length - 1];
|
||||
if (row) {
|
||||
// Band of the current row so far.
|
||||
const top = Math.min(...row.map((r) => r.y));
|
||||
const bottom = Math.max(...row.map((r) => r.y + r.h));
|
||||
// Same line if the glyph centre falls inside the row band (with a small
|
||||
// tolerance for sub/superscript jitter).
|
||||
const tol = g.h * 0.25;
|
||||
if (gMid >= top - tol && gMid <= bottom + tol) {
|
||||
row.push(g);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
rows.push([g]);
|
||||
}
|
||||
|
||||
// Flatten rows (each sorted left→right) into a single reading-order array and
|
||||
// record the line bands for spatial queries.
|
||||
let idx = 0;
|
||||
rows.forEach((row, lineNo) => {
|
||||
row.sort((a, b) => a.x - b.x);
|
||||
const start = idx;
|
||||
for (const g of row) {
|
||||
this.glyphs.push({
|
||||
...g,
|
||||
index: idx,
|
||||
line: lineNo,
|
||||
right: g.x + g.w,
|
||||
bottom: g.y + g.h,
|
||||
mid: g.x + g.w / 2,
|
||||
});
|
||||
idx += 1;
|
||||
}
|
||||
this.lines.push({
|
||||
top: Math.min(...row.map((r) => r.y)),
|
||||
bottom: Math.max(...row.map((r) => r.y + r.h)),
|
||||
mid: row[0].y + row[0].h / 2,
|
||||
start,
|
||||
end: idx,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
get length(): number {
|
||||
return this.glyphs.length;
|
||||
}
|
||||
|
||||
/** Index of the line whose band contains y, else the nearest line by centre. */
|
||||
private lineAt(y: number): number {
|
||||
if (this.lines.length === 0) return -1;
|
||||
for (let i = 0; i < this.lines.length; i++) {
|
||||
if (y >= this.lines[i].top && y <= this.lines[i].bottom) return i;
|
||||
}
|
||||
// Above the first / below the last / in an inter-line gap → nearest centre.
|
||||
let best = 0;
|
||||
let bestDist = Infinity;
|
||||
for (let i = 0; i < this.lines.length; i++) {
|
||||
const d = Math.abs(y - this.lines[i].mid);
|
||||
if (d < bestDist) {
|
||||
bestDist = d;
|
||||
best = i;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
/** Nearest caret (0..N) to a point. */
|
||||
caretAt(x: number, y: number): number {
|
||||
const li = this.lineAt(y);
|
||||
if (li < 0) return 0;
|
||||
const line = this.lines[li];
|
||||
if (x <= this.glyphs[line.start].x) return line.start;
|
||||
if (x >= this.glyphs[line.end - 1].right) return line.end;
|
||||
for (let i = line.start; i < line.end; i++) {
|
||||
const g = this.glyphs[i];
|
||||
if (x >= g.x && x <= g.right) return x < g.mid ? g.index : g.index + 1;
|
||||
// In the gap before this glyph.
|
||||
if (x < g.x) return g.index;
|
||||
}
|
||||
return line.end;
|
||||
}
|
||||
|
||||
/** Glyph index directly under a point, or -1. */
|
||||
glyphAt(x: number, y: number): number {
|
||||
const li = this.lineAt(y);
|
||||
if (li < 0) return -1;
|
||||
const line = this.lines[li];
|
||||
if (y < line.top || y > line.bottom) return -1;
|
||||
for (let i = line.start; i < line.end; i++) {
|
||||
const g = this.glyphs[i];
|
||||
if (x >= g.x && x <= g.right) return i;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
/** Word caret-range around a point (run of non-space within one line). */
|
||||
wordRangeAt(x: number, y: number): CaretRange | null {
|
||||
let gi = this.glyphAt(x, y);
|
||||
if (gi < 0) {
|
||||
// Snap to the caret, then take the glyph just right of it (or left at EOL).
|
||||
const c = this.caretAt(x, y);
|
||||
gi = c < this.length ? c : c - 1;
|
||||
if (gi < 0 || gi >= this.length) return null;
|
||||
}
|
||||
const line = this.glyphs[gi].line;
|
||||
if (isSpace(this.glyphs[gi].text)) return { start: gi, end: gi + 1 };
|
||||
let s = gi;
|
||||
let e = gi;
|
||||
while (s - 1 >= 0 && this.glyphs[s - 1].line === line && !isSpace(this.glyphs[s - 1].text)) s--;
|
||||
while (e + 1 < this.length && this.glyphs[e + 1].line === line && !isSpace(this.glyphs[e + 1].text)) e++;
|
||||
return { start: s, end: e + 1 };
|
||||
}
|
||||
|
||||
/** Whole-line caret-range around a point. */
|
||||
lineRangeAt(_x: number, y: number): CaretRange | null {
|
||||
const li = this.lineAt(y);
|
||||
if (li < 0) return null;
|
||||
return { start: this.lines[li].start, end: this.lines[li].end };
|
||||
}
|
||||
|
||||
selectAll(): CaretRange {
|
||||
return { start: 0, end: this.length };
|
||||
}
|
||||
|
||||
private static norm(r: CaretRange): CaretRange {
|
||||
return r.start <= r.end ? r : { start: r.end, end: r.start };
|
||||
}
|
||||
|
||||
glyphsInRange(r: CaretRange): OrderedGlyph[] {
|
||||
const { start, end } = TextSelectionModel.norm(r);
|
||||
return this.glyphs.slice(start, end);
|
||||
}
|
||||
|
||||
/** Reconstructed text with intra-line spaces and inter-line newlines. */
|
||||
textOfRange(r: CaretRange): string {
|
||||
const sel = this.glyphsInRange(r);
|
||||
if (sel.length === 0) return '';
|
||||
let out = '';
|
||||
let prev: OrderedGlyph | null = null;
|
||||
for (const g of sel) {
|
||||
if (prev) {
|
||||
if (g.line !== prev.line) out += '\n';
|
||||
else {
|
||||
const gap = g.x - prev.right;
|
||||
const alreadySpace = isSpace(g.text) || isSpace(prev.text);
|
||||
if (!alreadySpace && gap > g.h * 0.25) out += ' ';
|
||||
}
|
||||
}
|
||||
out += g.text;
|
||||
prev = g;
|
||||
}
|
||||
return out.replace(/[ \t]+\n/g, '\n').trimEnd();
|
||||
}
|
||||
|
||||
/** Per-line union rects covering the selection (page-point space). */
|
||||
rectsOfRange(r: CaretRange): SelRect[] {
|
||||
const sel = this.glyphsInRange(r);
|
||||
if (sel.length === 0) return [];
|
||||
const byLine = new Map<number, OrderedGlyph[]>();
|
||||
for (const g of sel) {
|
||||
const arr = byLine.get(g.line);
|
||||
if (arr) arr.push(g);
|
||||
else byLine.set(g.line, [g]);
|
||||
}
|
||||
const rects: SelRect[] = [];
|
||||
for (const arr of byLine.values()) {
|
||||
const x = Math.min(...arr.map((g) => g.x));
|
||||
const right = Math.max(...arr.map((g) => g.right));
|
||||
// Use the full line band height for a continuous highlight, not glyph height.
|
||||
const band = this.lines[arr[0].line];
|
||||
rects.push({ x, y: band.top, w: right - x, h: band.bottom - band.top });
|
||||
}
|
||||
return rects;
|
||||
}
|
||||
|
||||
/** Tight union of all selection rects (page-point space) — for highlight bbox. */
|
||||
unionRect(r: CaretRange): SelRect | null {
|
||||
const rects = this.rectsOfRange(r);
|
||||
if (rects.length === 0) return null;
|
||||
const x = Math.min(...rects.map((q) => q.x));
|
||||
const y = Math.min(...rects.map((q) => q.y));
|
||||
const right = Math.max(...rects.map((q) => q.x + q.w));
|
||||
const bottom = Math.max(...rects.map((q) => q.y + q.h));
|
||||
return { x, y, w: right - x, h: bottom - y };
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ export type ToolId =
|
||||
| 'draw'
|
||||
| 'comment'
|
||||
| 'textbox'
|
||||
| 'edit_text'
|
||||
| 'signature'
|
||||
| 'stamp'
|
||||
| 'redact';
|
||||
@@ -34,6 +35,7 @@ export const TOOL_SHORTCUTS: Record<string, ToolId> = {
|
||||
d: 'draw',
|
||||
c: 'comment',
|
||||
t: 'textbox',
|
||||
e: 'edit_text',
|
||||
s: 'signature',
|
||||
m: 'stamp',
|
||||
r: 'redact',
|
||||
|
||||
@@ -4,6 +4,8 @@ import { SelectionLayer } from './SelectionLayer';
|
||||
import { AnnotationLayer } from './AnnotationLayer';
|
||||
import type { Annotation } from './AnnotationLayer';
|
||||
import { OverlayLayer } from './OverlayLayer';
|
||||
import { TextEditLayer } from './TextEditLayer';
|
||||
import type { EditableRun } from './TextEditLayer';
|
||||
import { SearchOverlayLayer } from './SearchOverlayLayer';
|
||||
import type { Rect } from '../lib/coordinateMapping';
|
||||
import { gatewayService } from '../lib/gatewayService';
|
||||
@@ -33,6 +35,7 @@ interface PDFViewerProps {
|
||||
onPageVisible?: (pageIndex: number) => void;
|
||||
onRedactArea?: (pageIndex: number, bounds: Rect) => void;
|
||||
onPlaceText?: (pageIndex: number, rectPts: Rect, text: string) => void;
|
||||
onEditText?: (pageIndex: number, run: EditableRun, newText: string) => void;
|
||||
onPlaceStamp?: (pageIndex: number, pointPts: { x: number; y: number }) => void;
|
||||
onPlaceSignature?: (pageIndex: number, pointPts: { x: number; y: number }) => void;
|
||||
onFieldChange?: (id: string, value: string | boolean, pageIndex: number) => void;
|
||||
@@ -72,6 +75,7 @@ export const PDFViewer = React.forwardRef<PDFViewerRef, PDFViewerProps>(({
|
||||
onPageVisible,
|
||||
onRedactArea,
|
||||
onPlaceText,
|
||||
onEditText,
|
||||
onPlaceStamp,
|
||||
onPlaceSignature,
|
||||
onFieldChange,
|
||||
@@ -244,7 +248,10 @@ export const PDFViewer = React.forwardRef<PDFViewerRef, PDFViewerProps>(({
|
||||
l.runs?.forEach((r: any) => {
|
||||
const isBold = (r.flags & 262144) !== 0 || r.font_name.toLowerCase().includes('bold');
|
||||
const isItalic = (r.flags & 64) !== 0 || r.font_name.toLowerCase().includes('italic');
|
||||
console.log(`Run Text: "${r.text}", Font Name: ${r.font_name}, Font Size: ${r.font_size}, Bold: ${isBold}, Italic: ${isItalic}, Embedded: ${r.is_embedded}, Type: ${r.type}`);
|
||||
console.log(`Run Text: "${r.text}", Font Name: ${r.font_name}, Font Size: ${r.font_size}, Bold: ${isBold}, Italic: ${isItalic}, Embedded: ${r.is_embedded}, Type: ${r.type}, Object Indices: [${r.object_indices?.join(', ')}]`);
|
||||
r.glyphs?.forEach((g: any) => {
|
||||
console.log(` Glyph: "${g.text}", Page Object Index: ${g.page_object_index}`);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -403,6 +410,7 @@ export const PDFViewer = React.forwardRef<PDFViewerRef, PDFViewerProps>(({
|
||||
height={page.height}
|
||||
zoom={zoom}
|
||||
glyphs={pageTexts[page.index] || []}
|
||||
mode={activeTool === 'highlight' ? 'highlight' : 'select'}
|
||||
onTextSelected={(text, bbox) => handleTextSelection(text, bbox, page.index)}
|
||||
/>
|
||||
)}
|
||||
@@ -436,6 +444,18 @@ export const PDFViewer = React.forwardRef<PDFViewerRef, PDFViewerProps>(({
|
||||
onPlaceSignature={onPlaceSignature}
|
||||
/>
|
||||
|
||||
{/* Inline editor for existing page text (true content editing) */}
|
||||
{activeTool === 'edit_text' && (
|
||||
<TextEditLayer
|
||||
documentId={documentId}
|
||||
pageIndex={page.index}
|
||||
width={page.width}
|
||||
height={page.height}
|
||||
zoom={zoom}
|
||||
onEditText={onEditText}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Search highlights overlay */}
|
||||
<SearchOverlayLayer
|
||||
pageIndex={page.index}
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import React, { useState, useRef, useMemo } from 'react';
|
||||
import React, { useState, useRef, useMemo, useEffect, useCallback } from 'react';
|
||||
import type { Point, Rect } from '../lib/coordinateMapping';
|
||||
import type { Glyph } from '../lib/gatewayService';
|
||||
import { TextSelectionModel, type CaretRange } from '../lib/textSelection';
|
||||
|
||||
type Granularity = 'caret' | 'word' | 'line';
|
||||
|
||||
interface SelectionLayerProps {
|
||||
pageIndex: number;
|
||||
@@ -8,118 +11,201 @@ interface SelectionLayerProps {
|
||||
height: number;
|
||||
zoom: number;
|
||||
glyphs: Glyph[];
|
||||
/** 'select' keeps the selection live for copy; 'highlight' emits on mouse-up. */
|
||||
mode?: 'select' | 'highlight';
|
||||
onTextSelected?: (text: string, bbox: Rect) => void;
|
||||
}
|
||||
|
||||
interface ZGlyph { gx: number; gy: number; gw: number; gh: number; text: string }
|
||||
// Broadcast so that starting a selection on one page clears every other page's.
|
||||
const SEL_START_EVT = 'pdf-selection-start';
|
||||
|
||||
function rectsIntersect(ax: number, ay: number, aw: number, ah: number, b: Rect) {
|
||||
return ax < b.x + b.width && ax + aw > b.x && ay < b.y + b.height && ay + ah > b.y;
|
||||
}
|
||||
|
||||
export const SelectionLayer: React.FC<SelectionLayerProps> = ({ width, height, zoom, glyphs, onTextSelected }) => {
|
||||
const [dragStart, setDragStart] = useState<Point | null>(null);
|
||||
const [selectionBox, setSelectionBox] = useState<Rect | null>(null);
|
||||
export const SelectionLayer: React.FC<SelectionLayerProps> = ({
|
||||
pageIndex,
|
||||
width,
|
||||
height,
|
||||
zoom,
|
||||
glyphs,
|
||||
mode = 'select',
|
||||
onTextSelected,
|
||||
}) => {
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
const model = useMemo(() => new TextSelectionModel(glyphs), [glyphs]);
|
||||
|
||||
// Glyphs projected into zoomed pixel space once per glyphs/zoom change.
|
||||
const zGlyphs = useMemo<ZGlyph[]>(
|
||||
() => glyphs.map((g) => ({ gx: g.x * zoom, gy: g.y * zoom, gw: g.w * zoom, gh: g.h * zoom, text: g.text })),
|
||||
[glyphs, zoom],
|
||||
);
|
||||
// Caret selection {start: anchor, end: focus}; unnormalised (start may exceed end).
|
||||
const [sel, setSel] = useState<CaretRange | null>(null);
|
||||
const selRef = useRef<CaretRange | null>(null);
|
||||
selRef.current = sel;
|
||||
|
||||
// Glyphs covered by the current drag box (for live highlight feedback).
|
||||
const hitGlyphs = useMemo<ZGlyph[]>(() => {
|
||||
if (!selectionBox || zGlyphs.length === 0) return [];
|
||||
return zGlyphs.filter((g) => rectsIntersect(g.gx, g.gy, g.gw, g.gh, selectionBox));
|
||||
}, [selectionBox, zGlyphs]);
|
||||
// Active drag (null when idle). `base` is the range fixed at mouse-down.
|
||||
const drag = useRef<{ granularity: Granularity; base: CaretRange } | null>(null);
|
||||
|
||||
const point = (e: React.MouseEvent) => {
|
||||
const rect = containerRef.current!.getBoundingClientRect();
|
||||
return { x: e.clientX - rect.left, y: e.clientY - rect.top };
|
||||
// --- mock-mode fallback (no glyph geometry): raw drag box -------------------
|
||||
const [box, setBox] = useState<Rect | null>(null);
|
||||
const boxStart = useRef<Point | null>(null);
|
||||
const hasGlyphs = model.length > 0;
|
||||
|
||||
const toPage = (e: React.MouseEvent): Point => {
|
||||
const r = containerRef.current!.getBoundingClientRect();
|
||||
return { x: (e.clientX - r.left) / zoom, y: (e.clientY - r.top) / zoom };
|
||||
};
|
||||
|
||||
// Merge the mouse-down base range with the range under the cursor, honouring
|
||||
// drag direction (Adobe extends by the original granularity: word/line/caret).
|
||||
const extend = useCallback(
|
||||
(base: CaretRange, granularity: Granularity, p: Point): CaretRange => {
|
||||
if (granularity === 'caret') return { start: base.start, end: model.caretAt(p.x, p.y) };
|
||||
const cur = granularity === 'word' ? model.wordRangeAt(p.x, p.y) : model.lineRangeAt(p.x, p.y);
|
||||
if (!cur) return base;
|
||||
const c = model.caretAt(p.x, p.y);
|
||||
if (c >= base.end) return { start: base.start, end: cur.end };
|
||||
if (c <= base.start) return { start: base.end, end: cur.start };
|
||||
return { start: base.start, end: base.end };
|
||||
},
|
||||
[model],
|
||||
);
|
||||
|
||||
const clear = useCallback(() => {
|
||||
setSel(null);
|
||||
setBox(null);
|
||||
drag.current = null;
|
||||
boxStart.current = null;
|
||||
}, []);
|
||||
|
||||
// Clear this page's selection when another page starts one.
|
||||
useEffect(() => {
|
||||
const onOther = (e: Event) => {
|
||||
if ((e as CustomEvent<number>).detail !== pageIndex) clear();
|
||||
};
|
||||
window.addEventListener(SEL_START_EVT, onOther as EventListener);
|
||||
return () => window.removeEventListener(SEL_START_EVT, onOther as EventListener);
|
||||
}, [pageIndex, clear]);
|
||||
|
||||
// Keyboard: Ctrl/Cmd+C copies, Ctrl/Cmd+A extends to the whole page, Esc clears.
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
const cur = selRef.current;
|
||||
const has = !!cur && cur.start !== cur.end;
|
||||
const mod = e.ctrlKey || e.metaKey;
|
||||
if (mod && e.key.toLowerCase() === 'a' && has) {
|
||||
e.preventDefault();
|
||||
setSel(model.selectAll());
|
||||
} else if (mod && e.key.toLowerCase() === 'c' && has && mode === 'select') {
|
||||
const text = model.textOfRange(cur!);
|
||||
const u = model.unionRect(cur!);
|
||||
if (text && u) onTextSelected?.(text, { x: u.x * zoom, y: u.y * zoom, width: u.w * zoom, height: u.h * zoom });
|
||||
} else if (e.key === 'Escape' && has) {
|
||||
clear();
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, [model, mode, zoom, onTextSelected, clear]);
|
||||
|
||||
const handleMouseDown = (e: React.MouseEvent) => {
|
||||
if (!containerRef.current) return;
|
||||
const p = point(e);
|
||||
setDragStart(p);
|
||||
setSelectionBox({ x: p.x, y: p.y, width: 0, height: 0 });
|
||||
if (!containerRef.current || e.button !== 0) return;
|
||||
e.preventDefault();
|
||||
window.dispatchEvent(new CustomEvent<number>(SEL_START_EVT, { detail: pageIndex }));
|
||||
|
||||
if (!hasGlyphs) {
|
||||
const r = containerRef.current.getBoundingClientRect();
|
||||
const px = { x: e.clientX - r.left, y: e.clientY - r.top };
|
||||
boxStart.current = px;
|
||||
setBox({ x: px.x, y: px.y, width: 0, height: 0 });
|
||||
return;
|
||||
}
|
||||
|
||||
const p = toPage(e);
|
||||
let base: CaretRange;
|
||||
let granularity: Granularity;
|
||||
if (e.shiftKey && selRef.current) {
|
||||
base = { start: selRef.current.start, end: selRef.current.start };
|
||||
granularity = 'caret';
|
||||
setSel({ start: base.start, end: model.caretAt(p.x, p.y) });
|
||||
} else if (e.detail >= 3) {
|
||||
base = model.lineRangeAt(p.x, p.y) ?? { start: model.caretAt(p.x, p.y), end: model.caretAt(p.x, p.y) };
|
||||
granularity = 'line';
|
||||
setSel(base);
|
||||
} else if (e.detail === 2) {
|
||||
base = model.wordRangeAt(p.x, p.y) ?? { start: model.caretAt(p.x, p.y), end: model.caretAt(p.x, p.y) };
|
||||
granularity = 'word';
|
||||
setSel(base);
|
||||
} else {
|
||||
const c = model.caretAt(p.x, p.y);
|
||||
base = { start: c, end: c };
|
||||
granularity = 'caret';
|
||||
setSel(base);
|
||||
}
|
||||
drag.current = { granularity, base };
|
||||
};
|
||||
|
||||
const handleMouseMove = (e: React.MouseEvent) => {
|
||||
if (!dragStart || !containerRef.current) return;
|
||||
const p = point(e);
|
||||
setSelectionBox({
|
||||
x: Math.min(dragStart.x, p.x),
|
||||
y: Math.min(dragStart.y, p.y),
|
||||
width: Math.abs(dragStart.x - p.x),
|
||||
height: Math.abs(dragStart.y - p.y),
|
||||
});
|
||||
};
|
||||
|
||||
const buildText = (hits: ZGlyph[]): string => {
|
||||
if (hits.length === 0) return '';
|
||||
// Group into lines by vertical proximity, then order left→right.
|
||||
const sorted = [...hits].sort((a, b) => a.gy - b.gy || a.gx - b.gx);
|
||||
const lines: ZGlyph[][] = [];
|
||||
for (const g of sorted) {
|
||||
const last = lines[lines.length - 1];
|
||||
if (last && Math.abs(last[0].gy - g.gy) < last[0].gh * 0.6) last.push(g);
|
||||
else lines.push([g]);
|
||||
if (!containerRef.current) return;
|
||||
if (!hasGlyphs) {
|
||||
if (!boxStart.current) return;
|
||||
const r = containerRef.current.getBoundingClientRect();
|
||||
const px = { x: e.clientX - r.left, y: e.clientY - r.top };
|
||||
setBox({
|
||||
x: Math.min(boxStart.current.x, px.x),
|
||||
y: Math.min(boxStart.current.y, px.y),
|
||||
width: Math.abs(boxStart.current.x - px.x),
|
||||
height: Math.abs(boxStart.current.y - px.y),
|
||||
});
|
||||
return;
|
||||
}
|
||||
return lines
|
||||
.map((line) =>
|
||||
line
|
||||
.sort((a, b) => a.gx - b.gx)
|
||||
.map((g, i, arr) => {
|
||||
const prev = arr[i - 1];
|
||||
const gap = prev ? g.gx - (prev.gx + prev.gw) : 0;
|
||||
const sep = prev && gap > g.gw * 0.4 ? ' ' : '';
|
||||
return sep + g.text;
|
||||
})
|
||||
.join(''),
|
||||
)
|
||||
.join('\n')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
if (!drag.current) return;
|
||||
const p = toPage(e);
|
||||
setSel(extend(drag.current.base, drag.current.granularity, p));
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
if (selectionBox && selectionBox.width > 3 && selectionBox.height > 3) {
|
||||
if (hitGlyphs.length > 0) {
|
||||
// Tight union bbox around actually-selected glyphs (zoomed px).
|
||||
const minX = Math.min(...hitGlyphs.map((g) => g.gx));
|
||||
const minY = Math.min(...hitGlyphs.map((g) => g.gy));
|
||||
const maxX = Math.max(...hitGlyphs.map((g) => g.gx + g.gw));
|
||||
const maxY = Math.max(...hitGlyphs.map((g) => g.gy + g.gh));
|
||||
onTextSelected?.(buildText(hitGlyphs), { x: minX, y: minY, width: maxX - minX, height: maxY - minY });
|
||||
} else {
|
||||
// No glyph data (e.g. mock mode) — fall back to the raw drag box so the
|
||||
// highlight workflow still functions.
|
||||
onTextSelected?.('', selectionBox);
|
||||
}
|
||||
// Mock-mode: report the raw drag box so highlighting still works.
|
||||
if (!hasGlyphs) {
|
||||
if (box && box.width > 3 && box.height > 3) onTextSelected?.('', box);
|
||||
setBox(null);
|
||||
boxStart.current = null;
|
||||
return;
|
||||
}
|
||||
setDragStart(null);
|
||||
setSelectionBox(null);
|
||||
drag.current = null;
|
||||
const cur = selRef.current;
|
||||
if (!cur || cur.start === cur.end) {
|
||||
setSel(null);
|
||||
return;
|
||||
}
|
||||
if (mode === 'highlight') {
|
||||
const text = model.textOfRange(cur);
|
||||
const u = model.unionRect(cur);
|
||||
if (u) onTextSelected?.(text, { x: u.x * zoom, y: u.y * zoom, width: u.w * zoom, height: u.h * zoom });
|
||||
setSel(null);
|
||||
}
|
||||
// 'select' mode: keep the selection live for Ctrl+C.
|
||||
};
|
||||
|
||||
const rects = sel ? model.rectsOfRange(sel) : [];
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="absolute top-0 left-0 z-20 cursor-text"
|
||||
className="absolute top-0 left-0 z-20 cursor-text select-none"
|
||||
style={{ width: `${width}px`, height: `${height}px` }}
|
||||
onMouseDown={handleMouseDown}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseUp={handleMouseUp}
|
||||
onMouseLeave={handleMouseUp}
|
||||
onMouseLeave={() => {
|
||||
if (drag.current || boxStart.current) handleMouseUp();
|
||||
}}
|
||||
>
|
||||
{/* Live per-glyph highlight */}
|
||||
{hitGlyphs.map((g, i) => (
|
||||
<div key={i} className="absolute bg-[rgba(37,99,235,0.28)] pointer-events-none" style={{ left: g.gx, top: g.gy, width: g.gw, height: g.gh }} />
|
||||
{rects.map((q, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="absolute bg-[rgba(37,99,235,0.30)] pointer-events-none"
|
||||
style={{ left: q.x * zoom, top: q.y * zoom, width: q.w * zoom, height: q.h * zoom }}
|
||||
/>
|
||||
))}
|
||||
{/* Drag rectangle (only when nothing is being hit, e.g. mock mode) */}
|
||||
{selectionBox && hitGlyphs.length === 0 && (
|
||||
<div className="absolute bg-[rgba(37,99,235,0.22)] pointer-events-none rounded-[1px]" style={{ left: selectionBox.x, top: selectionBox.y, width: selectionBox.width, height: selectionBox.height }} />
|
||||
{box && (
|
||||
<div
|
||||
className="absolute bg-[rgba(37,99,235,0.22)] pointer-events-none rounded-[1px]"
|
||||
style={{ left: box.x, top: box.y, width: box.width, height: box.height }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { gatewayService } from '../lib/gatewayService';
|
||||
|
||||
// A single editable run, in PDF BOTTOM-LEFT page space (as getPageModel returns).
|
||||
export interface EditableRun {
|
||||
text: string;
|
||||
x: number;
|
||||
y: number;
|
||||
w: number;
|
||||
h: number;
|
||||
fontSize: number;
|
||||
}
|
||||
|
||||
interface TextEditLayerProps {
|
||||
documentId: string;
|
||||
pageIndex: number;
|
||||
width: number; // page width in ZOOMED px (= widthPts * zoom)
|
||||
height: number; // page height in ZOOMED px (= heightPts * zoom)
|
||||
zoom: number;
|
||||
onEditText?: (pageIndex: number, run: EditableRun, newText: string) => void;
|
||||
}
|
||||
|
||||
// Flatten the dynamic page-model JSON into a flat list of editable runs.
|
||||
function flattenRuns(model: any): EditableRun[] {
|
||||
const runs: EditableRun[] = [];
|
||||
const paragraphs = model?.paragraphs ?? [];
|
||||
for (const p of paragraphs) {
|
||||
for (const line of p.lines ?? []) {
|
||||
for (const r of line.runs ?? []) {
|
||||
if (typeof r.text === 'string' && r.text.trim() && r.w > 0 && r.h > 0) {
|
||||
runs.push({ text: r.text, x: r.x, y: r.y, w: r.w, h: r.h, fontSize: r.font_size ?? r.h });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return runs;
|
||||
}
|
||||
|
||||
export const TextEditLayer: React.FC<TextEditLayerProps> = ({
|
||||
documentId,
|
||||
pageIndex,
|
||||
width,
|
||||
height,
|
||||
zoom,
|
||||
onEditText,
|
||||
}) => {
|
||||
const [runs, setRuns] = useState<EditableRun[]>([]);
|
||||
const [editing, setEditing] = useState<number | null>(null);
|
||||
const [value, setValue] = useState('');
|
||||
const committedRef = useRef(false);
|
||||
|
||||
// Fetch the page model once per document/page.
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setEditing(null);
|
||||
gatewayService
|
||||
.getPageModel(documentId, pageIndex)
|
||||
.then((model) => {
|
||||
if (!cancelled) setRuns(flattenRuns(model));
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setRuns([]);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [documentId, pageIndex]);
|
||||
|
||||
// Page height in points (props are zoomed px) — needed for the bottom-left→top-left flip.
|
||||
const heightPts = height / zoom;
|
||||
|
||||
// Display rect (top-left, zoomed px) for a run whose coords are bottom-left points.
|
||||
const rectOf = (r: EditableRun) => ({
|
||||
left: r.x * zoom,
|
||||
top: (heightPts - (r.y + r.h)) * zoom,
|
||||
width: r.w * zoom,
|
||||
height: r.h * zoom,
|
||||
});
|
||||
|
||||
const openEditor = (i: number) => {
|
||||
committedRef.current = false;
|
||||
setEditing(i);
|
||||
setValue(runs[i].text);
|
||||
};
|
||||
|
||||
const commit = () => {
|
||||
if (editing === null || committedRef.current) return;
|
||||
committedRef.current = true;
|
||||
const run = runs[editing];
|
||||
const next = value;
|
||||
setEditing(null);
|
||||
if (next !== run.text) onEditText?.(pageIndex, run, next);
|
||||
};
|
||||
|
||||
const cancel = () => {
|
||||
committedRef.current = true;
|
||||
setEditing(null);
|
||||
};
|
||||
|
||||
const editingRect = useMemo(
|
||||
() => (editing !== null && runs[editing] ? rectOf(runs[editing]) : null),
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[editing, runs, zoom, height],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="absolute top-0 left-0 z-[35]" style={{ width: `${width}px`, height: `${height}px` }}>
|
||||
{/* Per-run hit targets (visible hint on hover). */}
|
||||
{editing === null &&
|
||||
runs.map((r, i) => {
|
||||
const box = rectOf(r);
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
title="Click to edit this text"
|
||||
onClick={() => openEditor(i)}
|
||||
className="absolute cursor-text rounded-[2px] hover:bg-[rgba(37,99,235,0.12)] hover:outline hover:outline-1 hover:outline-[#2563eb]"
|
||||
style={{ left: box.left, top: box.top, width: box.width, height: box.height }}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Inline editor for the selected run. */}
|
||||
{editing !== null && editingRect && (
|
||||
<textarea
|
||||
autoFocus
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
onBlur={commit}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
commit();
|
||||
}
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
cancel();
|
||||
}
|
||||
}}
|
||||
className="absolute z-[36] bg-white border-[1.5px] border-[#2563eb] rounded-[3px] shadow-[0_4px_12px_rgba(16,24,40,0.12)] outline-none resize-none overflow-hidden font-sans leading-[1.1] px-[3px] py-[1px] whitespace-nowrap"
|
||||
style={{
|
||||
left: editingRect.left,
|
||||
top: editingRect.top,
|
||||
minWidth: editingRect.width,
|
||||
height: Math.max(editingRect.height, (runs[editing].fontSize + 4) * zoom),
|
||||
fontSize: `${runs[editing].fontSize * zoom}px`,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -241,7 +241,12 @@ class SearchMatch(BaseModel):
|
||||
|
||||
|
||||
@router.get("/{document_id}/search", response_model=list[SearchMatch])
|
||||
def search_document(document_id: str, q: str) -> list[SearchMatch]:
|
||||
def search_document(
|
||||
document_id: str,
|
||||
q: str,
|
||||
case_sensitive: bool = False,
|
||||
whole_words: bool = False,
|
||||
) -> list[SearchMatch]:
|
||||
if not engine.is_available():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_501_NOT_IMPLEMENTED,
|
||||
@@ -255,11 +260,35 @@ def search_document(document_id: str, q: str) -> list[SearchMatch]:
|
||||
if not doc_info:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Document not found")
|
||||
|
||||
def _is_word_char(ch: str) -> bool:
|
||||
return ch.isalnum() or ch == "_"
|
||||
|
||||
def _find_all(haystack: str, needle: str) -> list[int]:
|
||||
"""Return start indices of all non-overlapping occurrences of needle in haystack."""
|
||||
results: list[int] = []
|
||||
start = 0
|
||||
needle_len = len(needle)
|
||||
while True:
|
||||
pos = haystack.find(needle, start)
|
||||
if pos == -1:
|
||||
break
|
||||
if whole_words:
|
||||
before_ok = pos == 0 or not _is_word_char(haystack[pos - 1])
|
||||
after_ok = (pos + needle_len) >= len(haystack) or not _is_word_char(
|
||||
haystack[pos + needle_len]
|
||||
)
|
||||
if before_ok and after_ok:
|
||||
results.append(pos)
|
||||
else:
|
||||
results.append(pos)
|
||||
start = pos + 1
|
||||
return results
|
||||
|
||||
try:
|
||||
doc = doc_info["doc_instance"]
|
||||
matches = []
|
||||
lower_query = q.lower()
|
||||
query_len = len(lower_query)
|
||||
search_needle = q if case_sensitive else q.lower()
|
||||
query_len = len(search_needle)
|
||||
|
||||
for page_idx in range(doc.page_count):
|
||||
page = doc.get_page(page_idx)
|
||||
@@ -268,7 +297,7 @@ def search_document(document_id: str, q: str) -> list[SearchMatch]:
|
||||
continue
|
||||
|
||||
text_str = ""
|
||||
char_to_glyph = []
|
||||
char_to_glyph: list[int] = []
|
||||
for i, g in enumerate(glyphs):
|
||||
s = g.get("text", "")
|
||||
start_len = len(text_str)
|
||||
@@ -276,12 +305,11 @@ def search_document(document_id: str, q: str) -> list[SearchMatch]:
|
||||
for _ in range(len(text_str) - start_len):
|
||||
char_to_glyph.append(i)
|
||||
|
||||
lower_text = text_str.lower()
|
||||
idx = 0
|
||||
while True:
|
||||
idx = lower_text.find(lower_query, idx)
|
||||
if idx == -1:
|
||||
break
|
||||
search_text = text_str if case_sensitive else text_str.lower()
|
||||
|
||||
for idx in _find_all(search_text, search_needle):
|
||||
if idx + query_len - 1 >= len(char_to_glyph):
|
||||
continue
|
||||
|
||||
start_glyph_idx = char_to_glyph[idx]
|
||||
end_glyph_idx = char_to_glyph[idx + query_len - 1]
|
||||
@@ -314,8 +342,6 @@ def search_document(document_id: str, q: str) -> list[SearchMatch]:
|
||||
)
|
||||
)
|
||||
|
||||
idx += 1
|
||||
|
||||
return matches
|
||||
|
||||
except Exception as e:
|
||||
@@ -335,6 +361,7 @@ class GlyphModel(BaseModel):
|
||||
bbox_w: float
|
||||
bbox_h: float
|
||||
angle: float
|
||||
page_object_index: int = -1
|
||||
|
||||
|
||||
class TextRunModel(BaseModel):
|
||||
@@ -350,6 +377,7 @@ class TextRunModel(BaseModel):
|
||||
y: float
|
||||
w: float
|
||||
h: float
|
||||
object_indices: list[int] = []
|
||||
|
||||
|
||||
class TextLineModel(BaseModel):
|
||||
@@ -415,6 +443,7 @@ def get_page_model(document_id: str, page_index: int) -> PageModelResponse:
|
||||
bbox_w=g.bbox_w,
|
||||
bbox_h=g.bbox_h,
|
||||
angle=g.angle,
|
||||
page_object_index=g.page_object_index,
|
||||
)
|
||||
)
|
||||
runs.append(
|
||||
@@ -431,6 +460,7 @@ def get_page_model(document_id: str, page_index: int) -> PageModelResponse:
|
||||
y=r.y,
|
||||
w=r.w,
|
||||
h=r.h,
|
||||
object_indices=r.object_indices,
|
||||
)
|
||||
)
|
||||
lines.append(
|
||||
|
||||
@@ -168,8 +168,29 @@ class PageReorderOperation(BaseModel):
|
||||
data: PageReorderData
|
||||
|
||||
|
||||
class EditTextData(BaseModel):
|
||||
# Target line/run bbox in PDF bottom-left page space (frontend does the Y-flip).
|
||||
x: float
|
||||
y: float
|
||||
width: float = Field(..., gt=0)
|
||||
height: float = Field(..., gt=0)
|
||||
newText: str
|
||||
originalText: str | None = None
|
||||
fontSize: float | None = Field(default=None, gt=0)
|
||||
color: str | None = None
|
||||
fallbackFont: str = "Helvetica"
|
||||
|
||||
|
||||
class EditTextOperation(BaseModel):
|
||||
id: str
|
||||
type: Literal["edit_text"]
|
||||
pageIndex: int = Field(..., ge=0)
|
||||
data: EditTextData
|
||||
|
||||
|
||||
class UpdateFieldData(BaseModel):
|
||||
value: str | bool
|
||||
annotationId: str | None = None
|
||||
|
||||
|
||||
class UpdateFieldOperation(BaseModel):
|
||||
@@ -208,6 +229,20 @@ class UpdateAnnotationOperation(BaseModel):
|
||||
data: UpdateAnnotationData
|
||||
|
||||
|
||||
class ReplaceTextData(BaseModel):
|
||||
objectIndices: list[int]
|
||||
text: str
|
||||
internalFontId: str
|
||||
fontSize: float
|
||||
|
||||
|
||||
class ReplaceTextOperation(BaseModel):
|
||||
id: str
|
||||
type: Literal["replace_text"]
|
||||
pageIndex: int = Field(..., ge=0)
|
||||
data: ReplaceTextData
|
||||
|
||||
|
||||
EditOperation = Annotated[
|
||||
TextOverlayOperation
|
||||
| RedactionOperation
|
||||
@@ -221,7 +256,9 @@ EditOperation = Annotated[
|
||||
| PageReorderOperation
|
||||
| UpdateFieldOperation
|
||||
| DeleteAnnotationOperation
|
||||
| UpdateAnnotationOperation,
|
||||
| UpdateAnnotationOperation
|
||||
| EditTextOperation
|
||||
| ReplaceTextOperation,
|
||||
Field(discriminator="type"),
|
||||
]
|
||||
|
||||
@@ -295,8 +332,11 @@ def apply_edits_impl(document_id: str, request: EditsRequest):
|
||||
edits_json = json.dumps(req_dict)
|
||||
doc.apply_edits(edits_json)
|
||||
|
||||
has_redaction = any(op.get("type") == "redaction" for op in req_dict.get("operations", []))
|
||||
new_bytes = doc.save_full() if has_redaction else doc.save_incremental()
|
||||
# Redaction and in-place text rewrites mutate existing objects, which do
|
||||
# not round-trip cleanly through an incremental save — force a full save.
|
||||
full_save_types = {"redaction", "edit_text"}
|
||||
needs_full = any(op.get("type") in full_save_types for op in req_dict.get("operations", []))
|
||||
new_bytes = doc.save_full() if needs_full else doc.save_incremental()
|
||||
|
||||
new_doc = pdfengine.PdfDocument.load_from_memory(new_bytes)
|
||||
|
||||
|
||||
@@ -56,7 +56,14 @@ def extract_page_text(document_id: str, page_index: Annotated[int, Path(ge=0)]):
|
||||
annots = page.extract_annotations_text()
|
||||
if annots:
|
||||
text += "\n" + "\n".join(annots)
|
||||
# The engine emits glyph bounds in PDFium-native BOTTOM-LEFT space (y = bbox
|
||||
# bottom). The frontend renders pages top-down, so flip to TOP-LEFT here —
|
||||
# the same convention /search and the annotation/highlight paths already use.
|
||||
# Without this, text selection is vertically mirrored.
|
||||
page_height = page.height
|
||||
glyphs = page.extract_text_with_bounds()
|
||||
for g in glyphs:
|
||||
g["y"] = page_height - (g["y"] + g["h"])
|
||||
return {"text": text, "glyphs": glyphs}
|
||||
except IndexError:
|
||||
raise HTTPException(
|
||||
|
||||
@@ -0,0 +1,314 @@
|
||||
"""
|
||||
Tests verifying the three bug fixes from the connectivity audit:
|
||||
|
||||
BUG-1 — FontInfo field name: gateway must return `fontName` (not `name`)
|
||||
BUG-2 — Search case-sensitivity and whole-word filtering
|
||||
BUG-3 — applyEdits canonical route POST /documents/{id}/edits
|
||||
"""
|
||||
import contextlib
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.services import engine
|
||||
from app.services.store import document_store
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Guard: skip entire module if the engine is unavailable / built without PDFium
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
has_pdfium = False
|
||||
if engine.is_available():
|
||||
with contextlib.suppress(Exception):
|
||||
has_pdfium = engine.require().engine_has_pdfium()
|
||||
|
||||
pytestmark = pytest.mark.skipif(
|
||||
not engine.is_available() or not has_pdfium,
|
||||
reason="pdfengine pybind11 module is not compiled/available, or was compiled without PDFium support.",
|
||||
)
|
||||
|
||||
CORPUS_DIR = Path(__file__).parent.parent.parent / "corpus"
|
||||
HELLO_WORLD_PDF = CORPUS_DIR / "basic" / "hello_world.pdf"
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def clean_store():
|
||||
with document_store._lock:
|
||||
document_store._documents.clear()
|
||||
yield
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# BUG-1 — FontInfo field name
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestBug1FontInfoFieldName:
|
||||
"""
|
||||
Gateway must serialise font records with the key `fontName`, not `name`.
|
||||
The frontend FontInfo interface expects `fontName`.
|
||||
"""
|
||||
|
||||
def test_document_fonts_response_has_fontName_key(self, client: TestClient):
|
||||
assert HELLO_WORLD_PDF.exists(), f"Test corpus file not found at {HELLO_WORLD_PDF}"
|
||||
|
||||
with open(HELLO_WORLD_PDF, "rb") as f:
|
||||
upload_resp = client.post(
|
||||
"/documents", files={"file": (HELLO_WORLD_PDF.name, f, "application/pdf")}
|
||||
)
|
||||
assert upload_resp.status_code == 201
|
||||
doc_id = upload_resp.json()["id"]
|
||||
|
||||
fonts_resp = client.get(f"/documents/{doc_id}/fonts")
|
||||
assert fonts_resp.status_code == 200
|
||||
fonts = fonts_resp.json()
|
||||
|
||||
# There must be at least one font in hello_world.pdf
|
||||
assert len(fonts) > 0, "Expected at least one font in hello_world.pdf"
|
||||
|
||||
for font in fonts:
|
||||
# The key MUST be 'fontName', not 'name'
|
||||
assert "fontName" in font, (
|
||||
f"Response font object missing 'fontName' key. Got keys: {list(font.keys())}"
|
||||
)
|
||||
assert "name" not in font, (
|
||||
"Response font object must NOT have a bare 'name' key (frontend expects 'fontName')"
|
||||
)
|
||||
assert isinstance(font["fontName"], str)
|
||||
assert len(font["fontName"]) > 0
|
||||
|
||||
def test_page_fonts_response_has_fontName_key(self, client: TestClient):
|
||||
assert HELLO_WORLD_PDF.exists()
|
||||
|
||||
with open(HELLO_WORLD_PDF, "rb") as f:
|
||||
upload_resp = client.post(
|
||||
"/documents", files={"file": (HELLO_WORLD_PDF.name, f, "application/pdf")}
|
||||
)
|
||||
doc_id = upload_resp.json()["id"]
|
||||
|
||||
page_fonts_resp = client.get(f"/documents/{doc_id}/pages/0/fonts")
|
||||
assert page_fonts_resp.status_code == 200
|
||||
fonts = page_fonts_resp.json()
|
||||
|
||||
for font in fonts:
|
||||
assert "fontName" in font
|
||||
assert "name" not in font
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# BUG-2 — Search: case-sensitive and whole-word options
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestBug2SearchOptions:
|
||||
"""
|
||||
GET /documents/{id}/search must honour the `case_sensitive` and
|
||||
`whole_words` query parameters forwarded by the frontend.
|
||||
"""
|
||||
|
||||
def _upload(self, client: TestClient) -> str:
|
||||
assert HELLO_WORLD_PDF.exists()
|
||||
with open(HELLO_WORLD_PDF, "rb") as f:
|
||||
r = client.post(
|
||||
"/documents", files={"file": (HELLO_WORLD_PDF.name, f, "application/pdf")}
|
||||
)
|
||||
assert r.status_code == 201
|
||||
return r.json()["id"]
|
||||
|
||||
# -- Basic case-insensitive (the old default behaviour must still work) --
|
||||
|
||||
def test_search_basic_case_insensitive(self, client: TestClient):
|
||||
doc_id = self._upload(client)
|
||||
# hello_world.pdf contains "Hello" or "hello" — search lowercase
|
||||
resp = client.get(f"/documents/{doc_id}/search?q=hello")
|
||||
assert resp.status_code == 200
|
||||
results = resp.json()
|
||||
assert len(results) > 0, "Expected at least one match for 'hello' (case-insensitive)"
|
||||
|
||||
# -- Case-sensitive: exact match must find the right casing -----------
|
||||
|
||||
def test_search_case_sensitive_exact_match(self, client: TestClient):
|
||||
doc_id = self._upload(client)
|
||||
|
||||
# First find what text is actually on the page
|
||||
text_resp = client.get(f"/documents/{doc_id}/pages/0/text")
|
||||
assert text_resp.status_code == 200
|
||||
page_text: str = text_resp.json()["text"]
|
||||
|
||||
# Derive a mixed-case word that exists in the document
|
||||
words = [w for w in page_text.split() if len(w) >= 3 and w[0].isupper()]
|
||||
if not words:
|
||||
pytest.skip("No suitable mixed-case word found in hello_world.pdf for this test")
|
||||
|
||||
word = words[0] # e.g. "Hello"
|
||||
lower_word = word.lower()
|
||||
|
||||
# Case-sensitive search for the correctly-cased word must find it
|
||||
resp_exact = client.get(
|
||||
f"/documents/{doc_id}/search?q={word}&case_sensitive=true"
|
||||
)
|
||||
assert resp_exact.status_code == 200
|
||||
assert len(resp_exact.json()) > 0, (
|
||||
f"case_sensitive=true search for '{word}' returned no results"
|
||||
)
|
||||
|
||||
# Case-sensitive search for the lowercase version must NOT find it
|
||||
# (only when the document only has the upper-cased version)
|
||||
if lower_word != word:
|
||||
resp_wrong_case = client.get(
|
||||
f"/documents/{doc_id}/search?q={lower_word}&case_sensitive=true"
|
||||
)
|
||||
assert resp_wrong_case.status_code == 200
|
||||
# The lowercase version should yield zero hits when document uses title-case
|
||||
assert len(resp_wrong_case.json()) == 0, (
|
||||
f"case_sensitive=true search for lowercase '{lower_word}' should return 0 "
|
||||
f"results when document only has '{word}'"
|
||||
)
|
||||
|
||||
# -- Case-sensitive vs case-insensitive: count must differ when casing matters
|
||||
|
||||
def test_search_case_insensitive_finds_more_or_equal(self, client: TestClient):
|
||||
doc_id = self._upload(client)
|
||||
|
||||
text_resp = client.get(f"/documents/{doc_id}/pages/0/text")
|
||||
page_text: str = text_resp.json()["text"]
|
||||
words = [w.strip(".,;:()") for w in page_text.split() if len(w) >= 3]
|
||||
if not words:
|
||||
pytest.skip("No words found")
|
||||
|
||||
q = words[0].lower()
|
||||
|
||||
insensitive = client.get(f"/documents/{doc_id}/search?q={q}&case_sensitive=false")
|
||||
sensitive = client.get(f"/documents/{doc_id}/search?q={q}&case_sensitive=true")
|
||||
|
||||
assert insensitive.status_code == 200
|
||||
assert sensitive.status_code == 200
|
||||
|
||||
# Case-insensitive must find at least as many results as case-sensitive
|
||||
assert len(insensitive.json()) >= len(sensitive.json())
|
||||
|
||||
# -- Whole-word: partial substring must NOT match -----------------------
|
||||
|
||||
def test_search_whole_words_no_partial_match(self, client: TestClient):
|
||||
doc_id = self._upload(client)
|
||||
|
||||
# Find a multi-character word in the document
|
||||
text_resp = client.get(f"/documents/{doc_id}/pages/0/text")
|
||||
page_text: str = text_resp.json()["text"]
|
||||
words = [w.strip(".,;:()") for w in page_text.split() if len(w) >= 4]
|
||||
if not words:
|
||||
pytest.skip("No suitable word found")
|
||||
|
||||
full_word = words[0].lower()
|
||||
# A prefix that is NOT itself a word
|
||||
partial = full_word[:-1]
|
||||
|
||||
# Partial substring should match without whole_words constraint
|
||||
resp_partial = client.get(f"/documents/{doc_id}/search?q={partial}&whole_words=false")
|
||||
assert resp_partial.status_code == 200
|
||||
|
||||
# With whole_words=true the partial prefix must NOT match the full word
|
||||
resp_whole = client.get(f"/documents/{doc_id}/search?q={partial}&whole_words=true")
|
||||
assert resp_whole.status_code == 200
|
||||
|
||||
partial_count = len(resp_partial.json())
|
||||
whole_count = len(resp_whole.json())
|
||||
|
||||
# Whole-word search must return <= partial results
|
||||
assert whole_count <= partial_count, (
|
||||
f"whole_words=true returned {whole_count} results but partial search returned {partial_count}"
|
||||
)
|
||||
|
||||
# -- whole_words=true for an exact word must still find it --------------
|
||||
|
||||
def test_search_whole_words_exact_word_found(self, client: TestClient):
|
||||
doc_id = self._upload(client)
|
||||
|
||||
text_resp = client.get(f"/documents/{doc_id}/pages/0/text")
|
||||
page_text: str = text_resp.json()["text"]
|
||||
words = [w.strip(".,;:()") for w in page_text.split() if len(w) >= 3]
|
||||
if not words:
|
||||
pytest.skip("No words found")
|
||||
|
||||
q = words[0].lower()
|
||||
|
||||
resp = client.get(f"/documents/{doc_id}/search?q={q}&whole_words=true")
|
||||
assert resp.status_code == 200
|
||||
# The exact word (cleaned of punctuation) should appear somewhere
|
||||
# We don't assert count > 0 unconditionally because punctuation stripping
|
||||
# may have altered the word boundary check, but the request must succeed.
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# BUG-3 — Canonical edits route
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestBug3CanonicalEditsRoute:
|
||||
"""
|
||||
Edits must be accepted at the canonical REST route:
|
||||
POST /documents/{id}/edits
|
||||
(not only at the legacy compat alias POST /edits/{id}).
|
||||
"""
|
||||
|
||||
def test_apply_edits_via_canonical_route(self, client: TestClient):
|
||||
assert HELLO_WORLD_PDF.exists()
|
||||
|
||||
with open(HELLO_WORLD_PDF, "rb") as f:
|
||||
upload_resp = client.post(
|
||||
"/documents", files={"file": (HELLO_WORLD_PDF.name, f, "application/pdf")}
|
||||
)
|
||||
assert upload_resp.status_code == 201
|
||||
doc_id = upload_resp.json()["id"]
|
||||
|
||||
edits_payload = {
|
||||
"version": "1.0",
|
||||
"operations": [
|
||||
{
|
||||
"id": "op_canonical_route_test",
|
||||
"type": "text_overlay",
|
||||
"pageIndex": 0,
|
||||
"data": {
|
||||
"text": "Canonical Route Test",
|
||||
"x": 50.0,
|
||||
"y": 50.0,
|
||||
"width": 200.0,
|
||||
"height": 20.0,
|
||||
"fontSize": 12.0,
|
||||
"fontFamily": "Helvetica",
|
||||
"color": "#000000",
|
||||
},
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
# Use the CANONICAL route — must return 200 with success
|
||||
resp = client.post(f"/documents/{doc_id}/edits", json=edits_payload)
|
||||
assert resp.status_code == 200, f"Canonical route failed: {resp.text}"
|
||||
payload = resp.json()
|
||||
assert payload["success"] is True
|
||||
assert payload["newDocumentId"] != doc_id
|
||||
|
||||
def test_compat_edits_route_still_works(self, client: TestClient):
|
||||
"""Regression guard: the compat alias must continue to work."""
|
||||
assert HELLO_WORLD_PDF.exists()
|
||||
|
||||
with open(HELLO_WORLD_PDF, "rb") as f:
|
||||
upload_resp = client.post(
|
||||
"/documents", files={"file": (HELLO_WORLD_PDF.name, f, "application/pdf")}
|
||||
)
|
||||
doc_id = upload_resp.json()["id"]
|
||||
|
||||
edits_payload = {
|
||||
"version": "1.0",
|
||||
"operations": [
|
||||
{
|
||||
"id": "op_compat_route_test",
|
||||
"type": "page_rotation",
|
||||
"pageIndex": 0,
|
||||
"data": {"rotation": 90},
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
resp = client.post(f"/edits/{doc_id}", json=edits_payload)
|
||||
assert resp.status_code == 200, f"Compat route failed: {resp.text}"
|
||||
assert resp.json()["success"] is True
|
||||
@@ -10,7 +10,10 @@ def test_health_returns_ok(client: TestClient) -> None:
|
||||
|
||||
payload = response.json()
|
||||
assert payload["status"] == "ok"
|
||||
assert payload["engine_available"] is False
|
||||
# engine_available reflects the actual build environment — just assert the field exists and is a bool
|
||||
assert isinstance(payload["engine_available"], bool), (
|
||||
f"Expected engine_available to be a bool, got: {payload['engine_available']!r}"
|
||||
)
|
||||
assert "version" in payload
|
||||
assert "environment" in payload
|
||||
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import os
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from app.main import app
|
||||
|
||||
client = TestClient(app)
|
||||
|
||||
def test_replace_text_operation():
|
||||
# 1. Upload hello_world.pdf
|
||||
filepath = os.path.abspath("../corpus/basic/hello_world.pdf")
|
||||
if not os.path.exists(filepath):
|
||||
filepath = os.path.abspath("gateway/../corpus/basic/hello_world.pdf")
|
||||
|
||||
with open(filepath, "rb") as f:
|
||||
resp = client.post("/documents", files={"file": ("hello_world.pdf", f, "application/pdf")})
|
||||
assert resp.status_code == 201
|
||||
doc_id = resp.json()["id"]
|
||||
|
||||
# 2. Extract document model
|
||||
resp = client.get(f"/documents/{doc_id}/pages/0/model")
|
||||
assert resp.status_code == 200
|
||||
model = resp.json()
|
||||
|
||||
# 3. Find a text run to replace (e.g. "Hello, world!" or just any run)
|
||||
target_run = None
|
||||
for p in model["paragraphs"]:
|
||||
for line in p["lines"]:
|
||||
for run in line["runs"]:
|
||||
if "hello" in run["text"].lower():
|
||||
target_run = run
|
||||
break
|
||||
if target_run:
|
||||
break
|
||||
if target_run:
|
||||
break
|
||||
|
||||
assert target_run is not None, "Could not find a text run with 'hello'"
|
||||
assert "object_indices" in target_run
|
||||
assert len(target_run["object_indices"]) > 0
|
||||
|
||||
# Check that glyphs have page_object_index
|
||||
for g in target_run["glyphs"]:
|
||||
assert "page_object_index" in g
|
||||
assert g["page_object_index"] >= 0
|
||||
|
||||
# 4. Perform replace_text operation
|
||||
edits_payload = {
|
||||
"version": "1.0",
|
||||
"operations": [
|
||||
{
|
||||
"id": "replace_text_op_1",
|
||||
"type": "replace_text",
|
||||
"pageIndex": 0,
|
||||
"data": {
|
||||
"objectIndices": target_run["object_indices"],
|
||||
"text": "Greeting, universe!",
|
||||
"internalFontId": target_run["internal_font_id"],
|
||||
"fontSize": target_run["font_size"]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
resp = client.post(f"/documents/{doc_id}/edits", json=edits_payload)
|
||||
assert resp.status_code == 200
|
||||
res = resp.json()
|
||||
assert res["success"] is True
|
||||
new_doc_id = res["newDocumentId"]
|
||||
|
||||
# 5. Verify text is replaced in new document
|
||||
resp = client.get(f"/documents/{new_doc_id}/pages/0/text")
|
||||
assert resp.status_code == 200
|
||||
text_data = resp.json()
|
||||
|
||||
assert "Greeting, universe!" in text_data["text"]
|
||||
assert "hello" not in text_data["text"].lower()
|
||||
|
||||
# 6. Verify page can render visual representation successfully
|
||||
resp = client.get(f"/documents/{new_doc_id}/pages/0/render")
|
||||
assert resp.status_code == 200
|
||||
assert resp.headers["content-type"] == "image/png"
|
||||
@@ -0,0 +1,130 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Download a large PDF corpus for fuzzing into a gitignored directory.
|
||||
|
||||
The committed `corpus/` holds a small, curated set of fixtures used by the
|
||||
rendering-regression baseline. Fuzzing wants *volume and variety* instead, so we
|
||||
pull hundreds of real-world PDFs from public test suites into `corpus/fuzz/`,
|
||||
which is gitignored.
|
||||
|
||||
python scripts/fetch_corpus.py # default: ~600 from pdf.js
|
||||
python scripts/fetch_corpus.py --limit 200
|
||||
python scripts/fetch_corpus.py --source pdfium # GoogleTest pdfium corpus
|
||||
|
||||
Network failures are tolerated: whatever downloads is usable, and re-running only
|
||||
fetches what's missing. Only standard-library modules are used.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
# Stable raw-file base for the pinned manifest (filename → bytes).
|
||||
_RAW_BASE = "https://raw.githubusercontent.com/mozilla/pdf.js/master/test/pdfs"
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
DEST = ROOT / "corpus" / "fuzz"
|
||||
|
||||
# GitHub "contents" API listings of directories full of .pdf files.
|
||||
SOURCES = {
|
||||
"pdfjs": "https://api.github.com/repos/mozilla/pdf.js/contents/test/pdfs?ref=master",
|
||||
"pdfium": "https://api.github.com/repos/PDFium/pdfium/contents/testing/resources?ref=main",
|
||||
}
|
||||
|
||||
_HEADERS = {"User-Agent": "pdfengine-fetch-corpus", "Accept": "application/vnd.github+json"}
|
||||
|
||||
|
||||
def _get(url: str, raw: bool = False) -> bytes:
|
||||
req = urllib.request.Request(url, headers=_HEADERS)
|
||||
with urllib.request.urlopen(req, timeout=30) as resp: # noqa: S310 (trusted hosts)
|
||||
return resp.read()
|
||||
|
||||
|
||||
def _fetch_from_manifest(manifest_path: Path, dest: Path) -> int:
|
||||
"""Download exactly the files in the pinned manifest and verify their SHA-256,
|
||||
so every developer and CI run gets a byte-identical corpus."""
|
||||
manifest = json.loads(manifest_path.read_text())
|
||||
files = manifest.get("files", [])
|
||||
print(f"Manifest: {len(files)} pinned files -> {dest} (verifying SHA-256)")
|
||||
got = existed = failed = mismatch = 0
|
||||
for entry in files:
|
||||
name, want = entry["name"], entry["sha256"]
|
||||
out = dest / name
|
||||
if out.exists() and hashlib.sha256(out.read_bytes()).hexdigest() == want:
|
||||
existed += 1
|
||||
continue
|
||||
try:
|
||||
data = _get(f"{_RAW_BASE}/{name}", raw=True)
|
||||
except (urllib.error.URLError, OSError) as exc:
|
||||
print(f" fail {name} ({exc})")
|
||||
failed += 1
|
||||
continue
|
||||
have = hashlib.sha256(data).hexdigest()
|
||||
if have != want:
|
||||
print(f" MISMATCH {name}: expected {want[:12]}..., got {have[:12]}... (skipped)")
|
||||
mismatch += 1
|
||||
continue
|
||||
out.write_bytes(data)
|
||||
got += 1
|
||||
if got % 50 == 0:
|
||||
print(f" ... {got} verified")
|
||||
total = len(list(dest.glob("*.pdf")))
|
||||
print(f"\nDone. +{got} new, {existed} already present & verified, "
|
||||
f"{failed} failed, {mismatch} hash-mismatch.")
|
||||
print(f"Corpus now holds {total} PDFs at {dest}")
|
||||
return 0 if mismatch == 0 else 1
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser(description=__doc__)
|
||||
ap.add_argument("--source", choices=sorted(SOURCES), default="pdfjs")
|
||||
ap.add_argument("--limit", type=int, default=600, help="max files to download")
|
||||
ap.add_argument("--dest", type=Path, default=DEST)
|
||||
ap.add_argument("--manifest", type=Path, default=None,
|
||||
help="reproduce the exact pinned corpus from a manifest (verifies SHA-256)")
|
||||
args = ap.parse_args()
|
||||
|
||||
args.dest.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Manifest mode: deterministic, hash-verified, identical for everyone.
|
||||
if args.manifest:
|
||||
return _fetch_from_manifest(args.manifest, args.dest)
|
||||
|
||||
print(f"Listing {args.source} corpus ...")
|
||||
try:
|
||||
listing = json.loads(_get(SOURCES[args.source]))
|
||||
except (urllib.error.URLError, json.JSONDecodeError) as exc:
|
||||
print(f"ERROR: could not list corpus ({exc}). Check your network / GitHub rate limit.")
|
||||
return 2
|
||||
|
||||
pdfs = [e for e in listing if e.get("name", "").lower().endswith(".pdf") and e.get("download_url")]
|
||||
print(f"Found {len(pdfs)} PDFs; downloading up to {args.limit} into {args.dest} ...")
|
||||
|
||||
got = failed = existed = 0
|
||||
for entry in pdfs[: args.limit]:
|
||||
out = args.dest / entry["name"]
|
||||
if out.exists() and out.stat().st_size > 0:
|
||||
existed += 1
|
||||
continue
|
||||
try:
|
||||
out.write_bytes(_get(entry["download_url"], raw=True))
|
||||
got += 1
|
||||
if got % 25 == 0:
|
||||
print(f" ... {got} downloaded")
|
||||
except (urllib.error.URLError, OSError) as exc:
|
||||
print(f" fail {entry['name']} ({exc})")
|
||||
failed += 1
|
||||
|
||||
total = len(list(args.dest.glob("*.pdf")))
|
||||
print(f"\nDone. +{got} new, {existed} already present, {failed} failed.")
|
||||
print(f"Corpus now holds {total} PDFs at {args.dest}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,130 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Round-trip tests for the `edit_text` op (line-level text rewriting).
|
||||
|
||||
Verifies that editing an existing text object: (1) actually changes the rendered
|
||||
text, (2) keeps the result within the original line's horizontal bounds (the
|
||||
acceptance criterion), and (3) fails closed when the target bbox matches nothing.
|
||||
|
||||
Run directly: gateway/.venv/Scripts/python.exe tests/edits/test_edit_text.py
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(ROOT / "gateway"))
|
||||
import pdfengine # noqa: E402
|
||||
|
||||
CORPUS = ROOT / "corpus" / "fonts" / "utf-8.pdf"
|
||||
EPS = 2.0 # points; tolerance for rounding / glyph metrics
|
||||
|
||||
|
||||
def _load():
|
||||
return pdfengine.PdfDocument.load_from_memory(CORPUS.read_bytes(), "")
|
||||
|
||||
|
||||
def _runs(model):
|
||||
return [r for p in model.paragraphs for l in p.lines for r in l.runs]
|
||||
|
||||
|
||||
def _first_run(model):
|
||||
return model.paragraphs[0].lines[0].runs[0]
|
||||
|
||||
|
||||
def _edit_op(model, run, new_text: str):
|
||||
# Engine model coords are PDFium-native BOTTOM-LEFT (y = bbox bottom), the same
|
||||
# space the op + FPDFPageObj_GetBounds use — so no flip here.
|
||||
return {
|
||||
"version": "1.0",
|
||||
"operations": [{
|
||||
"id": "e1", "type": "edit_text", "pageIndex": 0,
|
||||
"data": {
|
||||
"x": run.x,
|
||||
"y": run.y,
|
||||
"width": run.w,
|
||||
"height": run.h,
|
||||
"newText": new_text,
|
||||
"originalText": run.text,
|
||||
"fontSize": run.font_size,
|
||||
"fallbackFont": "Helvetica",
|
||||
},
|
||||
}],
|
||||
}
|
||||
|
||||
|
||||
def test_roundtrip_replaces_text():
|
||||
doc = _load()
|
||||
model = doc.get_page(0).extract_document_model()
|
||||
run = _first_run(model)
|
||||
orig_x, orig_w, orig_y = run.x, run.w, run.y
|
||||
doc.apply_edits(json.dumps(_edit_op(model, run, "REPLACED")))
|
||||
out = doc.save_full()
|
||||
|
||||
m2 = pdfengine.PdfDocument.load_from_memory(out, "").get_page(0).extract_document_model()
|
||||
edited = next((r for r in _runs(m2) if "REPLACED" in r.text), None)
|
||||
assert edited is not None, "edited text 'REPLACED' not found after round-trip"
|
||||
# stays within the original horizontal bounds
|
||||
assert edited.x >= orig_x - EPS, f"left moved out of bounds: {edited.x} < {orig_x}"
|
||||
assert edited.x + edited.w <= orig_x + orig_w + EPS, \
|
||||
f"right overflowed bounds: {edited.x + edited.w} > {orig_x + orig_w}"
|
||||
# baseline / vertical position preserved
|
||||
assert abs(edited.y - orig_y) < EPS + run.h, f"baseline drifted: {edited.y} vs {orig_y}"
|
||||
print(f" ok roundtrip: '{run.text[:20]}' -> '{edited.text[:20]}' "
|
||||
f"(x {orig_x:.0f}..{orig_x + orig_w:.0f} -> {edited.x:.0f}..{edited.x + edited.w:.0f})")
|
||||
|
||||
|
||||
def test_long_text_squeezes_within_bounds():
|
||||
doc = _load()
|
||||
model = doc.get_page(0).extract_document_model()
|
||||
run = _first_run(model)
|
||||
orig_right = run.x + run.w
|
||||
long_text = "This is a deliberately very long replacement string to force squeeze"
|
||||
doc.apply_edits(json.dumps(_edit_op(model, run, long_text)))
|
||||
out = doc.save_full()
|
||||
|
||||
m2 = pdfengine.PdfDocument.load_from_memory(out, "").get_page(0).extract_document_model()
|
||||
edited = next((r for r in _runs(m2) if "deliberately" in r.text), None)
|
||||
assert edited is not None, "long edited text not found"
|
||||
assert edited.x + edited.w <= orig_right + EPS, \
|
||||
f"long text overflowed: {edited.x + edited.w:.1f} > {orig_right:.1f}"
|
||||
print(f" ok squeeze: {len(long_text)} chars fit in {run.w:.0f}pt "
|
||||
f"(right {edited.x + edited.w:.0f} <= {orig_right:.0f})")
|
||||
|
||||
|
||||
def test_no_match_fails():
|
||||
doc = _load()
|
||||
model = doc.get_page(0).extract_document_model()
|
||||
op = {
|
||||
"version": "1.0",
|
||||
"operations": [{
|
||||
"id": "e1", "type": "edit_text", "pageIndex": 0,
|
||||
"data": {"x": 5000.0, "y": 5000.0, "width": 50.0, "height": 10.0,
|
||||
"newText": "X", "fallbackFont": "Helvetica"},
|
||||
}],
|
||||
}
|
||||
try:
|
||||
doc.apply_edits(json.dumps(op))
|
||||
except Exception:
|
||||
print(" ok no-match correctly raised")
|
||||
return
|
||||
raise AssertionError("edit_text with an off-page bbox should have failed, but succeeded")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
tests = [test_roundtrip_replaces_text, test_long_text_squeezes_within_bounds, test_no_match_fails]
|
||||
failed = 0
|
||||
for t in tests:
|
||||
try:
|
||||
t()
|
||||
except AssertionError as exc:
|
||||
print(f" FAIL {t.__name__}: {exc}")
|
||||
failed += 1
|
||||
print(f"\n{len(tests) - failed}/{len(tests)} passed.")
|
||||
return 1 if failed else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,72 @@
|
||||
# Rendering-regression harness
|
||||
|
||||
Renders the committed `corpus/` with the PDFium engine and compares each page to
|
||||
a **frozen baseline** using SSIM. A failure means *this* build renders
|
||||
differently from the blessed build — not that it disagrees with Acrobat.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- The engine is built and copied to `gateway/` (run `scripts/build_cpp.ps1 -Preset win-local-pdfium`).
|
||||
- The gateway venv has `numpy` and `Pillow` (`gateway/.venv`).
|
||||
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
# Check the current build against the frozen baseline (CI mode; non-zero exit on regression)
|
||||
gateway/.venv/Scripts/python.exe tests/regression/run.py
|
||||
|
||||
# Re-freeze the baseline after an *intentional* rendering change (review the diff!)
|
||||
gateway/.venv/Scripts/python.exe tests/regression/run.py --update
|
||||
```
|
||||
|
||||
Options: `--dpi 72`, `--max-pages 2`, `--threshold 0.990`, `--corpus`, `--baseline`.
|
||||
|
||||
## Large corpus (500+ real-world PDFs)
|
||||
|
||||
The corpus is **pinned and shared via a manifest** so every developer and CI run
|
||||
gets a byte-identical set. The 600 PDFs themselves are gitignored (≈68 MB); only
|
||||
the manifest (`corpus-manifest.json`, name + SHA-256 per file, ≈96 KB) is
|
||||
committed. Reproduce the exact corpus with:
|
||||
|
||||
```bash
|
||||
python scripts/fetch_corpus.py --manifest tests/regression/corpus-manifest.json
|
||||
```
|
||||
|
||||
This downloads each pinned file and **verifies its SHA-256** — a mismatch is
|
||||
reported and skipped, so the corpus can never silently drift between machines.
|
||||
(`scripts/fetch_corpus.py` with no `--manifest` just grabs "latest" from pdf.js,
|
||||
which is *not* reproducible — use it only to refresh/regenerate the manifest.)
|
||||
|
||||
Two ways to use the corpus for regression:
|
||||
|
||||
```bash
|
||||
# 1. Render-stability sweep — no baseline; passes as long as the engine never
|
||||
# crashes on any real-world PDF. The CI-friendly form of a 500+ regression.
|
||||
python scripts/fetch_corpus.py
|
||||
gateway/.venv/Scripts/python.exe tests/regression/run.py --sweep --corpus corpus/fuzz --max-pages 3
|
||||
|
||||
# 2. SSIM baseline over the large corpus (baseline is gitignored — it is derived
|
||||
# from gitignored inputs and is ~tens of MB, so it lives locally / as a CI
|
||||
# artifact, not in git).
|
||||
... run.py --update --corpus corpus/fuzz --baseline tests/regression/baseline-large
|
||||
... run.py --corpus corpus/fuzz --baseline tests/regression/baseline-large
|
||||
```
|
||||
|
||||
The committed `baseline/` stays the small curated set (reviewable in PRs);
|
||||
`baseline-large/` is the throwaway large-corpus reference. In CI the libFuzzer
|
||||
binary doubles as a sweep via `pdfengine_fuzz -runs=0 corpus/fuzz/` (see
|
||||
`.github/workflows/fuzz.yml`).
|
||||
|
||||
## How it works
|
||||
|
||||
- `ssim.py` — dependency-light SSIM (numpy only) via an integral-image box filter.
|
||||
Discriminates correctly: identical→1.0, shifted→~0.81, noise→~0.00, shape
|
||||
mismatch→0.0.
|
||||
- `run.py` — enumerates `corpus/**/*.pdf` (excluding the gitignored `corpus/fuzz/`),
|
||||
renders to grayscale, and scores against `baseline/*.png`.
|
||||
- `baseline/` — committed PNGs. Because the baseline is the engine's *own* prior
|
||||
output, it is tied to this PDFium version; re-freeze deliberately when PDFium
|
||||
is upgraded.
|
||||
|
||||
Encrypted / intentionally-malformed fixtures are skipped (and reported), not
|
||||
failed.
|
||||
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 550 B |
|
After Width: | Height: | Size: 218 B |
|
After Width: | Height: | Size: 237 B |
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 4.1 KiB |
|
After Width: | Height: | Size: 675 B |
|
After Width: | Height: | Size: 464 B |
|
After Width: | Height: | Size: 426 B |
|
After Width: | Height: | Size: 300 B |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 6.3 KiB |
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 2.9 KiB |
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 2.3 KiB |
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 866 B |
|
After Width: | Height: | Size: 866 B |
|
After Width: | Height: | Size: 3.3 KiB |
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 2.5 KiB |
|
After Width: | Height: | Size: 6.1 KiB |
|
After Width: | Height: | Size: 2.7 KiB |
|
After Width: | Height: | Size: 6.5 KiB |
|
After Width: | Height: | Size: 697 B |
|
After Width: | Height: | Size: 2.7 KiB |
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 7.9 KiB |
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 2.6 KiB |
|
After Width: | Height: | Size: 3.0 KiB |
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 2.7 KiB |
|
After Width: | Height: | Size: 2.3 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 6.4 KiB |
@@ -0,0 +1,139 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Rendering-regression harness for the PDFium engine.
|
||||
|
||||
Renders every page (capped) of every PDF in the committed corpus and compares it
|
||||
to a frozen baseline using SSIM. The baseline is the engine's own output at the
|
||||
moment it was approved — so a regression here means *this* build renders
|
||||
differently from the blessed build, not that it disagrees with Acrobat.
|
||||
|
||||
python tests/regression/run.py --update # (re)generate the frozen baseline
|
||||
python tests/regression/run.py # check current renders vs baseline
|
||||
|
||||
Exit code is non-zero if any page regresses below the SSIM threshold, so it
|
||||
drops straight into CI.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import io
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
from PIL import Image
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
from ssim import ssim # noqa: E402
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
DEFAULT_CORPUS = ROOT / "corpus"
|
||||
DEFAULT_BASELINE = Path(__file__).resolve().parent / "baseline"
|
||||
# corpus subdirs that are *not* part of the frozen baseline (large/downloaded).
|
||||
EXCLUDE_DIRS = {"fuzz"}
|
||||
|
||||
|
||||
def _import_engine():
|
||||
# The compiled extension lives in gateway/ after scripts/build_cpp.ps1.
|
||||
sys.path.insert(0, str(ROOT / "gateway"))
|
||||
import pdfengine # noqa: PLC0415
|
||||
|
||||
return pdfengine
|
||||
|
||||
|
||||
def _render_gray(page, dpi: int) -> np.ndarray:
|
||||
png = page.render(dpi).data
|
||||
img = Image.open(io.BytesIO(png)).convert("L")
|
||||
return np.asarray(img)
|
||||
|
||||
|
||||
def _corpus_pdfs(corpus: Path):
|
||||
for p in sorted(corpus.rglob("*.pdf")):
|
||||
if any(part in EXCLUDE_DIRS for part in p.relative_to(corpus).parts):
|
||||
continue
|
||||
yield p
|
||||
|
||||
|
||||
def _key(rel: Path, page_index: int) -> str:
|
||||
return f"{rel.as_posix().replace('/', '__')}__p{page_index}"
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser(description=__doc__)
|
||||
ap.add_argument("--update", action="store_true", help="write the frozen baseline instead of checking")
|
||||
ap.add_argument("--sweep", action="store_true",
|
||||
help="render-stability sweep over a large corpus (no baseline); "
|
||||
"passes as long as the engine never crashes")
|
||||
ap.add_argument("--corpus", type=Path, default=DEFAULT_CORPUS)
|
||||
ap.add_argument("--baseline", type=Path, default=DEFAULT_BASELINE)
|
||||
ap.add_argument("--dpi", type=int, default=72)
|
||||
ap.add_argument("--max-pages", type=int, default=2, help="pages rendered per document")
|
||||
ap.add_argument("--threshold", type=float, default=0.990, help="min SSIM to pass")
|
||||
args = ap.parse_args()
|
||||
|
||||
pdfengine = _import_engine()
|
||||
args.baseline.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
failures: list[tuple[str, float]] = []
|
||||
checked = skipped = updated = rendered = 0
|
||||
|
||||
for pdf in _corpus_pdfs(args.corpus):
|
||||
rel = pdf.relative_to(args.corpus)
|
||||
try:
|
||||
doc = pdfengine.PdfDocument.load_from_memory(pdf.read_bytes(), "")
|
||||
n = min(doc.page_count, args.max_pages)
|
||||
except Exception as exc: # encrypted / intentionally-malformed fixtures
|
||||
print(f" skip {rel} ({exc})")
|
||||
skipped += 1
|
||||
continue
|
||||
|
||||
for i in range(n):
|
||||
try:
|
||||
cur = _render_gray(doc.get_page(i), args.dpi)
|
||||
except Exception as exc:
|
||||
print(f" skip {rel} p{i} (render: {exc})")
|
||||
skipped += 1
|
||||
continue
|
||||
|
||||
if args.sweep:
|
||||
# Surviving the render is the whole test; a crash kills the process.
|
||||
rendered += 1
|
||||
continue
|
||||
|
||||
ref_path = args.baseline / f"{_key(rel, i)}.png"
|
||||
if args.update:
|
||||
Image.fromarray(cur).save(ref_path)
|
||||
updated += 1
|
||||
continue
|
||||
|
||||
if not ref_path.exists():
|
||||
print(f" NEW {rel} p{i} (no baseline — run --update)")
|
||||
failures.append((f"{rel} p{i}", -1.0))
|
||||
continue
|
||||
|
||||
ref = np.asarray(Image.open(ref_path).convert("L"))
|
||||
score = ssim(cur, ref)
|
||||
checked += 1
|
||||
mark = "ok " if score >= args.threshold else "FAIL "
|
||||
if score < args.threshold:
|
||||
failures.append((f"{rel} p{i}", score))
|
||||
print(f" {mark} {rel} p{i} SSIM={score:.4f}")
|
||||
|
||||
print()
|
||||
if args.sweep:
|
||||
print(f"Sweep complete: {rendered} pages rendered, {skipped} skipped (graceful). "
|
||||
f"No crash — engine is render-stable over this corpus.")
|
||||
return 0
|
||||
if args.update:
|
||||
print(f"Baseline updated: {updated} images written to {args.baseline}")
|
||||
return 0
|
||||
|
||||
print(f"Checked {checked} pages, skipped {skipped}, {len(failures)} regression(s).")
|
||||
for name, score in failures:
|
||||
tag = "missing baseline" if score < 0 else f"SSIM={score:.4f}"
|
||||
print(f" REGRESSION: {name} ({tag})")
|
||||
return 1 if failures else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,57 @@
|
||||
"""Windowed SSIM with no scipy/skimage dependency.
|
||||
|
||||
Implements the Wang et al. structural-similarity index using a uniform window
|
||||
(box filter) computed via an integral image — fast, vectorised, and dependency
|
||||
-light (numpy only). Good enough to catch rendering regressions while tolerating
|
||||
sub-pixel antialiasing noise.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
|
||||
_C1 = (0.01 * 255) ** 2
|
||||
_C2 = (0.03 * 255) ** 2
|
||||
|
||||
|
||||
def _mean_filter(img: np.ndarray, k: int) -> np.ndarray:
|
||||
"""Box-mean over a k×k window, same shape, edge-clamped window area."""
|
||||
h, w = img.shape
|
||||
pad = k // 2
|
||||
integral = np.zeros((h + 1, w + 1), dtype=np.float64)
|
||||
integral[1:, 1:] = img.cumsum(0).cumsum(1)
|
||||
|
||||
ys = np.clip(np.arange(h) - pad, 0, h)
|
||||
ye = np.clip(np.arange(h) + pad + 1, 0, h)
|
||||
xs = np.clip(np.arange(w) - pad, 0, w)
|
||||
xe = np.clip(np.arange(w) + pad + 1, 0, w)
|
||||
|
||||
a = integral[ye][:, xe]
|
||||
b = integral[ye][:, xs]
|
||||
c = integral[ys][:, xe]
|
||||
d = integral[ys][:, xs]
|
||||
total = a - b - c + d
|
||||
area = (ye - ys)[:, None] * (xe - xs)[None, :]
|
||||
return total / area
|
||||
|
||||
|
||||
def ssim(a: np.ndarray, b: np.ndarray, k: int = 7) -> float:
|
||||
"""Mean SSIM in [0, 1]. Mismatched shapes score 0.0 (always a regression)."""
|
||||
if a.shape != b.shape:
|
||||
return 0.0
|
||||
a = a.astype(np.float64)
|
||||
b = b.astype(np.float64)
|
||||
|
||||
mu_a = _mean_filter(a, k)
|
||||
mu_b = _mean_filter(b, k)
|
||||
mu_a2 = mu_a * mu_a
|
||||
mu_b2 = mu_b * mu_b
|
||||
mu_ab = mu_a * mu_b
|
||||
|
||||
var_a = _mean_filter(a * a, k) - mu_a2
|
||||
var_b = _mean_filter(b * b, k) - mu_b2
|
||||
cov_ab = _mean_filter(a * b, k) - mu_ab
|
||||
|
||||
num = (2 * mu_ab + _C1) * (2 * cov_ab + _C2)
|
||||
den = (mu_a2 + mu_b2 + _C1) * (var_a + var_b + _C2)
|
||||
return float(np.clip(num / den, 0.0, 1.0).mean())
|
||||