Compare commits

...
Author SHA1 Message Date
Ludy 0e2b1ee3f7 Merge branch 'main' into custom_task_20260812 2026-08-25 00:20:59 +02:00
79686a3a09 form field editing (#6655)
# Description of Changes

Building ontop of a users draft PR for form creation tools

**Fill Form** becomes a full **Form Editor**: fill, create, modify and
delete AcroForm fields visually. Builds on the community form-creation
draft, plus a UX/UI rework pass.

- **Backend**: `/api/v1/form` endpoints — `fields-with-coordinates`,
`add/modify/delete-fields`, combined `edit-fields` (one round-trip),
`fill`, `extract-csv/xlsx`; supports text (multiline, comb), checkbox,
dropdown, list box, radio, button actions (reset/print/URL/submit) and
signature placeholders
- **Create**: type palette, click-or-drag placement with snap guides,
inline property editor, batch "Add N fields"
- **Modify**: move/resize on the page, arrow-nudge + Delete key, X/Y/W/H
inputs, staged edits/deletes with chips, discard
- **Fill**: live progress + required tracking, flatten toggle, Export
menu (JSON/CSV/XLSX), Ctrl/Cmd+S
- **Safety**: confirm dialog before discarding staged work; empty
required fields warn with "Save anyway" instead of blocking
- **UI**: consistent panel skeleton (fixed header / scrolling list /
pinned actions), empty states that link into Create, full i18n with
plural keys



[walkthrough.html](https://github.com/user-attachments/files/30508976/walkthrough.html)



---

## Checklist

### General

- [ ] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [ ] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md)
(if applicable)
- [ ] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md)
(if applicable)
- [ ] I have performed a self-review of my own code
- [ ] My changes generate no new warnings

### Documentation

- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)

### Translations (if applicable)

- [ ] I ran
[`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md)

### UI Changes (if applicable)

- [ ] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)

### Testing (if applicable)

- [ ] I have run `task check` to verify linters, typechecks, and tests
pass
- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md#7-testing)
for more details.

---------

Co-authored-by: Denys Vitali <denys@denv.it>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-24 20:45:54 +00:00
Ludy cc42074b45 Merge branch 'main' into custom_task_20260812 2026-08-21 09:44:49 +02:00
Ludy87 4133c07476 Update action.yml 2026-08-20 23:35:17 +02:00
Ludy87 feaccebf01 Update action.yml 2026-08-20 23:30:24 +02:00
Ludy87 7861e33324 Update action.yml 2026-08-20 23:25:10 +02:00
Ludy87 e5f67e09c7 Install Task from release when Docker missing
Update the setup-task action to prefer the CI image when Docker is available, and fall back to downloading a verified release archive when it is not. The Linux step now detects Docker; otherwise it maps runner arch (X64/ARM64), downloads the appropriate task tarball and checksums from GitHub Releases, verifies the SHA256, and extracts the task binary. Also improves curl retry/options and updates the action description/step name to reflect behavior. This ensures Task can be installed securely on runners without Docker.
2026-08-20 23:19:33 +02:00
Ludy a574772226 Merge branch 'main' into custom_task_20260812 2026-08-20 23:15:11 +02:00
Ludy 536b46faea Merge branch 'main' into custom_task_20260812 2026-08-20 21:39:34 +02:00
Ludy87 5fc37ac46c Merge branch 'custom_task_20260812' of https://github.com/Stirling-Tools/Stirling-PDF into custom_task_20260812 2026-08-18 21:53:34 +02:00
Ludy87 beb232f9e1 Bump Task to 3.53.1 in CI and Dockerfiles
Upgrade the pinned Task CLI to 3.53.1 across CI and Docker build configs. Updated defaults and ARGs in .github/actions/setup-task/action.yml, .github/workflows/publish-ci-image.yml, docker/ci/Dockerfile, docker/embedded/Dockerfile(.fat,.ultra-lite) and engine/Dockerfile. Also updated platform-specific SHA256 checksums for the Task archives and clarified the action description to note native runner support.
2026-08-18 21:51:40 +02:00
Ludy 3106063e80 Merge branch 'main' into custom_task_20260812 2026-08-18 21:43:01 +02:00
Ludy a1489faaf1 Merge branch 'main' into custom_task_20260812 2026-08-17 10:22:53 +02:00
Ludy 9ff56af1f4 Merge branch 'main' into custom_task_20260812 2026-08-16 15:47:45 +02:00
Ludy 8f2a967141 Merge branch 'main' into custom_task_20260812 2026-08-13 00:17:39 +02:00
Ludy87 061e5d8b17 Update action.yml 2026-08-13 00:13:26 +02:00
Ludy87 0ef46240d9 Update Dockerfile 2026-08-12 22:33:48 +02:00
Ludy87 e4a31a6e6e Update dependabot.yml 2026-08-12 22:26:38 +02:00
Ludy87 b23ad4dc67 Update Dockerfile 2026-08-12 22:22:13 +02:00
Ludy87 67ff7372b7 Update docker.yml 2026-08-12 22:09:48 +02:00
Ludy87 885331e7ee Update Dockerfile 2026-08-12 22:09:34 +02:00
Ludy87 761a71e2f6 Update action.yml 2026-08-12 21:47:59 +02:00
Ludy87 e1ece98d13 Update publish-ci-image.yml 2026-08-12 21:42:12 +02:00
Ludy87 c0d0f62b8f Publish Task CI image and pull from GHCR
Change setup-task action to pull a prebuilt Task CI image from ghcr.io instead of building it in-action (remove build/cache logic). Add a new workflow (publish-ci-image.yml) that builds and pushes a multi-arch Docker image (ghcr.io/stirling-tools/stirling-pdf-ci:task-<version>) to GHCR; supports workflow_dispatch with a task-version input (default 3.52.0) and triggers on Dockerfile changes. This separates image publishing from CI setup and simplifies runner setup.
2026-08-12 21:38:36 +02:00
Ludy87 3798e0e370 Use local task setup action in workflows
Replace the pinned external go-task/setup-task GitHub Action with the repo-local ./.github/actions/setup-task in CI workflows. This keeps Task setup consistent across workflows and avoids relying on a duplicated external action reference.
2026-08-12 21:35:31 +02:00
Ludy87 d316946f41 Bump task to 3.52.0 and improve CI setup
Include the requested task version in the GitHub Action cache scope so caches are versioned per-input. Update the CI Dockerfile to use TASK_VERSION 3.52.0, refresh amd64/arm64 sha256 checksums for the new release, and make the download step more robust by adding --http1.1, increased retries, retry-all-errors and a connect timeout.
2026-08-12 21:30:15 +02:00
Ludy87 337df490dc Update Dockerfile 2026-08-12 21:25:19 +02:00
Ludy87 4dbe1028cb Update pre_commit.yml 2026-08-12 21:20:52 +02:00
Ludy87 4a51160a94 Add local setup-task action and CI image
Introduce a local GitHub Action (.github/actions/setup-task/action.yml) to install a pinned go-task binary. On Linux it builds a small Ubuntu CI image (docker/ci/Dockerfile) and extracts /usr/local/bin/task for a cacheable, reproducible install; on macOS/Windows it downloads the official release archive. Update .github/workflows/test-build-docker.yml to use the new local action. Defaults: action TASK_VERSION=3.52.0, Dockerfile ARG TASK_VERSION=3.51.1. Purpose: improved CI reproducibility and caching for Task installs.
2026-08-12 21:18:17 +02:00
100 changed files with 11590 additions and 370 deletions
+80
View File
@@ -0,0 +1,80 @@
name: Setup Task from the CI image
description: Install the pinned Task binary from the CI image when Docker is available, or from a verified release archive
inputs:
version:
description: Task version to install
required: false
default: 3.53.1
runs:
using: composite
steps:
- name: Install Task on Linux
if: runner.os == 'Linux'
shell: bash
env:
TASK_VERSION: ${{ inputs.version }}
run: |
set -euo pipefail
install_dir="${RUNNER_TEMP}/task-bin"
mkdir -p "${install_dir}"
if command -v docker >/dev/null 2>&1 && docker info >/dev/null 2>&1; then
image="ghcr.io/stirling-tools/stirling-pdf-ci:task-${TASK_VERSION}"
docker pull "${image}"
container_id="$(docker create "${image}")"
cleanup() { docker rm "${container_id}" >/dev/null; }
trap cleanup EXIT
docker cp "${container_id}:/usr/local/bin/task" "${install_dir}/task"
chmod 0755 "${install_dir}/task"
else
case "${RUNNER_ARCH}" in
X64) task_arch=amd64 ;;
ARM64) task_arch=arm64 ;;
*) echo "Unsupported runner architecture: ${RUNNER_ARCH}" >&2; exit 1 ;;
esac
task_file="task_linux_${task_arch}.tar.gz"
archive="${RUNNER_TEMP}/${task_file}"
checksums="${RUNNER_TEMP}/task_checksums.txt"
release_url="https://github.com/go-task/task/releases/download/v${TASK_VERSION}"
curl --fail --silent --show-error --location --http1.1 \
--retry 5 --retry-all-errors --retry-delay 2 --connect-timeout 30 \
"${release_url}/${task_file}" --output "${archive}"
curl --fail --silent --show-error --location --http1.1 \
--retry 5 --retry-all-errors --retry-delay 2 --connect-timeout 30 \
"${release_url}/task_checksums.txt" --output "${checksums}"
(
cd "${RUNNER_TEMP}"
grep " ${task_file}$" "${checksums}" | sha256sum -c -
)
tar -xzf "${archive}" -C "${install_dir}" task
fi
echo "${install_dir}" >> "${GITHUB_PATH}"
- name: Install Task natively
if: runner.os != 'Linux'
shell: bash
env:
TASK_VERSION: ${{ inputs.version }}
run: |
set -euo pipefail
install_dir="${RUNNER_TEMP}/task-bin"
mkdir -p "${install_dir}"
case "${RUNNER_OS}-${RUNNER_ARCH}" in
macOS-X64) task_platform=darwin; task_arch=amd64; task_file=task_darwin_amd64.tar.gz; task_sha256=7f1a702d54a789cb818a636039a83df071f4179893133afafa4eba351a7e19ef ;;
macOS-ARM64) task_platform=darwin; task_arch=arm64; task_file=task_darwin_arm64.tar.gz; task_sha256=85d2d96c2380b33d7855b07b3f7a20dc7ca0eda999a26efa0fb5f6f32b366cd7 ;;
Windows-X64) task_platform=windows; task_arch=amd64; task_file=task_windows_amd64.zip; task_sha256=27c0cd248c12cba03d8958d954a3df981c900be885ec9ce5f6a3cdc4e9a19316 ;;
Windows-ARM64) task_platform=windows; task_arch=arm64; task_file=task_windows_arm64.zip; task_sha256=4f7c32c0b5a09aabfd860bcf4ff5649f0483339c1f7f90ad5ee55692da7237b6 ;;
*) echo "Unsupported runner: ${RUNNER_OS}-${RUNNER_ARCH}" >&2; exit 1 ;;
esac
archive="${RUNNER_TEMP}/${task_file}"
curl --fail --silent --show-error --location --http1.1 \
--retry 5 --retry-all-errors --retry-delay 2 --connect-timeout 30 \
"https://github.com/go-task/task/releases/download/v${TASK_VERSION}/${task_file}" \
--output "${archive}"
echo "${task_sha256} ${archive}" | sha256sum -c -
if [[ "${task_platform}" == windows ]]; then
unzip -p "${archive}" task.exe > "${install_dir}/task.exe"
else
tar -xzf "${archive}" -C "${install_dir}" task
fi
echo "${install_dir}" >> "${GITHUB_PATH}"
+1
View File
@@ -31,6 +31,7 @@ updates:
- "/docker/embedded"
- "/docker/frontend"
- "/docker/base"
- "/docker/ci"
- "/docker/engine"
- "/docker/unoserver"
- "/engine"
+1 -1
View File
@@ -353,7 +353,7 @@ jobs:
- name: Install Task for Storybook
if: steps.sb-changes.outputs.storybook == 'true'
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
uses: ./.github/actions/setup-task
- name: Build and deploy Storybook
id: storybook
@@ -206,7 +206,7 @@ jobs:
distribution: "temurin"
- name: Install Task
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
uses: ./.github/actions/setup-task
- name: Run Gradle Command
run: |
if [ "${{ needs.check-comment.outputs.disable_security }}" == "true" ]; then
+1 -1
View File
@@ -37,7 +37,7 @@ jobs:
cache-suffix: ai-engine
- name: Install Task
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
uses: ./.github/actions/setup-task
- name: Quality-check engine
id: engine-check
+1 -1
View File
@@ -52,7 +52,7 @@ jobs:
distribution: "temurin"
- name: Install Task
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
uses: ./.github/actions/setup-task
- name: Check Java formatting (Spotless)
# Runs once per matrix combination - pick the cheapest leg
# (core - no proprietary, no saas) so we don't wait for the
+1 -1
View File
@@ -95,7 +95,7 @@ jobs:
cache: "npm"
cache-dependency-path: frontend/package-lock.json
- name: Install Task
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
uses: ./.github/actions/setup-task
- name: Install Playwright (chromium only)
run: task e2e:install -- chromium
- name: Build frontend (needed for playwright's vite preview webServer)
+1 -1
View File
@@ -76,7 +76,7 @@ jobs:
cache-dependency-path: frontend/package-lock.json
- name: Install Task
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
uses: ./.github/actions/setup-task
- name: Verify generated models are up to date
id: models-check
+1 -1
View File
@@ -38,7 +38,7 @@ jobs:
distribution: "temurin"
- name: Install Task
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
uses: ./.github/actions/setup-task
- name: Check licenses for compatibility
run: task backend:licenses:check
env:
+1 -1
View File
@@ -39,7 +39,7 @@ jobs:
distribution: "temurin"
- name: Install Task
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
uses: ./.github/actions/setup-task
- name: Generate OpenAPI documentation
run: task backend:swagger
env:
+1 -1
View File
@@ -45,7 +45,7 @@ jobs:
cache: "npm"
cache-dependency-path: frontend/package-lock.json
- name: Install Task
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
uses: ./.github/actions/setup-task
- name: Install Playwright (chromium only)
run: task e2e:install -- chromium
- name: Build frontend (production bundle for vite preview)
+1 -1
View File
@@ -44,7 +44,7 @@ jobs:
cache: "npm"
cache-dependency-path: frontend/package-lock.json
- name: Install Task
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
uses: ./.github/actions/setup-task
- name: Build frontend (production bundle for vite preview)
env:
VITE_BUILD_FOR_PREVIEW: "1"
+1 -1
View File
@@ -36,7 +36,7 @@ jobs:
cache: "npm"
cache-dependency-path: frontend/package-lock.json
- name: Install Task
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
uses: ./.github/actions/setup-task
- name: a11y gate (changed stories)
run: task frontend:storybook:a11y:changed -- origin/${{ github.base_ref || 'main' }}
- name: Upload scan reports
@@ -97,7 +97,7 @@ jobs:
run: npm ci --ignore-scripts --audit=false --fund=false
- name: Install Task
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
uses: ./.github/actions/setup-task
- name: Generate frontend license report (Push only)
if: github.event_name == 'push'
@@ -367,7 +367,7 @@ jobs:
distribution: "temurin"
- name: Install Task
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
uses: ./.github/actions/setup-task
- name: Check licenses and generate report
id: license-check
+1 -1
View File
@@ -27,7 +27,7 @@ jobs:
cache: "npm"
cache-dependency-path: frontend/package-lock.json
- name: Install Task
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
uses: ./.github/actions/setup-task
- name: Quality-check frontend
id: frontend-check
run: task frontend:check:all
+3 -3
View File
@@ -69,7 +69,7 @@ jobs:
distribution: "temurin"
- name: Install Task
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
uses: ./.github/actions/setup-task
- name: Get version number
id: versionNumber
run: |
@@ -169,7 +169,7 @@ jobs:
cache-dependency-path: frontend/package-lock.json
- name: Install Task
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
uses: ./.github/actions/setup-task
- name: Build JAR
run: ./gradlew build ${{ matrix.variant.build_frontend && '-PbuildWithFrontend=true' || '' }} -x spotlessApply -x spotlessCheck -x test -x sonarqube
@@ -268,7 +268,7 @@ jobs:
distribution: ${{ matrix.platform == 'windows-11-arm' && 'microsoft' || 'temurin' }}
- name: Install Task
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
uses: ./.github/actions/setup-task
# Build the universal JRE before desktop:prepare so the jlink:runtime
# task short-circuits on its `test -d runtime/jre` status check.
+2 -2
View File
@@ -38,7 +38,7 @@ jobs:
cache-dependency-path: frontend/package-lock.json
- name: Install Task
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
uses: ./.github/actions/setup-task
- name: Install all Playwright browsers
run: task e2e:install
@@ -89,7 +89,7 @@ jobs:
cache-dependency-path: frontend/package-lock.json
- name: Install Task
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
uses: ./.github/actions/setup-task
- name: a11y gate (every story, ${{ matrix.theme }})
run: task frontend:storybook:a11y:${{ matrix.theme }}
+1 -1
View File
@@ -34,7 +34,7 @@ jobs:
cache-suffix: pre-commit
- name: Install Task
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
uses: ./.github/actions/setup-task
- name: Run pre-commit checks
run: task pre-commit
+55
View File
@@ -0,0 +1,55 @@
name: Publish Task CI image
on:
workflow_dispatch:
inputs:
task-version:
description: Task version to publish
required: true
default: 3.53.1
type: string
push:
paths:
- docker/ci/Dockerfile
- .github/workflows/publish-ci-image.yml
jobs:
publish:
permissions:
contents: read
packages: write
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Set Task version
id: version
shell: bash
env:
INPUT_VERSION: ${{ inputs.task-version }}
run: echo "value=${INPUT_VERSION:-3.53.1}" >> "${GITHUB_OUTPUT}"
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
- name: Log in to GHCR
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Publish Task CI image
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
with:
context: .
file: docker/ci/Dockerfile
# GitHub-hosted Linux jobs in this repository run on amd64. Avoid
# QEMU/arm64 here because the image is only consumed by those jobs.
platforms: linux/amd64
push: true
build-args: |
TASK_VERSION=${{ steps.version.outputs.value }}
tags: |
ghcr.io/stirling-tools/stirling-pdf-ci:task-${{ steps.version.outputs.value }}
+1 -1
View File
@@ -88,7 +88,7 @@ jobs:
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
- name: Install Task
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
uses: ./.github/actions/setup-task
- name: Get version number
id: versionNumber
run: echo "versionNumber=$(./gradlew printVersion --quiet | tail -1)" >> $GITHUB_OUTPUT
+1 -1
View File
@@ -63,7 +63,7 @@ jobs:
SWAGGERHUB_USER: "Frooodle"
- name: Install Task
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
uses: ./.github/actions/setup-task
- name: Get version number
id: versionNumber
run: echo "versionNumber=$(./gradlew printVersion --quiet | tail -1)" >> $GITHUB_OUTPUT
+1 -1
View File
@@ -66,7 +66,7 @@ jobs:
uv sync --project engine --locked --group tools
- name: Install Task
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
uses: ./.github/actions/setup-task
- name: Sync translation TOML files
run: |
+1 -1
View File
@@ -212,7 +212,7 @@ jobs:
distribution: ${{ matrix.platform == 'windows-11-arm' && 'microsoft' || 'temurin' }}
- name: Setup Task
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
uses: ./.github/actions/setup-task
- name: Build universal macOS JRE
if: matrix.platform == 'macos-15'
+1 -1
View File
@@ -127,7 +127,7 @@ jobs:
distribution: "temurin"
- name: Install Task
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
uses: ./.github/actions/setup-task
- name: Build application
run: task backend:build
env:
+2
View File
@@ -176,6 +176,8 @@ app/core/src/main/resources/static/images/google-drive.svg
*.nar
*.ear
*.zip
# Real backend archives the form-bundle reader is tested against.
!frontend/editor/src/core/tools/formFill/__fixtures__/*.zip
*.tar.gz
*.rar
*.db
+5
View File
@@ -36,6 +36,11 @@ tasks:
cmds:
- docker build -t stirling-pdf-engine .
build:ci:
desc: "Build the Ubuntu CI image with Task installed"
cmds:
- docker build --tag stirling-pdf-ci --file docker/ci/Dockerfile .
up:
desc: "Start standard docker compose stack"
cmds:
@@ -62,6 +62,15 @@ public class FormFieldWithCoordinates {
@Schema(description = "Widget coordinates on each page (fields can have multiple widgets)")
private List<WidgetCoordinates> widgets;
@Schema(description = "Maximum character count for a text field (/MaxLen); null when unset")
private Integer maxLength;
@Schema(
description =
"Push button activation action as a spec string:"
+ " 'reset', 'print', 'uri:<url>' or 'submit:<url>'")
private String buttonActionSpec;
/**
* Coordinates for a single widget annotation (visual representation of the field). A field can
* have multiple widgets if it appears on multiple pages.
@@ -94,5 +103,12 @@ public class FormFieldWithCoordinates {
@Schema(description = "Font size in PDF points")
private Float fontSize;
@Schema(
description =
"CropBox height in PDF points. Lets the frontend reverse the backend's"
+ " Y-flip when sending new widget coordinates back for"
+ " create/modify operations.")
private Float cropBoxHeight;
}
}
@@ -3,14 +3,20 @@ package stirling.software.common.util;
import java.io.IOException;
import java.util.Arrays;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Optional;
import java.util.function.Function;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
import org.apache.pdfbox.cos.COSName;
import org.apache.pdfbox.pdmodel.graphics.color.PDColor;
import org.apache.pdfbox.pdmodel.graphics.color.PDDeviceRGB;
import org.apache.pdfbox.pdmodel.interactive.action.PDActionNamed;
import org.apache.pdfbox.pdmodel.interactive.action.PDActionResetForm;
import org.apache.pdfbox.pdmodel.interactive.action.PDActionSubmitForm;
import org.apache.pdfbox.pdmodel.interactive.action.PDActionURI;
import org.apache.pdfbox.pdmodel.interactive.annotation.PDAnnotationWidget;
import org.apache.pdfbox.pdmodel.interactive.annotation.PDAppearanceCharacteristicsDictionary;
import org.apache.pdfbox.pdmodel.interactive.form.PDAcroForm;
@@ -59,6 +65,24 @@ public enum FormFieldTypeSupport {
List<String> options)
throws IOException {
PDTextField textField = (PDTextField) field;
if (definition.fontSize() != null && definition.fontSize() > 0) {
textField.setDefaultAppearance("/Helv " + definition.fontSize() + " Tf 0 g");
}
if (Boolean.TRUE.equals(definition.multiline())) {
textField.setMultiline(true);
}
// Comb field: evenly spaced character cells (e.g. SSN, phone). Requires
// a positive MaxLen and is mutually exclusive with multiline.
if (definition.maxLength() != null && definition.maxLength() > 0) {
textField.setMaxLen(definition.maxLength());
if (!Boolean.TRUE.equals(definition.multiline())) {
try {
textField.setComb(true);
} catch (Exception e) {
log.debug("Unable to set comb flag: {}", e.getMessage());
}
}
}
String defaultValue = Optional.ofNullable(definition.defaultValue()).orElse("");
if (!defaultValue.isBlank()) {
FormUtils.setTextValue(textField, defaultValue);
@@ -272,14 +296,108 @@ public enum FormFieldTypeSupport {
PDTerminalField createField(PDAcroForm acroForm) {
return new PDSignatureField(acroForm);
}
@Override
boolean doesNotsupportsDefinitionCreation() {
return false;
}
// Empty signature placeholder: no value to apply (signed later by a sign tool).
},
BUTTON("button", "pushButton", PDPushButton.class) {
@Override
PDTerminalField createField(PDAcroForm acroForm) {
return new PDPushButton(acroForm);
}
@Override
boolean doesNotsupportsDefinitionCreation() {
return false;
}
@Override
void applyNewFieldDefinition(
PDTerminalField field,
FormUtils.NewFormFieldDefinition definition,
List<String> options)
throws IOException {
if (field.getWidgets().isEmpty()) {
return;
}
PDAnnotationWidget widget = field.getWidgets().get(0);
// Visible caption (/MK /CA).
String caption = definition.label();
if (caption == null || caption.isBlank()) {
caption = definition.name();
}
if (caption != null && !caption.isBlank()) {
PDAppearanceCharacteristicsDictionary mk = widget.getAppearanceCharacteristics();
if (mk == null) {
mk = new PDAppearanceCharacteristicsDictionary(widget.getCOSObject());
widget.setAppearanceCharacteristics(mk);
}
mk.setNormalCaption(caption);
}
widget.setPrinted(true);
applyButtonAction(widget, definition.buttonAction());
}
};
/**
* Writes a push button's activation action from a "reset"/"print"/"uri:"/"submit:" spec,
* returning why it could not, or null on success. A blank spec clears the action.
*/
public static String applyButtonAction(PDAnnotationWidget widget, String action) {
if (action == null) {
return null;
}
if (action.isBlank()) {
// An explicit blank clears the action rather than leaving the old one behind.
widget.getCOSObject().removeItem(COSName.A);
return null;
}
String spec = action.trim();
if (!ACTION_SPEC.matcher(spec).matches()) {
return "'" + action + "' is not a button action this editor understands";
}
// The editor emits "uri:" the moment that kind is picked, before a URL is typed; an
// empty target is not yet an action, so clear rather than write an inert one.
int colon = spec.indexOf(':');
if (colon >= 0 && spec.substring(colon + 1).isBlank()) {
widget.getCOSObject().removeItem(COSName.A);
return null;
}
try {
String lower = spec.toLowerCase(Locale.ROOT);
if (lower.equals("reset")) {
widget.getCOSObject().setItem(COSName.A, new PDActionResetForm().getCOSObject());
} else if (lower.equals("print")) {
PDActionNamed named = new PDActionNamed();
named.setN("Print");
widget.getCOSObject().setItem(COSName.A, named.getCOSObject());
} else if (lower.startsWith("uri:")) {
PDActionURI uri = new PDActionURI();
uri.setURI(spec.substring(4));
widget.getCOSObject().setItem(COSName.A, uri.getCOSObject());
} else if (lower.startsWith("submit:")) {
PDActionSubmitForm submit = new PDActionSubmitForm();
// Store the target URL on the action dictionary's /F entry.
submit.getCOSObject().setString(COSName.F, spec.substring(7));
widget.getCOSObject().setItem(COSName.A, submit.getCOSObject());
}
return null;
} catch (Exception e) {
log.debug("Unable to apply button action '{}': {}", action, e.getMessage());
return e.getMessage();
}
}
/** The spec forms applyButtonAction understands; anything else is reported, not dropped. */
private static final Pattern ACTION_SPEC =
Pattern.compile(
"^(reset|print|uri:.*|submit:.*)$", Pattern.CASE_INSENSITIVE | Pattern.DOTALL);
private static final Map<String, FormFieldTypeSupport> BY_TYPE =
Arrays.stream(values())
.collect(
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,116 @@
package stirling.software.common.util;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.util.List;
import org.apache.pdfbox.Loader;
import org.apache.pdfbox.cos.COSArray;
import org.apache.pdfbox.cos.COSName;
import org.apache.pdfbox.cos.COSObject;
import org.apache.pdfbox.cos.COSObjectKey;
import org.apache.pdfbox.cos.COSString;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.pdmodel.common.PDRectangle;
import org.apache.pdfbox.pdmodel.interactive.digitalsignature.PDSignature;
import org.apache.pdfbox.pdmodel.interactive.form.PDAcroForm;
import org.apache.pdfbox.pdmodel.interactive.form.PDComboBox;
import org.apache.pdfbox.pdmodel.interactive.form.PDSignatureField;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
/** Pins how a choice field's options survive a save, which real forms rely on. */
class ChoiceOptionRoundTripTest {
private static PDComboBox combo(PDDocument document, List<String> options) throws IOException {
document.addPage(new PDPage(PDRectangle.A4));
PDAcroForm form = new PDAcroForm(document);
document.getDocumentCatalog().setAcroForm(form);
PDComboBox field = new PDComboBox(form);
field.setPartialName("state");
field.setOptions(options);
form.getFields().add(field);
return field;
}
@Test
@DisplayName("a whitespace-only option survives a load, save and reload")
void whitespaceOptionSurvivesRoundTrip() throws IOException {
List<String> options = List.of(" ", "Alabama", "Alaska");
byte[] first;
try (PDDocument document = new PDDocument();
ByteArrayOutputStream out = new ByteArrayOutputStream()) {
combo(document, options);
document.save(out);
first = out.toByteArray();
}
// The real path edits a document loaded from bytes, not one built in memory.
byte[] saved;
try (PDDocument loaded = Loader.loadPDF(first);
ByteArrayOutputStream out = new ByteArrayOutputStream()) {
loaded.save(out);
saved = out.toByteArray();
}
try (PDDocument reloaded = Loader.loadPDF(saved)) {
PDComboBox reread =
(PDComboBox) reloaded.getDocumentCatalog().getAcroForm(null).getField("state");
assertEquals(
options,
reread.getOptionsExportValues(),
"an option must not vanish because the writer made it indirect");
}
}
@Test
@DisplayName("an option stored as an indirect reference is still reported")
void indirectOptionIsStillReported() throws IOException {
try (PDDocument document = new PDDocument()) {
PDComboBox field = combo(document, List.of(" ", "Alabama"));
// Real forms reference option strings indirectly; the reader must follow the reference.
COSArray options = new COSArray();
options.add(new COSObject(new COSString(" "), new COSObjectKey(629, 0)));
options.add(new COSString("Alabama"));
field.getCOSObject().setItem(COSName.OPT, options);
// Every read path runs this repair first, which is where the reference is followed.
FormUtils.repairMissingWidgetPageReferences(document);
assertEquals(
List.of(" ", "Alabama"),
field.getOptionsExportValues(),
"an indirectly stored option must not be dropped");
}
}
@Test
@DisplayName("a signature field reports no value rather than a JVM identity hash")
void signatureValueIsNotAnIdentityHash() throws IOException {
try (PDDocument document = new PDDocument()) {
document.addPage(new PDPage(PDRectangle.A4));
PDAcroForm form = new PDAcroForm(document);
document.getDocumentCatalog().setAcroForm(form);
PDSignatureField signature = new PDSignatureField(form);
signature.setPartialName("approval");
// Only a field that actually holds a signature hits getValueAsString's toString().
signature.setValue(new PDSignature());
form.getFields().add(signature);
List<FormUtils.FormFieldInfo> fields = FormUtils.extractFormFields(document);
FormUtils.FormFieldInfo field =
fields.stream()
.filter(f -> "approval".equals(f.name()))
.findFirst()
.orElseThrow();
// An identity hash differs per load, so the same document would describe itself twice.
assertNull(field.value(), "a signature has no text value");
}
}
}
@@ -0,0 +1,62 @@
package stirling.software.common.util;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import org.apache.pdfbox.Loader;
import org.apache.pdfbox.cos.COSArray;
import org.apache.pdfbox.cos.COSDictionary;
import org.apache.pdfbox.cos.COSName;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.pdmodel.common.PDRectangle;
import org.apache.pdfbox.pdmodel.interactive.form.PDAcroForm;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
/** A hostile or corrupt form must fail as a rejected request, never as a crashed thread. */
class DeepFieldTreeTest {
private static byte[] chainOfKids(int depth) throws IOException {
try (PDDocument document = new PDDocument();
ByteArrayOutputStream out = new ByteArrayOutputStream()) {
document.addPage(new PDPage(PDRectangle.A4));
PDAcroForm form = new PDAcroForm(document);
document.getDocumentCatalog().setAcroForm(form);
COSDictionary root = new COSDictionary();
root.setString(COSName.T, "n0");
COSDictionary cursor = root;
for (int i = 1; i < depth; i++) {
COSDictionary kid = new COSDictionary();
kid.setString(COSName.T, "n" + i);
kid.setItem(COSName.PARENT, cursor);
COSArray kids = new COSArray();
kids.add(kid);
cursor.setItem(COSName.KIDS, kids);
cursor = kid;
}
cursor.setItem(COSName.FT, COSName.getPDFName("Tx"));
COSArray fields = new COSArray();
fields.add(root);
form.getCOSObject().setItem(COSName.FIELDS, fields);
document.save(out);
return out.toByteArray();
}
}
@Test
@DisplayName("a deeply nested field tree extracts without overflowing the stack")
void deepKidsChainDoesNotOverflow() throws IOException {
// 2000 is as deep as PDFBox's own writer can build here; beyond that the overflow is in
// the writer, not in extraction, so it is not something a read endpoint would hit.
byte[] pdf = chainOfKids(2000);
try (PDDocument document = Loader.loadPDF(pdf)) {
assertDoesNotThrow(() -> FormUtils.extractFormFieldsWithCoordinates(document));
}
}
}
@@ -0,0 +1,201 @@
package stirling.software.common.util;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.pdmodel.common.PDRectangle;
import org.apache.pdfbox.pdmodel.interactive.form.PDAcroForm;
import org.apache.pdfbox.pdmodel.interactive.form.PDCheckBox;
import org.apache.pdfbox.pdmodel.interactive.form.PDField;
import org.apache.pdfbox.pdmodel.interactive.form.PDRadioButton;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import stirling.software.common.model.FormFieldWithCoordinates;
/** An edit that cannot be honoured must be refused and reported, never silently reshaped. */
class FormEditSafetyTest {
private static PDDocument formWith(String name, String type) throws IOException {
PDDocument document = new PDDocument();
document.addPage(new PDPage(PDRectangle.A4));
document.getDocumentCatalog().setAcroForm(new PDAcroForm(document));
FormUtils.addNewFields(
document,
List.of(
new FormUtils.NewFormFieldDefinition(
name,
null,
type,
0,
50f,
700f,
200f,
20f,
null,
null,
type.equals("radio") ? List.of("a", "b") : null,
null,
null,
null,
null,
null,
null,
null)));
return document;
}
private static FormUtils.ModifyFormFieldDefinition modify(
String target, String type, Float width, Float height) {
// Order: targetName, name, label, type, pageIndex, x, y, width, height, then the rest.
return new FormUtils.ModifyFormFieldDefinition(
target, null, null, type, null, null, null, width, height, null, null, null, null,
null, null, null, null, null, null);
}
@Test
@DisplayName("a type that cannot be rebuilt is refused instead of becoming a text field")
void unrebuildableTypeIsRefused() throws IOException {
try (PDDocument document = formWith("choice", "text")) {
List<FormUtils.SkippedFieldEdit> skipped = new ArrayList<>();
FormUtils.modifyFormFields(
document, List.of(modify("choice", "radio", null, null)), skipped);
PDField field = document.getDocumentCatalog().getAcroForm(null).getField("choice");
assertFalse(skipped.isEmpty(), "the refusal must be reported to the caller");
assertFalse(
field instanceof PDRadioButton,
"it could not become a radio, so it must not claim to be one");
assertEquals(
"text",
FormUtils.extractFormFields(document).getFirst().type(),
"the original field must survive untouched rather than be retyped");
}
}
@Test
@DisplayName("a field rebuilt as a checkbox gets an appearance so it can be ticked")
void rebuiltCheckboxIsUsable() throws IOException {
try (PDDocument document = formWith("agree", "text")) {
List<FormUtils.SkippedFieldEdit> skipped = new ArrayList<>();
FormUtils.modifyFormFields(
document, List.of(modify("agree", "checkbox", null, null)), skipped);
PDField field = document.getDocumentCatalog().getAcroForm(null).getField("agree");
assertTrue(field instanceof PDCheckBox, "the rebuild should have produced a checkbox");
assertNotNull(
field.getWidgets().getFirst().getAppearance(),
"without an appearance the checkbox renders blank and cannot be ticked");
}
}
@Test
@DisplayName("a size of zero or infinity is refused rather than written into the page")
void unusableSizeIsRefused() throws IOException {
for (Float bad : new Float[] {0f, -5f, Float.POSITIVE_INFINITY, Float.NaN}) {
try (PDDocument document = formWith("box", "text")) {
List<FormUtils.SkippedFieldEdit> skipped = new ArrayList<>();
FormUtils.modifyFormFields(
document, List.of(modify("box", null, bad, 20f)), skipped);
PDRectangle rect =
document.getDocumentCatalog()
.getAcroForm(null)
.getField("box")
.getWidgets()
.getFirst()
.getRectangle();
assertFalse(skipped.isEmpty(), "a refused resize must be reported: width " + bad);
assertEquals(
200f,
rect.getWidth(),
0.01f,
"the original size must survive: width " + bad);
}
}
}
@Test
@DisplayName("a widget off the page still reports its geometry instead of dropping the field")
void offPageWidgetKeepsItsGeometry() throws IOException {
try (PDDocument document = formWith("stray", "text")) {
PDField field = document.getDocumentCatalog().getAcroForm(null).getField("stray");
// Above the page top: legal PDF, and the user needs the coordinates to drag it back.
field.getWidgets().getFirst().setRectangle(new PDRectangle(50f, 2000f, 200f, 20f));
List<FormFieldWithCoordinates> fields =
FormUtils.extractFormFieldsWithCoordinates(document);
FormFieldWithCoordinates stray =
fields.stream()
.filter(f -> "stray".equals(f.getName()))
.findFirst()
.orElseThrow();
assertNotNull(stray.getWidgets(), "the field must keep its widget list");
assertFalse(stray.getWidgets().isEmpty(), "the off-page widget must still be reported");
assertNotNull(stray.getWidgets().getFirst(), "a null entry would crash the overlay");
}
}
private static FormUtils.ModifyFormFieldDefinition withValue(String target, String value) {
return new FormUtils.ModifyFormFieldDefinition(
target, null, null, null, null, null, null, null, null, null, null, null, value,
null, null, null, null, null, null);
}
private static FormUtils.ModifyFormFieldDefinition withOptions(
String target, List<String> options) {
return new FormUtils.ModifyFormFieldDefinition(
target, null, null, null, null, null, null, null, null, null, null, options, null,
null, null, null, null, null, null);
}
@Test
@DisplayName("a value a radio group cannot hold does not destroy the group")
void badRadioValueLeavesTheGroupIntact() throws IOException {
try (PDDocument document = formWith("plan", "radio")) {
List<FormUtils.SkippedFieldEdit> skipped = new ArrayList<>();
FormUtils.modifyFormFields(
document, List.of(withValue("plan", "not-an-option")), skipped);
PDField field = document.getDocumentCatalog().getAcroForm(null).getField("plan");
assertTrue(
field instanceof PDRadioButton,
"a rejected value must not turn the group into another kind of field");
assertEquals(
2,
field.getWidgets().size(),
"the group's options must survive a rejected value");
assertFalse(skipped.isEmpty(), "the caller must be told the value was not applied");
}
}
@Test
@DisplayName("editing a radio group's options is either applied or reported, never ignored")
void radioOptionEditIsNotSilentlyDropped() throws IOException {
try (PDDocument document = formWith("plan", "radio")) {
List<FormUtils.SkippedFieldEdit> skipped = new ArrayList<>();
FormUtils.modifyFormFields(
document, List.of(withOptions("plan", List.of("a", "b", "c"))), skipped);
PDField field = document.getDocumentCatalog().getAcroForm(null).getField("plan");
boolean applied = field.getWidgets().size() == 3;
assertTrue(
applied || !skipped.isEmpty(),
"a change the UI shows as saved must either happen or be reported as skipped");
}
}
}
@@ -0,0 +1,61 @@
package stirling.software.common.util;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import org.junit.jupiter.api.Test;
/**
* A field name is caller-supplied and reaches several loggers. A line break in one would forge a
* second log line (CWE-117), so names carrying control characters are refused outright.
*/
class FormFieldNameSafetyTest {
@Test
void aNameWithCrLfIsRefused() {
String forged = "evil\r\n2026-01-01 00:00:00 ERROR admin login from 1.2.3.4";
String reason = FormUtils.invalidFieldNameReason(forged);
assertNotNull(reason, "a name containing CR/LF must be refused");
assertFalse(reason.contains("\n"), "the refusal itself must not carry a line break");
assertFalse(reason.contains("\r"), "the refusal itself must not carry a carriage return");
}
@Test
void otherControlCharactersAreRefusedToo() {
assertNotNull(FormUtils.invalidFieldNameReason("tab\there"));
assertNotNull(FormUtils.invalidFieldNameReason("null\u0000byte"));
}
@Test
void ordinaryNamesStillPass() {
assertNull(FormUtils.invalidFieldNameReason("Full Name"));
assertNull(FormUtils.invalidFieldNameReason("weird/[]{}"));
assertNull(FormUtils.invalidFieldNameReason("Mr Smith"));
}
@Test
void thePeriodRefusalDoesNotEchoControlCharacters() {
// Both problems at once: the period branch must not leak the raw name into a log line.
String reason = FormUtils.invalidFieldNameReason("Customer.Name\r\nFORGED");
assertNotNull(reason);
assertFalse(reason.contains("\r") || reason.contains("\n"), "no raw line break: " + reason);
}
@Test
void sanitizeForLogFlattensControlCharacters() {
assertEquals("a b", FormUtils.sanitizeForLog("a\nb"));
assertEquals("a b", FormUtils.sanitizeForLog("a\rb"));
assertEquals("plain", FormUtils.sanitizeForLog("plain"));
assertNull(FormUtils.sanitizeForLog(null));
}
@Test
void aPeriodIsStillRefusedWithTheOffendingCharacterNamed() {
String reason = FormUtils.invalidFieldNameReason("Customer.Name");
assertNotNull(reason);
assertTrue(reason.contains("period"), "the message should name the problem: " + reason);
}
}
@@ -130,13 +130,15 @@ class FormFieldTypeSupportTest {
}
@Test
void doesNotSupportsDefinitionCreation_signatureReturnsTrue() {
assertTrue(FormFieldTypeSupport.SIGNATURE.doesNotsupportsDefinitionCreation());
void doesNotSupportsDefinitionCreation_signatureReturnsFalse() {
// Signature placeholders are now creatable via the editor.
assertFalse(FormFieldTypeSupport.SIGNATURE.doesNotsupportsDefinitionCreation());
}
@Test
void doesNotSupportsDefinitionCreation_buttonReturnsTrue() {
assertTrue(FormFieldTypeSupport.BUTTON.doesNotsupportsDefinitionCreation());
void doesNotSupportsDefinitionCreation_buttonReturnsFalse() {
// Push buttons (with actions) are now creatable via the editor.
assertFalse(FormFieldTypeSupport.BUTTON.doesNotsupportsDefinitionCreation());
}
@Test
@@ -0,0 +1,911 @@
package stirling.software.common.util;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;
import org.apache.pdfbox.Loader;
import org.apache.pdfbox.cos.COSName;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.pdmodel.PDResources;
import org.apache.pdfbox.pdmodel.common.PDRectangle;
import org.apache.pdfbox.pdmodel.interactive.annotation.PDAnnotationWidget;
import org.apache.pdfbox.pdmodel.interactive.annotation.PDAppearanceDictionary;
import org.apache.pdfbox.pdmodel.interactive.annotation.PDAppearanceEntry;
import org.apache.pdfbox.pdmodel.interactive.form.PDAcroForm;
import org.apache.pdfbox.pdmodel.interactive.form.PDCheckBox;
import org.apache.pdfbox.pdmodel.interactive.form.PDField;
import org.apache.pdfbox.pdmodel.interactive.form.PDNonTerminalField;
import org.apache.pdfbox.pdmodel.interactive.form.PDRadioButton;
import org.apache.pdfbox.pdmodel.interactive.form.PDSignatureField;
import org.apache.pdfbox.pdmodel.interactive.form.PDTerminalField;
import org.apache.pdfbox.pdmodel.interactive.form.PDTextField;
import org.junit.jupiter.api.Test;
/**
* Guards the form editor against silently destroying a field it edits. Assertions run after a
* save/reload cycle because only the serialised document reflects what a viewer sees.
*/
class FormUtilsEditRegressionTest {
private static PDAcroForm setupForm(PDDocument document) {
document.addPage(new PDPage(PDRectangle.A4));
PDAcroForm acroForm = new PDAcroForm(document);
acroForm.setDefaultResources(new PDResources());
document.getDocumentCatalog().setAcroForm(acroForm);
return acroForm;
}
private static byte[] save(PDDocument document) throws IOException {
ByteArrayOutputStream baos = new ByteArrayOutputStream();
document.save(baos);
return baos.toByteArray();
}
private static FormUtils.NewFormFieldDefinition newField(
String type, String name, float x, float y, float w, float h, List<String> options) {
return new FormUtils.NewFormFieldDefinition(
name, null, type, 0, x, y, w, h, null, null, options, null, null, null, null, null,
null, null);
}
/** Moves a field to a rect; null width/height leave the size alone. */
private static FormUtils.ModifyFormFieldDefinition moveTo(
String target, float x, float y, Float w, Float h) {
return new FormUtils.ModifyFormFieldDefinition(
target, null, null, null, 0, x, y, w, h, null, null, null, null, null, null, null,
null, null, null);
}
private static PDRectangle firstWidgetRect(PDAcroForm acroForm, String name) {
PDField field = acroForm.getField(name);
assertNotNull(field, "field '" + name + "' should exist");
return field.getWidgets().get(0).getRectangle();
}
/** The /AP /N state names on a widget. */
private static Set<String> normalStateNames(PDAnnotationWidget widget) {
PDAppearanceDictionary appearance = widget.getAppearance();
assertNotNull(appearance, "widget should have an /AP dictionary");
PDAppearanceEntry normal = appearance.getNormalAppearance();
assertNotNull(normal, "widget should have an /AP /N entry");
assertTrue(normal.isSubDictionary(), "a toggle needs per-state appearances");
return normal.getSubDictionary().keySet().stream()
.map(COSName::getName)
.collect(Collectors.toSet());
}
@Test
void movingCheckboxKeepsItFillable() throws IOException {
byte[] saved;
try (PDDocument document = new PDDocument()) {
setupForm(document);
FormUtils.addNewFields(
document, List.of(newField("checkbox", "agree", 50, 700, 14, 14, null)));
FormUtils.modifyFormFields(document, List.of(moveTo("agree", 200f, 400f, null, null)));
saved = save(document);
}
try (PDDocument reloaded = Loader.loadPDF(saved)) {
PDAcroForm acroForm = reloaded.getDocumentCatalog().getAcroForm(null);
PDField field = acroForm.getField("agree");
assertTrue(field instanceof PDCheckBox, "'agree' should still be a checkbox");
assertFalse(
((PDCheckBox) field).getOnValue().isEmpty(),
"a moved checkbox must keep an on-state, or it can never be ticked again");
assertTrue(
normalStateNames(field.getWidgets().get(0)).size() >= 2,
"both /AP /N states must survive a move");
PDRectangle rect = firstWidgetRect(acroForm, "agree");
assertEquals(200f, rect.getLowerLeftX(), 0.5f);
assertEquals(400f, rect.getLowerLeftY(), 0.5f);
}
}
@Test
void resizingCheckboxRebuildsAppearanceAtTheNewSize() throws IOException {
byte[] saved;
try (PDDocument document = new PDDocument()) {
setupForm(document);
FormUtils.addNewFields(
document, List.of(newField("checkbox", "agree", 50, 700, 14, 14, null)));
FormUtils.modifyFormFields(document, List.of(moveTo("agree", 50f, 700f, 28f, 28f)));
saved = save(document);
}
try (PDDocument reloaded = Loader.loadPDF(saved)) {
PDAcroForm acroForm = reloaded.getDocumentCatalog().getAcroForm(null);
PDCheckBox checkBox = (PDCheckBox) acroForm.getField("agree");
assertFalse(
checkBox.getOnValue().isEmpty(), "a resized checkbox must keep its on-state");
PDAnnotationWidget widget = checkBox.getWidgets().get(0);
assertTrue(normalStateNames(widget).size() >= 2, "both /AP /N states must be rebuilt");
PDRectangle bbox =
widget.getAppearance()
.getNormalAppearance()
.getSubDictionary()
.get(COSName.getPDFName(checkBox.getOnValue()))
.getBBox();
assertEquals(28f, bbox.getWidth(), 0.5f, "the rebuilt /AP must match the new size");
}
}
/** applyToggleAppearance parks /AS on Off, so a resize must put the selection back. */
@Test
void resizingCheckboxKeepsItChecked() throws IOException {
byte[] saved;
try (PDDocument document = new PDDocument()) {
setupForm(document);
FormUtils.addNewFields(
document, List.of(newField("checkbox", "agree", 50, 700, 14, 14, null)));
PDAcroForm form = document.getDocumentCatalog().getAcroForm(null);
((PDCheckBox) form.getField("agree")).check();
FormUtils.modifyFormFields(document, List.of(moveTo("agree", 50f, 700f, 30f, 30f)));
saved = save(document);
}
try (PDDocument reloaded = Loader.loadPDF(saved)) {
PDAcroForm acroForm = reloaded.getDocumentCatalog().getAcroForm(null);
assertTrue(
((PDCheckBox) acroForm.getField("agree")).isChecked(),
"a resize must not silently untick the box");
}
}
/** Only widgets.get(0) used to move, so a radio group lost every option but the first. */
@Test
void movingRadioGroupMovesEveryOption() throws IOException {
byte[] saved;
float[] before = new float[6];
try (PDDocument document = new PDDocument()) {
setupForm(document);
FormUtils.addNewFields(
document,
List.of(newField("radio", "choice", 50, 700, 14, 14, List.of("A", "B", "C"))));
PDAcroForm form = document.getDocumentCatalog().getAcroForm(null);
List<PDAnnotationWidget> widgets = form.getField("choice").getWidgets();
assertEquals(3, widgets.size(), "the fixture needs three option widgets");
for (int i = 0; i < 3; i++) {
before[i * 2] = widgets.get(i).getRectangle().getLowerLeftX();
before[i * 2 + 1] = widgets.get(i).getRectangle().getLowerLeftY();
}
FormUtils.modifyFormFields(document, List.of(moveTo("choice", 90f, 670f, null, null)));
saved = save(document);
}
try (PDDocument reloaded = Loader.loadPDF(saved)) {
PDAcroForm acroForm = reloaded.getDocumentCatalog().getAcroForm(null);
PDField field = acroForm.getField("choice");
assertTrue(field instanceof PDRadioButton, "'choice' should still be a radio group");
List<PDAnnotationWidget> widgets = field.getWidgets();
assertEquals(3, widgets.size(), "no option may be left behind");
float dx = 90f - before[0];
float dy = 670f - before[1];
for (int i = 0; i < 3; i++) {
PDRectangle rect = widgets.get(i).getRectangle();
assertEquals(
before[i * 2] + dx,
rect.getLowerLeftX(),
0.5f,
"option " + i + " should shift by the same delta");
assertEquals(before[i * 2 + 1] + dy, rect.getLowerLeftY(), 0.5f);
}
}
}
/** A signature's /AP is the signature, so it must never be dropped. */
@Test
void movingSignatureKeepsItsAppearance() throws IOException {
byte[] saved;
try (PDDocument document = new PDDocument()) {
setupForm(document);
FormUtils.addNewFields(
document, List.of(newField("signature", "sig", 50, 700, 120, 40, null)));
FormUtils.modifyFormFields(document, List.of(moveTo("sig", 60f, 600f, 140f, 50f)));
saved = save(document);
}
try (PDDocument reloaded = Loader.loadPDF(saved)) {
PDAcroForm acroForm = reloaded.getDocumentCatalog().getAcroForm(null);
assertTrue(
acroForm.getField("sig") instanceof PDSignatureField,
"'sig' should still be a signature");
assertEquals(60f, firstWidgetRect(acroForm, "sig").getLowerLeftX(), 0.5f);
}
}
@Test
void invalidFieldNameReason_rejectsPeriodAndAllowsTheRest() {
String reason = FormUtils.invalidFieldNameReason("Customer.Name");
assertNotNull(reason, "a period must be refused, not silently dropped");
assertTrue(reason.contains("period"), "the message should name the offending character");
assertNull(FormUtils.invalidFieldNameReason("Has Space"));
assertNull(FormUtils.invalidFieldNameReason("weird/[]{}"));
assertNull(FormUtils.invalidFieldNameReason(null));
}
/** Dropped operations used to log a warning and still report success. */
@Test
void applyFieldEdits_reportsEveryDroppedOperation() throws IOException {
try (PDDocument document = new PDDocument()) {
setupForm(document);
FormUtils.addNewFields(
document, List.of(newField("text", "present", 50, 700, 200, 20, null)));
List<FormUtils.SkippedFieldEdit> skipped = new ArrayList<>();
FormUtils.applyFieldEdits(
document,
List.of(newField("text", "Bad.Name", 50, 600, 100, 20, null)),
List.of(moveTo("ghost", 10f, 10f, null, null)),
List.of("alsoGhost"),
skipped);
assertEquals(3, skipped.size(), "each dropped operation should be reported");
assertTrue(skipped.stream().anyMatch(s -> "add".equals(s.operation())));
assertTrue(skipped.stream().anyMatch(s -> "modify".equals(s.operation())));
assertTrue(skipped.stream().anyMatch(s -> "delete".equals(s.operation())));
assertNotNull(
document.getDocumentCatalog().getAcroForm(null).getField("present"),
"the rest of the document must still be applied");
}
}
/** A clean batch must not report anything, or the UI would cry wolf on every save. */
@Test
void applyFieldEdits_reportsNothingWhenEverythingApplies() throws IOException {
try (PDDocument document = new PDDocument()) {
setupForm(document);
List<FormUtils.SkippedFieldEdit> skipped = new ArrayList<>();
FormUtils.applyFieldEdits(
document,
List.of(newField("text", "fine", 50, 700, 200, 20, null)),
List.of(),
List.of(),
skipped);
assertTrue(skipped.isEmpty(), "a fully applied batch reports no skips");
}
}
/** A drag must not normalise other options to the dragged widget's size. */
@Test
void movingRadioGroupKeepsEachOptionsOwnSize() throws IOException {
byte[] saved;
try (PDDocument document = new PDDocument()) {
setupForm(document);
FormUtils.addNewFields(
document,
List.of(newField("radio", "choice", 50, 700, 20, 20, List.of("A", "B"))));
PDAcroForm form = document.getDocumentCatalog().getAcroForm(null);
List<PDAnnotationWidget> widgets = form.getField("choice").getWidgets();
// Hand-authored groups legitimately have option boxes of differing size.
PDRectangle second = widgets.get(1).getRectangle();
widgets.get(1)
.setRectangle(
new PDRectangle(
second.getLowerLeftX(), second.getLowerLeftY(), 40f, 40f));
FormUtils.modifyFormFields(document, List.of(moveTo("choice", 90f, 700f, 20f, 20f)));
saved = save(document);
}
try (PDDocument reloaded = Loader.loadPDF(saved)) {
PDAcroForm acroForm = reloaded.getDocumentCatalog().getAcroForm(null);
List<PDAnnotationWidget> widgets = acroForm.getField("choice").getWidgets();
assertEquals(
40f,
widgets.get(1).getRectangle().getWidth(),
0.5f,
"a pure drag must not shrink the other options");
assertEquals(90f, widgets.get(0).getRectangle().getLowerLeftX(), 0.5f);
}
}
/** With no /AP and no /Opt the on-state must come from /V, not the invented "Yes". */
@Test
void resizingCheckboxWithoutAppearanceKeepsItsExportValue() throws IOException {
byte[] saved;
try (PDDocument document = new PDDocument()) {
setupForm(document);
FormUtils.addNewFields(
document, List.of(newField("checkbox", "agree", 50, 700, 14, 14, null)));
PDAcroForm form = document.getDocumentCatalog().getAcroForm(null);
PDCheckBox box = (PDCheckBox) form.getField("agree");
// A NeedAppearances form exported by Word/LibreOffice looks exactly like this.
box.getWidgets().get(0).getCOSObject().removeItem(COSName.AP);
box.getCOSObject().setItem(COSName.V, COSName.getPDFName("On"));
FormUtils.modifyFormFields(document, List.of(moveTo("agree", 50f, 700f, 30f, 30f)));
saved = save(document);
}
try (PDDocument reloaded = Loader.loadPDF(saved)) {
PDAcroForm acroForm = reloaded.getDocumentCatalog().getAcroForm(null);
PDCheckBox box = (PDCheckBox) acroForm.getField("agree");
assertEquals(
"On",
box.getOnValue(),
"the export value must survive; inventing 'Yes' would orphan /V");
assertTrue(box.isChecked(), "the box was ticked and must stay ticked");
}
}
/** Renaming to the same qualified name is not a rename, so a nested field is not rejected. */
@Test
void renameProblem_ignoresAnUnchangedQualifiedName() {
assertNull(
FormUtils.renameProblem("Customer.Name", "Customer.Name"),
"a field standing still must not be rejected for its parent's period");
assertNull(FormUtils.renameProblem("plain", null));
assertNotNull(
FormUtils.renameProblem("plain", "New.Name"),
"an actual rename introducing a period must still be refused");
}
/** A nested field whose name box was left at its qualified name must still be modified. */
@Test
void modifyingNestedFieldKeepsWorkingWhenNameIsUntouched() throws IOException {
byte[] saved;
try (PDDocument document = new PDDocument()) {
PDAcroForm form = setupForm(document);
FormUtils.addNewFields(
document, List.of(newField("text", "Name", 50, 700, 200, 20, null)));
// Re-parent it so its qualified name legitimately contains a period.
PDNonTerminalField parent = new PDNonTerminalField(form);
parent.setPartialName("Customer");
PDField child = form.getField("Name");
parent.setChildren(List.of(child));
child.getCOSObject().setItem(COSName.PARENT, parent.getCOSObject());
form.setFields(List.of(parent));
FormUtils.ModifyFormFieldDefinition mod =
new FormUtils.ModifyFormFieldDefinition(
"Customer.Name",
"Customer.Name",
null,
null,
0,
90f,
600f,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null);
List<FormUtils.SkippedFieldEdit> skipped = new ArrayList<>();
FormUtils.modifyFormFields(document, List.of(mod), skipped);
assertTrue(
skipped.isEmpty(), "an untouched qualified name is not a rename: " + skipped);
saved = save(document);
}
try (PDDocument reloaded = Loader.loadPDF(saved)) {
PDAcroForm acroForm = reloaded.getDocumentCatalog().getAcroForm(null);
PDField field = acroForm.getField("Customer.Name");
assertNotNull(field, "the nested field must survive the edit");
assertEquals(90f, field.getWidgets().get(0).getRectangle().getLowerLeftX(), 0.5f);
}
}
/** Zero clears /MaxLen; null means unchanged, so it could never be removed otherwise. */
@Test
void maxLengthZeroClearsTheCombSetting() throws IOException {
byte[] saved;
try (PDDocument document = new PDDocument()) {
setupForm(document);
FormUtils.addNewFields(
document,
List.of(
new FormUtils.NewFormFieldDefinition(
"code", null, "text", 0, 50f, 700f, 200f, 20f, null, null, null,
null, null, null, null, null, 8, null)));
PDAcroForm form = document.getDocumentCatalog().getAcroForm(null);
assertEquals(8, ((PDTextField) form.getField("code")).getMaxLen());
FormUtils.ModifyFormFieldDefinition clear =
new FormUtils.ModifyFormFieldDefinition(
"code", null, null, null, null, null, null, null, null, null, null,
null, null, null, null, null, null, 0, null);
FormUtils.modifyFormFields(document, List.of(clear));
saved = save(document);
}
try (PDDocument reloaded = Loader.loadPDF(saved)) {
PDAcroForm acroForm = reloaded.getDocumentCatalog().getAcroForm(null);
assertEquals(
-1,
((PDTextField) acroForm.getField("code")).getMaxLen(),
"/MaxLen should be gone, not merely zero");
}
}
/** An unrecognised button action must be reported rather than silently ignored. */
@Test
void unknownButtonActionIsReported() throws IOException {
try (PDDocument document = new PDDocument()) {
setupForm(document);
FormUtils.addNewFields(
document, List.of(newField("button", "go", 50, 700, 100, 24, null)));
FormUtils.ModifyFormFieldDefinition mod =
new FormUtils.ModifyFormFieldDefinition(
"go",
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
"launchTheMissiles");
List<FormUtils.SkippedFieldEdit> skipped = new ArrayList<>();
FormUtils.modifyFormFields(document, List.of(mod), skipped);
assertEquals(1, skipped.size(), "an unusable action spec should be reported");
assertTrue(skipped.get(0).reason().contains("launchTheMissiles"));
}
}
/** Renaming a nested field must not re-parent it to the top level. */
@Test
void renamingNestedFieldKeepsItUnderItsParent() throws IOException {
byte[] saved;
try (PDDocument document = new PDDocument()) {
PDAcroForm form = setupForm(document);
FormUtils.addNewFields(
document, List.of(newField("text", "Name", 50, 700, 200, 20, null)));
PDNonTerminalField parent = new PDNonTerminalField(form);
parent.setPartialName("Customer");
PDField child = form.getField("Name");
parent.setChildren(List.of(child));
child.getCOSObject().setItem(COSName.PARENT, parent.getCOSObject());
form.setFields(List.of(parent));
FormUtils.ModifyFormFieldDefinition rename =
new FormUtils.ModifyFormFieldDefinition(
"Customer.Name",
"Customer.Phone",
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null);
List<FormUtils.SkippedFieldEdit> skipped = new ArrayList<>();
FormUtils.modifyFormFields(document, List.of(rename), skipped);
assertTrue(
skipped.isEmpty(), "a leaf rename under the same parent is legal: " + skipped);
saved = save(document);
}
try (PDDocument reloaded = Loader.loadPDF(saved)) {
PDAcroForm acroForm = reloaded.getDocumentCatalog().getAcroForm(null);
assertNotNull(
acroForm.getField("Customer.Phone"),
"the field should still live under Customer, not at the top level");
assertNull(acroForm.getField("Customer.Name"), "the old name should be gone");
}
}
/** One rejected action on a multi-widget button is one report, not one per widget. */
@Test
void unknownButtonActionIsReportedOncePerField() throws IOException {
try (PDDocument document = new PDDocument()) {
setupForm(document);
FormUtils.addNewFields(
document, List.of(newField("button", "go", 50, 700, 100, 24, null)));
PDAcroForm form = document.getDocumentCatalog().getAcroForm(null);
PDField button = form.getField("go");
// Give it a second widget, as a button repeated on two pages would have.
PDAnnotationWidget extra = new PDAnnotationWidget();
extra.setRectangle(new PDRectangle(50, 600, 100, 24));
extra.getCOSObject().setItem(COSName.PARENT, button.getCOSObject());
List<PDAnnotationWidget> widgets = new ArrayList<>(button.getWidgets());
widgets.add(extra);
button.getCOSObject()
.setItem(
COSName.KIDS,
new org.apache.pdfbox.cos.COSArray() {
{
for (PDAnnotationWidget w : widgets) add(w.getCOSObject());
}
});
FormUtils.ModifyFormFieldDefinition mod =
new FormUtils.ModifyFormFieldDefinition(
"go",
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
"launchTheMissiles");
List<FormUtils.SkippedFieldEdit> skipped = new ArrayList<>();
FormUtils.modifyFormFields(document, List.of(mod), skipped);
assertEquals(1, skipped.size(), "one field, one report: " + skipped);
}
}
/** A clamped page index still creates the field, so it is not a dropped edit. */
@Test
void clampedPageIsNotReportedAsSkipped() throws IOException {
try (PDDocument document = new PDDocument()) {
setupForm(document);
List<FormUtils.SkippedFieldEdit> skipped = new ArrayList<>();
FormUtils.addNewFields(
document,
List.of(
new FormUtils.NewFormFieldDefinition(
"late", null, "text", 9, 50f, 700f, 100f, 20f, null, null, null,
null, null, null, null, null, null, null)),
skipped);
assertNotNull(
document.getDocumentCatalog().getAcroForm(null).getField("late"),
"the field is created on the clamped page");
assertTrue(skipped.isEmpty(), "an applied edit must not appear as skipped: " + skipped);
}
}
/** Recreation builds a top-level field, so it must refuse rather than re-parent. */
@Test
void typeChangeOnNestedFieldIsRefusedNotSilentlyReparented() throws IOException {
byte[] saved;
try (PDDocument document = new PDDocument()) {
PDAcroForm form = setupForm(document);
FormUtils.addNewFields(
document, List.of(newField("text", "Name", 50, 700, 200, 20, null)));
PDNonTerminalField parent = new PDNonTerminalField(form);
parent.setPartialName("Customer");
PDField child = form.getField("Name");
parent.setChildren(List.of(child));
child.getCOSObject().setItem(COSName.PARENT, parent.getCOSObject());
form.setFields(List.of(parent));
FormUtils.ModifyFormFieldDefinition retype =
new FormUtils.ModifyFormFieldDefinition(
"Customer.Name",
null,
null,
"checkbox",
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null);
List<FormUtils.SkippedFieldEdit> skipped = new ArrayList<>();
FormUtils.modifyFormFields(document, List.of(retype), skipped);
assertEquals(1, skipped.size(), "the refusal must be reported: " + skipped);
saved = save(document);
}
try (PDDocument reloaded = Loader.loadPDF(saved)) {
PDAcroForm acroForm = reloaded.getDocumentCatalog().getAcroForm(null);
assertNotNull(
acroForm.getField("Customer.Name"),
"the original nested field must be left intact");
assertNull(acroForm.getField("Name"), "nothing should be re-parented to the top level");
}
}
/** The editor emits "uri:" the moment that kind is picked, which must not fail the edit. */
@Test
void incompleteUrlActionClearsRatherThanFailing() throws IOException {
try (PDDocument document = new PDDocument()) {
setupForm(document);
FormUtils.addNewFields(
document, List.of(newField("button", "go", 50, 700, 100, 24, null)));
FormUtils.ModifyFormFieldDefinition pickUri =
new FormUtils.ModifyFormFieldDefinition(
"go", null, null, null, null, null, null, null, null, null, null, null,
null, null, null, null, null, null, "uri:");
List<FormUtils.SkippedFieldEdit> skipped = new ArrayList<>();
FormUtils.modifyFormFields(document, List.of(pickUri), skipped);
assertTrue(
skipped.isEmpty(),
"choosing a URL action before typing the URL is not an error: " + skipped);
PDField button = document.getDocumentCatalog().getAcroForm(null).getField("go");
assertNull(
button.getWidgets().get(0).getCOSObject().getDictionaryObject(COSName.A),
"an empty target must leave no action behind");
}
}
/** A real URL still writes a real action. */
@Test
void completeUrlActionIsApplied() throws IOException {
try (PDDocument document = new PDDocument()) {
setupForm(document);
FormUtils.addNewFields(
document, List.of(newField("button", "go", 50, 700, 100, 24, null)));
FormUtils.ModifyFormFieldDefinition setUri =
new FormUtils.ModifyFormFieldDefinition(
"go",
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
"uri:https://example.com");
List<FormUtils.SkippedFieldEdit> skipped = new ArrayList<>();
FormUtils.modifyFormFields(document, List.of(setUri), skipped);
assertTrue(skipped.isEmpty(), "a complete spec applies cleanly: " + skipped);
PDField button = document.getDocumentCatalog().getAcroForm(null).getField("go");
assertNotNull(
button.getWidgets().get(0).getCOSObject().getDictionaryObject(COSName.A),
"the action should be written");
}
}
/** Builds a parent with the given terminal children already attached. */
private static PDNonTerminalField nest(
PDDocument document, PDAcroForm form, String parentName, String... childNames)
throws IOException {
List<FormUtils.NewFormFieldDefinition> defs = new ArrayList<>();
for (int i = 0; i < childNames.length; i++) {
defs.add(newField("text", childNames[i], 50, 700 - i * 40, 200, 20, null));
}
FormUtils.addNewFields(document, defs);
PDNonTerminalField parent = new PDNonTerminalField(form);
parent.setPartialName(parentName);
List<PDField> kids = new ArrayList<>();
for (String child : childNames) {
PDField field = form.getField(child);
field.getCOSObject().setItem(COSName.PARENT, parent.getCOSObject());
kids.add(field);
}
parent.setChildren(kids);
form.setFields(List.of(parent));
return parent;
}
/** A refused edit must not release the name the field still really has. */
@Test
void refusedNestedEditDoesNotFreeItsNameForALaterEdit() throws IOException {
try (PDDocument document = new PDDocument()) {
PDAcroForm form = setupForm(document);
nest(document, form, "Customer", "Name", "Email");
// Edit 1 is refused (type change on a nested field). Edit 2 then asks for the
// name edit 1 still occupies, which must not be handed out.
FormUtils.ModifyFormFieldDefinition refused =
new FormUtils.ModifyFormFieldDefinition(
"Customer.Name",
"Customer.Foo",
null,
"checkbox",
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null);
FormUtils.ModifyFormFieldDefinition rename =
new FormUtils.ModifyFormFieldDefinition(
"Customer.Email",
"Customer.Name",
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null);
List<FormUtils.SkippedFieldEdit> skipped = new ArrayList<>();
FormUtils.modifyFormFields(document, List.of(refused, rename), skipped);
List<String> names = new ArrayList<>();
for (PDField f : document.getDocumentCatalog().getAcroForm(null).getFieldTree()) {
if (f instanceof PDTerminalField) names.add(f.getFullyQualifiedName());
}
assertEquals(
names.size(),
new java.util.HashSet<>(names).size(),
"two fields must never share a qualified name: " + names);
assertTrue(
names.contains("Customer.Name"), "the refused field keeps its name: " + names);
}
}
/** A group name occupies the namespace, so a new field must not be able to take it. */
@Test
void groupNamesParticipateInCollisionChecks() throws IOException {
try (PDDocument document = new PDDocument()) {
PDAcroForm form = setupForm(document);
nest(document, form, "Customer", "Name");
FormUtils.addNewFields(
document, List.of(newField("text", "Customer", 50, 500, 100, 20, null)));
List<String> names = new ArrayList<>();
for (PDField f : document.getDocumentCatalog().getAcroForm(null).getFieldTree()) {
String fqn = f.getFullyQualifiedName();
if (fqn != null) names.add(fqn);
}
assertEquals(
names.size(),
new java.util.HashSet<>(names).size(),
"the new field must not take the group's name: " + names);
}
}
/** "Customer." has no leaf, so it must be refused rather than become "Customer.field". */
@Test
void renameToBareParentPrefixIsRefused() {
assertNotNull(
FormUtils.renameProblem("Customer.Name", "Customer."),
"a name with nothing after the parent prefix is not a rename");
assertNull(FormUtils.renameProblem("Customer.Name", "Customer.Phone"));
}
/** A type change must leave the field on its own page, not relocate it to the last one. */
@Test
void typeChangeKeepsTheFieldOnItsPage() throws IOException {
byte[] saved;
try (PDDocument document = new PDDocument()) {
PDAcroForm form = new PDAcroForm(document);
for (int i = 0; i < 5; i++) {
document.addPage(new PDPage(PDRectangle.A4));
}
form.setDefaultResources(new PDResources());
document.getDocumentCatalog().setAcroForm(form);
FormUtils.addNewFields(
document,
List.of(
new FormUtils.NewFormFieldDefinition(
"onPageTwo",
null,
"text",
1,
50f,
700f,
200f,
20f,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null)));
FormUtils.ModifyFormFieldDefinition retype =
new FormUtils.ModifyFormFieldDefinition(
"onPageTwo",
null,
null,
"checkbox",
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null);
FormUtils.modifyFormFields(document, List.of(retype));
saved = save(document);
}
try (PDDocument reloaded = Loader.loadPDF(saved)) {
PDAcroForm acroForm = reloaded.getDocumentCatalog().getAcroForm(null);
PDField field = acroForm.getField("onPageTwo");
assertNotNull(field, "the retyped field should exist");
int page = -1;
for (int i = 0; i < reloaded.getNumberOfPages(); i++) {
for (var annot : reloaded.getPage(i).getAnnotations()) {
if (annot.getCOSObject() == field.getWidgets().get(0).getCOSObject()) page = i;
}
}
assertEquals(
1, page, "a retyped field must stay on its own page, not move to the last");
}
}
}
@@ -0,0 +1,118 @@
package stirling.software.common.util;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import org.apache.pdfbox.Loader;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.pdmodel.common.PDRectangle;
import org.apache.pdfbox.pdmodel.interactive.form.PDAcroForm;
import org.apache.pdfbox.pdmodel.interactive.form.PDCheckBox;
import org.junit.jupiter.api.Test;
/** An edit the backend cannot honour must be reported, not logged and reported as success. */
class FormUtilsEditReportingTest {
private static FormUtils.NewFormFieldDefinition field(String type, String name) {
return new FormUtils.NewFormFieldDefinition(
name, name, type, 0, 60f, 700f, 120f, 20f, null, null, null, null, null, null, null,
null, null, null);
}
private static PDDocument blank() {
PDDocument document = new PDDocument();
document.addPage(new PDPage(PDRectangle.LETTER));
document.getDocumentCatalog().setAcroForm(new PDAcroForm(document));
return document;
}
@Test
void anUncreatableTypeIsReportedRatherThanSilentlyMadeText() throws IOException {
List<FormUtils.SkippedFieldEdit> skipped = new ArrayList<>();
try (PDDocument document = blank()) {
FormUtils.addNewFields(document, List.of(field("nonsense", "mystery")), skipped);
PDAcroForm acroForm = document.getDocumentCatalog().getAcroForm(null);
assertTrue(
acroForm.getFields().isEmpty(),
"an unsupported type must not quietly become a text field");
}
assertEquals(1, skipped.size(), "the caller must be told: " + skipped);
assertTrue(skipped.get(0).reason().contains("nonsense"), skipped.get(0).reason());
}
@Test
void aLyingPageCountIsSurvivable() throws IOException {
// /Count overstates the tree, so getNumberOfPages() passes the guard but getPage throws.
byte[] broken =
("%PDF-1.4\n"
+ "1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n"
+ "2 0 obj << /Type /Pages /Count 1 /Kids [] >> endobj\n"
+ "trailer << /Root 1 0 R >>\n")
.getBytes(java.nio.charset.StandardCharsets.ISO_8859_1);
List<FormUtils.SkippedFieldEdit> skipped = new ArrayList<>();
try (PDDocument document = Loader.loadPDF(broken)) {
// Must not throw; the field is reported as skipped instead.
FormUtils.addNewFields(document, List.of(field("text", "ghost")), skipped);
} catch (IOException loadFailure) {
// A parser that refuses the file outright is an equally acceptable outcome.
return;
}
assertFalse(skipped.isEmpty(), "an unreachable page must be reported, not thrown");
}
@Test
void aTwoWidgetCheckboxKeepsItsOnStateWhenMoved() throws IOException {
byte[] saved;
try (PDDocument document = new PDDocument()) {
document.addPage(new PDPage(PDRectangle.LETTER));
document.addPage(new PDPage(PDRectangle.LETTER));
document.getDocumentCatalog().setAcroForm(new PDAcroForm(document));
FormUtils.addNewFields(
document,
List.of(
new FormUtils.NewFormFieldDefinition(
"agree",
"agree",
"checkbox",
0,
60f,
700f,
14f,
14f,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null)),
new ArrayList<>());
FormUtils.modifyFormFields(
document,
List.of(
new FormUtils.ModifyFormFieldDefinition(
"agree", null, null, null, 0, 200f, 400f, null, null, null,
null, null, null, null, null, null, null, null, null)));
ByteArrayOutputStream out = new ByteArrayOutputStream();
document.save(out);
saved = out.toByteArray();
}
try (PDDocument reloaded = Loader.loadPDF(saved)) {
PDAcroForm acroForm = reloaded.getDocumentCatalog().getAcroForm(null);
PDCheckBox box = (PDCheckBox) acroForm.getField("agree");
assertNotNull(box);
assertFalse(box.getOnValue().isEmpty(), "a moved checkbox must stay tickable");
}
}
}
@@ -0,0 +1,467 @@
package stirling.software.common.util;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.util.List;
import java.util.Set;
import org.apache.pdfbox.Loader;
import org.apache.pdfbox.cos.COSName;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.pdmodel.PDResources;
import org.apache.pdfbox.pdmodel.common.PDRectangle;
import org.apache.pdfbox.pdmodel.interactive.annotation.PDAnnotationWidget;
import org.apache.pdfbox.pdmodel.interactive.annotation.PDAppearanceDictionary;
import org.apache.pdfbox.pdmodel.interactive.annotation.PDAppearanceEntry;
import org.apache.pdfbox.pdmodel.interactive.form.PDAcroForm;
import org.apache.pdfbox.pdmodel.interactive.form.PDCheckBox;
import org.apache.pdfbox.pdmodel.interactive.form.PDField;
import org.apache.pdfbox.pdmodel.interactive.form.PDPushButton;
import org.apache.pdfbox.pdmodel.interactive.form.PDRadioButton;
import org.apache.pdfbox.pdmodel.interactive.form.PDSignatureField;
import org.apache.pdfbox.pdmodel.interactive.form.PDTextField;
import org.apache.pdfbox.pdmodel.interactive.form.PDVariableText;
import org.junit.jupiter.api.Test;
/**
* Assertions run after a save/reload cycle: PDFBox synthesises widgets for fields with no explicit
* {@code /Kids}, so only the serialised document reflects what a viewer sees.
*/
class FormUtilsEditingTest {
private static PDAcroForm setupForm(PDDocument document, PDRectangle pageSize) {
PDPage page = new PDPage(pageSize);
document.addPage(page);
PDAcroForm acroForm = new PDAcroForm(document);
acroForm.setDefaultResources(new PDResources());
document.getDocumentCatalog().setAcroForm(acroForm);
return acroForm;
}
private static byte[] save(PDDocument document) throws IOException {
ByteArrayOutputStream baos = new ByteArrayOutputStream();
document.save(baos);
return baos.toByteArray();
}
private static FormUtils.NewFormFieldDefinition newText(
String name, float x, float y, float w, float h) {
return new FormUtils.NewFormFieldDefinition(
name, null, "text", 0, x, y, w, h, null, null, null, null, null, null, null, null,
null, null);
}
private static FormUtils.NewFormFieldDefinition newField(
String type,
String name,
float x,
float y,
float w,
float h,
List<String> options,
Integer maxLength,
String buttonAction) {
return new FormUtils.NewFormFieldDefinition(
name,
null,
type,
0,
x,
y,
w,
h,
null,
null,
options,
null,
null,
null,
null,
null,
maxLength,
buttonAction);
}
private static PDRectangle firstWidgetRect(PDAcroForm acroForm, String name) {
PDField field = acroForm.getField(name);
assertNotNull(field, "field '" + name + "' should exist");
assertTrue(!field.getWidgets().isEmpty(), "field should have at least one widget");
return field.getWidgets().get(0).getRectangle();
}
/**
* PDAcroForm.refreshAppearances() never synthesizes /AP for the button family, so without an
* explicit appearance a created checkbox or radio renders blank and resolves to Off.
*/
@Test
void addNewFields_givesToggleFieldsAppearanceStreamsAndKeepsTheirDefault() throws IOException {
byte[] saved;
try (PDDocument document = new PDDocument()) {
setupForm(document, PDRectangle.A4);
FormUtils.addNewFields(
document,
List.of(
newField("checkbox", "agree", 50, 600, 20, 20, null, null, null),
newField(
"radio",
"choice",
50,
500,
20,
20,
List.of("Yes", "No"),
null,
null),
newText("fullname", 50, 400, 200, 24)));
saved = save(document);
}
try (PDDocument reloaded = Loader.loadPDF(saved)) {
PDAcroForm acroForm = reloaded.getDocumentCatalog().getAcroForm(null);
assertNotNull(acroForm);
// NeedAppearances=false means viewers trust our streams, so they must exist.
assertFalse(acroForm.getNeedAppearances(), "appearance generation should have run");
PDField checkBox = acroForm.getField("agree");
assertTrue(checkBox instanceof PDCheckBox);
assertEquals(
Set.of("Off", "Yes"),
normalStateNames(checkBox.getWidgets().get(0)),
"checkbox needs an Off and an on-state appearance");
PDField radio = acroForm.getField("choice");
assertTrue(radio instanceof PDRadioButton);
assertEquals(2, radio.getWidgets().size());
assertEquals(Set.of("Off", "Yes"), normalStateNames(radio.getWidgets().get(0)));
assertEquals(Set.of("Off", "No"), normalStateNames(radio.getWidgets().get(1)));
// A text field's DA names /Helv; if /DR lacks that alias refreshAppearances throws for
// the whole form and every field above loses its appearance too.
PDField text = acroForm.getField("fullname");
assertNotNull(
text.getWidgets().get(0).getAppearance().getNormalAppearance(),
"text field should have a generated appearance");
}
}
/** The /AP /N state names on a widget. */
private static Set<String> normalStateNames(PDAnnotationWidget widget) {
PDAppearanceDictionary appearance = widget.getAppearance();
assertNotNull(appearance, "widget should have an /AP dictionary");
PDAppearanceEntry normal = appearance.getNormalAppearance();
assertNotNull(normal, "widget should have an /AP /N entry");
assertTrue(normal.isSubDictionary(), "a toggle needs per-state appearances");
return normal.getSubDictionary().keySet().stream()
.map(COSName::getName)
.collect(java.util.stream.Collectors.toSet());
}
@Test
void addNewFields_createsTextFieldAtRequestedRectangle() throws IOException {
byte[] saved;
try (PDDocument document = new PDDocument()) {
setupForm(document, PDRectangle.A4);
FormUtils.addNewFields(document, List.of(newText("created", 50, 700, 200, 20)));
saved = save(document);
}
try (PDDocument reloaded = Loader.loadPDF(saved)) {
PDAcroForm acroForm = reloaded.getDocumentCatalog().getAcroForm(null);
assertNotNull(acroForm, "AcroForm should exist after reload");
assertTrue(acroForm.getField("created") instanceof PDTextField);
PDRectangle rect = firstWidgetRect(acroForm, "created");
assertNotNull(rect, "created widget should keep its rectangle after reload");
assertEquals(50f, rect.getLowerLeftX(), 0.5f);
assertEquals(700f, rect.getLowerLeftY(), 0.5f);
assertEquals(200f, rect.getWidth(), 0.5f);
assertEquals(20f, rect.getHeight(), 0.5f);
}
}
@Test
void addNewFields_appliesCropBoxOffsetToCoordinates() throws IOException {
byte[] saved;
try (PDDocument document = new PDDocument()) {
setupForm(document, PDRectangle.A4);
// Shift the CropBox origin; the frontend sends CropBox-relative coords.
document.getPage(0).setCropBox(new PDRectangle(10, 20, 500, 700));
FormUtils.addNewFields(document, List.of(newText("shifted", 5, 5, 100, 15)));
saved = save(document);
}
try (PDDocument reloaded = Loader.loadPDF(saved)) {
PDAcroForm acroForm = reloaded.getDocumentCatalog().getAcroForm(null);
PDRectangle rect = firstWidgetRect(acroForm, "shifted");
// Absolute = CropBox-relative + CropBox lower-left offset.
assertEquals(15f, rect.getLowerLeftX(), 0.5f);
assertEquals(25f, rect.getLowerLeftY(), 0.5f);
}
}
@Test
void addNewFields_appliesReadOnlyFontSizeAndMultiline() throws IOException {
byte[] saved;
try (PDDocument document = new PDDocument()) {
setupForm(document, PDRectangle.A4);
FormUtils.NewFormFieldDefinition def =
new FormUtils.NewFormFieldDefinition(
"opts",
null,
"text",
0,
10f,
10f,
120f,
18f,
null,
null,
null,
null,
null,
18f,
Boolean.TRUE,
Boolean.TRUE,
null,
null);
FormUtils.addNewFields(document, List.of(def));
saved = save(document);
}
try (PDDocument reloaded = Loader.loadPDF(saved)) {
PDAcroForm acroForm = reloaded.getDocumentCatalog().getAcroForm(null);
PDField field = acroForm.getField("opts");
assertNotNull(field);
assertTrue(field.isReadOnly(), "read-only flag should survive reload");
assertTrue(field instanceof PDTextField);
assertTrue(((PDTextField) field).isMultiline(), "multiline flag should survive reload");
String da = ((PDVariableText) field).getDefaultAppearance();
assertTrue(da.contains("18"), "default appearance should carry the font size: " + da);
}
}
@Test
void modifyFormFields_movesAndResizesWidget() throws IOException {
byte[] saved;
try (PDDocument document = new PDDocument()) {
setupForm(document, PDRectangle.A4);
FormUtils.addNewFields(document, List.of(newText("movable", 50, 700, 200, 20)));
FormUtils.ModifyFormFieldDefinition mod =
new FormUtils.ModifyFormFieldDefinition(
"movable", null, null, null, 0, 100f, 600f, 150f, 30f, null, null, null,
null, null, null, null, null, null, null);
FormUtils.modifyFormFields(document, List.of(mod));
saved = save(document);
}
try (PDDocument reloaded = Loader.loadPDF(saved)) {
PDAcroForm acroForm = reloaded.getDocumentCatalog().getAcroForm(null);
PDRectangle rect = firstWidgetRect(acroForm, "movable");
assertEquals(100f, rect.getLowerLeftX(), 0.5f);
assertEquals(600f, rect.getLowerLeftY(), 0.5f);
assertEquals(150f, rect.getWidth(), 0.5f);
assertEquals(30f, rect.getHeight(), 0.5f);
}
}
@Test
void modifyFormFields_setsReadOnlyAndFontSize() throws IOException {
byte[] saved;
try (PDDocument document = new PDDocument()) {
setupForm(document, PDRectangle.A4);
FormUtils.addNewFields(document, List.of(newText("editable", 50, 700, 200, 20)));
FormUtils.ModifyFormFieldDefinition mod =
new FormUtils.ModifyFormFieldDefinition(
"editable",
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
22f,
Boolean.TRUE,
null,
null,
null);
FormUtils.modifyFormFields(document, List.of(mod));
saved = save(document);
}
try (PDDocument reloaded = Loader.loadPDF(saved)) {
PDAcroForm acroForm = reloaded.getDocumentCatalog().getAcroForm(null);
PDField field = acroForm.getField("editable");
assertNotNull(field);
assertTrue(field.isReadOnly(), "read-only flag should survive reload");
String da = ((PDVariableText) field).getDefaultAppearance();
assertTrue(da.contains("22"), "font size should be reflected in DA: " + da);
}
}
@Test
void deleteFormFields_removesField() throws IOException {
byte[] saved;
try (PDDocument document = new PDDocument()) {
PDAcroForm acroForm = setupForm(document, PDRectangle.A4);
FormUtils.addNewFields(document, List.of(newText("temp", 50, 700, 200, 20)));
FormUtils.deleteFormFields(document, List.of("temp"));
// After delete the AcroForm may still exist; the field must be gone.
if (acroForm != null) {
assertNull(acroForm.getField("temp"));
}
saved = save(document);
}
try (PDDocument reloaded = Loader.loadPDF(saved)) {
PDAcroForm acroForm = reloaded.getDocumentCatalog().getAcroForm(null);
assertTrue(acroForm == null || acroForm.getField("temp") == null);
}
}
@Test
void addNewFields_createsRadioGroupWithOneWidgetPerOption() throws IOException {
byte[] saved;
try (PDDocument document = new PDDocument()) {
setupForm(document, PDRectangle.A4);
FormUtils.addNewFields(
document,
List.of(
newField(
"radio",
"choice",
60,
700,
16,
16,
List.of("Yes", "No"),
null,
null)));
saved = save(document);
}
try (PDDocument reloaded = Loader.loadPDF(saved)) {
PDAcroForm acroForm = reloaded.getDocumentCatalog().getAcroForm(null);
PDField field = acroForm.getField("choice");
assertNotNull(field, "radio field should exist");
assertTrue(field instanceof PDRadioButton, "should be a radio button group");
assertEquals(2, field.getWidgets().size(), "one widget per option");
assertTrue(((PDRadioButton) field).getExportValues().contains("Yes"));
assertTrue(((PDRadioButton) field).getExportValues().contains("No"));
}
}
@Test
void extractFormFields_prefersFieldNameOverFirstOptionForChoiceLabel() throws IOException {
// A radio group's label is its field name, not its first option, so the viewer label
// matches the name shown in the editor.
byte[] saved;
try (PDDocument document = new PDDocument()) {
setupForm(document, PDRectangle.A4);
FormUtils.addNewFields(
document,
List.of(
newField(
"radio",
"Choice",
60,
700,
16,
16,
List.of("Yes", "No"),
null,
null)));
saved = save(document);
}
try (PDDocument reloaded = Loader.loadPDF(saved)) {
FormUtils.FormFieldInfo choice =
FormUtils.extractFormFields(reloaded).stream()
.filter(f -> "Choice".equals(f.name()))
.findFirst()
.orElse(null);
assertNotNull(choice, "radio field should be extracted");
assertEquals(
"Choice", choice.label(), "field name should win over the first option value");
}
}
@Test
void addNewFields_createsCombTextField() throws IOException {
byte[] saved;
try (PDDocument document = new PDDocument()) {
setupForm(document, PDRectangle.A4);
FormUtils.addNewFields(
document, List.of(newField("text", "ssn", 50, 700, 200, 20, null, 9, null)));
saved = save(document);
}
try (PDDocument reloaded = Loader.loadPDF(saved)) {
PDAcroForm acroForm = reloaded.getDocumentCatalog().getAcroForm(null);
PDTextField field = (PDTextField) acroForm.getField("ssn");
assertNotNull(field);
assertEquals(9, field.getMaxLen(), "comb max length should persist");
assertTrue(field.isComb(), "comb flag should be set");
}
}
@Test
void addNewFields_createsSignatureAndButton() throws IOException {
byte[] saved;
try (PDDocument document = new PDDocument()) {
setupForm(document, PDRectangle.A4);
FormUtils.addNewFields(
document,
List.of(
newField("signature", "sig", 50, 600, 200, 60, null, null, null),
newField("button", "btn", 50, 500, 120, 24, null, null, "reset")));
saved = save(document);
}
try (PDDocument reloaded = Loader.loadPDF(saved)) {
PDAcroForm acroForm = reloaded.getDocumentCatalog().getAcroForm(null);
assertTrue(
acroForm.getField("sig") instanceof PDSignatureField,
"signature placeholder should exist");
assertTrue(
acroForm.getField("btn") instanceof PDPushButton, "push button should exist");
}
}
@Test
void applyFieldEdits_addsModifiesAndDeletesInOnePass() throws IOException {
byte[] saved;
try (PDDocument document = new PDDocument()) {
setupForm(document, PDRectangle.A4);
FormUtils.addNewFields(document, List.of(newText("old", 50, 700, 200, 20)));
FormUtils.applyFieldEdits(
document,
List.of(newText("fresh", 50, 600, 200, 20)),
List.of(),
List.of("old"));
saved = save(document);
}
try (PDDocument reloaded = Loader.loadPDF(saved)) {
PDAcroForm acroForm = reloaded.getDocumentCatalog().getAcroForm(null);
assertNotNull(acroForm.getField("fresh"), "added field should be present");
assertNull(acroForm.getField("old"), "deleted field should be gone");
}
}
}
@@ -705,10 +705,20 @@ class FormUtilsGapTest {
"newName",
"New Label",
null, // keep type (text) -> in-place path
null,
null,
null,
null,
null,
Boolean.TRUE,
null,
null,
null,
null,
null,
null,
null,
null,
null);
FormUtils.modifyFormFields(doc, List.of(mod));
@@ -731,7 +741,8 @@ class FormUtilsGapTest {
FormUtils.ModifyFormFieldDefinition mod =
new FormUtils.ModifyFormFieldDefinition(
"missing", null, null, null, null, null, null, null, null);
"missing", null, null, null, null, null, null, null, null, null,
null, null, null, null, null, null, null, null, null);
FormUtils.modifyFormFields(doc, List.of(mod));
@@ -754,7 +765,8 @@ class FormUtilsGapTest {
mods.add(null);
mods.add(
new FormUtils.ModifyFormFieldDefinition(
" ", null, null, null, null, null, null, null, null));
" ", null, null, null, null, null, null, null, null, null, null,
null, null, null, null, null, null, null, null));
FormUtils.modifyFormFields(doc, mods);
assertEquals(1, FormUtils.extractFormFields(doc).size());
@@ -285,13 +285,13 @@ class FormUtilsMoreTest {
}
@Test
void widgetOutOfBoundsYieldsNullCoordinateEntry() throws IOException {
void widgetOutOfBoundsStillReportsItsCoordinates() throws IOException {
try (PDDocument doc = new PDDocument()) {
SetupDocument setup = createBasicDocument(doc);
PDTextField text = new PDTextField(setup.acroForm());
text.setPartialName("offpage");
// Far below the page origin -> finalY exceeds bounds -> createWidgetCoordinates
// returns null, which is still added to the per-field widget list.
// Off the page is legal PDF; dropping it would leave the user unable to drag it
// back.
attachWidget(setup, text, new PDRectangle(50, -5000, 200, 20));
List<FormFieldWithCoordinates> fields =
@@ -301,7 +301,8 @@ class FormUtilsMoreTest {
fields.get(0).getWidgets();
assertNotNull(widgets);
assertEquals(1, widgets.size());
assertNull(widgets.get(0));
assertNotNull(widgets.get(0), "a null entry here crashes sorting and the overlay");
assertEquals(50f, widgets.get(0).getX(), 0.01f);
}
}
@@ -476,8 +477,18 @@ class FormUtilsMoreTest {
"combobox",
null,
null,
null,
null,
null,
null,
null,
List.of("One", "Two"),
"One",
null,
null,
null,
null,
null,
null);
FormUtils.modifyFormFields(doc, List.of(mod));
@@ -505,10 +516,20 @@ class FormUtilsMoreTest {
null,
"listbox", // same type -> in-place path
null,
null,
null,
null,
null,
null,
Boolean.TRUE,
List.of("X", "Y", "Z"),
null,
"Choose items");
"Choose items",
null,
null,
null,
null,
null);
FormUtils.modifyFormFields(doc, List.of(mod));
@@ -529,7 +550,25 @@ class FormUtilsMoreTest {
FormUtils.ModifyFormFieldDefinition mod =
new FormUtils.ModifyFormFieldDefinition(
"keep", null, null, "bogusType", null, null, null, null, null);
"keep",
null,
null,
"bogusType",
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null);
FormUtils.modifyFormFields(doc, List.of(mod));
// The field is preserved unchanged because the target type is unsupported.
@@ -554,7 +593,8 @@ class FormUtilsMoreTest {
// Rename beta -> alpha; should be uniquified to avoid the collision.
FormUtils.ModifyFormFieldDefinition mod =
new FormUtils.ModifyFormFieldDefinition(
"beta", "alpha", null, null, null, null, null, null, null);
"beta", "alpha", null, null, null, null, null, null, null, null,
null, null, null, null, null, null, null, null, null);
FormUtils.modifyFormFields(doc, List.of(mod));
@@ -575,7 +615,8 @@ class FormUtilsMoreTest {
doc.addPage(new PDPage());
FormUtils.ModifyFormFieldDefinition mod =
new FormUtils.ModifyFormFieldDefinition(
"x", null, null, null, null, null, null, null, null);
"x", null, null, null, null, null, null, null, null, null, null,
null, null, null, null, null, null, null, null);
FormUtils.modifyFormFields(doc, List.of(mod));
}
}
@@ -0,0 +1,102 @@
package stirling.software.common.util;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import org.apache.pdfbox.Loader;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.interactive.form.PDAcroForm;
import org.apache.pdfbox.pdmodel.interactive.form.PDTextField;
import org.junit.jupiter.api.Test;
/**
* Most real PDFs have no AcroForm at all, so adding the very first field has to build one that
* PDFBox will accept.
*/
class FormUtilsNoAcroFormTest {
private static final Path PLAIN_PDF =
Path.of("src/test/resources/pdf-ingestion-fixtures/many-tables-test_stress.pdf");
private static FormUtils.NewFormFieldDefinition newField(
String type, String name, float y, List<String> options, String defaultValue) {
// name, label, type, pageIndex, x, y, width, height, required, multiSelect,
// options, defaultValue, tooltip, fontSize, readOnly, multiline, maxLength, buttonAction
return new FormUtils.NewFormFieldDefinition(
name,
name,
type,
0,
60f,
y,
200f,
20f,
null,
null,
options,
defaultValue,
null,
null,
null,
null,
null,
null);
}
private static PDDocument loadPlain() throws IOException {
return Loader.loadPDF(Files.readAllBytes(PLAIN_PDF));
}
@Test
void plainPdfReallyHasNoAcroForm() throws IOException {
try (PDDocument document = loadPlain()) {
assertNull(
document.getDocumentCatalog().getAcroForm(null),
"fixture must have no AcroForm or this test proves nothing");
}
}
@Test
void addsFirstFieldToAPdfWithNoAcroForm() throws IOException {
byte[] saved;
List<FormUtils.SkippedFieldEdit> skipped = new ArrayList<>();
try (PDDocument document = loadPlain()) {
FormUtils.addNewFields(
document,
List.of(
newField("text", "fullName", 700f, null, "Ada"),
newField("checkbox", "agree", 660f, null, null),
newField("radio", "contact", 600f, List.of("Email", "Post"), null)),
skipped);
ByteArrayOutputStream out = new ByteArrayOutputStream();
document.save(out);
saved = out.toByteArray();
}
assertTrue(skipped.isEmpty(), "no field should be skipped: " + skipped);
try (PDDocument reloaded = Loader.loadPDF(saved)) {
PDAcroForm acroForm = reloaded.getDocumentCatalog().getAcroForm(null);
assertNotNull(acroForm, "an AcroForm should have been created");
assertNotNull(acroForm.getDefaultResources(), "/DR is required for variable text");
assertTrue(
acroForm.getDefaultAppearance() != null
&& !acroForm.getDefaultAppearance().isBlank(),
"/DA is required for variable text");
PDTextField text = (PDTextField) acroForm.getField("fullName");
assertNotNull(text, "the text field should exist");
assertEquals("Ada", text.getValueAsString());
assertNotNull(acroForm.getField("agree"));
assertNotNull(acroForm.getField("contact"));
}
}
}
@@ -0,0 +1,175 @@
package stirling.software.common.util;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.util.List;
import org.apache.pdfbox.Loader;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.pdmodel.common.PDRectangle;
import org.apache.pdfbox.pdmodel.interactive.form.PDAcroForm;
import org.apache.pdfbox.pdmodel.interactive.form.PDRadioButton;
import org.apache.pdfbox.text.PDFTextStripper;
import org.junit.jupiter.api.Test;
/**
* Option captions belong to the viewer, not the page. Drawing them into the content stream left
* orphan text behind on every move and delete, so these pin the page staying clean.
*/
class FormUtilsRadioCaptionTest {
private static FormUtils.NewFormFieldDefinition newField(
String type, String name, float x, float y, float w, float h, List<String> options) {
return new FormUtils.NewFormFieldDefinition(
name, null, type, 0, x, y, w, h, null, null, options, null, null, null, null, null,
null, null);
}
private static byte[] save(PDDocument document) throws IOException {
ByteArrayOutputStream out = new ByteArrayOutputStream();
document.save(out);
return out.toByteArray();
}
private static PDDocument blankWithForm() {
PDDocument document = new PDDocument();
document.addPage(new PDPage(PDRectangle.LETTER));
document.getDocumentCatalog().setAcroForm(new PDAcroForm(document));
return document;
}
private static String textOf(byte[] pdf) throws IOException {
try (PDDocument reloaded = Loader.loadPDF(pdf)) {
return new PDFTextStripper().getText(reloaded);
}
}
@Test
void radioOptionsAreNotBakedIntoThePage() throws IOException {
byte[] saved;
try (PDDocument document = blankWithForm()) {
FormUtils.addNewFields(
document,
List.of(
newField(
"radio",
"contact",
72,
600,
12,
12,
List.of("Email", "Telephone", "Post"))));
saved = save(document);
}
// The caption is the viewer's job; page content cannot follow a widget that moves.
String text = textOf(saved);
assertFalse(text.contains("Email"), "options must not be page content: " + text);
assertFalse(text.contains("Telephone"), "options must not be page content: " + text);
assertFalse(text.contains("Post"), "options must not be page content: " + text);
}
@Test
void captionsDoNotReplaceTheWidgetsThemselves() throws IOException {
byte[] saved;
try (PDDocument document = blankWithForm()) {
FormUtils.addNewFields(
document,
List.of(newField("radio", "size", 72, 600, 12, 12, List.of("S", "M", "L"))));
saved = save(document);
}
try (PDDocument reloaded = Loader.loadPDF(saved)) {
PDAcroForm acroForm = reloaded.getDocumentCatalog().getAcroForm(null);
PDRadioButton radio = (PDRadioButton) acroForm.getField("size");
assertEquals(3, radio.getWidgets().size(), "one widget per option");
assertFalse(radio.getExportValues().isEmpty(), "export values must survive");
}
}
@Test
void aTextFieldDrawsNoStrayCaption() throws IOException {
// Control: proves the assertions above read the captions and not some unrelated content.
byte[] saved;
try (PDDocument document = blankWithForm()) {
FormUtils.addNewFields(
document, List.of(newField("text", "fullName", 72, 600, 200, 18, null)));
saved = save(document);
}
assertTrue(textOf(saved).isBlank(), "a text field should add no page content");
}
@Test
void deletingARadioGroupTakesItsCaptionsWithIt() throws IOException {
byte[] withRadio;
try (PDDocument document = blankWithForm()) {
FormUtils.addNewFields(
document,
List.of(
newField(
"radio",
"contact",
72,
600,
12,
12,
List.of("Email", "Telephone", "Post"))));
withRadio = save(document);
}
assertFalse(
textOf(withRadio).contains("Telephone"),
"the group adds no page text to begin with");
byte[] afterDelete;
try (PDDocument document = Loader.loadPDF(withRadio)) {
FormUtils.applyFieldEdits(document, List.of(), List.of(), List.of("contact"));
afterDelete = save(document);
}
String text = textOf(afterDelete);
assertFalse(
text.contains("Telephone"),
"a deleted radio group must not leave its captions on the page: " + text);
}
@Test
void theDrawnBoxIsTheWholeGroupNotOneOption() {
// A 90pt box used to become a 360pt stack because each option got the full height.
PDRectangle box = new PDRectangle(72f, 500f, 100f, 90f);
var rects = FormUtils.radioOptionRects(box, 3, null, null);
assertEquals(3, rects.size());
float top = rects.get(0).getUpperRightY();
float bottom = rects.get(2).getLowerLeftY();
assertEquals(90f, top - bottom, 0.01f, "the group must fill exactly the drawn height");
assertEquals(
box.getUpperRightY(), top, 0.01f, "the first option starts at the box's top edge");
for (PDRectangle r : rects) {
assertEquals(r.getWidth(), r.getHeight(), 0.01f, "options stay square");
assertTrue(r.getWidth() <= box.getWidth() + 0.01f, "an option never exceeds the box");
}
}
@Test
void explicitSizeAndGapWin() {
PDRectangle box = new PDRectangle(0f, 0f, 100f, 90f);
var rects = FormUtils.radioOptionRects(box, 3, 20f, 14f);
for (PDRectangle r : rects) {
assertEquals(14f, r.getHeight(), 0.01f, "the requested size is used verbatim");
}
float gap = rects.get(0).getLowerLeftY() - rects.get(1).getUpperRightY();
assertEquals(20f, gap, 0.01f, "the requested gap is used verbatim");
}
@Test
void aSingleOptionStillFitsTheBox() {
var rects = FormUtils.radioOptionRects(new PDRectangle(0f, 0f, 40f, 40f), 1, null, null);
assertEquals(1, rects.size());
assertTrue(rects.get(0).getHeight() <= 40f, "one option cannot exceed its box");
}
}
@@ -0,0 +1,57 @@
package stirling.software.common.util;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.pdmodel.common.PDRectangle;
import org.apache.pdfbox.pdmodel.interactive.form.PDAcroForm;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
/** A form with no default resources is ordinary; adding a field to it must still work. */
class MissingDefaultResourcesTest {
@Test
@DisplayName("a text field can be added to a form that has no default resources")
void addsToFormWithoutDefaultResources() throws IOException {
// A real upload arrives as bytes, and plenty of forms in the wild carry no /DR at all.
byte[] pdf;
try (PDDocument built = new PDDocument();
java.io.ByteArrayOutputStream out = new java.io.ByteArrayOutputStream()) {
built.addPage(new PDPage(PDRectangle.A4));
PDAcroForm form = new PDAcroForm(built);
// A /DA naming a font with no /DR to resolve it is what PDFBox refuses.
form.setDefaultAppearance("/Helv 0 Tf 0 g");
form.getCOSObject().removeItem(org.apache.pdfbox.cos.COSName.DR);
built.getDocumentCatalog().setAcroForm(form);
built.save(out);
pdf = out.toByteArray();
}
try (PDDocument document = org.apache.pdfbox.Loader.loadPDF(pdf)) {
List<FormUtils.SkippedFieldEdit> skipped = new ArrayList<>();
FormUtils.addNewFields(
document,
List.of(
new FormUtils.NewFormFieldDefinition(
"note", null, "text", 0, 50f, 700f, 200f, 20f, null, null, null,
null, null, null, null, null, null, null)),
skipped);
assertTrue(
skipped.isEmpty(),
"adding a plain text field should not be refused: " + skipped);
assertEquals(
1,
FormUtils.extractFormFields(document).size(),
"the field should be in the document");
}
}
}
@@ -183,7 +183,9 @@ public class WebMvcConfig implements WebMvcConfigurer {
"X-Page-Number",
"X-Page-Size",
"Content-Disposition",
"Content-Type")
"Content-Type",
"X-Stirling-Skipped-Field-Edits",
"X-Stirling-Skipped-Field-Edits-Total")
.allowCredentials(true)
.maxAge(3600);
} else if (hasConfiguredOrigins) {
@@ -229,7 +231,9 @@ public class WebMvcConfig implements WebMvcConfigurer {
"X-Page-Number",
"X-Page-Size",
"Content-Disposition",
"Content-Type")
"Content-Type",
"X-Stirling-Skipped-Field-Edits",
"X-Stirling-Skipped-Field-Edits-Total")
.allowCredentials(true)
.maxAge(3600);
} else {
@@ -256,7 +260,9 @@ public class WebMvcConfig implements WebMvcConfigurer {
"X-Page-Number",
"X-Page-Size",
"Content-Disposition",
"Content-Type")
"Content-Type",
"X-Stirling-Skipped-Field-Edits",
"X-Stirling-Skipped-Field-Edits-Total")
.allowCredentials(true)
.maxAge(3600);
}
@@ -2,10 +2,20 @@ package stirling.software.SPDF.controller.api.form;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.StringWriter;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Base64;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.stream.Stream;
import java.util.zip.CRC32;
import java.util.zip.ZipEntry;
import java.util.zip.ZipOutputStream;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.poi.ss.usermodel.*;
@@ -35,6 +45,7 @@ import stirling.software.common.model.FormFieldWithCoordinates;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.ExceptionUtils;
import stirling.software.common.util.FormUtils;
import stirling.software.common.util.TempFile;
import stirling.software.common.util.TempFileManager;
import stirling.software.common.util.WebResponseUtils;
@@ -59,6 +70,25 @@ import tools.jackson.databind.ObjectMapper;
@RequiredArgsConstructor
public class FormFillController {
/** Carries the edits a request asked for but the document could not take, as base64 JSON. */
public static final String SKIPPED_EDITS_HEADER = "X-Stirling-Skipped-Field-Edits";
/** How many were skipped in total, which may exceed the number listed in the header above. */
public static final String SKIPPED_EDITS_TOTAL_HEADER = "X-Stirling-Skipped-Field-Edits-Total";
/** Keeps the header well inside Jetty's response-header budget. */
private static final int MAX_REPORTED_SKIPS = 20;
/** Bytes of encoded header value, well under the container's limit for the whole header set. */
private static final int MAX_SKIP_HEADER_BYTES = 4096;
private static final int MAX_SKIP_FIELD_CHARS = 120;
/** Entry names inside the {@code ?includeFields=true} bundle. */
private static final String FIELDS_ENTRY = "fields.json";
private static final String DOCUMENT_ENTRY = "document.pdf";
private final CustomPDFDocumentFactory pdfDocumentFactory;
private final ObjectMapper objectMapper;
private final TempFileManager tempFileManager;
@@ -68,6 +98,72 @@ public class FormFillController {
return WebResponseUtils.pdfDocToWebResponse(document, baseName + ".pdf", tempFileManager);
}
/**
* Rejects field names PDFBox cannot store before the document is touched, so the caller gets a
* 400 naming the offending character instead of a 200 with the field quietly missing.
*/
private static void requireUsableFieldNames(
List<FormUtils.NewFormFieldDefinition> adds,
List<FormUtils.ModifyFormFieldDefinition> modifies) {
Stream<String> problems =
Stream.concat(
adds.stream()
.map(FormUtils.NewFormFieldDefinition::name)
.map(FormUtils::invalidFieldNameReason),
// A rename to the same name is not a rename, so a nested field whose
// qualified name already contains a period is left alone.
modifies.stream()
.map(m -> FormUtils.renameProblem(m.targetName(), m.name())));
problems.filter(Objects::nonNull)
.findFirst()
.ifPresent(
reason -> {
throw ExceptionUtils.createIllegalArgumentException(
"error.invalidArgument", "{0}", reason);
});
}
/**
* The body is the updated PDF, so dropped edits travel as a base64 JSON header;
* percent-encoding would turn every space into a plus sign.
*/
private ResponseEntity<Resource> withSkippedEdits(
ResponseEntity<Resource> response, List<FormUtils.SkippedFieldEdit> skipped) {
if (skipped.isEmpty()) {
return response;
}
// A count cap alone is not enough: one very long field name can still overflow the
// header budget and turn the response into an error page, losing the edited PDF.
List<FormUtils.SkippedFieldEdit> reported = new ArrayList<>();
String encoded = "";
for (FormUtils.SkippedFieldEdit edit : skipped) {
if (reported.size() >= MAX_REPORTED_SKIPS) {
break;
}
reported.add(
new FormUtils.SkippedFieldEdit(
edit.operation(),
FormUtils.abbreviate(edit.target(), MAX_SKIP_FIELD_CHARS),
FormUtils.abbreviate(edit.reason(), MAX_SKIP_FIELD_CHARS)));
String candidate =
Base64.getEncoder()
.encodeToString(
objectMapper
.writeValueAsString(reported)
.getBytes(StandardCharsets.UTF_8));
if (candidate.length() > MAX_SKIP_HEADER_BYTES) {
reported.removeLast();
break;
}
encoded = candidate;
}
return ResponseEntity.status(response.getStatusCode())
.headers(response.getHeaders())
.header(SKIPPED_EDITS_TOTAL_HEADER, String.valueOf(skipped.size()))
.header(SKIPPED_EDITS_HEADER, encoded)
.body(response.getBody());
}
private static String buildBaseName(MultipartFile file, String suffix) {
String original = Filenames.toSimpleFileName(file.getOriginalFilename());
if (original == null || original.isBlank()) {
@@ -257,6 +353,110 @@ public class FormFillController {
}
}
@PostMapping(value = "/add-fields", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@Operation(
summary = "Add new form fields",
description =
"Creates new form fields in the provided PDF and returns the updated file")
public ResponseEntity<Resource> addFields(
@Parameter(
description = "The input PDF file",
required = true,
content =
@Content(
mediaType = MediaType.APPLICATION_PDF_VALUE,
schema = @Schema(type = "string", format = "binary")))
@RequestParam("file")
MultipartFile file,
@Parameter(
description = "JSON array of new field definitions",
example =
"[{\"name\":\"NewField\",\"type\":\"text\",\"pageIndex\":0,"
+ "\"x\":50,\"y\":700,\"width\":200,\"height\":20}]")
@RequestPart(value = "fields", required = false)
byte[] fieldsPayload)
throws IOException {
String rawFields = decodePart(fieldsPayload);
List<FormUtils.NewFormFieldDefinition> definitions =
FormPayloadParser.parseNewFieldDefinitions(objectMapper, rawFields);
if (definitions.isEmpty()) {
throw ExceptionUtils.createIllegalArgumentException(
"error.dataRequired",
"{0} must contain at least one definition",
"fields payload");
}
requireUsableFieldNames(definitions, List.of());
List<FormUtils.SkippedFieldEdit> skipped = new ArrayList<>();
return withSkippedEdits(
processSingleFile(
file,
"updated",
document -> FormUtils.addNewFields(document, definitions, skipped)),
skipped);
}
@PostMapping(value = "/edit-fields", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@Operation(
summary = "Apply a batch of form field edits",
description =
"Adds, modifies, and deletes form fields in a single request (one document"
+ " load/save) and returns the updated file")
public ResponseEntity<Resource> editFields(
@Parameter(
description = "The input PDF file",
required = true,
content =
@Content(
mediaType = MediaType.APPLICATION_PDF_VALUE,
schema = @Schema(type = "string", format = "binary")))
@RequestParam("file")
MultipartFile file,
@Parameter(
description =
"JSON object with optional 'add', 'modify' and 'delete'"
+ " sections",
example =
"{\"add\":[{\"name\":\"f\",\"type\":\"text\",\"pageIndex\":0,"
+ "\"x\":50,\"y\":700,\"width\":200,\"height\":20}],"
+ "\"modify\":[],\"delete\":[]}")
@RequestPart(value = "edits", required = false)
byte[] editsPayload,
@Parameter(
description =
"Return a ZIP holding the updated PDF plus the field list it"
+ " produced, instead of the bare PDF. Saves re-uploading"
+ " the result just to read its fields back.")
@RequestParam(value = "includeFields", defaultValue = "false")
boolean includeFields)
throws IOException {
String rawEdits = decodePart(editsPayload);
FormUtils.FieldEditBatch batch = FormPayloadParser.parseFieldEdits(objectMapper, rawEdits);
if (batch.add().isEmpty() && batch.modify().isEmpty() && batch.delete().isEmpty()) {
throw ExceptionUtils.createIllegalArgumentException(
"error.dataRequired", "{0} must contain at least one edit", "edits payload");
}
requireUsableFieldNames(batch.add(), batch.modify());
List<FormUtils.SkippedFieldEdit> skipped = new ArrayList<>();
return withSkippedEdits(
processSingleFile(
file,
"updated",
includeFields,
document ->
FormUtils.applyFieldEdits(
document,
batch.add(),
batch.modify(),
batch.delete(),
skipped)),
skipped);
}
@PostMapping(value = "/modify-fields", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@Operation(
summary = "Modify existing form fields",
@@ -285,8 +485,15 @@ public class FormFillController {
"updates payload");
}
return processSingleFile(
file, "updated", document -> FormUtils.modifyFormFields(document, modifications));
requireUsableFieldNames(List.of(), modifications);
List<FormUtils.SkippedFieldEdit> skipped = new ArrayList<>();
return withSkippedEdits(
processSingleFile(
file,
"updated",
document -> FormUtils.modifyFormFields(document, modifications, skipped)),
skipped);
}
@PostMapping(value = "/delete-fields", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@@ -319,8 +526,13 @@ public class FormFillController {
"error.dataRequired", "{0} must contain at least one value", "names payload");
}
return processSingleFile(
file, "updated", document -> FormUtils.deleteFormFields(document, names));
List<FormUtils.SkippedFieldEdit> skipped = new ArrayList<>();
return withSkippedEdits(
processSingleFile(
file,
"updated",
document -> FormUtils.deleteFormFields(document, names, skipped)),
skipped);
}
@PostMapping(value = "/fill", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@@ -358,13 +570,81 @@ public class FormFillController {
private ResponseEntity<Resource> processSingleFile(
MultipartFile file, String suffix, DocumentProcessor processor) throws IOException {
return processSingleFile(file, suffix, false, processor);
}
private ResponseEntity<Resource> processSingleFile(
MultipartFile file, String suffix, boolean includeFields, DocumentProcessor processor)
throws IOException {
requirePdf(file);
String baseName = buildBaseName(file, suffix);
try (PDDocument document = pdfDocumentFactory.load(file)) {
FormUtils.repairMissingWidgetPageReferences(document);
processor.accept(document);
return saveDocument(document, baseName);
return includeFields
? saveDocumentWithFields(document, baseName)
: saveDocument(document, baseName);
}
}
/**
* Answers "what fields does the saved file have?" from the document still open here, so the
* caller does not have to upload the result back to ask.
*/
private ResponseEntity<Resource> saveDocumentWithFields(PDDocument document, String baseName)
throws IOException {
TempFile zip = null;
boolean zipTransferred = false;
try (TempFile pdf = tempFileManager.createManagedTempFile(".pdf")) {
document.save(pdf.getFile());
// Read the fields after the save so they describe the bytes actually being returned.
byte[] fields =
objectMapper.writeValueAsBytes(
FormUtils.extractFormFieldsWithCoordinates(document));
zip = tempFileManager.createManagedTempFile(".zip");
writeFieldBundle(zip.getPath(), pdf.getPath(), fields);
ResponseEntity<Resource> response =
WebResponseUtils.zipFileToWebResponse(zip, baseName + ".zip");
zipTransferred = true;
return response;
} finally {
if (zip != null && !zipTransferred) {
zip.close();
}
}
}
/**
* Deflates the JSON because it is text, but stores the PDF: its streams are already compressed,
* so deflating costs ~25ms per MB to save a few percent.
*/
private static void writeFieldBundle(Path zipPath, Path pdfPath, byte[] fields)
throws IOException {
long pdfSize = Files.size(pdfPath);
CRC32 crc = new CRC32();
try (InputStream in = Files.newInputStream(pdfPath)) {
byte[] buffer = new byte[8192];
for (int read; (read = in.read(buffer)) != -1; ) {
crc.update(buffer, 0, read);
}
}
try (ZipOutputStream zip = new ZipOutputStream(Files.newOutputStream(zipPath))) {
ZipEntry fieldsEntry = new ZipEntry(FIELDS_ENTRY);
fieldsEntry.setMethod(ZipEntry.DEFLATED);
zip.putNextEntry(fieldsEntry);
zip.write(fields);
zip.closeEntry();
ZipEntry documentEntry = new ZipEntry(DOCUMENT_ENTRY);
documentEntry.setMethod(ZipEntry.STORED);
documentEntry.setSize(pdfSize);
documentEntry.setCompressedSize(pdfSize);
documentEntry.setCrc(crc.getValue());
zip.putNextEntry(documentEntry);
Files.copy(pdfPath, zip);
zip.closeEntry();
zip.finish();
}
}
@@ -28,6 +28,8 @@ final class FormPayloadParser {
private static final TypeReference<Map<String, Object>> MAP_TYPE = new TypeReference<>() {};
private static final TypeReference<List<FormUtils.ModifyFormFieldDefinition>>
MODIFY_FIELD_LIST_TYPE = new TypeReference<>() {};
private static final TypeReference<List<FormUtils.NewFormFieldDefinition>> NEW_FIELD_LIST_TYPE =
new TypeReference<>() {};
private static final TypeReference<List<String>> STRING_LIST_TYPE = new TypeReference<>() {};
private FormPayloadParser() {}
@@ -94,6 +96,43 @@ final class FormPayloadParser {
return objectMapper.readValue(json, MODIFY_FIELD_LIST_TYPE);
}
static List<FormUtils.NewFormFieldDefinition> parseNewFieldDefinitions(
ObjectMapper objectMapper, String json) {
if (json == null || json.isBlank()) {
return List.of();
}
return objectMapper.readValue(json, NEW_FIELD_LIST_TYPE);
}
/**
* Parses a combined edit batch: {@code {"add":[...],"modify":[...],"delete":[...]}}. Each
* section is optional. The delete section accepts the same shapes as {@link #parseNameList}.
*/
static FormUtils.FieldEditBatch parseFieldEdits(ObjectMapper objectMapper, String json) {
if (json == null || json.isBlank()) {
return new FormUtils.FieldEditBatch(List.of(), List.of(), List.of());
}
final JsonNode root = objectMapper.readTree(json);
List<FormUtils.NewFormFieldDefinition> adds = List.of();
List<FormUtils.ModifyFormFieldDefinition> modifies = List.of();
List<String> deletes = List.of();
if (root != null && root.isObject()) {
final JsonNode addNode = root.get("add");
if (addNode != null && addNode.isArray()) {
adds = objectMapper.readValue(addNode.toString(), NEW_FIELD_LIST_TYPE);
}
final JsonNode modifyNode = root.get("modify");
if (modifyNode != null && modifyNode.isArray()) {
modifies = objectMapper.readValue(modifyNode.toString(), MODIFY_FIELD_LIST_TYPE);
}
final JsonNode deleteNode = root.get("delete");
if (deleteNode != null && !deleteNode.isNull()) {
deletes = parseNameList(objectMapper, deleteNode.toString());
}
}
return new FormUtils.FieldEditBatch(adds, modifies, deletes);
}
static List<String> parseNameList(ObjectMapper objectMapper, String json) {
if (json == null || json.isBlank()) {
return List.of();
@@ -0,0 +1,374 @@
package stirling.software.SPDF.controller.api.form;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.zip.ZipEntry;
import java.util.zip.ZipInputStream;
import org.apache.pdfbox.Loader;
import org.apache.pdfbox.cos.COSName;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.pdmodel.common.PDRectangle;
import org.apache.pdfbox.pdmodel.interactive.form.PDAcroForm;
import org.apache.pdfbox.pdmodel.interactive.form.PDField;
import org.apache.pdfbox.pdmodel.interactive.form.PDNonTerminalField;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.MethodSource;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.core.io.Resource;
import org.springframework.http.ResponseEntity;
import org.springframework.mock.web.MockMultipartFile;
import stirling.software.common.model.FormFieldWithCoordinates;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.FormUtils;
import stirling.software.common.util.TempFile;
import stirling.software.common.util.TempFileManager;
import tools.jackson.databind.ObjectMapper;
import tools.jackson.databind.json.JsonMapper;
/**
* Drives ?includeFields=true across a spread of real form shapes, checking the bundled list stays
* interchangeable with the follow-up request it exists to remove.
*/
@ExtendWith(MockitoExtension.class)
@DisplayName("edit-fields field bundle")
class FormFieldBundleTest {
/** Set to a directory to dump the produced archives for the frontend reader's fixtures. */
private static final String FIXTURE_DIR = System.getProperty("bundle.fixtures");
@Mock private CustomPDFDocumentFactory pdfDocumentFactory;
@Mock private TempFileManager tempFileManager;
@InjectMocks private FormFillController controller;
private ObjectMapper objectMapper;
@BeforeEach
void setUp() throws Exception {
lenient()
.when(tempFileManager.createManagedTempFile(anyString()))
.thenAnswer(
invocation -> {
File file =
Files.createTempFile(
"bundle", invocation.<String>getArgument(0))
.toFile();
TempFile temp = mock(TempFile.class);
lenient().when(temp.getFile()).thenReturn(file);
lenient().when(temp.getPath()).thenReturn(file.toPath());
return temp;
});
objectMapper = JsonMapper.builder().build();
var field = FormFillController.class.getDeclaredField("objectMapper");
field.setAccessible(true);
field.set(controller, objectMapper);
}
// -- document shapes ----------------------------------------------
private record Style(
String name, int pages, int rotation, List<FormUtils.NewFormFieldDefinition> fields) {}
private static FormUtils.NewFormFieldDefinition field(
String name, String type, int page, float y, List<String> options) {
return new FormUtils.NewFormFieldDefinition(
name, null, type, page, 50f, y, 200f, 20f, null, null, options, null, null, null,
null, null, null, null);
}
static List<Style> styles() {
List<Style> styles = new ArrayList<>();
styles.add(new Style("text-only", 1, 0, List.of(field("fullName", "text", 0, 700f, null))));
styles.add(
new Style(
"checkbox-and-radio",
1,
0,
List.of(
field("agree", "checkbox", 0, 700f, null),
field("plan", "radio", 0, 650f, List.of("basic", "pro")))));
styles.add(
new Style(
"choice-widgets",
1,
0,
List.of(
field("country", "dropdown", 0, 700f, List.of("UK", "IE", "FR")),
field("tags", "listbox", 0, 640f, List.of("a", "b", "c")))));
styles.add(
new Style(
"signature", 1, 0, List.of(field("approval", "signature", 0, 700f, null))));
styles.add(
new Style(
"multi-page",
3,
0,
List.of(
field("p1", "text", 0, 700f, null),
field("p2", "text", 1, 700f, null),
field("p3", "text", 2, 700f, null))));
styles.add(new Style("rotated-90", 1, 90, List.of(field("rot", "text", 0, 700f, null))));
styles.add(new Style("rotated-270", 1, 270, List.of(field("rot", "text", 0, 700f, null))));
styles.add(
new Style(
"unicode-names",
1,
0,
List.of(
field("nom_complet", "text", 0, 700f, null),
field("adresse postale", "text", 0, 660f, null))));
List<FormUtils.NewFormFieldDefinition> many = new ArrayList<>();
for (int i = 0; i < 120; i++) {
many.add(
field(
"field_" + i,
i % 3 == 0 ? "checkbox" : "text",
i / 40,
740f - (i % 40) * 18f,
null));
}
styles.add(new Style("many-fields", 3, 0, many));
return styles;
}
private byte[] blankPdf(int pages, int rotation) throws IOException {
try (PDDocument document = new PDDocument();
ByteArrayOutputStream out = new ByteArrayOutputStream()) {
for (int i = 0; i < pages; i++) {
PDPage page = new PDPage(PDRectangle.A4);
page.setRotation(rotation);
document.addPage(page);
}
document.getDocumentCatalog().setAcroForm(new PDAcroForm(document));
document.save(out);
return out.toByteArray();
}
}
// -- the test ------------------------------------------------------
@ParameterizedTest(name = "{0}")
@MethodSource("styles")
@DisplayName("bundled list matches the follow-up request it replaces")
void bundleMatchesRefetch(Style style) throws Exception {
byte[] source = blankPdf(style.pages(), style.rotation());
MockMultipartFile upload =
new MockMultipartFile("file", style.name() + ".pdf", "application/pdf", source);
byte[] edits = objectMapper.writeValueAsBytes(Map.of("add", style.fields()));
byte[] zipBytes;
try (PDDocument document = Loader.loadPDF(source)) {
when(pdfDocumentFactory.load(eq(upload))).thenReturn(document);
zipBytes = drain(controller.editFields(upload, edits, true));
}
Map<String, byte[]> bundle = unzip(zipBytes);
assertThat(bundle).containsKeys("document.pdf", "fields.json");
byte[] editedPdf = bundle.get("document.pdf");
assertThat(new String(editedPdf, 0, 5, StandardCharsets.UTF_8)).isEqualTo("%PDF-");
// The comparison that matters: ask the endpoint this feature stops re-calling,
// and demand a match.
MockMultipartFile saved =
new MockMultipartFile("file", style.name() + ".pdf", "application/pdf", editedPdf);
try (PDDocument reloaded = Loader.loadPDF(editedPdf)) {
when(pdfDocumentFactory.load(eq(saved), eq(true))).thenReturn(reloaded);
ResponseEntity<List<FormFieldWithCoordinates>> refetched =
controller.listFieldsWithCoordinates(saved);
assertThat(new String(bundle.get("fields.json"), StandardCharsets.UTF_8))
.isEqualTo(objectMapper.writeValueAsString(refetched.getBody()));
}
dumpFixture(style.name(), zipBytes);
}
@ParameterizedTest(name = "{0}")
@MethodSource("styles")
@DisplayName("pdf entry is stored and json entry is deflated")
void perEntryCompression(Style style) throws Exception {
byte[] source = blankPdf(style.pages(), style.rotation());
MockMultipartFile upload =
new MockMultipartFile("file", style.name() + ".pdf", "application/pdf", source);
byte[] edits = objectMapper.writeValueAsBytes(Map.of("add", style.fields()));
byte[] zipBytes;
try (PDDocument document = Loader.loadPDF(source)) {
when(pdfDocumentFactory.load(eq(upload))).thenReturn(document);
zipBytes = drain(controller.editFields(upload, edits, true));
}
Map<String, Integer> methods = methodsOf(zipBytes);
assertThat(methods.get("document.pdf")).isEqualTo(ZipEntry.STORED);
assertThat(methods.get("fields.json")).isEqualTo(ZipEntry.DEFLATED);
}
@Test
@DisplayName("hierarchical field names survive the bundle")
void nestedFieldNames() throws Exception {
byte[] source = nestedPdf();
MockMultipartFile upload =
new MockMultipartFile("file", "nested.pdf", "application/pdf", source);
byte[] edits =
objectMapper.writeValueAsBytes(
Map.of(
"modify",
List.of(
Map.of(
"targetName",
"Customer.Name",
"defaultValue",
"Ada"))));
byte[] zipBytes;
try (PDDocument document = Loader.loadPDF(source)) {
when(pdfDocumentFactory.load(eq(upload))).thenReturn(document);
zipBytes = drain(controller.editFields(upload, edits, true));
}
Map<String, byte[]> bundle = unzip(zipBytes);
byte[] editedPdf = bundle.get("document.pdf");
MockMultipartFile saved =
new MockMultipartFile("file", "nested.pdf", "application/pdf", editedPdf);
try (PDDocument reloaded = Loader.loadPDF(editedPdf)) {
when(pdfDocumentFactory.load(eq(saved), eq(true))).thenReturn(reloaded);
ResponseEntity<List<FormFieldWithCoordinates>> refetched =
controller.listFieldsWithCoordinates(saved);
String bundled = new String(bundle.get("fields.json"), StandardCharsets.UTF_8);
assertThat(bundled).contains("Customer.Name");
assertThat(bundled).isEqualTo(objectMapper.writeValueAsString(refetched.getBody()));
}
}
/** Builds a parent field with two children, which add-fields cannot express. */
private byte[] nestedPdf() throws IOException {
try (PDDocument document = Loader.loadPDF(blankPdf(1, 0));
ByteArrayOutputStream out = new ByteArrayOutputStream()) {
PDAcroForm form = document.getDocumentCatalog().getAcroForm(null);
FormUtils.addNewFields(
document,
List.of(
field("Name", "text", 0, 700f, null),
field("Email", "text", 0, 660f, null)));
PDNonTerminalField parent = new PDNonTerminalField(form);
parent.setPartialName("Customer");
List<PDField> kids = new ArrayList<>();
for (String child : List.of("Name", "Email")) {
PDField kid = form.getField(child);
kid.getCOSObject().setItem(COSName.PARENT, parent.getCOSObject());
kids.add(kid);
}
parent.setChildren(kids);
form.setFields(List.of(parent));
document.save(out);
return out.toByteArray();
}
}
@Test
@DisplayName("bundle stays close to the wire cost of the two calls it replaces")
void wireCost() throws Exception {
Style style =
styles().stream()
.filter(s -> s.name().equals("many-fields"))
.findFirst()
.orElseThrow();
byte[] source = blankPdf(style.pages(), style.rotation());
MockMultipartFile upload =
new MockMultipartFile("file", "cost.pdf", "application/pdf", source);
byte[] edits = objectMapper.writeValueAsBytes(Map.of("add", style.fields()));
byte[] zipBytes;
Map<String, byte[]> bundle;
try (PDDocument document = Loader.loadPDF(source)) {
when(pdfDocumentFactory.load(eq(upload))).thenReturn(document);
zipBytes = drain(controller.editFields(upload, edits, true));
}
bundle = unzip(zipBytes);
int pdfSize = bundle.get("document.pdf").length;
int jsonSize = bundle.get("fields.json").length;
System.out.printf(
"wire: pdf=%d json=%d zip=%d overhead=%d bytes (%.2f%% over the pdf alone)%n",
pdfSize,
jsonSize,
zipBytes.length,
zipBytes.length - pdfSize,
100.0 * (zipBytes.length - pdfSize) / pdfSize);
// True for a field list this repetitive; on a tiny list the ~200 bytes of zip framing can
// exceed what deflate saves, so this is a property of the fixture, not of every document.
assertThat(zipBytes.length).isLessThan(pdfSize + jsonSize);
}
// -- helpers -------------------------------------------------------
private static byte[] drain(ResponseEntity<Resource> response) throws IOException {
ByteArrayOutputStream out = new ByteArrayOutputStream();
try (InputStream in = response.getBody().getInputStream()) {
in.transferTo(out);
}
return out.toByteArray();
}
private static Map<String, byte[]> unzip(byte[] zipBytes) throws IOException {
Map<String, byte[]> entries = new HashMap<>();
try (ZipInputStream in = new ZipInputStream(new ByteArrayInputStream(zipBytes))) {
for (ZipEntry entry; (entry = in.getNextEntry()) != null; ) {
ByteArrayOutputStream out = new ByteArrayOutputStream();
in.transferTo(out);
entries.put(entry.getName(), out.toByteArray());
}
}
return entries;
}
private static Map<String, Integer> methodsOf(byte[] zipBytes) throws IOException {
Map<String, Integer> methods = new HashMap<>();
try (ZipInputStream in = new ZipInputStream(new ByteArrayInputStream(zipBytes))) {
for (ZipEntry entry; (entry = in.getNextEntry()) != null; ) {
methods.put(entry.getName(), entry.getMethod());
in.transferTo(OutputStream.nullOutputStream());
}
}
return methods;
}
private static void dumpFixture(String name, byte[] zipBytes) throws IOException {
if (FIXTURE_DIR == null) {
return;
}
Path dir = Paths.get(FIXTURE_DIR);
Files.createDirectories(dir);
Files.write(dir.resolve(name + ".zip"), zipBytes);
}
}
@@ -29,6 +29,7 @@ import org.springframework.http.ResponseEntity;
import org.springframework.mock.web.MockMultipartFile;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.FormUtils;
import stirling.software.common.util.TempFile;
import stirling.software.common.util.TempFileManager;
@@ -330,6 +331,160 @@ class FormFillControllerTest {
}
}
// ── addFields ──────────────────────────────────────────────────────
@Nested
@DisplayName("addFields")
class AddFields {
@Test
@DisplayName("throws when fields payload is null")
void nullPayload() {
assertThatThrownBy(() -> controller.addFields(pdfFile(), null))
.isInstanceOf(IllegalArgumentException.class);
}
@Test
@DisplayName("throws when fields payload is an empty list")
void emptyPayload() {
assertThatThrownBy(() -> controller.addFields(pdfFile(), "[]".getBytes()))
.isInstanceOf(IllegalArgumentException.class);
}
@Test
@DisplayName("processes a valid new-field payload")
void validPayload() throws Exception {
MockMultipartFile file = pdfFile();
PDDocument doc = createMinimalPdf();
when(pdfDocumentFactory.load(eq(file))).thenReturn(doc);
String json =
"[{\"name\":\"NewField\",\"type\":\"text\",\"pageIndex\":0,"
+ "\"x\":50,\"y\":700,\"width\":200,\"height\":20}]";
ResponseEntity<Resource> response = controller.addFields(file, json.getBytes());
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(response.getBody()).isNotNull();
}
}
// ── editFields (combined) ──────────────────────────────────────────
@Nested
@DisplayName("editFields")
class EditFields {
@Test
@DisplayName("throws when edits payload is null")
void nullPayload() {
assertThatThrownBy(() -> controller.editFields(pdfFile(), null, false))
.isInstanceOf(IllegalArgumentException.class);
}
@Test
@DisplayName("throws when all sections are empty")
void emptyBatch() {
assertThatThrownBy(
() ->
controller.editFields(
pdfFile(),
"{\"add\":[],\"modify\":[],\"delete\":[]}".getBytes(),
false))
.isInstanceOf(IllegalArgumentException.class);
}
@Test
@DisplayName("processes a combined add/delete batch")
void validBatch() throws Exception {
MockMultipartFile file = pdfFile();
PDDocument doc = createMinimalPdf();
when(pdfDocumentFactory.load(eq(file))).thenReturn(doc);
String json =
"{\"add\":[{\"name\":\"f\",\"type\":\"text\",\"pageIndex\":0,\"x\":50,"
+ "\"y\":700,\"width\":200,\"height\":20}],\"modify\":[],"
+ "\"delete\":[]}";
ResponseEntity<Resource> response = controller.editFields(file, json.getBytes(), false);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(response.getBody()).isNotNull();
}
@Test
@DisplayName("refuses a field name containing a period before touching the document")
void refusesPeriodInName() throws Exception {
String json =
"{\"add\":[{\"name\":\"Customer.Name\",\"type\":\"text\",\"pageIndex\":0,"
+ "\"x\":50,\"y\":700,\"width\":200,\"height\":20}]}";
assertThatThrownBy(() -> controller.editFields(pdfFile(), json.getBytes(), false))
.hasMessageContaining("period");
// Rejected up front, so the document is never even loaded.
verify(pdfDocumentFactory, never()).load(any(MockMultipartFile.class));
}
@Test
@DisplayName("renaming a nested field to its own qualified name is not a rename")
void allowsUnchangedQualifiedName() throws Exception {
MockMultipartFile file = pdfFile();
when(pdfDocumentFactory.load(eq(file))).thenReturn(createMinimalPdf());
String json =
"{\"modify\":[{\"targetName\":\"Customer.Name\",\"name\":\"Customer.Name\","
+ "\"x\":10,\"y\":10}]}";
ResponseEntity<Resource> response = controller.editFields(file, json.getBytes(), false);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
// It must get past validation into the edit loop: the only complaint should be that
// this document has no such field, never that the name contains a period.
String encoded =
response.getHeaders().getFirst(FormFillController.SKIPPED_EDITS_HEADER);
assertThat(encoded).isNotNull();
String report =
new String(
java.util.Base64.getDecoder().decode(encoded),
java.nio.charset.StandardCharsets.UTF_8);
assertThat(report).contains("no field with that name exists").doesNotContain("period");
}
@Test
@DisplayName("reports a dropped edit as base64 JSON in the skipped-edits header")
void reportsSkippedEdits() throws Exception {
MockMultipartFile file = pdfFile();
when(pdfDocumentFactory.load(eq(file))).thenReturn(createMinimalPdf());
String json = "{\"delete\":[\"noSuchField\"]}";
ResponseEntity<Resource> response = controller.editFields(file, json.getBytes(), false);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
String encoded =
response.getHeaders().getFirst(FormFillController.SKIPPED_EDITS_HEADER);
assertThat(encoded).isNotNull();
String report =
new String(
java.util.Base64.getDecoder().decode(encoded),
java.nio.charset.StandardCharsets.UTF_8);
assertThat(report).contains("noSuchField").contains("delete");
// Base64 rather than percent-encoding, so spaces survive as spaces.
assertThat(report).contains("no field with that name exists");
}
@Test
@DisplayName("omits the skipped-edits header when everything applied")
void noHeaderOnCleanBatch() throws Exception {
MockMultipartFile file = pdfFile();
when(pdfDocumentFactory.load(eq(file))).thenReturn(createMinimalPdf());
String json =
"{\"add\":[{\"name\":\"clean\",\"type\":\"text\",\"pageIndex\":0,\"x\":50,"
+ "\"y\":700,\"width\":200,\"height\":20}]}";
ResponseEntity<Resource> response = controller.editFields(file, json.getBytes(), false);
assertThat(response.getHeaders().getFirst(FormFillController.SKIPPED_EDITS_HEADER))
.isNull();
}
}
// ── buildBaseName ──────────────────────────────────────────────────
@Nested
@@ -384,4 +539,156 @@ class FormFillControllerTest {
assertThat(result).isEqualTo("document_filled");
}
}
// -- includeFields bundle ------------------------------------------
@Nested
@DisplayName("editFields ?includeFields=true")
class FieldBundle {
private byte[] editsPayload() {
return ("{\"add\":[{\"name\":\"bundled\",\"type\":\"text\",\"pageIndex\":0,"
+ "\"x\":50,\"y\":700,\"width\":200,\"height\":20}]}")
.getBytes(java.nio.charset.StandardCharsets.UTF_8);
}
private java.util.Map<String, java.util.zip.ZipEntry> entriesOf(byte[] zipBytes)
throws IOException {
java.util.Map<String, java.util.zip.ZipEntry> found = new java.util.HashMap<>();
try (java.util.zip.ZipInputStream in =
new java.util.zip.ZipInputStream(new java.io.ByteArrayInputStream(zipBytes))) {
for (java.util.zip.ZipEntry e; (e = in.getNextEntry()) != null; ) {
java.io.ByteArrayOutputStream data = new java.io.ByteArrayOutputStream();
in.transferTo(data);
// getMethod/getSize are only final once the entry has been fully read.
found.put(e.getName(), e);
payloads.put(e.getName(), data.toByteArray());
}
}
return found;
}
private final java.util.Map<String, byte[]> payloads = new java.util.HashMap<>();
private byte[] bundleFor(MockMultipartFile file) throws Exception {
PDDocument doc = createMinimalPdf();
when(pdfDocumentFactory.load(eq(file))).thenReturn(doc);
ResponseEntity<Resource> response = controller.editFields(file, editsPayload(), true);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
return drainBody(response);
}
@Test
@DisplayName("returns a zip holding the pdf and the field list")
void bundlesBoth() throws Exception {
byte[] zip = bundleFor(pdfFile());
entriesOf(zip);
assertThat(payloads).containsKeys("document.pdf", "fields.json");
assertThat(new String(payloads.get("document.pdf"), 0, 5)).isEqualTo("%PDF-");
assertThat(
new String(
payloads.get("fields.json"),
java.nio.charset.StandardCharsets.UTF_8))
.contains("bundled");
}
@Test
@DisplayName("stores the pdf entry but deflates the json")
void perEntryMethods() throws Exception {
byte[] zip = bundleFor(pdfFile());
java.util.Map<String, java.util.zip.ZipEntry> entries = entriesOf(zip);
assertThat(entries.get("document.pdf").getMethod())
.as("deflating an already-compressed PDF burns CPU for almost nothing")
.isEqualTo(java.util.zip.ZipEntry.STORED);
assertThat(entries.get("fields.json").getMethod())
.as("the JSON is text and no longer gets the container's gzip")
.isEqualTo(java.util.zip.ZipEntry.DEFLATED);
}
@Test
@DisplayName("bundled fields match what a follow-up fetch would have returned")
void matchesTheSecondCallItReplaces() throws Exception {
byte[] zip = bundleFor(pdfFile());
entriesOf(zip);
byte[] bundledPdf = payloads.get("document.pdf");
// Re-ask the endpoint this feature stops re-calling, using the returned bytes.
MockMultipartFile saved =
new MockMultipartFile("file", "test.pdf", "application/pdf", bundledPdf);
try (PDDocument reloaded = org.apache.pdfbox.Loader.loadPDF(bundledPdf)) {
when(pdfDocumentFactory.load(eq(saved), eq(true))).thenReturn(reloaded);
ResponseEntity<
java.util.List<
stirling.software.common.model.FormFieldWithCoordinates>>
refetched = controller.listFieldsWithCoordinates(saved);
String viaRefetch = realObjectMapper.writeValueAsString(refetched.getBody());
String viaBundle =
new String(
payloads.get("fields.json"),
java.nio.charset.StandardCharsets.UTF_8);
assertThat(viaBundle)
.as("the bundle must be interchangeable with the round trip it removes")
.isEqualTo(viaRefetch);
}
}
@Test
@DisplayName("omitting the flag still returns a bare pdf")
void defaultsToPlainPdf() throws Exception {
MockMultipartFile file = pdfFile();
PDDocument doc = createMinimalPdf();
when(pdfDocumentFactory.load(eq(file))).thenReturn(doc);
byte[] body = drainBody(controller.editFields(file, editsPayload(), false));
assertThat(new String(body, 0, 5)).isEqualTo("%PDF-");
}
}
// -- skipped-edits header budget -----------------------------------
@Nested
@DisplayName("skipped-edits header")
class SkipHeaderBudget {
@Test
@DisplayName("stays within budget however long the reported names are")
void staysWithinBudget() throws Exception {
java.util.List<FormUtils.SkippedFieldEdit> skipped = new java.util.ArrayList<>();
String huge = "x".repeat(20000);
for (int i = 0; i < 40; i++) {
skipped.add(new FormUtils.SkippedFieldEdit("modify", huge, huge));
}
var method =
FormFillController.class.getDeclaredMethod(
"withSkippedEdits", ResponseEntity.class, java.util.List.class);
method.setAccessible(true);
@SuppressWarnings("unchecked")
ResponseEntity<Resource> response =
(ResponseEntity<Resource>)
method.invoke(controller, streamingOk(new byte[] {1}), skipped);
String header = response.getHeaders().getFirst(FormFillController.SKIPPED_EDITS_HEADER);
assertThat(header).isNotNull();
// Not merely short: an empty header would pass a length check while telling the
// user nothing, because the alert renders only when it has entries.
String decoded =
new String(
java.util.Base64.getDecoder().decode(header),
java.nio.charset.StandardCharsets.UTF_8);
assertThat(decoded).startsWith("[{");
assertThat(decoded).contains("...");
// Overflowing the container's header budget turns the reply into an error page,
// which loses the edited PDF the user just saved.
assertThat(header.length()).isLessThanOrEqualTo(4096);
assertThat(
response.getHeaders()
.getFirst(FormFillController.SKIPPED_EDITS_TOTAL_HEADER))
.isEqualTo("40");
}
}
}
@@ -170,6 +170,94 @@ class FormPayloadParserTest {
}
}
// ── parseNewFieldDefinitions ───────────────────────────────────────
@Nested
@DisplayName("parseNewFieldDefinitions")
class ParseNewFieldDefinitions {
@Test
@DisplayName("returns empty list for null input")
void nullInput() {
List<FormUtils.NewFormFieldDefinition> result =
FormPayloadParser.parseNewFieldDefinitions(objectMapper, null);
assertThat(result).isEmpty();
}
@Test
@DisplayName("returns empty list for blank input")
void blankInput() {
List<FormUtils.NewFormFieldDefinition> result =
FormPayloadParser.parseNewFieldDefinitions(objectMapper, " ");
assertThat(result).isEmpty();
}
@Test
@DisplayName("parses a valid new-field list including geometry and flags")
void validNewFields() {
String json =
"[{\"name\":\"NewField\",\"type\":\"text\",\"pageIndex\":0,"
+ "\"x\":50,\"y\":700,\"width\":200,\"height\":20,"
+ "\"fontSize\":14,\"readOnly\":true,\"multiline\":true}]";
List<FormUtils.NewFormFieldDefinition> result =
FormPayloadParser.parseNewFieldDefinitions(objectMapper, json);
assertThat(result).hasSize(1);
FormUtils.NewFormFieldDefinition def = result.get(0);
assertThat(def.name()).isEqualTo("NewField");
assertThat(def.type()).isEqualTo("text");
assertThat(def.pageIndex()).isEqualTo(0);
assertThat(def.x()).isEqualTo(50f);
assertThat(def.y()).isEqualTo(700f);
assertThat(def.width()).isEqualTo(200f);
assertThat(def.height()).isEqualTo(20f);
assertThat(def.fontSize()).isEqualTo(14f);
assertThat(def.readOnly()).isTrue();
assertThat(def.multiline()).isTrue();
}
}
// ── parseFieldEdits ────────────────────────────────────────────────
@Nested
@DisplayName("parseFieldEdits")
class ParseFieldEdits {
@Test
@DisplayName("returns empty batch for null input")
void nullInput() {
FormUtils.FieldEditBatch batch = FormPayloadParser.parseFieldEdits(objectMapper, null);
assertThat(batch.add()).isEmpty();
assertThat(batch.modify()).isEmpty();
assertThat(batch.delete()).isEmpty();
}
@Test
@DisplayName("parses a combined add/modify/delete batch")
void combinedBatch() {
String json =
"{\"add\":[{\"name\":\"new1\",\"type\":\"text\",\"pageIndex\":0,\"x\":1,"
+ "\"y\":2,\"width\":3,\"height\":4}],"
+ "\"modify\":[{\"targetName\":\"old1\",\"label\":\"L\"}],"
+ "\"delete\":[\"gone1\",{\"name\":\"gone2\"}]}";
FormUtils.FieldEditBatch batch = FormPayloadParser.parseFieldEdits(objectMapper, json);
assertThat(batch.add()).hasSize(1);
assertThat(batch.add().get(0).name()).isEqualTo("new1");
assertThat(batch.modify()).hasSize(1);
assertThat(batch.modify().get(0).targetName()).isEqualTo("old1");
assertThat(batch.delete()).containsExactly("gone1", "gone2");
}
@Test
@DisplayName("tolerates missing sections")
void missingSections() {
FormUtils.FieldEditBatch batch =
FormPayloadParser.parseFieldEdits(objectMapper, "{\"delete\":[\"x\"]}");
assertThat(batch.add()).isEmpty();
assertThat(batch.modify()).isEmpty();
assertThat(batch.delete()).containsExactly("x");
}
}
// ── parseNameList ──────────────────────────────────────────────────
@Nested
@@ -213,7 +213,9 @@ public class SecurityConfiguration {
"X-Page-Number",
"X-Page-Size",
"Content-Disposition",
"Content-Type"));
"Content-Type",
"X-Stirling-Skipped-Field-Edits",
"X-Stirling-Skipped-Field-Edits-Total"));
cfg.setAllowCredentials(true);
cfg.setMaxAge(3600L);
@@ -316,7 +316,11 @@ public class SupabaseSecurityConfig {
"Origin",
"X-API-KEY",
"X-Browser-Id"));
cfg.setExposedHeaders(List.of("WWW-Authenticate"));
cfg.setExposedHeaders(
List.of(
"WWW-Authenticate",
"X-Stirling-Skipped-Field-Edits",
"X-Stirling-Skipped-Field-Edits-Total"));
cfg.setAllowCredentials(true);
cfg.setMaxAge(3600L);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
+32
View File
@@ -0,0 +1,32 @@
# Lightweight Ubuntu image with a pinned Task binary for CI.
# Build with: task docker:build:ci
FROM ubuntu:noble@sha256:561618e2c15bf2397621dd04f96926663a3b5616c189cf7e38db7e82f5c538ea
ARG TASK_VERSION=3.53.1
ARG TARGETARCH=amd64
RUN set -eux; \
apt-get update; \
apt-get install --no-install-recommends -y ca-certificates curl tar; \
rm -rf /var/lib/apt/lists/*; \
case "${TARGETARCH}" in \
amd64) task_arch=amd64; task_sha256=a54a408f6861ff921f6e87774180db31bacd8c1e7c944ca696db9fea49a82fc7 ;; \
arm64) task_arch=arm64; task_sha256=e3ad19101493a0112e1f22ae8ccc54bf03e533b1076a0ca1e6c782a09ad2e588 ;; \
*) echo "Unsupported Docker architecture: ${TARGETARCH}" >&2; exit 1 ;; \
esac; \
archive="/tmp/task_linux_${task_arch}.tar.gz"; \
curl --fail --show-error --location --http1.1 --retry 5 --retry-all-errors --connect-timeout 30 \
"https://github.com/go-task/task/releases/download/v${TASK_VERSION}/task_linux_${task_arch}.tar.gz" \
--output "${archive}"; \
echo "${task_sha256} ${archive}" | sha256sum --check; \
tar --extract --file "${archive}" --directory /usr/local/bin task; \
chmod 0755 /usr/local/bin/task; \
rm "${archive}"
RUN useradd --create-home --uid 10001 --shell /usr/sbin/nologin taskuser
WORKDIR /workspace
USER taskuser
ENTRYPOINT ["task"]
+1 -1
View File
@@ -7,7 +7,7 @@ ARG BASE_IMAGE=stirlingtools/stirling-pdf-base:${BASE_VERSION}
# Stage 1: Build the Java application and frontend
FROM gradle:9.7.1-jdk25@sha256:a80276ab804c348989df46016e2b5d58cad07c5b29e06f2112434d28ca5b2844 AS app-build
ARG TASK_VERSION=3.52.0
ARG TASK_VERSION=3.53.1
RUN apt-get update \
&& apt-get install -y --no-install-recommends curl ca-certificates \
&& update-ca-certificates \
+1 -1
View File
@@ -10,7 +10,7 @@ ARG BASE_IMAGE=stirlingtools/stirling-pdf-base:${BASE_VERSION}
# Stage 1: Build the Java application and frontend
FROM gradle:9.7.1-jdk25@sha256:a80276ab804c348989df46016e2b5d58cad07c5b29e06f2112434d28ca5b2844 AS app-build
ARG TASK_VERSION=3.52.0
ARG TASK_VERSION=3.53.1
RUN apt-get update \
&& apt-get install -y --no-install-recommends curl ca-certificates \
&& update-ca-certificates \
+1 -1
View File
@@ -7,7 +7,7 @@
FROM gradle:9.7.1-jdk25@sha256:a80276ab804c348989df46016e2b5d58cad07c5b29e06f2112434d28ca5b2844 AS build
# Install Node.js and npm for frontend build
ARG TASK_VERSION=3.52.0
ARG TASK_VERSION=3.53.1
RUN apt-get update && apt-get install -y --no-install-recommends \
curl \
&& curl -fsSL https://deb.nodesource.com/setup_22.x | bash - \
@@ -4312,11 +4312,14 @@ issues = "GitHub"
[formFill]
allSaved = "All saved"
analyzingFields = "Analysing form fields..."
applyFailed = "Could not apply the changes"
extractCsvError = "Failed to extract CSV"
extractXlsxError = "Failed to extract XLSX"
filled = "filled"
flattenAfterFilling = "Flatten after filling"
goToPage = "Go to this page"
noFields = "No fillable form fields found in this PDF."
page = "Page"
placeholderEnter = "Enter"
placeholderSelect = "Select"
requiredAbbreviation = "req"
@@ -4325,8 +4328,83 @@ rescanFields = "Re-scan fields"
rescanFormFields = "Re-scan form fields"
save = "Save"
saveShortcut = "Ctrl+S to save"
skippedEdits_one = "1 change could not be applied:"
skippedEdits_other = "{{count}} changes could not be applied:"
skippedEditsTruncated = "{{count}} more not listed."
unsavedChanges = "Unsaved changes"
[formFill.create]
commit = "Add {{count}} field(s) to PDF"
empty = "No fields drawn yet."
failed = "Failed to add fields"
goToField = "Go to this field"
hint = "Pick a field type, then draw it on the page."
placing = "Draw a {{type}} field on the page. Press Esc to stop."
preview = "Hold to preview"
previewHelp = "Hold to see the fields as they will look once added, without the editing outlines."
removeField = "Remove field"
[formFill.editor]
action = "Button action"
actionHelp = "What the button does when clicked."
actionNone = "None"
actionPrint = "Print"
actionReset = "Reset form"
actionSubmit = "Submit to URL"
actionUri = "Open URL"
actionUrl = "URL"
actionUrlHelp = "The address the button opens or submits to."
addOption = "Add option"
caption = "Button caption"
captionHelp = "The text printed on the button face."
defaultValue = "Default value"
defaultValueHelp = "What the field contains before anyone fills it in. Leave blank for an empty field."
fontSize = "Font size"
fontSizeHelp = "Text size inside the field. Leave blank to let the reader size it to fit."
label = "Label"
labelHelp = "The wording shown to whoever fills the form. Leave it blank to fall back to the field name."
maxLength = "Max length (comb)"
maxLengthHelp = "Caps how many characters fit, drawn as evenly spaced boxes."
multiline = "Multi-line"
multilineHelp = "Allows more than one line of text and wraps at the field's edge."
multiSelect = "Allow multiple selection"
multiSelectHelp = "Lets more than one option be chosen at once."
name = "Field name"
nameHelp = "The field's internal name. Used when exporting data or filling the form from another system, so keep it unique and free of spaces."
optionGap = "Option spacing"
optionGapHelp = "Gap between buttons, in points. Leave blank to spread them evenly down the box."
optionPlaceholder = "Option {{n}}"
options = "Options"
optionsEmpty = "Add at least one option."
optionsHelp = "The choices offered in the list. Each one is stored as typed, so keep them short and distinct."
optionSize = "Option size"
optionSizeHelp = "Width and height of each button, in points. Leave blank to fit them to the box you drew."
readOnly = "Read-only"
readOnlyHelp = "Shows a value but stops anyone editing it."
removeOption = "Remove option"
required = "Required"
requiredHelp = "The form cannot be submitted until this field is filled in."
signatureNote = "Placeholder only - you don't sign here. It marks where a signature belongs so a PDF signer (Adobe Acrobat, a signing service, etc.) places the signature in this spot when the document is signed."
tooltip = "Tooltip"
tooltipHelp = "The hint shown when someone hovers the field in a PDF reader."
type = "Type"
typeHelp = "What kind of field this is. Changing it rebuilds the field, so its current value is not carried over."
[formFill.mode]
create = "Create"
fill = "Fill"
label = "Form editor mode"
modify = "Modify"
[formFill.modify]
commit = "Save {{count}} change(s)"
delete = "Delete"
empty = "This PDF has no form fields yet."
failed = "Failed to save changes"
groupSizeHint = "Use Option size"
hint = "Select a field to edit its properties, drag it on the page, or delete it."
restore = "Restore"
[formFill.sidebar]
close = "Close sidebar"
@@ -4657,8 +4735,8 @@ tags = "simplify,remove,interactive,flatten,flatten form,remove form fields,make
title = "Flatten"
[home.formFill]
desc = "Fill PDF form fields interactively with a visual editor"
title = "Fill Form"
desc = "Fill, create, edit, and delete PDF form fields with a visual editor"
title = "Form Editor"
[home.getPdfInfo]
desc = "Grabs any and all information possible on PDFs"
@@ -11699,7 +11777,7 @@ downloadAll = "Download All"
exitRedaction = "Exit Redaction Mode"
exportAll = "Export PDF"
exportSelected = "Export Selected Pages"
formFill = "Fill Form"
formFill = "Form Editor"
hideToolbar = "Hide toolbar"
moreActions = "More actions"
multiTool = "Multi-Tool"
+2 -2
View File
@@ -272,8 +272,8 @@
},
"formFill": {
"image": "/og_images/form-fill.png",
"title": "Fill Form - Stirling PDF",
"description": "Fill PDF form fields interactively with a visual editor"
"title": "Form Editor - Stirling PDF",
"description": "Fill, create, edit, and delete PDF form fields with a visual editor"
},
"multiTool": {
"image": "/og_images/multi-tool.png",
+2 -2
View File
@@ -273,8 +273,8 @@
},
"formFill": {
"image": "/og_images/form-fill.png",
"title": "Fill Form - Stirling PDF",
"description": "Fill PDF form fields interactively with a visual editor"
"title": "Form Editor - Stirling PDF",
"description": "Fill, create, edit, and delete PDF form fields with a visual editor"
},
"multiTool": {
"image": "/og_images/multi-tool.png",
+1 -1
View File
@@ -634,7 +634,7 @@ const CODE_EXEMPT_PATH = [
// PDF rendering/drawing surfaces that legitimately carry colour literals —
// scoped to specific tool paths, not a blanket "pdf" substring (which used to
// exempt most of the app in a PDF product).
/pdfTextEditor|pixelCompare|\/compare\.ts$|customPrimary|accentColors/,
/pdfTextEditor|pixelCompare|\/compare\.ts$|customPrimary|accentColors|formFieldColors/,
/validateSignature\/outputtedPDFSections|CenteredMessageSection|StatusBadgeSection/,
/\/viewer\/|Annotation|useViewerReadAloud|CommentsSidebar|\/constants\/search\.ts$|SignaturePreview/,
/ColorPicker|ColorControl|WatchedFolderManagementModal|watchedFolderPresets|fileColors|unifiedBackground|folder\.ts$|policyFolders/,
@@ -1,14 +1,8 @@
import { useRef, useEffect } from "react";
import { Modal, Text, Group, Stack, rem } from "@mantine/core";
import { Button } from "@app/ui/Button";
import { IconBadge } from "@app/ui/IconBadge";
import { useNavigationGuard } from "@app/contexts/NavigationContext";
import { useTranslation } from "react-i18next";
import WarningAmberRoundedIcon from "@mui/icons-material/WarningAmberRounded";
import { Z_INDEX_TOAST } from "@app/styles/zIndex";
import { UnsavedChangesDialog } from "@app/components/shared/UnsavedChangesDialog";
const NavigationWarningModal = () => {
const { t } = useTranslation();
const {
showNavigationWarning,
hasUnsavedChanges,
@@ -77,79 +71,13 @@ const NavigationWarningModal = () => {
}
return (
<Modal
<UnsavedChangesDialog
opened={showNavigationWarning}
onClose={handleKeepWorking}
centered
size={rem(400)}
radius="lg"
padding="xl"
withCloseButton={false}
overlayProps={{ blur: 4, opacity: 0.4 }}
transitionProps={{ transition: "pop", duration: 140 }}
closeOnClickOutside={true}
closeOnEscape={true}
zIndex={Z_INDEX_TOAST}
>
<Modal.Title className="sr-only">
{t("unsavedChangesTitle", "Unsaved changes")}
</Modal.Title>
<Stack align="center" gap="md">
<IconBadge accent="amber" size="md">
<WarningAmberRoundedIcon style={{ fontSize: 22 }} />
</IconBadge>
<Stack gap={4} ta="center">
<Text fw={600} size="lg">
{t("unsavedChangesTitle", "Unsaved changes")}
</Text>
<Text size="sm" c="var(--c-text-muted)" lh={1.5}>
{t(
"unsavedChangesBody",
"You have unsaved changes to your PDF. Are you sure you want to leave?",
)}
</Text>
</Stack>
<Stack gap="sm" w="100%" mt="xs">
{hasApply && (
<Button
fullWidth
variant="primary"
onClick={handleApplyAndContinue}
>
{t("applyAndContinue", "Save & Leave")}
</Button>
)}
{hasExport && (
<Button
fullWidth
variant="primary"
onClick={handleExportAndContinue}
>
{t("exportAndContinue", "Export & Leave")}
</Button>
)}
<Group grow gap="sm" wrap="nowrap">
<Button
variant="secondary"
accent="neutral"
data-autofocus
onClick={handleKeepWorking}
>
{t("keepWorking", "Keep Working")}
</Button>
<Button
variant="secondary"
accent="danger"
onClick={handleDiscardChanges}
>
{t("discardChanges", "Discard & Leave")}
</Button>
</Group>
</Stack>
</Stack>
</Modal>
onKeepWorking={handleKeepWorking}
onDiscard={handleDiscardChanges}
onSave={hasApply ? handleApplyAndContinue : undefined}
onExport={hasExport ? handleExportAndContinue : undefined}
/>
);
};
@@ -0,0 +1,108 @@
/**
* The one "unsaved changes" dialog. Navigation and the form editor's tab switch both render it,
* so the choice looks identical wherever it interrupts you; only the actions behind it differ.
*/
import { Modal, Text, Group, Stack, rem } from "@mantine/core";
import { useTranslation } from "react-i18next";
import WarningAmberRoundedIcon from "@mui/icons-material/WarningAmberRounded";
import { Button } from "@app/ui/Button";
import { IconBadge } from "@app/ui/IconBadge";
import { Z_INDEX_TOAST } from "@app/styles/zIndex";
export interface UnsavedChangesDialogProps {
opened: boolean;
saving?: boolean;
onKeepWorking: () => void;
onDiscard: () => void;
/** Omit to hide the button, as when there is nothing this caller can save. */
onSave?: () => void;
onExport?: () => void;
}
export function UnsavedChangesDialog({
opened,
saving = false,
onKeepWorking,
onDiscard,
onSave,
onExport,
}: UnsavedChangesDialogProps) {
const { t } = useTranslation();
const heading = t("unsavedChangesTitle", "Unsaved changes");
return (
<Modal
opened={opened}
onClose={onKeepWorking}
centered
size={rem(400)}
radius="lg"
padding="xl"
withCloseButton={false}
overlayProps={{ blur: 4, opacity: 0.4 }}
transitionProps={{ transition: "pop", duration: 140 }}
closeOnClickOutside={true}
closeOnEscape={true}
zIndex={Z_INDEX_TOAST}
>
<Modal.Title className="sr-only">{heading}</Modal.Title>
<Stack align="center" gap="md">
<IconBadge accent="amber" size="md">
<WarningAmberRoundedIcon style={{ fontSize: 22 }} />
</IconBadge>
<Stack gap={4} ta="center">
<Text fw={600} size="lg">
{heading}
</Text>
<Text size="sm" c="var(--c-text-muted)" lh={1.5}>
{t(
"unsavedChangesBody",
"You have unsaved changes to your PDF. Are you sure you want to leave?",
)}
</Text>
</Stack>
<Stack gap="sm" w="100%" mt="xs">
{onSave && (
<Button
fullWidth
variant="primary"
loading={saving}
data-testid="unsaved-save"
onClick={onSave}
>
{t("applyAndContinue", "Save & Leave")}
</Button>
)}
{onExport && (
<Button fullWidth variant="primary" onClick={onExport}>
{t("exportAndContinue", "Export & Leave")}
</Button>
)}
<Group grow gap="sm" wrap="nowrap">
<Button
variant="secondary"
accent="neutral"
data-autofocus
onClick={onKeepWorking}
>
{t("keepWorking", "Keep Working")}
</Button>
<Button
variant="secondary"
accent="danger"
data-testid="unsaved-discard"
onClick={onDiscard}
>
{t("discardChanges", "Discard & Leave")}
</Button>
</Group>
</Stack>
</Stack>
</Modal>
);
}
export default UnsavedChangesDialog;
@@ -43,6 +43,7 @@ import {
import { useWheelZoom } from "@app/hooks/useWheelZoom";
import { useFormFill } from "@app/tools/formFill/FormFillContext";
import { FormSaveBar } from "@app/tools/formFill/FormSaveBar";
import { FORM_APPLY_EVENT } from "@app/tools/formFill/formFillEvents";
import { useViewerKeyCommand } from "@app/hooks/useViewerKeyCommand";
import { useMeasurementManager } from "@app/hooks/useMeasurementManager";
import { ScaleCalibrationDialog } from "@app/components/viewer/ScaleCalibrationDialog";
@@ -782,8 +783,8 @@ const EmbedPdfViewerContent = ({
handleFormApply(blob);
}
};
window.addEventListener("formfill:apply", handler);
return () => window.removeEventListener("formfill:apply", handler);
window.addEventListener(FORM_APPLY_EVENT, handler);
return () => window.removeEventListener(FORM_APPLY_EVENT, handler);
}, [handleFormApply]);
// Apply layer visibility changes - reload the modified PDF into the viewer
@@ -1237,6 +1238,7 @@ const EmbedPdfViewerContent = ({
showBakedAnnotations={isAnnotationsVisible}
enableRedaction={shouldEnableRedaction}
enableFormFill={shouldEnableFormFill}
formEditingActive={isFormFillToolActive}
isManualRedactionMode={isManualRedactMode}
signatureApiRef={signatureApiRef as React.RefObject<any>}
annotationApiRef={annotationApiRef as React.RefObject<any>}
@@ -101,6 +101,9 @@ import { DocumentReadyWrapper } from "@app/components/viewer/DocumentReadyWrappe
import { ActiveDocumentProvider } from "@app/components/viewer/ActiveDocumentContext";
import { pdfiumWasmUrl } from "@app/services/wasmPrecompiler";
import { FormFieldOverlay } from "@app/tools/formFill/FormFieldOverlay";
import { FormCreationInteractionLock } from "@app/tools/formFill/FormCreationInteractionLock";
import { FormFieldCreationOverlay } from "@app/tools/formFill/FormFieldCreationOverlay";
import { FormFieldEditOverlay } from "@app/tools/formFill/FormFieldEditOverlay";
import { ButtonAppearanceOverlay } from "@app/tools/formFill/ButtonAppearanceOverlay";
import SignatureFieldOverlay from "@app/components/viewer/SignatureFieldOverlay";
import { CommentsSidebar } from "@app/components/viewer/CommentsSidebar";
@@ -114,6 +117,8 @@ interface LocalEmbedPDFProps {
enableAnnotations?: boolean;
enableRedaction?: boolean;
enableFormFill?: boolean;
/** Structural create/modify overlays only mount while the Form tool owns the viewer. */
formEditingActive?: boolean;
isManualRedactionMode?: boolean;
showBakedAnnotations?: boolean;
onSignatureAdded?: (annotation: PdfAnnotationObject) => void;
@@ -207,6 +212,7 @@ export function LocalEmbedPDF({
enableAnnotations = false,
enableRedaction = false,
enableFormFill = false,
formEditingActive = false,
isManualRedactionMode = false,
showBakedAnnotations = true,
onSignatureAdded,
@@ -1006,6 +1012,7 @@ export function LocalEmbedPDF({
<ZoomAPIBridge />
<ScrollAPIBridge />
<SelectionAPIBridge />
<FormCreationInteractionLock />
<PanAPIBridge />
<SpreadAPIBridge />
<SearchAPIBridge />
@@ -1153,6 +1160,28 @@ export function LocalEmbedPDF({
/>
)}
{/* Create-mode: drag to place new fields */}
{enableFormFill && formEditingActive && (
<FormFieldCreationOverlay
documentId={documentId}
pageIndex={pageIndex}
pageWidth={width}
pageHeight={height}
fileId={fileId}
/>
)}
{/* Modify-mode: select / move / resize existing fields */}
{enableFormFill && formEditingActive && (
<FormFieldEditOverlay
documentId={documentId}
pageIndex={pageIndex}
pageWidth={width}
pageHeight={height}
fileId={fileId}
/>
)}
{/* SignatureFieldOverlay — bitmaps of digital-signature appearances */}
{file && (
<SignatureFieldOverlay
@@ -11,6 +11,7 @@
* For widgets without an appearance stream (unsigned fields, or fields whose
* PDF writer didn't embed one), we fall back to a translucent badge overlay.
*/
import { useStaleBakedFieldNames } from "@app/tools/formFill/FormFillContext";
import React, { useEffect, useMemo, useRef, useState, memo } from "react";
import {
renderSignatureFieldAppearances,
@@ -114,6 +115,7 @@ function SignatureFieldOverlayInner({
pageWidth,
pageHeight,
}: SignatureFieldOverlayProps) {
const staleNames = useStaleBakedFieldNames();
const [fields, setFields] = useState<ResolvedSignatureField[]>([]);
useEffect(() => {
@@ -135,8 +137,13 @@ function SignatureFieldOverlayInner({
}, [pdfSource]);
const pageFields = useMemo(
() => fields.filter((f) => f.pageIndex === pageIndex),
[fields, pageIndex],
// A staged move or delete leaves this bitmap stranded at the original rect, on top of the
// editor chrome, so it is dropped until the edit is applied and the appearance re-extracted.
() =>
fields.filter(
(f) => f.pageIndex === pageIndex && !staleNames.has(f.fieldName),
),
[fields, pageIndex, staleNames],
);
if (pageFields.length === 0) return null;
@@ -124,7 +124,7 @@ export function useViewerWorkbenchBarButtons(
const layersLabel = t("workbenchBar.toggleLayers", "Toggle Layers");
const commentsLabel = t("workbenchBar.toggleComments", "Comments");
const annotationsLabel = t("workbenchBar.annotations", "Annotations");
const formFillLabel = t("workbenchBar.formFill", "Fill Form");
const formFillLabel = t("workbenchBar.formFill", "Form Editor");
const rulerLabel = t("workbenchBar.ruler", "Ruler / Measure");
const rulerSettingsLabel = t("workbenchBar.rulerSettings", "Scale Settings");
const readAloudLabel = t("workbenchBar.readAloud", "Read Aloud");
@@ -455,11 +455,11 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
height="1.5rem"
/>
),
name: t("home.formFill.title", "Fill Form"),
name: t("home.formFill.title", "Form Editor"),
component: lazy(() => import("@app/tools/formFill/FormFill")),
description: t(
"home.formFill.desc",
"Fill PDF form fields interactively with a visual editor",
"Fill, create, edit, and delete PDF form fields with a visual editor",
),
categoryId: ToolCategoryId.STANDARD_TOOLS,
subcategoryId: SubcategoryId.GENERAL,
@@ -467,7 +467,19 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
endpoints: ["form-fill"],
automationSettings: null,
supportsAutomate: false,
synonyms: ["form", "fill", "fillable", "input", "field", "acroform"],
synonyms: [
"form",
"fill",
"fillable",
"input",
"field",
"acroform",
"edit",
"create",
"editor",
"modify",
"builder",
],
},
changePermissions: {
icon: <LocalIcon icon="lock-outline" width="1.5rem" height="1.5rem" />,
@@ -0,0 +1,94 @@
import type { Page } from "@playwright/test";
/**
* WebKit elides Blob-backed multipart part bodies from `route.request().postData()`, so read them
* at the XHR/fetch seam instead; part headers stay readable on every engine.
*/
const STORE_KEY = "__capturedMultipartParts";
/**
* Start recording the text of the `partName` part of any `FormData` the page
* posts, keyed by the request's URL pathname. Must be called before navigation.
*/
export async function captureMultipartPart(
page: Page,
partName: string,
): Promise<void> {
await page.addInitScript((name: string) => {
const store: Record<string, string> = {};
(window as unknown as Record<string, unknown>).__capturedMultipartParts =
store;
const record = (url: string, body: unknown): void => {
if (!(body instanceof FormData)) return;
const part = body.get(name);
if (!(part instanceof Blob)) return;
let pathname: string;
try {
pathname = new URL(url, window.location.href).pathname;
} catch {
return;
}
void part.text().then((text) => {
store[pathname] = text;
});
};
// axios posts through XHR; keep the URL from open() so the capture stays
// keyed by endpoint and a commit to the wrong URL still fails the spec.
const originalOpen = XMLHttpRequest.prototype.open;
XMLHttpRequest.prototype.open = function (
this: XMLHttpRequest,
...args: unknown[]
) {
(this as unknown as Record<string, unknown>).__capturedUrl = String(
args[1] ?? "",
);
return (originalOpen as (...a: unknown[]) => unknown).apply(this, args);
};
const originalSend = XMLHttpRequest.prototype.send;
XMLHttpRequest.prototype.send = function (
this: XMLHttpRequest,
...args: unknown[]
) {
const url = (this as unknown as Record<string, unknown>).__capturedUrl;
record(typeof url === "string" ? url : "", args[0]);
return (originalSend as (...a: unknown[]) => unknown).apply(this, args);
};
// Mirror it on fetch so the capture survives if the api client moves off XHR.
const originalFetch = window.fetch;
window.fetch = function (
this: typeof window,
input: RequestInfo | URL,
init?: RequestInit,
) {
const url =
typeof input === "string"
? input
: input instanceof URL
? input.href
: input.url;
record(url, init?.body);
return originalFetch.call(this, input, init);
};
}, partName);
}
/** The captured part text for `pathname`, or undefined if nothing posted yet. */
export function readCapturedPart(
page: Page,
pathname: string,
): Promise<string | undefined> {
return page.evaluate(
([key, path]) =>
(
(window as unknown as Record<string, unknown>)[key] as
| Record<string, string>
| undefined
)?.[path],
[STORE_KEY, pathname] as const,
);
}
@@ -0,0 +1,474 @@
import { test, expect } from "@app/tests/helpers/stub-test-base";
import { uploadFiles } from "@app/tests/helpers/ui-helpers";
import {
captureMultipartPart,
readCapturedPart,
} from "@app/tests/helpers/multipart-capture";
import { readFileSync } from "fs";
import path from "path";
import type { Page, Route } from "@playwright/test";
import type { FieldEditBatch } from "@app/tools/formFill/types";
/**
* Form field editor with `/api/v1/form/*` mocked: panel UI, staged-change bookkeeping and the
* committed `/edit-fields` payload. The PDFBox round-trip lives in the live spec and JUnit tests.
*/
const SAMPLE_PDF = path.join(
import.meta.dirname,
"../test-fixtures/sample.pdf",
);
const PDF_BYTES = readFileSync(SAMPLE_PDF);
/** Two text fields on page 0, in the shape the backend emits. */
const STUB_FIELDS = [
{
name: "firstName",
label: "First name",
type: "text",
value: "",
options: null,
displayOptions: null,
required: false,
readOnly: false,
multiSelect: false,
multiline: false,
tooltip: null,
widgets: [
{
pageIndex: 0,
x: 100,
y: 100,
width: 180,
height: 20,
fontSize: 12,
cropBoxHeight: 792,
},
],
},
{
name: "lastName",
label: "Last name",
type: "text",
value: "",
options: null,
displayOptions: null,
required: false,
readOnly: false,
multiSelect: false,
multiline: false,
tooltip: null,
widgets: [
{
pageIndex: 0,
x: 100,
y: 140,
width: 180,
height: 20,
fontSize: 12,
cropBoxHeight: 792,
},
],
},
];
const EDIT_FIELDS_PATH = "/api/v1/form/edit-fields";
/**
* Stubs the form endpoints; `fields` is what extraction returns (default none, so create-mode
* drags land on an unobstructed overlay). Returns captured multipart envelopes (part headers only).
*/
async function stubFormEndpoints(page: Page, fields: unknown[] = []) {
const captured: Record<string, string> = {};
// The edits JSON is a Blob part, whose body Playwright cannot read back on
// WebKit; capture it in-page instead. Must be installed before navigation.
await captureMultipartPart(page, "edits");
await page.route("**/api/v1/form/fields-with-coordinates", (route: Route) =>
route.fulfill({ json: fields }),
);
// Trailing ** so the glob still matches once a query string is appended.
await page.route("**/api/v1/form/edit-fields**", (route: Route) => {
captured["edit-fields"] = route.request().postData() ?? "";
route.fulfill({
status: 200,
contentType: "application/pdf",
body: PDF_BYTES,
});
});
return captured;
}
/**
* Asserts the multipart envelope on the wire: part headers read on every engine (only Blob part
* bodies are elided on WebKit), and this is the shape the backend's `@RequestPart` binding needs.
*/
function expectEditFieldsEnvelope(envelope: string) {
expect(envelope).toContain('name="file"');
expect(envelope).toContain('filename="sample.pdf"');
expect(envelope).toContain('name="edits"');
expect(envelope).toMatch(/content-type:\s*application\/json/i);
}
/** The parsed `edits` JSON the app posted to /edit-fields. */
async function readEditBatch(page: Page): Promise<FieldEditBatch> {
await expect
.poll(() => readCapturedPart(page, EDIT_FIELDS_PATH))
.toBeTruthy();
const json = await readCapturedPart(page, EDIT_FIELDS_PATH);
return JSON.parse(json as string) as FieldEditBatch;
}
async function openFormTool(page: Page) {
await page.goto("/form-fill");
await page.waitForLoadState("domcontentloaded");
await uploadFiles(page, SAMPLE_PDF);
}
/** The Mantine SegmentedControl hides the radio input; the label is the target. */
function modeTab(page: Page, name: string) {
return page
.locator(".mantine-SegmentedControl-label")
.filter({ hasText: name });
}
async function selectMode(page: Page, name: string) {
await modeTab(page, name).click();
}
/**
* Draws on the create overlay, retrying until the commit button enables: pointer drags over the
* WASM-rendered page can drop under parallel load.
*/
async function drawField(page: Page) {
const overlay = page.getByTestId("form-create-overlay-0");
await expect(overlay).toBeVisible({ timeout: 30_000 });
const commit = page.getByTestId("form-create-commit");
for (let attempt = 0; attempt < 4; attempt++) {
const box = await overlay.boundingBox();
if (!box) continue;
const startX = box.x + box.width * 0.25;
const startY = box.y + box.height * 0.25;
await page.mouse.move(startX, startY);
await page.mouse.down();
await page.mouse.move(startX + 60, startY + 20, { steps: 4 });
await page.mouse.move(startX + 120, startY + 40, { steps: 4 });
await page.mouse.up();
try {
await expect(commit).toBeEnabled({ timeout: 2000 });
return;
} catch {
// drag dropped under load - try again
}
}
}
test.describe("Form field editor", () => {
test("exposes Fill / Create / Modify modes", async ({ page }) => {
await stubFormEndpoints(page);
await openFormTool(page);
await expect(modeTab(page, "Fill")).toBeVisible();
await expect(modeTab(page, "Create")).toBeVisible();
await expect(modeTab(page, "Modify")).toBeVisible();
});
test("create mode: palette offers every creatable type", async ({ page }) => {
await stubFormEndpoints(page);
await openFormTool(page);
await selectMode(page, "Create");
for (const type of [
"text",
"checkbox",
"combobox",
"listbox",
"radio",
"button",
"signature",
]) {
await expect(page.getByTestId(`form-create-type-${type}`)).toBeVisible();
}
// Commit disabled with nothing queued.
await expect(page.getByTestId("form-create-commit")).toBeDisabled();
// Arming a type reveals the "draw on the page" hint.
await page.getByTestId("form-create-type-text").click();
await expect(
page.getByText(/Draw a Text field on the page/i),
).toBeVisible();
});
test("create mode: drawing a text field commits via /edit-fields", async ({
page,
}) => {
const captured = await stubFormEndpoints(page);
await openFormTool(page);
await selectMode(page, "Create");
await page.getByTestId("form-create-type-text").click();
await drawField(page);
// A queued field appears with a commit affordance enabled.
await expect(page.getByTestId("form-create-commit")).toBeEnabled();
await page.getByTestId("form-create-commit").click();
const batch = await readEditBatch(page);
expect(batch.add).toHaveLength(1);
expect(batch.add?.[0]?.type).toBe("text");
// The drawn rectangle, not a degenerate one - guards the drag geometry.
expect(batch.add?.[0]?.pageIndex).toBe(0);
expect(batch.add?.[0]?.width).toBeGreaterThan(0);
expect(batch.add?.[0]?.height).toBeGreaterThan(0);
await expect.poll(() => captured["edit-fields"]).toBeTruthy();
expectEditFieldsEnvelope(captured["edit-fields"]);
// Committing clears the queue, so there is nothing left to add.
await expect(page.getByTestId("form-create-commit")).toBeDisabled();
});
test("create mode: a drawn field is selected and can be resized straight away", async ({
page,
}) => {
await stubFormEndpoints(page);
await openFormTool(page);
await selectMode(page, "Create");
await page.getByTestId("form-create-type-text").click();
await drawField(page);
// Selected on placement, so the resize handles are live without another click.
const box = page.locator('[data-testid^="form-edit-field-pending-"]');
await expect(box).toHaveCount(1);
// WebKit paints a beat after the element exists, so measure only once it is visible.
await expect(box).toBeVisible();
const before = await box.boundingBox();
expect(before).not.toBeNull();
const handle = page.getByTestId("form-edit-handle-se");
await expect(handle).toBeVisible();
// hover() waits for actionability and centres on the grip; a hand-computed point
// intermittently misses a 9px target once the layout shifts under it.
await handle.hover();
const grip = await handle.boundingBox();
expect(grip).not.toBeNull();
await page.mouse.down();
await page.mouse.move(grip!.x + 80, grip!.y + 50, { steps: 12 });
await page.mouse.up();
await expect
.poll(async () => (await box.boundingBox())?.width ?? 0)
.toBeGreaterThan(before!.width + 20);
});
test("create mode: drawing a field shows one box, and moving it leaves none behind", async ({
page,
}) => {
await stubFormEndpoints(page);
await openFormTool(page);
await selectMode(page, "Create");
await page.getByTestId("form-create-type-text").click();
await drawField(page);
// Measures geometry rather than hit-testing: the duplicate box was pointer-events:none,
// so elementsFromPoint skipped it and the bug sailed through. Counts every bordered box any
// overlay draws over the point.
const boxesAt = ([x, y]: [number, number]) =>
page.evaluate(
([px, py]) =>
Array.from(
document.querySelectorAll(
'[data-testid^="form-create-overlay-"] div, [data-testid^="form-edit-overlay-"] div',
),
).filter((el) => {
const style = getComputedStyle(el);
// The field box is drawn with an outline, not a border, so that its border does not
// indent the content box and push the field preview inside it out of alignment.
const framed =
style.borderStyle !== "none" || style.outlineStyle !== "none";
if (!framed || style.display === "none") {
return false;
}
const r = el.getBoundingClientRect();
// Ignore the resize grips, which are far smaller than any field box.
if (r.width < 12 || r.height < 12) return false;
return (
px >= r.left && px <= r.right && py >= r.top && py <= r.bottom
);
}).length,
[x, y],
);
const box = page
.locator('[data-testid^="form-edit-field-pending-"]')
.first();
// WebKit paints a beat after the element exists, so measure only once it is visible.
await expect(box).toBeVisible();
const before = await box.boundingBox();
expect(before).not.toBeNull();
const origin: [number, number] = [
before!.x + before!.width / 2,
before!.y + before!.height / 2,
];
await expect.poll(() => boxesAt(origin)).toBe(1);
await page.mouse.move(origin[0], origin[1]);
await page.mouse.down();
await page.mouse.move(origin[0] + 90, origin[1] + 60, { steps: 10 });
await page.mouse.up();
const after = await box.boundingBox();
expect(Math.abs(after!.x - before!.x)).toBeGreaterThan(20);
// Nothing left behind where it used to be.
expect(await boxesAt(origin)).toBe(0);
});
test("create mode: Delete removes the drawn field before it is applied", async ({
page,
}) => {
await stubFormEndpoints(page);
await openFormTool(page);
await selectMode(page, "Create");
await page.getByTestId("form-create-type-text").click();
await drawField(page);
await expect(
page.locator('[data-testid^="form-edit-field-pending-"]'),
).toHaveCount(1);
await page.keyboard.press("Delete");
await expect(
page.locator('[data-testid^="form-edit-field-pending-"]'),
).toHaveCount(0);
// Nothing queued means nothing to apply.
await expect(page.getByTestId("form-create-commit")).toBeDisabled();
});
test("create mode: drawing a radio field commits a radio definition", async ({
page,
}) => {
const captured = await stubFormEndpoints(page);
await openFormTool(page);
await selectMode(page, "Create");
await page.getByTestId("form-create-type-radio").click();
await drawField(page);
await expect(page.getByTestId("form-create-commit")).toBeEnabled();
await page.getByTestId("form-create-commit").click();
const batch = await readEditBatch(page);
expect(batch.add).toHaveLength(1);
expect(batch.add?.[0]?.type).toBe("radio");
expect(batch.add?.[0]?.options).toEqual(["Option 1", "Option 2"]);
await expect.poll(() => captured["edit-fields"]).toBeTruthy();
expectEditFieldsEnvelope(captured["edit-fields"]);
});
test("create mode: a choice field auto-shows seeded options", async ({
page,
}) => {
await stubFormEndpoints(page);
await openFormTool(page);
await selectMode(page, "Create");
await page.getByTestId("form-create-type-listbox").click();
await drawField(page);
// The just-drawn field's property editor auto-expands with Options
// pre-seeded, so no manual expand is needed.
await expect(page.getByText("Options", { exact: true })).toBeVisible();
await expect(page.getByPlaceholder("Option 1")).toHaveValue("Option 1");
await expect(page.getByPlaceholder("Option 2")).toHaveValue("Option 2");
});
test("create mode: signature field explains it is a placeholder", async ({
page,
}) => {
await stubFormEndpoints(page);
await openFormTool(page);
await selectMode(page, "Create");
await page.getByTestId("form-create-type-signature").click();
await drawField(page);
// The editor makes clear you don't sign here - it's a placeholder a signer fills.
await expect(page.getByText(/Placeholder only/i)).toBeVisible();
});
test("modify mode: lists fields and deletes one via /edit-fields", async ({
page,
}) => {
const captured = await stubFormEndpoints(page, STUB_FIELDS);
await openFormTool(page);
await selectMode(page, "Modify");
// Both stubbed fields render as rows.
await expect(page.getByTestId("form-modify-row-firstName")).toBeVisible({
timeout: 30_000,
});
await expect(page.getByTestId("form-modify-row-lastName")).toBeVisible();
// Mark one for deletion → commit count reflects it and button enables.
await page.getByTestId("form-modify-delete-firstName").click();
const commit = page.getByTestId("form-modify-commit");
await expect(commit).toContainText("1");
await expect(commit).toBeEnabled();
await commit.click();
const batch = await readEditBatch(page);
expect(batch.delete).toEqual(["firstName"]);
// A field queued for deletion is not also sent as a modification.
expect(batch.modify ?? []).toHaveLength(0);
await expect.poll(() => captured["edit-fields"]).toBeTruthy();
expectEditFieldsEnvelope(captured["edit-fields"]);
// The staged deletion is cleared once it has been saved.
await expect(commit).toBeDisabled();
});
test("modify mode: editing a property commits via /edit-fields", async ({
page,
}) => {
const captured = await stubFormEndpoints(page, STUB_FIELDS);
await openFormTool(page);
await selectMode(page, "Modify");
await page.getByTestId("form-modify-row-firstName").click();
// The property editor reveals the label input; change it.
const labelInput = page.getByLabel("Label").first();
await expect(labelInput).toBeVisible();
await labelInput.fill("Given name");
const commit = page.getByTestId("form-modify-commit");
await expect(commit).toBeEnabled();
await commit.click();
const batch = await readEditBatch(page);
expect(batch.modify).toHaveLength(1);
expect(batch.modify?.[0]?.targetName).toBe("firstName");
expect(batch.modify?.[0]?.label).toBe("Given name");
expect(batch.delete ?? []).toHaveLength(0);
await expect.poll(() => captured["edit-fields"]).toBeTruthy();
expectEditFieldsEnvelope(captured["edit-fields"]);
await expect(commit).toBeDisabled();
});
});
@@ -8,6 +8,7 @@
* Uses the same EPDF_RenderAnnotBitmap / FPDF_FFLDraw pipeline as
* SignatureFieldOverlay to produce the button's native PDF appearance.
*/
import { useStaleBakedFieldNames } from "@app/tools/formFill/FormFillContext";
import React, { useEffect, useMemo, useRef, useState, memo } from "react";
import {
renderButtonFieldAppearances,
@@ -66,6 +67,7 @@ function ButtonAppearanceOverlayInner({
pageWidth,
pageHeight,
}: ButtonAppearanceOverlayProps) {
const staleNames = useStaleBakedFieldNames();
const [appearances, setAppearances] = useState<SignatureFieldAppearance[]>(
[],
);
@@ -111,36 +113,38 @@ function ButtonAppearanceOverlayInner({
}}
data-button-appearance-page={pageIndex}
>
{pageAppearances.map((btn, idx) => {
const sx =
btn.sourcePageWidth > 0 ? pageWidth / btn.sourcePageWidth : 1;
const sy =
btn.sourcePageHeight > 0 ? pageHeight / btn.sourcePageHeight : 1;
const left = btn.x * sx;
const top = btn.y * sy;
const width = btn.width * sx;
const height = btn.height * sy;
{pageAppearances
.filter((btn) => !staleNames.has(btn.fieldName))
.map((btn, idx) => {
const sx =
btn.sourcePageWidth > 0 ? pageWidth / btn.sourcePageWidth : 1;
const sy =
btn.sourcePageHeight > 0 ? pageHeight / btn.sourcePageHeight : 1;
const left = btn.x * sx;
const top = btn.y * sy;
const width = btn.width * sx;
const height = btn.height * sy;
return (
<div
key={`btn-appearance-${btn.fieldName}-${idx}`}
style={{
position: "absolute",
left,
top,
width,
height,
overflow: "hidden",
}}
>
<ButtonBitmapCanvas
imageData={btn.imageData!}
cssWidth={width}
cssHeight={height}
/>
</div>
);
})}
return (
<div
key={`btn-appearance-${btn.fieldName}-${idx}`}
style={{
position: "absolute",
left,
top,
width,
height,
overflow: "hidden",
}}
>
<ButtonBitmapCanvas
imageData={btn.imageData!}
cssWidth={width}
cssHeight={height}
/>
</div>
);
})}
</div>
);
}
@@ -0,0 +1,26 @@
/**
* Holds the viewer's interactions paused for as long as the create tool is armed, so drawing a
* field never also selects the text underneath.
*/
import { useEffect } from "react";
import { useInteractionManagerCapability } from "@embedpdf/plugin-interaction-manager/react";
import { useFormFill } from "@app/tools/formFill/FormFillContext";
export function FormCreationInteractionLock() {
const { mode, creationType } = useFormFill();
const { provides: interactionManager } = useInteractionManagerCapability();
const armed = mode === "create" && creationType != null;
// Mounted once per document, not per page: the page overlays live inside a virtualising
// Scroller, so one scrolling out of view would otherwise resume mid-session.
useEffect(() => {
if (!armed || !interactionManager) return undefined;
// Never paused mid-gesture: pausing between a pointerdown and its pointerup strands the
// selection, which then keeps extending on every move once resumed.
interactionManager.pause();
return () => interactionManager.resume();
}, [armed, interactionManager]);
return null;
}
@@ -0,0 +1,326 @@
/** Left panel for "create" mode; drawing happens in FormFieldCreationOverlay. */
import React, { useCallback, useEffect, useRef, useState } from "react";
import {
Stack,
Text,
Group,
Alert,
Collapse,
Paper,
Tooltip,
} from "@mantine/core";
import { Button } from "@app/ui/Button";
import { ActionIcon } from "@app/ui/ActionIcon";
import { useTranslation } from "react-i18next";
import VisibilityOutlinedIcon from "@mui/icons-material/VisibilityOutlined";
import DeleteOutlineIcon from "@mui/icons-material/DeleteOutlineRounded";
import MyLocationIcon from "@mui/icons-material/MyLocation";
import { useViewer } from "@app/contexts/ViewerContext";
import {
pendingSelectionName,
pendingIdFrom,
} from "@app/tools/formFill/pendingSelection";
import WarningAmberIcon from "@mui/icons-material/WarningAmber";
import { useFormFill } from "@app/tools/formFill/FormFillContext";
import {
CREATABLE_FIELD_TYPES,
type CreatableFieldType,
type NewFieldDefinition,
} from "@app/tools/formFill/types";
import {
FIELD_TYPE_ICON,
FIELD_TYPE_COLOR,
} from "@app/tools/formFill/fieldMeta";
import { FormFieldPropertyEditor } from "@app/tools/formFill/FormFieldPropertyEditor";
import { SkippedEditsAlert } from "@app/tools/formFill/SkippedEditsAlert";
import { useFormCommit } from "@app/tools/formFill/useFormCommit";
import styles from "@app/tools/formFill/FormFill.module.css";
interface FormFieldCreatePanelProps {
currentFile: File | Blob | null;
onApplied?: (blob: Blob) => void;
}
const TYPE_LABEL: Record<CreatableFieldType, string> = {
text: "Text",
checkbox: "Checkbox",
combobox: "Dropdown",
listbox: "List box",
radio: "Radio",
button: "Button",
signature: "Signature",
};
export function FormFieldCreatePanel({
currentFile,
onApplied,
}: FormFieldCreatePanelProps) {
const { t } = useTranslation();
const {
creationType,
setCreationType,
pendingFields,
updatePendingField,
removePendingField,
commitNewFields,
setPreviewing,
selectedFieldName,
setSelectedField,
} = useFormFill();
const [expandedId, setExpandedId] = useState<string | null>(null);
const { scrollActions } = useViewer();
const goToPage = (pageIndex: number) =>
scrollActions.scrollToPage(pageIndex + 1);
const { committing, error, commit } = useFormCommit(onApplied);
// Auto-expand the property editor of a freshly-drawn field so its settings
// (especially options for choice/radio) are visible immediately.
const prevCountRef = useRef(0);
const expandedRowRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (pendingFields.length > prevCountRef.current) {
setExpandedId(pendingFields[pendingFields.length - 1].id);
}
prevCountRef.current = pendingFields.length;
}, [pendingFields]);
// Clicking a box on the page should land you on its settings, not leave you hunting the list.
useEffect(() => {
const id = selectedFieldName ? pendingIdFrom(selectedFieldName) : null;
if (id) setExpandedId(id);
}, [selectedFieldName]);
// Newly drawn fields land at the bottom while the panel stays at the top, so their settings
// open somewhere the user cannot see.
useEffect(() => {
if (!expandedId) return;
expandedRowRef.current?.scrollIntoView({ block: "nearest" });
}, [expandedId]);
const handleCommit = useCallback(() => {
if (!currentFile || pendingFields.length === 0) return;
commit(
() => commitNewFields(currentFile),
"formFill.create.failed",
"Failed to add fields",
);
}, [currentFile, pendingFields, commitNewFields, commit]);
return (
<div className={styles.root}>
<div className={styles.header}>
<Text size="xs" c="dimmed">
{t(
"formFill.create.hint",
"Pick a field type, then draw it on the page.",
)}
</Text>
{/* Type palette */}
<Group gap={6} wrap="wrap">
{CREATABLE_FIELD_TYPES.map((type) => {
const armed = creationType === type;
return (
<Button
key={type}
size="sm"
variant={armed ? "primary" : "secondary"}
leftSection={FIELD_TYPE_ICON[type]}
onClick={() => setCreationType(armed ? null : type)}
data-testid={`form-create-type-${type}`}
>
{TYPE_LABEL[type]}
</Button>
);
})}
</Group>
{creationType && (
<Alert color="blue" variant="light" p="xs" radius="sm">
<Text size="xs">
{t(
"formFill.create.placing",
"Draw a {{type}} field on the page. Press Esc to stop.",
{ type: TYPE_LABEL[creationType] },
)}
</Text>
</Alert>
)}
{error && (
<Alert
icon={<WarningAmberIcon sx={{ fontSize: 16 }} />}
color="red"
variant="light"
p="xs"
radius="sm"
>
<Text size="xs">{error}</Text>
</Alert>
)}
<SkippedEditsAlert />
</div>
{/* Content stacks naturally; ToolPanel's own ScrollArea does the scrolling. */}
<div className={styles.fieldListInner}>
{pendingFields.length === 0 ? (
<Text size="xs" c="dimmed" ta="center" py="md">
{t("formFill.create.empty", "No fields drawn yet.")}
</Text>
) : (
<Stack gap={6}>
{pendingFields.map((pf) => {
const expanded = expandedId === pf.id;
return (
<Paper
key={pf.id}
withBorder
p={6}
radius="sm"
ref={expanded ? expandedRowRef : undefined}
data-testid={`form-pending-row-${pf.id}`}
// The whole row opens its settings: hunting for the small pencil is a
// needless step when the row is the thing you just clicked.
onClick={() => setExpandedId(expanded ? null : pf.id)}
style={{ cursor: "pointer" }}
>
<Group gap={6} wrap="nowrap" justify="space-between">
<Group gap={6} wrap="nowrap" style={{ minWidth: 0 }}>
<span
style={{
color: `var(--mantine-color-${FIELD_TYPE_COLOR[pf.type]}-6)`,
display: "flex",
}}
>
{FIELD_TYPE_ICON[pf.type]}
</span>
<Text size="xs" truncate>
{pf.name}
</Text>
<Text size="xs" c="dimmed">
p{pf.pageIndex + 1}
</Text>
</Group>
<Group gap={2} wrap="nowrap">
<Tooltip
label={t(
"formFill.create.goToField",
"Go to this field",
)}
withArrow
>
<ActionIcon
size="sm"
variant="tertiary"
aria-label={t(
"formFill.create.goToField",
"Go to this field",
)}
onClick={(e) => {
e.stopPropagation();
setSelectedField(pendingSelectionName(pf.id));
setExpandedId(pf.id);
goToPage(pf.pageIndex);
}}
data-testid={`form-pending-goto-${pf.id}`}
>
<MyLocationIcon sx={{ fontSize: 16 }} />
</ActionIcon>
</Tooltip>
<Tooltip
label={t("formFill.create.removeField", "Remove field")}
withArrow
>
<ActionIcon
size="sm"
variant="tertiary"
accent="danger"
aria-label={t(
"formFill.create.removeField",
"Remove field",
)}
onClick={(e) => {
e.stopPropagation();
removePendingField(pf.id);
}}
data-testid={`form-pending-remove-${pf.id}`}
>
<DeleteOutlineIcon sx={{ fontSize: 16 }} />
</ActionIcon>
</Tooltip>
</Group>
</Group>
<Collapse in={expanded}>
{/* The row toggles on click, so the settings must not bubble into it or
every field you touch closes the panel you are typing in. */}
<div
style={{ marginTop: 8 }}
onClick={(e) => e.stopPropagation()}
>
<FormFieldPropertyEditor
value={pf}
onChange={(patch) =>
updatePendingField(
pf.id,
patch as Partial<NewFieldDefinition>,
)
}
showName
/>
</div>
</Collapse>
</Paper>
);
})}
</Stack>
)}
</div>
<div className={styles.footer}>
<Tooltip
label={t(
"formFill.create.previewHelp",
"Hold to see the fields as they will look once added, without the editing outlines.",
)}
openDelay={250}
withArrow
>
<Button
size="sm"
variant="secondary"
accent="neutral"
fullWidth
disabled={pendingFields.length === 0}
data-testid="form-create-preview"
// Held, not toggled: releasing always restores the editing view, so preview
// cannot be left switched on by accident.
onPointerDown={() => setPreviewing(true)}
onPointerUp={() => setPreviewing(false)}
onPointerLeave={() => setPreviewing(false)}
onPointerCancel={() => setPreviewing(false)}
onBlur={() => setPreviewing(false)}
leftSection={<VisibilityOutlinedIcon fontSize="small" />}
>
{t("formFill.create.preview", "Hold to preview")}
</Button>
</Tooltip>
<Button
size="sm"
onClick={handleCommit}
loading={committing}
disabled={!currentFile || pendingFields.length === 0}
data-testid="form-create-commit"
>
{t("formFill.create.commit", "Add {{count}} field(s) to PDF", {
count: pendingFields.length,
})}
</Button>
</div>
</div>
);
}
export default FormFieldCreatePanel;
@@ -0,0 +1,318 @@
/**
* Per-page drag-to-place layer for "create" mode. Uses FormFieldOverlay's scale
* basis (pageWidthPx / pdfPage.size.width) so placements round-trip on reload.
*/
import React, {
useCallback,
useMemo,
useRef,
useState,
useEffect,
} from "react";
import { useFormFill } from "@app/tools/formFill/FormFillContext";
import { pendingSelectionName } from "@app/tools/formFill/pendingSelection";
import type { CreatableFieldType } from "@app/tools/formFill/types";
import {
pixelsToBackendRect,
backendRectToPixels,
clampPixelRect,
roundPdfRect,
type PixelRect,
} from "@app/tools/formFill/formCoordinateUtils";
import {
collectSnapTargets,
snapMove,
type SnapGuide,
} from "@app/tools/formFill/formSnapUtils";
import {
usePageScale,
getLocalPoint,
isTextEntryTarget,
} from "@app/tools/formFill/usePageScale";
import { SnapGuides } from "@app/tools/formFill/SnapGuides";
import { FORM_COLORS } from "@app/tools/formFill/formFieldColors";
interface FormFieldCreationOverlayProps {
documentId: string;
pageIndex: number;
pageWidth: number;
pageHeight: number;
fileId?: string | null;
}
/** Minimum drawn size (pixels) below which we treat the gesture as a click. */
const MIN_DRAG_PX = 5;
/** Default field size in PDF points, used for click-to-place. */
const DEFAULT_SIZE_PTS: Record<CreatableFieldType, { w: number; h: number }> = {
text: { w: 150, h: 24 },
checkbox: { w: 16, h: 16 },
combobox: { w: 150, h: 24 },
listbox: { w: 150, h: 64 },
radio: { w: 16, h: 16 },
button: { w: 120, h: 28 },
signature: { w: 200, h: 60 },
};
export function FormFieldCreationOverlay({
documentId,
pageIndex,
pageWidth,
pageHeight,
fileId,
}: FormFieldCreationOverlayProps) {
const {
mode,
creationType,
setCreationType,
pendingFields,
addPendingField,
selectedFieldName,
setSelectedField,
previewing,
state,
forFileId,
} = useFormFill();
const rootRef = useRef<HTMLDivElement>(null);
const [dragRect, setDragRect] = useState<PixelRect | null>(null);
const [guides, setGuides] = useState<SnapGuide[]>([]);
const dragStartRef = useRef<{ x: number; y: number } | null>(null);
const { scaleX, scaleY, pageHeightPts, pageWidthPts, rotation } =
usePageScale(documentId, pageIndex, pageWidth, pageHeight);
// Pixel rects of the OTHER fields on this page, used as snap targets.
const snapRects = useMemo<PixelRect[]>(() => {
const rects: PixelRect[] = [];
for (const field of state.fields) {
for (const w of field.widgets ?? []) {
if (w.pageIndex !== pageIndex) continue;
rects.push({
left: w.x * scaleX,
top: w.y * scaleY,
width: w.width * scaleX,
height: w.height * scaleY,
});
}
}
for (const pf of pendingFields) {
if (pf.pageIndex !== pageIndex) continue;
rects.push(backendRectToPixels(pf, scaleX, scaleY, pageHeightPts));
}
return rects;
}, [state.fields, pendingFields, pageIndex, scaleX, scaleY, pageHeightPts]);
// Precompute snap edges once (not on every pointermove).
const snapTargets = useMemo(() => collectSnapTargets(snapRects), [snapRects]);
const active = mode === "create" && creationType != null && !previewing;
// Stale-file guard: don't draw on a page whose fields belong to another file.
const fileMismatch =
fileId != null && forFileId != null && fileId !== forFileId;
// Whether this gesture began with something selected; see handlePointerDown.
const startedSelectedRef = useRef(false);
const localPoint = useCallback(
(e: React.PointerEvent) => getLocalPoint(e, rootRef.current, rotation),
[rotation],
);
const handlePointerDown = useCallback(
(e: React.PointerEvent) => {
if (!active) return;
// Before the bare-overlay check: a press on a pending outline must not select text either.
e.preventDefault();
// Only start a drag on the bare overlay, never on a pending outline.
if (e.target !== rootRef.current) return;
// A click away from a selection means "deselect"; a drag always means "draw". Which one
// this is cannot be known until the pointer lifts, so start the drag either way.
startedSelectedRef.current = Boolean(selectedFieldName);
rootRef.current?.setPointerCapture(e.pointerId);
const p = localPoint(e);
dragStartRef.current = p;
setDragRect({ left: p.x, top: p.y, width: 0, height: 0 });
},
[active, localPoint, selectedFieldName, setSelectedField],
);
const handlePointerMove = useCallback(
(e: React.PointerEvent) => {
if (!active || !dragStartRef.current) return;
const p = localPoint(e);
const start = dragStartRef.current;
let rect: PixelRect = {
left: Math.min(start.x, p.x),
top: Math.min(start.y, p.y),
width: Math.abs(p.x - start.x),
height: Math.abs(p.y - start.y),
};
const snapped = snapMove(rect, snapTargets, 6);
rect = { ...rect, left: snapped.left, top: snapped.top };
setGuides(snapped.guides);
setDragRect(rect);
},
[active, localPoint, snapTargets],
);
// A cancelled gesture (system swipe, focus loss) must not leave a half-drawn rect behind.
const cancelDrag = useCallback((e: React.PointerEvent) => {
dragStartRef.current = null;
startedSelectedRef.current = false;
setDragRect(null);
setGuides([]);
try {
rootRef.current?.releasePointerCapture(e.pointerId);
} catch {
/* pointer capture may already be released */
}
}, []);
const finishDrag = useCallback(
(e: React.PointerEvent) => {
if (!active || !dragStartRef.current || !creationType) return;
const start = dragStartRef.current;
dragStartRef.current = null;
setGuides([]);
try {
rootRef.current?.releasePointerCapture(e.pointerId);
} catch {
/* pointer capture may already be released */
}
const current = dragRect;
setDragRect(null);
if (!current) return;
const dragged =
// Either axis is enough: requiring both threw away a deliberate thin drag, such as a
// signature line, and replaced it with a default box centred on the press point.
Math.max(current.width, current.height) >= MIN_DRAG_PX;
// A click that went nowhere only clears the selection it started with.
if (!dragged && startedSelectedRef.current) {
startedSelectedRef.current = false;
setSelectedField(null);
return;
}
startedSelectedRef.current = false;
let pixelRect: PixelRect;
if (dragged) {
pixelRect = current;
} else {
// Click-to-place: default size centred on the click point.
const def = DEFAULT_SIZE_PTS[creationType];
const wPx = def.w * scaleX;
const hPx = def.h * scaleY;
pixelRect = {
left: start.x - wPx / 2,
top: start.y - hPx / 2,
width: wPx,
height: hPx,
};
}
pixelRect = clampPixelRect(pixelRect, pageWidth, pageHeight);
const pdf = roundPdfRect(
pixelsToBackendRect(pixelRect, scaleX, scaleY, pageHeightPts),
);
const id = addPendingField({
type: creationType,
pageIndex,
x: pdf.x,
y: pdf.y,
width: pdf.width,
height: pdf.height,
});
// Selected the moment it lands, so it can be nudged or resized without another click.
setSelectedField(pendingSelectionName(id));
},
[
active,
creationType,
dragRect,
scaleX,
scaleY,
pageHeightPts,
pageWidth,
pageHeight,
pageIndex,
addPendingField,
setSelectedField,
],
);
// Escape disarms placement / cancels the in-progress drag.
useEffect(() => {
if (!active) return;
const onKey = (e: KeyboardEvent) => {
// Never steal keys from a field the user is typing in.
if (isTextEntryTarget(e.target)) return;
if (e.key === "Escape") {
dragStartRef.current = null;
setDragRect(null);
setGuides([]);
setCreationType(null);
}
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [active, setCreationType]);
if (mode !== "create" || fileMismatch || !pageWidthPts) return null;
return (
<div
ref={rootRef}
data-testid={`form-create-overlay-${pageIndex}`}
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={finishDrag}
onPointerCancel={cancelDrag}
style={{
position: "absolute",
inset: 0,
pointerEvents: active ? "auto" : "none",
// Without this the browser claims the gesture as a pan and drags never start on touch.
touchAction: active ? "none" : "auto",
// Crosshair means the next click draws; the arrow means it clears the selection.
cursor: !active
? "default"
: selectedFieldName
? "default"
: "crosshair",
userSelect: "none",
WebkitUserSelect: "none",
zIndex: 5,
}}
>
{/* Queued fields are drawn by FormFieldEditOverlay, which also moves and resizes
them; drawing them here too would show two boxes and leave one behind on drag. */}
{/* Live drag preview */}
{dragRect && creationType && (
<div
style={{
position: "absolute",
left: dragRect.left,
top: dragRect.top,
width: dragRect.width,
height: dragRect.height,
border: `2px dashed ${FORM_COLORS.accent}`,
background: FORM_COLORS.accentFill,
pointerEvents: "none",
boxSizing: "border-box",
}}
/>
)}
{/* Alignment guides */}
<SnapGuides guides={guides} />
</div>
);
}
export default FormFieldCreationOverlay;
@@ -0,0 +1,792 @@
/**
* Per-page select/move/resize layer for "modify" mode. Geometry is staged in
* CropBox-relative, lower-left-origin points on FormFieldOverlay's scale basis.
*/
import React, {
useCallback,
useMemo,
useRef,
useState,
useEffect,
} from "react";
import { useFormFill } from "@app/tools/formFill/FormFillContext";
import {
pendingIdFrom,
pendingSelectionName,
} from "@app/tools/formFill/pendingSelection";
import type { FormField } from "@app/tools/formFill/types";
import {
pixelsToBackendRect,
radioOptionRects,
backendRectToPixels,
widgetRectToPixels,
clampPixelRect,
roundPdfRect,
type PixelRect,
} from "@app/tools/formFill/formCoordinateUtils";
import {
collectSnapTargets,
snapMove,
snapResize,
type SnapGuide,
} from "@app/tools/formFill/formSnapUtils";
import {
usePageScale,
getLocalPoint,
isTextEntryTarget,
} from "@app/tools/formFill/usePageScale";
import { SnapGuides } from "@app/tools/formFill/SnapGuides";
import { FORM_COLORS } from "@app/tools/formFill/formFieldColors";
interface FormFieldEditOverlayProps {
documentId: string;
pageIndex: number;
pageWidth: number;
pageHeight: number;
fileId?: string | null;
}
type HandleId = "nw" | "n" | "ne" | "e" | "se" | "s" | "sw" | "w";
const HANDLES: { id: HandleId; cursor: string }[] = [
{ id: "nw", cursor: "nwse-resize" },
{ id: "n", cursor: "ns-resize" },
{ id: "ne", cursor: "nesw-resize" },
{ id: "e", cursor: "ew-resize" },
{ id: "se", cursor: "nwse-resize" },
{ id: "s", cursor: "ns-resize" },
{ id: "sw", cursor: "nesw-resize" },
{ id: "w", cursor: "ew-resize" },
];
const MIN_PX = 8;
const HANDLE_SIZE = 9;
interface Interaction {
kind: "move" | "resize";
handle?: HandleId;
fieldName: string;
startX: number;
startY: number;
startRect: PixelRect;
}
function handleEdges(h: HandleId) {
return {
left: h === "nw" || h === "w" || h === "sw",
right: h === "ne" || h === "e" || h === "se",
top: h === "nw" || h === "n" || h === "ne",
bottom: h === "sw" || h === "s" || h === "se",
};
}
function handlePosition(h: HandleId, rect: PixelRect) {
const cx = rect.left + rect.width / 2;
const cy = rect.top + rect.height / 2;
const map: Record<HandleId, { x: number; y: number }> = {
nw: { x: rect.left, y: rect.top },
n: { x: cx, y: rect.top },
ne: { x: rect.left + rect.width, y: rect.top },
e: { x: rect.left + rect.width, y: cy },
se: { x: rect.left + rect.width, y: rect.top + rect.height },
s: { x: cx, y: rect.top + rect.height },
sw: { x: rect.left, y: rect.top + rect.height },
w: { x: rect.left, y: cy },
};
return map[h];
}
/**
* What a queued field will look like once applied. Without this a drawn box is empty, so the
* default text and a radio group's options are invisible until after saving.
*/
function PendingPreview({
field,
rect,
}: {
field: FormField;
rect: PixelRect;
}) {
// Blank entries are dropped server-side (sanitizeOptions), so counting them here would
// preview one more button than actually gets written.
const options = (field.options ?? [])
.map((o) => o?.trim() ?? "")
.filter((o) => o.length > 0);
if (field.type === "radio" && options.length > 0) {
// The drawn box is the whole group; radioOptionRects splits it exactly as the backend does.
const rows = radioOptionRects(
rect,
options.length,
field.optionGap,
field.optionSize,
);
return (
<>
{rows.map((row, i) => (
<div
key={`${options[i]}-${i}`}
style={{
position: "absolute",
left: 0,
top: row.top,
width: rect.width,
height: row.size,
display: "flex",
alignItems: "center",
gap: 6,
pointerEvents: "none",
}}
>
<span
style={{
width: row.size,
height: row.size,
borderRadius: "50%",
border: `1.5px solid ${FORM_COLORS.neutralBorder}`,
// Without this the border sits outside the width and each button renders 3px
// taller than its slot, so the stack overflows the box it was laid out in.
boxSizing: "border-box",
flex: "0 0 auto",
}}
/>
<span
style={{
fontSize: Math.max(8, Math.min(12, row.size * 0.9)),
color: FORM_COLORS.neutralChip,
whiteSpace: "nowrap",
overflow: "hidden",
}}
>
{options[i]}
</span>
</div>
))}
</>
);
}
const sample =
field.value ||
(field.type === "combobox" || field.type === "listbox"
? (options[0] ?? "")
: "");
if (!sample) return null;
return (
<span
style={{
position: "absolute",
inset: 0,
display: "flex",
alignItems: "center",
padding: "0 4px",
fontSize: Math.min(12, Math.max(9, rect.height - 8)),
color: FORM_COLORS.neutralChip,
whiteSpace: "nowrap",
overflow: "hidden",
pointerEvents: "none",
}}
>
{sample}
</span>
);
}
export function FormFieldEditOverlay({
documentId,
pageIndex,
pageWidth,
pageHeight,
fileId,
}: FormFieldEditOverlayProps) {
const {
mode,
state,
selectedFieldName,
setSelectedField,
modifiedFields,
stageModification,
deletedFieldNames,
forFileId,
dragActiveRef,
pendingFields,
updatePendingField,
previewing,
} = useFormFill();
const rootRef = useRef<HTMLDivElement>(null);
const interactionRef = useRef<Interaction | null>(null);
const [liveRect, setLiveRect] = useState<PixelRect | null>(null);
const [guides, setGuides] = useState<SnapGuide[]>([]);
const { scaleX, scaleY, pageHeightPts, pageWidthPts, rotation } =
usePageScale(documentId, pageIndex, pageWidth, pageHeight);
/** First-widget pixel rect for a field on this page, honouring staged geometry. */
const fieldRect = useCallback(
(field: FormField): PixelRect | null => {
const widget = field.widgets?.find((w) => w.pageIndex === pageIndex);
if (!widget) return null;
// A queued field stores PDF coordinates, not the top-left widget ones the extractor
// produces, so it needs the other transform or it renders far from where it was drawn.
if (pendingIdFrom(field.name)) {
return backendRectToPixels(
{
x: widget.x,
y: widget.y,
width: widget.width,
height: widget.height,
},
scaleX,
scaleY,
pageHeightPts,
);
}
const staged = modifiedFields[field.name];
if (
staged &&
staged.x != null &&
staged.y != null &&
staged.width != null &&
staged.height != null
) {
return backendRectToPixels(
{
x: staged.x,
y: staged.y,
width: staged.width,
height: staged.height,
},
scaleX,
scaleY,
pageHeightPts,
);
}
return widgetRectToPixels(widget, scaleX, scaleY);
},
[modifiedFields, pageIndex, scaleX, scaleY, pageHeightPts],
);
// A drawn-but-unapplied field is shown as a one-widget field so selection, dragging and the
// resize handles all work on it before it has a PDF name.
const pendingOnPage = useMemo(
() =>
pendingFields
.filter((f) => f.pageIndex === pageIndex)
.map((f): FormField => ({
name: pendingSelectionName(f.id),
label: f.label || f.name,
type: f.type,
value: f.defaultValue ?? "",
options: f.options ?? null,
displayOptions: null,
required: f.required ?? false,
readOnly: f.readOnly ?? false,
multiSelect: f.multiSelect ?? false,
multiline: f.multiline ?? false,
tooltip: f.tooltip ?? null,
widgets: [
{
pageIndex: f.pageIndex,
x: f.x,
y: f.y,
width: f.width,
height: f.height,
},
],
})),
[pendingFields, pageIndex],
);
const fieldsOnPage = useMemo(
() => [
...state.fields.filter((f) =>
f.widgets?.some((w) => w.pageIndex === pageIndex),
),
...pendingOnPage,
],
[state.fields, pageIndex, pendingOnPage],
);
/** Pending geometry lives in the queue; committed geometry is staged as a modification. */
const commitGeometry = useCallback(
(
fieldName: string,
pdf: { x: number; y: number; width: number; height: number },
) => {
const pendingId = pendingIdFrom(fieldName);
if (pendingId) {
updatePendingField(pendingId, { pageIndex, ...pdf });
return;
}
stageModification(fieldName, { pageIndex, ...pdf });
},
[pageIndex, stageModification, updatePendingField],
);
const selectedField = useMemo(
() => fieldsOnPage.find((f) => f.name === selectedFieldName) ?? null,
[fieldsOnPage, selectedFieldName],
);
const selectedSingleWidget =
!!selectedField && (selectedField.widgets?.length ?? 0) === 1;
const snapRects = useMemo<PixelRect[]>(() => {
const rects: PixelRect[] = [];
for (const f of fieldsOnPage) {
if (f.name === selectedFieldName) continue;
const r = fieldRect(f);
if (r) rects.push(r);
}
return rects;
}, [fieldsOnPage, selectedFieldName, fieldRect]);
// Precompute snap edges once (not on every pointermove).
const snapTargets = useMemo(() => collectSnapTargets(snapRects), [snapRects]);
const localPoint = useCallback(
(e: React.PointerEvent) => getLocalPoint(e, rootRef.current, rotation),
[rotation],
);
const beginInteraction = useCallback(
(
e: React.PointerEvent,
field: FormField,
kind: "move" | "resize",
handle?: HandleId,
) => {
const rect = fieldRect(field);
if (!rect) return;
e.stopPropagation();
e.preventDefault();
rootRef.current?.setPointerCapture(e.pointerId);
dragActiveRef.current = true;
const p = localPoint(e);
interactionRef.current = {
kind,
handle,
fieldName: field.name,
startX: p.x,
startY: p.y,
startRect: rect,
};
setLiveRect(rect);
},
[fieldRect, localPoint],
);
const handlePointerMove = useCallback(
(e: React.PointerEvent) => {
const it = interactionRef.current;
if (!it) return;
const p = localPoint(e);
const dx = p.x - it.startX;
const dy = p.y - it.startY;
const targets = snapTargets;
if (it.kind === "move") {
let rect: PixelRect = {
...it.startRect,
left: it.startRect.left + dx,
top: it.startRect.top + dy,
};
const snapped = snapMove(rect, targets, 6);
rect = { ...rect, left: snapped.left, top: snapped.top };
rect = clampPixelRect(rect, pageWidth, pageHeight);
setGuides(snapped.guides);
setLiveRect(rect);
} else if (it.kind === "resize" && it.handle) {
const edges = handleEdges(it.handle);
let { left, top, width, height } = it.startRect;
if (edges.left) {
left = it.startRect.left + dx;
width = it.startRect.width - dx;
}
if (edges.right) {
width = it.startRect.width + dx;
}
if (edges.top) {
top = it.startRect.top + dy;
height = it.startRect.height - dy;
}
if (edges.bottom) {
height = it.startRect.height + dy;
}
// Keep a positive minimum, anchoring the opposite edge.
if (width < MIN_PX) {
if (edges.left)
left = it.startRect.left + it.startRect.width - MIN_PX;
width = MIN_PX;
}
if (height < MIN_PX) {
if (edges.top) top = it.startRect.top + it.startRect.height - MIN_PX;
height = MIN_PX;
}
let rect: PixelRect = { left, top, width, height };
const snapped = snapResize(rect, edges, targets, 6);
rect = snapped.rect;
setGuides(snapped.guides);
setLiveRect(rect);
}
},
[localPoint, snapTargets, pageWidth, pageHeight],
);
// Scrolling this page out of view mid-gesture would otherwise strand the shared flag
// at true. Only the overlay that owns the drag may clear it, or an unrelated page
// scrolling away would release a live one.
useEffect(
() => () => {
if (interactionRef.current) dragActiveRef.current = false;
},
[dragActiveRef],
);
// A cancelled gesture (system swipe, focus loss) must not leave a half-applied drag behind.
const cancelInteraction = useCallback(
(e: React.PointerEvent) => {
interactionRef.current = null;
dragActiveRef.current = false;
setGuides([]);
setLiveRect(null);
try {
rootRef.current?.releasePointerCapture(e.pointerId);
} catch {
/* already released */
}
},
[setGuides, setLiveRect],
);
const endInteraction = useCallback(
(e: React.PointerEvent) => {
const it = interactionRef.current;
interactionRef.current = null;
dragActiveRef.current = false;
setGuides([]);
try {
rootRef.current?.releasePointerCapture(e.pointerId);
} catch {
/* already released */
}
const rect = liveRect;
setLiveRect(null);
if (!it || !rect) return;
// A plain click produces no movement; staging it would mark the field dirty.
const moved =
Math.abs(rect.left - it.startRect.left) > 0.5 ||
Math.abs(rect.top - it.startRect.top) > 0.5 ||
Math.abs(rect.width - it.startRect.width) > 0.5 ||
Math.abs(rect.height - it.startRect.height) > 0.5;
if (!moved) return;
const clamped = clampPixelRect(rect, pageWidth, pageHeight);
const pdf = roundPdfRect(
pixelsToBackendRect(clamped, scaleX, scaleY, pageHeightPts),
);
commitGeometry(it.fieldName, {
x: pdf.x,
y: pdf.y,
width: pdf.width,
height: pdf.height,
});
},
[
liveRect,
pageWidth,
pageHeight,
scaleX,
scaleY,
pageHeightPts,
pageIndex,
stageModification,
],
);
// Arrow keys nudge the selected field; Escape cancels an in-progress drag.
// Every mounted overlay listens, but only the selection's page acts (fieldRect is null elsewhere).
useEffect(() => {
if (mode !== "modify" || !selectedField) return;
const onKey = (e: KeyboardEvent) => {
// Read only: every page's overlay runs this, so writing the shared flag here
// would let an idle page clear the flag of the page actually being dragged.
const dragging = interactionRef.current != null;
if (!dragging && isTextEntryTarget(e.target)) return;
if (e.key === "Escape") {
// Only release the shared flag if THIS overlay was the one dragging.
if (dragging) dragActiveRef.current = false;
interactionRef.current = null;
setLiveRect(null);
setGuides([]);
if (!dragging) setSelectedField(null);
return;
}
if (dragging) return;
if (
!selectedField ||
!selectedSingleWidget ||
deletedFieldNames.includes(selectedField.name)
) {
return;
}
const step = e.shiftKey ? 10 : 1;
let dx = 0;
let dy = 0;
switch (e.key) {
case "ArrowLeft":
dx = -step;
break;
case "ArrowRight":
dx = step;
break;
case "ArrowUp":
dy = -step;
break;
case "ArrowDown":
dy = step;
break;
default:
return;
}
const base = fieldRect(selectedField);
if (!base) return; // selected field's widget isn't on this page
e.preventDefault();
const moved = clampPixelRect(
{ ...base, left: base.left + dx, top: base.top + dy },
pageWidth,
pageHeight,
);
const pdf = roundPdfRect(
pixelsToBackendRect(moved, scaleX, scaleY, pageHeightPts),
);
commitGeometry(selectedField.name, {
x: pdf.x,
y: pdf.y,
width: pdf.width,
height: pdf.height,
});
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [
mode,
setSelectedField,
selectedField,
selectedSingleWidget,
deletedFieldNames,
fieldRect,
pageWidth,
pageHeight,
scaleX,
scaleY,
pageHeightPts,
pageIndex,
stageModification,
]);
const fileMismatch =
fileId != null && forFileId != null && fileId !== forFileId;
const creating = mode === "create";
if ((mode !== "modify" && !creating) || fileMismatch || !pageWidthPts) {
return null;
}
// Preview drops the chrome but still draws queued fields, plainly: they have no widget in the
// document yet, so hiding them outright would show everything except what is being added.
if (previewing) {
return (
<div
data-testid={`form-preview-overlay-${pageIndex}`}
style={{
position: "absolute",
inset: 0,
pointerEvents: "none",
zIndex: 5,
}}
>
{pendingOnPage.map((field) => {
const rect = fieldRect(field);
if (!rect) return null;
return (
<div
key={field.name}
data-testid={`form-preview-field-${pendingIdFrom(field.name)}`}
style={{
position: "absolute",
left: rect.left,
top: rect.top,
width: rect.width,
height: rect.height,
// A radio group has no box of its own; its buttons are the whole visual, so a
// frame around them is chrome the finished PDF will not have.
border:
field.type === "radio"
? undefined
: `1px solid ${FORM_COLORS.neutralBorder}`,
borderRadius: 2,
boxSizing: "border-box",
}}
>
{/* A preview of an empty rectangle is not a preview; draw what the field holds. */}
<PendingPreview field={field} rect={rect} />
</div>
);
})}
</div>
);
}
const selectedRect = selectedField
? (liveRect ?? fieldRect(selectedField))
: null;
return (
<div
ref={rootRef}
data-testid={`form-edit-overlay-${pageIndex}`}
onPointerDown={(e) => {
// Clicking empty space deselects. preventDefault stops the underlying
// PDF text layer from starting a text selection.
e.preventDefault();
setSelectedField(null);
}}
onPointerMove={handlePointerMove}
onPointerUp={endInteraction}
onPointerCancel={cancelInteraction}
style={{
position: "absolute",
inset: 0,
// In create mode the bare page belongs to the drawing layer underneath, so only the
// boxes below take pointer events; the root would otherwise swallow every new drag.
pointerEvents: creating ? "none" : "auto",
// touch-action stays on the draggable boxes below, not here: claiming every
// gesture over the page would stop touch users panning the document at all.
userSelect: "none",
WebkitUserSelect: "none",
zIndex: creating ? 6 : 5,
}}
>
{fieldsOnPage.map((field) => {
const rect =
field.name === selectedFieldName && selectedRect
? selectedRect
: fieldRect(field);
if (!rect) return null;
const isSelected = field.name === selectedFieldName;
const isDeleted = deletedFieldNames.includes(field.name);
return (
<div
key={field.name}
data-testid={`form-edit-field-${pendingIdFrom(field.name) ?? field.name}`}
onPointerDown={(e) => {
if (isDeleted) return;
e.stopPropagation();
// Select and start moving in one gesture; a click without movement
// just selects, since endInteraction ignores a zero delta.
if (field.name !== selectedFieldName)
setSelectedField(field.name);
// Moving is safe for a group too: the backend applies the delta to every widget
// on the anchor page. Only resizing is still single-widget, so the handles stay off.
beginInteraction(e, field, "move");
}}
style={{
position: "absolute",
left: rect.left,
top: rect.top,
width: rect.width,
height: rect.height,
// Outline, not border: a border indents the content box, which pushed the field
// preview inside it off by the border width and made the buttons overhang.
outline: isDeleted
? `1.5px dashed ${FORM_COLORS.danger}`
: isSelected
? `2px solid ${FORM_COLORS.accent}`
: `1.5px solid ${FORM_COLORS.neutralBorder}`,
outlineOffset: 0,
background: isDeleted
? FORM_COLORS.dangerFill
: isSelected
? FORM_COLORS.accentFill
: FORM_COLORS.neutralFill,
borderRadius: 2,
boxSizing: "border-box",
// Claim the gesture only where a drag can actually start, so touch users can
// still pan the page over boxes that are not draggable.
pointerEvents: "auto",
touchAction: isDeleted ? "auto" : "none",
cursor: isDeleted ? "not-allowed" : "move",
textDecoration: isDeleted ? "line-through" : undefined,
}}
>
{pendingIdFrom(field.name) && (
<PendingPreview field={field} rect={rect} />
)}
<span
style={{
position: "absolute",
top: -16,
left: 0,
fontSize: 10,
lineHeight: "14px",
padding: "0 4px",
background: isDeleted
? FORM_COLORS.danger
: isSelected
? FORM_COLORS.accent
: FORM_COLORS.neutralChip,
color: "#fff",
borderRadius: 2,
whiteSpace: "nowrap",
opacity: isSelected || isDeleted ? 1 : 0.75,
// The chip floats above its field, over blank page; it must never eat a
// press meant for the drawing surface underneath.
pointerEvents: "none",
}}
>
{field.label || field.name}
</span>
</div>
);
})}
{/* Resize handles for the selected single-widget field */}
{selectedRect &&
selectedSingleWidget &&
!deletedFieldNames.includes(selectedFieldName ?? "") &&
HANDLES.map((h) => {
const pos = handlePosition(h.id, selectedRect);
return (
<div
key={h.id}
data-testid={`form-edit-handle-${h.id}`}
onPointerDown={(e) =>
selectedField &&
beginInteraction(e, selectedField, "resize", h.id)
}
style={{
position: "absolute",
left: pos.x - HANDLE_SIZE / 2,
top: pos.y - HANDLE_SIZE / 2,
width: HANDLE_SIZE,
height: HANDLE_SIZE,
background: "#fff",
border: `1.5px solid ${FORM_COLORS.accent}`,
borderRadius: 2,
touchAction: "none",
cursor: h.cursor,
boxSizing: "border-box",
// The root is transparent while creating, so handles claim events themselves.
pointerEvents: "auto",
}}
/>
);
})}
{/* Alignment guides */}
<SnapGuides guides={guides} />
</div>
);
}
export default FormFieldEditOverlay;
@@ -0,0 +1,473 @@
/** Left panel for "modify" mode; page highlighting lives in FormFieldEditOverlay. */
import React, {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import {
Text,
Group,
Alert,
Collapse,
Paper,
NumberInput,
ScrollArea,
Tooltip,
} from "@mantine/core";
import { Button } from "@app/ui/Button";
import { ActionIcon } from "@app/ui/ActionIcon";
import { useTranslation } from "react-i18next";
import DeleteOutlineIcon from "@mui/icons-material/DeleteOutlineRounded";
import RestoreIcon from "@mui/icons-material/Restore";
import MyLocationIcon from "@mui/icons-material/MyLocation";
import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
import WarningAmberIcon from "@mui/icons-material/WarningAmber";
import { useViewer } from "@app/contexts/ViewerContext";
import { useFormFill } from "@app/tools/formFill/FormFillContext";
import type {
FormField,
ModifyFieldDefinition,
} from "@app/tools/formFill/types";
import {
FIELD_TYPE_ICON,
FIELD_TYPE_COLOR,
} from "@app/tools/formFill/fieldMeta";
import {
FormFieldPropertyEditor,
type EditableFieldProps,
} from "@app/tools/formFill/FormFieldPropertyEditor";
import { isTextEntryTarget } from "@app/tools/formFill/usePageScale";
import { SkippedEditsAlert } from "@app/tools/formFill/SkippedEditsAlert";
import { useFormCommit } from "@app/tools/formFill/useFormCommit";
import styles from "@app/tools/formFill/FormFill.module.css";
interface FormFieldModifyPanelProps {
currentFile: File | Blob | null;
onApplied?: (blob: Blob) => void;
}
/** Current backend (lower-left origin) coords for a field's first widget. */
function currentCoords(field: FormField, staged?: ModifyFieldDefinition) {
const w = field.widgets?.[0];
if (!w) return null;
if (
staged &&
staged.x != null &&
staged.y != null &&
staged.width != null &&
staged.height != null
) {
return {
x: staged.x,
y: staged.y,
width: staged.width,
height: staged.height,
};
}
const cropH = w.cropBoxHeight ?? 0;
return {
x: w.x,
y: cropH ? cropH - w.y - w.height : w.y,
width: w.width,
height: w.height,
};
}
export function FormFieldModifyPanel({
currentFile,
onApplied,
}: FormFieldModifyPanelProps) {
const { t } = useTranslation();
const {
state,
selectedFieldName,
setSelectedField,
modifiedFields,
stageModification,
deletedFieldNames,
toggleFieldDeleted,
commitModifications,
hasUncommittedChanges,
dragActiveRef,
} = useFormFill();
const { committing, error, commit } = useFormCommit(onApplied);
const selectedRowRef = useRef<HTMLDivElement>(null);
// A long form is easier to scan a page at a time; pages start open so nothing hides itself.
const [collapsedPages, setCollapsedPages] = useState<Set<number>>(new Set());
const togglePage = (pageIdx: number) =>
setCollapsedPages((prev) => {
const next = new Set(prev);
if (next.has(pageIdx)) next.delete(pageIdx);
else next.add(pageIdx);
return next;
});
const { scrollActions } = useViewer();
const { sortedPages, fieldsByPage } = useMemo(() => {
const byPage = new Map<number, FormField[]>();
for (const field of state.fields) {
const pageIndex = field.widgets?.[0]?.pageIndex ?? 0;
if (!byPage.has(pageIndex)) byPage.set(pageIndex, []);
byPage.get(pageIndex)!.push(field);
}
return {
sortedPages: Array.from(byPage.keys()).sort((a, b) => a - b),
fieldsByPage: byPage,
};
}, [state.fields]);
// Auto-scroll the list to the selected field (e.g. selected via the overlay).
useEffect(() => {
if (selectedFieldName && selectedRowRef.current) {
selectedRowRef.current.scrollIntoView({
behavior: "smooth",
block: "nearest",
});
}
}, [selectedFieldName]);
// Escape clears the selection. Lives here (single instance) so it still works
// when the selected field's page has scrolled out of view and unmounted.
useEffect(() => {
if (!selectedFieldName) return;
const onKey = (e: KeyboardEvent) => {
// A drag owns Escape (the overlay cancels it), and an input owns its own.
if (dragActiveRef.current || isTextEntryTarget(e.target)) return;
if (e.key === "Escape") setSelectedField(null);
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [selectedFieldName, setSelectedField, dragActiveRef]);
const changeCount =
Object.keys(modifiedFields).length + deletedFieldNames.length;
const handleCommit = useCallback(() => {
if (!currentFile || !hasUncommittedChanges) return;
commit(
() => commitModifications(currentFile),
"formFill.modify.failed",
"Failed to save changes",
);
}, [currentFile, hasUncommittedChanges, commitModifications, commit]);
const editorValue = useCallback(
(field: FormField): EditableFieldProps => {
const staged = modifiedFields[field.name];
return {
name: staged?.name ?? field.name,
label: staged?.label ?? field.label,
type: staged?.type ?? field.type,
defaultValue: staged?.defaultValue ?? field.value,
tooltip: staged?.tooltip ?? field.tooltip ?? "",
fontSize: staged?.fontSize ?? field.widgets?.[0]?.fontSize,
required: staged?.required ?? field.required,
readOnly: staged?.readOnly ?? field.readOnly,
multiline: staged?.multiline ?? field.multiline,
multiSelect: staged?.multiSelect ?? field.multiSelect,
options: staged?.options ?? field.options ?? [],
// Test for the staged KEY, not its value, or an explicit clear reads as
// "unchanged" and the input snaps back.
maxLength:
staged && "maxLength" in staged
? staged.maxLength
: (field.maxLength ?? undefined),
buttonAction:
staged && "buttonAction" in staged
? staged.buttonAction
: (field.buttonActionSpec ?? undefined),
};
},
[modifiedFields],
);
return (
<div className={styles.root}>
<div className={styles.header}>
<Text size="xs" c="dimmed">
{t(
"formFill.modify.hint",
"Select a field to edit its properties, drag it on the page, or delete it.",
)}
</Text>
{error && (
<Alert
icon={<WarningAmberIcon sx={{ fontSize: 16 }} />}
color="red"
variant="light"
p="xs"
radius="sm"
>
<Text size="xs">{error}</Text>
</Alert>
)}
<SkippedEditsAlert />
{state.fields.length === 0 && !state.loading && (
<Text size="xs" c="dimmed" ta="center" py="md">
{t("formFill.modify.empty", "This PDF has no form fields yet.")}
</Text>
)}
</div>
<ScrollArea className={styles.fieldList}>
<div className={styles.fieldListInner}>
{sortedPages.map((pageIdx, i) => (
<React.Fragment key={pageIdx}>
<div
className={styles.pageDivider}
style={{
...(i === 0 ? { marginTop: 0 } : {}),
cursor: "pointer",
display: "flex",
alignItems: "center",
gap: 4,
}}
onClick={() => togglePage(pageIdx)}
data-testid={`form-page-header-${pageIdx}`}
>
<ExpandMoreIcon
sx={{
fontSize: 16,
transform: collapsedPages.has(pageIdx)
? "rotate(-90deg)"
: undefined,
transition: "transform 120ms",
}}
/>
<Text className={styles.pageDividerLabel}>
{t("formFill.page", "Page")} {pageIdx + 1}
</Text>
<Text size="xs" c="dimmed">
{fieldsByPage.get(pageIdx)!.length}
</Text>
<Tooltip
label={t("formFill.goToPage", "Go to this page")}
withArrow
>
<ActionIcon
size="sm"
variant="tertiary"
aria-label={t("formFill.goToPage", "Go to this page")}
data-testid={`form-page-goto-${pageIdx}`}
onClick={(e) => {
e.stopPropagation();
scrollActions.scrollToPage(pageIdx + 1);
}}
>
<MyLocationIcon sx={{ fontSize: 15 }} />
</ActionIcon>
</Tooltip>
</div>
<Collapse in={!collapsedPages.has(pageIdx)}>
{fieldsByPage.get(pageIdx)!.map((field) => {
const selected = selectedFieldName === field.name;
const deleted = deletedFieldNames.includes(field.name);
const coords = currentCoords(
field,
modifiedFields[field.name],
);
// Resizing a group here would only resize its first widget.
const multiWidget = (field.widgets?.length ?? 0) > 1;
return (
<Paper
key={field.name}
ref={selected ? selectedRowRef : undefined}
withBorder
p={6}
radius="sm"
style={{
cursor: "pointer",
borderColor: selected
? "var(--mantine-color-blue-5)"
: undefined,
opacity: deleted ? 0.55 : 1,
}}
onClick={() =>
setSelectedField(selected ? null : field.name)
}
data-testid={`form-modify-row-${field.name}`}
>
<Group gap={6} wrap="nowrap" justify="space-between">
<Group gap={6} wrap="nowrap" style={{ minWidth: 0 }}>
<span
style={{
color: `var(--mantine-color-${FIELD_TYPE_COLOR[field.type]}-6)`,
display: "flex",
}}
>
{FIELD_TYPE_ICON[field.type]}
</span>
<Text
size="xs"
truncate
td={deleted ? "line-through" : undefined}
>
{field.label || field.name}
</Text>
</Group>
<Tooltip
label={
deleted
? t("formFill.modify.restore", "Restore")
: t("formFill.modify.delete", "Delete")
}
withArrow
>
<ActionIcon
size="sm"
variant="tertiary"
accent={deleted ? "default" : "danger"}
aria-label={
deleted
? t("formFill.modify.restore", "Restore")
: t("formFill.modify.delete", "Delete")
}
onClick={(e) => {
e.stopPropagation();
toggleFieldDeleted(field.name);
}}
data-testid={`form-modify-delete-${field.name}`}
>
{deleted ? (
<RestoreIcon sx={{ fontSize: 16 }} />
) : (
<DeleteOutlineIcon sx={{ fontSize: 16 }} />
)}
</ActionIcon>
</Tooltip>
</Group>
{/* Children are built only while open: Collapse keeps them mounted, so
every unopened row would otherwise carry a full property editor. */}
<Collapse in={selected && !deleted}>
{selected && !deleted && (
<div
style={{ marginTop: 8 }}
onClick={(e) => e.stopPropagation()}
>
<FormFieldPropertyEditor
value={editorValue(field)}
onChange={(patch) =>
stageModification(
field.name,
patch as Partial<ModifyFieldDefinition>,
)
}
showName
allowTypeChange
/>
{coords && (
<Group gap={6} mt="xs" grow>
<NumberInput
size="xs"
label="X"
value={Math.round(coords.x)}
onChange={(v) =>
typeof v === "number" &&
stageModification(field.name, {
pageIndex: pageIdx,
x: v,
y: coords.y,
width: coords.width,
height: coords.height,
})
}
/>
<NumberInput
size="xs"
label="Y"
value={Math.round(coords.y)}
onChange={(v) =>
typeof v === "number" &&
stageModification(field.name, {
pageIndex: pageIdx,
x: coords.x,
y: v,
width: coords.width,
height: coords.height,
})
}
/>
<NumberInput
size="xs"
label="W"
value={Math.round(coords.width)}
min={1}
disabled={multiWidget}
description={
multiWidget
? t(
"formFill.modify.groupSizeHint",
"Use Option size",
)
: undefined
}
onChange={(v) =>
typeof v === "number" &&
stageModification(field.name, {
pageIndex: pageIdx,
x: coords.x,
y: coords.y,
width: v,
height: coords.height,
})
}
/>
<NumberInput
size="xs"
label="H"
value={Math.round(coords.height)}
min={1}
disabled={multiWidget}
onChange={(v) =>
typeof v === "number" &&
stageModification(field.name, {
pageIndex: pageIdx,
x: coords.x,
y: coords.y,
width: coords.width,
height: v,
})
}
/>
</Group>
)}
</div>
)}
</Collapse>
</Paper>
);
})}
</Collapse>
</React.Fragment>
))}
</div>
</ScrollArea>
<div className={styles.footer}>
<Button
size="sm"
onClick={handleCommit}
loading={committing}
disabled={!currentFile || !hasUncommittedChanges}
data-testid="form-modify-commit"
>
{t("formFill.modify.commit", "Save {{count}} change(s)", {
count: changeCount,
})}
</Button>
</div>
</div>
);
}
export default FormFieldModifyPanel;
@@ -175,6 +175,8 @@ interface WidgetInputProps {
onFocus: (fieldName: string) => void;
onChange: (fieldName: string, value: string) => void;
onButtonClick: (field: FormField, action?: ButtonAction | null) => void;
/** True while the structural editor owns the page, so widgets show but do not respond. */
editing: boolean;
}
/**
@@ -192,6 +194,7 @@ function WidgetInputInner({
onFocus,
onChange,
onButtonClick,
editing,
}: WidgetInputProps) {
// Per-field value subscription — only this widget re-renders when its value changes
const value = useFieldValue(field.name);
@@ -231,7 +234,7 @@ function WidgetInputInner({
? `0 0 0 2px ${error ? "rgba(244, 67, 54, 0.25)" : "rgba(33, 150, 243, 0.25)"}`
: "none",
cursor: field.readOnly ? "default" : "text",
pointerEvents: "auto",
pointerEvents: editing ? "none" : "auto",
display: "flex",
alignItems: field.multiline ? "stretch" : "center",
};
@@ -365,8 +368,10 @@ function WidgetInputInner({
style={{
width: "85%",
height: "85%",
maxWidth: height * 0.9, // Prevent it from getting too wide in rectangular boxes
maxHeight: width * 0.9,
// Keep the tick square in a rectangular box; these were transposed, so a wide
// short box clamped the wrong axis and let the mark spill out.
maxWidth: width * 0.9,
maxHeight: height * 0.9,
fontSize: `${Math.max(10, height * 0.75)}px`,
lineHeight: 1,
color: isChecked ? "#2196F3" : "transparent",
@@ -457,6 +462,8 @@ function WidgetInputInner({
if (widgetIndex < 0) return null;
const widgetIndexStr = String(widgetIndex);
const isSelected = value === widgetIndexStr;
const optionLabel =
field.options?.[widgetIndex] ?? widget.exportValue ?? "";
return (
<div
{...commonProps}
@@ -491,14 +498,35 @@ function WidgetInputInner({
height: Math.min(width, height) * 0.8,
borderRadius: "50%",
border: `1.5px solid ${isSelected ? "#2196F3" : isActive ? "#2196F3" : "#999"}`,
// The border must sit inside the width, or the dot outgrows its widget.
boxSizing: "border-box",
background: isSelected ? "#2196F3" : "transparent",
display: "flex",
alignItems: "center",
justifyContent: "center",
boxShadow: isSelected ? "inset 0 0 0 2px white" : "none",
transition: "background 0.15s, border-color 0.15s",
flex: "0 0 auto",
}}
/>
{optionLabel && (
// A PDF radio widget carries no caption of its own, so without this the options are
// indistinguishable circles. Drawn outside the rect so the hit area stays the widget.
<span
style={{
position: "absolute",
left: "100%",
marginLeft: 4,
whiteSpace: "nowrap",
fontSize: Math.max(9, Math.min(12, height * 0.9)),
lineHeight: `${height}px`,
color: "#333",
pointerEvents: "none",
}}
>
{optionLabel}
</span>
)}
</div>
);
}
@@ -605,8 +633,17 @@ export function FormFieldOverlay({
pageHeight,
fileId,
}: FormFieldOverlayProps) {
const { setValue, setActiveField, fieldsByPage, state, forFileId } =
useFormFill();
const {
setValue,
setActiveField,
effectiveFieldsByPage,
state,
forFileId,
mode,
} = useFormFill();
// While the editor owns the page the outlines and handles do the interacting; these widgets
// are here to show what the field looks like, not to be typed into.
const editing = mode !== "fill";
const { activeFieldName, validationErrors } = state;
const { printActions, scrollActions, exportActions } = useViewer();
@@ -647,8 +684,8 @@ export function FormFieldOverlay({
}, [documentState, pageIndex, pageWidth, pageHeight]);
const pageFields = useMemo(
() => fieldsByPage.get(pageIndex) || [],
[fieldsByPage, pageIndex],
() => effectiveFieldsByPage.get(pageIndex) || [],
[effectiveFieldsByPage, pageIndex],
);
const handleFocus = useCallback(
@@ -779,6 +816,7 @@ export function FormFieldOverlay({
onFocus={handleFocus}
onChange={handleChange}
onButtonClick={handleButtonClick}
editing={editing}
/>
);
}),
@@ -0,0 +1,556 @@
/**
* Property form shared by the create and modify panels; reports edits as partial patches.
*/
import React from "react";
import {
Stack,
TextInput,
NumberInput,
Select,
Switch,
Group,
Text,
Alert,
Tooltip,
} from "@mantine/core";
import { Button } from "@app/ui/Button";
import { ActionIcon } from "@app/ui/ActionIcon";
import { useTranslation } from "react-i18next";
import AddIcon from "@mui/icons-material/Add";
import DeleteOutlineIcon from "@mui/icons-material/DeleteOutlineRounded";
import InfoOutlinedIcon from "@mui/icons-material/InfoOutlined";
/** A switch's help has to wrap the control: Mantine does not surface a tooltip from its label. */
function SwitchWithHelp({
help,
children,
}: {
help: string;
children: React.ReactElement;
}) {
return (
<Tooltip
label={help}
multiline
w={250}
withArrow
openDelay={250}
position="top-start"
>
{children}
</Tooltip>
);
}
/** Labels carry their own explanation on hover, so the panel stays uncluttered. */
function LabelWithHelp({ text, help }: { text: string; help: string }) {
return (
<Tooltip
label={help}
multiline
w={250}
withArrow
openDelay={250}
position="top-start"
>
<span style={{ cursor: "help" }}>{text}</span>
</Tooltip>
);
}
export interface EditableFieldProps {
name?: string;
label?: string;
type: string;
defaultValue?: string;
tooltip?: string;
fontSize?: number;
optionGap?: number;
optionSize?: number;
required?: boolean;
readOnly?: boolean;
multiline?: boolean;
multiSelect?: boolean;
options?: string[];
maxLength?: number;
buttonAction?: string;
}
interface FormFieldPropertyEditorProps {
value: EditableFieldProps;
onChange: (patch: Partial<EditableFieldProps>) => void;
/** Show the field-name input (create mode). */
showName?: boolean;
/** Allow changing the field type (modify mode). */
allowTypeChange?: boolean;
}
const TYPE_LABEL: Record<string, string> = {
text: "Text",
checkbox: "Checkbox",
combobox: "Dropdown",
listbox: "List box",
radio: "Radio group",
button: "Button",
signature: "Signature",
};
// Type-change is only safe between the "simple" single-widget types; retyping
// into radio/button/signature needs dedicated creation, so it's create-only.
const TYPE_CHANGE_OPTIONS = ["text", "checkbox", "combobox", "listbox"];
/** Split a stored buttonAction string into a kind + optional url. */
function parseButtonAction(action: string | undefined): {
kind: string;
url: string;
} {
if (!action) return { kind: "none", url: "" };
if (action === "reset") return { kind: "reset", url: "" };
if (action === "print") return { kind: "print", url: "" };
if (action.startsWith("uri:")) return { kind: "uri", url: action.slice(4) };
if (action.startsWith("submit:"))
return { kind: "submit", url: action.slice(7) };
return { kind: "none", url: "" };
}
function buildButtonAction(kind: string, url: string): string {
switch (kind) {
case "reset":
return "reset";
case "print":
return "print";
case "uri":
return `uri:${url}`;
case "submit":
return `submit:${url}`;
default:
return "";
}
}
export function FormFieldPropertyEditor({
value,
onChange,
showName = true,
allowTypeChange = false,
}: FormFieldPropertyEditorProps) {
const { t } = useTranslation();
const hasOptions =
value.type === "combobox" ||
value.type === "listbox" ||
value.type === "radio";
const isVariableText =
value.type === "text" ||
value.type === "combobox" ||
value.type === "listbox";
const isText = value.type === "text";
const isButton = value.type === "button";
const isSignature = value.type === "signature";
const isFillable = !isButton && !isSignature;
const canRetype = TYPE_CHANGE_OPTIONS.includes(value.type);
const updateOption = (index: number, next: string) => {
const options = [...(value.options ?? [])];
options[index] = next;
onChange({ options });
};
const addOption = () => onChange({ options: [...(value.options ?? []), ""] });
const removeOption = (index: number) =>
onChange({ options: (value.options ?? []).filter((_, i) => i !== index) });
const action = parseButtonAction(value.buttonAction);
return (
<Stack gap="xs">
{isSignature && (
<Alert
color="blue"
variant="light"
p="xs"
radius="sm"
icon={<InfoOutlinedIcon sx={{ fontSize: 16 }} />}
>
<Text size="xs">
{t(
"formFill.editor.signatureNote",
"Placeholder only - you don't sign here. It marks where a signature belongs so a PDF signer (Adobe Acrobat, a signing service, etc.) places the signature in this spot when the document is signed.",
)}
</Text>
</Alert>
)}
{showName && (
<TextInput
size="xs"
label={
<LabelWithHelp
text={t("formFill.editor.name", "Field name")}
help={t(
"formFill.editor.nameHelp",
"The field's internal name. Used when exporting data or filling the form from another system, so keep it unique and free of spaces.",
)}
/>
}
value={value.name ?? ""}
onChange={(e) => onChange({ name: e.currentTarget.value })}
/>
)}
<TextInput
size="xs"
label={
<LabelWithHelp
text={
isButton
? t("formFill.editor.caption", "Button caption")
: t("formFill.editor.label", "Label")
}
help={
isButton
? t(
"formFill.editor.captionHelp",
"The text printed on the button face.",
)
: t(
"formFill.editor.labelHelp",
"The wording shown to whoever fills the form. Leave it blank to fall back to the field name.",
)
}
/>
}
value={value.label ?? ""}
onChange={(e) => onChange({ label: e.currentTarget.value })}
/>
{allowTypeChange && (
<Select
size="xs"
label={
<LabelWithHelp
text={t("formFill.editor.type", "Type")}
help={t(
"formFill.editor.typeHelp",
"What kind of field this is. Changing it rebuilds the field, so its current value is not carried over.",
)}
/>
}
value={canRetype ? value.type : null}
data={TYPE_CHANGE_OPTIONS.map((tp) => ({
value: tp,
label: TYPE_LABEL[tp],
}))}
disabled={!canRetype}
placeholder={canRetype ? undefined : TYPE_LABEL[value.type]}
onChange={(v) => v && onChange({ type: v })}
comboboxProps={{ withinPortal: true }}
/>
)}
{hasOptions && (
<Stack gap={4}>
<Text size="xs" fw={600}>
<LabelWithHelp
text={t("formFill.editor.options", "Options")}
help={t(
"formFill.editor.optionsHelp",
"The choices offered in the list. Each one is stored as typed, so keep them short and distinct.",
)}
/>
</Text>
{(value.options ?? []).length === 0 && (
<Text size="xs" c="dimmed">
{t("formFill.editor.optionsEmpty", "Add at least one option.")}
</Text>
)}
{(value.options ?? []).map((opt, i) => (
<Group key={i} gap={4} wrap="nowrap">
<TextInput
size="xs"
style={{ flex: 1 }}
value={opt}
placeholder={t(
"formFill.editor.optionPlaceholder",
"Option {{n}}",
{
n: i + 1,
},
)}
onChange={(e) => updateOption(i, e.currentTarget.value)}
/>
<ActionIcon
size="sm"
variant="tertiary"
accent="danger"
aria-label={t("formFill.editor.removeOption", "Remove option")}
onClick={() => removeOption(i)}
>
<DeleteOutlineIcon sx={{ fontSize: 16 }} />
</ActionIcon>
</Group>
))}
<Button
size="sm"
variant="tertiary"
leftSection={<AddIcon sx={{ fontSize: 14 }} />}
onClick={addOption}
>
{t("formFill.editor.addOption", "Add option")}
</Button>
{value.type === "radio" && (
// The group fits the drawn box by default; these are for tightening it by hand.
<Group grow gap={6} align="flex-start">
<NumberInput
size="xs"
label={
<LabelWithHelp
text={t("formFill.editor.optionSize", "Option size")}
help={t(
"formFill.editor.optionSizeHelp",
"Width and height of each button, in points. Leave blank to fit them to the box you drew.",
)}
/>
}
value={value.optionSize ?? ""}
min={1}
max={144}
data-testid="form-option-size"
onChange={(v) =>
onChange({
optionSize: typeof v === "number" ? v : undefined,
})
}
/>
<NumberInput
size="xs"
label={
<LabelWithHelp
text={t("formFill.editor.optionGap", "Option spacing")}
help={t(
"formFill.editor.optionGapHelp",
"Gap between buttons, in points. Leave blank to spread them evenly down the box.",
)}
/>
}
value={value.optionGap ?? ""}
min={0}
max={144}
data-testid="form-option-gap"
onChange={(v) =>
onChange({ optionGap: typeof v === "number" ? v : undefined })
}
/>
</Group>
)}
</Stack>
)}
{isFillable && (
<TextInput
size="xs"
label={
<LabelWithHelp
text={t("formFill.editor.defaultValue", "Default value")}
help={t(
"formFill.editor.defaultValueHelp",
"What the field contains before anyone fills it in. Leave blank for an empty field.",
)}
/>
}
value={value.defaultValue ?? ""}
onChange={(e) => onChange({ defaultValue: e.currentTarget.value })}
/>
)}
<TextInput
size="xs"
label={
<LabelWithHelp
text={t("formFill.editor.tooltip", "Tooltip")}
help={t(
"formFill.editor.tooltipHelp",
"The hint shown when someone hovers the field in a PDF reader.",
)}
/>
}
value={value.tooltip ?? ""}
onChange={(e) => onChange({ tooltip: e.currentTarget.value })}
/>
{isButton && (
<>
<Select
size="xs"
label={
<LabelWithHelp
text={t("formFill.editor.action", "Button action")}
help={t(
"formFill.editor.actionHelp",
"What the button does when clicked.",
)}
/>
}
value={action.kind}
data={[
{ value: "none", label: t("formFill.editor.actionNone", "None") },
{
value: "reset",
label: t("formFill.editor.actionReset", "Reset form"),
},
{
value: "print",
label: t("formFill.editor.actionPrint", "Print"),
},
{
value: "uri",
label: t("formFill.editor.actionUri", "Open URL"),
},
{
value: "submit",
label: t("formFill.editor.actionSubmit", "Submit to URL"),
},
]}
onChange={(v) =>
onChange({
buttonAction: buildButtonAction(v ?? "none", action.url),
})
}
comboboxProps={{ withinPortal: true }}
/>
{(action.kind === "uri" || action.kind === "submit") && (
<TextInput
size="xs"
label={
<LabelWithHelp
text={t("formFill.editor.actionUrl", "URL")}
help={t(
"formFill.editor.actionUrlHelp",
"The address the button opens or submits to.",
)}
/>
}
value={action.url}
onChange={(e) =>
onChange({
buttonAction: buildButtonAction(
action.kind,
e.currentTarget.value,
),
})
}
/>
)}
</>
)}
{isVariableText && (
<NumberInput
size="xs"
label={
<LabelWithHelp
text={t("formFill.editor.fontSize", "Font size")}
help={t(
"formFill.editor.fontSizeHelp",
"Text size inside the field. Leave blank to let the reader size it to fit.",
)}
/>
}
value={value.fontSize ?? ""}
min={1}
max={144}
onChange={(v) =>
onChange({ fontSize: typeof v === "number" ? v : undefined })
}
/>
)}
{isText && (
<>
<SwitchWithHelp
help={t(
"formFill.editor.multilineHelp",
"Allows more than one line of text and wraps at the field's edge.",
)}
>
<Switch
size="xs"
label={t("formFill.editor.multiline", "Multi-line")}
checked={!!value.multiline}
onChange={(e) => onChange({ multiline: e.currentTarget.checked })}
/>
</SwitchWithHelp>
<NumberInput
size="xs"
label={
<LabelWithHelp
text={t("formFill.editor.maxLength", "Max length (comb)")}
help={t(
"formFill.editor.maxLengthHelp",
"Caps how many characters fit, drawn as evenly spaced boxes.",
)}
/>
}
value={value.maxLength ? value.maxLength : ""}
min={0}
max={500}
onChange={(v) =>
// 0 is the clear signal; undefined would read as "unchanged" server-side.
onChange({ maxLength: typeof v === "number" ? v : 0 })
}
/>
</>
)}
{value.type === "listbox" && (
<SwitchWithHelp
help={t(
"formFill.editor.multiSelectHelp",
"Lets more than one option be chosen at once.",
)}
>
<Switch
size="xs"
label={t("formFill.editor.multiSelect", "Allow multiple selection")}
checked={!!value.multiSelect}
onChange={(e) => onChange({ multiSelect: e.currentTarget.checked })}
/>
</SwitchWithHelp>
)}
{isFillable && (
<SwitchWithHelp
help={t(
"formFill.editor.requiredHelp",
"The form cannot be submitted until this field is filled in.",
)}
>
<Switch
size="xs"
label={t("formFill.editor.required", "Required")}
checked={!!value.required}
onChange={(e) => onChange({ required: e.currentTarget.checked })}
/>
</SwitchWithHelp>
)}
{/* Read-only belongs to an existing field; one being drawn has nothing to protect yet. */}
{isFillable && allowTypeChange && (
<SwitchWithHelp
help={t(
"formFill.editor.readOnlyHelp",
"Shows a value but stops anyone editing it.",
)}
>
<Switch
size="xs"
label={t("formFill.editor.readOnly", "Read-only")}
checked={!!value.readOnly}
onChange={(e) => onChange({ readOnly: e.currentTarget.checked })}
/>
</SwitchWithHelp>
)}
</Stack>
);
}
export default FormFieldPropertyEditor;
@@ -1,57 +1,77 @@
/* No height:100% / overflow here: the tool lives inside ToolPanel's ScrollArea, and
either one would make the sticky chrome below pin to a box that never scrolls. */
.root {
height: 100%;
display: flex;
flex-direction: column;
overflow: hidden;
background: transparent;
}
/* Sticky, not flex-pinned: the tool renders inside ToolPanel's ScrollArea, whose
display:table viewport makes height:100% resolve to content height. */
.modeTabs {
flex-shrink: 0;
position: sticky;
top: 0;
z-index: 2;
border-bottom: 1px solid var(--c-border, var(--mantine-color-default-border));
background: transparent;
background: var(--c-bg-raised, var(--mantine-color-body));
padding: 0.25rem;
}
.segmentedRoot {
background: rgba(0, 0, 0, 0.05) !important;
border-radius: var(--radius-sm) !important;
}
:global([data-mantine-color-scheme="dark"]) .segmentedRoot {
background: rgba(255, 255, 255, 0.05) !important;
}
.segmentedIndicator {
background-color: var(--mantine-color-blue-filled) !important;
box-shadow: var(--shadow-sm) !important;
border-radius: var(--radius-sm) !important;
}
/* The shared SegmentedControl sizes its label box for one line and hides the overflow,
so the stacked icon-over-text tabs need that box opened up (see .modeTabs overrides). */
.segmentedLabel {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0;
gap: 0.125rem;
padding: 0.125rem 0;
min-height: 2.25rem;
min-width: 0;
width: 100%;
}
/* Equal-width segments that can shrink, so a long translation truncates inside its
own tab instead of pushing the others off the edge. */
/* Mantine's size="xs" writes height:26px as an INLINE style, which would clip the
taller stacked tabs; only !important can outrank it. */
.modeTabs :global(.sui-seg.mantine-SegmentedControl-root) {
height: auto !important;
}
.modeTabs :global(.mantine-SegmentedControl-control) {
flex: 1 1 0;
min-width: 0;
min-height: 2.75rem;
}
.modeTabs :global(.mantine-SegmentedControl-label) {
min-width: 0;
padding-left: 0.25rem;
padding-right: 0.25rem;
}
/* Colour inherited: the shared SegmentedControl flips the active segment's text,
so setting it here would leave the label unreadable on the accent fill. */
.segmentedInnerLabel {
font-size: 0.625rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.02em;
color: var(--c-text-subtle);
transition: color 0.15s ease;
color: inherit;
line-height: 1;
/* Longer translations must ellipsise inside their segment; the control clips at
18px so they cannot wrap, and unconstrained they push the other tabs off. */
min-width: 0;
align-self: stretch;
text-align: center;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.modeTabIcon {
font-size: 1rem !important;
margin-bottom: 0.125rem;
opacity: 0.8;
flex-shrink: 0;
}
.header {
@@ -84,6 +104,20 @@
gap: 0.5rem;
}
/* Pinned footer. Sticky for the same reason as .modeTabs, and opaque so the list
scrolls underneath it rather than showing through. */
.footer {
position: sticky;
bottom: 0;
z-index: 2;
padding: 0.75rem 1rem;
background: var(--c-bg-raised, var(--mantine-color-body));
border-top: 1px solid var(--c-border, var(--mantine-color-default-border));
display: flex;
flex-direction: column;
gap: 0.625rem;
}
.primaryActions {
display: flex;
gap: 0.5rem;
@@ -108,7 +142,6 @@
.fieldList {
flex: 1;
overflow: hidden;
background: transparent;
}
@@ -1,13 +1,5 @@
/**
* FormFill: The tool component that renders in the left ToolPanel
* when the "Fill Form" tool is selected.
*
* Redesigned with:
* - Mode tabs for future extensibility (Fill / Make / Batch / Modify)
* - Clean visual hierarchy with proper spacing
* - Shared FieldInput component (eliminates duplication)
* - CSS module for theme-consistent styling
* - Status bar at bottom for contextual info
* Form Editor tool panel: fill values, create new fields, or modify existing ones.
*/
import React, {
useEffect,
@@ -25,6 +17,8 @@ import {
Progress,
Tooltip,
} from "@mantine/core";
import { UnsavedChangesDialog } from "@app/components/shared/UnsavedChangesDialog";
import { SegmentedControl } from "@app/ui/SegmentedControl";
import { Button } from "@app/ui/Button";
import { ActionIcon } from "@app/ui/ActionIcon";
import { useTranslation } from "react-i18next";
@@ -34,6 +28,8 @@ import {
useAllFormValues,
} from "@app/tools/formFill/FormFillContext";
import { useNavigation } from "@app/contexts/NavigationContext";
import { useFieldShortcuts } from "@app/tools/formFill/useFieldShortcuts";
import { serverMessage } from "@app/tools/formFill/useFormCommit";
import { useViewer } from "@app/contexts/ViewerContext";
import { useAllFiles, useFileState } from "@app/contexts/FileContext";
import { Skeleton } from "@mantine/core";
@@ -50,7 +46,6 @@ import RefreshIcon from "@mui/icons-material/Refresh";
import WarningAmberIcon from "@mui/icons-material/WarningAmber";
import EditNoteIcon from "@mui/icons-material/EditNote";
import PostAddIcon from "@mui/icons-material/PostAdd";
import FileCopyIcon from "@mui/icons-material/FileCopy";
import BuildCircleIcon from "@mui/icons-material/BuildCircle";
import DescriptionIcon from "@mui/icons-material/Description";
import FileDownloadIcon from "@mui/icons-material/FileDownload";
@@ -58,72 +53,34 @@ import {
extractFormFieldsCsv,
extractFormFieldsXlsx,
} from "@app/tools/formFill/formApi";
import type { FormMode } from "@app/tools/formFill/types";
import { FormFieldCreatePanel } from "@app/tools/formFill/FormFieldCreatePanel";
import { FormFieldModifyPanel } from "@app/tools/formFill/FormFieldModifyPanel";
import { dispatchFormApply } from "@app/tools/formFill/formFillEvents";
import styles from "@app/tools/formFill/FormFill.module.css";
// ---------------------------------------------------------------------------
// Mode tabs — extensible for future form tools
// ---------------------------------------------------------------------------
type FormMode = "fill" | "make" | "batch" | "modify";
interface ModeTabDef {
id: FormMode;
label: string;
icon: React.ReactNode;
ready: boolean;
}
const _MODE_TABS: ModeTabDef[] = [
{
id: "fill",
label: "Fill",
icon: <EditNoteIcon className={styles.modeTabIcon} />,
ready: true,
},
{
id: "make",
label: "Create",
icon: <PostAddIcon className={styles.modeTabIcon} />,
ready: false,
},
{
id: "batch",
label: "Batch",
icon: <FileCopyIcon className={styles.modeTabIcon} />,
ready: false,
},
{
id: "modify",
label: "Modify",
icon: <BuildCircleIcon className={styles.modeTabIcon} />,
ready: false,
},
];
// ---------------------------------------------------------------------------
// Coming-soon placeholder for unimplemented tabs
// ---------------------------------------------------------------------------
// ComingSoonPlaceholder — re-enable when mode tabs are exposed
// function ComingSoonPlaceholder({ mode }: { mode: ModeTabDef }) {
// return (
// <div className={styles.comingSoon}>
// <DescriptionIcon className={styles.comingSoonIcon} />
// <div className={styles.comingSoonTitle}>{mode.label} Forms</div>
// <div className={styles.comingSoonDesc}>
// This feature is coming soon. Stay tuned!
// </div>
// </div>
// );
// }
// ---------------------------------------------------------------------------
// Main FormFill component
// ---------------------------------------------------------------------------
const FormFill = (_props: BaseToolProps) => {
const { t } = useTranslation();
const { selectedTool } = useNavigation();
const {
selectedTool,
registerUnsavedChangesChecker,
unregisterUnsavedChangesChecker,
setHasUnsavedChanges,
} = useNavigation();
const { state: fileState } = useFileState();
const {
@@ -133,18 +90,43 @@ const FormFill = (_props: BaseToolProps) => {
setValue,
setActiveField,
validateForm,
mode,
setMode,
hasUncommittedChanges,
forFileId,
commitNewFields,
commitModifications,
setCreationType,
setSelectedField,
setPreviewing,
} = useFormFill();
const MODE_TABS: ModeTabDef[] = useMemo(
() => [
{
id: "fill",
label: t("formFill.mode.fill", "Fill"),
icon: <EditNoteIcon className={styles.modeTabIcon} />,
},
{
id: "create",
label: t("formFill.mode.create", "Create"),
icon: <PostAddIcon className={styles.modeTabIcon} />,
},
{
id: "modify",
label: t("formFill.mode.modify", "Modify"),
icon: <BuildCircleIcon className={styles.modeTabIcon} />,
},
],
[t],
);
const allValues = useAllFormValues();
const { validationErrors } = formState;
const { scrollActions } = useViewer();
// Mode system is temporarily restricted to 'fill' only.
// Other modes (make, batch, modify) are defined above but not yet exposed in the UI.
// When ready, uncomment the SegmentedControl and mode state below.
// const [mode, setMode] = useState<FormMode>('fill');
const mode: FormMode = "fill";
const [flatten, setFlatten] = useState(false);
const [saving, setSaving] = useState(false);
const [extracting, setExtracting] = useState(false);
@@ -175,9 +157,48 @@ const FormFill = (_props: BaseToolProps) => {
}
}, [allValues]);
const activeFieldRef = useRef<HTMLDivElement>(null);
useFieldShortcuts();
// A document with nothing to fill lands on Create: Fill would show an empty panel and leave
// the user hunting for the tab that does what they came for. Decided once per document, and
// only after the field list has settled - fields arrive a beat after the fetch reports done,
// so deciding immediately sends a form that does have fields to the wrong tab.
const autoModeFileRef = useRef<string | null>(null);
const fieldCountRef = useRef(0);
fieldCountRef.current = formState.fields.length;
const modeRef = useRef(mode);
modeRef.current = mode;
useEffect(() => {
if (formState.loading || !forFileId) return undefined;
if (autoModeFileRef.current === forFileId) return undefined;
const settle = window.setTimeout(() => {
autoModeFileRef.current = forFileId;
if (fieldCountRef.current === 0 && modeRef.current === "fill") {
setMode("create");
}
}, 300);
return () => window.clearTimeout(settle);
}, [formState.loading, formState.fields.length, forFileId, setMode]);
const isDirtyRef = useRef(formState.isDirty);
isDirtyRef.current = formState.isDirty;
// Fields drawn or edited but never applied are lost on navigation, so the app's existing
// leave-warning needs to hear about them the way other tools report theirs.
const hasUncommittedRef = useRef(false);
hasUncommittedRef.current = hasUncommittedChanges;
useEffect(() => {
registerUnsavedChangesChecker(
() => hasUncommittedRef.current || isDirtyRef.current,
);
return () => unregisterUnsavedChangesChecker();
}, [registerUnsavedChangesChecker, unregisterUnsavedChangesChecker]);
// requestNavigation gates on this flag rather than on the checker above.
useEffect(() => {
setHasUnsavedChanges(hasUncommittedChanges || formState.isDirty);
}, [hasUncommittedChanges, formState.isDirty, setHasUnsavedChanges]);
// Subscribing read: getFiles() during render doesn't re-run when the workbench
// changes, so the panel kept showing the pre-hydration (or pre-version) file.
const { files: activeFiles } = useAllFiles();
@@ -233,6 +254,15 @@ const FormFill = (_props: BaseToolProps) => {
const isActive = selectedTool === "formFill";
// Arming lives in an app-level provider, so leaving the tool would otherwise keep the page
// in crosshair mode and stage fields for whatever the user opened next.
useEffect(() => {
if (isActive) return;
setCreationType(null);
setSelectedField(null);
setPreviewing(false);
}, [isActive, setCreationType, setSelectedField, setPreviewing]);
useEffect(() => {
if (formState.activeFieldName && activeFieldRef.current) {
activeFieldRef.current.scrollIntoView({
@@ -264,23 +294,20 @@ const FormFill = (_props: BaseToolProps) => {
// Track the flatten value at save so toggling it later re-enables Save
setLastSavedFlatten(flatten);
// Dispatch to the viewer's handleFormApply via custom event.
// This ensures the viewer tracks the new file ID, preserves
// scroll position and rotation — instead of our own consumeFiles
// call which would lose the viewer's file tracking context.
const event = new CustomEvent("formfill:apply", {
detail: { blob: filledBlob },
});
window.dispatchEvent(event);
// Route through the viewer's handleFormApply: a direct consumeFiles call
// loses the viewer's file tracking, and with it scroll position and rotation.
dispatchFormApply(filledBlob);
} catch (err) {
const status = isAxiosError(err) ? err.response?.status : undefined;
const message =
status === 413
? "File too large. Try reducing the PDF size first."
: status === 400
? "Invalid form data. Please check all fields."
: (err instanceof Error ? err.message : undefined) ||
"Failed to save filled form";
: (await serverMessage(err)) ||
(status === 400
? "Invalid form data. Please check all fields."
: undefined) ||
(err instanceof Error ? err.message : undefined) ||
"Failed to save filled form";
setSaveError(message);
console.error("[FormFill] Save failed:", err);
} finally {
@@ -289,6 +316,64 @@ const FormFill = (_props: BaseToolProps) => {
}
}, [currentFile, submitForm, flatten, validateForm]);
const applyCurrentMode = useCallback(async () => {
if (!currentFile) return;
if (mode === "create") {
dispatchFormApply(await commitNewFields(currentFile));
return;
}
if (mode === "modify") {
dispatchFormApply(await commitModifications(currentFile));
return;
}
await handleSave();
}, [mode, currentFile, commitNewFields, commitModifications, handleSave]);
// Switching tabs throws away that tab's working state, so it asks first. Owned here rather
// than through the app-wide warning handlers, which hold a single registration app-wide.
const [pendingMode, setPendingMode] = useState<FormMode | null>(null);
const [switching, setSwitching] = useState(false);
const requestMode = useCallback(
(next: FormMode) => {
if (next === mode) return;
if (hasUncommittedChanges) {
setPendingMode(next);
return;
}
setMode(next);
},
[mode, hasUncommittedChanges, setMode],
);
const discardAndSwitch = useCallback(() => {
const next = pendingMode;
setPendingMode(null);
if (next) setMode(next);
}, [pendingMode, setMode]);
const applyAndSwitch = useCallback(async () => {
const next = pendingMode;
setSwitching(true);
try {
await applyCurrentMode();
setPendingMode(null);
if (next) setMode(next);
} catch (err) {
// Swallowing this left the dialog open with no explanation when the backend refused a
// name, so the reason it gives has to reach the user.
setSaveError(
(await serverMessage(err)) ||
(err instanceof Error ? err.message : null) ||
t("formFill.applyFailed", "Could not apply the changes"),
);
setPendingMode(null);
console.error("[FormFill] Could not apply before switching tabs:", err);
} finally {
setSwitching(false);
}
}, [pendingMode, applyCurrentMode, setMode, t]);
// Keyboard shortcut: Ctrl+S to save
const flattenChangedRef = useRef(flattenChanged);
flattenChangedRef.current = flattenChanged;
@@ -391,35 +476,44 @@ const FormFill = (_props: BaseToolProps) => {
return (
<div className={styles.root}>
{/* ---- Mode selection (commented out until additional modes are implemented) ----
<UnsavedChangesDialog
opened={pendingMode !== null}
saving={switching}
onKeepWorking={() => setPendingMode(null)}
onDiscard={discardAndSwitch}
onSave={applyAndSwitch}
/>
{/* ---- Mode selection ---- */}
<div className={styles.modeTabs}>
<SegmentedControl
value={mode}
onChange={(val) => setMode(val as FormMode)}
data={MODE_TABS.map((tab) => ({
onChange={(val) => requestMode(val as FormMode)}
options={MODE_TABS.map((tab) => ({
value: tab.id,
label: (
<div className={styles.segmentedLabel}>
// title carries the full label for locales where it has to ellipsise.
<div className={styles.segmentedLabel} title={tab.label}>
{tab.icon}
<span>{tab.label}</span>
<span className={styles.segmentedInnerLabel}>{tab.label}</span>
</div>
),
}))}
fullWidth
radius="xs"
size="xs"
classNames={{
root: styles.segmentedRoot,
indicator: styles.segmentedIndicator,
control: styles.segmentedControl,
label: styles.segmentedInnerLabel,
}}
ariaLabel={t("formFill.mode.label", "Form editor mode")}
/>
</div>
---- */}
{/* ---- Coming-soon for non-ready tabs (hidden while mode tabs are disabled) ---- */}
{/* !currentModeDef.ready && <ComingSoonPlaceholder mode={currentModeDef} /> */}
{/* ---- Create mode ---- */}
{mode === "create" && (
<FormFieldCreatePanel currentFile={currentFile as File | Blob | null} />
)}
{/* ---- Modify mode ---- */}
{mode === "modify" && (
<FormFieldModifyPanel currentFile={currentFile as File | Blob | null} />
)}
{/* ---- Fill Form content ---- */}
{mode === "fill" && (
@@ -0,0 +1,340 @@
/**
* Staged edits are keyed to the file they were made against, and a commit's skip
* report must survive the re-fetch that the commit itself triggers.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { renderHook, act, waitFor } from "@testing-library/react";
import React from "react";
import {
FormFillProvider,
useFormFill,
} from "@app/tools/formFill/FormFillContext";
import type { FieldEditResult } from "@app/tools/formFill/types";
const applyFieldEdits = vi.fn();
const fetchFields = vi.fn();
vi.mock("@app/tools/formFill/formApi", () => ({
applyFieldEdits: (...args: unknown[]) => applyFieldEdits(...args),
}));
// Defined inside each factory: vi.mock is hoisted above any module-level binding.
vi.mock("@app/tools/formFill/providers/PdfBoxFormProvider", () => ({
PdfBoxFormProvider: class {
fetchFields(...args: unknown[]) {
return fetchFields(...args);
}
fillForm() {
return Promise.resolve(new Blob());
}
},
}));
vi.mock("@app/tools/formFill/providers/PdfiumFormProvider", () => ({
PdfiumFormProvider: class {
fetchFields(...args: unknown[]) {
return fetchFields(...args);
}
fillForm() {
return Promise.resolve(new Blob());
}
},
}));
vi.mock("@app/services/pdfiumService", () => ({
fetchSignatureFieldsWithAppearances: vi.fn().mockResolvedValue([]),
}));
const wrapper = ({ children }: { children: React.ReactNode }) => (
<FormFillProvider>{children}</FormFillProvider>
);
const blob = () => new Blob(["%PDF-1.4"], { type: "application/pdf" });
function result(skipped: FieldEditResult["skipped"]): FieldEditResult {
return { blob: blob(), skipped, skippedTotal: skipped.length };
}
describe("FormFillContext staged-edit ownership", () => {
beforeEach(() => {
applyFieldEdits.mockReset();
fetchFields.mockReset();
fetchFields.mockResolvedValue([]);
});
it("keeps the skip report across the re-fetch a commit triggers", async () => {
const { result: hook } = renderHook(() => useFormFill(), { wrapper });
await act(async () => {
await hook.current.fetchFields(blob(), "file-A");
});
act(() => hook.current.stageModification("f", { x: 1 }));
applyFieldEdits.mockResolvedValue(
result([
{
operation: "delete",
target: "ghost",
reason: "no field with that name exists",
},
]),
);
await act(async () => {
await hook.current.commitModifications(blob());
});
expect(hook.current.skippedEdits).toHaveLength(1);
// Committing produces a NEW workbench file, so the viewer re-fetches under a new id.
await act(async () => {
await hook.current.fetchFields(blob(), "file-A-edited");
});
await waitFor(() => expect(hook.current.skippedEdits).toHaveLength(1));
});
it("drops edits staged against a different file", async () => {
const { result: hook } = renderHook(() => useFormFill(), { wrapper });
await act(async () => {
await hook.current.fetchFields(blob(), "file-A");
});
act(() => hook.current.stageModification("f", { x: 1 }));
expect(hook.current.hasUncommittedChanges).toBe(true);
await act(async () => {
await hook.current.fetchFields(blob(), "file-B");
});
await waitFor(() => expect(hook.current.hasUncommittedChanges).toBe(false));
});
it("drops them even when the new file's fetch fails", async () => {
const { result: hook } = renderHook(() => useFormFill(), { wrapper });
await act(async () => {
await hook.current.fetchFields(blob(), "file-A");
});
act(() => hook.current.stageModification("f", { x: 1 }));
fetchFields.mockRejectedValueOnce(new Error("corrupt PDF"));
await act(async () => {
await hook.current.fetchFields(blob(), "file-B");
});
await waitFor(() => expect(hook.current.hasUncommittedChanges).toBe(false));
});
it("keeps edits across a re-fetch of the same file", async () => {
const { result: hook } = renderHook(() => useFormFill(), { wrapper });
await act(async () => {
await hook.current.fetchFields(blob(), "file-A");
});
act(() => hook.current.stageModification("f", { x: 1 }));
await act(async () => {
await hook.current.fetchFields(blob(), "file-A");
});
expect(hook.current.hasUncommittedChanges).toBe(true);
});
it("drops the skip report once an unrelated document is opened", async () => {
const { result: hook } = renderHook(() => useFormFill(), { wrapper });
await act(async () => {
await hook.current.fetchFields(blob(), "file-A");
});
act(() => hook.current.stageModification("f", { x: 1 }));
applyFieldEdits.mockResolvedValue(
result([
{
operation: "delete",
target: "ghost",
reason: "no field with that name exists",
},
]),
);
await act(async () => {
await hook.current.commitModifications(blob());
});
// The commit's own re-fetch keeps the report...
await act(async () => {
await hook.current.fetchFields(blob(), "file-A-edited");
});
expect(hook.current.skippedEdits).toHaveLength(1);
// ...but opening a different document must not carry it over.
await act(async () => {
await hook.current.fetchFields(blob(), "file-B-unrelated");
});
await waitFor(() => expect(hook.current.skippedEdits).toHaveLength(0));
expect(hook.current.skippedTotal).toBe(0);
});
});
describe("FormFillContext bundled field list", () => {
beforeEach(() => {
applyFieldEdits.mockReset();
fetchFields.mockReset();
fetchFields.mockResolvedValue([]);
});
const bundled = [
{ name: "bundled", type: "text", widgets: [{ pageIndex: 0, x: 1, y: 2 }] },
] as unknown as FieldEditResult["fields"];
async function commitWith(
hook: { current: ReturnType<typeof useFormFill> },
edited: Blob,
fields: FieldEditResult["fields"],
) {
// The viewer switches to pdfbox whenever the form tool is open, which is when commits happen.
act(() => hook.current.setProviderMode("pdfbox"));
await act(async () => {
await hook.current.fetchFields(blob(), "file-A");
});
act(() => hook.current.stageModification("f", { x: 1 }));
applyFieldEdits.mockResolvedValue({
blob: edited,
skipped: [],
skippedTotal: 0,
fields,
});
await act(async () => {
await hook.current.commitModifications(blob());
});
}
it("skips the follow-up request when the commit already returned the fields", async () => {
const { result: hook } = renderHook(() => useFormFill(), { wrapper });
const edited = blob();
await commitWith(hook, edited, bundled);
const callsBefore = fetchFields.mock.calls.length;
await act(async () => {
await hook.current.fetchFields(edited, "file-A-edited");
});
// The whole point: no second upload for the post-commit fetch.
expect(fetchFields.mock.calls).toHaveLength(callsBefore);
await waitFor(() =>
expect(hook.current.state.fields.map((f) => f.name)).toEqual(["bundled"]),
);
});
it("still asks the backend when the commit returned no fields", async () => {
const { result: hook } = renderHook(() => useFormFill(), { wrapper });
const edited = blob();
await commitWith(hook, edited, undefined);
const callsBefore = fetchFields.mock.calls.length;
await act(async () => {
await hook.current.fetchFields(edited, "file-A-edited");
});
expect(fetchFields.mock.calls.length).toBe(callsBefore + 1);
});
it("ignores a bundle whose size does not match the file being fetched", async () => {
const { result: hook } = renderHook(() => useFormFill(), { wrapper });
await commitWith(hook, blob(), bundled);
const callsBefore = fetchFields.mock.calls.length;
// A different document must never adopt the previous commit's field list.
await act(async () => {
await hook.current.fetchFields(
new Blob(["%PDF-1.4 a longer unrelated document"]),
"file-B",
);
});
expect(fetchFields.mock.calls.length).toBe(callsBefore + 1);
});
it("does not reuse the bundle for a second fetch", async () => {
const { result: hook } = renderHook(() => useFormFill(), { wrapper });
const edited = blob();
await commitWith(hook, edited, bundled);
await act(async () => {
await hook.current.fetchFields(edited, "file-A-edited");
});
const afterFirst = fetchFields.mock.calls.length;
await act(async () => {
await hook.current.fetchFields(edited, "file-A-edited");
});
expect(fetchFields.mock.calls.length).toBe(afterFirst + 1);
});
});
describe("FormFillContext value retention", () => {
beforeEach(() => {
applyFieldEdits.mockReset();
fetchFields.mockReset();
fetchFields.mockResolvedValue([
{ name: "who", type: "text", value: "", widgets: [{ pageIndex: 0 }] },
]);
});
it("keeps what the user typed when the same file is re-fetched", async () => {
const { result: hook } = renderHook(() => useFormFill(), { wrapper });
await act(async () => {
await hook.current.fetchFields(blob(), "file-A");
});
act(() => hook.current.setValue("who", "Ada"));
expect(hook.current.getValue("who")).toBe("Ada");
// Opening the form tool switches provider, which re-fetches the very same document.
act(() => hook.current.setProviderMode("pdfbox"));
await act(async () => {
await hook.current.fetchFields(blob(), "file-A");
});
expect(hook.current.getValue("who")).toBe("Ada");
});
it("drops retained values when a different document is opened", async () => {
const { result: hook } = renderHook(() => useFormFill(), { wrapper });
await act(async () => {
await hook.current.fetchFields(blob(), "file-A");
});
act(() => hook.current.setValue("who", "Ada"));
await act(async () => {
await hook.current.fetchFields(blob(), "file-B");
});
expect(hook.current.getValue("who")).toBe("");
});
it("does not report unsaved changes when nothing was typed", async () => {
const { result: hook } = renderHook(() => useFormFill(), { wrapper });
await act(async () => {
await hook.current.fetchFields(blob(), "file-A");
});
act(() => hook.current.setProviderMode("pdfbox"));
await act(async () => {
await hook.current.fetchFields(blob(), "file-A");
});
// A sticky dirty flag makes the leave-page warning fire on every navigation.
expect(hook.current.state.isDirty).toBe(false);
});
it("still reports unsaved changes when something was typed", async () => {
const { result: hook } = renderHook(() => useFormFill(), { wrapper });
await act(async () => {
await hook.current.fetchFields(blob(), "file-A");
});
act(() => hook.current.setValue("who", "Ada"));
act(() => hook.current.setProviderMode("pdfbox"));
await act(async () => {
await hook.current.fetchFields(blob(), "file-A");
});
expect(hook.current.state.isDirty).toBe(true);
});
});
@@ -31,15 +31,32 @@ import React, {
} from "react";
import { useDebouncedCallback } from "@mantine/hooks";
import { isAxiosError } from "axios";
import { applyStagedGeometry } from "@app/tools/formFill/formCoordinateUtils";
import type {
FormField,
FormFillState,
WidgetCoordinates,
FormMode,
CreatableFieldType,
NewFieldDefinition,
ModifyFieldDefinition,
SkippedFieldEdit,
} from "@app/tools/formFill/types";
import { pendingIdFrom } from "@app/tools/formFill/pendingSelection";
import type { IFormDataProvider } from "@app/tools/formFill/providers/types";
import { PdfBoxFormProvider } from "@app/tools/formFill/providers/PdfBoxFormProvider";
import { PdfiumFormProvider } from "@app/tools/formFill/providers/PdfiumFormProvider";
import { fetchSignatureFieldsWithAppearances } from "@app/services/pdfiumService";
import { applyFieldEdits } from "@app/tools/formFill/formApi";
import { mergeSignatureAppearances } from "@app/tools/formFill/formFieldMerge";
/** Marks a skip report as belonging to whichever document the commit just produced. */
const PENDING_SKIP_REPORT = "__pending__";
/** A field queued for creation, with a client-side id for list keys. */
export interface PendingField extends NewFieldDefinition {
id: string;
}
// ---------------------------------------------------------------------------
// FormValuesStore — external store for field values (outside React state)
@@ -158,8 +175,20 @@ function reducer(state: FormFillState, action: Action): FormFillState {
return { ...state, isDirty: true };
case "SET_ACTIVE_FIELD":
return { ...state, activeFieldName: action.fieldName };
case "SET_VALIDATION_ERRORS":
return { ...state, validationErrors: action.errors };
case "SET_VALIDATION_ERRORS": {
// The debounce mints a fresh identical map ~3x/s while typing; without this every
// keystroke re-renders every consumer and every mounted page overlay.
const prev = state.validationErrors;
const next = action.errors;
const keys = Object.keys(next);
if (
keys.length === Object.keys(prev).length &&
keys.every((k) => prev[k] === next[k])
) {
return state;
}
return { ...state, validationErrors: next };
}
case "CLEAR_VALIDATION_ERROR": {
if (!state.validationErrors[action.fieldName]) return state;
const { [action.fieldName]: _, ...rest } = state.validationErrors;
@@ -196,6 +225,8 @@ export interface FormFillContextValue {
reset: () => void;
/** Pre-computed map of page index to fields for performance */
fieldsByPage: Map<number, FormField[]>;
/** fieldsByPage with staged moves, resizes, retypes and deletions already applied. */
effectiveFieldsByPage: Map<number, FormField[]>;
/** Name of the currently active provider ('pdf-lib' | 'pdfbox') */
activeProviderName: string;
/**
@@ -206,6 +237,69 @@ export interface FormFillContextValue {
setProviderMode: (mode: "pdflib" | "pdfbox") => void;
/** The file ID that the current form fields belong to (null if no fields loaded) */
forFileId: string | null;
// --- Structural editing (create / modify modes) ---
/** Current tool mode. */
mode: FormMode;
/** Switch mode. Switching clears the other mode's uncommitted working state. */
setMode: (mode: FormMode) => void;
// --- Create mode ---
/** Field type currently armed for placement (null = not placing). */
creationType: CreatableFieldType | null;
/** Steps back one staged edit; false when there was nothing left to undo. */
undo: () => boolean;
canUndo: boolean;
/** True while the user holds Preview, which hides the editing chrome. */
previewing: boolean;
setPreviewing: (previewing: boolean) => void;
setCreationType: (type: CreatableFieldType | null) => void;
/** Fields drawn but not yet committed to the PDF. */
pendingFields: PendingField[];
/** Queue a new field (id + default name auto-assigned). Returns the new id. */
addPendingField: (
field: Omit<NewFieldDefinition, "name"> & { name?: string },
) => string;
updatePendingField: (id: string, patch: Partial<NewFieldDefinition>) => void;
removePendingField: (id: string) => void;
clearPendingFields: () => void;
/** POST queued fields to the backend; resolves to the updated PDF blob. */
commitNewFields: (file: File | Blob) => Promise<Blob>;
// --- Modify mode ---
/** Field currently selected for editing in modify mode. */
selectedFieldName: string | null;
setSelectedField: (name: string | null) => void;
/** Staged (uncommitted) property/geometry changes, keyed by original field name. */
modifiedFields: Record<string, ModifyFieldDefinition>;
/** Merge a partial change for a field into the staged set. */
stageModification: (
targetName: string,
patch: Partial<ModifyFieldDefinition>,
) => void;
/** Discard staged changes for a single field. */
clearModification: (targetName: string) => void;
/** Field names marked for deletion. */
deletedFieldNames: string[];
/** Toggle a field's deletion mark. */
toggleFieldDeleted: (name: string) => void;
/** Discard all staged modifications and deletions. */
clearModifications: () => void;
/** POST staged modifications + deletions; resolves to the updated PDF blob. */
commitModifications: (file: File | Blob) => Promise<Blob>;
/** True when create or modify mode has uncommitted work. */
hasUncommittedChanges: boolean;
/** Edits the last commit asked for but the document could not take. */
skippedEdits: SkippedFieldEdit[];
/** May exceed skippedEdits.length when the report was truncated. */
skippedTotal: number;
clearSkippedEdits: () => void;
/** True while a field is being dragged, so Escape handlers elsewhere stand down. */
dragActiveRef: React.MutableRefObject<boolean>;
}
const FormFillContext = createContext<FormFillContextValue | null>(null);
@@ -307,6 +401,112 @@ export function FormFillProvider({
// This prevents full context re-renders on every keystroke.
const [valuesStore] = useState(() => new FormValuesStore());
// --- Structural editing state (create / modify modes) ---
const [mode, setModeState] = useState<FormMode>("fill");
const [creationType, setCreationType] = useState<CreatableFieldType | null>(
null,
);
const [pendingFields, setPendingFields] = useState<PendingField[]>([]);
const [previewing, setPreviewing] = useState(false);
// Undo covers the staged edits, which is what the user has been doing here; the applied
// document keeps its own version history elsewhere.
const undoStackRef = useRef<
{
pending: PendingField[];
modified: Record<string, ModifyFieldDefinition>;
deleted: string[];
}[]
>([]);
const [canUndo, setCanUndo] = useState(false);
const liveEditsRef = useRef({
pending: [] as PendingField[],
modified: {} as Record<string, ModifyFieldDefinition>,
deleted: [] as string[],
});
const rememberForUndo = useCallback(() => {
const live = liveEditsRef.current;
undoStackRef.current.push({
pending: [...live.pending],
modified: { ...live.modified },
deleted: [...live.deleted],
});
// A long session should not grow without bound.
if (undoStackRef.current.length > 50) undoStackRef.current.shift();
setCanUndo(true);
}, []);
const undo = useCallback(() => {
const previous = undoStackRef.current.pop();
setCanUndo(undoStackRef.current.length > 0);
if (!previous) return false;
setPendingFields(previous.pending);
setModifiedFields(previous.modified);
setDeletedFieldNames(previous.deleted);
// A selection pointing at a field the undo removed would swallow the next click.
setSelectedField((current) => {
const id = pendingIdFrom(current);
if (!id) return current;
return previous.pending.some((f) => f.id === id) ? current : null;
});
return true;
}, []);
const [selectedFieldName, setSelectedField] = useState<string | null>(null);
const [modifiedFields, setModifiedFields] = useState<
Record<string, ModifyFieldDefinition>
>({});
const [deletedFieldNames, setDeletedFieldNames] = useState<string[]>([]);
liveEditsRef.current = {
pending: pendingFields,
modified: modifiedFields,
deleted: deletedFieldNames,
};
const [skippedEdits, setSkippedEdits] = useState<SkippedFieldEdit[]>([]);
const [skippedTotal, setSkippedTotal] = useState(0);
const clearSkippedEdits = useCallback(() => {
setSkippedEdits([]);
setSkippedTotal(0);
skipReportFileIdRef.current = null;
}, []);
/** Which file the staged edits belong to, so they cannot be committed onto another. */
const editedFileIdRef = useRef<string | null>(null);
/** Survives an in-flight fetch, unlike forFileIdRef, so staged edits can be stamped. */
const lastKnownFileIdRef = useRef<string | null>(null);
/** The file the current skip report describes, so it cannot outlive that document. */
const skipReportFileIdRef = useRef<string | null>(null);
/** Set by the edit overlay so other Escape handlers do not steal a drag's cancel. */
const dragActiveRef = useRef(false);
// Monotonic counter for client-side pending-field ids and default names.
const pendingCounterRef = useRef(0);
// Fields the last commit's response carried, adopted by the next fetch instead of uploading
// the document again to ask. Size-checked so a different document cannot pick them up.
const bundledFieldsRef = useRef<{ fields: FormField[]; size: number } | null>(
null,
);
// What the user typed, carried across the re-fetch that opening the form tool triggers.
const retainedValuesRef = useRef<{
fileId: string | null;
values: Record<string, string>;
} | null>(null);
const clearEditingState = useCallback(() => {
setCreationType(null);
setPendingFields([]);
setSelectedField(null);
setModifiedFields({});
setDeletedFieldNames([]);
editedFileIdRef.current = null;
// Report and field list both describe the batch just discarded, so neither outlives it.
bundledFieldsRef.current = null;
setSkippedEdits([]);
setSkippedTotal(0);
}, []);
const fetchFields = useCallback(
async (file: File | Blob, fileId?: string) => {
// Increment version so any in-flight fetch for a previous file is discarded.
@@ -316,15 +516,60 @@ export function FormFillProvider({
// correctly discarded.
const version = ++fetchVersionRef.current;
// Staged edits reference the previous document's fields; committing them against a
// different file would edit the wrong PDF. Checked here, before any await, because
// forFileIdRef is null for the whole fetch and the success path may never run.
if (
editedFileIdRef.current != null &&
(fileId ?? null) !== editedFileIdRef.current
) {
clearEditingState();
}
// A commit produces a new file id, so the first fetch after one adopts the report;
// any later switch to a different document clears it.
if (skipReportFileIdRef.current === PENDING_SKIP_REPORT) {
skipReportFileIdRef.current = fileId ?? null;
} else if (
skipReportFileIdRef.current != null &&
(fileId ?? null) !== skipReportFileIdRef.current
) {
skipReportFileIdRef.current = null;
setSkippedEdits([]);
setSkippedTotal(0);
}
// Same document reloading means the typed values are still the user's; a different one
// means they belong to a document that is no longer open.
const sameDocument = (fileId ?? null) === lastKnownFileIdRef.current;
const carried =
retainedValuesRef.current?.fileId === (fileId ?? null)
? retainedValuesRef.current.values
: sameDocument
? { ...valuesStore.values }
: {};
retainedValuesRef.current = null;
lastKnownFileIdRef.current = fileId ?? null;
// Immediately clear previous state so FormFieldOverlay's stale-file guards
// prevent rendering fields from a previous document during the fetch.
forFileIdRef.current = null;
setForFileId(null);
valuesStore.reset({});
dispatch({ type: "RESET" });
// Deliberately keeps create/modify state: the viewer re-fetches on provider
// switch and file load, which must not wipe in-progress edits.
dispatch({ type: "FETCH_START" });
try {
let fields = await providerRef.current.fetchFields(file);
// Only pdfbox mode can use them: the bundle is PDFBox's own view of the document.
const bundled = bundledFieldsRef.current;
bundledFieldsRef.current = null;
const usable =
bundled &&
bundled.size === file.size &&
providerModeRef.current === "pdfbox";
let fields = usable
? bundled.fields
: await providerRef.current.fetchFields(file);
// If another fetch or reset happened while we were waiting, discard this result
if (fetchVersionRef.current !== version) {
console.debug(
@@ -333,8 +578,8 @@ export function FormFillProvider({
return;
}
// When the pdfbox provider is active the backend doesn't return signature fields
// (they're not fillable). Fetch them via pdflib so their appearances still render.
// pdfbox returns signature fields without a rendered appearance; merge the
// pdfium ones by name, since appending would list a signature twice.
if (providerModeRef.current === "pdfbox") {
try {
// Convert File/Blob to ArrayBuffer for pdfiumService
@@ -342,9 +587,7 @@ export function FormFillProvider({
const sigFields =
await fetchSignatureFieldsWithAppearances(arrayBuffer);
if (fetchVersionRef.current !== version) return; // stale check after async
if (sigFields.length > 0) {
fields = [...fields, ...sigFields];
}
fields = mergeSignatureAppearances(fields, sigFields);
} catch (e) {
console.warn(
"[FormFill] Failed to extract signature appearances for pdfbox mode:",
@@ -355,13 +598,21 @@ export function FormFillProvider({
// Initialise values in the external store
const values: Record<string, string> = {};
let edited = false;
for (const field of fields) {
values[field.name] = field.value ?? "";
const stored = field.value ?? "";
values[field.name] = carried[field.name] ?? stored;
edited = edited || values[field.name] !== stored;
}
valuesStore.reset(values);
forFileIdRef.current = fileId ?? null;
setForFileId(fileId ?? null);
dispatch({ type: "FETCH_SUCCESS", fields });
// After FETCH_SUCCESS, which clears the flag; and only when a value genuinely differs,
// or the unsaved-changes prompt cries wolf on every navigation.
if (edited) {
dispatch({ type: "MARK_DIRTY" });
}
} catch (err) {
if (fetchVersionRef.current !== version) return; // stale
const msg =
@@ -373,7 +624,7 @@ export function FormFillProvider({
dispatch({ type: "FETCH_ERROR", error: msg });
}
},
[valuesStore],
[valuesStore, clearEditingState],
);
const validateFieldDebounced = useDebouncedCallback((fieldName: string) => {
@@ -448,6 +699,11 @@ export function FormFillProvider({
fetchVersionRef.current++;
forFileIdRef.current = null;
setForFileId(null);
// The viewer re-fetches straight after this, and that fetch is where they are restored.
retainedValuesRef.current = {
fileId: lastKnownFileIdRef.current,
values: { ...valuesStore.values },
};
valuesStore.reset({});
dispatch({ type: "RESET" });
@@ -481,7 +737,171 @@ export function FormFillProvider({
setForFileId(null);
valuesStore.reset({});
dispatch({ type: "RESET" });
}, [valuesStore]);
clearEditingState();
}, [valuesStore, clearEditingState]);
// --- Mode switching ---
const setMode = useCallback(
(next: FormMode) => {
setModeState((prev) => {
if (prev === next) return prev;
// Leaving a mode discards its uncommitted working state so the user
// doesn't carry half-drawn fields or staged edits between modes.
clearEditingState();
return next;
});
},
[clearEditingState],
);
// --- Create mode ---
const addPendingField = useCallback(
(field: Omit<NewFieldDefinition, "name"> & { name?: string }): string => {
rememberForUndo();
editedFileIdRef.current = lastKnownFileIdRef.current;
const seq = ++pendingCounterRef.current;
const id = `pending-${seq}`;
// Friendly, readable default names that match how the viewer labels
// fields, instead of cryptic "Field_5".
const TYPE_DEFAULT_NAME: Record<string, string> = {
text: "Text field",
checkbox: "Checkbox",
combobox: "Dropdown",
listbox: "List",
radio: "Radio group",
button: "Button",
signature: "Signature",
};
const defaultName =
field.name?.trim() ||
`${TYPE_DEFAULT_NAME[field.type] ?? "Field"} ${seq}`;
// Choice/radio fields are useless without options, so seed defaults.
const needsOptions =
field.type === "combobox" ||
field.type === "listbox" ||
field.type === "radio";
const options =
field.options ?? (needsOptions ? ["Option 1", "Option 2"] : undefined);
setPendingFields((prev) => [
...prev,
{ ...field, name: defaultName, options, id } as PendingField,
]);
return id;
},
[],
);
const updatePendingField = useCallback(
(id: string, patch: Partial<NewFieldDefinition>) => {
rememberForUndo();
setPendingFields((prev) =>
prev.map((f) => (f.id === id ? { ...f, ...patch } : f)),
);
},
[],
);
const removePendingField = useCallback(
(id: string) => {
rememberForUndo();
setPendingFields((prev) => prev.filter((f) => f.id !== id));
},
[rememberForUndo],
);
const clearPendingFields = useCallback(() => {
setPendingFields([]);
setCreationType(null);
}, []);
const commitNewFields = useCallback(
async (file: File | Blob): Promise<Blob> => {
// Strip the client-side id before sending to the backend.
const definitions: NewFieldDefinition[] = pendingFields.map(
({ id: _id, ...rest }) => rest,
);
const result = await applyFieldEdits(file, { add: definitions });
bundledFieldsRef.current = result.fields
? { fields: result.fields, size: result.blob.size }
: null;
setSkippedEdits(result.skipped);
setSkippedTotal(result.skippedTotal);
skipReportFileIdRef.current = PENDING_SKIP_REPORT;
setPendingFields([]);
setCreationType(null);
// The batch is gone, so the stamp must not survive to trigger a clear on the
// post-commit re-fetch; that would wipe the skip report we just set.
editedFileIdRef.current = null;
return result.blob;
},
[pendingFields],
);
// --- Modify mode ---
const stageModification = useCallback(
(targetName: string, patch: Partial<ModifyFieldDefinition>) => {
rememberForUndo();
editedFileIdRef.current = lastKnownFileIdRef.current;
setModifiedFields((prev) => ({
...prev,
[targetName]: { ...prev[targetName], targetName, ...patch },
}));
},
[],
);
const clearModification = useCallback((targetName: string) => {
setModifiedFields((prev) => {
if (!(targetName in prev)) return prev;
const { [targetName]: _removed, ...rest } = prev;
return rest;
});
}, []);
const toggleFieldDeleted = useCallback((name: string) => {
rememberForUndo();
editedFileIdRef.current = lastKnownFileIdRef.current;
setDeletedFieldNames((prev) =>
prev.includes(name) ? prev.filter((n) => n !== name) : [...prev, name],
);
}, []);
const clearModifications = useCallback(() => {
setModifiedFields({});
setDeletedFieldNames([]);
setSelectedField(null);
}, []);
const commitModifications = useCallback(
async (file: File | Blob): Promise<Blob> => {
// Apply property/geometry changes (for fields not being deleted) and the
// deletions in a single backend round-trip.
const updates = Object.values(modifiedFields).filter(
(m) => !deletedFieldNames.includes(m.targetName),
);
const result = await applyFieldEdits(file, {
modify: updates,
delete: deletedFieldNames,
});
bundledFieldsRef.current = result.fields
? { fields: result.fields, size: result.blob.size }
: null;
setSkippedEdits(result.skipped);
setSkippedTotal(result.skippedTotal);
skipReportFileIdRef.current = PENDING_SKIP_REPORT;
setModifiedFields({});
setDeletedFieldNames([]);
setSelectedField(null);
editedFileIdRef.current = null;
return result.blob;
},
[modifiedFields, deletedFieldNames],
);
const hasUncommittedChanges =
pendingFields.length > 0 ||
Object.keys(modifiedFields).length > 0 ||
deletedFieldNames.length > 0;
const fieldsByPage = useMemo(() => {
const map = new Map<number, FormField[]>();
@@ -493,6 +913,24 @@ export function FormFillProvider({
return map;
}, [state.fields]);
/**
* The fields as they would look once the staged edits are applied. Drawing from this keeps one
* visual per field that follows a drag, instead of a stale copy left at the old coordinates.
*/
const effectiveFieldsByPage = useMemo(() => {
const map = new Map<number, FormField[]>();
const deleted = new Set(deletedFieldNames);
for (const field of state.fields) {
if (deleted.has(field.name)) continue;
const staged = modifiedFields[field.name];
const effective = staged ? applyStagedGeometry(field, staged) : field;
const pageIdx = effective.widgets?.[0]?.pageIndex ?? 0;
if (!map.has(pageIdx)) map.set(pageIdx, []);
map.get(pageIdx)!.push(effective);
}
return map;
}, [state.fields, modifiedFields, deletedFieldNames]);
// Context value — does NOT depend on values, so keystrokes don't
// trigger re-renders of all context consumers.
const value = useMemo<FormFillContextValue>(
@@ -508,9 +946,39 @@ export function FormFillProvider({
validateForm,
reset,
fieldsByPage,
effectiveFieldsByPage,
activeProviderName: providerRef.current.name,
setProviderMode,
forFileId,
// editing
mode,
setMode,
creationType,
previewing,
setPreviewing,
undo,
canUndo,
setCreationType,
pendingFields,
addPendingField,
updatePendingField,
removePendingField,
clearPendingFields,
commitNewFields,
selectedFieldName,
setSelectedField,
modifiedFields,
stageModification,
clearModification,
deletedFieldNames,
toggleFieldDeleted,
clearModifications,
commitModifications,
hasUncommittedChanges,
skippedEdits,
skippedTotal,
clearSkippedEdits,
dragActiveRef,
}),
[
state,
@@ -524,9 +992,36 @@ export function FormFillProvider({
validateForm,
reset,
fieldsByPage,
effectiveFieldsByPage,
providerMode,
setProviderMode,
forFileId,
mode,
setMode,
creationType,
previewing,
setPreviewing,
undo,
canUndo,
pendingFields,
addPendingField,
updatePendingField,
removePendingField,
clearPendingFields,
commitNewFields,
selectedFieldName,
modifiedFields,
stageModification,
clearModification,
deletedFieldNames,
toggleFieldDeleted,
clearModifications,
commitModifications,
hasUncommittedChanges,
skippedEdits,
skippedTotal,
clearSkippedEdits,
dragActiveRef,
],
);
@@ -540,3 +1035,26 @@ export function FormFillProvider({
}
export default FormFillContext;
/**
* Fields whose PDF-baked visuals (button and signature appearance bitmaps) no longer match the
* staged state. Those layers render from the un-edited file, so they must be hidden while editing
* or they leave a ghost at the original rect.
*/
export function useStaleBakedFieldNames(): Set<string> {
// Read the context directly: the viewer's appearance overlays render outside the form tool
// (and in isolation in Storybook), where there is no provider and nothing is staged.
const ctx = useContext(FormFillContext);
const mode = ctx?.mode ?? "fill";
const modifiedFields = ctx?.modifiedFields;
const deletedFieldNames = ctx?.deletedFieldNames;
return useMemo(() => {
if (mode === "fill" || !modifiedFields || !deletedFieldNames) {
return new Set<string>();
}
return new Set<string>([
...Object.keys(modifiedFields),
...deletedFieldNames,
]);
}, [mode, modifiedFields, deletedFieldNames]);
}
@@ -0,0 +1,54 @@
/**
* Reports edits the backend could not apply. The mutating endpoints return the
* PDF itself, so partial failures arrive in a response header, not the body.
*/
import { Alert, List, Text } from "@mantine/core";
import WarningAmberIcon from "@mui/icons-material/WarningAmber";
import { useTranslation } from "react-i18next";
import { useFormFill } from "@app/tools/formFill/FormFillContext";
export function SkippedEditsAlert() {
const { t } = useTranslation();
const { skippedEdits, skippedTotal, clearSkippedEdits } = useFormFill();
if (skippedEdits.length === 0) return null;
return (
<Alert
icon={<WarningAmberIcon sx={{ fontSize: 16 }} />}
color="yellow"
variant="light"
p="xs"
radius="sm"
withCloseButton
onClose={clearSkippedEdits}
data-testid="form-skipped-edits"
>
<Text size="xs" fw={600}>
{t("formFill.skippedEdits", {
count: Math.max(skippedTotal, skippedEdits.length),
defaultValue: "{{count}} changes could not be applied:",
})}
</Text>
<List size="xs" spacing={2} mt={4}>
{skippedEdits.map((skip, index) => (
<List.Item key={`${skip.operation}-${skip.target ?? index}`}>
<Text size="xs">
{skip.target ? `${skip.target}: ` : ""}
{skip.reason}
</Text>
</List.Item>
))}
</List>
{skippedTotal > skippedEdits.length && (
<Text size="xs" c="dimmed" mt={4}>
{t("formFill.skippedEditsTruncated", "{{count}} more not listed.", {
count: skippedTotal - skippedEdits.length,
})}
</Text>
)}
</Alert>
);
}
export default SkippedEditsAlert;
@@ -0,0 +1,41 @@
/** Alignment guide lines shared by the create and edit overlays; positioned within the page overlay. */
import React from "react";
import type { SnapGuide } from "@app/tools/formFill/formSnapUtils";
import { FORM_COLORS } from "@app/tools/formFill/formFieldColors";
const GUIDE_COLOR = FORM_COLORS.guide;
export function SnapGuides({ guides }: { guides: SnapGuide[] }) {
return (
<>
{guides.map((g, i) => (
<div
key={i}
style={
g.orientation === "v"
? {
position: "absolute",
left: g.position,
top: 0,
width: 1,
height: "100%",
background: GUIDE_COLOR,
pointerEvents: "none",
}
: {
position: "absolute",
top: g.position,
left: 0,
height: 1,
width: "100%",
background: GUIDE_COLOR,
pointerEvents: "none",
}
}
/>
))}
</>
);
}
export default SnapGuides;
@@ -0,0 +1,56 @@
import { describe, expect, it } from "vitest";
import { Blob as NodeBlob } from "node:buffer";
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { readFieldBundle } from "@app/tools/formFill/fieldBundle";
/**
* Reads archives produced by the real backend (see FormFieldBundleTest), not by a JS lookalike.
* Java defers the deflated entry's sizes to a data descriptor, so the local header reports zero.
*/
/** node:buffer's Blob satisfies the reader at runtime; TypeScript treats it as a separate type. */
function fixture(name: string): Blob {
const blob = new NodeBlob([
readFileSync(join(import.meta.dirname, "__fixtures__", name)),
]);
return blob as unknown as Blob;
}
describe("readFieldBundle against real backend output", () => {
it("reads a mixed checkbox and radio form", async () => {
const result = await readFieldBundle(fixture("checkbox-and-radio.zip"));
expect(result).not.toBeNull();
expect(result!.fields.map((field) => field.name).sort()).toEqual([
"agree",
"plan",
]);
const pdf = new Uint8Array(await result!.pdf.arrayBuffer());
expect(new TextDecoder().decode(pdf.subarray(0, 5))).toBe("%PDF-");
});
it("reads a 120-field document without truncating the pdf", async () => {
const blob = fixture("many-fields.zip");
const result = await readFieldBundle(blob);
expect(result!.fields).toHaveLength(120);
const pdf = new Uint8Array(await result!.pdf.arrayBuffer());
expect(new TextDecoder().decode(pdf.subarray(0, 5))).toBe("%PDF-");
// The tail proves the stored slice ran to the entry's real end, not to the archive's.
expect(new TextDecoder().decode(pdf.subarray(-6))).toContain("%%EOF");
expect(pdf.length).toBeLessThan(blob.size);
});
it("carries widget coordinates through, which is why the second request can go", async () => {
const result = await readFieldBundle(fixture("many-fields.zip"));
const widget = result!.fields[0]?.widgets?.[0];
expect(widget).toBeDefined();
expect(typeof widget!.pageIndex).toBe("number");
expect(typeof widget!.x).toBe("number");
expect(typeof widget!.y).toBe("number");
});
});
@@ -0,0 +1,241 @@
import { describe, expect, it } from "vitest";
import { Blob as NodeBlob } from "node:buffer";
import { deflateRawSync } from "node:zlib";
import {
readFieldBundle,
supportsFieldBundle,
} from "@app/tools/formFill/fieldBundle";
/**
* Builds ZIPs the way the backend does, using node:buffer's Blob: jsdom's ignores slice() ranges
* and has no stream(), so it silently cannot exercise an archive reader at all.
*/
/** node:buffer's Blob satisfies the reader at runtime; TypeScript treats it as a separate type. */
function asBlob(blob: NodeBlob): Blob {
return blob as unknown as Blob;
}
const CRC_TABLE = (() => {
const table = new Uint32Array(256);
for (let i = 0; i < 256; i++) {
let c = i;
for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
table[i] = c >>> 0;
}
return table;
})();
function crc32(bytes: Uint8Array): number {
let c = 0xffffffff;
for (const byte of bytes) c = CRC_TABLE[(c ^ byte) & 0xff] ^ (c >>> 8);
return (c ^ 0xffffffff) >>> 0;
}
interface BuildEntry {
name: string;
data: Uint8Array;
deflate: boolean;
/** Mimics a writer that defers sizes to a trailing data descriptor. */
dataDescriptor?: boolean;
extra?: number;
}
function buildZip(entries: BuildEntry[]): Blob {
const chunks: Uint8Array[] = [];
const central: Uint8Array[] = [];
let offset = 0;
for (const entry of entries) {
const name = new TextEncoder().encode(entry.name);
const payload = entry.deflate
? new Uint8Array(deflateRawSync(Buffer.from(entry.data)))
: entry.data;
const crc = crc32(entry.data);
const extraLength = entry.extra ?? 0;
const local = new DataView(new ArrayBuffer(30));
local.setUint32(0, 0x04034b50, true);
local.setUint16(4, 20, true);
local.setUint16(6, entry.dataDescriptor ? 0x08 : 0, true);
local.setUint16(8, entry.deflate ? 8 : 0, true);
// A data-descriptor writer zeroes these in the local header; only the central copy is right.
local.setUint32(14, entry.dataDescriptor ? 0 : crc, true);
local.setUint32(18, entry.dataDescriptor ? 0 : payload.length, true);
local.setUint32(22, entry.dataDescriptor ? 0 : entry.data.length, true);
local.setUint16(26, name.length, true);
local.setUint16(28, extraLength, true);
const localHeaderOffset = offset;
const parts = [
new Uint8Array(local.buffer),
name,
new Uint8Array(extraLength),
payload,
];
for (const part of parts) {
chunks.push(part);
offset += part.length;
}
const cd = new DataView(new ArrayBuffer(46));
cd.setUint32(0, 0x02014b50, true);
cd.setUint16(10, entry.deflate ? 8 : 0, true);
cd.setUint32(16, crc, true);
cd.setUint32(20, payload.length, true);
cd.setUint32(24, entry.data.length, true);
cd.setUint16(28, name.length, true);
cd.setUint32(42, localHeaderOffset, true);
central.push(new Uint8Array(cd.buffer), name);
}
const centralStart = offset;
const centralSize = central.reduce((sum, part) => sum + part.length, 0);
const eocd = new DataView(new ArrayBuffer(22));
eocd.setUint32(0, 0x06054b50, true);
eocd.setUint16(8, entries.length, true);
eocd.setUint16(10, entries.length, true);
eocd.setUint32(12, centralSize, true);
eocd.setUint32(16, centralStart, true);
return asBlob(
new NodeBlob([...chunks, ...central, new Uint8Array(eocd.buffer)]),
);
}
const FIELDS = [
{ name: "signature", type: "text", widgets: [{ pageIndex: 0 }] },
];
async function bytesOf(blob: Blob): Promise<Uint8Array> {
return new Uint8Array(await blob.arrayBuffer());
}
const PDF_BYTES = new Uint8Array([
0x25, 0x50, 0x44, 0x46, 0x2d, 0x31, 0x2e, 0x37, 0x0a, 0x00, 0xff, 0xfe, 0x80,
0x7f,
]);
function bundle(overrides: Partial<BuildEntry>[] = []): Blob {
const pdf = PDF_BYTES;
return buildZip([
{
name: "fields.json",
data: new TextEncoder().encode(JSON.stringify(FIELDS)),
deflate: true,
...overrides[0],
},
{ name: "document.pdf", data: pdf, deflate: false, ...overrides[1] },
]);
}
describe("readFieldBundle", () => {
it("reports support when DecompressionStream exists", () => {
expect(supportsFieldBundle()).toBe(
typeof DecompressionStream === "function",
);
});
it("returns both the pdf and the deflated field list", async () => {
const result = await readFieldBundle(bundle());
expect(result).not.toBeNull();
expect(result!.fields).toEqual(FIELDS);
expect(await bytesOf(result!.pdf)).toEqual(PDF_BYTES);
expect(result!.pdf.type).toBe("application/pdf");
});
it("survives an extra field in the local header", async () => {
// Some writers put an extra field in the local header only, so the payload offset
// has to be read from there rather than from the central directory.
const result = await readFieldBundle(bundle([{}, { extra: 9 }]));
// Byte-exact on purpose: a slice starting early still contains the header, so
// containment cannot detect a misread offset.
expect(await bytesOf(result!.pdf)).toEqual(PDF_BYTES);
});
it("reads sizes from the central directory when the local header defers them", async () => {
const result = await readFieldBundle(
bundle([{ dataDescriptor: true }, {}]),
);
expect(result!.fields).toEqual(FIELDS);
});
it("returns null for a bare pdf so the caller can fall back", async () => {
expect(
await readFieldBundle(
asBlob(new NodeBlob(["%PDF-1.7 not a zip at all"])),
),
).toBeNull();
});
it("throws when the expected entries are absent", async () => {
const wrong = buildZip([
{
name: "other.txt",
data: new TextEncoder().encode("nope"),
deflate: false,
},
]);
await expect(readFieldBundle(wrong)).rejects.toThrow(/missing/i);
});
it("throws when the field list is not an array", async () => {
const notArray = buildZip([
{
name: "fields.json",
data: new TextEncoder().encode('{"a":1}'),
deflate: true,
},
{
name: "document.pdf",
data: new TextEncoder().encode("%PDF-1.7"),
deflate: false,
},
]);
await expect(readFieldBundle(notArray)).rejects.toThrow(/not a list/i);
});
it("keeps the pdf byte-exact through the slice", async () => {
const bytes = new Uint8Array(4096);
for (let i = 0; i < bytes.length; i++) bytes[i] = (i * 31) % 256;
const zip = buildZip([
{
name: "fields.json",
data: new TextEncoder().encode("[]"),
deflate: true,
},
{ name: "document.pdf", data: bytes, deflate: false },
]);
const result = await readFieldBundle(zip);
expect(new Uint8Array(await result!.pdf.arrayBuffer())).toEqual(bytes);
});
it("throws rather than handing back a truncated archive as the document", async () => {
const whole = bundle();
// Losing the tail loses the central directory, which is how a cut-off download arrives.
const truncated = whole.slice(0, whole.size - 40);
// Returning this blob would let the caller save a ZIP over the user's PDF.
await expect(readFieldBundle(truncated)).rejects.toThrow(
/corrupt|truncated/i,
);
});
it("never resolves to a blob that is still the archive", async () => {
const whole = bundle();
const result = await readFieldBundle(whole);
expect(result!.pdf.size).toBeLessThan(whole.size);
const head = new Uint8Array(await result!.pdf.slice(0, 2).arrayBuffer());
expect(String.fromCharCode(...head)).not.toBe("PK");
});
});
@@ -0,0 +1,177 @@
import type { FormField } from "@app/tools/formFill/types";
/**
* Reads the ZIP that /edit-fields?includeFields=true returns: the edited PDF stored, the field
* list deflated. Slicing the stored entry hands back a Blob view, so the PDF is never copied.
*/
const EOCD_SIGNATURE = 0x06054b50;
const CENTRAL_HEADER_SIGNATURE = 0x02014b50;
const EOCD_MIN_SIZE = 22;
const CENTRAL_HEADER_SIZE = 46;
const LOCAL_HEADER_SIZE = 30;
const METHOD_STORED = 0;
const METHOD_DEFLATED = 8;
/** ZIP comments are 64KB at most, so the record cannot start further back than this. */
const EOCD_SEARCH_LIMIT = EOCD_MIN_SIZE + 0xffff;
export const FIELDS_ENTRY = "fields.json";
export const DOCUMENT_ENTRY = "document.pdf";
export interface FieldBundle {
pdf: Blob;
fields: FormField[];
}
interface CentralEntry {
name: string;
method: number;
compressedSize: number;
localHeaderOffset: number;
}
/**
* Without it the deflated entry cannot be read, so callers must fall back to a second request.
*/
export function supportsFieldBundle(): boolean {
return typeof DecompressionStream === "function";
}
/** ZIP local file headers start with "PK\x03\x04". */
async function looksZipped(blob: Blob): Promise<boolean> {
if (blob.size < EOCD_MIN_SIZE) return false;
const head = new Uint8Array(await blob.slice(0, 4).arrayBuffer());
return (
head[0] === 0x50 && head[1] === 0x4b && head[2] === 0x03 && head[3] === 0x04
);
}
/** Scans back from the end for the end-of-central-directory record. */
async function readEocd(
blob: Blob,
): Promise<{ offset: number; size: number } | null> {
const tailSize = Math.min(blob.size, EOCD_SEARCH_LIMIT);
const tail = new DataView(
await blob.slice(blob.size - tailSize).arrayBuffer(),
);
for (let i = tailSize - EOCD_MIN_SIZE; i >= 0; i--) {
if (tail.getUint32(i, true) !== EOCD_SIGNATURE) continue;
return {
size: tail.getUint32(i + 12, true),
offset: tail.getUint32(i + 16, true),
};
}
return null;
}
function parseCentralDirectory(bytes: ArrayBuffer): CentralEntry[] {
const view = new DataView(bytes);
const decoder = new TextDecoder();
const entries: CentralEntry[] = [];
let cursor = 0;
while (cursor + CENTRAL_HEADER_SIZE <= view.byteLength) {
if (view.getUint32(cursor, true) !== CENTRAL_HEADER_SIGNATURE) break;
const nameLength = view.getUint16(cursor + 28, true);
const extraLength = view.getUint16(cursor + 30, true);
const commentLength = view.getUint16(cursor + 32, true);
entries.push({
name: decoder.decode(
new Uint8Array(bytes, cursor + CENTRAL_HEADER_SIZE, nameLength),
),
method: view.getUint16(cursor + 10, true),
compressedSize: view.getUint32(cursor + 20, true),
localHeaderOffset: view.getUint32(cursor + 42, true),
});
cursor += CENTRAL_HEADER_SIZE + nameLength + extraLength + commentLength;
}
return entries;
}
/**
* The local header carries its own extra field, whose length can differ from the central copy, so
* the payload offset has to be read from the local header rather than assumed.
*/
async function dataOffset(blob: Blob, entry: CentralEntry): Promise<number> {
const header = new DataView(
await blob
.slice(
entry.localHeaderOffset,
entry.localHeaderOffset + LOCAL_HEADER_SIZE,
)
.arrayBuffer(),
);
return (
entry.localHeaderOffset +
LOCAL_HEADER_SIZE +
header.getUint16(26, true) +
header.getUint16(28, true)
);
}
async function inflateRaw(part: Blob): Promise<ArrayBuffer> {
const stream = part
.stream()
.pipeThrough(new DecompressionStream("deflate-raw"));
return new Response(stream).arrayBuffer();
}
/** A stored entry is returned as a view, so the PDF is never copied. */
async function readEntry(
blob: Blob,
entry: CentralEntry,
type: string,
): Promise<Blob> {
const start = await dataOffset(blob, entry);
const part = blob.slice(start, start + entry.compressedSize, type);
if (entry.method === METHOD_STORED) return part;
return new Blob([await readEntryBytes(blob, entry)], { type });
}
async function readEntryBytes(
blob: Blob,
entry: CentralEntry,
): Promise<ArrayBuffer> {
const start = await dataOffset(blob, entry);
const part = blob.slice(start, start + entry.compressedSize);
if (entry.method === METHOD_STORED) return part.arrayBuffer();
if (entry.method !== METHOD_DEFLATED) {
throw new Error(`Unsupported ZIP compression method ${entry.method}`);
}
return inflateRaw(part);
}
/**
* Null means the body is a plain PDF, so the caller keeps it and fetches fields separately. A body
* that is an archive but unreadable throws: it is not the document, and saving it would destroy one.
*/
export async function readFieldBundle(blob: Blob): Promise<FieldBundle | null> {
if (!(await looksZipped(blob))) return null;
const eocd = await readEocd(blob);
if (!eocd) {
throw new Error("Edited PDF bundle is a truncated or corrupt archive");
}
const directory = parseCentralDirectory(
await blob.slice(eocd.offset, eocd.offset + eocd.size).arrayBuffer(),
);
const documentEntry = directory.find(
(entry) => entry.name === DOCUMENT_ENTRY,
);
const fieldsEntry = directory.find((entry) => entry.name === FIELDS_ENTRY);
if (!documentEntry || !fieldsEntry) {
throw new Error(
`Edited PDF bundle is missing ${DOCUMENT_ENTRY} or ${FIELDS_ENTRY}`,
);
}
const [pdf, fields] = await Promise.all([
readEntry(blob, documentEntry, "application/pdf"),
readEntryBytes(blob, fieldsEntry),
]);
const parsed: unknown = JSON.parse(new TextDecoder().decode(fields));
if (!Array.isArray(parsed)) {
throw new Error(`Edited PDF bundle's ${FIELDS_ENTRY} is not a list`);
}
return { pdf, fields: parsed as FormField[] };
}
@@ -2,7 +2,18 @@
* API service for form-related backend calls.
*/
import apiClient from "@app/services/apiClient";
import type { FormField } from "@app/tools/formFill/types";
import {
readFieldBundle,
supportsFieldBundle,
} from "@app/tools/formFill/fieldBundle";
import type {
FormField,
NewFieldDefinition,
ModifyFieldDefinition,
FieldEditBatch,
FieldEditResult,
SkippedFieldEdit,
} from "@app/tools/formFill/types";
/**
* Fetch form fields with coordinates from the backend.
@@ -89,3 +100,116 @@ export async function extractFormFieldsXlsx(
});
return response.data;
}
/** POST /api/v1/form/add-fields: create fields, returns the updated PDF blob. */
export async function addFormFields(
file: File | Blob,
fields: NewFieldDefinition[],
): Promise<Blob> {
const formData = new FormData();
formData.append("file", file);
formData.append(
"fields",
new Blob([JSON.stringify(fields)], { type: "application/json" }),
);
const response = await apiClient.post("/api/v1/form/add-fields", formData, {
responseType: "blob",
});
return response.data;
}
/** POST /api/v1/form/modify-fields: rename/retype/move/resize, returns the updated PDF blob. */
export async function modifyFormFields(
file: File | Blob,
updates: ModifyFieldDefinition[],
): Promise<Blob> {
const formData = new FormData();
formData.append("file", file);
formData.append(
"updates",
new Blob([JSON.stringify(updates)], { type: "application/json" }),
);
const response = await apiClient.post(
"/api/v1/form/modify-fields",
formData,
{ responseType: "blob" },
);
return response.data;
}
/**
* POST /api/v1/form/edit-fields: add + modify + delete in one request. Asks for the field list in
* the same response where the browser can unpack it, which saves uploading the result back again.
*/
export async function applyFieldEdits(
file: File | Blob,
batch: FieldEditBatch,
): Promise<FieldEditResult> {
const formData = new FormData();
formData.append("file", file);
formData.append(
"edits",
new Blob([JSON.stringify(batch)], { type: "application/json" }),
);
// Off for now. Skipping the follow-up fetch changes when the viewer settles its page scale:
// the page renders at ~1.5x and then stops responding to zoom. The reader and the endpoint are
// both tested and ready; this flips back on once that interaction is understood.
const BUNDLE_ENABLED = false;
const includeFields = BUNDLE_ENABLED && supportsFieldBundle();
const response = await apiClient.post("/api/v1/form/edit-fields", formData, {
responseType: "blob",
params: includeFields ? { includeFields: true } : undefined,
});
const result: FieldEditResult = {
blob: response.data,
skipped: parseSkippedEdits(response.headers?.[SKIPPED_EDITS_HEADER]),
skippedTotal: Number(response.headers?.[SKIPPED_EDITS_TOTAL_HEADER]) || 0,
};
if (!includeFields) return result;
// Null only when the backend ignored the flag and sent a bare PDF; an unreadable archive throws
// rather than letting the caller save the archive over the user's document.
const bundle = await readFieldBundle(response.data);
return bundle
? { ...result, blob: bundle.pdf, fields: bundle.fields }
: result;
}
/** Set by the backend when it could not apply every requested edit. */
const SKIPPED_EDITS_HEADER = "x-stirling-skipped-field-edits";
const SKIPPED_EDITS_TOTAL_HEADER = "x-stirling-skipped-field-edits-total";
/** Base64 JSON, so a reason containing spaces or non-ASCII survives the header intact. */
function parseSkippedEdits(raw: unknown): SkippedFieldEdit[] {
if (typeof raw !== "string" || !raw) return [];
try {
const bytes = Uint8Array.from(atob(raw), (c) => c.charCodeAt(0));
const parsed: unknown = JSON.parse(new TextDecoder().decode(bytes));
return Array.isArray(parsed) ? (parsed as SkippedFieldEdit[]) : [];
} catch {
return [];
}
}
/** POST /api/v1/form/delete-fields: delete by name, returns the updated PDF blob. */
export async function deleteFormFields(
file: File | Blob,
names: string[],
): Promise<Blob> {
const formData = new FormData();
formData.append("file", file);
formData.append(
"names",
new Blob([JSON.stringify(names)], { type: "application/json" }),
);
const response = await apiClient.post(
"/api/v1/form/delete-fields",
formData,
{ responseType: "blob" },
);
return response.data;
}
@@ -0,0 +1,187 @@
/**
* Three spaces: page pixels (top-left), WidgetCoordinates points (top-left, CropBox-relative),
* and add/modify-fields points (lower-left, CropBox-relative). pageHeightPts is CropBox height.
*/
import type {
FormField,
ModifyFieldDefinition,
} from "@app/tools/formFill/types";
export interface PixelRect {
left: number;
top: number;
width: number;
height: number;
}
/** CropBox-relative, lower-left-origin PDF points (backend create/modify space). */
export interface PdfRect {
x: number;
y: number;
width: number;
height: number;
}
/** Top-left-origin PDF points, as stored on a WidgetCoordinates. */
export interface WidgetRect {
x: number;
y: number;
width: number;
height: number;
}
/** Convert a widget's top-left-origin point rect to pixel space for rendering. */
export function widgetRectToPixels(
widget: WidgetRect,
scaleX: number,
scaleY: number,
): PixelRect {
return {
left: widget.x * scaleX,
top: widget.y * scaleY,
width: widget.width * scaleX,
height: widget.height * scaleY,
};
}
/** Pixel rect (top-left) to backend PDF points (lower-left, CropBox-relative). */
export function pixelsToBackendRect(
rect: PixelRect,
scaleX: number,
scaleY: number,
pageHeightPts: number,
): PdfRect {
const xPts = rect.left / scaleX;
const widthPts = rect.width / scaleX;
const heightPts = rect.height / scaleY;
const topPts = rect.top / scaleY; // distance from page top, in points
// Flip to lower-left origin: y measures page bottom to the field's bottom edge.
const yPts = pageHeightPts - topPts - heightPts;
return { x: xPts, y: yPts, width: widthPts, height: heightPts };
}
/** Backend PDF points (lower-left, CropBox-relative) to a pixel rect (top-left). */
export function backendRectToPixels(
rect: PdfRect,
scaleX: number,
scaleY: number,
pageHeightPts: number,
): PixelRect {
const topPts = pageHeightPts - rect.y - rect.height;
return {
left: rect.x * scaleX,
top: topPts * scaleY,
width: rect.width * scaleX,
height: rect.height * scaleY,
};
}
/** Clamp a pixel rect so it stays within the page bounds. */
export function clampPixelRect(
rect: PixelRect,
pageWidthPx: number,
pageHeightPx: number,
): PixelRect {
const width = Math.min(rect.width, pageWidthPx);
const height = Math.min(rect.height, pageHeightPx);
const left = Math.max(0, Math.min(rect.left, pageWidthPx - width));
const top = Math.max(0, Math.min(rect.top, pageHeightPx - height));
return { left, top, width, height };
}
/** Round a PdfRect's components to a sane precision before sending to the API. */
export function roundPdfRect(rect: PdfRect): PdfRect {
const r = (n: number) => Math.round(n * 100) / 100;
return {
x: r(rect.x),
y: r(rect.y),
width: r(rect.width),
height: r(rect.height),
};
}
/**
* A field with its staged edit applied, back in the top-left widget space the extractor produces.
* Mirrors updateWidgetGeometry: the delta moves every widget on the anchor page, but only the
* first widget takes the new size.
*/
export function applyStagedGeometry(
field: FormField,
staged: ModifyFieldDefinition,
): FormField {
const next: FormField = {
...field,
type: staged.type ?? field.type,
options: staged.options ?? field.options,
readOnly: staged.readOnly ?? field.readOnly,
multiline: staged.multiline ?? field.multiline,
};
const widgets = field.widgets;
const anchor = widgets?.[0];
if (
!widgets ||
!anchor ||
staged.x == null ||
staged.y == null ||
staged.width == null ||
staged.height == null ||
anchor.cropBoxHeight == null
) {
return next;
}
const top = anchor.cropBoxHeight - staged.y - staged.height;
const dx = staged.x - anchor.x;
const dy = top - anchor.y;
next.widgets = widgets.map((w, i) =>
w.pageIndex === anchor.pageIndex
? {
...w,
x: w.x + dx,
y: w.y + dy,
width: i === 0 ? staged.width! : w.width,
height: i === 0 ? staged.height! : w.height,
}
: w,
);
return next;
}
/**
* Per-option rects inside the group box, in the box's own units. Mirrors FormUtils.
* radioOptionRects exactly - if these two drift, the preview stops matching the applied PDF.
*/
export function radioOptionRects(
box: { width: number; height: number },
count: number,
gapOverride?: number | null,
sizeOverride?: number | null,
): { top: number; size: number }[] {
const n = Math.max(1, count);
const h = box.height;
const slot = h / n;
let size: number;
if (sizeOverride != null && sizeOverride > 0) {
size = sizeOverride;
} else if (gapOverride != null && gapOverride >= 0) {
size = (h - (n - 1) * gapOverride) / n;
} else {
size = slot * 0.75;
}
size = Math.max(1, Math.min(size, box.width));
const gap =
gapOverride != null && gapOverride >= 0
? gapOverride
: n > 1
? Math.max(0, (h - n * size) / (n - 1))
: 0;
return Array.from({ length: n }, (_, i) => ({
top: i * (size + gap),
size,
}));
}
@@ -0,0 +1,22 @@
/**
* Form overlay palette, deliberately not per-type: slate = existing, blue = selected/new, red =
* delete. Field type is conveyed by the side-panel icon, not by a fill colour on the page.
*/
export const FORM_COLORS = {
/** Selected / active / newly-drawn fields. */
accent: "#2563eb",
accentFillSoft: "rgba(37, 99, 235, 0.06)",
accentFill: "rgba(37, 99, 235, 0.10)",
/** Existing (unselected) fields - quiet slate so the page stays readable. */
neutralBorder: "rgba(71, 85, 105, 0.55)",
neutralFill: "rgba(71, 85, 105, 0.05)",
neutralChip: "#475569",
/** Fields marked for deletion. */
danger: "#dc2626",
dangerFill: "rgba(220, 38, 38, 0.08)",
/** Alignment guides (thin lines, shown only while dragging). */
guide: "#2563eb",
} as const;
@@ -0,0 +1,67 @@
import { describe, it, expect } from "vitest";
import { mergeSignatureAppearances } from "@app/tools/formFill/formFieldMerge";
import type { FormField } from "@app/tools/formFill/types";
function field(name: string, type: FormField["type"]): FormField {
return {
name,
label: name,
type,
value: "",
options: null,
displayOptions: null,
required: false,
readOnly: false,
multiSelect: false,
multiline: false,
tooltip: null,
widgets: [{ pageIndex: 0, x: 0, y: 0, width: 10, height: 10 }],
};
}
describe("mergeSignatureAppearances", () => {
it("does not duplicate a signature the backend already returned", () => {
// The backend now returns signature fields; the pdfium pass returns the
// same field with a rendered appearance. They must merge to ONE entry.
const backend = [field("FullName", "text"), field("Sign", "signature")];
const sig = {
...field("Sign", "signature"),
appearanceDataUrl: "data:img",
};
const merged = mergeSignatureAppearances(backend, [sig]);
expect(merged).toHaveLength(2);
expect(merged.filter((f) => f.name === "Sign")).toHaveLength(1);
// …and the surviving entry is enriched with the rendered appearance.
expect(merged.find((f) => f.name === "Sign")?.appearanceDataUrl).toBe(
"data:img",
);
});
it("appends a signature the backend did not return", () => {
const backend = [field("FullName", "text")];
const sig = { ...field("Ghost", "signature"), appearanceDataUrl: "data:x" };
const merged = mergeSignatureAppearances(backend, [sig]);
expect(merged).toHaveLength(2);
expect(merged.map((f) => f.name)).toEqual(["FullName", "Ghost"]);
});
it("returns the backend list unchanged when there are no signatures", () => {
const backend = [field("A", "text"), field("B", "checkbox")];
expect(mergeSignatureAppearances(backend, [])).toBe(backend);
});
it("does not overwrite an existing appearance", () => {
const backend = [
{ ...field("Sign", "signature"), appearanceDataUrl: "keep" },
];
const sig = { ...field("Sign", "signature"), appearanceDataUrl: "new" };
const merged = mergeSignatureAppearances(backend, [sig]);
expect(merged.find((f) => f.name === "Sign")?.appearanceDataUrl).toBe(
"keep",
);
});
});
@@ -0,0 +1,30 @@
/**
* The backend returns signature fields but cannot render their appearance; PDFium rasterises it
* separately. Merge the two BY NAME, never concatenate, or the signature is listed twice.
*/
import type { FormField } from "@app/tools/formFill/types";
/** Copies each pdfium `appearanceDataUrl` onto the same-named backend field, appending unmatched ones. */
export function mergeSignatureAppearances(
backendFields: FormField[],
signatureFields: FormField[],
): FormField[] {
if (signatureFields.length === 0) return backendFields;
const merged = backendFields.map((f) => ({ ...f }));
const byName = new Map(merged.map((f) => [f.name, f]));
for (const sig of signatureFields) {
const existing = byName.get(sig.name);
if (existing) {
if (sig.appearanceDataUrl && !existing.appearanceDataUrl) {
existing.appearanceDataUrl = sig.appearanceDataUrl;
}
} else {
merged.push({ ...sig });
byName.set(sig.name, merged[merged.length - 1]);
}
}
return merged;
}
@@ -0,0 +1,13 @@
/** Hands a produced PDF blob to EmbedPdfViewer, which reloads the file preserving scroll/rotation. */
export const FORM_APPLY_EVENT = "formfill:apply";
export interface FormApplyDetail {
blob: Blob;
}
/** Dispatch a produced PDF blob to the viewer for reload + refresh. */
export function dispatchFormApply(blob: Blob): void {
window.dispatchEvent(
new CustomEvent<FormApplyDetail>(FORM_APPLY_EVENT, { detail: { blob } }),
);
}
@@ -0,0 +1,130 @@
/** Alignment snapping in page pixel space (top-left origin); matched lines come back as guides. */
import type { PixelRect } from "@app/tools/formFill/formCoordinateUtils";
export const DEFAULT_SNAP_THRESHOLD = 6;
export interface SnapGuide {
/** "v" = vertical line at x; "h" = horizontal line at y. */
orientation: "v" | "h";
/** Pixel offset of the line within the page. */
position: number;
}
export interface SnapTargets {
/** Candidate vertical lines (left/right/centre of other fields). */
xs: number[];
/** Candidate horizontal lines (top/bottom/middle of other fields). */
ys: number[];
}
/** Build snap targets from the pixel rects of the other fields on a page. */
export function collectSnapTargets(rects: PixelRect[]): SnapTargets {
const xs: number[] = [];
const ys: number[] = [];
for (const r of rects) {
xs.push(r.left, r.left + r.width, r.left + r.width / 2);
ys.push(r.top, r.top + r.height, r.top + r.height / 2);
}
return { xs, ys };
}
function nearest(
value: number,
targets: number[],
threshold: number,
): { snapped: number; delta: number } | null {
let best: { snapped: number; delta: number } | null = null;
for (const t of targets) {
const delta = t - value;
if (
Math.abs(delta) <= threshold &&
(!best || Math.abs(delta) < Math.abs(best.delta))
) {
best = { snapped: t, delta };
}
}
return best;
}
/** Snap a moving rectangle (size fixed) and report the matched guides. */
export function snapMove(
rect: PixelRect,
targets: SnapTargets,
threshold = DEFAULT_SNAP_THRESHOLD,
): { left: number; top: number; guides: SnapGuide[] } {
const guides: SnapGuide[] = [];
let { left, top } = rect;
// X axis: try left edge, right edge, then centre.
const xCandidates = [left, left + rect.width, left + rect.width / 2];
let xSnap: { snapped: number; delta: number } | null = null;
for (const c of xCandidates) {
const hit = nearest(c, targets.xs, threshold);
if (hit && (!xSnap || Math.abs(hit.delta) < Math.abs(xSnap.delta)))
xSnap = hit;
}
if (xSnap) {
left += xSnap.delta;
guides.push({ orientation: "v", position: xSnap.snapped });
}
const yCandidates = [top, top + rect.height, top + rect.height / 2];
let ySnap: { snapped: number; delta: number } | null = null;
for (const c of yCandidates) {
const hit = nearest(c, targets.ys, threshold);
if (hit && (!ySnap || Math.abs(hit.delta) < Math.abs(ySnap.delta)))
ySnap = hit;
}
if (ySnap) {
top += ySnap.delta;
guides.push({ orientation: "h", position: ySnap.snapped });
}
return { left, top, guides };
}
/** Snap a resizing rectangle: only the sides flagged in `edges` move, opposites stay put. */
export function snapResize(
rect: PixelRect,
edges: { left?: boolean; right?: boolean; top?: boolean; bottom?: boolean },
targets: SnapTargets,
threshold = DEFAULT_SNAP_THRESHOLD,
): { rect: PixelRect; guides: SnapGuide[] } {
const guides: SnapGuide[] = [];
let { left, top, width, height } = rect;
const right = left + width;
const bottom = top + height;
if (edges.left) {
const hit = nearest(left, targets.xs, threshold);
if (hit) {
left = hit.snapped;
width = right - left;
guides.push({ orientation: "v", position: hit.snapped });
}
}
if (edges.right) {
const hit = nearest(right, targets.xs, threshold);
if (hit) {
width = hit.snapped - left;
guides.push({ orientation: "v", position: hit.snapped });
}
}
if (edges.top) {
const hit = nearest(top, targets.ys, threshold);
if (hit) {
top = hit.snapped;
height = bottom - top;
guides.push({ orientation: "h", position: hit.snapped });
}
}
if (edges.bottom) {
const hit = nearest(bottom, targets.ys, threshold);
if (hit) {
height = hit.snapped - top;
guides.push({ orientation: "h", position: hit.snapped });
}
}
return { rect: { left, top, width, height }, guides };
}
@@ -0,0 +1,20 @@
/**
* A field the user has drawn but not yet applied has no PDF name, so it borrows the selection
* channel committed fields use. The prefix is readable on purpose - it reaches test ids and logs -
* and a real field would have to be named this exactly to collide.
*/
const PENDING_PREFIX = "__pending__:";
export function pendingSelectionName(id: string): string {
return PENDING_PREFIX + id;
}
export function pendingIdFrom(name: string | null | undefined): string | null {
if (!name || !name.startsWith(PENDING_PREFIX)) return null;
return name.slice(PENDING_PREFIX.length);
}
/** True for a selection that has no PDF field behind it yet. */
export function isPendingSelection(name: string | null | undefined): boolean {
return pendingIdFrom(name) != null;
}
@@ -0,0 +1,40 @@
import { describe, expect, it } from "vitest";
import { radioOptionRects } from "@app/tools/formFill/formCoordinateUtils";
/**
* These mirror FormUtilsRadioCaptionTest on the Java side. If the two layouts drift, the create
* preview stops matching the PDF that gets written, which is the bug this rule was added to fix.
*/
describe("radioOptionRects", () => {
it("fills exactly the drawn height", () => {
const rows = radioOptionRects({ width: 100, height: 90 }, 3);
expect(rows).toHaveLength(3);
const extent = rows[2].top + rows[2].size - rows[0].top;
expect(extent).toBeCloseTo(90, 5);
expect(rows[0].top).toBe(0);
});
it("agrees with the backend for the default 3-option case", () => {
// Java: size 22.5, gap 11.25 for a 100x90 box.
const rows = radioOptionRects({ width: 100, height: 90 }, 3);
expect(rows[0].size).toBeCloseTo(22.5, 5);
expect(rows[1].top - (rows[0].top + rows[0].size)).toBeCloseTo(11.25, 5);
});
it("uses an explicit size and gap verbatim", () => {
const rows = radioOptionRects({ width: 100, height: 90 }, 3, 20, 14);
expect(rows.every((r) => r.size === 14)).toBe(true);
expect(rows[1].top - (rows[0].top + rows[0].size)).toBeCloseTo(20, 5);
});
it("never lets an option exceed the box width", () => {
const rows = radioOptionRects({ width: 8, height: 90 }, 3);
expect(rows.every((r) => r.size <= 8)).toBe(true);
});
it("handles a single option", () => {
const rows = radioOptionRects({ width: 40, height: 40 }, 1);
expect(rows).toHaveLength(1);
expect(rows[0].size).toBeLessThanOrEqual(40);
});
});
@@ -13,6 +13,8 @@ export interface WidgetCoordinates {
exportValue?: string;
/** Font size in PDF points */
fontSize?: number;
/** CropBox height in PDF points; lets the editor reverse the backend's Y-flip when sending coordinates back. */
cropBoxHeight?: number;
}
export interface FormField {
@@ -34,8 +36,16 @@ export interface FormField {
buttonLabel?: string | null;
/** Action descriptor for push buttons */
buttonAction?: ButtonAction | null;
/** Same action as the editable spec string the backend round-trips ("reset", "uri:<url>", ...) */
buttonActionSpec?: string | null;
/** Text field /MaxLen; >0 also makes it a comb field */
maxLength?: number | null;
/** Pre-rendered appearance image for signed signature fields (data URL). */
appearanceDataUrl?: string;
/** Gap between radio options in points; derived from the drawn box when unset. */
optionGap?: number;
/** Radio option size in points; derived from the drawn box when unset. */
optionSize?: number;
}
export type FormFieldType =
@@ -67,6 +77,115 @@ export interface ButtonAction {
submitFlags?: number;
}
/** Field types that can be created/edited structurally through the editor. */
export type CreatableFieldType =
| "text"
| "checkbox"
| "combobox"
| "listbox"
| "radio"
| "button"
| "signature";
export const CREATABLE_FIELD_TYPES: CreatableFieldType[] = [
"text",
"checkbox",
"combobox",
"listbox",
"radio",
"button",
"signature",
];
/**
* A new field queued for creation. Coordinates are CropBox-relative,
* lower-left-origin PDF points - the reverse of what WidgetCoordinates carries.
*/
export interface NewFieldDefinition {
name: string;
label?: string;
type: CreatableFieldType;
pageIndex: number;
x: number;
y: number;
width: number;
height: number;
required?: boolean;
multiSelect?: boolean;
options?: string[];
defaultValue?: string;
tooltip?: string;
fontSize?: number;
readOnly?: boolean;
multiline?: boolean;
maxLength?: number; // text only; >0 also makes it a comb field
/** Push-button activation action: "reset" | "print" | "uri:<url>" | "submit:<url>" */
buttonAction?: string;
/** Gap between radio options in points; derived from the drawn box when unset. */
optionGap?: number;
/** Radio option size in points; derived from the drawn box when unset. */
optionSize?: number;
}
/**
* A change to an existing field. Only non-undefined properties are applied.
* Coordinates (when present) are CropBox-relative, lower-left-origin PDF points.
*/
export interface ModifyFieldDefinition {
targetName: string;
name?: string;
label?: string;
type?: FormFieldType;
pageIndex?: number;
x?: number;
y?: number;
width?: number;
height?: number;
required?: boolean;
multiSelect?: boolean;
options?: string[];
defaultValue?: string;
tooltip?: string;
fontSize?: number;
readOnly?: boolean;
multiline?: boolean;
maxLength?: number; // text only; >0 also makes it a comb field
/** Push-button activation action: "reset" | "print" | "uri:<url>" | "submit:<url>" */
buttonAction?: string;
/** Gap between radio options in points; derived from the drawn box when unset. */
optionGap?: number;
/** Radio option size in points; derived from the drawn box when unset. */
optionSize?: number;
}
/** A batch of field edits committed in one request via /api/v1/form/edit-fields. */
export interface FieldEditBatch {
add?: NewFieldDefinition[];
modify?: ModifyFieldDefinition[];
delete?: string[];
}
/** One requested edit the document could not take. The rest of the batch still applied. */
export interface SkippedFieldEdit {
operation: "add" | "modify" | "delete";
target?: string | null;
reason?: string | null;
}
/** The updated PDF plus whatever the backend had to drop. */
export interface FieldEditResult {
blob: Blob;
/** Capped at 20 entries so the response header stays inside Jetty's budget. */
skipped: SkippedFieldEdit[];
/** How many were skipped in total, which may exceed skipped.length. */
skippedTotal: number;
/** Present when the backend bundled the field list in, saving a second upload. */
fields?: FormField[];
}
/** The form tool's working mode. */
export type FormMode = "fill" | "create" | "modify";
export interface FormFillState {
/** Fields fetched from backend with coordinates */
fields: FormField[];
@@ -0,0 +1,138 @@
/**
* Delete, copy and paste for the selected form field, in both create and modify mode.
*/
import { useEffect, useRef } from "react";
import { useFormFill } from "@app/tools/formFill/FormFillContext";
import { isTextEntryTarget } from "@app/tools/formFill/usePageScale";
import {
pendingIdFrom,
pendingSelectionName,
} from "@app/tools/formFill/pendingSelection";
import type {
CreatableFieldType,
NewFieldDefinition,
} from "@app/tools/formFill/types";
/** Enough offset that the copy is visibly its own field rather than hiding the original. */
const PASTE_OFFSET_PT = 12;
type Copied = Omit<NewFieldDefinition, "name"> & { name?: string };
export function useFieldShortcuts() {
const {
mode,
state,
selectedFieldName,
setSelectedField,
pendingFields,
addPendingField,
removePendingField,
toggleFieldDeleted,
undo,
} = useFormFill();
const clipboardRef = useRef<Copied | null>(null);
// Refs, so the listener is installed once instead of on every selection change.
const latest = useRef({
mode,
state,
selectedFieldName,
setSelectedField,
pendingFields,
addPendingField,
removePendingField,
toggleFieldDeleted,
undo,
});
latest.current = {
mode,
state,
selectedFieldName,
setSelectedField,
pendingFields,
addPendingField,
removePendingField,
toggleFieldDeleted,
undo,
};
useEffect(() => {
const onKeyDown = (event: KeyboardEvent) => {
const ctx = latest.current;
if (ctx.mode === "fill") return;
// Never steal a shortcut from a field the user is typing in.
if (isTextEntryTarget(event.target)) return;
const selected = ctx.selectedFieldName;
const pendingId = pendingIdFrom(selected);
const copyOrPaste = event.ctrlKey || event.metaKey;
// Undo steps back through the staged edits, newest first.
if (copyOrPaste && event.key.toLowerCase() === "z" && !event.shiftKey) {
if (ctx.undo()) event.preventDefault();
return;
}
if (copyOrPaste && event.key.toLowerCase() === "c") {
if (!selected) return;
const pending = pendingId
? ctx.pendingFields.find((f) => f.id === pendingId)
: null;
if (pending) {
const { id: _id, ...rest } = pending;
clipboardRef.current = rest;
event.preventDefault();
return;
}
const field = ctx.state.fields.find((f) => f.name === selected);
const widget = field?.widgets?.[0];
if (!field || !widget) return;
clipboardRef.current = {
name: field.name,
type: field.type as CreatableFieldType,
pageIndex: widget.pageIndex,
x: widget.x,
y: widget.y,
width: widget.width,
height: widget.height,
options: field.options ?? undefined,
required: field.required,
multiline: field.multiline,
};
event.preventDefault();
return;
}
if (copyOrPaste && event.key.toLowerCase() === "v") {
const copied = clipboardRef.current;
if (!copied) return;
// The name is dropped: two fields cannot share one, and the queue names it.
const { name: _name, ...geometry } = copied;
const id = ctx.addPendingField({
...geometry,
x: geometry.x + PASTE_OFFSET_PT,
y: geometry.y - PASTE_OFFSET_PT,
});
ctx.setSelectedField(pendingSelectionName(id));
event.preventDefault();
return;
}
if (event.key === "Delete" || event.key === "Backspace") {
if (!selected) return;
if (pendingId) {
ctx.removePendingField(pendingId);
} else {
ctx.toggleFieldDeleted(selected);
}
ctx.setSelectedField(null);
event.preventDefault();
}
};
window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
}, []);
}
@@ -0,0 +1,64 @@
/**
* Shared commit flow for the create and modify panels: run an action producing
* the edited PDF blob, hand it to the viewer, track committing/error state.
*/
import { useState, useCallback } from "react";
import { useTranslation } from "react-i18next";
import { isAxiosError } from "axios";
import { dispatchFormApply } from "@app/tools/formFill/formFillEvents";
/**
* responseType "blob" means an error's ProblemDetail arrives as a Blob, so read
* the body to surface which field the backend refused.
*/
export async function serverMessage(err: unknown): Promise<string | null> {
if (!isAxiosError(err)) return null;
const data: unknown = err.response?.data;
try {
const text = data instanceof Blob ? await data.text() : null;
const parsed: unknown = text ? JSON.parse(text) : data;
if (parsed && typeof parsed === "object") {
const body = parsed as Record<string, unknown>;
for (const key of ["detail", "message", "error", "title"]) {
if (typeof body[key] === "string" && body[key]) return body[key];
}
}
return typeof text === "string" && text.trim() ? text.trim() : null;
} catch {
return null;
}
}
export function useFormCommit(onApplied?: (blob: Blob) => void) {
const { t } = useTranslation();
const [committing, setCommitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const commit = useCallback(
async (
run: () => Promise<Blob>,
errorKey: string,
errorFallback: string,
) => {
setCommitting(true);
setError(null);
try {
const blob = await run();
dispatchFormApply(blob);
onApplied?.(blob);
} catch (err) {
setError(
(await serverMessage(err)) ||
(err instanceof Error ? err.message : undefined) ||
t(errorKey, errorFallback),
);
console.error("[FormFill] commit failed:", err);
} finally {
setCommitting(false);
}
},
[onApplied, t],
);
return { committing, error, setError, commit };
}
@@ -0,0 +1,103 @@
/**
* Overlays sit inside EmbedPDF's <Rotate>, but getBoundingClientRect reports the
* axis-aligned screen box; these cases invert plugin-rotate's getRotationMatrix.
*/
import { describe, it, expect } from "vitest";
import {
getLocalPoint,
isTextEntryTarget,
} from "@app/tools/formFill/usePageScale";
/** An element whose un-rotated box is w x h, as it appears on screen after `turns`. */
function rotatedElement(w: number, h: number, turns: number): HTMLElement {
const swapped = turns === 1 || turns === 3;
const rect = {
left: 0,
top: 0,
width: swapped ? h : w,
height: swapped ? w : h,
};
return {
getBoundingClientRect: () => rect as DOMRect,
} as unknown as HTMLElement;
}
/** Forward transform: local -> screen, straight from EmbedPDF's matrix. */
function toScreen(x: number, y: number, w: number, h: number, turns: number) {
switch (turns) {
case 1:
return { clientX: h - y, clientY: x };
case 2:
return { clientX: w - x, clientY: h - y };
case 3:
return { clientX: y, clientY: w - x };
default:
return { clientX: x, clientY: y };
}
}
describe("getLocalPoint", () => {
const W = 400;
const H = 600;
const points = [
[0, 0],
[W, 0],
[0, H],
[W, H],
[123, 456],
];
for (const turns of [0, 1, 2, 3]) {
it(`inverts EmbedPDF's transform at ${turns} quarter turn(s)`, () => {
const el = rotatedElement(W, H, turns);
for (const [x, y] of points) {
const local = getLocalPoint(toScreen(x, y, W, H, turns), el, turns);
expect(local.x).toBeCloseTo(x, 5);
expect(local.y).toBeCloseTo(y, 5);
}
});
}
it("treats rotation as quarter turns, not degrees", () => {
// EmbedPDF's Rotation enum is Degree90 = 1; 90 would normalise to 2 and flip
// the page the wrong way, so 90 and 1 must NOT agree.
const el = rotatedElement(W, H, 1);
const screen = toScreen(10, 20, W, H, 1);
expect(getLocalPoint(screen, el, 1)).not.toEqual(
getLocalPoint(screen, el, 90),
);
});
it("normalises out-of-range and negative rotations", () => {
const el = rotatedElement(W, H, 1);
const screen = toScreen(10, 20, W, H, 1);
expect(getLocalPoint(screen, el, 5)).toEqual(getLocalPoint(screen, el, 1));
expect(getLocalPoint(screen, el, -3)).toEqual(getLocalPoint(screen, el, 1));
});
it("returns the origin when the element is gone", () => {
expect(getLocalPoint({ clientX: 5, clientY: 5 }, null)).toEqual({
x: 0,
y: 0,
});
});
});
describe("isTextEntryTarget", () => {
it("claims inputs, textareas, selects and contenteditable", () => {
for (const tag of ["input", "textarea", "select"]) {
expect(isTextEntryTarget(document.createElement(tag))).toBe(true);
}
const editable = document.createElement("div");
editable.contentEditable = "true";
// jsdom does not implement isContentEditable, so assert on the real getter.
Object.defineProperty(editable, "isContentEditable", { value: true });
expect(isTextEntryTarget(editable)).toBe(true);
});
it("leaves ordinary elements and non-elements alone", () => {
expect(isTextEntryTarget(document.createElement("div"))).toBe(false);
expect(isTextEntryTarget(null)).toBe(false);
expect(isTextEntryTarget(new EventTarget())).toBe(false);
});
});
@@ -0,0 +1,92 @@
/** Shared pixel<->PDF-point helpers, on the same basis as FormFieldOverlay so the overlays agree. */
import { useMemo } from "react";
import { useDocumentState } from "@embedpdf/core/react";
export interface PageScale {
scaleX: number;
scaleY: number;
/** CropBox height in PDF points; 0 until the page has rendered. */
pageHeightPts: number;
/** CropBox width in PDF points; 0 until the page has rendered. */
pageWidthPts: number;
/** Page rotation in clockwise quarter turns (0-3), as EmbedPDF's <Rotate> applies it. */
rotation: number;
}
/** Rotation as clockwise quarter turns, matching LocalEmbedPDF's normalizePageRotation. */
function normalizeRotation(rotation: number | null | undefined): number {
const value =
typeof rotation === "number" && Number.isFinite(rotation) ? rotation : 0;
return ((Math.round(value) % 4) + 4) % 4;
}
/**
* `scaleX = pageWidthPx / pageWidthPts`, from EmbedPDF's document state.
* pageWidthPts is 0 until the page has rendered, so guard on it before drawing.
*/
export function usePageScale(
documentId: string,
pageIndex: number,
pageWidth: number,
pageHeight: number,
): PageScale {
const documentState = useDocumentState(documentId);
return useMemo(() => {
const pdfPage = documentState?.document?.pages?.[pageIndex];
// Must match EmbedPDF's <Rotate>, which composes the page's own rotation
// with the viewer-level one; using only the page's is wrong once rotated.
const rotation = normalizeRotation(
(pdfPage?.rotation ?? 0) + (documentState?.rotation ?? 0),
);
if (!pdfPage?.size || !pageWidth || !pageHeight) {
const s = documentState?.scale ?? 1;
return {
scaleX: s,
scaleY: s,
pageHeightPts: 0,
pageWidthPts: 0,
rotation,
};
}
return {
scaleX: pageWidth / pdfPage.size.width,
scaleY: pageHeight / pdfPage.size.height,
pageHeightPts: pdfPage.size.height,
pageWidthPts: pdfPage.size.width,
rotation,
};
}, [documentState, pageIndex, pageWidth, pageHeight]);
}
/**
* Pointer position in the element's own un-rotated pixel space. getBoundingClientRect
* returns the axis-aligned screen box, so under <Rotate> it must be mapped back.
*/
export function getLocalPoint(
e: { clientX: number; clientY: number },
el: HTMLElement | null,
rotation: number = 0,
): { x: number; y: number } {
const rect = el?.getBoundingClientRect();
if (!rect) return { x: 0, y: 0 };
const sx = e.clientX - rect.left;
const sy = e.clientY - rect.top;
switch (normalizeRotation(rotation)) {
case 1:
return { x: sy, y: rect.width - sx };
case 2:
return { x: rect.width - sx, y: rect.height - sy };
case 3:
return { x: rect.height - sy, y: sx };
default:
return { x: sx, y: sy };
}
}
/** True when a key event targets somewhere the user is typing, so shortcuts must stand down. */
export function isTextEntryTarget(target: EventTarget | null): boolean {
if (!(target instanceof HTMLElement)) return false;
if (target.isContentEditable) return true;
const tag = target.tagName;
return tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT";
}