mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2026-09-03 05:10:16 +03:00
Compare commits
82
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3a6b9f34ef | ||
|
|
8fda0e58c2 | ||
|
|
560c0d3ca9 | ||
|
|
cbc8af1951 | ||
|
|
aca0e40c37 | ||
|
|
1b2a3118a6 | ||
|
|
3056e5ff44 | ||
|
|
798ba57f0b | ||
|
|
42bdce155c | ||
|
|
2cf355c5cd | ||
|
|
c57a2a45de | ||
|
|
d30faf246b | ||
|
|
f6661a8f87 | ||
|
|
ceeec53df4 | ||
|
|
4ef2e3811c | ||
|
|
31d52d4c32 | ||
|
|
d55d8acbfa | ||
|
|
af97e1b27b | ||
|
|
01c908e95d | ||
|
|
0920ea9493 | ||
|
|
96207a7304 | ||
|
|
d93049db9f | ||
|
|
54e839ae65 | ||
|
|
3aca7a26f6 | ||
|
|
5fe7df3933 | ||
|
|
b1e857fd01 | ||
|
|
b92f88361e | ||
|
|
f6124223e4 | ||
|
|
3235645203 | ||
|
|
1f4cc2612d | ||
|
|
e539eb1ab1 | ||
|
|
1bb6961414 | ||
|
|
34694c6f5e | ||
|
|
0b7b4e02c2 | ||
|
|
74be5bf0ad | ||
|
|
8c00fffe18 | ||
|
|
c5da4177c4 | ||
|
|
ddc0ac0ced | ||
|
|
8bdd00b2fa | ||
|
|
3718af45ff | ||
|
|
41cbd97b48 | ||
|
|
993adaa3cd | ||
|
|
ead8a536d2 | ||
|
|
c22d9ecf58 | ||
|
|
d3708c1e63 | ||
|
|
1c055f3d18 | ||
|
|
4ab2505a6c | ||
|
|
658aa54c20 | ||
|
|
0a3f0c1814 | ||
|
|
849d616451 | ||
|
|
a48356a2d2 | ||
|
|
f71b0247da | ||
|
|
be13028209 | ||
|
|
d4b1862654 | ||
|
|
51835a7b5e | ||
|
|
97c0ccf582 | ||
|
|
4e46ba3b5a | ||
|
|
cbe3ef8f69 | ||
|
|
5b5e922069 | ||
|
|
a215c30068 | ||
|
|
897c72e9d9 | ||
|
|
732ef18ae5 | ||
|
|
f7a2c626c9 | ||
|
|
caeca0b88a | ||
|
|
c93feb5dfc | ||
|
|
f945cc7dc6 | ||
|
|
72b7892312 | ||
|
|
353df7a647 | ||
|
|
0d75715af2 | ||
|
|
b44202185a | ||
|
|
49c1e75ced | ||
|
|
826e487f00 | ||
|
|
bcad2cd486 | ||
|
|
79686a3a09 | ||
|
|
e51885b379 | ||
|
|
ddee690c58 | ||
|
|
158187ac46 | ||
|
|
e50c3de0a9 | ||
|
|
8e7501aec1 | ||
|
|
dbd60d4765 | ||
|
|
629f501e9c | ||
|
|
1df372764f |
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/claude-code-settings.json",
|
||||
"hooks": {
|
||||
"Stop": [
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node",
|
||||
"args": [
|
||||
"${CLAUDE_PROJECT_DIR}/scripts/lint/comment-lint-hook.mjs"
|
||||
],
|
||||
"timeout": 60
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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.*
|
||||
|
||||
+2
-13
@@ -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
|
||||
|
||||
@@ -8,11 +8,6 @@ updates:
|
||||
- package-ecosystem: "gradle" # See documentation for possible values
|
||||
directories:
|
||||
- "/" # Location of package manifests
|
||||
- "/app/common"
|
||||
- "/app/core"
|
||||
- "/app/proprietary"
|
||||
- "/app/saas"
|
||||
- "/buildSrc"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
cooldown:
|
||||
|
||||
@@ -67,6 +67,7 @@ labels:
|
||||
- 'frontend/**'
|
||||
- 'frontend/.*'
|
||||
- 'frontend/**/.*'
|
||||
- '.taskfiles/frontend.yml'
|
||||
|
||||
- label: 'Tauri'
|
||||
files:
|
||||
|
||||
@@ -20,6 +20,7 @@ Closes #(issue_number)
|
||||
- [ ] I have read the [Stirling-PDF Developer Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md) (if applicable)
|
||||
- [ ] I have read the [How to add new languages to Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md) (if applicable)
|
||||
- [ ] I have performed a self-review of my own code
|
||||
- [ ] Every comment I added says something the code does not ([guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/CODE_COMMENTS.md))
|
||||
- [ ] My changes generate no new warnings
|
||||
|
||||
### Documentation
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -182,7 +182,7 @@ jobs:
|
||||
fetch-depth: 0 # Fetch full history for commit hash detection
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
|
||||
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
|
||||
|
||||
- name: Get version number
|
||||
id: versionNumber
|
||||
@@ -220,6 +220,42 @@ jobs:
|
||||
echo "app_short=${APP_HASH:0:8}" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
# The Stirling account previews connect to. Derived from the ref rather than stored as a URL
|
||||
# so it cannot drift from the key: a mismatched pair is accepted by the browser and rejected
|
||||
# by Supabase, surfacing much later as "session expired" on Usage rather than at sign-in.
|
||||
# Secret only to match Saas-Dev-Deploy.yml, which owns the same value; a project ref is not
|
||||
# itself sensitive, which is why SAAS_API_BASE_URL next to it is a plain variable.
|
||||
- name: Resolve Stirling account config
|
||||
id: saas
|
||||
env:
|
||||
PROJECT_REF: ${{ secrets.SAAS_DB_PROJECT_REF }}
|
||||
API_BASE_OVERRIDE: ${{ vars.SAAS_API_BASE_URL }}
|
||||
run: |
|
||||
# Set, this is the one value both halves use: the browser's portal reads and the backend's
|
||||
# register/entitlement calls have to land on the same SaaS, and nothing checks that they
|
||||
# do. Unset, only the backend gets a base, from its own compiled-in default.
|
||||
API_BASE="${API_BASE_OVERRIDE:-https://stirling.com/app}"
|
||||
echo "backend_base=${API_BASE}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
if [ -z "${PROJECT_REF}" ]; then
|
||||
echo "Not configured for this environment: the preview will build without a Stirling"
|
||||
echo "account, and the connect dialog will say so. To wire one up, set on the"
|
||||
echo "pr-preview environment the secrets SAAS_DB_PROJECT_REF and"
|
||||
echo "SAAS_SUPABASE_PUBLISHABLE_KEY, both from the same Supabase project."
|
||||
echo "supabase_url=" >> "$GITHUB_OUTPUT"
|
||||
echo "frontend_base=" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
# Only whether, not which: the ref is a secret here, so Actions masks it out of any
|
||||
# line it appears in, derived URL included.
|
||||
echo "Stirling account configured, at ${API_BASE}."
|
||||
echo "supabase_url=https://${PROJECT_REF}.supabase.co" >> "$GITHUB_OUTPUT"
|
||||
# Deliberately the override and not API_BASE: the backend's default is a subpath URL
|
||||
# nobody has confirmed answers /api/v1, and prod CORS does not list preview hostnames,
|
||||
# so portal reads stay off until someone sets a base they have checked. Empty leaves the
|
||||
# committed .env default alone, which is the clean "not configured" state.
|
||||
echo "frontend_base=${API_BASE_OVERRIDE}" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Check if image exists
|
||||
id: check-image
|
||||
run: |
|
||||
@@ -246,6 +282,9 @@ jobs:
|
||||
build-args: |
|
||||
VERSION_TAG=v2-alpha
|
||||
BUILD_PORTAL=${{ env.BUILD_PORTAL }}
|
||||
VITE_SUPABASE_URL=${{ steps.saas.outputs.supabase_url }}
|
||||
VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY=${{ secrets.SAAS_SUPABASE_PUBLISHABLE_KEY }}
|
||||
VITE_SAAS_API_URL=${{ steps.saas.outputs.frontend_base }}
|
||||
platforms: linux/amd64
|
||||
|
||||
- name: Set up SSH
|
||||
@@ -279,6 +318,13 @@ jobs:
|
||||
environment:
|
||||
DISABLE_ADDITIONAL_FEATURES: "false"
|
||||
STIRLING_BILLING_ACCOUNT_LINK_ENABLED: "true"
|
||||
STIRLING_BILLING_ACCOUNT_LINK_SAAS_BASE_URL: "${{ steps.saas.outputs.backend_base }}"
|
||||
# Off so preview traffic never accrues against a real wallet or trips its cap. The
|
||||
# 402 gate is separate and stays on, so gating is still testable here.
|
||||
STIRLING_BILLING_ACCOUNT_LINK_METERING_ENABLED: "false"
|
||||
# Stated rather than inferred from the request: the callback has to come back to the
|
||||
# preview hostname, not to the container's own :8080 behind this proxy.
|
||||
SYSTEM_FRONTENDURL: "https://${V2_PORT}.ssl.stirlingpdf.cloud"
|
||||
SECURITY_ENABLELOGIN: "true"
|
||||
SECURITY_INITIALLOGIN_USERNAME: "${TEST_LOGIN_USERNAME}"
|
||||
SECURITY_INITIALLOGIN_PASSWORD: "${TEST_LOGIN_PASSWORD}"
|
||||
@@ -353,7 +399,7 @@ jobs:
|
||||
|
||||
- name: Install Task for Storybook
|
||||
if: steps.sb-changes.outputs.storybook == 'true'
|
||||
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
|
||||
uses: go-task/setup-task@a00fbb05ce67b35648be3c78cbc9fd85354c757e # v2.2.0
|
||||
|
||||
- name: Build and deploy Storybook
|
||||
id: storybook
|
||||
@@ -475,7 +521,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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -206,7 +206,7 @@ jobs:
|
||||
distribution: "temurin"
|
||||
|
||||
- name: Install Task
|
||||
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
|
||||
uses: go-task/setup-task@a00fbb05ce67b35648be3c78cbc9fd85354c757e # v2.2.0
|
||||
- name: Run Gradle Command
|
||||
run: |
|
||||
if [ "${{ needs.check-comment.outputs.disable_security }}" == "true" ]; then
|
||||
@@ -222,7 +222,7 @@ jobs:
|
||||
STIRLING_PDF_DESKTOP_UI: false
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
|
||||
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -0,0 +1,246 @@
|
||||
name: Auto SaaS Dev Deployment
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- saas-prod
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
FRONTEND_PORT: "901"
|
||||
BACKEND_PORT: "902"
|
||||
DEPLOY_DIR: /stirling/SAAS-DEV
|
||||
|
||||
jobs:
|
||||
deploy-saas-dev:
|
||||
runs-on: ubuntu-latest
|
||||
environment: saas-dev
|
||||
concurrency:
|
||||
group: saas-dev-deploy
|
||||
cancel-in-progress: true
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Check SaaS configuration
|
||||
id: config
|
||||
env:
|
||||
PROJECT_REF: ${{ secrets.SAAS_DB_PROJECT_REF }}
|
||||
run: |
|
||||
echo "supabase_url=https://${PROJECT_REF}.supabase.co" >> "$GITHUB_OUTPUT"
|
||||
echo "meter_endpoint=https://${PROJECT_REF}.supabase.co/functions/v1/meter-payg-units" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ github.token }}
|
||||
|
||||
- name: Convert repository owner to lowercase
|
||||
id: repoowner
|
||||
run: echo "lowercase=$(echo ${{ github.repository_owner }} | awk '{print tolower($0)}')" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Get commit hash
|
||||
id: commit-hash
|
||||
run: echo "app_short=$(git rev-parse --short=8 HEAD)" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Build and push backend image
|
||||
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
|
||||
with:
|
||||
context: .
|
||||
file: ./docker/backend/Dockerfile
|
||||
push: true
|
||||
cache-from: type=gha,scope=stirling-saas-backend
|
||||
cache-to: type=gha,mode=max,scope=stirling-saas-backend
|
||||
tags: |
|
||||
ghcr.io/${{ steps.repoowner.outputs.lowercase }}/stirling-pdf-test:saas-backend-${{ steps.commit-hash.outputs.app_short }}
|
||||
ghcr.io/${{ steps.repoowner.outputs.lowercase }}/stirling-pdf-test:saas-backend-latest
|
||||
build-args: |
|
||||
VERSION_TAG=v2-alpha
|
||||
STIRLING_FLAVOR=saas
|
||||
platforms: linux/amd64
|
||||
|
||||
- name: Build and push frontend image
|
||||
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
|
||||
with:
|
||||
context: .
|
||||
file: ./docker/frontend/Dockerfile
|
||||
push: true
|
||||
cache-from: type=gha,scope=stirling-saas-frontend
|
||||
cache-to: type=gha,mode=max,scope=stirling-saas-frontend
|
||||
tags: |
|
||||
ghcr.io/${{ steps.repoowner.outputs.lowercase }}/stirling-pdf-test:saas-frontend-${{ steps.commit-hash.outputs.app_short }}
|
||||
ghcr.io/${{ steps.repoowner.outputs.lowercase }}/stirling-pdf-test:saas-frontend-latest
|
||||
build-args: |
|
||||
VERSION_TAG=v2-alpha
|
||||
STIRLING_FLAVOR=saas
|
||||
VITE_BUILD_MODE=development
|
||||
VITE_SUPABASE_URL=${{ steps.config.outputs.supabase_url }}
|
||||
VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY=${{ secrets.SAAS_SUPABASE_PUBLISHABLE_KEY }}
|
||||
platforms: linux/amd64
|
||||
|
||||
- name: Build and push AI engine image
|
||||
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
|
||||
with:
|
||||
context: .
|
||||
file: ./engine/Dockerfile
|
||||
push: true
|
||||
cache-from: type=gha,scope=stirling-saas-engine
|
||||
cache-to: type=gha,mode=max,scope=stirling-saas-engine
|
||||
tags: |
|
||||
ghcr.io/${{ steps.repoowner.outputs.lowercase }}/stirling-pdf-test:saas-engine-${{ steps.commit-hash.outputs.app_short }}
|
||||
ghcr.io/${{ steps.repoowner.outputs.lowercase }}/stirling-pdf-test:saas-engine-latest
|
||||
platforms: linux/amd64
|
||||
|
||||
- name: Set up SSH
|
||||
env:
|
||||
SSH_KEY: ${{ secrets.NEW_VPS_SSH_KEY }}
|
||||
run: |
|
||||
mkdir -p ~/.ssh/
|
||||
echo "$SSH_KEY" > ../private.key
|
||||
sudo chmod 600 ../private.key
|
||||
|
||||
- name: Deploy to VPS
|
||||
env:
|
||||
IMAGE_BASE: ghcr.io/${{ steps.repoowner.outputs.lowercase }}/stirling-pdf-test
|
||||
IMAGE_TAG: ${{ steps.commit-hash.outputs.app_short }}
|
||||
GHCR_USER: ${{ github.actor }}
|
||||
GHCR_TOKEN: ${{ github.token }}
|
||||
VPS_USERNAME: ${{ secrets.NEW_VPS_USERNAME }}
|
||||
VPS_HOST: ${{ secrets.NEW_VPS_HOST }}
|
||||
SAAS_DB_URL: ${{ secrets.SAAS_DB_URL }}
|
||||
SAAS_DB_USERNAME: ${{ secrets.SAAS_DB_USERNAME || 'postgres' }}
|
||||
SAAS_DB_PASSWORD: ${{ secrets.SAAS_DB_PASSWORD }}
|
||||
SAAS_DB_PROJECT_REF: ${{ secrets.SAAS_DB_PROJECT_REF }}
|
||||
SUPABASE_EDGE_FUNCTION_SECRET: ${{ secrets.SUPABASE_EDGE_FUNCTION_SECRET }}
|
||||
PAYG_METER_ENDPOINT: ${{ steps.config.outputs.meter_endpoint }}
|
||||
STIRLING_KEYGEN_ENABLED: ${{ secrets.KEYGEN_ACCOUNT_ID != '' && secrets.KEYGEN_API_TOKEN != '' && secrets.KEYGEN_POLICY_ID != '' }}
|
||||
KEYGEN_ACCOUNT_ID: ${{ secrets.KEYGEN_ACCOUNT_ID }}
|
||||
KEYGEN_API_TOKEN: ${{ secrets.KEYGEN_API_TOKEN }}
|
||||
KEYGEN_POLICY_ID: ${{ secrets.KEYGEN_POLICY_ID }}
|
||||
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
VOYAGE_API_KEY: ${{ secrets.VOYAGE_API_KEY }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
BASE_URL="http://${VPS_HOST}:${FRONTEND_PORT}"
|
||||
|
||||
yaml() {
|
||||
printf "'%s'" "$(printf '%s' "$1" | sed -e "s/'/''/g" -e 's/\$/$$/g')"
|
||||
}
|
||||
|
||||
ENGINE_SECRET="$(openssl rand -hex 32)"
|
||||
AI_BACKEND_VARS="
|
||||
SYSTEM_AIENGINE_ENABLED: \"true\"
|
||||
SYSTEM_AIENGINE_URL: \"http://saas-engine:5001\"
|
||||
APP_AI_SERVICEBASEURL: \"http://saas-engine:5001\"
|
||||
STIRLING_ENGINE_SHARED_SECRET: $(yaml "$ENGINE_SECRET")"
|
||||
AI_SERVICE="
|
||||
|
||||
saas-engine:
|
||||
container_name: stirling-saas-dev-engine
|
||||
image: ${IMAGE_BASE}:saas-engine-${IMAGE_TAG}
|
||||
environment:
|
||||
ANTHROPIC_API_KEY: $(yaml "$ANTHROPIC_API_KEY")
|
||||
VOYAGE_API_KEY: $(yaml "$VOYAGE_API_KEY")
|
||||
STIRLING_ENGINE_SHARED_SECRET: $(yaml "$ENGINE_SECRET")
|
||||
restart: on-failure:5"
|
||||
|
||||
cat > docker-compose.yml << EOF
|
||||
version: '3.3'
|
||||
services:
|
||||
saas-backend:
|
||||
container_name: stirling-saas-dev-backend
|
||||
image: ${IMAGE_BASE}:saas-backend-${IMAGE_TAG}
|
||||
ports:
|
||||
- "${BACKEND_PORT}:8080"
|
||||
volumes:
|
||||
- ${DEPLOY_DIR}/config:/configs:rw
|
||||
- ${DEPLOY_DIR}/logs:/logs:rw
|
||||
- ${DEPLOY_DIR}/storage:/storage:rw
|
||||
environment:
|
||||
SPRING_PROFILES_ACTIVE: "saas"
|
||||
DISABLE_ADDITIONAL_FEATURES: "false"
|
||||
SAAS_DB_URL: $(yaml "$SAAS_DB_URL")
|
||||
SAAS_DB_USERNAME: $(yaml "$SAAS_DB_USERNAME")
|
||||
SAAS_DB_PASSWORD: $(yaml "$SAAS_DB_PASSWORD")
|
||||
SAAS_DB_PROJECT_REF: $(yaml "$SAAS_DB_PROJECT_REF")
|
||||
SUPABASE_EDGE_FUNCTION_SECRET: $(yaml "$SUPABASE_EDGE_FUNCTION_SECRET")
|
||||
PAYG_METER_ENDPOINT: $(yaml "$PAYG_METER_ENDPOINT")
|
||||
STIRLING_KEYGEN_ENABLED: $(yaml "$STIRLING_KEYGEN_ENABLED")
|
||||
KEYGEN_ACCOUNT_ID: $(yaml "$KEYGEN_ACCOUNT_ID")
|
||||
KEYGEN_API_TOKEN: $(yaml "$KEYGEN_API_TOKEN")
|
||||
KEYGEN_POLICY_ID: $(yaml "$KEYGEN_POLICY_ID")
|
||||
SYSTEM_DEFAULTLOCALE: en-US
|
||||
SYSTEM_MAXFILESIZE: "100"
|
||||
METRICS_ENABLED: "true"
|
||||
SYSTEM_GOOGLEVISIBILITY: "false"
|
||||
SWAGGER_SERVER_URL: "${BASE_URL}"
|
||||
baseUrl: "${BASE_URL}"${AI_BACKEND_VARS}
|
||||
restart: on-failure:5
|
||||
|
||||
saas-frontend:
|
||||
container_name: stirling-saas-dev-frontend
|
||||
image: ${IMAGE_BASE}:saas-frontend-${IMAGE_TAG}
|
||||
ports:
|
||||
- "${FRONTEND_PORT}:80"
|
||||
environment:
|
||||
VITE_API_BASE_URL: "http://saas-backend:8080"
|
||||
depends_on:
|
||||
- saas-backend
|
||||
restart: on-failure:5${AI_SERVICE}
|
||||
EOF
|
||||
|
||||
SSH_OPTS=(-i ../private.key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null)
|
||||
|
||||
scp "${SSH_OPTS[@]}" docker-compose.yml "${VPS_USERNAME}@${VPS_HOST}:/tmp/saas-dev-docker-compose.yml"
|
||||
|
||||
ssh "${SSH_OPTS[@]}" -T "${VPS_USERNAME}@${VPS_HOST}" << ENDSSH
|
||||
set -e
|
||||
mkdir -p ${DEPLOY_DIR}/{config,logs,storage}
|
||||
mv /tmp/saas-dev-docker-compose.yml ${DEPLOY_DIR}/docker-compose.yml
|
||||
chmod 600 ${DEPLOY_DIR}/docker-compose.yml
|
||||
cd ${DEPLOY_DIR}
|
||||
printf '%s' "${GHCR_TOKEN}" | docker login ghcr.io -u "${GHCR_USER}" --password-stdin
|
||||
docker-compose down --remove-orphans 2>/dev/null || true
|
||||
docker-compose pull
|
||||
docker-compose up -d
|
||||
docker logout ghcr.io >/dev/null 2>&1 || true
|
||||
docker image prune -af --filter "until=336h" --filter "label!=keep=true" || true
|
||||
ENDSSH
|
||||
|
||||
- name: Wait for the backend to answer
|
||||
env:
|
||||
VPS_HOST: ${{ secrets.NEW_VPS_HOST }}
|
||||
run: |
|
||||
URL="http://${VPS_HOST}:${BACKEND_PORT}/api/v1/info/status"
|
||||
for i in $(seq 1 60); do
|
||||
code=$(curl -s -o /dev/null -w '%{http_code}' --max-time 5 "$URL" || true)
|
||||
if [ "$code" = "200" ]; then echo "Healthy after $((i * 10))s"; exit 0; fi
|
||||
sleep 10
|
||||
done
|
||||
echo "::error::SaaS dev backend did not become healthy within 10 minutes"
|
||||
exit 1
|
||||
|
||||
- name: Cleanup temporary files
|
||||
if: always()
|
||||
run: rm -f ../private.key docker-compose.yml
|
||||
continue-on-error: true
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -34,10 +34,9 @@ jobs:
|
||||
cache-dependency-glob: |
|
||||
engine/pyproject.toml
|
||||
engine/uv.lock
|
||||
cache-suffix: ai-engine
|
||||
|
||||
- name: Install Task
|
||||
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
|
||||
uses: go-task/setup-task@a00fbb05ce67b35648be3c78cbc9fd85354c757e # v2.2.0
|
||||
|
||||
- name: Quality-check engine
|
||||
id: engine-check
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -52,7 +52,7 @@ jobs:
|
||||
distribution: "temurin"
|
||||
|
||||
- name: Install Task
|
||||
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
|
||||
uses: go-task/setup-task@a00fbb05ce67b35648be3c78cbc9fd85354c757e # v2.2.0
|
||||
- name: Check Java formatting (Spotless)
|
||||
# Runs once per matrix combination - pick the cheapest leg
|
||||
# (core - no proprietary, no saas) so we don't wait for the
|
||||
|
||||
@@ -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
|
||||
@@ -95,7 +95,7 @@ jobs:
|
||||
cache: "npm"
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
- name: Install Task
|
||||
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
|
||||
uses: go-task/setup-task@a00fbb05ce67b35648be3c78cbc9fd85354c757e # v2.2.0
|
||||
- name: Install Playwright (chromium only)
|
||||
run: task e2e:install -- chromium
|
||||
- name: Build frontend (needed for playwright's vite preview webServer)
|
||||
@@ -351,7 +351,7 @@ 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
|
||||
|
||||
@@ -49,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
|
||||
@@ -298,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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -42,7 +42,6 @@ jobs:
|
||||
cache-dependency-glob: |
|
||||
engine/pyproject.toml
|
||||
engine/uv.lock
|
||||
cache-suffix: generated-models
|
||||
|
||||
- name: Restore cache Gradle User Home
|
||||
if: inputs.use_shared_cache
|
||||
@@ -76,7 +75,7 @@ jobs:
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
|
||||
- name: Install Task
|
||||
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
|
||||
uses: go-task/setup-task@a00fbb05ce67b35648be3c78cbc9fd85354c757e # v2.2.0
|
||||
|
||||
- name: Verify generated models are up to date
|
||||
id: models-check
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -38,7 +38,7 @@ jobs:
|
||||
distribution: "temurin"
|
||||
|
||||
- name: Install Task
|
||||
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
|
||||
uses: go-task/setup-task@a00fbb05ce67b35648be3c78cbc9fd85354c757e # v2.2.0
|
||||
- name: Check licenses for compatibility
|
||||
run: task backend:licenses:check
|
||||
env:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -39,7 +39,7 @@ jobs:
|
||||
distribution: "temurin"
|
||||
|
||||
- name: Install Task
|
||||
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
|
||||
uses: go-task/setup-task@a00fbb05ce67b35648be3c78cbc9fd85354c757e # v2.2.0
|
||||
- name: Generate OpenAPI documentation
|
||||
run: task backend:swagger
|
||||
env:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -57,7 +57,7 @@ jobs:
|
||||
# runtime token isn't exposed) since the docker driver can't use it.
|
||||
- name: Set up Docker Buildx
|
||||
if: inputs.docker-base-changed != 'true'
|
||||
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
|
||||
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
|
||||
|
||||
# Expose ACTIONS_RUNTIME_TOKEN / ACTIONS_RESULTS_URL for docker buildx type=gha cache backend.
|
||||
- name: Expose GitHub runtime for Buildx cache
|
||||
|
||||
@@ -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
|
||||
@@ -45,7 +45,7 @@ jobs:
|
||||
cache: "npm"
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
- name: Install Task
|
||||
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
|
||||
uses: go-task/setup-task@a00fbb05ce67b35648be3c78cbc9fd85354c757e # v2.2.0
|
||||
- name: Install Playwright (chromium only)
|
||||
run: task e2e:install -- chromium
|
||||
- name: Build frontend (production bundle for vite preview)
|
||||
|
||||
@@ -32,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
|
||||
@@ -44,7 +44,7 @@ jobs:
|
||||
cache: "npm"
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
- name: Install Task
|
||||
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
|
||||
uses: go-task/setup-task@a00fbb05ce67b35648be3c78cbc9fd85354c757e # v2.2.0
|
||||
- name: Build frontend (production bundle for vite preview)
|
||||
env:
|
||||
VITE_BUILD_FOR_PREVIEW: "1"
|
||||
|
||||
@@ -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
|
||||
@@ -36,7 +36,7 @@ jobs:
|
||||
cache: "npm"
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
- name: Install Task
|
||||
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
|
||||
uses: go-task/setup-task@a00fbb05ce67b35648be3c78cbc9fd85354c757e # v2.2.0
|
||||
- name: a11y gate (changed stories)
|
||||
run: task frontend:storybook:a11y:changed -- origin/${{ github.base_ref || 'main' }}
|
||||
- name: Upload scan reports
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -97,7 +97,7 @@ jobs:
|
||||
run: npm ci --ignore-scripts --audit=false --fund=false
|
||||
|
||||
- name: Install Task
|
||||
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
|
||||
uses: go-task/setup-task@a00fbb05ce67b35648be3c78cbc9fd85354c757e # v2.2.0
|
||||
|
||||
- name: Generate frontend license report (Push only)
|
||||
if: github.event_name == 'push'
|
||||
@@ -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
|
||||
|
||||
@@ -367,7 +367,7 @@ jobs:
|
||||
distribution: "temurin"
|
||||
|
||||
- name: Install Task
|
||||
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
|
||||
uses: go-task/setup-task@a00fbb05ce67b35648be3c78cbc9fd85354c757e # v2.2.0
|
||||
|
||||
- name: Check licenses and generate report
|
||||
id: license-check
|
||||
|
||||
@@ -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
|
||||
@@ -27,7 +27,7 @@ jobs:
|
||||
cache: "npm"
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
- name: Install Task
|
||||
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
|
||||
uses: go-task/setup-task@a00fbb05ce67b35648be3c78cbc9fd85354c757e # v2.2.0
|
||||
- name: Quality-check frontend
|
||||
id: frontend-check
|
||||
run: task frontend:check:all
|
||||
|
||||
@@ -18,7 +18,7 @@ jobs:
|
||||
timeout-minutes: 15
|
||||
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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -69,7 +69,7 @@ jobs:
|
||||
distribution: "temurin"
|
||||
|
||||
- name: Install Task
|
||||
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
|
||||
uses: go-task/setup-task@a00fbb05ce67b35648be3c78cbc9fd85354c757e # v2.2.0
|
||||
- name: Get version number
|
||||
id: versionNumber
|
||||
run: |
|
||||
@@ -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
|
||||
|
||||
@@ -169,7 +169,7 @@ jobs:
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
|
||||
- name: Install Task
|
||||
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
|
||||
uses: go-task/setup-task@a00fbb05ce67b35648be3c78cbc9fd85354c757e # v2.2.0
|
||||
|
||||
- name: Build JAR
|
||||
run: ./gradlew build ${{ matrix.variant.build_frontend && '-PbuildWithFrontend=true' || '' }} -x spotlessApply -x spotlessCheck -x test -x sonarqube
|
||||
@@ -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: >
|
||||
@@ -268,7 +268,7 @@ jobs:
|
||||
distribution: ${{ matrix.platform == 'windows-11-arm' && 'microsoft' || 'temurin' }}
|
||||
|
||||
- name: Install Task
|
||||
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
|
||||
uses: go-task/setup-task@a00fbb05ce67b35648be3c78cbc9fd85354c757e # v2.2.0
|
||||
|
||||
# Build the universal JRE before desktop:prepare so the jlink:runtime
|
||||
# task short-circuits on its `test -d runtime/jre` status check.
|
||||
@@ -715,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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -38,7 +38,7 @@ jobs:
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
|
||||
- name: Install Task
|
||||
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
|
||||
uses: go-task/setup-task@a00fbb05ce67b35648be3c78cbc9fd85354c757e # v2.2.0
|
||||
- name: Install all Playwright browsers
|
||||
run: task e2e:install
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -89,7 +89,7 @@ jobs:
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
|
||||
- name: Install Task
|
||||
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
|
||||
uses: go-task/setup-task@a00fbb05ce67b35648be3c78cbc9fd85354c757e # v2.2.0
|
||||
|
||||
- name: a11y gate (every story, ${{ matrix.theme }})
|
||||
run: task frontend:storybook:a11y:${{ matrix.theme }}
|
||||
@@ -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
|
||||
|
||||
@@ -162,7 +162,7 @@ jobs:
|
||||
engine/uv.lock
|
||||
|
||||
- name: Install Task
|
||||
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
|
||||
uses: go-task/setup-task@a00fbb05ce67b35648be3c78cbc9fd85354c757e # v2.2.0
|
||||
|
||||
- name: Start the fat image with login and storage enabled
|
||||
run: docker compose -f docker/embedded/compose/test_cicd.yml up -d --build
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -31,10 +31,14 @@ jobs:
|
||||
cache-dependency-glob: |
|
||||
engine/pyproject.toml
|
||||
engine/uv.lock
|
||||
cache-suffix: pre-commit
|
||||
|
||||
- name: Install Task
|
||||
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
|
||||
uses: go-task/setup-task@a00fbb05ce67b35648be3c78cbc9fd85354c757e # v2.2.0
|
||||
|
||||
- name: Run pre-commit checks
|
||||
run: task pre-commit
|
||||
|
||||
# The fixture corpus checks the comment rules themselves, so it runs here
|
||||
# rather than on every local commit.
|
||||
- name: Check the comment-lint fixture corpus
|
||||
run: task pre-commit:comment-lint:selftest
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -69,7 +69,7 @@ jobs:
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
id: buildx
|
||||
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
|
||||
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@96fe6ef7f33517b61c61be40b68a1882f3264fb8 # v4.2.0
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -74,10 +85,10 @@ jobs:
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
id: buildx
|
||||
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
|
||||
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
|
||||
|
||||
- name: Install Task
|
||||
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
|
||||
uses: go-task/setup-task@a00fbb05ce67b35648be3c78cbc9fd85354c757e # v2.2.0
|
||||
- name: Get version number
|
||||
id: versionNumber
|
||||
run: echo "versionNumber=$(./gradlew printVersion --quiet | tail -1)" >> $GITHUB_OUTPUT
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4.37.7
|
||||
uses: github/codeql-action/upload-sarif@db488ddef3bf6cb639b32c2e9a7c0a7ea8271d28 # v4.37.8
|
||||
with:
|
||||
sarif_file: results.sarif
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -63,7 +63,7 @@ jobs:
|
||||
SWAGGERHUB_USER: "Frooodle"
|
||||
|
||||
- name: Install Task
|
||||
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
|
||||
uses: go-task/setup-task@a00fbb05ce67b35648be3c78cbc9fd85354c757e # v2.2.0
|
||||
- name: Get version number
|
||||
id: versionNumber
|
||||
run: echo "versionNumber=$(./gradlew printVersion --quiet | tail -1)" >> $GITHUB_OUTPUT
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -59,14 +59,13 @@ jobs:
|
||||
cache-dependency-glob: |
|
||||
engine/pyproject.toml
|
||||
engine/uv.lock
|
||||
cache-suffix: sync-files
|
||||
|
||||
- name: Install Python dependencies
|
||||
run: |
|
||||
uv sync --project engine --locked --group tools
|
||||
|
||||
- name: Install Task
|
||||
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
|
||||
uses: go-task/setup-task@a00fbb05ce67b35648be3c78cbc9fd85354c757e # v2.2.0
|
||||
|
||||
- name: Sync translation TOML files
|
||||
run: |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -212,7 +212,7 @@ jobs:
|
||||
distribution: ${{ matrix.platform == 'windows-11-arm' && 'microsoft' || 'temurin' }}
|
||||
|
||||
- name: Setup Task
|
||||
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
|
||||
uses: go-task/setup-task@a00fbb05ce67b35648be3c78cbc9fd85354c757e # v2.2.0
|
||||
|
||||
- name: Build universal macOS JRE
|
||||
if: matrix.platform == 'macos-15'
|
||||
@@ -703,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
|
||||
|
||||
@@ -795,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
|
||||
|
||||
|
||||
@@ -34,7 +34,7 @@ jobs:
|
||||
timeout-minutes: 20
|
||||
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
|
||||
|
||||
@@ -77,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
|
||||
|
||||
@@ -127,7 +127,7 @@ jobs:
|
||||
distribution: "temurin"
|
||||
|
||||
- name: Install Task
|
||||
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
|
||||
uses: go-task/setup-task@a00fbb05ce67b35648be3c78cbc9fd85354c757e # v2.2.0
|
||||
- name: Build application
|
||||
run: task backend:build
|
||||
env:
|
||||
@@ -142,7 +142,7 @@ jobs:
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
id: buildx
|
||||
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
|
||||
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
|
||||
|
||||
- name: Set base image and platform for this build
|
||||
id: build-params
|
||||
@@ -217,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
|
||||
|
||||
@@ -229,7 +229,7 @@ jobs:
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
id: buildx
|
||||
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
|
||||
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
|
||||
|
||||
- name: Build docker/unoserver/Dockerfile
|
||||
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
|
||||
|
||||
@@ -19,7 +19,7 @@ jobs:
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- name: Harden runner
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
|
||||
+10
-2
@@ -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
|
||||
@@ -296,8 +298,13 @@ docs/type3/signatures/
|
||||
|
||||
**/application-dev-local.properties
|
||||
|
||||
# Claude
|
||||
.claude/
|
||||
# Claude. Contents are ignored so personal config stays local, with the two
|
||||
# shared pieces re-included: settings.json (the comment-lint hook) and skills/.
|
||||
# The directory itself cannot be ignored or git will not look inside it.
|
||||
.claude/*
|
||||
!.claude/settings.json
|
||||
!.claude/skills/
|
||||
.claude/settings.local.json
|
||||
|
||||
# Playwright MCP screenshots / traces
|
||||
.playwright-mcp/
|
||||
@@ -305,3 +312,4 @@ docs/type3/signatures/
|
||||
|
||||
# Local screenshot artifacts from *-screenshots.spec.ts
|
||||
frontend/editor/screenshots/
|
||||
frontend/editor/src-tauri/libs/.variant
|
||||
|
||||
+62
-3
@@ -40,12 +40,15 @@ tasks:
|
||||
AIENGINE_ENABLED: '{{.AIENGINE_ENABLED | default "false"}}'
|
||||
AIENGINE_TIMEOUTSECONDS: '{{.AIENGINE_TIMEOUTSECONDS | default "120"}}'
|
||||
SECURITY_ENABLELOGIN: '{{.SECURITY_ENABLELOGIN | default ""}}'
|
||||
# Set by dev:linked. Inline rather than in `env:` so an empty value emits nothing
|
||||
# and cannot blank the committed default.
|
||||
ACCOUNT_LINK_SAAS_BASE_URL: '{{.ACCOUNT_LINK_SAAS_BASE_URL | default ""}}'
|
||||
env:
|
||||
SERVER_PORT: '{{.PORT}}'
|
||||
cmds:
|
||||
- cmd: '{{if .AIENGINE_URL}}AIENGINE_URL={{.AIENGINE_URL}} AIENGINE_ENABLED={{.AIENGINE_ENABLED}} AIENGINE_TIMEOUTSECONDS={{.AIENGINE_TIMEOUTSECONDS}} {{end}}{{if .SECURITY_ENABLELOGIN}}SECURITY_ENABLELOGIN={{.SECURITY_ENABLELOGIN}} {{end}}cmd /c ".\gradlew.bat :stirling-pdf:bootRun"'
|
||||
- cmd: '{{if .AIENGINE_URL}}AIENGINE_URL={{.AIENGINE_URL}} AIENGINE_ENABLED={{.AIENGINE_ENABLED}} AIENGINE_TIMEOUTSECONDS={{.AIENGINE_TIMEOUTSECONDS}} {{end}}{{if .SECURITY_ENABLELOGIN}}SECURITY_ENABLELOGIN={{.SECURITY_ENABLELOGIN}} {{end}}{{if .ACCOUNT_LINK_SAAS_BASE_URL}}STIRLING_BILLING_ACCOUNT_LINK_ENABLED=true STIRLING_BILLING_ACCOUNT_LINK_SAAS_BASE_URL={{.ACCOUNT_LINK_SAAS_BASE_URL}} {{end}}cmd /c ".\gradlew.bat :stirling-pdf:bootRun"'
|
||||
platforms: [windows]
|
||||
- cmd: '{{if .AIENGINE_URL}}AIENGINE_URL={{.AIENGINE_URL}} AIENGINE_ENABLED={{.AIENGINE_ENABLED}} AIENGINE_TIMEOUTSECONDS={{.AIENGINE_TIMEOUTSECONDS}} {{end}}{{if .SECURITY_ENABLELOGIN}}SECURITY_ENABLELOGIN={{.SECURITY_ENABLELOGIN}} {{end}}./gradlew :stirling-pdf:bootRun'
|
||||
- cmd: '{{if .AIENGINE_URL}}AIENGINE_URL={{.AIENGINE_URL}} AIENGINE_ENABLED={{.AIENGINE_ENABLED}} AIENGINE_TIMEOUTSECONDS={{.AIENGINE_TIMEOUTSECONDS}} {{end}}{{if .SECURITY_ENABLELOGIN}}SECURITY_ENABLELOGIN={{.SECURITY_ENABLELOGIN}} {{end}}{{if .ACCOUNT_LINK_SAAS_BASE_URL}}STIRLING_BILLING_ACCOUNT_LINK_ENABLED=true STIRLING_BILLING_ACCOUNT_LINK_SAAS_BASE_URL={{.ACCOUNT_LINK_SAAS_BASE_URL}} {{end}}./gradlew :stirling-pdf:bootRun'
|
||||
platforms: [linux, darwin]
|
||||
|
||||
dev:bundled:
|
||||
@@ -84,6 +87,8 @@ tasks:
|
||||
AIENGINE_URL: '{{.AIENGINE_URL}}'
|
||||
AIENGINE_ENABLED: '{{.AIENGINE_ENABLED}}'
|
||||
AIENGINE_TIMEOUTSECONDS: '{{.AIENGINE_TIMEOUTSECONDS}}'
|
||||
APP_BASE_URL: '{{.APP_BASE_URL}}'
|
||||
BASE_PATH: '{{.BASE_PATH}}'
|
||||
|
||||
staging:saas:
|
||||
desc: "Start SaaS backend against the shared v3 staging project"
|
||||
@@ -95,10 +100,47 @@ tasks:
|
||||
AIENGINE_URL: '{{.AIENGINE_URL}}'
|
||||
AIENGINE_ENABLED: '{{.AIENGINE_ENABLED}}'
|
||||
AIENGINE_TIMEOUTSECONDS: '{{.AIENGINE_TIMEOUTSECONDS}}'
|
||||
APP_BASE_URL: '{{.APP_BASE_URL}}'
|
||||
BASE_PATH: '{{.BASE_PATH}}'
|
||||
|
||||
dev:linked:
|
||||
desc: "Self-hosted backend linked to a locally running SaaS backend (see task linked:*)"
|
||||
ignore_error: true
|
||||
vars:
|
||||
PORT: '{{.PORT | default "8080"}}'
|
||||
SAAS_BASE_URL: '{{.SAAS_BASE_URL | default "http://localhost:8081"}}'
|
||||
cmds:
|
||||
- 'echo ">> self-hosted :{{.PORT}} linking to SaaS at {{.SAAS_BASE_URL}}"'
|
||||
# The two backends run different STIRLING_FLAVOURs, which are different Gradle
|
||||
# project graphs sharing one build/ tree. Waiting avoids overlapping builds; it
|
||||
# does not make the sharing safe, so avoid rebuilding one while the other runs.
|
||||
- cmd: |
|
||||
n=0
|
||||
while [ "$n" -lt 150 ]; do
|
||||
if curl -s -m 2 "{{.SAAS_BASE_URL}}" >/dev/null 2>&1; then
|
||||
echo ">> SaaS backend is up, starting self-hosted"
|
||||
break
|
||||
fi
|
||||
n=$((n + 1))
|
||||
{{if eq OS "windows"}}powershell -NoProfile -Command "Start-Sleep -Seconds 2"{{else}}sleep 2{{end}}
|
||||
done
|
||||
if [ "$n" -ge 150 ]; then
|
||||
echo ">> SaaS backend never answered; starting anyway"
|
||||
fi
|
||||
- task: dev:proprietary
|
||||
vars:
|
||||
PORT: '{{.PORT}}'
|
||||
ACCOUNT_LINK_SAAS_BASE_URL: '{{.SAAS_BASE_URL}}'
|
||||
|
||||
_run:saas:
|
||||
internal: true
|
||||
dotenv: ['app/.env.saas.local', 'app/.env.saas']
|
||||
# The frontend files are here only for RUN_SUBPATH, which the authorize URL needs.
|
||||
# Last, because dotenv is set-if-absent: app/* still decides everything else.
|
||||
dotenv:
|
||||
- 'app/.env.saas.local'
|
||||
- 'app/.env.saas'
|
||||
- 'frontend/editor/.env.saas.local'
|
||||
- 'frontend/editor/.env.saas'
|
||||
ignore_error: true
|
||||
vars:
|
||||
PORT: '{{.PORT | default "8080"}}'
|
||||
@@ -111,12 +153,29 @@ tasks:
|
||||
AIENGINE_URL: '{{.AIENGINE_URL | default ""}}'
|
||||
AIENGINE_ENABLED: '{{.AIENGINE_ENABLED | default "false"}}'
|
||||
AIENGINE_TIMEOUTSECONDS: '{{.AIENGINE_TIMEOUTSECONDS | default "120"}}'
|
||||
# Empty is the same as unset: the property defaults to empty and is blank-checked.
|
||||
APP_BASE_URL: '{{.APP_BASE_URL | default ""}}'
|
||||
# Relocates configs/pipeline/logs, for a second backend in the same directory.
|
||||
# Empty is the same as unset: the reader blank-checks it.
|
||||
BASE_PATH: '{{.BASE_PATH | default ""}}'
|
||||
env:
|
||||
SERVER_PORT: '{{.PORT}}'
|
||||
STIRLING_FLAVOR: saas
|
||||
STIRLING_BASE_PATH: '{{.BASE_PATH}}'
|
||||
AIENGINE_URL: '{{.AIENGINE_URL}}'
|
||||
AIENGINE_ENABLED: '{{.AIENGINE_ENABLED}}'
|
||||
AIENGINE_TIMEOUTSECONDS: '{{.AIENGINE_TIMEOUTSECONDS}}'
|
||||
# Appends RUN_SUBPATH: the approval page is at <base>/link, so a subpath build
|
||||
# serves it at <base>/app/link. An explicit value still wins.
|
||||
SYSTEM_FRONTENDURL:
|
||||
sh: |
|
||||
if [ -n "${SYSTEM_FRONTENDURL:-}" ]; then
|
||||
echo "${SYSTEM_FRONTENDURL}"
|
||||
elif [ -n "{{.APP_BASE_URL}}" ] && [ -n "${RUN_SUBPATH:-}" ]; then
|
||||
echo "{{.APP_BASE_URL}}/${RUN_SUBPATH}"
|
||||
else
|
||||
echo "{{.APP_BASE_URL}}"
|
||||
fi
|
||||
cmds:
|
||||
# PROFILE_ARGS is empty when PROFILES=none, i.e. the bare `saas` profile
|
||||
# against SAAS_DB_* (production).
|
||||
|
||||
+19
-8
@@ -23,7 +23,7 @@ tasks:
|
||||
- package-lock.json
|
||||
- package.json
|
||||
status:
|
||||
- test -d node_modules
|
||||
- npm ls --depth=0
|
||||
env:
|
||||
CI: '{{ .CI | default "false" }}'
|
||||
|
||||
@@ -121,17 +121,17 @@ tasks:
|
||||
sh: |
|
||||
case "${SAAS_ENV:-dev}" in
|
||||
staging) ref="${SAAS_STAGING_PROJECT_REF:?set it in app/.env.saas.local}" ;;
|
||||
*) ref="${SAAS_DEV_PROJECT_REF:?set it in app/.env.saas.local, or run task staging:saas}" ;;
|
||||
*) ref="${SAAS_DEV_PROJECT_REF:?set it in app/.env.saas.local, or pass SAAS_ENV=staging}" ;;
|
||||
esac
|
||||
echo "https://${ref}.supabase.co"
|
||||
VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY:
|
||||
sh: |
|
||||
case "${SAAS_ENV:-dev}" in
|
||||
staging) echo "${SAAS_STAGING_PUBLISHABLE_KEY:?set it in app/.env.saas.local}" ;;
|
||||
*) echo "${SAAS_DEV_PUBLISHABLE_KEY:?set it in app/.env.saas.local}" ;;
|
||||
*) echo "${SAAS_DEV_PUBLISHABLE_KEY:?set it in app/.env.saas.local, or pass SAAS_ENV=staging}" ;;
|
||||
esac
|
||||
cmds:
|
||||
- 'echo ">> frontend Supabase target: $VITE_SUPABASE_URL"'
|
||||
- 'echo ">> frontend {{.SAAS_ENV}}: Supabase $VITE_SUPABASE_URL, backend $BACKEND_URL"'
|
||||
- npx vite editor --mode saas --port {{.PORT}}{{if .OPEN}} --open{{end}}
|
||||
|
||||
dev:
|
||||
@@ -173,6 +173,16 @@ tasks:
|
||||
OPEN: '{{.OPEN}}'
|
||||
SAAS_ENV: '{{.SAAS_ENV}}'
|
||||
|
||||
staging:saas:
|
||||
desc: "Start frontend dev server against the shared v3 staging project"
|
||||
cmds:
|
||||
- task: dev:saas
|
||||
vars:
|
||||
SAAS_ENV: staging
|
||||
PORT: '{{.PORT}}'
|
||||
BACKEND_URL: '{{.BACKEND_URL}}'
|
||||
OPEN: '{{.OPEN}}'
|
||||
|
||||
dev:desktop:
|
||||
desc: "Start frontend dev server in desktop mode"
|
||||
deps:
|
||||
@@ -375,13 +385,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 +564,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 +574,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"
|
||||
|
||||
@@ -11,6 +11,7 @@ vars:
|
||||
'.github/scripts/*.py'
|
||||
'app/core/src/main/resources/static/python/*.py'
|
||||
':(exclude)*split_photos.py'
|
||||
':(exclude)scripts/lint/fixtures/*'
|
||||
SPELL_FILES: >-
|
||||
'*.html'
|
||||
'*.css'
|
||||
@@ -59,6 +60,7 @@ tasks:
|
||||
- task: gitleaks
|
||||
- task: whitespace
|
||||
- task: toml-sort
|
||||
- task: comment-lint
|
||||
|
||||
fix:
|
||||
desc: "Auto-fix formatting, spelling, and secrets issues across the repo"
|
||||
@@ -75,6 +77,7 @@ tasks:
|
||||
vars: { FIX: '1' }
|
||||
- task: codespell
|
||||
- task: gitleaks
|
||||
- task: comment-lint
|
||||
|
||||
install:
|
||||
desc: "Install the pinned pre-commit Python tools"
|
||||
@@ -130,6 +133,85 @@ tasks:
|
||||
cmds:
|
||||
- "{{.GITLEAKS_BIN}} git --pre-commit --redact --staged --verbose"
|
||||
|
||||
comment-lint:
|
||||
desc: "Check comment quality on the lines this branch adds"
|
||||
summary: |
|
||||
Blocks a comment that restates the code below it, a section banner, or a
|
||||
block of commented-out code. Everything else it reports is advisory.
|
||||
|
||||
Scoped to added lines, so touching an old file never surfaces the standing
|
||||
backlog. The standard is devGuide/CODE_COMMENTS.md.
|
||||
|
||||
With no arguments it diffs the working tree against HEAD, which is what a
|
||||
pre-commit run wants: the lines you are about to commit. On a CI pull request
|
||||
it diffs against the target branch instead, via GITHUB_BASE_REF.
|
||||
|
||||
To ask what a whole branch adds instead, use the branch variant, which
|
||||
needs no argument passing:
|
||||
task comment-lint:branch
|
||||
|
||||
Full tree (report only): task pre-commit:comment-lint:all
|
||||
Fixture corpus: task pre-commit:comment-lint:selftest
|
||||
# Depends on the frontend install because the .ts/.tsx half of the rule set
|
||||
# runs as an oxlint plugin. Without it the TS engine warns and skips, which
|
||||
# would leave the frontend silently unchecked on CI.
|
||||
deps: [":frontend:install"]
|
||||
cmds:
|
||||
- node scripts/lint/comment-lint.mjs {{.CLI_ARGS}}
|
||||
|
||||
comment-lint:branch:
|
||||
desc: "Check comment quality on everything this branch adds over its base"
|
||||
summary: |
|
||||
Like `task comment-lint`, but scoped to the whole branch rather than to
|
||||
uncommitted work, so it still reports after you commit.
|
||||
|
||||
Exists as its own task because passing `-- --since origin/main` through Task
|
||||
is not portable: with the npm build of Task the launcher is a PowerShell
|
||||
script, and PowerShell strips the `--` before Task sees it, leaving Task to
|
||||
print its own usage.
|
||||
|
||||
Override the base with BASE=<ref>.
|
||||
vars:
|
||||
BASE: '{{.BASE | default "origin/main"}}'
|
||||
deps: [":frontend:install"]
|
||||
cmds:
|
||||
- node scripts/lint/comment-lint.mjs --since {{.BASE}}
|
||||
|
||||
comment-lint:ci:
|
||||
desc: "Comment gate as CI runs it: fixture corpus, then the diff"
|
||||
summary: |
|
||||
The corpus checks the rules themselves rather than the code under review, so
|
||||
it belongs on CI and not on every local commit. Run this before changing a
|
||||
rule, and let CI run it on every pull request.
|
||||
deps: [":frontend:install"]
|
||||
cmds:
|
||||
- node scripts/lint/comment-lint.mjs --selftest
|
||||
- node scripts/lint/comment-lint.mjs {{.CLI_ARGS}}
|
||||
|
||||
comment-lint:hook:
|
||||
desc: "Comment gate for the editor hook: everything this turn changed"
|
||||
summary: |
|
||||
Same scope as `task comment-lint`, kept as its own name so the hook has a
|
||||
stable entry point and the taskfile shows every way the linter is invoked.
|
||||
|
||||
Not in the frontend-install dependency chain on purpose: this runs at the end
|
||||
of every turn, so it stays as short as it can be. If oxlint is missing the TS
|
||||
half warns and skips.
|
||||
cmds:
|
||||
- node scripts/lint/comment-lint.mjs
|
||||
|
||||
comment-lint:all:
|
||||
desc: "Report every comment finding in the tree (never fails)"
|
||||
deps: [":frontend:install"]
|
||||
cmds:
|
||||
- node scripts/lint/comment-lint.mjs --all
|
||||
|
||||
comment-lint:selftest:
|
||||
desc: "Check both comment-lint engines against the fixture corpus"
|
||||
deps: [":frontend:install"]
|
||||
cmds:
|
||||
- node scripts/lint/comment-lint.mjs --selftest
|
||||
|
||||
gitleaks-bin:
|
||||
internal: true
|
||||
desc: "Ensure the pinned, checksum-verified gitleaks binary is cached in .task/bin"
|
||||
|
||||
@@ -21,6 +21,43 @@ Task `desc:` fields should describe **what** the task does, not **how** it does
|
||||
- `task docker:build` — build standard Docker image
|
||||
- `task docker:up` — start Docker compose stack
|
||||
|
||||
## Comments
|
||||
|
||||
A comment must carry information the code cannot. If a reader could derive it from the code in front of them, delete it.
|
||||
|
||||
Comment the current state. Not what the code used to do, not what changed, not why it changed: git holds that. Where history explains the shape, state the reason instead, so "this used to reimplement the modal internals" becomes "thin wrapper over the shared Modal: duplicating its portal and focus trap is how dialogs drift apart". Future state goes in a TODO with an issue.
|
||||
|
||||
Write a comment when it does one of these four jobs:
|
||||
|
||||
- **Contract.** What a caller must know that the signature cannot say: preconditions, invariants, units, ownership and lifetime, thread-safety, error semantics, side effects. Document the contract of everything a caller outside the file can reach, and nothing else. Goes on the type/method/module as Javadoc, JSDoc, or a docstring.
|
||||
- **Why.** The constraint the code satisfies, the bug it avoids, the alternative rejected and the reason.
|
||||
- **Hazard.** "Must stay in sync with X", "order matters because Y", "do not remove, it prevents Z".
|
||||
- **Map.** A short orientation at the top of a genuinely complex file: what it owns, and what it deliberately does not.
|
||||
|
||||
Never write:
|
||||
|
||||
- A comment that restates the next line. `// Handle drag start` above `handleDragStart` is noise.
|
||||
- Section banners or position markers: `// --- Types ---`, `// Helpers`, `// =====`.
|
||||
- Step narration in a function body (`// Step 1:`, `// Then we`). If the steps need labels they need names: extract functions. Numbering a genuinely numbered thing, like a wizard step, is fine.
|
||||
- Commented-out code. Delete it.
|
||||
- Doc tags that restate the signature. `@param blob - The blob` says nothing; omit the tag rather than pad it.
|
||||
- Docs on self-explanatory members with no constraint to state.
|
||||
|
||||
Two tests before keeping a comment:
|
||||
|
||||
- **Delete it.** Is any information lost? If not, it stays deleted.
|
||||
- **Could a name carry it instead?** A better identifier, an extracted function, or a named constant beats a comment. Prefer the code change.
|
||||
|
||||
A comment at the end of a line usually decodes that line, and that is worth keeping: `{0x25, 0x50} // "%PDF"`, `50L * 1024 * 1024 // 50 MB`. The rules that compare a comment against the code below it do not apply there, but a trailing TODO or a trailing bit of history is judged like any other.
|
||||
|
||||
A reference is supplementary, never load-bearing: the comment must survive deleting it. `// See #1234` is a dead end; `// saving first loses every annotation (#6865)` is not. Prefer a spec (`RFC 3161`) or CVE where one applies.
|
||||
|
||||
A TODO needs an issue, not an owner: `// TODO(#1234): re-enable the gate once account syncing lands`. If it is not worth an issue, it is not worth a TODO. A question is not a TODO.
|
||||
|
||||
A comment block over ~12 lines outside a file or type header usually means the code needs restructuring, or that the prose is product documentation and belongs in the docs repo.
|
||||
|
||||
`task comment-lint` checks the mechanical part of this on the lines you add, and runs inside `task pre-commit`. Reasoning, worked examples and the linter's own rules: @devGuide/CODE_COMMENTS.md
|
||||
|
||||
## Common Development Commands
|
||||
|
||||
### Build and Test
|
||||
@@ -70,7 +107,7 @@ The project structure is defined in `engine/pyproject.toml`. Any new dependencie
|
||||
- Avoid nested functions and nested classes unless the language construct requires them.
|
||||
- Prefer composition to inheritance when combining concepts.
|
||||
- Avoid speculative abstractions. Add a layer only when it removes real duplication or clarifies lifecycle.
|
||||
- Add comments sparingly and only when they explain non-obvious intent.
|
||||
- Comments follow the repo-wide rules in the "Comments" section above.
|
||||
|
||||
#### Python Typing and Models
|
||||
- Deserialize into Pydantic models as early as possible.
|
||||
|
||||
@@ -42,6 +42,7 @@ Please make sure your Pull Request adheres to the following guidelines:
|
||||
- Keep commits atomic. One commit should contain one change. If you want to make multiple changes, submit multiple Pull Requests.
|
||||
- Commits should be clear, concise, and easy to understand.
|
||||
- References to the Issue number in the Pull Request and/or Commit message.
|
||||
- Every comment in the diff should say something the code does not. See [Code comments](devGuide/CODE_COMMENTS.md); `task comment-lint` checks the mechanical part.
|
||||
|
||||
## Translations
|
||||
|
||||
|
||||
+100
@@ -121,6 +121,92 @@ tasks:
|
||||
cmds:
|
||||
- task: dev:_all
|
||||
|
||||
# No engine: linking never calls it.
|
||||
linked:staging:
|
||||
desc: "SaaS on the shared v3 project + a self-hosted instance linked to it"
|
||||
cmds:
|
||||
- task: linked:_all
|
||||
vars: { SAAS_ENV: staging }
|
||||
|
||||
linked:dev:
|
||||
desc: "SaaS on the current PR's preview branch + a self-hosted instance linked to it"
|
||||
cmds:
|
||||
- task: linked:_all
|
||||
vars: { SAAS_ENV: dev }
|
||||
|
||||
linked:_all:
|
||||
internal: true
|
||||
vars:
|
||||
SAAS_ENV: '{{.SAAS_ENV | default "staging"}}'
|
||||
PORTS:
|
||||
sh: '{{if eq OS "windows"}}{{.FIND_FREE_PORT_PS}} 8081 5174 8080 5173{{else}}{{.FIND_FREE_PORT_SH}} 8081 5174 8080 5173{{end}}'
|
||||
SAAS_BACKEND_PORT: '{{index (splitList "\n" .PORTS) 0}}'
|
||||
SAAS_FRONTEND_PORT: '{{index (splitList "\n" .PORTS) 1}}'
|
||||
APP_BACKEND_PORT: '{{index (splitList "\n" .PORTS) 2}}'
|
||||
APP_FRONTEND_PORT: '{{index (splitList "\n" .PORTS) 3}}'
|
||||
deps:
|
||||
# APP_BASE_URL is the SaaS *frontend*: the approval page is served by vite, not
|
||||
# by the API. BASE_PATH moves this backend's configs/pipeline aside so it does not
|
||||
# race the self-hosted one, which keeps ./configs and its existing database.
|
||||
- task: 'backend:{{.SAAS_ENV}}:saas'
|
||||
vars:
|
||||
PORT: '{{.SAAS_BACKEND_PORT}}'
|
||||
APP_BASE_URL: 'http://localhost:{{.SAAS_FRONTEND_PORT}}'
|
||||
BASE_PATH: 'tmp/linked-saas'
|
||||
- task: frontend:dev:saas
|
||||
vars:
|
||||
PORT: '{{.SAAS_FRONTEND_PORT}}'
|
||||
BACKEND_URL: 'http://localhost:{{.SAAS_BACKEND_PORT}}'
|
||||
SAAS_ENV: '{{.SAAS_ENV}}'
|
||||
- task: backend:dev:linked
|
||||
vars:
|
||||
PORT: '{{.APP_BACKEND_PORT}}'
|
||||
SAAS_BASE_URL: 'http://localhost:{{.SAAS_BACKEND_PORT}}'
|
||||
- task: frontend:dev:proprietary
|
||||
vars:
|
||||
PORT: '{{.APP_FRONTEND_PORT}}'
|
||||
BACKEND_URL: 'http://localhost:{{.APP_BACKEND_PORT}}'
|
||||
OPEN: "true"
|
||||
- task: linked:_ready
|
||||
vars:
|
||||
SAAS_BACKEND_PORT: '{{.SAAS_BACKEND_PORT}}'
|
||||
SAAS_FRONTEND_PORT: '{{.SAAS_FRONTEND_PORT}}'
|
||||
APP_BACKEND_PORT: '{{.APP_BACKEND_PORT}}'
|
||||
APP_FRONTEND_PORT: '{{.APP_FRONTEND_PORT}}'
|
||||
|
||||
# Waits for all four to answer, then prints where they landed.
|
||||
linked:_ready:
|
||||
internal: true
|
||||
cmds:
|
||||
- cmd: |
|
||||
n=0
|
||||
ok=0
|
||||
while [ "$n" -lt 150 ]; do
|
||||
ok=1
|
||||
for u in "http://localhost:{{.SAAS_BACKEND_PORT}}" \
|
||||
"http://localhost:{{.SAAS_FRONTEND_PORT}}" \
|
||||
"http://localhost:{{.APP_BACKEND_PORT}}" \
|
||||
"http://localhost:{{.APP_FRONTEND_PORT}}"; do
|
||||
# Not -o /dev/null: Windows curl.exe treats it as a real path and exits 23.
|
||||
curl -s -m 2 "$u" >/dev/null 2>&1 || ok=0
|
||||
done
|
||||
if [ "$ok" = 1 ]; then break; fi
|
||||
n=$((n + 1))
|
||||
# `sleep` is a binary, not a builtin, and Windows has none.
|
||||
{{if eq OS "windows"}}powershell -NoProfile -Command "Start-Sleep -Seconds 2"{{else}}sleep 2{{end}}
|
||||
done
|
||||
echo ""
|
||||
if [ "$ok" = 1 ]; then
|
||||
echo ">> all four answering"
|
||||
else
|
||||
echo ">> still waiting on one or more after 5 minutes; addresses below anyway"
|
||||
fi
|
||||
echo ">> self-hosted UI http://localhost:{{.APP_FRONTEND_PORT}}/processor"
|
||||
echo ">> self-hosted api http://localhost:{{.APP_BACKEND_PORT}}"
|
||||
echo ">> saas UI http://localhost:{{.SAAS_FRONTEND_PORT}}"
|
||||
echo ">> saas api http://localhost:{{.SAAS_BACKEND_PORT}}"
|
||||
echo ""
|
||||
|
||||
dev:_all:
|
||||
internal: true
|
||||
vars:
|
||||
@@ -180,6 +266,20 @@ tasks:
|
||||
cmds:
|
||||
- task: frontend:lint
|
||||
- task: engine:lint
|
||||
- task: comment-lint
|
||||
|
||||
comment-lint:
|
||||
desc: "Check comment quality on the lines this branch adds"
|
||||
aliases: [comments]
|
||||
cmds:
|
||||
- task: pre-commit:comment-lint
|
||||
vars: { CLI_ARGS: '{{.CLI_ARGS}}' }
|
||||
|
||||
comment-lint:branch:
|
||||
desc: "Check comment quality on everything this branch adds over its base"
|
||||
cmds:
|
||||
- task: pre-commit:comment-lint:branch
|
||||
vars: { BASE: '{{.BASE}}' }
|
||||
|
||||
fix:
|
||||
desc: "Auto-fix all components"
|
||||
|
||||
+33
-8
@@ -3,6 +3,10 @@ bootRun {
|
||||
enabled = false
|
||||
}
|
||||
dependencies {
|
||||
// Security-hardening utilities (zip-slip, SSRF, filename sanitization, command injection).
|
||||
// Declared as api here so core + proprietary (which depend on common) get it transitively,
|
||||
// keeping it off modules that don't need it (e.g. saas).
|
||||
api 'io.github.pixee:java-security-toolkit:1.2.3'
|
||||
api "com.google.guava:guava:${guavaVersion}"
|
||||
api 'org.springframework.boot:spring-boot-starter-webmvc'
|
||||
api 'org.springframework.boot:spring-boot-starter-aspectj'
|
||||
@@ -18,11 +22,14 @@ 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.3.2'
|
||||
api 'org.simplejavamail:outlook-module:9.3.2' // MSG file support
|
||||
// MSG file support; exclude commons-math3 (only HSSF/formula needs it, MSG parsing doesn't)
|
||||
api('org.simplejavamail:outlook-module:9.3.2') {
|
||||
exclude group: 'org.apache.commons', module: 'commons-math3'
|
||||
}
|
||||
api 'jakarta.mail:jakarta.mail-api:2.1.5'
|
||||
runtimeOnly 'org.eclipse.angus:angus-mail:2.0.5'
|
||||
|
||||
@@ -36,12 +43,30 @@ dependencies {
|
||||
|
||||
api "com.stirling:jpdfium:${jpdfiumVersion}"
|
||||
|
||||
// -PjpdfiumPlatforms=all|none|<csv of linux-x64,linux-arm64,darwin-x64,darwin-arm64,windows-x64>
|
||||
// 'none' skips natives entirely (windows-arm64 builds, until JPDFium ships that platform).
|
||||
def jpdfiumPlatformsProp = (project.findProperty('jpdfiumPlatforms') ?: 'all').toString().trim()
|
||||
def jpdfiumAllPlatforms = ['linux-x64', 'linux-arm64', 'darwin-x64', 'darwin-arm64', 'windows-x64']
|
||||
// -PjpdfiumPlatforms=auto|all|none|<csv of linux-x64,linux-arm64,linux-musl-x64,linux-musl-arm64,darwin-x64,darwin-arm64,windows-x64> (windows-arm64 natives not published yet)
|
||||
def jpdfiumPlatformsProp = (project.findProperty('jpdfiumPlatforms') ?: 'auto').toString().trim()
|
||||
def jpdfiumAllPlatforms = ['linux-x64', 'linux-arm64', 'linux-musl-x64', 'linux-musl-arm64', 'darwin-x64', 'darwin-arm64', 'windows-x64']
|
||||
def jpdfiumPlatforms
|
||||
if (jpdfiumPlatformsProp == 'all') {
|
||||
if (jpdfiumPlatformsProp == 'auto') {
|
||||
def osName = System.getProperty('os.name').toLowerCase()
|
||||
def osArch = System.getProperty('os.arch').toLowerCase()
|
||||
def isArm64 = osArch.contains('aarch64') || osArch.contains('arm64')
|
||||
if (osName.contains('linux')) {
|
||||
jpdfiumPlatforms = isArm64 ? ['linux-arm64'] : ['linux-x64']
|
||||
} else if (osName.contains('mac')) {
|
||||
jpdfiumPlatforms = isArm64 ? ['darwin-arm64'] : ['darwin-x64']
|
||||
} else if (osName.contains('win')) {
|
||||
if (isArm64) {
|
||||
logger.lifecycle("JPDFium natives are not available for windows-arm64; set -PjpdfiumPlatforms=none to skip bundling natives.")
|
||||
jpdfiumPlatforms = []
|
||||
} else {
|
||||
jpdfiumPlatforms = ['windows-x64']
|
||||
}
|
||||
} else {
|
||||
// Fallback: bundle all platforms when host can't be determined
|
||||
jpdfiumPlatforms = jpdfiumAllPlatforms
|
||||
}
|
||||
} else if (jpdfiumPlatformsProp == 'all') {
|
||||
jpdfiumPlatforms = jpdfiumAllPlatforms
|
||||
} else if (jpdfiumPlatformsProp == 'none') {
|
||||
jpdfiumPlatforms = []
|
||||
@@ -51,7 +76,7 @@ dependencies {
|
||||
def jpdfiumInvalid = jpdfiumPlatforms.findAll { !jpdfiumAllPlatforms.contains(it) }
|
||||
if (jpdfiumInvalid) {
|
||||
throw new GradleException("Unknown jpdfiumPlatforms value(s): ${jpdfiumInvalid.join(', ')}. " +
|
||||
"Valid: ${jpdfiumAllPlatforms.join(', ')}, 'all' or 'none'.")
|
||||
"Valid: ${jpdfiumAllPlatforms.join(', ')}, 'auto', 'all' or 'none'.")
|
||||
}
|
||||
logger.lifecycle("JPDFium native platforms: ${jpdfiumPlatforms ? jpdfiumPlatforms.join(', ') : 'none'}")
|
||||
jpdfiumPlatforms.each { platform ->
|
||||
|
||||
@@ -48,7 +48,7 @@ public class EndpointConfiguration {
|
||||
private final ApplicationProperties applicationProperties;
|
||||
@Getter private Map<String, Boolean> endpointStatuses = new ConcurrentHashMap<>();
|
||||
private Map<String, Set<String>> endpointGroups = new ConcurrentHashMap<>();
|
||||
private Set<String> disabledGroups = new HashSet<>();
|
||||
private Set<String> disabledGroups = ConcurrentHashMap.newKeySet();
|
||||
private Map<String, DisableReason> endpointDisableReasons = new ConcurrentHashMap<>();
|
||||
private Map<String, DisableReason> groupDisableReasons = new ConcurrentHashMap<>();
|
||||
private Map<String, Set<String>> endpointAlternatives = new ConcurrentHashMap<>();
|
||||
|
||||
@@ -237,7 +237,7 @@ public class TabulaTableParser implements TableParser {
|
||||
score -= 0.3f;
|
||||
}
|
||||
|
||||
return Math.max(0f, Math.min(1f, score));
|
||||
return Math.clamp(score, 0f, 1f);
|
||||
}
|
||||
|
||||
private Bounds tableBounds(Table table) {
|
||||
|
||||
@@ -62,6 +62,15 @@ public class FormFieldWithCoordinates {
|
||||
@Schema(description = "Widget coordinates on each page (fields can have multiple widgets)")
|
||||
private List<WidgetCoordinates> widgets;
|
||||
|
||||
@Schema(description = "Maximum character count for a text field (/MaxLen); null when unset")
|
||||
private Integer maxLength;
|
||||
|
||||
@Schema(
|
||||
description =
|
||||
"Push button activation action as a spec string:"
|
||||
+ " 'reset', 'print', 'uri:<url>' or 'submit:<url>'")
|
||||
private String buttonActionSpec;
|
||||
|
||||
/**
|
||||
* Coordinates for a single widget annotation (visual representation of the field). A field can
|
||||
* have multiple widgets if it appears on multiple pages.
|
||||
@@ -94,5 +103,12 @@ public class FormFieldWithCoordinates {
|
||||
|
||||
@Schema(description = "Font size in PDF points")
|
||||
private Float fontSize;
|
||||
|
||||
@Schema(
|
||||
description =
|
||||
"CropBox height in PDF points. Lets the frontend reverse the backend's"
|
||||
+ " Y-flip when sending new widget coordinates back for"
|
||||
+ " create/modify operations.")
|
||||
private Float cropBoxHeight;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,14 +3,20 @@ package stirling.software.common.util;
|
||||
import java.io.IOException;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.function.Function;
|
||||
import java.util.regex.Pattern;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import org.apache.pdfbox.cos.COSName;
|
||||
import org.apache.pdfbox.pdmodel.graphics.color.PDColor;
|
||||
import org.apache.pdfbox.pdmodel.graphics.color.PDDeviceRGB;
|
||||
import org.apache.pdfbox.pdmodel.interactive.action.PDActionNamed;
|
||||
import org.apache.pdfbox.pdmodel.interactive.action.PDActionResetForm;
|
||||
import org.apache.pdfbox.pdmodel.interactive.action.PDActionSubmitForm;
|
||||
import org.apache.pdfbox.pdmodel.interactive.action.PDActionURI;
|
||||
import org.apache.pdfbox.pdmodel.interactive.annotation.PDAnnotationWidget;
|
||||
import org.apache.pdfbox.pdmodel.interactive.annotation.PDAppearanceCharacteristicsDictionary;
|
||||
import org.apache.pdfbox.pdmodel.interactive.form.PDAcroForm;
|
||||
@@ -59,6 +65,24 @@ public enum FormFieldTypeSupport {
|
||||
List<String> options)
|
||||
throws IOException {
|
||||
PDTextField textField = (PDTextField) field;
|
||||
if (definition.fontSize() != null && definition.fontSize() > 0) {
|
||||
textField.setDefaultAppearance("/Helv " + definition.fontSize() + " Tf 0 g");
|
||||
}
|
||||
if (Boolean.TRUE.equals(definition.multiline())) {
|
||||
textField.setMultiline(true);
|
||||
}
|
||||
// Comb field: evenly spaced character cells (e.g. SSN, phone). Requires
|
||||
// a positive MaxLen and is mutually exclusive with multiline.
|
||||
if (definition.maxLength() != null && definition.maxLength() > 0) {
|
||||
textField.setMaxLen(definition.maxLength());
|
||||
if (!Boolean.TRUE.equals(definition.multiline())) {
|
||||
try {
|
||||
textField.setComb(true);
|
||||
} catch (Exception e) {
|
||||
log.debug("Unable to set comb flag: {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
String defaultValue = Optional.ofNullable(definition.defaultValue()).orElse("");
|
||||
if (!defaultValue.isBlank()) {
|
||||
FormUtils.setTextValue(textField, defaultValue);
|
||||
@@ -272,14 +296,108 @@ public enum FormFieldTypeSupport {
|
||||
PDTerminalField createField(PDAcroForm acroForm) {
|
||||
return new PDSignatureField(acroForm);
|
||||
}
|
||||
|
||||
@Override
|
||||
boolean doesNotsupportsDefinitionCreation() {
|
||||
return false;
|
||||
}
|
||||
// Empty signature placeholder: no value to apply (signed later by a sign tool).
|
||||
},
|
||||
BUTTON("button", "pushButton", PDPushButton.class) {
|
||||
@Override
|
||||
PDTerminalField createField(PDAcroForm acroForm) {
|
||||
return new PDPushButton(acroForm);
|
||||
}
|
||||
|
||||
@Override
|
||||
boolean doesNotsupportsDefinitionCreation() {
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
void applyNewFieldDefinition(
|
||||
PDTerminalField field,
|
||||
FormUtils.NewFormFieldDefinition definition,
|
||||
List<String> options)
|
||||
throws IOException {
|
||||
if (field.getWidgets().isEmpty()) {
|
||||
return;
|
||||
}
|
||||
PDAnnotationWidget widget = field.getWidgets().get(0);
|
||||
|
||||
// Visible caption (/MK /CA).
|
||||
String caption = definition.label();
|
||||
if (caption == null || caption.isBlank()) {
|
||||
caption = definition.name();
|
||||
}
|
||||
if (caption != null && !caption.isBlank()) {
|
||||
PDAppearanceCharacteristicsDictionary mk = widget.getAppearanceCharacteristics();
|
||||
if (mk == null) {
|
||||
mk = new PDAppearanceCharacteristicsDictionary(widget.getCOSObject());
|
||||
widget.setAppearanceCharacteristics(mk);
|
||||
}
|
||||
mk.setNormalCaption(caption);
|
||||
}
|
||||
widget.setPrinted(true);
|
||||
|
||||
applyButtonAction(widget, definition.buttonAction());
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Writes a push button's activation action from a "reset"/"print"/"uri:"/"submit:" spec,
|
||||
* returning why it could not, or null on success. A blank spec clears the action.
|
||||
*/
|
||||
public static String applyButtonAction(PDAnnotationWidget widget, String action) {
|
||||
if (action == null) {
|
||||
return null;
|
||||
}
|
||||
if (action.isBlank()) {
|
||||
// An explicit blank clears the action rather than leaving the old one behind.
|
||||
widget.getCOSObject().removeItem(COSName.A);
|
||||
return null;
|
||||
}
|
||||
String spec = action.trim();
|
||||
if (!ACTION_SPEC.matcher(spec).matches()) {
|
||||
return "'" + action + "' is not a button action this editor understands";
|
||||
}
|
||||
// The editor emits "uri:" the moment that kind is picked, before a URL is typed; an
|
||||
// empty target is not yet an action, so clear rather than write an inert one.
|
||||
int colon = spec.indexOf(':');
|
||||
if (colon >= 0 && spec.substring(colon + 1).isBlank()) {
|
||||
widget.getCOSObject().removeItem(COSName.A);
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
String lower = spec.toLowerCase(Locale.ROOT);
|
||||
if (lower.equals("reset")) {
|
||||
widget.getCOSObject().setItem(COSName.A, new PDActionResetForm().getCOSObject());
|
||||
} else if (lower.equals("print")) {
|
||||
PDActionNamed named = new PDActionNamed();
|
||||
named.setN("Print");
|
||||
widget.getCOSObject().setItem(COSName.A, named.getCOSObject());
|
||||
} else if (lower.startsWith("uri:")) {
|
||||
PDActionURI uri = new PDActionURI();
|
||||
uri.setURI(spec.substring(4));
|
||||
widget.getCOSObject().setItem(COSName.A, uri.getCOSObject());
|
||||
} else if (lower.startsWith("submit:")) {
|
||||
PDActionSubmitForm submit = new PDActionSubmitForm();
|
||||
// Store the target URL on the action dictionary's /F entry.
|
||||
submit.getCOSObject().setString(COSName.F, spec.substring(7));
|
||||
widget.getCOSObject().setItem(COSName.A, submit.getCOSObject());
|
||||
}
|
||||
return null;
|
||||
} catch (Exception e) {
|
||||
log.debug("Unable to apply button action '{}': {}", action, e.getMessage());
|
||||
return e.getMessage();
|
||||
}
|
||||
}
|
||||
|
||||
/** The spec forms applyButtonAction understands; anything else is reported, not dropped. */
|
||||
private static final Pattern ACTION_SPEC =
|
||||
Pattern.compile(
|
||||
"^(reset|print|uri:.*|submit:.*)$", Pattern.CASE_INSENSITIVE | Pattern.DOTALL);
|
||||
|
||||
private static final Map<String, FormFieldTypeSupport> BY_TYPE =
|
||||
Arrays.stream(values())
|
||||
.collect(
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+2
-1
@@ -15,7 +15,8 @@ public class StringToMapPropertyEditor extends PropertyEditorSupport {
|
||||
@Override
|
||||
public void setAsText(String text) throws IllegalArgumentException {
|
||||
try {
|
||||
TypeReference<HashMap<String, String>> typeRef = new TypeReference<>() {};
|
||||
TypeReference<HashMap<String, String>> typeRef =
|
||||
new TypeReference<HashMap<String, String>>() {};
|
||||
Map<String, String> map = objectMapper.readValue(text, typeRef);
|
||||
setValue(map);
|
||||
} catch (Exception e) {
|
||||
|
||||
+116
@@ -0,0 +1,116 @@
|
||||
package stirling.software.common.util;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.util.List;
|
||||
|
||||
import org.apache.pdfbox.Loader;
|
||||
import org.apache.pdfbox.cos.COSArray;
|
||||
import org.apache.pdfbox.cos.COSName;
|
||||
import org.apache.pdfbox.cos.COSObject;
|
||||
import org.apache.pdfbox.cos.COSObjectKey;
|
||||
import org.apache.pdfbox.cos.COSString;
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.apache.pdfbox.pdmodel.PDPage;
|
||||
import org.apache.pdfbox.pdmodel.common.PDRectangle;
|
||||
import org.apache.pdfbox.pdmodel.interactive.digitalsignature.PDSignature;
|
||||
import org.apache.pdfbox.pdmodel.interactive.form.PDAcroForm;
|
||||
import org.apache.pdfbox.pdmodel.interactive.form.PDComboBox;
|
||||
import org.apache.pdfbox.pdmodel.interactive.form.PDSignatureField;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
/** Pins how a choice field's options survive a save, which real forms rely on. */
|
||||
class ChoiceOptionRoundTripTest {
|
||||
|
||||
private static PDComboBox combo(PDDocument document, List<String> options) throws IOException {
|
||||
document.addPage(new PDPage(PDRectangle.A4));
|
||||
PDAcroForm form = new PDAcroForm(document);
|
||||
document.getDocumentCatalog().setAcroForm(form);
|
||||
PDComboBox field = new PDComboBox(form);
|
||||
field.setPartialName("state");
|
||||
field.setOptions(options);
|
||||
form.getFields().add(field);
|
||||
return field;
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a whitespace-only option survives a load, save and reload")
|
||||
void whitespaceOptionSurvivesRoundTrip() throws IOException {
|
||||
List<String> options = List.of(" ", "Alabama", "Alaska");
|
||||
|
||||
byte[] first;
|
||||
try (PDDocument document = new PDDocument();
|
||||
ByteArrayOutputStream out = new ByteArrayOutputStream()) {
|
||||
combo(document, options);
|
||||
document.save(out);
|
||||
first = out.toByteArray();
|
||||
}
|
||||
// The real path edits a document loaded from bytes, not one built in memory.
|
||||
byte[] saved;
|
||||
try (PDDocument loaded = Loader.loadPDF(first);
|
||||
ByteArrayOutputStream out = new ByteArrayOutputStream()) {
|
||||
loaded.save(out);
|
||||
saved = out.toByteArray();
|
||||
}
|
||||
|
||||
try (PDDocument reloaded = Loader.loadPDF(saved)) {
|
||||
PDComboBox reread =
|
||||
(PDComboBox) reloaded.getDocumentCatalog().getAcroForm(null).getField("state");
|
||||
assertEquals(
|
||||
options,
|
||||
reread.getOptionsExportValues(),
|
||||
"an option must not vanish because the writer made it indirect");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("an option stored as an indirect reference is still reported")
|
||||
void indirectOptionIsStillReported() throws IOException {
|
||||
try (PDDocument document = new PDDocument()) {
|
||||
PDComboBox field = combo(document, List.of(" ", "Alabama"));
|
||||
|
||||
// Real forms reference option strings indirectly; the reader must follow the reference.
|
||||
COSArray options = new COSArray();
|
||||
options.add(new COSObject(new COSString(" "), new COSObjectKey(629, 0)));
|
||||
options.add(new COSString("Alabama"));
|
||||
field.getCOSObject().setItem(COSName.OPT, options);
|
||||
|
||||
// Every read path runs this repair first, which is where the reference is followed.
|
||||
FormUtils.repairMissingWidgetPageReferences(document);
|
||||
|
||||
assertEquals(
|
||||
List.of(" ", "Alabama"),
|
||||
field.getOptionsExportValues(),
|
||||
"an indirectly stored option must not be dropped");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a signature field reports no value rather than a JVM identity hash")
|
||||
void signatureValueIsNotAnIdentityHash() throws IOException {
|
||||
try (PDDocument document = new PDDocument()) {
|
||||
document.addPage(new PDPage(PDRectangle.A4));
|
||||
PDAcroForm form = new PDAcroForm(document);
|
||||
document.getDocumentCatalog().setAcroForm(form);
|
||||
PDSignatureField signature = new PDSignatureField(form);
|
||||
signature.setPartialName("approval");
|
||||
// Only a field that actually holds a signature hits getValueAsString's toString().
|
||||
signature.setValue(new PDSignature());
|
||||
form.getFields().add(signature);
|
||||
|
||||
List<FormUtils.FormFieldInfo> fields = FormUtils.extractFormFields(document);
|
||||
|
||||
FormUtils.FormFieldInfo field =
|
||||
fields.stream()
|
||||
.filter(f -> "approval".equals(f.name()))
|
||||
.findFirst()
|
||||
.orElseThrow();
|
||||
// An identity hash differs per load, so the same document would describe itself twice.
|
||||
assertNull(field.value(), "a signature has no text value");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package stirling.software.common.util;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
|
||||
import org.apache.pdfbox.Loader;
|
||||
import org.apache.pdfbox.cos.COSArray;
|
||||
import org.apache.pdfbox.cos.COSDictionary;
|
||||
import org.apache.pdfbox.cos.COSName;
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.apache.pdfbox.pdmodel.PDPage;
|
||||
import org.apache.pdfbox.pdmodel.common.PDRectangle;
|
||||
import org.apache.pdfbox.pdmodel.interactive.form.PDAcroForm;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
/** A hostile or corrupt form must fail as a rejected request, never as a crashed thread. */
|
||||
class DeepFieldTreeTest {
|
||||
|
||||
private static byte[] chainOfKids(int depth) throws IOException {
|
||||
try (PDDocument document = new PDDocument();
|
||||
ByteArrayOutputStream out = new ByteArrayOutputStream()) {
|
||||
document.addPage(new PDPage(PDRectangle.A4));
|
||||
PDAcroForm form = new PDAcroForm(document);
|
||||
document.getDocumentCatalog().setAcroForm(form);
|
||||
|
||||
COSDictionary root = new COSDictionary();
|
||||
root.setString(COSName.T, "n0");
|
||||
COSDictionary cursor = root;
|
||||
for (int i = 1; i < depth; i++) {
|
||||
COSDictionary kid = new COSDictionary();
|
||||
kid.setString(COSName.T, "n" + i);
|
||||
kid.setItem(COSName.PARENT, cursor);
|
||||
COSArray kids = new COSArray();
|
||||
kids.add(kid);
|
||||
cursor.setItem(COSName.KIDS, kids);
|
||||
cursor = kid;
|
||||
}
|
||||
cursor.setItem(COSName.FT, COSName.getPDFName("Tx"));
|
||||
|
||||
COSArray fields = new COSArray();
|
||||
fields.add(root);
|
||||
form.getCOSObject().setItem(COSName.FIELDS, fields);
|
||||
document.save(out);
|
||||
return out.toByteArray();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a deeply nested field tree extracts without overflowing the stack")
|
||||
void deepKidsChainDoesNotOverflow() throws IOException {
|
||||
// 2000 is as deep as PDFBox's own writer can build here; beyond that the overflow is in
|
||||
// the writer, not in extraction, so it is not something a read endpoint would hit.
|
||||
byte[] pdf = chainOfKids(2000);
|
||||
|
||||
try (PDDocument document = Loader.loadPDF(pdf)) {
|
||||
assertDoesNotThrow(() -> FormUtils.extractFormFieldsWithCoordinates(document));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
package stirling.software.common.util;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.apache.pdfbox.pdmodel.PDPage;
|
||||
import org.apache.pdfbox.pdmodel.common.PDRectangle;
|
||||
import org.apache.pdfbox.pdmodel.interactive.form.PDAcroForm;
|
||||
import org.apache.pdfbox.pdmodel.interactive.form.PDCheckBox;
|
||||
import org.apache.pdfbox.pdmodel.interactive.form.PDField;
|
||||
import org.apache.pdfbox.pdmodel.interactive.form.PDRadioButton;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import stirling.software.common.model.FormFieldWithCoordinates;
|
||||
|
||||
/** An edit that cannot be honoured must be refused and reported, never silently reshaped. */
|
||||
class FormEditSafetyTest {
|
||||
|
||||
private static PDDocument formWith(String name, String type) throws IOException {
|
||||
PDDocument document = new PDDocument();
|
||||
document.addPage(new PDPage(PDRectangle.A4));
|
||||
document.getDocumentCatalog().setAcroForm(new PDAcroForm(document));
|
||||
FormUtils.addNewFields(
|
||||
document,
|
||||
List.of(
|
||||
new FormUtils.NewFormFieldDefinition(
|
||||
name,
|
||||
null,
|
||||
type,
|
||||
0,
|
||||
50f,
|
||||
700f,
|
||||
200f,
|
||||
20f,
|
||||
null,
|
||||
null,
|
||||
type.equals("radio") ? List.of("a", "b") : null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null)));
|
||||
return document;
|
||||
}
|
||||
|
||||
private static FormUtils.ModifyFormFieldDefinition modify(
|
||||
String target, String type, Float width, Float height) {
|
||||
// Order: targetName, name, label, type, pageIndex, x, y, width, height, then the rest.
|
||||
return new FormUtils.ModifyFormFieldDefinition(
|
||||
target, null, null, type, null, null, null, width, height, null, null, null, null,
|
||||
null, null, null, null, null, null);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a type that cannot be rebuilt is refused instead of becoming a text field")
|
||||
void unrebuildableTypeIsRefused() throws IOException {
|
||||
try (PDDocument document = formWith("choice", "text")) {
|
||||
List<FormUtils.SkippedFieldEdit> skipped = new ArrayList<>();
|
||||
|
||||
FormUtils.modifyFormFields(
|
||||
document, List.of(modify("choice", "radio", null, null)), skipped);
|
||||
|
||||
PDField field = document.getDocumentCatalog().getAcroForm(null).getField("choice");
|
||||
assertFalse(skipped.isEmpty(), "the refusal must be reported to the caller");
|
||||
assertFalse(
|
||||
field instanceof PDRadioButton,
|
||||
"it could not become a radio, so it must not claim to be one");
|
||||
assertEquals(
|
||||
"text",
|
||||
FormUtils.extractFormFields(document).getFirst().type(),
|
||||
"the original field must survive untouched rather than be retyped");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a field rebuilt as a checkbox gets an appearance so it can be ticked")
|
||||
void rebuiltCheckboxIsUsable() throws IOException {
|
||||
try (PDDocument document = formWith("agree", "text")) {
|
||||
List<FormUtils.SkippedFieldEdit> skipped = new ArrayList<>();
|
||||
|
||||
FormUtils.modifyFormFields(
|
||||
document, List.of(modify("agree", "checkbox", null, null)), skipped);
|
||||
|
||||
PDField field = document.getDocumentCatalog().getAcroForm(null).getField("agree");
|
||||
assertTrue(field instanceof PDCheckBox, "the rebuild should have produced a checkbox");
|
||||
assertNotNull(
|
||||
field.getWidgets().getFirst().getAppearance(),
|
||||
"without an appearance the checkbox renders blank and cannot be ticked");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a size of zero or infinity is refused rather than written into the page")
|
||||
void unusableSizeIsRefused() throws IOException {
|
||||
for (Float bad : new Float[] {0f, -5f, Float.POSITIVE_INFINITY, Float.NaN}) {
|
||||
try (PDDocument document = formWith("box", "text")) {
|
||||
List<FormUtils.SkippedFieldEdit> skipped = new ArrayList<>();
|
||||
|
||||
FormUtils.modifyFormFields(
|
||||
document, List.of(modify("box", null, bad, 20f)), skipped);
|
||||
|
||||
PDRectangle rect =
|
||||
document.getDocumentCatalog()
|
||||
.getAcroForm(null)
|
||||
.getField("box")
|
||||
.getWidgets()
|
||||
.getFirst()
|
||||
.getRectangle();
|
||||
assertFalse(skipped.isEmpty(), "a refused resize must be reported: width " + bad);
|
||||
assertEquals(
|
||||
200f,
|
||||
rect.getWidth(),
|
||||
0.01f,
|
||||
"the original size must survive: width " + bad);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a widget off the page still reports its geometry instead of dropping the field")
|
||||
void offPageWidgetKeepsItsGeometry() throws IOException {
|
||||
try (PDDocument document = formWith("stray", "text")) {
|
||||
PDField field = document.getDocumentCatalog().getAcroForm(null).getField("stray");
|
||||
// Above the page top: legal PDF, and the user needs the coordinates to drag it back.
|
||||
field.getWidgets().getFirst().setRectangle(new PDRectangle(50f, 2000f, 200f, 20f));
|
||||
|
||||
List<FormFieldWithCoordinates> fields =
|
||||
FormUtils.extractFormFieldsWithCoordinates(document);
|
||||
|
||||
FormFieldWithCoordinates stray =
|
||||
fields.stream()
|
||||
.filter(f -> "stray".equals(f.getName()))
|
||||
.findFirst()
|
||||
.orElseThrow();
|
||||
assertNotNull(stray.getWidgets(), "the field must keep its widget list");
|
||||
assertFalse(stray.getWidgets().isEmpty(), "the off-page widget must still be reported");
|
||||
assertNotNull(stray.getWidgets().getFirst(), "a null entry would crash the overlay");
|
||||
}
|
||||
}
|
||||
|
||||
private static FormUtils.ModifyFormFieldDefinition withValue(String target, String value) {
|
||||
return new FormUtils.ModifyFormFieldDefinition(
|
||||
target, null, null, null, null, null, null, null, null, null, null, null, value,
|
||||
null, null, null, null, null, null);
|
||||
}
|
||||
|
||||
private static FormUtils.ModifyFormFieldDefinition withOptions(
|
||||
String target, List<String> options) {
|
||||
return new FormUtils.ModifyFormFieldDefinition(
|
||||
target, null, null, null, null, null, null, null, null, null, null, options, null,
|
||||
null, null, null, null, null, null);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a value a radio group cannot hold does not destroy the group")
|
||||
void badRadioValueLeavesTheGroupIntact() throws IOException {
|
||||
try (PDDocument document = formWith("plan", "radio")) {
|
||||
List<FormUtils.SkippedFieldEdit> skipped = new ArrayList<>();
|
||||
|
||||
FormUtils.modifyFormFields(
|
||||
document, List.of(withValue("plan", "not-an-option")), skipped);
|
||||
|
||||
PDField field = document.getDocumentCatalog().getAcroForm(null).getField("plan");
|
||||
assertTrue(
|
||||
field instanceof PDRadioButton,
|
||||
"a rejected value must not turn the group into another kind of field");
|
||||
assertEquals(
|
||||
2,
|
||||
field.getWidgets().size(),
|
||||
"the group's options must survive a rejected value");
|
||||
assertFalse(skipped.isEmpty(), "the caller must be told the value was not applied");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("editing a radio group's options is either applied or reported, never ignored")
|
||||
void radioOptionEditIsNotSilentlyDropped() throws IOException {
|
||||
try (PDDocument document = formWith("plan", "radio")) {
|
||||
List<FormUtils.SkippedFieldEdit> skipped = new ArrayList<>();
|
||||
|
||||
FormUtils.modifyFormFields(
|
||||
document, List.of(withOptions("plan", List.of("a", "b", "c"))), skipped);
|
||||
|
||||
PDField field = document.getDocumentCatalog().getAcroForm(null).getField("plan");
|
||||
boolean applied = field.getWidgets().size() == 3;
|
||||
assertTrue(
|
||||
applied || !skipped.isEmpty(),
|
||||
"a change the UI shows as saved must either happen or be reported as skipped");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package stirling.software.common.util;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
/**
|
||||
* A field name is caller-supplied and reaches several loggers. A line break in one would forge a
|
||||
* second log line (CWE-117), so names carrying control characters are refused outright.
|
||||
*/
|
||||
class FormFieldNameSafetyTest {
|
||||
|
||||
@Test
|
||||
void aNameWithCrLfIsRefused() {
|
||||
String forged = "evil\r\n2026-01-01 00:00:00 ERROR admin login from 1.2.3.4";
|
||||
String reason = FormUtils.invalidFieldNameReason(forged);
|
||||
assertNotNull(reason, "a name containing CR/LF must be refused");
|
||||
assertFalse(reason.contains("\n"), "the refusal itself must not carry a line break");
|
||||
assertFalse(reason.contains("\r"), "the refusal itself must not carry a carriage return");
|
||||
}
|
||||
|
||||
@Test
|
||||
void otherControlCharactersAreRefusedToo() {
|
||||
assertNotNull(FormUtils.invalidFieldNameReason("tab\there"));
|
||||
assertNotNull(FormUtils.invalidFieldNameReason("null\u0000byte"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void ordinaryNamesStillPass() {
|
||||
assertNull(FormUtils.invalidFieldNameReason("Full Name"));
|
||||
assertNull(FormUtils.invalidFieldNameReason("weird/[]{}"));
|
||||
assertNull(FormUtils.invalidFieldNameReason("Mr Smith"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void thePeriodRefusalDoesNotEchoControlCharacters() {
|
||||
// Both problems at once: the period branch must not leak the raw name into a log line.
|
||||
String reason = FormUtils.invalidFieldNameReason("Customer.Name\r\nFORGED");
|
||||
assertNotNull(reason);
|
||||
assertFalse(reason.contains("\r") || reason.contains("\n"), "no raw line break: " + reason);
|
||||
}
|
||||
|
||||
@Test
|
||||
void sanitizeForLogFlattensControlCharacters() {
|
||||
assertEquals("a b", FormUtils.sanitizeForLog("a\nb"));
|
||||
assertEquals("a b", FormUtils.sanitizeForLog("a\rb"));
|
||||
assertEquals("plain", FormUtils.sanitizeForLog("plain"));
|
||||
assertNull(FormUtils.sanitizeForLog(null));
|
||||
}
|
||||
|
||||
@Test
|
||||
void aPeriodIsStillRefusedWithTheOffendingCharacterNamed() {
|
||||
String reason = FormUtils.invalidFieldNameReason("Customer.Name");
|
||||
assertNotNull(reason);
|
||||
assertTrue(reason.contains("period"), "the message should name the problem: " + reason);
|
||||
}
|
||||
}
|
||||
+6
-4
@@ -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
|
||||
|
||||
+911
@@ -0,0 +1,911 @@
|
||||
package stirling.software.common.util;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import org.apache.pdfbox.Loader;
|
||||
import org.apache.pdfbox.cos.COSName;
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.apache.pdfbox.pdmodel.PDPage;
|
||||
import org.apache.pdfbox.pdmodel.PDResources;
|
||||
import org.apache.pdfbox.pdmodel.common.PDRectangle;
|
||||
import org.apache.pdfbox.pdmodel.interactive.annotation.PDAnnotationWidget;
|
||||
import org.apache.pdfbox.pdmodel.interactive.annotation.PDAppearanceDictionary;
|
||||
import org.apache.pdfbox.pdmodel.interactive.annotation.PDAppearanceEntry;
|
||||
import org.apache.pdfbox.pdmodel.interactive.form.PDAcroForm;
|
||||
import org.apache.pdfbox.pdmodel.interactive.form.PDCheckBox;
|
||||
import org.apache.pdfbox.pdmodel.interactive.form.PDField;
|
||||
import org.apache.pdfbox.pdmodel.interactive.form.PDNonTerminalField;
|
||||
import org.apache.pdfbox.pdmodel.interactive.form.PDRadioButton;
|
||||
import org.apache.pdfbox.pdmodel.interactive.form.PDSignatureField;
|
||||
import org.apache.pdfbox.pdmodel.interactive.form.PDTerminalField;
|
||||
import org.apache.pdfbox.pdmodel.interactive.form.PDTextField;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
/**
|
||||
* Guards the form editor against silently destroying a field it edits. Assertions run after a
|
||||
* save/reload cycle because only the serialised document reflects what a viewer sees.
|
||||
*/
|
||||
class FormUtilsEditRegressionTest {
|
||||
|
||||
private static PDAcroForm setupForm(PDDocument document) {
|
||||
document.addPage(new PDPage(PDRectangle.A4));
|
||||
PDAcroForm acroForm = new PDAcroForm(document);
|
||||
acroForm.setDefaultResources(new PDResources());
|
||||
document.getDocumentCatalog().setAcroForm(acroForm);
|
||||
return acroForm;
|
||||
}
|
||||
|
||||
private static byte[] save(PDDocument document) throws IOException {
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
document.save(baos);
|
||||
return baos.toByteArray();
|
||||
}
|
||||
|
||||
private static FormUtils.NewFormFieldDefinition newField(
|
||||
String type, String name, float x, float y, float w, float h, List<String> options) {
|
||||
return new FormUtils.NewFormFieldDefinition(
|
||||
name, null, type, 0, x, y, w, h, null, null, options, null, null, null, null, null,
|
||||
null, null);
|
||||
}
|
||||
|
||||
/** Moves a field to a rect; null width/height leave the size alone. */
|
||||
private static FormUtils.ModifyFormFieldDefinition moveTo(
|
||||
String target, float x, float y, Float w, Float h) {
|
||||
return new FormUtils.ModifyFormFieldDefinition(
|
||||
target, null, null, null, 0, x, y, w, h, null, null, null, null, null, null, null,
|
||||
null, null, null);
|
||||
}
|
||||
|
||||
private static PDRectangle firstWidgetRect(PDAcroForm acroForm, String name) {
|
||||
PDField field = acroForm.getField(name);
|
||||
assertNotNull(field, "field '" + name + "' should exist");
|
||||
return field.getWidgets().get(0).getRectangle();
|
||||
}
|
||||
|
||||
/** The /AP /N state names on a widget. */
|
||||
private static Set<String> normalStateNames(PDAnnotationWidget widget) {
|
||||
PDAppearanceDictionary appearance = widget.getAppearance();
|
||||
assertNotNull(appearance, "widget should have an /AP dictionary");
|
||||
PDAppearanceEntry normal = appearance.getNormalAppearance();
|
||||
assertNotNull(normal, "widget should have an /AP /N entry");
|
||||
assertTrue(normal.isSubDictionary(), "a toggle needs per-state appearances");
|
||||
return normal.getSubDictionary().keySet().stream()
|
||||
.map(COSName::getName)
|
||||
.collect(Collectors.toSet());
|
||||
}
|
||||
|
||||
@Test
|
||||
void movingCheckboxKeepsItFillable() throws IOException {
|
||||
byte[] saved;
|
||||
try (PDDocument document = new PDDocument()) {
|
||||
setupForm(document);
|
||||
FormUtils.addNewFields(
|
||||
document, List.of(newField("checkbox", "agree", 50, 700, 14, 14, null)));
|
||||
FormUtils.modifyFormFields(document, List.of(moveTo("agree", 200f, 400f, null, null)));
|
||||
saved = save(document);
|
||||
}
|
||||
|
||||
try (PDDocument reloaded = Loader.loadPDF(saved)) {
|
||||
PDAcroForm acroForm = reloaded.getDocumentCatalog().getAcroForm(null);
|
||||
PDField field = acroForm.getField("agree");
|
||||
assertTrue(field instanceof PDCheckBox, "'agree' should still be a checkbox");
|
||||
assertFalse(
|
||||
((PDCheckBox) field).getOnValue().isEmpty(),
|
||||
"a moved checkbox must keep an on-state, or it can never be ticked again");
|
||||
assertTrue(
|
||||
normalStateNames(field.getWidgets().get(0)).size() >= 2,
|
||||
"both /AP /N states must survive a move");
|
||||
PDRectangle rect = firstWidgetRect(acroForm, "agree");
|
||||
assertEquals(200f, rect.getLowerLeftX(), 0.5f);
|
||||
assertEquals(400f, rect.getLowerLeftY(), 0.5f);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void resizingCheckboxRebuildsAppearanceAtTheNewSize() throws IOException {
|
||||
byte[] saved;
|
||||
try (PDDocument document = new PDDocument()) {
|
||||
setupForm(document);
|
||||
FormUtils.addNewFields(
|
||||
document, List.of(newField("checkbox", "agree", 50, 700, 14, 14, null)));
|
||||
FormUtils.modifyFormFields(document, List.of(moveTo("agree", 50f, 700f, 28f, 28f)));
|
||||
saved = save(document);
|
||||
}
|
||||
|
||||
try (PDDocument reloaded = Loader.loadPDF(saved)) {
|
||||
PDAcroForm acroForm = reloaded.getDocumentCatalog().getAcroForm(null);
|
||||
PDCheckBox checkBox = (PDCheckBox) acroForm.getField("agree");
|
||||
assertFalse(
|
||||
checkBox.getOnValue().isEmpty(), "a resized checkbox must keep its on-state");
|
||||
PDAnnotationWidget widget = checkBox.getWidgets().get(0);
|
||||
assertTrue(normalStateNames(widget).size() >= 2, "both /AP /N states must be rebuilt");
|
||||
PDRectangle bbox =
|
||||
widget.getAppearance()
|
||||
.getNormalAppearance()
|
||||
.getSubDictionary()
|
||||
.get(COSName.getPDFName(checkBox.getOnValue()))
|
||||
.getBBox();
|
||||
assertEquals(28f, bbox.getWidth(), 0.5f, "the rebuilt /AP must match the new size");
|
||||
}
|
||||
}
|
||||
|
||||
/** applyToggleAppearance parks /AS on Off, so a resize must put the selection back. */
|
||||
@Test
|
||||
void resizingCheckboxKeepsItChecked() throws IOException {
|
||||
byte[] saved;
|
||||
try (PDDocument document = new PDDocument()) {
|
||||
setupForm(document);
|
||||
FormUtils.addNewFields(
|
||||
document, List.of(newField("checkbox", "agree", 50, 700, 14, 14, null)));
|
||||
PDAcroForm form = document.getDocumentCatalog().getAcroForm(null);
|
||||
((PDCheckBox) form.getField("agree")).check();
|
||||
FormUtils.modifyFormFields(document, List.of(moveTo("agree", 50f, 700f, 30f, 30f)));
|
||||
saved = save(document);
|
||||
}
|
||||
|
||||
try (PDDocument reloaded = Loader.loadPDF(saved)) {
|
||||
PDAcroForm acroForm = reloaded.getDocumentCatalog().getAcroForm(null);
|
||||
assertTrue(
|
||||
((PDCheckBox) acroForm.getField("agree")).isChecked(),
|
||||
"a resize must not silently untick the box");
|
||||
}
|
||||
}
|
||||
|
||||
/** Only widgets.get(0) used to move, so a radio group lost every option but the first. */
|
||||
@Test
|
||||
void movingRadioGroupMovesEveryOption() throws IOException {
|
||||
byte[] saved;
|
||||
float[] before = new float[6];
|
||||
try (PDDocument document = new PDDocument()) {
|
||||
setupForm(document);
|
||||
FormUtils.addNewFields(
|
||||
document,
|
||||
List.of(newField("radio", "choice", 50, 700, 14, 14, List.of("A", "B", "C"))));
|
||||
PDAcroForm form = document.getDocumentCatalog().getAcroForm(null);
|
||||
List<PDAnnotationWidget> widgets = form.getField("choice").getWidgets();
|
||||
assertEquals(3, widgets.size(), "the fixture needs three option widgets");
|
||||
for (int i = 0; i < 3; i++) {
|
||||
before[i * 2] = widgets.get(i).getRectangle().getLowerLeftX();
|
||||
before[i * 2 + 1] = widgets.get(i).getRectangle().getLowerLeftY();
|
||||
}
|
||||
FormUtils.modifyFormFields(document, List.of(moveTo("choice", 90f, 670f, null, null)));
|
||||
saved = save(document);
|
||||
}
|
||||
|
||||
try (PDDocument reloaded = Loader.loadPDF(saved)) {
|
||||
PDAcroForm acroForm = reloaded.getDocumentCatalog().getAcroForm(null);
|
||||
PDField field = acroForm.getField("choice");
|
||||
assertTrue(field instanceof PDRadioButton, "'choice' should still be a radio group");
|
||||
List<PDAnnotationWidget> widgets = field.getWidgets();
|
||||
assertEquals(3, widgets.size(), "no option may be left behind");
|
||||
float dx = 90f - before[0];
|
||||
float dy = 670f - before[1];
|
||||
for (int i = 0; i < 3; i++) {
|
||||
PDRectangle rect = widgets.get(i).getRectangle();
|
||||
assertEquals(
|
||||
before[i * 2] + dx,
|
||||
rect.getLowerLeftX(),
|
||||
0.5f,
|
||||
"option " + i + " should shift by the same delta");
|
||||
assertEquals(before[i * 2 + 1] + dy, rect.getLowerLeftY(), 0.5f);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** A signature's /AP is the signature, so it must never be dropped. */
|
||||
@Test
|
||||
void movingSignatureKeepsItsAppearance() throws IOException {
|
||||
byte[] saved;
|
||||
try (PDDocument document = new PDDocument()) {
|
||||
setupForm(document);
|
||||
FormUtils.addNewFields(
|
||||
document, List.of(newField("signature", "sig", 50, 700, 120, 40, null)));
|
||||
FormUtils.modifyFormFields(document, List.of(moveTo("sig", 60f, 600f, 140f, 50f)));
|
||||
saved = save(document);
|
||||
}
|
||||
|
||||
try (PDDocument reloaded = Loader.loadPDF(saved)) {
|
||||
PDAcroForm acroForm = reloaded.getDocumentCatalog().getAcroForm(null);
|
||||
assertTrue(
|
||||
acroForm.getField("sig") instanceof PDSignatureField,
|
||||
"'sig' should still be a signature");
|
||||
assertEquals(60f, firstWidgetRect(acroForm, "sig").getLowerLeftX(), 0.5f);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void invalidFieldNameReason_rejectsPeriodAndAllowsTheRest() {
|
||||
String reason = FormUtils.invalidFieldNameReason("Customer.Name");
|
||||
assertNotNull(reason, "a period must be refused, not silently dropped");
|
||||
assertTrue(reason.contains("period"), "the message should name the offending character");
|
||||
assertNull(FormUtils.invalidFieldNameReason("Has Space"));
|
||||
assertNull(FormUtils.invalidFieldNameReason("weird/[]{}"));
|
||||
assertNull(FormUtils.invalidFieldNameReason(null));
|
||||
}
|
||||
|
||||
/** Dropped operations used to log a warning and still report success. */
|
||||
@Test
|
||||
void applyFieldEdits_reportsEveryDroppedOperation() throws IOException {
|
||||
try (PDDocument document = new PDDocument()) {
|
||||
setupForm(document);
|
||||
FormUtils.addNewFields(
|
||||
document, List.of(newField("text", "present", 50, 700, 200, 20, null)));
|
||||
|
||||
List<FormUtils.SkippedFieldEdit> skipped = new ArrayList<>();
|
||||
FormUtils.applyFieldEdits(
|
||||
document,
|
||||
List.of(newField("text", "Bad.Name", 50, 600, 100, 20, null)),
|
||||
List.of(moveTo("ghost", 10f, 10f, null, null)),
|
||||
List.of("alsoGhost"),
|
||||
skipped);
|
||||
|
||||
assertEquals(3, skipped.size(), "each dropped operation should be reported");
|
||||
assertTrue(skipped.stream().anyMatch(s -> "add".equals(s.operation())));
|
||||
assertTrue(skipped.stream().anyMatch(s -> "modify".equals(s.operation())));
|
||||
assertTrue(skipped.stream().anyMatch(s -> "delete".equals(s.operation())));
|
||||
assertNotNull(
|
||||
document.getDocumentCatalog().getAcroForm(null).getField("present"),
|
||||
"the rest of the document must still be applied");
|
||||
}
|
||||
}
|
||||
|
||||
/** A clean batch must not report anything, or the UI would cry wolf on every save. */
|
||||
@Test
|
||||
void applyFieldEdits_reportsNothingWhenEverythingApplies() throws IOException {
|
||||
try (PDDocument document = new PDDocument()) {
|
||||
setupForm(document);
|
||||
List<FormUtils.SkippedFieldEdit> skipped = new ArrayList<>();
|
||||
FormUtils.applyFieldEdits(
|
||||
document,
|
||||
List.of(newField("text", "fine", 50, 700, 200, 20, null)),
|
||||
List.of(),
|
||||
List.of(),
|
||||
skipped);
|
||||
assertTrue(skipped.isEmpty(), "a fully applied batch reports no skips");
|
||||
}
|
||||
}
|
||||
|
||||
/** A drag must not normalise other options to the dragged widget's size. */
|
||||
@Test
|
||||
void movingRadioGroupKeepsEachOptionsOwnSize() throws IOException {
|
||||
byte[] saved;
|
||||
try (PDDocument document = new PDDocument()) {
|
||||
setupForm(document);
|
||||
FormUtils.addNewFields(
|
||||
document,
|
||||
List.of(newField("radio", "choice", 50, 700, 20, 20, List.of("A", "B"))));
|
||||
PDAcroForm form = document.getDocumentCatalog().getAcroForm(null);
|
||||
List<PDAnnotationWidget> widgets = form.getField("choice").getWidgets();
|
||||
// Hand-authored groups legitimately have option boxes of differing size.
|
||||
PDRectangle second = widgets.get(1).getRectangle();
|
||||
widgets.get(1)
|
||||
.setRectangle(
|
||||
new PDRectangle(
|
||||
second.getLowerLeftX(), second.getLowerLeftY(), 40f, 40f));
|
||||
FormUtils.modifyFormFields(document, List.of(moveTo("choice", 90f, 700f, 20f, 20f)));
|
||||
saved = save(document);
|
||||
}
|
||||
|
||||
try (PDDocument reloaded = Loader.loadPDF(saved)) {
|
||||
PDAcroForm acroForm = reloaded.getDocumentCatalog().getAcroForm(null);
|
||||
List<PDAnnotationWidget> widgets = acroForm.getField("choice").getWidgets();
|
||||
assertEquals(
|
||||
40f,
|
||||
widgets.get(1).getRectangle().getWidth(),
|
||||
0.5f,
|
||||
"a pure drag must not shrink the other options");
|
||||
assertEquals(90f, widgets.get(0).getRectangle().getLowerLeftX(), 0.5f);
|
||||
}
|
||||
}
|
||||
|
||||
/** With no /AP and no /Opt the on-state must come from /V, not the invented "Yes". */
|
||||
@Test
|
||||
void resizingCheckboxWithoutAppearanceKeepsItsExportValue() throws IOException {
|
||||
byte[] saved;
|
||||
try (PDDocument document = new PDDocument()) {
|
||||
setupForm(document);
|
||||
FormUtils.addNewFields(
|
||||
document, List.of(newField("checkbox", "agree", 50, 700, 14, 14, null)));
|
||||
PDAcroForm form = document.getDocumentCatalog().getAcroForm(null);
|
||||
PDCheckBox box = (PDCheckBox) form.getField("agree");
|
||||
// A NeedAppearances form exported by Word/LibreOffice looks exactly like this.
|
||||
box.getWidgets().get(0).getCOSObject().removeItem(COSName.AP);
|
||||
box.getCOSObject().setItem(COSName.V, COSName.getPDFName("On"));
|
||||
FormUtils.modifyFormFields(document, List.of(moveTo("agree", 50f, 700f, 30f, 30f)));
|
||||
saved = save(document);
|
||||
}
|
||||
|
||||
try (PDDocument reloaded = Loader.loadPDF(saved)) {
|
||||
PDAcroForm acroForm = reloaded.getDocumentCatalog().getAcroForm(null);
|
||||
PDCheckBox box = (PDCheckBox) acroForm.getField("agree");
|
||||
assertEquals(
|
||||
"On",
|
||||
box.getOnValue(),
|
||||
"the export value must survive; inventing 'Yes' would orphan /V");
|
||||
assertTrue(box.isChecked(), "the box was ticked and must stay ticked");
|
||||
}
|
||||
}
|
||||
|
||||
/** Renaming to the same qualified name is not a rename, so a nested field is not rejected. */
|
||||
@Test
|
||||
void renameProblem_ignoresAnUnchangedQualifiedName() {
|
||||
assertNull(
|
||||
FormUtils.renameProblem("Customer.Name", "Customer.Name"),
|
||||
"a field standing still must not be rejected for its parent's period");
|
||||
assertNull(FormUtils.renameProblem("plain", null));
|
||||
assertNotNull(
|
||||
FormUtils.renameProblem("plain", "New.Name"),
|
||||
"an actual rename introducing a period must still be refused");
|
||||
}
|
||||
|
||||
/** A nested field whose name box was left at its qualified name must still be modified. */
|
||||
@Test
|
||||
void modifyingNestedFieldKeepsWorkingWhenNameIsUntouched() throws IOException {
|
||||
byte[] saved;
|
||||
try (PDDocument document = new PDDocument()) {
|
||||
PDAcroForm form = setupForm(document);
|
||||
FormUtils.addNewFields(
|
||||
document, List.of(newField("text", "Name", 50, 700, 200, 20, null)));
|
||||
// Re-parent it so its qualified name legitimately contains a period.
|
||||
PDNonTerminalField parent = new PDNonTerminalField(form);
|
||||
parent.setPartialName("Customer");
|
||||
PDField child = form.getField("Name");
|
||||
parent.setChildren(List.of(child));
|
||||
child.getCOSObject().setItem(COSName.PARENT, parent.getCOSObject());
|
||||
form.setFields(List.of(parent));
|
||||
|
||||
FormUtils.ModifyFormFieldDefinition mod =
|
||||
new FormUtils.ModifyFormFieldDefinition(
|
||||
"Customer.Name",
|
||||
"Customer.Name",
|
||||
null,
|
||||
null,
|
||||
0,
|
||||
90f,
|
||||
600f,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null);
|
||||
List<FormUtils.SkippedFieldEdit> skipped = new ArrayList<>();
|
||||
FormUtils.modifyFormFields(document, List.of(mod), skipped);
|
||||
assertTrue(
|
||||
skipped.isEmpty(), "an untouched qualified name is not a rename: " + skipped);
|
||||
saved = save(document);
|
||||
}
|
||||
|
||||
try (PDDocument reloaded = Loader.loadPDF(saved)) {
|
||||
PDAcroForm acroForm = reloaded.getDocumentCatalog().getAcroForm(null);
|
||||
PDField field = acroForm.getField("Customer.Name");
|
||||
assertNotNull(field, "the nested field must survive the edit");
|
||||
assertEquals(90f, field.getWidgets().get(0).getRectangle().getLowerLeftX(), 0.5f);
|
||||
}
|
||||
}
|
||||
|
||||
/** Zero clears /MaxLen; null means unchanged, so it could never be removed otherwise. */
|
||||
@Test
|
||||
void maxLengthZeroClearsTheCombSetting() throws IOException {
|
||||
byte[] saved;
|
||||
try (PDDocument document = new PDDocument()) {
|
||||
setupForm(document);
|
||||
FormUtils.addNewFields(
|
||||
document,
|
||||
List.of(
|
||||
new FormUtils.NewFormFieldDefinition(
|
||||
"code", null, "text", 0, 50f, 700f, 200f, 20f, null, null, null,
|
||||
null, null, null, null, null, 8, null)));
|
||||
PDAcroForm form = document.getDocumentCatalog().getAcroForm(null);
|
||||
assertEquals(8, ((PDTextField) form.getField("code")).getMaxLen());
|
||||
|
||||
FormUtils.ModifyFormFieldDefinition clear =
|
||||
new FormUtils.ModifyFormFieldDefinition(
|
||||
"code", null, null, null, null, null, null, null, null, null, null,
|
||||
null, null, null, null, null, null, 0, null);
|
||||
FormUtils.modifyFormFields(document, List.of(clear));
|
||||
saved = save(document);
|
||||
}
|
||||
|
||||
try (PDDocument reloaded = Loader.loadPDF(saved)) {
|
||||
PDAcroForm acroForm = reloaded.getDocumentCatalog().getAcroForm(null);
|
||||
assertEquals(
|
||||
-1,
|
||||
((PDTextField) acroForm.getField("code")).getMaxLen(),
|
||||
"/MaxLen should be gone, not merely zero");
|
||||
}
|
||||
}
|
||||
|
||||
/** An unrecognised button action must be reported rather than silently ignored. */
|
||||
@Test
|
||||
void unknownButtonActionIsReported() throws IOException {
|
||||
try (PDDocument document = new PDDocument()) {
|
||||
setupForm(document);
|
||||
FormUtils.addNewFields(
|
||||
document, List.of(newField("button", "go", 50, 700, 100, 24, null)));
|
||||
|
||||
FormUtils.ModifyFormFieldDefinition mod =
|
||||
new FormUtils.ModifyFormFieldDefinition(
|
||||
"go",
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
"launchTheMissiles");
|
||||
List<FormUtils.SkippedFieldEdit> skipped = new ArrayList<>();
|
||||
FormUtils.modifyFormFields(document, List.of(mod), skipped);
|
||||
|
||||
assertEquals(1, skipped.size(), "an unusable action spec should be reported");
|
||||
assertTrue(skipped.get(0).reason().contains("launchTheMissiles"));
|
||||
}
|
||||
}
|
||||
|
||||
/** Renaming a nested field must not re-parent it to the top level. */
|
||||
@Test
|
||||
void renamingNestedFieldKeepsItUnderItsParent() throws IOException {
|
||||
byte[] saved;
|
||||
try (PDDocument document = new PDDocument()) {
|
||||
PDAcroForm form = setupForm(document);
|
||||
FormUtils.addNewFields(
|
||||
document, List.of(newField("text", "Name", 50, 700, 200, 20, null)));
|
||||
PDNonTerminalField parent = new PDNonTerminalField(form);
|
||||
parent.setPartialName("Customer");
|
||||
PDField child = form.getField("Name");
|
||||
parent.setChildren(List.of(child));
|
||||
child.getCOSObject().setItem(COSName.PARENT, parent.getCOSObject());
|
||||
form.setFields(List.of(parent));
|
||||
|
||||
FormUtils.ModifyFormFieldDefinition rename =
|
||||
new FormUtils.ModifyFormFieldDefinition(
|
||||
"Customer.Name",
|
||||
"Customer.Phone",
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null);
|
||||
List<FormUtils.SkippedFieldEdit> skipped = new ArrayList<>();
|
||||
FormUtils.modifyFormFields(document, List.of(rename), skipped);
|
||||
assertTrue(
|
||||
skipped.isEmpty(), "a leaf rename under the same parent is legal: " + skipped);
|
||||
saved = save(document);
|
||||
}
|
||||
|
||||
try (PDDocument reloaded = Loader.loadPDF(saved)) {
|
||||
PDAcroForm acroForm = reloaded.getDocumentCatalog().getAcroForm(null);
|
||||
assertNotNull(
|
||||
acroForm.getField("Customer.Phone"),
|
||||
"the field should still live under Customer, not at the top level");
|
||||
assertNull(acroForm.getField("Customer.Name"), "the old name should be gone");
|
||||
}
|
||||
}
|
||||
|
||||
/** One rejected action on a multi-widget button is one report, not one per widget. */
|
||||
@Test
|
||||
void unknownButtonActionIsReportedOncePerField() throws IOException {
|
||||
try (PDDocument document = new PDDocument()) {
|
||||
setupForm(document);
|
||||
FormUtils.addNewFields(
|
||||
document, List.of(newField("button", "go", 50, 700, 100, 24, null)));
|
||||
PDAcroForm form = document.getDocumentCatalog().getAcroForm(null);
|
||||
PDField button = form.getField("go");
|
||||
// Give it a second widget, as a button repeated on two pages would have.
|
||||
PDAnnotationWidget extra = new PDAnnotationWidget();
|
||||
extra.setRectangle(new PDRectangle(50, 600, 100, 24));
|
||||
extra.getCOSObject().setItem(COSName.PARENT, button.getCOSObject());
|
||||
List<PDAnnotationWidget> widgets = new ArrayList<>(button.getWidgets());
|
||||
widgets.add(extra);
|
||||
button.getCOSObject()
|
||||
.setItem(
|
||||
COSName.KIDS,
|
||||
new org.apache.pdfbox.cos.COSArray() {
|
||||
{
|
||||
for (PDAnnotationWidget w : widgets) add(w.getCOSObject());
|
||||
}
|
||||
});
|
||||
|
||||
FormUtils.ModifyFormFieldDefinition mod =
|
||||
new FormUtils.ModifyFormFieldDefinition(
|
||||
"go",
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
"launchTheMissiles");
|
||||
List<FormUtils.SkippedFieldEdit> skipped = new ArrayList<>();
|
||||
FormUtils.modifyFormFields(document, List.of(mod), skipped);
|
||||
|
||||
assertEquals(1, skipped.size(), "one field, one report: " + skipped);
|
||||
}
|
||||
}
|
||||
|
||||
/** A clamped page index still creates the field, so it is not a dropped edit. */
|
||||
@Test
|
||||
void clampedPageIsNotReportedAsSkipped() throws IOException {
|
||||
try (PDDocument document = new PDDocument()) {
|
||||
setupForm(document);
|
||||
List<FormUtils.SkippedFieldEdit> skipped = new ArrayList<>();
|
||||
FormUtils.addNewFields(
|
||||
document,
|
||||
List.of(
|
||||
new FormUtils.NewFormFieldDefinition(
|
||||
"late", null, "text", 9, 50f, 700f, 100f, 20f, null, null, null,
|
||||
null, null, null, null, null, null, null)),
|
||||
skipped);
|
||||
|
||||
assertNotNull(
|
||||
document.getDocumentCatalog().getAcroForm(null).getField("late"),
|
||||
"the field is created on the clamped page");
|
||||
assertTrue(skipped.isEmpty(), "an applied edit must not appear as skipped: " + skipped);
|
||||
}
|
||||
}
|
||||
|
||||
/** Recreation builds a top-level field, so it must refuse rather than re-parent. */
|
||||
@Test
|
||||
void typeChangeOnNestedFieldIsRefusedNotSilentlyReparented() throws IOException {
|
||||
byte[] saved;
|
||||
try (PDDocument document = new PDDocument()) {
|
||||
PDAcroForm form = setupForm(document);
|
||||
FormUtils.addNewFields(
|
||||
document, List.of(newField("text", "Name", 50, 700, 200, 20, null)));
|
||||
PDNonTerminalField parent = new PDNonTerminalField(form);
|
||||
parent.setPartialName("Customer");
|
||||
PDField child = form.getField("Name");
|
||||
parent.setChildren(List.of(child));
|
||||
child.getCOSObject().setItem(COSName.PARENT, parent.getCOSObject());
|
||||
form.setFields(List.of(parent));
|
||||
|
||||
FormUtils.ModifyFormFieldDefinition retype =
|
||||
new FormUtils.ModifyFormFieldDefinition(
|
||||
"Customer.Name",
|
||||
null,
|
||||
null,
|
||||
"checkbox",
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null);
|
||||
List<FormUtils.SkippedFieldEdit> skipped = new ArrayList<>();
|
||||
FormUtils.modifyFormFields(document, List.of(retype), skipped);
|
||||
|
||||
assertEquals(1, skipped.size(), "the refusal must be reported: " + skipped);
|
||||
saved = save(document);
|
||||
}
|
||||
|
||||
try (PDDocument reloaded = Loader.loadPDF(saved)) {
|
||||
PDAcroForm acroForm = reloaded.getDocumentCatalog().getAcroForm(null);
|
||||
assertNotNull(
|
||||
acroForm.getField("Customer.Name"),
|
||||
"the original nested field must be left intact");
|
||||
assertNull(acroForm.getField("Name"), "nothing should be re-parented to the top level");
|
||||
}
|
||||
}
|
||||
|
||||
/** The editor emits "uri:" the moment that kind is picked, which must not fail the edit. */
|
||||
@Test
|
||||
void incompleteUrlActionClearsRatherThanFailing() throws IOException {
|
||||
try (PDDocument document = new PDDocument()) {
|
||||
setupForm(document);
|
||||
FormUtils.addNewFields(
|
||||
document, List.of(newField("button", "go", 50, 700, 100, 24, null)));
|
||||
|
||||
FormUtils.ModifyFormFieldDefinition pickUri =
|
||||
new FormUtils.ModifyFormFieldDefinition(
|
||||
"go", null, null, null, null, null, null, null, null, null, null, null,
|
||||
null, null, null, null, null, null, "uri:");
|
||||
List<FormUtils.SkippedFieldEdit> skipped = new ArrayList<>();
|
||||
FormUtils.modifyFormFields(document, List.of(pickUri), skipped);
|
||||
|
||||
assertTrue(
|
||||
skipped.isEmpty(),
|
||||
"choosing a URL action before typing the URL is not an error: " + skipped);
|
||||
PDField button = document.getDocumentCatalog().getAcroForm(null).getField("go");
|
||||
assertNull(
|
||||
button.getWidgets().get(0).getCOSObject().getDictionaryObject(COSName.A),
|
||||
"an empty target must leave no action behind");
|
||||
}
|
||||
}
|
||||
|
||||
/** A real URL still writes a real action. */
|
||||
@Test
|
||||
void completeUrlActionIsApplied() throws IOException {
|
||||
try (PDDocument document = new PDDocument()) {
|
||||
setupForm(document);
|
||||
FormUtils.addNewFields(
|
||||
document, List.of(newField("button", "go", 50, 700, 100, 24, null)));
|
||||
|
||||
FormUtils.ModifyFormFieldDefinition setUri =
|
||||
new FormUtils.ModifyFormFieldDefinition(
|
||||
"go",
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
"uri:https://example.com");
|
||||
List<FormUtils.SkippedFieldEdit> skipped = new ArrayList<>();
|
||||
FormUtils.modifyFormFields(document, List.of(setUri), skipped);
|
||||
|
||||
assertTrue(skipped.isEmpty(), "a complete spec applies cleanly: " + skipped);
|
||||
PDField button = document.getDocumentCatalog().getAcroForm(null).getField("go");
|
||||
assertNotNull(
|
||||
button.getWidgets().get(0).getCOSObject().getDictionaryObject(COSName.A),
|
||||
"the action should be written");
|
||||
}
|
||||
}
|
||||
|
||||
/** Builds a parent with the given terminal children already attached. */
|
||||
private static PDNonTerminalField nest(
|
||||
PDDocument document, PDAcroForm form, String parentName, String... childNames)
|
||||
throws IOException {
|
||||
List<FormUtils.NewFormFieldDefinition> defs = new ArrayList<>();
|
||||
for (int i = 0; i < childNames.length; i++) {
|
||||
defs.add(newField("text", childNames[i], 50, 700 - i * 40, 200, 20, null));
|
||||
}
|
||||
FormUtils.addNewFields(document, defs);
|
||||
|
||||
PDNonTerminalField parent = new PDNonTerminalField(form);
|
||||
parent.setPartialName(parentName);
|
||||
List<PDField> kids = new ArrayList<>();
|
||||
for (String child : childNames) {
|
||||
PDField field = form.getField(child);
|
||||
field.getCOSObject().setItem(COSName.PARENT, parent.getCOSObject());
|
||||
kids.add(field);
|
||||
}
|
||||
parent.setChildren(kids);
|
||||
form.setFields(List.of(parent));
|
||||
return parent;
|
||||
}
|
||||
|
||||
/** A refused edit must not release the name the field still really has. */
|
||||
@Test
|
||||
void refusedNestedEditDoesNotFreeItsNameForALaterEdit() throws IOException {
|
||||
try (PDDocument document = new PDDocument()) {
|
||||
PDAcroForm form = setupForm(document);
|
||||
nest(document, form, "Customer", "Name", "Email");
|
||||
|
||||
// Edit 1 is refused (type change on a nested field). Edit 2 then asks for the
|
||||
// name edit 1 still occupies, which must not be handed out.
|
||||
FormUtils.ModifyFormFieldDefinition refused =
|
||||
new FormUtils.ModifyFormFieldDefinition(
|
||||
"Customer.Name",
|
||||
"Customer.Foo",
|
||||
null,
|
||||
"checkbox",
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null);
|
||||
FormUtils.ModifyFormFieldDefinition rename =
|
||||
new FormUtils.ModifyFormFieldDefinition(
|
||||
"Customer.Email",
|
||||
"Customer.Name",
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null);
|
||||
|
||||
List<FormUtils.SkippedFieldEdit> skipped = new ArrayList<>();
|
||||
FormUtils.modifyFormFields(document, List.of(refused, rename), skipped);
|
||||
|
||||
List<String> names = new ArrayList<>();
|
||||
for (PDField f : document.getDocumentCatalog().getAcroForm(null).getFieldTree()) {
|
||||
if (f instanceof PDTerminalField) names.add(f.getFullyQualifiedName());
|
||||
}
|
||||
assertEquals(
|
||||
names.size(),
|
||||
new java.util.HashSet<>(names).size(),
|
||||
"two fields must never share a qualified name: " + names);
|
||||
assertTrue(
|
||||
names.contains("Customer.Name"), "the refused field keeps its name: " + names);
|
||||
}
|
||||
}
|
||||
|
||||
/** A group name occupies the namespace, so a new field must not be able to take it. */
|
||||
@Test
|
||||
void groupNamesParticipateInCollisionChecks() throws IOException {
|
||||
try (PDDocument document = new PDDocument()) {
|
||||
PDAcroForm form = setupForm(document);
|
||||
nest(document, form, "Customer", "Name");
|
||||
|
||||
FormUtils.addNewFields(
|
||||
document, List.of(newField("text", "Customer", 50, 500, 100, 20, null)));
|
||||
|
||||
List<String> names = new ArrayList<>();
|
||||
for (PDField f : document.getDocumentCatalog().getAcroForm(null).getFieldTree()) {
|
||||
String fqn = f.getFullyQualifiedName();
|
||||
if (fqn != null) names.add(fqn);
|
||||
}
|
||||
assertEquals(
|
||||
names.size(),
|
||||
new java.util.HashSet<>(names).size(),
|
||||
"the new field must not take the group's name: " + names);
|
||||
}
|
||||
}
|
||||
|
||||
/** "Customer." has no leaf, so it must be refused rather than become "Customer.field". */
|
||||
@Test
|
||||
void renameToBareParentPrefixIsRefused() {
|
||||
assertNotNull(
|
||||
FormUtils.renameProblem("Customer.Name", "Customer."),
|
||||
"a name with nothing after the parent prefix is not a rename");
|
||||
assertNull(FormUtils.renameProblem("Customer.Name", "Customer.Phone"));
|
||||
}
|
||||
|
||||
/** A type change must leave the field on its own page, not relocate it to the last one. */
|
||||
@Test
|
||||
void typeChangeKeepsTheFieldOnItsPage() throws IOException {
|
||||
byte[] saved;
|
||||
try (PDDocument document = new PDDocument()) {
|
||||
PDAcroForm form = new PDAcroForm(document);
|
||||
for (int i = 0; i < 5; i++) {
|
||||
document.addPage(new PDPage(PDRectangle.A4));
|
||||
}
|
||||
form.setDefaultResources(new PDResources());
|
||||
document.getDocumentCatalog().setAcroForm(form);
|
||||
|
||||
FormUtils.addNewFields(
|
||||
document,
|
||||
List.of(
|
||||
new FormUtils.NewFormFieldDefinition(
|
||||
"onPageTwo",
|
||||
null,
|
||||
"text",
|
||||
1,
|
||||
50f,
|
||||
700f,
|
||||
200f,
|
||||
20f,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null)));
|
||||
|
||||
FormUtils.ModifyFormFieldDefinition retype =
|
||||
new FormUtils.ModifyFormFieldDefinition(
|
||||
"onPageTwo",
|
||||
null,
|
||||
null,
|
||||
"checkbox",
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null);
|
||||
FormUtils.modifyFormFields(document, List.of(retype));
|
||||
saved = save(document);
|
||||
}
|
||||
|
||||
try (PDDocument reloaded = Loader.loadPDF(saved)) {
|
||||
PDAcroForm acroForm = reloaded.getDocumentCatalog().getAcroForm(null);
|
||||
PDField field = acroForm.getField("onPageTwo");
|
||||
assertNotNull(field, "the retyped field should exist");
|
||||
int page = -1;
|
||||
for (int i = 0; i < reloaded.getNumberOfPages(); i++) {
|
||||
for (var annot : reloaded.getPage(i).getAnnotations()) {
|
||||
if (annot.getCOSObject() == field.getWidgets().get(0).getCOSObject()) page = i;
|
||||
}
|
||||
}
|
||||
assertEquals(
|
||||
1, page, "a retyped field must stay on its own page, not move to the last");
|
||||
}
|
||||
}
|
||||
}
|
||||
+118
@@ -0,0 +1,118 @@
|
||||
package stirling.software.common.util;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
import org.apache.pdfbox.Loader;
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.apache.pdfbox.pdmodel.PDPage;
|
||||
import org.apache.pdfbox.pdmodel.common.PDRectangle;
|
||||
import org.apache.pdfbox.pdmodel.interactive.form.PDAcroForm;
|
||||
import org.apache.pdfbox.pdmodel.interactive.form.PDCheckBox;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
/** An edit the backend cannot honour must be reported, not logged and reported as success. */
|
||||
class FormUtilsEditReportingTest {
|
||||
|
||||
private static FormUtils.NewFormFieldDefinition field(String type, String name) {
|
||||
return new FormUtils.NewFormFieldDefinition(
|
||||
name, name, type, 0, 60f, 700f, 120f, 20f, null, null, null, null, null, null, null,
|
||||
null, null, null);
|
||||
}
|
||||
|
||||
private static PDDocument blank() {
|
||||
PDDocument document = new PDDocument();
|
||||
document.addPage(new PDPage(PDRectangle.LETTER));
|
||||
document.getDocumentCatalog().setAcroForm(new PDAcroForm(document));
|
||||
return document;
|
||||
}
|
||||
|
||||
@Test
|
||||
void anUncreatableTypeIsReportedRatherThanSilentlyMadeText() throws IOException {
|
||||
List<FormUtils.SkippedFieldEdit> skipped = new ArrayList<>();
|
||||
try (PDDocument document = blank()) {
|
||||
FormUtils.addNewFields(document, List.of(field("nonsense", "mystery")), skipped);
|
||||
PDAcroForm acroForm = document.getDocumentCatalog().getAcroForm(null);
|
||||
assertTrue(
|
||||
acroForm.getFields().isEmpty(),
|
||||
"an unsupported type must not quietly become a text field");
|
||||
}
|
||||
assertEquals(1, skipped.size(), "the caller must be told: " + skipped);
|
||||
assertTrue(skipped.get(0).reason().contains("nonsense"), skipped.get(0).reason());
|
||||
}
|
||||
|
||||
@Test
|
||||
void aLyingPageCountIsSurvivable() throws IOException {
|
||||
// /Count overstates the tree, so getNumberOfPages() passes the guard but getPage throws.
|
||||
byte[] broken =
|
||||
("%PDF-1.4\n"
|
||||
+ "1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n"
|
||||
+ "2 0 obj << /Type /Pages /Count 1 /Kids [] >> endobj\n"
|
||||
+ "trailer << /Root 1 0 R >>\n")
|
||||
.getBytes(java.nio.charset.StandardCharsets.ISO_8859_1);
|
||||
List<FormUtils.SkippedFieldEdit> skipped = new ArrayList<>();
|
||||
try (PDDocument document = Loader.loadPDF(broken)) {
|
||||
// Must not throw; the field is reported as skipped instead.
|
||||
FormUtils.addNewFields(document, List.of(field("text", "ghost")), skipped);
|
||||
} catch (IOException loadFailure) {
|
||||
// A parser that refuses the file outright is an equally acceptable outcome.
|
||||
return;
|
||||
}
|
||||
assertFalse(skipped.isEmpty(), "an unreachable page must be reported, not thrown");
|
||||
}
|
||||
|
||||
@Test
|
||||
void aTwoWidgetCheckboxKeepsItsOnStateWhenMoved() throws IOException {
|
||||
byte[] saved;
|
||||
try (PDDocument document = new PDDocument()) {
|
||||
document.addPage(new PDPage(PDRectangle.LETTER));
|
||||
document.addPage(new PDPage(PDRectangle.LETTER));
|
||||
document.getDocumentCatalog().setAcroForm(new PDAcroForm(document));
|
||||
FormUtils.addNewFields(
|
||||
document,
|
||||
List.of(
|
||||
new FormUtils.NewFormFieldDefinition(
|
||||
"agree",
|
||||
"agree",
|
||||
"checkbox",
|
||||
0,
|
||||
60f,
|
||||
700f,
|
||||
14f,
|
||||
14f,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null)),
|
||||
new ArrayList<>());
|
||||
FormUtils.modifyFormFields(
|
||||
document,
|
||||
List.of(
|
||||
new FormUtils.ModifyFormFieldDefinition(
|
||||
"agree", null, null, null, 0, 200f, 400f, null, null, null,
|
||||
null, null, null, null, null, null, null, null, null)));
|
||||
ByteArrayOutputStream out = new ByteArrayOutputStream();
|
||||
document.save(out);
|
||||
saved = out.toByteArray();
|
||||
}
|
||||
try (PDDocument reloaded = Loader.loadPDF(saved)) {
|
||||
PDAcroForm acroForm = reloaded.getDocumentCatalog().getAcroForm(null);
|
||||
PDCheckBox box = (PDCheckBox) acroForm.getField("agree");
|
||||
assertNotNull(box);
|
||||
assertFalse(box.getOnValue().isEmpty(), "a moved checkbox must stay tickable");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,467 @@
|
||||
package stirling.software.common.util;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
import org.apache.pdfbox.Loader;
|
||||
import org.apache.pdfbox.cos.COSName;
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.apache.pdfbox.pdmodel.PDPage;
|
||||
import org.apache.pdfbox.pdmodel.PDResources;
|
||||
import org.apache.pdfbox.pdmodel.common.PDRectangle;
|
||||
import org.apache.pdfbox.pdmodel.interactive.annotation.PDAnnotationWidget;
|
||||
import org.apache.pdfbox.pdmodel.interactive.annotation.PDAppearanceDictionary;
|
||||
import org.apache.pdfbox.pdmodel.interactive.annotation.PDAppearanceEntry;
|
||||
import org.apache.pdfbox.pdmodel.interactive.form.PDAcroForm;
|
||||
import org.apache.pdfbox.pdmodel.interactive.form.PDCheckBox;
|
||||
import org.apache.pdfbox.pdmodel.interactive.form.PDField;
|
||||
import org.apache.pdfbox.pdmodel.interactive.form.PDPushButton;
|
||||
import org.apache.pdfbox.pdmodel.interactive.form.PDRadioButton;
|
||||
import org.apache.pdfbox.pdmodel.interactive.form.PDSignatureField;
|
||||
import org.apache.pdfbox.pdmodel.interactive.form.PDTextField;
|
||||
import org.apache.pdfbox.pdmodel.interactive.form.PDVariableText;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
/**
|
||||
* Assertions run after a save/reload cycle: PDFBox synthesises widgets for fields with no explicit
|
||||
* {@code /Kids}, so only the serialised document reflects what a viewer sees.
|
||||
*/
|
||||
class FormUtilsEditingTest {
|
||||
|
||||
private static PDAcroForm setupForm(PDDocument document, PDRectangle pageSize) {
|
||||
PDPage page = new PDPage(pageSize);
|
||||
document.addPage(page);
|
||||
PDAcroForm acroForm = new PDAcroForm(document);
|
||||
acroForm.setDefaultResources(new PDResources());
|
||||
document.getDocumentCatalog().setAcroForm(acroForm);
|
||||
return acroForm;
|
||||
}
|
||||
|
||||
private static byte[] save(PDDocument document) throws IOException {
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
document.save(baos);
|
||||
return baos.toByteArray();
|
||||
}
|
||||
|
||||
private static FormUtils.NewFormFieldDefinition newText(
|
||||
String name, float x, float y, float w, float h) {
|
||||
return new FormUtils.NewFormFieldDefinition(
|
||||
name, null, "text", 0, x, y, w, h, null, null, null, null, null, null, null, null,
|
||||
null, null);
|
||||
}
|
||||
|
||||
private static FormUtils.NewFormFieldDefinition newField(
|
||||
String type,
|
||||
String name,
|
||||
float x,
|
||||
float y,
|
||||
float w,
|
||||
float h,
|
||||
List<String> options,
|
||||
Integer maxLength,
|
||||
String buttonAction) {
|
||||
return new FormUtils.NewFormFieldDefinition(
|
||||
name,
|
||||
null,
|
||||
type,
|
||||
0,
|
||||
x,
|
||||
y,
|
||||
w,
|
||||
h,
|
||||
null,
|
||||
null,
|
||||
options,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
maxLength,
|
||||
buttonAction);
|
||||
}
|
||||
|
||||
private static PDRectangle firstWidgetRect(PDAcroForm acroForm, String name) {
|
||||
PDField field = acroForm.getField(name);
|
||||
assertNotNull(field, "field '" + name + "' should exist");
|
||||
assertTrue(!field.getWidgets().isEmpty(), "field should have at least one widget");
|
||||
return field.getWidgets().get(0).getRectangle();
|
||||
}
|
||||
|
||||
/**
|
||||
* PDAcroForm.refreshAppearances() never synthesizes /AP for the button family, so without an
|
||||
* explicit appearance a created checkbox or radio renders blank and resolves to Off.
|
||||
*/
|
||||
@Test
|
||||
void addNewFields_givesToggleFieldsAppearanceStreamsAndKeepsTheirDefault() throws IOException {
|
||||
byte[] saved;
|
||||
try (PDDocument document = new PDDocument()) {
|
||||
setupForm(document, PDRectangle.A4);
|
||||
FormUtils.addNewFields(
|
||||
document,
|
||||
List.of(
|
||||
newField("checkbox", "agree", 50, 600, 20, 20, null, null, null),
|
||||
newField(
|
||||
"radio",
|
||||
"choice",
|
||||
50,
|
||||
500,
|
||||
20,
|
||||
20,
|
||||
List.of("Yes", "No"),
|
||||
null,
|
||||
null),
|
||||
newText("fullname", 50, 400, 200, 24)));
|
||||
saved = save(document);
|
||||
}
|
||||
|
||||
try (PDDocument reloaded = Loader.loadPDF(saved)) {
|
||||
PDAcroForm acroForm = reloaded.getDocumentCatalog().getAcroForm(null);
|
||||
assertNotNull(acroForm);
|
||||
|
||||
// NeedAppearances=false means viewers trust our streams, so they must exist.
|
||||
assertFalse(acroForm.getNeedAppearances(), "appearance generation should have run");
|
||||
|
||||
PDField checkBox = acroForm.getField("agree");
|
||||
assertTrue(checkBox instanceof PDCheckBox);
|
||||
assertEquals(
|
||||
Set.of("Off", "Yes"),
|
||||
normalStateNames(checkBox.getWidgets().get(0)),
|
||||
"checkbox needs an Off and an on-state appearance");
|
||||
|
||||
PDField radio = acroForm.getField("choice");
|
||||
assertTrue(radio instanceof PDRadioButton);
|
||||
assertEquals(2, radio.getWidgets().size());
|
||||
assertEquals(Set.of("Off", "Yes"), normalStateNames(radio.getWidgets().get(0)));
|
||||
assertEquals(Set.of("Off", "No"), normalStateNames(radio.getWidgets().get(1)));
|
||||
|
||||
// A text field's DA names /Helv; if /DR lacks that alias refreshAppearances throws for
|
||||
// the whole form and every field above loses its appearance too.
|
||||
PDField text = acroForm.getField("fullname");
|
||||
assertNotNull(
|
||||
text.getWidgets().get(0).getAppearance().getNormalAppearance(),
|
||||
"text field should have a generated appearance");
|
||||
}
|
||||
}
|
||||
|
||||
/** The /AP /N state names on a widget. */
|
||||
private static Set<String> normalStateNames(PDAnnotationWidget widget) {
|
||||
PDAppearanceDictionary appearance = widget.getAppearance();
|
||||
assertNotNull(appearance, "widget should have an /AP dictionary");
|
||||
PDAppearanceEntry normal = appearance.getNormalAppearance();
|
||||
assertNotNull(normal, "widget should have an /AP /N entry");
|
||||
assertTrue(normal.isSubDictionary(), "a toggle needs per-state appearances");
|
||||
return normal.getSubDictionary().keySet().stream()
|
||||
.map(COSName::getName)
|
||||
.collect(java.util.stream.Collectors.toSet());
|
||||
}
|
||||
|
||||
@Test
|
||||
void addNewFields_createsTextFieldAtRequestedRectangle() throws IOException {
|
||||
byte[] saved;
|
||||
try (PDDocument document = new PDDocument()) {
|
||||
setupForm(document, PDRectangle.A4);
|
||||
FormUtils.addNewFields(document, List.of(newText("created", 50, 700, 200, 20)));
|
||||
saved = save(document);
|
||||
}
|
||||
|
||||
try (PDDocument reloaded = Loader.loadPDF(saved)) {
|
||||
PDAcroForm acroForm = reloaded.getDocumentCatalog().getAcroForm(null);
|
||||
assertNotNull(acroForm, "AcroForm should exist after reload");
|
||||
assertTrue(acroForm.getField("created") instanceof PDTextField);
|
||||
PDRectangle rect = firstWidgetRect(acroForm, "created");
|
||||
assertNotNull(rect, "created widget should keep its rectangle after reload");
|
||||
assertEquals(50f, rect.getLowerLeftX(), 0.5f);
|
||||
assertEquals(700f, rect.getLowerLeftY(), 0.5f);
|
||||
assertEquals(200f, rect.getWidth(), 0.5f);
|
||||
assertEquals(20f, rect.getHeight(), 0.5f);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void addNewFields_appliesCropBoxOffsetToCoordinates() throws IOException {
|
||||
byte[] saved;
|
||||
try (PDDocument document = new PDDocument()) {
|
||||
setupForm(document, PDRectangle.A4);
|
||||
// Shift the CropBox origin; the frontend sends CropBox-relative coords.
|
||||
document.getPage(0).setCropBox(new PDRectangle(10, 20, 500, 700));
|
||||
FormUtils.addNewFields(document, List.of(newText("shifted", 5, 5, 100, 15)));
|
||||
saved = save(document);
|
||||
}
|
||||
|
||||
try (PDDocument reloaded = Loader.loadPDF(saved)) {
|
||||
PDAcroForm acroForm = reloaded.getDocumentCatalog().getAcroForm(null);
|
||||
PDRectangle rect = firstWidgetRect(acroForm, "shifted");
|
||||
// Absolute = CropBox-relative + CropBox lower-left offset.
|
||||
assertEquals(15f, rect.getLowerLeftX(), 0.5f);
|
||||
assertEquals(25f, rect.getLowerLeftY(), 0.5f);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void addNewFields_appliesReadOnlyFontSizeAndMultiline() throws IOException {
|
||||
byte[] saved;
|
||||
try (PDDocument document = new PDDocument()) {
|
||||
setupForm(document, PDRectangle.A4);
|
||||
FormUtils.NewFormFieldDefinition def =
|
||||
new FormUtils.NewFormFieldDefinition(
|
||||
"opts",
|
||||
null,
|
||||
"text",
|
||||
0,
|
||||
10f,
|
||||
10f,
|
||||
120f,
|
||||
18f,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
18f,
|
||||
Boolean.TRUE,
|
||||
Boolean.TRUE,
|
||||
null,
|
||||
null);
|
||||
FormUtils.addNewFields(document, List.of(def));
|
||||
saved = save(document);
|
||||
}
|
||||
|
||||
try (PDDocument reloaded = Loader.loadPDF(saved)) {
|
||||
PDAcroForm acroForm = reloaded.getDocumentCatalog().getAcroForm(null);
|
||||
PDField field = acroForm.getField("opts");
|
||||
assertNotNull(field);
|
||||
assertTrue(field.isReadOnly(), "read-only flag should survive reload");
|
||||
assertTrue(field instanceof PDTextField);
|
||||
assertTrue(((PDTextField) field).isMultiline(), "multiline flag should survive reload");
|
||||
String da = ((PDVariableText) field).getDefaultAppearance();
|
||||
assertTrue(da.contains("18"), "default appearance should carry the font size: " + da);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void modifyFormFields_movesAndResizesWidget() throws IOException {
|
||||
byte[] saved;
|
||||
try (PDDocument document = new PDDocument()) {
|
||||
setupForm(document, PDRectangle.A4);
|
||||
FormUtils.addNewFields(document, List.of(newText("movable", 50, 700, 200, 20)));
|
||||
|
||||
FormUtils.ModifyFormFieldDefinition mod =
|
||||
new FormUtils.ModifyFormFieldDefinition(
|
||||
"movable", null, null, null, 0, 100f, 600f, 150f, 30f, null, null, null,
|
||||
null, null, null, null, null, null, null);
|
||||
FormUtils.modifyFormFields(document, List.of(mod));
|
||||
saved = save(document);
|
||||
}
|
||||
|
||||
try (PDDocument reloaded = Loader.loadPDF(saved)) {
|
||||
PDAcroForm acroForm = reloaded.getDocumentCatalog().getAcroForm(null);
|
||||
PDRectangle rect = firstWidgetRect(acroForm, "movable");
|
||||
assertEquals(100f, rect.getLowerLeftX(), 0.5f);
|
||||
assertEquals(600f, rect.getLowerLeftY(), 0.5f);
|
||||
assertEquals(150f, rect.getWidth(), 0.5f);
|
||||
assertEquals(30f, rect.getHeight(), 0.5f);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void modifyFormFields_setsReadOnlyAndFontSize() throws IOException {
|
||||
byte[] saved;
|
||||
try (PDDocument document = new PDDocument()) {
|
||||
setupForm(document, PDRectangle.A4);
|
||||
FormUtils.addNewFields(document, List.of(newText("editable", 50, 700, 200, 20)));
|
||||
|
||||
FormUtils.ModifyFormFieldDefinition mod =
|
||||
new FormUtils.ModifyFormFieldDefinition(
|
||||
"editable",
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
22f,
|
||||
Boolean.TRUE,
|
||||
null,
|
||||
null,
|
||||
null);
|
||||
FormUtils.modifyFormFields(document, List.of(mod));
|
||||
saved = save(document);
|
||||
}
|
||||
|
||||
try (PDDocument reloaded = Loader.loadPDF(saved)) {
|
||||
PDAcroForm acroForm = reloaded.getDocumentCatalog().getAcroForm(null);
|
||||
PDField field = acroForm.getField("editable");
|
||||
assertNotNull(field);
|
||||
assertTrue(field.isReadOnly(), "read-only flag should survive reload");
|
||||
String da = ((PDVariableText) field).getDefaultAppearance();
|
||||
assertTrue(da.contains("22"), "font size should be reflected in DA: " + da);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void deleteFormFields_removesField() throws IOException {
|
||||
byte[] saved;
|
||||
try (PDDocument document = new PDDocument()) {
|
||||
PDAcroForm acroForm = setupForm(document, PDRectangle.A4);
|
||||
FormUtils.addNewFields(document, List.of(newText("temp", 50, 700, 200, 20)));
|
||||
FormUtils.deleteFormFields(document, List.of("temp"));
|
||||
// After delete the AcroForm may still exist; the field must be gone.
|
||||
if (acroForm != null) {
|
||||
assertNull(acroForm.getField("temp"));
|
||||
}
|
||||
saved = save(document);
|
||||
}
|
||||
|
||||
try (PDDocument reloaded = Loader.loadPDF(saved)) {
|
||||
PDAcroForm acroForm = reloaded.getDocumentCatalog().getAcroForm(null);
|
||||
assertTrue(acroForm == null || acroForm.getField("temp") == null);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void addNewFields_createsRadioGroupWithOneWidgetPerOption() throws IOException {
|
||||
byte[] saved;
|
||||
try (PDDocument document = new PDDocument()) {
|
||||
setupForm(document, PDRectangle.A4);
|
||||
FormUtils.addNewFields(
|
||||
document,
|
||||
List.of(
|
||||
newField(
|
||||
"radio",
|
||||
"choice",
|
||||
60,
|
||||
700,
|
||||
16,
|
||||
16,
|
||||
List.of("Yes", "No"),
|
||||
null,
|
||||
null)));
|
||||
saved = save(document);
|
||||
}
|
||||
|
||||
try (PDDocument reloaded = Loader.loadPDF(saved)) {
|
||||
PDAcroForm acroForm = reloaded.getDocumentCatalog().getAcroForm(null);
|
||||
PDField field = acroForm.getField("choice");
|
||||
assertNotNull(field, "radio field should exist");
|
||||
assertTrue(field instanceof PDRadioButton, "should be a radio button group");
|
||||
assertEquals(2, field.getWidgets().size(), "one widget per option");
|
||||
assertTrue(((PDRadioButton) field).getExportValues().contains("Yes"));
|
||||
assertTrue(((PDRadioButton) field).getExportValues().contains("No"));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void extractFormFields_prefersFieldNameOverFirstOptionForChoiceLabel() throws IOException {
|
||||
// A radio group's label is its field name, not its first option, so the viewer label
|
||||
// matches the name shown in the editor.
|
||||
byte[] saved;
|
||||
try (PDDocument document = new PDDocument()) {
|
||||
setupForm(document, PDRectangle.A4);
|
||||
FormUtils.addNewFields(
|
||||
document,
|
||||
List.of(
|
||||
newField(
|
||||
"radio",
|
||||
"Choice",
|
||||
60,
|
||||
700,
|
||||
16,
|
||||
16,
|
||||
List.of("Yes", "No"),
|
||||
null,
|
||||
null)));
|
||||
saved = save(document);
|
||||
}
|
||||
|
||||
try (PDDocument reloaded = Loader.loadPDF(saved)) {
|
||||
FormUtils.FormFieldInfo choice =
|
||||
FormUtils.extractFormFields(reloaded).stream()
|
||||
.filter(f -> "Choice".equals(f.name()))
|
||||
.findFirst()
|
||||
.orElse(null);
|
||||
assertNotNull(choice, "radio field should be extracted");
|
||||
assertEquals(
|
||||
"Choice", choice.label(), "field name should win over the first option value");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void addNewFields_createsCombTextField() throws IOException {
|
||||
byte[] saved;
|
||||
try (PDDocument document = new PDDocument()) {
|
||||
setupForm(document, PDRectangle.A4);
|
||||
FormUtils.addNewFields(
|
||||
document, List.of(newField("text", "ssn", 50, 700, 200, 20, null, 9, null)));
|
||||
saved = save(document);
|
||||
}
|
||||
|
||||
try (PDDocument reloaded = Loader.loadPDF(saved)) {
|
||||
PDAcroForm acroForm = reloaded.getDocumentCatalog().getAcroForm(null);
|
||||
PDTextField field = (PDTextField) acroForm.getField("ssn");
|
||||
assertNotNull(field);
|
||||
assertEquals(9, field.getMaxLen(), "comb max length should persist");
|
||||
assertTrue(field.isComb(), "comb flag should be set");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void addNewFields_createsSignatureAndButton() throws IOException {
|
||||
byte[] saved;
|
||||
try (PDDocument document = new PDDocument()) {
|
||||
setupForm(document, PDRectangle.A4);
|
||||
FormUtils.addNewFields(
|
||||
document,
|
||||
List.of(
|
||||
newField("signature", "sig", 50, 600, 200, 60, null, null, null),
|
||||
newField("button", "btn", 50, 500, 120, 24, null, null, "reset")));
|
||||
saved = save(document);
|
||||
}
|
||||
|
||||
try (PDDocument reloaded = Loader.loadPDF(saved)) {
|
||||
PDAcroForm acroForm = reloaded.getDocumentCatalog().getAcroForm(null);
|
||||
assertTrue(
|
||||
acroForm.getField("sig") instanceof PDSignatureField,
|
||||
"signature placeholder should exist");
|
||||
assertTrue(
|
||||
acroForm.getField("btn") instanceof PDPushButton, "push button should exist");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void applyFieldEdits_addsModifiesAndDeletesInOnePass() throws IOException {
|
||||
byte[] saved;
|
||||
try (PDDocument document = new PDDocument()) {
|
||||
setupForm(document, PDRectangle.A4);
|
||||
FormUtils.addNewFields(document, List.of(newText("old", 50, 700, 200, 20)));
|
||||
|
||||
FormUtils.applyFieldEdits(
|
||||
document,
|
||||
List.of(newText("fresh", 50, 600, 200, 20)),
|
||||
List.of(),
|
||||
List.of("old"));
|
||||
saved = save(document);
|
||||
}
|
||||
|
||||
try (PDDocument reloaded = Loader.loadPDF(saved)) {
|
||||
PDAcroForm acroForm = reloaded.getDocumentCatalog().getAcroForm(null);
|
||||
assertNotNull(acroForm.getField("fresh"), "added field should be present");
|
||||
assertNull(acroForm.getField("old"), "deleted field should be gone");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -705,10 +705,20 @@ class FormUtilsGapTest {
|
||||
"newName",
|
||||
"New Label",
|
||||
null, // keep type (text) -> in-place path
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
Boolean.TRUE,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null);
|
||||
|
||||
FormUtils.modifyFormFields(doc, List.of(mod));
|
||||
@@ -731,7 +741,8 @@ class FormUtilsGapTest {
|
||||
|
||||
FormUtils.ModifyFormFieldDefinition mod =
|
||||
new FormUtils.ModifyFormFieldDefinition(
|
||||
"missing", null, null, null, null, null, null, null, null);
|
||||
"missing", null, null, null, null, null, null, null, null, null,
|
||||
null, null, null, null, null, null, null, null, null);
|
||||
|
||||
FormUtils.modifyFormFields(doc, List.of(mod));
|
||||
|
||||
@@ -754,7 +765,8 @@ class FormUtilsGapTest {
|
||||
mods.add(null);
|
||||
mods.add(
|
||||
new FormUtils.ModifyFormFieldDefinition(
|
||||
" ", null, null, null, null, null, null, null, null));
|
||||
" ", null, null, null, null, null, null, null, null, null, null,
|
||||
null, null, null, null, null, null, null, null));
|
||||
|
||||
FormUtils.modifyFormFields(doc, mods);
|
||||
assertEquals(1, FormUtils.extractFormFields(doc).size());
|
||||
|
||||
@@ -285,13 +285,13 @@ class FormUtilsMoreTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void widgetOutOfBoundsYieldsNullCoordinateEntry() throws IOException {
|
||||
void widgetOutOfBoundsStillReportsItsCoordinates() throws IOException {
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
SetupDocument setup = createBasicDocument(doc);
|
||||
PDTextField text = new PDTextField(setup.acroForm());
|
||||
text.setPartialName("offpage");
|
||||
// Far below the page origin -> finalY exceeds bounds -> createWidgetCoordinates
|
||||
// returns null, which is still added to the per-field widget list.
|
||||
// Off the page is legal PDF; dropping it would leave the user unable to drag it
|
||||
// back.
|
||||
attachWidget(setup, text, new PDRectangle(50, -5000, 200, 20));
|
||||
|
||||
List<FormFieldWithCoordinates> fields =
|
||||
@@ -301,7 +301,8 @@ class FormUtilsMoreTest {
|
||||
fields.get(0).getWidgets();
|
||||
assertNotNull(widgets);
|
||||
assertEquals(1, widgets.size());
|
||||
assertNull(widgets.get(0));
|
||||
assertNotNull(widgets.get(0), "a null entry here crashes sorting and the overlay");
|
||||
assertEquals(50f, widgets.get(0).getX(), 0.01f);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -476,8 +477,18 @@ class FormUtilsMoreTest {
|
||||
"combobox",
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
List.of("One", "Two"),
|
||||
"One",
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null);
|
||||
|
||||
FormUtils.modifyFormFields(doc, List.of(mod));
|
||||
@@ -505,10 +516,20 @@ class FormUtilsMoreTest {
|
||||
null,
|
||||
"listbox", // same type -> in-place path
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
Boolean.TRUE,
|
||||
List.of("X", "Y", "Z"),
|
||||
null,
|
||||
"Choose items");
|
||||
"Choose items",
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null);
|
||||
|
||||
FormUtils.modifyFormFields(doc, List.of(mod));
|
||||
|
||||
@@ -529,7 +550,25 @@ class FormUtilsMoreTest {
|
||||
|
||||
FormUtils.ModifyFormFieldDefinition mod =
|
||||
new FormUtils.ModifyFormFieldDefinition(
|
||||
"keep", null, null, "bogusType", null, null, null, null, null);
|
||||
"keep",
|
||||
null,
|
||||
null,
|
||||
"bogusType",
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null);
|
||||
|
||||
FormUtils.modifyFormFields(doc, List.of(mod));
|
||||
// The field is preserved unchanged because the target type is unsupported.
|
||||
@@ -554,7 +593,8 @@ class FormUtilsMoreTest {
|
||||
// Rename beta -> alpha; should be uniquified to avoid the collision.
|
||||
FormUtils.ModifyFormFieldDefinition mod =
|
||||
new FormUtils.ModifyFormFieldDefinition(
|
||||
"beta", "alpha", null, null, null, null, null, null, null);
|
||||
"beta", "alpha", null, null, null, null, null, null, null, null,
|
||||
null, null, null, null, null, null, null, null, null);
|
||||
|
||||
FormUtils.modifyFormFields(doc, List.of(mod));
|
||||
|
||||
@@ -575,7 +615,8 @@ class FormUtilsMoreTest {
|
||||
doc.addPage(new PDPage());
|
||||
FormUtils.ModifyFormFieldDefinition mod =
|
||||
new FormUtils.ModifyFormFieldDefinition(
|
||||
"x", null, null, null, null, null, null, null, null);
|
||||
"x", null, null, null, null, null, null, null, null, null, null,
|
||||
null, null, null, null, null, null, null, null);
|
||||
FormUtils.modifyFormFields(doc, List.of(mod));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
package stirling.software.common.util;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
import org.apache.pdfbox.Loader;
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.apache.pdfbox.pdmodel.interactive.form.PDAcroForm;
|
||||
import org.apache.pdfbox.pdmodel.interactive.form.PDTextField;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
/**
|
||||
* Most real PDFs have no AcroForm at all, so adding the very first field has to build one that
|
||||
* PDFBox will accept.
|
||||
*/
|
||||
class FormUtilsNoAcroFormTest {
|
||||
|
||||
private static final Path PLAIN_PDF =
|
||||
Path.of("src/test/resources/pdf-ingestion-fixtures/many-tables-test_stress.pdf");
|
||||
|
||||
private static FormUtils.NewFormFieldDefinition newField(
|
||||
String type, String name, float y, List<String> options, String defaultValue) {
|
||||
// name, label, type, pageIndex, x, y, width, height, required, multiSelect,
|
||||
// options, defaultValue, tooltip, fontSize, readOnly, multiline, maxLength, buttonAction
|
||||
return new FormUtils.NewFormFieldDefinition(
|
||||
name,
|
||||
name,
|
||||
type,
|
||||
0,
|
||||
60f,
|
||||
y,
|
||||
200f,
|
||||
20f,
|
||||
null,
|
||||
null,
|
||||
options,
|
||||
defaultValue,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null);
|
||||
}
|
||||
|
||||
private static PDDocument loadPlain() throws IOException {
|
||||
return Loader.loadPDF(Files.readAllBytes(PLAIN_PDF));
|
||||
}
|
||||
|
||||
@Test
|
||||
void plainPdfReallyHasNoAcroForm() throws IOException {
|
||||
try (PDDocument document = loadPlain()) {
|
||||
assertNull(
|
||||
document.getDocumentCatalog().getAcroForm(null),
|
||||
"fixture must have no AcroForm or this test proves nothing");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void addsFirstFieldToAPdfWithNoAcroForm() throws IOException {
|
||||
byte[] saved;
|
||||
List<FormUtils.SkippedFieldEdit> skipped = new ArrayList<>();
|
||||
try (PDDocument document = loadPlain()) {
|
||||
FormUtils.addNewFields(
|
||||
document,
|
||||
List.of(
|
||||
newField("text", "fullName", 700f, null, "Ada"),
|
||||
newField("checkbox", "agree", 660f, null, null),
|
||||
newField("radio", "contact", 600f, List.of("Email", "Post"), null)),
|
||||
skipped);
|
||||
ByteArrayOutputStream out = new ByteArrayOutputStream();
|
||||
document.save(out);
|
||||
saved = out.toByteArray();
|
||||
}
|
||||
|
||||
assertTrue(skipped.isEmpty(), "no field should be skipped: " + skipped);
|
||||
|
||||
try (PDDocument reloaded = Loader.loadPDF(saved)) {
|
||||
PDAcroForm acroForm = reloaded.getDocumentCatalog().getAcroForm(null);
|
||||
assertNotNull(acroForm, "an AcroForm should have been created");
|
||||
assertNotNull(acroForm.getDefaultResources(), "/DR is required for variable text");
|
||||
assertTrue(
|
||||
acroForm.getDefaultAppearance() != null
|
||||
&& !acroForm.getDefaultAppearance().isBlank(),
|
||||
"/DA is required for variable text");
|
||||
PDTextField text = (PDTextField) acroForm.getField("fullName");
|
||||
assertNotNull(text, "the text field should exist");
|
||||
assertEquals("Ada", text.getValueAsString());
|
||||
assertNotNull(acroForm.getField("agree"));
|
||||
assertNotNull(acroForm.getField("contact"));
|
||||
}
|
||||
}
|
||||
}
|
||||
+175
@@ -0,0 +1,175 @@
|
||||
package stirling.software.common.util;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.util.List;
|
||||
|
||||
import org.apache.pdfbox.Loader;
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.apache.pdfbox.pdmodel.PDPage;
|
||||
import org.apache.pdfbox.pdmodel.common.PDRectangle;
|
||||
import org.apache.pdfbox.pdmodel.interactive.form.PDAcroForm;
|
||||
import org.apache.pdfbox.pdmodel.interactive.form.PDRadioButton;
|
||||
import org.apache.pdfbox.text.PDFTextStripper;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
/**
|
||||
* Option captions belong to the viewer, not the page. Drawing them into the content stream left
|
||||
* orphan text behind on every move and delete, so these pin the page staying clean.
|
||||
*/
|
||||
class FormUtilsRadioCaptionTest {
|
||||
|
||||
private static FormUtils.NewFormFieldDefinition newField(
|
||||
String type, String name, float x, float y, float w, float h, List<String> options) {
|
||||
return new FormUtils.NewFormFieldDefinition(
|
||||
name, null, type, 0, x, y, w, h, null, null, options, null, null, null, null, null,
|
||||
null, null);
|
||||
}
|
||||
|
||||
private static byte[] save(PDDocument document) throws IOException {
|
||||
ByteArrayOutputStream out = new ByteArrayOutputStream();
|
||||
document.save(out);
|
||||
return out.toByteArray();
|
||||
}
|
||||
|
||||
private static PDDocument blankWithForm() {
|
||||
PDDocument document = new PDDocument();
|
||||
document.addPage(new PDPage(PDRectangle.LETTER));
|
||||
document.getDocumentCatalog().setAcroForm(new PDAcroForm(document));
|
||||
return document;
|
||||
}
|
||||
|
||||
private static String textOf(byte[] pdf) throws IOException {
|
||||
try (PDDocument reloaded = Loader.loadPDF(pdf)) {
|
||||
return new PDFTextStripper().getText(reloaded);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void radioOptionsAreNotBakedIntoThePage() throws IOException {
|
||||
byte[] saved;
|
||||
try (PDDocument document = blankWithForm()) {
|
||||
FormUtils.addNewFields(
|
||||
document,
|
||||
List.of(
|
||||
newField(
|
||||
"radio",
|
||||
"contact",
|
||||
72,
|
||||
600,
|
||||
12,
|
||||
12,
|
||||
List.of("Email", "Telephone", "Post"))));
|
||||
saved = save(document);
|
||||
}
|
||||
|
||||
// The caption is the viewer's job; page content cannot follow a widget that moves.
|
||||
String text = textOf(saved);
|
||||
assertFalse(text.contains("Email"), "options must not be page content: " + text);
|
||||
assertFalse(text.contains("Telephone"), "options must not be page content: " + text);
|
||||
assertFalse(text.contains("Post"), "options must not be page content: " + text);
|
||||
}
|
||||
|
||||
@Test
|
||||
void captionsDoNotReplaceTheWidgetsThemselves() throws IOException {
|
||||
byte[] saved;
|
||||
try (PDDocument document = blankWithForm()) {
|
||||
FormUtils.addNewFields(
|
||||
document,
|
||||
List.of(newField("radio", "size", 72, 600, 12, 12, List.of("S", "M", "L"))));
|
||||
saved = save(document);
|
||||
}
|
||||
|
||||
try (PDDocument reloaded = Loader.loadPDF(saved)) {
|
||||
PDAcroForm acroForm = reloaded.getDocumentCatalog().getAcroForm(null);
|
||||
PDRadioButton radio = (PDRadioButton) acroForm.getField("size");
|
||||
assertEquals(3, radio.getWidgets().size(), "one widget per option");
|
||||
assertFalse(radio.getExportValues().isEmpty(), "export values must survive");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void aTextFieldDrawsNoStrayCaption() throws IOException {
|
||||
// Control: proves the assertions above read the captions and not some unrelated content.
|
||||
byte[] saved;
|
||||
try (PDDocument document = blankWithForm()) {
|
||||
FormUtils.addNewFields(
|
||||
document, List.of(newField("text", "fullName", 72, 600, 200, 18, null)));
|
||||
saved = save(document);
|
||||
}
|
||||
assertTrue(textOf(saved).isBlank(), "a text field should add no page content");
|
||||
}
|
||||
|
||||
@Test
|
||||
void deletingARadioGroupTakesItsCaptionsWithIt() throws IOException {
|
||||
byte[] withRadio;
|
||||
try (PDDocument document = blankWithForm()) {
|
||||
FormUtils.addNewFields(
|
||||
document,
|
||||
List.of(
|
||||
newField(
|
||||
"radio",
|
||||
"contact",
|
||||
72,
|
||||
600,
|
||||
12,
|
||||
12,
|
||||
List.of("Email", "Telephone", "Post"))));
|
||||
withRadio = save(document);
|
||||
}
|
||||
assertFalse(
|
||||
textOf(withRadio).contains("Telephone"),
|
||||
"the group adds no page text to begin with");
|
||||
|
||||
byte[] afterDelete;
|
||||
try (PDDocument document = Loader.loadPDF(withRadio)) {
|
||||
FormUtils.applyFieldEdits(document, List.of(), List.of(), List.of("contact"));
|
||||
afterDelete = save(document);
|
||||
}
|
||||
|
||||
String text = textOf(afterDelete);
|
||||
assertFalse(
|
||||
text.contains("Telephone"),
|
||||
"a deleted radio group must not leave its captions on the page: " + text);
|
||||
}
|
||||
|
||||
@Test
|
||||
void theDrawnBoxIsTheWholeGroupNotOneOption() {
|
||||
// A 90pt box used to become a 360pt stack because each option got the full height.
|
||||
PDRectangle box = new PDRectangle(72f, 500f, 100f, 90f);
|
||||
var rects = FormUtils.radioOptionRects(box, 3, null, null);
|
||||
|
||||
assertEquals(3, rects.size());
|
||||
float top = rects.get(0).getUpperRightY();
|
||||
float bottom = rects.get(2).getLowerLeftY();
|
||||
assertEquals(90f, top - bottom, 0.01f, "the group must fill exactly the drawn height");
|
||||
assertEquals(
|
||||
box.getUpperRightY(), top, 0.01f, "the first option starts at the box's top edge");
|
||||
for (PDRectangle r : rects) {
|
||||
assertEquals(r.getWidth(), r.getHeight(), 0.01f, "options stay square");
|
||||
assertTrue(r.getWidth() <= box.getWidth() + 0.01f, "an option never exceeds the box");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void explicitSizeAndGapWin() {
|
||||
PDRectangle box = new PDRectangle(0f, 0f, 100f, 90f);
|
||||
var rects = FormUtils.radioOptionRects(box, 3, 20f, 14f);
|
||||
for (PDRectangle r : rects) {
|
||||
assertEquals(14f, r.getHeight(), 0.01f, "the requested size is used verbatim");
|
||||
}
|
||||
float gap = rects.get(0).getLowerLeftY() - rects.get(1).getUpperRightY();
|
||||
assertEquals(20f, gap, 0.01f, "the requested gap is used verbatim");
|
||||
}
|
||||
|
||||
@Test
|
||||
void aSingleOptionStillFitsTheBox() {
|
||||
var rects = FormUtils.radioOptionRects(new PDRectangle(0f, 0f, 40f, 40f), 1, null, null);
|
||||
assertEquals(1, rects.size());
|
||||
assertTrue(rects.get(0).getHeight() <= 40f, "one option cannot exceed its box");
|
||||
}
|
||||
}
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
package stirling.software.common.util;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.apache.pdfbox.pdmodel.PDPage;
|
||||
import org.apache.pdfbox.pdmodel.common.PDRectangle;
|
||||
import org.apache.pdfbox.pdmodel.interactive.form.PDAcroForm;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
/** A form with no default resources is ordinary; adding a field to it must still work. */
|
||||
class MissingDefaultResourcesTest {
|
||||
|
||||
@Test
|
||||
@DisplayName("a text field can be added to a form that has no default resources")
|
||||
void addsToFormWithoutDefaultResources() throws IOException {
|
||||
// A real upload arrives as bytes, and plenty of forms in the wild carry no /DR at all.
|
||||
byte[] pdf;
|
||||
try (PDDocument built = new PDDocument();
|
||||
java.io.ByteArrayOutputStream out = new java.io.ByteArrayOutputStream()) {
|
||||
built.addPage(new PDPage(PDRectangle.A4));
|
||||
PDAcroForm form = new PDAcroForm(built);
|
||||
// A /DA naming a font with no /DR to resolve it is what PDFBox refuses.
|
||||
form.setDefaultAppearance("/Helv 0 Tf 0 g");
|
||||
form.getCOSObject().removeItem(org.apache.pdfbox.cos.COSName.DR);
|
||||
built.getDocumentCatalog().setAcroForm(form);
|
||||
built.save(out);
|
||||
pdf = out.toByteArray();
|
||||
}
|
||||
|
||||
try (PDDocument document = org.apache.pdfbox.Loader.loadPDF(pdf)) {
|
||||
|
||||
List<FormUtils.SkippedFieldEdit> skipped = new ArrayList<>();
|
||||
FormUtils.addNewFields(
|
||||
document,
|
||||
List.of(
|
||||
new FormUtils.NewFormFieldDefinition(
|
||||
"note", null, "text", 0, 50f, 700f, 200f, 20f, null, null, null,
|
||||
null, null, null, null, null, null, null)),
|
||||
skipped);
|
||||
|
||||
assertTrue(
|
||||
skipped.isEmpty(),
|
||||
"adding a plain text field should not be refused: " + skipped);
|
||||
assertEquals(
|
||||
1,
|
||||
FormUtils.extractFormFields(document).size(),
|
||||
"the field should be in the document");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -113,6 +113,16 @@ class RequestUriUtilsTest {
|
||||
assertTrue(RequestUriUtils.isFrontendRoute("", "/split-pdf"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testIsFrontendRoute_editorRouteOwnedByFrontend() {
|
||||
// /editor (and its tool routes) is an SPA route: a direct-nav/refresh must
|
||||
// serve index.html, not the auth filter's 302-to-/login. Regression test for
|
||||
// the editor moving from / to /editor, whose refresh bounced processor users
|
||||
// to the processor because the redirect dropped the return path.
|
||||
assertTrue(RequestUriUtils.isFrontendRoute("", "/editor"));
|
||||
assertTrue(RequestUriUtils.isFrontendRoute("/app", "/app/editor"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testIsFrontendRoute_filesRouteOwnedByFrontend() {
|
||||
// /files and /files/<folder-uuid> are FileManagerView routes - they
|
||||
|
||||
@@ -106,6 +106,7 @@ SwaggerDoc.json
|
||||
|
||||
# Log file
|
||||
*.log
|
||||
*.log.gz
|
||||
|
||||
# BlueJ files
|
||||
*.ctxt
|
||||
|
||||
+17
-2
@@ -62,8 +62,16 @@ dependencies {
|
||||
// CVE-2022-25647: Explicit gson to prevent unsafe deserialization (tabula would pull 2.8.7)
|
||||
implementation "com.google.code.gson:gson:${gsonVersion}"
|
||||
implementation 'org.apache.pdfbox:jbig2-imageio:3.0.5'
|
||||
implementation 'com.opencsv:opencsv:5.12.0' // https://mvnrepository.com/artifact/com.opencsv/opencsv
|
||||
implementation 'org.apache.poi:poi-ooxml:5.5.1'
|
||||
// OpenCSV: Stirling-PDF only uses CSVWriter, not the opencsv-bean module.
|
||||
// Exclude commons-beanutils + commons-collections.
|
||||
implementation('com.opencsv:opencsv:5.12.0') {
|
||||
exclude group: 'commons-beanutils', module: 'commons-beanutils'
|
||||
exclude group: 'commons-collections', module: 'commons-collections'
|
||||
}
|
||||
// POI: only XSSF (modern Excel) is used, not HSSF/FormulaEvaluator which need commons-math3.
|
||||
implementation('org.apache.poi:poi-ooxml:5.5.1') {
|
||||
exclude group: 'org.apache.commons', module: 'commons-math3'
|
||||
}
|
||||
|
||||
// Batik only bridge module needed (transitively pulls anim, gvt, util, css, dom, svg-dom)
|
||||
// Replaces batik-all which included unused codec, svggen, transcoder, script modules
|
||||
@@ -129,6 +137,10 @@ bootJar {
|
||||
exclude 'META-INF/*.RSA'
|
||||
exclude 'META-INF/*.EC'
|
||||
|
||||
// Exclude source maps from production JAR, dev-only debugging artifacts, not needed at runtime
|
||||
exclude 'static/pdfjs-legacy/**/*.map'
|
||||
exclude 'static/**/*.map'
|
||||
|
||||
manifest {
|
||||
attributes(
|
||||
'Implementation-Title': 'Stirling-PDF',
|
||||
@@ -294,6 +306,9 @@ tasks.register('copyFrontendAssets', Copy) {
|
||||
// Exclude files that conflict with backend static resources
|
||||
exclude 'robots.txt' // Backend already has this
|
||||
exclude 'favicon.ico' // Backend already has this
|
||||
// Backend ships its own NotoSans-Regular.ttf here and it is git-tracked;
|
||||
// letting the editor's copy win would dirty the source tree on every build.
|
||||
exclude 'fonts/NotoSans-Regular.ttf'
|
||||
}
|
||||
into resourcesStaticDir
|
||||
duplicatesStrategy = DuplicatesStrategy.INCLUDE // Let frontend overwrite when needed
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
+1
-1
@@ -237,7 +237,7 @@ public class EditTextController {
|
||||
|
||||
Matcher matcher = edit.pattern().matcher(joined);
|
||||
List<MatchSpan> spans = new ArrayList<>();
|
||||
StringBuffer interpolation = new StringBuffer();
|
||||
StringBuilder interpolation = new StringBuilder();
|
||||
int previousAppendPosition = 0;
|
||||
while (matcher.find()) {
|
||||
if (matcher.start() == matcher.end()) {
|
||||
|
||||
+598
@@ -0,0 +1,598 @@
|
||||
package stirling.software.SPDF.controller.api;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Base64;
|
||||
import java.util.List;
|
||||
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.apache.pdfbox.pdmodel.PDPage;
|
||||
import org.apache.pdfbox.pdmodel.PDResources;
|
||||
import org.apache.pdfbox.pdmodel.font.PDFont;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
|
||||
import lombok.Data;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.annotations.api.GeneralApi;
|
||||
import stirling.software.common.service.CustomPDFDocumentFactory;
|
||||
|
||||
/**
|
||||
* Charcode-encode helper for the v2 PDF text editor.
|
||||
*
|
||||
* <p>The frontend editor uses PDFium-WASM, which exposes {@code FPDFText_SetCharcodes} for writing
|
||||
* new text using raw font charcodes (skipping PDFium's broken reverse Unicode→CID lookup for
|
||||
* embedded subset fonts). What PDFium does NOT expose is the byte-encoding side of an existing font
|
||||
* - given a PDFont and a Unicode string, what are the bytes the font's encoding produces? PDFBox
|
||||
* does have that ({@link PDFont#encode}).
|
||||
*
|
||||
* <p>This endpoint accepts the source PDF + a "locator" describing where to find the font in
|
||||
* question (page index + a sample char known to render in the target font, optionally narrowed by
|
||||
* the font's /BaseFont name) + the Unicode text the frontend wants to encode. It returns the
|
||||
* charcode sequence the frontend can pass to {@code FPDFText_SetCharcodes}.
|
||||
*
|
||||
* <p>If the locator can't find a matching text fragment, or if the font can't encode some chars,
|
||||
* the response reports which chars are missing so the frontend can fall back to Helvetica per char.
|
||||
*/
|
||||
@Slf4j
|
||||
@GeneralApi
|
||||
@RequiredArgsConstructor
|
||||
public class PdfTextEditorCharcodeController {
|
||||
|
||||
/** Reject JSON bodies whose base64 implies a decoded PDF larger than this. */
|
||||
private static final int MAX_PDF_BYTES = 100 * 1024 * 1024;
|
||||
|
||||
/**
|
||||
* Upper bound on {@code request.text} code units. Editor requests are word-sized; an unbounded
|
||||
* text drove a per-code-point encode/exception loop (CPU burn) on crafted requests.
|
||||
*/
|
||||
private static final int MAX_TEXT_CHARS = 4096;
|
||||
|
||||
/** Nested form-XObject resource dictionaries visited per lookup (cycle/DoS guard). */
|
||||
private static final int MAX_RESOURCE_DICTS = 32;
|
||||
|
||||
/** Bound on the reverse-map cache so a busy multi-document server can't grow it forever. */
|
||||
private static final int REVERSE_MAP_CACHE_MAX = 32;
|
||||
|
||||
/** Access-ordered LRU bounded at {@link #REVERSE_MAP_CACHE_MAX} entries. */
|
||||
private static final class BoundedReverseMapCache
|
||||
extends java.util.LinkedHashMap<String, java.util.Map<String, Long>> {
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
BoundedReverseMapCache() {
|
||||
super(16, 0.75f, true);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected boolean removeEldestEntry(
|
||||
java.util.Map.Entry<String, java.util.Map<String, Long>> eldest) {
|
||||
return size() > REVERSE_MAP_CACHE_MAX;
|
||||
}
|
||||
}
|
||||
|
||||
private static final java.util.Map<String, java.util.Map<String, Long>> REVERSE_MAP_CACHE =
|
||||
java.util.Collections.synchronizedMap(new BoundedReverseMapCache());
|
||||
|
||||
private final CustomPDFDocumentFactory pdfDocumentFactory;
|
||||
|
||||
// NOTE: PDFBox's PDSimpleFont emits one "No Unicode mapping for .notdef" WARN per probed
|
||||
// charcode when buildReverseUnicodeMap iterates 0..0xFFFF, which once flooded info.log to
|
||||
// ~1.4 GB overnight. That logger is silenced DECLARATIVELY in logback.xml (a config entry ops
|
||||
// can see and revert) rather than by mutating the global logger from a static block here -
|
||||
// mutating it at class-load time hid the same warnings from every other tool in the JVM with
|
||||
// no trace in configuration.
|
||||
|
||||
@Data
|
||||
public static class EncodeCharcodesRequest {
|
||||
|
||||
/** Base64-encoded original PDF. The frontend already has the bytes loaded. */
|
||||
private String pdfBase64;
|
||||
|
||||
/** 0-based page index containing the font sample. */
|
||||
private int pageIndex;
|
||||
|
||||
/**
|
||||
* A char known to exist on the page in the target font. Combined with {@code fontName}
|
||||
* (when supplied) it locates the source PDFont via its ToUnicode CMap.
|
||||
*/
|
||||
private String locatorChar;
|
||||
|
||||
/**
|
||||
* Optional /BaseFont name of the target font (as PDFium's FPDFFont_GetBaseFontName reports
|
||||
* it). When a page has TWO fonts that both render {@code locatorChar}, this disambiguates
|
||||
* which one to encode against - otherwise the first font found wins and a cross-font edit
|
||||
* gets the wrong font's charcode. Null = keep the legacy first-match behaviour.
|
||||
*/
|
||||
private String fontName;
|
||||
|
||||
/**
|
||||
* Optional SHA-256 (lowercase hex) of the target font's embedded program bytes (what
|
||||
* PDFium's FPDFFont_GetFontData returns = the decoded FontFile/FontFile2/FontFile3 stream).
|
||||
* This is the ONLY unambiguous font identity: PDFium strips the "ABCDEF+" subset tag from
|
||||
* font names, so every subset of one family reports the same {@code fontName} and a
|
||||
* name-based lookup can land on a SIBLING subset whose charcode space is different -
|
||||
* returning valid-but-wrong charcodes that scramble the edited text. When present and a
|
||||
* font on the page matches, it wins over name matching.
|
||||
*/
|
||||
private String fontSha256;
|
||||
|
||||
/** Unicode text the frontend wants to encode. */
|
||||
private String text;
|
||||
}
|
||||
|
||||
@Data
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||
public static class EncodeCharcodesResponse {
|
||||
/**
|
||||
* Per-char charcode array (one entry per code point in {@code request.text}). When the
|
||||
* font's encoding produces multi-byte sequences, each char gets the full unsigned int value
|
||||
* of its bytes packed big-endian (so a 2-byte CID like 0x004D becomes 77).
|
||||
*/
|
||||
private List<Long> charcodes;
|
||||
|
||||
/** Chars from the request that the font couldn't encode. */
|
||||
private List<String> missing;
|
||||
|
||||
/** Diagnostic note - included so the frontend HUD can show what happened. */
|
||||
private String note;
|
||||
|
||||
/** Set when the request failed entirely (bad pdf bytes, no matching font, etc.). */
|
||||
private String error;
|
||||
}
|
||||
|
||||
@Operation(
|
||||
summary = "Encode Unicode → font charcodes for the v2 PDF text editor",
|
||||
description =
|
||||
"""
|
||||
Frontend-only helper: takes the source PDF, a locator pointing at an existing
|
||||
char rendered in the target font, and a Unicode string. Returns the byte
|
||||
sequence the target font produces for that Unicode, packed as one unsigned
|
||||
int per char. The frontend then calls FPDFText_SetCharcodes with the
|
||||
returned ints to inject new text that reuses the embedded font's actual
|
||||
glyphs. Chars the font can't encode are listed in `missing` so the caller
|
||||
can fall back per-char.
|
||||
""")
|
||||
@PostMapping(
|
||||
value = "/pdf-text-editor/encode-charcodes",
|
||||
consumes = "application/json",
|
||||
produces = "application/json")
|
||||
public ResponseEntity<EncodeCharcodesResponse> encodeCharcodes(
|
||||
@RequestBody EncodeCharcodesRequest request) {
|
||||
EncodeCharcodesResponse resp = new EncodeCharcodesResponse();
|
||||
if (request == null
|
||||
|| request.getPdfBase64() == null
|
||||
|| request.getText() == null
|
||||
|| request.getLocatorChar() == null) {
|
||||
resp.setError("missing required fields");
|
||||
return ResponseEntity.badRequest().body(resp);
|
||||
}
|
||||
// length/4*3 bounds the decoded size without decoding, so we reject early before
|
||||
// allocating.
|
||||
String b64 = request.getPdfBase64();
|
||||
if ((long) b64.length() / 4 * 3 > MAX_PDF_BYTES) {
|
||||
resp.setError("pdf too large");
|
||||
return ResponseEntity.status(413).body(resp);
|
||||
}
|
||||
// Reported separately: a combined check names only one cause and misleads the caller.
|
||||
if (request.getText().length() > MAX_TEXT_CHARS) {
|
||||
resp.setError("text too long");
|
||||
return ResponseEntity.badRequest().body(resp);
|
||||
}
|
||||
if (request.getLocatorChar().length() > 4) {
|
||||
resp.setError("locatorChar too long");
|
||||
return ResponseEntity.badRequest().body(resp);
|
||||
}
|
||||
byte[] pdfBytes;
|
||||
try {
|
||||
pdfBytes = Base64.getDecoder().decode(b64);
|
||||
} catch (IllegalArgumentException e) {
|
||||
resp.setError("pdfBase64 is not valid base64");
|
||||
return ResponseEntity.badRequest().body(resp);
|
||||
}
|
||||
try (PDDocument doc = pdfDocumentFactory.load(pdfBytes, true)) {
|
||||
if (request.getPageIndex() < 0 || request.getPageIndex() >= doc.getNumberOfPages()) {
|
||||
resp.setError("pageIndex out of range");
|
||||
return ResponseEntity.badRequest().body(resp);
|
||||
}
|
||||
PDPage page = doc.getPage(request.getPageIndex());
|
||||
// Skip walking the page's content stream (it crashes on Type3 fonts with
|
||||
// UnsupportedOperationException("Not implemented: Type3") before we can do anything
|
||||
// useful). Instead enumerate the page's font resources and pick the one identified by
|
||||
// the request's font-program hash (definitive), falling back to name matching.
|
||||
// For Chrome/Skia-printed PDFs that emit one Type3 font per glyph, this lands on
|
||||
// the exact font that renders the locator char.
|
||||
ResourceFont located =
|
||||
findFontByToUnicode(
|
||||
page,
|
||||
request.getLocatorChar(),
|
||||
request.getFontName(),
|
||||
request.getFontSha256(),
|
||||
doc);
|
||||
if (located == null) {
|
||||
resp.setError(
|
||||
"no font on page "
|
||||
+ request.getPageIndex()
|
||||
+ " renders locatorChar="
|
||||
+ request.getLocatorChar()
|
||||
+ (request.getFontName() != null
|
||||
? " (fontName=" + request.getFontName() + ")"
|
||||
: ""));
|
||||
return ResponseEntity.ok(resp);
|
||||
}
|
||||
// Build a reverse Unicode→charcode map by walking the font's ToUnicode CMap.
|
||||
// This is the ONLY path that works for Type3 fonts (PDFBox's font.encode() throws
|
||||
// "Not implemented: Type3" on them), and it also acts as a more reliable fallback
|
||||
// for subset fonts whose encode() rejects chars not in the original document.
|
||||
//
|
||||
// For Sample.pdf specifically, every embedded font is Type3 (Chrome/Skia output),
|
||||
// but they all carry a ToUnicode CMap mapping CIDs back to Unicode. We iterate
|
||||
// charcodes 0..0xFFFF, call font.toUnicode(cc) for each, and record the inverse
|
||||
// mapping for the chars the user wants to write.
|
||||
PDFont font = located.font();
|
||||
java.util.Map<String, Long> reverseMap =
|
||||
buildReverseUnicodeMap(pdfBytes, located, request.getPageIndex());
|
||||
List<Long> charcodes = new ArrayList<>();
|
||||
List<String> missing = new ArrayList<>();
|
||||
String text = request.getText();
|
||||
int i = 0;
|
||||
while (i < text.length()) {
|
||||
int cp = text.codePointAt(i);
|
||||
String oneChar = new String(Character.toChars(cp));
|
||||
i += Character.charCount(cp);
|
||||
// Whitespace is NEVER charcode-reused. Subset Type1/LaTeX fonts
|
||||
// usually have no real space glyph, yet font.encode(0x20) still
|
||||
// returns code 0x20 without throwing - and SetCharcodes(0x20)
|
||||
// then paints whatever glyph sits at that subset code (e.g. „
|
||||
// quotedblbase in LMRoman). Report whitespace as missing so the
|
||||
// frontend emits it as a positional gap instead.
|
||||
if (Character.isWhitespace(cp)) {
|
||||
missing.add(oneChar);
|
||||
continue;
|
||||
}
|
||||
// 1st try: font.encode() - works for Type0/TrueType/Type1
|
||||
Long packed = null;
|
||||
try {
|
||||
byte[] encoded = font.encode(oneChar);
|
||||
long p = 0L;
|
||||
for (byte b : encoded) p = (p << 8) | (b & 0xff);
|
||||
packed = p;
|
||||
} catch (IOException
|
||||
| IllegalArgumentException
|
||||
| UnsupportedOperationException encodeEx) {
|
||||
// 2nd try: ToUnicode reverse lookup - works for Type3 + anything with a CMap
|
||||
packed = reverseMap.get(oneChar);
|
||||
}
|
||||
if (packed != null) charcodes.add(packed);
|
||||
else missing.add(oneChar);
|
||||
}
|
||||
resp.setCharcodes(charcodes);
|
||||
if (!missing.isEmpty()) resp.setMissing(missing);
|
||||
resp.setNote(
|
||||
"font="
|
||||
+ font.getName()
|
||||
+ " encoded "
|
||||
+ charcodes.size()
|
||||
+ " of "
|
||||
+ (charcodes.size() + missing.size())
|
||||
+ " chars");
|
||||
return ResponseEntity.ok(resp);
|
||||
} catch (IOException e) {
|
||||
log.warn("encodeCharcodes: failed to load PDF", e);
|
||||
resp.setError("failed to load PDF");
|
||||
return ResponseEntity.badRequest().body(resp);
|
||||
} catch (RuntimeException e) {
|
||||
log.warn("encodeCharcodes: unexpected error", e);
|
||||
resp.setError("unexpected error");
|
||||
return ResponseEntity.status(500).body(resp);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Locate the font the request targets. Identity sources, strongest first:
|
||||
*
|
||||
* <ol>
|
||||
* <li><b>Program hash</b>: SHA-256 of the embedded font program bytes. Definitive - two
|
||||
* different subsets NEVER share program bytes, and PDFium's FPDFFont_GetFontData returns
|
||||
* exactly the decoded FontFile stream, so frontend and backend hash the same bytes.
|
||||
* <li><b>Exact /BaseFont name</b> (subset tag included), then <b>tag-stripped name</b>. Name
|
||||
* matches are only accepted when UNAMBIGUOUS: PDFium reports subset fonts WITHOUT their
|
||||
* "ABCDEF+" tag, so a page with several subsets of one family ("AAAAAC+Garamond",
|
||||
* "AAAAAG+Garamond", ...) has them ALL match the stripped name - and encoding against the
|
||||
* wrong sibling returns valid-but-wrong charcodes that scramble the edited text ("RUSSELL
|
||||
* W. MANGUM" rendered "US EEL W. MANGS M"). With 2+ candidates we return null so the
|
||||
* frontend takes its safe fallback instead of a coin flip.
|
||||
* </ol>
|
||||
*
|
||||
* <p>This avoids running PDFStreamEngine.processPage, which throws
|
||||
* UnsupportedOperationException on Type3 font glyph rendering. The PDFont lookup itself is
|
||||
* purely metadata-driven and works on all subtypes.
|
||||
*/
|
||||
private static ResourceFont findFontByToUnicode(
|
||||
PDPage page, String wantChar, String fontName, String fontSha256, PDDocument doc) {
|
||||
try {
|
||||
List<ResourceFont> fonts = collectResourceTreeFonts(page.getResources());
|
||||
|
||||
// 1) Program-hash identity. When several dicts share one program (identical bytes
|
||||
// re-embedded), any of them renders the same glyphs for the same codes; prefer the
|
||||
// one whose ToUnicode covers the locator char so the reverse map is usable.
|
||||
if (fontSha256 != null && !fontSha256.isEmpty()) {
|
||||
List<ResourceFont> hashMatches = new ArrayList<>();
|
||||
for (ResourceFont rf : fonts) {
|
||||
String sha = fontProgramSha256(rf.font());
|
||||
if (fontSha256.equalsIgnoreCase(sha)) hashMatches.add(rf);
|
||||
}
|
||||
for (ResourceFont rf : hashMatches) {
|
||||
if (probesToUnicode(rf.font(), wantChar)) return rf;
|
||||
}
|
||||
if (!hashMatches.isEmpty()) return hashMatches.get(0);
|
||||
// No program on this page hashes to what the frontend is editing (e.g. PDFium
|
||||
// returned a substitute font's bytes for a non-embedded font). Fall through to
|
||||
// name matching rather than failing outright.
|
||||
}
|
||||
|
||||
// 2) Name identity - exact tag-included first, then tag-stripped - each accepted
|
||||
// only when it selects a single font.
|
||||
if (fontName != null && !fontName.isEmpty()) {
|
||||
ResourceFont exact =
|
||||
selectUnambiguous(
|
||||
fonts, wantChar, f -> fontName.equals(f.getName()), "exact");
|
||||
if (exact != null) return exact;
|
||||
String wantStripped = stripSubsetTag(fontName);
|
||||
ResourceFont stripped =
|
||||
selectUnambiguous(
|
||||
fonts,
|
||||
wantChar,
|
||||
f -> wantStripped.equals(stripSubsetTag(f.getName())),
|
||||
"stripped");
|
||||
if (stripped != null) return stripped;
|
||||
// The frontend NAMED the font it is editing. Falling back to "any font that
|
||||
// renders the char" would hand back a DIFFERENT font's charcodes, which the
|
||||
// frontend then writes into the named font's text object - wrong glyph, and the
|
||||
// backend strategy skips all frontend validation. Report the char missing
|
||||
// instead so the caller takes its own fallback path.
|
||||
return null;
|
||||
}
|
||||
|
||||
// 3) Legacy locator-only behaviour: first font whose ToUnicode renders the char.
|
||||
for (ResourceFont rf : fonts) {
|
||||
if (probesToUnicode(rf.font(), wantChar)) return rf;
|
||||
}
|
||||
} catch (RuntimeException ignore) {
|
||||
// Be defensive: any single bad font shouldn't sink the whole request.
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply {@code nameFilter}, then decide: exactly one candidate whose ToUnicode covers {@code
|
||||
* wantChar} wins; two+ probe-hits are AMBIGUOUS (null). With zero probe-hits, a single
|
||||
* name-matching font is still returned (font.encode() may handle chars without a ToUnicode -
|
||||
* common for Type0/Identity-H), but two+ name matches are again ambiguous.
|
||||
*/
|
||||
private static ResourceFont selectUnambiguous(
|
||||
List<ResourceFont> fonts,
|
||||
String wantChar,
|
||||
java.util.function.Predicate<PDFont> nameFilter,
|
||||
String modeLabel) {
|
||||
List<ResourceFont> named = new ArrayList<>();
|
||||
for (ResourceFont rf : fonts) {
|
||||
try {
|
||||
if (rf.font().getName() != null && nameFilter.test(rf.font())) named.add(rf);
|
||||
} catch (RuntimeException ignore) {
|
||||
}
|
||||
}
|
||||
if (named.isEmpty()) return null;
|
||||
List<ResourceFont> probed = new ArrayList<>();
|
||||
for (ResourceFont rf : named) {
|
||||
if (probesToUnicode(rf.font(), wantChar)) probed.add(rf);
|
||||
}
|
||||
if (probed.size() == 1) return probed.get(0);
|
||||
if (probed.size() > 1) {
|
||||
log.debug(
|
||||
"encodeCharcodes: {} name match ambiguous ({} fonts render locator '{}') -"
|
||||
+ " refusing cross-subset guess",
|
||||
modeLabel,
|
||||
probed.size(),
|
||||
wantChar);
|
||||
return null;
|
||||
}
|
||||
return named.size() == 1 ? named.get(0) : null;
|
||||
}
|
||||
|
||||
/** True when some charcode in the font's ToUnicode CMap maps to {@code wantChar}. */
|
||||
private static boolean probesToUnicode(PDFont font, String wantChar) {
|
||||
// Cheap inverse-CMap probe: iterate codes until we hit one whose toUnicode is wantChar.
|
||||
// For Type3 with at most ~16 glyphs, this is microseconds. For full Type0 subsets
|
||||
// it's a few-thousand-iteration scan.
|
||||
int upper = font.isStandard14() ? 256 : 0x10000;
|
||||
for (int cc = 0; cc < upper; cc++) {
|
||||
String u;
|
||||
try {
|
||||
u = font.toUnicode(cc);
|
||||
} catch (Exception ignore) {
|
||||
continue;
|
||||
}
|
||||
if (u != null && u.equals(wantChar)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private record ResourceFont(PDFont font, String path) {}
|
||||
|
||||
private record PendingResources(PDResources resources, String path) {}
|
||||
|
||||
/**
|
||||
* Breadth-first collection of every distinct font reachable from the page's resources AND every
|
||||
* nested form XObject's resources (bounded by {@link #MAX_RESOURCE_DICTS}, cycle-safe, deduped
|
||||
* by COS dictionary identity). The v2 reader surfaces form-XObject text as editable, so its
|
||||
* fonts must be findable too.
|
||||
*/
|
||||
private static List<ResourceFont> collectResourceTreeFonts(PDResources resources) {
|
||||
List<ResourceFont> out = new ArrayList<>();
|
||||
java.util.ArrayDeque<PendingResources> queue = new java.util.ArrayDeque<>();
|
||||
java.util.Set<org.apache.pdfbox.cos.COSDictionary> seenDicts =
|
||||
java.util.Collections.newSetFromMap(new java.util.IdentityHashMap<>());
|
||||
java.util.Set<org.apache.pdfbox.cos.COSDictionary> seenFonts =
|
||||
java.util.Collections.newSetFromMap(new java.util.IdentityHashMap<>());
|
||||
if (resources != null) queue.add(new PendingResources(resources, ""));
|
||||
int visited = 0;
|
||||
// Bound a crafted page declaring many fonts none of which match (CPU-DoS guard).
|
||||
final int MAX_FONTS = 64;
|
||||
while (!queue.isEmpty() && visited < MAX_RESOURCE_DICTS) {
|
||||
PendingResources pending = queue.poll();
|
||||
PDResources res = pending.resources();
|
||||
if (!seenDicts.add(res.getCOSObject())) continue;
|
||||
visited++;
|
||||
for (org.apache.pdfbox.cos.COSName name : res.getFontNames()) {
|
||||
if (out.size() >= MAX_FONTS) break;
|
||||
PDFont font;
|
||||
try {
|
||||
font = res.getFont(name);
|
||||
} catch (IOException | RuntimeException e) {
|
||||
continue;
|
||||
}
|
||||
if (font == null || !seenFonts.add(font.getCOSObject())) continue;
|
||||
out.add(new ResourceFont(font, pending.path() + "/" + name.getName()));
|
||||
}
|
||||
try {
|
||||
for (org.apache.pdfbox.cos.COSName xn : res.getXObjectNames()) {
|
||||
try {
|
||||
org.apache.pdfbox.pdmodel.graphics.PDXObject xo = res.getXObject(xn);
|
||||
if (xo
|
||||
instanceof
|
||||
org.apache.pdfbox.pdmodel.graphics.form.PDFormXObject form) {
|
||||
PDResources fr = form.getResources();
|
||||
if (fr != null) {
|
||||
queue.add(
|
||||
new PendingResources(
|
||||
fr, pending.path() + "/" + xn.getName()));
|
||||
}
|
||||
}
|
||||
} catch (IOException | RuntimeException ignore) {
|
||||
}
|
||||
}
|
||||
} catch (RuntimeException ignore) {
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* SHA-256 (lowercase hex) of a font's embedded program bytes - the decoded
|
||||
* FontFile/FontFile2/FontFile3 stream, which is byte-identical to what PDFium's
|
||||
* FPDFFont_GetFontData hands the frontend. Null when the font embeds no program.
|
||||
*/
|
||||
private static String fontProgramSha256(PDFont font) {
|
||||
try {
|
||||
org.apache.pdfbox.pdmodel.font.PDFontDescriptor fd = font.getFontDescriptor();
|
||||
if (fd == null && font instanceof org.apache.pdfbox.pdmodel.font.PDType0Font type0) {
|
||||
fd = type0.getDescendantFont().getFontDescriptor();
|
||||
}
|
||||
if (fd == null) return null;
|
||||
org.apache.pdfbox.pdmodel.common.PDStream stream = fd.getFontFile2();
|
||||
if (stream == null) stream = fd.getFontFile3();
|
||||
if (stream == null) stream = fd.getFontFile();
|
||||
if (stream == null) return null;
|
||||
return sha256Hex(stream.toByteArray());
|
||||
} catch (IOException | RuntimeException e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Drop the 6-letter "ABCDEF+" subset prefix PDF puts on subset /BaseFont names. */
|
||||
private static String stripSubsetTag(String fontName) {
|
||||
if (fontName == null) return null;
|
||||
if (fontName.length() > 7
|
||||
&& fontName.charAt(6) == '+'
|
||||
&& fontName.chars().limit(6).allMatch(c -> c >= 'A' && c <= 'Z')) {
|
||||
return fontName.substring(7);
|
||||
}
|
||||
return fontName;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a Unicode→charcode map for a font by iterating every charcode in 0..0xFFFF and asking
|
||||
* the font's ToUnicode CMap what Unicode it maps to. Charcodes that aren't in the CMap throw
|
||||
* inside toUnicode (PDFBox returns null or throws depending on font subtype), and those are
|
||||
* skipped silently.
|
||||
*
|
||||
* <p>This is the encoding inverse PDFBox doesn't expose directly. For Type3 fonts (where
|
||||
* font.encode() throws "Not implemented"), this is the ONLY way to write text in the same font
|
||||
* - we look up the user's char in the reverse map and pass that charcode to
|
||||
* FPDFText_SetCharcodes on the frontend.
|
||||
*
|
||||
* <p>The 0..0xFFFF range is sufficient for Type0/CIDFontType2 fonts (CIDs are 16-bit). For
|
||||
* single-byte fonts the loop short-circuits after 256. We don't go higher because no PDF font
|
||||
* has a CID outside that range in practice; the per-font result is memoised in {@link
|
||||
* #REVERSE_MAP_CACHE} so the 65 536-entry probe runs once per document+font, not per request.
|
||||
*/
|
||||
private static java.util.Map<String, Long> buildReverseUnicodeMap(
|
||||
byte[] pdfBytes, ResourceFont located, int pageIndex) {
|
||||
String key = sha256Hex(pdfBytes) + "|" + fontCacheIdentity(located, pageIndex);
|
||||
// Compound get/put under the map's own monitor. The 0..0xFFFF probe runs OUTSIDE the
|
||||
// lock so one slow build can't block every other request on the shared cache.
|
||||
java.util.Map<String, Long> cached;
|
||||
synchronized (REVERSE_MAP_CACHE) {
|
||||
cached = REVERSE_MAP_CACHE.get(key);
|
||||
}
|
||||
if (cached != null) return cached;
|
||||
java.util.Map<String, Long> built = computeReverseUnicodeMap(located.font());
|
||||
synchronized (REVERSE_MAP_CACHE) {
|
||||
java.util.Map<String, Long> raced = REVERSE_MAP_CACHE.putIfAbsent(key, built);
|
||||
return raced != null ? raced : built;
|
||||
}
|
||||
}
|
||||
|
||||
private static String fontCacheIdentity(ResourceFont located, int pageIndex) {
|
||||
org.apache.pdfbox.cos.COSObjectKey objectKey = null;
|
||||
try {
|
||||
objectKey = located.font().getCOSObject().getKey();
|
||||
} catch (RuntimeException ignore) {
|
||||
}
|
||||
if (objectKey != null) {
|
||||
return "obj|" + objectKey.getNumber() + "." + objectKey.getGeneration();
|
||||
}
|
||||
return "res|p" + pageIndex + located.path();
|
||||
}
|
||||
|
||||
/** Lowercase hex SHA-256 of the PDF bytes; used as the reverse-map cache key. */
|
||||
private static String sha256Hex(byte[] bytes) {
|
||||
try {
|
||||
byte[] digest = java.security.MessageDigest.getInstance("SHA-256").digest(bytes);
|
||||
StringBuilder sb = new StringBuilder(digest.length * 2);
|
||||
for (byte b : digest) {
|
||||
sb.append(Character.forDigit((b >> 4) & 0xf, 16));
|
||||
sb.append(Character.forDigit(b & 0xf, 16));
|
||||
}
|
||||
return sb.toString();
|
||||
} catch (java.security.NoSuchAlgorithmException e) {
|
||||
// SHA-256 is always present in a JRE; fall back to a length+hash key just in case so
|
||||
// the cache still functions (correctness holds - collisions only cost a rebuild).
|
||||
return bytes.length + ":" + java.util.Arrays.hashCode(bytes);
|
||||
}
|
||||
}
|
||||
|
||||
private static java.util.Map<String, Long> computeReverseUnicodeMap(PDFont font) {
|
||||
java.util.Map<String, Long> out = new java.util.HashMap<>();
|
||||
int upper = font.isStandard14() ? 256 : 0x10000;
|
||||
for (int cc = 0; cc < upper; cc++) {
|
||||
String u;
|
||||
try {
|
||||
u = font.toUnicode(cc);
|
||||
} catch (Exception ignore) {
|
||||
continue;
|
||||
}
|
||||
if (u == null || u.isEmpty()) continue;
|
||||
// First charcode wins for a given Unicode (the canonical mapping).
|
||||
out.putIfAbsent(u, (long) cc);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
}
|
||||
@@ -95,7 +95,8 @@ public class UIDataController {
|
||||
|
||||
try (InputStream is = resource.getInputStream()) {
|
||||
Map<String, List<Dependency>> licenseData =
|
||||
objectMapper.readValue(is, new TypeReference<>() {});
|
||||
objectMapper.readValue(
|
||||
is, new TypeReference<Map<String, List<Dependency>>>() {});
|
||||
data.setDependencies(licenseData.get("dependencies"));
|
||||
} catch (IOException e) {
|
||||
log.error("Failed to load licenses data", e);
|
||||
|
||||
+285
-5
@@ -2,10 +2,20 @@ package stirling.software.SPDF.controller.api.form;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.StringWriter;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Base64;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.stream.Stream;
|
||||
import java.util.zip.CRC32;
|
||||
import java.util.zip.ZipEntry;
|
||||
import java.util.zip.ZipOutputStream;
|
||||
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.apache.poi.ss.usermodel.*;
|
||||
@@ -35,6 +45,7 @@ import stirling.software.common.model.FormFieldWithCoordinates;
|
||||
import stirling.software.common.service.CustomPDFDocumentFactory;
|
||||
import stirling.software.common.util.ExceptionUtils;
|
||||
import stirling.software.common.util.FormUtils;
|
||||
import stirling.software.common.util.TempFile;
|
||||
import stirling.software.common.util.TempFileManager;
|
||||
import stirling.software.common.util.WebResponseUtils;
|
||||
|
||||
@@ -59,6 +70,25 @@ import tools.jackson.databind.ObjectMapper;
|
||||
@RequiredArgsConstructor
|
||||
public class FormFillController {
|
||||
|
||||
/** Carries the edits a request asked for but the document could not take, as base64 JSON. */
|
||||
public static final String SKIPPED_EDITS_HEADER = "X-Stirling-Skipped-Field-Edits";
|
||||
|
||||
/** How many were skipped in total, which may exceed the number listed in the header above. */
|
||||
public static final String SKIPPED_EDITS_TOTAL_HEADER = "X-Stirling-Skipped-Field-Edits-Total";
|
||||
|
||||
/** Keeps the header well inside Jetty's response-header budget. */
|
||||
private static final int MAX_REPORTED_SKIPS = 20;
|
||||
|
||||
/** Bytes of encoded header value, well under the container's limit for the whole header set. */
|
||||
private static final int MAX_SKIP_HEADER_BYTES = 4096;
|
||||
|
||||
private static final int MAX_SKIP_FIELD_CHARS = 120;
|
||||
|
||||
/** Entry names inside the {@code ?includeFields=true} bundle. */
|
||||
private static final String FIELDS_ENTRY = "fields.json";
|
||||
|
||||
private static final String DOCUMENT_ENTRY = "document.pdf";
|
||||
|
||||
private final CustomPDFDocumentFactory pdfDocumentFactory;
|
||||
private final ObjectMapper objectMapper;
|
||||
private final TempFileManager tempFileManager;
|
||||
@@ -68,6 +98,72 @@ public class FormFillController {
|
||||
return WebResponseUtils.pdfDocToWebResponse(document, baseName + ".pdf", tempFileManager);
|
||||
}
|
||||
|
||||
/**
|
||||
* Rejects field names PDFBox cannot store before the document is touched, so the caller gets a
|
||||
* 400 naming the offending character instead of a 200 with the field quietly missing.
|
||||
*/
|
||||
private static void requireUsableFieldNames(
|
||||
List<FormUtils.NewFormFieldDefinition> adds,
|
||||
List<FormUtils.ModifyFormFieldDefinition> modifies) {
|
||||
Stream<String> problems =
|
||||
Stream.concat(
|
||||
adds.stream()
|
||||
.map(FormUtils.NewFormFieldDefinition::name)
|
||||
.map(FormUtils::invalidFieldNameReason),
|
||||
// A rename to the same name is not a rename, so a nested field whose
|
||||
// qualified name already contains a period is left alone.
|
||||
modifies.stream()
|
||||
.map(m -> FormUtils.renameProblem(m.targetName(), m.name())));
|
||||
problems.filter(Objects::nonNull)
|
||||
.findFirst()
|
||||
.ifPresent(
|
||||
reason -> {
|
||||
throw ExceptionUtils.createIllegalArgumentException(
|
||||
"error.invalidArgument", "{0}", reason);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The body is the updated PDF, so dropped edits travel as a base64 JSON header;
|
||||
* percent-encoding would turn every space into a plus sign.
|
||||
*/
|
||||
private ResponseEntity<Resource> withSkippedEdits(
|
||||
ResponseEntity<Resource> response, List<FormUtils.SkippedFieldEdit> skipped) {
|
||||
if (skipped.isEmpty()) {
|
||||
return response;
|
||||
}
|
||||
// A count cap alone is not enough: one very long field name can still overflow the
|
||||
// header budget and turn the response into an error page, losing the edited PDF.
|
||||
List<FormUtils.SkippedFieldEdit> reported = new ArrayList<>();
|
||||
String encoded = "";
|
||||
for (FormUtils.SkippedFieldEdit edit : skipped) {
|
||||
if (reported.size() >= MAX_REPORTED_SKIPS) {
|
||||
break;
|
||||
}
|
||||
reported.add(
|
||||
new FormUtils.SkippedFieldEdit(
|
||||
edit.operation(),
|
||||
FormUtils.abbreviate(edit.target(), MAX_SKIP_FIELD_CHARS),
|
||||
FormUtils.abbreviate(edit.reason(), MAX_SKIP_FIELD_CHARS)));
|
||||
String candidate =
|
||||
Base64.getEncoder()
|
||||
.encodeToString(
|
||||
objectMapper
|
||||
.writeValueAsString(reported)
|
||||
.getBytes(StandardCharsets.UTF_8));
|
||||
if (candidate.length() > MAX_SKIP_HEADER_BYTES) {
|
||||
reported.removeLast();
|
||||
break;
|
||||
}
|
||||
encoded = candidate;
|
||||
}
|
||||
return ResponseEntity.status(response.getStatusCode())
|
||||
.headers(response.getHeaders())
|
||||
.header(SKIPPED_EDITS_TOTAL_HEADER, String.valueOf(skipped.size()))
|
||||
.header(SKIPPED_EDITS_HEADER, encoded)
|
||||
.body(response.getBody());
|
||||
}
|
||||
|
||||
private static String buildBaseName(MultipartFile file, String suffix) {
|
||||
String original = Filenames.toSimpleFileName(file.getOriginalFilename());
|
||||
if (original == null || original.isBlank()) {
|
||||
@@ -257,6 +353,110 @@ public class FormFillController {
|
||||
}
|
||||
}
|
||||
|
||||
@PostMapping(value = "/add-fields", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||
@Operation(
|
||||
summary = "Add new form fields",
|
||||
description =
|
||||
"Creates new form fields in the provided PDF and returns the updated file")
|
||||
public ResponseEntity<Resource> addFields(
|
||||
@Parameter(
|
||||
description = "The input PDF file",
|
||||
required = true,
|
||||
content =
|
||||
@Content(
|
||||
mediaType = MediaType.APPLICATION_PDF_VALUE,
|
||||
schema = @Schema(type = "string", format = "binary")))
|
||||
@RequestParam("file")
|
||||
MultipartFile file,
|
||||
@Parameter(
|
||||
description = "JSON array of new field definitions",
|
||||
example =
|
||||
"[{\"name\":\"NewField\",\"type\":\"text\",\"pageIndex\":0,"
|
||||
+ "\"x\":50,\"y\":700,\"width\":200,\"height\":20}]")
|
||||
@RequestPart(value = "fields", required = false)
|
||||
byte[] fieldsPayload)
|
||||
throws IOException {
|
||||
|
||||
String rawFields = decodePart(fieldsPayload);
|
||||
List<FormUtils.NewFormFieldDefinition> definitions =
|
||||
FormPayloadParser.parseNewFieldDefinitions(objectMapper, rawFields);
|
||||
if (definitions.isEmpty()) {
|
||||
throw ExceptionUtils.createIllegalArgumentException(
|
||||
"error.dataRequired",
|
||||
"{0} must contain at least one definition",
|
||||
"fields payload");
|
||||
}
|
||||
|
||||
requireUsableFieldNames(definitions, List.of());
|
||||
|
||||
List<FormUtils.SkippedFieldEdit> skipped = new ArrayList<>();
|
||||
return withSkippedEdits(
|
||||
processSingleFile(
|
||||
file,
|
||||
"updated",
|
||||
document -> FormUtils.addNewFields(document, definitions, skipped)),
|
||||
skipped);
|
||||
}
|
||||
|
||||
@PostMapping(value = "/edit-fields", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||
@Operation(
|
||||
summary = "Apply a batch of form field edits",
|
||||
description =
|
||||
"Adds, modifies, and deletes form fields in a single request (one document"
|
||||
+ " load/save) and returns the updated file")
|
||||
public ResponseEntity<Resource> editFields(
|
||||
@Parameter(
|
||||
description = "The input PDF file",
|
||||
required = true,
|
||||
content =
|
||||
@Content(
|
||||
mediaType = MediaType.APPLICATION_PDF_VALUE,
|
||||
schema = @Schema(type = "string", format = "binary")))
|
||||
@RequestParam("file")
|
||||
MultipartFile file,
|
||||
@Parameter(
|
||||
description =
|
||||
"JSON object with optional 'add', 'modify' and 'delete'"
|
||||
+ " sections",
|
||||
example =
|
||||
"{\"add\":[{\"name\":\"f\",\"type\":\"text\",\"pageIndex\":0,"
|
||||
+ "\"x\":50,\"y\":700,\"width\":200,\"height\":20}],"
|
||||
+ "\"modify\":[],\"delete\":[]}")
|
||||
@RequestPart(value = "edits", required = false)
|
||||
byte[] editsPayload,
|
||||
@Parameter(
|
||||
description =
|
||||
"Return a ZIP holding the updated PDF plus the field list it"
|
||||
+ " produced, instead of the bare PDF. Saves re-uploading"
|
||||
+ " the result just to read its fields back.")
|
||||
@RequestParam(value = "includeFields", defaultValue = "false")
|
||||
boolean includeFields)
|
||||
throws IOException {
|
||||
|
||||
String rawEdits = decodePart(editsPayload);
|
||||
FormUtils.FieldEditBatch batch = FormPayloadParser.parseFieldEdits(objectMapper, rawEdits);
|
||||
if (batch.add().isEmpty() && batch.modify().isEmpty() && batch.delete().isEmpty()) {
|
||||
throw ExceptionUtils.createIllegalArgumentException(
|
||||
"error.dataRequired", "{0} must contain at least one edit", "edits payload");
|
||||
}
|
||||
requireUsableFieldNames(batch.add(), batch.modify());
|
||||
|
||||
List<FormUtils.SkippedFieldEdit> skipped = new ArrayList<>();
|
||||
return withSkippedEdits(
|
||||
processSingleFile(
|
||||
file,
|
||||
"updated",
|
||||
includeFields,
|
||||
document ->
|
||||
FormUtils.applyFieldEdits(
|
||||
document,
|
||||
batch.add(),
|
||||
batch.modify(),
|
||||
batch.delete(),
|
||||
skipped)),
|
||||
skipped);
|
||||
}
|
||||
|
||||
@PostMapping(value = "/modify-fields", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||
@Operation(
|
||||
summary = "Modify existing form fields",
|
||||
@@ -285,8 +485,15 @@ public class FormFillController {
|
||||
"updates payload");
|
||||
}
|
||||
|
||||
return processSingleFile(
|
||||
file, "updated", document -> FormUtils.modifyFormFields(document, modifications));
|
||||
requireUsableFieldNames(List.of(), modifications);
|
||||
|
||||
List<FormUtils.SkippedFieldEdit> skipped = new ArrayList<>();
|
||||
return withSkippedEdits(
|
||||
processSingleFile(
|
||||
file,
|
||||
"updated",
|
||||
document -> FormUtils.modifyFormFields(document, modifications, skipped)),
|
||||
skipped);
|
||||
}
|
||||
|
||||
@PostMapping(value = "/delete-fields", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||
@@ -319,8 +526,13 @@ public class FormFillController {
|
||||
"error.dataRequired", "{0} must contain at least one value", "names payload");
|
||||
}
|
||||
|
||||
return processSingleFile(
|
||||
file, "updated", document -> FormUtils.deleteFormFields(document, names));
|
||||
List<FormUtils.SkippedFieldEdit> skipped = new ArrayList<>();
|
||||
return withSkippedEdits(
|
||||
processSingleFile(
|
||||
file,
|
||||
"updated",
|
||||
document -> FormUtils.deleteFormFields(document, names, skipped)),
|
||||
skipped);
|
||||
}
|
||||
|
||||
@PostMapping(value = "/fill", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||
@@ -358,13 +570,81 @@ public class FormFillController {
|
||||
|
||||
private ResponseEntity<Resource> processSingleFile(
|
||||
MultipartFile file, String suffix, DocumentProcessor processor) throws IOException {
|
||||
return processSingleFile(file, suffix, false, processor);
|
||||
}
|
||||
|
||||
private ResponseEntity<Resource> processSingleFile(
|
||||
MultipartFile file, String suffix, boolean includeFields, DocumentProcessor processor)
|
||||
throws IOException {
|
||||
requirePdf(file);
|
||||
|
||||
String baseName = buildBaseName(file, suffix);
|
||||
try (PDDocument document = pdfDocumentFactory.load(file)) {
|
||||
FormUtils.repairMissingWidgetPageReferences(document);
|
||||
processor.accept(document);
|
||||
return saveDocument(document, baseName);
|
||||
return includeFields
|
||||
? saveDocumentWithFields(document, baseName)
|
||||
: saveDocument(document, baseName);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Answers "what fields does the saved file have?" from the document still open here, so the
|
||||
* caller does not have to upload the result back to ask.
|
||||
*/
|
||||
private ResponseEntity<Resource> saveDocumentWithFields(PDDocument document, String baseName)
|
||||
throws IOException {
|
||||
TempFile zip = null;
|
||||
boolean zipTransferred = false;
|
||||
try (TempFile pdf = tempFileManager.createManagedTempFile(".pdf")) {
|
||||
document.save(pdf.getFile());
|
||||
// Read the fields after the save so they describe the bytes actually being returned.
|
||||
byte[] fields =
|
||||
objectMapper.writeValueAsBytes(
|
||||
FormUtils.extractFormFieldsWithCoordinates(document));
|
||||
zip = tempFileManager.createManagedTempFile(".zip");
|
||||
writeFieldBundle(zip.getPath(), pdf.getPath(), fields);
|
||||
ResponseEntity<Resource> response =
|
||||
WebResponseUtils.zipFileToWebResponse(zip, baseName + ".zip");
|
||||
zipTransferred = true;
|
||||
return response;
|
||||
} finally {
|
||||
if (zip != null && !zipTransferred) {
|
||||
zip.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deflates the JSON because it is text, but stores the PDF: its streams are already compressed,
|
||||
* so deflating costs ~25ms per MB to save a few percent.
|
||||
*/
|
||||
private static void writeFieldBundle(Path zipPath, Path pdfPath, byte[] fields)
|
||||
throws IOException {
|
||||
long pdfSize = Files.size(pdfPath);
|
||||
CRC32 crc = new CRC32();
|
||||
try (InputStream in = Files.newInputStream(pdfPath)) {
|
||||
byte[] buffer = new byte[8192];
|
||||
for (int read; (read = in.read(buffer)) != -1; ) {
|
||||
crc.update(buffer, 0, read);
|
||||
}
|
||||
}
|
||||
try (ZipOutputStream zip = new ZipOutputStream(Files.newOutputStream(zipPath))) {
|
||||
ZipEntry fieldsEntry = new ZipEntry(FIELDS_ENTRY);
|
||||
fieldsEntry.setMethod(ZipEntry.DEFLATED);
|
||||
zip.putNextEntry(fieldsEntry);
|
||||
zip.write(fields);
|
||||
zip.closeEntry();
|
||||
|
||||
ZipEntry documentEntry = new ZipEntry(DOCUMENT_ENTRY);
|
||||
documentEntry.setMethod(ZipEntry.STORED);
|
||||
documentEntry.setSize(pdfSize);
|
||||
documentEntry.setCompressedSize(pdfSize);
|
||||
documentEntry.setCrc(crc.getValue());
|
||||
zip.putNextEntry(documentEntry);
|
||||
Files.copy(pdfPath, zip);
|
||||
zip.closeEntry();
|
||||
zip.finish();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+45
-3
@@ -25,10 +25,15 @@ final class FormPayloadParser {
|
||||
private static final String KEY_VALUE = "value";
|
||||
private static final String KEY_DEFAULT_VALUE = "defaultValue";
|
||||
|
||||
private static final TypeReference<Map<String, Object>> MAP_TYPE = new TypeReference<>() {};
|
||||
private static final TypeReference<Map<String, Object>> MAP_TYPE =
|
||||
new TypeReference<Map<String, Object>>() {};
|
||||
private static final TypeReference<List<FormUtils.ModifyFormFieldDefinition>>
|
||||
MODIFY_FIELD_LIST_TYPE = new TypeReference<>() {};
|
||||
private static final TypeReference<List<String>> STRING_LIST_TYPE = new TypeReference<>() {};
|
||||
MODIFY_FIELD_LIST_TYPE =
|
||||
new TypeReference<List<FormUtils.ModifyFormFieldDefinition>>() {};
|
||||
private static final TypeReference<List<FormUtils.NewFormFieldDefinition>> NEW_FIELD_LIST_TYPE =
|
||||
new TypeReference<>() {};
|
||||
private static final TypeReference<List<String>> STRING_LIST_TYPE =
|
||||
new TypeReference<List<String>>() {};
|
||||
|
||||
private FormPayloadParser() {}
|
||||
|
||||
@@ -94,6 +99,43 @@ final class FormPayloadParser {
|
||||
return objectMapper.readValue(json, MODIFY_FIELD_LIST_TYPE);
|
||||
}
|
||||
|
||||
static List<FormUtils.NewFormFieldDefinition> parseNewFieldDefinitions(
|
||||
ObjectMapper objectMapper, String json) {
|
||||
if (json == null || json.isBlank()) {
|
||||
return List.of();
|
||||
}
|
||||
return objectMapper.readValue(json, NEW_FIELD_LIST_TYPE);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses a combined edit batch: {@code {"add":[...],"modify":[...],"delete":[...]}}. Each
|
||||
* section is optional. The delete section accepts the same shapes as {@link #parseNameList}.
|
||||
*/
|
||||
static FormUtils.FieldEditBatch parseFieldEdits(ObjectMapper objectMapper, String json) {
|
||||
if (json == null || json.isBlank()) {
|
||||
return new FormUtils.FieldEditBatch(List.of(), List.of(), List.of());
|
||||
}
|
||||
final JsonNode root = objectMapper.readTree(json);
|
||||
List<FormUtils.NewFormFieldDefinition> adds = List.of();
|
||||
List<FormUtils.ModifyFormFieldDefinition> modifies = List.of();
|
||||
List<String> deletes = List.of();
|
||||
if (root != null && root.isObject()) {
|
||||
final JsonNode addNode = root.get("add");
|
||||
if (addNode != null && addNode.isArray()) {
|
||||
adds = objectMapper.readValue(addNode.toString(), NEW_FIELD_LIST_TYPE);
|
||||
}
|
||||
final JsonNode modifyNode = root.get("modify");
|
||||
if (modifyNode != null && modifyNode.isArray()) {
|
||||
modifies = objectMapper.readValue(modifyNode.toString(), MODIFY_FIELD_LIST_TYPE);
|
||||
}
|
||||
final JsonNode deleteNode = root.get("delete");
|
||||
if (deleteNode != null && !deleteNode.isNull()) {
|
||||
deletes = parseNameList(objectMapper, deleteNode.toString());
|
||||
}
|
||||
}
|
||||
return new FormUtils.FieldEditBatch(adds, modifies, deletes);
|
||||
}
|
||||
|
||||
static List<String> parseNameList(ObjectMapper objectMapper, String json) {
|
||||
if (json == null || json.isBlank()) {
|
||||
return List.of();
|
||||
|
||||
+3
-1
@@ -96,7 +96,9 @@ public class AddCommentsController {
|
||||
|
||||
List<CommentSpecDto> dtos;
|
||||
try {
|
||||
dtos = objectMapper.readValue(commentsJson, new TypeReference<>() {});
|
||||
dtos =
|
||||
objectMapper.readValue(
|
||||
commentsJson, new TypeReference<List<CommentSpecDto>>() {});
|
||||
} catch (JacksonException e) {
|
||||
throw new ResponseStatusException(
|
||||
HttpStatus.BAD_REQUEST, "comments must be a JSON array of CommentSpec objects");
|
||||
|
||||
+13
@@ -338,6 +338,19 @@ public class ConfigController {
|
||||
// Premium/Enterprise settings
|
||||
configData.put("premiumEnabled", applicationProperties.getPremium().isEnabled());
|
||||
|
||||
// Whether this instance can link a Stirling (SaaS) account at all. The account-link
|
||||
// beans live in :proprietary and are @ConditionalOnProperty on this same key, so when
|
||||
// it is off they are absent and /api/v1/account-link/* returns 404. The frontend cannot
|
||||
// tell that 404 apart from "not linked yet", so it needs this told to it explicitly
|
||||
// before it can prompt anyone to link. Read from the environment rather than
|
||||
// AccountLinkProperties because :core must not depend on :proprietary.
|
||||
configData.put(
|
||||
"accountLinkAvailable",
|
||||
applicationContext
|
||||
.getEnvironment()
|
||||
.getProperty(
|
||||
"stirling.billing.account-link.enabled", Boolean.class, false));
|
||||
|
||||
// AI Engine settings
|
||||
ApplicationProperties.AiEngine aiEngineConfig = applicationProperties.getAiEngine();
|
||||
configData.put("aiEngineEnabled", aiEngineConfig.isEnabled());
|
||||
|
||||
@@ -114,6 +114,7 @@ public class OCRController {
|
||||
List<String> selectedLanguages = request.getLanguages();
|
||||
boolean sidecar = request.isSidecar();
|
||||
Boolean deskew = request.isDeskew();
|
||||
Boolean rotatePages = request.isRotatePages();
|
||||
Boolean clean = request.isClean();
|
||||
Boolean cleanFinal = request.isCleanFinal();
|
||||
String ocrType = request.getOcrType();
|
||||
@@ -154,6 +155,7 @@ public class OCRController {
|
||||
selectedLanguages,
|
||||
sidecar,
|
||||
deskew,
|
||||
rotatePages,
|
||||
clean,
|
||||
cleanFinal,
|
||||
ocrType,
|
||||
@@ -236,6 +238,7 @@ public class OCRController {
|
||||
List<String> selectedLanguages,
|
||||
Boolean sidecar,
|
||||
Boolean deskew,
|
||||
Boolean rotatePages,
|
||||
Boolean clean,
|
||||
Boolean cleanFinal,
|
||||
String ocrType,
|
||||
@@ -268,6 +271,10 @@ public class OCRController {
|
||||
if (deskew != null && deskew) {
|
||||
command.add("--deskew");
|
||||
}
|
||||
if (rotatePages != null && rotatePages) {
|
||||
// Tesseract OSD-based automatic page orientation correction (90/180/270)
|
||||
command.add("--rotate-pages");
|
||||
}
|
||||
if (clean != null && clean) {
|
||||
command.add("--clean");
|
||||
}
|
||||
|
||||
+4
@@ -221,6 +221,10 @@ public class RedactController {
|
||||
.normalizeFonts(false)
|
||||
.fixToUnicode(false)
|
||||
.glyphAware(true)
|
||||
.ligatureAware(true)
|
||||
.bidiAware(true)
|
||||
.graphemeSafe(true)
|
||||
.sanitizeStructure(false) // WIP/Experimental API
|
||||
.redactMetadata(true)
|
||||
.build();
|
||||
|
||||
|
||||
+4
@@ -110,6 +110,10 @@ class TextRedactionService {
|
||||
.fixToUnicode(false)
|
||||
.repairWidths(false)
|
||||
.glyphAware(true)
|
||||
.ligatureAware(true)
|
||||
.bidiAware(true)
|
||||
.graphemeSafe(true)
|
||||
.sanitizeStructure(false)
|
||||
.build();
|
||||
|
||||
try (PdfDocument checkDoc = PdfDocument.open(tempIn.toPath())) {
|
||||
|
||||
+5
@@ -25,6 +25,11 @@ public class ProcessPdfWithOcrRequest extends PDFFile {
|
||||
@Schema(description = "Deskew the input file if set to true")
|
||||
private boolean deskew;
|
||||
|
||||
@Schema(
|
||||
description =
|
||||
"Auto-correct page orientation (90/180/270) using Tesseract OSD if set to true")
|
||||
private boolean rotatePages;
|
||||
|
||||
@Schema(description = "Clean the input file if set to true")
|
||||
private boolean clean;
|
||||
|
||||
|
||||
+11
-8
@@ -4,7 +4,9 @@ import java.time.Instant;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.atomic.AtomicLong;
|
||||
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
@@ -21,7 +23,7 @@ public class WeeklyActiveUsersService {
|
||||
private final Map<String, Instant> activeBrowsers = new ConcurrentHashMap<>();
|
||||
|
||||
// Track total unique browsers seen (overall)
|
||||
private long totalUniqueBrowsers = 0;
|
||||
private final AtomicLong totalUniqueBrowsers = new AtomicLong(0);
|
||||
|
||||
// Application start time
|
||||
private final Instant startTime = Instant.now();
|
||||
@@ -36,12 +38,12 @@ public class WeeklyActiveUsersService {
|
||||
return;
|
||||
}
|
||||
|
||||
boolean isNewBrowser = !activeBrowsers.containsKey(browserId);
|
||||
activeBrowsers.put(browserId, Instant.now());
|
||||
Instant now = Instant.now();
|
||||
Instant previous = activeBrowsers.put(browserId, now);
|
||||
|
||||
if (isNewBrowser) {
|
||||
totalUniqueBrowsers++;
|
||||
log.debug("New browser recorded: {} (Total: {})", browserId, totalUniqueBrowsers);
|
||||
if (previous == null) {
|
||||
long total = totalUniqueBrowsers.incrementAndGet();
|
||||
log.debug("New browser recorded: {} (Total: {})", browserId, total);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,7 +63,7 @@ public class WeeklyActiveUsersService {
|
||||
* @return Total unique browsers count
|
||||
*/
|
||||
public long getTotalUniqueBrowsers() {
|
||||
return totalUniqueBrowsers;
|
||||
return totalUniqueBrowsers.get();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -88,7 +90,8 @@ public class WeeklyActiveUsersService {
|
||||
activeBrowsers.entrySet().removeIf(entry -> entry.getValue().isBefore(sevenDaysAgo));
|
||||
}
|
||||
|
||||
/** Manual cleanup trigger (can be called by scheduled task if needed) */
|
||||
/** Scheduled cleanup trigger running every hour */
|
||||
@Scheduled(fixedRate = 3600000)
|
||||
public void performCleanup() {
|
||||
int sizeBefore = activeBrowsers.size();
|
||||
cleanupOldEntries();
|
||||
|
||||
+4
-2
@@ -154,7 +154,8 @@ public class PdfJsonFontService {
|
||||
return "otf";
|
||||
}
|
||||
if (signature == 0x74746366) {
|
||||
return "cff";
|
||||
log.debug("[FONT-DEBUG] TrueType Collection ('ttcf') font program is unsupported");
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -175,7 +176,8 @@ public class PdfJsonFontService {
|
||||
return "otf";
|
||||
}
|
||||
if (signature == 0x74746366) {
|
||||
return "cff";
|
||||
log.debug("[FONT-DEBUG] TrueType Collection ('ttcf') FontFile2 is unsupported");
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -15,24 +15,63 @@
|
||||
<encoder>
|
||||
<pattern>%d %p %c{1} [%thread] %m%n</pattern>
|
||||
</encoder>
|
||||
<rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
|
||||
<fileNamePattern>${LOG_PATH}/auth-%d{yyyy-MM-dd}.log</fileNamePattern>
|
||||
<maxHistory>1</maxHistory>
|
||||
<!-- SizeAndTime, not Time alone: the size trigger is what stops a
|
||||
runaway logger filling the disk (see GENERAL appender note).
|
||||
Archives are gzipped, so 64 MB of them holds far more than a
|
||||
day. Worst case on disk is one 100 MB live file plus the cap. -->
|
||||
<rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
|
||||
<fileNamePattern>${LOG_PATH}/auth-%d{yyyy-MM-dd}.%i.log.gz</fileNamePattern>
|
||||
<maxFileSize>100MB</maxFileSize>
|
||||
<maxHistory>7</maxHistory>
|
||||
<totalSizeCap>64MB</totalSizeCap>
|
||||
</rollingPolicy>
|
||||
</appender>
|
||||
|
||||
<!-- Rolling File Appender for General Logs -->
|
||||
<!-- Rolling File Appender for General Logs
|
||||
|
||||
Why SizeAndTimeBased + totalSizeCap: a previous build of the v2 PDF
|
||||
text editor's reverse-CMap probe loop triggered PDSimpleFont to emit
|
||||
one "No Unicode mapping for .notdef" WARN per probed charcode per
|
||||
font per request. With TimeBasedRollingPolicy alone there was no
|
||||
size ceiling; info.log grew to 1.4 GB in a single day before the JVM
|
||||
choked. The class-level silencer fixes the specific offender, but
|
||||
this size cap is the defence-in-depth: any future logger that
|
||||
floods unexpectedly will roll + auto-delete instead of starving
|
||||
disk + Jetty threads. -->
|
||||
<appender name="GENERAL" class="ch.qos.logback.core.rolling.RollingFileAppender">
|
||||
<file>${LOG_PATH}/info.log</file>
|
||||
<encoder>
|
||||
<pattern>%d %p %c{1} [%thread] %m%n</pattern>
|
||||
</encoder>
|
||||
<rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
|
||||
<fileNamePattern>${LOG_PATH}/info-%d{yyyy-MM-dd}.log</fileNamePattern>
|
||||
<maxHistory>1</maxHistory>
|
||||
<rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
|
||||
<fileNamePattern>${LOG_PATH}/info-%d{yyyy-MM-dd}.%i.log.gz</fileNamePattern>
|
||||
<maxFileSize>100MB</maxFileSize>
|
||||
<maxHistory>7</maxHistory>
|
||||
<totalSizeCap>256MB</totalSizeCap>
|
||||
</rollingPolicy>
|
||||
</appender>
|
||||
|
||||
<!-- Suppress PDFBox PDSimpleFont's per-charcode .notdef WARN.
|
||||
|
||||
Required by the v2 PDF text editor's `buildReverseUnicodeMap`
|
||||
which DELIBERATELY iterates every charcode in 0..0xFFFF to
|
||||
discover the encoding-to-Unicode map of an embedded subset
|
||||
font. For any subset font ~99% of those probes hit .notdef,
|
||||
and the default WARN level for those misses turned info.log
|
||||
into a 1.4 GB monster overnight.
|
||||
|
||||
This declarative logback entry is the SOLE mechanism: it is
|
||||
visible to ops and revertable via configuration. An earlier
|
||||
build also mutated this logger's level from a static block in
|
||||
PdfTextEditorCharcodeController, which silenced the same
|
||||
warnings JVM-wide with no trace in any config file - that
|
||||
static block has been removed in favour of this entry. -->
|
||||
<logger name="org.apache.pdfbox.pdmodel.font.PDSimpleFont"
|
||||
level="ERROR" additivity="false">
|
||||
<appender-ref ref="CONSOLE"/>
|
||||
<appender-ref ref="GENERAL"/>
|
||||
</logger>
|
||||
|
||||
<!-- Root Logger -->
|
||||
<root level="INFO">
|
||||
<appender-ref ref="CONSOLE"/>
|
||||
|
||||
@@ -186,7 +186,7 @@ system:
|
||||
maxDPI: 500 # Maximum allowed DPI for PDF to image conversion
|
||||
corsAllowedOrigins: [] # List of allowed origins for CORS (e.g. ['http://localhost:5173', 'https://app.example.com']). WARNING: leaving this empty falls back to allowing ALL origins (with credentials), it does NOT disable CORS. Set explicit origins to lock it down.
|
||||
backendUrl: "" # Backend base URL for SAML/OAuth/API callbacks (e.g. 'http://localhost:8080' for dev, 'https://api.example.com' for production). REQUIRED for SSO authentication to work correctly. This is where your IdP will send SAML responses and OAuth callbacks. Leave empty to default to 'http://localhost:8080' in development.
|
||||
frontendUrl: "" # Frontend URL for invite email links (e.g. 'https://app.example.com'). Optional - if not set, will use backendUrl. This is the URL users click in invite emails.
|
||||
frontendUrl: "" # Base URL of the web app, as a browser reaches it (e.g. 'https://app.example.com', or 'https://example.com/app' if served under a base path). Optional - if not set, will use backendUrl. Used for any link handed to a browser: invite emails, share links, mobile QR codes, and the account-link handshake.
|
||||
enableMobileScanner: true # Enable mobile phone QR code upload feature. Requires frontendUrl to be configured.
|
||||
enableMobileSignature: true # Enable drawing signatures on a phone via QR code from the Sign tool. Requires frontendUrl to be configured.
|
||||
mobileScannerSettings:
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
{
|
||||
"moduleName": "ch.qos.logback:logback-classic",
|
||||
"moduleUrl": "http://www.qos.ch",
|
||||
"moduleVersion": "1.6.1",
|
||||
"moduleVersion": "1.6.3",
|
||||
"moduleLicense": "LGPL-2.1-only",
|
||||
"moduleLicenseUrl": "https://www.gnu.org/licenses/old-licenses/lgpl-2.1.html"
|
||||
},
|
||||
@@ -31,7 +31,7 @@
|
||||
{
|
||||
"moduleName": "ch.qos.logback:logback-core",
|
||||
"moduleUrl": "http://www.qos.ch",
|
||||
"moduleVersion": "1.6.1",
|
||||
"moduleVersion": "1.6.3",
|
||||
"moduleLicense": "LGPL-2.1-only",
|
||||
"moduleLicenseUrl": "https://www.gnu.org/licenses/old-licenses/lgpl-2.1.html"
|
||||
},
|
||||
@@ -94,7 +94,7 @@
|
||||
{
|
||||
"moduleName": "com.fasterxml.jackson.core:jackson-core",
|
||||
"moduleUrl": "https://github.com/FasterXML/jackson-core",
|
||||
"moduleVersion": "2.22.1",
|
||||
"moduleVersion": "2.22.2",
|
||||
"moduleLicense": "The Apache Software License, Version 2.0",
|
||||
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0.txt"
|
||||
},
|
||||
@@ -108,7 +108,7 @@
|
||||
{
|
||||
"moduleName": "com.fasterxml.jackson.core:jackson-databind",
|
||||
"moduleUrl": "https://github.com/FasterXML/jackson",
|
||||
"moduleVersion": "2.22.1",
|
||||
"moduleVersion": "2.22.2",
|
||||
"moduleLicense": "The Apache Software License, Version 2.0",
|
||||
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0.txt"
|
||||
},
|
||||
@@ -143,7 +143,7 @@
|
||||
{
|
||||
"moduleName": "com.fasterxml.jackson:jackson-bom",
|
||||
"moduleUrl": "https://github.com/FasterXML/jackson-bom",
|
||||
"moduleVersion": "2.22.1",
|
||||
"moduleVersion": "2.22.2",
|
||||
"moduleLicense": "The Apache Software License, Version 2.0",
|
||||
"moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt"
|
||||
},
|
||||
@@ -440,42 +440,14 @@
|
||||
{
|
||||
"moduleName": "com.stirling:jpdfium",
|
||||
"moduleUrl": "https://github.com/Stirling-Tools/JPDFium",
|
||||
"moduleVersion": "1.0.4",
|
||||
"moduleLicense": "MIT License",
|
||||
"moduleLicenseUrl": "https://opensource.org/licenses/MIT"
|
||||
},
|
||||
{
|
||||
"moduleName": "com.stirling:jpdfium-natives-darwin-arm64",
|
||||
"moduleUrl": "https://github.com/Stirling-Tools/JPDFium",
|
||||
"moduleVersion": "1.0.4",
|
||||
"moduleLicense": "MIT License",
|
||||
"moduleLicenseUrl": "https://opensource.org/licenses/MIT"
|
||||
},
|
||||
{
|
||||
"moduleName": "com.stirling:jpdfium-natives-darwin-x64",
|
||||
"moduleUrl": "https://github.com/Stirling-Tools/JPDFium",
|
||||
"moduleVersion": "1.0.4",
|
||||
"moduleLicense": "MIT License",
|
||||
"moduleLicenseUrl": "https://opensource.org/licenses/MIT"
|
||||
},
|
||||
{
|
||||
"moduleName": "com.stirling:jpdfium-natives-linux-arm64",
|
||||
"moduleUrl": "https://github.com/Stirling-Tools/JPDFium",
|
||||
"moduleVersion": "1.0.4",
|
||||
"moduleVersion": "1.1.3",
|
||||
"moduleLicense": "MIT License",
|
||||
"moduleLicenseUrl": "https://opensource.org/licenses/MIT"
|
||||
},
|
||||
{
|
||||
"moduleName": "com.stirling:jpdfium-natives-linux-x64",
|
||||
"moduleUrl": "https://github.com/Stirling-Tools/JPDFium",
|
||||
"moduleVersion": "1.0.4",
|
||||
"moduleLicense": "MIT License",
|
||||
"moduleLicenseUrl": "https://opensource.org/licenses/MIT"
|
||||
},
|
||||
{
|
||||
"moduleName": "com.stirling:jpdfium-natives-windows-x64",
|
||||
"moduleUrl": "https://github.com/Stirling-Tools/JPDFium",
|
||||
"moduleVersion": "1.0.4",
|
||||
"moduleVersion": "1.1.3",
|
||||
"moduleLicense": "MIT License",
|
||||
"moduleLicenseUrl": "https://opensource.org/licenses/MIT"
|
||||
},
|
||||
@@ -521,36 +493,18 @@
|
||||
"moduleLicense": "GNU General Public License, version 2 with the GNU Classpath Exception",
|
||||
"moduleLicenseUrl": "https://www.gnu.org/software/classpath/license.html"
|
||||
},
|
||||
{
|
||||
"moduleName": "com.twelvemonkeys.common:common-image",
|
||||
"moduleVersion": "3.13.1",
|
||||
"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",
|
||||
@@ -569,12 +523,6 @@
|
||||
"moduleLicense": "The BSD License",
|
||||
"moduleLicenseUrl": "https://github.com/haraldk/TwelveMonkeys#license"
|
||||
},
|
||||
{
|
||||
"moduleName": "com.twelvemonkeys.imageio:imageio-core",
|
||||
"moduleVersion": "3.13.1",
|
||||
"moduleLicense": "The BSD License",
|
||||
"moduleLicenseUrl": "https://github.com/haraldk/TwelveMonkeys#license"
|
||||
},
|
||||
{
|
||||
"moduleName": "com.twelvemonkeys.imageio:imageio-core",
|
||||
"moduleVersion": "3.14.0",
|
||||
@@ -587,12 +535,6 @@
|
||||
"moduleLicense": "The BSD License",
|
||||
"moduleLicenseUrl": "https://github.com/haraldk/TwelveMonkeys#license"
|
||||
},
|
||||
{
|
||||
"moduleName": "com.twelvemonkeys.imageio:imageio-metadata",
|
||||
"moduleVersion": "3.13.1",
|
||||
"moduleLicense": "The BSD License",
|
||||
"moduleLicenseUrl": "https://github.com/haraldk/TwelveMonkeys#license"
|
||||
},
|
||||
{
|
||||
"moduleName": "com.twelvemonkeys.imageio:imageio-metadata",
|
||||
"moduleVersion": "3.14.0",
|
||||
@@ -605,24 +547,12 @@
|
||||
"moduleLicense": "The BSD License",
|
||||
"moduleLicenseUrl": "https://github.com/haraldk/TwelveMonkeys#license"
|
||||
},
|
||||
{
|
||||
"moduleName": "com.twelvemonkeys.imageio:imageio-tiff",
|
||||
"moduleVersion": "3.13.1",
|
||||
"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",
|
||||
@@ -769,13 +699,6 @@
|
||||
"moduleLicense": "The Apache Software License, Version 2.0",
|
||||
"moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt"
|
||||
},
|
||||
{
|
||||
"moduleName": "commons-beanutils:commons-beanutils",
|
||||
"moduleUrl": "https://commons.apache.org/proper/commons-beanutils",
|
||||
"moduleVersion": "1.11.0",
|
||||
"moduleLicense": "Apache-2.0",
|
||||
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0.txt"
|
||||
},
|
||||
{
|
||||
"moduleName": "commons-cli:commons-cli",
|
||||
"moduleUrl": "http://commons.apache.org/proper/commons-cli/",
|
||||
@@ -790,13 +713,6 @@
|
||||
"moduleLicense": "Apache-2.0",
|
||||
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0.txt"
|
||||
},
|
||||
{
|
||||
"moduleName": "commons-collections:commons-collections",
|
||||
"moduleUrl": "http://commons.apache.org/collections/",
|
||||
"moduleVersion": "3.2.2",
|
||||
"moduleLicense": "Apache License, Version 2.0",
|
||||
"moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt"
|
||||
},
|
||||
{
|
||||
"moduleName": "commons-io:commons-io",
|
||||
"moduleUrl": "https://commons.apache.org/proper/commons-io/",
|
||||
@@ -1064,21 +980,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"
|
||||
},
|
||||
@@ -1090,9 +999,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"
|
||||
},
|
||||
@@ -1103,6 +1012,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",
|
||||
@@ -1360,13 +1276,6 @@
|
||||
"moduleLicense": "Apache-2.0",
|
||||
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0.txt"
|
||||
},
|
||||
{
|
||||
"moduleName": "org.apache.commons:commons-math3",
|
||||
"moduleUrl": "http://commons.apache.org/proper/commons-math/",
|
||||
"moduleVersion": "3.6.1",
|
||||
"moduleLicense": "Apache License, Version 2.0",
|
||||
"moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt"
|
||||
},
|
||||
{
|
||||
"moduleName": "org.apache.commons:commons-text",
|
||||
"moduleUrl": "https://commons.apache.org/proper/commons-text",
|
||||
@@ -2304,7 +2213,7 @@
|
||||
},
|
||||
{
|
||||
"moduleName": "org.simplejavamail:core-module",
|
||||
"moduleVersion": "9.3.1",
|
||||
"moduleVersion": "9.3.2",
|
||||
"moduleLicense": "The Apache Software License, Version 2.0",
|
||||
"moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt"
|
||||
},
|
||||
@@ -2317,13 +2226,13 @@
|
||||
},
|
||||
{
|
||||
"moduleName": "org.simplejavamail:outlook-module",
|
||||
"moduleVersion": "9.3.1",
|
||||
"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.3.1",
|
||||
"moduleVersion": "9.3.2",
|
||||
"moduleLicense": "The Apache Software License, Version 2.0",
|
||||
"moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt"
|
||||
},
|
||||
@@ -2343,10 +2252,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",
|
||||
|
||||
+2
@@ -57,6 +57,8 @@ class ToolIODeclarationCoverageTest {
|
||||
// documents.
|
||||
"/api/v1/convert/pdf/text-editor",
|
||||
"/api/v1/convert/text-editor/pdf",
|
||||
// Charcode lookup for the v2 editor: returns glyph mappings, not a document.
|
||||
"/api/v1/general/pdf-text-editor",
|
||||
// Signing sessions, certificate checks and hardware token enumeration; the
|
||||
// signing tool itself is /api/v1/security/cert-sign, which is declared.
|
||||
"/api/v1/security/cert-sign/sessions",
|
||||
|
||||
+516
@@ -0,0 +1,516 @@
|
||||
package stirling.software.SPDF.controller.api;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
import javax.imageio.ImageIO;
|
||||
|
||||
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.PDPageContentStream;
|
||||
import org.apache.pdfbox.pdmodel.PDResources;
|
||||
import org.apache.pdfbox.pdmodel.font.PDFont;
|
||||
import org.apache.pdfbox.pdmodel.font.PDFontDescriptor;
|
||||
import org.apache.pdfbox.pdmodel.font.PDType0Font;
|
||||
import org.apache.pdfbox.pdmodel.font.PDType1Font;
|
||||
import org.apache.pdfbox.pdmodel.font.PDType3Font;
|
||||
import org.apache.pdfbox.pdmodel.font.Standard14Fonts;
|
||||
import org.apache.pdfbox.rendering.PDFRenderer;
|
||||
import org.junit.jupiter.api.Disabled;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
/**
|
||||
* Probe: what can PDFBox actually do for font ENCODING on real-world PDFs. This is a diagnostic
|
||||
* test (not a regression) - run with --tests PdfBoxFontEncodingProbeTest -i to see stdout.
|
||||
*
|
||||
* <p>Answers these questions:
|
||||
*
|
||||
* <ol>
|
||||
* <li>Type0/CIDFontType2 subset: can we add a new glyph not in the original subset? (no, encode
|
||||
* throws IllegalArgumentException).
|
||||
* <li>Type1: same question.
|
||||
* <li>TrueType: same question.
|
||||
* <li>Can we load a fresh TTF via PDType0Font.load(doc, file) and write text with it? (yes,
|
||||
* primary path).
|
||||
* <li>Round-trip via getFontStream / re-embed - can it rehabilitate Type3? (no - Type3 has no
|
||||
* FontFile* program at all).
|
||||
* <li>What fonts ship with PDFBox / fontbox? (only LiberationSans-Regular.ttf + AFM for the 14
|
||||
* standard fonts; CFF/Type1 binaries are NOT bundled - Standard14Fonts.getMappedFontName
|
||||
* redirects unmappable ones to LiberationSans).
|
||||
* </ol>
|
||||
*/
|
||||
@Disabled(
|
||||
"Diagnostic probe: dumps PDFBox font encoding tables to stdout and asserts nothing. Kept for font debugging; run manually.")
|
||||
public class PdfBoxFontEncodingProbeTest {
|
||||
|
||||
private static final Path PROJECT_ROOT =
|
||||
Paths.get(System.getProperty("user.dir")).getParent().getParent();
|
||||
|
||||
private static final Path SAMPLE =
|
||||
PROJECT_ROOT.resolve("frontend/editor/public/samples/Sample.pdf");
|
||||
|
||||
private static final Path[] EXTRA_FIXTURES = {
|
||||
PROJECT_ROOT.resolve("frontend/editor/src/core/tests/test-fixtures/stirling-marketing.pdf"),
|
||||
PROJECT_ROOT.resolve("frontend/editor/src/core/tests/test-fixtures/multi-page-sample.pdf"),
|
||||
PROJECT_ROOT.resolve("frontend/editor/src/core/tests/test-fixtures/big-sample.pdf"),
|
||||
PROJECT_ROOT.resolve("frontend/editor/src/core/tests/test-fixtures/paragraph-sample.pdf"),
|
||||
PROJECT_ROOT.resolve("frontend/editor/src/core/tests/test-fixtures/user-sample.pdf"),
|
||||
};
|
||||
|
||||
/**
|
||||
* Rasterize the Q4b output (Sample.pdf with injected Liberation text) to confirm the new text
|
||||
* actually renders on top of the existing Type3 content.
|
||||
*/
|
||||
@Test
|
||||
public void probeRenderInjectedSample() throws IOException {
|
||||
Path liberation =
|
||||
PROJECT_ROOT.resolve(
|
||||
"app/core/src/main/resources/static/fonts/LiberationSans-Regular.ttf");
|
||||
byte[] pdfBytes = Files.readAllBytes(SAMPLE);
|
||||
ByteArrayOutputStream out = new ByteArrayOutputStream();
|
||||
try (PDDocument doc = Loader.loadPDF(pdfBytes)) {
|
||||
PDPage page = doc.getPage(0);
|
||||
PDType0Font ttf;
|
||||
try (InputStream in = Files.newInputStream(liberation)) {
|
||||
ttf = PDType0Font.load(doc, in, true);
|
||||
}
|
||||
try (PDPageContentStream cs =
|
||||
new PDPageContentStream(
|
||||
doc, page, PDPageContentStream.AppendMode.APPEND, true, true)) {
|
||||
cs.beginText();
|
||||
cs.setFont(ttf, 24);
|
||||
cs.newLineAtOffset(50, 120);
|
||||
cs.showText("INJECTED via PDType0Font.load - $@#&Z");
|
||||
cs.endText();
|
||||
}
|
||||
doc.save(out);
|
||||
}
|
||||
// Rasterize page 0 to a PNG so we can eyeball it.
|
||||
try (PDDocument check = Loader.loadPDF(out.toByteArray())) {
|
||||
PDFRenderer renderer = new PDFRenderer(check);
|
||||
java.awt.image.BufferedImage img = renderer.renderImageWithDPI(0, 100);
|
||||
// Build dir, not the repo root: this render is a debugging aid and was
|
||||
// twice committed by accident when it landed in the working tree.
|
||||
Path png =
|
||||
Paths.get(System.getProperty("user.dir"), "build", "probe-output")
|
||||
.resolve("pdfbox-probe-q4b-rendered.png");
|
||||
Files.createDirectories(png.getParent());
|
||||
ImageIO.write(img, "PNG", png.toFile());
|
||||
System.out.println(
|
||||
"Rendered injected sample to "
|
||||
+ png
|
||||
+ " - "
|
||||
+ img.getWidth()
|
||||
+ "x"
|
||||
+ img.getHeight());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a PDF in memory that uses a Type0/CIDFontType2 subset font (the kind Word / InDesign /
|
||||
* LibreOffice produce), then probe whether encode() can add a glyph that wasn't in the original
|
||||
* subset.
|
||||
*/
|
||||
@Test
|
||||
public void probeType0CIDFontType2Subset() throws IOException {
|
||||
System.out.println(
|
||||
"\n##################################################################\n"
|
||||
+ "Q1 probe: Type0/CIDFontType2 SUBSET can/cannot add new glyphs\n"
|
||||
+ "##################################################################\n");
|
||||
Path liberation =
|
||||
PROJECT_ROOT.resolve(
|
||||
"app/core/src/main/resources/static/fonts/LiberationSans-Regular.ttf");
|
||||
|
||||
// Build a PDF that contains only "abc" subsetted from LiberationSans.
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
PDPage page = new PDPage();
|
||||
doc.addPage(page);
|
||||
PDType0Font subset;
|
||||
try (InputStream in = Files.newInputStream(liberation)) {
|
||||
subset = PDType0Font.load(doc, in, true /* embedSubset */);
|
||||
}
|
||||
try (PDPageContentStream cs = new PDPageContentStream(doc, page)) {
|
||||
cs.beginText();
|
||||
cs.setFont(subset, 12);
|
||||
cs.newLineAtOffset(100, 700);
|
||||
cs.showText("abc");
|
||||
cs.endText();
|
||||
}
|
||||
doc.save(baos);
|
||||
}
|
||||
|
||||
// Reload the produced PDF and try to add a NEW glyph through the embedded subset font.
|
||||
byte[] subsetPdf = baos.toByteArray();
|
||||
try (PDDocument doc = Loader.loadPDF(subsetPdf)) {
|
||||
PDResources res = doc.getPage(0).getResources();
|
||||
for (COSName fn : res.getFontNames()) {
|
||||
PDFont f = res.getFont(fn);
|
||||
System.out.println(
|
||||
" Subset font in saved PDF: "
|
||||
+ f.getName()
|
||||
+ " ("
|
||||
+ f.getClass().getSimpleName()
|
||||
+ ", subType="
|
||||
+ f.getSubType()
|
||||
+ ")");
|
||||
for (String ch : new String[] {"a", "b", "c", "Z", "z", "0", "$", "@", "X", " "}) {
|
||||
try {
|
||||
byte[] enc = f.encode(ch);
|
||||
StringBuilder hex = new StringBuilder();
|
||||
for (byte b : enc) hex.append(String.format("%02X ", b & 0xff));
|
||||
System.out.println(
|
||||
" encode('" + ch + "') -> [" + hex.toString().trim() + "] OK");
|
||||
} catch (UnsupportedOperationException uoe) {
|
||||
System.out.println(" encode('" + ch + "') UNSUPPORTED");
|
||||
} catch (IllegalArgumentException iae) {
|
||||
System.out.println(
|
||||
" encode('" + ch + "') MISSING - " + iae.getMessage());
|
||||
} catch (IOException ioe) {
|
||||
System.out.println(" encode('" + ch + "') IO ERR - " + ioe.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void probeExtraFixtures() throws IOException {
|
||||
System.out.println(
|
||||
"\n##################################################################\n"
|
||||
+ "Extra fixture font-class probe\n"
|
||||
+ "##################################################################\n");
|
||||
for (Path fixture : EXTRA_FIXTURES) {
|
||||
if (!Files.exists(fixture)) {
|
||||
System.out.println("(missing) " + fixture);
|
||||
continue;
|
||||
}
|
||||
System.out.println("\n=== " + fixture.getFileName() + " ===");
|
||||
byte[] bytes = Files.readAllBytes(fixture);
|
||||
try (PDDocument doc = Loader.loadPDF(bytes)) {
|
||||
Set<COSName> seen = new HashSet<>();
|
||||
for (int p = 0; p < doc.getNumberOfPages(); p++) {
|
||||
PDPage page = doc.getPage(p);
|
||||
PDResources res = page.getResources();
|
||||
if (res == null) continue;
|
||||
for (COSName name : res.getFontNames()) {
|
||||
if (!seen.add(name)) continue;
|
||||
try {
|
||||
PDFont f = res.getFont(name);
|
||||
if (f == null) continue;
|
||||
String fontFile = "none";
|
||||
PDFontDescriptor d = f.getFontDescriptor();
|
||||
if (d != null) {
|
||||
if (d.getFontFile() != null) fontFile = "FontFile";
|
||||
else if (d.getFontFile2() != null) fontFile = "FontFile2";
|
||||
else if (d.getFontFile3() != null) fontFile = "FontFile3";
|
||||
}
|
||||
String z = "?";
|
||||
try {
|
||||
f.encode("Z");
|
||||
z = "OK";
|
||||
} catch (UnsupportedOperationException ex) {
|
||||
z = "UNSUPPORTED";
|
||||
} catch (IllegalArgumentException ex) {
|
||||
z = "MISSING";
|
||||
} catch (IOException ex) {
|
||||
z = "IO_ERR";
|
||||
}
|
||||
System.out.println(
|
||||
" page "
|
||||
+ p
|
||||
+ " "
|
||||
+ name.getName()
|
||||
+ " -> "
|
||||
+ f.getName()
|
||||
+ " "
|
||||
+ f.getClass().getSimpleName()
|
||||
+ " ("
|
||||
+ f.getSubType()
|
||||
+ ", "
|
||||
+ fontFile
|
||||
+ ", embed="
|
||||
+ f.isEmbedded()
|
||||
+ ") encode('Z')="
|
||||
+ z);
|
||||
} catch (IOException e) {
|
||||
System.out.println(
|
||||
" page "
|
||||
+ p
|
||||
+ " "
|
||||
+ name.getName()
|
||||
+ " load failed: "
|
||||
+ e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void probeAllQuestions() throws IOException {
|
||||
System.out.println(
|
||||
"\n##################################################################\n"
|
||||
+ "PDFBox font-encoding probe (Sample.pdf + bundled fallback fonts)\n"
|
||||
+ "##################################################################\n");
|
||||
|
||||
// Discover every font in Sample.pdf so we have a real-world test set.
|
||||
byte[] pdfBytes = Files.readAllBytes(SAMPLE);
|
||||
try (PDDocument doc = Loader.loadPDF(pdfBytes)) {
|
||||
List<PDFont> allFonts = new ArrayList<>();
|
||||
Set<COSName> seen = new HashSet<>();
|
||||
for (int p = 0; p < doc.getNumberOfPages(); p++) {
|
||||
PDPage page = doc.getPage(p);
|
||||
PDResources res = page.getResources();
|
||||
if (res == null) continue;
|
||||
for (COSName name : res.getFontNames()) {
|
||||
if (!seen.add(name)) continue;
|
||||
try {
|
||||
PDFont f = res.getFont(name);
|
||||
if (f != null) allFonts.add(f);
|
||||
} catch (Exception e) {
|
||||
System.out.println(
|
||||
" (skipped " + name.getName() + " - " + e.getMessage() + ")");
|
||||
}
|
||||
}
|
||||
}
|
||||
System.out.println(
|
||||
"Discovered " + allFonts.size() + " unique fonts across Sample.pdf:");
|
||||
for (PDFont f : allFonts) {
|
||||
System.out.println(
|
||||
" - "
|
||||
+ f.getName()
|
||||
+ " ("
|
||||
+ f.getClass().getSimpleName()
|
||||
+ ", subType="
|
||||
+ f.getSubType()
|
||||
+ ", embedded="
|
||||
+ f.isEmbedded()
|
||||
+ ")");
|
||||
}
|
||||
|
||||
// Q1/Q2/Q3
|
||||
// Try encoding a char that is NEVER in Sample.pdf via each font.
|
||||
// 'Z' is unlikely to be in the subset for most marketing pages.
|
||||
// Try several candidates to surface what each font can/can't add.
|
||||
String[] candidates = {"Z", "$", "@", "#", "Q", "&", "A", "0", "M"};
|
||||
for (PDFont f : allFonts) {
|
||||
System.out.println("\n=== Encode-probe for font: " + f.getName() + " ===");
|
||||
for (String ch : candidates) {
|
||||
try {
|
||||
byte[] enc = f.encode(ch);
|
||||
StringBuilder hex = new StringBuilder();
|
||||
for (byte b : enc) hex.append(String.format("%02X ", b & 0xff));
|
||||
System.out.println(
|
||||
" encode('" + ch + "') -> [" + hex.toString().trim() + "] OK");
|
||||
} catch (UnsupportedOperationException uoe) {
|
||||
System.out.println(
|
||||
" encode('" + ch + "') UNSUPPORTED: " + uoe.getMessage());
|
||||
} catch (IllegalArgumentException iae) {
|
||||
System.out.println(" encode('" + ch + "') MISSING: " + iae.getMessage());
|
||||
} catch (IOException ioe) {
|
||||
System.out.println(" encode('" + ch + "') IO ERR: " + ioe.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Q5
|
||||
// For each font, see what's in the FontFile* stream - this is what we'd
|
||||
// have to round-trip through to "rehabilitate" a Type3 font.
|
||||
System.out.println("\n=== FontFile stream availability (Q5) ===");
|
||||
for (PDFont f : allFonts) {
|
||||
String kind = "none";
|
||||
int size = 0;
|
||||
PDFontDescriptor d = f.getFontDescriptor();
|
||||
if (d != null) {
|
||||
if (d.getFontFile() != null) {
|
||||
kind = "FontFile (Type1)";
|
||||
size = streamBytes(d.getFontFile().getCOSObject().createInputStream());
|
||||
} else if (d.getFontFile2() != null) {
|
||||
kind = "FontFile2 (TTF)";
|
||||
size = streamBytes(d.getFontFile2().getCOSObject().createInputStream());
|
||||
} else if (d.getFontFile3() != null) {
|
||||
kind = "FontFile3 (CFF/OpenType)";
|
||||
size = streamBytes(d.getFontFile3().getCOSObject().createInputStream());
|
||||
}
|
||||
}
|
||||
System.out.println(
|
||||
" "
|
||||
+ f.getName()
|
||||
+ " ("
|
||||
+ f.getClass().getSimpleName()
|
||||
+ "): "
|
||||
+ kind
|
||||
+ " ("
|
||||
+ size
|
||||
+ " bytes)");
|
||||
if (f instanceof PDType3Font) {
|
||||
System.out.println(
|
||||
" -> Type3 has CharProc streams, NOT a FontFile binary."
|
||||
+ " getFontStream() returns null. Round-trip rehab is impossible:");
|
||||
System.out.println(
|
||||
" each glyph is a mini content stream, not a glyph outline in a"
|
||||
+ " standard font format. We'd need to rasterize each CharProc to"
|
||||
+ " glyph outlines + build a fresh TTF/CFF from scratch.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Q4: PDType0Font.load(doc, file) round-trip
|
||||
System.out.println("\n=== Q4: load fresh TTF and write text to a fresh PDF ===");
|
||||
Path liberation =
|
||||
PROJECT_ROOT.resolve(
|
||||
"app/core/src/main/resources/static/fonts/LiberationSans-Regular.ttf");
|
||||
if (!Files.exists(liberation)) {
|
||||
System.out.println(" Liberation TTF not found at " + liberation);
|
||||
} else {
|
||||
try (PDDocument out = new PDDocument()) {
|
||||
PDPage page = new PDPage();
|
||||
out.addPage(page);
|
||||
PDType0Font ttf;
|
||||
try (InputStream in = Files.newInputStream(liberation)) {
|
||||
ttf = PDType0Font.load(out, in, true /* embedSubset */);
|
||||
}
|
||||
System.out.println(
|
||||
" Loaded TTF -> "
|
||||
+ ttf.getName()
|
||||
+ " ("
|
||||
+ ttf.getClass().getSimpleName()
|
||||
+ ")");
|
||||
String testText = "Hello world! 0123 Z $ @";
|
||||
byte[] encoded = ttf.encode(testText);
|
||||
System.out.println(
|
||||
" Encoded "
|
||||
+ testText.length()
|
||||
+ " chars -> "
|
||||
+ encoded.length
|
||||
+ " bytes (Identity-H = 2 bytes/glyph)");
|
||||
try (PDPageContentStream cs = new PDPageContentStream(out, page)) {
|
||||
cs.beginText();
|
||||
cs.setFont(ttf, 12);
|
||||
cs.newLineAtOffset(100, 700);
|
||||
cs.showText(testText);
|
||||
cs.endText();
|
||||
}
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
out.save(baos);
|
||||
Path tmp = Files.createTempFile("pdfbox-probe-q4-", ".pdf");
|
||||
Files.write(tmp, baos.toByteArray());
|
||||
System.out.println(
|
||||
" Wrote fresh-TTF PDF to "
|
||||
+ tmp
|
||||
+ " ("
|
||||
+ baos.size()
|
||||
+ " bytes) - opens cleanly.");
|
||||
|
||||
// Re-load to confirm the new font is embedded properly.
|
||||
try (PDDocument check = Loader.loadPDF(baos.toByteArray())) {
|
||||
PDResources res = check.getPage(0).getResources();
|
||||
for (COSName fn : res.getFontNames()) {
|
||||
PDFont f = res.getFont(fn);
|
||||
System.out.println(
|
||||
" embedded font: "
|
||||
+ f.getName()
|
||||
+ " ("
|
||||
+ f.getClass().getSimpleName()
|
||||
+ ", embedded="
|
||||
+ f.isEmbedded()
|
||||
+ ")");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Q4b: load TTF into an EXISTING PDF (Sample.pdf) and append text
|
||||
System.out.println(
|
||||
"\n=== Q4b: load TTF into EXISTING Sample.pdf and write text on page 0 ===");
|
||||
try (PDDocument doc = Loader.loadPDF(pdfBytes)) {
|
||||
PDPage page = doc.getPage(0);
|
||||
PDType0Font ttf;
|
||||
try (InputStream in = Files.newInputStream(liberation)) {
|
||||
ttf = PDType0Font.load(doc, in, true);
|
||||
}
|
||||
// append-mode content stream so we don't disturb existing graphics
|
||||
try (PDPageContentStream cs =
|
||||
new PDPageContentStream(
|
||||
doc,
|
||||
page,
|
||||
PDPageContentStream.AppendMode.APPEND,
|
||||
true /* compress */,
|
||||
true /* resetContext */)) {
|
||||
cs.beginText();
|
||||
cs.setFont(ttf, 12);
|
||||
cs.newLineAtOffset(50, 50);
|
||||
cs.showText("Injected via PDType0Font.load - $@#&");
|
||||
cs.endText();
|
||||
}
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
doc.save(baos);
|
||||
Path tmp = Files.createTempFile("pdfbox-probe-q4b-", ".pdf");
|
||||
Files.write(tmp, baos.toByteArray());
|
||||
System.out.println(
|
||||
" Wrote injected-text PDF to " + tmp + " (" + baos.size() + " bytes).");
|
||||
|
||||
// Verify by re-reading: how many fonts now on page 0?
|
||||
try (PDDocument check = Loader.loadPDF(baos.toByteArray())) {
|
||||
PDResources res = check.getPage(0).getResources();
|
||||
int count = 0;
|
||||
for (COSName fn : res.getFontNames()) {
|
||||
PDFont f = res.getFont(fn);
|
||||
count++;
|
||||
System.out.println(
|
||||
" page-0 font: "
|
||||
+ fn.getName()
|
||||
+ " -> "
|
||||
+ f.getName()
|
||||
+ " ("
|
||||
+ f.getClass().getSimpleName()
|
||||
+ ")");
|
||||
}
|
||||
System.out.println(" Total fonts on page 0: " + count);
|
||||
}
|
||||
}
|
||||
|
||||
// Q6: what fonts ship in PDFBox / fontbox
|
||||
System.out.println("\n=== Q6: bundled fonts (Standard14 redirect probe) ===");
|
||||
for (Standard14Fonts.FontName fn : Standard14Fonts.FontName.values()) {
|
||||
PDType1Font f = new PDType1Font(fn);
|
||||
String mapped = "" + Standard14Fonts.getMappedFontName(fn.getName());
|
||||
System.out.println(
|
||||
" Standard14 "
|
||||
+ fn.getName()
|
||||
+ " -> mapped='"
|
||||
+ mapped
|
||||
+ "' name="
|
||||
+ f.getName());
|
||||
}
|
||||
System.out.println(
|
||||
" (PDFBox bundles ONLY LiberationSans-Regular.ttf as a binary; the AFMs cover"
|
||||
+ " metrics for the 14 standard fonts but rendering Helvetica/Times/Courier"
|
||||
+ " glyphs falls back to LiberationSans glyphs at runtime when no system font"
|
||||
+ " is found.)");
|
||||
}
|
||||
|
||||
private static int streamBytes(InputStream is) {
|
||||
try (InputStream it = is) {
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
byte[] buf = new byte[4096];
|
||||
int n;
|
||||
while ((n = it.read(buf)) >= 0) baos.write(buf, 0, n);
|
||||
return baos.size();
|
||||
} catch (IOException e) {
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
}
|
||||
+755
@@ -0,0 +1,755 @@
|
||||
package stirling.software.SPDF.controller.api;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.Mockito.mock;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.InputStream;
|
||||
import java.util.Base64;
|
||||
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.apache.pdfbox.pdmodel.PDPage;
|
||||
import org.apache.pdfbox.pdmodel.PDPageContentStream;
|
||||
import org.apache.pdfbox.pdmodel.font.PDType1Font;
|
||||
import org.apache.pdfbox.pdmodel.font.Standard14Fonts;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
|
||||
import stirling.software.SPDF.controller.api.PdfTextEditorCharcodeController.EncodeCharcodesRequest;
|
||||
import stirling.software.SPDF.controller.api.PdfTextEditorCharcodeController.EncodeCharcodesResponse;
|
||||
import stirling.software.common.service.CustomPDFDocumentFactory;
|
||||
import stirling.software.common.service.PdfMetadataService;
|
||||
|
||||
/**
|
||||
* Regression coverage for the v2 text editor "spaces render as „" bug.
|
||||
*
|
||||
* <p>mushroom-life.pdf is a LaTeX document whose embedded LMRoman subset font has NO real space
|
||||
* glyph, yet {@code font.encode(" ")} still returns charcode 0x20 without throwing. Reusing that
|
||||
* code via {@code FPDFText_SetCharcodes} paints whatever glyph sits at subset code 0x20 - the
|
||||
* quotedblbase „. The controller must therefore report whitespace as {@code missing} so the
|
||||
* frontend emits it as a positional gap instead of a reused glyph.
|
||||
*/
|
||||
class PdfTextEditorCharcodeControllerTest {
|
||||
|
||||
private static PdfTextEditorCharcodeController controller() {
|
||||
return new PdfTextEditorCharcodeController(
|
||||
new CustomPDFDocumentFactory(mock(PdfMetadataService.class)));
|
||||
}
|
||||
|
||||
private static String mushroomBase64() throws Exception {
|
||||
try (InputStream in =
|
||||
PdfTextEditorCharcodeControllerTest.class.getResourceAsStream(
|
||||
"/pdftexteditor/mushroom-life.pdf")) {
|
||||
assertThat(in).as("mushroom-life.pdf test resource").isNotNull();
|
||||
return Base64.getEncoder().encodeToString(in.readAllBytes());
|
||||
}
|
||||
}
|
||||
|
||||
private static EncodeCharcodesRequest request(String text) throws Exception {
|
||||
EncodeCharcodesRequest req = new EncodeCharcodesRequest();
|
||||
req.setPdfBase64(mushroomBase64());
|
||||
req.setPageIndex(0);
|
||||
// findFontByToUnicode locates the font via the ToUnicode CMap - "M" exists on page 0.
|
||||
req.setLocatorChar("M");
|
||||
req.setText(text);
|
||||
return req;
|
||||
}
|
||||
|
||||
@Test
|
||||
void spaceIsReportedMissingNeverEncoded() throws Exception {
|
||||
PdfTextEditorCharcodeController controller = controller();
|
||||
ResponseEntity<EncodeCharcodesResponse> resp = controller.encodeCharcodes(request(" "));
|
||||
|
||||
EncodeCharcodesResponse body = resp.getBody();
|
||||
assertThat(body).isNotNull();
|
||||
assertThat(body.getError()).isNull();
|
||||
// The space must be reported missing, NOT handed back as a charcode
|
||||
// (0x20) the frontend would reuse into the „ glyph.
|
||||
assertThat(body.getMissing()).containsExactly(" ");
|
||||
assertThat(body.getCharcodes()).isNullOrEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void realCharsEncodeWhileWhitespaceStaysAGap() throws Exception {
|
||||
PdfTextEditorCharcodeController controller = controller();
|
||||
// "M M" - both M's must encode to real charcodes; only the space is a gap.
|
||||
ResponseEntity<EncodeCharcodesResponse> resp = controller.encodeCharcodes(request("M M"));
|
||||
|
||||
EncodeCharcodesResponse body = resp.getBody();
|
||||
assertThat(body).isNotNull();
|
||||
assertThat(body.getError()).isNull();
|
||||
assertThat(body.getCharcodes()).as("both M glyphs encode").hasSize(2);
|
||||
assertThat(body.getMissing()).containsExactly(" ");
|
||||
}
|
||||
|
||||
@Test
|
||||
void tabAndNewlineAreAlsoTreatedAsGaps() throws Exception {
|
||||
PdfTextEditorCharcodeController controller = controller();
|
||||
ResponseEntity<EncodeCharcodesResponse> resp = controller.encodeCharcodes(request("\t\n"));
|
||||
|
||||
EncodeCharcodesResponse body = resp.getBody();
|
||||
assertThat(body).isNotNull();
|
||||
assertThat(body.getMissing()).containsExactly("\t", "\n");
|
||||
assertThat(body.getCharcodes()).isNullOrEmpty();
|
||||
}
|
||||
|
||||
/**
|
||||
* A page with two fonts that BOTH render 'A'. {@code fontName} must select which one to encode
|
||||
* against - the cross-font fix. Without it the first font in resources order won wins and a
|
||||
* cross-font edit got the wrong font's charcode.
|
||||
*/
|
||||
private static String twoFontBase64() throws Exception {
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
PDPage page = new PDPage();
|
||||
doc.addPage(page);
|
||||
PDType1Font helvetica = new PDType1Font(Standard14Fonts.FontName.HELVETICA);
|
||||
PDType1Font times = new PDType1Font(Standard14Fonts.FontName.TIMES_ROMAN);
|
||||
try (PDPageContentStream cs = new PDPageContentStream(doc, page)) {
|
||||
cs.beginText();
|
||||
cs.setFont(helvetica, 12);
|
||||
cs.newLineAtOffset(72, 720);
|
||||
cs.showText("A");
|
||||
cs.endText();
|
||||
cs.beginText();
|
||||
cs.setFont(times, 12);
|
||||
cs.newLineAtOffset(72, 700);
|
||||
cs.showText("A");
|
||||
cs.endText();
|
||||
}
|
||||
ByteArrayOutputStream bos = new ByteArrayOutputStream();
|
||||
doc.save(bos);
|
||||
return Base64.getEncoder().encodeToString(bos.toByteArray());
|
||||
}
|
||||
}
|
||||
|
||||
private static EncodeCharcodesRequest twoFontRequest(String fontName) throws Exception {
|
||||
EncodeCharcodesRequest req = new EncodeCharcodesRequest();
|
||||
req.setPdfBase64(twoFontBase64());
|
||||
req.setPageIndex(0);
|
||||
req.setLocatorChar("A");
|
||||
req.setFontName(fontName);
|
||||
req.setText("A");
|
||||
return req;
|
||||
}
|
||||
|
||||
@Test
|
||||
void fontNameDisambiguatesBetweenTwoFontsRenderingTheSameChar() throws Exception {
|
||||
PdfTextEditorCharcodeController controller = controller();
|
||||
|
||||
// Targeting Times-Roman must encode against Times-Roman, not whichever
|
||||
// font happens to appear first in the page's font resources.
|
||||
EncodeCharcodesResponse times =
|
||||
controller.encodeCharcodes(twoFontRequest("Times-Roman")).getBody();
|
||||
assertThat(times).isNotNull();
|
||||
assertThat(times.getError()).isNull();
|
||||
assertThat(times.getNote()).contains("Times-Roman");
|
||||
assertThat(times.getCharcodes()).hasSize(1);
|
||||
|
||||
// Targeting Helvetica must encode against Helvetica.
|
||||
EncodeCharcodesResponse helv =
|
||||
controller.encodeCharcodes(twoFontRequest("Helvetica")).getBody();
|
||||
assertThat(helv).isNotNull();
|
||||
assertThat(helv.getError()).isNull();
|
||||
assertThat(helv.getNote()).contains("Helvetica");
|
||||
assertThat(helv.getCharcodes()).hasSize(1);
|
||||
}
|
||||
|
||||
@Test
|
||||
void unknownFontNameReportsNoFontInsteadOfWrongFont() throws Exception {
|
||||
PdfTextEditorCharcodeController controller = controller();
|
||||
// A name that matches no font on the page must NOT silently encode
|
||||
// against a different font: the frontend writes the returned charcodes
|
||||
// into the NAMED font's text object, so a first-match fallback would
|
||||
// bake wrong glyphs. It must report failure so the caller falls back.
|
||||
EncodeCharcodesResponse body =
|
||||
controller.encodeCharcodes(twoFontRequest("DoesNotExist")).getBody();
|
||||
assertThat(body).isNotNull();
|
||||
assertThat(body.getError()).contains("no font");
|
||||
assertThat(body.getCharcodes()).isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void missingRequiredFieldsReturns400() {
|
||||
EncodeCharcodesRequest req = new EncodeCharcodesRequest();
|
||||
req.setPdfBase64("AAAA");
|
||||
req.setLocatorChar("M");
|
||||
// text is null
|
||||
ResponseEntity<EncodeCharcodesResponse> resp = controller().encodeCharcodes(req);
|
||||
assertThat(resp.getStatusCode().value()).isEqualTo(400);
|
||||
assertThat(resp.getBody()).isNotNull();
|
||||
assertThat(resp.getBody().getError()).isEqualTo("missing required fields");
|
||||
}
|
||||
|
||||
@Test
|
||||
void invalidBase64Returns400() {
|
||||
EncodeCharcodesRequest req = new EncodeCharcodesRequest();
|
||||
req.setPdfBase64("!!!notbase64!!!");
|
||||
req.setLocatorChar("M");
|
||||
req.setText("M");
|
||||
ResponseEntity<EncodeCharcodesResponse> resp = controller().encodeCharcodes(req);
|
||||
assertThat(resp.getStatusCode().value()).isEqualTo(400);
|
||||
assertThat(resp.getBody()).isNotNull();
|
||||
assertThat(resp.getBody().getError()).isEqualTo("pdfBase64 is not valid base64");
|
||||
}
|
||||
|
||||
@Test
|
||||
void pageIndexOutOfRangeReturns400() throws Exception {
|
||||
EncodeCharcodesRequest req = request("M");
|
||||
req.setPageIndex(999);
|
||||
ResponseEntity<EncodeCharcodesResponse> resp = controller().encodeCharcodes(req);
|
||||
assertThat(resp.getStatusCode().value()).isEqualTo(400);
|
||||
assertThat(resp.getBody()).isNotNull();
|
||||
assertThat(resp.getBody().getError()).isEqualTo("pageIndex out of range");
|
||||
}
|
||||
|
||||
@Test
|
||||
void nonPdfBytesReturnsGenericError() {
|
||||
EncodeCharcodesRequest req = new EncodeCharcodesRequest();
|
||||
req.setPdfBase64(Base64.getEncoder().encodeToString("not a pdf".getBytes()));
|
||||
req.setLocatorChar("M");
|
||||
req.setText("M");
|
||||
// Must not throw, and must not leak the raw PDFBox parser message.
|
||||
ResponseEntity<EncodeCharcodesResponse> resp = controller().encodeCharcodes(req);
|
||||
assertThat(resp.getStatusCode().is4xxClientError()).isTrue();
|
||||
assertThat(resp.getBody()).isNotNull();
|
||||
assertThat(resp.getBody().getError()).isEqualTo("failed to load PDF");
|
||||
}
|
||||
|
||||
@Test
|
||||
void absentLocatorCharReturns200WithError() throws Exception {
|
||||
// U+FFFF never appears in the document, so no font matches.
|
||||
ResponseEntity<EncodeCharcodesResponse> resp =
|
||||
controller().encodeCharcodes(requestWithLocator(""));
|
||||
assertThat(resp.getStatusCode().value()).isEqualTo(200);
|
||||
EncodeCharcodesResponse body = resp.getBody();
|
||||
assertThat(body).isNotNull();
|
||||
assertThat(body.getError()).isNotNull();
|
||||
assertThat(body.getCharcodes()).isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void oversizePdfRejected() {
|
||||
EncodeCharcodesRequest req = new EncodeCharcodesRequest();
|
||||
// A base64 string long enough that length/4*3 exceeds the 100MB cap, without
|
||||
// ever allocating the decoded bytes (the guard runs before decode).
|
||||
char[] huge = new char[140 * 1024 * 1024];
|
||||
java.util.Arrays.fill(huge, 'A');
|
||||
req.setPdfBase64(new String(huge));
|
||||
req.setLocatorChar("M");
|
||||
req.setText("M");
|
||||
ResponseEntity<EncodeCharcodesResponse> resp = controller().encodeCharcodes(req);
|
||||
assertThat(resp.getStatusCode().value()).isEqualTo(413);
|
||||
assertThat(resp.getBody()).isNotNull();
|
||||
assertThat(resp.getBody().getError()).isEqualTo("pdf too large");
|
||||
}
|
||||
|
||||
private static EncodeCharcodesRequest requestWithLocator(String locator) throws Exception {
|
||||
EncodeCharcodesRequest req = request("M");
|
||||
req.setLocatorChar(locator);
|
||||
return req;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a page whose resources declare {@code filler} fonts that do NOT render 'A' (Symbol /
|
||||
* ZapfDingbats have non-Latin encodings) plus, optionally, a trailing Helvetica that does. The
|
||||
* Standard14 probe upper bound is 256 so each scan is cheap.
|
||||
*/
|
||||
private static String manyFontsBase64(int filler, boolean trailingTarget) throws Exception {
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
PDPage page = new PDPage();
|
||||
doc.addPage(page);
|
||||
org.apache.pdfbox.pdmodel.PDResources resources =
|
||||
new org.apache.pdfbox.pdmodel.PDResources();
|
||||
for (int n = 0; n < filler; n++) {
|
||||
Standard14Fonts.FontName fn =
|
||||
(n % 2 == 0)
|
||||
? Standard14Fonts.FontName.SYMBOL
|
||||
: Standard14Fonts.FontName.ZAPF_DINGBATS;
|
||||
resources.put(
|
||||
org.apache.pdfbox.cos.COSName.getPDFName("Ff" + n), new PDType1Font(fn));
|
||||
}
|
||||
if (trailingTarget) {
|
||||
resources.put(
|
||||
org.apache.pdfbox.cos.COSName.getPDFName("Target"),
|
||||
new PDType1Font(Standard14Fonts.FontName.HELVETICA));
|
||||
}
|
||||
page.setResources(resources);
|
||||
ByteArrayOutputStream bos = new ByteArrayOutputStream();
|
||||
doc.save(bos);
|
||||
return Base64.getEncoder().encodeToString(bos.toByteArray());
|
||||
}
|
||||
}
|
||||
|
||||
private static EncodeCharcodesRequest manyFontsRequest(String base64) {
|
||||
EncodeCharcodesRequest req = new EncodeCharcodesRequest();
|
||||
req.setPdfBase64(base64);
|
||||
req.setPageIndex(0);
|
||||
req.setLocatorChar("A");
|
||||
req.setText("A");
|
||||
return req;
|
||||
}
|
||||
|
||||
@Test
|
||||
void targetFontFoundAmongManyFonts() throws Exception {
|
||||
// 60 non-matching fonts then the Helvetica target, all within the 64-font cap.
|
||||
ResponseEntity<EncodeCharcodesResponse> resp =
|
||||
controller().encodeCharcodes(manyFontsRequest(manyFontsBase64(60, true)));
|
||||
assertThat(resp.getStatusCode().value()).isEqualTo(200);
|
||||
EncodeCharcodesResponse body = resp.getBody();
|
||||
assertThat(body).isNotNull();
|
||||
assertThat(body.getError()).isNull();
|
||||
assertThat(body.getCharcodes()).hasSize(1);
|
||||
}
|
||||
|
||||
@Test
|
||||
void targetBeyondFontCapReturnsGracefulNoFont() throws Exception {
|
||||
// 64 non-matching fonts then the target at position 65 - the scan cap stops
|
||||
// before reaching it, so we get a graceful no-font error rather than a full scan.
|
||||
ResponseEntity<EncodeCharcodesResponse> resp =
|
||||
controller().encodeCharcodes(manyFontsRequest(manyFontsBase64(64, true)));
|
||||
assertThat(resp.getStatusCode().value()).isEqualTo(200);
|
||||
EncodeCharcodesResponse body = resp.getBody();
|
||||
assertThat(body).isNotNull();
|
||||
assertThat(body.getError()).isNotNull();
|
||||
assertThat(body.getCharcodes()).isNull();
|
||||
}
|
||||
|
||||
// Same-family sibling subsets. One document can embed several subsets of
|
||||
// one family, each re-encoded by order of first glyph use, so a letter has
|
||||
// a different charcode in each ("R" = 0x21 in one, 0x22 in its sibling).
|
||||
// FPDFFont_GetBaseFontName strips the "ABCDEF+" tag, so a name-based
|
||||
// lookup cannot tell them apart and borrows the wrong subset's codes.
|
||||
//
|
||||
// The doc below mirrors that with two TrueType subsets differing only by
|
||||
// subset tag. PUA code points keep it deterministic: font.encode() cannot
|
||||
// resolve them by glyph name, so the charcode can only come from the
|
||||
// selected font's ToUnicode reverse map - proving WHICH font was picked.
|
||||
|
||||
private static final String PUA = "";
|
||||
|
||||
/** ToUnicode CMap mapping each supplied charcode to a BMP code point. */
|
||||
private static byte[] toUnicodeCmap(int[][] codeToUnicode) {
|
||||
StringBuilder sb =
|
||||
new StringBuilder(
|
||||
"""
|
||||
/CIDInit /ProcSet findresource begin
|
||||
12 dict begin
|
||||
begincmap
|
||||
/CIDSystemInfo << /Registry (Adobe) /Ordering (UCS) /Supplement 0 >> def
|
||||
/CMapName /Adobe-Identity-UCS def
|
||||
/CMapType 2 def
|
||||
1 begincodespacerange
|
||||
<00><FF>
|
||||
endcodespacerange
|
||||
""");
|
||||
sb.append(codeToUnicode.length).append(" beginbfchar\n");
|
||||
for (int[] pair : codeToUnicode) {
|
||||
sb.append(String.format("<%02X><%04X>%n", pair[0], pair[1]));
|
||||
}
|
||||
sb.append(
|
||||
"""
|
||||
endbfchar
|
||||
endcmap
|
||||
CMapName currentdict /CMap defineresource pop
|
||||
end
|
||||
end
|
||||
""");
|
||||
return sb.toString().getBytes(java.nio.charset.StandardCharsets.US_ASCII);
|
||||
}
|
||||
|
||||
private static org.apache.pdfbox.cos.COSDictionary subsetFontDict(
|
||||
PDDocument doc, String baseName, byte[] fontProgram, byte[] toUnicode)
|
||||
throws Exception {
|
||||
org.apache.pdfbox.cos.COSDictionary font = new org.apache.pdfbox.cos.COSDictionary();
|
||||
font.setItem(org.apache.pdfbox.cos.COSName.TYPE, org.apache.pdfbox.cos.COSName.FONT);
|
||||
font.setItem(
|
||||
org.apache.pdfbox.cos.COSName.SUBTYPE, org.apache.pdfbox.cos.COSName.TRUE_TYPE);
|
||||
if (baseName != null) {
|
||||
font.setName(org.apache.pdfbox.cos.COSName.BASE_FONT, baseName);
|
||||
}
|
||||
font.setInt(org.apache.pdfbox.cos.COSName.FIRST_CHAR, 0x21);
|
||||
font.setInt(org.apache.pdfbox.cos.COSName.LAST_CHAR, 0x22);
|
||||
org.apache.pdfbox.cos.COSArray widths = new org.apache.pdfbox.cos.COSArray();
|
||||
widths.add(org.apache.pdfbox.cos.COSInteger.get(500));
|
||||
widths.add(org.apache.pdfbox.cos.COSInteger.get(500));
|
||||
font.setItem(org.apache.pdfbox.cos.COSName.WIDTHS, widths);
|
||||
|
||||
org.apache.pdfbox.cos.COSDictionary fd = new org.apache.pdfbox.cos.COSDictionary();
|
||||
fd.setItem(org.apache.pdfbox.cos.COSName.TYPE, org.apache.pdfbox.cos.COSName.FONT_DESC);
|
||||
if (baseName != null) {
|
||||
fd.setName(org.apache.pdfbox.cos.COSName.FONT_NAME, baseName);
|
||||
}
|
||||
fd.setInt(org.apache.pdfbox.cos.COSName.FLAGS, 4);
|
||||
fd.setItem(
|
||||
org.apache.pdfbox.cos.COSName.FONT_BBOX,
|
||||
new org.apache.pdfbox.pdmodel.common.PDRectangle(0, 0, 1000, 1000).getCOSArray());
|
||||
fd.setInt(org.apache.pdfbox.cos.COSName.ITALIC_ANGLE, 0);
|
||||
fd.setInt(org.apache.pdfbox.cos.COSName.ASCENT, 800);
|
||||
fd.setInt(org.apache.pdfbox.cos.COSName.DESCENT, -200);
|
||||
fd.setInt(org.apache.pdfbox.cos.COSName.CAP_HEIGHT, 700);
|
||||
fd.setInt(org.apache.pdfbox.cos.COSName.STEM_V, 80);
|
||||
if (fontProgram != null) {
|
||||
org.apache.pdfbox.pdmodel.common.PDStream ff2 =
|
||||
new org.apache.pdfbox.pdmodel.common.PDStream(
|
||||
doc, new java.io.ByteArrayInputStream(fontProgram));
|
||||
ff2.getCOSObject().setInt(org.apache.pdfbox.cos.COSName.LENGTH1, fontProgram.length);
|
||||
fd.setItem(org.apache.pdfbox.cos.COSName.FONT_FILE2, ff2.getCOSObject());
|
||||
}
|
||||
font.setItem(org.apache.pdfbox.cos.COSName.FONT_DESC, fd);
|
||||
|
||||
org.apache.pdfbox.pdmodel.common.PDStream tu =
|
||||
new org.apache.pdfbox.pdmodel.common.PDStream(
|
||||
doc, new java.io.ByteArrayInputStream(toUnicode));
|
||||
font.setItem(org.apache.pdfbox.cos.COSName.getPDFName("ToUnicode"), tu.getCOSObject());
|
||||
return font;
|
||||
}
|
||||
|
||||
// Distinct fake font programs - hashing distinguishes the subsets by these bytes.
|
||||
private static final byte[] PROGRAM_A =
|
||||
"fake-ttf-program-A".getBytes(java.nio.charset.StandardCharsets.US_ASCII);
|
||||
private static final byte[] PROGRAM_B =
|
||||
"fake-ttf-program-B".getBytes(java.nio.charset.StandardCharsets.US_ASCII);
|
||||
|
||||
/**
|
||||
* Two sibling subsets of "FakeGaramond" whose ToUnicode maps give U+E000 DIFFERENT charcodes:
|
||||
* 0x22 in subset A (AAAAAC+), 0x21 in subset B (AAAAAG+) - exactly the CV's shifted-code
|
||||
* layout. {@code includeSecond=false} keeps only subset A for the unambiguous-fallback case.
|
||||
*/
|
||||
private static String siblingSubsetsBase64(boolean includeSecond) throws Exception {
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
PDPage page = new PDPage();
|
||||
doc.addPage(page);
|
||||
org.apache.pdfbox.cos.COSDictionary fonts = new org.apache.pdfbox.cos.COSDictionary();
|
||||
fonts.setItem(
|
||||
org.apache.pdfbox.cos.COSName.getPDFName("TTA"),
|
||||
subsetFontDict(
|
||||
doc,
|
||||
"AAAAAC+FakeGaramond",
|
||||
PROGRAM_A,
|
||||
toUnicodeCmap(new int[][] {{0x21, 0xE001}, {0x22, 0xE000}})));
|
||||
if (includeSecond) {
|
||||
fonts.setItem(
|
||||
org.apache.pdfbox.cos.COSName.getPDFName("TTB"),
|
||||
subsetFontDict(
|
||||
doc,
|
||||
"AAAAAG+FakeGaramond",
|
||||
PROGRAM_B,
|
||||
toUnicodeCmap(new int[][] {{0x21, 0xE000}, {0x22, 0xE002}})));
|
||||
}
|
||||
org.apache.pdfbox.pdmodel.PDResources resources =
|
||||
new org.apache.pdfbox.pdmodel.PDResources();
|
||||
resources.getCOSObject().setItem(org.apache.pdfbox.cos.COSName.FONT, fonts);
|
||||
page.setResources(resources);
|
||||
ByteArrayOutputStream bos = new ByteArrayOutputStream();
|
||||
doc.save(bos);
|
||||
return Base64.getEncoder().encodeToString(bos.toByteArray());
|
||||
}
|
||||
}
|
||||
|
||||
private static String sha256Hex(byte[] bytes) throws Exception {
|
||||
byte[] digest = java.security.MessageDigest.getInstance("SHA-256").digest(bytes);
|
||||
StringBuilder sb = new StringBuilder();
|
||||
for (byte b : digest) sb.append(String.format("%02x", b));
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
private static EncodeCharcodesRequest siblingRequest(
|
||||
String base64, String fontName, String fontSha256) {
|
||||
EncodeCharcodesRequest req = new EncodeCharcodesRequest();
|
||||
req.setPdfBase64(base64);
|
||||
req.setPageIndex(0);
|
||||
req.setLocatorChar(PUA);
|
||||
req.setFontName(fontName);
|
||||
req.setFontSha256(fontSha256);
|
||||
req.setText(PUA);
|
||||
return req;
|
||||
}
|
||||
|
||||
@Test
|
||||
void fontProgramHashSelectsTheExactSubset() throws Exception {
|
||||
String base64 = siblingSubsetsBase64(true);
|
||||
PdfTextEditorCharcodeController controller = controller();
|
||||
|
||||
// Both requests carry the SAME tag-stripped name PDFium reports ("FakeGaramond"),
|
||||
// so only the program hash can tell the subsets apart.
|
||||
EncodeCharcodesResponse viaA =
|
||||
controller
|
||||
.encodeCharcodes(
|
||||
siblingRequest(base64, "FakeGaramond", sha256Hex(PROGRAM_A)))
|
||||
.getBody();
|
||||
assertThat(viaA).isNotNull();
|
||||
assertThat(viaA.getError()).isNull();
|
||||
assertThat(viaA.getNote()).contains("AAAAAC+FakeGaramond");
|
||||
assertThat(viaA.getCharcodes()).containsExactly(0x22L);
|
||||
|
||||
EncodeCharcodesResponse viaB =
|
||||
controller
|
||||
.encodeCharcodes(
|
||||
siblingRequest(base64, "FakeGaramond", sha256Hex(PROGRAM_B)))
|
||||
.getBody();
|
||||
assertThat(viaB).isNotNull();
|
||||
assertThat(viaB.getError()).isNull();
|
||||
assertThat(viaB.getNote()).contains("AAAAAG+FakeGaramond");
|
||||
assertThat(viaB.getCharcodes()).containsExactly(0x21L);
|
||||
}
|
||||
|
||||
@Test
|
||||
void ambiguousStrippedNameRefusesToGuessBetweenSiblingSubsets() throws Exception {
|
||||
// No hash, and the tag-stripped name matches BOTH subsets which both render the
|
||||
// locator char. Guessing here is what scrambled "RUSSELL W. MANGUM III" into
|
||||
// "US EEL W. MANGS M III" - the sibling's codes hit different glyphs. The
|
||||
// backend must refuse so the frontend takes its safe fallback.
|
||||
EncodeCharcodesResponse body =
|
||||
controller()
|
||||
.encodeCharcodes(
|
||||
siblingRequest(siblingSubsetsBase64(true), "FakeGaramond", null))
|
||||
.getBody();
|
||||
assertThat(body).isNotNull();
|
||||
assertThat(body.getError()).contains("no font");
|
||||
assertThat(body.getCharcodes()).isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void exactTaggedNameStillSelectsItsSubset() throws Exception {
|
||||
// A caller that DOES know the full tagged /BaseFont name keeps working.
|
||||
EncodeCharcodesResponse body =
|
||||
controller()
|
||||
.encodeCharcodes(
|
||||
siblingRequest(
|
||||
siblingSubsetsBase64(true), "AAAAAG+FakeGaramond", null))
|
||||
.getBody();
|
||||
assertThat(body).isNotNull();
|
||||
assertThat(body.getError()).isNull();
|
||||
assertThat(body.getNote()).contains("AAAAAG+FakeGaramond");
|
||||
assertThat(body.getCharcodes()).containsExactly(0x21L);
|
||||
}
|
||||
|
||||
@Test
|
||||
void strippedNameStillWorksWhenUnambiguous() throws Exception {
|
||||
// With a SINGLE subset on the page, the tag-stripped name (what PDFium
|
||||
// reports) must keep resolving - the ambiguity guard only bites when
|
||||
// two+ siblings could answer.
|
||||
EncodeCharcodesResponse body =
|
||||
controller()
|
||||
.encodeCharcodes(
|
||||
siblingRequest(siblingSubsetsBase64(false), "FakeGaramond", null))
|
||||
.getBody();
|
||||
assertThat(body).isNotNull();
|
||||
assertThat(body.getError()).isNull();
|
||||
assertThat(body.getNote()).contains("AAAAAC+FakeGaramond");
|
||||
assertThat(body.getCharcodes()).containsExactly(0x22L);
|
||||
}
|
||||
|
||||
@Test
|
||||
void staleHashFallsBackToNameMatching() throws Exception {
|
||||
// A hash matching NO font on the page (e.g. PDFium handed back a substitute
|
||||
// font's bytes) must not brick the request: name matching still runs, and an
|
||||
// exact tagged name resolves.
|
||||
EncodeCharcodesResponse body =
|
||||
controller()
|
||||
.encodeCharcodes(
|
||||
siblingRequest(
|
||||
siblingSubsetsBase64(true),
|
||||
"AAAAAC+FakeGaramond",
|
||||
"0000000000000000000000000000000000000000000000000000000000000000"))
|
||||
.getBody();
|
||||
assertThat(body).isNotNull();
|
||||
assertThat(body.getError()).isNull();
|
||||
assertThat(body.getNote()).contains("AAAAAC+FakeGaramond");
|
||||
assertThat(body.getCharcodes()).containsExactly(0x22L);
|
||||
}
|
||||
|
||||
private static final String PUA_E000 = "";
|
||||
private static final String PUA_E002 = "";
|
||||
|
||||
private static final byte[] SHARED_PROGRAM =
|
||||
"fake-ttf-program-shared".getBytes(java.nio.charset.StandardCharsets.US_ASCII);
|
||||
|
||||
private static String cacheIdentityPairBase64(String baseName, byte[] program)
|
||||
throws Exception {
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
PDPage page = new PDPage();
|
||||
doc.addPage(page);
|
||||
org.apache.pdfbox.cos.COSDictionary fonts = new org.apache.pdfbox.cos.COSDictionary();
|
||||
fonts.setItem(
|
||||
org.apache.pdfbox.cos.COSName.getPDFName("C1"),
|
||||
subsetFontDict(
|
||||
doc,
|
||||
baseName,
|
||||
program,
|
||||
toUnicodeCmap(new int[][] {{0x21, 0xE001}, {0x22, 0xE000}})));
|
||||
fonts.setItem(
|
||||
org.apache.pdfbox.cos.COSName.getPDFName("C2"),
|
||||
subsetFontDict(
|
||||
doc,
|
||||
baseName,
|
||||
program,
|
||||
toUnicodeCmap(new int[][] {{0x21, 0xE002}, {0x22, 0xE003}})));
|
||||
org.apache.pdfbox.pdmodel.PDResources resources =
|
||||
new org.apache.pdfbox.pdmodel.PDResources();
|
||||
resources.getCOSObject().setItem(org.apache.pdfbox.cos.COSName.FONT, fonts);
|
||||
page.setResources(resources);
|
||||
ByteArrayOutputStream bos = new ByteArrayOutputStream();
|
||||
doc.save(bos);
|
||||
return Base64.getEncoder().encodeToString(bos.toByteArray());
|
||||
}
|
||||
}
|
||||
|
||||
private static EncodeCharcodesRequest cacheIdentityRequest(
|
||||
String base64, String locator, String fontName, String fontSha256) {
|
||||
EncodeCharcodesRequest req = new EncodeCharcodesRequest();
|
||||
req.setPdfBase64(base64);
|
||||
req.setPageIndex(0);
|
||||
req.setLocatorChar(locator);
|
||||
req.setFontName(fontName);
|
||||
req.setFontSha256(fontSha256);
|
||||
req.setText(locator);
|
||||
return req;
|
||||
}
|
||||
|
||||
@Test
|
||||
void unnamedFontsSharingOneProgramDoNotShareACachedMap() throws Exception {
|
||||
String base64 = cacheIdentityPairBase64(null, SHARED_PROGRAM);
|
||||
String sha = sha256Hex(SHARED_PROGRAM);
|
||||
PdfTextEditorCharcodeController controller = controller();
|
||||
|
||||
EncodeCharcodesResponse first =
|
||||
controller
|
||||
.encodeCharcodes(cacheIdentityRequest(base64, PUA_E000, null, sha))
|
||||
.getBody();
|
||||
assertThat(first).isNotNull();
|
||||
assertThat(first.getError()).isNull();
|
||||
assertThat(first.getCharcodes()).containsExactly(0x22L);
|
||||
|
||||
EncodeCharcodesResponse second =
|
||||
controller
|
||||
.encodeCharcodes(cacheIdentityRequest(base64, PUA_E002, null, sha))
|
||||
.getBody();
|
||||
assertThat(second).isNotNull();
|
||||
assertThat(second.getError()).isNull();
|
||||
assertThat(second.getMissing()).isNullOrEmpty();
|
||||
assertThat(second.getCharcodes())
|
||||
.as("second font must not be served the first font's cached map")
|
||||
.containsExactly(0x21L);
|
||||
}
|
||||
|
||||
@Test
|
||||
void fontsSharingOneNameDoNotShareACachedMap() throws Exception {
|
||||
String base64 = cacheIdentityPairBase64("SharedName", null);
|
||||
PdfTextEditorCharcodeController controller = controller();
|
||||
|
||||
EncodeCharcodesResponse first =
|
||||
controller
|
||||
.encodeCharcodes(cacheIdentityRequest(base64, PUA_E000, "SharedName", null))
|
||||
.getBody();
|
||||
assertThat(first).isNotNull();
|
||||
assertThat(first.getError()).isNull();
|
||||
assertThat(first.getCharcodes()).containsExactly(0x22L);
|
||||
|
||||
EncodeCharcodesResponse second =
|
||||
controller
|
||||
.encodeCharcodes(cacheIdentityRequest(base64, PUA_E002, "SharedName", null))
|
||||
.getBody();
|
||||
assertThat(second).isNotNull();
|
||||
assertThat(second.getError()).isNull();
|
||||
assertThat(second.getMissing()).isNullOrEmpty();
|
||||
assertThat(second.getCharcodes())
|
||||
.as("same-name fonts must not share one cached map")
|
||||
.containsExactly(0x21L);
|
||||
}
|
||||
|
||||
private static String formXObjectFontBase64() throws Exception {
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
PDPage page = new PDPage();
|
||||
doc.addPage(page);
|
||||
|
||||
org.apache.pdfbox.pdmodel.graphics.form.PDFormXObject outer =
|
||||
new org.apache.pdfbox.pdmodel.graphics.form.PDFormXObject(doc);
|
||||
outer.setBBox(new org.apache.pdfbox.pdmodel.common.PDRectangle(0, 0, 200, 200));
|
||||
org.apache.pdfbox.pdmodel.graphics.form.PDFormXObject inner =
|
||||
new org.apache.pdfbox.pdmodel.graphics.form.PDFormXObject(doc);
|
||||
inner.setBBox(new org.apache.pdfbox.pdmodel.common.PDRectangle(0, 0, 100, 100));
|
||||
|
||||
org.apache.pdfbox.pdmodel.PDResources innerResources =
|
||||
new org.apache.pdfbox.pdmodel.PDResources();
|
||||
innerResources.put(
|
||||
org.apache.pdfbox.cos.COSName.getPDFName("F1"),
|
||||
new PDType1Font(Standard14Fonts.FontName.HELVETICA));
|
||||
inner.setResources(innerResources);
|
||||
|
||||
org.apache.pdfbox.pdmodel.PDResources outerResources =
|
||||
new org.apache.pdfbox.pdmodel.PDResources();
|
||||
outerResources.put(org.apache.pdfbox.cos.COSName.getPDFName("Fm1"), inner);
|
||||
outer.setResources(outerResources);
|
||||
|
||||
org.apache.pdfbox.pdmodel.PDResources pageResources =
|
||||
new org.apache.pdfbox.pdmodel.PDResources();
|
||||
pageResources.put(org.apache.pdfbox.cos.COSName.getPDFName("Fm0"), outer);
|
||||
page.setResources(pageResources);
|
||||
|
||||
ByteArrayOutputStream bos = new ByteArrayOutputStream();
|
||||
doc.save(bos);
|
||||
return Base64.getEncoder().encodeToString(bos.toByteArray());
|
||||
}
|
||||
}
|
||||
|
||||
private static String cyclicFormXObjectsBase64() throws Exception {
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
PDPage page = new PDPage();
|
||||
doc.addPage(page);
|
||||
|
||||
org.apache.pdfbox.pdmodel.graphics.form.PDFormXObject formA =
|
||||
new org.apache.pdfbox.pdmodel.graphics.form.PDFormXObject(doc);
|
||||
formA.setBBox(new org.apache.pdfbox.pdmodel.common.PDRectangle(0, 0, 100, 100));
|
||||
org.apache.pdfbox.pdmodel.graphics.form.PDFormXObject formB =
|
||||
new org.apache.pdfbox.pdmodel.graphics.form.PDFormXObject(doc);
|
||||
formB.setBBox(new org.apache.pdfbox.pdmodel.common.PDRectangle(0, 0, 100, 100));
|
||||
|
||||
org.apache.pdfbox.pdmodel.PDResources resA =
|
||||
new org.apache.pdfbox.pdmodel.PDResources();
|
||||
org.apache.pdfbox.pdmodel.PDResources resB =
|
||||
new org.apache.pdfbox.pdmodel.PDResources();
|
||||
resA.put(org.apache.pdfbox.cos.COSName.getPDFName("Self"), formA);
|
||||
resA.put(org.apache.pdfbox.cos.COSName.getPDFName("Fb"), formB);
|
||||
resB.put(org.apache.pdfbox.cos.COSName.getPDFName("Fa"), formA);
|
||||
resB.put(
|
||||
org.apache.pdfbox.cos.COSName.getPDFName("F1"),
|
||||
new PDType1Font(Standard14Fonts.FontName.HELVETICA));
|
||||
formA.setResources(resA);
|
||||
formB.setResources(resB);
|
||||
|
||||
org.apache.pdfbox.pdmodel.PDResources pageResources =
|
||||
new org.apache.pdfbox.pdmodel.PDResources();
|
||||
pageResources.put(org.apache.pdfbox.cos.COSName.getPDFName("Fm0"), formA);
|
||||
page.setResources(pageResources);
|
||||
|
||||
ByteArrayOutputStream bos = new ByteArrayOutputStream();
|
||||
doc.save(bos);
|
||||
return Base64.getEncoder().encodeToString(bos.toByteArray());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void fontReachableOnlyThroughAFormXObjectIsFound() throws Exception {
|
||||
ResponseEntity<EncodeCharcodesResponse> resp =
|
||||
controller().encodeCharcodes(manyFontsRequest(formXObjectFontBase64()));
|
||||
assertThat(resp.getStatusCode().value()).isEqualTo(200);
|
||||
EncodeCharcodesResponse body = resp.getBody();
|
||||
assertThat(body).isNotNull();
|
||||
assertThat(body.getError()).isNull();
|
||||
assertThat(body.getNote()).contains("Helvetica");
|
||||
assertThat(body.getCharcodes()).containsExactly((long) 'A');
|
||||
}
|
||||
|
||||
@Test
|
||||
@org.junit.jupiter.api.Timeout(60)
|
||||
void cyclicFormXObjectResourcesTerminate() throws Exception {
|
||||
ResponseEntity<EncodeCharcodesResponse> resp =
|
||||
controller().encodeCharcodes(manyFontsRequest(cyclicFormXObjectsBase64()));
|
||||
assertThat(resp.getStatusCode().value()).isEqualTo(200);
|
||||
EncodeCharcodesResponse body = resp.getBody();
|
||||
assertThat(body).isNotNull();
|
||||
assertThat(body.getError()).isNull();
|
||||
assertThat(body.getCharcodes()).containsExactly((long) 'A');
|
||||
}
|
||||
}
|
||||
+340
@@ -0,0 +1,340 @@
|
||||
package stirling.software.SPDF.controller.api;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.util.HashSet;
|
||||
import java.util.Set;
|
||||
import java.util.TreeSet;
|
||||
|
||||
import org.apache.pdfbox.Loader;
|
||||
import org.apache.pdfbox.contentstream.PDFStreamEngine;
|
||||
import org.apache.pdfbox.contentstream.operator.state.Concatenate;
|
||||
import org.apache.pdfbox.contentstream.operator.state.Restore;
|
||||
import org.apache.pdfbox.contentstream.operator.state.Save;
|
||||
import org.apache.pdfbox.contentstream.operator.state.SetGraphicsStateParameters;
|
||||
import org.apache.pdfbox.contentstream.operator.state.SetMatrix;
|
||||
import org.apache.pdfbox.contentstream.operator.text.BeginText;
|
||||
import org.apache.pdfbox.contentstream.operator.text.EndText;
|
||||
import org.apache.pdfbox.contentstream.operator.text.SetFontAndSize;
|
||||
import org.apache.pdfbox.contentstream.operator.text.SetTextHorizontalScaling;
|
||||
import org.apache.pdfbox.contentstream.operator.text.SetTextLeading;
|
||||
import org.apache.pdfbox.contentstream.operator.text.SetTextRenderingMode;
|
||||
import org.apache.pdfbox.contentstream.operator.text.SetTextRise;
|
||||
import org.apache.pdfbox.contentstream.operator.text.SetWordSpacing;
|
||||
import org.apache.pdfbox.contentstream.operator.text.ShowText;
|
||||
import org.apache.pdfbox.cos.COSBase;
|
||||
import org.apache.pdfbox.cos.COSDictionary;
|
||||
import org.apache.pdfbox.cos.COSName;
|
||||
import org.apache.pdfbox.cos.COSStream;
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.apache.pdfbox.pdmodel.PDPage;
|
||||
import org.apache.pdfbox.pdmodel.PDResources;
|
||||
import org.apache.pdfbox.pdmodel.font.PDFont;
|
||||
import org.apache.pdfbox.pdmodel.font.PDFontDescriptor;
|
||||
import org.apache.pdfbox.pdmodel.font.PDType3CharProc;
|
||||
import org.apache.pdfbox.pdmodel.font.PDType3Font;
|
||||
import org.junit.jupiter.api.Disabled;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
/**
|
||||
* Diagnostic test: enumerate every font referenced by Sample.pdf and dump its subtype, encoding,
|
||||
* ToUnicode, and embedded font program info. For Type3 fonts also dump CharProcs glyph names and
|
||||
* the content stream of one glyph (the 'M' if present).
|
||||
*
|
||||
* <p>Not a real regression test - run with --tests SamplePdfFontDumpTest -i to see the stdout
|
||||
* output.
|
||||
*/
|
||||
@Disabled(
|
||||
"Diagnostic probe: dumps Sample.pdf font internals to stdout and asserts nothing. Kept for font debugging; run manually.")
|
||||
public class SamplePdfFontDumpTest {
|
||||
|
||||
private static final Path SAMPLE =
|
||||
Paths.get(System.getProperty("user.dir"))
|
||||
.getParent()
|
||||
.getParent()
|
||||
.resolve("frontend/editor/public/samples/Sample.pdf");
|
||||
|
||||
@Test
|
||||
public void dumpFonts() throws IOException {
|
||||
byte[] pdfBytes = Files.readAllBytes(SAMPLE);
|
||||
try (PDDocument doc = Loader.loadPDF(pdfBytes)) {
|
||||
int numPages = doc.getNumberOfPages();
|
||||
System.out.println("Sample.pdf has " + numPages + " pages.");
|
||||
Set<COSDictionary> seenFontDicts = new HashSet<>();
|
||||
for (int p = 0; p < numPages; p++) {
|
||||
PDPage page = doc.getPage(p);
|
||||
System.out.println("\n=== Page " + p + " ===");
|
||||
PDResources resources = page.getResources();
|
||||
if (resources == null) {
|
||||
System.out.println(" (no resources)");
|
||||
continue;
|
||||
}
|
||||
for (COSName fontName : resources.getFontNames()) {
|
||||
PDFont font;
|
||||
try {
|
||||
font = resources.getFont(fontName);
|
||||
} catch (IOException e) {
|
||||
System.out.println(
|
||||
" Font "
|
||||
+ fontName.getName()
|
||||
+ ": failed to load - "
|
||||
+ e.getMessage());
|
||||
continue;
|
||||
}
|
||||
if (font == null) continue;
|
||||
COSDictionary dict = font.getCOSObject();
|
||||
if (!seenFontDicts.add(dict)) {
|
||||
System.out.println(
|
||||
" Font " + fontName.getName() + " -> already seen above");
|
||||
continue;
|
||||
}
|
||||
dumpFont(fontName.getName(), font);
|
||||
}
|
||||
}
|
||||
// Scan: for every text-show operation, record per-font (charcode, unicode) pairs.
|
||||
System.out.println("\n=== All (font, charcode, unicode) seen on page ===");
|
||||
for (int p = 0; p < numPages; p++) {
|
||||
PDPage page = doc.getPage(p);
|
||||
AllCharsScanner scanner = new AllCharsScanner();
|
||||
scanner.processPage(page);
|
||||
System.out.println("\nPage " + p + ":");
|
||||
for (var entry : scanner.perFont.entrySet()) {
|
||||
PDFont font = entry.getKey();
|
||||
var seen = entry.getValue();
|
||||
System.out.println(" Font " + font.getName() + " " + font.getSubType() + ":");
|
||||
var sortedSeen = new java.util.TreeMap<Integer, String>(seen);
|
||||
for (var s : sortedSeen.entrySet()) {
|
||||
System.out.println(
|
||||
" charcode 0x"
|
||||
+ Integer.toHexString(s.getKey())
|
||||
+ " ("
|
||||
+ s.getKey()
|
||||
+ ") -> '"
|
||||
+ s.getValue()
|
||||
+ "'");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Confirm font.encode() works for Type3 fonts.
|
||||
System.out.println("\n=== Can we encode existing chars in F27/F28? ===");
|
||||
PDPage page0 = doc.getPage(0);
|
||||
PDResources r0 = page0.getResources();
|
||||
for (String fname : new String[] {"F27", "F28"}) {
|
||||
PDFont f = r0.getFont(COSName.getPDFName(fname));
|
||||
if (f == null) {
|
||||
System.out.println(" " + fname + ": NOT FOUND on page 0");
|
||||
continue;
|
||||
}
|
||||
System.out.println(" " + fname + ": " + f.getClass().getSimpleName());
|
||||
for (String ch : new String[] {"M", "0", "1", "+", "Z", "a"}) {
|
||||
try {
|
||||
byte[] enc = f.encode(ch);
|
||||
StringBuilder sb = new StringBuilder();
|
||||
for (byte b : enc) sb.append(String.format("%02X ", b & 0xff));
|
||||
System.out.println(
|
||||
" encode('" + ch + "') -> [" + sb.toString().trim() + "]");
|
||||
} catch (Exception e) {
|
||||
System.out.println(
|
||||
" encode('"
|
||||
+ ch
|
||||
+ "') FAILED: "
|
||||
+ e.getClass().getSimpleName()
|
||||
+ " "
|
||||
+ e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Dump page 0 content stream so we can see how "10M+" is composed.
|
||||
System.out.println("\n=== Page 0 RAW content stream (first 4kb) ===");
|
||||
try (InputStream is = doc.getPage(0).getContents()) {
|
||||
byte[] bytes = is.readAllBytes();
|
||||
System.out.println("Total content stream size: " + bytes.length + " bytes");
|
||||
String asStr = new String(bytes, StandardCharsets.ISO_8859_1);
|
||||
int idx = asStr.indexOf("F27");
|
||||
if (idx >= 0) {
|
||||
int start = Math.max(0, idx - 100);
|
||||
int end = Math.min(asStr.length(), idx + 2500);
|
||||
System.out.println("--- F27 context ---");
|
||||
System.out.println(asStr.substring(start, end));
|
||||
System.out.println("---");
|
||||
}
|
||||
int idx2 = asStr.indexOf("F28");
|
||||
if (idx2 >= 0) {
|
||||
int start = Math.max(0, idx2 - 200);
|
||||
int end = Math.min(asStr.length(), idx2 + 600);
|
||||
System.out.println("--- F28 context ---");
|
||||
System.out.println(asStr.substring(start, end));
|
||||
System.out.println("---");
|
||||
}
|
||||
}
|
||||
|
||||
// Dump a CharProc for each font's first non-zero glyph, with focus on any 'M' or "0".
|
||||
System.out.println("\n=== Sample CharProc dumps for Type3 fonts ===");
|
||||
Set<COSDictionary> printed = new HashSet<>();
|
||||
for (int p = 0; p < numPages; p++) {
|
||||
PDPage page = doc.getPage(p);
|
||||
PDResources resources = page.getResources();
|
||||
if (resources == null) continue;
|
||||
for (COSName fn : resources.getFontNames()) {
|
||||
PDFont font = resources.getFont(fn);
|
||||
if (!(font instanceof PDType3Font)) continue;
|
||||
if (!printed.add(font.getCOSObject())) continue;
|
||||
PDType3Font t3 = (PDType3Font) font;
|
||||
// Iterate charcodes 0..255 looking for any that map to 'M' or '0' or '+'.
|
||||
for (int cc = 0; cc < 256; cc++) {
|
||||
String u = null;
|
||||
try {
|
||||
u = t3.toUnicode(cc);
|
||||
} catch (Exception e) {
|
||||
/* */
|
||||
}
|
||||
if (u == null) continue;
|
||||
if (u.equals("M") || u.equals("0") || u.equals("+") || u.equals("1")) {
|
||||
System.out.println(
|
||||
"Page "
|
||||
+ p
|
||||
+ " font '"
|
||||
+ fn.getName()
|
||||
+ "' charcode "
|
||||
+ cc
|
||||
+ " maps to '"
|
||||
+ u
|
||||
+ "':");
|
||||
dumpType3Glyph(t3, cc);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void dumpFont(String resourceName, PDFont font) {
|
||||
COSDictionary dict = font.getCOSObject();
|
||||
String subtype = dict.getNameAsString(COSName.SUBTYPE);
|
||||
String baseFont = dict.getNameAsString(COSName.BASE_FONT);
|
||||
boolean hasEncoding = dict.containsKey(COSName.ENCODING);
|
||||
boolean hasToUnicode = dict.containsKey(COSName.TO_UNICODE);
|
||||
PDFontDescriptor descriptor = font.getFontDescriptor();
|
||||
boolean hasEmbedded = false;
|
||||
String embeddedKind = "none";
|
||||
if (descriptor != null) {
|
||||
COSDictionary dDict = descriptor.getCOSObject();
|
||||
if (dDict.containsKey(COSName.FONT_FILE)) {
|
||||
hasEmbedded = true;
|
||||
embeddedKind = "FontFile (Type1)";
|
||||
} else if (dDict.containsKey(COSName.FONT_FILE2)) {
|
||||
hasEmbedded = true;
|
||||
embeddedKind = "FontFile2 (TrueType)";
|
||||
} else if (dDict.containsKey(COSName.FONT_FILE3)) {
|
||||
hasEmbedded = true;
|
||||
COSBase ff3 = dDict.getDictionaryObject(COSName.FONT_FILE3);
|
||||
if (ff3 instanceof COSStream) {
|
||||
String ff3Subtype = ((COSStream) ff3).getNameAsString(COSName.SUBTYPE);
|
||||
embeddedKind = "FontFile3 (" + ff3Subtype + ")";
|
||||
} else {
|
||||
embeddedKind = "FontFile3";
|
||||
}
|
||||
}
|
||||
}
|
||||
System.out.println(
|
||||
" Font resource '"
|
||||
+ resourceName
|
||||
+ "': base='"
|
||||
+ baseFont
|
||||
+ "' subtype="
|
||||
+ subtype
|
||||
+ " hasEncoding="
|
||||
+ hasEncoding
|
||||
+ " hasToUnicode="
|
||||
+ hasToUnicode
|
||||
+ " embedded="
|
||||
+ hasEmbedded
|
||||
+ " ("
|
||||
+ embeddedKind
|
||||
+ ")");
|
||||
|
||||
if (font instanceof PDType3Font) {
|
||||
PDType3Font t3 = (PDType3Font) font;
|
||||
COSDictionary charProcs = t3.getCharProcs();
|
||||
int count = charProcs == null ? 0 : charProcs.size();
|
||||
System.out.println(" Type3 CharProcs count = " + count);
|
||||
if (charProcs != null) {
|
||||
TreeSet<String> names = new TreeSet<>();
|
||||
for (COSName k : charProcs.keySet()) names.add(k.getName());
|
||||
System.out.println(" glyph names: " + names);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void dumpType3Glyph(PDType3Font font, int charcode) throws IOException {
|
||||
String name = font.getEncoding() != null ? font.getEncoding().getName(charcode) : null;
|
||||
System.out.println(" Type3 charcode " + charcode + " -> glyph name '" + name + "'");
|
||||
PDType3CharProc proc = font.getCharProc(charcode);
|
||||
if (proc == null) {
|
||||
System.out.println(" (no CharProc for that charcode)");
|
||||
return;
|
||||
}
|
||||
COSStream stream = proc.getCOSObject();
|
||||
byte[] raw;
|
||||
try (InputStream is = stream.createInputStream()) {
|
||||
raw = is.readAllBytes();
|
||||
}
|
||||
System.out.println(" CharProc content stream (" + raw.length + " bytes):");
|
||||
System.out.println("---");
|
||||
System.out.println(new String(raw, StandardCharsets.ISO_8859_1));
|
||||
System.out.println("---");
|
||||
}
|
||||
|
||||
/** Records every (font, charcode -> unicode) tuple seen on a page. */
|
||||
static final class AllCharsScanner extends PDFStreamEngine {
|
||||
final java.util.LinkedHashMap<PDFont, java.util.Map<Integer, String>> perFont =
|
||||
new java.util.LinkedHashMap<>();
|
||||
|
||||
AllCharsScanner() {
|
||||
addOperator(new BeginText(this));
|
||||
addOperator(new EndText(this));
|
||||
addOperator(new SetFontAndSize(this));
|
||||
addOperator(new SetTextHorizontalScaling(this));
|
||||
addOperator(new SetTextLeading(this));
|
||||
addOperator(new SetTextRenderingMode(this));
|
||||
addOperator(new SetTextRise(this));
|
||||
addOperator(new SetWordSpacing(this));
|
||||
addOperator(new SetMatrix(this));
|
||||
addOperator(new Save(this));
|
||||
addOperator(new Restore(this));
|
||||
addOperator(new Concatenate(this));
|
||||
addOperator(new SetGraphicsStateParameters(this));
|
||||
addOperator(new ShowText(this));
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void showText(byte[] string) throws IOException {
|
||||
PDFont font = getGraphicsState().getTextState().getFont();
|
||||
if (font == null) return;
|
||||
var seen = perFont.computeIfAbsent(font, k -> new java.util.LinkedHashMap<>());
|
||||
ByteArrayInputStream in = new ByteArrayInputStream(string);
|
||||
while (in.available() > 0) {
|
||||
int code;
|
||||
try {
|
||||
code = font.readCode(in);
|
||||
} catch (IOException e) {
|
||||
break;
|
||||
}
|
||||
String u;
|
||||
try {
|
||||
u = font.toUnicode(code);
|
||||
} catch (RuntimeException e) {
|
||||
u = null;
|
||||
}
|
||||
seen.putIfAbsent(code, u);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+374
@@ -0,0 +1,374 @@
|
||||
package stirling.software.SPDF.controller.api.form;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.lenient;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.OutputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.zip.ZipEntry;
|
||||
import java.util.zip.ZipInputStream;
|
||||
|
||||
import org.apache.pdfbox.Loader;
|
||||
import org.apache.pdfbox.cos.COSName;
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.apache.pdfbox.pdmodel.PDPage;
|
||||
import org.apache.pdfbox.pdmodel.common.PDRectangle;
|
||||
import org.apache.pdfbox.pdmodel.interactive.form.PDAcroForm;
|
||||
import org.apache.pdfbox.pdmodel.interactive.form.PDField;
|
||||
import org.apache.pdfbox.pdmodel.interactive.form.PDNonTerminalField;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.MethodSource;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.mock.web.MockMultipartFile;
|
||||
|
||||
import stirling.software.common.model.FormFieldWithCoordinates;
|
||||
import stirling.software.common.service.CustomPDFDocumentFactory;
|
||||
import stirling.software.common.util.FormUtils;
|
||||
import stirling.software.common.util.TempFile;
|
||||
import stirling.software.common.util.TempFileManager;
|
||||
|
||||
import tools.jackson.databind.ObjectMapper;
|
||||
import tools.jackson.databind.json.JsonMapper;
|
||||
|
||||
/**
|
||||
* Drives ?includeFields=true across a spread of real form shapes, checking the bundled list stays
|
||||
* interchangeable with the follow-up request it exists to remove.
|
||||
*/
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
@DisplayName("edit-fields field bundle")
|
||||
class FormFieldBundleTest {
|
||||
|
||||
/** Set to a directory to dump the produced archives for the frontend reader's fixtures. */
|
||||
private static final String FIXTURE_DIR = System.getProperty("bundle.fixtures");
|
||||
|
||||
@Mock private CustomPDFDocumentFactory pdfDocumentFactory;
|
||||
@Mock private TempFileManager tempFileManager;
|
||||
@InjectMocks private FormFillController controller;
|
||||
|
||||
private ObjectMapper objectMapper;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() throws Exception {
|
||||
lenient()
|
||||
.when(tempFileManager.createManagedTempFile(anyString()))
|
||||
.thenAnswer(
|
||||
invocation -> {
|
||||
File file =
|
||||
Files.createTempFile(
|
||||
"bundle", invocation.<String>getArgument(0))
|
||||
.toFile();
|
||||
TempFile temp = mock(TempFile.class);
|
||||
lenient().when(temp.getFile()).thenReturn(file);
|
||||
lenient().when(temp.getPath()).thenReturn(file.toPath());
|
||||
return temp;
|
||||
});
|
||||
objectMapper = JsonMapper.builder().build();
|
||||
var field = FormFillController.class.getDeclaredField("objectMapper");
|
||||
field.setAccessible(true);
|
||||
field.set(controller, objectMapper);
|
||||
}
|
||||
|
||||
// -- document shapes ----------------------------------------------
|
||||
|
||||
private record Style(
|
||||
String name, int pages, int rotation, List<FormUtils.NewFormFieldDefinition> fields) {}
|
||||
|
||||
private static FormUtils.NewFormFieldDefinition field(
|
||||
String name, String type, int page, float y, List<String> options) {
|
||||
return new FormUtils.NewFormFieldDefinition(
|
||||
name, null, type, page, 50f, y, 200f, 20f, null, null, options, null, null, null,
|
||||
null, null, null, null);
|
||||
}
|
||||
|
||||
static List<Style> styles() {
|
||||
List<Style> styles = new ArrayList<>();
|
||||
styles.add(new Style("text-only", 1, 0, List.of(field("fullName", "text", 0, 700f, null))));
|
||||
styles.add(
|
||||
new Style(
|
||||
"checkbox-and-radio",
|
||||
1,
|
||||
0,
|
||||
List.of(
|
||||
field("agree", "checkbox", 0, 700f, null),
|
||||
field("plan", "radio", 0, 650f, List.of("basic", "pro")))));
|
||||
styles.add(
|
||||
new Style(
|
||||
"choice-widgets",
|
||||
1,
|
||||
0,
|
||||
List.of(
|
||||
field("country", "dropdown", 0, 700f, List.of("UK", "IE", "FR")),
|
||||
field("tags", "listbox", 0, 640f, List.of("a", "b", "c")))));
|
||||
styles.add(
|
||||
new Style(
|
||||
"signature", 1, 0, List.of(field("approval", "signature", 0, 700f, null))));
|
||||
styles.add(
|
||||
new Style(
|
||||
"multi-page",
|
||||
3,
|
||||
0,
|
||||
List.of(
|
||||
field("p1", "text", 0, 700f, null),
|
||||
field("p2", "text", 1, 700f, null),
|
||||
field("p3", "text", 2, 700f, null))));
|
||||
styles.add(new Style("rotated-90", 1, 90, List.of(field("rot", "text", 0, 700f, null))));
|
||||
styles.add(new Style("rotated-270", 1, 270, List.of(field("rot", "text", 0, 700f, null))));
|
||||
styles.add(
|
||||
new Style(
|
||||
"unicode-names",
|
||||
1,
|
||||
0,
|
||||
List.of(
|
||||
field("nom_complet", "text", 0, 700f, null),
|
||||
field("adresse postale", "text", 0, 660f, null))));
|
||||
|
||||
List<FormUtils.NewFormFieldDefinition> many = new ArrayList<>();
|
||||
for (int i = 0; i < 120; i++) {
|
||||
many.add(
|
||||
field(
|
||||
"field_" + i,
|
||||
i % 3 == 0 ? "checkbox" : "text",
|
||||
i / 40,
|
||||
740f - (i % 40) * 18f,
|
||||
null));
|
||||
}
|
||||
styles.add(new Style("many-fields", 3, 0, many));
|
||||
return styles;
|
||||
}
|
||||
|
||||
private byte[] blankPdf(int pages, int rotation) throws IOException {
|
||||
try (PDDocument document = new PDDocument();
|
||||
ByteArrayOutputStream out = new ByteArrayOutputStream()) {
|
||||
for (int i = 0; i < pages; i++) {
|
||||
PDPage page = new PDPage(PDRectangle.A4);
|
||||
page.setRotation(rotation);
|
||||
document.addPage(page);
|
||||
}
|
||||
document.getDocumentCatalog().setAcroForm(new PDAcroForm(document));
|
||||
document.save(out);
|
||||
return out.toByteArray();
|
||||
}
|
||||
}
|
||||
|
||||
// -- the test ------------------------------------------------------
|
||||
|
||||
@ParameterizedTest(name = "{0}")
|
||||
@MethodSource("styles")
|
||||
@DisplayName("bundled list matches the follow-up request it replaces")
|
||||
void bundleMatchesRefetch(Style style) throws Exception {
|
||||
byte[] source = blankPdf(style.pages(), style.rotation());
|
||||
MockMultipartFile upload =
|
||||
new MockMultipartFile("file", style.name() + ".pdf", "application/pdf", source);
|
||||
byte[] edits = objectMapper.writeValueAsBytes(Map.of("add", style.fields()));
|
||||
|
||||
byte[] zipBytes;
|
||||
try (PDDocument document = Loader.loadPDF(source)) {
|
||||
when(pdfDocumentFactory.load(eq(upload))).thenReturn(document);
|
||||
zipBytes = drain(controller.editFields(upload, edits, true));
|
||||
}
|
||||
Map<String, byte[]> bundle = unzip(zipBytes);
|
||||
|
||||
assertThat(bundle).containsKeys("document.pdf", "fields.json");
|
||||
byte[] editedPdf = bundle.get("document.pdf");
|
||||
assertThat(new String(editedPdf, 0, 5, StandardCharsets.UTF_8)).isEqualTo("%PDF-");
|
||||
|
||||
// The comparison that matters: ask the endpoint this feature stops re-calling,
|
||||
// and demand a match.
|
||||
MockMultipartFile saved =
|
||||
new MockMultipartFile("file", style.name() + ".pdf", "application/pdf", editedPdf);
|
||||
try (PDDocument reloaded = Loader.loadPDF(editedPdf)) {
|
||||
when(pdfDocumentFactory.load(eq(saved), eq(true))).thenReturn(reloaded);
|
||||
ResponseEntity<List<FormFieldWithCoordinates>> refetched =
|
||||
controller.listFieldsWithCoordinates(saved);
|
||||
assertThat(new String(bundle.get("fields.json"), StandardCharsets.UTF_8))
|
||||
.isEqualTo(objectMapper.writeValueAsString(refetched.getBody()));
|
||||
}
|
||||
|
||||
dumpFixture(style.name(), zipBytes);
|
||||
}
|
||||
|
||||
@ParameterizedTest(name = "{0}")
|
||||
@MethodSource("styles")
|
||||
@DisplayName("pdf entry is stored and json entry is deflated")
|
||||
void perEntryCompression(Style style) throws Exception {
|
||||
byte[] source = blankPdf(style.pages(), style.rotation());
|
||||
MockMultipartFile upload =
|
||||
new MockMultipartFile("file", style.name() + ".pdf", "application/pdf", source);
|
||||
byte[] edits = objectMapper.writeValueAsBytes(Map.of("add", style.fields()));
|
||||
|
||||
byte[] zipBytes;
|
||||
try (PDDocument document = Loader.loadPDF(source)) {
|
||||
when(pdfDocumentFactory.load(eq(upload))).thenReturn(document);
|
||||
zipBytes = drain(controller.editFields(upload, edits, true));
|
||||
}
|
||||
|
||||
Map<String, Integer> methods = methodsOf(zipBytes);
|
||||
assertThat(methods.get("document.pdf")).isEqualTo(ZipEntry.STORED);
|
||||
assertThat(methods.get("fields.json")).isEqualTo(ZipEntry.DEFLATED);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("hierarchical field names survive the bundle")
|
||||
void nestedFieldNames() throws Exception {
|
||||
byte[] source = nestedPdf();
|
||||
MockMultipartFile upload =
|
||||
new MockMultipartFile("file", "nested.pdf", "application/pdf", source);
|
||||
byte[] edits =
|
||||
objectMapper.writeValueAsBytes(
|
||||
Map.of(
|
||||
"modify",
|
||||
List.of(
|
||||
Map.of(
|
||||
"targetName",
|
||||
"Customer.Name",
|
||||
"defaultValue",
|
||||
"Ada"))));
|
||||
|
||||
byte[] zipBytes;
|
||||
try (PDDocument document = Loader.loadPDF(source)) {
|
||||
when(pdfDocumentFactory.load(eq(upload))).thenReturn(document);
|
||||
zipBytes = drain(controller.editFields(upload, edits, true));
|
||||
}
|
||||
Map<String, byte[]> bundle = unzip(zipBytes);
|
||||
byte[] editedPdf = bundle.get("document.pdf");
|
||||
|
||||
MockMultipartFile saved =
|
||||
new MockMultipartFile("file", "nested.pdf", "application/pdf", editedPdf);
|
||||
try (PDDocument reloaded = Loader.loadPDF(editedPdf)) {
|
||||
when(pdfDocumentFactory.load(eq(saved), eq(true))).thenReturn(reloaded);
|
||||
ResponseEntity<List<FormFieldWithCoordinates>> refetched =
|
||||
controller.listFieldsWithCoordinates(saved);
|
||||
String bundled = new String(bundle.get("fields.json"), StandardCharsets.UTF_8);
|
||||
assertThat(bundled).contains("Customer.Name");
|
||||
assertThat(bundled).isEqualTo(objectMapper.writeValueAsString(refetched.getBody()));
|
||||
}
|
||||
}
|
||||
|
||||
/** Builds a parent field with two children, which add-fields cannot express. */
|
||||
private byte[] nestedPdf() throws IOException {
|
||||
try (PDDocument document = Loader.loadPDF(blankPdf(1, 0));
|
||||
ByteArrayOutputStream out = new ByteArrayOutputStream()) {
|
||||
PDAcroForm form = document.getDocumentCatalog().getAcroForm(null);
|
||||
FormUtils.addNewFields(
|
||||
document,
|
||||
List.of(
|
||||
field("Name", "text", 0, 700f, null),
|
||||
field("Email", "text", 0, 660f, null)));
|
||||
|
||||
PDNonTerminalField parent = new PDNonTerminalField(form);
|
||||
parent.setPartialName("Customer");
|
||||
List<PDField> kids = new ArrayList<>();
|
||||
for (String child : List.of("Name", "Email")) {
|
||||
PDField kid = form.getField(child);
|
||||
kid.getCOSObject().setItem(COSName.PARENT, parent.getCOSObject());
|
||||
kids.add(kid);
|
||||
}
|
||||
parent.setChildren(kids);
|
||||
form.setFields(List.of(parent));
|
||||
document.save(out);
|
||||
return out.toByteArray();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("bundle stays close to the wire cost of the two calls it replaces")
|
||||
void wireCost() throws Exception {
|
||||
Style style =
|
||||
styles().stream()
|
||||
.filter(s -> s.name().equals("many-fields"))
|
||||
.findFirst()
|
||||
.orElseThrow();
|
||||
byte[] source = blankPdf(style.pages(), style.rotation());
|
||||
MockMultipartFile upload =
|
||||
new MockMultipartFile("file", "cost.pdf", "application/pdf", source);
|
||||
byte[] edits = objectMapper.writeValueAsBytes(Map.of("add", style.fields()));
|
||||
|
||||
byte[] zipBytes;
|
||||
Map<String, byte[]> bundle;
|
||||
try (PDDocument document = Loader.loadPDF(source)) {
|
||||
when(pdfDocumentFactory.load(eq(upload))).thenReturn(document);
|
||||
zipBytes = drain(controller.editFields(upload, edits, true));
|
||||
}
|
||||
bundle = unzip(zipBytes);
|
||||
|
||||
int pdfSize = bundle.get("document.pdf").length;
|
||||
int jsonSize = bundle.get("fields.json").length;
|
||||
System.out.printf(
|
||||
"wire: pdf=%d json=%d zip=%d overhead=%d bytes (%.2f%% over the pdf alone)%n",
|
||||
pdfSize,
|
||||
jsonSize,
|
||||
zipBytes.length,
|
||||
zipBytes.length - pdfSize,
|
||||
100.0 * (zipBytes.length - pdfSize) / pdfSize);
|
||||
|
||||
// True for a field list this repetitive; on a tiny list the ~200 bytes of zip framing can
|
||||
// exceed what deflate saves, so this is a property of the fixture, not of every document.
|
||||
assertThat(zipBytes.length).isLessThan(pdfSize + jsonSize);
|
||||
}
|
||||
|
||||
// -- helpers -------------------------------------------------------
|
||||
|
||||
private static byte[] drain(ResponseEntity<Resource> response) throws IOException {
|
||||
ByteArrayOutputStream out = new ByteArrayOutputStream();
|
||||
try (InputStream in = response.getBody().getInputStream()) {
|
||||
in.transferTo(out);
|
||||
}
|
||||
return out.toByteArray();
|
||||
}
|
||||
|
||||
private static Map<String, byte[]> unzip(byte[] zipBytes) throws IOException {
|
||||
Map<String, byte[]> entries = new HashMap<>();
|
||||
try (ZipInputStream in = new ZipInputStream(new ByteArrayInputStream(zipBytes))) {
|
||||
for (ZipEntry entry; (entry = in.getNextEntry()) != null; ) {
|
||||
ByteArrayOutputStream out = new ByteArrayOutputStream();
|
||||
in.transferTo(out);
|
||||
entries.put(entry.getName(), out.toByteArray());
|
||||
}
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
private static Map<String, Integer> methodsOf(byte[] zipBytes) throws IOException {
|
||||
Map<String, Integer> methods = new HashMap<>();
|
||||
try (ZipInputStream in = new ZipInputStream(new ByteArrayInputStream(zipBytes))) {
|
||||
for (ZipEntry entry; (entry = in.getNextEntry()) != null; ) {
|
||||
methods.put(entry.getName(), entry.getMethod());
|
||||
in.transferTo(OutputStream.nullOutputStream());
|
||||
}
|
||||
}
|
||||
return methods;
|
||||
}
|
||||
|
||||
private static void dumpFixture(String name, byte[] zipBytes) throws IOException {
|
||||
if (FIXTURE_DIR == null) {
|
||||
return;
|
||||
}
|
||||
Path dir = Paths.get(FIXTURE_DIR);
|
||||
Files.createDirectories(dir);
|
||||
Files.write(dir.resolve(name + ".zip"), zipBytes);
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user