mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2026-09-03 05:10:16 +03:00
Merge main into fix_ex_20260720
This commit is contained in:
@@ -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,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
@@ -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/**
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
@@ -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 }}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }}
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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: |
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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=
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
+22
@@ -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);
|
||||
}
|
||||
+31
-1
@@ -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 {
|
||||
|
||||
+155
-16
@@ -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() {
|
||||
|
||||
+11
-1
@@ -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"
|
||||
},
|
||||
|
||||
+98
-38
@@ -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);
|
||||
}
|
||||
|
||||
+4
-1
@@ -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) {
|
||||
|
||||
+14
-3
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
+176
@@ -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;
|
||||
}
|
||||
}
|
||||
+67
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
+19
-38
@@ -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.
|
||||
|
||||
+9
-2
@@ -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);
|
||||
|
||||
/**
|
||||
|
||||
+69
-22
@@ -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() {
|
||||
|
||||
+6
-3
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
+73
@@ -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;
|
||||
}
|
||||
+38
@@ -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;
|
||||
}
|
||||
+63
@@ -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;
|
||||
}
|
||||
}
|
||||
+19
@@ -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;
|
||||
}
|
||||
+18
@@ -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) {}
|
||||
+26
@@ -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) {}
|
||||
}
|
||||
+18
@@ -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;
|
||||
}
|
||||
}
|
||||
+84
@@ -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();
|
||||
}
|
||||
}
|
||||
+831
@@ -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;
|
||||
};
|
||||
}
|
||||
}
|
||||
+284
@@ -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();
|
||||
}
|
||||
}
|
||||
+47
@@ -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;
|
||||
}
|
||||
}
|
||||
+224
@@ -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)";
|
||||
}
|
||||
}
|
||||
+295
@@ -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];
|
||||
}
|
||||
}
|
||||
+630
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
+63
@@ -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
Reference in New Issue
Block a user