From 377a9044af20dcaef9c9194ae6423315e97486f4 Mon Sep 17 00:00:00 2001 From: Furqan-14 Date: Thu, 11 Jun 2026 19:19:01 +0530 Subject: [PATCH] feat: implemented hit testing, form field, content stream and base security hardening --- .github/workflows/fuzz.yml | 104 + .gitignore | 10 + CMakeLists.txt | 14 +- CMakePresets.json | 25 +- bindings/python/pdfengine_py.cpp | 34 + docs/phase3-dev1-plan.md | 130 + engine/CMakeLists.txt | 37 + engine/fuzz/README.md | 65 + engine/fuzz/fuzz_load.cpp | 50 + engine/include/pdfengine/hardened_limits.h | 67 + engine/include/pdfengine/pdf_document.hpp | 33 +- engine/src/parser/pdfium_document.cpp | 349 +- engine/src/parser/pdfium_document.hpp | 14 +- engine/src/text/selection.cpp | 349 ++ engine/tests/fonts_test.cpp | 94 +- frontend/src/App.tsx | 17 +- frontend/src/components/ToolRail.tsx | 11 + frontend/src/components/Toolbar.tsx | 1 + frontend/src/lib/gatewayService.ts | 14 + frontend/src/lib/textSelection.ts | 244 ++ frontend/src/lib/tools.ts | 2 + frontend/src/viewer/PDFViewer.tsx | 17 + frontend/src/viewer/SelectionLayer.tsx | 252 +- frontend/src/viewer/TextEditLayer.tsx | 152 + gateway/app/routers/edits.py | 31 +- gateway/app/routers/render.py | 7 + scripts/fetch_corpus.py | 130 + tests/edits/test_edit_text.py | 130 + tests/regression/README.md | 72 + .../baseline/basic__about_blank.pdf__p0.png | Bin 0 -> 2066 bytes .../baseline/basic__black.pdf__p0.png | Bin 0 -> 550 bytes .../baseline/basic__clip_path.pdf__p0.png | Bin 0 -> 218 bytes .../baseline/basic__dashed_lines.pdf__p0.png | Bin 0 -> 237 bytes .../baseline/basic__hello_world.pdf__p0.png | Bin 0 -> 1790 bytes .../basic__hello_world_2_pages.pdf__p0.png | Bin 0 -> 1790 bytes .../basic__hello_world_2_pages.pdf__p1.png | Bin 0 -> 1790 bytes .../basic__many_rectangles.pdf__p0.png | Bin 0 -> 4213 bytes .../baseline/basic__rectangles.pdf__p0.png | Bin 0 -> 675 bytes .../basic__rectangles_multi_pages.pdf__p0.png | Bin 0 -> 464 bytes .../basic__rectangles_multi_pages.pdf__p1.png | Bin 0 -> 426 bytes .../baseline/basic__whitespace.pdf__p0.png | Bin 0 -> 300 bytes .../baseline/edge-cases__annots.pdf__p0.png | Bin 0 -> 12105 bytes .../baseline/edge-cases__annots.pdf__p1.png | Bin 0 -> 6465 bytes .../edge-cases__bookmarks.pdf__p0.png | Bin 0 -> 2840 bytes .../edge-cases__bookmarks.pdf__p1.png | Bin 0 -> 2940 bytes .../edge-cases__combobox_form.pdf__p0.png | Bin 0 -> 1769 bytes ...ge-cases__embedded_attachments.pdf__p0.png | Bin 0 -> 2322 bytes .../edge-cases__empty_xref.pdf__p0.png | Bin 0 -> 1790 bytes .../edge-cases__linearized.pdf__p0.png | Bin 0 -> 2066 bytes .../edge-cases__linearized.pdf__p1.png | Bin 0 -> 2066 bytes .../edge-cases__listbox_form.pdf__p0.png | Bin 0 -> 1769 bytes .../edge-cases__no_page_count.pdf__p0.png | Bin 0 -> 866 bytes .../edge-cases__no_page_count.pdf__p1.png | Bin 0 -> 866 bytes .../edge-cases__page_labels.pdf__p0.png | Bin 0 -> 3424 bytes .../edge-cases__page_labels.pdf__p1.png | Bin 0 -> 2066 bytes .../edge-cases__text_form.pdf__p0.png | Bin 0 -> 1160 bytes ...dge-cases__unsupported_feature.pdf__p0.png | Bin 0 -> 2066 bytes ...edge-cases__zero_length_stream.pdf__p0.png | Bin 0 -> 1790 bytes .../baseline/fonts__cns1_test.pdf__p0.png | Bin 0 -> 2544 bytes .../fonts__custom_encoding.pdf__p0.png | Bin 0 -> 6227 bytes .../fonts__embedded_cid_font.pdf__p0.png | Bin 0 -> 2793 bytes .../fonts__embedded_truetype.pdf__p0.png | Bin 0 -> 6694 bytes .../fonts__hebrew_mirrored.pdf__p0.png | Bin 0 -> 697 bytes .../fonts__large_100pages.pdf__p0.png | Bin 0 -> 2794 bytes .../fonts__large_100pages.pdf__p1.png | Bin 0 -> 2852 bytes .../fonts__latin_extended.pdf__p0.png | Bin 0 -> 8064 bytes .../fonts__malformed_font.pdf__p0.png | Bin 0 -> 2881 bytes .../fonts__multipage_font_mix.pdf__p0.png | Bin 0 -> 2664 bytes .../baseline/fonts__no_tounicode.pdf__p0.png | Bin 0 -> 3104 bytes .../baseline/fonts__rotated_text.pdf__p0.png | Bin 0 -> 1899 bytes .../fonts__rotated_text_90.pdf__p0.png | Bin 0 -> 1676 bytes .../baseline/fonts__subset_font.pdf__p0.png | Bin 0 -> 2714 bytes .../baseline/fonts__text_font.pdf__p0.png | Bin 0 -> 2351 bytes .../baseline/fonts__utf-8.pdf__p0.png | Bin 0 -> 15378 bytes .../fonts__vertical_identity_v.pdf__p0.png | Bin 0 -> 2895 bytes .../baseline/fonts__vertical_text.pdf__p0.png | Bin 0 -> 1176 bytes .../fonts__with_tounicode.pdf__p0.png | Bin 0 -> 6509 bytes tests/regression/corpus-manifest.json | 3008 +++++++++++++++++ tests/regression/run.py | 139 + tests/regression/ssim.py | 57 + 80 files changed, 5509 insertions(+), 150 deletions(-) create mode 100644 .github/workflows/fuzz.yml create mode 100644 docs/phase3-dev1-plan.md create mode 100644 engine/fuzz/README.md create mode 100644 engine/fuzz/fuzz_load.cpp create mode 100644 engine/include/pdfengine/hardened_limits.h create mode 100644 engine/src/text/selection.cpp create mode 100644 frontend/src/lib/textSelection.ts create mode 100644 frontend/src/viewer/TextEditLayer.tsx create mode 100644 scripts/fetch_corpus.py create mode 100644 tests/edits/test_edit_text.py create mode 100644 tests/regression/README.md create mode 100644 tests/regression/baseline/basic__about_blank.pdf__p0.png create mode 100644 tests/regression/baseline/basic__black.pdf__p0.png create mode 100644 tests/regression/baseline/basic__clip_path.pdf__p0.png create mode 100644 tests/regression/baseline/basic__dashed_lines.pdf__p0.png create mode 100644 tests/regression/baseline/basic__hello_world.pdf__p0.png create mode 100644 tests/regression/baseline/basic__hello_world_2_pages.pdf__p0.png create mode 100644 tests/regression/baseline/basic__hello_world_2_pages.pdf__p1.png create mode 100644 tests/regression/baseline/basic__many_rectangles.pdf__p0.png create mode 100644 tests/regression/baseline/basic__rectangles.pdf__p0.png create mode 100644 tests/regression/baseline/basic__rectangles_multi_pages.pdf__p0.png create mode 100644 tests/regression/baseline/basic__rectangles_multi_pages.pdf__p1.png create mode 100644 tests/regression/baseline/basic__whitespace.pdf__p0.png create mode 100644 tests/regression/baseline/edge-cases__annots.pdf__p0.png create mode 100644 tests/regression/baseline/edge-cases__annots.pdf__p1.png create mode 100644 tests/regression/baseline/edge-cases__bookmarks.pdf__p0.png create mode 100644 tests/regression/baseline/edge-cases__bookmarks.pdf__p1.png create mode 100644 tests/regression/baseline/edge-cases__combobox_form.pdf__p0.png create mode 100644 tests/regression/baseline/edge-cases__embedded_attachments.pdf__p0.png create mode 100644 tests/regression/baseline/edge-cases__empty_xref.pdf__p0.png create mode 100644 tests/regression/baseline/edge-cases__linearized.pdf__p0.png create mode 100644 tests/regression/baseline/edge-cases__linearized.pdf__p1.png create mode 100644 tests/regression/baseline/edge-cases__listbox_form.pdf__p0.png create mode 100644 tests/regression/baseline/edge-cases__no_page_count.pdf__p0.png create mode 100644 tests/regression/baseline/edge-cases__no_page_count.pdf__p1.png create mode 100644 tests/regression/baseline/edge-cases__page_labels.pdf__p0.png create mode 100644 tests/regression/baseline/edge-cases__page_labels.pdf__p1.png create mode 100644 tests/regression/baseline/edge-cases__text_form.pdf__p0.png create mode 100644 tests/regression/baseline/edge-cases__unsupported_feature.pdf__p0.png create mode 100644 tests/regression/baseline/edge-cases__zero_length_stream.pdf__p0.png create mode 100644 tests/regression/baseline/fonts__cns1_test.pdf__p0.png create mode 100644 tests/regression/baseline/fonts__custom_encoding.pdf__p0.png create mode 100644 tests/regression/baseline/fonts__embedded_cid_font.pdf__p0.png create mode 100644 tests/regression/baseline/fonts__embedded_truetype.pdf__p0.png create mode 100644 tests/regression/baseline/fonts__hebrew_mirrored.pdf__p0.png create mode 100644 tests/regression/baseline/fonts__large_100pages.pdf__p0.png create mode 100644 tests/regression/baseline/fonts__large_100pages.pdf__p1.png create mode 100644 tests/regression/baseline/fonts__latin_extended.pdf__p0.png create mode 100644 tests/regression/baseline/fonts__malformed_font.pdf__p0.png create mode 100644 tests/regression/baseline/fonts__multipage_font_mix.pdf__p0.png create mode 100644 tests/regression/baseline/fonts__no_tounicode.pdf__p0.png create mode 100644 tests/regression/baseline/fonts__rotated_text.pdf__p0.png create mode 100644 tests/regression/baseline/fonts__rotated_text_90.pdf__p0.png create mode 100644 tests/regression/baseline/fonts__subset_font.pdf__p0.png create mode 100644 tests/regression/baseline/fonts__text_font.pdf__p0.png create mode 100644 tests/regression/baseline/fonts__utf-8.pdf__p0.png create mode 100644 tests/regression/baseline/fonts__vertical_identity_v.pdf__p0.png create mode 100644 tests/regression/baseline/fonts__vertical_text.pdf__p0.png create mode 100644 tests/regression/baseline/fonts__with_tounicode.pdf__p0.png create mode 100644 tests/regression/corpus-manifest.json create mode 100644 tests/regression/run.py create mode 100644 tests/regression/ssim.py 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/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..ef7ba66 100644 --- a/bindings/python/pdfengine_py.cpp +++ b/bindings/python/pdfengine_py.cpp @@ -212,6 +212,40 @@ PYBIND11_MODULE(pdfengine, m) { } return py_list; }) + .def("ordered_glyphs", [](const pdfengine::PdfPage& self) { + auto res = get_or_throw(self.orderedGlyphs()); + py::list py_list; + for (const auto& g : res) { + py::dict d; + d["text"] = g.text; d["x"] = g.x; d["y"] = g.y; + d["w"] = g.w; d["h"] = g.h; d["fontSize"] = g.fontSize; + py_list.append(d); + } + return py_list; + }) + .def("hit_glyph", [](const pdfengine::PdfPage& self, double x, double y) { + auto hit = get_or_throw(self.hitGlyph(x, y)); + py::dict d; + d["glyphIndex"] = hit.glyphIndex; + d["caret"] = hit.caret; + d["line"] = hit.line; + return d; + }, py::arg("x"), py::arg("y")) + .def("select_range", [](const pdfengine::PdfPage& self, double ax, double ay, double bx, double by) { + auto sel = get_or_throw(self.selectRange(ax, ay, bx, by)); + py::dict d; + d["startGlyph"] = sel.startGlyph; + d["endGlyph"] = sel.endGlyph; + d["text"] = sel.text; + py::list rects; + for (const auto& r : sel.rects) { + py::dict rd; + rd["x"] = r.x; rd["y"] = r.y; rd["w"] = r.w; rd["h"] = r.h; + rects.append(rd); + } + d["rects"] = rects; + return d; + }, py::arg("ax"), py::arg("ay"), py::arg("bx"), py::arg("by")) .def("get_fonts", [](const pdfengine::PdfPage& self) { return get_or_throw(self.getFonts()); }) 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 → `` / `