diff --git a/.github/workflows/fuzz.yml b/.github/workflows/fuzz.yml new file mode 100644 index 0000000..87eefb3 --- /dev/null +++ b/.github/workflows/fuzz.yml @@ -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 diff --git a/.gitignore b/.gitignore index 77ca622..1558b28 100644 --- a/.gitignore +++ b/.gitignore @@ -67,5 +67,15 @@ gateway/*.pyd gateway/*.so gateway/*.dylib +# Downloaded fuzzing corpus (large; fetched via scripts/fetch_corpus.py) +corpus/fuzz/ +# Large-corpus regression baseline (derived from the gitignored corpus above) +tests/regression/baseline-large/ +# Fuzzer working artifacts (crashes, leaks, coverage) +engine/fuzz/artifacts/ +crash-* +leak-* +timeout-* + PDF Editor Timeline.xlsx # Local environment config / secrets diff --git a/A.pgm b/A.pgm new file mode 100644 index 0000000..dfbd666 Binary files /dev/null and b/A.pgm differ diff --git a/CMakeLists.txt b/CMakeLists.txt index 10fc361..d7a9ae3 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -40,6 +40,16 @@ option(PDFENGINE_ENABLE_SANITIZERS "Build with AddressSanitizer/UBSan" option(PDFENGINE_WARNINGS_AS_ERRORS "Treat compiler warnings as errors" OFF) option(PDFENGINE_WITH_PDFIUM "Link the PDFium static lib (build it first)" OFF) option(PDFENGINE_WITH_SKIA "Link the Skia static lib (build it first)" OFF) +option(PDFENGINE_FUZZING "Build libFuzzer harnesses (requires Clang)" OFF) + +if(PDFENGINE_FUZZING) + # The fuzz harness instruments the engine; tests/bindings are not part of it. + set(PDFENGINE_BUILD_TESTS OFF CACHE BOOL "Build engine unit/smoke tests" FORCE) + if(NOT CMAKE_CXX_COMPILER_ID MATCHES "Clang") + message(FATAL_ERROR "PDFENGINE_FUZZING requires a Clang/clang-cl toolchain " + "(set in the 'fuzz-*' preset).") + endif() +endif() if(PDFENGINE_WASM) set(PDFENGINE_BUILD_TESTS OFF CACHE BOOL "Build engine unit/smoke tests" FORCE) @@ -80,7 +90,9 @@ else() endif() add_subdirectory(engine) - add_subdirectory(bindings) + if(NOT PDFENGINE_FUZZING) + add_subdirectory(bindings) + endif() endif() message(STATUS "PdfEngine ${PROJECT_VERSION} configured") diff --git a/CMakePresets.json b/CMakePresets.json index d08529d..a03d026 100644 --- a/CMakePresets.json +++ b/CMakePresets.json @@ -108,6 +108,27 @@ "PDFENGINE_BUILD_TESTS": "OFF", "PDFENGINE_WITH_PDFIUM": "OFF" } + }, + + { + "name": "fuzz-linux", + "displayName": "Linux • libFuzzer (Clang + ASan)", + "inherits": "base", + "condition": { "type": "equals", "lhs": "${hostSystemName}", "rhs": "Linux" }, + "cacheVariables": { + "CMAKE_BUILD_TYPE": "RelWithDebInfo", + "CMAKE_C_COMPILER": "clang", + "CMAKE_CXX_COMPILER": "clang++", + "PDFENGINE_FUZZING": "ON", + "PDFENGINE_WITH_PDFIUM": "ON", + "PDFENGINE_FUZZ_SANITIZERS": "fuzzer,address,undefined" + } + }, + { + "name": "fuzz-linux-nosan", + "displayName": "Linux • libFuzzer (Clang, coverage-only — for non-ASan PDFium)", + "inherits": "fuzz-linux", + "cacheVariables": { "PDFENGINE_FUZZ_SANITIZERS": "fuzzer" } } ], @@ -121,7 +142,9 @@ { "name": "macos-debug", "configurePreset": "macos-debug" }, { "name": "macos-release", "configurePreset": "macos-release" }, { "name": "macos-asan", "configurePreset": "macos-asan" }, - { "name": "wasm", "configurePreset": "wasm" } + { "name": "wasm", "configurePreset": "wasm" }, + { "name": "fuzz-linux", "configurePreset": "fuzz-linux" }, + { "name": "fuzz-linux-nosan", "configurePreset": "fuzz-linux-nosan" } ], "testPresets": [ diff --git a/bindings/python/pdfengine_py.cpp b/bindings/python/pdfengine_py.cpp index 650acb9..10921a5 100644 --- a/bindings/python/pdfengine_py.cpp +++ b/bindings/python/pdfengine_py.cpp @@ -123,7 +123,8 @@ PYBIND11_MODULE(pdfengine, m) { .def_readonly("bbox_y", &pdfengine::Glyph::bboxY) .def_readonly("bbox_w", &pdfengine::Glyph::bboxW) .def_readonly("bbox_h", &pdfengine::Glyph::bboxH) - .def_readonly("angle", &pdfengine::Glyph::angle); + .def_readonly("angle", &pdfengine::Glyph::angle) + .def_readonly("page_object_index", &pdfengine::Glyph::pageObjectIndex); py::class_(m, "TextRun") .def_readonly("text", &pdfengine::TextRun::text) @@ -137,7 +138,8 @@ PYBIND11_MODULE(pdfengine, m) { .def_readonly("x", &pdfengine::TextRun::x) .def_readonly("y", &pdfengine::TextRun::y) .def_readonly("w", &pdfengine::TextRun::w) - .def_readonly("h", &pdfengine::TextRun::h); + .def_readonly("h", &pdfengine::TextRun::h) + .def_readonly("object_indices", &pdfengine::TextRun::objectIndices); py::class_(m, "TextLine") .def_readonly("runs", &pdfengine::TextLine::runs) @@ -212,6 +214,40 @@ PYBIND11_MODULE(pdfengine, m) { } return py_list; }) + .def("ordered_glyphs", [](const pdfengine::PdfPage& self) { + auto res = get_or_throw(self.orderedGlyphs()); + py::list py_list; + for (const auto& g : res) { + py::dict d; + d["text"] = g.text; d["x"] = g.x; d["y"] = g.y; + d["w"] = g.w; d["h"] = g.h; d["fontSize"] = g.fontSize; + py_list.append(d); + } + return py_list; + }) + .def("hit_glyph", [](const pdfengine::PdfPage& self, double x, double y) { + auto hit = get_or_throw(self.hitGlyph(x, y)); + py::dict d; + d["glyphIndex"] = hit.glyphIndex; + d["caret"] = hit.caret; + d["line"] = hit.line; + return d; + }, py::arg("x"), py::arg("y")) + .def("select_range", [](const pdfengine::PdfPage& self, double ax, double ay, double bx, double by) { + auto sel = get_or_throw(self.selectRange(ax, ay, bx, by)); + py::dict d; + d["startGlyph"] = sel.startGlyph; + d["endGlyph"] = sel.endGlyph; + d["text"] = sel.text; + py::list rects; + for (const auto& r : sel.rects) { + py::dict rd; + rd["x"] = r.x; rd["y"] = r.y; rd["w"] = r.w; rd["h"] = r.h; + rects.append(rd); + } + d["rects"] = rects; + return d; + }, py::arg("ax"), py::arg("ay"), py::arg("bx"), py::arg("by")) .def("get_fonts", [](const pdfengine::PdfPage& self) { return get_or_throw(self.getFonts()); }) diff --git a/docs/phase3-dev1-plan.md b/docs/phase3-dev1-plan.md new file mode 100644 index 0000000..34bd13a --- /dev/null +++ b/docs/phase3-dev1-plan.md @@ -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__`), 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 → `` / `