feat: implemented hit testing, form field, content stream and base security hardening
@@ -0,0 +1,104 @@
|
|||||||
|
# Extended robustness: corpus render-stability sweep + libFuzzer run.
|
||||||
|
#
|
||||||
|
# DELIBERATELY ISOLATED from the main CI gate:
|
||||||
|
# - never triggers on push or pull_request, so it can NEVER block a merge,
|
||||||
|
# push, or pull;
|
||||||
|
# - the whole job is continue-on-error, so a crash finding or a build/runner
|
||||||
|
# problem reports red here but does not fail any required check;
|
||||||
|
# - runs on a weekly schedule and on manual dispatch only.
|
||||||
|
#
|
||||||
|
# A full 24h fuzz run needs a self-hosted runner (GitHub-hosted runners cap a job
|
||||||
|
# at 6h). Use the workflow_dispatch `duration_seconds` input for that; the weekly
|
||||||
|
# schedule does a short smoke instead.
|
||||||
|
|
||||||
|
name: Fuzz & corpus sweep
|
||||||
|
|
||||||
|
on:
|
||||||
|
schedule:
|
||||||
|
- cron: "0 3 * * 0" # Sundays 03:00 UTC — short smoke
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
duration_seconds:
|
||||||
|
description: "libFuzzer -max_total_time (e.g. 1800 smoke, 86400 for 24h on a self-hosted runner)"
|
||||||
|
default: "1800"
|
||||||
|
sanitizers:
|
||||||
|
description: "Sanitizer set (fuzzer,address,undefined needs an ASan/UBSan-built PDFium; fuzzer = coverage-only)"
|
||||||
|
default: "fuzzer,address,undefined"
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: fuzz-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
fuzz:
|
||||||
|
# Non-blocking by construction: nothing depends on this job and it is allowed to fail.
|
||||||
|
continue-on-error: true
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 1500 # permits a 24h dispatch on a self-hosted runner; hosted runners stop at 6h
|
||||||
|
env:
|
||||||
|
VCPKG_DEFAULT_BINARY_CACHE: ${{ github.workspace }}/.vcpkg-cache
|
||||||
|
FUZZ_SANITIZERS: ${{ github.event.inputs.sanitizers || 'fuzzer' }}
|
||||||
|
FUZZ_DURATION: ${{ github.event.inputs.duration_seconds || '600' }}
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Install Ninja + Clang
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install -y ninja-build clang
|
||||||
|
|
||||||
|
- name: Locate vcpkg
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
echo "VCPKG_ROOT=$VCPKG_INSTALLATION_ROOT" >> "$GITHUB_ENV"
|
||||||
|
mkdir -p "$VCPKG_DEFAULT_BINARY_CACHE"
|
||||||
|
|
||||||
|
- name: Cache vcpkg artifacts
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: ${{ env.VCPKG_DEFAULT_BINARY_CACHE }}
|
||||||
|
key: vcpkg-fuzz-${{ hashFiles('vcpkg.json') }}
|
||||||
|
restore-keys: vcpkg-fuzz-
|
||||||
|
|
||||||
|
- name: Pin vcpkg dependency baseline
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
if ! grep -q '"builtin-baseline"' vcpkg.json; then
|
||||||
|
"$VCPKG_ROOT/vcpkg" x-update-baseline --add-initial-baseline
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Configure (fuzz-linux)
|
||||||
|
run: cmake --preset fuzz-linux -DPDFENGINE_FUZZ_SANITIZERS="$FUZZ_SANITIZERS"
|
||||||
|
|
||||||
|
- name: Build fuzzer
|
||||||
|
run: cmake --build --preset fuzz-linux --target pdfengine_fuzz
|
||||||
|
|
||||||
|
- name: Fetch corpus (pinned + hash-verified)
|
||||||
|
shell: bash
|
||||||
|
run: python3 scripts/fetch_corpus.py --manifest tests/regression/corpus-manifest.json || echo "corpus fetch failed (network) — continuing with committed corpus"
|
||||||
|
|
||||||
|
- name: Render-stability sweep (replay every corpus PDF once)
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
BIN=out/build/fuzz-linux/bin/pdfengine_fuzz
|
||||||
|
mkdir -p engine/fuzz/artifacts
|
||||||
|
"$BIN" -runs=0 -artifact_prefix=engine/fuzz/artifacts/ corpus/ corpus/fuzz/ || true
|
||||||
|
|
||||||
|
- name: Fuzz run
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
BIN=out/build/fuzz-linux/bin/pdfengine_fuzz
|
||||||
|
"$BIN" -max_total_time="$FUZZ_DURATION" -print_final_stats=1 \
|
||||||
|
-artifact_prefix=engine/fuzz/artifacts/ corpus/fuzz/ corpus/ || true
|
||||||
|
|
||||||
|
- name: Upload crash artifacts
|
||||||
|
if: always()
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: fuzz-artifacts
|
||||||
|
path: engine/fuzz/artifacts/
|
||||||
|
if-no-files-found: ignore
|
||||||
@@ -67,5 +67,15 @@ gateway/*.pyd
|
|||||||
gateway/*.so
|
gateway/*.so
|
||||||
gateway/*.dylib
|
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
|
PDF Editor Timeline.xlsx
|
||||||
# Local environment config / secrets
|
# Local environment config / secrets
|
||||||
|
|||||||
@@ -40,6 +40,16 @@ option(PDFENGINE_ENABLE_SANITIZERS "Build with AddressSanitizer/UBSan"
|
|||||||
option(PDFENGINE_WARNINGS_AS_ERRORS "Treat compiler warnings as errors" OFF)
|
option(PDFENGINE_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_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_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)
|
if(PDFENGINE_WASM)
|
||||||
set(PDFENGINE_BUILD_TESTS OFF CACHE BOOL "Build engine unit/smoke tests" FORCE)
|
set(PDFENGINE_BUILD_TESTS OFF CACHE BOOL "Build engine unit/smoke tests" FORCE)
|
||||||
@@ -80,7 +90,9 @@ else()
|
|||||||
endif()
|
endif()
|
||||||
|
|
||||||
add_subdirectory(engine)
|
add_subdirectory(engine)
|
||||||
|
if(NOT PDFENGINE_FUZZING)
|
||||||
add_subdirectory(bindings)
|
add_subdirectory(bindings)
|
||||||
|
endif()
|
||||||
endif()
|
endif()
|
||||||
|
|
||||||
message(STATUS "PdfEngine ${PROJECT_VERSION} configured")
|
message(STATUS "PdfEngine ${PROJECT_VERSION} configured")
|
||||||
|
|||||||
@@ -108,6 +108,27 @@
|
|||||||
"PDFENGINE_BUILD_TESTS": "OFF",
|
"PDFENGINE_BUILD_TESTS": "OFF",
|
||||||
"PDFENGINE_WITH_PDFIUM": "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-debug", "configurePreset": "macos-debug" },
|
||||||
{ "name": "macos-release", "configurePreset": "macos-release" },
|
{ "name": "macos-release", "configurePreset": "macos-release" },
|
||||||
{ "name": "macos-asan", "configurePreset": "macos-asan" },
|
{ "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": [
|
"testPresets": [
|
||||||
|
|||||||
@@ -212,6 +212,40 @@ PYBIND11_MODULE(pdfengine, m) {
|
|||||||
}
|
}
|
||||||
return py_list;
|
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) {
|
.def("get_fonts", [](const pdfengine::PdfPage& self) {
|
||||||
return get_or_throw(self.getFonts());
|
return get_or_throw(self.getFonts());
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,130 @@
|
|||||||
|
# Phase 3 — Dev-1 Plan (Form Filling · Hit-Testing · Test Harness) — v2
|
||||||
|
|
||||||
|
**Status:** Review before build (reworked with your decisions). No code changed. Grounded in the current codebase (verified).
|
||||||
|
**Scope:** the three Phase-3 items not blocked by the content-stream analyser. Build them in parallel with (separate) content-stream work.
|
||||||
|
|
||||||
|
## Locked decisions (from review)
|
||||||
|
1. **Form fill UI → Acrobat-style on-page overlay inputs** (edit fields directly on the page), built now — not the lighter in-panel version.
|
||||||
|
2. **Form rendering → production-grade** (`FPDF_FFLDraw` + proper appearance regeneration; robust per-type).
|
||||||
|
3. **Hit-testing → build BOTH** the engine `hitGlyph`/spatial index (the roadmap Dev-1 deliverable — *not* deferred, since it's not scheduled in any other phase) **and** the frontend real-time selection model. They share one algorithm; the engine is authoritative/tested/SDK-exposed, the frontend is the responsive web layer.
|
||||||
|
4. **Regression gate → freeze PDFium baseline now**, becomes meaningful when the custom renderer lands.
|
||||||
|
5. **Fuzzing → Windows AND Linux** (clang-cl on Windows + clang on Linux).
|
||||||
|
6. **Corpus → download script** into a gitignored dir (500+ public PDFs).
|
||||||
|
|
||||||
|
> None of these need the content-stream analyser. They build on Phase-1 glyphs, the form-field reader, and the existing gtest/ASan infra.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Workstream A — Production-Grade Form Filling + Acrobat-Style On-Page Editing
|
||||||
|
|
||||||
|
### Current state (verified)
|
||||||
|
- ✅ `update_field` handler exists ([pdfium_document.cpp:1809](engine/src/parser/pdfium_document.cpp#L1809)) — sets `/V`, bool→checkbox, saves. **~50%.**
|
||||||
|
- ✅ Form-fill env initialized; field reader returns name/value/type/flags/options; Forms tab lists them (read-only).
|
||||||
|
- ❌ `render()` uses `flags=0` ([line 717](engine/src/parser/pdfium_document.cpp#L717)) → form widgets not drawn (filled values invisible).
|
||||||
|
- ❌ No `/AS` for checkboxes, no `FORM_SetIndexSelected` for choices, no appearance regen, no on-page edit UI.
|
||||||
|
|
||||||
|
### Build — Engine (C++)
|
||||||
|
1. **Render forms (production):** retain the form-fill env on the document; in `render()` call **`FPDF_FFLDraw(formHandle, bitmap, page, x, y, w, h, rotate, FPDF_ANNOT)`** after `FPDF_RenderPageBitmap` so field appearances (incl. filled values) composite into the page image.
|
||||||
|
2. **Complete `update_field` per field type:**
|
||||||
|
- **Text (Tx):** set `/V`; regenerate appearance (via form env `FORM_ReplaceSelection`/`FORM_ForceToKillFocus`, or set AcroForm `/NeedAppearances`) so the value renders + exports.
|
||||||
|
- **Checkbox/Radio (Btn):** set `/V` **and** `/AS` to the field's on-state name (read the AP states); radios update the sibling group.
|
||||||
|
- **Choice (Ch):** combo → `/V`; listbox/multi → `FORM_SetIndexSelected(formHandle, page, index, selected)` (+ `/V`).
|
||||||
|
3. **Robust field identity:** match by the same id convention as `delete_annotation` (`NM` else `anno_<page>_<idx>`), not raw index parsing.
|
||||||
|
4. **Save:** incremental (form edits are dict changes); `save_full` on export.
|
||||||
|
|
||||||
|
### Build — Gateway
|
||||||
|
- Extend `UpdateFieldData` if needed: `value: str | bool`, optional `selectedIndices: int[]` (listbox multi-select).
|
||||||
|
- (Optional) a `GET /…/form-fields` convenience endpoint — or keep reading them from `/annotations` (already includes field props).
|
||||||
|
|
||||||
|
### Build — Frontend (Acrobat-style on-page editing)
|
||||||
|
1. **`FormFieldLayer`** (new viewer layer, sibling to AnnotationLayer): for each `widget` annotation on the page, render an **HTML control positioned over the field rect** (×zoom), type-mapped:
|
||||||
|
- Tx → `<input>` / `<textarea>` (multiline per `fieldFlags`)
|
||||||
|
- Btn checkbox → `<input type=checkbox>`; radio → grouped radios
|
||||||
|
- Ch combo → `<select>` (or input+datalist if editable); listbox → `<select multiple>`
|
||||||
|
- Sig → click-to-sign placeholder (reuses Signature flow)
|
||||||
|
2. **Positioning:** field rect comes from `extractAnnotations` (top-left device space) → place + scale with zoom, like the other layers; transparent styling so the PDF field box shows through.
|
||||||
|
3. **Interaction:** edit on the page → on blur/change emit `update_field` → optimistic value + save → page re-renders with baked appearance (so it's consistent on export).
|
||||||
|
4. **Modes:** a "Fill & Sign" affordance to toggle the form layer on (so inputs don't interfere with other tools).
|
||||||
|
|
||||||
|
### Acceptance
|
||||||
|
On a form PDF you can **type into text fields, tick checkboxes, pick dropdowns directly on the page**; values persist after save, **render in the page image**, and survive export. Verified on `text_form` / `combobox_form` / `listbox_form`.
|
||||||
|
|
||||||
|
### Risk / effort
|
||||||
|
PDFium form rendering + appearance regen is the classic gotcha (`/AS` state names vary; multiline/comb fields). On-page overlay positioning across zoom/rotation needs care. **~1.5–2 weeks.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Workstream B — Adobe-Level Hit-Testing & Text Selection (engine + frontend)
|
||||||
|
|
||||||
|
### Current state (verified)
|
||||||
|
- ✅ Glyph bounds (`extractTextWithBounds`).
|
||||||
|
- 🟡 `SelectionLayer` = rectangular marquee (`rectsIntersect`) — not reading-order selection.
|
||||||
|
|
||||||
|
### Build — Engine (the roadmap Dev-1 deliverable; authoritative + SDK)
|
||||||
|
1. **Reading-order model per page:** line detection (cluster by baseline/y), order glyphs L→R within line, top→bottom across lines; assign sequential index. (v1 = LTR single/simple multi-column; note RTL/complex as limitation.)
|
||||||
|
2. **Spatial index:** sorted-vector + binary search (or uniform grid) over glyph rects.
|
||||||
|
3. **APIs (bound to Python/SDK):**
|
||||||
|
- `hit_glyph(x, y) → {glyphIndex, caretSide}` (nearest glyph + before/after).
|
||||||
|
- `select_range(p1, p2) → {glyphIndices[], text}` (ordered range between two points).
|
||||||
|
- `word_at(x,y)`, `line_at(x,y)`.
|
||||||
|
4. **Tests:** corpus-based accuracy (hit/range correctness; reading order sanity).
|
||||||
|
|
||||||
|
### Build — Frontend (Adobe-grade interactive selection)
|
||||||
|
1. **`TextSelectionModel`** (client-side): mirrors the engine's reading-order + spatial index for **real-time** interaction (no HTTP per mouse-move). Shares the algorithm; an automated test compares frontend vs engine selection on the corpus to prevent drift.
|
||||||
|
2. **Interactions (Adobe parity):**
|
||||||
|
- Drag = **anchor→focus reading-order range** (flows across lines, partial first/last line).
|
||||||
|
- **Double-click = word**, **triple-click = line/paragraph**.
|
||||||
|
- **Shift-click / Shift-drag = extend**; caret position; **Ctrl/⌘+A = select page**.
|
||||||
|
- **Copy** = ordered text with correct spaces/newlines.
|
||||||
|
- *(Stretch)* cross-page selection (Adobe does this; v1 may be per-page — flagged).
|
||||||
|
3. **Visual:** selection rendered as highlighted glyph runs (smooth at any zoom).
|
||||||
|
|
||||||
|
### Acceptance
|
||||||
|
Selection behaves like Acrobat/a browser: drag selects a flowing range across lines, double/triple-click select word/line, shift extends, copy yields correctly-ordered text; smooth on a dense/100-page doc. Engine `hit_glyph`/`select_range` exposed + tested. **Also the foundation for "click a glyph to edit it" once content-stream editing lands.**
|
||||||
|
|
||||||
|
### Risk / effort
|
||||||
|
Reading order for columns/RTL is hard — v1 targets LTR; complex layouts flagged. Engine + frontend + parity test. **~1.5 weeks.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Workstream C — Fuzz + Regression Harness (Windows + Linux)
|
||||||
|
|
||||||
|
### Current state (verified)
|
||||||
|
- ✅ 41 corpus PDFs; gtest suite (`engine/tests/`); CTest; ASan/UBSan presets (`windows-asan`/`linux-asan`).
|
||||||
|
|
||||||
|
### Build — Regression suite
|
||||||
|
1. `tests/regression/` — Python harness: render each corpus PDF (engine bindings) at 144 DPI → PNG → **SSIM vs `baseline/`**; fail < 0.95; HTML/JSON report.
|
||||||
|
2. **Freeze the PDFium baseline now** (golden images). When the **custom renderer** lands, switch engine-render to the custom path → gate becomes the real safety net.
|
||||||
|
3. `scripts/fetch_corpus.ps1`/`.sh` — pull 500+ permissive public PDFs (pdf.js / pdfium test resources) into a **gitignored** corpus dir.
|
||||||
|
4. CI: regression job wired as a gate.
|
||||||
|
|
||||||
|
### Build — Fuzzing (Windows + Linux)
|
||||||
|
1. `tests/fuzz/fuzz_load_render.cpp` — `LLVMFuzzerTestOneInput(data,size)` → `loadFromMemory` → page/render/extractText/applyEdits; seed = the 41 PDFs.
|
||||||
|
2. **Toolchain:** **Linux** = clang `-fsanitize=fuzzer,address,undefined`; **Windows** = **clang-cl** `-fsanitize=fuzzer,address` (UBSan partial on Windows — run full UBSan on Linux). New CMake `fuzz` preset(s) (`fuzz-linux`, `fuzz-windows`).
|
||||||
|
3. `engine/include/pdfengine/hardened_limits.h` — `MAX_OBJECTS` / `MAX_STREAM_SIZE` / `MAX_PAGE_COUNT` checked in load/parse paths.
|
||||||
|
4. Runs: nightly fuzz (both OSes), ASan/UBSan over the existing gtest suite via the `*-asan` presets.
|
||||||
|
|
||||||
|
### Acceptance
|
||||||
|
CI runs SSIM regression (green baseline now); fuzz targets **build and run on Windows + Linux**, N hours zero-crash, ASan clean; UBSan clean on Linux. `hardened_limits` guards enforced.
|
||||||
|
|
||||||
|
### Risk / effort
|
||||||
|
clang-cl + libFuzzer on Windows needs LLVM installed (the MSVC `windows-asan` preset stays for unit-test ASan). Corpus licensing → permissive sets only. **~1–1.5 weeks, mostly background/parallel.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Build order & parallelization
|
||||||
|
1. **A — Form filling** first (real Adobe feature; one engine rebuild; production-grade on-page editing).
|
||||||
|
2. **B — Hit-testing/selection** next (engine spatial index + frontend Adobe selection; sets up future text editing).
|
||||||
|
3. **C — Harness** stood up from day 1 in the background (protects later Dev-2/Dev-3 content-stream work).
|
||||||
|
|
||||||
|
All independent of each other and of the content-stream analyser. Each ends with: engine rebuild (A, B-engine) + frontend typecheck/build + end-to-end verification, and the gateway test instance stopped afterward.
|
||||||
|
|
||||||
|
## Revised effort
|
||||||
|
| Workstream | Effort | Engine rebuild? |
|
||||||
|
|---|---|---|
|
||||||
|
| A — Form filling (on-page, production) | ~1.5–2 wk | yes |
|
||||||
|
| B — Hit-testing (engine + frontend Adobe selection) | ~1.5 wk | yes |
|
||||||
|
| C — Fuzz + regression (Win + Linux) | ~1–1.5 wk | new fuzz preset |
|
||||||
|
|
||||||
|
## What this delivers
|
||||||
|
Form **filling** (Acrobat-style, on-page), **Adobe-grade text selection/copy** (engine-authoritative + responsive UI), and a **production quality safety net** (regression + fuzz on Windows & Linux). Still **not** editing existing page text/images — that's the content-stream chain (separate critical path).
|
||||||
@@ -17,6 +17,7 @@ add_library(pdfengine STATIC
|
|||||||
src/parser/pdfium_loader.cpp
|
src/parser/pdfium_loader.cpp
|
||||||
src/parser/pdfium_document.cpp
|
src/parser/pdfium_document.cpp
|
||||||
src/parser/content_stream_parser.cpp
|
src/parser/content_stream_parser.cpp
|
||||||
|
src/text/selection.cpp
|
||||||
src/fonts/face/font_face.cpp
|
src/fonts/face/font_face.cpp
|
||||||
src/fonts/face/free_type_manager.cpp
|
src/fonts/face/free_type_manager.cpp
|
||||||
src/fonts/loader/font_resolver.cpp
|
src/fonts/loader/font_resolver.cpp
|
||||||
@@ -72,3 +73,39 @@ pdfengine_enable_sanitizers(pdfengine)
|
|||||||
if(PDFENGINE_BUILD_TESTS)
|
if(PDFENGINE_BUILD_TESTS)
|
||||||
add_subdirectory(tests)
|
add_subdirectory(tests)
|
||||||
endif()
|
endif()
|
||||||
|
|
||||||
|
# --- Fuzzing -----------------------------------------------------------------
|
||||||
|
# Coverage-instrument the engine and build the libFuzzer harness. Requires Clang
|
||||||
|
# (enforced at the top level). PDFENGINE_FUZZ_SANITIZERS lets callers drop ASan
|
||||||
|
# when the PDFium static lib isn't ASan-compatible (coverage-only fuzzing).
|
||||||
|
if(PDFENGINE_FUZZING)
|
||||||
|
set(PDFENGINE_FUZZ_SANITIZERS "fuzzer,address,undefined" CACHE STRING
|
||||||
|
"Sanitizer set for fuzzing (e.g. 'fuzzer,address,undefined' or just 'fuzzer')")
|
||||||
|
|
||||||
|
# Split the set into the libFuzzer driver ('fuzzer', linked only into the
|
||||||
|
# harness exe) and the runtime sanitizers (address/undefined/...), which must
|
||||||
|
# instrument the engine library itself to catch bugs in its code.
|
||||||
|
string(REPLACE "," ";" _fuzz_sans "${PDFENGINE_FUZZ_SANITIZERS}")
|
||||||
|
set(_fuzz_runtime_sans "")
|
||||||
|
foreach(_s IN LISTS _fuzz_sans)
|
||||||
|
if(NOT _s STREQUAL "fuzzer")
|
||||||
|
list(APPEND _fuzz_runtime_sans "${_s}")
|
||||||
|
endif()
|
||||||
|
endforeach()
|
||||||
|
list(JOIN _fuzz_runtime_sans "," _fuzz_runtime_str)
|
||||||
|
|
||||||
|
# Coverage-instrument the engine; apply ASan/UBSan to it too so its own code
|
||||||
|
# is checked, not just the harness.
|
||||||
|
target_compile_options(pdfengine PRIVATE -fsanitize=fuzzer-no-link -fno-omit-frame-pointer)
|
||||||
|
if(_fuzz_runtime_str)
|
||||||
|
target_compile_options(pdfengine PRIVATE -fsanitize=${_fuzz_runtime_str})
|
||||||
|
target_link_options(pdfengine PUBLIC -fsanitize=${_fuzz_runtime_str})
|
||||||
|
endif()
|
||||||
|
|
||||||
|
add_executable(pdfengine_fuzz fuzz/fuzz_load.cpp)
|
||||||
|
target_link_libraries(pdfengine_fuzz PRIVATE pdfengine)
|
||||||
|
target_compile_options(pdfengine_fuzz PRIVATE
|
||||||
|
-fsanitize=${PDFENGINE_FUZZ_SANITIZERS} -fno-omit-frame-pointer)
|
||||||
|
target_link_options(pdfengine_fuzz PRIVATE
|
||||||
|
-fsanitize=${PDFENGINE_FUZZ_SANITIZERS})
|
||||||
|
endif()
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
# Fuzzing the PDF engine
|
||||||
|
|
||||||
|
`fuzz_load.cpp` is a libFuzzer harness that drives the full
|
||||||
|
**load → metadata → outline → render → text → annotations → hit-test → select**
|
||||||
|
path with arbitrary bytes. Combined with AddressSanitizer it surfaces crashes,
|
||||||
|
OOMs, and undefined behaviour in the parsing and rendering code.
|
||||||
|
|
||||||
|
Resource ceilings from `pdfengine/hardened_limits.h` keep the fuzzer focused on
|
||||||
|
logic bugs instead of trivial out-of-memory inputs (and those same ceilings now
|
||||||
|
guard the production render path against integer-overflow / OOM).
|
||||||
|
|
||||||
|
## Linux (primary)
|
||||||
|
|
||||||
|
Clang + libFuzzer + ASan is best supported on Linux. PDFium must be built with
|
||||||
|
the same Clang toolchain (so ASan is consistent across the static lib).
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Full ASan + coverage fuzzer
|
||||||
|
cmake --preset fuzz-linux
|
||||||
|
cmake --build --preset fuzz-linux
|
||||||
|
|
||||||
|
# If your PDFium static lib is NOT ASan-instrumented, use coverage-only:
|
||||||
|
cmake --preset fuzz-linux-nosan
|
||||||
|
cmake --build --preset fuzz-linux-nosan
|
||||||
|
|
||||||
|
# Run it against the downloaded corpus as a seed set
|
||||||
|
python scripts/fetch_corpus.py # populates corpus/fuzz/ (gitignored)
|
||||||
|
mkdir -p engine/fuzz/artifacts
|
||||||
|
./out/build/fuzz-linux/bin/pdfengine_fuzz \
|
||||||
|
-artifact_prefix=engine/fuzz/artifacts/ \
|
||||||
|
corpus/fuzz/ corpus/
|
||||||
|
```
|
||||||
|
|
||||||
|
`corpus/fuzz/` and `corpus/` are passed as seed corpora; new coverage-expanding
|
||||||
|
inputs are written back into the first directory. Crashes land in
|
||||||
|
`engine/fuzz/artifacts/` (gitignored).
|
||||||
|
|
||||||
|
## Windows (clang-cl)
|
||||||
|
|
||||||
|
Native Windows fuzzing needs a Clang toolchain *and* a PDFium static lib built
|
||||||
|
with the matching runtime. Configure with clang-cl and the existing
|
||||||
|
`x64-windows-static` triplet, then enable fuzzing:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
cmake -S . -B C:/Users/<you>/pdfeng-build/fuzz-win -G Ninja `
|
||||||
|
-DCMAKE_C_COMPILER=clang-cl -DCMAKE_CXX_COMPILER=clang-cl `
|
||||||
|
-DVCPKG_TARGET_TRIPLET=x64-windows-static `
|
||||||
|
-DPDFENGINE_FUZZING=ON -DPDFENGINE_WITH_PDFIUM=ON `
|
||||||
|
-DPDFENGINE_FUZZ_SANITIZERS=fuzzer `
|
||||||
|
--toolchain "$env:VCPKG_ROOT/scripts/buildsystems/vcpkg.cmake"
|
||||||
|
cmake --build C:/Users/<you>/pdfeng-build/fuzz-win
|
||||||
|
```
|
||||||
|
|
||||||
|
Use `PDFENGINE_FUZZ_SANITIZERS=fuzzer` (coverage-only) on Windows unless the
|
||||||
|
whole dependency chain — including PDFium — is ASan-built, since mixing an
|
||||||
|
ASan binary with a non-ASan MSVC static lib does not link cleanly.
|
||||||
|
|
||||||
|
## Reproducing a crash
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./pdfengine_fuzz engine/fuzz/artifacts/crash-<hash>
|
||||||
|
```
|
||||||
|
|
||||||
|
The ASan report points at the offending allocation/access; the input file is the
|
||||||
|
minimal reproducer (run with `-minimize_crash=1` to shrink further).
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
// libFuzzer entry point: drive the full load → inspect → render → select path
|
||||||
|
// with arbitrary bytes, so the fuzzer can find crashes, OOMs, and UB in the
|
||||||
|
// PDF parsing and rendering code.
|
||||||
|
//
|
||||||
|
// Build with a Clang toolchain via the `fuzz-linux` preset (see engine/fuzz/
|
||||||
|
// README.md). Every operation is wrapped so a clean error never aborts the run —
|
||||||
|
// only a real crash (caught by the sanitizer) should stop the fuzzer.
|
||||||
|
|
||||||
|
#include "pdfengine/hardened_limits.h"
|
||||||
|
#include "pdfengine/pdf_document.hpp"
|
||||||
|
|
||||||
|
#include <cstddef>
|
||||||
|
#include <cstdint>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
extern "C" int LLVMFuzzerTestOneInput(const uint8_t* data, size_t size) {
|
||||||
|
using namespace pdfengine;
|
||||||
|
|
||||||
|
if (!limits::documentSizeOk(size)) return 0;
|
||||||
|
|
||||||
|
std::vector<uint8_t> bytes(data, data + size);
|
||||||
|
auto doc = PdfDocument::loadFromMemory(bytes, "");
|
||||||
|
if (!doc) return 0;
|
||||||
|
PdfDocument& d = **doc;
|
||||||
|
|
||||||
|
const int pages = d.pageCount();
|
||||||
|
if (!limits::pageCountOk(pages)) return 0;
|
||||||
|
|
||||||
|
(void)d.metadata();
|
||||||
|
(void)d.extractOutline();
|
||||||
|
|
||||||
|
const int limit = pages < 3 ? pages : 3; // bound work per input
|
||||||
|
for (int i = 0; i < limit; ++i) {
|
||||||
|
auto page = d.getPage(i);
|
||||||
|
if (!page) continue;
|
||||||
|
PdfPage& p = **page;
|
||||||
|
|
||||||
|
(void)p.render(72);
|
||||||
|
(void)p.extractText();
|
||||||
|
(void)p.extractAnnotations();
|
||||||
|
|
||||||
|
auto glyphs = p.orderedGlyphs();
|
||||||
|
if (glyphs && !glyphs->empty()) {
|
||||||
|
const auto& g = glyphs->front();
|
||||||
|
(void)p.hitGlyph(g.x, g.y);
|
||||||
|
(void)p.selectRange(g.x, g.y, g.x + 50.0, g.y + 20.0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
// Resource limits for hardening against malicious / malformed PDFs.
|
||||||
|
//
|
||||||
|
// These guard the allocation-sizing arithmetic in the load and render paths
|
||||||
|
// against integer overflow and pathological out-of-memory inputs (a 2-billion-pt
|
||||||
|
// page, a million-page document, a multi-gigabyte raster). The ceilings are set
|
||||||
|
// far above anything a legitimate document needs, so enforcing them never
|
||||||
|
// rejects real files — they exist purely to turn "crash / OOM" into a clean,
|
||||||
|
// recoverable error, which is exactly what a fuzzer needs to make progress.
|
||||||
|
//
|
||||||
|
// Header-only and dependency-free so the fuzz harness and the engine share one
|
||||||
|
// source of truth.
|
||||||
|
|
||||||
|
#ifndef PDFENGINE_HARDENED_LIMITS_H
|
||||||
|
#define PDFENGINE_HARDENED_LIMITS_H
|
||||||
|
|
||||||
|
#include <cstdint>
|
||||||
|
|
||||||
|
namespace pdfengine::limits {
|
||||||
|
|
||||||
|
// Largest input document we will even attempt to parse (1 GiB).
|
||||||
|
inline constexpr std::uint64_t kMaxDocumentBytes = 1ull << 30;
|
||||||
|
|
||||||
|
// PDF hard-caps a page at 14,400 user units (200 in) per side; allow a very
|
||||||
|
// generous multiple of that to tolerate odd-but-real documents.
|
||||||
|
inline constexpr double kMaxPageDimensionPt = 200'000.0; // ~2,777 inches
|
||||||
|
|
||||||
|
// No legitimate document has this many pages; stops runaway iteration.
|
||||||
|
inline constexpr int kMaxPageCount = 100'000;
|
||||||
|
|
||||||
|
// MAX_OBJECTS: ceiling on the number of content objects on a single page. Guards
|
||||||
|
// against content-stream "object bombs" that would explode parsing/rendering
|
||||||
|
// time and memory. No real page comes near this.
|
||||||
|
inline constexpr int kMaxObjects = 5'000'000;
|
||||||
|
|
||||||
|
// Cap a single rasterised page at ~256 megapixels (≈1 GiB at 4 bytes/px). At
|
||||||
|
// 96 dpi that is roughly a 16k × 16k page — well beyond any real render.
|
||||||
|
inline constexpr std::int64_t kMaxRasterPixels = 256ll * 1024 * 1024;
|
||||||
|
|
||||||
|
// True if a raw page size (in points) is sane to render.
|
||||||
|
inline constexpr bool pageDimensionsOk(double widthPt, double heightPt) noexcept {
|
||||||
|
return widthPt > 0.0 && heightPt > 0.0 && widthPt <= kMaxPageDimensionPt &&
|
||||||
|
heightPt <= kMaxPageDimensionPt;
|
||||||
|
}
|
||||||
|
|
||||||
|
// True if a target raster (in pixels) fits the pixel budget without overflowing
|
||||||
|
// the width*height*4 byte computation.
|
||||||
|
inline constexpr bool rasterSizeOk(std::int64_t widthPx, std::int64_t heightPx) noexcept {
|
||||||
|
if (widthPx <= 0 || heightPx <= 0) return false;
|
||||||
|
if (widthPx > kMaxRasterPixels || heightPx > kMaxRasterPixels) return false;
|
||||||
|
return widthPx * heightPx <= kMaxRasterPixels;
|
||||||
|
}
|
||||||
|
|
||||||
|
inline constexpr bool documentSizeOk(std::uint64_t bytes) noexcept {
|
||||||
|
return bytes > 0 && bytes <= kMaxDocumentBytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
inline constexpr bool pageCountOk(int pages) noexcept {
|
||||||
|
return pages >= 0 && pages <= kMaxPageCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
inline constexpr bool objectCountOk(int objects) noexcept {
|
||||||
|
return objects >= 0 && objects <= kMaxObjects;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace pdfengine::limits
|
||||||
|
|
||||||
|
#endif // PDFENGINE_HARDENED_LIMITS_H
|
||||||
@@ -53,6 +53,23 @@ struct GlyphBounds {
|
|||||||
double fontSize;
|
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 {
|
struct FontInfo {
|
||||||
std::string fontName;
|
std::string fontName;
|
||||||
std::string type; // "TrueType", "Type1", "CIDFontType0", "CIDFontType2"
|
std::string type; // "TrueType", "Type1", "CIDFontType0", "CIDFontType2"
|
||||||
@@ -136,6 +153,20 @@ public:
|
|||||||
|
|
||||||
[[nodiscard]] virtual std::expected<std::vector<GlyphBounds>, EngineError> extractTextWithBounds() const = 0;
|
[[nodiscard]] virtual std::expected<std::vector<GlyphBounds>, EngineError> extractTextWithBounds() const = 0;
|
||||||
|
|
||||||
|
// Adobe-grade hit-testing & selection, layered on extractTextWithBounds().
|
||||||
|
// Concrete (non-virtual) so every page implementation gets them for free.
|
||||||
|
// Coordinates are in page-point space (top-left origin), matching GlyphBounds.
|
||||||
|
|
||||||
|
// Glyphs in reading order: clustered into lines top-to-bottom, left-to-right.
|
||||||
|
[[nodiscard]] std::expected<std::vector<GlyphBounds>, EngineError> orderedGlyphs() const;
|
||||||
|
|
||||||
|
// Nearest glyph/caret to a point.
|
||||||
|
[[nodiscard]] std::expected<HitResult, EngineError> hitGlyph(double x, double y) const;
|
||||||
|
|
||||||
|
// Reading-order selection between two points (e.g. drag anchor → focus).
|
||||||
|
[[nodiscard]] std::expected<TextSelection, EngineError>
|
||||||
|
selectRange(double ax, double ay, double bx, double by) const;
|
||||||
|
|
||||||
[[nodiscard]] virtual std::expected<PageModel, EngineError> extractDocumentModel() const = 0;
|
[[nodiscard]] virtual std::expected<PageModel, EngineError> extractDocumentModel() const = 0;
|
||||||
|
|
||||||
[[nodiscard]] virtual std::expected<std::vector<FontInfo>, EngineError> getFonts() const = 0;
|
[[nodiscard]] virtual std::expected<std::vector<FontInfo>, EngineError> getFonts() const = 0;
|
||||||
|
|||||||
@@ -6,12 +6,14 @@
|
|||||||
#include <fpdf_doc.h>
|
#include <fpdf_doc.h>
|
||||||
#include <fpdf_edit.h>
|
#include <fpdf_edit.h>
|
||||||
#include <fpdf_annot.h>
|
#include <fpdf_annot.h>
|
||||||
|
#include <fpdf_formfill.h>
|
||||||
#include <png.h>
|
#include <png.h>
|
||||||
#include "parser/pdfium_loader.hpp"
|
#include "parser/pdfium_loader.hpp"
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
#include "fonts/loader/font_resolver.hpp"
|
#include "fonts/loader/font_resolver.hpp"
|
||||||
#include "fonts/pdf_fonts/font.hpp"
|
#include "fonts/pdf_fonts/font.hpp"
|
||||||
|
#include "pdfengine/hardened_limits.h"
|
||||||
|
|
||||||
#include <nlohmann/json.hpp>
|
#include <nlohmann/json.hpp>
|
||||||
#include <spdlog/spdlog.h>
|
#include <spdlog/spdlog.h>
|
||||||
@@ -622,6 +624,13 @@ PdfDocument::loadFromMemory(const std::vector<uint8_t>& data, const std::string&
|
|||||||
if (data.empty()) {
|
if (data.empty()) {
|
||||||
return std::unexpected(EngineError::InvalidFormat);
|
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;
|
std::vector<uint8_t> buffer_copy = data;
|
||||||
FPDF_DOCUMENT doc = FPDF_LoadMemDocument(buffer_copy.data(), static_cast<int>(buffer_copy.size()),
|
FPDF_DOCUMENT doc = FPDF_LoadMemDocument(buffer_copy.data(), static_cast<int>(buffer_copy.size()),
|
||||||
password.empty() ? nullptr : password.c_str());
|
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);
|
spdlog::error("Failed to load PDF from memory (error code: {})", err);
|
||||||
return std::unexpected(mapPdfiumError(err, !password.empty()));
|
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));
|
return std::make_shared<parser::PdfiumDocument>(doc, std::move(buffer_copy));
|
||||||
#else
|
#else
|
||||||
(void)data;
|
(void)data;
|
||||||
@@ -643,8 +659,9 @@ PdfDocument::loadFromMemory(const std::vector<uint8_t>& data, const std::string&
|
|||||||
|
|
||||||
namespace pdfengine::parser {
|
namespace pdfengine::parser {
|
||||||
|
|
||||||
PdfiumPage::PdfiumPage(NativeDocHandle docHandle, NativePageHandle pageHandle, int pageIndex)
|
PdfiumPage::PdfiumPage(NativeDocHandle docHandle, NativePageHandle pageHandle, int pageIndex,
|
||||||
: doc_(docHandle), page_(pageHandle), pageIndex_(pageIndex) {
|
std::shared_ptr<PdfiumDocument> owner)
|
||||||
|
: doc_(docHandle), page_(pageHandle), pageIndex_(pageIndex), ownerDoc_(std::move(owner)) {
|
||||||
}
|
}
|
||||||
|
|
||||||
PdfiumPage::~PdfiumPage() {
|
PdfiumPage::~PdfiumPage() {
|
||||||
@@ -670,10 +687,13 @@ PdfiumPage& PdfiumPage::operator=(PdfiumPage&& other) noexcept {
|
|||||||
if (textPage_) FPDFText_ClosePage(textPage_);
|
if (textPage_) FPDFText_ClosePage(textPage_);
|
||||||
if (page_) FPDF_ClosePage(page_);
|
if (page_) FPDF_ClosePage(page_);
|
||||||
#endif
|
#endif
|
||||||
|
doc_ = other.doc_;
|
||||||
page_ = other.page_;
|
page_ = other.page_;
|
||||||
textPage_ = other.textPage_;
|
textPage_ = other.textPage_;
|
||||||
pageIndex_ = other.pageIndex_;
|
pageIndex_ = other.pageIndex_;
|
||||||
|
ownerDoc_ = std::move(other.ownerDoc_);
|
||||||
|
|
||||||
|
other.doc_ = nullptr;
|
||||||
other.page_ = nullptr;
|
other.page_ = nullptr;
|
||||||
other.textPage_ = nullptr;
|
other.textPage_ = nullptr;
|
||||||
other.pageIndex_ = 0;
|
other.pageIndex_ = 0;
|
||||||
@@ -703,10 +723,27 @@ std::expected<PageImage, EngineError> PdfiumPage::render(int dpi) const {
|
|||||||
return std::unexpected(EngineError::Unknown);
|
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;
|
double scale = dpi / 72.0;
|
||||||
int w = static_cast<int>(width() * scale);
|
int w = static_cast<int>(width() * scale);
|
||||||
int h = static_cast<int>(height() * 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);
|
FPDF_BITMAP bitmap = FPDFBitmap_Create(w, h, 1);
|
||||||
if (!bitmap) {
|
if (!bitmap) {
|
||||||
return std::unexpected(EngineError::RenderFailed);
|
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);
|
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));
|
const auto* buffer = static_cast<const uint8_t*>(FPDFBitmap_GetBuffer(bitmap));
|
||||||
int stride = FPDFBitmap_GetStride(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_);
|
std::lock_guard<std::mutex> lock(pageCacheMutex_);
|
||||||
auto it = pageCache_.find(pageIndex);
|
auto it = pageCache_.find(pageIndex);
|
||||||
if (it != pageCache_.end()) {
|
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);
|
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_);
|
std::lock_guard<std::mutex> lock(pageCacheMutex_);
|
||||||
pageCache_[pageIndex] = pageObj;
|
pageCache_[pageIndex] = pageObj;
|
||||||
@@ -1805,6 +1853,182 @@ std::expected<void, EngineError> PdfiumDocument::applyEdits(const std::string& e
|
|||||||
return std::unexpected(EngineError::Unknown);
|
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);
|
FPDF_ClosePage(page);
|
||||||
} else if (type == "update_field") {
|
} else if (type == "update_field") {
|
||||||
if (!op.contains("data") || !op["data"].is_object()) {
|
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);
|
return std::unexpected(EngineError::InvalidFormat);
|
||||||
}
|
}
|
||||||
auto data = op["data"];
|
auto data = op["data"];
|
||||||
std::string value;
|
const bool isBool = data["value"].is_boolean();
|
||||||
if (data["value"].is_boolean()) {
|
const bool boolVal = isBool && data["value"].get<bool>();
|
||||||
value = data["value"].get<bool>() ? "Yes" : "Off";
|
std::string strVal;
|
||||||
} else if (data["value"].is_string()) {
|
if (data["value"].is_string()) strVal = data["value"].get<std::string>();
|
||||||
value = data["value"].get<std::string>();
|
else if (!isBool) {
|
||||||
} else {
|
|
||||||
spdlog::error("update_field value must be a string or boolean");
|
spdlog::error("update_field value must be a string or boolean");
|
||||||
return std::unexpected(EngineError::InvalidFormat);
|
return std::unexpected(EngineError::InvalidFormat);
|
||||||
}
|
}
|
||||||
|
|
||||||
std::string id = op.value("id", "");
|
// Field identity: prefer data.annotationId (NM or anno_<page>_<idx>),
|
||||||
int annotIndex = -1;
|
// fall back to the op id (legacy) for compatibility.
|
||||||
size_t lastUnderscore = id.find_last_of('_');
|
std::string targetId = data.value("annotationId", op.value("id", ""));
|
||||||
if (lastUnderscore != std::string::npos) {
|
|
||||||
try {
|
|
||||||
annotIndex = std::stoi(id.substr(lastUnderscore + 1));
|
|
||||||
} catch (...) {
|
|
||||||
annotIndex = -1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (annotIndex >= 0) {
|
|
||||||
FPDF_PAGE page = FPDF_LoadPage(doc_, pageIndex);
|
FPDF_PAGE page = FPDF_LoadPage(doc_, pageIndex);
|
||||||
if (!page) {
|
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);
|
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);
|
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") {
|
} else if (type == "image_overlay") {
|
||||||
if (!op.contains("data") || !op["data"].is_object()) {
|
if (!op.contains("data") || !op["data"].is_object()) {
|
||||||
spdlog::error("image_overlay operation missing 'data' object");
|
spdlog::error("image_overlay operation missing 'data' object");
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ namespace pdfengine::fonts::loader { class FontResolver; }
|
|||||||
|
|
||||||
namespace pdfengine::parser {
|
namespace pdfengine::parser {
|
||||||
|
|
||||||
|
class PdfiumDocument; // a page keeps its owning document alive (see ownerDoc_)
|
||||||
|
|
||||||
#ifdef PDFENGINE_WITH_PDFIUM
|
#ifdef PDFENGINE_WITH_PDFIUM
|
||||||
using NativeDocHandle = FPDF_DOCUMENT;
|
using NativeDocHandle = FPDF_DOCUMENT;
|
||||||
using NativePageHandle = FPDF_PAGE;
|
using NativePageHandle = FPDF_PAGE;
|
||||||
@@ -28,7 +30,8 @@ using NativeTextHandle = void*;
|
|||||||
|
|
||||||
class PdfiumPage : public PdfPage {
|
class PdfiumPage : public PdfPage {
|
||||||
public:
|
public:
|
||||||
PdfiumPage(NativeDocHandle docHandle, NativePageHandle pageHandle, int pageIndex);
|
PdfiumPage(NativeDocHandle docHandle, NativePageHandle pageHandle, int pageIndex,
|
||||||
|
std::shared_ptr<PdfiumDocument> owner = nullptr);
|
||||||
~PdfiumPage() override;
|
~PdfiumPage() override;
|
||||||
|
|
||||||
PdfiumPage(const PdfiumPage&) = delete;
|
PdfiumPage(const PdfiumPage&) = delete;
|
||||||
@@ -58,6 +61,10 @@ private:
|
|||||||
mutable NativeTextHandle textPage_ = nullptr;
|
mutable NativeTextHandle textPage_ = nullptr;
|
||||||
int pageIndex_ = 0;
|
int pageIndex_ = 0;
|
||||||
mutable std::mutex textMutex_;
|
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
|
#ifdef PDFENGINE_WITH_PDFIUM
|
||||||
mutable std::unordered_map<std::string, FPDF_FONT> fontHandleCache_;
|
mutable std::unordered_map<std::string, FPDF_FONT> fontHandleCache_;
|
||||||
#endif
|
#endif
|
||||||
@@ -103,7 +110,10 @@ private:
|
|||||||
mutable std::unordered_map<std::string, std::vector<uint8_t>> fontDataCache_;
|
mutable std::unordered_map<std::string, std::vector<uint8_t>> fontDataCache_;
|
||||||
mutable int fontDataScannedPages_ = 0;
|
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_;
|
mutable std::mutex pageCacheMutex_;
|
||||||
|
|
||||||
// Font Engine Bridge
|
// Font Engine Bridge
|
||||||
|
|||||||
@@ -0,0 +1,349 @@
|
|||||||
|
// Adobe-grade hit-testing & text selection for PdfPage.
|
||||||
|
//
|
||||||
|
// Concrete implementations of PdfPage::orderedGlyphs / hitGlyph / selectRange,
|
||||||
|
// built on top of the (virtual) extractTextWithBounds(). All maths is in
|
||||||
|
// page-point space with a top-left origin — identical to the frontend
|
||||||
|
// TextSelectionModel, so engine and browser selections agree.
|
||||||
|
|
||||||
|
#include "pdfengine/pdf_document.hpp"
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
#include <cctype>
|
||||||
|
#include <cmath>
|
||||||
|
#include <limits>
|
||||||
|
#include <map>
|
||||||
|
|
||||||
|
namespace pdfengine {
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
struct OGlyph {
|
||||||
|
GlyphBounds g;
|
||||||
|
int line = 0;
|
||||||
|
double right = 0.0;
|
||||||
|
double bottom = 0.0;
|
||||||
|
double mid = 0.0; // horizontal centre
|
||||||
|
};
|
||||||
|
|
||||||
|
struct LineBand {
|
||||||
|
double top = 0.0;
|
||||||
|
double bottom = 0.0;
|
||||||
|
double mid = 0.0; // vertical centre of the first glyph
|
||||||
|
int start = 0; // inclusive glyph index
|
||||||
|
int end = 0; // exclusive glyph index
|
||||||
|
};
|
||||||
|
|
||||||
|
// Uniform 2D grid over glyph bounding boxes — a true spatial index for the
|
||||||
|
// point→glyph query, independent of line assignment. This is what makes
|
||||||
|
// hit-testing exact on overlapping/dense content (diacritics, multi-column,
|
||||||
|
// rotated runs) where a line-bucketed search would miss.
|
||||||
|
struct Grid {
|
||||||
|
double minX = 0.0, minY = 0.0, cell = 1.0;
|
||||||
|
int cols = 0, rows = 0;
|
||||||
|
std::vector<std::vector<int>> cells; // size cols*rows; glyph indices per cell
|
||||||
|
|
||||||
|
[[nodiscard]] bool empty() const { return cols == 0 || rows == 0; }
|
||||||
|
[[nodiscard]] int at(int c, int r) const { return r * cols + c; }
|
||||||
|
};
|
||||||
|
|
||||||
|
struct SelIndex {
|
||||||
|
std::vector<OGlyph> glyphs; // reading order
|
||||||
|
std::vector<LineBand> lines;
|
||||||
|
Grid grid;
|
||||||
|
};
|
||||||
|
|
||||||
|
bool isSpace(const std::string& t) {
|
||||||
|
if (t.empty()) return true;
|
||||||
|
for (unsigned char c : t)
|
||||||
|
if (!std::isspace(c)) return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cluster raw glyphs into lines (by vertical overlap) then sort each line L→R,
|
||||||
|
// flattening into a single reading-order array with cached line bands.
|
||||||
|
SelIndex buildIndex(const std::vector<GlyphBounds>& raw) {
|
||||||
|
SelIndex idx;
|
||||||
|
std::vector<GlyphBounds> clean;
|
||||||
|
clean.reserve(raw.size());
|
||||||
|
for (const auto& g : raw)
|
||||||
|
if (g.w >= 0 && g.h > 0) clean.push_back(g);
|
||||||
|
if (clean.empty()) return idx;
|
||||||
|
|
||||||
|
std::sort(clean.begin(), clean.end(), [](const GlyphBounds& a, const GlyphBounds& b) {
|
||||||
|
return (a.y + a.h / 2) < (b.y + b.h / 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
std::vector<std::vector<GlyphBounds>> rows;
|
||||||
|
for (const auto& g : clean) {
|
||||||
|
const double gMid = g.y + g.h / 2;
|
||||||
|
bool placed = false;
|
||||||
|
if (!rows.empty()) {
|
||||||
|
auto& row = rows.back();
|
||||||
|
double top = std::numeric_limits<double>::max();
|
||||||
|
double bottom = std::numeric_limits<double>::lowest();
|
||||||
|
for (const auto& r : row) {
|
||||||
|
top = std::min(top, r.y);
|
||||||
|
bottom = std::max(bottom, r.y + r.h);
|
||||||
|
}
|
||||||
|
const double tol = g.h * 0.25;
|
||||||
|
if (gMid >= top - tol && gMid <= bottom + tol) {
|
||||||
|
row.push_back(g);
|
||||||
|
placed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!placed) rows.push_back({g});
|
||||||
|
}
|
||||||
|
|
||||||
|
int running = 0;
|
||||||
|
for (auto& row : rows) {
|
||||||
|
std::sort(row.begin(), row.end(),
|
||||||
|
[](const GlyphBounds& a, const GlyphBounds& b) { return a.x < b.x; });
|
||||||
|
LineBand band;
|
||||||
|
band.start = running;
|
||||||
|
const int lineNo = static_cast<int>(idx.lines.size());
|
||||||
|
double top = std::numeric_limits<double>::max();
|
||||||
|
double bottom = std::numeric_limits<double>::lowest();
|
||||||
|
for (const auto& g : row) {
|
||||||
|
OGlyph og;
|
||||||
|
og.g = g;
|
||||||
|
og.line = lineNo;
|
||||||
|
og.right = g.x + g.w;
|
||||||
|
og.bottom = g.y + g.h;
|
||||||
|
og.mid = g.x + g.w / 2;
|
||||||
|
idx.glyphs.push_back(og);
|
||||||
|
top = std::min(top, g.y);
|
||||||
|
bottom = std::max(bottom, g.y + g.h);
|
||||||
|
running++;
|
||||||
|
}
|
||||||
|
band.end = running;
|
||||||
|
band.top = top;
|
||||||
|
band.bottom = bottom;
|
||||||
|
band.mid = row.front().y + row.front().h / 2;
|
||||||
|
idx.lines.push_back(band);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build the spatial grid over the final glyph set.
|
||||||
|
Grid& grid = idx.grid;
|
||||||
|
double minX = std::numeric_limits<double>::max();
|
||||||
|
double minY = std::numeric_limits<double>::max();
|
||||||
|
double maxX = std::numeric_limits<double>::lowest();
|
||||||
|
double maxY = std::numeric_limits<double>::lowest();
|
||||||
|
double sumH = 0.0;
|
||||||
|
for (const auto& og : idx.glyphs) {
|
||||||
|
minX = std::min(minX, og.g.x);
|
||||||
|
minY = std::min(minY, og.g.y);
|
||||||
|
maxX = std::max(maxX, og.right);
|
||||||
|
maxY = std::max(maxY, og.bottom);
|
||||||
|
sumH += og.g.h;
|
||||||
|
}
|
||||||
|
// Cell ≈ average glyph height (~one text line), so most cells hold a handful
|
||||||
|
// of glyphs. Grow the cell if the grid would otherwise be unreasonably large.
|
||||||
|
double cell = std::max(1.0, sumH / static_cast<double>(idx.glyphs.size()));
|
||||||
|
auto dim = [&](double lo, double hi) {
|
||||||
|
return std::max(1, static_cast<int>((hi - lo) / cell) + 1);
|
||||||
|
};
|
||||||
|
int gCols = dim(minX, maxX);
|
||||||
|
int gRows = dim(minY, maxY);
|
||||||
|
while (static_cast<long long>(gCols) * gRows > 2'000'000 && cell < 1e9) {
|
||||||
|
cell *= 2.0;
|
||||||
|
gCols = dim(minX, maxX);
|
||||||
|
gRows = dim(minY, maxY);
|
||||||
|
}
|
||||||
|
grid.minX = minX;
|
||||||
|
grid.minY = minY;
|
||||||
|
grid.cell = cell;
|
||||||
|
grid.cols = gCols;
|
||||||
|
grid.rows = gRows;
|
||||||
|
grid.cells.assign(static_cast<size_t>(gCols) * gRows, {});
|
||||||
|
auto col = [&](double x) { return std::clamp(static_cast<int>((x - minX) / cell), 0, gCols - 1); };
|
||||||
|
auto rowOf = [&](double y) { return std::clamp(static_cast<int>((y - minY) / cell), 0, gRows - 1); };
|
||||||
|
for (int i = 0; i < static_cast<int>(idx.glyphs.size()); ++i) {
|
||||||
|
const OGlyph& og = idx.glyphs[i];
|
||||||
|
const int c0 = col(og.g.x), c1 = col(og.right);
|
||||||
|
const int r0 = rowOf(og.g.y), r1 = rowOf(og.bottom);
|
||||||
|
for (int r = r0; r <= r1; ++r)
|
||||||
|
for (int c = c0; c <= c1; ++c)
|
||||||
|
grid.cells[grid.at(c, r)].push_back(i);
|
||||||
|
}
|
||||||
|
return idx;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- spatial index queries (Adobe-grade, fast on dense/overlapping content) --
|
||||||
|
//
|
||||||
|
// Point→glyph uses the 2D grid (exact regardless of line layout). Caret/line
|
||||||
|
// positioning uses the line bands via binary search (lines are in ascending
|
||||||
|
// vertical order; each line's glyphs are contiguous and x-sorted). Both stay
|
||||||
|
// cheap on pages with tens of thousands of glyphs.
|
||||||
|
|
||||||
|
// Exact glyph under a point via the spatial grid — never misses a containing
|
||||||
|
// glyph, even where text lines overlap.
|
||||||
|
int glyphAt(const SelIndex& idx, double x, double y) {
|
||||||
|
const Grid& grid = idx.grid;
|
||||||
|
if (grid.empty() || x < grid.minX || y < grid.minY) return -1;
|
||||||
|
const int c = static_cast<int>((x - grid.minX) / grid.cell);
|
||||||
|
const int r = static_cast<int>((y - grid.minY) / grid.cell);
|
||||||
|
if (c < 0 || c >= grid.cols || r < 0 || r >= grid.rows) return -1;
|
||||||
|
for (const int i : grid.cells[grid.at(c, r)]) {
|
||||||
|
const OGlyph& g = idx.glyphs[i];
|
||||||
|
if (x >= g.g.x && x <= g.right && y >= g.g.y && y <= g.bottom) return i;
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
int lineAt(const SelIndex& idx, double y) {
|
||||||
|
const auto& lines = idx.lines;
|
||||||
|
if (lines.empty()) return -1;
|
||||||
|
|
||||||
|
// Binary search for the last line whose top <= y.
|
||||||
|
int lo = 0;
|
||||||
|
int hi = static_cast<int>(lines.size());
|
||||||
|
while (lo < hi) {
|
||||||
|
const int m = (lo + hi) / 2;
|
||||||
|
if (lines[m].top <= y) lo = m + 1;
|
||||||
|
else hi = m;
|
||||||
|
}
|
||||||
|
const int cand = lo - 1; // -1 when y is above every line
|
||||||
|
|
||||||
|
// Check the immediate neighbourhood for true containment, else take the
|
||||||
|
// nearest band by centre. Bands can overlap slightly (sub/superscript
|
||||||
|
// tolerance), so a ±1 window around the search boundary is enough.
|
||||||
|
int best = -1;
|
||||||
|
double bestDist = std::numeric_limits<double>::max();
|
||||||
|
for (int i = cand - 1; i <= cand + 1; ++i) {
|
||||||
|
if (i < 0 || i >= static_cast<int>(lines.size())) continue;
|
||||||
|
if (y >= lines[i].top && y <= lines[i].bottom) return i;
|
||||||
|
const double d = std::abs(y - lines[i].mid);
|
||||||
|
if (d < bestDist) {
|
||||||
|
bestDist = d;
|
||||||
|
best = i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (best >= 0) return best;
|
||||||
|
return cand < 0 ? 0 : static_cast<int>(lines.size()) - 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Index of the last glyph in [start,end) whose left edge x <= queryX, or
|
||||||
|
// start-1 if queryX is left of the whole line. Glyphs are sorted by x.
|
||||||
|
static int lastGlyphLeftOf(const SelIndex& idx, const LineBand& line, double x) {
|
||||||
|
int lo = line.start;
|
||||||
|
int hi = line.end;
|
||||||
|
while (lo < hi) {
|
||||||
|
const int m = (lo + hi) / 2;
|
||||||
|
if (idx.glyphs[m].g.x <= x) lo = m + 1;
|
||||||
|
else hi = m;
|
||||||
|
}
|
||||||
|
return lo - 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
int caretAt(const SelIndex& idx, double x, double y) {
|
||||||
|
// If the point lands on a glyph, the caret sits on its near or far side.
|
||||||
|
const int hit = glyphAt(idx, x, y);
|
||||||
|
if (hit >= 0) {
|
||||||
|
const OGlyph& g = idx.glyphs[hit];
|
||||||
|
return x < g.mid ? hit : hit + 1;
|
||||||
|
}
|
||||||
|
// Otherwise position within the nearest line (gaps / between lines / margins).
|
||||||
|
const int li = lineAt(idx, y);
|
||||||
|
if (li < 0) return 0;
|
||||||
|
const LineBand& line = idx.lines[li];
|
||||||
|
if (x <= idx.glyphs[line.start].g.x) return line.start;
|
||||||
|
if (x >= idx.glyphs[line.end - 1].right) return line.end;
|
||||||
|
|
||||||
|
const int cand = lastGlyphLeftOf(idx, line, x);
|
||||||
|
if (cand < line.start) return line.start;
|
||||||
|
const OGlyph& g = idx.glyphs[cand];
|
||||||
|
if (x <= g.right) return x < g.mid ? cand : cand + 1; // inside the glyph
|
||||||
|
return cand + 1; // in the gap after it
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string textOfRange(const SelIndex& idx, int start, int end) {
|
||||||
|
std::string out;
|
||||||
|
const OGlyph* prev = nullptr;
|
||||||
|
for (int i = start; i < end; ++i) {
|
||||||
|
const OGlyph& g = idx.glyphs[i];
|
||||||
|
if (prev) {
|
||||||
|
if (g.line != prev->line) {
|
||||||
|
out += '\n';
|
||||||
|
} else {
|
||||||
|
const double gap = g.g.x - prev->right;
|
||||||
|
if (!isSpace(g.g.text) && !isSpace(prev->g.text) && gap > g.g.h * 0.25)
|
||||||
|
out += ' ';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out += g.g.text;
|
||||||
|
prev = &g;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::vector<GlyphBounds> rectsOfRange(const SelIndex& idx, int start, int end) {
|
||||||
|
std::vector<GlyphBounds> rects;
|
||||||
|
if (start >= end) return rects;
|
||||||
|
// Group selected glyphs by line, preserving first-seen order.
|
||||||
|
std::map<int, std::pair<double, double>> spanByLine; // line -> {minX, maxRight}
|
||||||
|
std::vector<int> order;
|
||||||
|
for (int i = start; i < end; ++i) {
|
||||||
|
const OGlyph& g = idx.glyphs[i];
|
||||||
|
auto it = spanByLine.find(g.line);
|
||||||
|
if (it == spanByLine.end()) {
|
||||||
|
spanByLine[g.line] = {g.g.x, g.right};
|
||||||
|
order.push_back(g.line);
|
||||||
|
} else {
|
||||||
|
it->second.first = std::min(it->second.first, g.g.x);
|
||||||
|
it->second.second = std::max(it->second.second, g.right);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (int line : order) {
|
||||||
|
const auto& span = spanByLine[line];
|
||||||
|
const LineBand& band = idx.lines[line];
|
||||||
|
GlyphBounds r;
|
||||||
|
r.text = "";
|
||||||
|
r.x = span.first;
|
||||||
|
r.y = band.top;
|
||||||
|
r.w = span.second - span.first;
|
||||||
|
r.h = band.bottom - band.top;
|
||||||
|
r.fontSize = 0.0;
|
||||||
|
rects.push_back(r);
|
||||||
|
}
|
||||||
|
return rects;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
std::expected<std::vector<GlyphBounds>, EngineError> PdfPage::orderedGlyphs() const {
|
||||||
|
auto raw = extractTextWithBounds();
|
||||||
|
if (!raw) return std::unexpected(raw.error());
|
||||||
|
const SelIndex idx = buildIndex(*raw);
|
||||||
|
std::vector<GlyphBounds> out;
|
||||||
|
out.reserve(idx.glyphs.size());
|
||||||
|
for (const auto& og : idx.glyphs) out.push_back(og.g);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::expected<HitResult, EngineError> PdfPage::hitGlyph(double x, double y) const {
|
||||||
|
auto raw = extractTextWithBounds();
|
||||||
|
if (!raw) return std::unexpected(raw.error());
|
||||||
|
const SelIndex idx = buildIndex(*raw);
|
||||||
|
HitResult hit;
|
||||||
|
hit.line = lineAt(idx, y);
|
||||||
|
hit.caret = caretAt(idx, x, y);
|
||||||
|
hit.glyphIndex = glyphAt(idx, x, y);
|
||||||
|
return hit;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::expected<TextSelection, EngineError>
|
||||||
|
PdfPage::selectRange(double ax, double ay, double bx, double by) const {
|
||||||
|
auto raw = extractTextWithBounds();
|
||||||
|
if (!raw) return std::unexpected(raw.error());
|
||||||
|
const SelIndex idx = buildIndex(*raw);
|
||||||
|
int a = caretAt(idx, ax, ay);
|
||||||
|
int b = caretAt(idx, bx, by);
|
||||||
|
if (a > b) std::swap(a, b);
|
||||||
|
TextSelection sel;
|
||||||
|
sel.startGlyph = a;
|
||||||
|
sel.endGlyph = b;
|
||||||
|
sel.text = textOfRange(idx, a, b);
|
||||||
|
sel.rects = rectsOfRange(idx, a, b);
|
||||||
|
return sel;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace pdfengine
|
||||||
@@ -12,6 +12,8 @@
|
|||||||
#include "fonts/pdf_fonts/font_subset.hpp"
|
#include "fonts/pdf_fonts/font_subset.hpp"
|
||||||
|
|
||||||
#include <gtest/gtest.h>
|
#include <gtest/gtest.h>
|
||||||
|
#include <algorithm>
|
||||||
|
#include <cctype>
|
||||||
#include <filesystem>
|
#include <filesystem>
|
||||||
#include <fstream>
|
#include <fstream>
|
||||||
#include <iostream>
|
#include <iostream>
|
||||||
@@ -20,6 +22,19 @@
|
|||||||
|
|
||||||
namespace {
|
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) {
|
bool saveGlyphAsPGM(const pdfengine::fonts::GlyphBitmap& bitmap, const std::string& filename) {
|
||||||
if (bitmap.width == 0 || bitmap.height == 0 || bitmap.pixels.empty()) {
|
if (bitmap.width == 0 || bitmap.height == 0 || bitmap.pixels.empty()) {
|
||||||
return false;
|
return false;
|
||||||
@@ -1020,22 +1035,36 @@ TEST(FontFallbackTest, SingletonInstanceIsUnique) {
|
|||||||
EXPECT_EQ(&instance1, &instance2);
|
EXPECT_EQ(&instance1, &instance2);
|
||||||
}
|
}
|
||||||
|
|
||||||
TEST(FontFallbackTest, StandardFontFallbacksOnWindows) {
|
TEST(FontFallbackTest, StandardFontFallbacks) {
|
||||||
using namespace pdfengine::fonts::pdf_fonts;
|
using namespace pdfengine::fonts::pdf_fonts;
|
||||||
|
|
||||||
auto& fallback = FontFallback::getInstance();
|
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");
|
std::string path1 = fallback.getFallbackFontPath("Helvetica");
|
||||||
EXPECT_FALSE(path1.empty());
|
EXPECT_FALSE(path1.empty());
|
||||||
EXPECT_TRUE(std::filesystem::exists(path1));
|
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");
|
std::string path2 = fallback.getFallbackFontPath("Times-Roman");
|
||||||
EXPECT_FALSE(path2.empty());
|
EXPECT_FALSE(path2.empty());
|
||||||
EXPECT_TRUE(std::filesystem::exists(path2));
|
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) {
|
TEST(FontFallbackTest, StyleModifierResolutions) {
|
||||||
@@ -1043,13 +1072,23 @@ TEST(FontFallbackTest, StyleModifierResolutions) {
|
|||||||
|
|
||||||
auto& fallback = FontFallback::getInstance();
|
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);
|
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);
|
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) {
|
TEST(FontFallbackTest, CustomFallbackRegistration) {
|
||||||
@@ -1058,19 +1097,26 @@ TEST(FontFallbackTest, CustomFallbackRegistration) {
|
|||||||
auto& fallback = FontFallback::getInstance();
|
auto& fallback = FontFallback::getInstance();
|
||||||
fallback.resetToDefaults();
|
fallback.resetToDefaults();
|
||||||
|
|
||||||
// Lookup standard Arial path
|
// Lookup the default substitute path for Helvetica.
|
||||||
std::string standardPath = fallback.getFallbackFontPath("Helvetica");
|
std::string standardPath = fallback.getFallbackFontPath("Helvetica");
|
||||||
|
|
||||||
// Register custom override for "helvetica" pointing to times.ttf
|
// The resolver only returns an override whose file actually exists on disk,
|
||||||
fallback.registerFallback("helvetica", "C:\\Windows\\Fonts\\times.ttf");
|
// 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");
|
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();
|
fallback.resetToDefaults();
|
||||||
std::string restoredPath = fallback.getFallbackFontPath("Helvetica");
|
std::string restoredPath = fallback.getFallbackFontPath("Helvetica");
|
||||||
EXPECT_EQ(restoredPath, standardPath);
|
EXPECT_EQ(restoredPath, standardPath);
|
||||||
|
|
||||||
|
std::filesystem::remove(overrideFont);
|
||||||
}
|
}
|
||||||
|
|
||||||
TEST(FontSubsetTest, SubsetTagParsingAndStripping) {
|
TEST(FontSubsetTest, SubsetTagParsingAndStripping) {
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import type { CustomConfirmationOptions } from './components/custom/CustomConfir
|
|||||||
import { PDFViewer } from './viewer/PDFViewer';
|
import { PDFViewer } from './viewer/PDFViewer';
|
||||||
import type { PDFViewerRef } from './viewer/PDFViewer';
|
import type { PDFViewerRef } from './viewer/PDFViewer';
|
||||||
import type { Annotation } from './viewer/AnnotationLayer';
|
import type { Annotation } from './viewer/AnnotationLayer';
|
||||||
|
import type { EditableRun } from './viewer/TextEditLayer';
|
||||||
import { gatewayService } from './lib/gatewayService';
|
import { gatewayService } from './lib/gatewayService';
|
||||||
import type { DocumentInfo, SearchResult, EditOperation, DocumentMetadata, FontInfo, OutlineItem } from './lib/gatewayService';
|
import type { DocumentInfo, SearchResult, EditOperation, DocumentMetadata, FontInfo, OutlineItem } from './lib/gatewayService';
|
||||||
import { viewportRectToPdf } from './lib/coordinateMapping';
|
import { viewportRectToPdf } from './lib/coordinateMapping';
|
||||||
@@ -271,6 +272,19 @@ function App() {
|
|||||||
setActiveTool('select');
|
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 }) => {
|
const handlePlaceStamp = (pageIndex: number, point: { x: number; y: number }) => {
|
||||||
if (!activeStamp) return;
|
if (!activeStamp) return;
|
||||||
const fontSize = 22;
|
const fontSize = 22;
|
||||||
@@ -523,13 +537,14 @@ function App() {
|
|||||||
searchCurrentMatch={searchCurrentMatch}
|
searchCurrentMatch={searchCurrentMatch}
|
||||||
onAnnotationAdded={handleAnnotationAdded}
|
onAnnotationAdded={handleAnnotationAdded}
|
||||||
onAnnotationUpdate={handleUpdateAnnotation}
|
onAnnotationUpdate={handleUpdateAnnotation}
|
||||||
onAnnotationClick={(a) => {
|
onAnnotationClick={() => {
|
||||||
setInspectorTab('notes');
|
setInspectorTab('notes');
|
||||||
if (!isInspectorOpen) setIsInspectorOpen(true);
|
if (!isInspectorOpen) setIsInspectorOpen(true);
|
||||||
}}
|
}}
|
||||||
onPageVisible={setCurrentPage}
|
onPageVisible={setCurrentPage}
|
||||||
onRedactArea={handleRedactArea}
|
onRedactArea={handleRedactArea}
|
||||||
onPlaceText={handlePlaceText}
|
onPlaceText={handlePlaceText}
|
||||||
|
onEditText={handleEditText}
|
||||||
onPlaceStamp={handlePlaceStamp}
|
onPlaceStamp={handlePlaceStamp}
|
||||||
onPlaceSignature={handlePlaceSignature}
|
onPlaceSignature={handlePlaceSignature}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -17,6 +17,17 @@ const TOOLS: (ToolDef | 'divider')[] = [
|
|||||||
{ id: 'draw', label: 'Draw (ink)', shortcut: 'D', icon: <DrawIcon /> },
|
{ id: 'draw', label: 'Draw (ink)', shortcut: 'D', icon: <DrawIcon /> },
|
||||||
{ id: 'comment', label: 'Comment', shortcut: 'C', icon: <CommentIcon /> },
|
{ id: 'comment', label: 'Comment', shortcut: 'C', icon: <CommentIcon /> },
|
||||||
{ id: 'textbox', label: 'Text box', shortcut: 'T', icon: <TextBoxIcon /> },
|
{ 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: 'signature', label: 'Signature', shortcut: 'S', icon: <SignatureIcon /> },
|
||||||
{ id: 'stamp', label: 'Stamp', shortcut: 'M', icon: <StampIcon /> },
|
{ id: 'stamp', label: 'Stamp', shortcut: 'M', icon: <StampIcon /> },
|
||||||
'divider',
|
'divider',
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ const TOOL_META: Record<ToolId, { label: string; icon: React.ReactNode }> = {
|
|||||||
draw: { label: 'Draw', icon: <DrawIcon size={17} /> },
|
draw: { label: 'Draw', icon: <DrawIcon size={17} /> },
|
||||||
comment: { label: 'Comment', icon: <CommentIcon size={17} /> },
|
comment: { label: 'Comment', icon: <CommentIcon size={17} /> },
|
||||||
textbox: { label: 'Text box', icon: <TextBoxIcon 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} /> },
|
signature: { label: 'Signature', icon: <SignatureIcon size={17} /> },
|
||||||
stamp: { label: 'Stamp', icon: <StampIcon size={17} /> },
|
stamp: { label: 'Stamp', icon: <StampIcon size={17} /> },
|
||||||
redact: { label: 'Redact', icon: <RedactIcon size={17} /> },
|
redact: { label: 'Redact', icon: <RedactIcon size={17} /> },
|
||||||
|
|||||||
@@ -185,8 +185,22 @@ export type EditOperationDataMap = {
|
|||||||
page_reorder: PageReorderData;
|
page_reorder: PageReorderData;
|
||||||
delete_annotation: DeleteAnnotationData;
|
delete_annotation: DeleteAnnotationData;
|
||||||
update_annotation: UpdateAnnotationData;
|
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 {
|
export interface DeleteAnnotationData {
|
||||||
annotationId: string;
|
annotationId: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,244 @@
|
|||||||
|
// Adobe-grade text-selection model.
|
||||||
|
//
|
||||||
|
// Operates purely in **page-point space** (unzoomed, top-left origin) — the same
|
||||||
|
// space the engine emits glyph bounds in. Callers convert mouse coordinates into
|
||||||
|
// this space (divide by zoom) before querying, and scale the returned rects back
|
||||||
|
// up (multiply by zoom) for rendering.
|
||||||
|
//
|
||||||
|
// A *caret* is a position between glyphs, indexed 0..N (N = glyph count). A
|
||||||
|
// selection is an ordered pair of carets {start, end}; start may be greater than
|
||||||
|
// end for a backward (right-to-left) drag. All range helpers normalise
|
||||||
|
// internally, so the component is free to keep anchor/focus unnormalised.
|
||||||
|
|
||||||
|
import type { Glyph } from './gatewayService';
|
||||||
|
|
||||||
|
export interface OrderedGlyph extends Glyph {
|
||||||
|
index: number; // position in reading order
|
||||||
|
line: number; // line band this glyph belongs to
|
||||||
|
right: number; // x + w (cached)
|
||||||
|
bottom: number; // y + h (cached)
|
||||||
|
mid: number; // x + w/2 (cached)
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CaretRange {
|
||||||
|
start: number;
|
||||||
|
end: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SelRect {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
w: number;
|
||||||
|
h: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LineBand {
|
||||||
|
top: number;
|
||||||
|
bottom: number;
|
||||||
|
mid: number;
|
||||||
|
start: number; // first glyph index (inclusive)
|
||||||
|
end: number; // last glyph index + 1 (exclusive)
|
||||||
|
}
|
||||||
|
|
||||||
|
const isSpace = (t: string) => t.length === 0 || /^\s+$/.test(t);
|
||||||
|
|
||||||
|
export class TextSelectionModel {
|
||||||
|
readonly glyphs: OrderedGlyph[] = [];
|
||||||
|
private readonly lines: LineBand[] = [];
|
||||||
|
|
||||||
|
constructor(raw: Glyph[]) {
|
||||||
|
const clean = raw.filter((g) => g.w >= 0 && g.h > 0);
|
||||||
|
if (clean.length === 0) return;
|
||||||
|
|
||||||
|
// Cluster glyphs into lines by vertical overlap. Sort by vertical centre so
|
||||||
|
// rows arrive top-to-bottom even when the source order is arbitrary.
|
||||||
|
const byY = [...clean].sort((a, b) => a.y + a.h / 2 - (b.y + b.h / 2));
|
||||||
|
const rows: Glyph[][] = [];
|
||||||
|
for (const g of byY) {
|
||||||
|
const gMid = g.y + g.h / 2;
|
||||||
|
const row = rows[rows.length - 1];
|
||||||
|
if (row) {
|
||||||
|
// Band of the current row so far.
|
||||||
|
const top = Math.min(...row.map((r) => r.y));
|
||||||
|
const bottom = Math.max(...row.map((r) => r.y + r.h));
|
||||||
|
// Same line if the glyph centre falls inside the row band (with a small
|
||||||
|
// tolerance for sub/superscript jitter).
|
||||||
|
const tol = g.h * 0.25;
|
||||||
|
if (gMid >= top - tol && gMid <= bottom + tol) {
|
||||||
|
row.push(g);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rows.push([g]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Flatten rows (each sorted left→right) into a single reading-order array and
|
||||||
|
// record the line bands for spatial queries.
|
||||||
|
let idx = 0;
|
||||||
|
rows.forEach((row, lineNo) => {
|
||||||
|
row.sort((a, b) => a.x - b.x);
|
||||||
|
const start = idx;
|
||||||
|
for (const g of row) {
|
||||||
|
this.glyphs.push({
|
||||||
|
...g,
|
||||||
|
index: idx,
|
||||||
|
line: lineNo,
|
||||||
|
right: g.x + g.w,
|
||||||
|
bottom: g.y + g.h,
|
||||||
|
mid: g.x + g.w / 2,
|
||||||
|
});
|
||||||
|
idx += 1;
|
||||||
|
}
|
||||||
|
this.lines.push({
|
||||||
|
top: Math.min(...row.map((r) => r.y)),
|
||||||
|
bottom: Math.max(...row.map((r) => r.y + r.h)),
|
||||||
|
mid: row[0].y + row[0].h / 2,
|
||||||
|
start,
|
||||||
|
end: idx,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
get length(): number {
|
||||||
|
return this.glyphs.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Index of the line whose band contains y, else the nearest line by centre. */
|
||||||
|
private lineAt(y: number): number {
|
||||||
|
if (this.lines.length === 0) return -1;
|
||||||
|
for (let i = 0; i < this.lines.length; i++) {
|
||||||
|
if (y >= this.lines[i].top && y <= this.lines[i].bottom) return i;
|
||||||
|
}
|
||||||
|
// Above the first / below the last / in an inter-line gap → nearest centre.
|
||||||
|
let best = 0;
|
||||||
|
let bestDist = Infinity;
|
||||||
|
for (let i = 0; i < this.lines.length; i++) {
|
||||||
|
const d = Math.abs(y - this.lines[i].mid);
|
||||||
|
if (d < bestDist) {
|
||||||
|
bestDist = d;
|
||||||
|
best = i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return best;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Nearest caret (0..N) to a point. */
|
||||||
|
caretAt(x: number, y: number): number {
|
||||||
|
const li = this.lineAt(y);
|
||||||
|
if (li < 0) return 0;
|
||||||
|
const line = this.lines[li];
|
||||||
|
if (x <= this.glyphs[line.start].x) return line.start;
|
||||||
|
if (x >= this.glyphs[line.end - 1].right) return line.end;
|
||||||
|
for (let i = line.start; i < line.end; i++) {
|
||||||
|
const g = this.glyphs[i];
|
||||||
|
if (x >= g.x && x <= g.right) return x < g.mid ? g.index : g.index + 1;
|
||||||
|
// In the gap before this glyph.
|
||||||
|
if (x < g.x) return g.index;
|
||||||
|
}
|
||||||
|
return line.end;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Glyph index directly under a point, or -1. */
|
||||||
|
glyphAt(x: number, y: number): number {
|
||||||
|
const li = this.lineAt(y);
|
||||||
|
if (li < 0) return -1;
|
||||||
|
const line = this.lines[li];
|
||||||
|
if (y < line.top || y > line.bottom) return -1;
|
||||||
|
for (let i = line.start; i < line.end; i++) {
|
||||||
|
const g = this.glyphs[i];
|
||||||
|
if (x >= g.x && x <= g.right) return i;
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Word caret-range around a point (run of non-space within one line). */
|
||||||
|
wordRangeAt(x: number, y: number): CaretRange | null {
|
||||||
|
let gi = this.glyphAt(x, y);
|
||||||
|
if (gi < 0) {
|
||||||
|
// Snap to the caret, then take the glyph just right of it (or left at EOL).
|
||||||
|
const c = this.caretAt(x, y);
|
||||||
|
gi = c < this.length ? c : c - 1;
|
||||||
|
if (gi < 0 || gi >= this.length) return null;
|
||||||
|
}
|
||||||
|
const line = this.glyphs[gi].line;
|
||||||
|
if (isSpace(this.glyphs[gi].text)) return { start: gi, end: gi + 1 };
|
||||||
|
let s = gi;
|
||||||
|
let e = gi;
|
||||||
|
while (s - 1 >= 0 && this.glyphs[s - 1].line === line && !isSpace(this.glyphs[s - 1].text)) s--;
|
||||||
|
while (e + 1 < this.length && this.glyphs[e + 1].line === line && !isSpace(this.glyphs[e + 1].text)) e++;
|
||||||
|
return { start: s, end: e + 1 };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whole-line caret-range around a point. */
|
||||||
|
lineRangeAt(_x: number, y: number): CaretRange | null {
|
||||||
|
const li = this.lineAt(y);
|
||||||
|
if (li < 0) return null;
|
||||||
|
return { start: this.lines[li].start, end: this.lines[li].end };
|
||||||
|
}
|
||||||
|
|
||||||
|
selectAll(): CaretRange {
|
||||||
|
return { start: 0, end: this.length };
|
||||||
|
}
|
||||||
|
|
||||||
|
private static norm(r: CaretRange): CaretRange {
|
||||||
|
return r.start <= r.end ? r : { start: r.end, end: r.start };
|
||||||
|
}
|
||||||
|
|
||||||
|
glyphsInRange(r: CaretRange): OrderedGlyph[] {
|
||||||
|
const { start, end } = TextSelectionModel.norm(r);
|
||||||
|
return this.glyphs.slice(start, end);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reconstructed text with intra-line spaces and inter-line newlines. */
|
||||||
|
textOfRange(r: CaretRange): string {
|
||||||
|
const sel = this.glyphsInRange(r);
|
||||||
|
if (sel.length === 0) return '';
|
||||||
|
let out = '';
|
||||||
|
let prev: OrderedGlyph | null = null;
|
||||||
|
for (const g of sel) {
|
||||||
|
if (prev) {
|
||||||
|
if (g.line !== prev.line) out += '\n';
|
||||||
|
else {
|
||||||
|
const gap = g.x - prev.right;
|
||||||
|
const alreadySpace = isSpace(g.text) || isSpace(prev.text);
|
||||||
|
if (!alreadySpace && gap > g.h * 0.25) out += ' ';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out += g.text;
|
||||||
|
prev = g;
|
||||||
|
}
|
||||||
|
return out.replace(/[ \t]+\n/g, '\n').trimEnd();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Per-line union rects covering the selection (page-point space). */
|
||||||
|
rectsOfRange(r: CaretRange): SelRect[] {
|
||||||
|
const sel = this.glyphsInRange(r);
|
||||||
|
if (sel.length === 0) return [];
|
||||||
|
const byLine = new Map<number, OrderedGlyph[]>();
|
||||||
|
for (const g of sel) {
|
||||||
|
const arr = byLine.get(g.line);
|
||||||
|
if (arr) arr.push(g);
|
||||||
|
else byLine.set(g.line, [g]);
|
||||||
|
}
|
||||||
|
const rects: SelRect[] = [];
|
||||||
|
for (const arr of byLine.values()) {
|
||||||
|
const x = Math.min(...arr.map((g) => g.x));
|
||||||
|
const right = Math.max(...arr.map((g) => g.right));
|
||||||
|
// Use the full line band height for a continuous highlight, not glyph height.
|
||||||
|
const band = this.lines[arr[0].line];
|
||||||
|
rects.push({ x, y: band.top, w: right - x, h: band.bottom - band.top });
|
||||||
|
}
|
||||||
|
return rects;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Tight union of all selection rects (page-point space) — for highlight bbox. */
|
||||||
|
unionRect(r: CaretRange): SelRect | null {
|
||||||
|
const rects = this.rectsOfRange(r);
|
||||||
|
if (rects.length === 0) return null;
|
||||||
|
const x = Math.min(...rects.map((q) => q.x));
|
||||||
|
const y = Math.min(...rects.map((q) => q.y));
|
||||||
|
const right = Math.max(...rects.map((q) => q.x + q.w));
|
||||||
|
const bottom = Math.max(...rects.map((q) => q.y + q.h));
|
||||||
|
return { x, y, w: right - x, h: bottom - y };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ export type ToolId =
|
|||||||
| 'draw'
|
| 'draw'
|
||||||
| 'comment'
|
| 'comment'
|
||||||
| 'textbox'
|
| 'textbox'
|
||||||
|
| 'edit_text'
|
||||||
| 'signature'
|
| 'signature'
|
||||||
| 'stamp'
|
| 'stamp'
|
||||||
| 'redact';
|
| 'redact';
|
||||||
@@ -34,6 +35,7 @@ export const TOOL_SHORTCUTS: Record<string, ToolId> = {
|
|||||||
d: 'draw',
|
d: 'draw',
|
||||||
c: 'comment',
|
c: 'comment',
|
||||||
t: 'textbox',
|
t: 'textbox',
|
||||||
|
e: 'edit_text',
|
||||||
s: 'signature',
|
s: 'signature',
|
||||||
m: 'stamp',
|
m: 'stamp',
|
||||||
r: 'redact',
|
r: 'redact',
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import { SelectionLayer } from './SelectionLayer';
|
|||||||
import { AnnotationLayer } from './AnnotationLayer';
|
import { AnnotationLayer } from './AnnotationLayer';
|
||||||
import type { Annotation } from './AnnotationLayer';
|
import type { Annotation } from './AnnotationLayer';
|
||||||
import { OverlayLayer } from './OverlayLayer';
|
import { OverlayLayer } from './OverlayLayer';
|
||||||
|
import { TextEditLayer } from './TextEditLayer';
|
||||||
|
import type { EditableRun } from './TextEditLayer';
|
||||||
import { SearchOverlayLayer } from './SearchOverlayLayer';
|
import { SearchOverlayLayer } from './SearchOverlayLayer';
|
||||||
import type { Rect } from '../lib/coordinateMapping';
|
import type { Rect } from '../lib/coordinateMapping';
|
||||||
import { gatewayService } from '../lib/gatewayService';
|
import { gatewayService } from '../lib/gatewayService';
|
||||||
@@ -33,6 +35,7 @@ interface PDFViewerProps {
|
|||||||
onPageVisible?: (pageIndex: number) => void;
|
onPageVisible?: (pageIndex: number) => void;
|
||||||
onRedactArea?: (pageIndex: number, bounds: Rect) => void;
|
onRedactArea?: (pageIndex: number, bounds: Rect) => void;
|
||||||
onPlaceText?: (pageIndex: number, rectPts: Rect, text: string) => 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;
|
onPlaceStamp?: (pageIndex: number, pointPts: { x: number; y: number }) => void;
|
||||||
onPlaceSignature?: (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;
|
onFieldChange?: (id: string, value: string | boolean, pageIndex: number) => void;
|
||||||
@@ -72,6 +75,7 @@ export const PDFViewer = React.forwardRef<PDFViewerRef, PDFViewerProps>(({
|
|||||||
onPageVisible,
|
onPageVisible,
|
||||||
onRedactArea,
|
onRedactArea,
|
||||||
onPlaceText,
|
onPlaceText,
|
||||||
|
onEditText,
|
||||||
onPlaceStamp,
|
onPlaceStamp,
|
||||||
onPlaceSignature,
|
onPlaceSignature,
|
||||||
onFieldChange,
|
onFieldChange,
|
||||||
@@ -403,6 +407,7 @@ export const PDFViewer = React.forwardRef<PDFViewerRef, PDFViewerProps>(({
|
|||||||
height={page.height}
|
height={page.height}
|
||||||
zoom={zoom}
|
zoom={zoom}
|
||||||
glyphs={pageTexts[page.index] || []}
|
glyphs={pageTexts[page.index] || []}
|
||||||
|
mode={activeTool === 'highlight' ? 'highlight' : 'select'}
|
||||||
onTextSelected={(text, bbox) => handleTextSelection(text, bbox, page.index)}
|
onTextSelected={(text, bbox) => handleTextSelection(text, bbox, page.index)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -436,6 +441,18 @@ export const PDFViewer = React.forwardRef<PDFViewerRef, PDFViewerProps>(({
|
|||||||
onPlaceSignature={onPlaceSignature}
|
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 */}
|
{/* Search highlights overlay */}
|
||||||
<SearchOverlayLayer
|
<SearchOverlayLayer
|
||||||
pageIndex={page.index}
|
pageIndex={page.index}
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
import React, { useState, useRef, useMemo } from 'react';
|
import React, { useState, useRef, useMemo, useEffect, useCallback } from 'react';
|
||||||
import type { Point, Rect } from '../lib/coordinateMapping';
|
import type { Point, Rect } from '../lib/coordinateMapping';
|
||||||
import type { Glyph } from '../lib/gatewayService';
|
import type { Glyph } from '../lib/gatewayService';
|
||||||
|
import { TextSelectionModel, type CaretRange } from '../lib/textSelection';
|
||||||
|
|
||||||
|
type Granularity = 'caret' | 'word' | 'line';
|
||||||
|
|
||||||
interface SelectionLayerProps {
|
interface SelectionLayerProps {
|
||||||
pageIndex: number;
|
pageIndex: number;
|
||||||
@@ -8,118 +11,201 @@ interface SelectionLayerProps {
|
|||||||
height: number;
|
height: number;
|
||||||
zoom: number;
|
zoom: number;
|
||||||
glyphs: Glyph[];
|
glyphs: Glyph[];
|
||||||
|
/** 'select' keeps the selection live for copy; 'highlight' emits on mouse-up. */
|
||||||
|
mode?: 'select' | 'highlight';
|
||||||
onTextSelected?: (text: string, bbox: Rect) => void;
|
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) {
|
export const SelectionLayer: React.FC<SelectionLayerProps> = ({
|
||||||
return ax < b.x + b.width && ax + aw > b.x && ay < b.y + b.height && ay + ah > b.y;
|
pageIndex,
|
||||||
}
|
width,
|
||||||
|
height,
|
||||||
export const SelectionLayer: React.FC<SelectionLayerProps> = ({ width, height, zoom, glyphs, onTextSelected }) => {
|
zoom,
|
||||||
const [dragStart, setDragStart] = useState<Point | null>(null);
|
glyphs,
|
||||||
const [selectionBox, setSelectionBox] = useState<Rect | null>(null);
|
mode = 'select',
|
||||||
|
onTextSelected,
|
||||||
|
}) => {
|
||||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const model = useMemo(() => new TextSelectionModel(glyphs), [glyphs]);
|
||||||
|
|
||||||
// Glyphs projected into zoomed pixel space once per glyphs/zoom change.
|
// Caret selection {start: anchor, end: focus}; unnormalised (start may exceed end).
|
||||||
const zGlyphs = useMemo<ZGlyph[]>(
|
const [sel, setSel] = useState<CaretRange | null>(null);
|
||||||
() => glyphs.map((g) => ({ gx: g.x * zoom, gy: g.y * zoom, gw: g.w * zoom, gh: g.h * zoom, text: g.text })),
|
const selRef = useRef<CaretRange | null>(null);
|
||||||
[glyphs, zoom],
|
selRef.current = sel;
|
||||||
);
|
|
||||||
|
|
||||||
// Glyphs covered by the current drag box (for live highlight feedback).
|
// Active drag (null when idle). `base` is the range fixed at mouse-down.
|
||||||
const hitGlyphs = useMemo<ZGlyph[]>(() => {
|
const drag = useRef<{ granularity: Granularity; base: CaretRange } | null>(null);
|
||||||
if (!selectionBox || zGlyphs.length === 0) return [];
|
|
||||||
return zGlyphs.filter((g) => rectsIntersect(g.gx, g.gy, g.gw, g.gh, selectionBox));
|
|
||||||
}, [selectionBox, zGlyphs]);
|
|
||||||
|
|
||||||
const point = (e: React.MouseEvent) => {
|
// --- mock-mode fallback (no glyph geometry): raw drag box -------------------
|
||||||
const rect = containerRef.current!.getBoundingClientRect();
|
const [box, setBox] = useState<Rect | null>(null);
|
||||||
return { x: e.clientX - rect.left, y: e.clientY - rect.top };
|
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) => {
|
const handleMouseDown = (e: React.MouseEvent) => {
|
||||||
if (!containerRef.current) return;
|
if (!containerRef.current || e.button !== 0) return;
|
||||||
const p = point(e);
|
e.preventDefault();
|
||||||
setDragStart(p);
|
window.dispatchEvent(new CustomEvent<number>(SEL_START_EVT, { detail: pageIndex }));
|
||||||
setSelectionBox({ x: p.x, y: p.y, width: 0, height: 0 });
|
|
||||||
|
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) => {
|
const handleMouseMove = (e: React.MouseEvent) => {
|
||||||
if (!dragStart || !containerRef.current) return;
|
if (!containerRef.current) return;
|
||||||
const p = point(e);
|
if (!hasGlyphs) {
|
||||||
setSelectionBox({
|
if (!boxStart.current) return;
|
||||||
x: Math.min(dragStart.x, p.x),
|
const r = containerRef.current.getBoundingClientRect();
|
||||||
y: Math.min(dragStart.y, p.y),
|
const px = { x: e.clientX - r.left, y: e.clientY - r.top };
|
||||||
width: Math.abs(dragStart.x - p.x),
|
setBox({
|
||||||
height: Math.abs(dragStart.y - p.y),
|
x: Math.min(boxStart.current.x, px.x),
|
||||||
|
y: Math.min(boxStart.current.y, px.y),
|
||||||
|
width: Math.abs(boxStart.current.x - px.x),
|
||||||
|
height: Math.abs(boxStart.current.y - px.y),
|
||||||
});
|
});
|
||||||
};
|
return;
|
||||||
|
|
||||||
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 lines
|
if (!drag.current) return;
|
||||||
.map((line) =>
|
const p = toPage(e);
|
||||||
line
|
setSel(extend(drag.current.base, drag.current.granularity, p));
|
||||||
.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();
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleMouseUp = () => {
|
const handleMouseUp = () => {
|
||||||
if (selectionBox && selectionBox.width > 3 && selectionBox.height > 3) {
|
// Mock-mode: report the raw drag box so highlighting still works.
|
||||||
if (hitGlyphs.length > 0) {
|
if (!hasGlyphs) {
|
||||||
// Tight union bbox around actually-selected glyphs (zoomed px).
|
if (box && box.width > 3 && box.height > 3) onTextSelected?.('', box);
|
||||||
const minX = Math.min(...hitGlyphs.map((g) => g.gx));
|
setBox(null);
|
||||||
const minY = Math.min(...hitGlyphs.map((g) => g.gy));
|
boxStart.current = null;
|
||||||
const maxX = Math.max(...hitGlyphs.map((g) => g.gx + g.gw));
|
return;
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
drag.current = null;
|
||||||
|
const cur = selRef.current;
|
||||||
|
if (!cur || cur.start === cur.end) {
|
||||||
|
setSel(null);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
setDragStart(null);
|
if (mode === 'highlight') {
|
||||||
setSelectionBox(null);
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={containerRef}
|
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` }}
|
style={{ width: `${width}px`, height: `${height}px` }}
|
||||||
onMouseDown={handleMouseDown}
|
onMouseDown={handleMouseDown}
|
||||||
onMouseMove={handleMouseMove}
|
onMouseMove={handleMouseMove}
|
||||||
onMouseUp={handleMouseUp}
|
onMouseUp={handleMouseUp}
|
||||||
onMouseLeave={handleMouseUp}
|
onMouseLeave={() => {
|
||||||
|
if (drag.current || boxStart.current) handleMouseUp();
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{/* Live per-glyph highlight */}
|
{rects.map((q, i) => (
|
||||||
{hitGlyphs.map((g, i) => (
|
<div
|
||||||
<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 }} />
|
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) */}
|
{box && (
|
||||||
{selectionBox && hitGlyphs.length === 0 && (
|
<div
|
||||||
<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 }} />
|
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>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,152 @@
|
|||||||
|
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import { gatewayService } from '../lib/gatewayService';
|
||||||
|
|
||||||
|
// A single editable run, in PDF BOTTOM-LEFT page space (as getPageModel returns).
|
||||||
|
export interface EditableRun {
|
||||||
|
text: string;
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
w: number;
|
||||||
|
h: number;
|
||||||
|
fontSize: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TextEditLayerProps {
|
||||||
|
documentId: string;
|
||||||
|
pageIndex: number;
|
||||||
|
width: number; // page width in ZOOMED px (= widthPts * zoom)
|
||||||
|
height: number; // page height in ZOOMED px (= heightPts * zoom)
|
||||||
|
zoom: number;
|
||||||
|
onEditText?: (pageIndex: number, run: EditableRun, newText: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Flatten the dynamic page-model JSON into a flat list of editable runs.
|
||||||
|
function flattenRuns(model: any): EditableRun[] {
|
||||||
|
const runs: EditableRun[] = [];
|
||||||
|
const paragraphs = model?.paragraphs ?? [];
|
||||||
|
for (const p of paragraphs) {
|
||||||
|
for (const line of p.lines ?? []) {
|
||||||
|
for (const r of line.runs ?? []) {
|
||||||
|
if (typeof r.text === 'string' && r.text.trim() && r.w > 0 && r.h > 0) {
|
||||||
|
runs.push({ text: r.text, x: r.x, y: r.y, w: r.w, h: r.h, fontSize: r.font_size ?? r.h });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return runs;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TextEditLayer: React.FC<TextEditLayerProps> = ({
|
||||||
|
documentId,
|
||||||
|
pageIndex,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
zoom,
|
||||||
|
onEditText,
|
||||||
|
}) => {
|
||||||
|
const [runs, setRuns] = useState<EditableRun[]>([]);
|
||||||
|
const [editing, setEditing] = useState<number | null>(null);
|
||||||
|
const [value, setValue] = useState('');
|
||||||
|
const committedRef = useRef(false);
|
||||||
|
|
||||||
|
// Fetch the page model once per document/page.
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
setEditing(null);
|
||||||
|
gatewayService
|
||||||
|
.getPageModel(documentId, pageIndex)
|
||||||
|
.then((model) => {
|
||||||
|
if (!cancelled) setRuns(flattenRuns(model));
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (!cancelled) setRuns([]);
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [documentId, pageIndex]);
|
||||||
|
|
||||||
|
// Page height in points (props are zoomed px) — needed for the bottom-left→top-left flip.
|
||||||
|
const heightPts = height / zoom;
|
||||||
|
|
||||||
|
// Display rect (top-left, zoomed px) for a run whose coords are bottom-left points.
|
||||||
|
const rectOf = (r: EditableRun) => ({
|
||||||
|
left: r.x * zoom,
|
||||||
|
top: (heightPts - (r.y + r.h)) * zoom,
|
||||||
|
width: r.w * zoom,
|
||||||
|
height: r.h * zoom,
|
||||||
|
});
|
||||||
|
|
||||||
|
const openEditor = (i: number) => {
|
||||||
|
committedRef.current = false;
|
||||||
|
setEditing(i);
|
||||||
|
setValue(runs[i].text);
|
||||||
|
};
|
||||||
|
|
||||||
|
const commit = () => {
|
||||||
|
if (editing === null || committedRef.current) return;
|
||||||
|
committedRef.current = true;
|
||||||
|
const run = runs[editing];
|
||||||
|
const next = value;
|
||||||
|
setEditing(null);
|
||||||
|
if (next !== run.text) onEditText?.(pageIndex, run, next);
|
||||||
|
};
|
||||||
|
|
||||||
|
const cancel = () => {
|
||||||
|
committedRef.current = true;
|
||||||
|
setEditing(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const editingRect = useMemo(
|
||||||
|
() => (editing !== null && runs[editing] ? rectOf(runs[editing]) : null),
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
[editing, runs, zoom, height],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="absolute top-0 left-0 z-[35]" style={{ width: `${width}px`, height: `${height}px` }}>
|
||||||
|
{/* Per-run hit targets (visible hint on hover). */}
|
||||||
|
{editing === null &&
|
||||||
|
runs.map((r, i) => {
|
||||||
|
const box = rectOf(r);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
title="Click to edit this text"
|
||||||
|
onClick={() => openEditor(i)}
|
||||||
|
className="absolute cursor-text rounded-[2px] hover:bg-[rgba(37,99,235,0.12)] hover:outline hover:outline-1 hover:outline-[#2563eb]"
|
||||||
|
style={{ left: box.left, top: box.top, width: box.width, height: box.height }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* Inline editor for the selected run. */}
|
||||||
|
{editing !== null && editingRect && (
|
||||||
|
<textarea
|
||||||
|
autoFocus
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => setValue(e.target.value)}
|
||||||
|
onBlur={commit}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter' && !e.shiftKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
commit();
|
||||||
|
}
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
e.preventDefault();
|
||||||
|
cancel();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="absolute z-[36] bg-white border-[1.5px] border-[#2563eb] rounded-[3px] shadow-[0_4px_12px_rgba(16,24,40,0.12)] outline-none resize-none overflow-hidden font-sans leading-[1.1] px-[3px] py-[1px] whitespace-nowrap"
|
||||||
|
style={{
|
||||||
|
left: editingRect.left,
|
||||||
|
top: editingRect.top,
|
||||||
|
minWidth: editingRect.width,
|
||||||
|
height: Math.max(editingRect.height, (runs[editing].fontSize + 4) * zoom),
|
||||||
|
fontSize: `${runs[editing].fontSize * zoom}px`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -168,8 +168,29 @@ class PageReorderOperation(BaseModel):
|
|||||||
data: PageReorderData
|
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):
|
class UpdateFieldData(BaseModel):
|
||||||
value: str | bool
|
value: str | bool
|
||||||
|
annotationId: str | None = None
|
||||||
|
|
||||||
|
|
||||||
class UpdateFieldOperation(BaseModel):
|
class UpdateFieldOperation(BaseModel):
|
||||||
@@ -221,7 +242,8 @@ EditOperation = Annotated[
|
|||||||
| PageReorderOperation
|
| PageReorderOperation
|
||||||
| UpdateFieldOperation
|
| UpdateFieldOperation
|
||||||
| DeleteAnnotationOperation
|
| DeleteAnnotationOperation
|
||||||
| UpdateAnnotationOperation,
|
| UpdateAnnotationOperation
|
||||||
|
| EditTextOperation,
|
||||||
Field(discriminator="type"),
|
Field(discriminator="type"),
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -295,8 +317,11 @@ def apply_edits_impl(document_id: str, request: EditsRequest):
|
|||||||
edits_json = json.dumps(req_dict)
|
edits_json = json.dumps(req_dict)
|
||||||
doc.apply_edits(edits_json)
|
doc.apply_edits(edits_json)
|
||||||
|
|
||||||
has_redaction = any(op.get("type") == "redaction" for op in req_dict.get("operations", []))
|
# Redaction and in-place text rewrites mutate existing objects, which do
|
||||||
new_bytes = doc.save_full() if has_redaction else doc.save_incremental()
|
# 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)
|
new_doc = pdfengine.PdfDocument.load_from_memory(new_bytes)
|
||||||
|
|
||||||
|
|||||||
@@ -56,7 +56,14 @@ def extract_page_text(document_id: str, page_index: Annotated[int, Path(ge=0)]):
|
|||||||
annots = page.extract_annotations_text()
|
annots = page.extract_annotations_text()
|
||||||
if annots:
|
if annots:
|
||||||
text += "\n" + "\n".join(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()
|
glyphs = page.extract_text_with_bounds()
|
||||||
|
for g in glyphs:
|
||||||
|
g["y"] = page_height - (g["y"] + g["h"])
|
||||||
return {"text": text, "glyphs": glyphs}
|
return {"text": text, "glyphs": glyphs}
|
||||||
except IndexError:
|
except IndexError:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
|
|||||||
@@ -0,0 +1,130 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Download a large PDF corpus for fuzzing into a gitignored directory.
|
||||||
|
|
||||||
|
The committed `corpus/` holds a small, curated set of fixtures used by the
|
||||||
|
rendering-regression baseline. Fuzzing wants *volume and variety* instead, so we
|
||||||
|
pull hundreds of real-world PDFs from public test suites into `corpus/fuzz/`,
|
||||||
|
which is gitignored.
|
||||||
|
|
||||||
|
python scripts/fetch_corpus.py # default: ~600 from pdf.js
|
||||||
|
python scripts/fetch_corpus.py --limit 200
|
||||||
|
python scripts/fetch_corpus.py --source pdfium # GoogleTest pdfium corpus
|
||||||
|
|
||||||
|
Network failures are tolerated: whatever downloads is usable, and re-running only
|
||||||
|
fetches what's missing. Only standard-library modules are used.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# Stable raw-file base for the pinned manifest (filename → bytes).
|
||||||
|
_RAW_BASE = "https://raw.githubusercontent.com/mozilla/pdf.js/master/test/pdfs"
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
DEST = ROOT / "corpus" / "fuzz"
|
||||||
|
|
||||||
|
# GitHub "contents" API listings of directories full of .pdf files.
|
||||||
|
SOURCES = {
|
||||||
|
"pdfjs": "https://api.github.com/repos/mozilla/pdf.js/contents/test/pdfs?ref=master",
|
||||||
|
"pdfium": "https://api.github.com/repos/PDFium/pdfium/contents/testing/resources?ref=main",
|
||||||
|
}
|
||||||
|
|
||||||
|
_HEADERS = {"User-Agent": "pdfengine-fetch-corpus", "Accept": "application/vnd.github+json"}
|
||||||
|
|
||||||
|
|
||||||
|
def _get(url: str, raw: bool = False) -> bytes:
|
||||||
|
req = urllib.request.Request(url, headers=_HEADERS)
|
||||||
|
with urllib.request.urlopen(req, timeout=30) as resp: # noqa: S310 (trusted hosts)
|
||||||
|
return resp.read()
|
||||||
|
|
||||||
|
|
||||||
|
def _fetch_from_manifest(manifest_path: Path, dest: Path) -> int:
|
||||||
|
"""Download exactly the files in the pinned manifest and verify their SHA-256,
|
||||||
|
so every developer and CI run gets a byte-identical corpus."""
|
||||||
|
manifest = json.loads(manifest_path.read_text())
|
||||||
|
files = manifest.get("files", [])
|
||||||
|
print(f"Manifest: {len(files)} pinned files -> {dest} (verifying SHA-256)")
|
||||||
|
got = existed = failed = mismatch = 0
|
||||||
|
for entry in files:
|
||||||
|
name, want = entry["name"], entry["sha256"]
|
||||||
|
out = dest / name
|
||||||
|
if out.exists() and hashlib.sha256(out.read_bytes()).hexdigest() == want:
|
||||||
|
existed += 1
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
data = _get(f"{_RAW_BASE}/{name}", raw=True)
|
||||||
|
except (urllib.error.URLError, OSError) as exc:
|
||||||
|
print(f" fail {name} ({exc})")
|
||||||
|
failed += 1
|
||||||
|
continue
|
||||||
|
have = hashlib.sha256(data).hexdigest()
|
||||||
|
if have != want:
|
||||||
|
print(f" MISMATCH {name}: expected {want[:12]}..., got {have[:12]}... (skipped)")
|
||||||
|
mismatch += 1
|
||||||
|
continue
|
||||||
|
out.write_bytes(data)
|
||||||
|
got += 1
|
||||||
|
if got % 50 == 0:
|
||||||
|
print(f" ... {got} verified")
|
||||||
|
total = len(list(dest.glob("*.pdf")))
|
||||||
|
print(f"\nDone. +{got} new, {existed} already present & verified, "
|
||||||
|
f"{failed} failed, {mismatch} hash-mismatch.")
|
||||||
|
print(f"Corpus now holds {total} PDFs at {dest}")
|
||||||
|
return 0 if mismatch == 0 else 1
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
ap = argparse.ArgumentParser(description=__doc__)
|
||||||
|
ap.add_argument("--source", choices=sorted(SOURCES), default="pdfjs")
|
||||||
|
ap.add_argument("--limit", type=int, default=600, help="max files to download")
|
||||||
|
ap.add_argument("--dest", type=Path, default=DEST)
|
||||||
|
ap.add_argument("--manifest", type=Path, default=None,
|
||||||
|
help="reproduce the exact pinned corpus from a manifest (verifies SHA-256)")
|
||||||
|
args = ap.parse_args()
|
||||||
|
|
||||||
|
args.dest.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
# Manifest mode: deterministic, hash-verified, identical for everyone.
|
||||||
|
if args.manifest:
|
||||||
|
return _fetch_from_manifest(args.manifest, args.dest)
|
||||||
|
|
||||||
|
print(f"Listing {args.source} corpus ...")
|
||||||
|
try:
|
||||||
|
listing = json.loads(_get(SOURCES[args.source]))
|
||||||
|
except (urllib.error.URLError, json.JSONDecodeError) as exc:
|
||||||
|
print(f"ERROR: could not list corpus ({exc}). Check your network / GitHub rate limit.")
|
||||||
|
return 2
|
||||||
|
|
||||||
|
pdfs = [e for e in listing if e.get("name", "").lower().endswith(".pdf") and e.get("download_url")]
|
||||||
|
print(f"Found {len(pdfs)} PDFs; downloading up to {args.limit} into {args.dest} ...")
|
||||||
|
|
||||||
|
got = failed = existed = 0
|
||||||
|
for entry in pdfs[: args.limit]:
|
||||||
|
out = args.dest / entry["name"]
|
||||||
|
if out.exists() and out.stat().st_size > 0:
|
||||||
|
existed += 1
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
out.write_bytes(_get(entry["download_url"], raw=True))
|
||||||
|
got += 1
|
||||||
|
if got % 25 == 0:
|
||||||
|
print(f" ... {got} downloaded")
|
||||||
|
except (urllib.error.URLError, OSError) as exc:
|
||||||
|
print(f" fail {entry['name']} ({exc})")
|
||||||
|
failed += 1
|
||||||
|
|
||||||
|
total = len(list(args.dest.glob("*.pdf")))
|
||||||
|
print(f"\nDone. +{got} new, {existed} already present, {failed} failed.")
|
||||||
|
print(f"Corpus now holds {total} PDFs at {args.dest}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Round-trip tests for the `edit_text` op (line-level text rewriting).
|
||||||
|
|
||||||
|
Verifies that editing an existing text object: (1) actually changes the rendered
|
||||||
|
text, (2) keeps the result within the original line's horizontal bounds (the
|
||||||
|
acceptance criterion), and (3) fails closed when the target bbox matches nothing.
|
||||||
|
|
||||||
|
Run directly: gateway/.venv/Scripts/python.exe tests/edits/test_edit_text.py
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
sys.path.insert(0, str(ROOT / "gateway"))
|
||||||
|
import pdfengine # noqa: E402
|
||||||
|
|
||||||
|
CORPUS = ROOT / "corpus" / "fonts" / "utf-8.pdf"
|
||||||
|
EPS = 2.0 # points; tolerance for rounding / glyph metrics
|
||||||
|
|
||||||
|
|
||||||
|
def _load():
|
||||||
|
return pdfengine.PdfDocument.load_from_memory(CORPUS.read_bytes(), "")
|
||||||
|
|
||||||
|
|
||||||
|
def _runs(model):
|
||||||
|
return [r for p in model.paragraphs for l in p.lines for r in l.runs]
|
||||||
|
|
||||||
|
|
||||||
|
def _first_run(model):
|
||||||
|
return model.paragraphs[0].lines[0].runs[0]
|
||||||
|
|
||||||
|
|
||||||
|
def _edit_op(model, run, new_text: str):
|
||||||
|
# Engine model coords are PDFium-native BOTTOM-LEFT (y = bbox bottom), the same
|
||||||
|
# space the op + FPDFPageObj_GetBounds use — so no flip here.
|
||||||
|
return {
|
||||||
|
"version": "1.0",
|
||||||
|
"operations": [{
|
||||||
|
"id": "e1", "type": "edit_text", "pageIndex": 0,
|
||||||
|
"data": {
|
||||||
|
"x": run.x,
|
||||||
|
"y": run.y,
|
||||||
|
"width": run.w,
|
||||||
|
"height": run.h,
|
||||||
|
"newText": new_text,
|
||||||
|
"originalText": run.text,
|
||||||
|
"fontSize": run.font_size,
|
||||||
|
"fallbackFont": "Helvetica",
|
||||||
|
},
|
||||||
|
}],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_roundtrip_replaces_text():
|
||||||
|
doc = _load()
|
||||||
|
model = doc.get_page(0).extract_document_model()
|
||||||
|
run = _first_run(model)
|
||||||
|
orig_x, orig_w, orig_y = run.x, run.w, run.y
|
||||||
|
doc.apply_edits(json.dumps(_edit_op(model, run, "REPLACED")))
|
||||||
|
out = doc.save_full()
|
||||||
|
|
||||||
|
m2 = pdfengine.PdfDocument.load_from_memory(out, "").get_page(0).extract_document_model()
|
||||||
|
edited = next((r for r in _runs(m2) if "REPLACED" in r.text), None)
|
||||||
|
assert edited is not None, "edited text 'REPLACED' not found after round-trip"
|
||||||
|
# stays within the original horizontal bounds
|
||||||
|
assert edited.x >= orig_x - EPS, f"left moved out of bounds: {edited.x} < {orig_x}"
|
||||||
|
assert edited.x + edited.w <= orig_x + orig_w + EPS, \
|
||||||
|
f"right overflowed bounds: {edited.x + edited.w} > {orig_x + orig_w}"
|
||||||
|
# baseline / vertical position preserved
|
||||||
|
assert abs(edited.y - orig_y) < EPS + run.h, f"baseline drifted: {edited.y} vs {orig_y}"
|
||||||
|
print(f" ok roundtrip: '{run.text[:20]}' -> '{edited.text[:20]}' "
|
||||||
|
f"(x {orig_x:.0f}..{orig_x + orig_w:.0f} -> {edited.x:.0f}..{edited.x + edited.w:.0f})")
|
||||||
|
|
||||||
|
|
||||||
|
def test_long_text_squeezes_within_bounds():
|
||||||
|
doc = _load()
|
||||||
|
model = doc.get_page(0).extract_document_model()
|
||||||
|
run = _first_run(model)
|
||||||
|
orig_right = run.x + run.w
|
||||||
|
long_text = "This is a deliberately very long replacement string to force squeeze"
|
||||||
|
doc.apply_edits(json.dumps(_edit_op(model, run, long_text)))
|
||||||
|
out = doc.save_full()
|
||||||
|
|
||||||
|
m2 = pdfengine.PdfDocument.load_from_memory(out, "").get_page(0).extract_document_model()
|
||||||
|
edited = next((r for r in _runs(m2) if "deliberately" in r.text), None)
|
||||||
|
assert edited is not None, "long edited text not found"
|
||||||
|
assert edited.x + edited.w <= orig_right + EPS, \
|
||||||
|
f"long text overflowed: {edited.x + edited.w:.1f} > {orig_right:.1f}"
|
||||||
|
print(f" ok squeeze: {len(long_text)} chars fit in {run.w:.0f}pt "
|
||||||
|
f"(right {edited.x + edited.w:.0f} <= {orig_right:.0f})")
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_match_fails():
|
||||||
|
doc = _load()
|
||||||
|
model = doc.get_page(0).extract_document_model()
|
||||||
|
op = {
|
||||||
|
"version": "1.0",
|
||||||
|
"operations": [{
|
||||||
|
"id": "e1", "type": "edit_text", "pageIndex": 0,
|
||||||
|
"data": {"x": 5000.0, "y": 5000.0, "width": 50.0, "height": 10.0,
|
||||||
|
"newText": "X", "fallbackFont": "Helvetica"},
|
||||||
|
}],
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
doc.apply_edits(json.dumps(op))
|
||||||
|
except Exception:
|
||||||
|
print(" ok no-match correctly raised")
|
||||||
|
return
|
||||||
|
raise AssertionError("edit_text with an off-page bbox should have failed, but succeeded")
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
tests = [test_roundtrip_replaces_text, test_long_text_squeezes_within_bounds, test_no_match_fails]
|
||||||
|
failed = 0
|
||||||
|
for t in tests:
|
||||||
|
try:
|
||||||
|
t()
|
||||||
|
except AssertionError as exc:
|
||||||
|
print(f" FAIL {t.__name__}: {exc}")
|
||||||
|
failed += 1
|
||||||
|
print(f"\n{len(tests) - failed}/{len(tests)} passed.")
|
||||||
|
return 1 if failed else 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
# Rendering-regression harness
|
||||||
|
|
||||||
|
Renders the committed `corpus/` with the PDFium engine and compares each page to
|
||||||
|
a **frozen baseline** using SSIM. A failure means *this* build renders
|
||||||
|
differently from the blessed build — not that it disagrees with Acrobat.
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- The engine is built and copied to `gateway/` (run `scripts/build_cpp.ps1 -Preset win-local-pdfium`).
|
||||||
|
- The gateway venv has `numpy` and `Pillow` (`gateway/.venv`).
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Check the current build against the frozen baseline (CI mode; non-zero exit on regression)
|
||||||
|
gateway/.venv/Scripts/python.exe tests/regression/run.py
|
||||||
|
|
||||||
|
# Re-freeze the baseline after an *intentional* rendering change (review the diff!)
|
||||||
|
gateway/.venv/Scripts/python.exe tests/regression/run.py --update
|
||||||
|
```
|
||||||
|
|
||||||
|
Options: `--dpi 72`, `--max-pages 2`, `--threshold 0.990`, `--corpus`, `--baseline`.
|
||||||
|
|
||||||
|
## Large corpus (500+ real-world PDFs)
|
||||||
|
|
||||||
|
The corpus is **pinned and shared via a manifest** so every developer and CI run
|
||||||
|
gets a byte-identical set. The 600 PDFs themselves are gitignored (≈68 MB); only
|
||||||
|
the manifest (`corpus-manifest.json`, name + SHA-256 per file, ≈96 KB) is
|
||||||
|
committed. Reproduce the exact corpus with:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python scripts/fetch_corpus.py --manifest tests/regression/corpus-manifest.json
|
||||||
|
```
|
||||||
|
|
||||||
|
This downloads each pinned file and **verifies its SHA-256** — a mismatch is
|
||||||
|
reported and skipped, so the corpus can never silently drift between machines.
|
||||||
|
(`scripts/fetch_corpus.py` with no `--manifest` just grabs "latest" from pdf.js,
|
||||||
|
which is *not* reproducible — use it only to refresh/regenerate the manifest.)
|
||||||
|
|
||||||
|
Two ways to use the corpus for regression:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Render-stability sweep — no baseline; passes as long as the engine never
|
||||||
|
# crashes on any real-world PDF. The CI-friendly form of a 500+ regression.
|
||||||
|
python scripts/fetch_corpus.py
|
||||||
|
gateway/.venv/Scripts/python.exe tests/regression/run.py --sweep --corpus corpus/fuzz --max-pages 3
|
||||||
|
|
||||||
|
# 2. SSIM baseline over the large corpus (baseline is gitignored — it is derived
|
||||||
|
# from gitignored inputs and is ~tens of MB, so it lives locally / as a CI
|
||||||
|
# artifact, not in git).
|
||||||
|
... run.py --update --corpus corpus/fuzz --baseline tests/regression/baseline-large
|
||||||
|
... run.py --corpus corpus/fuzz --baseline tests/regression/baseline-large
|
||||||
|
```
|
||||||
|
|
||||||
|
The committed `baseline/` stays the small curated set (reviewable in PRs);
|
||||||
|
`baseline-large/` is the throwaway large-corpus reference. In CI the libFuzzer
|
||||||
|
binary doubles as a sweep via `pdfengine_fuzz -runs=0 corpus/fuzz/` (see
|
||||||
|
`.github/workflows/fuzz.yml`).
|
||||||
|
|
||||||
|
## How it works
|
||||||
|
|
||||||
|
- `ssim.py` — dependency-light SSIM (numpy only) via an integral-image box filter.
|
||||||
|
Discriminates correctly: identical→1.0, shifted→~0.81, noise→~0.00, shape
|
||||||
|
mismatch→0.0.
|
||||||
|
- `run.py` — enumerates `corpus/**/*.pdf` (excluding the gitignored `corpus/fuzz/`),
|
||||||
|
renders to grayscale, and scores against `baseline/*.png`.
|
||||||
|
- `baseline/` — committed PNGs. Because the baseline is the engine's *own* prior
|
||||||
|
output, it is tied to this PDFium version; re-freeze deliberately when PDFium
|
||||||
|
is upgraded.
|
||||||
|
|
||||||
|
Encrypted / intentionally-malformed fixtures are skipped (and reported), not
|
||||||
|
failed.
|
||||||
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 550 B |
|
After Width: | Height: | Size: 218 B |
|
After Width: | Height: | Size: 237 B |
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 4.1 KiB |
|
After Width: | Height: | Size: 675 B |
|
After Width: | Height: | Size: 464 B |
|
After Width: | Height: | Size: 426 B |
|
After Width: | Height: | Size: 300 B |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 6.3 KiB |
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 2.9 KiB |
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 2.3 KiB |
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 866 B |
|
After Width: | Height: | Size: 866 B |
|
After Width: | Height: | Size: 3.3 KiB |
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 2.5 KiB |
|
After Width: | Height: | Size: 6.1 KiB |
|
After Width: | Height: | Size: 2.7 KiB |
|
After Width: | Height: | Size: 6.5 KiB |
|
After Width: | Height: | Size: 697 B |
|
After Width: | Height: | Size: 2.7 KiB |
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 7.9 KiB |
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 2.6 KiB |
|
After Width: | Height: | Size: 3.0 KiB |
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 2.7 KiB |
|
After Width: | Height: | Size: 2.3 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 6.4 KiB |
@@ -0,0 +1,139 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Rendering-regression harness for the PDFium engine.
|
||||||
|
|
||||||
|
Renders every page (capped) of every PDF in the committed corpus and compares it
|
||||||
|
to a frozen baseline using SSIM. The baseline is the engine's own output at the
|
||||||
|
moment it was approved — so a regression here means *this* build renders
|
||||||
|
differently from the blessed build, not that it disagrees with Acrobat.
|
||||||
|
|
||||||
|
python tests/regression/run.py --update # (re)generate the frozen baseline
|
||||||
|
python tests/regression/run.py # check current renders vs baseline
|
||||||
|
|
||||||
|
Exit code is non-zero if any page regresses below the SSIM threshold, so it
|
||||||
|
drops straight into CI.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import io
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||||
|
from ssim import ssim # noqa: E402
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
DEFAULT_CORPUS = ROOT / "corpus"
|
||||||
|
DEFAULT_BASELINE = Path(__file__).resolve().parent / "baseline"
|
||||||
|
# corpus subdirs that are *not* part of the frozen baseline (large/downloaded).
|
||||||
|
EXCLUDE_DIRS = {"fuzz"}
|
||||||
|
|
||||||
|
|
||||||
|
def _import_engine():
|
||||||
|
# The compiled extension lives in gateway/ after scripts/build_cpp.ps1.
|
||||||
|
sys.path.insert(0, str(ROOT / "gateway"))
|
||||||
|
import pdfengine # noqa: PLC0415
|
||||||
|
|
||||||
|
return pdfengine
|
||||||
|
|
||||||
|
|
||||||
|
def _render_gray(page, dpi: int) -> np.ndarray:
|
||||||
|
png = page.render(dpi).data
|
||||||
|
img = Image.open(io.BytesIO(png)).convert("L")
|
||||||
|
return np.asarray(img)
|
||||||
|
|
||||||
|
|
||||||
|
def _corpus_pdfs(corpus: Path):
|
||||||
|
for p in sorted(corpus.rglob("*.pdf")):
|
||||||
|
if any(part in EXCLUDE_DIRS for part in p.relative_to(corpus).parts):
|
||||||
|
continue
|
||||||
|
yield p
|
||||||
|
|
||||||
|
|
||||||
|
def _key(rel: Path, page_index: int) -> str:
|
||||||
|
return f"{rel.as_posix().replace('/', '__')}__p{page_index}"
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
ap = argparse.ArgumentParser(description=__doc__)
|
||||||
|
ap.add_argument("--update", action="store_true", help="write the frozen baseline instead of checking")
|
||||||
|
ap.add_argument("--sweep", action="store_true",
|
||||||
|
help="render-stability sweep over a large corpus (no baseline); "
|
||||||
|
"passes as long as the engine never crashes")
|
||||||
|
ap.add_argument("--corpus", type=Path, default=DEFAULT_CORPUS)
|
||||||
|
ap.add_argument("--baseline", type=Path, default=DEFAULT_BASELINE)
|
||||||
|
ap.add_argument("--dpi", type=int, default=72)
|
||||||
|
ap.add_argument("--max-pages", type=int, default=2, help="pages rendered per document")
|
||||||
|
ap.add_argument("--threshold", type=float, default=0.990, help="min SSIM to pass")
|
||||||
|
args = ap.parse_args()
|
||||||
|
|
||||||
|
pdfengine = _import_engine()
|
||||||
|
args.baseline.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
failures: list[tuple[str, float]] = []
|
||||||
|
checked = skipped = updated = rendered = 0
|
||||||
|
|
||||||
|
for pdf in _corpus_pdfs(args.corpus):
|
||||||
|
rel = pdf.relative_to(args.corpus)
|
||||||
|
try:
|
||||||
|
doc = pdfengine.PdfDocument.load_from_memory(pdf.read_bytes(), "")
|
||||||
|
n = min(doc.page_count, args.max_pages)
|
||||||
|
except Exception as exc: # encrypted / intentionally-malformed fixtures
|
||||||
|
print(f" skip {rel} ({exc})")
|
||||||
|
skipped += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
for i in range(n):
|
||||||
|
try:
|
||||||
|
cur = _render_gray(doc.get_page(i), args.dpi)
|
||||||
|
except Exception as exc:
|
||||||
|
print(f" skip {rel} p{i} (render: {exc})")
|
||||||
|
skipped += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
if args.sweep:
|
||||||
|
# Surviving the render is the whole test; a crash kills the process.
|
||||||
|
rendered += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
ref_path = args.baseline / f"{_key(rel, i)}.png"
|
||||||
|
if args.update:
|
||||||
|
Image.fromarray(cur).save(ref_path)
|
||||||
|
updated += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
if not ref_path.exists():
|
||||||
|
print(f" NEW {rel} p{i} (no baseline — run --update)")
|
||||||
|
failures.append((f"{rel} p{i}", -1.0))
|
||||||
|
continue
|
||||||
|
|
||||||
|
ref = np.asarray(Image.open(ref_path).convert("L"))
|
||||||
|
score = ssim(cur, ref)
|
||||||
|
checked += 1
|
||||||
|
mark = "ok " if score >= args.threshold else "FAIL "
|
||||||
|
if score < args.threshold:
|
||||||
|
failures.append((f"{rel} p{i}", score))
|
||||||
|
print(f" {mark} {rel} p{i} SSIM={score:.4f}")
|
||||||
|
|
||||||
|
print()
|
||||||
|
if args.sweep:
|
||||||
|
print(f"Sweep complete: {rendered} pages rendered, {skipped} skipped (graceful). "
|
||||||
|
f"No crash — engine is render-stable over this corpus.")
|
||||||
|
return 0
|
||||||
|
if args.update:
|
||||||
|
print(f"Baseline updated: {updated} images written to {args.baseline}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
print(f"Checked {checked} pages, skipped {skipped}, {len(failures)} regression(s).")
|
||||||
|
for name, score in failures:
|
||||||
|
tag = "missing baseline" if score < 0 else f"SSIM={score:.4f}"
|
||||||
|
print(f" REGRESSION: {name} ({tag})")
|
||||||
|
return 1 if failures else 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
"""Windowed SSIM with no scipy/skimage dependency.
|
||||||
|
|
||||||
|
Implements the Wang et al. structural-similarity index using a uniform window
|
||||||
|
(box filter) computed via an integral image — fast, vectorised, and dependency
|
||||||
|
-light (numpy only). Good enough to catch rendering regressions while tolerating
|
||||||
|
sub-pixel antialiasing noise.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
_C1 = (0.01 * 255) ** 2
|
||||||
|
_C2 = (0.03 * 255) ** 2
|
||||||
|
|
||||||
|
|
||||||
|
def _mean_filter(img: np.ndarray, k: int) -> np.ndarray:
|
||||||
|
"""Box-mean over a k×k window, same shape, edge-clamped window area."""
|
||||||
|
h, w = img.shape
|
||||||
|
pad = k // 2
|
||||||
|
integral = np.zeros((h + 1, w + 1), dtype=np.float64)
|
||||||
|
integral[1:, 1:] = img.cumsum(0).cumsum(1)
|
||||||
|
|
||||||
|
ys = np.clip(np.arange(h) - pad, 0, h)
|
||||||
|
ye = np.clip(np.arange(h) + pad + 1, 0, h)
|
||||||
|
xs = np.clip(np.arange(w) - pad, 0, w)
|
||||||
|
xe = np.clip(np.arange(w) + pad + 1, 0, w)
|
||||||
|
|
||||||
|
a = integral[ye][:, xe]
|
||||||
|
b = integral[ye][:, xs]
|
||||||
|
c = integral[ys][:, xe]
|
||||||
|
d = integral[ys][:, xs]
|
||||||
|
total = a - b - c + d
|
||||||
|
area = (ye - ys)[:, None] * (xe - xs)[None, :]
|
||||||
|
return total / area
|
||||||
|
|
||||||
|
|
||||||
|
def ssim(a: np.ndarray, b: np.ndarray, k: int = 7) -> float:
|
||||||
|
"""Mean SSIM in [0, 1]. Mismatched shapes score 0.0 (always a regression)."""
|
||||||
|
if a.shape != b.shape:
|
||||||
|
return 0.0
|
||||||
|
a = a.astype(np.float64)
|
||||||
|
b = b.astype(np.float64)
|
||||||
|
|
||||||
|
mu_a = _mean_filter(a, k)
|
||||||
|
mu_b = _mean_filter(b, k)
|
||||||
|
mu_a2 = mu_a * mu_a
|
||||||
|
mu_b2 = mu_b * mu_b
|
||||||
|
mu_ab = mu_a * mu_b
|
||||||
|
|
||||||
|
var_a = _mean_filter(a * a, k) - mu_a2
|
||||||
|
var_b = _mean_filter(b * b, k) - mu_b2
|
||||||
|
cov_ab = _mean_filter(a * b, k) - mu_ab
|
||||||
|
|
||||||
|
num = (2 * mu_ab + _C1) * (2 * cov_ab + _C2)
|
||||||
|
den = (mu_a2 + mu_b2 + _C1) * (var_a + var_b + _C2)
|
||||||
|
return float(np.clip(num / den, 0.0, 1.0).mean())
|
||||||