mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2026-09-03 21:30:14 +03:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1d6a1c2904 | ||
|
|
b4f1c67f65 | ||
|
|
e42175dd9b |
@@ -1,19 +0,0 @@
|
||||
{
|
||||
"$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
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,11 @@ 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,7 +67,6 @@ labels:
|
||||
- 'frontend/**'
|
||||
- 'frontend/.*'
|
||||
- 'frontend/**/.*'
|
||||
- '.taskfiles/frontend.yml'
|
||||
|
||||
- label: 'Tauri'
|
||||
files:
|
||||
|
||||
@@ -20,7 +20,6 @@ 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
|
||||
|
||||
@@ -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@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
|
||||
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
|
||||
|
||||
- name: Get version number
|
||||
id: versionNumber
|
||||
@@ -353,7 +353,7 @@ jobs:
|
||||
|
||||
- name: Install Task for Storybook
|
||||
if: steps.sb-changes.outputs.storybook == 'true'
|
||||
uses: go-task/setup-task@a00fbb05ce67b35648be3c78cbc9fd85354c757e # v2.2.0
|
||||
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
|
||||
|
||||
- name: Build and deploy Storybook
|
||||
id: storybook
|
||||
|
||||
@@ -206,7 +206,7 @@ jobs:
|
||||
distribution: "temurin"
|
||||
|
||||
- name: Install Task
|
||||
uses: go-task/setup-task@a00fbb05ce67b35648be3c78cbc9fd85354c757e # v2.2.0
|
||||
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.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@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
|
||||
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||
|
||||
@@ -1,246 +0,0 @@
|
||||
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
|
||||
@@ -34,9 +34,10 @@ jobs:
|
||||
cache-dependency-glob: |
|
||||
engine/pyproject.toml
|
||||
engine/uv.lock
|
||||
cache-suffix: ai-engine
|
||||
|
||||
- name: Install Task
|
||||
uses: go-task/setup-task@a00fbb05ce67b35648be3c78cbc9fd85354c757e # v2.2.0
|
||||
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
|
||||
|
||||
- name: Quality-check engine
|
||||
id: engine-check
|
||||
|
||||
@@ -52,7 +52,7 @@ jobs:
|
||||
distribution: "temurin"
|
||||
|
||||
- name: Install Task
|
||||
uses: go-task/setup-task@a00fbb05ce67b35648be3c78cbc9fd85354c757e # v2.2.0
|
||||
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.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
|
||||
|
||||
@@ -95,7 +95,7 @@ jobs:
|
||||
cache: "npm"
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
- name: Install Task
|
||||
uses: go-task/setup-task@a00fbb05ce67b35648be3c78cbc9fd85354c757e # v2.2.0
|
||||
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
|
||||
- name: Install Playwright (chromium only)
|
||||
run: task e2e:install -- chromium
|
||||
- name: Build frontend (needed for playwright's vite preview webServer)
|
||||
|
||||
@@ -42,6 +42,7 @@ 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
|
||||
@@ -75,7 +76,7 @@ jobs:
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
|
||||
- name: Install Task
|
||||
uses: go-task/setup-task@a00fbb05ce67b35648be3c78cbc9fd85354c757e # v2.2.0
|
||||
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
|
||||
|
||||
- name: Verify generated models are up to date
|
||||
id: models-check
|
||||
|
||||
@@ -38,7 +38,7 @@ jobs:
|
||||
distribution: "temurin"
|
||||
|
||||
- name: Install Task
|
||||
uses: go-task/setup-task@a00fbb05ce67b35648be3c78cbc9fd85354c757e # v2.2.0
|
||||
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
|
||||
- name: Check licenses for compatibility
|
||||
run: task backend:licenses:check
|
||||
env:
|
||||
|
||||
@@ -39,7 +39,7 @@ jobs:
|
||||
distribution: "temurin"
|
||||
|
||||
- name: Install Task
|
||||
uses: go-task/setup-task@a00fbb05ce67b35648be3c78cbc9fd85354c757e # v2.2.0
|
||||
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
|
||||
- name: Generate OpenAPI documentation
|
||||
run: task backend:swagger
|
||||
env:
|
||||
|
||||
@@ -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@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
|
||||
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
|
||||
|
||||
# Expose ACTIONS_RUNTIME_TOKEN / ACTIONS_RESULTS_URL for docker buildx type=gha cache backend.
|
||||
- name: Expose GitHub runtime for Buildx cache
|
||||
|
||||
@@ -45,7 +45,7 @@ jobs:
|
||||
cache: "npm"
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
- name: Install Task
|
||||
uses: go-task/setup-task@a00fbb05ce67b35648be3c78cbc9fd85354c757e # v2.2.0
|
||||
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
|
||||
- name: Install Playwright (chromium only)
|
||||
run: task e2e:install -- chromium
|
||||
- name: Build frontend (production bundle for vite preview)
|
||||
|
||||
@@ -44,7 +44,7 @@ jobs:
|
||||
cache: "npm"
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
- name: Install Task
|
||||
uses: go-task/setup-task@a00fbb05ce67b35648be3c78cbc9fd85354c757e # v2.2.0
|
||||
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
|
||||
- name: Build frontend (production bundle for vite preview)
|
||||
env:
|
||||
VITE_BUILD_FOR_PREVIEW: "1"
|
||||
|
||||
@@ -36,7 +36,7 @@ jobs:
|
||||
cache: "npm"
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
- name: Install Task
|
||||
uses: go-task/setup-task@a00fbb05ce67b35648be3c78cbc9fd85354c757e # v2.2.0
|
||||
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
|
||||
- name: a11y gate (changed stories)
|
||||
run: task frontend:storybook:a11y:changed -- origin/${{ github.base_ref || 'main' }}
|
||||
- name: Upload scan reports
|
||||
|
||||
@@ -97,7 +97,7 @@ jobs:
|
||||
run: npm ci --ignore-scripts --audit=false --fund=false
|
||||
|
||||
- name: Install Task
|
||||
uses: go-task/setup-task@a00fbb05ce67b35648be3c78cbc9fd85354c757e # v2.2.0
|
||||
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
|
||||
|
||||
- name: Generate frontend license report (Push only)
|
||||
if: github.event_name == 'push'
|
||||
@@ -367,7 +367,7 @@ jobs:
|
||||
distribution: "temurin"
|
||||
|
||||
- name: Install Task
|
||||
uses: go-task/setup-task@a00fbb05ce67b35648be3c78cbc9fd85354c757e # v2.2.0
|
||||
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
|
||||
|
||||
- name: Check licenses and generate report
|
||||
id: license-check
|
||||
|
||||
@@ -27,7 +27,7 @@ jobs:
|
||||
cache: "npm"
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
- name: Install Task
|
||||
uses: go-task/setup-task@a00fbb05ce67b35648be3c78cbc9fd85354c757e # v2.2.0
|
||||
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
|
||||
- name: Quality-check frontend
|
||||
id: frontend-check
|
||||
run: task frontend:check:all
|
||||
|
||||
@@ -69,7 +69,7 @@ jobs:
|
||||
distribution: "temurin"
|
||||
|
||||
- name: Install Task
|
||||
uses: go-task/setup-task@a00fbb05ce67b35648be3c78cbc9fd85354c757e # v2.2.0
|
||||
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
|
||||
- name: Get version number
|
||||
id: versionNumber
|
||||
run: |
|
||||
@@ -169,7 +169,7 @@ jobs:
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
|
||||
- name: Install Task
|
||||
uses: go-task/setup-task@a00fbb05ce67b35648be3c78cbc9fd85354c757e # v2.2.0
|
||||
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
|
||||
|
||||
- name: Build JAR
|
||||
run: ./gradlew build ${{ matrix.variant.build_frontend && '-PbuildWithFrontend=true' || '' }} -x spotlessApply -x spotlessCheck -x test -x sonarqube
|
||||
@@ -268,7 +268,7 @@ jobs:
|
||||
distribution: ${{ matrix.platform == 'windows-11-arm' && 'microsoft' || 'temurin' }}
|
||||
|
||||
- name: Install Task
|
||||
uses: go-task/setup-task@a00fbb05ce67b35648be3c78cbc9fd85354c757e # v2.2.0
|
||||
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
|
||||
|
||||
# Build the universal JRE before desktop:prepare so the jlink:runtime
|
||||
# task short-circuits on its `test -d runtime/jre` status check.
|
||||
|
||||
@@ -38,7 +38,7 @@ jobs:
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
|
||||
- name: Install Task
|
||||
uses: go-task/setup-task@a00fbb05ce67b35648be3c78cbc9fd85354c757e # v2.2.0
|
||||
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
|
||||
- name: Install all Playwright browsers
|
||||
run: task e2e:install
|
||||
|
||||
@@ -89,7 +89,7 @@ jobs:
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
|
||||
- name: Install Task
|
||||
uses: go-task/setup-task@a00fbb05ce67b35648be3c78cbc9fd85354c757e # v2.2.0
|
||||
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
|
||||
|
||||
- name: a11y gate (every story, ${{ matrix.theme }})
|
||||
run: task frontend:storybook:a11y:${{ matrix.theme }}
|
||||
@@ -162,7 +162,7 @@ jobs:
|
||||
engine/uv.lock
|
||||
|
||||
- name: Install Task
|
||||
uses: go-task/setup-task@a00fbb05ce67b35648be3c78cbc9fd85354c757e # v2.2.0
|
||||
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
|
||||
|
||||
- name: Start the fat image with login and storage enabled
|
||||
run: docker compose -f docker/embedded/compose/test_cicd.yml up -d --build
|
||||
|
||||
@@ -31,14 +31,10 @@ jobs:
|
||||
cache-dependency-glob: |
|
||||
engine/pyproject.toml
|
||||
engine/uv.lock
|
||||
cache-suffix: pre-commit
|
||||
|
||||
- name: Install Task
|
||||
uses: go-task/setup-task@a00fbb05ce67b35648be3c78cbc9fd85354c757e # v2.2.0
|
||||
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.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
|
||||
|
||||
@@ -69,7 +69,7 @@ jobs:
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
id: buildx
|
||||
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
|
||||
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@96fe6ef7f33517b61c61be40b68a1882f3264fb8 # v4.2.0
|
||||
|
||||
@@ -85,10 +85,10 @@ jobs:
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
id: buildx
|
||||
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
|
||||
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
|
||||
|
||||
- name: Install Task
|
||||
uses: go-task/setup-task@a00fbb05ce67b35648be3c78cbc9fd85354c757e # v2.2.0
|
||||
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
|
||||
- name: Get version number
|
||||
id: versionNumber
|
||||
run: echo "versionNumber=$(./gradlew printVersion --quiet | tail -1)" >> $GITHUB_OUTPUT
|
||||
|
||||
@@ -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@db488ddef3bf6cb639b32c2e9a7c0a7ea8271d28 # v4.37.8
|
||||
uses: github/codeql-action/upload-sarif@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4.37.7
|
||||
with:
|
||||
sarif_file: results.sarif
|
||||
|
||||
@@ -63,7 +63,7 @@ jobs:
|
||||
SWAGGERHUB_USER: "Frooodle"
|
||||
|
||||
- name: Install Task
|
||||
uses: go-task/setup-task@a00fbb05ce67b35648be3c78cbc9fd85354c757e # v2.2.0
|
||||
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
|
||||
- name: Get version number
|
||||
id: versionNumber
|
||||
run: echo "versionNumber=$(./gradlew printVersion --quiet | tail -1)" >> $GITHUB_OUTPUT
|
||||
|
||||
@@ -59,13 +59,14 @@ 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@a00fbb05ce67b35648be3c78cbc9fd85354c757e # v2.2.0
|
||||
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
|
||||
|
||||
- name: Sync translation TOML files
|
||||
run: |
|
||||
|
||||
@@ -212,7 +212,7 @@ jobs:
|
||||
distribution: ${{ matrix.platform == 'windows-11-arm' && 'microsoft' || 'temurin' }}
|
||||
|
||||
- name: Setup Task
|
||||
uses: go-task/setup-task@a00fbb05ce67b35648be3c78cbc9fd85354c757e # v2.2.0
|
||||
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
|
||||
|
||||
- name: Build universal macOS JRE
|
||||
if: matrix.platform == 'macos-15'
|
||||
|
||||
@@ -127,7 +127,7 @@ jobs:
|
||||
distribution: "temurin"
|
||||
|
||||
- name: Install Task
|
||||
uses: go-task/setup-task@a00fbb05ce67b35648be3c78cbc9fd85354c757e # v2.2.0
|
||||
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.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@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
|
||||
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
|
||||
|
||||
- name: Set base image and platform for this build
|
||||
id: build-params
|
||||
@@ -229,7 +229,7 @@ jobs:
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
id: buildx
|
||||
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
|
||||
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
|
||||
|
||||
- name: Build docker/unoserver/Dockerfile
|
||||
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
|
||||
|
||||
+2
-7
@@ -298,13 +298,8 @@ docs/type3/signatures/
|
||||
|
||||
**/application-dev-local.properties
|
||||
|
||||
# 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
|
||||
# Claude
|
||||
.claude/
|
||||
|
||||
# Playwright MCP screenshots / traces
|
||||
.playwright-mcp/
|
||||
|
||||
@@ -23,7 +23,7 @@ tasks:
|
||||
- package-lock.json
|
||||
- package.json
|
||||
status:
|
||||
- npm ls --depth=0
|
||||
- test -d node_modules
|
||||
env:
|
||||
CI: '{{ .CI | default "false" }}'
|
||||
|
||||
|
||||
@@ -11,7 +11,6 @@ vars:
|
||||
'.github/scripts/*.py'
|
||||
'app/core/src/main/resources/static/python/*.py'
|
||||
':(exclude)*split_photos.py'
|
||||
':(exclude)scripts/lint/fixtures/*'
|
||||
SPELL_FILES: >-
|
||||
'*.html'
|
||||
'*.css'
|
||||
@@ -60,7 +59,6 @@ tasks:
|
||||
- task: gitleaks
|
||||
- task: whitespace
|
||||
- task: toml-sort
|
||||
- task: comment-lint
|
||||
|
||||
fix:
|
||||
desc: "Auto-fix formatting, spelling, and secrets issues across the repo"
|
||||
@@ -77,7 +75,6 @@ tasks:
|
||||
vars: { FIX: '1' }
|
||||
- task: codespell
|
||||
- task: gitleaks
|
||||
- task: comment-lint
|
||||
|
||||
install:
|
||||
desc: "Install the pinned pre-commit Python tools"
|
||||
@@ -133,85 +130,6 @@ 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"
|
||||
|
||||
Vendored
+1
-1
@@ -42,7 +42,7 @@
|
||||
"java.configuration.updateBuildConfiguration": "interactive",
|
||||
"java.format.enabled": true,
|
||||
"java.format.settings.profile": "GoogleStyle",
|
||||
"java.format.settings.google.version": "1.35.0",
|
||||
"java.format.settings.google.version": "1.28.0",
|
||||
"java.format.settings.google.extra": "--aosp --skip-sorting-imports --skip-javadoc-formatting",
|
||||
// (DE) Aktiviert Kommentare im Java-Format.
|
||||
// (EN) Enables comments in Java formatting.
|
||||
|
||||
@@ -21,43 +21,6 @@ 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
|
||||
@@ -107,7 +70,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.
|
||||
- Comments follow the repo-wide rules in the "Comments" section above.
|
||||
- Add comments sparingly and only when they explain non-obvious intent.
|
||||
|
||||
#### Python Typing and Models
|
||||
- Deserialize into Pydantic models as early as possible.
|
||||
|
||||
@@ -42,7 +42,6 @@ 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
|
||||
|
||||
|
||||
@@ -266,20 +266,6 @@ 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"
|
||||
|
||||
+7
-32
@@ -3,10 +3,6 @@ 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'
|
||||
@@ -26,10 +22,7 @@ dependencies {
|
||||
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'
|
||||
// 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 'org.simplejavamail:outlook-module:9.3.2' // MSG file support
|
||||
api 'jakarta.mail:jakarta.mail-api:2.1.5'
|
||||
runtimeOnly 'org.eclipse.angus:angus-mail:2.0.5'
|
||||
|
||||
@@ -43,30 +36,12 @@ dependencies {
|
||||
|
||||
api "com.stirling:jpdfium:${jpdfiumVersion}"
|
||||
|
||||
// -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']
|
||||
// -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']
|
||||
def jpdfiumPlatforms
|
||||
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') {
|
||||
if (jpdfiumPlatformsProp == 'all') {
|
||||
jpdfiumPlatforms = jpdfiumAllPlatforms
|
||||
} else if (jpdfiumPlatformsProp == 'none') {
|
||||
jpdfiumPlatforms = []
|
||||
@@ -76,7 +51,7 @@ dependencies {
|
||||
def jpdfiumInvalid = jpdfiumPlatforms.findAll { !jpdfiumAllPlatforms.contains(it) }
|
||||
if (jpdfiumInvalid) {
|
||||
throw new GradleException("Unknown jpdfiumPlatforms value(s): ${jpdfiumInvalid.join(', ')}. " +
|
||||
"Valid: ${jpdfiumAllPlatforms.join(', ')}, 'auto', 'all' or 'none'.")
|
||||
"Valid: ${jpdfiumAllPlatforms.join(', ')}, '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 = ConcurrentHashMap.newKeySet();
|
||||
private Set<String> disabledGroups = new HashSet<>();
|
||||
private Map<String, DisableReason> endpointDisableReasons = new ConcurrentHashMap<>();
|
||||
private Map<String, DisableReason> groupDisableReasons = new ConcurrentHashMap<>();
|
||||
private Map<String, Set<String>> endpointAlternatives = new ConcurrentHashMap<>();
|
||||
@@ -174,8 +174,7 @@ public class EndpointConfiguration {
|
||||
&& disabledGroups.contains(group)
|
||||
&& entry.getValue().contains(endpoint)) {
|
||||
log.debug(
|
||||
"isEndpointEnabled('{}') -> false (single tool group '{}' disabled, no"
|
||||
+ " alternatives)",
|
||||
"isEndpointEnabled('{}') -> false (single tool group '{}' disabled, no alternatives)",
|
||||
original,
|
||||
group);
|
||||
return false;
|
||||
@@ -334,8 +333,7 @@ public class EndpointConfiguration {
|
||||
String.join(", ", functionallyDisabledEndpoints));
|
||||
} else if (!disabledToolGroups.isEmpty()) {
|
||||
log.info(
|
||||
"No endpoints disabled despite missing tools - fallback implementations"
|
||||
+ " available");
|
||||
"No endpoints disabled despite missing tools - fallback implementations available");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -237,7 +237,7 @@ public class TabulaTableParser implements TableParser {
|
||||
score -= 0.3f;
|
||||
}
|
||||
|
||||
return Math.clamp(score, 0f, 1f);
|
||||
return Math.max(0f, Math.min(1f, score));
|
||||
}
|
||||
|
||||
private Bounds tableBounds(Table table) {
|
||||
|
||||
@@ -85,8 +85,7 @@ public class AutoJobAspect {
|
||||
return joinPoint.proceed(args);
|
||||
} catch (Throwable ex) {
|
||||
log.error(
|
||||
"AutoJobAspect caught exception during job execution:"
|
||||
+ " {}",
|
||||
"AutoJobAspect caught exception during job execution: {}",
|
||||
ex.getMessage(),
|
||||
ex);
|
||||
// Rethrow RuntimeException as-is to preserve exception type
|
||||
@@ -166,8 +165,8 @@ public class AutoJobAspect {
|
||||
} catch (Throwable ex) {
|
||||
lastException = ex;
|
||||
log.error(
|
||||
"AutoJobAspect caught exception during job execution"
|
||||
+ " (attempt {}/{}): {}",
|
||||
"AutoJobAspect caught exception during job execution (attempt"
|
||||
+ " {}/{}): {}",
|
||||
currentAttempt,
|
||||
maxRetries,
|
||||
ex.getMessage(),
|
||||
@@ -184,8 +183,7 @@ public class AutoJobAspect {
|
||||
String jobId = jobIdRef.get();
|
||||
if (jobId != null) {
|
||||
log.debug(
|
||||
"Recording retry attempt for job {} in"
|
||||
+ " TaskManager",
|
||||
"Recording retry attempt for job {} in TaskManager",
|
||||
jobId);
|
||||
// Retry info is tracked in TaskManager for REST API
|
||||
// access
|
||||
|
||||
@@ -43,9 +43,9 @@ public class ClusterConfig {
|
||||
} else if ("inprocess".equalsIgnoreCase(backplane)) {
|
||||
// enabled+inprocess only coordinates the local JVM; cross-node lookups will 410.
|
||||
log.warn(
|
||||
"cluster.enabled=true with backplane=inprocess - only the local JVM is"
|
||||
+ " coordinated. Cross-node lookups and the file proxy will fail. Use"
|
||||
+ " backplane=valkey for real multi-node deployments.");
|
||||
"cluster.enabled=true with backplane=inprocess - only the local"
|
||||
+ " JVM is coordinated. Cross-node lookups and the file proxy will fail."
|
||||
+ " Use backplane=valkey for real multi-node deployments.");
|
||||
} else {
|
||||
// Fail fast on typos like "valky" so Spring doesn't later report a cryptic
|
||||
// "no ClusterBackplane bean" - the operator-facing error names the bad value.
|
||||
|
||||
+8
-15
@@ -230,14 +230,12 @@ public class RuntimePathConfig {
|
||||
// Check if one path is a parent of the other
|
||||
if (path1.startsWith(path2)) {
|
||||
log.warn(
|
||||
"Watched folder path '{}' is nested inside '{}' - this may cause"
|
||||
+ " duplicate processing",
|
||||
"Watched folder path '{}' is nested inside '{}' - this may cause duplicate processing",
|
||||
path1,
|
||||
path2);
|
||||
} else if (path2.startsWith(path1)) {
|
||||
log.warn(
|
||||
"Watched folder path '{}' is nested inside '{}' - this may cause"
|
||||
+ " duplicate processing",
|
||||
"Watched folder path '{}' is nested inside '{}' - this may cause duplicate processing",
|
||||
path2,
|
||||
path1);
|
||||
}
|
||||
@@ -255,24 +253,21 @@ public class RuntimePathConfig {
|
||||
// Check if watched folder is same as finished folder
|
||||
if (watchedPath.equals(finishedPath)) {
|
||||
log.error(
|
||||
"CRITICAL: Watched folder '{}' is the same as finished folder '{}' -"
|
||||
+ " this will cause processing loops!",
|
||||
"CRITICAL: Watched folder '{}' is the same as finished folder '{}' - this will cause processing loops!",
|
||||
watchedPath,
|
||||
finishedPath);
|
||||
}
|
||||
// Check if watched folder contains finished folder
|
||||
else if (finishedPath.startsWith(watchedPath)) {
|
||||
log.warn(
|
||||
"Finished folder '{}' is nested inside watched folder '{}' - this may"
|
||||
+ " cause issues",
|
||||
"Finished folder '{}' is nested inside watched folder '{}' - this may cause issues",
|
||||
finishedPath,
|
||||
watchedPath);
|
||||
}
|
||||
// Check if finished folder contains watched folder
|
||||
else if (watchedPath.startsWith(finishedPath)) {
|
||||
log.error(
|
||||
"CRITICAL: Watched folder '{}' is nested inside finished folder '{}' -"
|
||||
+ " this will cause processing loops!",
|
||||
"CRITICAL: Watched folder '{}' is nested inside finished folder '{}' - this will cause processing loops!",
|
||||
watchedPath,
|
||||
finishedPath);
|
||||
}
|
||||
@@ -300,17 +295,15 @@ public class RuntimePathConfig {
|
||||
// Warn if manual endpoint count doesn't match sessionLimit
|
||||
if (configured.size() != sessionLimit) {
|
||||
log.warn(
|
||||
"Manual UNO endpoint count ({}) differs from libreOfficeSessionLimit"
|
||||
+ " ({}). Concurrency will be limited by endpoint count, not"
|
||||
+ " sessionLimit.",
|
||||
"Manual UNO endpoint count ({}) differs from libreOfficeSessionLimit ({}). "
|
||||
+ "Concurrency will be limited by endpoint count, not sessionLimit.",
|
||||
configured.size(),
|
||||
sessionLimit);
|
||||
}
|
||||
return configured;
|
||||
}
|
||||
log.warn(
|
||||
"autoUnoServer disabled but no unoServerEndpoints configured; defaulting to"
|
||||
+ " 127.0.0.1:2003.");
|
||||
"autoUnoServer disabled but no unoServerEndpoints configured; defaulting to 127.0.0.1:2003.");
|
||||
return Collections.singletonList(
|
||||
new ApplicationProperties.ProcessExecutor.UnoServerEndpoint());
|
||||
}
|
||||
|
||||
@@ -144,8 +144,7 @@ public class ApplicationProperties {
|
||||
sizeInMB);
|
||||
} else {
|
||||
log.warn(
|
||||
"SYSTEM_MAXFILESIZE value {} is out of valid range (1-999),"
|
||||
+ " ignoring",
|
||||
"SYSTEM_MAXFILESIZE value {} is out of valid range (1-999), ignoring",
|
||||
sizeInMB);
|
||||
}
|
||||
} catch (NumberFormatException e) {
|
||||
|
||||
@@ -24,8 +24,7 @@ public class PDFFile {
|
||||
|
||||
@Schema(
|
||||
description =
|
||||
"File ID for server-side files (can be used instead of fileInput if job was"
|
||||
+ " previously done on file in async mode)")
|
||||
"File ID for server-side files (can be used instead of fileInput if job was previously done on file in async mode)")
|
||||
private String fileId;
|
||||
|
||||
@AssertTrue(message = "Either fileInput or fileId must be provided")
|
||||
|
||||
+361
-18
@@ -1,26 +1,89 @@
|
||||
package stirling.software.common.service;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.OffsetDateTime;
|
||||
import java.time.ZoneId;
|
||||
import java.time.ZonedDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.time.format.DateTimeParseException;
|
||||
import java.util.Calendar;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.apache.pdfbox.pdmodel.PDDocumentCatalog;
|
||||
import org.apache.pdfbox.pdmodel.PDDocumentInformation;
|
||||
import org.apache.pdfbox.pdmodel.common.PDMetadata;
|
||||
import org.apache.pdfbox.util.DateConverter;
|
||||
import org.apache.xmpbox.XMPMetadata;
|
||||
import org.apache.xmpbox.schema.AdobePDFSchema;
|
||||
import org.apache.xmpbox.schema.DublinCoreSchema;
|
||||
import org.apache.xmpbox.schema.XMPBasicSchema;
|
||||
import org.apache.xmpbox.schema.XMPMediaManagementSchema;
|
||||
import org.apache.xmpbox.schema.XMPSchema;
|
||||
import org.apache.xmpbox.type.AbstractField;
|
||||
import org.apache.xmpbox.xml.DomXmpParser;
|
||||
import org.apache.xmpbox.xml.XmpParsingException;
|
||||
import org.apache.xmpbox.xml.XmpSerializer;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.beans.factory.annotation.Qualifier;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.common.model.PdfMetadata;
|
||||
|
||||
@Slf4j
|
||||
@Service
|
||||
public class PdfMetadataService {
|
||||
|
||||
/** ({@code {labels}}). Written by the classify-and-label tool. */
|
||||
public static final String CLASSIFICATION_KEY = "StirlingPDFClassification";
|
||||
|
||||
public static final String PDFX_NAMESPACE = "http://ns.adobe.com/pdfx/1.3/";
|
||||
|
||||
private static final Pattern ILLEGAL_XML_NAME_CHARS = Pattern.compile("[^A-Za-z0-9._-]");
|
||||
|
||||
private static final List<DateTimeFormatter> DATE_TIME_FORMATTERS =
|
||||
List.of(
|
||||
DateTimeFormatter.ofPattern("yyyy/MM/dd HH:mm:ss"),
|
||||
DateTimeFormatter.ofPattern("yyyy/MM/dd HH:mm"),
|
||||
DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"),
|
||||
DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm"),
|
||||
DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss"),
|
||||
DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss.SSS"),
|
||||
DateTimeFormatter.ofPattern("d.M.yyyy HH:mm:ss"),
|
||||
DateTimeFormatter.ofPattern("d.M.yyyy HH:mm"),
|
||||
DateTimeFormatter.ofPattern("dd.MM.yyyy HH:mm:ss"),
|
||||
DateTimeFormatter.ofPattern("dd.MM.yyyy HH:mm"),
|
||||
DateTimeFormatter.ofPattern("d/M/yyyy HH:mm:ss"),
|
||||
DateTimeFormatter.ofPattern("d/M/yyyy HH:mm"),
|
||||
DateTimeFormatter.ofPattern("dd/MM/yyyy HH:mm:ss"),
|
||||
DateTimeFormatter.ofPattern("dd/MM/yyyy HH:mm"),
|
||||
DateTimeFormatter.ofPattern("M/d/yyyy HH:mm:ss"),
|
||||
DateTimeFormatter.ofPattern("M/d/yyyy HH:mm"),
|
||||
DateTimeFormatter.ofPattern("MM/dd/yyyy HH:mm:ss"),
|
||||
DateTimeFormatter.ofPattern("MM/dd/yyyy HH:mm"));
|
||||
|
||||
private static final List<DateTimeFormatter> DATE_ONLY_FORMATTERS =
|
||||
List.of(
|
||||
DateTimeFormatter.ofPattern("yyyy/MM/dd"),
|
||||
DateTimeFormatter.ofPattern("yyyy-MM-dd"),
|
||||
DateTimeFormatter.ofPattern("d.M.yyyy"),
|
||||
DateTimeFormatter.ofPattern("dd.MM.yyyy"),
|
||||
DateTimeFormatter.ofPattern("d/M/yyyy"),
|
||||
DateTimeFormatter.ofPattern("dd/MM/yyyy"),
|
||||
DateTimeFormatter.ofPattern("M/d/yyyy"),
|
||||
DateTimeFormatter.ofPattern("MM/dd/yyyy"));
|
||||
|
||||
private final ApplicationProperties applicationProperties;
|
||||
private final String stirlingPDFLabel;
|
||||
private final UserServiceInterface userService;
|
||||
@@ -38,10 +101,10 @@ public class PdfMetadataService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts ZonedDateTime to Calendar for PDFBox compatibility.
|
||||
* Converts a {@link ZonedDateTime} to a {@link Calendar} for PDFBox compatibility.
|
||||
*
|
||||
* @param zonedDateTime the ZonedDateTime to convert
|
||||
* @return Calendar instance or null if input is null
|
||||
* @param zonedDateTime the date-time to convert, or null
|
||||
* @return Calendar representation, or null if input is null
|
||||
*/
|
||||
public static Calendar toCalendar(ZonedDateTime zonedDateTime) {
|
||||
if (zonedDateTime == null) {
|
||||
@@ -69,23 +132,66 @@ public class PdfMetadataService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses a date string and converts it to Calendar for PDFBox compatibility.
|
||||
* Parses a date string into a {@link Calendar} supporting ISO-8601, PDF internal date format
|
||||
* ("D:YYYYMMDD..."), and common localized date and date-time patterns.
|
||||
*
|
||||
* @param dateString the date string in "yyyy/MM/dd HH:mm:ss" format
|
||||
* @return Calendar instance or null if parsing fails or input is empty
|
||||
* @param dateString raw date string
|
||||
* @return parsed Calendar, or null if input is null, blank, or cannot be parsed
|
||||
*/
|
||||
public static Calendar parseToCalendar(String dateString) {
|
||||
if (dateString == null || dateString.trim().isEmpty()) {
|
||||
if (dateString == null) {
|
||||
return null;
|
||||
}
|
||||
String trimmed = dateString.trim();
|
||||
if (trimmed.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (trimmed.startsWith("D:")) {
|
||||
Calendar cal = DateConverter.toCalendar(trimmed);
|
||||
if (cal != null) {
|
||||
return cal;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
return toCalendar(ZonedDateTime.parse(trimmed));
|
||||
} catch (DateTimeParseException ignored) {
|
||||
}
|
||||
try {
|
||||
DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy/MM/dd HH:mm:ss");
|
||||
ZonedDateTime zonedDateTime =
|
||||
LocalDateTime.parse(dateString, formatter).atZone(ZoneId.systemDefault());
|
||||
return toCalendar(zonedDateTime);
|
||||
} catch (Exception e) {
|
||||
return null;
|
||||
return toCalendar(OffsetDateTime.parse(trimmed).toZonedDateTime());
|
||||
} catch (DateTimeParseException ignored) {
|
||||
}
|
||||
try {
|
||||
return toCalendar(Instant.parse(trimmed).atZone(ZoneId.systemDefault()));
|
||||
} catch (DateTimeParseException ignored) {
|
||||
}
|
||||
|
||||
for (DateTimeFormatter dtf : DATE_TIME_FORMATTERS) {
|
||||
try {
|
||||
LocalDateTime ldt = LocalDateTime.parse(trimmed, dtf);
|
||||
return toCalendar(ldt.atZone(ZoneId.systemDefault()));
|
||||
} catch (DateTimeParseException ignored) {
|
||||
}
|
||||
}
|
||||
|
||||
for (DateTimeFormatter df : DATE_ONLY_FORMATTERS) {
|
||||
try {
|
||||
LocalDate ld = LocalDate.parse(trimmed, df);
|
||||
return toCalendar(ld.atStartOfDay(ZoneId.systemDefault()));
|
||||
} catch (DateTimeParseException ignored) {
|
||||
}
|
||||
}
|
||||
|
||||
if (!trimmed.startsWith("D:")) {
|
||||
Calendar cal = DateConverter.toCalendar(trimmed);
|
||||
if (cal != null) {
|
||||
return cal;
|
||||
}
|
||||
}
|
||||
|
||||
log.debug("Unparseable date string: '{}'", trimmed);
|
||||
return null;
|
||||
}
|
||||
|
||||
public PdfMetadata extractMetadataFromPdf(PDDocument pdf) {
|
||||
@@ -136,7 +242,6 @@ public class PdfMetadataService {
|
||||
|
||||
pdf.getDocumentInformation().setCreator(creator);
|
||||
|
||||
// Use existing creation date if available, otherwise create new one
|
||||
Calendar creationCal =
|
||||
pdfMetadata.getCreationDate() != null
|
||||
? toCalendar(pdfMetadata.getCreationDate())
|
||||
@@ -151,7 +256,6 @@ public class PdfMetadataService {
|
||||
pdf.getDocumentInformation().setSubject(pdfMetadata.getSubject());
|
||||
pdf.getDocumentInformation().setKeywords(pdfMetadata.getKeywords());
|
||||
|
||||
// Convert ZonedDateTime to Calendar for PDFBox compatibility
|
||||
Calendar modificationCal =
|
||||
pdfMetadata.getModificationDate() != null
|
||||
? toCalendar(pdfMetadata.getModificationDate())
|
||||
@@ -183,12 +287,251 @@ public class PdfMetadataService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Write the document classifier's JSON result into the custom Info-dictionary field {@link
|
||||
* #CLASSIFICATION_KEY}, leaving all other metadata untouched.
|
||||
* Writes document classification JSON into the custom Info-dictionary field {@link
|
||||
* #CLASSIFICATION_KEY}.
|
||||
*
|
||||
* @param pdf document to update
|
||||
* @param classificationJson classifier result JSON
|
||||
*/
|
||||
public void setClassificationMetadata(PDDocument pdf, String classificationJson) {
|
||||
PDDocumentInformation info = pdf.getDocumentInformation();
|
||||
info.setCustomMetadataValue(CLASSIFICATION_KEY, classificationJson);
|
||||
pdf.setDocumentInformation(info);
|
||||
}
|
||||
|
||||
/**
|
||||
* Synchronizes standard metadata fields and custom metadata from {@link PDDocumentInformation}
|
||||
* into the document catalog's XMP metadata stream (/Catalog /Metadata).
|
||||
*
|
||||
* <p>Windows Explorer, Adobe Acrobat, and PDF/A validators prioritize the XMP stream over the
|
||||
* legacy /Info dictionary (ISO 32000-1 §14.3.3). This method synchronizes Dublin Core, XMP
|
||||
* Basic, Adobe PDF, XMP Media Management (updating InstanceID), and custom metadata (stored in
|
||||
* the {@value #PDFX_NAMESPACE} schema following Adobe Acrobat convention).
|
||||
*
|
||||
* @param document the PDF document to synchronize
|
||||
* @param customMetadata custom metadata key-value pairs (or null if custom metadata should not
|
||||
* be modified)
|
||||
* @throws IOException if XMP serialization or parsing fails
|
||||
*/
|
||||
public void synchronizeXmpMetadata(PDDocument document, Map<String, String> customMetadata)
|
||||
throws IOException {
|
||||
PDDocumentCatalog catalog = document.getDocumentCatalog();
|
||||
if (catalog == null) {
|
||||
return;
|
||||
}
|
||||
PDDocumentInformation info = document.getDocumentInformation();
|
||||
if (info == null) {
|
||||
info = new PDDocumentInformation();
|
||||
document.setDocumentInformation(info);
|
||||
}
|
||||
|
||||
PDMetadata existingPdMetadata = catalog.getMetadata();
|
||||
XMPMetadata xmp = null;
|
||||
if (existingPdMetadata != null) {
|
||||
try (InputStream is = existingPdMetadata.createInputStream()) {
|
||||
DomXmpParser parser = new DomXmpParser();
|
||||
parser.setStrictParsing(false);
|
||||
xmp = parser.parse(is);
|
||||
} catch (XmpParsingException e) {
|
||||
log.debug(
|
||||
"Failed to parse existing XMP metadata, initializing fresh XMP: {}",
|
||||
e.getMessage());
|
||||
}
|
||||
}
|
||||
if (xmp == null) {
|
||||
xmp = XMPMetadata.createXMPMetadata();
|
||||
}
|
||||
|
||||
DublinCoreSchema dc = xmp.getDublinCoreSchema();
|
||||
if (dc == null) {
|
||||
dc = xmp.createAndAddDublinCoreSchema();
|
||||
}
|
||||
String title = info.getTitle();
|
||||
AbstractField tp = dc.getProperty("title");
|
||||
if (tp != null) {
|
||||
dc.removeProperty(tp);
|
||||
}
|
||||
if (title != null && !title.isBlank()) {
|
||||
dc.setTitle(title);
|
||||
}
|
||||
|
||||
String author = info.getAuthor();
|
||||
List<String> existingCreators = dc.getCreators();
|
||||
if (existingCreators != null) {
|
||||
for (String c : List.copyOf(existingCreators)) {
|
||||
dc.removeCreator(c);
|
||||
}
|
||||
}
|
||||
if (author != null && !author.isBlank()) {
|
||||
dc.addCreator(author);
|
||||
}
|
||||
|
||||
String subject = info.getSubject();
|
||||
AbstractField descProp = dc.getProperty("description");
|
||||
if (descProp != null) {
|
||||
dc.removeProperty(descProp);
|
||||
}
|
||||
if (subject != null && !subject.isBlank()) {
|
||||
dc.setDescription(subject);
|
||||
}
|
||||
|
||||
String keywords = info.getKeywords();
|
||||
List<String> existingSubjects = dc.getSubjects();
|
||||
if (existingSubjects != null) {
|
||||
for (String s : List.copyOf(existingSubjects)) {
|
||||
dc.removeSubject(s);
|
||||
}
|
||||
}
|
||||
if (keywords != null && !keywords.isBlank()) {
|
||||
for (String kw : keywords.split("[,;]")) {
|
||||
String trimmed = kw.trim();
|
||||
if (!trimmed.isEmpty()) {
|
||||
dc.addSubject(trimmed);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
XMPBasicSchema basic = xmp.getXMPBasicSchema();
|
||||
if (basic == null) {
|
||||
basic = xmp.createAndAddXMPBasicSchema();
|
||||
}
|
||||
Calendar creationDate = info.getCreationDate();
|
||||
if (creationDate != null) {
|
||||
basic.setCreateDate(creationDate);
|
||||
} else {
|
||||
AbstractField cd = basic.getProperty("CreateDate");
|
||||
if (cd != null) {
|
||||
basic.removeProperty(cd);
|
||||
}
|
||||
}
|
||||
|
||||
Calendar modificationDate = info.getModificationDate();
|
||||
if (modificationDate != null) {
|
||||
basic.setModifyDate(modificationDate);
|
||||
} else {
|
||||
AbstractField md = basic.getProperty("ModifyDate");
|
||||
if (md != null) {
|
||||
basic.removeProperty(md);
|
||||
}
|
||||
}
|
||||
// MetadataDate records when the metadata itself was last modified per ISO 16684-1
|
||||
basic.setMetadataDate(Calendar.getInstance());
|
||||
|
||||
String creator = info.getCreator();
|
||||
if (creator != null && !creator.isBlank()) {
|
||||
basic.setCreatorTool(creator);
|
||||
} else {
|
||||
AbstractField ct = basic.getProperty("CreatorTool");
|
||||
if (ct != null) {
|
||||
basic.removeProperty(ct);
|
||||
}
|
||||
}
|
||||
|
||||
XMPMediaManagementSchema mm = xmp.getXMPMediaManagementSchema();
|
||||
if (mm == null) {
|
||||
mm = xmp.createAndAddXMPMediaManagementSchema();
|
||||
}
|
||||
if (mm.getDocumentID() == null) {
|
||||
mm.setDocumentID("uuid:" + UUID.randomUUID());
|
||||
}
|
||||
mm.setInstanceID("uuid:" + UUID.randomUUID());
|
||||
|
||||
AdobePDFSchema adobePdf = xmp.getAdobePDFSchema();
|
||||
if (adobePdf == null) {
|
||||
adobePdf = xmp.createAndAddAdobePDFSchema();
|
||||
}
|
||||
String producer = info.getProducer();
|
||||
if (producer != null && !producer.isBlank()) {
|
||||
adobePdf.setProducer(producer);
|
||||
} else {
|
||||
AbstractField p = adobePdf.getProperty("Producer");
|
||||
if (p != null) {
|
||||
adobePdf.removeProperty(p);
|
||||
}
|
||||
}
|
||||
if (keywords != null && !keywords.isBlank()) {
|
||||
adobePdf.setKeywords(keywords);
|
||||
} else {
|
||||
AbstractField k = adobePdf.getProperty("Keywords");
|
||||
if (k != null) {
|
||||
adobePdf.removeProperty(k);
|
||||
}
|
||||
}
|
||||
|
||||
String trapped = info.getTrapped();
|
||||
String normalizedTrapped = null;
|
||||
if ("true".equalsIgnoreCase(trapped)) {
|
||||
normalizedTrapped = "True";
|
||||
} else if ("false".equalsIgnoreCase(trapped)) {
|
||||
normalizedTrapped = "False";
|
||||
}
|
||||
if (normalizedTrapped != null) {
|
||||
adobePdf.setTextPropertyValueAsSimple("Trapped", normalizedTrapped);
|
||||
} else {
|
||||
AbstractField t = adobePdf.getProperty("Trapped");
|
||||
if (t != null) {
|
||||
adobePdf.removeProperty(t);
|
||||
}
|
||||
}
|
||||
|
||||
// Adobe Acrobat convention places custom document properties into
|
||||
// http://ns.adobe.com/pdfx/1.3/
|
||||
if (customMetadata != null) {
|
||||
XMPSchema pdfx = xmp.getSchema(PDFX_NAMESPACE);
|
||||
if (pdfx == null && !customMetadata.isEmpty()) {
|
||||
pdfx = new XMPSchema(xmp, PDFX_NAMESPACE, "pdfx");
|
||||
xmp.addSchema(pdfx);
|
||||
}
|
||||
if (pdfx != null) {
|
||||
// Remove deleted custom properties, preserving standard PDF/X properties (e.g.
|
||||
// GTS_PDFXVersion)
|
||||
for (AbstractField prop : List.copyOf(pdfx.getAllProperties())) {
|
||||
String propName = prop.getPropertyName();
|
||||
if (propName != null && !propName.startsWith("GTS_")) {
|
||||
boolean retained =
|
||||
customMetadata.keySet().stream()
|
||||
.anyMatch(
|
||||
k ->
|
||||
sanitizeXmlPropertyName(k.trim())
|
||||
.equalsIgnoreCase(propName));
|
||||
if (!retained) {
|
||||
pdfx.removeProperty(prop);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (Map.Entry<String, String> entry : customMetadata.entrySet()) {
|
||||
String rawKey = entry.getKey();
|
||||
String val = entry.getValue();
|
||||
if (rawKey != null && !rawKey.trim().isEmpty() && val != null) {
|
||||
String cleanKey = sanitizeXmlPropertyName(rawKey.trim());
|
||||
pdfx.setTextPropertyValueAsSimple(cleanKey, val);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
try {
|
||||
// withXpacket = true writes the <?xpacket ... ?> processing instructions required by
|
||||
// ISO 32000-1 §14.3.2
|
||||
new XmpSerializer().serialize(xmp, baos, true);
|
||||
} catch (Exception e) {
|
||||
throw new IOException("Failed to serialize XMP metadata", e);
|
||||
}
|
||||
PDMetadata pdMetadata = new PDMetadata(document);
|
||||
pdMetadata.importXMPMetadata(baos.toByteArray());
|
||||
catalog.setMetadata(pdMetadata);
|
||||
}
|
||||
|
||||
private static String sanitizeXmlPropertyName(String key) {
|
||||
String cleaned = ILLEGAL_XML_NAME_CHARS.matcher(key).replaceAll("_");
|
||||
if (cleaned.isEmpty()
|
||||
|| (!Character.isLetter(cleaned.charAt(0)) && cleaned.charAt(0) != '_')) {
|
||||
cleaned = "_" + cleaned;
|
||||
}
|
||||
if (cleaned.toLowerCase(Locale.ROOT).startsWith("xml")) {
|
||||
cleaned = "_" + cleaned;
|
||||
}
|
||||
return cleaned;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -209,8 +209,7 @@ public class ResourceMonitor {
|
||||
return (double) m.invoke(osMXBean);
|
||||
} catch (Exception e2) {
|
||||
log.trace(
|
||||
"Could not get CPU load through reflection, assuming moderate load"
|
||||
+ " (0.5)");
|
||||
"Could not get CPU load through reflection, assuming moderate load (0.5)");
|
||||
return 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
+2
-4
@@ -167,8 +167,7 @@ public class TempFileCleanupService {
|
||||
|| unregisteredDeletedCount > 0
|
||||
|| directoriesDeletedCount > 0) {
|
||||
log.info(
|
||||
"Scheduled cleanup complete. Deleted {} registered files, {} unregistered"
|
||||
+ " files, {} directories",
|
||||
"Scheduled cleanup complete. Deleted {} registered files, {} unregistered files, {} directories",
|
||||
registeredDeletedCount,
|
||||
unregisteredDeletedCount,
|
||||
directoriesDeletedCount);
|
||||
@@ -253,8 +252,7 @@ public class TempFileCleanupService {
|
||||
dirDeletedCount.incrementAndGet();
|
||||
if (log.isDebugEnabled()) {
|
||||
log.debug(
|
||||
"Deleted temp file during {} cleanup:"
|
||||
+ " {}",
|
||||
"Deleted temp file during {} cleanup: {}",
|
||||
phase,
|
||||
path);
|
||||
}
|
||||
|
||||
@@ -41,8 +41,7 @@ public class AttachmentUtils {
|
||||
viewerPrefs.setBoolean(COSName.getPDFName("DisplayDocTitle"), true);
|
||||
|
||||
log.info(
|
||||
"Set PDF PageMode to UseAttachments to automatically show attachments"
|
||||
+ " pane");
|
||||
"Set PDF PageMode to UseAttachments to automatically show attachments pane");
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to set catalog viewer preferences for attachments", e);
|
||||
|
||||
@@ -342,26 +342,26 @@ public class EmlProcessingUtils {
|
||||
|
||||
private String getFallbackStyles() {
|
||||
return """
|
||||
/* Minimal fallback - main CSS resource failed to load */
|
||||
body {
|
||||
font-family: var(--font-family, Helvetica, sans-serif);
|
||||
font-size: var(--font-size, 12px);
|
||||
line-height: var(--line-height, 1.4);
|
||||
color: var(--text-color, #202124);
|
||||
margin: 0;
|
||||
padding: 20px;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
.email-container { max-width: 100%; }
|
||||
.email-header { border-bottom: 1px solid #ccc; margin-bottom: 16px; padding-bottom: 12px; }
|
||||
.email-header h1 { margin: 0 0 8px 0; font-size: 18px; }
|
||||
.email-meta { font-size: 12px; color: #666; }
|
||||
.email-body { line-height: 1.6; }
|
||||
.attachment-section { margin-top: 20px; padding: 12px; background: #f5f5f5; border-radius: 4px; }
|
||||
.attachment-item { padding: 6px 0; border-bottom: 1px solid #ddd; }
|
||||
.no-content { padding: 20px; text-align: center; color: #888; font-style: italic; }
|
||||
img { max-width: 100%; height: auto; }
|
||||
""";
|
||||
/* Minimal fallback - main CSS resource failed to load */
|
||||
body {
|
||||
font-family: var(--font-family, Helvetica, sans-serif);
|
||||
font-size: var(--font-size, 12px);
|
||||
line-height: var(--line-height, 1.4);
|
||||
color: var(--text-color, #202124);
|
||||
margin: 0;
|
||||
padding: 20px;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
.email-container { max-width: 100%; }
|
||||
.email-header { border-bottom: 1px solid #ccc; margin-bottom: 16px; padding-bottom: 12px; }
|
||||
.email-header h1 { margin: 0 0 8px 0; font-size: 18px; }
|
||||
.email-meta { font-size: 12px; color: #666; }
|
||||
.email-body { line-height: 1.6; }
|
||||
.attachment-section { margin-top: 20px; padding: 12px; background: #f5f5f5; border-radius: 4px; }
|
||||
.attachment-item { padding: 6px 0; border-bottom: 1px solid #ddd; }
|
||||
.no-content { padding: 20px; text-align: center; color: #888; font-style: italic; }
|
||||
img { max-width: 100%; height: auto; }
|
||||
""";
|
||||
}
|
||||
|
||||
private void appendAttachmentsSection(
|
||||
|
||||
@@ -290,8 +290,7 @@ public class ExceptionUtils {
|
||||
// Additional safety check: warn about very large images (> 1GB estimated)
|
||||
if (estimatedBytes > 1024L * 1024 * 1024) {
|
||||
log.warn(
|
||||
"Page {} will create a very large image: {}x{} pixels (~{} MB) at {} DPI. This"
|
||||
+ " may cause memory issues.",
|
||||
"Page {} will create a very large image: {}x{} pixels (~{} MB) at {} DPI. This may cause memory issues.",
|
||||
pageNumber,
|
||||
widthInPixels,
|
||||
heightInPixels,
|
||||
@@ -395,8 +394,7 @@ public class ExceptionUtils {
|
||||
message = getMessage(contextKey, defaultMsg, context);
|
||||
} else {
|
||||
message =
|
||||
"PDF file appears to be corrupted or damaged. Please try using the 'Repair PDF'"
|
||||
+ " feature first to fix the file before proceeding with this operation.";
|
||||
"PDF file appears to be corrupted or damaged. Please try using the 'Repair PDF' feature first to fix the file before proceeding with this operation.";
|
||||
}
|
||||
|
||||
return new PdfCorruptedException(message, cause, ErrorCode.PDF_CORRUPTED.getCode());
|
||||
@@ -1121,25 +1119,19 @@ public class ExceptionUtils {
|
||||
PDF_CORRUPTED(
|
||||
"E001",
|
||||
"error.pdfCorrupted",
|
||||
"PDF file appears to be corrupted or damaged. Please try using the 'Repair PDF'"
|
||||
+ " feature first to fix the file before proceeding with this operation."),
|
||||
"PDF file appears to be corrupted or damaged. Please try using the 'Repair PDF' feature first to fix the file before proceeding with this operation."),
|
||||
PDF_MULTIPLE_CORRUPTED(
|
||||
"E002",
|
||||
"error.multiplePdfCorrupted",
|
||||
"One or more PDF files appear to be corrupted or damaged. Please try using the"
|
||||
+ " 'Repair PDF' feature on each file first before attempting to merge them."),
|
||||
"One or more PDF files appear to be corrupted or damaged. Please try using the 'Repair PDF' feature on each file first before attempting to merge them."),
|
||||
PDF_ENCRYPTION(
|
||||
"E003",
|
||||
"error.pdfEncryption",
|
||||
"The PDF appears to have corrupted encryption data. This can happen when the PDF"
|
||||
+ " was created with incompatible encryption methods. Please try using the"
|
||||
+ " 'Repair PDF' feature first, or contact the document creator for a new"
|
||||
+ " copy."),
|
||||
"The PDF appears to have corrupted encryption data. This can happen when the PDF was created with incompatible encryption methods. Please try using the 'Repair PDF' feature first, or contact the document creator for a new copy."),
|
||||
PDF_PASSWORD(
|
||||
"E004",
|
||||
"error.pdfPassword",
|
||||
"The PDF Document is passworded and either the password was not provided or was"
|
||||
+ " incorrect"),
|
||||
"The PDF Document is passworded and either the password was not provided or was incorrect"),
|
||||
PDF_NO_PAGES("E005", "error.pdfNoPages", "PDF file contains no pages"),
|
||||
PDF_NOT_PDF("E006", "error.notPdfFile", "File must be in PDF format"),
|
||||
|
||||
@@ -1147,25 +1139,20 @@ public class ExceptionUtils {
|
||||
CBR_INVALID_FORMAT(
|
||||
"E010",
|
||||
"error.cbrInvalidFormat",
|
||||
"Invalid or corrupted CBR/RAR archive. The file may be corrupted, use an"
|
||||
+ " unsupported RAR format (RAR5+), encrypted, or may not be a valid RAR"
|
||||
+ " archive."),
|
||||
"Invalid or corrupted CBR/RAR archive. The file may be corrupted, use an unsupported RAR format (RAR5+), encrypted, or may not be a valid RAR archive."),
|
||||
CBR_NO_IMAGES(
|
||||
"E012",
|
||||
"error.cbrNoImages",
|
||||
"No valid images found in the CBR file. The archive may be empty, or all images may"
|
||||
+ " be corrupted or in unsupported formats."),
|
||||
"No valid images found in the CBR file. The archive may be empty, or all images may be corrupted or in unsupported formats."),
|
||||
CBR_NOT_CBR("E014", "error.notCbrFile", "File must be a CBR or RAR archive"),
|
||||
CBZ_INVALID_FORMAT(
|
||||
"E015",
|
||||
"error.cbzInvalidFormat",
|
||||
"Invalid or corrupted CBZ/ZIP archive. The file may be empty, corrupted, or may not"
|
||||
+ " be a valid ZIP archive."),
|
||||
"Invalid or corrupted CBZ/ZIP archive. The file may be empty, corrupted, or may not be a valid ZIP archive."),
|
||||
CBZ_NO_IMAGES(
|
||||
"E016",
|
||||
"error.cbzNoImages",
|
||||
"No valid images found in the CBZ file. The archive may be empty, or all images may"
|
||||
+ " be corrupted or in unsupported formats."),
|
||||
"No valid images found in the CBZ file. The archive may be empty, or all images may be corrupted or in unsupported formats."),
|
||||
CBZ_NOT_CBZ("E018", "error.notCbzFile", "File must be a CBZ or ZIP archive"),
|
||||
|
||||
// EML errors
|
||||
@@ -1218,8 +1205,7 @@ public class ExceptionUtils {
|
||||
FFMPEG_REQUIRED(
|
||||
"E063",
|
||||
"error.ffmpegRequired",
|
||||
"FFmpeg must be installed to convert PDFs to video. Install FFmpeg and ensure it is"
|
||||
+ " available on the system PATH."),
|
||||
"FFmpeg must be installed to convert PDFs to video. Install FFmpeg and ensure it is available on the system PATH."),
|
||||
|
||||
// Validation errors
|
||||
INVALID_ARGUMENT("E070", "error.invalidArgument", "Invalid argument ''{0}'': {1}"),
|
||||
@@ -1235,10 +1221,7 @@ public class ExceptionUtils {
|
||||
OUT_OF_MEMORY_DPI(
|
||||
"E081",
|
||||
"error.outOfMemoryDpi",
|
||||
"Out of memory or image-too-large error while rendering PDF page {0} at {1} DPI."
|
||||
+ " This can occur when the resulting image exceeds Java's array/memory limits"
|
||||
+ " (e.g., NegativeArraySizeException). Please use a lower DPI value"
|
||||
+ " (recommended: 150 or less) or process the document in smaller chunks.");
|
||||
"Out of memory or image-too-large error while rendering PDF page {0} at {1} DPI. This can occur when the resulting image exceeds Java's array/memory limits (e.g., NegativeArraySizeException). Please use a lower DPI value (recommended: 150 or less) or process the document in smaller chunks.");
|
||||
|
||||
private final String code;
|
||||
private final String messageKey;
|
||||
|
||||
@@ -456,8 +456,7 @@ public class FormUtils {
|
||||
|| !Float.isFinite(finalW)
|
||||
|| !Float.isFinite(finalH)) {
|
||||
log.warn(
|
||||
"Widget coordinates out of bounds for field '{}': page={}, x={}, y={}, w={},"
|
||||
+ " h={}",
|
||||
"Widget coordinates are not finite for field '{}': page={}, x={}, y={}, w={}, h={}",
|
||||
field.getFullyQualifiedName(),
|
||||
pageIndex,
|
||||
finalX,
|
||||
|
||||
@@ -392,9 +392,9 @@ public class PdfUtils {
|
||||
&& e.getMessage().contains("Maximum size of image exceeded")) {
|
||||
throw ExceptionUtils.createIllegalArgumentException(
|
||||
"error.pageTooBigFor300Dpi",
|
||||
"PDF page {0} is too large to render at 300 DPI. The resulting"
|
||||
+ " image would exceed Java's maximum array size. Please use a"
|
||||
+ " lower DPI value for PDF-to-image conversion.",
|
||||
"PDF page {0} is too large to render at 300 DPI. The resulting image"
|
||||
+ " would exceed Java's maximum array size. Please use a lower DPI"
|
||||
+ " value for PDF-to-image conversion.",
|
||||
pageIndex + 1);
|
||||
}
|
||||
throw e;
|
||||
|
||||
@@ -253,8 +253,7 @@ public class ProcessExecutor {
|
||||
}
|
||||
} catch (InterruptedIOException e) {
|
||||
log.warn(
|
||||
"Error reader thread was interrupted due to"
|
||||
+ " timeout.");
|
||||
"Error reader thread was interrupted due to timeout.");
|
||||
} catch (IOException e) {
|
||||
log.error("exception", e);
|
||||
}
|
||||
@@ -279,8 +278,7 @@ public class ProcessExecutor {
|
||||
}
|
||||
} catch (InterruptedIOException e) {
|
||||
log.warn(
|
||||
"Error reader thread was interrupted due to"
|
||||
+ " timeout.");
|
||||
"Error reader thread was interrupted due to timeout.");
|
||||
} catch (IOException e) {
|
||||
log.error("exception", e);
|
||||
}
|
||||
|
||||
+1
-2
@@ -15,8 +15,7 @@ public class StringToMapPropertyEditor extends PropertyEditorSupport {
|
||||
@Override
|
||||
public void setAsText(String text) throws IllegalArgumentException {
|
||||
try {
|
||||
TypeReference<HashMap<String, String>> typeRef =
|
||||
new TypeReference<HashMap<String, String>>() {};
|
||||
TypeReference<HashMap<String, String>> typeRef = new TypeReference<>() {};
|
||||
Map<String, String> map = objectMapper.readValue(text, typeRef);
|
||||
setValue(map);
|
||||
} catch (Exception e) {
|
||||
|
||||
+1
-2
@@ -237,7 +237,6 @@ class ApplicationPropertiesLogicTest {
|
||||
|
||||
assertTrue(
|
||||
oauth2.isValid(oneBlank, "scopes"),
|
||||
"Dokumentiert aktuelles Verhalten: nicht-leere Liste gilt als gültig, auch wenn"
|
||||
+ " Element leer/blank ist");
|
||||
"Dokumentiert aktuelles Verhalten: nicht-leere Liste gilt als gültig, auch wenn Element leer/blank ist");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -130,8 +130,7 @@ class PdfMarkdownConverterTest {
|
||||
if (similarity < THRESHOLD) {
|
||||
fail(
|
||||
String.format(
|
||||
"Markdown output differs from golden file '%s' by %.1f%% (threshold"
|
||||
+ " %.0f%%):%n%s",
|
||||
"Markdown output differs from golden file '%s' by %.1f%% (threshold %.0f%%):%n%s",
|
||||
mdName,
|
||||
(1.0 - similarity) * 100,
|
||||
(1.0 - THRESHOLD) * 100,
|
||||
|
||||
+193
-1
@@ -7,14 +7,27 @@ 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.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.ZoneId;
|
||||
import java.time.ZonedDateTime;
|
||||
import java.util.Calendar;
|
||||
import java.util.Map;
|
||||
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.apache.pdfbox.pdmodel.PDDocumentInformation;
|
||||
import org.apache.pdfbox.pdmodel.PDPage;
|
||||
import org.apache.pdfbox.pdmodel.common.PDMetadata;
|
||||
import org.apache.xmpbox.XMPMetadata;
|
||||
import org.apache.xmpbox.schema.AdobePDFSchema;
|
||||
import org.apache.xmpbox.schema.DublinCoreSchema;
|
||||
import org.apache.xmpbox.schema.XMPBasicSchema;
|
||||
import org.apache.xmpbox.schema.XMPSchema;
|
||||
import org.apache.xmpbox.xml.DomXmpParser;
|
||||
import org.apache.xmpbox.xml.XmpSerializer;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Nested;
|
||||
import org.junit.jupiter.api.Test;
|
||||
@@ -78,7 +91,7 @@ class PdfMetadataServiceTest {
|
||||
@DisplayName("returns null for unparsable input")
|
||||
void invalidReturnsNull() {
|
||||
assertNull(PdfMetadataService.parseToCalendar("not a date"));
|
||||
assertNull(PdfMetadataService.parseToCalendar("2021-06-15"));
|
||||
assertNull(PdfMetadataService.parseToCalendar("abcd-ef-gh"));
|
||||
assertNull(PdfMetadataService.parseToCalendar("2021/13/40 99:99:99"));
|
||||
}
|
||||
|
||||
@@ -97,6 +110,54 @@ class PdfMetadataServiceTest {
|
||||
.toEpochMilli();
|
||||
assertEquals(expectedMillis, cal.getTimeInMillis());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("parses diverse date formats including 1.1.2025 and ISO")
|
||||
void parsesDiverseDateFormats() {
|
||||
Calendar dotCal = PdfMetadataService.parseToCalendar("1.1.2025");
|
||||
assertNotNull(dotCal);
|
||||
long expectedDot =
|
||||
LocalDate.of(2025, 1, 1)
|
||||
.atStartOfDay(ZoneId.systemDefault())
|
||||
.toInstant()
|
||||
.toEpochMilli();
|
||||
assertEquals(expectedDot, dotCal.getTimeInMillis());
|
||||
|
||||
Calendar dashCal = PdfMetadataService.parseToCalendar("2021-06-15");
|
||||
assertNotNull(dashCal);
|
||||
long expectedDash =
|
||||
LocalDate.of(2021, 6, 15)
|
||||
.atStartOfDay(ZoneId.systemDefault())
|
||||
.toInstant()
|
||||
.toEpochMilli();
|
||||
assertEquals(expectedDash, dashCal.getTimeInMillis());
|
||||
|
||||
Calendar slashCal = PdfMetadataService.parseToCalendar("2025/01/01");
|
||||
assertNotNull(slashCal);
|
||||
long expectedSlash =
|
||||
LocalDate.of(2025, 1, 1)
|
||||
.atStartOfDay(ZoneId.systemDefault())
|
||||
.toInstant()
|
||||
.toEpochMilli();
|
||||
assertEquals(expectedSlash, slashCal.getTimeInMillis());
|
||||
|
||||
Calendar dashTimeCal = PdfMetadataService.parseToCalendar("2025-01-01 14:30:00");
|
||||
assertNotNull(dashTimeCal);
|
||||
long expectedDashTime =
|
||||
LocalDateTime.of(2025, 1, 1, 14, 30, 0)
|
||||
.atZone(ZoneId.systemDefault())
|
||||
.toInstant()
|
||||
.toEpochMilli();
|
||||
assertEquals(expectedDashTime, dashTimeCal.getTimeInMillis());
|
||||
|
||||
Calendar isoCal = PdfMetadataService.parseToCalendar("2025-01-01T12:00:00Z");
|
||||
assertNotNull(isoCal);
|
||||
assertEquals(
|
||||
Instant.parse("2025-01-01T12:00:00Z").toEpochMilli(), isoCal.getTimeInMillis());
|
||||
|
||||
Calendar pdfCal = PdfMetadataService.parseToCalendar("D:20250101120000");
|
||||
assertNotNull(pdfCal);
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@@ -413,4 +474,135 @@ class PdfMetadataServiceTest {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("synchronizeXmpMetadata(PDDocument, Map)")
|
||||
class SynchronizeXmpMetadataTests {
|
||||
|
||||
@Test
|
||||
@DisplayName("synchronizes all standard and custom fields to XMP stream")
|
||||
void synchronizesStandardAndCustomFields() throws Exception {
|
||||
PdfMetadataService service = nonProService(null);
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
doc.addPage(new PDPage());
|
||||
PDDocumentInformation info = doc.getDocumentInformation();
|
||||
info.setTitle("XMP Test Title");
|
||||
info.setAuthor("XMP Test Author");
|
||||
info.setSubject("XMP Test Subject");
|
||||
info.setKeywords("tag1, tag2, tag3");
|
||||
info.setCreator("XMP Test Creator");
|
||||
info.setProducer("XMP Test Producer");
|
||||
info.setTrapped("True");
|
||||
|
||||
Calendar creation = Calendar.getInstance();
|
||||
creation.setTimeInMillis(1_700_000_000_000L);
|
||||
Calendar modification = Calendar.getInstance();
|
||||
modification.setTimeInMillis(1_710_000_000_000L);
|
||||
info.setCreationDate(creation);
|
||||
info.setModificationDate(modification);
|
||||
|
||||
Map<String, String> customMetadata =
|
||||
Map.of(
|
||||
"Department", "Engineering",
|
||||
"Project-Code", "Apollo-11");
|
||||
|
||||
service.synchronizeXmpMetadata(doc, customMetadata);
|
||||
|
||||
PDMetadata pdMetadata = doc.getDocumentCatalog().getMetadata();
|
||||
assertNotNull(pdMetadata);
|
||||
|
||||
DomXmpParser parser = new DomXmpParser();
|
||||
parser.setStrictParsing(false);
|
||||
XMPMetadata xmp = parser.parse(new ByteArrayInputStream(pdMetadata.toByteArray()));
|
||||
assertNotNull(xmp);
|
||||
|
||||
DublinCoreSchema dc = xmp.getDublinCoreSchema();
|
||||
assertNotNull(dc);
|
||||
assertEquals("XMP Test Title", dc.getTitle());
|
||||
assertNotNull(dc.getCreators());
|
||||
assertEquals("XMP Test Author", dc.getCreators().get(0));
|
||||
assertEquals("XMP Test Subject", dc.getDescription());
|
||||
assertNotNull(dc.getSubjects());
|
||||
assertEquals(3, dc.getSubjects().size());
|
||||
|
||||
XMPBasicSchema basic = xmp.getXMPBasicSchema();
|
||||
assertNotNull(basic);
|
||||
assertEquals("XMP Test Creator", basic.getCreatorTool());
|
||||
assertNotNull(basic.getCreateDate());
|
||||
assertEquals(1_700_000_000_000L, basic.getCreateDate().getTimeInMillis());
|
||||
assertNotNull(basic.getModifyDate());
|
||||
assertEquals(1_710_000_000_000L, basic.getModifyDate().getTimeInMillis());
|
||||
|
||||
AdobePDFSchema pdfSchema = xmp.getAdobePDFSchema();
|
||||
assertNotNull(pdfSchema);
|
||||
assertEquals("XMP Test Producer", pdfSchema.getProducer());
|
||||
assertEquals("tag1, tag2, tag3", pdfSchema.getKeywords());
|
||||
|
||||
XMPSchema pdfx = xmp.getSchema(PdfMetadataService.PDFX_NAMESPACE);
|
||||
assertNotNull(pdfx);
|
||||
assertEquals("Engineering", pdfx.getUnqualifiedTextPropertyValue("Department"));
|
||||
assertEquals("Apollo-11", pdfx.getUnqualifiedTextPropertyValue("Project-Code"));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("removes deleted custom fields on subsequent synchronization")
|
||||
void removesDeletedCustomFields() throws Exception {
|
||||
PdfMetadataService service = nonProService(null);
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
doc.addPage(new PDPage());
|
||||
|
||||
service.synchronizeXmpMetadata(doc, Map.of("Field1", "Val1", "Field2", "Val2"));
|
||||
service.synchronizeXmpMetadata(doc, Map.of("Field2", "Val2Updated"));
|
||||
|
||||
PDMetadata pdMetadata = doc.getDocumentCatalog().getMetadata();
|
||||
DomXmpParser parser = new DomXmpParser();
|
||||
parser.setStrictParsing(false);
|
||||
XMPMetadata xmp = parser.parse(new ByteArrayInputStream(pdMetadata.toByteArray()));
|
||||
|
||||
XMPSchema pdfx = xmp.getSchema(PdfMetadataService.PDFX_NAMESPACE);
|
||||
assertNotNull(pdfx);
|
||||
assertNull(pdfx.getUnqualifiedTextPropertyValue("Field1"));
|
||||
assertEquals("Val2Updated", pdfx.getUnqualifiedTextPropertyValue("Field2"));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName(
|
||||
"preserves standard PDF/X properties like GTS_PDFXVersion during custom metadata synchronization")
|
||||
void preservesStandardPdfXProperties() throws Exception {
|
||||
PdfMetadataService service = nonProService(null);
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
doc.addPage(new PDPage());
|
||||
|
||||
XMPMetadata initialXmp = XMPMetadata.createXMPMetadata();
|
||||
XMPSchema pdfxInitial =
|
||||
new XMPSchema(initialXmp, PdfMetadataService.PDFX_NAMESPACE, "pdfx");
|
||||
pdfxInitial.setTextPropertyValueAsSimple("GTS_PDFXVersion", "PDF/X-1:2001");
|
||||
pdfxInitial.setTextPropertyValueAsSimple("OldCustom", "OldValue");
|
||||
initialXmp.addSchema(pdfxInitial);
|
||||
|
||||
ByteArrayOutputStream xmpBaos = new ByteArrayOutputStream();
|
||||
new XmpSerializer().serialize(initialXmp, xmpBaos, true);
|
||||
PDMetadata pdMetadata = new PDMetadata(doc);
|
||||
pdMetadata.importXMPMetadata(xmpBaos.toByteArray());
|
||||
doc.getDocumentCatalog().setMetadata(pdMetadata);
|
||||
|
||||
service.synchronizeXmpMetadata(doc, Map.of("NewCustom", "NewValue"));
|
||||
|
||||
PDMetadata updatedMetadata = doc.getDocumentCatalog().getMetadata();
|
||||
DomXmpParser parser = new DomXmpParser();
|
||||
parser.setStrictParsing(false);
|
||||
XMPMetadata xmp =
|
||||
parser.parse(new ByteArrayInputStream(updatedMetadata.toByteArray()));
|
||||
|
||||
XMPSchema pdfx = xmp.getSchema(PdfMetadataService.PDFX_NAMESPACE);
|
||||
assertNotNull(pdfx);
|
||||
assertEquals(
|
||||
"PDF/X-1:2001", pdfx.getUnqualifiedTextPropertyValue("GTS_PDFXVersion"));
|
||||
assertEquals("NewValue", pdfx.getUnqualifiedTextPropertyValue("NewCustom"));
|
||||
assertNull(pdfx.getUnqualifiedTextPropertyValue("OldCustom"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+11
-11
@@ -60,10 +60,10 @@ class CustomHtmlSanitizerTest {
|
||||
new String[] {"<p>", "<strong>", "<em>"}),
|
||||
Arguments.of(
|
||||
"<p>Text with <b>bold</b>, <i>italic</i>, <u>underline</u>,"
|
||||
+ " <em>emphasis</em>, <strong>strong</strong>,"
|
||||
+ " <strike>strikethrough</strike>, <s>strike</s>,"
|
||||
+ " <sub>subscript</sub>, <sup>superscript</sup>, <tt>teletype</tt>,"
|
||||
+ " <code>code</code>, <big>big</big>, <small>small</small>.</p>",
|
||||
+ " <em>emphasis</em>, <strong>strong</strong>,"
|
||||
+ " <strike>strikethrough</strike>, <s>strike</s>,"
|
||||
+ " <sub>subscript</sub>, <sup>superscript</sup>, <tt>teletype</tt>,"
|
||||
+ " <code>code</code>, <big>big</big>, <small>small</small>.</p>",
|
||||
new String[] {
|
||||
"<b>bold</b>",
|
||||
"<i>italic</i>",
|
||||
@@ -271,8 +271,8 @@ class CustomHtmlSanitizerTest {
|
||||
// Arrange
|
||||
String htmlWithObjects =
|
||||
"<p>Safe content</p><object data=\"data.swf\""
|
||||
+ " type=\"application/x-shockwave-flash\"></object><embed src=\"embed.swf\""
|
||||
+ " type=\"application/x-shockwave-flash\">";
|
||||
+ " type=\"application/x-shockwave-flash\"></object><embed src=\"embed.swf\""
|
||||
+ " type=\"application/x-shockwave-flash\">";
|
||||
|
||||
// Act
|
||||
String sanitizedHtml = customHtmlSanitizer.sanitize(htmlWithObjects);
|
||||
@@ -309,11 +309,11 @@ class CustomHtmlSanitizerTest {
|
||||
// Arrange
|
||||
String complexHtml =
|
||||
"<div class=\"container\"> <h1 style=\"color: blue;\">Welcome</h1> <p>This is a"
|
||||
+ " <strong>test</strong> with <a href=\"https://example.com\">link</a>.</p> "
|
||||
+ " <table> <tr><th>Name</th><th>Value</th></tr> <tr><td>Item"
|
||||
+ " 1</td><td>100</td></tr> </table> <img src=\"image.jpg\" alt=\"Test"
|
||||
+ " image\"> <script>alert('XSS');</script> <iframe"
|
||||
+ " src=\"https://evil.com\"></iframe></div>";
|
||||
+ " <strong>test</strong> with <a href=\"https://example.com\">link</a>.</p> "
|
||||
+ " <table> <tr><th>Name</th><th>Value</th></tr> <tr><td>Item"
|
||||
+ " 1</td><td>100</td></tr> </table> <img src=\"image.jpg\" alt=\"Test"
|
||||
+ " image\"> <script>alert('XSS');</script> <iframe"
|
||||
+ " src=\"https://evil.com\"></iframe></div>";
|
||||
|
||||
// Act
|
||||
String sanitizedHtml = customHtmlSanitizer.sanitize(complexHtml);
|
||||
|
||||
@@ -120,10 +120,10 @@ class EmlToPdfTest {
|
||||
void parseHtmlEmailWithStyling() throws IOException {
|
||||
String htmlBody =
|
||||
"<html><head><style>.header{color:blue;font-weight:bold;}"
|
||||
+ ".content{margin:10px;}.footer{font-size:12px;}</style></head><body><div"
|
||||
+ " class=\"header\">Important Notice</div><div class=\"content\">This is"
|
||||
+ " <strong>HTML content</strong> with styling.</div><div"
|
||||
+ " class=\"footer\">Best regards</div></body></html>";
|
||||
+ ".content{margin:10px;}.footer{font-size:12px;}</style></head>"
|
||||
+ "<body><div class=\"header\">Important Notice</div>"
|
||||
+ "<div class=\"content\">This is <strong>HTML content</strong> with styling.</div>"
|
||||
+ "<div class=\"footer\">Best regards</div></body></html>";
|
||||
|
||||
String emlContent =
|
||||
createHtmlEmail(
|
||||
@@ -286,13 +286,11 @@ class EmlToPdfTest {
|
||||
@DisplayName("Should handle complex nested HTML structures")
|
||||
void handleComplexNestedHtml() throws IOException {
|
||||
String complexHtml =
|
||||
"<html><head><title>Complex Email</title></head><body><div"
|
||||
+ " class=\"container\"><header><h1>Email"
|
||||
+ " Header</h1></header><main><section><p>Paragraph with <a"
|
||||
+ " href=\"https://example.com\">link</a></p><ul><li>List item"
|
||||
+ " 1</li><li>List item 2 with"
|
||||
+ " <em>emphasis</em></li></ul><table><tr><td>Cell 1</td><td>Cell"
|
||||
+ " 2</td></tr><tr><td>Cell 3</td><td>Cell 4</td></tr>"
|
||||
"<html><head><title>Complex Email</title></head><body>"
|
||||
+ "<div class=\"container\"><header><h1>Email Header</h1></header><main><section>"
|
||||
+ "<p>Paragraph with <a href=\"https://example.com\">link</a></p><ul>"
|
||||
+ "<li>List item 1</li><li>List item 2 with <em>emphasis</em></li></ul><table>"
|
||||
+ "<tr><td>Cell 1</td><td>Cell 2</td></tr><tr><td>Cell 3</td><td>Cell 4</td></tr>"
|
||||
+ "</table></section></main></div></body></html>";
|
||||
|
||||
String emlContent =
|
||||
@@ -348,8 +346,7 @@ class EmlToPdfTest {
|
||||
This line breaks header format
|
||||
Content-Type: text/plain
|
||||
|
||||
Body content\
|
||||
""";
|
||||
Body content""";
|
||||
|
||||
byte[] emlBytes = malformedEml.getBytes(StandardCharsets.UTF_8);
|
||||
EmlToPdfRequest request = createBasicRequest();
|
||||
@@ -784,13 +781,7 @@ class EmlToPdfTest {
|
||||
String from, String to, String subject, String body, String charset) {
|
||||
return String.format(
|
||||
Locale.ROOT,
|
||||
"From: %s\n"
|
||||
+ "To: %s\n"
|
||||
+ "Subject: %s\n"
|
||||
+ "Date: %s\n"
|
||||
+ "Content-Type: text/plain; charset=%s\n"
|
||||
+ "Content-Transfer-Encoding: 8bit\n\n"
|
||||
+ "%s",
|
||||
"From: %s\nTo: %s\nSubject: %s\nDate: %s\nContent-Type: text/plain; charset=%s\nContent-Transfer-Encoding: 8bit\n\n%s",
|
||||
from,
|
||||
to,
|
||||
subject,
|
||||
@@ -802,11 +793,7 @@ class EmlToPdfTest {
|
||||
private String createEmailWithCustomHeaders() {
|
||||
return String.format(
|
||||
Locale.ROOT,
|
||||
"From: sender@example.com\n"
|
||||
+ "Date: %s\n"
|
||||
+ "Content-Type: text/plain; charset=UTF-8\n"
|
||||
+ "Content-Transfer-Encoding: 8bit\n\n"
|
||||
+ "%s",
|
||||
"From: sender@example.com\nDate: %s\nContent-Type: text/plain; charset=UTF-8\nContent-Transfer-Encoding: 8bit\n\n%s",
|
||||
getTimestamp(),
|
||||
"This is an email body with some headers missing.");
|
||||
}
|
||||
@@ -814,13 +801,7 @@ class EmlToPdfTest {
|
||||
private String createHtmlEmail(String from, String to, String subject, String htmlBody) {
|
||||
return String.format(
|
||||
Locale.ROOT,
|
||||
"From: %s\n"
|
||||
+ "To: %s\n"
|
||||
+ "Subject: %s\n"
|
||||
+ "Date: %s\n"
|
||||
+ "Content-Type: text/html; charset=UTF-8\n"
|
||||
+ "Content-Transfer-Encoding: 8bit\n\n"
|
||||
+ "%s",
|
||||
"From: %s\nTo: %s\nSubject: %s\nDate: %s\nContent-Type: text/html; charset=UTF-8\nContent-Transfer-Encoding: 8bit\n\n%s",
|
||||
from,
|
||||
to,
|
||||
subject,
|
||||
@@ -842,27 +823,26 @@ class EmlToPdfTest {
|
||||
return String.format(
|
||||
Locale.ROOT,
|
||||
"""
|
||||
From: %s
|
||||
To: %s
|
||||
Subject: %s
|
||||
Date: %s
|
||||
Content-Type: multipart/mixed; boundary="%s"
|
||||
From: %s
|
||||
To: %s
|
||||
Subject: %s
|
||||
Date: %s
|
||||
Content-Type: multipart/mixed; boundary="%s"
|
||||
|
||||
--%s
|
||||
Content-Type: text/plain; charset=UTF-8
|
||||
Content-Transfer-Encoding: 8bit
|
||||
--%s
|
||||
Content-Type: text/plain; charset=UTF-8
|
||||
Content-Transfer-Encoding: 8bit
|
||||
|
||||
%s
|
||||
%s
|
||||
|
||||
--%s
|
||||
Content-Type: text/plain; charset=UTF-8
|
||||
Content-Disposition: attachment; filename="%s"
|
||||
Content-Transfer-Encoding: base64
|
||||
--%s
|
||||
Content-Type: text/plain; charset=UTF-8
|
||||
Content-Disposition: attachment; filename="%s"
|
||||
Content-Transfer-Encoding: base64
|
||||
|
||||
%s
|
||||
%s
|
||||
|
||||
--%s--\
|
||||
""",
|
||||
--%s--""",
|
||||
from,
|
||||
to,
|
||||
subject,
|
||||
@@ -883,27 +863,26 @@ class EmlToPdfTest {
|
||||
return String.format(
|
||||
Locale.ROOT,
|
||||
"""
|
||||
From: %s
|
||||
To: %s
|
||||
Subject: %s
|
||||
Date: %s
|
||||
Content-Type: multipart/mixed; boundary="%s"
|
||||
From: %s
|
||||
To: %s
|
||||
Subject: %s
|
||||
Date: %s
|
||||
Content-Type: multipart/mixed; boundary="%s"
|
||||
|
||||
--%s
|
||||
Content-Type: text/plain; charset=UTF-8
|
||||
Content-Transfer-Encoding: 8bit
|
||||
--%s
|
||||
Content-Type: text/plain; charset=UTF-8
|
||||
Content-Transfer-Encoding: 8bit
|
||||
|
||||
%s
|
||||
%s
|
||||
|
||||
--%s
|
||||
Content-Type: message/rfc822; name="%s"
|
||||
Content-Disposition: attachment; filename="%s"
|
||||
Content-Transfer-Encoding: base64
|
||||
--%s
|
||||
Content-Type: message/rfc822; name="%s"
|
||||
Content-Disposition: attachment; filename="%s"
|
||||
Content-Transfer-Encoding: base64
|
||||
|
||||
%s
|
||||
%s
|
||||
|
||||
--%s--\
|
||||
""",
|
||||
--%s--""",
|
||||
"outer@example.com",
|
||||
"outer_recipient@example.com",
|
||||
"Fwd: Inner Email Subject",
|
||||
@@ -923,27 +902,26 @@ class EmlToPdfTest {
|
||||
return String.format(
|
||||
Locale.ROOT,
|
||||
"""
|
||||
From: %s
|
||||
To: %s
|
||||
Subject: %s
|
||||
Date: %s
|
||||
MIME-Version: 1.0
|
||||
Content-Type: multipart/alternative; boundary="%s"
|
||||
From: %s
|
||||
To: %s
|
||||
Subject: %s
|
||||
Date: %s
|
||||
MIME-Version: 1.0
|
||||
Content-Type: multipart/alternative; boundary="%s"
|
||||
|
||||
--%s
|
||||
Content-Type: text/plain; charset=UTF-8
|
||||
Content-Transfer-Encoding: 7bit
|
||||
--%s
|
||||
Content-Type: text/plain; charset=UTF-8
|
||||
Content-Transfer-Encoding: 7bit
|
||||
|
||||
%s
|
||||
%s
|
||||
|
||||
--%s
|
||||
Content-Type: text/html; charset=UTF-8
|
||||
Content-Transfer-Encoding: 7bit
|
||||
--%s
|
||||
Content-Type: text/html; charset=UTF-8
|
||||
Content-Transfer-Encoding: 7bit
|
||||
|
||||
%s
|
||||
%s
|
||||
|
||||
--%s--\
|
||||
""",
|
||||
--%s--""",
|
||||
"sender@example.com",
|
||||
"receiver@example.com",
|
||||
"Multipart/Alternative Test",
|
||||
@@ -959,14 +937,7 @@ class EmlToPdfTest {
|
||||
private String createQuotedPrintableEmail() {
|
||||
return String.format(
|
||||
Locale.ROOT,
|
||||
"From: %s\n"
|
||||
+ "To: %s\n"
|
||||
+ "Subject: %s\n"
|
||||
+ "Date: %s\n"
|
||||
+ "MIME-Version: 1.0\n"
|
||||
+ "Content-Type: text/plain; charset=UTF-8\n"
|
||||
+ "Content-Transfer-Encoding: quoted-printable\n\n"
|
||||
+ "%s",
|
||||
"From: %s\nTo: %s\nSubject: %s\nDate: %s\nMIME-Version: 1.0\nContent-Type: text/plain; charset=UTF-8\nContent-Transfer-Encoding: quoted-printable\n\n%s",
|
||||
"sender@example.com",
|
||||
"recipient@example.com",
|
||||
"Quoted-Printable Test",
|
||||
@@ -979,14 +950,7 @@ class EmlToPdfTest {
|
||||
Base64.getEncoder().encodeToString(body.getBytes(StandardCharsets.UTF_8));
|
||||
return String.format(
|
||||
Locale.ROOT,
|
||||
"From: %s\n"
|
||||
+ "To: %s\n"
|
||||
+ "Subject: %s\n"
|
||||
+ "Date: %s\n"
|
||||
+ "MIME-Version: 1.0\n"
|
||||
+ "Content-Type: text/plain; charset=UTF-8\n"
|
||||
+ "Content-Transfer-Encoding: base64\n\n"
|
||||
+ "%s",
|
||||
"From: %s\nTo: %s\nSubject: %s\nDate: %s\nMIME-Version: 1.0\nContent-Type: text/plain; charset=UTF-8\nContent-Transfer-Encoding: base64\n\n%s",
|
||||
"sender@example.com",
|
||||
"recipient@example.com",
|
||||
"Base64 Test",
|
||||
@@ -999,28 +963,27 @@ class EmlToPdfTest {
|
||||
return String.format(
|
||||
Locale.ROOT,
|
||||
"""
|
||||
From: %s
|
||||
To: %s
|
||||
Subject: %s
|
||||
Date: %s
|
||||
Content-Type: multipart/related; boundary="%s"
|
||||
From: %s
|
||||
To: %s
|
||||
Subject: %s
|
||||
Date: %s
|
||||
Content-Type: multipart/related; boundary="%s"
|
||||
|
||||
--%s
|
||||
Content-Type: text/html; charset=UTF-8
|
||||
Content-Transfer-Encoding: 8bit
|
||||
--%s
|
||||
Content-Type: text/html; charset=UTF-8
|
||||
Content-Transfer-Encoding: 8bit
|
||||
|
||||
%s
|
||||
%s
|
||||
|
||||
--%s
|
||||
Content-Type: image/png
|
||||
Content-Transfer-Encoding: base64
|
||||
Content-ID: <%s>
|
||||
Content-Disposition: inline; filename="image.png"
|
||||
--%s
|
||||
Content-Type: image/png
|
||||
Content-Transfer-Encoding: base64
|
||||
Content-ID: <%s>
|
||||
Content-Disposition: inline; filename="image.png"
|
||||
|
||||
%s
|
||||
%s
|
||||
|
||||
--%s--\
|
||||
""",
|
||||
--%s--""",
|
||||
"sender@example.com",
|
||||
"receiver@example.com",
|
||||
"Inline Image Test",
|
||||
@@ -1045,40 +1008,39 @@ class EmlToPdfTest {
|
||||
return String.format(
|
||||
Locale.ROOT,
|
||||
"""
|
||||
From: %s
|
||||
To: %s
|
||||
Subject: %s
|
||||
Date: %s
|
||||
Content-Type: multipart/mixed; boundary="%s"
|
||||
From: %s
|
||||
To: %s
|
||||
Subject: %s
|
||||
Date: %s
|
||||
Content-Type: multipart/mixed; boundary="%s"
|
||||
|
||||
--%s
|
||||
Content-Type: multipart/related; boundary="related-%s"
|
||||
--%s
|
||||
Content-Type: multipart/related; boundary="related-%s"
|
||||
|
||||
--related-%s
|
||||
Content-Type: text/html; charset=UTF-8
|
||||
Content-Transfer-Encoding: 8bit
|
||||
--related-%s
|
||||
Content-Type: text/html; charset=UTF-8
|
||||
Content-Transfer-Encoding: 8bit
|
||||
|
||||
%s
|
||||
%s
|
||||
|
||||
--related-%s
|
||||
Content-Type: image/png
|
||||
Content-Transfer-Encoding: base64
|
||||
Content-ID: <%s>
|
||||
Content-Disposition: inline; filename="image.png"
|
||||
--related-%s
|
||||
Content-Type: image/png
|
||||
Content-Transfer-Encoding: base64
|
||||
Content-ID: <%s>
|
||||
Content-Disposition: inline; filename="image.png"
|
||||
|
||||
%s
|
||||
%s
|
||||
|
||||
--related-%s--
|
||||
--related-%s--
|
||||
|
||||
--%s
|
||||
Content-Type: text/plain; charset=UTF-8
|
||||
Content-Disposition: attachment; filename="%s"
|
||||
Content-Transfer-Encoding: base64
|
||||
--%s
|
||||
Content-Type: text/plain; charset=UTF-8
|
||||
Content-Disposition: attachment; filename="%s"
|
||||
Content-Transfer-Encoding: base64
|
||||
|
||||
%s
|
||||
%s
|
||||
|
||||
--%s--\
|
||||
""",
|
||||
--%s--""",
|
||||
"sender@example.com",
|
||||
"receiver@example.com",
|
||||
"Mixed Attachments Test",
|
||||
|
||||
+24
-27
@@ -31,22 +31,21 @@ class OfficeDocumentSanitizerTest {
|
||||
private static final String INTERNAL_TARGET = "media/image1.png";
|
||||
|
||||
private static final String DOCX_RELS =
|
||||
"<?xml version=\"1.0\" encoding=\"UTF-8\"?><Relationships"
|
||||
+ " xmlns=\"http://schemas.openxmlformats.org/package/2006/relationships\"><Relationship"
|
||||
+ " Id=\"rId1\""
|
||||
+ " Type=\"http://schemas.openxmlformats.org/officeDocument/2006/relationships/image\""
|
||||
+ " Target=\""
|
||||
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>"
|
||||
+ "<Relationships xmlns=\"http://schemas.openxmlformats.org/package/2006/relationships\">"
|
||||
+ "<Relationship Id=\"rId1\" Type=\"http://schemas.openxmlformats.org/officeDocument/2006/relationships/image\""
|
||||
+ " Target=\""
|
||||
+ EXTERNAL_URL
|
||||
+ "\" TargetMode=\"External\"/><Relationship Id=\"rId2\""
|
||||
+ " Type=\"http://schemas.openxmlformats.org/officeDocument/2006/relationships/image\""
|
||||
+ "\" TargetMode=\"External\"/>"
|
||||
+ "<Relationship Id=\"rId2\" Type=\"http://schemas.openxmlformats.org/officeDocument/2006/relationships/image\""
|
||||
+ " Target=\""
|
||||
+ INTERNAL_TARGET
|
||||
+ "\"/>"
|
||||
+ "</Relationships>";
|
||||
|
||||
private static final String DOCX_DOCUMENT =
|
||||
"<?xml version=\"1.0\" encoding=\"UTF-8\"?><w:document"
|
||||
+ " xmlns:w=\"http://schemas.openxmlformats.org/wordprocessingml/2006/main\">"
|
||||
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>"
|
||||
+ "<w:document xmlns:w=\"http://schemas.openxmlformats.org/wordprocessingml/2006/main\">"
|
||||
+ "<w:body><w:p/></w:body></w:document>";
|
||||
|
||||
private static final String ODF_CONTENT_EXTERNAL =
|
||||
@@ -58,8 +57,8 @@ class OfficeDocumentSanitizerTest {
|
||||
+ "<office:body><office:text>"
|
||||
+ "<draw:frame><draw:image xlink:href=\""
|
||||
+ EXTERNAL_URL
|
||||
+ "\" xlink:type=\"simple\"/></draw:frame><draw:frame><draw:image"
|
||||
+ " xlink:href=\"Pictures/image1.png\" xlink:type=\"simple\"/></draw:frame>"
|
||||
+ "\" xlink:type=\"simple\"/></draw:frame>"
|
||||
+ "<draw:frame><draw:image xlink:href=\"Pictures/image1.png\" xlink:type=\"simple\"/></draw:frame>"
|
||||
+ "</office:text></office:body></office:document-content>";
|
||||
|
||||
private SsrfProtectionService ssrfProtectionService;
|
||||
@@ -114,11 +113,10 @@ class OfficeDocumentSanitizerTest {
|
||||
@Test
|
||||
void sanitize_pptxExternalImageRelStripped() throws IOException {
|
||||
String pptxRels =
|
||||
"<?xml version=\"1.0\" encoding=\"UTF-8\"?><Relationships"
|
||||
+ " xmlns=\"http://schemas.openxmlformats.org/package/2006/relationships\"><Relationship"
|
||||
+ " Id=\"rId1\""
|
||||
+ " Type=\"http://schemas.openxmlformats.org/officeDocument/2006/relationships/image\""
|
||||
+ " Target=\""
|
||||
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>"
|
||||
+ "<Relationships xmlns=\"http://schemas.openxmlformats.org/package/2006/relationships\">"
|
||||
+ "<Relationship Id=\"rId1\" Type=\"http://schemas.openxmlformats.org/officeDocument/2006/relationships/image\""
|
||||
+ " Target=\""
|
||||
+ EXTERNAL_URL
|
||||
+ "\" TargetMode=\"External\"/>"
|
||||
+ "</Relationships>";
|
||||
@@ -137,11 +135,10 @@ class OfficeDocumentSanitizerTest {
|
||||
@Test
|
||||
void sanitize_xlsxExternalImageRelStripped() throws IOException {
|
||||
String xlsxRels =
|
||||
"<?xml version=\"1.0\" encoding=\"UTF-8\"?><Relationships"
|
||||
+ " xmlns=\"http://schemas.openxmlformats.org/package/2006/relationships\"><Relationship"
|
||||
+ " Id=\"rId1\""
|
||||
+ " Type=\"http://schemas.openxmlformats.org/officeDocument/2006/relationships/image\""
|
||||
+ " Target=\""
|
||||
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>"
|
||||
+ "<Relationships xmlns=\"http://schemas.openxmlformats.org/package/2006/relationships\">"
|
||||
+ "<Relationship Id=\"rId1\" Type=\"http://schemas.openxmlformats.org/officeDocument/2006/relationships/image\""
|
||||
+ " Target=\""
|
||||
+ EXTERNAL_URL
|
||||
+ "\" TargetMode=\"External\"/>"
|
||||
+ "</Relationships>";
|
||||
@@ -165,7 +162,7 @@ class OfficeDocumentSanitizerTest {
|
||||
entries.put("content.xml", ODF_CONTENT_EXTERNAL.getBytes(StandardCharsets.UTF_8));
|
||||
String manifestXml =
|
||||
"<?xml version=\"1.0\"?><manifest:manifest"
|
||||
+ " xmlns:manifest=\"urn:oasis:names:tc:opendocument:xmlns:manifest:1.0\"/>";
|
||||
+ " xmlns:manifest=\"urn:oasis:names:tc:opendocument:xmlns:manifest:1.0\"/>";
|
||||
entries.put("META-INF/manifest.xml", manifestXml.getBytes(StandardCharsets.UTF_8));
|
||||
byte[] odt = zip(entries);
|
||||
|
||||
@@ -297,11 +294,11 @@ class OfficeDocumentSanitizerTest {
|
||||
@Test
|
||||
void sanitize_internalLinksKeptWhenNoExternalPresent() throws IOException {
|
||||
String internalOnlyRels =
|
||||
"<?xml version=\"1.0\" encoding=\"UTF-8\"?><Relationships"
|
||||
+ " xmlns=\"http://schemas.openxmlformats.org/package/2006/relationships\"><Relationship"
|
||||
+ " Id=\"rId1\""
|
||||
+ " Type=\"http://schemas.openxmlformats.org/officeDocument/2006/relationships/image\""
|
||||
+ " Target=\"media/image1.png\"/></Relationships>";
|
||||
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>"
|
||||
+ "<Relationships xmlns=\"http://schemas.openxmlformats.org/package/2006/relationships\">"
|
||||
+ "<Relationship Id=\"rId1\" Type=\"http://schemas.openxmlformats.org/officeDocument/2006/relationships/image\""
|
||||
+ " Target=\"media/image1.png\"/>"
|
||||
+ "</Relationships>";
|
||||
Map<String, byte[]> entries = new LinkedHashMap<>();
|
||||
entries.put(
|
||||
"word/_rels/document.xml.rels", internalOnlyRels.getBytes(StandardCharsets.UTF_8));
|
||||
|
||||
@@ -249,8 +249,7 @@ class ProcessExecutorGapTest {
|
||||
|
||||
@Test
|
||||
@DisplayName(
|
||||
"injects --host/--port after the executable, defaults omit host-location and"
|
||||
+ " protocol")
|
||||
"injects --host/--port after the executable, defaults omit host-location and protocol")
|
||||
void injectsHostAndPortWithDefaults() throws Exception {
|
||||
List<String> command = List.of("unoconvert", "in.docx", "out.pdf");
|
||||
ApplicationProperties.ProcessExecutor.UnoServerEndpoint ep =
|
||||
|
||||
@@ -113,16 +113,6 @@ 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
|
||||
|
||||
@@ -38,8 +38,7 @@ class SvgSanitizerTest {
|
||||
@Test
|
||||
void testSanitize_removesScriptElement() throws IOException {
|
||||
String svg =
|
||||
"<svg xmlns=\"http://www.w3.org/2000/svg\"><script>alert('xss')</script><circle"
|
||||
+ " r=\"10\"/></svg>";
|
||||
"<svg xmlns=\"http://www.w3.org/2000/svg\"><script>alert('xss')</script><circle r=\"10\"/></svg>";
|
||||
byte[] result = sanitizer.sanitize(svg.getBytes(StandardCharsets.UTF_8));
|
||||
String output = new String(result, StandardCharsets.UTF_8);
|
||||
assertFalse(output.contains("script"));
|
||||
@@ -49,8 +48,7 @@ class SvgSanitizerTest {
|
||||
@Test
|
||||
void testSanitize_removesEventHandler() throws IOException {
|
||||
String svg =
|
||||
"<svg xmlns=\"http://www.w3.org/2000/svg\"><circle r=\"10\""
|
||||
+ " onclick=\"alert('xss')\"/></svg>";
|
||||
"<svg xmlns=\"http://www.w3.org/2000/svg\"><circle r=\"10\" onclick=\"alert('xss')\"/></svg>";
|
||||
byte[] result = sanitizer.sanitize(svg.getBytes(StandardCharsets.UTF_8));
|
||||
String output = new String(result, StandardCharsets.UTF_8);
|
||||
assertFalse(output.contains("onclick"));
|
||||
@@ -59,8 +57,7 @@ class SvgSanitizerTest {
|
||||
@Test
|
||||
void testSanitize_removesJavascriptUrl() throws IOException {
|
||||
String svg =
|
||||
"<svg xmlns=\"http://www.w3.org/2000/svg\"><a"
|
||||
+ " href=\"javascript:alert('xss')\"><circle r=\"10\"/></a></svg>";
|
||||
"<svg xmlns=\"http://www.w3.org/2000/svg\"><a href=\"javascript:alert('xss')\"><circle r=\"10\"/></a></svg>";
|
||||
byte[] result = sanitizer.sanitize(svg.getBytes(StandardCharsets.UTF_8));
|
||||
String output = new String(result, StandardCharsets.UTF_8);
|
||||
assertFalse(output.contains("javascript"));
|
||||
@@ -89,8 +86,7 @@ class SvgSanitizerTest {
|
||||
@Test
|
||||
void testSanitize_removesForeignObject() throws IOException {
|
||||
String svg =
|
||||
"<svg xmlns=\"http://www.w3.org/2000/svg\"><foreignObject><body>evil</body></foreignObject><rect"
|
||||
+ " width=\"10\" height=\"10\"/></svg>";
|
||||
"<svg xmlns=\"http://www.w3.org/2000/svg\"><foreignObject><body>evil</body></foreignObject><rect width=\"10\" height=\"10\"/></svg>";
|
||||
byte[] result = sanitizer.sanitize(svg.getBytes(StandardCharsets.UTF_8));
|
||||
String output = new String(result, StandardCharsets.UTF_8);
|
||||
assertFalse(output.toLowerCase().contains("foreignobject"));
|
||||
@@ -117,8 +113,8 @@ class SvgSanitizerTest {
|
||||
void testSanitize_removesRelativeLocalPath() throws IOException {
|
||||
when(ssrfProtectionService.isUrlAllowed(anyString())).thenReturn(false);
|
||||
String svg =
|
||||
"<svg xmlns=\"http://www.w3.org/2000/svg\"><image href=\"../../assets/image.png\""
|
||||
+ " width=\"10\" height=\"10\"/></svg>";
|
||||
"<svg xmlns=\"http://www.w3.org/2000/svg\">"
|
||||
+ "<image href=\"../../assets/image.png\" width=\"10\" height=\"10\"/></svg>";
|
||||
byte[] result = sanitizer.sanitize(svg.getBytes(StandardCharsets.UTF_8));
|
||||
String output = new String(result, StandardCharsets.UTF_8);
|
||||
assertFalse(output.contains("assets/image.png"), "Relative local path must be stripped");
|
||||
|
||||
@@ -106,7 +106,6 @@ SwaggerDoc.json
|
||||
|
||||
# Log file
|
||||
*.log
|
||||
*.log.gz
|
||||
|
||||
# BlueJ files
|
||||
*.ctxt
|
||||
|
||||
+2
-14
@@ -62,16 +62,8 @@ 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'
|
||||
// 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'
|
||||
}
|
||||
implementation 'com.opencsv:opencsv:5.12.0' // https://mvnrepository.com/artifact/com.opencsv/opencsv
|
||||
implementation 'org.apache.poi:poi-ooxml:5.5.1'
|
||||
|
||||
// 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
|
||||
@@ -137,10 +129,6 @@ 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',
|
||||
|
||||
+1
-2
@@ -36,8 +36,7 @@ public class ReplaceAndInvertColorFactory {
|
||||
if (replaceAndInvertOption == ReplaceAndInvert.COLOR_SPACE_CONVERSION
|
||||
&& !endpointConfiguration.isGroupEnabled("Ghostscript")) {
|
||||
throw new IllegalStateException(
|
||||
"CMYK color space conversion requires Ghostscript, which is not available on"
|
||||
+ " this system");
|
||||
"CMYK color space conversion requires Ghostscript, which is not available on this system");
|
||||
}
|
||||
|
||||
return switch (replaceAndInvertOption) {
|
||||
|
||||
+9
-18
@@ -74,8 +74,7 @@ public class GlobalErrorResponseCustomizer implements GlobalOpenApiCustomizer {
|
||||
private ApiResponse create400Response() {
|
||||
return new ApiResponse()
|
||||
.description(
|
||||
"Bad request - Invalid input parameters, unsupported format, or corrupted"
|
||||
+ " file")
|
||||
"Bad request - Invalid input parameters, unsupported format, or corrupted file")
|
||||
.content(
|
||||
new Content()
|
||||
.addMediaType(
|
||||
@@ -84,14 +83,12 @@ public class GlobalErrorResponseCustomizer implements GlobalOpenApiCustomizer {
|
||||
.schema(
|
||||
createErrorSchema(
|
||||
400,
|
||||
"Invalid input parameters or"
|
||||
+ " corrupted file",
|
||||
"Invalid input parameters or corrupted file",
|
||||
"/api/v1/example/endpoint"))
|
||||
.example(
|
||||
createErrorExample(
|
||||
400,
|
||||
"Invalid input parameters or"
|
||||
+ " corrupted file",
|
||||
"Invalid input parameters or corrupted file",
|
||||
"/api/v1/example/endpoint"))));
|
||||
}
|
||||
|
||||
@@ -106,14 +103,12 @@ public class GlobalErrorResponseCustomizer implements GlobalOpenApiCustomizer {
|
||||
.schema(
|
||||
createErrorSchema(
|
||||
413,
|
||||
"File size exceeds maximum allowed"
|
||||
+ " limit",
|
||||
"File size exceeds maximum allowed limit",
|
||||
"/api/v1/example/endpoint"))
|
||||
.example(
|
||||
createErrorExample(
|
||||
413,
|
||||
"File size exceeds maximum allowed"
|
||||
+ " limit",
|
||||
"File size exceeds maximum allowed limit",
|
||||
"/api/v1/example/endpoint"))));
|
||||
}
|
||||
|
||||
@@ -128,14 +123,12 @@ public class GlobalErrorResponseCustomizer implements GlobalOpenApiCustomizer {
|
||||
.schema(
|
||||
createErrorSchema(
|
||||
422,
|
||||
"File is valid but cannot be"
|
||||
+ " processed",
|
||||
"File is valid but cannot be processed",
|
||||
"/api/v1/example/endpoint"))
|
||||
.example(
|
||||
createErrorExample(
|
||||
422,
|
||||
"File is valid but cannot be"
|
||||
+ " processed",
|
||||
"File is valid but cannot be processed",
|
||||
"/api/v1/example/endpoint"))));
|
||||
}
|
||||
|
||||
@@ -150,14 +143,12 @@ public class GlobalErrorResponseCustomizer implements GlobalOpenApiCustomizer {
|
||||
.schema(
|
||||
createErrorSchema(
|
||||
500,
|
||||
"Unexpected error during"
|
||||
+ " processing",
|
||||
"Unexpected error during processing",
|
||||
"/api/v1/example/endpoint"))
|
||||
.example(
|
||||
createErrorExample(
|
||||
500,
|
||||
"Unexpected error during"
|
||||
+ " processing",
|
||||
"Unexpected error during processing",
|
||||
"/api/v1/example/endpoint"))));
|
||||
}
|
||||
|
||||
|
||||
@@ -51,8 +51,7 @@ public class LocaleConfiguration implements WebMvcConfigurer {
|
||||
defaultLocale = tempLocale;
|
||||
} else {
|
||||
System.err.println(
|
||||
"Invalid SYSTEM_DEFAULTLOCALE environment variable value. Falling back"
|
||||
+ " to default en-US.");
|
||||
"Invalid SYSTEM_DEFAULTLOCALE environment variable value. Falling back to default en-US.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,12 +46,7 @@ public class SpringDocConfig {
|
||||
openApi.getInfo()
|
||||
.title("Stirling PDF - Processing API")
|
||||
.description(
|
||||
"APIs for converting, editing, securing, and"
|
||||
+ " analysing PDF documents. Use these"
|
||||
+ " endpoints to automate common PDF tasks"
|
||||
+ " (like split, merge, convert, OCR) and"
|
||||
+ " plug them into your own apps and"
|
||||
+ " backend jobs."));
|
||||
"APIs for converting, editing, securing, and analysing PDF documents. Use these endpoints to automate common PDF tasks (like split, merge, convert, OCR) and plug them into your own apps and backend jobs."));
|
||||
})
|
||||
.build();
|
||||
}
|
||||
@@ -84,9 +79,7 @@ public class SpringDocConfig {
|
||||
openApi.getInfo()
|
||||
.title("Stirling PDF - Management API")
|
||||
.description(
|
||||
"Endpoints for authentication, user management,"
|
||||
+ " invitations, audit logging, and system"
|
||||
+ " configuration."));
|
||||
"Endpoints for authentication, user management, invitations, audit logging, and system configuration."));
|
||||
})
|
||||
.build();
|
||||
}
|
||||
@@ -109,8 +102,7 @@ public class SpringDocConfig {
|
||||
openApi.getInfo()
|
||||
.title("Stirling PDF - System API")
|
||||
.description(
|
||||
"System information, UI metadata, job status,"
|
||||
+ " and file management endpoints."));
|
||||
"System information, UI metadata, job status, and file management endpoints."));
|
||||
})
|
||||
.build();
|
||||
}
|
||||
|
||||
@@ -45,8 +45,7 @@ public class TauriProcessMonitor {
|
||||
startMonitoring();
|
||||
} else {
|
||||
logger.warn(
|
||||
"TAURI_PARENT_PID environment variable not found. Tauri process monitoring"
|
||||
+ " disabled.");
|
||||
"TAURI_PARENT_PID environment variable not found. Tauri process monitoring disabled.");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -75,8 +74,7 @@ public class TauriProcessMonitor {
|
||||
try {
|
||||
if (!isProcessAlive(parentProcessId)) {
|
||||
logger.warn(
|
||||
"Parent Tauri process (PID: {}) is no longer alive. Initiating graceful"
|
||||
+ " shutdown...",
|
||||
"Parent Tauri process (PID: {}) is no longer alive. Initiating graceful shutdown...",
|
||||
parentProcessId);
|
||||
initiateGracefulShutdown();
|
||||
}
|
||||
@@ -120,8 +118,7 @@ public class TauriProcessMonitor {
|
||||
} else {
|
||||
// Fallback to system exit
|
||||
logger.warn(
|
||||
"Unable to shutdown Spring context gracefully, using"
|
||||
+ " System.exit");
|
||||
"Unable to shutdown Spring context gracefully, using System.exit");
|
||||
System.exit(0);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
|
||||
+2
-5
@@ -29,8 +29,7 @@ import io.swagger.v3.oas.annotations.responses.ApiResponses;
|
||||
type = "string",
|
||||
format = "binary",
|
||||
description =
|
||||
"CSV file containing extracted table"
|
||||
+ " data")),
|
||||
"CSV file containing extracted table data")),
|
||||
@Content(
|
||||
mediaType = "application/zip",
|
||||
schema =
|
||||
@@ -38,9 +37,7 @@ import io.swagger.v3.oas.annotations.responses.ApiResponses;
|
||||
type = "string",
|
||||
format = "binary",
|
||||
description =
|
||||
"ZIP archive containing multiple CSV files"
|
||||
+ " when multiple tables are"
|
||||
+ " extracted"))
|
||||
"ZIP archive containing multiple CSV files when multiple tables are extracted"))
|
||||
}),
|
||||
@ApiResponse(
|
||||
responseCode = "400",
|
||||
|
||||
@@ -51,8 +51,7 @@ import io.swagger.v3.oas.annotations.responses.ApiResponses;
|
||||
@ApiResponse(
|
||||
responseCode = "422",
|
||||
description =
|
||||
"Unprocessable entity - PDF is valid but cannot be analyzed for"
|
||||
+ " filtering",
|
||||
"Unprocessable entity - PDF is valid but cannot be analyzed for filtering",
|
||||
content =
|
||||
@Content(
|
||||
mediaType = "application/json",
|
||||
|
||||
@@ -28,8 +28,7 @@ import io.swagger.v3.oas.annotations.responses.ApiResponses;
|
||||
@Schema(
|
||||
type = "object",
|
||||
description =
|
||||
"JSON object containing the requested"
|
||||
+ " data or analysis results"))),
|
||||
"JSON object containing the requested data or analysis results"))),
|
||||
@ApiResponse(
|
||||
responseCode = "400",
|
||||
description = "Invalid PDF file or request parameters",
|
||||
|
||||
@@ -21,8 +21,7 @@ import io.swagger.v3.oas.annotations.responses.ApiResponses;
|
||||
@ApiResponse(
|
||||
responseCode = "200",
|
||||
description =
|
||||
"Files processed successfully. Returns single file or ZIP archive"
|
||||
+ " containing multiple files.",
|
||||
"Files processed successfully. Returns single file or ZIP archive containing multiple files.",
|
||||
content = {
|
||||
@Content(
|
||||
mediaType = "application/pdf",
|
||||
@@ -38,8 +37,7 @@ import io.swagger.v3.oas.annotations.responses.ApiResponses;
|
||||
type = "string",
|
||||
format = "binary",
|
||||
description =
|
||||
"ZIP archive containing multiple output"
|
||||
+ " files")),
|
||||
"ZIP archive containing multiple output files")),
|
||||
@Content(
|
||||
mediaType = "image/png",
|
||||
schema =
|
||||
|
||||
+3
-6
@@ -30,13 +30,11 @@ import io.swagger.v3.oas.annotations.responses.ApiResponses;
|
||||
type = "string",
|
||||
format = "binary",
|
||||
description =
|
||||
"Microsoft PowerPoint presentation"
|
||||
+ " (PPTX)"))),
|
||||
"Microsoft PowerPoint presentation (PPTX)"))),
|
||||
@ApiResponse(
|
||||
responseCode = "400",
|
||||
description =
|
||||
"Bad request - Invalid input parameters, unsupported format, or"
|
||||
+ " corrupted PDF",
|
||||
"Bad request - Invalid input parameters, unsupported format, or corrupted PDF",
|
||||
content =
|
||||
@Content(
|
||||
mediaType = "application/json",
|
||||
@@ -51,8 +49,7 @@ import io.swagger.v3.oas.annotations.responses.ApiResponses;
|
||||
@ApiResponse(
|
||||
responseCode = "422",
|
||||
description =
|
||||
"Unprocessable entity - PDF is valid but cannot be converted to"
|
||||
+ " PowerPoint format",
|
||||
"Unprocessable entity - PDF is valid but cannot be converted to PowerPoint format",
|
||||
content =
|
||||
@Content(
|
||||
mediaType = "application/json",
|
||||
|
||||
+2
-4
@@ -41,8 +41,7 @@ import io.swagger.v3.oas.annotations.responses.ApiResponses;
|
||||
@ApiResponse(
|
||||
responseCode = "400",
|
||||
description =
|
||||
"Bad request - Invalid input parameters, unsupported format, or"
|
||||
+ " corrupted PDF",
|
||||
"Bad request - Invalid input parameters, unsupported format, or corrupted PDF",
|
||||
content =
|
||||
@Content(
|
||||
mediaType = "application/json",
|
||||
@@ -57,8 +56,7 @@ import io.swagger.v3.oas.annotations.responses.ApiResponses;
|
||||
@ApiResponse(
|
||||
responseCode = "422",
|
||||
description =
|
||||
"Unprocessable entity - PDF is valid but cannot be converted to Word"
|
||||
+ " format",
|
||||
"Unprocessable entity - PDF is valid but cannot be converted to Word format",
|
||||
content =
|
||||
@Content(
|
||||
mediaType = "application/json",
|
||||
|
||||
+11
-11
@@ -39,18 +39,18 @@ public class AdditionalLanguageJsController {
|
||||
// Generiere die `getDetailedLanguageCode`-Funktion
|
||||
writer.println(
|
||||
"""
|
||||
function getDetailedLanguageCode() {
|
||||
const userLanguages = navigator.languages ? navigator.languages : [navigator.language];
|
||||
for (let lang of userLanguages) {
|
||||
let matchedLang = supportedLanguages.find(supportedLang => supportedLang.startsWith(lang.replace('-', '_')));
|
||||
if (matchedLang) {
|
||||
return matchedLang;
|
||||
function getDetailedLanguageCode() {
|
||||
const userLanguages = navigator.languages ? navigator.languages : [navigator.language];
|
||||
for (let lang of userLanguages) {
|
||||
let matchedLang = supportedLanguages.find(supportedLang => supportedLang.startsWith(lang.replace('-', '_')));
|
||||
if (matchedLang) {
|
||||
return matchedLang;
|
||||
}
|
||||
}
|
||||
// Fallback
|
||||
return "en_US";
|
||||
}
|
||||
}
|
||||
// Fallback
|
||||
return "en_US";
|
||||
}
|
||||
""");
|
||||
""");
|
||||
writer.flush();
|
||||
}
|
||||
|
||||
|
||||
+3
-5
@@ -54,9 +54,8 @@ public class BookletImpositionController {
|
||||
summary = "Create a booklet with proper page imposition",
|
||||
description =
|
||||
"This operation combines page reordering for booklet printing with multi-page"
|
||||
+ " layout. It rearranges pages in the correct order for booklet printing"
|
||||
+ " and places multiple pages on each sheet for proper folding and"
|
||||
+ " binding.")
|
||||
+ " layout. It rearranges pages in the correct order for booklet printing and"
|
||||
+ " places multiple pages on each sheet for proper folding and binding.")
|
||||
public ResponseEntity<Resource> createBookletImposition(
|
||||
@ModelAttribute BookletImpositionRequest request) throws IOException {
|
||||
|
||||
@@ -74,8 +73,7 @@ public class BookletImpositionController {
|
||||
// Validate pages per sheet for booklet - only 2-up landscape is proper booklet
|
||||
if (pagesPerSheet != 2) {
|
||||
throw new IllegalArgumentException(
|
||||
"Booklet printing uses 2 pages per side (landscape). For 4-up, use the N-up"
|
||||
+ " feature.");
|
||||
"Booklet printing uses 2 pages per side (landscape). For 4-up, use the N-up feature.");
|
||||
}
|
||||
|
||||
try (PDDocument sourceDocument = pdfDocumentFactory.load(file)) {
|
||||
|
||||
@@ -150,8 +150,7 @@ public class CropController {
|
||||
|| request.getWidth() == null
|
||||
|| request.getHeight() == null) {
|
||||
throw new IllegalArgumentException(
|
||||
"Crop coordinates (x, y, width, height) are required when auto-crop is not"
|
||||
+ " enabled");
|
||||
"Crop coordinates (x, y, width, height) are required when auto-crop is not enabled");
|
||||
}
|
||||
|
||||
if (request.isRemoveDataOutsideCrop() && isGhostscriptEnabled()) {
|
||||
|
||||
+8
-9
@@ -90,14 +90,13 @@ public class EditTextController {
|
||||
summary = "Edit text in a PDF via find and replace",
|
||||
description =
|
||||
"Applies an ordered list of find/replace operations to the text in a PDF and"
|
||||
+ " returns the edited PDF. Useful for find-and-replace, bulk renames (e.g."
|
||||
+ " updating a company name throughout a document), and copy editing where"
|
||||
+ " the AI agent has identified specific replacements. Matching is"
|
||||
+ " performed against the joined text of each page, so find strings can"
|
||||
+ " span multiple visual runs (titles split per word, kerning-broken"
|
||||
+ " phrases). Cross-element matches are written as a single replacement run"
|
||||
+ " anchored at the leftmost matched position; centered or tracked text may"
|
||||
+ " shift left when its content changes.")
|
||||
+ " returns the edited PDF. Useful for find-and-replace, bulk renames (e.g."
|
||||
+ " updating a company name throughout a document), and copy editing where the AI"
|
||||
+ " agent has identified specific replacements. Matching is performed against the"
|
||||
+ " joined text of each page, so find strings can span multiple visual runs"
|
||||
+ " (titles split per word, kerning-broken phrases). Cross-element matches are"
|
||||
+ " written as a single replacement run anchored at the leftmost matched position;"
|
||||
+ " centered or tracked text may shift left when its content changes.")
|
||||
public ResponseEntity<Resource> editText(@ModelAttribute EditTextRequest request)
|
||||
throws Exception {
|
||||
MultipartFile inputFile = request.getFileInput();
|
||||
@@ -238,7 +237,7 @@ public class EditTextController {
|
||||
|
||||
Matcher matcher = edit.pattern().matcher(joined);
|
||||
List<MatchSpan> spans = new ArrayList<>();
|
||||
StringBuilder interpolation = new StringBuilder();
|
||||
StringBuffer interpolation = new StringBuffer();
|
||||
int previousAppendPosition = 0;
|
||||
while (matcher.find()) {
|
||||
if (matcher.start() == matcher.end()) {
|
||||
|
||||
@@ -246,8 +246,8 @@ public class MergeController {
|
||||
summary = "Merge multiple PDF files into one",
|
||||
description =
|
||||
"This endpoint merges multiple PDF files into a single PDF file. The merged"
|
||||
+ " file will contain all pages from the input files in the order they were"
|
||||
+ " provided.")
|
||||
+ " file will contain all pages from the input files in the order they were"
|
||||
+ " provided.")
|
||||
public ResponseEntity<Resource> mergePdfs(
|
||||
@ModelAttribute MergePdfsRequest request,
|
||||
@RequestParam(value = "fileOrder", required = false) String fileOrder)
|
||||
|
||||
+2
-3
@@ -220,9 +220,8 @@ public class MultiPageLayoutController {
|
||||
"error.invalidFormat",
|
||||
"Invalid {0} format: {1}",
|
||||
"margin/layout configuration",
|
||||
"Invalid margin or layout configuration: resulting cell size is"
|
||||
+ " non-positive. Please reduce outer margins or adjust"
|
||||
+ " rows/columns.");
|
||||
"Invalid margin or layout configuration: resulting cell size is non-positive. "
|
||||
+ "Please reduce outer margins or adjust rows/columns.");
|
||||
}
|
||||
|
||||
float innerWidth = cellWidth - 2 * innerMargin;
|
||||
|
||||
+3
-4
@@ -57,8 +57,8 @@ public class PosterPdfController {
|
||||
summary = "Split large PDF pages into smaller printable chunks",
|
||||
description =
|
||||
"This endpoint splits large or oddly-sized PDF pages into smaller chunks"
|
||||
+ " suitable for printing on standard paper sizes (e.g., A4, Letter)."
|
||||
+ " Divides each page into a grid of smaller pages using Apache PDFBox.")
|
||||
+ " suitable for printing on standard paper sizes (e.g., A4, Letter). Divides each"
|
||||
+ " page into a grid of smaller pages using Apache PDFBox.")
|
||||
public ResponseEntity<Resource> posterPdf(@ModelAttribute PosterPdfRequest request)
|
||||
throws Exception {
|
||||
|
||||
@@ -214,8 +214,7 @@ public class PosterPdfController {
|
||||
}
|
||||
|
||||
log.trace(
|
||||
"Created output page for grid cell [{},{}] of page {}:"
|
||||
+ " cropX={}, cropY={}, translate=({}, {})",
|
||||
"Created output page for grid cell [{},{}] of page {}: cropX={}, cropY={}, translate=({}, {})",
|
||||
row,
|
||||
actualCol,
|
||||
pageIndex,
|
||||
|
||||
+2
-2
@@ -241,8 +241,8 @@ public class RearrangePagesPDFController {
|
||||
summary = "Rearrange pages in a PDF file",
|
||||
description =
|
||||
"This endpoint rearranges pages in a given PDF file based on the specified page"
|
||||
+ " order or custom mode. Users can provide a page order as a"
|
||||
+ " comma-separated list of page numbers or page ranges, or a custom mode.")
|
||||
+ " order or custom mode. Users can provide a page order as a comma-separated list"
|
||||
+ " of page numbers or page ranges, or a custom mode.")
|
||||
public ResponseEntity<Resource> rearrangePages(@ModelAttribute RearrangePagesRequest request)
|
||||
throws IOException {
|
||||
MultipartFile pdfFile = request.getFileInput();
|
||||
|
||||
+2
-2
@@ -60,8 +60,8 @@ public class SplitPDFController {
|
||||
summary = "Split a PDF file into separate documents",
|
||||
description =
|
||||
"This endpoint splits a given PDF file into separate documents based on the"
|
||||
+ " specified page numbers or ranges. Users can specify pages using"
|
||||
+ " individual numbers, ranges, or 'all' for every page.")
|
||||
+ " specified page numbers or ranges. Users can specify pages using individual"
|
||||
+ " numbers, ranges, or 'all' for every page.")
|
||||
public ResponseEntity<Resource> splitPdf(@ModelAttribute SplitPagesRequest request)
|
||||
throws IOException {
|
||||
|
||||
|
||||
+2
-2
@@ -62,8 +62,8 @@ public class SplitPdfBySectionsController {
|
||||
summary = "Split PDF pages into smaller sections",
|
||||
description =
|
||||
"Split each page of a PDF into smaller sections based on the user's choice"
|
||||
+ " which page to split, and how to split ( halves, thirds, quarters,"
|
||||
+ " etc.), both vertically and horizontally.")
|
||||
+ " which page to split, and how to split ( halves, thirds, quarters, etc.), both"
|
||||
+ " vertically and horizontally.")
|
||||
public ResponseEntity<Resource> splitPdf(
|
||||
@Valid @ModelAttribute SplitPdfBySectionsRequest request) throws Exception {
|
||||
MultipartFile file = request.getFileInput();
|
||||
|
||||
+3
-3
@@ -60,9 +60,9 @@ public class SplitPdfBySizeController {
|
||||
summary = "Auto split PDF pages into separate documents based on size or count",
|
||||
description =
|
||||
"split PDF into multiple paged documents based on size/count, ie if 20 pages"
|
||||
+ " and split into 5, it does 5 documents each 4 pages\r\n"
|
||||
+ " if 10MB and each page is 1MB and you enter 2MB then 5 docs each 2MB"
|
||||
+ " (rounded so that it accepts 1.9MB but not 2.1MB)")
|
||||
+ " and split into 5, it does 5 documents each 4 pages\r\n if 10MB and each page"
|
||||
+ " is 1MB and you enter 2MB then 5 docs each 2MB (rounded so that it accepts"
|
||||
+ " 1.9MB but not 2.1MB)")
|
||||
public ResponseEntity<Resource> autoSplitPdf(
|
||||
@ModelAttribute SplitPdfBySizeOrCountRequest request) throws Exception {
|
||||
|
||||
|
||||
+2
-2
@@ -46,8 +46,8 @@ public class ToSinglePageController {
|
||||
summary = "Convert a multi-page PDF into a single long page PDF",
|
||||
description =
|
||||
"This endpoint converts a multi-page PDF document into a single paged PDF"
|
||||
+ " document. The width of the single page will be same as the input's"
|
||||
+ " width, but the height will be the sum of all the pages' heights.")
|
||||
+ " document. The width of the single page will be same as the input's width, but"
|
||||
+ " the height will be the sum of all the pages' heights.")
|
||||
public ResponseEntity<Resource> pdfToSinglePage(@ModelAttribute PDFFile request)
|
||||
throws IOException {
|
||||
|
||||
|
||||
@@ -95,8 +95,7 @@ public class UIDataController {
|
||||
|
||||
try (InputStream is = resource.getInputStream()) {
|
||||
Map<String, List<Dependency>> licenseData =
|
||||
objectMapper.readValue(
|
||||
is, new TypeReference<Map<String, List<Dependency>>>() {});
|
||||
objectMapper.readValue(is, new TypeReference<>() {});
|
||||
data.setDependencies(licenseData.get("dependencies"));
|
||||
} catch (IOException e) {
|
||||
log.error("Failed to load licenses data", e);
|
||||
|
||||
+3
-3
@@ -56,9 +56,9 @@ public class ConvertEmlToPDF {
|
||||
summary = "Convert EML/MSG to PDF",
|
||||
description =
|
||||
"This endpoint converts EML (email) and MSG (Outlook) files to PDF format with"
|
||||
+ " extensive customization options. Features include font settings, image"
|
||||
+ " constraints, display modes, attachment handling, and HTML debug output."
|
||||
+ " or MSG file, or HTML file.")
|
||||
+ " extensive customization options. Features include font settings, image"
|
||||
+ " constraints, display modes, attachment handling, and HTML debug output. or MSG"
|
||||
+ " file, or HTML file.")
|
||||
public ResponseEntity<Resource> convertEmlToPdf(@ModelAttribute EmlToPdfRequest request) {
|
||||
|
||||
MultipartFile inputFile = request.getFileInput();
|
||||
|
||||
+1
-2
@@ -48,8 +48,7 @@ public class ConvertHtmlToPDF {
|
||||
@Operation(
|
||||
summary = "Convert an HTML or ZIP (containing HTML and CSS) to PDF",
|
||||
description =
|
||||
"This endpoint takes an HTML or ZIP file input and converts it to a PDF"
|
||||
+ " format.")
|
||||
"This endpoint takes an HTML or ZIP file input and converts it to a PDF format.")
|
||||
public ResponseEntity<Resource> HtmlToPdf(@ModelAttribute HTMLToPdfRequest request)
|
||||
throws Exception {
|
||||
MultipartFile fileInput = request.getFileInput();
|
||||
|
||||
+2
-2
@@ -95,8 +95,8 @@ public class ConvertImgPDFController {
|
||||
summary = "Convert PDF to image(s)",
|
||||
description =
|
||||
"This endpoint converts a PDF file to image(s) with the specified image format,"
|
||||
+ " color type, and DPI. Users can choose to get a single image or multiple"
|
||||
+ " images.")
|
||||
+ " color type, and DPI. Users can choose to get a single image or multiple"
|
||||
+ " images.")
|
||||
public ResponseEntity<?> convertToImage(@ModelAttribute ConvertToImageRequest request)
|
||||
throws Exception {
|
||||
MultipartFile file = request.getFileInput();
|
||||
|
||||
+2
-2
@@ -97,8 +97,8 @@ public class ConvertPDFToEpubController {
|
||||
|
||||
if (!endpointConfiguration.isGroupEnabled(CALIBRE_GROUP)) {
|
||||
throw new IllegalStateException(
|
||||
"Calibre support is disabled. Enable the Calibre group or install Calibre to"
|
||||
+ " use this feature.");
|
||||
"Calibre support is disabled. Enable the Calibre group or install Calibre to use"
|
||||
+ " this feature.");
|
||||
}
|
||||
|
||||
MultipartFile inputFile = request.getFileInput();
|
||||
|
||||
+30
-36
@@ -453,32 +453,32 @@ public class ConvertPDFToPDFA {
|
||||
String pdfaDefContent =
|
||||
String.format(
|
||||
"""
|
||||
%% This is a sample prefix file for creating a PDF/A document.
|
||||
%% Feel free to modify entries marked with "Customize".
|
||||
%% This is a sample prefix file for creating a PDF/A document.
|
||||
%% Feel free to modify entries marked with "Customize".
|
||||
|
||||
%% Define entries in the document Info dictionary.
|
||||
[/Title (%s)
|
||||
/DOCINFO pdfmark
|
||||
%% Define entries in the document Info dictionary.
|
||||
[/Title (%s)
|
||||
/DOCINFO pdfmark
|
||||
|
||||
%% Define an ICC profile.
|
||||
[/_objdef {icc_PDFA} /type /stream /OBJ pdfmark
|
||||
[{icc_PDFA} <<
|
||||
/N 3
|
||||
>> /PUT pdfmark
|
||||
[{icc_PDFA} (%s) (r) file /PUT pdfmark
|
||||
%% Define an ICC profile.
|
||||
[/_objdef {icc_PDFA} /type /stream /OBJ pdfmark
|
||||
[{icc_PDFA} <<
|
||||
/N 3
|
||||
>> /PUT pdfmark
|
||||
[{icc_PDFA} (%s) (r) file /PUT pdfmark
|
||||
|
||||
%% Define the output intent dictionary.
|
||||
[/_objdef {OutputIntent_PDFA} /type /dict /OBJ pdfmark
|
||||
[{OutputIntent_PDFA} <<
|
||||
/Type /OutputIntent
|
||||
/S /GTS_PDFA1
|
||||
/DestOutputProfile {icc_PDFA}
|
||||
/OutputConditionIdentifier (sRGB IEC61966-2.1)
|
||||
/Info (sRGB IEC61966-2.1)
|
||||
/RegistryName (http://www.color.org)
|
||||
>> /PUT pdfmark
|
||||
[{Catalog} <</OutputIntents [ {OutputIntent_PDFA} ]>> /PUT pdfmark
|
||||
""",
|
||||
%% Define the output intent dictionary.
|
||||
[/_objdef {OutputIntent_PDFA} /type /dict /OBJ pdfmark
|
||||
[{OutputIntent_PDFA} <<
|
||||
/Type /OutputIntent
|
||||
/S /GTS_PDFA1
|
||||
/DestOutputProfile {icc_PDFA}
|
||||
/OutputConditionIdentifier (sRGB IEC61966-2.1)
|
||||
/Info (sRGB IEC61966-2.1)
|
||||
/RegistryName (http://www.color.org)
|
||||
>> /PUT pdfmark
|
||||
[{Catalog} <</OutputIntents [ {OutputIntent_PDFA} ]>> /PUT pdfmark
|
||||
""",
|
||||
title, rgbProfilePath);
|
||||
|
||||
Files.writeString(pdfaDefFile, pdfaDefContent);
|
||||
@@ -598,9 +598,8 @@ public class ConvertPDFToPDFA {
|
||||
summary = "Convert a PDF to a PDF/A or PDF/X",
|
||||
description =
|
||||
"This endpoint converts a PDF file to a PDF/A or PDF/X file using Ghostscript"
|
||||
+ " (preferred) or PDFBox/LibreOffice (fallback). PDF/A is a format"
|
||||
+ " designed for long-term archiving, while PDF/X is optimized for print"
|
||||
+ " production.")
|
||||
+ " (preferred) or PDFBox/LibreOffice (fallback). PDF/A is a format designed for"
|
||||
+ " long-term archiving, while PDF/X is optimized for print production.")
|
||||
public ResponseEntity<Resource> pdfToPdfA(@ModelAttribute PdfToPdfARequest request)
|
||||
throws Exception {
|
||||
MultipartFile inputFile = request.getFileInput();
|
||||
@@ -662,8 +661,7 @@ public class ConvertPDFToPDFA {
|
||||
if (!isGhostscriptAvailable()) {
|
||||
log.error("Ghostscript is required for PDF/X conversion");
|
||||
throw new IOException(
|
||||
"Ghostscript is required for PDF/X conversion but is not available on the"
|
||||
+ " system");
|
||||
"Ghostscript is required for PDF/X conversion but is not available on the system");
|
||||
}
|
||||
|
||||
log.info("Using Ghostscript for PDF/X conversion to {}", profile.getDisplayName());
|
||||
@@ -745,8 +743,7 @@ public class ConvertPDFToPDFA {
|
||||
if (fontNameStr.contains("+") || fontNameStr.contains("Subset")) {
|
||||
descDict.removeItem(COSName.CHAR_SET);
|
||||
log.debug(
|
||||
"Removed potentially invalid CharSet from subsetted Type1"
|
||||
+ " font: {}",
|
||||
"Removed potentially invalid CharSet from subsetted Type1 font: {}",
|
||||
fontNameStr);
|
||||
} else if (!hasFontFile && fontEmbedded) {
|
||||
// Font is embedded but we can't verify CharSet, remove it
|
||||
@@ -764,8 +761,7 @@ public class ConvertPDFToPDFA {
|
||||
if (!glyphSet.isEmpty()) {
|
||||
descDict.setString(COSName.CHAR_SET, glyphSet);
|
||||
log.debug(
|
||||
"Added missing CharSet for Type1 font {} with {}"
|
||||
+ " glyphs",
|
||||
"Added missing CharSet for Type1 font {} with {} glyphs",
|
||||
fontNameStr,
|
||||
countGlyphs(glyphSet));
|
||||
}
|
||||
@@ -1939,8 +1935,7 @@ public class ConvertPDFToPDFA {
|
||||
return WebResponseUtils.pdfFileToWebResponse(tempOut, outputFilename);
|
||||
} catch (IOException | InterruptedException e) {
|
||||
log.warn(
|
||||
"Ghostscript conversion failed, falling back to PDFBox/LibreOffice"
|
||||
+ " method",
|
||||
"Ghostscript conversion failed, falling back to PDFBox/LibreOffice method",
|
||||
e);
|
||||
}
|
||||
} else {
|
||||
@@ -2541,8 +2536,7 @@ public class ConvertPDFToPDFA {
|
||||
return converted;
|
||||
} catch (IOException | InterruptedException e) {
|
||||
log.warn(
|
||||
"Ghostscript conversion failed, falling back to PDFBox/LibreOffice"
|
||||
+ " method",
|
||||
"Ghostscript conversion failed, falling back to PDFBox/LibreOffice method",
|
||||
e);
|
||||
}
|
||||
} else {
|
||||
|
||||
+17
-20
@@ -62,8 +62,7 @@ public class ConvertPdfJsonController {
|
||||
@Operation(
|
||||
summary = "Convert PDF to Text Editor Format",
|
||||
description =
|
||||
"Extracts PDF text, fonts, and metadata into an editable JSON structure for the"
|
||||
+ " text editor tool.")
|
||||
"Extracts PDF text, fonts, and metadata into an editable JSON structure for the text editor tool.")
|
||||
public ResponseEntity<Resource> convertPdfToJson(
|
||||
@ModelAttribute PDFFile request,
|
||||
@RequestParam(value = "lightweight", defaultValue = "false") boolean lightweight)
|
||||
@@ -105,8 +104,7 @@ public class ConvertPdfJsonController {
|
||||
@Operation(
|
||||
summary = "Convert Text Editor Format to PDF",
|
||||
description =
|
||||
"Rebuilds a PDF from the editable JSON structure generated by the text editor"
|
||||
+ " tool.")
|
||||
"Rebuilds a PDF from the editable JSON structure generated by the text editor tool.")
|
||||
public ResponseEntity<Resource> convertJsonToPdf(@ModelAttribute GeneralFile request)
|
||||
throws Exception {
|
||||
MultipartFile jsonFile = request.getFileInput();
|
||||
@@ -139,9 +137,9 @@ public class ConvertPdfJsonController {
|
||||
@Operation(
|
||||
summary = "Extract PDF metadata for text editor lazy loading",
|
||||
description =
|
||||
"Extracts document metadata, fonts, and page dimensions for the text editor"
|
||||
+ " tool. Caches the document for subsequent page requests. Returns a"
|
||||
+ " server-generated jobId scoped to the authenticated user.")
|
||||
"Extracts document metadata, fonts, and page dimensions for the text editor tool. Caches the document for"
|
||||
+ " subsequent page requests. Returns a server-generated jobId scoped to the"
|
||||
+ " authenticated user.")
|
||||
public ResponseEntity<Resource> extractPdfMetadata(@ModelAttribute PDFFile request)
|
||||
throws Exception {
|
||||
MultipartFile inputFile = request.getFileInput();
|
||||
@@ -183,10 +181,9 @@ public class ConvertPdfJsonController {
|
||||
@Operation(
|
||||
summary = "Apply incremental edits from text editor to a cached PDF",
|
||||
description =
|
||||
"Applies edits for the specified pages of a cached PDF and returns an updated"
|
||||
+ " PDF. Requires the PDF to have been previously cached via the text"
|
||||
+ " editor metadata endpoint. The jobId must be obtained from the metadata"
|
||||
+ " extraction endpoint.")
|
||||
"Applies edits for the specified pages of a cached PDF and returns an updated PDF."
|
||||
+ " Requires the PDF to have been previously cached via the text editor metadata endpoint."
|
||||
+ " The jobId must be obtained from the metadata extraction endpoint.")
|
||||
public ResponseEntity<Resource> exportPartialPdf(
|
||||
@PathVariable String jobId,
|
||||
@RequestBody PdfJsonDocument document,
|
||||
@@ -227,9 +224,9 @@ public class ConvertPdfJsonController {
|
||||
@Operation(
|
||||
summary = "Extract single page from cached PDF for text editor",
|
||||
description =
|
||||
"Retrieves a single page's content from a previously cached PDF document for"
|
||||
+ " the text editor tool. Requires prior call to /pdf/text-editor/metadata."
|
||||
+ " The jobId must belong to the authenticated user.")
|
||||
"Retrieves a single page's content from a previously cached PDF document for the text editor tool."
|
||||
+ " Requires prior call to /pdf/text-editor/metadata. The jobId must belong to the"
|
||||
+ " authenticated user.")
|
||||
public ResponseEntity<Resource> extractSinglePage(
|
||||
@PathVariable String jobId, @PathVariable int pageNumber) throws Exception {
|
||||
|
||||
@@ -256,9 +253,9 @@ public class ConvertPdfJsonController {
|
||||
@Operation(
|
||||
summary = "Extract fonts used by a single cached page for text editor",
|
||||
description =
|
||||
"Retrieves the font payloads used by a single page from a previously cached PDF"
|
||||
+ " document. Requires prior call to /pdf/text-editor/metadata. The jobId"
|
||||
+ " must belong to the authenticated user.")
|
||||
"Retrieves the font payloads used by a single page from a previously cached PDF document."
|
||||
+ " Requires prior call to /pdf/text-editor/metadata. The jobId must belong to the"
|
||||
+ " authenticated user.")
|
||||
public ResponseEntity<Resource> extractPageFonts(
|
||||
@PathVariable String jobId, @PathVariable int pageNumber) throws Exception {
|
||||
|
||||
@@ -288,9 +285,9 @@ public class ConvertPdfJsonController {
|
||||
@Operation(
|
||||
summary = "Clear cached PDF document for text editor",
|
||||
description =
|
||||
"Manually clears a cached PDF document used by the text editor to free up"
|
||||
+ " server resources. Called automatically after 30 minutes. The jobId must"
|
||||
+ " belong to the authenticated user.")
|
||||
"Manually clears a cached PDF document used by the text editor to free up server resources."
|
||||
+ " Called automatically after 30 minutes. The jobId must belong to the"
|
||||
+ " authenticated user.")
|
||||
public ResponseEntity<Void> clearCache(@PathVariable String jobId) {
|
||||
|
||||
validateJobAccess(jobId);
|
||||
|
||||
+4
-5
@@ -68,11 +68,10 @@ public class ConvertSvgToPDF {
|
||||
summary = "Convert SVG to PDF",
|
||||
description =
|
||||
"This endpoint converts one or more SVG (Scalable Vector Graphics) files to PDF"
|
||||
+ " format. Each SVG is converted to a separate PDF file. The conversion"
|
||||
+ " preserves vector graphics for crisp output at any resolution - no"
|
||||
+ " rasterization occurs. SVG dimensions (width/height) determine the PDF"
|
||||
+ " page size; defaults to A4 if not specified. SVG content is sanitized to"
|
||||
+ " prevent XSS attacks.")
|
||||
+ " format. Each SVG is converted to a separate PDF file. The conversion preserves"
|
||||
+ " vector graphics for crisp output at any resolution - no rasterization occurs."
|
||||
+ " SVG dimensions (width/height) determine the PDF page size; defaults to A4 if"
|
||||
+ " not specified. SVG content is sanitized to prevent XSS attacks.")
|
||||
public ResponseEntity<Resource> convertSvgToPdf(@ModelAttribute SvgToPdfRequest request) {
|
||||
|
||||
MultipartFile[] inputFiles = request.getFileInput();
|
||||
|
||||
+3
-6
@@ -221,8 +221,7 @@ public class PdfVectorExportController {
|
||||
|
||||
if (result.getRc() != 0) {
|
||||
log.error(
|
||||
"Ghostscript PDF to {} conversion failed with rc={} and messages={}. Command:"
|
||||
+ " {}",
|
||||
"Ghostscript PDF to {} conversion failed with rc={} and messages={}. Command: {}",
|
||||
outputFormat.toUpperCase(),
|
||||
result.getRc(),
|
||||
result.getMessages(),
|
||||
@@ -262,8 +261,7 @@ public class PdfVectorExportController {
|
||||
ExceptionUtils.detectGhostscriptCriticalError(result.getMessages());
|
||||
if (criticalError != null) {
|
||||
log.error(
|
||||
"Ghostscript PostScript-to-PDF conversion detected critical error: {}. Command:"
|
||||
+ " {}",
|
||||
"Ghostscript PostScript-to-PDF conversion detected critical error: {}. Command: {}",
|
||||
criticalError.getMessage(),
|
||||
String.join(" ", command));
|
||||
throw criticalError;
|
||||
@@ -271,8 +269,7 @@ public class PdfVectorExportController {
|
||||
|
||||
if (result.getRc() != 0) {
|
||||
log.error(
|
||||
"Ghostscript PostScript-to-PDF conversion failed with rc={} and messages={}."
|
||||
+ " Command: {}",
|
||||
"Ghostscript PostScript-to-PDF conversion failed with rc={} and messages={}. Command: {}",
|
||||
result.getRc(),
|
||||
result.getMessages(),
|
||||
String.join(" ", command));
|
||||
|
||||
+3
-4
@@ -295,8 +295,7 @@ public class FormFillController {
|
||||
@Operation(
|
||||
summary = "Extract form fields as XLSX",
|
||||
description =
|
||||
"Returns an Excel (XLSX) file containing all form field names and their current"
|
||||
+ " values")
|
||||
"Returns an Excel (XLSX) file containing all form field names and their current values")
|
||||
public ResponseEntity<byte[]> extractXlsx(
|
||||
@Parameter(
|
||||
description = "The input PDF file",
|
||||
@@ -428,8 +427,8 @@ public class FormFillController {
|
||||
@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.")
|
||||
+ " 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 {
|
||||
|
||||
+3
-6
@@ -25,15 +25,12 @@ 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<Map<String, Object>>() {};
|
||||
private static final TypeReference<Map<String, Object>> MAP_TYPE = new TypeReference<>() {};
|
||||
private static final TypeReference<List<FormUtils.ModifyFormFieldDefinition>>
|
||||
MODIFY_FIELD_LIST_TYPE =
|
||||
new TypeReference<List<FormUtils.ModifyFormFieldDefinition>>() {};
|
||||
MODIFY_FIELD_LIST_TYPE = new TypeReference<>() {};
|
||||
private static final TypeReference<List<FormUtils.NewFormFieldDefinition>> NEW_FIELD_LIST_TYPE =
|
||||
new TypeReference<>() {};
|
||||
private static final TypeReference<List<String>> STRING_LIST_TYPE =
|
||||
new TypeReference<List<String>>() {};
|
||||
private static final TypeReference<List<String>> STRING_LIST_TYPE = new TypeReference<>() {};
|
||||
|
||||
private FormPayloadParser() {}
|
||||
|
||||
|
||||
+4
-7
@@ -79,10 +79,9 @@ public class AddCommentsController {
|
||||
summary = "Add sticky-note comments to a PDF at specified positions or anchored text",
|
||||
description =
|
||||
"Attaches PDF Text (sticky-note) annotations to the document. Each CommentSpec"
|
||||
+ " can either supply absolute coordinates or an `anchorText` hint; when"
|
||||
+ " provided, the tool locates the first matching line on the target page"
|
||||
+ " and anchors the icon there (falling back to the coordinates if no"
|
||||
+ " match).")
|
||||
+ " can either supply absolute coordinates or an `anchorText` hint; when provided,"
|
||||
+ " the tool locates the first matching line on the target page and anchors the"
|
||||
+ " icon there (falling back to the coordinates if no match).")
|
||||
public ResponseEntity<Resource> addComments(@ModelAttribute AddCommentsRequest request)
|
||||
throws IOException {
|
||||
|
||||
@@ -97,9 +96,7 @@ public class AddCommentsController {
|
||||
|
||||
List<CommentSpecDto> dtos;
|
||||
try {
|
||||
dtos =
|
||||
objectMapper.readValue(
|
||||
commentsJson, new TypeReference<List<CommentSpecDto>>() {});
|
||||
dtos = objectMapper.readValue(commentsJson, new TypeReference<>() {});
|
||||
} catch (JacksonException e) {
|
||||
throw new ResponseStatusException(
|
||||
HttpStatus.BAD_REQUEST, "comments must be a JSON array of CommentSpec objects");
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user