feat: implemented hit testing, form field, content stream and base security hardening

This commit is contained in:
Furqan-14
2026-06-11 19:19:01 +05:30
parent a4131d828e
commit 377a9044af
80 changed files with 5509 additions and 150 deletions
+104
View File
@@ -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
+10
View File
@@ -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
+12
View File
@@ -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)
if(NOT PDFENGINE_FUZZING)
add_subdirectory(bindings)
endif()
endif()
message(STATUS "PdfEngine ${PROJECT_VERSION} configured")
+24 -1
View File
@@ -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": [
+34
View File
@@ -212,6 +212,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());
})
+130
View File
@@ -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.52 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. **~11.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.52 wk | yes |
| B — Hit-testing (engine + frontend Adobe selection) | ~1.5 wk | yes |
| C — Fuzz + regression (Win + Linux) | ~11.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).
+37
View File
@@ -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()
+65
View File
@@ -0,0 +1,65 @@
# Fuzzing the PDF engine
`fuzz_load.cpp` is a libFuzzer harness that drives the full
**load → metadata → outline → render → text → annotations → hit-test → select**
path with arbitrary bytes. Combined with AddressSanitizer it surfaces crashes,
OOMs, and undefined behaviour in the parsing and rendering code.
Resource ceilings from `pdfengine/hardened_limits.h` keep the fuzzer focused on
logic bugs instead of trivial out-of-memory inputs (and those same ceilings now
guard the production render path against integer-overflow / OOM).
## Linux (primary)
Clang + libFuzzer + ASan is best supported on Linux. PDFium must be built with
the same Clang toolchain (so ASan is consistent across the static lib).
```bash
# Full ASan + coverage fuzzer
cmake --preset fuzz-linux
cmake --build --preset fuzz-linux
# If your PDFium static lib is NOT ASan-instrumented, use coverage-only:
cmake --preset fuzz-linux-nosan
cmake --build --preset fuzz-linux-nosan
# Run it against the downloaded corpus as a seed set
python scripts/fetch_corpus.py # populates corpus/fuzz/ (gitignored)
mkdir -p engine/fuzz/artifacts
./out/build/fuzz-linux/bin/pdfengine_fuzz \
-artifact_prefix=engine/fuzz/artifacts/ \
corpus/fuzz/ corpus/
```
`corpus/fuzz/` and `corpus/` are passed as seed corpora; new coverage-expanding
inputs are written back into the first directory. Crashes land in
`engine/fuzz/artifacts/` (gitignored).
## Windows (clang-cl)
Native Windows fuzzing needs a Clang toolchain *and* a PDFium static lib built
with the matching runtime. Configure with clang-cl and the existing
`x64-windows-static` triplet, then enable fuzzing:
```powershell
cmake -S . -B C:/Users/<you>/pdfeng-build/fuzz-win -G Ninja `
-DCMAKE_C_COMPILER=clang-cl -DCMAKE_CXX_COMPILER=clang-cl `
-DVCPKG_TARGET_TRIPLET=x64-windows-static `
-DPDFENGINE_FUZZING=ON -DPDFENGINE_WITH_PDFIUM=ON `
-DPDFENGINE_FUZZ_SANITIZERS=fuzzer `
--toolchain "$env:VCPKG_ROOT/scripts/buildsystems/vcpkg.cmake"
cmake --build C:/Users/<you>/pdfeng-build/fuzz-win
```
Use `PDFENGINE_FUZZ_SANITIZERS=fuzzer` (coverage-only) on Windows unless the
whole dependency chain — including PDFium — is ASan-built, since mixing an
ASan binary with a non-ASan MSVC static lib does not link cleanly.
## Reproducing a crash
```bash
./pdfengine_fuzz engine/fuzz/artifacts/crash-<hash>
```
The ASan report points at the offending allocation/access; the input file is the
minimal reproducer (run with `-minimize_crash=1` to shrink further).
+50
View File
@@ -0,0 +1,50 @@
// libFuzzer entry point: drive the full load → inspect → render → select path
// with arbitrary bytes, so the fuzzer can find crashes, OOMs, and UB in the
// PDF parsing and rendering code.
//
// Build with a Clang toolchain via the `fuzz-linux` preset (see engine/fuzz/
// README.md). Every operation is wrapped so a clean error never aborts the run —
// only a real crash (caught by the sanitizer) should stop the fuzzer.
#include "pdfengine/hardened_limits.h"
#include "pdfengine/pdf_document.hpp"
#include <cstddef>
#include <cstdint>
#include <vector>
extern "C" int LLVMFuzzerTestOneInput(const uint8_t* data, size_t size) {
using namespace pdfengine;
if (!limits::documentSizeOk(size)) return 0;
std::vector<uint8_t> bytes(data, data + size);
auto doc = PdfDocument::loadFromMemory(bytes, "");
if (!doc) return 0;
PdfDocument& d = **doc;
const int pages = d.pageCount();
if (!limits::pageCountOk(pages)) return 0;
(void)d.metadata();
(void)d.extractOutline();
const int limit = pages < 3 ? pages : 3; // bound work per input
for (int i = 0; i < limit; ++i) {
auto page = d.getPage(i);
if (!page) continue;
PdfPage& p = **page;
(void)p.render(72);
(void)p.extractText();
(void)p.extractAnnotations();
auto glyphs = p.orderedGlyphs();
if (glyphs && !glyphs->empty()) {
const auto& g = glyphs->front();
(void)p.hitGlyph(g.x, g.y);
(void)p.selectRange(g.x, g.y, g.x + 50.0, g.y + 20.0);
}
}
return 0;
}
@@ -0,0 +1,67 @@
// Resource limits for hardening against malicious / malformed PDFs.
//
// These guard the allocation-sizing arithmetic in the load and render paths
// against integer overflow and pathological out-of-memory inputs (a 2-billion-pt
// page, a million-page document, a multi-gigabyte raster). The ceilings are set
// far above anything a legitimate document needs, so enforcing them never
// rejects real files — they exist purely to turn "crash / OOM" into a clean,
// recoverable error, which is exactly what a fuzzer needs to make progress.
//
// Header-only and dependency-free so the fuzz harness and the engine share one
// source of truth.
#ifndef PDFENGINE_HARDENED_LIMITS_H
#define PDFENGINE_HARDENED_LIMITS_H
#include <cstdint>
namespace pdfengine::limits {
// Largest input document we will even attempt to parse (1 GiB).
inline constexpr std::uint64_t kMaxDocumentBytes = 1ull << 30;
// PDF hard-caps a page at 14,400 user units (200 in) per side; allow a very
// generous multiple of that to tolerate odd-but-real documents.
inline constexpr double kMaxPageDimensionPt = 200'000.0; // ~2,777 inches
// No legitimate document has this many pages; stops runaway iteration.
inline constexpr int kMaxPageCount = 100'000;
// MAX_OBJECTS: ceiling on the number of content objects on a single page. Guards
// against content-stream "object bombs" that would explode parsing/rendering
// time and memory. No real page comes near this.
inline constexpr int kMaxObjects = 5'000'000;
// Cap a single rasterised page at ~256 megapixels (≈1 GiB at 4 bytes/px). At
// 96 dpi that is roughly a 16k × 16k page — well beyond any real render.
inline constexpr std::int64_t kMaxRasterPixels = 256ll * 1024 * 1024;
// True if a raw page size (in points) is sane to render.
inline constexpr bool pageDimensionsOk(double widthPt, double heightPt) noexcept {
return widthPt > 0.0 && heightPt > 0.0 && widthPt <= kMaxPageDimensionPt &&
heightPt <= kMaxPageDimensionPt;
}
// True if a target raster (in pixels) fits the pixel budget without overflowing
// the width*height*4 byte computation.
inline constexpr bool rasterSizeOk(std::int64_t widthPx, std::int64_t heightPx) noexcept {
if (widthPx <= 0 || heightPx <= 0) return false;
if (widthPx > kMaxRasterPixels || heightPx > kMaxRasterPixels) return false;
return widthPx * heightPx <= kMaxRasterPixels;
}
inline constexpr bool documentSizeOk(std::uint64_t bytes) noexcept {
return bytes > 0 && bytes <= kMaxDocumentBytes;
}
inline constexpr bool pageCountOk(int pages) noexcept {
return pages >= 0 && pages <= kMaxPageCount;
}
inline constexpr bool objectCountOk(int objects) noexcept {
return objects >= 0 && objects <= kMaxObjects;
}
} // namespace pdfengine::limits
#endif // PDFENGINE_HARDENED_LIMITS_H
+31
View File
@@ -53,6 +53,23 @@ struct GlyphBounds {
double fontSize;
};
// Result of a point hit-test against a page's glyphs (page-point space, top-left
// origin — the same space GlyphBounds use).
struct HitResult {
int glyphIndex = -1; // glyph directly under the point in reading order, -1 if none
int caret = 0; // nearest caret position (0..N) for selection anchoring
int line = -1; // line band the point resolved to, -1 if the page has no text
};
// A resolved text selection: a half-open glyph range plus its reconstructed text
// and per-line union rectangles (for drawing the selection).
struct TextSelection {
int startGlyph = 0; // inclusive, reading order
int endGlyph = 0; // exclusive
std::string text;
std::vector<GlyphBounds> rects; // per-line union rects (text field left empty)
};
struct FontInfo {
std::string fontName;
std::string type; // "TrueType", "Type1", "CIDFontType0", "CIDFontType2"
@@ -136,6 +153,20 @@ public:
[[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;
+309 -28
View File
@@ -6,12 +6,14 @@
#include <fpdf_doc.h>
#include <fpdf_edit.h>
#include <fpdf_annot.h>
#include <fpdf_formfill.h>
#include <png.h>
#include "parser/pdfium_loader.hpp"
#endif
#include "fonts/loader/font_resolver.hpp"
#include "fonts/pdf_fonts/font.hpp"
#include "pdfengine/hardened_limits.h"
#include <nlohmann/json.hpp>
#include <spdlog/spdlog.h>
@@ -622,6 +624,13 @@ 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()),
password.empty() ? nullptr : password.c_str());
@@ -630,6 +639,13 @@ PdfDocument::loadFromMemory(const std::vector<uint8_t>& data, const std::string&
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 +659,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 +687,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 +723,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 +753,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);
@@ -1589,7 +1631,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 +1642,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;
@@ -1805,6 +1853,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 +2036,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", ""));
if (annotIndex >= 0) {
FPDF_PAGE page = FPDF_LoadPage(doc_, pageIndex);
if (!page) {
spdlog::error("Failed to load page index {} for field update", pageIndex);
spdlog::error("Failed to load page index {} for update_field", pageIndex);
return std::unexpected(EngineError::Unknown);
}
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);
FPDF_FORMFILLINFO formInfo{};
formInfo.version = 2;
FPDF_FORMHANDLE form = FPDFDOC_InitFormFillEnvironment(doc_, &formInfo);
int count = FPDFPage_GetAnnotCount(page);
FPDF_ANNOTATION target = nullptr;
for (int i = 0; i < count; ++i) {
FPDF_ANNOTATION annot = FPDFPage_GetAnnot(page, i);
if (!annot) continue;
std::string id;
unsigned long len = FPDFAnnot_GetStringValue(annot, "NM", nullptr, 0);
if (len > 2) {
std::vector<uint8_t> buf(len);
FPDFAnnot_GetStringValue(annot, "NM", reinterpret_cast<FPDF_WCHAR*>(buf.data()), len);
id = utf16le_to_utf8(reinterpret_cast<const char16_t*>(buf.data()), len / sizeof(char16_t));
while (!id.empty() && id.back() == '\0') id.pop_back();
}
if (id.empty()) id = "anno_" + std::to_string(pageIndex) + "_" + std::to_string(i);
if (id == targetId) { target = annot; break; }
FPDFPage_CloseAnnot(annot);
}
FPDF_ClosePage(page);
if (target) {
int fieldType = form ? FPDFAnnot_GetFormFieldType(form, target) : -1;
if (form) FORM_OnAfterLoadPage(page, form);
if (fieldType == 2 || fieldType == 3) {
// Checkbox / radio: /V + /AS (predefined on/off appearances render directly).
// NB: "Yes" is the common on-state; custom export values are a known v1 limitation.
std::string state = boolVal ? "Yes" : "Off";
auto u = utf8_to_utf16le(state);
FPDFAnnot_SetStringValue(target, "V", reinterpret_cast<FPDF_WIDESTRING>(u.data()));
FPDFAnnot_SetStringValue(target, "AS", reinterpret_cast<FPDF_WIDESTRING>(u.data()));
} else if (form && FORM_SetFocusedAnnot(form, target)) {
if (fieldType == 4 || fieldType == 5) {
// Choice (combo/listbox): select the option whose label matches the value.
int optCount = FPDFAnnot_GetOptionCount(form, target);
int sel = -1;
for (int o = 0; o < optCount; ++o) {
unsigned long ol = FPDFAnnot_GetOptionLabel(form, target, o, nullptr, 0);
if (ol <= 2) continue;
std::vector<FPDF_WCHAR> ob(ol / 2);
FPDFAnnot_GetOptionLabel(form, target, o, ob.data(), ol);
std::string label = utf16le_to_utf8(reinterpret_cast<const char16_t*>(ob.data()), ob.size());
while (!label.empty() && label.back() == '\0') label.pop_back();
if (label == strVal) { sel = o; break; }
}
if (sel >= 0) {
FORM_SetIndexSelected(form, page, sel, 1);
} else {
auto u = utf8_to_utf16le(strVal);
FPDFAnnot_SetStringValue(target, "V", reinterpret_cast<FPDF_WIDESTRING>(u.data()));
}
} else {
// Text field: select-all + replace → form module regenerates the appearance.
FORM_SelectAllText(form, page);
auto u = utf8_to_utf16le(strVal);
FORM_ReplaceSelection(form, page, reinterpret_cast<FPDF_WIDESTRING>(u.data()));
}
FORM_ForceToKillFocus(form);
} else {
// Fallback (no form env): best-effort value set.
std::string v = isBool ? (boolVal ? "Yes" : "Off") : strVal;
auto u = utf8_to_utf16le(v);
FPDFAnnot_SetStringValue(target, "V", reinterpret_cast<FPDF_WIDESTRING>(u.data()));
}
if (form) FORM_OnBeforeClosePage(page, form);
FPDFPage_CloseAnnot(target);
} else {
spdlog::warn("update_field: field '{}' not found on page {}", targetId, pageIndex);
}
if (form) FPDFDOC_ExitFormFillEnvironment(form);
FPDF_ClosePage(page);
} else if (type == "image_overlay") {
if (!op.contains("data") || !op["data"].is_object()) {
spdlog::error("image_overlay operation missing 'data' object");
+12 -2
View File
@@ -16,6 +16,8 @@ namespace pdfengine::fonts::loader { class FontResolver; }
namespace pdfengine::parser {
class PdfiumDocument; // a page keeps its owning document alive (see ownerDoc_)
#ifdef PDFENGINE_WITH_PDFIUM
using NativeDocHandle = FPDF_DOCUMENT;
using NativePageHandle = FPDF_PAGE;
@@ -28,7 +30,8 @@ using NativeTextHandle = void*;
class PdfiumPage : public PdfPage {
public:
PdfiumPage(NativeDocHandle docHandle, NativePageHandle pageHandle, int pageIndex);
PdfiumPage(NativeDocHandle docHandle, NativePageHandle pageHandle, int pageIndex,
std::shared_ptr<PdfiumDocument> owner = nullptr);
~PdfiumPage() override;
PdfiumPage(const PdfiumPage&) = delete;
@@ -58,6 +61,10 @@ private:
mutable NativeTextHandle textPage_ = nullptr;
int pageIndex_ = 0;
mutable std::mutex textMutex_;
// Keeps the owning document (and thus the native FPDF_DOCUMENT) alive for as
// long as this page exists, so page_/textPage_ can never dangle. Declared
// here (destroyed last) so it outlives the handle teardown in the destructor.
std::shared_ptr<PdfiumDocument> ownerDoc_;
#ifdef PDFENGINE_WITH_PDFIUM
mutable std::unordered_map<std::string, FPDF_FONT> fontHandleCache_;
#endif
@@ -103,7 +110,10 @@ private:
mutable std::unordered_map<std::string, std::vector<uint8_t>> fontDataCache_;
mutable int fontDataScannedPages_ = 0;
mutable std::unordered_map<int, std::shared_ptr<PdfPage>> pageCache_;
// Weak so the document never co-owns its pages: ownership runs page → document
// only. A cached entry is reused while a page is still referenced elsewhere,
// and lazily rebuilt once it expires.
mutable std::unordered_map<int, std::weak_ptr<PdfPage>> pageCache_;
mutable std::mutex pageCacheMutex_;
// Font Engine Bridge
+349
View File
@@ -0,0 +1,349 @@
// Adobe-grade hit-testing & text selection for PdfPage.
//
// Concrete implementations of PdfPage::orderedGlyphs / hitGlyph / selectRange,
// built on top of the (virtual) extractTextWithBounds(). All maths is in
// page-point space with a top-left origin — identical to the frontend
// TextSelectionModel, so engine and browser selections agree.
#include "pdfengine/pdf_document.hpp"
#include <algorithm>
#include <cctype>
#include <cmath>
#include <limits>
#include <map>
namespace pdfengine {
namespace {
struct OGlyph {
GlyphBounds g;
int line = 0;
double right = 0.0;
double bottom = 0.0;
double mid = 0.0; // horizontal centre
};
struct LineBand {
double top = 0.0;
double bottom = 0.0;
double mid = 0.0; // vertical centre of the first glyph
int start = 0; // inclusive glyph index
int end = 0; // exclusive glyph index
};
// Uniform 2D grid over glyph bounding boxes — a true spatial index for the
// point→glyph query, independent of line assignment. This is what makes
// hit-testing exact on overlapping/dense content (diacritics, multi-column,
// rotated runs) where a line-bucketed search would miss.
struct Grid {
double minX = 0.0, minY = 0.0, cell = 1.0;
int cols = 0, rows = 0;
std::vector<std::vector<int>> cells; // size cols*rows; glyph indices per cell
[[nodiscard]] bool empty() const { return cols == 0 || rows == 0; }
[[nodiscard]] int at(int c, int r) const { return r * cols + c; }
};
struct SelIndex {
std::vector<OGlyph> glyphs; // reading order
std::vector<LineBand> lines;
Grid grid;
};
bool isSpace(const std::string& t) {
if (t.empty()) return true;
for (unsigned char c : t)
if (!std::isspace(c)) return false;
return true;
}
// Cluster raw glyphs into lines (by vertical overlap) then sort each line L→R,
// flattening into a single reading-order array with cached line bands.
SelIndex buildIndex(const std::vector<GlyphBounds>& raw) {
SelIndex idx;
std::vector<GlyphBounds> clean;
clean.reserve(raw.size());
for (const auto& g : raw)
if (g.w >= 0 && g.h > 0) clean.push_back(g);
if (clean.empty()) return idx;
std::sort(clean.begin(), clean.end(), [](const GlyphBounds& a, const GlyphBounds& b) {
return (a.y + a.h / 2) < (b.y + b.h / 2);
});
std::vector<std::vector<GlyphBounds>> rows;
for (const auto& g : clean) {
const double gMid = g.y + g.h / 2;
bool placed = false;
if (!rows.empty()) {
auto& row = rows.back();
double top = std::numeric_limits<double>::max();
double bottom = std::numeric_limits<double>::lowest();
for (const auto& r : row) {
top = std::min(top, r.y);
bottom = std::max(bottom, r.y + r.h);
}
const double tol = g.h * 0.25;
if (gMid >= top - tol && gMid <= bottom + tol) {
row.push_back(g);
placed = true;
}
}
if (!placed) rows.push_back({g});
}
int running = 0;
for (auto& row : rows) {
std::sort(row.begin(), row.end(),
[](const GlyphBounds& a, const GlyphBounds& b) { return a.x < b.x; });
LineBand band;
band.start = running;
const int lineNo = static_cast<int>(idx.lines.size());
double top = std::numeric_limits<double>::max();
double bottom = std::numeric_limits<double>::lowest();
for (const auto& g : row) {
OGlyph og;
og.g = g;
og.line = lineNo;
og.right = g.x + g.w;
og.bottom = g.y + g.h;
og.mid = g.x + g.w / 2;
idx.glyphs.push_back(og);
top = std::min(top, g.y);
bottom = std::max(bottom, g.y + g.h);
running++;
}
band.end = running;
band.top = top;
band.bottom = bottom;
band.mid = row.front().y + row.front().h / 2;
idx.lines.push_back(band);
}
// Build the spatial grid over the final glyph set.
Grid& grid = idx.grid;
double minX = std::numeric_limits<double>::max();
double minY = std::numeric_limits<double>::max();
double maxX = std::numeric_limits<double>::lowest();
double maxY = std::numeric_limits<double>::lowest();
double sumH = 0.0;
for (const auto& og : idx.glyphs) {
minX = std::min(minX, og.g.x);
minY = std::min(minY, og.g.y);
maxX = std::max(maxX, og.right);
maxY = std::max(maxY, og.bottom);
sumH += og.g.h;
}
// Cell ≈ average glyph height (~one text line), so most cells hold a handful
// of glyphs. Grow the cell if the grid would otherwise be unreasonably large.
double cell = std::max(1.0, sumH / static_cast<double>(idx.glyphs.size()));
auto dim = [&](double lo, double hi) {
return std::max(1, static_cast<int>((hi - lo) / cell) + 1);
};
int gCols = dim(minX, maxX);
int gRows = dim(minY, maxY);
while (static_cast<long long>(gCols) * gRows > 2'000'000 && cell < 1e9) {
cell *= 2.0;
gCols = dim(minX, maxX);
gRows = dim(minY, maxY);
}
grid.minX = minX;
grid.minY = minY;
grid.cell = cell;
grid.cols = gCols;
grid.rows = gRows;
grid.cells.assign(static_cast<size_t>(gCols) * gRows, {});
auto col = [&](double x) { return std::clamp(static_cast<int>((x - minX) / cell), 0, gCols - 1); };
auto rowOf = [&](double y) { return std::clamp(static_cast<int>((y - minY) / cell), 0, gRows - 1); };
for (int i = 0; i < static_cast<int>(idx.glyphs.size()); ++i) {
const OGlyph& og = idx.glyphs[i];
const int c0 = col(og.g.x), c1 = col(og.right);
const int r0 = rowOf(og.g.y), r1 = rowOf(og.bottom);
for (int r = r0; r <= r1; ++r)
for (int c = c0; c <= c1; ++c)
grid.cells[grid.at(c, r)].push_back(i);
}
return idx;
}
// --- spatial index queries (Adobe-grade, fast on dense/overlapping content) --
//
// Point→glyph uses the 2D grid (exact regardless of line layout). Caret/line
// positioning uses the line bands via binary search (lines are in ascending
// vertical order; each line's glyphs are contiguous and x-sorted). Both stay
// cheap on pages with tens of thousands of glyphs.
// Exact glyph under a point via the spatial grid — never misses a containing
// glyph, even where text lines overlap.
int glyphAt(const SelIndex& idx, double x, double y) {
const Grid& grid = idx.grid;
if (grid.empty() || x < grid.minX || y < grid.minY) return -1;
const int c = static_cast<int>((x - grid.minX) / grid.cell);
const int r = static_cast<int>((y - grid.minY) / grid.cell);
if (c < 0 || c >= grid.cols || r < 0 || r >= grid.rows) return -1;
for (const int i : grid.cells[grid.at(c, r)]) {
const OGlyph& g = idx.glyphs[i];
if (x >= g.g.x && x <= g.right && y >= g.g.y && y <= g.bottom) return i;
}
return -1;
}
int lineAt(const SelIndex& idx, double y) {
const auto& lines = idx.lines;
if (lines.empty()) return -1;
// Binary search for the last line whose top <= y.
int lo = 0;
int hi = static_cast<int>(lines.size());
while (lo < hi) {
const int m = (lo + hi) / 2;
if (lines[m].top <= y) lo = m + 1;
else hi = m;
}
const int cand = lo - 1; // -1 when y is above every line
// Check the immediate neighbourhood for true containment, else take the
// nearest band by centre. Bands can overlap slightly (sub/superscript
// tolerance), so a ±1 window around the search boundary is enough.
int best = -1;
double bestDist = std::numeric_limits<double>::max();
for (int i = cand - 1; i <= cand + 1; ++i) {
if (i < 0 || i >= static_cast<int>(lines.size())) continue;
if (y >= lines[i].top && y <= lines[i].bottom) return i;
const double d = std::abs(y - lines[i].mid);
if (d < bestDist) {
bestDist = d;
best = i;
}
}
if (best >= 0) return best;
return cand < 0 ? 0 : static_cast<int>(lines.size()) - 1;
}
// Index of the last glyph in [start,end) whose left edge x <= queryX, or
// start-1 if queryX is left of the whole line. Glyphs are sorted by x.
static int lastGlyphLeftOf(const SelIndex& idx, const LineBand& line, double x) {
int lo = line.start;
int hi = line.end;
while (lo < hi) {
const int m = (lo + hi) / 2;
if (idx.glyphs[m].g.x <= x) lo = m + 1;
else hi = m;
}
return lo - 1;
}
int caretAt(const SelIndex& idx, double x, double y) {
// If the point lands on a glyph, the caret sits on its near or far side.
const int hit = glyphAt(idx, x, y);
if (hit >= 0) {
const OGlyph& g = idx.glyphs[hit];
return x < g.mid ? hit : hit + 1;
}
// Otherwise position within the nearest line (gaps / between lines / margins).
const int li = lineAt(idx, y);
if (li < 0) return 0;
const LineBand& line = idx.lines[li];
if (x <= idx.glyphs[line.start].g.x) return line.start;
if (x >= idx.glyphs[line.end - 1].right) return line.end;
const int cand = lastGlyphLeftOf(idx, line, x);
if (cand < line.start) return line.start;
const OGlyph& g = idx.glyphs[cand];
if (x <= g.right) return x < g.mid ? cand : cand + 1; // inside the glyph
return cand + 1; // in the gap after it
}
std::string textOfRange(const SelIndex& idx, int start, int end) {
std::string out;
const OGlyph* prev = nullptr;
for (int i = start; i < end; ++i) {
const OGlyph& g = idx.glyphs[i];
if (prev) {
if (g.line != prev->line) {
out += '\n';
} else {
const double gap = g.g.x - prev->right;
if (!isSpace(g.g.text) && !isSpace(prev->g.text) && gap > g.g.h * 0.25)
out += ' ';
}
}
out += g.g.text;
prev = &g;
}
return out;
}
std::vector<GlyphBounds> rectsOfRange(const SelIndex& idx, int start, int end) {
std::vector<GlyphBounds> rects;
if (start >= end) return rects;
// Group selected glyphs by line, preserving first-seen order.
std::map<int, std::pair<double, double>> spanByLine; // line -> {minX, maxRight}
std::vector<int> order;
for (int i = start; i < end; ++i) {
const OGlyph& g = idx.glyphs[i];
auto it = spanByLine.find(g.line);
if (it == spanByLine.end()) {
spanByLine[g.line] = {g.g.x, g.right};
order.push_back(g.line);
} else {
it->second.first = std::min(it->second.first, g.g.x);
it->second.second = std::max(it->second.second, g.right);
}
}
for (int line : order) {
const auto& span = spanByLine[line];
const LineBand& band = idx.lines[line];
GlyphBounds r;
r.text = "";
r.x = span.first;
r.y = band.top;
r.w = span.second - span.first;
r.h = band.bottom - band.top;
r.fontSize = 0.0;
rects.push_back(r);
}
return rects;
}
} // namespace
std::expected<std::vector<GlyphBounds>, EngineError> PdfPage::orderedGlyphs() const {
auto raw = extractTextWithBounds();
if (!raw) return std::unexpected(raw.error());
const SelIndex idx = buildIndex(*raw);
std::vector<GlyphBounds> out;
out.reserve(idx.glyphs.size());
for (const auto& og : idx.glyphs) out.push_back(og.g);
return out;
}
std::expected<HitResult, EngineError> PdfPage::hitGlyph(double x, double y) const {
auto raw = extractTextWithBounds();
if (!raw) return std::unexpected(raw.error());
const SelIndex idx = buildIndex(*raw);
HitResult hit;
hit.line = lineAt(idx, y);
hit.caret = caretAt(idx, x, y);
hit.glyphIndex = glyphAt(idx, x, y);
return hit;
}
std::expected<TextSelection, EngineError>
PdfPage::selectRange(double ax, double ay, double bx, double by) const {
auto raw = extractTextWithBounds();
if (!raw) return std::unexpected(raw.error());
const SelIndex idx = buildIndex(*raw);
int a = caretAt(idx, ax, ay);
int b = caretAt(idx, bx, by);
if (a > b) std::swap(a, b);
TextSelection sel;
sel.startGlyph = a;
sel.endGlyph = b;
sel.text = textOfRange(idx, a, b);
sel.rects = rectsOfRange(idx, a, b);
return sel;
}
} // namespace pdfengine
+60 -14
View File
@@ -12,6 +12,8 @@
#include "fonts/pdf_fonts/font_subset.hpp"
#include <gtest/gtest.h>
#include <algorithm>
#include <cctype>
#include <filesystem>
#include <fstream>
#include <iostream>
@@ -20,6 +22,19 @@
namespace {
// Case-insensitive substring check. System font filenames differ in case
// across platforms (e.g. macOS ships "Times.ttc", Windows "times.ttf"), so the
// font-fallback assertions match without regard to case.
bool containsCI(const std::string& haystack, const std::string& needle) {
auto it = std::search(
haystack.begin(), haystack.end(), needle.begin(), needle.end(),
[](char a, char b) {
return std::tolower(static_cast<unsigned char>(a)) ==
std::tolower(static_cast<unsigned char>(b));
});
return it != haystack.end();
}
bool saveGlyphAsPGM(const pdfengine::fonts::GlyphBitmap& bitmap, const std::string& filename) {
if (bitmap.width == 0 || bitmap.height == 0 || bitmap.pixels.empty()) {
return false;
@@ -1020,22 +1035,36 @@ 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) {
@@ -1043,13 +1072,23 @@ TEST(FontFallbackTest, StyleModifierResolutions) {
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);
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 Times New Roman Bold Italic
// 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) {
@@ -1058,19 +1097,26 @@ TEST(FontFallbackTest, CustomFallbackRegistration) {
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");
EXPECT_EQ(overridenPath, overrideFont.string());
// Reset back to defaults
// 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) {
+16 -1
View File
@@ -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}
/>
+11
View File
@@ -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',
+1
View File
@@ -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} /> },
+14
View File
@@ -185,8 +185,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;
}
+244
View File
@@ -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 };
}
}
+2
View File
@@ -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',
+17
View File
@@ -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,
@@ -403,6 +407,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 +441,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}
+167 -81
View File
@@ -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),
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),
});
};
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]);
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;
}
drag.current = null;
const cur = selRef.current;
if (!cur || cur.start === cur.end) {
setSel(null);
return;
}
setDragStart(null);
setSelectionBox(null);
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>
);
+152
View File
@@ -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>
);
};
+28 -3
View File
@@ -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):
@@ -221,7 +242,8 @@ EditOperation = Annotated[
| PageReorderOperation
| UpdateFieldOperation
| DeleteAnnotationOperation
| UpdateAnnotationOperation,
| UpdateAnnotationOperation
| EditTextOperation,
Field(discriminator="type"),
]
@@ -295,8 +317,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)
+7
View File
@@ -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(
+130
View File
@@ -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())
+130
View File
@@ -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())
+72
View File
@@ -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.
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 550 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 218 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 237 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 675 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 464 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 426 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 300 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 866 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 866 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 697 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

File diff suppressed because it is too large Load Diff
+139
View File
@@ -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())
+57
View File
@@ -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())