Merge main into fix_ex_20260720

This commit is contained in:
Anthony Stirling
2026-08-21 07:29:57 +01:00
360 changed files with 35587 additions and 17301 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
# Maintainer: Stirling PDF Inc <contact@stirlingpdf.com>
pkgname=stirling-pdf-desktop
pkgver=2.14.2
pkgver=2.14.3
pkgrel=1
pkgdesc="Locally hosted, web-based PDF manipulation tool (Tauri desktop app, official Stirling PDF Inc build)"
arch=('x86_64')
+1 -1
View File
@@ -1,6 +1,6 @@
# Maintainer: Stirling PDF Inc <contact@stirlingpdf.com>
pkgname=stirling-pdf-server-bin
pkgver=2.14.2
pkgver=2.14.3
pkgrel=1
pkgdesc="Locally hosted, web-based PDF manipulation tool (server JAR, prebuilt)"
arch=('any')
+36 -19
View File
@@ -1,13 +1,15 @@
# CI routing infra. Editing the top-level router (build.yml) or this filter
# config re-runs every area's jobs, so every job-gating filter below includes
# *ci. That makes a change to how jobs are dispatched actually exercise those
# jobs (self-testing), instead of a router edit only matching the project filter.
# CI routing infrastructure. Changes to the top-level router (build.yml) or
# this filter configuration rerun every area's jobs. Every job-gating filter
# therefore includes *ci, so routing changes exercise the jobs they affect
# instead of matching only the project filter.
ci: &ci
- .github/workflows/build.yml
- .github/workflows/gradle-cache-prime.yml
- .github/config/.files.yaml
build: &build
- *ci
- buildSrc/**
- build.gradle
- gradle/spotless.gradle
- app/(common|core|proprietary|saas)/build.gradle
@@ -15,6 +17,22 @@ build: &build
- .taskfiles/backend.yml
- .github/workflows/check-licence.yml
# Backend build inputs. This is intentionally broader than `build`: Java and
# backend resource changes must exercise the backend matrix even when Gradle
# build scripts themselves are unchanged.
backend: &backend
- *ci
- *build
- gradle/**
- gradle.properties
- gradlew
- gradlew.bat
- settings.gradle
- app/(common|core|proprietary|saas)/src/(main|test)/java/**
- "app/(common|core|proprietary|saas)/src/(main|test)/resources/**/!(messages_*.properties|*.md)*"
- scripts/db-migration/**
- .github/workflows/backend-build.yml
openapi: &openapi
- *ci
- *build
@@ -24,9 +42,9 @@ openapi: &openapi
docker-base: &docker-base
- docker/base/Dockerfile
# Dockerfiles only (base + embedded + unoserver). Gates the slow multi-arch
# (arm64) leg of the PR docker test build: arm64 is only rebuilt when a
# Dockerfile itself changes, not on every code PR.
# Dockerfiles only (base, embedded, and unoserver). The slow multi-architecture
# (arm64) leg of the PR Docker test build runs only when a Dockerfile changes,
# rather than for every code PR.
dockerfiles: &dockerfiles
- docker/**/Dockerfile*
@@ -68,7 +86,6 @@ project: &project
frontend: &frontend
- *ci
- frontend/**
- .github/workflows/testdriver.yml
- testing/**
- docker/**
- scripts/translations/*.py
@@ -88,8 +105,8 @@ frontend: &frontend
- .github/workflows/e2e-stubbed.yml
- .github/workflows/e2e-live.yml
# Files that affect the Tauri desktop bundle. Gate the multi-OS Tauri build
# job on changes to any of these.
# Files that affect the Tauri desktop bundle. Changes to any of these files
# trigger the multi-OS Tauri build job.
tauri: &tauri
- *ci
- frontend/editor/src-tauri/**
@@ -102,9 +119,9 @@ tauri: &tauri
- Taskfile.yml
- .taskfiles/desktop.yml
# Files that affect the AI engine (Python tool models, fixers, tests). Gate
# the engine validation job on changes to engine sources or to the Java
# tool surfaces it generates models from.
# Files that affect the AI engine, including its Python tool models, fixers,
# and tests. The engine validation job also runs when the Java tool surfaces
# used to generate those models change.
engine: &engine
- *ci
- engine/**
@@ -114,10 +131,10 @@ engine: &engine
- .taskfiles/engine.yml
# Files that can make the committed generated API models (frontend tool API
# types + engine tool models) go stale: the Java tool surfaces they derive from,
# the generators, the generated files themselves (to catch a hand-edit), and the
# tasks that drive generation. Deliberately excludes the broad frontend/docker/
# testing globs, so a CSS-only PR does not boot the backend to rebuild the spec.
# types and engine tool models) stale: their Java sources, generators,
# generated outputs (to catch hand edits), and generation tasks. Broad
# frontend, Docker, and testing globs are intentionally excluded, so a CSS-only
# PR does not start the backend to rebuild the specification.
generated-models: &generated-models
- *ci
- *openapi
@@ -141,8 +158,8 @@ licenses-backend: &licenses-backend
- ".github/workflows/frontend-backend-licenses-update.yml"
- *build
# Files that can affect premium / enterprise behaviour. Gate the enterprise
# Playwright job on changes to any of these on PRs.
# Files that can affect premium or enterprise behaviour. Changes to any of
# these files trigger the enterprise Playwright job for pull requests.
proprietary: &proprietary
- *ci
- app/proprietary/**
+8 -8
View File
@@ -65,6 +65,7 @@ updates:
directories:
- /devTools
- /frontend
- /testing/compose/mcp-client-check
schedule:
interval: "weekly"
cooldown:
@@ -93,6 +94,13 @@ updates:
- "react-dom"
- "@types/react"
- "@types/react-dom"
tanstack:
patterns:
- "@tanstack/*"
typescript:
patterns:
- "typescript"
- "@typescript/*"
vite:
patterns:
- "vite"
@@ -171,14 +179,6 @@ updates:
- "tokio"
- "tokio-*"
- package-ecosystem: pip
directory: /testing/cucumber
schedule:
interval: "weekly"
cooldown:
default-days: 7
rebase-strategy: "auto"
- package-ecosystem: "uv"
directory: "/engine"
schedule:
+9 -6
View File
@@ -39,7 +39,7 @@ jobs:
pr_ref: ${{ steps.resolve.outputs.ref }}
steps:
- name: Harden Runner
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
with:
egress-policy: audit
@@ -121,7 +121,7 @@ jobs:
steps:
- name: Harden Runner
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
with:
egress-policy: audit
@@ -334,7 +334,7 @@ jobs:
# under the exact same access rules as the app preview.
- name: Detect Storybook changes
id: sb-changes
uses: dorny/paths-filter@7b450fff21473bca461d4b92ce414b9d0420d706 # v4.0.2
uses: dorny/paths-filter@ceb8a2b8f2d89434be7ff52d3de7ec3738c5cc9d # v4.0.3
with:
list-files: json
filters: |
@@ -462,7 +462,10 @@ jobs:
});
cleanup-v2-deployment:
environment: pr-preview
# Tearing a preview down is not a deployment - no deployment object.
environment:
name: pr-preview
deployment: false
if: github.event.action == 'closed'
runs-on: ubuntu-latest
permissions:
@@ -472,7 +475,7 @@ jobs:
steps:
- name: Harden Runner
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
with:
egress-policy: audit
@@ -557,5 +560,5 @@ jobs:
- name: Cleanup temporary files
if: always()
run: |
rm -f ../private.key
rm -f ../private.key docker-compose.yml storybook.tgz
continue-on-error: true
@@ -70,7 +70,7 @@ jobs:
enable_prototypes: ${{ steps.check-prototypes-flag.outputs.enable_prototypes }}
steps:
- name: Harden Runner
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
with:
egress-policy: audit
@@ -177,7 +177,7 @@ jobs:
steps:
- name: Harden Runner
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
with:
egress-policy: audit
@@ -191,22 +191,19 @@ jobs:
# untrusted tree gets built below - never leave credentials in .git/config
persist-credentials: false
- name: Set up JDK 25
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
with:
java-version: "25"
distribution: "temurin"
- name: Cache Gradle User Home
- name: Cache Gradle
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
restore-keys: |
gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-
gradle-${{ runner.os }}-${{ runner.arch }}-
key: gradle-deploy-pr-v1-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
- name: Set up JDK 25
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
with:
java-version: "25"
distribution: "temurin"
- name: Install Task
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
@@ -485,7 +482,7 @@ jobs:
issues: write # add/remove labels, delete the command comment
steps:
- name: Harden Runner
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
with:
egress-policy: audit
+5 -6
View File
@@ -7,13 +7,12 @@ on:
permissions:
contents: read
env:
SERVER_IP: ${{ secrets.NEW_VPS_IP }} # Add this to your GitHub secrets
CLEANUP_PERFORMED: "false" # Add flag to track if cleanup occurred
jobs:
cleanup:
environment: pr-preview
# Tearing a preview down is not a deployment - no deployment object.
environment:
name: pr-preview
deployment: false
if: github.event.action == 'closed'
runs-on: ubuntu-latest
permissions:
@@ -23,7 +22,7 @@ jobs:
steps:
- name: Harden Runner
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
with:
egress-policy: audit
+1 -1
View File
@@ -38,7 +38,7 @@ jobs:
is_fork: ${{ steps.decide.outputs.is_fork }}
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
with:
egress-policy: audit
+1 -1
View File
@@ -20,7 +20,7 @@ jobs:
pull-requests: write
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
with:
egress-policy: audit
-221
View File
@@ -1,221 +0,0 @@
name: AI - PR Title Review
on:
pull_request:
types: [opened, edited]
branches: [main]
permissions: # required for secure-repo hardening
contents: read
jobs:
ai-title-review:
# GITHUB_TOKEN obeys this block, so it must cover every API call made below.
permissions:
contents: read # actions/checkout, git fetch/diff
issues: write # issues.listComments / createComment / updateComment on the PR
pull-requests: write # same endpoints when the target is a pull request
models: read # actions/ai-inference
runs-on: ubuntu-latest
steps:
- name: Harden Runner
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
with:
egress-policy: audit
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0
- name: Configure Git to suppress detached HEAD warning
run: git config --global advice.detachedHead false
- name: Check if actor is repo developer
id: actor
run: |
if [[ "${{ github.actor }}" == *"[bot]" ]]; then
echo "PR opened by a bot skipping AI title review."
echo "is_repo_dev=false" >> $GITHUB_OUTPUT
exit 0
fi
if [ ! -f .github/config/repo_devs.json ]; then
echo "Error: .github/config/repo_devs.json not found" >&2
exit 1
fi
# Validate JSON and extract repo_devs
REPO_DEVS=$(jq -r '.repo_devs[]' .github/config/repo_devs.json 2>/dev/null || { echo "Error: Invalid JSON in repo_devs.json" >&2; exit 1; })
# Convert developer list into Bash array
mapfile -t DEVS_ARRAY <<< "$REPO_DEVS"
if [[ " ${DEVS_ARRAY[*]} " == *" ${{ github.actor }} "* ]]; then
echo "is_repo_dev=true" >> $GITHUB_OUTPUT
else
echo "is_repo_dev=false" >> $GITHUB_OUTPUT
fi
- name: Get PR diff
if: steps.actor.outputs.is_repo_dev == 'true'
id: get_diff
run: |
git fetch origin ${{ github.base_ref }}
git diff origin/${{ github.base_ref }}...HEAD | head -n 10000 | grep -vP '[\x00-\x08\x0B\x0C\x0E-\x1F\x7F\x{202E}\x{200B}]' > pr.diff
echo "diff<<EOF" >> $GITHUB_OUTPUT
cat pr.diff >> $GITHUB_OUTPUT
echo "EOF" >> $GITHUB_OUTPUT
- name: Check and sanitize PR title
if: steps.actor.outputs.is_repo_dev == 'true'
id: sanitize_pr_title
env:
PR_TITLE_RAW: ${{ github.event.pull_request.title }}
run: |
# Sanitize PR title: max 72 characters, only printable characters
PR_TITLE=$(echo "$PR_TITLE_RAW" | tr -d '\n\r' | head -c 72 | sed 's/[^[:print:]]//g')
if [[ ${#PR_TITLE} -lt 5 ]]; then
echo "PR title is too short. Must be at least 5 characters." >&2
fi
echo "pr_title=$PR_TITLE" >> $GITHUB_OUTPUT
- name: AI PR Title Analysis
if: steps.actor.outputs.is_repo_dev == 'true'
id: ai-title-analysis
uses: actions/ai-inference@a7805884c80886efc241e94a5351df715968a0ad # v2.1.1
with:
model: openai/gpt-4o
system-prompt-file: ".github/config/system-prompt.txt"
prompt: |
Based on the following input data:
{
"diff": "${{ steps.get_diff.outputs.diff }}",
"pr_title": "${{ steps.sanitize_pr_title.outputs.pr_title }}"
}
Respond ONLY with valid JSON in the format:
{
"improved_rating": <0-10>,
"improved_ai_title_rating": <0-10>,
"improved_title": "<ai generated title>"
}
- name: Validate and set SCRIPT_OUTPUT
if: steps.actor.outputs.is_repo_dev == 'true'
run: |
cat <<EOF > ai_response.json
${{ steps.ai-title-analysis.outputs.response }}
EOF
# Validate JSON structure
jq -e '
(keys | sort) == ["improved_ai_title_rating", "improved_rating", "improved_title"] and
(.improved_rating | type == "number" and . >= 0 and . <= 10) and
(.improved_ai_title_rating | type == "number" and . >= 0 and . <= 10) and
(.improved_title | type == "string")
' ai_response.json
if [ $? -ne 0 ]; then
echo "Invalid AI response format" >&2
cat ai_response.json >&2
exit 1
fi
# Parse JSON fields
IMPROVED_RATING=$(jq -r '.improved_rating' ai_response.json)
IMPROVED_TITLE=$(jq -r '.improved_title' ai_response.json)
# Limit comment length to 1000 characters
COMMENT=$(cat <<EOF
## 🤖 AI PR Title Suggestion
**PR-Title Rating**: $IMPROVED_RATING/10
### ⬇️ Suggested Title (copy & paste):
\`\`\`
$IMPROVED_TITLE
\`\`\`
---
*Generated by GitHub Models AI*
EOF
)
echo "$COMMENT" > /tmp/ai-title-comment.md
# Log input and output to the GitHub Step Summary
echo "### 🤖 AI PR Title Analysis" >> $GITHUB_STEP_SUMMARY
echo "### Input PR Title" >> $GITHUB_STEP_SUMMARY
echo '```bash' >> $GITHUB_STEP_SUMMARY
echo "${{ steps.sanitize_pr_title.outputs.pr_title }}" >> $GITHUB_STEP_SUMMARY
echo '```' >> $GITHUB_STEP_SUMMARY
echo '### AI Response (raw JSON)' >> $GITHUB_STEP_SUMMARY
echo '```json' >> $GITHUB_STEP_SUMMARY
cat ai_response.json >> $GITHUB_STEP_SUMMARY
echo '```' >> $GITHUB_STEP_SUMMARY
- name: Post comment on PR if needed
if: steps.actor.outputs.is_repo_dev == 'true'
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
continue-on-error: true
with:
github-token: ${{ github.token }}
script: |
const fs = require('fs');
const body = fs.readFileSync('/tmp/ai-title-comment.md', 'utf8');
const { GITHUB_REPOSITORY } = process.env;
const [owner, repo] = GITHUB_REPOSITORY.split('/');
const issue_number = context.issue.number;
const ratingMatch = body.match(/\*\*PR-Title Rating\*\*: (\d+)\/10/);
const rating = ratingMatch ? parseInt(ratingMatch[1], 10) : null;
const expectedActor = "github-actions[bot]";
const comments = await github.rest.issues.listComments({ owner, repo, issue_number });
const existing = comments.data.find(c =>
c.user?.login === expectedActor &&
c.body.includes("## 🤖 AI PR Title Suggestion")
);
if (rating === null) {
console.log("No rating found in AI response skipping.");
return;
}
if (rating <= 5) {
if (existing) {
await github.rest.issues.updateComment({
owner, repo,
comment_id: existing.id,
body
});
console.log("Updated existing suggestion comment.");
} else {
await github.rest.issues.createComment({
owner, repo, issue_number,
body
});
console.log("Created new suggestion comment.");
}
} else {
const praise = `## 🤖 AI PR Title Suggestion\n\nGreat job! The current PR title is clear and well-structured.\n\n✅ No suggestions needed.\n\n---\n*Generated by GitHub Models AI*`;
if (existing) {
await github.rest.issues.updateComment({
owner, repo,
comment_id: existing.id,
body: praise
});
console.log("Replaced suggestion with praise.");
} else {
console.log("Rating > 5 and no existing comment skipping comment.");
}
}
- name: is not repo dev
if: steps.actor.outputs.is_repo_dev != 'true'
run: |
exit 0 # Skip the AI title review for non-repo developers
- name: Clean up
if: always()
run: |
rm -f pr.diff ai_response.json /tmp/ai-title-comment.md
echo "Cleaned up temporary files."
continue-on-error: true # Ensure cleanup runs even if previous steps fail
+2 -2
View File
@@ -26,7 +26,7 @@ jobs:
jar_sha256: ${{ steps.hashes.outputs.jar_sha256 }}
steps:
- name: Harden Runner
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
with:
egress-policy: audit
@@ -71,7 +71,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Harden Runner
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
with:
egress-policy: audit
+1 -1
View File
@@ -18,7 +18,7 @@ jobs:
issues: write # labels are applied through the issues API
steps:
- name: Harden Runner
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
with:
egress-policy: audit
+13 -13
View File
@@ -20,6 +20,9 @@ permissions:
jobs:
build:
environment:
name: ci-unsigned
deployment: false
runs-on: ubuntu-latest
strategy:
fail-fast: false
@@ -28,29 +31,26 @@ jobs:
flavor: [core, proprietary, saas]
steps:
- name: Harden Runner
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
with:
egress-policy: audit
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Restore cache Gradle User Home
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: gradle-v1-${{ runner.os }}-${{ runner.arch }}-jdk-${{ matrix.jdk-version }}-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
- name: Set up JDK ${{ matrix.jdk-version }}
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
with:
java-version: ${{ matrix.jdk-version }}
distribution: "temurin"
- name: Cache Gradle User Home
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: gradle-${{ runner.os }}-${{ runner.arch }}-jdk-${{ matrix.jdk-version }}-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
restore-keys: |
gradle-${{ runner.os }}-${{ runner.arch }}-jdk-${{ matrix.jdk-version }}-
gradle-${{ runner.os }}-${{ runner.arch }}-
- name: Install Task
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
- name: Check Java formatting (Spotless)
@@ -155,7 +155,7 @@ jobs:
STIRLING_FLAVOR: ${{ matrix.flavor }}
# Configure the Gradle daemon explicitly; GRADLE_OPTS alone only
# configures the Gradle client JVM.
GRADLE_OPTS: '-Dorg.gradle.jvmargs=-Xmx4g -XX:+UseG1GC'
GRADLE_OPTS: "-Dorg.gradle.jvmargs=-Xmx4g -XX:+UseG1GC"
- name: Check Test Reports Exist
if: always()
+38 -11
View File
@@ -15,6 +15,11 @@ name: Enterprise E2E (Playwright)
on:
workflow_call:
inputs:
use_shared_cache:
required: false
type: boolean
default: false
push:
branches: ["main"]
schedule:
@@ -37,6 +42,9 @@ jobs:
uses: ./.github/workflows/_runner-pick.yml
playwright-e2e-enterprise:
environment:
name: ci-unsigned
deployment: false
needs: pick
# Skip on fork PRs / untrusted authors: they have no PREMIUM_KEY_ENTERPRISE,
# so the suite can't boot premium and would fail. See the header comment.
@@ -50,26 +58,36 @@ jobs:
SYSTEM_ENABLEANALYTICS: "false"
steps:
- name: Harden Runner
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
with:
egress-policy: audit
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Set up JDK 25
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
- name: Restore cache Gradle User Home
if: inputs.use_shared_cache
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
java-version: "25"
distribution: "temurin"
- name: Cache Gradle User Home
path: |
~/.gradle/caches
~/.gradle/wrapper
key: gradle-v1-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
- name: Restore cache Gradle
if: inputs.use_shared_cache == false
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
restore-keys: |
gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-
gradle-${{ runner.os }}-${{ runner.arch }}-
key: gradle-playwright-e2e-v1-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
- name: Set up JDK 25
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
with:
java-version: "25"
distribution: "temurin"
- name: Set up Node.js
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
@@ -306,9 +324,18 @@ jobs:
path: frontend/playwright-report/
retention-days: 7
- name: Cleanup temporary files
if: always()
run: |
rm -f /tmp/helpers.sh /tmp/backend.log /tmp/backend.pid
continue-on-error: true
# Multi-node regression: builds + seeds the clustered stack (testing/compose/docker-compose-multinode.yml)
# and runs behave features/multinode. Licence-gated, so it runs after the Playwright job (not in parallel).
multinode-e2e:
environment:
name: ci-unsigned
deployment: false
needs: [pick, playwright-e2e-enterprise]
# Nightly cron + manual dispatch only (heavy build), fork-gated for the licence secret.
if: >-
@@ -324,7 +351,7 @@ jobs:
MN_COMPOSE: docker-compose-multinode.yml
steps:
- name: Harden Runner
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
with:
egress-policy: audit
- name: Checkout repository
+32 -39
View File
@@ -37,6 +37,7 @@ jobs:
timeout-minutes: 3
outputs:
build: ${{ steps.changes.outputs.build }}
backend: ${{ steps.changes.outputs.backend }}
project: ${{ steps.changes.outputs.project }}
openapi: ${{ steps.changes.outputs.openapi }}
frontend: ${{ steps.changes.outputs.frontend }}
@@ -48,54 +49,25 @@ jobs:
proprietary: ${{ steps.changes.outputs.proprietary }}
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
with:
egress-policy: audit
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Check for file changes
uses: dorny/paths-filter@7b450fff21473bca461d4b92ce414b9d0420d706 # v4.0.2
uses: dorny/paths-filter@ceb8a2b8f2d89434be7ff52d3de7ec3738c5cc9d # v4.0.3
id: changes
with:
filters: .github/config/.files.yaml
gradle-cache-prime:
name: Prime shared Gradle cache
needs: [files-changed]
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
with:
egress-policy: audit
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Set up JDK 25
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
with:
java-version: "25"
distribution: "temurin"
- name: Cache Gradle User Home
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
restore-keys: |
gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-
gradle-${{ runner.os }}-${{ runner.arch }}-
- name: Resolve backend dependencies
run: ./gradlew :stirling-pdf:classes -PnoSpotless --no-daemon
env:
STIRLING_FLAVOR: saas
MAVEN_USER: ${{ secrets.MAVEN_USER }}
MAVEN_PASSWORD: ${{ secrets.MAVEN_PASSWORD }}
MAVEN_PUBLIC_URL: ${{ secrets.MAVEN_PUBLIC_URL }}
uses: ./.github/workflows/gradle-cache-prime.yml
secrets: inherit
build:
if: needs.files-changed.outputs.backend == 'true'
needs: [files-changed, gradle-cache-prime]
permissions:
actions: read
@@ -169,10 +141,12 @@ jobs:
contents: read
uses: ./.github/workflows/build-enterprise.yml
secrets: inherit
with:
use_shared_cache: true
check-licence:
if: needs.files-changed.outputs.build == 'true'
needs: [files-changed, build, gradle-cache-prime]
needs: [files-changed, gradle-cache-prime]
permissions:
contents: read
uses: ./.github/workflows/check-licence.yml
@@ -191,8 +165,22 @@ jobs:
docker-base-changed: ${{ needs.files-changed.outputs.docker-base }}
test-build-docker-images:
if: github.event_name == 'pull_request' && needs.files-changed.outputs.project == 'true'
needs: [files-changed, build, check-generateOpenApiDocs, check-licence, gradle-cache-prime]
if: |
always() &&
github.event_name == 'pull_request' &&
needs.files-changed.outputs.project == 'true' &&
contains(fromJSON('["success", "skipped"]'), needs.gradle-cache-prime.result) &&
contains(fromJSON('["success", "skipped"]'), needs.build.result) &&
contains(fromJSON('["success", "skipped"]'), needs.check-generateOpenApiDocs.result) &&
contains(fromJSON('["success", "skipped"]'), needs.check-licence.result)
needs:
[
files-changed,
build,
check-generateOpenApiDocs,
check-licence,
gradle-cache-prime,
]
permissions:
contents: read
packages: read
@@ -204,7 +192,7 @@ jobs:
tauri-build:
if: needs.files-changed.outputs.tauri == 'true'
needs: [files-changed]
needs: [files-changed, gradle-cache-prime]
permissions:
contents: read
pull-requests: write
@@ -218,6 +206,7 @@ jobs:
with:
platform: windows-macos
sign: true
use_shared_cache: true
ai-engine:
if: needs.files-changed.outputs.engine == 'true'
@@ -241,6 +230,8 @@ jobs:
pull-requests: write
uses: ./.github/workflows/check-generated-models.yml
secrets: inherit
with:
use_shared_cache: true
pre-commit:
needs: [files-changed]
@@ -287,6 +278,7 @@ jobs:
if: always()
needs:
- files-changed
- gradle-cache-prime
- build
- db-migration-test
- check-generateOpenApiDocs
@@ -306,7 +298,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
with:
egress-policy: audit
@@ -314,6 +306,7 @@ jobs:
env:
RESULTS: |
files-changed=${{ needs.files-changed.result }}
gradle-cache-prime=${{ needs.gradle-cache-prime.result }}
build=${{ needs.build.result }}
db-migration-test=${{ needs.db-migration-test.result }}
check-generateOpenApiDocs=${{ needs.check-generateOpenApiDocs.result }}
+22 -10
View File
@@ -9,6 +9,11 @@ name: Check generated models
# post-merge safety net.
on:
workflow_call:
inputs:
use_shared_cache:
required: false
type: boolean
default: false
push:
branches: [main]
@@ -23,7 +28,7 @@ jobs:
pull-requests: write
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
with:
egress-policy: audit
@@ -39,22 +44,29 @@ jobs:
engine/uv.lock
cache-suffix: generated-models
- name: Set up JDK 25
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
- name: Restore cache Gradle User Home
if: inputs.use_shared_cache
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
java-version: "25"
distribution: "temurin"
path: |
~/.gradle/caches
~/.gradle/wrapper
key: gradle-v1-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
- name: Cache Gradle User Home
- name: Restore cache Gradle
if: inputs.use_shared_cache == false
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
restore-keys: |
gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-
gradle-${{ runner.os }}-${{ runner.arch }}-
key: gradle-generated-models-v1-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
- name: Set up JDK 25
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
with:
java-version: "25"
distribution: "temurin"
- name: Set up Node
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
+12 -12
View File
@@ -10,33 +10,33 @@ permissions:
jobs:
check-licence:
environment:
name: ci-unsigned
deployment: false
runs-on: ubuntu-latest
steps:
- name: Harden Runner
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
with:
egress-policy: audit
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Restore cache Gradle User Home
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: gradle-v1-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
- name: Set up JDK 25
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
with:
java-version: "25"
distribution: "temurin"
- name: Cache Gradle User Home
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
restore-keys: |
gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-
gradle-${{ runner.os }}-${{ runner.arch }}-
- name: Install Task
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
- name: Check licenses for compatibility
+12 -12
View File
@@ -11,33 +11,33 @@ permissions:
jobs:
check-generate-openapi-docs:
environment:
name: ci-unsigned
deployment: false
runs-on: ubuntu-latest
steps:
- name: Harden Runner
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
with:
egress-policy: audit
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Restore cache Gradle User Home
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: gradle-v1-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
- name: Set up JDK 25
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
with:
java-version: "25"
distribution: "temurin"
- name: Cache Gradle User Home
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
restore-keys: |
gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-
gradle-${{ runner.os }}-${{ runner.arch }}-
- name: Install Task
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
- name: Generate OpenAPI documentation
+2 -2
View File
@@ -28,7 +28,7 @@ jobs:
pull-requests: write # Allow writing to pull requests
steps:
- name: Harden Runner
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
with:
egress-policy: audit
@@ -125,7 +125,7 @@ jobs:
const changedFiles = files
.filter(file =>
file.status !== "removed" &&
/^frontend\/public\/locales\/[a-zA-Z-]+\/translation\.toml$/.test(file.filename)
/^frontend\/editor\/public\/locales\/[a-zA-Z-]+\/translation\.toml$/.test(file.filename)
)
.map(file => file.filename);
+9 -12
View File
@@ -34,29 +34,26 @@ jobs:
timeout-minutes: 15
steps:
- name: Harden Runner
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
with:
egress-policy: audit
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Restore cache Gradle User Home
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: gradle-v1-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
- name: Set up JDK 25
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
with:
java-version: "25"
distribution: "temurin"
- name: Cache Gradle User Home
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
restore-keys: |
gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-
gradle-${{ runner.os }}-${{ runner.arch }}-
- name: Install uv
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
with:
+17 -12
View File
@@ -13,34 +13,34 @@ permissions:
jobs:
migration-test:
environment:
name: ci-unsigned
deployment: false
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- name: Harden Runner
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
with:
egress-policy: audit
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Restore cache Gradle User Home
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: gradle-v1-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
- name: Set up JDK 25
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
with:
java-version: 25
distribution: temurin
- name: Cache Gradle User Home
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
restore-keys: |
gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-
gradle-${{ runner.os }}-${{ runner.arch }}-
# Keep the normal formatting path here so this smoke test exercises the
# same Gradle configuration as the backend build.
- name: Build Stirling-PDF JAR
@@ -81,3 +81,8 @@ jobs:
path: /tmp/stirling-migration-failed-*/app.log
retention-days: 7
if-no-files-found: warn
- name: Cleanup temporary files
if: always()
run: rm -rf /tmp/stirling-migration-failed-*
continue-on-error: true
+1 -1
View File
@@ -14,7 +14,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Harden Runner
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
with:
egress-policy: audit
-209
View File
@@ -1,209 +0,0 @@
name: Auto V2 Deploy on Push
on:
push:
branches:
- V2
- deploy-on-v2-commit
permissions:
contents: read
jobs:
deploy-v2-on-push:
environment: pr-preview
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
concurrency:
group: deploy-v2-push-V2
cancel-in-progress: true
steps:
- name: Harden Runner
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
with:
egress-policy: audit
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
- name: Get commit hashes for frontend and backend
id: commit-hashes
run: |
# Get last commit that touched the frontend folder, docker/frontend, or docker/compose
FRONTEND_HASH=$(git log -1 --format="%H" -- frontend/ docker/frontend/ docker/compose/ 2>/dev/null || echo "")
if [ -z "$FRONTEND_HASH" ]; then
FRONTEND_HASH="no-frontend-changes"
fi
# Get last commit that touched backend code, docker/backend, or docker/compose
BACKEND_HASH=$(git log -1 --format="%H" -- app/ docker/backend/ docker/compose/ 2>/dev/null || echo "")
if [ -z "$BACKEND_HASH" ]; then
BACKEND_HASH="no-backend-changes"
fi
echo "Frontend hash: $FRONTEND_HASH"
echo "Backend hash: $BACKEND_HASH"
echo "frontend_hash=$FRONTEND_HASH" >> $GITHUB_OUTPUT
echo "backend_hash=$BACKEND_HASH" >> $GITHUB_OUTPUT
# Short hashes for tags
if [ "$FRONTEND_HASH" = "no-frontend-changes" ]; then
echo "frontend_short=no-frontend" >> $GITHUB_OUTPUT
else
echo "frontend_short=${FRONTEND_HASH:0:8}" >> $GITHUB_OUTPUT
fi
if [ "$BACKEND_HASH" = "no-backend-changes" ]; then
echo "backend_short=no-backend" >> $GITHUB_OUTPUT
else
echo "backend_short=${BACKEND_HASH:0:8}" >> $GITHUB_OUTPUT
fi
- name: Convert repository owner to lowercase
id: repoowner
run: echo "lowercase=$(echo ${{ github.repository_owner }} | awk '{print tolower($0)}')" >> $GITHUB_OUTPUT
- 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: Check if frontend image exists
id: check-frontend
run: |
if docker manifest inspect ${IMAGE_BASE}:v2-frontend-${{ steps.commit-hashes.outputs.frontend_short }} >/dev/null 2>&1; then
echo "exists=true" >> $GITHUB_OUTPUT
echo "Frontend image already exists, skipping build"
else
echo "exists=false" >> $GITHUB_OUTPUT
echo "Frontend image needs to be built"
fi
env:
IMAGE_BASE: ghcr.io/${{ steps.repoowner.outputs.lowercase }}/stirling-pdf-test
- name: Check if backend image exists
id: check-backend
run: |
if docker manifest inspect ${IMAGE_BASE}:v2-backend-${{ steps.commit-hashes.outputs.backend_short }} >/dev/null 2>&1; then
echo "exists=true" >> $GITHUB_OUTPUT
echo "Backend image already exists, skipping build"
else
echo "exists=false" >> $GITHUB_OUTPUT
echo "Backend image needs to be built"
fi
env:
IMAGE_BASE: ghcr.io/${{ steps.repoowner.outputs.lowercase }}/stirling-pdf-test
- name: Build and push frontend image
if: steps.check-frontend.outputs.exists == 'false'
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
with:
context: .
file: ./docker/frontend/Dockerfile
push: true
cache-from: type=gha,scope=stirling-v2-frontend
cache-to: type=gha,mode=max,scope=stirling-v2-frontend
tags: |
ghcr.io/${{ steps.repoowner.outputs.lowercase }}/stirling-pdf-test:v2-frontend-${{ steps.commit-hashes.outputs.frontend_short }}
ghcr.io/${{ steps.repoowner.outputs.lowercase }}/stirling-pdf-test:v2-frontend-latest
build-args: VERSION_TAG=v2-alpha
platforms: linux/amd64
- name: Build and push backend image
if: steps.check-backend.outputs.exists == 'false'
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
with:
context: .
file: ./docker/backend/Dockerfile
push: true
cache-from: type=gha,scope=stirling-v2-backend
cache-to: type=gha,mode=max,scope=stirling-v2-backend
tags: |
ghcr.io/${{ steps.repoowner.outputs.lowercase }}/stirling-pdf-test:v2-backend-${{ steps.commit-hashes.outputs.backend_short }}
ghcr.io/${{ steps.repoowner.outputs.lowercase }}/stirling-pdf-test:v2-backend-latest
build-args: VERSION_TAG=v2-alpha
platforms: linux/amd64
- name: Set up SSH
run: |
mkdir -p ~/.ssh/
echo "${NEW_VPS_SSH_KEY}" > ../private.key
chmod 600 ../private.key
env:
NEW_VPS_SSH_KEY: ${{ secrets.NEW_VPS_SSH_KEY }}
- name: Deploy to VPS on port 3000
run: |
export UNIQUE_NAME=docker-compose-v2-$GITHUB_RUN_ID.yml
cat > $UNIQUE_NAME << EOF
version: '3.3'
services:
backend:
container_name: stirling-v2-backend
image: ${IMAGE_BASE}:v2-backend-${{ steps.commit-hashes.outputs.backend_short }}
ports:
- "13000:8080"
volumes:
- /stirling/V2/data:/usr/share/tessdata:rw
- /stirling/V2/config:/configs:rw
- /stirling/V2/logs:/logs:rw
environment:
DISABLE_ADDITIONAL_FEATURES: "true"
SECURITY_ENABLELOGIN: "false"
SYSTEM_DEFAULTLOCALE: en-US
UI_APPNAME: "Stirling-PDF V2"
UI_HOMEDESCRIPTION: "V2 Frontend/Backend Split"
UI_APPNAMENAVBAR: "V2 Deployment"
SYSTEM_MAXFILESIZE: "100"
METRICS_ENABLED: "true"
SYSTEM_GOOGLEVISIBILITY: "false"
SWAGGER_SERVER_URL: "https://demo.stirlingpdf.cloud"
baseUrl: "https://demo.stirlingpdf.cloud"
restart: on-failure:5
frontend:
container_name: stirling-v2-frontend
image: ${IMAGE_BASE}:v2-frontend-${{ steps.commit-hashes.outputs.frontend_short }}
ports:
- "3000:80"
environment:
VITE_API_BASE_URL: "http://${NEW_VPS_HOST}:13000"
depends_on:
- backend
restart: on-failure:5
EOF
# Copy to remote with unique name
scp -i ../private.key -o StrictHostKeyChecking=no $UNIQUE_NAME ${NEW_VPS_USERNAME}@${NEW_VPS_HOST}:/tmp/$UNIQUE_NAME
# SSH and rename/move atomically to avoid interference
ssh -i ../private.key -o StrictHostKeyChecking=no ${NEW_VPS_USERNAME}@${NEW_VPS_HOST} << ENDSSH
mkdir -p /stirling/V2/{data,config,logs}
mv /tmp/$UNIQUE_NAME /stirling/V2/docker-compose.yml
cd /stirling/V2
docker-compose down || true
docker-compose pull
docker-compose up -d
docker system prune -af --volumes || true
docker image prune -af --filter "until=336h" --filter "label!=keep=true" || true
ENDSSH
env:
IMAGE_BASE: ghcr.io/${{ steps.repoowner.outputs.lowercase }}/stirling-pdf-test
NEW_VPS_HOST: ${{ secrets.NEW_VPS_HOST }}
NEW_VPS_USERNAME: ${{ secrets.NEW_VPS_USERNAME }}
- name: Cleanup temporary files
if: always()
run: |
rm -f ../private.key
+13 -13
View File
@@ -17,6 +17,9 @@ permissions:
jobs:
docker-compose-tests:
environment:
name: ci-unsigned
deployment: false
runs-on: ubuntu-latest
permissions:
actions: write
@@ -25,30 +28,27 @@ jobs:
steps:
- name: Harden Runner
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
with:
egress-policy: audit
- name: Checkout Repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Restore cache Gradle User Home
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: gradle-v1-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
- name: Set up JDK 25
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
with:
java-version: "25"
distribution: "temurin"
- name: Cache Gradle User Home
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
restore-keys: |
gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-
gradle-${{ runner.os }}-${{ runner.arch }}-
# When the PR changes the base image, test.sh builds it locally
# (stirling-pdf-base:local) into the daemon image store. A buildx
# container builder can't see that store, so skip it here and let
@@ -66,7 +66,7 @@ jobs:
- name: Install Docker Compose
run: |
sudo curl -SL "https://github.com/docker/compose/releases/download/v2.39.4/docker-compose-$(uname -s)-$(uname -m)" -o /usr/local/bin/docker-compose
sudo curl -SL "https://github.com/docker/compose/releases/download/v5.4.0/docker-compose-$(uname -s)-$(uname -m)" -o /usr/local/bin/docker-compose
sudo chmod +x /usr/local/bin/docker-compose
- name: Install uv
+14 -29
View File
@@ -11,48 +11,33 @@ permissions:
jobs:
playwright-e2e-live:
environment:
name: ci-unsigned
deployment: false
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- name: Harden Runner
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
with:
egress-policy: audit
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Restore cache Gradle User Home
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: gradle-v1-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
- name: Set up JDK 25
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
with:
java-version: "25"
distribution: "temurin"
- name: Cache Gradle User Home
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
restore-keys: |
gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-
gradle-${{ runner.os }}-${{ runner.arch }}-
# Gradle does not retry 429s, and a cold cache resolving the buildscript
# classpath is exactly where Maven Central rate-limits us. Retry it here,
# where a failure is cheap, instead of inside the backgrounded bootRun.
- name: Prime Gradle dependencies
env:
MAVEN_USER: ${{ secrets.MAVEN_USER }}
MAVEN_PASSWORD: ${{ secrets.MAVEN_PASSWORD }}
MAVEN_PUBLIC_URL: ${{ secrets.MAVEN_PUBLIC_URL }}
run: |
for attempt in 1 2 3; do
if ./gradlew --quiet -PnoSpotless :stirling-pdf:classes; then
exit 0
fi
echo "::warning::Gradle dependency resolution failed (attempt $attempt of 3)"
sleep $((attempt * 30))
done
echo "::error::Gradle could not resolve dependencies after 3 attempts"
exit 1
- name: Set up Node.js
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
+16 -3
View File
@@ -14,6 +14,11 @@ jobs:
playwright-e2e:
name: playwright-e2e (${{ matrix.browser }})
runs-on: ubuntu-latest
# The image already contains the Playwright browsers and all Linux
# dependencies. This keeps the matrix for per-browser reporting while
# avoiding three concurrent `playwright install --with-deps` runs.
container:
image: mcr.microsoft.com/playwright:v1.58.2-noble@sha256:6446946a1d9fd62d9ae501312a2d76a43ee688542b21622056a372959b65d63d
strategy:
# One browser breaking must not mask a failure in another - report all.
fail-fast: false
@@ -27,7 +32,7 @@ jobs:
project: stubbed-webkit
steps:
- name: Harden Runner
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
with:
egress-policy: audit
- name: Checkout repository
@@ -40,15 +45,23 @@ jobs:
cache-dependency-path: frontend/package-lock.json
- name: Install Task
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
- name: Install Playwright (${{ matrix.browser }})
run: task e2e:install -- ${{ matrix.browser }}
- name: Build frontend (production bundle for vite preview)
env:
VITE_BUILD_FOR_PREVIEW: "1"
run: task frontend:build
- name: Run stubbed E2E tests (${{ matrix.browser }})
env:
# The official Playwright image expects its browser runtime under
# the root home directory. Keep this scoped to Playwright and use a
# neutral Docker config path so Docker does not read /root/.docker.
HOME: /root
DOCKER_CONFIG: /tmp/playwright-docker-config
PLAYWRIGHT_JSON_OUTPUT_FILE: ${{ github.workspace }}/frontend/playwright-report/results.json
NPM_CONFIG_PREFER_OFFLINE: "true"
NPM_CONFIG_FETCH_RETRIES: "5"
NPM_CONFIG_FETCH_RETRY_FACTOR: "2"
NPM_CONFIG_FETCH_RETRY_MINTIMEOUT: "1000"
NPM_CONFIG_FETCH_RETRY_MAXTIMEOUT: "120000"
run: task e2e:stubbed-project PROJECT=${{ matrix.project }} -- --workers=3
- name: Flag flaky tests
# Runs regardless of the test outcome: a flaky test (passed on retry)
+1 -1
View File
@@ -21,7 +21,7 @@ jobs:
timeout-minutes: 25
steps:
- name: Harden Runner
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
with:
egress-policy: audit
- name: Checkout repository
@@ -28,7 +28,7 @@ jobs:
licenses-backend: ${{ steps.changes.outputs.licenses-backend }}
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
with:
egress-policy: audit
@@ -36,12 +36,16 @@ jobs:
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Check for file changes
uses: dorny/paths-filter@7b450fff21473bca461d4b92ce414b9d0420d706 # v4.0.2
uses: dorny/paths-filter@ceb8a2b8f2d89434be7ff52d3de7ec3738c5cc9d # v4.0.3
id: changes
with:
filters: .github/config/.files.yaml
generate-frontend-license-report:
# ci-bot, not bot-identity: this job runs on PRs too, and bot-identity is main-only.
environment:
name: ci-bot
deployment: false
if: needs.files-changed.outputs.licenses-frontend == 'true'
name: Generate Frontend License Report
needs: files-changed
@@ -52,7 +56,7 @@ jobs:
repository-projects: write # Required for enabling automerge
steps:
- name: Harden Runner
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
with:
egress-policy: audit
@@ -316,6 +320,10 @@ jobs:
GH_TOKEN: ${{ steps.setup-bot.outputs.token }}
generate-backend-license-report:
# ci-bot, not bot-identity: this job runs on PRs too, and bot-identity is main-only.
environment:
name: ci-bot
deployment: false
if: needs.files-changed.outputs.licenses-backend == 'true'
needs: files-changed
name: Generate Backend License Report
@@ -326,7 +334,7 @@ jobs:
repository-projects: write # Required for enabling automerge
steps:
- name: Harden Runner
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
with:
egress-policy: audit
@@ -344,22 +352,19 @@ jobs:
app-id: ${{ secrets.GH_APP_ID }}
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
- name: Set up JDK 25
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
with:
java-version: "25"
distribution: "temurin"
- name: Cache Gradle User Home
- name: Cache Gradle
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
restore-keys: |
gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-
gradle-${{ runner.os }}-${{ runner.arch }}-
key: gradle-license-report-v1-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
- name: Set up JDK 25
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
with:
java-version: "25"
distribution: "temurin"
- name: Install Task
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
+1 -1
View File
@@ -15,7 +15,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Harden Runner
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
with:
egress-policy: audit
- name: Checkout repository
+66
View File
@@ -0,0 +1,66 @@
name: Prime Gradle Cache
on:
workflow_call:
push:
branches: ["main"]
permissions:
contents: read
jobs:
gradle-cache-prime:
environment:
name: ci-unsigned
deployment: false
name: Prime shared Gradle cache
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
with:
egress-policy: audit
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Calculate Gradle cache key
id: gradle-cache-key
shell: bash
run: |
echo "key=gradle-v1-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}" >> "$GITHUB_OUTPUT"
- name: Cache Gradle (lookup-only)
id: cache-gradle-restore
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: ${{ steps.gradle-cache-key.outputs.key }}
lookup-only: true
- name: Set up JDK 25
if: steps.cache-gradle-restore.outputs.cache-hit != 'true'
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
with:
java-version: "25"
distribution: "temurin"
- name: Resolve backend dependencies
if: steps.cache-gradle-restore.outputs.cache-hit != 'true'
run: ./gradlew :stirling-pdf:classes --no-daemon
env:
STIRLING_FLAVOR: saas
MAVEN_USER: ${{ secrets.MAVEN_USER }}
MAVEN_PASSWORD: ${{ secrets.MAVEN_PASSWORD }}
MAVEN_PUBLIC_URL: ${{ secrets.MAVEN_PUBLIC_URL }}
- name: Save cache Gradle User Home
if: steps.cache-gradle-restore.outputs.cache-hit != 'true'
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: ${{ steps.gradle-cache-key.outputs.key }}
+1 -1
View File
@@ -15,7 +15,7 @@ jobs:
issues: write
steps:
- name: Harden Runner
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
with:
egress-policy: audit
+59 -84
View File
@@ -38,6 +38,9 @@ permissions:
jobs:
determine-matrix:
environment:
name: ci-unsigned
deployment: false
if: ${{ vars.CI_PROFILE != 'lite' }}
runs-on: ubuntu-latest
outputs:
@@ -45,29 +48,26 @@ jobs:
version: ${{ steps.versionNumber.outputs.versionNumber }}
steps:
- name: Harden Runner
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
with:
egress-policy: audit
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Cache Gradle
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: gradle-tauri-releases-v1-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
- name: Set up JDK 25
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
with:
java-version: "25"
distribution: "temurin"
- name: Cache Gradle User Home
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
restore-keys: |
gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-
gradle-${{ runner.os }}-${{ runner.arch }}-
- name: Install Task
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
- name: Get version number
@@ -118,6 +118,9 @@ jobs:
env:
INPUT_PLATFORM: ${{ github.event.inputs.platform }}
build-jars:
environment:
name: ci-unsigned
deployment: false
needs: determine-matrix
runs-on: ubuntu-latest
strategy:
@@ -137,29 +140,26 @@ jobs:
file_suffix: "-server"
steps:
- name: Harden Runner
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
with:
egress-policy: audit
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Cache Gradle
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: gradle-tauri-releases-v1-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
- name: Set up JDK 25
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
with:
java-version: "25"
distribution: "temurin"
- name: Cache Gradle User Home
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
restore-keys: |
gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-
gradle-${{ runner.os }}-${{ runner.arch }}-
- name: Setup Node.js
if: matrix.variant.build_frontend == true
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
@@ -204,11 +204,10 @@ jobs:
runs-on: ${{ matrix.platform }}
env:
SM_API_KEY: ${{ secrets.SM_API_KEY }}
WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }}
RELEASE_GPG_PRIVATE_KEY: ${{ secrets.RELEASE_GPG_PRIVATE_KEY }}
steps:
- name: Harden Runner
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
with:
egress-policy: audit
allowed-endpoints: >
@@ -237,6 +236,14 @@ jobs:
toolchain: stable
targets: ${{ matrix.platform == 'macos-15' && 'aarch64-apple-darwin,x86_64-apple-darwin' || '' }}
- name: Cache Gradle
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: gradle-tauri-releases-v1-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
# x86_64 JDK is set up first so the aarch64 step below can leave its
# JAVA_HOME as the active one. The macOS universal JRE build needs
# jmods from both arches; the x64 path is captured into the env
@@ -260,17 +267,6 @@ jobs:
java-version: "25"
distribution: ${{ matrix.platform == 'windows-11-arm' && 'microsoft' || 'temurin' }}
- name: Cache Gradle User Home
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
restore-keys: |
gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-
gradle-${{ runner.os }}-${{ runner.arch }}-
- name: Install Task
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
@@ -295,7 +291,7 @@ jobs:
# DigiCert KeyLocker Setup (Cloud HSM)
- name: Setup DigiCert KeyLocker
id: digicert-setup
if: ${{ startsWith(matrix.platform, 'windows') && env.SM_API_KEY != '' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/V2-master') }}
if: ${{ startsWith(matrix.platform, 'windows') && env.SM_API_KEY != '' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/release') }}
uses: digicert/ssm-code-signing@1d820463733701cf1484c7eb5d7d24a15ca2c454 # v1.2.1
env:
SM_API_KEY: ${{ secrets.SM_API_KEY }}
@@ -305,7 +301,7 @@ jobs:
SM_HOST: ${{ secrets.SM_HOST }}
- name: Setup DigiCert KeyLocker Certificate
if: ${{ startsWith(matrix.platform, 'windows') && env.SM_API_KEY != '' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/V2-master') }}
if: ${{ startsWith(matrix.platform, 'windows') && env.SM_API_KEY != '' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/release') }}
shell: pwsh
run: |
Write-Host "Setting up DigiCert KeyLocker environment..."
@@ -344,40 +340,8 @@ jobs:
SM_API_KEY: ${{ secrets.SM_API_KEY }}
SM_CLIENT_CERT_PASSWORD: ${{ secrets.SM_CLIENT_CERT_PASSWORD }}
SM_KEYPAIR_ALIAS: ${{ secrets.SM_KEYPAIR_ALIAS }}
# Traditional PFX Certificate Import (fallback if KeyLocker not configured)
- name: Import Windows Code Signing Certificate
if: ${{ startsWith(matrix.platform, 'windows') && env.SM_API_KEY == '' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/V2-master') }}
env:
WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }}
WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }}
shell: powershell
run: |
if ($env:WINDOWS_CERTIFICATE) {
Write-Host "Importing Windows Code Signing Certificate..."
# Decode base64 certificate and save to file
$certBytes = [Convert]::FromBase64String($env:WINDOWS_CERTIFICATE)
$certPath = Join-Path $env:RUNNER_TEMP "certificate.pfx"
[IO.File]::WriteAllBytes($certPath, $certBytes)
# Import certificate to CurrentUser\My store
$cert = Import-PfxCertificate -FilePath $certPath -CertStoreLocation Cert:\CurrentUser\My -Password (ConvertTo-SecureString -String $env:WINDOWS_CERTIFICATE_PASSWORD -AsPlainText -Force)
# Extract and set thumbprint as environment variable
$thumbprint = $cert.Thumbprint
Write-Host "Certificate imported with thumbprint: $thumbprint"
echo "WINDOWS_CERTIFICATE_THUMBPRINT=$thumbprint" >> $env:GITHUB_ENV
# Clean up certificate file
Remove-Item $certPath
Write-Host "Windows certificate import completed."
} else {
Write-Host "⚠️ WINDOWS_CERTIFICATE secret not set - building unsigned binary"
}
- name: Import Apple Developer Certificate
if: matrix.platform == 'macos-15' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/V2-master')
if: matrix.platform == 'macos-15' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/release')
env:
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
@@ -398,7 +362,7 @@ jobs:
rm certificate.p12
- name: Verify Certificate
if: matrix.platform == 'macos-15' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/V2-master')
if: matrix.platform == 'macos-15' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/release')
run: |
echo "Verifying Apple Developer Certificate..."
KEYCHAIN_PATH=$RUNNER_TEMP/app-signing.keychain-db
@@ -414,7 +378,7 @@ jobs:
# Without this, signCommand failures are opaque (Tauri captures but drops
# smctl's stderr) - running these loudly surfaces auth/env/keypair issues.
- name: Preflight smctl
if: ${{ startsWith(matrix.platform, 'windows') && env.SM_API_KEY != '' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/V2-master') }}
if: ${{ startsWith(matrix.platform, 'windows') && env.SM_API_KEY != '' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/release') }}
shell: pwsh
env:
KEYPAIR_ALIAS: ${{ secrets.SM_KEYPAIR_ALIAS }}
@@ -445,7 +409,7 @@ jobs:
# smctl reads SM_HOST, SM_API_KEY, SM_CLIENT_CERT_FILE, SM_CLIENT_CERT_PASSWORD
# from env (set by prior DigiCert setup step). No --config-file needed.
- name: Configure Windows code signing
if: ${{ startsWith(matrix.platform, 'windows') && env.SM_API_KEY != '' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/V2-master') }}
if: ${{ startsWith(matrix.platform, 'windows') && env.SM_API_KEY != '' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/release') }}
shell: bash
env:
KEYPAIR_ALIAS: ${{ secrets.SM_KEYPAIR_ALIAS }}
@@ -466,7 +430,7 @@ jobs:
sed "s/${KEYPAIR_ALIAS}/***/g" ./frontend/editor/src-tauri/tauri.windows.conf.json
- name: Import release GPG signing key (Linux)
if: matrix.platform == 'ubuntu-22.04' && env.RELEASE_GPG_PRIVATE_KEY != '' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/V2-master')
if: matrix.platform == 'ubuntu-22.04' && env.RELEASE_GPG_PRIVATE_KEY != '' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/release')
run: |
echo "$RELEASE_GPG_PRIVATE_KEY" | gpg --batch --import
gpg --list-secret-keys --keyid-format=long
@@ -498,8 +462,8 @@ jobs:
# APPIMAGETOOL_SIGN_PASSPHRASE appimagetool uses this to unlock the GPG key non-interactively
# SIGN_KEY appimagetool picks the key matching this fingerprint
# Without SIGN=1, the other two are ignored and the AppImage is built unsigned even if a key is present.
# Mirror the Windows/macOS gate: only sign on a real release/dispatch+sign or V2-master, when secret is present.
SIGN: ${{ (env.RELEASE_GPG_PRIVATE_KEY != '' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/V2-master')) && '1' || '0' }}
# Mirror the Windows/macOS gate: only sign on a real release/dispatch+sign or the release branch, when secret is present.
SIGN: ${{ (env.RELEASE_GPG_PRIVATE_KEY != '' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/release')) && '1' || '0' }}
APPIMAGETOOL_SIGN_PASSPHRASE: ${{ secrets.RELEASE_GPG_PASSPHRASE }}
SIGN_KEY: ${{ vars.RELEASE_GPG_FINGERPRINT }}
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
@@ -525,7 +489,7 @@ jobs:
env:
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
GPG_SIGN: ${{ (env.RELEASE_GPG_PRIVATE_KEY != '' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/V2-master')) && '1' || '0' }}
GPG_SIGN: ${{ (env.RELEASE_GPG_PRIVATE_KEY != '' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/release')) && '1' || '0' }}
SIGN_KEY: ${{ vars.RELEASE_GPG_FINGERPRINT }}
APPIMAGETOOL_SIGN_PASSPHRASE: ${{ secrets.RELEASE_GPG_PASSPHRASE }}
run: |
@@ -564,7 +528,7 @@ jobs:
echo "Stripped bundled libwayland from $(basename "$AI")"
- name: Clear release GPG key from runner keyring (Linux)
if: always() && matrix.platform == 'ubuntu-22.04' && env.RELEASE_GPG_PRIVATE_KEY != '' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/V2-master')
if: always() && matrix.platform == 'ubuntu-22.04' && env.RELEASE_GPG_PRIVATE_KEY != '' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/release')
env:
RELEASE_GPG_FINGERPRINT: ${{ vars.RELEASE_GPG_FINGERPRINT }}
run: |
@@ -579,7 +543,7 @@ jobs:
# artifact. Tauri signs a COPY when bundling into the MSI and leaves the raw
# cargo output unsigned, so checking it produces false negatives.
- name: Verify Windows Code Signature
if: ${{ startsWith(matrix.platform, 'windows') && env.SM_API_KEY != '' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/V2-master') }}
if: ${{ startsWith(matrix.platform, 'windows') && env.SM_API_KEY != '' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/release') }}
timeout-minutes: 15
shell: pwsh
run: |
@@ -733,6 +697,17 @@ jobs:
path: ./dist/*
retention-days: 1
- name: Cleanup temporary files
if: always()
shell: bash
run: |
rm -f certificate.p12
rm -rf "$RUNNER_TEMP/msi-verify"
if [ "${{ matrix.platform }}" = "macos-15" ]; then
security delete-keychain "$RUNNER_TEMP/app-signing.keychain-db" 2>/dev/null || true
fi
continue-on-error: true
collect-and-release:
needs: [determine-matrix, build, build-jars]
runs-on: ubuntu-latest
@@ -740,7 +715,7 @@ jobs:
contents: write
steps:
- name: Harden Runner
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
with:
egress-policy: audit
@@ -911,11 +886,11 @@ jobs:
# workflow_dispatch path requires platform=='all' so a single-platform
# dispatch can't overwrite an existing release's full latest.json with a
# partial one (action-gh-release defaults overwrite_files:true).
# release / V2-master always build the full matrix so no extra guard needed.
# release event / release branch always build the full matrix so no extra guard needed.
# fail_on_unmatched_files makes a missing latest.json or installer fail loudly
# instead of silently shipping a broken auto-update.
- name: Upload binaries to Release
if: (github.event_name == 'workflow_dispatch' && github.event.inputs.test_mode != 'true' && github.event.inputs.platform == 'all') || github.event_name == 'release' || github.ref == 'refs/heads/V2-master'
if: (github.event_name == 'workflow_dispatch' && github.event.inputs.test_mode != 'true' && github.event.inputs.platform == 'all') || github.event_name == 'release' || github.ref == 'refs/heads/release'
uses: softprops/action-gh-release@3d0d9888cb7fd7b750713d6e236d1fcb99157228 # v3.0.2
with:
tag_name: v${{ needs.determine-matrix.outputs.version }}
+7 -4
View File
@@ -23,7 +23,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
with:
egress-policy: audit
@@ -74,7 +74,7 @@ jobs:
timeout-minutes: 60
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
with:
egress-policy: audit
@@ -127,6 +127,9 @@ jobs:
# Runs the @nightly tag (conversion scenarios) plus a 10-shard concurrency run
# of every other feature.
cucumber-nightly:
environment:
name: ci-unsigned
deployment: false
name: Cucumber (nightly scenarios + full concurrency)
runs-on: ubuntu-latest
# Fork pull requests get no MAVEN_* secrets, so the image build cannot work.
@@ -137,7 +140,7 @@ jobs:
contents: read
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
with:
egress-policy: audit
@@ -145,7 +148,7 @@ jobs:
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Set up JDK 25
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
with:
java-version: "25"
distribution: "temurin"
+2 -2
View File
@@ -28,7 +28,7 @@ jobs:
jar_sha256: ${{ steps.hashes.outputs.jar_sha256 }}
steps:
- name: Harden Runner
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
with:
egress-policy: audit
@@ -80,7 +80,7 @@ jobs:
contents: write
steps:
- name: Harden Runner
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
with:
egress-policy: audit
+1 -1
View File
@@ -32,7 +32,7 @@ jobs:
pull-requests: write # pulls.get/list plus add/remove the label on PRs
steps:
- name: Harden Runner
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
with:
egress-policy: audit
+2 -2
View File
@@ -14,7 +14,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Harden Runner
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
with:
egress-policy: audit
@@ -34,7 +34,7 @@ jobs:
cache-suffix: pre-commit
- name: Install Task
uses: go-task/setup-task@3be4020d41929789a01026e0e427a4321ce0ad44 # v2.0.0
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
- name: Run pre-commit checks
run: task pre-commit
+4 -1
View File
@@ -17,6 +17,9 @@ permissions:
jobs:
push-base:
# Own environment: docker-publish is branch-locked to release/main,
# which excludes the baseDockerImage/accessIssueFix branches this runs on.
environment: docker-base-publish
if: ${{ vars.CI_PROFILE != 'lite' && github.actor == 'Frooodle' }}
runs-on: ubuntu-24.04-8core
permissions:
@@ -45,7 +48,7 @@ jobs:
echo "version=${VERSION}" >> $GITHUB_OUTPUT
- name: Harden Runner
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
with:
egress-policy: audit
+23 -27
View File
@@ -20,9 +20,8 @@ on:
default: false
push:
branches:
- master
- release
- main
- V2-master
# cancel in-progress jobs if a new job is triggered
# This is useful to avoid running multiple builds for the same branch if a new commit is pushed
@@ -53,29 +52,26 @@ jobs:
RUN_UNOSERVER: ${{ github.event_name != 'workflow_dispatch' || inputs.build_unoserver }}
steps:
- name: Harden Runner
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
with:
egress-policy: audit
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Cache Gradle
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: gradle-push-docker-v1-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
- name: Set up JDK 25
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
with:
java-version: "25"
distribution: "temurin"
- name: Cache Gradle User Home
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
restore-keys: |
gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-
gradle-${{ runner.os }}-${{ runner.arch }}-
- name: Set up Docker Buildx
id: buildx
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
@@ -91,13 +87,13 @@ jobs:
MAVEN_PUBLIC_URL: ${{ secrets.MAVEN_PUBLIC_URL }}
- name: Install cosign
if: github.ref == 'refs/heads/master' || github.ref == 'refs/heads/V2-master'
if: github.ref == 'refs/heads/release'
uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2
with:
cosign-release: "v2.4.1"
- name: Install cosign
if: github.ref == 'refs/heads/master' || github.ref == 'refs/heads/V2-master'
if: github.ref == 'refs/heads/release'
uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2
with:
cosign-release: "v2.4.1"
@@ -133,8 +129,8 @@ jobs:
ghcr.io/${{ steps.repoowner.outputs.lowercase }}/stirling-pdf
${{ secrets.DOCKER_HUB_ORG_USERNAME }}/stirling-pdf
tags: |
type=raw,value=${{ steps.versionNumber.outputs.versionNumber }},enable=${{ github.ref == 'refs/heads/master' || github.ref == 'refs/heads/V2-master' }}
type=raw,value=latest,enable=${{ github.ref == 'refs/heads/master' || github.ref == 'refs/heads/V2-master' }}
type=raw,value=${{ steps.versionNumber.outputs.versionNumber }},enable=${{ github.ref == 'refs/heads/release' }}
type=raw,value=latest,enable=${{ github.ref == 'refs/heads/release' }}
- name: Build and push Unified Dockerfile (latest variant)
id: build-push-latest
@@ -158,7 +154,7 @@ jobs:
sbom: true
- name: Sign regular images
if: env.RUN_MAIN_APP == 'true' && (github.ref == 'refs/heads/master' || github.ref == 'refs/heads/V2-master') && steps.build-push-latest.outputs.digest != ''
if: env.RUN_MAIN_APP == 'true' && (github.ref == 'refs/heads/release') && steps.build-push-latest.outputs.digest != ''
env:
DIGEST: ${{ steps.build-push-latest.outputs.digest }}
TAGS: ${{ steps.meta.outputs.tags }}
@@ -182,8 +178,8 @@ jobs:
ghcr.io/${{ steps.repoowner.outputs.lowercase }}/stirling-pdf
${{ secrets.DOCKER_HUB_ORG_USERNAME }}/stirling-pdf
tags: |
type=raw,value=${{ steps.versionNumber.outputs.versionNumber }}-fat,enable=${{ github.ref == 'refs/heads/master' || github.ref == 'refs/heads/V2-master' }}
type=raw,value=latest-fat,enable=${{ github.ref == 'refs/heads/master' || github.ref == 'refs/heads/V2-master' }}
type=raw,value=${{ steps.versionNumber.outputs.versionNumber }}-fat,enable=${{ github.ref == 'refs/heads/release' }}
type=raw,value=latest-fat,enable=${{ github.ref == 'refs/heads/release' }}
- name: Build and push Unified Dockerfile (fat variant)
id: build-push-fat
@@ -204,7 +200,7 @@ jobs:
sbom: true
- name: Sign fat images
if: env.RUN_MAIN_APP == 'true' && (github.ref == 'refs/heads/master' || github.ref == 'refs/heads/V2-master') && steps.build-push-fat.outputs.digest != ''
if: env.RUN_MAIN_APP == 'true' && (github.ref == 'refs/heads/release') && steps.build-push-fat.outputs.digest != ''
env:
DIGEST: ${{ steps.build-push-fat.outputs.digest }}
TAGS: ${{ steps.meta-fat.outputs.tags }}
@@ -226,8 +222,8 @@ jobs:
ghcr.io/${{ steps.repoowner.outputs.lowercase }}/stirling-pdf
${{ secrets.DOCKER_HUB_ORG_USERNAME }}/stirling-pdf
tags: |
type=raw,value=${{ steps.versionNumber.outputs.versionNumber }}-ultra-lite,enable=${{ github.ref == 'refs/heads/master' || github.ref == 'refs/heads/V2-master' }}
type=raw,value=latest-ultra-lite,enable=${{ github.ref == 'refs/heads/master' || github.ref == 'refs/heads/V2-master' }}
type=raw,value=${{ steps.versionNumber.outputs.versionNumber }}-ultra-lite,enable=${{ github.ref == 'refs/heads/release' }}
type=raw,value=latest-ultra-lite,enable=${{ github.ref == 'refs/heads/release' }}
- name: Build and push Unified Dockerfile (ultra-lite variant)
id: build-push-lite
@@ -248,7 +244,7 @@ jobs:
sbom: true
- name: Sign ultra-lite images
if: env.RUN_MAIN_APP == 'true' && (github.ref == 'refs/heads/master' || github.ref == 'refs/heads/V2-master') && steps.build-push-lite.outputs.digest != ''
if: env.RUN_MAIN_APP == 'true' && (github.ref == 'refs/heads/release') && steps.build-push-lite.outputs.digest != ''
env:
DIGEST: ${{ steps.build-push-lite.outputs.digest }}
TAGS: ${{ steps.meta-lite.outputs.tags }}
@@ -260,7 +256,7 @@ jobs:
done
# Standalone unoserver image — versioned independently via
# docker/unoserver/VERSION. master/V2-master: publish <version>+latest
# docker/unoserver/VERSION. release: publish <version>+latest
# only when the version is new. main/testMain: republish :alpha only
# when the source hash differs from the published image's annotation.
- name: Read unoserver image version
@@ -319,7 +315,7 @@ jobs:
fi
case "$EFFECTIVE_REF" in
refs/heads/master|refs/heads/V2-master)
refs/heads/release)
if [ "${FORCE_REBUILD}" = "true" ]; then
echo "force_unoserver_rebuild=true — building stable regardless"
mode="stable"
+1 -1
View File
@@ -19,7 +19,7 @@ jobs:
packages: write
steps:
- name: Harden Runner
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
with:
egress-policy: audit
+2 -2
View File
@@ -35,7 +35,7 @@ jobs:
steps:
- name: Harden Runner
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
with:
egress-policy: audit
@@ -75,6 +75,6 @@ jobs:
# Upload the results to GitHub's code scanning dashboard.
- name: "Upload to code-scanning"
uses: github/codeql-action/upload-sarif@f205ea1c3313d32999d8d6a48b4f6530d4437b38 # v4.37.4
uses: github/codeql-action/upload-sarif@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4.37.7
with:
sarif_file: results.sarif
+1 -1
View File
@@ -17,7 +17,7 @@ jobs:
pull-requests: write
steps:
- name: Harden Runner
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
with:
egress-policy: audit
+13 -13
View File
@@ -4,7 +4,7 @@ on:
workflow_dispatch:
push:
branches:
- master
- release
# cancel in-progress jobs if a new job is triggered
# This is useful to avoid running multiple builds for the same branch if a new commit is pushed
@@ -23,33 +23,33 @@ permissions:
jobs:
push:
# package-publish holds SWAGGERHUB_API_KEY. It requires reviewer approval and
# is limited to main / release / v* tags, so every push to release waits on one.
environment: package-publish
if: ${{ vars.CI_PROFILE != 'lite' }}
runs-on: ubuntu-latest
steps:
- name: Harden Runner
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
with:
egress-policy: audit
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Cache Gradle
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: gradle-swagger-v1-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
- name: Set up JDK 25
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
with:
java-version: "25"
distribution: "temurin"
- name: Cache Gradle User Home
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
restore-keys: |
gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-
gradle-${{ runner.os }}-${{ runner.arch }}-
- name: Generate Swagger documentation
run: ./gradlew :stirling-pdf:generateOpenApiDocs
+1 -1
View File
@@ -33,7 +33,7 @@ jobs:
pull-requests: write
steps:
- name: Harden Runner
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
with:
egress-policy: audit
+2 -2
View File
@@ -37,7 +37,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Harden Runner
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
with:
egress-policy: audit
@@ -66,7 +66,7 @@ jobs:
uv sync --project engine --locked --group tools
- name: Install Task
uses: go-task/setup-task@3be4020d41929789a01026e0e427a4321ce0ad44 # v2.0.0
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
- name: Sync translation TOML files
run: |
+49 -49
View File
@@ -26,6 +26,10 @@ on:
required: false
type: boolean
default: false
use_shared_cache:
required: false
type: boolean
default: false
workflow_dispatch:
inputs:
platform:
@@ -57,13 +61,18 @@ permissions:
jobs:
determine-matrix:
# Only probes APPLE_CERTIFICATE for presence, so it stays on the unrestricted
# signing environment - release-signing would block every PR run.
environment:
name: ci-signing
deployment: false
if: ${{ vars.CI_PROFILE != 'lite' }}
runs-on: ubuntu-latest
outputs:
matrix: ${{ steps.set-matrix.outputs.matrix }}
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
with:
egress-policy: audit
@@ -103,6 +112,12 @@ jobs:
echo "matrix={\"include\":[$JOINED]}" >> $GITHUB_OUTPUT
build:
# Windows/GPG signing only runs on main (see the per-step gates below), so only
# that path needs the reviewer-gated release-signing environment. Everything else
# (PRs, merge queue, nightly) signs macOS only and uses ci-signing, which has no
# approval or branch restriction.
environment:
name: ${{ (inputs.sign && (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v'))) && 'release-signing' || 'ci-signing' }}
needs: determine-matrix
strategy:
fail-fast: false
@@ -110,7 +125,6 @@ jobs:
runs-on: ${{ matrix.platform }}
env:
SM_API_KEY: ${{ secrets.SM_API_KEY }}
WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }}
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
RELEASE_GPG_PRIVATE_KEY: ${{ secrets.RELEASE_GPG_PRIVATE_KEY }}
# Per-platform sign gate. macOS signs on any run with the cert available,
@@ -120,7 +134,7 @@ jobs:
SIGN_BUNDLE: ${{ inputs.sign && (matrix.platform == 'macos-15' && secrets.APPLE_CERTIFICATE != '' || github.ref == 'refs/heads/main') }}
steps:
- name: Harden Runner
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
with:
egress-policy: audit
@@ -150,7 +164,7 @@ jobs:
# only recompiles the app crate. Written on main; PRs and the merge queue
# restore from it.
- name: Cache Rust build
uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1
uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
workspaces: frontend/editor/src-tauri
# Stable key shared across workflows so the nightly warmer.
@@ -160,6 +174,24 @@ jobs:
# Save the dependency cache even if a later step fails
cache-on-failure: true
- name: Restore cache Gradle User Home
if: inputs.use_shared_cache
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: gradle-v1-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
- name: Restore cache Gradle
if: inputs.use_shared_cache == false
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: gradle-tauri-build-v1-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
- name: Set up x86_64 JDK 25 (macOS universal JRE)
if: matrix.platform == 'macos-15'
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
@@ -179,17 +211,6 @@ jobs:
java-version: "25"
distribution: ${{ matrix.platform == 'windows-11-arm' && 'microsoft' || 'temurin' }}
- name: Cache Gradle User Home
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
restore-keys: |
gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-
gradle-${{ runner.os }}-${{ runner.arch }}-
- name: Setup Task
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
@@ -264,38 +285,6 @@ jobs:
}
}
# Traditional PFX Certificate Import (fallback if KeyLocker not configured)
- name: Import Windows Code Signing Certificate
if: ${{ inputs.sign && startsWith(matrix.platform, 'windows') && env.SM_API_KEY == '' && github.ref == 'refs/heads/main' }}
env:
WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }}
WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }}
shell: powershell
run: |
if ($env:WINDOWS_CERTIFICATE) {
Write-Host "Importing Windows Code Signing Certificate..."
# Decode base64 certificate and save to file
$certBytes = [Convert]::FromBase64String($env:WINDOWS_CERTIFICATE)
$certPath = Join-Path $env:RUNNER_TEMP "certificate.pfx"
[IO.File]::WriteAllBytes($certPath, $certBytes)
# Import certificate to CurrentUser\My store
$cert = Import-PfxCertificate -FilePath $certPath -CertStoreLocation Cert:\CurrentUser\My -Password (ConvertTo-SecureString -String $env:WINDOWS_CERTIFICATE_PASSWORD -AsPlainText -Force)
# Extract and set thumbprint as environment variable
$thumbprint = $cert.Thumbprint
Write-Host "Certificate imported with thumbprint: $thumbprint"
echo "WINDOWS_CERTIFICATE_THUMBPRINT=$thumbprint" >> $env:GITHUB_ENV
# Clean up certificate file
Remove-Item $certPath
Write-Host "Windows certificate import completed."
} else {
Write-Host "⚠️ WINDOWS_CERTIFICATE secret not set - building unsigned binary"
}
- name: Import Apple Developer Certificate
if: env.SIGN_BUNDLE == 'true' && matrix.platform == 'macos-15'
env:
@@ -688,6 +677,17 @@ jobs:
fi
done
- name: Cleanup temporary files
if: always()
shell: bash
run: |
rm -f certificate.p12
rm -rf "$RUNNER_TEMP/msi-verify"
if [ "${{ matrix.platform }}" = "macos-15" ]; then
security delete-keychain "$RUNNER_TEMP/app-signing.keychain-db" 2>/dev/null || true
fi
continue-on-error: true
pr-comment:
needs: build
runs-on: ubuntu-latest
@@ -703,7 +703,7 @@ jobs:
pull-requests: write
steps:
- name: Harden the runner
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
with:
egress-policy: audit
@@ -795,7 +795,7 @@ jobs:
if: always()
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
with:
egress-policy: audit
+58 -32
View File
@@ -22,21 +22,45 @@ permissions:
contents: read
jobs:
# TODO: extract a pre-matrix `prepare` job that runs once and produces
# shared artifacts for the three matrix entries below to consume:
# 1. `task backend:build` — currently runs 3× in parallel with
# identical env (DISABLE_ADDITIONAL_FEATURES=true,
# STIRLING_PDF_DESKTOP_UI=false). Build once, upload the JAR as an
# artifact, matrix entries download.
# 2. The base-image `docker build` (gated on docker-base-changed) —
# currently runs 3× in parallel against the same Dockerfile and
# context. Build once, `docker save` to an artifact, matrix entries
# `docker load` before the embedded build.
# Saves ~2 full backend builds + 2 base-image builds per PR that touches
# docker. May also be reusable from backend-build.yml's jdk-25 +
# spring-security=true matrix entry if `task backend:build` and
# `task backend:build:ci` produce equivalent JARs (verify before wiring).
# A changed base image is shared by all three embedded-image builds. Build
# it once and transfer it as an artifact; the matrix jobs use the local
# Docker driver so the loaded image is visible to the build.
prepare-base-image:
if: github.event_name == 'pull_request' && inputs.docker-base-changed == 'true'
environment:
name: ci-unsigned
deployment: false
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- name: Harden Runner
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
with:
egress-policy: audit
- name: Checkout Repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Build base image locally
run: docker build --platform linux/amd64 -t stirling-pdf-base:pr-test -f docker/base/Dockerfile docker/base
- name: Export base image
run: docker save stirling-pdf-base:pr-test | gzip -1 > stirling-pdf-base-pr-test.tar.gz
- name: Upload base image
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: docker-base-pr-test
path: stirling-pdf-base-pr-test.tar.gz
retention-days: 1
if-no-files-found: error
test-build-docker-images:
if: always() && (needs.prepare-base-image.result == 'success' || needs.prepare-base-image.result == 'skipped')
needs: [prepare-base-image]
environment:
name: ci-unsigned
deployment: false
runs-on: ubuntu-latest
strategy:
fail-fast: false
@@ -53,7 +77,7 @@ jobs:
cache-scope: stirling-pdf-fat
steps:
- name: Harden Runner
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
with:
egress-policy: audit
@@ -78,23 +102,30 @@ jobs:
docker system prune -af || true
echo "Disk space after cleanup:" && df -h
- name: Download prepared base image
if: github.event_name == 'pull_request' && inputs.docker-base-changed == 'true'
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: docker-base-pr-test
- name: Load prepared base image
if: github.event_name == 'pull_request' && inputs.docker-base-changed == 'true'
run: gzip -dc stirling-pdf-base-pr-test.tar.gz | docker load
- name: Restore cache Gradle User Home
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: gradle-v1-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
- name: Set up JDK 25
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
with:
java-version: "25"
distribution: "temurin"
- name: Cache Gradle User Home
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
restore-keys: |
gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-
gradle-${{ runner.os }}-${{ runner.arch }}-
- name: Install Task
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
- name: Build application
@@ -113,11 +144,6 @@ jobs:
id: buildx
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
- name: Build base image locally (PR base change only)
if: github.event_name == 'pull_request' && inputs.docker-base-changed == 'true'
run: |
docker build -t stirling-pdf-base:pr-test -f docker/base/Dockerfile docker/base
- name: Set base image and platform for this build
id: build-params
# Pass workflow inputs through env vars rather than expanding `${{ }}`
@@ -191,7 +217,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Harden Runner
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
with:
egress-policy: audit
-235
View File
@@ -1,235 +0,0 @@
name: UI test with TestDriverAI
on:
push:
branches: ["master", "UITest", "testdriver"]
# cancel in-progress jobs if a new job is triggered
# This is useful to avoid running multiple builds for the same branch if a new commit is pushed
# or a pull request is updated.
# It helps to save resources and time by ensuring that only the latest commit is built and tested
# This is particularly useful for long-running jobs that may take a while to complete.
# The `group` is set to a combination of the workflow name, event name, and branch name.
# This ensures that jobs are grouped by the workflow and branch, allowing for cancellation of
# in-progress jobs when a new commit is pushed to the same branch or a new pull request is opened.
concurrency:
group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.ref_name || github.ref }}
cancel-in-progress: true
permissions:
contents: read
jobs:
deploy:
environment: pr-preview
if: ${{ vars.CI_PROFILE != 'lite' }}
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Harden Runner
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
with:
egress-policy: audit
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Set up JDK 25
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
with:
java-version: "25"
distribution: "temurin"
- name: Cache Gradle User Home
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
restore-keys: |
gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-
gradle-${{ runner.os }}-${{ runner.arch }}-
- name: Build with Gradle
run: ./gradlew build
env:
MAVEN_USER: ${{ secrets.MAVEN_USER }}
MAVEN_PASSWORD: ${{ secrets.MAVEN_PASSWORD }}
MAVEN_PUBLIC_URL: ${{ secrets.MAVEN_PUBLIC_URL }}
DISABLE_ADDITIONAL_FEATURES: true
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
- name: Get version number
id: versionNumber
run: |
VERSION=$(grep "^version =" build.gradle | awk -F'"' '{print $2}')
echo "versionNumber=$VERSION" >> $GITHUB_OUTPUT
- name: Convert repository owner to lowercase
id: repoowner
run: echo "lowercase=$(echo ${{ github.repository_owner }} | awk '{print tolower($0)}')" >> $GITHUB_OUTPUT
- 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: Build and push test image
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
with:
context: .
file: ./docker/embedded/Dockerfile
push: true
cache-from: type=gha,scope=stirling-pdf-latest
cache-to: type=gha,mode=max,scope=stirling-pdf-latest
tags: ghcr.io/${{ steps.repoowner.outputs.lowercase }}/stirling-pdf-test:test-${{ github.sha }}
build-args: VERSION_TAG=${{ steps.versionNumber.outputs.versionNumber }}
platforms: linux/amd64
- name: Set up SSH
run: |
mkdir -p ~/.ssh/
echo "${NEW_VPS_SSH_KEY}" > ../private.key
sudo chmod 600 ../private.key
env:
NEW_VPS_SSH_KEY: ${{ secrets.NEW_VPS_SSH_KEY }}
- name: Deploy to VPS
run: |
cat > docker-compose.yml << EOF
version: '3.3'
services:
stirling-pdf:
container_name: stirling-pdf-test-${{ github.sha }}
image: ${IMAGE_BASE}:test-${{ github.sha }}
ports:
- "1337:8080"
volumes:
- /stirling/test-${{ github.sha }}/data:/usr/share/tessdata:rw
- /stirling/test-${{ github.sha }}/config:/configs:rw
- /stirling/test-${{ github.sha }}/logs:/logs:rw
environment:
DISABLE_ADDITIONAL_FEATURES: "true"
SECURITY_ENABLELOGIN: "false"
SYSTEM_DEFAULTLOCALE: en-US
UI_APPNAME: "Stirling-PDF Test"
UI_HOMEDESCRIPTION: "Test Deployment"
UI_APPNAMENAVBAR: "Test"
SYSTEM_MAXFILESIZE: "100"
METRICS_ENABLED: "true"
SYSTEM_GOOGLEVISIBILITY: "false"
SYSTEM_ENABLEANALYTICS: "false"
restart: on-failure:5
EOF
scp -i ../private.key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null docker-compose.yml ${NEW_VPS_USERNAME}@${NEW_VPS_HOST}:/tmp/docker-compose.yml
ssh -i ../private.key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null ${NEW_VPS_USERNAME}@${NEW_VPS_HOST} << EOF
mkdir -p /stirling/test-${{ github.sha }}/{data,config,logs}
mv /tmp/docker-compose.yml /stirling/test-${{ github.sha }}/docker-compose.yml
cd /stirling/test-${{ github.sha }}
docker-compose pull
docker-compose up -d
EOF
env:
IMAGE_BASE: ghcr.io/${{ steps.repoowner.outputs.lowercase }}/stirling-pdf-test
NEW_VPS_USERNAME: ${{ secrets.NEW_VPS_USERNAME }}
NEW_VPS_HOST: ${{ secrets.NEW_VPS_HOST }}
files-changed:
if: always()
name: detect what files changed
runs-on: ubuntu-latest
timeout-minutes: 3
outputs:
frontend: ${{ steps.changes.outputs.frontend }}
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
with:
egress-policy: audit
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Check for file changes
uses: dorny/paths-filter@7b450fff21473bca461d4b92ce414b9d0420d706 # v4.0.2
id: changes
with:
filters: ".github/config/.files.yaml"
test:
environment: pr-preview
if: needs.files-changed.outputs.frontend == 'true'
needs: [deploy, files-changed]
runs-on: ubuntu-latest
steps:
- name: Harden Runner
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
with:
egress-policy: audit
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Set up Node
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
cache: "npm"
cache-dependency-path: frontend/package-lock.json
- name: Run TestDriver.ai
uses: testdriverai/action@f0d0f45fdd684db628baa843fe9313f3ca3a8aa8 #1.1.3
with:
key: ${{secrets.TESTDRIVER_API_KEY}}
prerun: |
choco install go-task -y
task frontend:build
cd frontend
npm install dashcam-chrome --save
Start-Process "C:/Program Files/Google/Chrome/Application/chrome.exe" -ArgumentList "--start-maximized", "--load-extension=$(pwd)/node_modules/dashcam-chrome/build", "http://${{ secrets.NEW_VPS_HOST }}:1337"
Start-Sleep -Seconds 20
prompt: |
1. /run testing/testdriver/test.yml
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
FORCE_COLOR: "3"
cleanup:
environment: pr-preview
needs: [deploy, test]
runs-on: ubuntu-latest
if: always()
steps:
- name: Harden Runner
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
with:
egress-policy: audit
- name: Set up SSH
run: |
mkdir -p ~/.ssh/
echo "${NEW_VPS_SSH_KEY}" > ../private.key
sudo chmod 600 ../private.key
env:
NEW_VPS_SSH_KEY: ${{ secrets.NEW_VPS_SSH_KEY }}
- name: Cleanup deployment
if: always()
run: |
ssh -i ../private.key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null ${NEW_VPS_USERNAME}@${NEW_VPS_HOST} << EOF
cd /stirling/test-${{ github.sha }}
docker-compose down
cd /stirling
rm -rf test-${{ github.sha }}
EOF
env:
NEW_VPS_USERNAME: ${{ secrets.NEW_VPS_USERNAME }}
NEW_VPS_HOST: ${{ secrets.NEW_VPS_HOST }}
continue-on-error: true # Ensure cleanup runs even if previous steps fail
+112
View File
@@ -0,0 +1,112 @@
name: Update Gradle
on:
workflow_dispatch:
schedule:
- cron: "0 3 * * 1"
concurrency:
group: update-gradle
cancel-in-progress: true
jobs:
update-gradle:
name: Update Gradle and Docker images
permissions:
contents: write
pull-requests: write
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- name: Harden runner
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
with:
egress-policy: audit
- name: Check out repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Set up Java
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
with:
distribution: temurin
java-version: "25"
- name: Find latest Gradle release
id: gradle
shell: bash
run: |
set -euo pipefail
version=$(curl --fail --silent --show-error --retry 3 \
https://services.gradle.org/versions/current | jq -r '.version')
[[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || {
echo "Could not determine a stable Gradle version: $version" >&2
exit 1
}
echo "version=$version" >> "$GITHUB_OUTPUT"
- name: Find matching Docker image digest
id: docker
env:
GRADLE_VERSION: ${{ steps.gradle.outputs.version }}
shell: bash
run: |
set -euo pipefail
tag="${GRADLE_VERSION}-jdk25"
digest=$(curl --fail --silent --show-error --retry 3 \
"https://hub.docker.com/v2/repositories/library/gradle/tags/${tag}" \
| jq -r '.digest // empty')
[[ "$digest" =~ ^sha256:[0-9a-f]{64}$ ]] || {
echo "Docker image gradle:${tag} was not found" >&2
exit 1
}
echo "tag=$tag" >> "$GITHUB_OUTPUT"
echo "digest=$digest" >> "$GITHUB_OUTPUT"
- name: Update Gradle wrapper
env:
GRADLE_VERSION: ${{ steps.gradle.outputs.version }}
run: ./gradlew wrapper --gradle-version "$GRADLE_VERSION" --distribution-type bin
- name: Update Gradle Docker images
env:
DOCKER_TAG: ${{ steps.docker.outputs.tag }}
DOCKER_DIGEST: ${{ steps.docker.outputs.digest }}
shell: bash
run: |
set -euo pipefail
find docker -type f -name 'Dockerfile*' -print0 |
xargs -0 sed -E -i \
"s#gradle:[^@[:space:]]+-jdk25(@sha256:[^[:space:]]+)?#gradle:${DOCKER_TAG}@${DOCKER_DIGEST}#g"
- name: Verify Gradle update
env:
EXPECTED_VERSION: ${{ steps.gradle.outputs.version }}
shell: bash
run: |
set -euo pipefail
actual=$(./gradlew --version | sed -n 's/^Gradle \([0-9.]*\)$/\1/p')
[[ "$actual" == "$EXPECTED_VERSION" ]] || {
echo "Wrapper resolved Gradle $actual, expected $EXPECTED_VERSION" >&2
exit 1
}
if git diff --quiet; then
echo "Gradle is already up to date."
exit 0
fi
git diff --check
- name: Create pull request
uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1
with:
token: ${{ secrets.GITHUB_TOKEN }}
branch: automation/update-gradle
delete-branch: true
commit-message: "chore: update Gradle"
title: "chore: update Gradle to ${{ steps.gradle.outputs.version }}"
body: |
Automated update of the Gradle wrapper and Gradle Docker build images.
Gradle version: `${{ steps.gradle.outputs.version }}`
Docker image: `gradle:${{ steps.docker.outputs.tag }}`
labels: dependencies
+5
View File
@@ -27,3 +27,8 @@ app/core/src/main/java/stirling/software/SPDF/pdf/signature/CreateSignatureBase.
# Supabase publishable key (public by design, RLS-protected) used as a CI fallback
# default in the tauri-build workflow when the GitHub secret is unset - not a real secret.
.github/workflows/tauri-build.yml:generic-api-key:402
# Staging Supabase publishable key (public by design). Ignored here rather than with an
# inline gitleaks:allow because a trailing comment in a .properties file is part of the
# value, so the pragma would end up inside the key.
app/saas/src/main/resources/application-staging.properties:generic-api-key:16
+49 -6
View File
@@ -57,16 +57,57 @@ tasks:
- cmd: ./gradlew clean bootRun -PbuildWithFrontend=true
platforms: [linux, darwin]
# SaaS backend. dev:saas -> the PR's preview branch, staging:saas -> shared v3,
# PROFILES=none -> production against your own SAAS_DB_*. Production has no named
# task on purpose. Use `none`, not an empty value: Go template `default` treats ""
# as absent and would resolve back to dev.
dev:saas:
desc: "Start backend in SaaS flavor against Supabase"
# `dotenv:` reads from the root Taskfile's directory (".") because this
# subtaskfile is included with `dir: .`.
desc: "Start SaaS backend against the current PR's Supabase preview branch"
dotenv: ['app/.env.saas.local', 'app/.env.saas']
vars:
PROFILES: '{{.PROFILES | default "dev"}}'
cmds:
# Don't move this check into a `sh:` var: dotenv is visible in cmds but not
# during var evaluation, so the test would always see an empty value.
- cmd: |
if [ "{{.PROFILES}}" = "dev" ] && [ -z "${SAAS_DEV_PROJECT_REF:-}" ]; then
echo ">> SAAS_DEV_PROJECT_REF is not set."
echo ">> Testing a SaaS PR? Put its ref, DB password and publishable key in app/.env.saas.local."
echo ">> Wanted the shared v3 project? Use 'task backend:staging:saas' instead."
exit 1
fi
- task: _run:saas
vars:
PORT: '{{.PORT}}'
PROFILES: '{{.PROFILES}}'
AIENGINE_URL: '{{.AIENGINE_URL}}'
AIENGINE_ENABLED: '{{.AIENGINE_ENABLED}}'
AIENGINE_TIMEOUTSECONDS: '{{.AIENGINE_TIMEOUTSECONDS}}'
staging:saas:
desc: "Start SaaS backend against the shared v3 staging project"
cmds:
- task: _run:saas
vars:
PORT: '{{.PORT}}'
PROFILES: staging
AIENGINE_URL: '{{.AIENGINE_URL}}'
AIENGINE_ENABLED: '{{.AIENGINE_ENABLED}}'
AIENGINE_TIMEOUTSECONDS: '{{.AIENGINE_TIMEOUTSECONDS}}'
_run:saas:
internal: true
dotenv: ['app/.env.saas.local', 'app/.env.saas']
ignore_error: true
vars:
PORT: '{{.PORT | default "8080"}}'
# Override to "" to run the pure `saas` profile against your own SAAS_DB_*.
PROFILES: '{{.PROFILES | default "dev"}}'
# Built here rather than inline in the cmds below: the Windows line is an
# unquoted YAML scalar wrapping a cmd.exe string, so a nested {{if ne .X
# "none"}} needs escaped quotes that reach the Go template as literal
# backslashes and fail with `unexpected "\" in operand`.
PROFILE_ARGS: '{{if ne .PROFILES "none"}}--spring.profiles.include={{.PROFILES}}{{end}}'
AIENGINE_URL: '{{.AIENGINE_URL | default ""}}'
AIENGINE_ENABLED: '{{.AIENGINE_ENABLED | default "false"}}'
AIENGINE_TIMEOUTSECONDS: '{{.AIENGINE_TIMEOUTSECONDS | default "120"}}'
@@ -77,9 +118,11 @@ tasks:
AIENGINE_ENABLED: '{{.AIENGINE_ENABLED}}'
AIENGINE_TIMEOUTSECONDS: '{{.AIENGINE_TIMEOUTSECONDS}}'
cmds:
- cmd: cmd /c ".\gradlew.bat :stirling-pdf:bootRun {{if .PROFILES}}--args=\"--spring.profiles.include={{.PROFILES}}\"{{end}}"
# PROFILE_ARGS is empty when PROFILES=none, i.e. the bare `saas` profile
# against SAAS_DB_* (production).
- cmd: cmd /c ".\gradlew.bat :stirling-pdf:bootRun {{if .PROFILE_ARGS}}--args=\"{{.PROFILE_ARGS}}\"{{end}}"
platforms: [windows]
- cmd: ./gradlew :stirling-pdf:bootRun {{if .PROFILES}}--args='--spring.profiles.include={{.PROFILES}}'{{end}}
- cmd: ./gradlew :stirling-pdf:bootRun {{if .PROFILE_ARGS}}--args='{{.PROFILE_ARGS}}'{{end}}
platforms: [linux, darwin]
build:
+64 -10
View File
@@ -5,6 +5,14 @@ version: '3'
# mode flag) or use `--project editor/...` for tsc — so the editor lives
# under frontend/editor/ without each task needing a cd.
vars:
# Dev-only browser-tab label so concurrent worktrees are distinguishable. Only
# the worktree folder basename (e.g. "wt1") is exposed — never the full path,
# hostname, or user. Dropped from production builds.
DEV_LABEL:
sh: >-
{{if eq OS "windows"}}powershell -NoProfile -Command '$root = git rev-parse --show-toplevel 2>$null; if (-not $root) { $root = (Get-Location).Path }; Split-Path -Leaf $root'{{else}}basename "$(git rev-parse --show-toplevel 2>/dev/null || pwd)"{{end}}
tasks:
install:
desc: "Install dependencies"
@@ -80,16 +88,52 @@ tasks:
OPEN: '{{.OPEN | default ""}}'
env:
BACKEND_URL: '{{.BACKEND_URL}}'
# Dev-only browser-tab label so concurrent worktrees are distinguishable.
# Only the worktree folder basename (e.g. "wt1") is exposed — never the
# full path, hostname, or user. Consumed at dev-serve time by vite.config
# and dropped from production builds.
STIRLING_DEV_LABEL:
sh: >-
{{if eq OS "windows"}}powershell -NoProfile -Command '$root = git rev-parse --show-toplevel 2>$null; if (-not $root) { $root = (Get-Location).Path }; Split-Path -Leaf $root'{{else}}basename "$(git rev-parse --show-toplevel 2>/dev/null || pwd)"{{end}}
STIRLING_DEV_LABEL: '{{.DEV_LABEL}}'
cmds:
- npx vite editor --mode {{.MODE}} --port {{.PORT}}{{if .OPEN}} --open{{end}}
# Separate from dev:_run rather than a flag on it: Task sets an `env:` key even
# when its value resolves to empty, and Vite treats an empty process.env VITE_* as
# authoritative over the committed editor/.env, so folding these in blanks Supabase
# config for the core, proprietary and desktop dev servers.
dev:_run:saas:
internal: true
ignore_error: true
# The backend's own env files, so both halves target one project. Paths are
# relative to this taskfile's dir, `frontend`.
dotenv: ['../app/.env.saas.local', '../app/.env.saas']
vars:
PORT: '{{.PORT | default "5173"}}'
BACKEND_URL: '{{.BACKEND_URL | default "http://localhost:8080"}}'
OPEN: '{{.OPEN | default ""}}'
SAAS_ENV: '{{.SAAS_ENV | default "dev"}}'
env:
BACKEND_URL: '{{.BACKEND_URL}}'
STIRLING_DEV_LABEL: '{{.DEV_LABEL}}'
SAAS_ENV: '{{.SAAS_ENV}}'
# A real process.env VITE_* beats a committed .env in Vite (loadEnv applies
# process.env last), which is what lets this override editor/.env.
#
# These must stay `sh:`, not Go templates: dotenv values are visible to Task's
# embedded shell but not to templates, where {{.SAAS_DEV_PROJECT_REF}} is
# always empty.
VITE_SUPABASE_URL:
sh: |
case "${SAAS_ENV:-dev}" in
staging) ref="${SAAS_STAGING_PROJECT_REF:?set it in app/.env.saas.local}" ;;
*) ref="${SAAS_DEV_PROJECT_REF:?set it in app/.env.saas.local, or run task staging:saas}" ;;
esac
echo "https://${ref}.supabase.co"
VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY:
sh: |
case "${SAAS_ENV:-dev}" in
staging) echo "${SAAS_STAGING_PUBLISHABLE_KEY:?set it in app/.env.saas.local}" ;;
*) echo "${SAAS_DEV_PUBLISHABLE_KEY:?set it in app/.env.saas.local}" ;;
esac
cmds:
- 'echo ">> frontend Supabase target: $VITE_SUPABASE_URL"'
- npx vite editor --mode saas --port {{.PORT}}{{if .OPEN}} --open{{end}}
dev:
desc: "Start frontend dev server"
cmds:
@@ -111,13 +155,23 @@ tasks:
vars: { MODE: proprietary, PORT: '{{.PORT}}', BACKEND_URL: '{{.BACKEND_URL}}', OPEN: '{{.OPEN}}' }
dev:saas:
desc: "Start frontend dev server in SaaS mode"
desc: "Start frontend dev server in SaaS mode (SAAS_ENV=dev|staging|prod)"
deps:
- task: prepare
vars: { MODE: saas }
vars:
SAAS_ENV: '{{.SAAS_ENV | default "dev"}}'
# prod routes to the plain runner, which sets no VITE_SUPABASE_* and so leaves
# the committed editor/.env alone.
RUNNER: '{{if eq .SAAS_ENV "prod"}}dev:_run{{else}}dev:_run:saas{{end}}'
cmds:
- task: dev:_run
vars: { MODE: saas, PORT: '{{.PORT}}', BACKEND_URL: '{{.BACKEND_URL}}', OPEN: '{{.OPEN}}' }
- task: '{{.RUNNER}}'
vars:
MODE: saas
PORT: '{{.PORT}}'
BACKEND_URL: '{{.BACKEND_URL}}'
OPEN: '{{.OPEN}}'
SAAS_ENV: '{{.SAAS_ENV}}'
dev:desktop:
desc: "Start frontend dev server in desktop mode"
+7 -4
View File
@@ -2,6 +2,13 @@
Thank you for your interest in contributing to Stirling-PDF! There are many ways to contribute other than writing code. For example, reporting bugs, creating suggestions, and adding or modifying translations.
## License
By contributing to this project, you agree that your contributions will be licensed under the project [license](LICENSE), which follows an open-core model.
The codebase is a mix of MIT and source-available code, so your contribution is licensed according to the directory it is committed to.
PRs are welcome in any directory by any user, just be aware of which license applies to the code you change.
## Issue Guidelines
Issues can be used to report bugs, request features, or ask questions. If you have a question, you could also ask us in our [Discord](https://discord.gg/FJUSXUSYec).
@@ -63,7 +70,3 @@ For technical guides, setup instructions, and development resources:
For configuration and usage guides, see:
- [Database Guide](DATABASE.md) - Database setup and configuration
- [OCR Guide](HowToUseOCR.md) - OCR setup and configuration
## License
By contributing to this project, you agree that your contributions will be licensed under the [MIT License](LICENSE).
+2 -2
View File
@@ -46,8 +46,8 @@ This guide focuses on developing for Stirling 2.0, including both the React fron
- Docker
- Git
- Java JDK 25
- Node.js 18+ and npm (required for frontend development)
- Gradle 7.0 or later (Included within the repo)
- Node.js 22+ and npm (required for frontend development)
- Gradle 9.0 or later (Included within the repo)
- [uv](https://docs.astral.sh/uv/) — Python package manager (required for engine development)
- Rust and Cargo (required for Tauri desktop app development)
- Tauri CLI (install with `cargo install tauri-cli`)
+18 -3
View File
@@ -99,11 +99,22 @@ tasks:
BACKEND_URL: 'http://localhost:{{.BACKEND_PORT}}'
OPEN: "true"
# Set SAAS_DEV_PROJECT_REF in app/.env.saas.local to pick the PR.
dev:saas:
desc: "Start SaaS backend + frontend concurrently on free ports"
desc: "Start SaaS backend + frontend + engine against the current PR's preview branch"
cmds:
- task: dev:_all
vars: { FRONTEND: saas, BACKEND: saas }
vars: { FRONTEND: saas, BACKEND: saas, SAAS_ENV: dev }
staging:saas:
desc: "Start SaaS backend + frontend + engine against the shared v3 staging project"
cmds:
- task: dev:_all
vars:
FRONTEND: saas
BACKEND: saas
BACKEND_TASK: backend:staging:saas
SAAS_ENV: staging
dev:all:
desc: "Start backend + frontend + engine concurrently on free ports"
@@ -115,6 +126,9 @@ tasks:
vars:
FRONTEND: '{{.FRONTEND | default "proprietary"}}'
BACKEND: '{{.BACKEND | default "proprietary"}}'
BACKEND_TASK: '{{.BACKEND_TASK | default (printf "backend:dev:%s" .BACKEND)}}'
# Only meaningful to the saas frontend; every other flavor ignores it.
SAAS_ENV: '{{.SAAS_ENV | default ""}}'
PORTS:
sh: '{{if eq OS "windows"}}{{.FIND_FREE_PORT_PS}} 8080 5173 5001{{else}}{{.FIND_FREE_PORT_SH}} 8080 5173 5001{{end}}'
BACKEND_PORT: '{{index (splitList "\n" .PORTS) 0}}'
@@ -124,7 +138,7 @@ tasks:
- task: engine:dev
vars:
PORT: '{{.ENGINE_PORT}}'
- task: 'backend:dev:{{.BACKEND}}'
- task: '{{.BACKEND_TASK}}'
vars:
PORT: '{{.BACKEND_PORT}}'
AIENGINE_URL: 'http://localhost:{{.ENGINE_PORT}}'
@@ -134,6 +148,7 @@ tasks:
PORT: '{{.FRONTEND_PORT}}'
BACKEND_URL: 'http://localhost:{{.BACKEND_PORT}}'
OPEN: "true"
SAAS_ENV: '{{.SAAS_ENV}}'
# ============================================================
# Build
+33 -38
View File
@@ -4,6 +4,11 @@ This guide explains how to set up Windows code signing for Stirling-PDF desktop
## Overview
Releases are signed with **DigiCert KeyLocker**, a cloud HSM: the private key never
leaves DigiCert, and the runner signs through a PKCS#11 provider. The older approach
of uploading a base64 `.pfx` to a repository secret has been removed from the
workflows - the sections below describe KeyLocker, which is what actually runs.
Windows code signing is essential for:
- Preventing Windows SmartScreen warnings
- Building trust with users
@@ -49,29 +54,19 @@ openssl pkcs12 -export -out certificate.pfx -inkey private-key.key -in certifica
### Required Secrets
Navigate to your GitHub repository → Settings → Secrets and variables → Actions
Navigate to your GitHub repository → Settings → Environments → `release-signing`.
Add the following secrets:
These live in the `release-signing` environment, not at repository scope. That
environment requires reviewer approval and is limited to `main`, `release`,
`hotfix/*` and `v*` tags. All five come from the DigiCert ONE console.
#### 1. `WINDOWS_CERTIFICATE`
- **Description**: Base64-encoded .pfx certificate file
- **How to create**:
**On macOS/Linux:**
```bash
base64 -i certificate.pfx | pbcopy # Copies to clipboard
```
**On Windows (PowerShell):**
```powershell
[Convert]::ToBase64String([IO.File]::ReadAllBytes("certificate.pfx")) | Set-Clipboard
```
Paste the entire base64 string into the GitHub secret.
#### 2. `WINDOWS_CERTIFICATE_PASSWORD`
- **Description**: Password for the .pfx certificate
- **Value**: The password you set when creating/exporting the .pfx file
| Secret | Description |
| --- | --- |
| `SM_API_KEY` | KeyLocker API key. Also acts as the on/off switch: signing steps are gated on it being non-empty. |
| `SM_CLIENT_CERT_FILE_B64` | Base64-encoded PKCS#12 client authentication certificate. |
| `SM_CLIENT_CERT_PASSWORD` | Password for that client certificate. |
| `SM_KEYPAIR_ALIAS` | Alias of the signing keypair to use. |
| `SM_HOST` | DigiCert ONE host, e.g. `https://clientauth.one.digicert.com`. |
### Optional Secrets for Tauri Updater
@@ -110,23 +105,23 @@ The Windows signing configuration is already set up:
### 2. GitHub Workflow (.github/workflows/tauri-build.yml)
The workflow includes three Windows signing steps:
The workflow includes four Windows signing steps, all gated on `SM_API_KEY` being
set and the ref being the release branch:
1. **Import Certificate**: Decodes and imports the .pfx certificate into Windows certificate store
2. **Build Tauri App**: Builds and signs the application using the imported certificate
3. **Verify Signature**: Validates that both .exe and .msi files are properly signed
1. **Setup DigiCert KeyLocker**: Installs the DigiCert signing tools via `digicert/ssm-code-signing`
2. **Setup DigiCert KeyLocker Certificate**: Writes the client cert and exports the PKCS#11 config
3. **Configure Windows code signing / Build Tauri app**: Signs through the PKCS#11 provider
4. **Verify Windows Code Signature**: Validates that the .exe and .msi are properly signed
## Testing the Setup
### 1. Local Testing (Windows Only)
Before pushing to GitHub, test locally:
KeyLocker is CI-only. To check signing locally, install your own certificate into
the Windows store and point Tauri at it; the build no longer reads any certificate
from an environment variable.
```powershell
# Set environment variables
$env:WINDOWS_CERTIFICATE = [Convert]::ToBase64String([IO.File]::ReadAllBytes("certificate.pfx"))
$env:WINDOWS_CERTIFICATE_PASSWORD = "your-certificate-password"
# Build the application
cd frontend
npm run tauri build
@@ -191,9 +186,10 @@ Look for:
- Consider EV certificate for immediate reputation
### Certificate Not Found During Build
- Verify `WINDOWS_CERTIFICATE` secret is set
- Check base64 encoding is correct (no extra whitespace)
- Ensure password is correct
- Verify `SM_API_KEY` is present in the `release-signing` environment. If it is empty
the signing steps skip silently and the build succeeds unsigned.
- Check `SM_CLIENT_CERT_FILE_B64` base64 encoding is correct (no extra whitespace)
- Ensure `SM_CLIENT_CERT_PASSWORD` and `SM_KEYPAIR_ALIAS` match the DigiCert keypair
## Security Best Practices
@@ -220,11 +216,10 @@ Look for:
## Certificate Lifecycle
### Before Expiration
1. Obtain new certificate from CA (typically annual renewal)
2. Convert to .pfx format if needed
3. Update `WINDOWS_CERTIFICATE` secret with new base64-encoded certificate
4. Update `WINDOWS_CERTIFICATE_PASSWORD` if password changed
5. Test build to verify new certificate works
1. Renew the certificate in the DigiCert ONE console (typically annual)
2. If the keypair alias changed, update `SM_KEYPAIR_ALIAS` in the `release-signing` environment
3. If the client authentication certificate was reissued, update `SM_CLIENT_CERT_FILE_B64` and `SM_CLIENT_CERT_PASSWORD`
4. Test build to verify the new certificate works
### Expired Certificates
- Signed binaries remain valid (timestamp proves signing time)
+35 -17
View File
@@ -1,15 +1,16 @@
###############################################################################
# Stirling-PDF SaaS environment defaults.
# Stirling-PDF SaaS environment defaults. Committed, non-secret. Real values for secrets go in
# .env.saas.local, which is loaded first and wins. Do not commit that file.
#
# This file is committed and provides non-secret defaults loaded by
# `task backend:dev:saas`. Put real values for secrets (passwords, project
# refs, edge function secrets) in `.env.saas.local` - any variable set there
# takes precedence over what's defined here.
# Three environments, each deriving its Supabase URLs, JWT issuer and JWKS from one project ref:
#
# DO NOT commit `.env.saas.local`. Only `.env.saas` is checked in.
###############################################################################
# prod PROFILES=none SAAS_DB_* the live project
# staging PROFILES=staging SAAS_STAGING_* pinned to v3, always there
# dev PROFILES=dev SAAS_DEV_* follows a SaaS PR's preview branch
#
# dev is the default for `task backend:dev:saas`. Use staging for somewhere stable; use dev when
# testing an open SaaS PR, since its preview branch is the only place those migrations are applied.
# ---------- Supabase project ----------
# ---------- Supabase project (prod / no-profile) ----------
# Project reference (the subdomain part of <ref>.supabase.co). Required.
# Set in .env.saas.local.
SAAS_DB_PROJECT_REF=
@@ -17,18 +18,35 @@ SAAS_DB_PROJECT_REF=
# Edge function secret used by billing/license rollup calls. Set in .env.saas.local.
SUPABASE_EDGE_FUNCTION_SECRET=
# ---------- Database (saas profile) ----------
# Direct JDBC URL to the Supabase Postgres. Required when running the plain
# `saas` profile (i.e. without `--spring.profiles.include=dev`).
# ---------- Database (no profile) ----------
# Direct JDBC URL to the Supabase Postgres. Required when running without
# `--spring.profiles.include=...`.
# Example: jdbc:postgresql://db.<project-ref>.supabase.co:5432/postgres
SAAS_DB_URL=
SAAS_DB_USERNAME=postgres
SAAS_DB_PASSWORD=
# ---------- Database (dev profile overrides) ----------
# Used when `--spring.profiles.include=dev` is active. The dev profile
# defaults the URL/username to the shared dev Supabase project, but the
# password must still be provided in .env.saas.local.
SAAS_DEV_DB_URL=
# ---------- staging profile ----------
# The shared long-lived v3 project. application-staging.properties defaults the ref,
# URL, database host and meter endpoint, so staging needs only the password, in
# .env.saas.local. Set SAAS_STAGING_PROJECT_REF to repoint it; everything derives.
#
# The ref and publishable key are duplicated here because the task derives the
# frontend's VITE_SUPABASE_* from them and a shell cannot read a Spring default.
# Neither is secret: the ref is a public subdomain, the key ships in the bundle.
SAAS_STAGING_PROJECT_REF=qacaivhsjtftfwtgjvva
SAAS_STAGING_PUBLISHABLE_KEY=sb_publishable_nIM8y-9ARPE7EzQwAQHKMg_40fCN6kY # gitleaks:allow
SAAS_STAGING_DB_USERNAME=postgres
SAAS_STAGING_DB_PASSWORD=
# ---------- dev profile ----------
# The SaaS PR's Supabase preview branch. Take the ref from that PR's "Supabase
# Preview" check; the profile derives URL, JWT issuer, JWKS, meter endpoint and
# database host from it, so this one value follows a different PR.
#
# A preview branch has its own password and keys; the parent project's will not
# authenticate. Both go in .env.saas.local, along with the ref.
SAAS_DEV_PROJECT_REF=
SAAS_DEV_PUBLISHABLE_KEY=
SAAS_DEV_DB_USERNAME=postgres
SAAS_DEV_DB_PASSWORD=
+2 -2
View File
@@ -21,8 +21,8 @@ dependencies {
api 'org.snakeyaml:snakeyaml-engine:3.0.1'
api "org.springdoc:springdoc-openapi-starter-webmvc-ui:3.0.3"
// Simple Java Mail for EML/MSG parsing (replaces direct Angus Mail usage)
api 'org.simplejavamail:simple-java-mail:9.2.0'
api 'org.simplejavamail:outlook-module:9.2.0' // MSG file support
api 'org.simplejavamail:simple-java-mail:9.3.1'
api 'org.simplejavamail:outlook-module:9.3.1' // MSG file support
api 'jakarta.mail:jakarta.mail-api:2.1.5'
runtimeOnly 'org.eclipse.angus:angus-mail:2.0.5'
@@ -6,6 +6,7 @@ import java.util.Map;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.stereotype.Service;
@@ -13,6 +14,7 @@ import lombok.Getter;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.service.PdfaLevelAServiceInterface;
@Service
@Slf4j
@@ -51,12 +53,16 @@ public class EndpointConfiguration {
private Map<String, DisableReason> groupDisableReasons = new ConcurrentHashMap<>();
private Map<String, Set<String>> endpointAlternatives = new ConcurrentHashMap<>();
private final boolean runningProOrHigher;
private final boolean pdfUaAvailable;
public EndpointConfiguration(
ApplicationProperties applicationProperties,
@Qualifier("runningProOrHigher") boolean runningProOrHigher) {
@Qualifier("runningProOrHigher") boolean runningProOrHigher,
@Autowired(required = false) PdfaLevelAServiceInterface pdfaLevelAService) {
this.applicationProperties = applicationProperties;
this.runningProOrHigher = runningProOrHigher;
// The PDF/UA tagger ships in the proprietary module, and so do its endpoints.
this.pdfUaAvailable = pdfaLevelAService != null;
init();
processEnvironmentConfigs();
}
@@ -356,6 +362,7 @@ public class EndpointConfiguration {
addEndpointToGroup("Convert", "pdf-to-img");
addEndpointToGroup("Convert", "img-to-pdf");
addEndpointToGroup("Convert", "pdf-to-pdfa");
addEndpointToGroup("Convert", "pdf-to-ua");
addEndpointToGroup("Convert", "file-to-pdf");
addEndpointToGroup("Convert", "pdf-to-word");
addEndpointToGroup("Convert", "pdf-to-presentation");
@@ -395,6 +402,7 @@ public class EndpointConfiguration {
// Backend-only endpoints (not in frontend tool registry endpoints)
addEndpointToGroup("Security", "redact");
addEndpointToGroup("Security", "verify-pdf");
addEndpointToGroup("Security", "accessibility-report");
addEndpointToGroup("Security", "sign");
// Adding endpoints to "Other" group
@@ -529,6 +537,8 @@ public class EndpointConfiguration {
addEndpointToGroup("Java", "json-to-pdf");
addEndpointToGroup("Java", "pdf-to-video");
addEndpointToGroup("Java", "verify-pdf");
addEndpointToGroup("Java", "pdf-to-ua");
addEndpointToGroup("Java", "accessibility-report");
addEndpointToGroup("Java", "flatten");
addEndpointToGroup("Java", "unlock-pdf-forms");
addEndpointToGroup("Java", "validate-signature");
@@ -600,6 +610,8 @@ public class EndpointConfiguration {
// veraPDF dependent endpoints
addEndpointToGroup("veraPDF", "verify-pdf");
addEndpointToGroup("veraPDF", "pdf-to-ua");
addEndpointToGroup("veraPDF", "accessibility-report");
// Pdftohtml dependent endpoints
addEndpointToGroup("Pdftohtml", "pdf-to-html");
@@ -630,6 +642,11 @@ public class EndpointConfiguration {
disableGroup("enterprise");
}
if (!pdfUaAvailable) {
disableEndpoint("pdf-to-ua");
disableEndpoint("accessibility-report");
}
if (!applicationProperties.getSystem().isEnableUrlToPDF()) {
disableEndpoint("url-to-pdf");
}
@@ -0,0 +1,22 @@
package stirling.software.common.service;
import java.util.List;
/**
* Raises a converted PDF/A file from conformance level B to level A, which needs the tagging the
* PDF/UA tagger does. Implemented only in the proprietary module; core builds convert at level B.
*/
public interface PdfaLevelAServiceInterface {
/**
* @param levelA true only when the file was tagged and validated, so the claim is never a guess
*/
record Result(byte[] pdfBytes, boolean levelA, List<String> warnings) {}
/**
* @param part PDF/A part, 1 to 3; part 1 keeps its PDF 1.4 version
* @param alsoDeclareUa additionally claim PDF/UA, but only if it validates
*/
Result upgradeToLevelA(
byte[] pdfBytes, int part, String language, String title, boolean alsoDeclareUa);
}
@@ -17,6 +17,7 @@ import org.junit.jupiter.api.Test;
import stirling.software.SPDF.config.EndpointConfiguration.DisableReason;
import stirling.software.SPDF.config.EndpointConfiguration.EndpointAvailability;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.service.PdfaLevelAServiceInterface;
/**
* Unit tests for {@link EndpointConfiguration}. The class wires up its endpoint/group registry in
@@ -32,7 +33,14 @@ class EndpointConfigurationGapTest {
* Construct an EndpointConfiguration with the given pro flag and current applicationProperties.
*/
private EndpointConfiguration build(boolean runningProOrHigher) {
return new EndpointConfiguration(applicationProperties, runningProOrHigher);
return build(runningProOrHigher, null);
}
/** The PDF/UA service is only present in proprietary builds, so it is injected separately. */
private EndpointConfiguration build(
boolean runningProOrHigher, PdfaLevelAServiceInterface pdfaLevelAService) {
return new EndpointConfiguration(
applicationProperties, runningProOrHigher, pdfaLevelAService);
}
/** Default config: not pro, no removals, url-to-pdf disabled (default System flag is false). */
@@ -177,6 +185,28 @@ class EndpointConfigurationGapTest {
}
}
@Nested
@DisplayName("PDF/UA availability")
class PdfUaTests {
@Test
@DisplayName("the PDF/UA endpoints are off when the proprietary tagger is absent")
void disabledWithoutTagger() {
EndpointConfiguration config = build(false, null);
assertFalse(config.isEndpointEnabled("pdf-to-ua"));
assertFalse(config.isEndpointEnabled("accessibility-report"));
}
@Test
@DisplayName("they are on once the tagger is on the classpath")
void enabledWithTagger() {
EndpointConfiguration config =
build(false, (pdfBytes, part, language, title, alsoDeclareUa) -> null);
assertTrue(config.isEndpointEnabled("pdf-to-ua"));
assertTrue(config.isEndpointEnabled("accessibility-report"));
}
}
@Nested
@DisplayName("group enable / disable")
class GroupTests {
@@ -11,6 +11,7 @@ import java.time.Instant;
import java.time.ZoneId;
import java.time.ZonedDateTime;
import java.util.*;
import java.util.Locale;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
import java.util.stream.Stream;
@@ -71,6 +72,7 @@ import org.apache.xmpbox.schema.PDFAIdentificationSchema;
import org.apache.xmpbox.schema.XMPBasicSchema;
import org.apache.xmpbox.xml.DomXmpParser;
import org.apache.xmpbox.xml.XmpSerializer;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.core.io.Resource;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
@@ -83,7 +85,6 @@ import io.github.pixee.security.Filenames;
import io.swagger.v3.oas.annotations.Operation;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.SPDF.model.api.converters.PdfToPdfARequest;
@@ -93,6 +94,7 @@ import stirling.software.common.configuration.RuntimePathConfig;
import stirling.software.common.enumeration.ResourceWeight;
import stirling.software.common.model.tool.ToolFormat;
import stirling.software.common.model.tool.ToolIO;
import stirling.software.common.service.PdfaLevelAServiceInterface;
import stirling.software.common.util.ExceptionUtils;
import stirling.software.common.util.ProcessExecutor;
import stirling.software.common.util.ProcessExecutor.ProcessExecutorResult;
@@ -102,14 +104,26 @@ import stirling.software.common.util.WebResponseUtils;
@ConvertApi
@Slf4j
@RequiredArgsConstructor
public class ConvertPDFToPDFA {
private static final Pattern NON_PRINTABLE_ASCII = Pattern.compile("[^\\x20-\\x7E]");
private final RuntimePathConfig runtimePathConfig;
private final stirling.software.SPDF.service.VeraPDFService veraPDFService;
// Level A needs the proprietary tagger; core builds convert at level B instead.
private final PdfaLevelAServiceInterface pdfaLevelAService;
private final TempFileManager tempFileManager;
public ConvertPDFToPDFA(
RuntimePathConfig runtimePathConfig,
stirling.software.SPDF.service.VeraPDFService veraPDFService,
@Autowired(required = false) PdfaLevelAServiceInterface pdfaLevelAService,
TempFileManager tempFileManager) {
this.runtimePathConfig = runtimePathConfig;
this.veraPDFService = veraPDFService;
this.pdfaLevelAService = pdfaLevelAService;
this.tempFileManager = tempFileManager;
}
private static final String ICC_RESOURCE_PATH = "/icc/sRGB2014.icc";
private static final int PDFA_COMPATIBILITY_POLICY = 1;
@@ -604,7 +618,10 @@ public class ConvertPDFToPDFA {
return handlePdfXConversion(inputFile, outputFormat);
} else {
return handlePdfAConversion(
inputFile, outputFormat, request.getStrict() != null && request.getStrict());
inputFile,
outputFormat,
request.getStrict() != null && request.getStrict(),
request.getPdfUa() != null && request.getPdfUa());
}
}
@@ -1815,8 +1832,64 @@ public class ConvertPDFToPDFA {
return Files.readAllBytes(outputPdf);
}
/** Tags a converted PDF/A for level A; must run after Ghostscript, which discards tags. */
private PdfaLevelAServiceInterface.Result applyLevelA(
byte[] converted,
Path original,
PdfaProfile profile,
String baseFileName,
boolean declarePdfUa) {
if (!profile.requiresTagging()) {
return new PdfaLevelAServiceInterface.Result(converted, true, List.of());
}
if (pdfaLevelAService == null) {
return new PdfaLevelAServiceInterface.Result(
converted,
false,
List.of(
"Level A tagging is not available in this build, so the file was left"
+ " at conformance level B."));
}
// Prefer the document's own title/language; hardcoding "en" mislabelled German reports.
// Read the original, not the converted bytes: Ghostscript discards /Lang, so probing its
// output always yields null and every document would be relabelled with the default.
String language = null;
String title = null;
try (PDDocument probe = Loader.loadPDF(original.toFile())) {
language = probe.getDocumentCatalog().getLanguage();
title = probe.getDocumentInformation().getTitle();
} catch (IOException e) {
log.debug("Could not read original title/language: {}", e.getMessage());
}
if (language == null || language.isBlank()) {
try (PDDocument probe = Loader.loadPDF(converted)) {
language = probe.getDocumentCatalog().getLanguage();
if (title == null || title.isBlank()) {
title = probe.getDocumentInformation().getTitle();
}
} catch (IOException e) {
log.debug("Could not read converted title/language: {}", e.getMessage());
}
}
PdfaLevelAServiceInterface.Result result =
pdfaLevelAService.upgradeToLevelA(
converted,
profile.getPart(),
language,
title != null && !title.isBlank() ? title : baseFileName,
declarePdfUa);
result.warnings().forEach(warning -> log.info("PDF/A level A: {}", warning));
if (!result.levelA()) {
log.warn(
"{} requested but the document could not be tagged; returning level B",
profile.getDisplayName());
}
return result;
}
private ResponseEntity<Resource> handlePdfAConversion(
MultipartFile inputFile, String outputFormat, boolean strict) throws Exception {
MultipartFile inputFile, String outputFormat, boolean strict, boolean declarePdfUa)
throws Exception {
PdfaProfile profile = PdfaProfile.fromRequest(outputFormat);
// Get the original filename without extension
@@ -1841,12 +1914,15 @@ public class ConvertPDFToPDFA {
log.info("Using Ghostscript for PDF/A conversion to {}", profile.getDisplayName());
try {
converted = convertWithGhostscript(inputPath, workingDir, profile);
String outputFilename = baseFileName + profile.outputSuffix();
var levelA =
applyLevelA(converted, inputPath, profile, baseFileName, declarePdfUa);
converted = levelA.pdfBytes();
String outputFilename = baseFileName + profile.outputSuffix(levelA.levelA());
validateAndWarnPdfA(converted, profile, "Ghostscript");
if (strict) {
verifyStrictCompliance(converted);
verifyStrictCompliance(converted, profile, levelA.levelA());
}
TempFile tempOut = tempFileManager.createManagedTempFile(".pdf");
@@ -1867,13 +1943,15 @@ public class ConvertPDFToPDFA {
}
converted = convertWithPdfBoxMethod(inputPath, profile);
String outputFilename = baseFileName + profile.outputSuffix();
var levelA = applyLevelA(converted, inputPath, profile, baseFileName, declarePdfUa);
converted = levelA.pdfBytes();
String outputFilename = baseFileName + profile.outputSuffix(levelA.levelA());
// Validate with PDFBox preflight and warn if issues found
validateAndWarnPdfA(converted, profile, "PDFBox/LibreOffice");
if (strict) {
verifyStrictCompliance(converted);
verifyStrictCompliance(converted, profile, levelA.levelA());
}
TempFile tempOut = tempFileManager.createManagedTempFile(".pdf");
@@ -1889,11 +1967,56 @@ public class ConvertPDFToPDFA {
}
}
private void verifyStrictCompliance(byte[] pdfBytes) throws IOException {
/** True for a PDF/UA or WCAG result, which says nothing about archival conformance. */
private static boolean isAccessibilityProfile(
stirling.software.SPDF.model.api.security.PDFVerificationResult result) {
String profile = result.getValidationProfile();
if (profile == null) {
return false;
}
String normalised = profile.toLowerCase(Locale.ROOT);
return normalised.contains("ua") || normalised.contains("wcag");
}
/**
* True when a result speaks for the requested profile. Only archival results count, and a level
* B pass must never satisfy a level A request.
*/
private static boolean answersRequest(
PdfaProfile profile,
stirling.software.SPDF.model.api.security.PDFVerificationResult result) {
if (isAccessibilityProfile(result)) {
return false;
}
String standard = result.getStandard();
if (standard == null || standard.length() < 2) {
return false;
}
if (standard.charAt(0) != Character.forDigit(profile.getPart(), 10)) {
return false;
}
return !profile.requiresTagging() || Character.toLowerCase(standard.charAt(1)) == 'a';
}
private void verifyStrictCompliance(byte[] pdfBytes, PdfaProfile profile, boolean levelAReached)
throws IOException {
// Tagging is the only route to level A, so an untagged file cannot answer a strict request.
if (!levelAReached) {
throw new ResponseStatusException(
HttpStatus.BAD_REQUEST,
"Strict PDF/A mode enabled: the document could not be tagged, so "
+ profile.getDisplayName()
+ " was not reached. It is valid at level B.");
}
try (InputStream is = new ByteArrayInputStream(pdfBytes)) {
List<stirling.software.SPDF.model.api.security.PDFVerificationResult> results =
veraPDFService.validatePDF(is);
boolean isCompliant = results.stream().anyMatch(result -> result.isCompliant());
boolean isCompliant =
results.stream()
.filter(result -> answersRequest(profile, result))
.anyMatch(
stirling.software.SPDF.model.api.security.PDFVerificationResult
::isCompliant);
if (!isCompliant) {
String details =
results.stream()
@@ -1901,7 +2024,9 @@ public class ConvertPDFToPDFA {
.collect(Collectors.joining("; "));
throw new ResponseStatusException(
HttpStatus.BAD_REQUEST,
"Strict PDF/A mode enabled: Conversion is not perfectly compliant. Details: "
"Strict PDF/A mode enabled: the output is not perfectly compliant with "
+ profile.getDisplayName()
+ ". Details: "
+ details);
}
} catch (Exception e) {
@@ -2466,11 +2591,16 @@ public class ConvertPDFToPDFA {
@Getter
private enum PdfaProfile {
PDF_A_1B(1, "PDF/A-1b", "_PDFA-1b.pdf", "1.4", Format.PDF_A1B, "pdfa-1"),
PDF_A_2B(2, "PDF/A-2b", "_PDFA-2b.pdf", "1.7", null, "pdfa", "pdfa-2", "pdfa-2b"),
PDF_A_3B(3, "PDF/A-3b", "_PDFA-3b.pdf", "1.7", null, "pdfa-3", "pdfa-3b");
PDF_A_1B(1, "B", "PDF/A-1b", "_PDFA-1b.pdf", "1.4", Format.PDF_A1B, "pdfa-1"),
PDF_A_2B(2, "B", "PDF/A-2b", "_PDFA-2b.pdf", "1.7", null, "pdfa", "pdfa-2", "pdfa-2b"),
PDF_A_3B(3, "B", "PDF/A-3b", "_PDFA-3b.pdf", "1.7", null, "pdfa-3", "pdfa-3b"),
// Level A = level B plus tagging, declared language and Unicode text; tagged post-convert.
PDF_A_1A(1, "A", "PDF/A-1a", "_PDFA-1a.pdf", "1.4", Format.PDF_A1B, "pdfa-1a"),
PDF_A_2A(2, "A", "PDF/A-2a", "_PDFA-2a.pdf", "1.7", null, "pdfa-2a"),
PDF_A_3A(3, "A", "PDF/A-3a", "_PDFA-3a.pdf", "1.7", null, "pdfa-3a");
private final int part;
private final String conformanceLevel;
private final String displayName;
private final String suffix;
private final String compatibilityLevel;
@@ -2479,12 +2609,14 @@ public class ConvertPDFToPDFA {
PdfaProfile(
int part,
String conformanceLevel,
String displayName,
String suffix,
String compatibilityLevel,
Format preflightFormat,
String... requestTokens) {
this.part = part;
this.conformanceLevel = conformanceLevel;
this.displayName = displayName;
this.suffix = suffix;
this.compatibilityLevel = compatibilityLevel;
@@ -2495,6 +2627,10 @@ public class ConvertPDFToPDFA {
.toList();
}
boolean requiresTagging() {
return "A".equals(conformanceLevel);
}
static PdfaProfile fromRequest(String requestToken) {
if (requestToken == null) {
return PDF_A_2B;
@@ -2508,8 +2644,11 @@ public class ConvertPDFToPDFA {
return match.orElse(PDF_A_2B);
}
String outputSuffix() {
return suffix;
/**
* Names the file at the level actually reached; a level A name over level B content lies.
*/
String outputSuffix(boolean levelAReached) {
return levelAReached ? suffix : "_PDFA-" + part + "b.pdf";
}
Optional<Format> preflightFormat() {
@@ -14,9 +14,19 @@ public class PdfToPdfARequest extends PDFFile {
@Schema(
description = "The output format type (PDF/A or PDF/X)",
requiredMode = Schema.RequiredMode.REQUIRED,
allowableValues = {"pdfa", "pdfa-1", "pdfa-2", "pdfa-2b", "pdfa-3", "pdfa-3b", "pdfx"})
allowableValues = {
"pdfa", "pdfa-1", "pdfa-2", "pdfa-2b", "pdfa-3", "pdfa-3b", "pdfa-1a", "pdfa-2a",
"pdfa-3a", "pdfx"
})
private String outputFormat;
@Schema(
description =
"Also declare PDF/UA accessibility alongside PDF/A. Only applies to the level A"
+ " formats, and the claim is written only if it validates.",
defaultValue = "false")
private Boolean pdfUa;
@Schema(
description =
"If true, the conversion will fail if the output is not perfectly compliant")
@@ -285,6 +285,8 @@ public class VeraPDFService {
}
}
// Never force PDF/UA here - it flags every ordinary document as non-compliant and doubles
// verify cost; /accessibility-report checks PDF/UA on demand.
if (!hasPdfaDeclaration) {
results.add(createNoPdfaDeclarationResult());
}
@@ -14,6 +14,13 @@
"moduleLicense": "GNU Lesser General Public License",
"moduleLicenseUrl": "https://www.gnu.org/licenses/old-licenses/lgpl-2.1.html"
},
{
"moduleName": "ch.qos.logback:logback-classic",
"moduleUrl": "http://www.qos.ch",
"moduleVersion": "1.6.1",
"moduleLicense": "LGPL-2.1-only",
"moduleLicenseUrl": "https://www.gnu.org/licenses/old-licenses/lgpl-2.1.html"
},
{
"moduleName": "ch.qos.logback:logback-core",
"moduleUrl": "http://www.qos.ch",
@@ -21,6 +28,13 @@
"moduleLicense": "GNU Lesser General Public License",
"moduleLicenseUrl": "https://www.gnu.org/licenses/old-licenses/lgpl-2.1.html"
},
{
"moduleName": "ch.qos.logback:logback-core",
"moduleUrl": "http://www.qos.ch",
"moduleVersion": "1.6.1",
"moduleLicense": "LGPL-2.1-only",
"moduleLicenseUrl": "https://www.gnu.org/licenses/old-licenses/lgpl-2.1.html"
},
{
"moduleName": "com.adobe.xmp:xmpcore",
"moduleUrl": "https://www.adobe.com/devnet/xmp/library/eula-xmp-library-java.html",
@@ -182,7 +196,7 @@
{
"moduleName": "com.github.mwiede:jsch",
"moduleUrl": "https://github.com/mwiede/jsch",
"moduleVersion": "0.2.23",
"moduleVersion": "2.28.6",
"moduleLicense": "Revised BSD",
"moduleLicenseUrl": "https://github.com/mwiede/jsch/blob/master/LICENSE.txt"
},
@@ -513,27 +527,45 @@
"moduleLicense": "The BSD License",
"moduleLicenseUrl": "https://github.com/haraldk/TwelveMonkeys#license"
},
{
"moduleName": "com.twelvemonkeys.common:common-image",
"moduleVersion": "3.14.0",
"moduleLicense": "The BSD License",
"moduleLicenseUrl": "https://github.com/haraldk/TwelveMonkeys#license"
},
{
"moduleName": "com.twelvemonkeys.common:common-io",
"moduleVersion": "3.13.1",
"moduleLicense": "The BSD License",
"moduleLicenseUrl": "https://github.com/haraldk/TwelveMonkeys#license"
},
{
"moduleName": "com.twelvemonkeys.common:common-io",
"moduleVersion": "3.14.0",
"moduleLicense": "The BSD License",
"moduleLicenseUrl": "https://github.com/haraldk/TwelveMonkeys#license"
},
{
"moduleName": "com.twelvemonkeys.common:common-lang",
"moduleVersion": "3.13.1",
"moduleLicense": "The BSD License",
"moduleLicenseUrl": "https://github.com/haraldk/TwelveMonkeys#license"
},
{
"moduleName": "com.twelvemonkeys.common:common-lang",
"moduleVersion": "3.14.0",
"moduleLicense": "The BSD License",
"moduleLicenseUrl": "https://github.com/haraldk/TwelveMonkeys#license"
},
{
"moduleName": "com.twelvemonkeys.imageio:imageio-batik",
"moduleVersion": "3.13.1",
"moduleVersion": "3.14.0",
"moduleLicense": "The BSD License",
"moduleLicenseUrl": "https://github.com/haraldk/TwelveMonkeys#license"
},
{
"moduleName": "com.twelvemonkeys.imageio:imageio-bmp",
"moduleVersion": "3.13.1",
"moduleVersion": "3.14.0",
"moduleLicense": "The BSD License",
"moduleLicenseUrl": "https://github.com/haraldk/TwelveMonkeys#license"
},
@@ -543,9 +575,15 @@
"moduleLicense": "The BSD License",
"moduleLicenseUrl": "https://github.com/haraldk/TwelveMonkeys#license"
},
{
"moduleName": "com.twelvemonkeys.imageio:imageio-core",
"moduleVersion": "3.14.0",
"moduleLicense": "The BSD License",
"moduleLicenseUrl": "https://github.com/haraldk/TwelveMonkeys#license"
},
{
"moduleName": "com.twelvemonkeys.imageio:imageio-jpeg",
"moduleVersion": "3.13.1",
"moduleVersion": "3.14.0",
"moduleLicense": "The BSD License",
"moduleLicenseUrl": "https://github.com/haraldk/TwelveMonkeys#license"
},
@@ -555,9 +593,15 @@
"moduleLicense": "The BSD License",
"moduleLicenseUrl": "https://github.com/haraldk/TwelveMonkeys#license"
},
{
"moduleName": "com.twelvemonkeys.imageio:imageio-metadata",
"moduleVersion": "3.14.0",
"moduleLicense": "The BSD License",
"moduleLicenseUrl": "https://github.com/haraldk/TwelveMonkeys#license"
},
{
"moduleName": "com.twelvemonkeys.imageio:imageio-psd",
"moduleVersion": "3.13.1",
"moduleVersion": "3.14.0",
"moduleLicense": "The BSD License",
"moduleLicenseUrl": "https://github.com/haraldk/TwelveMonkeys#license"
},
@@ -567,12 +611,24 @@
"moduleLicense": "The BSD License",
"moduleLicenseUrl": "https://github.com/haraldk/TwelveMonkeys#license"
},
{
"moduleName": "com.twelvemonkeys.imageio:imageio-tiff",
"moduleVersion": "3.14.0",
"moduleLicense": "The BSD License",
"moduleLicenseUrl": "https://github.com/haraldk/TwelveMonkeys#license"
},
{
"moduleName": "com.twelvemonkeys.imageio:imageio-webp",
"moduleVersion": "3.13.1",
"moduleLicense": "The BSD License",
"moduleLicenseUrl": "https://github.com/haraldk/TwelveMonkeys#license"
},
{
"moduleName": "com.twelvemonkeys.imageio:imageio-webp",
"moduleVersion": "3.14.0",
"moduleLicense": "The BSD License",
"moduleLicenseUrl": "https://github.com/haraldk/TwelveMonkeys#license"
},
{
"moduleName": "com.vladsch.flexmark:flexmark",
"moduleVersion": "0.64.8",
@@ -758,7 +814,7 @@
{
"moduleName": "commons-net:commons-net",
"moduleUrl": "https://commons.apache.org/proper/commons-net/",
"moduleVersion": "3.11.1",
"moduleVersion": "3.13.0",
"moduleLicense": "Apache-2.0",
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0.txt"
},
@@ -1213,24 +1269,48 @@
"moduleLicense": "The Apache Software License, Version 2.0",
"moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt"
},
{
"moduleName": "net.shibboleth:shib-networking",
"moduleVersion": "9.2.2",
"moduleLicense": "The Apache Software License, Version 2.0",
"moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt"
},
{
"moduleName": "net.shibboleth:shib-security",
"moduleVersion": "9.1.6",
"moduleLicense": "The Apache Software License, Version 2.0",
"moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt"
},
{
"moduleName": "net.shibboleth:shib-security",
"moduleVersion": "9.2.2",
"moduleLicense": "The Apache Software License, Version 2.0",
"moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt"
},
{
"moduleName": "net.shibboleth:shib-support",
"moduleVersion": "9.1.6",
"moduleLicense": "The Apache Software License, Version 2.0",
"moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt"
},
{
"moduleName": "net.shibboleth:shib-support",
"moduleVersion": "9.2.2",
"moduleLicense": "The Apache Software License, Version 2.0",
"moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt"
},
{
"moduleName": "net.shibboleth:shib-velocity",
"moduleVersion": "9.1.6",
"moduleLicense": "The Apache Software License, Version 2.0",
"moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt"
},
{
"moduleName": "net.shibboleth:shib-velocity",
"moduleVersion": "9.2.2",
"moduleLicense": "The Apache Software License, Version 2.0",
"moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt"
},
{
"moduleName": "org.antlr:antlr4-runtime",
"moduleUrl": "https://www.antlr.org/",
@@ -1325,13 +1405,6 @@
"moduleLicense": "Apache License, Version 2.0",
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0.txt"
},
{
"moduleName": "org.apache.httpcomponents:httpclient",
"moduleUrl": "http://hc.apache.org/httpcomponents-client",
"moduleVersion": "4.5.13",
"moduleLicense": "Apache License, Version 2.0",
"moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt"
},
{
"moduleName": "org.apache.httpcomponents:httpclient",
"moduleUrl": "http://hc.apache.org/httpcomponents-client-ga",
@@ -1456,6 +1529,13 @@
"moduleLicense": "Apache-2.0",
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0.txt"
},
{
"moduleName": "org.apache.santuario:xmlsec",
"moduleUrl": "https://www.apache.org/",
"moduleVersion": "3.0.6",
"moduleLicense": "Apache-2.0",
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0.txt"
},
{
"moduleName": "org.apache.tomcat.embed:tomcat-embed-el",
"moduleUrl": "https://tomcat.apache.org/",
@@ -1470,6 +1550,13 @@
"moduleLicense": "Apache License, Version 2.0",
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0.txt"
},
{
"moduleName": "org.apache.velocity:velocity-engine-core",
"moduleUrl": "https://www.apache.org/",
"moduleVersion": "2.4.1",
"moduleLicense": "Apache-2.0",
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0.txt"
},
{
"moduleName": "org.apache.xmlbeans:xmlbeans",
"moduleUrl": "https://xmlbeans.apache.org/",
@@ -1647,6 +1734,13 @@
"moduleLicense": "GNU Lesser General Public License",
"moduleLicenseUrl": "http://www.gnu.org/licenses/lgpl-3.0.txt"
},
{
"moduleName": "org.cryptacular:cryptacular",
"moduleUrl": "https://www.cryptacular.org",
"moduleVersion": "1.3.0",
"moduleLicense": "GNU Lesser General Public License",
"moduleLicenseUrl": "https://www.gnu.org/licenses/lgpl-3.0.txt"
},
{
"moduleName": "org.eclipse.angus:angus-activation",
"moduleUrl": "https://www.eclipse.org",
@@ -2016,78 +2110,156 @@
"moduleLicense": "The Apache Software License, Version 2.0",
"moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt"
},
{
"moduleName": "org.opensaml:opensaml-core-api",
"moduleVersion": "5.2.2",
"moduleLicense": "The Apache Software License, Version 2.0",
"moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt"
},
{
"moduleName": "org.opensaml:opensaml-core-impl",
"moduleVersion": "5.1.6",
"moduleLicense": "The Apache Software License, Version 2.0",
"moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt"
},
{
"moduleName": "org.opensaml:opensaml-core-impl",
"moduleVersion": "5.2.2",
"moduleLicense": "The Apache Software License, Version 2.0",
"moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt"
},
{
"moduleName": "org.opensaml:opensaml-messaging-api",
"moduleVersion": "5.1.6",
"moduleLicense": "The Apache Software License, Version 2.0",
"moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt"
},
{
"moduleName": "org.opensaml:opensaml-messaging-api",
"moduleVersion": "5.2.2",
"moduleLicense": "The Apache Software License, Version 2.0",
"moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt"
},
{
"moduleName": "org.opensaml:opensaml-profile-api",
"moduleVersion": "5.1.6",
"moduleLicense": "The Apache Software License, Version 2.0",
"moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt"
},
{
"moduleName": "org.opensaml:opensaml-profile-api",
"moduleVersion": "5.2.2",
"moduleLicense": "The Apache Software License, Version 2.0",
"moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt"
},
{
"moduleName": "org.opensaml:opensaml-saml-api",
"moduleVersion": "5.1.6",
"moduleLicense": "The Apache Software License, Version 2.0",
"moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt"
},
{
"moduleName": "org.opensaml:opensaml-saml-api",
"moduleVersion": "5.2.2",
"moduleLicense": "The Apache Software License, Version 2.0",
"moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt"
},
{
"moduleName": "org.opensaml:opensaml-saml-impl",
"moduleVersion": "5.1.6",
"moduleLicense": "The Apache Software License, Version 2.0",
"moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt"
},
{
"moduleName": "org.opensaml:opensaml-saml-impl",
"moduleVersion": "5.2.2",
"moduleLicense": "The Apache Software License, Version 2.0",
"moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt"
},
{
"moduleName": "org.opensaml:opensaml-security-api",
"moduleVersion": "5.1.6",
"moduleLicense": "The Apache Software License, Version 2.0",
"moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt"
},
{
"moduleName": "org.opensaml:opensaml-security-api",
"moduleVersion": "5.2.2",
"moduleLicense": "The Apache Software License, Version 2.0",
"moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt"
},
{
"moduleName": "org.opensaml:opensaml-security-impl",
"moduleVersion": "5.1.6",
"moduleLicense": "The Apache Software License, Version 2.0",
"moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt"
},
{
"moduleName": "org.opensaml:opensaml-security-impl",
"moduleVersion": "5.2.2",
"moduleLicense": "The Apache Software License, Version 2.0",
"moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt"
},
{
"moduleName": "org.opensaml:opensaml-soap-api",
"moduleVersion": "5.1.6",
"moduleLicense": "The Apache Software License, Version 2.0",
"moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt"
},
{
"moduleName": "org.opensaml:opensaml-soap-api",
"moduleVersion": "5.2.2",
"moduleLicense": "The Apache Software License, Version 2.0",
"moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt"
},
{
"moduleName": "org.opensaml:opensaml-soap-impl",
"moduleVersion": "5.1.6",
"moduleLicense": "The Apache Software License, Version 2.0",
"moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt"
},
{
"moduleName": "org.opensaml:opensaml-soap-impl",
"moduleVersion": "5.2.2",
"moduleLicense": "The Apache Software License, Version 2.0",
"moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt"
},
{
"moduleName": "org.opensaml:opensaml-storage-api",
"moduleVersion": "5.1.6",
"moduleLicense": "The Apache Software License, Version 2.0",
"moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt"
},
{
"moduleName": "org.opensaml:opensaml-storage-api",
"moduleVersion": "5.2.2",
"moduleLicense": "The Apache Software License, Version 2.0",
"moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt"
},
{
"moduleName": "org.opensaml:opensaml-xmlsec-api",
"moduleVersion": "5.1.6",
"moduleLicense": "The Apache Software License, Version 2.0",
"moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt"
},
{
"moduleName": "org.opensaml:opensaml-xmlsec-api",
"moduleVersion": "5.2.2",
"moduleLicense": "The Apache Software License, Version 2.0",
"moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt"
},
{
"moduleName": "org.opensaml:opensaml-xmlsec-impl",
"moduleVersion": "5.1.6",
"moduleLicense": "The Apache Software License, Version 2.0",
"moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt"
},
{
"moduleName": "org.opensaml:opensaml-xmlsec-impl",
"moduleVersion": "5.2.2",
"moduleLicense": "The Apache Software License, Version 2.0",
"moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt"
},
{
"moduleName": "org.ow2.asm:asm",
"moduleUrl": "http://asm.ow2.org",
@@ -2132,7 +2304,7 @@
},
{
"moduleName": "org.simplejavamail:core-module",
"moduleVersion": "9.2.0",
"moduleVersion": "9.3.1",
"moduleLicense": "The Apache Software License, Version 2.0",
"moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt"
},
@@ -2145,13 +2317,13 @@
},
{
"moduleName": "org.simplejavamail:outlook-module",
"moduleVersion": "9.2.0",
"moduleVersion": "9.3.1",
"moduleLicense": "The Apache Software License, Version 2.0",
"moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt"
},
{
"moduleName": "org.simplejavamail:simple-java-mail",
"moduleVersion": "9.2.0",
"moduleVersion": "9.3.1",
"moduleLicense": "The Apache Software License, Version 2.0",
"moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt"
},
@@ -2564,6 +2736,13 @@
"moduleLicense": "Apache License, Version 2.0",
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0"
},
{
"moduleName": "org.springframework.security:spring-security-core",
"moduleUrl": "https://spring.io/projects/spring-security",
"moduleVersion": "7.1.0",
"moduleLicense": "Apache License, Version 2.0",
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0"
},
{
"moduleName": "org.springframework.security:spring-security-crypto",
"moduleUrl": "https://spring.io/projects/spring-security",
@@ -2606,6 +2785,13 @@
"moduleLicense": "Apache License, Version 2.0",
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0"
},
{
"moduleName": "org.springframework.security:spring-security-saml2-service-provider",
"moduleUrl": "https://spring.io/projects/spring-security",
"moduleVersion": "7.1.0",
"moduleLicense": "Apache License, Version 2.0",
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0"
},
{
"moduleName": "org.springframework.security:spring-security-web",
"moduleUrl": "https://spring.io/projects/spring-security",
@@ -2805,207 +2991,207 @@
},
{
"moduleName": "software.amazon.awssdk:annotations",
"moduleVersion": "2.44.12",
"moduleVersion": "2.51.3",
"moduleLicense": "Apache License, Version 2.0",
"moduleLicenseUrl": "https://aws.amazon.com/apache2.0"
},
{
"moduleName": "software.amazon.awssdk:apache-client",
"moduleVersion": "2.44.12",
"moduleName": "software.amazon.awssdk:apache5-client",
"moduleVersion": "2.51.3",
"moduleLicense": "Apache License, Version 2.0",
"moduleLicenseUrl": "https://aws.amazon.com/apache2.0"
},
{
"moduleName": "software.amazon.awssdk:arns",
"moduleUrl": "https://aws.amazon.com/sdkforjava",
"moduleVersion": "2.44.12",
"moduleVersion": "2.51.3",
"moduleLicense": "Apache License, Version 2.0",
"moduleLicenseUrl": "https://aws.amazon.com/apache2.0"
},
{
"moduleName": "software.amazon.awssdk:auth",
"moduleUrl": "https://aws.amazon.com/sdkforjava",
"moduleVersion": "2.44.12",
"moduleVersion": "2.51.3",
"moduleLicense": "Apache License, Version 2.0",
"moduleLicenseUrl": "https://aws.amazon.com/apache2.0"
},
{
"moduleName": "software.amazon.awssdk:aws-core",
"moduleUrl": "https://aws.amazon.com/sdkforjava",
"moduleVersion": "2.44.12",
"moduleVersion": "2.51.3",
"moduleLicense": "Apache License, Version 2.0",
"moduleLicenseUrl": "https://aws.amazon.com/apache2.0"
},
{
"moduleName": "software.amazon.awssdk:aws-query-protocol",
"moduleUrl": "https://aws.amazon.com/sdkforjava",
"moduleVersion": "2.44.12",
"moduleVersion": "2.51.3",
"moduleLicense": "Apache License, Version 2.0",
"moduleLicenseUrl": "https://aws.amazon.com/apache2.0"
},
{
"moduleName": "software.amazon.awssdk:aws-xml-protocol",
"moduleUrl": "https://aws.amazon.com/sdkforjava",
"moduleVersion": "2.44.12",
"moduleVersion": "2.51.3",
"moduleLicense": "Apache License, Version 2.0",
"moduleLicenseUrl": "https://aws.amazon.com/apache2.0"
},
{
"moduleName": "software.amazon.awssdk:checksums",
"moduleUrl": "https://aws.amazon.com/sdkforjava",
"moduleVersion": "2.44.12",
"moduleVersion": "2.51.3",
"moduleLicense": "Apache License, Version 2.0",
"moduleLicenseUrl": "https://aws.amazon.com/apache2.0"
},
{
"moduleName": "software.amazon.awssdk:checksums-spi",
"moduleUrl": "https://aws.amazon.com/sdkforjava",
"moduleVersion": "2.44.12",
"moduleVersion": "2.51.3",
"moduleLicense": "Apache License, Version 2.0",
"moduleLicenseUrl": "https://aws.amazon.com/apache2.0"
},
{
"moduleName": "software.amazon.awssdk:crt-core",
"moduleUrl": "https://aws.amazon.com/sdkforjava",
"moduleVersion": "2.44.12",
"moduleVersion": "2.51.3",
"moduleLicense": "Apache License, Version 2.0",
"moduleLicenseUrl": "https://aws.amazon.com/apache2.0"
},
{
"moduleName": "software.amazon.awssdk:endpoints-spi",
"moduleVersion": "2.44.12",
"moduleVersion": "2.51.3",
"moduleLicense": "Apache License, Version 2.0",
"moduleLicenseUrl": "https://aws.amazon.com/apache2.0"
},
{
"moduleName": "software.amazon.awssdk:http-auth",
"moduleUrl": "https://aws.amazon.com/sdkforjava",
"moduleVersion": "2.44.12",
"moduleVersion": "2.51.3",
"moduleLicense": "Apache License, Version 2.0",
"moduleLicenseUrl": "https://aws.amazon.com/apache2.0"
},
{
"moduleName": "software.amazon.awssdk:http-auth-aws",
"moduleUrl": "https://aws.amazon.com/sdkforjava",
"moduleVersion": "2.44.12",
"moduleVersion": "2.51.3",
"moduleLicense": "Apache License, Version 2.0",
"moduleLicenseUrl": "https://aws.amazon.com/apache2.0"
},
{
"moduleName": "software.amazon.awssdk:http-auth-aws-eventstream",
"moduleUrl": "https://aws.amazon.com/sdkforjava",
"moduleVersion": "2.44.12",
"moduleVersion": "2.51.3",
"moduleLicense": "Apache License, Version 2.0",
"moduleLicenseUrl": "https://aws.amazon.com/apache2.0"
},
{
"moduleName": "software.amazon.awssdk:http-auth-spi",
"moduleUrl": "https://aws.amazon.com/sdkforjava",
"moduleVersion": "2.44.12",
"moduleVersion": "2.51.3",
"moduleLicense": "Apache License, Version 2.0",
"moduleLicenseUrl": "https://aws.amazon.com/apache2.0"
},
{
"moduleName": "software.amazon.awssdk:http-client-spi",
"moduleVersion": "2.44.12",
"moduleVersion": "2.51.3",
"moduleLicense": "Apache License, Version 2.0",
"moduleLicenseUrl": "https://aws.amazon.com/apache2.0"
},
{
"moduleName": "software.amazon.awssdk:identity-spi",
"moduleUrl": "https://aws.amazon.com/sdkforjava",
"moduleVersion": "2.44.12",
"moduleVersion": "2.51.3",
"moduleLicense": "Apache License, Version 2.0",
"moduleLicenseUrl": "https://aws.amazon.com/apache2.0"
},
{
"moduleName": "software.amazon.awssdk:json-utils",
"moduleUrl": "https://aws.amazon.com/sdkforjava",
"moduleVersion": "2.44.12",
"moduleVersion": "2.51.3",
"moduleLicense": "Apache License, Version 2.0",
"moduleLicenseUrl": "https://aws.amazon.com/apache2.0"
},
{
"moduleName": "software.amazon.awssdk:metrics-spi",
"moduleVersion": "2.44.12",
"moduleVersion": "2.51.3",
"moduleLicense": "Apache License, Version 2.0",
"moduleLicenseUrl": "https://aws.amazon.com/apache2.0"
},
{
"moduleName": "software.amazon.awssdk:netty-nio-client",
"moduleVersion": "2.44.12",
"moduleVersion": "2.51.3",
"moduleLicense": "Apache License, Version 2.0",
"moduleLicenseUrl": "https://aws.amazon.com/apache2.0"
},
{
"moduleName": "software.amazon.awssdk:profiles",
"moduleUrl": "https://aws.amazon.com/sdkforjava",
"moduleVersion": "2.44.12",
"moduleVersion": "2.51.3",
"moduleLicense": "Apache License, Version 2.0",
"moduleLicenseUrl": "https://aws.amazon.com/apache2.0"
},
{
"moduleName": "software.amazon.awssdk:protocol-core",
"moduleUrl": "https://aws.amazon.com/sdkforjava",
"moduleVersion": "2.44.12",
"moduleVersion": "2.51.3",
"moduleLicense": "Apache License, Version 2.0",
"moduleLicenseUrl": "https://aws.amazon.com/apache2.0"
},
{
"moduleName": "software.amazon.awssdk:regions",
"moduleVersion": "2.44.12",
"moduleVersion": "2.51.3",
"moduleLicense": "Apache License, Version 2.0",
"moduleLicenseUrl": "https://aws.amazon.com/apache2.0"
},
{
"moduleName": "software.amazon.awssdk:retries",
"moduleVersion": "2.44.12",
"moduleVersion": "2.51.3",
"moduleLicense": "Apache License, Version 2.0",
"moduleLicenseUrl": "https://aws.amazon.com/apache2.0"
},
{
"moduleName": "software.amazon.awssdk:retries-spi",
"moduleVersion": "2.44.12",
"moduleVersion": "2.51.3",
"moduleLicense": "Apache License, Version 2.0",
"moduleLicenseUrl": "https://aws.amazon.com/apache2.0"
},
{
"moduleName": "software.amazon.awssdk:s3",
"moduleUrl": "https://aws.amazon.com/sdkforjava",
"moduleVersion": "2.44.12",
"moduleVersion": "2.51.3",
"moduleLicense": "Apache License, Version 2.0",
"moduleLicenseUrl": "https://aws.amazon.com/apache2.0"
},
{
"moduleName": "software.amazon.awssdk:sdk-core",
"moduleUrl": "https://aws.amazon.com/sdkforjava",
"moduleVersion": "2.44.12",
"moduleVersion": "2.51.3",
"moduleLicense": "Apache License, Version 2.0",
"moduleLicenseUrl": "https://aws.amazon.com/apache2.0"
},
{
"moduleName": "software.amazon.awssdk:third-party-jackson-core",
"moduleUrl": "https://aws.amazon.com/sdkforjava",
"moduleVersion": "2.44.12",
"moduleVersion": "2.51.3",
"moduleLicense": "Apache License, Version 2.0",
"moduleLicenseUrl": "https://aws.amazon.com/apache2.0"
},
{
"moduleName": "software.amazon.awssdk:url-connection-client",
"moduleVersion": "2.44.12",
"moduleVersion": "2.51.3",
"moduleLicense": "Apache License, Version 2.0",
"moduleLicenseUrl": "https://aws.amazon.com/apache2.0"
},
{
"moduleName": "software.amazon.awssdk:utils",
"moduleVersion": "2.44.12",
"moduleVersion": "2.51.3",
"moduleLicense": "Apache License, Version 2.0",
"moduleLicenseUrl": "https://aws.amazon.com/apache2.0"
},
{
"moduleName": "software.amazon.awssdk:utils-lite",
"moduleUrl": "https://aws.amazon.com/sdkforjava",
"moduleVersion": "2.44.12",
"moduleVersion": "2.51.3",
"moduleLicense": "Apache License, Version 2.0",
"moduleLicenseUrl": "https://aws.amazon.com/apache2.0"
},
@@ -46,6 +46,7 @@ import stirling.software.SPDF.model.api.converters.PdfToPdfARequest;
import stirling.software.SPDF.model.api.security.PDFVerificationResult;
import stirling.software.SPDF.service.VeraPDFService;
import stirling.software.common.configuration.RuntimePathConfig;
import stirling.software.common.service.PdfaLevelAServiceInterface;
import stirling.software.common.util.TempFileManager;
/**
@@ -62,10 +63,12 @@ class ConvertPDFToPDFAGapTest {
@Mock private RuntimePathConfig runtimePathConfig;
@Mock private VeraPDFService veraPDFService;
@Mock private PdfaLevelAServiceInterface pdfaLevelAService;
@Mock private TempFileManager tempFileManager;
private ConvertPDFToPDFA newController() {
return new ConvertPDFToPDFA(runtimePathConfig, veraPDFService, tempFileManager);
return new ConvertPDFToPDFA(
runtimePathConfig, veraPDFService, pdfaLevelAService, tempFileManager);
}
// ---- reflection helpers ----------------------------------------------------------------
@@ -161,9 +164,21 @@ class ConvertPDFToPDFAGapTest {
}
private String suffixOf(Object profile) throws Exception {
Method m = profile.getClass().getDeclaredMethod("outputSuffix");
return suffixOf(profile, true);
}
private String suffixOf(Object profile, boolean levelAReached) throws Exception {
Method m = profile.getClass().getDeclaredMethod("outputSuffix", boolean.class);
m.setAccessible(true);
return (String) m.invoke(profile);
return (String) m.invoke(profile, levelAReached);
}
@Test
@DisplayName("a level A profile falls back to the level B name when tagging failed")
void levelANotReachedIsNamedLevelB() throws Exception {
assertThat(suffixOf(resolveProfile("pdfa-1a"), false)).isEqualTo("_PDFA-1b.pdf");
assertThat(suffixOf(resolveProfile("pdfa-2a"), false)).isEqualTo("_PDFA-2b.pdf");
assertThat(suffixOf(resolveProfile("pdfa-3a"), true)).isEqualTo("_PDFA-3a.pdf");
}
@Test
@@ -717,6 +732,30 @@ class ConvertPDFToPDFAGapTest {
@DisplayName("verifyStrictCompliance (VeraPDFService mocked)")
class StrictCompliance {
private Object profile(String token) throws Exception {
Class<?> enumClass = null;
for (Class<?> inner : ConvertPDFToPDFA.class.getDeclaredClasses()) {
if (inner.getSimpleName().equals("PdfaProfile")) {
enumClass = inner;
}
}
Method m = enumClass.getDeclaredMethod("fromRequest", String.class);
m.setAccessible(true);
return m.invoke(null, token);
}
private Throwable verify(String token, boolean levelAReached) throws Exception {
ConvertPDFToPDFA controller = newController();
return catchThrowable(
() ->
invokeInstance(
controller,
"verifyStrictCompliance",
(Object) "dummy".getBytes(),
profile(token),
levelAReached));
}
@Test
@DisplayName("compliant result passes without throwing")
void compliantPasses() throws Exception {
@@ -726,14 +765,7 @@ class ConvertPDFToPDFAGapTest {
ok.setComplianceSummary("PDF/A-1b compliant");
when(veraPDFService.validatePDF(any())).thenReturn(List.of(ok));
ConvertPDFToPDFA controller = newController();
assertThatCode(
() ->
invokeInstance(
controller,
"verifyStrictCompliance",
(Object) "dummy".getBytes()))
.doesNotThrowAnyException();
assertThat(verify("pdfa-1", true)).isNull();
}
@Test
@@ -745,34 +777,70 @@ class ConvertPDFToPDFAGapTest {
bad.setComplianceSummary("PDF/A-1b with errors");
when(veraPDFService.validatePDF(any())).thenReturn(List.of(bad));
ConvertPDFToPDFA controller = newController();
ResponseStatusException ex =
(ResponseStatusException)
catchThrowable(
() ->
invokeInstance(
controller,
"verifyStrictCompliance",
(Object) "dummy".getBytes()));
ResponseStatusException ex = (ResponseStatusException) verify("pdfa-1", true);
assertThat(ex).isNotNull();
assertThat(ex.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
assertThat(ex.getReason()).contains("PDF/A-1b with errors");
}
@Test
@DisplayName("a level B pass does not satisfy a level A request")
void levelBDoesNotSatisfyLevelA() throws Exception {
PDFVerificationResult ok = new PDFVerificationResult();
ok.setCompliant(true);
ok.setStandard("1b");
ok.setComplianceSummary("PDF/A-1b compliant");
when(veraPDFService.validatePDF(any())).thenReturn(List.of(ok));
ResponseStatusException ex = (ResponseStatusException) verify("pdfa-1a", true);
assertThat(ex).isNotNull();
assertThat(ex.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
assertThat(ex.getReason()).contains("PDF/A-1a");
}
@Test
@DisplayName("a level A result satisfies a level A request")
void levelASatisfiesLevelA() throws Exception {
PDFVerificationResult ok = new PDFVerificationResult();
ok.setCompliant(true);
ok.setStandard("2a");
ok.setComplianceSummary("PDF/A-2a compliant");
when(veraPDFService.validatePDF(any())).thenReturn(List.of(ok));
assertThat(verify("pdfa-2a", true)).isNull();
}
@Test
@DisplayName("untagged output fails a level A request before validation runs")
void untaggedLevelARequestFails() throws Exception {
ResponseStatusException ex = (ResponseStatusException) verify("pdfa-2a", false);
assertThat(ex).isNotNull();
assertThat(ex.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
assertThat(ex.getReason()).contains("could not be tagged");
verifyNoInteractions(veraPDFService);
}
@Test
@DisplayName("a compliant PDF/UA result never satisfies a strict PDF/A request")
void accessibilityResultIsIgnored() throws Exception {
PDFVerificationResult ua = new PDFVerificationResult();
ua.setCompliant(true);
ua.setStandard("ua1");
ua.setValidationProfile("ua1");
ua.setComplianceSummary("PDF/UA-1 compliant");
when(veraPDFService.validatePDF(any())).thenReturn(List.of(ua));
ResponseStatusException ex = (ResponseStatusException) verify("pdfa-2b", true);
assertThat(ex).isNotNull();
assertThat(ex.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
}
@Test
@DisplayName("empty result list is treated as non-compliant -> 400")
void emptyResultsTreatedNonCompliant() throws Exception {
when(veraPDFService.validatePDF(any())).thenReturn(Collections.emptyList());
ConvertPDFToPDFA controller = newController();
ResponseStatusException ex =
(ResponseStatusException)
catchThrowable(
() ->
invokeInstance(
controller,
"verifyStrictCompliance",
(Object) "dummy".getBytes()));
ResponseStatusException ex = (ResponseStatusException) verify("pdfa-1", true);
assertThat(ex).isNotNull();
assertThat(ex.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
}
@@ -782,15 +850,7 @@ class ConvertPDFToPDFAGapTest {
void serviceErrorWrappedAs500() throws Exception {
when(veraPDFService.validatePDF(any())).thenThrow(new IOException("boom"));
ConvertPDFToPDFA controller = newController();
ResponseStatusException ex =
(ResponseStatusException)
catchThrowable(
() ->
invokeInstance(
controller,
"verifyStrictCompliance",
(Object) "dummy".getBytes()));
ResponseStatusException ex = (ResponseStatusException) verify("pdfa-1", true);
assertThat(ex).isNotNull();
assertThat(ex.getStatusCode()).isEqualTo(HttpStatus.INTERNAL_SERVER_ERROR);
}
@@ -42,6 +42,7 @@ import org.springframework.mock.web.MockMultipartFile;
import stirling.software.SPDF.model.api.converters.PdfToPdfARequest;
import stirling.software.SPDF.service.VeraPDFService;
import stirling.software.common.configuration.RuntimePathConfig;
import stirling.software.common.service.PdfaLevelAServiceInterface;
import stirling.software.common.util.ProcessExecutor;
import stirling.software.common.util.ProcessExecutor.ProcessExecutorResult;
import stirling.software.common.util.TempFile;
@@ -63,10 +64,12 @@ class ConvertPDFToPDFAMoreTest {
@Mock private RuntimePathConfig runtimePathConfig;
@Mock private VeraPDFService veraPDFService;
@Mock private PdfaLevelAServiceInterface pdfaLevelAService;
@Mock private TempFileManager tempFileManager;
private ConvertPDFToPDFA newController() {
return new ConvertPDFToPDFA(runtimePathConfig, veraPDFService, tempFileManager);
return new ConvertPDFToPDFA(
runtimePathConfig, veraPDFService, pdfaLevelAService, tempFileManager);
}
private static ResponseEntity<Resource> streamingOk(byte[] bytes) {
@@ -90,7 +90,9 @@ class VeraPDFServicePdfaFixtureTest {
() -> service.validatePDF(new ByteArrayInputStream(pdfBytes)),
"Empty veraPDF flavour list must not surface as IndexOutOfBoundsException");
assertEquals(1, results.size());
// One result: PDF/UA is checked by the dedicated accessibility-report endpoint, not here.
assertEquals(1, results.size(), () -> "Expected a single PDF/A result, got: " + results);
PDFVerificationResult result = results.get(0);
assertEquals("not-pdfa", result.getStandard());
assertFalse(result.isDeclaredPdfa());
@@ -161,13 +163,22 @@ class VeraPDFServicePdfaFixtureTest {
}
}
/** The PDF/A result; every document is also checked against PDF/UA, so filter that one out. */
private PDFVerificationResult onlyResult(byte[] pdfBytes) throws Exception {
List<PDFVerificationResult> results =
service.validatePDF(new ByteArrayInputStream(pdfBytes));
assertNotNull(results);
assertEquals(1, results.size(), () -> "Expected a single result, got: " + results);
return results.get(0);
List<PDFVerificationResult> pdfaResults =
results.stream().filter(r -> !isUaResult(r)).toList();
assertEquals(
1, pdfaResults.size(), () -> "Expected a single PDF/A result, got: " + results);
return pdfaResults.get(0);
}
private static boolean isUaResult(PDFVerificationResult result) {
String profile = result.getValidationProfile();
return profile != null && profile.toLowerCase().contains("ua");
}
private static String messages(PDFVerificationResult result) {
+10 -1
View File
@@ -27,7 +27,7 @@ dependencies {
api 'org.springframework.boot:spring-boot-starter-cache'
api 'com.github.ben-manes.caffeine:caffeine'
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
api 'io.swagger.core.v3:swagger-core-jakarta:2.2.46'
api 'io.swagger.core.v3:swagger-core-jakarta:2.2.53'
implementation "com.bucket4j:bucket4j_jdk17-core:${bucket4jVersion}"
// Lettuce-backed Bucket4j ProxyManager used by ValkeyRateLimitStore for cluster-wide
// token-bucket rate limiting (parity with in-process Bucket4j semantics; no fixed-window
@@ -37,6 +37,15 @@ dependencies {
// https://mvnrepository.com/artifact/com.bucket4j/bucket4j_jdk17
implementation "org.bouncycastle:bcprov-jdk18on:$bouncycastleVersion"
// PDF/UA tagging and its validation oracle.
implementation 'org.verapdf:validation-model:1.30.2'
// CVE-2025-66453: Explicit rhino 1.7.15 to override verapdf's 1.7.13
implementation "org.mozilla:rhino:${rhinoVersion}"
// veraPDF still uses javax.xml.bind, not the new jakarta namespace
implementation 'javax.xml.bind:jaxb-api:2.3.1'
runtimeOnly 'com.sun.xml.bind:jaxb-impl:2.3.9'
runtimeOnly 'com.sun.xml.bind:jaxb-core:4.0.9'
implementation "com.google.code.gson:gson:${gsonVersion}"
// jinjava/jjwt transitively request older Jackson 2 versions; declare the current
@@ -0,0 +1,176 @@
package stirling.software.proprietary.controller.api.converters;
import java.io.IOException;
import java.nio.file.Files;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.regex.Pattern;
import org.springframework.core.io.Resource;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.multipart.MultipartFile;
import io.github.pixee.security.Filenames;
import io.swagger.v3.oas.annotations.Operation;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.annotations.AutoJobPostMapping;
import stirling.software.common.annotations.api.ConvertApi;
import stirling.software.common.enumeration.ResourceWeight;
import stirling.software.common.model.tool.ToolFormat;
import stirling.software.common.model.tool.ToolIO;
import stirling.software.common.util.ExceptionUtils;
import stirling.software.common.util.TempFile;
import stirling.software.common.util.TempFileManager;
import stirling.software.common.util.WebResponseUtils;
import stirling.software.proprietary.model.api.converters.PdfToPdfUaRequest;
import stirling.software.proprietary.model.api.ua.PdfUaConversionOutcome;
import stirling.software.proprietary.pdf.ua.PdfUaProfile;
import stirling.software.proprietary.pdf.ua.TaggingOptions;
import stirling.software.proprietary.service.ua.PdfUaConversionService;
/** Converts a PDF to PDF/UA; response headers say whether the result actually conforms. */
@ConvertApi
@Slf4j
@RequiredArgsConstructor
public class ConvertPdfToPdfUa {
private static final String HEADER_DECLARED = "X-Stirling-UA-Declared";
private static final String HEADER_FAILURES = "X-Stirling-UA-Failures";
private static final String HEADER_ALT_NEEDED = "X-Stirling-UA-Figures-Needing-Alt";
private static final String HEADER_WARNINGS = "X-Stirling-UA-Warnings";
/** Any line ending, so descriptions pasted from any platform parse the same. */
private static final Pattern NEWLINE = Pattern.compile("\\R");
private final PdfUaConversionService conversionService;
private final TempFileManager tempFileManager;
@AutoJobPostMapping(
consumes = MediaType.MULTIPART_FORM_DATA_VALUE,
value = "/pdf/ua",
resourceWeight = ResourceWeight.LARGE_WEIGHT)
@ToolIO(produces = ToolFormat.PDF)
@Operation(
summary = "Convert a PDF to PDF/UA-1 or PDF/UA-2",
description =
"Tags the document, marks decorative content as artifacts, embeds fonts and"
+ " applies the document-level requirements of PDF/UA, then validates"
+ " the result. A conformance declaration is written only if validation"
+ " passes, so the returned file never claims more than it delivers.")
public ResponseEntity<Resource> pdfToPdfUa(@ModelAttribute PdfToPdfUaRequest request)
throws IOException {
MultipartFile input = request.getFileInput();
if (input == null || input.isEmpty()) {
throw ExceptionUtils.createPdfFileRequiredException();
}
String originalName = Filenames.toSimpleFileName(input.getOriginalFilename());
String stem = stripExtension(originalName == null ? "document" : originalName);
PdfUaProfile profile = PdfUaProfile.fromRequest(request.getProfile());
TaggingOptions options =
TaggingOptions.builder()
.profile(profile)
.title(request.getTitle())
.fallbackTitle(stem)
// Only used when the document declares no language of its own.
.language(
request.getLanguage() == null || request.getLanguage().isBlank()
? "en-GB"
: request.getLanguage())
.overrideLanguage(
request.getOverrideLanguage() != null
&& request.getOverrideLanguage())
.existingTags(existingTags(request.getExistingTags()))
.figurePolicy(figurePolicy(request.getFigurePolicy()))
.embedFonts(request.getEmbedFonts() == null || request.getEmbedFonts())
.altTextByFigure(parseAltText(request.getAltText()))
.build();
PdfUaConversionOutcome outcome = conversionService.convert(input.getBytes(), options);
log.info(
"Converted '{}' to {}: declared={}, {} remaining failure(s)",
originalName,
profile.displayName(),
outcome.declared(),
outcome.validation().totalFailures());
outcome.warnings().forEach(warning -> log.info("PDF/UA warning: {}", warning));
// Streamed from a temp file so a large conversion does not hold a second heap copy.
String suffix = outcome.declared() ? "_pdfua" + profile.part() : "_tagged";
TempFile tempOut = tempFileManager.createManagedTempFile(".pdf");
try {
Files.write(tempOut.getPath(), outcome.pdfBytes());
} catch (IOException e) {
tempOut.close();
throw e;
}
ResponseEntity<Resource> response =
WebResponseUtils.pdfFileToWebResponse(tempOut, stem + suffix + ".pdf");
return ResponseEntity.status(response.getStatusCode())
.headers(response.getHeaders())
.header(HEADER_DECLARED, String.valueOf(outcome.declared()))
.header(HEADER_FAILURES, String.valueOf(outcome.validation().totalFailures()))
.header(
HEADER_ALT_NEEDED,
String.valueOf(outcome.tagging().figuresNeedingAltText()))
// Count only: warning text is multi-line prose, which HTTP headers mangle.
.header(HEADER_WARNINGS, String.valueOf(outcome.warnings().size()))
.body(response.getBody());
}
/**
* Parses newline-separated {@code key=description} pairs, keyed as the report hands them out.
* Only the first "=" splits, since a description may contain one.
*/
public static Map<String, String> parseAltText(String raw) {
if (raw == null || raw.isBlank()) {
return Map.of();
}
Map<String, String> parsed = new LinkedHashMap<>();
for (String line : NEWLINE.split(raw)) {
int split = line.indexOf('=');
if (split <= 0) {
continue;
}
String key = line.substring(0, split).strip();
String description = line.substring(split + 1).strip();
if (!key.isEmpty() && !description.isEmpty()) {
parsed.put(key, description);
}
}
return parsed;
}
private static TaggingOptions.ExistingTags existingTags(String value) {
if (value == null) {
return TaggingOptions.ExistingTags.AUTO;
}
return switch (value.trim().toLowerCase()) {
case "keep" -> TaggingOptions.ExistingTags.KEEP;
case "rebuild" -> TaggingOptions.ExistingTags.REBUILD;
default -> TaggingOptions.ExistingTags.AUTO;
};
}
private static TaggingOptions.FigurePolicy figurePolicy(String value) {
if (value != null && value.trim().equalsIgnoreCase("mark-decorative")) {
return TaggingOptions.FigurePolicy.MARK_DECORATIVE;
}
return TaggingOptions.FigurePolicy.REQUIRE_ALT;
}
private static String stripExtension(String filename) {
int dot = filename.lastIndexOf('.');
return dot > 0 ? filename.substring(0, dot) : filename;
}
}
@@ -0,0 +1,67 @@
package stirling.software.proprietary.controller.api.security;
import java.io.IOException;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.multipart.MultipartFile;
import io.swagger.v3.oas.annotations.Operation;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.annotations.AutoJobPostMapping;
import stirling.software.common.annotations.api.SecurityApi;
import stirling.software.common.enumeration.ResourceWeight;
import stirling.software.common.model.tool.ToolFormat;
import stirling.software.common.model.tool.ToolIO;
import stirling.software.common.util.ExceptionUtils;
import stirling.software.proprietary.model.api.ua.AccessibilityReport;
import stirling.software.proprietary.model.api.ua.AccessibilityReportRequest;
import stirling.software.proprietary.pdf.ua.PdfUaProfile;
import stirling.software.proprietary.service.ua.AccessibilityAuditService;
/** Reports how accessible a document is, without modifying it. */
@SecurityApi
@RequiredArgsConstructor
@Slf4j
public class AccessibilityReportController {
private final AccessibilityAuditService auditService;
@ToolIO(produces = ToolFormat.JSON)
@Operation(
summary = "Report a document's accessibility standing",
description =
"Validates the document against PDF/UA and reports what fails, which failures"
+ " can be fixed automatically, and which checks still need a person."
+ " Does not modify the file.")
// Costs a full veraPDF pass plus the converter's own layout analysis over every page.
@AutoJobPostMapping(
value = "/accessibility-report",
consumes = MediaType.MULTIPART_FORM_DATA_VALUE,
resourceWeight = ResourceWeight.LARGE_WEIGHT)
public ResponseEntity<AccessibilityReport> report(
@ModelAttribute AccessibilityReportRequest request) {
MultipartFile file = request.getFileInput();
if (file == null || file.isEmpty()) {
throw ExceptionUtils.createPdfFileRequiredException();
}
PdfUaProfile profile = PdfUaProfile.fromRequest(request.getProfile());
try {
AccessibilityReport report = auditService.audit(file.getBytes(), profile);
log.info(
"Accessibility report for '{}': tagged={}, {} issue(s)",
file.getOriginalFilename(),
report.isTagged(),
report.getIssues().size());
return ResponseEntity.ok(report);
} catch (IOException e) {
throw ExceptionUtils.createRuntimeException(
"error.ioException", "Could not read the PDF: {0}", e, e.getMessage());
}
}
}
@@ -22,15 +22,13 @@ import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.proprietary.policy.config.PolicyManagementAuthority;
/**
* Read and triage recorded failures for the caller's team. Note the absence of a team parameter:
* the team comes from the authenticated principal, never the request.
* Read and triage recorded failures. Note the absence of a team parameter: the team comes from the
* authenticated principal, never the request.
*
* <p>Reviewing failures is a leader-level capability, gated the same way policy editing is: see
* {@link #requireFailureReviewAllowed()}.
* <p>Every endpoint is open to any authenticated user and scoped in the service instead: a leader
* reads and closes the whole team's failures, everyone else their own. Nothing here decides who may
* do what, so the two cannot drift apart.
*/
@Slf4j
@RestController
@@ -46,21 +44,21 @@ public class FileRunEventController {
private static final int DEFAULT_LIMIT = 50;
private final FileRunEventService service;
private final PolicyManagementAuthority policyManagementAuthority;
private final ApplicationProperties applicationProperties;
@GetMapping
@Operation(
summary = "List recorded failures",
description =
"Failures recorded for the caller's team, newest first. Each row carries its"
+ " available actions already resolved.")
"Failures the caller may see, newest first: their team's for a leader, their own"
+ " for everyone else. Each row carries its available actions already"
+ " resolved.")
public FileRunEventsResponse list(
// Spring's converter 400s on a value outside the enum, so no hand-rolled parse.
@RequestParam(required = false) FileRunEventStatus status,
@RequestParam(required = false) String kindId,
@RequestParam(required = false) Integer limit) {
requireFailureReviewAllowed();
// No role gate: the service scopes the read instead, so a member gets their own failures
// and a leader the team's.
int cappedLimit = Math.min(limit == null ? DEFAULT_LIMIT : Math.max(1, limit), MAX_LIMIT);
List<FileRunEventView> events =
@@ -82,7 +80,8 @@ public class FileRunEventController {
@PathVariable String eventId,
@PathVariable String actionId,
@RequestBody(required = false) ActionRequest request) {
requireFailureReviewAllowed();
// No role gate: the service decides, which lets someone close their own failure while
// still keeping a colleague's out of reach.
Map<String, String> inputs = request == null ? Map.of() : request.safeInputs();
try {
FileRunEvent updated = service.dispatch(eventId, actionId, inputs);
@@ -97,9 +96,9 @@ public class FileRunEventController {
summary = "Report a failure hit in the editor",
description =
"For failures the server never sees, because the editor calls tools directly."
+ " Open to any authenticated user, unlike the read and triage endpoints:"
+ " whoever's work failed can say so, and a leader reviews it. Rejected"
+ " with 400 if it names more files than one report may carry.")
+ " Open to any authenticated user: whoever's work failed can say so, and"
+ " reads it back scoped to themselves. Rejected with 400 if it names"
+ " more files than one report may carry.")
public ResponseEntity<Void> report(@RequestBody EditorFailureReport report) {
if (report == null || !report.hasOperation()) {
throw new ResponseStatusException(
@@ -128,8 +127,8 @@ public class FileRunEventController {
summary = "Close the incidents about files deleted from the editor",
description =
"Deleting the document leaves nothing to act on, so its incidents drop out of"
+ " the queue while the rows stay for audit. Open to any authenticated"
+ " user, and applies only to their own editor rows.")
+ " the queue while the rows stay for audit. Applies only to the"
+ " caller's own editor rows, however senior they are.")
public ResponseEntity<Void> filesRemoved(@RequestBody(required = false) RemovedFiles request) {
service.forgetFiles(request == null ? List.of() : request.safeFileIds());
// No body: the editor is telling the server, not asking it anything.
@@ -143,29 +142,11 @@ public class FileRunEventController {
"The failure registry. Lets a client describe kinds it was not built with, and"
+ " doubles as the probe for whether failure tracking exists at all.")
public List<FailureKindView> kinds() {
requireFailureReviewAllowed();
// The registry is copy and metadata, not anyone's data, and a member needs it to render the
// failures they can already see.
return Arrays.stream(FailureKind.values()).map(FailureKindView::of).toList();
}
/**
* Triage is for a team leader (SaaS) or admin (self-hosted), mirroring {@code
* PolicyController.requirePolicyEditingAllowed()} rather than inventing a second notion of who
* manages a team's automation: a member can trigger runs, a leader reviews them.
*
* <p>Login disabled means a single-user deployment with no roles to tell apart, the same
* carve-out the policy endpoints make. Team scoping is separate, and lives in the service.
*/
private void requireFailureReviewAllowed() {
if (!applicationProperties.getSecurity().isEnableLogin()) {
return;
}
if (!policyManagementAuthority.canEditPolicies()) {
throw new ResponseStatusException(
HttpStatus.FORBIDDEN,
"Recorded failures may only be reviewed by a team leader");
}
}
/**
* A closed row is a conflict rather than a bad request: the request was well-formed and would
* have been valid a moment earlier.
@@ -20,26 +20,33 @@ public interface FileRunEventRepository extends JpaRepository<FileRunEventEntity
* As {@link #findByTeamAndStatus} but for a set of statuses, e.g. the open ones. The kind
* filter is in the query, before the limit: filtering an already-limited page could return
* nothing while matching rows exist.
*
* <p>{@code actor} narrows to one person's own failures. Null means the whole team, which only
* a leader ever asks for: see {@code FileRunEventService#readScope}.
*/
@Query(
"select e from FileRunEventEntity e where ((:teamId is null and e.teamId is null) or"
+ " e.teamId = :teamId) and e.status in :statuses"
+ " and (:kindId is null or e.kindId = :kindId) order by e.lastSeenAt desc")
+ " and (:kindId is null or e.kindId = :kindId)"
+ " and (:actor is null or e.actor = :actor) order by e.lastSeenAt desc")
List<FileRunEventEntity> findByTeamAndStatusIn(
@Param("teamId") Long teamId,
@Param("statuses") List<FileRunEventStatus> statuses,
@Param("kindId") String kindId,
@Param("actor") String actor,
Pageable pageable);
/** As {@link #findByTeamAndStatusIn} but for exactly one status, for the surface's filters. */
@Query(
"select e from FileRunEventEntity e where ((:teamId is null and e.teamId is null) or"
+ " e.teamId = :teamId) and e.status = :status"
+ " and (:kindId is null or e.kindId = :kindId) order by e.lastSeenAt desc")
+ " and (:kindId is null or e.kindId = :kindId)"
+ " and (:actor is null or e.actor = :actor) order by e.lastSeenAt desc")
List<FileRunEventEntity> findByTeamAndStatus(
@Param("teamId") Long teamId,
@Param("status") FileRunEventStatus status,
@Param("kindId") String kindId,
@Param("actor") String actor,
Pageable pageable);
/**
@@ -13,12 +13,14 @@ import stirling.software.common.service.UserServiceInterface;
import stirling.software.proprietary.policy.config.PolicyManagementAuthority;
/**
* Reads and acts on incidents for the calling user's team.
* Reads and acts on the incidents the calling user is allowed to see, which is where that decision
* is made: a leader reads and closes the whole team's failures, everyone else their own. Keeping it
* here rather than on the endpoints means the read and the triage cannot drift apart.
*
* <p>Team scoping mirrors {@code PolicyAccessGuard}: everyone sees only their own team's rows, the
* team always comes from the authenticated principal, and scoping applies only when login is
* enabled so single-user deployments keep working. When the team cannot be resolved the caller
* reads nothing; see {@link #scope()}.
* reads nothing; see {@link #readScope()}.
*/
@Slf4j
@Service
@@ -41,7 +43,9 @@ public class FileRunEventService {
*/
public List<FileRunEvent> report(EditorFailureReport report) {
FailureKind kind = FailureKind.byErrorCode(report.errorCode()).orElse(FailureKind.UNKNOWN);
Long teamId = scope().teamId();
// The caller's team, not their read scope: recording is open to everyone, and a reader who
// may see nothing still has their failure filed under the team it happened in.
Long teamId = currentTeamId();
String actor = currentActor();
String detail = detailFor(report);
@@ -81,10 +85,14 @@ public class FileRunEventService {
* cleared cache or another device never will. Rows left open that way are retention's problem,
* not this method's.
*
* <p>Narrowed to the caller's own rows however senior they are, which is why it passes {@link
* #currentActor()} rather than the read scope's actor: file ids are minted by each client, so a
* leader reading with a null actor would match every unattributed row in the team.
*
* @return how many incidents were closed
*/
public int forgetFiles(List<String> fileIds) {
TeamScope scope = scope();
ReadScope scope = readScope();
if (!scope.permitted()) {
return 0;
}
@@ -92,13 +100,16 @@ public class FileRunEventService {
return store.markFilesRemoved(scope.teamId(), currentActor(), named);
}
/** The calling user's events, newest first. Empty when their team cannot be resolved. */
/**
* The events the caller may read, newest first: the team's for a leader, their own for everyone
* else. Empty when their team cannot be resolved.
*/
public List<FileRunEvent> list(FileRunEventStatus status, String kindId, int limit) {
TeamScope scope = scope();
ReadScope scope = readScope();
if (!scope.permitted()) {
return List.of();
}
return store.list(scope.teamId(), status, kindId, limit);
return store.list(scope.teamId(), status, kindId, scope.actor(), limit);
}
/**
@@ -108,7 +119,13 @@ public class FileRunEventService {
* event's kind does not declare the action, or the event is already closed
*/
public FileRunEvent dispatch(String eventId, String actionId, Map<String, String> inputs) {
TeamScope scope = scope();
// Whoever can see it can close it: a leader for the whole team, everyone else for the
// failures they caused. Someone who fixes their own problem should not have to ask a leader
// to clear the row.
//
// Closing the row is all this covers. Acting on the document behind it, such as supplying a
// password for a retry, would need its own permission, and no such action exists yet.
ReadScope scope = readScope();
if (!scope.permitted()) {
// Reported as "no such event", the same as an id from another team, so the response
// does
@@ -118,6 +135,12 @@ public class FileRunEventService {
}
FileRunEvent event =
store.find(eventId, scope.teamId())
// Reported as "no such event" rather than a refusal, so a member cannot
// learn that a colleague's incident exists by trying to close it.
.filter(
found ->
scope.actor() == null
|| scope.actor().equals(found.actor()))
.orElseThrow(
() ->
new FailureActionException(
@@ -179,31 +202,55 @@ public class FileRunEventService {
}
/**
* Which rows the caller may touch, since a null team id means two different things. Login
* disabled is the self-hosted setup with no users or teams, where unteamed rows are everyone's,
* as {@code PolicyAccessGuard} also treats them. Login enabled with no resolvable team reads
* nothing, because unteamed rows there are shared by every team's ad-hoc runs.
* Which rows the caller may read. A leader reviews the whole team's, as before. Everyone else
* reads the failures they caused themselves: a member can already report one, so letting them
* see their own back is what makes telling them about it worth anything, and it exposes nothing
* of a colleague's.
*
* <p>A null team id means two different things. Login disabled is the self-hosted setup with no
* users or teams, where unteamed rows are everyone's, as {@code PolicyAccessGuard} also treats
* them. Login enabled with no resolvable team reads nothing, because unteamed rows there are
* shared by every team's ad-hoc runs.
*/
private TeamScope scope() {
private ReadScope readScope() {
if (!enforced()) {
return TeamScope.of(null);
return ReadScope.wholeTeam(null);
}
Long teamId = policyManagementAuthority.currentUserTeamId();
return teamId == null ? TeamScope.denied() : TeamScope.of(teamId);
Long teamId = currentTeamId();
if (teamId == null) {
return ReadScope.denied();
}
if (policyManagementAuthority.canEditPolicies()) {
return ReadScope.wholeTeam(teamId);
}
// Narrowing to "mine" needs a name to narrow by. Without one the filter would be dropped
// and a member would read the whole team, so refuse rather than widen.
String actor = currentActor();
return actor == null ? ReadScope.denied() : ReadScope.mine(teamId, actor);
}
/**
* The caller's readable team, or a refusal. {@code teamId} is only meaningful when permitted.
* What the caller may read. {@code actor} is the person to narrow to, or null for the whole
* team; both are only meaningful when permitted.
*/
private record TeamScope(boolean permitted, Long teamId) {
private record ReadScope(boolean permitted, Long teamId, String actor) {
static TeamScope of(Long teamId) {
return new TeamScope(true, teamId);
static ReadScope wholeTeam(Long teamId) {
return new ReadScope(true, teamId, null);
}
static TeamScope denied() {
return new TeamScope(false, null);
static ReadScope mine(Long teamId, String actor) {
return new ReadScope(true, teamId, actor);
}
static ReadScope denied() {
return new ReadScope(false, null, null);
}
}
/** The team a row belongs to, which is nobody's when there are no teams to belong to. */
private Long currentTeamId() {
return enforced() ? policyManagementAuthority.currentUserTeamId() : null;
}
private String currentActor() {
@@ -121,16 +121,19 @@ public class FileRunEventStore {
*
* <p>Both filters live in the query, before the limit: filtering an already-limited page could
* return nothing while matching rows exist.
*
* <p>{@code actor} narrows to one person's own failures, or reads the whole team when null. Who
* gets which is the service's decision, not this method's.
*/
@Transactional(readOnly = true)
public List<FileRunEvent> list(
Long teamId, FileRunEventStatus status, String kindId, int limit) {
Long teamId, FileRunEventStatus status, String kindId, String actor, int limit) {
Pageable page = PageRequest.of(0, Math.max(1, limit));
List<FileRunEventEntity> rows =
status == null
? repository.findByTeamAndStatusIn(
teamId, FileRunEventStatus.open(), kindId, page)
: repository.findByTeamAndStatus(teamId, status, kindId, page);
teamId, FileRunEventStatus.open(), kindId, actor, page)
: repository.findByTeamAndStatus(teamId, status, kindId, actor, page);
return rows.stream().map(FileRunEvent::of).toList();
}
@@ -0,0 +1,73 @@
package stirling.software.proprietary.model.api.converters;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import lombok.EqualsAndHashCode;
import stirling.software.common.model.api.PDFFile;
@Data
@EqualsAndHashCode(callSuper = true)
public class PdfToPdfUaRequest extends PDFFile {
@Schema(
description = "PDF/UA conformance level to target",
defaultValue = "ua1",
allowableValues = {"ua1", "ua2"})
private String profile;
@Schema(
description =
"Document title, required by PDF/UA. Falls back to the first heading, then the"
+ " filename.")
private String title;
@Schema(
description =
"Document language as a BCP-47 tag, for example en-GB. Applied only when the"
+ " document does not already declare one, unless overrideLanguage is"
+ " set.",
defaultValue = "en-GB")
private String language;
@Schema(
description =
"Replace the language the document already declares. Off by default, so a"
+ " document is never relabelled into a language it is not written in.",
defaultValue = "false")
private Boolean overrideLanguage;
@Schema(
description =
"What to do with an existing structure tree: keep it, rebuild it, or decide"
+ " automatically",
defaultValue = "auto",
allowableValues = {"auto", "keep", "rebuild"})
private String existingTags;
@Schema(
description =
"How to treat images with no description. require-alt leaves them undescribed so"
+ " the report asks for input; mark-decorative treats every image as"
+ " decoration.",
defaultValue = "require-alt",
allowableValues = {"require-alt", "mark-decorative"})
private String figurePolicy;
@Schema(
description =
"Embed fonts the document references but does not carry. Required for"
+ " conformance and needs Ghostscript.",
defaultValue = "true")
private Boolean embedFonts;
@Schema(
description =
"Alternative descriptions for figures, as key=text pairs separated by newlines."
+ " Keys come from the accessibility-report endpoint's"
+ " figuresNeedingDescription list, for example \"0:12=Bar chart of"
+ " quarterly revenue\". Descriptions are never invented, so without"
+ " these an illustrated document cannot claim conformance.")
private String altText;
}
@@ -0,0 +1,38 @@
package stirling.software.proprietary.model.api.ua;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
/** One accessibility problem, grouped across all of its occurrences. */
@Data
@Schema(description = "A single accessibility issue found in a document")
public class AccessibilityIssue {
@Schema(description = "ISO 14289 clause, e.g. 7.3")
private String clause;
@Schema(description = "Test number within the clause")
private String testNumber;
@Schema(description = "Plain-English description of the problem")
private String message;
@Schema(description = "The validator's own wording, for support and debugging")
private String technicalMessage;
@Schema(description = "error or warning")
private String severity = "error";
@Schema(description = "Standard the check came from, e.g. PDF/UA-1")
private String specification;
@Schema(description = "Where the problem was found, when the validator reports it")
private String location;
@Schema(description = "How many times this issue occurs")
private int occurrences;
@Schema(description = "True when the converter can fix this without human input")
private boolean autoFixable;
}
@@ -0,0 +1,63 @@
package stirling.software.proprietary.model.api.ua;
import java.util.List;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
/**
* A document's accessibility standing. The machine/human split is load-bearing: veraPDF covers only
* about half of the Matterhorn Protocol, so a clean automated pass is not "accessible".
*/
@Data
@Schema(description = "Accessibility standing of a document")
public class AccessibilityReport {
@Schema(description = "Profile the document was checked against, e.g. PDF/UA-1")
private String profile;
@Schema(description = "Whether the document has a structure tree at all")
private boolean tagged;
@Schema(description = "Whether the document declares PDF/UA conformance in its metadata")
private boolean declaresConformance;
@Schema(description = "Whether every automated check passed")
private boolean passesAutomatedChecks;
@Schema(description = "Automated checks that failed, grouped by rule")
private List<AccessibilityIssue> issues = List.of();
@Schema(description = "Things a person still has to verify; automation cannot decide these")
private List<String> humanChecks = List.of();
@Schema(description = "How many of the failing checks the converter can fix on its own")
private int automaticallyFixable;
@Schema(description = "How many need information from the user, such as alternative text")
private int needsInput;
@Schema(
description =
"Figures that need an alternative description. Each carries the key to pass"
+ " back in the conversion request's altTextByFigure map, so a caller"
+ " can enumerate what is missing and then supply it.")
private List<FigureDescriptor> figuresNeedingDescription = List.of();
@Schema(description = "Document-level facts that drive most failures")
private Summary summary = new Summary();
@Data
@Schema(description = "Quick document-level facts")
public static class Summary {
private int pages;
private boolean hasTitle;
private boolean displaysDocTitle;
private boolean hasLanguage;
private boolean allFontsEmbedded;
private int unembeddedFonts;
private int figures;
private boolean encrypted;
}
}
@@ -0,0 +1,19 @@
package stirling.software.proprietary.model.api.ua;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import lombok.EqualsAndHashCode;
import stirling.software.common.model.api.PDFFile;
@Data
@EqualsAndHashCode(callSuper = true)
public class AccessibilityReportRequest extends PDFFile {
@Schema(
description = "Profile to check against",
defaultValue = "ua1",
allowableValues = {"ua1", "ua2"})
private String profile;
}
@@ -0,0 +1,18 @@
package stirling.software.proprietary.model.api.ua;
import io.swagger.v3.oas.annotations.media.Schema;
/**
* One figure needing an alternative description, which is never invented. key is the
* altTextByFigure key "pageIndex:ordinal"; page is 1-based; kind is "figure" or "formula".
*/
@Schema(description = "A figure that needs an alternative description")
public record FigureDescriptor(
String key,
int page,
String kind,
float x,
float y,
float width,
float height,
String existingAlt) {}
@@ -0,0 +1,26 @@
package stirling.software.proprietary.model.api.ua;
import java.util.List;
import io.swagger.v3.oas.annotations.media.Schema;
/**
* Result of a PDF/UA conversion.
*
* @param declared whether a {@code pdfuaid} conformance claim was written into {@code pdfBytes}
*/
@Schema(description = "Result of converting a document to PDF/UA")
public record PdfUaConversionOutcome(
byte[] pdfBytes,
boolean declared,
UaValidationResult validation,
TaggingSummary tagging,
List<String> warnings) {
@Schema(description = "What the tagging pass produced")
public record TaggingSummary(
boolean rebuiltStructure,
int taggedElements,
int artifacts,
int figuresNeedingAltText) {}
}
@@ -0,0 +1,18 @@
package stirling.software.proprietary.model.api.ua;
import java.util.List;
import io.swagger.v3.oas.annotations.media.Schema;
/**
* Outcome of validating against one PDF/UA profile. compliant means every automated check passed,
* which is not the same as usable by assistive technology; totalFailures is ungrouped.
*/
@Schema(description = "Result of validating a document against a PDF/UA profile")
public record UaValidationResult(
String profile, boolean compliant, List<AccessibilityIssue> issues, int totalFailures) {
public boolean hasIssues() {
return !issues.isEmpty();
}
}
@@ -0,0 +1,23 @@
package stirling.software.proprietary.pdf.ua;
/** Artifact subtypes (ISO 32000-1 14.8.2.2). Artifacts are excluded from the structure tree. */
public enum ArtifactType {
/** Running heads, folios, page numbers. Required by PDF/UA-1 clause 7.8. */
PAGINATION("Pagination"),
/** Rules, boxes, and other layout ornamentation. */
LAYOUT("Layout"),
/** Cut marks and colour bars. */
PAGE("Page"),
/** Background graphics with no informational content. */
BACKGROUND("Background");
private final String subtype;
ArtifactType(String subtype) {
this.subtype = subtype;
}
public String subtype() {
return subtype;
}
}
@@ -0,0 +1,48 @@
package stirling.software.proprietary.pdf.ua;
/** An axis-aligned rectangle in PDF user space, with y increasing upwards. */
public record BBox(float x0, float y0, float x1, float y1) {
public static final BBox EMPTY = new BBox(0, 0, 0, 0);
public static BBox of(float x, float y, float width, float height) {
return new BBox(x, y, x + width, y + height);
}
public float width() {
return x1 - x0;
}
public float height() {
return y1 - y0;
}
public float centreX() {
return (x0 + x1) / 2f;
}
public BBox union(BBox other) {
if (other == null || other.isEmpty()) {
return this;
}
if (isEmpty()) {
return other;
}
return new BBox(
Math.min(x0, other.x0),
Math.min(y0, other.y0),
Math.max(x1, other.x1),
Math.max(y1, other.y1));
}
public boolean isEmpty() {
return x1 <= x0 || y1 <= y0;
}
/** Horizontal overlap with another box as a fraction of the narrower box's width. */
public float horizontalOverlap(BBox other) {
float overlap = Math.min(x1, other.x1) - Math.max(x0, other.x0);
float narrower = Math.min(width(), other.width());
return narrower <= 0 ? 0 : Math.max(0, overlap) / narrower;
}
}
@@ -0,0 +1,84 @@
package stirling.software.proprietary.pdf.ua;
import java.util.ArrayList;
import java.util.List;
import java.util.function.Consumer;
import lombok.Getter;
import lombok.Setter;
/** The derived logical structure of a document, ready for serialisation into a structure tree. */
@Getter
@Setter
public class DocumentStructure {
/** Top-level blocks in document reading order. */
private final List<StructBlock> blocks = new ArrayList<>();
/** Warnings raised during analysis, surfaced in the conversion report. */
private final List<String> warnings = new ArrayList<>();
private String title;
private String language;
/** True when real text was wrapped as artifacts, which blocks any conformance claim. */
private boolean textSuppressed;
/** Body text size used as the baseline for heading detection, in points. */
private float bodyFontSize;
public void add(StructBlock block) {
blocks.add(block);
}
public void warn(String message) {
if (!warnings.contains(message)) {
warnings.add(message);
}
}
public void visit(Consumer<StructBlock> visitor) {
blocks.forEach(block -> block.visit(visitor));
}
public int count(StructType type) {
int[] total = {0};
visit(
block -> {
if (block.getType() == type) {
total[0]++;
}
});
return total[0];
}
public int artifactCount() {
int[] total = {0};
visit(
block -> {
if (block.isArtifact()) {
total[0]++;
}
});
return total[0];
}
/** Figures with no alternative description, the most common PDF/UA failure. */
public List<StructBlock> figuresWithoutAlt() {
List<StructBlock> missing = new ArrayList<>();
visit(
block -> {
if ((block.getType() == StructType.FIGURE
|| block.getType() == StructType.FORMULA)
&& (block.getAlt() == null || block.getAlt().isBlank())
&& (block.getActualText() == null || block.getActualText().isBlank())) {
missing.add(block);
}
});
return missing;
}
public boolean isEmpty() {
return blocks.isEmpty();
}
}
@@ -0,0 +1,831 @@
package stirling.software.proprietary.pdf.ua;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
import java.util.HashMap;
import java.util.HashSet;
import java.util.IdentityHashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
import lombok.extern.slf4j.Slf4j;
/**
* Derives a logical structure from extracted lines and graphics, reusing {@code HeadingDetector}'s
* heuristics. Degrades to paragraphs rather than guessing, since a wrong tag misleads readers.
*/
@Slf4j
public class LayoutAnalyzer {
private static final Pattern BULLET = Pattern.compile("^[•‣◦⁃∙·▪●■o\\-\\*\\+]\\s+.*");
private static final Pattern ORDERED =
Pattern.compile("^(\\d{1,3}|[a-zA-Z]|[ivxlcIVXLC]{1,5})[\\.\\)]\\s+.*");
private static final Pattern PAGE_NUMBER =
Pattern.compile(
"^(page\\s+)?\\d{1,4}(\\s*(of|/)\\s*\\d{1,4})?$", Pattern.CASE_INSENSITIVE);
private static final Pattern DIGITS = Pattern.compile("\\d+");
/** Fraction of page height treated as the running head / foot band. */
private static final float MARGIN_BAND = 0.10f;
/** A line must exceed the body size by this ratio before it can be a heading. */
private static final float HEADING_RATIO = 1.10f;
/** Sizes within this many points are treated as the same heading tier. */
private static final float TIER_TOLERANCE = 0.4f;
private static final int MAX_HEADING_WORDS = 12;
/** Word gap beyond this multiple of the font size separates table cells. */
private static final float CELL_GAP_RATIO = 1.2f;
/** Images smaller than this in either dimension are decoration, not content. */
private static final float MIN_FIGURE_SIZE = 12f;
/** A size used by more than this share of lines is body text, however large the median says. */
private static final float MAX_HEADING_LINE_SHARE = 0.2f;
/** Consecutive lines sharing a size are a text block; headings appear alone. */
private static final int MAX_HEADING_RUN = 3;
/** A vector thinner than this in either dimension is a rule or border, not a drawing. */
private static final float MIN_VECTOR_THICKNESS = 3f;
/** Vector clusters smaller than this are ornament; larger ones are probably a chart. */
private static final float MIN_VECTOR_FIGURE_SIZE = 40f;
/** A drawing is built from several strokes; one big rectangle is a panel, not a chart. */
private static final int MIN_VECTOR_FIGURE_OPS = 4;
/** More text than this inside the region means shading behind content, not a drawing. */
private static final int MAX_LINES_INSIDE_FIGURE = 2;
public DocumentStructure analyse(List<PageContent> pages) {
DocumentStructure structure = new DocumentStructure();
float bodySize = bodyFontSize(pages);
structure.setBodyFontSize(bodySize);
Map<Float, Integer> tiers = headingTiers(pages, bodySize);
Map<Integer, List<TextLineInfo>> artifactLines = repeatedMarginLines(pages, bodySize);
for (PageContent page : pages) {
analysePage(
page,
structure,
bodySize,
tiers,
artifactLines.getOrDefault(page.pageIndex(), List.of()));
}
List<Integer> suppressedPages =
pages.stream()
.filter(PageContent::linesDropped)
.map(PageContent::pageIndex)
.toList();
if (!suppressedPages.isEmpty()) {
structure.setTextSuppressed(true);
structure.warn(
"Text on page(s) "
+ suppressedPages.stream()
.map(i -> String.valueOf(i + 1))
.collect(Collectors.joining(", "))
+ " could not be tagged reliably and was marked as artifacts. The"
+ " converter will not claim conformance while real text is hidden"
+ " from assistive technology.");
}
normaliseHeadingLevels(structure);
structure.setTitle(deriveTitle(structure));
return structure;
}
// --- Document-wide statistics -----------------------------------------
/** Character-weighted median line size, which is far more stable than a plain median. */
static float bodyFontSize(List<PageContent> pages) {
Map<Float, Integer> weights = new HashMap<>();
for (PageContent page : pages) {
for (TextLineInfo line : page.lines()) {
if (line.dominantFontSize() > 0 && !line.isBlank()) {
weights.merge(line.dominantFontSize(), line.charCount(), Integer::sum);
}
}
}
if (weights.isEmpty()) {
return 0f;
}
int total = weights.values().stream().mapToInt(Integer::intValue).sum();
List<Map.Entry<Float, Integer>> sorted =
weights.entrySet().stream().sorted(Map.Entry.comparingByKey()).toList();
int seen = 0;
for (Map.Entry<Float, Integer> entry : sorted) {
seen += entry.getValue();
if (seen >= total / 2) {
return entry.getKey();
}
}
return sorted.get(sorted.size() - 1).getKey();
}
/** Maps each distinct heading size to a 1-based level, largest size first. */
static Map<Float, Integer> headingTiers(List<PageContent> pages, float bodySize) {
if (bodySize <= 0) {
return Map.of();
}
// A size used by a large share of the lines is body text, whatever the median says.
Map<Float, Integer> lineCounts = new HashMap<>();
int totalLines = 0;
for (PageContent page : pages) {
for (TextLineInfo line : page.lines()) {
if (!line.isBlank()) {
lineCounts.merge(line.dominantFontSize(), 1, Integer::sum);
totalLines++;
}
}
}
int headingLineCeiling = Math.max(1, (int) (totalLines * MAX_HEADING_LINE_SHARE));
// Headings do not cluster; a run of same-size lines is a text block, not headings.
Map<Float, Integer> longestRun = new HashMap<>();
for (PageContent page : pages) {
Float runSize = null;
int runLength = 0;
for (TextLineInfo line : page.lines()) {
if (line.isBlank()) {
continue;
}
float size = line.dominantFontSize();
if (runSize != null && Float.compare(size, runSize) == 0) {
runLength++;
} else {
runSize = size;
runLength = 1;
}
int seen = longestRun.getOrDefault(size, 0);
if (runLength > seen) {
longestRun.put(size, runLength);
}
}
}
List<Float> sizes = new ArrayList<>();
for (PageContent page : pages) {
for (TextLineInfo line : page.lines()) {
if (isHeadingCandidate(line)
&& line.dominantFontSize() > bodySize * HEADING_RATIO
&& lineCounts.getOrDefault(line.dominantFontSize(), 0) <= headingLineCeiling
&& longestRun.getOrDefault(line.dominantFontSize(), 0) < MAX_HEADING_RUN) {
sizes.add(line.dominantFontSize());
}
}
}
List<Float> distinct = sizes.stream().distinct().sorted(Comparator.reverseOrder()).toList();
Map<Float, Integer> tiers = new LinkedHashMap<>();
int level = 0;
Float previous = null;
for (Float size : distinct) {
if (previous == null || previous - size > TIER_TOLERANCE) {
level = Math.min(level + 1, 6);
previous = size;
}
tiers.put(size, level);
}
return tiers;
}
/**
* Claims a line's operators word run by word run; claiming the whole ordinal interval would
* swallow anything drawn between them, an image included.
*/
private static void claimLine(StructBlock block, TextLineInfo line) {
// Sort by ordinal, not position: merging out-of-order runs silently drops them to
// /Artifact, hiding them from assistive technology while the file still validates.
List<WordInfo> words =
line.words().stream()
.filter(w -> !w.isBlank())
.sorted(Comparator.comparingInt(WordInfo::startOrdinal))
.toList();
if (words.isEmpty()) {
block.addRange(line.startOrdinal(), line.endOrdinal());
return;
}
int start = words.get(0).startOrdinal();
int end = words.get(0).endOrdinal();
for (int i = 1; i < words.size(); i++) {
WordInfo word = words.get(i);
if (word.startOrdinal() <= end + 1) {
end = Math.max(end, word.endOrdinal());
} else {
block.addRange(start, end);
start = word.startOrdinal();
end = word.endOrdinal();
}
}
block.addRange(start, end);
}
static boolean isHeadingCandidate(TextLineInfo line) {
String text = line.text().strip();
if (text.isEmpty() || line.wordCount() > MAX_HEADING_WORDS) {
return false;
}
char last = text.charAt(text.length() - 1);
return last != '.' && last != '!' && last != '?';
}
/**
* Finds lines in the head/foot bands whose text repeats across pages. Digits are masked first
* so that "Page 4" and "Page 5" count as the same running foot.
*/
static Map<Integer, List<TextLineInfo>> repeatedMarginLines(List<PageContent> pages) {
return repeatedMarginLines(pages, bodyFontSize(pages));
}
static Map<Integer, List<TextLineInfo>> repeatedMarginLines(
List<PageContent> pages, float bodySize) {
Map<Integer, List<TextLineInfo>> result = new HashMap<>();
if (pages.isEmpty()) {
return result;
}
Map<String, Integer> counts = new HashMap<>();
Map<Integer, List<TextLineInfo>> candidates = new HashMap<>();
for (PageContent page : pages) {
float height = page.mediaBox().height();
if (height <= 0) {
continue;
}
float topEdge = page.mediaBox().y1() - height * MARGIN_BAND;
float bottomEdge = page.mediaBox().y0() + height * MARGIN_BAND;
List<TextLineInfo> inBand = new ArrayList<>();
for (TextLineInfo line : page.lines()) {
if (line.bbox().y0() >= topEdge || line.bbox().y1() <= bottomEdge) {
inBand.add(line);
counts.merge(mask(line.text()), 1, Integer::sum);
}
}
candidates.put(page.pageIndex(), inBand);
}
int threshold = Math.max(2, pages.size() / 2);
for (Map.Entry<Integer, List<TextLineInfo>> entry : candidates.entrySet()) {
List<TextLineInfo> artifacts = new ArrayList<>();
for (TextLineInfo line : entry.getValue()) {
boolean repeats =
pages.size() >= 3 && counts.getOrDefault(mask(line.text()), 0) >= threshold;
boolean pageNumber = PAGE_NUMBER.matcher(line.text().strip()).matches();
// Masked digits merge "Section 1" and "Section 2"; size is the tie-break that stops
// a real heading being demoted, as running heads are never larger than body text.
boolean looksLikeChrome =
bodySize <= 0 || line.dominantFontSize() <= bodySize * 1.05f;
if (pageNumber || (repeats && looksLikeChrome)) {
artifacts.add(line);
}
}
result.put(entry.getKey(), artifacts);
}
return result;
}
private static String mask(String text) {
return DIGITS.matcher(text.strip().toLowerCase()).replaceAll("#").replaceAll("\\s+", " ");
}
// --- Per-page analysis -------------------------------------------------
private void analysePage(
PageContent page,
DocumentStructure structure,
float bodySize,
Map<Float, Integer> tiers,
List<TextLineInfo> marginArtifacts) {
for (TextLineInfo line : marginArtifacts) {
StructBlock artifact = StructBlock.artifact(ArtifactType.PAGINATION, page.pageIndex());
claimLine(artifact, line);
artifact.setBbox(line.bbox());
artifact.setText(line.text());
structure.add(artifact);
}
// Identity set, not List.contains: TextLineInfo is a record whose equals walks its word
// list, so a linear scan per line is quadratic with a deep comparison inside it.
java.util.Set<TextLineInfo> marginSet = Collections.newSetFromMap(new IdentityHashMap<>());
marginSet.addAll(marginArtifacts);
List<TextLineInfo> body =
page.lines().stream()
.filter(line -> !line.isBlank() && !marginSet.contains(line))
.sorted(readingOrder(page))
.toList();
List<StructBlock> blocks = new ArrayList<>();
int index = 0;
while (index < body.size()) {
TextLineInfo line = body.get(index);
int tableEnd = tableRunEnd(body, index);
if (tableEnd > index) {
StructBlock table = buildTable(body.subList(index, tableEnd + 1), page.pageIndex());
if (table != null) {
blocks.add(table);
index = tableEnd + 1;
continue;
}
}
int listEnd = listRunEnd(body, index);
if (listEnd > index) {
blocks.add(buildList(body.subList(index, listEnd + 1), page.pageIndex()));
index = listEnd + 1;
continue;
}
Integer level = headingLevel(line, tiers);
if (level != null) {
StructBlock heading = new StructBlock(StructType.heading(level), page.pageIndex());
claimLine(heading, line);
heading.setBbox(line.bbox());
heading.setText(line.text());
blocks.add(heading);
index++;
continue;
}
int paragraphEnd = paragraphRunEnd(body, index, tiers, bodySize);
blocks.add(buildParagraph(body.subList(index, paragraphEnd + 1), page.pageIndex()));
index = paragraphEnd + 1;
}
// Form XObject text is attributed to its Do, so a Figure too would double-claim it.
Set<Integer> claimed = new HashSet<>();
for (StructBlock block : blocks) {
block.visit(
node ->
node.getRanges()
.forEach(
range -> {
for (int i = range.start(); i <= range.end(); i++) {
claimed.add(i);
}
}));
}
blocks.addAll(buildGraphics(page, structure, claimed));
blocks.forEach(structure::add);
}
/**
* Orders lines top-to-bottom, splitting into columns first when the page is clearly
* multi-column. Without this, a two-column page reads as interleaved half-sentences.
*/
private Comparator<TextLineInfo> readingOrder(PageContent page) {
Float gutter = detectGutter(page);
if (gutter == null) {
return Comparator.comparingDouble((TextLineInfo l) -> -l.bbox().y1())
.thenComparingDouble(l -> l.bbox().x0());
}
return Comparator.comparingInt((TextLineInfo l) -> l.bbox().centreX() < gutter ? 0 : 1)
.thenComparingDouble(l -> -l.bbox().y1())
.thenComparingDouble(l -> l.bbox().x0());
}
/**
* Returns the x of a vertical gutter when the page is two-column, else null. A gutter must sit
* near the middle, be crossed by almost no line, and have substantial text on both sides.
*/
static Float detectGutter(PageContent page) {
List<TextLineInfo> lines = page.lines().stream().filter(line -> !line.isBlank()).toList();
if (lines.size() < 8) {
return null;
}
float pageWidth = page.mediaBox().width();
if (pageWidth <= 0) {
return null;
}
float centre = page.mediaBox().x0() + pageWidth / 2f;
long crossing =
lines.stream()
.filter(
line ->
line.bbox().x0() < centre - 5
&& line.bbox().x1() > centre + 5)
.count();
if (crossing > lines.size() * 0.1) {
return null;
}
long left = lines.stream().filter(line -> line.bbox().centreX() < centre).count();
long right = lines.size() - left;
boolean balanced = left > lines.size() * 0.25 && right > lines.size() * 0.25;
return balanced ? centre : null;
}
private static Integer headingLevel(TextLineInfo line, Map<Float, Integer> tiers) {
if (!isHeadingCandidate(line)) {
return null;
}
return tiers.get(line.dominantFontSize());
}
// --- Paragraphs --------------------------------------------------------
private static int paragraphRunEnd(
List<TextLineInfo> lines, int start, Map<Float, Integer> tiers, float bodySize) {
int end = start;
for (int i = start + 1; i < lines.size(); i++) {
TextLineInfo previous = lines.get(i - 1);
TextLineInfo current = lines.get(i);
if (headingLevel(current, tiers) != null || startsListItem(current)) {
break;
}
float gap = previous.bbox().y0() - current.bbox().y1();
float leading = Math.max(bodySize, current.bbox().height());
boolean sameBlock = gap < leading * 0.8f && gap > -leading;
boolean sentenceEnded = endsSentence(previous.text());
if (!sameBlock || (sentenceEnded && gap > leading * 0.4f)) {
break;
}
end = i;
}
return end;
}
private static boolean endsSentence(String text) {
String stripped = text.strip();
if (stripped.isEmpty()) {
return false;
}
char last = stripped.charAt(stripped.length() - 1);
return last == '.' || last == '!' || last == '?';
}
private static StructBlock buildParagraph(List<TextLineInfo> lines, int pageIndex) {
StructBlock paragraph = new StructBlock(StructType.P, pageIndex);
BBox box = BBox.EMPTY;
StringBuilder text = new StringBuilder();
for (TextLineInfo line : lines) {
claimLine(paragraph, line);
box = box.union(line.bbox());
if (text.length() > 0) {
text.append(' ');
}
text.append(line.text().strip());
}
paragraph.setBbox(box);
paragraph.setText(text.toString());
return paragraph;
}
// --- Lists -------------------------------------------------------------
static boolean startsListItem(TextLineInfo line) {
String text = line.text().strip();
return BULLET.matcher(text).matches() || ORDERED.matcher(text).matches();
}
private static int listRunEnd(List<TextLineInfo> lines, int start) {
if (!startsListItem(lines.get(start))) {
return start;
}
float indent = lines.get(start).bbox().x0();
int end = start;
for (int i = start + 1; i < lines.size(); i++) {
TextLineInfo line = lines.get(i);
boolean isItem = startsListItem(line) && Math.abs(line.bbox().x0() - indent) < 6f;
boolean isContinuation = !startsListItem(line) && line.bbox().x0() > indent + 2f;
if (!isItem && !isContinuation) {
break;
}
end = i;
}
// A single marker is a stray character, not a list.
long items =
lines.subList(start, end + 1).stream()
.filter(LayoutAnalyzer::startsListItem)
.count();
return items >= 2 ? end : start;
}
private static StructBlock buildList(List<TextLineInfo> lines, int pageIndex) {
StructBlock list = new StructBlock(StructType.L, pageIndex);
list.setListNumbering(listNumbering(lines.get(0)));
BBox box = BBox.EMPTY;
StructBlock currentBody = null;
for (TextLineInfo line : lines) {
box = box.union(line.bbox());
if (startsListItem(line) || currentBody == null) {
StructBlock item = new StructBlock(StructType.LI, pageIndex);
StructBlock body = new StructBlock(StructType.LBODY, pageIndex);
claimLine(body, line);
body.setBbox(line.bbox());
body.setText(line.text());
item.addChild(body);
item.setBbox(line.bbox());
list.addChild(item);
currentBody = body;
} else {
claimLine(currentBody, line);
currentBody.setBbox(currentBody.getBbox().union(line.bbox()));
currentBody.setText(currentBody.getText() + " " + line.text().strip());
}
}
list.setBbox(box);
return list;
}
private static String listNumbering(TextLineInfo first) {
String text = first.text().strip();
if (BULLET.matcher(text).matches()) {
return "Disc";
}
char c = text.charAt(0);
if (Character.isDigit(c)) {
return "Decimal";
}
if ("ivxlc".indexOf(Character.toLowerCase(c)) >= 0 && text.length() > 1) {
return Character.isUpperCase(c) ? "UpperRoman" : "LowerRoman";
}
return Character.isUpperCase(c) ? "UpperAlpha" : "LowerAlpha";
}
// --- Tables ------------------------------------------------------------
/** Splits a line into cells wherever the gap between words exceeds the cell threshold. */
static List<List<WordInfo>> splitCells(TextLineInfo line) {
List<WordInfo> words = line.words().stream().filter(w -> !w.isBlank()).toList();
List<List<WordInfo>> cells = new ArrayList<>();
if (words.isEmpty()) {
return cells;
}
float threshold = Math.max(line.dominantFontSize(), 1f) * CELL_GAP_RATIO;
List<WordInfo> current = new ArrayList<>();
current.add(words.get(0));
for (int i = 1; i < words.size(); i++) {
float gap = words.get(i).bbox().x0() - words.get(i - 1).bbox().x1();
if (gap > threshold) {
cells.add(List.copyOf(current));
current = new ArrayList<>();
}
current.add(words.get(i));
}
cells.add(List.copyOf(current));
return cells;
}
/**
* Index of the last line of a table run starting at {@code start}, or {@code start} if none.
*/
private static int tableRunEnd(List<TextLineInfo> lines, int start) {
int end = start;
for (int i = start; i < lines.size(); i++) {
if (splitCells(lines.get(i)).size() < 2) {
break;
}
end = i;
}
return end > start ? end : start;
}
/**
* Builds a Table when the run really looks tabular and each cell owns its own operators.
* Returns null when it does not, so the caller falls back to paragraphs.
*/
private static StructBlock buildTable(List<TextLineInfo> rows, int pageIndex) {
if (rows.size() < 2) {
return null;
}
List<List<List<WordInfo>>> grid = new ArrayList<>();
for (TextLineInfo row : rows) {
if (!row.wordsAreSeparable()) {
log.debug("Table row shares operators between cells; falling back to paragraphs");
return null;
}
grid.add(splitCells(row));
}
int columns = grid.get(0).size();
long consistent = grid.stream().filter(row -> row.size() == columns).count();
if (columns < 2 || consistent < Math.max(2, grid.size() * 0.6)) {
return null;
}
boolean headerRow = looksLikeHeader(rows, grid);
StructBlock table = new StructBlock(StructType.TABLE, pageIndex);
BBox box = BBox.EMPTY;
for (int r = 0; r < grid.size(); r++) {
List<List<WordInfo>> cells = grid.get(r);
if (cells.size() != columns) {
continue;
}
StructBlock tr = new StructBlock(StructType.TR, pageIndex);
boolean isHeader = headerRow && r == 0;
for (List<WordInfo> cell : cells) {
StructBlock td =
new StructBlock(isHeader ? StructType.TH : StructType.TD, pageIndex);
if (isHeader) {
td.setScope("Column");
}
BBox cellBox = BBox.EMPTY;
StringBuilder text = new StringBuilder();
int from = cell.get(0).startOrdinal();
int to = cell.get(cell.size() - 1).endOrdinal();
for (WordInfo word : cell) {
cellBox = cellBox.union(word.bbox());
if (text.length() > 0) {
text.append(' ');
}
text.append(word.text());
}
td.addRange(from, to);
td.setBbox(cellBox);
td.setText(text.toString());
tr.addChild(td);
box = box.union(cellBox);
}
tr.setBbox(box);
table.addChild(tr);
}
table.setBbox(box);
if (table.getChildren().size() < 2) {
return null;
}
// Clause 7.5 needs equal cell counts per row; a ragged table fails validation outright.
long distinctWidths =
table.getChildren().stream()
.map(row -> row.getChildren().size())
.distinct()
.count();
if (distinctWidths != 1) {
log.debug("Discarding a table whose rows have different cell counts");
return null;
}
return table;
}
/** The first row is a header when it is bold, or when only later rows carry numbers. */
private static boolean looksLikeHeader(
List<TextLineInfo> rows, List<List<List<WordInfo>>> grid) {
if (rows.get(0).bold()) {
return true;
}
boolean firstHasDigits = DIGITS.matcher(rows.get(0).text()).find();
boolean laterHasDigits =
rows.subList(1, rows.size()).stream()
.anyMatch(row -> DIGITS.matcher(row.text()).find());
return !firstHasDigits && laterHasDigits;
}
// --- Graphics ----------------------------------------------------------
private List<StructBlock> buildGraphics(
PageContent page, DocumentStructure structure, java.util.Set<Integer> claimed) {
List<StructBlock> blocks = new ArrayList<>();
boolean warnedForms = false;
// Vectors cluster: a chart is many strokes in one region, a rule is a single thin one.
java.util.Set<Integer> vectorFigureOrdinals = vectorFigureOrdinals(page, claimed);
for (MarkableOp op : page.ops()) {
if (op.kind() == MarkableOp.Kind.TEXT || claimed.contains(op.ordinal())) {
continue;
}
BBox box = op.bbox();
if (op.kind() == MarkableOp.Kind.VECTOR) {
StructBlock block;
if (vectorFigureOrdinals.contains(op.ordinal())) {
block = new StructBlock(StructType.FIGURE, page.pageIndex());
} else {
block = StructBlock.artifact(ArtifactType.LAYOUT, page.pageIndex());
}
block.addRange(op.ordinal(), op.ordinal());
block.setBbox(box);
blocks.add(block);
continue;
}
boolean decorative = box.width() < MIN_FIGURE_SIZE || box.height() < MIN_FIGURE_SIZE;
if (decorative) {
StructBlock artifact = StructBlock.artifact(ArtifactType.LAYOUT, page.pageIndex());
artifact.addRange(op.ordinal(), op.ordinal());
artifact.setBbox(box);
blocks.add(artifact);
continue;
}
if (op.kind() == MarkableOp.Kind.FORM && !warnedForms) {
structure.warn(
"Content inside form XObjects was tagged as a single region because its"
+ " text is not separately addressable; review those areas.");
warnedForms = true;
}
StructBlock figure = new StructBlock(StructType.FIGURE, page.pageIndex());
figure.addRange(op.ordinal(), op.ordinal());
figure.setBbox(box);
blocks.add(figure);
}
return blocks;
}
/**
* Finds vector operators belonging to a substantial drawing rather than page furniture; thin
* paths are rules and table borders, and a short run is ornament.
*/
private static Set<Integer> vectorFigureOrdinals(
PageContent page, java.util.Set<Integer> claimed) {
// A chart's plot area is mostly empty, while shading sits behind the text it decorates.
Set<Integer> result = new HashSet<>();
List<MarkableOp> run = new ArrayList<>();
BBox extent = BBox.EMPTY;
for (MarkableOp op : page.ops()) {
boolean substantial =
op.kind() == MarkableOp.Kind.VECTOR
&& !claimed.contains(op.ordinal())
&& !op.bbox().isEmpty()
&& op.bbox().width() >= MIN_VECTOR_THICKNESS
&& op.bbox().height() >= MIN_VECTOR_THICKNESS;
if (substantial) {
run.add(op);
extent = extent.isEmpty() ? op.bbox() : extent.union(op.bbox());
continue;
}
flushVectorRun(run, extent, page.lines(), result);
run = new ArrayList<>();
extent = BBox.EMPTY;
}
flushVectorRun(run, extent, page.lines(), result);
return result;
}
private static void flushVectorRun(
List<MarkableOp> run,
BBox extent,
List<TextLineInfo> lines,
java.util.Set<Integer> result) {
if (run.size() < MIN_VECTOR_FIGURE_OPS
|| extent.width() < MIN_VECTOR_FIGURE_SIZE
|| extent.height() < MIN_VECTOR_FIGURE_SIZE) {
return;
}
if (overlappingLines(extent, lines) > MAX_LINES_INSIDE_FIGURE) {
return;
}
run.forEach(op -> result.add(op.ordinal()));
}
/** How many text lines sit within the region a vector cluster covers. */
private static int overlappingLines(BBox extent, List<TextLineInfo> lines) {
int count = 0;
for (TextLineInfo line : lines) {
BBox box = line.bbox();
boolean inside =
box.x0() >= extent.x0() - 2
&& box.x1() <= extent.x1() + 2
&& box.y0() >= extent.y0() - 2
&& box.y1() <= extent.y1() + 2;
if (inside) {
count++;
}
}
return count;
}
// --- Post-processing ---------------------------------------------------
/**
* Rewrites heading levels so no level is skipped, which PDF/UA-1 clause 7.4 requires. A
* document that jumps H1 to H3 is remapped to H1, H2 while preserving relative depth.
*/
static void normaliseHeadingLevels(DocumentStructure structure) {
List<StructBlock> headings = new ArrayList<>();
structure.visit(
block -> {
if (block.getType().isHeading()) {
headings.add(block);
}
});
int previous = 0;
for (StructBlock heading : headings) {
int level = heading.getType().headingLevel();
int adjusted = level > previous + 1 ? previous + 1 : level;
heading.setType(StructType.heading(adjusted));
previous = adjusted;
}
}
/** Uses the first top-level heading as the title when the document has no metadata title. */
private static String deriveTitle(DocumentStructure structure) {
for (StructBlock block : structure.getBlocks()) {
if (block.getType().isHeading() && !block.getText().isBlank()) {
return block.getText().strip();
}
}
return null;
}
}
@@ -0,0 +1,44 @@
package stirling.software.proprietary.pdf.ua;
/**
* One operator in a page content stream that may be wrapped in a marked-content sequence. The
* ordinal counts only markable operators, joining text extraction to token rewriting.
*/
public record MarkableOp(int ordinal, Kind kind, BBox bbox, String resourceName) {
public enum Kind {
/** Tj, TJ, ' or " */
TEXT,
/** Do referencing an image XObject */
IMAGE,
/** Do referencing a form XObject */
FORM,
/** BI ... ID ... EI */
INLINE_IMAGE,
/** A path-painting or shading operator: rules, borders, fills, logos */
VECTOR;
public boolean isGraphic() {
return this == IMAGE || this == INLINE_IMAGE;
}
}
/**
* Operator names counted as markable; both passes must agree on this set. Path painting is
* included because clause 7.1 needs visible rules and borders tagged or artifacted.
*/
public static boolean isMarkableOperator(String name) {
return switch (name) {
case "Tj", "TJ", "'", "\"", "Do", "BI" -> true;
default -> isPathPainting(name);
};
}
/** Painting operators only: {@code n} ends a path without marking the page. */
public static boolean isPathPainting(String name) {
return switch (name) {
case "S", "s", "f", "F", "f*", "B", "B*", "b", "b*", "sh" -> true;
default -> false;
};
}
}
@@ -0,0 +1,284 @@
package stirling.software.proprietary.pdf.ua;
import java.io.IOException;
import java.io.OutputStream;
import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.Deque;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import org.apache.pdfbox.contentstream.operator.Operator;
import org.apache.pdfbox.cos.COSBase;
import org.apache.pdfbox.cos.COSDictionary;
import org.apache.pdfbox.cos.COSInteger;
import org.apache.pdfbox.cos.COSName;
import org.apache.pdfbox.pdfparser.PDFStreamParser;
import org.apache.pdfbox.pdfwriter.ContentStreamWriter;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.pdmodel.common.PDStream;
import lombok.extern.slf4j.Slf4j;
/**
* Rewrites a page stream so every markable operator sits inside a marked-content sequence: claimed
* content gets an MCID, everything else /Artifact, satisfying PDF/UA-1 clause 7.1 by construction.
*/
@Slf4j
public class MarkedContentInjector {
private static final COSName ARTIFACT = COSName.getPDFName("Artifact");
private static final COSName MCID = COSName.getPDFName("MCID");
private static final COSName ACTUAL_TEXT = COSName.getPDFName("ActualText");
private static final COSName ALT = COSName.getPDFName("Alt");
/** Operators that force an open sequence to close so nesting stays legal. */
private static boolean isBoundary(String name) {
return "BT".equals(name) || "ET".equals(name) || "q".equals(name) || "Q".equals(name);
}
private static boolean isMarkedContentOperator(String name) {
return "BDC".equals(name) || "BMC".equals(name) || "EMC".equals(name);
}
/**
* Path-construction operators; ISO 32000-1 forbids marked content inside a path object, so a
* sequence wrapping a fill or stroke must open before the path starts.
*/
private static boolean isPathConstruction(String name) {
return switch (name) {
case "m", "l", "c", "v", "y", "h", "re" -> true;
default -> false;
};
}
private static boolean opensMarkedContent(String name) {
return "BDC".equals(name) || "BMC".equals(name);
}
/**
* True for an optional-content sequence; stripping an {@code /OC} wrapper would make hidden
* layers such as watermarks or redaction overlays visible.
*/
private static boolean isOptionalContent(String name, List<COSBase> operands) {
return opensMarkedContent(name)
&& !operands.isEmpty()
&& operands.get(0) instanceof COSName tag
&& "OC".equals(tag.getName());
}
/**
* True when a sequence supplies replacement text for its glyphs; dropping it leaves a screen
* reader with the font's own mapping, which for a ligature says nothing useful.
*/
private static boolean carriesReplacementText(String name, List<COSBase> operands) {
if (!opensMarkedContent(name)) {
return false;
}
for (COSBase operand : operands) {
if (operand instanceof COSDictionary properties
&& (properties.containsKey(ACTUAL_TEXT)
|| properties.containsKey(ALT)
|| properties.containsKey(COSName.E))) {
return true;
}
}
return false;
}
/** The source's own ids mean nothing once the tree is rebuilt, so they are dropped. */
private static void stripStaleMcid(List<COSBase> operands) {
for (COSBase operand : operands) {
if (operand instanceof COSDictionary properties) {
properties.removeItem(MCID);
}
}
}
/** Wraps every markable operator on the page; returns the next unused marked content id. */
public int inject(
PDDocument document,
PDPage page,
List<StructBlock> blocks,
int nextMcid,
boolean stripExisting)
throws IOException {
Map<Integer, StructBlock> owners = ownersByOrdinal(blocks);
List<Object> tokens = parse(page);
List<Object> output = new ArrayList<>(tokens.size() + owners.size() * 4);
List<COSBase> operands = new ArrayList<>();
// Tracks, for each surviving source sequence, whether its closer should be kept.
Deque<Boolean> keptSequences = new ArrayDeque<>();
StructBlock openBlock = null;
boolean open = false;
int ordinal = -1;
int mcid = nextMcid;
int pathStart = -1;
for (Object token : tokens) {
if (!(token instanceof Operator operator)) {
operands.add((COSBase) token);
continue;
}
String name = operator.getName();
if (stripExisting && isMarkedContentOperator(name)) {
boolean keep;
if (opensMarkedContent(name)) {
keep =
isOptionalContent(name, operands)
|| carriesReplacementText(name, operands);
if (keep) {
stripStaleMcid(operands);
}
keptSequences.push(keep);
} else {
// A closer is kept exactly when its matching opener was.
keep = !keptSequences.isEmpty() && keptSequences.pop();
}
if (!keep) {
operands.clear();
continue;
}
// Close our own sequence first so the two never interleave illegally.
if (open) {
output.add(Operator.getOperator("EMC"));
open = false;
openBlock = null;
}
output.addAll(operands);
output.add(operator);
operands.clear();
continue;
}
if (isBoundary(name) && open) {
output.add(Operator.getOperator("EMC"));
open = false;
openBlock = null;
}
// Remember where the current path object began so a sequence wrapping its painting
// operator can be opened before it rather than inside it.
if (isPathConstruction(name)) {
if (pathStart < 0) {
pathStart = output.size();
}
} else if (!MarkableOp.isPathPainting(name) && !"n".equals(name)) {
pathStart = -1;
}
if (MarkableOp.isMarkableOperator(name)) {
ordinal++;
StructBlock owner = owners.get(ordinal);
if (!open || owner != openBlock) {
boolean insidePath = MarkableOp.isPathPainting(name) && pathStart >= 0;
if (open) {
// Close before the path began, so the EMC also stays outside the path.
output.add(
insidePath ? pathStart : output.size(),
Operator.getOperator("EMC"));
if (insidePath) {
pathStart++;
}
}
int at = insidePath ? pathStart : output.size();
mcid = openSequenceAt(output, at, owner, mcid);
open = true;
openBlock = owner;
}
}
output.addAll(operands);
output.add(operator);
operands.clear();
if (MarkableOp.isPathPainting(name) || "n".equals(name)) {
pathStart = -1;
}
}
if (open) {
output.add(Operator.getOperator("EMC"));
}
write(document, page, output);
return mcid;
}
/** Emits the opening BDC/BMC at a given position and records the id on the owning block. */
private int openSequenceAt(List<Object> output, int at, StructBlock owner, int mcid) {
List<Object> opening = new ArrayList<>(3);
if (owner == null) {
opening.add(ARTIFACT);
opening.add(Operator.getOperator("BMC"));
} else if (owner.isArtifact()) {
COSDictionary properties = new COSDictionary();
if (owner.getArtifactType() != null) {
properties.setName(COSName.TYPE, owner.getArtifactType().subtype());
}
opening.add(ARTIFACT);
opening.add(properties);
opening.add(Operator.getOperator("BDC"));
} else {
COSDictionary properties = new COSDictionary();
properties.setItem(MCID, COSInteger.get(mcid));
opening.add(COSName.getPDFName(owner.getType().tag()));
opening.add(properties);
opening.add(Operator.getOperator("BDC"));
owner.getMcids().add(mcid);
mcid++;
}
output.addAll(at, opening);
return mcid;
}
/**
* Maps each claimed ordinal to its block. Overlapping claims are dropped rather than merged:
* two structure elements sharing content would make the reading order ambiguous.
*/
static Map<Integer, StructBlock> ownersByOrdinal(List<StructBlock> blocks) {
Map<Integer, StructBlock> owners = new HashMap<>();
for (StructBlock block : blocks) {
block.visit(
node -> {
for (StructBlock.OrdinalRange range : node.getRanges()) {
for (int i = range.start(); i <= range.end(); i++) {
StructBlock existing = owners.putIfAbsent(i, node);
if (existing != null && existing != node) {
log.debug(
"Ordinal {} claimed by both {} and {}; keeping the first",
i,
existing,
node);
}
}
}
});
}
return owners;
}
private static List<Object> parse(PDPage page) throws IOException {
PDFStreamParser parser = new PDFStreamParser(page);
List<Object> tokens = new ArrayList<>();
Object token;
while ((token = parser.parseNextToken()) != null) {
tokens.add(token);
}
return tokens;
}
private static void write(PDDocument document, PDPage page, List<Object> tokens)
throws IOException {
PDStream stream = new PDStream(document);
try (OutputStream out = stream.createOutputStream(COSName.FLATE_DECODE)) {
new ContentStreamWriter(out).writeTokens(tokens);
}
page.setContents(stream);
}
}
@@ -0,0 +1,33 @@
package stirling.software.proprietary.pdf.ua;
import java.util.List;
/**
* Everything the layout analyser needs about one page. carriesTextSemantics: existing marked
* content has ActualText/Alt/expansion a rebuild would discard. linesDropped: text became
* artifacts.
*/
public record PageContent(
int pageIndex,
List<TextLineInfo> lines,
List<MarkableOp> ops,
int markableCount,
boolean preExistingMarkedContent,
boolean carriesTextSemantics,
boolean linesDropped,
BBox mediaBox) {
public boolean hasText() {
return lines.stream().anyMatch(line -> !line.isBlank());
}
/** Markable operators that draw graphics rather than text. */
public List<MarkableOp> graphics() {
return ops.stream().filter(op -> op.kind().isGraphic()).toList();
}
/** Form XObject invocations, which are tagged as a unit because their text is opaque here. */
public List<MarkableOp> forms() {
return ops.stream().filter(op -> op.kind() == MarkableOp.Kind.FORM).toList();
}
}
@@ -0,0 +1,47 @@
package stirling.software.proprietary.pdf.ua;
import org.apache.xmpbox.XMPMetadata;
import org.apache.xmpbox.schema.XMPSchema;
import org.apache.xmpbox.type.IntegerType;
import org.apache.xmpbox.type.StructuredType;
/**
* The {@code pdfuaid} XMP conformance schema, which XMPBox does not ship. Only write it once
* validation has passed - it is a compliance claim.
*/
@StructuredType(
preferedPrefix = PdfUaIdentificationSchema.PREFERRED_PREFIX,
namespace = PdfUaIdentificationSchema.NAMESPACE)
public class PdfUaIdentificationSchema extends XMPSchema {
public static final String PREFERRED_PREFIX = "pdfuaid";
public static final String NAMESPACE = "http://www.aiim.org/pdfua/ns/id/";
public static final String PART = "part";
public static final String REV = "rev";
public PdfUaIdentificationSchema(XMPMetadata metadata) {
super(metadata);
}
public PdfUaIdentificationSchema(XMPMetadata metadata, String prefix) {
super(metadata, prefix);
}
/** Sets {@code pdfuaid:part}, the conformance level (1 or 2). */
public void setPart(int part) {
addProperty(new IntegerType(getMetadata(), getNamespace(), getPrefix(), PART, part));
}
/** Sets {@code pdfuaid:rev}, the four-digit revision year used by PDF/UA-2. */
public void setRevision(int year) {
addProperty(new IntegerType(getMetadata(), getNamespace(), getPrefix(), REV, year));
}
public Integer getPart() {
if (getProperty(PART) instanceof IntegerType part) {
return part.getValue();
}
return null;
}
}
@@ -0,0 +1,224 @@
package stirling.software.proprietary.pdf.ua;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import org.apache.pdfbox.cos.COSName;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDDocumentCatalog;
import org.apache.pdfbox.pdmodel.PDDocumentInformation;
import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.pdmodel.common.PDMetadata;
import org.apache.pdfbox.pdmodel.interactive.form.PDAcroForm;
import org.apache.pdfbox.pdmodel.interactive.form.PDField;
import org.apache.pdfbox.pdmodel.interactive.viewerpreferences.PDViewerPreferences;
import org.apache.xmpbox.XMPMetadata;
import org.apache.xmpbox.schema.DublinCoreSchema;
import org.apache.xmpbox.schema.XMPSchema;
import org.apache.xmpbox.xml.DomXmpParser;
import org.apache.xmpbox.xml.XmpSerializer;
import lombok.extern.slf4j.Slf4j;
/** Applies the document-level PDF/UA requirements: title, language, tab order, declaration. */
@Slf4j
public class PdfUaMetadataWriter {
private static final COSName TABS = COSName.getPDFName("Tabs");
private static final COSName SUSPECTS = COSName.getPDFName("Suspects");
/**
* Applies everything except the conformance declaration. Clause 7.1 requires a title, so a
* blank one falls back to the existing metadata title.
*/
public List<String> applyDocumentRequirements(
PDDocument document, String title, String language, PdfUaProfile profile)
throws IOException {
return applyDocumentRequirements(document, title, language, profile, false);
}
public List<String> applyDocumentRequirements(
PDDocument document,
String title,
String language,
PdfUaProfile profile,
boolean preserveVersion)
throws IOException {
List<String> warnings = new ArrayList<>();
PDDocumentCatalog catalog = document.getDocumentCatalog();
if (language != null && !language.isBlank()) {
catalog.setLanguage(language);
}
String effectiveTitle = resolveTitle(document, title);
if (effectiveTitle != null) {
PDDocumentInformation info = document.getDocumentInformation();
info.setTitle(effectiveTitle);
document.setDocumentInformation(info);
}
// Without this a viewer shows the filename instead of the title, which defeats the point.
PDViewerPreferences preferences = catalog.getViewerPreferences();
if (preferences == null) {
preferences = new PDViewerPreferences(catalog.getCOSObject());
}
preferences.setDisplayDocTitle(true);
catalog.setViewerPreferences(preferences);
// Clause 7.18.1: every page needs an explicit tab order.
for (PDPage page : document.getPages()) {
page.getCOSObject().setName(TABS, "S");
}
// A structure tree flagged as suspect is not conforming.
if (catalog.getMarkInfo() != null) {
catalog.getMarkInfo().getCOSObject().removeItem(SUSPECTS);
}
if (!preserveVersion && document.getVersion() < profile.pdfVersion()) {
document.setVersion(profile.pdfVersion());
}
warnings.addAll(describeFormFields(document));
writeXmp(document, effectiveTitle, language, null);
return warnings;
}
/**
* Gives every form field the {@code /TU} description clause 7.18.1 requires, reusing its
* authored partial name. Unnamed fields are reported, never given a useless placeholder.
*/
private static List<String> describeFormFields(PDDocument document) {
List<String> warnings = new ArrayList<>();
PDAcroForm form = document.getDocumentCatalog().getAcroForm();
if (form == null) {
return warnings;
}
int unnamed = 0;
for (PDField field : form.getFieldTree()) {
String existing = field.getAlternateFieldName();
if (existing != null && !existing.isBlank()) {
continue;
}
String partialName = field.getPartialName();
if (partialName == null || partialName.isBlank()) {
unnamed++;
continue;
}
field.setAlternateFieldName(partialName);
}
if (unnamed > 0) {
warnings.add(
unnamed
+ " form field(s) have neither a description nor a name, so no tooltip"
+ " could be derived. Add one for each before claiming conformance.");
}
return warnings;
}
/**
* Strips the {@code pdfuaid} declaration when validation fails after it was written, so the
* returned file does not assert conformance it lacks.
*/
public void removeConformanceDeclaration(PDDocument document) throws IOException {
PDDocumentCatalog catalog = document.getDocumentCatalog();
XMPMetadata metadata = loadOrCreate(catalog);
XMPSchema identification = metadata.getSchema(PdfUaIdentificationSchema.NAMESPACE);
if (identification == null) {
return;
}
metadata.removeSchema(identification);
serialiseInto(document, metadata);
}
/** Writes the {@code pdfuaid:part} declaration. Only call this after validation has passed. */
public void declareConformance(PDDocument document, PdfUaProfile profile) throws IOException {
writeXmp(document, resolveTitle(document, null), documentLanguage(document), profile);
}
private String resolveTitle(PDDocument document, String preferred) {
if (preferred != null && !preferred.isBlank()) {
return preferred.strip();
}
String existing = document.getDocumentInformation().getTitle();
return existing != null && !existing.isBlank() ? existing.strip() : null;
}
private static String documentLanguage(PDDocument document) {
return document.getDocumentCatalog().getLanguage();
}
/**
* Rewrites the XMP packet, preserving what was there. A malformed packet is replaced, since an
* unparseable one fails validation on its own.
*/
private void writeXmp(PDDocument document, String title, String language, PdfUaProfile profile)
throws IOException {
PDDocumentCatalog catalog = document.getDocumentCatalog();
XMPMetadata metadata = loadOrCreate(catalog);
if (title != null) {
DublinCoreSchema dublinCore = metadata.getDublinCoreSchema();
if (dublinCore == null) {
dublinCore = metadata.createAndAddDublinCoreSchema();
}
dublinCore.setTitle(title);
if (language != null
&& !language.isBlank()
&& (dublinCore.getLanguages() == null
|| !dublinCore.getLanguages().contains(language))) {
dublinCore.addLanguage(language);
}
}
if (profile != null) {
// Re-converting an already-declared file must not leave two pdfuaid schemas.
XMPSchema stale = metadata.getSchema(PdfUaIdentificationSchema.NAMESPACE);
if (stale != null) {
metadata.removeSchema(stale);
}
PdfUaIdentificationSchema identification = new PdfUaIdentificationSchema(metadata);
identification.setPart(profile.part());
if (profile.revision() > 0) {
identification.setRevision(profile.revision());
}
metadata.addSchema(identification);
}
serialiseInto(document, metadata);
}
private static void serialiseInto(PDDocument document, XMPMetadata metadata)
throws IOException {
ByteArrayOutputStream out = new ByteArrayOutputStream();
try {
new XmpSerializer().serialize(metadata, out, true);
} catch (javax.xml.transform.TransformerException e) {
throw new IOException("Could not serialise XMP metadata", e);
}
PDMetadata pdMetadata = new PDMetadata(document);
pdMetadata.importXMPMetadata(out.toByteArray());
document.getDocumentCatalog().setMetadata(pdMetadata);
}
private XMPMetadata loadOrCreate(PDDocumentCatalog catalog) {
PDMetadata existing = catalog.getMetadata();
if (existing != null) {
try {
DomXmpParser parser = new DomXmpParser();
// Strict parsing rejects pdfuaid, silently discarding a packet we just wrote.
parser.setStrictParsing(false);
return parser.parse(new ByteArrayInputStream(existing.toByteArray()));
} catch (Exception e) {
log.debug("Replacing unparseable XMP packet: {}", e.getMessage());
}
}
return XMPMetadata.createXMPMetadata();
}
}
@@ -0,0 +1,47 @@
package stirling.software.proprietary.pdf.ua;
/** The PDF/UA conformance level a conversion targets. */
public enum PdfUaProfile {
/** ISO 14289-1, layered on PDF 1.7. */
UA1(1, 1.7f, 0),
/** ISO 14289-2: needs PDF 2.0, namespaced structure types and a revision year. */
UA2(2, 2.0f, 2024);
private final int part;
private final float pdfVersion;
private final int revision;
PdfUaProfile(int part, float pdfVersion, int revision) {
this.part = part;
this.pdfVersion = pdfVersion;
this.revision = revision;
}
public int part() {
return part;
}
public float pdfVersion() {
return pdfVersion;
}
/** The {@code pdfuaid:rev} year, or 0 when the profile does not use one. */
public int revision() {
return revision;
}
public String displayName() {
return "PDF/UA-" + part;
}
public static PdfUaProfile fromRequest(String value) {
if (value == null || value.isBlank()) {
return UA1;
}
String normalised = value.trim().toLowerCase().replace("/", "").replace("-", "");
return switch (normalised) {
case "ua2", "pdfua2", "2" -> UA2;
default -> UA1;
};
}
}
@@ -0,0 +1,303 @@
package stirling.software.proprietary.pdf.ua;
import java.io.IOException;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import org.apache.pdfbox.cos.COSName;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDDocumentCatalog;
import org.apache.pdfbox.pdmodel.documentinterchange.logicalstructure.PDStructureElement;
import org.apache.pdfbox.pdmodel.documentinterchange.logicalstructure.PDStructureTreeRoot;
import lombok.extern.slf4j.Slf4j;
/**
* Tags an untagged PDF and applies the document-level PDF/UA requirements. Content must be marked
* before the tree can reference it, and conformance is declared elsewhere, only after validation.
*/
@Slf4j
public class PdfUaTagger {
private final TaggedContentExtractor extractor = new TaggedContentExtractor();
private final LayoutAnalyzer analyzer = new LayoutAnalyzer();
private final MarkedContentInjector injector = new MarkedContentInjector();
private final PdfUaMetadataWriter metadataWriter = new PdfUaMetadataWriter();
public TaggingResult tag(PDDocument document, TaggingOptions options) throws IOException {
boolean alreadyTagged = hasUsableStructureTree(document);
boolean rebuild =
switch (options.getExistingTags()) {
case KEEP -> false;
case REBUILD -> true;
case AUTO -> !alreadyTagged;
};
List<String> languageWarnings = new ArrayList<>();
String language = resolveLanguage(document, options, languageWarnings);
if (!rebuild) {
log.info("Keeping existing structure tree; applying document requirements only");
DocumentStructure kept = new DocumentStructure();
languageWarnings.forEach(kept::warn);
metadataWriter
.applyDocumentRequirements(
document,
options.getTitle(),
language,
options.getProfile(),
options.isPreservePdfVersion())
.forEach(kept::warn);
return new TaggingResult(kept, false);
}
// Types the old tree carried, so a rebuild that cannot reproduce them can say so. Font
// embedding may already have deleted the tree, so fall back to what the source had.
Set<String> discardedTypes =
alreadyTagged
? structureTypes(document)
: options.getSourceFacts().structureTypes();
if (alreadyTagged) {
stripStructure(document);
}
List<PageContent> pages = extractor.extract(document);
DocumentStructure structure = analyzer.analyse(pages);
structure.setLanguage(language);
languageWarnings.forEach(structure::warn);
applyFigurePolicy(structure, options);
if (structure.isEmpty()) {
structure.warn(
"No taggable content was found; the document may be a scan with no text layer.");
}
injectMarkedContent(document, structure, pages);
new StructTreeWriter().write(document, structure, options.getProfile());
// Losing the tree to the embedder is a different problem from a requested rebuild, and
// the advice that helps differs too, so tell them apart.
boolean lostToEmbedder = !alreadyTagged && options.getSourceFacts().hasUsableTree();
warnAboutFlattenedStructure(
discardedTypes, structureTypes(document), structure, lostToEmbedder);
String title = resolveTitle(options, structure);
if (title == null) {
structure.warn(
"No document title could be derived. PDF/UA requires one, so supply a title.");
}
metadataWriter
.applyDocumentRequirements(
document,
title,
language,
options.getProfile(),
options.isPreservePdfVersion())
.forEach(structure::warn);
return new TaggingResult(structure, true);
}
/**
* Keeps the language the document already declares. Overwriting it relabels, say, a French file
* as English, and no validator can catch that.
*/
private static String resolveLanguage(
PDDocument document, TaggingOptions options, List<String> warnings) {
String existing = document.getDocumentCatalog().getLanguage();
if (existing == null || existing.isBlank()) {
// Font embedding discards /Lang, so without this a rewritten French document would
// silently take the caller's default language.
existing = options.getSourceFacts().language();
}
String requested = options.getLanguage();
if (existing == null || existing.isBlank() || options.isOverrideLanguage()) {
return requested;
}
if (requested != null && !requested.isBlank() && !requested.equalsIgnoreCase(existing)) {
warnings.add(
"The document already declares its language as '"
+ existing
+ "', so the requested '"
+ requested
+ "' was ignored. Ask to override the language to change it.");
}
return existing;
}
/** Explicit title first, then the first heading, then the caller's fallback. */
private static String resolveTitle(TaggingOptions options, DocumentStructure structure) {
for (String candidate :
new String[] {
options.getTitle(), structure.getTitle(), options.getFallbackTitle()
}) {
if (candidate != null && !candidate.isBlank()) {
return candidate.strip();
}
}
return null;
}
/** Writes the conformance declaration. Separate from tagging so validation can gate it. */
public void declareConformance(PDDocument document, PdfUaProfile profile) throws IOException {
metadataWriter.declareConformance(document, profile);
}
/** Withdraws the conformance claim, for a document that turned out not to validate. */
public void withdrawConformance(PDDocument document) throws IOException {
metadataWriter.removeConformanceDeclaration(document);
}
/** Wraps content page by page; marked content ids restart on each page. */
private void injectMarkedContent(
PDDocument document, DocumentStructure structure, List<PageContent> pages)
throws IOException {
Map<Integer, Integer> markableCounts = new LinkedHashMap<>();
pages.forEach(page -> markableCounts.put(page.pageIndex(), page.markableCount()));
Map<Integer, List<StructBlock>> byPage = new LinkedHashMap<>();
for (StructBlock block : structure.getBlocks()) {
byPage.computeIfAbsent(block.getPageIndex(), k -> new ArrayList<>()).add(block);
}
for (int pageIndex = 0; pageIndex < document.getNumberOfPages(); pageIndex++) {
List<StructBlock> blocks = byPage.getOrDefault(pageIndex, List.of());
// Nothing to wrap, and rewriting costs a parse and recompress for an identical stream.
if (blocks.isEmpty() && markableCounts.getOrDefault(pageIndex, 0) == 0) {
continue;
}
injector.inject(document, document.getPage(pageIndex), blocks, 0, true);
}
}
/** Applies alt text supplied by the caller, or demotes images to artifacts on request. */
private static void applyFigurePolicy(DocumentStructure structure, TaggingOptions options) {
int[] suppressed = {0};
structure.visit(
block -> {
if (block.getType() != StructType.FIGURE) {
return;
}
if (options.getFigurePolicy() == TaggingOptions.FigurePolicy.MARK_DECORATIVE) {
block.setType(StructType.ARTIFACT);
block.setArtifactType(ArtifactType.LAYOUT);
suppressed[0]++;
return;
}
int ordinal =
block.getRanges().isEmpty() ? -1 : block.getRanges().get(0).start();
String alt = options.altTextFor(block.getPageIndex(), ordinal);
if (alt != null && !alt.isBlank()) {
block.setAlt(alt);
}
});
// Marking images decorative validates by hiding content, so never report it as clean.
if (suppressed[0] > 0) {
structure.warn(
suppressed[0]
+ " image(s) were marked as decoration and are now hidden from"
+ " assistive technology. Confirm none of them carried meaning.");
}
int missing = structure.figuresWithoutAlt().size();
if (missing > 0) {
structure.warn(
missing
+ " figure(s) have no alternative description. PDF/UA requires one for"
+ " every image that carries meaning.");
}
}
/**
* A tree is only worth keeping when wired up: kids, a parent tree, and a marked catalog.
* Keeping one that fails any of those leaves the document permanently unfixable.
*/
public static boolean hasUsableStructureTree(PDDocument document) {
PDDocumentCatalog catalog = document.getDocumentCatalog();
PDStructureTreeRoot root = catalog.getStructureTreeRoot();
if (root == null) {
return false;
}
try {
boolean hasKids = root.getKids() != null && !root.getKids().isEmpty();
boolean hasParentTree = root.getParentTree() != null;
boolean marked = catalog.getMarkInfo() != null && catalog.getMarkInfo().isMarked();
return hasKids && hasParentTree && marked;
} catch (RuntimeException e) {
log.debug("Unreadable structure tree, treating as absent: {}", e.getMessage());
return false;
}
}
/**
* A rebuild derives structure from layout, so semantics the old tree carried can vanish - a
* table becomes loose paragraphs. Validators cannot see that loss, so it has to be reported.
*/
private static void warnAboutFlattenedStructure(
Set<String> before,
Set<String> after,
DocumentStructure structure,
boolean lostToEmbedder) {
List<String> lost =
MEANINGFUL_TYPES.stream()
.filter(type -> before.contains(type) && !after.contains(type))
.toList();
if (lost.isEmpty()) {
return;
}
// Keeping the tags cannot help once the embedder has deleted them, so do not suggest it.
String remedy =
lostToEmbedder
? " Embedding the missing fonts rewrote the document and deleted its"
+ " original tags. Turn off font embedding to keep them."
: " Keep the existing tags instead to preserve it.";
structure.warn(
"Rebuilding the tags could not reproduce "
+ String.join(", ", lost)
+ " structure, so that content is now plain paragraphs."
+ remedy);
}
/** Structure whose loss changes what a screen reader conveys, not just how it is nested. */
private static final List<String> MEANINGFUL_TYPES =
List.of("Table", "TH", "Formula", "L", "LI", "TOC", "Note");
private static Set<String> structureTypes(PDDocument document) {
Set<String> types = new HashSet<>();
try {
PDStructureTreeRoot root = document.getDocumentCatalog().getStructureTreeRoot();
if (root != null) {
collectTypes(root.getKids(), types, 0);
}
} catch (RuntimeException e) {
log.debug("Could not read structure types: {}", e.getMessage());
}
return types;
}
private static void collectTypes(Object node, Set<String> types, int depth) {
// Structure trees can be deep or, in damaged files, cyclic; cap rather than overflow.
if (node == null || depth > 64) {
return;
}
if (node instanceof List<?> list) {
list.forEach(child -> collectTypes(child, types, depth + 1));
} else if (node instanceof PDStructureElement element) {
types.add(element.getStructureType());
collectTypes(element.getKids(), types, depth + 1);
}
}
private static void stripStructure(PDDocument document) {
PDDocumentCatalog catalog = document.getDocumentCatalog();
catalog.getCOSObject().removeItem(COSName.getPDFName("StructTreeRoot"));
catalog.getCOSObject().removeItem(COSName.getPDFName("MarkInfo"));
document.getPages()
.forEach(
page ->
page.getCOSObject()
.removeItem(COSName.getPDFName("StructParents")));
log.info("Removed existing structure tree before rebuilding");
}
}
@@ -0,0 +1,59 @@
package stirling.software.proprietary.pdf.ua;
import java.util.HashSet;
import java.util.Set;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.documentinterchange.logicalstructure.PDStructureElement;
import org.apache.pdfbox.pdmodel.documentinterchange.logicalstructure.PDStructureTreeRoot;
import lombok.extern.slf4j.Slf4j;
/**
* What the document said about itself before anything rewrote it. Font embedding shells out to
* Ghostscript, which returns a file with no structure tree, no {@code /Lang} and no XMP, so a
* tagger reading the rewritten document sees an untagged, language-less file and cannot tell that
* anything was lost. These facts are captured from the original and carried past that stage.
*
* @param language the catalog {@code /Lang} the author declared, or null
* @param structureTypes every structure element type the original tree contained
* @param hasUsableTree whether the original had a structure tree worth preserving
*/
@Slf4j
public record SourceFacts(String language, Set<String> structureTypes, boolean hasUsableTree) {
private static final int MAX_DEPTH = 64;
/** Facts for a document nothing has rewritten, used when font embedding did not run. */
public static final SourceFacts NONE = new SourceFacts(null, Set.of(), false);
public static SourceFacts of(PDDocument document) {
String language = null;
Set<String> types = new HashSet<>();
boolean usable = false;
try {
language = document.getDocumentCatalog().getLanguage();
usable = PdfUaTagger.hasUsableStructureTree(document);
PDStructureTreeRoot root = document.getDocumentCatalog().getStructureTreeRoot();
if (root != null) {
collect(root.getKids(), types, 0);
}
} catch (RuntimeException e) {
log.debug("Could not read source facts: {}", e.getMessage());
}
return new SourceFacts(language, Set.copyOf(types), usable);
}
private static void collect(Object node, Set<String> types, int depth) {
// Damaged files can present a cyclic tree; cap rather than overflow the stack.
if (node == null || depth > MAX_DEPTH) {
return;
}
if (node instanceof java.util.List<?> list) {
list.forEach(child -> collect(child, types, depth + 1));
} else if (node instanceof PDStructureElement element) {
types.add(element.getStructureType());
collect(element.getKids(), types, depth + 1);
}
}
}
@@ -0,0 +1,134 @@
package stirling.software.proprietary.pdf.ua;
import java.util.ArrayList;
import java.util.List;
import java.util.function.Consumer;
import lombok.Getter;
import lombok.Setter;
/**
* One node of the derived logical structure: either page content (ranges of markable operator
* ordinals) or child blocks. Containers with no content are pruned before serialisation.
*/
@Getter
@Setter
public class StructBlock {
/** A contiguous, inclusive run of markable operator ordinals within one page stream. */
public record OrdinalRange(int start, int end) {
public boolean contains(int ordinal) {
return ordinal >= start && ordinal <= end;
}
public int size() {
return end - start + 1;
}
}
private StructType type;
private ArtifactType artifactType;
private int pageIndex;
private BBox bbox = BBox.EMPTY;
private String text = "";
private final List<OrdinalRange> ranges = new ArrayList<>();
private final List<StructBlock> children = new ArrayList<>();
/** {@code /Alt} - required on Figure and Formula for PDF/UA. */
private String alt;
/** {@code /ActualText} - replacement text for content whose glyphs do not spell the word. */
private String actualText;
/** {@code /Lang} - set only where it differs from the document default. */
private String lang;
/** {@code /Scope} on a TH: Row, Column or Both. */
private String scope;
/** {@code /ListNumbering} on an L. */
private String listNumbering;
/** Unique {@code /ID}, required on Note and FENote elements. */
private String id;
/**
* Marked content ids assigned during injection; one block yields several when split, since a
* sequence must nest inside BT/ET and q/Q rather than straddle them.
*/
private final List<Integer> mcids = new ArrayList<>();
/** True when the source content was already inside a marked-content sequence. */
private boolean preMarked;
public StructBlock(StructType type, int pageIndex) {
this.type = type;
this.pageIndex = pageIndex;
}
public static StructBlock artifact(ArtifactType artifactType, int pageIndex) {
StructBlock block = new StructBlock(StructType.ARTIFACT, pageIndex);
block.artifactType = artifactType;
return block;
}
public StructBlock addChild(StructBlock child) {
children.add(child);
return this;
}
public StructBlock addRange(int start, int end) {
ranges.add(new OrdinalRange(start, end));
return this;
}
public boolean isArtifact() {
return type == StructType.ARTIFACT;
}
/** Depth-first walk over this block and all descendants. */
public void visit(Consumer<StructBlock> visitor) {
visitor.accept(this);
for (StructBlock child : children) {
child.visit(visitor);
}
}
/** Total number of ordinals owned by this block and its descendants. */
public int contentCount() {
int total = ranges.stream().mapToInt(OrdinalRange::size).sum();
for (StructBlock child : children) {
total += child.contentCount();
}
return total;
}
/** Concatenated text of this block and its descendants, in tree order. */
public String collectText() {
StringBuilder sb = new StringBuilder();
visit(
block -> {
if (!block.text.isBlank()) {
if (sb.length() > 0) {
sb.append(' ');
}
sb.append(block.text.strip());
}
});
return sb.toString();
}
@Override
public String toString() {
return type.tag()
+ (artifactType != null ? "[" + artifactType.subtype() + "]" : "")
+ "(p"
+ pageIndex
+ ", "
+ ranges.size()
+ " ranges, "
+ children.size()
+ " kids)";
}
}
@@ -0,0 +1,295 @@
package stirling.software.proprietary.pdf.ua;
import java.io.IOException;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import org.apache.pdfbox.cos.COSArray;
import org.apache.pdfbox.cos.COSBase;
import org.apache.pdfbox.cos.COSDictionary;
import org.apache.pdfbox.cos.COSInteger;
import org.apache.pdfbox.cos.COSName;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.pdmodel.common.PDNumberTreeNode;
import org.apache.pdfbox.pdmodel.documentinterchange.logicalstructure.PDMarkInfo;
import org.apache.pdfbox.pdmodel.documentinterchange.logicalstructure.PDObjectReference;
import org.apache.pdfbox.pdmodel.documentinterchange.logicalstructure.PDStructureElement;
import org.apache.pdfbox.pdmodel.documentinterchange.logicalstructure.PDStructureTreeRoot;
import org.apache.pdfbox.pdmodel.documentinterchange.taggedpdf.PDListAttributeObject;
import org.apache.pdfbox.pdmodel.documentinterchange.taggedpdf.PDTableAttributeObject;
import org.apache.pdfbox.pdmodel.interactive.annotation.PDAnnotation;
import org.apache.pdfbox.pdmodel.interactive.annotation.PDAnnotationLink;
import org.apache.pdfbox.pdmodel.interactive.annotation.PDAnnotationWidget;
import lombok.extern.slf4j.Slf4j;
/**
* Serialises a {@link DocumentStructure} into a PDF structure tree. Must run after {@link
* MarkedContentInjector}, which assigns the marked content ids this writer references.
*/
@Slf4j
public class StructTreeWriter {
private static final COSName STRUCT_PARENT = COSName.getPDFName("StructParent");
private static final COSName NUMS = COSName.getPDFName("Nums");
private static final String PDF2_STANDARD_NAMESPACE = "http://iso.org/pdf2/ssn";
/** Per-page marked content id to owning element, built while walking the tree. */
private final Map<Integer, Map<Integer, PDStructureElement>> mcidOwners = new LinkedHashMap<>();
private COSDictionary standardNamespace;
private int nextParentKey;
public void write(PDDocument document, DocumentStructure structure, PdfUaProfile profile)
throws IOException {
PDStructureTreeRoot root = new PDStructureTreeRoot();
PDStructureElement documentElement =
new PDStructureElement(StructType.DOCUMENT.tag(), root);
if (structure.getLanguage() != null) {
documentElement.setLanguage(structure.getLanguage());
}
if (profile == PdfUaProfile.UA2) {
applyNamespace(documentElement, document);
}
for (StructBlock block : structure.getBlocks()) {
if (block.isArtifact()) {
continue;
}
PDStructureElement child = buildElement(document, block, documentElement, profile);
if (child != null) {
documentElement.appendKid(child);
}
}
root.appendKid(documentElement);
buildParentTree(document, root);
registerNamespaces(root);
PDMarkInfo markInfo = new PDMarkInfo();
markInfo.setMarked(true);
document.getDocumentCatalog().setMarkInfo(markInfo);
document.getDocumentCatalog().setStructureTreeRoot(root);
}
/** Recursively builds an element, returning null when the block carries no content at all. */
private PDStructureElement buildElement(
PDDocument document,
StructBlock block,
PDStructureElement parent,
PdfUaProfile profile) {
// Prune on assigned MCIDs, not claimed ranges: form-XObject lines all resolve to one Do,
// and emitting the losers would announce empty paragraphs to a screen reader.
if (!carriesContent(block)) {
return null;
}
StructType type = effectiveType(block, profile);
PDStructureElement element = new PDStructureElement(type.tag(), parent);
PDPage page = document.getPage(block.getPageIndex());
element.setPage(page);
if (profile == PdfUaProfile.UA2) {
applyNamespace(element, document);
}
applyAttributes(block, element);
for (int mcid : block.getMcids()) {
element.appendKid(mcid);
mcidOwners
.computeIfAbsent(block.getPageIndex(), k -> new LinkedHashMap<>())
.put(mcid, element);
}
for (StructBlock child : block.getChildren()) {
PDStructureElement childElement = buildElement(document, child, element, profile);
if (childElement != null) {
element.appendKid(childElement);
}
}
return element;
}
/** True when this block, or something beneath it, was actually given marked content. */
private static boolean carriesContent(StructBlock block) {
if (!block.getMcids().isEmpty()) {
return true;
}
return block.getChildren().stream().anyMatch(StructTreeWriter::carriesContent);
}
/** PDF/UA-2 replaces Note with FENote for footnotes. */
private static StructType effectiveType(StructBlock block, PdfUaProfile profile) {
if (profile == PdfUaProfile.UA2 && block.getType() == StructType.NOTE) {
return StructType.FENOTE;
}
return block.getType();
}
private static void applyAttributes(StructBlock block, PDStructureElement element) {
if (block.getAlt() != null && !block.getAlt().isBlank()) {
element.setAlternateDescription(block.getAlt());
}
if (block.getActualText() != null && !block.getActualText().isBlank()) {
element.setActualText(block.getActualText());
}
if (block.getLang() != null && !block.getLang().isBlank()) {
element.setLanguage(block.getLang());
}
if (block.getId() != null && !block.getId().isBlank()) {
element.setElementIdentifier(block.getId());
}
if (block.getScope() != null) {
PDTableAttributeObject table = new PDTableAttributeObject();
table.setScope(block.getScope());
element.addAttribute(table);
}
if (block.getListNumbering() != null) {
PDListAttributeObject list = new PDListAttributeObject();
list.setListNumbering(block.getListNumbering());
element.addAttribute(list);
}
}
/** PDF/UA-2 requires every element to declare the standard structure namespace. */
private void applyNamespace(PDStructureElement element, PDDocument document) {
element.getCOSObject().setItem(COSName.getPDFName("NS"), standardNamespace());
}
/** The PDF 2.0 standard structure namespace, created once per document. */
private COSDictionary standardNamespace() {
if (standardNamespace == null) {
standardNamespace = new COSDictionary();
standardNamespace.setName(COSName.TYPE, "Namespace");
standardNamespace.setString(COSName.getPDFName("NS"), PDF2_STANDARD_NAMESPACE);
}
return standardNamespace;
}
private void registerNamespaces(PDStructureTreeRoot root) {
if (standardNamespace == null) {
return;
}
COSArray namespaces = new COSArray();
namespaces.add(standardNamespace);
root.getCOSObject().setItem(COSName.getPDFName("Namespaces"), namespaces);
}
/**
* Builds {@code /ParentTree}: per page, an array indexed by marked content id keyed on {@code
* /StructParents}, plus one entry per annotation keyed on {@code /StructParent}.
*/
private void buildParentTree(PDDocument document, PDStructureTreeRoot root) {
COSArray nums = new COSArray();
nextParentKey = 0;
for (int pageIndex = 0; pageIndex < document.getNumberOfPages(); pageIndex++) {
Map<Integer, PDStructureElement> owners = mcidOwners.get(pageIndex);
if (owners == null || owners.isEmpty()) {
continue;
}
PDPage page = document.getPage(pageIndex);
int key = nextParentKey++;
page.setStructParents(key);
int maxMcid = owners.keySet().stream().mapToInt(Integer::intValue).max().orElse(-1);
COSArray entries = new COSArray();
for (int mcid = 0; mcid <= maxMcid; mcid++) {
PDStructureElement owner = owners.get(mcid);
entries.add(
owner != null ? owner.getCOSObject() : org.apache.pdfbox.cos.COSNull.NULL);
}
nums.add(COSInteger.get(key));
nums.add(entries);
}
List<COSBase> annotationEntries = tagAnnotations(document, root);
for (int i = 0; i + 1 < annotationEntries.size(); i += 2) {
nums.add(annotationEntries.get(i));
nums.add(annotationEntries.get(i + 1));
}
COSDictionary parentTreeDict = new COSDictionary();
parentTreeDict.setItem(NUMS, nums);
root.setParentTree(new PDNumberTreeNode(parentTreeDict, PDStructureElement.class));
root.setParentTreeNextKey(nextParentKey);
}
/**
* Clause 7.18: every visible annotation needs a structure element so it is reachable from the
* tree. Links become Link elements, anything else an Annot.
*/
private List<COSBase> tagAnnotations(PDDocument document, PDStructureTreeRoot root) {
List<COSBase> entries = new ArrayList<>();
PDStructureElement documentElement = firstDocumentElement(root);
if (documentElement == null) {
return entries;
}
for (int pageIndex = 0; pageIndex < document.getNumberOfPages(); pageIndex++) {
PDPage page = document.getPage(pageIndex);
List<PDAnnotation> annotations;
try {
annotations = page.getAnnotations();
} catch (IOException e) {
log.debug("Could not read annotations on page {}: {}", pageIndex, e.getMessage());
continue;
}
for (PDAnnotation annotation : annotations) {
if (annotation == null
|| annotation.isHidden()
|| annotation.isNoView()
|| "Popup".equals(annotation.getSubtype())) {
continue;
}
PDStructureElement element =
new PDStructureElement(annotationType(annotation), documentElement);
element.setPage(page);
PDObjectReference reference = new PDObjectReference();
reference.setReferencedObject(annotation);
element.appendKid(reference);
documentElement.appendKid(element);
int key = nextParentKey++;
annotation.getCOSObject().setInt(STRUCT_PARENT, key);
entries.add(COSInteger.get(key));
entries.add(element.getCOSObject());
if (annotation.getContents() == null || annotation.getContents().isBlank()) {
annotation.setContents(defaultContents(annotation));
}
}
}
return entries;
}
/** Clause 7.18.4: widgets need a Form element, links a Link element, everything else Annot. */
private static String annotationType(PDAnnotation annotation) {
if (annotation instanceof PDAnnotationWidget) {
return StructType.FORM.tag();
}
if (annotation instanceof PDAnnotationLink) {
return StructType.LINK.tag();
}
return "Annot";
}
private static String defaultContents(PDAnnotation annotation) {
if (annotation instanceof PDAnnotationLink link && link.getAction() != null) {
return "Link";
}
return annotation.getSubtype() == null ? "Annotation" : annotation.getSubtype();
}
private static PDStructureElement firstDocumentElement(PDStructureTreeRoot root) {
for (Object kid : root.getKids()) {
if (kid instanceof PDStructureElement element) {
return element;
}
}
return null;
}
}
@@ -0,0 +1,62 @@
package stirling.software.proprietary.pdf.ua;
/**
* PDF standard structure types emitted by the tagger (ISO 32000-1 14.8.4), limited to the PDF/UA
* subset. {@link #ARTIFACT} is not one: it marks content in the stream and stays out of the tree.
*/
public enum StructType {
DOCUMENT("Document"),
PART("Part"),
SECT("Sect"),
H1("H1"),
H2("H2"),
H3("H3"),
H4("H4"),
H5("H5"),
H6("H6"),
P("P"),
L("L"),
LI("LI"),
LBL("Lbl"),
LBODY("LBody"),
TABLE("Table"),
TR("TR"),
TH("TH"),
TD("TD"),
FIGURE("Figure"),
CAPTION("Caption"),
FORMULA("Formula"),
NOTE("Note"),
FENOTE("FENote"),
LINK("Link"),
/** Wraps a widget annotation; PDF/UA-1 clause 7.18.4 requires widgets to sit inside one. */
FORM("Form"),
SPAN("Span"),
ARTIFACT("Artifact");
private final String tag;
StructType(String tag) {
this.tag = tag;
}
/** The name written into the PDF {@code /S} entry. */
public String tag() {
return tag;
}
public boolean isHeading() {
return this == H1 || this == H2 || this == H3 || this == H4 || this == H5 || this == H6;
}
/** Heading level 1-6, or 0 when this is not a heading. */
public int headingLevel() {
return isHeading() ? ordinal() - H1.ordinal() + 1 : 0;
}
/** The heading type for a 1-based level, clamped to the H1-H6 range. */
public static StructType heading(int level) {
int clamped = Math.max(1, Math.min(6, level));
return values()[H1.ordinal() + clamped - 1];
}
}
@@ -0,0 +1,630 @@
package stirling.software.proprietary.pdf.ua;
import java.io.IOException;
import java.io.Writer;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.IdentityHashMap;
import java.util.List;
import java.util.Map;
import org.apache.pdfbox.contentstream.operator.Operator;
import org.apache.pdfbox.cos.COSBase;
import org.apache.pdfbox.cos.COSDictionary;
import org.apache.pdfbox.cos.COSName;
import org.apache.pdfbox.pdfparser.PDFStreamParser;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.pdmodel.PDResources;
import org.apache.pdfbox.pdmodel.common.PDRectangle;
import org.apache.pdfbox.pdmodel.font.PDFontDescriptor;
import org.apache.pdfbox.pdmodel.font.PDType3Font;
import org.apache.pdfbox.pdmodel.graphics.PDXObject;
import org.apache.pdfbox.pdmodel.graphics.form.PDFormXObject;
import org.apache.pdfbox.pdmodel.graphics.form.PDTransparencyGroup;
import org.apache.pdfbox.pdmodel.graphics.image.PDImageXObject;
import org.apache.pdfbox.text.PDFTextStripper;
import org.apache.pdfbox.text.TextPosition;
import org.apache.pdfbox.util.Matrix;
import org.apache.pdfbox.util.Vector;
import lombok.extern.slf4j.Slf4j;
/**
* Extracts text lines and graphic ops from page streams, tagging each with its operator ordinal.
* Both passes count the same operators in the same order, so ordinals cross-reference.
*/
@Slf4j
public class TaggedContentExtractor {
/** Glyph size below which a run is treated as noise rather than a line. */
private static final float MIN_FONT_SIZE = 0.5f;
public List<PageContent> extract(PDDocument document) throws IOException {
LineCollector collector = new LineCollector();
collector.setSortByPosition(true);
collector.setStartPage(1);
collector.setEndPage(document.getNumberOfPages());
collector.writeText(document, Writer.nullWriter());
List<PageContent> pages = new ArrayList<>(document.getNumberOfPages());
for (int i = 0; i < document.getNumberOfPages(); i++) {
PDPage page = document.getPage(i);
List<MarkableOp> ops = collector.opsFor(i);
List<TextLineInfo> lines = collector.linesFor(i);
boolean dropped = false;
if (ops.size() < maxOrdinal(lines) + 1) {
// Untrusted ordinals: drop the lines so the page is untaggable rather than
// mis-tagged, and flag it so the caller refuses to declare conformance.
log.warn(
"Ordinal mismatch on page {} (ops={}, text={}); skipping page",
i,
ops.size(),
maxOrdinal(lines) + 1);
dropped = !lines.isEmpty();
lines = List.of();
}
pages.add(
new PageContent(
i,
lines,
ops,
ops.size(),
collector.preMarkedOn(i),
collector.textSemanticsOn(i),
dropped,
normalisedBox(page)));
}
return pages;
}
/**
* The page box in the space of extracted line coordinates: origin-zero, width and height
* swapped for 90/270 rotations, because the text engine reports in the rotated frame.
*/
static BBox normalisedBox(PDPage page) {
PDRectangle mediaBox = page.getMediaBox();
boolean sideways = page.getRotation() % 180 != 0;
float width = sideways ? mediaBox.getHeight() : mediaBox.getWidth();
float height = sideways ? mediaBox.getWidth() : mediaBox.getHeight();
return new BBox(0, 0, width, height);
}
/** Counts images with the token scan alone, skipping the expensive text pass. */
public int countGraphics(PDDocument document) {
int total = 0;
for (int i = 0; i < document.getNumberOfPages(); i++) {
try {
PDResources resources = document.getPage(i).getResources();
PDFStreamParser parser = new PDFStreamParser(document.getPage(i));
List<COSBase> operands = new ArrayList<>();
Object token;
while ((token = parser.parseNextToken()) != null) {
if (!(token instanceof Operator operator)) {
operands.add((COSBase) token);
continue;
}
if (isGraphicOperator(operator.getName(), operands, resources)) {
total++;
}
operands.clear();
}
} catch (IOException e) {
log.debug("Could not scan page {} for graphics: {}", i, e.getMessage());
}
}
return total;
}
/** True for an inline image, or a Do that resolves to an image XObject. */
private static boolean isGraphicOperator(
String name, List<COSBase> operands, PDResources resources) {
if ("BI".equals(name)) {
return true;
}
if (!"Do".equals(name) || resources == null || operands.size() != 1) {
return false;
}
if (!(operands.get(0) instanceof COSName resourceName)) {
return false;
}
try {
return resources.getXObject(resourceName) instanceof PDImageXObject;
} catch (IOException e) {
return false;
}
}
private static int maxOrdinal(List<TextLineInfo> lines) {
return lines.stream().mapToInt(TextLineInfo::endOrdinal).max().orElse(-1);
}
static BBox toBBox(PDRectangle rect) {
return new BBox(
rect.getLowerLeftX(),
rect.getLowerLeftY(),
rect.getUpperRightX(),
rect.getUpperRightY());
}
// --- Operator classification -------------------------------------------
private static boolean isPathConstruction(String name) {
return switch (name) {
case "m", "l", "c", "v", "y", "re" -> true;
default -> false;
};
}
/** True when a sequence carries replacement or alternative text, which a rebuild would drop. */
private static boolean carriesTextSemantics(List<COSBase> operands) {
for (COSBase operand : operands) {
if (operand instanceof COSDictionary dictionary
&& (dictionary.containsKey(COSName.getPDFName("ActualText"))
|| dictionary.containsKey(COSName.getPDFName("Alt"))
|| dictionary.containsKey(COSName.E))) {
return true;
}
}
return false;
}
/**
* Describes one markable operator, placed with the engine's own matrix rather than a
* hand-rolled q/Q/cm stack that would get nesting and form matrices wrong.
*/
private static MarkableOp classify(
String name,
List<COSBase> operands,
PDResources resources,
Matrix ctm,
BBox pathBox,
int ordinal) {
if ("BI".equals(name)) {
return new MarkableOp(ordinal, MarkableOp.Kind.INLINE_IMAGE, unitSquare(ctm), null);
}
if (MarkableOp.isPathPainting(name)) {
return new MarkableOp(ordinal, MarkableOp.Kind.VECTOR, pathBox, null);
}
if (!"Do".equals(name)) {
return new MarkableOp(ordinal, MarkableOp.Kind.TEXT, BBox.EMPTY, null);
}
COSName resourceName =
operands.size() == 1 && operands.get(0) instanceof COSName n ? n : null;
if (resourceName == null || resources == null) {
return new MarkableOp(ordinal, MarkableOp.Kind.FORM, unitSquare(ctm), null);
}
try {
PDXObject xobject = resources.getXObject(resourceName);
if (xobject instanceof PDImageXObject) {
return new MarkableOp(
ordinal, MarkableOp.Kind.IMAGE, unitSquare(ctm), resourceName.getName());
}
if (xobject instanceof PDFormXObject form) {
return new MarkableOp(
ordinal, MarkableOp.Kind.FORM, formBox(form, ctm), resourceName.getName());
}
} catch (IOException e) {
log.debug("Could not resolve XObject {}: {}", resourceName.getName(), e.getMessage());
}
return new MarkableOp(
ordinal, MarkableOp.Kind.FORM, unitSquare(ctm), resourceName.getName());
}
/**
* Extends the running path box with one path-construction operator's points; without it every
* vector had an empty box and charts and vector logos vanished from the structure tree.
*/
private static BBox extendPath(BBox current, String name, List<COSBase> operands, Matrix ctm) {
int pairs =
switch (name) {
case "m", "l" -> 1;
case "re" -> 2;
case "v", "y" -> 2;
case "c" -> 3;
default -> 0;
};
if (pairs == 0 || operands.size() < pairs * 2) {
return current;
}
// Deliberately allocation-free; the obvious version cost a third of the extraction budget.
float minX = current.isEmpty() ? Float.MAX_VALUE : current.x0();
float minY = current.isEmpty() ? Float.MAX_VALUE : current.y0();
float maxX = current.isEmpty() ? -Float.MAX_VALUE : current.x1();
float maxY = current.isEmpty() ? -Float.MAX_VALUE : current.y1();
for (int pair = 0; pair < pairs; pair++) {
Float x = numberAt(operands, pair * 2);
Float y = numberAt(operands, pair * 2 + 1);
if (x == null || y == null) {
continue;
}
float px = x;
float py = y;
// "re" gives origin plus size, so the second pair is a corner offset from the first.
if ("re".equals(name) && pair == 1) {
Float ox = numberAt(operands, 0);
Float oy = numberAt(operands, 1);
if (ox == null || oy == null) {
continue;
}
px = ox + x;
py = oy + y;
}
float tx = ctm.getScaleX() * px + ctm.getShearX() * py + ctm.getTranslateX();
float ty = ctm.getShearY() * px + ctm.getScaleY() * py + ctm.getTranslateY();
minX = Math.min(minX, tx);
minY = Math.min(minY, ty);
maxX = Math.max(maxX, tx);
maxY = Math.max(maxY, ty);
}
return maxX < minX ? current : new BBox(minX, minY, maxX, maxY);
}
private static Float numberAt(List<COSBase> operands, int index) {
return index < operands.size()
&& operands.get(index) instanceof org.apache.pdfbox.cos.COSNumber number
? number.floatValue()
: null;
}
/** The unit square mapped through the CTM, which is how images are placed. */
private static BBox unitSquare(Matrix ctm) {
return transformBox(new BBox(0, 0, 1, 1), ctm);
}
private static BBox formBox(PDFormXObject form, Matrix ctm) {
PDRectangle box = form.getBBox();
if (box == null) {
return unitSquare(ctm);
}
Matrix combined = form.getMatrix() != null ? form.getMatrix().multiply(ctm) : ctm;
return transformBox(toBBox(box), combined);
}
private static BBox transformBox(BBox box, Matrix m) {
float[] xs = new float[4];
float[] ys = new float[4];
float[][] corners = {
{box.x0(), box.y0()}, {box.x1(), box.y0()},
{box.x0(), box.y1()}, {box.x1(), box.y1()}
};
for (int i = 0; i < 4; i++) {
Vector v = m.transform(new Vector(corners[i][0], corners[i][1]));
xs[i] = v.getX();
ys[i] = v.getY();
}
float minX = Math.min(Math.min(xs[0], xs[1]), Math.min(xs[2], xs[3]));
float maxX = Math.max(Math.max(xs[0], xs[1]), Math.max(xs[2], xs[3]));
float minY = Math.min(Math.min(ys[0], ys[1]), Math.min(ys[2], ys[3]));
float maxY = Math.max(Math.max(ys[0], ys[1]), Math.max(ys[2], ys[3]));
return new BBox(minX, minY, maxX, maxY);
}
// --- Text pass ---------------------------------------------------------
/** Marker recorded for each glyph so a finished line knows where it came from. */
private record GlyphOrigin(int ordinal, boolean marked) {}
private static final class LineCollector extends PDFTextStripper {
private final Map<Integer, List<TextLineInfo>> byPage = new HashMap<>();
private final Map<Integer, List<MarkableOp>> opsByPage = new HashMap<>();
private final Map<Integer, Boolean> preMarkedByPage = new HashMap<>();
private final Map<Integer, Boolean> textSemanticsByPage = new HashMap<>();
private final Map<TextPosition, GlyphOrigin> origins = new IdentityHashMap<>();
private final List<TextPosition> lineBuffer = new ArrayList<>();
private final List<WordInfo> lineWords = new ArrayList<>();
private final StringBuilder lineText = new StringBuilder();
private int ordinal = -1;
private int markedDepth;
private int nestedDepth;
private BBox pathBox = BBox.EMPTY;
private int syntheticDepth;
private float pageHeight;
private int pageIndex;
LineCollector() throws IOException {
super();
}
List<TextLineInfo> linesFor(int index) {
return byPage.getOrDefault(index, List.of());
}
List<MarkableOp> opsFor(int index) {
return opsByPage.getOrDefault(index, List.of());
}
boolean preMarkedOn(int index) {
return preMarkedByPage.getOrDefault(index, false);
}
boolean textSemanticsOn(int index) {
return textSemanticsByPage.getOrDefault(index, false);
}
@Override
protected void startPage(PDPage page) throws IOException {
ordinal = -1;
markedDepth = 0;
nestedDepth = 0;
syntheticDepth = 0;
pathBox = BBox.EMPTY;
origins.clear();
lineBuffer.clear();
lineWords.clear();
lineText.setLength(0);
// Dir-adjusted glyph coordinates live in the rotated frame, so the flip must too.
pageHeight = normalisedBox(page).height();
pageIndex = getCurrentPageNo() - 1;
super.startPage(page);
}
@Override
protected void endPage(PDPage page) throws IOException {
flushLine();
super.endPage(page);
}
/**
* Counts only operators physically present in the page's own stream: PDFBox re-enters here
* with synthetic calls for {@code '} and {@code "}, and descends into form XObjects.
*/
@Override
protected void processOperator(Operator operator, List<COSBase> operands)
throws IOException {
String name = operator.getName();
if (nestedDepth == 0 && syntheticDepth == 0) {
if (isPathConstruction(name)) {
pathBox =
extendPath(
pathBox,
name,
operands,
getGraphicsState().getCurrentTransformationMatrix());
}
if (MarkableOp.isMarkableOperator(name)) {
ordinal++;
// Classified here rather than in a second parse of the same stream: the engine
// already has the operands and the live transformation matrix.
opsByPage
.computeIfAbsent(pageIndex, k -> new ArrayList<>())
.add(
classify(
name,
operands,
getResources(),
getGraphicsState().getCurrentTransformationMatrix(),
pathBox,
ordinal));
if (MarkableOp.isPathPainting(name)) {
pathBox = BBox.EMPTY;
}
} else if ("n".equals(name)) {
pathBox = BBox.EMPTY;
} else if ("BDC".equals(name) || "BMC".equals(name)) {
markedDepth++;
preMarkedByPage.put(pageIndex, true);
if (carriesTextSemantics(operands)) {
textSemanticsByPage.put(pageIndex, true);
}
} else if ("EMC".equals(name) && markedDepth > 0) {
markedDepth--;
}
}
boolean synthesises = "'".equals(name) || "\"".equals(name);
if (synthesises) {
syntheticDepth++;
}
try {
super.processOperator(operator, operands);
} finally {
if (synthesises) {
syntheticDepth--;
}
}
}
@Override
public void showForm(PDFormXObject form) throws IOException {
nestedDepth++;
try {
super.showForm(form);
} finally {
nestedDepth--;
}
}
@Override
public void showTransparencyGroup(PDTransparencyGroup group) throws IOException {
nestedDepth++;
try {
super.showTransparencyGroup(group);
} finally {
nestedDepth--;
}
}
@Override
protected void showType3Glyph(
Matrix textRenderingMatrix,
PDType3Font font,
int code,
org.apache.pdfbox.util.Vector displacement)
throws IOException {
nestedDepth++;
try {
super.showType3Glyph(textRenderingMatrix, font, code, displacement);
} finally {
nestedDepth--;
}
}
@Override
protected void processChildStream(
org.apache.pdfbox.contentstream.PDContentStream contentStream, PDPage page)
throws IOException {
nestedDepth++;
try {
super.processChildStream(contentStream, page);
} finally {
nestedDepth--;
}
}
@Override
protected void processTextPosition(TextPosition text) {
origins.put(text, new GlyphOrigin(ordinal, markedDepth > 0));
super.processTextPosition(text);
}
@Override
protected void writeString(String text, List<TextPosition> positions) {
lineText.append(text);
lineBuffer.addAll(positions);
WordInfo word = buildWord(text, positions);
if (word != null) {
lineWords.add(word);
}
}
private WordInfo buildWord(String text, List<TextPosition> positions) {
if (text == null || text.isBlank() || positions.isEmpty()) {
return null;
}
Bounds bounds = new Bounds();
for (TextPosition tp : positions) {
bounds.accept(tp, pageHeight, origins.get(tp));
}
if (bounds.end < 0) {
return null;
}
return new WordInfo(
text,
bounds.box(),
bounds.start,
bounds.end,
bounds.dominantSize(),
bounds.bold);
}
@Override
protected void writeWordSeparator() {
lineText.append(' ');
}
@Override
protected void writeLineSeparator() {
flushLine();
}
@Override
protected void writeParagraphSeparator() {
flushLine();
}
private void flushLine() {
if (lineBuffer.isEmpty()) {
lineText.setLength(0);
lineWords.clear();
return;
}
TextLineInfo line = buildLine();
lineBuffer.clear();
lineWords.clear();
lineText.setLength(0);
if (line != null) {
byPage.computeIfAbsent(pageIndex, k -> new ArrayList<>()).add(line);
}
}
private TextLineInfo buildLine() {
String text = lineText.toString();
if (text.isBlank()) {
return null;
}
Bounds bounds = new Bounds();
for (TextPosition tp : lineBuffer) {
bounds.accept(tp, pageHeight, origins.get(tp));
}
if (bounds.end < 0) {
return null;
}
return new TextLineInfo(
pageIndex,
text,
bounds.box(),
bounds.dominantSize(),
bounds.bold,
bounds.start,
bounds.end,
bounds.marked,
List.copyOf(lineWords));
}
}
/** Accumulates glyph geometry, ordinals and font signals for a word or a line. */
private static final class Bounds {
private float minX = Float.MAX_VALUE;
private float maxX = -Float.MAX_VALUE;
private float minY = Float.MAX_VALUE;
private float maxY = -Float.MAX_VALUE;
private int start = Integer.MAX_VALUE;
private int end = -1;
private boolean marked;
private boolean bold;
private final Map<Float, Integer> sizeCounts = new HashMap<>();
void accept(TextPosition tp, float pageHeight, GlyphOrigin origin) {
float top = pageHeight - tp.getYDirAdj();
float bottom = top - Math.max(tp.getHeightDir(), 0);
minX = Math.min(minX, tp.getXDirAdj());
maxX = Math.max(maxX, tp.getXDirAdj() + tp.getWidthDirAdj());
minY = Math.min(minY, bottom);
maxY = Math.max(maxY, top);
if (origin != null) {
start = Math.min(start, origin.ordinal());
end = Math.max(end, origin.ordinal());
marked |= origin.marked();
}
float size = tp.getFontSizeInPt();
if (size > MIN_FONT_SIZE) {
sizeCounts.merge(round(size), 1, Integer::sum);
}
bold |= isBold(tp);
}
BBox box() {
return new BBox(minX, minY, maxX, maxY);
}
float dominantSize() {
return sizeCounts.entrySet().stream()
.max(Map.Entry.comparingByValue())
.map(Map.Entry::getKey)
.orElse(0f);
}
private static float round(float value) {
return Math.round(value * 10f) / 10f;
}
private static boolean isBold(TextPosition tp) {
if (tp.getFont() == null) {
return false;
}
String name = tp.getFont().getName();
if (name != null && name.toLowerCase().contains("bold")) {
return true;
}
PDFontDescriptor descriptor = tp.getFont().getFontDescriptor();
return descriptor != null
&& (descriptor.getFontWeight() >= 600 || descriptor.isForceBold());
}
}
}
@@ -0,0 +1,63 @@
package stirling.software.proprietary.pdf.ua;
import java.util.Map;
import lombok.Builder;
import lombok.Getter;
/** Inputs that change how a document is tagged. */
@Getter
@Builder(toBuilder = true)
public class TaggingOptions {
/** What to do when the source already has a structure tree. */
public enum ExistingTags {
/** Leave the tree alone and fix only document-level requirements. */
KEEP,
/** Discard the tree and derive a new one. */
REBUILD,
/** Keep a usable tree, rebuild an empty or trivially broken one. */
AUTO
}
/** How images with no alternative description are handled. */
public enum FigurePolicy {
/** Leave undescribed so validation fails honestly; a faked {@code /Alt} helps nobody. */
REQUIRE_ALT,
/** Treat every image as decoration and mark it as an artifact. */
MARK_DECORATIVE
}
@Builder.Default private PdfUaProfile profile = PdfUaProfile.UA1;
/** BCP-47 language tag for the document, for example {@code en-GB}. */
private String language;
/** Replace a language the document already declares. Off, so a French file stays French. */
@Builder.Default private boolean overrideLanguage = false;
private String title;
/** Last resort when no title is given and none can be derived; pass the uploaded filename. */
private String fallbackTitle;
/** Embed any font the document references but does not carry, which clause 7.21 requires. */
@Builder.Default private boolean embedFonts = true;
/** Leave the PDF version alone; raising it would break PDF/A-1, defined on PDF 1.4. */
@Builder.Default private boolean preservePdfVersion = false;
@Builder.Default private ExistingTags existingTags = ExistingTags.AUTO;
@Builder.Default private FigurePolicy figurePolicy = FigurePolicy.REQUIRE_ALT;
/** Alternative descriptions supplied by the caller, keyed by "pageIndex:ordinal". */
@Builder.Default private Map<String, String> altTextByFigure = Map.of();
/** What the document said before font embedding rewrote it; see {@link SourceFacts}. */
@Builder.Default private SourceFacts sourceFacts = SourceFacts.NONE;
public String altTextFor(int pageIndex, int ordinal) {
return altTextByFigure.get(pageIndex + ":" + ordinal);
}
}
@@ -0,0 +1,42 @@
package stirling.software.proprietary.pdf.ua;
import java.util.ArrayList;
import java.util.List;
import lombok.Getter;
/** What a tagging run produced, for the conversion report. */
@Getter
public class TaggingResult {
private final List<String> warnings = new ArrayList<>();
private final DocumentStructure structure;
private final boolean rebuilt;
private final int taggedElements;
private final int artifacts;
private final int figuresNeedingAlt;
/** True when text was hidden as artifacts; the caller must not declare conformance. */
private final boolean contentSuppressed;
public TaggingResult(DocumentStructure structure, boolean rebuilt) {
this.structure = structure;
this.rebuilt = rebuilt;
this.warnings.addAll(structure.getWarnings());
int[] elements = {0};
structure.visit(
block -> {
if (!block.isArtifact()) {
elements[0]++;
}
});
this.taggedElements = elements[0];
this.artifacts = structure.artifactCount();
this.figuresNeedingAlt = structure.figuresWithoutAlt().size();
this.contentSuppressed = structure.isTextSuppressed();
}
public boolean needsHumanReview() {
return figuresNeedingAlt > 0 || !warnings.isEmpty();
}
}
@@ -0,0 +1,42 @@
package stirling.software.proprietary.pdf.ua;
import java.util.List;
/**
* A run of text on one baseline, with the operator ordinals that produced it. {@code preMarked}
* means the source stream already wrapped this text in BDC/EMC.
*/
public record TextLineInfo(
int pageIndex,
String text,
BBox bbox,
float dominantFontSize,
boolean bold,
int startOrdinal,
int endOrdinal,
boolean preMarked,
List<WordInfo> words) {
public boolean isBlank() {
return text == null || text.isBlank();
}
public int charCount() {
return text == null ? 0 : text.strip().length();
}
public int wordCount() {
return (int) words.stream().filter(w -> !w.isBlank()).count();
}
/** True when every word occupies its own operator run, so cells can be tagged separately. */
public boolean wordsAreSeparable() {
List<WordInfo> real = words.stream().filter(w -> !w.isBlank()).toList();
for (int i = 1; i < real.size(); i++) {
if (!real.get(i - 1).isSeparableFrom(real.get(i))) {
return false;
}
}
return true;
}
}

Some files were not shown because too many files have changed in this diff Show More