Compare commits

..
196 changed files with 6504 additions and 11339 deletions
+4 -22
View File
@@ -2,9 +2,12 @@ build: &build
- build.gradle
- app/(common|core|proprietary)/build.gradle
app: &app
- app/(common|core|proprietary)/src/main/java/**
openapi: &openapi
- *build
- app/(common|core|proprietary)/src/main/java/**
- *app
docker: &docker
- Dockerfile
@@ -31,30 +34,9 @@ project: &project
- settings.gradle
- frontend/**
- docker/**
- scripts/RestartHelper.java
frontend: &frontend
- frontend/**
- .github/workflows/testdriver.yml
- testing/**
- docker/**
- scripts/translations/*.py
- scripts/build-tauri-jlink.bat
- scripts/build-tauri-jlink.sh
- scripts/convert_cff_to_ttf.py
- scripts/harvest_type3_fonts.py
- scripts/ignore_translation.toml
- scripts/index_type3_catalogue.py
- scripts/summarize_type3_signatures.py
- scripts/type3_to_cff.py
- scripts/update_type3_library.py
licenses-frontend: &licenses-frontend
- ".github/workflows/frontend-backend-licenses-update.yml"
- "frontend/package.json"
- "frontend/package-lock.json"
- "frontend/scripts/generate-licenses.js"
licenses-backend: &licenses-backend
- ".github/workflows/frontend-backend-licenses-update.yml"
- *build
+1 -1
View File
@@ -1 +1 @@
allow-ghsas: GHSA-wrw7-89jp-8q8g
allow-ghsas: GHSA-wrw7-89jp-8q8g
+2 -49
View File
@@ -6,65 +6,18 @@
version: 2
updates:
- package-ecosystem: "gradle" # See documentation for possible values
directories:
- "/" # Location of package manifests
- "/app/common"
- "/app/core"
- "/app/proprietary"
directory: "/" # Location of package manifests
schedule:
interval: "weekly"
open-pull-requests-limit: 10
rebase-strategy: "auto"
- package-ecosystem: "docker"
directory: "/" # Location of Dockerfile
schedule:
interval: "weekly"
rebase-strategy: "auto"
- package-ecosystem: github-actions
directory: /
schedule:
interval: weekly
rebase-strategy: "auto"
- package-ecosystem: npm
directory: /devTools
schedule:
interval: "weekly"
rebase-strategy: "auto"
- package-ecosystem: docker
directory: /docker/backend
schedule:
interval: "weekly"
rebase-strategy: "auto"
- package-ecosystem: docker
directory: /docker/embedded
schedule:
interval: "weekly"
rebase-strategy: "auto"
- package-ecosystem: docker
directory: /docker/frontend
schedule:
interval: "weekly"
rebase-strategy: "auto"
- package-ecosystem: npm
directory: /frontend
schedule:
interval: "weekly"
rebase-strategy: "auto"
- package-ecosystem: cargo
directory: /frontend/src-tauri
schedule:
interval: "weekly"
rebase-strategy: "auto"
- package-ecosystem: pip
directory: /testing/cucumber
schedule:
interval: "weekly"
rebase-strategy: "auto"
+7 -15
View File
@@ -46,34 +46,26 @@ labels:
- label: 'API'
title: '.*openapi.*|.*swagger.*|.*api.*'
- label: 'v3'
base-branch: 'V3'
- label: 'v2'
base-branch: 'V2'
- label: 'Translation'
files:
- 'frontend/public/locales/[a-zA-Z]{2}-[a-zA-Z\-]{2,7}/translation.toml'
- 'app/core/src/main/resources/messages_[a-zA-Z_]{2}_[a-zA-Z_]{2,7}.properties'
- 'scripts/ignore_translation.toml'
- 'scripts/remove_translation_keys.sh'
- 'scripts/replace_translation_line.sh'
- 'scripts/translations/.*'
- '.github/scripts/check_language_toml.py'
- 'scripts/counter_translation_v3.py'
- 'app/core/src/main/resources/templates/fragments/languages.html'
- '.github/scripts/check_language_properties.py'
- label: 'Front End'
files:
- 'app/core/src/main/resources/templates/.*'
- 'app/proprietary/src/main/resources/templates/.*'
- 'app/core/src/main/resources/static/.*'
- 'app/proprietary/src/main/resources/static/.*'
- 'app/core/src/main/java/stirling/software/SPDF/controller/web/.*'
- 'app/core/src/main/java/stirling/software/SPDF/UI/.*'
- 'app/proprietary/src/main/java/stirling/software/proprietary/security/controller/web/.*'
- 'frontend/**'
- 'frontend/.*'
- 'frontend/**/.*'
- label: 'Tauri'
files:
- 'frontend/src-tauri/**'
- 'frontend/src-tauri/.*'
- label: 'Java'
files:
-14
View File
@@ -84,9 +84,6 @@
- name: "v2"
color: "FFFF00"
description: "Issues or pull requests related to the v2 branch"
- name: "v3"
color: "FFA500"
description: "Issues or pull requests related to the v3 branch"
- name: "wontfix"
description: "This will not be worked on"
color: "FFFFFF"
@@ -190,14 +187,3 @@
- name: "break-change"
color: "FF0000"
description: "This PR introduces a breaking API change."
- name: "Rust"
color: "DEA584"
description: "Pull requests that update Rust code"
from_name: "rust"
- name: "Tauri"
color: "24C8FF"
description: "Pull requests that update Tauri code"
from_name: "tauri"
- name: "license-review-required"
color: "EDEDED"
description: "This PR requires a license review"
+10 -10
View File
@@ -14,10 +14,12 @@ Usage:
# Sample for Windows:
# python .github/scripts/check_language_toml.py --reference-file frontend/public/locales/en-GB/translation.toml --branch "" --files frontend/public/locales/de-DE/translation.toml frontend/public/locales/fr-FR/translation.toml
import copy
import glob
import os
import argparse
import re
import json
import tomllib # Python 3.11+ (stdlib)
import tomli_w # For writing TOML files
@@ -36,7 +38,7 @@ def find_duplicate_keys(file_path, keys=None, prefix=""):
duplicates = []
# Load TOML file
with open(file_path, "rb") as file:
with open(file_path, 'rb') as file:
data = tomllib.load(file)
def process_dict(obj, current_prefix=""):
@@ -65,7 +67,7 @@ def parse_toml_file(file_path):
:param file_path: Path to the TOML file.
:return: Dictionary with flattened keys.
"""
with open(file_path, "rb") as file:
with open(file_path, 'rb') as file:
data = tomllib.load(file)
def flatten_dict(d, parent_key="", sep="."):
@@ -191,13 +193,13 @@ def check_for_differences(reference_file, file_list, branch, actor):
basename_current_file = os.path.basename(os.path.join(branch, file_normpath))
locale_dir = os.path.basename(os.path.dirname(file_normpath))
if basename_current_file == basename_reference_file and locale_dir == "en-GB":
if (
basename_current_file == basename_reference_file
and locale_dir == "en-GB"
):
continue
if (
not file_normpath.endswith(".toml")
or basename_current_file != "translation.toml"
):
if not file_normpath.endswith(".toml") or basename_current_file != "translation.toml":
continue
only_reference_file = False
@@ -286,9 +288,7 @@ def check_for_differences(reference_file, file_list, branch, actor):
if __name__ == "__main__":
parser = argparse.ArgumentParser(
description="Find missing keys in TOML translation files"
)
parser = argparse.ArgumentParser(description="Find missing keys in TOML translation files")
parser.add_argument(
"--actor",
required=False,
+6 -6
View File
@@ -4,9 +4,9 @@
#
# pip-compile --generate-hashes --output-file='.github\scripts\requirements_pre_commit.txt' --strip-extras '.github\scripts\requirements_pre_commit.in'
#
cfgv==3.5.0 \
--hash=sha256:a8dc6b26ad22ff227d2634a65cb388215ce6cc96bbcc5cfde7641ae87e8dacc0 \
--hash=sha256:d5b1034354820651caa73ede66a6294d6e95c1b00acc5e9b098e917404669132
cfgv==3.4.0 \
--hash=sha256:b7265b1f29fd3316bfcd2b330d63d024f2bfd8bcb8b0272f8e19a504856c48f9 \
--hash=sha256:e52591d4c5f5dead8e0f673fb16db7949d2cfb3f7da4582893288f0ded8fe560
# via pre-commit
distlib==0.4.0 \
--hash=sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16 \
@@ -28,9 +28,9 @@ platformdirs==4.5.0 \
--hash=sha256:70ddccdd7c99fc5942e9fc25636a8b34d04c24b335100223152c2803e4063312 \
--hash=sha256:e578a81bb873cbb89a41fcc904c7ef523cc18284b7e3b3ccf06aca1403b7ebd3
# via virtualenv
pre-commit==4.5.0 \
--hash=sha256:25e2ce09595174d9c97860a95609f9f852c0614ba602de3561e267547f2335e1 \
--hash=sha256:dc5a065e932b19fc1d4c653c6939068fe54325af8e741e74e88db4d28a4dd66b
pre-commit==4.3.0 \
--hash=sha256:2b0747ad7e6e967169136edffee14c16e148a778a54e4f967921aa1ebf2308d8 \
--hash=sha256:499fe450cc9d42e9d58e606262795ecb64dd05438943c62b66f6a8673da30b16
# via -r .github/scripts/requirements_pre_commit.in
pyyaml==6.0.3 \
--hash=sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c \
@@ -1,2 +1 @@
tomlkit
tomli-w
@@ -4,10 +4,6 @@
#
# pip-compile --generate-hashes --output-file='.github\scripts\requirements_sync_readme.txt' --strip-extras '.github\scripts\requirements_sync_readme.in'
#
tomli-w==1.2.0 \
--hash=sha256:188306098d013b691fcadc011abd66727d3c414c571bb01b1a174ba8c983cf90 \
--hash=sha256:2dd14fac5a47c27be9cd4c976af5a12d87fb1f0b4512f81d69cce3b35ae25021
# via -r .github/scripts/requirements_sync_readme.in
tomlkit==0.13.3 \
--hash=sha256:430cf247ee57df2b94ee3fbe588e71d362a941ebb545dec29b53961d61add2a1 \
--hash=sha256:c89c649d79ee40629a9fda55f8ace8c6a1b42deb912b2a8fd8d942ddadb606b0
+17 -17
View File
@@ -37,7 +37,7 @@ jobs:
- name: Resolve PR info
id: resolve
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
with:
script: |
const { owner, repo } = context.repo;
@@ -128,13 +128,13 @@ jobs:
- name: Add deployment started comment
id: deployment-started
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
with:
github-token: ${{ steps.setup-bot.outputs.token }}
script: |
const { owner, repo } = context.repo;
const prNumber = ${{ needs.check-pr.outputs.pr_number }};
// Delete previous V2 deployment comments to avoid clutter
const { data: comments } = await github.rest.issues.listComments({
owner,
@@ -157,7 +157,7 @@ jobs:
comment_id: comment.id
});
}
// Create new deployment started comment
const { data: newComment } = await github.rest.issues.createComment({
owner,
@@ -173,7 +173,7 @@ jobs:
repository: ${{ needs.check-pr.outputs.pr_repository }}
ref: ${{ needs.check-pr.outputs.pr_ref }}
token: ${{ secrets.GITHUB_TOKEN }}
fetch-depth: 0 # Fetch full history for commit hash detection
fetch-depth: 0 # Fetch full history for commit hash detection
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1
@@ -204,20 +204,20 @@ jobs:
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
@@ -280,7 +280,7 @@ jobs:
# Use same port strategy as regular PRs - just the PR number
V2_PORT=${{ needs.check-pr.outputs.pr_number }}
BACKEND_PORT=$((V2_PORT + 10000)) # Backend on higher port to avoid conflicts
# Create docker-compose for V2 with separate frontend and backend
cat > docker-compose.yml << EOF
version: '3.3'
@@ -352,14 +352,14 @@ jobs:
- name: Post V2 deployment URL to PR
if: success()
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
with:
github-token: ${{ steps.setup-bot.outputs.token }}
script: |
const { owner, repo } = context.repo;
const prNumber = ${{ needs.check-pr.outputs.pr_number }};
const v2Port = ${{ steps.deploy.outputs.v2_port }};
// Delete the "deploying..." comment since we're posting the final result
const deploymentStartedId = ${{ steps.deployment-started.outputs.result }};
if (deploymentStartedId) {
@@ -374,7 +374,7 @@ jobs:
console.log(`Could not delete deployment started comment: ${error.message}`);
}
}
const deploymentUrl = `http://${{ secrets.VPS_HOST }}:${v2Port}`;
const httpsUrl = `https://${v2Port}.ssl.stirlingpdf.cloud`;
@@ -419,25 +419,25 @@ jobs:
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
- name: Clean up V2 deployment comments
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
with:
github-token: ${{ steps.setup-bot.outputs.token }}
script: |
const { owner, repo } = context.repo;
const prNumber = ${{ github.event.pull_request.number }};
// Find and delete V2 deployment comments
const { data: comments } = await github.rest.issues.listComments({
owner,
repo,
issue_number: prNumber
});
const v2Comments = comments.filter(c =>
c.body?.includes("## 🚀 V2 Auto-Deployment Complete!") &&
c.user?.type === "Bot"
);
for (const comment of v2Comments) {
await github.rest.issues.deleteComment({
owner,
@@ -151,7 +151,7 @@ jobs:
ref: refs/pull/${{ needs.check-comment.outputs.pr_number }}/merge
token: ${{ steps.setup-bot.outputs.token }}
- name: Set up JDK 21
- name: Set up JDK
uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v5.0.0
with:
java-version: "17"
+1
View File
@@ -96,6 +96,7 @@ jobs:
const hasDeploymentComment = deploymentComments.length > 0;
core.setOutput('present', (hasLabel || hasDeploymentComment) ? 'true' : 'false');
- name: Set up SSH
if: steps.remove-label-comment.outputs.present == 'true'
run: |
+2 -2
View File
@@ -5,7 +5,7 @@ on:
types: [opened, edited]
branches: [main]
permissions: # required for secure-repo hardening
permissions: # required for secure-repo hardening
contents: read
jobs:
@@ -87,7 +87,7 @@ jobs:
- name: AI PR Title Analysis
if: steps.actor.outputs.is_repo_dev == 'true'
id: ai-title-analysis
uses: actions/ai-inference@334892bb203895caaed82ec52d23c1ed9385151e # v2.0.4
uses: actions/ai-inference@b81b2afb8390ee6839b494a404766bef6493c7d9 # v1.2.8
with:
model: openai/gpt-4o
system-prompt-file: ".github/config/system-prompt.txt"
+1 -1
View File
@@ -4,7 +4,7 @@ on:
types: [opened, synchronize]
branches:
- main
- V3
- V2
permissions:
contents: read
+27 -25
View File
@@ -2,7 +2,7 @@ name: Build and Test Workflow
on:
pull_request:
branches: ["main"]
branches: ["main", "V2", "V2-gha"]
workflow_dispatch:
# cancel in-progress jobs if a new job is triggered
@@ -27,16 +27,15 @@ jobs:
timeout-minutes: 3
outputs:
build: ${{ steps.changes.outputs.build }}
app: ${{ steps.changes.outputs.app }}
project: ${{ steps.changes.outputs.project }}
openapi: ${{ steps.changes.outputs.openapi }}
frontend: ${{ steps.changes.outputs.frontend }}
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
with:
egress-policy: audit
- name: Checkout repository
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- name: Check for file changes
uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2
@@ -67,12 +66,14 @@ jobs:
with:
java-version: ${{ matrix.jdk-version }}
distribution: "temurin"
- name: Setup Gradle
uses: gradle/actions/setup-gradle@4d9f0ba0025fe599b4ebab900eb7f3a1d93ef4c2 # v5.0.0
with:
gradle-version: 8.14
- name: Build with Gradle and spring security ${{ matrix.spring-security }}
run: ./gradlew clean build -PnoSpotless
env:
DISABLE_ADDITIONAL_FEATURES: ${{ matrix.spring-security }}
- name: Check Test Reports Exist
if: always()
run: |
@@ -90,7 +91,6 @@ jobs:
exit 1
fi
done
- name: Upload Test Reports
if: always()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
@@ -117,17 +117,18 @@ jobs:
- name: Checkout repository
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- name: Set up JDK 21
- name: Set up JDK 17
uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v5.0.0
with:
java-version: "21"
java-version: "17"
distribution: "temurin"
- name: Setup Gradle
uses: gradle/actions/setup-gradle@4d9f0ba0025fe599b4ebab900eb7f3a1d93ef4c2 # v5.0.0
- name: Generate OpenAPI documentation
run: ./gradlew :stirling-pdf:generateOpenApiDocs
env:
DISABLE_ADDITIONAL_FEATURES: true
- name: Upload OpenAPI Documentation
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
@@ -135,8 +136,6 @@ jobs:
path: ./SwaggerDoc.json
frontend-validation:
if: needs.files-changed.outputs.frontend == 'true'
needs: files-changed
runs-on: ubuntu-latest
steps:
- name: Harden Runner
@@ -148,8 +147,8 @@ jobs:
- name: Set up Node.js
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
with:
node-version: "22"
cache: "npm"
node-version: '22'
cache: 'npm'
cache-dependency-path: frontend/package-lock.json
- name: Install frontend dependencies
run: cd frontend && npm ci
@@ -177,14 +176,12 @@ jobs:
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
with:
egress-policy: audit
- name: Checkout repository
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- name: Set up JDK 21
- name: Set up JDK 17
uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v5.0.0
with:
java-version: "21"
java-version: "17"
distribution: "temurin"
- name: check the licenses for compatibility
@@ -226,10 +223,10 @@ jobs:
- name: Checkout Repository
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- name: Set up JDK 21
- name: Set up Java 17
uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v5.0.0
with:
java-version: "21"
java-version: "17"
distribution: "temurin"
- name: Set up Docker Buildx
@@ -241,10 +238,10 @@ jobs:
sudo chmod +x /usr/local/bin/docker-compose
- name: Set up Python
uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0
uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0
with:
python-version: "3.12"
cache: "pip" # caching pip dependencies
cache: 'pip' # caching pip dependencies
cache-dependency-path: ./testing/cucumber/requirements.txt
- name: Pip requirements
@@ -288,12 +285,17 @@ jobs:
docker system prune -af || true
echo "Disk space after cleanup:" && df -h
- name: Set up JDK 21
- name: Set up JDK 17
uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v5.0.0
with:
java-version: "21"
java-version: "17"
distribution: "temurin"
- name: Set up Gradle
uses: gradle/actions/setup-gradle@4d9f0ba0025fe599b4ebab900eb7f3a1d93ef4c2 # v5.0.0
with:
gradle-version: 8.14
- name: Build application
run: ./gradlew clean build
env:
+3 -2
View File
@@ -194,12 +194,13 @@ jobs:
core.exportVariable("REFERENCE_FILE", referenceFilePath);
- name: Set up Python
uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0
uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0
with:
python-version: "3.12"
- name: Install Python dependencies
run: pip install --require-hashes -r ./.github/scripts/requirements_sync_readme.txt
run: |
pip install tomli-w
- name: Run Python script to check files
id: run-check
+79
View File
@@ -0,0 +1,79 @@
# For most projects, this workflow file will not need changing; you simply need
# to commit it to your repository.
#
# You may wish to alter this file to override the set of languages analyzed,
# or to provide custom queries or build logic.
#
# ******** NOTE ********
# We have attempted to detect the languages in your repository. Please check
# the `language` matrix defined below to confirm you have the correct set of
# supported CodeQL languages.
#
name: "CodeQL"
#disable for now
#on:
# push:
# branches: ["main"]
# pull_request:
# The branches below must be a subset of the branches above
# branches: ["main"]
# schedule:
# - cron: "0 0 * * 1"
permissions:
contents: read
jobs:
analyze:
name: Analyze
runs-on: ubuntu-latest
permissions:
actions: read
contents: read
security-events: write
strategy:
fail-fast: false
matrix:
language: ["java"]
# CodeQL supports [ $supported-codeql-languages ]
# Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support
steps:
- name: Harden Runner
uses: step-security/harden-runner@c95a14d0e5bab51a9f56296a4eb0e416910cd350 # v2.10.3
with:
egress-policy: audit
- name: Checkout repository
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@48ab28a6f5dbc2a99bf1e0131198dd8f1df78169 # v3.28.0
with:
languages: ${{ matrix.language }}
# If you wish to specify custom queries, you can do so here or in a config file.
# By default, queries listed here will override any specified in a config file.
# Prefix the list here with "+" to use these queries and those in the config file.
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
# If this step fails, then you should remove it and run the build manually (see below)
- name: Autobuild
uses: github/codeql-action/autobuild@48ab28a6f5dbc2a99bf1e0131198dd8f1df78169 # v3.28.0
# ℹ️ Command-line programs to run using the OS shell.
# 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
# If the Autobuild fails above, remove it and uncomment the following three lines.
# modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance.
# - run: |
# echo "Run, Build Application using script"
# ./location_of_script_within_repo/buildscript.sh
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@48ab28a6f5dbc2a99bf1e0131198dd8f1df78169 # v3.28.0
with:
category: "/language:${{matrix.language}}"
+1 -1
View File
@@ -26,4 +26,4 @@ jobs:
- name: "Dependency Review"
uses: actions/dependency-review-action@3c4e3dcb1aa7874d2c16be7d79418e9b7efd6261 # v4.8.2
with:
config-file: "./.github/config/dependency-review-config.yml"
config-file: './.github/config/dependency-review-config.yml'
+19 -15
View File
@@ -23,10 +23,10 @@ jobs:
egress-policy: audit
- name: Checkout code
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1
uses: docker/setup-buildx-action@v3
- name: Get commit hashes for frontend and backend
id: commit-hashes
@@ -36,26 +36,26 @@ jobs:
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
@@ -84,15 +84,16 @@ jobs:
echo "Backend image needs to be built"
fi
- name: Login to Docker Hub
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_HUB_USERNAME }}
password: ${{ secrets.DOCKER_HUB_API }}
- name: Build and push frontend image
if: steps.check-frontend.outputs.exists == 'false'
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
uses: docker/build-push-action@v6
with:
context: .
file: ./docker/frontend/Dockerfile
@@ -102,10 +103,10 @@ jobs:
${{ secrets.DOCKER_HUB_USERNAME }}/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@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
uses: docker/build-push-action@v6
with:
context: .
file: ./docker/backend/Dockerfile
@@ -116,16 +117,18 @@ jobs:
build-args: VERSION_TAG=v2-alpha
platforms: linux/amd64
- name: Set up SSH
run: |
mkdir -p ~/.ssh/
echo "${{ secrets.VPS_SSH_KEY }}" > ../private.key
chmod 600 ../private.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:
@@ -151,7 +154,7 @@ jobs:
SWAGGER_SERVER_URL: "https://demo.stirlingpdf.cloud"
baseUrl: "https://demo.stirlingpdf.cloud"
restart: on-failure:5
frontend:
container_name: stirling-v2-frontend
image: ${{ secrets.DOCKER_HUB_USERNAME }}/test:v2-frontend-${{ steps.commit-hashes.outputs.frontend_short }}
@@ -163,10 +166,10 @@ jobs:
- backend
restart: on-failure:5
EOF
# Copy to remote with unique name
scp -i ../private.key -o StrictHostKeyChecking=no $UNIQUE_NAME ${{ secrets.VPS_USERNAME }}@${{ secrets.VPS_HOST }}:/tmp/$UNIQUE_NAME
# SSH and rename/move atomically to avoid interference
ssh -i ../private.key -o StrictHostKeyChecking=no ${{ secrets.VPS_USERNAME }}@${{ secrets.VPS_HOST }} << ENDSSH
mkdir -p /stirling/V2/{data,config,logs}
@@ -183,3 +186,4 @@ jobs:
if: always()
run: |
rm -f ../private.key
@@ -1,510 +0,0 @@
name: License Report Workflow
on:
push:
branches:
- main
pull_request:
branches:
- main
concurrency:
group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.ref_name || github.ref }}
cancel-in-progress: true
permissions:
contents: read
jobs:
files-changed:
name: detect what files changed
runs-on: ubuntu-latest
timeout-minutes: 3
outputs:
licenses-frontend: ${{ steps.changes.outputs.licenses-frontend }}
licenses-backend: ${{ steps.changes.outputs.licenses-backend }}
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
with:
egress-policy: audit
- name: Checkout repository
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
- name: Check for file changes
uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2
id: changes
with:
filters: .github/config/.files.yaml
generate-frontend-license-report:
if: needs.files-changed.outputs.licenses-frontend == 'true'
name: Generate Frontend License Report
needs: files-changed
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
repository-projects: write # Required for enabling automerge
steps:
- name: Harden Runner
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
with:
egress-policy: audit
- name: Checkout PR head (default)
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
with:
fetch-depth: 0
persist-credentials: false
- name: Setup GitHub App Bot
if: (github.event_name == 'push' || (github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == false)) && github.actor != 'dependabot[bot]'
id: setup-bot
uses: ./.github/actions/setup-bot
with:
app-id: ${{ secrets.GH_APP_ID }}
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
- name: Checkout BASE branch (safe script)
if: github.event_name == 'pull_request'
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
with:
ref: ${{ github.event.pull_request.base.sha }}
path: base
fetch-depth: 1
persist-credentials: false
- name: Set up Node.js
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
with:
node-version: "22"
cache: "npm"
cache-dependency-path: frontend/package-lock.json
- name: Install frontend dependencies
working-directory: frontend
env:
NPM_CONFIG_IGNORE_SCRIPTS: "true"
run: npm ci --ignore-scripts --audit=false --fund=false
- name: Generate frontend license report (internal PR)
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == false
working-directory: frontend
env:
PR_IS_FORK: "false"
run: npm run generate-licenses
- name: Generate frontend license report (fork PRs, pinned)
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == true
env:
NPM_CONFIG_IGNORE_SCRIPTS: "true"
working-directory: frontend
run: |
mkdir -p src/assets
npx --yes license-report --only=prod --output=json > src/assets/3rdPartyLicenses.json
- name: Postprocess with project script (BASE version)
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == true
env:
PR_IS_FORK: "true"
run: |
node base/frontend/scripts/generate-licenses.js \
--input frontend/src/assets/3rdPartyLicenses.json
- name: Copy postprocessed artifacts back (fork PRs)
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == true
run: |
mkdir -p frontend/src/assets
if [ -f "base/frontend/src/assets/3rdPartyLicenses.json" ]; then
cp base/frontend/src/assets/3rdPartyLicenses.json frontend/src/assets/3rdPartyLicenses.json
fi
if [ -f "base/frontend/src/assets/license-warnings.json" ]; then
cp base/frontend/src/assets/license-warnings.json frontend/src/assets/license-warnings.json
fi
- name: Check for license warnings
run: |
if [ -f "frontend/src/assets/license-warnings.json" ]; then
echo "LICENSE_WARNINGS_EXIST=true" >> $GITHUB_ENV
else
echo "LICENSE_WARNINGS_EXIST=false" >> $GITHUB_ENV
fi
# PR Event: Check licenses and comment on PR
- name: Delete previous license check comments
if: (github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == false) && github.actor != 'dependabot[bot]'
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
with:
github-token: ${{ steps.setup-bot.outputs.token }}
script: |
const { owner, repo } = context.repo;
const prNumber = context.issue.number;
// Get all comments on the PR
const { data: comments } = await github.rest.issues.listComments({
owner,
repo,
issue_number: prNumber,
per_page: 100
});
// Filter for license check comments
const licenseComments = comments.filter(comment =>
comment.body.includes('## ✅ Frontend License Check Passed') ||
comment.body.includes('## ❌ Frontend License Check Failed')
);
// Delete old license check comments
for (const comment of licenseComments) {
console.log(`Deleting old license check comment: ${comment.id}`);
await github.rest.issues.deleteComment({
owner,
repo,
comment_id: comment.id
});
}
- name: Summarize results (fork PRs)
if: (github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == true) || github.actor == 'dependabot[bot]'
run: |
{
echo "## Frontend License Check"
echo ""
if [ "${LICENSE_WARNINGS_EXIST}" = "true" ]; then
echo "❌ **Failed** – incompatible or unknown licenses found."
if [ -f "frontend/src/assets/license-warnings.json" ]; then
echo ""
echo "### Warnings"
jq -r '.warnings[] | "- \(.message)"' frontend/src/assets/license-warnings.json || true
fi
else
echo "✅ **Passed** – no license warnings detected."
fi
echo ""
echo "_Note: This is a fork PR. PR comments are disabled; use this summary._"
} >> "$GITHUB_STEP_SUMMARY"
- name: Comment on PR - License Check Results
if: (github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == false) && github.actor != 'dependabot[bot]'
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
with:
github-token: ${{ steps.setup-bot.outputs.token }}
script: |
const { owner, repo } = context.repo;
const prNumber = context.issue.number;
const hasWarnings = process.env.LICENSE_WARNINGS_EXIST === 'true';
let commentBody;
if (hasWarnings) {
// Read warnings file to get specific issues
const fs = require('fs');
let warningDetails = '';
try {
const warnings = JSON.parse(fs.readFileSync('frontend/src/assets/license-warnings.json', 'utf8'));
warningDetails = warnings.warnings.map(w => `- ${w.message}`).join('\n');
} catch (e) {
warningDetails = 'Unable to read warning details';
}
commentBody = `## ❌ Frontend License Check Failed
The frontend license check has detected compatibility warnings that require review:
${warningDetails}
**Action Required:** Please review these licenses to ensure they are acceptable for your use case before merging.
_This check will fail the PR until license issues are resolved._`;
} else {
commentBody = `## ✅ Frontend License Check Passed
All frontend licenses have been validated and no compatibility warnings were detected.
The frontend license report has been updated successfully.`;
}
await github.rest.issues.createComment({
owner,
repo,
issue_number: prNumber,
body: commentBody
});
- name: Fail workflow if license warnings exist (PR only)
if: github.event_name == 'pull_request' && env.LICENSE_WARNINGS_EXIST == 'true'
run: |
echo "❌ License warnings detected. Failing the workflow."
exit 1
# Push Event: Commit license files and create PR
- name: Commit changes (Push only)
if: github.event_name == 'push'
run: |
git add frontend/src/assets/3rdPartyLicenses.json
# Note: Do NOT commit license-warnings.json - it's only for PR review
git diff --staged --quiet || echo "CHANGES_DETECTED=true" >> $GITHUB_ENV
- name: Prepare PR body (Push only)
if: github.event_name == 'push'
run: |
PR_BODY="Auto-generated by ${{ steps.setup-bot.outputs.app-slug }}[bot]
This PR updates the frontend license report based on changes to package.json dependencies."
if [ "${{ env.LICENSE_WARNINGS_EXIST }}" = "true" ]; then
PR_BODY="$PR_BODY
## ⚠️ License Compatibility Warnings
The following licenses may require review for corporate compatibility:
$(cat frontend/src/assets/license-warnings.json | jq -r '.warnings[].message')
Please review these licenses to ensure they are acceptable for your use case."
fi
echo "PR_BODY<<EOF" >> $GITHUB_ENV
echo "$PR_BODY" >> $GITHUB_ENV
echo "EOF" >> $GITHUB_ENV
- name: Create Pull Request (Push only)
id: cpr
if: github.event_name == 'push' && env.CHANGES_DETECTED == 'true'
uses: peter-evans/create-pull-request@98357b18bf14b5342f975ff684046ec3b2a07725 # v8.0.0
with:
token: ${{ steps.setup-bot.outputs.token }}
commit-message: "Update Frontend 3rd Party Licenses"
committer: ${{ steps.setup-bot.outputs.committer }}
author: ${{ steps.setup-bot.outputs.committer }}
signoff: true
branch: update-frontend-3rd-party-licenses
base: main
title: "Update Frontend 3rd Party Licenses"
body: ${{ env.PR_BODY }}
labels: Licenses,github-actions,frontend
draft: false
delete-branch: true
sign-commits: true
- name: Enable Pull Request Automerge (Push only)
if: github.event_name == 'push' && steps.cpr.outputs.pull-request-operation == 'created' && env.LICENSE_WARNINGS_EXIST == 'false'
run: gh pr merge --squash --auto "${{ steps.cpr.outputs.pull-request-number }}"
env:
GH_TOKEN: ${{ steps.setup-bot.outputs.token }}
- name: Add review required label (Push only)
if: github.event_name == 'push' && steps.cpr.outputs.pull-request-operation == 'created' && env.LICENSE_WARNINGS_EXIST == 'true'
run: gh pr edit "${{ steps.cpr.outputs.pull-request-number }}" --add-label "license-review-required"
env:
GH_TOKEN: ${{ steps.setup-bot.outputs.token }}
generate-backend-license-report:
if: needs.files-changed.outputs.licenses-backend == 'true'
needs: files-changed
name: Generate Backend License Report
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
repository-projects: write # Required for enabling automerge
steps:
- name: Harden Runner
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
with:
egress-policy: audit
- name: Checkout repository
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
with:
fetch-depth: 0
persist-credentials: false
- name: Setup GitHub App Bot
if: (github.event_name == 'push' || (github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == false)) && github.actor != 'dependabot[bot]'
id: setup-bot
uses: ./.github/actions/setup-bot
with:
app-id: ${{ secrets.GH_APP_ID }}
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
- name: Set up JDK 21
uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v5.0.0
with:
java-version: "21"
distribution: "temurin"
- name: Check licenses and generate report
id: license-check
run: |
./gradlew clean checkLicense generateLicenseReport || echo "LICENSE_CHECK_FAILED=true" >> $GITHUB_ENV
env:
DISABLE_ADDITIONAL_FEATURES: false
STIRLING_PDF_DESKTOP_UI: true
- name: Check for license compatibility issues
run: |
if [ -f build/reports/dependency-license/dependencies-without-allowed-license.json ] && \
jq '.dependenciesWithoutAllowedLicenses | length > 0' build/reports/dependency-license/dependencies-without-allowed-license.json | grep -q true; then
echo "LICENSE_WARNINGS_EXIST=true" >> $GITHUB_ENV
else
echo "LICENSE_WARNINGS_EXIST=false" >> $GITHUB_ENV
fi
if: always()
- name: Upload artifact on license issues
if: env.LICENSE_WARNINGS_EXIST == 'true'
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
with:
name: backend-dependencies-without-allowed-license.json
path: build/reports/dependency-license/dependencies-without-allowed-license.json
- name: Move license file
if: env.LICENSE_CHECK_FAILED != 'true' && env.LICENSE_WARNINGS_EXIST == 'false'
run: |
mkdir -p app/core/src/main/resources/static
cp build/reports/dependency-license/index.json app/core/src/main/resources/static/3rdPartyLicenses.json
- name: Delete previous backend license check comments
if: (github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == false) && github.actor != 'dependabot[bot]'
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
with:
github-token: ${{ steps.setup-bot.outputs.token }}
script: |
const { owner, repo } = context.repo;
const prNumber = context.issue.number;
const { data: comments } = await github.rest.issues.listComments({
owner,
repo,
issue_number: prNumber,
per_page: 100
});
const backendLicenseComments = comments.filter(comment =>
comment.body.includes('## ✅ Backend License Check Passed') ||
comment.body.includes('## ❌ Backend License Check Failed')
);
for (const comment of backendLicenseComments) {
console.log(`Deleting old backend license comment: ${comment.id}`);
await github.rest.issues.deleteComment({
owner,
repo,
comment_id: comment.id
});
}
- name: Comment on PR - Backend License Check Results
if: (github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == false) && github.actor != 'dependabot[bot]'
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
with:
github-token: ${{ steps.setup-bot.outputs.token }}
script: |
const hasWarnings = process.env.LICENSE_WARNINGS_EXIST === 'true';
const fs = require('fs');
let warningDetails = '';
if (hasWarnings) {
try {
const warningsFile = 'build/reports/dependency-license/dependencies-without-allowed-license.json';
if (fs.existsSync(warningsFile)) {
const data = JSON.parse(fs.readFileSync(warningsFile, 'utf8'));
if (data.length > 0) {
warningDetails = data.map(dep => `- **${dep.moduleName}@${dep.moduleVersion}** – ${dep.moduleLicenses.map(l => l.licenseName).join(', ')}`).join('\n');
}
}
} catch (e) {
warningDetails = 'Unable to parse warning details.';
}
}
let commentBody;
if (hasWarnings) {
commentBody = `## ❌ Backend License Check Failed
The backend license check has detected dependencies with incompatible or unallowed licenses:
${warningDetails || 'See uploaded artifact for details.'}
**Action Required:** Please review these licenses and resolve before merging.
_This check will fail the PR until license issues are resolved._`;
} else {
commentBody = `## ✅ Backend License Check Passed
All backend dependencies have valid and allowed licenses.
The backend license report has been updated successfully.`;
}
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body: commentBody
});
- name: Fail workflow if license warnings exist (PR only)
if: github.event_name == 'pull_request' && env.LICENSE_WARNINGS_EXIST == 'true'
run: |
echo "❌ Backend license warnings detected. Failing the workflow."
exit 1
- name: Commit changes (push only)
if: github.event_name == 'push' && env.LICENSE_WARNINGS_EXIST == 'false'
run: |
git config user.name "${{ steps.setup-bot.outputs.committer }}"
git config user.email "${{ steps.setup-bot.outputs.committer-email || 'bot@github.com' }}"
git add app/core/src/main/resources/static/3rdPartyLicenses.json
git diff --staged --quiet || echo "CHANGES_DETECTED=true" >> $GITHUB_ENV
- name: Prepare PR body (push only)
if: github.event_name == 'push' && env.CHANGES_DETECTED == 'true'
run: |
PR_BODY="Auto-generated by ${{ steps.setup-bot.outputs.app-slug }}[bot]
This PR updates the backend license report based on dependency changes."
if [ "${{ env.LICENSE_WARNINGS_EXIST }}" = "true" ]; then
PR_BODY="$PR_BODY
## ⚠️ License Compatibility Warnings
Incompatible licenses detected – manual review required before merge."
fi
echo "PR_BODY<<EOF" >> $GITHUB_ENV
echo "$PR_BODY" >> $GITHUB_ENV
echo "EOF" >> $GITHUB_ENV
- name: Create Pull Request (push only)
if: github.event_name == 'push' && env.CHANGES_DETECTED == 'true'
id: cpr
uses: peter-evans/create-pull-request@98357b18bf14b5342f975ff684046ec3b2a07725 # v8.0.0
with:
token: ${{ steps.setup-bot.outputs.token }}
commit-message: "Update Backend 3rd Party Licenses"
committer: ${{ steps.setup-bot.outputs.committer }}
author: ${{ steps.setup-bot.outputs.committer }}
signoff: true
branch: update-backend-3rd-party-licenses
base: main
title: "Update Backend 3rd Party Licenses"
body: ${{ env.PR_BODY }}
labels: Licenses,github-actions,backend
delete-branch: true
sign-commits: true
- name: Enable Pull Request Automerge (push only, no warnings)
if: github.event_name == 'push' && steps.cpr.outputs.pull-request-operation == 'created' && env.LICENSE_WARNINGS_EXIST == 'false'
run: gh pr merge --squash --auto "${{ steps.cpr.outputs.pull-request-number }}"
env:
GH_TOKEN: ${{ steps.setup-bot.outputs.token }}
- name: Add review required label (push only, with warnings)
if: github.event_name == 'push' && steps.cpr.outputs.pull-request-operation == 'created' && env.LICENSE_WARNINGS_EXIST == 'true'
run: gh pr edit "${{ steps.cpr.outputs.pull-request-number }}" --add-label "license-review-required"
env:
GH_TOKEN: ${{ steps.setup-bot.outputs.token }}
@@ -0,0 +1,282 @@
name: Frontend License Report Workflow
on:
push:
branches:
- V2
paths:
- "frontend/package.json"
- "frontend/package-lock.json"
- "frontend/scripts/generate-licenses.js"
pull_request:
branches:
- V2
paths:
- ".github/workflows/frontend-licenses-update.yml"
- "frontend/package.json"
- "frontend/package-lock.json"
- "frontend/scripts/generate-licenses.js"
permissions:
contents: read
jobs:
generate-frontend-license-report:
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
repository-projects: write # Required for enabling automerge
steps:
- name: Harden Runner
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
with:
egress-policy: audit
- name: Checkout PR head (default)
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
with:
fetch-depth: 0
persist-credentials: false
- name: Setup GitHub App Bot
if: github.event_name == 'push' || (github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == false)
id: setup-bot
uses: ./.github/actions/setup-bot
with:
app-id: ${{ secrets.GH_APP_ID }}
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
- name: Checkout BASE branch (safe script)
if: github.event_name == 'pull_request'
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
with:
ref: ${{ github.event.pull_request.base.sha }}
path: base
fetch-depth: 1
persist-credentials: false
- name: Set up Node.js
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
with:
node-version: '22'
cache: 'npm'
cache-dependency-path: frontend/package-lock.json
- name: Install frontend dependencies
working-directory: frontend
env:
NPM_CONFIG_IGNORE_SCRIPTS: "true"
run: npm ci --ignore-scripts --audit=false --fund=false
- name: Generate frontend license report (internal PR)
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == false
working-directory: frontend
env:
PR_IS_FORK: "false"
run: npm run generate-licenses
- name: Generate frontend license report (fork PRs, pinned)
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == true
env:
NPM_CONFIG_IGNORE_SCRIPTS: "true"
working-directory: frontend
run: |
mkdir -p src/assets
npx --yes license-report --only=prod --output=json > src/assets/3rdPartyLicenses.json
- name: Postprocess with project script (BASE version)
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == true
env:
PR_IS_FORK: "true"
run: |
node base/frontend/scripts/generate-licenses.js \
--input frontend/src/assets/3rdPartyLicenses.json
- name: Copy postprocessed artifacts back (fork PRs)
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == true
run: |
mkdir -p frontend/src/assets
if [ -f "base/frontend/src/assets/3rdPartyLicenses.json" ]; then
cp base/frontend/src/assets/3rdPartyLicenses.json frontend/src/assets/3rdPartyLicenses.json
fi
if [ -f "base/frontend/src/assets/license-warnings.json" ]; then
cp base/frontend/src/assets/license-warnings.json frontend/src/assets/license-warnings.json
fi
- name: Check for license warnings
run: |
if [ -f "frontend/src/assets/license-warnings.json" ]; then
echo "LICENSE_WARNINGS_EXIST=true" >> $GITHUB_ENV
else
echo "LICENSE_WARNINGS_EXIST=false" >> $GITHUB_ENV
fi
# PR Event: Check licenses and comment on PR
- name: Delete previous license check comments
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == false
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
with:
github-token: ${{ steps.setup-bot.outputs.token }}
script: |
const { owner, repo } = context.repo;
const prNumber = context.issue.number;
// Get all comments on the PR
const { data: comments } = await github.rest.issues.listComments({
owner,
repo,
issue_number: prNumber,
per_page: 100
});
// Filter for license check comments
const licenseComments = comments.filter(comment =>
comment.body.includes('## ✅ Frontend License Check Passed') ||
comment.body.includes('## ❌ Frontend License Check Failed')
);
// Delete old license check comments
for (const comment of licenseComments) {
console.log(`Deleting old license check comment: ${comment.id}`);
await github.rest.issues.deleteComment({
owner,
repo,
comment_id: comment.id
});
}
- name: Summarize results (fork PRs)
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == true
run: |
{
echo "## Frontend License Check"
echo ""
if [ "${LICENSE_WARNINGS_EXIST}" = "true" ]; then
echo "❌ **Failed** – incompatible or unknown licenses found."
if [ -f "frontend/src/assets/license-warnings.json" ]; then
echo ""
echo "### Warnings"
jq -r '.warnings[] | "- \(.message)"' frontend/src/assets/license-warnings.json || true
fi
else
echo "✅ **Passed** – no license warnings detected."
fi
echo ""
echo "_Note: This is a fork PR. PR comments are disabled; use this summary._"
} >> "$GITHUB_STEP_SUMMARY"
- name: Comment on PR - License Check Results
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == false
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
with:
github-token: ${{ steps.setup-bot.outputs.token }}
script: |
const { owner, repo } = context.repo;
const prNumber = context.issue.number;
const hasWarnings = process.env.LICENSE_WARNINGS_EXIST === 'true';
let commentBody;
if (hasWarnings) {
// Read warnings file to get specific issues
const fs = require('fs');
let warningDetails = '';
try {
const warnings = JSON.parse(fs.readFileSync('frontend/src/assets/license-warnings.json', 'utf8'));
warningDetails = warnings.warnings.map(w => `- ${w.message}`).join('\n');
} catch (e) {
warningDetails = 'Unable to read warning details';
}
commentBody = `## ❌ Frontend License Check Failed
The frontend license check has detected compatibility warnings that require review:
${warningDetails}
**Action Required:** Please review these licenses to ensure they are acceptable for your use case before merging.
_This check will fail the PR until license issues are resolved._`;
} else {
commentBody = `## ✅ Frontend License Check Passed
All frontend licenses have been validated and no compatibility warnings were detected.
The frontend license report has been updated successfully.`;
}
await github.rest.issues.createComment({
owner,
repo,
issue_number: prNumber,
body: commentBody
});
- name: Fail workflow if license warnings exist (PR only)
if: github.event_name == 'pull_request' && env.LICENSE_WARNINGS_EXIST == 'true'
run: |
echo "❌ License warnings detected. Failing the workflow."
exit 1
# Push Event: Commit license files and create PR
- name: Commit changes (Push only)
if: github.event_name == 'push'
run: |
git add frontend/src/assets/3rdPartyLicenses.json
# Note: Do NOT commit license-warnings.json - it's only for PR review
git diff --staged --quiet || echo "CHANGES_DETECTED=true" >> $GITHUB_ENV
- name: Prepare PR body (Push only)
if: github.event_name == 'push'
run: |
PR_BODY="Auto-generated by ${{ steps.setup-bot.outputs.app-slug }}[bot]
This PR updates the frontend license report based on changes to package.json dependencies."
if [ "${{ env.LICENSE_WARNINGS_EXIST }}" = "true" ]; then
PR_BODY="$PR_BODY
## ⚠️ License Compatibility Warnings
The following licenses may require review for corporate compatibility:
$(cat frontend/src/assets/license-warnings.json | jq -r '.warnings[].message')
Please review these licenses to ensure they are acceptable for your use case."
fi
echo "PR_BODY<<EOF" >> $GITHUB_ENV
echo "$PR_BODY" >> $GITHUB_ENV
echo "EOF" >> $GITHUB_ENV
- name: Create Pull Request (Push only)
id: cpr
if: github.event_name == 'push' && env.CHANGES_DETECTED == 'true'
uses: peter-evans/create-pull-request@271a8d0340265f705b14b6d32b9829c1cb33d45e # v7.0.8
with:
token: ${{ steps.setup-bot.outputs.token }}
commit-message: "Update Frontend 3rd Party Licenses"
committer: ${{ steps.setup-bot.outputs.committer }}
author: ${{ steps.setup-bot.outputs.committer }}
signoff: true
branch: update-frontend-3rd-party-licenses
base: V2
title: "Update Frontend 3rd Party Licenses"
body: ${{ env.PR_BODY }}
labels: Licenses,github-actions,frontend
draft: false
delete-branch: true
sign-commits: true
- name: Enable Pull Request Automerge (Push only)
if: github.event_name == 'push' && steps.cpr.outputs.pull-request-operation == 'created' && env.LICENSE_WARNINGS_EXIST == 'false'
run: gh pr merge --squash --auto "${{ steps.cpr.outputs.pull-request-number }}"
env:
GH_TOKEN: ${{ steps.setup-bot.outputs.token }}
- name: Add review required label (Push only)
if: github.event_name == 'push' && steps.cpr.outputs.pull-request-operation == 'created' && env.LICENSE_WARNINGS_EXIST == 'true'
run: gh pr edit "${{ steps.cpr.outputs.pull-request-number }}" --add-label "license-review-required"
env:
GH_TOKEN: ${{ steps.setup-bot.outputs.token }}
+105
View File
@@ -0,0 +1,105 @@
name: License Report Workflow
on:
push:
branches:
- main
paths:
- "build.gradle"
# 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:
generate-license-report:
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
repository-projects: write # Required for enabling automerge
steps:
- name: Harden Runner
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
with:
egress-policy: audit
- name: Check out code
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
with:
fetch-depth: 0
- name: Setup GitHub App Bot
id: setup-bot
uses: ./.github/actions/setup-bot
with:
app-id: ${{ secrets.GH_APP_ID }}
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
- name: Set up JDK 17
uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v5.0.0
with:
java-version: "17"
distribution: "temurin"
- name: Setup Gradle
uses: gradle/actions/setup-gradle@4d9f0ba0025fe599b4ebab900eb7f3a1d93ef4c2 # v5.0.0
- name: Check licenses for compatibility
run: ./gradlew clean checkLicense
env:
DISABLE_ADDITIONAL_FEATURES: false
STIRLING_PDF_DESKTOP_UI: true
- name: Upload artifact on failure
if: failure()
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
with:
name: dependencies-without-allowed-license.json
path: build/reports/dependency-license/dependencies-without-allowed-license.json
retention-days: 3
- name: Move and rename license file
run: |
mv build/reports/dependency-license/index.json app/core/src/main/resources/static/3rdPartyLicenses.json
- name: Commit changes
run: |
git add app/core/src/main/resources/static/3rdPartyLicenses.json
git diff --staged --quiet || echo "CHANGES_DETECTED=true" >> $GITHUB_ENV
- name: Create Pull Request
id: cpr
if: env.CHANGES_DETECTED == 'true'
uses: peter-evans/create-pull-request@84ae59a2cdc2258d6fa0732dd66352dddae2a412 # v7.0.9
with:
token: ${{ steps.setup-bot.outputs.token }}
commit-message: "Update 3rd Party Licenses"
committer: ${{ steps.setup-bot.outputs.committer }}
author: ${{ steps.setup-bot.outputs.committer }}
signoff: true
branch: update-3rd-party-licenses
title: "Update 3rd Party Licenses"
body: |
Auto-generated by ${{ steps.setup-bot.outputs.app-slug }}[bot]
labels: Licenses,github-actions
draft: false
delete-branch: true
sign-commits: true
- name: Enable Pull Request Automerge
if: steps.cpr.outputs.pull-request-operation == 'created'
run: gh pr merge --squash --auto "${{ steps.cpr.outputs.pull-request-number }}"
env:
GH_TOKEN: ${{ steps.setup-bot.outputs.token }}
+6 -6
View File
@@ -22,7 +22,7 @@ on:
- macos
- linux
push:
branches: [main, V2-master]
branches: [main, V2, V2-demo, V2-master]
release:
types: [created]
@@ -126,8 +126,8 @@ jobs:
if: matrix.variant.build_frontend == true
uses: actions/setup-node@v4
with:
node-version: 22
cache: "npm"
node-version: 20
cache: 'npm'
cache-dependency-path: frontend/package-lock.json
- name: Build JAR
@@ -178,8 +178,8 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 22
cache: "npm"
node-version: 20
cache: 'npm'
cache-dependency-path: frontend/package-lock.json
- name: Setup Rust
@@ -562,7 +562,7 @@ jobs:
run: ls -R ./artifacts
- name: Upload binaries to Release
uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b # v2.5.0
uses: softprops/action-gh-release@5be0e66d93ac7ed76da52eca8bb058f665c3a5fe # v2.4.2
with:
tag_name: v${{ needs.determine-matrix.outputs.version }}
generate_release_notes: true
+5 -5
View File
@@ -38,10 +38,10 @@ jobs:
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
- name: Set up Python
uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0
uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0
with:
python-version: 3.12
cache: "pip" # caching pip dependencies
cache: 'pip' # caching pip dependencies
cache-dependency-path: ./.github/scripts/requirements_pre_commit.txt
- name: Run Pre-Commit Hooks
@@ -51,10 +51,10 @@ jobs:
- run: pre-commit run --all-files -c .pre-commit-config.yaml
continue-on-error: true
- name: Set up JDK 21
- name: Set up JDK
uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v5.0.0
with:
java-version: 21
java-version: 17
distribution: "temurin"
- name: Build with Gradle
@@ -67,7 +67,7 @@ jobs:
- name: Create Pull Request
if: env.CHANGES_DETECTED == 'true'
uses: peter-evans/create-pull-request@98357b18bf14b5342f975ff684046ec3b2a07725 # v8.0.0
uses: peter-evans/create-pull-request@84ae59a2cdc2258d6fa0732dd66352dddae2a412 # v7.0.9
with:
token: ${{ steps.setup-bot.outputs.token }}
commit-message: ":file_folder: pre-commit"
+7 -6
View File
@@ -5,6 +5,7 @@ on:
push:
branches:
- V2-master
- V1_V2_merge
# 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
@@ -82,7 +83,7 @@ jobs:
- name: Generate tags for latest (V2-master branch - production)
id: meta
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
uses: docker/metadata-action@c1e51972afc2121e065aed6d45c65596fe445f3f # v5.8.0
if: github.ref == 'refs/heads/V2-master'
with:
images: |
@@ -96,7 +97,7 @@ jobs:
- name: Generate tags for latest (V1_V2_merge branch - test)
id: meta-test
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
uses: docker/metadata-action@c1e51972afc2121e065aed6d45c65596fe445f3f # v5.8.0
if: github.ref == 'refs/heads/V1_V2_merge'
with:
images: |
@@ -138,7 +139,7 @@ jobs:
- name: Generate tags for latest-fat (V2-master branch - production)
id: meta-fat
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
uses: docker/metadata-action@c1e51972afc2121e065aed6d45c65596fe445f3f # v5.8.0
if: github.ref == 'refs/heads/V2-master'
with:
images: |
@@ -152,7 +153,7 @@ jobs:
- name: Generate tags for latest-fat (V1_V2_merge branch - test)
id: meta-fat-test
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
uses: docker/metadata-action@c1e51972afc2121e065aed6d45c65596fe445f3f # v5.8.0
if: github.ref == 'refs/heads/V1_V2_merge'
with:
images: |
@@ -192,7 +193,7 @@ jobs:
- name: Generate tags for ultra-lite (V2-master branch - production)
id: meta-lite
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
uses: docker/metadata-action@c1e51972afc2121e065aed6d45c65596fe445f3f # v5.8.0
if: github.ref == 'refs/heads/V2-master'
with:
images: |
@@ -206,7 +207,7 @@ jobs:
- name: Generate tags for ultra-lite (V1_V2_merge branch - test)
id: meta-lite-test
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
uses: docker/metadata-action@c1e51972afc2121e065aed6d45c65596fe445f3f # v5.8.0
if: github.ref == 'refs/heads/V1_V2_merge'
with:
images: |
+5 -5
View File
@@ -37,10 +37,10 @@ jobs:
- uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
- name: Set up JDK 21
- name: Set up JDK 17
uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v5.0.0
with:
java-version: "21"
java-version: "17"
distribution: "temurin"
- uses: gradle/actions/setup-gradle@4d9f0ba0025fe599b4ebab900eb7f3a1d93ef4c2 # v5.0.0
@@ -89,7 +89,7 @@ jobs:
- name: Generate tags
id: meta
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
uses: docker/metadata-action@318604b99e75e41977312d83839a89be02ca4893 # v5.9.0
if: github.ref != 'refs/heads/main'
with:
images: |
@@ -135,7 +135,7 @@ jobs:
- name: Generate tags ultra-lite
id: meta2
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
uses: docker/metadata-action@318604b99e75e41977312d83839a89be02ca4893 # v5.9.0
if: github.ref != 'refs/heads/main'
with:
images: |
@@ -166,7 +166,7 @@ jobs:
- name: Generate tags fat
id: meta3
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
uses: docker/metadata-action@318604b99e75e41977312d83839a89be02ca4893 # v5.9.0
with:
images: |
${{ secrets.DOCKER_HUB_USERNAME }}/s-pdf
+535
View File
@@ -0,0 +1,535 @@
name: Release Artifacts
on:
workflow_dispatch:
inputs:
platform:
description: "Platform to build (windows, macos, linux, or all)"
required: true
default: "all"
type: choice
options:
- all
- windows
- macos
- linux
push:
branches: [main, V2, V2-demo]
permissions:
contents: read
jobs:
determine-matrix:
runs-on: ubuntu-latest
outputs:
matrix: ${{ steps.set-matrix.outputs.matrix }}
version: ${{ steps.versionNumber.outputs.versionNumber }}
steps:
- uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
- name: Get version number
id: versionNumber
run: |
VERSION=$(grep "^version =" build.gradle | awk -F'"' '{print $2}')
echo "versionNumber=$VERSION" >> $GITHUB_OUTPUT
- name: Determine build matrix
id: set-matrix
run: |
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
case "${{ github.event.inputs.platform }}" in
"windows")
echo 'matrix={"include":[{"platform":"windows-latest","args":"--target x86_64-pc-windows-msvc","name":"windows-x86_64"}]}' >> $GITHUB_OUTPUT
;;
"macos")
echo 'matrix={"include":[{"platform":"macos-15","args":"--target aarch64-apple-darwin","name":"macos-aarch64"},{"platform":"macos-15-intel","args":"--target x86_64-apple-darwin","name":"macos-x86_64"}]}' >> $GITHUB_OUTPUT
;;
"linux")
echo 'matrix={"include":[{"platform":"ubuntu-22.04","args":"","name":"linux-x86_64"}]}' >> $GITHUB_OUTPUT
;;
*)
echo 'matrix={"include":[{"platform":"windows-latest","args":"--target x86_64-pc-windows-msvc","name":"windows-x86_64"},{"platform":"macos-15","args":"--target aarch64-apple-darwin","name":"macos-aarch64"},{"platform":"macos-15-intel","args":"--target x86_64-apple-darwin","name":"macos-x86_64"},{"platform":"ubuntu-22.04","args":"","name":"linux-x86_64"}]}' >> $GITHUB_OUTPUT
;;
esac
else
# For push events, build all platforms
echo 'matrix={"include":[{"platform":"windows-latest","args":"--target x86_64-pc-windows-msvc","name":"windows-x86_64"},{"platform":"macos-15","args":"--target aarch64-apple-darwin","name":"macos-aarch64"},{"platform":"macos-15-intel","args":"--target x86_64-apple-darwin","name":"macos-x86_64"},{"platform":"ubuntu-22.04","args":"","name":"linux-x86_64"}]}' >> $GITHUB_OUTPUT
fi
build:
needs: determine-matrix
strategy:
fail-fast: false
matrix: ${{ fromJson(needs.determine-matrix.outputs.matrix) }}
runs-on: ${{ matrix.platform }}
env:
SM_API_KEY: ${{ secrets.SM_API_KEY }}
WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }}
steps:
- name: Harden Runner
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
with:
egress-policy: audit
- name: Checkout repository
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
- name: Install dependencies (ubuntu only)
if: matrix.platform == 'ubuntu-22.04'
run: |
sudo apt-get update
sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.0-dev libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf libjavascriptcoregtk-4.0-dev libsoup2.4-dev libjavascriptcoregtk-4.1-dev libsoup-3.0-dev
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 20
cache: 'npm'
cache-dependency-path: frontend/package-lock.json
- name: Setup Rust
uses: dtolnay/rust-toolchain@stable
with:
toolchain: stable
targets: ${{ (matrix.platform == 'macos-15' || matrix.platform == 'macos-15-intel') && 'aarch64-apple-darwin,x86_64-apple-darwin' || '' }}
- name: Set up JDK 21
uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v5.0.0
with:
java-version: "21"
distribution: "temurin"
- name: Build Java backend with JLink
working-directory: ./
shell: bash
run: |
chmod +x ./gradlew
echo "🔧 Building Stirling-PDF JAR..."
./gradlew clean build -x spotlessApply -x spotlessCheck -x test -x sonarqube
# Find the built JAR
STIRLING_JAR=$(ls app/core/build/libs/stirling-pdf-*.jar | head -n 1)
echo "✅ Built JAR: $STIRLING_JAR"
# Create Tauri directories
mkdir -p ./frontend/src-tauri/libs
mkdir -p ./frontend/src-tauri/runtime
# Copy JAR to Tauri libs
cp "$STIRLING_JAR" ./frontend/src-tauri/libs/
echo "✅ JAR copied to Tauri libs"
# Analyze JAR dependencies for jlink modules
echo "🔍 Analyzing JAR dependencies..."
if command -v jdeps &> /dev/null; then
DETECTED_MODULES=$(jdeps --print-module-deps --ignore-missing-deps "$STIRLING_JAR" 2>/dev/null || echo "")
if [ -n "$DETECTED_MODULES" ]; then
echo "📋 jdeps detected modules: $DETECTED_MODULES"
MODULES="$DETECTED_MODULES,java.compiler,java.instrument,java.management,java.naming,java.net.http,java.prefs,java.rmi,java.scripting,java.security.jgss,java.security.sasl,java.sql,java.transaction.xa,java.xml.crypto,jdk.crypto.ec,jdk.crypto.cryptoki,jdk.unsupported"
else
echo "⚠️ jdeps analysis failed, using predefined modules"
MODULES="java.base,java.compiler,java.desktop,java.instrument,java.logging,java.management,java.naming,java.net.http,java.prefs,java.rmi,java.scripting,java.security.jgss,java.security.sasl,java.sql,java.transaction.xa,java.xml,java.xml.crypto,jdk.crypto.ec,jdk.crypto.cryptoki,jdk.unsupported"
fi
else
echo "⚠️ jdeps not available, using predefined modules"
MODULES="java.base,java.compiler,java.desktop,java.instrument,java.logging,java.management,java.naming,java.net.http,java.prefs,java.rmi,java.scripting,java.security.jgss,java.security.sasl,java.sql,java.transaction.xa,java.xml,java.xml.crypto,jdk.crypto.ec,jdk.crypto.cryptoki,jdk.unsupported"
fi
# Create custom JRE with jlink
echo "🔧 Creating custom JRE with jlink..."
echo "📋 Using modules: $MODULES"
# Remove any existing JRE
rm -rf ./frontend/src-tauri/runtime/jre
# Create the custom JRE
jlink \
--add-modules "$MODULES" \
--strip-debug \
--compress=2 \
--no-header-files \
--no-man-pages \
--output ./frontend/src-tauri/runtime/jre
if [ ! -d "./frontend/src-tauri/runtime/jre" ]; then
echo "❌ Failed to create JLink runtime"
exit 1
fi
# Test the bundled runtime
if [ -f "./frontend/src-tauri/runtime/jre/bin/java" ]; then
RUNTIME_VERSION=$(./frontend/src-tauri/runtime/jre/bin/java --version 2>&1 | head -n 1)
echo "✅ Custom JRE created successfully: $RUNTIME_VERSION"
else
echo "❌ Custom JRE executable not found"
exit 1
fi
# Calculate runtime size
RUNTIME_SIZE=$(du -sh ./frontend/src-tauri/runtime/jre | cut -f1)
echo "📊 Custom JRE size: $RUNTIME_SIZE"
env:
DISABLE_ADDITIONAL_FEATURES: true
- name: Install frontend dependencies
working-directory: ./frontend
run: npm install
# DigiCert KeyLocker Setup (Cloud HSM)
- name: Setup DigiCert KeyLocker
id: digicert-setup
if: ${{ matrix.platform == 'windows-latest' && env.SM_API_KEY != '' && github.ref == 'refs/heads/main' }}
uses: digicert/ssm-code-signing@v1.1.0
env:
SM_API_KEY: ${{ secrets.SM_API_KEY }}
SM_CLIENT_CERT_FILE_B64: ${{ secrets.SM_CLIENT_CERT_FILE_B64 }}
SM_CLIENT_CERT_PASSWORD: ${{ secrets.SM_CLIENT_CERT_PASSWORD }}
SM_KEYPAIR_ALIAS: ${{ secrets.SM_KEYPAIR_ALIAS }}
SM_HOST: ${{ secrets.SM_HOST }}
- name: Setup DigiCert KeyLocker Certificate
if: ${{ matrix.platform == 'windows-latest' && env.SM_API_KEY != '' && github.ref == 'refs/heads/main' }}
shell: pwsh
run: |
Write-Host "Setting up DigiCert KeyLocker environment..."
# Decode client certificate
$certBytes = [Convert]::FromBase64String("${{ secrets.SM_CLIENT_CERT_FILE_B64 }}")
$certPath = "D:\Certificate_pkcs12.p12"
[IO.File]::WriteAllBytes($certPath, $certBytes)
# Set environment variables
echo "SM_CLIENT_CERT_FILE=D:\Certificate_pkcs12.p12" >> $env:GITHUB_ENV
echo "SM_HOST=${{ secrets.SM_HOST }}" >> $env:GITHUB_ENV
echo "SM_API_KEY=${{ secrets.SM_API_KEY }}" >> $env:GITHUB_ENV
echo "SM_CLIENT_CERT_PASSWORD=${{ secrets.SM_CLIENT_CERT_PASSWORD }}" >> $env:GITHUB_ENV
echo "SM_KEYPAIR_ALIAS=${{ secrets.SM_KEYPAIR_ALIAS }}" >> $env:GITHUB_ENV
# Get PKCS11 config path from DigiCert action
$pkcs11Config = $env:PKCS11_CONFIG
if ($pkcs11Config) {
Write-Host "Found PKCS11_CONFIG: $pkcs11Config"
echo "PKCS11_CONFIG=$pkcs11Config" >> $env:GITHUB_ENV
} else {
Write-Host "PKCS11_CONFIG not set by DigiCert action, using default path"
$defaultPath = "C:\Users\RUNNER~1\AppData\Local\Temp\smtools-windows-x64\pkcs11properties.cfg"
if (Test-Path $defaultPath) {
Write-Host "Found config at default path: $defaultPath"
echo "PKCS11_CONFIG=$defaultPath" >> $env:GITHUB_ENV
} else {
Write-Host "Warning: Could not find PKCS11 config file"
}
}
# Traditional PFX Certificate Import (fallback if KeyLocker not configured)
- name: Import Windows Code Signing Certificate
if: ${{ matrix.platform == 'windows-latest' && 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: matrix.platform == 'macos-15' || matrix.platform == 'macos-15-intel'
env:
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
run: |
echo "Importing Apple Developer Certificate..."
echo $APPLE_CERTIFICATE | base64 --decode > certificate.p12
# Create temporary keychain
KEYCHAIN_PATH=$RUNNER_TEMP/app-signing.keychain-db
KEYCHAIN_PASSWORD=$(openssl rand -base64 32)
security create-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
security set-keychain-settings -lut 21600 $KEYCHAIN_PATH
security unlock-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
# Import certificate
security import certificate.p12 -P "$APPLE_CERTIFICATE_PASSWORD" -A -t cert -f pkcs12 -k $KEYCHAIN_PATH
security list-keychain -d user -s $KEYCHAIN_PATH
security set-key-partition-list -S apple-tool:,apple: -k "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
# Clean up
rm certificate.p12
- name: Verify Certificate
if: matrix.platform == 'macos-15' || matrix.platform == 'macos-15-intel'
run: |
echo "Verifying Apple Developer Certificate..."
KEYCHAIN_PATH=$RUNNER_TEMP/app-signing.keychain-db
CERT_INFO=$(security find-identity -v -p codesigning $KEYCHAIN_PATH | grep "Developer ID Application")
echo "Certificate Info: $CERT_INFO"
CERT_ID=$(echo "$CERT_INFO" | awk -F'"' '{print $2}')
echo "Certificate ID: $CERT_ID"
echo "APPLE_SIGNING_IDENTITY=$CERT_ID" >> $GITHUB_ENV
echo "Certificate imported successfully."
- name: Build Tauri app
uses: tauri-apps/tauri-action@v0
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
APPLE_SIGNING_IDENTITY: ${{ env.APPLE_SIGNING_IDENTITY }}
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_PASSWORD: ${{ secrets.APPLE_ID_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
APPIMAGETOOL_SIGN_PASSPHRASE: ${{ secrets.APPIMAGETOOL_SIGN_PASSPHRASE }}
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY: ${{ secrets.VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY }}
VITE_SAAS_SERVER_URL: ${{ secrets.VITE_SAAS_SERVER_URL }}
# Only enable Windows signing in Tauri when on main
SIGN: ${{ github.ref == 'refs/heads/main' && (env.SM_API_KEY == '' && env.WINDOWS_CERTIFICATE != '') && '1' || '0' }}
CI: true
with:
projectPath: ./frontend
tauriScript: npx tauri
args: ${{ matrix.args }}
# Sign with DigiCert KeyLocker (post-build)
- name: Sign Windows binaries with DigiCert KeyLocker
if: ${{ matrix.platform == 'windows-latest' && env.SM_API_KEY != '' && github.ref == 'refs/heads/main' }}
shell: pwsh
run: |
Write-Host "=== DigiCert KeyLocker Signing ==="
# Test smctl connectivity first
Write-Host "Testing smctl connection..."
$healthCheck = & smctl healthcheck 2>&1
if ($LASTEXITCODE -eq 0) {
Write-Host "[SUCCESS] Connected to DigiCert KeyLocker"
} else {
Write-Host "[ERROR] Failed to connect to DigiCert KeyLocker"
Write-Host $healthCheck
exit 1
}
Write-Host ""
# Sync certificates to Windows certificate store
Write-Host "Syncing certificates to Windows certificate store..."
$syncOutput = & smctl windows certsync 2>&1
Write-Host "Cert sync result: $syncOutput"
Write-Host ""
# Find only the files we need to sign (not build scripts)
$filesToSign = @()
# Main application executable
$mainExe = Get-ChildItem -Path "./frontend/src-tauri/target/x86_64-pc-windows-msvc/release" -Filter "stirling-pdf.exe" -File -ErrorAction SilentlyContinue
if ($mainExe) { $filesToSign += $mainExe }
# MSI installer
$msiFiles = Get-ChildItem -Path "./frontend/src-tauri/target" -Filter "*.msi" -Recurse -File
$filesToSign += $msiFiles
if ($filesToSign.Count -eq 0) {
Write-Host "[ERROR] No files found to sign"
exit 1
}
Write-Host "Found $($filesToSign.Count) files to sign:"
foreach ($f in $filesToSign) { Write-Host " - $($f.Name)" }
Write-Host ""
$signedCount = 0
foreach ($file in $filesToSign) {
Write-Host "Signing: $($file.Name)"
# Get PKCS11 config file path (set by DigiCert action)
$pkcs11Config = $env:PKCS11_CONFIG
if (-not $pkcs11Config) {
Write-Host "[ERROR] PKCS11_CONFIG environment variable not set"
Write-Host "DigiCert KeyLocker action may not have run correctly"
exit 1
}
Write-Host "Using PKCS11 config: $pkcs11Config"
# Try signing with certificate fingerprint first (if available)
$fingerprint = "${{ secrets.SM_CODE_SIGNING_CERT_SHA1_HASH }}"
if ($fingerprint -and $fingerprint -ne "") {
Write-Host "Attempting to sign with certificate fingerprint..."
$output = & smctl sign --fingerprint "$fingerprint" --input "$($file.FullName)" --config-file "$pkcs11Config" --verbose 2>&1
$exitCode = $LASTEXITCODE
} else {
Write-Host "No fingerprint provided, using keypair alias..."
# Use smctl to sign with keypair alias
$output = & smctl sign --keypair-alias "${{ secrets.SM_KEYPAIR_ALIAS }}" --input "$($file.FullName)" --config-file "$pkcs11Config" --verbose 2>&1
$exitCode = $LASTEXITCODE
}
Write-Host "Exit code: $exitCode"
Write-Host "Output: $output"
# Check if output contains "FAILED" even with exit code 0
if ($output -match "FAILED" -or $output -match "error" -or $output -match "Error") {
Write-Host ""
Write-Host "[ERROR] Signing failed for $($file.Name)"
Write-Host "[ERROR] smctl returned success but output indicates failure"
exit 1
}
if ($exitCode -ne 0) {
Write-Host "[ERROR] Failed to sign $($file.Name)"
Write-Host "Full error output:"
Write-Host $output
exit 1
}
$signedCount++
Write-Host "[SUCCESS] Signed: $($file.Name)"
Write-Host ""
}
Write-Host "=== Summary ==="
Write-Host "[SUCCESS] Signed $signedCount/$($filesToSign.Count) files successfully"
- name: Rename artifacts
shell: bash
run: |
mkdir -p ./dist
cd ./frontend/src-tauri/target
# Find and rename artifacts based on platform
if [ "${{ matrix.platform }}" = "windows-latest" ]; then
find . -name "*.exe" -exec cp {} "../../../dist/Stirling-PDF-${{ matrix.name }}.exe" \;
find . -name "*.msi" -exec cp {} "../../../dist/Stirling-PDF-${{ matrix.name }}.msi" \;
elif [ "${{ matrix.platform }}" = "macos-15" ] || [ "${{ matrix.platform }}" = "macos-15-intel" ]; then
find . -name "*.dmg" -exec cp {} "../../../dist/Stirling-PDF-${{ matrix.name }}.dmg" \;
find . -name "*.app" -exec cp -r {} "../../../dist/Stirling-PDF-${{ matrix.name }}.app" \;
else
find . -name "*.deb" -exec cp {} "../../../dist/Stirling-PDF-${{ matrix.name }}.deb" \;
find . -name "*.AppImage" -exec cp {} "../../../dist/Stirling-PDF-${{ matrix.name }}.AppImage" \;
fi
- name: Upload build artifacts
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: Stirling-PDF-${{ matrix.name }}
path: ./dist/*
retention-days: 30
sign_verify:
needs: build
runs-on: ubuntu-latest
if: success()
strategy:
fail-fast: false
matrix:
name: [windows-x86_64, macos-aarch64, macos-x86_64, linux-x86_64]
steps:
- name: Harden Runner
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
with:
egress-policy: audit
- name: Download build artifacts
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
with:
name: Stirling-PDF-${{ matrix.name }}
- name: Display structure of downloaded files
run: ls -R
- name: Install Cosign
uses: sigstore/cosign-installer@d7543c93d881b35a8faa02e8e3605f69b7a1ce62 # v3.10.0
- name: Generate key pair
run: cosign generate-key-pair
- name: Sign and generate attestations
shell: bash
run: |
# Sign all artifacts for this platform
for file in *; do
if [ -f "$file" ] && [[ ! "$file" =~ \.(sig|intoto\.jsonl)$ ]]; then
echo "Signing: $file"
# Sign the artifact
cosign sign-blob \
--key ./cosign.key \
--yes \
--output-signature "${file}.sig" \
"$file"
# Generate attestation
cosign attest-blob \
--predicate - \
--key ./cosign.key \
--yes \
--output-attestation "${file}.intoto.jsonl" \
"$file"
# Verify the signature
cosign verify-blob \
--key ./cosign.pub \
--signature "${file}.sig" \
"$file"
echo "✅ Signed and verified: $file"
fi
done
- name: Upload signed artifacts
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: Stirling-PDF-${{ matrix.name }}-signed
path: |
*
!cosign.key
!cosign.pub
retention-days: 30
release:
needs: [determine-matrix, build, sign_verify]
runs-on: ubuntu-latest
if: github.event_name == 'workflow_dispatch' || github.ref == 'refs/heads/main'
permissions:
contents: write
steps:
- name: Harden Runner
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
with:
egress-policy: audit
- name: Download all signed artifacts
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
with:
pattern: Stirling-PDF-*-signed
path: ./artifacts
- name: Display structure of downloaded files
run: ls -R ./artifacts
- name: Create GitHub Release
uses: softprops/action-gh-release@5be0e66d93ac7ed76da52eca8bb058f665c3a5fe # v2.4.2
with:
tag_name: v${{ needs.determine-matrix.outputs.version }}
generate_release_notes: true
files: ./artifacts/**/*
draft: false
prerelease: false
+1 -1
View File
@@ -22,7 +22,7 @@ jobs:
egress-policy: audit
- name: 30 days stale issues
uses: actions/stale@997185467fa4f803885201cee163a9f38240193d # v10.1.1
uses: actions/stale@5f858e3efba33a5ca4407a664cc011ad407f2008 # v10.1.0
with:
repo-token: ${{ secrets.GITHUB_TOKEN }}
days-before-stale: 30
+2 -2
View File
@@ -33,10 +33,10 @@ jobs:
- uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
- name: Set up JDK 21
- name: Set up JDK 17
uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v5.0.0
with:
java-version: "21"
java-version: "17"
distribution: "temurin"
- uses: gradle/actions/setup-gradle@4d9f0ba0025fe599b4ebab900eb7f3a1d93ef4c2 # v5.0.0
+9 -4
View File
@@ -5,6 +5,7 @@ on:
push:
branches:
- main
- syncLangTest
paths:
- "build.gradle"
- "README.md"
@@ -46,13 +47,14 @@ jobs:
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
- name: Set up Python
uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0
uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0
with:
python-version: "3.12"
cache: "pip" # caching pip dependencies
- name: Install Python dependencies
run: pip install --require-hashes -r ./.github/scripts/requirements_sync_readme.txt
run: |
pip install tomli-w
- name: Sync translation TOML files
run: |
@@ -63,6 +65,9 @@ jobs:
git add frontend/public/locales/*/translation.toml
git diff --staged --quiet || git commit -m ":memo: Sync translation files (TOML)" || echo "No changes detected"
- name: Install README dependencies
run: pip install --require-hashes -r ./.github/scripts/requirements_sync_readme.txt
- name: Sync README.md
run: |
python scripts/counter_translation_v3.py
@@ -74,7 +79,7 @@ jobs:
- name: Create Pull Request
if: always()
uses: peter-evans/create-pull-request@98357b18bf14b5342f975ff684046ec3b2a07725 # v8.0.0
uses: peter-evans/create-pull-request@271a8d0340265f705b14b6d32b9829c1cb33d45e # v7.0.8
with:
token: ${{ steps.setup-bot.outputs.token }}
commit-message: Update files
@@ -116,4 +121,4 @@ jobs:
add-paths: |
README.md
frontend/public/locales/*/translation.toml
scripts/ignore_translation.toml
scripts/ignore_translation.toml
+18 -18
View File
@@ -14,14 +14,14 @@ on:
- macos
- linux
pull_request:
branches: [main, V2-tauri-windows]
branches: [main, V2, V2-tauri-windows]
paths:
- "frontend/src-tauri/**"
- "frontend/src/desktop/**"
- "frontend/tsconfig.desktop.json"
- ".github/workflows/tauri-build.yml"
- 'frontend/src-tauri/**'
- 'frontend/src/desktop/**'
- 'frontend/tsconfig.desktop.json'
- '.github/workflows/tauri-build.yml'
push:
branches: [main]
branches: [main, V2]
permissions:
contents: read
@@ -83,8 +83,8 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 22
cache: "npm"
node-version: 20
cache: 'npm'
cache-dependency-path: frontend/package-lock.json
- name: Setup Rust
@@ -111,15 +111,15 @@ jobs:
# Find the built JAR
STIRLING_JAR=$(ls app/core/build/libs/stirling-pdf-*.jar | head -n 1)
echo "✅ Built JAR: $STIRLING_JAR"
# Create Tauri directories
mkdir -p ./frontend/src-tauri/libs
mkdir -p ./frontend/src-tauri/runtime
# Copy JAR to Tauri libs
cp "$STIRLING_JAR" ./frontend/src-tauri/libs/
echo "✅ JAR copied to Tauri libs"
# Analyze JAR dependencies for jlink modules
echo "🔍 Analyzing JAR dependencies..."
if command -v jdeps &> /dev/null; then
@@ -135,14 +135,14 @@ jobs:
echo "⚠️ jdeps not available, using predefined modules"
MODULES="java.base,java.compiler,java.desktop,java.instrument,java.logging,java.management,java.naming,java.net.http,java.prefs,java.rmi,java.scripting,java.security.jgss,java.security.sasl,java.sql,java.transaction.xa,java.xml,java.xml.crypto,jdk.crypto.ec,jdk.crypto.cryptoki,jdk.unsupported"
fi
# Create custom JRE with jlink (always rebuild)
echo "🔧 Creating custom JRE with jlink..."
echo "📋 Using modules: $MODULES"
# Remove any existing JRE
rm -rf ./frontend/src-tauri/runtime/jre
# Create the custom JRE
jlink \
--add-modules "$MODULES" \
@@ -151,12 +151,12 @@ jobs:
--no-header-files \
--no-man-pages \
--output ./frontend/src-tauri/runtime/jre
if [ ! -d "./frontend/src-tauri/runtime/jre" ]; then
echo "❌ Failed to create JLink runtime"
exit 1
fi
# Test the bundled runtime
if [ -f "./frontend/src-tauri/runtime/jre/bin/java" ]; then
RUNTIME_VERSION=$(./frontend/src-tauri/runtime/jre/bin/java --version 2>&1 | head -n 1)
@@ -165,7 +165,7 @@ jobs:
echo "❌ Custom JRE executable not found"
exit 1
fi
# Calculate runtime size
RUNTIME_SIZE=$(du -sh ./frontend/src-tauri/runtime/jre | cut -f1)
echo "📊 Custom JRE size: $RUNTIME_SIZE"
@@ -579,7 +579,7 @@ jobs:
name: Stirling-PDF-${{ matrix.name }}
path: ./dist/*
retention-days: 7
- name: Verify build artifacts
shell: bash
run: |
+4 -4
View File
@@ -32,11 +32,11 @@ jobs:
- name: Checkout repository
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
- name: Set up JDK 21
- name: Set up JDK
uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v5.0.0
with:
java-version: "21"
distribution: "temurin"
java-version: '17'
distribution: 'temurin'
- name: Setup Gradle
uses: gradle/actions/setup-gradle@4d9f0ba0025fe599b4ebab900eb7f3a1d93ef4c2 # v5.0.0
@@ -149,7 +149,7 @@ jobs:
- name: Set up Node
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
with:
cache: "npm"
cache: 'npm'
cache-dependency-path: frontend/package-lock.json
- name: Run TestDriver.ai
-2
View File
@@ -43,8 +43,6 @@ app/core/src/main/resources/static/og_images/
app/core/src/main/resources/static/samples/
app/core/src/main/resources/static/manifest-classic.json
app/core/src/main/resources/static/robots.txt
app/core/src/main/resources/static/pdfium/
app/core/src/main/resources/static/vendor/
# Note: Keep backend-managed files like fonts/, css/, js/, pdfjs/, etc.
# Gradle
+2 -2
View File
@@ -1,6 +1,6 @@
repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.14.8
rev: v0.14.2
hooks:
- id: ruff
args:
@@ -22,7 +22,7 @@ repos:
files: \.(html|css|js|py|md)$
exclude: (.vscode|.devcontainer|app/core/src/main/resources|app/proprietary/src/main/resources|Dockerfile|.*/pdfjs.*|.*/thirdParty.*|bootstrap.*|.*\.min\..*|.*diff\.js)
- repo: https://github.com/gitleaks/gitleaks
rev: v8.30.0
rev: v8.28.0
hooks:
- id: gitleaks
- repo: https://github.com/pre-commit/pre-commit-hooks
-16
View File
@@ -96,22 +96,6 @@
"moduleName": ".*",
"moduleLicense": "MPL 2.0"
},
{
"moduleName": ".*",
"moduleLicense": "Mozilla Public License, Version 2.0"
},
{
"moduleName": ".*",
"moduleLicense": "Mozilla Public License 2.0 (MPL-2.0)"
},
{
"moduleName": ".*",
"moduleLicense": "CDDL+GPL License"
},
{
"moduleName": ".*",
"moduleLicense": "BSD"
},
{
"moduleName": ".*",
"moduleLicense": "UnboundID SCIM2 SDK Free Use License"
@@ -156,19 +156,19 @@ public class EndpointConfiguration {
return false;
}
// Rule 2: For tool groups, they're enabled unless explicitly disabled (handled above)
if (isToolGroup(group)) {
log.debug("isGroupEnabled('{}') -> true (tool group not disabled)", group);
return true;
}
// Rule 3: For functional groups, check if all endpoints are enabled
Set<String> endpoints = endpointGroups.get(group);
if (endpoints == null || endpoints.isEmpty()) {
log.debug("isGroupEnabled('{}') -> false (no endpoints)", group);
return false;
}
// Rule 2: For functional groups, check if all endpoints are enabled
// Rule 3: For tool groups, they're enabled unless explicitly disabled (handled above)
if (isToolGroup(group)) {
log.debug("isGroupEnabled('{}') -> true (tool group not disabled)", group);
return true;
}
// For functional groups, check each endpoint individually
for (String endpoint : endpoints) {
if (!isEndpointEnabledDirectly(endpoint)) {
@@ -353,7 +353,6 @@ public class EndpointConfiguration {
addEndpointToGroup("Security", "auto-redact");
addEndpointToGroup("Security", "redact");
addEndpointToGroup("Security", "validate-signature");
addEndpointToGroup("Security", "verify-pdf");
addEndpointToGroup("Security", "stamp");
addEndpointToGroup("Security", "sign");
@@ -480,8 +479,6 @@ public class EndpointConfiguration {
addEndpointToGroup("Java", "pdf-to-json");
addEndpointToGroup("Java", "json-to-pdf");
addEndpointToGroup("rar", "pdf-to-cbr");
addEndpointToGroup("Java", "pdf-to-video");
addEndpointToGroup("Java", "verify-pdf");
// Javascript
addEndpointToGroup("Javascript", "pdf-organizer");
@@ -538,9 +535,6 @@ public class EndpointConfiguration {
addEndpointToGroup("Weasyprint", "markdown-to-pdf");
addEndpointToGroup("Weasyprint", "eml-to-pdf");
// veraPDF dependent endpoints
addEndpointToGroup("veraPDF", "verify-pdf");
// Pdftohtml dependent endpoints
addEndpointToGroup("Pdftohtml", "pdf-to-html");
addEndpointToGroup("Pdftohtml", "pdf-to-markdown");
@@ -591,10 +585,7 @@ public class EndpointConfiguration {
|| "Weasyprint".equals(group)
|| "Pdftohtml".equals(group)
|| "ImageMagick".equals(group)
|| "rar".equals(group)
|| "Calibre".equals(group)
|| "FFmpeg".equals(group)
|| "veraPDF".equals(group);
|| "rar".equals(group);
}
private boolean isEndpointEnabledDirectly(String endpoint) {
@@ -393,7 +393,6 @@ public class ApplicationProperties {
private boolean googlevisibility;
private boolean showUpdate;
private boolean showUpdateOnlyAdmin;
private boolean showSettingsWhenNoLogin = true;
private boolean customHTMLFiles;
private String tessdataDir;
private boolean enableAlphaFunctionality;
@@ -417,7 +416,6 @@ public class ApplicationProperties {
private String frontendUrl; // Frontend URL for invite email links (e.g.
// 'https://app.example.com'). If not set, falls back to backendUrl.
private boolean enableMobileScanner = false; // Enable mobile phone QR code upload feature
public boolean isAnalyticsEnabled() {
return this.getEnableAnalytics() != null && this.getEnableAnalytics();
@@ -1,438 +0,0 @@
package stirling.software.common.service;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;
import lombok.extern.slf4j.Slf4j;
/**
* Service for handling mobile scanner file uploads and temporary storage. Files are stored
* temporarily and automatically cleaned up after 10 minutes or upon retrieval.
*/
@Service
@Slf4j
public class MobileScannerService {
private static final long SESSION_TIMEOUT_MS = 10 * 60 * 1000; // 10 minutes
private final Map<String, SessionData> activeSessions = new ConcurrentHashMap<>();
private final Path tempDirectory;
public MobileScannerService() throws IOException {
// Create temp directory for mobile scanner uploads
this.tempDirectory =
Paths.get(System.getProperty("java.io.tmpdir"), "stirling-mobile-scanner");
Files.createDirectories(tempDirectory);
log.info("Mobile scanner temp directory: {}", tempDirectory);
}
/**
* Register a new session (called by desktop when QR code is generated)
*
* @param sessionId Unique session identifier
* @return SessionInfo with creation time and expiry
*/
public SessionInfo createSession(String sessionId) {
validateSessionId(sessionId);
SessionData session = new SessionData(sessionId);
activeSessions.put(sessionId, session);
log.info("Created mobile scanner session: {}", sessionId);
return new SessionInfo(
sessionId,
session.createdAt,
session.createdAt + SESSION_TIMEOUT_MS,
SESSION_TIMEOUT_MS);
}
/**
* Validate if a session exists and is not expired
*
* @param sessionId Session identifier to validate
* @return SessionInfo if valid, null if invalid/expired
*/
public SessionInfo validateSession(String sessionId) {
SessionData session = activeSessions.get(sessionId);
if (session == null) {
return null;
}
long now = System.currentTimeMillis();
long expiryTime = session.getLastAccessTime() + SESSION_TIMEOUT_MS;
// Check if expired
if (now > expiryTime) {
deleteSession(sessionId);
return null;
}
session.updateLastAccess();
return new SessionInfo(sessionId, session.createdAt, expiryTime, SESSION_TIMEOUT_MS);
}
/**
* Stores uploaded files for a session
*
* @param sessionId Unique session identifier
* @param files Files to upload
* @throws IOException If file storage fails
*/
public void uploadFiles(String sessionId, List<MultipartFile> files) throws IOException {
validateSessionId(sessionId);
SessionData session =
activeSessions.computeIfAbsent(sessionId, id -> new SessionData(sessionId));
// Create session directory
Path sessionDir = getSafeSessionDirectory(sessionId);
Files.createDirectories(sessionDir);
// Save each file
for (MultipartFile file : files) {
if (file.isEmpty()) {
continue;
}
String originalFilename = file.getOriginalFilename();
if (originalFilename == null || originalFilename.isBlank()) {
originalFilename = "upload-" + System.currentTimeMillis();
}
// Sanitize filename
String safeFilename = sanitizeFilename(originalFilename);
Path filePath = sessionDir.resolve(safeFilename).normalize().toAbsolutePath();
// Ensure resulting path stays within the session directory
if (!filePath.startsWith(sessionDir)) {
throw new IOException("Invalid filename");
}
// Handle duplicate filenames
int counter = 1;
while (Files.exists(filePath)) {
String nameWithoutExt = safeFilename.replaceFirst("[.][^.]+$", "");
String ext =
safeFilename.contains(".")
? safeFilename.substring(safeFilename.lastIndexOf("."))
: "";
safeFilename = nameWithoutExt + "-" + counter + ext;
filePath = sessionDir.resolve(safeFilename).normalize().toAbsolutePath();
if (!filePath.startsWith(sessionDir)) {
throw new IOException("Invalid filename");
}
counter++;
}
file.transferTo(filePath);
session.addFile(new FileMetadata(safeFilename, file.getSize(), file.getContentType()));
log.info(
"Uploaded file for session {}: {} ({} bytes)",
sessionId,
safeFilename,
file.getSize());
}
session.updateLastAccess();
}
/**
* Retrieves file metadata for a session
*
* @param sessionId Session identifier
* @return List of file metadata, or empty list if session doesn't exist
*/
public List<FileMetadata> getSessionFiles(String sessionId) {
SessionData session = activeSessions.get(sessionId);
if (session == null) {
return List.of();
}
session.updateLastAccess();
return new ArrayList<>(session.getFiles());
}
/**
* Retrieves actual file data for download
*
* @param sessionId Session identifier
* @param filename Filename to retrieve
* @return File path
* @throws IOException If file not found or session doesn't exist
*/
public Path getFile(String sessionId, String filename) throws IOException {
SessionData session = activeSessions.get(sessionId);
if (session == null) {
throw new IOException("Session not found: " + sessionId);
}
Path filePath = getSafeFilePath(sessionId, filename);
if (!Files.exists(filePath)) {
throw new IOException("File not found: " + filename);
}
session.updateLastAccess();
session.markFileAsDownloaded(filename);
return filePath;
}
/**
* Deletes a file after it has been served to the client. Should be called after successful
* download.
*
* @param sessionId Session identifier
* @param filename Filename to delete
*/
public void deleteFileAfterDownload(String sessionId, String filename) {
try {
Path filePath = getSafeFilePath(sessionId, filename);
Files.deleteIfExists(filePath);
log.info("Deleted file after download: {}/{}", sessionId, filename);
// Check if all files have been downloaded - if so, delete the entire session
SessionData session = activeSessions.get(sessionId);
if (session != null && session.allFilesDownloaded()) {
deleteSession(sessionId);
log.info("All files downloaded - deleted session: {}", sessionId);
}
} catch (IOException | IllegalArgumentException e) {
log.warn("Failed to delete file after download: {}/{}", sessionId, filename, e);
}
}
/**
* Deletes a session and all its files
*
* @param sessionId Session to delete
*/
public void deleteSession(String sessionId) {
SessionData session = activeSessions.remove(sessionId);
if (session != null) {
try {
Path sessionDir = getSafeSessionDirectory(sessionId);
if (Files.exists(sessionDir)) {
// Delete all files in session directory
Files.walk(sessionDir)
.sorted(
(a, b) ->
-a.compareTo(b)) // Reverse order to delete files before
// directory
.forEach(
path -> {
try {
Files.deleteIfExists(path);
} catch (IOException e) {
log.warn("Failed to delete file: {}", path, e);
}
});
}
log.info("Deleted session: {}", sessionId);
} catch (IllegalArgumentException e) {
log.warn(
"Refused to delete session with invalid sessionId '{}': {}",
sessionId,
e.getMessage());
} catch (IOException e) {
log.error("Error deleting session directory: {}", sessionId, e);
}
}
}
/** Scheduled cleanup of expired sessions (runs every 5 minutes) */
@Scheduled(fixedRate = 5 * 60 * 1000)
public void cleanupExpiredSessions() {
long now = System.currentTimeMillis();
List<String> expiredSessions = new ArrayList<>();
activeSessions.forEach(
(sessionId, session) -> {
if (now - session.getLastAccessTime() > SESSION_TIMEOUT_MS) {
expiredSessions.add(sessionId);
}
});
if (!expiredSessions.isEmpty()) {
log.info("Cleaning up {} expired mobile scanner sessions", expiredSessions.size());
expiredSessions.forEach(this::deleteSession);
}
}
private void validateSessionId(String sessionId) {
if (sessionId == null || sessionId.isBlank()) {
throw new IllegalArgumentException("Session ID cannot be empty");
}
// Basic validation: alphanumeric and hyphens only
if (!sessionId.matches("[a-zA-Z0-9-]+")) {
throw new IllegalArgumentException("Invalid session ID format");
}
}
private String sanitizeFilename(String filename) {
// Remove path traversal attempts and dangerous characters
String sanitized = filename.replaceAll("[^a-zA-Z0-9._-]", "_");
// Ensure we have a non-empty, safe filename
if (sanitized.isBlank()) {
sanitized = "upload-" + System.currentTimeMillis();
}
return sanitized;
}
/**
* Safely resolves and validates a session directory path to prevent directory traversal
* attacks.
*
* @param sessionId Session identifier
* @return Normalized absolute path to the session directory
* @throws IllegalArgumentException if the resolved path escapes the temp directory
*/
private Path getSafeSessionDirectory(String sessionId) {
validateSessionId(sessionId);
Path baseDir = tempDirectory.normalize().toAbsolutePath();
Path sessionDir = baseDir.resolve(sessionId).normalize().toAbsolutePath();
// Verify the resolved path is still within the temp directory
if (!sessionDir.startsWith(baseDir)) {
throw new IllegalArgumentException("Invalid session ID: path traversal detected");
}
return sessionDir;
}
/**
* Safely resolves and validates a file path within a session directory to prevent directory
* traversal attacks.
*
* @param sessionId Session identifier
* @param filename Filename within the session
* @return Normalized absolute path to the file
* @throws IOException if the resolved path escapes the session directory
*/
private Path getSafeFilePath(String sessionId, String filename) throws IOException {
if (filename == null || filename.isBlank()) {
throw new IOException("Filename cannot be empty");
}
// Additional validation: reject filenames with path separators or parent references
if (filename.contains("..") || filename.contains("/") || filename.contains("\\")) {
throw new IOException(
"Invalid filename: contains path separators or parent references");
}
Path sessionDir = getSafeSessionDirectory(sessionId);
Path filePath = sessionDir.resolve(filename).normalize().toAbsolutePath();
// Verify the resolved path is still within the session directory
if (!filePath.startsWith(sessionDir)) {
throw new IOException("Invalid filename: path traversal detected");
}
return filePath;
}
/** Session information for client */
public static class SessionInfo {
private final String sessionId;
private final long createdAt;
private final long expiresAt;
private final long timeoutMs;
public SessionInfo(String sessionId, long createdAt, long expiresAt, long timeoutMs) {
this.sessionId = sessionId;
this.createdAt = createdAt;
this.expiresAt = expiresAt;
this.timeoutMs = timeoutMs;
}
public String getSessionId() {
return sessionId;
}
public long getCreatedAt() {
return createdAt;
}
public long getExpiresAt() {
return expiresAt;
}
public long getTimeoutMs() {
return timeoutMs;
}
}
/** File metadata for client */
public static class FileMetadata {
private final String filename;
private final long size;
private final String contentType;
public FileMetadata(String filename, long size, String contentType) {
this.filename = filename;
this.size = size;
this.contentType = contentType;
}
public String getFilename() {
return filename;
}
public long getSize() {
return size;
}
public String getContentType() {
return contentType;
}
}
/** Session data tracking */
private static class SessionData {
private final String sessionId;
private final List<FileMetadata> files = new ArrayList<>();
private final Map<String, Boolean> downloadedFiles = new HashMap<>();
private final long createdAt;
private long lastAccessTime;
public SessionData(String sessionId) {
this.sessionId = sessionId;
this.createdAt = System.currentTimeMillis();
this.lastAccessTime = createdAt;
}
public void addFile(FileMetadata file) {
files.add(file);
downloadedFiles.put(file.getFilename(), false);
}
public List<FileMetadata> getFiles() {
return files;
}
public void markFileAsDownloaded(String filename) {
downloadedFiles.put(filename, true);
}
public boolean allFilesDownloaded() {
return !downloadedFiles.isEmpty()
&& downloadedFiles.values().stream().allMatch(downloaded -> downloaded);
}
public void updateLastAccess() {
this.lastAccessTime = System.currentTimeMillis();
}
public long getLastAccessTime() {
return lastAccessTime;
}
}
}
@@ -91,14 +91,6 @@ public class GeneralUtils {
while ((bytesRead = inputStream.read(buffer)) != -1) {
outputStream.write(buffer, 0, bytesRead);
}
} catch (IOException e) {
if (tempFile.exists()) {
try {
Files.delete(tempFile.toPath());
} catch (IOException ignored) {
}
}
throw e;
}
return tempFile;
}
@@ -507,12 +499,6 @@ public class GeneralUtils {
while ((bytesRead = in.read(buffer)) != -1) {
out.write(buffer, 0, bytesRead);
}
} catch (IOException e) {
try {
Files.deleteIfExists(tempFile);
} catch (IOException ignored) {
}
throw e;
}
return tempFile.toFile();
}
@@ -63,16 +63,15 @@ public class ImageProcessingUtils {
} else {
int width = image.getWidth();
int height = image.getHeight();
int[] pixels = new int[width * height];
image.getRGB(0, 0, width, height, pixels, 0, width);
byte[] data = new byte[width * height * 3];
int index = 0;
for (int rgb : pixels) {
data[index++] = (byte) ((rgb >> 16) & 0xFF); // Red
data[index++] = (byte) ((rgb >> 8) & 0xFF); // Green
data[index++] = (byte) (rgb & 0xFF); // Blue
for (int y = 0; y < height; y++) {
for (int x = 0; x < width; x++) {
int rgb = image.getRGB(x, y);
data[index++] = (byte) ((rgb >> 16) & 0xFF); // Red
data[index++] = (byte) ((rgb >> 8) & 0xFF); // Green
data[index++] = (byte) (rgb & 0xFF); // Blue
}
}
return data;
}
@@ -52,11 +52,6 @@ public class RequestUriUtils {
return true;
}
// Mobile scanner page for QR code-based file uploads (peer-to-peer, no backend auth needed)
if (normalizedUri.startsWith("/mobile-scanner")) {
return true;
}
// Treat common static file extensions as static resources
return normalizedUri.endsWith(".svg")
|| normalizedUri.endsWith(".png")
@@ -173,8 +168,6 @@ public class RequestUriUtils {
"/api/v1/ui-data/footer-info") // Public footer configuration
|| trimmedUri.startsWith("/api/v1/invite/validate")
|| trimmedUri.startsWith("/api/v1/invite/accept")
|| trimmedUri.startsWith(
"/api/v1/mobile-scanner/") // Mobile scanner endpoints (no auth)
|| trimmedUri.startsWith("/v1/api-docs");
}
@@ -32,76 +32,83 @@ public class InvertFullColorStrategy extends ReplaceAndInvertColorStrategy {
@Override
public InputStreamResource replace() throws IOException {
try (TempFile tempFile =
new TempFile(
Files.createTempFile("temp", getFileInput().getOriginalFilename())
.toFile())) {
File file = null;
try {
// Create a temporary file, with the original filename from the multipart file
file = Files.createTempFile("temp", getFileInput().getOriginalFilename()).toFile();
// Transfer the content of the multipart file to the file
getFileInput().transferTo(tempFile.getFile());
getFileInput().transferTo(file);
// Load the uploaded PDF
try (PDDocument document = Loader.loadPDF(tempFile.getFile())) {
// Render each page and invert colors
PDFRenderer pdfRenderer = new PDFRenderer(document);
for (int page = 0; page < document.getNumberOfPages(); page++) {
BufferedImage image;
PDDocument document = Loader.loadPDF(file);
// Use global maximum DPI setting, fallback to 300 if not set
int renderDpi = 300; // Default fallback
ApplicationProperties properties =
ApplicationContextProvider.getBean(ApplicationProperties.class);
if (properties != null && properties.getSystem() != null) {
renderDpi = properties.getSystem().getMaxDPI();
}
final int dpi = renderDpi;
final int pageNum = page;
// Render each page and invert colors
PDFRenderer pdfRenderer = new PDFRenderer(document);
for (int page = 0; page < document.getNumberOfPages(); page++) {
BufferedImage image;
image =
ExceptionUtils.handleOomRendering(
pageNum + 1,
dpi,
() -> pdfRenderer.renderImageWithDPI(pageNum, dpi));
// Use global maximum DPI setting, fallback to 300 if not set
int renderDpi = 300; // Default fallback
ApplicationProperties properties =
ApplicationContextProvider.getBean(ApplicationProperties.class);
if (properties != null && properties.getSystem() != null) {
renderDpi = properties.getSystem().getMaxDPI();
}
final int dpi = renderDpi;
final int pageNum = page;
// Invert the colors
invertImageColors(image);
image =
ExceptionUtils.handleOomRendering(
pageNum + 1,
dpi,
() -> pdfRenderer.renderImageWithDPI(pageNum, dpi));
// Create a new PDPage from the inverted image
PDPage pdPage = document.getPage(page);
File tempImageFile = null;
try {
tempImageFile = convertToBufferedImageTpFile(image);
PDImageXObject pdImage =
PDImageXObject.createFromFileByContent(tempImageFile, document);
// Invert the colors
invertImageColors(image);
try (PDPageContentStream contentStream =
new PDPageContentStream(
document,
pdPage,
PDPageContentStream.AppendMode.OVERWRITE,
true)) {
contentStream.drawImage(
pdImage,
0,
0,
pdPage.getMediaBox().getWidth(),
pdPage.getMediaBox().getHeight());
}
} finally {
if (tempImageFile != null && tempImageFile.exists()) {
Files.delete(tempImageFile.toPath());
}
// Create a new PDPage from the inverted image
PDPage pdPage = document.getPage(page);
File tempImageFile = null;
try {
tempImageFile = convertToBufferedImageTpFile(image);
PDImageXObject pdImage =
PDImageXObject.createFromFileByContent(tempImageFile, document);
PDPageContentStream contentStream =
new PDPageContentStream(
document,
pdPage,
PDPageContentStream.AppendMode.OVERWRITE,
true);
contentStream.drawImage(
pdImage,
0,
0,
pdPage.getMediaBox().getWidth(),
pdPage.getMediaBox().getHeight());
contentStream.close();
} finally {
if (tempImageFile != null && tempImageFile.exists()) {
Files.delete(tempImageFile.toPath());
}
}
}
// Save the modified PDF to a ByteArrayOutputStream
ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream();
document.save(byteArrayOutputStream);
// Save the modified PDF to a ByteArrayOutputStream
ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream();
document.save(byteArrayOutputStream);
document.close();
// Prepare the modified PDF for download
ByteArrayInputStream inputStream =
new ByteArrayInputStream(byteArrayOutputStream.toByteArray());
InputStreamResource resource = new InputStreamResource(inputStream);
return resource;
// Prepare the modified PDF for download
ByteArrayInputStream inputStream =
new ByteArrayInputStream(byteArrayOutputStream.toByteArray());
InputStreamResource resource = new InputStreamResource(inputStream);
return resource;
} finally {
if (file != null && file.exists()) {
Files.delete(file.toPath());
}
}
}
@@ -110,20 +117,18 @@ public class InvertFullColorStrategy extends ReplaceAndInvertColorStrategy {
private void invertImageColors(BufferedImage image) {
int width = image.getWidth();
int height = image.getHeight();
int[] pixels = new int[width * height];
image.getRGB(0, 0, width, height, pixels, 0, width);
for (int i = 0; i < pixels.length; i++) {
int pixel = pixels[i];
int a = 0xff;
int r = (pixel >> 16) & 0xff;
int g = (pixel >> 8) & 0xff;
int b = pixel & 0xff;
pixels[i] = (a << 24) | ((255 - r) << 16) | ((255 - g) << 8) | (255 - b);
for (int x = 0; x < width; x++) {
for (int y = 0; y < height; y++) {
int rgba = image.getRGB(x, y);
Color color = new Color(rgba, true);
Color invertedColor =
new Color(
255 - color.getRed(),
255 - color.getGreen(),
255 - color.getBlue());
image.setRGB(x, y, invertedColor.getRGB());
}
}
image.setRGB(0, 0, width, height, pixels, 0, width);
}
// Helper method to convert BufferedImage to InputStream
@@ -132,23 +137,4 @@ public class InvertFullColorStrategy extends ReplaceAndInvertColorStrategy {
ImageIO.write(image, "png", file);
return file;
}
private static class TempFile implements AutoCloseable {
private final File file;
public TempFile(File file) {
this.file = file;
}
public File getFile() {
return file;
}
@Override
public void close() throws IOException {
if (file != null && file.exists()) {
Files.delete(file.toPath());
}
}
}
}
+3 -22
View File
@@ -71,13 +71,6 @@ dependencies {
implementation "org.apache.pdfbox:preflight:$pdfboxVersion"
implementation "org.apache.pdfbox:xmpbox:$pdfboxVersion"
implementation 'org.verapdf:validation-model:1.28.2'
// veraPDF still uses javax.xml.bind, not the new jakarta namespace
implementation 'javax.xml.bind:jaxb-api:2.3.1'
implementation 'com.sun.xml.bind:jaxb-impl:2.3.9'
implementation 'com.sun.xml.bind:jaxb-core:2.3.0.1'
// https://mvnrepository.com/artifact/technology.tabula/tabula
implementation ('technology.tabula:tabula:1.0.5') {
exclude group: 'org.slf4j', module: 'slf4j-simple'
@@ -275,18 +268,6 @@ tasks.register('cleanFrontendAssets', Delete) {
delete generatedFrontendPaths.collect { new File(resourcesStaticDir, it) }
}
tasks.register('copyApiLandingPage', Copy) {
group = 'frontend'
description = 'Copy API landing page to index.html for backend-only mode'
from(new File(resourcesStaticDir, 'api-landing.html'))
into(resourcesStaticDir)
rename('api-landing.html', 'index.html')
dependsOn cleanFrontendAssets
doFirst {
println "Copying API landing page to index.html for backend-only mode..."
}
}
// Ensure copyFrontendAssets runs after spotless tasks
tasks.named('copyFrontendAssets').configure {
mustRunAfter tasks.matching { it.name.startsWith('spotless') }
@@ -296,9 +277,9 @@ if (buildWithFrontend) {
println "Frontend build enabled - JAR will include React frontend"
processResources.dependsOn copyFrontendAssets
} else {
println "Frontend build disabled - JAR will be backend-only with API landing page"
// When not building the UI, ensure any stale frontend assets are removed and use API landing page
processResources.dependsOn copyApiLandingPage
println "Frontend build disabled - JAR will be backend-only"
// When not building the UI, ensure any stale frontend assets are removed
processResources.dependsOn cleanFrontendAssets
}
bootJar.dependsOn ':common:jar'
@@ -32,8 +32,7 @@ public class CleanUrlInterceptor implements HandlerInterceptor {
"principal",
"startDate",
"endDate",
"async",
"session");
"async");
@Override
public boolean preHandle(
@@ -57,20 +57,12 @@ public class MergeController {
// Merges a list of PDDocument objects into a single PDDocument
public PDDocument mergeDocuments(List<PDDocument> documents) throws IOException {
PDDocument mergedDoc = pdfDocumentFactory.createNewDocument();
boolean success = false;
try {
for (PDDocument doc : documents) {
for (PDPage page : doc.getPages()) {
mergedDoc.addPage(page);
}
}
success = true;
return mergedDoc;
} finally {
if (!success) {
mergedDoc.close();
for (PDDocument doc : documents) {
for (PDPage page : doc.getPages()) {
mergedDoc.addPage(page);
}
}
return mergedDoc;
}
// Re-order files to match the explicit order provided by the front-end.
@@ -371,18 +363,9 @@ public class MergeController {
// Save the modified document to a temporary file
outputTempFile = new TempFile(tempFileManager, ".pdf");
try {
mergedDocument.save(outputTempFile.getFile());
} catch (Exception e) {
outputTempFile.close();
outputTempFile = null;
throw e;
}
mergedDocument.save(outputTempFile.getFile());
}
} catch (Exception ex) {
if (outputTempFile != null) {
outputTempFile.close();
}
if (ex instanceof IOException && PdfErrorUtils.isCorruptedPdfError((IOException) ex)) {
log.warn("Corrupted PDF detected in merge pdf process: {}", ex.getMessage());
} else {
@@ -2,6 +2,7 @@ package stirling.software.SPDF.controller.api;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.OutputStream;
import java.nio.file.Files;
import java.util.*;
import java.util.zip.ZipEntry;
@@ -22,6 +23,7 @@ import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
@@ -33,6 +35,7 @@ import stirling.software.SPDF.model.api.SplitPdfBySectionsRequest;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.ExceptionUtils;
import stirling.software.common.util.GeneralUtils;
import stirling.software.common.util.PDFService;
import stirling.software.common.util.TempFile;
import stirling.software.common.util.TempFileManager;
import stirling.software.common.util.WebResponseUtils;
@@ -45,6 +48,7 @@ public class SplitPdfBySectionsController {
private final CustomPDFDocumentFactory pdfDocumentFactory;
private final TempFileManager tempFileManager;
private final PDFService pdfService;
@PostMapping(value = "/split-pdf-by-sections", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@Operation(
@@ -54,8 +58,10 @@ public class SplitPdfBySectionsController {
+ " which page to split, and how to split"
+ " ( halves, thirds, quarters, etc.), both vertically and horizontally."
+ " Input:PDF Output:ZIP-PDF Type:SISO")
public ResponseEntity<?> splitPdf(@ModelAttribute SplitPdfBySectionsRequest request)
throws Exception {
public ResponseEntity<StreamingResponseBody> splitPdf(
@ModelAttribute SplitPdfBySectionsRequest request) throws Exception {
List<ByteArrayOutputStream> splitDocumentsBoas = new ArrayList<>();
MultipartFile file = request.getFileInput();
String pageNumbers = request.getPageNumbers();
SplitTypes splitMode =
@@ -63,214 +69,169 @@ public class SplitPdfBySectionsController {
.map(SplitTypes::valueOf)
.orElse(SplitTypes.SPLIT_ALL);
try (PDDocument sourceDocument = pdfDocumentFactory.load(file, true)) {
int totalPages = sourceDocument.getNumberOfPages();
Set<Integer> customPagesToSplit =
getCustomPagesToSplit(pageNumbers, splitMode, totalPages);
PDDocument sourceDocument = pdfDocumentFactory.load(file);
// Process the PDF based on split parameters
int horiz = request.getHorizontalDivisions() + 1;
int verti = request.getVerticalDivisions() + 1;
boolean merge = Boolean.TRUE.equals(request.getMerge());
String filename = GeneralUtils.generateFilename(file.getOriginalFilename(), "_split");
Set<Integer> pagesToSplit =
getPagesToSplit(pageNumbers, splitMode, sourceDocument.getNumberOfPages());
if (merge) {
try (PDDocument mergedDoc =
pdfDocumentFactory.createNewDocumentBasedOnOldDocument(
sourceDocument);
ByteArrayOutputStream baos = new ByteArrayOutputStream()) {
LayerUtility layerUtility = new LayerUtility(mergedDoc);
for (int pageIndex = 0; pageIndex < totalPages; pageIndex++) {
if (shouldSplitPage(pageIndex, totalPages, splitMode, customPagesToSplit)) {
addSplitPageToTarget(
sourceDocument,
pageIndex,
mergedDoc,
layerUtility,
horiz,
verti);
} else {
addPageToTarget(sourceDocument, pageIndex, mergedDoc, layerUtility);
}
}
mergedDoc.save(baos);
return WebResponseUtils.baosToWebResponse(baos, filename + ".pdf");
}
} else {
TempFile zipTempFile = new TempFile(tempFileManager, ".zip");
try (ZipOutputStream zipOut =
new ZipOutputStream(Files.newOutputStream(zipTempFile.getPath()))) {
for (int pageIndex = 0; pageIndex < totalPages; pageIndex++) {
int pageNum = pageIndex + 1;
if (shouldSplitPage(pageIndex, totalPages, splitMode, customPagesToSplit)) {
for (int i = 0; i < horiz; i++) {
for (int j = 0; j < verti; j++) {
try (PDDocument subDoc =
pdfDocumentFactory.createNewDocument()) {
LayerUtility subLayerUtility = new LayerUtility(subDoc);
addSingleSectionToTarget(
sourceDocument,
pageIndex,
subDoc,
subLayerUtility,
i,
j,
horiz,
verti);
int sectionNum = i * verti + j + 1;
String entryName =
filename
+ "_"
+ pageNum
+ "_"
+ sectionNum
+ ".pdf";
saveDocToZip(subDoc, zipOut, entryName);
}
}
}
} else {
try (PDDocument subDoc = pdfDocumentFactory.createNewDocument()) {
LayerUtility subLayerUtility = new LayerUtility(subDoc);
addPageToTarget(sourceDocument, pageIndex, subDoc, subLayerUtility);
String entryName = filename + "_" + pageNum + "_1.pdf";
saveDocToZip(subDoc, zipOut, entryName);
}
}
}
}
return WebResponseUtils.zipFileToWebResponse(zipTempFile, filename + ".zip");
// Process the PDF based on split parameters
int horiz = request.getHorizontalDivisions() + 1;
int verti = request.getVerticalDivisions() + 1;
boolean merge = Boolean.TRUE.equals(request.getMerge());
List<PDDocument> splitDocuments = splitPdfPages(sourceDocument, verti, horiz, pagesToSplit);
String filename = GeneralUtils.generateFilename(file.getOriginalFilename(), "_split.pdf");
if (merge) {
TempFile tempFile = new TempFile(tempFileManager, ".pdf");
try (PDDocument merged = pdfService.mergeDocuments(splitDocuments);
OutputStream out = Files.newOutputStream(tempFile.getPath())) {
merged.save(out);
for (PDDocument d : splitDocuments) d.close();
sourceDocument.close();
}
return WebResponseUtils.pdfFileToWebResponse(tempFile, filename + "_split.pdf");
}
for (PDDocument doc : splitDocuments) {
ByteArrayOutputStream baos = new ByteArrayOutputStream();
doc.save(baos);
doc.close();
splitDocumentsBoas.add(baos);
}
sourceDocument.close();
TempFile zipTempFile = new TempFile(tempFileManager, ".zip");
try (ZipOutputStream zipOut =
new ZipOutputStream(Files.newOutputStream(zipTempFile.getPath()))) {
int pageNum = 1;
for (int i = 0; i < splitDocumentsBoas.size(); i++) {
ByteArrayOutputStream baos = splitDocumentsBoas.get(i);
int sectionNum = (i % (horiz * verti)) + 1;
String fileName = filename + "_" + pageNum + "_" + sectionNum + ".pdf";
byte[] pdf = baos.toByteArray();
ZipEntry pdfEntry = new ZipEntry(fileName);
zipOut.putNextEntry(pdfEntry);
zipOut.write(pdf);
zipOut.closeEntry();
if (sectionNum == horiz * verti) pageNum++;
}
}
}
private void addPageToTarget(
PDDocument sourceDoc, int pageIndex, PDDocument targetDoc, LayerUtility layerUtility)
throws IOException {
PDPage sourcePage = sourceDoc.getPage(pageIndex);
PDPage newPage = new PDPage(sourcePage.getMediaBox());
targetDoc.addPage(newPage);
PDFormXObject form = layerUtility.importPageAsForm(sourceDoc, pageIndex);
try (PDPageContentStream contentStream =
new PDPageContentStream(targetDoc, newPage, AppendMode.APPEND, true, true)) {
contentStream.drawForm(form);
}
}
private void addSplitPageToTarget(
PDDocument sourceDoc,
int pageIndex,
PDDocument targetDoc,
LayerUtility layerUtility,
int totalHoriz,
int totalVert)
throws IOException {
PDPage sourcePage = sourceDoc.getPage(pageIndex);
PDRectangle mediaBox = sourcePage.getMediaBox();
float width = mediaBox.getWidth();
float height = mediaBox.getHeight();
float subPageWidth = width / totalHoriz;
float subPageHeight = height / totalVert;
PDFormXObject form = layerUtility.importPageAsForm(sourceDoc, pageIndex);
for (int i = 0; i < totalHoriz; i++) {
for (int j = 0; j < totalVert; j++) {
PDPage subPage = new PDPage(new PDRectangle(subPageWidth, subPageHeight));
targetDoc.addPage(subPage);
try (PDPageContentStream contentStream =
new PDPageContentStream(
targetDoc, subPage, AppendMode.APPEND, true, true)) {
float translateX = -subPageWidth * i;
float translateY = -subPageHeight * (totalVert - 1 - j);
contentStream.saveGraphicsState();
contentStream.addRect(0, 0, subPageWidth, subPageHeight);
contentStream.clip();
contentStream.transform(new Matrix(1, 0, 0, 1, translateX, translateY));
contentStream.drawForm(form);
contentStream.restoreGraphicsState();
}
}
}
}
private void addSingleSectionToTarget(
PDDocument sourceDoc,
int pageIndex,
PDDocument targetDoc,
LayerUtility layerUtility,
int horizIndex,
int vertIndex,
int totalHoriz,
int totalVert)
throws IOException {
PDPage sourcePage = sourceDoc.getPage(pageIndex);
PDRectangle mediaBox = sourcePage.getMediaBox();
float subPageWidth = mediaBox.getWidth() / totalHoriz;
float subPageHeight = mediaBox.getHeight() / totalVert;
PDPage subPage = new PDPage(new PDRectangle(subPageWidth, subPageHeight));
targetDoc.addPage(subPage);
PDFormXObject form = layerUtility.importPageAsForm(sourceDoc, pageIndex);
try (PDPageContentStream contentStream =
new PDPageContentStream(targetDoc, subPage, AppendMode.APPEND, true, true)) {
float translateX = -subPageWidth * horizIndex;
float translateY = -subPageHeight * (totalVert - 1 - vertIndex);
contentStream.saveGraphicsState();
contentStream.addRect(0, 0, subPageWidth, subPageHeight);
contentStream.clip();
contentStream.transform(new Matrix(1, 0, 0, 1, translateX, translateY));
contentStream.drawForm(form);
contentStream.restoreGraphicsState();
}
}
private void saveDocToZip(PDDocument doc, ZipOutputStream zipOut, String entryName)
throws IOException {
ZipEntry entry = new ZipEntry(entryName);
zipOut.putNextEntry(entry);
doc.save(zipOut);
zipOut.closeEntry();
return WebResponseUtils.zipFileToWebResponse(zipTempFile, filename + "_split.zip");
}
// Based on the mode, get the pages that need to be split and return the pages set
private Set<Integer> getCustomPagesToSplit(
String pageNumbers, SplitTypes splitMode, int totalPages) {
if (splitMode != SplitTypes.CUSTOM) {
return Collections.emptySet();
private Set<Integer> getPagesToSplit(String pageNumbers, SplitTypes splitMode, int totalPages) {
Set<Integer> pagesToSplit = new HashSet<>();
switch (splitMode) {
case CUSTOM:
if (pageNumbers == null || pageNumbers.isBlank()) {
throw ExceptionUtils.createIllegalArgumentException(
"error.argumentRequired",
"{0} is required for {1} mode",
"page numbers",
"custom");
}
String[] pageOrderArr = pageNumbers.split(",");
List<Integer> pageListToSplit =
GeneralUtils.parsePageList(pageOrderArr, totalPages, false);
pagesToSplit.addAll(pageListToSplit);
break;
case SPLIT_ALL:
for (int i = 0; i < totalPages; i++) {
pagesToSplit.add(i);
}
break;
case SPLIT_ALL_EXCEPT_FIRST:
for (int i = 1; i < totalPages; i++) {
pagesToSplit.add(i);
}
break;
case SPLIT_ALL_EXCEPT_LAST:
for (int i = 0; i < totalPages - 1; i++) {
pagesToSplit.add(i);
}
break;
case SPLIT_ALL_EXCEPT_FIRST_AND_LAST:
for (int i = 1; i < totalPages - 1; i++) {
pagesToSplit.add(i);
}
break;
default:
throw ExceptionUtils.createIllegalArgumentException(
"error.invalidFormat", "Invalid {0} format: {1}", "split mode", splitMode);
}
if (pageNumbers == null || pageNumbers.isBlank()) {
throw ExceptionUtils.createIllegalArgumentException(
"error.argumentRequired",
"{0} is required for {1} mode",
"page numbers",
"custom");
}
String[] pageOrderArr = pageNumbers.split(",");
List<Integer> pageListToSplit = GeneralUtils.parsePageList(pageOrderArr, totalPages, false);
return new HashSet<>(pageListToSplit);
return pagesToSplit;
}
private boolean shouldSplitPage(
int pageIndex, int totalPages, SplitTypes splitMode, Set<Integer> customPagesToSplit) {
return switch (splitMode) {
case CUSTOM -> customPagesToSplit.contains(pageIndex);
case SPLIT_ALL -> true;
case SPLIT_ALL_EXCEPT_FIRST -> pageIndex > 0;
case SPLIT_ALL_EXCEPT_LAST -> pageIndex < totalPages - 1;
case SPLIT_ALL_EXCEPT_FIRST_AND_LAST -> pageIndex > 0 && pageIndex < totalPages - 1;
default ->
throw ExceptionUtils.createIllegalArgumentException(
"error.invalidFormat",
"Invalid {0} format: {1}",
"split mode",
splitMode);
};
public List<PDDocument> splitPdfPages(
PDDocument document,
int horizontalDivisions,
int verticalDivisions,
Set<Integer> pagesToSplit)
throws IOException {
List<PDDocument> splitDocuments = new ArrayList<>();
int pageIndex = 0;
for (PDPage originalPage : document.getPages()) {
// If current page is not to split, add it to the splitDocuments directly.
if (!pagesToSplit.contains(pageIndex)) {
PDDocument newDoc = pdfDocumentFactory.createNewDocument();
newDoc.addPage(originalPage);
splitDocuments.add(newDoc);
} else {
// Otherwise, split current page.
PDRectangle originalMediaBox = originalPage.getMediaBox();
float width = originalMediaBox.getWidth();
float height = originalMediaBox.getHeight();
float subPageWidth = width / horizontalDivisions;
float subPageHeight = height / verticalDivisions;
LayerUtility layerUtility = new LayerUtility(document);
for (int i = 0; i < horizontalDivisions; i++) {
for (int j = 0; j < verticalDivisions; j++) {
PDDocument subDoc = new PDDocument();
PDPage subPage = new PDPage(new PDRectangle(subPageWidth, subPageHeight));
subDoc.addPage(subPage);
PDFormXObject form =
layerUtility.importPageAsForm(
document, document.getPages().indexOf(originalPage));
try (PDPageContentStream contentStream =
new PDPageContentStream(
subDoc, subPage, AppendMode.APPEND, true, true)) {
// Set clipping area and position
float translateX = -subPageWidth * i;
// float translateY = height - subPageHeight * (verticalDivisions - j);
float translateY = -subPageHeight * (verticalDivisions - 1 - j);
contentStream.saveGraphicsState();
contentStream.addRect(0, 0, subPageWidth, subPageHeight);
contentStream.clip();
contentStream.transform(new Matrix(1, 0, 0, 1, translateX, translateY));
// Draw the form
contentStream.drawForm(form);
contentStream.restoreGraphicsState();
}
splitDocuments.add(subDoc);
}
}
}
pageIndex++;
}
return splitDocuments;
}
}
@@ -124,168 +124,124 @@ public class SplitPdfBySizeController {
throws IOException {
log.debug("Starting handleSplitBySize with maxBytes={}", maxBytes);
class DocHolder implements AutoCloseable {
private PDDocument doc;
public DocHolder(PDDocument doc) {
this.doc = doc;
}
public PDDocument getDoc() {
return doc;
}
public void setDoc(PDDocument doc) {
if (this.doc != null) {
try {
this.doc.close();
} catch (IOException e) {
log.error("Error closing document", e);
}
}
this.doc = doc;
}
@Override
public void close() throws IOException {
if (doc != null) {
doc.close();
}
}
}
PDDocument currentDoc =
pdfDocumentFactory.createNewDocumentBasedOnOldDocument(sourceDocument);
int fileIndex = 1;
try (DocHolder holder =
new DocHolder(
pdfDocumentFactory.createNewDocumentBasedOnOldDocument(sourceDocument))) {
int totalPages = sourceDocument.getNumberOfPages();
int pageAdded = 0;
int totalPages = sourceDocument.getNumberOfPages();
int pageAdded = 0;
// Smart size check frequency - check more often with larger documents
int baseCheckFrequency = 5;
// Smart size check frequency - check more often with larger documents
int baseCheckFrequency = 5;
for (int pageIndex = 0; pageIndex < totalPages; pageIndex++) {
PDPage page = sourceDocument.getPage(pageIndex);
log.debug("Processing page {} of {}", pageIndex + 1, totalPages);
for (int pageIndex = 0; pageIndex < totalPages; pageIndex++) {
PDPage page = sourceDocument.getPage(pageIndex);
log.debug("Processing page {} of {}", pageIndex + 1, totalPages);
// Add the page to current document
PDPage newPage = new PDPage(page.getCOSObject());
holder.getDoc().addPage(newPage);
pageAdded++;
// Add the page to current document
PDPage newPage = new PDPage(page.getCOSObject());
currentDoc.addPage(newPage);
pageAdded++;
// Dynamic size checking based on document size and page count
boolean shouldCheckSize =
(pageAdded % baseCheckFrequency == 0)
|| (pageIndex == totalPages - 1)
|| (pageAdded >= 20); // Always check after 20 pages
// Dynamic size checking based on document size and page count
boolean shouldCheckSize =
(pageAdded % baseCheckFrequency == 0)
|| (pageIndex == totalPages - 1)
|| (pageAdded >= 20); // Always check after 20 pages
if (shouldCheckSize) {
log.debug("Performing size check after {} pages", pageAdded);
long actualSize;
try (ByteArrayOutputStream checkSizeStream = new ByteArrayOutputStream()) {
holder.getDoc().save(checkSizeStream);
actualSize = checkSizeStream.size();
if (shouldCheckSize) {
log.debug("Performing size check after {} pages", pageAdded);
ByteArrayOutputStream checkSizeStream = new ByteArrayOutputStream();
currentDoc.save(checkSizeStream);
long actualSize = checkSizeStream.size();
log.debug("Current document size: {} bytes (max: {} bytes)", actualSize, maxBytes);
if (actualSize > maxBytes) {
// We exceeded the limit - remove the last page and save
if (currentDoc.getNumberOfPages() > 1) {
currentDoc.removePage(currentDoc.getNumberOfPages() - 1);
pageIndex--; // Process this page again in the next document
log.debug("Size limit exceeded - removed last page");
}
log.debug(
"Current document size: {} bytes (max: {} bytes)",
actualSize,
maxBytes);
"Saving document with {} pages as part {}",
currentDoc.getNumberOfPages(),
fileIndex);
saveDocumentToZip(currentDoc, zipOut, baseFilename, fileIndex++);
currentDoc = new PDDocument();
pageAdded = 0;
} else if (pageIndex < totalPages - 1) {
// We're under the limit, calculate if we might fit more pages
// Try to predict how many more similar pages might fit
if (actualSize < maxBytes * 0.75 && pageAdded > 0) {
// Rather than using a ratio, look ahead to test actual upcoming pages
int pagesToLookAhead = Math.min(5, totalPages - pageIndex - 1);
if (actualSize > maxBytes) {
// We exceeded the limit - remove the last page and save
if (holder.getDoc().getNumberOfPages() > 1) {
holder.getDoc().removePage(holder.getDoc().getNumberOfPages() - 1);
pageIndex--; // Process this page again in the next document
log.debug("Size limit exceeded - removed last page");
}
if (pagesToLookAhead > 0) {
log.debug(
"Testing {} upcoming pages for potential addition",
pagesToLookAhead);
log.debug(
"Saving document with {} pages as part {}",
holder.getDoc().getNumberOfPages(),
fileIndex);
saveDocumentToZip(holder.getDoc(), zipOut, baseFilename, fileIndex++);
holder.setDoc(new PDDocument());
pageAdded = 0;
} else if (pageIndex < totalPages - 1) {
// We're under the limit, calculate if we might fit more pages
// Try to predict how many more similar pages might fit
if (actualSize < maxBytes * 0.75 && pageAdded > 0) {
// Rather than using a ratio, look ahead to test actual upcoming pages
int pagesToLookAhead = Math.min(5, totalPages - pageIndex - 1);
// Create a temp document with current pages + look-ahead pages
PDDocument testDoc = new PDDocument();
// First copy existing pages
for (int i = 0; i < currentDoc.getNumberOfPages(); i++) {
testDoc.addPage(new PDPage(currentDoc.getPage(i).getCOSObject()));
}
if (pagesToLookAhead > 0) {
log.debug(
"Testing {} upcoming pages for potential addition",
pagesToLookAhead);
// Try adding look-ahead pages one by one
int extraPagesAdded = 0;
for (int i = 0; i < pagesToLookAhead; i++) {
int testPageIndex = pageIndex + 1 + i;
PDPage testPage = sourceDocument.getPage(testPageIndex);
testDoc.addPage(new PDPage(testPage.getCOSObject()));
// Create a temp document with current pages + look-ahead pages
try (PDDocument testDoc = new PDDocument()) {
// First copy existing pages
for (int i = 0; i < holder.getDoc().getNumberOfPages(); i++) {
testDoc.addPage(
new PDPage(
holder.getDoc().getPage(i).getCOSObject()));
}
// Check if we're still under size
ByteArrayOutputStream testStream = new ByteArrayOutputStream();
testDoc.save(testStream);
long testSize = testStream.size();
// Try adding look-ahead pages one by one
int extraPagesAdded = 0;
for (int i = 0; i < pagesToLookAhead; i++) {
int testPageIndex = pageIndex + 1 + i;
PDPage testPage = sourceDocument.getPage(testPageIndex);
testDoc.addPage(new PDPage(testPage.getCOSObject()));
// Check if we're still under size
long testSize;
try (ByteArrayOutputStream testStream =
new ByteArrayOutputStream()) {
testDoc.save(testStream);
testSize = testStream.size();
}
if (testSize <= maxBytes) {
extraPagesAdded++;
log.debug(
"Test: Can add page {} (size would be {})",
testPageIndex + 1,
testSize);
} else {
log.debug(
"Test: Cannot add page {} (size would be {})",
testPageIndex + 1,
testSize);
break;
}
}
// Add the pages we verified would fit
if (extraPagesAdded > 0) {
log.debug(
"Adding {} verified pages ahead", extraPagesAdded);
for (int i = 0; i < extraPagesAdded; i++) {
int extraPageIndex = pageIndex + 1 + i;
PDPage extraPage =
sourceDocument.getPage(extraPageIndex);
holder.getDoc()
.addPage(new PDPage(extraPage.getCOSObject()));
}
pageIndex += extraPagesAdded;
pageAdded += extraPagesAdded;
}
if (testSize <= maxBytes) {
extraPagesAdded++;
log.debug(
"Test: Can add page {} (size would be {})",
testPageIndex + 1,
testSize);
} else {
log.debug(
"Test: Cannot add page {} (size would be {})",
testPageIndex + 1,
testSize);
break;
}
}
testDoc.close();
// Add the pages we verified would fit
if (extraPagesAdded > 0) {
log.debug("Adding {} verified pages ahead", extraPagesAdded);
for (int i = 0; i < extraPagesAdded; i++) {
int extraPageIndex = pageIndex + 1 + i;
PDPage extraPage = sourceDocument.getPage(extraPageIndex);
currentDoc.addPage(new PDPage(extraPage.getCOSObject()));
}
pageIndex += extraPagesAdded;
pageAdded += extraPagesAdded;
}
}
}
}
}
}
// Save final document if it has any pages
if (holder.getDoc() != null && holder.getDoc().getNumberOfPages() > 0) {
log.debug(
"Saving final document with {} pages as part {}",
holder.getDoc().getNumberOfPages(),
fileIndex);
saveDocumentToZip(holder.getDoc(), zipOut, baseFilename, fileIndex++);
holder.setDoc(null);
}
// Save final document if it has any pages
if (currentDoc.getNumberOfPages() > 0) {
log.debug(
"Saving final document with {} pages as part {}",
currentDoc.getNumberOfPages(),
fileIndex);
saveDocumentToZip(currentDoc, zipOut, baseFilename, fileIndex++);
}
log.debug("Completed handleSplitBySize with {} document parts created", fileIndex - 1);
@@ -296,103 +252,96 @@ public class SplitPdfBySizeController {
throws IOException {
log.debug("Starting handleSplitByPageCount with pageCount={}", pageCount);
int currentPageCount = 0;
PDDocument currentDoc = null;
log.debug("Creating initial output document");
PDDocument currentDoc;
try {
currentDoc = pdfDocumentFactory.createNewDocumentBasedOnOldDocument(sourceDocument);
log.debug("Successfully created initial output document");
} catch (Exception e) {
ExceptionUtils.logException("initial output document creation", e);
throw ExceptionUtils.createFileProcessingException("split", e);
}
int fileIndex = 1;
int pageIndex = 0;
int totalPages = sourceDocument.getNumberOfPages();
log.debug("Processing {} pages", totalPages);
try {
log.debug("Creating initial output document");
try {
currentDoc = pdfDocumentFactory.createNewDocumentBasedOnOldDocument(sourceDocument);
log.debug("Successfully created initial output document");
} catch (Exception e) {
ExceptionUtils.logException("initial output document creation", e);
throw ExceptionUtils.createFileProcessingException("split", e);
}
for (PDPage page : sourceDocument.getPages()) {
pageIndex++;
log.debug("Processing page {} of {}", pageIndex, totalPages);
int pageIndex = 0;
int totalPages = sourceDocument.getNumberOfPages();
log.debug("Processing {} pages", totalPages);
try {
for (PDPage page : sourceDocument.getPages()) {
pageIndex++;
log.debug("Processing page {} of {}", pageIndex, totalPages);
try {
log.debug("Adding page {} to current document", pageIndex);
currentDoc.addPage(page);
log.debug("Successfully added page {} to current document", pageIndex);
} catch (Exception e) {
log.error("Error adding page {} to current document", pageIndex, e);
throw ExceptionUtils.createFileProcessingException("split", e);
}
currentPageCount++;
log.debug("Current page count: {}/{}", currentPageCount, pageCount);
if (currentPageCount == pageCount) {
log.debug(
"Reached target page count ({}), saving current document as part {}",
pageCount,
fileIndex);
try {
saveDocumentToZip(currentDoc, zipOut, baseFilename, fileIndex++);
currentDoc = null; // Document is closed by saveDocumentToZip
log.debug("Successfully saved document part {}", fileIndex - 1);
} catch (Exception e) {
log.error("Error saving document part {}", fileIndex - 1, e);
throw e;
}
try {
log.debug("Creating new document for next part");
currentDoc = new PDDocument();
log.debug("Successfully created new document");
} catch (Exception e) {
log.error("Error creating new document for next part", e);
throw ExceptionUtils.createFileProcessingException("split", e);
}
currentPageCount = 0;
log.debug("Reset current page count to 0");
}
try {
log.debug("Adding page {} to current document", pageIndex);
currentDoc.addPage(page);
log.debug("Successfully added page {} to current document", pageIndex);
} catch (Exception e) {
log.error("Error adding page {} to current document", pageIndex, e);
throw ExceptionUtils.createFileProcessingException("split", e);
}
} catch (Exception e) {
log.error("Error iterating through pages", e);
throw ExceptionUtils.createFileProcessingException("split", e);
}
// Add the last document if it contains any pages
try {
if (currentDoc != null && currentDoc.getPages().getCount() != 0) {
currentPageCount++;
log.debug("Current page count: {}/{}", currentPageCount, pageCount);
if (currentPageCount == pageCount) {
log.debug(
"Saving final document with {} pages as part {}",
currentDoc.getPages().getCount(),
"Reached target page count ({}), saving current document as part {}",
pageCount,
fileIndex);
try {
saveDocumentToZip(currentDoc, zipOut, baseFilename, fileIndex++);
currentDoc = null; // Document is closed by saveDocumentToZip
log.debug("Successfully saved final document part {}", fileIndex - 1);
log.debug("Successfully saved document part {}", fileIndex - 1);
} catch (Exception e) {
log.error("Error saving final document part {}", fileIndex - 1, e);
log.error("Error saving document part {}", fileIndex - 1, e);
throw e;
}
} else {
log.debug("Final document has no pages, skipping");
try {
log.debug("Creating new document for next part");
currentDoc = new PDDocument();
log.debug("Successfully created new document");
} catch (Exception e) {
log.error("Error creating new document for next part", e);
throw ExceptionUtils.createFileProcessingException("split", e);
}
currentPageCount = 0;
log.debug("Reset current page count to 0");
}
} catch (Exception e) {
log.error("Error checking or saving final document", e);
throw ExceptionUtils.createFileProcessingException("split", e);
}
} finally {
if (currentDoc != null) {
} catch (Exception e) {
log.error("Error iterating through pages", e);
throw ExceptionUtils.createFileProcessingException("split", e);
}
// Add the last document if it contains any pages
try {
if (currentDoc.getPages().getCount() != 0) {
log.debug(
"Saving final document with {} pages as part {}",
currentDoc.getPages().getCount(),
fileIndex);
try {
log.debug("Closing remaining document");
currentDoc.close();
log.debug("Successfully closed remaining document");
saveDocumentToZip(currentDoc, zipOut, baseFilename, fileIndex++);
log.debug("Successfully saved final document part {}", fileIndex - 1);
} catch (Exception e) {
log.error("Error closing remaining document", e);
log.error("Error saving final document part {}", fileIndex - 1, e);
throw e;
}
} else {
log.debug("Final document has no pages, skipping");
}
} catch (Exception e) {
log.error("Error checking or saving final document", e);
throw ExceptionUtils.createFileProcessingException("split", e);
} finally {
try {
log.debug("Closing final document");
currentDoc.close();
log.debug("Successfully closed final document");
} catch (Exception e) {
log.error("Error closing final document", e);
}
}
@@ -418,52 +367,42 @@ public class SplitPdfBySizeController {
for (int i = 0; i < documentCount; i++) {
log.debug("Creating document {} of {}", i + 1, documentCount);
PDDocument currentDoc = null;
PDDocument currentDoc;
try {
currentDoc = pdfDocumentFactory.createNewDocumentBasedOnOldDocument(sourceDocument);
log.debug("Successfully created document {} of {}", i + 1, documentCount);
int pagesToAdd = pagesPerDocument + (i < extraPages ? 1 : 0);
log.debug("Adding {} pages to document {}", pagesToAdd, i + 1);
for (int j = 0; j < pagesToAdd; j++) {
try {
log.debug(
"Adding page {} (index {}) to document {}",
j + 1,
currentPageIndex,
i + 1);
currentDoc.addPage(sourceDocument.getPage(currentPageIndex));
log.debug("Successfully added page {} to document {}", j + 1, i + 1);
currentPageIndex++;
} catch (Exception e) {
log.error("Error adding page {} to document {}", j + 1, i + 1, e);
throw ExceptionUtils.createFileProcessingException("split", e);
}
}
try {
log.debug("Saving document {} with {} pages", i + 1, pagesToAdd);
saveDocumentToZip(currentDoc, zipOut, baseFilename, fileIndex++);
// saveDocumentToZip closes the document
currentDoc = null;
log.debug("Successfully saved document {}", i + 1);
} catch (Exception e) {
log.error("Error saving document {}", i + 1, e);
throw e;
}
} catch (Exception e) {
log.error("Error creating document {} of {}", i + 1, documentCount, e);
throw ExceptionUtils.createFileProcessingException("split", e);
} finally {
if (currentDoc != null) {
try {
currentDoc.close();
} catch (IOException e) {
log.error("Error closing document {} of {}", i + 1, documentCount, e);
}
}
int pagesToAdd = pagesPerDocument + (i < extraPages ? 1 : 0);
log.debug("Adding {} pages to document {}", pagesToAdd, i + 1);
for (int j = 0; j < pagesToAdd; j++) {
try {
log.debug(
"Adding page {} (index {}) to document {}",
j + 1,
currentPageIndex,
i + 1);
currentDoc.addPage(sourceDocument.getPage(currentPageIndex));
log.debug("Successfully added page {} to document {}", j + 1, i + 1);
currentPageIndex++;
} catch (Exception e) {
log.error("Error adding page {} to document {}", j + 1, i + 1, e);
throw ExceptionUtils.createFileProcessingException("split", e);
}
}
try {
log.debug("Saving document {} with {} pages", i + 1, pagesToAdd);
saveDocumentToZip(currentDoc, zipOut, baseFilename, fileIndex++);
log.debug("Successfully saved document {}", i + 1);
} catch (Exception e) {
log.error("Error saving document {}", i + 1, e);
throw e;
}
}
log.debug("Completed handleSplitByDocCount with {} documents created", documentCount);
@@ -475,15 +414,24 @@ public class SplitPdfBySizeController {
log.debug("Starting saveDocumentToZip for document part {}", index);
ByteArrayOutputStream outStream = new ByteArrayOutputStream();
try (PDDocument doc = document) {
try {
log.debug("Saving document part {} to byte array", index);
doc.save(outStream);
document.save(outStream);
log.debug("Successfully saved document part {} ({} bytes)", index, outStream.size());
} catch (Exception e) {
log.error("Error saving document part {} to byte array", index, e);
throw ExceptionUtils.createFileProcessingException("split", e);
}
try {
log.debug("Closing document part {}", index);
document.close();
log.debug("Successfully closed document part {}", index);
} catch (Exception e) {
log.error("Error closing document part {}", index, e);
// Continue despite close error
}
try {
// Create a new zip entry
String entryName = baseFilename + "_" + index + ".pdf";
@@ -7,6 +7,7 @@ import java.io.IOException;
import org.apache.pdfbox.multipdf.LayerUtility;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.pdmodel.PDPageContentStream;
import org.apache.pdfbox.pdmodel.common.PDRectangle;
import org.apache.pdfbox.pdmodel.graphics.form.PDFormXObject;
import org.springframework.http.MediaType;
@@ -46,54 +47,53 @@ public class ToSinglePageController {
throws IOException {
// Load the source document
try (PDDocument sourceDocument = pdfDocumentFactory.load(request)) {
// Calculate total height and max width
float totalHeight = 0;
float maxWidth = 0;
for (PDPage page : sourceDocument.getPages()) {
PDRectangle pageSize = page.getMediaBox();
totalHeight += pageSize.getHeight();
maxWidth = Math.max(maxWidth, pageSize.getWidth());
}
PDDocument sourceDocument = pdfDocumentFactory.load(request);
// Create new document and page with calculated dimensions
try (PDDocument newDocument =
pdfDocumentFactory.createNewDocumentBasedOnOldDocument(sourceDocument)) {
PDPage newPage = new PDPage(new PDRectangle(maxWidth, totalHeight));
newDocument.addPage(newPage);
LayerUtility layerUtility = new LayerUtility(newDocument);
float yOffset = totalHeight;
// For each page, copy its content to the new page at the correct offset
try {
layerUtility.wrapInSaveRestore(newPage);
} catch (NullPointerException e) {
}
int pageIndex = 0;
for (PDPage page : sourceDocument.getPages()) {
PDFormXObject form = layerUtility.importPageAsForm(sourceDocument, pageIndex);
if (form != null) {
AffineTransform af =
AffineTransform.getTranslateInstance(
0, yOffset - page.getMediaBox().getHeight());
String defaultLayerName = "Layer" + pageIndex;
layerUtility.appendFormAsLayer(newPage, form, af, defaultLayerName);
}
yOffset -= page.getMediaBox().getHeight();
pageIndex++;
}
ByteArrayOutputStream baos = new ByteArrayOutputStream();
newDocument.save(baos);
byte[] result = baos.toByteArray();
return WebResponseUtils.bytesToWebResponse(
result,
GeneralUtils.generateFilename(
request.getFileInput().getOriginalFilename(), "_singlePage.pdf"));
}
// Calculate total height and max width
float totalHeight = 0;
float maxWidth = 0;
for (PDPage page : sourceDocument.getPages()) {
PDRectangle pageSize = page.getMediaBox();
totalHeight += pageSize.getHeight();
maxWidth = Math.max(maxWidth, pageSize.getWidth());
}
// Create new document and page with calculated dimensions
PDDocument newDocument =
pdfDocumentFactory.createNewDocumentBasedOnOldDocument(sourceDocument);
PDPage newPage = new PDPage(new PDRectangle(maxWidth, totalHeight));
newDocument.addPage(newPage);
// Initialize the content stream of the new page
PDPageContentStream contentStream = new PDPageContentStream(newDocument, newPage);
contentStream.close();
LayerUtility layerUtility = new LayerUtility(newDocument);
float yOffset = totalHeight;
// For each page, copy its content to the new page at the correct offset
int pageIndex = 0;
for (PDPage page : sourceDocument.getPages()) {
PDFormXObject form = layerUtility.importPageAsForm(sourceDocument, pageIndex);
AffineTransform af =
AffineTransform.getTranslateInstance(
0, yOffset - page.getMediaBox().getHeight());
layerUtility.wrapInSaveRestore(newPage);
String defaultLayerName = "Layer" + pageIndex;
layerUtility.appendFormAsLayer(newPage, form, af, defaultLayerName);
yOffset -= page.getMediaBox().getHeight();
pageIndex++;
}
ByteArrayOutputStream baos = new ByteArrayOutputStream();
newDocument.save(baos);
newDocument.close();
sourceDocument.close();
byte[] result = baos.toByteArray();
return WebResponseUtils.bytesToWebResponse(
result,
GeneralUtils.generateFilename(
request.getFileInput().getOriginalFilename(), "_singlePage.pdf"));
}
}
@@ -91,7 +91,8 @@ public class UIDataController {
LicensesData data = new LicensesData();
Resource resource = new ClassPathResource("static/3rdPartyLicenses.json");
try (InputStream is = resource.getInputStream()) {
try {
InputStream is = resource.getInputStream();
String json = new String(is.readAllBytes(), StandardCharsets.UTF_8);
ObjectMapper mapper = new ObjectMapper();
Map<String, List<Dependency>> licenseData =
@@ -380,7 +380,7 @@ public class ConvertImgPDFController {
/**
* Rearranges the pages of the given PDF document based on the specified page order.
*
* @param pdfFile The MultipartFile of the original PDF file.
* @param pdfBytes The byte array of the original PDF file.
* @param pageOrderArr An array of page numbers indicating the new order.
* @return A byte array of the rearranged PDF.
* @throws IOException If an error occurs while processing the PDF.
@@ -388,31 +388,35 @@ public class ConvertImgPDFController {
private byte[] rearrangePdfPages(MultipartFile pdfFile, String[] pageOrderArr)
throws IOException {
// Load the input PDF
try (PDDocument document = pdfDocumentFactory.load(pdfFile);
ByteArrayOutputStream baos = new ByteArrayOutputStream()) {
int totalPages = document.getNumberOfPages();
List<Integer> newPageOrder =
GeneralUtils.parsePageList(pageOrderArr, totalPages, false);
PDDocument document = pdfDocumentFactory.load(pdfFile);
int totalPages = document.getNumberOfPages();
List<Integer> newPageOrder = GeneralUtils.parsePageList(pageOrderArr, totalPages, false);
// Create a new list to hold the pages in the new order
List<PDPage> newPages = new ArrayList<>();
for (int pageIndex : newPageOrder) {
newPages.add(document.getPage(pageIndex));
}
// Remove all the pages from the original document
for (int i = document.getNumberOfPages() - 1; i >= 0; i--) {
document.removePage(i);
}
// Add the pages in the new order
for (PDPage page : newPages) {
document.addPage(page);
}
// Convert PDDocument to byte array
document.save(baos);
return baos.toByteArray();
// Create a new list to hold the pages in the new order
List<PDPage> newPages = new ArrayList<>();
for (int pageIndex : newPageOrder) {
newPages.add(document.getPage(pageIndex));
}
// Remove all the pages from the original document
for (int i = document.getNumberOfPages() - 1; i >= 0; i--) {
document.removePage(i);
}
// Add the pages in the new order
for (PDPage page : newPages) {
document.addPage(page);
}
// Convert PDDocument to byte array
byte[] newPdfBytes;
try (ByteArrayOutputStream baos = new ByteArrayOutputStream()) {
document.save(baos);
newPdfBytes = baos.toByteArray();
} finally {
document.close();
}
return newPdfBytes;
}
}
@@ -1,6 +1,5 @@
package stirling.software.SPDF.controller.api.converters;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.net.URI;
import java.net.http.HttpClient;
@@ -107,6 +106,7 @@ public class ConvertWebsiteToPDF {
Path tempOutputFile = null;
Path tempHtmlInput = null;
PDDocument doc = null;
try {
// Download the remote content first to ensure we don't allow dangerous schemes
String htmlContent = fetchRemoteHtml(URL);
@@ -140,14 +140,18 @@ public class ConvertWebsiteToPDF {
.runCommandWithOutputHandling(command);
// Load the PDF using pdfDocumentFactory
try (PDDocument doc = pdfDocumentFactory.load(tempOutputFile.toFile());
ByteArrayOutputStream baos = new ByteArrayOutputStream()) {
// Convert URL to a safe filename
String outputFilename = convertURLToFileName(URL);
doc = pdfDocumentFactory.load(tempOutputFile.toFile());
doc.save(baos);
return WebResponseUtils.baosToWebResponse(baos, outputFilename);
// Convert URL to a safe filename
String outputFilename = convertURLToFileName(URL);
ResponseEntity<byte[]> response =
WebResponseUtils.pdfDocToWebResponse(doc, outputFilename);
if (response == null) {
// Defensive fallback - should not happen but avoids null returns breaking tests
return ResponseEntity.ok(new byte[0]);
}
return response;
} finally {
if (tempHtmlInput != null) {
try {
@@ -1,6 +1,5 @@
package stirling.software.SPDF.controller.api.misc;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.util.List;
import java.util.Optional;
@@ -18,12 +17,8 @@ import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.SPDF.config.swagger.StandardPdfResponse;
import stirling.software.SPDF.controller.api.converters.ConvertPDFToPDFA;
import stirling.software.SPDF.model.api.misc.AddAttachmentRequest;
import stirling.software.SPDF.model.api.misc.DeleteAttachmentRequest;
import stirling.software.SPDF.model.api.misc.ExtractAttachmentsRequest;
import stirling.software.SPDF.model.api.misc.ListAttachmentsRequest;
import stirling.software.SPDF.model.api.misc.RenameAttachmentRequest;
import stirling.software.SPDF.service.AttachmentServiceInterface;
import stirling.software.common.annotations.AutoJobPostMapping;
import stirling.software.common.annotations.api.MiscApi;
@@ -41,8 +36,6 @@ public class AttachmentController {
private final AttachmentServiceInterface pdfAttachmentService;
private final ConvertPDFToPDFA convertPDFToPDFA;
@AutoJobPostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/add-attachments")
@StandardPdfResponse
@Operation(
@@ -50,87 +43,19 @@ public class AttachmentController {
description =
"This endpoint adds attachments to a PDF. Input:PDF, Output:PDF Type:MISO")
public ResponseEntity<byte[]> addAttachments(@ModelAttribute AddAttachmentRequest request)
throws Exception {
throws IOException {
MultipartFile fileInput = request.getFileInput();
List<MultipartFile> attachments = request.getAttachments();
boolean convertToPdfA3b = request.isConvertToPdfA3b();
validateAttachmentRequest(attachments);
PDDocument document =
pdfAttachmentService.addAttachment(
pdfDocumentFactory.load(fileInput, false), attachments);
String originalFileName = Filenames.toSimpleFileName(fileInput.getOriginalFilename());
if (originalFileName == null || originalFileName.isEmpty()) {
originalFileName = "document";
}
String baseFileName =
originalFileName.contains(".")
? originalFileName.substring(0, originalFileName.lastIndexOf('.'))
: originalFileName;
if (convertToPdfA3b) {
byte[] pdfaBytes;
try (PDDocument document = pdfDocumentFactory.load(request, false)) {
pdfaBytes = convertPDFToPDFA.convertPDDocumentToPDFA(document, "pdfa-3b");
}
try (PDDocument pdfaDocument = org.apache.pdfbox.Loader.loadPDF(pdfaBytes)) {
pdfAttachmentService.addAttachment(pdfaDocument, attachments);
convertPDFToPDFA.ensureEmbeddedFileCompliance(pdfaDocument);
ConvertPDFToPDFA.fixType1FontCharSet(pdfaDocument);
ByteArrayOutputStream baos = new ByteArrayOutputStream();
pdfaDocument.save(baos);
byte[] resultBytes = baos.toByteArray();
String outputFilename = baseFileName + "_with_attachments_PDFA-3b.pdf";
return WebResponseUtils.bytesToWebResponse(
resultBytes, outputFilename, MediaType.APPLICATION_PDF);
}
} else {
try (PDDocument document = pdfDocumentFactory.load(request, false)) {
pdfAttachmentService.addAttachment(document, attachments);
return WebResponseUtils.pdfDocToWebResponse(
document,
GeneralUtils.generateFilename(
Filenames.toSimpleFileName(fileInput.getOriginalFilename()),
"_with_attachments.pdf"));
}
}
}
private void validateAttachmentRequest(List<MultipartFile> attachments) {
if (attachments == null || attachments.isEmpty()) {
throw ExceptionUtils.createIllegalArgumentException(
"error.attachmentsRequired", "At least one attachment is required");
}
final long maxAttachmentSize = 50L * 1024 * 1024; // 50 MB per attachment
final long maxTotalSize = 200L * 1024 * 1024; // 200 MB total
long totalSize = 0;
for (MultipartFile attachment : attachments) {
if (attachment == null || attachment.isEmpty()) {
throw ExceptionUtils.createIllegalArgumentException(
"error.attachmentEmpty", "Attachment files cannot be null or empty");
}
if (attachment.getSize() > maxAttachmentSize) {
throw ExceptionUtils.createIllegalArgumentException(
"error.attachmentTooLarge",
"Attachment ''{0}'' exceeds maximum size of {1} bytes",
attachment.getOriginalFilename(),
maxAttachmentSize);
}
totalSize += attachment.getSize();
}
if (totalSize > maxTotalSize) {
throw ExceptionUtils.createIllegalArgumentException(
"error.totalAttachmentsTooLarge",
"Total attachment size {0} exceeds maximum of {1} bytes",
totalSize,
maxTotalSize);
}
return WebResponseUtils.pdfDocToWebResponse(
document,
GeneralUtils.generateFilename(
Filenames.toSimpleFileName(fileInput.getOriginalFilename()),
"_with_attachments.pdf"));
}
@AutoJobPostMapping(
@@ -163,82 +88,4 @@ public class AttachmentController {
extracted.get(), outputName, MediaType.APPLICATION_OCTET_STREAM);
}
}
@AutoJobPostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/list-attachments")
@Operation(
summary = "List attachments in PDF",
description =
"This endpoint lists all embedded attachments in a PDF. Input:PDF Output:JSON Type:SISO")
public ResponseEntity<List<stirling.software.SPDF.model.api.misc.AttachmentInfo>>
listAttachments(@ModelAttribute ListAttachmentsRequest request) throws IOException {
try (PDDocument document = pdfDocumentFactory.load(request, true)) {
List<stirling.software.SPDF.model.api.misc.AttachmentInfo> attachments =
pdfAttachmentService.listAttachments(document);
return ResponseEntity.ok(attachments);
}
}
@AutoJobPostMapping(
consumes = MediaType.MULTIPART_FORM_DATA_VALUE,
value = "/rename-attachment")
@StandardPdfResponse
@Operation(
summary = "Rename attachment in PDF",
description =
"This endpoint renames an embedded attachment in a PDF. Input:PDF Output:PDF Type:MISO")
public ResponseEntity<byte[]> renameAttachment(@ModelAttribute RenameAttachmentRequest request)
throws Exception {
MultipartFile fileInput = request.getFileInput();
String attachmentName = request.getAttachmentName();
String newName = request.getNewName();
if (attachmentName == null || attachmentName.isBlank()) {
throw ExceptionUtils.createIllegalArgumentException(
"error.attachmentNameRequired", "Attachment name cannot be null or empty");
}
if (newName == null || newName.isBlank()) {
throw ExceptionUtils.createIllegalArgumentException(
"error.newNameRequired", "New attachment name cannot be null or empty");
}
try (PDDocument document = pdfDocumentFactory.load(request, false)) {
pdfAttachmentService.renameAttachment(document, attachmentName, newName);
return WebResponseUtils.pdfDocToWebResponse(
document,
GeneralUtils.generateFilename(
Filenames.toSimpleFileName(fileInput.getOriginalFilename()),
"_attachment_renamed.pdf"));
}
}
@AutoJobPostMapping(
consumes = MediaType.MULTIPART_FORM_DATA_VALUE,
value = "/delete-attachment")
@StandardPdfResponse
@Operation(
summary = "Delete attachment from PDF",
description =
"This endpoint deletes an embedded attachment from a PDF. Input:PDF Output:PDF Type:MISO")
public ResponseEntity<byte[]> deleteAttachment(@ModelAttribute DeleteAttachmentRequest request)
throws Exception {
MultipartFile fileInput = request.getFileInput();
String attachmentName = request.getAttachmentName();
if (attachmentName == null || attachmentName.isBlank()) {
throw ExceptionUtils.createIllegalArgumentException(
"error.attachmentNameRequired", "Attachment name cannot be null or empty");
}
try (PDDocument document = pdfDocumentFactory.load(request, false)) {
pdfAttachmentService.deleteAttachment(document, attachmentName);
return WebResponseUtils.pdfDocToWebResponse(
document,
GeneralUtils.generateFilename(
Filenames.toSimpleFileName(fileInput.getOriginalFilename()),
"_attachment_deleted.pdf"));
}
}
}
@@ -119,9 +119,13 @@ public class AutoSplitPdfController {
MultipartFile file = request.getFileInput();
boolean duplexMode = Boolean.TRUE.equals(request.getDuplexMode());
PDDocument document = null;
List<PDDocument> splitDocuments = new ArrayList<>();
try (TempFile outputTempFile = new TempFile(tempFileManager, ".zip");
PDDocument document = pdfDocumentFactory.load(file.getInputStream())) {
TempFile outputTempFile = null;
try {
outputTempFile = new TempFile(tempFileManager, ".zip");
document = pdfDocumentFactory.load(file.getInputStream());
PDFRenderer pdfRenderer = new PDFRenderer(document);
pdfRenderer.setSubsamplingAllowed(true);
@@ -197,7 +201,15 @@ public class AutoSplitPdfController {
log.error("Error in auto split", e);
throw e;
} finally {
// Clean up split documents
// Clean up resources
if (document != null) {
try {
document.close();
} catch (IOException e) {
log.error("Error closing main PDDocument", e);
}
}
for (PDDocument splitDoc : splitDocuments) {
try {
splitDoc.close();
@@ -205,6 +217,10 @@ public class AutoSplitPdfController {
log.error("Error closing split PDDocument", e);
}
}
if (outputTempFile != null) {
outputTempFile.close();
}
}
}
}
@@ -53,20 +53,18 @@ public class BlankPageController {
// Convert to binary image based on the threshold
int whitePixels = 0;
int width = image.getWidth();
int height = image.getHeight();
int[] pixels = new int[width * height];
int totalPixels = image.getWidth() * image.getHeight();
image.getRGB(0, 0, width, height, pixels, 0, width);
for (int pixel : pixels) {
int blue = pixel & 0xFF;
if (blue >= 255 - threshold) {
whitePixels++;
for (int i = 0; i < image.getHeight(); i++) {
for (int j = 0; j < image.getWidth(); j++) {
int color = image.getRGB(j, i) & 0xFF;
if (color >= 255 - threshold) {
whitePixels++;
}
}
}
double whitePixelPercentage = (whitePixels / (double) (width * height)) * 100;
double whitePixelPercentage = (whitePixels / (double) totalPixels) * 100;
log.info(
String.format(
Locale.ROOT,
@@ -71,15 +71,6 @@ public class ConfigController {
configData.put("contextPath", appConfig.getContextPath());
configData.put("serverPort", appConfig.getServerPort());
// Add frontendUrl for mobile scanner QR codes
String frontendUrl = applicationProperties.getSystem().getFrontendUrl();
configData.put("frontendUrl", frontendUrl != null ? frontendUrl : "");
// Add mobile scanner setting
configData.put(
"enableMobileScanner",
applicationProperties.getSystem().isEnableMobileScanner());
// Extract values from ApplicationProperties
configData.put("appNameNavbar", applicationProperties.getUi().getAppNameNavbar());
configData.put("languages", applicationProperties.getUi().getLanguages());
@@ -93,9 +84,6 @@ public class ConfigController {
boolean enableLogin =
applicationProperties.getSecurity().isEnableLogin() && userService != null;
configData.put("enableLogin", enableLogin);
configData.put(
"showSettingsWhenNoLogin",
applicationProperties.getSystem().isShowSettingsWhenNoLogin());
// Mail settings - check both SMTP enabled AND invites enabled
boolean smtpEnabled = applicationProperties.getMail().isEnabled();
@@ -1,302 +0,0 @@
package stirling.software.SPDF.controller.api.misc;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import org.springframework.core.io.Resource;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
import io.swagger.v3.oas.annotations.Hidden;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.service.MobileScannerService;
import stirling.software.common.service.MobileScannerService.FileMetadata;
/**
* REST controller for mobile scanner functionality. Allows mobile devices to upload scanned images
* that can be retrieved by desktop clients via a session-based system. No authentication required
* for peer-to-peer scanning workflow.
*/
@RestController
@RequestMapping("/api/v1/mobile-scanner")
@Tag(
name = "Mobile Scanner",
description =
"Endpoints for mobile-to-desktop file transfer via QR code scanning. "
+ "Files are temporarily stored and automatically cleaned up after 10 minutes.")
@Hidden
@Slf4j
public class MobileScannerController {
private final MobileScannerService mobileScannerService;
public MobileScannerController(MobileScannerService mobileScannerService) {
this.mobileScannerService = mobileScannerService;
}
/**
* Create a new session (called by desktop when QR code is generated)
*
* @param sessionId Unique session identifier
* @return Session information with expiry time
*/
@PostMapping("/create-session/{sessionId}")
@Operation(
summary = "Create a new mobile scanner session",
description = "Desktop clients call this when generating a QR code")
@ApiResponse(
responseCode = "200",
description = "Session created successfully",
content = @Content(schema = @Schema(implementation = SessionInfoResponse.class)))
@ApiResponse(responseCode = "400", description = "Invalid session ID")
public ResponseEntity<Map<String, Object>> createSession(
@Parameter(description = "Session ID for QR code", required = true) @PathVariable
String sessionId) {
try {
MobileScannerService.SessionInfo sessionInfo =
mobileScannerService.createSession(sessionId);
Map<String, Object> response = new HashMap<>();
response.put("success", true);
response.put("sessionId", sessionInfo.getSessionId());
response.put("createdAt", sessionInfo.getCreatedAt());
response.put("expiresAt", sessionInfo.getExpiresAt());
response.put("timeoutMs", sessionInfo.getTimeoutMs());
return ResponseEntity.ok(response);
} catch (IllegalArgumentException e) {
log.warn("Invalid session creation request: {}", e.getMessage());
return ResponseEntity.badRequest().body(Map.of("error", e.getMessage()));
}
}
/**
* Validate if a session exists and is not expired
*
* @param sessionId Session identifier to validate
* @return Session information if valid, error if invalid/expired
*/
@GetMapping("/validate-session/{sessionId}")
@Operation(
summary = "Validate a mobile scanner session",
description = "Check if session exists and is not expired")
@ApiResponse(
responseCode = "200",
description = "Session is valid",
content = @Content(schema = @Schema(implementation = SessionInfoResponse.class)))
@ApiResponse(responseCode = "404", description = "Session not found or expired")
public ResponseEntity<Map<String, Object>> validateSession(
@Parameter(description = "Session ID to validate", required = true) @PathVariable
String sessionId) {
MobileScannerService.SessionInfo sessionInfo =
mobileScannerService.validateSession(sessionId);
if (sessionInfo == null) {
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(Map.of("valid", false, "error", "Session not found or expired"));
}
Map<String, Object> response = new HashMap<>();
response.put("valid", true);
response.put("sessionId", sessionInfo.getSessionId());
response.put("createdAt", sessionInfo.getCreatedAt());
response.put("expiresAt", sessionInfo.getExpiresAt());
response.put("timeoutMs", sessionInfo.getTimeoutMs());
return ResponseEntity.ok(response);
}
/**
* Upload files from mobile device
*
* @param sessionId Unique session identifier from QR code
* @param files Files to upload
* @return Upload status
*/
@PostMapping("/upload/{sessionId}")
@Operation(
summary = "Upload scanned files from mobile device",
description = "Mobile devices upload scanned images to a temporary session")
@ApiResponse(
responseCode = "200",
description = "Files uploaded successfully",
content = @Content(schema = @Schema(implementation = UploadResponse.class)))
@ApiResponse(responseCode = "400", description = "Invalid session ID or files")
@ApiResponse(responseCode = "500", description = "Upload failed")
public ResponseEntity<Map<String, Object>> uploadFiles(
@Parameter(description = "Session ID from QR code", required = true) @PathVariable
String sessionId,
@Parameter(description = "Files to upload", required = true) @RequestParam("files")
List<MultipartFile> files) {
try {
if (files == null || files.isEmpty()) {
return ResponseEntity.badRequest().body(Map.of("error", "No files provided"));
}
mobileScannerService.uploadFiles(sessionId, files);
Map<String, Object> response = new HashMap<>();
response.put("success", true);
response.put("sessionId", sessionId);
response.put("filesUploaded", files.size());
response.put("message", "Files uploaded successfully");
log.info("Mobile scanner upload: session={}, files={}", sessionId, files.size());
return ResponseEntity.ok(response);
} catch (IllegalArgumentException e) {
log.warn("Invalid mobile scanner upload request: {}", e.getMessage());
return ResponseEntity.badRequest().body(Map.of("error", e.getMessage()));
} catch (IOException e) {
log.error("Failed to upload files for session: {}", sessionId, e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(Map.of("error", "Failed to save files"));
}
}
/**
* Get list of uploaded files for a session
*
* @param sessionId Session identifier
* @return List of file metadata
*/
@GetMapping("/files/{sessionId}")
@Operation(
summary = "Get uploaded files for a session",
description = "Desktop clients poll this endpoint to check for new uploads")
@ApiResponse(
responseCode = "200",
description = "File list retrieved",
content = @Content(schema = @Schema(implementation = FileListResponse.class)))
public ResponseEntity<Map<String, Object>> getSessionFiles(
@Parameter(description = "Session ID", required = true) @PathVariable
String sessionId) {
List<FileMetadata> files = mobileScannerService.getSessionFiles(sessionId);
Map<String, Object> response = new HashMap<>();
response.put("sessionId", sessionId);
response.put("files", files);
response.put("count", files.size());
return ResponseEntity.ok(response);
}
/**
* Download a specific file from a session
*
* @param sessionId Session identifier
* @param filename Filename to download
* @return File content
*/
@GetMapping("/download/{sessionId}/{filename}")
@Operation(
summary = "Download a specific file",
description =
"Download a file that was uploaded to a session. File is automatically deleted after download.")
@ApiResponse(responseCode = "200", description = "File downloaded successfully")
@ApiResponse(responseCode = "404", description = "File or session not found")
public ResponseEntity<Resource> downloadFile(
@Parameter(description = "Session ID", required = true) @PathVariable String sessionId,
@Parameter(description = "Filename to download", required = true) @PathVariable
String filename) {
try {
Path filePath = mobileScannerService.getFile(sessionId, filename);
// Read file into memory first, so we can delete it before sending
byte[] fileBytes = Files.readAllBytes(filePath);
String contentType = Files.probeContentType(filePath);
if (contentType == null) {
contentType = MediaType.APPLICATION_OCTET_STREAM_VALUE;
}
// Delete file immediately after reading into memory (server-side cleanup)
mobileScannerService.deleteFileAfterDownload(sessionId, filename);
// Serve from memory
Resource resource = new org.springframework.core.io.ByteArrayResource(fileBytes);
return ResponseEntity.ok()
.contentType(MediaType.parseMediaType(contentType))
.header(
HttpHeaders.CONTENT_DISPOSITION,
"attachment; filename=\"" + filename + "\"")
.body(resource);
} catch (IOException e) {
log.warn("File not found: session={}, file={}", sessionId, filename);
return ResponseEntity.notFound().build();
}
}
/**
* Delete a session and all its files
*
* @param sessionId Session to delete
* @return Deletion status
*/
@DeleteMapping("/session/{sessionId}")
@Operation(
summary = "Delete a session",
description = "Manually delete a session and all its uploaded files")
@ApiResponse(responseCode = "200", description = "Session deleted successfully")
public ResponseEntity<Map<String, Object>> deleteSession(
@Parameter(description = "Session ID to delete", required = true) @PathVariable
String sessionId) {
mobileScannerService.deleteSession(sessionId);
return ResponseEntity.ok(
Map.of("success", true, "sessionId", sessionId, "message", "Session deleted"));
}
// Response schemas for OpenAPI documentation
private static class SessionInfoResponse {
public boolean success;
public String sessionId;
public long createdAt;
public long expiresAt;
public long timeoutMs;
}
private static class UploadResponse {
public boolean success;
public String sessionId;
public int filesUploaded;
public String message;
}
private static class FileListResponse {
public String sessionId;
public List<FileMetadata> files;
public int count;
}
}
@@ -255,7 +255,6 @@ public class OCRController {
command.add("--force-ocr");
}
}
command.add("--invalidate-digital-signatures");
command.addAll(
Arrays.asList(
@@ -77,40 +77,38 @@ public class PrintFileController {
log.info("Selected Printer: {}", selectedService.getName());
if (MediaType.APPLICATION_PDF_VALUE.equals(contentType)) {
try (PDDocument document = Loader.loadPDF(file.getBytes())) {
PrinterJob job = PrinterJob.getPrinterJob();
job.setPrintService(selectedService);
job.setPageable(new PDFPageable(document));
job.print();
}
PDDocument document = Loader.loadPDF(file.getBytes());
PrinterJob job = PrinterJob.getPrinterJob();
job.setPrintService(selectedService);
job.setPageable(new PDFPageable(document));
job.print();
document.close();
} else if (contentType.startsWith("image/")) {
try (var inputStream = file.getInputStream()) {
BufferedImage image = ImageIO.read(inputStream);
PrinterJob job = PrinterJob.getPrinterJob();
job.setPrintService(selectedService);
job.setPrintable(
new Printable() {
public int print(
Graphics graphics, PageFormat pageFormat, int pageIndex)
throws PrinterException {
if (pageIndex != 0) {
return NO_SUCH_PAGE;
}
Graphics2D g2d = (Graphics2D) graphics;
g2d.translate(
pageFormat.getImageableX(), pageFormat.getImageableY());
g2d.drawImage(
image,
0,
0,
(int) pageFormat.getImageableWidth(),
(int) pageFormat.getImageableHeight(),
null);
return PAGE_EXISTS;
BufferedImage image = ImageIO.read(file.getInputStream());
PrinterJob job = PrinterJob.getPrinterJob();
job.setPrintService(selectedService);
job.setPrintable(
new Printable() {
public int print(
Graphics graphics, PageFormat pageFormat, int pageIndex)
throws PrinterException {
if (pageIndex != 0) {
return NO_SUCH_PAGE;
}
});
job.print();
}
Graphics2D g2d = (Graphics2D) graphics;
g2d.translate(
pageFormat.getImageableX(), pageFormat.getImageableY());
g2d.drawImage(
image,
0,
0,
(int) pageFormat.getImageableWidth(),
(int) pageFormat.getImageableHeight(),
null);
return PAGE_EXISTS;
}
});
job.print();
}
return new ResponseEntity<>(
"File printed successfully to " + selectedService.getName(), HttpStatus.OK);
@@ -133,13 +133,13 @@ public class CertSignController {
signature.setReason(reason);
signature.setSignDate(Calendar.getInstance()); // PDFBox requires Calendar
if (Boolean.TRUE.equals(showSignature)) {
try (SignatureOptions signatureOptions = new SignatureOptions()) {
signatureOptions.setVisualSignature(
instance.createVisibleSignature(doc, signature, pageNumber, showLogo));
signatureOptions.setPage(pageNumber);
SignatureOptions signatureOptions = new SignatureOptions();
signatureOptions.setVisualSignature(
instance.createVisibleSignature(doc, signature, pageNumber, showLogo));
signatureOptions.setPage(pageNumber);
doc.addSignature(signature, instance, signatureOptions);
doc.addSignature(signature, instance, signatureOptions);
}
} else {
doc.addSignature(signature, instance);
}
@@ -1,91 +0,0 @@
package stirling.software.SPDF.controller.api.security;
import java.io.IOException;
import java.util.List;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
import org.verapdf.core.EncryptedPdfException;
import org.verapdf.core.ModelParsingException;
import org.verapdf.core.ValidationException;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.SPDF.model.api.security.PDFVerificationRequest;
import stirling.software.SPDF.model.api.security.PDFVerificationResult;
import stirling.software.SPDF.service.VeraPDFService;
import stirling.software.common.util.ExceptionUtils;
@RestController
@RequestMapping("/api/v1/security")
@Tag(name = "Security", description = "Security APIs")
@RequiredArgsConstructor
@Slf4j
public class VerifyPDFController {
private final VeraPDFService veraPDFService;
@Operation(
summary = "Verify PDF Standards Compliance",
description =
"Validates PDF files against the standards declared in their metadata. "
+ "Automatically detects PDF/A, PDF/UA-1, PDF/UA-2, and WTPDF standards "
+ "from the document's XMP metadata and validates compliance. "
+ "Input:PDF Output:JSON Type:SISO")
@PostMapping(value = "/verify-pdf", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public ResponseEntity<List<PDFVerificationResult>> verifyPDF(
@ModelAttribute PDFVerificationRequest request) {
MultipartFile file = request.getFileInput();
if (file == null || file.isEmpty()) {
throw ExceptionUtils.createRuntimeException(
"error.pdfRequired", "PDF file is required", null);
}
try {
log.info("Detecting and verifying standards in PDF '{}'", file.getOriginalFilename());
List<PDFVerificationResult> results = veraPDFService.validatePDF(file.getInputStream());
log.info(
"Verification complete for '{}': {} standard(s) checked",
file.getOriginalFilename(),
results.size());
return ResponseEntity.ok(results);
} catch (ValidationException e) {
log.error("Validation exception for file: {}", file.getOriginalFilename(), e);
throw ExceptionUtils.createRuntimeException(
"error.validationFailed", "PDF validation failed: {0}", e, e.getMessage());
} catch (ModelParsingException e) {
log.error("Model parsing exception for file: {}", file.getOriginalFilename(), e);
throw ExceptionUtils.createRuntimeException(
"error.modelParsingFailed", "PDF model parsing failed: {0}", e, e.getMessage());
} catch (EncryptedPdfException e) {
log.error("Encrypted PDF exception for file: {}", file.getOriginalFilename(), e);
throw ExceptionUtils.createRuntimeException(
"error.encryptedPdf",
"Cannot verify encrypted PDF. Please remove password first: {0}",
e,
e.getMessage());
} catch (IOException e) {
log.error("IO exception for file: {}", file.getOriginalFilename(), e);
throw ExceptionUtils.createRuntimeException(
"error.ioException",
"IO error during PDF verification: {0}",
e,
e.getMessage());
}
}
}
@@ -45,7 +45,8 @@ public class HomeWebController {
public String licensesForm(Model model) {
model.addAttribute("currentPage", "licenses");
Resource resource = new ClassPathResource("static/3rdPartyLicenses.json");
try (InputStream is = resource.getInputStream()) {
try {
InputStream is = resource.getInputStream();
String json = new String(is.readAllBytes(), StandardCharsets.UTF_8);
ObjectMapper mapper = new ObjectMapper();
Map<String, List<Dependency>> data = mapper.readValue(json, new TypeReference<>() {});
@@ -115,13 +115,13 @@ public class ReactRoutingController {
}
@GetMapping(
"/{path:^(?!api|static|robots\\.txt|favicon\\.ico|manifest.*\\.json|pipeline|pdfjs|pdfjs-legacy|pdfium|vendor|fonts|images|files|css|js|assets|locales|modern-logo|classic-logo|Login|og_images|samples)[^\\.]*$}")
"/{path:^(?!api|static|robots\\.txt|favicon\\.ico|manifest.*\\.json|pipeline|pdfjs|pdfjs-legacy|pdfium|fonts|images|files|css|js|assets|locales|modern-logo|classic-logo|Login|og_images|samples)[^\\.]*$}")
public ResponseEntity<String> forwardRootPaths(HttpServletRequest request) throws IOException {
return serveIndexHtml(request);
}
@GetMapping(
"/{path:^(?!api|static|pipeline|pdfjs|pdfjs-legacy|pdfium|vendor|fonts|images|files|css|js|assets|locales|modern-logo|classic-logo|Login|og_images|samples)[^\\.]*}/{subpath:^(?!.*\\.).*$}")
"/{path:^(?!api|static|pipeline|pdfjs|pdfjs-legacy|pdfium|fonts|images|files|css|js|assets|locales|modern-logo|classic-logo|Login|og_images|samples)[^\\.]*}/{subpath:^(?!.*\\.).*$}")
public ResponseEntity<String> forwardNestedPaths(HttpServletRequest request)
throws IOException {
return serveIndexHtml(request);
@@ -12,7 +12,6 @@ import org.springframework.http.HttpStatus;
import org.springframework.http.ProblemDetail;
import org.springframework.http.ResponseEntity;
import org.springframework.http.converter.HttpMessageNotReadableException;
import org.springframework.web.HttpMediaTypeNotAcceptableException;
import org.springframework.web.HttpMediaTypeNotSupportedException;
import org.springframework.web.HttpRequestMethodNotSupportedException;
import org.springframework.web.bind.MethodArgumentNotValidException;
@@ -24,7 +23,6 @@ import org.springframework.web.multipart.support.MissingServletRequestPartExcept
import org.springframework.web.servlet.NoHandlerFoundException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
@@ -810,56 +808,6 @@ public class GlobalExceptionHandler {
.body(problemDetail);
}
/**
* Handle 406 Not Acceptable errors when error responses cannot match client Accept header.
*
* <p>When thrown: When the client sends Accept: application/pdf but the server needs to return
* a JSON error response (e.g., when an attachment is not found).
*
* <p>This handler writes directly to HttpServletResponse to bypass Spring's content negotiation
* and ensure error responses are always delivered as JSON.
*
* @param ex the HttpMediaTypeNotAcceptableException
* @param request the HTTP servlet request
* @param response the HTTP servlet response
*/
@ExceptionHandler(HttpMediaTypeNotAcceptableException.class)
public void handleMediaTypeNotAcceptable(
HttpMediaTypeNotAcceptableException ex,
HttpServletRequest request,
HttpServletResponse response)
throws IOException {
log.warn(
"Media type not acceptable at {}: client accepts {}, server supports {}",
request.getRequestURI(),
request.getHeader("Accept"),
ex.getSupportedMediaTypes());
// Write JSON error response directly, bypassing content negotiation
response.setStatus(HttpStatus.NOT_ACCEPTABLE.value());
response.setContentType("application/problem+json");
response.setCharacterEncoding("UTF-8");
String errorJson =
String.format(
"""
{
"type": "about:blank",
"title": "Not Acceptable",
"status": 406,
"detail": "The requested resource could not be returned in an acceptable format. Error responses are returned as JSON.",
"instance": "%s",
"timestamp": "%s",
"hints": ["Error responses are always returned as application/json or application/problem+json", "Set Accept header to include application/json for proper error handling"]
}
""",
request.getRequestURI(), Instant.now().toString());
response.getWriter().write(errorJson);
response.getWriter().flush();
}
// ===========================================================================================
// JAVA STANDARD EXCEPTIONS
// ===========================================================================================
@@ -1015,8 +963,9 @@ public class GlobalExceptionHandler {
// Check if this RuntimeException wraps a typed exception from job execution
Throwable cause = ex.getCause();
if (cause instanceof BaseAppException appEx) {
if (cause instanceof BaseAppException) {
// Delegate to specific BaseAppException handlers
BaseAppException appEx = (BaseAppException) cause;
if (appEx instanceof PdfPasswordException) {
return handlePdfPassword((PdfPasswordException) appEx, request);
} else if (appEx instanceof PdfCorruptedException
@@ -1030,8 +979,9 @@ public class GlobalExceptionHandler {
} else {
return handleBaseApp(appEx, request);
}
} else if (cause instanceof BaseValidationException valEx) {
} else if (cause instanceof BaseValidationException) {
// Delegate to validation exception handlers
BaseValidationException valEx = (BaseValidationException) cause;
if (valEx instanceof CbrFormatException
|| valEx instanceof CbzFormatException
|| valEx instanceof EmlFormatException) {
@@ -1042,9 +992,6 @@ public class GlobalExceptionHandler {
} else if (cause instanceof IOException) {
// Unwrap and handle IOException (may contain PDF-specific errors)
return handleIOException((IOException) cause, request);
} else if (cause instanceof IllegalArgumentException) {
// Unwrap and handle IllegalArgumentException (business logic validation errors)
return handleIllegalArgument((IllegalArgumentException) cause, request);
}
// Not a wrapped exception - treat as unexpected error
@@ -20,10 +20,4 @@ public class AddAttachmentRequest extends PDFFile {
requiredMode = Schema.RequiredMode.REQUIRED,
format = "binary")
private List<MultipartFile> attachments;
@Schema(
description = "Convert the resulting PDF to PDF/A-3b format after adding attachments",
requiredMode = Schema.RequiredMode.NOT_REQUIRED,
defaultValue = "false")
private boolean convertToPdfA3b = false;
}
@@ -1,17 +0,0 @@
package stirling.software.SPDF.model.api.misc;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@NoArgsConstructor
@AllArgsConstructor
public class AttachmentInfo {
private String filename;
private Long size;
private String contentType;
private String description;
private String creationDate;
private String modificationDate;
}
@@ -1,18 +0,0 @@
package stirling.software.SPDF.model.api.misc;
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 DeleteAttachmentRequest extends PDFFile {
@Schema(
description = "The name of the attachment to delete",
requiredMode = Schema.RequiredMode.REQUIRED)
private String attachmentName;
}
@@ -1,10 +0,0 @@
package stirling.software.SPDF.model.api.misc;
import lombok.Data;
import lombok.EqualsAndHashCode;
import stirling.software.common.model.api.PDFFile;
@Data
@EqualsAndHashCode(callSuper = true)
public class ListAttachmentsRequest extends PDFFile {}
@@ -1,23 +0,0 @@
package stirling.software.SPDF.model.api.misc;
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 RenameAttachmentRequest extends PDFFile {
@Schema(
description = "The current name of the attachment to rename",
requiredMode = Schema.RequiredMode.REQUIRED)
private String attachmentName;
@Schema(
description = "The new name for the attachment",
requiredMode = Schema.RequiredMode.REQUIRED)
private String newName;
}
@@ -1,10 +0,0 @@
package stirling.software.SPDF.model.api.security;
import lombok.Data;
import lombok.EqualsAndHashCode;
import stirling.software.common.model.api.PDFFile;
@Data
@EqualsAndHashCode(callSuper = true)
public class PDFVerificationRequest extends PDFFile {}
@@ -1,48 +0,0 @@
package stirling.software.SPDF.model.api.security;
import java.util.ArrayList;
import java.util.List;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@NoArgsConstructor
@AllArgsConstructor
public class PDFVerificationResult {
private String standard;
private String standardName;
private String validationProfile;
private String validationProfileName;
private String complianceSummary;
private boolean declaredPdfa;
private boolean compliant;
private int totalFailures;
private int totalWarnings;
private List<ValidationIssue> failures = new ArrayList<>();
private List<ValidationIssue> warnings = new ArrayList<>();
public void addFailure(ValidationIssue failure) {
this.failures.add(failure);
this.totalFailures = this.failures.size();
}
public void addWarning(ValidationIssue warning) {
this.warnings.add(warning);
this.totalWarnings = this.warnings.size();
}
@Data
@NoArgsConstructor
@AllArgsConstructor
public static class ValidationIssue {
private String ruleId;
private String message;
private String location;
private String specification;
private String clause;
private String testNumber;
}
}
@@ -8,7 +8,6 @@ import java.nio.file.attribute.FileTime;
import java.time.Instant;
import java.time.ZoneId;
import java.time.ZonedDateTime;
import java.util.ArrayList;
import java.util.GregorianCalendar;
import java.util.HashMap;
import java.util.HashSet;
@@ -37,9 +36,6 @@ import io.github.pixee.security.Filenames;
import lombok.extern.slf4j.Slf4j;
import stirling.software.SPDF.model.api.misc.AttachmentInfo;
import stirling.software.common.util.ExceptionUtils;
@Slf4j
@Service
public class AttachmentService implements AttachmentServiceInterface {
@@ -220,142 +216,6 @@ public class AttachmentService implements AttachmentServiceInterface {
}
}
@Override
public List<AttachmentInfo> listAttachments(PDDocument document) throws IOException {
List<AttachmentInfo> attachments = new ArrayList<>();
PDDocumentCatalog catalog = document.getDocumentCatalog();
if (catalog == null) {
return attachments;
}
PDDocumentNameDictionary documentNames = catalog.getNames();
if (documentNames == null) {
return attachments;
}
PDEmbeddedFilesNameTreeNode embeddedFilesTree = documentNames.getEmbeddedFiles();
if (embeddedFilesTree == null) {
return attachments;
}
Map<String, PDComplexFileSpecification> embeddedFiles = new LinkedHashMap<>();
collectEmbeddedFiles(embeddedFilesTree, embeddedFiles);
for (Map.Entry<String, PDComplexFileSpecification> entry : embeddedFiles.entrySet()) {
PDComplexFileSpecification fileSpecification = entry.getValue();
PDEmbeddedFile embeddedFile = getEmbeddedFile(fileSpecification);
if (embeddedFile != null) {
String filename = determineFilename(entry.getKey(), fileSpecification);
String description = fileSpecification.getFileDescription();
String contentType = embeddedFile.getSubtype();
Long size = (long) embeddedFile.getSize();
String creationDate = null;
if (embeddedFile.getCreationDate() != null) {
creationDate = embeddedFile.getCreationDate().getTime().toString();
}
String modificationDate = null;
if (embeddedFile.getModDate() != null) {
modificationDate = embeddedFile.getModDate().getTime().toString();
}
AttachmentInfo attachmentInfo =
new AttachmentInfo(
filename,
size,
contentType,
description,
creationDate,
modificationDate);
attachments.add(attachmentInfo);
}
}
return attachments;
}
@Override
public PDDocument renameAttachment(PDDocument document, String attachmentName, String newName)
throws IOException {
PDEmbeddedFilesNameTreeNode embeddedFilesTree = getEmbeddedFilesTree(document);
Map<String, PDComplexFileSpecification> allEmbeddedFiles = new LinkedHashMap<>();
collectEmbeddedFiles(embeddedFilesTree, allEmbeddedFiles);
PDComplexFileSpecification fileToRename = null;
String keyToRename = null;
for (Map.Entry<String, PDComplexFileSpecification> entry : allEmbeddedFiles.entrySet()) {
String currentName = determineFilename(entry.getKey(), entry.getValue());
if (currentName.equals(attachmentName)) {
fileToRename = entry.getValue();
keyToRename = entry.getKey();
break;
}
}
if (fileToRename == null || keyToRename == null) {
log.warn("Attachment '{}' not found for renaming", attachmentName);
throw ExceptionUtils.createIllegalArgumentException(
"error.attachmentNotFound",
"Attachment ''{0}'' not found for renaming",
attachmentName);
}
fileToRename.setFile(newName);
fileToRename.setFileUnicode(newName);
allEmbeddedFiles.remove(keyToRename);
allEmbeddedFiles.put(newName, fileToRename);
embeddedFilesTree.setKids(null);
embeddedFilesTree.setNames(allEmbeddedFiles);
log.info("Renamed attachment from '{}' to '{}'", attachmentName, newName);
return document;
}
@Override
public PDDocument deleteAttachment(PDDocument document, String attachmentName)
throws IOException {
PDEmbeddedFilesNameTreeNode embeddedFilesTree = getEmbeddedFilesTree(document);
Map<String, PDComplexFileSpecification> allEmbeddedFiles = new LinkedHashMap<>();
collectEmbeddedFiles(embeddedFilesTree, allEmbeddedFiles);
String keyToRemove = null;
for (Map.Entry<String, PDComplexFileSpecification> entry : allEmbeddedFiles.entrySet()) {
String currentName = determineFilename(entry.getKey(), entry.getValue());
if (currentName.equals(attachmentName)) {
keyToRemove = entry.getKey();
break;
}
}
if (keyToRemove == null) {
log.warn("Attachment '{}' not found for deletion", attachmentName);
throw ExceptionUtils.createIllegalArgumentException(
"error.attachmentNotFound",
"Attachment ''{0}'' not found for deletion",
attachmentName);
}
allEmbeddedFiles.remove(keyToRemove);
embeddedFilesTree.setKids(null);
embeddedFilesTree.setNames(allEmbeddedFiles);
log.info("Deleted attachment: '{}'", attachmentName);
return document;
}
private String sanitizeFilename(String candidate) {
String sanitized = Filenames.toSimpleFileName(candidate);
if (StringUtils.isBlank(sanitized)) {
@@ -7,19 +7,10 @@ import java.util.Optional;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.springframework.web.multipart.MultipartFile;
import stirling.software.SPDF.model.api.misc.AttachmentInfo;
public interface AttachmentServiceInterface {
PDDocument addAttachment(PDDocument document, List<MultipartFile> attachments)
throws IOException;
Optional<byte[]> extractAttachments(PDDocument document) throws IOException;
List<AttachmentInfo> listAttachments(PDDocument document) throws IOException;
PDDocument renameAttachment(PDDocument document, String attachmentName, String newName)
throws IOException;
PDDocument deleteAttachment(PDDocument document, String attachmentName) throws IOException;
}
@@ -423,38 +423,38 @@ public class CertificateValidationService {
private void loadBundledMozillaCACerts() {
try {
log.info("Loading bundled Mozilla CA certificates from resources");
try (InputStream certStream =
getClass().getClassLoader().getResourceAsStream("certs/cacert.pem")) {
if (certStream == null) {
log.warn("Bundled Mozilla CA certificate file not found in resources");
return;
}
InputStream certStream =
getClass().getClassLoader().getResourceAsStream("certs/cacert.pem");
if (certStream == null) {
log.warn("Bundled Mozilla CA certificate file not found in resources");
return;
}
CertificateFactory cf = CertificateFactory.getInstance("X.509");
Collection<? extends Certificate> certs = cf.generateCertificates(certStream);
CertificateFactory cf = CertificateFactory.getInstance("X.509");
Collection<? extends Certificate> certs = cf.generateCertificates(certStream);
certStream.close();
int loadedCount = 0;
int skippedCount = 0;
int loadedCount = 0;
int skippedCount = 0;
for (Certificate cert : certs) {
if (cert instanceof X509Certificate x509) {
// Only add CA certificates to trust anchors
if (isCA(x509)) {
String fingerprint = sha256Fingerprint(x509);
String alias = "mozilla-" + fingerprint;
signingTrustAnchors.setCertificateEntry(alias, x509);
loadedCount++;
} else {
skippedCount++;
}
for (Certificate cert : certs) {
if (cert instanceof X509Certificate x509) {
// Only add CA certificates to trust anchors
if (isCA(x509)) {
String fingerprint = sha256Fingerprint(x509);
String alias = "mozilla-" + fingerprint;
signingTrustAnchors.setCertificateEntry(alias, x509);
loadedCount++;
} else {
skippedCount++;
}
}
log.info(
"Loaded {} Mozilla CA certificates as trust anchors (skipped {} non-CA certs)",
loadedCount,
skippedCount);
}
log.info(
"Loaded {} Mozilla CA certificates as trust anchors (skipped {} non-CA certs)",
loadedCount,
skippedCount);
} catch (Exception e) {
log.error("Failed to load bundled Mozilla CA certificates: {}", e.getMessage(), e);
}
@@ -1,319 +0,0 @@
package stirling.software.SPDF.service;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.util.ArrayList;
import java.util.List;
import org.springframework.stereotype.Service;
import org.verapdf.core.EncryptedPdfException;
import org.verapdf.core.ModelParsingException;
import org.verapdf.core.ValidationException;
import org.verapdf.gf.foundry.VeraGreenfieldFoundryProvider;
import org.verapdf.pdfa.Foundries;
import org.verapdf.pdfa.PDFAParser;
import org.verapdf.pdfa.PDFAValidator;
import org.verapdf.pdfa.flavours.PDFAFlavour;
import org.verapdf.pdfa.flavours.PDFFlavours;
import org.verapdf.pdfa.results.TestAssertion;
import org.verapdf.pdfa.results.ValidationResult;
import jakarta.annotation.PostConstruct;
import lombok.extern.slf4j.Slf4j;
import stirling.software.SPDF.model.api.security.PDFVerificationResult;
@Service
@Slf4j
public class VeraPDFService {
private static final String NOT_PDFA_STANDARD_ID = "not-pdfa";
private static final String NOT_PDFA_STANDARD_NAME =
"Not PDF/A (no PDF/A identification metadata)";
private static PDFVerificationResult convertToVerificationResult(
ValidationResult result, PDFAFlavour declaredFlavour, PDFAFlavour validationFlavour) {
PDFVerificationResult verificationResult = new PDFVerificationResult();
PDFAFlavour validationProfile =
validationFlavour != null ? validationFlavour : result.getPDFAFlavour();
boolean validationIsPdfa = isPdfaFlavour(validationProfile);
if (validationProfile != null) {
verificationResult.setValidationProfile(validationProfile.getId());
verificationResult.setValidationProfileName(getStandardName(validationProfile));
}
if (declaredFlavour != null) {
verificationResult.setStandard(declaredFlavour.getId());
verificationResult.setDeclaredPdfa(isPdfaFlavour(declaredFlavour));
} else if (validationProfile != null && !validationIsPdfa) {
verificationResult.setStandard(validationProfile.getId());
verificationResult.setDeclaredPdfa(false);
} else {
verificationResult.setStandard(NOT_PDFA_STANDARD_ID);
verificationResult.setDeclaredPdfa(false);
}
for (TestAssertion assertion : result.getTestAssertions()) {
if (assertion.getStatus() == TestAssertion.Status.FAILED) {
PDFVerificationResult.ValidationIssue issue = createValidationIssue(assertion);
verificationResult.addFailure(issue);
}
}
verificationResult.setCompliant(result.isCompliant());
String baseName;
if (declaredFlavour != null) {
baseName = getStandardName(declaredFlavour);
} else if (validationIsPdfa) {
baseName = NOT_PDFA_STANDARD_NAME;
} else if (validationProfile != null) {
baseName = getStandardName(validationProfile);
} else {
baseName = "Unknown standard";
}
String standardDisplay =
formatStandardDisplay(
baseName,
verificationResult.getTotalFailures(),
isPdfaFlavour(declaredFlavour),
validationIsPdfa && declaredFlavour == null);
verificationResult.setStandardName(standardDisplay);
verificationResult.setComplianceSummary(standardDisplay);
log.debug(
"Validation complete for profile {} (declared: {}): {} failures",
validationProfile != null ? validationProfile.getId() : "unknown",
declaredFlavour != null ? declaredFlavour.getId() : NOT_PDFA_STANDARD_ID,
verificationResult.getTotalFailures());
return verificationResult;
}
private static PDFVerificationResult.ValidationIssue createValidationIssue(
TestAssertion assertion) {
PDFVerificationResult.ValidationIssue issue = new PDFVerificationResult.ValidationIssue();
if (assertion.getRuleId() != null) {
issue.setRuleId(assertion.getRuleId().toString());
issue.setClause(assertion.getRuleId().getClause());
if (assertion.getRuleId().getSpecification() != null) {
issue.setSpecification(assertion.getRuleId().getSpecification().toString());
}
int testNumber = assertion.getRuleId().getTestNumber();
if (testNumber > 0) {
issue.setTestNumber(String.valueOf(testNumber));
}
}
issue.setMessage(assertion.getMessage());
issue.setLocation(
assertion.getLocation() != null ? assertion.getLocation().toString() : "Unknown");
return issue;
}
private static PDFVerificationResult createNoPdfaDeclarationResult() {
PDFVerificationResult result = new PDFVerificationResult();
result.setStandard(NOT_PDFA_STANDARD_ID);
result.setStandardName(NOT_PDFA_STANDARD_NAME);
result.setComplianceSummary(NOT_PDFA_STANDARD_NAME);
result.setCompliant(false);
result.setDeclaredPdfa(false);
PDFVerificationResult.ValidationIssue issue = new PDFVerificationResult.ValidationIssue();
issue.setMessage("Document does not declare PDF/A compliance in its XMP metadata.");
issue.setSpecification("XMP pdfaid");
result.addFailure(issue);
return result;
}
private static PDFVerificationResult buildErrorResult(
PDFAFlavour declaredFlavour, PDFAFlavour validationFlavour, String errorMessage) {
PDFVerificationResult errorResult = new PDFVerificationResult();
PDFAFlavour declaredForResult =
isPdfaFlavour(validationFlavour) ? declaredFlavour : validationFlavour;
if (declaredForResult != null) {
errorResult.setStandard(declaredForResult.getId());
errorResult.setStandardName(getStandardName(declaredForResult) + " with errors");
errorResult.setDeclaredPdfa(isPdfaFlavour(declaredForResult));
} else if (isPdfaFlavour(validationFlavour)) {
errorResult.setStandard(NOT_PDFA_STANDARD_ID);
errorResult.setStandardName(NOT_PDFA_STANDARD_NAME);
errorResult.setDeclaredPdfa(false);
} else {
errorResult.setStandard(
validationFlavour != null ? validationFlavour.getId() : NOT_PDFA_STANDARD_ID);
errorResult.setStandardName(
(validationFlavour != null
? getStandardName(validationFlavour)
: "Unknown standard")
+ " with errors");
errorResult.setDeclaredPdfa(false);
}
errorResult.setValidationProfile(
validationFlavour != null ? validationFlavour.getId() : NOT_PDFA_STANDARD_ID);
errorResult.setValidationProfileName(
validationFlavour != null
? getStandardName(validationFlavour)
: "Unknown standard");
errorResult.setComplianceSummary(errorResult.getStandardName());
errorResult.setCompliant(false);
PDFVerificationResult.ValidationIssue failure = new PDFVerificationResult.ValidationIssue();
failure.setMessage(errorMessage);
errorResult.addFailure(failure);
return errorResult;
}
@PostConstruct
public void initialize() {
try {
VeraGreenfieldFoundryProvider.initialise();
log.info("VeraPDF Greenfield initialized successfully");
} catch (Exception e) {
log.error("Failed to initialize VeraPDF", e);
}
}
public List<PDFVerificationResult> validatePDF(InputStream pdfStream)
throws IOException, ValidationException, ModelParsingException, EncryptedPdfException {
byte[] pdfBytes = pdfStream.readAllBytes();
List<PDFVerificationResult> results = new ArrayList<>();
PDFAFlavour declaredFlavour;
List<PDFAFlavour> detectedFlavours;
try (PDFAParser detectionParser =
Foundries.defaultInstance().createParser(new ByteArrayInputStream(pdfBytes))) {
declaredFlavour = detectionParser.getFlavour();
detectedFlavours = detectionParser.getFlavours();
}
List<PDFAFlavour> flavoursToValidate = new ArrayList<>();
boolean hasPdfaDeclaration = isPdfaFlavour(declaredFlavour);
if (declaredFlavour != null) {
flavoursToValidate.add(declaredFlavour);
}
for (PDFAFlavour flavour : detectedFlavours) {
if (flavour.equals(declaredFlavour)) {
continue;
}
if (PDFFlavours.isFlavourFamily(flavour, PDFAFlavour.SpecificationFamily.PDF_A)) {
if (hasPdfaDeclaration) {
flavoursToValidate.add(flavour);
} else {
log.debug(
"Ignoring detected PDF/A flavour {} because no PDF/A declaration exists in XMP",
flavour.getId());
}
} else if (PDFFlavours.isFlavourFamily(flavour, PDFAFlavour.SpecificationFamily.PDF_UA)
|| PDFFlavours.isFlavourFamily(
flavour, PDFAFlavour.SpecificationFamily.WTPDF)) {
flavoursToValidate.add(flavour);
}
}
if (!hasPdfaDeclaration) {
results.add(createNoPdfaDeclarationResult());
}
if (flavoursToValidate.isEmpty()) {
log.info("No verifiable PDF/A, PDF/UA, or WTPDF standards declared via XMP metadata");
return results;
}
for (PDFAFlavour flavour : flavoursToValidate) {
try (PDFAParser parser =
Foundries.defaultInstance()
.createParser(new ByteArrayInputStream(pdfBytes), flavour)) {
PDFAFlavour parserDeclared = parser.getFlavour();
PDFAValidator validator =
Foundries.defaultInstance().createValidator(flavour, false);
ValidationResult result = validator.validate(parser);
PDFAFlavour declaredForResult =
PDFFlavours.isFlavourFamily(flavour, PDFAFlavour.SpecificationFamily.PDF_A)
? parserDeclared
: flavour;
results.add(convertToVerificationResult(result, declaredForResult, flavour));
} catch (Exception e) {
log.error("Error validating standard {}: {}", flavour.getId(), e.getMessage());
results.add(
buildErrorResult(
declaredFlavour, flavour, "Validation error: " + e.getMessage()));
}
}
return results;
}
private static boolean isPdfaFlavour(PDFAFlavour flavour) {
return PDFFlavours.isFlavourFamily(flavour, PDFAFlavour.SpecificationFamily.PDF_A);
}
private static String formatStandardDisplay(
String baseName,
int errorCount,
boolean declaredPdfa,
boolean inferredPdfaWithoutDeclaration) {
if (inferredPdfaWithoutDeclaration) {
return NOT_PDFA_STANDARD_NAME;
}
if (!declaredPdfa && NOT_PDFA_STANDARD_NAME.equals(baseName)) {
return NOT_PDFA_STANDARD_NAME;
}
if (errorCount > 0) {
return baseName + " with errors";
}
return baseName + " compliant";
}
private static String getStandardName(PDFAFlavour flavour) {
String id = flavour.getId();
String part = flavour.getPart().toString();
String level = flavour.getLevel().toString();
// PDF/A standards - Fixed: proper length check and parentheses
if (!id.isEmpty()
&& (id.charAt(0) == '1'
|| id.charAt(0) == '2'
|| id.charAt(0) == '3'
|| id.charAt(0) == '4')) {
return "PDF/A-" + part + (level.isEmpty() ? "" : level);
}
// PDF/UA standards
else if (id.contains("ua")) {
return "PDF/UA-" + part;
}
// WTPDF standards
else if (id.contains("wtpdf")) {
return "WTPDF " + part;
}
return flavour.toString();
}
}
@@ -132,7 +132,6 @@ system:
enableAlphaFunctionality: false # set to enable functionality which might need more testing before it fully goes live (this feature might make no changes)
showUpdate: false # see when a new update is available
showUpdateOnlyAdmin: false # only admins can see when a new update is available, depending on showUpdate it must be set to 'true'
showSettingsWhenNoLogin: true # set to 'false' to hide settings button when login is disabled (enableLogin: false). Only applies when login is disabled.
customHTMLFiles: false # enable to have files placed in /customFiles/templates override the existing template HTML files
tessdataDir: "" # path to the directory containing the Tessdata files. This setting is relevant for Windows systems. For Windows users, this path should be adjusted to point to the appropriate directory where the Tessdata files are stored.
enableAnalytics: null # Master toggle for analytics: set to 'true' to enable all analytics, 'false' to disable all analytics, or leave as 'null' to prompt admin on first launch
@@ -145,7 +144,6 @@ system:
corsAllowedOrigins: [] # List of allowed origins for CORS (e.g. ['http://localhost:5173', 'https://app.example.com']). Leave empty to disable CORS. For local development with frontend on port 5173, add 'http://localhost:5173'
backendUrl: '' # Backend base URL for SAML/OAuth/API callbacks (e.g. 'http://localhost:8080' for dev, 'https://api.example.com' for production). REQUIRED for SSO authentication to work correctly. This is where your IdP will send SAML responses and OAuth callbacks. Leave empty to default to 'http://localhost:8080' in development.
frontendUrl: '' # Frontend URL for invite email links (e.g. 'https://app.example.com'). Optional - if not set, will use backendUrl. This is the URL users click in invite emails.
enableMobileScanner: false # Enable mobile phone QR code upload feature. Requires frontendUrl to be configured.
serverCertificate:
enabled: true # Enable server-side certificate for "Sign with Stirling-PDF" option
organizationName: Stirling-PDF # Organization name for generated certificates
@@ -1,268 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="description" content="Stirling-PDF API Server">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Stirling-PDF - API Server</title>
<!-- Icons -->
<link rel="apple-touch-icon" sizes="180x180" href="apple-touch-icon.png">
<link rel="icon" type="image/png" sizes="32x32" href="favicon-32x32.png">
<link rel="icon" type="image/png" sizes="16x16" href="favicon-16x16.png">
<link rel="shortcut icon" href="favicon.ico">
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
background-color: #f3f4f6;
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 1.5rem 1.5rem 0;
overflow: auto;
}
.auth-card {
width: min(45rem, 96vw);
background-color: #ffffff;
border-radius: 1.25rem;
box-shadow: 0 1.25rem 3.75rem rgba(0, 0, 0, 0.12);
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
padding: 2rem;
}
.auth-content {
max-width: 26.25rem;
width: 100%;
}
.login-header {
margin-bottom: 1.5rem;
margin-top: 0.5rem;
}
.login-header-logos {
display: flex;
align-items: center;
gap: 0.75rem;
margin-bottom: 1.25rem;
}
.login-logo-icon {
width: 2.5rem;
height: 2.5rem;
border-radius: 0.5rem;
}
.login-logo-text {
height: 2rem;
}
.login-title {
font-size: 2rem;
font-weight: 800;
color: #111827;
margin: 0 0 0.375rem;
}
.login-subtitle {
color: #6b7280;
font-size: 0.875rem;
margin: 0;
}
.section {
margin: 1.5rem 0 0.75rem;
}
.section-title {
font-size: 1rem;
font-weight: 600;
color: #374151;
margin-bottom: 0.5rem;
}
.section-text {
font-size: 0.875rem;
color: #6b7280;
line-height: 1.5;
margin-bottom: 0.75rem;
}
.section-list {
font-size: 0.875rem;
color: #6b7280;
line-height: 1.6;
margin-left: 1.25rem;
margin-bottom: 0.75rem;
}
.section-list li {
margin-bottom: 0.5rem;
}
.section-list a,
.section-text a {
color: #AF3434;
text-decoration: none;
font-weight: 500;
}
.section-list a:hover,
.section-text a:hover {
text-decoration: underline;
}
.code-inline {
background-color: #f3f4f6;
padding: 0.2rem 0.4rem;
border-radius: 0.25rem;
font-family: 'Courier New', monospace;
font-size: 0.8125rem;
color: #374151;
}
.cta-button {
width: 100%;
padding: 0.75rem 1rem;
background-color: #AF3434;
color: white;
border: none;
border-radius: 0.625rem;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
text-decoration: none;
display: inline-block;
text-align: center;
transition: background-color 0.2s;
margin: 0.75rem 0;
}
.cta-button:hover {
background-color: #9a2e2e;
}
.divider {
margin: 1.5rem 0;
border: 0;
border-top: 1px solid #e5e7eb;
}
.footer-link {
font-size: 0.875rem;
color: #6b7280;
text-align: center;
display: block;
margin-top: 1rem;
text-decoration: none;
}
.footer-link:hover {
color: #374151;
text-decoration: underline;
}
.footer {
position: fixed;
bottom: 0;
left: 0;
right: 0;
width: 100%;
text-align: center;
padding: 1rem;
font-size: 0.875rem;
color: #6b7280;
background-color: transparent;
z-index: 10;
}
.footer a {
color: #AF3434;
text-decoration: none;
}
.footer a:hover {
text-decoration: underline;
}
</style>
</head>
<body>
<div class="auth-card">
<div class="auth-content">
<div class="login-header">
<div class="login-header-logos">
<img src="favicon.svg" alt="Stirling PDF" class="login-logo-icon">
<img src="api-wordmark.svg" alt="Stirling PDF" class="login-logo-text">
</div>
<h1 class="login-title">API Server</h1>
<p class="login-subtitle">Backend-only mode without web UI</p>
</div>
<div class="section">
<p class="section-text">
This Stirling-PDF instance is running in <strong>API-only mode</strong>. The web interface has not been included in this build.
</p>
</div>
<a href="swagger-ui/index.html" class="cta-button">Open API Documentation</a>
<hr class="divider">
<div class="section">
<h2 class="section-title">Looking for the Web UI?</h2>
<p class="section-text"><strong>If you're using Docker:</strong></p>
<ul class="section-list">
<li>You may have pulled an <strong>API-only image</strong> or there was a build configuration issue</li>
<li>Use the standard Docker image: <code class="code-inline">stirlingtools/stirling-pdf:latest</code></li>
</ul>
<p class="section-text"><strong>If you're using a JAR file:</strong></p>
<ul class="section-list">
<li>You downloaded <code class="code-inline">Stirling-PDF-server.jar</code> which is the <strong>API-only version without UI</strong></li>
<li>Download the full version from <a href="https://github.com/Stirling-Tools/Stirling-PDF/releases" target="_blank">GitHub Releases</a>:</li>
<li style="margin-left: 1.5rem;"><code class="code-inline">Stirling-PDF.jar</code> - Standard version with UI</li>
<li style="margin-left: 1.5rem;"><code class="code-inline">Stirling-PDF-with-login.jar</code> - Version with authentication features</li>
</ul>
<p class="section-text"><strong>If you built from source:</strong></p>
<ul class="section-list">
<li>Rebuild with: <code class="code-inline">./gradlew build -PbuildWithFrontend=true</code></li>
<li>Or deploy the frontend separately from the <code class="code-inline">/frontend</code> directory</li>
</ul>
</div>
<hr class="divider">
<div class="section">
<h2 class="section-title">Need Help?</h2>
<p class="section-text">Join our community for support:</p>
<ul class="section-list">
<li><a href="https://discord.gg/Cn8pWhQRxZ" target="_blank">Discord Community</a> - Get help from the community</li>
<li><a href="https://github.com/Stirling-Tools/Stirling-PDF/issues" target="_blank">GitHub Issues</a> - Report bugs or request features</li>
<li><a href="https://github.com/Stirling-Tools/Stirling-PDF" target="_blank">GitHub Repository</a> - View documentation and source code</li>
</ul>
</div>
</div>
</div>
<div class="footer">
<span>Powered by </span>
<a href="https://stirlingpdf.com">Stirling PDF</a>
</div>
</body>
</html>
File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 6.4 KiB

@@ -1,99 +1,53 @@
/* Light theme variables */
:root {
--cc-bg: #ffffff;
--cc-primary-color: #1c1c1c;
--cc-secondary-color: #666666;
--cc-btn-primary-bg: #007BFF;
--cc-btn-primary-color: #ffffff;
--cc-btn-primary-border-color: #007BFF;
--cc-btn-primary-hover-bg: #0056b3;
--cc-btn-primary-hover-color: #ffffff;
--cc-btn-primary-hover-border-color: #0056b3;
--cc-btn-secondary-bg: #f1f3f4;
--cc-btn-secondary-color: #1c1c1c;
--cc-btn-secondary-border-color: #f1f3f4;
--cc-btn-secondary-hover-bg: #007BFF;
--cc-btn-secondary-hover-color: #ffffff;
--cc-btn-secondary-hover-border-color: #007BFF;
--cc-separator-border-color: #e0e0e0;
--cc-toggle-on-bg: #007BFF;
--cc-toggle-off-bg: #667481;
--cc-toggle-on-knob-bg: #ffffff;
--cc-toggle-off-knob-bg: #ffffff;
--cc-toggle-enabled-icon-color: #ffffff;
--cc-toggle-disabled-icon-color: #ffffff;
--cc-toggle-readonly-bg: #f1f3f4;
--cc-toggle-readonly-knob-bg: #79747E;
--cc-toggle-readonly-knob-icon-color: #f1f3f4;
--cc-section-category-border: #e0e0e0;
--cc-cookie-category-block-bg: #f1f3f4;
--cc-cookie-category-block-border: #f1f3f4;
--cc-cookie-category-block-hover-bg: #e9eff4;
--cc-cookie-category-block-hover-border: #e9eff4;
--cc-cookie-category-expanded-block-bg: #f1f3f4;
--cc-cookie-category-expanded-block-hover-bg: #e9eff4;
--cc-footer-bg: #ffffff;
--cc-footer-color: #1c1c1c;
--cc-footer-border-color: #ffffff;
}
/* Dark theme variables */
.cc--darkmode{
--cc-bg: #2d2d2d;
--cc-primary-color: #e5e5e5;
--cc-secondary-color: #b0b0b0;
--cc-bg: var(--md-sys-color-inverse-on-surface);
--cc-primary-color: var(--md-sys-color-on-surface);
--cc-secondary-color: var(--md-sys-color-on-surface);
--cc-btn-primary-bg: #4dabf7;
--cc-btn-primary-color: #ffffff;
--cc-btn-primary-border-color: #4dabf7;
--cc-btn-primary-hover-bg: #3d3d3d;
--cc-btn-primary-hover-color: #ffffff;
--cc-btn-primary-hover-border-color: #3d3d3d;
--cc-btn-primary-bg: var(--md-sys-color-secondary);
--cc-btn-primary-color: var(--cc-bg);
--cc-btn-primary-border-color: var(--cc-btn-primary-bg);
--cc-btn-primary-hover-bg: var(--md-sys-color-surface-3);
--cc-btn-primary-hover-color: var(--md-sys-color-on-secondary-container);
--cc-btn-primary-hover-border-color: var(--md-sys-color-surface-3);
--cc-btn-secondary-bg: #3d3d3d;
--cc-btn-secondary-color: #ffffff;
--cc-btn-secondary-border-color: #3d3d3d;
--cc-btn-secondary-hover-bg: #4dabf7;
--cc-btn-secondary-hover-color: #ffffff;
--cc-btn-secondary-hover-border-color: #4dabf7;
--cc-btn-secondary-bg: var(--md-sys-color-surface-3);
--cc-btn-secondary-color: var(--md-sys-color-on-secondary-container);
--cc-btn-secondary-border-color: var(--md-sys-color-surface-3);
--cc-btn-secondary-hover-bg:var(--md-sys-color-secondary);
--cc-btn-secondary-hover-color: var(--cc-bg);
--cc-btn-secondary-hover-border-color: var(--md-sys-color-secondary);
--cc-separator-border-color: #555555;
--cc-separator-border-color: var(--md-sys-color-outline);
--cc-toggle-on-bg: #4dabf7;
--cc-toggle-off-bg: #667481;
--cc-toggle-on-knob-bg: #2d2d2d;
--cc-toggle-off-knob-bg: #2d2d2d;
--cc-toggle-on-bg: var(--cc-btn-primary-bg);
--cc-toggle-off-bg: var(--md-sys-color-outline);
--cc-toggle-on-knob-bg: var(--cc-btn-primary-color);
--cc-toggle-off-knob-bg: var(--cc-btn-primary-color);
--cc-toggle-enabled-icon-color: #2d2d2d;
--cc-toggle-disabled-icon-color: #2d2d2d;
--cc-toggle-enabled-icon-color: var(--cc-btn-primary-color);
--cc-toggle-disabled-icon-color: var(--cc-btn-primary-color);
--cc-toggle-readonly-bg: #555555;
--cc-toggle-readonly-knob-bg: #8e8e8e;
--cc-toggle-readonly-knob-icon-color: #555555;
--cc-toggle-readonly-bg: var(--md-sys-color-surface);
--cc-toggle-readonly-knob-bg: var(--md-sys-color-outline);
--cc-toggle-readonly-knob-icon-color: var(--cc-toggle-readonly-bg);
--cc-section-category-border: #555555;
--cc-section-category-border: var(--md-sys-color-outline);
--cc-cookie-category-block-bg: #3d3d3d;
--cc-cookie-category-block-border: #3d3d3d;
--cc-cookie-category-block-hover-bg: #4d4d4d;
--cc-cookie-category-block-hover-border: #4d4d4d;
--cc-cookie-category-expanded-block-bg: #3d3d3d;
--cc-cookie-category-expanded-block-hover-bg: #4d4d4d;
--cc-cookie-category-block-bg: var(--cc-btn-secondary-bg);
--cc-cookie-category-block-border: var(--cc-btn-secondary-bg);
--cc-cookie-category-block-hover-bg: var(--cc-btn-secondary-bg);
--cc-cookie-category-block-hover-border: var(--cc-btn-secondary-bg);
--cc-footer-bg: #2d2d2d;
--cc-footer-color: #e5e5e5;
--cc-footer-border-color: #2d2d2d;
--cc-cookie-category-expanded-block-bg: var(--cc-btn-secondary-bg);
--cc-cookie-category-expanded-block-hover-bg: var(--cc-toggle-readonly-bg);
/* --cc-overlay-bg: rgba(0, 0, 0, 0.65);
--cc-webkit-scrollbar-bg: var(--cc-section-category-border);
--cc-webkit-scrollbar-hover-bg: var(--cc-btn-primary-hover-bg);
*/
--cc-footer-bg: var(--cc-bg);
--cc-footer-color: var(--cc-primary-color);
--cc-footer-border-color: var(--cc-bg);
}
.cm__body{
max-width: 90% !important;
@@ -124,83 +78,7 @@
}
}
/* Toggle visibility fixes */
#cc-main .section__toggle {
opacity: 0 !important; /* Keep invisible but functional */
}
#cc-main .toggle__icon {
display: flex !important;
align-items: center !important;
justify-content: flex-start !important;
}
#cc-main .toggle__icon-circle {
display: block !important;
position: absolute !important;
transition: transform 0.25s ease !important;
}
#cc-main .toggle__icon-on,
#cc-main .toggle__icon-off {
display: flex !important;
align-items: center !important;
justify-content: center !important;
position: absolute !important;
width: 100% !important;
height: 100% !important;
}
/* Ensure toggles are visible in both themes */
#cc-main .toggle__icon {
background: var(--cc-toggle-off-bg) !important;
border: 1px solid var(--cc-toggle-off-bg) !important;
}
#cc-main .section__toggle:checked ~ .toggle__icon {
background: var(--cc-toggle-on-bg) !important;
border: 1px solid var(--cc-toggle-on-bg) !important;
}
/* Ensure toggle text is visible */
#cc-main .pm__section-title {
color: var(--cc-primary-color) !important;
}
#cc-main .pm__section-desc {
color: var(--cc-secondary-color) !important;
}
/* Make sure the modal has proper contrast */
#cc-main .pm {
background: var(--cc-bg) !important;
color: var(--cc-primary-color) !important;
}
/* Lower z-index so cookie banner appears behind onboarding modals */
#cc-main {
z-index: 100 !important;
}
/* Ensure consent modal text is visible in both themes */
#cc-main .cm {
background: var(--cc-bg) !important;
color: var(--cc-primary-color) !important;
}
#cc-main .cm__title {
color: var(--cc-primary-color) !important;
}
#cc-main .cm__desc {
color: var(--cc-primary-color) !important;
}
#cc-main .cm__footer {
color: var(--cc-primary-color) !important;
}
#cc-main .cm__footer-links a,
#cc-main .cm__link {
color: var(--cc-primary-color) !important;
}
@@ -1,25 +1,20 @@
{
"short_name": "Stirling PDF",
"name": "Stirling PDF",
"name": "Stirling-PDF",
"short_name": "Stirling-PDF",
"icons": [
{
"src": "modern-logo/favicon.ico",
"sizes": "64x64 32x32 24x24 16x16",
"type": "image/x-icon"
"src": "/android-chrome-192x192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "modern-logo/logo192.png",
"type": "image/png",
"sizes": "192x192"
},
{
"src": "modern-logo/logo512.png",
"type": "image/png",
"sizes": "512x512"
"src": "/android-chrome-512x512.png",
"sizes": "512x512",
"type": "image/png"
}
],
"start_url": ".",
"start_url": "/",
"display": "standalone",
"theme_color": "#000000",
"background_color": "#ffffff"
"background_color": "#ffffff",
"theme_color": "#000000"
}
@@ -1,118 +0,0 @@
package stirling.software.SPDF.controller.api;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.when;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import org.apache.pdfbox.Loader;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.pdmodel.common.PDRectangle;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.junit.jupiter.api.io.TempDir;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.mock.web.MockMultipartFile;
import stirling.software.SPDF.model.api.general.SplitPdfBySizeOrCountRequest;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.TempFileManager;
@ExtendWith(MockitoExtension.class)
class SplitPdfBySizeControllerTest {
@TempDir Path tempDir;
@Mock private CustomPDFDocumentFactory pdfDocumentFactory;
@Mock private TempFileManager tempFileManager;
@InjectMocks private SplitPdfBySizeController controller;
@BeforeEach
void setUp() throws IOException {
when(tempFileManager.createTempFile(anyString()))
.thenAnswer(
invocation -> {
String suffix = invocation.getArgument(0);
return Files.createTempFile(tempDir, "test", suffix).toFile();
});
}
@Test
@DisplayName("Should split by page count successfully")
void shouldSplitByPageCount() throws Exception {
byte[] pdfBytes;
try (PDDocument doc = new PDDocument()) {
for (int i = 0; i < 5; i++) {
doc.addPage(new PDPage(PDRectangle.A4));
}
Path pdfPath = tempDir.resolve("input.pdf");
doc.save(pdfPath.toFile());
pdfBytes = Files.readAllBytes(pdfPath);
}
MockMultipartFile file =
new MockMultipartFile(
"fileInput", "input.pdf", MediaType.APPLICATION_PDF_VALUE, pdfBytes);
SplitPdfBySizeOrCountRequest request = new SplitPdfBySizeOrCountRequest();
request.setFileInput(file);
request.setSplitType(1); // Page count
request.setSplitValue("2");
when(pdfDocumentFactory.load(any(byte[].class)))
.thenAnswer(inv -> Loader.loadPDF((byte[]) inv.getArgument(0)));
when(pdfDocumentFactory.createNewDocumentBasedOnOldDocument(any(PDDocument.class)))
.thenAnswer(inv -> new PDDocument());
ResponseEntity<byte[]> response = controller.autoSplitPdf(request);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(response.getBody()).isNotEmpty();
assertThat(response.getHeaders().getContentType())
.isEqualTo(MediaType.APPLICATION_OCTET_STREAM);
}
@Test
@DisplayName("Should split by document count successfully")
void shouldSplitByDocCount() throws Exception {
byte[] pdfBytes;
try (PDDocument doc = new PDDocument()) {
for (int i = 0; i < 6; i++) {
doc.addPage(new PDPage(PDRectangle.A4));
}
Path pdfPath = tempDir.resolve("input.pdf");
doc.save(pdfPath.toFile());
pdfBytes = Files.readAllBytes(pdfPath);
}
MockMultipartFile file =
new MockMultipartFile(
"fileInput", "input.pdf", MediaType.APPLICATION_PDF_VALUE, pdfBytes);
SplitPdfBySizeOrCountRequest request = new SplitPdfBySizeOrCountRequest();
request.setFileInput(file);
request.setSplitType(2); // Document count
request.setSplitValue("3"); // Split into 3 docs (2 pages each)
when(pdfDocumentFactory.load(any(byte[].class)))
.thenAnswer(inv -> Loader.loadPDF((byte[]) inv.getArgument(0)));
when(pdfDocumentFactory.createNewDocumentBasedOnOldDocument(any(PDDocument.class)))
.thenAnswer(inv -> new PDDocument());
ResponseEntity<byte[]> response = controller.autoSplitPdf(request);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(response.getBody()).isNotEmpty();
}
}
@@ -6,7 +6,6 @@ import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.when;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.IOException;
import java.lang.reflect.Method;
@@ -190,12 +189,9 @@ public class ConvertWebsiteToPdfTest {
when(mockExec.runCommandWithOutputHandling(cmdCaptor.capture()))
.thenReturn(dummyResult);
// Mock WebResponseUtils
ResponseEntity<byte[]> fakeResponse = ResponseEntity.ok(new byte[0]);
wr.when(
() ->
WebResponseUtils.baosToWebResponse(
any(ByteArrayOutputStream.class), any()))
wr.when(() -> WebResponseUtils.pdfDocToWebResponse(any(PDDocument.class), anyString()))
.thenReturn(fakeResponse);
// Act
@@ -265,10 +261,7 @@ public class ConvertWebsiteToPdfTest {
// WebResponseUtils
ResponseEntity<byte[]> fakeResponse = ResponseEntity.ok(new byte[0]);
wr.when(
() ->
WebResponseUtils.baosToWebResponse(
any(ByteArrayOutputStream.class), any()))
wr.when(() -> WebResponseUtils.pdfDocToWebResponse(any(PDDocument.class), anyString()))
.thenReturn(fakeResponse);
// Act: should not throw and should return a Response
@@ -67,16 +67,16 @@ class AttachmentControllerTest {
}
@Test
void addAttachments_Success() throws Exception {
void addAttachments_Success() throws IOException {
List<MultipartFile> attachments = List.of(attachment1, attachment2);
request.setAttachments(attachments);
request.setFileInput(pdfFile);
ResponseEntity<byte[]> expectedResponse =
ResponseEntity.ok("modified PDF content".getBytes());
when(pdfDocumentFactory.load(request, false)).thenReturn(mockDocument);
when(pdfDocumentFactory.load(pdfFile, false)).thenReturn(mockDocument);
when(pdfAttachmentService.addAttachment(mockDocument, attachments))
.thenReturn(mockDocument);
.thenReturn(modifiedMockDocument);
try (MockedStatic<WebResponseUtils> mockedWebResponseUtils =
mockStatic(WebResponseUtils.class)) {
@@ -84,7 +84,8 @@ class AttachmentControllerTest {
.when(
() ->
WebResponseUtils.pdfDocToWebResponse(
eq(mockDocument), eq("test_with_attachments.pdf")))
eq(modifiedMockDocument),
eq("test_with_attachments.pdf")))
.thenReturn(expectedResponse);
ResponseEntity<byte[]> response = attachmentController.addAttachments(request);
@@ -92,22 +93,22 @@ class AttachmentControllerTest {
assertNotNull(response);
assertEquals(HttpStatus.OK, response.getStatusCode());
assertNotNull(response.getBody());
verify(pdfDocumentFactory).load(request, false);
verify(pdfDocumentFactory).load(pdfFile, false);
verify(pdfAttachmentService).addAttachment(mockDocument, attachments);
}
}
@Test
void addAttachments_SingleAttachment() throws Exception {
void addAttachments_SingleAttachment() throws IOException {
List<MultipartFile> attachments = List.of(attachment1);
request.setAttachments(attachments);
request.setFileInput(pdfFile);
ResponseEntity<byte[]> expectedResponse =
ResponseEntity.ok("modified PDF content".getBytes());
when(pdfDocumentFactory.load(request, false)).thenReturn(mockDocument);
when(pdfDocumentFactory.load(pdfFile, false)).thenReturn(mockDocument);
when(pdfAttachmentService.addAttachment(mockDocument, attachments))
.thenReturn(mockDocument);
.thenReturn(modifiedMockDocument);
try (MockedStatic<WebResponseUtils> mockedWebResponseUtils =
mockStatic(WebResponseUtils.class)) {
@@ -115,7 +116,8 @@ class AttachmentControllerTest {
.when(
() ->
WebResponseUtils.pdfDocToWebResponse(
eq(mockDocument), eq("test_with_attachments.pdf")))
eq(modifiedMockDocument),
eq("test_with_attachments.pdf")))
.thenReturn(expectedResponse);
ResponseEntity<byte[]> response = attachmentController.addAttachments(request);
@@ -123,33 +125,33 @@ class AttachmentControllerTest {
assertNotNull(response);
assertEquals(HttpStatus.OK, response.getStatusCode());
assertNotNull(response.getBody());
verify(pdfDocumentFactory).load(request, false);
verify(pdfDocumentFactory).load(pdfFile, false);
verify(pdfAttachmentService).addAttachment(mockDocument, attachments);
}
}
@Test
void addAttachments_IOExceptionFromPDFLoad() throws Exception {
void addAttachments_IOExceptionFromPDFLoad() throws IOException {
List<MultipartFile> attachments = List.of(attachment1);
request.setAttachments(attachments);
request.setFileInput(pdfFile);
IOException ioException = new IOException("Failed to load PDF");
when(pdfDocumentFactory.load(request, false)).thenThrow(ioException);
when(pdfDocumentFactory.load(pdfFile, false)).thenThrow(ioException);
assertThrows(IOException.class, () -> attachmentController.addAttachments(request));
verify(pdfDocumentFactory).load(request, false);
verify(pdfDocumentFactory).load(pdfFile, false);
verifyNoInteractions(pdfAttachmentService);
}
@Test
void addAttachments_IOExceptionFromAttachmentService() throws Exception {
void addAttachments_IOExceptionFromAttachmentService() throws IOException {
List<MultipartFile> attachments = List.of(attachment1);
request.setAttachments(attachments);
request.setFileInput(pdfFile);
IOException ioException = new IOException("Failed to add attachment");
when(pdfDocumentFactory.load(request, false)).thenReturn(mockDocument);
when(pdfDocumentFactory.load(pdfFile, false)).thenReturn(mockDocument);
when(pdfAttachmentService.addAttachment(mockDocument, attachments)).thenThrow(ioException);
assertThrows(IOException.class, () -> attachmentController.addAttachments(request));
+1 -1
View File
@@ -53,7 +53,7 @@ dependencies {
implementation 'com.bucket4j:bucket4j_jdk17-core:8.15.0'
// https://mvnrepository.com/artifact/com.bucket4j/bucket4j_jdk17
implementation 'org.bouncycastle:bcprov-jdk18on:1.83'
implementation 'org.bouncycastle:bcprov-jdk18on:1.82'
implementation 'org.thymeleaf.extras:thymeleaf-extras-springsecurity5:3.1.3.RELEASE'
api 'io.micrometer:micrometer-registry-prometheus'
@@ -3,6 +3,8 @@ package stirling.software.proprietary.security.session;
import java.time.Duration;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
import java.util.Date;
import java.util.List;
import java.util.Optional;
@@ -180,7 +182,15 @@ public class SessionPersistentRegistry implements SessionRegistry {
}
// Sort sessions by lastRequest in descending order
allSessions.sort((s1, s2) -> s2.getLastRequest().compareTo(s1.getLastRequest()));
Collections.sort(
allSessions,
new Comparator<SessionEntity>() {
@Override
public int compare(SessionEntity s1, SessionEntity s2) {
// Sort by lastRequest in descending order
return s2.getLastRequest().compareTo(s1.getLastRequest());
}
});
// The first session in the list is the latest session for the given principal name
return Optional.of(allSessions.get(0));
+7 -7
View File
@@ -8,7 +8,7 @@ plugins {
id "com.diffplug.spotless" version "8.1.0"
id "com.github.jk1.dependency-license-report" version "3.0.1"
//id "nebula.lint" version "19.0.3"
id "org.sonarqube" version "7.2.2.6593"
id "org.sonarqube" version "7.1.0.6387"
}
import com.github.jk1.license.render.*
@@ -18,14 +18,14 @@ import groovy.json.JsonSlurper
ext {
springBootVersion = "3.5.7"
pdfboxVersion = "3.0.6"
imageioVersion = "3.13.0"
imageioVersion = "3.12.0"
lombokVersion = "1.18.42"
bouncycastleVersion = "1.83"
bouncycastleVersion = "1.82"
springSecuritySamlVersion = "6.5.6"
openSamlVersion = "4.3.2"
commonmarkVersion = "0.27.0"
googleJavaFormatVersion = "1.28.0"
logback = "1.5.23"
logback = "1.5.21"
junitPlatformVersion = "1.12.2"
}
@@ -60,7 +60,7 @@ repositories {
allprojects {
group = 'stirling.software'
version = '2.2.0'
version = '2.1.5'
configurations.configureEach {
exclude group: 'commons-logging', module: 'commons-logging'
@@ -172,7 +172,7 @@ subprojects {
testRuntimeOnly 'org.mockito:mockito-inline:5.2.0'
testRuntimeOnly "org.junit.platform:junit-platform-launcher"
testImplementation platform("com.squareup.okhttp3:okhttp-bom:5.3.2")
testImplementation platform("com.squareup.okhttp3:okhttp-bom:5.3.1")
testImplementation "com.squareup.okhttp3:mockwebserver"
}
@@ -334,7 +334,7 @@ dependencies {
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testRuntimeOnly "org.junit.platform:junit-platform-launcher:$junitPlatformVersion"
testImplementation platform("com.squareup.okhttp3:okhttp-bom:5.3.2")
testImplementation platform("com.squareup.okhttp3:okhttp-bom:5.1.0")
testImplementation "com.squareup.okhttp3:mockwebserver"
}
+5 -32
View File
@@ -9,7 +9,7 @@
"version": "1.0.0",
"devDependencies": {
"@stylistic/stylelint-plugin": "^4.0.0",
"stylelint": "^16.26.1",
"stylelint": "^16.26.0",
"stylelint-config-standard": "^39.0.1"
}
},
@@ -78,7 +78,6 @@
}
],
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18"
},
@@ -86,26 +85,6 @@
"@csstools/css-tokenizer": "^3.0.4"
}
},
"node_modules/@csstools/css-syntax-patches-for-csstree": {
"version": "1.0.22",
"resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.0.22.tgz",
"integrity": "sha512-qBcx6zYlhleiFfdtzkRgwNC7VVoAwfK76Vmsw5t+PbvtdknO9StgRk7ROvq9so1iqbdW4uLIDAsXRsTfUrIoOw==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT-0",
"engines": {
"node": ">=18"
}
},
"node_modules/@csstools/css-tokenizer": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz",
@@ -122,7 +101,6 @@
}
],
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18"
}
@@ -918,7 +896,6 @@
"integrity": "sha512-eohl3hKTiVyD1ilYdw9T0OiB4hnjef89e3dMYKz+mVKDzj+5IteTseASUsOB+EU9Tf6VNTCjDePcP6wkDGmLKQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@keyv/serialize": "^1.1.1"
}
@@ -1127,7 +1104,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"nanoid": "^3.3.11",
"picocolors": "^1.1.1",
@@ -1177,7 +1153,6 @@
"integrity": "sha512-8sLjZwK0R+JlxlYcTuVnyT2v+htpdrjDOKuMcOVdYjt52Lh8hWRYpxBPoKx/Zg+bcjc3wx6fmQevMmUztS/ccA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"cssesc": "^3.0.0",
"util-deprecate": "^1.0.2"
@@ -1369,9 +1344,9 @@
"license": "ISC"
},
"node_modules/stylelint": {
"version": "16.26.1",
"resolved": "https://registry.npmjs.org/stylelint/-/stylelint-16.26.1.tgz",
"integrity": "sha512-v20V59/crfc8sVTAtge0mdafI3AdnzQ2KsWe6v523L4OA1bJO02S7MO2oyXDCS6iWb9ckIPnqAFVItqSBQr7jw==",
"version": "16.26.0",
"resolved": "https://registry.npmjs.org/stylelint/-/stylelint-16.26.0.tgz",
"integrity": "sha512-Y/3AVBefrkqqapVYH3LBF5TSDZ1kw+0XpdKN2KchfuhMK6lQ85S4XOG4lIZLcrcS4PWBmvcY6eS2kCQFz0jukQ==",
"dev": true,
"funding": [
{
@@ -1384,10 +1359,8 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"@csstools/css-parser-algorithms": "^3.0.5",
"@csstools/css-syntax-patches-for-csstree": "^1.0.19",
"@csstools/css-tokenizer": "^3.0.4",
"@csstools/media-query-list-parser": "^4.0.3",
"@csstools/selector-specificity": "^5.0.0",
@@ -1400,7 +1373,7 @@
"debug": "^4.4.3",
"fast-glob": "^3.3.3",
"fastest-levenshtein": "^1.0.16",
"file-entry-cache": "^11.1.1",
"file-entry-cache": "^11.1.0",
"global-modules": "^2.0.0",
"globby": "^11.1.0",
"globjoin": "^0.1.4",
+1 -1
View File
@@ -7,7 +7,7 @@
},
"devDependencies": {
"@stylistic/stylelint-plugin": "^4.0.0",
"stylelint": "^16.26.1",
"stylelint": "^16.26.0",
"stylelint-config-standard": "^39.0.1"
}
}
+1 -1
View File
@@ -54,4 +54,4 @@ services:
networks:
stirling-network:
driver: bridge
driver: bridge
+1 -1
View File
@@ -37,7 +37,7 @@ RUN DISABLE_ADDITIONAL_FEATURES=false \
# Stage 2: Runtime image based on Debian stable-slim
# Contains Java runtime + LibreOffice + Calibre + all PDF tools
FROM debian:stable-slim@sha256:1c25564b03942d874bf6a2b71f2062b71af8bc1475aa873c523e6f7c8fa29e60
FROM debian:stable-slim@sha256:7cb087f19bcc175b96fbe4c2aef42ed00733a659581a80f6ebccfd8fe3185a3d
SHELL ["/bin/bash", "-o", "pipefail", "-c"]
ENV DEBIAN_FRONTEND=noninteractive
+1 -1
View File
@@ -37,7 +37,7 @@ RUN DISABLE_ADDITIONAL_FEATURES=false \
# Stage 2: Runtime image based on Debian stable-slim
# Contains Java runtime + LibreOffice + Calibre + all PDF tools + extra fonts for air-gapped environments
FROM debian:stable-slim@sha256:1c25564b03942d874bf6a2b71f2062b71af8bc1475aa873c523e6f7c8fa29e60
FROM debian:stable-slim@sha256:7cb087f19bcc175b96fbe4c2aef42ed00733a659581a80f6ebccfd8fe3185a3d
SHELL ["/bin/bash", "-o", "pipefail", "-c"]
ENV DEBIAN_FRONTEND=noninteractive
-3
View File
@@ -19,8 +19,5 @@
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
<script type="module" src="src/index.tsx"></script>
<!-- jscanify and OpenCV for mobile scanner - loaded after React for non-blocking page load -->
<script src="/vendor/jscanify/opencv.js" async></script>
<script src="/vendor/jscanify/jscanify.js" async></script>
</body>
</html>
+2566 -1154
View File
File diff suppressed because it is too large Load Diff
-2
View File
@@ -54,9 +54,7 @@
"license-report": "^6.8.0",
"pdf-lib": "^1.17.1",
"pdfjs-dist": "^5.4.149",
"peerjs": "^1.5.5",
"posthog-js": "^1.268.0",
"qrcode.react": "^4.1.0",
"react": "^19.1.1",
"react-dom": "^19.1.1",
"react-i18next": "^15.7.3",
+4 -149
View File
@@ -963,7 +963,6 @@ desc = "Add custom text anywhere in your PDF"
addFiles = "Add Files"
uploadFromComputer = "Upload from computer"
openFromComputer = "Open from computer"
mobileUpload = "Upload from Mobile"
[viewPdf]
tags = "view,read,annotate,text,image,highlight,edit"
@@ -1250,7 +1249,7 @@ includeAllRecipients = "Include CC and BCC recipients in header"
downloadHtml = "Download HTML intermediate file instead of PDF"
pdfaOptions = "PDF/A Options"
outputFormat = "Output Format"
pdfaNote = "PDF/A-1b is more compatible, PDF/A-2b supports more features, PDF/A-3b supports embedded files."
pdfaNote = "PDF/A-1b is more compatible, PDF/A-2b supports more features."
pdfaDigitalSignatureWarning = "The PDF contains a digital signature. This will be removed in the next step."
fileFormat = "File Format"
wordDoc = "Word Document"
@@ -1279,21 +1278,6 @@ cbzOptions = "CBZ to PDF Options"
optimizeForEbook = "Optimize PDF for ebook readers (uses Ghostscript)"
cbzOutputOptions = "PDF to CBZ Options"
cbzDpi = "DPI for image rendering"
cbrOptions = "CBR Options"
cbrOutputOptions = "PDF to CBR Options"
cbrDpi = "DPI for image rendering"
[convert.ebookOptions]
ebookOptions = "eBook to PDF Options"
ebookOptionsDesc = "Options for converting eBooks to PDF"
embedAllFonts = "Embed all fonts"
embedAllFontsDesc = "Embed all fonts from the eBook into the generated PDF"
includeTableOfContents = "Include table of contents"
includeTableOfContentsDesc = "Add a generated table of contents to the resulting PDF"
includePageNumbers = "Include page numbers"
includePageNumbersDesc = "Add page numbers to the generated PDF"
optimizeForEbookPdf = "Optimize for ebook readers"
optimizeForEbookPdfDesc = "Optimize the PDF for eBook reading (smaller file size, better rendering on eInk devices)"
[imageToPdf]
tags = "conversion,img,jpg,picture,photo"
@@ -1410,11 +1394,6 @@ header = "Add Attachments"
add = "Add Attachment"
remove = "Remove Attachment"
embed = "Embed Attachment"
convertToPdfA3b = "Convert to PDF/A-3b"
convertToPdfA3bDescription = "Creates an archival PDF with embedded attachments"
convertToPdfA3bTooltip = "PDF/A-3b is an archival format ensuring long-term preservation. It allows embedding arbitrary file formats as attachments. Conversion requires Ghostscript and may take longer for large files."
convertToPdfA3bTooltipHeader = "About PDF/A-3b Conversion"
convertToPdfA3bTooltipTitle = "What it does"
submit = "Add Attachments"
[watermark]
@@ -2360,10 +2339,6 @@ saved = "Saved"
label = "Upload signature image"
placeholder = "Select image file"
hint = "Upload a PNG or JPG image of your signature"
removeBackground = "Remove white background (make transparent)"
processing = "Processing image..."
backgroundRemovalFailedTitle = "Background removal failed"
backgroundRemovalFailedMessage = "Could not remove the background from the image. Using original image instead."
[sign.instructions]
title = "How to add signature"
@@ -2409,11 +2384,6 @@ note = "Flattening removes interactive elements from the PDF, making them non-ed
label = "Flatten only forms"
desc = "Only flatten form fields, leaving other interactive elements intact"
[flatten.renderDpi]
label = "Rendering DPI (optional, recommended 150 DPI)"
help = "Leave blank to use the system default. Higher DPI sharpens output but increases processing time and file size."
placeholder = "e.g. 150"
[flatten.results]
title = "Flatten Results"
@@ -2988,7 +2958,6 @@ header = "Crop PDF"
submit = "Apply Crop"
noFileSelected = "Select a PDF file to begin cropping"
reset = "Reset to full PDF"
autoCrop = "Auto-crop whitespace"
[crop.preview]
title = "Crop Area Selection"
@@ -3406,19 +3375,6 @@ placeholder = "Enter number of horizontal divisions"
label = "Vertical Divisions"
placeholder = "Enter number of vertical divisions"
[split-by-sections.splitMode]
label = "Split Mode"
description = "Choose how to split the pages"
splitAll = "Split all pages"
splitAllExceptFirst = "Split all except first"
splitAllExceptLast = "Split all except last"
splitAllExceptFirstAndLast = "Split all except first and last"
custom = "Custom pages"
[split-by-sections.customPages]
label = "Custom Page Numbers"
placeholder = "e.g. 2,4,6"
[AddStampRequest]
tags = "Stamp, Add image, center image, Watermark, PDF, Embed, Customize,Customise"
header = "Stamp PDF"
@@ -3780,9 +3736,6 @@ filesize = "File Size"
[compress.grayscale]
label = "Apply Grayscale for Compression"
[compress.linearize]
label = "Linearize PDF for fast web viewing"
[compress.lineArt]
label = "Convert images to line art"
description = "Uses ImageMagick to reduce pages to high-contrast black and white for maximum size reduction."
@@ -3826,11 +3779,6 @@ failed = "An error occurred while compressing the PDF."
_value = "Compression Settings"
1 = "1-3 PDF compression,</br> 4-6 lite image compression,</br> 7-9 intense image compression Will dramatically reduce image quality"
[compress.compressionLevel]
range1to3 = "Lower values preserve quality but result in larger files"
range4to6 = "Medium compression with moderate quality reduction"
range7to9 = "Higher values reduce file size significantly but may reduce image clarity"
[decrypt]
passwordPrompt = "This file is password-protected. Please enter the password:"
cancelled = "Operation cancelled for PDF: {0}"
@@ -4361,13 +4309,6 @@ description = "Path to WeasyPrint executable for HTML to PDF conversion (leave e
label = "Unoconvert Executable"
description = "Path to LibreOffice unoconvert for document conversions (leave empty for default: /opt/venv/bin/unoconvert)"
[admin.settings.general.frontendUrl]
label = "Frontend URL"
description = "Base URL for frontend (e.g., https://pdf.example.com). Used for email invite links and mobile QR code uploads. Leave empty to use backend URL."
[admin.settings.badge]
clickToUpgrade = "Click to view plan details"
[admin.settings.security]
title = "Security"
description = "Configure authentication, login behaviour, and security policies."
@@ -4572,13 +4513,6 @@ description = "Automatically create user accounts on first SAML2 login"
label = "Block Registration"
description = "Prevent new user registration via SAML2"
[admin.settings.connections.mobileScanner]
label = "Mobile Phone Upload"
enable = "Enable QR Code Upload"
description = "Allow users to upload files from mobile devices by scanning a QR code"
note = "Note: Requires Frontend URL to be configured. "
link = "Configure in System Settings"
[admin.settings.database]
title = "Database"
description = "Configure custom database connection settings for enterprise deployments."
@@ -4760,10 +4694,6 @@ description = "Allow admins to invite users via email with auto-generated passwo
label = "Frontend URL"
description = "Base URL for frontend (e.g. https://pdf.example.com). Used for generating invite links in emails. Leave empty to use backend URL."
[admin.settings.mail.frontendUrlNote]
note = "Note: Requires Frontend URL to be configured. "
link = "Configure in System Settings"
[admin.settings.legal]
title = "Legal Documents"
description = "Configure links to legal documents and policies."
@@ -4949,8 +4879,6 @@ googleDriveShort = "Drive"
myFiles = "My Files"
noRecentFiles = "No recent files found"
googleDriveNotAvailable = "Google Drive integration not available"
mobileUpload = "Mobile Upload"
mobileShort = "Mobile"
downloadSelected = "Download Selected"
saveSelected = "Save Selected"
openFiles = "Open Files"
@@ -6232,7 +6160,7 @@ title = "Authentication Failed"
message = "Authentication was not successful. You can close this window and try again."
[pdfTextEditor]
title = "PDF Text Editor"
title = "PDF JSON Editor"
viewLabel = "PDF Editor"
converting = "Converting PDF to editable format..."
conversionFailed = "Failed to convert PDF. Please try again."
@@ -6261,8 +6189,6 @@ reset = "Reset Changes"
downloadJson = "Download JSON"
generatePdf = "Generate PDF"
saveChanges = "Save Changes"
applyChanges = "Apply Changes"
downloadCopy = "Download Copy"
[pdfTextEditor.options.autoScaleText]
title = "Auto-scale text to fit boxes"
@@ -6281,24 +6207,6 @@ descriptionInline = "Tip: Hold Ctrl (Cmd) or Shift to multi-select text boxes. A
title = "Lock edited text to a single PDF element"
description = "When enabled, the editor exports each edited text box as one PDF text element to avoid overlapping glyphs or mixed fonts."
[pdfTextEditor.options.advanced]
title = "Advanced Settings"
[pdfTextEditor.tooltip.header]
title = "Preview Limitations"
[pdfTextEditor.tooltip.textFocus]
title = "Text and Image Focus"
text = "This workspace focuses on editing text and repositioning embedded images. Complex page artwork, form widgets, and layered graphics are preserved for export but are not fully editable here."
[pdfTextEditor.tooltip.previewVariance]
title = "Preview Variance"
text = "Some visuals (such as table borders, shapes, or annotation appearances) may not display exactly in the preview. The exported PDF keeps the original drawing commands whenever possible."
[pdfTextEditor.tooltip.alpha]
title = "Alpha Viewer"
text = "This alpha viewer is still evolving—certain fonts, colours, transparency effects, and layout details may shift slightly. Please double-check the generated PDF before sharing."
[pdfTextEditor.manual]
mergeTooltip = "Merge selected boxes"
merge = "Merge selection"
@@ -6318,8 +6226,8 @@ alpha = "This alpha viewer is still evolving—certain fonts, colours, transpare
[pdfTextEditor.empty]
title = "No document loaded"
subtitle = "Load a PDF or JSON file to begin editing text content."
dropzone = "Drag and drop a PDF here, or click to browse"
dropzoneWithFiles = "Select a file from the Files tab, or drag and drop a PDF here, or click to browse"
dropzone = "Drag and drop a PDF or JSON file here, or click to browse"
dropzoneWithFiles = "Select a file from the Files tab, or drag and drop a PDF or JSON file here, or click to browse"
[pdfTextEditor.welcomeBanner]
title = "Welcome to PDF Text Editor (Early Access)"
@@ -6420,56 +6328,3 @@ title = "Add Text Results"
[addText.error]
failed = "An error occurred while adding text to the PDF."
[mobileUpload]
title = "Upload from Mobile"
description = "Scan this QR code with your mobile device to upload photos directly to this page."
error = "Connection Error"
pollingError = "Error checking for files"
sessionId = "Session ID"
sessionCreateError = "Failed to create session"
expiryWarning = "Session Expiring Soon"
expiryWarningMessage = "This QR code will expire in {{seconds}} seconds. A new code will be generated automatically."
filesReceived = "{{count}} file(s) received"
connected = "Mobile device connected"
instructions = "Open the camera app on your phone and scan this code. Files will be transferred directly between devices."
[mobileScanner]
title = "Mobile Scanner"
noSession = "Invalid Session"
noSessionMessage = "Please scan a valid QR code to access this page."
validating = "Validating session..."
sessionInvalid = "Session Error"
sessionExpired = "This session has expired. Please refresh and try again."
sessionNotFound = "Session not found. Please refresh and try again."
sessionValidationError = "Unable to verify session. Please try again."
uploadSuccess = "Upload Successful!"
uploadSuccessMessage = "Your images have been transferred."
httpsRequired = "Camera access requires HTTPS or localhost. Please use HTTPS or access via localhost."
uploadFailed = "Upload failed. Please try again."
uploading = "Uploading..."
connected = "Connected"
connecting = "Connecting..."
chooseMethod = "Choose Upload Method"
chooseMethodDescription = "Select how you want to scan and upload documents"
camera = "Camera"
cameraDescription = "Scan documents using your device camera with automatic edge detection"
fileUpload = "File Upload"
fileDescription = "Upload existing photos or documents from your device"
cameraAccessDenied = "Camera access denied. Please enable camera access."
back = "Back"
settings = "Settings"
edgeDetection = "Edge Detection"
flashlight = "Flashlight"
flash = "Flash"
processing = "Processing..."
capture = "Capture Photo"
selectFilesPrompt = "Select files to upload"
selectImage = "Select Image"
preview = "Preview"
retake = "Retake"
addToBatch = "Add to Batch"
upload = "Upload"
batchImages = "Batch"
clearBatch = "Clear"
uploadAll = "Upload All"
+31 -31
View File
@@ -1107,7 +1107,7 @@ title = "Dividi per conteggio"
text = "Crea più PDF con un numero specifico di pagine o documenti ciascuno."
bullet1 = "Conteggio pagine: numero fisso per file"
bullet2 = "Numero documenti: numero fisso di file in output"
bullet3 = "Utile per batch di flusso di lavoro"
bullet3 = "Utile per workflow batch"
[split.tooltip.byChapters]
title = "Dividi per capitoli"
@@ -1696,7 +1696,7 @@ submit = "Estrai pagine"
[extractPages.pageNumbers]
label = "Pagine da estrarre"
placeholder = "es., 1,3,5-8 o dispari & 1-10"
placeholder = "es., 1,3,5-8 o odd & 1-10"
[extractPages.settings]
title = "Impostazioni"
@@ -1711,7 +1711,7 @@ failed = "Impossibile estrarre le pagine"
title = "Pagine estratte"
[pageSelection.tooltip]
description = "Scegli quali pagine usare per l'operazione. Supporta pagine singole, intervalli, formule e la parola chiave \"tutte\"."
description = "Scegli quali pagine usare per l'operazione. Supporta pagine singole, intervalli, formule e la parola chiave \"all\"."
[pageSelection.tooltip.header]
title = "Guida Selezione Pagine"
@@ -1721,7 +1721,7 @@ title = "Uso di base"
text = "Seleziona pagine specifiche dal tuo PDF usando una sintassi semplice."
bullet1 = "Pagine singole: 1,3,5"
bullet2 = "Intervalli: 3-6 o 10-15"
bullet3 = "Tutte le pagine: tutte"
bullet3 = "Tutte le pagine: all"
[pageSelection.tooltip.advanced]
title = "Funzionalità avanzate"
@@ -1739,15 +1739,15 @@ text = "Usa numeri, intervalli, parole chiave e progressioni (n parte da 0). Son
[pageSelection.tooltip.syntax.bullets]
numbers = "Numeri/intervalli: 5, 10-20"
keywords = "Parole chiave: dispari, pari"
keywords = "Parole chiave: odd, even"
progressions = "Progressioni: 3n, 4n+1"
[pageSelection.tooltip.operators]
title = "Operatori"
text = "E ha precedenza più alta della virgola. NON si applica all’interno dell’intervallo del documento."
and = "E: & o \"e\" — richiede entrambe le condizioni (es., 1-50 & pari)"
text = "AND ha precedenza più alta della virgola. NOT si applica all’interno dell’intervallo del documento."
and = "AND: & o \"and\" — richiede entrambe le condizioni (es., 1-50 & even)"
comma = "Virgola: , o | — combina selezioni (es., 1-10, 20)"
not = "NON: ! o \"NON\" — esclude pagine (es., 3n & non 30)"
not = "NOT: ! o \"not\" — esclude pagine (es., 3n & not 30)"
[pageSelection.tooltip.examples]
title = "Esempi"
@@ -1795,15 +1795,15 @@ text = "Usa numeri, intervalli, parole chiave e progressioni (n parte da 0). Son
[bulkSelection.syntax.bullets]
numbers = "Numeri/intervalli: 5, 10-20"
keywords = "Parole chiave: dispari, pari"
keywords = "Parole chiave: odd, even"
progressions = "Progressioni: 3n, 4n+1"
[bulkSelection.operators]
title = "Operatori"
text = "E ha precedenza più alta della virgola. NON si applica all’interno dell’intervallo del documento."
and = "E: & o \"e\" — richiede entrambe le condizioni (es., 1-50 & pari)"
text = "AND ha precedenza più alta della virgola. NOT si applica all’interno dell’intervallo del documento."
and = "AND: & o \"and\" — richiede entrambe le condizioni (es., 1-50 & even)"
comma = "Virgola: , o | — combina selezioni (es., 1-10, 20)"
not = "NON: ! o \"non\" — esclude pagine (es., 3n & non 30)"
not = "NOT: ! o \"not\" — esclude pagine (es., 3n & not 30)"
[bulkSelection.examples]
title = "Esempi"
@@ -2163,9 +2163,9 @@ info = "Python non è installato. È necessario per l'esecuzione."
[ScannerImageSplit.selectText]
1 = "Soglia angolo:"
2 = "Imposta il minimo angolo richiesto perché l'immagine venga ruotata (predefinito: 10)."
2 = "Imposta il minimo angolo richiesto perché l'immagine venga ruotata (default: 10)."
3 = "Tolleranza:"
4 = "Imposta lo spettro di colori attorno al colore di sfondo stimato (predefinito: 30)."
4 = "Imposta lo spettro di colori attorno al colore di sfondo stimato (default: 30)."
5 = "Area minima:"
6 = "Imposta l'area minima di una foto (default: 10000)."
7 = "Area di contorno minima:"
@@ -2343,26 +2343,26 @@ placeholder = "Seleziona un file PDF nella vista principale per iniziare"
settings = "Impostazioni"
[flatten.options]
stepTitle = "Opzioni di appiattimento"
title = "Opzioni di appiattimento"
note = "L'appiattimento rimuove gli elementi interattivi dal PDF, rendendoli non modificabili."
stepTitle = "Opzioni di flattening"
title = "Opzioni di flattening"
note = "Il flattening rimuove gli elementi interattivi dal PDF, rendendoli non modificabili."
[flatten.options.flattenOnlyForms]
label = "Appiattisci solo i moduli"
desc = "Appiattisci solo i campi modulo, lasciando intatti gli altri elementi interattivi"
[flatten.results]
title = "Risultati di appiattimento"
title = "Risultati Flatten"
[flatten.error]
failed = "Si è verificato un errore durante l'appiattimento del PDF."
failed = "Si è verificato un errore durante il flattening del PDF."
[flatten.tooltip.header]
title = "Informazioni sull'appiattimento dei PDF"
title = "Informazioni sul flattening dei PDF"
[flatten.tooltip.description]
title = "Cosa fa l'appiattimento?"
text = "L'appiattimento rende il tuo PDF non modificabile trasformando moduli compilabili e pulsanti in testo e immagini normali. Il PDF avrà lo stesso aspetto, ma nessuno potrà più modificare o compilare i moduli. Perfetto per condividere moduli completati, creare documenti finali per gli archivi o garantire un aspetto uniforme ovunque."
title = "Cosa fa il flattening?"
text = "Il flattening rende il tuo PDF non modificabile trasformando moduli compilabili e pulsanti in testo e immagini normali. Il PDF avrà lo stesso aspetto, ma nessuno potrà più modificare o compilare i moduli. Perfetto per condividere moduli completati, creare documenti finali per gli archivi o garantire un aspetto uniforme ovunque."
bullet1 = "Le caselle di testo diventano testo normale (non modificabile)"
bullet2 = "Checkbox e pulsanti diventano immagini"
bullet3 = "Ottimo per versioni finali che non vuoi vengano modificate"
@@ -2469,7 +2469,7 @@ title = "Compara"
header = "Compara PDF"
clearSelected = "Cancella selezionati"
addFilesHint = "Aggiungi PDF nel passaggio File per abilitarne la selezione."
noFiles = "Nessun PDF disponibile"
noFiles = "Nessun PDF disponibile yet"
pages = "Pagine"
cta = "Confronta"
loading = "Confronto in corso..."
@@ -4168,7 +4168,7 @@ label = "Directory pipeline"
[admin.settings.general.customPaths.pipeline.watchedFoldersDir]
label = "Directory cartelle monitorate"
description = "Directory in cui la pipeline monitora i PDF in arrivo (lascia vuoto per predefinito: /pipeline/watchedFolders)"
description = "Directory in cui la pipeline monitora i PDF in arrivo (lascia vuoto per default: /pipeline/watchedFolders)"
[admin.settings.general.customPaths.pipeline.finishedFoldersDir]
label = "Directory cartelle completate"
@@ -4179,11 +4179,11 @@ label = "Percorsi strumenti esterni"
[admin.settings.general.customPaths.operations.weasyprint]
label = "Eseguibile WeasyPrint"
description = "Percorso dell'eseguibile WeasyPrint per conversione da HTML a PDF (lascia vuoto per predefinito: /opt/venv/bin/weasyprint)"
description = "Percorso dell'eseguibile WeasyPrint per conversione da HTML a PDF (lascia vuoto per default: /opt/venv/bin/weasyprint)"
[admin.settings.general.customPaths.operations.unoconvert]
label = "Eseguibile Unoconvert"
description = "Percorso di LibreOffice unoconvert per conversioni di documenti (lascia vuoto per predefinito: /opt/venv/bin/unoconvert)"
description = "Percorso di LibreOffice unoconvert per conversioni di documenti (lascia vuoto per default: /opt/venv/bin/unoconvert)"
[admin.settings.security]
title = "Sicurezza"
@@ -4305,12 +4305,12 @@ label = "Blocca localhost"
description = "Blocca localhost e indirizzi loopback (127.x.x.x, ::1)"
[admin.settings.security.htmlUrlSecurity.blockLinkLocal]
label = "Blocca indirizzi link-locali"
description = "Blocca indirizzi link-locali (169.254.x.x, fe80::/10)"
label = "Blocca indirizzi link-local"
description = "Blocca indirizzi link-local (169.254.x.x, fe80::/10)"
[admin.settings.security.htmlUrlSecurity.blockCloudMetadata]
label = "Blocca endpoint dei metadati cloud"
description = "Blocca gli endpoint dei metadati dei provider cloud (169.254.169.254)"
label = "Blocca endpoint dei metadata cloud"
description = "Blocca gli endpoint dei metadata dei provider cloud (169.254.169.254)"
[admin.settings.connections]
title = "Connessioni"
@@ -5211,7 +5211,7 @@ noSearchResults = "Nessuno strumento trovato"
noTools = "Nessuno strumento disponibile"
[onboarding]
allTools = "Questo è il pannello <strong>Tutti gli Strumenti</strong>, dove puoi sfogliare e selezionare tutti gli strumenti PDF disponibili."
allTools = "This is the <strong>All Tools</strong> panel, where you can browse and select from all available PDF tools."
selectCropTool = "Selezioniamo lo strumento <strong>Ritaglia</strong> per mostrare come usarne uno."
toolInterface = "Questa è l'interfaccia dello strumento <strong>Ritaglia</strong>. Come vedi, non c'è molto perché non abbiamo ancora aggiunto file PDF su cui lavorare."
filesButton = "Il pulsante <strong>File</strong> sulla barra di accesso rapido consente di caricare PDF su cui usare gli strumenti."
-263
View File
@@ -1,263 +0,0 @@
/*! jscanify v1.4.0 | (c) ColonelParrot and other contributors | MIT License */
(function (global, factory) {
typeof exports === "object" && typeof module !== "undefined"
? (module.exports = factory())
: typeof define === "function" && define.amd
? define(factory)
: (global.jscanify = factory());
})(this, function () {
"use strict";
/**
* Calculates distance between two points. Each point must have `x` and `y` property
* @param {*} p1 point 1
* @param {*} p2 point 2
* @returns distance between two points
*/
function distance(p1, p2) {
return Math.hypot(p1.x - p2.x, p1.y - p2.y);
}
class jscanify {
constructor() { }
/**
* Finds the contour of the paper within the image
* @param {*} img image to process (cv.Mat)
* @returns the biggest contour inside the image
*/
findPaperContour(img) {
const imgGray = new cv.Mat();
cv.Canny(img, imgGray, 50, 200);
const imgBlur = new cv.Mat();
cv.GaussianBlur(
imgGray,
imgBlur,
new cv.Size(3, 3),
0,
0,
cv.BORDER_DEFAULT
);
const imgThresh = new cv.Mat();
cv.threshold(
imgBlur,
imgThresh,
0,
255,
cv.THRESH_OTSU
);
let contours = new cv.MatVector();
let hierarchy = new cv.Mat();
cv.findContours(
imgThresh,
contours,
hierarchy,
cv.RETR_CCOMP,
cv.CHAIN_APPROX_SIMPLE
);
let maxArea = 0;
let maxContourIndex = -1;
for (let i = 0; i < contours.size(); ++i) {
let contourArea = cv.contourArea(contours.get(i));
if (contourArea > maxArea) {
maxArea = contourArea;
maxContourIndex = i;
}
}
const maxContour =
maxContourIndex >= 0 ?
contours.get(maxContourIndex) :
null;
imgGray.delete();
imgBlur.delete();
imgThresh.delete();
contours.delete();
hierarchy.delete();
return maxContour;
}
/**
* Highlights the paper detected inside the image.
* @param {*} image image to process
* @param {*} options options for highlighting. Accepts `color` and `thickness` parameter
* @returns `HTMLCanvasElement` with original image and paper highlighted
*/
highlightPaper(image, options) {
options = options || {};
options.color = options.color || "orange";
options.thickness = options.thickness || 10;
const canvas = document.createElement("canvas");
const ctx = canvas.getContext("2d");
const img = cv.imread(image);
const maxContour = this.findPaperContour(img);
cv.imshow(canvas, img);
if (maxContour) {
const {
topLeftCorner,
topRightCorner,
bottomLeftCorner,
bottomRightCorner,
} = this.getCornerPoints(maxContour, img);
if (
topLeftCorner &&
topRightCorner &&
bottomLeftCorner &&
bottomRightCorner
) {
ctx.strokeStyle = options.color;
ctx.lineWidth = options.thickness;
ctx.beginPath();
ctx.moveTo(...Object.values(topLeftCorner));
ctx.lineTo(...Object.values(topRightCorner));
ctx.lineTo(...Object.values(bottomRightCorner));
ctx.lineTo(...Object.values(bottomLeftCorner));
ctx.lineTo(...Object.values(topLeftCorner));
ctx.stroke();
}
}
img.delete();
return canvas;
}
/**
* Extracts and undistorts the image detected within the frame.
*
* Returns `null` if no paper is detected.
*
* @param {*} image image to process
* @param {*} resultWidth desired result paper width
* @param {*} resultHeight desired result paper height
* @param {*} cornerPoints optional custom corner points, in case automatic corner points are incorrect
* @returns `HTMLCanvasElement` containing undistorted image
*/
extractPaper(image, resultWidth, resultHeight, cornerPoints) {
const canvas = document.createElement("canvas");
const img = cv.imread(image);
const maxContour = cornerPoints ? null : this.findPaperContour(img);
if(maxContour == null && cornerPoints === undefined){
return null;
}
const {
topLeftCorner,
topRightCorner,
bottomLeftCorner,
bottomRightCorner,
} = cornerPoints || this.getCornerPoints(maxContour, img);
let warpedDst = new cv.Mat();
let dsize = new cv.Size(resultWidth, resultHeight);
let srcTri = cv.matFromArray(4, 1, cv.CV_32FC2, [
topLeftCorner.x,
topLeftCorner.y,
topRightCorner.x,
topRightCorner.y,
bottomLeftCorner.x,
bottomLeftCorner.y,
bottomRightCorner.x,
bottomRightCorner.y,
]);
let dstTri = cv.matFromArray(4, 1, cv.CV_32FC2, [
0,
0,
resultWidth,
0,
0,
resultHeight,
resultWidth,
resultHeight,
]);
let M = cv.getPerspectiveTransform(srcTri, dstTri);
cv.warpPerspective(
img,
warpedDst,
M,
dsize,
cv.INTER_LINEAR,
cv.BORDER_CONSTANT,
new cv.Scalar()
);
cv.imshow(canvas, warpedDst);
img.delete()
warpedDst.delete()
return canvas;
}
/**
* Calculates the corner points of a contour.
* @param {*} contour contour from {@link findPaperContour}
* @returns object with properties `topLeftCorner`, `topRightCorner`, `bottomLeftCorner`, `bottomRightCorner`, each with `x` and `y` property
*/
getCornerPoints(contour) {
let rect = cv.minAreaRect(contour);
const center = rect.center;
let topLeftCorner;
let topLeftCornerDist = 0;
let topRightCorner;
let topRightCornerDist = 0;
let bottomLeftCorner;
let bottomLeftCornerDist = 0;
let bottomRightCorner;
let bottomRightCornerDist = 0;
for (let i = 0; i < contour.data32S.length; i += 2) {
const point = { x: contour.data32S[i], y: contour.data32S[i + 1] };
const dist = distance(point, center);
if (point.x < center.x && point.y < center.y) {
// top left
if (dist > topLeftCornerDist) {
topLeftCorner = point;
topLeftCornerDist = dist;
}
} else if (point.x > center.x && point.y < center.y) {
// top right
if (dist > topRightCornerDist) {
topRightCorner = point;
topRightCornerDist = dist;
}
} else if (point.x < center.x && point.y > center.y) {
// bottom left
if (dist > bottomLeftCornerDist) {
bottomLeftCorner = point;
bottomLeftCornerDist = dist;
}
} else if (point.x > center.x && point.y > center.y) {
// bottom right
if (dist > bottomRightCornerDist) {
bottomRightCorner = point;
bottomRightCornerDist = dist;
}
}
}
return {
topLeftCorner,
topRightCorner,
bottomLeftCorner,
bottomRightCorner,
};
}
}
return jscanify;
});
File diff suppressed because one or more lines are too long
+14 -14
View File
@@ -19,27 +19,27 @@ const debug = (message) => {
function scanForUsedIcons() {
const usedIcons = new Set();
const srcDir = path.join(__dirname, '..', 'src');
info('🔍 Scanning codebase for LocalIcon usage...');
if (!fs.existsSync(srcDir)) {
console.error('❌ Source directory not found:', srcDir);
process.exit(1);
}
// Recursively scan all .tsx and .ts files
function scanDirectory(dir) {
const files = fs.readdirSync(dir);
files.forEach(file => {
const filePath = path.join(dir, file);
const stat = fs.statSync(filePath);
if (stat.isDirectory()) {
scanDirectory(filePath);
} else if (file.endsWith('.tsx') || file.endsWith('.ts')) {
const content = fs.readFileSync(filePath, 'utf8');
// Match LocalIcon usage: <LocalIcon icon="icon-name" ...>
const localIconMatches = content.match(/<LocalIcon\s+[^>]*icon="([^"]+)"/g);
if (localIconMatches) {
@@ -51,7 +51,7 @@ function scanForUsedIcons() {
}
});
}
// Match old material-symbols-rounded spans: <span className="material-symbols-rounded">icon-name</span>
const spanMatches = content.match(/<span[^>]*className="[^"]*material-symbols-rounded[^"]*"[^>]*>([^<]+)<\/span>/g);
if (spanMatches) {
@@ -64,7 +64,7 @@ function scanForUsedIcons() {
}
});
}
// Match Icon component usage: <Icon icon="material-symbols:icon-name" ...>
const iconMatches = content.match(/<Icon\s+[^>]*icon="material-symbols:([^"]+)"/g);
if (iconMatches) {
@@ -79,12 +79,12 @@ function scanForUsedIcons() {
}
});
}
scanDirectory(srcDir);
const iconArray = Array.from(usedIcons).sort();
info(`📋 Found ${iconArray.length} unique icons across codebase`);
return iconArray;
}
@@ -102,7 +102,7 @@ async function main() {
const existingSet = JSON.parse(fs.readFileSync(outputPath, 'utf8'));
const existingIcons = Object.keys(existingSet.icons || {}).sort();
const currentIcons = [...usedIcons].sort();
if (JSON.stringify(existingIcons) === JSON.stringify(currentIcons)) {
needsRegeneration = false;
info(`✅ Icon set already up-to-date (${usedIcons.length} icons, ${Math.round(fs.statSync(outputPath).size / 1024)}KB)`);
@@ -122,7 +122,7 @@ async function main() {
// Dynamic import of ES module
const { getIcons } = await import('@iconify/utils');
// Extract only our used icons from the full set
const extractedIcons = getIcons(icons, usedIcons);
@@ -183,4 +183,4 @@ export default iconSet;
main().catch(error => {
console.error('❌ Script failed:', error);
process.exit(1);
});
});
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
"productName": "Stirling-PDF",
"version": "2.2.0",
"version": "2.1.5",
"identifier": "stirling.pdf.dev",
"build": {
"frontendDist": "../dist",

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