diff --git a/.dockerignore b/.dockerignore index 492480e3a0..12326e5bc5 100644 --- a/.dockerignore +++ b/.dockerignore @@ -96,6 +96,14 @@ configs/ __pycache__/ **/__pycache__/ +# Python virtualenvs. Large, platform-specific, and their symlinks break the build. +.venv/ +**/.venv/ +venv/ +**/venv/ +*.egg-info/ +**/*.egg-info/ + # Local env .env .env.* diff --git a/.editorconfig b/.editorconfig index 665a74a09a..e6bda814c1 100644 --- a/.editorconfig +++ b/.editorconfig @@ -22,26 +22,15 @@ indent_size = 4 [*.html] indent_size = 2 -insert_final_newline = false -trim_trailing_whitespace = false -[{*.js,*.jsx,*.mjs,*.ts,*.tsx}] +[{*.js,*.jsx,*.mjs,*.ts,*.tsx,*.mts}] indent_size = 2 [*.css] -# CSS files typically use an indent size of 2 spaces for better readability and alignment with community standards. indent_size = 2 [*.{yml,yaml}] -# YAML files use an indent size of 2 spaces to maintain consistency with common YAML formatting practices. -indent_size = 2 -insert_final_newline = false -trim_trailing_whitespace = false - -[*.json] -# JSON files use an indent size of 2 spaces, which is the standard for JSON formatting. indent_size = 2 -[*.jsonc] -# JSONC (JSON with comments) files also follow the standard JSON formatting with an indent size of 2 spaces. +[*.{json,jsonc}] indent_size = 2 diff --git a/.github/config/.files.yaml b/.github/config/.files.yaml index b5cc0527b0..7936145677 100644 --- a/.github/config/.files.yaml +++ b/.github/config/.files.yaml @@ -4,10 +4,12 @@ # instead of matching only the project filter. ci: &ci - .github/workflows/build.yml + - .github/workflows/gradle-cache-prime.yml - .github/config/.files.yaml build: &build - *ci + - buildSrc/** - build.gradle - gradle/spotless.gradle - app/(common|core|proprietary|saas)/build.gradle @@ -15,6 +17,22 @@ build: &build - .taskfiles/backend.yml - .github/workflows/check-licence.yml +# Backend build inputs. This is intentionally broader than `build`: Java and +# backend resource changes must exercise the backend matrix even when Gradle +# build scripts themselves are unchanged. +backend: &backend + - *ci + - *build + - gradle/** + - gradle.properties + - gradlew + - gradlew.bat + - settings.gradle + - app/(common|core|proprietary|saas)/src/(main|test)/java/** + - "app/(common|core|proprietary|saas)/src/(main|test)/resources/**/!(messages_*.properties|*.md)*" + - scripts/db-migration/** + - .github/workflows/backend-build.yml + openapi: &openapi - *ci - *build diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 92de1d9503..883c4f7f46 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -65,6 +65,7 @@ updates: directories: - /devTools - /frontend + - /testing/compose/mcp-client-check schedule: interval: "weekly" cooldown: @@ -93,6 +94,13 @@ updates: - "react-dom" - "@types/react" - "@types/react-dom" + tanstack: + patterns: + - "@tanstack/*" + typescript: + patterns: + - "typescript" + - "@typescript/*" vite: patterns: - "vite" @@ -171,14 +179,6 @@ updates: - "tokio" - "tokio-*" - - package-ecosystem: pip - directory: /testing/cucumber - schedule: - interval: "weekly" - cooldown: - default-days: 7 - rebase-strategy: "auto" - - package-ecosystem: "uv" directory: "/engine" schedule: diff --git a/.github/workflows/PR-Auto-Deploy-V2.yml b/.github/workflows/PR-Auto-Deploy-V2.yml index 4375b1b8b0..6420712640 100644 --- a/.github/workflows/PR-Auto-Deploy-V2.yml +++ b/.github/workflows/PR-Auto-Deploy-V2.yml @@ -39,7 +39,7 @@ jobs: pr_ref: ${{ steps.resolve.outputs.ref }} steps: - name: Harden Runner - uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 + uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0 with: egress-policy: audit @@ -121,7 +121,7 @@ jobs: steps: - name: Harden Runner - uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 + uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0 with: egress-policy: audit @@ -475,7 +475,7 @@ jobs: steps: - name: Harden Runner - uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 + uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0 with: egress-policy: audit @@ -560,5 +560,5 @@ jobs: - name: Cleanup temporary files if: always() run: | - rm -f ../private.key + rm -f ../private.key docker-compose.yml storybook.tgz continue-on-error: true diff --git a/.github/workflows/PR-Demo-Comment-with-react.yml b/.github/workflows/PR-Demo-Comment-with-react.yml index 410aa82dc9..111dba441f 100644 --- a/.github/workflows/PR-Demo-Comment-with-react.yml +++ b/.github/workflows/PR-Demo-Comment-with-react.yml @@ -70,7 +70,7 @@ jobs: enable_prototypes: ${{ steps.check-prototypes-flag.outputs.enable_prototypes }} steps: - name: Harden Runner - uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 + uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0 with: egress-policy: audit @@ -177,7 +177,7 @@ jobs: steps: - name: Harden Runner - uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 + uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0 with: egress-policy: audit @@ -482,7 +482,7 @@ jobs: issues: write # add/remove labels, delete the command comment steps: - name: Harden Runner - uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 + uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0 with: egress-policy: audit diff --git a/.github/workflows/PR-Demo-cleanup.yml b/.github/workflows/PR-Demo-cleanup.yml index 098f8d7803..f2912dcedb 100644 --- a/.github/workflows/PR-Demo-cleanup.yml +++ b/.github/workflows/PR-Demo-cleanup.yml @@ -22,7 +22,7 @@ jobs: steps: - name: Harden Runner - uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 + uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0 with: egress-policy: audit diff --git a/.github/workflows/_runner-pick.yml b/.github/workflows/_runner-pick.yml index d65831c5df..ca2337960a 100644 --- a/.github/workflows/_runner-pick.yml +++ b/.github/workflows/_runner-pick.yml @@ -38,7 +38,7 @@ jobs: is_fork: ${{ steps.decide.outputs.is_fork }} steps: - name: Harden the runner (Audit all outbound calls) - uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 + uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0 with: egress-policy: audit diff --git a/.github/workflows/ai-engine.yml b/.github/workflows/ai-engine.yml index 015934030f..caa5af1acb 100644 --- a/.github/workflows/ai-engine.yml +++ b/.github/workflows/ai-engine.yml @@ -20,7 +20,7 @@ jobs: pull-requests: write steps: - name: Harden the runner (Audit all outbound calls) - uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 + uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0 with: egress-policy: audit @@ -28,7 +28,7 @@ jobs: uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Install uv - uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 + uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 with: enable-cache: true cache-dependency-glob: | diff --git a/.github/workflows/aur-publish.yml b/.github/workflows/aur-publish.yml index af0dc85aa6..8c658ec69e 100644 --- a/.github/workflows/aur-publish.yml +++ b/.github/workflows/aur-publish.yml @@ -26,7 +26,7 @@ jobs: jar_sha256: ${{ steps.hashes.outputs.jar_sha256 }} steps: - name: Harden Runner - uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 + uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0 with: egress-policy: audit @@ -71,7 +71,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Harden Runner - uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 + uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0 with: egress-policy: audit diff --git a/.github/workflows/auto-labelerV2.yml b/.github/workflows/auto-labelerV2.yml index 8e6d974f3c..a5ce864d2b 100644 --- a/.github/workflows/auto-labelerV2.yml +++ b/.github/workflows/auto-labelerV2.yml @@ -18,7 +18,7 @@ jobs: issues: write # labels are applied through the issues API steps: - name: Harden Runner - uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 + uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0 with: egress-policy: audit diff --git a/.github/workflows/backend-build.yml b/.github/workflows/backend-build.yml index 596be96e8c..09c6bbc9a8 100644 --- a/.github/workflows/backend-build.yml +++ b/.github/workflows/backend-build.yml @@ -31,7 +31,7 @@ jobs: flavor: [core, proprietary, saas] steps: - name: Harden Runner - uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 + uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0 with: egress-policy: audit - name: Checkout repository @@ -197,7 +197,7 @@ jobs: - name: Install uv if: always() && matrix.flavor == 'saas' - uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 + uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 with: enable-cache: true cache-dependency-glob: | diff --git a/.github/workflows/build-enterprise.yml b/.github/workflows/build-enterprise.yml index 194d86d9da..a5a346db88 100644 --- a/.github/workflows/build-enterprise.yml +++ b/.github/workflows/build-enterprise.yml @@ -58,7 +58,7 @@ jobs: SYSTEM_ENABLEANALYTICS: "false" steps: - name: Harden Runner - uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 + uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0 with: egress-policy: audit - name: Checkout repository @@ -324,6 +324,12 @@ jobs: path: frontend/playwright-report/ retention-days: 7 + - name: Cleanup temporary files + if: always() + run: | + rm -f /tmp/helpers.sh /tmp/backend.log /tmp/backend.pid + continue-on-error: true + # Multi-node regression: builds + seeds the clustered stack (testing/compose/docker-compose-multinode.yml) # and runs behave features/multinode. Licence-gated, so it runs after the Playwright job (not in parallel). multinode-e2e: @@ -345,13 +351,13 @@ jobs: MN_COMPOSE: docker-compose-multinode.yml steps: - name: Harden Runner - uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 + uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0 with: egress-policy: audit - name: Checkout repository uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Install uv - uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 + uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 with: enable-cache: true cache-dependency-glob: | diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 1feaff2560..16279c67f4 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -37,6 +37,7 @@ jobs: timeout-minutes: 3 outputs: build: ${{ steps.changes.outputs.build }} + backend: ${{ steps.changes.outputs.backend }} project: ${{ steps.changes.outputs.project }} openapi: ${{ steps.changes.outputs.openapi }} frontend: ${{ steps.changes.outputs.frontend }} @@ -48,7 +49,7 @@ jobs: proprietary: ${{ steps.changes.outputs.proprietary }} steps: - name: Harden the runner (Audit all outbound calls) - uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 + uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0 with: egress-policy: audit - name: Checkout repository @@ -61,63 +62,12 @@ jobs: filters: .github/config/.files.yaml gradle-cache-prime: - environment: - name: ci-unsigned - deployment: false - name: Prime shared Gradle cache needs: [files-changed] - runs-on: ubuntu-latest - timeout-minutes: 15 - steps: - - name: Harden the runner (Audit all outbound calls) - uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 - with: - egress-policy: audit - - name: Checkout repository - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - - name: Calculate Gradle cache key - id: gradle-cache-key - shell: bash - run: | - echo "key=gradle-v1-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}" >> "$GITHUB_OUTPUT" - - - name: Cache Gradle (lookup-only) - id: cache-gradle-restore - uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 - with: - path: | - ~/.gradle/caches - ~/.gradle/wrapper - key: ${{ steps.gradle-cache-key.outputs.key }} - lookup-only: true - - - name: Set up JDK 25 - if: steps.cache-gradle-restore.outputs.cache-hit != 'true' - uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0 - with: - java-version: "25" - distribution: "temurin" - - - name: Resolve backend dependencies - if: steps.cache-gradle-restore.outputs.cache-hit != 'true' - run: ./gradlew :stirling-pdf:classes --no-daemon - env: - STIRLING_FLAVOR: saas - MAVEN_USER: ${{ secrets.MAVEN_USER }} - MAVEN_PASSWORD: ${{ secrets.MAVEN_PASSWORD }} - MAVEN_PUBLIC_URL: ${{ secrets.MAVEN_PUBLIC_URL }} - - - name: Save cache Gradle User Home - if: steps.cache-gradle-restore.outputs.cache-hit != 'true' - uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 - with: - path: | - ~/.gradle/caches - ~/.gradle/wrapper - key: ${{ steps.gradle-cache-key.outputs.key }} + uses: ./.github/workflows/gradle-cache-prime.yml + secrets: inherit build: + if: needs.files-changed.outputs.backend == 'true' needs: [files-changed, gradle-cache-prime] permissions: actions: read @@ -196,7 +146,7 @@ jobs: check-licence: if: needs.files-changed.outputs.build == 'true' - needs: [files-changed, build, gradle-cache-prime] + needs: [files-changed, gradle-cache-prime] permissions: contents: read uses: ./.github/workflows/check-licence.yml @@ -215,7 +165,14 @@ jobs: docker-base-changed: ${{ needs.files-changed.outputs.docker-base }} test-build-docker-images: - if: github.event_name == 'pull_request' && needs.files-changed.outputs.project == 'true' + if: | + always() && + github.event_name == 'pull_request' && + needs.files-changed.outputs.project == 'true' && + contains(fromJSON('["success", "skipped"]'), needs.gradle-cache-prime.result) && + contains(fromJSON('["success", "skipped"]'), needs.build.result) && + contains(fromJSON('["success", "skipped"]'), needs.check-generateOpenApiDocs.result) && + contains(fromJSON('["success", "skipped"]'), needs.check-licence.result) needs: [ files-changed, @@ -321,6 +278,7 @@ jobs: if: always() needs: - files-changed + - gradle-cache-prime - build - db-migration-test - check-generateOpenApiDocs @@ -340,7 +298,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Harden the runner (Audit all outbound calls) - uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 + uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0 with: egress-policy: audit @@ -348,6 +306,7 @@ jobs: env: RESULTS: | files-changed=${{ needs.files-changed.result }} + gradle-cache-prime=${{ needs.gradle-cache-prime.result }} build=${{ needs.build.result }} db-migration-test=${{ needs.db-migration-test.result }} check-generateOpenApiDocs=${{ needs.check-generateOpenApiDocs.result }} diff --git a/.github/workflows/check-generated-models.yml b/.github/workflows/check-generated-models.yml index fafffcc241..476f9d21a7 100644 --- a/.github/workflows/check-generated-models.yml +++ b/.github/workflows/check-generated-models.yml @@ -28,7 +28,7 @@ jobs: pull-requests: write steps: - name: Harden the runner (Audit all outbound calls) - uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 + uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0 with: egress-policy: audit @@ -36,7 +36,7 @@ jobs: uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Install uv - uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 + uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 with: enable-cache: true cache-dependency-glob: | diff --git a/.github/workflows/check-licence.yml b/.github/workflows/check-licence.yml index 4e04a83656..7626122884 100644 --- a/.github/workflows/check-licence.yml +++ b/.github/workflows/check-licence.yml @@ -16,7 +16,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Harden Runner - uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 + uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0 with: egress-policy: audit diff --git a/.github/workflows/check-openapi.yml b/.github/workflows/check-openapi.yml index bc9b302857..47341834a3 100644 --- a/.github/workflows/check-openapi.yml +++ b/.github/workflows/check-openapi.yml @@ -17,7 +17,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Harden Runner - uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 + uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0 with: egress-policy: audit diff --git a/.github/workflows/check_toml.yml b/.github/workflows/check_toml.yml index 1681546e71..84347defe2 100644 --- a/.github/workflows/check_toml.yml +++ b/.github/workflows/check_toml.yml @@ -28,7 +28,7 @@ jobs: pull-requests: write # Allow writing to pull requests steps: - name: Harden Runner - uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 + uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0 with: egress-policy: audit @@ -195,7 +195,7 @@ jobs: core.exportVariable("REFERENCE_FILE", referenceFilePath); - name: Install uv - uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 + uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 with: enable-cache: true cache-dependency-glob: | diff --git a/.github/workflows/coverage-aggregate.yml b/.github/workflows/coverage-aggregate.yml index 61ef8793c4..899bed7f9d 100644 --- a/.github/workflows/coverage-aggregate.yml +++ b/.github/workflows/coverage-aggregate.yml @@ -34,7 +34,7 @@ jobs: timeout-minutes: 15 steps: - name: Harden Runner - uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 + uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0 with: egress-policy: audit - name: Checkout repository @@ -55,7 +55,7 @@ jobs: distribution: "temurin" - name: Install uv - uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 + uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 with: enable-cache: true cache-dependency-glob: | diff --git a/.github/workflows/db-migration-test.yml b/.github/workflows/db-migration-test.yml index 785073944e..3841ea4410 100644 --- a/.github/workflows/db-migration-test.yml +++ b/.github/workflows/db-migration-test.yml @@ -20,7 +20,7 @@ jobs: timeout-minutes: 30 steps: - name: Harden Runner - uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 + uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0 with: egress-policy: audit @@ -81,3 +81,8 @@ jobs: path: /tmp/stirling-migration-failed-*/app.log retention-days: 7 if-no-files-found: warn + + - name: Cleanup temporary files + if: always() + run: rm -rf /tmp/stirling-migration-failed-* + continue-on-error: true diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml index b27596ddfb..527dca9703 100644 --- a/.github/workflows/dependency-review.yml +++ b/.github/workflows/dependency-review.yml @@ -14,7 +14,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Harden Runner - uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 + uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0 with: egress-policy: audit diff --git a/.github/workflows/docker-compose-tests.yml b/.github/workflows/docker-compose-tests.yml index 439d4240b2..cf5887a205 100644 --- a/.github/workflows/docker-compose-tests.yml +++ b/.github/workflows/docker-compose-tests.yml @@ -28,7 +28,7 @@ jobs: steps: - name: Harden Runner - uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 + uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0 with: egress-policy: audit @@ -66,11 +66,11 @@ jobs: - name: Install Docker Compose run: | - sudo curl -SL "https://github.com/docker/compose/releases/download/v2.39.4/docker-compose-$(uname -s)-$(uname -m)" -o /usr/local/bin/docker-compose + sudo curl -SL "https://github.com/docker/compose/releases/download/v5.4.0/docker-compose-$(uname -s)-$(uname -m)" -o /usr/local/bin/docker-compose sudo chmod +x /usr/local/bin/docker-compose - name: Install uv - uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 + uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 with: enable-cache: true cache-dependency-glob: | diff --git a/.github/workflows/e2e-live.yml b/.github/workflows/e2e-live.yml index 844d26a3d1..b04f5022cc 100644 --- a/.github/workflows/e2e-live.yml +++ b/.github/workflows/e2e-live.yml @@ -18,7 +18,7 @@ jobs: timeout-minutes: 30 steps: - name: Harden Runner - uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 + uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0 with: egress-policy: audit - name: Checkout repository @@ -106,7 +106,7 @@ jobs: fi - name: Install uv if: always() - uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 + uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 with: enable-cache: true cache-dependency-glob: | diff --git a/.github/workflows/e2e-stubbed.yml b/.github/workflows/e2e-stubbed.yml index d02005936c..5038a7585a 100644 --- a/.github/workflows/e2e-stubbed.yml +++ b/.github/workflows/e2e-stubbed.yml @@ -14,6 +14,11 @@ jobs: playwright-e2e: name: playwright-e2e (${{ matrix.browser }}) runs-on: ubuntu-latest + # The image already contains the Playwright browsers and all Linux + # dependencies. This keeps the matrix for per-browser reporting while + # avoiding three concurrent `playwright install --with-deps` runs. + container: + image: mcr.microsoft.com/playwright:v1.58.2-noble@sha256:6446946a1d9fd62d9ae501312a2d76a43ee688542b21622056a372959b65d63d strategy: # One browser breaking must not mask a failure in another - report all. fail-fast: false @@ -27,7 +32,7 @@ jobs: project: stubbed-webkit steps: - name: Harden Runner - uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 + uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0 with: egress-policy: audit - name: Checkout repository @@ -40,15 +45,23 @@ jobs: cache-dependency-path: frontend/package-lock.json - name: Install Task uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0 - - name: Install Playwright (${{ matrix.browser }}) - run: task e2e:install -- ${{ matrix.browser }} - name: Build frontend (production bundle for vite preview) env: VITE_BUILD_FOR_PREVIEW: "1" run: task frontend:build - name: Run stubbed E2E tests (${{ matrix.browser }}) env: + # The official Playwright image expects its browser runtime under + # the root home directory. Keep this scoped to Playwright and use a + # neutral Docker config path so Docker does not read /root/.docker. + HOME: /root + DOCKER_CONFIG: /tmp/playwright-docker-config PLAYWRIGHT_JSON_OUTPUT_FILE: ${{ github.workspace }}/frontend/playwright-report/results.json + NPM_CONFIG_PREFER_OFFLINE: "true" + NPM_CONFIG_FETCH_RETRIES: "5" + NPM_CONFIG_FETCH_RETRY_FACTOR: "2" + NPM_CONFIG_FETCH_RETRY_MINTIMEOUT: "1000" + NPM_CONFIG_FETCH_RETRY_MAXTIMEOUT: "120000" run: task e2e:stubbed-project PROJECT=${{ matrix.project }} -- --workers=3 - name: Flag flaky tests # Runs regardless of the test outcome: a flaky test (passed on retry) diff --git a/.github/workflows/frontend-a11y.yml b/.github/workflows/frontend-a11y.yml index 8f97120d96..247d8375fc 100644 --- a/.github/workflows/frontend-a11y.yml +++ b/.github/workflows/frontend-a11y.yml @@ -21,7 +21,7 @@ jobs: timeout-minutes: 25 steps: - name: Harden Runner - uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 + uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0 with: egress-policy: audit - name: Checkout repository diff --git a/.github/workflows/frontend-backend-licenses-update.yml b/.github/workflows/frontend-backend-licenses-update.yml index 9aef2316d2..96e0edd8ac 100644 --- a/.github/workflows/frontend-backend-licenses-update.yml +++ b/.github/workflows/frontend-backend-licenses-update.yml @@ -28,7 +28,7 @@ jobs: licenses-backend: ${{ steps.changes.outputs.licenses-backend }} steps: - name: Harden the runner (Audit all outbound calls) - uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 + uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0 with: egress-policy: audit @@ -56,7 +56,7 @@ jobs: repository-projects: write # Required for enabling automerge steps: - name: Harden Runner - uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 + uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0 with: egress-policy: audit @@ -334,7 +334,7 @@ jobs: repository-projects: write # Required for enabling automerge steps: - name: Harden Runner - uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 + uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0 with: egress-policy: audit diff --git a/.github/workflows/frontend-validation.yml b/.github/workflows/frontend-validation.yml index 133d940b28..2650a945d6 100644 --- a/.github/workflows/frontend-validation.yml +++ b/.github/workflows/frontend-validation.yml @@ -15,7 +15,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Harden Runner - uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 + uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0 with: egress-policy: audit - name: Checkout repository @@ -107,7 +107,7 @@ jobs: } - name: Install uv if: always() - uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 + uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 with: enable-cache: true cache-dependency-glob: | diff --git a/.github/workflows/gradle-cache-prime.yml b/.github/workflows/gradle-cache-prime.yml new file mode 100644 index 0000000000..1c79ee4d12 --- /dev/null +++ b/.github/workflows/gradle-cache-prime.yml @@ -0,0 +1,66 @@ +name: Prime Gradle Cache + +on: + workflow_call: + push: + branches: ["main"] + +permissions: + contents: read + +jobs: + gradle-cache-prime: + environment: + name: ci-unsigned + deployment: false + name: Prime shared Gradle cache + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - name: Harden the runner (Audit all outbound calls) + uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0 + with: + egress-policy: audit + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Calculate Gradle cache key + id: gradle-cache-key + shell: bash + run: | + echo "key=gradle-v1-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}" >> "$GITHUB_OUTPUT" + + - name: Cache Gradle (lookup-only) + id: cache-gradle-restore + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: ${{ steps.gradle-cache-key.outputs.key }} + lookup-only: true + + - name: Set up JDK 25 + if: steps.cache-gradle-restore.outputs.cache-hit != 'true' + uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0 + with: + java-version: "25" + distribution: "temurin" + + - name: Resolve backend dependencies + if: steps.cache-gradle-restore.outputs.cache-hit != 'true' + run: ./gradlew :stirling-pdf:classes --no-daemon + env: + STIRLING_FLAVOR: saas + MAVEN_USER: ${{ secrets.MAVEN_USER }} + MAVEN_PASSWORD: ${{ secrets.MAVEN_PASSWORD }} + MAVEN_PUBLIC_URL: ${{ secrets.MAVEN_PUBLIC_URL }} + + - name: Save cache Gradle User Home + if: steps.cache-gradle-restore.outputs.cache-hit != 'true' + uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: ${{ steps.gradle-cache-key.outputs.key }} diff --git a/.github/workflows/manage-label.yml b/.github/workflows/manage-label.yml index 1a8bdd112d..cc27e45946 100644 --- a/.github/workflows/manage-label.yml +++ b/.github/workflows/manage-label.yml @@ -15,7 +15,7 @@ jobs: issues: write steps: - name: Harden Runner - uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 + uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0 with: egress-policy: audit diff --git a/.github/workflows/multiOSReleases.yml b/.github/workflows/multiOSReleases.yml index 0ac94ffe68..9071997ad7 100644 --- a/.github/workflows/multiOSReleases.yml +++ b/.github/workflows/multiOSReleases.yml @@ -48,7 +48,7 @@ jobs: version: ${{ steps.versionNumber.outputs.versionNumber }} steps: - name: Harden Runner - uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 + uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0 with: egress-policy: audit @@ -140,7 +140,7 @@ jobs: file_suffix: "-server" steps: - name: Harden Runner - uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 + uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0 with: egress-policy: audit @@ -207,7 +207,7 @@ jobs: RELEASE_GPG_PRIVATE_KEY: ${{ secrets.RELEASE_GPG_PRIVATE_KEY }} steps: - name: Harden Runner - uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 + uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0 with: egress-policy: audit allowed-endpoints: > @@ -697,6 +697,17 @@ jobs: path: ./dist/* retention-days: 1 + - name: Cleanup temporary files + if: always() + shell: bash + run: | + rm -f certificate.p12 + rm -rf "$RUNNER_TEMP/msi-verify" + if [ "${{ matrix.platform }}" = "macos-15" ]; then + security delete-keychain "$RUNNER_TEMP/app-signing.keychain-db" 2>/dev/null || true + fi + continue-on-error: true + collect-and-release: needs: [determine-matrix, build, build-jars] runs-on: ubuntu-latest @@ -704,7 +715,7 @@ jobs: contents: write steps: - name: Harden Runner - uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 + uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0 with: egress-policy: audit @@ -861,7 +872,7 @@ jobs: # Gate publish on valid updater sigs. Runs after the review upload (so # artifacts survive for debugging) and before action-gh-release. - name: Install uv - uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 + uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 with: enable-cache: true cache-dependency-glob: | diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index 65c25b7b66..5daa60f56f 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -23,7 +23,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Harden the runner (Audit all outbound calls) - uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 + uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0 with: egress-policy: audit @@ -74,7 +74,7 @@ jobs: timeout-minutes: 60 steps: - name: Harden the runner (Audit all outbound calls) - uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 + uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0 with: egress-policy: audit @@ -140,7 +140,7 @@ jobs: contents: read steps: - name: Harden the runner (Audit all outbound calls) - uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 + uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0 with: egress-policy: audit @@ -148,13 +148,13 @@ jobs: uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Set up JDK 25 - uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0 + uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0 with: java-version: "25" distribution: "temurin" - name: Install uv - uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 + uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 with: enable-cache: true cache-dependency-glob: | diff --git a/.github/workflows/package-managers.yml b/.github/workflows/package-managers.yml index 777c8d6ed4..02d946e5a6 100644 --- a/.github/workflows/package-managers.yml +++ b/.github/workflows/package-managers.yml @@ -28,7 +28,7 @@ jobs: jar_sha256: ${{ steps.hashes.outputs.jar_sha256 }} steps: - name: Harden Runner - uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 + uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0 with: egress-policy: audit @@ -80,7 +80,7 @@ jobs: contents: write steps: - name: Harden Runner - uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 + uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0 with: egress-policy: audit diff --git a/.github/workflows/pr-conflict-labeler.yml b/.github/workflows/pr-conflict-labeler.yml index c642b6cf3d..564ea9cd93 100644 --- a/.github/workflows/pr-conflict-labeler.yml +++ b/.github/workflows/pr-conflict-labeler.yml @@ -32,7 +32,7 @@ jobs: pull-requests: write # pulls.get/list plus add/remove the label on PRs steps: - name: Harden Runner - uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 + uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0 with: egress-policy: audit diff --git a/.github/workflows/pre_commit.yml b/.github/workflows/pre_commit.yml index db67ccefe6..674822363b 100644 --- a/.github/workflows/pre_commit.yml +++ b/.github/workflows/pre_commit.yml @@ -14,7 +14,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Harden Runner - uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 + uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0 with: egress-policy: audit @@ -25,7 +25,7 @@ jobs: persist-credentials: false - name: Install uv - uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 + uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 with: enable-cache: true cache-dependency-glob: | diff --git a/.github/workflows/push-docker-base.yml b/.github/workflows/push-docker-base.yml index 97c227f23c..9da49ad7ae 100644 --- a/.github/workflows/push-docker-base.yml +++ b/.github/workflows/push-docker-base.yml @@ -48,7 +48,7 @@ jobs: echo "version=${VERSION}" >> $GITHUB_OUTPUT - name: Harden Runner - uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 + uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0 with: egress-policy: audit diff --git a/.github/workflows/push-docker.yml b/.github/workflows/push-docker.yml index ea379cf7c6..844a77b489 100644 --- a/.github/workflows/push-docker.yml +++ b/.github/workflows/push-docker.yml @@ -18,6 +18,16 @@ on: required: false type: boolean default: false + build_engine: + description: "Build & push the standalone stirling-engine image." + required: false + type: boolean + default: true + force_engine_rebuild: + description: "Rebuild stirling-engine even if its source hash is unchanged." + required: false + type: boolean + default: false push: branches: - release @@ -50,9 +60,10 @@ jobs: env: RUN_MAIN_APP: ${{ github.event_name != 'workflow_dispatch' || inputs.build_main_app }} RUN_UNOSERVER: ${{ github.event_name != 'workflow_dispatch' || inputs.build_unoserver }} + RUN_ENGINE: ${{ github.event_name != 'workflow_dispatch' || inputs.build_engine }} steps: - name: Harden Runner - uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 + uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0 with: egress-policy: audit @@ -387,3 +398,119 @@ jobs: else echo "Warning: COSIGN_PRIVATE_KEY not set, skipping unoserver image signing" fi + + # Standalone AI engine image, same shape as the unoserver image above. + - name: Compute engine image source hash + id: engineHash + if: env.RUN_ENGINE == 'true' + run: | + set -eu + hash=$( { cat engine/Dockerfile engine/pyproject.toml engine/uv.lock engine/.env; \ + find engine/src -type f -print0 | sort -z | xargs -0 cat; } \ + | sha256sum | cut -d' ' -f1) + echo "hash=${hash}" >> "$GITHUB_OUTPUT" + echo "Engine source hash: ${hash}" + + - name: Decide whether to publish engine image + id: engineDecision + if: env.RUN_ENGINE == 'true' + env: + ENGINE_VERSION: ${{ steps.versionNumber.outputs.versionNumber }} + ENGINE_HASH: ${{ steps.engineHash.outputs.hash }} + ENGINE_IMAGE: ghcr.io/${{ steps.repoowner.outputs.lowercase }}/stirling-engine + ENGINE_HASH_ANNOTATION: org.stirlingpdf.engine-source-hash + FORCE_REBUILD: ${{ inputs.force_engine_rebuild }} + GH_REF: ${{ github.ref }} + EVENT_NAME: ${{ github.event_name }} + run: | + set -eu + mode="skip" + tags="" + + read_published_hash() { + local ref="$1" + docker buildx imagetools inspect "$ref" --raw 2>/dev/null \ + | jq -r --arg key "$ENGINE_HASH_ANNOTATION" \ + '.annotations[$key] // empty' \ + 2>/dev/null || true + } + + # Manual dispatch from any branch routes to the :alpha publish path. + EFFECTIVE_REF="$GH_REF" + if [ "$EVENT_NAME" = "workflow_dispatch" ]; then + EFFECTIVE_REF="refs/heads/testMain" + fi + + case "$EFFECTIVE_REF" in + refs/heads/release) + if [ "${FORCE_REBUILD}" = "true" ]; then + echo "force_engine_rebuild=true — building stable regardless" + mode="stable" + tags="${ENGINE_IMAGE}:${ENGINE_VERSION},${ENGINE_IMAGE}:latest" + elif docker manifest inspect "${ENGINE_IMAGE}:${ENGINE_VERSION}" >/dev/null 2>&1; then + echo "stirling-engine:${ENGINE_VERSION} already on GHCR — skipping" + else + echo "stirling-engine:${ENGINE_VERSION} is new — will publish" + mode="stable" + tags="${ENGINE_IMAGE}:${ENGINE_VERSION},${ENGINE_IMAGE}:latest" + fi + ;; + refs/heads/main|refs/heads/testMain) + published_hash=$(read_published_hash "${ENGINE_IMAGE}:alpha") + if [ "${FORCE_REBUILD}" = "true" ]; then + echo "force_engine_rebuild=true — rebuilding :alpha regardless" + mode="alpha" + tags="${ENGINE_IMAGE}:alpha" + elif [ -n "$published_hash" ] && [ "$published_hash" = "$ENGINE_HASH" ]; then + echo "Published :alpha source hash matches (${published_hash}) — skipping" + else + if [ -z "$published_hash" ]; then + echo ":alpha has no source-hash annotation (first publish) — will publish" + else + echo "Source hash changed (was ${published_hash}, now ${ENGINE_HASH}) — will publish" + fi + mode="alpha" + tags="${ENGINE_IMAGE}:alpha" + fi + ;; + *) + echo "Branch ${GH_REF} does not publish engine image" + ;; + esac + echo "mode=${mode}" >> "$GITHUB_OUTPUT" + echo "tags=${tags}" >> "$GITHUB_OUTPUT" + + - name: Build and push engine image + id: build-push-engine + if: env.RUN_ENGINE == 'true' && steps.engineDecision.outputs.mode != 'skip' + uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 + with: + builder: ${{ steps.buildx.outputs.name }} + context: . + file: ./engine/Dockerfile + push: true + cache-from: type=gha,scope=stirling-engine + cache-to: type=gha,mode=max,scope=stirling-engine + tags: ${{ steps.engineDecision.outputs.tags }} + # Manifest annotation read by the decision step above to detect drift. + annotations: | + index:org.stirlingpdf.engine-source-hash=${{ steps.engineHash.outputs.hash }} + platforms: linux/amd64,linux/arm64/v8 + provenance: true + sbom: true + + - name: Sign engine image + if: env.RUN_ENGINE == 'true' && steps.engineDecision.outputs.mode == 'stable' + env: + DIGEST: ${{ steps.build-push-engine.outputs.digest }} + TAGS: ${{ steps.engineDecision.outputs.tags }} + COSIGN_PRIVATE_KEY: ${{ secrets.COSIGN_PRIVATE_KEY }} + COSIGN_PASSWORD: ${{ secrets.COSIGN_PASSWORD }} + run: | + if [ -n "$COSIGN_PRIVATE_KEY" ]; then + echo "$TAGS" | tr ',' '\n' | while read -r tag; do + cosign sign --key env://COSIGN_PRIVATE_KEY --yes "${tag}@${DIGEST}" + done + else + echo "Warning: COSIGN_PRIVATE_KEY not set, skipping engine image signing" + fi diff --git a/.github/workflows/rollback-latest.yml b/.github/workflows/rollback-latest.yml index 3e49f727c6..fb0289b5a0 100644 --- a/.github/workflows/rollback-latest.yml +++ b/.github/workflows/rollback-latest.yml @@ -19,7 +19,7 @@ jobs: packages: write steps: - name: Harden Runner - uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 + uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0 with: egress-policy: audit diff --git a/.github/workflows/scorecards.yml b/.github/workflows/scorecards.yml index f0129277de..dbda06764b 100644 --- a/.github/workflows/scorecards.yml +++ b/.github/workflows/scorecards.yml @@ -35,7 +35,7 @@ jobs: steps: - name: Harden Runner - uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 + uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0 with: egress-policy: audit @@ -75,6 +75,6 @@ jobs: # Upload the results to GitHub's code scanning dashboard. - name: "Upload to code-scanning" - uses: github/codeql-action/upload-sarif@5595ccaf912efad79be6eef63a5619ff05969be3 # v4.37.6 + uses: github/codeql-action/upload-sarif@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4.37.7 with: sarif_file: results.sarif diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml index af255de3cf..2033154a00 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -17,7 +17,7 @@ jobs: pull-requests: write steps: - name: Harden Runner - uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 + uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0 with: egress-policy: audit diff --git a/.github/workflows/swagger.yml b/.github/workflows/swagger.yml index 1bfc94be5b..1f53edd17b 100644 --- a/.github/workflows/swagger.yml +++ b/.github/workflows/swagger.yml @@ -30,7 +30,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Harden Runner - uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 + uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0 with: egress-policy: audit diff --git a/.github/workflows/sync-portal-docs.yml b/.github/workflows/sync-portal-docs.yml index 636cd5f2e1..a6073b3f1f 100644 --- a/.github/workflows/sync-portal-docs.yml +++ b/.github/workflows/sync-portal-docs.yml @@ -33,7 +33,7 @@ jobs: pull-requests: write steps: - name: Harden Runner - uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 + uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0 with: egress-policy: audit diff --git a/.github/workflows/sync_files_v2.yml b/.github/workflows/sync_files_v2.yml index 124c992c71..a199fd6cbd 100644 --- a/.github/workflows/sync_files_v2.yml +++ b/.github/workflows/sync_files_v2.yml @@ -37,7 +37,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Harden Runner - uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 + uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0 with: egress-policy: audit @@ -53,7 +53,7 @@ jobs: private-key: ${{ secrets.GH_APP_PRIVATE_KEY }} - name: Install uv - uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 + uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 with: enable-cache: true cache-dependency-glob: | diff --git a/.github/workflows/tauri-build.yml b/.github/workflows/tauri-build.yml index 0a82647690..3b7a0b04fa 100644 --- a/.github/workflows/tauri-build.yml +++ b/.github/workflows/tauri-build.yml @@ -72,7 +72,7 @@ jobs: matrix: ${{ steps.set-matrix.outputs.matrix }} steps: - name: Harden the runner (Audit all outbound calls) - uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 + uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0 with: egress-policy: audit @@ -134,7 +134,7 @@ jobs: SIGN_BUNDLE: ${{ inputs.sign && (matrix.platform == 'macos-15' && secrets.APPLE_CERTIFICATE != '' || github.ref == 'refs/heads/main') }} steps: - name: Harden Runner - uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 + uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0 with: egress-policy: audit @@ -677,6 +677,17 @@ jobs: fi done + - name: Cleanup temporary files + if: always() + shell: bash + run: | + rm -f certificate.p12 + rm -rf "$RUNNER_TEMP/msi-verify" + if [ "${{ matrix.platform }}" = "macos-15" ]; then + security delete-keychain "$RUNNER_TEMP/app-signing.keychain-db" 2>/dev/null || true + fi + continue-on-error: true + pr-comment: needs: build runs-on: ubuntu-latest @@ -692,7 +703,7 @@ jobs: pull-requests: write steps: - name: Harden the runner - uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 + uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0 with: egress-policy: audit @@ -784,7 +795,7 @@ jobs: if: always() steps: - name: Harden the runner (Audit all outbound calls) - uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 + uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0 with: egress-policy: audit diff --git a/.github/workflows/test-build-docker.yml b/.github/workflows/test-build-docker.yml index 37cb7cb546..4717c9c387 100644 --- a/.github/workflows/test-build-docker.yml +++ b/.github/workflows/test-build-docker.yml @@ -22,21 +22,42 @@ permissions: contents: read jobs: - # TODO: extract a pre-matrix `prepare` job that runs once and produces - # shared artifacts for the three matrix entries below to consume: - # 1. `task backend:build` — currently runs 3× in parallel with - # identical env (DISABLE_ADDITIONAL_FEATURES=true, - # STIRLING_PDF_DESKTOP_UI=false). Build once, upload the JAR as an - # artifact, matrix entries download. - # 2. The base-image `docker build` (gated on docker-base-changed) — - # currently runs 3× in parallel against the same Dockerfile and - # context. Build once, `docker save` to an artifact, matrix entries - # `docker load` before the embedded build. - # Saves ~2 full backend builds + 2 base-image builds per PR that touches - # docker. May also be reusable from backend-build.yml's jdk-25 + - # spring-security=true matrix entry if `task backend:build` and - # `task backend:build:ci` produce equivalent JARs (verify before wiring). + # A changed base image is shared by all three embedded-image builds. Build + # it once and transfer it as an artifact; the matrix jobs use the local + # Docker driver so the loaded image is visible to the build. + prepare-base-image: + if: github.event_name == 'pull_request' && inputs.docker-base-changed == 'true' + environment: + name: ci-unsigned + deployment: false + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - name: Harden Runner + uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0 + with: + egress-policy: audit + + - name: Checkout Repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Build base image locally + run: docker build --platform linux/amd64 -t stirling-pdf-base:pr-test -f docker/base/Dockerfile docker/base + + - name: Export base image + run: docker save stirling-pdf-base:pr-test | gzip -1 > stirling-pdf-base-pr-test.tar.gz + + - name: Upload base image + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: docker-base-pr-test + path: stirling-pdf-base-pr-test.tar.gz + retention-days: 1 + if-no-files-found: error + test-build-docker-images: + if: always() && (needs.prepare-base-image.result == 'success' || needs.prepare-base-image.result == 'skipped') + needs: [prepare-base-image] environment: name: ci-unsigned deployment: false @@ -56,7 +77,7 @@ jobs: cache-scope: stirling-pdf-fat steps: - name: Harden Runner - uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 + uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0 with: egress-policy: audit @@ -81,6 +102,16 @@ jobs: docker system prune -af || true echo "Disk space after cleanup:" && df -h + - name: Download prepared base image + if: github.event_name == 'pull_request' && inputs.docker-base-changed == 'true' + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: docker-base-pr-test + + - name: Load prepared base image + if: github.event_name == 'pull_request' && inputs.docker-base-changed == 'true' + run: gzip -dc stirling-pdf-base-pr-test.tar.gz | docker load + - name: Restore cache Gradle User Home uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: @@ -113,11 +144,6 @@ jobs: id: buildx uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 - - name: Build base image locally (PR base change only) - if: github.event_name == 'pull_request' && inputs.docker-base-changed == 'true' - run: | - docker build -t stirling-pdf-base:pr-test -f docker/base/Dockerfile docker/base - - name: Set base image and platform for this build id: build-params # Pass workflow inputs through env vars rather than expanding `${{ }}` @@ -191,7 +217,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Harden Runner - uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 + uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0 with: egress-policy: audit diff --git a/.github/workflows/update-gradle.yml b/.github/workflows/update-gradle.yml new file mode 100644 index 0000000000..07474f112b --- /dev/null +++ b/.github/workflows/update-gradle.yml @@ -0,0 +1,112 @@ +name: Update Gradle + +on: + workflow_dispatch: + schedule: + - cron: "0 3 * * 1" + +concurrency: + group: update-gradle + cancel-in-progress: true + +jobs: + update-gradle: + name: Update Gradle and Docker images + permissions: + contents: write + pull-requests: write + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - name: Harden runner + uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0 + with: + egress-policy: audit + + - name: Check out repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Set up Java + uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0 + with: + distribution: temurin + java-version: "25" + + - name: Find latest Gradle release + id: gradle + shell: bash + run: | + set -euo pipefail + version=$(curl --fail --silent --show-error --retry 3 \ + https://services.gradle.org/versions/current | jq -r '.version') + [[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || { + echo "Could not determine a stable Gradle version: $version" >&2 + exit 1 + } + echo "version=$version" >> "$GITHUB_OUTPUT" + + - name: Find matching Docker image digest + id: docker + env: + GRADLE_VERSION: ${{ steps.gradle.outputs.version }} + shell: bash + run: | + set -euo pipefail + tag="${GRADLE_VERSION}-jdk25" + digest=$(curl --fail --silent --show-error --retry 3 \ + "https://hub.docker.com/v2/repositories/library/gradle/tags/${tag}" \ + | jq -r '.digest // empty') + [[ "$digest" =~ ^sha256:[0-9a-f]{64}$ ]] || { + echo "Docker image gradle:${tag} was not found" >&2 + exit 1 + } + echo "tag=$tag" >> "$GITHUB_OUTPUT" + echo "digest=$digest" >> "$GITHUB_OUTPUT" + + - name: Update Gradle wrapper + env: + GRADLE_VERSION: ${{ steps.gradle.outputs.version }} + run: ./gradlew wrapper --gradle-version "$GRADLE_VERSION" --distribution-type bin + + - name: Update Gradle Docker images + env: + DOCKER_TAG: ${{ steps.docker.outputs.tag }} + DOCKER_DIGEST: ${{ steps.docker.outputs.digest }} + shell: bash + run: | + set -euo pipefail + find docker -type f -name 'Dockerfile*' -print0 | + xargs -0 sed -E -i \ + "s#gradle:[^@[:space:]]+-jdk25(@sha256:[^[:space:]]+)?#gradle:${DOCKER_TAG}@${DOCKER_DIGEST}#g" + + - name: Verify Gradle update + env: + EXPECTED_VERSION: ${{ steps.gradle.outputs.version }} + shell: bash + run: | + set -euo pipefail + actual=$(./gradlew --version | sed -n 's/^Gradle \([0-9.]*\)$/\1/p') + [[ "$actual" == "$EXPECTED_VERSION" ]] || { + echo "Wrapper resolved Gradle $actual, expected $EXPECTED_VERSION" >&2 + exit 1 + } + if git diff --quiet; then + echo "Gradle is already up to date." + exit 0 + fi + git diff --check + + - name: Create pull request + uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1 + with: + token: ${{ secrets.GITHUB_TOKEN }} + branch: automation/update-gradle + delete-branch: true + commit-message: "chore: update Gradle" + title: "chore: update Gradle to ${{ steps.gradle.outputs.version }}" + body: | + Automated update of the Gradle wrapper and Gradle Docker build images. + + Gradle version: `${{ steps.gradle.outputs.version }}` + Docker image: `gradle:${{ steps.docker.outputs.tag }}` + labels: dependencies diff --git a/.gitignore b/.gitignore index 4b34350f67..1290056e05 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/.imgbotconfig b/.imgbotconfig index 720b938475..6a1b1bf7a2 100644 --- a/.imgbotconfig +++ b/.imgbotconfig @@ -1,5 +1,7 @@ { "ignoredFiles": [ - "frontend/editor/src-tauri/icons/icon.png" + "frontend/editor/src-tauri/icons/macos/*", + "frontend/editor/src-tauri/icons/linux/*", + "frontend/editor/src-tauri/icons/windows/*" ] } diff --git a/.taskfiles/frontend.yml b/.taskfiles/frontend.yml index f5325c9ed7..844306824d 100644 --- a/.taskfiles/frontend.yml +++ b/.taskfiles/frontend.yml @@ -375,13 +375,13 @@ tasks: desc: "Auto-fix code formatting" deps: [install] cmds: - - npx prettier --write . + - npx oxfmt --write . format:check: desc: "Check code formatting" deps: [install] cmds: - - npx prettier --check . + - npx oxfmt --check . fix: desc: "Auto-fix lint and format" @@ -554,6 +554,7 @@ tasks: deps: [install, ":backend:swagger"] cmds: - npx tsx editor/scripts/generate-tool-api-types.mts --spec ../SwaggerDoc.json --output editor/src/core/types/toolApiTypes.ts --io-output editor/src/core/types/toolIO.ts + - task: format sources: - editor/scripts/generate-tool-api-types.mts - ../SwaggerDoc.json @@ -563,9 +564,9 @@ tasks: tool-models:check: desc: "Fail if committed tool API types are out of date" - deps: [install, ":backend:swagger"] cmds: - - npx tsx editor/scripts/generate-tool-api-types.mts --spec ../SwaggerDoc.json --output editor/src/core/types/toolApiTypes.ts --io-output editor/src/core/types/toolIO.ts --check + - task: tool-models + - git diff --exit-code -- editor/src/core/types/toolApiTypes.ts editor/src/core/types/toolIO.ts licenses:generate: desc: "Generate frontend license report" diff --git a/DeveloperGuide.md b/DeveloperGuide.md index 7c2e21d5ca..d87406715b 100644 --- a/DeveloperGuide.md +++ b/DeveloperGuide.md @@ -46,8 +46,8 @@ This guide focuses on developing for Stirling 2.0, including both the React fron - Docker - Git - Java JDK 25 -- Node.js 18+ and npm (required for frontend development) -- Gradle 7.0 or later (Included within the repo) +- Node.js 22+ and npm (required for frontend development) +- Gradle 9.0 or later (Included within the repo) - [uv](https://docs.astral.sh/uv/) — Python package manager (required for engine development) - Rust and Cargo (required for Tauri desktop app development) - Tauri CLI (install with `cargo install tauri-cli`) diff --git a/app/common/build.gradle b/app/common/build.gradle index f53ad0eb79..8af68bcb76 100644 --- a/app/common/build.gradle +++ b/app/common/build.gradle @@ -18,11 +18,11 @@ dependencies { api "org.apache.pdfbox:preflight:$pdfboxVersion" api 'com.github.junrar:junrar:8.0.0' // RAR archive support for CBR files api 'jakarta.servlet:jakarta.servlet-api:6.1.0' - api 'org.snakeyaml:snakeyaml-engine:3.0.1' + api 'org.snakeyaml:snakeyaml-engine:3.1.1' api "org.springdoc:springdoc-openapi-starter-webmvc-ui:3.0.3" // Simple Java Mail for EML/MSG parsing (replaces direct Angus Mail usage) - api 'org.simplejavamail:simple-java-mail:9.2.0' - api 'org.simplejavamail:outlook-module:9.2.0' // MSG file support + api 'org.simplejavamail:simple-java-mail:9.3.2' + api 'org.simplejavamail:outlook-module:9.3.2' // MSG file support api 'jakarta.mail:jakarta.mail-api:2.1.5' runtimeOnly 'org.eclipse.angus:angus-mail:2.0.5' diff --git a/app/common/src/main/java/stirling/software/SPDF/config/EndpointConfiguration.java b/app/common/src/main/java/stirling/software/SPDF/config/EndpointConfiguration.java index ff1a880010..5e8f7fe336 100644 --- a/app/common/src/main/java/stirling/software/SPDF/config/EndpointConfiguration.java +++ b/app/common/src/main/java/stirling/software/SPDF/config/EndpointConfiguration.java @@ -6,6 +6,7 @@ import java.util.Map; import java.util.Set; import java.util.concurrent.ConcurrentHashMap; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.stereotype.Service; @@ -13,6 +14,7 @@ import lombok.Getter; import lombok.extern.slf4j.Slf4j; import stirling.software.common.model.ApplicationProperties; +import stirling.software.common.service.PdfaLevelAServiceInterface; @Service @Slf4j @@ -51,12 +53,16 @@ public class EndpointConfiguration { private Map groupDisableReasons = new ConcurrentHashMap<>(); private Map> endpointAlternatives = new ConcurrentHashMap<>(); private final boolean runningProOrHigher; + private final boolean pdfUaAvailable; public EndpointConfiguration( ApplicationProperties applicationProperties, - @Qualifier("runningProOrHigher") boolean runningProOrHigher) { + @Qualifier("runningProOrHigher") boolean runningProOrHigher, + @Autowired(required = false) PdfaLevelAServiceInterface pdfaLevelAService) { this.applicationProperties = applicationProperties; this.runningProOrHigher = runningProOrHigher; + // The PDF/UA tagger ships in the proprietary module, and so do its endpoints. + this.pdfUaAvailable = pdfaLevelAService != null; init(); processEnvironmentConfigs(); } @@ -356,6 +362,7 @@ public class EndpointConfiguration { addEndpointToGroup("Convert", "pdf-to-img"); addEndpointToGroup("Convert", "img-to-pdf"); addEndpointToGroup("Convert", "pdf-to-pdfa"); + addEndpointToGroup("Convert", "pdf-to-ua"); addEndpointToGroup("Convert", "file-to-pdf"); addEndpointToGroup("Convert", "pdf-to-word"); addEndpointToGroup("Convert", "pdf-to-presentation"); @@ -395,6 +402,7 @@ public class EndpointConfiguration { // Backend-only endpoints (not in frontend tool registry endpoints) addEndpointToGroup("Security", "redact"); addEndpointToGroup("Security", "verify-pdf"); + addEndpointToGroup("Security", "accessibility-report"); addEndpointToGroup("Security", "sign"); // Adding endpoints to "Other" group @@ -529,6 +537,8 @@ public class EndpointConfiguration { addEndpointToGroup("Java", "json-to-pdf"); addEndpointToGroup("Java", "pdf-to-video"); addEndpointToGroup("Java", "verify-pdf"); + addEndpointToGroup("Java", "pdf-to-ua"); + addEndpointToGroup("Java", "accessibility-report"); addEndpointToGroup("Java", "flatten"); addEndpointToGroup("Java", "unlock-pdf-forms"); addEndpointToGroup("Java", "validate-signature"); @@ -600,6 +610,8 @@ public class EndpointConfiguration { // veraPDF dependent endpoints addEndpointToGroup("veraPDF", "verify-pdf"); + addEndpointToGroup("veraPDF", "pdf-to-ua"); + addEndpointToGroup("veraPDF", "accessibility-report"); // Pdftohtml dependent endpoints addEndpointToGroup("Pdftohtml", "pdf-to-html"); @@ -630,6 +642,11 @@ public class EndpointConfiguration { disableGroup("enterprise"); } + if (!pdfUaAvailable) { + disableEndpoint("pdf-to-ua"); + disableEndpoint("accessibility-report"); + } + if (!applicationProperties.getSystem().isEnableUrlToPDF()) { disableEndpoint("url-to-pdf"); } diff --git a/app/common/src/main/java/stirling/software/common/model/FormFieldWithCoordinates.java b/app/common/src/main/java/stirling/software/common/model/FormFieldWithCoordinates.java index 54ccafd665..9a0f23aa33 100644 --- a/app/common/src/main/java/stirling/software/common/model/FormFieldWithCoordinates.java +++ b/app/common/src/main/java/stirling/software/common/model/FormFieldWithCoordinates.java @@ -62,6 +62,15 @@ public class FormFieldWithCoordinates { @Schema(description = "Widget coordinates on each page (fields can have multiple widgets)") private List 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:' or 'submit:'") + 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; } } diff --git a/app/common/src/main/java/stirling/software/common/service/PdfaLevelAServiceInterface.java b/app/common/src/main/java/stirling/software/common/service/PdfaLevelAServiceInterface.java new file mode 100644 index 0000000000..2ee55719b2 --- /dev/null +++ b/app/common/src/main/java/stirling/software/common/service/PdfaLevelAServiceInterface.java @@ -0,0 +1,22 @@ +package stirling.software.common.service; + +import java.util.List; + +/** + * Raises a converted PDF/A file from conformance level B to level A, which needs the tagging the + * PDF/UA tagger does. Implemented only in the proprietary module; core builds convert at level B. + */ +public interface PdfaLevelAServiceInterface { + + /** + * @param levelA true only when the file was tagged and validated, so the claim is never a guess + */ + record Result(byte[] pdfBytes, boolean levelA, List warnings) {} + + /** + * @param part PDF/A part, 1 to 3; part 1 keeps its PDF 1.4 version + * @param alsoDeclareUa additionally claim PDF/UA, but only if it validates + */ + Result upgradeToLevelA( + byte[] pdfBytes, int part, String language, String title, boolean alsoDeclareUa); +} diff --git a/app/common/src/main/java/stirling/software/common/util/FormFieldTypeSupport.java b/app/common/src/main/java/stirling/software/common/util/FormFieldTypeSupport.java index 1f971d8d9e..5d833290ec 100644 --- a/app/common/src/main/java/stirling/software/common/util/FormFieldTypeSupport.java +++ b/app/common/src/main/java/stirling/software/common/util/FormFieldTypeSupport.java @@ -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 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 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 BY_TYPE = Arrays.stream(values()) .collect( diff --git a/app/common/src/main/java/stirling/software/common/util/FormUtils.java b/app/common/src/main/java/stirling/software/common/util/FormUtils.java index a1862d379c..401b1eb1ac 100644 --- a/app/common/src/main/java/stirling/software/common/util/FormUtils.java +++ b/app/common/src/main/java/stirling/software/common/util/FormUtils.java @@ -23,6 +23,7 @@ import org.apache.pdfbox.cos.COSArray; import org.apache.pdfbox.cos.COSBase; import org.apache.pdfbox.cos.COSDictionary; import org.apache.pdfbox.cos.COSName; +import org.apache.pdfbox.cos.COSString; import org.apache.pdfbox.pdmodel.PDDocument; import org.apache.pdfbox.pdmodel.PDDocumentCatalog; import org.apache.pdfbox.pdmodel.PDPage; @@ -32,10 +33,12 @@ import org.apache.pdfbox.pdmodel.common.PDRectangle; import org.apache.pdfbox.pdmodel.font.PDFont; import org.apache.pdfbox.pdmodel.font.PDType1Font; import org.apache.pdfbox.pdmodel.font.Standard14Fonts; +import org.apache.pdfbox.pdmodel.graphics.color.PDColor; import org.apache.pdfbox.pdmodel.graphics.image.JPEGFactory; import org.apache.pdfbox.pdmodel.graphics.image.PDImageXObject; import org.apache.pdfbox.pdmodel.interactive.annotation.PDAnnotation; import org.apache.pdfbox.pdmodel.interactive.annotation.PDAnnotationWidget; +import org.apache.pdfbox.pdmodel.interactive.annotation.PDAppearanceCharacteristicsDictionary; import org.apache.pdfbox.pdmodel.interactive.annotation.PDAppearanceDictionary; import org.apache.pdfbox.pdmodel.interactive.annotation.PDAppearanceEntry; import org.apache.pdfbox.pdmodel.interactive.annotation.PDAppearanceStream; @@ -68,6 +71,12 @@ public class FormUtils { public final Set CHOICE_FIELD_TYPES = Set.of(FIELD_TYPE_COMBOBOX, FIELD_TYPE_LISTBOX, FIELD_TYPE_RADIO); + /** The reserved off-state name every toggle widget must carry an appearance for. */ + private final String OFF_STATE = "Off"; + + /** The on-state a checkbox gets when the definition supplies no export values. */ + private final String DEFAULT_CHECKBOX_ON_STATE = "Yes"; + /** * Threshold in PDF points for considering two widgets to be on the same line. Fields whose * y-coordinates differ by less than this value are sorted left-to-right by x-coordinate instead @@ -75,6 +84,9 @@ public class FormUtils { */ private static final float SAME_LINE_THRESHOLD_PT = 10.0f; + /** Below this, a rect change is a no-op and the existing /AP still maps exactly. */ + private static final float GEOMETRY_EPSILON_PT = 0.01f; + private static final Pattern HEX_UUID_PATTERN = Pattern.compile("^[0-9a-fA-F]{8}[0-9a-fA-F]{24,}$"); private static final Pattern WHITESPACE_PATTERN = Pattern.compile("\\s+"); @@ -236,6 +248,8 @@ public class FormUtils { .multiline(multiline) .tooltip(tooltip) .widgets(widgets.isEmpty() ? null : widgets) + .maxLength(extractMaxLength(terminalField)) + .buttonActionSpec(extractButtonAction(terminalField)) .build()); } @@ -300,7 +314,8 @@ public class FormUtils { findPageIndexForAnnotation(document, fieldDict, annotationPageMap); if (pageIndex >= 0) { PDRectangle rectangle = new PDRectangle(rectArray); - result.add( + addWidget( + result, createWidgetCoordinates( document, rectangle, pageIndex, null, field)); } else { @@ -373,7 +388,8 @@ public class FormUtils { } } - result.add( + addWidget( + result, createWidgetCoordinates( document, rectangle, pageIndex, exportValue, field)); } catch (Exception e) { @@ -387,6 +403,15 @@ public class FormUtils { return result; } + /** Unreadable geometry yields null, which must never reach the list the comparator walks. */ + private void addWidget( + List target, + FormFieldWithCoordinates.WidgetCoordinates widget) { + if (widget != null) { + target.add(widget); + } + } + private FormFieldWithCoordinates.WidgetCoordinates createWidgetCoordinates( PDDocument document, PDRectangle rectangle, @@ -424,13 +449,14 @@ public class FormUtils { float finalW = width; float finalH = height; - // Validate coordinates are within reasonable bounds - if (finalX < -1.0f - || finalY < -1.0f - || finalX > cropBox.getWidth() * 2 // Allow some horizontal overflow - || finalY > cropHeight + 1.0f) { + // Only nonsense is rejected. A widget outside the visible page is legal and must still be + // reported, or the field loses its geometry and the user cannot drag it back. + if (!Float.isFinite(finalX) + || !Float.isFinite(finalY) + || !Float.isFinite(finalW) + || !Float.isFinite(finalH)) { log.warn( - "Widget coordinates out of bounds for field '{}': page={}, x={}, y={}, w={}, h={}", + "Widget coordinates are not finite for field '{}': page={}, x={}, y={}, w={}, h={}", field.getFullyQualifiedName(), pageIndex, finalX, @@ -439,6 +465,12 @@ public class FormUtils { finalH); return null; } + if (finalX < 0 || finalY < 0 || finalX > cropBox.getWidth() || finalY > cropHeight) { + log.debug( + "Widget for field '{}' sits outside page {}", + field.getFullyQualifiedName(), + pageIndex); + } return FormFieldWithCoordinates.WidgetCoordinates.builder() .pageIndex(pageIndex) @@ -448,6 +480,7 @@ public class FormUtils { .height(finalH) .exportValue(exportValue) .fontSize(extractFontSize(field)) + .cropBoxHeight(cropHeight) .build(); } @@ -459,12 +492,40 @@ public class FormUtils { * * @param document PDF document to repair */ + /** + * PDFBox reads /Opt entries without following references, so an option stored indirectly - as + * real forms do - silently disappears. Resolving in place keeps the value and the reader + * honest. + */ + private void resolveIndirectChoiceOptions(PDAcroForm acroForm) { + try { + for (PDField field : acroForm.getFieldTree()) { + if (!(field instanceof PDChoice)) { + continue; + } + COSBase raw = field.getCOSObject().getDictionaryObject(COSName.OPT); + if (!(raw instanceof COSArray options)) { + continue; + } + for (int i = 0; i < options.size(); i++) { + COSBase resolved = options.getObject(i); + if (resolved != null && resolved != options.get(i)) { + options.set(i, resolved); + } + } + } + } catch (Exception e) { + log.debug("Could not resolve indirect choice options: {}", e.getMessage()); + } + } + public void repairMissingWidgetPageReferences(PDDocument document) { try { PDAcroForm acroForm = getAcroFormSafely(document); if (acroForm == null) { return; } + resolveIndirectChoiceOptions(acroForm); log.debug("Checking for widgets with missing page references..."); int repairedCount = 0; @@ -887,7 +948,9 @@ public class FormUtils { } PDFont helvetica = new PDType1Font(Standard14Fonts.FontName.HELVETICA); try { - // Map standard name used by many DAs + // Both spellings: a DA naming a font missing from /DR makes + // refreshAppearances throw for the whole form, not just that field. + dr.put(COSName.getPDFName("Helv"), helvetica); dr.put(COSName.getPDFName("Helvetica"), helvetica); } catch (Exception ignore) { try { @@ -991,7 +1054,7 @@ public class FormUtils { try { textField.setValue(value != null ? value : ""); return; - } catch (IOException initial) { + } catch (IOException | RuntimeException initial) { log.debug( "Primary fill failed for text field '{}': {}", textField.getFullyQualifiedName(), @@ -1277,6 +1340,11 @@ public class FormUtils { } return String.join(",", selected); } + // A signature has no text value; getValueAsString would emit a JVM identity hash that + // changes on every load, so the same document would describe itself differently. + if (field instanceof PDSignatureField) { + return null; + } return field.getValueAsString(); } catch (Exception e) { log.debug( @@ -1451,16 +1519,26 @@ public class FormUtils { return tooltipLabel; } - // Only check options for choice-type fields (combobox, listbox, radio) - if (CHOICE_FIELD_TYPES.contains(type) && options != null && !options.isEmpty()) { + // A clearly meaningful field name describes the whole field and matches + // the name shown in the editor, so it is the best label. + String humanized = cleanLabel(humanizeName(name)); + if (humanized != null && !looksGeneric(humanized)) { + return humanized; + } + + // An option only beats the name when the name is auto-generated; a human-typed + // one wins below, so a group named "Choice" does not read as its first option. + if (CHOICE_FIELD_TYPES.contains(type) + && options != null + && !options.isEmpty() + && looksAutoGenerated(name)) { String optionCandidate = cleanLabel(options.getFirst()); if (optionCandidate != null && !looksGeneric(optionCandidate)) { return optionCandidate; } } - String humanized = cleanLabel(humanizeName(name)); - if (humanized != null && !looksGeneric(humanized)) { + if (humanized != null && !looksAutoGenerated(name)) { return humanized; } @@ -1497,6 +1575,27 @@ public class FormUtils { || patterns.getOptionalTNumericPattern().matcher(simplified).matches(); } + /** + * True only for auto-generated identifiers ("Field_5", "t3", UUIDs). Unlike {@link + * #looksGeneric} it keeps human-typed placeholders like "Choice", which are usable labels. + */ + private boolean looksAutoGenerated(String value) { + if (value == null) return true; + + RegexPatternUtils patterns = RegexPatternUtils.getInstance(); + String simplified = patterns.getPunctuationPattern().matcher(value).replaceAll(" ").trim(); + if (simplified.isEmpty()) return true; + + String nospaces = WHITESPACE_PATTERN.matcher(simplified).replaceAll(""); + if (nospaces.length() >= 32 && HEX_UUID_PATTERN.matcher(nospaces).matches()) return true; + + return patterns.getPattern("^field(\\s*\\d+)?$", Pattern.CASE_INSENSITIVE) + .matcher(simplified) + .matches() + || patterns.getSimpleFormFieldPattern().matcher(simplified).matches() + || patterns.getOptionalTNumericPattern().matcher(simplified).matches(); + } + private String humanizeName(String name) { if (name == null) return null; @@ -1513,35 +1612,62 @@ public class FormUtils { public void modifyFormFields( PDDocument document, List modifications) { + modifyFormFields(document, modifications, null); + } + + public void modifyFormFields( + PDDocument document, + List modifications, + List skipped) { if (document == null || modifications == null || modifications.isEmpty()) return; PDAcroForm acroForm = getAcroFormSafely(document); if (acroForm == null) { log.warn("Cannot modify fields because the document has no AcroForm"); + for (ModifyFormFieldDefinition modification : modifications) { + if (modification != null) { + recordSkip( + skipped, + "modify", + modification.targetName(), + "the document has no form to modify"); + } + } return; } Set existingNames = collectExistingFieldNames(acroForm); for (ModifyFormFieldDefinition modification : modifications) { - if (modification == null || modification.targetName() == null) { + if (modification == null) { continue; } - String lookupName = modification.targetName().trim(); + String lookupName = + modification.targetName() == null ? "" : modification.targetName().trim(); if (lookupName.isEmpty()) { + recordSkip(skipped, "modify", null, "the request named no field to change"); + continue; + } + + String nameProblem = renameProblem(lookupName, modification.name()); + if (nameProblem != null) { + log.warn("Rejecting rename of '{}': {}", sanitizeForLog(lookupName), nameProblem); + recordSkip(skipped, "modify", lookupName, nameProblem); continue; } PDField originalField = locateField(acroForm, lookupName); if (originalField == null) { log.warn("No matching field '{}' found for modification", lookupName); + recordSkip(skipped, "modify", lookupName, "no field with that name exists"); continue; } List widgets = originalField.getWidgets(); if (widgets == null || widgets.isEmpty()) { log.warn("Field '{}' has no widgets; skipping modification", lookupName); + recordSkip(skipped, "modify", lookupName, "the field has nothing drawn on a page"); continue; } @@ -1552,6 +1678,8 @@ public class FormUtils { log.warn( "Unable to resolve widget page or rectangle for '{}'; skipping", lookupName); + recordSkip( + skipped, "modify", lookupName, "the field is not placed on a known page"); continue; } @@ -1564,6 +1692,11 @@ public class FormUtils { .getSupportedNewFieldTypes() .contains(resolvedType)) { log.warn("Unsupported target type '{}' for field '{}'", resolvedType, lookupName); + recordSkip( + skipped, + "modify", + lookupName, + "'" + abbreviate(resolvedType, 60) + "' is not a supported field type"); continue; } @@ -1571,13 +1704,22 @@ public class FormUtils { Optional.ofNullable(modification.name()) .map(String::trim) .filter(s -> !s.isEmpty()) + // The editor seeds the box with the qualified name, so a submission + // equal to it is not a rename; keep the field's own partial name. + .filter(name -> !name.equals(lookupName)) + .map(name -> leafName(lookupName, name)) .orElseGet(originalField::getPartialName); + String qualified = originalField.getFullyQualifiedName(); + // desiredName is a PARTIAL name but existingNames holds qualified ones, so compare + // under this field's own parent or siblings collide unnoticed. + String prefix = parentPrefix(qualified); + String reservedName = null; if (desiredName != null) { - existingNames.remove(originalField.getFullyQualifiedName()); - existingNames.remove(originalField.getPartialName()); - desiredName = generateUniqueFieldName(desiredName, existingNames); - existingNames.add(desiredName); + existingNames.remove(qualified); + desiredName = generateUniqueFieldName(desiredName, existingNames, prefix); + reservedName = prefix + desiredName; + existingNames.add(reservedName); } // Try to modify field in-place first for simple property changes @@ -1586,17 +1728,27 @@ public class FormUtils { if (!typeChanging) { try { - modifyFieldPropertiesInPlace(originalField, modification, desiredName); + modifyFieldPropertiesInPlace( + document, originalField, modification, desiredName, skipped); log.debug("Successfully modified field '{}' in-place", lookupName); continue; // Skip the remove-and-recreate process } catch (Exception e) { log.debug( "In-place modification failed for '{}', falling back to recreation: {}", - lookupName, + sanitizeForLog(lookupName), e.getMessage()); } } + // Recreation always builds a top-level field, so running it on a field nested under a + // parent would silently move it out of that parent and change its qualified name. + if (!prefix.isEmpty()) { + log.warn("Cannot recreate nested field '{}'; leaving it as it was", lookupName); + recordSkip(skipped, "modify", lookupName, refusalReason(typeChanging)); + releaseReservedName(existingNames, reservedName, qualified); + continue; + } + // For type changes or when in-place modification fails, use remove-and-recreate // But create the new field first to ensure success before removing the original NewFormFieldDefinition replacementDefinition = @@ -1613,16 +1765,31 @@ public class FormUtils { modification.multiSelect(), modification.options(), modification.defaultValue(), - modification.tooltip()); + modification.tooltip(), + modification.fontSize(), + modification.readOnly(), + modification.multiline(), + modification.maxLength(), + modification.buttonAction()); List sanitizedOptions = sanitizeOptions(modification.options()); - try { - FormFieldTypeSupport handler = FormFieldTypeSupport.forTypeName(resolvedType); - if (handler == null || handler.doesNotsupportsDefinitionCreation()) { - handler = FormFieldTypeSupport.TEXT; - } + FormFieldTypeSupport handler = FormFieldTypeSupport.forTypeName(resolvedType); + if (handler == null || handler.doesNotsupportsDefinitionCreation()) { + // Falling back to a text field here would silently retype the field and report + // success, so refuse instead and leave the original alone. + recordSkip( + skipped, + "modify", + lookupName, + "'" + + resolvedType + + "' cannot be rebuilt, so the field was left as it was"); + releaseReservedName(existingNames, reservedName, qualified); + continue; + } + try { // Create new field first - if this fails, original field is preserved createNewField( handler, @@ -1635,25 +1802,56 @@ public class FormUtils { removeFieldFromDocument(document, acroForm, originalField); + // A rebuilt toggle has no /AP yet, so without this it renders blank and cannot + // tick. + applyButtonAppearances( + document, + acroForm, + List.of(Map.entry(prefix + desiredName, replacementDefinition))); + log.debug( "Successfully replaced field '{}' with type '{}'", - lookupName, + sanitizeForLog(lookupName), resolvedType); } catch (Exception e) { log.warn( "Failed to modify form field '{}' to type '{}': {}", - lookupName, + sanitizeForLog(lookupName), resolvedType, e.getMessage(), e); + recordSkip(skipped, "modify", lookupName, readableFailure(e)); + releaseReservedName(existingNames, reservedName, qualified); } } ensureAppearances(acroForm); } + /** Nothing was applied, so give the field back its real name and drop the one we reserved. */ + private void releaseReservedName( + Set existingNames, String reservedName, String originalQualifiedName) { + if (reservedName != null) { + existingNames.remove(reservedName); + } + if (originalQualifiedName != null) { + existingNames.add(originalQualifiedName); + } + } + + /** Why the edit was refused, which is not always the type change that triggered the path. */ + private String refusalReason(boolean typeChanging) { + return typeChanging + ? "a field nested under a parent cannot have its type changed here" + : "this change needs the field rebuilt, which a nested field does not support"; + } + private void modifyFieldPropertiesInPlace( - PDField field, ModifyFormFieldDefinition modification, String newName) + PDDocument document, + PDField field, + ModifyFormFieldDefinition modification, + String newName, + List skipped) throws IOException { if (newName != null && !newName.equals(field.getPartialName())) { field.setPartialName(newName); @@ -1690,6 +1888,14 @@ public class FormUtils { if (modification.multiSelect() != null) { choiceField.setMultiSelect(modification.multiSelect()); } + } else if (modification.options() != null && !(field instanceof PDChoice)) { + // Only a choice field stores an option list, so say so rather than drop the edit and + // let the panel report it as saved. + recordSkip( + skipped, + "modify", + field.getFullyQualifiedName(), + "only dropdown and list fields have an editable option list"); } // Update tooltip on widgets @@ -1703,6 +1909,318 @@ public class FormUtils { } } } + + // Update read-only flag + if (modification.readOnly() != null) { + field.setReadOnly(modification.readOnly()); + } + + // Update multiline flag (text fields only) + if (modification.multiline() != null && field instanceof PDTextField tf) { + tf.setMultiline(modification.multiline()); + } + + // Update the activation action (push buttons only) + if (modification.buttonAction() != null && field instanceof PDPushButton) { + String actionProblem = null; + for (PDAnnotationWidget widget : field.getWidgets()) { + String problem = + FormFieldTypeSupport.applyButtonAction(widget, modification.buttonAction()); + if (problem != null && actionProblem == null) { + actionProblem = problem; + } + } + if (actionProblem != null) { + recordSkip(skipped, "modify", field.getFullyQualifiedName(), actionProblem); + } + } + + // Update comb / max length (text fields only). Zero clears it, since a null means + // "unchanged" and the editor otherwise has no way to remove an existing /MaxLen. + if (modification.maxLength() != null && field instanceof PDTextField combTf) { + int maxLength = modification.maxLength(); + if (maxLength > 0) { + combTf.setMaxLen(maxLength); + if (!combTf.isMultiline()) { + try { + combTf.setComb(true); + } catch (Exception ignore) { + // comb is best-effort + } + } + } else { + combTf.getCOSObject().removeItem(COSName.MAX_LEN); + try { + combTf.setComb(false); + } catch (Exception ignore) { + // comb is best-effort + } + } + } + + // Update font size (variable-text fields only: text/combo/list) + if (modification.fontSize() != null + && modification.fontSize() > 0 + && field instanceof PDVariableText vt) { + applyFontSizeToDefaultAppearance(vt, modification.fontSize()); + // Clear the cached appearance so ensureAppearances() regenerates it + // with the new font size; otherwise viewers keep the old glyph sizing. + removeWidgetAppearanceStreams(field); + } + + // Incoming coordinates are CropBox-relative and lower-left-origin, the reverse + // of what createWidgetCoordinates extracts. + if (modification.x() != null + || modification.y() != null + || modification.width() != null + || modification.height() != null + || modification.optionGap() != null + || modification.optionSize() != null) { + updateWidgetGeometry(document, field, modification, skipped); + } + } + + /** + * Moves/resizes a field's widgets. The rect describes widget 0; the rest shift by the same + * delta and keep their own size, so a radio group travels intact instead of being normalised. + */ + /** A size PDFBox would write as "Infinity" or a zero-area box makes the field unusable. */ + private static boolean unusableSize(Float value) { + return value != null && (!Float.isFinite(value) || value <= 0); + } + + /** The rectangle enclosing every widget, or null when none has one. */ + private static PDRectangle widgetBounds(List widgets) { + float minX = Float.MAX_VALUE; + float minY = Float.MAX_VALUE; + float maxX = -Float.MAX_VALUE; + float maxY = -Float.MAX_VALUE; + boolean any = false; + for (PDAnnotationWidget widget : widgets) { + PDRectangle r = widget.getRectangle(); + if (r == null) continue; + any = true; + minX = Math.min(minX, r.getLowerLeftX()); + minY = Math.min(minY, r.getLowerLeftY()); + maxX = Math.max(maxX, r.getUpperRightX()); + maxY = Math.max(maxY, r.getUpperRightY()); + } + return any ? new PDRectangle(minX, minY, maxX - minX, maxY - minY) : null; + } + + private void updateWidgetGeometry( + PDDocument document, + PDField field, + ModifyFormFieldDefinition modification, + List skipped) { + List widgets = field.getWidgets(); + if (widgets == null || widgets.isEmpty()) { + return; + } + if (unusableSize(modification.width()) || unusableSize(modification.height())) { + recordSkip( + skipped, + "modify", + field.getFullyQualifiedName(), + "a width and height above zero are required, so the size was left as it was"); + return; + } + if (modification.x() != null && !Float.isFinite(modification.x()) + || modification.y() != null && !Float.isFinite(modification.y())) { + recordSkip( + skipped, + "modify", + field.getFullyQualifiedName(), + "the position is not a usable number, so the field was left where it was"); + return; + } + PDAnnotationWidget anchor = widgets.get(0); + PDRectangle anchorRect = anchor.getRectangle(); + if (anchorRect == null) { + return; + } + + Map pageMap = buildAnnotationPageMap(document); + int anchorPage = determineWidgetPageIndex(document, anchor, pageMap); + float offX = 0; + float offY = 0; + if (anchorPage >= 0) { + PDRectangle cropBox = document.getPage(anchorPage).getCropBox(); + offX = cropBox.getLowerLeftX(); + offY = cropBox.getLowerLeftY(); + } + + float newX = + modification.x() != null ? modification.x() + offX : anchorRect.getLowerLeftX(); + float newY = + modification.y() != null ? modification.y() + offY : anchorRect.getLowerLeftY(); + float dx = newX - anchorRect.getLowerLeftX(); + float dy = newY - anchorRect.getLowerLeftY(); + Float newW = modification.width(); + Float newH = modification.height(); + + // Spacing and size are a property of the whole group, so an explicit change re-flows + // every option. Gated on those two: a plain drag sends widget 0's size, which would be + // mistaken for the group's height and collapse the stack. + if ((modification.optionGap() != null || modification.optionSize() != null) + && field instanceof PDRadioButton + && widgets.size() > 1) { + PDRectangle bounds = widgetBounds(widgets); + if (bounds != null) { + PDRectangle box = + new PDRectangle( + bounds.getLowerLeftX() + dx, + bounds.getLowerLeftY() + dy, + modification.width() != null + ? modification.width() + : bounds.getWidth(), + modification.height() != null + ? modification.height() + : bounds.getHeight()); + List reflowed = + radioOptionRects( + box, + widgets.size(), + modification.optionGap(), + modification.optionSize()); + List groupStates = currentWidgetOnStates((PDButton) field); + for (int i = 0; i < widgets.size(); i++) { + widgets.get(i).setRectangle(reflowed.get(i)); + rebuildWidgetAppearance(document, field, widgets.get(i), i, groupStates, true); + } + return; + } + } + + // Read the on-states off /AP /N before the strip: PDFBox derives a button's + // value vocabulary from those keys, so a guessed state orphans /V. + List onStates = + field instanceof PDButton button ? currentWidgetOnStates(button) : List.of(); + boolean isRadio = field instanceof PDRadioButton; + + int leftOffPage = 0; + for (int i = 0; i < widgets.size(); i++) { + PDAnnotationWidget widget = widgets.get(i); + PDRectangle rect = widget.getRectangle(); + if (rect == null) { + continue; + } + if (i > 0 && determineWidgetPageIndex(document, widget, pageMap) != anchorPage) { + // A delta measured in another page's user space means nothing here. + log.warn( + "Field '{}' widget {} sits on a different page; geometry left alone", + field.getFullyQualifiedName(), + i); + leftOffPage++; + continue; + } + // Only the widget the request describes takes the new size; the rest keep theirs. + float targetW = i == 0 && newW != null ? newW : rect.getWidth(); + float targetH = i == 0 && newH != null ? newH : rect.getHeight(); + boolean resized = + Math.abs(targetW - rect.getWidth()) > GEOMETRY_EPSILON_PT + || Math.abs(targetH - rect.getHeight()) > GEOMETRY_EPSILON_PT; + widget.setRectangle( + new PDRectangle( + rect.getLowerLeftX() + dx, + rect.getLowerLeftY() + dy, + targetW, + targetH)); + // A pure translation re-maps the same /AP onto the new /Rect unchanged, but a toggle + // with no /AP at all has no on-state vocabulary and must be given one regardless. + boolean toggle = field instanceof PDCheckBox || field instanceof PDRadioButton; + if (resized || (toggle && normalAppearanceOnState(widget) == null)) { + rebuildWidgetAppearance(document, field, widget, i, onStates, isRadio); + } + } + // One row per field, not per widget, so a split group cannot fill the report on its own. + if (leftOffPage > 0) { + recordSkip( + skipped, + "modify", + field.getFullyQualifiedName(), + leftOffPage + " widget(s) on another page were left where they were"); + } + } + + /** After a resize, rebuilds the appearance PDFBox cannot regenerate by itself. */ + private void rebuildWidgetAppearance( + PDDocument document, + PDField field, + PDAnnotationWidget widget, + int index, + List onStates, + boolean isRadio) { + if (field instanceof PDSignatureField) { + // PDFBox never rebuilds a signature appearance; dropping it blanks the field. + return; + } + COSName priorState = widget.getCOSObject().getCOSName(COSName.AS); + try { + if (field instanceof PDCheckBox || field instanceof PDRadioButton) { + // Drop the stale streams: viewers stretch an /AP built for the old BBox + // onto the new /Rect, so a resized toggle looks distorted. + widget.getCOSObject().removeItem(COSName.AP); + String onState = + index < onStates.size() ? onStates.get(index) : DEFAULT_CHECKBOX_ON_STATE; + applyToggleAppearance(document, widget, onState, isRadio); + // applyToggleAppearance parks the widget on Off; put the selection back. + if (priorState != null && !OFF_STATE.equals(priorState.getName())) { + widget.getCOSObject().setName(COSName.AS, onState); + } + } else if (field instanceof PDPushButton) { + // /D and /R still carry the old BBox and cannot be regenerated, so drop them + // rather than leave a stretched down-state; viewers fall back to /N. + PDAppearanceDictionary existing = widget.getAppearance(); + if (existing != null) { + existing.getCOSObject().removeItem(COSName.D); + existing.getCOSObject().removeItem(COSName.R); + } + applyPushButtonAppearance(document, widget); + } else { + // text/choice: refreshAppearances() rebuilds these from /DA in ensureAppearances(). + widget.getCOSObject().removeItem(COSName.AP); + } + } catch (Exception e) { + log.warn( + "Could not rebuild the appearance for '{}' widget {}: {}", + field.getFullyQualifiedName(), + index, + e.getMessage()); + } + } + + /** Rewrites only the size token in a variable-text field's /DA, keeping font and colour. */ + private void applyFontSizeToDefaultAppearance(PDVariableText field, float fontSize) { + String da = field.getDefaultAppearance(); + if (da != null && !da.isBlank()) { + // Replace the size token (the operand immediately before "Tf"). + String[] tokens = da.split("\\s+"); + boolean replaced = false; + for (int i = 0; i < tokens.length; i++) { + if ("Tf".equals(tokens[i]) && i > 0) { + tokens[i - 1] = String.valueOf(fontSize); + replaced = true; + break; + } + } + if (replaced) { + field.setDefaultAppearance(String.join(" ", tokens)); + return; + } + } + field.setDefaultAppearance("/Helv " + fontSize + " Tf 0 g"); + } + + /** Drops cached /AP appearance streams from every widget of a field. */ + private void removeWidgetAppearanceStreams(PDField field) { + List widgets = field.getWidgets(); + if (widgets == null) { + return; + } + for (PDAnnotationWidget widget : widgets) { + widget.getCOSObject().removeItem(COSName.AP); + } } private String fallbackLabelForType(String type, int typeIndex) { @@ -1830,12 +2348,700 @@ public class FormUtils { return -1; } + /** + * Adds fields, creating an AcroForm if absent. Definition coordinates are CropBox-relative and + * lower-left-origin (the inverse of {@link #createWidgetCoordinates}). + */ + public void addNewFields(PDDocument document, List definitions) + throws IOException { + addNewFields(document, definitions, null); + } + + /** + * A form with variable-text fields needs /DR and /DA; PDFBox refuses to set a value without + * them, and a PDF that never had a form has neither. + */ + private void ensureAcroFormDefaults(PDAcroForm acroForm) { + if (acroForm == null) return; + try { + PDResources dr = acroForm.getDefaultResources(); + if (dr == null) { + dr = new PDResources(); + acroForm.setDefaultResources(dr); + } + String resourceName = "Helv"; + COSName alias = dr.add(new PDType1Font(Standard14Fonts.FontName.HELVETICA)); + if (alias != null && alias.getName() != null && !alias.getName().isBlank()) { + resourceName = alias.getName(); + } + String da = acroForm.getDefaultAppearance(); + if (da == null || da.isBlank()) { + acroForm.setDefaultAppearance("/" + resourceName + " 12 Tf 0 g"); + } + } catch (Exception e) { + log.debug("Could not prepare AcroForm defaults: {}", e.getMessage()); + } + } + + public void addNewFields( + PDDocument document, + List definitions, + List skipped) + throws IOException { + if (document == null || definitions == null || definitions.isEmpty()) return; + // A page-less document has nowhere to put a widget; the clamp below cannot make it safe. + if (document.getNumberOfPages() == 0) { + log.warn("Cannot add form fields: document has no pages"); + for (NewFormFieldDefinition definition : definitions) { + if (definition != null) { + recordSkip(skipped, "add", definition.name(), "the document has no pages"); + } + } + return; + } + + PDAcroForm acroForm = getAcroFormSafely(document); + if (acroForm == null) { + // Create a new AcroForm for PDFs that don't have one yet + acroForm = new PDAcroForm(document); + document.getDocumentCatalog().setAcroForm(acroForm); + } + ensureAcroFormDefaults(acroForm); + + Set existingNames = collectExistingFieldNames(acroForm); + int pageCount = document.getNumberOfPages(); + // Buttons need their appearances built after creation; see applyButtonAppearances. + List> createdButtons = new ArrayList<>(); + + for (NewFormFieldDefinition definition : definitions) { + if (definition == null) continue; + + String nameProblem = invalidFieldNameReason(definition.name()); + if (nameProblem != null) { + log.warn("Rejecting new field: {}", nameProblem); + recordSkip(skipped, "add", definition.name(), nameProblem); + continue; + } + + String resolvedType = + Optional.ofNullable(definition.type()) + .map(FormUtils::normalizeFieldType) + .orElse(FIELD_TYPE_TEXT); + + int pageIdx = definition.pageIndex() != null ? definition.pageIndex() : 0; + if (pageIdx < 0 || pageIdx >= pageCount) { + log.warn( + "Page index {} out of range (0-{}); clamping to last page", + pageIdx, + pageCount - 1); + // Clamped, so the field IS created; not a dropped edit and not reported as one. + pageIdx = Math.max(0, pageCount - 1); + } + PDPage page; + try { + page = document.getPage(pageIdx); + } catch (RuntimeException e) { + // getNumberOfPages() reports the raw /Count, which a broken /Pages tree can + // overstate, so the page may still not be there. + recordSkip( + skipped, + "add", + definition.name(), + "page " + (pageIdx + 1) + " could not be read from this PDF"); + continue; + } + PDRectangle cropBox = page.getCropBox(); + + // CropBox-relative, lower-left-origin -> absolute PDF user space. + float x = (definition.x() != null ? definition.x() : 0f) + cropBox.getLowerLeftX(); + float y = (definition.y() != null ? definition.y() : 0f) + cropBox.getLowerLeftY(); + float w = definition.width() != null ? definition.width() : 150f; + float h = definition.height() != null ? definition.height() : 20f; + PDRectangle rectangle = new PDRectangle(x, y, w, h); + + String baseName = + Optional.ofNullable(definition.name()) + .map(String::trim) + .filter(s -> !s.isEmpty()) + .orElse("field"); + String uniqueName = generateUniqueFieldName(baseName, existingNames); + existingNames.add(uniqueName); + + List options = sanitizeOptions(definition.options()); + + try { + if (FIELD_TYPE_RADIO.equals(resolvedType)) { + // Radio is a single field with one widget per option; it can't go + // through the single-widget createNewField path. + createRadioField(acroForm, page, rectangle, uniqueName, definition, options); + } else { + FormFieldTypeSupport handler = FormFieldTypeSupport.forTypeName(resolvedType); + if (handler == null || handler.doesNotsupportsDefinitionCreation()) { + // Quietly making it a text field reported success for a field the + // caller never asked for; say so instead. + recordSkip( + skipped, + "add", + uniqueName, + "'" + resolvedType + "' fields cannot be created"); + existingNames.remove(uniqueName); + continue; + } + createNewField( + handler, acroForm, page, rectangle, uniqueName, definition, options); + } + createdButtons.add(Map.entry(uniqueName, definition)); + } catch (Exception e) { + log.warn( + "Failed to create field '{}' of type '{}': {}", + sanitizeForLog(uniqueName), + resolvedType, + e.getMessage(), + e); + recordSkip(skipped, "add", uniqueName, readableFailure(e)); + } + } + + applyButtonAppearances(document, acroForm, createdButtons); + ensureAppearances(acroForm); + } + + /** + * {@link PDAcroForm#refreshAppearances()} never synthesizes /AP for the button family, and + * PDFBox reads a button's on-state from the /AP /N keys, so draw them then re-apply the value. + */ + private void applyButtonAppearances( + PDDocument document, + PDAcroForm acroForm, + List> created) { + for (Map.Entry entry : created) { + PDField field = acroForm.getField(entry.getKey()); + if (field instanceof PDPushButton) { + for (PDAnnotationWidget widget : field.getWidgets()) { + try { + applyPushButtonAppearance(document, widget); + } catch (Exception e) { + log.warn( + "Could not build an appearance for button '{}': {}", + entry.getKey(), + e.getMessage()); + } + } + continue; + } + if (!(field instanceof PDCheckBox) && !(field instanceof PDRadioButton)) { + continue; + } + boolean isRadio = field instanceof PDRadioButton; + List onStates = buttonOnStates((PDButton) field); + List widgets = field.getWidgets(); + for (int i = 0; i < widgets.size(); i++) { + String onState = i < onStates.size() ? onStates.get(i) : DEFAULT_CHECKBOX_ON_STATE; + try { + applyToggleAppearance(document, widgets.get(i), onState, isRadio); + } catch (Exception e) { + log.warn( + "Could not build an appearance for '{}' widget {}: {}", + entry.getKey(), + i, + e.getMessage()); + } + } + applyButtonDefault((PDButton) field, entry.getValue(), onStates); + } + } + + /** The on-state per widget: the export values when set, else the single checkbox state. */ + private List buttonOnStates(PDButton button) { + List exportValues = button.getExportValues(); + if (exportValues != null && !exportValues.isEmpty()) { + return exportValues; + } + return List.of(DEFAULT_CHECKBOX_ON_STATE); + } + + /** Each widget's live on-state, read from /AP /N before that dictionary is dropped. */ + private List currentWidgetOnStates(PDButton button) { + List exportValues = button.getExportValues(); + List widgets = button.getWidgets(); + // A checkbox's widgets all share one on-state, so /V names it however many there are. + // A radio's widgets each have their own, so /V identifies one and cannot stand in. + boolean sharedOnState = !(button instanceof PDRadioButton); + String fromValue = sharedOnState || widgets.size() == 1 ? nonOffValueName(button) : null; + List states = new ArrayList<>(widgets.size()); + for (int i = 0; i < widgets.size(); i++) { + String state = normalAppearanceOnState(widgets.get(i)); + if ((state == null || state.isEmpty()) + && exportValues != null + && i < exportValues.size()) { + state = sanitizePdfName(exportValues.get(i)); + } + if (state == null || state.isEmpty()) { + state = fromValue; + } + states.add(state == null || state.isEmpty() ? DEFAULT_CHECKBOX_ON_STATE : state); + } + return states; + } + + /** A button's current value when it names a real on-state, else null. */ + private String nonOffValueName(PDButton button) { + try { + // /V is inheritable, so walk up. A malformed PDF can point /Parent back at an + // ancestor, so track what we have seen rather than trusting the chain to end. + COSBase raw = null; + Set seen = Collections.newSetFromMap(new IdentityHashMap<>()); + COSDictionary d = button.getCOSObject(); + while (d != null && raw == null && seen.add(d)) { + raw = d.getDictionaryObject(COSName.V); + COSBase parent = d.getDictionaryObject(COSName.PARENT); + d = parent instanceof COSDictionary parentDict ? parentDict : null; + } + String name = + switch (raw) { + case COSName cosName -> cosName.getName(); + case COSString cosString -> cosString.getString(); + case null, default -> null; + }; + return name == null || name.isEmpty() || OFF_STATE.equals(name) ? null : name; + } catch (Exception e) { + log.debug("Could not read a button's value: {}", e.getMessage()); + return null; + } + } + + /** The first non-Off key of a widget's /AP /N sub-dictionary, or null. */ + private String normalAppearanceOnState(PDAnnotationWidget widget) { + try { + PDAppearanceDictionary appearance = widget.getAppearance(); + PDAppearanceEntry normal = appearance != null ? appearance.getNormalAppearance() : null; + if (normal == null || !normal.isSubDictionary()) { + return null; + } + for (COSName name : normal.getSubDictionary().keySet()) { + if (!OFF_STATE.equals(name.getName())) { + return name.getName(); + } + } + } catch (Exception e) { + log.debug("Could not read a widget's on-state: {}", e.getMessage()); + } + return null; + } + + /** Re-applies the definition's default now that the on-state keys exist to resolve it. */ + private void applyButtonDefault( + PDButton button, NewFormFieldDefinition definition, List onStates) { + try { + if (button instanceof PDCheckBox checkBox) { + if (isChecked(definition.defaultValue())) { + checkBox.check(); + } else { + checkBox.unCheck(); + } + return; + } + String requested = definition.defaultValue(); + if (requested == null || requested.isBlank()) { + return; + } + // The widget states are sanitized PDF names, so match the raw request against those. + String match = + onStates.stream() + .filter( + state -> + state.equals(requested) + || state.equalsIgnoreCase( + sanitizePdfName(requested))) + .findFirst() + .orElse(null); + if (match != null) { + button.setValue(match); + } + } catch (Exception e) { + log.debug( + "Could not apply default value for '{}': {}", + button.getPartialName(), + e.getMessage()); + } + } + + /** + * Builds a toggle's two-state /AP /N from drawing primitives, so no font resource is needed. + */ + private void applyToggleAppearance( + PDDocument document, PDAnnotationWidget widget, String onState, boolean isRadio) + throws IOException { + PDRectangle rect = widget.getRectangle(); + if (rect == null || rect.getWidth() <= 0 || rect.getHeight() <= 0) { + return; + } + float w = rect.getWidth(); + float h = rect.getHeight(); + PDRectangle bbox = new PDRectangle(w, h); + + PDAppearanceDictionary appearance = new PDAppearanceDictionary(); + COSDictionary normalStates = new COSDictionary(); + normalStates.setItem( + COSName.getPDFName(OFF_STATE), + toggleStream(document, bbox, false, false).getCOSObject()); + normalStates.setItem( + COSName.getPDFName(onState), + toggleStream(document, bbox, true, isRadio).getCOSObject()); + appearance.getCOSObject().setItem(COSName.N, normalStates); + widget.setAppearance(appearance); + // Until a value selects it, the widget shows the Off appearance. + widget.getCOSObject().setName(COSName.AS, OFF_STATE); + } + + private PDAppearanceStream toggleStream( + PDDocument document, PDRectangle bbox, boolean on, boolean isRadio) throws IOException { + PDAppearanceStream stream = new PDAppearanceStream(document); + stream.setBBox(bbox); + stream.setResources(new PDResources()); + + float w = bbox.getWidth(); + float h = bbox.getHeight(); + float inset = Math.min(w, h) * 0.1f; + try (PDPageContentStream content = + new PDPageContentStream( + document, stream, stream.getStream().createOutputStream())) { + content.setStrokingColor(0f, 0f, 0f); + content.setNonStrokingColor(0f, 0f, 0f); + content.setLineWidth(Math.max(0.5f, Math.min(w, h) * 0.06f)); + if (isRadio) { + drawCircle(content, w / 2, h / 2, Math.min(w, h) / 2 - inset); + content.stroke(); + if (on) { + drawCircle(content, w / 2, h / 2, Math.min(w, h) / 4 - inset / 2); + content.fill(); + } + } else { + content.addRect(inset, inset, w - 2 * inset, h - 2 * inset); + content.stroke(); + if (on) { + content.moveTo(w * 0.25f, h * 0.5f); + content.lineTo(w * 0.45f, h * 0.28f); + content.lineTo(w * 0.78f, h * 0.72f); + content.stroke(); + } + } + } + return stream; + } + + /** + * Draws a push button's single {@code /AP /N} stream from its {@code /MK} characteristics. + * PDFBox never synthesizes one, so without this a push button has no appearance at all. + */ + private void applyPushButtonAppearance(PDDocument document, PDAnnotationWidget widget) + throws IOException { + PDRectangle rect = widget.getRectangle(); + if (rect == null || rect.getWidth() <= 0 || rect.getHeight() <= 0) { + return; + } + float w = rect.getWidth(); + float h = rect.getHeight(); + + PDAppearanceStream stream = new PDAppearanceStream(document); + stream.setBBox(new PDRectangle(w, h)); + stream.setResources(new PDResources()); + + PDAppearanceCharacteristicsDictionary mk = widget.getAppearanceCharacteristics(); + String caption = mk != null ? mk.getNormalCaption() : null; + // Honour the authored /MK colours; a hardcoded grey would restyle an existing button. + float[] background = mkColour(mk == null ? null : mk.getBackground(), 0.85f); + float[] border = mkColour(mk == null ? null : mk.getBorderColour(), 0f); + PDFont font = new PDType1Font(Standard14Fonts.FontName.HELVETICA); + float fontSize = Math.min(12f, h * 0.6f); + + try (PDPageContentStream content = + new PDPageContentStream( + document, stream, stream.getStream().createOutputStream())) { + content.setNonStrokingColor(background[0], background[1], background[2]); + content.addRect(0, 0, w, h); + content.fill(); + content.setStrokingColor(border[0], border[1], border[2]); + content.setLineWidth(1f); + content.addRect(0.5f, 0.5f, w - 1f, h - 1f); + content.stroke(); + if (caption != null && !caption.isBlank()) { + try { + float textWidth = font.getStringWidth(caption) / 1000f * fontSize; + content.beginText(); + content.setFont(font, fontSize); + content.setNonStrokingColor(0f, 0f, 0f); + content.newLineAtOffset( + Math.max(2f, (w - textWidth) / 2f), + (h - fontSize) / 2f + fontSize * 0.2f); + content.showText(caption); + content.endText(); + } catch (Exception e) { + // Unencodable caption: keep the frame, drop the text. + log.debug("Could not draw button caption '{}': {}", caption, e.getMessage()); + } + } + } + + // Reuse the existing dictionary so an authored /D or /R is not collateral damage. + PDAppearanceDictionary appearance = widget.getAppearance(); + if (appearance == null) { + appearance = new PDAppearanceDictionary(); + widget.setAppearance(appearance); + } + appearance.setNormalAppearance(stream); + // A push button has no value, so no /AS. + widget.getCOSObject().removeItem(COSName.AS); + } + + /** A /MK colour array as RGB, falling back to a grey level when absent or unsupported. */ + private float[] mkColour(PDColor colour, float fallback) { + float[] rgb = {fallback, fallback, fallback}; + if (colour == null) { + return rgb; + } + float[] components; + try { + components = colour.getComponents(); + } catch (Exception e) { + log.debug("Unreadable /MK colour: {}", e.getMessage()); + return rgb; + } + if (components.length == 3) { + rgb = components.clone(); + } else if (components.length == 1) { + rgb = new float[] {components[0], components[0], components[0]}; + } else if (components.length == 4) { + // CMYK to RGB, good enough for button chrome. + float k = components[3]; + rgb = + new float[] { + (1 - components[0]) * (1 - k), + (1 - components[1]) * (1 - k), + (1 - components[2]) * (1 - k) + }; + } + // PDPageContentStream rejects anything outside 0..1, and a throw here loses the appearance. + for (int i = 0; i < rgb.length; i++) { + rgb[i] = Math.min(1f, Math.max(0f, rgb[i])); + } + return rgb; + } + + /** A circle from four Bezier arcs; PDF has no primitive for one. */ + private void drawCircle(PDPageContentStream content, float cx, float cy, float r) + throws IOException { + if (r <= 0) { + return; + } + float k = r * 0.5523f; + content.moveTo(cx - r, cy); + content.curveTo(cx - r, cy + k, cx - k, cy + r, cx, cy + r); + content.curveTo(cx + k, cy + r, cx + r, cy + k, cx + r, cy); + content.curveTo(cx + r, cy - k, cx + k, cy - r, cx, cy - r); + content.curveTo(cx - k, cy - r, cx - r, cy - k, cx - r, cy); + content.closePath(); + } + + /** + * One widget per option stacked below {@code baseRect}, each keyed by its sanitized export + * value so the group behaves as a single selectable field. + */ + /** + * Per-option widget rects laid out INSIDE the drawn box, which is the group's total extent. + * Stacking outside it made a three-option group three times taller than what was drawn. + */ + public static List radioOptionRects( + PDRectangle box, int count, Float gapOverride, Float sizeOverride) { + List rects = new ArrayList<>(); + int n = Math.max(1, count); + float h = box.getHeight(); + float slot = h / n; + + float size; + if (sizeOverride != null && sizeOverride > 0f) { + size = sizeOverride; + } else if (gapOverride != null && gapOverride >= 0f) { + size = (h - (n - 1) * gapOverride) / n; + } else { + // A quarter of each slot is breathing room, so the stack fills the drawn height. + size = slot * 0.75f; + } + // Square keeps the circle round; a wide box becomes a left-aligned column. + size = Math.max(1f, Math.min(size, box.getWidth())); + + float gap; + if (gapOverride != null && gapOverride >= 0f) { + gap = gapOverride; + } else { + gap = n > 1 ? Math.max(0f, (h - n * size) / (n - 1)) : 0f; + } + + float top = box.getLowerLeftY() + h; + for (int i = 0; i < n; i++) { + float y = top - (i + 1) * size - i * gap; + rects.add(new PDRectangle(box.getLowerLeftX(), y, size, size)); + } + return rects; + } + + private void createRadioField( + PDAcroForm acroForm, + PDPage page, + PDRectangle baseRect, + String name, + NewFormFieldDefinition definition, + List options) + throws IOException { + + List values = (options == null || options.isEmpty()) ? List.of("1", "2") : options; + + PDRadioButton radio = new PDRadioButton(acroForm); + radio.setPartialName(name); + if (definition.label() != null && !definition.label().isBlank()) { + try { + radio.setAlternateFieldName(definition.label()); + } catch (Exception ignore) { + // alternate name is best-effort + } + } + radio.setRequired(Boolean.TRUE.equals(definition.required())); + if (Boolean.TRUE.equals(definition.readOnly())) { + radio.setReadOnly(true); + } + + List optionRects = + radioOptionRects( + baseRect, values.size(), definition.optionGap(), definition.optionSize()); + + List widgets = new ArrayList<>(); + List exportValues = new ArrayList<>(); + Set usedStates = new HashSet<>(); + for (int i = 0; i < values.size(); i++) { + String onState = sanitizeOnState(values.get(i), i, usedStates); + exportValues.add(onState); + + PDRectangle rect = optionRects.get(i); + + PDAnnotationWidget widget = new PDAnnotationWidget(); + widget.setRectangle(rect); + widget.setPage(page); + widget.getCOSObject().setItem(COSName.P, page.getCOSObject()); + widget.getCOSObject().setItem(COSName.TYPE, COSName.getPDFName("Annot")); + widget.getCOSObject().setItem(COSName.SUBTYPE, COSName.getPDFName("Widget")); + widget.setParent(radio); + // The widget's appearance state is "Off" until the group value selects it. + widget.getCOSObject().setName(COSName.AS, OFF_STATE); + widgets.add(widget); + + List annotations = page.getAnnotations(); + if (annotations == null) { + annotations = new ArrayList<>(); + page.setAnnotations(annotations); + } + annotations.add(widget); + } + + radio.setWidgets(widgets); + try { + radio.setExportValues(exportValues); + } catch (Exception e) { + log.debug("Unable to set radio export values for '{}': {}", name, e.getMessage()); + } + + String defaultValue = definition.defaultValue(); + if (defaultValue != null + && !defaultValue.isBlank() + && exportValues.contains(defaultValue)) { + try { + radio.setValue(defaultValue); + } catch (Exception e) { + log.debug("Unable to set radio default '{}': {}", defaultValue, e.getMessage()); + } + } + + acroForm.getFields().add(radio); + } + + /** Builds a unique, PDF-name-safe "on" state for a radio widget. */ + private String sanitizeOnState(String raw, int index, Set used) { + String base = + Optional.ofNullable(raw) + .map(String::trim) + .filter(s -> !s.isEmpty()) + .map(FormUtils::sanitizePdfName) + .orElse("Option" + (index + 1)); + if (OFF_STATE.equalsIgnoreCase(base)) { + base = "Option" + (index + 1); + } + String candidate = base; + int suffix = 1; + while (!used.add(candidate)) { + candidate = base + "_" + suffix++; + } + return candidate; + } + + /** Reduces a label to characters that are safe inside a PDF name. */ + private static String sanitizePdfName(String raw) { + return raw == null ? "" : raw.trim().replaceAll("[^A-Za-z0-9_-]", "_"); + } + + /** Modify, then delete, then add, so generated names dedupe against the surviving set. */ + public void applyFieldEdits( + PDDocument document, + List adds, + List modifies, + List deletes) + throws IOException { + applyFieldEdits(document, adds, modifies, deletes, null); + } + + /** + * As above, but records every operation that could not be applied into {@code skipped} so the + * caller can report "3 of 4" instead of a bare success. + */ + public void applyFieldEdits( + PDDocument document, + List adds, + List modifies, + List deletes, + List skipped) + throws IOException { + if (document == null) return; + if (modifies != null && !modifies.isEmpty()) { + modifyFormFields(document, modifies, skipped); + } + if (deletes != null && !deletes.isEmpty()) { + deleteFormFields(document, deletes, skipped); + } + if (adds != null && !adds.isEmpty()) { + addNewFields(document, adds, skipped); + } + } + + /** Adds an entry to a skip list that may be absent, so call sites stay one-liners. */ + private void recordSkip( + List skipped, String operation, String target, String reason) { + if (skipped != null) { + skipped.add(new SkippedFieldEdit(operation, target, reason)); + } + } + public void deleteFormFields(PDDocument document, List fieldNames) { + deleteFormFields(document, fieldNames, null); + } + + public void deleteFormFields( + PDDocument document, List fieldNames, List skipped) { if (document == null || fieldNames == null || fieldNames.isEmpty()) return; PDAcroForm acroForm = getAcroFormSafely(document); if (acroForm == null) { log.warn("Cannot delete fields because the document has no AcroForm"); + for (String name : fieldNames) { + recordSkip(skipped, "delete", name, "the document has no form to delete from"); + } return; } @@ -1847,6 +3053,7 @@ public class FormUtils { PDField field = locateField(acroForm, name.trim()); if (field == null) { log.warn("No matching field '{}' found for deletion", name); + recordSkip(skipped, "delete", name, "no field with that name exists"); continue; } @@ -2138,17 +3345,122 @@ public class FormUtils { return Collections.emptySet(); } Set existing = new HashSet<>(); + // Group (non-terminal) names occupy the namespace too, so a new field must not be + // allowed to take one; omitting them hides a whole class of collision. for (PDField field : acroForm.getFieldTree()) { - if (field instanceof PDTerminalField) { - String fqn = field.getFullyQualifiedName(); - if (fqn != null && !fqn.isEmpty()) { - existing.add(fqn); - } + String fqn = field.getFullyQualifiedName(); + if (fqn != null && !fqn.isEmpty()) { + existing.add(fqn); } } return existing; } + /** A text field's /MaxLen, or null when unset so the editor shows an empty box. */ + private Integer extractMaxLength(PDField field) { + if (field instanceof PDTextField textField) { + int maxLen = textField.getMaxLen(); + return maxLen > 0 ? maxLen : null; + } + return null; + } + + /** Reads a push button's action back into the same spec string the editor sends. */ + private String extractButtonAction(PDField field) { + if (!(field instanceof PDPushButton)) { + return null; + } + for (PDAnnotationWidget widget : field.getWidgets()) { + COSBase raw = widget.getCOSObject().getDictionaryObject(COSName.A); + if (!(raw instanceof COSDictionary action)) { + continue; + } + String subtype = action.getNameAsString(COSName.S); + if ("ResetForm".equals(subtype)) { + return "reset"; + } + if ("Named".equals(subtype)) { + return "Print".equalsIgnoreCase(action.getNameAsString(COSName.N)) ? "print" : null; + } + if ("URI".equals(subtype)) { + return "uri:" + Optional.ofNullable(action.getString(COSName.URI)).orElse(""); + } + if ("SubmitForm".equals(subtype)) { + return "submit:" + Optional.ofNullable(action.getString(COSName.F)).orElse(""); + } + } + return null; + } + + /** The parent prefix of a qualified name, including the trailing dot, or "" if top level. */ + private String parentPrefix(String qualifiedName) { + int dot = qualifiedName == null ? -1 : qualifiedName.lastIndexOf('.'); + return dot < 0 ? "" : qualifiedName.substring(0, dot + 1); + } + + /** + * The partial name a rename should set. Only a new name under the target's own parent may be + * qualified; anything else is used verbatim so it cannot silently re-parent the field. + */ + private String leafName(String targetName, String newName) { + String prefix = parentPrefix(targetName); + return !prefix.isEmpty() && newName.startsWith(prefix) + ? newName.substring(prefix.length()) + : newName; + } + + /** + * Why the rename is impossible, or null. An unchanged name is not a rename, so a field nested + * under a parent is not rejected for the period in its qualified name. + */ + public String renameProblem(String targetName, String newName) { + if (newName == null || newName.equals(targetName)) { + return null; + } + // A nested field's box shows "Parent.Child", so renaming the leaf under the same + // parent is legitimate; only the leaf has to be a storable partial name. + String trimmed = newName.trim(); + String leaf = leafName(targetName, trimmed); + if (!trimmed.isEmpty() && leaf.isBlank()) { + return "Field name '" + newName + "' has no name after the parent prefix."; + } + return invalidFieldNameReason(leaf); + } + + /** + * Why {@code name} is unusable as a field name, or null when it is fine. AcroForm reserves the + * period as the parent/child separator, so PDFBox rejects it outright in a partial name. + */ + public String invalidFieldNameReason(String name) { + if (name == null || name.isBlank()) { + return null; + } + if (name.chars().anyMatch(Character::isISOControl)) { + // A line break in a name would also forge a second line in every log it reaches. + return "Field name cannot contain line breaks or control characters."; + } + if (name.indexOf('.') >= 0) { + return "Field name '" + + sanitizeForLog(name) + + "' cannot contain a period. PDF forms use '.' to separate a parent field" + + " from its children."; + } + return null; + } + + /** + * A caller-supplied string made safe to log. Without this a name containing CR/LF writes an + * extra, attacker-chosen line into the log file (CWE-117). + */ + public static String sanitizeForLog(String value) { + if (value == null) { + return null; + } + StringBuilder out = new StringBuilder(value.length()); + value.chars().forEach(c -> out.append(Character.isISOControl(c) ? ' ' : (char) c)); + return out.toString(); + } + private PDField locateField(PDAcroForm acroForm, String name) { if (acroForm == null || name == null) { return null; @@ -2185,20 +3497,26 @@ public class FormUtils { } private String generateUniqueFieldName(String baseName, Set existingNames) { - String sanitized = + return generateUniqueFieldName(baseName, existingNames, ""); + } + + /** + * A partial name no sibling already uses. {@code qualifiedPrefix} is prepended only for the + * collision check, because {@code existingNames} holds fully qualified names. + */ + private String generateUniqueFieldName( + String baseName, Set existingNames, String qualifiedPrefix) { + // Trimmed, not sanitized: callers must reject bad names first via invalidFieldNameReason. + String trimmed = Optional.ofNullable(baseName) .map(String::trim) .filter(s -> !s.isEmpty()) .orElse("field"); - StringBuilder candidateBuilder = new StringBuilder(sanitized); - String candidate = candidateBuilder.toString(); + String candidate = trimmed; int counter = 1; - - while (existingNames.contains(candidate)) { - candidateBuilder.setLength(0); - candidateBuilder.append(sanitized).append("_").append(counter); - candidate = candidateBuilder.toString(); + while (existingNames.contains(qualifiedPrefix + candidate)) { + candidate = trimmed + "_" + counter; counter++; } @@ -2235,9 +3553,29 @@ public class FormUtils { } } field.setRequired(Boolean.TRUE.equals(definition.required())); + if (Boolean.TRUE.equals(definition.readOnly())) { + field.setReadOnly(true); + } - PDAnnotationWidget widget = - existingWidget != null ? existingWidget : new PDAnnotationWidget(); + // A terminal field with no /Kids shares its dictionary with one merged widget. + // A separately built widget is not linked via /Kids, so its /Rect is lost on save. + boolean reuseFieldDict; + PDAnnotationWidget widget; + if (existingWidget != null) { + widget = existingWidget; + reuseFieldDict = false; + } else { + List current = field.getWidgets(); + if (current != null && !current.isEmpty()) { + widget = current.get(0); + } else { + widget = new PDAnnotationWidget(); + } + reuseFieldDict = widget.getCOSObject() == field.getCOSObject(); + // Make sure the shared dictionary is recognised as a widget annotation. + widget.getCOSObject().setItem(COSName.TYPE, COSName.getPDFName("Annot")); + widget.getCOSObject().setItem(COSName.SUBTYPE, COSName.getPDFName("Widget")); + } // Ensure rectangle is valid and set before any appearance-related operations // please note removal of this might cause **subtle** issues @@ -2250,10 +3588,10 @@ public class FormUtils { } widget.setRectangle(validRectangle); widget.setPage(page); - - if (existingWidget == null) { - widget.setPrinted(true); - } + // Explicitly set the /P entry so the widget keeps a valid page reference + // after save/reload (some viewers rely on it to resolve the widget page). + widget.getCOSObject().setItem(COSName.P, page.getCOSObject()); + widget.setPrinted(true); if (definition.tooltip() != null && !definition.tooltip().isBlank()) { widget.getCOSObject().setString(COSName.TU, definition.tooltip()); @@ -2265,13 +3603,25 @@ public class FormUtils { } } - field.getWidgets().add(widget); - widget.setParent(field); + // Only link a SEPARATE widget into the field; the merged widget IS the + // field dictionary and is already its own widget. + if (!reuseFieldDict) { + List widgets = new ArrayList<>(field.getWidgets()); + if (!widgets.contains(widget)) { + widgets.add(widget); + field.setWidgets(widgets); + } + widget.setParent(field); + } List annotations = page.getAnnotations(); if (annotations == null) { - page.getAnnotations().add(widget); - } else if (!annotations.contains(widget)) { + // page.getAnnotations() can return null; calling it again and adding + // would NPE. Initialise the list and attach it to the page first. + annotations = new ArrayList<>(); + page.setAnnotations(annotations); + } + if (!annotations.contains(widget)) { annotations.add(widget); } acroForm.getFields().add(field); @@ -2399,7 +3749,60 @@ public class FormUtils { Boolean multiSelect, List options, String defaultValue, - String tooltip) {} + String tooltip, + Float fontSize, + Boolean readOnly, + Boolean multiline, + Integer maxLength, + String buttonAction, + /** Gap between radio options in points; derived from the drawn box when null. */ + Float optionGap, + /** Radio option size in points; derived from the drawn box when null. */ + Float optionSize) { + + /** The shape before option layout was tunable; both extras default to derived. */ + public NewFormFieldDefinition( + String name, + String label, + String type, + Integer pageIndex, + Float x, + Float y, + Float width, + Float height, + Boolean required, + Boolean multiSelect, + List options, + String defaultValue, + String tooltip, + Float fontSize, + Boolean readOnly, + Boolean multiline, + Integer maxLength, + String buttonAction) { + this( + name, + label, + type, + pageIndex, + x, + y, + width, + height, + required, + multiSelect, + options, + defaultValue, + tooltip, + fontSize, + readOnly, + multiline, + maxLength, + buttonAction, + null, + null); + } + } @JsonInclude(JsonInclude.Include.NON_NULL) public record ModifyFormFieldDefinition( @@ -2407,11 +3810,120 @@ public class FormUtils { String name, String label, String type, + Integer pageIndex, + Float x, + Float y, + Float width, + Float height, Boolean required, Boolean multiSelect, List options, String defaultValue, - String tooltip) {} + String tooltip, + Float fontSize, + Boolean readOnly, + Boolean multiline, + Integer maxLength, + String buttonAction, + /** Gap between radio options in points; leaves the existing layout alone when null. */ + Float optionGap, + /** Radio option size in points; leaves the existing layout alone when null. */ + Float optionSize) { + + /** The shape before option layout was tunable; both extras default to unchanged. */ + public ModifyFormFieldDefinition( + String targetName, + String name, + String label, + String type, + Integer pageIndex, + Float x, + Float y, + Float width, + Float height, + Boolean required, + Boolean multiSelect, + List options, + String defaultValue, + String tooltip, + Float fontSize, + Boolean readOnly, + Boolean multiline, + Integer maxLength, + String buttonAction) { + this( + targetName, + name, + label, + type, + pageIndex, + x, + y, + width, + height, + required, + multiSelect, + options, + defaultValue, + tooltip, + fontSize, + readOnly, + multiline, + maxLength, + buttonAction, + null, + null); + } + } + + /** A mixed batch of field edits applied in one request via {@link #applyFieldEdits}. */ + @JsonInclude(JsonInclude.Include.NON_NULL) + public record FieldEditBatch( + List add, + List modify, + List delete) {} + + /** + * One requested edit the document could not take, so the caller can report "3 of 4" rather than + * a bare success. {@code operation} is "add", "modify" or "delete". + */ + @JsonInclude(JsonInclude.Include.NON_NULL) + /** + * Turns a library failure into something a person can act on. Raw messages like "/DR is a + * required entry" name PDF internals the user has never heard of. + */ + public static String readableFailure(Throwable failure) { + String raw = failure == null ? null : failure.getMessage(); + if (raw == null || raw.isBlank()) { + return "this PDF would not accept the change"; + } + String lower = raw.toLowerCase(java.util.Locale.ROOT); + if (lower.contains("/dr") || lower.contains("default resources")) { + return "this PDF's form has no font settings, so the field could not be styled"; + } + if (lower.contains("font") && lower.contains("not")) { + return "the font this field asks for is not embedded in the PDF"; + } + if (lower.contains("encrypt") || lower.contains("password")) { + return "the PDF is protected, so its form cannot be changed"; + } + if (lower.contains("read-only") || lower.contains("readonly")) { + return "the field is read-only in this PDF"; + } + // Anything unrecognised stays vague rather than leaking internals at the user. + log.debug("Unmapped form edit failure: {}", raw); + return "this PDF would not accept the change"; + } + + /** Skip reasons travel in a response header, so an echoed value cannot be unbounded. */ + public static String abbreviate(String value, int max) { + if (value == null || value.length() <= max) { + return value; + } + return value.substring(0, max) + "..."; + } + + public record SkippedFieldEdit(String operation, String target, String reason) {} @JsonInclude(JsonInclude.Include.NON_NULL) public record FormFieldInfo( @@ -2433,19 +3945,25 @@ public class FormUtils { static final class FieldCoordinateComparator implements Comparator { private static int firstWidgetPageIndex(FormFieldWithCoordinates f) { - return (f.getWidgets() != null && !f.getWidgets().isEmpty()) + return (f.getWidgets() != null + && !f.getWidgets().isEmpty() + && f.getWidgets().getFirst() != null) ? f.getWidgets().getFirst().getPageIndex() : -1; } private static float firstWidgetY(FormFieldWithCoordinates f) { - return (f.getWidgets() != null && !f.getWidgets().isEmpty()) + return (f.getWidgets() != null + && !f.getWidgets().isEmpty() + && f.getWidgets().getFirst() != null) ? f.getWidgets().getFirst().getY() : 0; } private static float firstWidgetX(FormFieldWithCoordinates f) { - return (f.getWidgets() != null && !f.getWidgets().isEmpty()) + return (f.getWidgets() != null + && !f.getWidgets().isEmpty() + && f.getWidgets().getFirst() != null) ? f.getWidgets().getFirst().getX() : 0; } diff --git a/app/common/src/main/java/stirling/software/common/util/RequestUriUtils.java b/app/common/src/main/java/stirling/software/common/util/RequestUriUtils.java index f85880df5d..bfd3f53c67 100644 --- a/app/common/src/main/java/stirling/software/common/util/RequestUriUtils.java +++ b/app/common/src/main/java/stirling/software/common/util/RequestUriUtils.java @@ -4,7 +4,14 @@ import java.util.regex.Pattern; public class RequestUriUtils { - private static final Pattern SHARE_LINK_PATTERN = Pattern.compile("^/share/[^/]+/?$"); + // Share tokens are 36-char lowercase UUIDs (UUID.randomUUID().toString()); match exactly + private static final Pattern SHARE_LINK_PATTERN = + Pattern.compile( + "^/share/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/?$"); + // Invite tokens are 36-char lowercase UUIDs (UUID.randomUUID().toString()); match exactly + private static final Pattern INVITE_LINK_PATTERN = + Pattern.compile( + "^/invite/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/?$"); public static boolean isStaticResource(String requestURI) { return isStaticResource("", requestURI); @@ -69,7 +76,7 @@ public class RequestUriUtils { // cookie, so the server can't authenticate the navigation itself). The // portal gates access via its own auth gate + RequirePortalAccess, and its // data APIs stay protected, so serving the shell pre-auth is safe. - if (normalizedUri.equals("/processor") || normalizedUri.startsWith("/processor/")) { + if ("/processor".equals(normalizedUri) || normalizedUri.startsWith("/processor/")) { return true; } @@ -209,7 +216,9 @@ public class RequestUriUtils { // Workflow participant endpoints - access controlled by share tokens, not login || trimmedUri.startsWith("/api/v1/workflow/participant/") // Share-link SPA bootstrap; data APIs remain protected - || SHARE_LINK_PATTERN.matcher(trimmedUri).matches(); + || SHARE_LINK_PATTERN.matcher(trimmedUri).matches() + // Invite-accept SPA bootstrap; data APIs remain protected + || INVITE_LINK_PATTERN.matcher(trimmedUri).matches(); } private static String stripContextPath(String contextPath, String requestURI) { diff --git a/app/common/src/test/java/stirling/software/SPDF/config/EndpointConfigurationGapTest.java b/app/common/src/test/java/stirling/software/SPDF/config/EndpointConfigurationGapTest.java index 6275b49343..afc6fa7020 100644 --- a/app/common/src/test/java/stirling/software/SPDF/config/EndpointConfigurationGapTest.java +++ b/app/common/src/test/java/stirling/software/SPDF/config/EndpointConfigurationGapTest.java @@ -17,6 +17,7 @@ import org.junit.jupiter.api.Test; import stirling.software.SPDF.config.EndpointConfiguration.DisableReason; import stirling.software.SPDF.config.EndpointConfiguration.EndpointAvailability; import stirling.software.common.model.ApplicationProperties; +import stirling.software.common.service.PdfaLevelAServiceInterface; /** * Unit tests for {@link EndpointConfiguration}. The class wires up its endpoint/group registry in @@ -32,7 +33,14 @@ class EndpointConfigurationGapTest { * Construct an EndpointConfiguration with the given pro flag and current applicationProperties. */ private EndpointConfiguration build(boolean runningProOrHigher) { - return new EndpointConfiguration(applicationProperties, runningProOrHigher); + return build(runningProOrHigher, null); + } + + /** The PDF/UA service is only present in proprietary builds, so it is injected separately. */ + private EndpointConfiguration build( + boolean runningProOrHigher, PdfaLevelAServiceInterface pdfaLevelAService) { + return new EndpointConfiguration( + applicationProperties, runningProOrHigher, pdfaLevelAService); } /** Default config: not pro, no removals, url-to-pdf disabled (default System flag is false). */ @@ -177,6 +185,28 @@ class EndpointConfigurationGapTest { } } + @Nested + @DisplayName("PDF/UA availability") + class PdfUaTests { + + @Test + @DisplayName("the PDF/UA endpoints are off when the proprietary tagger is absent") + void disabledWithoutTagger() { + EndpointConfiguration config = build(false, null); + assertFalse(config.isEndpointEnabled("pdf-to-ua")); + assertFalse(config.isEndpointEnabled("accessibility-report")); + } + + @Test + @DisplayName("they are on once the tagger is on the classpath") + void enabledWithTagger() { + EndpointConfiguration config = + build(false, (pdfBytes, part, language, title, alsoDeclareUa) -> null); + assertTrue(config.isEndpointEnabled("pdf-to-ua")); + assertTrue(config.isEndpointEnabled("accessibility-report")); + } + } + @Nested @DisplayName("group enable / disable") class GroupTests { diff --git a/app/common/src/test/java/stirling/software/common/util/ChoiceOptionRoundTripTest.java b/app/common/src/test/java/stirling/software/common/util/ChoiceOptionRoundTripTest.java new file mode 100644 index 0000000000..b5312576e4 --- /dev/null +++ b/app/common/src/test/java/stirling/software/common/util/ChoiceOptionRoundTripTest.java @@ -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 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 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 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"); + } + } +} diff --git a/app/common/src/test/java/stirling/software/common/util/DeepFieldTreeTest.java b/app/common/src/test/java/stirling/software/common/util/DeepFieldTreeTest.java new file mode 100644 index 0000000000..4c28952ff0 --- /dev/null +++ b/app/common/src/test/java/stirling/software/common/util/DeepFieldTreeTest.java @@ -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)); + } + } +} diff --git a/app/common/src/test/java/stirling/software/common/util/FormEditSafetyTest.java b/app/common/src/test/java/stirling/software/common/util/FormEditSafetyTest.java new file mode 100644 index 0000000000..1f213c15ba --- /dev/null +++ b/app/common/src/test/java/stirling/software/common/util/FormEditSafetyTest.java @@ -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 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 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 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 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 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 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 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"); + } + } +} diff --git a/app/common/src/test/java/stirling/software/common/util/FormFieldNameSafetyTest.java b/app/common/src/test/java/stirling/software/common/util/FormFieldNameSafetyTest.java new file mode 100644 index 0000000000..c1b0c806e1 --- /dev/null +++ b/app/common/src/test/java/stirling/software/common/util/FormFieldNameSafetyTest.java @@ -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); + } +} diff --git a/app/common/src/test/java/stirling/software/common/util/FormFieldTypeSupportTest.java b/app/common/src/test/java/stirling/software/common/util/FormFieldTypeSupportTest.java index 771ce89659..6924764a65 100644 --- a/app/common/src/test/java/stirling/software/common/util/FormFieldTypeSupportTest.java +++ b/app/common/src/test/java/stirling/software/common/util/FormFieldTypeSupportTest.java @@ -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 diff --git a/app/common/src/test/java/stirling/software/common/util/FormUtilsEditRegressionTest.java b/app/common/src/test/java/stirling/software/common/util/FormUtilsEditRegressionTest.java new file mode 100644 index 0000000000..408380b790 --- /dev/null +++ b/app/common/src/test/java/stirling/software/common/util/FormUtilsEditRegressionTest.java @@ -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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 skipped = new ArrayList<>(); + FormUtils.modifyFormFields(document, List.of(refused, rename), skipped); + + List 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 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"); + } + } +} diff --git a/app/common/src/test/java/stirling/software/common/util/FormUtilsEditReportingTest.java b/app/common/src/test/java/stirling/software/common/util/FormUtilsEditReportingTest.java new file mode 100644 index 0000000000..2c606d481a --- /dev/null +++ b/app/common/src/test/java/stirling/software/common/util/FormUtilsEditReportingTest.java @@ -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 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 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"); + } + } +} diff --git a/app/common/src/test/java/stirling/software/common/util/FormUtilsEditingTest.java b/app/common/src/test/java/stirling/software/common/util/FormUtilsEditingTest.java new file mode 100644 index 0000000000..a97c06daba --- /dev/null +++ b/app/common/src/test/java/stirling/software/common/util/FormUtilsEditingTest.java @@ -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 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 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"); + } + } +} diff --git a/app/common/src/test/java/stirling/software/common/util/FormUtilsGapTest.java b/app/common/src/test/java/stirling/software/common/util/FormUtilsGapTest.java index dd828327b1..5c13b13908 100644 --- a/app/common/src/test/java/stirling/software/common/util/FormUtilsGapTest.java +++ b/app/common/src/test/java/stirling/software/common/util/FormUtilsGapTest.java @@ -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()); diff --git a/app/common/src/test/java/stirling/software/common/util/FormUtilsMoreTest.java b/app/common/src/test/java/stirling/software/common/util/FormUtilsMoreTest.java index f4e013e082..fa3425176a 100644 --- a/app/common/src/test/java/stirling/software/common/util/FormUtilsMoreTest.java +++ b/app/common/src/test/java/stirling/software/common/util/FormUtilsMoreTest.java @@ -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 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)); } } diff --git a/app/common/src/test/java/stirling/software/common/util/FormUtilsNoAcroFormTest.java b/app/common/src/test/java/stirling/software/common/util/FormUtilsNoAcroFormTest.java new file mode 100644 index 0000000000..d8f5e1deb2 --- /dev/null +++ b/app/common/src/test/java/stirling/software/common/util/FormUtilsNoAcroFormTest.java @@ -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 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 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")); + } + } +} diff --git a/app/common/src/test/java/stirling/software/common/util/FormUtilsRadioCaptionTest.java b/app/common/src/test/java/stirling/software/common/util/FormUtilsRadioCaptionTest.java new file mode 100644 index 0000000000..04b317feb8 --- /dev/null +++ b/app/common/src/test/java/stirling/software/common/util/FormUtilsRadioCaptionTest.java @@ -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 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"); + } +} diff --git a/app/common/src/test/java/stirling/software/common/util/MissingDefaultResourcesTest.java b/app/common/src/test/java/stirling/software/common/util/MissingDefaultResourcesTest.java new file mode 100644 index 0000000000..8c8d7e14be --- /dev/null +++ b/app/common/src/test/java/stirling/software/common/util/MissingDefaultResourcesTest.java @@ -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 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"); + } + } +} diff --git a/app/common/src/test/java/stirling/software/common/util/RequestUriUtilsTest.java b/app/common/src/test/java/stirling/software/common/util/RequestUriUtilsTest.java index 0e399c1fae..72e5eae9a2 100644 --- a/app/common/src/test/java/stirling/software/common/util/RequestUriUtilsTest.java +++ b/app/common/src/test/java/stirling/software/common/util/RequestUriUtilsTest.java @@ -206,12 +206,24 @@ class RequestUriUtilsTest { @Test void testIsPublicAuthEndpoint_shareLinkTokenTrailingSlash() { - assertTrue(RequestUriUtils.isPublicAuthEndpoint("/share/abc123/", "")); + assertTrue( + RequestUriUtils.isPublicAuthEndpoint( + "/share/00dcac3a-fc7a-4989-9c4f-97745484d62f/", "")); } @Test void testIsPublicAuthEndpoint_shareLinkWithContextPath() { - assertTrue(RequestUriUtils.isPublicAuthEndpoint("/app/share/abc123", "/app")); + assertTrue( + RequestUriUtils.isPublicAuthEndpoint( + "/app/share/00dcac3a-fc7a-4989-9c4f-97745484d62f", "/app")); + } + + @Test + void testIsPublicAuthEndpoint_shareLinkWithInvalidTokenLength() { + assertFalse(RequestUriUtils.isPublicAuthEndpoint("/share/abc123", "")); + assertFalse( + RequestUriUtils.isPublicAuthEndpoint( + "/share/00dcac3a-fc7a-4989-9c4f-97745484d62fa", "")); } @Test @@ -236,4 +248,86 @@ class RequestUriUtilsTest { RequestUriUtils.isPublicAuthEndpoint( "/api/v1/storage/share-links/abc123/metadata", "")); } + + // --- invite-accept SPA bootstrap --- + + private static final String INVITE_TOKEN = "06a20e7e-2e35-4e26-be7d-2dce14f28f12"; + + @Test + void testIsPublicAuthEndpoint_inviteLinkToken() { + assertTrue(RequestUriUtils.isPublicAuthEndpoint("/invite/" + INVITE_TOKEN, "")); + } + + @Test + void testIsPublicAuthEndpoint_inviteLinkTokenTrailingSlash() { + assertTrue(RequestUriUtils.isPublicAuthEndpoint("/invite/" + INVITE_TOKEN + "/", "")); + } + + @Test + void testIsPublicAuthEndpoint_inviteLinkWithContextPath() { + assertTrue(RequestUriUtils.isPublicAuthEndpoint("/app/invite/" + INVITE_TOKEN, "/app")); + } + + @Test + void testIsPublicAuthEndpoint_inviteRootNotPublic() { + // Avoid matching bare "/invite" or "/invite/" - must have a token segment + assertFalse(RequestUriUtils.isPublicAuthEndpoint("/invite", "")); + assertFalse(RequestUriUtils.isPublicAuthEndpoint("/invite/", "")); + } + + @Test + void testIsPublicAuthEndpoint_inviteNestedPathNotPublic() { + // Guard against future additions like /invite//foo becoming accidentally public + assertFalse(RequestUriUtils.isPublicAuthEndpoint("/invite/" + INVITE_TOKEN + "/foo", "")); + } + + @Test + void testIsPublicAuthEndpoint_invitePrefixDoesNotOvermatch() { + // "/inviteX" must not match the invite pattern + assertFalse(RequestUriUtils.isPublicAuthEndpoint("/inviteX", "")); + } + + @Test + void testIsPublicAuthEndpoint_inviteNonUuidTokenNotPublic() { + // Only exactly-shaped 36-char lowercase UUID tokens are treated as invite links + assertFalse(RequestUriUtils.isPublicAuthEndpoint("/invite/abc123", "")); + } + + @Test + void testIsPublicAuthEndpoint_inviteUppercaseUuidNotPublic() { + // Tokens are generated lowercase by UUID.randomUUID().toString() + assertFalse( + RequestUriUtils.isPublicAuthEndpoint( + "/invite/06A20E7E-2E35-4E26-BE7D-2DCE14F28F12", "")); + } + + @Test + void testIsPublicAuthEndpoint_inviteWrongLengthNotPublic() { + // 35-char and 37-char UUID-like tokens are not valid UUIDs + assertFalse( + RequestUriUtils.isPublicAuthEndpoint( + "/invite/06a20e7e-2e35-4e26-be7d-2dce14f28f1", "")); + assertFalse( + RequestUriUtils.isPublicAuthEndpoint( + "/invite/06a20e7e-2e35-4e26-be7d-2dce14f28f122", "")); + } + + @Test + void testIsPublicAuthEndpoint_inviteWrongGroupingNotPublic() { + // Groups of 8-4-4-4-4 must not be shifted around (e.g. 4-4-4-4-8) + assertFalse( + RequestUriUtils.isPublicAuthEndpoint( + "/invite/06a2-0e7e-2e35-4e26-be7d2dce14f28f12", "")); + } + + @Test + void testIsPublicAuthEndpoint_inviteTokenInvalidCharsNotPublic() { + // Hex-only; anything outside [0-9a-f] or the UUID hyphens is rejected + assertFalse(RequestUriUtils.isPublicAuthEndpoint("/invite/abc$123", "")); + assertFalse(RequestUriUtils.isPublicAuthEndpoint("/invite/abc..123", "")); + assertFalse(RequestUriUtils.isPublicAuthEndpoint("/invite/abc%2F123", "")); + assertFalse( + RequestUriUtils.isPublicAuthEndpoint( + "/invite/06a20e7e-2e35-4e26-be7d-2dce14f28f1g", "")); + } } diff --git a/app/core/src/main/java/stirling/software/SPDF/config/WebMvcConfig.java b/app/core/src/main/java/stirling/software/SPDF/config/WebMvcConfig.java index 2726f8d764..01d3f49e2c 100644 --- a/app/core/src/main/java/stirling/software/SPDF/config/WebMvcConfig.java +++ b/app/core/src/main/java/stirling/software/SPDF/config/WebMvcConfig.java @@ -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); } diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/converters/ConvertPDFToPDFA.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/converters/ConvertPDFToPDFA.java index 49bf4e4895..0354817315 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/converters/ConvertPDFToPDFA.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/converters/ConvertPDFToPDFA.java @@ -11,6 +11,7 @@ import java.time.Instant; import java.time.ZoneId; import java.time.ZonedDateTime; import java.util.*; +import java.util.Locale; import java.util.regex.Pattern; import java.util.stream.Collectors; import java.util.stream.Stream; @@ -71,6 +72,7 @@ import org.apache.xmpbox.schema.PDFAIdentificationSchema; import org.apache.xmpbox.schema.XMPBasicSchema; import org.apache.xmpbox.xml.DomXmpParser; import org.apache.xmpbox.xml.XmpSerializer; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.core.io.Resource; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; @@ -83,7 +85,6 @@ import io.github.pixee.security.Filenames; import io.swagger.v3.oas.annotations.Operation; import lombok.Getter; -import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import stirling.software.SPDF.model.api.converters.PdfToPdfARequest; @@ -93,6 +94,7 @@ import stirling.software.common.configuration.RuntimePathConfig; import stirling.software.common.enumeration.ResourceWeight; import stirling.software.common.model.tool.ToolFormat; import stirling.software.common.model.tool.ToolIO; +import stirling.software.common.service.PdfaLevelAServiceInterface; import stirling.software.common.util.ExceptionUtils; import stirling.software.common.util.ProcessExecutor; import stirling.software.common.util.ProcessExecutor.ProcessExecutorResult; @@ -102,14 +104,26 @@ import stirling.software.common.util.WebResponseUtils; @ConvertApi @Slf4j -@RequiredArgsConstructor public class ConvertPDFToPDFA { private static final Pattern NON_PRINTABLE_ASCII = Pattern.compile("[^\\x20-\\x7E]"); private final RuntimePathConfig runtimePathConfig; private final stirling.software.SPDF.service.VeraPDFService veraPDFService; + // Level A needs the proprietary tagger; core builds convert at level B instead. + private final PdfaLevelAServiceInterface pdfaLevelAService; private final TempFileManager tempFileManager; + public ConvertPDFToPDFA( + RuntimePathConfig runtimePathConfig, + stirling.software.SPDF.service.VeraPDFService veraPDFService, + @Autowired(required = false) PdfaLevelAServiceInterface pdfaLevelAService, + TempFileManager tempFileManager) { + this.runtimePathConfig = runtimePathConfig; + this.veraPDFService = veraPDFService; + this.pdfaLevelAService = pdfaLevelAService; + this.tempFileManager = tempFileManager; + } + private static final String ICC_RESOURCE_PATH = "/icc/sRGB2014.icc"; private static final int PDFA_COMPATIBILITY_POLICY = 1; @@ -604,7 +618,10 @@ public class ConvertPDFToPDFA { return handlePdfXConversion(inputFile, outputFormat); } else { return handlePdfAConversion( - inputFile, outputFormat, request.getStrict() != null && request.getStrict()); + inputFile, + outputFormat, + request.getStrict() != null && request.getStrict(), + request.getPdfUa() != null && request.getPdfUa()); } } @@ -1815,8 +1832,64 @@ public class ConvertPDFToPDFA { return Files.readAllBytes(outputPdf); } + /** Tags a converted PDF/A for level A; must run after Ghostscript, which discards tags. */ + private PdfaLevelAServiceInterface.Result applyLevelA( + byte[] converted, + Path original, + PdfaProfile profile, + String baseFileName, + boolean declarePdfUa) { + if (!profile.requiresTagging()) { + return new PdfaLevelAServiceInterface.Result(converted, true, List.of()); + } + if (pdfaLevelAService == null) { + return new PdfaLevelAServiceInterface.Result( + converted, + false, + List.of( + "Level A tagging is not available in this build, so the file was left" + + " at conformance level B.")); + } + // Prefer the document's own title/language; hardcoding "en" mislabelled German reports. + // Read the original, not the converted bytes: Ghostscript discards /Lang, so probing its + // output always yields null and every document would be relabelled with the default. + String language = null; + String title = null; + try (PDDocument probe = Loader.loadPDF(original.toFile())) { + language = probe.getDocumentCatalog().getLanguage(); + title = probe.getDocumentInformation().getTitle(); + } catch (IOException e) { + log.debug("Could not read original title/language: {}", e.getMessage()); + } + if (language == null || language.isBlank()) { + try (PDDocument probe = Loader.loadPDF(converted)) { + language = probe.getDocumentCatalog().getLanguage(); + if (title == null || title.isBlank()) { + title = probe.getDocumentInformation().getTitle(); + } + } catch (IOException e) { + log.debug("Could not read converted title/language: {}", e.getMessage()); + } + } + PdfaLevelAServiceInterface.Result result = + pdfaLevelAService.upgradeToLevelA( + converted, + profile.getPart(), + language, + title != null && !title.isBlank() ? title : baseFileName, + declarePdfUa); + result.warnings().forEach(warning -> log.info("PDF/A level A: {}", warning)); + if (!result.levelA()) { + log.warn( + "{} requested but the document could not be tagged; returning level B", + profile.getDisplayName()); + } + return result; + } + private ResponseEntity handlePdfAConversion( - MultipartFile inputFile, String outputFormat, boolean strict) throws Exception { + MultipartFile inputFile, String outputFormat, boolean strict, boolean declarePdfUa) + throws Exception { PdfaProfile profile = PdfaProfile.fromRequest(outputFormat); // Get the original filename without extension @@ -1841,12 +1914,15 @@ public class ConvertPDFToPDFA { log.info("Using Ghostscript for PDF/A conversion to {}", profile.getDisplayName()); try { converted = convertWithGhostscript(inputPath, workingDir, profile); - String outputFilename = baseFileName + profile.outputSuffix(); + var levelA = + applyLevelA(converted, inputPath, profile, baseFileName, declarePdfUa); + converted = levelA.pdfBytes(); + String outputFilename = baseFileName + profile.outputSuffix(levelA.levelA()); validateAndWarnPdfA(converted, profile, "Ghostscript"); if (strict) { - verifyStrictCompliance(converted); + verifyStrictCompliance(converted, profile, levelA.levelA()); } TempFile tempOut = tempFileManager.createManagedTempFile(".pdf"); @@ -1867,13 +1943,15 @@ public class ConvertPDFToPDFA { } converted = convertWithPdfBoxMethod(inputPath, profile); - String outputFilename = baseFileName + profile.outputSuffix(); + var levelA = applyLevelA(converted, inputPath, profile, baseFileName, declarePdfUa); + converted = levelA.pdfBytes(); + String outputFilename = baseFileName + profile.outputSuffix(levelA.levelA()); // Validate with PDFBox preflight and warn if issues found validateAndWarnPdfA(converted, profile, "PDFBox/LibreOffice"); if (strict) { - verifyStrictCompliance(converted); + verifyStrictCompliance(converted, profile, levelA.levelA()); } TempFile tempOut = tempFileManager.createManagedTempFile(".pdf"); @@ -1889,11 +1967,56 @@ public class ConvertPDFToPDFA { } } - private void verifyStrictCompliance(byte[] pdfBytes) throws IOException { + /** True for a PDF/UA or WCAG result, which says nothing about archival conformance. */ + private static boolean isAccessibilityProfile( + stirling.software.SPDF.model.api.security.PDFVerificationResult result) { + String profile = result.getValidationProfile(); + if (profile == null) { + return false; + } + String normalised = profile.toLowerCase(Locale.ROOT); + return normalised.contains("ua") || normalised.contains("wcag"); + } + + /** + * True when a result speaks for the requested profile. Only archival results count, and a level + * B pass must never satisfy a level A request. + */ + private static boolean answersRequest( + PdfaProfile profile, + stirling.software.SPDF.model.api.security.PDFVerificationResult result) { + if (isAccessibilityProfile(result)) { + return false; + } + String standard = result.getStandard(); + if (standard == null || standard.length() < 2) { + return false; + } + if (standard.charAt(0) != Character.forDigit(profile.getPart(), 10)) { + return false; + } + return !profile.requiresTagging() || Character.toLowerCase(standard.charAt(1)) == 'a'; + } + + private void verifyStrictCompliance(byte[] pdfBytes, PdfaProfile profile, boolean levelAReached) + throws IOException { + // Tagging is the only route to level A, so an untagged file cannot answer a strict request. + if (!levelAReached) { + throw new ResponseStatusException( + HttpStatus.BAD_REQUEST, + "Strict PDF/A mode enabled: the document could not be tagged, so " + + profile.getDisplayName() + + " was not reached. It is valid at level B."); + } try (InputStream is = new ByteArrayInputStream(pdfBytes)) { List results = veraPDFService.validatePDF(is); - boolean isCompliant = results.stream().anyMatch(result -> result.isCompliant()); + boolean isCompliant = + results.stream() + .filter(result -> answersRequest(profile, result)) + .anyMatch( + stirling.software.SPDF.model.api.security.PDFVerificationResult + ::isCompliant); if (!isCompliant) { String details = results.stream() @@ -1901,7 +2024,9 @@ public class ConvertPDFToPDFA { .collect(Collectors.joining("; ")); throw new ResponseStatusException( HttpStatus.BAD_REQUEST, - "Strict PDF/A mode enabled: Conversion is not perfectly compliant. Details: " + "Strict PDF/A mode enabled: the output is not perfectly compliant with " + + profile.getDisplayName() + + ". Details: " + details); } } catch (Exception e) { @@ -2466,11 +2591,16 @@ public class ConvertPDFToPDFA { @Getter private enum PdfaProfile { - PDF_A_1B(1, "PDF/A-1b", "_PDFA-1b.pdf", "1.4", Format.PDF_A1B, "pdfa-1"), - PDF_A_2B(2, "PDF/A-2b", "_PDFA-2b.pdf", "1.7", null, "pdfa", "pdfa-2", "pdfa-2b"), - PDF_A_3B(3, "PDF/A-3b", "_PDFA-3b.pdf", "1.7", null, "pdfa-3", "pdfa-3b"); + PDF_A_1B(1, "B", "PDF/A-1b", "_PDFA-1b.pdf", "1.4", Format.PDF_A1B, "pdfa-1"), + PDF_A_2B(2, "B", "PDF/A-2b", "_PDFA-2b.pdf", "1.7", null, "pdfa", "pdfa-2", "pdfa-2b"), + PDF_A_3B(3, "B", "PDF/A-3b", "_PDFA-3b.pdf", "1.7", null, "pdfa-3", "pdfa-3b"), + // Level A = level B plus tagging, declared language and Unicode text; tagged post-convert. + PDF_A_1A(1, "A", "PDF/A-1a", "_PDFA-1a.pdf", "1.4", Format.PDF_A1B, "pdfa-1a"), + PDF_A_2A(2, "A", "PDF/A-2a", "_PDFA-2a.pdf", "1.7", null, "pdfa-2a"), + PDF_A_3A(3, "A", "PDF/A-3a", "_PDFA-3a.pdf", "1.7", null, "pdfa-3a"); private final int part; + private final String conformanceLevel; private final String displayName; private final String suffix; private final String compatibilityLevel; @@ -2479,12 +2609,14 @@ public class ConvertPDFToPDFA { PdfaProfile( int part, + String conformanceLevel, String displayName, String suffix, String compatibilityLevel, Format preflightFormat, String... requestTokens) { this.part = part; + this.conformanceLevel = conformanceLevel; this.displayName = displayName; this.suffix = suffix; this.compatibilityLevel = compatibilityLevel; @@ -2495,6 +2627,10 @@ public class ConvertPDFToPDFA { .toList(); } + boolean requiresTagging() { + return "A".equals(conformanceLevel); + } + static PdfaProfile fromRequest(String requestToken) { if (requestToken == null) { return PDF_A_2B; @@ -2508,8 +2644,11 @@ public class ConvertPDFToPDFA { return match.orElse(PDF_A_2B); } - String outputSuffix() { - return suffix; + /** + * Names the file at the level actually reached; a level A name over level B content lies. + */ + String outputSuffix(boolean levelAReached) { + return levelAReached ? suffix : "_PDFA-" + part + "b.pdf"; } Optional preflightFormat() { diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/form/FormFillController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/form/FormFillController.java index d24d175f5c..0bd3daf180 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/form/FormFillController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/form/FormFillController.java @@ -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 adds, + List modifies) { + Stream 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 withSkippedEdits( + ResponseEntity response, List 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 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 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 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 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 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 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 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 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 processSingleFile( MultipartFile file, String suffix, DocumentProcessor processor) throws IOException { + return processSingleFile(file, suffix, false, processor); + } + + private ResponseEntity 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 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 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(); } } diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/form/FormPayloadParser.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/form/FormPayloadParser.java index 6f82c7546e..f48f419a6d 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/form/FormPayloadParser.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/form/FormPayloadParser.java @@ -28,6 +28,8 @@ final class FormPayloadParser { private static final TypeReference> MAP_TYPE = new TypeReference<>() {}; private static final TypeReference> MODIFY_FIELD_LIST_TYPE = new TypeReference<>() {}; + private static final TypeReference> NEW_FIELD_LIST_TYPE = + new TypeReference<>() {}; private static final TypeReference> STRING_LIST_TYPE = new TypeReference<>() {}; private FormPayloadParser() {} @@ -94,6 +96,43 @@ final class FormPayloadParser { return objectMapper.readValue(json, MODIFY_FIELD_LIST_TYPE); } + static List 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 adds = List.of(); + List modifies = List.of(); + List 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 parseNameList(ObjectMapper objectMapper, String json) { if (json == null || json.isBlank()) { return List.of(); diff --git a/app/core/src/main/java/stirling/software/SPDF/model/api/converters/PdfToPdfARequest.java b/app/core/src/main/java/stirling/software/SPDF/model/api/converters/PdfToPdfARequest.java index bb0520a4ba..921663912b 100644 --- a/app/core/src/main/java/stirling/software/SPDF/model/api/converters/PdfToPdfARequest.java +++ b/app/core/src/main/java/stirling/software/SPDF/model/api/converters/PdfToPdfARequest.java @@ -14,9 +14,19 @@ public class PdfToPdfARequest extends PDFFile { @Schema( description = "The output format type (PDF/A or PDF/X)", requiredMode = Schema.RequiredMode.REQUIRED, - allowableValues = {"pdfa", "pdfa-1", "pdfa-2", "pdfa-2b", "pdfa-3", "pdfa-3b", "pdfx"}) + allowableValues = { + "pdfa", "pdfa-1", "pdfa-2", "pdfa-2b", "pdfa-3", "pdfa-3b", "pdfa-1a", "pdfa-2a", + "pdfa-3a", "pdfx" + }) private String outputFormat; + @Schema( + description = + "Also declare PDF/UA accessibility alongside PDF/A. Only applies to the level A" + + " formats, and the claim is written only if it validates.", + defaultValue = "false") + private Boolean pdfUa; + @Schema( description = "If true, the conversion will fail if the output is not perfectly compliant") diff --git a/app/core/src/main/java/stirling/software/SPDF/service/VeraPDFService.java b/app/core/src/main/java/stirling/software/SPDF/service/VeraPDFService.java index bb3c84534c..6361157b21 100644 --- a/app/core/src/main/java/stirling/software/SPDF/service/VeraPDFService.java +++ b/app/core/src/main/java/stirling/software/SPDF/service/VeraPDFService.java @@ -285,6 +285,8 @@ public class VeraPDFService { } } + // Never force PDF/UA here - it flags every ordinary document as non-compliant and doubles + // verify cost; /accessibility-report checks PDF/UA on demand. if (!hasPdfaDeclaration) { results.add(createNoPdfaDeclarationResult()); } diff --git a/app/core/src/main/resources/static/3rdPartyLicenses.json b/app/core/src/main/resources/static/3rdPartyLicenses.json index fa852846b3..a0dae640e0 100644 --- a/app/core/src/main/resources/static/3rdPartyLicenses.json +++ b/app/core/src/main/resources/static/3rdPartyLicenses.json @@ -14,6 +14,13 @@ "moduleLicense": "GNU Lesser General Public License", "moduleLicenseUrl": "https://www.gnu.org/licenses/old-licenses/lgpl-2.1.html" }, + { + "moduleName": "ch.qos.logback:logback-classic", + "moduleUrl": "http://www.qos.ch", + "moduleVersion": "1.6.3", + "moduleLicense": "LGPL-2.1-only", + "moduleLicenseUrl": "https://www.gnu.org/licenses/old-licenses/lgpl-2.1.html" + }, { "moduleName": "ch.qos.logback:logback-core", "moduleUrl": "http://www.qos.ch", @@ -21,6 +28,13 @@ "moduleLicense": "GNU Lesser General Public License", "moduleLicenseUrl": "https://www.gnu.org/licenses/old-licenses/lgpl-2.1.html" }, + { + "moduleName": "ch.qos.logback:logback-core", + "moduleUrl": "http://www.qos.ch", + "moduleVersion": "1.6.3", + "moduleLicense": "LGPL-2.1-only", + "moduleLicenseUrl": "https://www.gnu.org/licenses/old-licenses/lgpl-2.1.html" + }, { "moduleName": "com.adobe.xmp:xmpcore", "moduleUrl": "https://www.adobe.com/devnet/xmp/library/eula-xmp-library-java.html", @@ -182,7 +196,7 @@ { "moduleName": "com.github.mwiede:jsch", "moduleUrl": "https://github.com/mwiede/jsch", - "moduleVersion": "0.2.23", + "moduleVersion": "2.28.6", "moduleLicense": "Revised BSD", "moduleLicenseUrl": "https://github.com/mwiede/jsch/blob/master/LICENSE.txt" }, @@ -513,27 +527,45 @@ "moduleLicense": "The BSD License", "moduleLicenseUrl": "https://github.com/haraldk/TwelveMonkeys#license" }, + { + "moduleName": "com.twelvemonkeys.common:common-image", + "moduleVersion": "3.14.0", + "moduleLicense": "The BSD License", + "moduleLicenseUrl": "https://github.com/haraldk/TwelveMonkeys#license" + }, { "moduleName": "com.twelvemonkeys.common:common-io", "moduleVersion": "3.13.1", "moduleLicense": "The BSD License", "moduleLicenseUrl": "https://github.com/haraldk/TwelveMonkeys#license" }, + { + "moduleName": "com.twelvemonkeys.common:common-io", + "moduleVersion": "3.14.0", + "moduleLicense": "The BSD License", + "moduleLicenseUrl": "https://github.com/haraldk/TwelveMonkeys#license" + }, { "moduleName": "com.twelvemonkeys.common:common-lang", "moduleVersion": "3.13.1", "moduleLicense": "The BSD License", "moduleLicenseUrl": "https://github.com/haraldk/TwelveMonkeys#license" }, + { + "moduleName": "com.twelvemonkeys.common:common-lang", + "moduleVersion": "3.14.0", + "moduleLicense": "The BSD License", + "moduleLicenseUrl": "https://github.com/haraldk/TwelveMonkeys#license" + }, { "moduleName": "com.twelvemonkeys.imageio:imageio-batik", - "moduleVersion": "3.13.1", + "moduleVersion": "3.14.0", "moduleLicense": "The BSD License", "moduleLicenseUrl": "https://github.com/haraldk/TwelveMonkeys#license" }, { "moduleName": "com.twelvemonkeys.imageio:imageio-bmp", - "moduleVersion": "3.13.1", + "moduleVersion": "3.14.0", "moduleLicense": "The BSD License", "moduleLicenseUrl": "https://github.com/haraldk/TwelveMonkeys#license" }, @@ -543,9 +575,15 @@ "moduleLicense": "The BSD License", "moduleLicenseUrl": "https://github.com/haraldk/TwelveMonkeys#license" }, + { + "moduleName": "com.twelvemonkeys.imageio:imageio-core", + "moduleVersion": "3.14.0", + "moduleLicense": "The BSD License", + "moduleLicenseUrl": "https://github.com/haraldk/TwelveMonkeys#license" + }, { "moduleName": "com.twelvemonkeys.imageio:imageio-jpeg", - "moduleVersion": "3.13.1", + "moduleVersion": "3.14.0", "moduleLicense": "The BSD License", "moduleLicenseUrl": "https://github.com/haraldk/TwelveMonkeys#license" }, @@ -555,9 +593,15 @@ "moduleLicense": "The BSD License", "moduleLicenseUrl": "https://github.com/haraldk/TwelveMonkeys#license" }, + { + "moduleName": "com.twelvemonkeys.imageio:imageio-metadata", + "moduleVersion": "3.14.0", + "moduleLicense": "The BSD License", + "moduleLicenseUrl": "https://github.com/haraldk/TwelveMonkeys#license" + }, { "moduleName": "com.twelvemonkeys.imageio:imageio-psd", - "moduleVersion": "3.13.1", + "moduleVersion": "3.14.0", "moduleLicense": "The BSD License", "moduleLicenseUrl": "https://github.com/haraldk/TwelveMonkeys#license" }, @@ -567,12 +611,24 @@ "moduleLicense": "The BSD License", "moduleLicenseUrl": "https://github.com/haraldk/TwelveMonkeys#license" }, + { + "moduleName": "com.twelvemonkeys.imageio:imageio-tiff", + "moduleVersion": "3.14.0", + "moduleLicense": "The BSD License", + "moduleLicenseUrl": "https://github.com/haraldk/TwelveMonkeys#license" + }, { "moduleName": "com.twelvemonkeys.imageio:imageio-webp", "moduleVersion": "3.13.1", "moduleLicense": "The BSD License", "moduleLicenseUrl": "https://github.com/haraldk/TwelveMonkeys#license" }, + { + "moduleName": "com.twelvemonkeys.imageio:imageio-webp", + "moduleVersion": "3.14.0", + "moduleLicense": "The BSD License", + "moduleLicenseUrl": "https://github.com/haraldk/TwelveMonkeys#license" + }, { "moduleName": "com.vladsch.flexmark:flexmark", "moduleVersion": "0.64.8", @@ -758,7 +814,7 @@ { "moduleName": "commons-net:commons-net", "moduleUrl": "https://commons.apache.org/proper/commons-net/", - "moduleVersion": "3.11.1", + "moduleVersion": "3.13.0", "moduleLicense": "Apache-2.0", "moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0.txt" }, @@ -1008,21 +1064,14 @@ { "moduleName": "io.swagger.core.v3:swagger-annotations-jakarta", "moduleUrl": "https://github.com/swagger-api/swagger-core/modules/swagger-annotations", - "moduleVersion": "2.2.46", + "moduleVersion": "2.2.47", "moduleLicense": "Apache License, Version 2.0", "moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0" }, { "moduleName": "io.swagger.core.v3:swagger-annotations-jakarta", "moduleUrl": "https://github.com/swagger-api/swagger-core/modules/swagger-annotations", - "moduleVersion": "2.2.47", - "moduleLicense": "Apache License, Version 2.0", - "moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0" - }, - { - "moduleName": "io.swagger.core.v3:swagger-core-jakarta", - "moduleUrl": "https://github.com/swagger-api/swagger-core/modules/swagger-core", - "moduleVersion": "2.2.46", + "moduleVersion": "2.2.53", "moduleLicense": "Apache License, Version 2.0", "moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0" }, @@ -1034,9 +1083,9 @@ "moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0" }, { - "moduleName": "io.swagger.core.v3:swagger-models-jakarta", - "moduleUrl": "https://github.com/swagger-api/swagger-core/modules/swagger-models", - "moduleVersion": "2.2.46", + "moduleName": "io.swagger.core.v3:swagger-core-jakarta", + "moduleUrl": "https://github.com/swagger-api/swagger-core/modules/swagger-core", + "moduleVersion": "2.2.53", "moduleLicense": "Apache License, Version 2.0", "moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0" }, @@ -1047,6 +1096,13 @@ "moduleLicense": "Apache License, Version 2.0", "moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0" }, + { + "moduleName": "io.swagger.core.v3:swagger-models-jakarta", + "moduleUrl": "https://github.com/swagger-api/swagger-core/modules/swagger-models", + "moduleVersion": "2.2.53", + "moduleLicense": "Apache License, Version 2.0", + "moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0" + }, { "moduleName": "jakarta.activation:jakarta.activation-api", "moduleUrl": "https://www.eclipse.org", @@ -1213,24 +1269,48 @@ "moduleLicense": "The Apache Software License, Version 2.0", "moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt" }, + { + "moduleName": "net.shibboleth:shib-networking", + "moduleVersion": "9.2.2", + "moduleLicense": "The Apache Software License, Version 2.0", + "moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt" + }, { "moduleName": "net.shibboleth:shib-security", "moduleVersion": "9.1.6", "moduleLicense": "The Apache Software License, Version 2.0", "moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt" }, + { + "moduleName": "net.shibboleth:shib-security", + "moduleVersion": "9.2.2", + "moduleLicense": "The Apache Software License, Version 2.0", + "moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt" + }, { "moduleName": "net.shibboleth:shib-support", "moduleVersion": "9.1.6", "moduleLicense": "The Apache Software License, Version 2.0", "moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt" }, + { + "moduleName": "net.shibboleth:shib-support", + "moduleVersion": "9.2.2", + "moduleLicense": "The Apache Software License, Version 2.0", + "moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt" + }, { "moduleName": "net.shibboleth:shib-velocity", "moduleVersion": "9.1.6", "moduleLicense": "The Apache Software License, Version 2.0", "moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt" }, + { + "moduleName": "net.shibboleth:shib-velocity", + "moduleVersion": "9.2.2", + "moduleLicense": "The Apache Software License, Version 2.0", + "moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt" + }, { "moduleName": "org.antlr:antlr4-runtime", "moduleUrl": "https://www.antlr.org/", @@ -1325,13 +1405,6 @@ "moduleLicense": "Apache License, Version 2.0", "moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0.txt" }, - { - "moduleName": "org.apache.httpcomponents:httpclient", - "moduleUrl": "http://hc.apache.org/httpcomponents-client", - "moduleVersion": "4.5.13", - "moduleLicense": "Apache License, Version 2.0", - "moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt" - }, { "moduleName": "org.apache.httpcomponents:httpclient", "moduleUrl": "http://hc.apache.org/httpcomponents-client-ga", @@ -1456,6 +1529,13 @@ "moduleLicense": "Apache-2.0", "moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0.txt" }, + { + "moduleName": "org.apache.santuario:xmlsec", + "moduleUrl": "https://www.apache.org/", + "moduleVersion": "3.0.6", + "moduleLicense": "Apache-2.0", + "moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0.txt" + }, { "moduleName": "org.apache.tomcat.embed:tomcat-embed-el", "moduleUrl": "https://tomcat.apache.org/", @@ -1470,6 +1550,13 @@ "moduleLicense": "Apache License, Version 2.0", "moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0.txt" }, + { + "moduleName": "org.apache.velocity:velocity-engine-core", + "moduleUrl": "https://www.apache.org/", + "moduleVersion": "2.4.1", + "moduleLicense": "Apache-2.0", + "moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0.txt" + }, { "moduleName": "org.apache.xmlbeans:xmlbeans", "moduleUrl": "https://xmlbeans.apache.org/", @@ -1647,6 +1734,13 @@ "moduleLicense": "GNU Lesser General Public License", "moduleLicenseUrl": "http://www.gnu.org/licenses/lgpl-3.0.txt" }, + { + "moduleName": "org.cryptacular:cryptacular", + "moduleUrl": "https://www.cryptacular.org", + "moduleVersion": "1.3.0", + "moduleLicense": "GNU Lesser General Public License", + "moduleLicenseUrl": "https://www.gnu.org/licenses/lgpl-3.0.txt" + }, { "moduleName": "org.eclipse.angus:angus-activation", "moduleUrl": "https://www.eclipse.org", @@ -2016,78 +2110,156 @@ "moduleLicense": "The Apache Software License, Version 2.0", "moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt" }, + { + "moduleName": "org.opensaml:opensaml-core-api", + "moduleVersion": "5.2.2", + "moduleLicense": "The Apache Software License, Version 2.0", + "moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt" + }, { "moduleName": "org.opensaml:opensaml-core-impl", "moduleVersion": "5.1.6", "moduleLicense": "The Apache Software License, Version 2.0", "moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt" }, + { + "moduleName": "org.opensaml:opensaml-core-impl", + "moduleVersion": "5.2.2", + "moduleLicense": "The Apache Software License, Version 2.0", + "moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt" + }, { "moduleName": "org.opensaml:opensaml-messaging-api", "moduleVersion": "5.1.6", "moduleLicense": "The Apache Software License, Version 2.0", "moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt" }, + { + "moduleName": "org.opensaml:opensaml-messaging-api", + "moduleVersion": "5.2.2", + "moduleLicense": "The Apache Software License, Version 2.0", + "moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt" + }, { "moduleName": "org.opensaml:opensaml-profile-api", "moduleVersion": "5.1.6", "moduleLicense": "The Apache Software License, Version 2.0", "moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt" }, + { + "moduleName": "org.opensaml:opensaml-profile-api", + "moduleVersion": "5.2.2", + "moduleLicense": "The Apache Software License, Version 2.0", + "moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt" + }, { "moduleName": "org.opensaml:opensaml-saml-api", "moduleVersion": "5.1.6", "moduleLicense": "The Apache Software License, Version 2.0", "moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt" }, + { + "moduleName": "org.opensaml:opensaml-saml-api", + "moduleVersion": "5.2.2", + "moduleLicense": "The Apache Software License, Version 2.0", + "moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt" + }, { "moduleName": "org.opensaml:opensaml-saml-impl", "moduleVersion": "5.1.6", "moduleLicense": "The Apache Software License, Version 2.0", "moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt" }, + { + "moduleName": "org.opensaml:opensaml-saml-impl", + "moduleVersion": "5.2.2", + "moduleLicense": "The Apache Software License, Version 2.0", + "moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt" + }, { "moduleName": "org.opensaml:opensaml-security-api", "moduleVersion": "5.1.6", "moduleLicense": "The Apache Software License, Version 2.0", "moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt" }, + { + "moduleName": "org.opensaml:opensaml-security-api", + "moduleVersion": "5.2.2", + "moduleLicense": "The Apache Software License, Version 2.0", + "moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt" + }, { "moduleName": "org.opensaml:opensaml-security-impl", "moduleVersion": "5.1.6", "moduleLicense": "The Apache Software License, Version 2.0", "moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt" }, + { + "moduleName": "org.opensaml:opensaml-security-impl", + "moduleVersion": "5.2.2", + "moduleLicense": "The Apache Software License, Version 2.0", + "moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt" + }, { "moduleName": "org.opensaml:opensaml-soap-api", "moduleVersion": "5.1.6", "moduleLicense": "The Apache Software License, Version 2.0", "moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt" }, + { + "moduleName": "org.opensaml:opensaml-soap-api", + "moduleVersion": "5.2.2", + "moduleLicense": "The Apache Software License, Version 2.0", + "moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt" + }, { "moduleName": "org.opensaml:opensaml-soap-impl", "moduleVersion": "5.1.6", "moduleLicense": "The Apache Software License, Version 2.0", "moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt" }, + { + "moduleName": "org.opensaml:opensaml-soap-impl", + "moduleVersion": "5.2.2", + "moduleLicense": "The Apache Software License, Version 2.0", + "moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt" + }, { "moduleName": "org.opensaml:opensaml-storage-api", "moduleVersion": "5.1.6", "moduleLicense": "The Apache Software License, Version 2.0", "moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt" }, + { + "moduleName": "org.opensaml:opensaml-storage-api", + "moduleVersion": "5.2.2", + "moduleLicense": "The Apache Software License, Version 2.0", + "moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt" + }, { "moduleName": "org.opensaml:opensaml-xmlsec-api", "moduleVersion": "5.1.6", "moduleLicense": "The Apache Software License, Version 2.0", "moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt" }, + { + "moduleName": "org.opensaml:opensaml-xmlsec-api", + "moduleVersion": "5.2.2", + "moduleLicense": "The Apache Software License, Version 2.0", + "moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt" + }, { "moduleName": "org.opensaml:opensaml-xmlsec-impl", "moduleVersion": "5.1.6", "moduleLicense": "The Apache Software License, Version 2.0", "moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt" }, + { + "moduleName": "org.opensaml:opensaml-xmlsec-impl", + "moduleVersion": "5.2.2", + "moduleLicense": "The Apache Software License, Version 2.0", + "moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt" + }, { "moduleName": "org.ow2.asm:asm", "moduleUrl": "http://asm.ow2.org", @@ -2132,7 +2304,7 @@ }, { "moduleName": "org.simplejavamail:core-module", - "moduleVersion": "9.2.0", + "moduleVersion": "9.3.2", "moduleLicense": "The Apache Software License, Version 2.0", "moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt" }, @@ -2145,13 +2317,13 @@ }, { "moduleName": "org.simplejavamail:outlook-module", - "moduleVersion": "9.2.0", + "moduleVersion": "9.3.2", "moduleLicense": "The Apache Software License, Version 2.0", "moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt" }, { "moduleName": "org.simplejavamail:simple-java-mail", - "moduleVersion": "9.2.0", + "moduleVersion": "9.3.2", "moduleLicense": "The Apache Software License, Version 2.0", "moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt" }, @@ -2171,10 +2343,10 @@ }, { "moduleName": "org.snakeyaml:snakeyaml-engine", - "moduleUrl": "https://bitbucket.org/snakeyaml/snakeyaml-engine", - "moduleVersion": "3.0.1", + "moduleUrl": "https://codeberg.org/snakeyaml/snakeyaml-engine", + "moduleVersion": "3.1.1", "moduleLicense": "Apache License, Version 2.0", - "moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt" + "moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0.txt" }, { "moduleName": "org.springdoc:springdoc-openapi-starter-common", @@ -2564,6 +2736,13 @@ "moduleLicense": "Apache License, Version 2.0", "moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0" }, + { + "moduleName": "org.springframework.security:spring-security-core", + "moduleUrl": "https://spring.io/projects/spring-security", + "moduleVersion": "7.1.0", + "moduleLicense": "Apache License, Version 2.0", + "moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0" + }, { "moduleName": "org.springframework.security:spring-security-crypto", "moduleUrl": "https://spring.io/projects/spring-security", @@ -2606,6 +2785,13 @@ "moduleLicense": "Apache License, Version 2.0", "moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0" }, + { + "moduleName": "org.springframework.security:spring-security-saml2-service-provider", + "moduleUrl": "https://spring.io/projects/spring-security", + "moduleVersion": "7.1.0", + "moduleLicense": "Apache License, Version 2.0", + "moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0" + }, { "moduleName": "org.springframework.security:spring-security-web", "moduleUrl": "https://spring.io/projects/spring-security", @@ -2805,207 +2991,207 @@ }, { "moduleName": "software.amazon.awssdk:annotations", - "moduleVersion": "2.44.12", + "moduleVersion": "2.51.3", "moduleLicense": "Apache License, Version 2.0", "moduleLicenseUrl": "https://aws.amazon.com/apache2.0" }, { - "moduleName": "software.amazon.awssdk:apache-client", - "moduleVersion": "2.44.12", + "moduleName": "software.amazon.awssdk:apache5-client", + "moduleVersion": "2.51.3", "moduleLicense": "Apache License, Version 2.0", "moduleLicenseUrl": "https://aws.amazon.com/apache2.0" }, { "moduleName": "software.amazon.awssdk:arns", "moduleUrl": "https://aws.amazon.com/sdkforjava", - "moduleVersion": "2.44.12", + "moduleVersion": "2.51.3", "moduleLicense": "Apache License, Version 2.0", "moduleLicenseUrl": "https://aws.amazon.com/apache2.0" }, { "moduleName": "software.amazon.awssdk:auth", "moduleUrl": "https://aws.amazon.com/sdkforjava", - "moduleVersion": "2.44.12", + "moduleVersion": "2.51.3", "moduleLicense": "Apache License, Version 2.0", "moduleLicenseUrl": "https://aws.amazon.com/apache2.0" }, { "moduleName": "software.amazon.awssdk:aws-core", "moduleUrl": "https://aws.amazon.com/sdkforjava", - "moduleVersion": "2.44.12", + "moduleVersion": "2.51.3", "moduleLicense": "Apache License, Version 2.0", "moduleLicenseUrl": "https://aws.amazon.com/apache2.0" }, { "moduleName": "software.amazon.awssdk:aws-query-protocol", "moduleUrl": "https://aws.amazon.com/sdkforjava", - "moduleVersion": "2.44.12", + "moduleVersion": "2.51.3", "moduleLicense": "Apache License, Version 2.0", "moduleLicenseUrl": "https://aws.amazon.com/apache2.0" }, { "moduleName": "software.amazon.awssdk:aws-xml-protocol", "moduleUrl": "https://aws.amazon.com/sdkforjava", - "moduleVersion": "2.44.12", + "moduleVersion": "2.51.3", "moduleLicense": "Apache License, Version 2.0", "moduleLicenseUrl": "https://aws.amazon.com/apache2.0" }, { "moduleName": "software.amazon.awssdk:checksums", "moduleUrl": "https://aws.amazon.com/sdkforjava", - "moduleVersion": "2.44.12", + "moduleVersion": "2.51.3", "moduleLicense": "Apache License, Version 2.0", "moduleLicenseUrl": "https://aws.amazon.com/apache2.0" }, { "moduleName": "software.amazon.awssdk:checksums-spi", "moduleUrl": "https://aws.amazon.com/sdkforjava", - "moduleVersion": "2.44.12", + "moduleVersion": "2.51.3", "moduleLicense": "Apache License, Version 2.0", "moduleLicenseUrl": "https://aws.amazon.com/apache2.0" }, { "moduleName": "software.amazon.awssdk:crt-core", "moduleUrl": "https://aws.amazon.com/sdkforjava", - "moduleVersion": "2.44.12", + "moduleVersion": "2.51.3", "moduleLicense": "Apache License, Version 2.0", "moduleLicenseUrl": "https://aws.amazon.com/apache2.0" }, { "moduleName": "software.amazon.awssdk:endpoints-spi", - "moduleVersion": "2.44.12", + "moduleVersion": "2.51.3", "moduleLicense": "Apache License, Version 2.0", "moduleLicenseUrl": "https://aws.amazon.com/apache2.0" }, { "moduleName": "software.amazon.awssdk:http-auth", "moduleUrl": "https://aws.amazon.com/sdkforjava", - "moduleVersion": "2.44.12", + "moduleVersion": "2.51.3", "moduleLicense": "Apache License, Version 2.0", "moduleLicenseUrl": "https://aws.amazon.com/apache2.0" }, { "moduleName": "software.amazon.awssdk:http-auth-aws", "moduleUrl": "https://aws.amazon.com/sdkforjava", - "moduleVersion": "2.44.12", + "moduleVersion": "2.51.3", "moduleLicense": "Apache License, Version 2.0", "moduleLicenseUrl": "https://aws.amazon.com/apache2.0" }, { "moduleName": "software.amazon.awssdk:http-auth-aws-eventstream", "moduleUrl": "https://aws.amazon.com/sdkforjava", - "moduleVersion": "2.44.12", + "moduleVersion": "2.51.3", "moduleLicense": "Apache License, Version 2.0", "moduleLicenseUrl": "https://aws.amazon.com/apache2.0" }, { "moduleName": "software.amazon.awssdk:http-auth-spi", "moduleUrl": "https://aws.amazon.com/sdkforjava", - "moduleVersion": "2.44.12", + "moduleVersion": "2.51.3", "moduleLicense": "Apache License, Version 2.0", "moduleLicenseUrl": "https://aws.amazon.com/apache2.0" }, { "moduleName": "software.amazon.awssdk:http-client-spi", - "moduleVersion": "2.44.12", + "moduleVersion": "2.51.3", "moduleLicense": "Apache License, Version 2.0", "moduleLicenseUrl": "https://aws.amazon.com/apache2.0" }, { "moduleName": "software.amazon.awssdk:identity-spi", "moduleUrl": "https://aws.amazon.com/sdkforjava", - "moduleVersion": "2.44.12", + "moduleVersion": "2.51.3", "moduleLicense": "Apache License, Version 2.0", "moduleLicenseUrl": "https://aws.amazon.com/apache2.0" }, { "moduleName": "software.amazon.awssdk:json-utils", "moduleUrl": "https://aws.amazon.com/sdkforjava", - "moduleVersion": "2.44.12", + "moduleVersion": "2.51.3", "moduleLicense": "Apache License, Version 2.0", "moduleLicenseUrl": "https://aws.amazon.com/apache2.0" }, { "moduleName": "software.amazon.awssdk:metrics-spi", - "moduleVersion": "2.44.12", + "moduleVersion": "2.51.3", "moduleLicense": "Apache License, Version 2.0", "moduleLicenseUrl": "https://aws.amazon.com/apache2.0" }, { "moduleName": "software.amazon.awssdk:netty-nio-client", - "moduleVersion": "2.44.12", + "moduleVersion": "2.51.3", "moduleLicense": "Apache License, Version 2.0", "moduleLicenseUrl": "https://aws.amazon.com/apache2.0" }, { "moduleName": "software.amazon.awssdk:profiles", "moduleUrl": "https://aws.amazon.com/sdkforjava", - "moduleVersion": "2.44.12", + "moduleVersion": "2.51.3", "moduleLicense": "Apache License, Version 2.0", "moduleLicenseUrl": "https://aws.amazon.com/apache2.0" }, { "moduleName": "software.amazon.awssdk:protocol-core", "moduleUrl": "https://aws.amazon.com/sdkforjava", - "moduleVersion": "2.44.12", + "moduleVersion": "2.51.3", "moduleLicense": "Apache License, Version 2.0", "moduleLicenseUrl": "https://aws.amazon.com/apache2.0" }, { "moduleName": "software.amazon.awssdk:regions", - "moduleVersion": "2.44.12", + "moduleVersion": "2.51.3", "moduleLicense": "Apache License, Version 2.0", "moduleLicenseUrl": "https://aws.amazon.com/apache2.0" }, { "moduleName": "software.amazon.awssdk:retries", - "moduleVersion": "2.44.12", + "moduleVersion": "2.51.3", "moduleLicense": "Apache License, Version 2.0", "moduleLicenseUrl": "https://aws.amazon.com/apache2.0" }, { "moduleName": "software.amazon.awssdk:retries-spi", - "moduleVersion": "2.44.12", + "moduleVersion": "2.51.3", "moduleLicense": "Apache License, Version 2.0", "moduleLicenseUrl": "https://aws.amazon.com/apache2.0" }, { "moduleName": "software.amazon.awssdk:s3", "moduleUrl": "https://aws.amazon.com/sdkforjava", - "moduleVersion": "2.44.12", + "moduleVersion": "2.51.3", "moduleLicense": "Apache License, Version 2.0", "moduleLicenseUrl": "https://aws.amazon.com/apache2.0" }, { "moduleName": "software.amazon.awssdk:sdk-core", "moduleUrl": "https://aws.amazon.com/sdkforjava", - "moduleVersion": "2.44.12", + "moduleVersion": "2.51.3", "moduleLicense": "Apache License, Version 2.0", "moduleLicenseUrl": "https://aws.amazon.com/apache2.0" }, { "moduleName": "software.amazon.awssdk:third-party-jackson-core", "moduleUrl": "https://aws.amazon.com/sdkforjava", - "moduleVersion": "2.44.12", + "moduleVersion": "2.51.3", "moduleLicense": "Apache License, Version 2.0", "moduleLicenseUrl": "https://aws.amazon.com/apache2.0" }, { "moduleName": "software.amazon.awssdk:url-connection-client", - "moduleVersion": "2.44.12", + "moduleVersion": "2.51.3", "moduleLicense": "Apache License, Version 2.0", "moduleLicenseUrl": "https://aws.amazon.com/apache2.0" }, { "moduleName": "software.amazon.awssdk:utils", - "moduleVersion": "2.44.12", + "moduleVersion": "2.51.3", "moduleLicense": "Apache License, Version 2.0", "moduleLicenseUrl": "https://aws.amazon.com/apache2.0" }, { "moduleName": "software.amazon.awssdk:utils-lite", "moduleUrl": "https://aws.amazon.com/sdkforjava", - "moduleVersion": "2.44.12", + "moduleVersion": "2.51.3", "moduleLicense": "Apache License, Version 2.0", "moduleLicenseUrl": "https://aws.amazon.com/apache2.0" }, diff --git a/app/core/src/test/java/stirling/software/SPDF/controller/api/converters/ConvertPDFToPDFAGapTest.java b/app/core/src/test/java/stirling/software/SPDF/controller/api/converters/ConvertPDFToPDFAGapTest.java index b64776665b..ea693ddd27 100644 --- a/app/core/src/test/java/stirling/software/SPDF/controller/api/converters/ConvertPDFToPDFAGapTest.java +++ b/app/core/src/test/java/stirling/software/SPDF/controller/api/converters/ConvertPDFToPDFAGapTest.java @@ -46,6 +46,7 @@ import stirling.software.SPDF.model.api.converters.PdfToPdfARequest; import stirling.software.SPDF.model.api.security.PDFVerificationResult; import stirling.software.SPDF.service.VeraPDFService; import stirling.software.common.configuration.RuntimePathConfig; +import stirling.software.common.service.PdfaLevelAServiceInterface; import stirling.software.common.util.TempFileManager; /** @@ -62,10 +63,12 @@ class ConvertPDFToPDFAGapTest { @Mock private RuntimePathConfig runtimePathConfig; @Mock private VeraPDFService veraPDFService; + @Mock private PdfaLevelAServiceInterface pdfaLevelAService; @Mock private TempFileManager tempFileManager; private ConvertPDFToPDFA newController() { - return new ConvertPDFToPDFA(runtimePathConfig, veraPDFService, tempFileManager); + return new ConvertPDFToPDFA( + runtimePathConfig, veraPDFService, pdfaLevelAService, tempFileManager); } // ---- reflection helpers ---------------------------------------------------------------- @@ -161,9 +164,21 @@ class ConvertPDFToPDFAGapTest { } private String suffixOf(Object profile) throws Exception { - Method m = profile.getClass().getDeclaredMethod("outputSuffix"); + return suffixOf(profile, true); + } + + private String suffixOf(Object profile, boolean levelAReached) throws Exception { + Method m = profile.getClass().getDeclaredMethod("outputSuffix", boolean.class); m.setAccessible(true); - return (String) m.invoke(profile); + return (String) m.invoke(profile, levelAReached); + } + + @Test + @DisplayName("a level A profile falls back to the level B name when tagging failed") + void levelANotReachedIsNamedLevelB() throws Exception { + assertThat(suffixOf(resolveProfile("pdfa-1a"), false)).isEqualTo("_PDFA-1b.pdf"); + assertThat(suffixOf(resolveProfile("pdfa-2a"), false)).isEqualTo("_PDFA-2b.pdf"); + assertThat(suffixOf(resolveProfile("pdfa-3a"), true)).isEqualTo("_PDFA-3a.pdf"); } @Test @@ -717,6 +732,30 @@ class ConvertPDFToPDFAGapTest { @DisplayName("verifyStrictCompliance (VeraPDFService mocked)") class StrictCompliance { + private Object profile(String token) throws Exception { + Class enumClass = null; + for (Class inner : ConvertPDFToPDFA.class.getDeclaredClasses()) { + if (inner.getSimpleName().equals("PdfaProfile")) { + enumClass = inner; + } + } + Method m = enumClass.getDeclaredMethod("fromRequest", String.class); + m.setAccessible(true); + return m.invoke(null, token); + } + + private Throwable verify(String token, boolean levelAReached) throws Exception { + ConvertPDFToPDFA controller = newController(); + return catchThrowable( + () -> + invokeInstance( + controller, + "verifyStrictCompliance", + (Object) "dummy".getBytes(), + profile(token), + levelAReached)); + } + @Test @DisplayName("compliant result passes without throwing") void compliantPasses() throws Exception { @@ -726,14 +765,7 @@ class ConvertPDFToPDFAGapTest { ok.setComplianceSummary("PDF/A-1b compliant"); when(veraPDFService.validatePDF(any())).thenReturn(List.of(ok)); - ConvertPDFToPDFA controller = newController(); - assertThatCode( - () -> - invokeInstance( - controller, - "verifyStrictCompliance", - (Object) "dummy".getBytes())) - .doesNotThrowAnyException(); + assertThat(verify("pdfa-1", true)).isNull(); } @Test @@ -745,34 +777,70 @@ class ConvertPDFToPDFAGapTest { bad.setComplianceSummary("PDF/A-1b with errors"); when(veraPDFService.validatePDF(any())).thenReturn(List.of(bad)); - ConvertPDFToPDFA controller = newController(); - ResponseStatusException ex = - (ResponseStatusException) - catchThrowable( - () -> - invokeInstance( - controller, - "verifyStrictCompliance", - (Object) "dummy".getBytes())); + ResponseStatusException ex = (ResponseStatusException) verify("pdfa-1", true); assertThat(ex).isNotNull(); assertThat(ex.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); assertThat(ex.getReason()).contains("PDF/A-1b with errors"); } + @Test + @DisplayName("a level B pass does not satisfy a level A request") + void levelBDoesNotSatisfyLevelA() throws Exception { + PDFVerificationResult ok = new PDFVerificationResult(); + ok.setCompliant(true); + ok.setStandard("1b"); + ok.setComplianceSummary("PDF/A-1b compliant"); + when(veraPDFService.validatePDF(any())).thenReturn(List.of(ok)); + + ResponseStatusException ex = (ResponseStatusException) verify("pdfa-1a", true); + assertThat(ex).isNotNull(); + assertThat(ex.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + assertThat(ex.getReason()).contains("PDF/A-1a"); + } + + @Test + @DisplayName("a level A result satisfies a level A request") + void levelASatisfiesLevelA() throws Exception { + PDFVerificationResult ok = new PDFVerificationResult(); + ok.setCompliant(true); + ok.setStandard("2a"); + ok.setComplianceSummary("PDF/A-2a compliant"); + when(veraPDFService.validatePDF(any())).thenReturn(List.of(ok)); + + assertThat(verify("pdfa-2a", true)).isNull(); + } + + @Test + @DisplayName("untagged output fails a level A request before validation runs") + void untaggedLevelARequestFails() throws Exception { + ResponseStatusException ex = (ResponseStatusException) verify("pdfa-2a", false); + assertThat(ex).isNotNull(); + assertThat(ex.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + assertThat(ex.getReason()).contains("could not be tagged"); + verifyNoInteractions(veraPDFService); + } + + @Test + @DisplayName("a compliant PDF/UA result never satisfies a strict PDF/A request") + void accessibilityResultIsIgnored() throws Exception { + PDFVerificationResult ua = new PDFVerificationResult(); + ua.setCompliant(true); + ua.setStandard("ua1"); + ua.setValidationProfile("ua1"); + ua.setComplianceSummary("PDF/UA-1 compliant"); + when(veraPDFService.validatePDF(any())).thenReturn(List.of(ua)); + + ResponseStatusException ex = (ResponseStatusException) verify("pdfa-2b", true); + assertThat(ex).isNotNull(); + assertThat(ex.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } + @Test @DisplayName("empty result list is treated as non-compliant -> 400") void emptyResultsTreatedNonCompliant() throws Exception { when(veraPDFService.validatePDF(any())).thenReturn(Collections.emptyList()); - ConvertPDFToPDFA controller = newController(); - ResponseStatusException ex = - (ResponseStatusException) - catchThrowable( - () -> - invokeInstance( - controller, - "verifyStrictCompliance", - (Object) "dummy".getBytes())); + ResponseStatusException ex = (ResponseStatusException) verify("pdfa-1", true); assertThat(ex).isNotNull(); assertThat(ex.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); } @@ -782,15 +850,7 @@ class ConvertPDFToPDFAGapTest { void serviceErrorWrappedAs500() throws Exception { when(veraPDFService.validatePDF(any())).thenThrow(new IOException("boom")); - ConvertPDFToPDFA controller = newController(); - ResponseStatusException ex = - (ResponseStatusException) - catchThrowable( - () -> - invokeInstance( - controller, - "verifyStrictCompliance", - (Object) "dummy".getBytes())); + ResponseStatusException ex = (ResponseStatusException) verify("pdfa-1", true); assertThat(ex).isNotNull(); assertThat(ex.getStatusCode()).isEqualTo(HttpStatus.INTERNAL_SERVER_ERROR); } diff --git a/app/core/src/test/java/stirling/software/SPDF/controller/api/converters/ConvertPDFToPDFAMoreTest.java b/app/core/src/test/java/stirling/software/SPDF/controller/api/converters/ConvertPDFToPDFAMoreTest.java index e9d9b9ce1d..d56a283464 100644 --- a/app/core/src/test/java/stirling/software/SPDF/controller/api/converters/ConvertPDFToPDFAMoreTest.java +++ b/app/core/src/test/java/stirling/software/SPDF/controller/api/converters/ConvertPDFToPDFAMoreTest.java @@ -42,6 +42,7 @@ import org.springframework.mock.web.MockMultipartFile; import stirling.software.SPDF.model.api.converters.PdfToPdfARequest; import stirling.software.SPDF.service.VeraPDFService; import stirling.software.common.configuration.RuntimePathConfig; +import stirling.software.common.service.PdfaLevelAServiceInterface; import stirling.software.common.util.ProcessExecutor; import stirling.software.common.util.ProcessExecutor.ProcessExecutorResult; import stirling.software.common.util.TempFile; @@ -63,10 +64,12 @@ class ConvertPDFToPDFAMoreTest { @Mock private RuntimePathConfig runtimePathConfig; @Mock private VeraPDFService veraPDFService; + @Mock private PdfaLevelAServiceInterface pdfaLevelAService; @Mock private TempFileManager tempFileManager; private ConvertPDFToPDFA newController() { - return new ConvertPDFToPDFA(runtimePathConfig, veraPDFService, tempFileManager); + return new ConvertPDFToPDFA( + runtimePathConfig, veraPDFService, pdfaLevelAService, tempFileManager); } private static ResponseEntity streamingOk(byte[] bytes) { diff --git a/app/core/src/test/java/stirling/software/SPDF/controller/api/form/FormFieldBundleTest.java b/app/core/src/test/java/stirling/software/SPDF/controller/api/form/FormFieldBundleTest.java new file mode 100644 index 0000000000..8082949c8e --- /dev/null +++ b/app/core/src/test/java/stirling/software/SPDF/controller/api/form/FormFieldBundleTest.java @@ -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.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 fields) {} + + private static FormUtils.NewFormFieldDefinition field( + String name, String type, int page, float y, List 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
{banner}
{children}
diff --git a/frontend/editor/src/core/components/filesPage/FileGrid.tsx b/frontend/editor/src/core/components/filesPage/FileGrid.tsx index e54964c592..40e17dc502 100644 --- a/frontend/editor/src/core/components/filesPage/FileGrid.tsx +++ b/frontend/editor/src/core/components/filesPage/FileGrid.tsx @@ -13,6 +13,7 @@ import DeleteIcon from "@mui/icons-material/Delete"; import HistoryIcon from "@mui/icons-material/History"; import OpenInNewIcon from "@mui/icons-material/OpenInNew"; import DriveFileRenameOutlineIcon from "@mui/icons-material/DriveFileRenameOutline"; +import ContentCopyOutlinedIcon from "@mui/icons-material/ContentCopyOutlined"; import CloudUploadIcon from "@mui/icons-material/CloudUpload"; import UploadFileIcon from "@mui/icons-material/UploadFile"; import CreateNewFolderIcon from "@mui/icons-material/CreateNewFolder"; @@ -36,6 +37,8 @@ import { FolderThumbnail } from "@app/components/filesPage/FolderThumbnail"; import { findFolderIcon } from "@app/components/filesPage/folderIcons"; import { FolderAppearancePicker } from "@app/components/filesPage/FolderAppearancePicker"; import { useLazyThumbnail } from "@app/hooks/useLazyThumbnail"; +import { useFileActionIcons } from "@app/hooks/useFileActionIcons"; +import { useFileActionTerminology } from "@app/hooks/useFileActionTerminology"; import type { FilesPageSortMode } from "@app/contexts/FilesPageContext"; import { OpenInNewWindowMenuItem } from "@app/components/filesPage/OpenInNewWindowMenuItem"; @@ -83,6 +86,12 @@ interface FileGridProps { onSaveToServer?: (file: StirlingFileStub) => void; /** Open the version-history modal for a file (only when it has >1 version). */ onVersionHistory?: (file: StirlingFileStub) => void; + /** Download a copy (desktop: save a copy). */ + onDownloadFile?: (file: StirlingFileStub) => void; + /** Open the rename dialog for a file. */ + onRenameFile?: (file: StirlingFileStub) => void; + /** Save a second copy of the file into the library. */ + onDuplicateFile?: (file: StirlingFileStub) => void; /** When set, the Save to server item renders disabled with this tooltip. */ saveToServerDisabledReason?: string | null; /** When supplied the list-view column headers become sortable. */ @@ -366,24 +375,21 @@ function EmptyState({ ); } -function GridView({ - entries, - selectedFileIds, - activeWorkspaceFileIds, - onSelectFile, - onOpenFolder, - onOpenFile, - onMoveFiles, - onMoveFolder, - onRenameFolder, - onDeleteFolder, - onChangeFolderAppearance, - onRemoveFiles, - onPromptMoveFiles, - onSaveToServer, - onVersionHistory, - saveToServerDisabledReason, -}: FileGridProps) { +function GridView(props: FileGridProps) { + const { + entries, + selectedFileIds, + activeWorkspaceFileIds, + onSelectFile, + onOpenFolder, + onOpenFile, + onMoveFiles, + onMoveFolder, + onRenameFolder, + onDeleteFolder, + onChangeFolderAppearance, + } = props; + const menuHandlersFor = useFileMenuHandlers(props); return (
{entries.map((entry) => { @@ -424,22 +430,7 @@ function GridView({ onSelectFile(entry.file!.id, e.shiftKey, e.metaKey || e.ctrlKey) } onDoubleClick={() => onOpenFile(entry.file!)} - onRemove={() => onRemoveFiles([entry.file!.id])} - onMove={() => { - const target = selectedFileIds.has(entry.file!.id) - ? Array.from(selectedFileIds) - : [entry.file!.id]; - onPromptMoveFiles(target); - }} - onSaveToServer={ - onSaveToServer ? () => onSaveToServer(entry.file!) : undefined - } - onVersionHistory={ - onVersionHistory - ? () => onVersionHistory(entry.file!) - : undefined - } - saveToServerDisabledReason={saveToServerDisabledReason} + {...menuHandlersFor(entry.file)} /> ); } @@ -626,7 +617,222 @@ function PolicyBadges({ fileId }: { fileId: string }) { return ; } -interface FileCardProps { +/** Per-file actions. Shared verbatim by the grid card and the list row, and + * kept in step with the file sidebar's kebab so both surfaces offer the same. */ +interface FileActionsMenuProps { + file: StirlingFileStub; + triggerRef: React.RefObject; + onOpen: () => void; + onMove: () => void; + onRemove: () => void; + onDownload?: () => void; + onRename?: () => void; + onDuplicate?: () => void; + onSaveToServer?: () => void; + onVersionHistory?: () => void; + saveToServerDisabledReason?: string | null; +} + +function FileActionsMenu({ + file, + triggerRef, + onOpen, + onMove, + onRemove, + onDownload, + onRename, + onDuplicate, + onSaveToServer, + onVersionHistory, + saveToServerDisabledReason, +}: FileActionsMenuProps) { + const { t } = useTranslation(); + const terminology = useFileActionTerminology(); + const DownloadIcon = useFileActionIcons().download; + const showSaveToServer = + Boolean(onSaveToServer) && file.remoteStorageId == null; + const showVersionHistory = + Boolean(onVersionHistory) && (file.versionNumber ?? 1) > 1; + return ( + + + e.stopPropagation()} + aria-label={t("filesPage.fileMenu", "File actions")} + data-testid="file-card-actions" + > + + + + + } + onClick={(e) => { + e.stopPropagation(); + onOpen(); + }} + > + {t("filesPage.addToWorkspace", "Add to workspace")} + + + } + onClick={(e) => { + e.stopPropagation(); + onMove(); + }} + data-testid="file-menu-move-to" + > + {t("filesPage.moveTo", "Move to…")} + + + {(onDownload || onRename || onDuplicate) && } + {onDownload && ( + } + onClick={(e) => { + e.stopPropagation(); + onDownload(); + }} + data-testid="file-menu-download" + > + {terminology.download} + + )} + {onRename && ( + } + onClick={(e) => { + e.stopPropagation(); + onRename(); + }} + data-testid="file-menu-rename" + > + {t("filesPage.rename", "Rename")} + + )} + {onDuplicate && ( + } + onClick={(e) => { + e.stopPropagation(); + onDuplicate(); + }} + data-testid="file-menu-duplicate" + > + {t("filesPage.duplicate", "Duplicate")} + + )} + + {(showSaveToServer || showVersionHistory) && } + {/* Per-file Save to server; shown for local-only files. When + storage is off it stays visible but disabled with a tooltip. */} + {showSaveToServer && onSaveToServer && ( + + } + disabled={Boolean(saveToServerDisabledReason)} + onClick={(e) => { + e.stopPropagation(); + onSaveToServer(); + }} + style={ + saveToServerDisabledReason + ? { pointerEvents: "auto" } + : undefined + } + > + {t("filesPage.saveToServer", "Save to server")} + + + )} + {showVersionHistory && onVersionHistory && ( + } + onClick={(e) => { + e.stopPropagation(); + onVersionHistory(); + }} + > + {t("filesPage.versionHistory", "Version history")} + + )} + + + } + onClick={(e) => { + e.stopPropagation(); + onRemove(); + }} + > + {t("filesPage.remove", "Delete")} + + + + ); +} + +/** Binds one file's kebab handlers, so grid and list wire them identically. */ +function useFileMenuHandlers( + props: FileGridProps, +): (file: StirlingFileStub) => FileMenuHandlers { + const { + selectedFileIds, + onRemoveFiles, + onPromptMoveFiles, + onSaveToServer, + onVersionHistory, + onDownloadFile, + onRenameFile, + onDuplicateFile, + saveToServerDisabledReason, + } = props; + return (file: StirlingFileStub) => ({ + onRemove: () => onRemoveFiles([file.id]), + // A move acts on the whole selection when this file is part of it. + onMove: () => + onPromptMoveFiles( + selectedFileIds.has(file.id) ? Array.from(selectedFileIds) : [file.id], + ), + onDownload: onDownloadFile ? () => onDownloadFile(file) : undefined, + onRename: onRenameFile ? () => onRenameFile(file) : undefined, + onDuplicate: onDuplicateFile ? () => onDuplicateFile(file) : undefined, + onSaveToServer: onSaveToServer ? () => onSaveToServer(file) : undefined, + onVersionHistory: onVersionHistory + ? () => onVersionHistory(file) + : undefined, + saveToServerDisabledReason, + }); +} + +/** Per-file kebab handlers, shared by the card and row wrappers. */ +interface FileMenuHandlers { + onRemove: () => void; + onMove: () => void; + onDownload?: () => void; + onRename?: () => void; + onDuplicate?: () => void; + /** Kebab Save to server; only fires when file is local-only. */ + onSaveToServer?: () => void; + /** Open the version-history modal; shown only when file has >1 version. */ + onVersionHistory?: () => void; + /** When set, the kebab Save to server is disabled with this tooltip. */ + saveToServerDisabledReason?: string | null; +} + +interface FileCardProps extends FileMenuHandlers { file: StirlingFileStub; isSelected: boolean; isInWorkspace: boolean; @@ -637,14 +843,6 @@ interface FileCardProps { multiSelectActive: boolean; onClick: (e: React.MouseEvent) => void; onDoubleClick: () => void; - onRemove: () => void; - onMove: () => void; - /** Kebab Save to server; only fires when file is local-only. */ - onSaveToServer?: () => void; - /** Open the version-history modal; shown only when file has >1 version. */ - onVersionHistory?: () => void; - /** When set, the kebab Save to server is disabled with this tooltip. */ - saveToServerDisabledReason?: string | null; } function FileCard({ @@ -656,11 +854,7 @@ function FileCard({ multiSelectActive, onClick, onDoubleClick, - onRemove, - onMove, - onSaveToServer, - onVersionHistory, - saveToServerDisabledReason, + ...menuHandlers }: FileCardProps) { const { t } = useTranslation(); const cardRef = useRef(null); @@ -784,126 +978,48 @@ function FileCard({ )}
{fileSize} - · + {fileDate}
- - - e.stopPropagation()} - aria-label={t("filesPage.fileMenu", "File actions")} - data-testid="file-card-actions" - > - - - - - } - onClick={(e) => { - e.stopPropagation(); - onDoubleClick(); - }} - > - {t("filesPage.addToWorkspace", "Add to workspace")} - - - } - onClick={(e) => { - e.stopPropagation(); - onMove(); - }} - data-testid="file-menu-move-to" - > - {t("filesPage.moveTo", "Move to…")} - - {/* Per-file Save to server; shown for local-only files. When - storage is off it stays visible but disabled with a tooltip. */} - {onSaveToServer && file.remoteStorageId == null && ( - - } - disabled={Boolean(saveToServerDisabledReason)} - onClick={(e) => { - e.stopPropagation(); - onSaveToServer(); - }} - style={ - saveToServerDisabledReason - ? { pointerEvents: "auto" } - : undefined - } - > - {t("filesPage.saveToServer", "Save to server")} - - - )} - {onVersionHistory && (file.versionNumber ?? 1) > 1 && ( - } - onClick={(e) => { - e.stopPropagation(); - onVersionHistory(); - }} - > - {t("filesPage.versionHistory", "Version history")} - - )} - - } - onClick={(e) => { - e.stopPropagation(); - onRemove(); - }} - > - {t("filesPage.remove", "Delete")} - - - +
); } -function ListView({ - entries, - selectedFileIds, - activeWorkspaceFileIds, - onSelectFile, - onSetSelection, - onOpenFolder, - onOpenFile, - onMoveFiles, - onMoveFolder, - onRenameFolder, - onDeleteFolder, - onSaveToServer, - onVersionHistory, - saveToServerDisabledReason, - onChangeFolderAppearance, - onRemoveFiles, - onPromptMoveFiles, - sortMode, - onChangeSortMode, -}: FileGridProps & { - sortMode?: FilesPageSortMode; - onChangeSortMode?: (next: FilesPageSortMode) => void; -}) { +function ListView( + props: FileGridProps & { + sortMode?: FilesPageSortMode; + onChangeSortMode?: (next: FilesPageSortMode) => void; + }, +) { + const { + entries, + selectedFileIds, + activeWorkspaceFileIds, + onSelectFile, + onSetSelection, + onOpenFolder, + onOpenFile, + onMoveFiles, + onMoveFolder, + onRenameFolder, + onDeleteFolder, + onChangeFolderAppearance, + sortMode, + onChangeSortMode, + } = props; + const menuHandlersFor = useFileMenuHandlers(props); const { t } = useTranslation(); // Tri-state header checkbox state - computed from current entries. @@ -1029,22 +1145,7 @@ function ListView({ onSelectFile(entry.file!.id, e.shiftKey, e.metaKey || e.ctrlKey) } onOpen={() => onOpenFile(entry.file!)} - onRemove={() => onRemoveFiles([entry.file!.id])} - onMove={() => { - const target = selectedFileIds.has(entry.file!.id) - ? Array.from(selectedFileIds) - : [entry.file!.id]; - onPromptMoveFiles(target); - }} - onSaveToServer={ - onSaveToServer ? () => onSaveToServer(entry.file!) : undefined - } - onVersionHistory={ - onVersionHistory - ? () => onVersionHistory(entry.file!) - : undefined - } - saveToServerDisabledReason={saveToServerDisabledReason} + {...menuHandlersFor(entry.file)} /> ); } @@ -1241,7 +1342,7 @@ function FolderRow({ ); } -interface FileRowProps { +interface FileRowProps extends FileMenuHandlers { file: StirlingFileStub; isSelected: boolean; isInWorkspace: boolean; @@ -1251,14 +1352,6 @@ interface FileRowProps { multiSelectActive: boolean; onClick: (e: React.MouseEvent) => void; onOpen: () => void; - onRemove: () => void; - onMove: () => void; - /** Kebab Save to server; only fires when file is local-only. */ - onSaveToServer?: () => void; - /** Open the version-history modal; shown only when file has >1 version. */ - onVersionHistory?: () => void; - /** When set, the kebab Save to server is disabled with this tooltip. */ - saveToServerDisabledReason?: string | null; } function FileRow({ @@ -1270,11 +1363,7 @@ function FileRow({ multiSelectActive, onClick, onOpen, - onRemove, - onMove, - onSaveToServer, - onVersionHistory, - saveToServerDisabledReason, + ...menuHandlers }: FileRowProps) { const { t } = useTranslation(); const kebabRef = useRef(null); @@ -1414,91 +1503,12 @@ function FileRow({ {fileSize} {fileDate} - - - e.stopPropagation()} - aria-label={t("filesPage.fileMenu", "File actions")} - data-testid="file-card-actions" - > - - - - - } - onClick={(e) => { - e.stopPropagation(); - onOpen(); - }} - > - {t("filesPage.addToWorkspace", "Add to workspace")} - - - } - onClick={(e) => { - e.stopPropagation(); - onMove(); - }} - > - {t("filesPage.moveTo", "Move to…")} - - {/* Per-file Save to server; shown for local-only files. When - storage is off it stays visible but disabled with a tooltip. */} - {onSaveToServer && file.remoteStorageId == null && ( - - } - disabled={Boolean(saveToServerDisabledReason)} - onClick={(e) => { - e.stopPropagation(); - onSaveToServer(); - }} - style={ - saveToServerDisabledReason - ? { pointerEvents: "auto" } - : undefined - } - > - {t("filesPage.saveToServer", "Save to server")} - - - )} - {onVersionHistory && (file.versionNumber ?? 1) > 1 && ( - } - onClick={(e) => { - e.stopPropagation(); - onVersionHistory(); - }} - > - {t("filesPage.versionHistory", "Version history")} - - )} - - } - onClick={(e) => { - e.stopPropagation(); - onRemove(); - }} - > - {t("filesPage.remove", "Delete")} - - - + ); diff --git a/frontend/editor/src/core/components/filesPage/FileManagerView.tsx b/frontend/editor/src/core/components/filesPage/FileManagerView.tsx index a0b0c6cfa1..36ffb9c4dc 100644 --- a/frontend/editor/src/core/components/filesPage/FileManagerView.tsx +++ b/frontend/editor/src/core/components/filesPage/FileManagerView.tsx @@ -33,6 +33,10 @@ import InfoOutlinedIcon from "@mui/icons-material/InfoOutlined"; import CloudUploadIcon from "@mui/icons-material/CloudUpload"; import KeyboardArrowRightIcon from "@mui/icons-material/KeyboardArrowRight"; import RefreshIcon from "@mui/icons-material/Refresh"; +import { FilesToolbarBulkMenu } from "@app/components/filesPage/FilesToolbarBulkMenu"; +import { FilesToolbarCount } from "@app/components/filesPage/FilesToolbarCount"; +import { FilesToolbarFilterMenu } from "@app/components/filesPage/FilesToolbarFilterMenu"; +import { FilesToolbarSortMenu } from "@app/components/filesPage/FilesToolbarSortMenu"; import { stripBasePath } from "@app/constants/app"; import { useAuth } from "@app/auth/UseSession"; @@ -71,6 +75,10 @@ import { FolderNameDialog } from "@app/components/filesPage/FolderNameDialog"; import { DeleteFolderDialog } from "@app/components/filesPage/DeleteFolderDialog"; import { DeleteFilesDialog } from "@app/components/filesPage/DeleteFilesDialog"; import { VersionHistoryModal } from "@app/components/filesPage/VersionHistoryModal"; +import { RenameFileDialog } from "@app/components/shared/RenameFileDialog"; +import { duplicateStoredFile } from "@app/utils/duplicateFile"; +import { downloadFileFromStorage } from "@app/utils/downloadUtils"; +import { fileStorage } from "@app/services/fileStorage"; import { materializeServerStubs } from "@app/services/fileSyncService"; import { FILES_PAGE_DRAG_TYPE, @@ -794,6 +802,92 @@ export default function FileManagerView() { [removeFiles], ); + // ─── per-file kebab: download / rename / duplicate ─────────────────────── + // Same actions the file sidebar's kebab offers, so both surfaces match. + + /** Cloud-only rows hold no bytes; pull them local before acting on them. */ + const localCopyOf = useCallback( + async (file: StirlingFileStub): Promise => { + const [materialized] = await materializeServerStubs([file], { + addFiles: fileActions.addFilesWithOptions, + updateStub: fileActions.updateStirlingFileStub, + }); + return materialized ?? null; + }, + [fileActions], + ); + + const handleDownloadFile = useCallback( + async (file: StirlingFileStub) => { + try { + const local = await localCopyOf(file); + if (!local) return; + await downloadFileFromStorage(local); + } catch (err) { + console.error("[FilesPage] Download failed", err); + folders.setError( + t("filesPage.error.downloadFailed", "Could not download the file."), + ); + } + }, + [localCopyOf, folders, t], + ); + + const handleDuplicateFile = useCallback( + async (file: StirlingFileStub) => { + try { + const local = await localCopyOf(file); + if (!local) return; + const copyId = await duplicateStoredFile( + local, + allFiles.map((f) => f.name), + addFiles, + ); + if (!copyId) { + throw new Error(`File "${local.name}" not found in storage`); + } + await refresh(); + } catch (err) { + console.error("[FilesPage] Duplicate failed", err); + folders.setError( + t("filesPage.error.duplicateFailed", "Could not duplicate the file."), + ); + } + }, + [localCopyOf, allFiles, addFiles, refresh, folders, t], + ); + + const [renameTarget, setRenameTarget] = useState( + null, + ); + + // The stub name is what the UI and exports read, so a rename is a metadata + // write; the workbench copy (if any) is updated in the same breath. + const handleConfirmRename = useCallback( + async (name: string) => { + const file = renameTarget; + if (!file) return; + const local = await localCopyOf(file); + if (!local) return; + // quickKey is name|size|lastModified; a stale one would make a re-upload + // of the original look like a duplicate of the renamed file. + const quickKey = `${name}|${local.size}|${local.lastModified}`; + const saved = await fileStorage.updateFileMetadata(local.id, { + name, + quickKey, + }); + if (!saved) { + throw new Error( + t("fileSidebar.rename.error", "Could not rename the file."), + ); + } + fileActions.updateStirlingFileStub(local.id, { name, quickKey }); + setRenameTarget(null); + await refresh(); + }, + [renameTarget, localCopyOf, fileActions, refresh, t], + ); + // ─── derived UI bits ──────────────────────────────────────────────────── const currentFolderRecord = currentFolderId ? (foldersById.get(currentFolderId) ?? null) @@ -803,6 +897,9 @@ export default function FileManagerView() { () => Array.from(selectedFileIds), [selectedFileIds], ); + // A phone with files selected shows a contextual selection bar instead of the + // full toolbar - five bulk buttons plus filters cannot fit the width. + const mobileSelection = isMobile && selectedFiles.length > 0; // Local-only subset of selection; drives Save-to-server visibility. const localOnlySelectedStubs = useMemo( @@ -1120,22 +1217,12 @@ export default function FileManagerView() { })()}
- - {loading - ? t("filesPage.loading", "Loading…") - : t("filesPage.summary", "{{count}} items", { - count: totalCount, - })} - {selectedFiles.length > 0 && ( - - {" "} - ·{" "} - {t("filesPage.selectedCount", "{{count}} selected", { - count: selectedFiles.length, - })} - - )} - + {(() => { // Select all / Clear toggle over visible files. if (visibleFiles.length === 0) return null; @@ -1175,289 +1262,382 @@ export default function FileManagerView() { ); })()}
- {selectedFiles.length > 0 && - (() => { - // Bulk-action labels; CSS collapses to icon-only below 900px. - const addLabel = + {mobileSelection ? ( + handleAddToWorkspace(selectedFiles)} + onSaveToServer={ + localOnlySelectedStubs.length > 0 + ? () => setSaveToServerTarget(localOnlySelectedStubs) + : undefined + } + saveToServerDisabledReason={ + saveToServerDisabledReason ?? undefined + } + onShowDetails={ selectedFiles.length === 1 - ? t("filesPage.addToWorkspace", "Add to workspace") - : t( - "filesPage.addToWorkspaceCount", - "Add {{count}} to workspace", - { count: selectedFiles.length }, - ); - const moveLabel = t("filesPage.moveTo", "Move to…"); - const removeLabel = t("filesPage.remove", "Remove"); - return ( - // wrap="nowrap" keeps the row single-line. - - - - - {/* Save to server; shown whenever local-only files are + ? () => setMobileDetailsOpen(true) + : undefined + } + onMove={() => promptMoveFiles(selectedFiles)} + onRemove={() => handleRemoveFiles(selectedFiles)} + /> + ) : ( + <> + {selectedFiles.length > 0 && + (() => { + // Bulk-action labels; CSS collapses to icon-only below 900px. + const addLabel = + selectedFiles.length === 1 + ? t("filesPage.addToWorkspace", "Add to workspace") + : t( + "filesPage.addToWorkspaceCount", + "Add {{count}} to workspace", + { count: selectedFiles.length }, + ); + const moveLabel = t("filesPage.moveTo", "Move to…"); + const removeLabel = t("filesPage.remove", "Remove"); + return ( + // wrap="nowrap" keeps the row single-line. + + + + + {/* Save to server; shown whenever local-only files are selected. When storage is off it stays visible but disabled, tooltip pointing at the admin. */} - {localOnlySelectedStubs.length > 0 && ( - - + + )} + {/* Show details button on compact viewports. */} + {selectedFiles.length === 1 && + isCompactDetailsViewport && ( + + + )} - > - {t("filesPage.saveToServer", "Save to server")} - - - )} - {/* Show details button on compact viewports. */} - {selectedFiles.length === 1 && - isCompactDetailsViewport && ( - + + + + + + clearSelection()} + aria-label={t( + "filesPage.clearSelection", + "Clear selection", + )} + > + × + + + + ); + })()} + {selectedFiles.length > 0 && ( +
@@ -1503,6 +1683,9 @@ export default function FileManagerView() { onPromptMoveFiles={promptMoveFiles} onSaveToServer={(file) => setSaveToServerTarget([file])} onVersionHistory={(file) => setVersionHistoryFile(file)} + onDownloadFile={handleDownloadFile} + onRenameFile={setRenameTarget} + onDuplicateFile={handleDuplicateFile} saveToServerDisabledReason={saveToServerDisabledReason} // Center-of-grid CTAs when the empty state shows - same // handlers the corner header buttons use so behaviour @@ -1662,6 +1845,14 @@ export default function FileManagerView() { onConfirm={confirmRemoveFiles} /> + {/* Rename (opened from the card kebab). */} + setRenameTarget(null)} + onSubmit={handleConfirmRename} + /> + {/* Version journey in a modal (opened from the card kebab). */} span { + white-space: nowrap; + } + .files-page-card-meta-sep { + display: none; + } +} + /* Parent-folder breadcrumb shown on cards/rows during recursive search so the user can tell which folder each hit lives in without navigating. */ .files-page-card-path { @@ -1295,30 +1323,38 @@ sits next to the Upload button without breaking the action row. */ display: none; } -@media (max-width: 900px) { +@media (max-width: 1024px) { .files-page-toolbar { /* nowrap so "7 items" + "Select all" sit on the same row as the filter dropdowns and view-toggle instead of stacking on three - separate lines. Per-child min-width:0 lets them shrink as needed. - Used to only kick in at ≤640px which left a broken zone where - both side panels were hidden but the toolbar was still wrapping - to multiple rows. */ + separate lines. Runs to the app's mobile breakpoint: capping it at + 900px left 901-1024px wrapping to two rows, which is the band the + mobile layout actually renders in. + + Scrolls rather than clips. With a selection active the bulk-action + strip cannot fit any phone width, and `overflow-x: hidden` put those + buttons permanently out of reach behind the edge. */ flex-wrap: nowrap; gap: 0.35rem; padding: 0.35rem 0.5rem; min-height: auto; - overflow-x: hidden; + overflow-x: auto; + scrollbar-width: none; + } + .files-page-toolbar::-webkit-scrollbar { + display: none; } .files-page-toolbar-info { - /* Was `flex-basis: 100%` which forced a row break. Let it share - the row, shrink hard if needed, and ellipsize so the count line - collapses gracefully (was overlapping the bulk-action buttons - at ~400px because no truncation rule existed). */ - flex: 0 1 auto; + /* The toolbar's only status text. Pinned, because against nowrap + siblings it lost every shrink round and rendered as "3 i". */ + flex-shrink: 0; min-width: 0; white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; + } + /* Filter and sort collapse to icon triggers here (see FilesToolbar*Menu); + they are the whole control, so they never shrink. */ + .files-page-toolbar-icon-btn { + flex-shrink: 0; } .files-page-toolbar-actions { flex-wrap: nowrap; @@ -1350,41 +1386,48 @@ navigation, so the in-header Home/Apps/Close trio is duplicated and the first to go. Same for "Upload" - the user can use the centre drop overlay. */ -@media (max-width: 640px) { - /* Drop the 3-column grid on phones; flex-wrap lets the search slip onto - * its own row when chrome is too cramped to share. */ +/* ── Mobile + tablet chrome (≤1024px = useIsMobile) ────────────────── + The desktop header is a 3-column grid whose middle track can grow to + 40rem. Below ~1024px that track eats the row: the breadcrumb column + collapsed to ~36px (wrapping "All files" to two lines) and the action + column overflowed, pushing Upload off the right edge. One flex row + instead - breadcrumb and actions keep their intrinsic width and the + search takes whatever is left. Ends at the app's mobile breakpoint so + it matches the layout HomePage is already rendering. */ +@media (max-width: 1024px) { .files-page-header { display: flex; - flex-wrap: wrap; + flex-wrap: nowrap; + align-items: center; gap: 0.4rem; - padding: 0 0.4rem; + padding: 0.25rem 0.4rem; overflow-x: hidden; } - .files-page-header [data-mobile-hide="true"] { - display: none !important; + .files-page-header-search { + flex: 1 1 auto; + min-width: 0; + justify-content: flex-start; } - .files-page-header [data-desktop-hide="true"] { - display: inline-flex !important; - } - /* Mobile-hide for sub-toolbar create buttons. */ - .files-page-toolbar [data-mobile-hide="true"] { - display: none !important; + /* Undo the fixed 24rem basis so the pill tracks the row's spare width. */ + .files-page-header-search .super-search { + flex: 1 1 auto; + width: 100%; + max-width: none; } .files-page-header-actions { + flex: 0 0 auto; margin-left: auto; - gap: 0.3rem; + gap: 0.25rem; flex-wrap: nowrap; } .files-page-breadcrumbs { + flex: 0 1 auto; font-size: 0.85rem; flex-wrap: nowrap; overflow-x: auto; min-width: 0; } - /* Upload becomes an icon-only square button on mobile so the action - row stops getting clipped. Scoped to `.files-page-header-actions` - so the Back button at the header level keeps its visible "Back" - label (Back has no other on-screen indicator that it's about leaving). */ + /* Icon-only actions: the labels are what pushed Upload past the edge. */ .files-page-header-actions .mantine-Button-root { padding-left: 0.55rem; padding-right: 0.55rem; @@ -1395,6 +1438,9 @@ .files-page-header-actions .mantine-Button-label { display: none; } +} + +@media (max-width: 640px) { /* Grid: single column on very narrow phones; two columns from ~440px */ .files-page-grid { grid-template-columns: repeat(auto-fill, minmax(9rem, 1fr)); diff --git a/frontend/editor/src/core/components/filesPage/FilesToolbarBulkMenu.tsx b/frontend/editor/src/core/components/filesPage/FilesToolbarBulkMenu.tsx new file mode 100644 index 0000000000..3e344ac7b0 --- /dev/null +++ b/frontend/editor/src/core/components/filesPage/FilesToolbarBulkMenu.tsx @@ -0,0 +1,104 @@ +import { Menu } from "@mantine/core"; +import { useTranslation } from "react-i18next"; +import CloudUploadIcon from "@mui/icons-material/CloudUpload"; +import DeleteIcon from "@mui/icons-material/Delete"; +import DriveFileMoveIcon from "@mui/icons-material/DriveFileMove"; +import ExpandMoreIcon from "@mui/icons-material/ExpandMore"; +import InfoOutlinedIcon from "@mui/icons-material/InfoOutlined"; +import OpenInNewIcon from "@mui/icons-material/OpenInNew"; + +import { Button } from "@app/ui/Button"; + +interface FilesToolbarBulkMenuProps { + selectedCount: number; + onAddToWorkspace: () => void; + /** Local-only files in the selection; omit when there are none to upload. */ + onSaveToServer?: () => void; + /** Set when storage is off - the item stays listed but disabled. */ + saveToServerDisabledReason?: string; + onShowDetails?: () => void; + onMove: () => void; + onRemove: () => void; +} + +/** + * Bulk actions behind one trigger. The full strip is five buttons wide, which + * no phone can hold alongside the count and the clear control, so rather than + * letting the row scroll them off the edge they collapse into a menu where + * every action keeps its label. + */ +export function FilesToolbarBulkMenu({ + selectedCount, + onAddToWorkspace, + onSaveToServer, + saveToServerDisabledReason, + onShowDetails, + onMove, + onRemove, +}: FilesToolbarBulkMenuProps) { + const { t } = useTranslation(); + + const addLabel = + selectedCount === 1 + ? t("filesPage.addToWorkspace", "Add to workspace") + : t("filesPage.addToWorkspaceCount", "Add {{count}} to workspace", { + count: selectedCount, + }); + + return ( + + + + + + } + onClick={onAddToWorkspace} + > + {addLabel} + + {onSaveToServer && ( + } + disabled={Boolean(saveToServerDisabledReason)} + onClick={onSaveToServer} + > + {t("filesPage.saveToServer", "Save to server")} + + )} + {onShowDetails && ( + } + onClick={onShowDetails} + > + {t("filesPage.showDetails", "Show details")} + + )} + } + onClick={onMove} + > + {t("filesPage.moveTo", "Move to…")} + + + } + onClick={onRemove} + > + {t("filesPage.remove", "Remove")} + + + + ); +} + +export default FilesToolbarBulkMenu; diff --git a/frontend/editor/src/core/components/filesPage/FilesToolbarCount.tsx b/frontend/editor/src/core/components/filesPage/FilesToolbarCount.tsx new file mode 100644 index 0000000000..d808638d63 --- /dev/null +++ b/frontend/editor/src/core/components/filesPage/FilesToolbarCount.tsx @@ -0,0 +1,41 @@ +import { useTranslation } from "react-i18next"; + +interface FilesToolbarCountProps { + loading: boolean; + totalCount: number; + selectedCount: number; + /** + * Selection-bar mode: report only the selection. A phone spends the room on + * the actions rather than on "3 items · 3 selected". + */ + selectionOnly: boolean; +} + +/** Status text at the head of the files toolbar. */ +export function FilesToolbarCount({ + loading, + totalCount, + selectedCount, + selectionOnly, +}: FilesToolbarCountProps) { + const { t } = useTranslation(); + + const selected = t("filesPage.selectedCount", "{{count}} selected", { + count: selectedCount, + }); + + if (selectionOnly) { + return {selected}; + } + + return ( + + {loading + ? t("filesPage.loading", "Loading…") + : t("filesPage.summary", "{{count}} items", { count: totalCount })} + {selectedCount > 0 && · {selected}} + + ); +} + +export default FilesToolbarCount; diff --git a/frontend/editor/src/core/components/filesPage/FilesToolbarFilterMenu.tsx b/frontend/editor/src/core/components/filesPage/FilesToolbarFilterMenu.tsx new file mode 100644 index 0000000000..81d84daf67 --- /dev/null +++ b/frontend/editor/src/core/components/filesPage/FilesToolbarFilterMenu.tsx @@ -0,0 +1,149 @@ +import { MultiSelect, Popover, Select, Stack, TextInput } from "@mantine/core"; +import { useTranslation } from "react-i18next"; +import CloseIcon from "@mui/icons-material/Close"; +import SearchIcon from "@mui/icons-material/Search"; +import TuneIcon from "@mui/icons-material/Tune"; + +import { ActionIcon } from "@app/ui/ActionIcon"; +import { Button } from "@app/ui/Button"; +import { Tooltip } from "@app/components/shared/Tooltip"; +import type { FilesPageOriginFilter } from "@app/contexts/FilesPageContext"; + +interface FilesToolbarFilterMenuProps { + originFilter: FilesPageOriginFilter; + onOriginChange: (value: FilesPageOriginFilter) => void; + availableTypes: string[]; + typeFilter: string[]; + onTypeChange: (value: string[]) => void; + search: string; + onSearchChange: (value: string) => void; +} + +/** + * Source, type and name filters collapsed behind one icon. Side by side these + * three need ~480px, so on narrow viewports they were each truncated to + * unreadable stubs ("All sour"). In the popover they get their full width back, + * and a dot on the trigger keeps an active filter discoverable while hidden. + */ +export function FilesToolbarFilterMenu({ + originFilter, + onOriginChange, + availableTypes, + typeFilter, + onTypeChange, + search, + onSearchChange, +}: FilesToolbarFilterMenuProps) { + const { t } = useTranslation(); + + const activeCount = + (originFilter !== "all" ? 1 : 0) + + (typeFilter.length > 0 ? 1 : 0) + + (search.trim() !== "" ? 1 : 0); + const label = t("filesPage.filters.label", "Filters"); + + const clearAll = () => { + onOriginChange("all"); + onTypeChange([]); + onSearchChange(""); + }; + + return ( + + +
+ 0 + ? t( + "filesPage.filters.activeCount", + "{{count}} filters active", + { + count: activeCount, + }, + ) + : label + } + position="bottom" + > + 0 ? "primary" : "tertiary"} + size="sm" + aria-label={label} + className="files-page-toolbar-icon-btn" + > + + + +
+
+ + + onSearchChange(e.currentTarget.value)} + placeholder={t("filesPage.search.placeholder", "Filter files…")} + leftSection={} + rightSection={ + search ? ( + onSearchChange("")} + aria-label={t("filesPage.search.clear", "Clear filter")} + > + + + ) : null + } + aria-label={t("filesPage.search.label", "Filter files by name")} + /> + update({ profile: value || "ua1" })} + data={profileOptions} + disabled={disabled} + comboboxProps={{ zIndex: Z_INDEX_AUTOMATE_DROPDOWN }} + data-testid="pdfua-profile-select" + /> + + + update({ language: event.currentTarget.value })} + disabled={disabled} + data-testid="pdfua-language-input" + /> + + + update({ overrideLanguage: event.currentTarget.checked }) + } + disabled={disabled} + data-testid="pdfua-override-language" + /> + + update({ title: event.currentTarget.value })} + disabled={disabled} + data-testid="pdfua-title-input" + /> + + + update({ embedFonts: event.currentTarget.checked }) + } + disabled={disabled} + data-testid="pdfua-embed-fonts" + /> + + + + {t( + "convert.pdfUaAltTextNotice", + "Images need a written description before a document can be certified. Descriptions are never generated automatically, because an invented one passes the checker while telling a screen-reader user nothing. Any image left without one is reported, and the file comes back tagged but not certified.", + )} + + + + {tooManyFiles && ( + + + {t( + "convert.pdfUaAltTextSingleFileOnly", + "Descriptions belong to one document: an image is identified by its position, which is a different image in every file. Convert these {{fileCount}} files to tag them, then convert one at a time to describe its images.", + { fileCount: selectedFiles.length }, + )} + + + )} + + {!tooManyFiles && ( + + )} + + {scanError && ( + + {scanError} + + )} + + {!tooManyFiles && figures?.length === 0 && ( + + {t( + "convert.pdfUaNoImagesNeedingText", + "No image is missing a description.", + )} + + )} + + {!tooManyFiles && + figures?.map((figure) => ( + + update({ + altText: formatAltText({ + ...descriptions, + [figure.key]: event.currentTarget.value, + }), + }) + } + disabled={disabled} + data-testid={`pdfua-alt-text-${figure.key}`} + /> + ))} + + ); +}; + +export default ConvertToPdfUaSettings; diff --git a/frontend/editor/src/core/components/tools/convert/ConvertToPdfaSettings.tsx b/frontend/editor/src/core/components/tools/convert/ConvertToPdfaSettings.tsx index 298fcc847b..fa035eb067 100644 --- a/frontend/editor/src/core/components/tools/convert/ConvertToPdfaSettings.tsx +++ b/frontend/editor/src/core/components/tools/convert/ConvertToPdfaSettings.tsx @@ -3,6 +3,7 @@ import { Stack, Text, Select, Alert, Checkbox } from "@mantine/core"; import { useTranslation } from "react-i18next"; import { ConvertParameters } from "@app/hooks/tools/convert/useConvertParameters"; import { usePdfSignatureDetection } from "@app/hooks/usePdfSignatureDetection"; +import { useEndpointEnabled } from "@app/hooks/useEndpointConfig"; import { StirlingFile } from "@app/types/fileContext"; import { Z_INDEX_AUTOMATE_DROPDOWN } from "@app/styles/zIndex"; @@ -26,11 +27,21 @@ const ConvertToPdfaSettings = ({ const { hasDigitalSignatures, isChecking } = usePdfSignatureDetection(selectedFiles); const outputFormatLabelId = useId(); + // Level A needs the same tagger as PDF/UA, so it stands or falls with that endpoint. + const { enabled: taggingAvailable } = useEndpointEnabled("pdf-to-ua"); const pdfaFormatOptions = [ { value: "pdfa-1", label: "PDF/A-1b" }, { value: "pdfa-2b", label: "PDF/A-2b" }, { value: "pdfa-3b", label: "PDF/A-3b" }, + // Level A is level B plus accessibility: it additionally tags the document. + ...(taggingAvailable === false + ? [] + : [ + { value: "pdfa-1a", label: "PDF/A-1a (accessible)" }, + { value: "pdfa-2a", label: "PDF/A-2a (accessible)" }, + { value: "pdfa-3a", label: "PDF/A-3a (accessible)" }, + ]), ]; return ( diff --git a/frontend/editor/src/core/components/viewer/EmbedPdfViewer.tsx b/frontend/editor/src/core/components/viewer/EmbedPdfViewer.tsx index 187a48ad16..c0f9d4b4c4 100644 --- a/frontend/editor/src/core/components/viewer/EmbedPdfViewer.tsx +++ b/frontend/editor/src/core/components/viewer/EmbedPdfViewer.tsx @@ -13,6 +13,7 @@ import { useFileActions, } from "@app/contexts/FileContext"; import { useFileWithUrl } from "@app/hooks/useFileWithUrl"; +import { ZoomMode } from "@embedpdf/plugin-zoom/react"; import { useViewer } from "@app/contexts/ViewerContext"; import { LocalEmbedPDF } from "@app/components/viewer/LocalEmbedPDF"; import { PdfViewerToolbar } from "@app/components/viewer/PdfViewerToolbar"; @@ -42,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"; @@ -418,7 +420,7 @@ const EmbedPdfViewerContent = ({ return; case "0": event.preventDefault(); - zoomActions.requestZoom("fit-width"); + zoomActions.requestZoom(ZoomMode.FitWidth); return; } } @@ -781,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 @@ -1236,6 +1238,7 @@ const EmbedPdfViewerContent = ({ showBakedAnnotations={isAnnotationsVisible} enableRedaction={shouldEnableRedaction} enableFormFill={shouldEnableFormFill} + formEditingActive={isFormFillToolActive} isManualRedactionMode={isManualRedactMode} signatureApiRef={signatureApiRef as React.RefObject} annotationApiRef={annotationApiRef as React.RefObject} diff --git a/frontend/editor/src/core/components/viewer/LocalEmbedPDF.tsx b/frontend/editor/src/core/components/viewer/LocalEmbedPDF.tsx index e1084e2772..07fe867ed2 100644 --- a/frontend/editor/src/core/components/viewer/LocalEmbedPDF.tsx +++ b/frontend/editor/src/core/components/viewer/LocalEmbedPDF.tsx @@ -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({ + @@ -1153,6 +1160,28 @@ export function LocalEmbedPDF({ /> )} + {/* Create-mode: drag to place new fields */} + {enableFormFill && formEditingActive && ( + + )} + + {/* Modify-mode: select / move / resize existing fields */} + {enableFormFill && formEditingActive && ( + + )} + {/* SignatureFieldOverlay — bitmaps of digital-signature appearances */} {file && ( ({ copyToClipboard: () => selection.copyToClipboard(), - getSelectedText: () => selection.getSelectedText(), getFormattedSelection: () => selection.getFormattedSelection(), selectAll: async (totalPages: number) => { const docId = activeDocumentId; diff --git a/frontend/editor/src/core/components/viewer/SignatureFieldOverlay.tsx b/frontend/editor/src/core/components/viewer/SignatureFieldOverlay.tsx index afd04bf6b2..7f914741f3 100644 --- a/frontend/editor/src/core/components/viewer/SignatureFieldOverlay.tsx +++ b/frontend/editor/src/core/components/viewer/SignatureFieldOverlay.tsx @@ -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([]); 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; diff --git a/frontend/editor/src/core/components/viewer/nonpdf/HtmlViewer.tsx b/frontend/editor/src/core/components/viewer/nonpdf/HtmlViewer.tsx index ee9016b926..329e0cdfd8 100644 --- a/frontend/editor/src/core/components/viewer/nonpdf/HtmlViewer.tsx +++ b/frontend/editor/src/core/components/viewer/nonpdf/HtmlViewer.tsx @@ -1,7 +1,9 @@ import { useEffect, useState } from "react"; -import { Box, Paper, Text } from "@mantine/core"; +import { Box, Center, Group, Paper, Stack, Text } from "@mantine/core"; import { useTranslation } from "react-i18next"; +import { Button } from "@app/ui/Button"; +import { useIsMobile } from "@app/hooks/useIsMobile"; import { formatFileSize } from "@app/utils/fileUtils"; interface HtmlViewerProps { @@ -10,37 +12,79 @@ interface HtmlViewerProps { export function HtmlViewer({ file }: HtmlViewerProps) { const { t } = useTranslation(); + const isMobile = useIsMobile(); const [objectUrl, setObjectUrl] = useState(null); + // Phones render a desktop-width document into ~400px, which reads as a blank + // column, so the iframe is opt-in there. Derived rather than seeded into + // state because useIsMobile resolves after first paint. + const [optedIn, setOptedIn] = useState(false); + const showPreview = !isMobile || optedIn; useEffect(() => { + if (!showPreview) return; const url = URL.createObjectURL(file); setObjectUrl(url); return () => URL.revokeObjectURL(url); - }, [file]); + }, [file, showPreview]); return ( - - {t("viewer.nonPdf.htmlPreviewWarning", { - size: formatFileSize(file.size), - })} - + + + {t("viewer.nonPdf.htmlPreviewWarning", { + size: formatFileSize(file.size), + })} + + {/* Opting in used to be one-way: the only way back was closing and + reopening the file. */} + {isMobile && optedIn && ( + + )} + - {objectUrl && ( -