Compare commits

..
7 Commits
Author SHA1 Message Date
pixeebot[bot]GitHubpixeebot[bot] <104101892+pixeebot[bot]@users.noreply.github.com>
7f096297af Hardening suggestions for Stirling-PDF / multiFileAsync (#3923)
I've reviewed the recently opened PR ([3922 - Support multi-file async
job results and ZIP
extraction](https://github.com/Stirling-Tools/Stirling-PDF/pull/3922))
and have identified some area(s) that could benefit from additional
hardening measures.

These changes should help prevent potential security vulnerabilities and
improve overall code quality.

Thank you for your consideration!
🧚🤖  Powered by Pixeebot  

[Feedback](https://ask.pixee.ai/feedback) |
[Community](https://pixee-community.slack.com/signup#/domain-signup) |
[Docs](https://docs.pixee.ai/)
![](https://d1zaessa2hpsmj.cloudfront.net/pixel/v1/track?writeKey=2PI43jNm7atYvAuK7rJUz3Kcd6A&event=PR_HARDENING%7CStirling-Tools%2FStirling-PDF%7C624e04a783753f7728d85d32469b6a2b5d4b113f)

Co-authored-by: pixeebot[bot] <104101892+pixeebot[bot]@users.noreply.github.com>
2025-07-10 16:36:19 +01:00
Anthony Stirling 624e04a783 tests and formatting 2025-07-10 15:22:37 +01:00
Anthony Stirling bb9f1d4f8b extra fixes 2025-07-10 14:58:11 +01:00
0d967aa5ea Update common/src/main/java/stirling/software/common/service/TaskManager.java
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-07-10 14:55:49 +01:00
Anthony Stirling f19ff6691d cleanups 2025-07-10 14:34:43 +01:00
Anthony Stirling 98b4763c96 remove "legacy" 2025-07-10 14:30:22 +01:00
Anthony Stirling 2c9537f786 multi file async 2025-07-10 14:18:35 +01:00
1401 changed files with 25183 additions and 83798 deletions
+1 -7
View File
@@ -5,13 +5,7 @@
"Bash(mkdir:*)",
"Bash(./gradlew:*)",
"Bash(grep:*)",
"Bash(cat:*)",
"Bash(find:*)",
"Bash(grep:*)",
"Bash(rg:*)",
"Bash(strings:*)",
"Bash(pkill:*)",
"Bash(true)"
"Bash(cat:*)"
],
"deny": []
}
+2 -15
View File
@@ -49,7 +49,7 @@
"java.configuration.updateBuildConfiguration": "interactive",
"java.format.enabled": true,
"java.format.settings.profile": "GoogleStyle",
"java.format.settings.google.version": "1.28.0",
"java.format.settings.google.version": "1.26.0",
"java.format.settings.google.extra": "--aosp --skip-sorting-imports --skip-javadoc-formatting",
"java.saveActions.cleanup": true,
"java.cleanup.actions": [
@@ -79,17 +79,9 @@
".venv*/",
".vscode/",
"bin/",
"app/core/bin/",
"app/common/bin/",
"app/proprietary/bin/",
"build/",
"app/core/build/",
"app/common/build/",
"app/proprietary/build/",
"configs/",
"app/core/configs/",
"customFiles/",
"app/core/customFiles/",
"docs/",
"exampleYmlFiles",
"gradle/",
@@ -101,9 +93,6 @@
".git-blame-ignore-revs",
".gitattributes",
".gitignore",
"app/core/.gitignore",
"app/common/.gitignore",
"app/proprietary/.gitignore",
".pre-commit-config.yaml"
],
"java.signatureHelp.enabled": true,
@@ -130,9 +119,7 @@
"EditorConfig.EditorConfig", // EditorConfig support for maintaining consistent coding styles
"ms-azuretools.vscode-docker", // Docker extension for Visual Studio Code
"charliermarsh.ruff", // Ruff extension for Ruff language support
"github.vscode-github-actions", // GitHub Actions extension for Visual Studio Code
"stylelint.vscode-stylelint", // Stylelint extension for CSS and SCSS linting
"redhat.vscode-yaml" // YAML extension for Visual Studio Code
"github.vscode-github-actions" // GitHub Actions extension for Visual Studio Code
]
}
},
+7 -1
View File
@@ -31,12 +31,18 @@ indent_size = 2
# CSS files typically use an indent size of 2 spaces for better readability and alignment with community standards.
indent_size = 2
[*.{yml,yaml}]
[*.yaml]
# YAML files use an indent size of 2 spaces to maintain consistency with common YAML formatting practices.
indent_size = 2
insert_final_newline = false
trim_trailing_whitespace = false
[*.yml]
# YML files follow the same conventions as YAML files, using an indent size of 2 spaces.
indent_size = 2
insert_final_newline = false
trim_trailing_whitespace = false
[*.json]
# JSON files use an indent size of 2 spaces, which is the standard for JSON formatting.
indent_size = 2
-4
View File
@@ -1,9 +1,5 @@
# Formatting
5f771b785130154ed47952635b7acef371ffe0ec
7fa5e130d99227c2202ebddfdd91348176ec0c7b
14d4fbb2a36195eedb034785e5a5ff6a47f268c6
ee8030c1c4148062cde15c49c67d04ef03930c55
fcd41924f5f261febfa9d9a92994671f3ebc97d6
# Normalize files
55d4fda01b2f39f5b7d7b4fda5214bd7ff0fd5dd
+7 -7
View File
@@ -1,10 +1,10 @@
* text=auto eol=lf
# Ignore all JavaScript files in a directory
app/core/src/main/resources/static/pdfjs/* linguist-vendored
app/core/src/main/resources/static/pdfjs/** linguist-vendored
app/core/src/main/resources/static/pdfjs-legacy/* linguist-vendored
app/core/src/main/resources/static/pdfjs-legacy/** linguist-vendored
app/core/src/main/resources/static/css/bootstrap-icons.css linguist-vendored
app/core/src/main/resources/static/css/bootstrap.min.css linguist-vendored
app/core/src/main/resources/static/css/fonts/* linguist-vendored
stirling-pdf/src/main/resources/static/pdfjs/* linguist-vendored
stirling-pdf/src/main/resources/static/pdfjs/** linguist-vendored
stirling-pdf/src/main/resources/static/pdfjs-legacy/* linguist-vendored
stirling-pdf/src/main/resources/static/pdfjs-legacy/** linguist-vendored
stirling-pdf/src/main/resources/static/css/bootstrap-icons.css linguist-vendored
stirling-pdf/src/main/resources/static/css/bootstrap.min.css linguist-vendored
stirling-pdf/src/main/resources/static/css/fonts/* linguist-vendored
+2 -21
View File
@@ -1,21 +1,2 @@
# All PRs must be approved by Frooodle or Ludy87
* @Frooodle @Ludy87 @jbrunton96 @ConnorYoh
# Backend
/app/** @DarioGii @Frooodle @Ludy87 @jbrunton96 @ConnorYoh
#V1 frontend
/app/core/src/main/resources/static/** @reecebrowne @ConnorYoh @EthanHealy01 @jbrunton96 @Frooodle @Ludy87
/app/core/src/main/resources/templates/** @reecebrowne @ConnorYoh @EthanHealy01 @jbrunton96 @Frooodle @Ludy87
#V2 frontend
/frontend/** @reecebrowne @ConnorYoh @EthanHealy01 @jbrunton96 @Frooodle
#V2 docker
/docker/backend/** @Frooodle @Ludy87 @DarioGii @Ludy87
/docker/frontend/** @reecebrowne @ConnorYoh @EthanHealy01 @jbrunton96 @Frooodle @Ludy87
/docker/compose/** @reecebrowne @ConnorYoh @EthanHealy01 @DarioGii @jbrunton96 @Frooodle @Ludy87
#GHA (All users)
/.github/** @reecebrowne @ConnorYoh @EthanHealy01 @DarioGii @jbrunton96 @Frooodle @Ludy87
# All PRs to V1 must be approved by Frooodle
* @Frooodle @reecebrowne @Ludy87 @DarioGii @ConnorYoh
-42
View File
@@ -1,42 +0,0 @@
build: &build
- build.gradle
- app/(common|core|proprietary)/build.gradle
app: &app
- app/(common|core|proprietary)/src/main/java/**
openapi: &openapi
- *build
- *app
docker: &docker
- Dockerfile
- Dockerfile.fat
- Dockerfile.ultra-lite
- ".github/workflows/build.yml"
- scripts/init.sh
- scripts/init-without-ocr.sh
- exampleYmlFiles/**
project: &project
- app/(common|core|proprietary)/src/(main|test)/java/**
- *build
- "app/(common|core|proprietary)/src/(main|test)/resources/**/!(messages_*.properties|*.md)*"
- exampleYmlFiles/**
- gradle/**
- libs/**
- "testing/**/!(requirements*.txt|requirements*.in)*"
- *docker
- gradle.properties
- gradlew
- gradlew.bat
- launch4jConfig.xml
- settings.gradle
- frontend/**
- docker/**
frontend: &frontend
- frontend/**
- .github/workflows/testdriver.yml
- testing/**
- docker/**
+1 -7
View File
@@ -1,9 +1,4 @@
{
"label_changer": [
"Frooodle",
"Ludy87",
"balazs-szucs"
],
"repo_devs": [
"Frooodle",
"sf298",
@@ -12,7 +7,6 @@
"sbplat",
"reecebrowne",
"DarioGii",
"ConnorYoh",
"EthanHealy01"
"ConnorYoh"
]
}
+44 -64
View File
@@ -2,106 +2,88 @@ version: 1
labels:
- label: "Bugfix"
title: '^fix(\([^)]*\))?:|^fix:.*'
title: '^fix:.*'
- label: "enhancement"
title: '^feat(\([^)]*\))?:|^feat:.*'
title: '^feat:.*'
- label: "build"
title: '^build(\([^)]*\))?:|^build:.*'
title: '^build:.*'
- label: "chore"
title: '^chore(\([^)]*\))?:|^chore:.*'
title: '^chore:.*'
- label: "ci"
title: '^ci(\([^)]*\))?:|^ci:.*'
- label: "ci"
title: '^.*\(ci\):.*'
title: '^ci:.*'
- label: "perf"
title: '^perf(\([^)]*\))?:|^perf:.*'
title: '^perf:.*'
- label: "refactor"
title: '^refactor(\([^)]*\))?:|^refactor:.*'
title: '^refactor:.*'
- label: "revert"
title: '^revert(\([^)]*\))?:|^revert:.*'
title: '^revert:.*'
- label: "style"
title: '^style(\([^)]*\))?:|^style:.*'
title: '^style:.*'
- label: "Documentation"
title: '^docs(\([^)]*\))?:|^docs:.*'
- label: "Documentation"
title: '^.*\(docs\):.*'
- label: "dependencies"
title: '^deps(\([^)]*\))?:|^deps:.*'
- label: "dependencies"
title: '^.*\(deps\):.*'
title: '^docs:.*'
- label: 'API'
title: '.*openapi.*|.*swagger.*|.*api.*'
- label: 'v2'
base-branch: 'V2'
title: '.*openapi.*'
- label: 'Translation'
files:
- 'app/core/src/main/resources/messages_[a-zA-Z_]{2}_[a-zA-Z_]{2,7}.properties'
- 'stirling-pdf/src/main/resources/messages_[a-zA-Z_]{2}_[a-zA-Z_]{2,7}.properties'
- 'scripts/ignore_translation.toml'
- 'app/core/src/main/resources/templates/fragments/languages.html'
- 'stirling-pdf/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/**'
- 'stirling-pdf/src/main/resources/templates/.*'
- 'proprietary/src/main/resources/templates/.*'
- 'stirling-pdf/src/main/resources/static/.*'
- 'proprietary/src/main/resources/static/.*'
- 'stirling-pdf/src/main/java/stirling/software/SPDF/controller/web/.*'
- 'stirling-pdf/src/main/java/stirling/software/SPDF/UI/.*'
- 'proprietary/src/main/java/stirling/software/proprietary/security/controller/web/.*'
- label: 'Java'
files:
- 'app/common/src/main/java/.*.java'
- 'app/proprietary/src/main/java/.*.java'
- 'app/core/src/main/java/.*.java'
- 'common/src/main/java/.*.java'
- 'proprietary/src/main/java/.*.java'
- 'stirling-pdf/src/main/java/.*.java'
- label: 'Back End'
files:
- 'app/core/src/main/java/stirling/software/SPDF/config/.*'
- 'app/core/src/main/java/stirling/software/SPDF/controller/.*'
- 'app/core/src/main/resources/settings.yml.template'
- 'app/core/src/main/resources/application.properties'
- 'app/core/src/main/resources/banner.txt'
- 'app/core/src/main/resources/static/python/png_to_webp.py'
- 'app/core/src/main/resources/static/python/split_photos.py'
- 'app/core/src/main/resources/static/pipeline/defaultWebUIConfigs/**'
- 'stirling-pdf/src/main/java/stirling/software/SPDF/config/.*'
- 'stirling-pdf/src/main/java/stirling/software/SPDF/controller/.*'
- 'stirling-pdf/src/main/resources/settings.yml.template'
- 'stirling-pdf/src/main/resources/application.properties'
- 'stirling-pdf/src/main/resources/banner.txt'
- 'scripts/png_to_webp.py'
- 'split_photos.py'
- 'application.properties'
- label: 'Security'
files:
- 'app/proprietary/src/main/java/stirling/software/proprietary/security/.*'
- 'proprietary/src/main/java/stirling/software/proprietary/security/.*'
- 'scripts/download-security-jar.sh'
- '.github/workflows/dependency-review.yml'
- '.github/workflows/scorecards.yml'
- label: 'API'
files:
- 'app/core/src/main/java/stirling/software/SPDF/config/OpenApiConfig.java'
- 'app/core/src/main/java/stirling/software/SPDF/controller/web/MetricsController.java'
- 'app/core/src/main/java/stirling/software/SPDF/controller/api/.*'
- 'app/core/src/main/java/stirling/software/SPDF/model/api/.*'
- 'app/core/src/main/java/stirling/software/SPDF/service/ApiDocService.java'
- 'app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/.*'
- 'app/core/src/main/resources/static/python/png_to_webp.py'
- 'app/core/src/main/resources/static/python/split_photos.py'
- 'stirling-pdf/src/main/java/stirling/software/SPDF/config/OpenApiConfig.java'
- 'stirling-pdf/src/main/java/stirling/software/SPDF/controller/web/MetricsController.java'
- 'stirling-pdf/src/main/java/stirling/software/SPDF/controller/api/.*'
- 'stirling-pdf/src/main/java/stirling/software/SPDF/model/api/.*'
- 'proprietary/src/main/java/stirling/software/proprietary/security/controller/api/.*'
- 'scripts/png_to_webp.py'
- 'split_photos.py'
- '.github/workflows/swagger.yml'
- label: 'Documentation'
@@ -124,7 +106,6 @@ labels:
- 'scripts/installFonts.sh'
- 'test.sh'
- 'test2.sh'
- 'docker/**'
- label: 'Devtools'
files:
@@ -135,13 +116,12 @@ labels:
- '.pre-commit-config'
- '.github/workflows/pre_commit.yml'
- 'devGuide/.*'
- 'devTools/.*'
- label: 'Test'
files:
- 'app/common/src/test/.*'
- 'app/proprietary/src/test/.*'
- 'app/core/src/test/.*'
- 'common/src/test/.*'
- 'proprietary/src/test/.*'
- 'stirling-pdf/src/test/.*'
- 'testing/.*'
- '.github/workflows/scorecards.yml'
- 'exampleYmlFiles/test_cicd.yml'
@@ -157,6 +137,6 @@ labels:
- 'gradlew.bat'
- 'settings.gradle'
- 'build.gradle'
- 'app/common/build.gradle'
- 'app/proprietary/build.gradle'
- 'app/core/build.gradle'
- 'common/build.gradle'
- 'proprietary/build.gradle'
- 'stirling-pdf/build.gradle'
+86
View File
@@ -0,0 +1,86 @@
Translation:
- changed-files:
- any-glob-to-any-file: 'stirling-pdf/src/main/resources/messages_*_*.properties'
- any-glob-to-any-file: 'scripts/ignore_translation.toml'
- any-glob-to-any-file: 'stirling-pdf/src/main/resources/templates/fragments/languages.html'
Front End:
- changed-files:
- any-glob-to-any-file: 'stirling-pdf/src/main/resources/templates/**/*'
- any-glob-to-any-file: 'stirling-pdf/src/main/resources/static/**/*'
- any-glob-to-any-file: 'stirling-pdf/src/main/java/stirling/software/SPDF/controller/web/**'
- any-glob-to-any-file: 'stirling-pdf/src/main/java/stirling/software/SPDF/UI/**/*'
Java:
- changed-files:
- any-glob-to-any-file: 'common/src/main/java/**/*.java'
- any-glob-to-any-file: 'proprietary/src/main/java/**/*.java'
- any-glob-to-any-file: 'stirling-pdf/src/main/java/**/*.java'
Back End:
- changed-files:
- any-glob-to-any-file: 'stirling-pdf/src/main/java/stirling/software/SPDF/config/**/*'
- any-glob-to-any-file: 'stirling-pdf/src/main/java/stirling/software/SPDF/controller/**/*'
- any-glob-to-any-file: 'stirling-pdf/src/main/resources/settings.yml.template'
- any-glob-to-any-file: 'stirling-pdf/src/main/resources/application.properties'
- any-glob-to-any-file: 'stirling-pdf/src/main/resources/banner.txt'
- any-glob-to-any-file: 'scripts/png_to_webp.py'
- any-glob-to-any-file: 'split_photos.py'
Security:
- changed-files:
- any-glob-to-any-file: 'proprietary/src/main/java/stirling/software/proprietary/security/**/*'
- any-glob-to-any-file: 'scripts/download-security-jar.sh'
- any-glob-to-any-file: '.github/workflows/dependency-review.yml'
- any-glob-to-any-file: '.github/workflows/scorecards.yml'
API:
- changed-files:
- any-glob-to-any-file: 'stirling-pdf/src/main/java/stirling/software/SPDF/config/OpenApiConfig.java'
- any-glob-to-any-file: 'stirling-pdf/src/main/java/stirling/software/SPDF/controller/web/MetricsController.java'
- any-glob-to-any-file: 'stirling-pdf/src/main/java/stirling/software/SPDF/controller/api/**/*'
- any-glob-to-any-file: 'stirling-pdf/src/main/java/stirling/software/SPDF/model/api/**/*'
- any-glob-to-any-file: 'scripts/png_to_webp.py'
- any-glob-to-any-file: 'split_photos.py'
- any-glob-to-any-file: '.github/workflows/swagger.yml'
Documentation:
- changed-files:
- any-glob-to-any-file: '**/*.md'
- any-glob-to-any-file: 'scripts/counter_translation.py'
- any-glob-to-any-file: 'scripts/ignore_translation.toml'
Docker:
- changed-files:
- any-glob-to-any-file: '.github/workflows/build.yml'
- any-glob-to-any-file: '.github/workflows/push-docker.yml'
- any-glob-to-any-file: 'Dockerfile'
- any-glob-to-any-file: 'Dockerfile.fat'
- any-glob-to-any-file: 'Dockerfile.ultra-lite'
- any-glob-to-any-file: 'exampleYmlFiles/*.yml'
- any-glob-to-any-file: 'scripts/download-security-jar.sh'
- any-glob-to-any-file: 'scripts/init.sh'
- any-glob-to-any-file: 'scripts/init-without-ocr.sh'
- any-glob-to-any-file: 'scripts/installFonts.sh'
- any-glob-to-any-file: 'test.sh'
- any-glob-to-any-file: 'test2.sh'
Devtools:
- changed-files:
- any-glob-to-any-file: '.devcontainer/**/*'
- any-glob-to-any-file: 'Dockerfile.dev'
Test:
- changed-files:
- any-glob-to-any-file: 'cucumber/**/*'
- any-glob-to-any-file: 'common/src/test/**/*'
- any-glob-to-any-file: 'proprietary/src/test/**/*'
- any-glob-to-any-file: 'stirling-pdf/src/test/**/*'
- any-glob-to-any-file: 'src/testing/**/*'
- any-glob-to-any-file: '.pre-commit-config'
- any-glob-to-any-file: '.github/workflows/pre_commit.yml'
- any-glob-to-any-file: '.github/workflows/scorecards.yml'
Github:
- changed-files:
- any-glob-to-any-file: '.github/**/*'
-12
View File
@@ -42,7 +42,6 @@
- name: "Front End"
color: "BBD2F1"
description: "Issues or pull requests related to front-end development"
from_name: "frontend"
- name: "github-actions"
description: "Pull requests that update GitHub Actions code"
color: "999999"
@@ -78,12 +77,10 @@
- name: "Translation"
color: "9FABF9"
from_name: "translation"
description: "Issues or pull requests related to translation"
- name: "upstream"
color: "DEDEDE"
- name: "v2"
color: "FFFF00"
description: "Issues or pull requests related to the v2 branch"
- name: "wontfix"
description: "This will not be worked on"
color: "FFFFFF"
@@ -178,12 +175,3 @@
description: "This PR changes 1000+ lines ignoring generated files."
- name: "to research"
color: "FBCA04"
- name: "pr-deployed"
color: "00FF00"
description: "Pull request has been deployed to a test environment"
- name: "codex"
color: "ededed"
description: "chatgpt AI generated code"
- name: "break-change"
color: "FF0000"
description: "This PR introduces a breaking API change."
-4
View File
@@ -27,10 +27,6 @@ Closes #(issue_number)
- [ ] I have updated relevant docs on [Stirling-PDF's doc repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/) (if functionality has heavily changed)
- [ ] I have read the section [Add New Translation Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags) (for new translation tags only)
### Translations (if applicable)
- [ ] I ran [`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md)
### UI Changes (if applicable)
- [ ] Screenshots or videos demonstrating the UI changes are attached (e.g., as comments or direct attachments in the PR)
-4
View File
@@ -1,9 +1,5 @@
changelog:
categories:
- title: Breaking Changes
labels:
- break-change
- title: Bug Fixes
labels:
- Bug
+5 -7
View File
@@ -197,7 +197,7 @@ def check_for_differences(reference_file, file_list, branch, actor):
if len(file_list) == 1:
file_arr = file_list[0].split()
base_dir = os.path.abspath(
os.path.join(os.getcwd(), "app", "core", "src", "main", "resources")
os.path.join(os.getcwd(), "stirling-pdf", "src", "main", "resources")
)
for file_path in file_arr:
@@ -219,14 +219,13 @@ def check_for_differences(reference_file, file_list, branch, actor):
# only local windows command
not file_normpath.startswith(
os.path.join(
"", "app", "core", "src", "main", "resources", "messages_"
"", "stirling-pdf", "src", "main", "resources", "messages_"
)
)
and not file_normpath.startswith(
os.path.join(
os.getcwd(),
"app",
"core",
"stirling-pdf",
"src",
"main",
"resources",
@@ -329,7 +328,7 @@ def check_for_differences(reference_file, file_list, branch, actor):
report.append("## ❌ Overall Check Status: **_Failed_**")
report.append("")
report.append(
f"@{actor} please check your translation if it conforms to the standard. Follow the format of [messages_en_GB.properties](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/app/core/src/main/resources/messages_en_GB.properties)"
f"@{actor} please check your translation if it conforms to the standard. Follow the format of [messages_en_GB.properties](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/stirling-pdf/src/main/resources/messages_en_GB.properties)"
)
else:
report.append("## ✅ Overall Check Status: **_Success_**")
@@ -390,8 +389,7 @@ if __name__ == "__main__":
file_list = glob.glob(
os.path.join(
os.getcwd(),
"app",
"core",
"stirling-pdf",
"src",
"main",
"resources",
-9
View File
@@ -1,9 +0,0 @@
pip
setuptools
WeasyPrint
pdf2image
pillow
unoserver
opencv-python-headless
pre-commit
brotli @ git+https://github.com/google/brotli.git@028fb5a23661f123017c060daa546b55cf4bde29
-556
View File
@@ -1,556 +0,0 @@
#
# This file is autogenerated by pip-compile with Python 3.12
# by the following command:
#
# pip-compile --allow-unsafe --generate-hashes --output-file='.github\scripts\requirements_dev.txt' --strip-extras '.github\scripts\requirements_dev.in'
#
# WARNING: pip install will require the following package to be hashed.
# Consider using a hashable URL like https://github.com/jazzband/pip-tools/archive/SOMECOMMIT.zip
# CVE-2025-6176 mitigation: pin brotli to a specific commit
brotli @ git+https://github.com/google/brotli.git@028fb5a23661f123017c060daa546b55cf4bde29
# via
# -r .github/scripts/requirements_dev.in
# fonttools
cffi==2.0.0 \
--hash=sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb \
--hash=sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b \
--hash=sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f \
--hash=sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9 \
--hash=sha256:0cf2d91ecc3fcc0625c2c530fe004f82c110405f101548512cce44322fa8ac44 \
--hash=sha256:0f6084a0ea23d05d20c3edcda20c3d006f9b6f3fefeac38f59262e10cef47ee2 \
--hash=sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c \
--hash=sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75 \
--hash=sha256:1cd13c99ce269b3ed80b417dcd591415d3372bcac067009b6e0f59c7d4015e65 \
--hash=sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e \
--hash=sha256:1f72fb8906754ac8a2cc3f9f5aaa298070652a0ffae577e0ea9bd480dc3c931a \
--hash=sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e \
--hash=sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25 \
--hash=sha256:2081580ebb843f759b9f617314a24ed5738c51d2aee65d31e02f6f7a2b97707a \
--hash=sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe \
--hash=sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b \
--hash=sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91 \
--hash=sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592 \
--hash=sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187 \
--hash=sha256:2de9a304e27f7596cd03d16f1b7c72219bd944e99cc52b84d0145aefb07cbd3c \
--hash=sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1 \
--hash=sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94 \
--hash=sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba \
--hash=sha256:3e837e369566884707ddaf85fc1744b47575005c0a229de3327f8f9a20f4efeb \
--hash=sha256:3f4d46d8b35698056ec29bca21546e1551a205058ae1a181d871e278b0b28165 \
--hash=sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529 \
--hash=sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca \
--hash=sha256:4647afc2f90d1ddd33441e5b0e85b16b12ddec4fca55f0d9671fef036ecca27c \
--hash=sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6 \
--hash=sha256:53f77cbe57044e88bbd5ed26ac1d0514d2acf0591dd6bb02a3ae37f76811b80c \
--hash=sha256:5eda85d6d1879e692d546a078b44251cdd08dd1cfb98dfb77b670c97cee49ea0 \
--hash=sha256:5fed36fccc0612a53f1d4d9a816b50a36702c28a2aa880cb8a122b3466638743 \
--hash=sha256:61d028e90346df14fedc3d1e5441df818d095f3b87d286825dfcbd6459b7ef63 \
--hash=sha256:66f011380d0e49ed280c789fbd08ff0d40968ee7b665575489afa95c98196ab5 \
--hash=sha256:6824f87845e3396029f3820c206e459ccc91760e8fa24422f8b0c3d1731cbec5 \
--hash=sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4 \
--hash=sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d \
--hash=sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b \
--hash=sha256:730cacb21e1bdff3ce90babf007d0a0917cc3e6492f336c2f0134101e0944f93 \
--hash=sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205 \
--hash=sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27 \
--hash=sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512 \
--hash=sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d \
--hash=sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c \
--hash=sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037 \
--hash=sha256:8941aaadaf67246224cee8c3803777eed332a19d909b47e29c9842ef1e79ac26 \
--hash=sha256:89472c9762729b5ae1ad974b777416bfda4ac5642423fa93bd57a09204712322 \
--hash=sha256:8ea985900c5c95ce9db1745f7933eeef5d314f0565b27625d9a10ec9881e1bfb \
--hash=sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c \
--hash=sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8 \
--hash=sha256:9332088d75dc3241c702d852d4671613136d90fa6881da7d770a483fd05248b4 \
--hash=sha256:94698a9c5f91f9d138526b48fe26a199609544591f859c870d477351dc7b2414 \
--hash=sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9 \
--hash=sha256:9de40a7b0323d889cf8d23d1ef214f565ab154443c42737dfe52ff82cf857664 \
--hash=sha256:a05d0c237b3349096d3981b727493e22147f934b20f6f125a3eba8f994bec4a9 \
--hash=sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775 \
--hash=sha256:b18a3ed7d5b3bd8d9ef7a8cb226502c6bf8308df1525e1cc676c3680e7176739 \
--hash=sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc \
--hash=sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062 \
--hash=sha256:b4c854ef3adc177950a8dfc81a86f5115d2abd545751a304c5bcf2c2c7283cfe \
--hash=sha256:b882b3df248017dba09d6b16defe9b5c407fe32fc7c65a9c69798e6175601be9 \
--hash=sha256:baf5215e0ab74c16e2dd324e8ec067ef59e41125d3eade2b863d294fd5035c92 \
--hash=sha256:c649e3a33450ec82378822b3dad03cc228b8f5963c0c12fc3b1e0ab940f768a5 \
--hash=sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13 \
--hash=sha256:c6638687455baf640e37344fe26d37c404db8b80d037c3d29f58fe8d1c3b194d \
--hash=sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26 \
--hash=sha256:cb527a79772e5ef98fb1d700678fe031e353e765d1ca2d409c92263c6d43e09f \
--hash=sha256:cf364028c016c03078a23b503f02058f1814320a56ad535686f90565636a9495 \
--hash=sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b \
--hash=sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6 \
--hash=sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c \
--hash=sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef \
--hash=sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5 \
--hash=sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18 \
--hash=sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad \
--hash=sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3 \
--hash=sha256:de8dad4425a6ca6e4e5e297b27b5c824ecc7581910bf9aee86cb6835e6812aa7 \
--hash=sha256:e11e82b744887154b182fd3e7e8512418446501191994dbf9c9fc1f32cc8efd5 \
--hash=sha256:e6e73b9e02893c764e7e8d5bb5ce277f1a009cd5243f8228f75f842bf937c534 \
--hash=sha256:f73b96c41e3b2adedc34a7356e64c8eb96e03a3782b535e043a986276ce12a49 \
--hash=sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2 \
--hash=sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5 \
--hash=sha256:fc7de24befaeae77ba923797c7c87834c73648a05a4bde34b3b7e5588973a453 \
--hash=sha256:fe562eb1a64e67dd297ccc4f5addea2501664954f2692b69a76449ec7913ecbf
# via weasyprint
cfgv==3.4.0 \
--hash=sha256:b7265b1f29fd3316bfcd2b330d63d024f2bfd8bcb8b0272f8e19a504856c48f9 \
--hash=sha256:e52591d4c5f5dead8e0f673fb16db7949d2cfb3f7da4582893288f0ded8fe560
# via pre-commit
cssselect2==0.8.0 \
--hash=sha256:46fc70ebc41ced7a32cd42d58b1884d72ade23d21e5a4eaaf022401c13f0e76e \
--hash=sha256:7674ffb954a3b46162392aee2a3a0aedb2e14ecf99fcc28644900f4e6e3e9d3a
# via weasyprint
distlib==0.4.0 \
--hash=sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16 \
--hash=sha256:feec40075be03a04501a973d81f633735b4b69f98b05450592310c0f401a4e0d
# via virtualenv
filelock==3.20.0 \
--hash=sha256:339b4732ffda5cd79b13f4e2711a31b0365ce445d95d243bb996273d072546a2 \
--hash=sha256:711e943b4ec6be42e1d4e6690b48dc175c822967466bb31c0c293f34334c13f4
# via virtualenv
fonttools==4.60.1 \
--hash=sha256:022beaea4b73a70295b688f817ddc24ed3e3418b5036ffcd5658141184ef0d0c \
--hash=sha256:026290e4ec76583881763fac284aca67365e0be9f13a7fb137257096114cb3bc \
--hash=sha256:0b0835ed15dd5b40d726bb61c846a688f5b4ce2208ec68779bc81860adb5851a \
--hash=sha256:0eae96373e4b7c9e45d099d7a523444e3554360927225c1cdae221a58a45b856 \
--hash=sha256:122e1a8ada290423c493491d002f622b1992b1ab0b488c68e31c413390dc7eb2 \
--hash=sha256:1410155d0e764a4615774e5c2c6fc516259fe3eca5882f034eb9bfdbee056259 \
--hash=sha256:145daa14bf24824b677b9357c5e44fd8895c2a8f53596e1b9ea3496081dc692c \
--hash=sha256:1525796c3ffe27bb6268ed2a1bb0dcf214d561dfaf04728abf01489eb5339dce \
--hash=sha256:154cb6ee417e417bf5f7c42fe25858c9140c26f647c7347c06f0cc2d47eff003 \
--hash=sha256:2299df884c11162617a66b7c316957d74a18e3758c0274762d2cc87df7bc0272 \
--hash=sha256:2409d5fb7b55fd70f715e6d34e7a6e4f7511b8ad29a49d6df225ee76da76dd77 \
--hash=sha256:268ecda8ca6cb5c4f044b1fb9b3b376e8cd1b361cef275082429dc4174907038 \
--hash=sha256:282dafa55f9659e8999110bd8ed422ebe1c8aecd0dc396550b038e6c9a08b8ea \
--hash=sha256:2ee06fc57512144d8b0445194c2da9f190f61ad51e230f14836286470c99f854 \
--hash=sha256:3630e86c484263eaac71d117085d509cbcf7b18f677906824e4bace598fb70d2 \
--hash=sha256:398447f3d8c0c786cbf1209711e79080a40761eb44b27cdafffb48f52bcec258 \
--hash=sha256:4ba4bd646e86de16160f0fb72e31c3b9b7d0721c3e5b26b9fa2fc931dfdb2652 \
--hash=sha256:5664fd1a9ea7f244487ac8f10340c4e37664675e8667d6fee420766e0fb3cf08 \
--hash=sha256:583b7f8e3c49486e4d489ad1deacfb8d5be54a8ef34d6df824f6a171f8511d99 \
--hash=sha256:596ecaca36367027d525b3b426d8a8208169d09edcf8c7506aceb3a38bfb55c7 \
--hash=sha256:5c1015318e4fec75dd4943ad5f6a206d9727adf97410d58b7e32ab644a807914 \
--hash=sha256:66929e2ea2810c6533a5184f938502cfdaea4bc3efb7130d8cc02e1c1b4108d6 \
--hash=sha256:6ec722ee589e89a89f5b7574f5c45604030aa6ae24cb2c751e2707193b466fed \
--hash=sha256:6f68576bb4bbf6060c7ab047b1574a1ebe5c50a17de62830079967b211059ebb \
--hash=sha256:7473a8ed9ed09aeaa191301244a5a9dbe46fe0bf54f9d6cd21d83044c3321217 \
--hash=sha256:7b0c6d57ab00dae9529f3faf187f2254ea0aa1e04215cf2f1a8ec277c96661bc \
--hash=sha256:7b4c32e232a71f63a5d00259ca3d88345ce2a43295bb049d21061f338124246f \
--hash=sha256:8177ec9676ea6e1793c8a084a90b65a9f778771998eb919d05db6d4b1c0b114c \
--hash=sha256:839565cbf14645952d933853e8ade66a463684ed6ed6c9345d0faf1f0e868877 \
--hash=sha256:875cb7764708b3132637f6c5fb385b16eeba0f7ac9fa45a69d35e09b47045801 \
--hash=sha256:8a44788d9d91df72d1a5eac49b31aeb887a5f4aab761b4cffc4196c74907ea85 \
--hash=sha256:8b4eb332f9501cb1cd3d4d099374a1e1306783ff95489a1026bde9eb02ccc34a \
--hash=sha256:906306ac7afe2156fcf0042173d6ebbb05416af70f6b370967b47f8f00103bbb \
--hash=sha256:992775c9fbe2cf794786fa0ffca7f09f564ba3499b8fe9f2f80bd7197db60383 \
--hash=sha256:996a4d1834524adbb423385d5a629b868ef9d774670856c63c9a0408a3063401 \
--hash=sha256:9a52f254ce051e196b8fe2af4634c2d2f02c981756c6464dc192f1b6050b4e28 \
--hash=sha256:9d0ced62b59e0430b3690dbc5373df1c2aa7585e9a8ce38eff87f0fd993c5b01 \
--hash=sha256:a140761c4ff63d0cb9256ac752f230460ee225ccef4ad8f68affc723c88e2036 \
--hash=sha256:a184b2ea57b13680ab6d5fbde99ccef152c95c06746cb7718c583abd8f945ccc \
--hash=sha256:a3db56f153bd4c5c2b619ab02c5db5192e222150ce5a1bc10f16164714bc39ac \
--hash=sha256:a46b2f450bc79e06ef3b6394f0c68660529ed51692606ad7f953fc2e448bc903 \
--hash=sha256:a884aef09d45ba1206712c7dbda5829562d3fea7726935d3289d343232ecb0d3 \
--hash=sha256:b2cf105cee600d2de04ca3cfa1f74f1127f8455b71dbad02b9da6ec266e116d6 \
--hash=sha256:b33a7884fabd72bdf5f910d0cf46be50dce86a0362a65cfc746a4168c67eb96c \
--hash=sha256:b42d86938e8dda1cd9a1a87a6d82f1818eaf933348429653559a458d027446da \
--hash=sha256:b6379e7546ba4ae4b18f8ae2b9bc5960936007a1c0e30b342f662577e8bc3299 \
--hash=sha256:c7420a2696a44650120cdd269a5d2e56a477e2bfa9d95e86229059beb1c19e15 \
--hash=sha256:c8651e0d4b3bdeda6602b85fdc2abbefc1b41e573ecb37b6779c4ca50753a199 \
--hash=sha256:d066ea419f719ed87bc2c99a4a4bfd77c2e5949cb724588b9dd58f3fd90b92bf \
--hash=sha256:e6c58beb17380f7c2ea181ea11e7db8c0ceb474c9dd45f48e71e2cb577d146a1 \
--hash=sha256:e852d9dda9f93ad3651ae1e3bb770eac544ec93c3807888798eccddf84596537 \
--hash=sha256:ec3681a0cb34c255d76dd9d865a55f260164adb9fa02628415cdc2d43ee2c05d \
--hash=sha256:ee0c0b3b35b34f782afc673d503167157094a16f442ace7c6c5e0ca80b08f50c \
--hash=sha256:eedacb5c5d22b7097482fa834bda0dafa3d914a4e829ec83cdea2a01f8c813c4 \
--hash=sha256:ef00af0439ebfee806b25f24c8f92109157ff3fac5731dc7867957812e87b8d9 \
--hash=sha256:f0e8817c7d1a0c2eedebf57ef9a9896f3ea23324769a9a2061a80fe8852705ed \
--hash=sha256:f3d5be054c461d6a2268831f04091dc82753176f6ea06dc6047a5e168265a987 \
--hash=sha256:f4b5c37a5f40e4d733d3bbaaef082149bee5a5ea3156a785ff64d949bd1353fa
# via weasyprint
identify==2.6.15 \
--hash=sha256:1181ef7608e00704db228516541eb83a88a9f94433a8c80bb9b5bd54b1d81757 \
--hash=sha256:e4f4864b96c6557ef2a1e1c951771838f4edc9df3a72ec7118b338801b11c7bf
# via pre-commit
nodeenv==1.9.1 \
--hash=sha256:6ec12890a2dab7946721edbfbcd91f3319c6ccc9aec47be7c7e6b7011ee6645f \
--hash=sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9
# via pre-commit
numpy==2.2.6 \
--hash=sha256:038613e9fb8c72b0a41f025a7e4c3f0b7a1b5d768ece4796b674c8f3fe13efff \
--hash=sha256:0678000bb9ac1475cd454c6b8c799206af8107e310843532b04d49649c717a47 \
--hash=sha256:0811bb762109d9708cca4d0b13c4f67146e3c3b7cf8d34018c722adb2d957c84 \
--hash=sha256:0b605b275d7bd0c640cad4e5d30fa701a8d59302e127e5f79138ad62762c3e3d \
--hash=sha256:0bca768cd85ae743b2affdc762d617eddf3bcf8724435498a1e80132d04879e6 \
--hash=sha256:1bc23a79bfabc5d056d106f9befb8d50c31ced2fbc70eedb8155aec74a45798f \
--hash=sha256:287cc3162b6f01463ccd86be154f284d0893d2b3ed7292439ea97eafa8170e0b \
--hash=sha256:37c0ca431f82cd5fa716eca9506aefcabc247fb27ba69c5062a6d3ade8cf8f49 \
--hash=sha256:37e990a01ae6ec7fe7fa1c26c55ecb672dd98b19c3d0e1d1f326fa13cb38d163 \
--hash=sha256:389d771b1623ec92636b0786bc4ae56abafad4a4c513d36a55dce14bd9ce8571 \
--hash=sha256:3d70692235e759f260c3d837193090014aebdf026dfd167834bcba43e30c2a42 \
--hash=sha256:41c5a21f4a04fa86436124d388f6ed60a9343a6f767fced1a8a71c3fbca038ff \
--hash=sha256:481b49095335f8eed42e39e8041327c05b0f6f4780488f61286ed3c01368d491 \
--hash=sha256:4eeaae00d789f66c7a25ac5f34b71a7035bb474e679f410e5e1a94deb24cf2d4 \
--hash=sha256:55a4d33fa519660d69614a9fad433be87e5252f4b03850642f88993f7b2ca566 \
--hash=sha256:5a6429d4be8ca66d889b7cf70f536a397dc45ba6faeb5f8c5427935d9592e9cf \
--hash=sha256:5bd4fc3ac8926b3819797a7c0e2631eb889b4118a9898c84f585a54d475b7e40 \
--hash=sha256:5beb72339d9d4fa36522fc63802f469b13cdbe4fdab4a288f0c441b74272ebfd \
--hash=sha256:6031dd6dfecc0cf9f668681a37648373bddd6421fff6c66ec1624eed0180ee06 \
--hash=sha256:71594f7c51a18e728451bb50cc60a3ce4e6538822731b2933209a1f3614e9282 \
--hash=sha256:74d4531beb257d2c3f4b261bfb0fc09e0f9ebb8842d82a7b4209415896adc680 \
--hash=sha256:7befc596a7dc9da8a337f79802ee8adb30a552a94f792b9c9d18c840055907db \
--hash=sha256:894b3a42502226a1cac872f840030665f33326fc3dac8e57c607905773cdcde3 \
--hash=sha256:8e41fd67c52b86603a91c1a505ebaef50b3314de0213461c7a6e99c9a3beff90 \
--hash=sha256:8e9ace4a37db23421249ed236fdcdd457d671e25146786dfc96835cd951aa7c1 \
--hash=sha256:8fc377d995680230e83241d8a96def29f204b5782f371c532579b4f20607a289 \
--hash=sha256:9551a499bf125c1d4f9e250377c1ee2eddd02e01eac6644c080162c0c51778ab \
--hash=sha256:b0544343a702fa80c95ad5d3d608ea3599dd54d4632df855e4c8d24eb6ecfa1c \
--hash=sha256:b093dd74e50a8cba3e873868d9e93a85b78e0daf2e98c6797566ad8044e8363d \
--hash=sha256:b412caa66f72040e6d268491a59f2c43bf03eb6c96dd8f0307829feb7fa2b6fb \
--hash=sha256:b4f13750ce79751586ae2eb824ba7e1e8dba64784086c98cdbbcc6a42112ce0d \
--hash=sha256:b64d8d4d17135e00c8e346e0a738deb17e754230d7e0810ac5012750bbd85a5a \
--hash=sha256:ba10f8411898fc418a521833e014a77d3ca01c15b0c6cdcce6a0d2897e6dbbdf \
--hash=sha256:bd48227a919f1bafbdda0583705e547892342c26fb127219d60a5c36882609d1 \
--hash=sha256:c1f9540be57940698ed329904db803cf7a402f3fc200bfe599334c9bd84a40b2 \
--hash=sha256:c820a93b0255bc360f53eca31a0e676fd1101f673dda8da93454a12e23fc5f7a \
--hash=sha256:ce47521a4754c8f4593837384bd3424880629f718d87c5d44f8ed763edd63543 \
--hash=sha256:d042d24c90c41b54fd506da306759e06e568864df8ec17ccc17e9e884634fd00 \
--hash=sha256:de749064336d37e340f640b05f24e9e3dd678c57318c7289d222a8a2f543e90c \
--hash=sha256:e1dda9c7e08dc141e0247a5b8f49cf05984955246a327d4c48bda16821947b2f \
--hash=sha256:e29554e2bef54a90aa5cc07da6ce955accb83f21ab5de01a62c8478897b264fd \
--hash=sha256:e3143e4451880bed956e706a3220b4e5cf6172ef05fcc397f6f36a550b1dd868 \
--hash=sha256:e8213002e427c69c45a52bbd94163084025f533a55a59d6f9c5b820774ef3303 \
--hash=sha256:efd28d4e9cd7d7a8d39074a4d44c63eda73401580c5c76acda2ce969e0a38e83 \
--hash=sha256:f0fd6321b839904e15c46e0d257fdd101dd7f530fe03fd6359c1ea63738703f3 \
--hash=sha256:f1372f041402e37e5e633e586f62aa53de2eac8d98cbfb822806ce4bbefcb74d \
--hash=sha256:f2618db89be1b4e05f7a1a847a9c1c0abd63e63a1607d892dd54668dd92faf87 \
--hash=sha256:f447e6acb680fd307f40d3da4852208af94afdfab89cf850986c3ca00562f4fa \
--hash=sha256:f92729c95468a2f4f15e9bb94c432a9229d0d50de67304399627a943201baa2f \
--hash=sha256:f9f1adb22318e121c5c69a09142811a201ef17ab257a1e66ca3025065b7f53ae \
--hash=sha256:fc0c5673685c508a142ca65209b4e79ed6740a4ed6b2267dbba90f34b0b3cfda \
--hash=sha256:fc7b73d02efb0e18c000e9ad8b83480dfcd5dfd11065997ed4c6747470ae8915 \
--hash=sha256:fd83c01228a688733f1ded5201c678f0c53ecc1006ffbc404db9f7a899ac6249 \
--hash=sha256:fe27749d33bb772c80dcd84ae7e8df2adc920ae8297400dabec45f0dedb3f6de \
--hash=sha256:fee4236c876c4e8369388054d02d0e9bb84821feb1a64dd59e137e6511a551f8
# via opencv-python-headless
opencv-python-headless==4.12.0.88 \
--hash=sha256:1e58d664809b3350c1123484dd441e1667cd7bed3086db1b9ea1b6f6cb20b50e \
--hash=sha256:236c8df54a90f4d02076e6f9c1cc763d794542e886c576a6fee46ec8ff75a7a9 \
--hash=sha256:365bb2e486b50feffc2d07a405b953a8f3e8eaa63865bc650034e5c71e7a5154 \
--hash=sha256:86b413bdd6c6bf497832e346cd5371995de148e579b9774f8eba686dee3f5528 \
--hash=sha256:aeb4b13ecb8b4a0beb2668ea07928160ea7c2cd2d9b5ef571bbee6bafe9cc8d0 \
--hash=sha256:cfdc017ddf2e59b6c2f53bc12d74b6b0be7ded4ec59083ea70763921af2b6c09 \
--hash=sha256:fde2cf5c51e4def5f2132d78e0c08f9c14783cd67356922182c6845b9af87dbd
# via -r .github/scripts/requirements_dev.in
pdf2image==1.17.0 \
--hash=sha256:eaa959bc116b420dd7ec415fcae49b98100dda3dd18cd2fdfa86d09f112f6d57 \
--hash=sha256:ecdd58d7afb810dffe21ef2b1bbc057ef434dabbac6c33778a38a3f7744a27e2
# via -r .github/scripts/requirements_dev.in
pillow==12.0.0 \
--hash=sha256:0869154a2d0546545cde61d1789a6524319fc1897d9ee31218eae7a60ccc5643 \
--hash=sha256:09f2d0abef9e4e2f349305a4f8cc784a8a6c2f58a8c4892eea13b10a943bd26e \
--hash=sha256:0b817e7035ea7f6b942c13aa03bb554fc44fea70838ea21f8eb31c638326584e \
--hash=sha256:0fd00cac9c03256c8b2ff58f162ebcd2587ad3e1f2e397eab718c47e24d231cc \
--hash=sha256:110486b79f2d112cf6add83b28b627e369219388f64ef2f960fef9ebaf54c642 \
--hash=sha256:1979f4566bb96c1e50a62d9831e2ea2d1211761e5662afc545fa766f996632f6 \
--hash=sha256:1ac11e8ea4f611c3c0147424eae514028b5e9077dd99ab91e1bd7bc33ff145e1 \
--hash=sha256:1b1b133e6e16105f524a8dec491e0586d072948ce15c9b914e41cdadd209052b \
--hash=sha256:1ee80a59f6ce048ae13cda1abf7fbd2a34ab9ee7d401c46be3ca685d1999a399 \
--hash=sha256:21f241bdd5080a15bc86d3466a9f6074a9c2c2b314100dd896ac81ee6db2f1ba \
--hash=sha256:266cd5f2b63ff316d5a1bba46268e603c9caf5606d44f38c2873c380950576ad \
--hash=sha256:26d9f7d2b604cd23aba3e9faf795787456ac25634d82cd060556998e39c6fa47 \
--hash=sha256:27f95b12453d165099c84f8a8bfdfd46b9e4bda9e0e4b65f0635430027f55739 \
--hash=sha256:2c54c1a783d6d60595d3514f0efe9b37c8808746a66920315bfd34a938d7994b \
--hash=sha256:2fa5f0b6716fc88f11380b88b31fe591a06c6315e955c096c35715788b339e3f \
--hash=sha256:32ed80ea8a90ee3e6fa08c21e2e091bba6eda8eccc83dbc34c95169507a91f10 \
--hash=sha256:3830c769decf88f1289680a59d4f4c46c72573446352e2befec9a8512104fa52 \
--hash=sha256:38df9b4bfd3db902c9c2bd369bcacaf9d935b2fff73709429d95cc41554f7b3d \
--hash=sha256:3adfb466bbc544b926d50fe8f4a4e6abd8c6bffd28a26177594e6e9b2b76572b \
--hash=sha256:3e42edad50b6909089750e65c91aa09aaf1e0a71310d383f11321b27c224ed8a \
--hash=sha256:4078242472387600b2ce8d93ade8899c12bf33fa89e55ec89fe126e9d6d5d9e9 \
--hash=sha256:455247ac8a4cfb7b9bc45b7e432d10421aea9fc2e74d285ba4072688a74c2e9d \
--hash=sha256:4cc6b3b2efff105c6a1656cfe59da4fdde2cda9af1c5e0b58529b24525d0a098 \
--hash=sha256:4cf7fed4b4580601c4345ceb5d4cbf5a980d030fd5ad07c4d2ec589f95f09905 \
--hash=sha256:5193fde9a5f23c331ea26d0cf171fbf67e3f247585f50c08b3e205c7aeb4589b \
--hash=sha256:5269cc1caeedb67e6f7269a42014f381f45e2e7cd42d834ede3c703a1d915fe3 \
--hash=sha256:53561a4ddc36facb432fae7a9d8afbfaf94795414f5cdc5fc52f28c1dca90371 \
--hash=sha256:55f818bd74fe2f11d4d7cbc65880a843c4075e0ac7226bc1a23261dbea531953 \
--hash=sha256:58eea5ebe51504057dd95c5b77d21700b77615ab0243d8152793dc00eb4faf01 \
--hash=sha256:5d5c411a8eaa2299322b647cd932586b1427367fd3184ffbb8f7a219ea2041ca \
--hash=sha256:6846bd2d116ff42cba6b646edf5bf61d37e5cbd256425fa089fee4ff5c07a99e \
--hash=sha256:6ace95230bfb7cd79ef66caa064bbe2f2a1e63d93471c3a2e1f1348d9f22d6b7 \
--hash=sha256:6e51b71417049ad6ab14c49608b4a24d8fb3fe605e5dfabfe523b58064dc3d27 \
--hash=sha256:71db6b4c1653045dacc1585c1b0d184004f0d7e694c7b34ac165ca70c0838082 \
--hash=sha256:7438839e9e053ef79f7112c881cef684013855016f928b168b81ed5835f3e75e \
--hash=sha256:759de84a33be3b178a64c8ba28ad5c135900359e85fb662bc6e403ad4407791d \
--hash=sha256:792a2c0be4dcc18af9d4a2dfd8a11a17d5e25274a1062b0ec1c2d79c76f3e7f8 \
--hash=sha256:7d87ef5795da03d742bf49439f9ca4d027cde49c82c5371ba52464aee266699a \
--hash=sha256:7dfb439562f234f7d57b1ac6bc8fe7f838a4bd49c79230e0f6a1da93e82f1fad \
--hash=sha256:7fa22993bac7b77b78cae22bad1e2a987ddf0d9015c63358032f84a53f23cdc3 \
--hash=sha256:805ebf596939e48dbb2e4922a1d3852cfc25c38160751ce02da93058b48d252a \
--hash=sha256:82240051c6ca513c616f7f9da06e871f61bfd7805f566275841af15015b8f98d \
--hash=sha256:87d4f8125c9988bfbed67af47dd7a953e2fc7b0cc1e7800ec6d2080d490bb353 \
--hash=sha256:8d8ca2b210ada074d57fcee40c30446c9562e542fc46aedc19baf758a93532ee \
--hash=sha256:8dc232e39d409036af549c86f24aed8273a40ffa459981146829a324e0848b4b \
--hash=sha256:90387104ee8400a7b4598253b4c406f8958f59fcf983a6cea2b50d59f7d63d0b \
--hash=sha256:905b0365b210c73afb0ebe9101a32572152dfd1c144c7e28968a331b9217b94a \
--hash=sha256:99353a06902c2e43b43e8ff74ee65a7d90307d82370604746738a1e0661ccca7 \
--hash=sha256:99a7f72fb6249302aa62245680754862a44179b545ded638cf1fef59befb57ef \
--hash=sha256:9f0b04c6b8584c2c193babcccc908b38ed29524b29dd464bc8801bf10d746a3a \
--hash=sha256:9fe611163f6303d1619bbcb653540a4d60f9e55e622d60a3108be0d5b441017a \
--hash=sha256:a3475b96f5908b3b16c47533daaa87380c491357d197564e0ba34ae75c0f3257 \
--hash=sha256:a6597ff2b61d121172f5844b53f21467f7082f5fb385a9a29c01414463f93b07 \
--hash=sha256:a7921c5a6d31b3d756ec980f2f47c0cfdbce0fc48c22a39347a895f41f4a6ea4 \
--hash=sha256:aa5129de4e174daccbc59d0a3b6d20eaf24417d59851c07ebb37aeb02947987c \
--hash=sha256:aeaefa96c768fc66818730b952a862235d68825c178f1b3ffd4efd7ad2edcb7c \
--hash=sha256:afbefa430092f71a9593a99ab6a4e7538bc9eabbf7bf94f91510d3503943edc4 \
--hash=sha256:aff9e4d82d082ff9513bdd6acd4f5bd359f5b2c870907d2b0a9c5e10d40c88fe \
--hash=sha256:b22bd8c974942477156be55a768f7aa37c46904c175be4e158b6a86e3a6b7ca8 \
--hash=sha256:b290fd8aa38422444d4b50d579de197557f182ef1068b75f5aa8558638b8d0a5 \
--hash=sha256:b2e4b27a6e15b04832fe9bf292b94b5ca156016bbc1ea9c2c20098a0320d6cf6 \
--hash=sha256:b583dc9070312190192631373c6c8ed277254aa6e6084b74bdd0a6d3b221608e \
--hash=sha256:b87843e225e74576437fd5b6a4c2205d422754f84a06942cfaf1dc32243e45a8 \
--hash=sha256:bc91a56697869546d1b8f0a3ff35224557ae7f881050e99f615e0119bf934b4e \
--hash=sha256:bd87e140e45399c818fac4247880b9ce719e4783d767e030a883a970be632275 \
--hash=sha256:bde737cff1a975b70652b62d626f7785e0480918dece11e8fef3c0cf057351c3 \
--hash=sha256:bdee52571a343d721fb2eb3b090a82d959ff37fc631e3f70422e0c2e029f3e76 \
--hash=sha256:bee2a6db3a7242ea309aa7ee8e2780726fed67ff4e5b40169f2c940e7eb09227 \
--hash=sha256:beeae3f27f62308f1ddbcfb0690bf44b10732f2ef43758f169d5e9303165d3f9 \
--hash=sha256:c50f36a62a22d350c96e49ad02d0da41dbd17ddc2e29750dbdba4323f85eb4a5 \
--hash=sha256:c607c90ba67533e1b2355b821fef6764d1dd2cbe26b8c1005ae84f7aea25ff79 \
--hash=sha256:c7b2a63fd6d5246349f3d3f37b14430d73ee7e8173154461785e43036ffa96ca \
--hash=sha256:c828a1ae702fc712978bda0320ba1b9893d99be0badf2647f693cc01cf0f04fa \
--hash=sha256:c85de1136429c524e55cfa4e033b4a7940ac5c8ee4d9401cc2d1bf48154bbc7b \
--hash=sha256:c98fa880d695de164b4135a52fd2e9cd7b7c90a9d8ac5e9e443a24a95ef9248e \
--hash=sha256:cae81479f77420d217def5f54b5b9d279804d17e982e0f2fa19b1d1e14ab5197 \
--hash=sha256:d034140032870024e6b9892c692fe2968493790dd57208b2c37e3fb35f6df3ab \
--hash=sha256:d120c38a42c234dc9a8c5de7ceaaf899cf33561956acb4941653f8bdc657aa79 \
--hash=sha256:d4827615da15cd59784ce39d3388275ec093ae3ee8d7f0c089b76fa87af756c2 \
--hash=sha256:d49e2314c373f4c2b39446fb1a45ed333c850e09d0c59ac79b72eb3b95397363 \
--hash=sha256:d52610d51e265a51518692045e372a4c363056130d922a7351429ac9f27e70b0 \
--hash=sha256:d64317d2587c70324b79861babb9c09f71fbb780bad212018874b2c013d8600e \
--hash=sha256:d77153e14b709fd8b8af6f66a3afbb9ed6e9fc5ccf0b6b7e1ced7b036a228782 \
--hash=sha256:d7e091d464ac59d2c7ad8e7e08105eaf9dafbc3883fd7265ffccc2baad6ac925 \
--hash=sha256:dd333073e0cacdc3089525c7df7d39b211bcdf31fc2824e49d01c6b6187b07d0 \
--hash=sha256:e5d8efac84c9afcb40914ab49ba063d94f5dbdf5066db4482c66a992f47a3a3b \
--hash=sha256:f135c702ac42262573fe9714dfe99c944b4ba307af5eb507abef1667e2cbbced \
--hash=sha256:f13711b1a5ba512d647a0e4ba79280d3a9a045aaf7e0cc6fbe96b91d4cdf6b0c \
--hash=sha256:f4f1231b7dec408e8670264ce63e9c71409d9583dd21d32c163e25213ee2a344 \
--hash=sha256:fa3ed2a29a9e9d2d488b4da81dcb54720ac3104a20bf0bd273f1e4648aff5af9 \
--hash=sha256:fb3096c30df99fd01c7bf8e544f392103d0795b9f98ba71a8054bcbf56b255f1
# via
# -r .github/scripts/requirements_dev.in
# pdf2image
# weasyprint
platformdirs==4.5.0 \
--hash=sha256:70ddccdd7c99fc5942e9fc25636a8b34d04c24b335100223152c2803e4063312 \
--hash=sha256:e578a81bb873cbb89a41fcc904c7ef523cc18284b7e3b3ccf06aca1403b7ebd3
# via virtualenv
pre-commit==4.3.0 \
--hash=sha256:2b0747ad7e6e967169136edffee14c16e148a778a54e4f967921aa1ebf2308d8 \
--hash=sha256:499fe450cc9d42e9d58e606262795ecb64dd05438943c62b66f6a8673da30b16
# via -r .github/scripts/requirements_dev.in
pycparser==2.23 \
--hash=sha256:78816d4f24add8f10a06d6f05b4d424ad9e96cfebf68a4ddc99c65c0720d00c2 \
--hash=sha256:e5c6e8d3fbad53479cab09ac03729e0a9faf2bee3db8208a550daf5af81a5934
# via cffi
pydyf==0.11.0 \
--hash=sha256:0aaf9e2ebbe786ec7a78ec3fbffa4cdcecde53fd6f563221d53c6bc1328848a3 \
--hash=sha256:394dddf619cca9d0c55715e3c55ea121a9bf9cbc780cdc1201a2427917b86b64
# via weasyprint
pyphen==0.17.2 \
--hash=sha256:3a07fb017cb2341e1d9ff31b8634efb1ae4dc4b130468c7c39dd3d32e7c3affd \
--hash=sha256:f60647a9c9b30ec6c59910097af82bc5dd2d36576b918e44148d8b07ef3b4aa3
# via weasyprint
pyyaml==6.0.3 \
--hash=sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c \
--hash=sha256:0150219816b6a1fa26fb4699fb7daa9caf09eb1999f3b70fb6e786805e80375a \
--hash=sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3 \
--hash=sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956 \
--hash=sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6 \
--hash=sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c \
--hash=sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65 \
--hash=sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a \
--hash=sha256:1ebe39cb5fc479422b83de611d14e2c0d3bb2a18bbcb01f229ab3cfbd8fee7a0 \
--hash=sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b \
--hash=sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1 \
--hash=sha256:22ba7cfcad58ef3ecddc7ed1db3409af68d023b7f940da23c6c2a1890976eda6 \
--hash=sha256:27c0abcb4a5dac13684a37f76e701e054692a9b2d3064b70f5e4eb54810553d7 \
--hash=sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e \
--hash=sha256:2e71d11abed7344e42a8849600193d15b6def118602c4c176f748e4583246007 \
--hash=sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310 \
--hash=sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4 \
--hash=sha256:3c5677e12444c15717b902a5798264fa7909e41153cdf9ef7ad571b704a63dd9 \
--hash=sha256:3ff07ec89bae51176c0549bc4c63aa6202991da2d9a6129d7aef7f1407d3f295 \
--hash=sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea \
--hash=sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0 \
--hash=sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e \
--hash=sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac \
--hash=sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9 \
--hash=sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7 \
--hash=sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35 \
--hash=sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb \
--hash=sha256:5cf4e27da7e3fbed4d6c3d8e797387aaad68102272f8f9752883bc32d61cb87b \
--hash=sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69 \
--hash=sha256:5ed875a24292240029e4483f9d4a4b8a1ae08843b9c54f43fcc11e404532a8a5 \
--hash=sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b \
--hash=sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c \
--hash=sha256:6344df0d5755a2c9a276d4473ae6b90647e216ab4757f8426893b5dd2ac3f369 \
--hash=sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd \
--hash=sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824 \
--hash=sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198 \
--hash=sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065 \
--hash=sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c \
--hash=sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c \
--hash=sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764 \
--hash=sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196 \
--hash=sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b \
--hash=sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00 \
--hash=sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac \
--hash=sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8 \
--hash=sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e \
--hash=sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28 \
--hash=sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3 \
--hash=sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5 \
--hash=sha256:9c57bb8c96f6d1808c030b1687b9b5fb476abaa47f0db9c0101f5e9f394e97f4 \
--hash=sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b \
--hash=sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf \
--hash=sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5 \
--hash=sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702 \
--hash=sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8 \
--hash=sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788 \
--hash=sha256:b865addae83924361678b652338317d1bd7e79b1f4596f96b96c77a5a34b34da \
--hash=sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d \
--hash=sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc \
--hash=sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c \
--hash=sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba \
--hash=sha256:c2514fceb77bc5e7a2f7adfaa1feb2fb311607c9cb518dbc378688ec73d8292f \
--hash=sha256:c3355370a2c156cffb25e876646f149d5d68f5e0a3ce86a5084dd0b64a994917 \
--hash=sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5 \
--hash=sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26 \
--hash=sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f \
--hash=sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b \
--hash=sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be \
--hash=sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c \
--hash=sha256:efd7b85f94a6f21e4932043973a7ba2613b059c4a000551892ac9f1d11f5baf3 \
--hash=sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6 \
--hash=sha256:fa160448684b4e94d80416c0fa4aac48967a969efe22931448d853ada8baf926 \
--hash=sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0
# via pre-commit
tinycss2==1.4.0 \
--hash=sha256:10c0972f6fc0fbee87c3edb76549357415e94548c1ae10ebccdea16fb404a9b7 \
--hash=sha256:3a49cf47b7675da0b15d0c6e1df8df4ebd96e9394bb905a5775adb0d884c5289
# via
# cssselect2
# weasyprint
tinyhtml5==2.0.0 \
--hash=sha256:086f998833da24c300c414d9fe81d9b368fd04cb9d2596a008421cbc705fcfcc \
--hash=sha256:13683277c5b176d070f82d099d977194b7a1e26815b016114f581a74bbfbf47e
# via weasyprint
unoserver==3.4 \
--hash=sha256:3dcf2204013def1d1ddd3671f38b11346bdf349fef9728277462666a8a634419 \
--hash=sha256:64c24d33d4f65d680a2d9f676518cb28e7fd6c1f9d9a745c33e4a4cb59afdfcd
# via -r .github/scripts/requirements_dev.in
virtualenv==20.35.4 \
--hash=sha256:643d3914d73d3eeb0c552cbb12d7e82adf0e504dbf86a3182f8771a153a1971c \
--hash=sha256:c21c9cede36c9753eeade68ba7d523529f228a403463376cf821eaae2b650f1b
# via pre-commit
weasyprint==66.0 \
--hash=sha256:82b0783b726fcd318e2c977dcdddca76515b30044bc7a830cc4fbe717582a6d0 \
--hash=sha256:da71dc87dc129ac9cffdc65e5477e90365ab9dbae45c744014ec1d06303dde40
# via -r .github/scripts/requirements_dev.in
webencodings==0.5.1 \
--hash=sha256:a0af1213f3c2226497a97e2b3aa01a7e4bee4f403f95be16fc9acd2947514a78 \
--hash=sha256:b36a1c245f2d304965eb4e0a82848379241dc04b865afcc4aab16748587e1923
# via
# cssselect2
# tinycss2
# tinyhtml5
zopfli==0.2.3.post1 \
--hash=sha256:0aa5f90d6298bda02a95bc8dc8c3c19004d5a4e44bda00b67ca7431d857b4b54 \
--hash=sha256:0cc20b02a9531559945324c38302fd4ba763311632d0ec8a1a0aa9c10ea363e6 \
--hash=sha256:1d8cc06605519e82b16df090e17cb3990d1158861b2872c3117f1168777b81e4 \
--hash=sha256:1f990634fd5c5c8ced8edddd8bd45fab565123b4194d6841e01811292650acae \
--hash=sha256:2345e713260a350bea0b01a816a469ea356bc2d63d009a0d777691ecbbcf7493 \
--hash=sha256:2768c877f76c8a0e7519b1c86c93757f3c01492ddde55751e9988afb7eff64e1 \
--hash=sha256:29ea74e72ffa6e291b8c6f2504ce6c146b4fe990c724c1450eb8e4c27fd31431 \
--hash=sha256:34a99592f3d9eb6f737616b5bd74b48a589fdb3cb59a01a50d636ea81d6af272 \
--hash=sha256:3654bfc927bc478b1c3f3ff5056ed7b20a1a37fa108ca503256d0a699c03bbb1 \
--hash=sha256:3657e416ffb8f31d9d3424af12122bb251befae109f2e271d87d825c92fc5b7b \
--hash=sha256:37d011e92f7b9622742c905fdbed9920a1d0361df84142807ea2a528419dea7f \
--hash=sha256:3827170de28faf144992d3d4dcf8f3998fe3c8a6a6f4a08f1d42c2ec6119d2bb \
--hash=sha256:39e576f93576c5c223b41d9c780bbb91fd6db4babf3223d2a4fe7bf568e2b5a8 \
--hash=sha256:3a89277ed5f8c0fb2d0b46d669aa0633123aa7381f1f6118c12f15e0fb48f8ca \
--hash=sha256:3c163911f8bad94b3e1db0a572e7c28ba681a0c91d0002ea1e4fa9264c21ef17 \
--hash=sha256:3f0197b6aa6eb3086ae9e66d6dd86c4d502b6c68b0ec490496348ae8c05ecaef \
--hash=sha256:48dba9251060289101343110ab47c0756f66f809bb4d1ddbb6d5c7e7752115c5 \
--hash=sha256:4915a41375bdee4db749ecd07d985a0486eb688a6619f713b7bf6fbfd145e960 \
--hash=sha256:4c1226a7e2c7105ac31503a9bb97454743f55d88164d6d46bc138051b77f609b \
--hash=sha256:4e50ffac74842c1c1018b9b73875a0d0a877c066ab06bf7cccbaa84af97e754f \
--hash=sha256:518f1f4ed35dd69ce06b552f84e6d081f07c552b4c661c5312d950a0b764a58a \
--hash=sha256:5aad740b4d4fcbaaae4887823925166ffd062db3b248b3f432198fc287381d1a \
--hash=sha256:5f272186e03ad55e7af09ab78055535c201b1a0bcc2944edb1768298d9c483a4 \
--hash=sha256:5fcfc0dc2761e4fcc15ad5d273b4d58c2e8e059d3214a7390d4d3c8e2aee644e \
--hash=sha256:60db20f06c3d4c5934b16cfa62a2cc5c3f0686bffe0071ed7804d3c31ab1a04e \
--hash=sha256:615a8ac9dda265e9cc38b2a76c3142e4a9f30fea4a79c85f670850783bc6feb4 \
--hash=sha256:6482db9876c68faac2d20a96b566ffbf65ddaadd97b222e4e73641f4f8722fc4 \
--hash=sha256:6617fb10f9e4393b331941861d73afb119cd847e88e4974bdbe8068ceef3f73f \
--hash=sha256:676919fba7311125244eb0c4393679ac5fe856e5864a15d122bd815205369fa0 \
--hash=sha256:6c2d2bc8129707e34c51f9352c4636ca313b52350bbb7e04637c46c1818a2a70 \
--hash=sha256:71390dbd3fbf6ebea9a5d85ffed8c26ee1453ee09248e9b88486e30e0397b775 \
--hash=sha256:716cdbfc57bfd3d3e31a58e6246e8190e6849b7dbb7c4ce39ef8bbf0edb8f6d5 \
--hash=sha256:75a26a2307b10745a83b660c404416e984ee6fca515ec7f0765f69af3ce08072 \
--hash=sha256:7be5cc6732eb7b4df17305d8a7b293223f934a31783a874a01164703bc1be6cd \
--hash=sha256:7cce242b5df12b2b172489daf19c32e5577dd2fac659eb4b17f6a6efb446fd5c \
--hash=sha256:81c341d9bb87a6dbbb0d45d6e272aca80c7c97b4b210f9b6e233bf8b87242f29 \
--hash=sha256:89899641d4de97dbad8e0cde690040d078b6aea04066dacaab98e0b5a23573f2 \
--hash=sha256:8d5ab297d660b75c159190ce6d73035502310e40fd35170aed7d1a1aea7ddd65 \
--hash=sha256:8fbe5bcf10d01aab3513550f284c09fef32f342b36f56bfae2120a9c4d12c130 \
--hash=sha256:91a2327a4d7e77471fa4fbb26991c6de4a738c6fc6a33e09bb25f56a870a4b7b \
--hash=sha256:95a260cafd56b8fffa679918937401c80bb38e1681c448b988022e4c3610965d \
--hash=sha256:96484dc0f48be1c5d7ae9f38ed1ce41e3675fd506b27c11a6607f14b49101e99 \
--hash=sha256:9a6aec38a989bad7ddd1ef53f1265699e49e294d08231b5313d61293f3cd6237 \
--hash=sha256:9ba214f4f45bec195ee8559651154d3ac2932470b9d91c5715fc29c013349f8c \
--hash=sha256:9f4a7ec2770e6af05f5a02733fd3900f30a9cd58e5d6d3727e14c5bcd6e7d587 \
--hash=sha256:a1cf720896d2ce998bc8e051d4b4ce0d8bec007aab6243102e8e1d22a0b2fb3f \
--hash=sha256:a241a68581d34d67b40c425cce3d1fd211c092f99d9250947824ccba9f491949 \
--hash=sha256:a53b18797cdef27e019db595d66c4b077325afe2fd62145953275f53d84ce40c \
--hash=sha256:a82fc2dbebe6eb908b9c665e71496f8525c1bc4d2e3a7a7722ef2b128b6227c8 \
--hash=sha256:a86eb88e06bd87e1fff31dac878965c26b0c26db59ddcf78bb0379a954b120de \
--hash=sha256:aa588b21044f8a74e423d8c8a4c7fc9988501878aacced793467010039c50734 \
--hash=sha256:b05296e8bc88c92e2b21e0a9bae4740c1551ee613c1d93a51fd28a7a0b2b6fbb \
--hash=sha256:b0ec13f352ea5ae0fc91f98a48540512eed0767d0ec4f7f3cb92d92797983d18 \
--hash=sha256:b3df42f52502438ee973042cc551877d24619fa1cd38ef7b7e9ac74200daca8b \
--hash=sha256:b78008a69300d929ca2efeffec951b64a312e9a811e265ea4a907ab546d79fa6 \
--hash=sha256:b9026a21b6d41eb0e2e63f5bc1242c3fcc43ecb770963cda99a4307863dac12e \
--hash=sha256:bbe429fc50686bb2a2608a30843e36fbaa123462a5284f136c7d9e0145220bfd \
--hash=sha256:bfa1eb759e07d8b7aa7a310a2bc535e127ee70addf90dc8d4b946b593c3e51a8 \
--hash=sha256:c1e0ed5d84ffa2d677cc9582fc01e61dab2e7ef8b8996e055f0a76167b1b94df \
--hash=sha256:c4278d1873ce6e803e5d4f8d702fd3026bd67fca744aa98881324d1157ddf748 \
--hash=sha256:cac2b37ab21c2b36a10b685b1893ebd6b0f83ae26004838ac817680881576567 \
--hash=sha256:cbe6df25807227519debd1a57ab236f5f6bad441500e85b13903e51f93a43214 \
--hash=sha256:cd2c002f160502608dcc822ed2441a0f4509c52e86fcfd1a09e937278ed1ca14 \
--hash=sha256:e0137dd64a493ba6a4be37405cfd6febe650a98cc1e9dca8f6b8c63b1db11b41 \
--hash=sha256:e63d558847166543c2c9789e6f985400a520b7eacc4b99181668b2c3aeadd352 \
--hash=sha256:eb45a34f23da4f8bc712b6376ca5396914b0b7c09adbb001dad964eb7f3132f8 \
--hash=sha256:ecb7572df5372abce8073df078207d9d1749f20b8b136089916a4a0868d56051 \
--hash=sha256:f12000a6accdd4bf0a3fa6eaa1b1c7a7bc80af0a2edf3f89d770d3dcce1d0e22 \
--hash=sha256:f7d69c1a7168ad0e9cb864e8663acb232986a0c9c9cb9801f56bf6214f53a54d \
--hash=sha256:f815fcc2b2a457977724bad97fb4854022980f51ce7b136925e336b530545ae1 \
--hash=sha256:fc39f5c27f962ec8660d8d20c24762431131b5d8c672b44b0a54cf2b5bcde9b9
# via fonttools
# The following packages are considered to be unsafe in a requirements file:
pip==25.3 \
--hash=sha256:8d0538dbbd7babbd207f261ed969c65de439f6bc9e5dbd3b3b9a77f25d95f343 \
--hash=sha256:9655943313a94722b7774661c21049070f6bbb0a1516bf02f7c8d5d9201514cd
# via -r .github/scripts/requirements_dev.in
setuptools==80.9.0 \
--hash=sha256:062d34222ad13e0cc312a4c02d73f059e86a4acbfbdea8f8f76b28c99f306922 \
--hash=sha256:f36b47402ecde768dbfafc46e8e4207b4360c654f1f3bb84475f0a28628fb19c
# via -r .github/scripts/requirements_dev.in
+74 -94
View File
@@ -1,5 +1,5 @@
#
# This file is autogenerated by pip-compile with Python 3.12
# This file is autogenerated by pip-compile with Python 3.10
# by the following command:
#
# pip-compile --generate-hashes --output-file='.github\scripts\requirements_pre_commit.txt' --strip-extras '.github\scripts\requirements_pre_commit.in'
@@ -8,106 +8,86 @@ cfgv==3.4.0 \
--hash=sha256:b7265b1f29fd3316bfcd2b330d63d024f2bfd8bcb8b0272f8e19a504856c48f9 \
--hash=sha256:e52591d4c5f5dead8e0f673fb16db7949d2cfb3f7da4582893288f0ded8fe560
# via pre-commit
distlib==0.4.0 \
--hash=sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16 \
--hash=sha256:feec40075be03a04501a973d81f633735b4b69f98b05450592310c0f401a4e0d
distlib==0.3.9 \
--hash=sha256:47f8c22fd27c27e25a65601af709b38e4f0a45ea4fc2e710f65755fa8caaaf87 \
--hash=sha256:a60f20dea646b8a33f3e7772f74dc0b2d0772d2837ee1342a00645c81edf9403
# via virtualenv
filelock==3.20.0 \
--hash=sha256:339b4732ffda5cd79b13f4e2711a31b0365ce445d95d243bb996273d072546a2 \
--hash=sha256:711e943b4ec6be42e1d4e6690b48dc175c822967466bb31c0c293f34334c13f4
filelock==3.18.0 \
--hash=sha256:adbc88eabb99d2fec8c9c1b229b171f18afa655400173ddc653d5d01501fb9f2 \
--hash=sha256:c401f4f8377c4464e6db25fff06205fd89bdd83b65eb0488ed1b160f780e21de
# via virtualenv
identify==2.6.15 \
--hash=sha256:1181ef7608e00704db228516541eb83a88a9f94433a8c80bb9b5bd54b1d81757 \
--hash=sha256:e4f4864b96c6557ef2a1e1c951771838f4edc9df3a72ec7118b338801b11c7bf
identify==2.6.12 \
--hash=sha256:ad9672d5a72e0d2ff7c5c8809b62dfa60458626352fb0eb7b55e69bdc45334a2 \
--hash=sha256:d8de45749f1efb108badef65ee8386f0f7bb19a7f26185f74de6367bffbaf0e6
# via pre-commit
nodeenv==1.9.1 \
--hash=sha256:6ec12890a2dab7946721edbfbcd91f3319c6ccc9aec47be7c7e6b7011ee6645f \
--hash=sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9
# via pre-commit
platformdirs==4.5.0 \
--hash=sha256:70ddccdd7c99fc5942e9fc25636a8b34d04c24b335100223152c2803e4063312 \
--hash=sha256:e578a81bb873cbb89a41fcc904c7ef523cc18284b7e3b3ccf06aca1403b7ebd3
platformdirs==4.3.8 \
--hash=sha256:3d512d96e16bcb959a814c9f348431070822a6496326a4be0911c40b5a74c2bc \
--hash=sha256:ff7059bb7eb1179e2685604f4aaf157cfd9535242bd23742eadc3c13542139b4
# via virtualenv
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 \
--hash=sha256:0150219816b6a1fa26fb4699fb7daa9caf09eb1999f3b70fb6e786805e80375a \
--hash=sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3 \
--hash=sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956 \
--hash=sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6 \
--hash=sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c \
--hash=sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65 \
--hash=sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a \
--hash=sha256:1ebe39cb5fc479422b83de611d14e2c0d3bb2a18bbcb01f229ab3cfbd8fee7a0 \
--hash=sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b \
--hash=sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1 \
--hash=sha256:22ba7cfcad58ef3ecddc7ed1db3409af68d023b7f940da23c6c2a1890976eda6 \
--hash=sha256:27c0abcb4a5dac13684a37f76e701e054692a9b2d3064b70f5e4eb54810553d7 \
--hash=sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e \
--hash=sha256:2e71d11abed7344e42a8849600193d15b6def118602c4c176f748e4583246007 \
--hash=sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310 \
--hash=sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4 \
--hash=sha256:3c5677e12444c15717b902a5798264fa7909e41153cdf9ef7ad571b704a63dd9 \
--hash=sha256:3ff07ec89bae51176c0549bc4c63aa6202991da2d9a6129d7aef7f1407d3f295 \
--hash=sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea \
--hash=sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0 \
--hash=sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e \
--hash=sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac \
--hash=sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9 \
--hash=sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7 \
--hash=sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35 \
--hash=sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb \
--hash=sha256:5cf4e27da7e3fbed4d6c3d8e797387aaad68102272f8f9752883bc32d61cb87b \
--hash=sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69 \
--hash=sha256:5ed875a24292240029e4483f9d4a4b8a1ae08843b9c54f43fcc11e404532a8a5 \
--hash=sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b \
--hash=sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c \
--hash=sha256:6344df0d5755a2c9a276d4473ae6b90647e216ab4757f8426893b5dd2ac3f369 \
--hash=sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd \
--hash=sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824 \
--hash=sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198 \
--hash=sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065 \
--hash=sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c \
--hash=sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c \
--hash=sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764 \
--hash=sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196 \
--hash=sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b \
--hash=sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00 \
--hash=sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac \
--hash=sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8 \
--hash=sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e \
--hash=sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28 \
--hash=sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3 \
--hash=sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5 \
--hash=sha256:9c57bb8c96f6d1808c030b1687b9b5fb476abaa47f0db9c0101f5e9f394e97f4 \
--hash=sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b \
--hash=sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf \
--hash=sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5 \
--hash=sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702 \
--hash=sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8 \
--hash=sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788 \
--hash=sha256:b865addae83924361678b652338317d1bd7e79b1f4596f96b96c77a5a34b34da \
--hash=sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d \
--hash=sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc \
--hash=sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c \
--hash=sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba \
--hash=sha256:c2514fceb77bc5e7a2f7adfaa1feb2fb311607c9cb518dbc378688ec73d8292f \
--hash=sha256:c3355370a2c156cffb25e876646f149d5d68f5e0a3ce86a5084dd0b64a994917 \
--hash=sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5 \
--hash=sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26 \
--hash=sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f \
--hash=sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b \
--hash=sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be \
--hash=sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c \
--hash=sha256:efd7b85f94a6f21e4932043973a7ba2613b059c4a000551892ac9f1d11f5baf3 \
--hash=sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6 \
--hash=sha256:fa160448684b4e94d80416c0fa4aac48967a969efe22931448d853ada8baf926 \
--hash=sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0
pre-commit==4.2.0 \
--hash=sha256:601283b9757afd87d40c4c4a9b2b5de9637a8ea02eaff7adc2d0fb4e04841146 \
--hash=sha256:a009ca7205f1eb497d10b845e52c838a98b6cdd2102a6c8e4540e94ee75c58bd
# via -r .github\scripts\requirements_pre_commit.in
pyyaml==6.0.2 \
--hash=sha256:01179a4a8559ab5de078078f37e5c1a30d76bb88519906844fd7bdea1b7729ff \
--hash=sha256:0833f8694549e586547b576dcfaba4a6b55b9e96098b36cdc7ebefe667dfed48 \
--hash=sha256:0a9a2848a5b7feac301353437eb7d5957887edbf81d56e903999a75a3d743086 \
--hash=sha256:0b69e4ce7a131fe56b7e4d770c67429700908fc0752af059838b1cfb41960e4e \
--hash=sha256:0ffe8360bab4910ef1b9e87fb812d8bc0a308b0d0eef8c8f44e0254ab3b07133 \
--hash=sha256:11d8f3dd2b9c1207dcaf2ee0bbbfd5991f571186ec9cc78427ba5bd32afae4b5 \
--hash=sha256:17e311b6c678207928d649faa7cb0d7b4c26a0ba73d41e99c4fff6b6c3276484 \
--hash=sha256:1e2120ef853f59c7419231f3bf4e7021f1b936f6ebd222406c3b60212205d2ee \
--hash=sha256:1f71ea527786de97d1a0cc0eacd1defc0985dcf6b3f17bb77dcfc8c34bec4dc5 \
--hash=sha256:23502f431948090f597378482b4812b0caae32c22213aecf3b55325e049a6c68 \
--hash=sha256:24471b829b3bf607e04e88d79542a9d48bb037c2267d7927a874e6c205ca7e9a \
--hash=sha256:29717114e51c84ddfba879543fb232a6ed60086602313ca38cce623c1d62cfbf \
--hash=sha256:2e99c6826ffa974fe6e27cdb5ed0021786b03fc98e5ee3c5bfe1fd5015f42b99 \
--hash=sha256:39693e1f8320ae4f43943590b49779ffb98acb81f788220ea932a6b6c51004d8 \
--hash=sha256:3ad2a3decf9aaba3d29c8f537ac4b243e36bef957511b4766cb0057d32b0be85 \
--hash=sha256:3b1fdb9dc17f5a7677423d508ab4f243a726dea51fa5e70992e59a7411c89d19 \
--hash=sha256:41e4e3953a79407c794916fa277a82531dd93aad34e29c2a514c2c0c5fe971cc \
--hash=sha256:43fa96a3ca0d6b1812e01ced1044a003533c47f6ee8aca31724f78e93ccc089a \
--hash=sha256:50187695423ffe49e2deacb8cd10510bc361faac997de9efef88badc3bb9e2d1 \
--hash=sha256:5ac9328ec4831237bec75defaf839f7d4564be1e6b25ac710bd1a96321cc8317 \
--hash=sha256:5d225db5a45f21e78dd9358e58a98702a0302f2659a3c6cd320564b75b86f47c \
--hash=sha256:6395c297d42274772abc367baaa79683958044e5d3835486c16da75d2a694631 \
--hash=sha256:688ba32a1cffef67fd2e9398a2efebaea461578b0923624778664cc1c914db5d \
--hash=sha256:68ccc6023a3400877818152ad9a1033e3db8625d899c72eacb5a668902e4d652 \
--hash=sha256:70b189594dbe54f75ab3a1acec5f1e3faa7e8cf2f1e08d9b561cb41b845f69d5 \
--hash=sha256:797b4f722ffa07cc8d62053e4cff1486fa6dc094105d13fea7b1de7d8bf71c9e \
--hash=sha256:7c36280e6fb8385e520936c3cb3b8042851904eba0e58d277dca80a5cfed590b \
--hash=sha256:7e7401d0de89a9a855c839bc697c079a4af81cf878373abd7dc625847d25cbd8 \
--hash=sha256:80bab7bfc629882493af4aa31a4cfa43a4c57c83813253626916b8c7ada83476 \
--hash=sha256:82d09873e40955485746739bcb8b4586983670466c23382c19cffecbf1fd8706 \
--hash=sha256:8388ee1976c416731879ac16da0aff3f63b286ffdd57cdeb95f3f2e085687563 \
--hash=sha256:8824b5a04a04a047e72eea5cec3bc266db09e35de6bdfe34c9436ac5ee27d237 \
--hash=sha256:8b9c7197f7cb2738065c481a0461e50ad02f18c78cd75775628afb4d7137fb3b \
--hash=sha256:9056c1ecd25795207ad294bcf39f2db3d845767be0ea6e6a34d856f006006083 \
--hash=sha256:936d68689298c36b53b29f23c6dbb74de12b4ac12ca6cfe0e047bedceea56180 \
--hash=sha256:9b22676e8097e9e22e36d6b7bda33190d0d400f345f23d4065d48f4ca7ae0425 \
--hash=sha256:a4d3091415f010369ae4ed1fc6b79def9416358877534caf6a0fdd2146c87a3e \
--hash=sha256:a8786accb172bd8afb8be14490a16625cbc387036876ab6ba70912730faf8e1f \
--hash=sha256:a9f8c2e67970f13b16084e04f134610fd1d374bf477b17ec1599185cf611d725 \
--hash=sha256:bc2fa7c6b47d6bc618dd7fb02ef6fdedb1090ec036abab80d4681424b84c1183 \
--hash=sha256:c70c95198c015b85feafc136515252a261a84561b7b1d51e3384e0655ddf25ab \
--hash=sha256:cc1c1159b3d456576af7a3e4d1ba7e6924cb39de8f67111c735f6fc832082774 \
--hash=sha256:ce826d6ef20b1bc864f0a68340c8b3287705cae2f8b4b1d932177dcc76721725 \
--hash=sha256:d584d9ec91ad65861cc08d42e834324ef890a082e591037abe114850ff7bbc3e \
--hash=sha256:d7fded462629cfa4b685c5416b949ebad6cec74af5e2d42905d41e257e0869f5 \
--hash=sha256:d84a1718ee396f54f3a086ea0a66d8e552b2ab2017ef8b420e92edbc841c352d \
--hash=sha256:d8e03406cac8513435335dbab54c0d385e4a49e4945d2909a581c83647ca0290 \
--hash=sha256:e10ce637b18caea04431ce14fabcf5c64a1c61ec9c56b071a4b7ca131ca52d44 \
--hash=sha256:ec031d5d2feb36d1d1a24380e4db6d43695f3748343d99434e6f5f9156aaa2ed \
--hash=sha256:ef6107725bd54b262d6dedcc2af448a266975032bc85ef0172c5f059da6325b4 \
--hash=sha256:efdca5630322a10774e8e98e1af481aad470dd62c3170801852d752aa7a783ba \
--hash=sha256:f753120cb8181e736c57ef7636e83f31b9c0d1722c516f7e86cf15b7aa57ff12 \
--hash=sha256:ff3824dc5261f50c9b0dfb3be22b4567a6f938ccce4587b38952d85fd9e9afe4
# via pre-commit
virtualenv==20.35.4 \
--hash=sha256:643d3914d73d3eeb0c552cbb12d7e82adf0e504dbf86a3182f8771a153a1971c \
--hash=sha256:c21c9cede36c9753eeade68ba7d523529f228a403463376cf821eaae2b650f1b
virtualenv==20.31.2 \
--hash=sha256:36efd0d9650ee985f0cad72065001e66d49a6f24eb44d98980f630686243cf11 \
--hash=sha256:e10c0a9d02835e592521be48b332b6caee6887f332c111aa79a09b9e79efc2af
# via pre-commit
+2 -2
View File
@@ -1,5 +1,5 @@
#
# This file is autogenerated by pip-compile with Python 3.12
# This file is autogenerated by pip-compile with Python 3.10
# by the following command:
#
# pip-compile --generate-hashes --output-file='.github\scripts\requirements_sync_readme.txt' --strip-extras '.github\scripts\requirements_sync_readme.in'
@@ -7,4 +7,4 @@
tomlkit==0.13.3 \
--hash=sha256:430cf247ee57df2b94ee3fbe588e71d362a941ebb545dec29b53961d61add2a1 \
--hash=sha256:c89c649d79ee40629a9fda55f8ace8c6a1b42deb912b2a8fd8d942ddadb606b0
# via -r .github/scripts/requirements_sync_readme.in
# via -r .github\scripts\requirements_sync_readme.in
+61 -205
View File
@@ -6,18 +6,20 @@ on:
permissions:
contents: read
pull-requests: read
issues: write # Required for adding reactions to comments
pull-requests: read # Required for reading PR information
jobs:
check-comment:
runs-on: ubuntu-latest
permissions:
issues: write
pull-requests: read
if: |
github.event.issue.pull_request &&
(
contains(github.event.comment.body, 'prdeploy') ||
contains(github.event.comment.body, 'deploypr')
contains(github.event.comment.body, 'prdeploy') ||
contains(github.event.comment.body, 'deploypr')
)
&&
(
@@ -33,37 +35,58 @@ jobs:
)
outputs:
pr_number: ${{ steps.get-pr.outputs.pr_number }}
pr_repository: ${{ steps.get-pr-info.outputs.repository }}
pr_ref: ${{ steps.get-pr-info.outputs.ref }}
comment_id: ${{ github.event.comment.id }}
disable_security: ${{ steps.check-security-flag.outputs.disable_security }}
enable_pro: ${{ steps.check-pro-flag.outputs.enable_pro }}
enable_enterprise: ${{ steps.check-pro-flag.outputs.enable_enterprise }}
steps:
- name: Harden Runner
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
uses: step-security/harden-runner@6c439dc8bdf85cadbbce9ed30d1c7b959517bc49 # v2.12.2
with:
egress-policy: audit
- name: Checkout PR
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
- name: Setup GitHub App Bot
if: github.actor != 'dependabot[bot]'
id: setup-bot
uses: ./.github/actions/setup-bot
continue-on-error: true
# Generate GitHub App token
- name: Generate GitHub App Token
id: generate-token
uses: actions/create-github-app-token@df432ceedc7162793a195dd1713ff69aefc7379e # v2.0.6
with:
app-id: ${{ secrets.GH_APP_ID }}
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
- name: Get PR data
id: get-pr
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
with:
script: |
const prNumber = context.payload.issue.number;
console.log(`PR Number: ${prNumber}`);
core.setOutput('pr_number', prNumber);
- name: Get PR repository and ref
id: get-pr-info
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
with:
script: |
const { owner, repo } = context.repo;
const prNumber = context.payload.issue.number;
const { data: pr } = await github.rest.pulls.get({
owner,
repo,
pull_number: prNumber,
});
// For forks, use the full repository name, for internal PRs use the current repo
const repository = pr.head.repo.fork ? pr.head.repo.full_name : `${owner}/${repo}`;
console.log(`PR Repository: ${repository}`);
console.log(`PR Branch: ${pr.head.ref}`);
core.setOutput('repository', repository);
core.setOutput('ref', pr.head.ref);
- name: Check for security/login flag
id: check-security-flag
env:
@@ -98,9 +121,9 @@ jobs:
- name: Add 'in_progress' reaction to comment
id: add-eyes-reaction
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
with:
github-token: ${{ steps.setup-bot.outputs.token }}
github-token: ${{ steps.generate-token.outputs.token }}
script: |
console.log(`Adding eyes reaction to comment ID: ${context.payload.comment.id}`);
try {
@@ -122,35 +145,31 @@ jobs:
needs: check-comment
runs-on: ubuntu-latest
permissions:
contents: read
issues: write
pull-requests: write
steps:
- name: Harden Runner
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
uses: step-security/harden-runner@6c439dc8bdf85cadbbce9ed30d1c7b959517bc49 # v2.12.2
with:
egress-policy: audit
- name: Checkout PR
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
- name: Setup GitHub App Bot
if: github.actor != 'dependabot[bot]'
id: setup-bot
uses: ./.github/actions/setup-bot
continue-on-error: true
- name: Generate GitHub App Token
id: generate-token
uses: actions/create-github-app-token@df432ceedc7162793a195dd1713ff69aefc7379e # v2.0.6
with:
app-id: ${{ secrets.GH_APP_ID }}
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
- name: Checkout PR
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
ref: refs/pull/${{ needs.check-comment.outputs.pr_number }}/merge
token: ${{ steps.setup-bot.outputs.token }}
repository: ${{ needs.check-comment.outputs.pr_repository }}
ref: ${{ needs.check-comment.outputs.pr_ref }}
token: ${{ secrets.GITHUB_TOKEN }}
- name: Set up JDK
uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v5.0.0
uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # v4.7.1
with:
java-version: "17"
distribution: "temurin"
@@ -169,8 +188,14 @@ jobs:
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1
- name: Get version number
id: versionNumber
run: |
VERSION=$(grep "^version =" build.gradle | awk -F'"' '{print $2}')
echo "versionNumber=$VERSION" >> $GITHUB_OUTPUT
- name: Login to Docker Hub
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3.4.0
with:
username: ${{ secrets.DOCKER_HUB_USERNAME }}
password: ${{ secrets.DOCKER_HUB_API }}
@@ -270,9 +295,9 @@ jobs:
- name: Add success reaction to comment
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 }}
github-token: ${{ steps.generate-token.outputs.token }}
script: |
console.log(`Adding rocket reaction to comment ID: ${{ needs.check-comment.outputs.comment_id }}`);
try {
@@ -288,26 +313,11 @@ jobs:
console.error(error);
}
// add label to PR
const prNumber = ${{ needs.check-comment.outputs.pr_number }};
try {
await github.rest.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: prNumber,
labels: ['pr-deployed']
});
console.log(`Added 'pr-deployed' label to PR #${prNumber}`);
} catch (error) {
console.error(`Failed to add label to PR: ${error.message}`);
console.error(error);
}
- name: Add failure reaction to comment
if: failure()
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
with:
github-token: ${{ steps.setup-bot.outputs.token }}
github-token: ${{ steps.generate-token.outputs.token }}
script: |
console.log(`Adding -1 reaction to comment ID: ${{ needs.check-comment.outputs.comment_id }}`);
try {
@@ -325,9 +335,9 @@ jobs:
- name: Post 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 }}
github-token: ${{ steps.generate-token.outputs.token }}
script: |
const { GITHUB_REPOSITORY } = process.env;
const [repoOwner, repoName] = GITHUB_REPOSITORY.split('/');
@@ -347,157 +357,3 @@ jobs:
issue_number: prNumber,
body: commentBody
});
- name: Cleanup temporary files
if: always()
run: |
echo "Cleaning up temporary files..."
rm -f ../private.key docker-compose.yml
echo "Cleanup complete."
continue-on-error: true
handle-label-commands:
if: ${{ github.event.issue.pull_request != null }}
runs-on: ubuntu-latest
steps:
- name: Harden Runner
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
with:
egress-policy: audit
- name: Check out the repository
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.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: Apply label commands
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
with:
github-token: ${{ steps.setup-bot.outputs.token }}
script: |
const fs = require('fs');
const path = require('path');
const { comment, issue } = context.payload;
const commentBody = comment?.body ?? '';
if (!commentBody.includes('::label::')) {
core.info('No label commands detected in comment.');
return;
}
const configPath = path.join(process.env.GITHUB_WORKSPACE, '.github', 'config', 'repo_devs.json');
const repoDevsConfig = JSON.parse(fs.readFileSync(configPath, 'utf8'));
const label_changer = (repoDevsConfig.label_changer || []).map((login) => login.toLowerCase());
const commenter = (comment?.user?.login || '').toLowerCase();
if (!label_changer.includes(commenter)) {
core.info(`User ${commenter} is not authorized to manage labels.`);
return;
}
const labelsConfigPath = path.join(process.env.GITHUB_WORKSPACE, '.github', 'labels.yml');
const labelsFile = fs.readFileSync(labelsConfigPath, 'utf8');
const labelNameMap = new Map();
for (const match of labelsFile.matchAll(/-\s+name:\s*(?:"([^"]+)"|'([^']+)'|([^\n]+))/g)) {
const labelName = (match[1] ?? match[2] ?? match[3] ?? '').trim();
if (!labelName) {
continue;
}
const normalized = labelName.toLowerCase();
if (!labelNameMap.has(normalized)) {
labelNameMap.set(normalized, labelName);
}
}
if (!labelNameMap.size) {
core.warning('No labels could be read from .github/labels.yml; aborting label commands.');
return;
}
let allowedLabelNames = new Set(labelNameMap.values());
const labelsToAdd = new Set();
const labelsToRemove = new Set();
const commandRegex = /^(\w+)::(label)::"([^"]+)"/gim;
let match;
while ((match = commandRegex.exec(commentBody)) !== null) {
core.info(`Found label command: ${match[0]} (action: ${match[1]}, label: ${match[2]}, labelName: ${match[3]})`);
const action = match[1].toLowerCase();
const labelName = match[3].trim();
if (!labelName) {
continue;
}
const normalized = labelName.toLowerCase();
const resolvedLabelName = labelNameMap.get(normalized);
if (action === 'add') {
if (!resolvedLabelName) {
core.warning(`Label "${labelName}" is not defined in .github/labels.yml and cannot be added.`);
continue;
}
if (!allowedLabelNames.has(resolvedLabelName)) {
core.warning(`Label "${resolvedLabelName}" is not allowed for add commands and will be skipped.`);
continue;
}
labelsToAdd.add(resolvedLabelName);
} else if (action === 'rm') {
const labelToRemove = resolvedLabelName ?? labelName;
if (!resolvedLabelName) {
core.warning(`Label "${labelName}" is not defined in .github/labels.yml; attempting to remove as provided.`);
}
labelsToRemove.add(labelToRemove);
}
}
const addLabels = Array.from(labelsToAdd);
const removeLabels = Array.from(labelsToRemove);
if (!addLabels.length && !removeLabels.length) {
core.info('No valid label commands found after parsing.');
return;
}
const issueParams = {
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issue.number,
};
if (addLabels.length) {
core.info(`Adding labels: ${addLabels.join(', ')}`);
await github.rest.issues.addLabels({
...issueParams,
labels: addLabels,
});
}
for (const labelName of removeLabels) {
core.info(`Removing label: ${labelName}`);
try {
await github.rest.issues.removeLabel({
...issueParams,
name: labelName,
});
} catch (error) {
if (error.status === 404) {
core.warning(`Label "${labelName}" was not present on the pull request.`);
} else {
throw error;
}
}
}
await github.rest.issues.deleteComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: comment.id,
});
core.info('Processed label commands and deleted the comment.');
+4 -86
View File
@@ -1,7 +1,7 @@
name: PR Deployment cleanup
on:
pull_request_target:
pull_request:
types: [opened, synchronize, reopened, closed]
permissions:
@@ -13,99 +13,25 @@ env:
jobs:
cleanup:
if: github.event.action == 'closed'
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
issues: write
if: github.event.action == 'closed'
steps:
- name: Harden Runner
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
uses: step-security/harden-runner@6c439dc8bdf85cadbbce9ed30d1c7b959517bc49 # v2.12.2
with:
egress-policy: audit
- name: Checkout PR
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
- name: Setup GitHub App Bot
if: github.actor != 'dependabot[bot]'
id: setup-bot
uses: ./.github/actions/setup-bot
continue-on-error: true
with:
app-id: ${{ secrets.GH_APP_ID }}
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
- name: Remove 'pr-deployed' label if present
id: remove-label-comment
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
with:
github-token: ${{ steps.setup-bot.outputs.token }}
script: |
const prNumber = ${{ github.event.pull_request.number }};
const owner = context.repo.owner;
const repo = context.repo.repo;
// Get all labels on the PR
const { data: labels } = await github.rest.issues.listLabelsOnIssue({
owner,
repo,
issue_number: prNumber
});
const hasLabel = labels.some(label => label.name === 'pr-deployed');
if (hasLabel) {
console.log("Label 'pr-deployed' found. Removing...");
await github.rest.issues.removeLabel({
owner,
repo,
issue_number: prNumber,
name: 'pr-deployed'
});
} else {
console.log("Label 'pr-deployed' not found. Nothing to do.");
}
// Find existing bot comments about the deployment
const { data: comments } = await github.rest.issues.listComments({
owner,
repo,
issue_number: prNumber
});
const deploymentComments = comments.filter(c =>
c.body?.includes("## 🚀 PR Test Deployment") &&
c.user?.type === "Bot"
);
if (deploymentComments.length > 0) {
for (const comment of deploymentComments) {
await github.rest.issues.deleteComment({
owner,
repo,
comment_id: comment.id
});
console.log(`Deleted deployment comment (ID: ${comment.id})`);
}
} else {
console.log("No matching deployment comments found.");
}
// Set flag if either label or comment was present
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: |
mkdir -p ~/.ssh/
echo "${{ secrets.VPS_SSH_KEY }}" > ../private.key
sudo chmod 600 ../private.key
- name: Cleanup PR deployment
if: steps.remove-label-comment.outputs.present == 'true'
id: cleanup
run: |
ssh -i ../private.key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -T ${{ secrets.VPS_USERNAME }}@${{ secrets.VPS_HOST }} << 'ENDSSH'
@@ -131,11 +57,3 @@ jobs:
echo "NO_CLEANUP_NEEDED"
fi
ENDSSH
- name: Cleanup temporary files
if: always()
run: |
echo "Cleaning up temporary files..."
rm -f ../private.key
echo "Cleanup complete."
continue-on-error: true
+4 -4
View File
@@ -19,11 +19,11 @@ jobs:
steps:
- name: Harden Runner
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
uses: step-security/harden-runner@6c439dc8bdf85cadbbce9ed30d1c7b959517bc49 # v2.12.2
with:
egress-policy: audit
- uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
fetch-depth: 0
@@ -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@b81b2afb8390ee6839b494a404766bef6493c7d9 # v1.2.8
uses: actions/ai-inference@d645f067d89ee1d5d736a5990e327e504d1c5a4a # v1.1.0
with:
model: openai/gpt-4o
system-prompt-file: ".github/config/system-prompt.txt"
@@ -158,7 +158,7 @@ jobs:
- name: Post comment on PR if needed
if: steps.actor.outputs.is_repo_dev == 'true'
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
continue-on-error: true
with:
github-token: ${{ steps.setup-bot.outputs.token }}
+27
View File
@@ -0,0 +1,27 @@
name: "Pull Request Labeler"
on:
pull_request_target:
types: [opened, synchronize]
permissions:
contents: read
jobs:
labeler:
runs-on: ubuntu-latest
permissions:
pull-requests: write
steps:
- name: Harden Runner
uses: step-security/harden-runner@6c439dc8bdf85cadbbce9ed30d1c7b959517bc49 # v2.12.2
with:
egress-policy: audit
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Apply Labels
uses: actions/labeler@8558fd74291d67161a8a78ce36a881fa63b766a9 # v5.0.0
with:
repo-token: ${{ secrets.GITHUB_TOKEN }}
configuration-path: .github/labeler-config.yml
sync-labels: true
+2 -5
View File
@@ -2,9 +2,6 @@ name: "Auto Pull Request Labeler V2"
on:
pull_request_target:
types: [opened, synchronize]
branches:
- main
- V2
permissions:
contents: read
@@ -16,11 +13,11 @@ jobs:
pull-requests: write
steps:
- name: Harden Runner
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
uses: step-security/harden-runner@6c439dc8bdf85cadbbce9ed30d1c7b959517bc49 # v2.12.2
with:
egress-policy: audit
- uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Setup GitHub App Bot
id: setup-bot
+40 -380
View File
@@ -1,47 +1,15 @@
name: Build and Test Workflow
name: Build repo
on:
workflow_dispatch:
# push:
# branches: ["main"]
push:
branches: ["main"]
pull_request:
branches: ["main"]
# 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.event.pull_request.number || 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
# Map a step output to a job output
outputs:
build: ${{ steps.changes.outputs.build }}
app: ${{ steps.changes.outputs.app }}
project: ${{ steps.changes.outputs.project }}
openapi: ${{ steps.changes.outputs.openapi }}
docker: ${{ steps.changes.outputs.docker }}
steps:
- 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"
build:
runs-on: ubuntu-latest
@@ -57,22 +25,21 @@ jobs:
steps:
- name: Harden Runner
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
uses: step-security/harden-runner@6c439dc8bdf85cadbbce9ed30d1c7b959517bc49 # v2.12.2
with:
egress-policy: audit
- name: Checkout repository
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Set up JDK ${{ matrix.jdk-version }}
uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v5.0.0
uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # v4.7.1
with:
java-version: ${{ matrix.jdk-version }}
distribution: "temurin"
cache: gradle
- name: Build with Gradle and spring security ${{ matrix.spring-security }}
run: ./gradlew clean build -x spotlessApply -x spotlessCheck -x sonarqube
run: ./gradlew clean build
env:
DISABLE_ADDITIONAL_FEATURES: ${{ matrix.spring-security }}
@@ -81,12 +48,12 @@ jobs:
if: always()
run: |
declare -a dirs=(
"app/core/build/reports/tests/"
"app/core/build/test-results/"
"app/common/build/reports/tests/"
"app/common/build/test-results/"
"app/proprietary/build/reports/tests/"
"app/proprietary/build/test-results/"
"stirling-pdf/build/reports/tests/"
"stirling-pdf/build/test-results/"
"common/build/reports/tests/"
"common/build/test-results/"
"proprietary/build/reports/tests/"
"proprietary/build/test-results/"
)
missing_reports=()
for dir in "${dirs[@]}"; do
@@ -97,106 +64,52 @@ jobs:
if [ ${#missing_reports[@]} -gt 0 ]; then
echo "ERROR: The following required test report directories are missing:"
printf '%s\n' "${missing_reports[@]}"
echo "reports-present=false" >> "$GITHUB_OUTPUT"
else
echo "All required test report directories are present"
echo "reports-present=true" >> "$GITHUB_OUTPUT"
exit 1
fi
echo "All required test report directories are present"
- name: Upload Test Reports
if: always() && steps.check-reports.outputs.reports-present == 'true'
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
if: always()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: test-reports-jdk-${{ matrix.jdk-version }}-spring-security-${{ matrix.spring-security }}
path: |
app/core/build/reports/jacoco/test
app/core/build/reports/tests/
app/core/build/test-results/
app/core/build/reports/problems/
app/common/build/reports/tests/
app/common/build/test-results/
app/common/build/reports/jacoco/test
app/common/build/reports/problems/
app/proprietary/build/reports/tests/
app/proprietary/build/test-results/
app/proprietary/build/reports/jacoco/test
app/proprietary/build/reports/problems/
stirling-pdf/build/reports/tests/
stirling-pdf/build/test-results/
stirling-pdf/build/reports/problems/
common/build/reports/tests/
common/build/test-results/
common/build/reports/problems/
proprietary/build/reports/tests/
proprietary/build/test-results/
proprietary/build/reports/problems/
build/reports/problems/
retention-days: 3
if-no-files-found: warn
- name: Add coverage to PR with spring security ${{ matrix.spring-security }} and JDK ${{ matrix.jdk-version }}
if: steps.check-reports.outputs.reports-present == 'true'
id: jacoco
uses: madrapps/jacoco-report@50d3aff4548aa991e6753342d9ba291084e63848 # v1.7.2
with:
paths: |
${{ github.workspace }}/**/build/reports/jacoco/test/jacocoTestReport.xml
token: ${{ secrets.GITHUB_TOKEN }}
min-coverage-overall: 10
min-coverage-changed-files: 0
comment-type: summary
check-generateOpenApiDocs:
if: needs.files-changed.outputs.openapi == 'true'
needs: [files-changed, build]
runs-on: ubuntu-latest
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: Set up JDK 17
uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v5.0.0
with:
java-version: "17"
distribution: "temurin"
cache: gradle
- name: Generate OpenAPI documentation
run: ./gradlew :stirling-pdf:generateOpenApiDocs
env:
DISABLE_ADDITIONAL_FEATURES: true
- name: Upload OpenAPI Documentation
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
with:
name: openapi-docs
path: ./SwaggerDoc.json
check-licence:
if: needs.files-changed.outputs.build == 'true'
needs: [files-changed, build]
runs-on: ubuntu-latest
steps:
- name: Harden Runner
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
uses: step-security/harden-runner@6c439dc8bdf85cadbbce9ed30d1c7b959517bc49 # v2.12.2
with:
egress-policy: audit
- name: Checkout repository
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Set up JDK 17
uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v5.0.0
uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # v4.7.1
with:
java-version: "17"
distribution: "temurin"
cache: gradle
distribution: "adopt"
- name: Check licenses for compatibility
- name: check the licenses for compatibility
run: ./gradlew clean checkLicense
env:
DISABLE_ADDITIONAL_FEATURES: false
STIRLING_PDF_DESKTOP_UI: true
- name: FAILED - Check licenses for compatibility
- name: FAILED - check the licenses for compatibility
if: failure()
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: dependencies-without-allowed-license.json
path: |
@@ -204,8 +117,6 @@ jobs:
retention-days: 3
docker-compose-tests:
if: needs.files-changed.outputs.project == 'true'
needs: files-changed
# if: github.event_name == 'push' && github.ref == 'refs/heads/main' ||
# (github.event_name == 'pull_request' &&
# contains(github.event.pull_request.labels.*.name, 'licenses') == false &&
@@ -224,34 +135,32 @@ jobs:
steps:
- name: Harden Runner
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
uses: step-security/harden-runner@6c439dc8bdf85cadbbce9ed30d1c7b959517bc49 # v2.12.2
with:
egress-policy: audit
- name: Checkout Repository
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Set up Java 17
uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v5.0.0
uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # v4.7.1
with:
java-version: "17"
distribution: "temurin"
cache: gradle
distribution: "adopt"
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1
- name: Install Docker Compose
run: |
sudo curl -SL "https://github.com/docker/compose/releases/download/v2.40.3/docker-compose-$(uname -s)-$(uname -m)" -o /usr/local/bin/docker-compose
sudo curl -SL "https://github.com/docker/compose/releases/download/v2.37.2/docker-compose-$(uname -s)-$(uname -m)" -o /usr/local/bin/docker-compose
sudo chmod +x /usr/local/bin/docker-compose
- name: Set up Python
uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
with:
python-version: "3.12"
cache: "pip" # caching pip dependencies
cache-dependency-path: ./testing/cucumber/requirements.txt
cache: 'pip' # caching pip dependencies
- name: Pip requirements
run: |
@@ -263,252 +172,3 @@ jobs:
chmod +x ./testing/test.sh
chmod +x ./testing/test_disabledEndpoints.sh
./testing/test.sh
test-build-docker-images:
if: github.event_name == 'pull_request' && needs.files-changed.outputs.docker == 'true'
needs: [files-changed, build]
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
strategy:
fail-fast: false
matrix:
docker:
- name: "Dockerfile.ultra-lite"
tag: "ultra-lite"
- name: "Dockerfile.fat"
tag: "fat"
- name: "Dockerfile"
tag: "latest"
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: Set up JDK 17
uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v5.0.0
with:
java-version: "17"
distribution: "temurin"
cache: gradle
- name: Build application
run: ./gradlew clean build -x spotlessApply -x spotlessCheck -x test -x sonarqube
env:
DISABLE_ADDITIONAL_FEATURES: true
STIRLING_PDF_DESKTOP_UI: false
# - name: Free disk space on runner
# run: |
# echo "Disk space before cleanup:" && df -h
# sudo rm -rf /usr/share/dotnet /opt/ghc /usr/local/lib/android /usr/local/share/boost
# docker system prune -af || true
# echo "Disk space after cleanup:" && df -h
- name: Set up QEMU
uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0
with:
platforms: linux/amd64,linux/arm64/v8
- name: Set up Docker Buildx
id: buildx
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1
with:
platforms: linux/amd64,linux/arm64/v8
- name: Prepare branch tag
id: branch_tag
shell: bash
run: |
BRANCH_SOURCE="${GITHUB_HEAD_REF:-${GITHUB_REF_NAME}}"
BRANCH_LOWER=$(echo "$BRANCH_SOURCE" | tr '[:upper:]' '[:lower:]')
SAFE_BRANCH=$(echo "$BRANCH_LOWER" | sed 's/[^a-z0-9_.-]/-/g' | sed 's/^-\+//' | sed 's/-\+$//' | sed 's/--\+/-/g')
if [ -z "$SAFE_BRANCH" ]; then
SAFE_BRANCH="branch"
fi
SHORT_SHA=$(echo "${GITHUB_SHA:-${{ github.sha }}}" | cut -c1-8)
echo "safe_branch=$SAFE_BRANCH" >> "$GITHUB_OUTPUT"
echo "short_sha=$SHORT_SHA" >> "$GITHUB_OUTPUT"
- name: Convert repository owner to lowercase
id: repoowner
run: echo "lowercase=$(echo ${{ github.repository_owner }} | tr '[:upper:]' '[:lower:]')" >> $GITHUB_OUTPUT
- name: Docker meta
id: meta
uses: docker/metadata-action@318604b99e75e41977312d83839a89be02ca4893 # v5.9.0
with:
images: |
# ${{ secrets.DOCKER_HUB_USERNAME }}/stirling-pdf-test
ghcr.io/${{ steps.repoowner.outputs.lowercase }}/stirling-pdf-test
flavor: |
latest=false
tags: |
type=raw,value=${{ matrix.docker.tag }},enable=true
# type=raw,value=${{ matrix.docker.tag }}-${{ steps.branch_tag.outputs.safe_branch }},enable=true
# type=raw,value=${{ matrix.docker.tag }}-${{ steps.branch_tag.outputs.safe_branch }}-${{ steps.branch_tag.outputs.short_sha }},enable=true
labels: |
org.opencontainers.image.title=Stirling-PDF Test
org.opencontainers.image.description=CI test image for Stirling-PDF
org.opencontainers.image.url=https://www.stirlingpdf.com
org.opencontainers.image.documentation=https://docs.stirlingpdf.com
org.opencontainers.image.authors=Stirling-Tools
org.opencontainers.image.licenses=MIT
org.opencontainers.image.version=${{ matrix.docker.tag }}
org.opencontainers.image.revision=${{ github.sha }}
org.opencontainers.image.source=${{ github.repository }}
maintainer=Stirling-Tools
- name: Choose primary tag for tests
id: testtag
shell: bash
run: |
IMAGE="ghcr.io/${{ steps.repoowner.outputs.lowercase }}/stirling-pdf-test"
VARIANT="${{ matrix.docker.tag }}"
BRANCH="${{ steps.branch_tag.outputs.safe_branch }}"
SHA_SHORT="${{ steps.branch_tag.outputs.short_sha }}"
CANDIDATE="$IMAGE:$VARIANT-$BRANCH-$SHA_SHORT"
SECONDARY="$IMAGE:$VARIANT-$BRANCH"
ALL_TAGS="$(echo '${{ steps.meta.outputs.tags }}' | tr ' ' '\n')"
if echo "$ALL_TAGS" | grep -qx "$CANDIDATE"; then
SELECTED="$CANDIDATE"
elif echo "$ALL_TAGS" | grep -qx "$SECONDARY"; then
SELECTED="$SECONDARY"
else
SELECTED="$(echo "$ALL_TAGS" | head -n1)"
fi
echo "tag=$SELECTED" >> $GITHUB_OUTPUT
echo "Using test tag: $SELECTED"
# - name: Log in to Docker Hub
# uses: docker/login-action@184bdaa0721073962dff0199f1fb9940f07167d1 # v3.5.0
# with:
# username: ${{ secrets.DOCKER_HUB_USERNAME }}
# password: ${{ secrets.DOCKER_HUB_API }}
# - name: Log in to GitHub Container Registry
# uses: docker/login-action@184bdaa0721073962dff0199f1fb9940f07167d1 # v3.5.0
# with:
# registry: ghcr.io
# username: ${{ github.actor }}
# password: ${{ github.token }}
- name: Build and push amd64 image
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
with:
builder: ${{ steps.buildx.outputs.name }}
context: .
file: ./${{ matrix.docker.name }}
push: false
load: true
cache-from: type=gha
cache-to: type=gha,mode=max
tags: ${{ steps.meta.outputs.tags }} # ALLE Tags publishen
labels: ${{ steps.meta.outputs.labels }}
platforms: linux/amd64
provenance: false
sbom: false
- name: Show amd64 image size
run: |
IMAGE_TAG="${{ steps.testtag.outputs.tag }}"
echo "Inspecting image: ${IMAGE_TAG}"
SIZE=$(docker image inspect "${IMAGE_TAG}" --format='{{.Size}}')
FORMATTED=$(numfmt --to=iec --suffix=B "${SIZE}")
echo "Image size (amd64): ${FORMATTED}"
- name: Start amd64 image for 2 minutes
run: |
IMAGE_TAG="${{ steps.testtag.outputs.tag }}"
CONTAINER_NAME="stirling-pdf-test-${{ matrix.docker.tag }}-amd64"
echo "Starting container ${CONTAINER_NAME} from ${IMAGE_TAG}"
docker run -d --name "${CONTAINER_NAME}" "${IMAGE_TAG}"
echo "Waiting up to 2 minutes..."
sleep 120 || true
echo "===== Logs for ${CONTAINER_NAME} ====="
docker logs "${CONTAINER_NAME}" || true
echo "Stopping container ${CONTAINER_NAME} after 2 minutes"
docker stop "${CONTAINER_NAME}" || true
docker rm "${CONTAINER_NAME}" || true
- name: Prune amd64 image and cache
if: always()
run: |
docker image rm -f ${{ steps.testtag.outputs.tag }} || true
docker builder prune --force || true
- name: Build and push arm64 image
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
with:
builder: ${{ steps.buildx.outputs.name }}
context: .
file: ./${{ matrix.docker.name }}
push: false
load: true
cache-from: type=gha
cache-to: type=gha,mode=max
tags: ${{ steps.meta.outputs.tags }} # ALLE Tags publishen
labels: ${{ steps.meta.outputs.labels }}
platforms: linux/arm64/v8
provenance: false
sbom: false
- name: Show arm64 image size
run: |
IMAGE_TAG="${{ steps.testtag.outputs.tag }}"
echo "Inspecting image: ${IMAGE_TAG}"
SIZE=$(docker image inspect "${IMAGE_TAG}" --format='{{.Size}}')
FORMATTED=$(numfmt --to=iec --suffix=B "${SIZE}")
echo "Image size (arm64): ${FORMATTED}"
- name: Start arm64 image for 2 minutes
run: |
IMAGE_TAG="${{ steps.testtag.outputs.tag }}"
CONTAINER_NAME="stirling-pdf-test-${{ matrix.docker.tag }}-arm64"
echo "Starting container ${CONTAINER_NAME} from ${IMAGE_TAG}"
docker run -d --name "${CONTAINER_NAME}" "${IMAGE_TAG}"
echo "Waiting up to 2 minutes..."
sleep 120 || true
echo "===== Logs for ${CONTAINER_NAME} ====="
docker logs "${CONTAINER_NAME}" || true
echo "Stopping container ${CONTAINER_NAME} after 2 minutes"
docker stop "${CONTAINER_NAME}" || true
docker rm "${CONTAINER_NAME}" || true
- name: Cleanup arm64 image and cache
if: always()
run: |
docker image rm -f ${{ steps.testtag.outputs.tag }} || true
docker builder prune --force || true
# - name: Build and push multi-arch image
# uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
# with:
# builder: ${{ steps.buildx.outputs.name }}
# context: .
# file: ./${{ matrix.docker.name }}
# push: true
# cache-from: type=gha
# cache-to: type=gha,mode=max
# tags: ${{ steps.meta.outputs.tags }}
# labels: ${{ steps.meta.outputs.labels }}
# platforms: linux/amd64,linux/arm64/v8
# provenance: false
# sbom: false
# - name: Upload Docker build reports
# if: always()
# uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
# with:
# name: reports-docker-${{ matrix.docker.name }}
# path: |
# build/reports/
# build/test-results/
# build/reports/problems/
# retention-days: 3
# if-no-files-found: warn
+13 -27
View File
@@ -4,21 +4,7 @@ on:
pull_request_target:
types: [opened, synchronize, reopened]
paths:
- "app/core/src/main/resources/messages_*.properties"
branches:
- main
# 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.event.pull_request.number || github.ref_name || github.ref }}
cancel-in-progress: true
- "stirling-pdf/src/main/resources/messages_*.properties"
permissions:
contents: read # Allow read access to repository content
@@ -32,12 +18,12 @@ jobs:
pull-requests: write # Allow writing to pull requests
steps:
- name: Harden Runner
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
uses: step-security/harden-runner@6c439dc8bdf85cadbbce9ed30d1c7b959517bc49 # v2.12.2
with:
egress-policy: audit
- name: Checkout main branch first
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Setup GitHub App Bot
id: setup-bot
@@ -48,7 +34,7 @@ jobs:
- name: Get PR data
id: get-pr-data
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: |
@@ -81,7 +67,7 @@ jobs:
exit 1
fi
# Get changed files and filter for properties files, handle case where no matches are found
gh pr view ${{ steps.get-pr-data.outputs.pr_number }} --json files -q ".files[].path" | grep -E '^app/core/src/main/resources/messages_[a-zA-Z_]{2}_[a-zA-Z_]{2,7}\.properties$' > changed_files.txt || echo "No matching properties files found in PR"
gh pr view ${{ steps.get-pr-data.outputs.pr_number }} --json files -q ".files[].path" | grep -E '^stirling-pdf/src/main/resources/messages_[a-zA-Z_]{2}_[a-zA-Z_]{2,7}\.properties$' > changed_files.txt || echo "No matching properties files found in PR"
# Check if any files were found
if [ ! -s changed_files.txt ]; then
echo "No properties files changed in this PR"
@@ -92,7 +78,7 @@ jobs:
- name: Determine reference file test
id: determine-file
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: |
@@ -129,9 +115,9 @@ jobs:
// Filter for relevant files based on the PR changes
const changedFiles = files
.filter(file =>
.filter(file =>
file.status !== "removed" &&
/^app\/core\/src\/main\/resources\/messages_[a-zA-Z_]{2}_[a-zA-Z_]{2,7}\.properties$/.test(file.filename)
/^stirling-pdf\/src\/main\/resources\/messages_[a-zA-Z_]{2}_[a-zA-Z_]{2,7}\.properties$/.test(file.filename)
)
.map(file => file.filename);
@@ -171,12 +157,12 @@ jobs:
// Determine reference file
let referenceFilePath;
if (changedFiles.includes("app/core/src/main/resources/messages_en_GB.properties")) {
if (changedFiles.includes("stirling-pdf/src/main/resources/messages_en_GB.properties")) {
console.log("Using PR branch reference file.");
const { data: fileContent } = await github.rest.repos.getContent({
owner: prRepoOwner,
repo: prRepoName,
path: "app/core/src/main/resources/messages_en_GB.properties",
path: "stirling-pdf/src/main/resources/messages_en_GB.properties",
ref: branch,
});
@@ -188,7 +174,7 @@ jobs:
const { data: fileContent } = await github.rest.repos.getContent({
owner: repoOwner,
repo: repoName,
path: "app/core/src/main/resources/messages_en_GB.properties",
path: "stirling-pdf/src/main/resources/messages_en_GB.properties",
ref: "main",
});
@@ -236,7 +222,7 @@ jobs:
- name: Post comment on PR
if: env.SCRIPT_OUTPUT != ''
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: |
@@ -291,4 +277,4 @@ jobs:
rm -rf pr-branch
rm -f pr-branch-messages_en_GB.properties main-branch-messages_en_GB.properties changed_files.txt result.txt
echo "Cleanup complete."
continue-on-error: true # Ensure cleanup runs even if previous steps fail
continue-on-error: true # Ensure cleanup runs even if previous steps fail
+3 -3
View File
@@ -17,11 +17,11 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Harden Runner
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
uses: step-security/harden-runner@6c439dc8bdf85cadbbce9ed30d1c7b959517bc49 # v2.12.2
with:
egress-policy: audit
- name: "Checkout Repository"
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: "Dependency Review"
uses: actions/dependency-review-action@3c4e3dcb1aa7874d2c16be7d79418e9b7efd6261 # v4.8.2
uses: actions/dependency-review-action@da24556b548a50705dd671f47852072ea4c105d9 # v4.7.1
+9 -24
View File
@@ -7,18 +7,6 @@ on:
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
@@ -31,12 +19,12 @@ jobs:
repository-projects: write # Required for enabling automerge
steps:
- name: Harden Runner
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
uses: step-security/harden-runner@6c439dc8bdf85cadbbce9ed30d1c7b959517bc49 # v2.12.2
with:
egress-policy: audit
- name: Check out code
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
fetch-depth: 0
@@ -48,23 +36,20 @@ jobs:
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
- name: Set up JDK 17
uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v5.0.0
uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # v4.7.1
with:
java-version: "17"
distribution: "temurin"
distribution: "adopt"
- name: Setup Gradle
uses: gradle/actions/setup-gradle@4d9f0ba0025fe599b4ebab900eb7f3a1d93ef4c2 # v5.0.0
uses: gradle/actions/setup-gradle@ac638b010cf58a27ee6c972d7336334ccaf61c96 # v4.4.1
- 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
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: dependencies-without-allowed-license.json
path: build/reports/dependency-license/dependencies-without-allowed-license.json
@@ -72,17 +57,17 @@ jobs:
- name: Move and rename license file
run: |
mv build/reports/dependency-license/index.json app/core/src/main/resources/static/3rdPartyLicenses.json
mv build/reports/dependency-license/index.json stirling-pdf/src/main/resources/static/3rdPartyLicenses.json
- name: Commit changes
run: |
git add app/core/src/main/resources/static/3rdPartyLicenses.json
git add stirling-pdf/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
uses: peter-evans/create-pull-request@271a8d0340265f705b14b6d32b9829c1cb33d45e # v7.0.8
with:
token: ${{ steps.setup-bot.outputs.token }}
commit-message: "Update 3rd Party Licenses"
+2 -2
View File
@@ -15,12 +15,12 @@ jobs:
issues: write
steps:
- name: Harden Runner
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
uses: step-security/harden-runner@6c439dc8bdf85cadbbce9ed30d1c7b959517bc49 # v2.12.2
with:
egress-policy: audit
- name: Check out the repository
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Run Labeler
uses: crazy-max/ghaction-github-labeler@24d110aa46a59976b8a7f35518cb7f14f434c916 # v5.3.0
+89 -21
View File
@@ -21,14 +21,14 @@ jobs:
versionMac: ${{ steps.versionNumberMac.outputs.versionNumberMac }}
steps:
- name: Harden Runner
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
uses: step-security/harden-runner@6c439dc8bdf85cadbbce9ed30d1c7b959517bc49 # v2.12.2
with:
egress-policy: audit
- uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Set up JDK
uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v5.0.0
uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # v4.7.1
with:
distribution: 'temurin'
java-version: '21'
@@ -60,19 +60,19 @@ jobs:
file_suffix: ""
steps:
- name: Harden Runner
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
uses: step-security/harden-runner@6c439dc8bdf85cadbbce9ed30d1c7b959517bc49 # v2.12.2
with:
egress-policy: audit
- uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Set up JDK 21
uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v5.0.0
uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # v4.7.1
with:
java-version: "21"
distribution: "temurin"
- uses: gradle/actions/setup-gradle@4d9f0ba0025fe599b4ebab900eb7f3a1d93ef4c2 # v5.0.0
- uses: gradle/actions/setup-gradle@ac638b010cf58a27ee6c972d7336334ccaf61c96 # v4.4.1
with:
gradle-version: 8.14
@@ -86,10 +86,10 @@ jobs:
run: |
mkdir ./binaries
mv ./build/launch4j/Stirling-PDF.exe ./binaries/win-Stirling-PDF-portable-Server${{ matrix.file_suffix }}.exe
mv ./app/core/build/libs/stirling-pdf-${{ needs.read_versions.outputs.version }}.jar ./binaries/Stirling-PDF${{ matrix.file_suffix }}.jar
mv ./stirling-pdf/build/libs/stirling-pdf-${{ needs.read_versions.outputs.version }}.jar ./binaries/Stirling-PDF${{ matrix.file_suffix }}.jar
- name: Upload build artifacts
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
retention-days: 1
if-no-files-found: error
@@ -110,12 +110,12 @@ jobs:
file_suffix: ""
steps:
- name: Harden Runner
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
uses: step-security/harden-runner@6c439dc8bdf85cadbbce9ed30d1c7b959517bc49 # v2.12.2
with:
egress-policy: audit
- name: Download build artifacts
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
with:
name: stirling-${{ matrix.file_suffix }}binaries
@@ -123,7 +123,7 @@ jobs:
run: ls -R
- name: Upload signed artifacts
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
retention-days: 1
if-no-files-found: error
@@ -148,19 +148,19 @@ jobs:
contents: write
steps:
- name: Harden Runner
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
uses: step-security/harden-runner@6c439dc8bdf85cadbbce9ed30d1c7b959517bc49 # v2.12.2
with:
egress-policy: audit
- uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Set up JDK 21
uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v5.0.0
uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # v4.7.1
with:
java-version: "21"
distribution: "temurin"
- uses: gradle/actions/setup-gradle@4d9f0ba0025fe599b4ebab900eb7f3a1d93ef4c2 # v5.0.0
- uses: gradle/actions/setup-gradle@ac638b010cf58a27ee6c972d7336334ccaf61c96 # v4.4.1
with:
gradle-version: 8.14
@@ -216,7 +216,7 @@ jobs:
run: ls -R ./binaries
- name: Upload build artifacts
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
retention-days: 1
if-no-files-found: error
@@ -224,25 +224,93 @@ jobs:
path: |
./binaries/*
sign_verify:
needs: [read_versions, build-installers]
strategy:
matrix:
include:
- os: windows-latest
platform: win-
- os: macos-latest
platform: mac-
# - os: ubuntu-latest
# platform: linux-
runs-on: ubuntu-latest
steps:
- name: Harden Runner
uses: step-security/harden-runner@6c439dc8bdf85cadbbce9ed30d1c7b959517bc49 # v2.12.2
with:
egress-policy: audit
- name: Download build artifacts
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
with:
name: ${{ matrix.platform }}binaries
- name: Display structure of downloaded files
run: ls -R
- name: Install Cosign
if: matrix.os == 'windows-latest'
uses: sigstore/cosign-installer@398d4b0eeef1380460a10c8013a76f728fb906ac # v3.9.1
- name: Generate key pair
if: matrix.os == 'windows-latest'
run: cosign generate-key-pair
- name: Sign and generate attestations
if: matrix.os == 'windows-latest'
run: |
cosign sign-blob \
--key ./cosign.key \
--yes \
--output-signature ./Stirling-PDF-win-installer.exe.sig \
./Stirling-PDF-win-installer.exe
cosign attest-blob \
--predicate - \
--key ./cosign.key \
--yes \
--output-attestation ./Stirling-PDF-win-installer.exe.intoto.jsonl \
./Stirling-PDF-win-installer.exe
cosign verify-blob \
--key ./cosign.pub \
--signature ./Stirling-PDF-win-installer.exe.sig \
./Stirling-PDF-win-installer.exe
- name: Display structure of downloaded files
run: ls -R
- name: Upload signed artifacts
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
retention-days: 1
if-no-files-found: error
name: ${{ matrix.platform }}signed
path: |
./Stirling-PDF-${{ matrix.platform }}installer.*
./Stirling-PDF-${{ matrix.platform }}x86_64-installer.*
!cosign.*
create-release:
if: github.event_name != 'workflow_dispatch' || github.event.inputs.test_mode != 'true'
needs: [read_versions, sign_verify-portable]
needs: [read_versions, sign_verify, sign_verify-portable]
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Harden Runner
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
uses: step-security/harden-runner@6c439dc8bdf85cadbbce9ed30d1c7b959517bc49 # v2.12.2
with:
egress-policy: audit
- name: Download signed artifacts
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
- name: Display structure of downloaded files
run: ls -R
- name: Upload binaries, attestations and signatures to Release and create GitHub Release
uses: softprops/action-gh-release@5be0e66d93ac7ed76da52eca8bb058f665c3a5fe # v2.4.2
uses: softprops/action-gh-release@72f2c25fcb47643c292f7107632f7a47c1df5cd8 # v2.3.2
with:
tag_name: v${{ needs.read_versions.outputs.version }}
generate_release_notes: true
+7 -22
View File
@@ -2,9 +2,8 @@ name: Pre-commit
on:
workflow_dispatch:
push:
branches:
- main
schedule:
- cron: "0 0 * * 1"
permissions:
contents: read
@@ -12,21 +11,17 @@ permissions:
jobs:
pre-commit:
runs-on: ubuntu-latest
env:
# Prevents sdist builds → no tar extraction
PIP_ONLY_BINARY: ":all:"
PIP_DISABLE_PIP_VERSION_CHECK: "1"
permissions:
contents: write
pull-requests: write
steps:
- name: Harden Runner
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
uses: step-security/harden-runner@6c439dc8bdf85cadbbce9ed30d1c7b959517bc49 # v2.12.2
with:
egress-policy: audit
- name: Checkout repository
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
fetch-depth: 0
@@ -38,28 +33,18 @@ jobs:
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
- name: Set up Python
uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
with:
python-version: 3.12
cache: 'pip' # caching pip dependencies
cache-dependency-path: ./.github/scripts/requirements_pre_commit.txt
- name: Run Pre-Commit Hooks
run: |
pip install --require-hashes --only-binary=:all: -r ./.github/scripts/requirements_pre_commit.txt
pip install --require-hashes -r ./.github/scripts/requirements_pre_commit.txt
- run: pre-commit run --all-files -c .pre-commit-config.yaml
continue-on-error: true
- name: Set up JDK
uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v5.0.0
with:
java-version: 17
distribution: "temurin"
- name: Build with Gradle
run: ./gradlew clean build
- name: git add
run: |
git add .
@@ -67,7 +52,7 @@ jobs:
- name: Create Pull Request
if: env.CHANGES_DETECTED == 'true'
uses: peter-evans/create-pull-request@84ae59a2cdc2258d6fa0732dd66352dddae2a412 # v7.0.9
uses: peter-evans/create-pull-request@271a8d0340265f705b14b6d32b9829c1cb33d45e # v7.0.8
with:
token: ${{ steps.setup-bot.outputs.token }}
commit-message: ":file_folder: pre-commit"
+11 -23
View File
@@ -7,18 +7,6 @@ on:
- master
- main
# 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
@@ -30,19 +18,19 @@ jobs:
id-token: write
steps:
- name: Harden Runner
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
uses: step-security/harden-runner@6c439dc8bdf85cadbbce9ed30d1c7b959517bc49 # v2.12.2
with:
egress-policy: audit
- uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Set up JDK 17
uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v5.0.0
uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # v4.7.1
with:
java-version: "17"
distribution: "temurin"
- uses: gradle/actions/setup-gradle@4d9f0ba0025fe599b4ebab900eb7f3a1d93ef4c2 # v5.0.0
- uses: gradle/actions/setup-gradle@ac638b010cf58a27ee6c972d7336334ccaf61c96 # v4.4.1
with:
gradle-version: 8.14
@@ -54,7 +42,7 @@ jobs:
- name: Install cosign
if: github.ref == 'refs/heads/master'
uses: sigstore/cosign-installer@faadad0cce49287aee09b3a48701e75088a2c6ad # v4.0.0
uses: sigstore/cosign-installer@398d4b0eeef1380460a10c8013a76f728fb906ac # v3.9.1
with:
cosign-release: "v2.4.1"
@@ -67,20 +55,20 @@ jobs:
run: echo "versionNumber=$(./gradlew printVersion --quiet | tail -1)" >> $GITHUB_OUTPUT
- name: Login to Docker Hub
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3.4.0
with:
username: ${{ secrets.DOCKER_HUB_USERNAME }}
password: ${{ secrets.DOCKER_HUB_API }}
- name: Login to GitHub Container Registry
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3.4.0
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ github.token }}
- name: Set up QEMU
uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0
uses: docker/setup-qemu-action@29109295f81e9208d7d86ff1c6c12d2833863392 # v3.6.0
- name: Convert repository owner to lowercase
id: repoowner
@@ -88,7 +76,7 @@ jobs:
- name: Generate tags
id: meta
uses: docker/metadata-action@318604b99e75e41977312d83839a89be02ca4893 # v5.9.0
uses: docker/metadata-action@902fa8ec7d6ecbf8d84d538b9b233a880e428804 # v5.7.0
if: github.ref != 'refs/heads/main'
with:
images: |
@@ -134,7 +122,7 @@ jobs:
- name: Generate tags ultra-lite
id: meta2
uses: docker/metadata-action@318604b99e75e41977312d83839a89be02ca4893 # v5.9.0
uses: docker/metadata-action@902fa8ec7d6ecbf8d84d538b9b233a880e428804 # v5.7.0
if: github.ref != 'refs/heads/main'
with:
images: |
@@ -165,7 +153,7 @@ jobs:
- name: Generate tags fat
id: meta3
uses: docker/metadata-action@318604b99e75e41977312d83839a89be02ca4893 # v5.9.0
uses: docker/metadata-action@902fa8ec7d6ecbf8d84d538b9b233a880e428804 # v5.7.0
with:
images: |
${{ secrets.DOCKER_HUB_USERNAME }}/s-pdf
+12 -12
View File
@@ -23,19 +23,19 @@ jobs:
version: ${{ steps.versionNumber.outputs.versionNumber }}
steps:
- name: Harden Runner
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
uses: step-security/harden-runner@6c439dc8bdf85cadbbce9ed30d1c7b959517bc49 # v2.12.2
with:
egress-policy: audit
- uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Set up JDK 17
uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v5.0.0
uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # v4.7.1
with:
java-version: "17"
distribution: "temurin"
- uses: gradle/actions/setup-gradle@4d9f0ba0025fe599b4ebab900eb7f3a1d93ef4c2 # v5.0.0
- uses: gradle/actions/setup-gradle@ac638b010cf58a27ee6c972d7336334ccaf61c96 # v4.4.1
with:
gradle-version: 8.14
@@ -63,7 +63,7 @@ jobs:
ls -R ./build/launch4j
- name: Upload build artifacts
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: binaries${{ matrix.file_suffix }}
path: |
@@ -83,19 +83,19 @@ jobs:
file_suffix: ""
steps:
- name: Harden Runner
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
uses: step-security/harden-runner@6c439dc8bdf85cadbbce9ed30d1c7b959517bc49 # v2.12.2
with:
egress-policy: audit
- name: Download build artifacts
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
with:
name: binaries${{ matrix.file_suffix }}
- name: Display structure of downloaded files
run: ls -R
- name: Install Cosign
uses: sigstore/cosign-installer@faadad0cce49287aee09b3a48701e75088a2c6ad # v4.0.0
uses: sigstore/cosign-installer@398d4b0eeef1380460a10c8013a76f728fb906ac # v3.9.1
- name: Generate key pair
run: cosign generate-key-pair
@@ -139,7 +139,7 @@ jobs:
./launch4j/Stirling-PDF-Server${{ matrix.file_suffix }}.exe
- name: Upload signed artifacts
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: signed${{ matrix.file_suffix }}
path: |
@@ -161,17 +161,17 @@ jobs:
file_suffix: ""
steps:
- name: Harden Runner
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
uses: step-security/harden-runner@6c439dc8bdf85cadbbce9ed30d1c7b959517bc49 # v2.12.2
with:
egress-policy: audit
- name: Download signed artifacts
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
with:
name: signed${{ matrix.file_suffix }}
- name: Upload binaries, attestations and signatures to Release and create GitHub Release
uses: softprops/action-gh-release@5be0e66d93ac7ed76da52eca8bb058f665c3a5fe # v2.4.2
uses: softprops/action-gh-release@72f2c25fcb47643c292f7107632f7a47c1df5cd8 # v2.3.2
with:
tag_name: v${{ needs.build.outputs.version }}
generate_release_notes: true
+5 -5
View File
@@ -34,17 +34,17 @@ jobs:
steps:
- name: Harden Runner
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
uses: step-security/harden-runner@6c439dc8bdf85cadbbce9ed30d1c7b959517bc49 # v2.12.2
with:
egress-policy: audit
- name: "Checkout code"
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
persist-credentials: false
- name: "Run analysis"
uses: ossf/scorecard-action@4eaacf0543bb3f2c246792bd56e8cdeffafb205a # v2.4.3
uses: ossf/scorecard-action@05b42c624433fc40578a4040d5cf5e36ddca8cde # v2.4.2
with:
results_file: results.sarif
results_format: sarif
@@ -66,7 +66,7 @@ jobs:
# Upload the results as artifacts (optional). Commenting out will disable uploads of run results in SARIF
# format to the repository Actions tab.
- name: "Upload artifact"
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: SARIF file
path: results.sarif
@@ -74,6 +74,6 @@ jobs:
# Upload the results to GitHub's code scanning dashboard.
- name: "Upload to code-scanning"
uses: github/codeql-action/upload-sarif@fdbfb4d2750291e159f0156def62b853c2798ca2 # v3.29.5
uses: github/codeql-action/upload-sarif@181d5eefc20863364f96762470ba6f862bdef56b # v3.29.2
with:
sarif_file: results.sarif
+63
View File
@@ -0,0 +1,63 @@
name: Run Sonarqube
on:
push:
branches:
- master
pull_request_target:
branches:
- main
workflow_dispatch:
permissions:
pull-requests: read
actions: read
jobs:
sonarqube:
runs-on: ubuntu-latest
steps:
- name: Harden Runner
uses: step-security/harden-runner@6c439dc8bdf85cadbbce9ed30d1c7b959517bc49 # v2.12.2
with:
egress-policy: audit
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
fetch-depth: 0
- name: Setup Gradle
uses: gradle/actions/setup-gradle@ac638b010cf58a27ee6c972d7336334ccaf61c96 # v4.4.1
- name: Build and analyze with Gradle
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
DISABLE_ADDITIONAL_FEATURES: false
STIRLING_PDF_DESKTOP_UI: true
run: |
./gradlew clean build sonar \
-Dsonar.projectKey=Stirling-Tools_Stirling-PDF \
-Dsonar.organization=stirling-tools \
-Dsonar.host.url=https://sonarcloud.io \
-Dsonar.login=${SONAR_TOKEN} \
-Dsonar.log.level=DEBUG \
--info
- name: Upload Problems Report on Failure
if: failure()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: gradle-problems-report
path: build/reports/problems/problems-report.html
retention-days: 7
- name: Upload Sonar Logs on Failure
if: failure()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: sonar-logs
path: |
.scannerwork/report-task.txt
build/sonar/
retention-days: 7
+2 -2
View File
@@ -16,12 +16,12 @@ jobs:
pull-requests: write
steps:
- name: Harden Runner
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
uses: step-security/harden-runner@6c439dc8bdf85cadbbce9ed30d1c7b959517bc49 # v2.12.2
with:
egress-policy: audit
- name: 30 days stale issues
uses: actions/stale@5f858e3efba33a5ca4407a664cc011ad407f2008 # v10.1.0
uses: actions/stale@5bef64f19d7facfb25b37b414482c7164d639639 # v9.1.0
with:
repo-token: ${{ secrets.GITHUB_TOKEN }}
days-before-stale: 30
+5 -17
View File
@@ -6,18 +6,6 @@ on:
branches:
- master
# cancel in-progress jobs if a new job is triggered
# This is useful to avoid running multiple builds for the same branch if a new commit is pushed
# 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
@@ -26,22 +14,22 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Harden Runner
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
uses: step-security/harden-runner@6c439dc8bdf85cadbbce9ed30d1c7b959517bc49 # v2.12.2
with:
egress-policy: audit
- uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Set up JDK 17
uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v5.0.0
uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # v4.7.1
with:
java-version: "17"
distribution: "temurin"
- uses: gradle/actions/setup-gradle@4d9f0ba0025fe599b4ebab900eb7f3a1d93ef4c2 # v5.0.0
- uses: gradle/actions/setup-gradle@ac638b010cf58a27ee6c972d7336334ccaf61c96 # v4.4.1
- name: Generate Swagger documentation
run: ./gradlew :stirling-pdf:generateOpenApiDocs
run: ./gradlew generateOpenApiDocs
- name: Upload Swagger Documentation to SwaggerHub
run: ./gradlew swaggerhubUpload
+10 -28
View File
@@ -8,39 +8,23 @@ on:
paths:
- "build.gradle"
- "README.md"
- "app/core/src/main/resources/messages_*.properties"
- "app/core/src/main/resources/static/3rdPartyLicenses.json"
- "stirling-pdf/src/main/resources/messages_*.properties"
- "stirling-pdf/src/main/resources/static/3rdPartyLicenses.json"
- "scripts/ignore_translation.toml"
# 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:
sync-files:
runs-on: ubuntu-latest
env:
# Prevents sdist builds → no tar extraction
PIP_ONLY_BINARY: ":all:"
PIP_DISABLE_PIP_VERSION_CHECK: "1"
steps:
- name: Harden Runner
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
uses: step-security/harden-runner@6c439dc8bdf85cadbbce9ed30d1c7b959517bc49 # v2.12.2
with:
egress-policy: audit
- uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Setup GitHub App Bot
id: setup-bot
@@ -50,24 +34,22 @@ jobs:
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
- name: Set up Python
uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
with:
python-version: "3.12"
cache: "pip" # caching pip dependencies
- name: Sync translation property files
run: |
python .github/scripts/check_language_properties.py --reference-file "app/core/src/main/resources/messages_en_GB.properties" --branch main
python .github/scripts/check_language_properties.py --reference-file "stirling-pdf/src/main/resources/messages_en_GB.properties" --branch main
- name: Commit translation files
run: |
git add app/core/src/main/resources/messages_*.properties
git add stirling-pdf/src/main/resources/messages_*.properties
git diff --staged --quiet || git commit -m ":memo: Sync translation files" || echo "No changes detected"
- name: Install dependencies
# Wheels-only + Hash-Pinning
run: |
pip install --require-hashes --only-binary=:all: -r ./.github/scripts/requirements_sync_readme.txt
run: pip install --require-hashes -r ./.github/scripts/requirements_sync_readme.txt
- name: Sync README.md
run: |
@@ -80,7 +62,7 @@ jobs:
- name: Create Pull Request
if: always()
uses: peter-evans/create-pull-request@84ae59a2cdc2258d6fa0732dd66352dddae2a412 # v7.0.9
uses: peter-evans/create-pull-request@271a8d0340265f705b14b6d32b9829c1cb33d45e # v7.0.8
with:
token: ${{ steps.setup-bot.outputs.token }}
commit-message: Update files
@@ -119,4 +101,4 @@ jobs:
sign-commits: true
add-paths: |
README.md
app/core/src/main/resources/messages_*.properties
stirling-pdf/src/main/resources/messages_*.properties
+8 -51
View File
@@ -4,18 +4,6 @@ on:
push:
branches: ["master", "UITest", "testdriver"]
# cancel in-progress jobs if a new job is triggered
# This is useful to avoid running multiple builds for the same branch if a new commit is pushed
# or a pull request is updated.
# It helps to save resources and time by ensuring that only the latest commit is built and tested
# This is particularly useful for long-running jobs that may take a while to complete.
# The `group` is set to a combination of the workflow name, event name, and branch name.
# This ensures that jobs are grouped by the workflow and branch, allowing for cancellation of
# in-progress jobs when a new commit is pushed to the same branch or a new pull request is opened.
concurrency:
group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.ref_name || github.ref }}
cancel-in-progress: true
permissions:
contents: read
@@ -24,24 +12,19 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Harden Runner
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
uses: step-security/harden-runner@6c439dc8bdf85cadbbce9ed30d1c7b959517bc49 # v2.12.2
with:
egress-policy: audit
- name: Checkout repository
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Set up JDK
uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v5.0.0
uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # v4.7.1
with:
java-version: '17'
distribution: 'temurin'
- name: Setup Gradle
uses: gradle/actions/setup-gradle@4d9f0ba0025fe599b4ebab900eb7f3a1d93ef4c2 # v5.0.0
with:
gradle-version: 8.14
- name: Build with Gradle
run: ./gradlew clean build
env:
@@ -57,7 +40,7 @@ jobs:
echo "versionNumber=$VERSION" >> $GITHUB_OUTPUT
- name: Login to Docker Hub
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3.4.0
with:
username: ${{ secrets.DOCKER_HUB_USERNAME }}
password: ${{ secrets.DOCKER_HUB_API }}
@@ -116,47 +99,23 @@ jobs:
docker-compose up -d
EOF
files-changed:
if: always()
name: detect what files changed
runs-on: ubuntu-latest
timeout-minutes: 3
outputs:
frontend: ${{ steps.changes.outputs.frontend }}
steps:
- 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"
test:
if: needs.files-changed.outputs.frontend == 'true'
needs: [deploy, files-changed]
needs: deploy
runs-on: ubuntu-latest
steps:
- name: Harden Runner
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
uses: step-security/harden-runner@6c439dc8bdf85cadbbce9ed30d1c7b959517bc49 # v2.12.2
with:
egress-policy: audit
- uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
- name: Set up Node
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
with:
cache: 'npm'
cache-dependency-path: frontend/package-lock.json
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Run TestDriver.ai
uses: testdriverai/action@f0d0f45fdd684db628baa843fe9313f3ca3a8aa8 #1.1.3
with:
key: ${{secrets.TESTDRIVER_API_KEY}}
prerun: |
cd frontend
npm install
npm run build
npm install dashcam-chrome --save
@@ -175,7 +134,7 @@ jobs:
steps:
- name: Harden Runner
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
uses: step-security/harden-runner@6c439dc8bdf85cadbbce9ed30d1c7b959517bc49 # v2.12.2
with:
egress-policy: audit
@@ -186,7 +145,6 @@ jobs:
sudo chmod 600 ../private.key
- name: Cleanup deployment
if: always()
run: |
ssh -i ../private.key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null ${{ secrets.VPS_USERNAME }}@${{ secrets.VPS_HOST }} << EOF
cd /stirling/test-${{ github.sha }}
@@ -194,4 +152,3 @@ jobs:
cd /stirling
rm -rf test-${{ github.sha }}
EOF
continue-on-error: true # Ensure cleanup runs even if previous steps fail
+4 -10
View File
@@ -124,13 +124,10 @@ SwaggerDoc.json
*.tar.gz
*.rar
*.db
build
app/core/build
app/common/build
app/proprietary/build
common/build
proprietary/build
stirling-pdf/build
/build
/stirling-pdf/build
/common/build
/proprietary/build
# Byte-compiled / optimized / DLL files
__pycache__/
@@ -200,6 +197,3 @@ id_ed25519.pub
# node_modules
node_modules/
# weasyPrint
**/LOCAL_APPDATA_FONTCONFIG_CACHE/**
+6 -16
View File
@@ -1,15 +1,15 @@
repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.14.2
rev: v0.12.0
hooks:
- id: ruff
args:
- --fix
- --line-length=127
files: ^((\.github/scripts|scripts|app/core/src/main/resources/static/python)/.+)?[^/]+\.py$
files: ^((\.github/scripts|scripts)/.+)?[^/]+\.py$
exclude: (split_photos.py)
- id: ruff-format
files: ^((\.github/scripts|scripts|app/core/src/main/resources/static/python)/.+)?[^/]+\.py$
files: ^((\.github/scripts|scripts)/.+)?[^/]+\.py$
exclude: (split_photos.py)
- repo: https://github.com/codespell-project/codespell
rev: v2.4.1
@@ -20,13 +20,13 @@ repos:
- --skip="./.*,*.csv,*.json,*.ambr"
- --quiet-level=2
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)
exclude: (.vscode|.devcontainer|stirling-pdf/src/main/resources|Dockerfile|.*/pdfjs.*|.*/thirdParty.*|bootstrap.*|.*\.min\..*|.*diff\.js)
- repo: https://github.com/gitleaks/gitleaks
rev: v8.28.0
rev: v8.27.2
hooks:
- id: gitleaks
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v6.0.0
rev: v5.0.0
hooks:
- id: end-of-file-fixer
files: ^.*(\.js|\.java|\.py|\.yml)$
@@ -34,13 +34,3 @@ repos:
- id: trailing-whitespace
files: ^.*(\.js|\.java|\.py|\.yml)$
exclude: ^(.*/pdfjs.*|.*/thirdParty.*|bootstrap.*|.*\.min\..*|.*diff\.js|\.github/workflows/.*$)
# - repo: https://github.com/thibaudcolas/pre-commit-stylelint
# rev: v16.21.1
# hooks:
# - id: stylelint
# additional_dependencies:
# - stylelint@16.21.1
# - stylelint-config-standard@38.0.0
# - "@stylistic/stylelint-plugin@3.1.3"
# files: \.(css)$
# args: [--fix]
-2
View File
@@ -17,7 +17,5 @@
"GitHub.vscode-pull-request-github", // GitHub Pull Requests extension for Visual Studio Code
"charliermarsh.ruff", // Ruff code formatter for Python to follow the Ruff Style Guide
"yzhang.markdown-all-in-one", // Markdown All-in-One extension for enhanced Markdown editing
"stylelint.vscode-stylelint", // Stylelint extension for CSS and SCSS linting
"redhat.vscode-yaml", // YAML extension for Visual Studio Code
]
}
+10 -26
View File
@@ -2,20 +2,13 @@
"editor.wordSegmenterLocales": "",
"editor.guides.bracketPairs": "active",
"editor.guides.bracketPairsHorizontal": "active",
"editor.defaultFormatter": "EditorConfig.EditorConfig",
"cSpell.enabled": false,
"[feature]": {
"editor.defaultFormatter": "alexkrechik.cucumberautocomplete"
},
"[java]": {
"editor.defaultFormatter": "josevseb.google-java-format-for-vs-code"
},
"[jsonc]": {
"editor.defaultFormatter": "vscode.json-language-features"
},
"[css]": {
"editor.defaultFormatter": "stylelint.vscode-stylelint"
},
"[json]": {
"editor.defaultFormatter": "vscode.json-language-features"
},
@@ -34,14 +27,11 @@
"[gradle]": {
"editor.defaultFormatter": "vscjava.vscode-gradle"
},
"[yaml]": {
"editor.defaultFormatter": "redhat.vscode-yaml"
},
"java.compile.nullAnalysis.mode": "automatic",
"java.configuration.updateBuildConfiguration": "interactive",
"java.format.enabled": true,
"java.format.settings.profile": "GoogleStyle",
"java.format.settings.google.version": "1.28.0",
"java.format.settings.google.version": "1.27.0",
"java.format.settings.google.extra": "--aosp --skip-sorting-imports --skip-javadoc-formatting",
// (DE) Aktiviert Kommentare im Java-Format.
// (EN) Enables comments in Java formatting.
@@ -80,17 +70,13 @@
".venv*/",
".vscode/",
"bin/",
"app/core/bin/",
"app/common/bin/",
"app/proprietary/bin/",
"common/bin/",
"proprietary/bin/",
"build/",
"app/core/build/",
"app/common/build/",
"app/proprietary/build/",
"common/build/",
"proprietary/build/",
"configs/",
"app/core/configs/",
"customFiles/",
"app/core/customFiles/",
"docs/",
"exampleYmlFiles",
"gradle/",
@@ -102,9 +88,8 @@
".git-blame-ignore-revs",
".gitattributes",
".gitignore",
"app/core/.gitignore",
"app/common/.gitignore",
"app/proprietary/.gitignore",
"common/.gitignore",
"proprietary/.gitignore",
".pre-commit-config.yaml",
],
// Enables signature help in Java.
@@ -134,10 +119,9 @@
"html.format.indentHandlebars": true,
"html.format.preserveNewLines": true,
"html.format.maxPreserveNewLines": 2,
"stylelint.configFile": "devTools/.stylelintrc.json",
"java.project.sourcePaths": [
"app/core/src/main/java",
"app/common/src/main/java",
"app/proprietary/src/main/java"
"stirling-pdf/src/main/java",
"common/src/main/java",
"proprietary/src/main/java"
]
}
+1 -1
View File
@@ -5,7 +5,7 @@
The newly introduced feature enhances the application with robust database backup and import capabilities. This feature is designed to ensure data integrity and provide a straightforward way to manage database backups. Here's how it works:
1. Automatic Backup Creation
- The system automatically creates a database backup on a configurable schedule (default: daily at midnight via `system.databaseBackup.cron`). This ensures that there is always a recent backup available, minimizing the risk of data loss.
- The system automatically creates a database backup every day at midnight. This ensures that there is always a recent backup available, minimizing the risk of data loss.
2. Manual Backup Export
- Admin actions that modify the user database trigger a manual export of the database. This keeps the backup up-to-date with the latest changes and provides an extra layer of data security.
3. Importing Database Backups
+72 -133
View File
@@ -1,88 +1,12 @@
# ==============================================================================
# Multi-stage Dockerfile for Stirling-PDF image with everything included
# Includes: LibreOffice, Calibre, Tesseract, OCRmyPDF, unoserver, WeasyPrint, etc.
# ==============================================================================
# Main stage
FROM alpine:3.22.0@sha256:8a1f59ffb675680d47db6337b49d22281a139e9d709335b492be023728e11715
# ========================================
# STAGE 1: Runtime image based on Debian stable-slim
# Contains Java runtime + LibreOffice + Calibre + all PDF tools
# ========================================
FROM debian:stable-slim@sha256:7cb087f19bcc175b96fbe4c2aef42ed00733a659581a80f6ebccfd8fe3185a3d
# Copy necessary files
COPY scripts /scripts
COPY pipeline /pipeline
COPY stirling-pdf/src/main/resources/static/fonts/*.ttf /usr/share/fonts/opentype/noto/
COPY stirling-pdf/build/libs/*.jar app.jar
SHELL ["/bin/bash", "-o", "pipefail", "-c"]
ENV DEBIAN_FRONTEND=noninteractive
ENV TESS_BASE_PATH=/usr/share/tesseract-ocr/5/tessdata
# Install core runtime dependencies + tools required by Stirling-PDF features
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates tzdata tini bash fontconfig \
openjdk-21-jre-headless \
ffmpeg poppler-utils ocrmypdf \
libreoffice-nogui libreoffice-java-common \
python3 python3-venv python3-uno \
tesseract-ocr tesseract-ocr-eng tesseract-ocr-deu tesseract-ocr-fra \
tesseract-ocr-por tesseract-ocr-chi-sim \
libcairo2 libpango-1.0-0 libpangoft2-1.0-0 libgdk-pixbuf-2.0-0 \
gosu unpaper \
# AWT headless support (required for some Java graphics operations)
libfreetype6 libfontconfig1 libx11-6 libxt6 libxext6 libxrender1 libxtst6 libxi6 \
libxinerama1 libxkbcommon0 libxkbfile1 libsm6 libice6 \
# Qt WebEngine dependencies for Calibre
libegl1 libopengl0 libgl1 libxdamage1 libxfixes3 libxshmfence1 libdrm2 libgbm1 \
libxkbcommon-x11-0 libxrandr2 libxcomposite1 libnss3 libx11-xcb1 \
libxcb-cursor0 libdbus-1-3 libglib2.0-0 \
# Virtual framebuffer (required for headless LibreOffice)
xvfb x11-utils coreutils \
# Temporary packages only needed for Calibre installer
xz-utils gpgv curl xdg-utils \
\
# Install Calibre from official installer script
&& curl -fsSL https://download.calibre-ebook.com/linux-installer.sh | sh /dev/stdin \
\
# Clean up installer-only packages
&& apt-get purge -y xz-utils gpgv xdg-utils \
&& apt-get autoremove -y \
&& rm -rf /var/lib/apt/lists/*
# Make ebook-convert available in PATH
RUN ln -sf /opt/calibre/ebook-convert /usr/bin/ebook-convert \
&& /opt/calibre/ebook-convert --version
# ==============================================================================
# Create non-root user (stirlingpdfuser) with configurable UID/GID
# ==============================================================================
ARG PUID=1000
ARG PGID=1000
RUN set -eux; \
# Create group if it doesn't exist
if ! getent group stirlingpdfgroup >/dev/null 2>&1; then \
if getent group "${PGID}" >/dev/null 2>&1; then \
groupadd -o -g "${PGID}" stirlingpdfgroup; \
else \
groupadd -g "${PGID}" stirlingpdfgroup; \
fi; \
fi; \
# Create user if it doesn't exist, avoid UID conflicts
if ! id -u stirlingpdfuser >/dev/null 2>&1; then \
if getent passwd | awk -F: -v id="${PUID}" '$3==id{found=1} END{exit !found}'; then \
echo "UID ${PUID} already in use creating stirlingpdfuser with automatic UID"; \
useradd -m -g stirlingpdfgroup -d /home/stirlingpdfuser -s /bin/bash stirlingpdfuser; \
else \
useradd -m -u "${PUID}" -g stirlingpdfgroup -d /home/stirlingpdfuser -s /bin/bash stirlingpdfuser; \
fi; \
fi
# Compatibility alias for older entrypoint scripts expecting su-exec
RUN ln -sf /usr/sbin/gosu /usr/local/bin/su-exec
# Copy application files from build stage
COPY scripts/ /scripts/
COPY app/core/src/main/resources/static/fonts/*.ttf /usr/share/fonts/truetype/
COPY app/core/build/libs/*.jar app.jar
# Optional version tag (can be passed at build time)
ARG VERSION_TAG
LABEL org.opencontainers.image.title="Stirling-PDF"
@@ -97,68 +21,83 @@ LABEL org.opencontainers.image.authors="Stirling-Tools"
LABEL org.opencontainers.image.version="${VERSION_TAG}"
LABEL org.opencontainers.image.keywords="PDF, manipulation, merge, split, convert, OCR, watermark"
# ==============================================================================
# Runtime environment variables
# ==============================================================================
# Set Environment Variables
ENV DISABLE_ADDITIONAL_FEATURES=true \
JAVA_BASE_OPTS="-XX:+UnlockExperimentalVMOptions -XX:MaxRAMPercentage=75 -XX:InitiatingHeapOccupancyPercent=20 \
-XX:+G1PeriodicGCInvokesConcurrent -XX:G1PeriodicGCInterval=10000 \
-XX:+UseStringDeduplication -XX:G1PeriodicGCSystemLoadThreshold=70 \
-Djava.awt.headless=true" \
VERSION_TAG=$VERSION_TAG \
JAVA_BASE_OPTS="-XX:+UnlockExperimentalVMOptions -XX:MaxRAMPercentage=75 -XX:InitiatingHeapOccupancyPercent=20 -XX:+G1PeriodicGCInvokesConcurrent -XX:G1PeriodicGCInterval=10000 -XX:+UseStringDeduplication -XX:G1PeriodicGCSystemLoadThreshold=70" \
JAVA_CUSTOM_OPTS="" \
HOME=/home/stirlingpdfuser \
PUID=${PUID} \
PGID=${PGID} \
PUID=1000 \
PGID=1000 \
UMASK=022 \
PYTHONPATH=/usr/lib/libreoffice/program:/opt/venv/lib/python3.12/site-packages \
UNO_PATH=/usr/lib/libreoffice/program \
URE_BOOTSTRAP=file:///usr/lib/libreoffice/program/fundamentalrc \
PATH=$PATH:/opt/venv/bin \
STIRLING_TEMPFILES_DIRECTORY=/tmp/stirling-pdf \
TMPDIR=/tmp/stirling-pdf \
TEMP=/tmp/stirling-pdf \
TMP=/tmp/stirling-pdf
# ==============================================================================
# Python virtual environment for additional Python tools (WeasyPrint, OpenCV, etc.)
# ==============================================================================
RUN python3 -m venv /opt/venv --system-site-packages \
&& /opt/venv/bin/pip install --no-cache-dir weasyprint pdf2image opencv-python-headless \
&& /opt/venv/bin/python -c "import cv2; print('OpenCV version:', cv2.__version__)"
# Separate venv for unoserver (keeps it isolated)
RUN python3 -m venv /opt/unoserver-venv --system-site-packages \
&& /opt/unoserver-venv/bin/pip install --no-cache-dir unoserver
# JDK for app
RUN echo "@main https://dl-cdn.alpinelinux.org/alpine/edge/main" | tee -a /etc/apk/repositories && \
echo "@community https://dl-cdn.alpinelinux.org/alpine/edge/community" | tee -a /etc/apk/repositories && \
echo "@testing https://dl-cdn.alpinelinux.org/alpine/edge/testing" | tee -a /etc/apk/repositories && \
apk upgrade --no-cache -a && \
apk add --no-cache \
ca-certificates \
tzdata \
tini \
bash \
curl \
shadow \
su-exec \
openssl \
openssl-dev \
openjdk21-jre \
# Doc conversion
gcompat \
libc6-compat \
libreoffice \
# pdftohtml
poppler-utils \
# OCR MY PDF (unpaper for descew and other advanced features)
tesseract-ocr-data-eng \
tesseract-ocr-data-chi_sim \
tesseract-ocr-data-deu \
tesseract-ocr-data-fra \
tesseract-ocr-data-por \
unpaper \
# CV
py3-opencv \
python3 \
ocrmypdf \
py3-pip \
py3-pillow@testing \
py3-pdf2image@testing \
# URW Base 35 fonts for better PDF rendering
font-urw-base35 && \
python3 -m venv /opt/venv && \
/opt/venv/bin/pip install --upgrade pip setuptools && \
/opt/venv/bin/pip install --no-cache-dir --upgrade unoserver weasyprint && \
ln -s /usr/lib/libreoffice/program/uno.py /opt/venv/lib/python3.12/site-packages/ && \
ln -s /usr/lib/libreoffice/program/unohelper.py /opt/venv/lib/python3.12/site-packages/ && \
ln -s /usr/lib/libreoffice/program /opt/venv/lib/python3.12/site-packages/LibreOffice && \
mv /usr/share/tessdata /usr/share/tessdata-original && \
mkdir -p $HOME /configs /logs /customFiles /pipeline/watchedFolders /pipeline/finishedFolders /tmp/stirling-pdf && \
# Configure URW Base 35 fonts
ln -s /usr/share/fontconfig/conf.avail/69-urw-*.conf /etc/fonts/conf.d/ && \
fc-cache -f -v && \
chmod +x /scripts/* && \
chmod +x /scripts/init.sh && \
# User permissions
addgroup -S stirlingpdfgroup && adduser -S stirlingpdfuser -G stirlingpdfgroup && \
chown -R stirlingpdfuser:stirlingpdfgroup $HOME /scripts /usr/share/fonts/opentype/noto /configs /customFiles /pipeline /tmp/stirling-pdf && \
chown stirlingpdfuser:stirlingpdfgroup /app.jar
# Make unoserver tools available in main venv PATH
RUN ln -sf /opt/unoserver-venv/bin/unoconvert /opt/venv/bin/unoconvert \
&& ln -sf /opt/unoserver-venv/bin/unoserver /opt/venv/bin/unoserver
# Extend PATH to include both virtual environments
ENV PATH="/opt/venv/bin:/opt/unoserver-venv/bin:${PATH}"
# ==============================================================================
# Final permissions, directories and font cache
# ==============================================================================
RUN set -eux; \
chmod +x /scripts/*; \
mkdir -p /configs /logs /customFiles /pipeline/watchedFolders /pipeline/finishedFolders /tmp/stirling-pdf; \
chown -R stirlingpdfuser:stirlingpdfgroup \
/home/stirlingpdfuser /configs /logs /customFiles /pipeline /tmp/stirling-pdf \
/app.jar /usr/share/fonts/truetype /scripts; \
chmod -R 755 /tmp/stirling-pdf
# Rebuild font cache
RUN fc-cache -f -v
# Force Qt/WebEngine to run headlessly (required for Calibre in Docker)
ENV QT_QPA_PLATFORM=offscreen \
QTWEBENGINE_CHROMIUM_FLAGS="--disable-gpu --disable-dev-shm-usage"
# Expose web UI port
EXPOSE 8080/tcp
STOPSIGNAL SIGTERM
# Use tini as init (handles signals and zombies correctly)
# Set user and run command
ENTRYPOINT ["tini", "--", "/scripts/init.sh"]
# CMD is empty actual start command is defined in init.sh
CMD []
CMD ["sh", "-c", "java -Dfile.encoding=UTF-8 -Djava.io.tmpdir=/tmp/stirling-pdf -jar /app.jar & /opt/venv/bin/unoserver --port 2003 --interface 127.0.0.1"]
+4 -10
View File
@@ -19,18 +19,11 @@ RUN apt-get update && apt-get install -y \
# settings.yml | tessdataDir: /usr/share/tesseract-ocr/5/tessdata
tesseract-ocr \
tesseract-ocr-eng \
ffmpeg \
fonts-terminus fonts-dejavu fonts-font-awesome fonts-noto fonts-noto-core fonts-noto-cjk fonts-noto-extra fonts-liberation fonts-linuxlibertine fonts-urw-base35 \
python3-uno \
python3-venv \
# ss -tln
iproute2 \
# calibre requires these dependencies
wget \
xz-utils \
libopengl0 \
libxcb-cursor0 \
&& wget -nv -O- https://download.calibre-ebook.com/linux-installer.sh | sh /dev/stdin \
&& apt-get clean && rm -rf /var/lib/apt/lists/*
# Setze die Environment Variable für setuptools
@@ -41,10 +34,10 @@ ENV SETUPTOOLS_USE_DISTUTILS=local \
TMP=/tmp/stirling-pdf
# Installation der benötigten Python-Pakete
COPY .github/scripts/requirements_dev.txt /tmp/requirements_dev.txt
RUN python3 -m venv --system-site-packages /opt/venv \
&& . /opt/venv/bin/activate \
&& pip install --no-cache-dir --only-binary=:all: --require-hashes -r /tmp/requirements_dev.txt
&& pip install --upgrade pip setuptools \
&& pip install --no-cache-dir WeasyPrint pdf2image pillow unoserver opencv-python-headless pre-commit
# Füge den venv-Pfad zur globalen PATH-Variable hinzu, damit die Tools verfügbar sind
ENV PATH="/opt/venv/bin:$PATH"
@@ -61,7 +54,8 @@ RUN echo "devuser ALL=(ALL) NOPASSWD:ALL" > /etc/sudoers.d/devuser \
# Setze das Arbeitsverzeichnis (wird später per Bind-Mount überschrieben)
WORKDIR /workspace
RUN chmod +x /workspace/.devcontainer/git-init.sh /workspace/.devcontainer/init-setup.sh
RUN chmod +x /workspace/.devcontainer/git-init.sh
RUN sudo chmod +x /workspace/.devcontainer/init-setup.sh
# Wechsel zum NichtRoot Benutzer
USER devuser
+88 -183
View File
@@ -1,209 +1,114 @@
# ==============================================================================
# Multi-stage Dockerfile for Stirling-PDF "fat" image with everything included
# Includes: LibreOffice, Calibre, Tesseract, OCRmyPDF, unoserver, WeasyPrint, etc.
# ==============================================================================
# Build the application
FROM gradle:8.14-jdk21 AS build
# ========================================
# STAGE 1: Build Stirling-PDF with Gradle (Alpine)
# ========================================
FROM eclipse-temurin:21-jdk-alpine@sha256:c4799f335a65b1ecca8a31239b05522f2b0a184d6818f6349e83484ee6956198 AS build
COPY build.gradle .
COPY settings.gradle .
COPY gradlew .
COPY gradle gradle/
COPY stirling-pdf/build.gradle stirling-pdf/.
COPY common/build.gradle common/.
COPY proprietary/build.gradle proprietary/.
RUN ./gradlew build -x spotlessApply -x spotlessCheck -x test -x sonarqube || return 0
# Install build tools
RUN apk add --no-cache bash unzip curl git
WORKDIR /workspace
# Copy Gradle wrapper and configuration files
COPY build.gradle settings.gradle gradlew ./
COPY gradle ./gradle/
# Make gradlew executable
RUN chmod +x gradlew
# Create module directories and copy module build files (for Gradle layer caching)
RUN mkdir -p core common proprietary
COPY app/core/build.gradle core/.
COPY app/common/build.gradle common/.
COPY app/proprietary/build.gradle proprietary/.
# Warm-up Gradle dependency cache (optional but improves subsequent builds)
RUN ./gradlew --no-daemon printVersion --quiet | tail -1 > /tmp/version_tag || true
RUN ./gradlew --no-daemon build -x spotlessApply -x spotlessCheck -x test -x sonarqube || true
# Switch to final source directory and copy full source code
# Set the working directory
WORKDIR /app
# Copy the entire project to the working directory
COPY . .
# Environment variables (can be overridden at build time)
ENV DISABLE_ADDITIONAL_FEATURES=false \
STIRLING_PDF_DESKTOP_UI=false
# Build the application with DISABLE_ADDITIONAL_FEATURES=false
RUN DISABLE_ADDITIONAL_FEATURES=false \
STIRLING_PDF_DESKTOP_UI=false \
./gradlew clean build -x spotlessApply -x spotlessCheck -x test -x sonarqube
# Final build produce the fat JAR
RUN ./gradlew --no-daemon clean build \
-x spotlessApply -x spotlessCheck -x test -x sonarqube \
&& apk del bash unzip curl git
# Main stage
FROM alpine:3.22.0@sha256:8a1f59ffb675680d47db6337b49d22281a139e9d709335b492be023728e11715
# Copy necessary files
COPY scripts /scripts
COPY pipeline /pipeline
COPY stirling-pdf/src/main/resources/static/fonts/*.ttf /usr/share/fonts/opentype/noto/
COPY --from=build /app/stirling-pdf/build/libs/*.jar app.jar
# ========================================
# STAGE 2: Runtime image based on Debian stable-slim
# Contains Java runtime + LibreOffice + Calibre + all PDF tools
# ========================================
FROM debian:stable-slim@sha256:7cb087f19bcc175b96fbe4c2aef42ed00733a659581a80f6ebccfd8fe3185a3d
SHELL ["/bin/bash", "-o", "pipefail", "-c"]
ENV DEBIAN_FRONTEND=noninteractive
# Install core runtime dependencies + tools required by Stirling-PDF features
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates tzdata tini bash fontconfig \
openjdk-21-jre-headless \
ffmpeg poppler-utils qpdf ghostscript ocrmypdf \
libreoffice-nogui libreoffice-java-common \
python3 python3-venv python3-uno \
tesseract-ocr tesseract-ocr-eng tesseract-ocr-deu tesseract-ocr-fra \
tesseract-ocr-por tesseract-ocr-chi-sim \
libcairo2 libpango-1.0-0 libpangoft2-1.0-0 libgdk-pixbuf-2.0-0 \
gosu unpaper \
# AWT headless support (required for some Java graphics operations)
libfreetype6 libfontconfig1 libx11-6 libxt6 libxext6 libxrender1 libxtst6 libxi6 \
libxinerama1 libxkbcommon0 libxkbfile1 libsm6 libice6 \
# Qt WebEngine dependencies for Calibre
libegl1 libopengl0 libgl1 libxdamage1 libxfixes3 libxshmfence1 libdrm2 libgbm1 \
libxkbcommon-x11-0 libxrandr2 libxcomposite1 libnss3 libx11-xcb1 \
libxcb-cursor0 libdbus-1-3 libglib2.0-0 \
# Virtual framebuffer (required for headless LibreOffice)
xvfb x11-utils coreutils \
# Temporary packages only needed for Calibre installer
xz-utils gpgv curl xdg-utils \
\
# Install Calibre from official installer script
&& curl -fsSL https://download.calibre-ebook.com/linux-installer.sh | sh /dev/stdin \
\
# Clean up installer-only packages
&& apt-get purge -y xz-utils gpgv xdg-utils \
&& apt-get autoremove -y \
&& rm -rf /var/lib/apt/lists/*
# Make ebook-convert available in PATH
RUN ln -sf /opt/calibre/ebook-convert /usr/bin/ebook-convert \
&& /opt/calibre/ebook-convert --version
# ==============================================================================
# Create non-root user (stirlingpdfuser) with configurable UID/GID
# ==============================================================================
ARG PUID=1000
ARG PGID=1000
RUN set -eux; \
# Create group if it doesn't exist
if ! getent group stirlingpdfgroup >/dev/null 2>&1; then \
if getent group "${PGID}" >/dev/null 2>&1; then \
groupadd -o -g "${PGID}" stirlingpdfgroup; \
else \
groupadd -g "${PGID}" stirlingpdfgroup; \
fi; \
fi; \
# Create user if it doesn't exist, avoid UID conflicts
if ! id -u stirlingpdfuser >/dev/null 2>&1; then \
if getent passwd | awk -F: -v id="${PUID}" '$3==id{found=1} END{exit !found}'; then \
echo "UID ${PUID} already in use creating stirlingpdfuser with automatic UID"; \
useradd -m -g stirlingpdfgroup -d /home/stirlingpdfuser -s /bin/bash stirlingpdfuser; \
else \
useradd -m -u "${PUID}" -g stirlingpdfgroup -d /home/stirlingpdfuser -s /bin/bash stirlingpdfuser; \
fi; \
fi
# Compatibility alias for older entrypoint scripts expecting su-exec
RUN ln -sf /usr/sbin/gosu /usr/local/bin/su-exec
# Copy application files from build stage
COPY scripts/ /scripts/
COPY app/core/src/main/resources/static/fonts/*.ttf /usr/share/fonts/truetype/
COPY --from=build /app/app/core/build/libs/*.jar /app.jar
# Copy version tag generated during build
COPY --from=build /tmp/version_tag /etc/stirling_version
# Optional version tag (can be passed at build time)
ARG VERSION_TAG
# Metadata labels
LABEL org.opencontainers.image.title="Stirling-PDF"
LABEL org.opencontainers.image.description="A powerful locally hosted web-based PDF manipulation tool supporting 50+ operations including merging, splitting, conversion, OCR, watermarking, and more."
LABEL org.opencontainers.image.source="https://github.com/Stirling-Tools/Stirling-PDF"
LABEL org.opencontainers.image.licenses="MIT"
LABEL org.opencontainers.image.vendor="Stirling-Tools"
LABEL org.opencontainers.image.url="https://www.stirlingpdf.com"
LABEL org.opencontainers.image.documentation="https://docs.stirlingpdf.com"
LABEL maintainer="Stirling-Tools"
LABEL org.opencontainers.image.authors="Stirling-Tools"
LABEL org.opencontainers.image.version="${VERSION_TAG}"
LABEL org.opencontainers.image.keywords="PDF, manipulation, merge, split, convert, OCR, watermark"
# ==============================================================================
# Runtime environment variables
# ==============================================================================
# Set Environment Variables
ENV DISABLE_ADDITIONAL_FEATURES=true \
JAVA_BASE_OPTS="-XX:+UnlockExperimentalVMOptions -XX:MaxRAMPercentage=75 -XX:InitiatingHeapOccupancyPercent=20 \
-XX:+G1PeriodicGCInvokesConcurrent -XX:G1PeriodicGCInterval=10000 \
-XX:+UseStringDeduplication -XX:G1PeriodicGCSystemLoadThreshold=70 \
-Djava.awt.headless=true" \
VERSION_TAG=$VERSION_TAG \
JAVA_BASE_OPTS="-XX:+UnlockExperimentalVMOptions -XX:MaxRAMPercentage=75 -XX:InitiatingHeapOccupancyPercent=20 -XX:+G1PeriodicGCInvokesConcurrent -XX:G1PeriodicGCInterval=10000 -XX:+UseStringDeduplication -XX:G1PeriodicGCSystemLoadThreshold=70" \
JAVA_CUSTOM_OPTS="" \
HOME=/home/stirlingpdfuser \
PUID=${PUID} \
PGID=${PGID} \
PUID=1000 \
PGID=1000 \
UMASK=022 \
FAT_DOCKER=true \
INSTALL_BOOK_AND_ADVANCED_HTML_OPS=false \
PYTHONPATH=/usr/lib/libreoffice/program:/opt/venv/lib/python3.12/site-packages \
UNO_PATH=/usr/lib/libreoffice/program \
URE_BOOTSTRAP=file:///usr/lib/libreoffice/program/fundamentalrc \
PATH=$PATH:/opt/venv/bin \
STIRLING_TEMPFILES_DIRECTORY=/tmp/stirling-pdf \
TMPDIR=/tmp/stirling-pdf \
TEMP=/tmp/stirling-pdf \
TMP=/tmp/stirling-pdf
# ==============================================================================
# Python virtual environment for additional Python tools (WeasyPrint, OpenCV, etc.)
# ==============================================================================
RUN python3 -m venv /opt/venv --system-site-packages \
&& /opt/venv/bin/pip install --no-cache-dir weasyprint pdf2image opencv-python-headless \
&& /opt/venv/bin/python -c "import cv2; print('OpenCV version:', cv2.__version__)"
# Separate venv for unoserver (keeps it isolated)
RUN python3 -m venv /opt/unoserver-venv --system-site-packages \
&& /opt/unoserver-venv/bin/pip install --no-cache-dir unoserver
# JDK for app
RUN echo "@main https://dl-cdn.alpinelinux.org/alpine/edge/main" | tee -a /etc/apk/repositories && \
echo "@community https://dl-cdn.alpinelinux.org/alpine/edge/community" | tee -a /etc/apk/repositories && \
echo "@testing https://dl-cdn.alpinelinux.org/alpine/edge/testing" | tee -a /etc/apk/repositories && \
apk upgrade --no-cache -a && \
apk add --no-cache \
ca-certificates \
tzdata \
tini \
bash \
curl \
shadow \
su-exec \
openssl \
openssl-dev \
openjdk21-jre \
# Doc conversion
gcompat \
libc6-compat \
libreoffice \
# pdftohtml
poppler-utils \
# OCR MY PDF (unpaper for descew and other advanced featues)
tesseract-ocr-data-eng \
tesseract-ocr-data-chi_sim \
tesseract-ocr-data-deu \
tesseract-ocr-data-fra \
tesseract-ocr-data-por \
unpaper \
font-terminus font-dejavu font-noto font-noto-cjk font-awesome font-noto-extra font-liberation font-linux-libertine font-urw-base35 \
# CV
py3-opencv \
python3 \
ocrmypdf \
py3-pip \
py3-pillow@testing \
py3-pdf2image@testing && \
python3 -m venv /opt/venv && \
/opt/venv/bin/pip install --upgrade pip setuptools && \
/opt/venv/bin/pip install --no-cache-dir --upgrade unoserver weasyprint && \
ln -s /usr/lib/libreoffice/program/uno.py /opt/venv/lib/python3.12/site-packages/ && \
ln -s /usr/lib/libreoffice/program/unohelper.py /opt/venv/lib/python3.12/site-packages/ && \
ln -s /usr/lib/libreoffice/program /opt/venv/lib/python3.12/site-packages/LibreOffice && \
mv /usr/share/tessdata /usr/share/tessdata-original && \
mkdir -p $HOME /configs /logs /customFiles /pipeline/watchedFolders /pipeline/finishedFolders /tmp/stirling-pdf && \
# Configure URW Base 35 fonts
ln -s /usr/share/fontconfig/conf.avail/69-urw-*.conf /etc/fonts/conf.d/ && \
fc-cache -f -v && \
chmod +x /scripts/* && \
chmod +x /scripts/init.sh && \
# User permissions
addgroup -S stirlingpdfgroup && adduser -S stirlingpdfuser -G stirlingpdfgroup && \
chown -R stirlingpdfuser:stirlingpdfgroup $HOME /scripts /usr/share/fonts/opentype/noto /configs /customFiles /pipeline /tmp/stirling-pdf && \
chown stirlingpdfuser:stirlingpdfgroup /app.jar
# Make unoserver tools available in main venv PATH
RUN ln -sf /opt/unoserver-venv/bin/unoconvert /opt/venv/bin/unoconvert \
&& ln -sf /opt/unoserver-venv/bin/unoserver /opt/venv/bin/unoserver
# Extend PATH to include both virtual environments
ENV PATH="/opt/venv/bin:/opt/unoserver-venv/bin:${PATH}"
# ==============================================================================
# Final permissions, directories and font cache
# ==============================================================================
RUN set -eux; \
chmod +x /scripts/*; \
mkdir -p /configs /logs /customFiles /pipeline/watchedFolders /pipeline/finishedFolders /tmp/stirling-pdf; \
chown -R stirlingpdfuser:stirlingpdfgroup \
/home/stirlingpdfuser /configs /logs /customFiles /pipeline /tmp/stirling-pdf \
/app.jar /usr/share/fonts/truetype /scripts; \
chmod -R 755 /tmp/stirling-pdf
# Rebuild font cache
RUN fc-cache -f -v
# Force Qt/WebEngine to run headlessly (required for Calibre in Docker)
ENV QT_QPA_PLATFORM=offscreen \
QTWEBENGINE_CHROMIUM_FLAGS="--disable-gpu --disable-dev-shm-usage"
# Expose web UI port
EXPOSE 8080/tcp
STOPSIGNAL SIGTERM
# Use tini as init (handles signals and zombies correctly)
# Set user and run command
ENTRYPOINT ["tini", "--", "/scripts/init.sh"]
# CMD is empty actual start command is defined in init.sh
CMD []
CMD ["sh", "-c", "java -Dfile.encoding=UTF-8 -Djava.io.tmpdir=/tmp/stirling-pdf -jar /app.jar & /opt/venv/bin/unoserver --port 2003 --interface 127.0.0.1"]
+10 -14
View File
@@ -1,5 +1,5 @@
# use alpine
FROM alpine:3.22.2@sha256:4b7ce07002c69e8f3d704a9c5d6fd3053be500b7f1c69fc0d80990c2ad8dd412
FROM alpine:3.22.0@sha256:8a1f59ffb675680d47db6337b49d22281a139e9d709335b492be023728e11715
ARG VERSION_TAG
@@ -21,16 +21,13 @@ ENV DISABLE_ADDITIONAL_FEATURES=true \
COPY scripts/download-security-jar.sh /scripts/download-security-jar.sh
COPY scripts/init-without-ocr.sh /scripts/init-without-ocr.sh
COPY scripts/installFonts.sh /scripts/installFonts.sh
COPY app/core/build/libs/*.jar app.jar
COPY pipeline /pipeline
COPY stirling-pdf/build/libs/*.jar app.jar
# Set up necessary directories and permissions
RUN apk add --no-cache bash \
&& ln -sf /bin/bash /bin/sh \
&& printf '%s\n' \
'https://dl-cdn.alpinelinux.org/alpine/edge/main' \
'https://dl-cdn.alpinelinux.org/alpine/edge/community' \
'https://dl-cdn.alpinelinux.org/alpine/edge/testing' \
> /etc/apk/repositories && \
RUN echo "@testing https://dl-cdn.alpinelinux.org/alpine/edge/main" | tee -a /etc/apk/repositories && \
echo "@testing https://dl-cdn.alpinelinux.org/alpine/edge/community" | tee -a /etc/apk/repositories && \
echo "@testing https://dl-cdn.alpinelinux.org/alpine/edge/testing" | tee -a /etc/apk/repositories && \
apk upgrade --no-cache -a && \
apk add --no-cache \
ca-certificates \
@@ -42,12 +39,11 @@ RUN apk add --no-cache bash \
su-exec \
openjdk21-jre && \
# User permissions
mkdir -p /configs /logs /customFiles /usr/share/fonts/opentype/noto /tmp/stirling-pdf /pipeline/watchedFolders /pipeline/finishedFolders && \
mkdir -p /configs /logs /customFiles /usr/share/fonts/opentype/noto /tmp/stirling-pdf && \
chmod +x /scripts/*.sh && \
addgroup -S stirlingpdfgroup && adduser -S stirlingpdfuser -G stirlingpdfgroup && \
chown -R stirlingpdfuser:stirlingpdfgroup $HOME /scripts /pipeline /configs /customFiles /tmp/stirling-pdf && \
chown stirlingpdfuser:stirlingpdfgroup /app.jar && \
ln -sf /bin/busybox /bin/sh
chown -R stirlingpdfuser:stirlingpdfgroup $HOME /scripts /configs /customFiles /pipeline /tmp/stirling-pdf && \
chown stirlingpdfuser:stirlingpdfgroup /app.jar
# Set environment variables
ENV ENDPOINTS_GROUPS_TO_REMOVE=CLI
@@ -56,4 +52,4 @@ EXPOSE 8080/tcp
# Run the application
ENTRYPOINT ["tini", "--", "/scripts/init-without-ocr.sh"]
CMD []
CMD ["java", "-Dfile.encoding=UTF-8", "-Djava.io.tmpdir=/tmp/stirling-pdf", "-jar", "/app.jar"]
+2 -2
View File
@@ -4,8 +4,8 @@ Copyright (c) 2025 Stirling PDF Inc.
Portions of this software are licensed as follows:
* All content that resides under the "app/proprietary/" directory of this repository,
if that directory exists, is licensed under the license defined in "app/proprietary/LICENSE".
* All content that resides under the "proprietary/" directory of this repository,
if that directory exists, is licensed under the license defined in "proprietary/LICENSE".
* Content outside of the above mentioned directories or restrictions above is
available under the MIT License as defined below.
+103 -125
View File
@@ -21,104 +21,82 @@ All documentation available at [https://docs.stirlingpdf.com/](https://docs.stir
## Features
- 50+ PDF Operations
- Parallel file processing and downloads
- Dark mode support
- Custom download options
- Custom 'Pipelines' to run multiple features in an automated queue
- Custom 'Pipelines' to run multiple features in a automated queue
- API for integration with external scripts
- Optional Login and Authentication support (see [here](https://docs.stirlingpdf.com/Advanced%20Configuration/System%20and%20Security) for documentation)
- Enterprise features like SSO (see [here](https://docs.stirlingpdf.com/Advanced%20Configuration/Single%20Sign-On%20Configuration) for documentation)
- Database Backup and Import (see [here](https://docs.stirlingpdf.com/Advanced%20Configuration/DATABASE) for documentation)
- Enterprise features like SSO (see [here](https://docs.stirlingpdf.com/Advanced%20Configuration/Single%20Sign-On%20Configuration) for documentation)
### 50+ PDF Operations
## PDF Features
#### Organise
- **PDF Multi Tool**: Access merge, rotate, rearrange, split and delete actions from a single dashboard
- **Merge**: Combine multiple PDFs into one
- **Split**: Divide PDFs into multiple files
- **Extract page(s)**: Extract specific pages from PDF
- **Remove**: Delete pages from PDF
- **Crop PDF**: Adjust PDF page boundaries
- **Rotate**: Rotate pages in 90-degree increments
- **Adjust page size/scale**: Resize page contents
- **Multi-Page Layout**: Add multiple pages to PDF
- **PDF to Single Large Page**: Convert to single continuous page
- **Organize**: Rearrange PDF pages
### Page Operations
#### Convert to PDF
- **Image to PDF**: Convert images to PDF format
- **Convert file to PDF**: Convert various common file types to PDF
- **URL/Website to PDF**: Capture live webpages as PDFs
- **HTML to PDF**: Transform HTML documents to PDF
- **Markdown to PDF**: Convert Markdown files to PDF
- **CBZ to PDF**: Convert comic book archives
- **CBR to PDF**: Convert comic book rar archives
- **Email to PDF**: Convert email files to PDF
- **eBook to PDF**: Convert eBook formats (EPUB, MOBI, AZW3, FB2, TXT, DOCX) to PDF (using Calibre)
- **Vector Image to PDF**: Convert vector images (PS, EPS, EPSF) to PDF format
- View and modify PDFs - View multi-page PDFs with custom viewing, sorting, and searching. Plus, on-page edit features like annotating, drawing, and adding text and images. (Using PDF.js with Joxit and Liberation fonts)
- Full interactive GUI for merging/splitting/rotating/moving PDFs and their pages
- Merge multiple PDFs into a single resultant file
- Split PDFs into multiple files at specified page numbers or extract all pages as individual files
- Reorganize PDF pages into different orders
- Rotate PDFs in 90-degree increments
- Remove pages
- Multi-page layout (format PDFs into a multi-paged page)
- Scale page contents size by set percentage
- Adjust contrast
- Crop PDF
- Auto-split PDF (with physically scanned page dividers)
- Extract page(s)
- Convert PDF to a single page
- Overlay PDFs on top of each other
- PDF to a single page
- Split PDF by sections
#### Convert from PDF
- **PDF to Word**: Convert to document (docx, doc, odt) format
- **PDF to Image**: Extract PDF pages as images
- **PDF to RTF (Text)**: Convert to Rich Text Format
- **PDF to Presentation**: Convert to presentation (pptx, ppt, odp) format
- **PDF to CSV**: Extract tables to CSV
- **PDF to XML**: Convert to XML format
- **PDF to HTML**: Transform to HTML
- **PDF to PDF/A**: Convert to archival (PDF/A-1b, PDF/A-2b) format
- **PDF to Markdown**: Convert PDF to Markdown
- **PDF to CBZ**: Convert to comic book archive
- **PDF to CBR**: Convert to comic book rar archive
- **PDF to Vector Image**: Convert PDF to vector image (EPS, PS, PCL, XPS) format
- **PDF to Video Slideshow**: Export pages as an automated video presentation
### Conversion Operations
- Convert PDFs to and from images
- Convert any common file to PDF (using LibreOffice)
- Convert PDF to Word/PowerPoint/others (using LibreOffice)
- Convert HTML to PDF
- Convert PDF to XML
- Convert PDF to CSV
- URL to PDF
- Markdown to PDF
### Security & Permissions
- Add and remove passwords
- Change/set PDF permissions
- Add watermark(s)
- Certify/sign PDFs
- Sanitize PDFs
- Auto-redact text
### Other Operations
- Add/generate/write signatures
- Split by Size or PDF
- Repair PDFs
- Detect and remove blank pages
- Compare two PDFs and show differences in text
- Add images to PDFs
- Compress PDFs to decrease their filesize (using qpdf)
- Extract images from PDF
- Remove images from PDF
- Extract images from scans
- Remove annotations
- Add page numbers
- Auto-rename files by detecting PDF header text
- OCR on PDF (using Tesseract OCR)
- PDF/A conversion (using LibreOffice)
- Edit metadata
- Flatten PDFs
- Get all information on a PDF to view or export as JSON
- Show/detect embedded JavaScript
#### Sign & Security
- **Sign**: Add digital signatures
- **Remove Password**: Remove PDF security
- **Add Watermark**: Apply watermarks
- **Sign with Certificate**: Certificate-based signing
- **Add Stamp to PDF**: Apply digital stamps
- **Auto Redact**: Automatically redact content
- **Change Permissions**: Modify access permissions
- **Add Password**: Apply PDF encryption
- **Manual Redaction**: Manual content redaction
- **Remove Certificate Sign**: Remove digital signatures
- **Sanitize**: Clean PDF of potential security issues
- **Validate PDF Signature**: Verify digital signatures
#### View & Edit
- **OCR / Cleanup scans**: Optical Character Recognition
- **Add Image**: Insert images into PDF
- **Extract Images**: Extract embedded images
- **Extract Attachments**: Retrieve embedded attachments
- **Change Metadata**: Edit PDF metadata
- **Get ALL Info on PDF**: Comprehensive PDF analysis
- **Advanced Colour options**: Colour manipulation (various options for colour inversion, CMYK conversion)
- **Compare**: Compare PDF documents
- **Add Page Numbers**: Insert page numbering
- **Flatten**: Flatten PDF layers, and interactive elements
- **Remove Annotations**: Delete comments and markups
- **Remove Blank pages**: Delete empty pages
- **Remove Image**: Delete embedded images
- **View/Edit PDF**: Interactive PDF editing
- **Unlock PDF Forms**: Enable form editing
- **Add Attachments**: Attach files to PDF
#### Advanced
- **Compress**: Reduce file size
- **Pipeline**: Automated workflow processing (OCR images pipeline, prepare PDFs for emailing pipeline)
- **Adjust Colours/Contrast**: Colour and contrast adjustment
- **Auto Rename PDF File**: Automatic file renaming
- **Auto Split Pages**: Automatic page splitting
- **Detect/Split Scanned photos**: Photo detection and splitting
- **Overlay PDFs**: Layer PDFs over each other
- **Repair**: Fix corrupted PDFs
- **Show JavaScript**: Display embedded JavaScript
- **Auto Split by Size/Count**: Split by file size or page count
- **Split PDF by Chapters**: Chapter-based splitting
- **Split PDF by Sections**: Section-based splitting
- **Scanner Effect**: Apply scanner-like effects
- **Edit Table of Contents**: Modify PDF bookmarks and TOC
# 📖 Get Started
@@ -137,48 +115,48 @@ Visit our comprehensive documentation at [docs.stirlingpdf.com](https://docs.sti
Stirling-PDF currently supports 40 languages!
| Language | Progress |
|----------------------------------------------|----------------------------------------|
| Arabic (العربية) (ar_AR) | ![58%](https://geps.dev/progress/58) |
| Azerbaijani (Azərbaycan Dili) (az_AZ) | ![50%](https://geps.dev/progress/50) |
| Basque (Euskara) (eu_ES) | ![29%](https://geps.dev/progress/29) |
| Bulgarian (Български) (bg_BG) | ![82%](https://geps.dev/progress/82) |
| Catalan (Català) (ca_CA) | ![54%](https://geps.dev/progress/54) |
| Croatian (Hrvatski) (hr_HR) | ![79%](https://geps.dev/progress/79) |
| Czech (Česky) (cs_CZ) | ![56%](https://geps.dev/progress/56) |
| Danish (Dansk) (da_DK) | ![49%](https://geps.dev/progress/49) |
| Dutch (Nederlands) (nl_NL) | ![78%](https://geps.dev/progress/78) |
| -------------------------------------------- | -------------------------------------- |
| Arabic (العربية) (ar_AR) | ![63%](https://geps.dev/progress/63) |
| Azerbaijani (Azərbaycan Dili) (az_AZ) | ![63%](https://geps.dev/progress/63) |
| Basque (Euskara) (eu_ES) | ![37%](https://geps.dev/progress/37) |
| Bulgarian (Български) (bg_BG) | ![70%](https://geps.dev/progress/70) |
| Catalan (Català) (ca_CA) | ![69%](https://geps.dev/progress/69) |
| Croatian (Hrvatski) (hr_HR) | ![62%](https://geps.dev/progress/62) |
| Czech (Česky) (cs_CZ) | ![71%](https://geps.dev/progress/71) |
| Danish (Dansk) (da_DK) | ![63%](https://geps.dev/progress/63) |
| Dutch (Nederlands) (nl_NL) | ![61%](https://geps.dev/progress/61) |
| English (English) (en_GB) | ![100%](https://geps.dev/progress/100) |
| English (US) (en_US) | ![100%](https://geps.dev/progress/100) |
| French (Français) (fr_FR) | ![85%](https://geps.dev/progress/85) |
| German (Deutsch) (de_DE) | ![86%](https://geps.dev/progress/86) |
| Greek (Ελληνικά) (el_GR) | ![54%](https://geps.dev/progress/54) |
| Hindi (हिंदी) (hi_IN) | ![54%](https://geps.dev/progress/54) |
| Hungarian (Magyar) (hu_HU) | ![85%](https://geps.dev/progress/85) |
| Indonesian (Bahasa Indonesia) (id_ID) | ![50%](https://geps.dev/progress/50) |
| Irish (Gaeilge) (ga_IE) | ![55%](https://geps.dev/progress/55) |
| Italian (Italiano) (it_IT) | ![85%](https://geps.dev/progress/85) |
| Japanese (日本語) (ja_JP) | ![79%](https://geps.dev/progress/79) |
| Korean (한국어) (ko_KR) | ![54%](https://geps.dev/progress/54) |
| Norwegian (Norsk) (no_NB) | ![53%](https://geps.dev/progress/53) |
| Persian (فارسی) (fa_IR) | ![52%](https://geps.dev/progress/52) |
| Polish (Polski) (pl_PL) | ![77%](https://geps.dev/progress/77) |
| Portuguese (Português) (pt_PT) | ![76%](https://geps.dev/progress/76) |
| Portuguese Brazilian (Português) (pt_BR) | ![74%](https://geps.dev/progress/74) |
| Romanian (Română) (ro_RO) | ![46%](https://geps.dev/progress/46) |
| Russian (Русский) (ru_RU) | ![91%](https://geps.dev/progress/91) |
| Serbian Latin alphabet (Srpski) (sr_LATN_RS) | ![80%](https://geps.dev/progress/80) |
| Simplified Chinese (简体中文) (zh_CN) | ![75%](https://geps.dev/progress/75) |
| Slovakian (Slovensky) (sk_SK) | ![41%](https://geps.dev/progress/41) |
| Slovenian (Slovenščina) (sl_SI) | ![57%](https://geps.dev/progress/57) |
| Spanish (Español) (es_ES) | ![85%](https://geps.dev/progress/85) |
| Swedish (Svenska) (sv_SE) | ![52%](https://geps.dev/progress/52) |
| Thai (ไทย) (th_TH) | ![47%](https://geps.dev/progress/47) |
| Tibetan (བོད་ཡིག་) (bo_CN) | ![52%](https://geps.dev/progress/52) |
| Traditional Chinese (繁體中文) (zh_TW) | ![82%](https://geps.dev/progress/82) |
| Turkish (Türkçe) (tr_TR) | ![80%](https://geps.dev/progress/80) |
| Ukrainian (Українська) (uk_UA) | ![57%](https://geps.dev/progress/57) |
| Vietnamese (Tiếng Việt) (vi_VN) | ![46%](https://geps.dev/progress/46) |
| Malayalam (മലയാളം) (ml_IN) | ![59%](https://geps.dev/progress/59) |
| French (Français) (fr_FR) | ![91%](https://geps.dev/progress/91) |
| German (Deutsch) (de_DE) | ![100%](https://geps.dev/progress/100) |
| Greek (Ελληνικά) (el_GR) | ![69%](https://geps.dev/progress/69) |
| Hindi (हिंदी) (hi_IN) | ![68%](https://geps.dev/progress/68) |
| Hungarian (Magyar) (hu_HU) | ![99%](https://geps.dev/progress/99) |
| Indonesian (Bahasa Indonesia) (id_ID) | ![63%](https://geps.dev/progress/63) |
| Irish (Gaeilge) (ga_IE) | ![70%](https://geps.dev/progress/70) |
| Italian (Italiano) (it_IT) | ![98%](https://geps.dev/progress/98) |
| Japanese (日本語) (ja_JP) | ![70%](https://geps.dev/progress/70) |
| Korean (한국어) (ko_KR) | ![69%](https://geps.dev/progress/69) |
| Norwegian (Norsk) (no_NB) | ![67%](https://geps.dev/progress/67) |
| Persian (فارسی) (fa_IR) | ![66%](https://geps.dev/progress/66) |
| Polish (Polski) (pl_PL) | ![73%](https://geps.dev/progress/73) |
| Portuguese (Português) (pt_PT) | ![70%](https://geps.dev/progress/70) |
| Portuguese Brazilian (Português) (pt_BR) | ![77%](https://geps.dev/progress/77) |
| Romanian (Română) (ro_RO) | ![59%](https://geps.dev/progress/59) |
| Russian (Русский) (ru_RU) | ![70%](https://geps.dev/progress/70) |
| Serbian Latin alphabet (Srpski) (sr_LATN_RS) | ![97%](https://geps.dev/progress/97) |
| Simplified Chinese (简体中文) (zh_CN) | ![90%](https://geps.dev/progress/90) |
| Slovakian (Slovensky) (sk_SK) | ![53%](https://geps.dev/progress/53) |
| Slovenian (Slovenščina) (sl_SI) | ![73%](https://geps.dev/progress/73) |
| Spanish (Español) (es_ES) | ![75%](https://geps.dev/progress/75) |
| Swedish (Svenska) (sv_SE) | ![67%](https://geps.dev/progress/67) |
| Thai (ไทย) (th_TH) | ![60%](https://geps.dev/progress/60) |
| Tibetan (བོད་ཡིག་) (bo_CN) | ![66%](https://geps.dev/progress/66) |
| Traditional Chinese (繁體中文) (zh_TW) | ![77%](https://geps.dev/progress/77) |
| Turkish (Türkçe) (tr_TR) | ![82%](https://geps.dev/progress/82) |
| Ukrainian (Українська) (uk_UA) | ![72%](https://geps.dev/progress/72) |
| Vietnamese (Tiếng Việt) (vi_VN) | ![58%](https://geps.dev/progress/58) |
| Malayalam (മലയാളം) (ml_IN) | ![75%](https://geps.dev/progress/75) |
## Stirling PDF Enterprise
@@ -167,10 +167,6 @@
{
"moduleName": ".*",
"moduleLicense": "The W3C License"
},
{
"moduleName": ".*",
"moduleLicense": "UnRar License"
}
]
}
@@ -1,272 +0,0 @@
package stirling.software.common.service;
import java.net.Inet4Address;
import java.net.Inet6Address;
import java.net.InetAddress;
import java.net.URI;
import java.net.UnknownHostException;
import java.util.Locale;
import java.util.regex.Pattern;
import org.springframework.stereotype.Service;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.util.RegexPatternUtils;
@Service
@RequiredArgsConstructor
@Slf4j
public class SsrfProtectionService {
private final ApplicationProperties applicationProperties;
private static final Pattern DATA_URL_PATTERN =
RegexPatternUtils.getInstance().getPattern("^data:.*", Pattern.CASE_INSENSITIVE);
private static final Pattern FRAGMENT_PATTERN =
RegexPatternUtils.getInstance().getPattern("^#.*");
public enum SsrfProtectionLevel {
OFF, // No SSRF protection - allows all URLs
MEDIUM, // Block internal networks but allow external URLs
MAX // Block all external URLs - only data: and fragments
}
public boolean isUrlAllowed(String url) {
ApplicationProperties.Html.UrlSecurity config =
applicationProperties.getSystem().getHtml().getUrlSecurity();
if (!config.isEnabled()) {
return true;
}
if (url == null || url.trim().isEmpty()) {
return false;
}
String trimmedUrl = url.trim();
// Always allow data URLs and fragments
if (DATA_URL_PATTERN.matcher(trimmedUrl).matches()
|| FRAGMENT_PATTERN.matcher(trimmedUrl).matches()) {
return true;
}
SsrfProtectionLevel level = parseProtectionLevel(config.getLevel());
return switch (level) {
case OFF -> true;
case MAX -> isMaxSecurityAllowed(trimmedUrl, config);
case MEDIUM -> isMediumSecurityAllowed(trimmedUrl, config);
default -> false;
};
}
private SsrfProtectionLevel parseProtectionLevel(SsrfProtectionLevel level) {
try {
return SsrfProtectionLevel.valueOf(level.name());
} catch (IllegalArgumentException e) {
log.warn("Invalid SSRF protection level '{}', defaulting to MEDIUM", level);
return SsrfProtectionLevel.MEDIUM;
}
}
private boolean isMaxSecurityAllowed(
String url, ApplicationProperties.Html.UrlSecurity config) {
// MAX security: only allow explicitly whitelisted domains
try {
URI uri = new URI(url);
String host = uri.getHost();
if (host == null) {
return false;
}
return config.getAllowedDomains().contains(host.toLowerCase(Locale.ROOT));
} catch (Exception e) {
log.debug("Failed to parse URL for MAX security check: {}", url, e);
return false;
}
}
private boolean isMediumSecurityAllowed(
String url, ApplicationProperties.Html.UrlSecurity config) {
try {
URI uri = new URI(url);
String host = uri.getHost();
if (host == null) {
return false;
}
String hostLower = host.toLowerCase(Locale.ROOT);
// Check explicit blocked domains
if (config.getBlockedDomains().contains(hostLower)) {
log.debug("URL blocked by explicit domain blocklist: {}", url);
return false;
}
// Check internal TLD patterns
for (String tld : config.getInternalTlds()) {
if (hostLower.endsWith(tld.toLowerCase(Locale.ROOT))) {
log.debug("URL blocked by internal TLD pattern '{}': {}", tld, url);
return false;
}
}
// If allowedDomains is specified, only allow those
if (!config.getAllowedDomains().isEmpty()) {
boolean isAllowed =
config.getAllowedDomains().stream()
.anyMatch(
domain ->
hostLower.equals(domain.toLowerCase(Locale.ROOT))
|| hostLower.endsWith(
"."
+ domain.toLowerCase(
Locale.ROOT)));
if (!isAllowed) {
log.debug("URL not in allowed domains list: {}", url);
return false;
}
}
// Resolve hostname to IP address for network-based checks
try {
InetAddress address = InetAddress.getByName(host);
if (config.isBlockPrivateNetworks() && isPrivateAddress(address)) {
log.debug("URL blocked - private network address: {}", url);
return false;
}
if (config.isBlockLocalhost() && address.isLoopbackAddress()) {
log.debug("URL blocked - localhost address: {}", url);
return false;
}
if (config.isBlockLinkLocal() && address.isLinkLocalAddress()) {
log.debug("URL blocked - link-local address: {}", url);
return false;
}
if (config.isBlockCloudMetadata()
&& isCloudMetadataAddress(address.getHostAddress())) {
log.debug("URL blocked - cloud metadata endpoint: {}", url);
return false;
}
} catch (UnknownHostException e) {
log.debug("Failed to resolve hostname for SSRF check: {}", host, e);
return false;
}
return true;
} catch (Exception e) {
log.debug("Failed to parse URL for MEDIUM security check: {}", url, e);
return false;
}
}
private boolean isPrivateAddress(InetAddress address) {
if (address.isAnyLocalAddress() || address.isLoopbackAddress()) {
return true;
}
if (address instanceof Inet4Address) {
return isPrivateIPv4Range(address.getHostAddress());
}
if (address instanceof Inet6Address addr6) {
if (addr6.isLinkLocalAddress() || addr6.isSiteLocalAddress()) {
return true;
}
byte[] bytes = addr6.getAddress();
if (isIpv4MappedAddress(bytes)) {
String ipv4 =
(bytes[12] & 0xff)
+ "."
+ (bytes[13] & 0xff)
+ "."
+ (bytes[14] & 0xff)
+ "."
+ (bytes[15] & 0xff);
return isPrivateIPv4Range(ipv4);
}
int firstByte = bytes[0] & 0xff;
// Check for IPv6 unique local addresses (fc00::/7)
if ((firstByte & 0xfe) == 0xfc) {
return true;
}
}
return false;
}
private boolean isIpv4MappedAddress(byte[] addr) {
if (addr.length != 16) {
return false;
}
for (int i = 0; i < 10; i++) {
if (addr[i] != 0) {
return false;
}
}
// For IPv4-mapped IPv6 addresses, bytes 10 and 11 must be 0xff (i.e., address is
// ::ffff:w.x.y.z)
return addr[10] == (byte) 0xff && addr[11] == (byte) 0xff;
}
private boolean isPrivateIPv4Range(String ip) {
// Includes RFC1918, loopback, link-local, and unspecified addresses
return ip.startsWith("10.")
|| ip.startsWith("192.168.")
|| (ip.startsWith("172.") && isInRange172(ip))
|| ip.startsWith("169.254.")
|| ip.startsWith("127.")
|| "0.0.0.0".equals(ip);
}
private boolean isInRange172(String ip) {
String[] parts = ip.split("\\.");
if (parts.length >= 2) {
try {
int secondOctet = Integer.parseInt(parts[1]);
return secondOctet >= 16 && secondOctet <= 31;
} catch (NumberFormatException e) {
}
}
return false;
}
private boolean isCloudMetadataAddress(String ip) {
String normalizedIp = normalizeIpv4MappedAddress(ip);
// Cloud metadata endpoints for AWS, GCP, Azure, Oracle Cloud, and IBM Cloud
return normalizedIp.startsWith("169.254.169.254") // AWS/GCP/Azure
|| normalizedIp.startsWith("fd00:ec2::254") // AWS IPv6
|| normalizedIp.startsWith("169.254.169.253") // Oracle Cloud
|| normalizedIp.startsWith("169.254.169.250"); // IBM Cloud
}
private String normalizeIpv4MappedAddress(String ip) {
if (ip == null) {
return "";
}
if (ip.startsWith("::ffff:")) {
return ip.substring(7);
}
int lastColon = ip.lastIndexOf(':');
if (lastColon >= 0 && ip.indexOf('.') > lastColon) {
return ip.substring(lastColon + 1);
}
return ip;
}
}
@@ -1,253 +0,0 @@
package stirling.software.common.util;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.Locale;
import org.apache.commons.io.FilenameUtils;
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.image.PDImageXObject;
import org.springframework.web.multipart.MultipartFile;
import com.github.junrar.Archive;
import com.github.junrar.exception.CorruptHeaderException;
import com.github.junrar.exception.RarException;
import com.github.junrar.rarfile.FileHeader;
import lombok.experimental.UtilityClass;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.service.CustomPDFDocumentFactory;
@Slf4j
@UtilityClass
public class CbrUtils {
public byte[] convertCbrToPdf(
MultipartFile cbrFile,
CustomPDFDocumentFactory pdfDocumentFactory,
TempFileManager tempFileManager)
throws IOException {
return convertCbrToPdf(cbrFile, pdfDocumentFactory, tempFileManager, false);
}
public byte[] convertCbrToPdf(
MultipartFile cbrFile,
CustomPDFDocumentFactory pdfDocumentFactory,
TempFileManager tempFileManager,
boolean optimizeForEbook)
throws IOException {
validateCbrFile(cbrFile);
try (TempFile tempFile = new TempFile(tempFileManager, ".cbr")) {
cbrFile.transferTo(tempFile.getFile());
try (PDDocument document = pdfDocumentFactory.createNewDocument()) {
Archive archive;
try {
archive = new Archive(tempFile.getFile());
} catch (CorruptHeaderException e) {
log.warn(
"Failed to open CBR/RAR archive due to corrupt header: {}",
e.getMessage());
throw ExceptionUtils.createCbrInvalidFormatException(null);
} catch (RarException e) {
log.warn("Failed to open CBR/RAR archive: {}", e.getMessage());
String exMessage = e.getMessage() != null ? e.getMessage() : "";
if (exMessage.contains("encrypted")) {
throw ExceptionUtils.createCbrEncryptedException();
} else if (exMessage.isEmpty()) {
throw ExceptionUtils.createCbrInvalidFormatException(
"Invalid CBR/RAR archive. The file may be encrypted, corrupted, or"
+ " use an unsupported format.");
} else {
throw ExceptionUtils.createCbrInvalidFormatException(
"Invalid CBR/RAR archive: "
+ exMessage
+ ". The file may be encrypted, corrupted, or use an"
+ " unsupported format.");
}
} catch (IOException e) {
log.warn("IO error reading CBR/RAR archive: {}", e.getMessage());
throw ExceptionUtils.createFileProcessingException("CBR extraction", e);
}
List<ImageEntryData> imageEntries = new ArrayList<>();
try {
for (FileHeader fileHeader : archive) {
if (!fileHeader.isDirectory() && isImageFile(fileHeader.getFileName())) {
try (InputStream is = archive.getInputStream(fileHeader)) {
ByteArrayOutputStream baos = new ByteArrayOutputStream();
is.transferTo(baos);
imageEntries.add(
new ImageEntryData(
fileHeader.getFileName(), baos.toByteArray()));
} catch (Exception e) {
log.warn(
"Error reading image {}: {}",
fileHeader.getFileName(),
e.getMessage());
}
}
}
} finally {
try {
archive.close();
} catch (IOException e) {
log.warn("Error closing CBR/RAR archive: {}", e.getMessage());
}
}
imageEntries.sort(
Comparator.comparing(ImageEntryData::name, new NaturalOrderComparator()));
if (imageEntries.isEmpty()) {
throw ExceptionUtils.createIllegalArgumentException(
"error.fileProcessing",
"No valid images found in the CBR file. The archive may be empty or"
+ " contain no supported image formats.");
}
for (ImageEntryData imageEntry : imageEntries) {
try {
PDImageXObject pdImage =
PDImageXObject.createFromByteArray(
document, imageEntry.data(), imageEntry.name());
PDPage page =
new PDPage(
new PDRectangle(pdImage.getWidth(), pdImage.getHeight()));
document.addPage(page);
try (PDPageContentStream contentStream =
new PDPageContentStream(document, page)) {
contentStream.drawImage(pdImage, 0, 0);
}
} catch (IOException e) {
log.warn(
"Error processing image {}: {}", imageEntry.name(), e.getMessage());
}
}
if (document.getNumberOfPages() == 0) {
throw ExceptionUtils.createIllegalArgumentException(
"error.fileProcessing",
"No images could be processed from the CBR file. All images may be"
+ " corrupted or in unsupported formats.");
}
ByteArrayOutputStream baos = new ByteArrayOutputStream();
document.save(baos);
byte[] pdfBytes = baos.toByteArray();
// Apply Ghostscript optimization if requested
if (optimizeForEbook) {
try {
return GeneralUtils.optimizePdfWithGhostscript(pdfBytes);
} catch (IOException e) {
log.warn("Ghostscript optimization failed, returning unoptimized PDF", e);
}
}
return pdfBytes;
}
}
}
private void validateCbrFile(MultipartFile file) {
if (file == null || file.isEmpty()) {
throw ExceptionUtils.createFileNullOrEmptyException();
}
String filename = file.getOriginalFilename();
if (filename == null) {
throw ExceptionUtils.createFileNoNameException();
}
String extension = FilenameUtils.getExtension(filename).toLowerCase(Locale.ROOT);
if (!"cbr".equals(extension) && !"rar".equals(extension)) {
throw ExceptionUtils.createNotCbrFileException();
}
}
public boolean isCbrFile(MultipartFile file) {
String filename = file.getOriginalFilename();
if (filename == null) {
return false;
}
String extension = FilenameUtils.getExtension(filename).toLowerCase(Locale.ROOT);
return "cbr".equals(extension) || "rar".equals(extension);
}
private boolean isImageFile(String filename) {
return RegexPatternUtils.getInstance().getImageFilePattern().matcher(filename).matches();
}
private record ImageEntryData(String name, byte[] data) {}
private class NaturalOrderComparator implements Comparator<String> {
private static String getChunk(String s, int length, int marker) {
StringBuilder chunk = new StringBuilder();
char c = s.charAt(marker);
chunk.append(c);
marker++;
if (isDigit(c)) {
while (marker < length && isDigit(s.charAt(marker))) {
chunk.append(s.charAt(marker));
marker++;
}
} else {
while (marker < length && !isDigit(s.charAt(marker))) {
chunk.append(s.charAt(marker));
marker++;
}
}
return chunk.toString();
}
private static boolean isDigit(char ch) {
return ch >= '0' && ch <= '9';
}
@Override
public int compare(String s1, String s2) {
int len1 = s1.length();
int len2 = s2.length();
int marker1 = 0, marker2 = 0;
while (marker1 < len1 && marker2 < len2) {
String chunk1 = getChunk(s1, len1, marker1);
marker1 += chunk1.length();
String chunk2 = getChunk(s2, len2, marker2);
marker2 += chunk2.length();
int result;
if (isDigit(chunk1.charAt(0)) && isDigit(chunk2.charAt(0))) {
int thisNumericValue = Integer.parseInt(chunk1);
int thatNumericValue = Integer.parseInt(chunk2);
result = Integer.compare(thisNumericValue, thatNumericValue);
} else {
result = chunk1.compareTo(chunk2);
}
if (result != 0) {
return result;
}
}
return Integer.compare(len1, len2);
}
}
}
@@ -1,230 +0,0 @@
package stirling.software.common.util;
import java.io.BufferedInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.Enumeration;
import java.util.List;
import java.util.Locale;
import java.util.zip.ZipEntry;
import java.util.zip.ZipFile;
import java.util.zip.ZipInputStream;
import org.apache.commons.io.FilenameUtils;
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.image.PDImageXObject;
import org.springframework.web.multipart.MultipartFile;
import lombok.experimental.UtilityClass;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.service.CustomPDFDocumentFactory;
@Slf4j
@UtilityClass
public class CbzUtils {
public byte[] convertCbzToPdf(
MultipartFile cbzFile,
CustomPDFDocumentFactory pdfDocumentFactory,
TempFileManager tempFileManager)
throws IOException {
return convertCbzToPdf(cbzFile, pdfDocumentFactory, tempFileManager, false);
}
public byte[] convertCbzToPdf(
MultipartFile cbzFile,
CustomPDFDocumentFactory pdfDocumentFactory,
TempFileManager tempFileManager,
boolean optimizeForEbook)
throws IOException {
validateCbzFile(cbzFile);
try (TempFile tempFile = new TempFile(tempFileManager, ".cbz")) {
cbzFile.transferTo(tempFile.getFile());
// Early ZIP validity check using ZipInputStream (fail fast on non-zip content)
try (BufferedInputStream bis =
new BufferedInputStream(
new java.io.FileInputStream(tempFile.getFile()));
ZipInputStream zis = new ZipInputStream(bis)) {
if (zis.getNextEntry() == null) {
throw ExceptionUtils.createCbzEmptyException();
}
} catch (IOException e) {
throw ExceptionUtils.createCbzInvalidFormatException(e);
}
try (PDDocument document = pdfDocumentFactory.createNewDocument();
ZipFile zipFile = new ZipFile(tempFile.getFile())) {
Enumeration<? extends ZipEntry> entries = zipFile.entries();
List<ImageEntryData> imageEntries = new ArrayList<>();
while (entries.hasMoreElements()) {
ZipEntry entry = entries.nextElement();
if (!entry.isDirectory() && isImageFile(entry.getName())) {
try (InputStream is = zipFile.getInputStream(entry)) {
ByteArrayOutputStream baos = new ByteArrayOutputStream();
is.transferTo(baos);
imageEntries.add(
new ImageEntryData(entry.getName(), baos.toByteArray()));
} catch (IOException e) {
log.warn("Error reading image {}: {}", entry.getName(), e.getMessage());
}
}
}
imageEntries.sort(
Comparator.comparing(ImageEntryData::name, new NaturalOrderComparator()));
if (imageEntries.isEmpty()) {
throw ExceptionUtils.createCbzNoImagesException();
}
for (ImageEntryData imageEntry : imageEntries) {
try {
PDImageXObject pdImage =
PDImageXObject.createFromByteArray(
document, imageEntry.data(), imageEntry.name());
PDPage page =
new PDPage(
new PDRectangle(pdImage.getWidth(), pdImage.getHeight()));
document.addPage(page);
try (PDPageContentStream contentStream =
new PDPageContentStream(document, page)) {
contentStream.drawImage(pdImage, 0, 0);
}
} catch (IOException e) {
log.warn(
"Error processing image {}: {}", imageEntry.name(), e.getMessage());
}
}
if (document.getNumberOfPages() == 0) {
throw ExceptionUtils.createCbzCorruptedImagesException();
}
ByteArrayOutputStream baos = new ByteArrayOutputStream();
document.save(baos);
byte[] pdfBytes = baos.toByteArray();
// Apply Ghostscript optimization if requested
if (optimizeForEbook) {
try {
return GeneralUtils.optimizePdfWithGhostscript(pdfBytes);
} catch (IOException e) {
log.warn("Ghostscript optimization failed, returning unoptimized PDF", e);
}
}
return pdfBytes;
}
}
}
private void validateCbzFile(MultipartFile file) {
if (file == null || file.isEmpty()) {
throw ExceptionUtils.createFileNullOrEmptyException();
}
String filename = file.getOriginalFilename();
if (filename == null) {
throw ExceptionUtils.createFileNoNameException();
}
String extension = FilenameUtils.getExtension(filename).toLowerCase(Locale.ROOT);
if (!"cbz".equals(extension) && !"zip".equals(extension)) {
throw ExceptionUtils.createNotCbzFileException();
}
}
public boolean isCbzFile(MultipartFile file) {
String filename = file.getOriginalFilename();
if (filename == null) {
return false;
}
String extension = FilenameUtils.getExtension(filename).toLowerCase(Locale.ROOT);
return "cbz".equals(extension) || "zip".equals(extension);
}
public static boolean isComicBookFile(MultipartFile file) {
String filename = file.getOriginalFilename();
if (filename == null) {
return false;
}
String extension = FilenameUtils.getExtension(filename).toLowerCase(Locale.ROOT);
return "cbz".equals(extension)
|| "zip".equals(extension)
|| "cbr".equals(extension)
|| "rar".equals(extension);
}
private boolean isImageFile(String filename) {
return RegexPatternUtils.getInstance().getImageFilePattern().matcher(filename).matches();
}
private record ImageEntryData(String name, byte[] data) {}
private class NaturalOrderComparator implements Comparator<String> {
@Override
public int compare(String s1, String s2) {
int len1 = s1.length();
int len2 = s2.length();
int marker1 = 0, marker2 = 0;
while (marker1 < len1 && marker2 < len2) {
String chunk1 = getChunk(s1, len1, marker1);
marker1 += chunk1.length();
String chunk2 = getChunk(s2, len2, marker2);
marker2 += chunk2.length();
int result;
if (isDigit(chunk1.charAt(0)) && isDigit(chunk2.charAt(0))) {
int thisNumericValue = Integer.parseInt(chunk1);
int thatNumericValue = Integer.parseInt(chunk2);
result = Integer.compare(thisNumericValue, thatNumericValue);
} else {
result = chunk1.compareTo(chunk2);
}
if (result != 0) {
return result;
}
}
return Integer.compare(len1, len2);
}
private static String getChunk(String s, int length, int marker) {
StringBuilder chunk = new StringBuilder();
char c = s.charAt(marker);
chunk.append(c);
marker++;
if (isDigit(c)) {
while (marker < length && isDigit(s.charAt(marker))) {
chunk.append(s.charAt(marker));
marker++;
}
} else {
while (marker < length && !isDigit(s.charAt(marker))) {
chunk.append(s.charAt(marker));
marker++;
}
}
return chunk.toString();
}
private static boolean isDigit(char ch) {
return ch >= '0' && ch <= '9';
}
}
}
@@ -1,297 +0,0 @@
package stirling.software.common.util;
import java.io.IOException;
import java.io.InputStream;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.Base64;
import java.util.LinkedHashMap;
import java.util.Locale;
import java.util.Map;
import java.util.zip.Adler32;
import java.util.zip.CRC32;
import java.util.zip.Checksum;
import lombok.experimental.UtilityClass;
@UtilityClass
public class ChecksumUtils {
/** Shared buffer size for streaming I/O. */
private static final int BUFFER_SIZE = 8192;
/** Mask to extract the lower 32 bits of a long value (unsigned int). */
private static final long UNSIGNED_32_BIT_MASK = 0xFFFFFFFFL;
/**
* Computes a checksum for the given file using the chosen algorithm and returns a lowercase hex
* string.
*
* <p>For digest algorithms (e.g., SHA-256, SHA-1, MD5), this returns the digest as hex. For
* 32-bit {@link Checksum} algorithms ("CRC32", "ADLER32"), this returns an 8-character
* lowercase hex string of the unsigned 32-bit value.
*
* @param path file to read
* @param algorithm algorithm name (case-insensitive). Special values: "CRC32", "ADLER32".
* @return hex string of the checksum
* @throws IOException if the file cannot be read
*/
public static String checksum(Path path, String algorithm) throws IOException {
try (InputStream is = Files.newInputStream(path)) {
return checksum(is, algorithm);
}
}
/**
* Computes a checksum for the given stream using the chosen algorithm and returns a lowercase
* hex string.
*
* <p><strong>Note:</strong> This method does <em>not</em> close the provided stream.
*
* @param is input stream (not closed by this method)
* @param algorithm algorithm name (case-insensitive). Special values: "CRC32", "ADLER32".
* @return hex string of the checksum
* @throws IOException if reading from the stream fails
*/
public static String checksum(InputStream is, String algorithm) throws IOException {
return switch (algorithm.toUpperCase(Locale.ROOT)) {
case "CRC32" -> checksumChecksum(is, new CRC32());
case "ADLER32" -> checksumChecksum(is, new Adler32());
default -> toHex(checksumBytes(is, algorithm));
};
}
/**
* Computes a checksum for the given file using the chosen algorithm and returns a Base64
* encoded string.
*
* <p>For digest algorithms this is the Base64 of the raw digest bytes. For 32-bit checksum
* algorithms ("CRC32", "ADLER32"), this is the Base64 of the 4-byte big-endian unsigned value.
*
* @param path file to read
* @param algorithm algorithm name (case-insensitive). Special values: "CRC32", "ADLER32".
* @return Base64-encoded checksum bytes
* @throws IOException if the file cannot be read
*/
public static String checksumBase64(Path path, String algorithm) throws IOException {
try (InputStream is = Files.newInputStream(path)) {
return checksumBase64(is, algorithm);
}
}
/**
* Computes a checksum for the given stream using the chosen algorithm and returns a Base64
* encoded string.
*
* <p><strong>Note:</strong> This method does <em>not</em> close the provided stream.
*
* @param is input stream (not closed by this method)
* @param algorithm algorithm name (case-insensitive). Special values: "CRC32", "ADLER32".
* @return Base64-encoded checksum bytes
* @throws IOException if reading from the stream fails
*/
public static String checksumBase64(InputStream is, String algorithm) throws IOException {
return switch (algorithm.toUpperCase(Locale.ROOT)) {
case "CRC32" ->
Base64.getEncoder().encodeToString(checksumChecksumBytes(is, new CRC32()));
case "ADLER32" ->
Base64.getEncoder().encodeToString(checksumChecksumBytes(is, new Adler32()));
default -> Base64.getEncoder().encodeToString(checksumBytes(is, algorithm));
};
}
/**
* Computes multiple checksums for the given file in a single pass over the data.
*
* <p>Returns a map from algorithm name to lowercase hex string. Order of results follows the
* order of the provided {@code algorithms}.
*
* @param path file to read
* @param algorithms algorithm names (case-insensitive). Special: "CRC32", "ADLER32".
* @return map of algorithm → hex string
* @throws IOException if the file cannot be read
*/
public static Map<String, String> checksums(Path path, String... algorithms)
throws IOException {
try (InputStream is = Files.newInputStream(path)) {
return checksums(is, algorithms);
}
}
/**
* Computes multiple checksums for the given stream in a single pass over the data.
*
* <p><strong>Note:</strong> This method does <em>not</em> close the provided stream.
*
* @param is input stream (not closed by this method)
* @param algorithms algorithm names (case-insensitive). Special: "CRC32", "ADLER32".
* @return map of algorithm → hex string
* @throws IOException if reading from the stream fails
*/
public static Map<String, String> checksums(InputStream is, String... algorithms)
throws IOException {
// Use LinkedHashMap to preserve the order of requested algorithms in the result.
Map<String, MessageDigest> digests = new LinkedHashMap<>();
Map<String, Checksum> checksums = new LinkedHashMap<>();
for (String algorithm : algorithms) {
String key = algorithm; // keep original key for output
switch (algorithm.toUpperCase(Locale.ROOT)) {
case "CRC32":
checksums.put(key, new CRC32());
break;
case "ADLER32":
checksums.put(key, new Adler32());
break;
default:
try {
// For MessageDigest, pass the original name (case-insensitive per JCA)
digests.put(key, MessageDigest.getInstance(algorithm));
} catch (NoSuchAlgorithmException e) {
throw new IllegalStateException("Unsupported algorithm: " + algorithm, e);
}
}
}
byte[] buffer = new byte[BUFFER_SIZE];
int read;
while ((read = is.read(buffer)) != -1) {
for (MessageDigest digest : digests.values()) {
digest.update(buffer, 0, read);
}
for (Checksum cs : checksums.values()) {
cs.update(buffer, 0, read);
}
}
Map<String, String> results = new LinkedHashMap<>();
for (Map.Entry<String, MessageDigest> entry : digests.entrySet()) {
results.put(entry.getKey(), toHex(entry.getValue().digest()));
}
for (Map.Entry<String, Checksum> entry : checksums.entrySet()) {
// Keep value as long and mask to ensure unsigned hex formatting.
long unsigned32 = entry.getValue().getValue() & UNSIGNED_32_BIT_MASK;
results.put(entry.getKey(), String.format(Locale.ROOT, "%08x", unsigned32));
}
return results;
}
/**
* Compares the checksum of a file with an expected hex string (case-insensitive).
*
* @param path file to read
* @param algorithm algorithm name (case-insensitive). Special: "CRC32", "ADLER32".
* @param expected expected hex string (case-insensitive)
* @return {@code true} if they match, otherwise {@code false}
* @throws IOException if the file cannot be read
*/
public static boolean matches(Path path, String algorithm, String expected) throws IOException {
try (InputStream is = Files.newInputStream(path)) {
return matches(is, algorithm, expected);
}
}
/**
* Compares the checksum of a stream with an expected hex string (case-insensitive).
*
* <p><strong>Note:</strong> This method does <em>not</em> close the provided stream.
*
* @param is input stream (not closed by this method)
* @param algorithm algorithm name (case-insensitive). Special: "CRC32", "ADLER32".
* @param expected expected hex string (case-insensitive)
* @return {@code true} if they match, otherwise {@code false}
* @throws IOException if reading from the stream fails
*/
public static boolean matches(InputStream is, String algorithm, String expected)
throws IOException {
return checksum(is, algorithm).equalsIgnoreCase(expected);
}
// ---------- Internal helpers ----------
/**
* Computes a MessageDigest over a stream and returns the raw digest bytes.
*
* @param is input stream (not closed)
* @param algorithm JCA MessageDigest algorithm (e.g., "SHA-256")
* @return raw digest bytes
* @throws IOException if reading fails
* @throws IllegalStateException if the algorithm is unsupported
*/
private static byte[] checksumBytes(InputStream is, String algorithm) throws IOException {
try {
MessageDigest digest = MessageDigest.getInstance(algorithm);
byte[] buffer = new byte[BUFFER_SIZE];
int read;
while ((read = is.read(buffer)) != -1) {
digest.update(buffer, 0, read);
}
return digest.digest();
} catch (NoSuchAlgorithmException e) {
// Keep the message explicit to aid debugging
throw new IllegalStateException("Unsupported algorithm: " + algorithm, e);
}
}
/**
* Computes a 32-bit {@link Checksum} over a stream and returns the lowercase 8-char hex of the
* unsigned 32-bit value.
*
* @param is input stream (not closed)
* @param checksum checksum implementation (CRC32, Adler32, etc.)
* @return 8-character lowercase hex (big-endian representation)
* @throws IOException if reading fails
*/
private static String checksumChecksum(InputStream is, Checksum checksum) throws IOException {
byte[] buffer = new byte[BUFFER_SIZE];
int read;
while ((read = is.read(buffer)) != -1) {
checksum.update(buffer, 0, read);
}
// Keep as long and mask to ensure correct unsigned representation.
long unsigned32 = checksum.getValue() & UNSIGNED_32_BIT_MASK;
return String.format(Locale.ROOT, "%08x", unsigned32);
}
/**
* Computes a 32-bit {@link Checksum} over a stream and returns the raw 4-byte big-endian
* representation of the unsigned 32-bit value.
*
* <p>Cast to int already truncates to the lower 32 bits; the sign is irrelevant because we
* serialize the bit pattern directly into 4 bytes.
*
* @param is input stream (not closed)
* @param checksum checksum implementation (CRC32, Adler32, etc.)
* @return 4 bytes (big-endian)
* @throws IOException if reading fails
*/
private static byte[] checksumChecksumBytes(InputStream is, Checksum checksum)
throws IOException {
byte[] buffer = new byte[BUFFER_SIZE];
int read;
while ((read = is.read(buffer)) != -1) {
checksum.update(buffer, 0, read);
}
// Cast keeps only the lower 32 bits; mask is unnecessary here.
int v = (int) checksum.getValue();
return ByteBuffer.allocate(4).order(ByteOrder.BIG_ENDIAN).putInt(v).array();
}
/**
* Converts bytes to a lowercase hex string.
*
* @param hash the byte array to convert
* @return the lowercase hex string
*/
private static String toHex(byte[] hash) {
StringBuilder sb = new StringBuilder(hash.length * 2);
for (byte b : hash) {
sb.append(String.format(Locale.ROOT, "%02x", b));
}
return sb.toString();
}
}
@@ -1,68 +0,0 @@
package stirling.software.common.util;
import org.owasp.html.AttributePolicy;
import org.owasp.html.HtmlPolicyBuilder;
import org.owasp.html.PolicyFactory;
import org.owasp.html.Sanitizers;
import org.springframework.stereotype.Component;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.service.SsrfProtectionService;
@Component
public class CustomHtmlSanitizer {
private final SsrfProtectionService ssrfProtectionService;
private final ApplicationProperties applicationProperties;
public CustomHtmlSanitizer(
SsrfProtectionService ssrfProtectionService,
ApplicationProperties applicationProperties) {
this.ssrfProtectionService = ssrfProtectionService;
this.applicationProperties = applicationProperties;
}
private final AttributePolicy SSRF_SAFE_URL_POLICY =
new AttributePolicy() {
@Override
public String apply(String elementName, String attributeName, String value) {
if (value.trim().isEmpty()) {
return null;
}
String trimmedValue = value.trim();
// Use the SSRF protection service to validate the URL
if (ssrfProtectionService != null
&& !ssrfProtectionService.isUrlAllowed(trimmedValue)) {
return null;
}
return trimmedValue;
}
};
private final PolicyFactory SSRF_SAFE_IMAGES_POLICY =
new HtmlPolicyBuilder()
.allowElements("img")
.allowAttributes("alt", "width", "height", "title")
.onElements("img")
.allowAttributes("src")
.matching(SSRF_SAFE_URL_POLICY)
.onElements("img")
.toFactory();
private final PolicyFactory POLICY =
Sanitizers.FORMATTING
.and(Sanitizers.BLOCKS)
.and(Sanitizers.STYLES)
.and(Sanitizers.LINKS)
.and(Sanitizers.TABLES)
.and(SSRF_SAFE_IMAGES_POLICY)
.and(new HtmlPolicyBuilder().disallowElements("noscript").toFactory());
public String sanitize(String html) {
boolean disableSanitize = applicationProperties.getSystem().isDisableSanitize();
return disableSanitize ? html : POLICY.sanitize(html);
}
}
@@ -1,694 +0,0 @@
package stirling.software.common.util;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.lang.reflect.Constructor;
import java.lang.reflect.Method;
import java.nio.charset.StandardCharsets;
import java.time.ZoneId;
import java.time.ZonedDateTime;
import java.util.ArrayList;
import java.util.Date;
import java.util.List;
import java.util.Locale;
import java.util.Properties;
import java.util.regex.Pattern;
import org.springframework.http.MediaType;
import lombok.Data;
import lombok.experimental.UtilityClass;
import stirling.software.common.model.api.converters.EmlToPdfRequest;
@UtilityClass
public class EmlParser {
private static volatile Boolean jakartaMailAvailable = null;
private static volatile Method mimeUtilityDecodeTextMethod = null;
private static volatile boolean mimeUtilityChecked = false;
private static final Pattern MIME_ENCODED_PATTERN =
RegexPatternUtils.getInstance().getMimeEncodedWordPattern();
private static final String DISPOSITION_ATTACHMENT = "attachment";
private static final String TEXT_PLAIN = MediaType.TEXT_PLAIN_VALUE;
private static final String TEXT_HTML = MediaType.TEXT_HTML_VALUE;
private static final String MULTIPART_PREFIX = "multipart/";
private static final String HEADER_CONTENT_TYPE = "content-type:";
private static final String HEADER_CONTENT_DISPOSITION = "content-disposition:";
private static final String HEADER_CONTENT_TRANSFER_ENCODING = "content-transfer-encoding:";
private static final String HEADER_CONTENT_ID = "Content-ID";
private static final String HEADER_SUBJECT = "Subject:";
private static final String HEADER_FROM = "From:";
private static final String HEADER_TO = "To:";
private static final String HEADER_CC = "Cc:";
private static final String HEADER_BCC = "Bcc:";
private static final String HEADER_DATE = "Date:";
private static synchronized boolean isJakartaMailAvailable() {
if (jakartaMailAvailable == null) {
try {
Class.forName("jakarta.mail.internet.MimeMessage");
Class.forName("jakarta.mail.Session");
Class.forName("jakarta.mail.internet.MimeUtility");
Class.forName("jakarta.mail.internet.MimePart");
Class.forName("jakarta.mail.internet.MimeMultipart");
Class.forName("jakarta.mail.Multipart");
Class.forName("jakarta.mail.Part");
jakartaMailAvailable = true;
} catch (ClassNotFoundException e) {
jakartaMailAvailable = false;
}
}
return jakartaMailAvailable;
}
public static EmailContent extractEmailContent(
byte[] emlBytes, EmlToPdfRequest request, CustomHtmlSanitizer customHtmlSanitizer)
throws IOException {
EmlProcessingUtils.validateEmlInput(emlBytes);
if (isJakartaMailAvailable()) {
return extractEmailContentAdvanced(emlBytes, request, customHtmlSanitizer);
} else {
return extractEmailContentBasic(emlBytes, customHtmlSanitizer);
}
}
private static EmailContent extractEmailContentBasic(
byte[] emlBytes, CustomHtmlSanitizer customHtmlSanitizer) {
String emlContent = new String(emlBytes, StandardCharsets.UTF_8);
EmailContent content = new EmailContent();
content.setSubject(extractBasicHeader(emlContent, HEADER_SUBJECT));
content.setFrom(extractBasicHeader(emlContent, HEADER_FROM));
content.setTo(extractBasicHeader(emlContent, HEADER_TO));
content.setCc(extractBasicHeader(emlContent, HEADER_CC));
content.setBcc(extractBasicHeader(emlContent, HEADER_BCC));
String dateStr = extractBasicHeader(emlContent, HEADER_DATE);
if (!dateStr.isEmpty()) {
content.setDateString(dateStr);
}
String htmlBody = extractHtmlBody(emlContent);
if (htmlBody != null) {
content.setHtmlBody(htmlBody);
} else {
String textBody = extractTextBody(emlContent);
content.setTextBody(textBody != null ? textBody : "Email content could not be parsed");
}
content.getAttachments().addAll(extractAttachmentsBasic(emlContent));
return content;
}
private static EmailContent extractEmailContentAdvanced(
byte[] emlBytes, EmlToPdfRequest request, CustomHtmlSanitizer customHtmlSanitizer) {
try {
Class<?> sessionClass = Class.forName("jakarta.mail.Session");
Class<?> mimeMessageClass = Class.forName("jakarta.mail.internet.MimeMessage");
Method getDefaultInstance =
sessionClass.getMethod("getDefaultInstance", Properties.class);
Object session = getDefaultInstance.invoke(null, new Properties());
Class<?>[] constructorArgs = new Class<?>[] {sessionClass, InputStream.class};
Constructor<?> mimeMessageConstructor =
mimeMessageClass.getConstructor(constructorArgs);
Object message =
mimeMessageConstructor.newInstance(session, new ByteArrayInputStream(emlBytes));
return extractFromMimeMessage(message, request, customHtmlSanitizer);
} catch (ReflectiveOperationException e) {
return extractEmailContentBasic(emlBytes, customHtmlSanitizer);
}
}
private static EmailContent extractFromMimeMessage(
Object message, EmlToPdfRequest request, CustomHtmlSanitizer customHtmlSanitizer) {
EmailContent content = new EmailContent();
try {
Class<?> messageClass = message.getClass();
Method getSubject = messageClass.getMethod("getSubject");
String subject = (String) getSubject.invoke(message);
content.setSubject(subject != null ? safeMimeDecode(subject) : "No Subject");
Method getFrom = messageClass.getMethod("getFrom");
Object[] fromAddresses = (Object[]) getFrom.invoke(message);
content.setFrom(buildAddressString(fromAddresses));
extractRecipients(message, messageClass, content);
Method getSentDate = messageClass.getMethod("getSentDate");
Date legacyDate = (Date) getSentDate.invoke(message);
if (legacyDate != null) {
content.setDate(
ZonedDateTime.ofInstant(legacyDate.toInstant(), ZoneId.systemDefault()));
}
Method getContent = messageClass.getMethod("getContent");
Object messageContent = getContent.invoke(message);
processMessageContent(message, messageContent, content, request, customHtmlSanitizer);
} catch (ReflectiveOperationException | RuntimeException e) {
content.setSubject("Email Conversion");
content.setFrom("Unknown");
content.setTo("Unknown");
content.setCc("");
content.setBcc("");
content.setTextBody("Email content could not be parsed with advanced processing");
}
return content;
}
private static void extractRecipients(
Object message, Class<?> messageClass, EmailContent content) {
try {
Method getRecipients =
messageClass.getMethod(
"getRecipients", Class.forName("jakarta.mail.Message$RecipientType"));
Class<?> recipientTypeClass = Class.forName("jakarta.mail.Message$RecipientType");
Object toType = recipientTypeClass.getField("TO").get(null);
Object[] toRecipients = (Object[]) getRecipients.invoke(message, toType);
content.setTo(buildAddressString(toRecipients));
Object ccType = recipientTypeClass.getField("CC").get(null);
Object[] ccRecipients = (Object[]) getRecipients.invoke(message, ccType);
content.setCc(buildAddressString(ccRecipients));
Object bccType = recipientTypeClass.getField("BCC").get(null);
Object[] bccRecipients = (Object[]) getRecipients.invoke(message, bccType);
content.setBcc(buildAddressString(bccRecipients));
} catch (ReflectiveOperationException e) {
try {
Method getAllRecipients = messageClass.getMethod("getAllRecipients");
Object[] recipients = (Object[]) getAllRecipients.invoke(message);
content.setTo(buildAddressString(recipients));
content.setCc("");
content.setBcc("");
} catch (ReflectiveOperationException ex) {
content.setTo("");
content.setCc("");
content.setBcc("");
}
}
}
private static String buildAddressString(Object[] addresses) {
if (addresses == null || addresses.length == 0) {
return "";
}
StringBuilder builder = new StringBuilder();
for (int i = 0; i < addresses.length; i++) {
if (i > 0) builder.append(", ");
builder.append(safeMimeDecode(addresses[i].toString()));
}
return builder.toString();
}
private static void processMessageContent(
Object message,
Object messageContent,
EmailContent content,
EmlToPdfRequest request,
CustomHtmlSanitizer customHtmlSanitizer) {
try {
if (messageContent instanceof String stringContent) {
Method getContentType = message.getClass().getMethod("getContentType");
String contentType = (String) getContentType.invoke(message);
if (contentType != null
&& contentType.toLowerCase(Locale.ROOT).contains(TEXT_HTML)) {
content.setHtmlBody(stringContent);
} else {
content.setTextBody(stringContent);
}
} else {
Class<?> multipartClass = Class.forName("jakarta.mail.Multipart");
if (multipartClass.isInstance(messageContent)) {
processMultipart(messageContent, content, request, customHtmlSanitizer, 0);
}
}
} catch (ReflectiveOperationException | ClassCastException e) {
content.setTextBody("Email content could not be parsed with advanced processing");
}
}
private static void processMultipart(
Object multipart,
EmailContent content,
EmlToPdfRequest request,
CustomHtmlSanitizer customHtmlSanitizer,
int depth) {
final int MAX_MULTIPART_DEPTH = 10;
if (depth > MAX_MULTIPART_DEPTH) {
content.setHtmlBody("<div class=\"error\">Maximum multipart depth exceeded</div>");
return;
}
try {
Class<?> multipartClass = multipart.getClass();
Method getCount = multipartClass.getMethod("getCount");
int count = (Integer) getCount.invoke(multipart);
Method getBodyPart = multipartClass.getMethod("getBodyPart", int.class);
for (int i = 0; i < count; i++) {
Object part = getBodyPart.invoke(multipart, i);
processPart(part, content, request, customHtmlSanitizer, depth + 1);
}
} catch (ReflectiveOperationException | ClassCastException e) {
content.setHtmlBody("<div class=\"error\">Error processing multipart content</div>");
}
}
private static void processPart(
Object part,
EmailContent content,
EmlToPdfRequest request,
CustomHtmlSanitizer customHtmlSanitizer,
int depth) {
try {
Class<?> partClass = part.getClass();
Method isMimeType = partClass.getMethod("isMimeType", String.class);
Method getContent = partClass.getMethod("getContent");
Method getDisposition = partClass.getMethod("getDisposition");
Method getFileName = partClass.getMethod("getFileName");
Method getContentType = partClass.getMethod("getContentType");
Method getHeader = partClass.getMethod("getHeader", String.class);
Object disposition = getDisposition.invoke(part);
String filename = (String) getFileName.invoke(part);
String contentType = (String) getContentType.invoke(part);
String normalizedDisposition =
disposition != null ? ((String) disposition).toLowerCase(Locale.ROOT) : null;
if ((Boolean) isMimeType.invoke(part, TEXT_PLAIN) && normalizedDisposition == null) {
Object partContent = getContent.invoke(part);
if (partContent instanceof String stringContent) {
content.setTextBody(stringContent);
}
} else if ((Boolean) isMimeType.invoke(part, TEXT_HTML)
&& normalizedDisposition == null) {
Object partContent = getContent.invoke(part);
if (partContent instanceof String stringContent) {
String htmlBody =
customHtmlSanitizer != null
? customHtmlSanitizer.sanitize(stringContent)
: stringContent;
content.setHtmlBody(htmlBody);
}
} else if ((normalizedDisposition != null
&& normalizedDisposition.contains(DISPOSITION_ATTACHMENT))
|| (filename != null && !filename.trim().isEmpty())) {
processAttachment(
part, content, request, getHeader, getContent, filename, contentType);
} else if ((Boolean) isMimeType.invoke(part, "multipart/*")) {
Object multipartContent = getContent.invoke(part);
if (multipartContent != null) {
Class<?> multipartClass = Class.forName("jakarta.mail.Multipart");
if (multipartClass.isInstance(multipartContent)) {
processMultipart(
multipartContent, content, request, customHtmlSanitizer, depth + 1);
}
}
}
} catch (ReflectiveOperationException | RuntimeException e) {
// Continue processing other parts if one fails
}
}
private static void processAttachment(
Object part,
EmailContent content,
EmlToPdfRequest request,
Method getHeader,
Method getContent,
String filename,
String contentType) {
content.setAttachmentCount(content.getAttachmentCount() + 1);
if (filename != null && !filename.trim().isEmpty()) {
EmailAttachment attachment = new EmailAttachment();
attachment.setFilename(safeMimeDecode(filename));
attachment.setContentType(contentType);
try {
String[] contentIdHeaders = (String[]) getHeader.invoke(part, HEADER_CONTENT_ID);
if (contentIdHeaders != null) {
for (String contentIdHeader : contentIdHeaders) {
if (contentIdHeader != null && !contentIdHeader.trim().isEmpty()) {
attachment.setEmbedded(true);
String contentId =
RegexPatternUtils.getInstance()
.getAngleBracketsPattern()
.matcher(contentIdHeader.trim())
.replaceAll("");
attachment.setContentId(contentId);
break;
}
}
}
} catch (ReflectiveOperationException e) {
}
if ((request != null && request.isIncludeAttachments()) || attachment.isEmbedded()) {
extractAttachmentData(part, attachment, getContent, request);
}
content.getAttachments().add(attachment);
}
}
private static void extractAttachmentData(
Object part, EmailAttachment attachment, Method getContent, EmlToPdfRequest request) {
try {
Object attachmentContent = getContent.invoke(part);
byte[] attachmentData = null;
if (attachmentContent instanceof InputStream inputStream) {
try (InputStream stream = inputStream) {
attachmentData = stream.readAllBytes();
} catch (IOException e) {
if (attachment.isEmbedded()) {
attachmentData = new byte[0];
} else {
throw new RuntimeException(e);
}
}
} else if (attachmentContent instanceof byte[] byteArray) {
attachmentData = byteArray;
} else if (attachmentContent instanceof String stringContent) {
attachmentData = stringContent.getBytes(StandardCharsets.UTF_8);
}
if (attachmentData != null) {
long maxSizeMB = request != null ? request.getMaxAttachmentSizeMB() : 10L;
long maxSizeBytes = maxSizeMB * 1024 * 1024;
if (attachmentData.length <= maxSizeBytes || attachment.isEmbedded()) {
attachment.setData(attachmentData);
attachment.setSizeBytes(attachmentData.length);
} else {
attachment.setSizeBytes(attachmentData.length);
}
}
} catch (ReflectiveOperationException | RuntimeException e) {
// Continue without attachment data
}
}
private static String extractBasicHeader(String emlContent, String headerName) {
try {
String[] lines =
RegexPatternUtils.getInstance().getNewlineSplitPattern().split(emlContent);
for (int i = 0; i < lines.length; i++) {
String line = lines[i];
if (line.toLowerCase(Locale.ROOT).startsWith(headerName.toLowerCase(Locale.ROOT))) {
StringBuilder value =
new StringBuilder(line.substring(headerName.length()).trim());
for (int j = i + 1; j < lines.length; j++) {
if (lines[j].startsWith(" ") || lines[j].startsWith("\t")) {
value.append(" ").append(lines[j].trim());
} else {
break;
}
}
return safeMimeDecode(value.toString());
}
if (line.trim().isEmpty()) break;
}
} catch (RuntimeException e) {
// Ignore errors in header extraction
}
return "";
}
private static String extractHtmlBody(String emlContent) {
try {
String lowerContent = emlContent.toLowerCase(Locale.ROOT);
int htmlStart = lowerContent.indexOf(HEADER_CONTENT_TYPE + " " + TEXT_HTML);
if (htmlStart == -1) return null;
int bodyStart = emlContent.indexOf("\r\n\r\n", htmlStart);
if (bodyStart == -1) bodyStart = emlContent.indexOf("\n\n", htmlStart);
if (bodyStart == -1) return null;
bodyStart += (emlContent.charAt(bodyStart + 1) == '\r') ? 4 : 2;
int bodyEnd = findPartEnd(emlContent, bodyStart);
return emlContent.substring(bodyStart, bodyEnd).trim();
} catch (Exception e) {
return null;
}
}
private static String extractTextBody(String emlContent) {
try {
String lowerContent = emlContent.toLowerCase(Locale.ROOT);
int textStart = lowerContent.indexOf(HEADER_CONTENT_TYPE + " " + TEXT_PLAIN);
if (textStart == -1) {
int bodyStart = emlContent.indexOf("\r\n\r\n");
if (bodyStart == -1) bodyStart = emlContent.indexOf("\n\n");
if (bodyStart != -1) {
bodyStart += (emlContent.charAt(bodyStart + 1) == '\r') ? 4 : 2;
int bodyEnd = findPartEnd(emlContent, bodyStart);
return emlContent.substring(bodyStart, bodyEnd).trim();
}
return null;
}
int bodyStart = emlContent.indexOf("\r\n\r\n", textStart);
if (bodyStart == -1) bodyStart = emlContent.indexOf("\n\n", textStart);
if (bodyStart == -1) return null;
bodyStart += (emlContent.charAt(bodyStart + 1) == '\r') ? 4 : 2;
int bodyEnd = findPartEnd(emlContent, bodyStart);
return emlContent.substring(bodyStart, bodyEnd).trim();
} catch (RuntimeException e) {
return null;
}
}
private static int findPartEnd(String content, int start) {
String[] lines =
RegexPatternUtils.getInstance()
.getNewlineSplitPattern()
.split(content.substring(start));
StringBuilder result = new StringBuilder();
for (String line : lines) {
if (line.startsWith("--") && line.length() > 10) break;
result.append(line).append("\n");
}
return start + result.length();
}
private static List<EmailAttachment> extractAttachmentsBasic(String emlContent) {
List<EmailAttachment> attachments = new ArrayList<>();
try {
String[] lines =
RegexPatternUtils.getInstance().getNewlineSplitPattern().split(emlContent);
boolean inHeaders = true;
String currentContentType = "";
String currentDisposition = "";
String currentFilename = "";
String currentEncoding = "";
for (String line : lines) {
String lowerLine = line.toLowerCase(Locale.ROOT).trim();
if (line.trim().isEmpty()) {
inHeaders = false;
if (isAttachment(currentDisposition, currentFilename, currentContentType)) {
EmailAttachment attachment = new EmailAttachment();
attachment.setFilename(currentFilename);
attachment.setContentType(currentContentType);
attachment.setTransferEncoding(currentEncoding);
attachments.add(attachment);
}
currentContentType = "";
currentDisposition = "";
currentFilename = "";
currentEncoding = "";
inHeaders = true;
continue;
}
if (!inHeaders) continue;
if (lowerLine.startsWith(HEADER_CONTENT_TYPE)) {
currentContentType = line.substring(HEADER_CONTENT_TYPE.length()).trim();
} else if (lowerLine.startsWith(HEADER_CONTENT_DISPOSITION)) {
currentDisposition = line.substring(HEADER_CONTENT_DISPOSITION.length()).trim();
currentFilename = extractFilenameFromDisposition(currentDisposition);
} else if (lowerLine.startsWith(HEADER_CONTENT_TRANSFER_ENCODING)) {
currentEncoding =
line.substring(HEADER_CONTENT_TRANSFER_ENCODING.length()).trim();
}
}
} catch (RuntimeException e) {
// Continue with empty list
}
return attachments;
}
private static boolean isAttachment(String disposition, String filename, String contentType) {
return (disposition.toLowerCase(Locale.ROOT).contains(DISPOSITION_ATTACHMENT)
&& !filename.isEmpty())
|| (!filename.isEmpty()
&& !contentType.toLowerCase(Locale.ROOT).startsWith("text/"))
|| (contentType.toLowerCase(Locale.ROOT).contains("application/")
&& !filename.isEmpty());
}
private static String extractFilenameFromDisposition(String disposition) {
if (disposition == null || !disposition.contains("filename=")) {
return "";
}
// Handle filename*= (RFC 2231 encoded filename)
if (disposition.toLowerCase(Locale.ROOT).contains("filename*=")) {
int filenameStarStart = disposition.toLowerCase(Locale.ROOT).indexOf("filename*=") + 10;
int filenameStarEnd = disposition.indexOf(";", filenameStarStart);
if (filenameStarEnd == -1) filenameStarEnd = disposition.length();
String extendedFilename =
disposition.substring(filenameStarStart, filenameStarEnd).trim();
extendedFilename =
RegexPatternUtils.getInstance()
.getQuotesRemovalPattern()
.matcher(extendedFilename)
.replaceAll("");
if (extendedFilename.contains("'")) {
String[] parts = extendedFilename.split("'", 3);
if (parts.length == 3) {
return EmlProcessingUtils.decodeUrlEncoded(parts[2]);
}
}
}
// Handle regular filename=
int filenameStart = disposition.toLowerCase(Locale.ROOT).indexOf("filename=") + 9;
int filenameEnd = disposition.indexOf(";", filenameStart);
if (filenameEnd == -1) filenameEnd = disposition.length();
String filename = disposition.substring(filenameStart, filenameEnd).trim();
filename =
RegexPatternUtils.getInstance()
.getQuotesRemovalPattern()
.matcher(filename)
.replaceAll("");
return safeMimeDecode(filename);
}
public static String safeMimeDecode(String headerValue) {
if (headerValue == null || headerValue.trim().isEmpty()) {
return "";
}
if (!mimeUtilityChecked) {
synchronized (EmlParser.class) {
if (!mimeUtilityChecked) {
initializeMimeUtilityDecoding();
}
}
}
if (mimeUtilityDecodeTextMethod != null) {
try {
return (String) mimeUtilityDecodeTextMethod.invoke(null, headerValue.trim());
} catch (ReflectiveOperationException | RuntimeException e) {
// Fall through to custom implementation
}
}
return EmlProcessingUtils.decodeMimeHeader(headerValue.trim());
}
private static void initializeMimeUtilityDecoding() {
try {
Class<?> mimeUtilityClass = Class.forName("jakarta.mail.internet.MimeUtility");
mimeUtilityDecodeTextMethod = mimeUtilityClass.getMethod("decodeText", String.class);
} catch (ClassNotFoundException | NoSuchMethodException e) {
mimeUtilityDecodeTextMethod = null;
}
mimeUtilityChecked = true;
}
@Data
public static class EmailContent {
private String subject;
private String from;
private String to;
private String cc;
private String bcc;
private ZonedDateTime date;
private String dateString; // For basic parsing fallback
private String htmlBody;
private String textBody;
private int attachmentCount;
private List<EmailAttachment> attachments = new ArrayList<>();
public void setHtmlBody(String htmlBody) {
this.htmlBody =
htmlBody != null
? RegexPatternUtils.getInstance()
.getCarriageReturnPattern()
.matcher(htmlBody)
.replaceAll("")
: null;
}
public void setTextBody(String textBody) {
this.textBody =
textBody != null
? RegexPatternUtils.getInstance()
.getCarriageReturnPattern()
.matcher(textBody)
.replaceAll("")
: null;
}
}
@Data
public static class EmailAttachment {
private String filename;
private String contentType;
private byte[] data;
private boolean embedded;
private String embeddedFilename;
private long sizeBytes;
private String contentId;
private String disposition;
private String transferEncoding;
public void setData(byte[] data) {
this.data = data;
if (data != null) {
this.sizeBytes = data.length;
}
}
}
}
@@ -1,642 +0,0 @@
package stirling.software.common.util;
import java.nio.charset.Charset;
import java.nio.charset.StandardCharsets;
import java.util.Base64;
import java.util.Locale;
import java.util.Map;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import org.springframework.http.MediaType;
import lombok.experimental.UtilityClass;
import stirling.software.common.model.api.converters.EmlToPdfRequest;
import stirling.software.common.model.api.converters.HTMLToPdfRequest;
@UtilityClass
public class EmlProcessingUtils {
// Style constants
private static final int DEFAULT_FONT_SIZE = 12;
private static final String DEFAULT_FONT_FAMILY = "Helvetica, sans-serif";
private static final float DEFAULT_LINE_HEIGHT = 1.4f;
private static final String DEFAULT_ZOOM = "1.0";
private static final String DEFAULT_TEXT_COLOR = "#202124";
private static final String DEFAULT_BACKGROUND_COLOR = "#ffffff";
private static final String DEFAULT_BORDER_COLOR = "#e8eaed";
private static final String ATTACHMENT_BACKGROUND_COLOR = "#f9f9f9";
private static final String ATTACHMENT_BORDER_COLOR = "#eeeeee";
private static final int EML_CHECK_LENGTH = 8192;
private static final int MIN_HEADER_COUNT_FOR_VALID_EML = 2;
// MIME type detection
private static final Map<String, String> EXTENSION_TO_MIME_TYPE =
Map.of(
".png", MediaType.IMAGE_PNG_VALUE,
".jpg", MediaType.IMAGE_JPEG_VALUE,
".jpeg", MediaType.IMAGE_JPEG_VALUE,
".gif", MediaType.IMAGE_GIF_VALUE,
".bmp", "image/bmp",
".webp", "image/webp",
".svg", "image/svg+xml",
".ico", "image/x-icon",
".tiff", "image/tiff",
".tif", "image/tiff");
public static void validateEmlInput(byte[] emlBytes) {
if (emlBytes == null || emlBytes.length == 0) {
throw ExceptionUtils.createEmlEmptyException();
}
if (isInvalidEmlFormat(emlBytes)) {
throw ExceptionUtils.createEmlInvalidFormatException();
}
}
private static boolean isInvalidEmlFormat(byte[] emlBytes) {
try {
int checkLength = Math.min(emlBytes.length, EML_CHECK_LENGTH);
String content;
try {
content = new String(emlBytes, 0, checkLength, StandardCharsets.UTF_8);
if (content.contains("\uFFFD")) {
content = new String(emlBytes, 0, checkLength, StandardCharsets.ISO_8859_1);
}
} catch (Exception e) {
content = new String(emlBytes, 0, checkLength, StandardCharsets.ISO_8859_1);
}
String lowerContent = content.toLowerCase(Locale.ROOT);
boolean hasFrom =
lowerContent.contains("from:") || lowerContent.contains("return-path:");
boolean hasSubject = lowerContent.contains("subject:");
boolean hasMessageId = lowerContent.contains("message-id:");
boolean hasDate = lowerContent.contains("date:");
boolean hasTo =
lowerContent.contains("to:")
|| lowerContent.contains("cc:")
|| lowerContent.contains("bcc:");
boolean hasMimeStructure =
lowerContent.contains("multipart/")
|| lowerContent.contains(MediaType.TEXT_PLAIN_VALUE)
|| lowerContent.contains(MediaType.TEXT_HTML_VALUE)
|| lowerContent.contains("boundary=");
int headerCount = 0;
if (hasFrom) headerCount++;
if (hasSubject) headerCount++;
if (hasMessageId) headerCount++;
if (hasDate) headerCount++;
if (hasTo) headerCount++;
return headerCount < MIN_HEADER_COUNT_FOR_VALID_EML && !hasMimeStructure;
} catch (RuntimeException e) {
return false;
}
}
public static String generateEnhancedEmailHtml(
EmlParser.EmailContent content,
EmlToPdfRequest request,
CustomHtmlSanitizer customHtmlSanitizer) {
StringBuilder html = new StringBuilder();
html.append(
String.format(
Locale.ROOT,
"""
<!DOCTYPE html>
<html lang="en"><head><meta charset="UTF-8">
<title>%s</title>
<style>
""",
sanitizeText(content.getSubject(), customHtmlSanitizer)));
appendEnhancedStyles(html);
html.append(
"""
</style>
</head><body>
""");
html.append(
String.format(
Locale.ROOT,
"""
<div class="email-container">
<div class="email-header">
<h1>%s</h1>
<div class="email-meta">
<div><strong>From:</strong> %s</div>
<div><strong>To:</strong> %s</div>
""",
sanitizeText(content.getSubject(), customHtmlSanitizer),
sanitizeText(content.getFrom(), customHtmlSanitizer),
sanitizeText(content.getTo(), customHtmlSanitizer)));
if (content.getCc() != null && !content.getCc().trim().isEmpty()) {
html.append(
String.format(
Locale.ROOT,
"<div><strong>CC:</strong> %s</div>\n",
sanitizeText(content.getCc(), customHtmlSanitizer)));
}
if (content.getBcc() != null && !content.getBcc().trim().isEmpty()) {
html.append(
String.format(
Locale.ROOT,
"<div><strong>BCC:</strong> %s</div>\n",
sanitizeText(content.getBcc(), customHtmlSanitizer)));
}
if (content.getDate() != null) {
html.append(
String.format(
Locale.ROOT,
"<div><strong>Date:</strong> %s</div>\n",
PdfAttachmentHandler.formatEmailDate(content.getDate())));
} else if (content.getDateString() != null && !content.getDateString().trim().isEmpty()) {
html.append(
String.format(
Locale.ROOT,
"<div><strong>Date:</strong> %s</div>\n",
sanitizeText(content.getDateString(), customHtmlSanitizer)));
}
html.append("</div></div>\n");
html.append("<div class=\"email-body\">\n");
if (content.getHtmlBody() != null && !content.getHtmlBody().trim().isEmpty()) {
String processedHtml =
processEmailHtmlBody(content.getHtmlBody(), content, customHtmlSanitizer);
html.append(processedHtml);
} else if (content.getTextBody() != null && !content.getTextBody().trim().isEmpty()) {
html.append(
String.format(
Locale.ROOT,
"<div class=\"text-body\">%s</div>",
convertTextToHtml(content.getTextBody(), customHtmlSanitizer)));
} else {
html.append("<div class=\"no-content\"><p><em>No content available</em></p></div>");
}
html.append("</div>\n");
if (content.getAttachmentCount() > 0 || !content.getAttachments().isEmpty()) {
appendAttachmentsSection(html, content, request, customHtmlSanitizer);
}
html.append("</div>\n</body></html>");
return html.toString();
}
public static String processEmailHtmlBody(
String htmlBody,
EmlParser.EmailContent emailContent,
CustomHtmlSanitizer customHtmlSanitizer) {
if (htmlBody == null) return "";
String processed =
customHtmlSanitizer != null ? customHtmlSanitizer.sanitize(htmlBody) : htmlBody;
processed =
RegexPatternUtils.getInstance()
.getFixedPositionCssPattern()
.matcher(processed)
.replaceAll("");
processed =
RegexPatternUtils.getInstance()
.getAbsolutePositionCssPattern()
.matcher(processed)
.replaceAll("");
if (emailContent != null && !emailContent.getAttachments().isEmpty()) {
processed = PdfAttachmentHandler.processInlineImages(processed, emailContent);
}
return processed;
}
public static String convertTextToHtml(
String textBody, CustomHtmlSanitizer customHtmlSanitizer) {
if (textBody == null) return "";
String html =
customHtmlSanitizer != null
? customHtmlSanitizer.sanitize(textBody)
: escapeHtml(textBody);
html = html.replace("\r\n", "\n").replace("\r", "\n");
html = html.replace("\n", "<br>\n");
html =
RegexPatternUtils.getInstance()
.getUrlLinkPattern()
.matcher(html)
.replaceAll(
"<a href=\"$1\" style=\"color: #1a73e8; text-decoration:"
+ " underline;\">$1</a>");
html =
RegexPatternUtils.getInstance()
.getEmailLinkPattern()
.matcher(html)
.replaceAll(
"<a href=\"mailto:$1\" style=\"color: #1a73e8; text-decoration:"
+ " underline;\">$1</a>");
return html;
}
private static void appendEnhancedStyles(StringBuilder html) {
String css =
String.format(
Locale.ROOT,
"""
body {
font-family: %s;
font-size: %dpx;
line-height: %s;
color: %s;
margin: 0;
padding: 16px;
background-color: %s;
}
.email-container {
width: 100%%;
max-width: 100%%;
margin: 0 auto;
}
.email-header {
padding-bottom: 10px;
border-bottom: 1px solid %s;
margin-bottom: 10px;
}
.email-header h1 {
margin: 0 0 10px 0;
font-size: %dpx;
font-weight: bold;
}
.email-meta div {
margin-bottom: 2px;
font-size: %dpx;
}
.email-body {
word-wrap: break-word;
}
.attachment-section {
margin-top: 15px;
padding: 10px;
background-color: %s;
border: 1px solid %s;
border-radius: 3px;
}
.attachment-section h3 {
margin: 0 0 8px 0;
font-size: %dpx;
}
.attachment-item {
padding: 5px 0;
}
.attachment-icon {
margin-right: 5px;
}
.attachment-details, .attachment-type {
font-size: %dpx;
color: #555555;
}
.attachment-inclusion-note, .attachment-info-note {
margin-top: 8px;
padding: 6px;
font-size: %dpx;
border-radius: 3px;
}
.attachment-inclusion-note {
background-color: #e6ffed;
border: 1px solid #d4f7dc;
color: #006420;
}
.attachment-info-note {
background-color: #fff9e6;
border: 1px solid #fff0c2;
color: #664d00;
}
.attachment-link-container {
display: flex;
align-items: center;
padding: 8px;
background-color: #f8f9fa;
border: 1px solid #dee2e6;
border-radius: 4px;
margin: 4px 0;
}
.attachment-link-container:hover {
background-color: #e9ecef;
}
.attachment-note {
font-size: %dpx;
color: #6c757d;
font-style: italic;
margin-left: 8px;
}
.no-content {
padding: 20px;
text-align: center;
color: #666;
font-style: italic;
}
.text-body {
white-space: pre-wrap;
}
img {
max-width: 100%%;
height: auto;
display: block;
}
""",
DEFAULT_FONT_FAMILY,
DEFAULT_FONT_SIZE,
DEFAULT_LINE_HEIGHT,
DEFAULT_TEXT_COLOR,
DEFAULT_BACKGROUND_COLOR,
DEFAULT_BORDER_COLOR,
DEFAULT_FONT_SIZE + 4,
DEFAULT_FONT_SIZE - 1,
ATTACHMENT_BACKGROUND_COLOR,
ATTACHMENT_BORDER_COLOR,
DEFAULT_FONT_SIZE + 1,
DEFAULT_FONT_SIZE - 2,
DEFAULT_FONT_SIZE - 2,
DEFAULT_FONT_SIZE - 3);
html.append(css);
}
private static void appendAttachmentsSection(
StringBuilder html,
EmlParser.EmailContent content,
EmlToPdfRequest request,
CustomHtmlSanitizer customHtmlSanitizer) {
html.append("<div class=\"attachment-section\">\n");
int displayedAttachmentCount =
content.getAttachmentCount() > 0
? content.getAttachmentCount()
: content.getAttachments().size();
html.append("<h3>Attachments (").append(displayedAttachmentCount).append(")</h3>\n");
if (!content.getAttachments().isEmpty()) {
for (int i = 0; i < content.getAttachments().size(); i++) {
EmlParser.EmailAttachment attachment = content.getAttachments().get(i);
String embeddedFilename =
attachment.getFilename() != null
? attachment.getFilename()
: ("attachment_" + i);
attachment.setEmbeddedFilename(embeddedFilename);
String sizeStr = GeneralUtils.formatBytes(attachment.getSizeBytes());
String contentType =
attachment.getContentType() != null
&& !attachment.getContentType().isEmpty()
? ", " + escapeHtml(attachment.getContentType())
: "";
String attachmentId = "attachment_" + i;
html.append(
String.format(
Locale.ROOT,
"""
<div class="attachment-item" id="%s">
<span class="attachment-icon" data-filename="%s">@</span>
<span class="attachment-name">%s</span>
<span class="attachment-details">(%s%s)</span>
</div>
""",
attachmentId,
escapeHtml(embeddedFilename),
escapeHtml(EmlParser.safeMimeDecode(attachment.getFilename())),
sizeStr,
contentType));
}
}
if (request != null && request.isIncludeAttachments()) {
html.append(
"""
<div class="attachment-info-note">
<p><em>Attachments are embedded in the file.</em></p>
</div>
""");
} else {
html.append(
"""
<div class="attachment-info-note">
<p><em>Attachment information displayed - files not included in PDF.</em></p>
</div>
""");
}
html.append("</div>\n");
}
public static HTMLToPdfRequest createHtmlRequest(EmlToPdfRequest request) {
HTMLToPdfRequest htmlRequest = new HTMLToPdfRequest();
if (request != null) {
htmlRequest.setFileInput(request.getFileInput());
}
htmlRequest.setZoom(Float.parseFloat(DEFAULT_ZOOM));
return htmlRequest;
}
public static String detectMimeType(String filename, String existingMimeType) {
if (existingMimeType != null && !existingMimeType.isEmpty()) {
return existingMimeType;
}
if (filename != null) {
String lowerFilename = filename.toLowerCase(Locale.ROOT);
for (Map.Entry<String, String> entry : EXTENSION_TO_MIME_TYPE.entrySet()) {
if (lowerFilename.endsWith(entry.getKey())) {
return entry.getValue();
}
}
}
return MediaType.IMAGE_PNG_VALUE; // Default MIME type
}
public static String decodeUrlEncoded(String encoded) {
try {
return java.net.URLDecoder.decode(encoded, StandardCharsets.UTF_8);
} catch (Exception e) {
return encoded; // Return original if decoding fails
}
}
public static String decodeMimeHeader(String encodedText) {
if (encodedText == null || encodedText.trim().isEmpty()) {
return encodedText;
}
try {
StringBuilder result = new StringBuilder();
Pattern concatenatedPattern =
Pattern.compile(
"(=\\?[^?]+\\?[BbQq]\\?[^?]*\\?=)(\\s*=\\?[^?]+\\?[BbQq]\\?[^?]*\\?=)+");
Matcher concatenatedMatcher = concatenatedPattern.matcher(encodedText);
String processedText =
concatenatedMatcher.replaceAll(
match ->
RegexPatternUtils.getInstance()
.getMimeHeaderWhitespacePattern()
.matcher(match.group())
.replaceAll(""));
Pattern mimePattern = RegexPatternUtils.getInstance().getMimeEncodedWordPattern();
Matcher matcher = mimePattern.matcher(processedText);
int lastEnd = 0;
while (matcher.find()) {
result.append(processedText, lastEnd, matcher.start());
String charset = matcher.group(1);
String encoding = matcher.group(2).toUpperCase(Locale.ROOT);
String encodedValue = matcher.group(3);
try {
String decodedValue =
switch (encoding) {
case "B" -> {
String cleanBase64 =
RegexPatternUtils.getInstance()
.getWhitespacePattern()
.matcher(encodedValue)
.replaceAll("");
byte[] decodedBytes = Base64.getDecoder().decode(cleanBase64);
Charset targetCharset;
try {
targetCharset = Charset.forName(charset);
} catch (Exception e) {
targetCharset = StandardCharsets.UTF_8;
}
yield new String(decodedBytes, targetCharset);
}
case "Q" -> decodeQuotedPrintable(encodedValue, charset);
default -> matcher.group(0); // Return original if unknown encoding
};
result.append(decodedValue);
} catch (RuntimeException e) {
result.append(matcher.group(0)); // Keep original on decode error
}
lastEnd = matcher.end();
}
result.append(processedText.substring(lastEnd));
return result.toString();
} catch (Exception e) {
return encodedText; // Return original on any parsing error
}
}
private static String decodeQuotedPrintable(String encodedText, String charset) {
StringBuilder result = new StringBuilder();
for (int i = 0; i < encodedText.length(); i++) {
char c = encodedText.charAt(i);
switch (c) {
case '=' -> {
if (i + 2 < encodedText.length()) {
String hex = encodedText.substring(i + 1, i + 3);
try {
int value = Integer.parseInt(hex, 16);
result.append((char) value);
i += 2;
} catch (NumberFormatException e) {
result.append(c);
}
} else if (i + 1 == encodedText.length()
|| (i + 2 == encodedText.length()
&& encodedText.charAt(i + 1) == '\n')) {
if (i + 1 < encodedText.length() && encodedText.charAt(i + 1) == '\n') {
i++; // Skip the newline too
}
} else {
result.append(c);
}
}
case '_' -> result.append(' '); // Space encoding in Q encoding
default -> result.append(c);
}
}
byte[] bytes = result.toString().getBytes(StandardCharsets.ISO_8859_1);
try {
Charset targetCharset = Charset.forName(charset);
return new String(bytes, targetCharset);
} catch (Exception e) {
try {
return new String(bytes, StandardCharsets.UTF_8);
} catch (Exception fallbackException) {
return new String(bytes, StandardCharsets.ISO_8859_1);
}
}
}
public static String escapeHtml(String text) {
if (text == null) return "";
return text.replace("&", "&amp;")
.replace("<", "&lt;")
.replace(">", "&gt;")
.replace("\"", "&quot;")
.replace("'", "&#39;");
}
public static String sanitizeText(String text, CustomHtmlSanitizer customHtmlSanitizer) {
if (customHtmlSanitizer != null) {
return customHtmlSanitizer.sanitize(text);
} else {
return escapeHtml(text);
}
}
public static String simplifyHtmlContent(String htmlContent) {
String simplified =
RegexPatternUtils.getInstance()
.getScriptTagPattern()
.matcher(htmlContent)
.replaceAll("");
simplified =
RegexPatternUtils.getInstance()
.getStyleTagPattern()
.matcher(simplified)
.replaceAll("");
return simplified;
}
}
@@ -1,103 +0,0 @@
package stirling.software.common.util;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import lombok.experimental.UtilityClass;
import stirling.software.common.model.api.converters.EmlToPdfRequest;
import stirling.software.common.service.CustomPDFDocumentFactory;
@UtilityClass
public class EmlToPdf {
public static String convertEmlToHtml(byte[] emlBytes, EmlToPdfRequest request)
throws IOException {
EmlProcessingUtils.validateEmlInput(emlBytes);
EmlParser.EmailContent emailContent =
EmlParser.extractEmailContent(emlBytes, request, null);
return EmlProcessingUtils.generateEnhancedEmailHtml(emailContent, request, null);
}
public static byte[] convertEmlToPdf(
String weasyprintPath,
EmlToPdfRequest request,
byte[] emlBytes,
String fileName,
CustomPDFDocumentFactory pdfDocumentFactory,
TempFileManager tempFileManager,
CustomHtmlSanitizer customHtmlSanitizer)
throws IOException, InterruptedException {
EmlProcessingUtils.validateEmlInput(emlBytes);
try {
EmlParser.EmailContent emailContent =
EmlParser.extractEmailContent(emlBytes, request, customHtmlSanitizer);
String htmlContent =
EmlProcessingUtils.generateEnhancedEmailHtml(
emailContent, request, customHtmlSanitizer);
byte[] pdfBytes =
convertHtmlToPdf(
weasyprintPath,
request,
htmlContent,
tempFileManager,
customHtmlSanitizer);
if (shouldAttachFiles(emailContent, request)) {
pdfBytes =
PdfAttachmentHandler.attachFilesToPdf(
pdfBytes, emailContent.getAttachments(), pdfDocumentFactory);
}
return pdfBytes;
} catch (IOException | InterruptedException e) {
throw e;
} catch (Exception e) {
throw new IOException("Error converting EML to PDF", e);
}
}
private static boolean shouldAttachFiles(
EmlParser.EmailContent emailContent, EmlToPdfRequest request) {
return emailContent != null
&& request != null
&& request.isIncludeAttachments()
&& !emailContent.getAttachments().isEmpty();
}
private static byte[] convertHtmlToPdf(
String weasyprintPath,
EmlToPdfRequest request,
String htmlContent,
TempFileManager tempFileManager,
CustomHtmlSanitizer customHtmlSanitizer)
throws IOException, InterruptedException {
var htmlRequest = EmlProcessingUtils.createHtmlRequest(request);
try {
return FileToPdf.convertHtmlToPdf(
weasyprintPath,
htmlRequest,
htmlContent.getBytes(StandardCharsets.UTF_8),
"email.html",
tempFileManager,
customHtmlSanitizer);
} catch (IOException | InterruptedException e) {
String simplifiedHtml = EmlProcessingUtils.simplifyHtmlContent(htmlContent);
return FileToPdf.convertHtmlToPdf(
weasyprintPath,
htmlRequest,
simplifiedHtml.getBytes(StandardCharsets.UTF_8),
"email.html",
tempFileManager,
customHtmlSanitizer);
}
}
}
File diff suppressed because it is too large Load Diff
@@ -1,658 +0,0 @@
package stirling.software.common.util;
import java.io.IOException;
import java.lang.reflect.Method;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import org.apache.pdfbox.cos.COSName;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDDocumentCatalog;
import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.pdmodel.PDResources;
import org.apache.pdfbox.pdmodel.common.PDRectangle;
import org.apache.pdfbox.pdmodel.font.PDType1Font;
import org.apache.pdfbox.pdmodel.font.Standard14Fonts;
import org.apache.pdfbox.pdmodel.interactive.annotation.PDAnnotation;
import org.apache.pdfbox.pdmodel.interactive.annotation.PDAnnotationWidget;
import org.apache.pdfbox.pdmodel.interactive.form.*;
import lombok.extern.slf4j.Slf4j;
@Slf4j
public final class FormUtils {
private FormUtils() {}
public static boolean hasAnyRotatedPage(PDDocument document) {
try {
for (PDPage page : document.getPages()) {
int rot = page.getRotation();
int norm = ((rot % 360) + 360) % 360;
if (norm != 0) {
return true;
}
}
} catch (Exception e) {
log.warn("Failed to inspect page rotations: {}", e.getMessage(), e);
}
return false;
}
public static void copyAndTransformFormFields(
PDDocument sourceDocument,
PDDocument newDocument,
int totalPages,
int pagesPerSheet,
int cols,
int rows,
float cellWidth,
float cellHeight)
throws IOException {
PDDocumentCatalog sourceCatalog = sourceDocument.getDocumentCatalog();
PDAcroForm sourceAcroForm = sourceCatalog.getAcroForm();
if (sourceAcroForm == null || sourceAcroForm.getFields().isEmpty()) {
return;
}
PDDocumentCatalog newCatalog = newDocument.getDocumentCatalog();
PDAcroForm newAcroForm = new PDAcroForm(newDocument);
newCatalog.setAcroForm(newAcroForm);
PDResources dr = new PDResources();
PDType1Font helvetica = new PDType1Font(Standard14Fonts.FontName.HELVETICA);
PDType1Font zapfDingbats = new PDType1Font(Standard14Fonts.FontName.ZAPF_DINGBATS);
dr.put(COSName.getPDFName("Helv"), helvetica);
dr.put(COSName.getPDFName("ZaDb"), zapfDingbats);
newAcroForm.setDefaultResources(dr);
newAcroForm.setDefaultAppearance("/Helv 12 Tf 0 g");
// Do not mutate the source AcroForm; skip bad widgets during copy
newAcroForm.setNeedAppearances(true);
Map<String, Integer> fieldNameCounters = new HashMap<>();
// Build widget -> field map once for efficient lookups
Map<PDAnnotationWidget, PDField> widgetFieldMap = buildWidgetFieldMap(sourceAcroForm);
for (int pageIndex = 0; pageIndex < totalPages; pageIndex++) {
PDPage sourcePage = sourceDocument.getPage(pageIndex);
List<PDAnnotation> annotations = sourcePage.getAnnotations();
if (annotations.isEmpty()) {
continue;
}
int destinationPageIndex = pageIndex / pagesPerSheet;
int adjustedPageIndex = pageIndex % pagesPerSheet;
int rowIndex = adjustedPageIndex / cols;
int colIndex = adjustedPageIndex % cols;
if (destinationPageIndex >= newDocument.getNumberOfPages()) {
continue;
}
PDPage destinationPage = newDocument.getPage(destinationPageIndex);
PDRectangle sourceRect = sourcePage.getMediaBox();
float scaleWidth = cellWidth / sourceRect.getWidth();
float scaleHeight = cellHeight / sourceRect.getHeight();
float scale = Math.min(scaleWidth, scaleHeight);
float x = colIndex * cellWidth + (cellWidth - sourceRect.getWidth() * scale) / 2;
float y =
destinationPage.getMediaBox().getHeight()
- ((rowIndex + 1) * cellHeight
- (cellHeight - sourceRect.getHeight() * scale) / 2);
copyBasicFormFields(
sourceAcroForm,
newAcroForm,
sourcePage,
destinationPage,
x,
y,
scale,
pageIndex,
fieldNameCounters,
widgetFieldMap);
}
// Refresh appearances to ensure widgets render correctly across viewers
try {
// Use reflection to avoid compile-time dependency on PDFBox version
Method m = newAcroForm.getClass().getMethod("refreshAppearances");
m.invoke(newAcroForm);
} catch (NoSuchMethodException nsme) {
log.warn(
"AcroForm.refreshAppearances() not available in this PDFBox version; relying on NeedAppearances.");
} catch (Throwable t) {
log.warn("Failed to refresh field appearances via AcroForm: {}", t.getMessage(), t);
}
}
private static void copyBasicFormFields(
PDAcroForm sourceAcroForm,
PDAcroForm newAcroForm,
PDPage sourcePage,
PDPage destinationPage,
float offsetX,
float offsetY,
float scale,
int pageIndex,
Map<String, Integer> fieldNameCounters,
Map<PDAnnotationWidget, PDField> widgetFieldMap) {
try {
List<PDAnnotation> sourceAnnotations = sourcePage.getAnnotations();
List<PDAnnotation> destinationAnnotations = destinationPage.getAnnotations();
for (PDAnnotation annotation : sourceAnnotations) {
if (annotation instanceof PDAnnotationWidget widgetAnnotation) {
if (widgetAnnotation.getRectangle() == null) {
continue;
}
PDField sourceField =
widgetFieldMap != null ? widgetFieldMap.get(widgetAnnotation) : null;
if (sourceField == null) {
continue; // skip widgets without a matching field
}
if (sourceField instanceof PDTextField pdtextfield) {
createSimpleTextField(
newAcroForm,
destinationPage,
destinationAnnotations,
pdtextfield,
widgetAnnotation,
offsetX,
offsetY,
scale,
pageIndex,
fieldNameCounters);
} else if (sourceField instanceof PDCheckBox pdCheckBox) {
createSimpleCheckBoxField(
newAcroForm,
destinationPage,
destinationAnnotations,
pdCheckBox,
widgetAnnotation,
offsetX,
offsetY,
scale,
pageIndex,
fieldNameCounters);
} else if (sourceField instanceof PDRadioButton pdRadioButton) {
createSimpleRadioButtonField(
newAcroForm,
destinationPage,
destinationAnnotations,
pdRadioButton,
widgetAnnotation,
offsetX,
offsetY,
scale,
pageIndex,
fieldNameCounters);
} else if (sourceField instanceof PDComboBox pdComboBox) {
createSimpleComboBoxField(
newAcroForm,
destinationPage,
destinationAnnotations,
pdComboBox,
widgetAnnotation,
offsetX,
offsetY,
scale,
pageIndex,
fieldNameCounters);
} else if (sourceField instanceof PDListBox pdlistbox) {
createSimpleListBoxField(
newAcroForm,
destinationPage,
destinationAnnotations,
pdlistbox,
widgetAnnotation,
offsetX,
offsetY,
scale,
pageIndex,
fieldNameCounters);
} else if (sourceField instanceof PDSignatureField pdSignatureField) {
createSimpleSignatureField(
newAcroForm,
destinationPage,
destinationAnnotations,
pdSignatureField,
widgetAnnotation,
offsetX,
offsetY,
scale,
pageIndex,
fieldNameCounters);
} else if (sourceField instanceof PDPushButton pdPushButton) {
createSimplePushButtonField(
newAcroForm,
destinationPage,
destinationAnnotations,
pdPushButton,
widgetAnnotation,
offsetX,
offsetY,
scale,
pageIndex,
fieldNameCounters);
}
}
}
} catch (Exception e) {
log.warn(
"Failed to copy basic form fields for page {}: {}",
pageIndex,
e.getMessage(),
e);
}
}
private static void createSimpleTextField(
PDAcroForm newAcroForm,
PDPage destinationPage,
List<PDAnnotation> destinationAnnotations,
PDTextField sourceField,
PDAnnotationWidget sourceWidget,
float offsetX,
float offsetY,
float scale,
int pageIndex,
Map<String, Integer> fieldNameCounters) {
try {
PDTextField newTextField = new PDTextField(newAcroForm);
newTextField.setDefaultAppearance("/Helv 12 Tf 0 g");
boolean initialized =
initializeFieldWithWidget(
newAcroForm,
destinationPage,
destinationAnnotations,
newTextField,
sourceField.getPartialName(),
"textField",
sourceWidget,
offsetX,
offsetY,
scale,
pageIndex,
fieldNameCounters);
if (!initialized) {
return;
}
if (sourceField.getValueAsString() != null) {
newTextField.setValue(sourceField.getValueAsString());
}
} catch (Exception e) {
log.warn(
"Failed to create text field '{}': {}",
sourceField.getPartialName(),
e.getMessage(),
e);
}
}
private static void createSimpleCheckBoxField(
PDAcroForm newAcroForm,
PDPage destinationPage,
List<PDAnnotation> destinationAnnotations,
PDCheckBox sourceField,
PDAnnotationWidget sourceWidget,
float offsetX,
float offsetY,
float scale,
int pageIndex,
Map<String, Integer> fieldNameCounters) {
try {
PDCheckBox newCheckBox = new PDCheckBox(newAcroForm);
boolean initialized =
initializeFieldWithWidget(
newAcroForm,
destinationPage,
destinationAnnotations,
newCheckBox,
sourceField.getPartialName(),
"checkBox",
sourceWidget,
offsetX,
offsetY,
scale,
pageIndex,
fieldNameCounters);
if (!initialized) {
return;
}
if (sourceField.isChecked()) {
newCheckBox.check();
} else {
newCheckBox.unCheck();
}
} catch (Exception e) {
log.warn(
"Failed to create checkbox field '{}': {}",
sourceField.getPartialName(),
e.getMessage(),
e);
}
}
private static void createSimpleRadioButtonField(
PDAcroForm newAcroForm,
PDPage destinationPage,
List<PDAnnotation> destinationAnnotations,
PDRadioButton sourceField,
PDAnnotationWidget sourceWidget,
float offsetX,
float offsetY,
float scale,
int pageIndex,
Map<String, Integer> fieldNameCounters) {
try {
PDRadioButton newRadioButton = new PDRadioButton(newAcroForm);
boolean initialized =
initializeFieldWithWidget(
newAcroForm,
destinationPage,
destinationAnnotations,
newRadioButton,
sourceField.getPartialName(),
"radioButton",
sourceWidget,
offsetX,
offsetY,
scale,
pageIndex,
fieldNameCounters);
if (!initialized) {
return;
}
if (sourceField.getExportValues() != null) {
newRadioButton.setExportValues(sourceField.getExportValues());
}
if (sourceField.getValue() != null) {
newRadioButton.setValue(sourceField.getValue());
}
} catch (Exception e) {
log.warn(
"Failed to create radio button field '{}': {}",
sourceField.getPartialName(),
e.getMessage(),
e);
}
}
private static void createSimpleComboBoxField(
PDAcroForm newAcroForm,
PDPage destinationPage,
List<PDAnnotation> destinationAnnotations,
PDComboBox sourceField,
PDAnnotationWidget sourceWidget,
float offsetX,
float offsetY,
float scale,
int pageIndex,
Map<String, Integer> fieldNameCounters) {
try {
PDComboBox newComboBox = new PDComboBox(newAcroForm);
boolean initialized =
initializeFieldWithWidget(
newAcroForm,
destinationPage,
destinationAnnotations,
newComboBox,
sourceField.getPartialName(),
"comboBox",
sourceWidget,
offsetX,
offsetY,
scale,
pageIndex,
fieldNameCounters);
if (!initialized) {
return;
}
if (sourceField.getOptions() != null) {
newComboBox.setOptions(sourceField.getOptions());
}
if (sourceField.getValue() != null && !sourceField.getValue().isEmpty()) {
newComboBox.setValue(sourceField.getValue());
}
} catch (Exception e) {
log.warn(
"Failed to create combo box field '{}': {}",
sourceField.getPartialName(),
e.getMessage(),
e);
}
}
private static void createSimpleListBoxField(
PDAcroForm newAcroForm,
PDPage destinationPage,
List<PDAnnotation> destinationAnnotations,
PDListBox sourceField,
PDAnnotationWidget sourceWidget,
float offsetX,
float offsetY,
float scale,
int pageIndex,
Map<String, Integer> fieldNameCounters) {
try {
PDListBox newListBox = new PDListBox(newAcroForm);
boolean initialized =
initializeFieldWithWidget(
newAcroForm,
destinationPage,
destinationAnnotations,
newListBox,
sourceField.getPartialName(),
"listBox",
sourceWidget,
offsetX,
offsetY,
scale,
pageIndex,
fieldNameCounters);
if (!initialized) {
return;
}
if (sourceField.getOptions() != null) {
newListBox.setOptions(sourceField.getOptions());
}
if (sourceField.getValue() != null && !sourceField.getValue().isEmpty()) {
newListBox.setValue(sourceField.getValue());
}
} catch (Exception e) {
log.warn(
"Failed to create list box field '{}': {}",
sourceField.getPartialName(),
e.getMessage(),
e);
}
}
private static void createSimpleSignatureField(
PDAcroForm newAcroForm,
PDPage destinationPage,
List<PDAnnotation> destinationAnnotations,
PDSignatureField sourceField,
PDAnnotationWidget sourceWidget,
float offsetX,
float offsetY,
float scale,
int pageIndex,
Map<String, Integer> fieldNameCounters) {
try {
PDSignatureField newSignatureField = new PDSignatureField(newAcroForm);
boolean initialized =
initializeFieldWithWidget(
newAcroForm,
destinationPage,
destinationAnnotations,
newSignatureField,
sourceField.getPartialName(),
"signature",
sourceWidget,
offsetX,
offsetY,
scale,
pageIndex,
fieldNameCounters);
if (!initialized) {
return;
}
} catch (Exception e) {
log.warn(
"Failed to create signature field '{}': {}",
sourceField.getPartialName(),
e.getMessage(),
e);
}
}
private static void createSimplePushButtonField(
PDAcroForm newAcroForm,
PDPage destinationPage,
List<PDAnnotation> destinationAnnotations,
PDPushButton sourceField,
PDAnnotationWidget sourceWidget,
float offsetX,
float offsetY,
float scale,
int pageIndex,
Map<String, Integer> fieldNameCounters) {
try {
PDPushButton newPushButton = new PDPushButton(newAcroForm);
boolean initialized =
initializeFieldWithWidget(
newAcroForm,
destinationPage,
destinationAnnotations,
newPushButton,
sourceField.getPartialName(),
"pushButton",
sourceWidget,
offsetX,
offsetY,
scale,
pageIndex,
fieldNameCounters);
} catch (Exception e) {
log.warn(
"Failed to create push button field '{}': {}",
sourceField.getPartialName(),
e.getMessage(),
e);
}
}
private static <T extends PDTerminalField> boolean initializeFieldWithWidget(
PDAcroForm newAcroForm,
PDPage destinationPage,
List<PDAnnotation> destinationAnnotations,
T newField,
String originalName,
String fallbackName,
PDAnnotationWidget sourceWidget,
float offsetX,
float offsetY,
float scale,
int pageIndex,
Map<String, Integer> fieldNameCounters) {
String baseName = (originalName != null) ? originalName : fallbackName;
String newFieldName = generateUniqueFieldName(baseName, pageIndex, fieldNameCounters);
newField.setPartialName(newFieldName);
PDAnnotationWidget newWidget = new PDAnnotationWidget();
PDRectangle sourceRect = sourceWidget.getRectangle();
if (sourceRect == null) {
return false;
}
float newX = (sourceRect.getLowerLeftX() * scale) + offsetX;
float newY = (sourceRect.getLowerLeftY() * scale) + offsetY;
float newWidth = sourceRect.getWidth() * scale;
float newHeight = sourceRect.getHeight() * scale;
newWidget.setRectangle(new PDRectangle(newX, newY, newWidth, newHeight));
newWidget.setPage(destinationPage);
newField.getWidgets().add(newWidget);
newWidget.setParent(newField);
newAcroForm.getFields().add(newField);
destinationAnnotations.add(newWidget);
return true;
}
private static String generateUniqueFieldName(
String originalName, int pageIndex, Map<String, Integer> fieldNameCounters) {
String baseName = "page" + pageIndex + "_" + originalName;
Integer counter = fieldNameCounters.get(baseName);
if (counter == null) {
counter = 0;
} else {
counter++;
}
fieldNameCounters.put(baseName, counter);
return counter == 0 ? baseName : baseName + "_" + counter;
}
private static Map<PDAnnotationWidget, PDField> buildWidgetFieldMap(PDAcroForm acroForm) {
Map<PDAnnotationWidget, PDField> map = new HashMap<>();
if (acroForm == null) {
return map;
}
try {
for (PDField field : acroForm.getFieldTree()) {
List<PDAnnotationWidget> widgets = field.getWidgets();
if (widgets != null) {
for (PDAnnotationWidget w : widgets) {
if (w != null) {
map.put(w, field);
}
}
}
}
} catch (Exception e) {
log.warn("Failed to build widget->field map: {}", e.getMessage(), e);
}
return map;
}
}
File diff suppressed because it is too large Load Diff
@@ -1,35 +0,0 @@
package stirling.software.common.util;
import java.io.IOException;
import java.util.List;
import org.apache.pdfbox.multipdf.PDFMergerUtility;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.springframework.stereotype.Service;
import lombok.RequiredArgsConstructor;
import stirling.software.common.service.CustomPDFDocumentFactory;
@Service
@RequiredArgsConstructor
public class PDFService {
private final CustomPDFDocumentFactory pdfDocumentFactory;
/*
* Merge multiple PDF documents into a single PDF document
*
* @param documents List of PDDocument to be merged
* @return Merged PDDocument
* @throws IOException If an error occurs during merging
*/
public PDDocument mergeDocuments(List<PDDocument> documents) throws IOException {
PDDocument merged = pdfDocumentFactory.createNewDocument();
PDFMergerUtility merger = new PDFMergerUtility();
for (PDDocument doc : documents) {
merger.appendDocument(merged, doc);
}
return merged;
}
}
@@ -1,692 +0,0 @@
package stirling.software.common.util;
import static stirling.software.common.util.AttachmentUtils.setCatalogViewerPreferences;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.time.ZoneId;
import java.time.ZonedDateTime;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.Base64;
import java.util.Date;
import java.util.GregorianCalendar;
import java.util.HashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDDocumentCatalog;
import org.apache.pdfbox.pdmodel.PDDocumentNameDictionary;
import org.apache.pdfbox.pdmodel.PDEmbeddedFilesNameTreeNode;
import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.pdmodel.PageMode;
import org.apache.pdfbox.pdmodel.common.PDRectangle;
import org.apache.pdfbox.pdmodel.common.filespecification.PDComplexFileSpecification;
import org.apache.pdfbox.pdmodel.common.filespecification.PDEmbeddedFile;
import org.apache.pdfbox.pdmodel.interactive.annotation.PDAnnotationFileAttachment;
import org.apache.pdfbox.pdmodel.interactive.annotation.PDAppearanceDictionary;
import org.apache.pdfbox.pdmodel.interactive.annotation.PDAppearanceStream;
import org.apache.pdfbox.text.PDFTextStripper;
import org.apache.pdfbox.text.TextPosition;
import org.jetbrains.annotations.NotNull;
import org.springframework.http.MediaType;
import org.springframework.web.multipart.MultipartFile;
import lombok.Data;
import lombok.Getter;
import lombok.experimental.UtilityClass;
import stirling.software.common.service.CustomPDFDocumentFactory;
@UtilityClass
public class PdfAttachmentHandler {
// Note: This class is designed for EML attachments, not general PDF attachments.
private static final String ATTACHMENT_MARKER = "@";
private static final float ATTACHMENT_ICON_WIDTH = 12f;
private static final float ATTACHMENT_ICON_HEIGHT = 14f;
private static final float ANNOTATION_X_OFFSET = 2f;
private static final float ANNOTATION_Y_OFFSET = 10f;
public static byte[] attachFilesToPdf(
byte[] pdfBytes,
List<EmlParser.EmailAttachment> attachments,
CustomPDFDocumentFactory pdfDocumentFactory)
throws IOException {
if (attachments == null || attachments.isEmpty()) {
return pdfBytes;
}
try (PDDocument document = pdfDocumentFactory.load(pdfBytes);
ByteArrayOutputStream outputStream = new ByteArrayOutputStream()) {
List<MultipartFile> multipartAttachments = new ArrayList<>(attachments.size());
for (int i = 0; i < attachments.size(); i++) {
EmlParser.EmailAttachment attachment = attachments.get(i);
if (attachment.getData() != null && attachment.getData().length > 0) {
String embeddedFilename =
attachment.getFilename() != null
? attachment.getFilename()
: ("attachment_" + i);
attachment.setEmbeddedFilename(embeddedFilename);
multipartAttachments.add(createMultipartFile(attachment));
}
}
if (!multipartAttachments.isEmpty()) {
Map<Integer, String> indexToFilenameMap =
addAttachmentsToDocumentWithMapping(
document, multipartAttachments, attachments);
setCatalogViewerPreferences(document, PageMode.USE_ATTACHMENTS);
addAttachmentAnnotationsToDocumentWithMapping(
document, attachments, indexToFilenameMap);
}
document.save(outputStream);
return outputStream.toByteArray();
} catch (RuntimeException e) {
throw new IOException(
"Invalid PDF structure or processing error: " + e.getMessage(), e);
} catch (Exception e) {
throw new IOException("Error attaching files to PDF: " + e.getMessage(), e);
}
}
private static MultipartFile createMultipartFile(EmlParser.EmailAttachment attachment) {
return new MultipartFile() {
@Override
public @NotNull String getName() {
return "attachment";
}
@Override
public String getOriginalFilename() {
return attachment.getFilename() != null
? attachment.getFilename()
: "attachment_" + System.currentTimeMillis();
}
@Override
public String getContentType() {
return attachment.getContentType() != null
? attachment.getContentType()
: MediaType.APPLICATION_OCTET_STREAM_VALUE;
}
@Override
public boolean isEmpty() {
return attachment.getData() == null || attachment.getData().length == 0;
}
@Override
public long getSize() {
return attachment.getData() != null ? attachment.getData().length : 0;
}
@Override
public byte @NotNull [] getBytes() {
return attachment.getData() != null ? attachment.getData() : new byte[0];
}
@Override
public @NotNull InputStream getInputStream() {
byte[] data = attachment.getData();
return new ByteArrayInputStream(data != null ? data : new byte[0]);
}
@Override
public void transferTo(@NotNull File dest) throws IOException, IllegalStateException {
try (FileOutputStream fos = new FileOutputStream(dest)) {
byte[] data = attachment.getData();
if (data != null) {
fos.write(data);
}
}
}
};
}
private static String ensureUniqueFilename(String filename, Set<String> existingNames) {
if (!existingNames.contains(filename)) {
return filename;
}
String baseName;
String extension = "";
int lastDot = filename.lastIndexOf('.');
if (lastDot > 0) {
baseName = filename.substring(0, lastDot);
extension = filename.substring(lastDot);
} else {
baseName = filename;
}
int counter = 1;
String uniqueName;
do {
uniqueName = baseName + "_" + counter + extension;
counter++;
} while (existingNames.contains(uniqueName));
return uniqueName;
}
private static @NotNull PDRectangle calculateAnnotationRectangle(
PDPage page, float x, float y) {
PDRectangle cropBox = page.getCropBox();
// ISO 32000-1:2008 Section 8.3: PDF coordinate system transforms
int rotation = page.getRotation();
float pdfX = x;
float pdfY = cropBox.getHeight() - y;
switch (rotation) {
case 90 -> {
float temp = pdfX;
pdfX = pdfY;
pdfY = cropBox.getWidth() - temp;
}
case 180 -> {
pdfX = cropBox.getWidth() - pdfX;
pdfY = y;
}
case 270 -> {
float temp = pdfX;
pdfX = cropBox.getHeight() - pdfY;
pdfY = temp;
}
default -> {}
}
float iconHeight = ATTACHMENT_ICON_HEIGHT;
float paddingX = 2.0f;
float paddingY = 2.0f;
PDRectangle rect =
new PDRectangle(
pdfX + ANNOTATION_X_OFFSET + paddingX,
pdfY - iconHeight + ANNOTATION_Y_OFFSET + paddingY,
ATTACHMENT_ICON_WIDTH,
iconHeight);
PDRectangle mediaBox = page.getMediaBox();
if (rect.getLowerLeftX() < mediaBox.getLowerLeftX()
|| rect.getLowerLeftY() < mediaBox.getLowerLeftY()
|| rect.getUpperRightX() > mediaBox.getUpperRightX()
|| rect.getUpperRightY() > mediaBox.getUpperRightY()) {
float adjustedX =
Math.max(
mediaBox.getLowerLeftX(),
Math.min(
rect.getLowerLeftX(),
mediaBox.getUpperRightX() - rect.getWidth()));
float adjustedY =
Math.max(
mediaBox.getLowerLeftY(),
Math.min(
rect.getLowerLeftY(),
mediaBox.getUpperRightY() - rect.getHeight()));
rect = new PDRectangle(adjustedX, adjustedY, rect.getWidth(), rect.getHeight());
}
return rect;
}
public static String processInlineImages(
String htmlContent, EmlParser.EmailContent emailContent) {
if (htmlContent == null || emailContent == null) return htmlContent;
Map<String, EmlParser.EmailAttachment> contentIdMap = new HashMap<>();
for (EmlParser.EmailAttachment attachment : emailContent.getAttachments()) {
if (attachment.isEmbedded()
&& attachment.getContentId() != null
&& attachment.getData() != null) {
contentIdMap.put(attachment.getContentId(), attachment);
}
}
if (contentIdMap.isEmpty()) return htmlContent;
Pattern cidPattern = RegexPatternUtils.getInstance().getInlineCidImagePattern();
Matcher matcher = cidPattern.matcher(htmlContent);
StringBuilder result = new StringBuilder();
while (matcher.find()) {
String contentId = matcher.group(1);
EmlParser.EmailAttachment attachment = contentIdMap.get(contentId);
if (attachment != null && attachment.getData() != null) {
String mimeType =
EmlProcessingUtils.detectMimeType(
attachment.getFilename(), attachment.getContentType());
String base64Data = Base64.getEncoder().encodeToString(attachment.getData());
String dataUri = "data:" + mimeType + ";base64," + base64Data;
String replacement =
matcher.group(0).replaceFirst("cid:" + Pattern.quote(contentId), dataUri);
matcher.appendReplacement(result, Matcher.quoteReplacement(replacement));
} else {
matcher.appendReplacement(result, Matcher.quoteReplacement(matcher.group(0)));
}
}
matcher.appendTail(result);
return result.toString();
}
public static String formatEmailDate(Date date) {
if (date == null) return "";
return formatEmailDate(ZonedDateTime.ofInstant(date.toInstant(), ZoneId.systemDefault()));
}
public static String formatEmailDate(ZonedDateTime dateTime) {
if (dateTime == null) return "";
DateTimeFormatter formatter =
DateTimeFormatter.ofPattern("EEE, MMM d, yyyy 'at' h:mm a z", Locale.ENGLISH);
return dateTime.withZoneSameInstant(ZoneId.of("UTC")).format(formatter);
}
@Data
public static class MarkerPosition {
private int pageIndex;
private float x;
private float y;
private String character;
private String filename;
public MarkerPosition(int pageIndex, float x, float y, String character, String filename) {
this.pageIndex = pageIndex;
this.x = x;
this.y = y;
this.character = character;
this.filename = filename;
}
}
private static String normalizeFilename(String filename) {
if (filename == null) return "";
String normalized = filename.toLowerCase(Locale.ROOT).trim();
normalized =
RegexPatternUtils.getInstance()
.getWhitespacePattern()
.matcher(normalized)
.replaceAll(" ");
normalized =
RegexPatternUtils.getInstance()
.getPattern("[^a-zA-Z0-9._-]")
.matcher(normalized)
.replaceAll("");
return normalized;
}
private static Map<Integer, String> addAttachmentsToDocumentWithMapping(
PDDocument document,
List<MultipartFile> attachments,
List<EmlParser.EmailAttachment> originalAttachments)
throws IOException {
PDDocumentCatalog catalog = document.getDocumentCatalog();
if (catalog == null) {
throw new IOException("PDF document catalog is not accessible");
}
PDDocumentNameDictionary documentNames = catalog.getNames();
if (documentNames == null) {
documentNames = new PDDocumentNameDictionary(catalog);
catalog.setNames(documentNames);
}
PDEmbeddedFilesNameTreeNode embeddedFilesTree = documentNames.getEmbeddedFiles();
if (embeddedFilesTree == null) {
embeddedFilesTree = new PDEmbeddedFilesNameTreeNode();
documentNames.setEmbeddedFiles(embeddedFilesTree);
}
Map<String, PDComplexFileSpecification> existingNames = embeddedFilesTree.getNames();
if (existingNames == null) {
existingNames = new HashMap<>();
}
Map<Integer, String> indexToFilenameMap = new HashMap<>();
for (int i = 0; i < attachments.size(); i++) {
MultipartFile attachment = attachments.get(i);
String filename = attachment.getOriginalFilename();
if (filename == null || filename.trim().isEmpty()) {
filename = "attachment_" + i;
}
String normalizedFilename =
isAscii(filename)
? filename
: java.text.Normalizer.normalize(
filename, java.text.Normalizer.Form.NFC);
String uniqueFilename =
ensureUniqueFilename(normalizedFilename, existingNames.keySet());
indexToFilenameMap.put(i, uniqueFilename);
PDEmbeddedFile embeddedFile = new PDEmbeddedFile(document, attachment.getInputStream());
embeddedFile.setSize((int) attachment.getSize());
GregorianCalendar currentTime = new GregorianCalendar();
embeddedFile.setCreationDate(currentTime);
embeddedFile.setModDate(currentTime);
String contentType = attachment.getContentType();
if (contentType != null && !contentType.trim().isEmpty()) {
embeddedFile.setSubtype(contentType);
}
PDComplexFileSpecification fileSpecification = new PDComplexFileSpecification();
fileSpecification.setFile(uniqueFilename);
fileSpecification.setFileUnicode(uniqueFilename);
fileSpecification.setEmbeddedFile(embeddedFile);
fileSpecification.setEmbeddedFileUnicode(embeddedFile);
existingNames.put(uniqueFilename, fileSpecification);
}
embeddedFilesTree.setNames(existingNames);
documentNames.setEmbeddedFiles(embeddedFilesTree);
catalog.setNames(documentNames);
return indexToFilenameMap;
}
private static void addAttachmentAnnotationsToDocumentWithMapping(
PDDocument document,
List<EmlParser.EmailAttachment> attachments,
Map<Integer, String> indexToFilenameMap)
throws IOException {
if (document.getNumberOfPages() == 0 || attachments == null || attachments.isEmpty()) {
return;
}
AttachmentMarkerPositionFinder finder = new AttachmentMarkerPositionFinder();
finder.setSortByPosition(false); // Keep document order to maintain pairing
finder.getText(document);
List<MarkerPosition> markerPositions = finder.getPositions();
int annotationsToAdd = Math.min(markerPositions.size(), attachments.size());
for (int i = 0; i < annotationsToAdd; i++) {
MarkerPosition position = markerPositions.get(i);
String filenameNearMarker = position.getFilename();
EmlParser.EmailAttachment matchingAttachment =
findAttachmentByFilename(attachments, filenameNearMarker);
if (matchingAttachment != null) {
String embeddedFilename =
findEmbeddedFilenameForAttachment(matchingAttachment, indexToFilenameMap);
if (embeddedFilename != null) {
PDPage page = document.getPage(position.getPageIndex());
addAttachmentAnnotationToPageWithMapping(
document,
page,
matchingAttachment,
embeddedFilename,
position.getX(),
position.getY(),
i);
} else {
// No embedded filename found for attachment
}
} else {
// No matching attachment found for filename near marker
}
}
}
private static EmlParser.EmailAttachment findAttachmentByFilename(
List<EmlParser.EmailAttachment> attachments, String targetFilename) {
if (targetFilename == null || targetFilename.trim().isEmpty()) {
return null;
}
String normalizedTarget = normalizeFilename(targetFilename);
// First try exact match
for (EmlParser.EmailAttachment attachment : attachments) {
if (attachment.getFilename() != null) {
String normalizedAttachment = normalizeFilename(attachment.getFilename());
if (normalizedAttachment.equals(normalizedTarget)) {
return attachment;
}
}
}
// Then try contains match
for (EmlParser.EmailAttachment attachment : attachments) {
if (attachment.getFilename() != null) {
String normalizedAttachment = normalizeFilename(attachment.getFilename());
if (normalizedAttachment.contains(normalizedTarget)
|| normalizedTarget.contains(normalizedAttachment)) {
return attachment;
}
}
}
return null;
}
private static String findEmbeddedFilenameForAttachment(
EmlParser.EmailAttachment attachment, Map<Integer, String> indexToFilenameMap) {
String attachmentFilename = attachment.getFilename();
if (attachmentFilename == null) {
return null;
}
for (Map.Entry<Integer, String> entry : indexToFilenameMap.entrySet()) {
String embeddedFilename = entry.getValue();
if (embeddedFilename != null
&& (embeddedFilename.equals(attachmentFilename)
|| embeddedFilename.contains(attachmentFilename)
|| attachmentFilename.contains(embeddedFilename))) {
return embeddedFilename;
}
}
return null;
}
public static class AttachmentMarkerPositionFinder extends PDFTextStripper {
private static final Pattern ATTACHMENT_SECTION_PATTERN =
RegexPatternUtils.getInstance().getAttachmentSectionPattern();
private static final Pattern FILENAME_PATTERN =
RegexPatternUtils.getInstance().getAttachmentFilenamePattern();
@Getter private final List<MarkerPosition> positions = new ArrayList<>();
private final StringBuilder currentText = new StringBuilder();
protected boolean sortByPosition;
private int currentPageIndex;
private boolean isInAttachmentSection;
private boolean attachmentSectionFound;
public AttachmentMarkerPositionFinder() {
super();
this.currentPageIndex = 0;
this.sortByPosition = false; // Disable sorting to preserve document order
this.isInAttachmentSection = false;
this.attachmentSectionFound = false;
}
@Override
public String getText(PDDocument document) throws IOException {
super.getText(document);
if (sortByPosition) {
positions.sort(
(a, b) -> {
int pageCompare = Integer.compare(a.getPageIndex(), b.getPageIndex());
if (pageCompare != 0) return pageCompare;
return Float.compare(
b.getY(), a.getY()); // Descending Y per PDF coordinate system
});
}
return ""; // Return empty string as we only need positions
}
@Override
protected void startPage(PDPage page) throws IOException {
super.startPage(page);
}
@Override
protected void endPage(PDPage page) throws IOException {
currentPageIndex++;
super.endPage(page);
}
@Override
protected void writeString(String string, List<TextPosition> textPositions)
throws IOException {
String lowerString = string.toLowerCase(Locale.ROOT);
if (ATTACHMENT_SECTION_PATTERN.matcher(lowerString).find()) {
isInAttachmentSection = true;
attachmentSectionFound = true;
}
if (isInAttachmentSection
&& (lowerString.contains("</body>")
|| lowerString.contains("</html>")
|| (attachmentSectionFound
&& lowerString.trim().isEmpty()
&& string.length() > 50))) {
isInAttachmentSection = false;
}
if (isInAttachmentSection) {
currentText.append(string);
for (int i = 0; (i = string.indexOf(ATTACHMENT_MARKER, i)) != -1; i++) {
if (i < textPositions.size()) {
TextPosition textPosition = textPositions.get(i);
String filename = extractFilenameAfterMarker(string, i);
MarkerPosition position =
new MarkerPosition(
currentPageIndex,
textPosition.getXDirAdj(),
textPosition.getYDirAdj(),
ATTACHMENT_MARKER,
filename);
positions.add(position);
}
}
}
super.writeString(string, textPositions);
}
@Override
public void setSortByPosition(boolean sortByPosition) {
this.sortByPosition = sortByPosition;
}
private String extractFilenameAfterMarker(String text, int markerIndex) {
String afterMarker = text.substring(markerIndex + 1);
Matcher matcher = FILENAME_PATTERN.matcher("@" + afterMarker);
if (matcher.find()) {
return matcher.group(1);
}
String[] parts =
RegexPatternUtils.getInstance()
.getWhitespaceParenthesesSplitPattern()
.split(afterMarker);
for (String part : parts) {
part = part.trim();
if (part.length() > 3 && part.contains(".")) {
return part;
}
}
return null;
}
}
private static void addAttachmentAnnotationToPageWithMapping(
PDDocument document,
PDPage page,
EmlParser.EmailAttachment attachment,
String embeddedFilename,
float x,
float y,
int attachmentIndex)
throws IOException {
PDAnnotationFileAttachment fileAnnotation = new PDAnnotationFileAttachment();
PDRectangle rect = calculateAnnotationRectangle(page, x, y);
fileAnnotation.setRectangle(rect);
fileAnnotation.setPrinted(false);
fileAnnotation.setHidden(false);
fileAnnotation.setNoView(false);
fileAnnotation.setNoZoom(true);
fileAnnotation.setNoRotate(true);
try {
PDAppearanceDictionary appearance = new PDAppearanceDictionary();
PDAppearanceStream normalAppearance = new PDAppearanceStream(document);
normalAppearance.setBBox(new PDRectangle(0, 0, rect.getWidth(), rect.getHeight()));
appearance.setNormalAppearance(normalAppearance);
fileAnnotation.setAppearance(appearance);
} catch (RuntimeException e) {
fileAnnotation.setAppearance(null);
}
PDEmbeddedFilesNameTreeNode efTree =
document.getDocumentCatalog().getNames().getEmbeddedFiles();
if (efTree != null) {
Map<String, PDComplexFileSpecification> efMap = efTree.getNames();
if (efMap != null) {
PDComplexFileSpecification fileSpec = efMap.get(embeddedFilename);
if (fileSpec != null) {
fileAnnotation.setFile(fileSpec);
} else {
// Could not find embedded file
}
}
}
fileAnnotation.setContents(
"Attachment " + (attachmentIndex + 1) + ": " + attachment.getFilename());
fileAnnotation.setAnnotationName(
"EmbeddedFile_" + attachmentIndex + "_" + embeddedFilename);
page.getAnnotations().add(fileAnnotation);
}
private static boolean isAscii(String str) {
if (str == null) return true;
for (int i = 0; i < str.length(); i++) {
if (str.charAt(i) > 127) {
return false;
}
}
return true;
}
}
@@ -1,186 +0,0 @@
package stirling.software.common.util;
import java.awt.image.BufferedImage;
import java.io.ByteArrayOutputStream;
import java.io.FileInputStream;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.Locale;
import javax.imageio.ImageIO;
import org.apache.commons.io.FilenameUtils;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.rendering.ImageType;
import org.apache.pdfbox.rendering.PDFRenderer;
import org.springframework.web.multipart.MultipartFile;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.ProcessExecutor.ProcessExecutorResult;
@Slf4j
public class PdfToCbrUtils {
public static byte[] convertPdfToCbr(
MultipartFile pdfFile, int dpi, CustomPDFDocumentFactory pdfDocumentFactory)
throws IOException {
validatePdfFile(pdfFile);
try (PDDocument document = pdfDocumentFactory.load(pdfFile)) {
if (document.getNumberOfPages() == 0) {
throw ExceptionUtils.createPdfNoPages();
}
return createCbrFromPdf(document, dpi);
}
}
private static void validatePdfFile(MultipartFile file) {
if (file == null || file.isEmpty()) {
throw ExceptionUtils.createFileNullOrEmptyException();
}
String filename = file.getOriginalFilename();
if (filename == null) {
throw ExceptionUtils.createFileNoNameException();
}
String extension = FilenameUtils.getExtension(filename).toLowerCase(Locale.ROOT);
if (!"pdf".equals(extension)) {
throw ExceptionUtils.createPdfFileRequiredException();
}
}
private static byte[] createCbrFromPdf(PDDocument document, int dpi) throws IOException {
PDFRenderer pdfRenderer = new PDFRenderer(document);
Path tempDir = Files.createTempDirectory("stirling-pdf-cbr-");
List<Path> generatedImages = new ArrayList<>();
try {
int totalPages = document.getNumberOfPages();
for (int pageIndex = 0; pageIndex < totalPages; pageIndex++) {
final int currentPage = pageIndex;
try {
BufferedImage image =
ExceptionUtils.handleOomRendering(
currentPage + 1,
dpi,
() ->
pdfRenderer.renderImageWithDPI(
currentPage, dpi, ImageType.RGB));
String imageFilename =
String.format(Locale.ROOT, "page_%03d.png", currentPage + 1);
Path imagePath = tempDir.resolve(imageFilename);
ImageIO.write(image, "PNG", imagePath.toFile());
generatedImages.add(imagePath);
} catch (ExceptionUtils.OutOfMemoryDpiException e) {
// Re-throw OOM exceptions without wrapping
throw e;
} catch (IOException e) {
// Wrap other IOExceptions with context
throw ExceptionUtils.createFileProcessingException(
"CBR creation for page " + (currentPage + 1), e);
}
}
if (generatedImages.isEmpty()) {
throw ExceptionUtils.createFileProcessingException(
"CBR conversion", new IOException("No pages were successfully rendered"));
}
return createRarArchive(tempDir, generatedImages);
} finally {
cleanupTempFiles(generatedImages, tempDir);
}
}
private static byte[] createRarArchive(Path tempDir, List<Path> images) throws IOException {
List<String> command = new ArrayList<>();
command.add("rar");
command.add("a");
command.add("-m5");
command.add("-ep1");
Path rarFile = tempDir.resolve("output.cbr");
command.add(rarFile.getFileName().toString());
for (Path image : images) {
command.add(image.getFileName().toString());
}
ProcessExecutor executor =
ProcessExecutor.getInstance(ProcessExecutor.Processes.INSTALL_APP);
try {
ProcessExecutorResult result =
executor.runCommandWithOutputHandling(command, tempDir.toFile());
if (result.getRc() != 0) {
throw ExceptionUtils.createFileProcessingException(
"RAR archive creation",
new IOException("RAR command failed with code " + result.getRc()));
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw ExceptionUtils.createProcessingInterruptedException("RAR creation", e);
}
if (!Files.exists(rarFile)) {
throw ExceptionUtils.createFileProcessingException(
"RAR archive creation", new IOException("RAR file was not created"));
}
try (FileInputStream fis = new FileInputStream(rarFile.toFile());
ByteArrayOutputStream baos = new ByteArrayOutputStream()) {
fis.transferTo(baos);
return baos.toByteArray();
}
}
private static void cleanupTempFiles(List<Path> images, Path tempDir) {
for (Path image : images) {
try {
Files.deleteIfExists(image);
} catch (IOException e) {
log.warn("Failed to delete temp image file {}: {}", image, e.getMessage());
}
}
if (tempDir != null) {
try (var paths = Files.walk(tempDir)) {
paths.sorted(Comparator.reverseOrder())
.forEach(
path -> {
try {
Files.deleteIfExists(path);
} catch (IOException e) {
log.warn(
"Failed to delete temp path {}: {}",
path,
e.getMessage());
}
});
} catch (IOException e) {
log.warn("Failed to clean up temp directory {}: {}", tempDir, e.getMessage());
}
}
}
public static boolean isPdfFile(MultipartFile file) {
String filename = file.getOriginalFilename();
if (filename == null) {
return false;
}
String extension = FilenameUtils.getExtension(filename).toLowerCase(Locale.ROOT);
return "pdf".equals(extension);
}
}
@@ -1,107 +0,0 @@
package stirling.software.common.util;
import java.awt.image.BufferedImage;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.util.Locale;
import java.util.zip.ZipEntry;
import java.util.zip.ZipOutputStream;
import javax.imageio.ImageIO;
import org.apache.commons.io.FilenameUtils;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.rendering.ImageType;
import org.apache.pdfbox.rendering.PDFRenderer;
import org.springframework.web.multipart.MultipartFile;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.service.CustomPDFDocumentFactory;
@Slf4j
public class PdfToCbzUtils {
public static byte[] convertPdfToCbz(
MultipartFile pdfFile, int dpi, CustomPDFDocumentFactory pdfDocumentFactory)
throws IOException {
validatePdfFile(pdfFile);
try (PDDocument document = pdfDocumentFactory.load(pdfFile)) {
if (document.getNumberOfPages() == 0) {
throw ExceptionUtils.createPdfNoPages();
}
return createCbzFromPdf(document, dpi);
}
}
private static void validatePdfFile(MultipartFile file) {
if (file == null || file.isEmpty()) {
throw ExceptionUtils.createFileNullOrEmptyException();
}
String filename = file.getOriginalFilename();
if (filename == null) {
throw ExceptionUtils.createFileNoNameException();
}
String extension = FilenameUtils.getExtension(filename).toLowerCase(Locale.ROOT);
if (!"pdf".equals(extension)) {
throw ExceptionUtils.createPdfFileRequiredException();
}
}
private static byte[] createCbzFromPdf(PDDocument document, int dpi) throws IOException {
PDFRenderer pdfRenderer = new PDFRenderer(document);
try (ByteArrayOutputStream cbzOutputStream = new ByteArrayOutputStream();
ZipOutputStream zipOut = new ZipOutputStream(cbzOutputStream)) {
int totalPages = document.getNumberOfPages();
for (int pageIndex = 0; pageIndex < totalPages; pageIndex++) {
final int currentPage = pageIndex;
try {
BufferedImage image =
ExceptionUtils.handleOomRendering(
currentPage + 1,
dpi,
() ->
pdfRenderer.renderImageWithDPI(
currentPage, dpi, ImageType.RGB));
String imageFilename =
String.format(Locale.ROOT, "page_%03d.png", currentPage + 1);
ZipEntry zipEntry = new ZipEntry(imageFilename);
zipOut.putNextEntry(zipEntry);
ImageIO.write(image, "PNG", zipOut);
zipOut.closeEntry();
} catch (ExceptionUtils.OutOfMemoryDpiException e) {
// Re-throw OOM exceptions without wrapping
throw e;
} catch (IOException e) {
// Wrap other IOExceptions with context
throw ExceptionUtils.createFileProcessingException(
"CBZ creation for page " + (currentPage + 1), e);
}
}
zipOut.finish();
return cbzOutputStream.toByteArray();
}
}
public static boolean isPdfFile(MultipartFile file) {
String filename = file.getOriginalFilename();
if (filename == null) {
return false;
}
String extension = FilenameUtils.getExtension(filename).toLowerCase(Locale.ROOT);
return "pdf".equals(extension);
}
}
@@ -1,539 +0,0 @@
package stirling.software.common.util;
import java.util.concurrent.ConcurrentHashMap;
import java.util.regex.Pattern;
import java.util.regex.PatternSyntaxException;
import lombok.extern.slf4j.Slf4j;
@Slf4j
public final class RegexPatternUtils {
private static final RegexPatternUtils INSTANCE = new RegexPatternUtils();
private final ConcurrentHashMap<PatternKey, Pattern> patternCache = new ConcurrentHashMap<>();
private static final String WHITESPACE_REGEX = "\\s++";
private static final String EXTENSION_REGEX = "\\.(?:[^.]*+)?$";
private RegexPatternUtils() {
super();
// Initialize with commonly used patterns for immediate availability
precompileCommonPatterns();
}
/**
* Get the singleton instance of the pattern cache.
*
* @return the singleton RegexPatternCache instance
*/
public static RegexPatternUtils getInstance() {
return INSTANCE;
}
/**
* Get a compiled pattern from cache, compiling and caching if not present.
*
* <p>This method is thread-safe and uses lazy initialization. Multiple threads calling with the
* same regex will result in only one compilation, with all threads receiving the same cached
* Pattern instance.
*
* <p>Performance: first call compiles and caches (expensive), subsequent calls return cached
* pattern (fast O(1) lookup).
*
* @param regex the regular expression string to compile
* @return compiled Pattern object, never null
* @throws PatternSyntaxException if the regex syntax is invalid
* @throws IllegalArgumentException if regex is null
*/
public Pattern getPattern(String regex) {
if (regex == null) {
throw new IllegalArgumentException("Regex pattern cannot be null");
}
return patternCache.computeIfAbsent(new PatternKey(regex, 0), this::compilePattern);
}
/**
* Get a compiled pattern with flags.
*
* <p>Patterns with different flags are cached separately using a composite key. Common flags
* include:
*
* <ul>
* <li>{@link Pattern#CASE_INSENSITIVE} - ignore case differences
* <li>{@link Pattern#MULTILINE} - ^ and $ match line boundaries
* <li>{@link Pattern#DOTALL} - . matches any character including newlines
* </ul>
*
* @param regex the regular expression string
* @param flags pattern flags (e.g., Pattern.CASE_INSENSITIVE)
* @return compiled Pattern object with specified flags
* @throws PatternSyntaxException if the regex syntax is invalid
* @throws IllegalArgumentException if regex is null
*/
public Pattern getPattern(String regex, int flags) {
if (regex == null) {
throw new IllegalArgumentException("Regex pattern cannot be null");
}
return patternCache.computeIfAbsent(new PatternKey(regex, flags), this::compilePattern);
}
/**
* Check if a pattern is already cached.
*
* @param regex the regular expression string
* @return true if pattern is cached, false otherwise
*/
public boolean isCached(String regex) {
return isCached(regex, 0);
}
/**
* Check if a pattern with flags is already cached.
*
* @param regex the regular expression string
* @param flags pattern flags
* @return true if pattern is cached, false otherwise
*/
public boolean isCached(String regex, int flags) {
return regex != null && patternCache.containsKey(new PatternKey(regex, flags));
}
/**
* Get current cache size (number of cached patterns). Useful for monitoring and debugging.
*
* @return number of patterns currently cached
*/
public int getCacheSize() {
return patternCache.size();
}
/**
* Clear all cached patterns. Use sparingly as it forces recompilation of all patterns. Mainly
* useful for testing or memory cleanup in long-running applications.
*/
public void clearCache() {
patternCache.clear();
log.debug("Regex pattern cache cleared");
}
/**
* Remove a specific pattern from cache.
*
* @param regex the regular expression string to remove
* @return true if pattern was cached and removed, false otherwise
*/
public boolean removeFromCache(String regex) {
return removeFromCache(regex, 0);
}
/**
* Remove a specific pattern with flags from cache.
*
* @param regex the regular expression string to remove
* @param flags pattern flags
* @return true if pattern was cached and removed, false otherwise
*/
public boolean removeFromCache(String regex, int flags) {
if (regex == null) {
return false;
}
PatternKey key = new PatternKey(regex, flags);
boolean removed = patternCache.remove(key) != null;
if (removed) {
log.debug("Removed regex pattern from cache: {} (flags: {})", regex, flags);
}
return removed;
}
/**
* Internal method to compile a pattern and handle errors consistently.
*
* @return compiled Pattern
* @throws PatternSyntaxException if regex is invalid
*/
private Pattern compilePattern(PatternKey key) {
String regex = key.regex;
int flags = key.flags;
try {
Pattern pattern = Pattern.compile(regex, flags);
log.trace("Compiled and cached regex pattern with flags {}: {}", flags, regex);
return pattern;
} catch (PatternSyntaxException e) {
log.error(
"Invalid regex pattern: '{}' with flags {} - {}", regex, flags, e.getMessage());
throw e;
}
}
public static String getWhitespaceRegex() {
return WHITESPACE_REGEX;
}
/** Creates a case-insensitive pattern for text searching */
public Pattern createSearchPattern(String regex, boolean caseInsensitive) {
int flags = caseInsensitive ? (Pattern.CASE_INSENSITIVE | Pattern.UNICODE_CASE) : 0;
return getPattern(regex, flags);
}
/** Pattern for matching trailing slashes (e.g., "/path/to/dir///") */
public Pattern getTrailingSlashesPattern() {
return getPattern("/+$");
}
/** Pattern for removing drive letters from paths */
public Pattern getDriveLetterPattern() {
return getPattern("^[a-zA-Z]:[\\\\/]+");
}
/** Pattern for removing leading slashes from paths */
public Pattern getLeadingSlashesPattern() {
return getPattern("^[\\\\/]+");
}
/** Pattern for matching backslashes */
public Pattern getBackslashPattern() {
return getPattern("\\\\");
}
/** Pattern for sanitizing filenames by removing problematic characters */
public Pattern getSafeFilenamePattern() {
return getPattern("[/\\\\?%*:|\"<>]");
}
/** Pattern for sanitizing filenames (keeps only alphanumeric) */
public Pattern getFilenameSafePattern() {
return getPattern("[^a-zA-Z0-9]");
}
/**
* Pattern for replacing non-alphanumeric characters with underscore (explicit underscore
* variant)
*/
public Pattern getNonAlnumUnderscorePattern() {
return getPattern("[^A-Za-z0-9_]");
}
/** Pattern for collapsing multiple underscores */
public Pattern getMultipleUnderscoresPattern() {
return getPattern("_+");
}
/** Pattern for trimming leading underscores */
public Pattern getLeadingUnderscoresPattern() {
return getPattern("^_+");
}
/** Pattern for trimming trailing underscores */
public Pattern getTrailingUnderscoresPattern() {
return getPattern("_+$");
}
/** Pattern for matching upload/download paths (case insensitive) */
public Pattern getUploadDownloadPathPattern() {
return getPattern("(?i).*/(upload|download)/.*");
}
/** Pattern for matching one or more whitespace characters */
public Pattern getWhitespacePattern() {
return getPattern("\\s+");
}
/** Pattern for matching newlines (Windows and Unix style) */
public Pattern getNewlinesPattern() {
return getPattern("\\r?\\n");
}
/** Pattern for splitting on newlines (Windows and Unix style) */
public Pattern getNewlineSplitPattern() {
return getPattern("\\r?\\n");
}
/** Pattern for splitting text into words */
public Pattern getWordSplitPattern() {
return getPattern("\\s+");
}
/** Pattern for removing carriage returns */
public Pattern getCarriageReturnPattern() {
return getPattern("\\r");
}
/** Pattern for matching newline characters */
public Pattern getNewlineCharsPattern() {
return getPattern("[\n\r]");
}
/** Pattern for multi-format newline splitting (Windows, Mac, Unix) */
public Pattern getMultiFormatNewlinePattern() {
return getPattern("\r\n|\r|\n");
}
/** Pattern for encoded payload newline removal */
public Pattern getEncodedPayloadNewlinePattern() {
return getPattern("\\r?\\n");
}
/** Pattern for escaped newlines in watermark text */
public Pattern getEscapedNewlinePattern() {
return getPattern("\\\\n");
}
/** Pattern for input sanitization (allows only alphanumeric and spaces) */
public Pattern getInputSanitizePattern() {
return getPattern("[^a-zA-Z0-9 ]");
}
/** Pattern for removing angle brackets */
public Pattern getAngleBracketsPattern() {
return getPattern("[<>]");
}
/** Pattern for removing leading and trailing quotes */
public Pattern getQuotesRemovalPattern() {
return getPattern("^\"|\"$");
}
/** Pattern for plus signs (URL encoding replacement) */
public Pattern getPlusSignPattern() {
return getPattern("\\+");
}
/** Pattern for splitting on pipe delimiter (used for hint lists in i18n messages) */
public Pattern getPipeDelimiterPattern() {
return getPattern("\\|");
}
/** Pattern for username validation */
public Pattern getUsernameValidationPattern() {
return getPattern("^[a-zA-Z0-9](?!.*[-@._+]{2,})[a-zA-Z0-9@._+-]{1,48}[a-zA-Z0-9]$");
}
public static String getExtensionRegex() {
return EXTENSION_REGEX;
}
/** Pattern for extracting non-numeric characters */
public Pattern getNumericExtractionPattern() {
return getPattern("\\D");
}
/** Pattern for removing non-digit/dot characters (for timeout parsing) */
public Pattern getNonDigitDotPattern() {
return getPattern("[^\\d.]");
}
/** Pattern for matching digit/dot characters (for timeout parsing) */
public Pattern getDigitDotPattern() {
return getPattern("[\\d.]");
}
/** Pattern for detecting strings containing digits */
public Pattern getContainsDigitsPattern() {
return getPattern(".*\\d+.*");
}
/** Pattern for matching 1-3 digit numbers */
public Pattern getNumberRangePattern() {
return getPattern("[1-9][0-9]{0,2}");
}
/** Pattern for validating mathematical expressions */
public Pattern getMathExpressionPattern() {
return getPattern("[0-9n+\\-*/() ]+");
}
/** Pattern for adding multiplication between numbers and 'n' */
public Pattern getNumberBeforeNPattern() {
return getPattern("(\\d)n");
}
/** Pattern for detecting consecutive 'n' characters */
public Pattern getConsecutiveNPattern() {
return getPattern(".*n{2,}.*");
}
/** Pattern for replacing consecutive 'n' characters */
public Pattern getConsecutiveNReplacementPattern() {
return getPattern("(?<!n)n{2}");
}
/** Pattern for validating HTTP/HTTPS URLs */
public Pattern getHttpUrlPattern() {
return getPattern("^https?://.*");
}
/** Pattern for matching URLs in text for link creation */
public Pattern getUrlLinkPattern() {
return getPattern("(https?://[\\w\\-._~:/?#\\[\\]@!$&'()*+,;=%]+)");
}
/** Pattern for matching email addresses in text for link creation */
public Pattern getEmailLinkPattern() {
return getPattern("([a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,63})");
}
/** Pattern for removing script tags from HTML */
public Pattern getScriptTagPattern() {
return getPattern("(?i)<script[^>]*>.*?</script>");
}
/** Pattern for removing style tags from HTML */
public Pattern getStyleTagPattern() {
return getPattern("(?i)<style[^>]*>.*?</style>");
}
/** Pattern for removing fixed position CSS */
public Pattern getFixedPositionCssPattern() {
return getPattern("(?i)\\s*position\\s*:\\s*fixed[^;]*;?");
}
/** Pattern for removing absolute position CSS */
public Pattern getAbsolutePositionCssPattern() {
return getPattern("(?i)\\s*position\\s*:\\s*absolute[^;]*;?");
}
/** Pattern for matching size unit suffixes (KB, MB, GB, etc.) */
public Pattern getSizeUnitPattern() {
return getPattern("[KMGkmg][Bb]");
}
/** Pattern for system temp file type 1 */
public Pattern getSystemTempFile1Pattern() {
return getPattern("lu\\d+[a-z0-9]*\\.tmp");
}
/** Pattern for system temp file type 2 (OCR processes) */
public Pattern getSystemTempFile2Pattern() {
return getPattern("ocr_process\\d+");
}
/** Pattern for splitting on whitespace and parentheses */
public Pattern getWhitespaceParenthesesSplitPattern() {
return getPattern("[\\s\\(\\)]+");
}
/** Pattern for MIME header whitespace cleanup before encoded sequences */
public Pattern getMimeHeaderWhitespacePattern() {
return getPattern("\\s+(?==\\?)");
}
/** Pattern for font name validation (6 uppercase letters + plus + rest) */
public Pattern getFontNamePattern() {
return getPattern("^[A-Z]{6}\\+.*");
}
/** Pattern for matching access="readOnly" attribute in XFA XML (with optional whitespace) */
public Pattern getAccessReadOnlyPattern() {
return getPattern("access\\s*=\\s*\"readOnly\"");
}
/** Pattern for matching MIME encoded-word headers (RFC 2047) Example: =?charset?B?encoded?= */
public Pattern getMimeEncodedWordPattern() {
return getPattern("=\\?([^?]+)\\?([BbQq])\\?([^?]*)\\?=");
}
/** Pattern for matching inline CID images in HTML (case-insensitive) */
public Pattern getInlineCidImagePattern() {
return getPattern(
"(?i)<img[^>]*\\ssrc\\s*=\\s*['\"]cid:([^'\"]+)['\"][^>]*>",
Pattern.CASE_INSENSITIVE);
}
/** Pattern for matching image file extensions (case-insensitive) */
public Pattern getImageFilePattern() {
return getPattern(".*\\.(jpg|jpeg|png|gif|bmp|webp)$", Pattern.CASE_INSENSITIVE);
}
/** Pattern for matching attachment section headers (case-insensitive) */
public Pattern getAttachmentSectionPattern() {
return getPattern("attachments\\s*\\(\\d+\\)", Pattern.CASE_INSENSITIVE);
}
/** Pattern for matching filenames in attachment markers */
public Pattern getAttachmentFilenamePattern() {
return getPattern("@\\s*([^\\s\\(]+(?:\\.[a-zA-Z0-9]+)?)");
}
/** Pattern for matching pdfaid:part attribute in XMP metadata */
public Pattern getPdfAidPartPattern() {
return getPattern("pdfaid:part[\"\\s]*=[\"\\s]*([0-9]+)");
}
/** Pattern for matching pdfaid:conformance attribute in XMP metadata */
public Pattern getPdfAidConformancePattern() {
return getPattern("pdfaid:conformance[\"\\s]*=[\"\\s]*([A-Za-z]+)");
}
/** Pattern for matching slash in page mode description */
public Pattern getPageModePattern() {
return getPattern("/");
}
/**
* Pre-compile commonly used patterns for immediate availability. This eliminates first-call
* compilation overhead for frequent patterns.
*/
private void precompileCommonPatterns() {
getPattern("\\.(?:[^.]*+)?$"); // Extension removal - possessive, optional, anchored
getPattern("\\.[^.]+$"); // Simple extension match - anchored
getPattern("\\s+"); // One or more whitespace
getPattern("\\s*"); // Zero or more whitespace
getPattern("/+$"); // Trailing slashes
getPattern("\\D"); // Non-numeric characters
getPattern("[/\\\\?%*:|\"<>]"); // Unsafe filename characters
getPattern("[^a-zA-Z0-9 ]"); // Input sanitization
getPattern("[^a-zA-Z0-9]"); // Filename sanitization
// API doc patterns
getPattern("Output:(\\w+)"); // precompiled single-escaped for runtime regex \w
getPattern("Input:(\\w+)");
getPattern("Type:(\\w+)");
log.debug("Pre-compiled {} common regex patterns", patternCache.size());
}
/** Pattern for email validation */
public Pattern getEmailValidationPattern() {
return getPattern(
"^(?=.{1,320}$)(?=.{1,64}@)[A-Za-z0-9](?:[A-Za-z0-9_.+-]*[A-Za-z0-9])?@[^-][A-Za-z0-9-]+(?:\\.[A-Za-z0-9-]+)*(?:\\.[A-Za-z]{2,})$");
}
/* Pattern for matching Output:<TYPE> in API descriptions */
public Pattern getApiDocOutputTypePattern() {
return getPattern("Output:(\\w+)");
}
/* Pattern for matching Input:<TYPE> in API descriptions */
public Pattern getApiDocInputTypePattern() {
return getPattern("Input:(\\w+)");
}
/**
* Pattern for matching Type:<CODE> in API descriptions
*/
public Pattern getApiDocTypePattern() {
return getPattern("Type:(\\w+)");
}
/* Pattern for validating file extensions (2-4 alphanumeric, case-insensitive) */
public Pattern getFileExtensionValidationPattern() {
return getPattern("^[a-zA-Z0-9]{2,4}$", Pattern.CASE_INSENSITIVE);
}
/** Pattern for splitting on line breaks (Unicode line separator) */
public Pattern getLineSeparatorPattern() {
return getPattern("\\R");
}
/** Pattern for removing leading asterisks and whitespace */
public Pattern getLeadingAsterisksWhitespacePattern() {
return getPattern("^[*\\s]+");
}
private record PatternKey(String regex, int flags) {
// Record automatically provides equals, hashCode, and toString
}
}
@@ -1,131 +0,0 @@
package stirling.software.common.util;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody;
import io.github.pixee.security.Filenames;
import lombok.extern.slf4j.Slf4j;
@Slf4j
public class WebResponseUtils {
public static ResponseEntity<byte[]> baosToWebResponse(
ByteArrayOutputStream baos, String docName) throws IOException {
return WebResponseUtils.bytesToWebResponse(baos.toByteArray(), docName);
}
public static ResponseEntity<byte[]> baosToWebResponse(
ByteArrayOutputStream baos, String docName, MediaType mediaType) throws IOException {
return WebResponseUtils.bytesToWebResponse(baos.toByteArray(), docName, mediaType);
}
public static ResponseEntity<byte[]> multiPartFileToWebResponse(MultipartFile file)
throws IOException {
String fileName = Filenames.toSimpleFileName(file.getOriginalFilename());
MediaType mediaType = MediaType.parseMediaType(file.getContentType());
byte[] bytes = file.getBytes();
return bytesToWebResponse(bytes, fileName, mediaType);
}
public static ResponseEntity<byte[]> bytesToWebResponse(
byte[] bytes, String docName, MediaType mediaType) throws IOException {
// Return the PDF as a response
HttpHeaders headers = new HttpHeaders();
headers.setContentType(mediaType);
headers.setContentLength(bytes.length);
String encodedDocName =
RegexPatternUtils.getInstance()
.getPlusSignPattern()
.matcher(URLEncoder.encode(docName, StandardCharsets.UTF_8))
.replaceAll("%20");
headers.setContentDispositionFormData("attachment", encodedDocName);
return new ResponseEntity<>(bytes, headers, HttpStatus.OK);
}
public static ResponseEntity<byte[]> bytesToWebResponse(byte[] bytes, String docName)
throws IOException {
return bytesToWebResponse(bytes, docName, MediaType.APPLICATION_PDF);
}
public static ResponseEntity<byte[]> pdfDocToWebResponse(PDDocument document, String docName)
throws IOException {
// Open Byte Array and save document to it
ByteArrayOutputStream baos = new ByteArrayOutputStream();
document.save(baos);
document.close();
return baosToWebResponse(baos, docName);
}
/**
* Convert a File to a web response (PDF default).
*
* @param outputTempFile The temporary file to be sent as a response.
* @param docName The name of the document.
* @return A ResponseEntity containing the file as a resource.
*/
public static ResponseEntity<StreamingResponseBody> pdfFileToWebResponse(
TempFile outputTempFile, String docName) throws IOException {
return fileToWebResponse(outputTempFile, docName, MediaType.APPLICATION_PDF);
}
/**
* Convert a File to a web response (ZIP default).
*
* @param outputTempFile The temporary file to be sent as a response.
* @param docName The name of the document.
* @return A ResponseEntity containing the file as a resource.
*/
public static ResponseEntity<StreamingResponseBody> zipFileToWebResponse(
TempFile outputTempFile, String docName) throws IOException {
return fileToWebResponse(outputTempFile, docName, MediaType.APPLICATION_OCTET_STREAM);
}
/**
* Convert a File to a web response with explicit media type (e.g., ZIP).
*
* @param outputTempFile The temporary file to be sent as a response.
* @param docName The name of the document.
* @param mediaType The content type to set on the response.
* @return A ResponseEntity containing the file as a resource.
*/
public static ResponseEntity<StreamingResponseBody> fileToWebResponse(
TempFile outputTempFile, String docName, MediaType mediaType) throws IOException {
Path path = outputTempFile.getFile().toPath().normalize();
long len = Files.size(path);
HttpHeaders headers = new HttpHeaders();
headers.setContentType(mediaType);
headers.setContentLength(len);
headers.add(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + docName + "\"");
StreamingResponseBody body =
os -> {
try (os) {
Files.copy(path, os);
os.flush();
} finally {
outputTempFile.close();
}
};
return new ResponseEntity<>(body, headers, HttpStatus.OK);
}
}
@@ -1,86 +0,0 @@
package stirling.software.common.util.misc;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import org.springframework.core.io.InputStreamResource;
import org.springframework.web.multipart.MultipartFile;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.model.api.misc.ReplaceAndInvert;
import stirling.software.common.util.ProcessExecutor;
import stirling.software.common.util.ProcessExecutor.ProcessExecutorResult;
import stirling.software.common.util.TempFile;
import stirling.software.common.util.TempFileManager;
@Slf4j
public class ColorSpaceConversionStrategy extends ReplaceAndInvertColorStrategy {
private final TempFileManager tempFileManager;
public ColorSpaceConversionStrategy(
MultipartFile file,
ReplaceAndInvert replaceAndInvert,
TempFileManager tempFileManager) {
super(file, replaceAndInvert);
this.tempFileManager = tempFileManager;
}
@Override
public InputStreamResource replace() throws IOException {
try (TempFile tempInput = new TempFile(tempFileManager, ".pdf");
TempFile tempOutput = new TempFile(tempFileManager, ".pdf")) {
Path tempInputFile = tempInput.getPath();
Path tempOutputFile = tempOutput.getPath();
Files.write(tempInputFile, getFileInput().getBytes());
log.info("Starting CMYK color space conversion");
List<String> command = new ArrayList<>();
command.add("gs");
command.add("-sDEVICE=pdfwrite");
command.add("-dCompatibilityLevel=1.5");
command.add("-dPDFSETTINGS=/prepress");
command.add("-dNOPAUSE");
command.add("-dQUIET");
command.add("-dBATCH");
command.add("-sProcessColorModel=DeviceCMYK");
command.add("-sColorConversionStrategy=CMYK");
command.add("-sColorConversionStrategyForImages=CMYK");
command.add("-sOutputFile=" + tempOutputFile.toString());
command.add(tempInputFile.toString());
log.debug("Executing Ghostscript command for CMYK conversion: {}", command);
ProcessExecutorResult result =
ProcessExecutor.getInstance(ProcessExecutor.Processes.GHOSTSCRIPT)
.runCommandWithOutputHandling(command);
if (result.getRc() != 0) {
log.error(
"Ghostscript CMYK conversion failed with return code: {}. Output: {}",
result.getRc(),
result.getMessages());
throw new IOException(
"CMYK color space conversion failed: " + result.getMessages());
}
log.info("CMYK color space conversion completed successfully");
byte[] pdfBytes = Files.readAllBytes(tempOutputFile);
return new InputStreamResource(new ByteArrayInputStream(pdfBytes));
} catch (Exception e) {
log.warn("CMYK color space conversion failed", e);
throw new IOException(
"Failed to convert PDF to CMYK color space: " + e.getMessage(), e);
}
}
}
@@ -1,17 +0,0 @@
package stirling.software.common.configuration.interfaces;
import static org.junit.jupiter.api.Assertions.assertTrue;
import org.junit.jupiter.api.Test;
class ShowAdminInterfaceTest {
// Create a simple implementation for testing
static class TestImpl implements ShowAdminInterface {}
@Test
void getShowUpdateOnlyAdmins_returnsTrueByDefault() {
ShowAdminInterface instance = new TestImpl();
assertTrue(instance.getShowUpdateOnlyAdmins(), "Default should return true");
}
}
@@ -1,59 +0,0 @@
package stirling.software.common.model;
import static org.junit.jupiter.api.Assertions.*;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import org.junit.jupiter.api.Test;
import org.mockito.MockedStatic;
import org.mockito.Mockito;
import org.springframework.core.env.ConfigurableEnvironment;
import org.springframework.core.env.StandardEnvironment;
import stirling.software.common.configuration.InstallationPathConfig;
class ApplicationPropertiesDynamicYamlPropertySourceTest {
@Test
void loads_yaml_into_environment() throws Exception {
String yaml =
"""
\
ui:
appName: "My App"
system:
enableAnalytics: true
""";
Path tmp = Files.createTempFile("spdf-settings-", ".yml");
Files.writeString(tmp, yaml);
try (MockedStatic<InstallationPathConfig> mocked =
Mockito.mockStatic(InstallationPathConfig.class)) {
mocked.when(InstallationPathConfig::getSettingsPath).thenReturn(tmp.toString());
ConfigurableEnvironment env = new StandardEnvironment();
ApplicationProperties props = new ApplicationProperties();
props.dynamicYamlPropertySource(env);
assertEquals("My App", env.getProperty("ui.appName"));
assertEquals("true", env.getProperty("system.enableAnalytics"));
}
}
@Test
void throws_when_settings_file_missing() {
String missing = "/path/does/not/exist/spdf.yml";
try (MockedStatic<InstallationPathConfig> mocked =
Mockito.mockStatic(InstallationPathConfig.class)) {
mocked.when(InstallationPathConfig::getSettingsPath).thenReturn(missing);
ConfigurableEnvironment env = new StandardEnvironment();
ApplicationProperties props = new ApplicationProperties();
assertThrows(IOException.class, () -> props.dynamicYamlPropertySource(env));
}
}
}
@@ -1,258 +0,0 @@
package stirling.software.common.model;
import static org.junit.jupiter.api.Assertions.*;
import java.io.File;
import java.nio.file.Paths;
import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
import java.util.Locale;
import java.util.function.Function;
import org.junit.jupiter.api.Test;
import stirling.software.common.model.ApplicationProperties.Driver;
import stirling.software.common.model.ApplicationProperties.Premium;
import stirling.software.common.model.ApplicationProperties.Security;
import stirling.software.common.model.exception.UnsupportedProviderException;
class ApplicationPropertiesLogicTest {
@Test
void system_isAnalyticsEnabled_null_false_true() {
ApplicationProperties.System sys = new ApplicationProperties.System();
sys.setEnableAnalytics(null);
assertFalse(sys.isAnalyticsEnabled());
sys.setEnableAnalytics(Boolean.FALSE);
assertFalse(sys.isAnalyticsEnabled());
sys.setEnableAnalytics(Boolean.TRUE);
assertTrue(sys.isAnalyticsEnabled());
}
@Test
void tempFileManagement_defaults_and_overrides() {
Function<String, String> normalize = s -> Paths.get(s).normalize().toString();
ApplicationProperties.TempFileManagement tfm =
new ApplicationProperties.TempFileManagement();
String expectedBase =
Paths.get(java.lang.System.getProperty("java.io.tmpdir"), "stirling-pdf")
.toString();
assertEquals(expectedBase, tfm.getBaseTmpDir());
String expectedLibre = Paths.get(expectedBase, "libreoffice").toString();
assertEquals(expectedLibre, tfm.getLibreofficeDir());
tfm.setBaseTmpDir(File.separator + "custom" + File.separator + "base");
assertEquals(
File.separator + "custom" + File.separator + "base",
normalize.apply(tfm.getBaseTmpDir()));
tfm.setLibreofficeDir(File.separator + "opt" + File.separator + "libre");
assertEquals(
File.separator + "opt" + File.separator + "libre",
normalize.apply(tfm.getLibreofficeDir()));
}
@Test
void oauth2_scope_parsing_and_validity() {
Security.OAUTH2 oauth2 = new Security.OAUTH2();
oauth2.setIssuer("https://issuer");
oauth2.setClientId("client");
oauth2.setClientSecret("secret");
oauth2.setUseAsUsername("email");
oauth2.setScopes("openid, profile ,email");
assertTrue(oauth2.isSettingsValid());
}
@Test
void security_login_method_flags() {
Security sec = new Security();
sec.getOauth2().setEnabled(true);
sec.getSaml2().setEnabled(true);
assertTrue(sec.isUserPass());
assertTrue(sec.isOauth2Active());
assertTrue(sec.isSaml2Active());
sec.setLoginMethod(Security.LoginMethods.NORMAL.toString());
assertTrue(sec.isUserPass());
assertFalse(sec.isOauth2Active());
assertFalse(sec.isSaml2Active());
}
@Test
void security_isAltLogin_reflects_oauth2_or_saml2() {
Security sec = new Security();
assertFalse(sec.isAltLogin());
sec.getOauth2().setEnabled(true);
sec.getSaml2().setEnabled(false);
assertTrue(sec.isAltLogin());
sec.getOauth2().setEnabled(false);
sec.getSaml2().setEnabled(true);
assertTrue(sec.isAltLogin());
sec.getOauth2().setEnabled(true);
sec.getSaml2().setEnabled(true);
assertTrue(sec.isAltLogin());
}
@Test
void oauth2_client_provider_mapping_and_unsupported() throws UnsupportedProviderException {
Security.OAUTH2.Client client = new Security.OAUTH2.Client();
assertNotNull(client.get("google"));
assertNotNull(client.get("github"));
assertNotNull(client.get("keycloak"));
UnsupportedProviderException ex =
assertThrows(UnsupportedProviderException.class, () -> client.get("unknown"));
assertTrue(ex.getMessage().toLowerCase(Locale.ROOT).contains("not supported"));
}
@Test
void premium_google_drive_getters_return_empty_string_on_null_or_blank() {
Premium.ProFeatures.GoogleDrive gd = new Premium.ProFeatures.GoogleDrive();
assertEquals("", gd.getClientId());
assertEquals("", gd.getApiKey());
assertEquals("", gd.getAppId());
gd.setClientId(" id ");
gd.setApiKey(" key ");
gd.setAppId(" app ");
assertEquals(" id ", gd.getClientId());
assertEquals(" key ", gd.getApiKey());
assertEquals(" app ", gd.getAppId());
}
@Test
void ui_getters_return_null_for_blank() {
ApplicationProperties.Ui ui = new ApplicationProperties.Ui();
ui.setAppName(" ");
ui.setHomeDescription("");
ui.setAppNameNavbar(null);
assertNull(ui.getAppName());
assertNull(ui.getHomeDescription());
assertNull(ui.getAppNameNavbar());
ui.setAppName("Stirling-PDF");
ui.setHomeDescription("Home");
ui.setAppNameNavbar("Nav");
assertEquals("Stirling-PDF", ui.getAppName());
assertEquals("Home", ui.getHomeDescription());
assertEquals("Nav", ui.getAppNameNavbar());
}
@Test
void driver_toString_contains_driver_name() {
assertTrue(Driver.H2.toString().contains("h2"));
assertTrue(Driver.POSTGRESQL.toString().contains("postgresql"));
}
@Test
void session_limits_and_timeouts_have_reasonable_defaults() {
ApplicationProperties.ProcessExecutor pe = new ApplicationProperties.ProcessExecutor();
ApplicationProperties.ProcessExecutor.SessionLimit s = pe.getSessionLimit();
assertEquals(2, s.getQpdfSessionLimit());
assertEquals(1, s.getTesseractSessionLimit());
assertEquals(1, s.getLibreOfficeSessionLimit());
assertEquals(1, s.getPdfToHtmlSessionLimit());
assertEquals(8, s.getPythonOpenCvSessionLimit());
assertEquals(16, s.getWeasyPrintSessionLimit());
assertEquals(1, s.getInstallAppSessionLimit());
assertEquals(1, s.getCalibreSessionLimit());
assertEquals(8, s.getGhostscriptSessionLimit());
assertEquals(2, s.getOcrMyPdfSessionLimit());
ApplicationProperties.ProcessExecutor.TimeoutMinutes t = pe.getTimeoutMinutes();
assertEquals(30, t.getTesseractTimeoutMinutes());
assertEquals(30, t.getQpdfTimeoutMinutes());
assertEquals(30, t.getLibreOfficeTimeoutMinutes());
assertEquals(20, t.getPdfToHtmlTimeoutMinutes());
assertEquals(30, t.getPythonOpenCvTimeoutMinutes());
assertEquals(30, t.getWeasyPrintTimeoutMinutes());
assertEquals(60, t.getInstallAppTimeoutMinutes());
assertEquals(30, t.getCalibreTimeoutMinutes());
assertEquals(30, t.getGhostscriptTimeoutMinutes());
assertEquals(30, t.getOcrMyPdfTimeoutMinutes());
}
@Deprecated(since = "0.45.0")
@Test
void enterprise_metadata_defaults() {
ApplicationProperties.EnterpriseEdition ee = new ApplicationProperties.EnterpriseEdition();
ApplicationProperties.EnterpriseEdition.CustomMetadata eMeta = ee.getCustomMetadata();
eMeta.setCreator(" ");
eMeta.setProducer(null);
assertEquals("Stirling-PDF", eMeta.getCreator());
assertEquals("Stirling-PDF", eMeta.getProducer());
}
@Test
void premium_metadata_defaults() {
Premium.ProFeatures pf = new Premium.ProFeatures();
Premium.ProFeatures.CustomMetadata pMeta = pf.getCustomMetadata();
pMeta.setCreator("");
pMeta.setProducer("");
assertEquals("Stirling-PDF", pMeta.getCreator());
assertEquals("Stirling-PDF", pMeta.getProducer());
}
@Test
void premium_metadata_awesome() {
Premium.ProFeatures pf = new Premium.ProFeatures();
Premium.ProFeatures.CustomMetadata pMeta = pf.getCustomMetadata();
pMeta.setCreator("Awesome PDF Tool");
pMeta.setProducer("Awesome PDF Tool");
assertEquals("Awesome PDF Tool", pMeta.getCreator());
assertEquals("Awesome PDF Tool", pMeta.getProducer());
}
@Test
void string_isValid_handles_null_empty_blank_and_trimmed() {
ApplicationProperties.Security.OAUTH2 oauth2 = new ApplicationProperties.Security.OAUTH2();
assertFalse(oauth2.isValid((String) null, "issuer"));
assertFalse(oauth2.isValid("", "issuer"));
assertFalse(oauth2.isValid(" ", "issuer"));
assertTrue(oauth2.isValid("x", "issuer"));
assertTrue(oauth2.isValid(" x ", "issuer")); // trimmt intern
}
@Test
void collection_isValid_handles_null_and_empty() {
ApplicationProperties.Security.OAUTH2 oauth2 = new ApplicationProperties.Security.OAUTH2();
Collection<String> nullColl = null;
Collection<String> empty = List.of();
assertFalse(oauth2.isValid((Collection<String>) null, "scopes"));
assertFalse(oauth2.isValid(empty, "scopes"));
}
@Test
void collection_isValid_true_when_non_empty_even_if_element_is_blank() {
ApplicationProperties.Security.OAUTH2 oauth2 = new ApplicationProperties.Security.OAUTH2();
// Current behavior: checks ONLY !isEmpty(), not the content
Collection<String> oneBlank = new ArrayList<>();
oneBlank.add(" ");
assertTrue(
oauth2.isValid(oneBlank, "scopes"),
"Documents current behavior: non-empty list is considered valid, even if an element"
+ " is empty/blank");
}
}
@@ -1,81 +0,0 @@
package stirling.software.common.model;
import static org.junit.jupiter.api.Assertions.*;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import org.junit.jupiter.api.Test;
import org.springframework.core.io.FileSystemResource;
import org.springframework.core.io.Resource;
import org.springframework.http.MediaType;
import okhttp3.mockwebserver.MockResponse;
import okhttp3.mockwebserver.MockWebServer;
class ApplicationPropertiesSaml2HttpTest {
@Test
void idpMetadataUri_http_is_resolved_via_mockwebserver() throws Exception {
try (MockWebServer server = new MockWebServer()) {
server.enqueue(
new MockResponse()
.setResponseCode(200)
.addHeader("Content-Type", MediaType.APPLICATION_XML_VALUE)
.setBody("<EntityDescriptor/>"));
server.start();
String url = server.url("/meta").toString();
var s = new ApplicationProperties.Security.SAML2();
s.setIdpMetadataUri(url);
try (InputStream in = s.getIdpMetadataUri()) {
String body = new String(in.readAllBytes(), StandardCharsets.UTF_8);
assertTrue(body.contains("EntityDescriptor"));
}
}
}
@Test
void idpMetadataUri_invalidUri_triggers_catch_and_throwsIOException() {
// Ungültige URI -> new URI(...) wirft URISyntaxException -> catch -> IOException
var s = new ApplicationProperties.Security.SAML2();
s.setIdpMetadataUri("http:##invalid uri"); // absichtlich kaputt (Leerzeichen + ##)
assertThrows(IOException.class, s::getIdpMetadataUri);
}
@Test
void spCert_else_branch_returns_FileSystemResource_for_filesystem_path() throws Exception {
var s = new ApplicationProperties.Security.SAML2();
// temporäre Datei simuliert "Filesystem"-Pfad (-> else-Zweig)
Path tmp = Files.createTempFile("spdf-spcert-", ".crt");
Files.writeString(tmp, "CERT");
s.setSpCert(tmp.toString());
Resource r = s.getSpCert();
assertNotNull(r);
assertInstanceOf(FileSystemResource.class, r, "Expected FileSystemResource for FS path");
assertTrue(r.exists(), "Temp file should exist");
}
@Test
void idpCert_else_branch_returns_FileSystemResource_even_if_missing() {
var s = new ApplicationProperties.Security.SAML2();
// bewusst nicht existierender Pfad -> else-Zweig wird trotzdem genommen
String missing = "/this/path/does/not/exist/idp.crt";
s.setIdpCert(missing);
Resource r = s.getIdpCert();
assertNotNull(r);
assertInstanceOf(FileSystemResource.class, r, "Expected FileSystemResource for FS path");
assertFalse(r.exists(), "Resource should not exist for missing file");
}
}
@@ -1,55 +0,0 @@
package stirling.software.common.model;
import static org.junit.jupiter.api.Assertions.*;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import org.junit.jupiter.api.Test;
import org.springframework.core.io.Resource;
class ApplicationPropertiesSaml2ResourceTest {
@Test
void idpMetadataUri_classpath_is_resolved() throws Exception {
var s = new ApplicationProperties.Security.SAML2();
s.setIdpMetadataUri("classpath:saml/dummy.txt");
try (InputStream in = s.getIdpMetadataUri()) {
assertNotNull(in, "Classpath InputStream should not be null");
String txt = new String(in.readAllBytes(), StandardCharsets.UTF_8);
assertTrue(txt.contains("ok"));
}
}
@Test
void spCert_idpCert_privateKey_null_classpath_and_filesystem() throws Exception {
var s = new ApplicationProperties.Security.SAML2();
s.setSpCert(null);
s.setIdpCert(null);
s.setPrivateKey(null);
assertNull(s.getSpCert());
assertNull(s.getIdpCert());
assertNull(s.getPrivateKey());
s.setSpCert("classpath:saml/dummy.txt");
s.setIdpCert("classpath:saml/dummy.txt");
s.setPrivateKey("classpath:saml/dummy.txt");
Resource sp = s.getSpCert();
Resource idp = s.getIdpCert();
Resource pk = s.getPrivateKey();
assertTrue(sp.exists());
assertTrue(idp.exists());
assertTrue(pk.exists());
Path tmp = Files.createTempFile("spdf-key-", ".pem");
Files.writeString(tmp, "KEY");
s.setPrivateKey(tmp.toString());
Resource pkFs = s.getPrivateKey();
assertNotNull(pkFs);
assertTrue(pkFs.exists());
}
}
@@ -1,174 +0,0 @@
package stirling.software.common.model;
import static org.junit.jupiter.api.Assertions.*;
import java.nio.file.Path;
import java.time.LocalDateTime;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
public class FileInfoTest {
private static final LocalDateTime FIXED_NOW = LocalDateTime.of(2025, 11, 1, 12, 0, 0);
@ParameterizedTest(name = "{index}: fileSize={0}")
@CsvSource({
"0, '0 Bytes'",
"1023, '1023 Bytes'",
"1024, '1.00 KB'",
"1048575, '1024.00 KB'", // Do we really want this as result?
"1048576, '1.00 MB'",
"1073741823, '1024.00 MB'", // Do we really want this as result?
"1073741824, '1.00 GB'"
})
void testGetFormattedFileSize(long fileSize, String expectedFormattedSize) {
FileInfo fileInfo =
new FileInfo(
"example.txt",
"/path/to/example.txt",
FIXED_NOW,
fileSize,
FIXED_NOW.minusDays(1));
assertEquals(expectedFormattedSize, fileInfo.getFormattedFileSize());
}
@Nested
@DisplayName("getFilePathAsPath")
class GetFilePathAsPathTests {
@Test
@DisplayName("Should convert filePath string into a Path instance")
void shouldConvertStringToPath() {
FileInfo fi =
new FileInfo(
"example.txt",
"/path/to/example.txt",
FIXED_NOW,
123,
FIXED_NOW.minusDays(1));
Path path = fi.getFilePathAsPath();
// Basic sanity checks
assertNotNull(path, "Path should not be null");
assertEquals(
Path.of("/path/to/example.txt"),
path,
"Converted Path should match input string");
}
}
@Nested
@DisplayName("Date formatting")
class DateFormattingTests {
@Test
@DisplayName("Should format modificationDate as 'yyyy-MM-dd HH:mm:ss'")
void shouldFormatModificationDate() {
LocalDateTime mod = LocalDateTime.of(2025, 8, 10, 15, 30, 45);
FileInfo fi =
new FileInfo(
"example.txt",
"/path/to/example.txt",
mod,
1,
LocalDateTime.of(2024, 1, 1, 0, 0, 0));
assertEquals("2025-08-10 15:30:45", fi.getFormattedModificationDate());
}
@Test
@DisplayName("Should format creationDate as 'yyyy-MM-dd HH:mm:ss'")
void shouldFormatCreationDate() {
LocalDateTime created = LocalDateTime.of(2024, 12, 31, 23, 59, 59);
FileInfo fi =
new FileInfo(
"example.txt",
"/path/to/example.txt",
LocalDateTime.of(2025, 1, 1, 0, 0, 0),
1,
created);
assertEquals("2024-12-31 23:59:59", fi.getFormattedCreationDate());
}
@Test
@DisplayName("Should throw NPE when modificationDate is null (current behavior)")
void shouldThrowWhenModificationDateNull() {
// Assumption: Current implementation does not guard null -> NPE is expected.
FileInfo fi =
new FileInfo(
"example.txt",
"/path/to/example.txt",
null, // modificationDate null
1,
FIXED_NOW);
assertThrows(
NullPointerException.class,
fi::getFormattedModificationDate,
"Formatting a null modificationDate should throw NPE with current"
+ " implementation");
}
@Test
@DisplayName("Should throw NPE when creationDate is null (current behavior)")
void shouldThrowWhenCreationDateNull() {
// Assumption: Current implementation does not guard null -> NPE is expected.
FileInfo fi =
new FileInfo(
"example.txt",
"/path/to/example.txt",
FIXED_NOW,
1,
null); // creationDate null
assertThrows(
NullPointerException.class,
fi::getFormattedCreationDate,
"Formatting a null creationDate should throw NPE with current implementation");
}
}
@Nested
@DisplayName("Additional size formatting cases")
class AdditionalSizeFormattingTests {
@Test
@DisplayName("Should round to two decimals for KB (e.g., 1536 B -> 1.50 KB)")
void shouldRoundKbToTwoDecimals() {
FileInfo fi =
new FileInfo(
"example.txt",
"/path/to/example.txt",
FIXED_NOW,
1536, // 1.5 KB
FIXED_NOW.minusDays(1));
assertEquals("1.50 KB", fi.getFormattedFileSize());
}
@Test
@DisplayName("Values above 1 TB are still represented in GB (design choice)")
void shouldRepresentTerabytesInGb() {
// 2 TB = 2 * 1024 GB -> 2 * 1024 * 1024^3 bytes
long twoTB = 2L * 1024 * 1024 * 1024 * 1024; // 2 * 2^40
FileInfo fi =
new FileInfo(
"example.txt",
"/path/to/example.txt",
FIXED_NOW,
twoTB,
FIXED_NOW.minusDays(1));
// 2 TB equals 2048.00 GB with current implementation
assertEquals(
"2048.00 GB",
fi.getFormattedFileSize(),
"Current implementation caps at GB and shows TB in GB units");
}
}
}
@@ -1,95 +0,0 @@
package stirling.software.common.model;
import static org.junit.jupiter.api.Assertions.*;
import java.io.ByteArrayInputStream;
import java.io.InputStream;
import java.io.Reader;
import java.lang.reflect.Field;
import java.lang.reflect.Modifier;
import java.nio.charset.StandardCharsets;
import java.util.Arrays;
import org.junit.jupiter.api.Test;
public class InputStreamTemplateResourceTest {
@Test
void gettersReturnProvidedFields() {
byte[] data = {1, 2, 3};
InputStream is = new ByteArrayInputStream(data);
String encoding = "UTF-8";
InputStreamTemplateResource resource = new InputStreamTemplateResource(is, encoding);
assertSame(is, resource.getInputStream());
assertEquals(encoding, resource.getCharacterEncoding());
}
@Test
void fieldsAreFinal() throws NoSuchFieldException {
Field inputStreamField = InputStreamTemplateResource.class.getDeclaredField("inputStream");
Field characterEncodingField =
InputStreamTemplateResource.class.getDeclaredField("characterEncoding");
assertTrue(Modifier.isFinal(inputStreamField.getModifiers()));
assertTrue(Modifier.isFinal(characterEncodingField.getModifiers()));
}
@Test
void noSetterMethodsPresent() {
long setterCount =
Arrays.stream(InputStreamTemplateResource.class.getDeclaredMethods())
.filter(method -> method.getName().startsWith("set"))
.count();
assertEquals(0, setterCount, "InputStreamTemplateResource should not have setter methods");
}
@Test
void readerReturnsCorrectContent() throws Exception {
String content = "Hello, world!";
InputStream is = new ByteArrayInputStream(content.getBytes(StandardCharsets.UTF_8));
InputStreamTemplateResource resource = new InputStreamTemplateResource(is, "UTF-8");
try (Reader reader = resource.reader()) {
char[] buffer = new char[content.length()];
int read = reader.read(buffer);
assertEquals(content.length(), read);
assertEquals(content, new String(buffer));
}
}
@Test
void relativeThrowsUnsupportedOperationException() {
InputStream is = new ByteArrayInputStream(new byte[0]);
InputStreamTemplateResource resource = new InputStreamTemplateResource(is, "UTF-8");
assertThrows(UnsupportedOperationException.class, () -> resource.relative("other"));
}
@Test
void getDescriptionReturnsExpectedString() {
InputStream is = new ByteArrayInputStream(new byte[0]);
InputStreamTemplateResource resource = new InputStreamTemplateResource(is, "UTF-8");
assertEquals("InputStream resource [Stream]", resource.getDescription());
}
@Test
void getBaseNameReturnsExpectedString() {
InputStream is = new ByteArrayInputStream(new byte[0]);
InputStreamTemplateResource resource = new InputStreamTemplateResource(is, "UTF-8");
assertEquals("streamResource", resource.getBaseName());
}
@Test
void existsReturnsTrueWhenInputStreamNotNull() {
InputStream is = new ByteArrayInputStream(new byte[0]);
InputStreamTemplateResource resource = new InputStreamTemplateResource(is, "UTF-8");
assertTrue(resource.exists());
}
@Test
void existsReturnsFalseWhenInputStreamIsNull() {
InputStreamTemplateResource resource = new InputStreamTemplateResource(null, "UTF-8");
assertFalse(resource.exists());
}
}
@@ -1,34 +0,0 @@
package stirling.software.common.model.exception;
import static org.junit.jupiter.api.Assertions.*;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
@DisplayName("Tests for UnsupportedClaimException")
class UnsupportedClaimExceptionTest {
@Test
@DisplayName("should store message passed to constructor")
void shouldStoreMessageFromConstructor() {
String expectedMessage = "This claim is not supported";
UnsupportedClaimException exception = new UnsupportedClaimException(expectedMessage);
// Verify the stored message
assertEquals(
expectedMessage,
exception.getMessage(),
"Constructor should correctly store the provided message");
}
@Test
@DisplayName("should allow null message without throwing exception")
void shouldAllowNullMessage() {
UnsupportedClaimException exception = new UnsupportedClaimException(null);
// Null message should be stored as null
assertNull(
exception.getMessage(),
"Constructor should accept null message and store it as null");
}
}
@@ -1,66 +0,0 @@
package stirling.software.common.util;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.io.ByteArrayInputStream;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.util.Map;
import org.junit.jupiter.api.Test;
public class ChecksumUtilsTest {
@Test
void computeChecksums_basic() throws Exception {
byte[] data = "hello".getBytes(StandardCharsets.UTF_8);
// MD5 (hex)
try (InputStream is = new ByteArrayInputStream(data)) {
assertEquals("5d41402abc4b2a76b9719d911017c592", ChecksumUtils.checksum(is, "MD5"));
}
// MD5 (Base64)
try (InputStream is = new ByteArrayInputStream(data)) {
assertEquals("XUFAKrxLKna5cZ2REBfFkg==", ChecksumUtils.checksumBase64(is, "MD5"));
}
// MD5 + CRC32 (hex map)
try (InputStream is = new ByteArrayInputStream(data)) {
Map<String, String> map = ChecksumUtils.checksums(is, "MD5", "CRC32");
assertEquals("5d41402abc4b2a76b9719d911017c592", map.get("MD5"));
assertEquals("3610a686", map.get("CRC32"));
}
}
@Test
void crc32_base64_bigEndianBytes_forHello() throws Exception {
// CRC32("hello") = 0x3610A686 → bytes: 36 10 A6 86 → Base64: "NhCmhg=="
byte[] data = "hello".getBytes(StandardCharsets.UTF_8);
try (InputStream is = new ByteArrayInputStream(data)) {
assertEquals("NhCmhg==", ChecksumUtils.checksumBase64(is, "CRC32"));
}
}
@Test
void crc32_unsignedFormatting_highBitSet() throws Exception {
// CRC32 of single zero byte (0x00) is 0xD202EF8D (>= 0x8000_0000)
byte[] data = {0x00};
// Hex (unsigned, 8 chars, lowercase)
try (InputStream is = new ByteArrayInputStream(data)) {
assertEquals("d202ef8d", ChecksumUtils.checksum(is, "CRC32"));
}
// Base64 of the 4-byte big-endian representation
try (InputStream is = new ByteArrayInputStream(data)) {
assertEquals("0gLvjQ==", ChecksumUtils.checksumBase64(is, "CRC32"));
}
// matches(..) must be case-insensitive for hex
try (InputStream is = new ByteArrayInputStream("hello".getBytes(StandardCharsets.UTF_8))) {
assertTrue(ChecksumUtils.matches(is, "CRC32", "3610A686")); // uppercase expected
}
}
}
File diff suppressed because it is too large Load Diff
@@ -1,354 +0,0 @@
package stirling.software.common.util;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
import java.io.IOException;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.mockito.MockedStatic;
/**
* Unit tests for {@link ExceptionUtils}. Assumptions: - PdfErrorUtils.isCorruptedPdfError is a
* static method that returns true for certain exception types. We will mock it in tests for
* handlePdfException and logException.
*/
class ExceptionUtilsTest {
@Nested
@DisplayName("PDF corruption exception creation")
class PdfCorruptionTests {
@Test
@DisplayName("should create PdfCorruptedException without context")
void testCreatePdfCorruptedExceptionWithoutContext() {
Exception cause = new Exception("root");
IOException ex = ExceptionUtils.createPdfCorruptedException(null, cause);
assertTrue(ex.getMessage().contains("PDF file appears to be corrupted"));
assertSame(cause, ex.getCause());
}
@Test
@DisplayName("should create PdfCorruptedException with context")
void testCreatePdfCorruptedExceptionWithContext() {
Exception cause = new Exception("root");
IOException ex = ExceptionUtils.createPdfCorruptedException("during merge", cause);
assertTrue(
ex.getMessage()
.startsWith("Error during merge: PDF file appears to be corrupted"));
assertSame(cause, ex.getCause());
}
@Test
@DisplayName("should create MultiplePdfCorruptedException")
void testCreateMultiplePdfCorruptedException() {
Exception cause = new Exception("root");
IOException ex = ExceptionUtils.createMultiplePdfCorruptedException(cause);
assertTrue(ex.getMessage().startsWith("One or more PDF files appear to be corrupted"));
assertSame(cause, ex.getCause());
}
}
@Nested
@DisplayName("PDF encryption and password exception creation")
class PdfSecurityTests {
@Test
void testCreatePdfEncryptionException() {
Exception cause = new Exception("root");
IOException ex = ExceptionUtils.createPdfEncryptionException(cause);
assertTrue(ex.getMessage().contains("corrupted encryption data"));
assertSame(cause, ex.getCause());
}
@Test
void testCreatePdfPasswordException() {
Exception cause = new Exception("root");
IOException ex = ExceptionUtils.createPdfPasswordException(cause);
assertTrue(ex.getMessage().contains("passworded"));
assertSame(cause, ex.getCause());
}
}
@Nested
@DisplayName("File processing exception creation")
class FileProcessingTests {
@Test
void testCreateFileProcessingException() {
Exception cause = new Exception("boom");
IOException ex = ExceptionUtils.createFileProcessingException("merge", cause);
assertTrue(ex.getMessage().contains("while processing the file during merge"));
assertSame(cause, ex.getCause());
}
}
@Nested
@DisplayName("Generic exception creation")
class GenericCreationTests {
@Test
void testCreateIOException() {
IOException ex =
ExceptionUtils.createIOException(
"key", "Default message: {0}", new Exception("cause"), "X");
assertEquals("Default message: X", ex.getMessage());
}
@Test
void testCreateRuntimeException() {
RuntimeException ex =
ExceptionUtils.createRuntimeException(
"key", "Default message: {0}", new Exception("cause"), "Y");
assertEquals("Default message: Y", ex.getMessage());
}
@Test
void testCreateIllegalArgumentException() {
IllegalArgumentException ex =
ExceptionUtils.createIllegalArgumentException("key", "Format {0}", "Z");
assertEquals("Format Z", ex.getMessage());
}
}
@Nested
@DisplayName("Predefined validation exceptions")
class PredefinedValidationTests {
@Test
void testCreateHtmlFileRequiredException() {
IllegalArgumentException ex = ExceptionUtils.createHtmlFileRequiredException();
assertTrue(ex.getMessage().contains("HTML or ZIP"));
}
@Test
void testCreatePdfFileRequiredException() {
IllegalArgumentException ex = ExceptionUtils.createPdfFileRequiredException();
assertTrue(ex.getMessage().contains("PDF"));
}
@Test
void testCreateInvalidPageSizeException() {
IllegalArgumentException ex = ExceptionUtils.createInvalidPageSizeException("A5");
assertTrue(ex.getMessage().contains("page size"));
}
}
@Nested
@DisplayName("OCR and system requirement exceptions")
class OcrAndSystemTests {
@Test
void testCreateOcrLanguageRequiredException() {
IOException ex = ExceptionUtils.createOcrLanguageRequiredException();
assertTrue(ex.getMessage().contains("OCR language"));
}
@Test
void testCreateOcrInvalidLanguagesException() {
IOException ex = ExceptionUtils.createOcrInvalidLanguagesException();
assertTrue(ex.getMessage().contains("none of the selected languages"));
}
@Test
void testCreateOcrToolsUnavailableException() {
IOException ex = ExceptionUtils.createOcrToolsUnavailableException();
assertTrue(ex.getMessage().contains("OCR tools"));
}
@Test
void testCreatePythonRequiredForWebpException() {
IOException ex = ExceptionUtils.createPythonRequiredForWebpException();
assertTrue(ex.getMessage().contains("Python"));
assertTrue(ex.getMessage().contains("WebP conversion"));
}
}
@Nested
@DisplayName("File operation and compression exceptions")
class FileAndCompressionTests {
@Test
void testCreatePdfaConversionFailedException() {
RuntimeException ex = ExceptionUtils.createPdfaConversionFailedException();
assertTrue(ex.getMessage().contains("PDF/A conversion failed"));
}
@Test
void testCreateMd5AlgorithmException() {
RuntimeException ex = ExceptionUtils.createMd5AlgorithmException(new Exception("x"));
assertTrue(ex.getMessage().contains("MD5"));
}
@Test
void testCreateGhostscriptCompressionExceptionNoCause() {
IOException ex = ExceptionUtils.createGhostscriptCompressionException();
assertTrue(ex.getMessage().contains("Ghostscript"));
}
@Test
void testCreateGhostscriptCompressionExceptionWithCause() {
IOException ex =
ExceptionUtils.createGhostscriptCompressionException(new Exception("cause"));
assertTrue(ex.getMessage().contains("Ghostscript"));
}
}
@Nested
@DisplayName("PDF exception handling")
class PdfExceptionHandlingTests {
@Test
void testHandlePdfExceptionWhenCorrupted() {
IOException original = new IOException("corrupted pdf");
try (MockedStatic<PdfErrorUtils> mock = mockStatic(PdfErrorUtils.class)) {
mock.when(() -> PdfErrorUtils.isCorruptedPdfError(original)).thenReturn(true);
IOException result = ExceptionUtils.handlePdfException(original);
assertNotSame(original, result);
assertTrue(result.getMessage().contains("corrupted"));
}
}
@Test
void testHandlePdfExceptionWhenEncryptionError() {
IOException original = new IOException("BadPaddingException");
try (MockedStatic<PdfErrorUtils> mock = mockStatic(PdfErrorUtils.class)) {
mock.when(() -> PdfErrorUtils.isCorruptedPdfError(original)).thenReturn(false);
IOException result = ExceptionUtils.handlePdfException(original);
assertTrue(result.getMessage().contains("corrupted encryption data"));
}
}
@Test
void testHandlePdfExceptionWhenPasswordError() {
IOException original = new IOException("password is incorrect");
try (MockedStatic<PdfErrorUtils> mock = mockStatic(PdfErrorUtils.class)) {
mock.when(() -> PdfErrorUtils.isCorruptedPdfError(original)).thenReturn(false);
IOException result = ExceptionUtils.handlePdfException(original);
assertTrue(result.getMessage().contains("passworded"));
}
}
@Test
void testHandlePdfExceptionWhenNoSpecialError() {
IOException original = new IOException("something else");
try (MockedStatic<PdfErrorUtils> mock = mockStatic(PdfErrorUtils.class)) {
mock.when(() -> PdfErrorUtils.isCorruptedPdfError(original)).thenReturn(false);
IOException result = ExceptionUtils.handlePdfException(original);
assertSame(original, result);
}
}
}
@Nested
@DisplayName("Encryption and password detection")
class ErrorDetectionTests {
@Test
void testIsEncryptionErrorTrue() {
assertTrue(ExceptionUtils.isEncryptionError(new IOException("BadPaddingException")));
assertTrue(
ExceptionUtils.isEncryptionError(
new IOException("Given final block not properly padded")));
assertTrue(
ExceptionUtils.isEncryptionError(
new IOException("AES initialization vector not fully read")));
assertTrue(ExceptionUtils.isEncryptionError(new IOException("Failed to decrypt")));
}
@Test
void testIsEncryptionErrorFalse() {
assertFalse(ExceptionUtils.isEncryptionError(new IOException("other message")));
assertFalse(ExceptionUtils.isEncryptionError(new IOException((String) null)));
}
@Test
void testIsPasswordErrorTrue() {
assertTrue(ExceptionUtils.isPasswordError(new IOException("password is incorrect")));
assertTrue(ExceptionUtils.isPasswordError(new IOException("Password is not provided")));
assertTrue(
ExceptionUtils.isPasswordError(
new IOException("PDF contains an encryption dictionary")));
}
@Test
void testIsPasswordErrorFalse() {
assertFalse(ExceptionUtils.isPasswordError(new IOException("something else")));
assertFalse(ExceptionUtils.isPasswordError(new IOException((String) null)));
}
}
@Nested
@DisplayName("Logging behavior")
class LoggingTests {
@Test
void testLogExceptionWhenCorruptedPdf() {
Exception e = new IOException("corrupted");
try (MockedStatic<PdfErrorUtils> mock = mockStatic(PdfErrorUtils.class)) {
mock.when(() -> PdfErrorUtils.isCorruptedPdfError(e)).thenReturn(true);
// We can't assert log output here without a custom appender, but this ensures no
// exception is thrown
ExceptionUtils.logException("merge", e);
}
}
@Test
void testLogExceptionWhenEncryptionError() {
IOException e = new IOException("BadPaddingException");
try (MockedStatic<PdfErrorUtils> mock = mockStatic(PdfErrorUtils.class)) {
mock.when(() -> PdfErrorUtils.isCorruptedPdfError(e)).thenReturn(false);
ExceptionUtils.logException("merge", e);
}
}
@Test
void testLogExceptionWhenPasswordError() {
IOException e = new IOException("password is incorrect");
try (MockedStatic<PdfErrorUtils> mock = mockStatic(PdfErrorUtils.class)) {
mock.when(() -> PdfErrorUtils.isCorruptedPdfError(e)).thenReturn(false);
ExceptionUtils.logException("merge", e);
}
}
@Test
void testLogExceptionUnexpectedError() {
Exception e = new RuntimeException("unexpected");
try (MockedStatic<PdfErrorUtils> mock = mockStatic(PdfErrorUtils.class)) {
mock.when(() -> PdfErrorUtils.isCorruptedPdfError(e)).thenReturn(false);
ExceptionUtils.logException("merge", e);
}
}
}
@Nested
@DisplayName("Invalid and null argument exceptions")
class ArgumentValidationTests {
@Test
void testCreateInvalidArgumentExceptionSingle() {
IllegalArgumentException ex =
ExceptionUtils.createInvalidArgumentException("arg", "invalidValue");
assertTrue(ex.getMessage().contains("arg"));
assertTrue(ex.getMessage().contains("invalidValue"));
}
@Test
void testCreateInvalidArgumentExceptionWithValue() {
IllegalArgumentException ex =
ExceptionUtils.createInvalidArgumentException("arg", "val");
assertTrue(ex.getMessage().contains("val"));
}
@Test
void testCreateNullArgumentException() {
IllegalArgumentException ex = ExceptionUtils.createNullArgumentException("arg");
assertTrue(ex.getMessage().contains("arg"));
}
}
}
@@ -1,383 +0,0 @@
package stirling.software.common.util;
import static org.junit.jupiter.api.Assertions.*;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
import org.junit.jupiter.api.Test;
public class GeneralUtilsTest {
@Test
void testParsePageListWithAll() {
List<Integer> result = GeneralUtils.parsePageList(new String[] {"all"}, 5, false);
assertEquals(List.of(0, 1, 2, 3, 4), result, "'All' keyword should return all pages.");
}
@Test
void testParsePageListWithAllOneBased() {
List<Integer> result = GeneralUtils.parsePageList(new String[] {"all"}, 5, true);
assertEquals(List.of(1, 2, 3, 4, 5), result, "'All' keyword should return all pages.");
}
@Test
void nFunc() {
List<Integer> result = GeneralUtils.parsePageList(new String[] {"n"}, 5, true);
assertEquals(List.of(1, 2, 3, 4, 5), result, "'n' keyword should return all pages.");
}
@Test
void nFuncAdvanced() {
List<Integer> result = GeneralUtils.parsePageList(new String[] {"4n"}, 9, true);
// skip 0 as not valid
assertEquals(List.of(4, 8), result, "'All' keyword should return all pages.");
}
@Test
void nFuncAdvancedZero() {
List<Integer> result = GeneralUtils.parsePageList(new String[] {"4n"}, 9, false);
// skip 0 as not valid
assertEquals(List.of(3, 7), result, "'All' keyword should return all pages.");
}
@Test
void nFuncAdvanced2() {
List<Integer> result = GeneralUtils.parsePageList(new String[] {"4n-1"}, 9, true);
// skip -1 as not valid
assertEquals(List.of(3, 7), result, "4n-1 should do (0-1), (4-1), (8-1)");
}
@Test
void nFuncAdvanced3() {
List<Integer> result = GeneralUtils.parsePageList(new String[] {"4n+1"}, 9, true);
assertEquals(List.of(5, 9), result, "'All' keyword should return all pages.");
}
@Test
void nFunc_spaces() {
List<Integer> result = GeneralUtils.parsePageList(new String[] {"n + 1"}, 9, true);
assertEquals(List.of(2, 3, 4, 5, 6, 7, 8, 9), result);
}
@Test
void nFunc_consecutive_Ns_nnn() {
List<Integer> result = GeneralUtils.parsePageList(new String[] {"nnn"}, 9, true);
assertEquals(List.of(1, 8), result);
}
@Test
void nFunc_consecutive_Ns_nn() {
List<Integer> result = GeneralUtils.parsePageList(new String[] {"nn"}, 9, true);
assertEquals(List.of(1, 4, 9), result);
}
@Test
void nFunc_opening_closing_round_brackets() {
List<Integer> result = GeneralUtils.parsePageList(new String[] {"(n-1)(n-2)"}, 9, true);
assertEquals(List.of(2, 6), result);
}
@Test
void nFunc_opening_round_brackets() {
List<Integer> result = GeneralUtils.parsePageList(new String[] {"2(n-1)"}, 9, true);
assertEquals(List.of(2, 4, 6, 8), result);
}
@Test
void nFunc_opening_round_brackets_n() {
List<Integer> result = GeneralUtils.parsePageList(new String[] {"n(n-1)"}, 9, true);
assertEquals(List.of(2, 6), result);
}
@Test
void nFunc_closing_round_brackets() {
List<Integer> result = GeneralUtils.parsePageList(new String[] {"(n-1)2"}, 9, true);
assertEquals(List.of(2, 4, 6, 8), result);
}
@Test
void nFunc_closing_round_brackets_n() {
List<Integer> result = GeneralUtils.parsePageList(new String[] {"(n-1)n"}, 9, true);
assertEquals(List.of(2, 6), result);
}
@Test
void nFunc_function_surrounded_with_brackets() {
List<Integer> result = GeneralUtils.parsePageList(new String[] {"(n-1)"}, 9, true);
assertEquals(List.of(1, 2, 3, 4, 5, 6, 7, 8), result);
}
@Test
void nFuncAdvanced4() {
List<Integer> result = GeneralUtils.parsePageList(new String[] {"3+2n"}, 9, true);
assertEquals(List.of(5, 7, 9), result, "'All' keyword should return all pages.");
}
@Test
void nFuncAdvancedZerobased() {
List<Integer> result = GeneralUtils.parsePageList(new String[] {"4n"}, 9, false);
assertEquals(List.of(3, 7), result, "'All' keyword should return all pages.");
}
@Test
void nFuncAdvanced2Zerobased() {
List<Integer> result = GeneralUtils.parsePageList(new String[] {"4n-1"}, 9, false);
assertEquals(List.of(2, 6), result, "'All' keyword should return all pages.");
}
@Test
void testParsePageListWithRangeOneBasedOutput() {
List<Integer> result = GeneralUtils.parsePageList(new String[] {"1-3"}, 5, true);
assertEquals(List.of(1, 2, 3), result, "Range should be parsed correctly.");
}
@Test
void testParsePageListWithRangeZeroBaseOutput() {
List<Integer> result = GeneralUtils.parsePageList(new String[] {"1-3"}, 5, false);
assertEquals(List.of(0, 1, 2), result, "Range should be parsed correctly.");
}
@Test
void testParsePageListWithRangeOneBasedOutputFull() {
List<Integer> result = GeneralUtils.parsePageList(new String[] {"1,3,7-8"}, 8, true);
assertEquals(List.of(1, 3, 7, 8), result, "Range should be parsed correctly.");
}
@Test
void testParsePageListWithRangeOneBasedOutputFullOutOfRange() {
List<Integer> result = GeneralUtils.parsePageList(new String[] {"1,3,7-8"}, 5, true);
assertEquals(List.of(1, 3), result, "Range should be parsed correctly.");
}
@Test
void testParsePageListWithRangeZeroBaseOutputFull() {
List<Integer> result = GeneralUtils.parsePageList(new String[] {"1,3,7-8"}, 8, false);
assertEquals(List.of(0, 2, 6, 7), result, "Range should be parsed correctly.");
}
@Test
void testRemoveExtension() {
// Test common cases (should use fast string operations)
assertEquals("document", GeneralUtils.removeExtension("document.pdf"));
assertEquals("image", GeneralUtils.removeExtension("image.jpg"));
assertEquals("file.backup", GeneralUtils.removeExtension("file.backup.zip"));
assertEquals("complex.file.name", GeneralUtils.removeExtension("complex.file.name.txt"));
// Test edge cases (should fall back to regex)
assertEquals("default", GeneralUtils.removeExtension(null));
assertEquals("noextension", GeneralUtils.removeExtension("noextension"));
assertEquals(
".hidden", GeneralUtils.removeExtension(".hidden")); // Hidden file, no extension
assertEquals("trailing.", GeneralUtils.removeExtension("trailing.")); // Trailing dot
assertEquals("", GeneralUtils.removeExtension(""));
assertEquals("a", GeneralUtils.removeExtension("a"));
// Test multiple dots
assertEquals("file.with.multiple", GeneralUtils.removeExtension("file.with.multiple.dots"));
assertEquals("path/to/file", GeneralUtils.removeExtension("path/to/file.ext"));
}
@Test
void testAppendSuffix() {
// Normal cases
assertEquals("document_processed", GeneralUtils.appendSuffix("document", "_processed"));
assertEquals("file.txt", GeneralUtils.appendSuffix("file", ".txt"));
// Null handling
assertEquals("default_suffix", GeneralUtils.appendSuffix(null, "_suffix"));
assertEquals("basename", GeneralUtils.appendSuffix("basename", null));
assertEquals("default", GeneralUtils.appendSuffix(null, null));
// Empty strings
assertEquals("_suffix", GeneralUtils.appendSuffix("", "_suffix"));
assertEquals("basename", GeneralUtils.appendSuffix("basename", ""));
}
@Test
void testProcessFilenames() {
List<String> filenames = new ArrayList<>();
filenames.add("document.pdf");
filenames.add("image.jpg");
filenames.add("spreadsheet.xlsx");
filenames.add("presentation.pptx");
filenames.add(null); // Should handle null gracefully
filenames.add("noextension");
List<String> results = new ArrayList<>();
GeneralUtils.processFilenames(filenames, "_processed", results::add);
List<String> expected =
List.of(
"document_processed",
"image_processed",
"spreadsheet_processed",
"presentation_processed",
"default_processed",
"noextension_processed");
assertEquals(expected, results);
}
@Test
void testProcessFilenamesNullHandling() {
List<String> results = new ArrayList<>();
// Null filenames list
GeneralUtils.processFilenames(null, "_suffix", results::add);
assertTrue(results.isEmpty(), "Should handle null filenames list");
// Null processor
List<String> filenames = List.of("test.txt");
GeneralUtils.processFilenames(filenames, "_suffix", null); // Should not throw
}
@Test
void testRemoveExtensionThreadSafety() throws InterruptedException {
final int threadCount = 50;
final int operationsPerThread = 100;
final String[] testFilenames = {
"document.pdf", "image.jpg", "data.csv", "presentation.pptx",
"archive.zip", "music.mp3", "video.mp4", "text.txt"
};
ExecutorService executor = Executors.newFixedThreadPool(threadCount);
CountDownLatch latch = new CountDownLatch(threadCount);
AtomicInteger successCount = new AtomicInteger(0);
List<Exception> exceptions = Collections.synchronizedList(new ArrayList<>());
for (int i = 0; i < threadCount; i++) {
executor.submit(
() -> {
try {
for (int j = 0; j < operationsPerThread; j++) {
String filename = testFilenames[j % testFilenames.length];
String result = GeneralUtils.removeExtension(filename);
// Verify result is correct
assertFalse(
result.contains("."),
"Result should not contain extension: " + result);
assertTrue(
filename.startsWith(result),
"Original should start with result: "
+ filename
+ " -> "
+ result);
}
successCount.incrementAndGet();
} catch (Exception e) {
exceptions.add(e);
} finally {
latch.countDown();
}
});
}
assertTrue(latch.await(10, TimeUnit.SECONDS), "All threads should complete");
if (!exceptions.isEmpty()) {
fail("Thread safety test failed with exceptions: " + exceptions);
}
assertEquals(threadCount, successCount.get(), "All threads should succeed");
executor.shutdown();
}
@Test
void testBatchProcessingPerformance() {
List<String> filenames = new ArrayList<>();
for (int i = 0; i < 1000; i++) {
filenames.add("file" + i + ".pdf");
filenames.add("document" + i + ".docx");
filenames.add("image" + i + ".jpg");
}
List<String> results = new ArrayList<>();
GeneralUtils.processFilenames(filenames, "_processed", results::add);
assertEquals(filenames.size(), results.size(), "Should process all filenames");
assertTrue(results.contains("file0_processed"), "Should contain processed filename");
assertTrue(results.contains("document500_processed"), "Should contain processed filename");
assertTrue(results.contains("image999_processed"), "Should contain processed filename");
}
@Test
void testHybridStringRegexApproach() {
String[] edgeCases = {
"", // Empty string
".", // Just a dot
"..", // Two dots
"...", // Three dots
".hidden", // Hidden file
"file.", // Trailing dot
"a.b.c.d.e.f.g", // Many extensions
"no-extension-here", // No extension
"file..double.dot" // Double dots
};
for (String edgeCase : edgeCases) {
String result = GeneralUtils.removeExtension(edgeCase);
assertNotNull(result, "Result should not be null for: " + edgeCase);
// For specific edge cases, verify expected behavior
switch (edgeCase) {
case "" -> assertEquals("", result, "Empty string should remain empty");
case "." -> assertEquals(".", result, "Single dot should remain unchanged");
case ".." -> assertEquals("..", result, "Double dots should remain unchanged");
case "..." -> assertEquals("...", result, "Triple dots should remain unchanged");
case ".hidden" ->
assertEquals(".hidden", result, "Hidden file should remain unchanged");
case "file." ->
assertEquals("file.", result, "Trailing dot should remain unchanged");
case "no-extension-here" ->
assertEquals(
"no-extension-here",
result,
"No extension should remain unchanged");
case "a.b.c.d.e.f.g" ->
assertEquals(
"a.b.c.d.e.f",
result,
"Multiple extensions should remove last one");
case "file..double.dot" ->
assertEquals(
"file..double",
result,
"Double dot case should remove last extension");
}
}
}
@Test
void testGetTitleFromFilename() {
// Test normal cases
assertEquals("document", GeneralUtils.getTitleFromFilename("document.pdf"));
assertEquals("presentation", GeneralUtils.getTitleFromFilename("presentation.pptx"));
assertEquals("file.backup", GeneralUtils.getTitleFromFilename("file.backup.zip"));
// Test null and empty handling
assertEquals("Untitled", GeneralUtils.getTitleFromFilename(null));
assertEquals("Untitled", GeneralUtils.getTitleFromFilename(""));
// Test edge cases
assertEquals(".hidden", GeneralUtils.getTitleFromFilename(".hidden"));
assertEquals("file.", GeneralUtils.getTitleFromFilename("file."));
assertEquals("noextension", GeneralUtils.getTitleFromFilename("noextension"));
// Test complex cases
assertEquals(
"complex.file.name", GeneralUtils.getTitleFromFilename("complex.file.name.txt"));
assertEquals("path/to/file", GeneralUtils.getTitleFromFilename("path/to/file.ext"));
}
}
@@ -1,736 +0,0 @@
package stirling.software.common.util;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.mockStatic;
import static org.mockito.Mockito.when;
import java.awt.Color;
import java.awt.Graphics2D;
import java.awt.image.BufferedImage;
import java.awt.image.ColorModel;
import java.awt.image.RenderedImage;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.util.Arrays;
import java.util.List;
import java.util.zip.ZipEntry;
import java.util.zip.ZipInputStream;
import javax.imageio.ImageIO;
import org.apache.pdfbox.cos.COSName;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.pdmodel.PDPageContentStream;
import org.apache.pdfbox.pdmodel.PDPageContentStream.AppendMode;
import org.apache.pdfbox.pdmodel.PDResources;
import org.apache.pdfbox.pdmodel.common.PDRectangle;
import org.apache.pdfbox.pdmodel.font.PDType1Font;
import org.apache.pdfbox.pdmodel.font.Standard14Fonts;
import org.apache.pdfbox.pdmodel.graphics.PDXObject;
import org.apache.pdfbox.pdmodel.graphics.form.PDFormXObject;
import org.apache.pdfbox.pdmodel.graphics.image.LosslessFactory;
import org.apache.pdfbox.pdmodel.graphics.image.PDImageXObject;
import org.apache.pdfbox.rendering.ImageType;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.mockito.MockedStatic;
import org.springframework.mock.web.MockMultipartFile;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.service.PdfMetadataService;
public class PdfUtilsTest {
@Test
void testTextToPageSize() {
assertEquals(PDRectangle.A0, PdfUtils.textToPageSize("A0"));
assertEquals(PDRectangle.A1, PdfUtils.textToPageSize("A1"));
assertEquals(PDRectangle.A2, PdfUtils.textToPageSize("A2"));
assertEquals(PDRectangle.A3, PdfUtils.textToPageSize("A3"));
assertEquals(PDRectangle.A4, PdfUtils.textToPageSize("A4"));
assertEquals(PDRectangle.A5, PdfUtils.textToPageSize("A5"));
assertEquals(PDRectangle.A6, PdfUtils.textToPageSize("A6"));
assertEquals(PDRectangle.LETTER, PdfUtils.textToPageSize("LETTER"));
assertEquals(PDRectangle.LEGAL, PdfUtils.textToPageSize("LEGAL"));
assertThrows(IllegalArgumentException.class, () -> PdfUtils.textToPageSize("INVALID"));
}
@Test
void testGetAllImages() throws Exception {
// Root resources
PDResources root = mock(PDResources.class);
COSName im1 = COSName.getPDFName("Im1");
COSName form1 = COSName.getPDFName("Form1");
COSName other1 = COSName.getPDFName("Other1");
when(root.getXObjectNames()).thenReturn(Arrays.asList(im1, form1, other1));
// Direct image at root
PDImageXObject imgXObj1 = mock(PDImageXObject.class);
BufferedImage img1 = new BufferedImage(2, 2, BufferedImage.TYPE_INT_ARGB);
when(imgXObj1.getImage()).thenReturn(img1);
when(root.getXObject(im1)).thenReturn(imgXObj1);
// "Other" XObject that should be ignored
PDXObject otherXObj = mock(PDXObject.class);
when(root.getXObject(other1)).thenReturn(otherXObj);
// Form XObject with its own resources
PDFormXObject formXObj = mock(PDFormXObject.class);
PDResources formRes = mock(PDResources.class);
when(formXObj.getResources()).thenReturn(formRes);
when(root.getXObject(form1)).thenReturn(formXObj);
// Inside the form: one image and a nested form
COSName im2 = COSName.getPDFName("Im2");
COSName nestedForm = COSName.getPDFName("NestedForm");
when(formRes.getXObjectNames()).thenReturn(Arrays.asList(im2, nestedForm));
PDImageXObject imgXObj2 = mock(PDImageXObject.class);
BufferedImage img2 = new BufferedImage(3, 3, BufferedImage.TYPE_INT_RGB);
when(imgXObj2.getImage()).thenReturn(img2);
when(formRes.getXObject(im2)).thenReturn(imgXObj2);
PDFormXObject nestedFormXObj = mock(PDFormXObject.class);
PDResources nestedRes = mock(PDResources.class);
when(nestedFormXObj.getResources()).thenReturn(nestedRes);
when(formRes.getXObject(nestedForm)).thenReturn(nestedFormXObj);
// Deep nest: another image
COSName im3 = COSName.getPDFName("Im3");
when(nestedRes.getXObjectNames()).thenReturn(List.of(im3));
PDImageXObject imgXObj3 = mock(PDImageXObject.class);
BufferedImage img3 = new BufferedImage(1, 1, BufferedImage.TYPE_INT_RGB);
when(imgXObj3.getImage()).thenReturn(img3);
when(nestedRes.getXObject(im3)).thenReturn(imgXObj3);
// Act
List<RenderedImage> result = PdfUtils.getAllImages(root);
// Assert
assertEquals(
3, result.size(), "It should find exactly 3 images (root + form + nested form).");
assertTrue(
result.containsAll(List.of(img1, img2, img3)),
"All expected images must be present.");
}
@Test
void testPageCountComparators() throws Exception {
PDDocument doc1 = new PDDocument();
doc1.addPage(new PDPage());
doc1.addPage(new PDPage());
doc1.addPage(new PDPage());
assertTrue(PdfUtils.pageCount(doc1, 2, "greater"));
PDDocument doc2 = new PDDocument();
doc2.addPage(new PDPage());
doc2.addPage(new PDPage());
doc2.addPage(new PDPage());
assertTrue(PdfUtils.pageCount(doc2, 3, "equal"));
PDDocument doc3 = new PDDocument();
doc3.addPage(new PDPage());
doc3.addPage(new PDPage());
assertTrue(PdfUtils.pageCount(doc3, 5, "less"));
PDDocument doc4 = new PDDocument();
doc4.addPage(new PDPage());
assertThrows(IllegalArgumentException.class, () -> PdfUtils.pageCount(doc4, 1, "bad"));
}
@Test
void testPageSize() throws Exception {
PDDocument doc = new PDDocument();
PDPage page = new PDPage(PDRectangle.A4);
doc.addPage(page);
PDRectangle rect = page.getMediaBox();
String expected = rect.getWidth() + "x" + rect.getHeight();
assertTrue(PdfUtils.pageSize(doc, expected));
}
@Test
void testOverlayImage() throws Exception {
PDDocument doc = new PDDocument();
doc.addPage(new PDPage(PDRectangle.A4));
ByteArrayOutputStream pdfOut = new ByteArrayOutputStream();
doc.save(pdfOut);
doc.close();
BufferedImage image = new BufferedImage(10, 10, BufferedImage.TYPE_INT_RGB);
Graphics2D g = image.createGraphics();
g.setColor(Color.RED);
g.fillRect(0, 0, 10, 10);
g.dispose();
ByteArrayOutputStream imgOut = new ByteArrayOutputStream();
ImageIO.write(image, "png", imgOut);
PdfMetadataService meta =
new PdfMetadataService(new ApplicationProperties(), "label", false, null);
CustomPDFDocumentFactory factory = new CustomPDFDocumentFactory(meta);
byte[] result =
PdfUtils.overlayImage(
factory, pdfOut.toByteArray(), imgOut.toByteArray(), 0, 0, false);
try (PDDocument resultDoc = factory.load(result)) {
assertEquals(1, resultDoc.getNumberOfPages());
}
}
// ===============================================================
// Additional tests (added without modifying existing ones)
// ===============================================================
/* Helper: create a colored test image */
private static BufferedImage createImage(int w, int h, Color color) {
BufferedImage img = new BufferedImage(w, h, BufferedImage.TYPE_INT_RGB);
Graphics2D g = img.createGraphics();
g.setColor(color);
g.fillRect(0, 0, w, h);
g.dispose();
return img;
}
/* Helper: create a factory like in existing tests */
private static CustomPDFDocumentFactory factory() {
PdfMetadataService meta =
new PdfMetadataService(new ApplicationProperties(), "label", false, null);
return new CustomPDFDocumentFactory(meta);
}
@Test
@DisplayName("convertPdfToPdfImage: creates image-PDF with same page count")
void convertPdfToPdfImage_shouldCreateImagePdfWithSamePageCount() throws IOException {
try (PDDocument doc = new PDDocument()) {
PDPage p1 = new PDPage(PDRectangle.A4);
doc.addPage(p1);
try (PDPageContentStream cs =
new PDPageContentStream(doc, p1, AppendMode.APPEND, true, true)) {
cs.beginText();
cs.setFont(new PDType1Font(Standard14Fonts.FontName.HELVETICA), 12);
cs.newLineAtOffset(50, 750);
cs.showText("Hello PDF");
cs.endText();
}
PDPage p2 = new PDPage(PDRectangle.A4);
doc.addPage(p2);
PDDocument out = PdfUtils.convertPdfToPdfImage(doc);
assertNotNull(out);
assertEquals(2, out.getNumberOfPages(), "Page count should be preserved");
out.close();
}
}
@Test
@DisplayName("imageToPdf: PNG -> single-page PDF (static ImageProcessingUtils mocked)")
void imageToPdf_shouldCreatePdfFromPng() throws Exception {
BufferedImage img = createImage(320, 200, Color.RED);
ByteArrayOutputStream pngOut = new ByteArrayOutputStream();
ImageIO.write(img, "png", pngOut);
MockMultipartFile file =
new MockMultipartFile("files", "test.png", "image/png", pngOut.toByteArray());
try (MockedStatic<ImageProcessingUtils> mocked = mockStatic(ImageProcessingUtils.class)) {
// Assume: loadImageWithExifOrientation/convertColorType exist static mock
mocked.when(() -> ImageProcessingUtils.loadImageWithExifOrientation(any()))
.thenReturn(img);
mocked.when(
() ->
ImageProcessingUtils.convertColorType(
any(BufferedImage.class), anyString()))
.thenAnswer(inv -> inv.getArgument(0, BufferedImage.class));
byte[] pdfBytes =
PdfUtils.imageToPdf(
new MockMultipartFile[] {file},
"maintainAspectRatio",
true,
"RGB",
factory());
try (PDDocument result = factory().load(pdfBytes)) {
assertEquals(1, result.getNumberOfPages());
}
}
}
@Test
@DisplayName("imageToPdf: JPEG -> single-page PDF (JPEGFactory path)")
void imageToPdf_shouldCreatePdfFromJpeg_UsingJpegFactory() throws Exception {
BufferedImage img = createImage(640, 360, Color.BLUE);
ByteArrayOutputStream jpgOut = new ByteArrayOutputStream();
ImageIO.write(img, "jpg", jpgOut);
MockMultipartFile file =
new MockMultipartFile("files", "photo.jpg", "image/jpeg", jpgOut.toByteArray());
try (MockedStatic<ImageProcessingUtils> mocked = mockStatic(ImageProcessingUtils.class)) {
mocked.when(() -> ImageProcessingUtils.loadImageWithExifOrientation(any()))
.thenReturn(img);
mocked.when(
() ->
ImageProcessingUtils.convertColorType(
any(BufferedImage.class), anyString()))
.thenAnswer(inv -> inv.getArgument(0, BufferedImage.class));
byte[] pdfBytes =
PdfUtils.imageToPdf(
new MockMultipartFile[] {file}, "fillPage", false, "RGB", factory());
try (PDDocument result = factory().load(pdfBytes)) {
assertEquals(1, result.getNumberOfPages());
}
}
}
@Test
@DisplayName("addImageToDocument: fitDocumentToImage -> page size = image size")
void addImageToDocument_shouldUseImageSizeForPage_whenFitDocumentToImage() throws IOException {
try (PDDocument doc = new PDDocument()) {
BufferedImage img = createImage(300, 500, Color.GREEN);
PDImageXObject ximg = LosslessFactory.createFromImage(doc, img);
PdfUtils.addImageToDocument(doc, ximg, "fitDocumentToImage", false);
assertEquals(1, doc.getNumberOfPages());
PDRectangle box = doc.getPage(0).getMediaBox();
assertEquals(300, (int) box.getWidth());
assertEquals(500, (int) box.getHeight());
}
}
@Test
@DisplayName("addImageToDocument: autoRotate rotates A4 for landscape image")
void addImageToDocument_shouldRotateA4_whenAutoRotateAndLandscape() throws IOException {
try (PDDocument doc = new PDDocument()) {
BufferedImage img = createImage(800, 400, Color.ORANGE); // Landscape
PDImageXObject ximg = LosslessFactory.createFromImage(doc, img);
PdfUtils.addImageToDocument(doc, ximg, "maintainAspectRatio", true);
assertEquals(1, doc.getNumberOfPages());
PDRectangle box = doc.getPage(0).getMediaBox();
assertTrue(
box.getWidth() > box.getHeight(),
"A4 should be landscape when auto-rotate + landscape");
}
}
@Test
@DisplayName("addImageToDocument: fillPage runs without errors")
void addImageToDocument_fillPage_executes() throws IOException {
try (PDDocument doc = new PDDocument()) {
BufferedImage img = createImage(200, 200, Color.MAGENTA);
PDImageXObject ximg = LosslessFactory.createFromImage(doc, img);
PdfUtils.addImageToDocument(doc, ximg, "fillPage", false);
assertEquals(1, doc.getNumberOfPages());
}
}
@Test
@DisplayName("overlayImage: everyPage=true overlays all pages")
void overlayImage_shouldOverlayAllPages_whenEveryPageTrue() throws IOException {
CustomPDFDocumentFactory factory = factory();
// Create PDF with 2 pages
byte[] basePdf;
try (PDDocument doc = factory.createNewDocument()) {
doc.addPage(new PDPage(PDRectangle.A4));
doc.addPage(new PDPage(PDRectangle.A4));
ByteArrayOutputStream baos = new ByteArrayOutputStream();
doc.save(baos);
basePdf = baos.toByteArray();
}
// Create image bytes
BufferedImage img = createImage(50, 50, Color.BLACK);
ByteArrayOutputStream pngOut = new ByteArrayOutputStream();
ImageIO.write(img, "png", pngOut);
byte[] result = PdfUtils.overlayImage(factory, basePdf, pngOut.toByteArray(), 10, 10, true);
try (PDDocument out = factory.load(result)) {
assertEquals(2, out.getNumberOfPages(), "Page count remains identical");
}
}
/* Helper function: document with text on page1/page2 */
private static PDDocument createDocWithText(String p1, String p2) throws IOException {
PDDocument doc = new PDDocument();
PDPage page1 = new PDPage(PDRectangle.A4);
doc.addPage(page1);
try (PDPageContentStream cs =
new PDPageContentStream(doc, page1, AppendMode.APPEND, true, true)) {
cs.beginText();
cs.setFont(new PDType1Font(Standard14Fonts.FontName.HELVETICA), 12);
cs.newLineAtOffset(50, 750);
cs.showText(p1);
cs.endText();
}
PDPage page2 = new PDPage(PDRectangle.A4);
doc.addPage(page2);
try (PDPageContentStream cs =
new PDPageContentStream(doc, page2, AppendMode.APPEND, true, true)) {
cs.beginText();
cs.setFont(new PDType1Font(Standard14Fonts.FontName.HELVETICA), 12);
cs.newLineAtOffset(50, 750);
cs.showText(p2);
cs.endText();
}
return doc;
}
@Test
@DisplayName("containsTextInFile: pagesToCheck='all' finds text")
void containsTextInFile_allPages_true() throws IOException {
try (PDDocument doc = createDocWithText("alpha", "beta")) {
assertTrue(PdfUtils.containsTextInFile(doc, "beta", "all"));
}
}
@Test
@DisplayName("containsTextInFile: single page '2' finds text")
void containsTextInFile_singlePage_two_true() throws IOException {
try (PDDocument doc = createDocWithText("alpha", "beta")) {
assertTrue(PdfUtils.containsTextInFile(doc, "beta", "2"));
}
}
@Test
@DisplayName("containsTextInFile: range '1-1' finds text on page 1")
void containsTextInFile_range_oneToOne_true() throws IOException {
try (PDDocument doc = createDocWithText("findme", "other")) {
assertTrue(PdfUtils.containsTextInFile(doc, "findme", "1-1"));
}
}
@Test
@DisplayName("containsTextInFile: list '1,2' finds text (whitespace robust)")
void containsTextInFile_list_pages_true() throws IOException {
try (PDDocument doc = createDocWithText("foo", "bar")) {
assertTrue(PdfUtils.containsTextInFile(doc, "bar", " 1 , 2 "));
}
}
@Test
@DisplayName("containsTextInFile: text not present -> false")
void containsTextInFile_textNotPresent_false() throws IOException {
try (PDDocument doc = createDocWithText("xxx", "yyy")) {
assertFalse(PdfUtils.containsTextInFile(doc, "zzz", "all"));
}
}
@Test
@DisplayName("pageSize: different size returns false")
void pageSize_shouldReturnFalse_whenSizeDoesNotMatch() throws IOException {
try (PDDocument doc = new PDDocument()) {
doc.addPage(new PDPage(PDRectangle.A4));
assertFalse(PdfUtils.pageSize(doc, "600x842"));
}
}
// ===================== New: convertFromPdf coverage =====================
@Test
@DisplayName("convertFromPdf: singleImage=true creates combined PNG file (readable)")
void convertFromPdf_singleImagePng_combinedReadable() throws Exception {
// Create two-page PDF
byte[] pdfBytes;
PdfMetadataService meta =
new PdfMetadataService(new ApplicationProperties(), "label", false, null);
CustomPDFDocumentFactory factory = new CustomPDFDocumentFactory(meta);
try (PDDocument doc = new PDDocument()) {
doc.addPage(new PDPage(PDRectangle.A4));
doc.addPage(new PDPage(PDRectangle.A4));
ByteArrayOutputStream baos = new ByteArrayOutputStream();
doc.save(baos);
pdfBytes = baos.toByteArray();
}
byte[] imageBytes =
PdfUtils.convertFromPdf(
factory, pdfBytes, "png", ImageType.RGB, true, 72, "test.pdf", false);
// Should be readable as a single combined PNG image
BufferedImage img = ImageIO.read(new java.io.ByteArrayInputStream(imageBytes));
assertNotNull(img, "PNG should be readable");
assertTrue(img.getWidth() > 0 && img.getHeight() > 0, "Image dimensions > 0");
}
@Test
@DisplayName(
"convertFromPdf: singleImage=false returns ZIP with PNG entries (first image readable)")
void convertFromPdf_multiImagePng_firstReadable() throws Exception {
// Create two-page PDF
byte[] pdfBytes;
PdfMetadataService meta =
new PdfMetadataService(new ApplicationProperties(), "label", false, null);
CustomPDFDocumentFactory factory = new CustomPDFDocumentFactory(meta);
try (PDDocument doc = new PDDocument()) {
doc.addPage(new PDPage(PDRectangle.A4));
doc.addPage(new PDPage(PDRectangle.A4));
ByteArrayOutputStream baos = new ByteArrayOutputStream();
doc.save(baos);
pdfBytes = baos.toByteArray();
}
// Act: singleImage=false -> ZIP with separate images
byte[] zipBytes =
PdfUtils.convertFromPdf(
factory, pdfBytes, "png", ImageType.RGB, false, 72, "test.pdf", false);
// Assert: open ZIP, read first entry as PNG
try (ZipInputStream zis = new ZipInputStream(new ByteArrayInputStream(zipBytes))) {
ZipEntry entry = zis.getNextEntry();
assertNotNull(entry, "ZIP should contain at least one entry");
ByteArrayOutputStream imgOut = new ByteArrayOutputStream();
zis.transferTo(imgOut);
BufferedImage first = ImageIO.read(new ByteArrayInputStream(imgOut.toByteArray()));
assertNotNull(first, "First PNG entry should be readable");
assertTrue(first.getWidth() > 0 && first.getHeight() > 0, "Image dimensions > 0");
}
}
@Test
@DisplayName("hasText: detects phrase on selected pages ('1', '2', 'all')")
void hasText_shouldDetectPhrase_onSelectedPages() throws Exception {
// Arrange: PDF with 2 pages and text
try (PDDocument doc = new PDDocument()) {
PDPage p1 = new PDPage(PDRectangle.A4);
PDPage p2 = new PDPage(PDRectangle.A4);
doc.addPage(p1);
doc.addPage(p2);
try (PDPageContentStream cs =
new PDPageContentStream(doc, p1, AppendMode.APPEND, true, true)) {
cs.beginText();
cs.setFont(new PDType1Font(Standard14Fonts.FontName.HELVETICA), 12);
cs.newLineAtOffset(50, 750);
cs.showText("alpha on page 1");
cs.endText();
}
try (PDPageContentStream cs =
new PDPageContentStream(doc, p2, AppendMode.APPEND, true, true)) {
cs.beginText();
cs.setFont(new PDType1Font(Standard14Fonts.FontName.HELVETICA), 12);
cs.newLineAtOffset(50, 750);
cs.showText("beta on page 2");
cs.endText();
}
assertTrue(PdfUtils.hasText(doc, "1", "alpha"), "Page 1 should contain 'alpha'");
}
// For further checks, create new doc with identical content
try (PDDocument doc = new PDDocument()) {
PDPage p1 = new PDPage(PDRectangle.A4);
PDPage p2 = new PDPage(PDRectangle.A4);
doc.addPage(p1);
doc.addPage(p2);
try (PDPageContentStream cs =
new PDPageContentStream(doc, p1, AppendMode.APPEND, true, true)) {
cs.beginText();
cs.setFont(new PDType1Font(Standard14Fonts.FontName.HELVETICA), 12);
cs.newLineAtOffset(50, 750);
cs.showText("alpha on page 1");
cs.endText();
}
try (PDPageContentStream cs =
new PDPageContentStream(doc, p2, AppendMode.APPEND, true, true)) {
cs.beginText();
cs.setFont(new PDType1Font(Standard14Fonts.FontName.HELVETICA), 12);
cs.newLineAtOffset(50, 750);
cs.showText("beta on page 2");
cs.endText();
}
assertTrue(PdfUtils.hasText(doc, "2", "beta"), "Page 2 should contain 'beta'");
}
// Third doc for 'all'
try (PDDocument doc = new PDDocument()) {
PDPage p1 = new PDPage(PDRectangle.A4);
PDPage p2 = new PDPage(PDRectangle.A4);
doc.addPage(p1);
doc.addPage(p2);
try (PDPageContentStream cs =
new PDPageContentStream(doc, p1, AppendMode.APPEND, true, true)) {
cs.beginText();
cs.setFont(new PDType1Font(Standard14Fonts.FontName.HELVETICA), 12);
cs.newLineAtOffset(50, 750);
cs.showText("gamma");
cs.endText();
}
assertTrue(PdfUtils.hasText(doc, "all", "gamma"), "'all' should find text on page 1");
}
}
@Test
@DisplayName("hasTextOnPage: true if page contains phrase, else false")
void hasTextOnPage_shouldReturnTrueOnlyForPagesWithPhrase() throws Exception {
try (PDDocument doc = new PDDocument()) {
PDPage p1 = new PDPage(PDRectangle.A4);
PDPage p2 = new PDPage(PDRectangle.A4);
doc.addPage(p1);
doc.addPage(p2);
try (PDPageContentStream cs =
new PDPageContentStream(doc, p1, AppendMode.APPEND, true, true)) {
cs.beginText();
cs.setFont(new PDType1Font(Standard14Fonts.FontName.HELVETICA), 12);
cs.newLineAtOffset(50, 750);
cs.showText("needle");
cs.endText();
}
assertTrue(PdfUtils.hasTextOnPage(p1, "needle"));
assertTrue(!PdfUtils.hasTextOnPage(p2, "needle"));
}
}
@Test
@DisplayName("hasImages: detects images on selected pages and 'all'")
void hasImages_shouldDetectImages_onSelectedPages() throws Exception {
// Case 1: Page 1 without image (but resources set) -> false
try (PDDocument doc = new PDDocument()) {
PDPage p1 = new PDPage(PDRectangle.A4);
PDPage p2 = new PDPage(PDRectangle.A4);
p1.setResources(new PDResources());
p2.setResources(new PDResources());
doc.addPage(p1);
doc.addPage(p2);
// Image only on page 2
BufferedImage bi = new BufferedImage(20, 20, BufferedImage.TYPE_INT_RGB);
Graphics2D g = bi.createGraphics();
g.setColor(Color.GREEN);
g.fillRect(0, 0, 20, 20);
g.dispose();
PDImageXObject ximg = LosslessFactory.createFromImage(doc, bi);
try (PDPageContentStream cs =
new PDPageContentStream(doc, p2, AppendMode.APPEND, true, true)) {
cs.drawImage(ximg, 50, 700, 20, 20);
}
assertTrue(!PdfUtils.hasImages(doc, "1"), "Page 1 should have no image");
}
// Case 2: Page 2 with image -> true
try (PDDocument doc = new PDDocument()) {
PDPage p1 = new PDPage(PDRectangle.A4);
PDPage p2 = new PDPage(PDRectangle.A4);
p1.setResources(new PDResources());
p2.setResources(new PDResources());
doc.addPage(p1);
doc.addPage(p2);
BufferedImage bi = new BufferedImage(20, 20, BufferedImage.TYPE_INT_RGB);
Graphics2D g = bi.createGraphics();
g.setColor(Color.BLUE);
g.fillRect(0, 0, 20, 20);
g.dispose();
PDImageXObject ximg = LosslessFactory.createFromImage(doc, bi);
try (PDPageContentStream cs =
new PDPageContentStream(doc, p2, AppendMode.APPEND, true, true)) {
cs.drawImage(ximg, 50, 700, 20, 20);
}
assertTrue(PdfUtils.hasImages(doc, "2"), "Page 2 should have an image");
}
// Case 3: 'all' detects image
try (PDDocument doc = new PDDocument()) {
PDPage p = new PDPage(PDRectangle.A4);
p.setResources(new PDResources());
doc.addPage(p);
BufferedImage bi = new BufferedImage(10, 10, BufferedImage.TYPE_INT_RGB);
PDImageXObject ximg = LosslessFactory.createFromImage(doc, bi);
try (PDPageContentStream cs =
new PDPageContentStream(doc, p, AppendMode.APPEND, true, true)) {
cs.drawImage(ximg, 20, 730, 10, 10);
}
assertTrue(PdfUtils.hasImages(doc, "all"), "'all' should detect the image");
}
}
@Test
@DisplayName("hasImagesOnPage: true if page contains an image, else false")
void hasImagesOnPage_shouldReturnTrueOnlyForPagesWithImage() throws Exception {
try (PDDocument doc = new PDDocument()) {
PDPage p1 = new PDPage(PDRectangle.A4);
PDPage p2 = new PDPage(PDRectangle.A4);
p1.setResources(new PDResources());
p2.setResources(new PDResources());
doc.addPage(p1);
doc.addPage(p2);
BufferedImage bi = new BufferedImage(12, 12, BufferedImage.TYPE_INT_RGB);
Graphics2D g = bi.createGraphics();
g.setColor(Color.RED);
g.fillRect(0, 0, 12, 12);
g.dispose();
PDImageXObject ximg = LosslessFactory.createFromImage(doc, bi);
try (PDPageContentStream cs =
new PDPageContentStream(doc, p1, AppendMode.APPEND, true, true)) {
cs.drawImage(ximg, 40, 720, 12, 12);
}
assertTrue(PdfUtils.hasImagesOnPage(p1));
assertTrue(!PdfUtils.hasImagesOnPage(p2));
}
}
@Test
@DisplayName("convertFromPdf: singleImage=true with JPG -> no alpha, white background")
void convertFromPdf_singleImageJpg_noAlphaWhiteBackground() throws Exception {
// small 1-page PDF
byte[] pdfBytes;
PdfMetadataService meta =
new PdfMetadataService(new ApplicationProperties(), "label", false, null);
CustomPDFDocumentFactory factory = new CustomPDFDocumentFactory(meta);
try (PDDocument doc = new PDDocument()) {
PDPage p = new PDPage(PDRectangle.A4);
doc.addPage(p);
ByteArrayOutputStream baos = new ByteArrayOutputStream();
doc.save(baos);
pdfBytes = baos.toByteArray();
}
byte[] jpgBytes =
PdfUtils.convertFromPdf(
factory, pdfBytes, "jpg", ImageType.RGB, true, 72, "sample.pdf", false);
BufferedImage img = ImageIO.read(new ByteArrayInputStream(jpgBytes));
assertNotNull(img, "JPG should be readable");
ColorModel cm = img.getColorModel();
assertFalse(cm.hasAlpha(), "JPG output should have no alpha channel");
// JPG background should be white (approximate check)
int rgb = img.getRGB(img.getWidth() / 2, img.getHeight() / 2) & 0x00FFFFFF;
assertEquals(0xFFFFFF, rgb, "Background pixel should be white");
}
}
@@ -1,109 +0,0 @@
package stirling.software.common.util;
import static org.junit.jupiter.api.Assertions.*;
import java.util.regex.Pattern;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
public class RegexPatternUtilsTest {
private RegexPatternUtils utils;
@BeforeEach
void setUp() {
utils = RegexPatternUtils.getInstance();
utils.clearCache(); // Start with clean cache for each test
}
@Test
void testPatternCaching() {
String regex = "test\\d+";
Pattern pattern1 = utils.getPattern(regex);
assertNotNull(pattern1);
assertTrue(utils.isCached(regex));
assertEquals(
1, utils.getCacheSize()); // Should have at least 1 pattern (plus precompiled ones
// are cleared)
Pattern pattern2 = utils.getPattern(regex);
assertSame(pattern1, pattern2); // Should be the exact same object
}
@Test
void testPatternWithFlags() {
String regex = "test";
int flags = Pattern.CASE_INSENSITIVE;
Pattern pattern1 = utils.getPattern(regex, flags);
Pattern pattern2 = utils.getPattern(regex); // No flags
assertNotSame(pattern1, pattern2); // Different flags = different cached patterns
assertTrue(utils.isCached(regex, flags));
assertTrue(utils.isCached(regex, 0));
}
@Test
void testCacheEviction() {
String regex = "evict\\d+";
utils.getPattern(regex);
assertTrue(utils.isCached(regex));
boolean removed = utils.removeFromCache(regex);
assertTrue(removed);
assertFalse(utils.isCached(regex));
boolean removedAgain = utils.removeFromCache(regex);
assertFalse(removedAgain);
}
@Test
void testNullRegexHandling() {
assertThrows(IllegalArgumentException.class, () -> utils.getPattern(null));
assertThrows(
IllegalArgumentException.class,
() -> utils.getPattern(null, Pattern.CASE_INSENSITIVE));
assertFalse(utils.isCached(null));
assertFalse(utils.removeFromCache(null));
}
@Test
void testCommonPatterns() {
Pattern whitespace = utils.getWhitespacePattern();
assertTrue(whitespace.matcher(" \t ").matches());
Pattern trailing = utils.getTrailingSlashesPattern();
assertTrue(trailing.matcher("/path/to/dir///").find());
Pattern filename = utils.getSafeFilenamePattern();
assertTrue(filename.matcher("bad<file>name").find());
}
@Test
void testCreateSearchPattern() {
String regex = "Hello";
Pattern caseSensitive = utils.createSearchPattern(regex, false);
Pattern caseInsensitive = utils.createSearchPattern(regex, true);
assertTrue(caseSensitive.matcher("Hello").matches());
assertFalse(caseSensitive.matcher("hello").matches());
assertTrue(caseInsensitive.matcher("Hello").matches());
assertTrue(caseInsensitive.matcher("hello").matches());
assertTrue(caseInsensitive.matcher("HELLO").matches());
}
@Test
void testSingletonBehavior() {
RegexPatternUtils instance1 = RegexPatternUtils.getInstance();
RegexPatternUtils instance2 = RegexPatternUtils.getInstance();
assertSame(instance1, instance2);
}
}
@@ -1,96 +0,0 @@
package stirling.software.common.util;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
/**
* Unit tests for {@link TempDirectory}. Assumption: TempFileManager has methods
* createTempDirectory() and deleteTempDirectory(Path).
*/
class TempDirectoryTest {
@Test
@DisplayName("should create temp directory and return correct path info")
void shouldReturnCorrectPathInfo() throws IOException {
TempFileManager manager = mock(TempFileManager.class);
Path tempPath = Files.createTempDirectory("testDir");
when(manager.createTempDirectory()).thenReturn(tempPath);
try (TempDirectory tempDir = new TempDirectory(manager)) {
assertEquals(
tempPath,
tempDir.getPath(),
"getPath should return the created directory path");
assertEquals(
tempPath.toAbsolutePath().toString(),
tempDir.getAbsolutePath(),
"getAbsolutePath should return absolute path");
assertTrue(tempDir.exists(), "exists should return true when directory exists");
assertTrue(
tempDir.toString().contains(tempPath.toAbsolutePath().toString()),
"toString should include the absolute path");
}
}
@Test
@DisplayName("should call deleteTempDirectory on close")
void shouldDeleteTempDirectoryOnClose() throws IOException {
TempFileManager manager = mock(TempFileManager.class);
Path tempPath = Files.createTempDirectory("testDir");
when(manager.createTempDirectory()).thenReturn(tempPath);
try (TempDirectory tempDir = new TempDirectory(manager)) {
// do nothing
}
ArgumentCaptor<Path> captor = ArgumentCaptor.forClass(Path.class);
verify(manager, times(1)).deleteTempDirectory(captor.capture());
assertEquals(
tempPath,
captor.getValue(),
"deleteTempDirectory should be called with the created path");
}
@Test
@DisplayName("should handle multiple close calls without exception")
void shouldHandleMultipleCloseCalls() throws IOException {
TempFileManager manager = mock(TempFileManager.class);
Path tempPath = Files.createTempDirectory("testDir");
when(manager.createTempDirectory()).thenReturn(tempPath);
TempDirectory tempDir = new TempDirectory(manager);
tempDir.close();
assertDoesNotThrow(tempDir::close, "Second close should not throw exception");
}
@Test
@DisplayName("should return false for exists if directory does not exist")
void shouldReturnFalseIfDirectoryDoesNotExist() throws IOException {
TempFileManager manager = mock(TempFileManager.class);
Path tempPath = Files.createTempDirectory("testDir");
Files.delete(tempPath); // delete immediately
when(manager.createTempDirectory()).thenReturn(tempPath);
try (TempDirectory tempDir = new TempDirectory(manager)) {
assertFalse(tempDir.exists(), "exists should return false when directory is missing");
}
}
@Test
@DisplayName("should throw IOException if createTempDirectory fails")
void shouldThrowIfCreateTempDirectoryFails() throws IOException {
TempFileManager manager = mock(TempFileManager.class);
when(manager.createTempDirectory()).thenThrow(new IOException("Disk full"));
IOException ex = assertThrows(IOException.class, () -> new TempDirectory(manager));
assertEquals("Disk full", ex.getMessage(), "Exception message should be propagated");
}
}
@@ -1 +0,0 @@
ok
@@ -1,46 +0,0 @@
package stirling.software.SPDF.Factories;
import org.springframework.stereotype.Component;
import org.springframework.web.multipart.MultipartFile;
import lombok.RequiredArgsConstructor;
import stirling.software.common.model.api.misc.HighContrastColorCombination;
import stirling.software.common.model.api.misc.ReplaceAndInvert;
import stirling.software.common.util.TempFileManager;
import stirling.software.common.util.misc.ColorSpaceConversionStrategy;
import stirling.software.common.util.misc.CustomColorReplaceStrategy;
import stirling.software.common.util.misc.InvertFullColorStrategy;
import stirling.software.common.util.misc.ReplaceAndInvertColorStrategy;
@Component
@RequiredArgsConstructor
public class ReplaceAndInvertColorFactory {
private final TempFileManager tempFileManager;
public ReplaceAndInvertColorStrategy replaceAndInvert(
MultipartFile file,
ReplaceAndInvert replaceAndInvertOption,
HighContrastColorCombination highContrastColorCombination,
String backGroundColor,
String textColor) {
if (replaceAndInvertOption == null) {
return null;
}
return switch (replaceAndInvertOption) {
case CUSTOM_COLOR, HIGH_CONTRAST_COLOR ->
new CustomColorReplaceStrategy(
file,
replaceAndInvertOption,
textColor,
backGroundColor,
highContrastColorCombination);
case FULL_INVERSION -> new InvertFullColorStrategy(file, replaceAndInvertOption);
case COLOR_SPACE_CONVERSION ->
new ColorSpaceConversionStrategy(file, replaceAndInvertOption, tempFileManager);
};
}
}
@@ -1,359 +0,0 @@
package stirling.software.SPDF.config;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.*;
import java.util.concurrent.*;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
import org.springframework.context.annotation.Configuration;
import jakarta.annotation.PostConstruct;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.configuration.RuntimePathConfig;
import stirling.software.common.util.RegexPatternUtils;
/**
* Dependency checker that - runs probes in parallel with timeouts (prevents hanging on broken
* PATHs) - supports Windows+Unix in a single place - de-duplicates logic for version extraction &
* command availability - keeps group <-> command mapping and feature formatting tidy & immutable
*/
@Configuration
@Slf4j
public class ExternalAppDepConfig {
private static final Duration DEFAULT_TIMEOUT = Duration.ofSeconds(5);
private static final Pattern VERSION_PATTERN = Pattern.compile("(\\d+(?:\\.\\d+){0,2})");
private final EndpointConfiguration endpointConfiguration;
private final boolean isWindows =
System.getProperty("os.name").toLowerCase(Locale.ROOT).contains("windows");
private final String weasyprintPath;
private final String unoconvPath;
private final String calibrePath;
private final String ocrMyPdfPath;
private final String sOfficePath;
/**
* Map of command(binary) -> affected groups (e.g. "gs" -> ["Ghostscript"]). Immutable to avoid
* accidental mutations.
*/
private final Map<String, List<String>> commandToGroupMapping;
private final ExecutorService pool =
Executors.newFixedThreadPool(
Math.max(2, Runtime.getRuntime().availableProcessors() / 2));
public ExternalAppDepConfig(
EndpointConfiguration endpointConfiguration, RuntimePathConfig runtimePathConfig) {
this.endpointConfiguration = endpointConfiguration;
this.weasyprintPath = runtimePathConfig.getWeasyPrintPath();
this.unoconvPath = runtimePathConfig.getUnoConvertPath();
this.calibrePath = runtimePathConfig.getCalibrePath();
this.ocrMyPdfPath = runtimePathConfig.getOcrMyPdfPath();
this.sOfficePath = runtimePathConfig.getSOfficePath();
Map<String, List<String>> tmp = new HashMap<>();
tmp.put("gs", List.of("Ghostscript"));
tmp.put(ocrMyPdfPath, List.of("OCRmyPDF"));
tmp.put(sOfficePath, List.of("LibreOffice"));
tmp.put(weasyprintPath, List.of("Weasyprint"));
tmp.put("pdftohtml", List.of("Pdftohtml"));
tmp.put(unoconvPath, List.of("Unoconvert"));
tmp.put("qpdf", List.of("qpdf"));
tmp.put("tesseract", List.of("tesseract"));
tmp.put("rar", List.of("rar"));
tmp.put(calibrePath, List.of("Calibre"));
tmp.put("ffmpeg", List.of("FFmpeg"));
this.commandToGroupMapping = Collections.unmodifiableMap(tmp);
}
@PostConstruct
public void checkDependencies() {
try {
// core checks in parallel
List<Callable<Void>> tasks =
commandToGroupMapping.keySet().stream()
.<Callable<Void>>map(
cmd ->
() -> {
checkDependencyAndDisableGroup(cmd);
return null;
})
.collect(Collectors.toList());
invokeAllWithTimeout(tasks, DEFAULT_TIMEOUT.plusSeconds(3));
// Python / OpenCV special handling
checkPythonAndOpenCV();
} finally {
endpointConfiguration.logDisabledEndpointsSummary();
pool.shutdown();
}
}
private void checkDependencyAndDisableGroup(String command) {
boolean available = isCommandAvailable(command);
if (!available) {
List<String> affectedGroups = commandToGroupMapping.get(command);
if (affectedGroups == null || affectedGroups.isEmpty()) return;
for (String group : affectedGroups) {
List<String> affectedFeatures = getAffectedFeatures(group);
endpointConfiguration.disableGroup(group);
log.warn(
"Missing dependency: {} - Disabling group: {} (Affected features: {})",
command,
group,
affectedFeatures.isEmpty()
? "unknown"
: String.join(", ", affectedFeatures));
}
return;
}
// Extra: enforce minimum WeasyPrint version if command matches
if (isWeasyprint(command)) {
Optional<String> version = getVersionSafe(command, "--version");
version.ifPresentOrElse(
v -> {
Version installed = new Version(v);
// https://www.courtbouillon.org/blog/00040-weasyprint-58/
Version required = new Version("58.0");
if (installed.compareTo(required) < 0) {
List<String> affectedGroups =
commandToGroupMapping.getOrDefault(
command, List.of("Weasyprint"));
for (String group : affectedGroups) {
endpointConfiguration.disableGroup(group);
}
log.warn(
"WeasyPrint version {} is below required {} - disabling"
+ " group(s): {}",
installed,
required,
String.join(", ", affectedGroups));
} else {
log.info("WeasyPrint {} meets minimum {}", installed, required);
}
},
() ->
log.warn(
"WeasyPrint version could not be determined ({} --version)",
command));
}
// Extra: enforce minimum qpdf version if command matches
if (isQpdf(command)) {
Optional<String> version = getVersionSafe(command, "--version");
version.ifPresentOrElse(
v -> {
Version installed = new Version(v);
Version required = new Version("12.0.0");
if (installed.compareTo(required) < 0) {
List<String> affectedGroups =
commandToGroupMapping.getOrDefault(command, List.of("qpdf"));
for (String group : affectedGroups) {
endpointConfiguration.disableGroup(group);
}
log.warn(
"qpdf version {} is below required {} - disabling group(s): {}",
installed,
required,
String.join(", ", affectedGroups));
} else {
log.info("qpdf {} meets minimum {}", installed, required);
}
},
() -> log.warn("qpdf version could not be determined ({} --version)", command));
}
}
private boolean isWeasyprint(String command) {
return Objects.equals(command, weasyprintPath)
|| command.toLowerCase(Locale.ROOT).contains("weasyprint");
}
private boolean isQpdf(String command) {
return command.toLowerCase(Locale.ROOT).contains("qpdf");
}
private List<String> getAffectedFeatures(String group) {
List<String> endpoints = new ArrayList<>(endpointConfiguration.getEndpointsForGroup(group));
return endpoints.stream().map(this::formatEndpointAsFeature).toList();
}
private String formatEndpointAsFeature(String endpoint) {
String feature = endpoint.replace("-", " ").replace("pdf", "PDF").replace("img", "image");
return Arrays.stream(RegexPatternUtils.getInstance().getWordSplitPattern().split(feature))
.map(this::capitalizeWord)
.collect(Collectors.joining(" "));
}
private String capitalizeWord(String word) {
if (word == null || word.isEmpty()) return word;
if ("pdf".equalsIgnoreCase(word)) return "PDF";
return word.substring(0, 1).toUpperCase(Locale.ROOT)
+ word.substring(1).toLowerCase(Locale.ROOT);
}
private void checkPythonAndOpenCV() {
String python = findFirstAvailable(List.of("python3", "python")).orElse(null);
if (python == null) {
disablePythonAndOpenCV("Python interpreter not found on PATH");
return;
}
// Check OpenCV import
int ec = runAndWait(List.of(python, "-c", "import cv2"), DEFAULT_TIMEOUT).exitCode();
if (ec != 0) {
List<String> openCVFeatures = getAffectedFeatures("OpenCV");
endpointConfiguration.disableGroup("OpenCV");
log.warn(
"OpenCV not available in Python - Disabling OpenCV features: {}",
String.join(", ", openCVFeatures));
}
}
private void disablePythonAndOpenCV(String reason) {
List<String> pythonFeatures = getAffectedFeatures("Python");
List<String> openCVFeatures = getAffectedFeatures("OpenCV");
endpointConfiguration.disableGroup("Python");
endpointConfiguration.disableGroup("OpenCV");
log.warn(
"Missing dependency: Python (reason: {}) - Disabling Python features: {} and OpenCV"
+ " features: {}",
reason,
String.join(", ", pythonFeatures),
String.join(", ", openCVFeatures));
}
private Optional<String> findFirstAvailable(List<String> commands) {
for (String c : commands) {
if (isCommandAvailable(c)) return Optional.of(c);
}
return Optional.empty();
}
private boolean isCommandAvailable(String command) {
// First try OS-native lookup
List<String> lookup = isWindows ? List.of("where", command) : List.of("which", command);
ProbeResult res = runAndWait(lookup, DEFAULT_TIMEOUT);
if (res.exitCode() == 0) return true;
// Fallback: try `--version` when helpful (covers py-launcher shims on Windows etc.)
ProbeResult ver = runAndWait(List.of(command, "--version"), DEFAULT_TIMEOUT);
return ver.exitCode() == 0;
}
private Optional<String> getVersionSafe(String command, String arg) {
try {
ProbeResult res = runAndWait(List.of(command, arg), DEFAULT_TIMEOUT);
if (res.exitCode() != 0) return Optional.empty();
String text = res.combined();
Matcher m = VERSION_PATTERN.matcher(text);
return m.find() ? Optional.of(m.group(1)) : Optional.empty();
} catch (Exception e) {
return Optional.empty();
}
}
private void invokeAllWithTimeout(List<Callable<Void>> tasks, Duration timeout) {
try {
List<Future<Void>> futures =
pool.invokeAll(tasks, timeout.toMillis(), TimeUnit.MILLISECONDS);
for (Future<Void> f : futures) {
try {
f.get();
} catch (Exception ignored) {
}
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
private ProbeResult runAndWait(List<String> cmd, Duration timeout) {
ProcessBuilder pb = new ProcessBuilder(cmd);
try {
Process p = pb.start();
boolean finished = p.waitFor(timeout.toMillis(), TimeUnit.MILLISECONDS);
if (!finished) {
p.destroyForcibly();
return new ProbeResult(124, "", "timeout");
}
String out = readStream(p.getInputStream());
String err = readStream(p.getErrorStream());
int ec = p.exitValue();
return new ProbeResult(ec, out, err);
} catch (IOException | InterruptedException e) {
if (e instanceof InterruptedException) Thread.currentThread().interrupt();
return new ProbeResult(127, "", String.valueOf(e.getMessage()));
}
}
private static String readStream(InputStream in) throws IOException {
StringBuilder sb = new StringBuilder();
try (BufferedReader br =
new BufferedReader(new InputStreamReader(in, StandardCharsets.UTF_8))) {
String line;
while ((line = br.readLine()) != null) {
if (sb.length() > 0) sb.append('\n');
sb.append(line);
}
}
return sb.toString().trim();
}
private record ProbeResult(int exitCode, String stdout, String stderr) {
String combined() {
return (stdout == null ? "" : stdout) + "\n" + (stderr == null ? "" : stderr);
}
}
/** Simple numeric version comparator (major.minor.patch). */
static class Version implements Comparable<Version> {
private final int[] parts;
Version(String ver) {
String[] tokens = ver.split("\\.");
parts = new int[3];
for (int i = 0; i < 3; i++) {
if (i < tokens.length) {
try {
parts[i] = Integer.parseInt(tokens[i]);
} catch (NumberFormatException e) {
parts[i] = 0;
}
} else {
parts[i] = 0;
}
}
}
@Override
public int compareTo(Version o) {
for (int i = 0; i < 3; i++) {
int a = parts[i];
int b = o.parts[i];
if (a != b) return Integer.compare(a, b);
}
return 0;
}
@Override
public String toString() {
return parts[0] + "." + parts[1] + "." + parts[2];
}
}
}
@@ -1,337 +0,0 @@
package stirling.software.SPDF.controller.api;
import java.awt.image.BufferedImage;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
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.PDPageContentStream.AppendMode;
import org.apache.pdfbox.pdmodel.common.PDRectangle;
import org.apache.pdfbox.pdmodel.graphics.form.PDFormXObject;
import org.apache.pdfbox.rendering.PDFRenderer;
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 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.general.CropPdfForm;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.ExceptionUtils;
import stirling.software.common.util.GeneralUtils;
import stirling.software.common.util.ProcessExecutor;
import stirling.software.common.util.WebResponseUtils;
@RestController
@RequestMapping("/api/v1/general")
@Tag(name = "General", description = "General APIs")
@RequiredArgsConstructor
@Slf4j
public class CropController {
private static final int DEFAULT_RENDER_DPI = 150;
private static final int WHITE_THRESHOLD = 250;
private static final String TEMP_INPUT_PREFIX = "crop_input";
private static final String TEMP_OUTPUT_PREFIX = "crop_output";
private static final String PDF_EXTENSION = ".pdf";
private final CustomPDFDocumentFactory pdfDocumentFactory;
private static int[] detectContentBounds(BufferedImage image) {
int width = image.getWidth();
int height = image.getHeight();
// Early exit if image is too small
if (width < 1 || height < 1) {
return new int[] {0, 0, width - 1, height - 1};
}
// Sample every nth pixel for large images to reduce processing time
int step = (width > 2000 || height > 2000) ? 2 : 1;
int top = 0;
boolean found = false;
for (int y = 0; y < height && !found; y += step) {
for (int x = 0; x < width; x += step) {
if (!isWhite(image.getRGB(x, y), WHITE_THRESHOLD)) {
top = y;
found = true;
break;
}
}
}
int bottom = height - 1;
found = false;
for (int y = height - 1; y >= 0 && !found; y -= step) {
for (int x = 0; x < width; x += step) {
if (!isWhite(image.getRGB(x, y), WHITE_THRESHOLD)) {
bottom = y;
found = true;
break;
}
}
}
int left = 0;
found = false;
for (int x = 0; x < width && !found; x += step) {
for (int y = top; y <= bottom; y += step) {
if (!isWhite(image.getRGB(x, y), WHITE_THRESHOLD)) {
left = x;
found = true;
break;
}
}
}
int right = width - 1;
found = false;
for (int x = width - 1; x >= 0 && !found; x -= step) {
for (int y = top; y <= bottom; y += step) {
if (!isWhite(image.getRGB(x, y), WHITE_THRESHOLD)) {
right = x;
found = true;
break;
}
}
}
// Return bounds in format: [left, bottom, right, top]
// Note: Image coordinates are top-down, PDF coordinates are bottom-up
return new int[] {left, height - bottom - 1, right, height - top - 1};
}
private static boolean isWhite(int rgb, int threshold) {
int r = (rgb >> 16) & 0xFF;
int g = (rgb >> 8) & 0xFF;
int b = rgb & 0xFF;
return r >= threshold && g >= threshold && b >= threshold;
}
@PostMapping(value = "/crop", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@Operation(
summary = "Crops a PDF document",
description =
"This operation takes an input PDF file and crops it according to the given"
+ " coordinates. Input:PDF Output:PDF Type:SISO")
public ResponseEntity<byte[]> cropPdf(@ModelAttribute CropPdfForm request) throws IOException {
if (request.isAutoCrop()) {
return cropWithAutomaticDetection(request);
}
if (request.getX() == null
|| request.getY() == null
|| request.getWidth() == null
|| request.getHeight() == null) {
throw new IllegalArgumentException(
"Crop coordinates (x, y, width, height) are required when auto-crop is not enabled");
}
if (request.isRemoveDataOutsideCrop()) {
return cropWithGhostscript(request);
} else {
return cropWithPDFBox(request);
}
}
private ResponseEntity<byte[]> cropWithAutomaticDetection(@ModelAttribute CropPdfForm request)
throws IOException {
try (PDDocument sourceDocument = pdfDocumentFactory.load(request)) {
try (PDDocument newDocument =
pdfDocumentFactory.createNewDocumentBasedOnOldDocument(sourceDocument)) {
PDFRenderer renderer = new PDFRenderer(sourceDocument);
LayerUtility layerUtility = new LayerUtility(newDocument);
for (int i = 0; i < sourceDocument.getNumberOfPages(); i++) {
PDPage sourcePage = sourceDocument.getPage(i);
PDRectangle mediaBox = sourcePage.getMediaBox();
BufferedImage image = renderer.renderImageWithDPI(i, DEFAULT_RENDER_DPI);
int[] bounds = detectContentBounds(image);
float scaleX = mediaBox.getWidth() / image.getWidth();
float scaleY = mediaBox.getHeight() / image.getHeight();
CropBounds cropBounds = CropBounds.fromPixels(bounds, scaleX, scaleY);
PDPage newPage = new PDPage(mediaBox);
newDocument.addPage(newPage);
try (PDPageContentStream contentStream =
new PDPageContentStream(
newDocument, newPage, AppendMode.OVERWRITE, true, true)) {
PDFormXObject formXObject =
layerUtility.importPageAsForm(sourceDocument, i);
contentStream.saveGraphicsState();
contentStream.addRect(
cropBounds.x, cropBounds.y, cropBounds.width, cropBounds.height);
contentStream.clip();
contentStream.drawForm(formXObject);
contentStream.restoreGraphicsState();
}
newPage.setMediaBox(
new PDRectangle(
cropBounds.x,
cropBounds.y,
cropBounds.width,
cropBounds.height));
}
ByteArrayOutputStream baos = new ByteArrayOutputStream();
newDocument.save(baos);
byte[] pdfContent = baos.toByteArray();
return WebResponseUtils.bytesToWebResponse(
pdfContent,
GeneralUtils.generateFilename(
request.getFileInput().getOriginalFilename(), "_cropped.pdf"));
}
}
}
private ResponseEntity<byte[]> cropWithPDFBox(@ModelAttribute CropPdfForm request)
throws IOException {
try (PDDocument sourceDocument = pdfDocumentFactory.load(request)) {
try (PDDocument newDocument =
pdfDocumentFactory.createNewDocumentBasedOnOldDocument(sourceDocument)) {
int totalPages = sourceDocument.getNumberOfPages();
LayerUtility layerUtility = new LayerUtility(newDocument);
for (int i = 0; i < totalPages; i++) {
PDPage sourcePage = sourceDocument.getPage(i);
// Create a new page with the size of the source page
PDPage newPage = new PDPage(sourcePage.getMediaBox());
newDocument.addPage(newPage);
try (PDPageContentStream contentStream =
new PDPageContentStream(
newDocument, newPage, AppendMode.OVERWRITE, true, true)) {
// Import the source page as a form XObject
PDFormXObject formXObject =
layerUtility.importPageAsForm(sourceDocument, i);
contentStream.saveGraphicsState();
// Define the crop area
contentStream.addRect(
request.getX(),
request.getY(),
request.getWidth(),
request.getHeight());
contentStream.clip();
// Draw the entire formXObject
contentStream.drawForm(formXObject);
contentStream.restoreGraphicsState();
}
// Now, set the new page's media box to the cropped size
newPage.setMediaBox(
new PDRectangle(
request.getX(),
request.getY(),
request.getWidth(),
request.getHeight()));
}
ByteArrayOutputStream baos = new ByteArrayOutputStream();
newDocument.save(baos);
byte[] pdfContent = baos.toByteArray();
return WebResponseUtils.bytesToWebResponse(
pdfContent,
GeneralUtils.generateFilename(
request.getFileInput().getOriginalFilename(), "_cropped.pdf"));
}
}
}
private ResponseEntity<byte[]> cropWithGhostscript(@ModelAttribute CropPdfForm request)
throws IOException {
Path tempInputFile = null;
Path tempOutputFile = null;
try (PDDocument sourceDocument = pdfDocumentFactory.load(request)) {
for (int i = 0; i < sourceDocument.getNumberOfPages(); i++) {
PDPage page = sourceDocument.getPage(i);
PDRectangle cropBox =
new PDRectangle(
request.getX(),
request.getY(),
request.getWidth(),
request.getHeight());
page.setCropBox(cropBox);
}
tempInputFile = Files.createTempFile(TEMP_INPUT_PREFIX, PDF_EXTENSION);
tempOutputFile = Files.createTempFile(TEMP_OUTPUT_PREFIX, PDF_EXTENSION);
// Save the source document with crop boxes
sourceDocument.save(tempInputFile.toFile());
// Execute Ghostscript to process the crop boxes
ProcessExecutor processExecutor =
ProcessExecutor.getInstance(ProcessExecutor.Processes.GHOSTSCRIPT);
List<String> command =
List.of(
"gs",
"-sDEVICE=pdfwrite",
"-dUseCropBox",
"-o",
tempOutputFile.toString(),
tempInputFile.toString());
processExecutor.runCommandWithOutputHandling(command);
byte[] pdfContent = Files.readAllBytes(tempOutputFile);
return WebResponseUtils.bytesToWebResponse(
pdfContent,
GeneralUtils.generateFilename(
request.getFileInput().getOriginalFilename(), "_cropped.pdf"));
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw ExceptionUtils.createProcessingInterruptedException("Ghostscript", e);
} finally {
if (tempInputFile != null) {
Files.deleteIfExists(tempInputFile);
}
if (tempOutputFile != null) {
Files.deleteIfExists(tempOutputFile);
}
}
}
private record CropBounds(float x, float y, float width, float height) {
static CropBounds fromPixels(int[] pixelBounds, float scaleX, float scaleY) {
if (pixelBounds.length != 4) {
throw new IllegalArgumentException(
"pixelBounds array must contain exactly 4 elements: [x1, y1, x2, y2]");
}
float x = pixelBounds[0] * scaleX;
float y = pixelBounds[1] * scaleY;
float width = (pixelBounds[2] - pixelBounds[0]) * scaleX;
float height = (pixelBounds[3] - pixelBounds[1]) * scaleY;
return new CropBounds(x, y, width, height);
}
}
}
@@ -1,353 +0,0 @@
package stirling.software.SPDF.controller.api;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Comparator;
import java.util.List;
import org.apache.pdfbox.multipdf.PDFMergerUtility;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDDocumentCatalog;
import org.apache.pdfbox.pdmodel.PDDocumentInformation;
import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.pdmodel.common.PDMetadata;
import org.apache.pdfbox.pdmodel.interactive.documentnavigation.outline.PDDocumentOutline;
import org.apache.pdfbox.pdmodel.interactive.documentnavigation.outline.PDOutlineItem;
import org.apache.pdfbox.pdmodel.interactive.form.PDAcroForm;
import org.apache.pdfbox.pdmodel.interactive.form.PDField;
import org.apache.pdfbox.pdmodel.interactive.form.PDSignatureField;
import org.apache.xmpbox.XMPMetadata;
import org.apache.xmpbox.schema.XMPBasicSchema;
import org.apache.xmpbox.xml.DomXmpParser;
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.RequestParam;
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;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.SPDF.model.api.general.MergePdfsRequest;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.ExceptionUtils;
import stirling.software.common.util.GeneralUtils;
import stirling.software.common.util.PdfErrorUtils;
import stirling.software.common.util.TempFile;
import stirling.software.common.util.TempFileManager;
import stirling.software.common.util.WebResponseUtils;
@RestController
@Slf4j
@RequestMapping("/api/v1/general")
@Tag(name = "General", description = "General APIs")
@RequiredArgsConstructor
public class MergeController {
private final CustomPDFDocumentFactory pdfDocumentFactory;
private final TempFileManager tempFileManager;
// Merges a list of PDDocument objects into a single PDDocument
public PDDocument mergeDocuments(List<PDDocument> documents) throws IOException {
PDDocument mergedDoc = pdfDocumentFactory.createNewDocument();
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.
// fileOrder is newline-delimited original filenames in the desired order.
private static MultipartFile[] reorderFilesByProvidedOrder(
MultipartFile[] files, String fileOrder) {
// Split by various line endings and trim each entry
String[] desired =
stirling.software.common.util.RegexPatternUtils.getInstance()
.getNewlineSplitPattern()
.split(fileOrder);
List<MultipartFile> remaining = new ArrayList<>(Arrays.asList(files));
List<MultipartFile> ordered = new ArrayList<>(files.length);
for (String name : desired) {
name = name.trim();
if (name.isEmpty()) {
log.debug("Skipping empty entry");
continue;
}
int idx = indexOfByOriginalFilename(remaining, name);
if (idx >= 0) {
ordered.add(remaining.remove(idx));
} else {
log.debug("Filename from order list not found in uploaded files: {}", name);
}
}
ordered.addAll(remaining);
return ordered.toArray(new MultipartFile[0]);
}
// Returns a comparator for sorting MultipartFile arrays based on the given sort type
private Comparator<MultipartFile> getSortComparator(String sortType) {
return switch (sortType) {
case "byFileName" ->
Comparator.comparing(
(MultipartFile mf) -> {
String name = mf.getOriginalFilename();
return name == null ? "" : name;
},
String.CASE_INSENSITIVE_ORDER);
case "byDateModified" ->
(file1, file2) -> {
long t1 = getPdfDateTimeSafe(file1);
long t2 = getPdfDateTimeSafe(file2);
return Long.compare(t2, t1);
};
case "byDateCreated" ->
(file1, file2) -> {
long t1 = getPdfDateTimeSafe(file1);
long t2 = getPdfDateTimeSafe(file2);
return Long.compare(t2, t1);
};
case "byPDFTitle" ->
(file1, file2) -> {
try (PDDocument doc1 = pdfDocumentFactory.load(file1);
PDDocument doc2 = pdfDocumentFactory.load(file2)) {
String title1 =
doc1.getDocumentInformation() != null
? doc1.getDocumentInformation().getTitle()
: null;
String title2 =
doc2.getDocumentInformation() != null
? doc2.getDocumentInformation().getTitle()
: null;
if (title1 == null && title2 == null) {
return 0;
}
if (title1 == null) {
return 1;
}
if (title2 == null) {
return -1;
}
return title1.compareToIgnoreCase(title2);
} catch (IOException e) {
return 0;
}
};
case "orderProvided" -> (file1, file2) -> 0; // Default is the order provided
default -> (file1, file2) -> 0; // Default is the order provided
};
}
// Adds a table of contents to the merged document using filenames as chapter titles
private void addTableOfContents(PDDocument mergedDocument, MultipartFile[] files) {
// Create the document outline
PDDocumentOutline outline = new PDDocumentOutline();
mergedDocument.getDocumentCatalog().setDocumentOutline(outline);
int pageIndex = 0; // Current page index in the merged document
// Iterate through the original files
for (MultipartFile file : files) {
// Get the filename without extension to use as bookmark title
String filename = file.getOriginalFilename();
String title = GeneralUtils.removeExtension(filename);
// Create an outline item for this file
PDOutlineItem item = new PDOutlineItem();
item.setTitle(title);
// Set the destination to the first page of this file in the merged document
if (pageIndex < mergedDocument.getNumberOfPages()) {
PDPage page = mergedDocument.getPage(pageIndex);
item.setDestination(page);
}
// Add the item to the outline
outline.addLast(item);
// Increment page index for the next file
try (PDDocument doc = pdfDocumentFactory.load(file)) {
pageIndex += doc.getNumberOfPages();
} catch (IOException e) {
ExceptionUtils.logException("document loading for TOC generation", e);
pageIndex++; // Increment by at least one if we can't determine page count
}
}
}
private long getPdfDateTimeSafe(MultipartFile file) {
try {
try (PDDocument doc = pdfDocumentFactory.load(file)) {
PDDocumentInformation info = doc.getDocumentInformation();
if (info != null) {
if (info.getModificationDate() != null) {
return info.getModificationDate().getTimeInMillis();
}
if (info.getCreationDate() != null) {
return info.getCreationDate().getTimeInMillis();
}
}
// Fallback to XMP metadata if Info dates are missing
PDMetadata metadata = doc.getDocumentCatalog().getMetadata();
if (metadata != null) {
try (InputStream is = metadata.createInputStream()) {
DomXmpParser parser = new DomXmpParser();
XMPMetadata xmp = parser.parse(is);
XMPBasicSchema basic = xmp.getXMPBasicSchema();
if (basic != null) {
if (basic.getModifyDate() != null) {
return basic.getModifyDate().getTimeInMillis();
}
if (basic.getCreateDate() != null) {
return basic.getCreateDate().getTimeInMillis();
}
}
} catch (Exception e) {
log.debug(
"Unable to read XMP metadata dates from uploaded file: {}",
e.getMessage());
}
}
}
} catch (IOException e) {
log.debug("Unable to read PDF dates from uploaded file: {}", e.getMessage());
}
return 0L;
}
private static int indexOfByOriginalFilename(List<MultipartFile> list, String name) {
for (int i = 0; i < list.size(); i++) {
MultipartFile f = list.get(i);
if (name.equals(f.getOriginalFilename())) return i;
}
return -1;
}
@PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/merge-pdfs")
@Operation(
summary = "Merge multiple PDF files into one",
description =
"This endpoint merges multiple PDF files into a single PDF file. The merged"
+ " file will contain all pages from the input files in the order they were"
+ " provided. Input:PDF Output:PDF Type:MISO")
public ResponseEntity<StreamingResponseBody> mergePdfs(
@ModelAttribute MergePdfsRequest request,
@RequestParam(value = "fileOrder", required = false) String fileOrder)
throws IOException {
List<File> filesToDelete = new ArrayList<>(); // List of temporary files to delete
TempFile outputTempFile = null;
boolean removeCertSign = Boolean.TRUE.equals(request.getRemoveCertSign());
boolean generateToc = request.isGenerateToc();
MultipartFile[] files = request.getFileInput();
if (files == null) {
files = new MultipartFile[0];
}
// If front-end provided explicit visible order, honor it and override backend sorting
if (fileOrder != null && !fileOrder.isBlank()) {
log.info("Reordering files based on fileOrder parameter");
files = reorderFilesByProvidedOrder(files, fileOrder);
} else {
log.info("Sorting files based on sortType: {}", request.getSortType());
Arrays.sort(
files,
getSortComparator(
request.getSortType())); // Sort files based on requested sort type
}
ResponseEntity<StreamingResponseBody> response;
try (TempFile mt = new TempFile(tempFileManager, ".pdf")) {
PDFMergerUtility mergerUtility = new PDFMergerUtility();
long totalSize = 0;
for (MultipartFile multipartFile : files) {
totalSize += multipartFile.getSize();
File tempFile =
tempFileManager.convertMultipartFileToFile(
multipartFile); // Convert MultipartFile to File
filesToDelete.add(tempFile); // Add temp file to the list for later deletion
mergerUtility.addSource(tempFile); // Add source file to the merger utility
}
mergerUtility.setDestinationFileName(mt.getFile().getAbsolutePath());
try {
mergerUtility.mergeDocuments(
pdfDocumentFactory.getStreamCacheFunction(
totalSize)); // Merge the documents
} catch (IOException e) {
ExceptionUtils.logException("PDF merge", e);
if (PdfErrorUtils.isCorruptedPdfError(e)) {
throw ExceptionUtils.createMultiplePdfCorruptedException(e);
}
throw e;
}
// Load the merged PDF document and operate on it inside try-with-resources
try (PDDocument mergedDocument = pdfDocumentFactory.load(mt.getFile())) {
// Remove signatures if removeCertSign is true
if (removeCertSign) {
PDDocumentCatalog catalog = mergedDocument.getDocumentCatalog();
PDAcroForm acroForm = catalog.getAcroForm();
if (acroForm != null) {
List<PDField> fieldsToRemove =
acroForm.getFields().stream()
.filter(PDSignatureField.class::isInstance)
.toList();
if (!fieldsToRemove.isEmpty()) {
acroForm.flatten(
fieldsToRemove,
false); // Flatten the fields, effectively removing them
}
}
}
// Add table of contents if generateToc is true
if (generateToc && files.length > 0) {
addTableOfContents(mergedDocument, files);
}
// Save the modified document to a temporary file
outputTempFile = new TempFile(tempFileManager, ".pdf");
mergedDocument.save(outputTempFile.getFile());
}
} catch (Exception ex) {
if (ex instanceof IOException && PdfErrorUtils.isCorruptedPdfError((IOException) ex)) {
log.warn("Corrupted PDF detected in merge pdf process: {}", ex.getMessage());
} else {
log.error("Error in merge pdf process", ex);
}
throw ex;
} finally {
for (File file : filesToDelete) {
tempFileManager.deleteTempFile(file); // Delete temporary files
}
}
String firstFilename = files.length > 0 ? files[0].getOriginalFilename() : null;
String mergedFileName =
GeneralUtils.generateFilename(firstFilename, "_merged_unsigned.pdf");
response = WebResponseUtils.pdfFileToWebResponse(outputTempFile, mergedFileName);
return response;
}
}
@@ -1,182 +0,0 @@
package stirling.software.SPDF.controller.api;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.util.HashMap;
import java.util.Map;
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.apache.pdfbox.util.Matrix;
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 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.general.ScalePagesRequest;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.ExceptionUtils;
import stirling.software.common.util.GeneralUtils;
import stirling.software.common.util.WebResponseUtils;
@RestController
@RequestMapping("/api/v1/general")
@Tag(name = "General", description = "General APIs")
@Slf4j
@RequiredArgsConstructor
public class ScalePagesController {
private final CustomPDFDocumentFactory pdfDocumentFactory;
private static PDRectangle getTargetSize(String targetPDRectangle, PDDocument sourceDocument) {
if ("KEEP".equals(targetPDRectangle)) {
if (sourceDocument.getNumberOfPages() == 0) {
throw ExceptionUtils.createInvalidPageSizeException("KEEP");
}
PDPage sourcePage = sourceDocument.getPage(0);
PDRectangle sourceSize = sourcePage.getMediaBox();
if (sourceSize == null) {
throw ExceptionUtils.createInvalidPageSizeException("KEEP");
}
return sourceSize;
}
Map<String, PDRectangle> sizeMap = getSizeMap();
if (sizeMap.containsKey(targetPDRectangle)) {
return sizeMap.get(targetPDRectangle);
}
throw ExceptionUtils.createInvalidPageSizeException(targetPDRectangle);
}
private static Map<String, PDRectangle> getSizeMap() {
Map<String, PDRectangle> sizeMap = new HashMap<>();
// Portrait sizes (A0-A6)
sizeMap.put("A0", PDRectangle.A0);
sizeMap.put("A1", PDRectangle.A1);
sizeMap.put("A2", PDRectangle.A2);
sizeMap.put("A3", PDRectangle.A3);
sizeMap.put("A4", PDRectangle.A4);
sizeMap.put("A5", PDRectangle.A5);
sizeMap.put("A6", PDRectangle.A6);
// Landscape sizes (A0-A6)
sizeMap.put(
"A0_LANDSCAPE",
new PDRectangle(PDRectangle.A0.getHeight(), PDRectangle.A0.getWidth()));
sizeMap.put(
"A1_LANDSCAPE",
new PDRectangle(PDRectangle.A1.getHeight(), PDRectangle.A1.getWidth()));
sizeMap.put(
"A2_LANDSCAPE",
new PDRectangle(PDRectangle.A2.getHeight(), PDRectangle.A2.getWidth()));
sizeMap.put(
"A3_LANDSCAPE",
new PDRectangle(PDRectangle.A3.getHeight(), PDRectangle.A3.getWidth()));
sizeMap.put(
"A4_LANDSCAPE",
new PDRectangle(PDRectangle.A4.getHeight(), PDRectangle.A4.getWidth()));
sizeMap.put(
"A5_LANDSCAPE",
new PDRectangle(PDRectangle.A5.getHeight(), PDRectangle.A5.getWidth()));
sizeMap.put(
"A6_LANDSCAPE",
new PDRectangle(PDRectangle.A6.getHeight(), PDRectangle.A6.getWidth()));
// Portrait US sizes
sizeMap.put("LETTER", PDRectangle.LETTER);
sizeMap.put("LEGAL", PDRectangle.LEGAL);
// Landscape US sizes
sizeMap.put(
"LETTER_LANDSCAPE",
new PDRectangle(PDRectangle.LETTER.getHeight(), PDRectangle.LETTER.getWidth()));
sizeMap.put(
"LEGAL_LANDSCAPE",
new PDRectangle(PDRectangle.LEGAL.getHeight(), PDRectangle.LEGAL.getWidth()));
return sizeMap;
}
@PostMapping(value = "/scale-pages", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@Operation(
summary = "Change the size of a PDF page/document",
description =
"This operation takes an input PDF file and the size to scale the pages to in"
+ " the output PDF file. Input:PDF Output:PDF Type:SISO")
public ResponseEntity<byte[]> scalePages(@ModelAttribute ScalePagesRequest request)
throws IOException {
MultipartFile file = request.getFileInput();
String targetPDRectangle = request.getPageSize();
float scaleFactor = request.getScaleFactor();
try (PDDocument sourceDocument = pdfDocumentFactory.load(file);
PDDocument outputDocument =
pdfDocumentFactory.createNewDocumentBasedOnOldDocument(sourceDocument);
ByteArrayOutputStream baos = new ByteArrayOutputStream()) {
PDRectangle targetSize = getTargetSize(targetPDRectangle, sourceDocument);
// Create LayerUtility once outside the loop for better performance
LayerUtility layerUtility = new LayerUtility(outputDocument);
int totalPages = sourceDocument.getNumberOfPages();
for (int i = 0; i < totalPages; i++) {
PDPage sourcePage = sourceDocument.getPage(i);
PDRectangle sourceSize = sourcePage.getMediaBox();
float scaleWidth = targetSize.getWidth() / sourceSize.getWidth();
float scaleHeight = targetSize.getHeight() / sourceSize.getHeight();
float scale = Math.min(scaleWidth, scaleHeight) * scaleFactor;
PDPage newPage = new PDPage(targetSize);
outputDocument.addPage(newPage);
try (PDPageContentStream contentStream =
new PDPageContentStream(
outputDocument,
newPage,
PDPageContentStream.AppendMode.APPEND,
true,
true)) {
float x = (targetSize.getWidth() - sourceSize.getWidth() * scale) / 2;
float y = (targetSize.getHeight() - sourceSize.getHeight() * scale) / 2;
contentStream.saveGraphicsState();
contentStream.transform(Matrix.getTranslateInstance(x, y));
contentStream.transform(Matrix.getScaleInstance(scale, scale));
PDFormXObject form = layerUtility.importPageAsForm(sourceDocument, i);
contentStream.drawForm(form);
contentStream.restoreGraphicsState();
}
}
outputDocument.save(baos);
return WebResponseUtils.bytesToWebResponse(
baos.toByteArray(),
GeneralUtils.generateFilename(file.getOriginalFilename(), "_scaled.pdf"));
}
}
}
@@ -1,237 +0,0 @@
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;
import java.util.zip.ZipOutputStream;
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.PDPageContentStream.AppendMode;
import org.apache.pdfbox.pdmodel.common.PDRectangle;
import org.apache.pdfbox.pdmodel.graphics.form.PDFormXObject;
import org.apache.pdfbox.util.Matrix;
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.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import stirling.software.SPDF.model.SplitTypes;
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;
@RestController
@RequestMapping("/api/v1/general")
@Tag(name = "General", description = "General APIs")
@RequiredArgsConstructor
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(
summary = "Split PDF pages into smaller sections",
description =
"Split each page of a PDF into smaller sections based on the user's choice"
+ " which page to split, and how to split"
+ " ( halves, thirds, quarters, etc.), both vertically and horizontally."
+ " Input:PDF Output:ZIP-PDF Type:SISO")
public ResponseEntity<StreamingResponseBody> splitPdf(
@ModelAttribute SplitPdfBySectionsRequest request) throws Exception {
List<ByteArrayOutputStream> splitDocumentsBoas = new ArrayList<>();
MultipartFile file = request.getFileInput();
String pageNumbers = request.getPageNumbers();
SplitTypes splitMode =
Optional.ofNullable(request.getSplitMode())
.map(SplitTypes::valueOf)
.orElse(SplitTypes.SPLIT_ALL);
PDDocument sourceDocument = pdfDocumentFactory.load(file);
Set<Integer> pagesToSplit =
getPagesToSplit(pageNumbers, splitMode, sourceDocument.getNumberOfPages());
// 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++;
}
}
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> 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);
}
return pagesToSplit;
}
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;
}
}
@@ -1,208 +0,0 @@
package stirling.software.SPDF.controller.api.converters;
import java.io.IOException;
import java.io.InputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.Set;
import org.apache.commons.io.FilenameUtils;
import org.apache.pdfbox.pdmodel.PDDocument;
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 io.github.pixee.security.Filenames;
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.config.EndpointConfiguration;
import stirling.software.SPDF.model.api.converters.ConvertEbookToPdfRequest;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.GeneralUtils;
import stirling.software.common.util.ProcessExecutor;
import stirling.software.common.util.ProcessExecutor.ProcessExecutorResult;
import stirling.software.common.util.TempFileManager;
import stirling.software.common.util.WebResponseUtils;
@RestController
@RequestMapping("/api/v1/convert")
@Tag(name = "Convert", description = "Convert APIs")
@RequiredArgsConstructor
@Slf4j
public class ConvertEbookToPDFController {
private static final Set<String> SUPPORTED_EXTENSIONS =
Set.of("epub", "mobi", "azw3", "fb2", "txt", "docx");
private final CustomPDFDocumentFactory pdfDocumentFactory;
private final TempFileManager tempFileManager;
private final EndpointConfiguration endpointConfiguration;
private boolean isCalibreEnabled() {
return endpointConfiguration.isGroupEnabled("Calibre");
}
private boolean isGhostscriptEnabled() {
return endpointConfiguration.isGroupEnabled("Ghostscript");
}
@PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/ebook/pdf")
@Operation(
summary = "Convert an eBook file to PDF",
description =
"This endpoint converts common eBook formats (EPUB, MOBI, AZW3, FB2, TXT, DOCX)"
+ " to PDF using Calibre. Input:BOOK Output:PDF Type:SISO")
public ResponseEntity<byte[]> convertEbookToPdf(
@ModelAttribute ConvertEbookToPdfRequest request) throws Exception {
if (!isCalibreEnabled()) {
throw new IllegalStateException("Calibre support is disabled");
}
MultipartFile inputFile = request.getFileInput();
if (inputFile == null || inputFile.isEmpty()) {
throw new IllegalArgumentException("No input file provided");
}
boolean optimizeForEbook = Boolean.TRUE.equals(request.getOptimizeForEbook());
if (optimizeForEbook && !isGhostscriptEnabled()) {
log.warn(
"Ghostscript optimization requested but Ghostscript is not enabled/available"
+ " for ebook conversion");
optimizeForEbook = false;
}
boolean embedAllFonts = Boolean.TRUE.equals(request.getEmbedAllFonts());
boolean includeTableOfContents = Boolean.TRUE.equals(request.getIncludeTableOfContents());
boolean includePageNumbers = Boolean.TRUE.equals(request.getIncludePageNumbers());
String originalFilename = Filenames.toSimpleFileName(inputFile.getOriginalFilename());
if (originalFilename == null || originalFilename.isBlank()) {
originalFilename = "document";
}
String extension = FilenameUtils.getExtension(originalFilename);
if (extension == null || extension.isBlank()) {
throw new IllegalArgumentException("Unable to determine file type");
}
String lowerExtension = extension.toLowerCase(Locale.ROOT);
if (!SUPPORTED_EXTENSIONS.contains(lowerExtension)) {
throw new IllegalArgumentException("Unsupported eBook file extension: " + extension);
}
String baseName = FilenameUtils.getBaseName(originalFilename);
if (baseName == null || baseName.isBlank()) {
baseName = "document";
}
Path workingDirectory = tempFileManager.createTempDirectory();
Path inputPath = workingDirectory.resolve(baseName + "." + lowerExtension);
Path outputPath = workingDirectory.resolve(baseName + ".pdf");
try (InputStream inputStream = inputFile.getInputStream()) {
Files.copy(inputStream, inputPath, StandardCopyOption.REPLACE_EXISTING);
}
List<String> command =
buildCalibreCommand(
inputPath,
outputPath,
embedAllFonts,
includeTableOfContents,
includePageNumbers);
ProcessExecutorResult result =
ProcessExecutor.getInstance(ProcessExecutor.Processes.CALIBRE)
.runCommandWithOutputHandling(command, workingDirectory.toFile());
if (result == null) {
throw new IllegalStateException("Calibre conversion returned no result");
}
if (result.getRc() != 0) {
String errorMessage = result.getMessages();
if (errorMessage == null || errorMessage.isBlank()) {
errorMessage = "Calibre conversion failed";
}
throw new IllegalStateException(errorMessage);
}
if (!Files.exists(outputPath) || Files.size(outputPath) == 0L) {
throw new IllegalStateException("Calibre did not produce a PDF output");
}
String outputFilename =
GeneralUtils.generateFilename(originalFilename, "_convertedToPDF.pdf");
try {
if (optimizeForEbook) {
byte[] pdfBytes = Files.readAllBytes(outputPath);
try {
byte[] optimizedPdf = GeneralUtils.optimizePdfWithGhostscript(pdfBytes);
return WebResponseUtils.bytesToWebResponse(optimizedPdf, outputFilename);
} catch (IOException e) {
log.warn(
"Ghostscript optimization failed for ebook conversion, returning"
+ " original PDF",
e);
return WebResponseUtils.bytesToWebResponse(pdfBytes, outputFilename);
}
}
try (PDDocument document = pdfDocumentFactory.load(outputPath.toFile())) {
return WebResponseUtils.pdfDocToWebResponse(document, outputFilename);
}
} finally {
cleanupTempFiles(workingDirectory, inputPath, outputPath);
}
}
private List<String> buildCalibreCommand(
Path inputPath,
Path outputPath,
boolean embedAllFonts,
boolean includeTableOfContents,
boolean includePageNumbers) {
List<String> command = new ArrayList<>();
command.add("ebook-convert");
command.add(inputPath.toString());
command.add(outputPath.toString());
if (embedAllFonts) {
command.add("--embed-all-fonts");
}
if (includeTableOfContents) {
command.add("--pdf-add-toc");
}
if (includePageNumbers) {
command.add("--pdf-page-numbers");
}
return command;
}
private void cleanupTempFiles(Path workingDirectory, Path inputPath, Path outputPath) {
List<Path> pathsToDelete = new ArrayList<>();
pathsToDelete.add(inputPath);
pathsToDelete.add(outputPath);
for (Path path : pathsToDelete) {
try {
Files.deleteIfExists(path);
} catch (IOException e) {
log.warn("Failed to delete temporary file: {}", path, e);
}
}
tempFileManager.deleteTempDirectory(workingDirectory);
}
}
@@ -1,214 +0,0 @@
package stirling.software.SPDF.controller.api.converters;
import java.io.File;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import org.apache.commons.io.FileUtils;
import org.apache.commons.io.FilenameUtils;
import org.apache.pdfbox.pdmodel.PDDocument;
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 io.github.pixee.security.Filenames;
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.config.EndpointConfiguration;
import stirling.software.common.configuration.RuntimePathConfig;
import stirling.software.common.model.api.GeneralFile;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.CustomHtmlSanitizer;
import stirling.software.common.util.ExceptionUtils;
import stirling.software.common.util.GeneralUtils;
import stirling.software.common.util.ProcessExecutor;
import stirling.software.common.util.ProcessExecutor.ProcessExecutorResult;
import stirling.software.common.util.RegexPatternUtils;
import stirling.software.common.util.WebResponseUtils;
@RestController
@Tag(name = "Convert", description = "Convert APIs")
@RequestMapping("/api/v1/convert")
@RequiredArgsConstructor
@Slf4j
public class ConvertOfficeController {
private final CustomPDFDocumentFactory pdfDocumentFactory;
private final RuntimePathConfig runtimePathConfig;
private final CustomHtmlSanitizer customHtmlSanitizer;
private final EndpointConfiguration endpointConfiguration;
private boolean isUnoconvertAvailable() {
return endpointConfiguration.isGroupEnabled("Unoconvert")
|| endpointConfiguration.isGroupEnabled("Python");
}
public File convertToPdf(MultipartFile inputFile) throws IOException, InterruptedException {
// Check for valid file extension and sanitize filename
String originalFilename = Filenames.toSimpleFileName(inputFile.getOriginalFilename());
if (originalFilename == null || originalFilename.isBlank()) {
throw ExceptionUtils.createFileNoNameException();
}
// Check for valid file extension
String extension = FilenameUtils.getExtension(originalFilename);
if (extension == null || !isValidFileExtension(extension)) {
throw ExceptionUtils.createIllegalArgumentException(
"error.invalid.extension", "Invalid file extension: " + extension);
}
String extensionLower = extension.toLowerCase(Locale.ROOT);
String baseName = FilenameUtils.getBaseName(originalFilename);
if (baseName == null || baseName.isBlank()) {
baseName = "input";
}
// create temporary working directory
Path workDir = Files.createTempDirectory("office2pdf_");
Path inputPath = workDir.resolve(baseName + "." + extensionLower);
Path outputPath = workDir.resolve(baseName + ".pdf");
// Check if the file is HTML and apply sanitization if needed
if ("html".equals(extensionLower) || "htm".equals(extensionLower)) {
// Read and sanitize HTML content
String htmlContent = new String(inputFile.getBytes(), StandardCharsets.UTF_8);
String sanitizedHtml = customHtmlSanitizer.sanitize(htmlContent);
Files.writeString(inputPath, sanitizedHtml, StandardCharsets.UTF_8);
} else {
// copy file content
Files.copy(inputFile.getInputStream(), inputPath, StandardCopyOption.REPLACE_EXISTING);
}
Path libreOfficeProfile = null;
try {
ProcessExecutorResult result;
// Run Unoconvert command
if (isUnoconvertAvailable()) {
// Unoconvert: schreibe direkt in outputPath innerhalb des workDir
List<String> command = new ArrayList<>();
command.add(runtimePathConfig.getUnoConvertPath());
command.add("--port");
command.add("2003");
command.add("--convert-to");
command.add("pdf");
command.add(inputPath.toString());
command.add(outputPath.toString());
result =
ProcessExecutor.getInstance(ProcessExecutor.Processes.LIBRE_OFFICE)
.runCommandWithOutputHandling(command);
} // Run soffice command
else {
libreOfficeProfile = Files.createTempDirectory("libreoffice_profile_");
List<String> command = new ArrayList<>();
command.add(runtimePathConfig.getSOfficePath());
command.add("-env:UserInstallation=" + libreOfficeProfile.toUri().toString());
command.add("--headless");
command.add("--nologo");
command.add("--convert-to");
command.add("pdf:writer_pdf_Export");
command.add("--outdir");
command.add(workDir.toString());
command.add(inputPath.toString());
result =
ProcessExecutor.getInstance(ProcessExecutor.Processes.LIBRE_OFFICE)
.runCommandWithOutputHandling(command);
}
// Check the result
if (result == null) {
throw new IllegalStateException("Converter returned no result");
}
if (result.getRc() != 0) {
throw new IllegalStateException("Conversion failed (exit " + result.getRc() + ")");
}
if (!Files.exists(outputPath)) {
// Some LibreOffice versions may deviate with exotic names as a fallback, we try
// to find any .pdf in the workDir
try (var stream = Files.list(workDir)) {
Path fallback =
stream.filter(
p ->
p.getFileName()
.toString()
.toLowerCase(Locale.ROOT)
.endsWith(".pdf"))
.findFirst()
.orElse(null);
if (fallback == null) {
throw new IllegalStateException("No PDF produced.");
}
// Move the found PDF to the expected outputPath
Files.move(fallback, outputPath, StandardCopyOption.REPLACE_EXISTING);
}
}
// Check if the output file is empty
if (Files.size(outputPath) == 0L) {
throw new IllegalStateException("Produced PDF is empty");
}
return outputPath.toFile();
} finally {
// Clean up the temporary files
try {
Files.deleteIfExists(inputPath);
} catch (IOException e) {
log.warn("Failed to delete temp input file: {}", inputPath, e);
}
if (libreOfficeProfile != null) {
FileUtils.deleteQuietly(libreOfficeProfile.toFile());
}
}
}
private boolean isValidFileExtension(String fileExtension) {
return RegexPatternUtils.getInstance()
.getFileExtensionValidationPattern()
.matcher(fileExtension)
.matches();
}
@PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/file/pdf")
@Operation(
summary = "Convert a file to a PDF using LibreOffice",
description =
"This endpoint converts a given file to a PDF using LibreOffice API Input:ANY"
+ " Output:PDF Type:SISO")
public ResponseEntity<byte[]> processFileToPDF(@ModelAttribute GeneralFile generalFile)
throws Exception {
MultipartFile inputFile = generalFile.getFileInput();
// unused but can start server instance if startup time is to long
// LibreOfficeListener.getInstance().start();
File file = null;
try {
file = convertToPdf(inputFile);
PDDocument doc = pdfDocumentFactory.load(file);
return WebResponseUtils.pdfDocToWebResponse(
doc,
GeneralUtils.generateFilename(
inputFile.getOriginalFilename(), "_convertedToPDF.pdf"));
} finally {
if (file != null && file.getParent() != null) {
FileUtils.deleteDirectory(file.getParentFile());
}
}
}
}
@@ -1,204 +0,0 @@
package stirling.software.SPDF.controller.api.converters;
import java.io.IOException;
import java.io.InputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.util.ArrayList;
import java.util.List;
import org.apache.commons.io.FilenameUtils;
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 io.github.pixee.security.Filenames;
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.config.EndpointConfiguration;
import stirling.software.SPDF.model.api.converters.ConvertPdfToEpubRequest;
import stirling.software.SPDF.model.api.converters.ConvertPdfToEpubRequest.OutputFormat;
import stirling.software.SPDF.model.api.converters.ConvertPdfToEpubRequest.TargetDevice;
import stirling.software.common.util.GeneralUtils;
import stirling.software.common.util.ProcessExecutor;
import stirling.software.common.util.ProcessExecutor.ProcessExecutorResult;
import stirling.software.common.util.TempFileManager;
import stirling.software.common.util.WebResponseUtils;
@RestController
@RequestMapping("/api/v1/convert")
@Tag(name = "Convert", description = "Convert APIs")
@RequiredArgsConstructor
@Slf4j
public class ConvertPDFToEpubController {
private static final String CALIBRE_GROUP = "Calibre";
private static final String DEFAULT_EXTENSION = "pdf";
private static final String FILTERED_CSS =
"font-family,color,background-color,margin-left,margin-right";
private static final String SMART_CHAPTER_EXPRESSION =
"//h:*[re:test(., '\\s*Chapter\\s+', 'i')]";
private final TempFileManager tempFileManager;
private final EndpointConfiguration endpointConfiguration;
private static List<String> buildCalibreCommand(
Path inputPath, Path outputPath, boolean detectChapters, TargetDevice targetDevice) {
List<String> command = new ArrayList<>();
command.add("ebook-convert");
command.add(inputPath.toString());
command.add(outputPath.toString());
// Golden defaults
command.add("--enable-heuristics");
command.add("--insert-blank-line");
command.add("--filter-css");
command.add(FILTERED_CSS);
if (detectChapters) {
command.add("--chapter");
command.add(SMART_CHAPTER_EXPRESSION);
}
if (targetDevice != null) {
command.add("--output-profile");
command.add(targetDevice.getCalibreProfile());
}
return command;
}
@PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/pdf/epub")
@Operation(
summary = "Convert PDF to EPUB/AZW3",
description =
"Convert a PDF file to a high-quality EPUB or AZW3 ebook using Calibre. Input:PDF"
+ " Output:EPUB/AZW3 Type:SISO")
public ResponseEntity<byte[]> convertPdfToEpub(@ModelAttribute ConvertPdfToEpubRequest request)
throws Exception {
if (!endpointConfiguration.isGroupEnabled(CALIBRE_GROUP)) {
throw new IllegalStateException(
"Calibre support is disabled. Enable the Calibre group or install Calibre to use"
+ " this feature.");
}
MultipartFile inputFile = request.getFileInput();
if (inputFile == null || inputFile.isEmpty()) {
throw new IllegalArgumentException("No input file provided");
}
boolean detectChapters = !Boolean.FALSE.equals(request.getDetectChapters());
TargetDevice targetDevice =
request.getTargetDevice() == null
? TargetDevice.TABLET_PHONE_IMAGES
: request.getTargetDevice();
OutputFormat outputFormat =
request.getOutputFormat() == null ? OutputFormat.EPUB : request.getOutputFormat();
String originalFilename = Filenames.toSimpleFileName(inputFile.getOriginalFilename());
if (originalFilename == null || originalFilename.isBlank()) {
originalFilename = "document." + DEFAULT_EXTENSION;
}
String extension = FilenameUtils.getExtension(originalFilename);
if (extension.isBlank()) {
throw new IllegalArgumentException("Unable to determine file type");
}
if (!DEFAULT_EXTENSION.equalsIgnoreCase(extension)) {
throw new IllegalArgumentException("Input file must be a PDF");
}
String baseName = FilenameUtils.getBaseName(originalFilename);
if (baseName == null || baseName.isBlank()) {
baseName = "document";
}
Path workingDirectory = null;
Path inputPath = null;
Path outputPath = null;
try {
workingDirectory = tempFileManager.createTempDirectory();
inputPath = workingDirectory.resolve(baseName + "." + DEFAULT_EXTENSION);
outputPath = workingDirectory.resolve(baseName + "." + outputFormat.getExtension());
try (InputStream inputStream = inputFile.getInputStream()) {
Files.copy(inputStream, inputPath, StandardCopyOption.REPLACE_EXISTING);
}
List<String> command =
buildCalibreCommand(inputPath, outputPath, detectChapters, targetDevice);
ProcessExecutorResult result =
ProcessExecutor.getInstance(ProcessExecutor.Processes.CALIBRE)
.runCommandWithOutputHandling(command, workingDirectory.toFile());
if (result == null) {
throw new IllegalStateException("Calibre conversion returned no result");
}
if (result.getRc() != 0) {
String errorMessage = result.getMessages();
if (errorMessage == null || errorMessage.isBlank()) {
errorMessage = "Calibre conversion failed";
}
throw new IllegalStateException(errorMessage);
}
if (!Files.exists(outputPath) || Files.size(outputPath) == 0L) {
throw new IllegalStateException(
"Calibre did not produce a " + outputFormat.name() + " output");
}
String outputFilename =
GeneralUtils.generateFilename(
originalFilename,
"_convertedTo"
+ outputFormat.name()
+ "."
+ outputFormat.getExtension());
byte[] outputBytes = Files.readAllBytes(outputPath);
MediaType mediaType = MediaType.valueOf(outputFormat.getMediaType());
return WebResponseUtils.bytesToWebResponse(outputBytes, outputFilename, mediaType);
} finally {
cleanupTempFiles(workingDirectory, inputPath, outputPath);
}
}
private void cleanupTempFiles(Path workingDirectory, Path inputPath, Path outputPath) {
if (workingDirectory == null) {
return;
}
List<Path> pathsToDelete = new ArrayList<>();
if (inputPath != null) {
pathsToDelete.add(inputPath);
}
if (outputPath != null) {
pathsToDelete.add(outputPath);
}
for (Path path : pathsToDelete) {
try {
Files.deleteIfExists(path);
} catch (IOException e) {
log.warn("Failed to delete temporary file: {}", path, e);
}
}
try {
tempFileManager.deleteTempDirectory(workingDirectory);
} catch (Exception e) {
log.warn("Failed to delete temporary directory: {}", workingDirectory, e);
}
}
}
@@ -1,293 +0,0 @@
package stirling.software.SPDF.controller.api.converters;
import java.awt.AlphaComposite;
import java.awt.Color;
import java.awt.Font;
import java.awt.FontMetrics;
import java.awt.Graphics2D;
import java.awt.geom.AffineTransform;
import java.awt.image.BufferedImage;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.text.DecimalFormat;
import java.text.DecimalFormatSymbols;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import javax.imageio.ImageIO;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.rendering.ImageType;
import org.apache.pdfbox.rendering.PDFRenderer;
import org.springframework.http.HttpStatus;
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 io.github.pixee.security.Filenames;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import stirling.software.SPDF.model.api.converters.PdfToVideoRequest;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.ApplicationContextProvider;
import stirling.software.common.util.CheckProgramInstall;
import stirling.software.common.util.ExceptionUtils;
import stirling.software.common.util.ProcessExecutor;
import stirling.software.common.util.ProcessExecutor.ProcessExecutorResult;
import stirling.software.common.util.TempDirectory;
import stirling.software.common.util.TempFile;
import stirling.software.common.util.TempFileManager;
import stirling.software.common.util.WebResponseUtils;
@RestController
@RequestMapping("/api/v1/convert")
@Tag(name = "Convert", description = "Convert APIs")
@RequiredArgsConstructor
public class ConvertPdfToVideoController {
private static final Set<String> SUPPORTED_FORMATS = Set.of("mp4", "webm");
private static final Map<String, String> RESOLUTION_FILTERS =
Map.of(
"ORIGINAL", "scale=trunc(iw/2)*2:trunc(ih/2)*2,setsar=1",
"1080P", "scale=-2:1080,setsar=1",
"720P", "scale=-2:720,setsar=1",
"480P", "scale=-2:480,setsar=1");
private final CustomPDFDocumentFactory pdfDocumentFactory;
private final TempFileManager tempFileManager;
@PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/pdf/video")
@Operation(
summary = "Convert PDF to Video Slideshow",
description =
"This endpoint converts a PDF document into a slideshow-style video."
+ " Input:PDF Output:Video Type:SISO")
public ResponseEntity<byte[]> convertPdfToVideo(@ModelAttribute PdfToVideoRequest request)
throws Exception {
if (!CheckProgramInstall.isFfmpegAvailable()) {
throw ExceptionUtils.createFfmpegRequiredException();
}
MultipartFile inputFile = request.getFileInput();
if (inputFile == null || inputFile.isEmpty()) {
throw ExceptionUtils.createPdfFileRequiredException();
}
if (!MediaType.APPLICATION_PDF_VALUE.equals(inputFile.getContentType())) {
return new ResponseEntity<>(HttpStatus.BAD_REQUEST);
}
float opacity = request.getOpacity() != null ? request.getOpacity() : 1.0f;
if (opacity < 0.0f || opacity > 1.0f) {
throw ExceptionUtils.createIllegalArgumentException(
"error.invalidFormat", "Invalid {0} format: {1}", "opacity", opacity);
}
String format = normalizeFormat(request.getVideoFormat());
int secondsPerPage = request.getSecondsPerPage() != null ? request.getSecondsPerPage() : 3;
if (secondsPerPage <= 0) {
throw ExceptionUtils.createIllegalArgumentException(
"error.invalidFormat",
"Invalid {0} format: {1}",
"secondsPerPage",
secondsPerPage);
}
int dpi = request.getDpi() != null ? request.getDpi() : 150;
int maxDpi = getMaxDpi();
if (dpi > maxDpi) {
throw ExceptionUtils.createIllegalArgumentException(
"error.dpiExceedsLimit",
"DPI value {0} exceeds maximum safe limit of {1}.",
dpi,
maxDpi);
}
if (dpi < 72) {
throw ExceptionUtils.createIllegalArgumentException(
"error.invalidFormat", "Invalid {0} format: {1}", "dpi", dpi);
}
String resolution =
request.getResolution() != null
? request.getResolution().toUpperCase(Locale.ROOT)
: "ORIGINAL";
if (!RESOLUTION_FILTERS.containsKey(resolution)) {
resolution = "ORIGINAL";
}
String watermarkText = request.getWatermarkText();
boolean isWatermarkEnabled = watermarkText != null && !watermarkText.isBlank();
String originalPdfFileName = Filenames.toSimpleFileName(inputFile.getOriginalFilename());
if (originalPdfFileName == null || originalPdfFileName.isBlank()) {
originalPdfFileName = "document.pdf";
}
String pdfBaseName =
originalPdfFileName.contains(".")
? originalPdfFileName.substring(0, originalPdfFileName.lastIndexOf('.'))
: originalPdfFileName;
try (TempFile inputTempFile = new TempFile(tempFileManager, ".pdf");
TempDirectory framesDirectory = new TempDirectory(tempFileManager);
TempFile outputVideo = new TempFile(tempFileManager, "." + format)) {
inputFile.transferTo(inputTempFile.getFile());
generateFrames(
inputTempFile.getPath(),
framesDirectory.getPath(),
dpi,
opacity,
watermarkText,
isWatermarkEnabled);
DecimalFormat decimalFormat =
new DecimalFormat("0.######", DecimalFormatSymbols.getInstance(Locale.ROOT));
String frameRate = decimalFormat.format(1.0d / secondsPerPage);
List<String> command = buildFfmpegCommand(format, resolution, frameRate, outputVideo);
ProcessExecutorResult result =
ProcessExecutor.getInstance(ProcessExecutor.Processes.FFMPEG)
.runCommandWithOutputHandling(
command, framesDirectory.getPath().toFile());
byte[] videoBytes = Files.readAllBytes(outputVideo.getPath());
MediaType mediaType = getMediaType(format);
String outputName = pdfBaseName + "-video." + format;
return WebResponseUtils.bytesToWebResponse(videoBytes, outputName, mediaType);
}
}
private void generateFrames(
Path inputPdf,
Path outputDir,
int dpi,
float opacity,
String watermarkText,
boolean isWatermarkEnabled)
throws IOException {
try (PDDocument document = pdfDocumentFactory.load(inputPdf.toFile())) {
PDFRenderer renderer = new PDFRenderer(document);
renderer.setSubsamplingAllowed(true);
int pageCount = document.getNumberOfPages();
if (pageCount == 0) {
throw ExceptionUtils.createIllegalArgumentException(
"error.invalidFormat", "Invalid {0} format: {1}", "PDF", "no pages");
}
for (int pageIndex = 0; pageIndex < pageCount; pageIndex++) {
final int currentPageIndex = pageIndex;
// Validate dimensions BEFORE attempting to render to prevent OOM
ExceptionUtils.validateRenderingDimensions(
document.getPage(currentPageIndex), currentPageIndex + 1, dpi);
BufferedImage image =
ExceptionUtils.handleOomRendering(
currentPageIndex + 1,
dpi,
() ->
renderer.renderImageWithDPI(
currentPageIndex, dpi, ImageType.RGB));
if (isWatermarkEnabled) {
applyWatermark(image, opacity, watermarkText);
}
Path framePath =
outputDir.resolve(
String.format(Locale.ROOT, "frame_%05d.png", pageIndex + 1));
ImageIO.write(image, "png", framePath.toFile());
}
}
}
private void applyWatermark(BufferedImage image, float opacity, String watermarkText) {
Graphics2D graphics = image.createGraphics();
try {
graphics.setRenderingHint(
java.awt.RenderingHints.KEY_TEXT_ANTIALIASING,
java.awt.RenderingHints.VALUE_TEXT_ANTIALIAS_ON);
int baseDimension = Math.min(image.getWidth(), image.getHeight());
int fontSize = Math.max(32, baseDimension / 5);
Font font = new Font(Font.SANS_SERIF, Font.BOLD, fontSize);
graphics.setFont(font);
FontMetrics metrics = graphics.getFontMetrics(font);
int textWidth = metrics.stringWidth(watermarkText);
int textHeight = metrics.getAscent();
AffineTransform originalTransform = graphics.getTransform();
double angle = Math.atan2(image.getHeight(), image.getWidth());
graphics.translate(image.getWidth() / 2.0, image.getHeight() / 2.0);
graphics.rotate(-angle);
// Draw shadow
graphics.setComposite(AlphaComposite.getInstance(AlphaComposite.SRC_OVER, opacity / 2));
graphics.setColor(Color.BLACK);
graphics.drawString(watermarkText, -textWidth / 2f + 3, textHeight / 2f + 3);
// Draw main text
graphics.setComposite(AlphaComposite.getInstance(AlphaComposite.SRC_OVER, opacity));
graphics.setColor(Color.WHITE);
graphics.drawString(watermarkText, -textWidth / 2f, textHeight / 2f);
graphics.setTransform(originalTransform);
} finally {
graphics.dispose();
}
}
private List<String> buildFfmpegCommand(
String format, String resolution, String frameRate, TempFile outputVideo) {
List<String> command = new ArrayList<>();
command.add("ffmpeg");
command.add("-y");
command.add("-framerate");
command.add(frameRate);
command.add("-i");
command.add("frame_%05d.png");
command.add("-vf");
command.add(
RESOLUTION_FILTERS.getOrDefault(resolution, RESOLUTION_FILTERS.get("ORIGINAL")));
if ("mp4".equals(format)) {
command.addAll(
List.of("-c:v", "libx264", "-pix_fmt", "yuv420p", "-movflags", "+faststart"));
} else if ("webm".equals(format)) {
command.addAll(List.of("-c:v", "libvpx-vp9", "-b:v", "0", "-crf", "30"));
}
command.add(outputVideo.getAbsolutePath());
return command;
}
private String normalizeFormat(String requestedFormat) {
String format = requestedFormat != null ? requestedFormat.toLowerCase(Locale.ROOT) : "mp4";
if (!SUPPORTED_FORMATS.contains(format)) {
format = "mp4";
}
return format;
}
private int getMaxDpi() {
ApplicationProperties properties =
ApplicationContextProvider.getBean(ApplicationProperties.class);
if (properties != null && properties.getSystem() != null) {
return properties.getSystem().getMaxDPI();
}
return 500;
}
private MediaType getMediaType(String format) {
return switch (format) {
case "webm" -> MediaType.valueOf("video/webm");
default -> MediaType.valueOf("video/mp4");
};
}
}
@@ -1,297 +0,0 @@
package stirling.software.SPDF.controller.api.converters;
import java.io.IOException;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Duration;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.springframework.http.HttpStatus;
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.servlet.support.ServletUriComponentsBuilder;
import org.springframework.web.util.UriComponentsBuilder;
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.converters.UrlToPdfRequest;
import stirling.software.common.configuration.RuntimePathConfig;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.ExceptionUtils;
import stirling.software.common.util.GeneralUtils;
import stirling.software.common.util.ProcessExecutor;
import stirling.software.common.util.RegexPatternUtils;
import stirling.software.common.util.WebResponseUtils;
@RestController
@Tag(name = "Convert", description = "Convert APIs")
@Slf4j
@RequestMapping("/api/v1/convert")
@RequiredArgsConstructor
public class ConvertWebsiteToPDF {
private final CustomPDFDocumentFactory pdfDocumentFactory;
private final RuntimePathConfig runtimePathConfig;
private final ApplicationProperties applicationProperties;
private static final Pattern FILE_SCHEME_PATTERN =
Pattern.compile("(?<![a-z0-9_])file\\s*:(?:/{1,3}|%2f|%5c|%3a|&#x2f;|&#47;)");
private static final Pattern NUMERIC_HTML_ENTITY_PATTERN = Pattern.compile("&#(x?[0-9a-f]+);");
@PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/url/pdf")
@Operation(
summary = "Convert a URL to a PDF",
description =
"This endpoint fetches content from a URL and converts it to a PDF format."
+ " Input:N/A Output:PDF Type:SISO")
public ResponseEntity<?> urlToPdf(@ModelAttribute UrlToPdfRequest request)
throws IOException, InterruptedException {
String URL = request.getUrlInput();
UriComponentsBuilder uriComponentsBuilder =
ServletUriComponentsBuilder.fromCurrentContextPath().path("/url-to-pdf");
URI location = null;
HttpStatus status = HttpStatus.SEE_OTHER;
if (!applicationProperties.getSystem().isEnableUrlToPDF()) {
location =
uriComponentsBuilder
.queryParam("error", "error.endpointDisabled")
.build()
.toUri();
} else {
// Validate the URL format (relaxed: only invalid if BOTH checks fail)
boolean patternValid =
RegexPatternUtils.getInstance().getHttpUrlPattern().matcher(URL).matches();
boolean generalValid = GeneralUtils.isValidURL(URL);
if (!patternValid && !generalValid) {
location =
uriComponentsBuilder
.queryParam("error", "error.invalidUrlFormat")
.build()
.toUri();
} else if (!GeneralUtils.isURLReachable(URL)) {
// validate the URL is reachable
location =
uriComponentsBuilder
.queryParam("error", "error.urlNotReachable")
.build()
.toUri();
}
}
if (location != null) {
log.info("Redirecting to: {}", location.toString());
return ResponseEntity.status(status).location(location).build();
}
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);
if (containsDisallowedUriScheme(htmlContent)) {
URI rejectionLocation =
uriComponentsBuilder
.queryParam("error", "error.disallowedUrlContent")
.build()
.toUri();
log.warn("Rejected URL to PDF conversion due to disallowed content references");
return ResponseEntity.status(status).location(rejectionLocation).build();
}
tempHtmlInput = Files.createTempFile("url_input_", ".html");
Files.writeString(tempHtmlInput, htmlContent, StandardCharsets.UTF_8);
// Prepare the output file path
tempOutputFile = Files.createTempFile("output_", ".pdf");
// Prepare the WeasyPrint command
List<String> command = new ArrayList<>();
command.add(runtimePathConfig.getWeasyPrintPath());
command.add(tempHtmlInput.toString());
command.add("--base-url");
command.add(URL);
command.add("--pdf-forms");
command.add(tempOutputFile.toString());
ProcessExecutor.getInstance(ProcessExecutor.Processes.WEASYPRINT)
.runCommandWithOutputHandling(command);
// Load the PDF using pdfDocumentFactory
doc = pdfDocumentFactory.load(tempOutputFile.toFile());
// 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 {
Files.deleteIfExists(tempHtmlInput);
} catch (IOException e) {
log.error("Error deleting temporary HTML input file", e);
}
}
if (tempOutputFile != null) {
try {
Files.deleteIfExists(tempOutputFile);
} catch (IOException e) {
log.error("Error deleting temporary output file", e);
}
}
}
}
private String fetchRemoteHtml(String url) throws IOException, InterruptedException {
HttpClient client =
HttpClient.newBuilder()
.followRedirects(HttpClient.Redirect.NORMAL)
.connectTimeout(Duration.ofSeconds(10))
.build();
HttpRequest request =
HttpRequest.newBuilder(URI.create(url))
.timeout(Duration.ofSeconds(20))
.GET()
.header("User-Agent", "Stirling-PDF/URL-to-PDF")
.build();
HttpResponse<String> response =
client.send(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
if (response.statusCode() >= 400 || response.body() == null) {
throw ExceptionUtils.createIOException(
"error.httpRequestFailed",
"Failed to retrieve remote HTML. Status: {0}",
null,
response.statusCode());
}
return response.body();
}
private boolean containsDisallowedUriScheme(String htmlContent) {
if (htmlContent == null || htmlContent.isEmpty()) {
return false;
}
String normalized = normalizeForSchemeDetection(htmlContent);
return FILE_SCHEME_PATTERN.matcher(normalized).find();
}
private String normalizeForSchemeDetection(String htmlContent) {
String lowerCaseContent = htmlContent.toLowerCase(Locale.ROOT);
String decodedHtmlEntities = decodeNumericHtmlEntities(lowerCaseContent);
decodedHtmlEntities =
decodedHtmlEntities
.replace("&colon;", ":")
.replace("&sol;", "/")
.replace("&frasl;", "/");
return percentDecode(decodedHtmlEntities);
}
private String percentDecode(String content) {
StringBuilder result = new StringBuilder(content.length());
for (int i = 0; i < content.length(); i++) {
char current = content.charAt(i);
if (current == '%' && i + 2 < content.length()) {
String hex = content.substring(i + 1, i + 3);
try {
int value = Integer.parseInt(hex, 16);
result.append((char) value);
i += 2;
continue;
} catch (NumberFormatException ignored) {
// Fall through to append the literal characters when parsing fails
}
}
result.append(current);
}
return result.toString();
}
private String decodeNumericHtmlEntities(String content) {
Matcher matcher = NUMERIC_HTML_ENTITY_PATTERN.matcher(content);
StringBuffer decoded = new StringBuffer();
while (matcher.find()) {
String entityBody = matcher.group(1);
try {
int radix = entityBody.startsWith("x") ? 16 : 10;
int codePoint =
Integer.parseInt(radix == 16 ? entityBody.substring(1) : entityBody, radix);
matcher.appendReplacement(
decoded, Matcher.quoteReplacement(Character.toString((char) codePoint)));
} catch (NumberFormatException ex) {
matcher.appendReplacement(decoded, matcher.group(0));
}
}
matcher.appendTail(decoded);
return decoded.toString();
}
private String convertURLToFileName(String url) {
String safeName = GeneralUtils.convertToFileName(url);
if (safeName == null || safeName.isBlank()) {
// Fallback: derive from URL host/path or use default
try {
URI uri = URI.create(url);
String hostPart = uri.getHost();
if (hostPart == null || hostPart.isBlank()) {
hostPart = "document";
}
safeName =
RegexPatternUtils.getInstance()
.getNonAlnumUnderscorePattern()
.matcher(hostPart)
.replaceAll("_");
} catch (Exception e) {
safeName = "document";
}
}
// Restrict characters strictly to alphanumeric and underscore for predictable tests
RegexPatternUtils patterns = RegexPatternUtils.getInstance();
safeName = patterns.getNonAlnumUnderscorePattern().matcher(safeName).replaceAll("_");
// Collapse multiple underscores
safeName = patterns.getMultipleUnderscoresPattern().matcher(safeName).replaceAll("_");
// Trim leading underscores
safeName = patterns.getLeadingUnderscoresPattern().matcher(safeName).replaceAll("");
// Trim trailing underscores
safeName = patterns.getTrailingUnderscoresPattern().matcher(safeName).replaceAll("");
if (safeName.isEmpty()) {
safeName = "document";
}
if (safeName.length() > 50) {
safeName = safeName.substring(0, 50); // restrict to 50 characters
}
return GeneralUtils.generateFilename(safeName, ".pdf");
}
}
@@ -1,252 +0,0 @@
package stirling.software.SPDF.controller.api.converters;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.Set;
import org.apache.commons.io.FilenameUtils;
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 io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.SPDF.config.EndpointConfiguration;
import stirling.software.SPDF.model.api.converters.PdfVectorExportRequest;
import stirling.software.common.util.ExceptionUtils;
import stirling.software.common.util.GeneralUtils;
import stirling.software.common.util.ProcessExecutor;
import stirling.software.common.util.ProcessExecutor.ProcessExecutorResult;
import stirling.software.common.util.TempFile;
import stirling.software.common.util.TempFileManager;
import stirling.software.common.util.WebResponseUtils;
@RestController
@RequestMapping("/api/v1/convert")
@Slf4j
@Tag(name = "Convert", description = "Convert APIs")
@RequiredArgsConstructor
public class PdfVectorExportController {
private static final MediaType PDF_MEDIA_TYPE = MediaType.APPLICATION_PDF;
private static final Set<String> GHOSTSCRIPT_INPUTS =
Set.of("ps", "eps", "epsf"); // PCL/PXL/XPS require GhostPDL (gpcl6/gxps)
private final TempFileManager tempFileManager;
private final EndpointConfiguration endpointConfiguration;
@PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/vector/pdf")
@Operation(
summary = "Convert PostScript formats to PDF",
description =
"Converts PostScript vector inputs (PS, EPS, EPSF) to PDF using Ghostscript."
+ " Input:PS/EPS Output:PDF Type:SISO")
public ResponseEntity<byte[]> convertGhostscriptInputsToPdf(
@Valid @ModelAttribute PdfVectorExportRequest request) throws Exception {
String originalName =
request.getFileInput() != null
? request.getFileInput().getOriginalFilename()
: null;
String extension =
originalName != null
? FilenameUtils.getExtension(originalName).toLowerCase(Locale.ROOT)
: "";
try (TempFile inputTemp =
new TempFile(tempFileManager, extension.isEmpty() ? "" : "." + extension);
TempFile outputTemp = new TempFile(tempFileManager, ".pdf")) {
request.getFileInput().transferTo(inputTemp.getFile());
if (GHOSTSCRIPT_INPUTS.contains(extension)) {
boolean prepress = request.getPrepress() != null && request.getPrepress();
runGhostscriptToPdf(inputTemp.getPath(), outputTemp.getPath(), prepress);
} else if ("pdf".equals(extension)) {
Files.copy(
inputTemp.getPath(),
outputTemp.getPath(),
StandardCopyOption.REPLACE_EXISTING);
} else {
throw ExceptionUtils.createIllegalArgumentException(
"error.invalidFormat",
"Unsupported Ghostscript input format {0}",
extension);
}
byte[] pdfBytes = Files.readAllBytes(outputTemp.getPath());
String outputName = GeneralUtils.generateFilename(originalName, "_converted.pdf");
return WebResponseUtils.bytesToWebResponse(pdfBytes, outputName, PDF_MEDIA_TYPE);
}
}
@PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/pdf/vector")
@Operation(
summary = "Convert PDF to vector format",
description =
"Converts PDF to Ghostscript vector formats (EPS, PS, PCL, or XPS)."
+ " Input:PDF Output:VECTOR Type:SISO")
public ResponseEntity<byte[]> convertPdfToVector(
@Valid @ModelAttribute PdfVectorExportRequest request) throws Exception {
String originalName =
request.getFileInput() != null
? request.getFileInput().getOriginalFilename()
: null;
String outputFormat = request.getOutputFormat();
if (outputFormat == null || outputFormat.isEmpty()) {
outputFormat = "eps";
}
outputFormat = outputFormat.toLowerCase(Locale.ROOT);
try (TempFile inputTemp = new TempFile(tempFileManager, ".pdf");
TempFile outputTemp = new TempFile(tempFileManager, "." + outputFormat)) {
request.getFileInput().transferTo(inputTemp.getFile());
runGhostscriptPdfToVector(inputTemp.getPath(), outputTemp.getPath(), outputFormat);
byte[] vectorBytes = Files.readAllBytes(outputTemp.getPath());
String outputName =
GeneralUtils.generateFilename(originalName, "_converted." + outputFormat);
MediaType mediaType;
switch (outputFormat.toLowerCase(Locale.ROOT)) {
case "eps":
case "ps":
mediaType = MediaType.parseMediaType("application/postscript");
break;
case "pcl":
mediaType = MediaType.parseMediaType("application/vnd.hp-PCL");
break;
case "xps":
mediaType = MediaType.parseMediaType("application/vnd.ms-xpsdocument");
break;
default:
mediaType = MediaType.APPLICATION_OCTET_STREAM;
}
return WebResponseUtils.bytesToWebResponse(vectorBytes, outputName, mediaType);
}
}
private void runGhostscriptPdfToVector(Path inputPath, Path outputPath, String outputFormat)
throws IOException, InterruptedException {
if (!endpointConfiguration.isGroupEnabled("Ghostscript")) {
throw ExceptionUtils.createGhostscriptConversionException(outputFormat);
}
List<String> command = new ArrayList<>();
command.add("gs");
// Set device based on output format
String device =
switch (outputFormat.toLowerCase(Locale.ROOT)) {
case "eps" -> "eps2write";
case "ps" -> "ps2write";
case "pcl" -> "pxlcolor"; // PCL XL color
case "xps" -> "xpswrite";
default ->
throw ExceptionUtils.createIllegalArgumentException(
"error.invalidFormat",
"Unsupported output format: {0}",
outputFormat);
};
command.add("-sDEVICE=" + device);
command.add("-dNOPAUSE");
command.add("-dBATCH");
command.add("-dSAFER");
command.add("-sOutputFile=" + outputPath.toAbsolutePath());
command.add(inputPath.toAbsolutePath().toString());
log.debug("Executing Ghostscript command: {}", String.join(" ", command));
ProcessExecutorResult result =
ProcessExecutor.getInstance(ProcessExecutor.Processes.GHOSTSCRIPT)
.runCommandWithOutputHandling(command);
ExceptionUtils.GhostscriptException criticalError =
ExceptionUtils.detectGhostscriptCriticalError(result.getMessages());
if (criticalError != null) {
log.error(
"Ghostscript PDF to {} conversion detected critical error: {}. Command: {}",
outputFormat.toUpperCase(),
criticalError.getMessage(),
String.join(" ", command));
throw criticalError;
}
if (result.getRc() != 0) {
log.error(
"Ghostscript PDF to {} conversion failed with rc={} and messages={}. Command: {}",
outputFormat.toUpperCase(),
result.getRc(),
result.getMessages(),
String.join(" ", command));
throw ExceptionUtils.createGhostscriptConversionException(outputFormat);
}
}
private void runGhostscriptToPdf(Path inputPath, Path outputPath, boolean prepress)
throws IOException, InterruptedException {
if (!endpointConfiguration.isGroupEnabled("Ghostscript")) {
throw ExceptionUtils.createGhostscriptConversionException("pdfwrite");
}
List<String> command = new ArrayList<>();
command.add("gs");
command.add("-sDEVICE=pdfwrite");
command.add("-dNOPAUSE");
command.add("-dBATCH");
command.add("-dSAFER");
command.add("-dCompatibilityLevel=1.4");
if (prepress) {
command.add("-dPDFSETTINGS=/prepress");
}
command.add("-sOutputFile=" + outputPath.toAbsolutePath());
command.add(inputPath.toAbsolutePath().toString());
log.debug("Executing Ghostscript PostScript-to-PDF command: {}", String.join(" ", command));
ProcessExecutorResult result =
ProcessExecutor.getInstance(ProcessExecutor.Processes.GHOSTSCRIPT)
.runCommandWithOutputHandling(command);
ExceptionUtils.GhostscriptException criticalError =
ExceptionUtils.detectGhostscriptCriticalError(result.getMessages());
if (criticalError != null) {
log.error(
"Ghostscript PostScript-to-PDF conversion detected critical error: {}. Command: {}",
criticalError.getMessage(),
String.join(" ", command));
throw criticalError;
}
if (result.getRc() != 0) {
log.error(
"Ghostscript PostScript-to-PDF conversion failed with rc={} and messages={}. Command: {}",
result.getRc(),
result.getMessages(),
String.join(" ", command));
throw ExceptionUtils.createGhostscriptConversionException("pdfwrite");
}
}
}
@@ -1,248 +0,0 @@
package stirling.software.SPDF.controller.api.filters;
import java.io.IOException;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.pdmodel.common.PDRectangle;
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 io.github.pixee.security.Filenames;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.responses.ApiResponses;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import stirling.software.SPDF.model.api.PDFComparisonAndCount;
import stirling.software.SPDF.model.api.PDFWithPageNums;
import stirling.software.SPDF.model.api.filter.ContainsTextRequest;
import stirling.software.SPDF.model.api.filter.FileSizeRequest;
import stirling.software.SPDF.model.api.filter.PageRotationRequest;
import stirling.software.SPDF.model.api.filter.PageSizeRequest;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.ExceptionUtils;
import stirling.software.common.util.PdfUtils;
import stirling.software.common.util.WebResponseUtils;
@RestController
@RequestMapping("/api/v1/filter")
@Tag(name = "Filter", description = "Filter APIs")
@RequiredArgsConstructor
public class FilterController {
private final CustomPDFDocumentFactory pdfDocumentFactory;
@PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/filter-contains-text")
@Operation(
summary = "Checks if a PDF contains set text, returns true if does",
description = "Input:PDF Output:Boolean Type:SISO")
@ApiResponses({
@ApiResponse(
responseCode = "200",
description = "PDF passed filter",
content = @Content(mediaType = MediaType.APPLICATION_PDF_VALUE)),
@ApiResponse(
responseCode = "204",
description = "PDF did not pass filter",
content = @Content())
})
public ResponseEntity<byte[]> containsText(@ModelAttribute ContainsTextRequest request)
throws IOException, InterruptedException {
MultipartFile inputFile = request.getFileInput();
String text = request.getText();
String pageNumber = request.getPageNumbers();
try (PDDocument pdfDocument = pdfDocumentFactory.load(inputFile)) {
if (PdfUtils.hasText(pdfDocument, pageNumber, text)) {
return WebResponseUtils.pdfDocToWebResponse(
pdfDocument, Filenames.toSimpleFileName(inputFile.getOriginalFilename()));
}
}
return ResponseEntity.noContent().build();
}
@PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/filter-contains-image")
@Operation(
summary = "Checks if a PDF contains an image",
description = "Input:PDF Output:Boolean Type:SISO")
@ApiResponses({
@ApiResponse(
responseCode = "200",
description = "PDF passed filter",
content = @Content(mediaType = MediaType.APPLICATION_PDF_VALUE)),
@ApiResponse(
responseCode = "204",
description = "PDF did not pass filter",
content = @Content())
})
public ResponseEntity<byte[]> containsImage(@ModelAttribute PDFWithPageNums request)
throws IOException, InterruptedException {
MultipartFile inputFile = request.getFileInput();
String pageNumber = request.getPageNumbers();
try (PDDocument pdfDocument = pdfDocumentFactory.load(inputFile)) {
if (PdfUtils.hasImages(pdfDocument, pageNumber)) {
return WebResponseUtils.pdfDocToWebResponse(
pdfDocument, Filenames.toSimpleFileName(inputFile.getOriginalFilename()));
}
}
return ResponseEntity.noContent().build();
}
@PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/filter-page-count")
@Operation(
summary = "Checks if a PDF is greater, less or equal to a setPageCount",
description = "Input:PDF Output:Boolean Type:SISO")
@ApiResponses({
@ApiResponse(
responseCode = "200",
description = "PDF passed filter",
content = @Content(mediaType = MediaType.APPLICATION_PDF_VALUE)),
@ApiResponse(
responseCode = "204",
description = "PDF did not pass filter",
content = @Content())
})
public ResponseEntity<byte[]> pageCount(@ModelAttribute PDFComparisonAndCount request)
throws IOException, InterruptedException {
MultipartFile inputFile = request.getFileInput();
int pageCount = request.getPageCount();
String comparator = request.getComparator();
boolean valid;
try (PDDocument document = pdfDocumentFactory.load(inputFile)) {
int actualPageCount = document.getNumberOfPages();
valid = compare(actualPageCount, pageCount, comparator);
}
return valid
? WebResponseUtils.multiPartFileToWebResponse(inputFile)
: ResponseEntity.noContent().build();
}
@PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/filter-page-size")
@Operation(
summary = "Checks if a PDF is of a certain size",
description = "Input:PDF Output:Boolean Type:SISO")
@ApiResponses({
@ApiResponse(
responseCode = "200",
description = "PDF passed filter",
content = @Content(mediaType = MediaType.APPLICATION_PDF_VALUE)),
@ApiResponse(
responseCode = "204",
description = "PDF did not pass filter",
content = @Content())
})
public ResponseEntity<byte[]> pageSize(@ModelAttribute PageSizeRequest request)
throws IOException, InterruptedException {
MultipartFile inputFile = request.getFileInput();
String standardPageSize = request.getStandardPageSize();
String comparator = request.getComparator();
final boolean valid;
try (PDDocument document = pdfDocumentFactory.load(inputFile)) {
PDPage firstPage = document.getPage(0);
PDRectangle actualPageSize = firstPage.getMediaBox();
float actualArea = actualPageSize.getWidth() * actualPageSize.getHeight();
PDRectangle standardSize = PdfUtils.textToPageSize(standardPageSize);
float standardArea = standardSize.getWidth() * standardSize.getHeight();
valid = compare(actualArea, standardArea, comparator);
}
return valid
? WebResponseUtils.multiPartFileToWebResponse(inputFile)
: ResponseEntity.noContent().build();
}
@PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/filter-file-size")
@Operation(
summary = "Checks if a PDF is a set file size",
description = "Input:PDF Output:Boolean Type:SISO")
@ApiResponses({
@ApiResponse(
responseCode = "200",
description = "PDF passed filter",
content = @Content(mediaType = MediaType.APPLICATION_PDF_VALUE)),
@ApiResponse(
responseCode = "204",
description = "PDF did not pass filter",
content = @Content())
})
public ResponseEntity<byte[]> fileSize(@ModelAttribute FileSizeRequest request)
throws IOException, InterruptedException {
MultipartFile inputFile = request.getFileInput();
long fileSize = request.getFileSize();
String comparator = request.getComparator();
long actualFileSize = inputFile.getSize();
boolean valid = compare(actualFileSize, fileSize, comparator);
return valid
? WebResponseUtils.multiPartFileToWebResponse(inputFile)
: ResponseEntity.noContent().build();
}
@PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/filter-page-rotation")
@Operation(
summary = "Checks if a PDF is of a certain rotation",
description = "Input:PDF Output:Boolean Type:SISO")
@ApiResponses({
@ApiResponse(
responseCode = "200",
description = "PDF passed filter",
content = @Content(mediaType = MediaType.APPLICATION_PDF_VALUE)),
@ApiResponse(
responseCode = "204",
description = "PDF did not pass filter",
content = @Content())
})
public ResponseEntity<byte[]> pageRotation(@ModelAttribute PageRotationRequest request)
throws IOException, InterruptedException {
MultipartFile inputFile = request.getFileInput();
int rotation = request.getRotation();
String comparator = request.getComparator();
boolean valid;
try (PDDocument document = pdfDocumentFactory.load(inputFile)) {
PDPage firstPage = document.getPage(0);
int actualRotation = firstPage.getRotation();
valid = compare(actualRotation, rotation, comparator);
}
return valid
? WebResponseUtils.multiPartFileToWebResponse(inputFile)
: ResponseEntity.noContent().build();
}
/**
* Compares two values based on the provided comparator.
*
* @param <T> The type of the values being compared.
* @param actual The actual value.
* @param expected The expected value.
* @param comparator The comparator to use (e.g., "Greater", "Less", "Equal").
* @return True if the comparison is valid, false otherwise.
*/
private static <T extends Comparable<T>> boolean compare(
T actual, T expected, String comparator) {
return switch (comparator) {
case "Greater" -> actual.compareTo(expected) > 0;
case "Equal" -> actual.compareTo(expected) == 0;
case "Less" -> actual.compareTo(expected) < 0;
default ->
throw ExceptionUtils.createInvalidArgumentException("comparator", comparator);
};
}
}
@@ -1,841 +0,0 @@
package stirling.software.SPDF.controller.api.misc;
import java.awt.Color;
import java.awt.Graphics2D;
import java.awt.RenderingHints;
import java.awt.geom.AffineTransform;
import java.awt.image.BufferedImage;
import java.awt.image.DataBufferInt;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.UncheckedIOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.Queue;
import java.util.concurrent.Callable;
import java.util.concurrent.ConcurrentLinkedQueue;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.ForkJoinPool;
import java.util.concurrent.Future;
import java.util.concurrent.ThreadLocalRandom;
import java.util.concurrent.TimeUnit;
import java.util.stream.IntStream;
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.image.LosslessFactory;
import org.apache.pdfbox.pdmodel.graphics.image.PDImageXObject;
import org.apache.pdfbox.rendering.PDFRenderer;
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 io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.SPDF.model.api.misc.ScannerEffectRequest;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.ApplicationContextProvider;
import stirling.software.common.util.ExceptionUtils;
import stirling.software.common.util.GeneralUtils;
import stirling.software.common.util.WebResponseUtils;
@RestController
@RequestMapping("/api/v1/misc")
@Tag(name = "Misc", description = "Miscellaneous PDF APIs")
@RequiredArgsConstructor
@Slf4j
public class ScannerEffectController {
private final CustomPDFDocumentFactory pdfDocumentFactory;
private static final int MAX_IMAGE_WIDTH = 8192;
private static final int MAX_IMAGE_HEIGHT = 8192;
private static final long MAX_IMAGE_PIXELS = 16_777_216; // 4096x4096
private static final long RENDER_CLONE_IN_MEMORY_THRESHOLD = 16 * 1024 * 1024; // 16 MB
private static final ThreadLocal<BufferCache> BUFFER_CACHE =
ThreadLocal.withInitial(BufferCache::new);
private static int calculateSafeResolution(
float pageWidthPts, float pageHeightPts, int resolution) {
int projectedWidth = (int) Math.ceil(pageWidthPts * resolution / 72.0);
int projectedHeight = (int) Math.ceil(pageHeightPts * resolution / 72.0);
long projectedPixels = (long) projectedWidth * projectedHeight;
if (projectedWidth <= MAX_IMAGE_WIDTH
&& projectedHeight <= MAX_IMAGE_HEIGHT
&& projectedPixels <= MAX_IMAGE_PIXELS) {
return resolution;
}
double widthScale = (double) MAX_IMAGE_WIDTH / projectedWidth;
double heightScale = (double) MAX_IMAGE_HEIGHT / projectedHeight;
double pixelScale = Math.sqrt((double) MAX_IMAGE_PIXELS / projectedPixels);
double minScale = Math.min(Math.min(widthScale, heightScale), pixelScale);
return (int) Math.max(72, resolution * minScale);
}
private static int determineRenderResolution(ScannerEffectRequest request) {
return request.getResolution();
}
private static BufferedImage renderPageSafely(PDFRenderer renderer, int pageIndex, int dpi)
throws IOException {
return ExceptionUtils.handleOomRendering(
pageIndex + 1, dpi, () -> renderer.renderImageWithDPI(pageIndex, dpi));
}
private static BufferedImage convertColorspace(
BufferedImage image, ScannerEffectRequest.Colorspace colorspace) {
BufferedImage result =
new BufferedImage(image.getWidth(), image.getHeight(), BufferedImage.TYPE_INT_RGB);
Graphics2D g = result.createGraphics();
g.drawImage(image, 0, 0, null);
g.dispose();
if (colorspace == ScannerEffectRequest.Colorspace.grayscale) {
convertToGrayscale(result);
}
return result;
}
private static void convertToGrayscale(BufferedImage image) {
int[] pixels = ((DataBufferInt) image.getRaster().getDataBuffer()).getData();
for (int i = 0; i < pixels.length; i++) {
int rgb = pixels[i];
int r = (rgb >> 16) & 0xFF;
int g = (rgb >> 8) & 0xFF;
int b = rgb & 0xFF;
int gray = (r + g + b) / 3;
pixels[i] = (gray << 16) | (gray << 8) | gray;
}
}
private static GradientConfig createRandomGradient() {
boolean vertical = ThreadLocalRandom.current().nextBoolean();
float startGrey = 0.6f + 0.3f * ThreadLocalRandom.current().nextFloat();
float endGrey = 0.6f + 0.3f * ThreadLocalRandom.current().nextFloat();
Color startColor =
new Color(
Math.round(startGrey * 255),
Math.round(startGrey * 255),
Math.round(startGrey * 255));
Color endColor =
new Color(
Math.round(endGrey * 255),
Math.round(endGrey * 255),
Math.round(endGrey * 255));
return new GradientConfig(vertical, startColor, endColor);
}
private static BufferedImage addBorderWithGradient(
BufferedImage image, int borderPx, GradientConfig gradient) {
int width = image.getWidth() + 2 * borderPx;
int height = image.getHeight() + 2 * borderPx;
int[] gradientLUT = createGradientLUT(width, height, gradient);
BufferedImage result = new BufferedImage(width, height, image.getType());
int[] pixels = ((DataBufferInt) result.getRaster().getDataBuffer()).getData();
fillWithGradient(pixels, width, height, gradientLUT, gradient.vertical);
Graphics2D g = result.createGraphics();
g.drawImage(image, borderPx, borderPx, null);
g.dispose();
return result;
}
private static int[] createGradientLUT(int width, int height, GradientConfig gradient) {
int size = gradient.vertical ? height : width;
int[] lut = new int[size];
int rStart = gradient.startColor.getRed();
int gStart = gradient.startColor.getGreen();
int bStart = gradient.startColor.getBlue();
int rDiff = gradient.endColor.getRed() - rStart;
int gDiff = gradient.endColor.getGreen() - gStart;
int bDiff = gradient.endColor.getBlue() - bStart;
for (int i = 0; i < size; i++) {
float frac = (float) i / Math.max(1, size - 1);
int r = Math.round(rStart + rDiff * frac);
int g = Math.round(gStart + gDiff * frac);
int b = Math.round(bStart + bDiff * frac);
lut[i] = (r << 16) | (g << 8) | b;
}
return lut;
}
private static void fillWithGradient(
int[] pixels, int width, int height, int[] gradientLUT, boolean vertical) {
if (vertical) {
for (int y = 0; y < height; y++) {
Arrays.fill(pixels, y * width, (y + 1) * width, gradientLUT[y]);
}
} else {
for (int y = 0; y < height; y++) {
System.arraycopy(gradientLUT, 0, pixels, y * width, width);
}
}
}
private static double calculateRotation(int baseRotation, int rotateVariance) {
if (baseRotation == 0 && rotateVariance == 0) {
return 0;
}
return baseRotation + (ThreadLocalRandom.current().nextDouble() * 2 - 1) * rotateVariance;
}
private static BufferedImage rotateImage(
BufferedImage image, double rotation, GradientConfig gradient) {
if (rotation == 0) {
return image;
}
int w = image.getWidth();
int h = image.getHeight();
double radians = Math.toRadians(rotation);
double sin = Math.abs(Math.sin(radians));
double cos = Math.abs(Math.cos(radians));
int rotW = (int) Math.floor(w * cos + h * sin);
int rotH = (int) Math.floor(h * cos + w * sin);
BufferedImage background = createRotatedBackground(rotW, rotH, image.getType(), gradient);
BufferedImage result = new BufferedImage(rotW, rotH, image.getType());
Graphics2D g = result.createGraphics();
g.drawImage(background, 0, 0, null);
AffineTransform transform = new AffineTransform();
transform.translate((rotW - w) / 2.0, (rotH - h) / 2.0);
transform.rotate(radians, w / 2.0, h / 2.0);
g.setRenderingHint(
RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BICUBIC);
g.setRenderingHint(RenderingHints.KEY_RENDERING, RenderingHints.VALUE_RENDER_QUALITY);
g.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
g.drawImage(image, transform, null);
g.dispose();
return result;
}
private static BufferedImage createRotatedBackground(
int width, int height, int imageType, GradientConfig gradient) {
BufferedImage background = new BufferedImage(width, height, imageType);
int[] pixels = ((DataBufferInt) background.getRaster().getDataBuffer()).getData();
int[] gradientLUT = createGradientLUT(width, height, gradient);
fillWithGradient(pixels, width, height, gradientLUT, gradient.vertical);
return background;
}
private static BufferedImage applyAllEffectsSinglePass(
BufferedImage image,
float brightness,
float contrast,
boolean yellowish,
double noise) {
int width = image.getWidth();
int height = image.getHeight();
BufferedImage output = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB);
int[] srcPixels = ((DataBufferInt) image.getRaster().getDataBuffer()).getData();
int[] dstPixels = ((DataBufferInt) output.getRaster().getDataBuffer()).getData();
double scaledStrength = noise * Math.min(width, height) / 1000.0;
boolean applyNoise = scaledStrength > 0;
float contrastOffset = 128.0f - 128.0f * contrast;
float inv765 = 1.0f / 765.0f;
for (int i = 0; i < srcPixels.length; i++) {
int rgb = srcPixels[i];
int r = (rgb >> 16) & 0xFF;
int g = (rgb >> 8) & 0xFF;
int b = rgb & 0xFF;
r = (int) ((r * contrast + contrastOffset) * brightness);
g = (int) ((g * contrast + contrastOffset) * brightness);
b = (int) ((b * contrast + contrastOffset) * brightness);
r = Math.min(255, Math.max(0, r));
g = Math.min(255, Math.max(0, g));
b = Math.min(255, Math.max(0, b));
if (yellowish) {
float bright = (r + g + b) * inv765;
r = Math.min(255, (int) (r + (255 - r) * 0.18f * bright));
g = Math.min(255, (int) (g + (255 - g) * 0.12f * bright));
b = Math.max(0, (int) (b * (1.0f - 0.25f * bright)));
}
if (applyNoise) {
r =
Math.min(
255,
Math.max(
0,
r
+ (int)
(ThreadLocalRandom.current().nextGaussian()
* scaledStrength)));
g =
Math.min(
255,
Math.max(
0,
g
+ (int)
(ThreadLocalRandom.current().nextGaussian()
* scaledStrength)));
b =
Math.min(
255,
Math.max(
0,
b
+ (int)
(ThreadLocalRandom.current().nextGaussian()
* scaledStrength)));
}
dstPixels[i] = (r << 16) | (g << 8) | b;
}
return output;
}
private static BufferedImage softenEdges(
BufferedImage image,
int featherRadius,
Color startColor,
Color endColor,
boolean vertical) {
int width = image.getWidth();
int height = image.getHeight();
BufferedImage output = new BufferedImage(width, height, image.getType());
int[] srcPixels = ((DataBufferInt) image.getRaster().getDataBuffer()).getData();
int[] dstPixels = ((DataBufferInt) output.getRaster().getDataBuffer()).getData();
int[] gradientLUT =
createGradientLUT(
width, height, new GradientConfig(vertical, startColor, endColor));
for (int y = 0; y < height; y++) {
for (int x = 0; x < width; x++) {
int dx = Math.min(x, width - 1 - x);
int dy = Math.min(y, height - 1 - y);
int d = Math.min(dx, dy);
int bgVal = gradientLUT[vertical ? y : x];
int fgVal = srcPixels[y * width + x];
float alpha = d < featherRadius ? (float) d / featherRadius : 1.0f;
dstPixels[y * width + x] = blendColors(fgVal, bgVal, alpha);
}
}
return output;
}
private static int blendColors(int fg, int bg, float alpha) {
int r = Math.round(((fg >> 16) & 0xFF) * alpha + ((bg >> 16) & 0xFF) * (1 - alpha));
int g = Math.round(((fg >> 8) & 0xFF) * alpha + ((bg >> 8) & 0xFF) * (1 - alpha));
int b = Math.round((fg & 0xFF) * alpha + (bg & 0xFF) * (1 - alpha));
return (r << 16) | (g << 8) | b;
}
private static BufferedImage applyGaussianBlur(BufferedImage image, double sigma) {
if (sigma <= 0) {
return image;
}
double scaledSigma = sigma * Math.min(image.getWidth(), image.getHeight()) / 1000.0;
int radius = Math.max(1, (int) Math.ceil(scaledSigma * 2));
int width = image.getWidth();
int height = image.getHeight();
int pixelCount = width * height;
int[] srcPixels = ((DataBufferInt) image.getRaster().getDataBuffer()).getData();
BufferCache cache = BUFFER_CACHE.get();
int[] tempPixels = cache.getTempBuffer(pixelCount);
int[] dstPixels = cache.getDstBuffer(pixelCount);
System.arraycopy(srcPixels, 0, tempPixels, 0, pixelCount);
for (int pass = 0; pass < 2; pass++) {
boxBlurHorizontal(tempPixels, dstPixels, width, height, radius);
boxBlurVertical(dstPixels, tempPixels, width, height, radius);
}
BufferedImage result = new BufferedImage(width, height, image.getType());
int[] resultPixels = ((DataBufferInt) result.getRaster().getDataBuffer()).getData();
System.arraycopy(tempPixels, 0, resultPixels, 0, pixelCount);
return result;
}
private static void boxBlurHorizontal(int[] src, int[] dst, int width, int height, int radius) {
int diameter = radius * 2 + 1;
float invDiameter = 1.0f / diameter;
for (int y = 0; y < height; y++) {
int rowStart = y * width;
int sumR = 0, sumG = 0, sumB = 0;
for (int x = -radius; x <= radius; x++) {
int px = Math.max(0, Math.min(width - 1, x));
int rgb = src[rowStart + px];
sumR += (rgb >> 16) & 0xFF;
sumG += (rgb >> 8) & 0xFF;
sumB += rgb & 0xFF;
}
for (int x = 0; x < width; x++) {
int r = (int) (sumR * invDiameter);
int g = (int) (sumG * invDiameter);
int b = (int) (sumB * invDiameter);
dst[rowStart + x] = (r << 16) | (g << 8) | b;
int leftX = Math.max(0, x - radius);
int rightX = Math.min(width - 1, x + radius + 1);
int leftRgb = src[rowStart + leftX];
int rightRgb = src[rowStart + rightX];
sumR += ((rightRgb >> 16) & 0xFF) - ((leftRgb >> 16) & 0xFF);
sumG += ((rightRgb >> 8) & 0xFF) - ((leftRgb >> 8) & 0xFF);
sumB += (rightRgb & 0xFF) - (leftRgb & 0xFF);
}
}
}
private static void boxBlurVertical(int[] src, int[] dst, int width, int height, int radius) {
int diameter = radius * 2 + 1;
float invDiameter = 1.0f / diameter;
for (int x = 0; x < width; x++) {
int sumR = 0, sumG = 0, sumB = 0;
for (int y = -radius; y <= radius; y++) {
int py = Math.max(0, Math.min(height - 1, y));
int rgb = src[py * width + x];
sumR += (rgb >> 16) & 0xFF;
sumG += (rgb >> 8) & 0xFF;
sumB += rgb & 0xFF;
}
for (int y = 0; y < height; y++) {
int r = (int) (sumR * invDiameter);
int g = (int) (sumG * invDiameter);
int b = (int) (sumB * invDiameter);
dst[y * width + x] = (r << 16) | (g << 8) | b;
int topY = Math.max(0, y - radius);
int bottomY = Math.min(height - 1, y + radius + 1);
int topRgb = src[topY * width + x];
int bottomRgb = src[bottomY * width + x];
sumR += ((bottomRgb >> 16) & 0xFF) - ((topRgb >> 16) & 0xFF);
sumG += ((bottomRgb >> 8) & 0xFF) - ((topRgb >> 8) & 0xFF);
sumB += (bottomRgb & 0xFF) - (topRgb & 0xFF);
}
}
}
private static void writeProcessedPagesToDocument(
List<ProcessedPage> pages, PDDocument document) throws IOException {
for (ProcessedPage page : pages) {
PDPage newPage = new PDPage(new PDRectangle(page.origW, page.origH));
document.addPage(newPage);
try (PDPageContentStream contentStream = new PDPageContentStream(document, newPage)) {
PDImageXObject pdImage = LosslessFactory.createFromImage(document, page.image);
contentStream.drawImage(
pdImage, page.offsetX, page.offsetY, page.drawW, page.drawH);
}
page.image.flush();
}
}
private static ProcessedPage processPage(
int pageIndex,
RenderingResources renderingResources,
int baseRotation,
int rotateVariance,
int borderPx,
float brightness,
float contrast,
float blur,
float noise,
boolean yellowish,
int renderResolution,
ScannerEffectRequest.Colorspace colorspace)
throws ExceptionUtils.OutOfMemoryDpiException {
try {
PDRectangle pageSize = renderingResources.getPageMediaBox(pageIndex);
float pageWidthPts = pageSize.getWidth();
float pageHeightPts = pageSize.getHeight();
int safeResolution =
calculateSafeResolution(pageWidthPts, pageHeightPts, renderResolution);
BufferedImage image = renderingResources.renderPage(pageIndex, safeResolution);
BufferedImage processed = convertColorspace(image, colorspace);
image.flush();
GradientConfig gradient = createRandomGradient();
BufferedImage composed = addBorderWithGradient(processed, borderPx, gradient);
processed.flush();
double rotation = calculateRotation(baseRotation, rotateVariance);
BufferedImage rotated = rotateImage(composed, rotation, gradient);
if (rotated != composed) {
composed.flush();
}
// Reuse the pageSize we already retrieved to avoid redundant document access
float origW = pageSize.getWidth();
float origH = pageSize.getHeight();
int rotW = rotated.getWidth();
int rotH = rotated.getHeight();
float scale = Math.max(origW / rotW, origH / rotH);
float drawW = rotW * scale;
float drawH = rotH * scale;
float offsetX = (origW - drawW) / 2f;
float offsetY = (origH - drawH) / 2f;
int featherRadius = Math.max(10, Math.round(Math.min(rotW, rotH) * 0.02f));
BufferedImage softened =
softenEdges(
rotated,
featherRadius,
gradient.startColor,
gradient.endColor,
gradient.vertical);
BufferedImage blurred = applyGaussianBlur(softened, blur);
BufferedImage adjusted =
applyAllEffectsSinglePass(blurred, brightness, contrast, yellowish, noise);
softened.flush();
blurred.flush();
if (rotated != composed) {
rotated.flush();
}
return new ProcessedPage(adjusted, origW, origH, offsetX, offsetY, drawW, drawH);
} catch (IOException e) {
throw ExceptionUtils.wrapException(
e, "scanner effect processing for page " + (pageIndex + 1));
} catch (OutOfMemoryError | NegativeArraySizeException e) {
throw ExceptionUtils.createOutOfMemoryDpiException(pageIndex + 1, renderResolution, e);
}
}
@PostMapping(value = "/scanner-effect", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@Operation(
summary = "Apply scanner effect to PDF",
description =
"Applies various effects to simulate a scanned document, including rotation, noise, and edge softening. Input:PDF Output:PDF Type:SISO")
public ResponseEntity<byte[]> scannerEffect(@Valid @ModelAttribute ScannerEffectRequest request)
throws IOException {
MultipartFile file = request.getFileInput();
List<Path> tempFiles = new ArrayList<>();
Path processingInput;
try {
Path originalInput = Files.createTempFile("scanner_effect_input_", ".pdf");
tempFiles.add(originalInput);
file.transferTo(originalInput.toFile());
processingInput = originalInput;
if (!request.isAdvancedEnabled()) {
switch (request.getQuality()) {
case high -> request.applyHighQualityPreset();
case medium -> request.applyMediumQualityPreset();
case low -> request.applyLowQualityPreset();
}
}
int baseRotation = request.getRotationValue() + request.getRotate();
int rotateVariance = request.getRotateVariance();
int borderPx = request.getBorder();
float brightness = request.getBrightness();
float contrast = request.getContrast();
float blur = request.getBlur();
float noise = request.getNoise();
boolean yellowish = request.isYellowish();
int resolution = request.getResolution();
int renderResolution = determineRenderResolution(request);
ScannerEffectRequest.Colorspace colorspace = request.getColorspace();
long inputFileSize = Files.size(processingInput);
byte[] renderingPdfBytes = null;
if (inputFileSize <= RENDER_CLONE_IN_MEMORY_THRESHOLD) {
renderingPdfBytes = Files.readAllBytes(processingInput);
}
final byte[] sharedPdfBytes = renderingPdfBytes;
final Path sharedPdfPath = sharedPdfBytes == null ? processingInput : null;
int maxSafeDpi = 500; // Default maximum safe DPI
ApplicationProperties properties =
ApplicationContextProvider.getBean(ApplicationProperties.class);
if (properties != null && properties.getSystem() != null) {
maxSafeDpi = properties.getSystem().getMaxDPI();
}
if (resolution > maxSafeDpi) {
throw ExceptionUtils.createIllegalArgumentException(
"error.dpiExceedsLimit",
"DPI value {0} exceeds maximum safe limit of {1}. High DPI values can cause"
+ " memory issues and crashes. Please use a lower DPI value.",
resolution,
maxSafeDpi);
}
try (PDDocument document =
sharedPdfBytes != null
? pdfDocumentFactory.load(sharedPdfBytes)
: pdfDocumentFactory.load(processingInput);
PDDocument outputDocument = new PDDocument();
ByteArrayOutputStream outputStream = new ByteArrayOutputStream()) {
int totalPages = document.getNumberOfPages();
if (totalPages == 0) {
throw ExceptionUtils.createIllegalArgumentException(
"error.emptyDocument",
"The provided PDF contains no pages to process.");
}
int configuredParallelism =
Math.min(64, Math.max(2, Runtime.getRuntime().availableProcessors() * 2));
int desiredParallelism = Math.max(1, Math.min(totalPages, configuredParallelism));
try (ManagedForkJoinPool managedPool =
new ManagedForkJoinPool(desiredParallelism)) {
ForkJoinPool customPool = managedPool.getPool();
Queue<RenderingResources> renderingResourcesToClose =
new ConcurrentLinkedQueue<>();
ThreadLocal<RenderingResources> renderingResources =
ThreadLocal.withInitial(
() -> {
try {
RenderingResources resources =
sharedPdfBytes != null
? RenderingResources.fromBytes(
pdfDocumentFactory,
sharedPdfBytes)
: RenderingResources.fromPath(
pdfDocumentFactory,
sharedPdfPath);
renderingResourcesToClose.add(resources);
return resources;
} catch (IOException e) {
throw new UncheckedIOException(
"Failed to prepare rendering resources", e);
}
});
List<ProcessedPage> processedPages;
try {
List<Callable<ProcessedPage>> tasks =
IntStream.range(0, totalPages)
.mapToObj(
i ->
(Callable<ProcessedPage>)
() ->
processPage(
i,
renderingResources
.get(),
baseRotation,
rotateVariance,
borderPx,
brightness,
contrast,
blur,
noise,
yellowish,
renderResolution,
colorspace))
.toList();
List<Future<ProcessedPage>> futures = customPool.invokeAll(tasks);
processedPages = new ArrayList<>(totalPages);
for (Future<ProcessedPage> future : futures) {
processedPages.add(future.get());
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw ExceptionUtils.createProcessingInterruptedException(
"scanner effect parallel page processing", e);
} catch (ExecutionException e) {
throw ExceptionUtils.createFileProcessingException(
"scanner effect parallel page processing",
new IOException("Processing failed", e.getCause()));
} finally {
renderingResources.remove();
for (RenderingResources resources : renderingResourcesToClose) {
resources.closeQuietly();
}
}
writeProcessedPagesToDocument(processedPages, outputDocument);
outputDocument.save(outputStream);
return WebResponseUtils.bytesToWebResponse(
outputStream.toByteArray(),
GeneralUtils.generateFilename(
file.getOriginalFilename(), "_scanner_effect.pdf"));
}
}
} finally {
for (Path tempFile : tempFiles) {
try {
Files.deleteIfExists(tempFile);
} catch (IOException e) {
// Ignore cleanup failures
}
}
}
}
private static final class RenderingResources implements AutoCloseable {
private final PDDocument document;
private final PDFRenderer renderer;
private RenderingResources(PDDocument document) {
this.document = document;
this.renderer = new PDFRenderer(document);
this.renderer.setSubsamplingAllowed(true);
this.renderer.setImageDownscalingOptimizationThreshold(0.5f);
}
static RenderingResources fromBytes(CustomPDFDocumentFactory factory, byte[] pdfBytes)
throws IOException {
return new RenderingResources(factory.load(pdfBytes, true));
}
static RenderingResources fromPath(CustomPDFDocumentFactory factory, Path pdfPath)
throws IOException {
return new RenderingResources(factory.load(pdfPath, true));
}
PDRectangle getPageMediaBox(int pageIndex) {
return document.getPage(pageIndex).getMediaBox();
}
BufferedImage renderPage(int pageIndex, int dpi) throws IOException {
return renderPageSafely(renderer, pageIndex, dpi);
}
@Override
public void close() throws IOException {
document.close();
}
void closeQuietly() {
try {
close();
} catch (IOException e) {
// Ignore close failure
}
}
}
private static class BufferCache {
int[] tempPixels = new int[0];
int[] dstPixels = new int[0];
int[] getTempBuffer(int requiredSize) {
if (tempPixels.length < requiredSize) {
tempPixels = new int[requiredSize];
}
return tempPixels;
}
int[] getDstBuffer(int requiredSize) {
if (dstPixels.length < requiredSize) {
dstPixels = new int[requiredSize];
}
return dstPixels;
}
}
private static class ManagedForkJoinPool implements AutoCloseable {
private final ForkJoinPool pool;
ManagedForkJoinPool(int parallelism) {
this.pool = new ForkJoinPool(parallelism);
}
ForkJoinPool getPool() {
return pool;
}
@Override
public void close() {
pool.shutdown();
try {
if (!pool.awaitTermination(60, TimeUnit.SECONDS)) {
pool.shutdownNow();
if (!pool.awaitTermination(60, TimeUnit.SECONDS)) {
log.warn("ForkJoinPool did not terminate within timeout");
}
}
} catch (InterruptedException e) {
pool.shutdownNow();
Thread.currentThread().interrupt();
}
}
}
private record GradientConfig(boolean vertical, Color startColor, Color endColor) {}
private static class ProcessedPage {
final BufferedImage image;
final float origW, origH, offsetX, offsetY, drawW, drawH;
ProcessedPage(
BufferedImage image,
float origW,
float origH,
float offsetX,
float offsetY,
float drawW,
float drawH) {
this.image = image;
this.origW = origW;
this.origH = origH;
this.offsetX = offsetX;
this.offsetY = offsetY;
this.drawW = drawW;
this.drawH = drawH;
}
}
}

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