Compare commits
24
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9ac260ee92 | ||
|
|
1b68a513a9 | ||
|
|
93d7919c4c | ||
|
|
3e4c984fcc | ||
|
|
c4c43593e6 | ||
|
|
fd1b7abc83 | ||
|
|
0c46f77179 | ||
|
|
afda066579 | ||
|
|
e9148437f6 | ||
|
|
cb835bcce1 | ||
|
|
161bfef7da | ||
|
|
690eceb548 | ||
|
|
b53c236234 | ||
|
|
abb8b1f721 | ||
|
|
012bd1af92 | ||
|
|
8b25db37ad | ||
|
|
48dd4154e9 | ||
|
|
cfe040485b | ||
|
|
c15ff1e832 | ||
|
|
c244edf8b7 | ||
|
|
13d7ee7496 | ||
|
|
930d7a0df8 | ||
|
|
6a1597bb8d | ||
|
|
d98ff194e4 |
@@ -68,3 +68,8 @@ updates:
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
rebase-strategy: "auto"
|
||||
|
||||
- package-ecosystem: cargo
|
||||
directory: /frontend/src-tauri/provisioner
|
||||
schedule:
|
||||
interval: daily
|
||||
|
||||
@@ -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@a6101c89c6feaecc585efdd8d461f18bb7896f20 # v2.0.5
|
||||
uses: actions/ai-inference@e09e65981758de8b2fdab13c2bfb7c7d5493b0b6 # v2.0.7
|
||||
with:
|
||||
model: openai/gpt-4o
|
||||
system-prompt-file: ".github/config/system-prompt.txt"
|
||||
|
||||
@@ -69,7 +69,7 @@ jobs:
|
||||
distribution: "temurin"
|
||||
|
||||
- name: Cache Gradle dependency artifacts
|
||||
uses: actions/cache@v4
|
||||
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/wrapper
|
||||
@@ -147,14 +147,14 @@ jobs:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Set up JDK 21
|
||||
- name: Set up JDK 25
|
||||
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
|
||||
with:
|
||||
java-version: "25"
|
||||
distribution: "temurin"
|
||||
|
||||
- name: Cache Gradle dependency artifacts
|
||||
uses: actions/cache@v4
|
||||
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/wrapper
|
||||
@@ -236,7 +236,7 @@ jobs:
|
||||
distribution: "temurin"
|
||||
|
||||
- name: Cache Gradle dependency artifacts
|
||||
uses: actions/cache@v4
|
||||
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/wrapper
|
||||
@@ -309,7 +309,7 @@ jobs:
|
||||
distribution: "temurin"
|
||||
|
||||
- name: Cache Gradle dependency artifacts
|
||||
uses: actions/cache@v4
|
||||
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/wrapper
|
||||
@@ -328,7 +328,7 @@ jobs:
|
||||
|
||||
# Expose ACTIONS_RUNTIME_TOKEN / ACTIONS_RESULTS_URL for docker buildx type=gha cache backend.
|
||||
- name: Expose GitHub runtime for Buildx cache
|
||||
uses: crazy-max/ghaction-github-runtime@v3
|
||||
uses: crazy-max/ghaction-github-runtime@3cb05d89e1f492524af3d41a1c98c83bc3025124 # v3.1.0
|
||||
|
||||
- name: Install Docker Compose
|
||||
run: |
|
||||
@@ -416,7 +416,7 @@ jobs:
|
||||
distribution: "temurin"
|
||||
|
||||
- name: Cache Gradle dependency artifacts
|
||||
uses: actions/cache@v4
|
||||
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/wrapper
|
||||
|
||||
@@ -49,7 +49,7 @@ jobs:
|
||||
distribution: "temurin"
|
||||
|
||||
- name: Cache Gradle dependencies
|
||||
uses: actions/cache@v4
|
||||
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
@@ -419,8 +419,9 @@ jobs:
|
||||
APPIMAGETOOL_SIGN_PASSPHRASE: ${{ secrets.APPIMAGETOOL_SIGN_PASSPHRASE }}
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
||||
VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY: ${{ secrets.VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY }}
|
||||
VITE_SAAS_SERVER_URL: ${{ secrets.VITE_SAAS_SERVER_URL }}
|
||||
VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY: ${{ secrets.VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY || 'sb_publishable_UHz2SVRF5mvdrPHWkRteyA_yNlZTkYb' }}
|
||||
VITE_SAAS_SERVER_URL: ${{ secrets.VITE_SAAS_SERVER_URL || 'https://app.stirlingpdf.com' }}
|
||||
VITE_SAAS_BACKEND_API_URL: ${{ secrets.VITE_SAAS_BACKEND_API_URL || 'https://api.stirlingpdf.com' }}
|
||||
# Only enable Windows signing in Tauri when on release or V2-master
|
||||
SIGN: ${{ (github.event_name == 'release' || github.ref == 'refs/heads/V2-master') && (env.SM_API_KEY == '' && env.WINDOWS_CERTIFICATE != '') && '1' || '0' }}
|
||||
CI: true
|
||||
|
||||
@@ -59,10 +59,10 @@ jobs:
|
||||
pre-commit run trailing-whitespace --all-files -c .pre-commit-config.yaml
|
||||
continue-on-error: true
|
||||
|
||||
- name: Set up JDK 21
|
||||
- name: Set up JDK 25
|
||||
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
|
||||
with:
|
||||
java-version: 21
|
||||
java-version: "25"
|
||||
distribution: "temurin"
|
||||
|
||||
- name: Setup Gradle
|
||||
|
||||
@@ -46,7 +46,7 @@ jobs:
|
||||
distribution: "temurin"
|
||||
|
||||
- name: Cache Gradle dependencies
|
||||
uses: actions/cache@v4
|
||||
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
|
||||
@@ -75,6 +75,6 @@ jobs:
|
||||
|
||||
# Upload the results to GitHub's code scanning dashboard.
|
||||
- name: "Upload to code-scanning"
|
||||
uses: github/codeql-action/upload-sarif@cdefb33c0f6224e58673d9004f47f7cb3e328b89 # v3.29.5
|
||||
uses: github/codeql-action/upload-sarif@89a39a4e59826350b863aa6b6252a07ad50cf83e # v3.29.5
|
||||
with:
|
||||
sarif_file: results.sarif
|
||||
|
||||
@@ -73,6 +73,7 @@ jobs:
|
||||
env:
|
||||
SM_API_KEY: ${{ secrets.SM_API_KEY }}
|
||||
WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }}
|
||||
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
|
||||
@@ -101,10 +102,10 @@ jobs:
|
||||
toolchain: stable
|
||||
targets: ${{ (matrix.platform == 'macos-15' || matrix.platform == 'macos-15-intel') && 'aarch64-apple-darwin,x86_64-apple-darwin' || '' }}
|
||||
|
||||
- name: Set up JDK 21
|
||||
- name: Set up JDK 25
|
||||
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
|
||||
with:
|
||||
java-version: "21"
|
||||
java-version: "25"
|
||||
distribution: "temurin"
|
||||
|
||||
- name: Setup Gradle
|
||||
@@ -271,7 +272,7 @@ jobs:
|
||||
}
|
||||
|
||||
- name: Import Apple Developer Certificate
|
||||
if: matrix.platform == 'macos-15' || matrix.platform == 'macos-15-intel'
|
||||
if: (matrix.platform == 'macos-15' || matrix.platform == 'macos-15-intel') && env.APPLE_CERTIFICATE != ''
|
||||
env:
|
||||
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
|
||||
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
|
||||
@@ -292,7 +293,7 @@ jobs:
|
||||
rm certificate.p12
|
||||
|
||||
- name: Verify Certificate
|
||||
if: matrix.platform == 'macos-15' || matrix.platform == 'macos-15-intel'
|
||||
if: (matrix.platform == 'macos-15' || matrix.platform == 'macos-15-intel') && env.APPLE_CERTIFICATE != ''
|
||||
run: |
|
||||
echo "Verifying Apple Developer Certificate..."
|
||||
KEYCHAIN_PATH=$RUNNER_TEMP/app-signing.keychain-db
|
||||
@@ -327,9 +328,9 @@ jobs:
|
||||
APPIMAGETOOL_SIGN_PASSPHRASE: ${{ secrets.APPIMAGETOOL_SIGN_PASSPHRASE }}
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
||||
VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY: ${{ secrets.VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY }}
|
||||
VITE_SAAS_SERVER_URL: ${{ secrets.VITE_SAAS_SERVER_URL }}
|
||||
VITE_SAAS_BACKEND_API_URL: ${{ secrets.VITE_SAAS_BACKEND_API_URL }}
|
||||
VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY: ${{ secrets.VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY || 'sb_publishable_UHz2SVRF5mvdrPHWkRteyA_yNlZTkYb' }}
|
||||
VITE_SAAS_SERVER_URL: ${{ secrets.VITE_SAAS_SERVER_URL || 'https://app.stirlingpdf.com' }}
|
||||
VITE_SAAS_BACKEND_API_URL: ${{ secrets.VITE_SAAS_BACKEND_API_URL || 'https://api.stirlingpdf.com' }}
|
||||
# Only enable Windows signing in Tauri when on main
|
||||
SIGN: ${{ github.ref == 'refs/heads/main' && (env.SM_API_KEY == '' && env.WINDOWS_CERTIFICATE != '') && '1' || '0' }}
|
||||
CI: true
|
||||
|
||||
@@ -32,10 +32,10 @@ jobs:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Set up JDK 21
|
||||
- name: Set up JDK 25
|
||||
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
|
||||
with:
|
||||
java-version: "21"
|
||||
java-version: "25"
|
||||
distribution: "temurin"
|
||||
|
||||
- name: Setup Gradle
|
||||
|
||||
@@ -308,7 +308,7 @@ The frontend is organized with a clear separation of concerns:
|
||||
|
||||
## Important Notes
|
||||
|
||||
- **Java Version**: Minimum JDK 17, supports and recommends JDK 21
|
||||
- **Java Version**: Minimum JDK 21, supports and recommends JDK 25
|
||||
- **Lombok**: Used extensively - ensure IDE plugin is installed
|
||||
- **File Persistence**:
|
||||
- **Backend**: Designed to be stateless - files are processed in memory/temp locations only
|
||||
|
||||
+3
-3
@@ -11,7 +11,7 @@ This guide focuses on developing for Stirling 2.0, including both the React fron
|
||||
**Stirling 2.0** is built using:
|
||||
|
||||
**Backend:**
|
||||
- Spring Boot (Java 17+, JDK 21 recommended)
|
||||
- Spring Boot (Java 21+, JDK 25 recommended)
|
||||
- PDFBox for core PDF operations
|
||||
- LibreOffice for document conversions
|
||||
- qpdf for PDF optimization
|
||||
@@ -44,7 +44,7 @@ This guide focuses on developing for Stirling 2.0, including both the React fron
|
||||
|
||||
- Docker
|
||||
- Git
|
||||
- Java JDK 17 or later (JDK 21 recommended)
|
||||
- Java JDK 21 or later (JDK 25 recommended)
|
||||
- Node.js 18+ and npm (required for frontend development)
|
||||
- Gradle 7.0 or later (Included within the repo)
|
||||
- Rust and Cargo (required for Tauri desktop app development)
|
||||
@@ -59,7 +59,7 @@ This guide focuses on developing for Stirling 2.0, including both the React fron
|
||||
cd Stirling-PDF
|
||||
```
|
||||
|
||||
2. Install Docker and JDK17 if not already installed.
|
||||
2. Install Docker and JDK 21 (or JDK 25 recommended) if not already installed.
|
||||
|
||||
3. Install a recommended Java IDE such as Eclipse, IntelliJ, or VSCode
|
||||
1. Only VSCode
|
||||
|
||||
@@ -5,10 +5,10 @@ bootRun {
|
||||
spotless {
|
||||
java {
|
||||
target 'src/**/java/**/*.java'
|
||||
targetExclude 'src/main/java/org/apache/**'
|
||||
googleJavaFormat(googleJavaFormatVersion).aosp().reorderImports(false)
|
||||
|
||||
importOrder("java", "javax", "org", "com", "net", "io", "jakarta", "lombok", "me", "stirling")
|
||||
toggleOffOn()
|
||||
trimTrailingWhitespace()
|
||||
leadingTabsToSpaces()
|
||||
endWithNewline()
|
||||
@@ -27,6 +27,7 @@ spotless {
|
||||
}
|
||||
}
|
||||
dependencies {
|
||||
api 'com.google.guava:guava:33.4.8-jre'
|
||||
api 'org.springframework.boot:spring-boot-starter-webmvc'
|
||||
api 'org.springframework.boot:spring-boot-starter-aspectj'
|
||||
api 'com.googlecode.owasp-java-html-sanitizer:owasp-java-html-sanitizer:20260102.1'
|
||||
|
||||
@@ -103,8 +103,9 @@ public class EndpointConfiguration {
|
||||
|
||||
// Rule 2: Functional-group override - check if endpoint belongs to any disabled functional
|
||||
// group
|
||||
for (String group : endpointGroups.keySet()) {
|
||||
if (disabledGroups.contains(group) && endpointGroups.get(group).contains(endpoint)) {
|
||||
for (Map.Entry<String, Set<String>> entry : endpointGroups.entrySet()) {
|
||||
String group = entry.getKey();
|
||||
if (disabledGroups.contains(group) && entry.getValue().contains(endpoint)) {
|
||||
// Skip tool groups (qpdf, OCRmyPDF, Ghostscript, LibreOffice, etc.)
|
||||
if (!isToolGroup(group)) {
|
||||
log.debug(
|
||||
@@ -131,10 +132,11 @@ public class EndpointConfiguration {
|
||||
|
||||
// Rule 4: Single-dependency check - if no alternatives defined, check if endpoint belongs
|
||||
// to any disabled tool groups
|
||||
for (String group : endpointGroups.keySet()) {
|
||||
for (Map.Entry<String, Set<String>> entry : endpointGroups.entrySet()) {
|
||||
String group = entry.getKey();
|
||||
if (isToolGroup(group)
|
||||
&& disabledGroups.contains(group)
|
||||
&& endpointGroups.get(group).contains(endpoint)) {
|
||||
&& entry.getValue().contains(endpoint)) {
|
||||
log.debug(
|
||||
"isEndpointEnabled('{}') -> false (single tool group '{}' disabled, no alternatives)",
|
||||
original,
|
||||
@@ -645,8 +647,9 @@ public class EndpointConfiguration {
|
||||
}
|
||||
|
||||
// Check if endpoint belongs to any disabled functional group
|
||||
for (String group : endpointGroups.keySet()) {
|
||||
if (disabledGroups.contains(group) && endpointGroups.get(group).contains(endpoint)) {
|
||||
for (Map.Entry<String, Set<String>> entry : endpointGroups.entrySet()) {
|
||||
String group = entry.getKey();
|
||||
if (disabledGroups.contains(group) && entry.getValue().contains(endpoint)) {
|
||||
if (!isToolGroup(group)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -365,11 +365,11 @@ public class ApplicationProperties {
|
||||
}
|
||||
|
||||
public boolean isSettingsValid() {
|
||||
return !ValidationUtils.isStringEmpty(this.getIssuer())
|
||||
&& !ValidationUtils.isStringEmpty(this.getClientId())
|
||||
&& !ValidationUtils.isStringEmpty(this.getClientSecret())
|
||||
&& !ValidationUtils.isCollectionEmpty(this.getScopes())
|
||||
&& !ValidationUtils.isStringEmpty(this.getUseAsUsername());
|
||||
return !ValidationUtils.isStringEmpty(this.issuer)
|
||||
&& !ValidationUtils.isStringEmpty(this.clientId)
|
||||
&& !ValidationUtils.isStringEmpty(this.clientSecret)
|
||||
&& !ValidationUtils.isCollectionEmpty(this.scopes)
|
||||
&& !ValidationUtils.isStringEmpty(this.useAsUsername);
|
||||
}
|
||||
|
||||
@Data
|
||||
@@ -575,19 +575,17 @@ public class ApplicationProperties {
|
||||
}
|
||||
|
||||
public boolean isAnalyticsEnabled() {
|
||||
return this.getEnableAnalytics() != null && this.getEnableAnalytics();
|
||||
return this.enableAnalytics != null && this.enableAnalytics;
|
||||
}
|
||||
|
||||
public boolean isPosthogEnabled() {
|
||||
// Treat null as enabled when analytics is enabled
|
||||
return this.isAnalyticsEnabled()
|
||||
&& (this.getEnablePosthog() == null || this.getEnablePosthog());
|
||||
return this.isAnalyticsEnabled() && (this.enablePosthog == null || this.enablePosthog);
|
||||
}
|
||||
|
||||
public boolean isScarfEnabled() {
|
||||
// Treat null as enabled when analytics is enabled
|
||||
return this.isAnalyticsEnabled()
|
||||
&& (this.getEnableScarf() == null || this.getEnableScarf());
|
||||
return this.isAnalyticsEnabled() && (this.enableScarf == null || this.enableScarf);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -42,7 +42,7 @@ public enum Role {
|
||||
// Using the fromString method to get the Role enum based on the roleId
|
||||
Role role = fromString(roleId);
|
||||
// Return the roleName of the found Role enum
|
||||
return role.getRoleName();
|
||||
return role.roleName;
|
||||
}
|
||||
|
||||
// Method to retrieve all role IDs and role names
|
||||
@@ -50,14 +50,14 @@ public enum Role {
|
||||
// Using LinkedHashMap to preserve order
|
||||
Map<String, String> roleDetails = new LinkedHashMap<>();
|
||||
for (Role role : Role.values()) {
|
||||
roleDetails.put(role.getRoleId(), role.getRoleName());
|
||||
roleDetails.put(role.roleId, role.roleName);
|
||||
}
|
||||
return roleDetails;
|
||||
}
|
||||
|
||||
public static Role fromString(String roleId) {
|
||||
for (Role role : Role.values()) {
|
||||
if (role.getRoleId().equalsIgnoreCase(roleId)) {
|
||||
if (role.roleId.equalsIgnoreCase(roleId)) {
|
||||
return role;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -117,13 +117,13 @@ public class Provider {
|
||||
+ ", clientName="
|
||||
+ getClientName()
|
||||
+ ", clientId="
|
||||
+ getClientId()
|
||||
+ clientId
|
||||
+ ", clientSecret="
|
||||
+ (getClientSecret() != null && !getClientSecret().isEmpty() ? "*****" : "NULL")
|
||||
+ (clientSecret != null && !clientSecret.isEmpty() ? "*****" : "NULL")
|
||||
+ ", scopes="
|
||||
+ getScopes()
|
||||
+ ", useAsUsername="
|
||||
+ getUseAsUsername()
|
||||
+ useAsUsername
|
||||
+ "]";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package stirling.software.common.service;
|
||||
|
||||
import java.io.BufferedInputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.UUID;
|
||||
@@ -21,6 +23,9 @@ import lombok.extern.slf4j.Slf4j;
|
||||
@Slf4j
|
||||
public class FileStorage {
|
||||
|
||||
/** Holds the result of a stream-to-disk store operation: the file ID and the bytes written. */
|
||||
public record StoredFile(String fileId, long size) {}
|
||||
|
||||
@Value("${stirling.tempDir:/tmp/stirling-files}")
|
||||
private String tempDirPath;
|
||||
|
||||
@@ -104,6 +109,40 @@ public class FileStorage {
|
||||
return Files.readAllBytes(filePath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve a file by its ID as a streaming InputStream. The caller is responsible for closing
|
||||
* the returned stream.
|
||||
*
|
||||
* @param fileId The ID of the file to retrieve
|
||||
* @return A buffered InputStream for the file
|
||||
* @throws IOException If the file doesn't exist or can't be read
|
||||
*/
|
||||
public InputStream retrieveInputStream(String fileId) throws IOException {
|
||||
Path filePath = getFilePath(fileId);
|
||||
// Let Files.newInputStream throw NoSuchFileException naturally — avoids TOCTOU race
|
||||
// between exists-check and open when another thread may delete concurrently.
|
||||
return new BufferedInputStream(Files.newInputStream(filePath));
|
||||
}
|
||||
|
||||
/**
|
||||
* Store data from an InputStream as a file and return its unique ID and byte count. Streams
|
||||
* directly to disk without buffering the entire content in heap.
|
||||
*
|
||||
* @param inputStream The input stream to read from
|
||||
* @param originalName The original name of the file (unused, kept for API symmetry)
|
||||
* @return A {@link StoredFile} containing the file ID and the number of bytes written
|
||||
* @throws IOException If there is an error storing the file
|
||||
*/
|
||||
public StoredFile storeInputStream(InputStream inputStream, String originalName)
|
||||
throws IOException {
|
||||
String fileId = generateFileId();
|
||||
Path filePath = getFilePath(fileId);
|
||||
Files.createDirectories(filePath.getParent());
|
||||
long size = Files.copy(inputStream, filePath);
|
||||
log.debug("Stored input stream with ID: {}", fileId);
|
||||
return new StoredFile(fileId, size);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a file by its ID
|
||||
*
|
||||
|
||||
@@ -316,7 +316,7 @@ public class JobExecutorService {
|
||||
filename =
|
||||
disposition.substring(
|
||||
disposition.indexOf("filename=") + 9,
|
||||
disposition.lastIndexOf("\""));
|
||||
disposition.lastIndexOf('"'));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -129,7 +129,7 @@ public class MobileScannerService {
|
||||
FILE_EXTENSION_PATTERN.matcher(safeFilename).replaceFirst("");
|
||||
String ext =
|
||||
safeFilename.contains(".")
|
||||
? safeFilename.substring(safeFilename.lastIndexOf("."))
|
||||
? safeFilename.substring(safeFilename.lastIndexOf('.'))
|
||||
: "";
|
||||
safeFilename = nameWithoutExt + "-" + counter + ext;
|
||||
filePath = sessionDir.resolve(safeFilename).normalize().toAbsolutePath();
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
package stirling.software.common.service;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.BufferedInputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.ArrayList;
|
||||
@@ -19,7 +19,6 @@ import java.util.zip.ZipInputStream;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import io.github.pixee.security.ZipSecurity;
|
||||
|
||||
@@ -363,39 +362,29 @@ public class TaskManager {
|
||||
String zipFileId, String originalZipFileName) throws IOException {
|
||||
List<ResultFile> extractedFiles = new ArrayList<>();
|
||||
|
||||
MultipartFile zipFile = fileStorage.retrieveFile(zipFileId);
|
||||
|
||||
try (ZipInputStream zipIn =
|
||||
ZipSecurity.createHardenedInputStream(
|
||||
new ByteArrayInputStream(zipFile.getBytes()))) {
|
||||
try (InputStream fileStream = fileStorage.retrieveInputStream(zipFileId);
|
||||
ZipInputStream zipIn =
|
||||
ZipSecurity.createHardenedInputStream(
|
||||
new BufferedInputStream(fileStream))) {
|
||||
ZipEntry entry;
|
||||
while ((entry = zipIn.getNextEntry()) != null) {
|
||||
if (!entry.isDirectory()) {
|
||||
// Use buffered reading for memory safety
|
||||
ByteArrayOutputStream out = new ByteArrayOutputStream();
|
||||
byte[] buffer = new byte[4096];
|
||||
int bytesRead;
|
||||
while ((bytesRead = zipIn.read(buffer)) != -1) {
|
||||
out.write(buffer, 0, bytesRead);
|
||||
}
|
||||
byte[] fileContent = out.toByteArray();
|
||||
|
||||
String contentType = determineContentType(entry.getName());
|
||||
String individualFileId = fileStorage.storeBytes(fileContent, entry.getName());
|
||||
// storeInputStream returns the fileId and byte count — no extra stat needed
|
||||
FileStorage.StoredFile stored =
|
||||
fileStorage.storeInputStream(zipIn, entry.getName());
|
||||
|
||||
ResultFile resultFile =
|
||||
ResultFile.builder()
|
||||
.fileId(individualFileId)
|
||||
.fileId(stored.fileId())
|
||||
.fileName(entry.getName())
|
||||
.contentType(contentType)
|
||||
.fileSize(fileContent.length)
|
||||
.fileSize(stored.size())
|
||||
.build();
|
||||
|
||||
extractedFiles.add(resultFile);
|
||||
log.debug(
|
||||
"Extracted file: {} (size: {} bytes)",
|
||||
entry.getName(),
|
||||
fileContent.length);
|
||||
"Extracted file: {} (size: {} bytes)", entry.getName(), stored.size());
|
||||
}
|
||||
zipIn.closeEntry();
|
||||
}
|
||||
@@ -467,4 +456,24 @@ public class TaskManager {
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the job key that owns a given file ID.
|
||||
*
|
||||
* @param fileId file identifier to look up
|
||||
* @return scoped job key if found, otherwise null
|
||||
*/
|
||||
public String findJobKeyByFileId(String fileId) {
|
||||
for (Map.Entry<String, JobResult> entry : jobResults.entrySet()) {
|
||||
JobResult jobResult = entry.getValue();
|
||||
if (jobResult.hasFiles()) {
|
||||
for (ResultFile resultFile : jobResult.getAllResultFiles()) {
|
||||
if (fileId.equals(resultFile.getFileId())) {
|
||||
return entry.getKey();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import java.io.BufferedInputStream;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.nio.file.Files;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.Enumeration;
|
||||
@@ -30,15 +31,7 @@ import stirling.software.common.service.CustomPDFDocumentFactory;
|
||||
@UtilityClass
|
||||
public class CbzUtils {
|
||||
|
||||
public byte[] convertCbzToPdf(
|
||||
MultipartFile cbzFile,
|
||||
CustomPDFDocumentFactory pdfDocumentFactory,
|
||||
TempFileManager tempFileManager)
|
||||
throws IOException {
|
||||
return convertCbzToPdf(cbzFile, pdfDocumentFactory, tempFileManager, false);
|
||||
}
|
||||
|
||||
public byte[] convertCbzToPdf(
|
||||
public TempFile convertCbzToPdf(
|
||||
MultipartFile cbzFile,
|
||||
CustomPDFDocumentFactory pdfDocumentFactory,
|
||||
TempFileManager tempFileManager,
|
||||
@@ -64,70 +57,90 @@ public class CbzUtils {
|
||||
|
||||
try (PDDocument document = pdfDocumentFactory.createNewDocument();
|
||||
ZipFile zipFile = new ZipFile(tempFile.getFile())) {
|
||||
|
||||
// Pass 1: collect sorted image names (cheap just strings, no image data)
|
||||
List<String> sortedImageNames = new ArrayList<>();
|
||||
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());
|
||||
}
|
||||
sortedImageNames.add(entry.getName());
|
||||
}
|
||||
}
|
||||
sortedImageNames.sort(new NaturalOrderComparator());
|
||||
|
||||
imageEntries.sort(
|
||||
Comparator.comparing(ImageEntryData::name, new NaturalOrderComparator()));
|
||||
|
||||
if (imageEntries.isEmpty()) {
|
||||
if (sortedImageNames.isEmpty()) {
|
||||
throw ExceptionUtils.createCbzNoImagesException();
|
||||
}
|
||||
|
||||
for (ImageEntryData imageEntry : imageEntries) {
|
||||
try {
|
||||
PDImageXObject pdImage =
|
||||
PDImageXObject.createFromByteArray(
|
||||
document, imageEntry.data(), imageEntry.name());
|
||||
PDPage page =
|
||||
new PDPage(
|
||||
new PDRectangle(pdImage.getWidth(), pdImage.getHeight()));
|
||||
document.addPage(page);
|
||||
try (PDPageContentStream contentStream =
|
||||
new PDPageContentStream(
|
||||
document,
|
||||
page,
|
||||
PDPageContentStream.AppendMode.OVERWRITE,
|
||||
true,
|
||||
true)) {
|
||||
contentStream.drawImage(pdImage, 0, 0);
|
||||
// Pass 2: load ONE image at a time peak memory = max(single image)
|
||||
for (String imageName : sortedImageNames) {
|
||||
ZipEntry entry = zipFile.getEntry(imageName);
|
||||
try (InputStream is = zipFile.getInputStream(entry)) {
|
||||
ByteArrayOutputStream imgBaos = new ByteArrayOutputStream();
|
||||
is.transferTo(imgBaos);
|
||||
byte[] imageBytes = imgBaos.toByteArray();
|
||||
try {
|
||||
PDImageXObject pdImage =
|
||||
PDImageXObject.createFromByteArray(
|
||||
document, imageBytes, imageName);
|
||||
PDPage page =
|
||||
new PDPage(
|
||||
new PDRectangle(
|
||||
pdImage.getWidth(), pdImage.getHeight()));
|
||||
document.addPage(page);
|
||||
try (PDPageContentStream contentStream =
|
||||
new PDPageContentStream(
|
||||
document,
|
||||
page,
|
||||
PDPageContentStream.AppendMode.OVERWRITE,
|
||||
true,
|
||||
true)) {
|
||||
contentStream.drawImage(pdImage, 0, 0);
|
||||
}
|
||||
} catch (IOException e) {
|
||||
log.warn("Error processing image {}: {}", imageName, e.getMessage());
|
||||
}
|
||||
// imageBytes eligible for GC after each iteration
|
||||
} catch (IOException e) {
|
||||
log.warn(
|
||||
"Error processing image {}: {}", imageEntry.name(), e.getMessage());
|
||||
log.warn("Error reading image {}: {}", imageName, e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
if (document.getNumberOfPages() == 0) {
|
||||
throw ExceptionUtils.createCbzCorruptedImagesException();
|
||||
}
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
document.save(baos);
|
||||
byte[] pdfBytes = baos.toByteArray();
|
||||
|
||||
// Apply Ghostscript optimization if requested
|
||||
if (optimizeForEbook) {
|
||||
try {
|
||||
return GeneralUtils.optimizePdfWithGhostscript(pdfBytes);
|
||||
} catch (IOException e) {
|
||||
log.warn("Ghostscript optimization failed, returning unoptimized PDF", e);
|
||||
// Write to TempFile (not BAOS)
|
||||
TempFile pdfTempFile = new TempFile(tempFileManager, ".pdf");
|
||||
try {
|
||||
document.save(pdfTempFile.getFile());
|
||||
|
||||
if (optimizeForEbook) {
|
||||
try {
|
||||
byte[] pdfBytes = Files.readAllBytes(pdfTempFile.getPath());
|
||||
byte[] optimized = GeneralUtils.optimizePdfWithGhostscript(pdfBytes);
|
||||
pdfTempFile.close();
|
||||
TempFile optimizedFile = new TempFile(tempFileManager, ".pdf");
|
||||
try {
|
||||
Files.write(optimizedFile.getPath(), optimized);
|
||||
return optimizedFile;
|
||||
} catch (Exception e) {
|
||||
optimizedFile.close();
|
||||
throw e;
|
||||
}
|
||||
} catch (IOException e) {
|
||||
log.warn(
|
||||
"Ghostscript optimization failed, returning unoptimized PDF",
|
||||
e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return pdfBytes;
|
||||
return pdfTempFile;
|
||||
} catch (Exception e) {
|
||||
pdfTempFile.close();
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -175,8 +188,6 @@ public class CbzUtils {
|
||||
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) {
|
||||
|
||||
@@ -44,9 +44,7 @@ public class FileToPdf {
|
||||
sanitizeHtmlContent(
|
||||
new String(fileBytes, StandardCharsets.UTF_8),
|
||||
customHtmlSanitizer);
|
||||
Files.write(
|
||||
tempInputFile.getPath(),
|
||||
sanitizedHtml.getBytes(StandardCharsets.UTF_8));
|
||||
Files.writeString(tempInputFile.getPath(), sanitizedHtml);
|
||||
} else if (fileName.toLowerCase(Locale.ROOT).endsWith(".zip")) {
|
||||
Files.write(tempInputFile.getPath(), fileBytes);
|
||||
sanitizeHtmlFilesInZip(
|
||||
@@ -115,8 +113,7 @@ public class FileToPdf {
|
||||
new String(zipIn.readAllBytes(), StandardCharsets.UTF_8);
|
||||
String sanitizedContent =
|
||||
sanitizeHtmlContent(content, customHtmlSanitizer);
|
||||
Files.write(
|
||||
filePath, sanitizedContent.getBytes(StandardCharsets.UTF_8));
|
||||
Files.writeString(filePath, sanitizedContent);
|
||||
} else {
|
||||
Files.copy(zipIn, filePath);
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
import org.apache.pdfbox.cos.COSArray;
|
||||
import org.apache.pdfbox.cos.COSBase;
|
||||
@@ -74,6 +75,10 @@ public class FormUtils {
|
||||
*/
|
||||
private static final float SAME_LINE_THRESHOLD_PT = 10.0f;
|
||||
|
||||
private static final Pattern HEX_UUID_PATTERN =
|
||||
Pattern.compile("^[0-9a-fA-F]{8}[0-9a-fA-F]{24,}$");
|
||||
private static final Pattern WHITESPACE_PATTERN = Pattern.compile("\\s+");
|
||||
|
||||
/**
|
||||
* Returns a normalized logical type string for the supplied PDFBox field instance. Centralized
|
||||
* so all callers share identical mapping logic.
|
||||
@@ -1357,7 +1362,7 @@ public class FormUtils {
|
||||
if (da != null && !da.isBlank()) {
|
||||
// Standard DA looks like: /Helv 12 Tf 0 g
|
||||
// We want the number before 'Tf'
|
||||
String[] tokens = da.split("\\s+");
|
||||
String[] tokens = WHITESPACE_PATTERN.split(da);
|
||||
for (int i = 0; i < tokens.length; i++) {
|
||||
if ("Tf".equals(tokens[i]) && i > 0) {
|
||||
try {
|
||||
@@ -1457,9 +1462,8 @@ public class FormUtils {
|
||||
// Detect UUID-like hex strings (e.g. "cdc47b7041524571 7b2d93017fe77bf7")
|
||||
// Standard UUIDs are 32 hex characters; require at least that to avoid
|
||||
// false positives on short hex-like field names.
|
||||
String nospaces = simplified.replaceAll("\\s+", "");
|
||||
if (nospaces.length() >= 32 && nospaces.matches("^[0-9a-fA-F]{8}[0-9a-fA-F]{24,}$"))
|
||||
return true;
|
||||
String nospaces = WHITESPACE_PATTERN.matcher(simplified).replaceAll("");
|
||||
if (nospaces.length() >= 32 && HEX_UUID_PATTERN.matcher(nospaces).matches()) return true;
|
||||
|
||||
return patterns.getGenericFieldNamePattern().matcher(simplified).matches()
|
||||
|| patterns.getSimpleFormFieldPattern().matcher(simplified).matches()
|
||||
|
||||
@@ -10,6 +10,7 @@ import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.regex.Pattern;
|
||||
import java.util.zip.ZipEntry;
|
||||
import java.util.zip.ZipOutputStream;
|
||||
|
||||
@@ -33,6 +34,8 @@ import stirling.software.common.util.ProcessExecutor.ProcessExecutorResult;
|
||||
@Slf4j
|
||||
public class PDFToFile {
|
||||
|
||||
private static final Pattern PATTERN =
|
||||
Pattern.compile("(!\\[.*?\\])\\((?!images/)([^/)][^)]*?)\\)");
|
||||
private final TempFileManager tempFileManager;
|
||||
private final RuntimePathConfig runtimePathConfig;
|
||||
|
||||
@@ -163,7 +166,7 @@ public class PDFToFile {
|
||||
private String updateImageReferences(String markdown) {
|
||||
// Match markdown image syntax: 
|
||||
// Only update if the path doesn't already start with images/
|
||||
return markdown.replaceAll("(!\\[.*?\\])\\((?!images/)([^/)][^)]*?)\\)", "$1(images/$2)");
|
||||
return PATTERN.matcher(markdown).replaceAll("$1(images/$2)");
|
||||
}
|
||||
|
||||
public ResponseEntity<byte[]> processPdfToHtml(MultipartFile inputFile)
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
package stirling.software.common.util;
|
||||
|
||||
import java.awt.image.BufferedImage;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.util.Locale;
|
||||
import java.util.zip.ZipEntry;
|
||||
import java.util.zip.ZipOutputStream;
|
||||
@@ -22,8 +22,11 @@ import stirling.software.common.service.CustomPDFDocumentFactory;
|
||||
@Slf4j
|
||||
public class PdfToCbzUtils {
|
||||
|
||||
public static byte[] convertPdfToCbz(
|
||||
MultipartFile pdfFile, int dpi, CustomPDFDocumentFactory pdfDocumentFactory)
|
||||
public static TempFile convertPdfToCbz(
|
||||
MultipartFile pdfFile,
|
||||
int dpi,
|
||||
CustomPDFDocumentFactory pdfDocumentFactory,
|
||||
TempFileManager tempFileManager)
|
||||
throws IOException {
|
||||
|
||||
validatePdfFile(pdfFile);
|
||||
@@ -33,7 +36,7 @@ public class PdfToCbzUtils {
|
||||
throw ExceptionUtils.createPdfNoPages();
|
||||
}
|
||||
|
||||
return createCbzFromPdf(document, dpi);
|
||||
return createCbzFromPdf(document, dpi, tempFileManager);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,46 +56,49 @@ public class PdfToCbzUtils {
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[] createCbzFromPdf(PDDocument document, int dpi) throws IOException {
|
||||
private static TempFile createCbzFromPdf(
|
||||
PDDocument document, int dpi, TempFileManager tempFileManager) throws IOException {
|
||||
PDFRenderer pdfRenderer = new PDFRenderer(document);
|
||||
pdfRenderer.setSubsamplingAllowed(true); // Enable subsampling to reduce memory usage
|
||||
|
||||
try (ByteArrayOutputStream cbzOutputStream = new ByteArrayOutputStream();
|
||||
ZipOutputStream zipOut = new ZipOutputStream(cbzOutputStream)) {
|
||||
TempFile cbzTempFile = new TempFile(tempFileManager, ".cbz");
|
||||
try {
|
||||
try (ZipOutputStream zipOut =
|
||||
new ZipOutputStream(Files.newOutputStream(cbzTempFile.getPath()))) {
|
||||
|
||||
int totalPages = document.getNumberOfPages();
|
||||
int totalPages = document.getNumberOfPages();
|
||||
|
||||
for (int pageIndex = 0; pageIndex < totalPages; pageIndex++) {
|
||||
final int currentPage = pageIndex;
|
||||
try {
|
||||
BufferedImage image =
|
||||
ExceptionUtils.handleOomRendering(
|
||||
currentPage + 1,
|
||||
dpi,
|
||||
() ->
|
||||
pdfRenderer.renderImageWithDPI(
|
||||
currentPage, dpi, ImageType.RGB));
|
||||
for (int pageIndex = 0; pageIndex < totalPages; pageIndex++) {
|
||||
final int currentPage = pageIndex;
|
||||
try {
|
||||
BufferedImage image =
|
||||
ExceptionUtils.handleOomRendering(
|
||||
currentPage + 1,
|
||||
dpi,
|
||||
() ->
|
||||
pdfRenderer.renderImageWithDPI(
|
||||
currentPage, dpi, ImageType.RGB));
|
||||
|
||||
String imageFilename =
|
||||
String.format(Locale.ROOT, "page_%03d.png", currentPage + 1);
|
||||
ZipEntry zipEntry = new ZipEntry(imageFilename);
|
||||
zipOut.putNextEntry(zipEntry);
|
||||
String imageFilename =
|
||||
String.format(Locale.ROOT, "page_%03d.png", currentPage + 1);
|
||||
zipOut.putNextEntry(new ZipEntry(imageFilename));
|
||||
ImageIO.write(image, "PNG", zipOut);
|
||||
zipOut.closeEntry();
|
||||
|
||||
ImageIO.write(image, "PNG", zipOut);
|
||||
zipOut.closeEntry();
|
||||
|
||||
} catch (ExceptionUtils.OutOfMemoryDpiException e) {
|
||||
// Re-throw OOM exceptions without wrapping
|
||||
throw e;
|
||||
} catch (IOException e) {
|
||||
// Wrap other IOExceptions with context
|
||||
throw ExceptionUtils.createFileProcessingException(
|
||||
"CBZ creation for page " + (currentPage + 1), e);
|
||||
} catch (ExceptionUtils.OutOfMemoryDpiException e) {
|
||||
// Re-throw OOM exceptions without wrapping
|
||||
throw e;
|
||||
} catch (IOException e) {
|
||||
// Wrap other IOExceptions with context
|
||||
throw ExceptionUtils.createFileProcessingException(
|
||||
"CBZ creation for page " + (currentPage + 1), e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
zipOut.finish();
|
||||
return cbzOutputStream.toByteArray();
|
||||
return cbzTempFile;
|
||||
} catch (Exception e) {
|
||||
cbzTempFile.close();
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -219,58 +219,31 @@ public class PdfUtils {
|
||||
int maxWidth = 0;
|
||||
int totalHeight = 0;
|
||||
|
||||
BufferedImage pdfSizeImage = null;
|
||||
int pdfSizeImageIndex = -1;
|
||||
|
||||
// Using a map to store the rendered dimensions of each page size
|
||||
// to avoid rendering the same page sizes multiple times
|
||||
// Using a map to store the calculated dimensions of each page size
|
||||
HashMap<PdfRenderSettingsKey, PdfImageDimensionValue> pageSizes =
|
||||
new HashMap<>();
|
||||
for (int i = 0; i < pageCount; ++i) {
|
||||
final int pageIndex = i;
|
||||
PDPage page = document.getPage(i);
|
||||
PDRectangle mediaBox = page.getMediaBox();
|
||||
PDRectangle cropBox = page.getCropBox();
|
||||
int rotation = page.getRotation();
|
||||
PdfRenderSettingsKey settings =
|
||||
new PdfRenderSettingsKey(
|
||||
mediaBox.getWidth(), mediaBox.getHeight(), rotation);
|
||||
cropBox.getWidth(), cropBox.getHeight(), rotation);
|
||||
PdfImageDimensionValue dimension = pageSizes.get(settings);
|
||||
if (dimension == null) {
|
||||
// Render the image to get the dimensions
|
||||
try {
|
||||
// Validate dimensions before rendering
|
||||
ExceptionUtils.validateRenderingDimensions(
|
||||
page, pageIndex + 1, DPI);
|
||||
|
||||
pdfSizeImage =
|
||||
ExceptionUtils.handleOomRendering(
|
||||
pageIndex + 1,
|
||||
DPI,
|
||||
() ->
|
||||
pdfRenderer.renderImageWithDPI(
|
||||
pageIndex, DPI, colorType));
|
||||
} catch (IllegalArgumentException e) {
|
||||
if (e.getMessage() != null
|
||||
&& e.getMessage()
|
||||
.contains("Maximum size of image exceeded")) {
|
||||
throw ExceptionUtils.createIllegalArgumentException(
|
||||
"error.pageTooBigExceedsArray",
|
||||
"PDF page {0} is too large to render at {1} DPI. The"
|
||||
+ " resulting image would exceed Java's maximum"
|
||||
+ " array size. Please try a lower DPI value"
|
||||
+ " (recommended: 150 or less).",
|
||||
i + 1,
|
||||
DPI);
|
||||
}
|
||||
throw e;
|
||||
float scale = DPI / 72f;
|
||||
int widthPx = (int) Math.max(Math.floor(cropBox.getWidth() * scale), 1);
|
||||
int heightPx =
|
||||
(int) Math.max(Math.floor(cropBox.getHeight() * scale), 1);
|
||||
if (rotation == 90 || rotation == 270) {
|
||||
int tmp = widthPx;
|
||||
widthPx = heightPx;
|
||||
heightPx = tmp;
|
||||
}
|
||||
pdfSizeImageIndex = i;
|
||||
dimension =
|
||||
new PdfImageDimensionValue(
|
||||
pdfSizeImage.getWidth(), pdfSizeImage.getHeight());
|
||||
dimension = new PdfImageDimensionValue(widthPx, heightPx);
|
||||
pageSizes.put(settings, dimension);
|
||||
if (pdfSizeImage.getWidth() > maxWidth) {
|
||||
maxWidth = pdfSizeImage.getWidth();
|
||||
if (widthPx > maxWidth) {
|
||||
maxWidth = widthPx;
|
||||
}
|
||||
}
|
||||
totalHeight += dimension.height();
|
||||
@@ -284,40 +257,32 @@ public class PdfUtils {
|
||||
int currentHeight = 0;
|
||||
BufferedImage pageImage;
|
||||
|
||||
// Check if the first image is the last rendered image
|
||||
boolean firstImageAlreadyRendered = pdfSizeImageIndex == 0;
|
||||
|
||||
for (int i = 0; i < pageCount; ++i) {
|
||||
final int pageIndex = i;
|
||||
if (firstImageAlreadyRendered && i == 0) {
|
||||
pageImage = pdfSizeImage;
|
||||
} else {
|
||||
try {
|
||||
// Validate dimensions before rendering
|
||||
ExceptionUtils.validateRenderingDimensions(
|
||||
document.getPage(pageIndex), pageIndex + 1, DPI);
|
||||
try {
|
||||
// Validate dimensions before rendering
|
||||
ExceptionUtils.validateRenderingDimensions(
|
||||
document.getPage(pageIndex), pageIndex + 1, DPI);
|
||||
|
||||
pageImage =
|
||||
ExceptionUtils.handleOomRendering(
|
||||
pageIndex + 1,
|
||||
DPI,
|
||||
() ->
|
||||
pdfRenderer.renderImageWithDPI(
|
||||
pageIndex, DPI, colorType));
|
||||
} catch (IllegalArgumentException e) {
|
||||
if (e.getMessage() != null
|
||||
&& e.getMessage()
|
||||
.contains("Maximum size of image exceeded")) {
|
||||
throw ExceptionUtils.createIllegalArgumentException(
|
||||
"error.pageTooBigForDpi",
|
||||
"PDF page {0} is too large to render at {1} DPI. Please"
|
||||
+ " try a lower DPI value (recommended: 150 or"
|
||||
+ " less).",
|
||||
i + 1,
|
||||
DPI);
|
||||
}
|
||||
throw e;
|
||||
pageImage =
|
||||
ExceptionUtils.handleOomRendering(
|
||||
pageIndex + 1,
|
||||
DPI,
|
||||
() ->
|
||||
pdfRenderer.renderImageWithDPI(
|
||||
pageIndex, DPI, colorType));
|
||||
} catch (IllegalArgumentException e) {
|
||||
if (e.getMessage() != null
|
||||
&& e.getMessage().contains("Maximum size of image exceeded")) {
|
||||
throw ExceptionUtils.createIllegalArgumentException(
|
||||
"error.pageTooBigForDpi",
|
||||
"PDF page {0} is too large to render at {1} DPI. Please"
|
||||
+ " try a lower DPI value (recommended: 150 or"
|
||||
+ " less).",
|
||||
i + 1,
|
||||
DPI);
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
|
||||
// Calculate the x-coordinate to center the image
|
||||
|
||||
@@ -191,7 +191,7 @@ public class YamlHelper {
|
||||
mappingNode.getValue().clear();
|
||||
mappingNode.getValue().addAll(updatedTuples);
|
||||
}
|
||||
setNewNode(node);
|
||||
updatedRootNode = node;
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
+1
-1
@@ -19,7 +19,7 @@ public abstract class ReplaceAndInvertColorStrategy extends PDFFile {
|
||||
|
||||
public ReplaceAndInvertColorStrategy(MultipartFile file, ReplaceAndInvert replaceAndInvert) {
|
||||
setFileInput(file);
|
||||
setReplaceAndInvert(replaceAndInvert);
|
||||
this.replaceAndInvert = replaceAndInvert;
|
||||
}
|
||||
|
||||
public abstract InputStreamResource replace() throws IOException;
|
||||
|
||||
@@ -12,11 +12,10 @@ configurations {
|
||||
spotless {
|
||||
java {
|
||||
target 'src/**/java/**/*.java'
|
||||
targetExclude 'src/main/resources/static/**'
|
||||
targetExclude 'src/main/resources/static/**', 'src/main/java/org/apache/**'
|
||||
googleJavaFormat(googleJavaFormatVersion).aosp().reorderImports(false)
|
||||
|
||||
importOrder("java", "javax", "org", "com", "net", "io", "jakarta", "lombok", "me", "stirling")
|
||||
toggleOffOn()
|
||||
trimTrailingWhitespace()
|
||||
leadingTabsToSpaces()
|
||||
endWithNewline()
|
||||
@@ -82,7 +81,7 @@ dependencies {
|
||||
// veraPDF still uses javax.xml.bind, not the new jakarta namespace
|
||||
implementation 'javax.xml.bind:jaxb-api:2.3.1'
|
||||
implementation 'com.sun.xml.bind:jaxb-impl:2.3.9'
|
||||
implementation 'com.sun.xml.bind:jaxb-core:2.3.0.1'
|
||||
implementation 'com.sun.xml.bind:jaxb-core:4.0.6'
|
||||
implementation 'org.apache.poi:poi-ooxml:5.5.1'
|
||||
|
||||
// https://mvnrepository.com/artifact/technology.tabula/tabula
|
||||
@@ -91,7 +90,7 @@ dependencies {
|
||||
exclude group: 'org.bouncycastle', module: 'bcprov-jdk15on'
|
||||
exclude group: 'com.google.code.gson', module: 'gson'
|
||||
}
|
||||
// CVE-2022-25647: Explicit gson 2.8.9 to prevent unsafe deserialization (tabula would pull 2.8.7)
|
||||
// CVE-2022-25647: Explicit gson 2.13.2 to prevent unsafe deserialization (tabula would pull 2.8.7)
|
||||
implementation 'com.google.code.gson:gson:2.13.2'
|
||||
implementation 'org.apache.pdfbox:jbig2-imageio:3.0.4'
|
||||
implementation 'com.opencsv:opencsv:5.12.0' // https://mvnrepository.com/artifact/com.opencsv/opencsv
|
||||
|
||||
+2
-2
@@ -88,10 +88,10 @@ public abstract class CreateSignatureBase implements SignatureInterface {
|
||||
Certificate cert = null;
|
||||
while (cert == null && aliases.hasMoreElements()) {
|
||||
alias = aliases.nextElement();
|
||||
setPrivateKey((PrivateKey) keystore.getKey(alias, pin));
|
||||
privateKey = (PrivateKey) keystore.getKey(alias, pin);
|
||||
Certificate[] certChain = keystore.getCertificateChain(alias);
|
||||
if (certChain != null) {
|
||||
setCertificateChain(certChain);
|
||||
certificateChain = certChain;
|
||||
cert = certChain[0];
|
||||
if (cert instanceof X509Certificate) {
|
||||
// avoid expired certificate
|
||||
|
||||
@@ -134,7 +134,7 @@ public class SPDFApplication {
|
||||
baseUrlStatic = normalizeBackendUrl(backendUrl, serverPort);
|
||||
contextPathStatic = contextPath;
|
||||
serverPortStatic = serverPort;
|
||||
String url = buildFullUrl(baseUrlStatic, getStaticPort(), contextPathStatic);
|
||||
String url = buildFullUrl(baseUrlStatic, serverPortStatic, contextPathStatic);
|
||||
|
||||
// Log Tauri mode information
|
||||
if (Boolean.parseBoolean(System.getProperty("STIRLING_PDF_TAURI_MODE", "false"))) {
|
||||
@@ -188,7 +188,7 @@ public class SPDFApplication {
|
||||
|
||||
private static void printStartupLogs() {
|
||||
log.info("Stirling-PDF Started.");
|
||||
String url = buildFullUrl(baseUrlStatic, getStaticPort(), contextPathStatic);
|
||||
String url = buildFullUrl(baseUrlStatic, serverPortStatic, contextPathStatic);
|
||||
log.info("Navigate to {}", url);
|
||||
}
|
||||
|
||||
|
||||
@@ -104,7 +104,7 @@ public class EndpointInspector implements ApplicationListener<ContextRefreshedEv
|
||||
String infoString = mappingInfo.toString();
|
||||
if (infoString.contains("{")) {
|
||||
String patternsSection =
|
||||
infoString.substring(infoString.indexOf("{") + 1, infoString.indexOf("}"));
|
||||
infoString.substring(infoString.indexOf('{') + 1, infoString.indexOf('}'));
|
||||
|
||||
for (String pattern : patternsSection.split(",")) {
|
||||
pattern = pattern.trim();
|
||||
|
||||
@@ -24,6 +24,7 @@ import io.swagger.v3.oas.annotations.Operation;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.SPDF.config.EndpointConfiguration;
|
||||
import stirling.software.SPDF.model.api.general.CropPdfForm;
|
||||
import stirling.software.common.annotations.AutoJobPostMapping;
|
||||
import stirling.software.common.annotations.api.GeneralApi;
|
||||
@@ -118,6 +119,12 @@ public class CropController {
|
||||
return r >= threshold && g >= threshold && b >= threshold;
|
||||
}
|
||||
|
||||
private final EndpointConfiguration endpointConfiguration;
|
||||
|
||||
private boolean isGhostscriptEnabled() {
|
||||
return endpointConfiguration.isGroupEnabled("Ghostscript");
|
||||
}
|
||||
|
||||
@AutoJobPostMapping(value = "/crop", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||
@Operation(
|
||||
summary = "Crops a PDF document",
|
||||
@@ -137,7 +144,7 @@ public class CropController {
|
||||
"Crop coordinates (x, y, width, height) are required when auto-crop is not enabled");
|
||||
}
|
||||
|
||||
if (request.isRemoveDataOutsideCrop()) {
|
||||
if (request.isRemoveDataOutsideCrop() && isGhostscriptEnabled()) {
|
||||
return cropWithGhostscript(request);
|
||||
} else {
|
||||
return cropWithPDFBox(request);
|
||||
|
||||
+160
-156
@@ -1,7 +1,7 @@
|
||||
package stirling.software.SPDF.controller.api;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.zip.ZipEntry;
|
||||
@@ -14,10 +14,10 @@ import org.apache.pdfbox.pdmodel.PDPageContentStream;
|
||||
import org.apache.pdfbox.pdmodel.common.PDRectangle;
|
||||
import org.apache.pdfbox.pdmodel.graphics.form.PDFormXObject;
|
||||
import org.apache.pdfbox.util.Matrix;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.ModelAttribute;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody;
|
||||
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
|
||||
@@ -31,6 +31,8 @@ import stirling.software.common.annotations.api.GeneralApi;
|
||||
import stirling.software.common.service.CustomPDFDocumentFactory;
|
||||
import stirling.software.common.util.ExceptionUtils;
|
||||
import stirling.software.common.util.GeneralUtils;
|
||||
import stirling.software.common.util.TempFile;
|
||||
import stirling.software.common.util.TempFileManager;
|
||||
import stirling.software.common.util.WebResponseUtils;
|
||||
|
||||
@GeneralApi
|
||||
@@ -39,10 +41,9 @@ import stirling.software.common.util.WebResponseUtils;
|
||||
public class PosterPdfController {
|
||||
|
||||
private final CustomPDFDocumentFactory pdfDocumentFactory;
|
||||
private final TempFileManager tempFileManager;
|
||||
|
||||
@AutoJobPostMapping(
|
||||
value = "/split-for-poster-print",
|
||||
consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||
@AutoJobPostMapping(value = "/split-for-poster-print", consumes = "multipart/form-data")
|
||||
@MultiFileResponse
|
||||
@Operation(
|
||||
summary = "Split large PDF pages into smaller printable chunks",
|
||||
@@ -51,7 +52,7 @@ public class PosterPdfController {
|
||||
+ "suitable for printing on standard paper sizes (e.g., A4, Letter). "
|
||||
+ "Divides each page into a grid of smaller pages using Apache PDFBox. "
|
||||
+ "Input: PDF Output: ZIP-PDF Type: SISO")
|
||||
public ResponseEntity<byte[]> posterPdf(@ModelAttribute PosterPdfRequest request)
|
||||
public ResponseEntity<StreamingResponseBody> posterPdf(@ModelAttribute PosterPdfRequest request)
|
||||
throws Exception {
|
||||
|
||||
log.debug("Starting PDF poster split process with request: {}", request);
|
||||
@@ -60,184 +61,187 @@ public class PosterPdfController {
|
||||
String filename = GeneralUtils.generateFilename(file.getOriginalFilename(), "");
|
||||
log.debug("Base filename for output: {}", filename);
|
||||
|
||||
try (PDDocument sourceDocument = pdfDocumentFactory.load(file);
|
||||
PDDocument outputDocument =
|
||||
pdfDocumentFactory.createNewDocumentBasedOnOldDocument(sourceDocument);
|
||||
ByteArrayOutputStream pdfOutputStream = new ByteArrayOutputStream();
|
||||
ByteArrayOutputStream zipOutputStream = new ByteArrayOutputStream()) {
|
||||
TempFile zipTempFile = new TempFile(tempFileManager, ".zip");
|
||||
try {
|
||||
try (PDDocument sourceDocument = pdfDocumentFactory.load(file);
|
||||
PDDocument outputDocument =
|
||||
pdfDocumentFactory.createNewDocumentBasedOnOldDocument(sourceDocument);
|
||||
TempFile pdfTempFile = new TempFile(tempFileManager, ".pdf")) {
|
||||
|
||||
// Get target page size
|
||||
PDRectangle targetPageSize = getTargetPageSize(request.getPageSize());
|
||||
log.debug(
|
||||
"Target page size: {} ({}x{})",
|
||||
request.getPageSize(),
|
||||
targetPageSize.getWidth(),
|
||||
targetPageSize.getHeight());
|
||||
// Get target page size
|
||||
PDRectangle targetPageSize = getTargetPageSize(request.getPageSize());
|
||||
log.debug(
|
||||
"Target page size: {} ({}x{})",
|
||||
request.getPageSize(),
|
||||
targetPageSize.getWidth(),
|
||||
targetPageSize.getHeight());
|
||||
|
||||
// Create LayerUtility for importing pages as forms
|
||||
LayerUtility layerUtility = new LayerUtility(outputDocument);
|
||||
// Create LayerUtility for importing pages as forms
|
||||
LayerUtility layerUtility = new LayerUtility(outputDocument);
|
||||
|
||||
int totalPages = sourceDocument.getNumberOfPages();
|
||||
int xFactor = request.getXFactor();
|
||||
int yFactor = request.getYFactor();
|
||||
boolean rightToLeft = request.isRightToLeft();
|
||||
|
||||
log.debug(
|
||||
"Processing {} pages with grid {}x{}, RTL={}",
|
||||
totalPages,
|
||||
xFactor,
|
||||
yFactor,
|
||||
rightToLeft);
|
||||
|
||||
// Process each page
|
||||
for (int pageIndex = 0; pageIndex < totalPages; pageIndex++) {
|
||||
PDPage sourcePage = sourceDocument.getPage(pageIndex);
|
||||
|
||||
// Get both MediaBox and CropBox
|
||||
PDRectangle mediaBox = sourcePage.getMediaBox();
|
||||
PDRectangle cropBox = sourcePage.getCropBox();
|
||||
|
||||
// If no CropBox is set, use MediaBox
|
||||
if (cropBox == null) {
|
||||
cropBox = mediaBox;
|
||||
}
|
||||
|
||||
// Save original boxes for restoration
|
||||
PDRectangle originalMediaBox = sourcePage.getMediaBox();
|
||||
PDRectangle originalCropBox = sourcePage.getCropBox();
|
||||
|
||||
// Normalize the page: set MediaBox to CropBox
|
||||
// This ensures the form's coordinate space starts at (0, 0)
|
||||
// instead of having an offset from the original MediaBox
|
||||
sourcePage.setMediaBox(cropBox);
|
||||
sourcePage.setCropBox(cropBox);
|
||||
|
||||
// Handle page rotation
|
||||
int rotation = sourcePage.getRotation();
|
||||
float sourceWidth = cropBox.getWidth();
|
||||
float sourceHeight = cropBox.getHeight();
|
||||
|
||||
// Swap dimensions if rotated 90 or 270 degrees
|
||||
if (rotation == 90 || rotation == 270) {
|
||||
float temp = sourceWidth;
|
||||
sourceWidth = sourceHeight;
|
||||
sourceHeight = temp;
|
||||
}
|
||||
int totalPages = sourceDocument.getNumberOfPages();
|
||||
int xFactor = request.getXFactor();
|
||||
int yFactor = request.getYFactor();
|
||||
boolean rightToLeft = request.isRightToLeft();
|
||||
|
||||
log.debug(
|
||||
"Page {}: Normalized to CropBox dimensions {}x{}, rotation {}",
|
||||
pageIndex,
|
||||
sourceWidth,
|
||||
sourceHeight,
|
||||
rotation);
|
||||
"Processing {} pages with grid {}x{}, RTL={}",
|
||||
totalPages,
|
||||
xFactor,
|
||||
yFactor,
|
||||
rightToLeft);
|
||||
|
||||
// Import source page as form (now with normalized coordinate space)
|
||||
PDFormXObject form = layerUtility.importPageAsForm(sourceDocument, pageIndex);
|
||||
// Process each page
|
||||
for (int pageIndex = 0; pageIndex < totalPages; pageIndex++) {
|
||||
PDPage sourcePage = sourceDocument.getPage(pageIndex);
|
||||
|
||||
// Restore original boxes
|
||||
sourcePage.setMediaBox(originalMediaBox);
|
||||
sourcePage.setCropBox(originalCropBox);
|
||||
// Get both MediaBox and CropBox
|
||||
PDRectangle mediaBox = sourcePage.getMediaBox();
|
||||
PDRectangle cropBox = sourcePage.getCropBox();
|
||||
|
||||
// Calculate cell dimensions in source page coordinates
|
||||
float cellWidth = sourceWidth / xFactor;
|
||||
float cellHeight = sourceHeight / yFactor;
|
||||
// If no CropBox is set, use MediaBox
|
||||
if (cropBox == null) {
|
||||
cropBox = mediaBox;
|
||||
}
|
||||
|
||||
// Create grid cells (rows × columns)
|
||||
for (int row = 0; row < yFactor; row++) {
|
||||
for (int col = 0; col < xFactor; col++) {
|
||||
// Apply RTL ordering for columns if enabled
|
||||
int actualCol = rightToLeft ? (xFactor - 1 - col) : col;
|
||||
// Save original boxes for restoration
|
||||
PDRectangle originalMediaBox = sourcePage.getMediaBox();
|
||||
PDRectangle originalCropBox = sourcePage.getCropBox();
|
||||
|
||||
// Calculate crop rectangle in source coordinates
|
||||
// PDF coordinates start at bottom-left
|
||||
float cropX = actualCol * cellWidth;
|
||||
// For Y: invert so row 0 shows TOP (following SplitPdfBySectionsController
|
||||
// pattern)
|
||||
float cropY = (yFactor - 1 - row) * cellHeight;
|
||||
// Normalize the page: set MediaBox to CropBox
|
||||
// This ensures the form's coordinate space starts at (0, 0)
|
||||
// instead of having an offset from the original MediaBox
|
||||
sourcePage.setMediaBox(cropBox);
|
||||
sourcePage.setCropBox(cropBox);
|
||||
|
||||
// Create new output page with target size
|
||||
PDPage outputPage = new PDPage(targetPageSize);
|
||||
outputDocument.addPage(outputPage);
|
||||
// Handle page rotation
|
||||
int rotation = sourcePage.getRotation();
|
||||
float sourceWidth = cropBox.getWidth();
|
||||
float sourceHeight = cropBox.getHeight();
|
||||
|
||||
try (PDPageContentStream contentStream =
|
||||
new PDPageContentStream(
|
||||
outputDocument,
|
||||
outputPage,
|
||||
PDPageContentStream.AppendMode.APPEND,
|
||||
true,
|
||||
true)) {
|
||||
// Swap dimensions if rotated 90 or 270 degrees
|
||||
if (rotation == 90 || rotation == 270) {
|
||||
float temp = sourceWidth;
|
||||
sourceWidth = sourceHeight;
|
||||
sourceHeight = temp;
|
||||
}
|
||||
|
||||
// Calculate uniform scale to fit cell into target page
|
||||
// Scale UP if cell is smaller than target, scale DOWN if larger
|
||||
float scaleX = targetPageSize.getWidth() / cellWidth;
|
||||
float scaleY = targetPageSize.getHeight() / cellHeight;
|
||||
float scale = Math.min(scaleX, scaleY);
|
||||
log.debug(
|
||||
"Page {}: Normalized to CropBox dimensions {}x{}, rotation {}",
|
||||
pageIndex,
|
||||
sourceWidth,
|
||||
sourceHeight,
|
||||
rotation);
|
||||
|
||||
// Center the scaled content on the target page
|
||||
float scaledCellWidth = cellWidth * scale;
|
||||
float scaledCellHeight = cellHeight * scale;
|
||||
float offsetX = (targetPageSize.getWidth() - scaledCellWidth) / 2;
|
||||
float offsetY = (targetPageSize.getHeight() - scaledCellHeight) / 2;
|
||||
// Import source page as form (now with normalized coordinate space)
|
||||
PDFormXObject form = layerUtility.importPageAsForm(sourceDocument, pageIndex);
|
||||
|
||||
// Apply transformations
|
||||
contentStream.saveGraphicsState();
|
||||
// Restore original boxes
|
||||
sourcePage.setMediaBox(originalMediaBox);
|
||||
sourcePage.setCropBox(originalCropBox);
|
||||
|
||||
// Translate to center position
|
||||
contentStream.transform(Matrix.getTranslateInstance(offsetX, offsetY));
|
||||
// Calculate cell dimensions in source page coordinates
|
||||
float cellWidth = sourceWidth / xFactor;
|
||||
float cellHeight = sourceHeight / yFactor;
|
||||
|
||||
// Scale uniformly
|
||||
contentStream.transform(Matrix.getScaleInstance(scale, scale));
|
||||
// Create grid cells (rows × columns)
|
||||
for (int row = 0; row < yFactor; row++) {
|
||||
for (int col = 0; col < xFactor; col++) {
|
||||
// Apply RTL ordering for columns if enabled
|
||||
int actualCol = rightToLeft ? (xFactor - 1 - col) : col;
|
||||
|
||||
// Translate to show only the desired grid cell
|
||||
// IMPORTANT: The PDFormXObject's BBox already matches the CropBox
|
||||
// (including its offset), so we only need to translate by cropX/cropY
|
||||
// relative to the CropBox origin, NOT the MediaBox origin
|
||||
contentStream.transform(Matrix.getTranslateInstance(-cropX, -cropY));
|
||||
// Calculate crop rectangle in source coordinates
|
||||
// PDF coordinates start at bottom-left
|
||||
float cropX = actualCol * cellWidth;
|
||||
// For Y: invert so row 0 shows TOP (following
|
||||
// SplitPdfBySectionsController
|
||||
// pattern)
|
||||
float cropY = (yFactor - 1 - row) * cellHeight;
|
||||
|
||||
// Draw the form
|
||||
contentStream.drawForm(form);
|
||||
// Create new output page with target size
|
||||
PDPage outputPage = new PDPage(targetPageSize);
|
||||
outputDocument.addPage(outputPage);
|
||||
|
||||
contentStream.restoreGraphicsState();
|
||||
try (PDPageContentStream contentStream =
|
||||
new PDPageContentStream(
|
||||
outputDocument,
|
||||
outputPage,
|
||||
PDPageContentStream.AppendMode.APPEND,
|
||||
true,
|
||||
true)) {
|
||||
|
||||
// Calculate uniform scale to fit cell into target page
|
||||
// Scale UP if cell is smaller than target, scale DOWN if larger
|
||||
float scaleX = targetPageSize.getWidth() / cellWidth;
|
||||
float scaleY = targetPageSize.getHeight() / cellHeight;
|
||||
float scale = Math.min(scaleX, scaleY);
|
||||
|
||||
// Center the scaled content on the target page
|
||||
float scaledCellWidth = cellWidth * scale;
|
||||
float scaledCellHeight = cellHeight * scale;
|
||||
float offsetX = (targetPageSize.getWidth() - scaledCellWidth) / 2;
|
||||
float offsetY = (targetPageSize.getHeight() - scaledCellHeight) / 2;
|
||||
|
||||
// Apply transformations
|
||||
contentStream.saveGraphicsState();
|
||||
|
||||
// Translate to center position
|
||||
contentStream.transform(
|
||||
Matrix.getTranslateInstance(offsetX, offsetY));
|
||||
|
||||
// Scale uniformly
|
||||
contentStream.transform(Matrix.getScaleInstance(scale, scale));
|
||||
|
||||
// Translate to show only the desired grid cell
|
||||
// IMPORTANT: The PDFormXObject's BBox already matches the CropBox
|
||||
// (including its offset), so we only need to translate by
|
||||
// cropX/cropY
|
||||
// relative to the CropBox origin, NOT the MediaBox origin
|
||||
contentStream.transform(
|
||||
Matrix.getTranslateInstance(-cropX, -cropY));
|
||||
|
||||
// Draw the form
|
||||
contentStream.drawForm(form);
|
||||
|
||||
contentStream.restoreGraphicsState();
|
||||
}
|
||||
|
||||
log.trace(
|
||||
"Created output page for grid cell [{},{}] of page {}: cropX={}, cropY={}, translate=({}, {})",
|
||||
row,
|
||||
actualCol,
|
||||
pageIndex,
|
||||
cropX,
|
||||
cropY,
|
||||
-cropX,
|
||||
-cropY);
|
||||
}
|
||||
|
||||
log.trace(
|
||||
"Created output page for grid cell [{},{}] of page {}: cropX={}, cropY={}, translate=({}, {})",
|
||||
row,
|
||||
actualCol,
|
||||
pageIndex,
|
||||
cropX,
|
||||
cropY,
|
||||
-cropX,
|
||||
-cropY);
|
||||
}
|
||||
}
|
||||
|
||||
// Save output PDF to intermediate TempFile
|
||||
outputDocument.save(pdfTempFile.getFile());
|
||||
log.debug("Generated output PDF with {} pages", outputDocument.getNumberOfPages());
|
||||
|
||||
// Create ZIP from the PDF TempFile, streaming directly to zip TempFile
|
||||
try (ZipOutputStream zipOut =
|
||||
new ZipOutputStream(Files.newOutputStream(zipTempFile.getPath()))) {
|
||||
zipOut.putNextEntry(new ZipEntry(filename + "_poster.pdf"));
|
||||
Files.copy(pdfTempFile.getPath(), zipOut);
|
||||
zipOut.closeEntry();
|
||||
}
|
||||
// pdfTempFile auto-closed and deleted here (end of inner try-with-resources)
|
||||
}
|
||||
|
||||
// Save output PDF
|
||||
outputDocument.save(pdfOutputStream);
|
||||
byte[] pdfData = pdfOutputStream.toByteArray();
|
||||
|
||||
log.debug(
|
||||
"Generated output PDF with {} pages ({} bytes)",
|
||||
outputDocument.getNumberOfPages(),
|
||||
pdfData.length);
|
||||
|
||||
// Create ZIP file with the result
|
||||
try (ZipOutputStream zipOut = new ZipOutputStream(zipOutputStream)) {
|
||||
ZipEntry zipEntry = new ZipEntry(filename + "_poster.pdf");
|
||||
zipOut.putNextEntry(zipEntry);
|
||||
zipOut.write(pdfData);
|
||||
zipOut.closeEntry();
|
||||
}
|
||||
|
||||
byte[] zipData = zipOutputStream.toByteArray();
|
||||
log.debug("Successfully created ZIP with {} bytes", zipData.length);
|
||||
|
||||
return WebResponseUtils.bytesToWebResponse(
|
||||
zipData, filename + "_poster.zip", MediaType.APPLICATION_OCTET_STREAM);
|
||||
log.debug("Successfully created ZIP");
|
||||
return WebResponseUtils.zipFileToWebResponse(zipTempFile, filename + "_poster.zip");
|
||||
|
||||
} catch (IOException e) {
|
||||
ExceptionUtils.logException("PDF poster split process", e);
|
||||
zipTempFile.close();
|
||||
throw e;
|
||||
} catch (Exception e) {
|
||||
zipTempFile.close();
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
+40
-64
@@ -1,6 +1,5 @@
|
||||
package stirling.software.SPDF.controller.api;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.util.ArrayList;
|
||||
@@ -10,11 +9,11 @@ import java.util.zip.ZipEntry;
|
||||
import java.util.zip.ZipOutputStream;
|
||||
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.apache.pdfbox.pdmodel.PDPage;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.ModelAttribute;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody;
|
||||
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
|
||||
@@ -49,83 +48,60 @@ public class SplitPDFController {
|
||||
+ " specified page numbers or ranges. Users can specify pages using"
|
||||
+ " individual numbers, ranges, or 'all' for every page. Input:PDF"
|
||||
+ " Output:PDF Type:SIMO")
|
||||
public ResponseEntity<byte[]> splitPdf(@ModelAttribute PDFWithPageNums request)
|
||||
public ResponseEntity<StreamingResponseBody> splitPdf(@ModelAttribute PDFWithPageNums request)
|
||||
throws IOException {
|
||||
|
||||
MultipartFile file = request.getFileInput();
|
||||
|
||||
try (TempFile outputTempFile = new TempFile(tempFileManager, ".zip");
|
||||
PDDocument document = pdfDocumentFactory.load(file)) {
|
||||
|
||||
List<ByteArrayOutputStream> splitDocumentsBoas = new ArrayList<>();
|
||||
|
||||
int totalPages = document.getNumberOfPages();
|
||||
List<Integer> pageNumbers = request.getPageNumbersList(document, false);
|
||||
if (!pageNumbers.contains(totalPages - 1)) {
|
||||
// Create a mutable ArrayList so we can add to it
|
||||
pageNumbers = new ArrayList<>(pageNumbers);
|
||||
pageNumbers.add(totalPages - 1);
|
||||
}
|
||||
|
||||
log.debug(
|
||||
"Splitting PDF into pages: {}",
|
||||
pageNumbers.stream().map(String::valueOf).collect(Collectors.joining(",")));
|
||||
|
||||
splitDocumentsBoas = new ArrayList<>(pageNumbers.size());
|
||||
int previousPageNumber = 0;
|
||||
for (int splitPoint : pageNumbers) {
|
||||
try (PDDocument splitDocument =
|
||||
pdfDocumentFactory.createNewDocumentBasedOnOldDocument(document);
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream()) {
|
||||
for (int i = previousPageNumber; i <= splitPoint; i++) {
|
||||
PDPage page = document.getPage(i);
|
||||
splitDocument.addPage(page);
|
||||
log.debug("Adding page {} to split document", i);
|
||||
}
|
||||
previousPageNumber = splitPoint + 1;
|
||||
|
||||
// Transfer metadata to split pdf
|
||||
// PdfMetadataService.setMetadataToPdf(splitDocument, metadata);
|
||||
|
||||
splitDocument.save(baos);
|
||||
splitDocumentsBoas.add(baos);
|
||||
} catch (Exception e) {
|
||||
ExceptionUtils.logException("document splitting and saving", e);
|
||||
throw e;
|
||||
TempFile outputTempFile = new TempFile(tempFileManager, ".zip");
|
||||
try {
|
||||
try (PDDocument document = pdfDocumentFactory.load(file)) {
|
||||
int totalPages = document.getNumberOfPages();
|
||||
List<Integer> pageNumbers = request.getPageNumbersList(document, false);
|
||||
if (!pageNumbers.contains(totalPages - 1)) {
|
||||
pageNumbers = new ArrayList<>(pageNumbers);
|
||||
pageNumbers.add(totalPages - 1);
|
||||
}
|
||||
}
|
||||
|
||||
String baseFilename = GeneralUtils.removeExtension(file.getOriginalFilename());
|
||||
log.debug(
|
||||
"Splitting PDF into pages: {}",
|
||||
pageNumbers.stream().map(String::valueOf).collect(Collectors.joining(",")));
|
||||
|
||||
try (ZipOutputStream zipOut =
|
||||
new ZipOutputStream(Files.newOutputStream(outputTempFile.getPath()))) {
|
||||
int splitDocumentsSize = splitDocumentsBoas.size();
|
||||
for (int i = 0; i < splitDocumentsSize; i++) {
|
||||
StringBuilder sb = new StringBuilder(baseFilename.length() + 10);
|
||||
sb.append(baseFilename).append('_').append(i + 1).append(".pdf");
|
||||
String fileName = sb.toString();
|
||||
String baseFilename = GeneralUtils.removeExtension(file.getOriginalFilename());
|
||||
try (ZipOutputStream zipOut =
|
||||
new ZipOutputStream(Files.newOutputStream(outputTempFile.getPath()))) {
|
||||
int previousPageNumber = 0;
|
||||
for (int splitIndex = 0; splitIndex < pageNumbers.size(); splitIndex++) {
|
||||
int splitPoint = pageNumbers.get(splitIndex);
|
||||
try (PDDocument splitDocument =
|
||||
pdfDocumentFactory.createNewDocumentBasedOnOldDocument(document)) {
|
||||
for (int i = previousPageNumber; i <= splitPoint; i++) {
|
||||
splitDocument.addPage(document.getPage(i));
|
||||
log.debug("Adding page {} to split document", i);
|
||||
}
|
||||
previousPageNumber = splitPoint + 1;
|
||||
|
||||
ByteArrayOutputStream baos = splitDocumentsBoas.get(i);
|
||||
byte[] pdf = baos.toByteArray();
|
||||
|
||||
ZipEntry pdfEntry = new ZipEntry(fileName);
|
||||
zipOut.putNextEntry(pdfEntry);
|
||||
zipOut.write(pdf);
|
||||
zipOut.closeEntry();
|
||||
|
||||
log.debug("Wrote split document {} to zip file", fileName);
|
||||
String fileName = baseFilename + "_" + (splitIndex + 1) + ".pdf";
|
||||
zipOut.putNextEntry(new ZipEntry(fileName));
|
||||
splitDocument.save(zipOut);
|
||||
zipOut.closeEntry();
|
||||
log.debug("Wrote split document {} to zip file", fileName);
|
||||
} catch (Exception e) {
|
||||
ExceptionUtils.logException("document splitting and saving", e);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
log.debug(
|
||||
"Successfully created zip file with split documents: {}",
|
||||
outputTempFile.getPath().toString());
|
||||
byte[] data = Files.readAllBytes(outputTempFile.getPath());
|
||||
|
||||
String zipFilename =
|
||||
GeneralUtils.generateFilename(file.getOriginalFilename(), "_split.zip");
|
||||
return WebResponseUtils.bytesToWebResponse(
|
||||
data, zipFilename, MediaType.APPLICATION_OCTET_STREAM);
|
||||
return WebResponseUtils.zipFileToWebResponse(outputTempFile, zipFilename);
|
||||
} catch (Exception e) {
|
||||
outputTempFile.close();
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+61
-99
@@ -1,8 +1,6 @@
|
||||
package stirling.software.SPDF.controller.api;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
@@ -17,6 +15,7 @@ import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.ModelAttribute;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody;
|
||||
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
|
||||
@@ -36,6 +35,8 @@ import stirling.software.common.service.CustomPDFDocumentFactory;
|
||||
import stirling.software.common.service.PdfMetadataService;
|
||||
import stirling.software.common.util.ExceptionUtils;
|
||||
import stirling.software.common.util.GeneralUtils;
|
||||
import stirling.software.common.util.TempFile;
|
||||
import stirling.software.common.util.TempFileManager;
|
||||
import stirling.software.common.util.WebResponseUtils;
|
||||
|
||||
@GeneralApi
|
||||
@@ -47,6 +48,8 @@ public class SplitPdfByChaptersController {
|
||||
|
||||
private final CustomPDFDocumentFactory pdfDocumentFactory;
|
||||
|
||||
private final TempFileManager tempFileManager;
|
||||
|
||||
private static List<Bookmark> extractOutlineItems(
|
||||
PDDocument sourceDocument,
|
||||
PDOutlineItem current,
|
||||
@@ -122,22 +125,19 @@ public class SplitPdfByChaptersController {
|
||||
@Operation(
|
||||
summary = "Split PDFs by Chapters",
|
||||
description = "Splits a PDF into chapters and returns a ZIP file.")
|
||||
public ResponseEntity<byte[]> splitPdf(@ModelAttribute SplitPdfByChaptersRequest request)
|
||||
throws Exception {
|
||||
public ResponseEntity<StreamingResponseBody> splitPdf(
|
||||
@ModelAttribute SplitPdfByChaptersRequest request) throws Exception {
|
||||
MultipartFile file = request.getFileInput();
|
||||
PDDocument sourceDocument = null;
|
||||
Path zipFile = null;
|
||||
|
||||
try {
|
||||
boolean includeMetadata = Boolean.TRUE.equals(request.getIncludeMetadata());
|
||||
Integer bookmarkLevel =
|
||||
request.getBookmarkLevel(); // levels start from 0 (top most bookmarks)
|
||||
if (bookmarkLevel < 0) {
|
||||
throw ExceptionUtils.createIllegalArgumentException(
|
||||
"error.invalidArgument", "Invalid argument: {0}", "bookmark level");
|
||||
}
|
||||
sourceDocument = pdfDocumentFactory.load(file);
|
||||
boolean includeMetadata = Boolean.TRUE.equals(request.getIncludeMetadata());
|
||||
Integer bookmarkLevel =
|
||||
request.getBookmarkLevel(); // levels start from 0 (top most bookmarks)
|
||||
if (bookmarkLevel < 0) {
|
||||
throw ExceptionUtils.createIllegalArgumentException(
|
||||
"error.invalidArgument", "Invalid argument: {0}", "bookmark level");
|
||||
}
|
||||
|
||||
try (PDDocument sourceDocument = pdfDocumentFactory.load(file)) {
|
||||
PDDocumentOutline outline = sourceDocument.getDocumentCatalog().getDocumentOutline();
|
||||
|
||||
if (outline == null) {
|
||||
@@ -157,12 +157,10 @@ public class SplitPdfByChaptersController {
|
||||
bookmarkLevel);
|
||||
// to handle last page edge case
|
||||
bookmarks.get(bookmarks.size() - 1).setEndPage(sourceDocument.getNumberOfPages());
|
||||
Bookmark lastBookmark = bookmarks.get(bookmarks.size() - 1);
|
||||
|
||||
} catch (Exception e) {
|
||||
ExceptionUtils.logException("outline extraction", e);
|
||||
return ResponseEntity.internalServerError()
|
||||
.body("Unable to extract outline items".getBytes());
|
||||
throw e;
|
||||
}
|
||||
|
||||
boolean allowDuplicates = Boolean.TRUE.equals(request.getAllowDuplicates());
|
||||
@@ -181,29 +179,10 @@ public class SplitPdfByChaptersController {
|
||||
bookmark.getStartPage(),
|
||||
bookmark.getEndPage());
|
||||
}
|
||||
List<ByteArrayOutputStream> splitDocumentsBoas =
|
||||
getSplitDocumentsBoas(sourceDocument, bookmarks, includeMetadata);
|
||||
|
||||
zipFile = createZipFile(bookmarks, splitDocumentsBoas);
|
||||
|
||||
byte[] data = Files.readAllBytes(zipFile);
|
||||
Files.deleteIfExists(zipFile);
|
||||
|
||||
TempFile zipTempFile = createZipFile(sourceDocument, bookmarks, includeMetadata);
|
||||
String filename = GeneralUtils.generateFilename(file.getOriginalFilename(), "");
|
||||
sourceDocument.close();
|
||||
return WebResponseUtils.bytesToWebResponse(
|
||||
data, filename + ".zip", MediaType.APPLICATION_OCTET_STREAM);
|
||||
} finally {
|
||||
try {
|
||||
if (sourceDocument != null) {
|
||||
sourceDocument.close();
|
||||
}
|
||||
if (zipFile != null) {
|
||||
Files.deleteIfExists(zipFile);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error("Error while cleaning up resources", e);
|
||||
}
|
||||
return WebResponseUtils.zipFileToWebResponse(zipTempFile, filename + ".zip");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -232,72 +211,55 @@ public class SplitPdfByChaptersController {
|
||||
return bookmarks;
|
||||
}
|
||||
|
||||
private Path createZipFile(
|
||||
List<Bookmark> bookmarks, List<ByteArrayOutputStream> splitDocumentsBoas)
|
||||
throws Exception {
|
||||
Path zipFile = Files.createTempFile("split_documents", ".zip");
|
||||
String fileNumberFormatter = "%0" + (Integer.toString(bookmarks.size()).length()) + "d ";
|
||||
try (ZipOutputStream zipOut = new ZipOutputStream(Files.newOutputStream(zipFile))) {
|
||||
for (int i = 0; i < splitDocumentsBoas.size(); i++) {
|
||||
|
||||
// split files will be named as "[FILE_NUMBER] [BOOKMARK_TITLE].pdf"
|
||||
|
||||
String fileName =
|
||||
String.format(Locale.ROOT, fileNumberFormatter, i)
|
||||
+ bookmarks.get(i).getTitle()
|
||||
+ ".pdf";
|
||||
ByteArrayOutputStream baos = splitDocumentsBoas.get(i);
|
||||
byte[] pdf = baos.toByteArray();
|
||||
|
||||
ZipEntry pdfEntry = new ZipEntry(fileName);
|
||||
zipOut.putNextEntry(pdfEntry);
|
||||
zipOut.write(pdf);
|
||||
zipOut.closeEntry();
|
||||
|
||||
log.debug("Wrote split document {} to zip file", fileName);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error("Failed writing to zip", e);
|
||||
throw e;
|
||||
}
|
||||
|
||||
log.info("Successfully created zip file with split documents: {}", zipFile);
|
||||
return zipFile;
|
||||
}
|
||||
|
||||
public List<ByteArrayOutputStream> getSplitDocumentsBoas(
|
||||
private TempFile createZipFile(
|
||||
PDDocument sourceDocument, List<Bookmark> bookmarks, boolean includeMetadata)
|
||||
throws Exception {
|
||||
List<ByteArrayOutputStream> splitDocumentsBoas = new ArrayList<>();
|
||||
PdfMetadata metadata = null;
|
||||
if (includeMetadata) {
|
||||
metadata = pdfMetadataService.extractMetadataFromPdf(sourceDocument);
|
||||
}
|
||||
for (Bookmark bookmark : bookmarks) {
|
||||
try (PDDocument splitDocument = new PDDocument()) {
|
||||
boolean isSinglePage = (bookmark.getStartPage() == bookmark.getEndPage());
|
||||
PdfMetadata metadata =
|
||||
includeMetadata ? pdfMetadataService.extractMetadataFromPdf(sourceDocument) : null;
|
||||
String fileNumberFormatter = "%0" + (Integer.toString(bookmarks.size()).length()) + "d ";
|
||||
TempFile zipTempFile = new TempFile(tempFileManager, ".zip");
|
||||
try {
|
||||
try (ZipOutputStream zipOut =
|
||||
new ZipOutputStream(Files.newOutputStream(zipTempFile.getPath()))) {
|
||||
for (int i = 0; i < bookmarks.size(); i++) {
|
||||
Bookmark bookmark = bookmarks.get(i);
|
||||
try (PDDocument splitDocument = new PDDocument()) {
|
||||
boolean isSinglePage = (bookmark.getStartPage() == bookmark.getEndPage());
|
||||
|
||||
for (int i = bookmark.getStartPage();
|
||||
i < bookmark.getEndPage() + (isSinglePage ? 1 : 0);
|
||||
i++) {
|
||||
PDPage page = sourceDocument.getPage(i);
|
||||
splitDocument.addPage(page);
|
||||
log.debug("Adding page {} to split document", i);
|
||||
for (int pg = bookmark.getStartPage();
|
||||
pg < bookmark.getEndPage() + (isSinglePage ? 1 : 0);
|
||||
pg++) {
|
||||
PDPage page = sourceDocument.getPage(pg);
|
||||
splitDocument.addPage(page);
|
||||
log.debug("Adding page {} to split document", pg);
|
||||
}
|
||||
if (includeMetadata) {
|
||||
pdfMetadataService.setMetadataToPdf(splitDocument, metadata);
|
||||
}
|
||||
|
||||
// split files will be named as "[FILE_NUMBER] [BOOKMARK_TITLE].pdf"
|
||||
String fileName =
|
||||
String.format(Locale.ROOT, fileNumberFormatter, i)
|
||||
+ bookmark.getTitle()
|
||||
+ ".pdf";
|
||||
zipOut.putNextEntry(new ZipEntry(fileName));
|
||||
splitDocument.save(zipOut);
|
||||
zipOut.closeEntry();
|
||||
log.debug("Wrote split document {} to zip file", fileName);
|
||||
} catch (Exception e) {
|
||||
ExceptionUtils.logException("document splitting and saving", e);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
if (includeMetadata) {
|
||||
pdfMetadataService.setMetadataToPdf(splitDocument, metadata);
|
||||
}
|
||||
|
||||
splitDocument.save(baos);
|
||||
|
||||
splitDocumentsBoas.add(baos);
|
||||
} catch (Exception e) {
|
||||
ExceptionUtils.logException("document splitting and saving", e);
|
||||
throw e;
|
||||
}
|
||||
log.info(
|
||||
"Successfully created zip file with split documents: {}",
|
||||
zipTempFile.getPath());
|
||||
return zipTempFile;
|
||||
} catch (Exception e) {
|
||||
zipTempFile.close();
|
||||
throw e;
|
||||
}
|
||||
return splitDocumentsBoas;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+46
-60
@@ -3,7 +3,6 @@ package stirling.software.SPDF.controller.api;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.zip.ZipEntry;
|
||||
import java.util.zip.ZipOutputStream;
|
||||
|
||||
@@ -13,9 +12,11 @@ import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.ModelAttribute;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody;
|
||||
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
@@ -50,8 +51,8 @@ public class SplitPdfBySizeController {
|
||||
+ " if 10MB and each page is 1MB and you enter 2MB then 5 docs each 2MB"
|
||||
+ " (rounded so that it accepts 1.9MB but not 2.1MB) Input:PDF"
|
||||
+ " Output:ZIP-PDF Type:SISO")
|
||||
public ResponseEntity<byte[]> autoSplitPdf(@ModelAttribute SplitPdfBySizeOrCountRequest request)
|
||||
throws Exception {
|
||||
public ResponseEntity<StreamingResponseBody> autoSplitPdf(
|
||||
@ModelAttribute SplitPdfBySizeOrCountRequest request) throws Exception {
|
||||
|
||||
log.debug("Starting PDF split process with request: {}", request);
|
||||
MultipartFile file = request.getFileInput();
|
||||
@@ -59,63 +60,51 @@ public class SplitPdfBySizeController {
|
||||
String filename = GeneralUtils.generateFilename(file.getOriginalFilename(), "");
|
||||
log.debug("Base filename for output: {}", filename);
|
||||
|
||||
try (TempFile zipTempFile = new TempFile(tempFileManager, ".zip")) {
|
||||
Path managedZipPath = zipTempFile.getPath();
|
||||
log.debug("Created temporary managed zip file: {}", managedZipPath);
|
||||
try {
|
||||
log.debug("Reading input file bytes");
|
||||
byte[] pdfBytes = file.getBytes();
|
||||
log.debug("Successfully read {} bytes from input file", pdfBytes.length);
|
||||
TempFile zipTempFile = new TempFile(tempFileManager, ".zip");
|
||||
try {
|
||||
log.debug("Created temporary managed zip file: {}", zipTempFile.getPath());
|
||||
log.debug("Creating ZIP output stream");
|
||||
try (ZipOutputStream zipOut =
|
||||
new ZipOutputStream(Files.newOutputStream(zipTempFile.getPath()));
|
||||
PDDocument sourceDocument = pdfDocumentFactory.load(file)) {
|
||||
log.debug(
|
||||
"Successfully loaded PDF with {} pages", sourceDocument.getNumberOfPages());
|
||||
|
||||
log.debug("Creating ZIP output stream");
|
||||
try (ZipOutputStream zipOut =
|
||||
new ZipOutputStream(Files.newOutputStream(managedZipPath))) {
|
||||
log.debug("Loading PDF document");
|
||||
try (PDDocument sourceDocument = pdfDocumentFactory.load(pdfBytes)) {
|
||||
log.debug(
|
||||
"Successfully loaded PDF with {} pages",
|
||||
sourceDocument.getNumberOfPages());
|
||||
int type = request.getSplitType();
|
||||
String value = request.getSplitValue();
|
||||
log.debug("Split type: {}, Split value: {}", type, value);
|
||||
|
||||
int type = request.getSplitType();
|
||||
String value = request.getSplitValue();
|
||||
log.debug("Split type: {}, Split value: {}", type, value);
|
||||
|
||||
if (type == 0) {
|
||||
log.debug("Processing split by size");
|
||||
long maxBytes = GeneralUtils.convertSizeToBytes(value);
|
||||
log.debug("Max bytes per document: {}", maxBytes);
|
||||
handleSplitBySize(sourceDocument, maxBytes, zipOut, filename);
|
||||
} else if (type == 1) {
|
||||
log.debug("Processing split by page count");
|
||||
int pageCount = Integer.parseInt(value);
|
||||
log.debug("Pages per document: {}", pageCount);
|
||||
handleSplitByPageCount(sourceDocument, pageCount, zipOut, filename);
|
||||
} else if (type == 2) {
|
||||
log.debug("Processing split by document count");
|
||||
int documentCount = Integer.parseInt(value);
|
||||
log.debug("Total number of documents: {}", documentCount);
|
||||
handleSplitByDocCount(sourceDocument, documentCount, zipOut, filename);
|
||||
} else {
|
||||
log.error("Invalid split type: {}", type);
|
||||
throw ExceptionUtils.createIllegalArgumentException(
|
||||
"error.invalidArgument",
|
||||
"Invalid argument: {0}",
|
||||
"split type: " + type);
|
||||
}
|
||||
log.debug("PDF splitting completed successfully");
|
||||
}
|
||||
if (type == 0) {
|
||||
log.debug("Processing split by size");
|
||||
long maxBytes = GeneralUtils.convertSizeToBytes(value);
|
||||
log.debug("Max bytes per document: {}", maxBytes);
|
||||
handleSplitBySize(sourceDocument, maxBytes, zipOut, filename);
|
||||
} else if (type == 1) {
|
||||
log.debug("Processing split by page count");
|
||||
int pageCount = Integer.parseInt(value);
|
||||
log.debug("Pages per document: {}", pageCount);
|
||||
handleSplitByPageCount(sourceDocument, pageCount, zipOut, filename);
|
||||
} else if (type == 2) {
|
||||
log.debug("Processing split by document count");
|
||||
int documentCount = Integer.parseInt(value);
|
||||
log.debug("Total number of documents: {}", documentCount);
|
||||
handleSplitByDocCount(sourceDocument, documentCount, zipOut, filename);
|
||||
} else {
|
||||
log.error("Invalid split type: {}", type);
|
||||
throw ExceptionUtils.createIllegalArgumentException(
|
||||
"error.invalidArgument",
|
||||
"Invalid argument: {0}",
|
||||
"split type: " + type);
|
||||
}
|
||||
|
||||
byte[] data = Files.readAllBytes(managedZipPath);
|
||||
log.debug("Successfully read {} bytes from ZIP file", data.length);
|
||||
|
||||
log.debug("Returning response with {} bytes of data", data.length);
|
||||
return WebResponseUtils.bytesToWebResponse(
|
||||
data, filename + ".zip", MediaType.APPLICATION_OCTET_STREAM);
|
||||
} catch (Exception e) {
|
||||
ExceptionUtils.logException("PDF splitting process", e);
|
||||
throw e; // Re-throw to ensure proper error response
|
||||
log.debug("PDF splitting completed successfully");
|
||||
}
|
||||
|
||||
log.debug("Returning streaming response for zip file");
|
||||
return WebResponseUtils.zipFileToWebResponse(zipTempFile, filename + ".zip");
|
||||
} catch (Exception e) {
|
||||
ExceptionUtils.logException("PDF splitting process", e);
|
||||
zipTempFile.close();
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -124,6 +113,7 @@ public class SplitPdfBySizeController {
|
||||
throws IOException {
|
||||
log.debug("Starting handleSplitBySize with maxBytes={}", maxBytes);
|
||||
|
||||
@Getter
|
||||
class DocHolder implements AutoCloseable {
|
||||
private PDDocument doc;
|
||||
|
||||
@@ -131,10 +121,6 @@ public class SplitPdfBySizeController {
|
||||
this.doc = doc;
|
||||
}
|
||||
|
||||
public PDDocument getDoc() {
|
||||
return doc;
|
||||
}
|
||||
|
||||
public void setDoc(PDDocument doc) {
|
||||
if (this.doc != null) {
|
||||
try {
|
||||
|
||||
+29
-22
@@ -22,6 +22,7 @@ import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.ModelAttribute;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody;
|
||||
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
|
||||
@@ -51,6 +52,7 @@ import stirling.software.common.util.PdfUtils;
|
||||
import stirling.software.common.util.ProcessExecutor;
|
||||
import stirling.software.common.util.ProcessExecutor.ProcessExecutorResult;
|
||||
import stirling.software.common.util.RegexPatternUtils;
|
||||
import stirling.software.common.util.TempFile;
|
||||
import stirling.software.common.util.TempFileManager;
|
||||
import stirling.software.common.util.WebResponseUtils;
|
||||
|
||||
@@ -78,7 +80,7 @@ public class ConvertImgPDFController {
|
||||
"This endpoint converts a PDF file to image(s) with the specified image format,"
|
||||
+ " color type, and DPI. Users can choose to get a single image or multiple"
|
||||
+ " images. Input:PDF Output:Image Type:SI-Conditional")
|
||||
public ResponseEntity<byte[]> convertToImage(@ModelAttribute ConvertToImageRequest request)
|
||||
public ResponseEntity<?> convertToImage(@ModelAttribute ConvertToImageRequest request)
|
||||
throws Exception {
|
||||
MultipartFile file = request.getFileInput();
|
||||
String imageFormat = request.getImageFormat();
|
||||
@@ -180,28 +182,33 @@ public class ConvertImgPDFController {
|
||||
"No WebP files were created. " + resultProcess.getMessages());
|
||||
}
|
||||
|
||||
byte[] bodyBytes = new byte[0];
|
||||
|
||||
if (webpFiles.size() == 1) {
|
||||
// Return the single WebP file directly
|
||||
Path webpFilePath = webpFiles.get(0);
|
||||
bodyBytes = Files.readAllBytes(webpFilePath);
|
||||
byte[] webpBytes = Files.readAllBytes(webpFilePath);
|
||||
Files.deleteIfExists(tempFile);
|
||||
tempFile = null;
|
||||
FileUtils.deleteDirectory(tempOutputDir.toFile());
|
||||
tempOutputDir = null;
|
||||
String docName = filename + "." + imageFormat;
|
||||
MediaType mediaType = MediaType.parseMediaType(getMediaType(imageFormat));
|
||||
return WebResponseUtils.bytesToWebResponse(webpBytes, docName, mediaType);
|
||||
} else {
|
||||
// Create a ZIP file containing all WebP images
|
||||
try (ByteArrayOutputStream zipOutputStream = new ByteArrayOutputStream();
|
||||
ZipOutputStream zos = new ZipOutputStream(zipOutputStream)) {
|
||||
ByteArrayOutputStream zipBAOS = new ByteArrayOutputStream();
|
||||
try (ZipOutputStream zos = new ZipOutputStream(zipBAOS)) {
|
||||
for (Path webpFile : webpFiles) {
|
||||
zos.putNextEntry(new ZipEntry(webpFile.getFileName().toString()));
|
||||
Files.copy(webpFile, zos);
|
||||
zos.closeEntry();
|
||||
}
|
||||
bodyBytes = zipOutputStream.toByteArray();
|
||||
}
|
||||
Files.deleteIfExists(tempFile);
|
||||
tempFile = null;
|
||||
FileUtils.deleteDirectory(tempOutputDir.toFile());
|
||||
tempOutputDir = null;
|
||||
String zipFilename = filename + "_convertedToImages.zip";
|
||||
return WebResponseUtils.bytesToWebResponse(
|
||||
zipBAOS.toByteArray(), zipFilename, MediaType.APPLICATION_OCTET_STREAM);
|
||||
}
|
||||
// Clean up the temporary files
|
||||
Files.deleteIfExists(tempFile);
|
||||
if (tempOutputDir != null) FileUtils.deleteDirectory(tempOutputDir.toFile());
|
||||
result = bodyBytes;
|
||||
}
|
||||
|
||||
if (singleImage) {
|
||||
@@ -267,8 +274,8 @@ public class ConvertImgPDFController {
|
||||
description =
|
||||
"This endpoint converts a CBZ (ZIP) comic book archive to a PDF file. "
|
||||
+ "Input:CBZ Output:PDF Type:SISO")
|
||||
public ResponseEntity<?> convertCbzToPdf(@ModelAttribute ConvertCbzToPdfRequest request)
|
||||
throws IOException {
|
||||
public ResponseEntity<StreamingResponseBody> convertCbzToPdf(
|
||||
@ModelAttribute ConvertCbzToPdfRequest request) throws IOException {
|
||||
MultipartFile file = request.getFileInput();
|
||||
boolean optimizeForEbook = request.isOptimizeForEbook();
|
||||
|
||||
@@ -278,13 +285,13 @@ public class ConvertImgPDFController {
|
||||
optimizeForEbook = false;
|
||||
}
|
||||
|
||||
byte[] pdfBytes =
|
||||
TempFile pdfFile =
|
||||
CbzUtils.convertCbzToPdf(
|
||||
file, pdfDocumentFactory, tempFileManager, optimizeForEbook);
|
||||
|
||||
String filename = createConvertedFilename(file.getOriginalFilename(), "_converted.pdf");
|
||||
|
||||
return WebResponseUtils.bytesToWebResponse(pdfBytes, filename);
|
||||
return WebResponseUtils.pdfFileToWebResponse(pdfFile, filename);
|
||||
}
|
||||
|
||||
@AutoJobPostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/pdf/cbz")
|
||||
@@ -293,8 +300,8 @@ public class ConvertImgPDFController {
|
||||
description =
|
||||
"This endpoint converts a PDF file to a CBZ (ZIP) comic book archive. "
|
||||
+ "Input:PDF Output:CBZ Type:SISO")
|
||||
public ResponseEntity<?> convertPdfToCbz(@ModelAttribute ConvertPdfToCbzRequest request)
|
||||
throws IOException {
|
||||
public ResponseEntity<StreamingResponseBody> convertPdfToCbz(
|
||||
@ModelAttribute ConvertPdfToCbzRequest request) throws IOException {
|
||||
MultipartFile file = request.getFileInput();
|
||||
int dpi = request.getDpi();
|
||||
|
||||
@@ -302,12 +309,12 @@ public class ConvertImgPDFController {
|
||||
dpi = 300;
|
||||
}
|
||||
|
||||
byte[] cbzBytes = PdfToCbzUtils.convertPdfToCbz(file, dpi, pdfDocumentFactory);
|
||||
TempFile cbzFile =
|
||||
PdfToCbzUtils.convertPdfToCbz(file, dpi, pdfDocumentFactory, tempFileManager);
|
||||
|
||||
String filename = createConvertedFilename(file.getOriginalFilename(), "_converted.cbz");
|
||||
|
||||
return WebResponseUtils.bytesToWebResponse(
|
||||
cbzBytes, filename, MediaType.APPLICATION_OCTET_STREAM);
|
||||
return WebResponseUtils.zipFileToWebResponse(cbzFile, filename);
|
||||
}
|
||||
|
||||
@AutoJobPostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/cbr/pdf")
|
||||
|
||||
+6
@@ -54,6 +54,12 @@ public class ConvertPDFToEpubController {
|
||||
command.add(inputPath.toString());
|
||||
command.add(outputPath.toString());
|
||||
|
||||
// Use pdftohtml engine (poppler) for PDF input instead of calibre's Qt-based engine.
|
||||
// This avoids the Qt WebEngine dependency for PDF parsing and uses the lighter
|
||||
// poppler-utils pdftohtml binary which is already available in the container.
|
||||
command.add("--pdf-engine");
|
||||
command.add("pdftohtml");
|
||||
|
||||
// Golden defaults
|
||||
command.add("--enable-heuristics");
|
||||
command.add("--insert-blank-line");
|
||||
|
||||
+8
-3
@@ -39,6 +39,8 @@ import stirling.software.common.util.WebResponseUtils;
|
||||
public class ConvertPdfJsonController {
|
||||
|
||||
private static final Pattern FILE_EXTENSION_PATTERN = Pattern.compile("[.][^.]+$");
|
||||
private static final Pattern WHITESPACE_PATTERN = Pattern.compile("[\\r\\n\\t]+");
|
||||
private static final Pattern NON_PRINTABLE_PATTERN = Pattern.compile("[^\\x20-\\x7E]");
|
||||
private final PdfJsonConversionService pdfJsonConversionService;
|
||||
|
||||
@Autowired(required = false)
|
||||
@@ -259,7 +261,10 @@ public class ConvertPdfJsonController {
|
||||
if (length > 0) {
|
||||
int start = Math.max(0, length - 64);
|
||||
tail = new String(jsonBytes, start, length - start, StandardCharsets.UTF_8);
|
||||
tail = tail.replaceAll("[\\r\\n\\t]+", " ").replaceAll("[^\\x20-\\x7E]", "?");
|
||||
tail =
|
||||
NON_PRINTABLE_PATTERN
|
||||
.matcher(WHITESPACE_PATTERN.matcher(tail).replaceAll(" "))
|
||||
.replaceAll("?");
|
||||
}
|
||||
log.debug(
|
||||
"Returning {} JSON response ({} bytes, endsWithJson={}, tail='{}')",
|
||||
@@ -421,9 +426,9 @@ public class ConvertPdfJsonController {
|
||||
private String truncateForLog(String value) {
|
||||
int max = 64;
|
||||
if (value.length() <= max) {
|
||||
return value.replaceAll("[\\r\\n\\t]+", " ");
|
||||
return WHITESPACE_PATTERN.matcher(value).replaceAll(" ");
|
||||
}
|
||||
return value.substring(0, max).replaceAll("[\\r\\n\\t]+", " ") + "...";
|
||||
return WHITESPACE_PATTERN.matcher(value.substring(0, max)).replaceAll(" ") + "...";
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+1
-1
@@ -67,7 +67,7 @@ public class ExtractImageScansController {
|
||||
MultipartFile inputFile = request.getFileInput();
|
||||
|
||||
String fileName = inputFile.getOriginalFilename();
|
||||
String extension = fileName.substring(fileName.lastIndexOf(".") + 1);
|
||||
String extension = fileName.substring(fileName.lastIndexOf('.') + 1);
|
||||
|
||||
List<String> images = new ArrayList<>();
|
||||
|
||||
|
||||
+100
-87
@@ -5,6 +5,7 @@ import java.awt.image.BufferedImage;
|
||||
import java.awt.image.RenderedImage;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.util.Arrays;
|
||||
@@ -28,6 +29,7 @@ import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.ModelAttribute;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody;
|
||||
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
|
||||
@@ -42,6 +44,8 @@ import stirling.software.common.service.CustomPDFDocumentFactory;
|
||||
import stirling.software.common.util.ExceptionUtils;
|
||||
import stirling.software.common.util.GeneralUtils;
|
||||
import stirling.software.common.util.ImageProcessingUtils;
|
||||
import stirling.software.common.util.TempFile;
|
||||
import stirling.software.common.util.TempFileManager;
|
||||
import stirling.software.common.util.WebResponseUtils;
|
||||
|
||||
@MiscApi
|
||||
@@ -50,6 +54,7 @@ import stirling.software.common.util.WebResponseUtils;
|
||||
public class ExtractImagesController {
|
||||
|
||||
private final CustomPDFDocumentFactory pdfDocumentFactory;
|
||||
private final TempFileManager tempFileManager;
|
||||
|
||||
@AutoJobPostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/extract-images")
|
||||
@MultiFileResponse
|
||||
@@ -59,111 +64,115 @@ public class ExtractImagesController {
|
||||
"This endpoint extracts images from a given PDF file and returns them in a zip"
|
||||
+ " file. Users can specify the output image format. Input:PDF"
|
||||
+ " Output:IMAGE/ZIP Type:SIMO")
|
||||
public ResponseEntity<byte[]> extractImages(@ModelAttribute PDFExtractImagesRequest request)
|
||||
public ResponseEntity<StreamingResponseBody> extractImages(
|
||||
@ModelAttribute PDFExtractImagesRequest request)
|
||||
throws IOException, InterruptedException, ExecutionException {
|
||||
MultipartFile file = request.getFileInput();
|
||||
String format = request.getFormat();
|
||||
boolean allowDuplicates = Boolean.TRUE.equals(request.getAllowDuplicates());
|
||||
PDDocument document = pdfDocumentFactory.load(file);
|
||||
|
||||
// Determine if multithreading should be used based on PDF size or number of pages
|
||||
boolean useMultithreading = shouldUseMultithreading(file, document);
|
||||
|
||||
// Create ByteArrayOutputStream to write zip file to byte array
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
|
||||
// Create ZipOutputStream to create zip file
|
||||
ZipOutputStream zos = new ZipOutputStream(baos);
|
||||
|
||||
// Set compression level
|
||||
zos.setLevel(Deflater.BEST_COMPRESSION);
|
||||
|
||||
String filename = GeneralUtils.removeExtension(file.getOriginalFilename());
|
||||
Set<byte[]> processedImages = new HashSet<>();
|
||||
|
||||
if (useMultithreading) {
|
||||
// Virtual thread executor — lightweight threads ideal for I/O-bound image extraction
|
||||
ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();
|
||||
Set<Future<Void>> futures = new HashSet<>();
|
||||
TempFile zipTempFile = new TempFile(tempFileManager, ".zip");
|
||||
try (ZipOutputStream zos =
|
||||
new ZipOutputStream(Files.newOutputStream(zipTempFile.getPath()));
|
||||
PDDocument document = pdfDocumentFactory.load(file)) {
|
||||
|
||||
// Safely iterate over each page, handling corrupt PDFs where page count might be wrong
|
||||
try {
|
||||
int pageCount = document.getPages().getCount();
|
||||
log.debug("Document reports {} pages", pageCount);
|
||||
// Set compression level
|
||||
zos.setLevel(Deflater.BEST_COMPRESSION);
|
||||
|
||||
int consecutiveFailures = 0;
|
||||
// Determine if multithreading should be used based on PDF size or number of pages
|
||||
boolean useMultithreading = shouldUseMultithreading(file, document);
|
||||
|
||||
for (int pgNum = 0; pgNum < pageCount; pgNum++) {
|
||||
try {
|
||||
PDPage page = document.getPage(pgNum);
|
||||
consecutiveFailures = 0; // Reset on success
|
||||
final int currentPageNum = pgNum + 1; // Convert to 1-based page numbering
|
||||
Future<Void> future =
|
||||
executor.submit(
|
||||
() -> {
|
||||
try {
|
||||
// Call the image extraction method for each page
|
||||
extractImagesFromPage(
|
||||
page,
|
||||
format,
|
||||
filename,
|
||||
currentPageNum,
|
||||
processedImages,
|
||||
zos,
|
||||
allowDuplicates);
|
||||
} catch (Exception e) {
|
||||
// Log the error and continue processing other pages
|
||||
ExceptionUtils.logException(
|
||||
"image extraction from page "
|
||||
+ currentPageNum,
|
||||
e);
|
||||
}
|
||||
if (useMultithreading) {
|
||||
ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();
|
||||
Set<Future<Void>> futures = new HashSet<>();
|
||||
|
||||
return null; // Callable requires a return type
|
||||
});
|
||||
try {
|
||||
int pageCount = document.getPages().getCount();
|
||||
log.debug("Document reports {} pages", pageCount);
|
||||
|
||||
// Add the Future object to the list to track completion
|
||||
futures.add(future);
|
||||
} catch (Exception e) {
|
||||
consecutiveFailures++;
|
||||
ExceptionUtils.logException("page access for page " + (pgNum + 1), e);
|
||||
int consecutiveFailures = 0;
|
||||
|
||||
if (consecutiveFailures >= 3) {
|
||||
log.warn("Stopping page iteration after 3 consecutive failures");
|
||||
break;
|
||||
for (int pgNum = 0; pgNum < pageCount; pgNum++) {
|
||||
try {
|
||||
PDPage page = document.getPage(pgNum);
|
||||
consecutiveFailures = 0; // Reset on success
|
||||
final int currentPageNum =
|
||||
pgNum + 1; // Convert to 1-based page numbering
|
||||
Future<Void> future =
|
||||
executor.submit(
|
||||
() -> {
|
||||
try {
|
||||
// Call the image extraction method for each
|
||||
// page
|
||||
extractImagesFromPage(
|
||||
page,
|
||||
format,
|
||||
filename,
|
||||
currentPageNum,
|
||||
processedImages,
|
||||
zos,
|
||||
allowDuplicates);
|
||||
} catch (Exception e) {
|
||||
// Log the error and continue processing other
|
||||
// pages
|
||||
ExceptionUtils.logException(
|
||||
"image extraction from page "
|
||||
+ currentPageNum,
|
||||
e);
|
||||
}
|
||||
|
||||
return null; // Callable requires a return type
|
||||
});
|
||||
|
||||
// Add the Future object to the list to track completion
|
||||
futures.add(future);
|
||||
} catch (Exception e) {
|
||||
consecutiveFailures++;
|
||||
ExceptionUtils.logException("page access for page " + (pgNum + 1), e);
|
||||
|
||||
if (consecutiveFailures >= 3) {
|
||||
log.warn("Stopping page iteration after 3 consecutive failures");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
ExceptionUtils.logException("page count determination", e);
|
||||
throw e;
|
||||
}
|
||||
} catch (Exception e) {
|
||||
ExceptionUtils.logException("page count determination", e);
|
||||
throw e;
|
||||
}
|
||||
|
||||
// Wait for all tasks to complete
|
||||
for (Future<Void> future : futures) {
|
||||
future.get();
|
||||
}
|
||||
// Wait for all tasks to complete
|
||||
for (Future<Void> future : futures) {
|
||||
future.get();
|
||||
}
|
||||
|
||||
// Close executor service
|
||||
executor.shutdown();
|
||||
} else {
|
||||
// Single-threaded extraction
|
||||
for (int pgNum = 0; pgNum < document.getPages().getCount(); pgNum++) {
|
||||
PDPage page = document.getPage(pgNum);
|
||||
extractImagesFromPage(
|
||||
page, format, filename, pgNum + 1, processedImages, zos, allowDuplicates);
|
||||
// Close executor service
|
||||
executor.shutdown();
|
||||
} else {
|
||||
// Single-threaded extraction
|
||||
for (int pgNum = 0; pgNum < document.getPages().getCount(); pgNum++) {
|
||||
PDPage page = document.getPage(pgNum);
|
||||
extractImagesFromPage(
|
||||
page,
|
||||
format,
|
||||
filename,
|
||||
pgNum + 1,
|
||||
processedImages,
|
||||
zos,
|
||||
allowDuplicates);
|
||||
}
|
||||
}
|
||||
// document and zos closed by try-with-resources
|
||||
} catch (Exception e) {
|
||||
zipTempFile.close();
|
||||
throw e;
|
||||
}
|
||||
|
||||
// Close PDDocument and ZipOutputStream
|
||||
document.close();
|
||||
zos.close();
|
||||
|
||||
// Create ByteArrayResource from byte array
|
||||
byte[] zipContents = baos.toByteArray();
|
||||
|
||||
return WebResponseUtils.baosToWebResponse(
|
||||
baos, filename + "_extracted-images.zip", MediaType.APPLICATION_OCTET_STREAM);
|
||||
return WebResponseUtils.zipFileToWebResponse(
|
||||
zipTempFile, filename + "_extracted-images.zip");
|
||||
}
|
||||
|
||||
private boolean shouldUseMultithreading(MultipartFile file, PDDocument document) {
|
||||
@@ -214,13 +223,17 @@ public class ExtractImagesController {
|
||||
// Convert to standard RGB colorspace if needed
|
||||
BufferedImage bufferedImage = convertToRGB(renderedImage, format);
|
||||
|
||||
// Write image to zip file
|
||||
// Encode image outside the lock to allow parallel encoding across threads
|
||||
String imageName = filename + "_page_" + pageNum + "_" + count++ + "." + format;
|
||||
ByteArrayOutputStream imageBaos = new ByteArrayOutputStream();
|
||||
ImageIO.write(bufferedImage, format, imageBaos);
|
||||
byte[] imageData = imageBaos.toByteArray();
|
||||
|
||||
// Write encoded bytes to zip under lock (ZipOutputStream requires
|
||||
// serialization)
|
||||
synchronized (zos) {
|
||||
zos.putNextEntry(new ZipEntry(imageName));
|
||||
ByteArrayOutputStream imageBaos = new ByteArrayOutputStream();
|
||||
ImageIO.write(bufferedImage, format, imageBaos);
|
||||
zos.write(imageBaos.toByteArray());
|
||||
zos.write(imageData);
|
||||
zos.closeEntry();
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -94,7 +94,7 @@ public class OCRController {
|
||||
throws IOException, InterruptedException {
|
||||
MultipartFile inputFile = request.getFileInput();
|
||||
List<String> selectedLanguages = request.getLanguages();
|
||||
Boolean sidecar = request.isSidecar();
|
||||
boolean sidecar = request.isSidecar();
|
||||
Boolean deskew = request.isDeskew();
|
||||
Boolean clean = request.isClean();
|
||||
Boolean cleanFinal = request.isCleanFinal();
|
||||
|
||||
+1
-1
@@ -233,7 +233,7 @@ public class StampController {
|
||||
};
|
||||
|
||||
ClassPathResource classPathResource = new ClassPathResource(resourceDir);
|
||||
String fileExtension = resourceDir.substring(resourceDir.lastIndexOf("."));
|
||||
String fileExtension = resourceDir.substring(resourceDir.lastIndexOf('.'));
|
||||
|
||||
// Use TempFile with try-with-resources for automatic cleanup
|
||||
try (TempFile tempFileWrapper = new TempFile(tempFileManager, fileExtension)) {
|
||||
|
||||
+52
-42
@@ -1,7 +1,7 @@
|
||||
package stirling.software.SPDF.controller.api.pipeline;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.InputStream;
|
||||
import java.nio.file.Files;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
@@ -13,6 +13,7 @@ import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.ModelAttribute;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody;
|
||||
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
|
||||
@@ -28,6 +29,8 @@ import stirling.software.common.annotations.AutoJobPostMapping;
|
||||
import stirling.software.common.annotations.api.PipelineApi;
|
||||
import stirling.software.common.service.PostHogService;
|
||||
import stirling.software.common.util.GeneralUtils;
|
||||
import stirling.software.common.util.TempFile;
|
||||
import stirling.software.common.util.TempFileManager;
|
||||
import stirling.software.common.util.WebResponseUtils;
|
||||
|
||||
import tools.jackson.core.JacksonException;
|
||||
@@ -45,6 +48,8 @@ public class PipelineController {
|
||||
|
||||
private final PostHogService postHogService;
|
||||
|
||||
private final TempFileManager tempFileManager;
|
||||
|
||||
@AutoJobPostMapping(value = "/handleData", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||
@MultiFileResponse
|
||||
@Operation(
|
||||
@@ -53,8 +58,8 @@ public class PipelineController {
|
||||
"This endpoint processes multiple PDF files through a configurable pipeline of operations. "
|
||||
+ "Users provide files and a JSON configuration defining the sequence of operations to perform. "
|
||||
+ "Input:PDF Output:PDF/ZIP Type:MIMO")
|
||||
public ResponseEntity<byte[]> handleData(@ModelAttribute HandleDataRequest request)
|
||||
throws DatabindException, JacksonException {
|
||||
public ResponseEntity<StreamingResponseBody> handleData(
|
||||
@ModelAttribute HandleDataRequest request) throws DatabindException, JacksonException {
|
||||
MultipartFile[] files = request.getFileInput();
|
||||
String jsonString = request.getJson();
|
||||
if (files == null) {
|
||||
@@ -80,51 +85,56 @@ public class PipelineController {
|
||||
PipelineResult result = processor.runPipelineAgainstFiles(inputFiles, config);
|
||||
List<Resource> outputFiles = result.getOutputFiles();
|
||||
if (outputFiles != null && outputFiles.size() == 1) {
|
||||
// If there is only one file, return it directly
|
||||
// If there is only one file, return it directly — stream without int-overflow
|
||||
Resource singleFile = outputFiles.get(0);
|
||||
InputStream is = singleFile.getInputStream();
|
||||
byte[] bytes = new byte[(int) singleFile.contentLength()];
|
||||
is.read(bytes);
|
||||
is.close();
|
||||
log.info("Returning single file response...");
|
||||
return WebResponseUtils.bytesToWebResponse(
|
||||
bytes, singleFile.getFilename(), MediaType.APPLICATION_OCTET_STREAM);
|
||||
TempFile singleTempFile = new TempFile(tempFileManager, ".out");
|
||||
try {
|
||||
try (InputStream is = singleFile.getInputStream()) {
|
||||
is.transferTo(Files.newOutputStream(singleTempFile.getPath()));
|
||||
}
|
||||
log.info("Returning single file response...");
|
||||
return WebResponseUtils.fileToWebResponse(
|
||||
singleTempFile,
|
||||
singleFile.getFilename(),
|
||||
MediaType.APPLICATION_OCTET_STREAM);
|
||||
} catch (Exception e) {
|
||||
singleTempFile.close();
|
||||
throw e;
|
||||
}
|
||||
} else if (outputFiles == null) {
|
||||
return null;
|
||||
}
|
||||
// Create a ByteArrayOutputStream to hold the zip
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
ZipOutputStream zipOut = new ZipOutputStream(baos);
|
||||
// A map to keep track of filenames and their counts
|
||||
Map<String, Integer> filenameCount = new HashMap<>();
|
||||
// Loop through each file and add it to the zip
|
||||
for (Resource file : outputFiles) {
|
||||
String originalFilename = file.getFilename();
|
||||
String filename = originalFilename;
|
||||
// Check if the filename already exists, and modify it if necessary
|
||||
if (filenameCount.containsKey(originalFilename)) {
|
||||
int count = filenameCount.get(originalFilename);
|
||||
assert originalFilename != null;
|
||||
filename = GeneralUtils.generateFilename(originalFilename, "(" + count + ")");
|
||||
filenameCount.put(originalFilename, count + 1);
|
||||
} else {
|
||||
filenameCount.put(originalFilename, 1);
|
||||
// Multiple files: stream into a zip TempFile
|
||||
TempFile zipTempFile = new TempFile(tempFileManager, ".zip");
|
||||
try {
|
||||
Map<String, Integer> filenameCount = new HashMap<>();
|
||||
try (ZipOutputStream zipOut =
|
||||
new ZipOutputStream(Files.newOutputStream(zipTempFile.getPath()))) {
|
||||
for (Resource file : outputFiles) {
|
||||
String originalFilename = file.getFilename();
|
||||
String filename = originalFilename;
|
||||
if (filenameCount.containsKey(originalFilename)) {
|
||||
int count = filenameCount.get(originalFilename);
|
||||
filename =
|
||||
GeneralUtils.generateFilename(
|
||||
originalFilename, "(" + count + ")");
|
||||
filenameCount.put(originalFilename, count + 1);
|
||||
} else {
|
||||
filenameCount.put(originalFilename, 1);
|
||||
}
|
||||
zipOut.putNextEntry(new ZipEntry(filename));
|
||||
try (InputStream is = file.getInputStream()) {
|
||||
is.transferTo(zipOut);
|
||||
}
|
||||
zipOut.closeEntry();
|
||||
}
|
||||
}
|
||||
ZipEntry zipEntry = new ZipEntry(filename);
|
||||
zipOut.putNextEntry(zipEntry);
|
||||
// Read the file into a byte array
|
||||
InputStream is = file.getInputStream();
|
||||
byte[] bytes = new byte[(int) file.contentLength()];
|
||||
is.read(bytes);
|
||||
// Write the bytes of the file to the zip
|
||||
zipOut.write(bytes, 0, bytes.length);
|
||||
zipOut.closeEntry();
|
||||
is.close();
|
||||
log.info("Returning zipped file response...");
|
||||
return WebResponseUtils.zipFileToWebResponse(zipTempFile, "output.zip");
|
||||
} catch (Exception e) {
|
||||
zipTempFile.close();
|
||||
throw e;
|
||||
}
|
||||
zipOut.close();
|
||||
log.info("Returning zipped file response...");
|
||||
return WebResponseUtils.baosToWebResponse(
|
||||
baos, "output.zip", MediaType.APPLICATION_OCTET_STREAM);
|
||||
} catch (Exception e) {
|
||||
log.error("Error handling data: ", e);
|
||||
return null;
|
||||
|
||||
+1
-1
@@ -254,7 +254,7 @@ public class PipelineDirectoryProcessor {
|
||||
String extension =
|
||||
filename.contains(".")
|
||||
? filename.substring(
|
||||
filename.lastIndexOf(".")
|
||||
filename.lastIndexOf('.')
|
||||
+ 1)
|
||||
.toLowerCase(Locale.ROOT)
|
||||
: "";
|
||||
|
||||
+19
-5
@@ -67,7 +67,7 @@ public class PipelineProcessor {
|
||||
|
||||
public static String removeTrailingNaming(String filename) {
|
||||
// Splitting filename into name and extension
|
||||
int dotIndex = filename.lastIndexOf(".");
|
||||
int dotIndex = filename.lastIndexOf('.');
|
||||
if (dotIndex == -1) {
|
||||
// No extension found
|
||||
return filename;
|
||||
@@ -75,7 +75,7 @@ public class PipelineProcessor {
|
||||
String name = filename.substring(0, dotIndex);
|
||||
String extension = filename.substring(dotIndex);
|
||||
// Finding the last underscore
|
||||
int underscoreIndex = name.lastIndexOf("_");
|
||||
int underscoreIndex = name.lastIndexOf('_');
|
||||
if (underscoreIndex == -1) {
|
||||
// No underscore found
|
||||
return filename;
|
||||
@@ -173,7 +173,7 @@ public class PipelineProcessor {
|
||||
String providedExtension = "no extension";
|
||||
if (filename != null && filename.contains(".")) {
|
||||
providedExtension =
|
||||
filename.substring(filename.lastIndexOf("."))
|
||||
filename.substring(filename.lastIndexOf('.'))
|
||||
.toLowerCase(Locale.ROOT);
|
||||
}
|
||||
|
||||
@@ -248,7 +248,7 @@ public class PipelineProcessor {
|
||||
String filename = file.getFilename();
|
||||
if (filename != null && filename.contains(".")) {
|
||||
return filename.substring(
|
||||
filename.lastIndexOf("."))
|
||||
filename.lastIndexOf('.'))
|
||||
.toLowerCase(Locale.ROOT);
|
||||
}
|
||||
return "no extension";
|
||||
@@ -450,7 +450,21 @@ public class PipelineProcessor {
|
||||
return isZip(data, null);
|
||||
}
|
||||
|
||||
private static final int MAX_UNZIP_DEPTH = 10;
|
||||
|
||||
private List<Resource> unzip(Resource data, PipelineResult result) throws IOException {
|
||||
return unzip(data, result, 0);
|
||||
}
|
||||
|
||||
private List<Resource> unzip(Resource data, PipelineResult result, int depth)
|
||||
throws IOException {
|
||||
if (depth > MAX_UNZIP_DEPTH) {
|
||||
log.warn(
|
||||
"ZIP nesting depth {} exceeds limit {}, treating as file",
|
||||
depth,
|
||||
MAX_UNZIP_DEPTH);
|
||||
return List.of(data);
|
||||
}
|
||||
log.info("Unzipping data of length: {}", data.contentLength());
|
||||
List<Resource> unzippedFiles = new ArrayList<>();
|
||||
try (InputStream bais = data.getInputStream();
|
||||
@@ -481,7 +495,7 @@ public class PipelineProcessor {
|
||||
// If the unzipped file is a zip file, unzip it
|
||||
if (isZip(fileResource, filename)) {
|
||||
log.info("File {} is a zip file. Unzipping...", filename);
|
||||
unzippedFiles.addAll(unzip(fileResource, result));
|
||||
unzippedFiles.addAll(unzip(fileResource, result, depth + 1));
|
||||
} else {
|
||||
unzippedFiles.add(fileResource);
|
||||
}
|
||||
|
||||
+1
-1
@@ -188,7 +188,7 @@ public class WatermarkController {
|
||||
};
|
||||
|
||||
ClassPathResource classPathResource = new ClassPathResource(resourceDir);
|
||||
String fileExtension = resourceDir.substring(resourceDir.lastIndexOf("."));
|
||||
String fileExtension = resourceDir.substring(resourceDir.lastIndexOf('.'));
|
||||
File tempFile = Files.createTempFile("NotoSansFont", fileExtension).toFile();
|
||||
try (InputStream is = classPathResource.getInputStream();
|
||||
FileOutputStream os = new FileOutputStream(tempFile)) {
|
||||
|
||||
+13
-3
@@ -51,9 +51,19 @@ public class MetricsController {
|
||||
description =
|
||||
"This endpoint returns the status of the application and its version number.")
|
||||
public ResponseEntity<?> getStatus() {
|
||||
if (!metricsEnabled) {
|
||||
return ResponseEntity.status(HttpStatus.FORBIDDEN).body("This endpoint is disabled.");
|
||||
}
|
||||
return getApplicationStatus();
|
||||
}
|
||||
|
||||
@GetMapping("/health")
|
||||
@Operation(
|
||||
summary = "Application health check",
|
||||
description =
|
||||
"This endpoint returns the health status of the application and its version number. Mirrors /api/v1/info/status.")
|
||||
public ResponseEntity<?> getHealth() {
|
||||
return getApplicationStatus();
|
||||
}
|
||||
|
||||
private ResponseEntity<?> getApplicationStatus() {
|
||||
Map<String, String> status = new HashMap<>();
|
||||
status.put("status", "UP");
|
||||
String version = getClass().getPackage().getImplementationVersion();
|
||||
|
||||
@@ -3,6 +3,7 @@ package stirling.software.SPDF.exception;
|
||||
import java.io.IOException;
|
||||
import java.net.URI;
|
||||
import java.time.Instant;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
|
||||
import org.springframework.context.MessageSource;
|
||||
@@ -23,8 +24,6 @@ import org.springframework.web.multipart.MaxUploadSizeExceededException;
|
||||
import org.springframework.web.multipart.support.MissingServletRequestPartException;
|
||||
import org.springframework.web.servlet.NoHandlerFoundException;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
|
||||
@@ -35,6 +34,8 @@ import stirling.software.common.util.ExceptionUtils;
|
||||
import stirling.software.common.util.ExceptionUtils.*;
|
||||
import stirling.software.common.util.RegexPatternUtils;
|
||||
|
||||
import tools.jackson.databind.ObjectMapper;
|
||||
|
||||
/**
|
||||
* Returns RFC 7807 Problem Details for HTTP APIs, ensuring consistent error responses across the
|
||||
* application.
|
||||
@@ -870,7 +871,7 @@ public class GlobalExceptionHandler {
|
||||
|
||||
// Use ObjectMapper to properly escape JSON values and prevent XSS
|
||||
ObjectMapper mapper = new ObjectMapper();
|
||||
java.util.Map<String, Object> errorMap = new java.util.LinkedHashMap<>();
|
||||
java.util.Map<String, Object> errorMap = new LinkedHashMap<>();
|
||||
errorMap.put("type", "about:blank");
|
||||
errorMap.put("title", "Not Acceptable");
|
||||
errorMap.put("status", 406);
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
package stirling.software.SPDF.model.json;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
@@ -27,13 +25,13 @@ public class PdfJsonAnnotation {
|
||||
private String contents;
|
||||
|
||||
/** Annotation rectangle [x1, y1, x2, y2] */
|
||||
private List<Float> rect;
|
||||
private float[] rect;
|
||||
|
||||
/** Annotation appearance characteristics */
|
||||
private String appearanceState;
|
||||
|
||||
/** Color components (e.g., [r, g, b] for RGB) */
|
||||
private List<Float> color;
|
||||
private float[] color;
|
||||
|
||||
/** Annotation flags (print, hidden, etc.) */
|
||||
private Integer flags;
|
||||
|
||||
@@ -14,7 +14,7 @@ import lombok.NoArgsConstructor;
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||
@JsonInclude(JsonInclude.Include.NON_DEFAULT)
|
||||
public class PdfJsonDocument {
|
||||
|
||||
private PdfJsonMetadata metadata;
|
||||
@@ -23,7 +23,7 @@ public class PdfJsonDocument {
|
||||
private String xmpMetadata;
|
||||
|
||||
/** Indicates that images should be loaded lazily via API rather than embedded in the JSON. */
|
||||
private Boolean lazyImages;
|
||||
private boolean lazyImages;
|
||||
|
||||
@Builder.Default private List<PdfJsonFont> fonts = new ArrayList<>();
|
||||
|
||||
|
||||
+1
-3
@@ -1,7 +1,5 @@
|
||||
package stirling.software.SPDF.model.json;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
@@ -65,5 +63,5 @@ public class PdfJsonFontConversionCandidate {
|
||||
private String diagnostics;
|
||||
|
||||
/** Known unicode/codepoint coverage derived from the conversion strategy. */
|
||||
private List<Integer> glyphCoverage;
|
||||
private int[] glyphCoverage;
|
||||
}
|
||||
|
||||
@@ -45,13 +45,13 @@ public class PdfJsonFormField {
|
||||
private Integer pageNumber;
|
||||
|
||||
/** Field rectangle [x1, y1, x2, y2] on the page */
|
||||
private List<Float> rect;
|
||||
private float[] rect;
|
||||
|
||||
/** For choice fields: list of options */
|
||||
private List<String> options;
|
||||
|
||||
/** For choice fields: selected indices */
|
||||
private List<Integer> selectedIndices;
|
||||
private int[] selectedIndices;
|
||||
|
||||
/** For button fields: whether it's checked */
|
||||
private Boolean checked;
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
package stirling.software.SPDF.model.json;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
@@ -30,7 +27,7 @@ public class PdfJsonImageElement {
|
||||
private Float right;
|
||||
private Float top;
|
||||
private Float bottom;
|
||||
@Builder.Default private List<Float> transform = new ArrayList<>();
|
||||
private float[] transform;
|
||||
private Integer zOrder;
|
||||
private String imageData;
|
||||
private String imageFormat;
|
||||
|
||||
@@ -11,10 +11,10 @@ import lombok.NoArgsConstructor;
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||
@JsonInclude(JsonInclude.Include.NON_DEFAULT)
|
||||
public class PdfJsonPageDimension {
|
||||
private Integer pageNumber;
|
||||
private Float width;
|
||||
private Float height;
|
||||
private Integer rotation;
|
||||
private int pageNumber;
|
||||
private float width;
|
||||
private float height;
|
||||
private int rotation;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
package stirling.software.SPDF.model.json;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
@@ -17,5 +15,5 @@ import lombok.NoArgsConstructor;
|
||||
public class PdfJsonTextColor {
|
||||
|
||||
private String colorSpace;
|
||||
private List<Float> components;
|
||||
private float[] components;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
package stirling.software.SPDF.model.json;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
@@ -32,10 +30,10 @@ public class PdfJsonTextElement {
|
||||
private Float y;
|
||||
private Float width;
|
||||
private Float height;
|
||||
private List<Float> textMatrix;
|
||||
private float[] textMatrix;
|
||||
private PdfJsonTextColor fillColor;
|
||||
private PdfJsonTextColor strokeColor;
|
||||
private Integer renderingMode;
|
||||
private Boolean fallbackUsed;
|
||||
private List<Integer> charCodes;
|
||||
private int[] charCodes;
|
||||
}
|
||||
|
||||
+157
-163
@@ -273,11 +273,11 @@ public class PdfJsonConversionService {
|
||||
|
||||
// Get job ID from request context if running in async mode
|
||||
String contextJobId = getJobIdFromRequest();
|
||||
boolean isRealJobId = (contextJobId != null && !contextJobId.isEmpty());
|
||||
boolean useLazyImages = (contextJobId != null && !contextJobId.isEmpty());
|
||||
|
||||
// Generate synthetic jobId for synchronous conversions to prevent cache collisions
|
||||
final String jobId;
|
||||
if (!isRealJobId) {
|
||||
if (!useLazyImages) {
|
||||
jobId = "pdf2json:" + java.util.UUID.randomUUID().toString();
|
||||
log.debug("Generated synthetic jobId for synchronous conversion: {}", jobId);
|
||||
} else {
|
||||
@@ -302,7 +302,7 @@ public class PdfJsonConversionService {
|
||||
: "");
|
||||
progressCallback.accept(p);
|
||||
}
|
||||
: isRealJobId
|
||||
: useLazyImages
|
||||
? (p) -> {
|
||||
log.debug(
|
||||
"Progress: [{}%] {} - {}{}",
|
||||
@@ -369,7 +369,6 @@ public class PdfJsonConversionService {
|
||||
int totalPages = document.getNumberOfPages();
|
||||
// Always enable lazy mode for real async jobs so cache is available regardless of
|
||||
// page count. Synchronous calls with synthetic jobId still do full extraction.
|
||||
boolean useLazyImages = isRealJobId;
|
||||
Map<COSBase, FontModelCacheEntry> fontCache = new IdentityHashMap<>();
|
||||
Map<COSBase, EncodedImage> imageCache = new IdentityHashMap<>();
|
||||
log.debug(
|
||||
@@ -377,7 +376,7 @@ public class PdfJsonConversionService {
|
||||
totalPages,
|
||||
useLazyImages ? "lazy image" : "standard",
|
||||
jobId,
|
||||
isRealJobId);
|
||||
useLazyImages);
|
||||
Map<String, PdfJsonFont> fonts = new LinkedHashMap<>();
|
||||
Map<Integer, List<PdfJsonTextElement>> textByPage = new LinkedHashMap<>();
|
||||
Map<Integer, Map<PDFont, String>> pageFontResources = new HashMap<>();
|
||||
@@ -431,7 +430,7 @@ public class PdfJsonConversionService {
|
||||
progress.accept(
|
||||
PdfJsonConversionProgress.of(
|
||||
80, "annotations", "Collecting annotations and form fields"));
|
||||
boolean includeAnnotationRawData = !(lightweight && isRealJobId);
|
||||
boolean includeAnnotationRawData = !(lightweight && useLazyImages);
|
||||
Map<Integer, List<PdfJsonAnnotation>> annotationsByPage =
|
||||
collectAnnotations(
|
||||
document, totalPages, progress, includeAnnotationRawData);
|
||||
@@ -463,8 +462,8 @@ public class PdfJsonConversionService {
|
||||
textByPage,
|
||||
imagesByPage,
|
||||
annotationsByPage,
|
||||
lightweight && isRealJobId));
|
||||
if (lightweight && isRealJobId) {
|
||||
lightweight && useLazyImages));
|
||||
if (lightweight && useLazyImages) {
|
||||
// Lightweight async editor flow does not use form fields and this payload can
|
||||
// be
|
||||
// very large due nested raw dictionaries.
|
||||
@@ -474,12 +473,11 @@ public class PdfJsonConversionService {
|
||||
}
|
||||
|
||||
// Only cache for real async jobIds, not synthetic synchronous ones
|
||||
if (useLazyImages && isRealJobId) {
|
||||
if (useLazyImages) {
|
||||
log.debug(
|
||||
"Creating cache for jobId: {} (useLazyImages={}, isRealJobId={})",
|
||||
"Creating cache for jobId: {} (useLazyImages={})",
|
||||
jobId,
|
||||
useLazyImages,
|
||||
isRealJobId);
|
||||
useLazyImages);
|
||||
PdfJsonDocumentMetadata docMetadata = new PdfJsonDocumentMetadata();
|
||||
docMetadata.setMetadata(pdfJson.getMetadata());
|
||||
docMetadata.setXmpMetadata(pdfJson.getXmpMetadata());
|
||||
@@ -529,16 +527,15 @@ public class PdfJsonConversionService {
|
||||
scheduleDocumentCleanup(jobId);
|
||||
} else {
|
||||
log.warn(
|
||||
"Skipping cache creation: useLazyImages={}, isRealJobId={}, jobId={}",
|
||||
"Skipping cache creation: useLazyImages={}, jobId={}",
|
||||
useLazyImages,
|
||||
isRealJobId,
|
||||
jobId);
|
||||
}
|
||||
|
||||
if (lightweight) {
|
||||
applyLightweightTransformations(pdfJson);
|
||||
}
|
||||
if (lightweight && isRealJobId) {
|
||||
if (lightweight && useLazyImages) {
|
||||
stripFontProgramPayloads(responseFonts);
|
||||
stripFontCosStreamData(responseFonts);
|
||||
}
|
||||
@@ -2503,11 +2500,12 @@ public class PdfJsonConversionService {
|
||||
PDRectangle rect = annotation.getRectangle();
|
||||
if (rect != null) {
|
||||
ann.setRect(
|
||||
List.of(
|
||||
rect.getLowerLeftX(),
|
||||
rect.getLowerLeftY(),
|
||||
rect.getUpperRightX(),
|
||||
rect.getUpperRightY()));
|
||||
new float[] {
|
||||
rect.getLowerLeftX(),
|
||||
rect.getLowerLeftY(),
|
||||
rect.getUpperRightX(),
|
||||
rect.getUpperRightY()
|
||||
});
|
||||
}
|
||||
|
||||
COSName appearanceState = annotation.getAppearanceState();
|
||||
@@ -2516,12 +2514,7 @@ public class PdfJsonConversionService {
|
||||
}
|
||||
|
||||
if (annotation.getColor() != null) {
|
||||
float[] colorComponents = annotation.getColor().getComponents();
|
||||
List<Float> colorList = new ArrayList<>(colorComponents.length);
|
||||
for (float c : colorComponents) {
|
||||
colorList.add(c);
|
||||
}
|
||||
ann.setColor(colorList);
|
||||
ann.setColor(annotation.getColor().getComponents());
|
||||
}
|
||||
|
||||
COSDictionary annotDict = annotation.getCOSObject();
|
||||
@@ -2636,11 +2629,12 @@ public class PdfJsonConversionService {
|
||||
PDRectangle rect = widget.getRectangle();
|
||||
if (rect != null) {
|
||||
formField.setRect(
|
||||
List.of(
|
||||
rect.getLowerLeftX(),
|
||||
rect.getLowerLeftY(),
|
||||
rect.getUpperRightX(),
|
||||
rect.getUpperRightY()));
|
||||
new float[] {
|
||||
rect.getLowerLeftX(),
|
||||
rect.getLowerLeftY(),
|
||||
rect.getUpperRightX(),
|
||||
rect.getUpperRightY()
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3351,7 +3345,7 @@ public class PdfJsonConversionService {
|
||||
PDFont currentFont = baseFont;
|
||||
String currentFontId = baseFontId;
|
||||
|
||||
List<Integer> elementCodes = element.getCharCodes();
|
||||
int[] elementCodes = element.getCharCodes();
|
||||
int codeIndex = 0;
|
||||
boolean rawType3CodesUsed = false;
|
||||
int rawType3GlyphCount = 0;
|
||||
@@ -3363,8 +3357,8 @@ public class PdfJsonConversionService {
|
||||
PDFont targetFont = baseFont;
|
||||
String targetFontId = baseFontId;
|
||||
Integer rawCode = null;
|
||||
if (elementCodes != null && codeIndex < elementCodes.size()) {
|
||||
rawCode = elementCodes.get(codeIndex);
|
||||
if (elementCodes != null && codeIndex < elementCodes.length) {
|
||||
rawCode = elementCodes[codeIndex];
|
||||
}
|
||||
codeIndex++;
|
||||
|
||||
@@ -3667,10 +3661,7 @@ public class PdfJsonConversionService {
|
||||
if (color == null || color.getComponents() == null) {
|
||||
return;
|
||||
}
|
||||
float[] components = new float[color.getComponents().size()];
|
||||
for (int i = 0; i < components.length; i++) {
|
||||
components[i] = color.getComponents().get(i);
|
||||
}
|
||||
float[] components = color.getComponents();
|
||||
String space = color.getColorSpace();
|
||||
if (space == null) {
|
||||
// Infer color space from component count
|
||||
@@ -4287,8 +4278,11 @@ public class PdfJsonConversionService {
|
||||
List<Integer> combinedCodes = new ArrayList<>();
|
||||
for (PdfJsonTextElement element : elements) {
|
||||
builder.append(Objects.toString(element.getText(), ""));
|
||||
if (element.getCharCodes() != null && !element.getCharCodes().isEmpty()) {
|
||||
combinedCodes.addAll(element.getCharCodes());
|
||||
int[] codes = element.getCharCodes();
|
||||
if (codes != null && codes.length > 0) {
|
||||
for (int code : codes) {
|
||||
combinedCodes.add(code);
|
||||
}
|
||||
}
|
||||
}
|
||||
return new MergedText(builder.toString(), combinedCodes.isEmpty() ? null : combinedCodes);
|
||||
@@ -4354,9 +4348,9 @@ public class PdfJsonConversionService {
|
||||
}
|
||||
|
||||
private int countGlyphs(PdfJsonTextElement element) {
|
||||
List<Integer> codes = element.getCharCodes();
|
||||
if (codes != null && !codes.isEmpty()) {
|
||||
return codes.size();
|
||||
int[] codes = element.getCharCodes();
|
||||
if (codes != null && codes.length > 0) {
|
||||
return codes.length;
|
||||
}
|
||||
String text = element.getText();
|
||||
if (text != null && !text.isEmpty()) {
|
||||
@@ -4920,15 +4914,15 @@ public class PdfJsonConversionService {
|
||||
|
||||
private void applyTextMatrix(PDPageContentStream contentStream, PdfJsonTextElement element)
|
||||
throws IOException {
|
||||
List<Float> matrix = element.getTextMatrix();
|
||||
if (matrix != null && matrix.size() == 6) {
|
||||
float[] matrix = element.getTextMatrix();
|
||||
if (matrix != null && matrix.length == 6) {
|
||||
float fontScale = resolveFontMatrixSize(element);
|
||||
float a = matrix.get(0);
|
||||
float b = matrix.get(1);
|
||||
float c = matrix.get(2);
|
||||
float d = matrix.get(3);
|
||||
float e = matrix.get(4);
|
||||
float f = matrix.get(5);
|
||||
float a = matrix[0];
|
||||
float b = matrix[1];
|
||||
float c = matrix[2];
|
||||
float d = matrix[3];
|
||||
float e = matrix[4];
|
||||
float f = matrix[5];
|
||||
|
||||
if (fontScale != 0f) {
|
||||
a /= fontScale;
|
||||
@@ -4950,12 +4944,12 @@ public class PdfJsonConversionService {
|
||||
if (fromElement != null && fromElement > 0f) {
|
||||
return fromElement;
|
||||
}
|
||||
List<Float> matrix = element.getTextMatrix();
|
||||
if (matrix != null && matrix.size() >= 4) {
|
||||
float a = matrix.get(0);
|
||||
float b = matrix.get(1);
|
||||
float c = matrix.get(2);
|
||||
float d = matrix.get(3);
|
||||
float[] matrix = element.getTextMatrix();
|
||||
if (matrix != null && matrix.length >= 4) {
|
||||
float a = matrix[0];
|
||||
float b = matrix[1];
|
||||
float c = matrix[2];
|
||||
float d = matrix[3];
|
||||
float verticalScale = (float) Math.hypot(b, d);
|
||||
if (verticalScale > 0f) {
|
||||
return verticalScale;
|
||||
@@ -5048,7 +5042,7 @@ public class PdfJsonConversionService {
|
||||
}
|
||||
Matrix ctm = getGraphicsState().getCurrentTransformationMatrix();
|
||||
Bounds bounds = computeBounds(ctm);
|
||||
List<Float> matrixValues = toMatrixValues(ctm);
|
||||
float[] matrixValues = toMatrixValues(ctm);
|
||||
|
||||
PdfJsonImageElement element =
|
||||
PdfJsonImageElement.builder()
|
||||
@@ -5222,15 +5216,15 @@ public class PdfJsonConversionService {
|
||||
|
||||
private record EncodedImage(String base64, String format) {}
|
||||
|
||||
private List<Float> toMatrixValues(Matrix matrix) {
|
||||
List<Float> values = new ArrayList<>(6);
|
||||
values.add(matrix.getValue(0, 0));
|
||||
values.add(matrix.getValue(0, 1));
|
||||
values.add(matrix.getValue(1, 0));
|
||||
values.add(matrix.getValue(1, 1));
|
||||
values.add(matrix.getValue(2, 0));
|
||||
values.add(matrix.getValue(2, 1));
|
||||
return values;
|
||||
private float[] toMatrixValues(Matrix matrix) {
|
||||
return new float[] {
|
||||
matrix.getValue(0, 0),
|
||||
matrix.getValue(0, 1),
|
||||
matrix.getValue(1, 0),
|
||||
matrix.getValue(1, 1),
|
||||
matrix.getValue(2, 0),
|
||||
matrix.getValue(2, 1)
|
||||
};
|
||||
}
|
||||
|
||||
private EncodedImage encodeImage(PDImage image) {
|
||||
@@ -5341,16 +5335,16 @@ public class PdfJsonConversionService {
|
||||
cache.put(cacheKey, image);
|
||||
}
|
||||
|
||||
List<Float> transform = element.getTransform();
|
||||
if (transform != null && transform.size() == 6) {
|
||||
float[] transform = element.getTransform();
|
||||
if (transform != null && transform.length == 6) {
|
||||
Matrix matrix =
|
||||
new Matrix(
|
||||
safeFloat(transform.get(0), 1f),
|
||||
safeFloat(transform.get(1), 0f),
|
||||
safeFloat(transform.get(2), 0f),
|
||||
safeFloat(transform.get(3), 1f),
|
||||
safeFloat(transform.get(4), 0f),
|
||||
safeFloat(transform.get(5), 0f));
|
||||
safeFloat(transform[0], 1f),
|
||||
safeFloat(transform[1], 0f),
|
||||
safeFloat(transform[2], 0f),
|
||||
safeFloat(transform[3], 1f),
|
||||
safeFloat(transform[4], 0f),
|
||||
safeFloat(transform[5], 0f));
|
||||
contentStream.drawImage(image, matrix);
|
||||
return;
|
||||
}
|
||||
@@ -5516,14 +5510,17 @@ public class PdfJsonConversionService {
|
||||
if (pdfont instanceof PDType3Font) {
|
||||
int[] codes = position.getCharacterCodes();
|
||||
if (codes != null && codes.length > 0) {
|
||||
List<Integer> codeList = new ArrayList<>(codes.length);
|
||||
int count = 0;
|
||||
for (int code : codes) {
|
||||
if (code >= 0) {
|
||||
codeList.add(code);
|
||||
}
|
||||
if (code >= 0) count++;
|
||||
}
|
||||
if (!codeList.isEmpty()) {
|
||||
element.setCharCodes(codeList);
|
||||
if (count > 0) {
|
||||
int[] filtered = new int[count];
|
||||
int idx = 0;
|
||||
for (int code : codes) {
|
||||
if (code >= 0) filtered[idx++] = code;
|
||||
}
|
||||
element.setCharCodes(filtered);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5552,11 +5549,11 @@ public class PdfJsonConversionService {
|
||||
return;
|
||||
}
|
||||
|
||||
List<Float> matrix = element.getTextMatrix();
|
||||
float[] matrix = element.getTextMatrix();
|
||||
if (matrix != null) {
|
||||
if (matrix.isEmpty()) {
|
||||
if (matrix.length == 0) {
|
||||
element.setTextMatrix(null);
|
||||
} else if (matrix.size() == 6) {
|
||||
} else if (matrix.length == 6) {
|
||||
element.setX(null);
|
||||
element.setY(null);
|
||||
}
|
||||
@@ -5597,29 +5594,29 @@ public class PdfJsonConversionService {
|
||||
if (color == null || color.getComponents() == null) {
|
||||
return true;
|
||||
}
|
||||
List<Float> components = color.getComponents();
|
||||
if (components.isEmpty()) {
|
||||
float[] components = color.getComponents();
|
||||
if (components.length == 0) {
|
||||
return true;
|
||||
}
|
||||
String space = color.getColorSpace();
|
||||
if (space == null || "DeviceRGB".equals(space)) {
|
||||
if (components.size() < 3) {
|
||||
if (components.length < 3) {
|
||||
return false;
|
||||
}
|
||||
return Math.abs(components.get(0)) < FLOAT_EPSILON
|
||||
&& Math.abs(components.get(1)) < FLOAT_EPSILON
|
||||
&& Math.abs(components.get(2)) < FLOAT_EPSILON;
|
||||
return Math.abs(components[0]) < FLOAT_EPSILON
|
||||
&& Math.abs(components[1]) < FLOAT_EPSILON
|
||||
&& Math.abs(components[2]) < FLOAT_EPSILON;
|
||||
}
|
||||
if ("DeviceGray".equals(space)) {
|
||||
return Math.abs(components.get(0)) < FLOAT_EPSILON;
|
||||
return Math.abs(components[0]) < FLOAT_EPSILON;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private Float baselineFrom(PdfJsonTextElement element) {
|
||||
List<Float> matrix = element.getTextMatrix();
|
||||
if (matrix != null && matrix.size() >= 6) {
|
||||
return matrix.get(5);
|
||||
float[] matrix = element.getTextMatrix();
|
||||
if (matrix != null && matrix.length >= 6) {
|
||||
return matrix[5];
|
||||
}
|
||||
return element.getY();
|
||||
}
|
||||
@@ -5648,11 +5645,12 @@ public class PdfJsonConversionService {
|
||||
private final float orientationC;
|
||||
private final float orientationD;
|
||||
private final Float baseline;
|
||||
private final List<Float> baseMatrix;
|
||||
private final float[] baseMatrix;
|
||||
private final float startXCoord;
|
||||
private final float startYCoord;
|
||||
private final StringBuilder textBuilder = new StringBuilder();
|
||||
private final List<Integer> charCodeBuffer = new ArrayList<>();
|
||||
private int[] charCodeBuf = new int[16];
|
||||
private int charCodeLen = 0;
|
||||
private float totalWidth;
|
||||
private float maxHeight;
|
||||
private float endXCoord;
|
||||
@@ -5660,17 +5658,15 @@ public class PdfJsonConversionService {
|
||||
TextRunAccumulator(PdfJsonTextElement element, TextPosition position) {
|
||||
this.baseElement = element;
|
||||
this.styleKey = buildStyleKey(element);
|
||||
this.baseMatrix =
|
||||
element.getTextMatrix() != null
|
||||
? new ArrayList<>(element.getTextMatrix())
|
||||
: null;
|
||||
if (baseMatrix != null && baseMatrix.size() >= 6) {
|
||||
orientationA = baseMatrix.get(0);
|
||||
orientationB = baseMatrix.get(1);
|
||||
orientationC = baseMatrix.get(2);
|
||||
orientationD = baseMatrix.get(3);
|
||||
startXCoord = baseMatrix.get(4);
|
||||
startYCoord = baseMatrix.get(5);
|
||||
float[] tm = element.getTextMatrix();
|
||||
this.baseMatrix = tm != null ? tm.clone() : null;
|
||||
if (baseMatrix != null && baseMatrix.length >= 6) {
|
||||
orientationA = baseMatrix[0];
|
||||
orientationB = baseMatrix[1];
|
||||
orientationC = baseMatrix[2];
|
||||
orientationD = baseMatrix[3];
|
||||
startXCoord = baseMatrix[4];
|
||||
startYCoord = baseMatrix[5];
|
||||
} else {
|
||||
orientationA = 1f;
|
||||
orientationB = 0f;
|
||||
@@ -5684,25 +5680,23 @@ public class PdfJsonConversionService {
|
||||
this.maxHeight = element.getHeight() != null ? element.getHeight() : 0f;
|
||||
this.endXCoord = position.getXDirAdj() + position.getWidthDirAdj();
|
||||
this.textBuilder.append(element.getText());
|
||||
if (element.getCharCodes() != null) {
|
||||
charCodeBuffer.addAll(element.getCharCodes());
|
||||
}
|
||||
appendCharCodes(element.getCharCodes());
|
||||
}
|
||||
|
||||
boolean canAppend(PdfJsonTextElement element, TextPosition position) {
|
||||
if (!styleKey.equals(buildStyleKey(element))) {
|
||||
return false;
|
||||
}
|
||||
List<Float> matrix = element.getTextMatrix();
|
||||
float[] matrix = element.getTextMatrix();
|
||||
float a = 1f;
|
||||
float b = 0f;
|
||||
float c = 0f;
|
||||
float d = 1f;
|
||||
if (matrix != null && matrix.size() >= 4) {
|
||||
a = matrix.get(0);
|
||||
b = matrix.get(1);
|
||||
c = matrix.get(2);
|
||||
d = matrix.get(3);
|
||||
if (matrix != null && matrix.length >= 4) {
|
||||
a = matrix[0];
|
||||
b = matrix[1];
|
||||
c = matrix[2];
|
||||
d = matrix[3];
|
||||
}
|
||||
if (Math.abs(a - orientationA) > ORIENTATION_TOLERANCE
|
||||
|| Math.abs(b - orientationB) > ORIENTATION_TOLERANCE
|
||||
@@ -5734,9 +5728,19 @@ public class PdfJsonConversionService {
|
||||
maxHeight = height;
|
||||
}
|
||||
endXCoord = position.getXDirAdj() + position.getWidthDirAdj();
|
||||
if (element.getCharCodes() != null) {
|
||||
charCodeBuffer.addAll(element.getCharCodes());
|
||||
appendCharCodes(element.getCharCodes());
|
||||
}
|
||||
|
||||
private void appendCharCodes(int[] codes) {
|
||||
if (codes == null) return;
|
||||
int needed = charCodeLen + codes.length;
|
||||
if (needed > charCodeBuf.length) {
|
||||
charCodeBuf =
|
||||
java.util.Arrays.copyOf(
|
||||
charCodeBuf, Math.max(needed, charCodeBuf.length * 2));
|
||||
}
|
||||
System.arraycopy(codes, 0, charCodeBuf, charCodeLen, codes.length);
|
||||
charCodeLen += codes.length;
|
||||
}
|
||||
|
||||
PdfJsonTextElement build() {
|
||||
@@ -5748,22 +5752,21 @@ public class PdfJsonConversionService {
|
||||
}
|
||||
result.setWidth(totalWidth);
|
||||
result.setHeight(maxHeight);
|
||||
if (baseMatrix != null && baseMatrix.size() == 6) {
|
||||
List<Float> matrix = new ArrayList<>(baseMatrix);
|
||||
matrix.set(0, orientationA);
|
||||
matrix.set(1, orientationB);
|
||||
matrix.set(2, orientationC);
|
||||
matrix.set(3, orientationD);
|
||||
matrix.set(4, startXCoord);
|
||||
matrix.set(5, startYCoord);
|
||||
if (baseMatrix != null && baseMatrix.length == 6) {
|
||||
float[] matrix =
|
||||
new float[] {
|
||||
orientationA, orientationB,
|
||||
orientationC, orientationD,
|
||||
startXCoord, startYCoord
|
||||
};
|
||||
result.setTextMatrix(matrix);
|
||||
result.setX(null);
|
||||
result.setY(null);
|
||||
}
|
||||
if (charCodeBuffer.isEmpty()) {
|
||||
if (charCodeLen == 0) {
|
||||
result.setCharCodes(null);
|
||||
} else {
|
||||
result.setCharCodes(new ArrayList<>(charCodeBuffer));
|
||||
result.setCharCodes(java.util.Arrays.copyOf(charCodeBuf, charCodeLen));
|
||||
}
|
||||
compactTextElement(result);
|
||||
return result;
|
||||
@@ -5784,29 +5787,25 @@ public class PdfJsonConversionService {
|
||||
Integer renderingMode,
|
||||
Float spaceWidth) {}
|
||||
|
||||
private List<Float> extractMatrix(TextPosition position) {
|
||||
float[] values = new float[6];
|
||||
values[0] = position.getTextMatrix().getValue(0, 0);
|
||||
values[1] = position.getTextMatrix().getValue(0, 1);
|
||||
values[2] = position.getTextMatrix().getValue(1, 0);
|
||||
values[3] = position.getTextMatrix().getValue(1, 1);
|
||||
values[4] = position.getTextMatrix().getValue(2, 0);
|
||||
values[5] = position.getTextMatrix().getValue(2, 1);
|
||||
List<Float> matrix = new ArrayList<>(6);
|
||||
for (float value : values) {
|
||||
matrix.add(value);
|
||||
}
|
||||
return matrix;
|
||||
private float[] extractMatrix(TextPosition position) {
|
||||
return new float[] {
|
||||
position.getTextMatrix().getValue(0, 0),
|
||||
position.getTextMatrix().getValue(0, 1),
|
||||
position.getTextMatrix().getValue(1, 0),
|
||||
position.getTextMatrix().getValue(1, 1),
|
||||
position.getTextMatrix().getValue(2, 0),
|
||||
position.getTextMatrix().getValue(2, 1)
|
||||
};
|
||||
}
|
||||
|
||||
private Float computeFontMatrixSize(List<Float> matrix) {
|
||||
if (matrix == null || matrix.size() < 4) {
|
||||
private Float computeFontMatrixSize(float[] matrix) {
|
||||
if (matrix == null || matrix.length < 4) {
|
||||
return null;
|
||||
}
|
||||
float a = matrix.get(0);
|
||||
float b = matrix.get(1);
|
||||
float c = matrix.get(2);
|
||||
float d = matrix.get(3);
|
||||
float a = matrix[0];
|
||||
float b = matrix[1];
|
||||
float c = matrix[2];
|
||||
float d = matrix[3];
|
||||
float scaleX = (float) Math.hypot(a, c);
|
||||
float scaleY = (float) Math.hypot(b, d);
|
||||
float scale = Math.max(scaleX, scaleY);
|
||||
@@ -5850,11 +5849,10 @@ public class PdfJsonConversionService {
|
||||
colorSpaceName,
|
||||
ex.getMessage());
|
||||
}
|
||||
List<Float> values = new ArrayList<>(effective.length);
|
||||
for (float component : effective) {
|
||||
values.add(component);
|
||||
}
|
||||
return PdfJsonTextColor.builder().colorSpace(colorSpaceName).components(values).build();
|
||||
return PdfJsonTextColor.builder()
|
||||
.colorSpace(colorSpaceName)
|
||||
.components(effective)
|
||||
.build();
|
||||
}
|
||||
|
||||
private String sanitizeForLog(String value) {
|
||||
@@ -6241,11 +6239,12 @@ public class PdfJsonConversionService {
|
||||
PDRectangle rect = annotation.getRectangle();
|
||||
if (rect != null) {
|
||||
ann.setRect(
|
||||
List.of(
|
||||
rect.getLowerLeftX(),
|
||||
rect.getLowerLeftY(),
|
||||
rect.getUpperRightX(),
|
||||
rect.getUpperRightY()));
|
||||
new float[] {
|
||||
rect.getLowerLeftX(),
|
||||
rect.getLowerLeftY(),
|
||||
rect.getUpperRightX(),
|
||||
rect.getUpperRightY()
|
||||
});
|
||||
}
|
||||
|
||||
COSName appearanceState = annotation.getAppearanceState();
|
||||
@@ -6254,12 +6253,7 @@ public class PdfJsonConversionService {
|
||||
}
|
||||
|
||||
if (annotation.getColor() != null) {
|
||||
float[] colorComponents = annotation.getColor().getComponents();
|
||||
List<Float> colorList = new ArrayList<>(colorComponents.length);
|
||||
for (float c : colorComponents) {
|
||||
colorList.add(c);
|
||||
}
|
||||
ann.setColor(colorList);
|
||||
ann.setColor(annotation.getColor().getComponents());
|
||||
}
|
||||
|
||||
COSDictionary annotDict = annotation.getCOSObject();
|
||||
|
||||
@@ -30,7 +30,7 @@ public class SharedSignatureService {
|
||||
|
||||
private static final Pattern FILENAME_VALIDATION_PATTERN = Pattern.compile("^[a-zA-Z0-9_.-]+$");
|
||||
private final String SIGNATURE_BASE_PATH;
|
||||
private final String ALL_USERS_FOLDER = "ALL_USERS";
|
||||
private static final String ALL_USERS_FOLDER = "ALL_USERS";
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
public SharedSignatureService(ObjectMapper objectMapper) {
|
||||
@@ -159,12 +159,12 @@ public class SharedSignatureService {
|
||||
String dataUrl = request.getDataUrl();
|
||||
if (dataUrl != null && dataUrl.startsWith("data:image/")) {
|
||||
// Extract base64 data
|
||||
String base64Data = dataUrl.substring(dataUrl.indexOf(",") + 1);
|
||||
String base64Data = dataUrl.substring(dataUrl.indexOf(',') + 1);
|
||||
byte[] imageBytes = Base64.getDecoder().decode(base64Data);
|
||||
|
||||
// Determine and validate file extension from data URL
|
||||
String mimeType = dataUrl.substring(dataUrl.indexOf(":") + 1, dataUrl.indexOf(";"));
|
||||
String rawExtension = mimeType.substring(mimeType.indexOf("/") + 1);
|
||||
String mimeType = dataUrl.substring(dataUrl.indexOf(':') + 1, dataUrl.indexOf(';'));
|
||||
String rawExtension = mimeType.substring(mimeType.indexOf('/') + 1);
|
||||
String extension = validateAndNormalizeExtension(rawExtension);
|
||||
|
||||
// Save image file only
|
||||
|
||||
+19
-19
@@ -134,16 +134,16 @@ public class PdfJsonImageService {
|
||||
cache.put(cacheKey, image);
|
||||
}
|
||||
|
||||
List<Float> transform = element.getTransform();
|
||||
if (transform != null && transform.size() == 6) {
|
||||
float[] transform = element.getTransform();
|
||||
if (transform != null && transform.length == 6) {
|
||||
Matrix matrix =
|
||||
new Matrix(
|
||||
safeFloat(transform.get(0), 1f),
|
||||
safeFloat(transform.get(1), 0f),
|
||||
safeFloat(transform.get(2), 0f),
|
||||
safeFloat(transform.get(3), 1f),
|
||||
safeFloat(transform.get(4), 0f),
|
||||
safeFloat(transform.get(5), 0f));
|
||||
safeFloat(transform[0], 1f),
|
||||
safeFloat(transform[1], 0f),
|
||||
safeFloat(transform[2], 0f),
|
||||
safeFloat(transform[3], 1f),
|
||||
safeFloat(transform[4], 0f),
|
||||
safeFloat(transform[5], 0f));
|
||||
contentStream.drawImage(image, matrix);
|
||||
return;
|
||||
}
|
||||
@@ -269,18 +269,18 @@ public class PdfJsonImageService {
|
||||
return 0f;
|
||||
}
|
||||
|
||||
private List<Float> toMatrixValues(Matrix matrix) {
|
||||
List<Float> values = new ArrayList<>(6);
|
||||
values.add(matrix.getValue(0, 0));
|
||||
values.add(matrix.getValue(0, 1));
|
||||
values.add(matrix.getValue(1, 0));
|
||||
values.add(matrix.getValue(1, 1));
|
||||
values.add(matrix.getValue(2, 0));
|
||||
values.add(matrix.getValue(2, 1));
|
||||
return values;
|
||||
private float[] toMatrixValues(Matrix matrix) {
|
||||
return new float[] {
|
||||
matrix.getValue(0, 0),
|
||||
matrix.getValue(0, 1),
|
||||
matrix.getValue(1, 0),
|
||||
matrix.getValue(1, 1),
|
||||
matrix.getValue(2, 0),
|
||||
matrix.getValue(2, 1)
|
||||
};
|
||||
}
|
||||
|
||||
private float safeFloat(Float value, float defaultValue) {
|
||||
private static float safeFloat(Float value, float defaultValue) {
|
||||
if (value == null || Float.isNaN(value) || Float.isInfinite(value)) {
|
||||
return defaultValue;
|
||||
}
|
||||
@@ -324,7 +324,7 @@ public class PdfJsonImageService {
|
||||
}
|
||||
Matrix ctm = getGraphicsState().getCurrentTransformationMatrix();
|
||||
Bounds bounds = computeBounds(ctm);
|
||||
List<Float> matrixValues = toMatrixValues(ctm);
|
||||
float[] matrixValues = toMatrixValues(ctm);
|
||||
|
||||
PdfJsonImageElement element =
|
||||
PdfJsonImageElement.builder()
|
||||
|
||||
+6
-1
@@ -110,7 +110,12 @@ public class Type3LibraryStrategy implements Type3ConversionStrategy {
|
||||
.webProgramFormat(toFormat(entry.getWebProgram()))
|
||||
.pdfProgram(toBase64(entry.getPdfProgram()))
|
||||
.pdfProgramFormat(toFormat(entry.getPdfProgram()))
|
||||
.glyphCoverage(entry.getGlyphCoverage())
|
||||
.glyphCoverage(
|
||||
entry.getGlyphCoverage() != null
|
||||
? entry.getGlyphCoverage().stream()
|
||||
.mapToInt(Integer::intValue)
|
||||
.toArray()
|
||||
: null)
|
||||
.message(message)
|
||||
.build();
|
||||
}
|
||||
|
||||
@@ -262,17 +262,30 @@ public class JobController {
|
||||
@Operation(summary = "Get file metadata")
|
||||
public ResponseEntity<?> getFileMetadata(@PathVariable("fileId") String fileId) {
|
||||
try {
|
||||
// Verify file exists
|
||||
if (!fileStorage.fileExists(fileId)) {
|
||||
String jobKey = taskManager.findJobKeyByFileId(fileId);
|
||||
if (jobKey == null) {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
|
||||
if (!validateJobAccess(jobKey)) {
|
||||
log.warn("Unauthorized attempt to access file metadata: {}", fileId);
|
||||
return ResponseEntity.status(403)
|
||||
.body(Map.of("message", "You are not authorized to access this file"));
|
||||
}
|
||||
|
||||
// Find the file metadata from any job that contains this file
|
||||
ResultFile resultFile = taskManager.findResultFileByFileId(fileId);
|
||||
|
||||
if (resultFile != null) {
|
||||
return ResponseEntity.ok(resultFile);
|
||||
} else {
|
||||
}
|
||||
|
||||
if (!isSecurityEnabled()) {
|
||||
// Backwards compatibility when ownership service is unavailable
|
||||
if (!fileStorage.fileExists(fileId)) {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
|
||||
// File exists but no metadata found, get basic info efficiently
|
||||
long fileSize = fileStorage.getFileSize(fileId);
|
||||
return ResponseEntity.ok(
|
||||
@@ -286,6 +299,8 @@ public class JobController {
|
||||
"fileSize",
|
||||
fileSize));
|
||||
}
|
||||
|
||||
return ResponseEntity.notFound().build();
|
||||
} catch (Exception e) {
|
||||
log.error("Error retrieving file metadata {}: {}", fileId, e.getMessage(), e);
|
||||
return ResponseEntity.internalServerError()
|
||||
@@ -303,11 +318,17 @@ public class JobController {
|
||||
@Operation(summary = "Download a file")
|
||||
public ResponseEntity<?> downloadFile(@PathVariable("fileId") String fileId) {
|
||||
try {
|
||||
// Verify file exists
|
||||
if (!fileStorage.fileExists(fileId)) {
|
||||
String jobKey = taskManager.findJobKeyByFileId(fileId);
|
||||
if (jobKey == null) {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
|
||||
if (!validateJobAccess(jobKey)) {
|
||||
log.warn("Unauthorized attempt to download file: {}", fileId);
|
||||
return ResponseEntity.status(403)
|
||||
.body(Map.of("message", "You are not authorized to access this file"));
|
||||
}
|
||||
|
||||
// Retrieve file content
|
||||
byte[] fileContent = fileStorage.retrieveBytes(fileId);
|
||||
|
||||
@@ -327,11 +348,14 @@ public class JobController {
|
||||
.body(fileContent);
|
||||
} catch (Exception e) {
|
||||
log.error("Error retrieving file {}: {}", fileId, e.getMessage(), e);
|
||||
return ResponseEntity.internalServerError()
|
||||
.body("Error retrieving file: " + e.getMessage());
|
||||
return ResponseEntity.internalServerError().body("Error retrieving file");
|
||||
}
|
||||
}
|
||||
|
||||
private boolean isSecurityEnabled() {
|
||||
return jobOwnershipService != null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create Content-Disposition header with UTF-8 filename support
|
||||
*
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
multipart.enabled=true
|
||||
|
||||
# Jackson 3 defaults FAIL_ON_NULL_FOR_PRIMITIVES to true (was false in Jackson 2).
|
||||
# Restore Jackson 2 behaviour so absent/null JSON fields map to Java primitive defaults.
|
||||
spring.jackson.deserialization.fail-on-null-for-primitives=false
|
||||
|
||||
logging.level.org.springframework=WARN
|
||||
logging.level.org.springframework.security=WARN
|
||||
logging.level.org.hibernate=WARN
|
||||
@@ -25,14 +30,16 @@ server.compression.enabled=true
|
||||
server.compression.min-response-size=1024
|
||||
server.compression.mime-types=application/json,application/xml,text/html,text/plain,text/css,application/javascript
|
||||
|
||||
server.error.path=/error
|
||||
server.error.whitelabel.enabled=false
|
||||
server.error.include-stacktrace=always
|
||||
server.error.include-exception=true
|
||||
server.error.include-message=always
|
||||
spring.web.error.path=/error
|
||||
spring.web.error.whitelabel.enabled=false
|
||||
spring.web.error.include-stacktrace=always
|
||||
spring.web.error.include-exception=true
|
||||
spring.web.error.include-message=always
|
||||
|
||||
# Enable RFC 7807 Problem Details for HTTP APIs
|
||||
spring.mvc.problemdetails.enabled=true
|
||||
# Disable Spring's built-in ProblemDetailsExceptionHandler (@Order(0)) so that
|
||||
# GlobalExceptionHandler runs first and provides detailed, logged error responses.
|
||||
# GlobalExceptionHandler already produces RFC 7807 ProblemDetail objects.
|
||||
spring.mvc.problemdetails.enabled=false
|
||||
|
||||
#logging.level.org.springframework.web=DEBUG
|
||||
#logging.level.org.springframework=DEBUG
|
||||
|
||||
@@ -173,7 +173,7 @@ system:
|
||||
corsAllowedOrigins: [] # List of allowed origins for CORS (e.g. ['http://localhost:5173', 'https://app.example.com']). Leave empty to disable CORS. For local development with frontend on port 5173, add 'http://localhost:5173'
|
||||
backendUrl: "" # Backend base URL for SAML/OAuth/API callbacks (e.g. 'http://localhost:8080' for dev, 'https://api.example.com' for production). REQUIRED for SSO authentication to work correctly. This is where your IdP will send SAML responses and OAuth callbacks. Leave empty to default to 'http://localhost:8080' in development.
|
||||
frontendUrl: "" # Frontend URL for invite email links (e.g. 'https://app.example.com'). Optional - if not set, will use backendUrl. This is the URL users click in invite emails.
|
||||
enableMobileScanner: false # Enable mobile phone QR code upload feature. Requires frontendUrl to be configured.
|
||||
enableMobileScanner: true # Enable mobile phone QR code upload feature. Requires frontendUrl to be configured.
|
||||
mobileScannerSettings:
|
||||
convertToPdf: true # Automatically convert uploaded images to PDF format. If false, images are kept as-is.
|
||||
imageResolution: full # Image resolution for mobile uploads: 'full' (original size) or 'reduced' (max 1200px on longest side). Only applies when convertToPdf is true.
|
||||
|
||||
+12
-8
@@ -25,6 +25,7 @@ import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.mock.web.MockMultipartFile;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import stirling.software.SPDF.model.api.general.SplitPdfBySizeOrCountRequest;
|
||||
import stirling.software.common.service.CustomPDFDocumentFactory;
|
||||
@@ -69,16 +70,15 @@ class SplitPdfBySizeControllerTest {
|
||||
request.setSplitType(1); // Page count
|
||||
request.setSplitValue("2");
|
||||
|
||||
when(pdfDocumentFactory.load(any(byte[].class)))
|
||||
.thenAnswer(inv -> Loader.loadPDF((byte[]) inv.getArgument(0)));
|
||||
when(pdfDocumentFactory.load(any(MultipartFile.class)))
|
||||
.thenAnswer(inv -> Loader.loadPDF(((MultipartFile) inv.getArgument(0)).getBytes()));
|
||||
|
||||
when(pdfDocumentFactory.createNewDocumentBasedOnOldDocument(any(PDDocument.class)))
|
||||
.thenAnswer(inv -> new PDDocument());
|
||||
|
||||
ResponseEntity<byte[]> response = controller.autoSplitPdf(request);
|
||||
ResponseEntity<?> response = controller.autoSplitPdf(request);
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
assertThat(response.getBody()).isNotEmpty();
|
||||
assertThat(response.getHeaders().getContentType())
|
||||
.isEqualTo(MediaType.APPLICATION_OCTET_STREAM);
|
||||
}
|
||||
@@ -104,15 +104,19 @@ class SplitPdfBySizeControllerTest {
|
||||
request.setSplitType(2); // Document count
|
||||
request.setSplitValue("3"); // Split into 3 docs (2 pages each)
|
||||
|
||||
when(pdfDocumentFactory.load(any(byte[].class)))
|
||||
.thenAnswer(inv -> Loader.loadPDF((byte[]) inv.getArgument(0)));
|
||||
when(pdfDocumentFactory.load(any(org.springframework.web.multipart.MultipartFile.class)))
|
||||
.thenAnswer(
|
||||
inv ->
|
||||
Loader.loadPDF(
|
||||
((org.springframework.web.multipart.MultipartFile)
|
||||
inv.getArgument(0))
|
||||
.getBytes()));
|
||||
|
||||
when(pdfDocumentFactory.createNewDocumentBasedOnOldDocument(any(PDDocument.class)))
|
||||
.thenAnswer(inv -> new PDDocument());
|
||||
|
||||
ResponseEntity<byte[]> response = controller.autoSplitPdf(request);
|
||||
ResponseEntity<?> response = controller.autoSplitPdf(request);
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
assertThat(response.getBody()).isNotEmpty();
|
||||
}
|
||||
}
|
||||
|
||||
+8
-2
@@ -113,10 +113,12 @@ class ConvertPDFToEpubControllerTest {
|
||||
ResponseEntity<byte[]> response = controller.convertPdfToEpub(request);
|
||||
|
||||
List<String> command = commandCaptor.getValue();
|
||||
assertEquals(11, command.size());
|
||||
assertEquals(13, command.size());
|
||||
assertEquals("ebook-convert", command.get(0));
|
||||
assertEquals(expectedInput.toString(), command.get(1));
|
||||
assertEquals(expectedOutput.toString(), command.get(2));
|
||||
assertTrue(command.contains("--pdf-engine"));
|
||||
assertTrue(command.contains("pdftohtml"));
|
||||
assertTrue(command.contains("--enable-heuristics"));
|
||||
assertTrue(command.contains("--insert-blank-line"));
|
||||
assertTrue(command.contains("--filter-css"));
|
||||
@@ -206,11 +208,13 @@ class ConvertPDFToEpubControllerTest {
|
||||
assertTrue(command.stream().noneMatch(arg -> "--chapter".equals(arg)));
|
||||
assertTrue(command.contains("--output-profile"));
|
||||
assertTrue(command.contains(TargetDevice.KINDLE_EINK_TEXT.getCalibreProfile()));
|
||||
assertTrue(command.contains("--pdf-engine"));
|
||||
assertTrue(command.contains("pdftohtml"));
|
||||
assertTrue(command.contains("--filter-css"));
|
||||
assertTrue(
|
||||
command.contains(
|
||||
"font-family,color,background-color,margin-left,margin-right"));
|
||||
assertTrue(command.size() >= 9);
|
||||
assertTrue(command.size() >= 11);
|
||||
|
||||
assertEquals(EPUB_MEDIA_TYPE, response.getHeaders().getContentType());
|
||||
assertEquals(
|
||||
@@ -289,6 +293,8 @@ class ConvertPDFToEpubControllerTest {
|
||||
assertEquals("ebook-convert", command.get(0));
|
||||
assertEquals(expectedInput.toString(), command.get(1));
|
||||
assertEquals(expectedOutput.toString(), command.get(2));
|
||||
assertTrue(command.contains("--pdf-engine"));
|
||||
assertTrue(command.contains("pdftohtml"));
|
||||
assertTrue(command.contains("--enable-heuristics"));
|
||||
assertTrue(command.contains("--insert-blank-line"));
|
||||
assertTrue(command.contains("--filter-css"));
|
||||
|
||||
+14
-4
@@ -13,10 +13,12 @@ import org.springframework.mock.web.MockMultipartFile;
|
||||
|
||||
import stirling.software.common.service.CustomPDFDocumentFactory;
|
||||
import stirling.software.common.util.PdfToCbzUtils;
|
||||
import stirling.software.common.util.TempFileManager;
|
||||
|
||||
public class PdfToCbzUtilsTest {
|
||||
|
||||
@Mock private CustomPDFDocumentFactory pdfDocumentFactory;
|
||||
@Mock private TempFileManager tempFileManager;
|
||||
|
||||
@BeforeEach
|
||||
public void setUp() {
|
||||
@@ -42,7 +44,9 @@ public class PdfToCbzUtilsTest {
|
||||
IllegalArgumentException exception =
|
||||
Assertions.assertThrows(
|
||||
IllegalArgumentException.class,
|
||||
() -> PdfToCbzUtils.convertPdfToCbz(null, 300, pdfDocumentFactory));
|
||||
() ->
|
||||
PdfToCbzUtils.convertPdfToCbz(
|
||||
null, 300, pdfDocumentFactory, tempFileManager));
|
||||
Assertions.assertEquals("File cannot be null or empty", exception.getMessage());
|
||||
}
|
||||
|
||||
@@ -54,7 +58,9 @@ public class PdfToCbzUtilsTest {
|
||||
IllegalArgumentException exception =
|
||||
Assertions.assertThrows(
|
||||
IllegalArgumentException.class,
|
||||
() -> PdfToCbzUtils.convertPdfToCbz(emptyFile, 300, pdfDocumentFactory));
|
||||
() ->
|
||||
PdfToCbzUtils.convertPdfToCbz(
|
||||
emptyFile, 300, pdfDocumentFactory, tempFileManager));
|
||||
Assertions.assertEquals("File cannot be null or empty", exception.getMessage());
|
||||
}
|
||||
|
||||
@@ -66,7 +72,9 @@ public class PdfToCbzUtilsTest {
|
||||
IllegalArgumentException exception =
|
||||
Assertions.assertThrows(
|
||||
IllegalArgumentException.class,
|
||||
() -> PdfToCbzUtils.convertPdfToCbz(nonPdfFile, 300, pdfDocumentFactory));
|
||||
() ->
|
||||
PdfToCbzUtils.convertPdfToCbz(
|
||||
nonPdfFile, 300, pdfDocumentFactory, tempFileManager));
|
||||
Assertions.assertEquals("File must be in PDF format", exception.getMessage());
|
||||
}
|
||||
|
||||
@@ -84,7 +92,9 @@ public class PdfToCbzUtilsTest {
|
||||
// structure
|
||||
Assertions.assertThrows(
|
||||
Exception.class,
|
||||
() -> PdfToCbzUtils.convertPdfToCbz(pdfFile, 300, pdfDocumentFactory));
|
||||
() ->
|
||||
PdfToCbzUtils.convertPdfToCbz(
|
||||
pdfFile, 300, pdfDocumentFactory, tempFileManager));
|
||||
|
||||
// Verify that load was called
|
||||
Mockito.verify(pdfDocumentFactory).load(pdfFile);
|
||||
|
||||
+1
-1
@@ -27,7 +27,7 @@ import stirling.software.common.service.UserServiceInterface;
|
||||
class PdfMetadataServiceBasicTest {
|
||||
|
||||
private PdfMetadataService pdfMetadataService;
|
||||
private final String STIRLING_PDF_LABEL = "Stirling PDF";
|
||||
private static final String STIRLING_PDF_LABEL = "Stirling PDF";
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
|
||||
@@ -33,7 +33,7 @@ class PdfMetadataServiceTest {
|
||||
@Mock private ApplicationProperties applicationProperties;
|
||||
@Mock private UserServiceInterface userService;
|
||||
private PdfMetadataService pdfMetadataService;
|
||||
private final String STIRLING_PDF_LABEL = "Stirling PDF";
|
||||
private static final String STIRLING_PDF_LABEL = "Stirling PDF";
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
|
||||
@@ -28,8 +28,8 @@ class SignatureServiceTest {
|
||||
private SharedSignatureService signatureService;
|
||||
private Path personalSignatureFolder;
|
||||
private Path sharedSignatureFolder;
|
||||
private final String ALL_USERS_FOLDER = "ALL_USERS";
|
||||
private final String TEST_USER = "testUser";
|
||||
private static final String ALL_USERS_FOLDER = "ALL_USERS";
|
||||
private static final String TEST_USER = "testUser";
|
||||
|
||||
@BeforeEach
|
||||
void setUp() throws IOException {
|
||||
|
||||
@@ -15,11 +15,13 @@ import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.mock.web.MockHttpSession;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
|
||||
import stirling.software.common.model.job.JobResult;
|
||||
import stirling.software.common.service.FileStorage;
|
||||
import stirling.software.common.service.JobOwnershipService;
|
||||
import stirling.software.common.service.JobQueue;
|
||||
import stirling.software.common.service.TaskManager;
|
||||
|
||||
@@ -33,6 +35,8 @@ class JobControllerTest {
|
||||
|
||||
@Mock private HttpServletRequest request;
|
||||
|
||||
@Mock private JobOwnershipService jobOwnershipService;
|
||||
|
||||
private MockHttpSession session;
|
||||
|
||||
@InjectMocks private JobController controller;
|
||||
@@ -404,4 +408,32 @@ class JobControllerTest {
|
||||
|
||||
verify(taskManager).setError(jobId, "Job was cancelled by user");
|
||||
}
|
||||
|
||||
@Test
|
||||
void testDownloadFile_ForbiddenWhenFileOwnedByAnotherUser() throws Exception {
|
||||
String fileId = "file-id";
|
||||
|
||||
ReflectionTestUtils.setField(controller, "jobOwnershipService", jobOwnershipService);
|
||||
when(taskManager.findJobKeyByFileId(fileId)).thenReturn("other-user:job-id");
|
||||
when(jobOwnershipService.validateJobAccess("other-user:job-id")).thenReturn(false);
|
||||
|
||||
ResponseEntity<?> response = controller.downloadFile(fileId);
|
||||
|
||||
assertEquals(HttpStatus.FORBIDDEN, response.getStatusCode());
|
||||
verify(fileStorage, never()).retrieveBytes(eq(fileId));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testGetFileMetadata_ForbiddenWhenFileOwnedByAnotherUser() throws Exception {
|
||||
String fileId = "file-id";
|
||||
|
||||
ReflectionTestUtils.setField(controller, "jobOwnershipService", jobOwnershipService);
|
||||
when(taskManager.findJobKeyByFileId(fileId)).thenReturn("other-user:job-id");
|
||||
when(jobOwnershipService.validateJobAccess("other-user:job-id")).thenReturn(false);
|
||||
|
||||
ResponseEntity<?> response = controller.getFileMetadata(fileId);
|
||||
|
||||
assertEquals(HttpStatus.FORBIDDEN, response.getStatusCode());
|
||||
verify(fileStorage, never()).getFileSize(eq(fileId));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
repositories {
|
||||
maven { url = "https://build.shibboleth.net/maven/releases" }
|
||||
maven { url = "https://repository.jboss.org/" }
|
||||
}
|
||||
|
||||
ext {
|
||||
@@ -13,10 +14,10 @@ bootRun {
|
||||
spotless {
|
||||
java {
|
||||
target 'src/**/java/**/*.java'
|
||||
targetExclude 'src/main/java/org/apache/**'
|
||||
googleJavaFormat(googleJavaFormatVersion).aosp().reorderImports(false)
|
||||
|
||||
importOrder("java", "javax", "org", "com", "net", "io", "jakarta", "lombok", "me", "stirling")
|
||||
toggleOffOn()
|
||||
trimTrailingWhitespace()
|
||||
leadingTabsToSpaces()
|
||||
endWithNewline()
|
||||
@@ -36,6 +37,7 @@ spotless {
|
||||
}
|
||||
dependencies {
|
||||
implementation project(':common')
|
||||
api 'com.google.guava:guava:33.4.8-jre'
|
||||
|
||||
api 'org.springframework:spring-jdbc'
|
||||
api 'org.springframework:spring-webmvc'
|
||||
@@ -63,12 +65,9 @@ dependencies {
|
||||
runtimeOnly "io.jsonwebtoken:jjwt-jackson:$jwtVersion"
|
||||
runtimeOnly 'com.h2database:h2:2.3.232' // Don't upgrade h2database
|
||||
runtimeOnly 'org.postgresql:postgresql:42.7.10'
|
||||
constraints {
|
||||
implementation "org.opensaml:opensaml-core:$openSamlVersion"
|
||||
implementation "org.opensaml:opensaml-saml-api:$openSamlVersion"
|
||||
implementation "org.opensaml:opensaml-saml-impl:$openSamlVersion"
|
||||
implementation('com.coveo:saml-client:5.0.0') {
|
||||
exclude group: 'org.opensaml', module: 'opensaml-core'
|
||||
}
|
||||
implementation 'com.coveo:saml-client:5.0.0'
|
||||
}
|
||||
|
||||
tasks.register('prepareKotlinBuildScriptModel') {}
|
||||
|
||||
+1
-1
@@ -49,7 +49,7 @@ public enum AuditEventType {
|
||||
// If the exact enum name doesn't match, try finding a similar one
|
||||
for (AuditEventType eventType : values()) {
|
||||
if (eventType.name().equalsIgnoreCase(type)
|
||||
|| eventType.getDescription().equalsIgnoreCase(type)) {
|
||||
|| eventType.description.equalsIgnoreCase(type)) {
|
||||
return eventType;
|
||||
}
|
||||
}
|
||||
|
||||
+35
-2
@@ -43,6 +43,7 @@ import stirling.software.proprietary.security.database.repository.UserRepository
|
||||
import stirling.software.proprietary.security.model.Authority;
|
||||
import stirling.software.proprietary.security.model.SessionEntity;
|
||||
import stirling.software.proprietary.security.model.User;
|
||||
import stirling.software.proprietary.security.model.dto.AdminUserSummary;
|
||||
import stirling.software.proprietary.security.repository.TeamRepository;
|
||||
import stirling.software.proprietary.security.saml2.CustomSaml2AuthenticatedPrincipal;
|
||||
import stirling.software.proprietary.security.service.DatabaseService;
|
||||
@@ -357,8 +358,12 @@ public class ProprietaryUIDataController {
|
||||
int licenseMaxUsers = licenseSettingsService.getSettings().getLicenseMaxUsers();
|
||||
boolean premiumEnabled = applicationProperties.getPremium().isEnabled();
|
||||
|
||||
// Convert User entities to AdminUserSummary DTOs to exclude sensitive fields
|
||||
List<AdminUserSummary> userSummaries =
|
||||
sortedUsers.stream().map(this::convertUserToSummary).toList();
|
||||
|
||||
AdminSettingsData data = new AdminSettingsData();
|
||||
data.setUsers(sortedUsers);
|
||||
data.setUsers(userSummaries);
|
||||
data.setCurrentUsername(authentication.getName());
|
||||
data.setRoleDetails(roleDetails);
|
||||
data.setUserSessions(userSessions);
|
||||
@@ -518,6 +523,34 @@ public class ProprietaryUIDataController {
|
||||
return ResponseEntity.ok(data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert User entity to AdminUserSummary DTO, excluding sensitive fields like password and
|
||||
* apiKey.
|
||||
*/
|
||||
private AdminUserSummary convertUserToSummary(User user) {
|
||||
AdminUserSummary summary = new AdminUserSummary();
|
||||
summary.setId(user.getId());
|
||||
summary.setUsername(user.getUsername());
|
||||
summary.setEmail(user.getUsername()); // Use username as email for consistency
|
||||
summary.setRoleName(user.getRoleName());
|
||||
summary.setRolesAsString(user.getRolesAsString());
|
||||
summary.setEnabled(user.isEnabled());
|
||||
summary.setIsFirstLogin(user.isFirstLogin());
|
||||
summary.setAuthenticationType(user.getAuthenticationType());
|
||||
summary.setCreatedAt(user.getCreatedAt());
|
||||
summary.setUpdatedAt(user.getUpdatedAt());
|
||||
|
||||
// Map team if present
|
||||
if (user.getTeam() != null) {
|
||||
AdminUserSummary.TeamSummary teamSummary = new AdminUserSummary.TeamSummary();
|
||||
teamSummary.setId(user.getTeam().getId());
|
||||
teamSummary.setName(user.getTeam().getName());
|
||||
summary.setTeam(teamSummary);
|
||||
}
|
||||
|
||||
return summary;
|
||||
}
|
||||
|
||||
// Data classes
|
||||
@Data
|
||||
public static class AuditDashboardData {
|
||||
@@ -544,7 +577,7 @@ public class ProprietaryUIDataController {
|
||||
|
||||
@Data
|
||||
public static class AdminSettingsData {
|
||||
private List<User> users;
|
||||
private List<AdminUserSummary> users;
|
||||
private String currentUsername;
|
||||
private Map<String, String> roleDetails;
|
||||
private Map<String, Boolean> userSessions;
|
||||
|
||||
+27
-2
@@ -196,8 +196,9 @@ public class AdminSettingsController {
|
||||
log.info("Admin updating setting: {} = {}", key, value);
|
||||
GeneralUtils.saveKeyToSettings(key, value);
|
||||
|
||||
// Track this as a pending change
|
||||
pendingChanges.put(key, value);
|
||||
// Track this as a pending change (convert null to empty string for
|
||||
// ConcurrentHashMap)
|
||||
pendingChanges.put(key, value != null ? value : "");
|
||||
|
||||
updatedCount++;
|
||||
}
|
||||
@@ -268,6 +269,9 @@ public class AdminSettingsController {
|
||||
}
|
||||
}
|
||||
|
||||
// Mask sensitive fields before returning to frontend
|
||||
sectionMap = maskSensitiveFields(sectionMap);
|
||||
|
||||
log.debug(
|
||||
"Admin requested settings section: {} (includePending={})",
|
||||
sectionName,
|
||||
@@ -404,6 +408,13 @@ public class AdminSettingsController {
|
||||
return ResponseEntity.badRequest()
|
||||
.body("Setting key not found: " + HtmlUtils.htmlEscape(key));
|
||||
}
|
||||
|
||||
// Mask sensitive values before returning
|
||||
String keyName = key.contains(".") ? key.substring(key.lastIndexOf(".") + 1) : key;
|
||||
if (isSensitiveFieldWithPath(keyName, key)) {
|
||||
value = createMaskedValue(value);
|
||||
}
|
||||
|
||||
log.debug("Admin requested setting: {}", key);
|
||||
return ResponseEntity.ok(new SettingValueResponse(key, value));
|
||||
} catch (IllegalArgumentException e) {
|
||||
@@ -441,6 +452,20 @@ public class AdminSettingsController {
|
||||
}
|
||||
|
||||
Object value = request.getValue();
|
||||
|
||||
// Prevent saving masked values for sensitive fields to avoid data loss
|
||||
if ("********".equals(value)) {
|
||||
String keyName = key.contains(".") ? key.substring(key.lastIndexOf(".") + 1) : key;
|
||||
if (isSensitiveFieldWithPath(keyName, key)) {
|
||||
log.warn(
|
||||
"Admin attempted to save masked value for sensitive field: {}. This operation is blocked to prevent data loss.",
|
||||
key);
|
||||
return ResponseEntity.badRequest()
|
||||
.body(
|
||||
"Cannot save masked values for sensitive settings. Please provide the actual value.");
|
||||
}
|
||||
}
|
||||
|
||||
log.info("Admin updating single setting: {} = {}", key, value);
|
||||
GeneralUtils.saveKeyToSettings(key, value);
|
||||
|
||||
|
||||
+1
-1
@@ -123,7 +123,7 @@ public class AuthController {
|
||||
log.warn("Invalid password for user: {} from IP: {}", username, ip);
|
||||
loginAttemptService.loginFailed(username);
|
||||
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
|
||||
.body(Map.of("error", "Invalid credentials"));
|
||||
.body(Map.of("error", "Invalid username or password"));
|
||||
}
|
||||
|
||||
if (!user.isEnabled()) {
|
||||
|
||||
+14
@@ -191,6 +191,20 @@ public class DatabaseController {
|
||||
if (fileName == null || fileName.isEmpty()) {
|
||||
throw new IllegalArgumentException("File must not be null or empty");
|
||||
}
|
||||
|
||||
// Validate that file is a legitimate backup file
|
||||
// Only allow files matching the backup naming pattern
|
||||
if (!fileName.startsWith("backup_") || !fileName.endsWith(".sql")) {
|
||||
log.warn("Attempted download of non-backup file: {}", fileName);
|
||||
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
|
||||
.body(
|
||||
java.util.Map.of(
|
||||
"error",
|
||||
"invalidFileName",
|
||||
"message",
|
||||
"Only backup files are allowed"));
|
||||
}
|
||||
|
||||
try {
|
||||
Path filePath = databaseService.getBackupFilePath(fileName);
|
||||
InputStreamResource resource = new InputStreamResource(Files.newInputStream(filePath));
|
||||
|
||||
+15
-19
@@ -358,27 +358,23 @@ public class InviteLinkController {
|
||||
Optional<InviteToken> inviteOpt = inviteTokenRepository.findByToken(token);
|
||||
|
||||
if (inviteOpt.isEmpty()) {
|
||||
return ResponseEntity.status(HttpStatus.NOT_FOUND)
|
||||
.body(Map.of("error", "Invalid invite link"));
|
||||
return invalidInviteResponse();
|
||||
}
|
||||
|
||||
InviteToken invite = inviteOpt.get();
|
||||
|
||||
if (invite.isUsed()) {
|
||||
return ResponseEntity.status(HttpStatus.GONE)
|
||||
.body(Map.of("error", "This invite link has already been used"));
|
||||
return invalidInviteResponse();
|
||||
}
|
||||
|
||||
if (invite.isExpired()) {
|
||||
return ResponseEntity.status(HttpStatus.GONE)
|
||||
.body(Map.of("error", "This invite link has expired"));
|
||||
return invalidInviteResponse();
|
||||
}
|
||||
|
||||
// Check if user already exists (only if email is pre-set)
|
||||
if (invite.getEmail() != null
|
||||
&& userService.usernameExistsIgnoreCase(invite.getEmail())) {
|
||||
return ResponseEntity.status(HttpStatus.CONFLICT)
|
||||
.body(Map.of("error", "User already exists"));
|
||||
return invalidInviteResponse();
|
||||
}
|
||||
|
||||
Map<String, Object> response = new HashMap<>();
|
||||
@@ -391,8 +387,7 @@ public class InviteLinkController {
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to validate invite token: {}", e.getMessage(), e);
|
||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
.body(Map.of("error", "Failed to validate invite link"));
|
||||
return invalidInviteResponse();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -419,20 +414,17 @@ public class InviteLinkController {
|
||||
Optional<InviteToken> inviteOpt = inviteTokenRepository.findByToken(token);
|
||||
|
||||
if (inviteOpt.isEmpty()) {
|
||||
return ResponseEntity.status(HttpStatus.NOT_FOUND)
|
||||
.body(Map.of("error", "Invalid invite link"));
|
||||
return invalidInviteResponse();
|
||||
}
|
||||
|
||||
InviteToken invite = inviteOpt.get();
|
||||
|
||||
if (invite.isUsed()) {
|
||||
return ResponseEntity.status(HttpStatus.GONE)
|
||||
.body(Map.of("error", "This invite link has already been used"));
|
||||
return invalidInviteResponse();
|
||||
}
|
||||
|
||||
if (invite.isExpired()) {
|
||||
return ResponseEntity.status(HttpStatus.GONE)
|
||||
.body(Map.of("error", "This invite link has expired"));
|
||||
return invalidInviteResponse();
|
||||
}
|
||||
|
||||
// Determine the email to use
|
||||
@@ -455,8 +447,7 @@ public class InviteLinkController {
|
||||
|
||||
// Check if user already exists
|
||||
if (userService.usernameExistsIgnoreCase(effectiveEmail)) {
|
||||
return ResponseEntity.status(HttpStatus.CONFLICT)
|
||||
.body(Map.of("error", "User already exists"));
|
||||
return invalidInviteResponse();
|
||||
}
|
||||
|
||||
// Create the user account
|
||||
@@ -484,7 +475,12 @@ public class InviteLinkController {
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to accept invite: {}", e.getMessage(), e);
|
||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
.body(Map.of("error", "Failed to create account: " + e.getMessage()));
|
||||
.body(Map.of("error", "Failed to create account"));
|
||||
}
|
||||
}
|
||||
|
||||
private ResponseEntity<Map<String, String>> invalidInviteResponse() {
|
||||
return ResponseEntity.status(HttpStatus.NOT_FOUND)
|
||||
.body(Map.of("error", "Invalid invite link"));
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -942,7 +942,7 @@ public class UserController {
|
||||
public ResponseEntity<?> completeInitialSetup() {
|
||||
try {
|
||||
String username = userService.getCurrentUsername();
|
||||
if (username == null) {
|
||||
if (username == null || "anonymousUser".equalsIgnoreCase(username)) {
|
||||
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
|
||||
.body("User not authenticated");
|
||||
}
|
||||
|
||||
@@ -46,9 +46,11 @@ public class User implements UserDetails, Serializable {
|
||||
private String username;
|
||||
|
||||
@Column(name = "password")
|
||||
@JsonIgnore
|
||||
private String password;
|
||||
|
||||
@Column(name = "apiKey")
|
||||
@JsonIgnore
|
||||
private String apiKey;
|
||||
|
||||
@Column(name = "enabled")
|
||||
|
||||
+72
@@ -0,0 +1,72 @@
|
||||
package stirling.software.proprietary.security.model.dto;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.Setter;
|
||||
|
||||
/**
|
||||
* Data Transfer Object for admin user listings. Contains only the fields needed by the admin UI for
|
||||
* displaying user information. Excludes sensitive fields like password and apiKey.
|
||||
*/
|
||||
@Getter
|
||||
@Setter
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||
@Schema(description = "Admin user summary for listing in admin UI - excludes sensitive fields")
|
||||
public class AdminUserSummary {
|
||||
|
||||
@Schema(description = "User ID")
|
||||
private Long id;
|
||||
|
||||
@Schema(description = "Username/login identifier")
|
||||
private String username;
|
||||
|
||||
@Schema(description = "User email address")
|
||||
private String email;
|
||||
|
||||
@Schema(description = "Role name (translation key like 'adminUserSettings.admin')")
|
||||
private String roleName;
|
||||
|
||||
@Schema(description = "Role identifier (e.g., 'ROLE_ADMIN')")
|
||||
private String rolesAsString;
|
||||
|
||||
@Schema(description = "Whether user account is enabled")
|
||||
private boolean enabled;
|
||||
|
||||
@Schema(description = "Whether this is the user's first login")
|
||||
private Boolean isFirstLogin;
|
||||
|
||||
@Schema(description = "Authentication type (WEB, OAUTH2, SAML2)")
|
||||
private String authenticationType;
|
||||
|
||||
@Schema(description = "Team membership (if any)")
|
||||
private TeamSummary team;
|
||||
|
||||
@Schema(description = "User account creation timestamp")
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
@Schema(description = "User account last update timestamp")
|
||||
private LocalDateTime updatedAt;
|
||||
|
||||
/** Minimal Team DTO for admin user listings */
|
||||
@Getter
|
||||
@Setter
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Schema(description = "Team summary (id and name only)")
|
||||
public static class TeamSummary {
|
||||
@Schema(description = "Team ID")
|
||||
private Long id;
|
||||
|
||||
@Schema(description = "Team name")
|
||||
private String name;
|
||||
}
|
||||
}
|
||||
+108
@@ -22,6 +22,7 @@ import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
import java.util.regex.Pattern;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import javax.sql.DataSource;
|
||||
@@ -47,6 +48,48 @@ public class DatabaseService implements DatabaseServiceInterface {
|
||||
public static final String SQL_SUFFIX = ".sql";
|
||||
private final Path BACKUP_DIR;
|
||||
|
||||
// Whitelist of allowed SQL patterns for H2 database backups (generated by SCRIPT command)
|
||||
// Only standard DDL/DML operations are permitted - anything else is rejected by default
|
||||
private static final java.util.List<Pattern> ALLOWED_PATTERNS =
|
||||
java.util.List.of(
|
||||
Pattern.compile("(?i)\\bCREATE\\b"),
|
||||
Pattern.compile("(?i)\\bSET\\s+SCHEMA\\b"),
|
||||
Pattern.compile("(?i)\\bALTER\\s+SEQUENCE\\b"),
|
||||
Pattern.compile("(?i)\\bDROP\\s+TABLE\\b"),
|
||||
Pattern.compile("(?i)\\bALTER\\s+TABLE\\b"),
|
||||
Pattern.compile("(?i)\\bADD\\s+COLUMN\\b"),
|
||||
Pattern.compile("(?i)\\bINSERT\\s+INTO\\b"),
|
||||
Pattern.compile("(?i)\\bVALUES\\b"),
|
||||
Pattern.compile("(?i)\\bADD\\s+CONSTRAINT\\b"),
|
||||
Pattern.compile("(?i)\\bPRIMARY\\s+KEY\\b"),
|
||||
Pattern.compile("(?i)\\bFOREIGN\\s+KEY\\b"),
|
||||
Pattern.compile("(?i)\\bCONSTRAINT\\b"),
|
||||
Pattern.compile("(?i)\\bCHECK\\b"),
|
||||
Pattern.compile(
|
||||
"(?i)\\bGENERATED\\s+(BY\\s+DEFAULT|ALWAYS)\\s+AS\\s+IDENTITY\\b"),
|
||||
Pattern.compile("(?i)\\bDEFAULT\\b"),
|
||||
Pattern.compile(
|
||||
"(?i)\\bSET\\s+(REFERENTIAL_INTEGRITY|MODE|DATABASE|IGNORECASE|AUTOINCREMENT|DB_CLOSE_DELAY)\\b"),
|
||||
Pattern.compile("(?i)\\bIF\\s+EXISTS\\b"),
|
||||
Pattern.compile("(?i)\\bIF\\s+NOT\\s+EXISTS\\b"),
|
||||
Pattern.compile("(?i)\\bNULLS\\s+(FIRST|LAST)\\b"),
|
||||
Pattern.compile("(?i)\\bREFERENCES\\b"),
|
||||
Pattern.compile("(?i)\\bNOCHECK\\b"),
|
||||
Pattern.compile("(?i)\\bRESTART\\s+WITH\\b"),
|
||||
Pattern.compile("(?i)\\bCASCADE\\b"),
|
||||
Pattern.compile("(?i)\\bON\\b"),
|
||||
Pattern.compile("(?i)\\bSTART\\s+WITH\\b"),
|
||||
Pattern.compile("(?i)\\bAS\\b"),
|
||||
Pattern.compile("(?i)\\bUNIQUE\\b"),
|
||||
Pattern.compile("(?i)\\bDISTINCT\\b"),
|
||||
Pattern.compile("(?i)\\bWITH\\s+TIME\\s+ZONE\\b"),
|
||||
Pattern.compile("(?i)\\bTIMESTAMP\\b"),
|
||||
Pattern.compile("(?i)\\bVARYING\\b"),
|
||||
Pattern.compile("(?i)\\bNOT\\s+NULL\\b"),
|
||||
Pattern.compile("(?i)\\bTRUE\\b"),
|
||||
Pattern.compile("(?i)\\bFALSE\\b"),
|
||||
Pattern.compile("(?i)\\bNULL\\b"));
|
||||
|
||||
private final ApplicationProperties.Datasource datasourceProps;
|
||||
private final DataSource dataSource;
|
||||
private final DatabaseNotificationServiceInterface backupNotificationService;
|
||||
@@ -450,6 +493,9 @@ public class DatabaseService implements DatabaseServiceInterface {
|
||||
throw new IllegalArgumentException("Backup verification failed for: " + scriptPath);
|
||||
}
|
||||
|
||||
// Validate SQL content before execution to prevent injection attacks
|
||||
validateSqlContent(scriptPath);
|
||||
|
||||
String query = "RUNSCRIPT from ?;";
|
||||
|
||||
try (Connection conn = dataSource.getConnection();
|
||||
@@ -466,6 +512,68 @@ public class DatabaseService implements DatabaseServiceInterface {
|
||||
log.info("Database import completed: {}", scriptPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates SQL content to prevent execution of dangerous functions. Uses an allowlist approach
|
||||
* - only allows standard DML/DDL operations generated by H2's SCRIPT command.
|
||||
*
|
||||
* @param scriptPath the path to the SQL file
|
||||
* @throws IllegalArgumentException if dangerous SQL or disallowed patterns are detected
|
||||
*/
|
||||
private void validateSqlContent(Path scriptPath) {
|
||||
try {
|
||||
String content = Files.readString(scriptPath);
|
||||
String normalizedContent = normalizeSqlContent(content);
|
||||
|
||||
// Validate that content only contains allowed operations (whitelist approach)
|
||||
// Split by semicolons to check individual statements
|
||||
String[] statements = normalizedContent.split(";");
|
||||
for (String statement : statements) {
|
||||
statement = statement.trim();
|
||||
if (statement.isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if statement matches any allowed pattern
|
||||
boolean isAllowed = false;
|
||||
for (Pattern allowedPattern : ALLOWED_PATTERNS) {
|
||||
if (allowedPattern.matcher(statement).find()) {
|
||||
isAllowed = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!isAllowed) {
|
||||
log.error(
|
||||
"Unrecognized SQL statement in backup file: {}",
|
||||
statement.substring(0, Math.min(50, statement.length())));
|
||||
throw new IllegalArgumentException(
|
||||
"SQL script contains unrecognized or disallowed SQL statements. File may be"
|
||||
+ " corrupted or tampered with.");
|
||||
}
|
||||
}
|
||||
|
||||
} catch (IOException e) {
|
||||
log.error("Error reading SQL script for validation: {}", e.getMessage());
|
||||
throw new IllegalArgumentException("Failed to validate SQL script: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes SQL content by removing comments to prevent bypass attacks.
|
||||
*
|
||||
* @param sql the SQL content to normalize
|
||||
* @return normalized SQL without comments
|
||||
*/
|
||||
private String normalizeSqlContent(String sql) {
|
||||
// Remove block comments (/* ... */)
|
||||
sql = sql.replaceAll("/\\*[\\s\\S]*?\\*/", " ");
|
||||
// Remove line comments (--....)
|
||||
sql = sql.replaceAll("--[^\r\n]*", " ");
|
||||
// Collapse multiple whitespaces
|
||||
sql = sql.replaceAll("\\s+", " ");
|
||||
return sql.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks for invalid characters or sequences
|
||||
*
|
||||
|
||||
+4
-4
@@ -35,7 +35,7 @@ public class SignatureService implements PersonalSignatureServiceInterface {
|
||||
|
||||
private static final Pattern FILENAME_VALIDATION_PATTERN = Pattern.compile("^[a-zA-Z0-9_.-]+$");
|
||||
private final String SIGNATURE_BASE_PATH;
|
||||
private final String ALL_USERS_FOLDER = "ALL_USERS";
|
||||
private static final String ALL_USERS_FOLDER = "ALL_USERS";
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
// Storage limits per user
|
||||
@@ -115,7 +115,7 @@ public class SignatureService implements PersonalSignatureServiceInterface {
|
||||
}
|
||||
|
||||
// Extract base64 data
|
||||
String base64Data = dataUrl.substring(dataUrl.indexOf(",") + 1);
|
||||
String base64Data = dataUrl.substring(dataUrl.indexOf(',') + 1);
|
||||
byte[] imageBytes = Base64.getDecoder().decode(base64Data);
|
||||
|
||||
// Validate decoded size
|
||||
@@ -127,8 +127,8 @@ public class SignatureService implements PersonalSignatureServiceInterface {
|
||||
}
|
||||
|
||||
// Determine and validate file extension from data URL
|
||||
String mimeType = dataUrl.substring(dataUrl.indexOf(":") + 1, dataUrl.indexOf(";"));
|
||||
String rawExtension = mimeType.substring(mimeType.indexOf("/") + 1);
|
||||
String mimeType = dataUrl.substring(dataUrl.indexOf(':') + 1, dataUrl.indexOf(';'));
|
||||
String rawExtension = mimeType.substring(mimeType.indexOf('/') + 1);
|
||||
String extension = validateAndNormalizeExtension(rawExtension);
|
||||
|
||||
// Save image file
|
||||
|
||||
+1
-1
@@ -149,7 +149,7 @@ class AuthControllerLoginTest {
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(payload)))
|
||||
.andExpect(status().isUnauthorized())
|
||||
.andExpect(jsonPath("$.error").value("Invalid credentials"));
|
||||
.andExpect(jsonPath("$.error").value("Invalid username or password"));
|
||||
|
||||
verify(loginAttemptService).loginFailed("user@example.com");
|
||||
}
|
||||
|
||||
+3
-3
@@ -125,7 +125,7 @@ class InviteLinkControllerTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void validateInviteTokenReturnsGoneWhenExpired() throws Exception {
|
||||
void validateInviteTokenReturnsNotFoundWhenExpired() throws Exception {
|
||||
InviteToken expired = new InviteToken();
|
||||
expired.setToken("abc");
|
||||
expired.setExpiresAt(LocalDateTime.now().minusHours(1));
|
||||
@@ -133,8 +133,8 @@ class InviteLinkControllerTest {
|
||||
when(inviteTokenRepository.findByToken("abc")).thenReturn(Optional.of(expired));
|
||||
|
||||
mockMvc.perform(get("/api/v1/invite/validate/abc"))
|
||||
.andExpect(status().isGone())
|
||||
.andExpect(jsonPath("$.error").value("This invite link has expired"));
|
||||
.andExpect(status().isNotFound())
|
||||
.andExpect(jsonPath("$.error").value("Invalid invite link"));
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
+135
@@ -128,4 +128,139 @@ class DatabaseServiceTest {
|
||||
org.mockito.ArgumentMatchers.anyString(),
|
||||
org.mockito.ArgumentMatchers.anyString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void validateSqlContentAcceptsRealH2BackupFile() throws IOException {
|
||||
String sqlContent =
|
||||
"-- H2 2.4.240; \n"
|
||||
+ "SET DB_CLOSE_DELAY -1; \n"
|
||||
+ "; \n"
|
||||
+ "CREATE USER IF NOT EXISTS \"SA\" SALT 'd4a7c2f9e1b5a8c3' HASH 'b8f3e6c1a9d2f7e4c5a8d1f6b3e7a2c9d0f5b8e1a4c7f2d9e6b3a1c8f5d2' ADMIN; \n"
|
||||
+ "DROP TABLE IF EXISTS \"PUBLIC\".\"AUDIT_EVENTS\" CASCADE; \n"
|
||||
+ "DROP TABLE IF EXISTS \"PUBLIC\".\"AUTHORITIES\" CASCADE; \n"
|
||||
+ "DROP TABLE IF EXISTS \"PUBLIC\".\"INVITE_TOKENS\" CASCADE; \n"
|
||||
+ "DROP TABLE IF EXISTS \"PUBLIC\".\"PERSISTENT_LOGINS\" CASCADE; \n"
|
||||
+ "DROP TABLE IF EXISTS \"PUBLIC\".\"SESSIONS\" CASCADE; \n"
|
||||
+ "DROP TABLE IF EXISTS \"PUBLIC\".\"TEAMS\" CASCADE; \n"
|
||||
+ "DROP TABLE IF EXISTS \"PUBLIC\".\"USER_LICENSE_SETTINGS\" CASCADE; \n"
|
||||
+ "DROP TABLE IF EXISTS \"PUBLIC\".\"USER_SETTINGS\" CASCADE; \n"
|
||||
+ "DROP TABLE IF EXISTS \"PUBLIC\".\"USERS\" CASCADE; \n"
|
||||
+ "CREATE CACHED TABLE \"PUBLIC\".\"AUDIT_EVENTS\"(\n"
|
||||
+ " \"ID\" BIGINT GENERATED BY DEFAULT AS IDENTITY(START WITH 1) NOT NULL,\n"
|
||||
+ " \"DATA\" CHARACTER VARYING,\n"
|
||||
+ " \"PRINCIPAL\" CHARACTER VARYING(255),\n"
|
||||
+ " \"TIMESTAMP\" TIMESTAMP(6) WITH TIME ZONE,\n"
|
||||
+ " \"TYPE\" CHARACTER VARYING(255)\n"
|
||||
+ "); \n"
|
||||
+ "ALTER TABLE \"PUBLIC\".\"AUDIT_EVENTS\" ADD CONSTRAINT \"PUBLIC\".\"CONSTRAINT_C\" PRIMARY KEY(\"ID\"); \n"
|
||||
+ "-- 0 +/- SELECT COUNT(*) FROM PUBLIC.AUDIT_EVENTS; \n"
|
||||
+ "CREATE INDEX \"PUBLIC\".\"IDX_AUDIT_TIMESTAMP\" ON \"PUBLIC\".\"AUDIT_EVENTS\"(\"TIMESTAMP\" NULLS FIRST); \n"
|
||||
+ "CREATE INDEX \"PUBLIC\".\"IDX_AUDIT_PRINCIPAL\" ON \"PUBLIC\".\"AUDIT_EVENTS\"(\"PRINCIPAL\" NULLS FIRST); \n"
|
||||
+ "CREATE INDEX \"PUBLIC\".\"IDX_AUDIT_TYPE\" ON \"PUBLIC\".\"AUDIT_EVENTS\"(\"TYPE\" NULLS FIRST); \n"
|
||||
+ "CREATE INDEX \"PUBLIC\".\"IDX_AUDIT_PRINCIPAL_TYPE\" ON \"PUBLIC\".\"AUDIT_EVENTS\"(\"PRINCIPAL\" NULLS FIRST, \"TYPE\" NULLS FIRST); \n"
|
||||
+ "CREATE INDEX \"PUBLIC\".\"IDX_AUDIT_TYPE_TIMESTAMP\" ON \"PUBLIC\".\"AUDIT_EVENTS\"(\"TYPE\" NULLS FIRST, \"TIMESTAMP\" NULLS FIRST); \n"
|
||||
+ "CREATE CACHED TABLE \"PUBLIC\".\"AUTHORITIES\"(\n"
|
||||
+ " \"ID\" BIGINT GENERATED BY DEFAULT AS IDENTITY(START WITH 1 RESTART WITH 3) NOT NULL,\n"
|
||||
+ " \"AUTHORITY\" CHARACTER VARYING(255),\n"
|
||||
+ " \"USER_ID\" BIGINT\n"
|
||||
+ "); \n"
|
||||
+ "ALTER TABLE \"PUBLIC\".\"AUTHORITIES\" ADD CONSTRAINT \"PUBLIC\".\"CONSTRAINT_A\" PRIMARY KEY(\"ID\"); \n"
|
||||
+ "-- 2 +/- SELECT COUNT(*) FROM PUBLIC.AUTHORITIES; \n"
|
||||
+ "INSERT INTO \"PUBLIC\".\"AUTHORITIES\"(\"ID\", \"AUTHORITY\", \"USER_ID\") VALUES(1, 'ROLE_ADMIN', 1); \n"
|
||||
+ "INSERT INTO \"PUBLIC\".\"AUTHORITIES\"(\"ID\", \"AUTHORITY\", \"USER_ID\") VALUES(2, 'STIRLING-PDF-BACKEND-API-USER', 2);\n"
|
||||
+ "CREATE CACHED TABLE \"PUBLIC\".\"INVITE_TOKENS\"(\n"
|
||||
+ " \"ID\" BIGINT GENERATED BY DEFAULT AS IDENTITY(START WITH 1) NOT NULL,\n"
|
||||
+ " \"CREATED_AT\" TIMESTAMP(6),\n"
|
||||
+ " \"CREATED_BY\" CHARACTER VARYING(255) NOT NULL,\n"
|
||||
+ " \"EMAIL\" CHARACTER VARYING(255),\n"
|
||||
+ " \"EXPIRES_AT\" TIMESTAMP(6) NOT NULL,\n"
|
||||
+ " \"ROLE\" CHARACTER VARYING(50) NOT NULL,\n"
|
||||
+ " \"TEAM_ID\" BIGINT,\n"
|
||||
+ " \"TOKEN\" CHARACTER VARYING(100) NOT NULL,\n"
|
||||
+ " \"USED\" BOOLEAN NOT NULL,\n"
|
||||
+ " \"USED_AT\" TIMESTAMP(6)\n"
|
||||
+ "); \n"
|
||||
+ "ALTER TABLE \"PUBLIC\".\"INVITE_TOKENS\" ADD CONSTRAINT \"PUBLIC\".\"CONSTRAINT_E\" PRIMARY KEY(\"ID\"); \n"
|
||||
+ "-- 0 +/- SELECT COUNT(*) FROM PUBLIC.INVITE_TOKENS; \n"
|
||||
+ "CREATE CACHED TABLE \"PUBLIC\".\"PERSISTENT_LOGINS\"(\n"
|
||||
+ " \"SERIES\" CHARACTER VARYING(255) NOT NULL,\n"
|
||||
+ " \"LAST_USED\" TIMESTAMP(6) WITH TIME ZONE NOT NULL,\n"
|
||||
+ " \"TOKEN\" CHARACTER VARYING(64) NOT NULL,\n"
|
||||
+ " \"USERNAME\" CHARACTER VARYING(64) NOT NULL\n"
|
||||
+ "); \n"
|
||||
+ "ALTER TABLE \"PUBLIC\".\"PERSISTENT_LOGINS\" ADD CONSTRAINT \"PUBLIC\".\"CONSTRAINT_A3\" PRIMARY KEY(\"SERIES\"); \n"
|
||||
+ "-- 0 +/- SELECT COUNT(*) FROM PUBLIC.PERSISTENT_LOGINS; \n"
|
||||
+ "CREATE CACHED TABLE \"PUBLIC\".\"SESSIONS\"(\n"
|
||||
+ " \"SESSION_ID\" CHARACTER VARYING(255) NOT NULL,\n"
|
||||
+ " \"EXPIRED\" BOOLEAN NOT NULL,\n"
|
||||
+ " \"LAST_REQUEST\" TIMESTAMP(6) WITH TIME ZONE,\n"
|
||||
+ " \"PRINCIPAL_NAME\" CHARACTER VARYING(255)\n"
|
||||
+ "); \n"
|
||||
+ "ALTER TABLE \"PUBLIC\".\"SESSIONS\" ADD CONSTRAINT \"PUBLIC\".\"CONSTRAINT_8\" PRIMARY KEY(\"SESSION_ID\"); \n"
|
||||
+ "-- 0 +/- SELECT COUNT(*) FROM PUBLIC.SESSIONS; \n"
|
||||
+ "CREATE CACHED TABLE \"PUBLIC\".\"TEAMS\"(\n"
|
||||
+ " \"TEAM_ID\" BIGINT GENERATED BY DEFAULT AS IDENTITY(START WITH 1 RESTART WITH 3) NOT NULL,\n"
|
||||
+ " \"NAME\" CHARACTER VARYING(255) NOT NULL\n"
|
||||
+ "); \n"
|
||||
+ "ALTER TABLE \"PUBLIC\".\"TEAMS\" ADD CONSTRAINT \"PUBLIC\".\"CONSTRAINT_4\" PRIMARY KEY(\"TEAM_ID\"); \n"
|
||||
+ "-- 2 +/- SELECT COUNT(*) FROM PUBLIC.TEAMS; \n"
|
||||
+ "INSERT INTO \"PUBLIC\".\"TEAMS\"(\"TEAM_ID\", \"NAME\") VALUES(1, 'Default'); \n"
|
||||
+ "INSERT INTO \"PUBLIC\".\"TEAMS\"(\"TEAM_ID\", \"NAME\") VALUES(2, 'Internal'); \n"
|
||||
+ "CREATE CACHED TABLE \"PUBLIC\".\"USER_LICENSE_SETTINGS\"(\n"
|
||||
+ " \"ID\" BIGINT NOT NULL,\n"
|
||||
+ " \"GRANDFATHERED_USER_COUNT\" INTEGER NOT NULL,\n"
|
||||
+ " \"GRANDFATHERED_USER_SIGNATURE\" CHARACTER VARYING(256) NOT NULL,\n"
|
||||
+ " \"GRANDFATHERING_LOCKED\" BOOLEAN NOT NULL,\n"
|
||||
+ " \"INTEGRITY_SALT\" CHARACTER VARYING(64) NOT NULL,\n"
|
||||
+ " \"LICENSE_MAX_USERS\" INTEGER NOT NULL\n"
|
||||
+ "); \n"
|
||||
+ "ALTER TABLE \"PUBLIC\".\"USER_LICENSE_SETTINGS\" ADD CONSTRAINT \"PUBLIC\".\"CONSTRAINT_9\" PRIMARY KEY(\"ID\"); \n"
|
||||
+ "-- 0 +/- SELECT COUNT(*) FROM PUBLIC.USER_LICENSE_SETTINGS; \n"
|
||||
+ "CREATE CACHED TABLE \"PUBLIC\".\"USER_SETTINGS\"(\n"
|
||||
+ " \"USER_ID\" BIGINT NOT NULL,\n"
|
||||
+ " \"SETTING_VALUE\" CHARACTER VARYING,\n"
|
||||
+ " \"SETTING_KEY\" CHARACTER VARYING(255) NOT NULL\n"
|
||||
+ "); \n"
|
||||
+ "ALTER TABLE \"PUBLIC\".\"USER_SETTINGS\" ADD CONSTRAINT \"PUBLIC\".\"CONSTRAINT_9A\" PRIMARY KEY(\"USER_ID\", \"SETTING_KEY\"); \n"
|
||||
+ "-- 4 +/- SELECT COUNT(*) FROM PUBLIC.USER_SETTINGS; \n"
|
||||
+ "INSERT INTO \"PUBLIC\".\"USER_SETTINGS\"(\"USER_ID\", \"SETTING_VALUE\", \"SETTING_KEY\") VALUES(1, 'false', 'mfaEnabled'); \n"
|
||||
+ "INSERT INTO \"PUBLIC\".\"USER_SETTINGS\"(\"USER_ID\", \"SETTING_VALUE\", \"SETTING_KEY\") VALUES(1, 'false', 'mfaRequired'); \n"
|
||||
+ "INSERT INTO \"PUBLIC\".\"USER_SETTINGS\"(\"USER_ID\", \"SETTING_VALUE\", \"SETTING_KEY\") VALUES(2, 'false', 'mfaEnabled'); \n"
|
||||
+ "INSERT INTO \"PUBLIC\".\"USER_SETTINGS\"(\"USER_ID\", \"SETTING_VALUE\", \"SETTING_KEY\") VALUES(2, 'false', 'mfaRequired'); \n"
|
||||
+ "CREATE CACHED TABLE \"PUBLIC\".\"USERS\"(\n"
|
||||
+ " \"USER_ID\" BIGINT GENERATED BY DEFAULT AS IDENTITY(START WITH 1 RESTART WITH 3) NOT NULL,\n"
|
||||
+ " \"API_KEY\" CHARACTER VARYING(255),\n"
|
||||
+ " \"AUTHENTICATIONTYPE\" CHARACTER VARYING(255),\n"
|
||||
+ " \"CREATED_AT\" TIMESTAMP(6),\n"
|
||||
+ " \"ENABLED\" BOOLEAN,\n"
|
||||
+ " \"FORCE_PASSWORD_CHANGE\" BOOLEAN,\n"
|
||||
+ " \"HAS_COMPLETED_INITIAL_SETUP\" BOOLEAN,\n"
|
||||
+ " \"IS_FIRST_LOGIN\" BOOLEAN,\n"
|
||||
+ " \"OAUTH_GRANDFATHERED\" BOOLEAN,\n"
|
||||
+ " \"PASSWORD\" CHARACTER VARYING(255),\n"
|
||||
+ " \"ROLE_NAME\" CHARACTER VARYING(255),\n"
|
||||
+ " \"SSO_PROVIDER\" CHARACTER VARYING(255),\n"
|
||||
+ " \"SSO_PROVIDER_ID\" CHARACTER VARYING(255),\n"
|
||||
+ " \"UPDATED_AT\" TIMESTAMP(6),\n"
|
||||
+ " \"USERNAME\" CHARACTER VARYING(255),\n"
|
||||
+ " \"TEAM_ID\" BIGINT\n"
|
||||
+ "); \n"
|
||||
+ "ALTER TABLE \"PUBLIC\".\"USERS\" ADD CONSTRAINT \"PUBLIC\".\"CONSTRAINT_4D\" PRIMARY KEY(\"USER_ID\"); \n"
|
||||
+ "-- 2 +/- SELECT COUNT(*) FROM PUBLIC.USERS; \n"
|
||||
+ "INSERT INTO \"PUBLIC\".\"USERS\"(\"USER_ID\", \"API_KEY\", \"AUTHENTICATIONTYPE\", \"CREATED_AT\", \"ENABLED\", \"FORCE_PASSWORD_CHANGE\", \"HAS_COMPLETED_INITIAL_SETUP\", \"IS_FIRST_LOGIN\", \"OAUTH_GRANDFATHERED\", \"PASSWORD\", \"ROLE_NAME\", \"SSO_PROVIDER\", \"SSO_PROVIDER_ID\", \"UPDATED_AT\", \"USERNAME\", \"TEAM_ID\") VALUES(1, NULL, 'web', TIMESTAMP '2026-02-26 00:25:03.688329', TRUE, FALSE, FALSE, FALSE, FALSE, '$2a$10$k9mF3xL6pD8nB2v5rT1wSuZqJ4kP7mH8gN5aY3bX9cW2eR6fD9sY1', NULL, NULL, NULL, TIMESTAMP '2026-02-26 00:25:03.688365', 'ludy', 1); \n"
|
||||
+ "INSERT INTO \"PUBLIC\".\"USERS\"(\"USER_ID\", \"API_KEY\", \"AUTHENTICATIONTYPE\", \"CREATED_AT\", \"ENABLED\", \"FORCE_PASSWORD_CHANGE\", \"HAS_COMPLETED_INITIAL_SETUP\", \"IS_FIRST_LOGIN\", \"OAUTH_GRANDFATHERED\", \"PASSWORD\", \"ROLE_NAME\", \"SSO_PROVIDER\", \"SSO_PROVIDER_ID\", \"UPDATED_AT\", \"USERNAME\", \"TEAM_ID\") VALUES(2, 'e3c7f1a9-6b2d-4f5e-8c3a-9d1b7e4f2c6a', 'web', TIMESTAMP '2026-02-26 00:25:03.845308', TRUE, FALSE, FALSE, FALSE, FALSE, '$2a$10$m7pB4qL9tF2jW5hX8vR3nUYsK1dE6cG9oP0jM3iC8lA7nZ4fT9xR2', NULL, NULL, NULL, TIMESTAMP '2026-02-26 00:25:03.914916', 'STIRLING-PDF-BACKEND-API-USER', 2); \n"
|
||||
+ "ALTER TABLE \"PUBLIC\".\"USERS\" ADD CONSTRAINT \"PUBLIC\".\"UKR43AF9AP4EDM43MMTQ01ODDJ6\" UNIQUE NULLS DISTINCT (\"USERNAME\"); \n"
|
||||
+ "ALTER TABLE \"PUBLIC\".\"TEAMS\" ADD CONSTRAINT \"PUBLIC\".\"UKA510NO6SJWQCX153YD5SM4JRR\" UNIQUE NULLS DISTINCT (\"NAME\"); \n"
|
||||
+ "ALTER TABLE \"PUBLIC\".\"INVITE_TOKENS\" ADD CONSTRAINT \"PUBLIC\".\"UKEWCI50C0NCFIIHR2TC41NFBNF\" UNIQUE NULLS DISTINCT (\"TOKEN\"); \n"
|
||||
+ "ALTER TABLE \"PUBLIC\".\"AUTHORITIES\" ADD CONSTRAINT \"PUBLIC\".\"FKK91UPMBUEYIM93V469WJ7B2QH\" FOREIGN KEY(\"USER_ID\") REFERENCES \"PUBLIC\".\"USERS\"(\"USER_ID\") NOCHECK;\n"
|
||||
+ "ALTER TABLE \"PUBLIC\".\"USER_SETTINGS\" ADD CONSTRAINT \"PUBLIC\".\"FK8V82NJ88RMAI0NYCK19F873DW\" FOREIGN KEY(\"USER_ID\") REFERENCES \"PUBLIC\".\"USERS\"(\"USER_ID\") NOCHECK; \n"
|
||||
+ "ALTER TABLE \"PUBLIC\".\"USERS\" ADD CONSTRAINT \"PUBLIC\".\"FKFJWS1RDRUAB2BQG7QIPOQF65R\" FOREIGN KEY(\"TEAM_ID\") REFERENCES \"PUBLIC\".\"TEAMS\"(\"TEAM_ID\") NOCHECK; \n"
|
||||
+ "";
|
||||
|
||||
Path script = Files.createTempFile("backup", ".sql");
|
||||
Files.writeString(script, sqlContent);
|
||||
|
||||
boolean result = databaseService.importDatabaseFromUI(script);
|
||||
assertThat(result).isTrue();
|
||||
}
|
||||
}
|
||||
|
||||
+31
-39
@@ -26,7 +26,7 @@ ext {
|
||||
lombokVersion = "1.18.42"
|
||||
bouncycastleVersion = "1.83"
|
||||
springSecuritySamlVersion = "7.0.2"
|
||||
openSamlVersion = "4.3.2"
|
||||
openSamlVersion = "5.2.1"
|
||||
commonmarkVersion = "0.27.1"
|
||||
googleJavaFormatVersion = "1.34.1"
|
||||
logback = "1.5.32"
|
||||
@@ -85,49 +85,40 @@ allprojects {
|
||||
}
|
||||
}
|
||||
|
||||
def writeIfChanged(File targetFile, String newContent) {
|
||||
if (targetFile.getText('UTF-8') != newContent) {
|
||||
targetFile.write(newContent, 'UTF-8')
|
||||
}
|
||||
}
|
||||
|
||||
def updateTauriConfigVersion(String version) {
|
||||
File tauriConfig = file('frontend/src-tauri/tauri.conf.json')
|
||||
def parsed = new JsonSlurper().parse(tauriConfig)
|
||||
parsed.version = version
|
||||
|
||||
def formatted = JsonOutput.prettyPrint(JsonOutput.toJson(parsed)) + System.lineSeparator()
|
||||
writeIfChanged(tauriConfig, formatted)
|
||||
}
|
||||
|
||||
def updateSimulationVersion(File fileToUpdate, String version) {
|
||||
def content = fileToUpdate.getText('UTF-8')
|
||||
def matcher = content =~ /(appVersion:\s*')([^']*)(')/
|
||||
|
||||
if (!matcher.find()) {
|
||||
throw new GradleException("Could not locate appVersion in ${fileToUpdate} for synchronization")
|
||||
}
|
||||
|
||||
def updatedContent = matcher.replaceFirst("${matcher.group(1)}${version}${matcher.group(3)}")
|
||||
writeIfChanged(fileToUpdate, updatedContent)
|
||||
}
|
||||
|
||||
def rootProjectRef = project
|
||||
def appVersionStr = project.version.toString()
|
||||
def tauriConfigPath = layout.projectDirectory.file('frontend/src-tauri/tauri.conf.json').asFile.path
|
||||
def sim1Path = layout.projectDirectory.file('frontend/src/core/testing/serverExperienceSimulations.ts').asFile.path
|
||||
def sim2Path = layout.projectDirectory.file('frontend/src/proprietary/testing/serverExperienceSimulations.ts').asFile.path
|
||||
|
||||
tasks.register('syncAppVersion') {
|
||||
group = 'versioning'
|
||||
description = 'Synchronizes app version across desktop and simulation configs.'
|
||||
|
||||
doLast {
|
||||
def appVersion = rootProjectRef.version.toString()
|
||||
println "Synchronizing application version to ${appVersion}"
|
||||
updateTauriConfigVersion(appVersion)
|
||||
println "Synchronizing application version to ${appVersionStr}"
|
||||
|
||||
[
|
||||
'frontend/src/core/testing/serverExperienceSimulations.ts',
|
||||
'frontend/src/proprietary/testing/serverExperienceSimulations.ts'
|
||||
].each { path ->
|
||||
updateSimulationVersion(file(path), appVersion)
|
||||
def tauriConfigFile = new File(tauriConfigPath)
|
||||
if (tauriConfigFile.exists()) {
|
||||
def parsed = new groovy.json.JsonSlurper().parse(tauriConfigFile)
|
||||
parsed.version = appVersionStr
|
||||
def formatted = groovy.json.JsonOutput.prettyPrint(groovy.json.JsonOutput.toJson(parsed)) + System.lineSeparator()
|
||||
if (tauriConfigFile.getText('UTF-8') != formatted) {
|
||||
tauriConfigFile.write(formatted, 'UTF-8')
|
||||
}
|
||||
}
|
||||
|
||||
[new File(sim1Path), new File(sim2Path)].each { f ->
|
||||
if (f.exists()) {
|
||||
def content = f.getText('UTF-8')
|
||||
def matcher = (content =~ /(appVersion:\s*')([^']*)(')/)
|
||||
if (!matcher.find()) {
|
||||
throw new GradleException("Could not locate appVersion in ${f} for synchronization")
|
||||
}
|
||||
def updatedContent = matcher.replaceFirst("${matcher.group(1)}${appVersionStr}${matcher.group(3)}")
|
||||
if (content != updatedContent) {
|
||||
f.write(updatedContent, 'UTF-8')
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -176,6 +167,7 @@ subprojects {
|
||||
}
|
||||
}
|
||||
maven { url = "https://build.shibboleth.net/maven/releases" }
|
||||
maven { url = "https://repository.jboss.org/" }
|
||||
mavenCentral()
|
||||
}
|
||||
|
||||
@@ -278,7 +270,7 @@ subprojects {
|
||||
}
|
||||
|
||||
def thresholds = [
|
||||
LINE : 0.16,
|
||||
LINE : 0.13,
|
||||
INSTRUCTION: 0.14,
|
||||
BRANCH : 0.09
|
||||
]
|
||||
@@ -371,7 +363,7 @@ subprojects {
|
||||
limit {
|
||||
counter = 'LINE'
|
||||
value = 'COVEREDRATIO'
|
||||
minimum = 0.16
|
||||
minimum = 0.13
|
||||
}
|
||||
// Verzweigungen (if/else, switch) abgedeckt; misst Logik-Abdeckung
|
||||
limit {
|
||||
|
||||
@@ -25,7 +25,7 @@ Stirling-PDF is built using:
|
||||
|
||||
- Docker
|
||||
- Git
|
||||
- Java JDK 17 or later
|
||||
- Java JDK 21 or later (JDK 25 recommended)
|
||||
- Gradle 7.0 or later (Included within the repo)
|
||||
|
||||
### Setup Steps
|
||||
@@ -37,7 +37,7 @@ Stirling-PDF is built using:
|
||||
cd Stirling-PDF
|
||||
```
|
||||
|
||||
2. Install Docker and JDK17 if not already installed.
|
||||
2. Install Docker and JDK 21 (or JDK 25 recommended) if not already installed.
|
||||
|
||||
3. Install a recommended Java IDE such as Eclipse, IntelliJ, or VSCode
|
||||
1. Only VSCode
|
||||
|
||||
Generated
+369
-231
@@ -9,7 +9,7 @@
|
||||
"version": "1.0.0",
|
||||
"devDependencies": {
|
||||
"@stylistic/stylelint-plugin": "^4.0.0",
|
||||
"stylelint": "^16.26.1",
|
||||
"stylelint": "^17.4.0",
|
||||
"stylelint-config-standard": "^40.0.0"
|
||||
}
|
||||
},
|
||||
@@ -39,27 +39,27 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@cacheable/memory": {
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@cacheable/memory/-/memory-2.0.5.tgz",
|
||||
"integrity": "sha512-fkiAxCvssEyJZ5fxX4tcdZFRmW9JehSTGvvqmXn6rTzG5cH6V/3C4ad8yb01vOjp2xBydHkHrgpW0qeGtzt6VQ==",
|
||||
"version": "2.0.8",
|
||||
"resolved": "https://registry.npmjs.org/@cacheable/memory/-/memory-2.0.8.tgz",
|
||||
"integrity": "sha512-FvEb29x5wVwu/Kf93IWwsOOEuhHh6dYCJF3vcKLzXc0KXIW181AOzv6ceT4ZpBHDvAfG60eqb+ekmrnLHIy+jw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@cacheable/utils": "^2.3.0",
|
||||
"@keyv/bigmap": "^1.1.0",
|
||||
"hookified": "^1.12.2",
|
||||
"keyv": "^5.5.4"
|
||||
"@cacheable/utils": "^2.4.0",
|
||||
"@keyv/bigmap": "^1.3.1",
|
||||
"hookified": "^1.15.1",
|
||||
"keyv": "^5.6.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@cacheable/utils": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@cacheable/utils/-/utils-2.3.1.tgz",
|
||||
"integrity": "sha512-38NJXjIr4W1Sghun8ju+uYWD8h2c61B4dKwfnQHVDFpAJ9oS28RpfqZQJ6Dgd3RceGkILDY9YT+72HJR3LoeSQ==",
|
||||
"version": "2.4.0",
|
||||
"resolved": "https://registry.npmjs.org/@cacheable/utils/-/utils-2.4.0.tgz",
|
||||
"integrity": "sha512-PeMMsqjVq+bF0WBsxFBxr/WozBJiZKY0rUojuaCoIaKnEl3Ju1wfEwS+SV1DU/cSe8fqHIPiYJFif8T3MVt4cQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"hashery": "^1.2.0",
|
||||
"keyv": "^5.5.4"
|
||||
"hashery": "^1.5.0",
|
||||
"keyv": "^5.6.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@csstools/css-parser-algorithms": {
|
||||
@@ -86,9 +86,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@csstools/css-syntax-patches-for-csstree": {
|
||||
"version": "1.0.22",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.0.22.tgz",
|
||||
"integrity": "sha512-qBcx6zYlhleiFfdtzkRgwNC7VVoAwfK76Vmsw5t+PbvtdknO9StgRk7ROvq9so1iqbdW4uLIDAsXRsTfUrIoOw==",
|
||||
"version": "1.0.29",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.0.29.tgz",
|
||||
"integrity": "sha512-jx9GjkkP5YHuTmko2eWAvpPnb0mB4mGRr2U7XwVNwevm8nlpobZEVk+GNmiYMk2VuA75v+plfXWyroWKmICZXg==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@@ -100,10 +100,7 @@
|
||||
"url": "https://opencollective.com/csstools"
|
||||
}
|
||||
],
|
||||
"license": "MIT-0",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
"license": "MIT-0"
|
||||
},
|
||||
"node_modules/@csstools/css-tokenizer": {
|
||||
"version": "3.0.4",
|
||||
@@ -149,10 +146,10 @@
|
||||
"@csstools/css-tokenizer": "^3.0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@csstools/selector-specificity": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/selector-specificity/-/selector-specificity-5.0.0.tgz",
|
||||
"integrity": "sha512-PCqQV3c4CoVm3kdPhyeZ07VmBRdH2EpMFA/pd9OASpOEC3aXNGoqPDAZ80D0cLpMBxnmk0+yNhGsEx31hq7Gtw==",
|
||||
"node_modules/@csstools/selector-resolve-nested": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/selector-resolve-nested/-/selector-resolve-nested-4.0.0.tgz",
|
||||
"integrity": "sha512-9vAPxmp+Dx3wQBIUwc1v7Mdisw1kbbaGqXUM8QLTgWg7SoPGYtXBsMXvsFs/0Bn5yoFhcktzxNZGNaUt0VjgjA==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@@ -166,38 +163,50 @@
|
||||
],
|
||||
"license": "MIT-0",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
"node": ">=20.19.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"postcss-selector-parser": "^7.0.0"
|
||||
"postcss-selector-parser": "^7.1.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@dual-bundle/import-meta-resolve": {
|
||||
"version": "4.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@dual-bundle/import-meta-resolve/-/import-meta-resolve-4.2.1.tgz",
|
||||
"integrity": "sha512-id+7YRUgoUX6CgV0DtuhirQWodeeA7Lf4i2x71JS/vtA5pRb/hIGWlw+G6MeXvsM+MXrz0VAydTGElX1rAfgPg==",
|
||||
"node_modules/@csstools/selector-specificity": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/selector-specificity/-/selector-specificity-6.0.0.tgz",
|
||||
"integrity": "sha512-4sSgl78OtOXEX/2d++8A83zHNTgwCJMaR24FvsYL7Uf/VS8HZk9PTwR51elTbGqMuwH3szLvvOXEaVnqn0Z3zA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/JounQin"
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/csstools"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/csstools"
|
||||
}
|
||||
],
|
||||
"license": "MIT-0",
|
||||
"engines": {
|
||||
"node": ">=20.19.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"postcss-selector-parser": "^7.1.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@keyv/bigmap": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@keyv/bigmap/-/bigmap-1.3.0.tgz",
|
||||
"integrity": "sha512-KT01GjzV6AQD5+IYrcpoYLkCu1Jod3nau1Z7EsEuViO3TZGRacSbO9MfHmbJ1WaOXFtWLxPVj169cn2WNKPkIg==",
|
||||
"version": "1.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@keyv/bigmap/-/bigmap-1.3.1.tgz",
|
||||
"integrity": "sha512-WbzE9sdmQtKy8vrNPa9BRnwZh5UF4s1KTmSK0KUVLo3eff5BlQNNWDnFOouNpKfPKDnms9xynJjsMYjMaT/aFQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"hashery": "^1.2.0",
|
||||
"hookified": "^1.13.0"
|
||||
"hashery": "^1.4.0",
|
||||
"hookified": "^1.15.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 18"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"keyv": "^5.5.4"
|
||||
"keyv": "^5.6.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@keyv/serialize": {
|
||||
@@ -245,6 +254,19 @@
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/@sindresorhus/merge-streams": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@sindresorhus/merge-streams/-/merge-streams-4.0.0.tgz",
|
||||
"integrity": "sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/@stylistic/stylelint-plugin": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@stylistic/stylelint-plugin/-/stylelint-plugin-4.0.0.tgz",
|
||||
@@ -268,9 +290,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/ajv": {
|
||||
"version": "8.17.1",
|
||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz",
|
||||
"integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==",
|
||||
"version": "8.18.0",
|
||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz",
|
||||
"integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -317,16 +339,6 @@
|
||||
"dev": true,
|
||||
"license": "Python-2.0"
|
||||
},
|
||||
"node_modules/array-union": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz",
|
||||
"integrity": "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/astral-regex": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/astral-regex/-/astral-regex-2.0.0.tgz",
|
||||
@@ -337,13 +349,6 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/balanced-match": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-2.0.0.tgz",
|
||||
"integrity": "sha512-1ugUSr8BHXRnK23KfuYS+gVMC3LB8QGH9W1iGtDPsNWoQbgtXSExkBu2aDR4epiGWZOjZsj6lDl/N/AqqTC3UA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/braces": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
|
||||
@@ -358,17 +363,17 @@
|
||||
}
|
||||
},
|
||||
"node_modules/cacheable": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/cacheable/-/cacheable-2.2.0.tgz",
|
||||
"integrity": "sha512-LEJxRqfeomiiRd2t0uON6hxAtgOoWDfY3fugebbz+J3vDLO+SkdfFChQcOHTZhj9SYa9iwE9MGYNX72dKiOE4w==",
|
||||
"version": "2.3.3",
|
||||
"resolved": "https://registry.npmjs.org/cacheable/-/cacheable-2.3.3.tgz",
|
||||
"integrity": "sha512-iffYMX4zxKp54evOH27fm92hs+DeC1DhXmNVN8Tr94M/iZIV42dqTHSR2Ik4TOSPyOAwKr7Yu3rN9ALoLkbWyQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@cacheable/memory": "^2.0.5",
|
||||
"@cacheable/utils": "^2.3.0",
|
||||
"hookified": "^1.13.0",
|
||||
"keyv": "^5.5.4",
|
||||
"qified": "^0.5.2"
|
||||
"@cacheable/memory": "^2.0.8",
|
||||
"@cacheable/utils": "^2.4.0",
|
||||
"hookified": "^1.15.0",
|
||||
"keyv": "^5.6.0",
|
||||
"qified": "^0.6.0"
|
||||
}
|
||||
},
|
||||
"node_modules/callsites": {
|
||||
@@ -436,13 +441,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/css-functions-list": {
|
||||
"version": "3.2.3",
|
||||
"resolved": "https://registry.npmjs.org/css-functions-list/-/css-functions-list-3.2.3.tgz",
|
||||
"integrity": "sha512-IQOkD3hbR5KrN93MtcYuad6YPuTSUhntLHDuLEbFWE+ff2/XSZNdZG+LcbbIW5AXKg/WFIfYItIzVoHngHXZzA==",
|
||||
"version": "3.3.3",
|
||||
"resolved": "https://registry.npmjs.org/css-functions-list/-/css-functions-list-3.3.3.tgz",
|
||||
"integrity": "sha512-8HFEBPKhOpJPEPu70wJJetjKta86Gw9+CCyCnB3sui2qQfOvRyqBy4IKLKKAwdMpWb2lHXWk9Wb4Z6AmaUT1Pg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12 || >=16"
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/css-tree": {
|
||||
@@ -490,19 +495,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/dir-glob": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz",
|
||||
"integrity": "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"path-type": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/emoji-regex": {
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
|
||||
@@ -582,9 +574,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/fastq": {
|
||||
"version": "1.19.1",
|
||||
"resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz",
|
||||
"integrity": "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==",
|
||||
"version": "1.20.1",
|
||||
"resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz",
|
||||
"integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
@@ -592,13 +584,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/file-entry-cache": {
|
||||
"version": "11.1.1",
|
||||
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-11.1.1.tgz",
|
||||
"integrity": "sha512-TPVFSDE7q91Dlk1xpFLvFllf8r0HyOMOlnWy7Z2HBku5H3KhIeOGInexrIeg2D64DosVB/JXkrrk6N/7Wriq4A==",
|
||||
"version": "11.1.2",
|
||||
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-11.1.2.tgz",
|
||||
"integrity": "sha512-N2WFfK12gmrK1c1GXOqiAJ1tc5YE+R53zvQ+t5P8S5XhnmKYVB5eZEiLNZKDSmoG8wqqbF9EXYBBW/nef19log==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"flat-cache": "^6.1.19"
|
||||
"flat-cache": "^6.1.20"
|
||||
}
|
||||
},
|
||||
"node_modules/fill-range": {
|
||||
@@ -615,24 +607,37 @@
|
||||
}
|
||||
},
|
||||
"node_modules/flat-cache": {
|
||||
"version": "6.1.19",
|
||||
"resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-6.1.19.tgz",
|
||||
"integrity": "sha512-l/K33newPTZMTGAnnzaiqSl6NnH7Namh8jBNjrgjprWxGmZUuxx/sJNIRaijOh3n7q7ESbhNZC+pvVZMFdeU4A==",
|
||||
"version": "6.1.20",
|
||||
"resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-6.1.20.tgz",
|
||||
"integrity": "sha512-AhHYqwvN62NVLp4lObVXGVluiABTHapoB57EyegZVmazN+hhGhLTn3uZbOofoTw4DSDvVCadzzyChXhOAvy8uQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cacheable": "^2.2.0",
|
||||
"cacheable": "^2.3.2",
|
||||
"flatted": "^3.3.3",
|
||||
"hookified": "^1.13.0"
|
||||
"hookified": "^1.15.0"
|
||||
}
|
||||
},
|
||||
"node_modules/flatted": {
|
||||
"version": "3.3.3",
|
||||
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz",
|
||||
"integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==",
|
||||
"version": "3.3.4",
|
||||
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.4.tgz",
|
||||
"integrity": "sha512-3+mMldrTAPdta5kjX2G2J7iX4zxtnwpdA8Tr2ZSjkyPSanvbZAcy6flmtnXbEybHrDcU9641lxrMfFuUxVz9vA==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/get-east-asian-width": {
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.5.0.tgz",
|
||||
"integrity": "sha512-CQ+bEO+Tva/qlmw24dCejulK5pMzVnUOFOijVogd3KQs07HnRIgp8TGipvCCRT06xeYEbpbgwaCxglFyiuIcmA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/glob-parent": {
|
||||
"version": "5.1.2",
|
||||
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
|
||||
@@ -675,36 +680,26 @@
|
||||
}
|
||||
},
|
||||
"node_modules/globby": {
|
||||
"version": "11.1.0",
|
||||
"resolved": "https://registry.npmjs.org/globby/-/globby-11.1.0.tgz",
|
||||
"integrity": "sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==",
|
||||
"version": "16.1.1",
|
||||
"resolved": "https://registry.npmjs.org/globby/-/globby-16.1.1.tgz",
|
||||
"integrity": "sha512-dW7vl+yiAJSp6aCekaVnVJxurRv7DCOLyXqEG3RYMYUg7AuJ2jCqPkZTA8ooqC2vtnkaMcV5WfFBMuEnTu1OQg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"array-union": "^2.1.0",
|
||||
"dir-glob": "^3.0.1",
|
||||
"fast-glob": "^3.2.9",
|
||||
"ignore": "^5.2.0",
|
||||
"merge2": "^1.4.1",
|
||||
"slash": "^3.0.0"
|
||||
"@sindresorhus/merge-streams": "^4.0.0",
|
||||
"fast-glob": "^3.3.3",
|
||||
"ignore": "^7.0.5",
|
||||
"is-path-inside": "^4.0.0",
|
||||
"slash": "^5.1.0",
|
||||
"unicorn-magic": "^0.4.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
"node": ">=20"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/globby/node_modules/ignore": {
|
||||
"version": "5.3.2",
|
||||
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
|
||||
"integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 4"
|
||||
}
|
||||
},
|
||||
"node_modules/globjoin": {
|
||||
"version": "0.1.4",
|
||||
"resolved": "https://registry.npmjs.org/globjoin/-/globjoin-0.1.4.tgz",
|
||||
@@ -713,43 +708,46 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/has-flag": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
|
||||
"integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-5.0.1.tgz",
|
||||
"integrity": "sha512-CsNUt5x9LUdx6hnk/E2SZLsDyvfqANZSUq4+D3D8RzDJ2M+HDTIkF60ibS1vHaK55vzgiZw1bEPFG9yH7l33wA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/hashery": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/hashery/-/hashery-1.2.0.tgz",
|
||||
"integrity": "sha512-43XJKpwle72Ik5Zpam7MuzRWyNdwwdf6XHlh8wCj2PggvWf+v/Dm5B0dxGZOmddidgeO6Ofu9As/o231Ti/9PA==",
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://registry.npmjs.org/hashery/-/hashery-1.5.0.tgz",
|
||||
"integrity": "sha512-nhQ6ExaOIqti2FDWoEMWARUqIKyjr2VcZzXShrI+A3zpeiuPWzx6iPftt44LhP74E5sW36B75N6VHbvRtpvO6Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"hookified": "^1.13.0"
|
||||
"hookified": "^1.14.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/hookified": {
|
||||
"version": "1.13.0",
|
||||
"resolved": "https://registry.npmjs.org/hookified/-/hookified-1.13.0.tgz",
|
||||
"integrity": "sha512-6sPYUY8olshgM/1LDNW4QZQN0IqgKhtl/1C8koNZBJrKLBk3AZl6chQtNwpNztvfiApHMEwMHek5rv993PRbWw==",
|
||||
"version": "1.15.1",
|
||||
"resolved": "https://registry.npmjs.org/hookified/-/hookified-1.15.1.tgz",
|
||||
"integrity": "sha512-MvG/clsADq1GPM2KGo2nyfaWVyn9naPiXrqIe4jYjXNZQt238kWyOGrsyc/DmRAQ+Re6yeo6yX/yoNCG5KAEVg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/html-tags": {
|
||||
"version": "3.3.1",
|
||||
"resolved": "https://registry.npmjs.org/html-tags/-/html-tags-3.3.1.tgz",
|
||||
"integrity": "sha512-ztqyC3kLto0e9WbNp0aeP+M3kTt+nbaIveGmUxAtZa+8iFgKLUOD4YKM5j+f3QD89bra7UeumolZHKuOXnTmeQ==",
|
||||
"version": "5.1.0",
|
||||
"resolved": "https://registry.npmjs.org/html-tags/-/html-tags-5.1.0.tgz",
|
||||
"integrity": "sha512-n6l5uca7/y5joxZ3LUePhzmBFUJ+U2YWzhMa8XUTecSeSlQiZdF5XAd/Q3/WUl0VsXgUwWi8I7CNIwdI5WN1SQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
"node": ">=20.10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
@@ -792,6 +790,17 @@
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/import-meta-resolve": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/import-meta-resolve/-/import-meta-resolve-4.2.0.tgz",
|
||||
"integrity": "sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/wooorm"
|
||||
}
|
||||
},
|
||||
"node_modules/imurmurhash": {
|
||||
"version": "0.1.4",
|
||||
"resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz",
|
||||
@@ -859,6 +868,19 @@
|
||||
"node": ">=0.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/is-path-inside": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-4.0.0.tgz",
|
||||
"integrity": "sha512-lJJV/5dYS+RcL8uQdBDW9c9uWFLLBNRyFhnAKXw5tVqLlKZ4RMGZKv+YQ/IA3OhD+RpbJa1LLFM1FQPGyIXvOA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/is-plain-object": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-5.0.0.tgz",
|
||||
@@ -911,9 +933,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/keyv": {
|
||||
"version": "5.5.4",
|
||||
"resolved": "https://registry.npmjs.org/keyv/-/keyv-5.5.4.tgz",
|
||||
"integrity": "sha512-eohl3hKTiVyD1ilYdw9T0OiB4hnjef89e3dMYKz+mVKDzj+5IteTseASUsOB+EU9Tf6VNTCjDePcP6wkDGmLKQ==",
|
||||
"version": "5.6.0",
|
||||
"resolved": "https://registry.npmjs.org/keyv/-/keyv-5.6.0.tgz",
|
||||
"integrity": "sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -930,13 +952,6 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/known-css-properties": {
|
||||
"version": "0.37.0",
|
||||
"resolved": "https://registry.npmjs.org/known-css-properties/-/known-css-properties-0.37.0.tgz",
|
||||
"integrity": "sha512-JCDrsP4Z1Sb9JwG0aJ8Eo2r7k4Ou5MwmThS/6lcIe1ICyb7UBJKGRIUUdqc2ASdE/42lgz6zFUnzAIhtXnBVrQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lines-and-columns": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz",
|
||||
@@ -952,9 +967,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/mathml-tag-names": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/mathml-tag-names/-/mathml-tag-names-2.1.3.tgz",
|
||||
"integrity": "sha512-APMBEanjybaPzUrfqU0IMU5I0AswKMH7k8OTLs0vvV4KZpExkTkY87nR/zpbuTPj+gARop7aGUbl11pnDfW6xg==",
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/mathml-tag-names/-/mathml-tag-names-4.0.0.tgz",
|
||||
"integrity": "sha512-aa6AU2Pcx0VP/XWnh8IGL0SYSgQHDT6Ucror2j2mXeFAlN3ahaNs8EZtG1YiticMkSLj3Gt6VPFfZogt7G5iFQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
@@ -970,13 +985,13 @@
|
||||
"license": "CC0-1.0"
|
||||
},
|
||||
"node_modules/meow": {
|
||||
"version": "13.2.0",
|
||||
"resolved": "https://registry.npmjs.org/meow/-/meow-13.2.0.tgz",
|
||||
"integrity": "sha512-pxQJQzB6djGPXh08dacEloMFopsOqGVRKFPYvPOt9XDZ1HasbgDZA74CJGreSU4G3Ak7EFJGoiH2auq+yXISgA==",
|
||||
"version": "14.1.0",
|
||||
"resolved": "https://registry.npmjs.org/meow/-/meow-14.1.0.tgz",
|
||||
"integrity": "sha512-EDYo6VlmtnumlcBCbh1gLJ//9jvM/ndXHfVXIFrZVr6fGcwTUyCTFNTLCKuY3ffbK8L/+3Mzqnd58RojiZqHVw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
"node": ">=20"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
@@ -1074,16 +1089,6 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/path-type": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz",
|
||||
"integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/picocolors": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||
@@ -1133,13 +1138,6 @@
|
||||
"node": "^10 || ^12 || >=14"
|
||||
}
|
||||
},
|
||||
"node_modules/postcss-resolve-nested-selector": {
|
||||
"version": "0.1.6",
|
||||
"resolved": "https://registry.npmjs.org/postcss-resolve-nested-selector/-/postcss-resolve-nested-selector-0.1.6.tgz",
|
||||
"integrity": "sha512-0sglIs9Wmkzbr8lQwEyIzlDOOC9bGmfVKcJTaxv3vMmd3uo4o4DerC3En0bnmgceeql9BfC8hRkp7cg0fjdVqw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/postcss-safe-parser": {
|
||||
"version": "7.0.1",
|
||||
"resolved": "https://registry.npmjs.org/postcss-safe-parser/-/postcss-safe-parser-7.0.1.tgz",
|
||||
@@ -1168,9 +1166,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/postcss-selector-parser": {
|
||||
"version": "7.1.0",
|
||||
"resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.0.tgz",
|
||||
"integrity": "sha512-8sLjZwK0R+JlxlYcTuVnyT2v+htpdrjDOKuMcOVdYjt52Lh8hWRYpxBPoKx/Zg+bcjc3wx6fmQevMmUztS/ccA==",
|
||||
"version": "7.1.1",
|
||||
"resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.1.tgz",
|
||||
"integrity": "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -1189,13 +1187,13 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/qified": {
|
||||
"version": "0.5.2",
|
||||
"resolved": "https://registry.npmjs.org/qified/-/qified-0.5.2.tgz",
|
||||
"integrity": "sha512-7gJ6mxcQb9vUBOtbKm5mDevbe2uRcOEVp1g4gb/Q+oLntB3HY8eBhOYRxFI2mlDFlY1e4DOSCptzxarXRvzxCA==",
|
||||
"version": "0.6.0",
|
||||
"resolved": "https://registry.npmjs.org/qified/-/qified-0.6.0.tgz",
|
||||
"integrity": "sha512-tsSGN1x3h569ZSU1u6diwhltLyfUWDp3YbFHedapTmpBl0B3P6U3+Qptg7xu+v+1io1EwhdPyyRHYbEw0KN2FA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"hookified": "^1.13.0"
|
||||
"hookified": "^1.14.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
@@ -1232,16 +1230,6 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/resolve-from": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz",
|
||||
"integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/reusify": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz",
|
||||
@@ -1291,13 +1279,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/slash": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz",
|
||||
"integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==",
|
||||
"version": "5.1.0",
|
||||
"resolved": "https://registry.npmjs.org/slash/-/slash-5.1.0.tgz",
|
||||
"integrity": "sha512-ZA6oR3T/pEyuqwMgAKT0/hAv8oAXckzbkmR0UkUosQ+Mc4RxGoJkRmwHgHufaenlyAgE1Mxgpdcrf75y6XcnDg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
"node": ">=14.16"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/slice-ansi": {
|
||||
@@ -1364,9 +1355,9 @@
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/stylelint": {
|
||||
"version": "16.26.1",
|
||||
"resolved": "https://registry.npmjs.org/stylelint/-/stylelint-16.26.1.tgz",
|
||||
"integrity": "sha512-v20V59/crfc8sVTAtge0mdafI3AdnzQ2KsWe6v523L4OA1bJO02S7MO2oyXDCS6iWb9ckIPnqAFVItqSBQr7jw==",
|
||||
"version": "17.4.0",
|
||||
"resolved": "https://registry.npmjs.org/stylelint/-/stylelint-17.4.0.tgz",
|
||||
"integrity": "sha512-3kQ2/cHv3Zt8OBg+h2B8XCx9evEABQIrv4hh3uXahGz/ZEHrTR80zxBiK2NfXNaSoyBzxO1pjsz1Vhdzwn5XSw==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@@ -1380,51 +1371,49 @@
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@csstools/css-parser-algorithms": "^3.0.5",
|
||||
"@csstools/css-syntax-patches-for-csstree": "^1.0.19",
|
||||
"@csstools/css-tokenizer": "^3.0.4",
|
||||
"@csstools/media-query-list-parser": "^4.0.3",
|
||||
"@csstools/selector-specificity": "^5.0.0",
|
||||
"@dual-bundle/import-meta-resolve": "^4.2.1",
|
||||
"balanced-match": "^2.0.0",
|
||||
"@csstools/css-calc": "^3.1.1",
|
||||
"@csstools/css-parser-algorithms": "^4.0.0",
|
||||
"@csstools/css-syntax-patches-for-csstree": "^1.0.27",
|
||||
"@csstools/css-tokenizer": "^4.0.0",
|
||||
"@csstools/media-query-list-parser": "^5.0.0",
|
||||
"@csstools/selector-resolve-nested": "^4.0.0",
|
||||
"@csstools/selector-specificity": "^6.0.0",
|
||||
"colord": "^2.9.3",
|
||||
"cosmiconfig": "^9.0.0",
|
||||
"css-functions-list": "^3.2.3",
|
||||
"css-functions-list": "^3.3.3",
|
||||
"css-tree": "^3.1.0",
|
||||
"debug": "^4.4.3",
|
||||
"fast-glob": "^3.3.3",
|
||||
"fastest-levenshtein": "^1.0.16",
|
||||
"file-entry-cache": "^11.1.1",
|
||||
"file-entry-cache": "^11.1.2",
|
||||
"global-modules": "^2.0.0",
|
||||
"globby": "^11.1.0",
|
||||
"globby": "^16.1.0",
|
||||
"globjoin": "^0.1.4",
|
||||
"html-tags": "^3.3.1",
|
||||
"html-tags": "^5.1.0",
|
||||
"ignore": "^7.0.5",
|
||||
"import-meta-resolve": "^4.2.0",
|
||||
"imurmurhash": "^0.1.4",
|
||||
"is-plain-object": "^5.0.0",
|
||||
"known-css-properties": "^0.37.0",
|
||||
"mathml-tag-names": "^2.1.3",
|
||||
"meow": "^13.2.0",
|
||||
"mathml-tag-names": "^4.0.0",
|
||||
"meow": "^14.0.0",
|
||||
"micromatch": "^4.0.8",
|
||||
"normalize-path": "^3.0.0",
|
||||
"picocolors": "^1.1.1",
|
||||
"postcss": "^8.5.6",
|
||||
"postcss-resolve-nested-selector": "^0.1.6",
|
||||
"postcss-safe-parser": "^7.0.1",
|
||||
"postcss-selector-parser": "^7.1.0",
|
||||
"postcss-selector-parser": "^7.1.1",
|
||||
"postcss-value-parser": "^4.2.0",
|
||||
"resolve-from": "^5.0.0",
|
||||
"string-width": "^4.2.3",
|
||||
"supports-hyperlinks": "^3.2.0",
|
||||
"string-width": "^8.1.1",
|
||||
"supports-hyperlinks": "^4.4.0",
|
||||
"svg-tags": "^1.0.0",
|
||||
"table": "^6.9.0",
|
||||
"write-file-atomic": "^5.0.1"
|
||||
"write-file-atomic": "^7.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"stylelint": "bin/stylelint.mjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.12.0"
|
||||
"node": ">=20.19.0"
|
||||
}
|
||||
},
|
||||
"node_modules/stylelint-config-standard": {
|
||||
@@ -1476,31 +1465,168 @@
|
||||
"stylelint": "^17.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/supports-color": {
|
||||
"version": "7.2.0",
|
||||
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
|
||||
"integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
|
||||
"node_modules/stylelint/node_modules/@csstools/css-calc": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.1.1.tgz",
|
||||
"integrity": "sha512-HJ26Z/vmsZQqs/o3a6bgKslXGFAungXGbinULZO3eMsOyNJHeBBZfup5FiZInOghgoM4Hwnmw+OgbJCNg1wwUQ==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/csstools"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/csstools"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=20.19.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@csstools/css-parser-algorithms": "^4.0.0",
|
||||
"@csstools/css-tokenizer": "^4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/stylelint/node_modules/@csstools/css-parser-algorithms": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz",
|
||||
"integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/csstools"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/csstools"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=20.19.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@csstools/css-tokenizer": "^4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/stylelint/node_modules/@csstools/css-tokenizer": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz",
|
||||
"integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/csstools"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/csstools"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=20.19.0"
|
||||
}
|
||||
},
|
||||
"node_modules/stylelint/node_modules/@csstools/media-query-list-parser": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/media-query-list-parser/-/media-query-list-parser-5.0.0.tgz",
|
||||
"integrity": "sha512-T9lXmZOfnam3eMERPsszjY5NK0jX8RmThmmm99FZ8b7z8yMaFZWKwLWGZuTwdO3ddRY5fy13GmmEYZXB4I98Eg==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/csstools"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/csstools"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=20.19.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@csstools/css-parser-algorithms": "^4.0.0",
|
||||
"@csstools/css-tokenizer": "^4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/stylelint/node_modules/ansi-regex": {
|
||||
"version": "6.2.2",
|
||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz",
|
||||
"integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/ansi-regex?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/stylelint/node_modules/string-width": {
|
||||
"version": "8.2.0",
|
||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.0.tgz",
|
||||
"integrity": "sha512-6hJPQ8N0V0P3SNmP6h2J99RLuzrWz2gvT7VnK5tKvrNqJoyS9W4/Fb8mo31UiPvy00z7DQXkP2hnKBVav76thw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"has-flag": "^4.0.0"
|
||||
"get-east-asian-width": "^1.5.0",
|
||||
"strip-ansi": "^7.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
"node": ">=20"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/stylelint/node_modules/strip-ansi": {
|
||||
"version": "7.2.0",
|
||||
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz",
|
||||
"integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-regex": "^6.2.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/strip-ansi?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/supports-color": {
|
||||
"version": "10.2.2",
|
||||
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.2.2.tgz",
|
||||
"integrity": "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/supports-color?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/supports-hyperlinks": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/supports-hyperlinks/-/supports-hyperlinks-3.2.0.tgz",
|
||||
"integrity": "sha512-zFObLMyZeEwzAoKCyu1B91U79K2t7ApXuQfo8OuxwXLDgcKxuwM+YvcbIhm6QWqz7mHUH1TVytR1PwVVjEuMig==",
|
||||
"version": "4.4.0",
|
||||
"resolved": "https://registry.npmjs.org/supports-hyperlinks/-/supports-hyperlinks-4.4.0.tgz",
|
||||
"integrity": "sha512-UKbpT93hN5Nr9go5UY7bopIB9YQlMz9nm/ct4IXt/irb5YRkn9WaqrOBJGZ5Pwvsd5FQzSVeYlGdXoCAPQZrPg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"has-flag": "^4.0.0",
|
||||
"supports-color": "^7.0.0"
|
||||
"has-flag": "^5.0.1",
|
||||
"supports-color": "^10.2.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.18"
|
||||
"node": ">=20"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/supports-hyperlinks?sponsor=1"
|
||||
@@ -1542,6 +1668,19 @@
|
||||
"node": ">=8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/unicorn-magic": {
|
||||
"version": "0.4.0",
|
||||
"resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.4.0.tgz",
|
||||
"integrity": "sha512-wH590V9VNgYH9g3lH9wWjTrUoKsjLF6sGLjhR4sH1LWpLmCOH0Zf7PukhDA8BiS7KHe4oPNkcTHqYkj7SOGUOw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/util-deprecate": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
||||
@@ -1563,17 +1702,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/write-file-atomic": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-5.0.1.tgz",
|
||||
"integrity": "sha512-+QU2zd6OTD8XWIJCbffaiQeH9U73qIqafo1x6V1snCWYGJf6cVE0cDR4D8xRzcEnfI21IFrUPzPGtcPf8AC+Rw==",
|
||||
"version": "7.0.1",
|
||||
"resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-7.0.1.tgz",
|
||||
"integrity": "sha512-OTIk8iR8/aCRWBqvxrzxR0hgxWpnYBblY1S5hDWBQfk/VFmJwzmJgQFN3WsoUKHISv2eAwe+PpbUzyL1CKTLXg==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"imurmurhash": "^0.1.4",
|
||||
"signal-exit": "^4.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^14.17.0 || ^16.13.0 || >=18.0.0"
|
||||
"node": "^20.17.0 || >=22.9.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@stylistic/stylelint-plugin": "^4.0.0",
|
||||
"stylelint": "^16.26.1",
|
||||
"stylelint": "^17.4.0",
|
||||
"stylelint-config-standard": "^40.0.0"
|
||||
}
|
||||
}
|
||||
|
||||
+255
-81
@@ -1,8 +1,9 @@
|
||||
# Stirling-PDF - Full version (embedded frontend)
|
||||
|
||||
FROM ubuntu:noble AS calibre-build
|
||||
|
||||
ARG CALIBRE_VERSION=9.3.1
|
||||
ARG TARGETPLATFORM
|
||||
ARG CALIBRE_VERSION=9.4.0
|
||||
ARG CALIBRE_STRIP_WEBENGINE=false
|
||||
|
||||
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
|
||||
set -eux; \
|
||||
@@ -10,7 +11,8 @@ RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
|
||||
apt-get install -y --no-install-recommends \
|
||||
ca-certificates curl xz-utils libnss3 libfontconfig1 \
|
||||
libgl1 libegl1 libdbus-1-3 libasound2t64 libxcomposite1 \
|
||||
libxrandr2 libxkbcommon0 libxi6 libxtst6 libopengl0; \
|
||||
libxrandr2 libxkbcommon0 libxi6 libxtst6 libopengl0 \
|
||||
poppler-utils; \
|
||||
rm -rf /var/lib/apt/lists/*; \
|
||||
\
|
||||
case "$(uname -m)" in \
|
||||
@@ -26,8 +28,8 @@ RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
|
||||
tar xJf /tmp/calibre.txz -C /opt/calibre; \
|
||||
rm /tmp/calibre.txz; \
|
||||
\
|
||||
# Remove GUI-only shared libraries.
|
||||
# Libs required by WebEngine PDF output are preserved.
|
||||
# We only need Qt6 WebEngine (Chromium) for ebook->PDF output.
|
||||
# PDF INPUT now uses the pdftohtml engine (poppler), not Qt.
|
||||
rm -f /opt/calibre/lib/libQt6Designer* \
|
||||
/opt/calibre/lib/libQt6Multimedia* \
|
||||
/opt/calibre/lib/libQt6SpatialAudio.so.* \
|
||||
@@ -35,6 +37,27 @@ RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
|
||||
/opt/calibre/lib/libQt6Concurrent.so.* \
|
||||
/opt/calibre/lib/libQt6OpenGLWidgets.so.* \
|
||||
/opt/calibre/lib/libQt6QuickWidgets.so.* \
|
||||
/opt/calibre/lib/libQt6Svg.so.* \
|
||||
/opt/calibre/lib/libQt6SvgWidgets.so.* \
|
||||
/opt/calibre/lib/libQt6Pdf*.so.* \
|
||||
/opt/calibre/lib/libQt6ShaderTools.so.* \
|
||||
/opt/calibre/lib/libQt6SerialPort.so.* \
|
||||
/opt/calibre/lib/libQt6Sensors.so.* \
|
||||
/opt/calibre/lib/libQt6Test.so.* \
|
||||
/opt/calibre/lib/libQt6Sql.so.* \
|
||||
/opt/calibre/lib/libQt6RemoteObjects.so.* \
|
||||
/opt/calibre/lib/libQt6Help.so.* \
|
||||
/opt/calibre/lib/libQt6VirtualKeyboard.so.* \
|
||||
/opt/calibre/lib/libQt6WaylandClient.so.* \
|
||||
/opt/calibre/lib/libQt6WaylandCompositor.so.* \
|
||||
/opt/calibre/lib/libQt6Bluetooth.so.* \
|
||||
/opt/calibre/lib/libQt6Nfc.so.* \
|
||||
/opt/calibre/lib/libQt6Charts.so.* \
|
||||
/opt/calibre/lib/libQt6DataVisualization.so.* \
|
||||
/opt/calibre/lib/libQt6Scxml.so.* \
|
||||
/opt/calibre/lib/libQt6StateMachine.so.* \
|
||||
/opt/calibre/lib/libQt6TextToSpeech.so.* \
|
||||
/opt/calibre/lib/libQt63D*.so.* \
|
||||
/opt/calibre/lib/libavcodec.so.* \
|
||||
/opt/calibre/lib/libavfilter.so.* \
|
||||
/opt/calibre/lib/libavformat.so.* \
|
||||
@@ -65,11 +88,39 @@ RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
|
||||
/opt/calibre/lib/libgpg-error.so.* \
|
||||
/opt/calibre/lib/libicuio.so.* \
|
||||
/opt/calibre/lib/libreadline.so.* \
|
||||
/opt/calibre/lib/libusb-1.0.so.*; \
|
||||
/opt/calibre/lib/libusb-1.0.so.* \
|
||||
/opt/calibre/lib/libpulse*.so.* \
|
||||
/opt/calibre/lib/libsndfile.so.* \
|
||||
/opt/calibre/lib/libmpv.so.* \
|
||||
/opt/calibre/lib/libass.so.* \
|
||||
/opt/calibre/lib/librubberband.so.* \
|
||||
/opt/calibre/lib/libsamplerate.so.*; \
|
||||
rm -rf /opt/calibre/lib/qt6/plugins/platformthemes \
|
||||
/opt/calibre/lib/qt6/plugins/multimedia \
|
||||
/opt/calibre/lib/qt6/plugins/designer \
|
||||
/opt/calibre/lib/qt6/plugins/qmltooling; \
|
||||
/opt/calibre/lib/qt6/plugins/virtualkeyboard \
|
||||
/opt/calibre/lib/qt6/plugins/wayland* \
|
||||
/opt/calibre/lib/qt6/plugins/texttospeech \
|
||||
/opt/calibre/lib/qt6/plugins/position \
|
||||
/opt/calibre/lib/qt6/plugins/sensors \
|
||||
/opt/calibre/lib/qt6/plugins/sqldrivers \
|
||||
/opt/calibre/lib/qt6/plugins/canbus \
|
||||
/opt/calibre/lib/qt6/plugins/sceneparsers \
|
||||
/opt/calibre/lib/qt6/plugins/renderers \
|
||||
/opt/calibre/lib/qt6/plugins/geometryloaders \
|
||||
/opt/calibre/lib/qt6/plugins/generic \
|
||||
/opt/calibre/lib/qt6/plugins/qmltooling \
|
||||
/opt/calibre/lib/qt6/libexec/QtWebEngineProcess.bak; \
|
||||
rm -rf /opt/calibre/plugins/sqldrivers \
|
||||
/opt/calibre/plugins/multimedia \
|
||||
/opt/calibre/plugins/wayland-shell-integration \
|
||||
/opt/calibre/plugins/wayland-graphics-integration-client \
|
||||
/opt/calibre/plugins/wayland-decoration-client \
|
||||
/opt/calibre/plugins/texttospeech \
|
||||
/opt/calibre/plugins/platformthemes \
|
||||
/opt/calibre/plugins/platforminputcontexts \
|
||||
/opt/calibre/plugins/egldeviceintegrations \
|
||||
/opt/calibre/plugins/iconengines; \
|
||||
\
|
||||
# Remove GUI executables but keep ebook-convert, ebook-meta, and calibre-parallel.
|
||||
rm -f /opt/calibre/calibre \
|
||||
@@ -95,7 +146,11 @@ RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
|
||||
/opt/calibre/lib/calibre/db \
|
||||
/opt/calibre/lib/calibre/srv \
|
||||
/opt/calibre/lib/calibre/spell \
|
||||
/opt/calibre/lib/calibre/live; \
|
||||
/opt/calibre/lib/calibre/live \
|
||||
/opt/calibre/lib/calibre/utils/piper \
|
||||
/opt/calibre/lib/calibre/utils/certgen.so \
|
||||
/opt/calibre/lib/calibre/utils/https \
|
||||
/opt/calibre/lib/calibre/utils/mdns; \
|
||||
\
|
||||
# Remove resources not needed for CLI conversion.
|
||||
rm -rf /opt/calibre/resources/images \
|
||||
@@ -143,15 +198,18 @@ RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
|
||||
/opt/calibre/resources/user-manual-translation-stats.json \
|
||||
/opt/calibre/resources/pin-template.svg \
|
||||
/opt/calibre/resources/scripts.calibre_msgpack \
|
||||
/opt/calibre/resources/fonts \
|
||||
/opt/calibre/resources/qtwebengine_devtools_resources.pak \
|
||||
/opt/calibre/lib/calibre/ebooks/docx/images \
|
||||
/opt/calibre/share \
|
||||
/opt/calibre/man; \
|
||||
\
|
||||
# Remove translations and localization while keeping required libraries.
|
||||
# Keep iso639.calibre_msgpack (required)
|
||||
# Keep qtwebengine_locales (required for WebEngine)
|
||||
# Keep only en-US.pak from qtwebengine_locales (required for WebEngine)
|
||||
rm -rf /opt/calibre/lib/qt6/translations; \
|
||||
find /opt/calibre/translations -mindepth 1 -maxdepth 1 ! -name 'qtwebengine_locales' -exec rm -rf {} +; \
|
||||
find /opt/calibre/translations/qtwebengine_locales -type f ! -name 'en-US.pak' -delete 2>/dev/null || true; \
|
||||
if [ -d /opt/calibre/resources/localization ]; then \
|
||||
rm -rf /opt/calibre/resources/localization/locales.zip \
|
||||
/opt/calibre/resources/localization/stats.calibre_msgpack \
|
||||
@@ -172,7 +230,7 @@ RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
|
||||
find /opt/calibre -name '*.pyc' -delete 2>/dev/null || true; \
|
||||
\
|
||||
# ── Verify conversion still works ──
|
||||
# NOTE: txt→epub used intentionally NOT txt→pdf.
|
||||
# NOTE: txt->epub used intentionally NOT txt->pdf.
|
||||
# Calibre 7+ uses WebEngine (Chromium) for PDF output, which requires kernel
|
||||
# capabilities unavailable in Docker RUN steps and segfaults under QEMU.
|
||||
# epub output exercises the same Python/plugin stack without touching WebEngine.
|
||||
@@ -180,8 +238,26 @@ RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
|
||||
echo "Hello" > /tmp/test.txt; \
|
||||
/opt/calibre/ebook-convert /tmp/test.txt /tmp/test.epub; \
|
||||
rm -f /tmp/test.txt /tmp/test.epub; \
|
||||
\
|
||||
# Verify pdftohtml (poppler) is available for the pdftohtml PDF engine.
|
||||
pdftohtml -v >/dev/null 2>&1 && echo "pdftohtml OK" || { echo "ERROR: pdftohtml not found"; exit 1; }; \
|
||||
echo "=== Calibre stripped successfully ==="
|
||||
|
||||
# Optional: strip Chromium/WebEngine (~80 MB savings) when PDF output via Calibre is not needed.
|
||||
# Build with --build-arg CALIBRE_STRIP_WEBENGINE=true to enable.
|
||||
RUN if [ "${CALIBRE_STRIP_WEBENGINE}" = "true" ]; then \
|
||||
echo "Stripping Calibre WebEngine (Chromium), PDF output via Calibre will be disabled"; \
|
||||
rm -rf /opt/calibre/lib/qt6/libexec/QtWebEngineProcess \
|
||||
/opt/calibre/lib/qt6/resources \
|
||||
/opt/calibre/lib/libQt6WebEngine*.so.* \
|
||||
/opt/calibre/lib/libQt6Quick*.so.* \
|
||||
/opt/calibre/lib/libQt6Qml*.so.* \
|
||||
/opt/calibre/translations/qtwebengine_locales 2>/dev/null || true; \
|
||||
echo "WebEngine stripped, Calibre PDF output disabled"; \
|
||||
else \
|
||||
echo "CALIBRE_STRIP_WEBENGINE=false, keeping WebEngine for PDF output"; \
|
||||
fi
|
||||
|
||||
|
||||
# Build the Java application and frontend.
|
||||
FROM gradle:9.3.1-jdk25 AS app-build
|
||||
@@ -218,6 +294,7 @@ COPY . .
|
||||
|
||||
RUN --mount=type=cache,target=/home/gradle/.gradle/caches \
|
||||
--mount=type=cache,target=/home/gradle/.gradle/wrapper \
|
||||
--mount=type=cache,target=/root/.npm,sharing=locked \
|
||||
DISABLE_ADDITIONAL_FEATURES=false \
|
||||
gradle clean build \
|
||||
-PbuildWithFrontend=true \
|
||||
@@ -233,6 +310,7 @@ RUN java -Djarmode=tools -jar app.jar extract --layers --destination /layers
|
||||
|
||||
# Build Ghostscript 10.06.0 from source in an isolated stage (avoids library conflicts).
|
||||
FROM ubuntu:noble AS gs-build
|
||||
ARG TARGETPLATFORM
|
||||
ARG GS_VERSION=10.06.0
|
||||
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
|
||||
--mount=type=cache,target=/tmp/gs-build,id=gs-build-${TARGETPLATFORM:-local} \
|
||||
@@ -255,6 +333,7 @@ RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
|
||||
|
||||
# Build PDF Tools (QPDF and ImageMagick 7).
|
||||
FROM ubuntu:noble AS pdf-tools-build
|
||||
ARG TARGETPLATFORM
|
||||
ARG QPDF_VERSION=12.3.2
|
||||
ARG IM_VERSION=7.1.2-13
|
||||
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
|
||||
@@ -270,14 +349,14 @@ RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
|
||||
cd "qpdf-${QPDF_VERSION}" && \
|
||||
cmake -S . -B build -DCMAKE_BUILD_TYPE=Release -DBUILD_SHARED_LIBS=OFF -DALLOW_CRYPTO_OPENSSL=ON -DDEFAULT_CRYPTO=openssl && \
|
||||
cmake --build build --parallel "$(nproc)" && \
|
||||
cmake --install build && \
|
||||
cmake --install build --strip && \
|
||||
cd .. && \
|
||||
# Build ImageMagick 7
|
||||
(test -d "ImageMagick-${IM_VERSION}" || curl -fsSL "https://github.com/ImageMagick/ImageMagick/archive/refs/tags/${IM_VERSION}.tar.gz" | tar xz) && \
|
||||
cd "ImageMagick-${IM_VERSION}" && \
|
||||
./configure --prefix=/usr/local --with-modules --with-perl=no --with-magick-plus-plus=no --with-quantum-depth=16 --disable-static --enable-shared && \
|
||||
make -j"$(nproc)" && \
|
||||
make install && \
|
||||
make install-strip && \
|
||||
# Enable PDF/PS/EPS in policy
|
||||
sed -i 's/rights="none" pattern="PDF"/rights="read|write" pattern="PDF"/' /usr/local/etc/ImageMagick-7/policy.xml && \
|
||||
sed -i 's/rights="none" pattern="PS"/rights="read|write" pattern="PS"/' /usr/local/etc/ImageMagick-7/policy.xml && \
|
||||
@@ -285,6 +364,44 @@ RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
|
||||
cd .. && \
|
||||
ldconfig /usr/local/lib
|
||||
|
||||
# Stage ImageMagick outputs into a single directory so runtime can import them with one COPY
|
||||
# (reduces 4 separate COPY layers to 1 independent --link layer).
|
||||
RUN mkdir -p /magick-export/usr/bin \
|
||||
/magick-export/usr/local/lib \
|
||||
/magick-export/usr/local/etc && \
|
||||
cp /usr/local/bin/magick /magick-export/usr/bin/ && \
|
||||
cp -a /usr/local/lib/libMagick*.so* /magick-export/usr/local/lib/ && \
|
||||
cp -a /usr/local/lib/ImageMagick-7* /magick-export/usr/local/lib/ && \
|
||||
cp -a /usr/local/etc/ImageMagick-7 /magick-export/usr/local/etc/
|
||||
|
||||
|
||||
# Build Python venv in an isolated stage so runtime image never needs build tools.
|
||||
# Packages with native extensions (opencv, cryptography) use pre-built wheels (--prefer-binary).
|
||||
# python3-uno is intentionally NOT installed here, it is a system package in the runtime stage
|
||||
# and accessed via --system-site-packages at runtime.
|
||||
FROM ubuntu:noble AS python-venv-build
|
||||
ARG TARGETPLATFORM
|
||||
ARG UNOSERVER_VERSION=3.6
|
||||
|
||||
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
|
||||
apt-get update && apt-get install -y --no-install-recommends \
|
||||
python3 python3-venv ca-certificates binutils && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
RUN --mount=type=cache,target=/root/.cache/pip,sharing=locked \
|
||||
python3 -m venv /opt/venv --system-site-packages && \
|
||||
/opt/venv/bin/pip install --no-cache-dir --prefer-binary \
|
||||
weasyprint pdf2image opencv-python-headless ocrmypdf \
|
||||
cryptography \
|
||||
"unoserver==${UNOSERVER_VERSION}" && \
|
||||
find /opt/venv -type d -name __pycache__ -exec rm -rf {} + 2>/dev/null || true && \
|
||||
find /opt/venv \( -name '*.pyc' -o -name '*.pyi' \) -delete 2>/dev/null || true && \
|
||||
rm -rf /opt/venv/lib/python*/site-packages/pip \
|
||||
/opt/venv/lib/python*/site-packages/pip-*.dist-info \
|
||||
/opt/venv/lib/python*/site-packages/setuptools \
|
||||
/opt/venv/lib/python*/site-packages/setuptools-*.dist-info && \
|
||||
find /opt/venv -name '*.so' -exec strip --strip-unneeded {} + 2>/dev/null || true
|
||||
|
||||
|
||||
# Final runtime image.
|
||||
FROM eclipse-temurin:25-jre-noble AS runtime
|
||||
@@ -316,10 +433,11 @@ RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
|
||||
fonts-crosextra-caladea fonts-crosextra-carlito \
|
||||
fonts-noto-core fonts-noto-mono fonts-noto-extra \
|
||||
fonts-noto-cjk poppler-data \
|
||||
# We install these via apt to avoid downloading "fat wheels" from pip
|
||||
# python3-full replaced with minimal set
|
||||
python3 python3-dev python3-venv python3-uno \
|
||||
# Python dependencies via pip to avoid conflicts, so we don't install them here
|
||||
# python3-uno required for UNO bridge (accessed by venv via --system-site-packages)
|
||||
# python3-venv is NOT needed: the copied /opt/venv works without it at runtime
|
||||
# python3-dev is NOT needed, venv is pre-built in python-venv-build stage
|
||||
python3 python3-uno \
|
||||
# Python packages are in /opt/venv (copied from python-venv-build stage below)
|
||||
# OCR
|
||||
tesseract-ocr tesseract-ocr-eng tesseract-ocr-deu tesseract-ocr-fra \
|
||||
tesseract-ocr-por tesseract-ocr-chi-sim \
|
||||
@@ -340,27 +458,19 @@ RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
|
||||
; \
|
||||
\
|
||||
\
|
||||
# Note: We do NOT install numpy/pillow/cv2 here; it uses the system versions
|
||||
python3 -m venv /opt/venv --system-site-packages; \
|
||||
/opt/venv/bin/pip install --no-cache-dir \
|
||||
weasyprint pdf2image opencv-python-headless ocrmypdf \
|
||||
"unoserver==${UNOSERVER_VERSION}"; \
|
||||
\
|
||||
ln -sf /opt/venv/bin/unoconvert /usr/local/bin/unoconvert; \
|
||||
ln -sf /opt/venv/bin/unoserver /usr/local/bin/unoserver; \
|
||||
\
|
||||
# Verify and fix LibreOffice
|
||||
libreoffice --version; \
|
||||
soffice --version 2>/dev/null || true; \
|
||||
# Rebuild UNO bridge type database
|
||||
/usr/lib/libreoffice/program/soffice.bin --headless --convert-to pdf /dev/null 2>/dev/null || true; \
|
||||
# Force font cache rebuild and verify filters are available
|
||||
# Force font cache rebuild
|
||||
fc-cache -f -v 2>&1 | awk 'NR <= 20'; \
|
||||
/opt/venv/bin/python -c "import cv2; print('OpenCV', cv2.__version__)"; \
|
||||
/opt/venv/bin/python -c "import ocrmypdf; print('ocrmypdf OK')"; \
|
||||
\
|
||||
# Cleanup stage.
|
||||
\
|
||||
# Remove PPA helper, no longer needed after apt-get update
|
||||
apt-get remove --purge -y software-properties-common || true; \
|
||||
apt-get autoremove --purge -y || true; \
|
||||
rm -rf /var/lib/apt/lists/*; \
|
||||
\
|
||||
# Docs / man / info / icons / themes / GUI assets (headless server)
|
||||
@@ -370,15 +480,31 @@ RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
|
||||
/usr/share/javascript/* \
|
||||
/usr/share/gtk-3.0/* \
|
||||
/usr/share/fontforge/pixmaps \
|
||||
/usr/share/fontforge/osx \
|
||||
/usr/share/fontforge/cidmap \
|
||||
/usr/share/fontforge/prefs \
|
||||
/usr/share/liblangtag/* \
|
||||
/usr/share/tcltk/* \
|
||||
/usr/share/python-wheels/*; \
|
||||
/usr/share/python-wheels/* \
|
||||
/usr/share/glib-2.0/schemas/* \
|
||||
/usr/share/mime/* \
|
||||
/usr/share/xml/iso-codes \
|
||||
/usr/share/GConf \
|
||||
/usr/share/bash-completion \
|
||||
/usr/share/zsh \
|
||||
/usr/share/libmysofa \
|
||||
/usr/share/alsa \
|
||||
/usr/share/iso-codes \
|
||||
/usr/share/perl5 \
|
||||
/usr/share/libthai \
|
||||
/usr/share/libexttextcat \
|
||||
/usr/share/openal \
|
||||
/usr/share/gcc; \
|
||||
\
|
||||
find /usr/share/locale -mindepth 1 -maxdepth 1 -type d \
|
||||
! -name 'en*' -exec rm -rf {} + 2>/dev/null || true; \
|
||||
rm -rf /usr/share/i18n/locales /usr/share/i18n/charmaps; \
|
||||
\
|
||||
# LibreOffice extras: Only remove specific directories as requested
|
||||
rm -rf /usr/lib/libreoffice/share/gallery \
|
||||
/usr/lib/libreoffice/share/template \
|
||||
/usr/lib/libreoffice/share/wizards \
|
||||
@@ -401,43 +527,36 @@ RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
|
||||
/usr/lib/libreoffice/share/dtd \
|
||||
/usr/lib/libreoffice/share/tipoftheday \
|
||||
/usr/lib/libreoffice/share/toolbarmode \
|
||||
/usr/lib/libreoffice/share/psprint; \
|
||||
/usr/lib/libreoffice/share/psprint \
|
||||
/usr/lib/libreoffice/CREDITS.fodt \
|
||||
/usr/lib/libreoffice/LICENSE.html; \
|
||||
\
|
||||
# Preserving soffice.cfg because LibreOffice needs it to load documents.
|
||||
# Remove unused LO extensions (GUI-only; not needed for document conversion)
|
||||
rm -rf /usr/lib/libreoffice/share/extensions/wiki-publisher \
|
||||
/usr/lib/libreoffice/share/extensions/nlpsolver \
|
||||
/usr/lib/libreoffice/share/extensions/dict-* 2>/dev/null || true; \
|
||||
# Remove LO database components (LO Base; not needed for Writer/Calc/Impress conversion)
|
||||
rm -rf /usr/lib/libreoffice/program/libdba* \
|
||||
/usr/lib/libreoffice/program/libdbahsql* \
|
||||
/usr/lib/libreoffice/program/libdbu* \
|
||||
/usr/lib/libreoffice/program/libreport* 2>/dev/null || true; \
|
||||
\
|
||||
\
|
||||
# Python caches + pip/setuptools cleanup
|
||||
find /opt/venv -type d -name __pycache__ \
|
||||
-exec rm -rf {} + 2>/dev/null || true; \
|
||||
find /opt/venv \
|
||||
\( -name '*.pyc' -o -name '*.pyi' \) -delete 2>/dev/null || true; \
|
||||
rm -rf /opt/venv/lib/python*/site-packages/pip \
|
||||
/opt/venv/lib/python*/site-packages/pip-*.dist-info \
|
||||
/opt/venv/lib/python*/site-packages/setuptools \
|
||||
/opt/venv/lib/python*/site-packages/setuptools-*.dist-info; \
|
||||
\
|
||||
# Python stdlib: remove unused modules (~71 MB)
|
||||
rm -rf /usr/lib/python3.12/test \
|
||||
/usr/lib/python3.12/idlelib \
|
||||
/usr/lib/python3.12/tkinter \
|
||||
/usr/lib/python3.12/lib2to3 \
|
||||
/usr/lib/python3.12/pydoc_data; \
|
||||
\
|
||||
# System Python packages not needed at runtime (~153 MB)
|
||||
rm -rf /usr/lib/python3/dist-packages/scipy \
|
||||
/usr/lib/python3/dist-packages/sympy \
|
||||
/usr/lib/python3/dist-packages/mpmath; \
|
||||
\
|
||||
# Remove system cffi superseded by venv cffi 2.0
|
||||
rm -rf \
|
||||
/usr/lib/python3/dist-packages/cffi \
|
||||
/usr/lib/python3/dist-packages/cffi-*.dist-info \
|
||||
/usr/lib/python3/dist-packages/_cffi_backend*.so \
|
||||
/usr/lib/python3/dist-packages/_cffi_backend*.cpython*.so \
|
||||
2>/dev/null || true; \
|
||||
# Verify cffi is still importable from the venv after system package removal
|
||||
/opt/venv/bin/python -c "import cffi; print('cffi OK:', cffi.__version__)" \
|
||||
|| { echo 'ERROR: cffi broken after system package cleanup'; exit 1; }; \
|
||||
\
|
||||
# Strip debug symbols from ALL shared libraries
|
||||
find /usr/lib -name '*.so*' -type f \
|
||||
@@ -455,16 +574,12 @@ RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
|
||||
"${MULTIARCH_LIBDIR}"/libgallium*.so* \
|
||||
2>/dev/null || true; \
|
||||
\
|
||||
# System-wide Python cache cleanup
|
||||
find /usr/lib/python3* -type d -name __pycache__ \
|
||||
-exec rm -rf {} + 2>/dev/null || true; \
|
||||
find /usr/lib/python3* \( -name '*.pyc' -o -name '*.pyi' \) \
|
||||
-delete 2>/dev/null || true; \
|
||||
\
|
||||
# Additional metadata cleanup
|
||||
# FIX: Only remove ImageMagick doc/www, NOT the whole dir (preserves policy.xml/delegates.xml)
|
||||
rm -rf /usr/share/bug /usr/share/lintian /usr/share/linda \
|
||||
/var/lib/dpkg/info/*.md5sums \
|
||||
/var/log/dpkg.log /var/log/apt/* \
|
||||
/usr/local/share/ghostscript/*/doc \
|
||||
/usr/local/share/ghostscript/*/examples \
|
||||
@@ -475,24 +590,77 @@ RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
|
||||
# NEW: Tesseract training configs (not needed for OCR, but keep configs/ for hocr/txt output)
|
||||
rm -rf /usr/share/tesseract-ocr/*/tessdata/tessconfigs; \
|
||||
\
|
||||
# Trim CJK fonts to Regular weight only (FIX: Broadened path)
|
||||
find /usr/share/fonts -name '*CJK*' \
|
||||
! -name '*Regular*' -type f -delete 2>/dev/null || true; \
|
||||
# Noto fonts ship 1800+ files in many weights (Bold, Italic, SemiBold, etc.)
|
||||
# For PDF processing, Regular weight covers all scripts. Saves ~370MB.
|
||||
find /usr/share/fonts/truetype/noto -type f \
|
||||
! -name '*Regular*' -delete 2>/dev/null || true; \
|
||||
find /usr/share/fonts/opentype -type f \
|
||||
! -name '*Regular*' -delete 2>/dev/null || true; \
|
||||
# DejaVu: keep Regular and Bold only (commonly referenced in PDFs)
|
||||
find /usr/share/fonts/truetype -name '*DejaVu*' \
|
||||
! -name '*-Regular*' ! -name '*-Bold*' ! -name '*Bold.ttf' \
|
||||
! -name 'DejaVuSans.ttf' ! -name 'DejaVuSerif.ttf' ! -name 'DejaVuSansMono.ttf' \
|
||||
-type f -delete 2>/dev/null || true; \
|
||||
# Remove empty font directories after cleanup
|
||||
find /usr/share/fonts -type d -empty -delete 2>/dev/null || true; \
|
||||
\
|
||||
# ── gconv: keep only essential charset conversion modules (~6MB savings) ──
|
||||
# PDF processing needs UTF-8, ISO-8859-*, and a few CJK encodings.
|
||||
GCONV_DIR=$(find /usr/lib -type d -name gconv 2>/dev/null | head -1); \
|
||||
if [ -n "$GCONV_DIR" ] && [ -d "$GCONV_DIR" ]; then \
|
||||
mkdir -p /tmp/gconv-keep; \
|
||||
for mod in UTF-8.so UTF-16.so UTF-32.so UTF-7.so \
|
||||
ISO8859-1.so ISO8859-15.so ISO8859-2.so ISO8859-9.so \
|
||||
UNICODE.so CP1252.so CP1251.so CP1250.so \
|
||||
EUC-JP.so EUC-KR.so EUC-CN.so \
|
||||
SHIFT_JIS.so GB18030.so BIG5.so \
|
||||
gconv-modules gconv-modules.d gconv-modules.cache; do \
|
||||
[ -e "$GCONV_DIR/$mod" ] && \
|
||||
cp -a "$GCONV_DIR/$mod" /tmp/gconv-keep/ 2>/dev/null || true; \
|
||||
done; \
|
||||
rm -rf "$GCONV_DIR"/*; \
|
||||
cp -a /tmp/gconv-keep/* "$GCONV_DIR/" 2>/dev/null || true; \
|
||||
rm -rf /tmp/gconv-keep; \
|
||||
fi; \
|
||||
\
|
||||
# NOTE: flite TTS voice libs (~26MB) are kept because ffmpeg directly links them.
|
||||
# Removing them breaks ffmpeg startup. To save these 26MB, ffmpeg would need
|
||||
# to be rebuilt without --enable-libflite (not worth the complexity).
|
||||
\
|
||||
# ── dpkg metadata cleanup (~14MB) ──
|
||||
# Not needed at runtime, container won't run apt-get.
|
||||
rm -rf /var/lib/dpkg/info/*.list \
|
||||
/var/lib/dpkg/info/*.md5sums \
|
||||
/var/lib/dpkg/info/*.conffiles \
|
||||
/var/lib/dpkg/info/*.postinst \
|
||||
/var/lib/dpkg/info/*.preinst \
|
||||
/var/lib/dpkg/info/*.prerm \
|
||||
/var/lib/dpkg/info/*.postrm \
|
||||
/var/lib/dpkg/info/*.triggers \
|
||||
/var/lib/dpkg/info/*.shlibs \
|
||||
/var/lib/dpkg/info/*.symbols \
|
||||
/var/lib/dpkg/info/*.templates; \
|
||||
\
|
||||
# Misc caches
|
||||
rm -rf /var/cache/fontconfig/* /tmp/*
|
||||
|
||||
# Calibre and QPDF tools.
|
||||
COPY --from=calibre-build /opt/calibre /opt/calibre
|
||||
COPY --from=pdf-tools-build /usr/local/bin/qpdf /usr/bin/qpdf
|
||||
COPY --from=pdf-tools-build /usr/local/bin/magick /usr/bin/magick
|
||||
COPY --from=pdf-tools-build /usr/local/lib/libMagick* /usr/local/lib/
|
||||
# Copy loadable coder/filter modules (required when built with --with-modules)
|
||||
COPY --from=pdf-tools-build /usr/local/lib/ImageMagick-7* /usr/local/lib/
|
||||
COPY --from=pdf-tools-build /usr/local/etc/ImageMagick-7 /usr/local/etc/ImageMagick-7
|
||||
COPY --from=gs-build /usr/local/bin/gs /usr/local/bin/gs
|
||||
COPY --from=gs-build /usr/local/share/ghostscript /usr/local/share/ghostscript
|
||||
RUN ldconfig /usr/local/lib
|
||||
# External tool layers, all use --link for independent layer caching and parallel pulls.
|
||||
COPY --link --from=calibre-build /opt/calibre /opt/calibre
|
||||
COPY --link --from=pdf-tools-build /usr/local/bin/qpdf /usr/bin/qpdf
|
||||
# ImageMagick: 4 layers collapsed to 1 via the magick-export staging dir in pdf-tools-build
|
||||
COPY --link --from=pdf-tools-build /magick-export/ /
|
||||
COPY --link --from=gs-build /usr/local/bin/gs /usr/local/bin/gs
|
||||
COPY --link --from=gs-build /usr/local/share/ghostscript /usr/local/share/ghostscript
|
||||
# Python venv pre-built in python-venv-build (no pip install at runtime, no build tools needed)
|
||||
COPY --link --from=python-venv-build /opt/venv /opt/venv
|
||||
RUN ldconfig /usr/local/lib && \
|
||||
PYTHONDONTWRITEBYTECODE=1 \
|
||||
/opt/venv/bin/python -c "import cffi; print('cffi OK:', cffi.__version__)" && \
|
||||
PYTHONDONTWRITEBYTECODE=1 \
|
||||
/opt/venv/bin/python -c "import cv2; print('OpenCV', cv2.__version__)" && \
|
||||
PYTHONDONTWRITEBYTECODE=1 \
|
||||
/opt/venv/bin/python -c "import ocrmypdf; print('ocrmypdf OK')" && \
|
||||
find /opt/venv -type d -name __pycache__ -exec rm -rf {} + 2>/dev/null || true
|
||||
|
||||
# ---
|
||||
# Non-root user
|
||||
@@ -515,16 +683,16 @@ RUN set -eux; \
|
||||
|
||||
# Application files.
|
||||
WORKDIR /app
|
||||
COPY --from=jar-extract --chown=1000:1000 /layers/dependencies/ /app/
|
||||
COPY --from=jar-extract --chown=1000:1000 /layers/spring-boot-loader/ /app/
|
||||
COPY --from=jar-extract --chown=1000:1000 /layers/snapshot-dependencies/ /app/
|
||||
COPY --from=jar-extract --chown=1000:1000 /layers/application/ /app/
|
||||
COPY --link --from=jar-extract --chown=1000:1000 /layers/dependencies/ /app/
|
||||
COPY --link --from=jar-extract --chown=1000:1000 /layers/spring-boot-loader/ /app/
|
||||
COPY --link --from=jar-extract --chown=1000:1000 /layers/snapshot-dependencies/ /app/
|
||||
COPY --link --from=jar-extract --chown=1000:1000 /layers/application/ /app/
|
||||
|
||||
COPY --from=app-build --chown=1000:1000 \
|
||||
COPY --link --from=app-build --chown=1000:1000 \
|
||||
/app/build/libs/restart-helper.jar /restart-helper.jar
|
||||
COPY --chown=1000:1000 scripts/ /scripts/
|
||||
COPY --link --chown=1000:1000 scripts/ /scripts/
|
||||
|
||||
# Fonts go to system dir — root ownership is correct (world-readable)
|
||||
# Fonts go to system dir, root ownership is correct (world-readable)
|
||||
COPY app/core/src/main/resources/static/fonts/*.ttf /usr/share/fonts/truetype/
|
||||
|
||||
# Permissions and configuration.
|
||||
@@ -536,7 +704,7 @@ RUN set -eux; \
|
||||
ln -sf /opt/venv/bin/weasyprint /usr/local/bin/weasyprint; \
|
||||
ln -sf /opt/venv/bin/unoping /usr/local/bin/unoping; \
|
||||
chmod +x /scripts/*; \
|
||||
mkdir -p /configs /logs /customFiles \
|
||||
mkdir -p /configs /configs/cache /configs/heap_dumps /logs /customFiles \
|
||||
/pipeline/watchedFolders /pipeline/finishedFolders \
|
||||
/tmp/stirling-pdf/heap_dumps; \
|
||||
# Create symlinks to allow app to find these in /app/
|
||||
@@ -553,15 +721,21 @@ RUN set -eux; \
|
||||
chmod 750 /tmp/stirling-pdf/heap_dumps; \
|
||||
fc-cache -f
|
||||
# NOTE: Project Leyden AOT cache is generated in the background on first boot
|
||||
# by init-without-ocr.sh. The cache is picked up on subsequent boots for
|
||||
# 15-25% faster startup. See: JEP 483 + 514 + 515 (JDK 25).
|
||||
# by init-without-ocr.sh and stored in /configs/cache/stirling.aot (persistent volume).
|
||||
# The cache is picked up on subsequent boots for 15-25% faster startup.
|
||||
# See: JEP 483 + 514 + 515 (JDK 25).
|
||||
|
||||
# Environment variables.
|
||||
ARG VERSION_TAG
|
||||
# Write version to a file so it is readable by scripts without env-var inheritance.
|
||||
# init-without-ocr.sh reads /etc/stirling_version for the AOT cache fingerprint.
|
||||
RUN echo "${VERSION_TAG:-dev}" > /etc/stirling_version
|
||||
|
||||
ENV VERSION_TAG=$VERSION_TAG \
|
||||
STIRLING_AOT_ENABLE="false" \
|
||||
STIRLING_JVM_PROFILE="balanced" \
|
||||
_JVM_OPTS_BALANCED="-XX:+ExitOnOutOfMemoryError -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/tmp/stirling-pdf/heap_dumps -XX:+UseG1GC -XX:MaxGCPauseMillis=200 -XX:G1HeapRegionSize=4m -XX:G1PeriodicGCInterval=60000 -XX:+UseStringDeduplication -XX:+UseCompactObjectHeaders -XX:+ExplicitGCInvokesConcurrent -Dspring.threads.virtual.enabled=true -Djava.awt.headless=true" \
|
||||
_JVM_OPTS_PERFORMANCE="-XX:+ExitOnOutOfMemoryError -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/tmp/stirling-pdf/heap_dumps -XX:+UseShenandoahGC -XX:ShenandoahGCMode=generational -XX:+UseCompactObjectHeaders -XX:+UseStringDeduplication -XX:+AlwaysPreTouch -XX:+ExplicitGCInvokesConcurrent -Dspring.threads.virtual.enabled=true -Djava.awt.headless=true" \
|
||||
_JVM_OPTS_BALANCED="-XX:+ExitOnOutOfMemoryError -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/configs/heap_dumps -XX:+UseG1GC -XX:MaxGCPauseMillis=200 -XX:G1HeapRegionSize=4m -XX:G1PeriodicGCInterval=60000 -XX:+UseStringDeduplication -XX:+UseCompactObjectHeaders -XX:+ExplicitGCInvokesConcurrent -Dspring.threads.virtual.enabled=true -Djava.awt.headless=true" \
|
||||
_JVM_OPTS_PERFORMANCE="-XX:+ExitOnOutOfMemoryError -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/configs/heap_dumps -XX:+UseShenandoahGC -XX:ShenandoahGCMode=generational -XX:+UseCompactObjectHeaders -XX:+UseStringDeduplication -XX:+AlwaysPreTouch -XX:+ExplicitGCInvokesConcurrent -Dspring.threads.virtual.enabled=true -Djava.awt.headless=true" \
|
||||
JAVA_CUSTOM_OPTS="" \
|
||||
HOME=/home/stirlingpdfuser \
|
||||
PUID=${PUID} \
|
||||
@@ -593,8 +767,8 @@ LABEL org.opencontainers.image.title="Stirling-PDF" \
|
||||
EXPOSE 8080/tcp
|
||||
STOPSIGNAL SIGTERM
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \
|
||||
CMD curl -fs --show-error http://localhost:8080/api/v1/info/status || exit 1
|
||||
HEALTHCHECK --interval=30s --timeout=15s --start-period=120s --retries=5 \
|
||||
CMD curl -fs --max-time 10 http://localhost:8080/api/v1/info/status || exit 1
|
||||
|
||||
ENTRYPOINT ["tini", "--", "/scripts/init.sh"]
|
||||
CMD []
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
FROM ubuntu:noble AS calibre-build
|
||||
|
||||
ARG CALIBRE_VERSION=9.3.1
|
||||
ARG CALIBRE_VERSION=9.4.0
|
||||
|
||||
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
|
||||
set -eux; \
|
||||
@@ -245,7 +245,7 @@ ENV PATH="/opt/venv/bin:$PATH"
|
||||
# Build all heavy python packages here
|
||||
RUN --mount=type=cache,target=/root/.cache/pip \
|
||||
pip install \
|
||||
weasyprint pdf2image opencv-python-headless ocrmypdf \
|
||||
weasyprint pdf2image opencv-python-headless ocrmypdf \
|
||||
"unoserver==${UNOSERVER_VERSION}"
|
||||
|
||||
|
||||
@@ -562,9 +562,10 @@ RUN set -eux; \
|
||||
# Environment variables.
|
||||
ARG VERSION_TAG
|
||||
ENV VERSION_TAG=$VERSION_TAG \
|
||||
STIRLING_AOT_ENABLE="false" \
|
||||
STIRLING_JVM_PROFILE="balanced" \
|
||||
_JVM_OPTS_BALANCED="-XX:+ExitOnOutOfMemoryError -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/tmp/stirling-pdf/heap_dumps -XX:+UseG1GC -XX:MaxGCPauseMillis=200 -XX:G1HeapRegionSize=4m -XX:G1PeriodicGCInterval=60000 -XX:+UseStringDeduplication -XX:+UseCompactObjectHeaders -XX:+ExplicitGCInvokesConcurrent -Dspring.threads.virtual.enabled=true -Djava.awt.headless=true" \
|
||||
_JVM_OPTS_PERFORMANCE="-XX:+ExitOnOutOfMemoryError -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/tmp/stirling-pdf/heap_dumps -XX:+UseShenandoahGC -XX:ShenandoahGCMode=generational -XX:+UseCompactObjectHeaders -XX:+UseStringDeduplication -XX:+AlwaysPreTouch -XX:+ExplicitGCInvokesConcurrent -Dspring.threads.virtual.enabled=true -Djava.awt.headless=true" \
|
||||
_JVM_OPTS_BALANCED="-XX:+ExitOnOutOfMemoryError -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/configs/heap_dumps -XX:+UseG1GC -XX:MaxGCPauseMillis=200 -XX:G1HeapRegionSize=4m -XX:G1PeriodicGCInterval=60000 -XX:+UseStringDeduplication -XX:+UseCompactObjectHeaders -XX:+ExplicitGCInvokesConcurrent -Dspring.threads.virtual.enabled=true -Djava.awt.headless=true" \
|
||||
_JVM_OPTS_PERFORMANCE="-XX:+ExitOnOutOfMemoryError -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/configs/heap_dumps -XX:+UseShenandoahGC -XX:ShenandoahGCMode=generational -XX:+UseCompactObjectHeaders -XX:+UseStringDeduplication -XX:+AlwaysPreTouch -XX:+ExplicitGCInvokesConcurrent -Dspring.threads.virtual.enabled=true -Djava.awt.headless=true" \
|
||||
JAVA_CUSTOM_OPTS="" \
|
||||
HOME=/home/stirlingpdfuser \
|
||||
PUID=${PUID} \
|
||||
|
||||
@@ -69,9 +69,10 @@ LABEL org.opencontainers.image.title="Stirling-PDF Ultra-Lite" \
|
||||
# NOTE: Memory flags (InitialRAMPercentage, MaxRAMPercentage, MaxMetaspaceSize)
|
||||
# are computed dynamically by init-without-ocr.sh based on container memory limits.
|
||||
ENV VERSION_TAG=$VERSION_TAG \
|
||||
STIRLING_AOT_ENABLE="false" \
|
||||
STIRLING_JVM_PROFILE="balanced" \
|
||||
_JVM_OPTS_BALANCED="-XX:+ExitOnOutOfMemoryError -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/tmp/stirling-pdf/heap_dumps -XX:+UseG1GC -XX:MaxGCPauseMillis=200 -XX:G1HeapRegionSize=4m -XX:G1PeriodicGCInterval=60000 -XX:+UseStringDeduplication -XX:+UseCompactObjectHeaders -XX:+ExplicitGCInvokesConcurrent -Dspring.threads.virtual.enabled=true -Djava.awt.headless=true" \
|
||||
_JVM_OPTS_PERFORMANCE="-XX:+ExitOnOutOfMemoryError -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/tmp/stirling-pdf/heap_dumps -XX:+UseShenandoahGC -XX:ShenandoahGCMode=generational -XX:+UseCompactObjectHeaders -XX:+UseStringDeduplication -XX:+AlwaysPreTouch -XX:+ExplicitGCInvokesConcurrent -Dspring.threads.virtual.enabled=true -Djava.awt.headless=true" \
|
||||
_JVM_OPTS_BALANCED="-XX:+ExitOnOutOfMemoryError -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/configs/heap_dumps -XX:+UseG1GC -XX:MaxGCPauseMillis=200 -XX:G1HeapRegionSize=4m -XX:G1PeriodicGCInterval=60000 -XX:+UseStringDeduplication -XX:+UseCompactObjectHeaders -XX:+ExplicitGCInvokesConcurrent -Dspring.threads.virtual.enabled=true -Djava.awt.headless=true" \
|
||||
_JVM_OPTS_PERFORMANCE="-XX:+ExitOnOutOfMemoryError -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/configs/heap_dumps -XX:+UseShenandoahGC -XX:ShenandoahGCMode=generational -XX:+UseCompactObjectHeaders -XX:+UseStringDeduplication -XX:+AlwaysPreTouch -XX:+ExplicitGCInvokesConcurrent -Dspring.threads.virtual.enabled=true -Djava.awt.headless=true" \
|
||||
JAVA_CUSTOM_OPTS="" \
|
||||
HOME=/home/stirlingpdfuser \
|
||||
PUID=1000 \
|
||||
|
||||
Generated
+530
-532
File diff suppressed because it is too large
Load Diff
+23
-23
@@ -8,29 +8,29 @@
|
||||
"@atlaskit/pragmatic-drag-and-drop": "^1.7.7",
|
||||
"@cantoo/pdf-lib": "^2.5.3",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@embedpdf/core": "^2.6.2",
|
||||
"@embedpdf/engines": "^2.6.2",
|
||||
"@embedpdf/models": "^2.6.2",
|
||||
"@embedpdf/plugin-annotation": "^2.6.2",
|
||||
"@embedpdf/plugin-attachment": "^2.6.2",
|
||||
"@embedpdf/plugin-bookmark": "^2.6.2",
|
||||
"@embedpdf/plugin-document-manager": "^2.6.2",
|
||||
"@embedpdf/plugin-export": "^2.6.2",
|
||||
"@embedpdf/plugin-history": "^2.6.2",
|
||||
"@embedpdf/plugin-interaction-manager": "^2.6.2",
|
||||
"@embedpdf/plugin-pan": "^2.6.2",
|
||||
"@embedpdf/plugin-print": "^2.6.2",
|
||||
"@embedpdf/plugin-redaction": "^2.6.2",
|
||||
"@embedpdf/plugin-render": "^2.6.2",
|
||||
"@embedpdf/plugin-rotate": "^2.6.2",
|
||||
"@embedpdf/plugin-scroll": "^2.6.2",
|
||||
"@embedpdf/plugin-search": "^2.6.2",
|
||||
"@embedpdf/plugin-selection": "^2.6.2",
|
||||
"@embedpdf/plugin-spread": "^2.6.2",
|
||||
"@embedpdf/plugin-thumbnail": "^2.6.2",
|
||||
"@embedpdf/plugin-tiling": "^2.6.2",
|
||||
"@embedpdf/plugin-viewport": "^2.6.2",
|
||||
"@embedpdf/plugin-zoom": "^2.6.2",
|
||||
"@embedpdf/core": "^2.7.0",
|
||||
"@embedpdf/engines": "^2.7.0",
|
||||
"@embedpdf/models": "^2.7.0",
|
||||
"@embedpdf/plugin-annotation": "^2.7.0",
|
||||
"@embedpdf/plugin-attachment": "^2.7.0",
|
||||
"@embedpdf/plugin-bookmark": "^2.7.0",
|
||||
"@embedpdf/plugin-document-manager": "^2.7.0",
|
||||
"@embedpdf/plugin-export": "^2.7.0",
|
||||
"@embedpdf/plugin-history": "^2.7.0",
|
||||
"@embedpdf/plugin-interaction-manager": "^2.7.0",
|
||||
"@embedpdf/plugin-pan": "^2.7.0",
|
||||
"@embedpdf/plugin-print": "^2.7.0",
|
||||
"@embedpdf/plugin-redaction": "^2.7.0",
|
||||
"@embedpdf/plugin-render": "^2.7.0",
|
||||
"@embedpdf/plugin-rotate": "^2.7.0",
|
||||
"@embedpdf/plugin-scroll": "^2.7.0",
|
||||
"@embedpdf/plugin-search": "^2.7.0",
|
||||
"@embedpdf/plugin-selection": "^2.7.0",
|
||||
"@embedpdf/plugin-spread": "^2.7.0",
|
||||
"@embedpdf/plugin-thumbnail": "^2.7.0",
|
||||
"@embedpdf/plugin-tiling": "^2.7.0",
|
||||
"@embedpdf/plugin-viewport": "^2.7.0",
|
||||
"@embedpdf/plugin-zoom": "^2.7.0",
|
||||
"@emotion/react": "^11.14.0",
|
||||
"@emotion/styled": "^11.14.1",
|
||||
"@iconify/react": "^6.0.2",
|
||||
|
||||
@@ -105,6 +105,7 @@ save = "Save"
|
||||
saveToBrowser = "Save to Browser"
|
||||
saveUnavailable = "Save unavailable for this item"
|
||||
seeDockerHub = "See Docker Hub"
|
||||
editSecret = "Edit this value"
|
||||
selectFillter = "-- Select --"
|
||||
size = "Size"
|
||||
sponsor = "Sponsor"
|
||||
|
||||
Generated
+467
-578
File diff suppressed because it is too large
Load Diff
@@ -36,13 +36,13 @@ tauri-plugin-opener = "2.5.3"
|
||||
tauri-plugin-deep-link = "2.4.6"
|
||||
keyring = { version = "3.6.1", features = ["apple-native", "windows-native"] }
|
||||
tokio = { version = "1.0", features = ["time", "sync"] }
|
||||
reqwest = { version = "0.11", default-features = false, features = ["json", "rustls-tls", "rustls-tls-native-roots"] }
|
||||
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls", "rustls-tls-native-roots"] }
|
||||
tiny_http = "0.12"
|
||||
url = "2.5"
|
||||
urlencoding = "2.1"
|
||||
sha2 = "0.10"
|
||||
base64 = "0.22"
|
||||
rand = "0.8"
|
||||
rand = "0.9"
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
core-foundation = "0.10"
|
||||
|
||||
@@ -6,8 +6,8 @@ use tauri_plugin_store::StoreExt;
|
||||
use tiny_http::{Response, Server};
|
||||
use sha2::{Sha256, Digest};
|
||||
use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
|
||||
use rand::{thread_rng, Rng};
|
||||
use rand::distributions::Alphanumeric;
|
||||
use rand::Rng;
|
||||
use rand::distr::Alphanumeric;
|
||||
|
||||
const STORE_FILE: &str = "connection.json";
|
||||
const USER_INFO_KEY: &str = "user_info";
|
||||
@@ -524,8 +524,8 @@ pub async fn login(
|
||||
|
||||
/// Generate PKCE code_verifier (random 43-128 character string)
|
||||
fn generate_code_verifier() -> String {
|
||||
thread_rng()
|
||||
.sample_iter(&Alphanumeric)
|
||||
rand::rng()
|
||||
.sample_iter(Alphanumeric)
|
||||
.take(128)
|
||||
.map(char::from)
|
||||
.collect()
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user