mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2026-09-03 13:20:08 +03:00
Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7f096297af | ||
|
|
624e04a783 | ||
|
|
bb9f1d4f8b | ||
|
|
0d967aa5ea | ||
|
|
f19ff6691d | ||
|
|
98b4763c96 | ||
|
|
2c9537f786 |
@@ -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": []
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
# Formatting
|
||||
5f771b785130154ed47952635b7acef371ffe0ec
|
||||
7fa5e130d99227c2202ebddfdd91348176ec0c7b
|
||||
14d4fbb2a36195eedb034785e5a5ff6a47f268c6
|
||||
ee8030c1c4148062cde15c49c67d04ef03930c55
|
||||
fcd41924f5f261febfa9d9a92994671f3ebc97d6
|
||||
|
||||
# Normalize files
|
||||
55d4fda01b2f39f5b7d7b4fda5214bd7ff0fd5dd
|
||||
|
||||
+7
-7
@@ -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
@@ -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
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
build: &build
|
||||
- build.gradle
|
||||
- app/(common|core|proprietary)/build.gradle
|
||||
|
||||
app: &app
|
||||
- app/(common|core|proprietary)/src/main/java/**
|
||||
|
||||
openapi: &openapi
|
||||
- build.gradle
|
||||
- app/(common|core|proprietary)/build.gradle
|
||||
- app/(common|core|proprietary)/src/main/java/**
|
||||
|
||||
project: &project
|
||||
- app/(common|core|proprietary)/src/(main|test)/java/**
|
||||
- app/(common|core|proprietary)/build.gradle
|
||||
- 'app/(common|core|proprietary)/src/(main|test)/resources/**/!(messages_*.properties|*.md)*'
|
||||
- exampleYmlFiles/**
|
||||
- gradle/**
|
||||
- libs/**
|
||||
- testing/**
|
||||
- build.gradle
|
||||
- Dockerfile
|
||||
- Dockerfile.fat
|
||||
- Dockerfile.ultra-lite
|
||||
- gradle.properties
|
||||
- gradlew
|
||||
- gradlew.bat
|
||||
- launch4jConfig.xml
|
||||
- settings.gradle
|
||||
- frontend/**
|
||||
- docker/**
|
||||
- testing/**
|
||||
|
||||
frontend: &frontend
|
||||
- frontend/**
|
||||
- .github/workflows/testdriver.yml
|
||||
- testing/**
|
||||
- docker/**
|
||||
@@ -7,7 +7,6 @@
|
||||
"sbplat",
|
||||
"reecebrowne",
|
||||
"DarioGii",
|
||||
"ConnorYoh",
|
||||
"EthanHealy01"
|
||||
"ConnorYoh"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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/**/*'
|
||||
@@ -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,9 +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"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
pip
|
||||
setuptools
|
||||
WeasyPrint
|
||||
pdf2image
|
||||
pillow
|
||||
unoserver
|
||||
opencv-python-headless
|
||||
pre-commit
|
||||
@@ -1,638 +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'
|
||||
#
|
||||
brotli==1.1.0 \
|
||||
--hash=sha256:03d20af184290887bdea3f0f78c4f737d126c74dc2f3ccadf07e54ceca3bf208 \
|
||||
--hash=sha256:0541e747cce78e24ea12d69176f6a7ddb690e62c425e01d31cc065e69ce55b48 \
|
||||
--hash=sha256:069a121ac97412d1fe506da790b3e69f52254b9df4eb665cd42460c837193354 \
|
||||
--hash=sha256:0737ddb3068957cf1b054899b0883830bb1fec522ec76b1098f9b6e0f02d9419 \
|
||||
--hash=sha256:0b63b949ff929fbc2d6d3ce0e924c9b93c9785d877a21a1b678877ffbbc4423a \
|
||||
--hash=sha256:0c6244521dda65ea562d5a69b9a26120769b7a9fb3db2fe9545935ed6735b128 \
|
||||
--hash=sha256:11d00ed0a83fa22d29bc6b64ef636c4552ebafcef57154b4ddd132f5638fbd1c \
|
||||
--hash=sha256:141bd4d93984070e097521ed07e2575b46f817d08f9fa42b16b9b5f27b5ac088 \
|
||||
--hash=sha256:19c116e796420b0cee3da1ccec3b764ed2952ccfcc298b55a10e5610ad7885f9 \
|
||||
--hash=sha256:1ab4fbee0b2d9098c74f3057b2bc055a8bd92ccf02f65944a241b4349229185a \
|
||||
--hash=sha256:1ae56aca0402a0f9a3431cddda62ad71666ca9d4dc3a10a142b9dce2e3c0cda3 \
|
||||
--hash=sha256:1b2c248cd517c222d89e74669a4adfa5577e06ab68771a529060cf5a156e9757 \
|
||||
--hash=sha256:1e9a65b5736232e7a7f91ff3d02277f11d339bf34099a56cdab6a8b3410a02b2 \
|
||||
--hash=sha256:224e57f6eac61cc449f498cc5f0e1725ba2071a3d4f48d5d9dffba42db196438 \
|
||||
--hash=sha256:22fc2a8549ffe699bfba2256ab2ed0421a7b8fadff114a3d201794e45a9ff578 \
|
||||
--hash=sha256:23032ae55523cc7bccb4f6a0bf368cd25ad9bcdcc1990b64a647e7bbcce9cb5b \
|
||||
--hash=sha256:2333e30a5e00fe0fe55903c8832e08ee9c3b1382aacf4db26664a16528d51b4b \
|
||||
--hash=sha256:2954c1c23f81c2eaf0b0717d9380bd348578a94161a65b3a2afc62c86467dd68 \
|
||||
--hash=sha256:2a24c50840d89ded6c9a8fdc7b6ed3692ed4e86f1c4a4a938e1e92def92933e0 \
|
||||
--hash=sha256:2de9d02f5bda03d27ede52e8cfe7b865b066fa49258cbab568720aa5be80a47d \
|
||||
--hash=sha256:2feb1d960f760a575dbc5ab3b1c00504b24caaf6986e2dc2b01c09c87866a943 \
|
||||
--hash=sha256:30924eb4c57903d5a7526b08ef4a584acc22ab1ffa085faceb521521d2de32dd \
|
||||
--hash=sha256:316cc9b17edf613ac76b1f1f305d2a748f1b976b033b049a6ecdfd5612c70409 \
|
||||
--hash=sha256:32d95b80260d79926f5fab3c41701dbb818fde1c9da590e77e571eefd14abe28 \
|
||||
--hash=sha256:38025d9f30cf4634f8309c6874ef871b841eb3c347e90b0851f63d1ded5212da \
|
||||
--hash=sha256:39da8adedf6942d76dc3e46653e52df937a3c4d6d18fdc94a7c29d263b1f5b50 \
|
||||
--hash=sha256:3c0ef38c7a7014ffac184db9e04debe495d317cc9c6fb10071f7fefd93100a4f \
|
||||
--hash=sha256:3d7954194c36e304e1523f55d7042c59dc53ec20dd4e9ea9d151f1b62b4415c0 \
|
||||
--hash=sha256:3ee8a80d67a4334482d9712b8e83ca6b1d9bc7e351931252ebef5d8f7335a547 \
|
||||
--hash=sha256:4093c631e96fdd49e0377a9c167bfd75b6d0bad2ace734c6eb20b348bc3ea180 \
|
||||
--hash=sha256:43395e90523f9c23a3d5bdf004733246fba087f2948f87ab28015f12359ca6a0 \
|
||||
--hash=sha256:43ce1b9935bfa1ede40028054d7f48b5469cd02733a365eec8a329ffd342915d \
|
||||
--hash=sha256:4410f84b33374409552ac9b6903507cdb31cd30d2501fc5ca13d18f73548444a \
|
||||
--hash=sha256:494994f807ba0b92092a163a0a283961369a65f6cbe01e8891132b7a320e61eb \
|
||||
--hash=sha256:4d4a848d1837973bf0f4b5e54e3bec977d99be36a7895c61abb659301b02c112 \
|
||||
--hash=sha256:4ed11165dd45ce798d99a136808a794a748d5dc38511303239d4e2363c0695dc \
|
||||
--hash=sha256:4f3607b129417e111e30637af1b56f24f7a49e64763253bbc275c75fa887d4b2 \
|
||||
--hash=sha256:510b5b1bfbe20e1a7b3baf5fed9e9451873559a976c1a78eebaa3b86c57b4265 \
|
||||
--hash=sha256:524f35912131cc2cabb00edfd8d573b07f2d9f21fa824bd3fb19725a9cf06327 \
|
||||
--hash=sha256:587ca6d3cef6e4e868102672d3bd9dc9698c309ba56d41c2b9c85bbb903cdb95 \
|
||||
--hash=sha256:58d4b711689366d4a03ac7957ab8c28890415e267f9b6589969e74b6e42225ec \
|
||||
--hash=sha256:5b3cc074004d968722f51e550b41a27be656ec48f8afaeeb45ebf65b561481dd \
|
||||
--hash=sha256:5dab0844f2cf82be357a0eb11a9087f70c5430b2c241493fc122bb6f2bb0917c \
|
||||
--hash=sha256:5e55da2c8724191e5b557f8e18943b1b4839b8efc3ef60d65985bcf6f587dd38 \
|
||||
--hash=sha256:5eeb539606f18a0b232d4ba45adccde4125592f3f636a6182b4a8a436548b914 \
|
||||
--hash=sha256:5f4d5ea15c9382135076d2fb28dde923352fe02951e66935a9efaac8f10e81b0 \
|
||||
--hash=sha256:5fb2ce4b8045c78ebbc7b8f3c15062e435d47e7393cc57c25115cfd49883747a \
|
||||
--hash=sha256:6172447e1b368dcbc458925e5ddaf9113477b0ed542df258d84fa28fc45ceea7 \
|
||||
--hash=sha256:6967ced6730aed543b8673008b5a391c3b1076d834ca438bbd70635c73775368 \
|
||||
--hash=sha256:6974f52a02321b36847cd19d1b8e381bf39939c21efd6ee2fc13a28b0d99348c \
|
||||
--hash=sha256:6c3020404e0b5eefd7c9485ccf8393cfb75ec38ce75586e046573c9dc29967a0 \
|
||||
--hash=sha256:6c6e0c425f22c1c719c42670d561ad682f7bfeeef918edea971a79ac5252437f \
|
||||
--hash=sha256:70051525001750221daa10907c77830bc889cb6d865cc0b813d9db7fefc21451 \
|
||||
--hash=sha256:7905193081db9bfa73b1219140b3d315831cbff0d8941f22da695832f0dd188f \
|
||||
--hash=sha256:7bc37c4d6b87fb1017ea28c9508b36bbcb0c3d18b4260fcdf08b200c74a6aee8 \
|
||||
--hash=sha256:7c4855522edb2e6ae7fdb58e07c3ba9111e7621a8956f481c68d5d979c93032e \
|
||||
--hash=sha256:7e4c4629ddad63006efa0ef968c8e4751c5868ff0b1c5c40f76524e894c50248 \
|
||||
--hash=sha256:7eedaa5d036d9336c95915035fb57422054014ebdeb6f3b42eac809928e40d0c \
|
||||
--hash=sha256:7f4bf76817c14aa98cc6697ac02f3972cb8c3da93e9ef16b9c66573a68014f91 \
|
||||
--hash=sha256:81de08ac11bcb85841e440c13611c00b67d3bf82698314928d0b676362546724 \
|
||||
--hash=sha256:832436e59afb93e1836081a20f324cb185836c617659b07b129141a8426973c7 \
|
||||
--hash=sha256:861bf317735688269936f755fa136a99d1ed526883859f86e41a5d43c61d8966 \
|
||||
--hash=sha256:87a3044c3a35055527ac75e419dfa9f4f3667a1e887ee80360589eb8c90aabb9 \
|
||||
--hash=sha256:890b5a14ce214389b2cc36ce82f3093f96f4cc730c1cffdbefff77a7c71f2a97 \
|
||||
--hash=sha256:89f4988c7203739d48c6f806f1e87a1d96e0806d44f0fba61dba81392c9e474d \
|
||||
--hash=sha256:8bf32b98b75c13ec7cf774164172683d6e7891088f6316e54425fde1efc276d5 \
|
||||
--hash=sha256:8dadd1314583ec0bf2d1379f7008ad627cd6336625d6679cf2f8e67081b83acf \
|
||||
--hash=sha256:901032ff242d479a0efa956d853d16875d42157f98951c0230f69e69f9c09bac \
|
||||
--hash=sha256:9011560a466d2eb3f5a6e4929cf4a09be405c64154e12df0dd72713f6500e32b \
|
||||
--hash=sha256:906bc3a79de8c4ae5b86d3d75a8b77e44404b0f4261714306e3ad248d8ab0951 \
|
||||
--hash=sha256:919e32f147ae93a09fe064d77d5ebf4e35502a8df75c29fb05788528e330fe74 \
|
||||
--hash=sha256:91d7cc2a76b5567591d12c01f019dd7afce6ba8cba6571187e21e2fc418ae648 \
|
||||
--hash=sha256:929811df5462e182b13920da56c6e0284af407d1de637d8e536c5cd00a7daf60 \
|
||||
--hash=sha256:949f3b7c29912693cee0afcf09acd6ebc04c57af949d9bf77d6101ebb61e388c \
|
||||
--hash=sha256:a090ca607cbb6a34b0391776f0cb48062081f5f60ddcce5d11838e67a01928d1 \
|
||||
--hash=sha256:a1fd8a29719ccce974d523580987b7f8229aeace506952fa9ce1d53a033873c8 \
|
||||
--hash=sha256:a37b8f0391212d29b3a91a799c8e4a2855e0576911cdfb2515487e30e322253d \
|
||||
--hash=sha256:a3daabb76a78f829cafc365531c972016e4aa8d5b4bf60660ad8ecee19df7ccc \
|
||||
--hash=sha256:a469274ad18dc0e4d316eefa616d1d0c2ff9da369af19fa6f3daa4f09671fd61 \
|
||||
--hash=sha256:a599669fd7c47233438a56936988a2478685e74854088ef5293802123b5b2460 \
|
||||
--hash=sha256:a743e5a28af5f70f9c080380a5f908d4d21d40e8f0e0c8901604d15cfa9ba751 \
|
||||
--hash=sha256:a77def80806c421b4b0af06f45d65a136e7ac0bdca3c09d9e2ea4e515367c7e9 \
|
||||
--hash=sha256:a7e53012d2853a07a4a79c00643832161a910674a893d296c9f1259859a289d2 \
|
||||
--hash=sha256:a93dde851926f4f2678e704fadeb39e16c35d8baebd5252c9fd94ce8ce68c4a0 \
|
||||
--hash=sha256:aac0411d20e345dc0920bdec5548e438e999ff68d77564d5e9463a7ca9d3e7b1 \
|
||||
--hash=sha256:ae15b066e5ad21366600ebec29a7ccbc86812ed267e4b28e860b8ca16a2bc474 \
|
||||
--hash=sha256:aea440a510e14e818e67bfc4027880e2fb500c2ccb20ab21c7a7c8b5b4703d75 \
|
||||
--hash=sha256:af6fa6817889314555aede9a919612b23739395ce767fe7fcbea9a80bf140fe5 \
|
||||
--hash=sha256:b760c65308ff1e462f65d69c12e4ae085cff3b332d894637f6273a12a482d09f \
|
||||
--hash=sha256:be36e3d172dc816333f33520154d708a2657ea63762ec16b62ece02ab5e4daf2 \
|
||||
--hash=sha256:c247dd99d39e0338a604f8c2b3bc7061d5c2e9e2ac7ba9cc1be5a69cb6cd832f \
|
||||
--hash=sha256:c5529b34c1c9d937168297f2c1fde7ebe9ebdd5e121297ff9c043bdb2ae3d6fb \
|
||||
--hash=sha256:c8146669223164fc87a7e3de9f81e9423c67a79d6b3447994dfb9c95da16e2d6 \
|
||||
--hash=sha256:c8fd5270e906eef71d4a8d19b7c6a43760c6abcfcc10c9101d14eb2357418de9 \
|
||||
--hash=sha256:ca63e1890ede90b2e4454f9a65135a4d387a4585ff8282bb72964fab893f2111 \
|
||||
--hash=sha256:caf9ee9a5775f3111642d33b86237b05808dafcd6268faa492250e9b78046eb2 \
|
||||
--hash=sha256:cb1dac1770878ade83f2ccdf7d25e494f05c9165f5246b46a621cc849341dc01 \
|
||||
--hash=sha256:cdad5b9014d83ca68c25d2e9444e28e967ef16e80f6b436918c700c117a85467 \
|
||||
--hash=sha256:cdbc1fc1bc0bff1cef838eafe581b55bfbffaed4ed0318b724d0b71d4d377619 \
|
||||
--hash=sha256:ceb64bbc6eac5a140ca649003756940f8d6a7c444a68af170b3187623b43bebf \
|
||||
--hash=sha256:d0c5516f0aed654134a2fc936325cc2e642f8a0e096d075209672eb321cff408 \
|
||||
--hash=sha256:d143fd47fad1db3d7c27a1b1d66162e855b5d50a89666af46e1679c496e8e579 \
|
||||
--hash=sha256:d192f0f30804e55db0d0e0a35d83a9fead0e9a359a9ed0285dbacea60cc10a84 \
|
||||
--hash=sha256:d2b35ca2c7f81d173d2fadc2f4f31e88cc5f7a39ae5b6db5513cf3383b0e0ec7 \
|
||||
--hash=sha256:d342778ef319e1026af243ed0a07c97acf3bad33b9f29e7ae6a1f68fd083e90c \
|
||||
--hash=sha256:d487f5432bf35b60ed625d7e1b448e2dc855422e87469e3f450aa5552b0eb284 \
|
||||
--hash=sha256:d7702622a8b40c49bffb46e1e3ba2e81268d5c04a34f460978c6b5517a34dd52 \
|
||||
--hash=sha256:db85ecf4e609a48f4b29055f1e144231b90edc90af7481aa731ba2d059226b1b \
|
||||
--hash=sha256:de6551e370ef19f8de1807d0a9aa2cdfdce2e85ce88b122fe9f6b2b076837e59 \
|
||||
--hash=sha256:e1140c64812cb9b06c922e77f1c26a75ec5e3f0fb2bf92cc8c58720dec276752 \
|
||||
--hash=sha256:e4fe605b917c70283db7dfe5ada75e04561479075761a0b3866c081d035b01c1 \
|
||||
--hash=sha256:e6a904cb26bfefc2f0a6f240bdf5233be78cd2488900a2f846f3c3ac8489ab80 \
|
||||
--hash=sha256:e79e6520141d792237c70bcd7a3b122d00f2613769ae0cb61c52e89fd3443839 \
|
||||
--hash=sha256:e84799f09591700a4154154cab9787452925578841a94321d5ee8fb9a9a328f0 \
|
||||
--hash=sha256:e93dfc1a1165e385cc8239fab7c036fb2cd8093728cbd85097b284d7b99249a2 \
|
||||
--hash=sha256:efa8b278894b14d6da122a72fefcebc28445f2d3f880ac59d46c90f4c13be9a3 \
|
||||
--hash=sha256:f0d8a7a6b5983c2496e364b969f0e526647a06b075d034f3297dc66f3b360c64 \
|
||||
--hash=sha256:f0db75f47be8b8abc8d9e31bc7aad0547ca26f24a54e6fd10231d623f183d089 \
|
||||
--hash=sha256:f296c40e23065d0d6650c4aefe7470d2a25fffda489bcc3eb66083f3ac9f6643 \
|
||||
--hash=sha256:f31859074d57b4639318523d6ffdca586ace54271a73ad23ad021acd807eb14b \
|
||||
--hash=sha256:f66b5337fa213f1da0d9000bc8dc0cb5b896b726eefd9c6046f699b169c41b9e \
|
||||
--hash=sha256:f733d788519c7e3e71f0855c96618720f5d3d60c3cb829d8bbb722dddce37985 \
|
||||
--hash=sha256:fce1473f3ccc4187f75b4690cfc922628aed4d3dd013d047f95a9b3919a86596 \
|
||||
--hash=sha256:fd5f17ff8f14003595ab414e45fce13d073e0762394f957182e69035c9f3d7c2 \
|
||||
--hash=sha256:fdc3ff3bfccdc6b9cc7c342c03aa2400683f0cb891d46e94b64a197910dc4064
|
||||
# via fonttools
|
||||
cffi==1.17.1 \
|
||||
--hash=sha256:045d61c734659cc045141be4bae381a41d89b741f795af1dd018bfb532fd0df8 \
|
||||
--hash=sha256:0984a4925a435b1da406122d4d7968dd861c1385afe3b45ba82b750f229811e2 \
|
||||
--hash=sha256:0e2b1fac190ae3ebfe37b979cc1ce69c81f4e4fe5746bb401dca63a9062cdaf1 \
|
||||
--hash=sha256:0f048dcf80db46f0098ccac01132761580d28e28bc0f78ae0d58048063317e15 \
|
||||
--hash=sha256:1257bdabf294dceb59f5e70c64a3e2f462c30c7ad68092d01bbbfb1c16b1ba36 \
|
||||
--hash=sha256:1c39c6016c32bc48dd54561950ebd6836e1670f2ae46128f67cf49e789c52824 \
|
||||
--hash=sha256:1d599671f396c4723d016dbddb72fe8e0397082b0a77a4fab8028923bec050e8 \
|
||||
--hash=sha256:28b16024becceed8c6dfbc75629e27788d8a3f9030691a1dbf9821a128b22c36 \
|
||||
--hash=sha256:2bb1a08b8008b281856e5971307cc386a8e9c5b625ac297e853d36da6efe9c17 \
|
||||
--hash=sha256:30c5e0cb5ae493c04c8b42916e52ca38079f1b235c2f8ae5f4527b963c401caf \
|
||||
--hash=sha256:31000ec67d4221a71bd3f67df918b1f88f676f1c3b535a7eb473255fdc0b83fc \
|
||||
--hash=sha256:386c8bf53c502fff58903061338ce4f4950cbdcb23e2902d86c0f722b786bbe3 \
|
||||
--hash=sha256:3edc8d958eb099c634dace3c7e16560ae474aa3803a5df240542b305d14e14ed \
|
||||
--hash=sha256:45398b671ac6d70e67da8e4224a065cec6a93541bb7aebe1b198a61b58c7b702 \
|
||||
--hash=sha256:46bf43160c1a35f7ec506d254e5c890f3c03648a4dbac12d624e4490a7046cd1 \
|
||||
--hash=sha256:4ceb10419a9adf4460ea14cfd6bc43d08701f0835e979bf821052f1805850fe8 \
|
||||
--hash=sha256:51392eae71afec0d0c8fb1a53b204dbb3bcabcb3c9b807eedf3e1e6ccf2de903 \
|
||||
--hash=sha256:5da5719280082ac6bd9aa7becb3938dc9f9cbd57fac7d2871717b1feb0902ab6 \
|
||||
--hash=sha256:610faea79c43e44c71e1ec53a554553fa22321b65fae24889706c0a84d4ad86d \
|
||||
--hash=sha256:636062ea65bd0195bc012fea9321aca499c0504409f413dc88af450b57ffd03b \
|
||||
--hash=sha256:6883e737d7d9e4899a8a695e00ec36bd4e5e4f18fabe0aca0efe0a4b44cdb13e \
|
||||
--hash=sha256:6b8b4a92e1c65048ff98cfe1f735ef8f1ceb72e3d5f0c25fdb12087a23da22be \
|
||||
--hash=sha256:6f17be4345073b0a7b8ea599688f692ac3ef23ce28e5df79c04de519dbc4912c \
|
||||
--hash=sha256:706510fe141c86a69c8ddc029c7910003a17353970cff3b904ff0686a5927683 \
|
||||
--hash=sha256:72e72408cad3d5419375fc87d289076ee319835bdfa2caad331e377589aebba9 \
|
||||
--hash=sha256:733e99bc2df47476e3848417c5a4540522f234dfd4ef3ab7fafdf555b082ec0c \
|
||||
--hash=sha256:7596d6620d3fa590f677e9ee430df2958d2d6d6de2feeae5b20e82c00b76fbf8 \
|
||||
--hash=sha256:78122be759c3f8a014ce010908ae03364d00a1f81ab5c7f4a7a5120607ea56e1 \
|
||||
--hash=sha256:805b4371bf7197c329fcb3ead37e710d1bca9da5d583f5073b799d5c5bd1eee4 \
|
||||
--hash=sha256:85a950a4ac9c359340d5963966e3e0a94a676bd6245a4b55bc43949eee26a655 \
|
||||
--hash=sha256:8f2cdc858323644ab277e9bb925ad72ae0e67f69e804f4898c070998d50b1a67 \
|
||||
--hash=sha256:9755e4345d1ec879e3849e62222a18c7174d65a6a92d5b346b1863912168b595 \
|
||||
--hash=sha256:98e3969bcff97cae1b2def8ba499ea3d6f31ddfdb7635374834cf89a1a08ecf0 \
|
||||
--hash=sha256:a08d7e755f8ed21095a310a693525137cfe756ce62d066e53f502a83dc550f65 \
|
||||
--hash=sha256:a1ed2dd2972641495a3ec98445e09766f077aee98a1c896dcb4ad0d303628e41 \
|
||||
--hash=sha256:a24ed04c8ffd54b0729c07cee15a81d964e6fee0e3d4d342a27b020d22959dc6 \
|
||||
--hash=sha256:a45e3c6913c5b87b3ff120dcdc03f6131fa0065027d0ed7ee6190736a74cd401 \
|
||||
--hash=sha256:a9b15d491f3ad5d692e11f6b71f7857e7835eb677955c00cc0aefcd0669adaf6 \
|
||||
--hash=sha256:ad9413ccdeda48c5afdae7e4fa2192157e991ff761e7ab8fdd8926f40b160cc3 \
|
||||
--hash=sha256:b2ab587605f4ba0bf81dc0cb08a41bd1c0a5906bd59243d56bad7668a6fc6c16 \
|
||||
--hash=sha256:b62ce867176a75d03a665bad002af8e6d54644fad99a3c70905c543130e39d93 \
|
||||
--hash=sha256:c03e868a0b3bc35839ba98e74211ed2b05d2119be4e8a0f224fba9384f1fe02e \
|
||||
--hash=sha256:c59d6e989d07460165cc5ad3c61f9fd8f1b4796eacbd81cee78957842b834af4 \
|
||||
--hash=sha256:c7eac2ef9b63c79431bc4b25f1cd649d7f061a28808cbc6c47b534bd789ef964 \
|
||||
--hash=sha256:c9c3d058ebabb74db66e431095118094d06abf53284d9c81f27300d0e0d8bc7c \
|
||||
--hash=sha256:ca74b8dbe6e8e8263c0ffd60277de77dcee6c837a3d0881d8c1ead7268c9e576 \
|
||||
--hash=sha256:caaf0640ef5f5517f49bc275eca1406b0ffa6aa184892812030f04c2abf589a0 \
|
||||
--hash=sha256:cdf5ce3acdfd1661132f2a9c19cac174758dc2352bfe37d98aa7512c6b7178b3 \
|
||||
--hash=sha256:d016c76bdd850f3c626af19b0542c9677ba156e4ee4fccfdd7848803533ef662 \
|
||||
--hash=sha256:d01b12eeeb4427d3110de311e1774046ad344f5b1a7403101878976ecd7a10f3 \
|
||||
--hash=sha256:d63afe322132c194cf832bfec0dc69a99fb9bb6bbd550f161a49e9e855cc78ff \
|
||||
--hash=sha256:da95af8214998d77a98cc14e3a3bd00aa191526343078b530ceb0bd710fb48a5 \
|
||||
--hash=sha256:dd398dbc6773384a17fe0d3e7eeb8d1a21c2200473ee6806bb5e6a8e62bb73dd \
|
||||
--hash=sha256:de2ea4b5833625383e464549fec1bc395c1bdeeb5f25c4a3a82b5a8c756ec22f \
|
||||
--hash=sha256:de55b766c7aa2e2a3092c51e0483d700341182f08e67c63630d5b6f200bb28e5 \
|
||||
--hash=sha256:df8b1c11f177bc2313ec4b2d46baec87a5f3e71fc8b45dab2ee7cae86d9aba14 \
|
||||
--hash=sha256:e03eab0a8677fa80d646b5ddece1cbeaf556c313dcfac435ba11f107ba117b5d \
|
||||
--hash=sha256:e221cf152cff04059d011ee126477f0d9588303eb57e88923578ace7baad17f9 \
|
||||
--hash=sha256:e31ae45bc2e29f6b2abd0de1cc3b9d5205aa847cafaecb8af1476a609a2f6eb7 \
|
||||
--hash=sha256:edae79245293e15384b51f88b00613ba9f7198016a5948b5dddf4917d4d26382 \
|
||||
--hash=sha256:f1e22e8c4419538cb197e4dd60acc919d7696e5ef98ee4da4e01d3f8cfa4cc5a \
|
||||
--hash=sha256:f3a2b4222ce6b60e2e8b337bb9596923045681d71e5a082783484d845390938e \
|
||||
--hash=sha256:f6a16c31041f09ead72d69f583767292f750d24913dadacf5756b966aacb3f1a \
|
||||
--hash=sha256:f75c7ab1f9e4aca5414ed4d8e5c0e303a34f4421f8a0d47a4d019ceff0ab6af4 \
|
||||
--hash=sha256:f79fc4fc25f1c8698ff97788206bb3c2598949bfe0fef03d299eb1b5356ada99 \
|
||||
--hash=sha256:f7f5baafcc48261359e14bcd6d9bff6d4b28d9103847c9e136694cb0501aef87 \
|
||||
--hash=sha256:fc48c783f9c87e60831201f2cce7f3b2e4846bf4d8728eabe54d60700b318a0b
|
||||
# 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.18.0 \
|
||||
--hash=sha256:adbc88eabb99d2fec8c9c1b229b171f18afa655400173ddc653d5d01501fb9f2 \
|
||||
--hash=sha256:c401f4f8377c4464e6db25fff06205fd89bdd83b65eb0488ed1b160f780e21de
|
||||
# via virtualenv
|
||||
fonttools==4.59.0 \
|
||||
--hash=sha256:052444a5d0151878e87e3e512a1aa1a0ab35ee4c28afde0a778e23b0ace4a7de \
|
||||
--hash=sha256:169b99a2553a227f7b5fea8d9ecd673aa258617f466b2abc6091fe4512a0dcd0 \
|
||||
--hash=sha256:209b75943d158f610b78320eacb5539aa9e920bee2c775445b2846c65d20e19d \
|
||||
--hash=sha256:21e606b2d38fed938dde871c5736822dd6bda7a4631b92e509a1f5cd1b90c5df \
|
||||
--hash=sha256:241313683afd3baacb32a6bd124d0bce7404bc5280e12e291bae1b9bba28711d \
|
||||
--hash=sha256:26731739daa23b872643f0e4072d5939960237d540c35c14e6a06d47d71ca8fe \
|
||||
--hash=sha256:2e7cf8044ce2598bb87e44ba1d2c6e45d7a8decf56055b92906dc53f67c76d64 \
|
||||
--hash=sha256:31003b6a10f70742a63126b80863ab48175fb8272a18ca0846c0482968f0588e \
|
||||
--hash=sha256:332bfe685d1ac58ca8d62b8d6c71c2e52a6c64bc218dc8f7825c9ea51385aa01 \
|
||||
--hash=sha256:37c377f7cb2ab2eca8a0b319c68146d34a339792f9420fca6cd49cf28d370705 \
|
||||
--hash=sha256:37e01c6ec0c98599778c2e688350d624fa4770fbd6144551bd5e032f1199171c \
|
||||
--hash=sha256:401b1941ce37e78b8fd119b419b617277c65ae9417742a63282257434fd68ea2 \
|
||||
--hash=sha256:4536f2695fe5c1ffb528d84a35a7d3967e5558d2af58b4775e7ab1449d65767b \
|
||||
--hash=sha256:4c908a7036f0f3677f8afa577bcd973e3e20ddd2f7c42a33208d18bee95cdb6f \
|
||||
--hash=sha256:51ab1ff33c19e336c02dee1e9fd1abd974a4ca3d8f7eef2a104d0816a241ce97 \
|
||||
--hash=sha256:524133c1be38445c5c0575eacea42dbd44374b310b1ffc4b60ff01d881fabb96 \
|
||||
--hash=sha256:57bb7e26928573ee7c6504f54c05860d867fd35e675769f3ce01b52af38d48e2 \
|
||||
--hash=sha256:60f6665579e909b618282f3c14fa0b80570fbf1ee0e67678b9a9d43aa5d67a37 \
|
||||
--hash=sha256:62224a9bb85b4b66d1b46d45cbe43d71cbf8f527d332b177e3b96191ffbc1e64 \
|
||||
--hash=sha256:6770d7da00f358183d8fd5c4615436189e4f683bdb6affb02cad3d221d7bb757 \
|
||||
--hash=sha256:6801aeddb6acb2c42eafa45bc1cb98ba236871ae6f33f31e984670b749a8e58e \
|
||||
--hash=sha256:70d6b3ceaa9cc5a6ac52884f3b3d9544e8e231e95b23f138bdb78e6d4dc0eae3 \
|
||||
--hash=sha256:78813b49d749e1bb4db1c57f2d4d7e6db22c253cb0a86ad819f5dc197710d4b2 \
|
||||
--hash=sha256:841b2186adce48903c0fef235421ae21549020eca942c1da773ac380b056ab3c \
|
||||
--hash=sha256:84fc186980231a287b28560d3123bd255d3c6b6659828c642b4cf961e2b923d0 \
|
||||
--hash=sha256:885bde7d26e5b40e15c47bd5def48b38cbd50830a65f98122a8fb90962af7cd1 \
|
||||
--hash=sha256:8b4309a2775e4feee7356e63b163969a215d663399cce1b3d3b65e7ec2d9680e \
|
||||
--hash=sha256:8d77f92438daeaddc05682f0f3dac90c5b9829bcac75b57e8ce09cb67786073c \
|
||||
--hash=sha256:902425f5afe28572d65d2bf9c33edd5265c612ff82c69e6f83ea13eafc0dcbea \
|
||||
--hash=sha256:9bcc1e77fbd1609198966ded6b2a9897bd6c6bcbd2287a2fc7d75f1a254179c5 \
|
||||
--hash=sha256:a408c3c51358c89b29cfa5317cf11518b7ce5de1717abb55c5ae2d2921027de6 \
|
||||
--hash=sha256:a9bf8adc9e1f3012edc8f09b08336272aec0c55bc677422273e21280db748f7c \
|
||||
--hash=sha256:b818db35879d2edf7f46c7e729c700a0bce03b61b9412f5a7118406687cb151d \
|
||||
--hash=sha256:b8974b2a266b54c96709bd5e239979cddfd2dbceed331aa567ea1d7c4a2202db \
|
||||
--hash=sha256:be392ec3529e2f57faa28709d60723a763904f71a2b63aabe14fee6648fe3b14 \
|
||||
--hash=sha256:d3972b13148c1d1fbc092b27678a33b3080d1ac0ca305742b0119b75f9e87e38 \
|
||||
--hash=sha256:d40dcf533ca481355aa7b682e9e079f766f35715defa4929aeb5597f9604272e \
|
||||
--hash=sha256:e93df708c69a193fc7987192f94df250f83f3851fda49413f02ba5dded639482 \
|
||||
--hash=sha256:efd7e6660674e234e29937bc1481dceb7e0336bfae75b856b4fb272b5093c5d4 \
|
||||
--hash=sha256:f9b3a78f69dcbd803cf2fb3f972779875b244c1115481dfbdd567b2c22b31f6b \
|
||||
--hash=sha256:fa39475eaccb98f9199eccfda4298abaf35ae0caec676ffc25b3a5e224044464 \
|
||||
--hash=sha256:fbce6dae41b692a5973d0f2158f782b9ad05babc2c2019a970a1094a23909b1b
|
||||
# via weasyprint
|
||||
identify==2.6.13 \
|
||||
--hash=sha256:60381139b3ae39447482ecc406944190f690d4a2997f2584062089848361b33b \
|
||||
--hash=sha256:da8d6c828e773620e13bfa86ea601c5a5310ba4bcd65edf378198b56a1f9fb32
|
||||
# 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==11.3.0 \
|
||||
--hash=sha256:023f6d2d11784a465f09fd09a34b150ea4672e85fb3d05931d89f373ab14abb2 \
|
||||
--hash=sha256:02a723e6bf909e7cea0dac1b0e0310be9d7650cd66222a5f1c571455c0a45214 \
|
||||
--hash=sha256:040a5b691b0713e1f6cbe222e0f4f74cd233421e105850ae3b3c0ceda520f42e \
|
||||
--hash=sha256:05f6ecbeff5005399bb48d198f098a9b4b6bdf27b8487c7f38ca16eeb070cd59 \
|
||||
--hash=sha256:068d9c39a2d1b358eb9f245ce7ab1b5c3246c7c8c7d9ba58cfa5b43146c06e50 \
|
||||
--hash=sha256:0743841cabd3dba6a83f38a92672cccbd69af56e3e91777b0ee7f4dba4385632 \
|
||||
--hash=sha256:092c80c76635f5ecb10f3f83d76716165c96f5229addbd1ec2bdbbda7d496e06 \
|
||||
--hash=sha256:0b275ff9b04df7b640c59ec5a3cb113eefd3795a8df80bac69646ef699c6981a \
|
||||
--hash=sha256:0bce5c4fd0921f99d2e858dc4d4d64193407e1b99478bc5cacecba2311abde51 \
|
||||
--hash=sha256:1019b04af07fc0163e2810167918cb5add8d74674b6267616021ab558dc98ced \
|
||||
--hash=sha256:106064daa23a745510dabce1d84f29137a37224831d88eb4ce94bb187b1d7e5f \
|
||||
--hash=sha256:118ca10c0d60b06d006be10a501fd6bbdfef559251ed31b794668ed569c87e12 \
|
||||
--hash=sha256:13f87d581e71d9189ab21fe0efb5a23e9f28552d5be6979e84001d3b8505abe8 \
|
||||
--hash=sha256:155658efb5e044669c08896c0c44231c5e9abcaadbc5cd3648df2f7c0b96b9a6 \
|
||||
--hash=sha256:1904e1264881f682f02b7f8167935cce37bc97db457f8e7849dc3a6a52b99580 \
|
||||
--hash=sha256:19d2ff547c75b8e3ff46f4d9ef969a06c30ab2d4263a9e287733aa8b2429ce8f \
|
||||
--hash=sha256:1a992e86b0dd7aeb1f053cd506508c0999d710a8f07b4c791c63843fc6a807ac \
|
||||
--hash=sha256:1b9c17fd4ace828b3003dfd1e30bff24863e0eb59b535e8f80194d9cc7ecf860 \
|
||||
--hash=sha256:1c627742b539bba4309df89171356fcb3cc5a9178355b2727d1b74a6cf155fbd \
|
||||
--hash=sha256:1cd110edf822773368b396281a2293aeb91c90a2db00d78ea43e7e861631b722 \
|
||||
--hash=sha256:1f85acb69adf2aaee8b7da124efebbdb959a104db34d3a2cb0f3793dbae422a8 \
|
||||
--hash=sha256:23cff760a9049c502721bdb743a7cb3e03365fafcdfc2ef9784610714166e5a4 \
|
||||
--hash=sha256:2465a69cf967b8b49ee1b96d76718cd98c4e925414ead59fdf75cf0fd07df673 \
|
||||
--hash=sha256:2a3117c06b8fb646639dce83694f2f9eac405472713fcb1ae887469c0d4f6788 \
|
||||
--hash=sha256:2aceea54f957dd4448264f9bf40875da0415c83eb85f55069d89c0ed436e3542 \
|
||||
--hash=sha256:2d6fcc902a24ac74495df63faad1884282239265c6839a0a6416d33faedfae7e \
|
||||
--hash=sha256:30807c931ff7c095620fe04448e2c2fc673fcbb1ffe2a7da3fb39613489b1ddd \
|
||||
--hash=sha256:30b7c02f3899d10f13d7a48163c8969e4e653f8b43416d23d13d1bbfdc93b9f8 \
|
||||
--hash=sha256:3828ee7586cd0b2091b6209e5ad53e20d0649bbe87164a459d0676e035e8f523 \
|
||||
--hash=sha256:3cee80663f29e3843b68199b9d6f4f54bd1d4a6b59bdd91bceefc51238bcb967 \
|
||||
--hash=sha256:3e184b2f26ff146363dd07bde8b711833d7b0202e27d13540bfe2e35a323a809 \
|
||||
--hash=sha256:41342b64afeba938edb034d122b2dda5db2139b9a4af999729ba8818e0056477 \
|
||||
--hash=sha256:41742638139424703b4d01665b807c6468e23e699e8e90cffefe291c5832b027 \
|
||||
--hash=sha256:4445fa62e15936a028672fd48c4c11a66d641d2c05726c7ec1f8ba6a572036ae \
|
||||
--hash=sha256:45dfc51ac5975b938e9809451c51734124e73b04d0f0ac621649821a63852e7b \
|
||||
--hash=sha256:465b9e8844e3c3519a983d58b80be3f668e2a7a5db97f2784e7079fbc9f9822c \
|
||||
--hash=sha256:48d254f8a4c776de343051023eb61ffe818299eeac478da55227d96e241de53f \
|
||||
--hash=sha256:4c834a3921375c48ee6b9624061076bc0a32a60b5532b322cc0ea64e639dd50e \
|
||||
--hash=sha256:4c96f993ab8c98460cd0c001447bff6194403e8b1d7e149ade5f00594918128b \
|
||||
--hash=sha256:504b6f59505f08ae014f724b6207ff6222662aab5cc9542577fb084ed0676ac7 \
|
||||
--hash=sha256:527b37216b6ac3a12d7838dc3bd75208ec57c1c6d11ef01902266a5a0c14fc27 \
|
||||
--hash=sha256:5418b53c0d59b3824d05e029669efa023bbef0f3e92e75ec8428f3799487f361 \
|
||||
--hash=sha256:59a03cdf019efbfeeed910bf79c7c93255c3d54bc45898ac2a4140071b02b4ae \
|
||||
--hash=sha256:5e05688ccef30ea69b9317a9ead994b93975104a677a36a8ed8106be9260aa6d \
|
||||
--hash=sha256:6359a3bc43f57d5b375d1ad54a0074318a0844d11b76abccf478c37c986d3cfc \
|
||||
--hash=sha256:643f189248837533073c405ec2f0bb250ba54598cf80e8c1e043381a60632f58 \
|
||||
--hash=sha256:65dc69160114cdd0ca0f35cb434633c75e8e7fad4cf855177a05bf38678f73ad \
|
||||
--hash=sha256:67172f2944ebba3d4a7b54f2e95c786a3a50c21b88456329314caaa28cda70f6 \
|
||||
--hash=sha256:676b2815362456b5b3216b4fd5bd89d362100dc6f4945154ff172e206a22c024 \
|
||||
--hash=sha256:6a418691000f2a418c9135a7cf0d797c1bb7d9a485e61fe8e7722845b95ef978 \
|
||||
--hash=sha256:6abdbfd3aea42be05702a8dd98832329c167ee84400a1d1f61ab11437f1717eb \
|
||||
--hash=sha256:6be31e3fc9a621e071bc17bb7de63b85cbe0bfae91bb0363c893cbe67247780d \
|
||||
--hash=sha256:7107195ddc914f656c7fc8e4a5e1c25f32e9236ea3ea860f257b0436011fddd0 \
|
||||
--hash=sha256:71f511f6b3b91dd543282477be45a033e4845a40278fa8dcdbfdb07109bf18f9 \
|
||||
--hash=sha256:7859a4cc7c9295f5838015d8cc0a9c215b77e43d07a25e460f35cf516df8626f \
|
||||
--hash=sha256:7966e38dcd0fa11ca390aed7c6f20454443581d758242023cf36fcb319b1a874 \
|
||||
--hash=sha256:79ea0d14d3ebad43ec77ad5272e6ff9bba5b679ef73375ea760261207fa8e0aa \
|
||||
--hash=sha256:7aee118e30a4cf54fdd873bd3a29de51e29105ab11f9aad8c32123f58c8f8081 \
|
||||
--hash=sha256:7b161756381f0918e05e7cb8a371fff367e807770f8fe92ecb20d905d0e1c149 \
|
||||
--hash=sha256:7c8ec7a017ad1bd562f93dbd8505763e688d388cde6e4a010ae1486916e713e6 \
|
||||
--hash=sha256:7d1aa4de119a0ecac0a34a9c8bde33f34022e2e8f99104e47a3ca392fd60e37d \
|
||||
--hash=sha256:7db51d222548ccfd274e4572fdbf3e810a5e66b00608862f947b163e613b67dd \
|
||||
--hash=sha256:819931d25e57b513242859ce1876c58c59dc31587847bf74cfe06b2e0cb22d2f \
|
||||
--hash=sha256:83e1b0161c9d148125083a35c1c5a89db5b7054834fd4387499e06552035236c \
|
||||
--hash=sha256:857844335c95bea93fb39e0fa2726b4d9d758850b34075a7e3ff4f4fa3aa3b31 \
|
||||
--hash=sha256:8797edc41f3e8536ae4b10897ee2f637235c94f27404cac7297f7b607dd0716e \
|
||||
--hash=sha256:8924748b688aa210d79883357d102cd64690e56b923a186f35a82cbc10f997db \
|
||||
--hash=sha256:89bd777bc6624fe4115e9fac3352c79ed60f3bb18651420635f26e643e3dd1f6 \
|
||||
--hash=sha256:8dc70ca24c110503e16918a658b869019126ecfe03109b754c402daff12b3d9f \
|
||||
--hash=sha256:91da1d88226663594e3f6b4b8c3c8d85bd504117d043740a8e0ec449087cc494 \
|
||||
--hash=sha256:921bd305b10e82b4d1f5e802b6850677f965d8394203d182f078873851dada69 \
|
||||
--hash=sha256:932c754c2d51ad2b2271fd01c3d121daaa35e27efae2a616f77bf164bc0b3e94 \
|
||||
--hash=sha256:93efb0b4de7e340d99057415c749175e24c8864302369e05914682ba642e5d77 \
|
||||
--hash=sha256:97afb3a00b65cc0804d1c7abddbf090a81eaac02768af58cbdcaaa0a931e0b6d \
|
||||
--hash=sha256:97f07ed9f56a3b9b5f49d3661dc9607484e85c67e27f3e8be2c7d28ca032fec7 \
|
||||
--hash=sha256:98a9afa7b9007c67ed84c57c9e0ad86a6000da96eaa638e4f8abe5b65ff83f0a \
|
||||
--hash=sha256:9ab6ae226de48019caa8074894544af5b53a117ccb9d3b3dcb2871464c829438 \
|
||||
--hash=sha256:9c412fddd1b77a75aa904615ebaa6001f169b26fd467b4be93aded278266b288 \
|
||||
--hash=sha256:a1bc6ba083b145187f648b667e05a2534ecc4b9f2784c2cbe3089e44868f2b9b \
|
||||
--hash=sha256:a418486160228f64dd9e9efcd132679b7a02a5f22c982c78b6fc7dab3fefb635 \
|
||||
--hash=sha256:a4d336baed65d50d37b88ca5b60c0fa9d81e3a87d4a7930d3880d1624d5b31f3 \
|
||||
--hash=sha256:a6444696fce635783440b7f7a9fc24b3ad10a9ea3f0ab66c5905be1c19ccf17d \
|
||||
--hash=sha256:a7bc6e6fd0395bc052f16b1a8670859964dbd7003bd0af2ff08342eb6e442cfe \
|
||||
--hash=sha256:b4b8f3efc8d530a1544e5962bd6b403d5f7fe8b9e08227c6b255f98ad82b4ba0 \
|
||||
--hash=sha256:b5f56c3f344f2ccaf0dd875d3e180f631dc60a51b314295a3e681fe8cf851fbe \
|
||||
--hash=sha256:be5463ac478b623b9dd3937afd7fb7ab3d79dd290a28e2b6df292dc75063eb8a \
|
||||
--hash=sha256:c37d8ba9411d6003bba9e518db0db0c58a680ab9fe5179f040b0463644bc9805 \
|
||||
--hash=sha256:c84d689db21a1c397d001aa08241044aa2069e7587b398c8cc63020390b1c1b8 \
|
||||
--hash=sha256:c96d333dcf42d01f47b37e0979b6bd73ec91eae18614864622d9b87bbd5bbf36 \
|
||||
--hash=sha256:cadc9e0ea0a2431124cde7e1697106471fc4c1da01530e679b2391c37d3fbb3a \
|
||||
--hash=sha256:cc3e831b563b3114baac7ec2ee86819eb03caa1a2cef0b481a5675b59c4fe23b \
|
||||
--hash=sha256:cd8ff254faf15591e724dc7c4ddb6bf4793efcbe13802a4ae3e863cd300b493e \
|
||||
--hash=sha256:d000f46e2917c705e9fb93a3606ee4a819d1e3aa7a9b442f6444f07e77cf5e25 \
|
||||
--hash=sha256:d9da3df5f9ea2a89b81bb6087177fb1f4d1c7146d583a3fe5c672c0d94e55e12 \
|
||||
--hash=sha256:e5c5858ad8ec655450a7c7df532e9842cf8df7cc349df7225c60d5d348c8aada \
|
||||
--hash=sha256:e67d793d180c9df62f1f40aee3accca4829d3794c95098887edc18af4b8b780c \
|
||||
--hash=sha256:ea944117a7974ae78059fcc1800e5d3295172bb97035c0c1d9345fca1419da71 \
|
||||
--hash=sha256:eb76541cba2f958032d79d143b98a3a6b3ea87f0959bbe256c0b5e416599fd5d \
|
||||
--hash=sha256:ec1ee50470b0d050984394423d96325b744d55c701a439d2bd66089bff963d3c \
|
||||
--hash=sha256:ee92f2fd10f4adc4b43d07ec5e779932b4eb3dbfbc34790ada5a6669bc095aa6 \
|
||||
--hash=sha256:f0f5d8f4a08090c6d6d578351a2b91acf519a54986c055af27e7a93feae6d3f1 \
|
||||
--hash=sha256:f1f182ebd2303acf8c380a54f615ec883322593320a9b00438eb842c1f37ae50 \
|
||||
--hash=sha256:f8a5827f84d973d8636e9dc5764af4f0cf2318d26744b3d902931701b0d46653 \
|
||||
--hash=sha256:f944255db153ebb2b19c51fe85dd99ef0ce494123f21b9db4877ffdfc5590c7c \
|
||||
--hash=sha256:fdae223722da47b024b867c1ea0be64e0df702c5e0a60e27daad39bf960dd1e4 \
|
||||
--hash=sha256:fe27fb049cdcca11f11a7bfda64043c37b30e6b91f10cb5bab275806c32f6ab3
|
||||
# via
|
||||
# -r .github/scripts/requirements_dev.in
|
||||
# pdf2image
|
||||
# weasyprint
|
||||
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_dev.in
|
||||
pycparser==2.22 \
|
||||
--hash=sha256:491c8be9c040f5390f5bf44a5b07752bd07f56edf992381b05c701439eec10f6 \
|
||||
--hash=sha256:c3702b6d3dd8c7abc1afa565d7e63d53a1d0bd86cdc24edd75470f4de499cfcc
|
||||
# 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.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
|
||||
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.3.2 \
|
||||
--hash=sha256:1eeb7467cf6b56b8eff3b576e2d1b2b2ff4e0eb2052e995ac80a1456de300639 \
|
||||
--hash=sha256:87e144f903ee21951b2e06a97549450c13ed7eca5bcebad942d3352d4e882616
|
||||
# via -r .github/scripts/requirements_dev.in
|
||||
virtualenv==20.33.1 \
|
||||
--hash=sha256:07c19bc66c11acab6a5958b815cbcee30891cd1c2ccf53785a28651a0d8d8a67 \
|
||||
--hash=sha256:1b44478d9e261b3fb8baa5e74a0ca3bc0e05f21aa36167bf9cbf850e542765b8
|
||||
# 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.2 \
|
||||
--hash=sha256:578283f006390f85bb6282dffb876454593d637f5d1be494b5202ce4877e71f2 \
|
||||
--hash=sha256:6d67a2b4e7f14d8b31b8b52648866fa717f45a1eb70e83002f4331d07e953717
|
||||
# via -r .github/scripts/requirements_dev.in
|
||||
setuptools==80.9.0 \
|
||||
--hash=sha256:062d34222ad13e0cc312a4c02d73f059e86a4acbfbdea8f8f76b28c99f306922 \
|
||||
--hash=sha256:f36b47402ecde768dbfafc46e8e4207b4360c654f1f3bb84475f0a28628fb19c
|
||||
# via -r .github/scripts/requirements_dev.in
|
||||
@@ -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.19.1 \
|
||||
--hash=sha256:66eda1888b0171c998b35be2bcc0f6d75c388a7ce20c3f3f37aa8e96c2dddf58 \
|
||||
--hash=sha256:d38e30481def20772f5baf097c122c3babc4fcdb7e14e57049eb9d88c6dc017d
|
||||
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.4.0 \
|
||||
--hash=sha256:abd01743f24e5287cd7a5db3752faf1a2d65353f38ec26d98e25a6db65958c85 \
|
||||
--hash=sha256:ca753cf4d81dc309bc67b0ea38fd15dc97bc30ce419a7f58d13eb3bf14c4febf
|
||||
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.34.0 \
|
||||
--hash=sha256:341f5afa7eee943e4984a9207c025feedd768baff6753cd660c857ceb3e36026 \
|
||||
--hash=sha256:44815b2c9dee7ed86e387b842a84f20b93f7f417f95886ca1996a72a4138eb1a
|
||||
virtualenv==20.31.2 \
|
||||
--hash=sha256:36efd0d9650ee985f0cad72065001e66d49a6f24eb44d98980f630686243cf11 \
|
||||
--hash=sha256:e10c0a9d02835e592521be48b332b6caee6887f332c111aa79a09b9e79efc2af
|
||||
# via pre-commit
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
uses: step-security/harden-runner@6c439dc8bdf85cadbbce9ed30d1c7b959517bc49 # v2.12.2
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout PR
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.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@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
uses: step-security/harden-runner@6c439dc8bdf85cadbbce9ed30d1c7b959517bc49 # v2.12.2
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout PR
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.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@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.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,11 +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
|
||||
|
||||
@@ -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@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
uses: step-security/harden-runner@6c439dc8bdf85cadbbce9ed30d1c7b959517bc49 # v2.12.2
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout PR
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.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
|
||||
|
||||
@@ -19,11 +19,11 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
uses: step-security/harden-runner@6c439dc8bdf85cadbbce9ed30d1c7b959517bc49 # v2.12.2
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.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 }}
|
||||
|
||||
@@ -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
|
||||
@@ -13,11 +13,11 @@ jobs:
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
uses: step-security/harden-runner@6c439dc8bdf85cadbbce9ed30d1c7b959517bc49 # v2.12.2
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
|
||||
- name: Setup GitHub App Bot
|
||||
id: setup-bot
|
||||
|
||||
+32
-175
@@ -1,46 +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 }}
|
||||
steps:
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.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
|
||||
|
||||
@@ -56,24 +25,19 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
uses: step-security/harden-runner@6c439dc8bdf85cadbbce9ed30d1c7b959517bc49 # v2.12.2
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.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"
|
||||
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@4d9f0ba0025fe599b4ebab900eb7f3a1d93ef4c2 # v5.0.0
|
||||
with:
|
||||
gradle-version: 8.14
|
||||
|
||||
- name: Build with Gradle and spring security ${{ matrix.spring-security }}
|
||||
run: ./gradlew clean build
|
||||
env:
|
||||
@@ -84,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
|
||||
@@ -110,78 +74,40 @@ jobs:
|
||||
with:
|
||||
name: test-reports-jdk-${{ matrix.jdk-version }}-spring-security-${{ matrix.spring-security }}
|
||||
path: |
|
||||
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/problems/
|
||||
app/proprietary/build/reports/tests/
|
||||
app/proprietary/build/test-results/
|
||||
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
|
||||
|
||||
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@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
|
||||
- name: Set up JDK 17
|
||||
uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v5.0.0
|
||||
with:
|
||||
java-version: "17"
|
||||
distribution: "temurin"
|
||||
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@4d9f0ba0025fe599b4ebab900eb7f3a1d93ef4c2 # v5.0.0
|
||||
|
||||
- name: Generate OpenAPI documentation
|
||||
run: ./gradlew :stirling-pdf:generateOpenApiDocs
|
||||
env:
|
||||
DISABLE_ADDITIONAL_FEATURES: true
|
||||
|
||||
- name: Upload OpenAPI Documentation
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
with:
|
||||
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@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
uses: step-security/harden-runner@6c439dc8bdf85cadbbce9ed30d1c7b959517bc49 # v2.12.2
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.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"
|
||||
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@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
with:
|
||||
@@ -191,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 &&
|
||||
@@ -211,18 +135,18 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
uses: step-security/harden-runner@6c439dc8bdf85cadbbce9ed30d1c7b959517bc49 # v2.12.2
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout Repository
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.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"
|
||||
distribution: "adopt"
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1
|
||||
@@ -233,11 +157,10 @@ jobs:
|
||||
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
|
||||
|
||||
- name: Pip requirements
|
||||
run: |
|
||||
@@ -249,69 +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.project == 'true'
|
||||
needs: [files-changed, build, check-generateOpenApiDocs, check-licence]
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
docker-rev: ["Dockerfile", "Dockerfile.ultra-lite", "Dockerfile.fat"]
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout Repository
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
|
||||
- name: Set up JDK 17
|
||||
uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v5.0.0
|
||||
with:
|
||||
java-version: "17"
|
||||
distribution: "temurin"
|
||||
|
||||
- name: Set up Gradle
|
||||
uses: gradle/actions/setup-gradle@4d9f0ba0025fe599b4ebab900eb7f3a1d93ef4c2 # v5.0.0
|
||||
with:
|
||||
gradle-version: 8.14
|
||||
|
||||
- name: Build application
|
||||
run: ./gradlew clean build
|
||||
env:
|
||||
DISABLE_ADDITIONAL_FEATURES: true
|
||||
STIRLING_PDF_DESKTOP_UI: false
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@29109295f81e9208d7d86ff1c6c12d2833863392 # v3.6.0
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
id: buildx
|
||||
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1
|
||||
|
||||
- name: Build ${{ matrix.docker-rev }}
|
||||
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
|
||||
with:
|
||||
builder: ${{ steps.buildx.outputs.name }}
|
||||
context: .
|
||||
file: ./${{ matrix.docker-rev }}
|
||||
push: false
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
platforms: linux/amd64,linux/arm64/v8
|
||||
provenance: true
|
||||
sbom: true
|
||||
|
||||
- name: Upload Reports
|
||||
if: always()
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
with:
|
||||
name: reports-docker-${{ matrix.docker-rev }}
|
||||
path: |
|
||||
build/reports/tests/
|
||||
build/test-results/
|
||||
build/reports/problems/
|
||||
retention-days: 3
|
||||
if-no-files-found: warn
|
||||
|
||||
@@ -4,19 +4,7 @@ on:
|
||||
pull_request_target:
|
||||
types: [opened, synchronize, reopened]
|
||||
paths:
|
||||
- "app/core/src/main/resources/messages_*.properties"
|
||||
|
||||
# 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
|
||||
@@ -30,12 +18,12 @@ jobs:
|
||||
pull-requests: write # Allow writing to pull requests
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
uses: step-security/harden-runner@6c439dc8bdf85cadbbce9ed30d1c7b959517bc49 # v2.12.2
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout main branch first
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
|
||||
- name: Setup GitHub App Bot
|
||||
id: setup-bot
|
||||
@@ -46,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: |
|
||||
@@ -79,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"
|
||||
@@ -90,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: |
|
||||
@@ -127,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);
|
||||
|
||||
@@ -169,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,
|
||||
});
|
||||
|
||||
@@ -186,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",
|
||||
});
|
||||
|
||||
@@ -234,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: |
|
||||
@@ -289,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
|
||||
@@ -17,11 +17,11 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
uses: step-security/harden-runner@6c439dc8bdf85cadbbce9ed30d1c7b959517bc49 # v2.12.2
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: "Checkout Repository"
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
- name: "Dependency Review"
|
||||
uses: actions/dependency-review-action@56339e523c0409420f6c2c9a2f4292bbb3c07dd3 # v4.8.0
|
||||
uses: actions/dependency-review-action@da24556b548a50705dd671f47852072ea4c105d9 # v4.7.1
|
||||
|
||||
@@ -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@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
uses: step-security/harden-runner@6c439dc8bdf85cadbbce9ed30d1c7b959517bc49 # v2.12.2
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Check out code
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
@@ -48,19 +36,16 @@ 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()
|
||||
@@ -72,11 +57,11 @@ 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
|
||||
|
||||
@@ -15,12 +15,12 @@ jobs:
|
||||
issues: write
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
uses: step-security/harden-runner@6c439dc8bdf85cadbbce9ed30d1c7b959517bc49 # v2.12.2
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Check out the repository
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
|
||||
- name: Run Labeler
|
||||
uses: crazy-max/ghaction-github-labeler@24d110aa46a59976b8a7f35518cb7f14f434c916 # v5.3.0
|
||||
|
||||
@@ -21,14 +21,14 @@ jobs:
|
||||
versionMac: ${{ steps.versionNumberMac.outputs.versionNumberMac }}
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
uses: step-security/harden-runner@6c439dc8bdf85cadbbce9ed30d1c7b959517bc49 # v2.12.2
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.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@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
uses: step-security/harden-runner@6c439dc8bdf85cadbbce9ed30d1c7b959517bc49 # v2.12.2
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.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,7 +86,7 @@ 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@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
@@ -110,12 +110,12 @@ jobs:
|
||||
file_suffix: ""
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
uses: step-security/harden-runner@6c439dc8bdf85cadbbce9ed30d1c7b959517bc49 # v2.12.2
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Download build artifacts
|
||||
uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0
|
||||
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
|
||||
with:
|
||||
name: stirling-${{ matrix.file_suffix }}binaries
|
||||
|
||||
@@ -148,19 +148,19 @@ jobs:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
uses: step-security/harden-runner@6c439dc8bdf85cadbbce9ed30d1c7b959517bc49 # v2.12.2
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.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
|
||||
|
||||
@@ -238,12 +238,12 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
uses: step-security/harden-runner@6c439dc8bdf85cadbbce9ed30d1c7b959517bc49 # v2.12.2
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Download build artifacts
|
||||
uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0
|
||||
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
|
||||
with:
|
||||
name: ${{ matrix.platform }}binaries
|
||||
|
||||
@@ -252,7 +252,7 @@ jobs:
|
||||
|
||||
- name: Install Cosign
|
||||
if: matrix.os == 'windows-latest'
|
||||
uses: sigstore/cosign-installer@d7543c93d881b35a8faa02e8e3605f69b7a1ce62 # v3.10.0
|
||||
uses: sigstore/cosign-installer@398d4b0eeef1380460a10c8013a76f728fb906ac # v3.9.1
|
||||
|
||||
- name: Generate key pair
|
||||
if: matrix.os == 'windows-latest'
|
||||
@@ -301,16 +301,16 @@ jobs:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
uses: step-security/harden-runner@6c439dc8bdf85cadbbce9ed30d1c7b959517bc49 # v2.12.2
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Download signed artifacts
|
||||
uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.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@62c96d0c4e8a889135c1f3a25910db8dbe0e85f7 # v2.3.4
|
||||
uses: softprops/action-gh-release@72f2c25fcb47643c292f7107632f7a47c1df5cd8 # v2.3.2
|
||||
with:
|
||||
tag_name: v${{ needs.read_versions.outputs.version }}
|
||||
generate_release_notes: true
|
||||
|
||||
@@ -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@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
uses: step-security/harden-runner@6c439dc8bdf85cadbbce9ed30d1c7b959517bc49 # v2.12.2
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.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 .
|
||||
|
||||
@@ -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@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
uses: step-security/harden-runner@6c439dc8bdf85cadbbce9ed30d1c7b959517bc49 # v2.12.2
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.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@d7543c93d881b35a8faa02e8e3605f69b7a1ce62 # v3.10.0
|
||||
uses: sigstore/cosign-installer@398d4b0eeef1380460a10c8013a76f728fb906ac # v3.9.1
|
||||
with:
|
||||
cosign-release: "v2.4.1"
|
||||
|
||||
@@ -67,13 +55,13 @@ 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 }}
|
||||
@@ -88,7 +76,7 @@ jobs:
|
||||
|
||||
- name: Generate tags
|
||||
id: meta
|
||||
uses: docker/metadata-action@c1e51972afc2121e065aed6d45c65596fe445f3f # v5.8.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@c1e51972afc2121e065aed6d45c65596fe445f3f # v5.8.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@c1e51972afc2121e065aed6d45c65596fe445f3f # v5.8.0
|
||||
uses: docker/metadata-action@902fa8ec7d6ecbf8d84d538b9b233a880e428804 # v5.7.0
|
||||
with:
|
||||
images: |
|
||||
${{ secrets.DOCKER_HUB_USERNAME }}/s-pdf
|
||||
|
||||
@@ -23,19 +23,19 @@ jobs:
|
||||
version: ${{ steps.versionNumber.outputs.versionNumber }}
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
uses: step-security/harden-runner@6c439dc8bdf85cadbbce9ed30d1c7b959517bc49 # v2.12.2
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.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
|
||||
|
||||
@@ -83,19 +83,19 @@ jobs:
|
||||
file_suffix: ""
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
uses: step-security/harden-runner@6c439dc8bdf85cadbbce9ed30d1c7b959517bc49 # v2.12.2
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Download build artifacts
|
||||
uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.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@d7543c93d881b35a8faa02e8e3605f69b7a1ce62 # v3.10.0
|
||||
uses: sigstore/cosign-installer@398d4b0eeef1380460a10c8013a76f728fb906ac # v3.9.1
|
||||
|
||||
- name: Generate key pair
|
||||
run: cosign generate-key-pair
|
||||
@@ -161,17 +161,17 @@ jobs:
|
||||
file_suffix: ""
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
uses: step-security/harden-runner@6c439dc8bdf85cadbbce9ed30d1c7b959517bc49 # v2.12.2
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Download signed artifacts
|
||||
uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.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@62c96d0c4e8a889135c1f3a25910db8dbe0e85f7 # v2.3.4
|
||||
uses: softprops/action-gh-release@72f2c25fcb47643c292f7107632f7a47c1df5cd8 # v2.3.2
|
||||
with:
|
||||
tag_name: v${{ needs.build.outputs.version }}
|
||||
generate_release_notes: true
|
||||
|
||||
@@ -34,17 +34,17 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
uses: step-security/harden-runner@6c439dc8bdf85cadbbce9ed30d1c7b959517bc49 # v2.12.2
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: "Checkout code"
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.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
|
||||
@@ -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@64d10c13136e1c5bce3e5fbde8d4906eeaafc885 # v3.29.5
|
||||
uses: github/codeql-action/upload-sarif@181d5eefc20863364f96762470ba6f862bdef56b # v3.29.2
|
||||
with:
|
||||
sarif_file: results.sarif
|
||||
|
||||
@@ -9,18 +9,6 @@ on:
|
||||
- main
|
||||
workflow_dispatch:
|
||||
|
||||
# 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:
|
||||
pull-requests: read
|
||||
actions: read
|
||||
@@ -30,16 +18,16 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
uses: step-security/harden-runner@6c439dc8bdf85cadbbce9ed30d1c7b959517bc49 # v2.12.2
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@4d9f0ba0025fe599b4ebab900eb7f3a1d93ef4c2 # v5.0.0
|
||||
uses: gradle/actions/setup-gradle@ac638b010cf58a27ee6c972d7336334ccaf61c96 # v4.4.1
|
||||
|
||||
- name: Build and analyze with Gradle
|
||||
env:
|
||||
|
||||
@@ -16,12 +16,12 @@ jobs:
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
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
|
||||
|
||||
@@ -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@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
uses: step-security/harden-runner@6c439dc8bdf85cadbbce9ed30d1c7b959517bc49 # v2.12.2
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.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
|
||||
|
||||
@@ -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@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
uses: step-security/harden-runner@6c439dc8bdf85cadbbce9ed30d1c7b959517bc49 # v2.12.2
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.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: |
|
||||
@@ -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
|
||||
|
||||
@@ -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@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
uses: step-security/harden-runner@6c439dc8bdf85cadbbce9ed30d1c7b959517bc49 # v2.12.2
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.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@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.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@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
uses: step-security/harden-runner@6c439dc8bdf85cadbbce9ed30d1c7b959517bc49 # v2.12.2
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.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@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
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
@@ -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/**
|
||||
|
||||
+5
-15
@@ -1,15 +1,15 @@
|
||||
repos:
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
rev: v0.12.7
|
||||
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,9 +20,9 @@ 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
|
||||
@@ -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]
|
||||
|
||||
Vendored
-2
@@ -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
|
||||
]
|
||||
}
|
||||
|
||||
Vendored
+10
-26
@@ -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
@@ -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
|
||||
|
||||
+6
-4
@@ -1,10 +1,11 @@
|
||||
# Main stage
|
||||
FROM alpine:3.22.1@sha256:4bcff63911fcb4448bd4fdacec207030997caf25e9bea4045fa6c8c44de311d1
|
||||
FROM alpine:3.22.0@sha256:8a1f59ffb675680d47db6337b49d22281a139e9d709335b492be023728e11715
|
||||
|
||||
# Copy necessary files
|
||||
COPY scripts /scripts
|
||||
COPY app/core/src/main/resources/static/fonts/*.ttf /usr/share/fonts/opentype/noto/
|
||||
COPY app/core/build/libs/*.jar app.jar
|
||||
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
|
||||
|
||||
ARG VERSION_TAG
|
||||
|
||||
@@ -78,7 +79,7 @@ RUN echo "@main https://dl-cdn.alpinelinux.org/alpine/edge/main" | tee -a /etc/a
|
||||
# URW Base 35 fonts for better PDF rendering
|
||||
font-urw-base35 && \
|
||||
python3 -m venv /opt/venv && \
|
||||
/opt/venv/bin/pip install --no-cache-dir --upgrade pip setuptools && \
|
||||
/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/ && \
|
||||
@@ -89,6 +90,7 @@ RUN echo "@main https://dl-cdn.alpinelinux.org/alpine/edge/main" | tee -a /etc/a
|
||||
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 && \
|
||||
|
||||
+4
-3
@@ -34,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 --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"
|
||||
@@ -54,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 Nicht‑Root Benutzer
|
||||
USER devuser
|
||||
|
||||
+9
-8
@@ -5,9 +5,9 @@ COPY build.gradle .
|
||||
COPY settings.gradle .
|
||||
COPY gradlew .
|
||||
COPY gradle gradle/
|
||||
COPY app/core/build.gradle core/.
|
||||
COPY app/common/build.gradle common/.
|
||||
COPY app/proprietary/build.gradle proprietary/.
|
||||
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
|
||||
|
||||
# Set the working directory
|
||||
@@ -22,13 +22,13 @@ RUN DISABLE_ADDITIONAL_FEATURES=false \
|
||||
./gradlew clean build -x spotlessApply -x spotlessCheck -x test -x sonarqube
|
||||
|
||||
# Main stage
|
||||
FROM alpine:3.22.1@sha256:4bcff63911fcb4448bd4fdacec207030997caf25e9bea4045fa6c8c44de311d1
|
||||
FROM alpine:3.22.0@sha256:8a1f59ffb675680d47db6337b49d22281a139e9d709335b492be023728e11715
|
||||
|
||||
# Copy necessary files
|
||||
COPY scripts /scripts
|
||||
COPY app/core/src/main/resources/static/fonts/*.ttf /usr/share/fonts/opentype/noto/
|
||||
# first /app directory is for the build stage, second is for the final image
|
||||
COPY --from=build /app/app/core/build/libs/*.jar app.jar
|
||||
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
|
||||
|
||||
ARG VERSION_TAG
|
||||
|
||||
@@ -91,7 +91,7 @@ RUN echo "@main https://dl-cdn.alpinelinux.org/alpine/edge/main" | tee -a /etc/a
|
||||
py3-pillow@testing \
|
||||
py3-pdf2image@testing && \
|
||||
python3 -m venv /opt/venv && \
|
||||
/opt/venv/bin/pip install --no-cache-dir --upgrade pip setuptools && \
|
||||
/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/ && \
|
||||
@@ -102,6 +102,7 @@ RUN echo "@main https://dl-cdn.alpinelinux.org/alpine/edge/main" | tee -a /etc/a
|
||||
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 && \
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# use alpine
|
||||
FROM alpine:3.22.1@sha256:4bcff63911fcb4448bd4fdacec207030997caf25e9bea4045fa6c8c44de311d1
|
||||
FROM alpine:3.22.0@sha256:8a1f59ffb675680d47db6337b49d22281a139e9d709335b492be023728e11715
|
||||
|
||||
ARG VERSION_TAG
|
||||
|
||||
@@ -21,7 +21,8 @@ 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 echo "@testing https://dl-cdn.alpinelinux.org/alpine/edge/main" | tee -a /etc/apk/repositories && \
|
||||
@@ -38,10 +39,10 @@ RUN echo "@testing https://dl-cdn.alpinelinux.org/alpine/edge/main" | tee -a /et
|
||||
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 -R stirlingpdfuser:stirlingpdfgroup $HOME /scripts /configs /customFiles /pipeline /tmp/stirling-pdf && \
|
||||
chown stirlingpdfuser:stirlingpdfgroup /app.jar
|
||||
|
||||
# Set environment variables
|
||||
@@ -51,4 +52,4 @@ EXPOSE 8080/tcp
|
||||
|
||||
# Run the application
|
||||
ENTRYPOINT ["tini", "--", "/scripts/init-without-ocr.sh"]
|
||||
CMD ["java", "-Dfile.encoding=UTF-8", "-Djava.io.tmpdir=/tmp/stirling-pdf", "-jar", "/app.jar"]
|
||||
CMD ["java", "-Dfile.encoding=UTF-8", "-Djava.io.tmpdir=/tmp/stirling-pdf", "-jar", "/app.jar"]
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -21,97 +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
|
||||
- **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
|
||||
- **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
|
||||
- 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 documet (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
|
||||
### 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
|
||||
- **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
|
||||
|
||||
@@ -130,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) |  |
|
||||
| Azerbaijani (Azərbaycan Dili) (az_AZ) |  |
|
||||
| Basque (Euskara) (eu_ES) |  |
|
||||
| Bulgarian (Български) (bg_BG) |  |
|
||||
| Catalan (Català) (ca_CA) |  |
|
||||
| Croatian (Hrvatski) (hr_HR) |  |
|
||||
| Czech (Česky) (cs_CZ) |  |
|
||||
| Danish (Dansk) (da_DK) |  |
|
||||
| Dutch (Nederlands) (nl_NL) |  |
|
||||
| -------------------------------------------- | -------------------------------------- |
|
||||
| Arabic (العربية) (ar_AR) |  |
|
||||
| Azerbaijani (Azərbaycan Dili) (az_AZ) |  |
|
||||
| Basque (Euskara) (eu_ES) |  |
|
||||
| Bulgarian (Български) (bg_BG) |  |
|
||||
| Catalan (Català) (ca_CA) |  |
|
||||
| Croatian (Hrvatski) (hr_HR) |  |
|
||||
| Czech (Česky) (cs_CZ) |  |
|
||||
| Danish (Dansk) (da_DK) |  |
|
||||
| Dutch (Nederlands) (nl_NL) |  |
|
||||
| English (English) (en_GB) |  |
|
||||
| English (US) (en_US) |  |
|
||||
| French (Français) (fr_FR) |  |
|
||||
| German (Deutsch) (de_DE) |  |
|
||||
| Greek (Ελληνικά) (el_GR) |  |
|
||||
| Hindi (हिंदी) (hi_IN) |  |
|
||||
| Hungarian (Magyar) (hu_HU) |  |
|
||||
| Indonesian (Bahasa Indonesia) (id_ID) |  |
|
||||
| Irish (Gaeilge) (ga_IE) |  |
|
||||
| Italian (Italiano) (it_IT) |  |
|
||||
| Japanese (日本語) (ja_JP) |  |
|
||||
| Korean (한국어) (ko_KR) |  |
|
||||
| Norwegian (Norsk) (no_NB) |  |
|
||||
| Persian (فارسی) (fa_IR) |  |
|
||||
| Polish (Polski) (pl_PL) |  |
|
||||
| Portuguese (Português) (pt_PT) |  |
|
||||
| Portuguese Brazilian (Português) (pt_BR) |  |
|
||||
| Romanian (Română) (ro_RO) |  |
|
||||
| Russian (Русский) (ru_RU) |  |
|
||||
| French (Français) (fr_FR) |  |
|
||||
| German (Deutsch) (de_DE) |  |
|
||||
| Greek (Ελληνικά) (el_GR) |  |
|
||||
| Hindi (हिंदी) (hi_IN) |  |
|
||||
| Hungarian (Magyar) (hu_HU) |  |
|
||||
| Indonesian (Bahasa Indonesia) (id_ID) |  |
|
||||
| Irish (Gaeilge) (ga_IE) |  |
|
||||
| Italian (Italiano) (it_IT) |  |
|
||||
| Japanese (日本語) (ja_JP) |  |
|
||||
| Korean (한국어) (ko_KR) |  |
|
||||
| Norwegian (Norsk) (no_NB) |  |
|
||||
| Persian (فارسی) (fa_IR) |  |
|
||||
| Polish (Polski) (pl_PL) |  |
|
||||
| Portuguese (Português) (pt_PT) |  |
|
||||
| Portuguese Brazilian (Português) (pt_BR) |  |
|
||||
| Romanian (Română) (ro_RO) |  |
|
||||
| Russian (Русский) (ru_RU) |  |
|
||||
| Serbian Latin alphabet (Srpski) (sr_LATN_RS) |  |
|
||||
| Simplified Chinese (简体中文) (zh_CN) |  |
|
||||
| Slovakian (Slovensky) (sk_SK) |  |
|
||||
| Slovenian (Slovenščina) (sl_SI) |  |
|
||||
| Spanish (Español) (es_ES) |  |
|
||||
| Swedish (Svenska) (sv_SE) |  |
|
||||
| Thai (ไทย) (th_TH) |  |
|
||||
| Tibetan (བོད་ཡིག་) (bo_CN) |  |
|
||||
| Traditional Chinese (繁體中文) (zh_TW) |  |
|
||||
| Turkish (Türkçe) (tr_TR) |  |
|
||||
| Ukrainian (Українська) (uk_UA) |  |
|
||||
| Vietnamese (Tiếng Việt) (vi_VN) |  |
|
||||
| Malayalam (മലയാളം) (ml_IN) |  |
|
||||
| Simplified Chinese (简体中文) (zh_CN) |  |
|
||||
| Slovakian (Slovensky) (sk_SK) |  |
|
||||
| Slovenian (Slovenščina) (sl_SI) |  |
|
||||
| Spanish (Español) (es_ES) |  |
|
||||
| Swedish (Svenska) (sv_SE) |  |
|
||||
| Thai (ไทย) (th_TH) |  |
|
||||
| Tibetan (བོད་ཡིག་) (bo_CN) |  |
|
||||
| Traditional Chinese (繁體中文) (zh_TW) |  |
|
||||
| Turkish (Türkçe) (tr_TR) |  |
|
||||
| Ukrainian (Українська) (uk_UA) |  |
|
||||
| Vietnamese (Tiếng Việt) (vi_VN) |  |
|
||||
| Malayalam (മലയാളം) (ml_IN) |  |
|
||||
|
||||
## Stirling PDF Enterprise
|
||||
|
||||
|
||||
@@ -167,10 +167,6 @@
|
||||
{
|
||||
"moduleName": ".*",
|
||||
"moduleLicense": "The W3C License"
|
||||
},
|
||||
{
|
||||
"moduleName": ".*",
|
||||
"moduleLicense": "UnRar License"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,269 +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.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());
|
||||
|
||||
} 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();
|
||||
|
||||
// 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())) {
|
||||
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())
|
||||
|| hostLower.endsWith(
|
||||
"." + domain.toLowerCase()));
|
||||
|
||||
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,258 +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 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.createIllegalArgumentException(
|
||||
"error.invalidFormat",
|
||||
"Invalid or corrupted CBR/RAR archive. "
|
||||
+ "The file may be corrupted, use an unsupported RAR format (RAR5+), "
|
||||
+ "or may not be a valid RAR archive. "
|
||||
+ "Please ensure the file is a valid RAR archive.");
|
||||
} catch (RarException e) {
|
||||
log.warn("Failed to open CBR/RAR archive: {}", e.getMessage());
|
||||
String errorMessage;
|
||||
String exMessage = e.getMessage() != null ? e.getMessage() : "";
|
||||
|
||||
if (exMessage.contains("encrypted")) {
|
||||
errorMessage = "Encrypted CBR/RAR archives are not supported.";
|
||||
} else if (exMessage.isEmpty()) {
|
||||
errorMessage =
|
||||
"Invalid CBR/RAR archive. "
|
||||
+ "The file may be encrypted, corrupted, or use an unsupported format.";
|
||||
} else {
|
||||
errorMessage =
|
||||
"Invalid CBR/RAR archive: "
|
||||
+ exMessage
|
||||
+ ". The file may be encrypted, corrupted, or use an unsupported format.";
|
||||
}
|
||||
throw ExceptionUtils.createIllegalArgumentException(
|
||||
"error.invalidFormat", errorMessage);
|
||||
} 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;
|
||||
}
|
||||
}
|
||||
|
||||
return pdfBytes;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void validateCbrFile(MultipartFile file) {
|
||||
if (file == null || file.isEmpty()) {
|
||||
throw new IllegalArgumentException("File cannot be null or empty");
|
||||
}
|
||||
|
||||
String filename = file.getOriginalFilename();
|
||||
if (filename == null) {
|
||||
throw new IllegalArgumentException("File must have a name");
|
||||
}
|
||||
|
||||
String extension = FilenameUtils.getExtension(filename).toLowerCase();
|
||||
if (!"cbr".equals(extension) && !"rar".equals(extension)) {
|
||||
throw new IllegalArgumentException("File must be a CBR or RAR archive");
|
||||
}
|
||||
}
|
||||
|
||||
public boolean isCbrFile(MultipartFile file) {
|
||||
String filename = file.getOriginalFilename();
|
||||
if (filename == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
String extension = FilenameUtils.getExtension(filename).toLowerCase();
|
||||
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,231 +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.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 new IllegalArgumentException("Archive is empty or invalid ZIP");
|
||||
}
|
||||
} catch (IOException e) {
|
||||
throw new IllegalArgumentException("Invalid CBZ/ZIP archive", 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 new IllegalArgumentException("No valid images found in the CBZ file");
|
||||
}
|
||||
|
||||
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 new IllegalArgumentException(
|
||||
"No images could be processed from the CBZ file");
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
return pdfBytes;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void validateCbzFile(MultipartFile file) {
|
||||
if (file == null || file.isEmpty()) {
|
||||
throw new IllegalArgumentException("File cannot be null or empty");
|
||||
}
|
||||
|
||||
String filename = file.getOriginalFilename();
|
||||
if (filename == null) {
|
||||
throw new IllegalArgumentException("File must have a name");
|
||||
}
|
||||
|
||||
String extension = FilenameUtils.getExtension(filename).toLowerCase();
|
||||
if (!"cbz".equals(extension) && !"zip".equals(extension)) {
|
||||
throw new IllegalArgumentException("File must be a CBZ or ZIP archive");
|
||||
}
|
||||
}
|
||||
|
||||
public boolean isCbzFile(MultipartFile file) {
|
||||
String filename = file.getOriginalFilename();
|
||||
if (filename == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
String extension = FilenameUtils.getExtension(filename).toLowerCase();
|
||||
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();
|
||||
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,301 +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 {
|
||||
switch (algorithm.toUpperCase(Locale.ROOT)) {
|
||||
case "CRC32":
|
||||
return checksumChecksum(is, new CRC32());
|
||||
case "ADLER32":
|
||||
return checksumChecksum(is, new Adler32());
|
||||
default:
|
||||
return 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 {
|
||||
switch (algorithm.toUpperCase(Locale.ROOT)) {
|
||||
case "CRC32":
|
||||
return Base64.getEncoder().encodeToString(checksumChecksumBytes(is, new CRC32()));
|
||||
case "ADLER32":
|
||||
return Base64.getEncoder().encodeToString(checksumChecksumBytes(is, new Adler32()));
|
||||
default:
|
||||
return 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("%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("%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("%02x", b));
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
}
|
||||
@@ -1,69 +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 =
|
||||
Boolean.TRUE.equals(applicationProperties.getSystem().getDisableSanitize());
|
||||
return disableSanitize ? html : POLICY.sanitize(html);
|
||||
}
|
||||
}
|
||||
@@ -1,689 +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.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().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() : 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().startsWith(headerName.toLowerCase())) {
|
||||
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();
|
||||
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();
|
||||
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().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().contains(DISPOSITION_ATTACHMENT) && !filename.isEmpty())
|
||||
|| (!filename.isEmpty() && !contentType.toLowerCase().startsWith("text/"))
|
||||
|| (contentType.toLowerCase().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().contains("filename*=")) {
|
||||
int filenameStarStart = disposition.toLowerCase().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().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,631 +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 new IllegalArgumentException("EML file is empty or null");
|
||||
}
|
||||
|
||||
if (isInvalidEmlFormat(emlBytes)) {
|
||||
throw new IllegalArgumentException("Invalid EML file format");
|
||||
}
|
||||
}
|
||||
|
||||
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(
|
||||
"""
|
||||
<!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(
|
||||
"""
|
||||
<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(
|
||||
"<div><strong>CC:</strong> %s</div>\n",
|
||||
sanitizeText(content.getCc(), customHtmlSanitizer)));
|
||||
}
|
||||
|
||||
if (content.getBcc() != null && !content.getBcc().trim().isEmpty()) {
|
||||
html.append(
|
||||
String.format(
|
||||
"<div><strong>BCC:</strong> %s</div>\n",
|
||||
sanitizeText(content.getBcc(), customHtmlSanitizer)));
|
||||
}
|
||||
|
||||
if (content.getDate() != null) {
|
||||
html.append(
|
||||
String.format(
|
||||
"<div><strong>Date:</strong> %s</div>\n",
|
||||
PdfAttachmentHandler.formatEmailDate(content.getDate())));
|
||||
} else if (content.getDateString() != null && !content.getDateString().trim().isEmpty()) {
|
||||
html.append(
|
||||
String.format(
|
||||
"<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(
|
||||
"<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(
|
||||
"""
|
||||
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(
|
||||
"""
|
||||
<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();
|
||||
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();
|
||||
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("&", "&")
|
||||
.replace("<", "<")
|
||||
.replace(">", ">")
|
||||
.replace("\"", """)
|
||||
.replace("'", "'");
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -1,971 +0,0 @@
|
||||
package stirling.software.common.util;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.FileOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.net.*;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.*;
|
||||
import java.nio.file.attribute.BasicFileAttributes;
|
||||
import java.security.MessageDigest;
|
||||
import java.util.*;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
import org.springframework.core.io.ClassPathResource;
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.core.io.ResourceLoader;
|
||||
import org.springframework.core.io.support.ResourcePatternUtils;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import com.fathzer.soft.javaluator.DoubleEvaluator;
|
||||
|
||||
import io.github.pixee.security.HostValidator;
|
||||
import io.github.pixee.security.Urls;
|
||||
|
||||
import lombok.experimental.UtilityClass;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.configuration.InstallationPathConfig;
|
||||
|
||||
@Slf4j
|
||||
@UtilityClass
|
||||
public class GeneralUtils {
|
||||
|
||||
private final Set<String> DEFAULT_VALID_SCRIPTS = Set.of("png_to_webp.py", "split_photos.py");
|
||||
private final Set<String> DEFAULT_VALID_PIPELINE =
|
||||
Set.of(
|
||||
"OCR images.json",
|
||||
"Prepare-pdfs-for-email.json",
|
||||
"split-rotate-auto-rename.json");
|
||||
|
||||
private final String DEFAULT_WEBUI_CONFIGS_DIR = "defaultWebUIConfigs";
|
||||
private final String PYTHON_SCRIPTS_DIR = "python";
|
||||
private final RegexPatternUtils patternCache = RegexPatternUtils.getInstance();
|
||||
// Valid size units used for convertSizeToBytes validation and parsing
|
||||
private final Set<String> VALID_SIZE_UNITS = Set.of("B", "KB", "MB", "GB", "TB");
|
||||
|
||||
/*
|
||||
* Converts a MultipartFile to a regular File with improved performance and security.
|
||||
*
|
||||
* @param multipartFile the multipart file to convert
|
||||
* @return temporary File containing the multipart file data
|
||||
* @throws IOException if I/O error occurs during conversion
|
||||
* @throws IllegalArgumentException if file exceeds maximum allowed size
|
||||
*/
|
||||
public File convertMultipartFileToFile(MultipartFile multipartFile) throws IOException {
|
||||
String customTempDir = System.getenv("STIRLING_TEMPFILES_DIRECTORY");
|
||||
if (customTempDir == null || customTempDir.isEmpty()) {
|
||||
customTempDir = System.getProperty("stirling.tempfiles.directory");
|
||||
}
|
||||
|
||||
File tempFile;
|
||||
|
||||
if (customTempDir != null && !customTempDir.isEmpty()) {
|
||||
Path tempDir = Path.of(customTempDir);
|
||||
if (!Files.exists(tempDir)) {
|
||||
Files.createDirectories(tempDir);
|
||||
}
|
||||
tempFile = Files.createTempFile(tempDir, "stirling-pdf-", null).toFile();
|
||||
} else {
|
||||
Path tempDir = Path.of(System.getProperty("java.io.tmpdir"), "stirling-pdf");
|
||||
if (!Files.exists(tempDir)) {
|
||||
Files.createDirectories(tempDir);
|
||||
}
|
||||
tempFile = Files.createTempFile(tempDir, "stirling-pdf-", null).toFile();
|
||||
}
|
||||
|
||||
try (InputStream inputStream = multipartFile.getInputStream();
|
||||
FileOutputStream outputStream = new FileOutputStream(tempFile)) {
|
||||
|
||||
byte[] buffer = new byte[8192];
|
||||
int bytesRead;
|
||||
|
||||
while ((bytesRead = inputStream.read(buffer)) != -1) {
|
||||
outputStream.write(buffer, 0, bytesRead);
|
||||
}
|
||||
}
|
||||
return tempFile;
|
||||
}
|
||||
|
||||
/*
|
||||
* Gets the configured temporary directory, creating it if necessary.
|
||||
*
|
||||
* @return Path to the temporary directory
|
||||
* @throws IOException if directory creation fails
|
||||
*/
|
||||
private Path getTempDirectory() throws IOException {
|
||||
String customTempDir = System.getenv("STIRLING_TEMPFILES_DIRECTORY");
|
||||
if (customTempDir == null || customTempDir.isEmpty()) {
|
||||
customTempDir = System.getProperty("stirling.tempfiles.directory");
|
||||
}
|
||||
|
||||
Path tempDir;
|
||||
if (customTempDir != null && !customTempDir.isEmpty()) {
|
||||
tempDir = Path.of(customTempDir);
|
||||
} else {
|
||||
tempDir = Path.of(System.getProperty("java.io.tmpdir"), "stirling-pdf");
|
||||
}
|
||||
|
||||
if (!Files.exists(tempDir)) {
|
||||
Files.createDirectories(tempDir);
|
||||
}
|
||||
|
||||
return tempDir;
|
||||
}
|
||||
|
||||
/*
|
||||
* Remove file extension
|
||||
*
|
||||
* <p>Uses fast string operations for common cases (valid extensions) and falls back to
|
||||
* optimized regex for edge cases (no extension, hidden files, etc.).
|
||||
*
|
||||
* <ul>
|
||||
* <li>String operations avoid regex engine overhead for common cases
|
||||
* <li>Cached pattern compilation eliminates recompilation costs
|
||||
* <li>Fresh Matcher instances ensure thread safety
|
||||
* </ul>
|
||||
*
|
||||
* @param filename the filename to process, may be null
|
||||
* @return filename without extension, or "default" if input is null
|
||||
*/
|
||||
public String removeExtension(String filename) {
|
||||
if (filename == null) {
|
||||
return "default";
|
||||
}
|
||||
|
||||
if (filename.isEmpty()) {
|
||||
return filename;
|
||||
}
|
||||
|
||||
int dotIndex = filename.lastIndexOf('.');
|
||||
if (dotIndex > 0 && dotIndex < filename.length() - 1) {
|
||||
return filename.substring(0, dotIndex);
|
||||
}
|
||||
|
||||
if (dotIndex == 0 || dotIndex == filename.length() - 1 || dotIndex == -1) {
|
||||
return filename;
|
||||
}
|
||||
|
||||
Pattern pattern = patternCache.getPattern(RegexPatternUtils.getExtensionRegex());
|
||||
Matcher matcher = pattern.matcher(filename);
|
||||
return matcher.find() ? matcher.replaceFirst("") : filename;
|
||||
}
|
||||
|
||||
/*
|
||||
* Append suffix to base name with null safety.
|
||||
*
|
||||
* @param baseName the base filename, null becomes "default"
|
||||
* @param suffix the suffix to append, null becomes empty string
|
||||
* @return concatenated string with null safety
|
||||
*/
|
||||
public String appendSuffix(String baseName, String suffix) {
|
||||
return (baseName == null ? "default" : baseName) + (suffix != null ? suffix : "");
|
||||
}
|
||||
|
||||
/*
|
||||
* Generate a PDF filename by removing extension from first file and adding suffix.
|
||||
*
|
||||
* <p>High-level utility method for common PDF naming scenarios. Handles null safety and uses
|
||||
* extension removal.
|
||||
*
|
||||
* @param firstFilename the filename of the first file being, may be null
|
||||
* @param suffix the suffix to append (e.g., "_merged.pdf")
|
||||
* @return filename with suffix, or default name if input is null
|
||||
*/
|
||||
public String generateFilename(String firstFilename, String suffix) {
|
||||
String baseName = removeExtension(firstFilename);
|
||||
return appendSuffix(baseName, suffix);
|
||||
}
|
||||
|
||||
/*
|
||||
* Process a list of filenames by removing extensions and adding suffix.
|
||||
*
|
||||
* <p>Efficiently processes multiple filenames using streaming operations and bulk operations
|
||||
* where possible. Handles null safety for both input list and individual filenames.
|
||||
*
|
||||
* @param filenames the list of filenames to process, may be null
|
||||
* @param suffix the suffix to append to each processed filename
|
||||
* @param processor consumer to handle each processed filename, may be null
|
||||
*/
|
||||
public void processFilenames(
|
||||
List<String> filenames, String suffix, java.util.function.Consumer<String> processor) {
|
||||
if (filenames == null || processor == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
filenames.stream()
|
||||
.map(filename -> appendSuffix(removeExtension(filename), suffix))
|
||||
.forEach(processor);
|
||||
}
|
||||
|
||||
/*
|
||||
* Extract title from filename by removing extension, with fallback handling.
|
||||
*
|
||||
* <p>Returns "Untitled" for null or empty filenames, otherwise removes the extension using the
|
||||
* optimized removeExtension method.
|
||||
*
|
||||
* @param filename the filename to extract title from, may be null
|
||||
* @return the title without extension, or "Untitled" if input is null/empty
|
||||
*/
|
||||
public String getTitleFromFilename(String filename) {
|
||||
if (filename == null || filename.isEmpty()) {
|
||||
return "Untitled";
|
||||
}
|
||||
return removeExtension(filename);
|
||||
}
|
||||
|
||||
public void deleteDirectory(Path path) throws IOException {
|
||||
Files.walkFileTree(
|
||||
path,
|
||||
new SimpleFileVisitor<>() {
|
||||
@Override
|
||||
public FileVisitResult visitFile(Path file, BasicFileAttributes attrs)
|
||||
throws IOException {
|
||||
Files.deleteIfExists(file);
|
||||
return FileVisitResult.CONTINUE;
|
||||
}
|
||||
|
||||
@Override
|
||||
public FileVisitResult postVisitDirectory(Path dir, IOException exc)
|
||||
throws IOException {
|
||||
Files.deleteIfExists(dir);
|
||||
return FileVisitResult.CONTINUE;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public String convertToFileName(String name) {
|
||||
if (name == null) return "_";
|
||||
StringBuilder safeNameBuilder = new StringBuilder(name.length());
|
||||
for (int i = 0; i < name.length(); i++) {
|
||||
char c = name.charAt(i);
|
||||
if (Character.isLetterOrDigit(c)) {
|
||||
safeNameBuilder.append(c);
|
||||
} else {
|
||||
safeNameBuilder.append('_');
|
||||
}
|
||||
}
|
||||
String safeName = safeNameBuilder.toString();
|
||||
if (safeName.length() > 50) {
|
||||
safeName = safeName.substring(0, 50);
|
||||
}
|
||||
return safeName;
|
||||
}
|
||||
|
||||
// Get resources from a location pattern
|
||||
public Resource[] getResourcesFromLocationPattern(
|
||||
String locationPattern, ResourceLoader resourceLoader) throws Exception {
|
||||
// Normalize the path for file resources
|
||||
String pattern = locationPattern;
|
||||
if (pattern.startsWith("file:")) {
|
||||
String rawPath = pattern.substring(5).replace("\\*", "").replace("/*", "");
|
||||
Path normalizePath = Paths.get(rawPath).normalize();
|
||||
pattern = "file:" + normalizePath.toString().replace("\\", "/") + "/*";
|
||||
}
|
||||
return ResourcePatternUtils.getResourcePatternResolver(resourceLoader)
|
||||
.getResources(pattern);
|
||||
}
|
||||
|
||||
public boolean isValidURL(String urlStr) {
|
||||
try {
|
||||
Urls.create(
|
||||
urlStr, Urls.HTTP_PROTOCOLS, HostValidator.DENY_COMMON_INFRASTRUCTURE_TARGETS);
|
||||
return true;
|
||||
} catch (MalformedURLException e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* Checks if a URL is reachable with proper timeout configuration and error handling.
|
||||
*
|
||||
* @param urlStr the URL string to check
|
||||
* @return true if URL is reachable, false otherwise
|
||||
*/
|
||||
public boolean isURLReachable(String urlStr) {
|
||||
return isURLReachable(urlStr, 5000, 5000);
|
||||
}
|
||||
|
||||
/*
|
||||
* Checks if a URL is reachable with configurable timeouts.
|
||||
*
|
||||
* @param urlStr the URL string to check
|
||||
* @param connectTimeout connection timeout in milliseconds
|
||||
* @param readTimeout read timeout in milliseconds
|
||||
* @return true if URL is reachable, false otherwise
|
||||
*/
|
||||
public boolean isURLReachable(String urlStr, int connectTimeout, int readTimeout) {
|
||||
try {
|
||||
// Parse the URL
|
||||
URL url = URI.create(urlStr).toURL();
|
||||
|
||||
// Allow only http and https protocols
|
||||
String protocol = url.getProtocol();
|
||||
if (!"http".equals(protocol) && !"https".equals(protocol)) {
|
||||
return false; // Disallow other protocols
|
||||
}
|
||||
|
||||
// Check if the host is a local address
|
||||
String host = url.getHost();
|
||||
if (isLocalAddress(host)) {
|
||||
return false; // Exclude local addresses
|
||||
}
|
||||
|
||||
// Check if the URL is reachable
|
||||
HttpURLConnection connection = (HttpURLConnection) url.openConnection();
|
||||
connection.setRequestMethod("HEAD");
|
||||
connection.setConnectTimeout(connectTimeout);
|
||||
connection.setReadTimeout(readTimeout);
|
||||
connection.setInstanceFollowRedirects(false); // Security: prevent redirect loops
|
||||
|
||||
int responseCode = connection.getResponseCode();
|
||||
return (200 <= responseCode && responseCode <= 399);
|
||||
} catch (Exception e) {
|
||||
log.debug("URL {} is not reachable: {}", urlStr, e.getMessage());
|
||||
return false; // Return false in case of any exception
|
||||
}
|
||||
}
|
||||
|
||||
private boolean isLocalAddress(String host) {
|
||||
try {
|
||||
// Resolve DNS to IP address
|
||||
InetAddress address = InetAddress.getByName(host);
|
||||
|
||||
// Check for local addresses
|
||||
return address.isAnyLocalAddress()
|
||||
|| // Matches 0.0.0.0 or similar
|
||||
address.isLoopbackAddress()
|
||||
|| // Matches 127.0.0.1 or ::1
|
||||
address.isSiteLocalAddress()
|
||||
|| // Matches private IPv4 ranges: 192.168.x.x, 10.x.x.x, 172.16.x.x to
|
||||
// 172.31.x.x
|
||||
address.getHostAddress()
|
||||
.startsWith("fe80:"); // Matches link-local IPv6 addresses
|
||||
} catch (Exception e) {
|
||||
return false; // Return false for invalid or unresolved addresses
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* Improved multipart file conversion using the shared helper method.
|
||||
*
|
||||
* @param multipart the multipart file to convert
|
||||
* @return temporary File containing the multipart file data
|
||||
* @throws IOException if I/O error occurs during conversion
|
||||
*/
|
||||
public File multipartToFile(MultipartFile multipart) throws IOException {
|
||||
Path tempFile = Files.createTempFile("overlay-", ".pdf");
|
||||
try (InputStream in = multipart.getInputStream();
|
||||
FileOutputStream out = new FileOutputStream(tempFile.toFile())) {
|
||||
byte[] buffer = new byte[1024];
|
||||
int bytesRead;
|
||||
while ((bytesRead = in.read(buffer)) != -1) {
|
||||
out.write(buffer, 0, bytesRead);
|
||||
}
|
||||
}
|
||||
return tempFile.toFile();
|
||||
}
|
||||
|
||||
/*
|
||||
* Supports TB/PB units and provides detailed error messages.
|
||||
*
|
||||
* @param sizeStr the size string to convert (e.g., "100MB", "1.5GB")
|
||||
* @param defaultUnit the default unit to assume if none specified ("MB", "GB", etc.)
|
||||
* @return size in bytes, or null if parsing fails
|
||||
* @throws IllegalArgumentException if defaultUnit is invalid
|
||||
*/
|
||||
public Long convertSizeToBytes(String sizeStr, String defaultUnit) {
|
||||
if (sizeStr == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (defaultUnit != null && !isValidSizeUnit(defaultUnit)) {
|
||||
throw new IllegalArgumentException("Invalid default unit: " + defaultUnit);
|
||||
}
|
||||
|
||||
sizeStr = sizeStr.trim().toUpperCase();
|
||||
sizeStr = sizeStr.replace(",", ".").replace(" ", "");
|
||||
|
||||
try {
|
||||
if (sizeStr.endsWith("TB")) {
|
||||
return (long)
|
||||
(Double.parseDouble(sizeStr.substring(0, sizeStr.length() - 2))
|
||||
* 1024L
|
||||
* 1024L
|
||||
* 1024L
|
||||
* 1024L);
|
||||
} else if (sizeStr.endsWith("GB")) {
|
||||
return (long)
|
||||
(Double.parseDouble(sizeStr.substring(0, sizeStr.length() - 2))
|
||||
* 1024L
|
||||
* 1024L
|
||||
* 1024L);
|
||||
} else if (sizeStr.endsWith("MB")) {
|
||||
return (long)
|
||||
(Double.parseDouble(sizeStr.substring(0, sizeStr.length() - 2))
|
||||
* 1024L
|
||||
* 1024L);
|
||||
} else if (sizeStr.endsWith("KB")) {
|
||||
return (long)
|
||||
(Double.parseDouble(sizeStr.substring(0, sizeStr.length() - 2)) * 1024L);
|
||||
} else if (!sizeStr.isEmpty() && sizeStr.charAt(sizeStr.length() - 1) == 'B') {
|
||||
return Long.parseLong(sizeStr.substring(0, sizeStr.length() - 1));
|
||||
} else {
|
||||
// Use provided default unit or fall back to MB
|
||||
String unit = defaultUnit != null ? defaultUnit.toUpperCase() : "MB";
|
||||
double value = Double.parseDouble(sizeStr);
|
||||
return switch (unit) {
|
||||
case "TB" -> (long) (value * 1024L * 1024L * 1024L * 1024L);
|
||||
case "GB" -> (long) (value * 1024L * 1024L * 1024L);
|
||||
case "MB" -> (long) (value * 1024L * 1024L);
|
||||
case "KB" -> (long) (value * 1024L);
|
||||
case "B" -> (long) value;
|
||||
default -> (long) (value * 1024L * 1024L); // Default to MB
|
||||
};
|
||||
}
|
||||
} catch (NumberFormatException e) {
|
||||
log.warn("Failed to parse size string '{}': {}", sizeStr, e.getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* Converts size string to bytes using MB as default unit.
|
||||
*
|
||||
* @param sizeStr the size string to convert
|
||||
* @return size in bytes, or null if parsing fails
|
||||
*/
|
||||
public Long convertSizeToBytes(String sizeStr) {
|
||||
return convertSizeToBytes(sizeStr, "MB");
|
||||
}
|
||||
|
||||
/* Validates if a string represents a valid size unit. */
|
||||
private boolean isValidSizeUnit(String unit) {
|
||||
// Use a precomputed Set for O(1) lookup, normalize using a locale-safe toUpperCase
|
||||
return unit != null && VALID_SIZE_UNITS.contains(unit.toUpperCase(Locale.ROOT));
|
||||
}
|
||||
|
||||
/* Enhanced byte formatting with TB/PB support and better precision. */
|
||||
public String formatBytes(long bytes) {
|
||||
if (bytes < 0) {
|
||||
return "Invalid size";
|
||||
}
|
||||
|
||||
if (bytes < 1024) {
|
||||
return bytes + " B";
|
||||
} else if (bytes < 1024L * 1024L) {
|
||||
return String.format(Locale.US, "%.2f KB", bytes / 1024.0);
|
||||
} else if (bytes < 1024L * 1024L * 1024L) {
|
||||
return String.format(Locale.US, "%.2f MB", bytes / (1024.0 * 1024.0));
|
||||
} else if (bytes < 1024L * 1024L * 1024L * 1024L) {
|
||||
return String.format(Locale.US, "%.2f GB", bytes / (1024.0 * 1024.0 * 1024.0));
|
||||
} else {
|
||||
return String.format(Locale.US, "%.2f TB", bytes / (1024.0 * 1024.0 * 1024.0 * 1024.0));
|
||||
}
|
||||
}
|
||||
|
||||
public List<Integer> parsePageList(String pages, int totalPages, boolean oneBased) {
|
||||
if (pages == null) {
|
||||
return List.of(1); // Default to first page if input is null
|
||||
}
|
||||
try {
|
||||
return parsePageList(pages.split(","), totalPages, oneBased);
|
||||
} catch (NumberFormatException e) {
|
||||
return List.of(1); // Default to first page if input is invalid
|
||||
}
|
||||
}
|
||||
|
||||
public List<Integer> parsePageList(String[] pages, int totalPages) {
|
||||
return parsePageList(pages, totalPages, false);
|
||||
}
|
||||
|
||||
public List<Integer> parsePageList(String[] pages, int totalPages, boolean oneBased) {
|
||||
List<Integer> result = new ArrayList<>();
|
||||
int offset = oneBased ? 1 : 0;
|
||||
for (String page : pages) {
|
||||
if ("all".equalsIgnoreCase(page)) {
|
||||
|
||||
for (int i = 0; i < totalPages; i++) {
|
||||
result.add(i + offset);
|
||||
}
|
||||
} else if (page.contains(",")) {
|
||||
// Split the string into parts, could be single pages or ranges
|
||||
String[] parts = page.split(",");
|
||||
for (String part : parts) {
|
||||
result.addAll(handlePart(part, totalPages, offset));
|
||||
}
|
||||
} else {
|
||||
result.addAll(handlePart(page, totalPages, offset));
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/*
|
||||
* Enhanced mathematical expression evaluation with bounds checking and timeout protection.
|
||||
*
|
||||
* @param expression the mathematical expression containing 'n'
|
||||
* @param maxValue the maximum value for 'n' and result bounds
|
||||
* @return list of valid page numbers
|
||||
* @throws IllegalArgumentException if expression is invalid or unsafe
|
||||
*/
|
||||
public List<Integer> evaluateNFunc(String expression, int maxValue) {
|
||||
if (expression == null || expression.trim().isEmpty()) {
|
||||
throw new IllegalArgumentException("Expression cannot be null or empty");
|
||||
}
|
||||
|
||||
if (maxValue <= 0 || maxValue > 10000) {
|
||||
throw new IllegalArgumentException("maxValue must be between 1 and 10000 for safety");
|
||||
}
|
||||
|
||||
List<Integer> results = new ArrayList<>();
|
||||
DoubleEvaluator evaluator = new DoubleEvaluator();
|
||||
|
||||
// Validate the expression format
|
||||
if (!RegexPatternUtils.getInstance()
|
||||
.getMathExpressionPattern()
|
||||
.matcher(expression.trim())
|
||||
.matches()) {
|
||||
throw new IllegalArgumentException("Invalid expression format: " + expression);
|
||||
}
|
||||
|
||||
for (int n = 1; n <= maxValue; n++) {
|
||||
try {
|
||||
// Replace 'n' with the current value of n, correctly handling numbers before 'n'
|
||||
String sanitizedExpression = sanitizeNFunction(expression.trim(), n);
|
||||
Double result = evaluator.evaluate(sanitizedExpression);
|
||||
|
||||
// Check if the result is null or not within bounds
|
||||
if (result == null || !Double.isFinite(result)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
int intResult = result.intValue();
|
||||
if (intResult > 0 && intResult <= maxValue) {
|
||||
results.add(intResult);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.debug(
|
||||
"Failed to evaluate expression '{}' for n={}: {}",
|
||||
expression,
|
||||
n,
|
||||
e.getMessage());
|
||||
// Continue with next value instead of breaking
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
private String sanitizeNFunction(String expression, int nValue) {
|
||||
// Remove all spaces using a specialized character removal
|
||||
StringBuilder sb = new StringBuilder(expression.length());
|
||||
for (int i = 0; i < expression.length(); i++) {
|
||||
char c = expression.charAt(i);
|
||||
if (c != ' ') {
|
||||
sb.append(c);
|
||||
}
|
||||
}
|
||||
String sanitizedExpression = sb.toString();
|
||||
String multiplyByOpeningRoundBracketPattern =
|
||||
"([0-9n)])\\("; // example: n(n-1), 9(n-1), (n-1)(n-2)
|
||||
sanitizedExpression =
|
||||
sanitizedExpression.replaceAll(multiplyByOpeningRoundBracketPattern, "$1*(");
|
||||
|
||||
String multiplyByClosingRoundBracketPattern =
|
||||
"\\)([0-9n)])"; // example: (n-1)n, (n-1)9, (n-1)(n-2)
|
||||
sanitizedExpression =
|
||||
sanitizedExpression.replaceAll(multiplyByClosingRoundBracketPattern, ")*$1");
|
||||
|
||||
sanitizedExpression = insertMultiplicationBeforeN(sanitizedExpression, nValue);
|
||||
return sanitizedExpression;
|
||||
}
|
||||
|
||||
private String insertMultiplicationBeforeN(String expression, int nValue) {
|
||||
// Insert multiplication between a number and 'n' (e.g., "4n" becomes "4*n") using a loop
|
||||
StringBuilder sb = new StringBuilder(expression.length() + 4); // +4 for possible extra '*'
|
||||
for (int i = 0; i < expression.length(); i++) {
|
||||
char c = expression.charAt(i);
|
||||
sb.append(c);
|
||||
if (Character.isDigit(c)
|
||||
&& i + 1 < expression.length()
|
||||
&& expression.charAt(i + 1) == 'n') {
|
||||
sb.append('*');
|
||||
}
|
||||
}
|
||||
String withMultiplication = sb.toString();
|
||||
withMultiplication = formatConsecutiveNsForNFunction(withMultiplication);
|
||||
// Now replace 'n' with its current value
|
||||
return withMultiplication.replace("n", String.valueOf(nValue));
|
||||
}
|
||||
|
||||
private String formatConsecutiveNsForNFunction(String expression) {
|
||||
String text = expression;
|
||||
// Replace all consecutive 'nn' with 'n*n' until no more 'nn' is found
|
||||
while (text.contains("nn")) {
|
||||
StringBuilder sb = new StringBuilder(text.length() + 2); // +2 for possible extra '*'
|
||||
int i = 0;
|
||||
while (i < text.length()) {
|
||||
if (i < text.length() - 1 && text.charAt(i) == 'n' && text.charAt(i + 1) == 'n') {
|
||||
sb.append("n*n");
|
||||
i += 2;
|
||||
} else {
|
||||
sb.append(text.charAt(i));
|
||||
i++;
|
||||
}
|
||||
}
|
||||
text = sb.toString();
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
private List<Integer> handlePart(String part, int totalPages, int offset) {
|
||||
List<Integer> partResult = new ArrayList<>();
|
||||
|
||||
// First check for n-syntax because it should not be processed as a range
|
||||
if (part.contains("n")) {
|
||||
partResult = evaluateNFunc(part, totalPages);
|
||||
// Adjust the results according to the offset
|
||||
for (int i = 0; i < partResult.size(); i++) {
|
||||
int adjustedValue = partResult.get(i) - 1 + offset;
|
||||
partResult.set(i, adjustedValue);
|
||||
}
|
||||
} else if (part.contains("-")) {
|
||||
// Process ranges only if it's not n-syntax
|
||||
String[] rangeParts = part.split("-");
|
||||
try {
|
||||
int start = Integer.parseInt(rangeParts[0]);
|
||||
int end =
|
||||
(rangeParts.length > 1 && !rangeParts[1].isEmpty())
|
||||
? Integer.parseInt(rangeParts[1])
|
||||
: totalPages;
|
||||
for (int i = start; i <= end; i++) {
|
||||
if (i >= 1 && i <= totalPages) {
|
||||
partResult.add(i - 1 + offset);
|
||||
}
|
||||
}
|
||||
} catch (NumberFormatException e) {
|
||||
log.debug("Invalid range: {}", part);
|
||||
}
|
||||
} else {
|
||||
// This is a single page number
|
||||
try {
|
||||
int pageNum = Integer.parseInt(part.trim());
|
||||
if (pageNum >= 1 && pageNum <= totalPages) {
|
||||
partResult.add(pageNum - 1 + offset);
|
||||
}
|
||||
} catch (NumberFormatException e) {
|
||||
log.debug("Invalid page number: {}", part);
|
||||
}
|
||||
}
|
||||
return partResult;
|
||||
}
|
||||
|
||||
public boolean createDir(String path) {
|
||||
Path folder = Paths.get(path);
|
||||
if (!Files.exists(folder)) {
|
||||
try {
|
||||
Files.createDirectories(folder);
|
||||
} catch (IOException e) {
|
||||
log.error("exception", e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
public boolean isValidUUID(String uuid) {
|
||||
if (uuid == null) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
UUID.fromString(uuid);
|
||||
return true;
|
||||
} catch (IllegalArgumentException e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/*------------------------------------------------------------------------*
|
||||
* Internal Implementation Details *
|
||||
*------------------------------------------------------------------------*/
|
||||
|
||||
public void saveKeyToSettings(String key, Object newValue) throws IOException {
|
||||
String[] keyArray = key.split("\\.");
|
||||
Path settingsPath = Paths.get(InstallationPathConfig.getSettingsPath());
|
||||
YamlHelper settingsYaml = new YamlHelper(settingsPath);
|
||||
settingsYaml.updateValue(Arrays.asList(keyArray), newValue);
|
||||
settingsYaml.saveOverride(settingsPath);
|
||||
}
|
||||
|
||||
/*
|
||||
* Machine fingerprint generation with better error logging and fallbacks.
|
||||
*
|
||||
* @return unique machine fingerprint or "GenericID" if generation fails
|
||||
*/
|
||||
public String generateMachineFingerprint() {
|
||||
try {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
|
||||
// Try to get MAC address from primary network interface
|
||||
InetAddress ip = InetAddress.getLocalHost();
|
||||
NetworkInterface network = NetworkInterface.getByInetAddress(ip);
|
||||
|
||||
if (network == null || network.getHardwareAddress() == null) {
|
||||
// Fallback: iterate through all network interfaces
|
||||
Enumeration<NetworkInterface> networks = NetworkInterface.getNetworkInterfaces();
|
||||
while (networks.hasMoreElements()) {
|
||||
NetworkInterface net = networks.nextElement();
|
||||
if (net.isUp() && !net.isLoopback() && !net.isVirtual()) {
|
||||
byte[] mac = net.getHardwareAddress();
|
||||
if (mac != null && mac.length > 0) {
|
||||
for (byte b : mac) {
|
||||
sb.append(String.format("%02X", b));
|
||||
}
|
||||
break; // Use the first valid network interface
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
byte[] mac = network.getHardwareAddress();
|
||||
if (mac != null) {
|
||||
for (byte b : mac) {
|
||||
sb.append(String.format("%02X", b));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If no MAC address found, use hostname as fallback
|
||||
if (sb.length() == 0) {
|
||||
String hostname = InetAddress.getLocalHost().getHostName();
|
||||
sb.append(hostname != null ? hostname : "unknown-host");
|
||||
log.warn("No MAC address found, using hostname for fingerprint generation");
|
||||
}
|
||||
|
||||
// Hash the collected data for privacy and consistency
|
||||
MessageDigest md = MessageDigest.getInstance("SHA-256");
|
||||
byte[] hash = md.digest(sb.toString().getBytes(StandardCharsets.UTF_8));
|
||||
StringBuilder fingerprint = new StringBuilder();
|
||||
for (byte b : hash) {
|
||||
fingerprint.append(String.format("%02x", b));
|
||||
}
|
||||
|
||||
log.debug("Successfully generated machine fingerprint");
|
||||
return fingerprint.toString();
|
||||
} catch (Exception e) {
|
||||
log.warn("Failed to generate machine fingerprint: {}", e.getMessage());
|
||||
return "GenericID";
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* Extracts the default pipeline configurations from the classpath to the installation path.
|
||||
* Creates directories if needed and copies default JSON files.
|
||||
*
|
||||
* <p>Existing files will be overwritten atomically (when supported). In case of unsupported
|
||||
* atomic moves, falls back to non-atomic replace.
|
||||
*
|
||||
* @throws IOException if an I/O error occurs during file operations
|
||||
*/
|
||||
public void extractPipeline() throws IOException {
|
||||
Path pipelineDir =
|
||||
Paths.get(InstallationPathConfig.getPipelinePath(), DEFAULT_WEBUI_CONFIGS_DIR);
|
||||
Files.createDirectories(pipelineDir);
|
||||
|
||||
for (String name : DEFAULT_VALID_PIPELINE) {
|
||||
if (!Paths.get(name).getFileName().toString().equals(name)) {
|
||||
log.error("Invalid pipeline file name: {}", name);
|
||||
throw new IllegalArgumentException("Invalid pipeline file name: " + name);
|
||||
}
|
||||
Path target = pipelineDir.resolve(name);
|
||||
ClassPathResource res =
|
||||
new ClassPathResource(
|
||||
"static/pipeline/" + DEFAULT_WEBUI_CONFIGS_DIR + "/" + name);
|
||||
if (!res.exists()) {
|
||||
log.error("Resource not found: {}", res.getPath());
|
||||
throw new IOException("Resource not found: " + res.getPath());
|
||||
}
|
||||
copyResourceToFile(res, target);
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* Extracts the specified Python script from the classpath to the installation path. Validates
|
||||
* name and copies file atomically when possible, overwriting existing.
|
||||
*
|
||||
* <p>Existing files will be overwritten atomically (when supported).
|
||||
*
|
||||
* @param scriptName the name of the script to extract
|
||||
* @return the path to the extracted script
|
||||
* @throws IllegalArgumentException if the script name is invalid or not allowed
|
||||
* @throws IOException if an I/O error occurs
|
||||
*/
|
||||
public Path extractScript(String scriptName) throws IOException {
|
||||
// Validate input
|
||||
if (scriptName == null || scriptName.trim().isEmpty()) {
|
||||
throw new IllegalArgumentException("scriptName must not be null or empty");
|
||||
}
|
||||
if (scriptName.contains("..") || scriptName.contains("/")) {
|
||||
throw new IllegalArgumentException(
|
||||
"scriptName must not contain path traversal characters");
|
||||
}
|
||||
if (!Paths.get(scriptName).getFileName().toString().equals(scriptName)) {
|
||||
throw new IllegalArgumentException(
|
||||
"scriptName must not contain path traversal characters");
|
||||
}
|
||||
|
||||
if (!DEFAULT_VALID_SCRIPTS.contains(scriptName)) {
|
||||
throw new IllegalArgumentException(
|
||||
"scriptName must be either 'png_to_webp.py' or 'split_photos.py'");
|
||||
}
|
||||
|
||||
Path scriptsDir = Paths.get(InstallationPathConfig.getScriptsPath(), PYTHON_SCRIPTS_DIR);
|
||||
Files.createDirectories(scriptsDir);
|
||||
|
||||
Path target = scriptsDir.resolve(scriptName);
|
||||
ClassPathResource res =
|
||||
new ClassPathResource("static/" + PYTHON_SCRIPTS_DIR + "/" + scriptName);
|
||||
if (!res.exists()) {
|
||||
log.error("Resource not found: {}", res.getPath());
|
||||
throw new IOException("Resource not found: " + res.getPath());
|
||||
}
|
||||
copyResourceToFile(res, target);
|
||||
return target;
|
||||
}
|
||||
|
||||
/*
|
||||
* Copies a resource from the classpath to a specified target file.
|
||||
*
|
||||
* @param resource the ClassPathResource to copy
|
||||
* @param target the target Path where the resource will be copied
|
||||
* @throws IOException if an I/O error occurs during the copy operation
|
||||
*/
|
||||
private void copyResourceToFile(ClassPathResource resource, Path target) throws IOException {
|
||||
Path dir = target.getParent();
|
||||
Path tmp = Files.createTempFile(dir, target.getFileName().toString(), ".tmp");
|
||||
try (InputStream in = resource.getInputStream()) {
|
||||
Files.copy(in, tmp, StandardCopyOption.REPLACE_EXISTING);
|
||||
try {
|
||||
Files.move(tmp, target, StandardCopyOption.ATOMIC_MOVE);
|
||||
} catch (AtomicMoveNotSupportedException e) {
|
||||
log.warn(
|
||||
"Atomic move not supported, falling back to non-atomic move for {}",
|
||||
target,
|
||||
e);
|
||||
Files.move(tmp, target, StandardCopyOption.REPLACE_EXISTING);
|
||||
}
|
||||
} catch (FileAlreadyExistsException e) {
|
||||
log.debug("File already exists at {}, attempting to replace it.", target);
|
||||
Files.move(tmp, target, StandardCopyOption.REPLACE_EXISTING);
|
||||
} catch (AccessDeniedException e) {
|
||||
log.error("Access denied while attempting to copy resource to {}", target, e);
|
||||
throw e;
|
||||
} catch (FileSystemException e) {
|
||||
log.error("File system error occurred while copying resource to {}", target, e);
|
||||
throw e;
|
||||
} catch (IOException e) {
|
||||
log.error("Failed to copy resource to {}", target, e);
|
||||
throw e;
|
||||
} finally {
|
||||
try {
|
||||
Files.deleteIfExists(tmp);
|
||||
} catch (IOException e) {
|
||||
log.warn("Failed to delete temporary file {}", tmp, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public boolean isVersionHigher(String currentVersion, String compareVersion) {
|
||||
if (currentVersion == null || compareVersion == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Split versions into components
|
||||
String[] current = currentVersion.split("\\.");
|
||||
String[] compare = compareVersion.split("\\.");
|
||||
|
||||
// Get the length of the shorter version array
|
||||
int length = Math.min(current.length, compare.length);
|
||||
|
||||
// Compare each component
|
||||
for (int i = 0; i < length; i++) {
|
||||
int currentPart = Integer.parseInt(current[i]);
|
||||
int comparePart = Integer.parseInt(compare[i]);
|
||||
|
||||
if (currentPart > comparePart) {
|
||||
return true;
|
||||
}
|
||||
if (currentPart < comparePart) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// If all components so far are equal, the longer version is considered higher
|
||||
return current.length > compare.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Optimizes a PDF using Ghostscript with ebook settings for better e-reader compatibility. Uses
|
||||
* -dPDFSETTINGS=/ebook -dFastWebView=true settings to create an optimized PDF.
|
||||
*
|
||||
* @param inputPdfBytes Original PDF as byte array
|
||||
* @return Optimized PDF as byte array
|
||||
* @throws IOException if Ghostscript optimization fails
|
||||
*/
|
||||
public byte[] optimizePdfWithGhostscript(byte[] inputPdfBytes) throws IOException {
|
||||
Path tempInput = null;
|
||||
Path tempOutput = null;
|
||||
|
||||
try {
|
||||
tempInput = Files.createTempFile("gs_input_", ".pdf");
|
||||
tempOutput = Files.createTempFile("gs_output_", ".pdf");
|
||||
|
||||
Files.write(tempInput, inputPdfBytes);
|
||||
|
||||
List<String> command = new ArrayList<>();
|
||||
command.add("gs");
|
||||
command.add("-sDEVICE=pdfwrite");
|
||||
command.add("-dPDFSETTINGS=/ebook");
|
||||
command.add("-dFastWebView=true");
|
||||
command.add("-dNOPAUSE");
|
||||
command.add("-dQUIET");
|
||||
command.add("-dBATCH");
|
||||
command.add("-sOutputFile=" + tempOutput.toString());
|
||||
command.add(tempInput.toString());
|
||||
|
||||
ProcessExecutor.ProcessExecutorResult result =
|
||||
ProcessExecutor.getInstance(ProcessExecutor.Processes.GHOSTSCRIPT)
|
||||
.runCommandWithOutputHandling(command);
|
||||
|
||||
if (result.getRc() != 0) {
|
||||
log.warn(
|
||||
"Ghostscript ebook optimization failed with return code: {}",
|
||||
result.getRc());
|
||||
throw ExceptionUtils.createGhostscriptCompressionException();
|
||||
}
|
||||
|
||||
return Files.readAllBytes(tempOutput);
|
||||
|
||||
} catch (Exception e) {
|
||||
log.warn("Ghostscript ebook optimization failed", e);
|
||||
throw ExceptionUtils.createGhostscriptCompressionException(e);
|
||||
} finally {
|
||||
if (tempInput != null) {
|
||||
try {
|
||||
Files.deleteIfExists(tempInput);
|
||||
} catch (IOException e) {
|
||||
log.warn("Failed to delete temp input file: {}", tempInput, e);
|
||||
}
|
||||
}
|
||||
if (tempOutput != null) {
|
||||
try {
|
||||
Files.deleteIfExists(tempOutput);
|
||||
} catch (IOException e) {
|
||||
log.warn("Failed to delete temp output file: {}", tempOutput, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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().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();
|
||||
|
||||
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,173 +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 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 new IllegalArgumentException("PDF file contains no pages");
|
||||
}
|
||||
|
||||
return createCbrFromPdf(document, dpi);
|
||||
}
|
||||
}
|
||||
|
||||
private static void validatePdfFile(MultipartFile file) {
|
||||
if (file == null || file.isEmpty()) {
|
||||
throw new IllegalArgumentException("File cannot be null or empty");
|
||||
}
|
||||
|
||||
String filename = file.getOriginalFilename();
|
||||
if (filename == null) {
|
||||
throw new IllegalArgumentException("File must have a name");
|
||||
}
|
||||
|
||||
String extension = FilenameUtils.getExtension(filename).toLowerCase();
|
||||
if (!"pdf".equals(extension)) {
|
||||
throw new IllegalArgumentException("File must be a PDF");
|
||||
}
|
||||
}
|
||||
|
||||
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++) {
|
||||
try {
|
||||
BufferedImage image =
|
||||
pdfRenderer.renderImageWithDPI(pageIndex, dpi, ImageType.RGB);
|
||||
|
||||
String imageFilename = String.format("page_%03d.png", pageIndex + 1);
|
||||
Path imagePath = tempDir.resolve(imageFilename);
|
||||
|
||||
ImageIO.write(image, "PNG", imagePath.toFile());
|
||||
generatedImages.add(imagePath);
|
||||
|
||||
} catch (IOException e) {
|
||||
log.warn("Error processing page {}: {}", pageIndex + 1, e.getMessage());
|
||||
} catch (OutOfMemoryError e) {
|
||||
throw ExceptionUtils.createOutOfMemoryDpiException(pageIndex + 1, dpi, e);
|
||||
} catch (NegativeArraySizeException e) {
|
||||
throw ExceptionUtils.createOutOfMemoryDpiException(pageIndex + 1, dpi, e);
|
||||
}
|
||||
}
|
||||
|
||||
if (generatedImages.isEmpty()) {
|
||||
throw new IOException("Failed to render any pages to images for CBR conversion");
|
||||
}
|
||||
|
||||
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 new IOException("RAR command failed: " + result.getMessages());
|
||||
}
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new IOException("RAR command interrupted", e);
|
||||
}
|
||||
|
||||
if (!Files.exists(rarFile)) {
|
||||
throw 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();
|
||||
return "pdf".equals(extension);
|
||||
}
|
||||
}
|
||||
@@ -1,99 +0,0 @@
|
||||
package stirling.software.common.util;
|
||||
|
||||
import java.awt.image.BufferedImage;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
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 new IllegalArgumentException("PDF file contains no pages");
|
||||
}
|
||||
|
||||
return createCbzFromPdf(document, dpi);
|
||||
}
|
||||
}
|
||||
|
||||
private static void validatePdfFile(MultipartFile file) {
|
||||
if (file == null || file.isEmpty()) {
|
||||
throw new IllegalArgumentException("File cannot be null or empty");
|
||||
}
|
||||
|
||||
String filename = file.getOriginalFilename();
|
||||
if (filename == null) {
|
||||
throw new IllegalArgumentException("File must have a name");
|
||||
}
|
||||
|
||||
String extension = FilenameUtils.getExtension(filename).toLowerCase();
|
||||
if (!"pdf".equals(extension)) {
|
||||
throw new IllegalArgumentException("File must be a PDF");
|
||||
}
|
||||
}
|
||||
|
||||
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++) {
|
||||
try {
|
||||
BufferedImage image =
|
||||
pdfRenderer.renderImageWithDPI(pageIndex, dpi, ImageType.RGB);
|
||||
|
||||
String imageFilename = String.format("page_%03d.png", pageIndex + 1);
|
||||
|
||||
ZipEntry zipEntry = new ZipEntry(imageFilename);
|
||||
zipOut.putNextEntry(zipEntry);
|
||||
|
||||
ImageIO.write(image, "PNG", zipOut);
|
||||
zipOut.closeEntry();
|
||||
|
||||
} catch (IOException e) {
|
||||
log.warn("Error processing page {}: {}", pageIndex + 1, e.getMessage());
|
||||
} catch (OutOfMemoryError e) {
|
||||
throw ExceptionUtils.createOutOfMemoryDpiException(pageIndex + 1, dpi, e);
|
||||
} catch (NegativeArraySizeException e) {
|
||||
throw ExceptionUtils.createOutOfMemoryDpiException(pageIndex + 1, dpi, 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();
|
||||
return "pdf".equals(extension);
|
||||
}
|
||||
}
|
||||
@@ -1,524 +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 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);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
-86
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
-59
@@ -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 {
|
||||
// YAML-Config in Temp-Datei schreiben
|
||||
String yaml =
|
||||
""
|
||||
+ "ui:\n"
|
||||
+ " appName: \"My App\"\n"
|
||||
+ "system:\n"
|
||||
+ " enableAnalytics: true\n";
|
||||
Path tmp = Files.createTempFile("spdf-settings-", ".yml");
|
||||
Files.writeString(tmp, yaml);
|
||||
|
||||
// Pfad per statischem Mock liefern
|
||||
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); // fügt PropertySource an erster Stelle ein
|
||||
|
||||
assertEquals("My App", env.getProperty("ui.appName"));
|
||||
assertEquals("true", env.getProperty("system.enableAnalytics"));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void throws_when_settings_file_missing() throws Exception {
|
||||
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));
|
||||
}
|
||||
}
|
||||
}
|
||||
-251
@@ -1,251 +0,0 @@
|
||||
package stirling.software.common.model;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
import java.nio.file.Paths;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
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("/custom/base");
|
||||
assertEquals("/custom/base", normalize.apply(tfm.getBaseTmpDir()));
|
||||
|
||||
tfm.setLibreofficeDir("/opt/libre");
|
||||
assertEquals("/opt/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().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(nullColl, "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();
|
||||
|
||||
// Aktuelles Verhalten: prüft NUR !isEmpty(), nicht Inhalt
|
||||
Collection<String> oneBlank = new ArrayList<>();
|
||||
oneBlank.add(" ");
|
||||
|
||||
assertTrue(
|
||||
oauth2.isValid(oneBlank, "scopes"),
|
||||
"Dokumentiert aktuelles Verhalten: nicht-leere Liste gilt als gültig, auch wenn Element leer/blank ist");
|
||||
}
|
||||
}
|
||||
-81
@@ -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);
|
||||
assertTrue(r instanceof FileSystemResource, "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);
|
||||
assertTrue(r instanceof FileSystemResource, "Expected FileSystemResource for FS path");
|
||||
assertFalse(r.exists(), "Resource should not exist for missing file");
|
||||
}
|
||||
}
|
||||
-55
@@ -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,111 +0,0 @@
|
||||
package stirling.software.common.model;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
|
||||
import java.io.File;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.CsvSource;
|
||||
|
||||
public class FileInfoTest {
|
||||
|
||||
@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",
|
||||
File.separator
|
||||
+ "path"
|
||||
+ File.separator
|
||||
+ "to"
|
||||
+ File.separator
|
||||
+ "example.txt",
|
||||
LocalDateTime.now(),
|
||||
fileSize,
|
||||
LocalDateTime.now().minusDays(1));
|
||||
|
||||
assertEquals(expectedFormattedSize, fileInfo.getFormattedFileSize());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testGetFilePathAsPath() {
|
||||
FileInfo fileInfo =
|
||||
new FileInfo(
|
||||
"test.pdf",
|
||||
File.separator + "tmp" + File.separator + "test.pdf",
|
||||
LocalDateTime.now(),
|
||||
1234,
|
||||
LocalDateTime.now().minusDays(2));
|
||||
assertEquals(
|
||||
File.separator + "tmp" + File.separator + "test.pdf",
|
||||
fileInfo.getFilePathAsPath().toString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testGetFormattedModificationDate() {
|
||||
LocalDateTime modDate = LocalDateTime.of(2024, 6, 1, 15, 30, 45);
|
||||
FileInfo fileInfo =
|
||||
new FileInfo(
|
||||
"file.txt",
|
||||
File.separator + "file.txt",
|
||||
modDate,
|
||||
100,
|
||||
LocalDateTime.of(2024, 5, 31, 10, 0, 0));
|
||||
assertEquals("2024-06-01 15:30:45", fileInfo.getFormattedModificationDate());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testGetFormattedCreationDate() {
|
||||
LocalDateTime creationDate = LocalDateTime.of(2023, 12, 25, 8, 15, 0);
|
||||
FileInfo fileInfo =
|
||||
new FileInfo(
|
||||
"holiday.txt",
|
||||
File.separator + "holiday.txt",
|
||||
LocalDateTime.of(2024, 1, 1, 0, 0, 0),
|
||||
500,
|
||||
creationDate);
|
||||
assertEquals("2023-12-25 08:15:00", fileInfo.getFormattedCreationDate());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testGettersAndSetters() {
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
FileInfo fileInfo =
|
||||
new FileInfo(
|
||||
"doc.pdf",
|
||||
File.separator + "docs" + File.separator + "doc.pdf",
|
||||
now,
|
||||
2048,
|
||||
now.minusDays(1));
|
||||
// Test getters
|
||||
assertEquals("doc.pdf", fileInfo.getFileName());
|
||||
assertEquals(File.separator + "docs" + File.separator + "doc.pdf", fileInfo.getFilePath());
|
||||
assertEquals(now, fileInfo.getModificationDate());
|
||||
assertEquals(2048, fileInfo.getFileSize());
|
||||
assertEquals(now.minusDays(1), fileInfo.getCreationDate());
|
||||
|
||||
// Test setters
|
||||
fileInfo.setFileName("new.pdf");
|
||||
fileInfo.setFilePath(File.separator + "new" + File.separator + "new.pdf");
|
||||
fileInfo.setModificationDate(now.plusDays(1));
|
||||
fileInfo.setFileSize(4096);
|
||||
fileInfo.setCreationDate(now.minusDays(2));
|
||||
|
||||
assertEquals("new.pdf", fileInfo.getFileName());
|
||||
assertEquals(File.separator + "new" + File.separator + "new.pdf", fileInfo.getFilePath());
|
||||
assertEquals(now.plusDays(1), fileInfo.getModificationDate());
|
||||
assertEquals(4096, fileInfo.getFileSize());
|
||||
assertEquals(now.minusDays(2), fileInfo.getCreationDate());
|
||||
}
|
||||
}
|
||||
-94
@@ -1,94 +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.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("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,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 = new byte[] {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,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,115 +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 +0,0 @@
|
||||
ok
|
||||
-42
@@ -1,42 +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) {
|
||||
|
||||
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,341 +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) {
|
||||
String[] desired = fileOrder.split("\n", -1);
|
||||
List<MultipartFile> remaining = new ArrayList<>(Arrays.asList(files));
|
||||
List<MultipartFile> ordered = new ArrayList<>(files.length);
|
||||
|
||||
for (String name : desired) {
|
||||
if (name == null || name.isEmpty()) continue;
|
||||
int idx = indexOfByOriginalFilename(remaining, name);
|
||||
if (idx >= 0) {
|
||||
ordered.add(remaining.remove(idx));
|
||||
}
|
||||
}
|
||||
|
||||
// Append any files not explicitly listed, preserving their relative order
|
||||
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()) {
|
||||
files = reorderFilesByProvidedOrder(files, fileOrder);
|
||||
} else {
|
||||
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;
|
||||
}
|
||||
}
|
||||
-205
@@ -1,205 +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 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.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 new IllegalArgumentException("Missing original filename");
|
||||
}
|
||||
|
||||
// Check for valid file extension
|
||||
String extension = FilenameUtils.getExtension(originalFilename);
|
||||
if (extension == null || !isValidFileExtension(extension)) {
|
||||
throw new IllegalArgumentException("Invalid file extension");
|
||||
}
|
||||
String extensionLower = extension.toLowerCase();
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
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 {
|
||||
List<String> command = new ArrayList<>();
|
||||
command.add("soffice");
|
||||
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()
|
||||
.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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
-170
@@ -1,170 +0,0 @@
|
||||
package stirling.software.SPDF.controller.api.converters;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.URI;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
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.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;
|
||||
|
||||
@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().getEnableUrlToPDF()) {
|
||||
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;
|
||||
PDDocument doc = null;
|
||||
try {
|
||||
// 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(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 (tempOutputFile != null) {
|
||||
try {
|
||||
Files.deleteIfExists(tempOutputFile);
|
||||
} catch (IOException e) {
|
||||
log.error("Error deleting temporary output file", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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");
|
||||
}
|
||||
}
|
||||
-1690
File diff suppressed because it is too large
Load Diff
@@ -1,70 +0,0 @@
|
||||
package stirling.software.SPDF.controller.web;
|
||||
|
||||
import java.util.Locale;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
|
||||
@Service
|
||||
@Slf4j
|
||||
public class UploadLimitService {
|
||||
|
||||
@Autowired private ApplicationProperties applicationProperties;
|
||||
|
||||
public long getUploadLimit() {
|
||||
String raw =
|
||||
applicationProperties.getSystem().getFileUploadLimit() != null
|
||||
? applicationProperties.getSystem().getFileUploadLimit()
|
||||
: "";
|
||||
if (raw == null || raw.isEmpty()) {
|
||||
return 0L;
|
||||
}
|
||||
String s = raw.trim();
|
||||
// Normalize case for unit parsing
|
||||
String upper = s.toUpperCase(Locale.ROOT);
|
||||
// Expect strictly: 0-999 followed by KB/MB/GB
|
||||
// Find last two chars as unit if length >= 3
|
||||
if (upper.length() < 3) return 0L;
|
||||
String unit = upper.substring(upper.length() - 2);
|
||||
if (!unit.equals("KB") && !unit.equals("MB") && !unit.equals("GB")) {
|
||||
return 0L;
|
||||
}
|
||||
String numPart = upper.substring(0, upper.length() - 2);
|
||||
// Disallow signs, decimals, spaces; only 1-3 digits (allow 0)
|
||||
if (numPart.length() > 3) {
|
||||
return 0L;
|
||||
}
|
||||
for (int i = 0; i < numPart.length(); i++) {
|
||||
char c = numPart.charAt(i);
|
||||
if (c < '0' || c > '9') return 0L;
|
||||
}
|
||||
long value;
|
||||
try {
|
||||
value = Long.parseLong(numPart);
|
||||
} catch (NumberFormatException e) {
|
||||
return 0L;
|
||||
}
|
||||
return switch (unit) {
|
||||
case "KB" -> value * 1024L;
|
||||
case "MB" -> value * 1024L * 1024L;
|
||||
case "GB" -> value * 1024L * 1024L * 1024L;
|
||||
default -> 0L;
|
||||
};
|
||||
}
|
||||
|
||||
// TODO: why do this server side not client?
|
||||
public String getReadableUploadLimit() {
|
||||
return humanReadableByteCount(getUploadLimit());
|
||||
}
|
||||
|
||||
private String humanReadableByteCount(long bytes) {
|
||||
if (bytes < 1024) return bytes + " B";
|
||||
int exp = (int) (Math.log(bytes) / Math.log(1024));
|
||||
String pre = "KMGTPE".charAt(exp - 1) + "B";
|
||||
return String.format(Locale.US, "%.1f %s", bytes / Math.pow(1024, exp), pre);
|
||||
}
|
||||
}
|
||||
-23
@@ -1,23 +0,0 @@
|
||||
package stirling.software.SPDF.model.api.converters;
|
||||
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
|
||||
@Data
|
||||
@EqualsAndHashCode
|
||||
public class ConvertCbrToPdfRequest {
|
||||
|
||||
@Schema(
|
||||
description = "The input CBR file to be converted to a PDF file",
|
||||
requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private MultipartFile fileInput;
|
||||
|
||||
@Schema(
|
||||
description = "Optimize the output PDF for ebook reading using Ghostscript",
|
||||
defaultValue = "false")
|
||||
private boolean optimizeForEbook;
|
||||
}
|
||||
-23
@@ -1,23 +0,0 @@
|
||||
package stirling.software.SPDF.model.api.converters;
|
||||
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
|
||||
@Data
|
||||
@EqualsAndHashCode
|
||||
public class ConvertCbzToPdfRequest {
|
||||
|
||||
@Schema(
|
||||
description = "The input CBZ file to be converted to a PDF file",
|
||||
requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private MultipartFile fileInput;
|
||||
|
||||
@Schema(
|
||||
description = "Optimize the output PDF for ebook reading using Ghostscript",
|
||||
defaultValue = "false")
|
||||
private boolean optimizeForEbook;
|
||||
}
|
||||
-24
@@ -1,24 +0,0 @@
|
||||
package stirling.software.SPDF.model.api.converters;
|
||||
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
|
||||
@Data
|
||||
@EqualsAndHashCode
|
||||
public class ConvertPdfToCbrRequest {
|
||||
|
||||
@Schema(
|
||||
description = "The input PDF file to be converted to a CBR file",
|
||||
requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private MultipartFile fileInput;
|
||||
|
||||
@Schema(
|
||||
description = "The DPI (Dots Per Inch) for rendering PDF pages as images",
|
||||
example = "150",
|
||||
requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private int dpi = 150;
|
||||
}
|
||||
-24
@@ -1,24 +0,0 @@
|
||||
package stirling.software.SPDF.model.api.converters;
|
||||
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
|
||||
@Data
|
||||
@EqualsAndHashCode
|
||||
public class ConvertPdfToCbzRequest {
|
||||
|
||||
@Schema(
|
||||
description = "The input PDF file to be converted to a CBZ file",
|
||||
requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private MultipartFile fileInput;
|
||||
|
||||
@Schema(
|
||||
description = "The DPI (Dots Per Inch) for rendering PDF pages as images",
|
||||
example = "150",
|
||||
requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private int dpi = 150;
|
||||
}
|
||||
@@ -1,218 +0,0 @@
|
||||
package stirling.software.SPDF.pdf;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
import org.apache.pdfbox.pdmodel.PDPage;
|
||||
import org.apache.pdfbox.text.PDFTextStripper;
|
||||
import org.apache.pdfbox.text.TextPosition;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.SPDF.model.PDFText;
|
||||
import stirling.software.common.util.RegexPatternUtils;
|
||||
|
||||
@Slf4j
|
||||
public class TextFinder extends PDFTextStripper {
|
||||
|
||||
private final String searchTerm;
|
||||
private final boolean useRegex;
|
||||
private final boolean wholeWordSearch;
|
||||
@Getter private final List<PDFText> foundTexts = new ArrayList<>();
|
||||
|
||||
private final List<TextPosition> pageTextPositions = new ArrayList<>();
|
||||
private final StringBuilder pageTextBuilder = new StringBuilder();
|
||||
|
||||
public TextFinder(String searchTerm, boolean useRegex, boolean wholeWordSearch)
|
||||
throws IOException {
|
||||
this.searchTerm = searchTerm;
|
||||
this.useRegex = useRegex;
|
||||
this.wholeWordSearch = wholeWordSearch;
|
||||
this.setWordSeparator(" ");
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void startPage(PDPage page) throws IOException {
|
||||
super.startPage(page);
|
||||
pageTextPositions.clear();
|
||||
pageTextBuilder.setLength(0);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void writeString(String text, List<TextPosition> textPositions) {
|
||||
pageTextBuilder.append(text);
|
||||
pageTextPositions.addAll(textPositions);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void writeWordSeparator() {
|
||||
pageTextBuilder.append(getWordSeparator());
|
||||
pageTextPositions.add(null); // Placeholder for separator
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void writeLineSeparator() {
|
||||
pageTextBuilder.append(getLineSeparator());
|
||||
pageTextPositions.add(null); // Placeholder for separator
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void endPage(PDPage page) throws IOException {
|
||||
String text = pageTextBuilder.toString();
|
||||
if (text.isEmpty() || this.searchTerm == null || this.searchTerm.isEmpty()) {
|
||||
super.endPage(page);
|
||||
return;
|
||||
}
|
||||
|
||||
String processedSearchTerm = this.searchTerm.trim();
|
||||
if (processedSearchTerm.isEmpty()) {
|
||||
super.endPage(page);
|
||||
return;
|
||||
}
|
||||
String regex = this.useRegex ? processedSearchTerm : "\\Q" + processedSearchTerm + "\\E";
|
||||
if (this.wholeWordSearch) {
|
||||
if (processedSearchTerm.length() == 1
|
||||
&& Character.isDigit(processedSearchTerm.charAt(0))) {
|
||||
regex = "(?<![\\w])(?<!\\d[\\.,])" + regex + "(?![\\w])(?![\\.,]\\d)";
|
||||
} else if (processedSearchTerm.length() == 1) {
|
||||
regex = "(?<![\\w])" + regex + "(?![\\w])";
|
||||
} else {
|
||||
regex = "\\b" + regex + "\\b";
|
||||
}
|
||||
}
|
||||
|
||||
// Use cached pattern compilation for better performance
|
||||
Pattern pattern = RegexPatternUtils.getInstance().createSearchPattern(regex, true);
|
||||
Matcher matcher = pattern.matcher(text);
|
||||
|
||||
log.debug(
|
||||
"Searching for '{}' in page {} with regex '{}' (wholeWord: {}, useRegex: {})",
|
||||
processedSearchTerm,
|
||||
getCurrentPageNo(),
|
||||
regex,
|
||||
wholeWordSearch,
|
||||
useRegex);
|
||||
|
||||
int matchCount = 0;
|
||||
while (matcher.find()) {
|
||||
matchCount++;
|
||||
int matchStart = matcher.start();
|
||||
int matchEnd = matcher.end();
|
||||
|
||||
log.debug(
|
||||
"Found match #{} at positions {}-{}: '{}'",
|
||||
matchCount,
|
||||
matchStart,
|
||||
matchEnd,
|
||||
matcher.group());
|
||||
|
||||
float minX = Float.MAX_VALUE;
|
||||
float minY = Float.MAX_VALUE;
|
||||
float maxX = Float.MIN_VALUE;
|
||||
float maxY = Float.MIN_VALUE;
|
||||
boolean foundPosition = false;
|
||||
|
||||
for (int i = matchStart; i < matchEnd; i++) {
|
||||
if (i >= pageTextPositions.size()) {
|
||||
log.debug(
|
||||
"Position index {} exceeds available positions ({})",
|
||||
i,
|
||||
pageTextPositions.size());
|
||||
continue;
|
||||
}
|
||||
TextPosition pos = pageTextPositions.get(i);
|
||||
if (pos != null) {
|
||||
foundPosition = true;
|
||||
minX = Math.min(minX, pos.getX());
|
||||
maxX = Math.max(maxX, pos.getX() + pos.getWidth());
|
||||
minY = Math.min(minY, pos.getY() - pos.getHeight());
|
||||
maxY = Math.max(maxY, pos.getY());
|
||||
}
|
||||
}
|
||||
|
||||
if (!foundPosition && matchStart < pageTextPositions.size()) {
|
||||
log.debug(
|
||||
"Attempting to find nearby positions for match at {}-{}",
|
||||
matchStart,
|
||||
matchEnd);
|
||||
|
||||
for (int i = Math.max(0, matchStart - 5);
|
||||
i < Math.min(pageTextPositions.size(), matchEnd + 5);
|
||||
i++) {
|
||||
TextPosition pos = pageTextPositions.get(i);
|
||||
if (pos != null) {
|
||||
foundPosition = true;
|
||||
minX = Math.min(minX, pos.getX());
|
||||
maxX = Math.max(maxX, pos.getX() + pos.getWidth());
|
||||
minY = Math.min(minY, pos.getY() - pos.getHeight());
|
||||
maxY = Math.max(maxY, pos.getY());
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (foundPosition) {
|
||||
foundTexts.add(
|
||||
new PDFText(
|
||||
this.getCurrentPageNo() - 1,
|
||||
minX,
|
||||
minY,
|
||||
maxX,
|
||||
maxY,
|
||||
matcher.group()));
|
||||
log.debug(
|
||||
"Added PDFText for match: page={}, bounds=({},{},{},{}), text='{}'",
|
||||
getCurrentPageNo() - 1,
|
||||
minX,
|
||||
minY,
|
||||
maxX,
|
||||
maxY,
|
||||
matcher.group());
|
||||
} else {
|
||||
log.warn(
|
||||
"Found text match '{}' but no valid position data at {}-{}",
|
||||
matcher.group(),
|
||||
matchStart,
|
||||
matchEnd);
|
||||
}
|
||||
}
|
||||
|
||||
log.debug(
|
||||
"Page {} search complete: found {} matches for '{}'",
|
||||
getCurrentPageNo(),
|
||||
matchCount,
|
||||
processedSearchTerm);
|
||||
|
||||
super.endPage(page);
|
||||
}
|
||||
|
||||
public String getDebugInfo() {
|
||||
StringBuilder debug = new StringBuilder();
|
||||
debug.append("Extracted text length: ").append(pageTextBuilder.length()).append("\n");
|
||||
debug.append("Position count: ").append(pageTextPositions.size()).append("\n");
|
||||
debug.append("Text content: '")
|
||||
.append(pageTextBuilder.toString().replace("\n", "\\n").replace("\r", "\\r"))
|
||||
.append("'\n");
|
||||
|
||||
String text = pageTextBuilder.toString();
|
||||
for (int i = 0; i < Math.min(text.length(), 50); i++) {
|
||||
char c = text.charAt(i);
|
||||
TextPosition pos = i < pageTextPositions.size() ? pageTextPositions.get(i) : null;
|
||||
debug.append(
|
||||
String.format(
|
||||
" [%d] '%c' (0x%02X) -> %s\n",
|
||||
i,
|
||||
c,
|
||||
(int) c,
|
||||
pos != null
|
||||
? String.format("(%.1f,%.1f)", pos.getX(), pos.getY())
|
||||
: "null"));
|
||||
}
|
||||
|
||||
return debug.toString();
|
||||
}
|
||||
}
|
||||
@@ -1,353 +0,0 @@
|
||||
package stirling.software.SPDF.utils.text;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
import org.apache.pdfbox.pdmodel.font.PDFont;
|
||||
import org.apache.pdfbox.pdmodel.font.PDSimpleFont;
|
||||
import org.apache.pdfbox.pdmodel.font.encoding.DictionaryEncoding;
|
||||
import org.apache.pdfbox.pdmodel.font.encoding.Encoding;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.util.RegexPatternUtils;
|
||||
|
||||
@Slf4j
|
||||
public class TextEncodingHelper {
|
||||
|
||||
public static boolean canEncodeCharacters(PDFont font, String text) {
|
||||
if (font == null || text == null || text.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
// Step 1: Primary check - full-string encoding (permissive for "good" cases)
|
||||
byte[] encoded = font.encode(text);
|
||||
if (encoded.length > 0) {
|
||||
log.debug(
|
||||
"Text '{}' has good full-string encoding for font {} - permissively allowing",
|
||||
text,
|
||||
font.getName() != null ? font.getName() : "Unknown");
|
||||
return true;
|
||||
}
|
||||
|
||||
// Step 2: Smart array-based fallback for TJ operator-style text
|
||||
log.debug(
|
||||
"Full encoding failed for '{}' - using array-based fallback for font {}",
|
||||
text,
|
||||
font.getName() != null ? font.getName() : "Unknown");
|
||||
|
||||
return validateAsCodePointArray(font, text);
|
||||
|
||||
} catch (IOException | IllegalArgumentException e) {
|
||||
log.debug(
|
||||
"Encoding exception for text '{}' with font {} - trying array fallback: {}",
|
||||
text,
|
||||
font.getName() != null ? font.getName() : "Unknown",
|
||||
e.getMessage());
|
||||
|
||||
if (isFontSubset(font.getName()) || hasCustomEncoding(font)) {
|
||||
return validateAsCodePointArray(font, text);
|
||||
}
|
||||
|
||||
return false; // Non-subset fonts with encoding exceptions are likely problematic
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean validateAsCodePointArray(PDFont font, String text) {
|
||||
int totalCodePoints = 0;
|
||||
int successfulCodePoints = 0;
|
||||
|
||||
// Iterate through code points (handles surrogates correctly per Unicode docs)
|
||||
for (int i = 0; i < text.length(); ) {
|
||||
int codePoint = text.codePointAt(i);
|
||||
String charStr = new String(Character.toChars(codePoint));
|
||||
totalCodePoints++;
|
||||
|
||||
try {
|
||||
// Test encoding for this code point
|
||||
byte[] charEncoded = font.encode(charStr);
|
||||
if (charEncoded.length > 0) {
|
||||
float charWidth = font.getStringWidth(charStr);
|
||||
|
||||
if (charWidth >= 0) {
|
||||
successfulCodePoints++;
|
||||
log.debug(
|
||||
"Code point '{}' (U+{}) encoded successfully",
|
||||
charStr,
|
||||
Integer.toHexString(codePoint).toUpperCase());
|
||||
} else {
|
||||
log.debug(
|
||||
"Code point '{}' (U+{}) has invalid width: {}",
|
||||
charStr,
|
||||
Integer.toHexString(codePoint).toUpperCase(),
|
||||
charWidth);
|
||||
}
|
||||
} else {
|
||||
log.debug(
|
||||
"Code point '{}' (U+{}) encoding failed - empty result",
|
||||
charStr,
|
||||
Integer.toHexString(codePoint).toUpperCase());
|
||||
}
|
||||
} catch (IOException | IllegalArgumentException e) {
|
||||
log.debug(
|
||||
"Code point '{}' (U+{}) validation failed: {}",
|
||||
charStr,
|
||||
Integer.toHexString(codePoint).toUpperCase(),
|
||||
e.getMessage());
|
||||
}
|
||||
|
||||
i += Character.charCount(codePoint); // Handle surrogates properly
|
||||
}
|
||||
|
||||
double successRate =
|
||||
totalCodePoints > 0 ? (double) successfulCodePoints / totalCodePoints : 0;
|
||||
boolean isAcceptable = successRate >= 0.95;
|
||||
|
||||
log.debug(
|
||||
"Array validation for '{}': {}/{} code points successful ({:.1f}%) - {}",
|
||||
text,
|
||||
successfulCodePoints,
|
||||
totalCodePoints,
|
||||
successRate * 100,
|
||||
isAcceptable ? "ALLOWING" : "rejecting");
|
||||
|
||||
return isAcceptable;
|
||||
}
|
||||
|
||||
public static boolean isTextSegmentRemovable(PDFont font, String text) {
|
||||
if (font == null || text == null || text.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Log the attempt
|
||||
log.debug(
|
||||
"Evaluating text segment for removal: '{}' with font {}",
|
||||
text,
|
||||
font.getName() != null ? font.getName() : "Unknown Font");
|
||||
|
||||
if (isSimpleCharacter(text)) {
|
||||
try {
|
||||
font.encode(text);
|
||||
font.getStringWidth(text);
|
||||
log.debug(
|
||||
"Text '{}' is a simple character and passed validation - allowing removal",
|
||||
text);
|
||||
return true;
|
||||
} catch (Exception e) {
|
||||
log.debug(
|
||||
"Simple character '{}' failed basic validation with font {}: {}",
|
||||
text,
|
||||
font.getName() != null ? font.getName() : "Unknown",
|
||||
e.getMessage());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// For complex text, require comprehensive validation
|
||||
return isTextFullyRemovable(font, text);
|
||||
}
|
||||
|
||||
public static boolean isTextFullyRemovable(PDFont font, String text) {
|
||||
if (font == null || text == null || text.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
// Check 1: Verify encoding capability using new smart approach
|
||||
if (!canEncodeCharacters(font, text)) {
|
||||
log.debug(
|
||||
"Text '{}' failed encoding validation for font {}",
|
||||
text,
|
||||
font.getName() != null ? font.getName() : "Unknown");
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check 2: Validate width calculation capability
|
||||
float width = font.getStringWidth(text);
|
||||
if (width < 0) { // Allow zero width (invisible chars) but reject negative (invalid)
|
||||
log.debug(
|
||||
"Text '{}' has invalid width {} for font {}",
|
||||
text,
|
||||
width,
|
||||
font.getName() != null ? font.getName() : "Unknown");
|
||||
return false; // Invalid metrics prevent accurate removal
|
||||
}
|
||||
|
||||
// Check 3: Verify font descriptor completeness for redaction area calculation
|
||||
if (font.getFontDescriptor() == null) {
|
||||
log.debug(
|
||||
"Missing font descriptor for font {}",
|
||||
font.getName() != null ? font.getName() : "Unknown");
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check 4: Test bounding box calculation for redaction area
|
||||
try {
|
||||
font.getFontDescriptor().getFontBoundingBox();
|
||||
} catch (IllegalArgumentException e) {
|
||||
log.debug(
|
||||
"Font bounding box unavailable for font {}: {}",
|
||||
font.getName() != null ? font.getName() : "Unknown",
|
||||
e.getMessage());
|
||||
return false;
|
||||
}
|
||||
|
||||
log.debug(
|
||||
"Text '{}' passed comprehensive validation for font {}",
|
||||
text,
|
||||
font.getName() != null ? font.getName() : "Unknown");
|
||||
return true;
|
||||
|
||||
} catch (IOException e) {
|
||||
log.debug(
|
||||
"Text '{}' failed validation for font {} due to IO error: {}",
|
||||
text,
|
||||
font.getName() != null ? font.getName() : "Unknown",
|
||||
e.getMessage());
|
||||
return false;
|
||||
} catch (IllegalArgumentException e) {
|
||||
log.debug(
|
||||
"Text '{}' failed validation for font {} due to argument error: {}",
|
||||
text,
|
||||
font.getName() != null ? font.getName() : "Unknown",
|
||||
e.getMessage());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean isSimpleCharacter(String text) {
|
||||
if (text == null || text.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (text.length() > 20) {
|
||||
return false;
|
||||
}
|
||||
|
||||
for (int i = 0; i < text.length(); i++) {
|
||||
char c = text.charAt(i);
|
||||
|
||||
// Allow letters, digits, and whitespace (most common cases)
|
||||
if (Character.isLetterOrDigit(c) || Character.isWhitespace(c)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Allow common ASCII punctuation
|
||||
if (c >= 32 && c <= 126 && ".,!?;:()-[]{}\"'/@#$%&*+=<>|\\~`".indexOf(c) >= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public static boolean hasCustomEncoding(PDFont font) {
|
||||
try {
|
||||
if (font instanceof PDSimpleFont simpleFont) {
|
||||
try {
|
||||
Encoding encoding = simpleFont.getEncoding();
|
||||
if (encoding != null) {
|
||||
// Check for dictionary-based custom encodings
|
||||
if (encoding instanceof DictionaryEncoding) {
|
||||
log.debug("Font {} uses DictionaryEncoding (custom)", font.getName());
|
||||
return true;
|
||||
}
|
||||
|
||||
String encodingName = encoding.getClass().getSimpleName();
|
||||
if (encodingName.contains("Custom")
|
||||
|| encodingName.contains("Dictionary")) {
|
||||
log.debug(
|
||||
"Font {} uses custom encoding: {}",
|
||||
font.getName(),
|
||||
encodingName);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.debug(
|
||||
"Encoding detection failed for font {}: {}",
|
||||
font.getName(),
|
||||
e.getMessage());
|
||||
return true; // Assume custom if detection fails
|
||||
}
|
||||
}
|
||||
|
||||
if (font instanceof org.apache.pdfbox.pdmodel.font.PDType0Font) {
|
||||
log.debug(
|
||||
"Font {} is Type0 (CID) - generally uses standard CMaps",
|
||||
font.getName() != null ? font.getName() : "Unknown");
|
||||
return false;
|
||||
}
|
||||
|
||||
log.debug(
|
||||
"Font {} type {} - assuming standard encoding",
|
||||
font.getName() != null ? font.getName() : "Unknown",
|
||||
font.getClass().getSimpleName());
|
||||
return false;
|
||||
|
||||
} catch (IllegalArgumentException e) {
|
||||
log.debug(
|
||||
"Custom encoding detection failed for font {}: {}",
|
||||
font.getName() != null ? font.getName() : "Unknown",
|
||||
e.getMessage());
|
||||
return false; // Be forgiving on detection failure
|
||||
}
|
||||
}
|
||||
|
||||
public static boolean fontSupportsCharacter(PDFont font, String character) {
|
||||
if (font == null || character == null || character.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
byte[] encoded = font.encode(character);
|
||||
if (encoded.length == 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
float width = font.getStringWidth(character);
|
||||
return width > 0;
|
||||
|
||||
} catch (IOException | IllegalArgumentException e) {
|
||||
log.debug(
|
||||
"Character '{}' not supported by font {}: {}",
|
||||
character,
|
||||
font.getName() != null ? font.getName() : "Unknown",
|
||||
e.getMessage());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public static boolean isFontSubset(String fontName) {
|
||||
if (fontName == null) {
|
||||
return false;
|
||||
}
|
||||
return RegexPatternUtils.getInstance().getFontNamePattern().matcher(fontName).matches();
|
||||
}
|
||||
|
||||
public static boolean canCalculateBasicWidths(PDFont font) {
|
||||
try {
|
||||
float spaceWidth = font.getStringWidth(" ");
|
||||
if (spaceWidth <= 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
String[] testChars = {"a", "A", "0", ".", "e", "!"};
|
||||
for (String ch : testChars) {
|
||||
try {
|
||||
float width = font.getStringWidth(ch);
|
||||
if (width > 0) {
|
||||
return true;
|
||||
}
|
||||
} catch (IOException | IllegalArgumentException e) {
|
||||
}
|
||||
}
|
||||
|
||||
return false; // Can't calculate width for any test characters
|
||||
} catch (IOException | IllegalArgumentException e) {
|
||||
return false; // Font failed basic width calculation
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,142 +0,0 @@
|
||||
package stirling.software.SPDF.utils.text;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
import org.apache.pdfbox.pdmodel.PDPage;
|
||||
import org.apache.pdfbox.pdmodel.PDResources;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.util.RegexPatternUtils;
|
||||
|
||||
@Slf4j
|
||||
public class TextFinderUtils {
|
||||
|
||||
public static boolean validateFontReliability(org.apache.pdfbox.pdmodel.font.PDFont font) {
|
||||
if (font == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (font.isDamaged()) {
|
||||
log.debug(
|
||||
"Font {} is marked as damaged - using TextEncodingHelper validation",
|
||||
font.getName());
|
||||
}
|
||||
|
||||
if (TextEncodingHelper.canCalculateBasicWidths(font)) {
|
||||
log.debug(
|
||||
"Font {} passed basic width calculations - considering reliable",
|
||||
font.getName());
|
||||
return true;
|
||||
}
|
||||
|
||||
String[] basicTests = {"1", "2", "3", "a", "A", "e", "E", " "};
|
||||
|
||||
int workingChars = 0;
|
||||
for (String testChar : basicTests) {
|
||||
if (TextEncodingHelper.canEncodeCharacters(font, testChar)) {
|
||||
workingChars++;
|
||||
}
|
||||
}
|
||||
|
||||
if (workingChars > 0) {
|
||||
log.debug(
|
||||
"Font {} can process {}/{} basic characters - considering reliable",
|
||||
font.getName(),
|
||||
workingChars,
|
||||
basicTests.length);
|
||||
return true;
|
||||
}
|
||||
|
||||
log.debug("Font {} failed all basic tests - considering unreliable", font.getName());
|
||||
return false;
|
||||
}
|
||||
|
||||
public static List<Pattern> createOptimizedSearchPatterns(
|
||||
Set<String> searchTerms, boolean useRegex, boolean wholeWordSearch) {
|
||||
List<Pattern> patterns = new ArrayList<>();
|
||||
|
||||
for (String term : searchTerms) {
|
||||
if (term == null || term.trim().isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
String patternString = useRegex ? term.trim() : Pattern.quote(term.trim());
|
||||
|
||||
if (wholeWordSearch) {
|
||||
patternString = applyWordBoundaries(term.trim(), patternString);
|
||||
}
|
||||
|
||||
// Use PatternFactory for better performance with cached compilation
|
||||
Pattern pattern =
|
||||
RegexPatternUtils.getInstance().createSearchPattern(patternString, true);
|
||||
patterns.add(pattern);
|
||||
|
||||
log.debug("Created search pattern: '{}' -> '{}'", term.trim(), patternString);
|
||||
|
||||
} catch (Exception e) {
|
||||
log.warn("Failed to create pattern for term '{}': {}", term, e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
return patterns;
|
||||
}
|
||||
|
||||
private static String applyWordBoundaries(String originalTerm, String patternString) {
|
||||
if (originalTerm.length() == 1 && Character.isDigit(originalTerm.charAt(0))) {
|
||||
return "(?<![\\w])" + patternString + "(?![\\w])";
|
||||
} else if (originalTerm.length() == 1) {
|
||||
return "(?<![\\w])" + patternString + "(?![\\w])";
|
||||
} else {
|
||||
return "\\b" + patternString + "\\b";
|
||||
}
|
||||
}
|
||||
|
||||
public static boolean hasProblematicFonts(PDPage page) {
|
||||
if (page == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
PDResources resources = page.getResources();
|
||||
if (resources == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
int totalFonts = 0;
|
||||
int completelyUnusableFonts = 0;
|
||||
|
||||
for (org.apache.pdfbox.cos.COSName fontName : resources.getFontNames()) {
|
||||
try {
|
||||
org.apache.pdfbox.pdmodel.font.PDFont font = resources.getFont(fontName);
|
||||
if (font != null) {
|
||||
totalFonts++;
|
||||
if (!validateFontReliability(font)) {
|
||||
completelyUnusableFonts++;
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.debug("Font loading failed for {}: {}", fontName.getName(), e.getMessage());
|
||||
totalFonts++;
|
||||
}
|
||||
}
|
||||
|
||||
boolean hasProblems = totalFonts > 0 && (completelyUnusableFonts * 2 > totalFonts);
|
||||
log.debug(
|
||||
"Page font analysis: {}/{} fonts are completely unusable - page {} problematic",
|
||||
completelyUnusableFonts,
|
||||
totalFonts,
|
||||
hasProblems ? "IS" : "is NOT");
|
||||
|
||||
return hasProblems;
|
||||
|
||||
} catch (Exception e) {
|
||||
log.warn("Font analysis failed for page: {}", e.getMessage());
|
||||
return false; // Be permissive if analysis fails
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,136 +0,0 @@
|
||||
package stirling.software.SPDF.utils.text;
|
||||
|
||||
import org.apache.pdfbox.pdmodel.common.PDRectangle;
|
||||
import org.apache.pdfbox.pdmodel.font.PDFont;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
@Slf4j
|
||||
public class WidthCalculator {
|
||||
|
||||
private static final int FONT_SCALE_FACTOR = 1000;
|
||||
|
||||
public static float calculateAccurateWidth(PDFont font, String text, float fontSize) {
|
||||
if (font == null || text == null || text.isEmpty() || fontSize <= 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (!TextEncodingHelper.canEncodeCharacters(font, text)) {
|
||||
log.debug(
|
||||
"Text cannot be encoded by font {}, using fallback width calculation",
|
||||
font.getName());
|
||||
return calculateFallbackWidth(font, text, fontSize);
|
||||
}
|
||||
|
||||
try {
|
||||
float rawWidth = font.getStringWidth(text);
|
||||
float scaledWidth = (rawWidth / FONT_SCALE_FACTOR) * fontSize;
|
||||
|
||||
log.debug(
|
||||
"Direct width calculation successful for font {}: {} -> {}",
|
||||
font.getName(),
|
||||
rawWidth,
|
||||
scaledWidth);
|
||||
return scaledWidth;
|
||||
|
||||
} catch (Exception e) {
|
||||
log.debug(
|
||||
"Direct width calculation failed for font {}: {}",
|
||||
font.getName(),
|
||||
e.getMessage());
|
||||
return calculateWidthWithCharacterIteration(font, text, fontSize);
|
||||
}
|
||||
}
|
||||
|
||||
private static float calculateWidthWithCharacterIteration(
|
||||
PDFont font, String text, float fontSize) {
|
||||
try {
|
||||
float totalWidth = 0;
|
||||
|
||||
for (int i = 0; i < text.length(); i++) {
|
||||
String character = text.substring(i, i + 1);
|
||||
try {
|
||||
byte[] encoded = font.encode(character);
|
||||
if (encoded.length > 0) {
|
||||
int glyphCode = encoded[0] & 0xFF;
|
||||
float glyphWidth = font.getWidth(glyphCode);
|
||||
|
||||
if (glyphWidth == 0) {
|
||||
try {
|
||||
glyphWidth = font.getWidthFromFont(glyphCode);
|
||||
} catch (Exception e2) {
|
||||
glyphWidth = font.getAverageFontWidth();
|
||||
}
|
||||
}
|
||||
|
||||
totalWidth += (glyphWidth / FONT_SCALE_FACTOR) * fontSize;
|
||||
} else {
|
||||
totalWidth += (font.getAverageFontWidth() / FONT_SCALE_FACTOR) * fontSize;
|
||||
}
|
||||
} catch (Exception e2) {
|
||||
totalWidth += (font.getAverageFontWidth() / FONT_SCALE_FACTOR) * fontSize;
|
||||
}
|
||||
}
|
||||
|
||||
log.debug("Character iteration width calculation: {}", totalWidth);
|
||||
return totalWidth;
|
||||
|
||||
} catch (Exception e) {
|
||||
log.debug("Character iteration failed: {}", e.getMessage());
|
||||
return calculateFallbackWidth(font, text, fontSize);
|
||||
}
|
||||
}
|
||||
|
||||
private static float calculateFallbackWidth(PDFont font, String text, float fontSize) {
|
||||
try {
|
||||
if (font.getFontDescriptor() != null
|
||||
&& font.getFontDescriptor().getFontBoundingBox() != null) {
|
||||
|
||||
PDRectangle bbox = font.getFontDescriptor().getFontBoundingBox();
|
||||
float avgCharWidth =
|
||||
bbox.getWidth() / FONT_SCALE_FACTOR * 0.6f; // Conservative estimate
|
||||
float fallbackWidth = text.length() * avgCharWidth * fontSize;
|
||||
|
||||
log.debug("Bounding box fallback width: {}", fallbackWidth);
|
||||
return fallbackWidth;
|
||||
}
|
||||
|
||||
float avgWidth = font.getAverageFontWidth();
|
||||
float fallbackWidth = (text.length() * avgWidth / FONT_SCALE_FACTOR) * fontSize;
|
||||
|
||||
log.debug("Average width fallback: {}", fallbackWidth);
|
||||
return fallbackWidth;
|
||||
|
||||
} catch (Exception e) {
|
||||
float conservativeWidth = text.length() * 0.5f * fontSize;
|
||||
log.debug(
|
||||
"Conservative fallback width for font {}: {}",
|
||||
font.getName(),
|
||||
conservativeWidth);
|
||||
return conservativeWidth;
|
||||
}
|
||||
}
|
||||
|
||||
public static boolean isWidthCalculationReliable(PDFont font) {
|
||||
if (font == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (font.isDamaged()) {
|
||||
log.debug("Font {} is damaged", font.getName());
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!TextEncodingHelper.canCalculateBasicWidths(font)) {
|
||||
log.debug("Font {} cannot perform basic width calculations", font.getName());
|
||||
return false;
|
||||
}
|
||||
|
||||
if (TextEncodingHelper.hasCustomEncoding(font)) {
|
||||
log.debug("Font {} has custom encoding", font.getName());
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,67 +0,0 @@
|
||||
// Authentication utility for cookie-based JWT
|
||||
window.JWTManager = {
|
||||
|
||||
// Logout - clear cookies and redirect to login
|
||||
logout: function() {
|
||||
|
||||
// Clear JWT cookie manually (fallback)
|
||||
document.cookie = 'stirling_jwt=; Path=/; Expires=Thu, 01 Jan 1970 00:00:01 GMT; SameSite=None; Secure';
|
||||
|
||||
// Perform logout request to clear server-side session
|
||||
fetch('/logout', {
|
||||
method: 'POST',
|
||||
credentials: 'include'
|
||||
}).then(response => {
|
||||
if (response.redirected) {
|
||||
window.location.href = response.url;
|
||||
} else {
|
||||
window.location.href = '/login?logout=true';
|
||||
}
|
||||
}).catch(() => {
|
||||
// If logout fails, let server handle it
|
||||
window.location.href = '/logout';
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
window.fetchWithCsrf = async function(url, options = {}) {
|
||||
function getCsrfToken() {
|
||||
const cookieValue = document.cookie
|
||||
.split('; ')
|
||||
.find(row => row.startsWith('XSRF-TOKEN='))
|
||||
?.split('=')[1];
|
||||
|
||||
if (cookieValue) {
|
||||
return cookieValue;
|
||||
}
|
||||
const csrfElement = document.querySelector('input[name="_csrf"]');
|
||||
return csrfElement ? csrfElement.value : null;
|
||||
}
|
||||
|
||||
// Create a new options object to avoid modifying the passed object
|
||||
const fetchOptions = { ...options };
|
||||
|
||||
// Ensure headers object exists
|
||||
fetchOptions.headers = { ...options.headers };
|
||||
|
||||
// Add CSRF token if available
|
||||
const csrfToken = getCsrfToken();
|
||||
if (csrfToken) {
|
||||
fetchOptions.headers['X-XSRF-TOKEN'] = csrfToken;
|
||||
}
|
||||
|
||||
// Always include credentials to send JWT cookies
|
||||
fetchOptions.credentials = 'include';
|
||||
|
||||
// Make the request
|
||||
const response = await fetch(url, fetchOptions);
|
||||
|
||||
// Handle 401 responses (unauthorized)
|
||||
if (response.status === 401) {
|
||||
console.warn('Authentication failed, redirecting to login');
|
||||
window.JWTManager.logout();
|
||||
return response;
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
@@ -1,390 +0,0 @@
|
||||
function compareVersions(version1, version2) {
|
||||
const v1 = version1.split(".");
|
||||
const v2 = version2.split(".");
|
||||
|
||||
for (let i = 0; i < v1.length || i < v2.length; i++) {
|
||||
const n1 = parseInt(v1[i]) || 0;
|
||||
const n2 = parseInt(v2[i]) || 0;
|
||||
|
||||
if (n1 > n2) {
|
||||
return 1;
|
||||
} else if (n1 < n2) {
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
function getDownloadUrl() {
|
||||
// Only show download for non-Docker installations
|
||||
if (machineType === 'Docker' || machineType === 'Kubernetes') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const baseUrl = 'https://files.stirlingpdf.com/';
|
||||
|
||||
// Determine file based on machine type and security
|
||||
if (machineType === 'Server-jar') {
|
||||
return baseUrl + (activeSecurity ? 'Stirling-PDF-with-login.jar' : 'Stirling-PDF.jar');
|
||||
}
|
||||
|
||||
// Client installations
|
||||
if (machineType.startsWith('Client-')) {
|
||||
const os = machineType.replace('Client-', ''); // win, mac, unix
|
||||
const type = activeSecurity ? '-server-security' : '-server';
|
||||
|
||||
if (os === 'unix') {
|
||||
return baseUrl + os + type + '.jar';
|
||||
} else if (os === 'win') {
|
||||
return baseUrl + os + '-installer.exe';
|
||||
} else if (os === 'mac') {
|
||||
return baseUrl + os + '-installer.dmg';
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// Function to get translated priority text
|
||||
function getTranslatedPriority(priority) {
|
||||
switch(priority?.toLowerCase()) {
|
||||
case 'urgent': return updatePriorityUrgent;
|
||||
case 'normal': return updatePriorityNormal;
|
||||
case 'minor': return updatePriorityMinor;
|
||||
case 'low': return updatePriorityLow;
|
||||
default: return priority?.toUpperCase() || 'NORMAL';
|
||||
}
|
||||
}
|
||||
|
||||
async function getUpdateSummary() {
|
||||
// Map Java License enum to API types
|
||||
let type = 'normal';
|
||||
if (licenseType === 'PRO') {
|
||||
type = 'pro';
|
||||
} else if (licenseType === 'ENTERPRISE') {
|
||||
type = 'enterprise';
|
||||
}
|
||||
const url = `https://supabase.stirling.com/functions/v1/updates?from=${currentVersion}&type=${type}&login=${activeSecurity}&summary=true`;
|
||||
console.log("Fetching update summary from:", url);
|
||||
try {
|
||||
const response = await fetch(url);
|
||||
console.log("Response status:", response.status);
|
||||
if (response.status === 200) {
|
||||
const data = await response.json();
|
||||
return data;
|
||||
} else {
|
||||
console.error("Failed to fetch update summary from Supabase:", response.status);
|
||||
return null;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch update summary from Supabase:", error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function getFullUpdateInfo() {
|
||||
// Map Java License enum to API types
|
||||
let type = 'normal';
|
||||
if (licenseType === 'PRO') {
|
||||
type = 'pro';
|
||||
} else if (licenseType === 'ENTERPRISE') {
|
||||
type = 'enterprise';
|
||||
}
|
||||
const url = `https://supabase.stirling.com/functions/v1/updates?from=${currentVersion}&type=${type}&login=${activeSecurity}&summary=false`;
|
||||
console.log("Fetching full update info from:", url);
|
||||
try {
|
||||
const response = await fetch(url);
|
||||
console.log("Full update response status:", response.status);
|
||||
if (response.status === 200) {
|
||||
const data = await response.json();
|
||||
return data;
|
||||
} else {
|
||||
console.error("Failed to fetch full update info from Supabase:", response.status);
|
||||
return null;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch full update info from Supabase:", error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function getCurrentVersionFromBypass() {
|
||||
const url = "https://raw.githubusercontent.com/Stirling-Tools/Stirling-PDF/master/build.gradle";
|
||||
try {
|
||||
const response = await fetch(url);
|
||||
if (response.status === 200) {
|
||||
const text = await response.text();
|
||||
const versionRegex = /version\s*=\s*['"](\d+\.\d+\.\d+)['"]/;
|
||||
const match = versionRegex.exec(text);
|
||||
if (match) {
|
||||
return match[1];
|
||||
}
|
||||
}
|
||||
throw new Error("Version number not found");
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch latest version from build.gradle:", error);
|
||||
return ""; // Return an empty string if the fetch fails
|
||||
}
|
||||
}
|
||||
|
||||
async function checkForUpdate() {
|
||||
// Initialize the update button as hidden
|
||||
var updateBtn = document.getElementById("update-btn") || null;
|
||||
var updateLink = document.getElementById("update-link") || null;
|
||||
var updateLinkLegacy = document.getElementById("update-link-legacy") || null;
|
||||
if (updateBtn !== null) {
|
||||
updateBtn.style.display = "none";
|
||||
updateBtn.classList.remove("btn-danger", "btn-warning", "btn-outline-primary");
|
||||
}
|
||||
if (updateLink !== null) {
|
||||
updateLink.style.display = "none";
|
||||
}
|
||||
if (updateLinkLegacy !== null) {
|
||||
console.log("hidden!");
|
||||
if (!updateLinkLegacy.classList.contains("visually-hidden")) {
|
||||
updateLinkLegacy.classList.add("visually-hidden");
|
||||
}
|
||||
}
|
||||
|
||||
const updateSummary = await getUpdateSummary();
|
||||
if (!updateSummary) {
|
||||
console.log("No update summary available");
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("updateSummary=", updateSummary);
|
||||
console.log("currentVersion=" + currentVersion);
|
||||
console.log("latestVersion=" + updateSummary.latest_version);
|
||||
|
||||
if (updateSummary.latest_version && compareVersions(updateSummary.latest_version, currentVersion) > 0) {
|
||||
const priority = updateSummary.max_priority || 'normal';
|
||||
|
||||
if (updateBtn != null) {
|
||||
// Style button based on priority
|
||||
if (priority === 'urgent') {
|
||||
updateBtn.classList.add("btn-danger");
|
||||
updateBtn.innerHTML = urgentUpdateAvailable;
|
||||
} else if (priority === 'normal') {
|
||||
updateBtn.classList.add("btn-warning");
|
||||
updateBtn.innerHTML = updateAvailableText;
|
||||
} else {
|
||||
updateBtn.classList.add("btn-outline-primary");
|
||||
updateBtn.innerHTML = updateAvailableText;
|
||||
}
|
||||
|
||||
// Store summary for initial display
|
||||
updateBtn.setAttribute('data-update-summary', JSON.stringify(updateSummary));
|
||||
updateBtn.style.display = "block";
|
||||
|
||||
// Add click handler for update details modal
|
||||
updateBtn.onclick = function(e) {
|
||||
e.preventDefault();
|
||||
showUpdateModal();
|
||||
};
|
||||
}
|
||||
if (updateLink !== null) {
|
||||
document.getElementById("update-link").style.display = "flex";
|
||||
}
|
||||
if (updateLinkLegacy !== null) {
|
||||
document.getElementById("app-update").innerHTML = updateAvailable.replace("{0}", '<b>' + currentVersion + '</b>').replace("{1}", '<b>' + updateSummary.latest_version + '</b>');
|
||||
if (updateLinkLegacy.classList.contains("visually-hidden")) {
|
||||
updateLinkLegacy.classList.remove("visually-hidden");
|
||||
}
|
||||
}
|
||||
console.log("visible");
|
||||
} else {
|
||||
if (updateLinkLegacy !== null) {
|
||||
if (!updateLinkLegacy.classList.contains("visually-hidden")) {
|
||||
updateLinkLegacy.classList.add("visually-hidden");
|
||||
}
|
||||
}
|
||||
console.log("hidden");
|
||||
}
|
||||
}
|
||||
|
||||
async function showUpdateModal() {
|
||||
// Close settings modal if open
|
||||
const settingsModal = bootstrap.Modal.getInstance(document.getElementById('settingsModal'));
|
||||
if (settingsModal) {
|
||||
settingsModal.hide();
|
||||
}
|
||||
|
||||
// Get summary data from button
|
||||
const updateBtn = document.getElementById("update-btn");
|
||||
const summaryData = JSON.parse(updateBtn.getAttribute('data-update-summary'));
|
||||
|
||||
// Utility function to escape HTML special characters
|
||||
function escapeHtml(str) {
|
||||
if (typeof str !== 'string') return str;
|
||||
return str
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
.replace(/\//g, '/');
|
||||
}
|
||||
|
||||
// Create initial modal with loading state
|
||||
const initialModalHtml = `
|
||||
<div class="modal fade" id="updateModal" tabindex="-1" role="dialog" aria-labelledby="updateModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog modal-lg modal-dialog-centered modal-dialog-scrollable" role="document" style="max-height: 80vh;">
|
||||
<div class="modal-content" style="max-height: 80vh;">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="updateModalLabel">${updateModalTitle}</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close">
|
||||
<span class="material-symbols-rounded">close</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="modal-body" id="updateModalBody" style="max-height: 60vh; overflow-y: auto;">
|
||||
<div class="update-summary mb-4">
|
||||
<div class="row mb-3">
|
||||
<div class="${summaryData.latest_stable_version ? 'col-4' : 'col-6'} text-center">
|
||||
<small class="text-muted">${updateCurrent}</small><br>
|
||||
<strong>${escapeHtml(currentVersion)}</strong>
|
||||
</div>
|
||||
<div class="${summaryData.latest_stable_version ? 'col-4' : 'col-6'} text-center">
|
||||
<small class="text-muted">${updateLatest}</small><br>
|
||||
<strong class="text-primary">${escapeHtml(summaryData.latest_version)}</strong>
|
||||
</div>
|
||||
${summaryData.latest_stable_version ? `
|
||||
<div class="col-4 text-center">
|
||||
<small class="text-muted">${updateLatestStable}</small><br>
|
||||
<strong class="text-success">${escapeHtml(summaryData.latest_stable_version)}</strong>
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
<div class="alert ${summaryData.max_priority === 'urgent' ? 'alert-danger' : 'alert-warning'}" role="alert">
|
||||
<strong>${updatePriority}:</strong> ${getTranslatedPriority(summaryData.max_priority)}
|
||||
${summaryData.recommended_action ? `<br><strong>${updateRecommendedAction}:</strong> ${escapeHtml(summaryData.recommended_action)}` : ''}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
${summaryData.any_breaking ? `
|
||||
<div class="alert alert-warning" role="alert">
|
||||
<h6><strong>${updateBreakingChangesDetected}</strong></h6>
|
||||
<p>${updateBreakingChangesMessage}</p>
|
||||
</div>
|
||||
` : ''}
|
||||
|
||||
${summaryData.migration_guides && summaryData.migration_guides.length > 0 ? `
|
||||
<div class="migration-guides mb-4">
|
||||
<h6>${updateMigrationGuides}</h6>
|
||||
<ul class="list-group">
|
||||
${summaryData.migration_guides.map(guide => `
|
||||
<li class="list-group-item d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<strong>${updateVersion} ${escapeHtml(guide.version)}:</strong> ${escapeHtml(guide.notes)}
|
||||
</div>
|
||||
<a href="${escapeHtml(guide.url)}" target="_blank" class="btn btn-sm btn-outline-primary">${updateViewGuide}</a>
|
||||
</li>
|
||||
`).join('')}
|
||||
</ul>
|
||||
</div>
|
||||
` : ''}
|
||||
|
||||
<div class="text-center">
|
||||
<div class="spinner-border text-primary" role="status" id="loadingSpinner">
|
||||
<span class="visually-hidden">${updateLoadingDetailedInfo}</span>
|
||||
</div>
|
||||
<p class="mt-2">${updateLoadingDetailedInfo}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">${updateClose}</button>
|
||||
<a href="https://github.com/Stirling-Tools/Stirling-PDF/releases" target="_blank" class="btn btn-outline-primary">${updateViewAllReleases}</a>
|
||||
${getDownloadUrl() ? `<a href="${escapeHtml(getDownloadUrl())}" class="btn btn-success" target="_blank">${updateDownloadLatest}</a>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Remove existing modal if present
|
||||
const existingModal = document.getElementById('updateModal');
|
||||
if (existingModal) {
|
||||
existingModal.remove();
|
||||
}
|
||||
|
||||
// Add modal to body
|
||||
document.body.insertAdjacentHTML('beforeend', initialModalHtml);
|
||||
|
||||
// Show modal
|
||||
const modal = new bootstrap.Modal(document.getElementById('updateModal'));
|
||||
modal.show();
|
||||
|
||||
// Fetch full update info
|
||||
const fullUpdateInfo = await getFullUpdateInfo();
|
||||
|
||||
// Update modal with full information
|
||||
const modalBody = document.getElementById('updateModalBody');
|
||||
if (fullUpdateInfo && fullUpdateInfo.new_versions) {
|
||||
const storedMode = localStorage.getItem("dark-mode");
|
||||
const isDarkMode = storedMode === "on" ||
|
||||
(storedMode === null && window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches);
|
||||
const darkClasses = isDarkMode ? {
|
||||
accordionItem: 'bg-dark border-secondary text-light',
|
||||
accordionButton: 'bg-dark text-light border-secondary',
|
||||
accordionBody: 'bg-dark text-light'
|
||||
} : {
|
||||
accordionItem: '',
|
||||
accordionButton: '',
|
||||
accordionBody: ''
|
||||
};
|
||||
|
||||
const detailedVersionsHtml = `
|
||||
<div class="detailed-versions mt-4">
|
||||
<h6>${updateAvailableUpdates}</h6>
|
||||
<div class="accordion" id="versionsAccordion">
|
||||
${fullUpdateInfo.new_versions.map((version, index) => `
|
||||
<div class="accordion-item" style="border-color: var(--md-sys-color-outline);">
|
||||
<h2 class="accordion-header" id="heading${index}">
|
||||
<button class="accordion-button ${index === 0 ? '' : 'collapsed'}" style="color: var(--md-sys-color-on-surface); background-color:
|
||||
var(--md-sys-color-surface);" type="button" data-bs-toggle="collapse"
|
||||
data-bs-target="#collapse${index}" aria-expanded="${index === 0 ? 'true' : 'false'}" aria-controls="collapse${index}">
|
||||
<div class="d-flex justify-content-between w-100 me-3">
|
||||
<span><strong>${updateVersion} ${version.version}</strong></span>
|
||||
<span class="badge ${version.priority === 'urgent' ? 'bg-danger' : version.priority === 'normal' ? 'bg-warning' : 'bg-secondary'}">${getTranslatedPriority(version.priority)}</span>
|
||||
</div>
|
||||
</button>
|
||||
</h2>
|
||||
<div id="collapse${index}" class="accordion-collapse collapse ${index === 0 ? 'show' : ''}"
|
||||
aria-labelledby="heading${index}" data-bs-parent="#versionsAccordion">
|
||||
<div class="accordion-body" style="color: var(--md-sys-color-on-surface); background-color:
|
||||
var(--md-sys-color-surface-bright);">
|
||||
<h6>${version.announcement.title}</h6>
|
||||
<p>${version.announcement.message}</p>
|
||||
${version.compatibility.breaking_changes ? `
|
||||
<div class="alert alert-warning alert-sm" role="alert">
|
||||
<small><strong>⚠️ ${updateBreakingChanges}</strong> ${version.compatibility.breaking_description || updateBreakingChangesDefault}</small>
|
||||
${version.compatibility.migration_guide_url ? `<br><a href="${version.compatibility.migration_guide_url}" target="_blank" class="btn btn-sm btn-outline-warning mt-1">${updateMigrationGuide}</a>` : ''}
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Remove loading spinner and add detailed info
|
||||
const spinner = document.getElementById('loadingSpinner');
|
||||
if (spinner) {
|
||||
spinner.parentElement.remove();
|
||||
}
|
||||
modalBody.insertAdjacentHTML('beforeend', detailedVersionsHtml);
|
||||
|
||||
} else {
|
||||
// Remove loading spinner if failed to load
|
||||
const spinner = document.getElementById('loadingSpinner');
|
||||
if (spinner) {
|
||||
spinner.parentElement.innerHTML = `<p class="text-muted">${updateUnableToLoadDetails}</p>`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", (event) => {
|
||||
checkForUpdate();
|
||||
});
|
||||
@@ -1,44 +0,0 @@
|
||||
// JWT Authentication Management Script
|
||||
// This script handles cookie-based JWT authentication and page access control
|
||||
|
||||
(function() {
|
||||
// Clean up JWT token from URL parameters after OAuth/Login flows
|
||||
function cleanupTokenFromUrl() {
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const hasToken = urlParams.get('jwt') || urlParams.get('token');
|
||||
if (hasToken) {
|
||||
// Clean up URL by removing token parameter
|
||||
// Token should now be set as cookie by server
|
||||
urlParams.delete('jwt');
|
||||
urlParams.delete('token');
|
||||
const newUrl = window.location.pathname + (urlParams.toString() ? '?' + urlParams.toString() : '');
|
||||
window.history.replaceState({}, '', newUrl);
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize JWT handling when page loads
|
||||
function initializeJWT() {
|
||||
// Clean up any JWT tokens from URL (OAuth flow)
|
||||
cleanupTokenFromUrl();
|
||||
|
||||
// Authentication is handled server-side
|
||||
// If user is not authenticated, server will redirect to login
|
||||
console.log('JWT initialization complete - authentication handled server-side');
|
||||
}
|
||||
|
||||
// No form enhancement needed for cookie-based JWT
|
||||
// Cookies are automatically sent with form submissions
|
||||
function enhanceFormSubmissions() {
|
||||
// Cookie-based JWT is automatically included in form submissions
|
||||
// No additional processing needed
|
||||
}
|
||||
|
||||
// Initialize when DOM is ready
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
initializeJWT();
|
||||
});
|
||||
} else {
|
||||
initializeJWT();
|
||||
}
|
||||
})();
|
||||
@@ -1,163 +0,0 @@
|
||||
function toolsManager() {
|
||||
const convertToPDF = document.querySelector('#groupConvertTo');
|
||||
const convertFromPDF = document.querySelector('#groupConvertFrom');
|
||||
|
||||
if (convertToPDF && convertFromPDF) {
|
||||
const itemsTo = Array.from(convertToPDF.querySelectorAll('.dropdown-item')).filter(
|
||||
(item) => !item.querySelector('hr.dropdown-divider')
|
||||
);
|
||||
const itemsFrom = Array.from(convertFromPDF.querySelectorAll('.dropdown-item')).filter(
|
||||
(item) => !item.querySelector('hr.dropdown-divider')
|
||||
);
|
||||
|
||||
const totalItems = itemsTo.length + itemsFrom.length;
|
||||
|
||||
if (totalItems > 12) {
|
||||
document.querySelectorAll('#convertGroup').forEach((element) => (element.style.display = 'none'));
|
||||
document.querySelectorAll('#groupConvertTo').forEach((element) => (element.style.display = 'flex'));
|
||||
document.querySelectorAll('#groupConvertFrom').forEach((element) => (element.style.display = 'flex'));
|
||||
} else {
|
||||
document.querySelectorAll('#convertGroup').forEach((element) => (element.style.display = 'flex'));
|
||||
document.querySelectorAll('#groupConvertTo').forEach((element) => (element.style.display = 'none'));
|
||||
document.querySelectorAll('#groupConvertFrom').forEach((element) => (element.style.display = 'none'));
|
||||
}
|
||||
}
|
||||
|
||||
document.querySelectorAll('.navbar-item').forEach((element) => {
|
||||
if (!element.closest('#stacked')) {
|
||||
const dropdownItems = element.querySelectorAll('.dropdown-item');
|
||||
const items = Array.from(dropdownItems).filter((item) => !item.querySelector('hr.dropdown-divider'));
|
||||
|
||||
if (items.length === 0) {
|
||||
if (
|
||||
element.previousElementSibling &&
|
||||
element.previousElementSibling.classList.contains('navbar-item') &&
|
||||
element.previousElementSibling.classList.contains('nav-item-separator')
|
||||
) {
|
||||
element.previousElementSibling.remove();
|
||||
}
|
||||
element.remove();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function setupDropdowns() {
|
||||
const dropdowns = document.querySelectorAll('.navbar-nav > .nav-item.dropdown');
|
||||
|
||||
dropdowns.forEach((dropdown) => {
|
||||
const toggle = dropdown.querySelector('[data-bs-toggle="dropdown"]');
|
||||
if (!toggle) return;
|
||||
|
||||
// Skip search dropdown, it has its own logic
|
||||
if (toggle.id === 'searchDropdown') {
|
||||
return;
|
||||
}
|
||||
|
||||
dropdown.addEventListener('show.bs.dropdown', () => {
|
||||
// Find all other open dropdowns and hide them
|
||||
const openDropdowns = document.querySelectorAll('.navbar-nav .dropdown-menu.show');
|
||||
openDropdowns.forEach((menu) => {
|
||||
const parentDropdown = menu.closest('.dropdown');
|
||||
if (parentDropdown && parentDropdown !== dropdown) {
|
||||
const parentToggle = parentDropdown.querySelector('[data-bs-toggle="dropdown"]');
|
||||
if (parentToggle) {
|
||||
// Get or create Bootstrap dropdown instance
|
||||
let instance = bootstrap.Dropdown.getInstance(parentToggle);
|
||||
if (!instance) {
|
||||
instance = new bootstrap.Dropdown(parentToggle);
|
||||
}
|
||||
instance.hide();
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function tooltipSetup() {
|
||||
// initialize global tooltip element or get reference
|
||||
let customTooltip = document.getElementById("customTooltip");
|
||||
if (!customTooltip) {
|
||||
customTooltip = document.createElement("div");
|
||||
customTooltip.id = "customTooltip";
|
||||
customTooltip.className = "btn-tooltip";
|
||||
document.body.appendChild(customTooltip);
|
||||
}
|
||||
|
||||
function updateTooltipPosition(event, text) {
|
||||
if (window.innerWidth >= 1200) {
|
||||
customTooltip.textContent = text;
|
||||
customTooltip.style.display = "block";
|
||||
customTooltip.style.left = `${event.pageX + 10}px`;
|
||||
customTooltip.style.top = `${event.pageY + 10}px`;
|
||||
}
|
||||
}
|
||||
|
||||
function hideTooltip() {
|
||||
customTooltip.style.display = "none";
|
||||
}
|
||||
|
||||
// find uninitialized tooltips and set up event listeners
|
||||
const tooltipElements = document.querySelectorAll("[title]");
|
||||
|
||||
tooltipElements.forEach((element) => {
|
||||
const tooltipText = element.getAttribute("title");
|
||||
element.removeAttribute("title");
|
||||
element.setAttribute("data-title", tooltipText); // no UI effect, just for reference
|
||||
|
||||
element.addEventListener("mouseenter", (event) => updateTooltipPosition(event, tooltipText));
|
||||
element.addEventListener("mousemove", (event) => updateTooltipPosition(event, tooltipText));
|
||||
element.addEventListener("mouseleave", hideTooltip);
|
||||
|
||||
// in case UI moves and mouseleave is not triggered, tooltip is readded when mouse is moved over the element
|
||||
element.addEventListener("click", hideTooltip);
|
||||
});
|
||||
};
|
||||
window.tooltipSetup = tooltipSetup;
|
||||
|
||||
// Override the bootstrap dropdown styles for mobile
|
||||
function fixNavbarDropdownStyles() {
|
||||
if (window.innerWidth < 1200) {
|
||||
document.querySelectorAll('.navbar .dropdown-menu').forEach(function(menu) {
|
||||
menu.style.transform = 'none';
|
||||
menu.style.transformOrigin = 'none';
|
||||
menu.style.left = '0';
|
||||
menu.style.right = '0';
|
||||
menu.style.maxWidth = '95vw';
|
||||
menu.style.width = '100vw';
|
||||
menu.style.marginBottom = '0';
|
||||
});
|
||||
} else {
|
||||
document.querySelectorAll('.navbar .dropdown-menu').forEach(function(menu) {
|
||||
menu.style.transform = '';
|
||||
menu.style.transformOrigin = '';
|
||||
menu.style.left = '';
|
||||
menu.style.right = '';
|
||||
menu.style.maxWidth = '';
|
||||
menu.style.width = '';
|
||||
menu.style.marginBottom = '';
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
tooltipSetup();
|
||||
setupDropdowns();
|
||||
fixNavbarDropdownStyles();
|
||||
// Setup logout button functionality
|
||||
const logoutButton = document.querySelector('a[href="/logout"]');
|
||||
if (logoutButton) {
|
||||
logoutButton.addEventListener('click', function(event) {
|
||||
event.preventDefault();
|
||||
if (window.JWTManager) {
|
||||
window.JWTManager.logout();
|
||||
} else {
|
||||
// Fallback if JWTManager is not available
|
||||
window.location.href = '/logout';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
});
|
||||
window.addEventListener('resize', fixNavbarDropdownStyles);
|
||||
@@ -1,802 +0,0 @@
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
const bookmarksContainer = document.getElementById("bookmarks-container");
|
||||
const errorMessageContainer = document.getElementById("error-message-container");
|
||||
const addBookmarkBtn = document.getElementById("addBookmarkBtn");
|
||||
const bookmarkDataInput = document.getElementById("bookmarkData");
|
||||
let bookmarks = [];
|
||||
let counter = 0; // Used for generating unique IDs
|
||||
|
||||
// callback function on file input change to extract bookmarks from PDF
|
||||
async function getBookmarkDataFromPdf(event) {
|
||||
if (!event.target.files || event.target.files.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append("file", event.target.files[0]);
|
||||
|
||||
try {
|
||||
// Call the API to extract bookmarks using fetchWithCsrf for CSRF protection
|
||||
const response = await fetchWithCsrf("/api/v1/general/extract-bookmarks", {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch API: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
|
||||
const extractedBookmarks = await response.json();
|
||||
return extractedBookmarks;
|
||||
} catch (error) {
|
||||
throw new Error("Error extracting bookmark data:", error);
|
||||
}
|
||||
}
|
||||
|
||||
// callback function on file input change to extract bookmarks from JSON
|
||||
async function getBookmarkDataFromJson(event) {
|
||||
if (!event.target.files || event.target.files.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const file = event.target.files[0];
|
||||
|
||||
try {
|
||||
const fileText = await file.text();
|
||||
const jsonData = JSON.parse(fileText);
|
||||
return jsonData;
|
||||
} catch (error) {
|
||||
throw new Error(`Error extracting bookmark data: error while reading or parsing JSON file: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// display new bookmark data given by a callback function that loads or fetches the data
|
||||
async function loadBookmarks(getBookmarkDataCallback) {
|
||||
// reset bookmarks
|
||||
bookmarks = [];
|
||||
updateBookmarksUI();
|
||||
showLoadingIndicator();
|
||||
|
||||
try {
|
||||
// Get new bookmarks from the callback
|
||||
const newBookmarks = await getBookmarkDataCallback();
|
||||
|
||||
// Convert extracted bookmarks to our format with IDs
|
||||
if (newBookmarks && newBookmarks.length > 0) {
|
||||
bookmarks = newBookmarks.map(convertExtractedBookmark);
|
||||
}
|
||||
} catch (error) {
|
||||
bookmarks = [];
|
||||
throw new Error(`Error loading bookmarks: ${error}`);
|
||||
} finally {
|
||||
removeLoadingIndicator();
|
||||
updateBookmarksUI();
|
||||
}
|
||||
}
|
||||
|
||||
// Add event listener to the file input to extract existing bookmarks
|
||||
document.getElementById("fileInput-input").addEventListener("change", async function (event) {
|
||||
try {
|
||||
await loadBookmarks(async function () {
|
||||
return getBookmarkDataFromPdf(event);
|
||||
});
|
||||
} catch {
|
||||
showErrorMessage("Failed to extract bookmarks. You can still create new ones.");
|
||||
}
|
||||
});
|
||||
|
||||
function showLoadingIndicator() {
|
||||
const loadingEl = document.createElement("div");
|
||||
loadingEl.className = "alert alert-info";
|
||||
loadingEl.textContent = "Loading bookmarks from PDF...";
|
||||
loadingEl.id = "loading-bookmarks";
|
||||
errorMessageContainer.innerHTML = "";
|
||||
bookmarksContainer.innerHTML = "";
|
||||
bookmarksContainer.appendChild(loadingEl);
|
||||
}
|
||||
|
||||
function removeLoadingIndicator() {
|
||||
const loadingEl = document.getElementById("loading-bookmarks");
|
||||
if (loadingEl) {
|
||||
loadingEl.remove();
|
||||
}
|
||||
}
|
||||
|
||||
function showErrorMessage(message) {
|
||||
const errorEl = document.createElement("div");
|
||||
errorEl.className = "alert alert-danger";
|
||||
errorEl.textContent = message;
|
||||
errorMessageContainer.appendChild(errorEl);
|
||||
}
|
||||
|
||||
function showEmptyState() {
|
||||
const emptyStateEl = document.createElement("div");
|
||||
emptyStateEl.className = "empty-bookmarks mb-3";
|
||||
emptyStateEl.innerHTML = `
|
||||
<span class="material-symbols-rounded mb-2" style="font-size: 48px;">bookmark_add</span>
|
||||
<h5>No bookmarks found</h5>
|
||||
<p class="mb-3">This PDF doesn't have any bookmarks yet. Add your first bookmark to get started.</p>
|
||||
<button type="button" class="btn btn-primary btn-add-first-bookmark">
|
||||
<span class="material-symbols-rounded">add</span> Add First Bookmark
|
||||
</button>
|
||||
`;
|
||||
|
||||
// Add event listener to the "Add First Bookmark" button
|
||||
emptyStateEl.querySelector(".btn-add-first-bookmark").addEventListener("click", function () {
|
||||
addBookmark(null, "New Bookmark", 1);
|
||||
emptyStateEl.remove();
|
||||
});
|
||||
|
||||
bookmarksContainer.appendChild(emptyStateEl);
|
||||
}
|
||||
|
||||
// Function to convert extracted bookmarks to our format with IDs
|
||||
function convertExtractedBookmark(bookmark) {
|
||||
counter++;
|
||||
const result = {
|
||||
id: Date.now() + counter, // Generate a unique ID
|
||||
title: bookmark.title || "Untitled Bookmark",
|
||||
pageNumber: bookmark.pageNumber || 1,
|
||||
children: [],
|
||||
expanded: false, // All bookmarks start collapsed for better visibility
|
||||
};
|
||||
|
||||
// Convert children recursively
|
||||
if (bookmark.children && bookmark.children.length > 0) {
|
||||
result.children = bookmark.children.map((child) => {
|
||||
return convertExtractedBookmark(child);
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// Add bookmark button click handler
|
||||
addBookmarkBtn.addEventListener("click", function (e) {
|
||||
e.preventDefault();
|
||||
addBookmark();
|
||||
});
|
||||
|
||||
// Add form submit handler to update JSON data
|
||||
document.getElementById("editTocForm").addEventListener("submit", function () {
|
||||
updateBookmarkData();
|
||||
});
|
||||
|
||||
function addBookmark(parent = null, title = "", pageNumber = 1) {
|
||||
counter++;
|
||||
const newBookmark = {
|
||||
id: Date.now() + counter,
|
||||
title: title || "New Bookmark",
|
||||
pageNumber: pageNumber || 1,
|
||||
children: [],
|
||||
expanded: false, // New bookmarks start collapsed
|
||||
};
|
||||
|
||||
if (parent === null) {
|
||||
bookmarks.push(newBookmark);
|
||||
} else {
|
||||
const parentBookmark = findBookmark(bookmarks, parent);
|
||||
if (parentBookmark) {
|
||||
parentBookmark.children.push(newBookmark);
|
||||
parentBookmark.expanded = true; // Auto-expand the parent when adding a child
|
||||
} else {
|
||||
// Add to root level if parent not found
|
||||
bookmarks.push(newBookmark);
|
||||
}
|
||||
}
|
||||
|
||||
updateBookmarksUI();
|
||||
|
||||
// After updating UI, find and focus the new bookmark's title field
|
||||
setTimeout(() => {
|
||||
const newElement = document.querySelector(`[data-id="${newBookmark.id}"]`);
|
||||
if (newElement) {
|
||||
const titleInput = newElement.querySelector(".bookmark-title");
|
||||
if (titleInput) {
|
||||
titleInput.focus();
|
||||
titleInput.select();
|
||||
}
|
||||
// Scroll to the new element
|
||||
newElement.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
}
|
||||
}, 50);
|
||||
}
|
||||
|
||||
function findBookmark(bookmarkArray, id) {
|
||||
for (const bookmark of bookmarkArray) {
|
||||
if (bookmark.id === id) {
|
||||
return bookmark;
|
||||
}
|
||||
if (bookmark.children.length > 0) {
|
||||
const found = findBookmark(bookmark.children, id);
|
||||
if (found) return found;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Find the parent bookmark of a given bookmark by ID
|
||||
function findParentBookmark(bookmarkArray, id, parent = null) {
|
||||
for (const bookmark of bookmarkArray) {
|
||||
if (bookmark.id === id) {
|
||||
return parent; // Return the parent ID (or null if top-level)
|
||||
}
|
||||
|
||||
if (bookmark.children.length > 0) {
|
||||
const found = findParentBookmark(bookmark.children, id, bookmark.id);
|
||||
if (found !== undefined) return found;
|
||||
}
|
||||
}
|
||||
return undefined; // Not found at this level
|
||||
}
|
||||
|
||||
function removeBookmark(id) {
|
||||
// Remove from top level
|
||||
const index = bookmarks.findIndex((b) => b.id === id);
|
||||
if (index !== -1) {
|
||||
bookmarks.splice(index, 1);
|
||||
updateBookmarksUI();
|
||||
return;
|
||||
}
|
||||
|
||||
// Remove from children
|
||||
function removeFromChildren(bookmarkArray, id) {
|
||||
for (const bookmark of bookmarkArray) {
|
||||
const childIndex = bookmark.children.findIndex((b) => b.id === id);
|
||||
if (childIndex !== -1) {
|
||||
bookmark.children.splice(childIndex, 1);
|
||||
return true;
|
||||
}
|
||||
if (removeFromChildren(bookmark.children, id)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
if (removeFromChildren(bookmarks, id)) {
|
||||
updateBookmarksUI();
|
||||
}
|
||||
|
||||
// If no bookmarks left, show empty state
|
||||
if (bookmarks.length === 0) {
|
||||
showEmptyState();
|
||||
}
|
||||
}
|
||||
|
||||
function toggleBookmarkExpanded(id) {
|
||||
const bookmark = findBookmark(bookmarks, id);
|
||||
if (bookmark) {
|
||||
bookmark.expanded = !bookmark.expanded;
|
||||
updateBookmarksUI();
|
||||
}
|
||||
}
|
||||
|
||||
function updateBookmarkData() {
|
||||
// Create a clean version without the IDs for submission
|
||||
const cleanBookmarks = bookmarks.map(cleanBookmark);
|
||||
bookmarkDataInput.value = JSON.stringify(cleanBookmarks);
|
||||
}
|
||||
|
||||
function cleanBookmark(bookmark) {
|
||||
return {
|
||||
title: bookmark.title,
|
||||
pageNumber: bookmark.pageNumber,
|
||||
children: bookmark.children.map(cleanBookmark),
|
||||
};
|
||||
}
|
||||
|
||||
function updateBookmarksUI() {
|
||||
if (!bookmarksContainer) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Only clear the container if there are no error messages or loading indicators
|
||||
if (!document.querySelector("#bookmarks-container .alert")) {
|
||||
bookmarksContainer.innerHTML = "";
|
||||
}
|
||||
|
||||
// Check if there are bookmarks to display
|
||||
if (bookmarks.length === 0 && !document.querySelector(".empty-bookmarks")) {
|
||||
showEmptyState();
|
||||
} else {
|
||||
// Remove empty state if it exists and there are bookmarks
|
||||
const emptyState = document.querySelector(".empty-bookmarks");
|
||||
if (emptyState && bookmarks.length > 0) {
|
||||
emptyState.remove();
|
||||
}
|
||||
|
||||
// Create bookmark elements
|
||||
bookmarks.forEach((bookmark) => {
|
||||
const bookmarkElement = createBookmarkElement(bookmark);
|
||||
bookmarksContainer.appendChild(bookmarkElement);
|
||||
});
|
||||
}
|
||||
|
||||
updateBookmarkData();
|
||||
|
||||
// Initialize tooltips for dynamically added elements
|
||||
window.tooltipSetup();
|
||||
}
|
||||
|
||||
// Create the main bookmark element with collapsible interface
|
||||
function createBookmarkElement(bookmark, level = 0) {
|
||||
const bookmarkEl = document.createElement("div");
|
||||
bookmarkEl.className = "bookmark-item";
|
||||
bookmarkEl.dataset.id = bookmark.id;
|
||||
bookmarkEl.dataset.level = level;
|
||||
|
||||
// Create the header (always visible part)
|
||||
const header = createBookmarkHeader(bookmark, level);
|
||||
bookmarkEl.appendChild(header);
|
||||
|
||||
// Create the content (collapsible part)
|
||||
const content = document.createElement("div");
|
||||
content.className = "bookmark-content";
|
||||
if (!bookmark.expanded) {
|
||||
content.style.display = "none";
|
||||
}
|
||||
|
||||
// Main input row
|
||||
const inputRow = createInputRow(bookmark);
|
||||
content.appendChild(inputRow);
|
||||
bookmarkEl.appendChild(content);
|
||||
|
||||
// Add children container if has children and expanded
|
||||
if (bookmark.children && bookmark.children.length > 0) {
|
||||
const childrenContainer = createChildrenContainer(bookmark, level);
|
||||
if (bookmark.expanded) {
|
||||
bookmarkEl.appendChild(childrenContainer);
|
||||
}
|
||||
}
|
||||
|
||||
return bookmarkEl;
|
||||
}
|
||||
|
||||
// Create the header that's always visible
|
||||
function createBookmarkHeader(bookmark, level) {
|
||||
const header = document.createElement("div");
|
||||
header.className = "bookmark-header";
|
||||
if (!bookmark.expanded) {
|
||||
header.classList.add("collapsed");
|
||||
}
|
||||
|
||||
// Left side of header with expand/collapse and info
|
||||
const headerLeft = document.createElement("div");
|
||||
headerLeft.className = "d-flex align-items-center";
|
||||
|
||||
// Toggle expand/collapse icon with child count
|
||||
const toggleContainer = document.createElement("div");
|
||||
toggleContainer.className = "d-flex align-items-center";
|
||||
toggleContainer.style.marginRight = "8px";
|
||||
|
||||
// Only show toggle if has children
|
||||
if (bookmark.children && bookmark.children.length > 0) {
|
||||
// Create toggle icon
|
||||
const toggleIcon = document.createElement("span");
|
||||
toggleIcon.className = "material-symbols-rounded toggle-icon me-1";
|
||||
toggleIcon.textContent = "expand_more";
|
||||
toggleIcon.style.cursor = "pointer";
|
||||
toggleContainer.appendChild(toggleIcon);
|
||||
|
||||
// Add child count indicator
|
||||
const childCount = document.createElement("span");
|
||||
childCount.className = "badge rounded-pill";
|
||||
// Use theme-appropriate badge color
|
||||
const isDarkMode = document.documentElement.getAttribute("data-bs-theme") === "dark";
|
||||
childCount.classList.add(isDarkMode ? "bg-info" : "bg-secondary");
|
||||
childCount.style.fontSize = "0.7rem";
|
||||
childCount.style.padding = "0.2em 0.5em";
|
||||
childCount.textContent = bookmark.children.length;
|
||||
childCount.title = `${bookmark.children.length} child bookmark${bookmark.children.length > 1 ? "s" : ""}`;
|
||||
toggleContainer.appendChild(childCount);
|
||||
} else {
|
||||
// Add spacer if no children
|
||||
const spacer = document.createElement("span");
|
||||
spacer.style.width = "24px";
|
||||
spacer.style.display = "inline-block";
|
||||
toggleContainer.appendChild(spacer);
|
||||
}
|
||||
|
||||
headerLeft.appendChild(toggleContainer);
|
||||
|
||||
// Level indicator for nested items
|
||||
if (level > 0) {
|
||||
// Add relationship indicator visual line
|
||||
const relationshipIndicator = document.createElement("div");
|
||||
relationshipIndicator.className = "bookmark-relationship-indicator";
|
||||
|
||||
const line = document.createElement("div");
|
||||
line.className = "relationship-line";
|
||||
relationshipIndicator.appendChild(line);
|
||||
|
||||
const arrow = document.createElement("div");
|
||||
arrow.className = "relationship-arrow";
|
||||
relationshipIndicator.appendChild(arrow);
|
||||
|
||||
header.appendChild(relationshipIndicator);
|
||||
|
||||
// Text indicator
|
||||
const levelIndicator = document.createElement("span");
|
||||
levelIndicator.className = "bookmark-level-indicator";
|
||||
levelIndicator.textContent = `Child`;
|
||||
headerLeft.appendChild(levelIndicator);
|
||||
}
|
||||
|
||||
// Title preview
|
||||
const titlePreview = document.createElement("span");
|
||||
titlePreview.className = "bookmark-title-preview";
|
||||
titlePreview.textContent = bookmark.title;
|
||||
headerLeft.appendChild(titlePreview);
|
||||
|
||||
// Page number preview
|
||||
const pagePreview = document.createElement("span");
|
||||
pagePreview.className = "bookmark-page-preview";
|
||||
pagePreview.textContent = `Page ${bookmark.pageNumber}`;
|
||||
headerLeft.appendChild(pagePreview);
|
||||
|
||||
// Right side of header with action buttons
|
||||
const headerRight = document.createElement("div");
|
||||
headerRight.className = "bookmark-actions-header";
|
||||
|
||||
// Quick add buttons with clear visual distinction - using Stirling-PDF's tooltip system
|
||||
const quickAddChildButton = createButton("subdirectory_arrow_right", "btn-add-child", "Add child bookmark", function (e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
addBookmark(bookmark.id);
|
||||
});
|
||||
|
||||
const quickAddSiblingButton = createButton("add", "btn-add-sibling", "Add sibling bookmark", function (e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
// Find parent of current bookmark
|
||||
const parentId = findParentBookmark(bookmarks, bookmark.id);
|
||||
addBookmark(parentId, "", bookmark.pageNumber); // Same level as current bookmark
|
||||
});
|
||||
|
||||
// Quick remove button
|
||||
const quickRemoveButton = createButton("delete", "btn-outline-danger", "Remove bookmark", function (e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
if (
|
||||
confirm(
|
||||
"Are you sure you want to remove this bookmark" + (bookmark.children.length > 0 ? " and all its children?" : "?")
|
||||
)
|
||||
) {
|
||||
removeBookmark(bookmark.id);
|
||||
}
|
||||
});
|
||||
|
||||
headerRight.appendChild(quickAddChildButton);
|
||||
headerRight.appendChild(quickAddSiblingButton);
|
||||
headerRight.appendChild(quickRemoveButton);
|
||||
|
||||
// Assemble header
|
||||
header.appendChild(headerLeft);
|
||||
header.appendChild(headerRight);
|
||||
|
||||
// Add click handler for expansion toggle
|
||||
header.addEventListener("click", function (e) {
|
||||
// Only toggle if not clicking on buttons
|
||||
if (!e.target.closest("button")) {
|
||||
toggleBookmarkExpanded(bookmark.id);
|
||||
}
|
||||
});
|
||||
|
||||
return header;
|
||||
}
|
||||
|
||||
function createInputRow(bookmark) {
|
||||
const row = document.createElement("div");
|
||||
row.className = "row";
|
||||
|
||||
// Title input
|
||||
row.appendChild(createTitleInputElement(bookmark));
|
||||
|
||||
// Page input
|
||||
row.appendChild(createPageInputElement(bookmark));
|
||||
|
||||
return row;
|
||||
}
|
||||
|
||||
function createTitleInputElement(bookmark) {
|
||||
const titleCol = document.createElement("div");
|
||||
titleCol.className = "col-md-8";
|
||||
|
||||
const titleGroup = document.createElement("div");
|
||||
titleGroup.className = "mb-3";
|
||||
|
||||
const titleLabel = document.createElement("label");
|
||||
titleLabel.textContent = "Title";
|
||||
titleLabel.className = "form-label";
|
||||
|
||||
const titleInput = document.createElement("input");
|
||||
titleInput.type = "text";
|
||||
titleInput.className = "form-control bookmark-title";
|
||||
titleInput.value = bookmark.title;
|
||||
titleInput.addEventListener("input", function () {
|
||||
bookmark.title = this.value;
|
||||
updateBookmarkData();
|
||||
|
||||
// Also update the preview in the header
|
||||
const header = titleInput.closest(".bookmark-item").querySelector(".bookmark-title-preview");
|
||||
if (header) {
|
||||
header.textContent = this.value;
|
||||
}
|
||||
});
|
||||
|
||||
titleGroup.appendChild(titleLabel);
|
||||
titleGroup.appendChild(titleInput);
|
||||
titleCol.appendChild(titleGroup);
|
||||
|
||||
return titleCol;
|
||||
}
|
||||
|
||||
function createPageInputElement(bookmark) {
|
||||
const pageCol = document.createElement("div");
|
||||
pageCol.className = "col-md-4";
|
||||
|
||||
const pageGroup = document.createElement("div");
|
||||
pageGroup.className = "mb-3";
|
||||
|
||||
const pageLabel = document.createElement("label");
|
||||
pageLabel.textContent = "Page";
|
||||
pageLabel.className = "form-label";
|
||||
|
||||
const pageInput = document.createElement("input");
|
||||
pageInput.type = "number";
|
||||
pageInput.className = "form-control bookmark-page";
|
||||
pageInput.value = bookmark.pageNumber;
|
||||
pageInput.min = 1;
|
||||
pageInput.addEventListener("input", function () {
|
||||
bookmark.pageNumber = parseInt(this.value) || 1;
|
||||
updateBookmarkData();
|
||||
|
||||
// Also update the preview in the header
|
||||
const header = pageInput.closest(".bookmark-item").querySelector(".bookmark-page-preview");
|
||||
if (header) {
|
||||
header.textContent = `Page ${bookmark.pageNumber}`;
|
||||
}
|
||||
});
|
||||
|
||||
pageGroup.appendChild(pageLabel);
|
||||
pageGroup.appendChild(pageInput);
|
||||
pageCol.appendChild(pageGroup);
|
||||
|
||||
return pageCol;
|
||||
}
|
||||
|
||||
function createButton(icon, className, title, clickHandler) {
|
||||
const button = document.createElement("button");
|
||||
button.type = "button";
|
||||
button.className = `btn ${className} btn-bookmark-action`;
|
||||
button.innerHTML = `<span class="material-symbols-rounded">${icon}</span>`;
|
||||
button.title = title;
|
||||
|
||||
button.addEventListener("click", clickHandler);
|
||||
return button;
|
||||
}
|
||||
|
||||
function createChildrenContainer(bookmark, level) {
|
||||
const childrenContainer = document.createElement("div");
|
||||
childrenContainer.className = "bookmark-children";
|
||||
|
||||
bookmark.children.forEach((child) => {
|
||||
childrenContainer.appendChild(createBookmarkElement(child, level + 1));
|
||||
});
|
||||
|
||||
return childrenContainer;
|
||||
}
|
||||
|
||||
// Update the add bookmark button appearance with clear visual cue
|
||||
addBookmarkBtn.innerHTML = '<span class="material-symbols-rounded">add</span> Add Top-level Bookmark';
|
||||
addBookmarkBtn.className = "btn btn-primary btn-add-bookmark top-level";
|
||||
addBookmarkBtn.title = "Add a new top-level bookmark";
|
||||
|
||||
// Add icon to empty state button as well
|
||||
const updateEmptyStateButton = function () {
|
||||
const emptyStateBtn = document.querySelector(".btn-add-first-bookmark");
|
||||
if (emptyStateBtn) {
|
||||
emptyStateBtn.innerHTML = '<span class="material-symbols-rounded">add</span> Add First Bookmark';
|
||||
emptyStateBtn.title = "Add first bookmark";
|
||||
|
||||
// Initialize tooltips for the empty state button
|
||||
window.tooltipSetup();
|
||||
}
|
||||
};
|
||||
|
||||
// Initialize with an empty state if no bookmarks
|
||||
if (bookmarks.length === 0) {
|
||||
showEmptyState();
|
||||
updateEmptyStateButton();
|
||||
}
|
||||
|
||||
// Add bookmarks Import/Export functionality
|
||||
|
||||
// Import/Export button references
|
||||
const importDefaultBtn = document.getElementById("importDefaultBtn");
|
||||
const exportDefaultBtn = document.getElementById("exportDefaultBtn");
|
||||
const importUploadJsonFileInput = document.getElementById("importUploadJsonFileInput");
|
||||
const importPasteFromClipboardBtn = document.getElementById("importPasteFromClipboardBtn");
|
||||
const exportDownloadJsonFileBtn = document.getElementById("exportDownloadJsonFileBtn");
|
||||
const exportCopyToClipboardBtn = document.getElementById("exportCopyToClipboardBtn");
|
||||
|
||||
// display import/export from/to clipboard buttons if supported
|
||||
if (navigator.clipboard && navigator.clipboard.readText) {
|
||||
importPasteFromClipboardBtn.parentElement.classList.remove("d-none");
|
||||
}
|
||||
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||||
exportCopyToClipboardBtn.parentElement.classList.remove("d-none");
|
||||
}
|
||||
|
||||
function flashButtonSuccess(button) {
|
||||
const originalClass = button.className;
|
||||
|
||||
button.classList.remove("btn-outline-primary");
|
||||
button.classList.add("btn-success", "success-flash");
|
||||
|
||||
setTimeout(() => {
|
||||
button.className = originalClass;
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
// Import handlers
|
||||
async function handleJsonFileInputChange(event) {
|
||||
try {
|
||||
await loadBookmarks(async function () {
|
||||
return getBookmarkDataFromJson(event);
|
||||
});
|
||||
flashButtonSuccess(importDefaultBtn);
|
||||
} catch (error) {
|
||||
console.error(`Failed to import bookmarks from JSON file: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function importBookmarksFromClipboard() {
|
||||
console.log("Importing bookmarks from clipboard...");
|
||||
|
||||
try {
|
||||
await loadBookmarks(async function () {
|
||||
const clipboardText = await navigator.clipboard.readText();
|
||||
if (!clipboardText) return [];
|
||||
|
||||
return JSON.parse(clipboardText);
|
||||
});
|
||||
flashButtonSuccess(importDefaultBtn);
|
||||
} catch (error) {
|
||||
console.error(`Failed to import bookmarks from clipboard: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleBookmarksPasteFromClipboard(event) {
|
||||
// do not override normal paste behavior on input fields
|
||||
if (event.target.tagName.toLowerCase() === "input") return;
|
||||
|
||||
try {
|
||||
await loadBookmarks(async function () {
|
||||
const clipboardText = event.clipboardData?.getData("text/plain");
|
||||
if (!clipboardText) return [];
|
||||
|
||||
return JSON.parse(clipboardText);
|
||||
});
|
||||
flashButtonSuccess(importDefaultBtn);
|
||||
} catch (error) {
|
||||
console.error(`Failed to import bookmarks from clipboard (ctrl-v): ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Export handlers
|
||||
async function exportBookmarksToJson() {
|
||||
console.log("Exporting bookmarks to JSON...");
|
||||
|
||||
try {
|
||||
const bookmarkData = bookmarkDataInput.value;
|
||||
const blob = new Blob([bookmarkData], { type: "application/json" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = "bookmarks.json";
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
flashButtonSuccess(exportDefaultBtn);
|
||||
} catch (error) {
|
||||
console.error(`Failed to export bookmarks to JSON: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function exportBookmarksToClipboard() {
|
||||
const bookmarkData = bookmarkDataInput.value;
|
||||
try {
|
||||
await navigator.clipboard.writeText(bookmarkData);
|
||||
flashButtonSuccess(exportDefaultBtn);
|
||||
} catch (error) {
|
||||
console.error(`Failed to export bookmarks to clipboard: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleBookmarksCopyToClipboard(event) {
|
||||
// do not override normal copy behavior on input fields
|
||||
if (event.target.tagName.toLowerCase() === "input") return;
|
||||
|
||||
const bookmarkData = bookmarkDataInput.value;
|
||||
|
||||
try {
|
||||
event.clipboardData.setData("text/plain", bookmarkData);
|
||||
event.preventDefault();
|
||||
flashButtonSuccess(exportDefaultBtn);
|
||||
} catch (error) {
|
||||
console.error(`Failed to export bookmarks to clipboard (ctrl-c): ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// register event listeners for import/export functions
|
||||
importUploadJsonFileInput.addEventListener("change", handleJsonFileInputChange);
|
||||
importPasteFromClipboardBtn.addEventListener("click", importBookmarksFromClipboard);
|
||||
exportDownloadJsonFileBtn.addEventListener("click", exportBookmarksToJson);
|
||||
exportCopyToClipboardBtn.addEventListener("click", exportBookmarksToClipboard);
|
||||
document.body.addEventListener("copy", handleBookmarksCopyToClipboard);
|
||||
document.body.addEventListener("paste", handleBookmarksPasteFromClipboard);
|
||||
// set default actions
|
||||
// importDefaultBtn is already handled by being a label for the file input
|
||||
exportDefaultBtn.addEventListener("click", exportBookmarksToJson);
|
||||
|
||||
// Listen for theme changes to update badge colors
|
||||
const observer = new MutationObserver(function (mutations) {
|
||||
mutations.forEach(function (mutation) {
|
||||
if (mutation.attributeName === "data-bs-theme") {
|
||||
const isDarkMode = document.documentElement.getAttribute("data-bs-theme") === "dark";
|
||||
document.querySelectorAll(".badge").forEach((badge) => {
|
||||
badge.classList.remove("bg-secondary", "bg-info");
|
||||
badge.classList.add(isDarkMode ? "bg-info" : "bg-secondary");
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
observer.observe(document.documentElement, { attributes: true });
|
||||
|
||||
// Add visual enhancement to clearly show the top-level/child relationship
|
||||
document.addEventListener("mouseover", function (e) {
|
||||
// When hovering over add buttons, highlight their relationship targets
|
||||
const button = e.target.closest(".btn-add-child, .btn-add-sibling");
|
||||
if (button) {
|
||||
if (button.classList.contains("btn-add-child")) {
|
||||
// Highlight parent-child relationship
|
||||
const bookmarkItem = button.closest(".bookmark-item");
|
||||
if (bookmarkItem) {
|
||||
bookmarkItem.style.boxShadow = "0 0 0 2px var(--btn-add-child-border, #198754)";
|
||||
}
|
||||
} else if (button.classList.contains("btn-add-sibling")) {
|
||||
// Highlight sibling relationship
|
||||
const bookmarkItem = button.closest(".bookmark-item");
|
||||
if (bookmarkItem) {
|
||||
// Find siblings
|
||||
const parent = bookmarkItem.parentElement;
|
||||
const siblings = parent.querySelectorAll(":scope > .bookmark-item");
|
||||
siblings.forEach((sibling) => {
|
||||
if (sibling !== bookmarkItem) {
|
||||
sibling.style.boxShadow = "0 0 0 2px var(--btn-add-sibling-border, #0d6efd)";
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener("mouseout", function (e) {
|
||||
// Remove highlights when not hovering
|
||||
const button = e.target.closest(".btn-add-child, .btn-add-sibling");
|
||||
if (button) {
|
||||
// Remove all highlights
|
||||
document.querySelectorAll(".bookmark-item").forEach((item) => {
|
||||
item.style.boxShadow = "";
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,143 +0,0 @@
|
||||
|
||||
window.onload = function () {
|
||||
var items = document.querySelectorAll(".dropdown-item, .nav-link");
|
||||
var dummyContainer = document.createElement("div");
|
||||
dummyContainer.style.position = "absolute";
|
||||
dummyContainer.style.visibility = "hidden";
|
||||
dummyContainer.style.whiteSpace = "nowrap"; // Ensure we measure full width
|
||||
document.body.appendChild(dummyContainer);
|
||||
|
||||
var maxWidth = 0;
|
||||
|
||||
items.forEach(function (item) {
|
||||
var clone = item.cloneNode(true);
|
||||
dummyContainer.appendChild(clone);
|
||||
var width = clone.offsetWidth;
|
||||
if (width > maxWidth) {
|
||||
maxWidth = width;
|
||||
}
|
||||
dummyContainer.removeChild(clone);
|
||||
});
|
||||
|
||||
document.body.removeChild(dummyContainer);
|
||||
|
||||
// Store max width for later use
|
||||
window.navItemMaxWidth = maxWidth;
|
||||
};
|
||||
|
||||
document.querySelector("#navbarSearchInput").addEventListener("input", function (e) {
|
||||
var searchText = e.target.value.trim().toLowerCase();
|
||||
var items = document.querySelectorAll("a.dropdown-item[data-bs-tags]");
|
||||
var resultsBox = document.querySelector("#searchResults");
|
||||
|
||||
resultsBox.innerHTML = "";
|
||||
|
||||
if (searchText !== "") {
|
||||
var addedResults = new Set();
|
||||
|
||||
items.forEach(function (item) {
|
||||
var titleElement = item.querySelector(".icon-text");
|
||||
var iconElement = item.querySelector(".material-symbols-rounded, .icon");
|
||||
var itemHref = item.getAttribute("href");
|
||||
var tags = item.getAttribute("data-bs-tags") || "";
|
||||
|
||||
if (titleElement && iconElement && itemHref !== "#") {
|
||||
var title = titleElement.innerText.trim();
|
||||
|
||||
if (
|
||||
(title.toLowerCase().includes(searchText) || tags.toLowerCase().includes(searchText)) &&
|
||||
!addedResults.has(itemHref)
|
||||
) {
|
||||
var dropdownItem = document.createElement("div");
|
||||
dropdownItem.className = "dropdown-item d-flex justify-content-between align-items-center";
|
||||
|
||||
var contentWrapper = document.createElement("div");
|
||||
contentWrapper.className = "d-flex align-items-center flex-grow-1";
|
||||
contentWrapper.style.textDecoration = "none";
|
||||
contentWrapper.style.color = "inherit";
|
||||
|
||||
var divElement = item.querySelector("div");
|
||||
if (divElement) {
|
||||
var originalContent = divElement.cloneNode(true);
|
||||
contentWrapper.appendChild(originalContent);
|
||||
} else {
|
||||
// Fallback: create content manually if div is not found
|
||||
var fallbackContent = document.createElement("div");
|
||||
fallbackContent.innerHTML = item.innerHTML;
|
||||
contentWrapper.appendChild(fallbackContent);
|
||||
}
|
||||
|
||||
contentWrapper.onclick = function () {
|
||||
window.location.href = itemHref;
|
||||
};
|
||||
|
||||
dropdownItem.appendChild(contentWrapper);
|
||||
resultsBox.appendChild(dropdownItem);
|
||||
addedResults.add(itemHref);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
resultsBox.style.width = window.navItemMaxWidth + "px";
|
||||
});
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
const searchDropdown = document.getElementById('searchDropdown');
|
||||
const searchInput = document.getElementById('navbarSearchInput');
|
||||
|
||||
// Check if elements are missing and skip initialization if necessary
|
||||
if (!searchDropdown || !searchInput) {
|
||||
console.warn('Search dropdown or input not found. Skipping initialization.');
|
||||
return;
|
||||
}
|
||||
const dropdownMenu = searchDropdown.querySelector('.dropdown-menu');
|
||||
if (!dropdownMenu) {
|
||||
console.warn('Dropdown menu not found within the search dropdown. Skipping initialization.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Create a single dropdown instance
|
||||
const dropdownInstance = new bootstrap.Dropdown(searchDropdown);
|
||||
|
||||
// Handle click for mobile
|
||||
searchDropdown.addEventListener('click', function (e) {
|
||||
e.preventDefault();
|
||||
const isOpen = dropdownMenu.classList.contains('show');
|
||||
// Close all other open dropdowns
|
||||
document.querySelectorAll('.navbar-nav .dropdown-menu.show').forEach((menu) => {
|
||||
if (menu !== dropdownMenu) {
|
||||
const parentDropdown = menu.closest('.dropdown');
|
||||
if (parentDropdown) {
|
||||
const parentToggle = parentDropdown.querySelector('[data-bs-toggle="dropdown"]');
|
||||
if (parentToggle) {
|
||||
let instance = bootstrap.Dropdown.getInstance(parentToggle);
|
||||
if (!instance) {
|
||||
instance = new bootstrap.Dropdown(parentToggle);
|
||||
}
|
||||
instance.hide();
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
if (!isOpen) {
|
||||
dropdownInstance.show();
|
||||
setTimeout(() => searchInput.focus(), 150);
|
||||
} else {
|
||||
dropdownInstance.hide();
|
||||
}
|
||||
});
|
||||
|
||||
// Hide dropdown if it's open and user clicks outside
|
||||
document.addEventListener('click', function (e) {
|
||||
if (!searchDropdown.contains(e.target) && dropdownMenu.classList.contains('show')) {
|
||||
dropdownInstance.hide();
|
||||
}
|
||||
});
|
||||
|
||||
// Keep dropdown open if search input is clicked
|
||||
searchInput.addEventListener('click', function (e) {
|
||||
e.stopPropagation();
|
||||
});
|
||||
|
||||
});
|
||||
@@ -1,44 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html th:data-language="${#locale.toString()}" th:dir="#{language.direction}" th:lang="${#locale.language}"
|
||||
xmlns:th="https://www.thymeleaf.org">
|
||||
|
||||
<head>
|
||||
<th:block th:insert="~{fragments/common :: head(title=#{cbrToPDF.title}, header=#{cbrToPDF.header})}"></th:block>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<th:block th:insert="~{fragments/common :: game}"></th:block>
|
||||
<div id="page-container">
|
||||
<div id="content-wrap">
|
||||
<th:block th:insert="~{fragments/navbar.html :: navbar}"></th:block>
|
||||
<br><br>
|
||||
<div class="container">
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-md-6 bg-card">
|
||||
<div class="tool-header">
|
||||
<span class="material-symbols-rounded tool-header-icon convertto">auto_stories</span>
|
||||
<span class="tool-header-text" th:text="#{cbrToPDF.header}"></span>
|
||||
</div>
|
||||
<form enctype="multipart/form-data" id="cbrToPDFForm" method="post"
|
||||
th:action="@{'/api/v1/convert/cbr/pdf'}">
|
||||
<div
|
||||
th:replace="~{fragments/common :: fileSelector(name='fileInput', multipleInputsForSingleRequest=false, accept='.cbr,.rar', inputText=#{cbrToPDF.selectText})}">
|
||||
</div>
|
||||
|
||||
<div class="form-check mb-3" th:if="${@endpointConfiguration.isGroupEnabled('Ghostscript')}">
|
||||
<input id="optimizeForEbook" name="optimizeForEbook" type="checkbox" value="true">
|
||||
<label for="optimizeForEbook" th:text="#{cbrToPDF.optimizeForEbook}">Optimize PDF for ebook readers (uses Ghostscript)</label>
|
||||
</div>
|
||||
|
||||
<br>
|
||||
<button class="btn btn-primary" id="submitBtn" th:text="#{cbrToPDF.submit}" type="submit">Convert to PDF</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<th:block th:insert="~{fragments/footer.html :: footer}"></th:block>
|
||||
</div>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -1,44 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html th:data-language="${#locale.toString()}" th:dir="#{language.direction}" th:lang="${#locale.language}"
|
||||
xmlns:th="https://www.thymeleaf.org">
|
||||
|
||||
<head>
|
||||
<th:block th:insert="~{fragments/common :: head(title=#{cbzToPDF.title}, header=#{cbzToPDF.header})}"></th:block>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<th:block th:insert="~{fragments/common :: game}"></th:block>
|
||||
<div id="page-container">
|
||||
<div id="content-wrap">
|
||||
<th:block th:insert="~{fragments/navbar.html :: navbar}"></th:block>
|
||||
<br><br>
|
||||
<div class="container">
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-md-6 bg-card">
|
||||
<div class="tool-header">
|
||||
<span class="material-symbols-rounded tool-header-icon convertto">auto_stories</span>
|
||||
<span class="tool-header-text" th:text="#{cbzToPDF.header}"></span>
|
||||
</div>
|
||||
<form enctype="multipart/form-data" id="cbzToPDFForm" method="post"
|
||||
th:action="@{'/api/v1/convert/cbz/pdf'}">
|
||||
<div
|
||||
th:replace="~{fragments/common :: fileSelector(name='fileInput', multipleInputsForSingleRequest=false, accept='.cbz,.zip', inputText=#{cbzToPDF.selectText})}">
|
||||
</div>
|
||||
|
||||
<div class="form-check mb-3" th:if="${@endpointConfiguration.isGroupEnabled('Ghostscript')}">
|
||||
<input id="optimizeForEbook" name="optimizeForEbook" type="checkbox" value="true">
|
||||
<label for="optimizeForEbook" th:text="#{cbzToPDF.optimizeForEbook}">Optimize PDF for ebook readers (uses Ghostscript)</label>
|
||||
</div>
|
||||
|
||||
<br>
|
||||
<button class="btn btn-primary" id="submitBtn" th:text="#{cbzToPDF.submit}" type="submit">Convert to PDF</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<th:block th:insert="~{fragments/footer.html :: footer}"></th:block>
|
||||
</div>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -1,45 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html th:data-language="${#locale.toString()}" th:dir="#{language.direction}" th:lang="${#locale.language}"
|
||||
xmlns:th="https://www.thymeleaf.org">
|
||||
|
||||
<head>
|
||||
<th:block th:insert="~{fragments/common :: head(title=#{pdfToCBR.title}, header=#{pdfToCBR.header})}"></th:block>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<th:block th:insert="~{fragments/common :: game}"></th:block>
|
||||
<div id="page-container">
|
||||
<div id="content-wrap">
|
||||
<th:block th:insert="~{fragments/navbar.html :: navbar}"></th:block>
|
||||
<br><br>
|
||||
<div class="container">
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-md-6 bg-card">
|
||||
<div class="tool-header">
|
||||
<span class="material-symbols-rounded tool-header-icon convertto">auto_stories</span>
|
||||
<span class="tool-header-text" th:text="#{pdfToCBR.header}"></span>
|
||||
</div>
|
||||
<form enctype="multipart/form-data" id="pdfToCBRForm" method="post"
|
||||
th:action="@{'/api/v1/convert/pdf/cbr'}">
|
||||
<div
|
||||
th:replace="~{fragments/common :: fileSelector(name='fileInput', multipleInputsForSingleRequest=false, accept='.pdf', inputText=#{pdfToCBR.selectText})}">
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label" for="dpi" th:text="#{pdfToCBR.dpi}">DPI:</label>
|
||||
<input class="form-control" id="dpi" max="600" min="50" name="dpi" type="number" value="300">
|
||||
<div class="form-text" th:text="#{pdfToCBR.dpiHelp}">Higher DPI results in better quality but larger file size.</div>
|
||||
</div>
|
||||
|
||||
<br>
|
||||
<button class="btn btn-primary" id="submitBtn" th:text="#{pdfToCBR.submit}" type="submit">Convert to CBR</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<th:block th:insert="~{fragments/footer.html :: footer}"></th:block>
|
||||
</div>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user