mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-16 19:33:11 +02:00
V1 merge (#5193)
# Description of Changes <!-- Please provide a summary of the changes, including: - What was changed - Why the change was made - Any challenges encountered Closes #(issue_number) --> --- ## Checklist ### General - [ ] I have read the [Contribution Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md) - [ ] I have read the [Stirling-PDF Developer Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md) (if applicable) - [ ] I have read the [How to add new languages to Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md) (if applicable) - [ ] I have performed a self-review of my own code - [ ] My changes generate no new warnings ### Documentation - [ ] I have updated relevant docs on [Stirling-PDF's doc repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/) (if functionality has heavily changed) - [ ] I have read the section [Add New Translation Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags) (for new translation tags only) ### UI Changes (if applicable) - [ ] Screenshots or videos demonstrating the UI changes are attached (e.g., as comments or direct attachments in the PR) ### Testing (if applicable) - [ ] I have tested my changes locally. Refer to the [Testing Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing) for more details. --------- Signed-off-by: dependabot[bot] <[email protected]> Signed-off-by: Balázs Szücs <[email protected]> Signed-off-by: stirlingbot[bot] <stirlingbot[bot]@users.noreply.github.com> Co-authored-by: ConnorYoh <[email protected]> Co-authored-by: Connor Yoh <[email protected]> Co-authored-by: OUNZAR Aymane <[email protected]> Co-authored-by: YAOU Reda <[email protected]> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: stirlingbot[bot] <195170888+stirlingbot[bot]@users.noreply.github.com> Co-authored-by: Balázs Szücs <[email protected]> Co-authored-by: Ludy <[email protected]> Co-authored-by: tkymmm <[email protected]> Co-authored-by: Peter Dave Hello <[email protected]> Co-authored-by: albanobattistella <[email protected]> Co-authored-by: PingLin8888 <[email protected]> Co-authored-by: FdaSilvaYY <[email protected]> Co-authored-by: Copilot <[email protected]> Co-authored-by: OteJlo <[email protected]> Co-authored-by: Angel <[email protected]> Co-authored-by: Ricardo Catarino <[email protected]> Co-authored-by: Luis Antonio Argüelles González <[email protected]> Co-authored-by: Dawid Urbański <[email protected]> Co-authored-by: Stephan Paternotte <[email protected]> Co-authored-by: Leonardo Santos Paulucio <[email protected]> Co-authored-by: hamza khalem <[email protected]> Co-authored-by: IT Creativity + Art Team <[email protected]> Co-authored-by: Reece Browne <[email protected]> Co-authored-by: James Brunton <[email protected]> Co-authored-by: Victor Villarreal <[email protected]>
This commit is contained in:
co-authored by
ConnorYoh
Connor Yoh
OUNZAR Aymane
YAOU Reda
dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
stirlingbot[bot] <195170888+stirlingbot[bot]@users.noreply.github.com>
Balázs Szücs
Ludy
tkymmm
Peter Dave Hello
albanobattistella
PingLin8888
FdaSilvaYY
Copilot
OteJlo
Angel
Ricardo Catarino
Luis Antonio Argüelles González
Dawid Urbański
Stephan Paternotte
Leonardo Santos Paulucio
hamza khalem
IT Creativity + Art Team
Reece Browne
James Brunton
Victor Villarreal
parent
a5dcdd5bd9
commit
68ed54e398
@@ -33,16 +33,16 @@ dependencies {
|
||||
api 'com.googlecode.owasp-java-html-sanitizer:owasp-java-html-sanitizer:20240325.1'
|
||||
api 'com.fathzer:javaluator:3.0.6'
|
||||
api 'com.posthog.java:posthog:1.2.0'
|
||||
api 'org.apache.commons:commons-lang3:3.19.0'
|
||||
api 'org.apache.commons:commons-lang3:3.20.0'
|
||||
api 'com.drewnoakes:metadata-extractor:2.19.0' // Image metadata extractor
|
||||
api 'com.vladsch.flexmark:flexmark-html2md-converter:0.64.8'
|
||||
api "org.apache.pdfbox:pdfbox:$pdfboxVersion"
|
||||
api "org.apache.pdfbox:xmpbox:$pdfboxVersion"
|
||||
api "org.apache.pdfbox:preflight:$pdfboxVersion"
|
||||
api 'com.github.junrar:junrar:7.5.5' // RAR archive support for CBR files
|
||||
api 'com.github.junrar:junrar:7.5.7' // RAR archive support for CBR files
|
||||
api 'jakarta.servlet:jakarta.servlet-api:6.1.0'
|
||||
api 'org.snakeyaml:snakeyaml-engine:2.10'
|
||||
api "org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.13"
|
||||
api "org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.14"
|
||||
api 'jakarta.mail:jakarta.mail-api:2.1.5'
|
||||
runtimeOnly 'org.eclipse.angus:angus-mail:2.0.5'
|
||||
}
|
||||
|
||||
@@ -334,6 +334,9 @@ public class EndpointConfiguration {
|
||||
addEndpointToGroup("Convert", "pdf-to-csv");
|
||||
addEndpointToGroup("Convert", "pdf-to-markdown");
|
||||
addEndpointToGroup("Convert", "eml-to-pdf");
|
||||
addEndpointToGroup("Convert", "pdf-to-vector");
|
||||
addEndpointToGroup("Convert", "vector-to-pdf");
|
||||
addEndpointToGroup("Convert", "pdf-to-video");
|
||||
addEndpointToGroup("Convert", "cbz-to-pdf");
|
||||
addEndpointToGroup("Convert", "pdf-to-cbz");
|
||||
addEndpointToGroup("Convert", "pdf-to-json");
|
||||
@@ -490,6 +493,10 @@ public class EndpointConfiguration {
|
||||
/* Ghostscript */
|
||||
addEndpointToGroup("Ghostscript", "repair");
|
||||
addEndpointToGroup("Ghostscript", "compress-pdf");
|
||||
addEndpointToGroup("Ghostscript", "crop");
|
||||
addEndpointToGroup("Ghostscript", "replace-invert-pdf");
|
||||
addEndpointToGroup("Ghostscript", "pdf-to-vector");
|
||||
addEndpointToGroup("Ghostscript", "vector-to-pdf");
|
||||
|
||||
/* ImageMagick */
|
||||
addEndpointToGroup("ImageMagick", "compress-pdf");
|
||||
@@ -554,7 +561,7 @@ public class EndpointConfiguration {
|
||||
disableGroup("enterprise");
|
||||
}
|
||||
|
||||
if (!applicationProperties.getSystem().getEnableUrlToPDF()) {
|
||||
if (!applicationProperties.getSystem().isEnableUrlToPDF()) {
|
||||
disableEndpoint("url-to-pdf");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,7 +37,7 @@ public class AutoJobAspect {
|
||||
|
||||
@Around("@annotation(autoJobPostMapping)")
|
||||
public Object wrapWithJobExecution(
|
||||
ProceedingJoinPoint joinPoint, AutoJobPostMapping autoJobPostMapping) {
|
||||
ProceedingJoinPoint joinPoint, AutoJobPostMapping autoJobPostMapping) throws Exception {
|
||||
// This aspect will run before any audit aspects due to @Order(0)
|
||||
// Extract parameters from the request and annotation
|
||||
boolean async = Boolean.parseBoolean(request.getParameter("async"));
|
||||
@@ -81,6 +81,12 @@ public class AutoJobAspect {
|
||||
"AutoJobAspect caught exception during job execution: {}",
|
||||
ex.getMessage(),
|
||||
ex);
|
||||
// Rethrow RuntimeException as-is to preserve exception type
|
||||
if (ex instanceof RuntimeException) {
|
||||
throw (RuntimeException) ex;
|
||||
}
|
||||
// Wrap checked exceptions - GlobalExceptionHandler will unwrap
|
||||
// BaseAppException
|
||||
throw new RuntimeException(ex);
|
||||
}
|
||||
},
|
||||
@@ -109,7 +115,8 @@ public class AutoJobAspect {
|
||||
int maxRetries,
|
||||
boolean trackProgress,
|
||||
boolean queueable,
|
||||
int resourceWeight) {
|
||||
int resourceWeight)
|
||||
throws Exception {
|
||||
|
||||
// Keep jobId reference for progress tracking in TaskManager
|
||||
AtomicReference<String> jobIdRef = new AtomicReference<>();
|
||||
@@ -207,6 +214,12 @@ public class AutoJobAspect {
|
||||
|
||||
// If we get here, all retries failed
|
||||
if (lastException != null) {
|
||||
// Rethrow RuntimeException as-is to preserve exception type
|
||||
if (lastException instanceof RuntimeException) {
|
||||
throw (RuntimeException) lastException;
|
||||
}
|
||||
// Wrap checked exceptions - GlobalExceptionHandler will unwrap
|
||||
// BaseAppException
|
||||
throw new RuntimeException(
|
||||
"Job failed after "
|
||||
+ maxRetries
|
||||
|
||||
@@ -76,7 +76,7 @@ public class AppConfig {
|
||||
|
||||
@Bean(name = "loginEnabled")
|
||||
public boolean loginEnabled() {
|
||||
return applicationProperties.getSecurity().getEnableLogin();
|
||||
return applicationProperties.getSecurity().isEnableLogin();
|
||||
}
|
||||
|
||||
@Bean(name = "appName")
|
||||
@@ -120,9 +120,7 @@ public class AppConfig {
|
||||
|
||||
@Bean(name = "enableAlphaFunctionality")
|
||||
public boolean enableAlphaFunctionality() {
|
||||
return applicationProperties.getSystem().getEnableAlphaFunctionality() != null
|
||||
? applicationProperties.getSystem().getEnableAlphaFunctionality()
|
||||
: false;
|
||||
return applicationProperties.getSystem().isEnableAlphaFunctionality();
|
||||
}
|
||||
|
||||
@Bean(name = "rateLimit")
|
||||
@@ -265,9 +263,14 @@ public class AppConfig {
|
||||
return "NORMAL";
|
||||
}
|
||||
|
||||
@Bean(name = "disablePixel")
|
||||
public boolean disablePixel() {
|
||||
return Boolean.parseBoolean(env.getProperty("DISABLE_PIXEL", "false"));
|
||||
@Bean(name = "scarfEnabled")
|
||||
public boolean scarfEnabled() {
|
||||
return applicationProperties.getSystem().isScarfEnabled();
|
||||
}
|
||||
|
||||
@Bean(name = "posthogEnabled")
|
||||
public boolean posthogEnabled() {
|
||||
return applicationProperties.getSystem().isPosthogEnabled();
|
||||
}
|
||||
|
||||
@Bean(name = "machineType")
|
||||
|
||||
+2
-1
@@ -2,6 +2,7 @@ package stirling.software.common.configuration;
|
||||
|
||||
import java.io.File;
|
||||
import java.nio.file.Paths;
|
||||
import java.util.Locale;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
@@ -61,7 +62,7 @@ public class InstallationPathConfig {
|
||||
|
||||
private static String initializeBasePath() {
|
||||
if (Boolean.parseBoolean(System.getProperty("STIRLING_PDF_DESKTOP_UI", "false"))) {
|
||||
String os = System.getProperty("os.name").toLowerCase();
|
||||
String os = System.getProperty("os.name").toLowerCase(Locale.ROOT);
|
||||
if (os.contains("win")) {
|
||||
return Paths.get(
|
||||
System.getenv("APPDATA"), // parent path
|
||||
|
||||
+40
-2
@@ -10,8 +10,10 @@ import lombok.Getter;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.common.model.ApplicationProperties.CustomPaths;
|
||||
import stirling.software.common.model.ApplicationProperties.CustomPaths.Operations;
|
||||
import stirling.software.common.model.ApplicationProperties.CustomPaths.Pipeline;
|
||||
import stirling.software.common.model.ApplicationProperties.System;
|
||||
|
||||
@Slf4j
|
||||
@Configuration
|
||||
@@ -19,8 +21,16 @@ import stirling.software.common.model.ApplicationProperties.CustomPaths.Pipeline
|
||||
public class RuntimePathConfig {
|
||||
private final ApplicationProperties properties;
|
||||
private final String basePath;
|
||||
|
||||
// Operation paths
|
||||
private final String weasyPrintPath;
|
||||
private final String unoConvertPath;
|
||||
private final String calibrePath;
|
||||
private final String ocrMyPdfPath;
|
||||
private final String sOfficePath;
|
||||
|
||||
// Tesseract data path
|
||||
private final String tessDataPath;
|
||||
|
||||
// Pipeline paths
|
||||
private final String pipelineWatchedFoldersPath;
|
||||
@@ -37,7 +47,10 @@ public class RuntimePathConfig {
|
||||
String defaultFinishedFolders = Path.of(this.pipelinePath, "finishedFolders").toString();
|
||||
String defaultWebUIConfigs = Path.of(this.pipelinePath, "defaultWebUIConfigs").toString();
|
||||
|
||||
Pipeline pipeline = properties.getSystem().getCustomPaths().getPipeline();
|
||||
System system = properties.getSystem();
|
||||
CustomPaths customPaths = system.getCustomPaths();
|
||||
|
||||
Pipeline pipeline = customPaths.getPipeline();
|
||||
|
||||
this.pipelineWatchedFoldersPath =
|
||||
resolvePath(
|
||||
@@ -57,8 +70,11 @@ public class RuntimePathConfig {
|
||||
// Initialize Operation paths
|
||||
String defaultWeasyPrintPath = isDocker ? "/opt/venv/bin/weasyprint" : "weasyprint";
|
||||
String defaultUnoConvertPath = isDocker ? "/opt/venv/bin/unoconvert" : "unoconvert";
|
||||
String defaultCalibrePath = isDocker ? "/opt/calibre/ebook-convert" : "ebook-convert";
|
||||
String defaultOcrMyPdfPath = isDocker ? "/usr/bin/ocrmypdf" : "ocrmypdf";
|
||||
String defaultSOfficePath = isDocker ? "/usr/bin/soffice" : "soffice";
|
||||
|
||||
Operations operations = properties.getSystem().getCustomPaths().getOperations();
|
||||
Operations operations = customPaths.getOperations();
|
||||
this.weasyPrintPath =
|
||||
resolvePath(
|
||||
defaultWeasyPrintPath,
|
||||
@@ -67,6 +83,28 @@ public class RuntimePathConfig {
|
||||
resolvePath(
|
||||
defaultUnoConvertPath,
|
||||
operations != null ? operations.getUnoconvert() : null);
|
||||
this.calibrePath =
|
||||
resolvePath(
|
||||
defaultCalibrePath, operations != null ? operations.getCalibre() : null);
|
||||
this.ocrMyPdfPath =
|
||||
resolvePath(
|
||||
defaultOcrMyPdfPath, operations != null ? operations.getOcrmypdf() : null);
|
||||
this.sOfficePath =
|
||||
resolvePath(
|
||||
defaultSOfficePath, operations != null ? operations.getSoffice() : null);
|
||||
|
||||
// Initialize Tesseract data path
|
||||
String defaultTessDataPath =
|
||||
isDocker ? "/usr/share/tesseract-ocr/5/tessdata" : "/usr/share/tessdata";
|
||||
|
||||
String tessPath = system.getTessdataDir();
|
||||
String tessdataDir = java.lang.System.getenv("TESSDATA_PREFIX");
|
||||
|
||||
this.tessDataPath =
|
||||
resolvePath(
|
||||
defaultTessDataPath,
|
||||
(tessPath != null && !tessPath.isEmpty()) ? tessPath : tessdataDir);
|
||||
log.info("Using Tesseract data path: {}", this.tessDataPath);
|
||||
}
|
||||
|
||||
private String resolvePath(String defaultPath, String customPath) {
|
||||
|
||||
+47
-14
@@ -12,6 +12,7 @@ import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
@@ -152,7 +153,7 @@ public class ApplicationProperties {
|
||||
|
||||
@Data
|
||||
public static class Security {
|
||||
private Boolean enableLogin;
|
||||
private boolean enableLogin;
|
||||
private InitialLogin initialLogin = new InitialLogin();
|
||||
private OAUTH2 oauth2 = new OAUTH2();
|
||||
private SAML2 saml2 = new SAML2();
|
||||
@@ -327,7 +328,7 @@ public class ApplicationProperties {
|
||||
private KeycloakProvider keycloak = new KeycloakProvider();
|
||||
|
||||
public Provider get(String registrationId) throws UnsupportedProviderException {
|
||||
return switch (registrationId.toLowerCase()) {
|
||||
return switch (registrationId.toLowerCase(Locale.ROOT)) {
|
||||
case "google" -> getGoogle();
|
||||
case "github" -> getGithub();
|
||||
case "keycloak" -> getKeycloak();
|
||||
@@ -335,8 +336,8 @@ public class ApplicationProperties {
|
||||
throw new UnsupportedProviderException(
|
||||
"Logout from the provider "
|
||||
+ registrationId
|
||||
+ " is not supported. "
|
||||
+ "Report it at https://github.com/Stirling-Tools/Stirling-PDF/issues");
|
||||
+ " is not supported. Report it at"
|
||||
+ " https://github.com/Stirling-Tools/Stirling-PDF/issues");
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -389,20 +390,20 @@ public class ApplicationProperties {
|
||||
@Data
|
||||
public static class System {
|
||||
private String defaultLocale;
|
||||
private Boolean googlevisibility;
|
||||
private boolean googlevisibility;
|
||||
private boolean showUpdate;
|
||||
private Boolean showUpdateOnlyAdmin;
|
||||
private boolean showUpdateOnlyAdmin;
|
||||
private boolean customHTMLFiles;
|
||||
private String tessdataDir;
|
||||
private Boolean enableAlphaFunctionality;
|
||||
private boolean enableAlphaFunctionality;
|
||||
private Boolean enableAnalytics;
|
||||
private Boolean enablePosthog;
|
||||
private Boolean enableScarf;
|
||||
private Boolean enableDesktopInstallSlide;
|
||||
private Datasource datasource;
|
||||
private Boolean disableSanitize;
|
||||
private boolean disableSanitize;
|
||||
private int maxDPI;
|
||||
private Boolean enableUrlToPDF;
|
||||
private boolean enableUrlToPDF;
|
||||
private Html html = new Html();
|
||||
private CustomPaths customPaths = new CustomPaths();
|
||||
private String fileUploadLimit;
|
||||
@@ -454,6 +455,9 @@ public class ApplicationProperties {
|
||||
public static class Operations {
|
||||
private String weasyprint;
|
||||
private String unoconvert;
|
||||
private String calibre;
|
||||
private String ocrmypdf;
|
||||
private String soffice;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -536,10 +540,10 @@ public class ApplicationProperties {
|
||||
@Override
|
||||
public String toString() {
|
||||
return """
|
||||
Driver {
|
||||
driverName='%s'
|
||||
}
|
||||
"""
|
||||
Driver {
|
||||
driverName='%s'
|
||||
}
|
||||
"""
|
||||
.formatted(driverName);
|
||||
}
|
||||
}
|
||||
@@ -571,7 +575,7 @@ public class ApplicationProperties {
|
||||
|
||||
@Data
|
||||
public static class Metrics {
|
||||
private Boolean enabled;
|
||||
private boolean enabled;
|
||||
}
|
||||
|
||||
@Data
|
||||
@@ -668,6 +672,25 @@ public class ApplicationProperties {
|
||||
public static class EnterpriseFeatures {
|
||||
private PersistentMetrics persistentMetrics = new PersistentMetrics();
|
||||
private Audit audit = new Audit();
|
||||
private DatabaseNotifications databaseNotifications = new DatabaseNotifications();
|
||||
|
||||
@Data
|
||||
public static class DatabaseNotifications {
|
||||
private Backup backups = new Backup();
|
||||
private Imports imports = new Imports();
|
||||
|
||||
@Data
|
||||
public static class Backup {
|
||||
private boolean successful = false;
|
||||
private boolean failed = false;
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class Imports {
|
||||
private boolean successful = false;
|
||||
private boolean failed = false;
|
||||
}
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class Audit {
|
||||
@@ -702,6 +725,7 @@ public class ApplicationProperties {
|
||||
private int tesseractSessionLimit;
|
||||
private int ghostscriptSessionLimit;
|
||||
private int ocrMyPdfSessionLimit;
|
||||
private int ffmpegSessionLimit;
|
||||
|
||||
public int getQpdfSessionLimit() {
|
||||
return qpdfSessionLimit > 0 ? qpdfSessionLimit : 2;
|
||||
@@ -746,6 +770,10 @@ public class ApplicationProperties {
|
||||
public int getOcrMyPdfSessionLimit() {
|
||||
return ocrMyPdfSessionLimit > 0 ? ocrMyPdfSessionLimit : 2;
|
||||
}
|
||||
|
||||
public int getFfmpegSessionLimit() {
|
||||
return ffmpegSessionLimit > 0 ? ffmpegSessionLimit : 2;
|
||||
}
|
||||
}
|
||||
|
||||
@Data
|
||||
@@ -774,6 +802,7 @@ public class ApplicationProperties {
|
||||
private long qpdfTimeoutMinutes;
|
||||
private long ghostscriptTimeoutMinutes;
|
||||
private long ocrMyPdfTimeoutMinutes;
|
||||
private long ffmpegTimeoutMinutes;
|
||||
|
||||
public long getTesseractTimeoutMinutes() {
|
||||
return tesseractTimeoutMinutes > 0 ? tesseractTimeoutMinutes : 30;
|
||||
@@ -818,6 +847,10 @@ public class ApplicationProperties {
|
||||
public long getOcrMyPdfTimeoutMinutes() {
|
||||
return ocrMyPdfTimeoutMinutes > 0 ? ocrMyPdfTimeoutMinutes : 30;
|
||||
}
|
||||
|
||||
public long getFfmpegTimeoutMinutes() {
|
||||
return ffmpegTimeoutMinutes > 0 ? ffmpegTimeoutMinutes : 30;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,11 +30,11 @@ public class FileInfo {
|
||||
// Formats the file size into a human-readable string.
|
||||
public String getFormattedFileSize() {
|
||||
if (fileSize >= 1024 * 1024 * 1024) {
|
||||
return String.format(Locale.US, "%.2f GB", fileSize / (1024.0 * 1024 * 1024));
|
||||
return String.format(Locale.ROOT, "%.2f GB", fileSize / (1024.0 * 1024 * 1024));
|
||||
} else if (fileSize >= 1024 * 1024) {
|
||||
return String.format(Locale.US, "%.2f MB", fileSize / (1024.0 * 1024));
|
||||
return String.format(Locale.ROOT, "%.2f MB", fileSize / (1024.0 * 1024));
|
||||
} else if (fileSize >= 1024) {
|
||||
return String.format(Locale.US, "%.2f KB", fileSize / 1024.0);
|
||||
return String.format(Locale.ROOT, "%.2f KB", fileSize / 1024.0);
|
||||
} else {
|
||||
return String.format("%d Bytes", fileSize);
|
||||
}
|
||||
|
||||
+3
-7
@@ -7,6 +7,7 @@ import java.io.InputStream;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
import java.util.Locale;
|
||||
import java.util.concurrent.atomic.AtomicLong;
|
||||
|
||||
import org.apache.pdfbox.Loader;
|
||||
@@ -249,7 +250,7 @@ public class CustomPDFDocumentFactory {
|
||||
log.debug(
|
||||
"Memory status - Free: {}MB ({}%), Used: {}MB, Max: {}MB",
|
||||
actualFreeMemory / (1024 * 1024),
|
||||
String.format("%.2f", freeMemoryPercent),
|
||||
String.format(Locale.ROOT, "%.2f", freeMemoryPercent),
|
||||
usedMemory / (1024 * 1024),
|
||||
maxMemory / (1024 * 1024));
|
||||
|
||||
@@ -258,7 +259,7 @@ public class CustomPDFDocumentFactory {
|
||||
|| actualFreeMemory < MIN_FREE_MEMORY_BYTES) {
|
||||
log.debug(
|
||||
"Low memory detected ({}%), forcing file-based cache",
|
||||
String.format("%.2f", freeMemoryPercent));
|
||||
String.format(Locale.ROOT, "%.2f", freeMemoryPercent));
|
||||
return createScratchFileCacheFunction(MemoryUsageSetting.setupTempFileOnly());
|
||||
} else if (contentSize < SMALL_FILE_THRESHOLD) {
|
||||
log.debug("Using memory-only cache for small document ({}KB)", contentSize / 1024);
|
||||
@@ -477,11 +478,6 @@ public class CustomPDFDocumentFactory {
|
||||
return file;
|
||||
}
|
||||
|
||||
/** Create a uniquely named temporary directory */
|
||||
private Path createTempDirectory(String prefix) throws IOException {
|
||||
return Files.createTempDirectory(prefix + tempCounter.incrementAndGet() + "-");
|
||||
}
|
||||
|
||||
/** Create new document bytes based on an existing document */
|
||||
public byte[] createNewBytesBasedOnOldDocument(byte[] oldDocument) throws IOException {
|
||||
try (PDDocument document = load(oldDocument)) {
|
||||
|
||||
@@ -253,6 +253,22 @@ public class JobExecutorService {
|
||||
log.error("Synchronous job timed out after {} ms", timeoutToUse);
|
||||
return ResponseEntity.internalServerError()
|
||||
.body(Map.of("error", "Job timed out after " + timeoutToUse + " ms"));
|
||||
} catch (RuntimeException e) {
|
||||
// Check if this is a wrapped typed exception that should be handled by
|
||||
// GlobalExceptionHandler
|
||||
Throwable cause = e.getCause();
|
||||
if (cause instanceof stirling.software.common.util.ExceptionUtils.BaseAppException
|
||||
|| cause
|
||||
instanceof
|
||||
stirling.software.common.util.ExceptionUtils
|
||||
.BaseValidationException) {
|
||||
// Rethrow so GlobalExceptionHandler can handle with proper HTTP status codes
|
||||
throw e;
|
||||
}
|
||||
// Handle other RuntimeExceptions as generic errors
|
||||
log.error("Error executing synchronous job: {}", e.getMessage(), e);
|
||||
return ResponseEntity.internalServerError()
|
||||
.body(Map.of("error", "Job failed: " + e.getMessage()));
|
||||
} catch (Exception e) {
|
||||
log.error("Error executing synchronous job: {}", e.getMessage(), e);
|
||||
// Construct a JSON error response
|
||||
|
||||
@@ -253,7 +253,7 @@ public class PostHogService {
|
||||
addIfNotEmpty(
|
||||
properties,
|
||||
"security_enableLogin",
|
||||
applicationProperties.getSecurity().getEnableLogin());
|
||||
applicationProperties.getSecurity().isEnableLogin());
|
||||
addIfNotEmpty(properties, "security_csrfDisabled", true);
|
||||
addIfNotEmpty(
|
||||
properties,
|
||||
@@ -299,13 +299,13 @@ public class PostHogService {
|
||||
addIfNotEmpty(
|
||||
properties,
|
||||
"system_googlevisibility",
|
||||
applicationProperties.getSystem().getGooglevisibility());
|
||||
applicationProperties.getSystem().isGooglevisibility());
|
||||
addIfNotEmpty(
|
||||
properties, "system_showUpdate", applicationProperties.getSystem().isShowUpdate());
|
||||
addIfNotEmpty(
|
||||
properties,
|
||||
"system_showUpdateOnlyAdmin",
|
||||
applicationProperties.getSystem().getShowUpdateOnlyAdmin());
|
||||
applicationProperties.getSystem().isShowUpdateOnlyAdmin());
|
||||
addIfNotEmpty(
|
||||
properties,
|
||||
"system_customHTMLFiles",
|
||||
@@ -317,7 +317,7 @@ public class PostHogService {
|
||||
addIfNotEmpty(
|
||||
properties,
|
||||
"system_enableAlphaFunctionality",
|
||||
applicationProperties.getSystem().getEnableAlphaFunctionality());
|
||||
applicationProperties.getSystem().isEnableAlphaFunctionality());
|
||||
addIfNotEmpty(
|
||||
properties,
|
||||
"system_enableAnalytics",
|
||||
@@ -337,7 +337,7 @@ public class PostHogService {
|
||||
|
||||
// Capture Metrics properties
|
||||
addIfNotEmpty(
|
||||
properties, "metrics_enabled", applicationProperties.getMetrics().getEnabled());
|
||||
properties, "metrics_enabled", applicationProperties.getMetrics().isEnabled());
|
||||
|
||||
// Capture EnterpriseEdition properties
|
||||
addIfNotEmpty(
|
||||
|
||||
@@ -5,6 +5,7 @@ import java.lang.management.MemoryMXBean;
|
||||
import java.lang.management.OperatingSystemMXBean;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.Locale;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.ScheduledExecutorService;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
@@ -173,8 +174,8 @@ public class ResourceMonitor {
|
||||
log.info("System resource status changed from {} to {}", oldStatus, newStatus);
|
||||
log.info(
|
||||
"Current metrics - CPU: {}%, Memory: {}%, Free Memory: {} MB",
|
||||
String.format("%.1f", cpuUsage * 100),
|
||||
String.format("%.1f", memoryUsage * 100),
|
||||
String.format(Locale.ROOT, "%.1f", cpuUsage * 100),
|
||||
String.format(Locale.ROOT, "%.1f", memoryUsage * 100),
|
||||
freeMemory / (1024 * 1024));
|
||||
}
|
||||
} catch (Exception e) {
|
||||
|
||||
+8
-5
@@ -5,6 +5,7 @@ import java.net.Inet6Address;
|
||||
import java.net.InetAddress;
|
||||
import java.net.URI;
|
||||
import java.net.UnknownHostException;
|
||||
import java.util.Locale;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
import org.springframework.stereotype.Service;
|
||||
@@ -83,7 +84,7 @@ public class SsrfProtectionService {
|
||||
return false;
|
||||
}
|
||||
|
||||
return config.getAllowedDomains().contains(host.toLowerCase());
|
||||
return config.getAllowedDomains().contains(host.toLowerCase(Locale.ROOT));
|
||||
|
||||
} catch (Exception e) {
|
||||
log.debug("Failed to parse URL for MAX security check: {}", url, e);
|
||||
@@ -101,7 +102,7 @@ public class SsrfProtectionService {
|
||||
return false;
|
||||
}
|
||||
|
||||
String hostLower = host.toLowerCase();
|
||||
String hostLower = host.toLowerCase(Locale.ROOT);
|
||||
|
||||
// Check explicit blocked domains
|
||||
if (config.getBlockedDomains().contains(hostLower)) {
|
||||
@@ -111,7 +112,7 @@ public class SsrfProtectionService {
|
||||
|
||||
// Check internal TLD patterns
|
||||
for (String tld : config.getInternalTlds()) {
|
||||
if (hostLower.endsWith(tld.toLowerCase())) {
|
||||
if (hostLower.endsWith(tld.toLowerCase(Locale.ROOT))) {
|
||||
log.debug("URL blocked by internal TLD pattern '{}': {}", tld, url);
|
||||
return false;
|
||||
}
|
||||
@@ -123,9 +124,11 @@ public class SsrfProtectionService {
|
||||
config.getAllowedDomains().stream()
|
||||
.anyMatch(
|
||||
domain ->
|
||||
hostLower.equals(domain.toLowerCase())
|
||||
hostLower.equals(domain.toLowerCase(Locale.ROOT))
|
||||
|| hostLower.endsWith(
|
||||
"." + domain.toLowerCase()));
|
||||
"."
|
||||
+ domain.toLowerCase(
|
||||
Locale.ROOT)));
|
||||
|
||||
if (!isAllowed) {
|
||||
log.debug("URL not in allowed domains list: {}", url);
|
||||
|
||||
@@ -7,6 +7,7 @@ import java.time.LocalDateTime;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.Executors;
|
||||
@@ -101,14 +102,16 @@ public class TaskManager {
|
||||
if (!extractedFiles.isEmpty()) {
|
||||
jobResult.completeWithFiles(extractedFiles);
|
||||
log.debug(
|
||||
"Set multiple file results for job ID: {} with {} files extracted from ZIP",
|
||||
"Set multiple file results for job ID: {} with {} files extracted from"
|
||||
+ " ZIP",
|
||||
jobId,
|
||||
extractedFiles.size());
|
||||
return;
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn(
|
||||
"Failed to extract ZIP file for job {}: {}. Falling back to single file result.",
|
||||
"Failed to extract ZIP file for job {}: {}. Falling back to single file"
|
||||
+ " result.",
|
||||
jobId,
|
||||
e.getMessage());
|
||||
}
|
||||
@@ -342,12 +345,12 @@ public class TaskManager {
|
||||
/** Check if a file is a ZIP file based on content type and filename */
|
||||
private boolean isZipFile(String contentType, String fileName) {
|
||||
if (contentType != null
|
||||
&& (contentType.equals("application/zip")
|
||||
|| contentType.equals("application/x-zip-compressed"))) {
|
||||
&& ("application/zip".equals(contentType)
|
||||
|| "application/x-zip-compressed".equals(contentType))) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (fileName != null && fileName.toLowerCase().endsWith(".zip")) {
|
||||
if (fileName != null && fileName.toLowerCase(Locale.ROOT).endsWith(".zip")) {
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -414,7 +417,7 @@ public class TaskManager {
|
||||
return MediaType.APPLICATION_OCTET_STREAM_VALUE;
|
||||
}
|
||||
|
||||
String lowerName = fileName.toLowerCase();
|
||||
String lowerName = fileName.toLowerCase(Locale.ROOT);
|
||||
if (lowerName.endsWith(".pdf")) {
|
||||
return MediaType.APPLICATION_PDF_VALUE;
|
||||
} else if (lowerName.endsWith(".txt")) {
|
||||
|
||||
@@ -6,6 +6,7 @@ import java.io.InputStream;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
|
||||
import org.apache.commons.io.FilenameUtils;
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
@@ -58,31 +59,24 @@ public class CbrUtils {
|
||||
log.warn(
|
||||
"Failed to open CBR/RAR archive due to corrupt header: {}",
|
||||
e.getMessage());
|
||||
throw ExceptionUtils.createIllegalArgumentException(
|
||||
"error.invalidFormat",
|
||||
"Invalid or corrupted CBR/RAR archive. "
|
||||
+ "The file may be corrupted, use an unsupported RAR format (RAR5+), "
|
||||
+ "or may not be a valid RAR archive. "
|
||||
+ "Please ensure the file is a valid RAR archive.");
|
||||
throw ExceptionUtils.createCbrInvalidFormatException(null);
|
||||
} catch (RarException e) {
|
||||
log.warn("Failed to open CBR/RAR archive: {}", e.getMessage());
|
||||
String errorMessage;
|
||||
String exMessage = e.getMessage() != null ? e.getMessage() : "";
|
||||
|
||||
if (exMessage.contains("encrypted")) {
|
||||
errorMessage = "Encrypted CBR/RAR archives are not supported.";
|
||||
throw ExceptionUtils.createCbrEncryptedException();
|
||||
} else if (exMessage.isEmpty()) {
|
||||
errorMessage =
|
||||
"Invalid CBR/RAR archive. "
|
||||
+ "The file may be encrypted, corrupted, or use an unsupported format.";
|
||||
throw ExceptionUtils.createCbrInvalidFormatException(
|
||||
"Invalid CBR/RAR archive. The file may be encrypted, corrupted, or"
|
||||
+ " use an unsupported format.");
|
||||
} else {
|
||||
errorMessage =
|
||||
throw ExceptionUtils.createCbrInvalidFormatException(
|
||||
"Invalid CBR/RAR archive: "
|
||||
+ exMessage
|
||||
+ ". The file may be encrypted, corrupted, or use an unsupported format.";
|
||||
+ ". The file may be encrypted, corrupted, or use an"
|
||||
+ " unsupported format.");
|
||||
}
|
||||
throw ExceptionUtils.createIllegalArgumentException(
|
||||
"error.invalidFormat", errorMessage);
|
||||
} catch (IOException e) {
|
||||
log.warn("IO error reading CBR/RAR archive: {}", e.getMessage());
|
||||
throw ExceptionUtils.createFileProcessingException("CBR extraction", e);
|
||||
@@ -121,7 +115,8 @@ public class CbrUtils {
|
||||
if (imageEntries.isEmpty()) {
|
||||
throw ExceptionUtils.createIllegalArgumentException(
|
||||
"error.fileProcessing",
|
||||
"No valid images found in the CBR file. The archive may be empty or contain no supported image formats.");
|
||||
"No valid images found in the CBR file. The archive may be empty or"
|
||||
+ " contain no supported image formats.");
|
||||
}
|
||||
|
||||
for (ImageEntryData imageEntry : imageEntries) {
|
||||
@@ -146,7 +141,8 @@ public class CbrUtils {
|
||||
if (document.getNumberOfPages() == 0) {
|
||||
throw ExceptionUtils.createIllegalArgumentException(
|
||||
"error.fileProcessing",
|
||||
"No images could be processed from the CBR file. All images may be corrupted or in unsupported formats.");
|
||||
"No images could be processed from the CBR file. All images may be"
|
||||
+ " corrupted or in unsupported formats.");
|
||||
}
|
||||
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
@@ -159,7 +155,6 @@ public class CbrUtils {
|
||||
return GeneralUtils.optimizePdfWithGhostscript(pdfBytes);
|
||||
} catch (IOException e) {
|
||||
log.warn("Ghostscript optimization failed, returning unoptimized PDF", e);
|
||||
return pdfBytes;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -170,17 +165,17 @@ public class CbrUtils {
|
||||
|
||||
private void validateCbrFile(MultipartFile file) {
|
||||
if (file == null || file.isEmpty()) {
|
||||
throw new IllegalArgumentException("File cannot be null or empty");
|
||||
throw ExceptionUtils.createFileNullOrEmptyException();
|
||||
}
|
||||
|
||||
String filename = file.getOriginalFilename();
|
||||
if (filename == null) {
|
||||
throw new IllegalArgumentException("File must have a name");
|
||||
throw ExceptionUtils.createFileNoNameException();
|
||||
}
|
||||
|
||||
String extension = FilenameUtils.getExtension(filename).toLowerCase();
|
||||
String extension = FilenameUtils.getExtension(filename).toLowerCase(Locale.ROOT);
|
||||
if (!"cbr".equals(extension) && !"rar".equals(extension)) {
|
||||
throw new IllegalArgumentException("File must be a CBR or RAR archive");
|
||||
throw ExceptionUtils.createNotCbrFileException();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -190,7 +185,7 @@ public class CbrUtils {
|
||||
return false;
|
||||
}
|
||||
|
||||
String extension = FilenameUtils.getExtension(filename).toLowerCase();
|
||||
String extension = FilenameUtils.getExtension(filename).toLowerCase(Locale.ROOT);
|
||||
return "cbr".equals(extension) || "rar".equals(extension);
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.Enumeration;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.zip.ZipEntry;
|
||||
import java.util.zip.ZipFile;
|
||||
import java.util.zip.ZipInputStream;
|
||||
@@ -55,10 +56,10 @@ public class CbzUtils {
|
||||
new java.io.FileInputStream(tempFile.getFile()));
|
||||
ZipInputStream zis = new ZipInputStream(bis)) {
|
||||
if (zis.getNextEntry() == null) {
|
||||
throw new IllegalArgumentException("Archive is empty or invalid ZIP");
|
||||
throw ExceptionUtils.createCbzEmptyException();
|
||||
}
|
||||
} catch (IOException e) {
|
||||
throw new IllegalArgumentException("Invalid CBZ/ZIP archive", e);
|
||||
throw ExceptionUtils.createCbzInvalidFormatException(e);
|
||||
}
|
||||
|
||||
try (PDDocument document = pdfDocumentFactory.createNewDocument();
|
||||
@@ -83,7 +84,7 @@ public class CbzUtils {
|
||||
Comparator.comparing(ImageEntryData::name, new NaturalOrderComparator()));
|
||||
|
||||
if (imageEntries.isEmpty()) {
|
||||
throw new IllegalArgumentException("No valid images found in the CBZ file");
|
||||
throw ExceptionUtils.createCbzNoImagesException();
|
||||
}
|
||||
|
||||
for (ImageEntryData imageEntry : imageEntries) {
|
||||
@@ -106,8 +107,7 @@ public class CbzUtils {
|
||||
}
|
||||
|
||||
if (document.getNumberOfPages() == 0) {
|
||||
throw new IllegalArgumentException(
|
||||
"No images could be processed from the CBZ file");
|
||||
throw ExceptionUtils.createCbzCorruptedImagesException();
|
||||
}
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
document.save(baos);
|
||||
@@ -119,7 +119,6 @@ public class CbzUtils {
|
||||
return GeneralUtils.optimizePdfWithGhostscript(pdfBytes);
|
||||
} catch (IOException e) {
|
||||
log.warn("Ghostscript optimization failed, returning unoptimized PDF", e);
|
||||
return pdfBytes;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -130,17 +129,17 @@ public class CbzUtils {
|
||||
|
||||
private void validateCbzFile(MultipartFile file) {
|
||||
if (file == null || file.isEmpty()) {
|
||||
throw new IllegalArgumentException("File cannot be null or empty");
|
||||
throw ExceptionUtils.createFileNullOrEmptyException();
|
||||
}
|
||||
|
||||
String filename = file.getOriginalFilename();
|
||||
if (filename == null) {
|
||||
throw new IllegalArgumentException("File must have a name");
|
||||
throw ExceptionUtils.createFileNoNameException();
|
||||
}
|
||||
|
||||
String extension = FilenameUtils.getExtension(filename).toLowerCase();
|
||||
String extension = FilenameUtils.getExtension(filename).toLowerCase(Locale.ROOT);
|
||||
if (!"cbz".equals(extension) && !"zip".equals(extension)) {
|
||||
throw new IllegalArgumentException("File must be a CBZ or ZIP archive");
|
||||
throw ExceptionUtils.createNotCbzFileException();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -150,7 +149,7 @@ public class CbzUtils {
|
||||
return false;
|
||||
}
|
||||
|
||||
String extension = FilenameUtils.getExtension(filename).toLowerCase();
|
||||
String extension = FilenameUtils.getExtension(filename).toLowerCase(Locale.ROOT);
|
||||
return "cbz".equals(extension) || "zip".equals(extension);
|
||||
}
|
||||
|
||||
@@ -160,7 +159,7 @@ public class CbzUtils {
|
||||
return false;
|
||||
}
|
||||
|
||||
String extension = FilenameUtils.getExtension(filename).toLowerCase();
|
||||
String extension = FilenameUtils.getExtension(filename).toLowerCase(Locale.ROOT);
|
||||
return "cbz".equals(extension)
|
||||
|| "zip".equals(extension)
|
||||
|| "cbr".equals(extension)
|
||||
|
||||
@@ -11,6 +11,8 @@ public class CheckProgramInstall {
|
||||
private static final List<String> PYTHON_COMMANDS = Arrays.asList("python3", "python");
|
||||
private static boolean pythonAvailableChecked = false;
|
||||
private static String availablePythonCommand = null;
|
||||
private static boolean ffmpegAvailableChecked = false;
|
||||
private static boolean ffmpegAvailable = false;
|
||||
|
||||
/**
|
||||
* Checks which Python command is available and returns it.
|
||||
@@ -56,4 +58,25 @@ public class CheckProgramInstall {
|
||||
public static boolean isPythonAvailable() {
|
||||
return getAvailablePythonCommand() != null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if FFmpeg is available on the system.
|
||||
*
|
||||
* @return true if FFmpeg is installed and accessible, false otherwise.
|
||||
*/
|
||||
public static boolean isFfmpegAvailable() {
|
||||
if (!ffmpegAvailableChecked) {
|
||||
try {
|
||||
ProcessExecutorResult result =
|
||||
ProcessExecutor.getInstance(ProcessExecutor.Processes.FFMPEG)
|
||||
.runCommandWithOutputHandling(Arrays.asList("ffmpeg", "-version"));
|
||||
ffmpegAvailable = true;
|
||||
} catch (IOException | InterruptedException e) {
|
||||
ffmpegAvailable = false;
|
||||
} finally {
|
||||
ffmpegAvailableChecked = true;
|
||||
}
|
||||
}
|
||||
return ffmpegAvailable;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,14 +58,11 @@ public class ChecksumUtils {
|
||||
* @throws IOException if reading from the stream fails
|
||||
*/
|
||||
public static String checksum(InputStream is, String algorithm) throws IOException {
|
||||
switch (algorithm.toUpperCase(Locale.ROOT)) {
|
||||
case "CRC32":
|
||||
return checksumChecksum(is, new CRC32());
|
||||
case "ADLER32":
|
||||
return checksumChecksum(is, new Adler32());
|
||||
default:
|
||||
return toHex(checksumBytes(is, algorithm));
|
||||
}
|
||||
return switch (algorithm.toUpperCase(Locale.ROOT)) {
|
||||
case "CRC32" -> checksumChecksum(is, new CRC32());
|
||||
case "ADLER32" -> checksumChecksum(is, new Adler32());
|
||||
default -> toHex(checksumBytes(is, algorithm));
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -98,14 +95,13 @@ public class ChecksumUtils {
|
||||
* @throws IOException if reading from the stream fails
|
||||
*/
|
||||
public static String checksumBase64(InputStream is, String algorithm) throws IOException {
|
||||
switch (algorithm.toUpperCase(Locale.ROOT)) {
|
||||
case "CRC32":
|
||||
return Base64.getEncoder().encodeToString(checksumChecksumBytes(is, new CRC32()));
|
||||
case "ADLER32":
|
||||
return Base64.getEncoder().encodeToString(checksumChecksumBytes(is, new Adler32()));
|
||||
default:
|
||||
return Base64.getEncoder().encodeToString(checksumBytes(is, algorithm));
|
||||
}
|
||||
return switch (algorithm.toUpperCase(Locale.ROOT)) {
|
||||
case "CRC32" ->
|
||||
Base64.getEncoder().encodeToString(checksumChecksumBytes(is, new CRC32()));
|
||||
case "ADLER32" ->
|
||||
Base64.getEncoder().encodeToString(checksumChecksumBytes(is, new Adler32()));
|
||||
default -> Base64.getEncoder().encodeToString(checksumBytes(is, algorithm));
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -179,7 +175,7 @@ public class ChecksumUtils {
|
||||
for (Map.Entry<String, Checksum> entry : checksums.entrySet()) {
|
||||
// Keep value as long and mask to ensure unsigned hex formatting.
|
||||
long unsigned32 = entry.getValue().getValue() & UNSIGNED_32_BIT_MASK;
|
||||
results.put(entry.getKey(), String.format("%08x", unsigned32));
|
||||
results.put(entry.getKey(), String.format(Locale.ROOT, "%08x", unsigned32));
|
||||
}
|
||||
return results;
|
||||
}
|
||||
@@ -258,7 +254,7 @@ public class ChecksumUtils {
|
||||
}
|
||||
// Keep as long and mask to ensure correct unsigned representation.
|
||||
long unsigned32 = checksum.getValue() & UNSIGNED_32_BIT_MASK;
|
||||
return String.format("%08x", unsigned32);
|
||||
return String.format(Locale.ROOT, "%08x", unsigned32);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -294,7 +290,7 @@ public class ChecksumUtils {
|
||||
private static String toHex(byte[] hash) {
|
||||
StringBuilder sb = new StringBuilder(hash.length * 2);
|
||||
for (byte b : hash) {
|
||||
sb.append(String.format("%02x", b));
|
||||
sb.append(String.format(Locale.ROOT, "%02x", b));
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
@@ -62,8 +62,7 @@ public class CustomHtmlSanitizer {
|
||||
.and(new HtmlPolicyBuilder().disallowElements("noscript").toFactory());
|
||||
|
||||
public String sanitize(String html) {
|
||||
boolean disableSanitize =
|
||||
Boolean.TRUE.equals(applicationProperties.getSystem().getDisableSanitize());
|
||||
boolean disableSanitize = applicationProperties.getSystem().isDisableSanitize();
|
||||
return disableSanitize ? html : POLICY.sanitize(html);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import java.time.ZonedDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Properties;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
@@ -229,7 +230,8 @@ public class EmlParser {
|
||||
Method getContentType = message.getClass().getMethod("getContentType");
|
||||
String contentType = (String) getContentType.invoke(message);
|
||||
|
||||
if (contentType != null && contentType.toLowerCase().contains(TEXT_HTML)) {
|
||||
if (contentType != null
|
||||
&& contentType.toLowerCase(Locale.ROOT).contains(TEXT_HTML)) {
|
||||
content.setHtmlBody(stringContent);
|
||||
} else {
|
||||
content.setTextBody(stringContent);
|
||||
@@ -296,7 +298,7 @@ public class EmlParser {
|
||||
String contentType = (String) getContentType.invoke(part);
|
||||
|
||||
String normalizedDisposition =
|
||||
disposition != null ? ((String) disposition).toLowerCase() : null;
|
||||
disposition != null ? ((String) disposition).toLowerCase(Locale.ROOT) : null;
|
||||
|
||||
if ((Boolean) isMimeType.invoke(part, TEXT_PLAIN) && normalizedDisposition == null) {
|
||||
Object partContent = getContent.invoke(part);
|
||||
@@ -422,7 +424,7 @@ public class EmlParser {
|
||||
RegexPatternUtils.getInstance().getNewlineSplitPattern().split(emlContent);
|
||||
for (int i = 0; i < lines.length; i++) {
|
||||
String line = lines[i];
|
||||
if (line.toLowerCase().startsWith(headerName.toLowerCase())) {
|
||||
if (line.toLowerCase(Locale.ROOT).startsWith(headerName.toLowerCase(Locale.ROOT))) {
|
||||
StringBuilder value =
|
||||
new StringBuilder(line.substring(headerName.length()).trim());
|
||||
for (int j = i + 1; j < lines.length; j++) {
|
||||
@@ -444,7 +446,7 @@ public class EmlParser {
|
||||
|
||||
private static String extractHtmlBody(String emlContent) {
|
||||
try {
|
||||
String lowerContent = emlContent.toLowerCase();
|
||||
String lowerContent = emlContent.toLowerCase(Locale.ROOT);
|
||||
int htmlStart = lowerContent.indexOf(HEADER_CONTENT_TYPE + " " + TEXT_HTML);
|
||||
if (htmlStart == -1) return null;
|
||||
|
||||
@@ -463,7 +465,7 @@ public class EmlParser {
|
||||
|
||||
private static String extractTextBody(String emlContent) {
|
||||
try {
|
||||
String lowerContent = emlContent.toLowerCase();
|
||||
String lowerContent = emlContent.toLowerCase(Locale.ROOT);
|
||||
int textStart = lowerContent.indexOf(HEADER_CONTENT_TYPE + " " + TEXT_PLAIN);
|
||||
if (textStart == -1) {
|
||||
int bodyStart = emlContent.indexOf("\r\n\r\n");
|
||||
@@ -516,7 +518,7 @@ public class EmlParser {
|
||||
String currentEncoding = "";
|
||||
|
||||
for (String line : lines) {
|
||||
String lowerLine = line.toLowerCase().trim();
|
||||
String lowerLine = line.toLowerCase(Locale.ROOT).trim();
|
||||
|
||||
if (line.trim().isEmpty()) {
|
||||
inHeaders = false;
|
||||
@@ -554,9 +556,12 @@ public class EmlParser {
|
||||
}
|
||||
|
||||
private static boolean isAttachment(String disposition, String filename, String contentType) {
|
||||
return (disposition.toLowerCase().contains(DISPOSITION_ATTACHMENT) && !filename.isEmpty())
|
||||
|| (!filename.isEmpty() && !contentType.toLowerCase().startsWith("text/"))
|
||||
|| (contentType.toLowerCase().contains("application/") && !filename.isEmpty());
|
||||
return (disposition.toLowerCase(Locale.ROOT).contains(DISPOSITION_ATTACHMENT)
|
||||
&& !filename.isEmpty())
|
||||
|| (!filename.isEmpty()
|
||||
&& !contentType.toLowerCase(Locale.ROOT).startsWith("text/"))
|
||||
|| (contentType.toLowerCase(Locale.ROOT).contains("application/")
|
||||
&& !filename.isEmpty());
|
||||
}
|
||||
|
||||
private static String extractFilenameFromDisposition(String disposition) {
|
||||
@@ -565,8 +570,8 @@ public class EmlParser {
|
||||
}
|
||||
|
||||
// Handle filename*= (RFC 2231 encoded filename)
|
||||
if (disposition.toLowerCase().contains("filename*=")) {
|
||||
int filenameStarStart = disposition.toLowerCase().indexOf("filename*=") + 10;
|
||||
if (disposition.toLowerCase(Locale.ROOT).contains("filename*=")) {
|
||||
int filenameStarStart = disposition.toLowerCase(Locale.ROOT).indexOf("filename*=") + 10;
|
||||
int filenameStarEnd = disposition.indexOf(";", filenameStarStart);
|
||||
if (filenameStarEnd == -1) filenameStarEnd = disposition.length();
|
||||
String extendedFilename =
|
||||
@@ -586,7 +591,7 @@ public class EmlParser {
|
||||
}
|
||||
|
||||
// Handle regular filename=
|
||||
int filenameStart = disposition.toLowerCase().indexOf("filename=") + 9;
|
||||
int filenameStart = disposition.toLowerCase(Locale.ROOT).indexOf("filename=") + 9;
|
||||
int filenameEnd = disposition.indexOf(";", filenameStart);
|
||||
if (filenameEnd == -1) filenameEnd = disposition.length();
|
||||
String filename = disposition.substring(filenameStart, filenameEnd).trim();
|
||||
|
||||
@@ -48,11 +48,11 @@ public class EmlProcessingUtils {
|
||||
|
||||
public static void validateEmlInput(byte[] emlBytes) {
|
||||
if (emlBytes == null || emlBytes.length == 0) {
|
||||
throw new IllegalArgumentException("EML file is empty or null");
|
||||
throw ExceptionUtils.createEmlEmptyException();
|
||||
}
|
||||
|
||||
if (isInvalidEmlFormat(emlBytes)) {
|
||||
throw new IllegalArgumentException("Invalid EML file format");
|
||||
throw ExceptionUtils.createEmlInvalidFormatException();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -109,12 +109,13 @@ public class EmlProcessingUtils {
|
||||
|
||||
html.append(
|
||||
String.format(
|
||||
Locale.ROOT,
|
||||
"""
|
||||
<!DOCTYPE html>
|
||||
<html lang="en"><head><meta charset="UTF-8">
|
||||
<title>%s</title>
|
||||
<style>
|
||||
""",
|
||||
<!DOCTYPE html>
|
||||
<html lang="en"><head><meta charset="UTF-8">
|
||||
<title>%s</title>
|
||||
<style>
|
||||
""",
|
||||
sanitizeText(content.getSubject(), customHtmlSanitizer)));
|
||||
|
||||
appendEnhancedStyles(html);
|
||||
@@ -127,14 +128,15 @@ public class EmlProcessingUtils {
|
||||
|
||||
html.append(
|
||||
String.format(
|
||||
Locale.ROOT,
|
||||
"""
|
||||
<div class="email-container">
|
||||
<div class="email-header">
|
||||
<h1>%s</h1>
|
||||
<div class="email-meta">
|
||||
<div><strong>From:</strong> %s</div>
|
||||
<div><strong>To:</strong> %s</div>
|
||||
""",
|
||||
<div class="email-container">
|
||||
<div class="email-header">
|
||||
<h1>%s</h1>
|
||||
<div class="email-meta">
|
||||
<div><strong>From:</strong> %s</div>
|
||||
<div><strong>To:</strong> %s</div>
|
||||
""",
|
||||
sanitizeText(content.getSubject(), customHtmlSanitizer),
|
||||
sanitizeText(content.getFrom(), customHtmlSanitizer),
|
||||
sanitizeText(content.getTo(), customHtmlSanitizer)));
|
||||
@@ -142,6 +144,7 @@ public class EmlProcessingUtils {
|
||||
if (content.getCc() != null && !content.getCc().trim().isEmpty()) {
|
||||
html.append(
|
||||
String.format(
|
||||
Locale.ROOT,
|
||||
"<div><strong>CC:</strong> %s</div>\n",
|
||||
sanitizeText(content.getCc(), customHtmlSanitizer)));
|
||||
}
|
||||
@@ -149,6 +152,7 @@ public class EmlProcessingUtils {
|
||||
if (content.getBcc() != null && !content.getBcc().trim().isEmpty()) {
|
||||
html.append(
|
||||
String.format(
|
||||
Locale.ROOT,
|
||||
"<div><strong>BCC:</strong> %s</div>\n",
|
||||
sanitizeText(content.getBcc(), customHtmlSanitizer)));
|
||||
}
|
||||
@@ -156,11 +160,13 @@ public class EmlProcessingUtils {
|
||||
if (content.getDate() != null) {
|
||||
html.append(
|
||||
String.format(
|
||||
Locale.ROOT,
|
||||
"<div><strong>Date:</strong> %s</div>\n",
|
||||
PdfAttachmentHandler.formatEmailDate(content.getDate())));
|
||||
} else if (content.getDateString() != null && !content.getDateString().trim().isEmpty()) {
|
||||
html.append(
|
||||
String.format(
|
||||
Locale.ROOT,
|
||||
"<div><strong>Date:</strong> %s</div>\n",
|
||||
sanitizeText(content.getDateString(), customHtmlSanitizer)));
|
||||
}
|
||||
@@ -175,6 +181,7 @@ public class EmlProcessingUtils {
|
||||
} else if (content.getTextBody() != null && !content.getTextBody().trim().isEmpty()) {
|
||||
html.append(
|
||||
String.format(
|
||||
Locale.ROOT,
|
||||
"<div class=\"text-body\">%s</div>",
|
||||
convertTextToHtml(content.getTextBody(), customHtmlSanitizer)));
|
||||
} else {
|
||||
@@ -234,14 +241,16 @@ public class EmlProcessingUtils {
|
||||
.getUrlLinkPattern()
|
||||
.matcher(html)
|
||||
.replaceAll(
|
||||
"<a href=\"$1\" style=\"color: #1a73e8; text-decoration: underline;\">$1</a>");
|
||||
"<a href=\"$1\" style=\"color: #1a73e8; text-decoration:"
|
||||
+ " underline;\">$1</a>");
|
||||
|
||||
html =
|
||||
RegexPatternUtils.getInstance()
|
||||
.getEmailLinkPattern()
|
||||
.matcher(html)
|
||||
.replaceAll(
|
||||
"<a href=\"mailto:$1\" style=\"color: #1a73e8; text-decoration: underline;\">$1</a>");
|
||||
"<a href=\"mailto:$1\" style=\"color: #1a73e8; text-decoration:"
|
||||
+ " underline;\">$1</a>");
|
||||
|
||||
return html;
|
||||
}
|
||||
@@ -249,127 +258,128 @@ public class EmlProcessingUtils {
|
||||
private static void appendEnhancedStyles(StringBuilder html) {
|
||||
String css =
|
||||
String.format(
|
||||
Locale.ROOT,
|
||||
"""
|
||||
body {
|
||||
font-family: %s;
|
||||
font-size: %dpx;
|
||||
line-height: %s;
|
||||
color: %s;
|
||||
margin: 0;
|
||||
padding: 16px;
|
||||
background-color: %s;
|
||||
}
|
||||
body {
|
||||
font-family: %s;
|
||||
font-size: %dpx;
|
||||
line-height: %s;
|
||||
color: %s;
|
||||
margin: 0;
|
||||
padding: 16px;
|
||||
background-color: %s;
|
||||
}
|
||||
|
||||
.email-container {
|
||||
width: 100%%;
|
||||
max-width: 100%%;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.email-container {
|
||||
width: 100%%;
|
||||
max-width: 100%%;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.email-header {
|
||||
padding-bottom: 10px;
|
||||
border-bottom: 1px solid %s;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.email-header {
|
||||
padding-bottom: 10px;
|
||||
border-bottom: 1px solid %s;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.email-header h1 {
|
||||
margin: 0 0 10px 0;
|
||||
font-size: %dpx;
|
||||
font-weight: bold;
|
||||
}
|
||||
.email-header h1 {
|
||||
margin: 0 0 10px 0;
|
||||
font-size: %dpx;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.email-meta div {
|
||||
margin-bottom: 2px;
|
||||
font-size: %dpx;
|
||||
}
|
||||
.email-meta div {
|
||||
margin-bottom: 2px;
|
||||
font-size: %dpx;
|
||||
}
|
||||
|
||||
.email-body {
|
||||
word-wrap: break-word;
|
||||
}
|
||||
.email-body {
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
.attachment-section {
|
||||
margin-top: 15px;
|
||||
padding: 10px;
|
||||
background-color: %s;
|
||||
border: 1px solid %s;
|
||||
border-radius: 3px;
|
||||
}
|
||||
.attachment-section {
|
||||
margin-top: 15px;
|
||||
padding: 10px;
|
||||
background-color: %s;
|
||||
border: 1px solid %s;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.attachment-section h3 {
|
||||
margin: 0 0 8px 0;
|
||||
font-size: %dpx;
|
||||
}
|
||||
.attachment-section h3 {
|
||||
margin: 0 0 8px 0;
|
||||
font-size: %dpx;
|
||||
}
|
||||
|
||||
.attachment-item {
|
||||
padding: 5px 0;
|
||||
}
|
||||
.attachment-item {
|
||||
padding: 5px 0;
|
||||
}
|
||||
|
||||
.attachment-icon {
|
||||
margin-right: 5px;
|
||||
}
|
||||
.attachment-icon {
|
||||
margin-right: 5px;
|
||||
}
|
||||
|
||||
.attachment-details, .attachment-type {
|
||||
font-size: %dpx;
|
||||
color: #555555;
|
||||
}
|
||||
.attachment-details, .attachment-type {
|
||||
font-size: %dpx;
|
||||
color: #555555;
|
||||
}
|
||||
|
||||
.attachment-inclusion-note, .attachment-info-note {
|
||||
margin-top: 8px;
|
||||
padding: 6px;
|
||||
font-size: %dpx;
|
||||
border-radius: 3px;
|
||||
}
|
||||
.attachment-inclusion-note, .attachment-info-note {
|
||||
margin-top: 8px;
|
||||
padding: 6px;
|
||||
font-size: %dpx;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.attachment-inclusion-note {
|
||||
background-color: #e6ffed;
|
||||
border: 1px solid #d4f7dc;
|
||||
color: #006420;
|
||||
}
|
||||
.attachment-inclusion-note {
|
||||
background-color: #e6ffed;
|
||||
border: 1px solid #d4f7dc;
|
||||
color: #006420;
|
||||
}
|
||||
|
||||
.attachment-info-note {
|
||||
background-color: #fff9e6;
|
||||
border: 1px solid #fff0c2;
|
||||
color: #664d00;
|
||||
}
|
||||
.attachment-info-note {
|
||||
background-color: #fff9e6;
|
||||
border: 1px solid #fff0c2;
|
||||
color: #664d00;
|
||||
}
|
||||
|
||||
.attachment-link-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 8px;
|
||||
background-color: #f8f9fa;
|
||||
border: 1px solid #dee2e6;
|
||||
border-radius: 4px;
|
||||
margin: 4px 0;
|
||||
}
|
||||
.attachment-link-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 8px;
|
||||
background-color: #f8f9fa;
|
||||
border: 1px solid #dee2e6;
|
||||
border-radius: 4px;
|
||||
margin: 4px 0;
|
||||
}
|
||||
|
||||
.attachment-link-container:hover {
|
||||
background-color: #e9ecef;
|
||||
}
|
||||
.attachment-link-container:hover {
|
||||
background-color: #e9ecef;
|
||||
}
|
||||
|
||||
.attachment-note {
|
||||
font-size: %dpx;
|
||||
color: #6c757d;
|
||||
font-style: italic;
|
||||
margin-left: 8px;
|
||||
}
|
||||
.attachment-note {
|
||||
font-size: %dpx;
|
||||
color: #6c757d;
|
||||
font-style: italic;
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
.no-content {
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
color: #666;
|
||||
font-style: italic;
|
||||
}
|
||||
.no-content {
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
color: #666;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.text-body {
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
.text-body {
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
img {
|
||||
max-width: 100%%;
|
||||
height: auto;
|
||||
display: block;
|
||||
}
|
||||
""",
|
||||
img {
|
||||
max-width: 100%%;
|
||||
height: auto;
|
||||
display: block;
|
||||
}
|
||||
""",
|
||||
DEFAULT_FONT_FAMILY,
|
||||
DEFAULT_FONT_SIZE,
|
||||
DEFAULT_LINE_HEIGHT,
|
||||
@@ -420,13 +430,14 @@ public class EmlProcessingUtils {
|
||||
String attachmentId = "attachment_" + i;
|
||||
html.append(
|
||||
String.format(
|
||||
Locale.ROOT,
|
||||
"""
|
||||
<div class="attachment-item" id="%s">
|
||||
<span class="attachment-icon" data-filename="%s">@</span>
|
||||
<span class="attachment-name">%s</span>
|
||||
<span class="attachment-details">(%s%s)</span>
|
||||
</div>
|
||||
""",
|
||||
<div class="attachment-item" id="%s">
|
||||
<span class="attachment-icon" data-filename="%s">@</span>
|
||||
<span class="attachment-name">%s</span>
|
||||
<span class="attachment-details">(%s%s)</span>
|
||||
</div>
|
||||
""",
|
||||
attachmentId,
|
||||
escapeHtml(embeddedFilename),
|
||||
escapeHtml(EmlParser.safeMimeDecode(attachment.getFilename())),
|
||||
@@ -470,7 +481,7 @@ public class EmlProcessingUtils {
|
||||
}
|
||||
|
||||
if (filename != null) {
|
||||
String lowerFilename = filename.toLowerCase();
|
||||
String lowerFilename = filename.toLowerCase(Locale.ROOT);
|
||||
for (Map.Entry<String, String> entry : EXTENSION_TO_MIME_TYPE.entrySet()) {
|
||||
if (lowerFilename.endsWith(entry.getKey())) {
|
||||
return entry.getValue();
|
||||
@@ -516,7 +527,7 @@ public class EmlProcessingUtils {
|
||||
result.append(processedText, lastEnd, matcher.start());
|
||||
|
||||
String charset = matcher.group(1);
|
||||
String encoding = matcher.group(2).toUpperCase();
|
||||
String encoding = matcher.group(2).toUpperCase(Locale.ROOT);
|
||||
String encodedValue = matcher.group(3);
|
||||
|
||||
try {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -5,13 +5,11 @@ import java.io.FileOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.UncheckedIOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.FileVisitResult;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.SimpleFileVisitor;
|
||||
import java.nio.file.attribute.BasicFileAttributes;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.stream.Stream;
|
||||
import java.util.zip.ZipEntry;
|
||||
import java.util.zip.ZipInputStream;
|
||||
@@ -37,9 +35,11 @@ public class FileToPdf {
|
||||
try (TempFile tempInputFile =
|
||||
new TempFile(
|
||||
tempFileManager,
|
||||
fileName.toLowerCase().endsWith(".html") ? ".html" : ".zip")) {
|
||||
fileName.toLowerCase(Locale.ROOT).endsWith(".html")
|
||||
? ".html"
|
||||
: ".zip")) {
|
||||
|
||||
if (fileName.toLowerCase().endsWith(".html")) {
|
||||
if (fileName.toLowerCase(Locale.ROOT).endsWith(".html")) {
|
||||
String sanitizedHtml =
|
||||
sanitizeHtmlContent(
|
||||
new String(fileBytes, StandardCharsets.UTF_8),
|
||||
@@ -47,7 +47,7 @@ public class FileToPdf {
|
||||
Files.write(
|
||||
tempInputFile.getPath(),
|
||||
sanitizedHtml.getBytes(StandardCharsets.UTF_8));
|
||||
} else if (fileName.toLowerCase().endsWith(".zip")) {
|
||||
} else if (fileName.toLowerCase(Locale.ROOT).endsWith(".zip")) {
|
||||
Files.write(tempInputFile.getPath(), fileBytes);
|
||||
sanitizeHtmlFilesInZip(
|
||||
tempInputFile.getPath(), tempFileManager, customHtmlSanitizer);
|
||||
@@ -102,8 +102,8 @@ public class FileToPdf {
|
||||
tempUnzippedDir.getPath().resolve(sanitizeZipFilename(entry.getName()));
|
||||
if (!entry.isDirectory()) {
|
||||
Files.createDirectories(filePath.getParent());
|
||||
if (entry.getName().toLowerCase().endsWith(".html")
|
||||
|| entry.getName().toLowerCase().endsWith(".htm")) {
|
||||
if (entry.getName().toLowerCase(Locale.ROOT).endsWith(".html")
|
||||
|| entry.getName().toLowerCase(Locale.ROOT).endsWith(".htm")) {
|
||||
String content =
|
||||
new String(zipIn.readAllBytes(), StandardCharsets.UTF_8);
|
||||
String sanitizedContent =
|
||||
@@ -145,64 +145,6 @@ public class FileToPdf {
|
||||
}
|
||||
}
|
||||
|
||||
private static void deleteDirectory(Path dir) throws IOException {
|
||||
Files.walkFileTree(
|
||||
dir,
|
||||
new SimpleFileVisitor<Path>() {
|
||||
@Override
|
||||
public FileVisitResult visitFile(Path file, BasicFileAttributes attrs)
|
||||
throws IOException {
|
||||
Files.delete(file);
|
||||
return FileVisitResult.CONTINUE;
|
||||
}
|
||||
|
||||
@Override
|
||||
public FileVisitResult postVisitDirectory(Path dir, IOException exc)
|
||||
throws IOException {
|
||||
Files.delete(dir);
|
||||
return FileVisitResult.CONTINUE;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private static Path unzipAndGetMainHtml(byte[] fileBytes) throws IOException {
|
||||
Path tempDirectory = Files.createTempDirectory("unzipped_");
|
||||
try (ZipInputStream zipIn =
|
||||
ZipSecurity.createHardenedInputStream(new ByteArrayInputStream(fileBytes))) {
|
||||
ZipEntry entry = zipIn.getNextEntry();
|
||||
while (entry != null) {
|
||||
Path filePath = tempDirectory.resolve(sanitizeZipFilename(entry.getName()));
|
||||
if (entry.isDirectory()) {
|
||||
Files.createDirectories(filePath); // Explicitly create the directory structure
|
||||
} else {
|
||||
Files.createDirectories(
|
||||
filePath.getParent()); // Create parent directories if they don't exist
|
||||
Files.copy(zipIn, filePath);
|
||||
}
|
||||
zipIn.closeEntry();
|
||||
entry = zipIn.getNextEntry();
|
||||
}
|
||||
}
|
||||
|
||||
// Search for the main HTML file.
|
||||
try (Stream<Path> walk = Files.walk(tempDirectory)) {
|
||||
List<Path> htmlFiles = walk.filter(file -> file.toString().endsWith(".html")).toList();
|
||||
|
||||
if (htmlFiles.isEmpty()) {
|
||||
throw new IOException("No HTML files found in the unzipped directory.");
|
||||
}
|
||||
|
||||
// Prioritize 'index.html' if it exists, otherwise use the first .html file
|
||||
for (Path htmlFile : htmlFiles) {
|
||||
if ("index.html".equals(htmlFile.getFileName().toString())) {
|
||||
return htmlFile;
|
||||
}
|
||||
}
|
||||
|
||||
return htmlFiles.get(0);
|
||||
}
|
||||
}
|
||||
|
||||
static String sanitizeZipFilename(String entryName) {
|
||||
if (entryName == null || entryName.trim().isEmpty()) {
|
||||
return "";
|
||||
|
||||
+9
-4
@@ -1,4 +1,4 @@
|
||||
package stirling.software.proprietary.util;
|
||||
package stirling.software.common.util;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.HashMap;
|
||||
@@ -23,9 +23,13 @@ import org.apache.pdfbox.pdmodel.interactive.form.PDTerminalField;
|
||||
import lombok.experimental.UtilityClass;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
/**
|
||||
* Utility class for copying and transforming PDF form fields during page operations. Used by
|
||||
* multi-page layout and other page manipulation operations that need to preserve form fields.
|
||||
*/
|
||||
@Slf4j
|
||||
@UtilityClass
|
||||
public class FormCopyUtils {
|
||||
public class GeneralFormCopyUtils {
|
||||
|
||||
public boolean hasAnyRotatedPage(PDDocument document) {
|
||||
try {
|
||||
@@ -188,7 +192,8 @@ public class FormCopyUtils {
|
||||
continue;
|
||||
}
|
||||
|
||||
FormFieldTypeSupport handler = FormFieldTypeSupport.forField(terminalField);
|
||||
GeneralFormFieldTypeSupport handler =
|
||||
GeneralFormFieldTypeSupport.forField(terminalField);
|
||||
if (handler == null) {
|
||||
log.debug(
|
||||
"Skipping unsupported field type '{}' for widget '{}'",
|
||||
@@ -222,7 +227,7 @@ public class FormCopyUtils {
|
||||
}
|
||||
|
||||
private void copyFieldUsingHandler(
|
||||
FormFieldTypeSupport handler,
|
||||
GeneralFormFieldTypeSupport handler,
|
||||
PDTerminalField sourceField,
|
||||
PDAcroForm newAcroForm,
|
||||
PDPage destinationPage,
|
||||
+165
@@ -0,0 +1,165 @@
|
||||
package stirling.software.common.util;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
import org.apache.pdfbox.pdmodel.interactive.form.PDAcroForm;
|
||||
import org.apache.pdfbox.pdmodel.interactive.form.PDCheckBox;
|
||||
import org.apache.pdfbox.pdmodel.interactive.form.PDComboBox;
|
||||
import org.apache.pdfbox.pdmodel.interactive.form.PDField;
|
||||
import org.apache.pdfbox.pdmodel.interactive.form.PDListBox;
|
||||
import org.apache.pdfbox.pdmodel.interactive.form.PDPushButton;
|
||||
import org.apache.pdfbox.pdmodel.interactive.form.PDRadioButton;
|
||||
import org.apache.pdfbox.pdmodel.interactive.form.PDSignatureField;
|
||||
import org.apache.pdfbox.pdmodel.interactive.form.PDTerminalField;
|
||||
import org.apache.pdfbox.pdmodel.interactive.form.PDTextField;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
/**
|
||||
* Simplified form field type support for general PDF operations. This is a subset of the full
|
||||
* proprietary FormFieldTypeSupport, containing only what's needed for basic form field copying
|
||||
* during page operations.
|
||||
*/
|
||||
@Slf4j
|
||||
public enum GeneralFormFieldTypeSupport {
|
||||
TEXT("text", "textField", PDTextField.class) {
|
||||
@Override
|
||||
PDTerminalField createField(PDAcroForm acroForm) {
|
||||
PDTextField textField = new PDTextField(acroForm);
|
||||
textField.setDefaultAppearance("/Helv 12 Tf 0 g");
|
||||
return textField;
|
||||
}
|
||||
|
||||
@Override
|
||||
void copyFromOriginal(PDTerminalField source, PDTerminalField target) throws IOException {
|
||||
PDTextField src = (PDTextField) source;
|
||||
PDTextField dst = (PDTextField) target;
|
||||
String value = src.getValueAsString();
|
||||
if (value != null) {
|
||||
dst.setValue(value);
|
||||
}
|
||||
}
|
||||
},
|
||||
CHECKBOX("checkbox", "checkBox", PDCheckBox.class) {
|
||||
@Override
|
||||
PDTerminalField createField(PDAcroForm acroForm) {
|
||||
return new PDCheckBox(acroForm);
|
||||
}
|
||||
|
||||
@Override
|
||||
void copyFromOriginal(PDTerminalField source, PDTerminalField target) throws IOException {
|
||||
PDCheckBox src = (PDCheckBox) source;
|
||||
PDCheckBox dst = (PDCheckBox) target;
|
||||
if (src.isChecked()) {
|
||||
dst.check();
|
||||
} else {
|
||||
dst.unCheck();
|
||||
}
|
||||
}
|
||||
},
|
||||
RADIO("radio", "radioButton", PDRadioButton.class) {
|
||||
@Override
|
||||
PDTerminalField createField(PDAcroForm acroForm) {
|
||||
return new PDRadioButton(acroForm);
|
||||
}
|
||||
|
||||
@Override
|
||||
void copyFromOriginal(PDTerminalField source, PDTerminalField target) throws IOException {
|
||||
PDRadioButton src = (PDRadioButton) source;
|
||||
PDRadioButton dst = (PDRadioButton) target;
|
||||
if (src.getExportValues() != null) {
|
||||
dst.setExportValues(src.getExportValues());
|
||||
}
|
||||
if (src.getValue() != null) {
|
||||
dst.setValue(src.getValue());
|
||||
}
|
||||
}
|
||||
},
|
||||
COMBOBOX("combobox", "comboBox", PDComboBox.class) {
|
||||
@Override
|
||||
PDTerminalField createField(PDAcroForm acroForm) {
|
||||
return new PDComboBox(acroForm);
|
||||
}
|
||||
|
||||
@Override
|
||||
void copyFromOriginal(PDTerminalField source, PDTerminalField target) throws IOException {
|
||||
PDComboBox src = (PDComboBox) source;
|
||||
PDComboBox dst = (PDComboBox) target;
|
||||
if (src.getOptions() != null) {
|
||||
dst.setOptions(src.getOptions());
|
||||
}
|
||||
if (src.getValue() != null && !src.getValue().isEmpty()) {
|
||||
dst.setValue(src.getValue());
|
||||
}
|
||||
}
|
||||
},
|
||||
LISTBOX("listbox", "listBox", PDListBox.class) {
|
||||
@Override
|
||||
PDTerminalField createField(PDAcroForm acroForm) {
|
||||
return new PDListBox(acroForm);
|
||||
}
|
||||
|
||||
@Override
|
||||
void copyFromOriginal(PDTerminalField source, PDTerminalField target) throws IOException {
|
||||
PDListBox src = (PDListBox) source;
|
||||
PDListBox dst = (PDListBox) target;
|
||||
if (src.getOptions() != null) {
|
||||
dst.setOptions(src.getOptions());
|
||||
}
|
||||
if (src.getValue() != null && !src.getValue().isEmpty()) {
|
||||
dst.setValue(src.getValue());
|
||||
}
|
||||
}
|
||||
},
|
||||
SIGNATURE("signature", "signature", PDSignatureField.class) {
|
||||
@Override
|
||||
PDTerminalField createField(PDAcroForm acroForm) {
|
||||
return new PDSignatureField(acroForm);
|
||||
}
|
||||
},
|
||||
BUTTON("button", "pushButton", PDPushButton.class) {
|
||||
@Override
|
||||
PDTerminalField createField(PDAcroForm acroForm) {
|
||||
return new PDPushButton(acroForm);
|
||||
}
|
||||
};
|
||||
|
||||
private final String typeName;
|
||||
private final String fallbackWidgetName;
|
||||
private final Class<? extends PDTerminalField> fieldClass;
|
||||
|
||||
GeneralFormFieldTypeSupport(
|
||||
String typeName,
|
||||
String fallbackWidgetName,
|
||||
Class<? extends PDTerminalField> fieldClass) {
|
||||
this.typeName = typeName;
|
||||
this.fallbackWidgetName = fallbackWidgetName;
|
||||
this.fieldClass = fieldClass;
|
||||
}
|
||||
|
||||
public static GeneralFormFieldTypeSupport forField(PDField field) {
|
||||
if (field == null) {
|
||||
return null;
|
||||
}
|
||||
for (GeneralFormFieldTypeSupport handler : values()) {
|
||||
if (handler.fieldClass.isInstance(field)) {
|
||||
return handler;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
String typeName() {
|
||||
return typeName;
|
||||
}
|
||||
|
||||
String fallbackWidgetName() {
|
||||
return fallbackWidgetName;
|
||||
}
|
||||
|
||||
abstract PDTerminalField createField(PDAcroForm acroForm);
|
||||
|
||||
void copyFromOriginal(PDTerminalField source, PDTerminalField target) throws IOException {
|
||||
// default no-op
|
||||
}
|
||||
}
|
||||
@@ -33,6 +33,11 @@ import stirling.software.common.configuration.InstallationPathConfig;
|
||||
@UtilityClass
|
||||
public class GeneralUtils {
|
||||
|
||||
/**
|
||||
* Maximum number of resolved DNS addresses allowed for a host before it is considered unsafe.
|
||||
*/
|
||||
private static final int MAX_DNS_ADDRESSES = 20;
|
||||
|
||||
private final Set<String> DEFAULT_VALID_SCRIPTS = Set.of("png_to_webp.py", "split_photos.py");
|
||||
private final Set<String> DEFAULT_VALID_PIPELINE =
|
||||
Set.of(
|
||||
@@ -90,32 +95,6 @@ public class GeneralUtils {
|
||||
return tempFile;
|
||||
}
|
||||
|
||||
/*
|
||||
* Gets the configured temporary directory, creating it if necessary.
|
||||
*
|
||||
* @return Path to the temporary directory
|
||||
* @throws IOException if directory creation fails
|
||||
*/
|
||||
private Path getTempDirectory() throws IOException {
|
||||
String customTempDir = System.getenv("STIRLING_TEMPFILES_DIRECTORY");
|
||||
if (customTempDir == null || customTempDir.isEmpty()) {
|
||||
customTempDir = System.getProperty("stirling.tempfiles.directory");
|
||||
}
|
||||
|
||||
Path tempDir;
|
||||
if (customTempDir != null && !customTempDir.isEmpty()) {
|
||||
tempDir = Path.of(customTempDir);
|
||||
} else {
|
||||
tempDir = Path.of(System.getProperty("java.io.tmpdir"), "stirling-pdf");
|
||||
}
|
||||
|
||||
if (!Files.exists(tempDir)) {
|
||||
Files.createDirectories(tempDir);
|
||||
}
|
||||
|
||||
return tempDir;
|
||||
}
|
||||
|
||||
/*
|
||||
* Remove file extension
|
||||
*
|
||||
@@ -154,7 +133,7 @@ public class GeneralUtils {
|
||||
return matcher.find() ? matcher.replaceFirst("") : filename;
|
||||
}
|
||||
|
||||
/*
|
||||
/**
|
||||
* Append suffix to base name with null safety.
|
||||
*
|
||||
* @param baseName the base filename, null becomes "default"
|
||||
@@ -165,7 +144,7 @@ public class GeneralUtils {
|
||||
return (baseName == null ? "default" : baseName) + (suffix != null ? suffix : "");
|
||||
}
|
||||
|
||||
/*
|
||||
/**
|
||||
* Generate a PDF filename by removing extension from first file and adding suffix.
|
||||
*
|
||||
* <p>High-level utility method for common PDF naming scenarios. Handles null safety and uses
|
||||
@@ -180,7 +159,7 @@ public class GeneralUtils {
|
||||
return appendSuffix(baseName, suffix);
|
||||
}
|
||||
|
||||
/*
|
||||
/**
|
||||
* Process a list of filenames by removing extensions and adding suffix.
|
||||
*
|
||||
* <p>Efficiently processes multiple filenames using streaming operations and bulk operations
|
||||
@@ -201,7 +180,7 @@ public class GeneralUtils {
|
||||
.forEach(processor);
|
||||
}
|
||||
|
||||
/*
|
||||
/**
|
||||
* Extract title from filename by removing extension, with fallback handling.
|
||||
*
|
||||
* <p>Returns "Untitled" for null or empty filenames, otherwise removes the extension using the
|
||||
@@ -269,6 +248,12 @@ public class GeneralUtils {
|
||||
.getResources(pattern);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates URL syntax and disallows common-infrastructure targets to reduce SSRF risk.
|
||||
*
|
||||
* @param urlStr a URL string to validate
|
||||
* @return {@code true} if the URL is syntactically valid and allowed; {@code false} otherwise
|
||||
*/
|
||||
public boolean isValidURL(String urlStr) {
|
||||
try {
|
||||
Urls.create(
|
||||
@@ -279,7 +264,7 @@ public class GeneralUtils {
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
/**
|
||||
* Checks if a URL is reachable with proper timeout configuration and error handling.
|
||||
*
|
||||
* @param urlStr the URL string to check
|
||||
@@ -289,15 +274,17 @@ public class GeneralUtils {
|
||||
return isURLReachable(urlStr, 5000, 5000);
|
||||
}
|
||||
|
||||
/*
|
||||
* Checks if a URL is reachable with configurable timeouts.
|
||||
/**
|
||||
* Checks whether a URL is reachable using configurable timeouts. Only {@code http} and {@code
|
||||
* https} protocols are permitted, and local/private/multicast ranges are blocked.
|
||||
*
|
||||
* @param urlStr the URL string to check
|
||||
* @param urlStr the URL to probe
|
||||
* @param connectTimeout connection timeout in milliseconds
|
||||
* @param readTimeout read timeout in milliseconds
|
||||
* @return true if URL is reachable, false otherwise
|
||||
* @return {@code true} if a HEAD request returns a 2xx or 3xx status; {@code false} otherwise
|
||||
*/
|
||||
public boolean isURLReachable(String urlStr, int connectTimeout, int readTimeout) {
|
||||
HttpURLConnection connection = null;
|
||||
try {
|
||||
// Parse the URL
|
||||
URL url = URI.create(urlStr).toURL();
|
||||
@@ -308,14 +295,17 @@ public class GeneralUtils {
|
||||
return false; // Disallow other protocols
|
||||
}
|
||||
|
||||
// Check if the host is a local address
|
||||
String host = url.getHost();
|
||||
if (isLocalAddress(host)) {
|
||||
return false; // Exclude local addresses
|
||||
if (host == null || host.isBlank()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (isDisallowedNetworkLocation(host)) {
|
||||
return false; // Exclude local, private or otherwise sensitive addresses
|
||||
}
|
||||
|
||||
// Check if the URL is reachable
|
||||
HttpURLConnection connection = (HttpURLConnection) url.openConnection();
|
||||
connection = (HttpURLConnection) url.openConnection();
|
||||
connection.setRequestMethod("HEAD");
|
||||
connection.setConnectTimeout(connectTimeout);
|
||||
connection.setReadTimeout(readTimeout);
|
||||
@@ -326,29 +316,173 @@ public class GeneralUtils {
|
||||
} catch (Exception e) {
|
||||
log.debug("URL {} is not reachable: {}", urlStr, e.getMessage());
|
||||
return false; // Return false in case of any exception
|
||||
} finally {
|
||||
if (connection != null) {
|
||||
connection.disconnect();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private boolean isLocalAddress(String host) {
|
||||
/**
|
||||
* Determines whether the specified host resolves to a disallowed network location, such as
|
||||
* local, private, multicast, or reserved ranges. Excessive DNS results are also blocked.
|
||||
*
|
||||
* @param host the hostname to resolve
|
||||
* @return {@code true} if the host should be considered unsafe
|
||||
*/
|
||||
private boolean isDisallowedNetworkLocation(String host) {
|
||||
// Resolution is delegated to the JVM/OS resolver which already applies system
|
||||
// configured query limits and timeouts. We only need the resolved addresses here so
|
||||
// that we can enforce the MAX_DNS_ADDRESSES limit and perform the sensitive range
|
||||
// checks below.
|
||||
try {
|
||||
// Resolve DNS to IP address
|
||||
InetAddress address = InetAddress.getByName(host);
|
||||
|
||||
// Check for local addresses
|
||||
return address.isAnyLocalAddress()
|
||||
|| // Matches 0.0.0.0 or similar
|
||||
address.isLoopbackAddress()
|
||||
|| // Matches 127.0.0.1 or ::1
|
||||
address.isSiteLocalAddress()
|
||||
|| // Matches private IPv4 ranges: 192.168.x.x, 10.x.x.x, 172.16.x.x to
|
||||
// 172.31.x.x
|
||||
address.getHostAddress()
|
||||
.startsWith("fe80:"); // Matches link-local IPv6 addresses
|
||||
InetAddress[] addresses = InetAddress.getAllByName(host);
|
||||
if (addresses.length > MAX_DNS_ADDRESSES) {
|
||||
log.debug(
|
||||
"Blocking URL to host {} due to excessive DNS records (>{})",
|
||||
host,
|
||||
MAX_DNS_ADDRESSES);
|
||||
return true;
|
||||
}
|
||||
for (InetAddress address : addresses) {
|
||||
if (address == null || isSensitiveAddress(address)) {
|
||||
log.debug("Blocking URL to host {} resolved to {}", host, address);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
} catch (Exception e) {
|
||||
return false; // Return false for invalid or unresolved addresses
|
||||
log.debug("Unable to resolve host {}: {}", host, e.getMessage());
|
||||
return true; // Treat resolution issues as unsafe to avoid SSRF
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether the given IP address lies within ranges that should not be contacted by the
|
||||
* server (loopback, link-local, private, multicast, etc.). IPv6 ULA and IPv4-mapped addresses
|
||||
* are handled.
|
||||
*
|
||||
* @param address the resolved address
|
||||
* @return {@code true} if the address is considered sensitive
|
||||
*/
|
||||
private boolean isSensitiveAddress(InetAddress address) {
|
||||
if (address.isAnyLocalAddress()
|
||||
|| address.isLoopbackAddress()
|
||||
|| address.isLinkLocalAddress()
|
||||
|| address.isSiteLocalAddress()
|
||||
|| address.isMulticastAddress()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
byte[] rawAddress = address.getAddress();
|
||||
if (address instanceof Inet4Address) {
|
||||
return isPrivateOrReservedIPv4(rawAddress);
|
||||
}
|
||||
|
||||
if (address instanceof Inet6Address inet6Address) {
|
||||
if (isUniqueLocalIPv6(rawAddress)) {
|
||||
return true;
|
||||
}
|
||||
if (isIPv4MappedAddress(rawAddress) || inet6Address.isIPv4CompatibleAddress()) {
|
||||
byte[] ipv4 =
|
||||
Arrays.copyOfRange(rawAddress, rawAddress.length - 4, rawAddress.length);
|
||||
return isPrivateOrReservedIPv4(ipv4);
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether an IPv4 address is private or reserved. Any malformed input defaults to {@code
|
||||
* true} (conservative) to avoid misuse.
|
||||
*
|
||||
* @param address 4-byte IPv4 address
|
||||
* @return {@code true} if private/reserved
|
||||
*/
|
||||
private boolean isPrivateOrReservedIPv4(byte[] address) {
|
||||
// IPv4 addresses must be exactly 4 bytes. Treat null or unexpected lengths as
|
||||
// sensitive to avoid processing malformed input.
|
||||
if (address == null || address.length != 4) {
|
||||
return true;
|
||||
}
|
||||
|
||||
int first = Byte.toUnsignedInt(address[0]);
|
||||
int second = Byte.toUnsignedInt(address[1]);
|
||||
|
||||
if (first == 0 || first == 127) {
|
||||
return true; // 0.0.0.0/8 and 127.0.0.0/8
|
||||
}
|
||||
if (first == 100 && second >= 64 && second <= 127) {
|
||||
return true; // 100.64.0.0/10 Carrier-grade NAT
|
||||
}
|
||||
if (first == 169 && second == 254) {
|
||||
return true; // 169.254.0.0/16 Link-local
|
||||
}
|
||||
if (first == 172 && second >= 16 && second <= 31) {
|
||||
return true; // 172.16.0.0/12 Private
|
||||
}
|
||||
if (first == 192 && second == 0 && Byte.toUnsignedInt(address[2]) == 0) {
|
||||
return true; // 192.0.0.0/24 IETF Protocol Assignments
|
||||
}
|
||||
if (first == 192 && second == 0 && Byte.toUnsignedInt(address[2]) == 2) {
|
||||
return true; // 192.0.2.0/24 TEST-NET-1
|
||||
}
|
||||
if (first == 192 && second == 168) {
|
||||
return true; // 192.168.0.0/16 Private
|
||||
}
|
||||
if (first == 198 && (second == 18 || second == 19)) {
|
||||
return true; // 198.18.0.0/15 Benchmark tests
|
||||
}
|
||||
if (first == 198 && second == 51 && Byte.toUnsignedInt(address[2]) == 100) {
|
||||
return true; // 198.51.100.0/24 TEST-NET-2
|
||||
}
|
||||
if (first == 203 && second == 0 && Byte.toUnsignedInt(address[2]) == 113) {
|
||||
return true; // 203.0.113.0/24 TEST-NET-3
|
||||
}
|
||||
if (first == 10) {
|
||||
return true; // 10.0.0.0/8 Private
|
||||
}
|
||||
if (first >= 224) {
|
||||
return true; // 224.0.0.0/4 Multicast and 240.0.0.0/4 Reserved for future use
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether an IPv6 address is a Unique Local Address (ULA, fc00::/7). Any malformed input
|
||||
* defaults to {@code true} (conservative) to avoid misuse.
|
||||
*
|
||||
* @param address 16-byte IPv6 address
|
||||
* @return {@code true} if ULA
|
||||
*/
|
||||
private boolean isUniqueLocalIPv6(byte[] address) {
|
||||
if (address == null || address.length != 16) {
|
||||
return true;
|
||||
}
|
||||
int first = Byte.toUnsignedInt(address[0]);
|
||||
return (first & 0xFE) == 0xFC; // fc00::/7 Unique local addresses
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether an IPv6 address is an IPv4-mapped address (::ffff:0:0/96). Any malformed input
|
||||
* defaults to {@code false} (conservative) to avoid misuse.
|
||||
*
|
||||
* @param address 16-byte IPv6 address
|
||||
* @return {@code true} if IPv4-mapped
|
||||
*/
|
||||
private boolean isIPv4MappedAddress(byte[] address) {
|
||||
if (address == null || address.length != 16) {
|
||||
return false;
|
||||
}
|
||||
for (int i = 0; i < 10; i++) {
|
||||
if (address[i] != 0) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return address[10] == (byte) 0xFF && address[11] == (byte) 0xFF;
|
||||
}
|
||||
|
||||
/*
|
||||
* Improved multipart file conversion using the shared helper method.
|
||||
*
|
||||
@@ -386,7 +520,7 @@ public class GeneralUtils {
|
||||
throw new IllegalArgumentException("Invalid default unit: " + defaultUnit);
|
||||
}
|
||||
|
||||
sizeStr = sizeStr.trim().toUpperCase();
|
||||
sizeStr = sizeStr.trim().toUpperCase(Locale.ROOT);
|
||||
sizeStr = sizeStr.replace(",", ".").replace(" ", "");
|
||||
|
||||
try {
|
||||
@@ -415,7 +549,7 @@ public class GeneralUtils {
|
||||
return Long.parseLong(sizeStr.substring(0, sizeStr.length() - 1));
|
||||
} else {
|
||||
// Use provided default unit or fall back to MB
|
||||
String unit = defaultUnit != null ? defaultUnit.toUpperCase() : "MB";
|
||||
String unit = defaultUnit != null ? defaultUnit.toUpperCase(Locale.ROOT) : "MB";
|
||||
double value = Double.parseDouble(sizeStr);
|
||||
return switch (unit) {
|
||||
case "TB" -> (long) (value * 1024L * 1024L * 1024L * 1024L);
|
||||
@@ -457,13 +591,14 @@ public class GeneralUtils {
|
||||
if (bytes < 1024) {
|
||||
return bytes + " B";
|
||||
} else if (bytes < 1024L * 1024L) {
|
||||
return String.format(Locale.US, "%.2f KB", bytes / 1024.0);
|
||||
return String.format(Locale.ROOT, "%.2f KB", bytes / 1024.0);
|
||||
} else if (bytes < 1024L * 1024L * 1024L) {
|
||||
return String.format(Locale.US, "%.2f MB", bytes / (1024.0 * 1024.0));
|
||||
return String.format(Locale.ROOT, "%.2f MB", bytes / (1024.0 * 1024.0));
|
||||
} else if (bytes < 1024L * 1024L * 1024L * 1024L) {
|
||||
return String.format(Locale.US, "%.2f GB", bytes / (1024.0 * 1024.0 * 1024.0));
|
||||
return String.format(Locale.ROOT, "%.2f GB", bytes / (1024.0 * 1024.0 * 1024.0));
|
||||
} else {
|
||||
return String.format(Locale.US, "%.2f TB", bytes / (1024.0 * 1024.0 * 1024.0 * 1024.0));
|
||||
return String.format(
|
||||
Locale.ROOT, "%.2f TB", bytes / (1024.0 * 1024.0 * 1024.0 * 1024.0));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -723,7 +858,7 @@ public class GeneralUtils {
|
||||
byte[] mac = net.getHardwareAddress();
|
||||
if (mac != null && mac.length > 0) {
|
||||
for (byte b : mac) {
|
||||
sb.append(String.format("%02X", b));
|
||||
sb.append(String.format(Locale.ROOT, "%02X", b));
|
||||
}
|
||||
break; // Use the first valid network interface
|
||||
}
|
||||
@@ -733,7 +868,7 @@ public class GeneralUtils {
|
||||
byte[] mac = network.getHardwareAddress();
|
||||
if (mac != null) {
|
||||
for (byte b : mac) {
|
||||
sb.append(String.format("%02X", b));
|
||||
sb.append(String.format(Locale.ROOT, "%02X", b));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -940,17 +1075,29 @@ public class GeneralUtils {
|
||||
ProcessExecutor.getInstance(ProcessExecutor.Processes.GHOSTSCRIPT)
|
||||
.runCommandWithOutputHandling(command);
|
||||
|
||||
ExceptionUtils.GhostscriptException detectedError =
|
||||
ExceptionUtils.detectGhostscriptCriticalError(result.getMessages());
|
||||
if (detectedError != null) {
|
||||
log.warn(
|
||||
"Ghostscript ebook optimization reported a critical error: {}",
|
||||
detectedError.getMessage());
|
||||
throw detectedError;
|
||||
}
|
||||
|
||||
if (result.getRc() != 0) {
|
||||
log.warn(
|
||||
"Ghostscript ebook optimization failed with return code: {}",
|
||||
result.getRc());
|
||||
throw ExceptionUtils.createGhostscriptCompressionException();
|
||||
throw ExceptionUtils.createGhostscriptCompressionException(result.getMessages());
|
||||
}
|
||||
|
||||
return Files.readAllBytes(tempOutput);
|
||||
|
||||
} catch (Exception e) {
|
||||
log.warn("Ghostscript ebook optimization failed", e);
|
||||
if (e instanceof ExceptionUtils.GhostscriptException ghostscriptException) {
|
||||
throw ghostscriptException;
|
||||
}
|
||||
throw ExceptionUtils.createGhostscriptCompressionException(e);
|
||||
} finally {
|
||||
if (tempInput != null) {
|
||||
|
||||
@@ -6,6 +6,7 @@ import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.util.Iterator;
|
||||
import java.util.Locale;
|
||||
|
||||
import javax.imageio.ImageIO;
|
||||
import javax.imageio.ImageReader;
|
||||
@@ -118,7 +119,7 @@ public class ImageProcessingUtils {
|
||||
BufferedImage image = null;
|
||||
String filename = file.getOriginalFilename();
|
||||
|
||||
if (filename != null && filename.toLowerCase().endsWith(".psd")) {
|
||||
if (filename != null && filename.toLowerCase(Locale.ROOT).endsWith(".psd")) {
|
||||
// For PSD files, try explicit ImageReader
|
||||
Iterator<ImageReader> readers = ImageIO.getImageReadersByFormatName("PSD");
|
||||
if (readers.hasNext()) {
|
||||
@@ -134,7 +135,8 @@ public class ImageProcessingUtils {
|
||||
throw new IOException(
|
||||
"Unable to read image from file: "
|
||||
+ filename
|
||||
+ ". Supported PSD formats: RGB/CMYK/Gray 8-32 bit, RLE/ZIP compression");
|
||||
+ ". Supported PSD formats: RGB/CMYK/Gray 8-32 bit, RLE/ZIP"
|
||||
+ " compression");
|
||||
}
|
||||
} else {
|
||||
// For non-PSD files, use standard ImageIO
|
||||
@@ -142,7 +144,7 @@ public class ImageProcessingUtils {
|
||||
}
|
||||
|
||||
if (image == null) {
|
||||
throw new IOException("Unable to read image from file: " + filename);
|
||||
throw ExceptionUtils.createImageReadException(filename);
|
||||
}
|
||||
|
||||
double orientation = extractImageOrientation(file.getInputStream());
|
||||
|
||||
@@ -27,15 +27,22 @@ import io.github.pixee.security.Filenames;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.configuration.RuntimePathConfig;
|
||||
import stirling.software.common.util.ProcessExecutor.ProcessExecutorResult;
|
||||
|
||||
@Slf4j
|
||||
public class PDFToFile {
|
||||
|
||||
private final TempFileManager tempFileManager;
|
||||
private final RuntimePathConfig runtimePathConfig;
|
||||
|
||||
public PDFToFile(TempFileManager tempFileManager) {
|
||||
this(tempFileManager, null);
|
||||
}
|
||||
|
||||
public PDFToFile(TempFileManager tempFileManager, RuntimePathConfig runtimePathConfig) {
|
||||
this.tempFileManager = tempFileManager;
|
||||
this.runtimePathConfig = runtimePathConfig;
|
||||
}
|
||||
|
||||
public ResponseEntity<byte[]> processPdfToMarkdown(MultipartFile inputFile)
|
||||
@@ -241,31 +248,65 @@ public class PDFToFile {
|
||||
byte[] fileBytes;
|
||||
String fileName;
|
||||
|
||||
Path libreOfficeProfile = null;
|
||||
try (TempFile inputFileTemp = new TempFile(tempFileManager, ".pdf");
|
||||
TempDirectory outputDirTemp = new TempDirectory(tempFileManager)) {
|
||||
|
||||
Path tempInputFile = inputFileTemp.getPath();
|
||||
Path tempOutputDir = outputDirTemp.getPath();
|
||||
Path unoOutputFile =
|
||||
tempOutputDir.resolve(
|
||||
pdfBaseName + "." + resolvePrimaryExtension(outputFormat));
|
||||
|
||||
// Save the uploaded file to a temporary location
|
||||
inputFile.transferTo(tempInputFile);
|
||||
|
||||
// Run the LibreOffice command
|
||||
List<String> command =
|
||||
new ArrayList<>(
|
||||
Arrays.asList(
|
||||
"soffice",
|
||||
"--headless",
|
||||
"--nologo",
|
||||
"--infilter=" + libreOfficeFilter,
|
||||
"--convert-to",
|
||||
outputFormat,
|
||||
"--outdir",
|
||||
tempOutputDir.toString(),
|
||||
tempInputFile.toString()));
|
||||
ProcessExecutorResult returnCode =
|
||||
ProcessExecutor.getInstance(ProcessExecutor.Processes.LIBRE_OFFICE)
|
||||
.runCommandWithOutputHandling(command);
|
||||
ProcessExecutorResult returnCode = null;
|
||||
IOException unoconvertException = null;
|
||||
|
||||
if (isUnoConvertEnabled()) {
|
||||
try {
|
||||
List<String> unoCommand =
|
||||
buildUnoConvertCommand(
|
||||
tempInputFile, unoOutputFile, outputFormat, libreOfficeFilter);
|
||||
returnCode =
|
||||
ProcessExecutor.getInstance(ProcessExecutor.Processes.LIBRE_OFFICE)
|
||||
.runCommandWithOutputHandling(unoCommand);
|
||||
} catch (IOException e) {
|
||||
unoconvertException = e;
|
||||
log.warn(
|
||||
"Unoconvert command failed ({}). Falling back to soffice command.",
|
||||
e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
if (returnCode == null) {
|
||||
// Run the LibreOffice command as a fallback
|
||||
libreOfficeProfile = Files.createTempDirectory("libreoffice_profile_");
|
||||
List<String> command = new ArrayList<>();
|
||||
command.add(runtimePathConfig.getSOfficePath());
|
||||
command.add("-env:UserInstallation=" + libreOfficeProfile.toUri().toString());
|
||||
command.add("--headless");
|
||||
command.add("--nologo");
|
||||
command.add("--infilter=" + libreOfficeFilter);
|
||||
command.add("--convert-to");
|
||||
command.add(outputFormat);
|
||||
command.add("--outdir");
|
||||
command.add(tempOutputDir.toString());
|
||||
command.add(tempInputFile.toString());
|
||||
|
||||
try {
|
||||
returnCode =
|
||||
ProcessExecutor.getInstance(ProcessExecutor.Processes.LIBRE_OFFICE)
|
||||
.runCommandWithOutputHandling(command);
|
||||
} catch (IOException e) {
|
||||
if (unoconvertException != null) {
|
||||
e.addSuppressed(unoconvertException);
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
// Get output files
|
||||
List<File> outputFiles = Arrays.asList(tempOutputDir.toFile().listFiles());
|
||||
@@ -300,8 +341,42 @@ public class PDFToFile {
|
||||
|
||||
fileBytes = byteArrayOutputStream.toByteArray();
|
||||
}
|
||||
} finally {
|
||||
if (libreOfficeProfile != null) {
|
||||
FileUtils.deleteQuietly(libreOfficeProfile.toFile());
|
||||
}
|
||||
}
|
||||
return WebResponseUtils.bytesToWebResponse(
|
||||
fileBytes, fileName, MediaType.APPLICATION_OCTET_STREAM);
|
||||
}
|
||||
|
||||
private boolean isUnoConvertEnabled() {
|
||||
return runtimePathConfig != null
|
||||
&& runtimePathConfig.getUnoConvertPath() != null
|
||||
&& !runtimePathConfig.getUnoConvertPath().isBlank();
|
||||
}
|
||||
|
||||
private List<String> buildUnoConvertCommand(
|
||||
Path inputFile, Path outputFile, String outputFormat, String libreOfficeFilter) {
|
||||
List<String> command = new ArrayList<>();
|
||||
command.add(runtimePathConfig.getUnoConvertPath());
|
||||
command.add("--port");
|
||||
command.add("2003");
|
||||
command.add("--convert-to");
|
||||
command.add(outputFormat);
|
||||
if (libreOfficeFilter != null && !libreOfficeFilter.isBlank()) {
|
||||
command.add("--input-filter=" + libreOfficeFilter);
|
||||
}
|
||||
command.add(inputFile.toString());
|
||||
command.add(outputFile.toString());
|
||||
return command;
|
||||
}
|
||||
|
||||
private String resolvePrimaryExtension(String outputFormat) {
|
||||
if (outputFormat == null) {
|
||||
return "";
|
||||
}
|
||||
int colonIndex = outputFormat.indexOf(':');
|
||||
return colonIndex > 0 ? outputFormat.substring(0, colonIndex) : outputFormat;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -318,7 +318,7 @@ public class PdfAttachmentHandler {
|
||||
|
||||
private static String normalizeFilename(String filename) {
|
||||
if (filename == null) return "";
|
||||
String normalized = filename.toLowerCase().trim();
|
||||
String normalized = filename.toLowerCase(Locale.ROOT).trim();
|
||||
normalized =
|
||||
RegexPatternUtils.getInstance()
|
||||
.getWhitespacePattern()
|
||||
@@ -560,7 +560,7 @@ public class PdfAttachmentHandler {
|
||||
@Override
|
||||
protected void writeString(String string, List<TextPosition> textPositions)
|
||||
throws IOException {
|
||||
String lowerString = string.toLowerCase();
|
||||
String lowerString = string.toLowerCase(Locale.ROOT);
|
||||
|
||||
if (ATTACHMENT_SECTION_PATTERN.matcher(lowerString).find()) {
|
||||
isInAttachmentSection = true;
|
||||
|
||||
@@ -9,6 +9,7 @@ import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
|
||||
import javax.imageio.ImageIO;
|
||||
|
||||
@@ -34,7 +35,7 @@ public class PdfToCbrUtils {
|
||||
|
||||
try (PDDocument document = pdfDocumentFactory.load(pdfFile)) {
|
||||
if (document.getNumberOfPages() == 0) {
|
||||
throw new IllegalArgumentException("PDF file contains no pages");
|
||||
throw ExceptionUtils.createPdfNoPages();
|
||||
}
|
||||
|
||||
return createCbrFromPdf(document, dpi);
|
||||
@@ -43,17 +44,17 @@ public class PdfToCbrUtils {
|
||||
|
||||
private static void validatePdfFile(MultipartFile file) {
|
||||
if (file == null || file.isEmpty()) {
|
||||
throw new IllegalArgumentException("File cannot be null or empty");
|
||||
throw ExceptionUtils.createFileNullOrEmptyException();
|
||||
}
|
||||
|
||||
String filename = file.getOriginalFilename();
|
||||
if (filename == null) {
|
||||
throw new IllegalArgumentException("File must have a name");
|
||||
throw ExceptionUtils.createFileNoNameException();
|
||||
}
|
||||
|
||||
String extension = FilenameUtils.getExtension(filename).toLowerCase();
|
||||
String extension = FilenameUtils.getExtension(filename).toLowerCase(Locale.ROOT);
|
||||
if (!"pdf".equals(extension)) {
|
||||
throw new IllegalArgumentException("File must be a PDF");
|
||||
throw ExceptionUtils.createPdfFileRequiredException();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,27 +67,36 @@ public class PdfToCbrUtils {
|
||||
int totalPages = document.getNumberOfPages();
|
||||
|
||||
for (int pageIndex = 0; pageIndex < totalPages; pageIndex++) {
|
||||
final int currentPage = pageIndex;
|
||||
try {
|
||||
BufferedImage image =
|
||||
pdfRenderer.renderImageWithDPI(pageIndex, dpi, ImageType.RGB);
|
||||
ExceptionUtils.handleOomRendering(
|
||||
currentPage + 1,
|
||||
dpi,
|
||||
() ->
|
||||
pdfRenderer.renderImageWithDPI(
|
||||
currentPage, dpi, ImageType.RGB));
|
||||
|
||||
String imageFilename = String.format("page_%03d.png", pageIndex + 1);
|
||||
String imageFilename =
|
||||
String.format(Locale.ROOT, "page_%03d.png", currentPage + 1);
|
||||
Path imagePath = tempDir.resolve(imageFilename);
|
||||
|
||||
ImageIO.write(image, "PNG", imagePath.toFile());
|
||||
generatedImages.add(imagePath);
|
||||
|
||||
} catch (ExceptionUtils.OutOfMemoryDpiException e) {
|
||||
// Re-throw OOM exceptions without wrapping
|
||||
throw e;
|
||||
} catch (IOException e) {
|
||||
log.warn("Error processing page {}: {}", pageIndex + 1, e.getMessage());
|
||||
} catch (OutOfMemoryError e) {
|
||||
throw ExceptionUtils.createOutOfMemoryDpiException(pageIndex + 1, dpi, e);
|
||||
} catch (NegativeArraySizeException e) {
|
||||
throw ExceptionUtils.createOutOfMemoryDpiException(pageIndex + 1, dpi, e);
|
||||
// Wrap other IOExceptions with context
|
||||
throw ExceptionUtils.createFileProcessingException(
|
||||
"CBR creation for page " + (currentPage + 1), e);
|
||||
}
|
||||
}
|
||||
|
||||
if (generatedImages.isEmpty()) {
|
||||
throw new IOException("Failed to render any pages to images for CBR conversion");
|
||||
throw ExceptionUtils.createFileProcessingException(
|
||||
"CBR conversion", new IOException("No pages were successfully rendered"));
|
||||
}
|
||||
|
||||
return createRarArchive(tempDir, generatedImages);
|
||||
@@ -115,15 +125,18 @@ public class PdfToCbrUtils {
|
||||
ProcessExecutorResult result =
|
||||
executor.runCommandWithOutputHandling(command, tempDir.toFile());
|
||||
if (result.getRc() != 0) {
|
||||
throw new IOException("RAR command failed: " + result.getMessages());
|
||||
throw ExceptionUtils.createFileProcessingException(
|
||||
"RAR archive creation",
|
||||
new IOException("RAR command failed with code " + result.getRc()));
|
||||
}
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new IOException("RAR command interrupted", e);
|
||||
throw ExceptionUtils.createProcessingInterruptedException("RAR creation", e);
|
||||
}
|
||||
|
||||
if (!Files.exists(rarFile)) {
|
||||
throw new IOException("RAR file was not created");
|
||||
throw ExceptionUtils.createFileProcessingException(
|
||||
"RAR archive creation", new IOException("RAR file was not created"));
|
||||
}
|
||||
|
||||
try (FileInputStream fis = new FileInputStream(rarFile.toFile());
|
||||
@@ -167,7 +180,7 @@ public class PdfToCbrUtils {
|
||||
return false;
|
||||
}
|
||||
|
||||
String extension = FilenameUtils.getExtension(filename).toLowerCase();
|
||||
String extension = FilenameUtils.getExtension(filename).toLowerCase(Locale.ROOT);
|
||||
return "pdf".equals(extension);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package stirling.software.common.util;
|
||||
import java.awt.image.BufferedImage;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.util.Locale;
|
||||
import java.util.zip.ZipEntry;
|
||||
import java.util.zip.ZipOutputStream;
|
||||
|
||||
@@ -29,7 +30,7 @@ public class PdfToCbzUtils {
|
||||
|
||||
try (PDDocument document = pdfDocumentFactory.load(pdfFile)) {
|
||||
if (document.getNumberOfPages() == 0) {
|
||||
throw new IllegalArgumentException("PDF file contains no pages");
|
||||
throw ExceptionUtils.createPdfNoPages();
|
||||
}
|
||||
|
||||
return createCbzFromPdf(document, dpi);
|
||||
@@ -38,17 +39,17 @@ public class PdfToCbzUtils {
|
||||
|
||||
private static void validatePdfFile(MultipartFile file) {
|
||||
if (file == null || file.isEmpty()) {
|
||||
throw new IllegalArgumentException("File cannot be null or empty");
|
||||
throw ExceptionUtils.createFileNullOrEmptyException();
|
||||
}
|
||||
|
||||
String filename = file.getOriginalFilename();
|
||||
if (filename == null) {
|
||||
throw new IllegalArgumentException("File must have a name");
|
||||
throw ExceptionUtils.createFileNoNameException();
|
||||
}
|
||||
|
||||
String extension = FilenameUtils.getExtension(filename).toLowerCase();
|
||||
String extension = FilenameUtils.getExtension(filename).toLowerCase(Locale.ROOT);
|
||||
if (!"pdf".equals(extension)) {
|
||||
throw new IllegalArgumentException("File must be a PDF");
|
||||
throw ExceptionUtils.createPdfFileRequiredException();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,24 +62,31 @@ public class PdfToCbzUtils {
|
||||
int totalPages = document.getNumberOfPages();
|
||||
|
||||
for (int pageIndex = 0; pageIndex < totalPages; pageIndex++) {
|
||||
final int currentPage = pageIndex;
|
||||
try {
|
||||
BufferedImage image =
|
||||
pdfRenderer.renderImageWithDPI(pageIndex, dpi, ImageType.RGB);
|
||||
|
||||
String imageFilename = String.format("page_%03d.png", pageIndex + 1);
|
||||
ExceptionUtils.handleOomRendering(
|
||||
currentPage + 1,
|
||||
dpi,
|
||||
() ->
|
||||
pdfRenderer.renderImageWithDPI(
|
||||
currentPage, dpi, ImageType.RGB));
|
||||
|
||||
String imageFilename =
|
||||
String.format(Locale.ROOT, "page_%03d.png", currentPage + 1);
|
||||
ZipEntry zipEntry = new ZipEntry(imageFilename);
|
||||
zipOut.putNextEntry(zipEntry);
|
||||
|
||||
ImageIO.write(image, "PNG", zipOut);
|
||||
zipOut.closeEntry();
|
||||
|
||||
} catch (ExceptionUtils.OutOfMemoryDpiException e) {
|
||||
// Re-throw OOM exceptions without wrapping
|
||||
throw e;
|
||||
} catch (IOException e) {
|
||||
log.warn("Error processing page {}: {}", pageIndex + 1, e.getMessage());
|
||||
} catch (OutOfMemoryError e) {
|
||||
throw ExceptionUtils.createOutOfMemoryDpiException(pageIndex + 1, dpi, e);
|
||||
} catch (NegativeArraySizeException e) {
|
||||
throw ExceptionUtils.createOutOfMemoryDpiException(pageIndex + 1, dpi, e);
|
||||
// Wrap other IOExceptions with context
|
||||
throw ExceptionUtils.createFileProcessingException(
|
||||
"CBZ creation for page " + (currentPage + 1), e);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -93,7 +101,7 @@ public class PdfToCbzUtils {
|
||||
return false;
|
||||
}
|
||||
|
||||
String extension = FilenameUtils.getExtension(filename).toLowerCase();
|
||||
String extension = FilenameUtils.getExtension(filename).toLowerCase(Locale.ROOT);
|
||||
return "pdf".equals(extension);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import java.io.IOException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
import java.util.zip.ZipEntry;
|
||||
@@ -50,36 +51,18 @@ public class PdfUtils {
|
||||
|
||||
public PDRectangle textToPageSize(String size) {
|
||||
|
||||
switch (size.toUpperCase()) {
|
||||
case "A0" -> {
|
||||
return PDRectangle.A0;
|
||||
}
|
||||
case "A1" -> {
|
||||
return PDRectangle.A1;
|
||||
}
|
||||
case "A2" -> {
|
||||
return PDRectangle.A2;
|
||||
}
|
||||
case "A3" -> {
|
||||
return PDRectangle.A3;
|
||||
}
|
||||
case "A4" -> {
|
||||
return PDRectangle.A4;
|
||||
}
|
||||
case "A5" -> {
|
||||
return PDRectangle.A5;
|
||||
}
|
||||
case "A6" -> {
|
||||
return PDRectangle.A6;
|
||||
}
|
||||
case "LETTER" -> {
|
||||
return PDRectangle.LETTER;
|
||||
}
|
||||
case "LEGAL" -> {
|
||||
return PDRectangle.LEGAL;
|
||||
}
|
||||
return switch (size.toUpperCase(Locale.ROOT)) {
|
||||
case "A0" -> PDRectangle.A0;
|
||||
case "A1" -> PDRectangle.A1;
|
||||
case "A2" -> PDRectangle.A2;
|
||||
case "A3" -> PDRectangle.A3;
|
||||
case "A4" -> PDRectangle.A4;
|
||||
case "A5" -> PDRectangle.A5;
|
||||
case "A6" -> PDRectangle.A6;
|
||||
case "LETTER" -> PDRectangle.LETTER;
|
||||
case "LEGAL" -> PDRectangle.LEGAL;
|
||||
default -> throw ExceptionUtils.createInvalidPageSizeException(size);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
public List<RenderedImage> getAllImages(PDResources resources) throws IOException {
|
||||
@@ -182,8 +165,8 @@ public class PdfUtils {
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
|
||||
if (singleImage) {
|
||||
if ("tiff".equals(imageType.toLowerCase())
|
||||
|| "tif".equals(imageType.toLowerCase())) {
|
||||
if ("tiff".equals(imageType.toLowerCase(Locale.ROOT))
|
||||
|| "tif".equals(imageType.toLowerCase(Locale.ROOT))) {
|
||||
// Write the images to the output stream as a TIFF with multiple frames
|
||||
ImageWriter writer = ImageIO.getImageWritersByFormatName("tiff").next();
|
||||
ImageWriteParam param = writer.getDefaultWriteParam();
|
||||
@@ -196,9 +179,20 @@ public class PdfUtils {
|
||||
writer.prepareWriteSequence(null);
|
||||
|
||||
for (int i = 0; i < pageCount; ++i) {
|
||||
final int pageIndex = i;
|
||||
BufferedImage image;
|
||||
try {
|
||||
image = pdfRenderer.renderImageWithDPI(i, DPI, colorType);
|
||||
// Validate dimensions before rendering
|
||||
ExceptionUtils.validateRenderingDimensions(
|
||||
document.getPage(pageIndex), pageIndex + 1, DPI);
|
||||
|
||||
image =
|
||||
ExceptionUtils.handleOomRendering(
|
||||
pageIndex + 1,
|
||||
DPI,
|
||||
() ->
|
||||
pdfRenderer.renderImageWithDPI(
|
||||
pageIndex, DPI, colorType));
|
||||
} catch (IllegalArgumentException e) {
|
||||
if (e.getMessage() != null
|
||||
&& e.getMessage()
|
||||
@@ -212,10 +206,6 @@ public class PdfUtils {
|
||||
DPI);
|
||||
}
|
||||
throw e;
|
||||
} catch (OutOfMemoryError e) {
|
||||
throw ExceptionUtils.createOutOfMemoryDpiException(i + 1, DPI, e);
|
||||
} catch (NegativeArraySizeException e) {
|
||||
throw ExceptionUtils.createOutOfMemoryDpiException(i + 1, DPI, e);
|
||||
}
|
||||
writer.writeToSequence(new IIOImage(image, null, null), param);
|
||||
}
|
||||
@@ -239,6 +229,7 @@ public class PdfUtils {
|
||||
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();
|
||||
int rotation = page.getRotation();
|
||||
@@ -249,7 +240,17 @@ public class PdfUtils {
|
||||
if (dimension == null) {
|
||||
// Render the image to get the dimensions
|
||||
try {
|
||||
pdfSizeImage = pdfRenderer.renderImageWithDPI(i, DPI, colorType);
|
||||
// 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()
|
||||
@@ -264,10 +265,6 @@ public class PdfUtils {
|
||||
DPI);
|
||||
}
|
||||
throw e;
|
||||
} catch (OutOfMemoryError e) {
|
||||
throw ExceptionUtils.createOutOfMemoryDpiException(i + 1, DPI, e);
|
||||
} catch (NegativeArraySizeException e) {
|
||||
throw ExceptionUtils.createOutOfMemoryDpiException(i + 1, DPI, e);
|
||||
}
|
||||
pdfSizeImageIndex = i;
|
||||
dimension =
|
||||
@@ -293,11 +290,22 @@ public class PdfUtils {
|
||||
boolean firstImageAlreadyRendered = pdfSizeImageIndex == 0;
|
||||
|
||||
for (int i = 0; i < pageCount; ++i) {
|
||||
final int pageIndex = i;
|
||||
if (firstImageAlreadyRendered && i == 0) {
|
||||
pageImage = pdfSizeImage;
|
||||
} else {
|
||||
try {
|
||||
pageImage = pdfRenderer.renderImageWithDPI(i, DPI, colorType);
|
||||
// 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()
|
||||
@@ -311,10 +319,6 @@ public class PdfUtils {
|
||||
DPI);
|
||||
}
|
||||
throw e;
|
||||
} catch (OutOfMemoryError e) {
|
||||
throw ExceptionUtils.createOutOfMemoryDpiException(i + 1, DPI, e);
|
||||
} catch (NegativeArraySizeException e) {
|
||||
throw ExceptionUtils.createOutOfMemoryDpiException(i + 1, DPI, e);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -335,9 +339,20 @@ public class PdfUtils {
|
||||
// Zip the images and return as byte array
|
||||
try (ZipOutputStream zos = new ZipOutputStream(baos)) {
|
||||
for (int i = 0; i < pageCount; ++i) {
|
||||
final int pageIndex = i;
|
||||
BufferedImage image;
|
||||
try {
|
||||
image = pdfRenderer.renderImageWithDPI(i, DPI, colorType);
|
||||
// Validate dimensions before rendering
|
||||
ExceptionUtils.validateRenderingDimensions(
|
||||
document.getPage(pageIndex), pageIndex + 1, DPI);
|
||||
|
||||
image =
|
||||
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")) {
|
||||
@@ -349,10 +364,6 @@ public class PdfUtils {
|
||||
DPI);
|
||||
}
|
||||
throw e;
|
||||
} catch (OutOfMemoryError e) {
|
||||
throw ExceptionUtils.createOutOfMemoryDpiException(i + 1, DPI, e);
|
||||
} catch (NegativeArraySizeException e) {
|
||||
throw ExceptionUtils.createOutOfMemoryDpiException(i + 1, DPI, e);
|
||||
}
|
||||
try (ByteArrayOutputStream baosImage = new ByteArrayOutputStream()) {
|
||||
ImageIO.write(image, imageType, baosImage);
|
||||
@@ -361,9 +372,10 @@ public class PdfUtils {
|
||||
zos.putNextEntry(
|
||||
new ZipEntry(
|
||||
String.format(
|
||||
Locale.ROOT,
|
||||
filename + "_%d.%s",
|
||||
i + 1,
|
||||
imageType.toLowerCase())));
|
||||
imageType.toLowerCase(Locale.ROOT))));
|
||||
zos.write(baosImage.toByteArray());
|
||||
}
|
||||
}
|
||||
@@ -391,6 +403,7 @@ public class PdfUtils {
|
||||
PDFRenderer pdfRenderer = new PDFRenderer(document);
|
||||
pdfRenderer.setSubsamplingAllowed(true);
|
||||
for (int page = 0; page < document.getNumberOfPages(); ++page) {
|
||||
final int pageIndex = page;
|
||||
BufferedImage bim;
|
||||
|
||||
// Use global maximum DPI setting, fallback to 300 if not set
|
||||
@@ -400,9 +413,16 @@ public class PdfUtils {
|
||||
if (properties != null && properties.getSystem() != null) {
|
||||
renderDpi = properties.getSystem().getMaxDPI();
|
||||
}
|
||||
final int dpi = renderDpi;
|
||||
|
||||
try {
|
||||
bim = pdfRenderer.renderImageWithDPI(page, renderDpi, ImageType.RGB);
|
||||
bim =
|
||||
ExceptionUtils.handleOomRendering(
|
||||
pageIndex + 1,
|
||||
dpi,
|
||||
() ->
|
||||
pdfRenderer.renderImageWithDPI(
|
||||
pageIndex, dpi, ImageType.RGB));
|
||||
} catch (IllegalArgumentException e) {
|
||||
if (e.getMessage() != null
|
||||
&& e.getMessage().contains("Maximum size of image exceeded")) {
|
||||
@@ -411,13 +431,9 @@ public class PdfUtils {
|
||||
"PDF page {0} is too large to render at 300 DPI. The resulting image"
|
||||
+ " would exceed Java's maximum array size. Please use a lower DPI"
|
||||
+ " value for PDF-to-image conversion.",
|
||||
page + 1);
|
||||
pageIndex + 1);
|
||||
}
|
||||
throw e;
|
||||
} catch (OutOfMemoryError e) {
|
||||
throw ExceptionUtils.createOutOfMemoryDpiException(page + 1, 300, e);
|
||||
} catch (NegativeArraySizeException e) {
|
||||
throw ExceptionUtils.createOutOfMemoryDpiException(page + 1, 300, e);
|
||||
}
|
||||
PDPage originalPage = document.getPage(page);
|
||||
|
||||
@@ -463,8 +479,8 @@ public class PdfUtils {
|
||||
String contentType = file.getContentType();
|
||||
String originalFilename = Filenames.toSimpleFileName(file.getOriginalFilename());
|
||||
if (originalFilename != null
|
||||
&& (originalFilename.toLowerCase().endsWith(".tiff")
|
||||
|| originalFilename.toLowerCase().endsWith(".tif"))) {
|
||||
&& (originalFilename.toLowerCase(Locale.ROOT).endsWith(".tiff")
|
||||
|| originalFilename.toLowerCase(Locale.ROOT).endsWith(".tif"))) {
|
||||
ImageReader reader = ImageIO.getImageReadersByFormatName("tiff").next();
|
||||
reader.setInput(ImageIO.createImageInputStream(file.getInputStream()));
|
||||
int numPages = reader.getNumImages(true);
|
||||
@@ -567,7 +583,7 @@ public class PdfUtils {
|
||||
PDImageXObject image = PDImageXObject.createFromByteArray(document, imageBytes, "");
|
||||
// Draw the image onto the page at the specified x and y coordinates
|
||||
contentStream.drawImage(image, x, y);
|
||||
log.info("Image successfully overlayed onto PDF");
|
||||
log.info("Image successfully overlaid onto PDF");
|
||||
if (!everyPage && i == 0) {
|
||||
break;
|
||||
}
|
||||
@@ -631,7 +647,7 @@ public class PdfUtils {
|
||||
int actualPageCount = pdfDocument.getNumberOfPages();
|
||||
pdfDocument.close();
|
||||
|
||||
return switch (comparator.toLowerCase()) {
|
||||
return switch (comparator.toLowerCase(Locale.ROOT)) {
|
||||
case "greater" -> actualPageCount > pageCount;
|
||||
case "equal" -> actualPageCount == pageCount;
|
||||
case "less" -> actualPageCount < pageCount;
|
||||
|
||||
@@ -102,6 +102,11 @@ public class ProcessExecutor {
|
||||
.getSessionLimit()
|
||||
.getOcrMyPdfSessionLimit();
|
||||
case CFF_CONVERTER -> 1;
|
||||
case FFMPEG ->
|
||||
applicationProperties
|
||||
.getProcessExecutor()
|
||||
.getSessionLimit()
|
||||
.getFfmpegSessionLimit();
|
||||
};
|
||||
|
||||
long timeoutMinutes =
|
||||
@@ -162,6 +167,11 @@ public class ProcessExecutor {
|
||||
.getTimeoutMinutes()
|
||||
.getOcrMyPdfTimeoutMinutes();
|
||||
case CFF_CONVERTER -> 5L;
|
||||
case FFMPEG ->
|
||||
applicationProperties
|
||||
.getProcessExecutor()
|
||||
.getTimeoutMinutes()
|
||||
.getFfmpegTimeoutMinutes();
|
||||
};
|
||||
return new ProcessExecutor(semaphoreLimit, liveUpdates, timeoutMinutes);
|
||||
});
|
||||
@@ -316,7 +326,8 @@ public class ProcessExecutor {
|
||||
QPDF,
|
||||
GHOSTSCRIPT,
|
||||
OCR_MY_PDF,
|
||||
CFF_CONVERTER
|
||||
CFF_CONVERTER,
|
||||
FFMPEG
|
||||
}
|
||||
|
||||
@Setter
|
||||
|
||||
@@ -325,6 +325,11 @@ public final class RegexPatternUtils {
|
||||
return getPattern("\\+");
|
||||
}
|
||||
|
||||
/** Pattern for splitting on pipe delimiter (used for hint lists in i18n messages) */
|
||||
public Pattern getPipeDelimiterPattern() {
|
||||
return getPattern("\\|");
|
||||
}
|
||||
|
||||
/** Pattern for username validation */
|
||||
public Pattern getUsernameValidationPattern() {
|
||||
return getPattern("^[a-zA-Z0-9](?!.*[-@._+]{2,})[a-zA-Z0-9@._+-]{1,48}[a-zA-Z0-9]$");
|
||||
@@ -567,6 +572,16 @@ public final class RegexPatternUtils {
|
||||
return getPattern("^[a-zA-Z0-9]{2,4}$", Pattern.CASE_INSENSITIVE);
|
||||
}
|
||||
|
||||
/** Pattern for splitting on line breaks (Unicode line separator) */
|
||||
public Pattern getLineSeparatorPattern() {
|
||||
return getPattern("\\R");
|
||||
}
|
||||
|
||||
/** Pattern for removing leading asterisks and whitespace */
|
||||
public Pattern getLeadingAsterisksWhitespacePattern() {
|
||||
return getPattern("^[*\\s]+");
|
||||
}
|
||||
|
||||
private record PatternKey(String regex, int flags) {
|
||||
// Record automatically provides equals, hashCode, and toString
|
||||
}
|
||||
|
||||
@@ -28,6 +28,17 @@ public class TempFileManager {
|
||||
private final TempFileRegistry registry;
|
||||
private final ApplicationProperties applicationProperties;
|
||||
|
||||
/**
|
||||
* Create a managed temporary file that will be tracked by the TempFileManager.
|
||||
*
|
||||
* @param suffix The suffix for the temporary file
|
||||
* @return The created temporary file wrapper
|
||||
* @throws IOException If an I/O error occurs
|
||||
*/
|
||||
public TempFile createManagedTempFile(String suffix) throws IOException {
|
||||
return new TempFile(this, suffix);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a temporary file with the Stirling-PDF prefix. The file is automatically registered
|
||||
* with the registry.
|
||||
@@ -130,7 +141,6 @@ public class TempFileManager {
|
||||
return deleted;
|
||||
} catch (IOException e) {
|
||||
log.warn("Failed to delete temp file: {}", path.toString(), e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
package stirling.software.common.util;
|
||||
|
||||
import java.util.Collection;
|
||||
|
||||
public class ValidationUtil {
|
||||
|
||||
public static boolean isStringEmpty(String input) {
|
||||
return input == null || input.isBlank();
|
||||
}
|
||||
|
||||
public static boolean isCollectionEmpty(Collection<String> input) {
|
||||
return input == null || input.isEmpty();
|
||||
}
|
||||
}
|
||||
+7
-9
@@ -56,16 +56,14 @@ public class InvertFullColorStrategy extends ReplaceAndInvertColorStrategy {
|
||||
if (properties != null && properties.getSystem() != null) {
|
||||
renderDpi = properties.getSystem().getMaxDPI();
|
||||
}
|
||||
final int dpi = renderDpi;
|
||||
final int pageNum = page;
|
||||
|
||||
try {
|
||||
image =
|
||||
pdfRenderer.renderImageWithDPI(
|
||||
page, renderDpi); // Render page with global DPI setting
|
||||
} catch (OutOfMemoryError e) {
|
||||
throw ExceptionUtils.createOutOfMemoryDpiException(page + 1, renderDpi, e);
|
||||
} catch (NegativeArraySizeException e) {
|
||||
throw ExceptionUtils.createOutOfMemoryDpiException(page + 1, renderDpi, e);
|
||||
}
|
||||
image =
|
||||
ExceptionUtils.handleOomRendering(
|
||||
pageNum + 1,
|
||||
dpi,
|
||||
() -> pdfRenderer.renderImageWithDPI(pageNum, dpi));
|
||||
|
||||
// Invert the colors
|
||||
invertImageColors(image);
|
||||
|
||||
+3
-11
@@ -1,8 +1,6 @@
|
||||
package stirling.software.common.annotations;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyBoolean;
|
||||
import static org.mockito.ArgumentMatchers.anyInt;
|
||||
@@ -31,8 +29,6 @@ import stirling.software.common.aop.AutoJobAspect;
|
||||
import stirling.software.common.model.api.PDFFile;
|
||||
import stirling.software.common.service.FileStorage;
|
||||
import stirling.software.common.service.JobExecutorService;
|
||||
import stirling.software.common.service.JobQueue;
|
||||
import stirling.software.common.service.ResourceMonitor;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class AutoJobPostMappingIntegrationTest {
|
||||
@@ -45,10 +41,6 @@ class AutoJobPostMappingIntegrationTest {
|
||||
|
||||
@Mock private FileStorage fileStorage;
|
||||
|
||||
@Mock private ResourceMonitor resourceMonitor;
|
||||
|
||||
@Mock private JobQueue jobQueue;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
autoJobAspect = new AutoJobAspect(jobExecutorService, request, fileStorage);
|
||||
@@ -73,7 +65,7 @@ class AutoJobPostMappingIntegrationTest {
|
||||
// Given
|
||||
PDFFile pdfFile = new PDFFile();
|
||||
pdfFile.setFileId("test-file-id");
|
||||
Object[] args = new Object[] {pdfFile};
|
||||
Object[] args = {pdfFile};
|
||||
|
||||
when(joinPoint.getArgs()).thenReturn(args);
|
||||
when(request.getParameter("async")).thenReturn("true");
|
||||
@@ -153,7 +145,7 @@ class AutoJobPostMappingIntegrationTest {
|
||||
// Given
|
||||
PDFFile pdfFile = new PDFFile();
|
||||
pdfFile.setFileInput(mock(MultipartFile.class));
|
||||
Object[] args = new Object[] {pdfFile};
|
||||
Object[] args = {pdfFile};
|
||||
|
||||
when(joinPoint.getArgs()).thenReturn(args);
|
||||
when(request.getParameter("async")).thenReturn("true");
|
||||
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
package stirling.software.common.configuration.interfaces;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class ShowAdminInterfaceTest {
|
||||
|
||||
// Create a simple implementation for testing
|
||||
static class TestImpl implements ShowAdminInterface {}
|
||||
|
||||
@Test
|
||||
void getShowUpdateOnlyAdmins_returnsTrueByDefault() {
|
||||
ShowAdminInterface instance = new TestImpl();
|
||||
assertTrue(instance.getShowUpdateOnlyAdmins(), "Default should return true");
|
||||
}
|
||||
}
|
||||
+9
-9
@@ -18,17 +18,17 @@ class ApplicationPropertiesDynamicYamlPropertySourceTest {
|
||||
|
||||
@Test
|
||||
void loads_yaml_into_environment() throws Exception {
|
||||
// YAML-Config in Temp-Datei schreiben
|
||||
String yaml =
|
||||
""
|
||||
+ "ui:\n"
|
||||
+ " appName: \"My App\"\n"
|
||||
+ "system:\n"
|
||||
+ " enableAnalytics: true\n";
|
||||
"""
|
||||
\
|
||||
ui:
|
||||
appName: "My App"
|
||||
system:
|
||||
enableAnalytics: true
|
||||
""";
|
||||
Path tmp = Files.createTempFile("spdf-settings-", ".yml");
|
||||
Files.writeString(tmp, yaml);
|
||||
|
||||
// Pfad per statischem Mock liefern
|
||||
try (MockedStatic<InstallationPathConfig> mocked =
|
||||
Mockito.mockStatic(InstallationPathConfig.class)) {
|
||||
mocked.when(InstallationPathConfig::getSettingsPath).thenReturn(tmp.toString());
|
||||
@@ -36,7 +36,7 @@ class ApplicationPropertiesDynamicYamlPropertySourceTest {
|
||||
ConfigurableEnvironment env = new StandardEnvironment();
|
||||
ApplicationProperties props = new ApplicationProperties();
|
||||
|
||||
props.dynamicYamlPropertySource(env); // fügt PropertySource an erster Stelle ein
|
||||
props.dynamicYamlPropertySource(env);
|
||||
|
||||
assertEquals("My App", env.getProperty("ui.appName"));
|
||||
assertEquals("true", env.getProperty("system.enableAnalytics"));
|
||||
@@ -44,7 +44,7 @@ class ApplicationPropertiesDynamicYamlPropertySourceTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void throws_when_settings_file_missing() throws Exception {
|
||||
void throws_when_settings_file_missing() {
|
||||
String missing = "/path/does/not/exist/spdf.yml";
|
||||
try (MockedStatic<InstallationPathConfig> mocked =
|
||||
Mockito.mockStatic(InstallationPathConfig.class)) {
|
||||
|
||||
+2
-2
@@ -61,7 +61,7 @@ class ApplicationPropertiesSaml2HttpTest {
|
||||
Resource r = s.getSpCert();
|
||||
|
||||
assertNotNull(r);
|
||||
assertTrue(r instanceof FileSystemResource, "Expected FileSystemResource for FS path");
|
||||
assertInstanceOf(FileSystemResource.class, r, "Expected FileSystemResource for FS path");
|
||||
assertTrue(r.exists(), "Temp file should exist");
|
||||
}
|
||||
|
||||
@@ -75,7 +75,7 @@ class ApplicationPropertiesSaml2HttpTest {
|
||||
Resource r = s.getIdpCert();
|
||||
|
||||
assertNotNull(r);
|
||||
assertTrue(r instanceof FileSystemResource, "Expected FileSystemResource for FS path");
|
||||
assertInstanceOf(FileSystemResource.class, r, "Expected FileSystemResource for FS path");
|
||||
assertFalse(r.exists(), "Resource should not exist for missing file");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,16 +1,20 @@
|
||||
package stirling.software.common.model;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
import java.io.File;
|
||||
import java.nio.file.Path;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Nested;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.CsvSource;
|
||||
|
||||
public class FileInfoTest {
|
||||
|
||||
private static final LocalDateTime FIXED_NOW = LocalDateTime.of(2025, 11, 1, 12, 0, 0);
|
||||
|
||||
@ParameterizedTest(name = "{index}: fileSize={0}")
|
||||
@CsvSource({
|
||||
"0, '0 Bytes'",
|
||||
@@ -25,87 +29,146 @@ public class FileInfoTest {
|
||||
FileInfo fileInfo =
|
||||
new FileInfo(
|
||||
"example.txt",
|
||||
File.separator
|
||||
+ "path"
|
||||
+ File.separator
|
||||
+ "to"
|
||||
+ File.separator
|
||||
+ "example.txt",
|
||||
LocalDateTime.now(),
|
||||
"/path/to/example.txt",
|
||||
FIXED_NOW,
|
||||
fileSize,
|
||||
LocalDateTime.now().minusDays(1));
|
||||
FIXED_NOW.minusDays(1));
|
||||
|
||||
assertEquals(expectedFormattedSize, fileInfo.getFormattedFileSize());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testGetFilePathAsPath() {
|
||||
FileInfo fileInfo =
|
||||
new FileInfo(
|
||||
"test.pdf",
|
||||
File.separator + "tmp" + File.separator + "test.pdf",
|
||||
LocalDateTime.now(),
|
||||
1234,
|
||||
LocalDateTime.now().minusDays(2));
|
||||
assertEquals(
|
||||
File.separator + "tmp" + File.separator + "test.pdf",
|
||||
fileInfo.getFilePathAsPath().toString());
|
||||
@Nested
|
||||
@DisplayName("getFilePathAsPath")
|
||||
class GetFilePathAsPathTests {
|
||||
@Test
|
||||
@DisplayName("Should convert filePath string into a Path instance")
|
||||
void shouldConvertStringToPath() {
|
||||
FileInfo fi =
|
||||
new FileInfo(
|
||||
"example.txt",
|
||||
"/path/to/example.txt",
|
||||
FIXED_NOW,
|
||||
123,
|
||||
FIXED_NOW.minusDays(1));
|
||||
|
||||
Path path = fi.getFilePathAsPath();
|
||||
|
||||
// Basic sanity checks
|
||||
assertNotNull(path, "Path should not be null");
|
||||
assertEquals(
|
||||
Path.of("/path/to/example.txt"),
|
||||
path,
|
||||
"Converted Path should match input string");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void testGetFormattedModificationDate() {
|
||||
LocalDateTime modDate = LocalDateTime.of(2024, 6, 1, 15, 30, 45);
|
||||
FileInfo fileInfo =
|
||||
new FileInfo(
|
||||
"file.txt",
|
||||
File.separator + "file.txt",
|
||||
modDate,
|
||||
100,
|
||||
LocalDateTime.of(2024, 5, 31, 10, 0, 0));
|
||||
assertEquals("2024-06-01 15:30:45", fileInfo.getFormattedModificationDate());
|
||||
@Nested
|
||||
@DisplayName("Date formatting")
|
||||
class DateFormattingTests {
|
||||
@Test
|
||||
@DisplayName("Should format modificationDate as 'yyyy-MM-dd HH:mm:ss'")
|
||||
void shouldFormatModificationDate() {
|
||||
LocalDateTime mod = LocalDateTime.of(2025, 8, 10, 15, 30, 45);
|
||||
FileInfo fi =
|
||||
new FileInfo(
|
||||
"example.txt",
|
||||
"/path/to/example.txt",
|
||||
mod,
|
||||
1,
|
||||
LocalDateTime.of(2024, 1, 1, 0, 0, 0));
|
||||
|
||||
assertEquals("2025-08-10 15:30:45", fi.getFormattedModificationDate());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should format creationDate as 'yyyy-MM-dd HH:mm:ss'")
|
||||
void shouldFormatCreationDate() {
|
||||
LocalDateTime created = LocalDateTime.of(2024, 12, 31, 23, 59, 59);
|
||||
FileInfo fi =
|
||||
new FileInfo(
|
||||
"example.txt",
|
||||
"/path/to/example.txt",
|
||||
LocalDateTime.of(2025, 1, 1, 0, 0, 0),
|
||||
1,
|
||||
created);
|
||||
|
||||
assertEquals("2024-12-31 23:59:59", fi.getFormattedCreationDate());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should throw NPE when modificationDate is null (current behavior)")
|
||||
void shouldThrowWhenModificationDateNull() {
|
||||
// Assumption: Current implementation does not guard null -> NPE is expected.
|
||||
FileInfo fi =
|
||||
new FileInfo(
|
||||
"example.txt",
|
||||
"/path/to/example.txt",
|
||||
null, // modificationDate null
|
||||
1,
|
||||
FIXED_NOW);
|
||||
|
||||
assertThrows(
|
||||
NullPointerException.class,
|
||||
fi::getFormattedModificationDate,
|
||||
"Formatting a null modificationDate should throw NPE with current"
|
||||
+ " implementation");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should throw NPE when creationDate is null (current behavior)")
|
||||
void shouldThrowWhenCreationDateNull() {
|
||||
// Assumption: Current implementation does not guard null -> NPE is expected.
|
||||
FileInfo fi =
|
||||
new FileInfo(
|
||||
"example.txt",
|
||||
"/path/to/example.txt",
|
||||
FIXED_NOW,
|
||||
1,
|
||||
null); // creationDate null
|
||||
|
||||
assertThrows(
|
||||
NullPointerException.class,
|
||||
fi::getFormattedCreationDate,
|
||||
"Formatting a null creationDate should throw NPE with current implementation");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void testGetFormattedCreationDate() {
|
||||
LocalDateTime creationDate = LocalDateTime.of(2023, 12, 25, 8, 15, 0);
|
||||
FileInfo fileInfo =
|
||||
new FileInfo(
|
||||
"holiday.txt",
|
||||
File.separator + "holiday.txt",
|
||||
LocalDateTime.of(2024, 1, 1, 0, 0, 0),
|
||||
500,
|
||||
creationDate);
|
||||
assertEquals("2023-12-25 08:15:00", fileInfo.getFormattedCreationDate());
|
||||
}
|
||||
@Nested
|
||||
@DisplayName("Additional size formatting cases")
|
||||
class AdditionalSizeFormattingTests {
|
||||
|
||||
@Test
|
||||
void testGettersAndSetters() {
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
FileInfo fileInfo =
|
||||
new FileInfo(
|
||||
"doc.pdf",
|
||||
File.separator + "docs" + File.separator + "doc.pdf",
|
||||
now,
|
||||
2048,
|
||||
now.minusDays(1));
|
||||
// Test getters
|
||||
assertEquals("doc.pdf", fileInfo.getFileName());
|
||||
assertEquals(File.separator + "docs" + File.separator + "doc.pdf", fileInfo.getFilePath());
|
||||
assertEquals(now, fileInfo.getModificationDate());
|
||||
assertEquals(2048, fileInfo.getFileSize());
|
||||
assertEquals(now.minusDays(1), fileInfo.getCreationDate());
|
||||
@Test
|
||||
@DisplayName("Should round to two decimals for KB (e.g., 1536 B -> 1.50 KB)")
|
||||
void shouldRoundKbToTwoDecimals() {
|
||||
FileInfo fi =
|
||||
new FileInfo(
|
||||
"example.txt",
|
||||
"/path/to/example.txt",
|
||||
FIXED_NOW,
|
||||
1536, // 1.5 KB
|
||||
FIXED_NOW.minusDays(1));
|
||||
|
||||
// Test setters
|
||||
fileInfo.setFileName("new.pdf");
|
||||
fileInfo.setFilePath(File.separator + "new" + File.separator + "new.pdf");
|
||||
fileInfo.setModificationDate(now.plusDays(1));
|
||||
fileInfo.setFileSize(4096);
|
||||
fileInfo.setCreationDate(now.minusDays(2));
|
||||
assertEquals("1.50 KB", fi.getFormattedFileSize());
|
||||
}
|
||||
|
||||
assertEquals("new.pdf", fileInfo.getFileName());
|
||||
assertEquals(File.separator + "new" + File.separator + "new.pdf", fileInfo.getFilePath());
|
||||
assertEquals(now.plusDays(1), fileInfo.getModificationDate());
|
||||
assertEquals(4096, fileInfo.getFileSize());
|
||||
assertEquals(now.minusDays(2), fileInfo.getCreationDate());
|
||||
@Test
|
||||
@DisplayName("Values above 1 TB are still represented in GB (design choice)")
|
||||
void shouldRepresentTerabytesInGb() {
|
||||
// 2 TB = 2 * 1024 GB -> 2 * 1024 * 1024^3 bytes
|
||||
long twoTB = 2L * 1024 * 1024 * 1024 * 1024; // 2 * 2^40
|
||||
FileInfo fi =
|
||||
new FileInfo(
|
||||
"example.txt",
|
||||
"/path/to/example.txt",
|
||||
FIXED_NOW,
|
||||
twoTB,
|
||||
FIXED_NOW.minusDays(1));
|
||||
|
||||
// 2 TB equals 2048.00 GB with current implementation
|
||||
assertEquals(
|
||||
"2048.00 GB",
|
||||
fi.getFormattedFileSize(),
|
||||
"Current implementation caps at GB and shows TB in GB units");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
package stirling.software.common.model.exception;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
@DisplayName("Tests for UnsupportedClaimException")
|
||||
class UnsupportedClaimExceptionTest {
|
||||
|
||||
@Test
|
||||
@DisplayName("should store message passed to constructor")
|
||||
void shouldStoreMessageFromConstructor() {
|
||||
String expectedMessage = "This claim is not supported";
|
||||
UnsupportedClaimException exception = new UnsupportedClaimException(expectedMessage);
|
||||
|
||||
// Verify the stored message
|
||||
assertEquals(
|
||||
expectedMessage,
|
||||
exception.getMessage(),
|
||||
"Constructor should correctly store the provided message");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("should allow null message without throwing exception")
|
||||
void shouldAllowNullMessage() {
|
||||
UnsupportedClaimException exception = new UnsupportedClaimException(null);
|
||||
|
||||
// Null message should be stored as null
|
||||
assertNull(
|
||||
exception.getMessage(),
|
||||
"Constructor should accept null message and store it as null");
|
||||
}
|
||||
}
|
||||
+52
-59
@@ -1,7 +1,6 @@
|
||||
package stirling.software.common.service;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.*;
|
||||
import static stirling.software.common.service.SpyPDFDocumentFactory.*;
|
||||
|
||||
@@ -22,7 +21,6 @@ import org.springframework.http.MediaType;
|
||||
import org.springframework.mock.web.MockMultipartFile;
|
||||
|
||||
import stirling.software.common.model.api.PDFFile;
|
||||
import stirling.software.common.service.SpyPDFDocumentFactory.StrategyType;
|
||||
|
||||
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
|
||||
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
|
||||
@@ -43,58 +41,7 @@ class CustomPDFDocumentFactoryTest {
|
||||
}
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@CsvSource({"5,MEMORY_ONLY", "20,MIXED", "60,TEMP_FILE"})
|
||||
void testStrategy_FileInput(int sizeMB, StrategyType expected) throws IOException {
|
||||
File file = writeTempFile(inflatePdf(basePdfBytes, sizeMB));
|
||||
try (PDDocument doc = factory.load(file)) {
|
||||
Assertions.assertEquals(expected, factory.lastStrategyUsed);
|
||||
}
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@CsvSource({"5,MEMORY_ONLY", "20,MIXED", "60,TEMP_FILE"})
|
||||
void testStrategy_ByteArray(int sizeMB, StrategyType expected) throws IOException {
|
||||
byte[] inflated = inflatePdf(basePdfBytes, sizeMB);
|
||||
try (PDDocument doc = factory.load(inflated)) {
|
||||
Assertions.assertEquals(expected, factory.lastStrategyUsed);
|
||||
}
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@CsvSource({"5,MEMORY_ONLY", "20,MIXED", "60,TEMP_FILE"})
|
||||
void testStrategy_InputStream(int sizeMB, StrategyType expected) throws IOException {
|
||||
byte[] inflated = inflatePdf(basePdfBytes, sizeMB);
|
||||
try (PDDocument doc = factory.load(new ByteArrayInputStream(inflated))) {
|
||||
Assertions.assertEquals(expected, factory.lastStrategyUsed);
|
||||
}
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@CsvSource({"5,MEMORY_ONLY", "20,MIXED", "60,TEMP_FILE"})
|
||||
void testStrategy_MultipartFile(int sizeMB, StrategyType expected) throws IOException {
|
||||
byte[] inflated = inflatePdf(basePdfBytes, sizeMB);
|
||||
MockMultipartFile multipart =
|
||||
new MockMultipartFile("file", "doc.pdf", MediaType.APPLICATION_PDF_VALUE, inflated);
|
||||
try (PDDocument doc = factory.load(multipart)) {
|
||||
Assertions.assertEquals(expected, factory.lastStrategyUsed);
|
||||
}
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@CsvSource({"5,MEMORY_ONLY", "20,MIXED", "60,TEMP_FILE"})
|
||||
void testStrategy_PDFFile(int sizeMB, StrategyType expected) throws IOException {
|
||||
byte[] inflated = inflatePdf(basePdfBytes, sizeMB);
|
||||
MockMultipartFile multipart =
|
||||
new MockMultipartFile("file", "doc.pdf", MediaType.APPLICATION_PDF_VALUE, inflated);
|
||||
PDFFile pdfFile = new PDFFile();
|
||||
pdfFile.setFileInput(multipart);
|
||||
try (PDDocument doc = factory.load(pdfFile)) {
|
||||
Assertions.assertEquals(expected, factory.lastStrategyUsed);
|
||||
}
|
||||
}
|
||||
|
||||
private byte[] inflatePdf(byte[] input, int sizeInMB) throws IOException {
|
||||
private static byte[] inflatePdf(byte[] input, int sizeInMB) throws IOException {
|
||||
try (PDDocument doc = Loader.loadPDF(input)) {
|
||||
byte[] largeData = new byte[sizeInMB * 1024 * 1024];
|
||||
Arrays.fill(largeData, (byte) 'A');
|
||||
@@ -113,6 +60,46 @@ class CustomPDFDocumentFactoryTest {
|
||||
}
|
||||
}
|
||||
|
||||
private static File writeTempFile(byte[] content) throws IOException {
|
||||
File file = Files.createTempFile("pdf-test-", ".pdf").toFile();
|
||||
Files.write(file.toPath(), content);
|
||||
return file;
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@CsvSource({"5,MEMORY_ONLY", "20,MIXED", "60,TEMP_FILE"})
|
||||
void testStrategy_FileInput(int sizeMB, StrategyType expected) throws IOException {
|
||||
File file = writeTempFile(inflatePdf(basePdfBytes, sizeMB));
|
||||
factory.load(file);
|
||||
Assertions.assertEquals(expected, factory.lastStrategyUsed);
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@CsvSource({"5,MEMORY_ONLY", "20,MIXED", "60,TEMP_FILE"})
|
||||
void testStrategy_ByteArray(int sizeMB, StrategyType expected) throws IOException {
|
||||
byte[] inflated = inflatePdf(basePdfBytes, sizeMB);
|
||||
factory.load(inflated);
|
||||
Assertions.assertEquals(expected, factory.lastStrategyUsed);
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@CsvSource({"5,MEMORY_ONLY", "20,MIXED", "60,TEMP_FILE"})
|
||||
void testStrategy_InputStream(int sizeMB, StrategyType expected) throws IOException {
|
||||
byte[] inflated = inflatePdf(basePdfBytes, sizeMB);
|
||||
factory.load(new ByteArrayInputStream(inflated));
|
||||
Assertions.assertEquals(expected, factory.lastStrategyUsed);
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@CsvSource({"5,MEMORY_ONLY", "20,MIXED", "60,TEMP_FILE"})
|
||||
void testStrategy_MultipartFile(int sizeMB, StrategyType expected) throws IOException {
|
||||
byte[] inflated = inflatePdf(basePdfBytes, sizeMB);
|
||||
MockMultipartFile multipart =
|
||||
new MockMultipartFile("file", "doc.pdf", MediaType.APPLICATION_PDF_VALUE, inflated);
|
||||
factory.load(multipart);
|
||||
Assertions.assertEquals(expected, factory.lastStrategyUsed);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testLoadFromPath() throws IOException {
|
||||
File file = writeTempFile(inflatePdf(basePdfBytes, 5));
|
||||
@@ -130,7 +117,7 @@ class CustomPDFDocumentFactoryTest {
|
||||
}
|
||||
}
|
||||
|
||||
// neeed to add password pdf
|
||||
// need to add password pdf
|
||||
// @Test
|
||||
// void testLoadPasswordProtectedPdfFromInputStream() throws IOException {
|
||||
// try (InputStream is = getClass().getResourceAsStream("/protected.pdf")) {
|
||||
@@ -212,10 +199,16 @@ class CustomPDFDocumentFactoryTest {
|
||||
assertTrue(newBytes.length > 0);
|
||||
}
|
||||
|
||||
private File writeTempFile(byte[] content) throws IOException {
|
||||
File file = Files.createTempFile("pdf-test-", ".pdf").toFile();
|
||||
Files.write(file.toPath(), content);
|
||||
return file;
|
||||
@ParameterizedTest
|
||||
@CsvSource({"5,MEMORY_ONLY", "20,MIXED", "60,TEMP_FILE"})
|
||||
void testStrategy_PDFFile(int sizeMB, StrategyType expected) throws IOException {
|
||||
byte[] inflated = inflatePdf(basePdfBytes, sizeMB);
|
||||
MockMultipartFile multipart =
|
||||
new MockMultipartFile("file", "doc.pdf", MediaType.APPLICATION_PDF_VALUE, inflated);
|
||||
PDFFile pdfFile = new PDFFile();
|
||||
pdfFile.setFileInput(multipart);
|
||||
factory.load(pdfFile);
|
||||
Assertions.assertEquals(expected, factory.lastStrategyUsed);
|
||||
}
|
||||
|
||||
@BeforeEach
|
||||
|
||||
@@ -1,15 +1,11 @@
|
||||
package stirling.software.common.service;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.AdditionalAnswers.*;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.UUID;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
@@ -86,7 +82,7 @@ class FileStorageTest {
|
||||
void testRetrieveFile() throws IOException {
|
||||
// Arrange
|
||||
byte[] fileContent = "Test PDF content".getBytes();
|
||||
String fileId = UUID.randomUUID().toString();
|
||||
String fileId = "test-file-1";
|
||||
Path filePath = tempDir.resolve(fileId);
|
||||
Files.write(filePath, fileContent);
|
||||
|
||||
@@ -106,7 +102,7 @@ class FileStorageTest {
|
||||
void testRetrieveBytes() throws IOException {
|
||||
// Arrange
|
||||
byte[] fileContent = "Test PDF content".getBytes();
|
||||
String fileId = UUID.randomUUID().toString();
|
||||
String fileId = "test-file-2";
|
||||
Path filePath = tempDir.resolve(fileId);
|
||||
Files.write(filePath, fileContent);
|
||||
|
||||
@@ -139,7 +135,7 @@ class FileStorageTest {
|
||||
void testDeleteFile() throws IOException {
|
||||
// Arrange
|
||||
byte[] fileContent = "Test PDF content".getBytes();
|
||||
String fileId = UUID.randomUUID().toString();
|
||||
String fileId = "test-file-3";
|
||||
Path filePath = tempDir.resolve(fileId);
|
||||
Files.write(filePath, fileContent);
|
||||
|
||||
@@ -167,7 +163,7 @@ class FileStorageTest {
|
||||
void testFileExists() throws IOException {
|
||||
// Arrange
|
||||
byte[] fileContent = "Test PDF content".getBytes();
|
||||
String fileId = UUID.randomUUID().toString();
|
||||
String fileId = "test-file-4";
|
||||
Path filePath = tempDir.resolve(fileId);
|
||||
Files.write(filePath, fileContent);
|
||||
|
||||
|
||||
+11
-10
@@ -1,6 +1,7 @@
|
||||
package stirling.software.common.service;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertInstanceOf;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
@@ -105,7 +106,7 @@ class JobExecutorServiceTest {
|
||||
|
||||
// Then
|
||||
assertEquals(HttpStatus.OK, response.getStatusCode());
|
||||
assertTrue(response.getBody() instanceof JobResponse);
|
||||
assertInstanceOf(JobResponse.class, response.getBody());
|
||||
JobResponse<?> jobResponse = (JobResponse<?>) response.getBody();
|
||||
assertTrue(jobResponse.isAsync());
|
||||
assertNotNull(jobResponse.getJobId());
|
||||
@@ -134,7 +135,7 @@ class JobExecutorServiceTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldQueueJobWhenResourcesLimited() {
|
||||
void shouldQueueJobWhenResourcesLimited() throws Exception {
|
||||
// Given
|
||||
Supplier<Object> work = () -> "test-result";
|
||||
CompletableFuture<ResponseEntity<?>> future = new CompletableFuture<>();
|
||||
@@ -150,7 +151,7 @@ class JobExecutorServiceTest {
|
||||
|
||||
// Then
|
||||
assertEquals(HttpStatus.OK, response.getStatusCode());
|
||||
assertTrue(response.getBody() instanceof JobResponse);
|
||||
assertInstanceOf(JobResponse.class, response.getBody());
|
||||
|
||||
// Verify job was queued
|
||||
verify(jobQueue).queueJob(anyString(), eq(80), any(), eq(5000L));
|
||||
@@ -184,13 +185,13 @@ class JobExecutorServiceTest {
|
||||
// Given
|
||||
Supplier<Object> work =
|
||||
() -> {
|
||||
try {
|
||||
Thread.sleep(100); // Simulate long-running job
|
||||
return "test-result";
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new RuntimeException(e);
|
||||
// Simulate long-running job without actual sleep
|
||||
// Use a loop to consume time instead of Thread.sleep
|
||||
long startTime = System.nanoTime();
|
||||
while (System.nanoTime() - startTime < 100_000_000) { // 100ms in nanoseconds
|
||||
// Busy wait to simulate work without Thread.sleep
|
||||
}
|
||||
return "test-result";
|
||||
};
|
||||
|
||||
// Use reflection to access the private executeWithTimeout method
|
||||
@@ -203,7 +204,7 @@ class JobExecutorServiceTest {
|
||||
try {
|
||||
executeMethod.invoke(jobExecutorService, work, 1L); // Very short timeout
|
||||
} catch (Exception e) {
|
||||
assertTrue(e.getCause() instanceof TimeoutException);
|
||||
assertInstanceOf(TimeoutException.class, e.getCause());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
package stirling.software.common.service;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
import java.lang.management.MemoryMXBean;
|
||||
import java.lang.management.OperatingSystemMXBean;
|
||||
import java.time.Instant;
|
||||
import java.util.Locale;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
@@ -33,11 +32,11 @@ class ResourceMonitorTest {
|
||||
@Mock private MemoryMXBean memoryMXBean;
|
||||
|
||||
@Spy
|
||||
private AtomicReference<ResourceStatus> currentStatus =
|
||||
private final AtomicReference<ResourceStatus> currentStatus =
|
||||
new AtomicReference<>(ResourceStatus.OK);
|
||||
|
||||
@Spy
|
||||
private AtomicReference<ResourceMetrics> latestMetrics =
|
||||
private final AtomicReference<ResourceMetrics> latestMetrics =
|
||||
new AtomicReference<>(new ResourceMetrics());
|
||||
|
||||
@BeforeEach
|
||||
@@ -118,18 +117,24 @@ class ResourceMonitorTest {
|
||||
shouldQueue,
|
||||
result,
|
||||
String.format(
|
||||
Locale.ROOT,
|
||||
"For weight %d and status %s, shouldQueue should be %s",
|
||||
weight, status, shouldQueue));
|
||||
weight,
|
||||
status,
|
||||
shouldQueue));
|
||||
}
|
||||
|
||||
@Test
|
||||
void resourceMetricsShouldDetectStaleState() {
|
||||
// Capture test time at the beginning for deterministic calculations
|
||||
final Instant testTime = Instant.now();
|
||||
|
||||
// Given
|
||||
Instant now = Instant.now();
|
||||
Instant pastInstant = now.minusMillis(6000);
|
||||
Instant pastInstant =
|
||||
testTime.minusMillis(6000); // 6 seconds ago (relative to test start time)
|
||||
|
||||
ResourceMetrics staleMetrics = new ResourceMetrics(0.5, 0.5, 1024, 2048, 4096, pastInstant);
|
||||
ResourceMetrics freshMetrics = new ResourceMetrics(0.5, 0.5, 1024, 2048, 4096, now);
|
||||
ResourceMetrics freshMetrics = new ResourceMetrics(0.5, 0.5, 1024, 2048, 4096, testTime);
|
||||
|
||||
// When/Then
|
||||
assertTrue(
|
||||
|
||||
@@ -5,7 +5,6 @@ import static org.mockito.Mockito.*;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
@@ -42,7 +41,7 @@ class TaskManagerTest {
|
||||
@Test
|
||||
void testCreateTask() {
|
||||
// Act
|
||||
String jobId = UUID.randomUUID().toString();
|
||||
String jobId = "test-job-1";
|
||||
taskManager.createTask(jobId);
|
||||
|
||||
// Assert
|
||||
@@ -56,7 +55,7 @@ class TaskManagerTest {
|
||||
@Test
|
||||
void testSetResult() {
|
||||
// Arrange
|
||||
String jobId = UUID.randomUUID().toString();
|
||||
String jobId = "test-job-2";
|
||||
taskManager.createTask(jobId);
|
||||
Object resultObject = "Test result";
|
||||
|
||||
@@ -74,7 +73,7 @@ class TaskManagerTest {
|
||||
@Test
|
||||
void testSetFileResult() throws Exception {
|
||||
// Arrange
|
||||
String jobId = UUID.randomUUID().toString();
|
||||
String jobId = "test-job-3";
|
||||
taskManager.createTask(jobId);
|
||||
String fileId = "file-id";
|
||||
String originalFileName = "test.pdf";
|
||||
@@ -108,7 +107,7 @@ class TaskManagerTest {
|
||||
@Test
|
||||
void testSetError() {
|
||||
// Arrange
|
||||
String jobId = UUID.randomUUID().toString();
|
||||
String jobId = "test-job-4";
|
||||
taskManager.createTask(jobId);
|
||||
String errorMessage = "Test error";
|
||||
|
||||
@@ -126,7 +125,7 @@ class TaskManagerTest {
|
||||
@Test
|
||||
void testSetComplete_WithExistingResult() {
|
||||
// Arrange
|
||||
String jobId = UUID.randomUUID().toString();
|
||||
String jobId = "test-job-5";
|
||||
taskManager.createTask(jobId);
|
||||
Object resultObject = "Test result";
|
||||
taskManager.setResult(jobId, resultObject);
|
||||
@@ -144,7 +143,7 @@ class TaskManagerTest {
|
||||
@Test
|
||||
void testSetComplete_WithoutExistingResult() {
|
||||
// Arrange
|
||||
String jobId = UUID.randomUUID().toString();
|
||||
String jobId = "test-job-6";
|
||||
taskManager.createTask(jobId);
|
||||
|
||||
// Act
|
||||
@@ -160,7 +159,7 @@ class TaskManagerTest {
|
||||
@Test
|
||||
void testIsComplete() {
|
||||
// Arrange
|
||||
String jobId = UUID.randomUUID().toString();
|
||||
String jobId = "test-job-7";
|
||||
taskManager.createTask(jobId);
|
||||
|
||||
// Assert - not complete initially
|
||||
@@ -215,7 +214,9 @@ class TaskManagerTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void testCleanupOldJobs() throws Exception {
|
||||
void testCleanupOldJobs() {
|
||||
// Capture test time at the beginning for deterministic calculations
|
||||
final LocalDateTime testTime = LocalDateTime.now();
|
||||
// Arrange
|
||||
// 1. Create a recent completed job
|
||||
String recentJobId = "recent-job";
|
||||
@@ -227,8 +228,9 @@ class TaskManagerTest {
|
||||
taskManager.createTask(oldJobId);
|
||||
JobResult oldJob = taskManager.getJobResult(oldJobId);
|
||||
|
||||
// Manually set the completion time to be older than the expiry
|
||||
LocalDateTime oldTime = LocalDateTime.now().minusHours(1);
|
||||
// Manually set the completion time to be older than the expiry (relative to test start
|
||||
// time)
|
||||
LocalDateTime oldTime = testTime.minusHours(1);
|
||||
ReflectionTestUtils.setField(oldJob, "completedAt", oldTime);
|
||||
ReflectionTestUtils.setField(oldJob, "complete", true);
|
||||
|
||||
@@ -253,6 +255,7 @@ class TaskManagerTest {
|
||||
taskManager.createTask(activeJobId);
|
||||
|
||||
// Verify all jobs are in the map
|
||||
assertNotNull(jobResultsMap);
|
||||
assertTrue(jobResultsMap.containsKey(recentJobId));
|
||||
assertTrue(jobResultsMap.containsKey(oldJobId));
|
||||
assertTrue(jobResultsMap.containsKey(activeJobId));
|
||||
@@ -268,7 +271,7 @@ class TaskManagerTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void testShutdown() throws Exception {
|
||||
void testShutdown() {
|
||||
// This mainly tests that the shutdown method doesn't throw exceptions
|
||||
taskManager.shutdown();
|
||||
|
||||
@@ -279,7 +282,7 @@ class TaskManagerTest {
|
||||
@Test
|
||||
void testAddNote() {
|
||||
// Arrange
|
||||
String jobId = UUID.randomUUID().toString();
|
||||
String jobId = "test-job-8";
|
||||
taskManager.createTask(jobId);
|
||||
String note = "Test note";
|
||||
|
||||
|
||||
+49
-41
@@ -1,8 +1,6 @@
|
||||
package stirling.software.common.service;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyLong;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
import java.io.File;
|
||||
@@ -133,6 +131,9 @@ public class TempFileCleanupServiceTest {
|
||||
|
||||
// Use MockedStatic to mock Files operations
|
||||
try (MockedStatic<Files> mockedFiles = mockStatic(Files.class)) {
|
||||
// Capture test time at the beginning for deterministic calculations
|
||||
final long testTime = System.currentTimeMillis();
|
||||
|
||||
// Mock Files.list for each directory we'll process
|
||||
mockedFiles
|
||||
.when(() -> Files.list(eq(systemTempDir)))
|
||||
@@ -177,18 +178,17 @@ public class TempFileCleanupServiceTest {
|
||||
// maxAgeMillis
|
||||
if (fileName.contains("old")) {
|
||||
return FileTime.fromMillis(
|
||||
System.currentTimeMillis() - 5000000);
|
||||
testTime - 5000000); // ~1.4 hours ago
|
||||
}
|
||||
// For empty.tmp file, return a timestamp older than 5 minutes (for
|
||||
// empty file test)
|
||||
else if (fileName.equals("empty.tmp")) {
|
||||
else if ("empty.tmp".equals(fileName)) {
|
||||
return FileTime.fromMillis(
|
||||
System.currentTimeMillis() - 6 * 60 * 1000);
|
||||
testTime - 6 * 60 * 1000); // 6 minutes ago
|
||||
}
|
||||
// For all other files, return a recent timestamp
|
||||
else {
|
||||
return FileTime.fromMillis(
|
||||
System.currentTimeMillis() - 60000); // 1 minute ago
|
||||
return FileTime.fromMillis(testTime - 60000); // 1 minute ago
|
||||
}
|
||||
});
|
||||
|
||||
@@ -201,7 +201,7 @@ public class TempFileCleanupServiceTest {
|
||||
String fileName = path.getFileName().toString();
|
||||
|
||||
// Return 0 bytes for the empty file
|
||||
if (fileName.equals("empty.tmp")) {
|
||||
if ("empty.tmp".equals(fileName)) {
|
||||
return 0L;
|
||||
}
|
||||
// Return normal size for all other files
|
||||
@@ -221,9 +221,9 @@ public class TempFileCleanupServiceTest {
|
||||
});
|
||||
|
||||
// Act - set containerMode to false for this test
|
||||
invokeCleanupDirectoryStreaming(systemTempDir, false, 0, 3600000);
|
||||
invokeCleanupDirectoryStreaming(customTempDir, false, 0, 3600000);
|
||||
invokeCleanupDirectoryStreaming(libreOfficeTempDir, false, 0, 3600000);
|
||||
invokeCleanupDirectoryStreaming(systemTempDir, false, 3600000);
|
||||
invokeCleanupDirectoryStreaming(customTempDir, false, 3600000);
|
||||
invokeCleanupDirectoryStreaming(libreOfficeTempDir, false, 3600000);
|
||||
|
||||
// Assert - Only old temp files and empty files should be deleted
|
||||
assertTrue(deletedFiles.contains(oldTempFile), "Old temp file should be deleted");
|
||||
@@ -276,6 +276,9 @@ public class TempFileCleanupServiceTest {
|
||||
|
||||
// Use MockedStatic to mock Files operations
|
||||
try (MockedStatic<Files> mockedFiles = mockStatic(Files.class)) {
|
||||
// Capture test time at the beginning for deterministic calculations
|
||||
final long testTime = System.currentTimeMillis();
|
||||
|
||||
// Mock Files.list for systemTempDir
|
||||
mockedFiles
|
||||
.when(() -> Files.list(eq(systemTempDir)))
|
||||
@@ -290,9 +293,7 @@ public class TempFileCleanupServiceTest {
|
||||
// Configure Files.getLastModifiedTime to return recent timestamps
|
||||
mockedFiles
|
||||
.when(() -> Files.getLastModifiedTime(any(Path.class)))
|
||||
.thenReturn(
|
||||
FileTime.fromMillis(
|
||||
System.currentTimeMillis() - 60000)); // 1 minute ago
|
||||
.thenReturn(FileTime.fromMillis(testTime - 60000)); // 1 minute ago
|
||||
|
||||
// Configure Files.size to return normal size
|
||||
mockedFiles.when(() -> Files.size(any(Path.class))).thenReturn(1024L); // 1 KB
|
||||
@@ -308,7 +309,7 @@ public class TempFileCleanupServiceTest {
|
||||
});
|
||||
|
||||
// Act - set containerMode to true and maxAgeMillis to 0 for container startup cleanup
|
||||
invokeCleanupDirectoryStreaming(systemTempDir, true, 0, 0);
|
||||
invokeCleanupDirectoryStreaming(systemTempDir, true, 0);
|
||||
|
||||
// Assert - In container mode, both our temp files and system temp files should be
|
||||
// deleted
|
||||
@@ -337,6 +338,9 @@ public class TempFileCleanupServiceTest {
|
||||
|
||||
// Use MockedStatic to mock Files operations
|
||||
try (MockedStatic<Files> mockedFiles = mockStatic(Files.class)) {
|
||||
// Capture test time at the beginning for deterministic calculations
|
||||
final long testTime = System.currentTimeMillis();
|
||||
|
||||
// Mock Files.list for systemTempDir
|
||||
mockedFiles
|
||||
.when(() -> Files.list(eq(systemTempDir)))
|
||||
@@ -356,14 +360,14 @@ public class TempFileCleanupServiceTest {
|
||||
Path path = invocation.getArgument(0);
|
||||
String fileName = path.getFileName().toString();
|
||||
|
||||
if (fileName.equals("empty.tmp")) {
|
||||
if ("empty.tmp".equals(fileName)) {
|
||||
// More than 5 minutes old
|
||||
return FileTime.fromMillis(
|
||||
System.currentTimeMillis() - 6 * 60 * 1000);
|
||||
testTime - 6 * 60 * 1000); // 6 minutes ago
|
||||
} else {
|
||||
// Less than 5 minutes old
|
||||
return FileTime.fromMillis(
|
||||
System.currentTimeMillis() - 2 * 60 * 1000);
|
||||
testTime - 2 * 60 * 1000); // 2 minutes ago
|
||||
}
|
||||
});
|
||||
|
||||
@@ -381,7 +385,7 @@ public class TempFileCleanupServiceTest {
|
||||
});
|
||||
|
||||
// Act
|
||||
invokeCleanupDirectoryStreaming(systemTempDir, false, 0, 3600000);
|
||||
invokeCleanupDirectoryStreaming(systemTempDir, false, 3600000);
|
||||
|
||||
// Assert
|
||||
assertTrue(
|
||||
@@ -412,14 +416,25 @@ public class TempFileCleanupServiceTest {
|
||||
|
||||
// Use MockedStatic to mock Files operations
|
||||
try (MockedStatic<Files> mockedFiles = mockStatic(Files.class)) {
|
||||
// Capture test time at the beginning for deterministic calculations
|
||||
final long testTime = System.currentTimeMillis();
|
||||
|
||||
// Mock Files.list for each directory
|
||||
mockedFiles.when(() -> Files.list(eq(systemTempDir))).thenReturn(Stream.of(dir1));
|
||||
mockedFiles
|
||||
.when(() -> Files.list(eq(systemTempDir)))
|
||||
.thenAnswer(invocation -> Stream.of(dir1));
|
||||
|
||||
mockedFiles.when(() -> Files.list(eq(dir1))).thenReturn(Stream.of(tempFile1, dir2));
|
||||
mockedFiles
|
||||
.when(() -> Files.list(eq(dir1)))
|
||||
.thenAnswer(invocation -> Stream.of(tempFile1, dir2));
|
||||
|
||||
mockedFiles.when(() -> Files.list(eq(dir2))).thenReturn(Stream.of(tempFile2, dir3));
|
||||
mockedFiles
|
||||
.when(() -> Files.list(eq(dir2)))
|
||||
.thenAnswer(invocation -> Stream.of(tempFile2, dir3));
|
||||
|
||||
mockedFiles.when(() -> Files.list(eq(dir3))).thenReturn(Stream.of(tempFile3));
|
||||
mockedFiles
|
||||
.when(() -> Files.list(eq(dir3)))
|
||||
.thenAnswer(invocation -> Stream.of(tempFile3));
|
||||
|
||||
// Configure Files.isDirectory for each path
|
||||
mockedFiles.when(() -> Files.isDirectory(eq(dir1))).thenReturn(true);
|
||||
@@ -432,6 +447,9 @@ public class TempFileCleanupServiceTest {
|
||||
// Configure Files.exists to return true for all paths
|
||||
mockedFiles.when(() -> Files.exists(any(Path.class))).thenReturn(true);
|
||||
|
||||
// Configure Files.size to return 0 for all files (ensure they're not empty)
|
||||
mockedFiles.when(() -> Files.size(any(Path.class))).thenReturn(1024L);
|
||||
|
||||
// Configure Files.getLastModifiedTime to return different times based on file names
|
||||
mockedFiles
|
||||
.when(() -> Files.getLastModifiedTime(any(Path.class)))
|
||||
@@ -441,19 +459,14 @@ public class TempFileCleanupServiceTest {
|
||||
String fileName = path.getFileName().toString();
|
||||
|
||||
if (fileName.contains("old")) {
|
||||
// Old file
|
||||
return FileTime.fromMillis(
|
||||
System.currentTimeMillis() - 5000000);
|
||||
// Old file - very old timestamp (older than 1 hour)
|
||||
return FileTime.fromMillis(testTime - 7200000); // 2 hours ago
|
||||
} else {
|
||||
// Recent file
|
||||
return FileTime.fromMillis(System.currentTimeMillis() - 60000);
|
||||
// Recent file - very recent timestamp (less than 1 hour)
|
||||
return FileTime.fromMillis(testTime - 60000); // 1 minute ago
|
||||
}
|
||||
});
|
||||
|
||||
// Configure Files.size to return normal size
|
||||
mockedFiles.when(() -> Files.size(any(Path.class))).thenReturn(1024L);
|
||||
|
||||
// For deleteIfExists, track which files would be deleted
|
||||
mockedFiles
|
||||
.when(() -> Files.deleteIfExists(any(Path.class)))
|
||||
.thenAnswer(
|
||||
@@ -463,12 +476,8 @@ public class TempFileCleanupServiceTest {
|
||||
return true;
|
||||
});
|
||||
|
||||
// Act
|
||||
invokeCleanupDirectoryStreaming(systemTempDir, false, 0, 3600000);
|
||||
|
||||
// Debug - print what was deleted
|
||||
System.out.println("Deleted files: " + deletedFiles);
|
||||
System.out.println("Looking for: " + tempFile3);
|
||||
// Act - pass maxAgeMillis = 3600000 (1 hour)
|
||||
invokeCleanupDirectoryStreaming(systemTempDir, false, 3600000);
|
||||
|
||||
// Assert
|
||||
assertFalse(deletedFiles.contains(tempFile1), "Recent temp file should be preserved");
|
||||
@@ -481,8 +490,7 @@ public class TempFileCleanupServiceTest {
|
||||
|
||||
/** Helper method to invoke the private cleanupDirectoryStreaming method using reflection */
|
||||
private void invokeCleanupDirectoryStreaming(
|
||||
Path directory, boolean containerMode, int depth, long maxAgeMillis)
|
||||
throws IOException {
|
||||
Path directory, boolean containerMode, long maxAgeMillis) {
|
||||
try {
|
||||
// Create a consumer that tracks deleted files
|
||||
AtomicInteger deleteCount = new AtomicInteger(0);
|
||||
@@ -505,7 +513,7 @@ public class TempFileCleanupServiceTest {
|
||||
cleanupService,
|
||||
directory,
|
||||
containerMode,
|
||||
depth,
|
||||
0,
|
||||
maxAgeMillis,
|
||||
false,
|
||||
deleteCallback);
|
||||
|
||||
+58
-7
@@ -1,9 +1,6 @@
|
||||
package stirling.software.common.util;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.anyList;
|
||||
import static org.mockito.Mockito.mockStatic;
|
||||
import static org.mockito.Mockito.times;
|
||||
@@ -26,6 +23,7 @@ class CheckProgramInstallTest {
|
||||
|
||||
private MockedStatic<ProcessExecutor> mockProcessExecutor;
|
||||
private ProcessExecutor mockExecutor;
|
||||
private ProcessExecutor mockFfmpegExecutor;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() throws Exception {
|
||||
@@ -34,10 +32,14 @@ class CheckProgramInstallTest {
|
||||
|
||||
// Set up mock for ProcessExecutor
|
||||
mockExecutor = Mockito.mock(ProcessExecutor.class);
|
||||
mockFfmpegExecutor = Mockito.mock(ProcessExecutor.class);
|
||||
mockProcessExecutor = mockStatic(ProcessExecutor.class);
|
||||
mockProcessExecutor
|
||||
.when(() -> ProcessExecutor.getInstance(ProcessExecutor.Processes.PYTHON_OPENCV))
|
||||
.thenReturn(mockExecutor);
|
||||
mockProcessExecutor
|
||||
.when(() -> ProcessExecutor.getInstance(ProcessExecutor.Processes.FFMPEG))
|
||||
.thenReturn(mockFfmpegExecutor);
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
@@ -49,7 +51,7 @@ class CheckProgramInstallTest {
|
||||
}
|
||||
|
||||
/** Reset static fields in the CheckProgramInstall class using reflection */
|
||||
private void resetStaticFields() throws Exception {
|
||||
private static void resetStaticFields() throws Exception {
|
||||
Field pythonAvailableCheckedField =
|
||||
CheckProgramInstall.class.getDeclaredField("pythonAvailableChecked");
|
||||
pythonAvailableCheckedField.setAccessible(true);
|
||||
@@ -59,6 +61,15 @@ class CheckProgramInstallTest {
|
||||
CheckProgramInstall.class.getDeclaredField("availablePythonCommand");
|
||||
availablePythonCommandField.setAccessible(true);
|
||||
availablePythonCommandField.set(null, null);
|
||||
|
||||
Field ffmpegAvailableCheckedField =
|
||||
CheckProgramInstall.class.getDeclaredField("ffmpegAvailableChecked");
|
||||
ffmpegAvailableCheckedField.setAccessible(true);
|
||||
ffmpegAvailableCheckedField.set(null, false);
|
||||
|
||||
Field ffmpegAvailableField = CheckProgramInstall.class.getDeclaredField("ffmpegAvailable");
|
||||
ffmpegAvailableField.setAccessible(true);
|
||||
ffmpegAvailableField.set(null, false);
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -108,8 +119,7 @@ class CheckProgramInstallTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void testGetAvailablePythonCommand_WhenPythonReturnsNonZeroExitCode()
|
||||
throws IOException, InterruptedException, Exception {
|
||||
void testGetAvailablePythonCommand_WhenPythonReturnsNonZeroExitCode() throws Exception {
|
||||
// Arrange
|
||||
// Reset the static fields again to ensure clean state
|
||||
resetStaticFields();
|
||||
@@ -205,4 +215,45 @@ class CheckProgramInstallTest {
|
||||
// Verify getAvailablePythonCommand was called internally
|
||||
verify(mockExecutor).runCommandWithOutputHandling(Arrays.asList("python3", "--version"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testIsFfmpegAvailable_WhenInstalled() throws Exception {
|
||||
resetStaticFields();
|
||||
ProcessExecutorResult result = Mockito.mock(ProcessExecutorResult.class);
|
||||
when(mockFfmpegExecutor.runCommandWithOutputHandling(Arrays.asList("ffmpeg", "-version")))
|
||||
.thenReturn(result);
|
||||
|
||||
assertTrue(CheckProgramInstall.isFfmpegAvailable());
|
||||
verify(mockFfmpegExecutor)
|
||||
.runCommandWithOutputHandling(Arrays.asList("ffmpeg", "-version"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testIsFfmpegAvailable_WhenNotInstalled() throws Exception {
|
||||
resetStaticFields();
|
||||
when(mockFfmpegExecutor.runCommandWithOutputHandling(Arrays.asList("ffmpeg", "-version")))
|
||||
.thenThrow(new IOException("Command not found"));
|
||||
|
||||
assertFalse(CheckProgramInstall.isFfmpegAvailable());
|
||||
verify(mockFfmpegExecutor)
|
||||
.runCommandWithOutputHandling(Arrays.asList("ffmpeg", "-version"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testIsFfmpegAvailable_CachesResult() throws Exception {
|
||||
resetStaticFields();
|
||||
ProcessExecutorResult result = Mockito.mock(ProcessExecutorResult.class);
|
||||
when(mockFfmpegExecutor.runCommandWithOutputHandling(Arrays.asList("ffmpeg", "-version")))
|
||||
.thenReturn(result);
|
||||
|
||||
assertTrue(CheckProgramInstall.isFfmpegAvailable());
|
||||
|
||||
when(mockFfmpegExecutor.runCommandWithOutputHandling(Arrays.asList("ffmpeg", "-version")))
|
||||
.thenThrow(new IOException("Command not found"));
|
||||
|
||||
assertTrue(CheckProgramInstall.isFfmpegAvailable());
|
||||
|
||||
verify(mockFfmpegExecutor, times(1))
|
||||
.runCommandWithOutputHandling(Arrays.asList("ffmpeg", "-version"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,7 +46,7 @@ public class ChecksumUtilsTest {
|
||||
@Test
|
||||
void crc32_unsignedFormatting_highBitSet() throws Exception {
|
||||
// CRC32 of single zero byte (0x00) is 0xD202EF8D (>= 0x8000_0000)
|
||||
byte[] data = new byte[] {0x00};
|
||||
byte[] data = {0x00};
|
||||
|
||||
// Hex (unsigned, 8 chars, lowercase)
|
||||
try (InputStream is = new ByteArrayInputStream(data)) {
|
||||
|
||||
+163
-38
@@ -1,41 +1,44 @@
|
||||
package stirling.software.common.util;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.ArgumentMatchers.argThat;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
import java.util.stream.Stream;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Nested;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.Arguments;
|
||||
import org.junit.jupiter.params.provider.MethodSource;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.common.service.SsrfProtectionService;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class CustomHtmlSanitizerTest {
|
||||
|
||||
@Mock private SsrfProtectionService ssrfProtectionService;
|
||||
@Mock private ApplicationProperties applicationProperties;
|
||||
@Mock private ApplicationProperties.System systemProperties;
|
||||
|
||||
private CustomHtmlSanitizer customHtmlSanitizer;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
SsrfProtectionService mockSsrfProtectionService = mock(SsrfProtectionService.class);
|
||||
stirling.software.common.model.ApplicationProperties mockApplicationProperties =
|
||||
mock(stirling.software.common.model.ApplicationProperties.class);
|
||||
stirling.software.common.model.ApplicationProperties.System mockSystem =
|
||||
mock(stirling.software.common.model.ApplicationProperties.System.class);
|
||||
// Default behavior: allow all URLs and enable sanitization. Lenient stubs avoid
|
||||
// strict-stubbing failures when individual tests bypass certain branches.
|
||||
lenient().when(ssrfProtectionService.isUrlAllowed(anyString())).thenReturn(true);
|
||||
lenient().when(applicationProperties.getSystem()).thenReturn(systemProperties);
|
||||
lenient().when(systemProperties.isDisableSanitize()).thenReturn(false);
|
||||
|
||||
// Allow all URLs by default for basic tests
|
||||
when(mockSsrfProtectionService.isUrlAllowed(org.mockito.ArgumentMatchers.anyString()))
|
||||
.thenReturn(true);
|
||||
when(mockApplicationProperties.getSystem()).thenReturn(mockSystem);
|
||||
when(mockSystem.getDisableSanitize()).thenReturn(false); // Enable sanitization for tests
|
||||
|
||||
customHtmlSanitizer =
|
||||
new CustomHtmlSanitizer(mockSsrfProtectionService, mockApplicationProperties);
|
||||
customHtmlSanitizer = new CustomHtmlSanitizer(ssrfProtectionService, applicationProperties);
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@@ -56,10 +59,11 @@ class CustomHtmlSanitizerTest {
|
||||
"<p>This is <strong>valid</strong> HTML with <em>formatting</em>.</p>",
|
||||
new String[] {"<p>", "<strong>", "<em>"}),
|
||||
Arguments.of(
|
||||
"<p>Text with <b>bold</b>, <i>italic</i>, <u>underline</u>, "
|
||||
+ "<em>emphasis</em>, <strong>strong</strong>, <strike>strikethrough</strike>, "
|
||||
+ "<s>strike</s>, <sub>subscript</sub>, <sup>superscript</sup>, "
|
||||
+ "<tt>teletype</tt>, <code>code</code>, <big>big</big>, <small>small</small>.</p>",
|
||||
"<p>Text with <b>bold</b>, <i>italic</i>, <u>underline</u>,"
|
||||
+ " <em>emphasis</em>, <strong>strong</strong>,"
|
||||
+ " <strike>strikethrough</strike>, <s>strike</s>,"
|
||||
+ " <sub>subscript</sub>, <sup>superscript</sup>, <tt>teletype</tt>,"
|
||||
+ " <code>code</code>, <big>big</big>, <small>small</small>.</p>",
|
||||
new String[] {
|
||||
"<b>bold</b>",
|
||||
"<i>italic</i>",
|
||||
@@ -163,7 +167,7 @@ class CustomHtmlSanitizerTest {
|
||||
|
||||
@Test
|
||||
void testSanitizeAllowsImages() {
|
||||
// Arrange - Testing Sanitizers.IMAGES
|
||||
// Arrange - Testing custom images policy with SSRF check
|
||||
String htmlWithImage =
|
||||
"<img src=\"image.jpg\" alt=\"An image\" width=\"100\" height=\"100\">";
|
||||
|
||||
@@ -182,7 +186,16 @@ class CustomHtmlSanitizerTest {
|
||||
void testSanitizeDisallowsDataUrlImages() {
|
||||
// Arrange
|
||||
String htmlWithDataUrlImage =
|
||||
"<img src=\"data:image/svg+xml;base64,PHN2ZyBvbmxvYWQ9ImFsZXJ0KDEpIj48L3N2Zz4=\" alt=\"SVG with XSS\">";
|
||||
"<img src=\"data:image/svg+xml;base64,PHN2ZyBvbmxvYWQ9ImFsZXJ0KDEpIj48L3N2Zz4=\""
|
||||
+ " alt=\"SVG with XSS\">";
|
||||
|
||||
// Changed: Explicitly tell SSRF service to reject data: URLs so the custom AttributePolicy
|
||||
// drops the src attribute. Without this, a permissive SSRF mock might allow data: URLs.
|
||||
lenient()
|
||||
.when(
|
||||
ssrfProtectionService.isUrlAllowed(
|
||||
argThat(v -> v != null && v.startsWith("data:"))))
|
||||
.thenReturn(false);
|
||||
|
||||
// Act
|
||||
String sanitizedHtml = customHtmlSanitizer.sanitize(htmlWithDataUrlImage);
|
||||
@@ -257,9 +270,9 @@ class CustomHtmlSanitizerTest {
|
||||
void testSanitizeRemovesObjectAndEmbed() {
|
||||
// Arrange
|
||||
String htmlWithObjects =
|
||||
"<p>Safe content</p>"
|
||||
+ "<object data=\"data.swf\" type=\"application/x-shockwave-flash\"></object>"
|
||||
+ "<embed src=\"embed.swf\" type=\"application/x-shockwave-flash\">";
|
||||
"<p>Safe content</p><object data=\"data.swf\""
|
||||
+ " type=\"application/x-shockwave-flash\"></object><embed src=\"embed.swf\""
|
||||
+ " type=\"application/x-shockwave-flash\">";
|
||||
|
||||
// Act
|
||||
String sanitizedHtml = customHtmlSanitizer.sanitize(htmlWithObjects);
|
||||
@@ -295,17 +308,12 @@ class CustomHtmlSanitizerTest {
|
||||
void testSanitizeHandlesComplexHtml() {
|
||||
// Arrange
|
||||
String complexHtml =
|
||||
"<div class=\"container\">"
|
||||
+ " <h1 style=\"color: blue;\">Welcome</h1>"
|
||||
+ " <p>This is a <strong>test</strong> with <a href=\"https://example.com\">link</a>.</p>"
|
||||
+ " <table>"
|
||||
+ " <tr><th>Name</th><th>Value</th></tr>"
|
||||
+ " <tr><td>Item 1</td><td>100</td></tr>"
|
||||
+ " </table>"
|
||||
+ " <img src=\"image.jpg\" alt=\"Test image\">"
|
||||
+ " <script>alert('XSS');</script>"
|
||||
+ " <iframe src=\"https://evil.com\"></iframe>"
|
||||
+ "</div>";
|
||||
"<div class=\"container\"> <h1 style=\"color: blue;\">Welcome</h1> <p>This is a"
|
||||
+ " <strong>test</strong> with <a href=\"https://example.com\">link</a>.</p> "
|
||||
+ " <table> <tr><th>Name</th><th>Value</th></tr> <tr><td>Item"
|
||||
+ " 1</td><td>100</td></tr> </table> <img src=\"image.jpg\" alt=\"Test"
|
||||
+ " image\"> <script>alert('XSS');</script> <iframe"
|
||||
+ " src=\"https://evil.com\"></iframe></div>";
|
||||
|
||||
// Act
|
||||
String sanitizedHtml = customHtmlSanitizer.sanitize(complexHtml);
|
||||
@@ -353,4 +361,121 @@ class CustomHtmlSanitizerTest {
|
||||
// Assert
|
||||
assertEquals("", sanitizedHtml, "Null input should result in empty string");
|
||||
}
|
||||
|
||||
// -----------------------
|
||||
// Additional coverage
|
||||
// -----------------------
|
||||
|
||||
@Test
|
||||
@DisplayName("Should return input unchanged when sanitize is disabled via properties")
|
||||
void shouldBypassSanitizationWhenDisabled() {
|
||||
// Arrange
|
||||
String malicious =
|
||||
"<p>ok</p><script>alert('XSS')</script><img src=\"http://blocked.local/a.png\">";
|
||||
|
||||
// For this test, disable sanitize
|
||||
when(systemProperties.isDisableSanitize()).thenReturn(true);
|
||||
|
||||
// Also ensure SSRF would block it if sanitization were enabled (to prove bypass)
|
||||
lenient().when(ssrfProtectionService.isUrlAllowed(anyString())).thenReturn(false);
|
||||
|
||||
// Act
|
||||
String result = customHtmlSanitizer.sanitize(malicious);
|
||||
|
||||
// Assert
|
||||
// When disabled, sanitizer must return original string as-is.
|
||||
assertEquals(malicious, result, "Sanitization disabled should return input as-is");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should remove img src when SSRF service rejects the URL")
|
||||
void shouldRemoveImageSrcWhenSsrfRejects() {
|
||||
// Arrange
|
||||
String html = "<img src=\"http://internal/admin\" alt=\"x\">";
|
||||
|
||||
// Reject this URL
|
||||
lenient()
|
||||
.when(ssrfProtectionService.isUrlAllowed("http://internal/admin"))
|
||||
.thenReturn(false);
|
||||
|
||||
// Act
|
||||
String sanitized = customHtmlSanitizer.sanitize(html);
|
||||
|
||||
// Assert
|
||||
assertTrue(sanitized.contains("<img"), "Image element should remain");
|
||||
assertFalse(
|
||||
sanitized.contains("src=\"http://internal/admin\""),
|
||||
"Rejected URL should be removed from src attribute");
|
||||
assertTrue(sanitized.contains("alt=\"x\""), "Other allowed attributes should remain");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should trim and preserve allowed img src values")
|
||||
void shouldTrimAndPreserveAllowedImgSrc() {
|
||||
// Arrange
|
||||
String html = "<img src=\" https://example.com/image.jpg \" alt=\"pic\" title=\"t\">";
|
||||
|
||||
// Explicit allow (clarity)
|
||||
lenient()
|
||||
.when(ssrfProtectionService.isUrlAllowed("https://example.com/image.jpg"))
|
||||
.thenReturn(true);
|
||||
|
||||
// Act
|
||||
String sanitized = customHtmlSanitizer.sanitize(html);
|
||||
|
||||
// Assert
|
||||
assertTrue(sanitized.contains("<img"), "Image element should remain");
|
||||
assertFalse(
|
||||
sanitized.contains(" https://example.com/image.jpg "),
|
||||
"Untrimmed src value should not appear in output");
|
||||
assertTrue(
|
||||
sanitized.contains("alt=\"pic\"") || sanitized.contains("alt='pic'"),
|
||||
"Alt attribute should remain");
|
||||
assertTrue(
|
||||
sanitized.contains("title=\"t\"") || sanitized.contains("title='t'"),
|
||||
"Title attribute should remain");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should drop empty or whitespace-only img src values")
|
||||
void shouldDropEmptyImgSrc() {
|
||||
// Arrange
|
||||
String html = "<img src=\" \" alt=\"no src\">";
|
||||
|
||||
// Act
|
||||
String sanitized = customHtmlSanitizer.sanitize(html);
|
||||
|
||||
// Assert
|
||||
assertTrue(sanitized.contains("<img"), "Image element should remain");
|
||||
assertFalse(sanitized.contains("src="), "Empty src should be removed entirely");
|
||||
assertTrue(sanitized.contains("alt=\"no src\""), "Other attributes should remain");
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("Anchor tag protocol handling (sanitizer-level)")
|
||||
class AnchorProtocolTests {
|
||||
@Test
|
||||
@DisplayName(
|
||||
"mailto: links should keep anchor and text; protocol may be stripped depending on"
|
||||
+ " sanitizer version")
|
||||
void mailtoAllowed() {
|
||||
String html = "<a href=\"mailto:[email protected]\">mail me</a>";
|
||||
String sanitized = customHtmlSanitizer.sanitize(html);
|
||||
|
||||
// Anchor and text should remain
|
||||
assertTrue(sanitized.contains("<a"), "Anchor element should be preserved");
|
||||
assertTrue(sanitized.contains("mail me"), "Link text should remain");
|
||||
|
||||
// Some sanitizer versions allow mailto:, others strip it. Accept both.
|
||||
boolean keepsMailto = sanitized.contains("mailto:[email protected]");
|
||||
boolean strippedHref =
|
||||
sanitized.contains("href="); // still has an href, possibly sanitized
|
||||
// We accept either: mailto kept OR href present in sanitized form OR (in strict setups)
|
||||
// href removed but anchor kept.
|
||||
assertTrue(
|
||||
keepsMailto || strippedHref || sanitized.contains("</a>"),
|
||||
"Sanitized output should keep anchor; mailto: may or may not be present"
|
||||
+ " depending on sanitizer configuration");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,6 @@
|
||||
package stirling.software.common.util;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.Mockito.mock;
|
||||
@@ -17,6 +12,7 @@ import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Base64;
|
||||
import java.util.Locale;
|
||||
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.apache.pdfbox.pdmodel.PDPage;
|
||||
@@ -52,7 +48,7 @@ class EmlToPdfTest {
|
||||
when(mockSsrfProtectionService.isUrlAllowed(org.mockito.ArgumentMatchers.anyString()))
|
||||
.thenReturn(true);
|
||||
when(mockApplicationProperties.getSystem()).thenReturn(mockSystem);
|
||||
when(mockSystem.getDisableSanitize()).thenReturn(false);
|
||||
when(mockSystem.isDisableSanitize()).thenReturn(false);
|
||||
|
||||
customHtmlSanitizer =
|
||||
new CustomHtmlSanitizer(mockSsrfProtectionService, mockApplicationProperties);
|
||||
@@ -765,7 +761,7 @@ class EmlToPdfTest {
|
||||
}
|
||||
|
||||
// Helper methods
|
||||
private String getTimestamp() {
|
||||
private static String getTimestamp() {
|
||||
java.time.ZonedDateTime fixedDateTime =
|
||||
java.time.ZonedDateTime.of(2023, 1, 1, 12, 0, 0, 0, java.time.ZoneId.of("GMT"));
|
||||
return java.time.format.DateTimeFormatter.RFC_1123_DATE_TIME.format(fixedDateTime);
|
||||
@@ -778,20 +774,33 @@ class EmlToPdfTest {
|
||||
private String createSimpleTextEmailWithCharset(
|
||||
String from, String to, String subject, String body, String charset) {
|
||||
return String.format(
|
||||
Locale.ROOT,
|
||||
"From: %s\nTo: %s\nSubject: %s\nDate: %s\nContent-Type: text/plain; charset=%s\nContent-Transfer-Encoding: 8bit\n\n%s",
|
||||
from, to, subject, getTimestamp(), charset, body);
|
||||
from,
|
||||
to,
|
||||
subject,
|
||||
getTimestamp(),
|
||||
charset,
|
||||
body);
|
||||
}
|
||||
|
||||
private String createEmailWithCustomHeaders() {
|
||||
return String.format(
|
||||
Locale.ROOT,
|
||||
"From: [email protected]\nDate: %s\nContent-Type: text/plain; charset=UTF-8\nContent-Transfer-Encoding: 8bit\n\n%s",
|
||||
getTimestamp(), "This is an email body with some headers missing.");
|
||||
getTimestamp(),
|
||||
"This is an email body with some headers missing.");
|
||||
}
|
||||
|
||||
private String createHtmlEmail(String from, String to, String subject, String htmlBody) {
|
||||
return String.format(
|
||||
Locale.ROOT,
|
||||
"From: %s\nTo: %s\nSubject: %s\nDate: %s\nContent-Type: text/html; charset=UTF-8\nContent-Transfer-Encoding: 8bit\n\n%s",
|
||||
from, to, subject, getTimestamp(), htmlBody);
|
||||
from,
|
||||
to,
|
||||
subject,
|
||||
getTimestamp(),
|
||||
htmlBody);
|
||||
}
|
||||
|
||||
private String createMultipartEmailWithAttachment(
|
||||
@@ -806,6 +815,7 @@ class EmlToPdfTest {
|
||||
Base64.getEncoder()
|
||||
.encodeToString(attachmentContent.getBytes(StandardCharsets.UTF_8));
|
||||
return String.format(
|
||||
Locale.ROOT,
|
||||
"""
|
||||
From: %s
|
||||
To: %s
|
||||
@@ -845,6 +855,7 @@ class EmlToPdfTest {
|
||||
Base64.getEncoder()
|
||||
.encodeToString(attachmentEmlContent.getBytes(StandardCharsets.UTF_8));
|
||||
return String.format(
|
||||
Locale.ROOT,
|
||||
"""
|
||||
From: %s
|
||||
To: %s
|
||||
@@ -883,6 +894,7 @@ class EmlToPdfTest {
|
||||
private String createMultipartAlternativeEmail(
|
||||
String textBody, String htmlBody, String boundary) {
|
||||
return String.format(
|
||||
Locale.ROOT,
|
||||
"""
|
||||
From: %s
|
||||
To: %s
|
||||
@@ -918,6 +930,7 @@ class EmlToPdfTest {
|
||||
|
||||
private String createQuotedPrintableEmail() {
|
||||
return String.format(
|
||||
Locale.ROOT,
|
||||
"From: %s\nTo: %s\nSubject: %s\nDate: %s\nMIME-Version: 1.0\nContent-Type: text/plain; charset=UTF-8\nContent-Transfer-Encoding: quoted-printable\n\n%s",
|
||||
"[email protected]",
|
||||
"[email protected]",
|
||||
@@ -930,6 +943,7 @@ class EmlToPdfTest {
|
||||
String encodedBody =
|
||||
Base64.getEncoder().encodeToString(body.getBytes(StandardCharsets.UTF_8));
|
||||
return String.format(
|
||||
Locale.ROOT,
|
||||
"From: %s\nTo: %s\nSubject: %s\nDate: %s\nMIME-Version: 1.0\nContent-Type: text/plain; charset=UTF-8\nContent-Transfer-Encoding: base64\n\n%s",
|
||||
"[email protected]",
|
||||
"[email protected]",
|
||||
@@ -941,6 +955,7 @@ class EmlToPdfTest {
|
||||
private String createEmailWithInlineImage(
|
||||
String htmlBody, String boundary, String contentId, String base64Image) {
|
||||
return String.format(
|
||||
Locale.ROOT,
|
||||
"""
|
||||
From: %s
|
||||
To: %s
|
||||
@@ -985,6 +1000,7 @@ class EmlToPdfTest {
|
||||
String encodedAttachment =
|
||||
Base64.getEncoder().encodeToString(attachmentBody.getBytes(StandardCharsets.UTF_8));
|
||||
return String.format(
|
||||
Locale.ROOT,
|
||||
"""
|
||||
From: %s
|
||||
To: %s
|
||||
@@ -1039,13 +1055,13 @@ class EmlToPdfTest {
|
||||
}
|
||||
|
||||
// Creates a basic EmlToPdfRequest with default settings
|
||||
private EmlToPdfRequest createBasicRequest() {
|
||||
private static EmlToPdfRequest createBasicRequest() {
|
||||
EmlToPdfRequest request = new EmlToPdfRequest();
|
||||
request.setIncludeAttachments(false);
|
||||
return request;
|
||||
}
|
||||
|
||||
private EmlToPdfRequest createRequestWithAttachments() {
|
||||
private static EmlToPdfRequest createRequestWithAttachments() {
|
||||
EmlToPdfRequest request = new EmlToPdfRequest();
|
||||
request.setIncludeAttachments(true);
|
||||
request.setMaxAttachmentSizeMB(10);
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package stirling.software.common.util;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.ui.Model;
|
||||
|
||||
@@ -0,0 +1,354 @@
|
||||
package stirling.software.common.util;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Nested;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.MockedStatic;
|
||||
|
||||
/**
|
||||
* Unit tests for {@link ExceptionUtils}. Assumptions: - PdfErrorUtils.isCorruptedPdfError is a
|
||||
* static method that returns true for certain exception types. We will mock it in tests for
|
||||
* handlePdfException and logException.
|
||||
*/
|
||||
class ExceptionUtilsTest {
|
||||
|
||||
@Nested
|
||||
@DisplayName("PDF corruption exception creation")
|
||||
class PdfCorruptionTests {
|
||||
|
||||
@Test
|
||||
@DisplayName("should create PdfCorruptedException without context")
|
||||
void testCreatePdfCorruptedExceptionWithoutContext() {
|
||||
Exception cause = new Exception("root");
|
||||
IOException ex = ExceptionUtils.createPdfCorruptedException(null, cause);
|
||||
|
||||
assertTrue(ex.getMessage().contains("PDF file appears to be corrupted"));
|
||||
assertSame(cause, ex.getCause());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("should create PdfCorruptedException with context")
|
||||
void testCreatePdfCorruptedExceptionWithContext() {
|
||||
Exception cause = new Exception("root");
|
||||
IOException ex = ExceptionUtils.createPdfCorruptedException("during merge", cause);
|
||||
|
||||
assertTrue(
|
||||
ex.getMessage()
|
||||
.startsWith("Error during merge: PDF file appears to be corrupted"));
|
||||
assertSame(cause, ex.getCause());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("should create MultiplePdfCorruptedException")
|
||||
void testCreateMultiplePdfCorruptedException() {
|
||||
Exception cause = new Exception("root");
|
||||
IOException ex = ExceptionUtils.createMultiplePdfCorruptedException(cause);
|
||||
|
||||
assertTrue(ex.getMessage().startsWith("One or more PDF files appear to be corrupted"));
|
||||
assertSame(cause, ex.getCause());
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("PDF encryption and password exception creation")
|
||||
class PdfSecurityTests {
|
||||
|
||||
@Test
|
||||
void testCreatePdfEncryptionException() {
|
||||
Exception cause = new Exception("root");
|
||||
IOException ex = ExceptionUtils.createPdfEncryptionException(cause);
|
||||
assertTrue(ex.getMessage().contains("corrupted encryption data"));
|
||||
assertSame(cause, ex.getCause());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testCreatePdfPasswordException() {
|
||||
Exception cause = new Exception("root");
|
||||
IOException ex = ExceptionUtils.createPdfPasswordException(cause);
|
||||
assertTrue(ex.getMessage().contains("passworded"));
|
||||
assertSame(cause, ex.getCause());
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("File processing exception creation")
|
||||
class FileProcessingTests {
|
||||
|
||||
@Test
|
||||
void testCreateFileProcessingException() {
|
||||
Exception cause = new Exception("boom");
|
||||
IOException ex = ExceptionUtils.createFileProcessingException("merge", cause);
|
||||
assertTrue(ex.getMessage().contains("while processing the file during merge"));
|
||||
assertSame(cause, ex.getCause());
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("Generic exception creation")
|
||||
class GenericCreationTests {
|
||||
|
||||
@Test
|
||||
void testCreateIOException() {
|
||||
IOException ex =
|
||||
ExceptionUtils.createIOException(
|
||||
"key", "Default message: {0}", new Exception("cause"), "X");
|
||||
assertEquals("Default message: X", ex.getMessage());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testCreateRuntimeException() {
|
||||
RuntimeException ex =
|
||||
ExceptionUtils.createRuntimeException(
|
||||
"key", "Default message: {0}", new Exception("cause"), "Y");
|
||||
assertEquals("Default message: Y", ex.getMessage());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testCreateIllegalArgumentException() {
|
||||
IllegalArgumentException ex =
|
||||
ExceptionUtils.createIllegalArgumentException("key", "Format {0}", "Z");
|
||||
assertEquals("Format Z", ex.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("Predefined validation exceptions")
|
||||
class PredefinedValidationTests {
|
||||
|
||||
@Test
|
||||
void testCreateHtmlFileRequiredException() {
|
||||
IllegalArgumentException ex = ExceptionUtils.createHtmlFileRequiredException();
|
||||
assertTrue(ex.getMessage().contains("HTML or ZIP"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testCreatePdfFileRequiredException() {
|
||||
IllegalArgumentException ex = ExceptionUtils.createPdfFileRequiredException();
|
||||
assertTrue(ex.getMessage().contains("PDF"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testCreateInvalidPageSizeException() {
|
||||
IllegalArgumentException ex = ExceptionUtils.createInvalidPageSizeException("A5");
|
||||
assertTrue(ex.getMessage().contains("page size"));
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("OCR and system requirement exceptions")
|
||||
class OcrAndSystemTests {
|
||||
|
||||
@Test
|
||||
void testCreateOcrLanguageRequiredException() {
|
||||
IOException ex = ExceptionUtils.createOcrLanguageRequiredException();
|
||||
assertTrue(ex.getMessage().contains("OCR language"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testCreateOcrInvalidLanguagesException() {
|
||||
IOException ex = ExceptionUtils.createOcrInvalidLanguagesException();
|
||||
assertTrue(ex.getMessage().contains("none of the selected languages"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testCreateOcrToolsUnavailableException() {
|
||||
IOException ex = ExceptionUtils.createOcrToolsUnavailableException();
|
||||
assertTrue(ex.getMessage().contains("OCR tools"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testCreatePythonRequiredForWebpException() {
|
||||
IOException ex = ExceptionUtils.createPythonRequiredForWebpException();
|
||||
assertTrue(ex.getMessage().contains("Python"));
|
||||
assertTrue(ex.getMessage().contains("WebP conversion"));
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("File operation and compression exceptions")
|
||||
class FileAndCompressionTests {
|
||||
|
||||
@Test
|
||||
void testCreatePdfaConversionFailedException() {
|
||||
RuntimeException ex = ExceptionUtils.createPdfaConversionFailedException();
|
||||
assertTrue(ex.getMessage().contains("PDF/A conversion failed"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testCreateMd5AlgorithmException() {
|
||||
RuntimeException ex = ExceptionUtils.createMd5AlgorithmException(new Exception("x"));
|
||||
assertTrue(ex.getMessage().contains("MD5"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testCreateGhostscriptCompressionExceptionNoCause() {
|
||||
IOException ex = ExceptionUtils.createGhostscriptCompressionException();
|
||||
assertTrue(ex.getMessage().contains("Ghostscript"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testCreateGhostscriptCompressionExceptionWithCause() {
|
||||
IOException ex =
|
||||
ExceptionUtils.createGhostscriptCompressionException(new Exception("cause"));
|
||||
assertTrue(ex.getMessage().contains("Ghostscript"));
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("PDF exception handling")
|
||||
class PdfExceptionHandlingTests {
|
||||
|
||||
@Test
|
||||
void testHandlePdfExceptionWhenCorrupted() {
|
||||
IOException original = new IOException("corrupted pdf");
|
||||
try (MockedStatic<PdfErrorUtils> mock = mockStatic(PdfErrorUtils.class)) {
|
||||
mock.when(() -> PdfErrorUtils.isCorruptedPdfError(original)).thenReturn(true);
|
||||
IOException result = ExceptionUtils.handlePdfException(original);
|
||||
assertNotSame(original, result);
|
||||
assertTrue(result.getMessage().contains("corrupted"));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void testHandlePdfExceptionWhenEncryptionError() {
|
||||
IOException original = new IOException("BadPaddingException");
|
||||
try (MockedStatic<PdfErrorUtils> mock = mockStatic(PdfErrorUtils.class)) {
|
||||
mock.when(() -> PdfErrorUtils.isCorruptedPdfError(original)).thenReturn(false);
|
||||
IOException result = ExceptionUtils.handlePdfException(original);
|
||||
assertTrue(result.getMessage().contains("corrupted encryption data"));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void testHandlePdfExceptionWhenPasswordError() {
|
||||
IOException original = new IOException("password is incorrect");
|
||||
try (MockedStatic<PdfErrorUtils> mock = mockStatic(PdfErrorUtils.class)) {
|
||||
mock.when(() -> PdfErrorUtils.isCorruptedPdfError(original)).thenReturn(false);
|
||||
IOException result = ExceptionUtils.handlePdfException(original);
|
||||
assertTrue(result.getMessage().contains("passworded"));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void testHandlePdfExceptionWhenNoSpecialError() {
|
||||
IOException original = new IOException("something else");
|
||||
try (MockedStatic<PdfErrorUtils> mock = mockStatic(PdfErrorUtils.class)) {
|
||||
mock.when(() -> PdfErrorUtils.isCorruptedPdfError(original)).thenReturn(false);
|
||||
IOException result = ExceptionUtils.handlePdfException(original);
|
||||
assertSame(original, result);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("Encryption and password detection")
|
||||
class ErrorDetectionTests {
|
||||
|
||||
@Test
|
||||
void testIsEncryptionErrorTrue() {
|
||||
assertTrue(ExceptionUtils.isEncryptionError(new IOException("BadPaddingException")));
|
||||
assertTrue(
|
||||
ExceptionUtils.isEncryptionError(
|
||||
new IOException("Given final block not properly padded")));
|
||||
assertTrue(
|
||||
ExceptionUtils.isEncryptionError(
|
||||
new IOException("AES initialization vector not fully read")));
|
||||
assertTrue(ExceptionUtils.isEncryptionError(new IOException("Failed to decrypt")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testIsEncryptionErrorFalse() {
|
||||
assertFalse(ExceptionUtils.isEncryptionError(new IOException("other message")));
|
||||
assertFalse(ExceptionUtils.isEncryptionError(new IOException((String) null)));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testIsPasswordErrorTrue() {
|
||||
assertTrue(ExceptionUtils.isPasswordError(new IOException("password is incorrect")));
|
||||
assertTrue(ExceptionUtils.isPasswordError(new IOException("Password is not provided")));
|
||||
assertTrue(
|
||||
ExceptionUtils.isPasswordError(
|
||||
new IOException("PDF contains an encryption dictionary")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testIsPasswordErrorFalse() {
|
||||
assertFalse(ExceptionUtils.isPasswordError(new IOException("something else")));
|
||||
assertFalse(ExceptionUtils.isPasswordError(new IOException((String) null)));
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("Logging behavior")
|
||||
class LoggingTests {
|
||||
|
||||
@Test
|
||||
void testLogExceptionWhenCorruptedPdf() {
|
||||
Exception e = new IOException("corrupted");
|
||||
try (MockedStatic<PdfErrorUtils> mock = mockStatic(PdfErrorUtils.class)) {
|
||||
mock.when(() -> PdfErrorUtils.isCorruptedPdfError(e)).thenReturn(true);
|
||||
// We can't assert log output here without a custom appender, but this ensures no
|
||||
// exception is thrown
|
||||
ExceptionUtils.logException("merge", e);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void testLogExceptionWhenEncryptionError() {
|
||||
IOException e = new IOException("BadPaddingException");
|
||||
try (MockedStatic<PdfErrorUtils> mock = mockStatic(PdfErrorUtils.class)) {
|
||||
mock.when(() -> PdfErrorUtils.isCorruptedPdfError(e)).thenReturn(false);
|
||||
ExceptionUtils.logException("merge", e);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void testLogExceptionWhenPasswordError() {
|
||||
IOException e = new IOException("password is incorrect");
|
||||
try (MockedStatic<PdfErrorUtils> mock = mockStatic(PdfErrorUtils.class)) {
|
||||
mock.when(() -> PdfErrorUtils.isCorruptedPdfError(e)).thenReturn(false);
|
||||
ExceptionUtils.logException("merge", e);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void testLogExceptionUnexpectedError() {
|
||||
Exception e = new RuntimeException("unexpected");
|
||||
try (MockedStatic<PdfErrorUtils> mock = mockStatic(PdfErrorUtils.class)) {
|
||||
mock.when(() -> PdfErrorUtils.isCorruptedPdfError(e)).thenReturn(false);
|
||||
ExceptionUtils.logException("merge", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("Invalid and null argument exceptions")
|
||||
class ArgumentValidationTests {
|
||||
|
||||
@Test
|
||||
void testCreateInvalidArgumentExceptionSingle() {
|
||||
IllegalArgumentException ex =
|
||||
ExceptionUtils.createInvalidArgumentException("arg", "invalidValue");
|
||||
assertTrue(ex.getMessage().contains("arg"));
|
||||
assertTrue(ex.getMessage().contains("invalidValue"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testCreateInvalidArgumentExceptionWithValue() {
|
||||
IllegalArgumentException ex =
|
||||
ExceptionUtils.createInvalidArgumentException("arg", "val");
|
||||
assertTrue(ex.getMessage().contains("val"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testCreateNullArgumentException() {
|
||||
IllegalArgumentException ex = ExceptionUtils.createNullArgumentException("arg");
|
||||
assertTrue(ex.getMessage().contains("arg"));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
package stirling.software.common.util;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
@@ -46,12 +45,15 @@ class FileMonitorTest {
|
||||
|
||||
@Test
|
||||
void testIsFileReadyForProcessing_OldFile() throws IOException {
|
||||
// Capture test time at the beginning for deterministic calculations
|
||||
final Instant testTime = Instant.now();
|
||||
|
||||
// Create a test file
|
||||
Path testFile = tempDir.resolve("test-file.txt");
|
||||
Files.write(testFile, "test content".getBytes());
|
||||
|
||||
// Set modified time to 10 seconds ago
|
||||
Files.setLastModifiedTime(testFile, FileTime.from(Instant.now().minusMillis(10000)));
|
||||
// Set modified time to 10 seconds ago (relative to test start time)
|
||||
Files.setLastModifiedTime(testFile, FileTime.from(testTime.minusMillis(10000)));
|
||||
|
||||
// File should be ready for processing as it was modified more than 5 seconds ago
|
||||
assertTrue(fileMonitor.isFileReadyForProcessing(testFile));
|
||||
@@ -59,12 +61,15 @@ class FileMonitorTest {
|
||||
|
||||
@Test
|
||||
void testIsFileReadyForProcessing_RecentFile() throws IOException {
|
||||
// Capture test time at the beginning for deterministic calculations
|
||||
final Instant testTime = Instant.now();
|
||||
|
||||
// Create a test file
|
||||
Path testFile = tempDir.resolve("recent-file.txt");
|
||||
Files.write(testFile, "test content".getBytes());
|
||||
|
||||
// Set modified time to just now
|
||||
Files.setLastModifiedTime(testFile, FileTime.from(Instant.now()));
|
||||
// Set modified time to just now (relative to test start time)
|
||||
Files.setLastModifiedTime(testFile, FileTime.from(testTime));
|
||||
|
||||
// File should not be ready for processing as it was just modified
|
||||
assertFalse(fileMonitor.isFileReadyForProcessing(testFile));
|
||||
@@ -81,12 +86,16 @@ class FileMonitorTest {
|
||||
|
||||
@Test
|
||||
void testIsFileReadyForProcessing_LockedFile() throws IOException {
|
||||
// Capture test time at the beginning for deterministic calculations
|
||||
final Instant testTime = Instant.now();
|
||||
|
||||
// Create a test file
|
||||
Path testFile = tempDir.resolve("locked-file.txt");
|
||||
Files.write(testFile, "test content".getBytes());
|
||||
|
||||
// Set modified time to 10 seconds ago to make sure it passes the time check
|
||||
Files.setLastModifiedTime(testFile, FileTime.from(Instant.now().minusMillis(10000)));
|
||||
// Set modified time to 10 seconds ago (relative to test start time) to make sure it passes
|
||||
// the time check
|
||||
Files.setLastModifiedTime(testFile, FileTime.from(testTime.minusMillis(10000)));
|
||||
|
||||
// Verify the file is considered ready when it meets the time criteria
|
||||
assertTrue(
|
||||
@@ -105,12 +114,12 @@ class FileMonitorTest {
|
||||
// Create a PDF file
|
||||
Path pdfFile = tempDir.resolve("test.pdf");
|
||||
Files.write(pdfFile, "pdf content".getBytes());
|
||||
Files.setLastModifiedTime(pdfFile, FileTime.from(Instant.now().minusMillis(10000)));
|
||||
Files.setLastModifiedTime(pdfFile, FileTime.from(Instant.ofEpochMilli(1000000L)));
|
||||
|
||||
// Create a TXT file
|
||||
Path txtFile = tempDir.resolve("test.txt");
|
||||
Files.write(txtFile, "text content".getBytes());
|
||||
Files.setLastModifiedTime(txtFile, FileTime.from(Instant.now().minusMillis(10000)));
|
||||
Files.setLastModifiedTime(txtFile, FileTime.from(Instant.ofEpochMilli(1000000L)));
|
||||
|
||||
// PDF file should be ready for processing
|
||||
assertTrue(pdfMonitor.isFileReadyForProcessing(pdfFile));
|
||||
@@ -126,12 +135,15 @@ class FileMonitorTest {
|
||||
|
||||
@Test
|
||||
void testIsFileReadyForProcessing_FileInUse() throws IOException {
|
||||
// Capture test time at the beginning for deterministic calculations
|
||||
final Instant testTime = Instant.now();
|
||||
|
||||
// Create a test file
|
||||
Path testFile = tempDir.resolve("in-use-file.txt");
|
||||
Files.write(testFile, "initial content".getBytes());
|
||||
|
||||
// Set modified time to 10 seconds ago
|
||||
Files.setLastModifiedTime(testFile, FileTime.from(Instant.now().minusMillis(10000)));
|
||||
// Set modified time to 10 seconds ago (relative to test start time)
|
||||
Files.setLastModifiedTime(testFile, FileTime.from(testTime.minusMillis(10000)));
|
||||
|
||||
// First check that the file is ready when meeting time criteria
|
||||
assertTrue(
|
||||
@@ -140,7 +152,7 @@ class FileMonitorTest {
|
||||
|
||||
// After modifying the file to simulate closing, it should still be ready
|
||||
Files.write(testFile, "updated content".getBytes());
|
||||
Files.setLastModifiedTime(testFile, FileTime.from(Instant.now().minusMillis(10000)));
|
||||
Files.setLastModifiedTime(testFile, FileTime.from(testTime.minusMillis(10000)));
|
||||
|
||||
assertTrue(
|
||||
fileMonitor.isFileReadyForProcessing(testFile),
|
||||
@@ -149,12 +161,15 @@ class FileMonitorTest {
|
||||
|
||||
@Test
|
||||
void testIsFileReadyForProcessing_FileWithAbsolutePath() throws IOException {
|
||||
// Capture test time at the beginning for deterministic calculations
|
||||
final Instant testTime = Instant.now();
|
||||
|
||||
// Create a test file
|
||||
Path testFile = tempDir.resolve("absolute-path-file.txt");
|
||||
Files.write(testFile, "test content".getBytes());
|
||||
|
||||
// Set modified time to 10 seconds ago
|
||||
Files.setLastModifiedTime(testFile, FileTime.from(Instant.now().minusMillis(10000)));
|
||||
// Set modified time to 10 seconds ago (relative to test start time)
|
||||
Files.setLastModifiedTime(testFile, FileTime.from(testTime.minusMillis(10000)));
|
||||
|
||||
// File should be ready for processing as it was modified more than 5 seconds ago
|
||||
// Use the absolute path to make sure it's handled correctly
|
||||
@@ -168,7 +183,7 @@ class FileMonitorTest {
|
||||
Files.createDirectory(testDir);
|
||||
|
||||
// Set modified time to 10 seconds ago
|
||||
Files.setLastModifiedTime(testDir, FileTime.from(Instant.now().minusMillis(10000)));
|
||||
Files.setLastModifiedTime(testDir, FileTime.from(Instant.ofEpochMilli(1000000L)));
|
||||
|
||||
// A directory should not be considered ready for processing
|
||||
boolean isReady = fileMonitor.isFileReadyForProcessing(testDir);
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
package stirling.software.common.util;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
@@ -31,7 +29,7 @@ public class FileToPdfTest {
|
||||
when(mockSsrfProtectionService.isUrlAllowed(org.mockito.ArgumentMatchers.anyString()))
|
||||
.thenReturn(true);
|
||||
when(mockApplicationProperties.getSystem()).thenReturn(mockSystem);
|
||||
when(mockSystem.getDisableSanitize()).thenReturn(false);
|
||||
when(mockSystem.isDisableSanitize()).thenReturn(false);
|
||||
|
||||
customHtmlSanitizer =
|
||||
new CustomHtmlSanitizer(mockSsrfProtectionService, mockApplicationProperties);
|
||||
|
||||
+15
@@ -29,6 +29,21 @@ class GeneralUtilsAdditionalTest {
|
||||
assertTrue(GeneralUtils.isValidURL("https://example.com"));
|
||||
assertFalse(GeneralUtils.isValidURL("htp:/bad"));
|
||||
assertFalse(GeneralUtils.isURLReachable("http://localhost"));
|
||||
assertFalse(GeneralUtils.isURLReachable("http://0.0.0.0"));
|
||||
assertFalse(GeneralUtils.isURLReachable("http://192.168.1.1"));
|
||||
assertFalse(GeneralUtils.isURLReachable("http://169.254.0.1"));
|
||||
assertFalse(GeneralUtils.isURLReachable("http://172.16.0.1"));
|
||||
assertFalse(GeneralUtils.isURLReachable("http://192.0.2.1"));
|
||||
assertFalse(GeneralUtils.isURLReachable("http://192.0.0.0"));
|
||||
assertFalse(GeneralUtils.isURLReachable("http://192.168.0.0"));
|
||||
assertFalse(GeneralUtils.isURLReachable("http://198.18.0.1"));
|
||||
assertFalse(GeneralUtils.isURLReachable("http://198.51.100.0"));
|
||||
assertFalse(GeneralUtils.isURLReachable("http://203.0.113.0"));
|
||||
assertFalse(GeneralUtils.isURLReachable("http://10.0.0.0"));
|
||||
assertFalse(GeneralUtils.isURLReachable("http://100.64.0.1"));
|
||||
assertFalse(GeneralUtils.isURLReachable("http://224.0.0.0"));
|
||||
assertFalse(GeneralUtils.isURLReachable("http://[::ffff:127.0.0.1]/"));
|
||||
assertFalse(GeneralUtils.isURLReachable("http://[fd12:3456:789a::1]/"));
|
||||
assertFalse(GeneralUtils.isURLReachable("ftp://example.com"));
|
||||
|
||||
assertTrue(GeneralUtils.isValidUUID("123e4567-e89b-12d3-a456-426614174000"));
|
||||
|
||||
+13
-15
@@ -1,8 +1,6 @@
|
||||
package stirling.software.common.util;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
import java.awt.*;
|
||||
import java.awt.image.BufferedImage;
|
||||
@@ -11,10 +9,18 @@ import org.junit.jupiter.api.Test;
|
||||
|
||||
public class ImageProcessingUtilsTest {
|
||||
|
||||
private static void fillImageWithColor(BufferedImage image) {
|
||||
for (int y = 0; y < image.getHeight(); y++) {
|
||||
for (int x = 0; x < image.getWidth(); x++) {
|
||||
image.setRGB(x, y, Color.RED.getRGB());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void testConvertColorTypeToGreyscale() {
|
||||
BufferedImage sourceImage = new BufferedImage(100, 100, BufferedImage.TYPE_INT_RGB);
|
||||
fillImageWithColor(sourceImage, Color.RED);
|
||||
fillImageWithColor(sourceImage);
|
||||
|
||||
BufferedImage convertedImage =
|
||||
ImageProcessingUtils.convertColorType(sourceImage, "greyscale");
|
||||
@@ -33,7 +39,7 @@ public class ImageProcessingUtilsTest {
|
||||
@Test
|
||||
void testConvertColorTypeToBlackWhite() {
|
||||
BufferedImage sourceImage = new BufferedImage(100, 100, BufferedImage.TYPE_INT_RGB);
|
||||
fillImageWithColor(sourceImage, Color.RED);
|
||||
fillImageWithColor(sourceImage);
|
||||
|
||||
BufferedImage convertedImage =
|
||||
ImageProcessingUtils.convertColorType(sourceImage, "blackwhite");
|
||||
@@ -51,7 +57,7 @@ public class ImageProcessingUtilsTest {
|
||||
@Test
|
||||
void testConvertColorTypeToFullColor() {
|
||||
BufferedImage sourceImage = new BufferedImage(100, 100, BufferedImage.TYPE_INT_RGB);
|
||||
fillImageWithColor(sourceImage, Color.RED);
|
||||
fillImageWithColor(sourceImage);
|
||||
|
||||
BufferedImage convertedImage =
|
||||
ImageProcessingUtils.convertColorType(sourceImage, "fullcolor");
|
||||
@@ -63,7 +69,7 @@ public class ImageProcessingUtilsTest {
|
||||
@Test
|
||||
void testConvertColorTypeInvalid() {
|
||||
BufferedImage sourceImage = new BufferedImage(100, 100, BufferedImage.TYPE_INT_RGB);
|
||||
fillImageWithColor(sourceImage, Color.RED);
|
||||
fillImageWithColor(sourceImage);
|
||||
|
||||
BufferedImage convertedImage =
|
||||
ImageProcessingUtils.convertColorType(sourceImage, "invalidtype");
|
||||
@@ -71,12 +77,4 @@ public class ImageProcessingUtilsTest {
|
||||
assertNotNull(convertedImage);
|
||||
assertEquals(sourceImage, convertedImage);
|
||||
}
|
||||
|
||||
private void fillImageWithColor(BufferedImage image, Color color) {
|
||||
for (int y = 0; y < image.getHeight(); y++) {
|
||||
for (int x = 0; x < image.getWidth(); x++) {
|
||||
image.setRGB(x, y, color.getRGB());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
package stirling.software.common.util;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyList;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
@@ -34,6 +32,7 @@ import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import io.github.pixee.security.ZipSecurity;
|
||||
|
||||
import stirling.software.common.configuration.RuntimePathConfig;
|
||||
import stirling.software.common.util.ProcessExecutor.ProcessExecutorResult;
|
||||
|
||||
/**
|
||||
@@ -50,6 +49,7 @@ class PDFToFileTest {
|
||||
@Mock private ProcessExecutor mockProcessExecutor;
|
||||
@Mock private ProcessExecutorResult mockExecutorResult;
|
||||
@Mock private TempFileManager mockTempFileManager;
|
||||
@Mock private RuntimePathConfig mockRuntimePathConfig;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() throws IOException {
|
||||
@@ -63,7 +63,9 @@ class PDFToFileTest {
|
||||
.when(mockTempFileManager.createTempDirectory())
|
||||
.thenAnswer(invocation -> Files.createTempDirectory("test"));
|
||||
|
||||
pdfToFile = new PDFToFile(mockTempFileManager);
|
||||
lenient().when(mockRuntimePathConfig.getSOfficePath()).thenReturn("/usr/bin/soffice");
|
||||
|
||||
pdfToFile = new PDFToFile(mockTempFileManager, mockRuntimePathConfig);
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -329,12 +331,10 @@ class PDFToFileTest {
|
||||
boolean foundImage = false;
|
||||
|
||||
while ((entry = zipStream.getNextEntry()) != null) {
|
||||
if ("test.html".equals(entry.getName())) {
|
||||
foundMainHtml = true;
|
||||
} else if ("test_ind.html".equals(entry.getName())) {
|
||||
foundIndexHtml = true;
|
||||
} else if ("test_img.png".equals(entry.getName())) {
|
||||
foundImage = true;
|
||||
switch (entry.getName()) {
|
||||
case "test.html" -> foundMainHtml = true;
|
||||
case "test_ind.html" -> foundIndexHtml = true;
|
||||
case "test_img.png" -> foundImage = true;
|
||||
}
|
||||
zipStream.closeEntry();
|
||||
}
|
||||
@@ -367,7 +367,8 @@ class PDFToFileTest {
|
||||
when(mockProcessExecutor.runCommandWithOutputHandling(
|
||||
argThat(
|
||||
args ->
|
||||
args.contains("--convert-to")
|
||||
args != null
|
||||
&& args.contains("--convert-to")
|
||||
&& args.contains("docx"))))
|
||||
.thenAnswer(
|
||||
invocation -> {
|
||||
@@ -382,6 +383,7 @@ class PDFToFileTest {
|
||||
}
|
||||
|
||||
// Create output file
|
||||
assertNotNull(outDir);
|
||||
Files.write(
|
||||
Path.of(outDir, "document.docx"),
|
||||
"Fake DOCX content".getBytes());
|
||||
@@ -427,7 +429,11 @@ class PDFToFileTest {
|
||||
.thenReturn(mockProcessExecutor);
|
||||
|
||||
when(mockProcessExecutor.runCommandWithOutputHandling(
|
||||
argThat(args -> args.contains("--convert-to") && args.contains("odp"))))
|
||||
argThat(
|
||||
args ->
|
||||
args != null
|
||||
&& args.contains("--convert-to")
|
||||
&& args.contains("odp"))))
|
||||
.thenAnswer(
|
||||
invocation -> {
|
||||
// When command is executed, find the output directory argument
|
||||
@@ -442,6 +448,7 @@ class PDFToFileTest {
|
||||
|
||||
// Create multiple output files (simulating a presentation with
|
||||
// multiple files)
|
||||
assertNotNull(outDir);
|
||||
Files.write(
|
||||
Path.of(outDir, "document.odp"),
|
||||
"Fake ODP content".getBytes());
|
||||
@@ -515,7 +522,8 @@ class PDFToFileTest {
|
||||
when(mockProcessExecutor.runCommandWithOutputHandling(
|
||||
argThat(
|
||||
args ->
|
||||
args.contains("--convert-to")
|
||||
args != null
|
||||
&& args.contains("--convert-to")
|
||||
&& args.contains("txt:Text"))))
|
||||
.thenAnswer(
|
||||
invocation -> {
|
||||
@@ -530,6 +538,7 @@ class PDFToFileTest {
|
||||
}
|
||||
|
||||
// Create text output file
|
||||
assertNotNull(outDir);
|
||||
Files.write(
|
||||
Path.of(outDir, "document.txt"),
|
||||
"Extracted text content".getBytes());
|
||||
@@ -587,6 +596,7 @@ class PDFToFileTest {
|
||||
}
|
||||
|
||||
// Create output file - uses default name
|
||||
assertNotNull(outDir);
|
||||
Files.write(
|
||||
Path.of(outDir, "output.docx"),
|
||||
"Fake DOCX content".getBytes());
|
||||
@@ -611,4 +621,110 @@ class PDFToFileTest {
|
||||
.contains("output.docx"));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void testProcessPdfToOfficeFormat_UsesUnoconvertWhenConfigured()
|
||||
throws IOException, InterruptedException {
|
||||
when(mockRuntimePathConfig.getUnoConvertPath()).thenReturn("/custom/unoconvert");
|
||||
PDFToFile pdfToFileWithUno = new PDFToFile(mockTempFileManager, mockRuntimePathConfig);
|
||||
|
||||
try (MockedStatic<ProcessExecutor> mockedStaticProcessExecutor =
|
||||
mockStatic(ProcessExecutor.class)) {
|
||||
MultipartFile pdfFile =
|
||||
new MockMultipartFile(
|
||||
"file",
|
||||
"document.pdf",
|
||||
MediaType.APPLICATION_PDF_VALUE,
|
||||
"Fake PDF content".getBytes());
|
||||
|
||||
mockedStaticProcessExecutor
|
||||
.when(() -> ProcessExecutor.getInstance(ProcessExecutor.Processes.LIBRE_OFFICE))
|
||||
.thenReturn(mockProcessExecutor);
|
||||
|
||||
when(mockProcessExecutor.runCommandWithOutputHandling(
|
||||
argThat(args -> args != null && args.contains("/custom/unoconvert"))))
|
||||
.thenAnswer(
|
||||
invocation -> {
|
||||
List<String> args = invocation.getArgument(0);
|
||||
String outputPath = args.get(args.size() - 1);
|
||||
Files.write(Path.of(outputPath), "Fake DOCX content".getBytes());
|
||||
return mockExecutorResult;
|
||||
});
|
||||
|
||||
ResponseEntity<byte[]> response =
|
||||
pdfToFileWithUno.processPdfToOfficeFormat(pdfFile, "docx", "writer_pdf_import");
|
||||
|
||||
assertEquals(HttpStatus.OK, response.getStatusCode());
|
||||
assertNotNull(response.getBody());
|
||||
assertTrue(response.getBody().length > 0);
|
||||
assertTrue(
|
||||
response.getHeaders()
|
||||
.getContentDisposition()
|
||||
.toString()
|
||||
.contains("document.docx"));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void testProcessPdfToOfficeFormat_FallsBackWhenUnoconvertFails()
|
||||
throws IOException, InterruptedException {
|
||||
when(mockRuntimePathConfig.getUnoConvertPath()).thenReturn("/custom/unoconvert");
|
||||
PDFToFile pdfToFileWithUno = new PDFToFile(mockTempFileManager, mockRuntimePathConfig);
|
||||
|
||||
try (MockedStatic<ProcessExecutor> mockedStaticProcessExecutor =
|
||||
mockStatic(ProcessExecutor.class)) {
|
||||
MultipartFile pdfFile =
|
||||
new MockMultipartFile(
|
||||
"file",
|
||||
"document.pdf",
|
||||
MediaType.APPLICATION_PDF_VALUE,
|
||||
"Fake PDF content".getBytes());
|
||||
|
||||
mockedStaticProcessExecutor
|
||||
.when(() -> ProcessExecutor.getInstance(ProcessExecutor.Processes.LIBRE_OFFICE))
|
||||
.thenReturn(mockProcessExecutor);
|
||||
|
||||
when(mockProcessExecutor.runCommandWithOutputHandling(
|
||||
argThat(args -> args != null && args.contains("/custom/unoconvert"))))
|
||||
.thenThrow(new IOException("Conversion failed"));
|
||||
|
||||
when(mockProcessExecutor.runCommandWithOutputHandling(
|
||||
argThat(
|
||||
args ->
|
||||
args != null
|
||||
&& args.stream()
|
||||
.anyMatch(
|
||||
arg ->
|
||||
arg.contains(
|
||||
"soffice")))))
|
||||
.thenAnswer(
|
||||
invocation -> {
|
||||
List<String> args = invocation.getArgument(0);
|
||||
String outDir = null;
|
||||
for (int i = 0; i < args.size(); i++) {
|
||||
if ("--outdir".equals(args.get(i)) && i + 1 < args.size()) {
|
||||
outDir = args.get(i + 1);
|
||||
break;
|
||||
}
|
||||
}
|
||||
assertNotNull(outDir);
|
||||
Files.write(
|
||||
Path.of(outDir, "document.docx"),
|
||||
"Fallback DOCX content".getBytes());
|
||||
return mockExecutorResult;
|
||||
});
|
||||
|
||||
ResponseEntity<byte[]> response =
|
||||
pdfToFileWithUno.processPdfToOfficeFormat(pdfFile, "docx", "writer_pdf_import");
|
||||
|
||||
assertEquals(HttpStatus.OK, response.getStatusCode());
|
||||
assertNotNull(response.getBody());
|
||||
assertTrue(response.getBody().length > 0);
|
||||
assertTrue(
|
||||
response.getHeaders()
|
||||
.getContentDisposition()
|
||||
.toString()
|
||||
.contains("document.docx"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,27 +1,45 @@
|
||||
package stirling.software.common.util;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.mockStatic;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.awt.Color;
|
||||
import java.awt.Graphics2D;
|
||||
import java.awt.image.BufferedImage;
|
||||
import java.awt.image.ColorModel;
|
||||
import java.awt.image.RenderedImage;
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.util.Collections;
|
||||
import java.util.HashSet;
|
||||
import java.util.Set;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.zip.ZipEntry;
|
||||
import java.util.zip.ZipInputStream;
|
||||
|
||||
import javax.imageio.ImageIO;
|
||||
|
||||
import org.apache.pdfbox.cos.COSName;
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.apache.pdfbox.pdmodel.PDPage;
|
||||
import org.apache.pdfbox.pdmodel.PDPageContentStream;
|
||||
import org.apache.pdfbox.pdmodel.PDPageContentStream.AppendMode;
|
||||
import org.apache.pdfbox.pdmodel.PDResources;
|
||||
import org.apache.pdfbox.pdmodel.common.PDRectangle;
|
||||
import org.apache.pdfbox.pdmodel.font.PDType1Font;
|
||||
import org.apache.pdfbox.pdmodel.font.Standard14Fonts;
|
||||
import org.apache.pdfbox.pdmodel.graphics.PDXObject;
|
||||
import org.apache.pdfbox.pdmodel.graphics.form.PDFormXObject;
|
||||
import org.apache.pdfbox.pdmodel.graphics.image.LosslessFactory;
|
||||
import org.apache.pdfbox.pdmodel.graphics.image.PDImageXObject;
|
||||
import org.apache.pdfbox.rendering.ImageType;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.Mockito;
|
||||
import org.mockito.MockedStatic;
|
||||
import org.springframework.mock.web.MockMultipartFile;
|
||||
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.common.service.CustomPDFDocumentFactory;
|
||||
@@ -31,32 +49,77 @@ public class PdfUtilsTest {
|
||||
|
||||
@Test
|
||||
void testTextToPageSize() {
|
||||
assertEquals(PDRectangle.A0, PdfUtils.textToPageSize("A0"));
|
||||
assertEquals(PDRectangle.A1, PdfUtils.textToPageSize("A1"));
|
||||
assertEquals(PDRectangle.A2, PdfUtils.textToPageSize("A2"));
|
||||
assertEquals(PDRectangle.A3, PdfUtils.textToPageSize("A3"));
|
||||
assertEquals(PDRectangle.A4, PdfUtils.textToPageSize("A4"));
|
||||
assertEquals(PDRectangle.A5, PdfUtils.textToPageSize("A5"));
|
||||
assertEquals(PDRectangle.A6, PdfUtils.textToPageSize("A6"));
|
||||
assertEquals(PDRectangle.LETTER, PdfUtils.textToPageSize("LETTER"));
|
||||
assertEquals(PDRectangle.LEGAL, PdfUtils.textToPageSize("LEGAL"));
|
||||
assertThrows(IllegalArgumentException.class, () -> PdfUtils.textToPageSize("INVALID"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testHasImagesOnPage() throws IOException {
|
||||
// Mock a PDPage and its resources
|
||||
PDPage page = Mockito.mock(PDPage.class);
|
||||
PDResources resources = Mockito.mock(PDResources.class);
|
||||
Mockito.when(page.getResources()).thenReturn(resources);
|
||||
void testGetAllImages() throws Exception {
|
||||
// Root resources
|
||||
PDResources root = mock(PDResources.class);
|
||||
|
||||
// Case 1: No images in resources
|
||||
Mockito.when(resources.getXObjectNames()).thenReturn(Collections.emptySet());
|
||||
assertFalse(PdfUtils.hasImagesOnPage(page));
|
||||
COSName im1 = COSName.getPDFName("Im1");
|
||||
COSName form1 = COSName.getPDFName("Form1");
|
||||
COSName other1 = COSName.getPDFName("Other1");
|
||||
when(root.getXObjectNames()).thenReturn(Arrays.asList(im1, form1, other1));
|
||||
|
||||
// Case 2: Resources with an image
|
||||
Set<COSName> xObjectNames = new HashSet<>();
|
||||
COSName cosName = Mockito.mock(COSName.class);
|
||||
xObjectNames.add(cosName);
|
||||
// Direct image at root
|
||||
PDImageXObject imgXObj1 = mock(PDImageXObject.class);
|
||||
BufferedImage img1 = new BufferedImage(2, 2, BufferedImage.TYPE_INT_ARGB);
|
||||
when(imgXObj1.getImage()).thenReturn(img1);
|
||||
when(root.getXObject(im1)).thenReturn(imgXObj1);
|
||||
|
||||
PDImageXObject imageXObject = Mockito.mock(PDImageXObject.class);
|
||||
Mockito.when(resources.getXObjectNames()).thenReturn(xObjectNames);
|
||||
Mockito.when(resources.getXObject(cosName)).thenReturn(imageXObject);
|
||||
// "Other" XObject that should be ignored
|
||||
PDXObject otherXObj = mock(PDXObject.class);
|
||||
when(root.getXObject(other1)).thenReturn(otherXObj);
|
||||
|
||||
assertTrue(PdfUtils.hasImagesOnPage(page));
|
||||
// Form XObject with its own resources
|
||||
PDFormXObject formXObj = mock(PDFormXObject.class);
|
||||
PDResources formRes = mock(PDResources.class);
|
||||
when(formXObj.getResources()).thenReturn(formRes);
|
||||
when(root.getXObject(form1)).thenReturn(formXObj);
|
||||
|
||||
// Inside the form: one image and a nested form
|
||||
COSName im2 = COSName.getPDFName("Im2");
|
||||
COSName nestedForm = COSName.getPDFName("NestedForm");
|
||||
when(formRes.getXObjectNames()).thenReturn(Arrays.asList(im2, nestedForm));
|
||||
|
||||
PDImageXObject imgXObj2 = mock(PDImageXObject.class);
|
||||
BufferedImage img2 = new BufferedImage(3, 3, BufferedImage.TYPE_INT_RGB);
|
||||
when(imgXObj2.getImage()).thenReturn(img2);
|
||||
when(formRes.getXObject(im2)).thenReturn(imgXObj2);
|
||||
|
||||
PDFormXObject nestedFormXObj = mock(PDFormXObject.class);
|
||||
PDResources nestedRes = mock(PDResources.class);
|
||||
when(nestedFormXObj.getResources()).thenReturn(nestedRes);
|
||||
when(formRes.getXObject(nestedForm)).thenReturn(nestedFormXObj);
|
||||
|
||||
// Deep nest: another image
|
||||
COSName im3 = COSName.getPDFName("Im3");
|
||||
when(nestedRes.getXObjectNames()).thenReturn(List.of(im3));
|
||||
|
||||
PDImageXObject imgXObj3 = mock(PDImageXObject.class);
|
||||
BufferedImage img3 = new BufferedImage(1, 1, BufferedImage.TYPE_INT_RGB);
|
||||
when(imgXObj3.getImage()).thenReturn(img3);
|
||||
when(nestedRes.getXObject(im3)).thenReturn(imgXObj3);
|
||||
|
||||
// Act
|
||||
List<RenderedImage> result = PdfUtils.getAllImages(root);
|
||||
|
||||
// Assert
|
||||
assertEquals(
|
||||
3, result.size(), "It should find exactly 3 images (root + form + nested form).");
|
||||
assertTrue(
|
||||
result.containsAll(List.of(img1, img2, img3)),
|
||||
"All expected images must be present.");
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -107,7 +170,7 @@ public class PdfUtilsTest {
|
||||
g.fillRect(0, 0, 10, 10);
|
||||
g.dispose();
|
||||
ByteArrayOutputStream imgOut = new ByteArrayOutputStream();
|
||||
javax.imageio.ImageIO.write(image, "png", imgOut);
|
||||
ImageIO.write(image, "png", imgOut);
|
||||
|
||||
PdfMetadataService meta =
|
||||
new PdfMetadataService(new ApplicationProperties(), "label", false, null);
|
||||
@@ -120,4 +183,554 @@ public class PdfUtilsTest {
|
||||
assertEquals(1, resultDoc.getNumberOfPages());
|
||||
}
|
||||
}
|
||||
|
||||
// ===============================================================
|
||||
// Additional tests (added without modifying existing ones)
|
||||
// ===============================================================
|
||||
|
||||
/* Helper: create a colored test image */
|
||||
private static BufferedImage createImage(int w, int h, Color color) {
|
||||
BufferedImage img = new BufferedImage(w, h, BufferedImage.TYPE_INT_RGB);
|
||||
Graphics2D g = img.createGraphics();
|
||||
g.setColor(color);
|
||||
g.fillRect(0, 0, w, h);
|
||||
g.dispose();
|
||||
return img;
|
||||
}
|
||||
|
||||
/* Helper: create a factory like in existing tests */
|
||||
private static CustomPDFDocumentFactory factory() {
|
||||
PdfMetadataService meta =
|
||||
new PdfMetadataService(new ApplicationProperties(), "label", false, null);
|
||||
return new CustomPDFDocumentFactory(meta);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("convertPdfToPdfImage: creates image-PDF with same page count")
|
||||
void convertPdfToPdfImage_shouldCreateImagePdfWithSamePageCount() throws IOException {
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
PDPage p1 = new PDPage(PDRectangle.A4);
|
||||
doc.addPage(p1);
|
||||
try (PDPageContentStream cs =
|
||||
new PDPageContentStream(doc, p1, AppendMode.APPEND, true, true)) {
|
||||
cs.beginText();
|
||||
cs.setFont(new PDType1Font(Standard14Fonts.FontName.HELVETICA), 12);
|
||||
cs.newLineAtOffset(50, 750);
|
||||
cs.showText("Hello PDF");
|
||||
cs.endText();
|
||||
}
|
||||
PDPage p2 = new PDPage(PDRectangle.A4);
|
||||
doc.addPage(p2);
|
||||
|
||||
PDDocument out = PdfUtils.convertPdfToPdfImage(doc);
|
||||
assertNotNull(out);
|
||||
assertEquals(2, out.getNumberOfPages(), "Page count should be preserved");
|
||||
out.close();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("imageToPdf: PNG -> single-page PDF (static ImageProcessingUtils mocked)")
|
||||
void imageToPdf_shouldCreatePdfFromPng() throws Exception {
|
||||
BufferedImage img = createImage(320, 200, Color.RED);
|
||||
ByteArrayOutputStream pngOut = new ByteArrayOutputStream();
|
||||
ImageIO.write(img, "png", pngOut);
|
||||
|
||||
MockMultipartFile file =
|
||||
new MockMultipartFile("files", "test.png", "image/png", pngOut.toByteArray());
|
||||
|
||||
try (MockedStatic<ImageProcessingUtils> mocked = mockStatic(ImageProcessingUtils.class)) {
|
||||
// Assume: loadImageWithExifOrientation/convertColorType exist – static mock
|
||||
mocked.when(() -> ImageProcessingUtils.loadImageWithExifOrientation(any()))
|
||||
.thenReturn(img);
|
||||
mocked.when(
|
||||
() ->
|
||||
ImageProcessingUtils.convertColorType(
|
||||
any(BufferedImage.class), anyString()))
|
||||
.thenAnswer(inv -> inv.getArgument(0, BufferedImage.class));
|
||||
|
||||
byte[] pdfBytes =
|
||||
PdfUtils.imageToPdf(
|
||||
new MockMultipartFile[] {file},
|
||||
"maintainAspectRatio",
|
||||
true,
|
||||
"RGB",
|
||||
factory());
|
||||
|
||||
try (PDDocument result = factory().load(pdfBytes)) {
|
||||
assertEquals(1, result.getNumberOfPages());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("imageToPdf: JPEG -> single-page PDF (JPEGFactory path)")
|
||||
void imageToPdf_shouldCreatePdfFromJpeg_UsingJpegFactory() throws Exception {
|
||||
BufferedImage img = createImage(640, 360, Color.BLUE);
|
||||
ByteArrayOutputStream jpgOut = new ByteArrayOutputStream();
|
||||
ImageIO.write(img, "jpg", jpgOut);
|
||||
|
||||
MockMultipartFile file =
|
||||
new MockMultipartFile("files", "photo.jpg", "image/jpeg", jpgOut.toByteArray());
|
||||
|
||||
try (MockedStatic<ImageProcessingUtils> mocked = mockStatic(ImageProcessingUtils.class)) {
|
||||
mocked.when(() -> ImageProcessingUtils.loadImageWithExifOrientation(any()))
|
||||
.thenReturn(img);
|
||||
mocked.when(
|
||||
() ->
|
||||
ImageProcessingUtils.convertColorType(
|
||||
any(BufferedImage.class), anyString()))
|
||||
.thenAnswer(inv -> inv.getArgument(0, BufferedImage.class));
|
||||
|
||||
byte[] pdfBytes =
|
||||
PdfUtils.imageToPdf(
|
||||
new MockMultipartFile[] {file}, "fillPage", false, "RGB", factory());
|
||||
|
||||
try (PDDocument result = factory().load(pdfBytes)) {
|
||||
assertEquals(1, result.getNumberOfPages());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("addImageToDocument: fitDocumentToImage -> page size = image size")
|
||||
void addImageToDocument_shouldUseImageSizeForPage_whenFitDocumentToImage() throws IOException {
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
BufferedImage img = createImage(300, 500, Color.GREEN);
|
||||
PDImageXObject ximg = LosslessFactory.createFromImage(doc, img);
|
||||
|
||||
PdfUtils.addImageToDocument(doc, ximg, "fitDocumentToImage", false);
|
||||
|
||||
assertEquals(1, doc.getNumberOfPages());
|
||||
PDRectangle box = doc.getPage(0).getMediaBox();
|
||||
assertEquals(300, (int) box.getWidth());
|
||||
assertEquals(500, (int) box.getHeight());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("addImageToDocument: autoRotate rotates A4 for landscape image")
|
||||
void addImageToDocument_shouldRotateA4_whenAutoRotateAndLandscape() throws IOException {
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
BufferedImage img = createImage(800, 400, Color.ORANGE); // Landscape
|
||||
PDImageXObject ximg = LosslessFactory.createFromImage(doc, img);
|
||||
|
||||
PdfUtils.addImageToDocument(doc, ximg, "maintainAspectRatio", true);
|
||||
|
||||
assertEquals(1, doc.getNumberOfPages());
|
||||
PDRectangle box = doc.getPage(0).getMediaBox();
|
||||
assertTrue(
|
||||
box.getWidth() > box.getHeight(),
|
||||
"A4 should be landscape when auto-rotate + landscape");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("addImageToDocument: fillPage runs without errors")
|
||||
void addImageToDocument_fillPage_executes() throws IOException {
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
BufferedImage img = createImage(200, 200, Color.MAGENTA);
|
||||
PDImageXObject ximg = LosslessFactory.createFromImage(doc, img);
|
||||
|
||||
PdfUtils.addImageToDocument(doc, ximg, "fillPage", false);
|
||||
|
||||
assertEquals(1, doc.getNumberOfPages());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("overlayImage: everyPage=true overlays all pages")
|
||||
void overlayImage_shouldOverlayAllPages_whenEveryPageTrue() throws IOException {
|
||||
CustomPDFDocumentFactory factory = factory();
|
||||
|
||||
// Create PDF with 2 pages
|
||||
byte[] basePdf;
|
||||
try (PDDocument doc = factory.createNewDocument()) {
|
||||
doc.addPage(new PDPage(PDRectangle.A4));
|
||||
doc.addPage(new PDPage(PDRectangle.A4));
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
doc.save(baos);
|
||||
basePdf = baos.toByteArray();
|
||||
}
|
||||
|
||||
// Create image bytes
|
||||
BufferedImage img = createImage(50, 50, Color.BLACK);
|
||||
ByteArrayOutputStream pngOut = new ByteArrayOutputStream();
|
||||
ImageIO.write(img, "png", pngOut);
|
||||
|
||||
byte[] result = PdfUtils.overlayImage(factory, basePdf, pngOut.toByteArray(), 10, 10, true);
|
||||
|
||||
try (PDDocument out = factory.load(result)) {
|
||||
assertEquals(2, out.getNumberOfPages(), "Page count remains identical");
|
||||
}
|
||||
}
|
||||
|
||||
/* Helper function: document with text on page1/page2 */
|
||||
private static PDDocument createDocWithText(String p1, String p2) throws IOException {
|
||||
PDDocument doc = new PDDocument();
|
||||
|
||||
PDPage page1 = new PDPage(PDRectangle.A4);
|
||||
doc.addPage(page1);
|
||||
try (PDPageContentStream cs =
|
||||
new PDPageContentStream(doc, page1, AppendMode.APPEND, true, true)) {
|
||||
cs.beginText();
|
||||
cs.setFont(new PDType1Font(Standard14Fonts.FontName.HELVETICA), 12);
|
||||
cs.newLineAtOffset(50, 750);
|
||||
cs.showText(p1);
|
||||
cs.endText();
|
||||
}
|
||||
|
||||
PDPage page2 = new PDPage(PDRectangle.A4);
|
||||
doc.addPage(page2);
|
||||
try (PDPageContentStream cs =
|
||||
new PDPageContentStream(doc, page2, AppendMode.APPEND, true, true)) {
|
||||
cs.beginText();
|
||||
cs.setFont(new PDType1Font(Standard14Fonts.FontName.HELVETICA), 12);
|
||||
cs.newLineAtOffset(50, 750);
|
||||
cs.showText(p2);
|
||||
cs.endText();
|
||||
}
|
||||
|
||||
return doc;
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("containsTextInFile: pagesToCheck='all' finds text")
|
||||
void containsTextInFile_allPages_true() throws IOException {
|
||||
try (PDDocument doc = createDocWithText("alpha", "beta")) {
|
||||
assertTrue(PdfUtils.containsTextInFile(doc, "beta", "all"));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("containsTextInFile: single page '2' finds text")
|
||||
void containsTextInFile_singlePage_two_true() throws IOException {
|
||||
try (PDDocument doc = createDocWithText("alpha", "beta")) {
|
||||
assertTrue(PdfUtils.containsTextInFile(doc, "beta", "2"));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("containsTextInFile: range '1-1' finds text on page 1")
|
||||
void containsTextInFile_range_oneToOne_true() throws IOException {
|
||||
try (PDDocument doc = createDocWithText("findme", "other")) {
|
||||
assertTrue(PdfUtils.containsTextInFile(doc, "findme", "1-1"));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("containsTextInFile: list '1,2' finds text (whitespace robust)")
|
||||
void containsTextInFile_list_pages_true() throws IOException {
|
||||
try (PDDocument doc = createDocWithText("foo", "bar")) {
|
||||
assertTrue(PdfUtils.containsTextInFile(doc, "bar", " 1 , 2 "));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("containsTextInFile: text not present -> false")
|
||||
void containsTextInFile_textNotPresent_false() throws IOException {
|
||||
try (PDDocument doc = createDocWithText("xxx", "yyy")) {
|
||||
assertFalse(PdfUtils.containsTextInFile(doc, "zzz", "all"));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("pageSize: different size returns false")
|
||||
void pageSize_shouldReturnFalse_whenSizeDoesNotMatch() throws IOException {
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
doc.addPage(new PDPage(PDRectangle.A4));
|
||||
assertFalse(PdfUtils.pageSize(doc, "600x842"));
|
||||
}
|
||||
}
|
||||
|
||||
// ===================== New: convertFromPdf – coverage =====================
|
||||
|
||||
@Test
|
||||
@DisplayName("convertFromPdf: singleImage=true creates combined PNG file (readable)")
|
||||
void convertFromPdf_singleImagePng_combinedReadable() throws Exception {
|
||||
// Create two-page PDF
|
||||
byte[] pdfBytes;
|
||||
PdfMetadataService meta =
|
||||
new PdfMetadataService(new ApplicationProperties(), "label", false, null);
|
||||
CustomPDFDocumentFactory factory = new CustomPDFDocumentFactory(meta);
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
doc.addPage(new PDPage(PDRectangle.A4));
|
||||
doc.addPage(new PDPage(PDRectangle.A4));
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
doc.save(baos);
|
||||
pdfBytes = baos.toByteArray();
|
||||
}
|
||||
|
||||
byte[] imageBytes =
|
||||
PdfUtils.convertFromPdf(
|
||||
factory, pdfBytes, "png", ImageType.RGB, true, 72, "test.pdf", false);
|
||||
|
||||
// Should be readable as a single combined PNG image
|
||||
BufferedImage img = ImageIO.read(new java.io.ByteArrayInputStream(imageBytes));
|
||||
assertNotNull(img, "PNG should be readable");
|
||||
assertTrue(img.getWidth() > 0 && img.getHeight() > 0, "Image dimensions > 0");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName(
|
||||
"convertFromPdf: singleImage=false returns ZIP with PNG entries (first image readable)")
|
||||
void convertFromPdf_multiImagePng_firstReadable() throws Exception {
|
||||
// Create two-page PDF
|
||||
byte[] pdfBytes;
|
||||
PdfMetadataService meta =
|
||||
new PdfMetadataService(new ApplicationProperties(), "label", false, null);
|
||||
CustomPDFDocumentFactory factory = new CustomPDFDocumentFactory(meta);
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
doc.addPage(new PDPage(PDRectangle.A4));
|
||||
doc.addPage(new PDPage(PDRectangle.A4));
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
doc.save(baos);
|
||||
pdfBytes = baos.toByteArray();
|
||||
}
|
||||
|
||||
// Act: singleImage=false -> ZIP with separate images
|
||||
byte[] zipBytes =
|
||||
PdfUtils.convertFromPdf(
|
||||
factory, pdfBytes, "png", ImageType.RGB, false, 72, "test.pdf", false);
|
||||
|
||||
// Assert: open ZIP, read first entry as PNG
|
||||
try (ZipInputStream zis = new ZipInputStream(new ByteArrayInputStream(zipBytes))) {
|
||||
ZipEntry entry = zis.getNextEntry();
|
||||
assertNotNull(entry, "ZIP should contain at least one entry");
|
||||
|
||||
ByteArrayOutputStream imgOut = new ByteArrayOutputStream();
|
||||
zis.transferTo(imgOut);
|
||||
BufferedImage first = ImageIO.read(new ByteArrayInputStream(imgOut.toByteArray()));
|
||||
|
||||
assertNotNull(first, "First PNG entry should be readable");
|
||||
assertTrue(first.getWidth() > 0 && first.getHeight() > 0, "Image dimensions > 0");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("hasText: detects phrase on selected pages ('1', '2', 'all')")
|
||||
void hasText_shouldDetectPhrase_onSelectedPages() throws Exception {
|
||||
// Arrange: PDF with 2 pages and text
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
PDPage p1 = new PDPage(PDRectangle.A4);
|
||||
PDPage p2 = new PDPage(PDRectangle.A4);
|
||||
doc.addPage(p1);
|
||||
doc.addPage(p2);
|
||||
|
||||
try (PDPageContentStream cs =
|
||||
new PDPageContentStream(doc, p1, AppendMode.APPEND, true, true)) {
|
||||
cs.beginText();
|
||||
cs.setFont(new PDType1Font(Standard14Fonts.FontName.HELVETICA), 12);
|
||||
cs.newLineAtOffset(50, 750);
|
||||
cs.showText("alpha on page 1");
|
||||
cs.endText();
|
||||
}
|
||||
try (PDPageContentStream cs =
|
||||
new PDPageContentStream(doc, p2, AppendMode.APPEND, true, true)) {
|
||||
cs.beginText();
|
||||
cs.setFont(new PDType1Font(Standard14Fonts.FontName.HELVETICA), 12);
|
||||
cs.newLineAtOffset(50, 750);
|
||||
cs.showText("beta on page 2");
|
||||
cs.endText();
|
||||
}
|
||||
|
||||
assertTrue(PdfUtils.hasText(doc, "1", "alpha"), "Page 1 should contain 'alpha'");
|
||||
}
|
||||
|
||||
// For further checks, create new doc with identical content
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
PDPage p1 = new PDPage(PDRectangle.A4);
|
||||
PDPage p2 = new PDPage(PDRectangle.A4);
|
||||
doc.addPage(p1);
|
||||
doc.addPage(p2);
|
||||
|
||||
try (PDPageContentStream cs =
|
||||
new PDPageContentStream(doc, p1, AppendMode.APPEND, true, true)) {
|
||||
cs.beginText();
|
||||
cs.setFont(new PDType1Font(Standard14Fonts.FontName.HELVETICA), 12);
|
||||
cs.newLineAtOffset(50, 750);
|
||||
cs.showText("alpha on page 1");
|
||||
cs.endText();
|
||||
}
|
||||
try (PDPageContentStream cs =
|
||||
new PDPageContentStream(doc, p2, AppendMode.APPEND, true, true)) {
|
||||
cs.beginText();
|
||||
cs.setFont(new PDType1Font(Standard14Fonts.FontName.HELVETICA), 12);
|
||||
cs.newLineAtOffset(50, 750);
|
||||
cs.showText("beta on page 2");
|
||||
cs.endText();
|
||||
}
|
||||
|
||||
assertTrue(PdfUtils.hasText(doc, "2", "beta"), "Page 2 should contain 'beta'");
|
||||
}
|
||||
|
||||
// Third doc for 'all'
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
PDPage p1 = new PDPage(PDRectangle.A4);
|
||||
PDPage p2 = new PDPage(PDRectangle.A4);
|
||||
doc.addPage(p1);
|
||||
doc.addPage(p2);
|
||||
|
||||
try (PDPageContentStream cs =
|
||||
new PDPageContentStream(doc, p1, AppendMode.APPEND, true, true)) {
|
||||
cs.beginText();
|
||||
cs.setFont(new PDType1Font(Standard14Fonts.FontName.HELVETICA), 12);
|
||||
cs.newLineAtOffset(50, 750);
|
||||
cs.showText("gamma");
|
||||
cs.endText();
|
||||
}
|
||||
assertTrue(PdfUtils.hasText(doc, "all", "gamma"), "'all' should find text on page 1");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("hasTextOnPage: true if page contains phrase, else false")
|
||||
void hasTextOnPage_shouldReturnTrueOnlyForPagesWithPhrase() throws Exception {
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
PDPage p1 = new PDPage(PDRectangle.A4);
|
||||
PDPage p2 = new PDPage(PDRectangle.A4);
|
||||
doc.addPage(p1);
|
||||
doc.addPage(p2);
|
||||
|
||||
try (PDPageContentStream cs =
|
||||
new PDPageContentStream(doc, p1, AppendMode.APPEND, true, true)) {
|
||||
cs.beginText();
|
||||
cs.setFont(new PDType1Font(Standard14Fonts.FontName.HELVETICA), 12);
|
||||
cs.newLineAtOffset(50, 750);
|
||||
cs.showText("needle");
|
||||
cs.endText();
|
||||
}
|
||||
|
||||
assertTrue(PdfUtils.hasTextOnPage(p1, "needle"));
|
||||
assertTrue(!PdfUtils.hasTextOnPage(p2, "needle"));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("hasImages: detects images on selected pages and 'all'")
|
||||
void hasImages_shouldDetectImages_onSelectedPages() throws Exception {
|
||||
// Case 1: Page 1 without image (but resources set) -> false
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
PDPage p1 = new PDPage(PDRectangle.A4);
|
||||
PDPage p2 = new PDPage(PDRectangle.A4);
|
||||
p1.setResources(new PDResources());
|
||||
p2.setResources(new PDResources());
|
||||
doc.addPage(p1);
|
||||
doc.addPage(p2);
|
||||
|
||||
// Image only on page 2
|
||||
BufferedImage bi = new BufferedImage(20, 20, BufferedImage.TYPE_INT_RGB);
|
||||
Graphics2D g = bi.createGraphics();
|
||||
g.setColor(Color.GREEN);
|
||||
g.fillRect(0, 0, 20, 20);
|
||||
g.dispose();
|
||||
|
||||
PDImageXObject ximg = LosslessFactory.createFromImage(doc, bi);
|
||||
try (PDPageContentStream cs =
|
||||
new PDPageContentStream(doc, p2, AppendMode.APPEND, true, true)) {
|
||||
cs.drawImage(ximg, 50, 700, 20, 20);
|
||||
}
|
||||
|
||||
assertTrue(!PdfUtils.hasImages(doc, "1"), "Page 1 should have no image");
|
||||
}
|
||||
|
||||
// Case 2: Page 2 with image -> true
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
PDPage p1 = new PDPage(PDRectangle.A4);
|
||||
PDPage p2 = new PDPage(PDRectangle.A4);
|
||||
p1.setResources(new PDResources());
|
||||
p2.setResources(new PDResources());
|
||||
doc.addPage(p1);
|
||||
doc.addPage(p2);
|
||||
|
||||
BufferedImage bi = new BufferedImage(20, 20, BufferedImage.TYPE_INT_RGB);
|
||||
Graphics2D g = bi.createGraphics();
|
||||
g.setColor(Color.BLUE);
|
||||
g.fillRect(0, 0, 20, 20);
|
||||
g.dispose();
|
||||
|
||||
PDImageXObject ximg = LosslessFactory.createFromImage(doc, bi);
|
||||
try (PDPageContentStream cs =
|
||||
new PDPageContentStream(doc, p2, AppendMode.APPEND, true, true)) {
|
||||
cs.drawImage(ximg, 50, 700, 20, 20);
|
||||
}
|
||||
|
||||
assertTrue(PdfUtils.hasImages(doc, "2"), "Page 2 should have an image");
|
||||
}
|
||||
|
||||
// Case 3: 'all' detects image
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
PDPage p = new PDPage(PDRectangle.A4);
|
||||
p.setResources(new PDResources());
|
||||
doc.addPage(p);
|
||||
|
||||
BufferedImage bi = new BufferedImage(10, 10, BufferedImage.TYPE_INT_RGB);
|
||||
PDImageXObject ximg = LosslessFactory.createFromImage(doc, bi);
|
||||
try (PDPageContentStream cs =
|
||||
new PDPageContentStream(doc, p, AppendMode.APPEND, true, true)) {
|
||||
cs.drawImage(ximg, 20, 730, 10, 10);
|
||||
}
|
||||
|
||||
assertTrue(PdfUtils.hasImages(doc, "all"), "'all' should detect the image");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("hasImagesOnPage: true if page contains an image, else false")
|
||||
void hasImagesOnPage_shouldReturnTrueOnlyForPagesWithImage() throws Exception {
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
PDPage p1 = new PDPage(PDRectangle.A4);
|
||||
PDPage p2 = new PDPage(PDRectangle.A4);
|
||||
p1.setResources(new PDResources());
|
||||
p2.setResources(new PDResources());
|
||||
doc.addPage(p1);
|
||||
doc.addPage(p2);
|
||||
|
||||
BufferedImage bi = new BufferedImage(12, 12, BufferedImage.TYPE_INT_RGB);
|
||||
Graphics2D g = bi.createGraphics();
|
||||
g.setColor(Color.RED);
|
||||
g.fillRect(0, 0, 12, 12);
|
||||
g.dispose();
|
||||
|
||||
PDImageXObject ximg = LosslessFactory.createFromImage(doc, bi);
|
||||
try (PDPageContentStream cs =
|
||||
new PDPageContentStream(doc, p1, AppendMode.APPEND, true, true)) {
|
||||
cs.drawImage(ximg, 40, 720, 12, 12);
|
||||
}
|
||||
|
||||
assertTrue(PdfUtils.hasImagesOnPage(p1));
|
||||
assertTrue(!PdfUtils.hasImagesOnPage(p2));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("convertFromPdf: singleImage=true with JPG -> no alpha, white background")
|
||||
void convertFromPdf_singleImageJpg_noAlphaWhiteBackground() throws Exception {
|
||||
// small 1-page PDF
|
||||
byte[] pdfBytes;
|
||||
PdfMetadataService meta =
|
||||
new PdfMetadataService(new ApplicationProperties(), "label", false, null);
|
||||
CustomPDFDocumentFactory factory = new CustomPDFDocumentFactory(meta);
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
PDPage p = new PDPage(PDRectangle.A4);
|
||||
doc.addPage(p);
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
doc.save(baos);
|
||||
pdfBytes = baos.toByteArray();
|
||||
}
|
||||
|
||||
byte[] jpgBytes =
|
||||
PdfUtils.convertFromPdf(
|
||||
factory, pdfBytes, "jpg", ImageType.RGB, true, 72, "sample.pdf", false);
|
||||
|
||||
BufferedImage img = ImageIO.read(new ByteArrayInputStream(jpgBytes));
|
||||
assertNotNull(img, "JPG should be readable");
|
||||
|
||||
ColorModel cm = img.getColorModel();
|
||||
assertFalse(cm.hasAlpha(), "JPG output should have no alpha channel");
|
||||
|
||||
// JPG background should be white (approximate check)
|
||||
int rgb = img.getRGB(img.getWidth() / 2, img.getHeight() / 2) & 0x00FFFFFF;
|
||||
assertEquals(0xFFFFFF, rgb, "Background pixel should be white");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
package stirling.software.common.util;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.ArrayList;
|
||||
@@ -48,9 +45,7 @@ public class ProcessExecutorTest {
|
||||
IOException thrown =
|
||||
assertThrows(
|
||||
IOException.class,
|
||||
() -> {
|
||||
processExecutor.runCommandWithOutputHandling(command);
|
||||
});
|
||||
() -> processExecutor.runCommandWithOutputHandling(command));
|
||||
|
||||
// Check the exception message to ensure it indicates the command was not found
|
||||
String errorMessage = thrown.getMessage();
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package stirling.software.common.util;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
@@ -22,7 +23,7 @@ public class PropertyConfigsTest {
|
||||
boolean result = PropertyConfigs.getBooleanValue(keys, defaultValue);
|
||||
|
||||
// Verify the result
|
||||
assertEquals(true, result);
|
||||
assertTrue(result);
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -51,7 +52,7 @@ public class PropertyConfigsTest {
|
||||
boolean result = PropertyConfigs.getBooleanValue(key, defaultValue);
|
||||
|
||||
// Verify the result
|
||||
assertEquals(true, result);
|
||||
assertTrue(result);
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -62,17 +62,11 @@ public class RegexPatternUtilsTest {
|
||||
|
||||
@Test
|
||||
void testNullRegexHandling() {
|
||||
assertThrows(
|
||||
IllegalArgumentException.class,
|
||||
() -> {
|
||||
utils.getPattern(null);
|
||||
});
|
||||
assertThrows(IllegalArgumentException.class, () -> utils.getPattern(null));
|
||||
|
||||
assertThrows(
|
||||
IllegalArgumentException.class,
|
||||
() -> {
|
||||
utils.getPattern(null, Pattern.CASE_INSENSITIVE);
|
||||
});
|
||||
() -> utils.getPattern(null, Pattern.CASE_INSENSITIVE));
|
||||
|
||||
assertFalse(utils.isCached(null));
|
||||
assertFalse(utils.removeFromCache(null));
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package stirling.software.common.util;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
|
||||
@@ -57,8 +57,7 @@ class SpringContextHolderTest {
|
||||
void testGetBean_BeanNotFound() {
|
||||
// Arrange
|
||||
contextHolder.setApplicationContext(mockApplicationContext);
|
||||
when(mockApplicationContext.getBean(TestBean.class))
|
||||
.thenThrow(new org.springframework.beans.BeansException("Bean not found") {});
|
||||
when(mockApplicationContext.getBean(TestBean.class)).thenThrow(new MyBeansException());
|
||||
|
||||
// Act
|
||||
TestBean result = SpringContextHolder.getBean(TestBean.class);
|
||||
@@ -69,4 +68,10 @@ class SpringContextHolderTest {
|
||||
|
||||
// Simple test class
|
||||
private static class TestBean {}
|
||||
|
||||
private static class MyBeansException extends org.springframework.beans.BeansException {
|
||||
public MyBeansException() {
|
||||
super("Bean not found");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
package stirling.software.common.util;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
|
||||
/**
|
||||
* Unit tests for {@link TempDirectory}. Assumption: TempFileManager has methods
|
||||
* createTempDirectory() and deleteTempDirectory(Path).
|
||||
*/
|
||||
class TempDirectoryTest {
|
||||
|
||||
@Test
|
||||
@DisplayName("should create temp directory and return correct path info")
|
||||
void shouldReturnCorrectPathInfo() throws IOException {
|
||||
TempFileManager manager = mock(TempFileManager.class);
|
||||
Path tempPath = Files.createTempDirectory("testDir");
|
||||
when(manager.createTempDirectory()).thenReturn(tempPath);
|
||||
|
||||
try (TempDirectory tempDir = new TempDirectory(manager)) {
|
||||
assertEquals(
|
||||
tempPath,
|
||||
tempDir.getPath(),
|
||||
"getPath should return the created directory path");
|
||||
assertEquals(
|
||||
tempPath.toAbsolutePath().toString(),
|
||||
tempDir.getAbsolutePath(),
|
||||
"getAbsolutePath should return absolute path");
|
||||
assertTrue(tempDir.exists(), "exists should return true when directory exists");
|
||||
assertTrue(
|
||||
tempDir.toString().contains(tempPath.toAbsolutePath().toString()),
|
||||
"toString should include the absolute path");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("should call deleteTempDirectory on close")
|
||||
void shouldDeleteTempDirectoryOnClose() throws IOException {
|
||||
TempFileManager manager = mock(TempFileManager.class);
|
||||
Path tempPath = Files.createTempDirectory("testDir");
|
||||
when(manager.createTempDirectory()).thenReturn(tempPath);
|
||||
|
||||
try (TempDirectory tempDir = new TempDirectory(manager)) {
|
||||
// do nothing
|
||||
}
|
||||
|
||||
ArgumentCaptor<Path> captor = ArgumentCaptor.forClass(Path.class);
|
||||
verify(manager, times(1)).deleteTempDirectory(captor.capture());
|
||||
assertEquals(
|
||||
tempPath,
|
||||
captor.getValue(),
|
||||
"deleteTempDirectory should be called with the created path");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("should handle multiple close calls without exception")
|
||||
void shouldHandleMultipleCloseCalls() throws IOException {
|
||||
TempFileManager manager = mock(TempFileManager.class);
|
||||
Path tempPath = Files.createTempDirectory("testDir");
|
||||
when(manager.createTempDirectory()).thenReturn(tempPath);
|
||||
|
||||
TempDirectory tempDir = new TempDirectory(manager);
|
||||
tempDir.close();
|
||||
assertDoesNotThrow(tempDir::close, "Second close should not throw exception");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("should return false for exists if directory does not exist")
|
||||
void shouldReturnFalseIfDirectoryDoesNotExist() throws IOException {
|
||||
TempFileManager manager = mock(TempFileManager.class);
|
||||
Path tempPath = Files.createTempDirectory("testDir");
|
||||
Files.delete(tempPath); // delete immediately
|
||||
when(manager.createTempDirectory()).thenReturn(tempPath);
|
||||
|
||||
try (TempDirectory tempDir = new TempDirectory(manager)) {
|
||||
assertFalse(tempDir.exists(), "exists should return false when directory is missing");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("should throw IOException if createTempDirectory fails")
|
||||
void shouldThrowIfCreateTempDirectoryFails() throws IOException {
|
||||
TempFileManager manager = mock(TempFileManager.class);
|
||||
when(manager.createTempDirectory()).thenThrow(new IOException("Disk full"));
|
||||
|
||||
IOException ex = assertThrows(IOException.class, () -> new TempDirectory(manager));
|
||||
assertEquals("Disk full", ex.getMessage(), "Exception message should be propagated");
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,6 @@
|
||||
package stirling.software.common.util;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.anyInt;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.mockStatic;
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
package stirling.software.common.util;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
package stirling.software.common.util;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.fail;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
|
||||
+2
-3
@@ -1,7 +1,6 @@
|
||||
package stirling.software.common.util.misc;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.lang.reflect.Method;
|
||||
@@ -106,7 +105,7 @@ class CustomColorReplaceStrategyTest {
|
||||
|
||||
} catch (Exception e) {
|
||||
// If we get here, the test failed
|
||||
org.junit.jupiter.api.Assertions.fail("Exception occurred: " + e.getMessage());
|
||||
fail("Exception occurred: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+1
-2
@@ -1,7 +1,6 @@
|
||||
package stirling.software.common.util.misc;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
|
||||
+14
-17
@@ -1,8 +1,6 @@
|
||||
package stirling.software.common.util.misc;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
import java.awt.Color;
|
||||
import java.awt.image.BufferedImage;
|
||||
@@ -33,22 +31,9 @@ import stirling.software.common.model.api.misc.ReplaceAndInvert;
|
||||
class InvertFullColorStrategyTest {
|
||||
|
||||
private InvertFullColorStrategy strategy;
|
||||
private MultipartFile mockPdfFile;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() throws Exception {
|
||||
// Create a simple PDF document for testing
|
||||
byte[] pdfBytes = createSimplePdfWithRectangle();
|
||||
mockPdfFile =
|
||||
new MockMultipartFile(
|
||||
"file", "test.pdf", MediaType.APPLICATION_PDF_VALUE, pdfBytes);
|
||||
|
||||
// Create the strategy instance
|
||||
strategy = new InvertFullColorStrategy(mockPdfFile, ReplaceAndInvert.FULL_INVERSION);
|
||||
}
|
||||
|
||||
/** Helper method to create a simple PDF with a colored rectangle for testing */
|
||||
private byte[] createSimplePdfWithRectangle() throws IOException {
|
||||
private static byte[] createSimplePdfWithRectangle() throws IOException {
|
||||
PDDocument document = new PDDocument();
|
||||
PDPage page = new PDPage(PDRectangle.A4);
|
||||
document.addPage(page);
|
||||
@@ -68,6 +53,18 @@ class InvertFullColorStrategyTest {
|
||||
return baos.toByteArray();
|
||||
}
|
||||
|
||||
@BeforeEach
|
||||
void setUp() throws Exception {
|
||||
// Create a simple PDF document for testing
|
||||
byte[] pdfBytes = createSimplePdfWithRectangle();
|
||||
MultipartFile mockPdfFile =
|
||||
new MockMultipartFile(
|
||||
"file", "test.pdf", MediaType.APPLICATION_PDF_VALUE, pdfBytes);
|
||||
|
||||
// Create the strategy instance
|
||||
strategy = new InvertFullColorStrategy(mockPdfFile, ReplaceAndInvert.FULL_INVERSION);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testReplace() throws IOException {
|
||||
// Test the replace method
|
||||
|
||||
+4
-6
@@ -1,7 +1,6 @@
|
||||
package stirling.software.common.util.misc;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
@@ -16,7 +15,6 @@ class PdfTextStripperCustomTest {
|
||||
|
||||
private PdfTextStripperCustom stripper;
|
||||
private PDPage mockPage;
|
||||
private PDRectangle mockMediaBox;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() throws IOException {
|
||||
@@ -25,7 +23,7 @@ class PdfTextStripperCustomTest {
|
||||
|
||||
// Create mock objects
|
||||
mockPage = mock(PDPage.class);
|
||||
mockMediaBox = mock(PDRectangle.class);
|
||||
PDRectangle mockMediaBox = mock(PDRectangle.class);
|
||||
|
||||
// Configure mock behavior
|
||||
when(mockPage.getMediaBox()).thenReturn(mockMediaBox);
|
||||
@@ -43,14 +41,14 @@ class PdfTextStripperCustomTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void testBasicFunctionality() throws IOException {
|
||||
void testBasicFunctionality() {
|
||||
// Simply test that the method runs without exceptions
|
||||
try {
|
||||
stripper.addRegion("testRegion", new java.awt.geom.Rectangle2D.Float(0, 0, 100, 100));
|
||||
stripper.extractRegions(mockPage);
|
||||
assertTrue(true, "Should execute without errors");
|
||||
} catch (Exception e) {
|
||||
assertTrue(false, "Method should not throw exception: " + e.getMessage());
|
||||
fail("Method should not throw exception: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+1
-2
@@ -1,7 +1,6 @@
|
||||
package stirling.software.common.util.misc;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
|
||||
+6
-9
@@ -1,9 +1,6 @@
|
||||
package stirling.software.common.util.propertyeditor;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@@ -33,7 +30,7 @@ class StringToArrayListPropertyEditorTest {
|
||||
|
||||
// Assert
|
||||
assertNotNull(value, "Value should not be null");
|
||||
assertTrue(value instanceof List, "Value should be a List");
|
||||
assertInstanceOf(List.class, value, "Value should be a List");
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
List<RedactionArea> list = (List<RedactionArea>) value;
|
||||
@@ -63,7 +60,7 @@ class StringToArrayListPropertyEditorTest {
|
||||
|
||||
// Assert
|
||||
assertNotNull(value, "Value should not be null");
|
||||
assertTrue(value instanceof List, "Value should be a List");
|
||||
assertInstanceOf(List.class, value, "Value should be a List");
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
List<RedactionArea> list = (List<RedactionArea>) value;
|
||||
@@ -91,7 +88,7 @@ class StringToArrayListPropertyEditorTest {
|
||||
|
||||
// Assert
|
||||
assertNotNull(value, "Value should not be null");
|
||||
assertTrue(value instanceof List, "Value should be a List");
|
||||
assertInstanceOf(List.class, value, "Value should be a List");
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
List<RedactionArea> list = (List<RedactionArea>) value;
|
||||
@@ -106,7 +103,7 @@ class StringToArrayListPropertyEditorTest {
|
||||
|
||||
// Assert
|
||||
assertNotNull(value, "Value should not be null");
|
||||
assertTrue(value instanceof List, "Value should be a List");
|
||||
assertInstanceOf(List.class, value, "Value should be a List");
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
List<RedactionArea> list = (List<RedactionArea>) value;
|
||||
@@ -125,7 +122,7 @@ class StringToArrayListPropertyEditorTest {
|
||||
|
||||
// Assert
|
||||
assertNotNull(value, "Value should not be null");
|
||||
assertTrue(value instanceof List, "Value should be a List");
|
||||
assertInstanceOf(List.class, value, "Value should be a List");
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
List<RedactionArea> list = (List<RedactionArea>) value;
|
||||
|
||||
+5
-8
@@ -1,9 +1,6 @@
|
||||
package stirling.software.common.util.propertyeditor;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
@@ -30,7 +27,7 @@ class StringToMapPropertyEditorTest {
|
||||
|
||||
// Assert
|
||||
assertNotNull(value, "Value should not be null");
|
||||
assertTrue(value instanceof Map, "Value should be a Map");
|
||||
assertInstanceOf(Map.class, value, "Value should be a Map");
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
Map<String, String> map = (Map<String, String>) value;
|
||||
@@ -50,7 +47,7 @@ class StringToMapPropertyEditorTest {
|
||||
|
||||
// Assert
|
||||
assertNotNull(value, "Value should not be null");
|
||||
assertTrue(value instanceof Map, "Value should be a Map");
|
||||
assertInstanceOf(Map.class, value, "Value should be a Map");
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
Map<String, String> map = (Map<String, String>) value;
|
||||
@@ -68,7 +65,7 @@ class StringToMapPropertyEditorTest {
|
||||
|
||||
// Assert
|
||||
assertNotNull(value, "Value should not be null");
|
||||
assertTrue(value instanceof Map, "Value should be a Map");
|
||||
assertInstanceOf(Map.class, value, "Value should be a Map");
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
Map<String, String> map = (Map<String, String>) value;
|
||||
@@ -87,7 +84,7 @@ class StringToMapPropertyEditorTest {
|
||||
|
||||
// Assert
|
||||
assertNotNull(value, "Value should not be null");
|
||||
assertTrue(value instanceof Map, "Value should be a Map");
|
||||
assertInstanceOf(Map.class, value, "Value should be a Map");
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
Map<String, String> map = (Map<String, String>) value;
|
||||
|
||||
@@ -59,11 +59,11 @@ dependencies {
|
||||
implementation project(':common')
|
||||
implementation 'org.springframework.boot:spring-boot-starter-jetty'
|
||||
implementation 'com.posthog.java:posthog:1.2.0'
|
||||
implementation 'commons-io:commons-io:2.20.0'
|
||||
implementation 'commons-io:commons-io:2.21.0'
|
||||
implementation "org.bouncycastle:bcprov-jdk18on:$bouncycastleVersion"
|
||||
implementation "org.bouncycastle:bcpkix-jdk18on:$bouncycastleVersion"
|
||||
implementation 'io.micrometer:micrometer-core:1.15.4'
|
||||
implementation 'com.google.zxing:core:3.5.3'
|
||||
implementation 'io.micrometer:micrometer-core:1.16.0'
|
||||
implementation 'com.google.zxing:core:3.5.4'
|
||||
implementation "org.commonmark:commonmark:$commonmarkVersion" // https://mvnrepository.com/artifact/org.commonmark/commonmark
|
||||
implementation "org.commonmark:commonmark-ext-gfm-tables:$commonmarkVersion"
|
||||
|
||||
|
||||
+4
@@ -28,6 +28,10 @@ public class ReplaceAndInvertColorFactory {
|
||||
String backGroundColor,
|
||||
String textColor) {
|
||||
|
||||
if (replaceAndInvertOption == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check Ghostscript availability for CMYK conversion
|
||||
if (replaceAndInvertOption == ReplaceAndInvert.COLOR_SPACE_CONVERSION
|
||||
&& !endpointConfiguration.isGroupEnabled("Ghostscript")) {
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
package stirling.software.SPDF.config;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.io.BufferedReader;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.InputStreamReader;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Duration;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.*;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
@@ -12,105 +18,66 @@ import jakarta.annotation.PostConstruct;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.SPDF.config.EndpointConfiguration.DisableReason;
|
||||
import stirling.software.common.configuration.RuntimePathConfig;
|
||||
import stirling.software.common.util.RegexPatternUtils;
|
||||
|
||||
/**
|
||||
* Dependency checker that - runs probes in parallel with timeouts (prevents hanging on broken
|
||||
* PATHs) - supports Windows+Unix in a single place - de-duplicates logic for version extraction &
|
||||
* command availability - keeps group <-> command mapping and feature formatting tidy & immutable
|
||||
*/
|
||||
@Configuration
|
||||
@Slf4j
|
||||
public class ExternalAppDepConfig {
|
||||
|
||||
private static final Duration DEFAULT_TIMEOUT = Duration.ofSeconds(5);
|
||||
private static final Pattern VERSION_PATTERN = Pattern.compile("(\\d+(?:\\.\\d+){0,2})");
|
||||
|
||||
private final EndpointConfiguration endpointConfiguration;
|
||||
private volatile boolean dependenciesChecked = false;
|
||||
|
||||
private final boolean isWindows =
|
||||
System.getProperty("os.name").toLowerCase(Locale.ROOT).contains("windows");
|
||||
|
||||
private final String weasyprintPath;
|
||||
private final String unoconvPath;
|
||||
private final String calibrePath;
|
||||
private final String ocrMyPdfPath;
|
||||
private final String sOfficePath;
|
||||
|
||||
/**
|
||||
* Map of command(binary) -> affected groups (e.g. "gs" -> ["Ghostscript"]). Immutable to avoid
|
||||
* accidental mutations.
|
||||
*/
|
||||
private final Map<String, List<String>> commandToGroupMapping;
|
||||
private volatile boolean dependenciesChecked = false;
|
||||
|
||||
private final ExecutorService pool =
|
||||
Executors.newFixedThreadPool(
|
||||
Math.max(2, Runtime.getRuntime().availableProcessors() / 2));
|
||||
|
||||
public ExternalAppDepConfig(
|
||||
EndpointConfiguration endpointConfiguration, RuntimePathConfig runtimePathConfig) {
|
||||
this.endpointConfiguration = endpointConfiguration;
|
||||
weasyprintPath = runtimePathConfig.getWeasyPrintPath();
|
||||
unoconvPath = runtimePathConfig.getUnoConvertPath();
|
||||
this.weasyprintPath = runtimePathConfig.getWeasyPrintPath();
|
||||
this.unoconvPath = runtimePathConfig.getUnoConvertPath();
|
||||
this.calibrePath = runtimePathConfig.getCalibrePath();
|
||||
this.ocrMyPdfPath = runtimePathConfig.getOcrMyPdfPath();
|
||||
this.sOfficePath = runtimePathConfig.getSOfficePath();
|
||||
|
||||
commandToGroupMapping =
|
||||
new HashMap<>() {
|
||||
|
||||
{
|
||||
put("gs", List.of("Ghostscript"));
|
||||
put("ocrmypdf", List.of("OCRmyPDF"));
|
||||
put("soffice", List.of("LibreOffice"));
|
||||
put(weasyprintPath, List.of("Weasyprint"));
|
||||
put("pdftohtml", List.of("Pdftohtml"));
|
||||
put(unoconvPath, List.of("Unoconvert"));
|
||||
put("qpdf", List.of("qpdf"));
|
||||
put("tesseract", List.of("tesseract"));
|
||||
put("rar", List.of("rar")); // Required for real CBR output
|
||||
put("magick", List.of("ImageMagick"));
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private boolean isCommandAvailable(String command) {
|
||||
try {
|
||||
ProcessBuilder processBuilder = new ProcessBuilder();
|
||||
if (System.getProperty("os.name").toLowerCase().contains("windows")) {
|
||||
processBuilder.command("where", command);
|
||||
} else {
|
||||
processBuilder.command("which", command);
|
||||
}
|
||||
Process process = processBuilder.start();
|
||||
int exitCode = process.waitFor();
|
||||
return exitCode == 0;
|
||||
} catch (Exception e) {
|
||||
log.debug("Error checking for command {}: {}", command, e.getMessage());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private List<String> getAffectedFeatures(String group) {
|
||||
return endpointConfiguration.getEndpointsForGroup(group).stream()
|
||||
.map(endpoint -> formatEndpointAsFeature(endpoint))
|
||||
.toList();
|
||||
}
|
||||
|
||||
private String formatEndpointAsFeature(String endpoint) {
|
||||
// First replace common terms
|
||||
String feature = endpoint.replace("-", " ").replace("pdf", "PDF").replace("img", "image");
|
||||
// Split into words and capitalize each word
|
||||
return Arrays.stream(RegexPatternUtils.getInstance().getWordSplitPattern().split(feature))
|
||||
.map(word -> capitalizeWord(word))
|
||||
.collect(Collectors.joining(" "));
|
||||
}
|
||||
|
||||
private String capitalizeWord(String word) {
|
||||
if (word.isEmpty()) {
|
||||
return word;
|
||||
}
|
||||
if ("pdf".equalsIgnoreCase(word)) {
|
||||
return "PDF";
|
||||
}
|
||||
return word.substring(0, 1).toUpperCase() + word.substring(1).toLowerCase();
|
||||
}
|
||||
|
||||
private void checkDependencyAndDisableGroup(String command) {
|
||||
boolean isAvailable = isCommandAvailable(command);
|
||||
if (!isAvailable) {
|
||||
List<String> affectedGroups = commandToGroupMapping.get(command);
|
||||
if (affectedGroups != null) {
|
||||
for (String group : affectedGroups) {
|
||||
List<String> affectedFeatures = getAffectedFeatures(group);
|
||||
endpointConfiguration.disableGroup(group, DisableReason.DEPENDENCY);
|
||||
log.warn(
|
||||
"Missing dependency: {} - Disabling group: {} (Affected features: {})",
|
||||
command,
|
||||
group,
|
||||
!affectedFeatures.isEmpty()
|
||||
? String.join(", ", affectedFeatures)
|
||||
: "unknown");
|
||||
}
|
||||
}
|
||||
}
|
||||
Map<String, List<String>> tmp = new HashMap<>();
|
||||
tmp.put("gs", List.of("Ghostscript"));
|
||||
tmp.put(ocrMyPdfPath, List.of("OCRmyPDF"));
|
||||
tmp.put(sOfficePath, List.of("LibreOffice"));
|
||||
tmp.put(weasyprintPath, List.of("Weasyprint"));
|
||||
tmp.put("pdftohtml", List.of("Pdftohtml"));
|
||||
tmp.put(unoconvPath, List.of("Unoconvert"));
|
||||
tmp.put("qpdf", List.of("qpdf"));
|
||||
tmp.put("tesseract", List.of("tesseract"));
|
||||
tmp.put("rar", List.of("rar")); // Required for real CBR output
|
||||
tmp.put(calibrePath, List.of("Calibre"));
|
||||
tmp.put("ffmpeg", List.of("FFmpeg"));
|
||||
tmp.put("magick", List.of("ImageMagick"));
|
||||
this.commandToGroupMapping = Collections.unmodifiableMap(tmp);
|
||||
}
|
||||
|
||||
public boolean isDependenciesChecked() {
|
||||
@@ -119,57 +86,282 @@ public class ExternalAppDepConfig {
|
||||
|
||||
@PostConstruct
|
||||
public void checkDependencies() {
|
||||
// Check core dependencies
|
||||
checkDependencyAndDisableGroup("gs");
|
||||
checkDependencyAndDisableGroup("ocrmypdf");
|
||||
checkDependencyAndDisableGroup("tesseract");
|
||||
checkDependencyAndDisableGroup("soffice");
|
||||
checkDependencyAndDisableGroup("qpdf");
|
||||
checkDependencyAndDisableGroup(weasyprintPath);
|
||||
checkDependencyAndDisableGroup("pdftohtml");
|
||||
checkDependencyAndDisableGroup(unoconvPath);
|
||||
checkDependencyAndDisableGroup("rar");
|
||||
checkDependencyAndDisableGroup("magick");
|
||||
// Special handling for Python/OpenCV dependencies
|
||||
boolean pythonAvailable = isCommandAvailable("python3") || isCommandAvailable("python");
|
||||
if (!pythonAvailable) {
|
||||
List<String> pythonFeatures = getAffectedFeatures("Python");
|
||||
List<String> openCVFeatures = getAffectedFeatures("OpenCV");
|
||||
endpointConfiguration.disableGroup("Python", DisableReason.DEPENDENCY);
|
||||
endpointConfiguration.disableGroup("OpenCV", DisableReason.DEPENDENCY);
|
||||
log.warn(
|
||||
"Missing dependency: Python - Disabling Python features: {} and OpenCV features: {}",
|
||||
String.join(", ", pythonFeatures),
|
||||
String.join(", ", openCVFeatures));
|
||||
} else {
|
||||
// If Python is available, check for OpenCV
|
||||
try {
|
||||
ProcessBuilder processBuilder = new ProcessBuilder();
|
||||
if (System.getProperty("os.name").toLowerCase().contains("windows")) {
|
||||
processBuilder.command("python", "-c", "import cv2");
|
||||
} else {
|
||||
processBuilder.command("python3", "-c", "import cv2");
|
||||
}
|
||||
Process process = processBuilder.start();
|
||||
int exitCode = process.waitFor();
|
||||
if (exitCode != 0) {
|
||||
List<String> openCVFeatures = getAffectedFeatures("OpenCV");
|
||||
endpointConfiguration.disableGroup("OpenCV", DisableReason.DEPENDENCY);
|
||||
log.warn(
|
||||
"OpenCV not available in Python - Disabling OpenCV features: {}",
|
||||
String.join(", ", openCVFeatures));
|
||||
}
|
||||
} catch (Exception e) {
|
||||
List<String> openCVFeatures = getAffectedFeatures("OpenCV");
|
||||
endpointConfiguration.disableGroup("OpenCV", DisableReason.DEPENDENCY);
|
||||
try {
|
||||
// core checks in parallel
|
||||
List<Callable<Void>> tasks =
|
||||
commandToGroupMapping.keySet().stream()
|
||||
.<Callable<Void>>map(
|
||||
cmd ->
|
||||
() -> {
|
||||
checkDependencyAndDisableGroup(cmd);
|
||||
return null;
|
||||
})
|
||||
.collect(Collectors.toList());
|
||||
invokeAllWithTimeout(tasks, DEFAULT_TIMEOUT.plusSeconds(3));
|
||||
|
||||
// Python / OpenCV special handling
|
||||
checkPythonAndOpenCV();
|
||||
|
||||
dependenciesChecked = true;
|
||||
} finally {
|
||||
endpointConfiguration.logDisabledEndpointsSummary();
|
||||
pool.shutdown();
|
||||
}
|
||||
}
|
||||
|
||||
private void checkDependencyAndDisableGroup(String command) {
|
||||
boolean available = isCommandAvailable(command);
|
||||
|
||||
if (!available) {
|
||||
List<String> affectedGroups = commandToGroupMapping.get(command);
|
||||
if (affectedGroups == null || affectedGroups.isEmpty()) return;
|
||||
|
||||
for (String group : affectedGroups) {
|
||||
List<String> affectedFeatures = getAffectedFeatures(group);
|
||||
endpointConfiguration.disableGroup(group);
|
||||
log.warn(
|
||||
"Error checking OpenCV: {} - Disabling OpenCV features: {}",
|
||||
e.getMessage(),
|
||||
String.join(", ", openCVFeatures));
|
||||
"Missing dependency: {} - Disabling group: {} (Affected features: {})",
|
||||
command,
|
||||
group,
|
||||
affectedFeatures.isEmpty()
|
||||
? "unknown"
|
||||
: String.join(", ", affectedFeatures));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Extra: enforce minimum WeasyPrint version if command matches
|
||||
if (isWeasyprint(command)) {
|
||||
Optional<String> version = getVersionSafe(command, "--version");
|
||||
version.ifPresentOrElse(
|
||||
v -> {
|
||||
Version installed = new Version(v);
|
||||
// https://www.courtbouillon.org/blog/00040-weasyprint-58/
|
||||
Version required = new Version("58.0");
|
||||
if (installed.compareTo(required) < 0) {
|
||||
List<String> affectedGroups =
|
||||
commandToGroupMapping.getOrDefault(
|
||||
command, List.of("Weasyprint"));
|
||||
for (String group : affectedGroups) {
|
||||
endpointConfiguration.disableGroup(group);
|
||||
}
|
||||
log.warn(
|
||||
"WeasyPrint version {} is below required {} - disabling"
|
||||
+ " group(s): {}",
|
||||
installed,
|
||||
required,
|
||||
String.join(", ", affectedGroups));
|
||||
} else {
|
||||
log.info("WeasyPrint {} meets minimum {}", installed, required);
|
||||
}
|
||||
},
|
||||
() ->
|
||||
log.warn(
|
||||
"WeasyPrint version could not be determined ({} --version)",
|
||||
command));
|
||||
}
|
||||
|
||||
// Extra: enforce minimum qpdf version if command matches
|
||||
if (isQpdf(command)) {
|
||||
Optional<String> version = getVersionSafe(command, "--version");
|
||||
version.ifPresentOrElse(
|
||||
v -> {
|
||||
Version installed = new Version(v);
|
||||
Version required = new Version("12.0.0");
|
||||
if (installed.compareTo(required) < 0) {
|
||||
List<String> affectedGroups =
|
||||
commandToGroupMapping.getOrDefault(command, List.of("qpdf"));
|
||||
for (String group : affectedGroups) {
|
||||
endpointConfiguration.disableGroup(group);
|
||||
}
|
||||
log.warn(
|
||||
"qpdf version {} is below required {} - disabling group(s): {}",
|
||||
installed,
|
||||
required,
|
||||
String.join(", ", affectedGroups));
|
||||
} else {
|
||||
log.info("qpdf {} meets minimum {}", installed, required);
|
||||
}
|
||||
},
|
||||
() -> log.warn("qpdf version could not be determined ({} --version)", command));
|
||||
}
|
||||
}
|
||||
|
||||
private boolean isWeasyprint(String command) {
|
||||
return Objects.equals(command, weasyprintPath)
|
||||
|| command.toLowerCase(Locale.ROOT).contains("weasyprint");
|
||||
}
|
||||
|
||||
private boolean isQpdf(String command) {
|
||||
return command.toLowerCase(Locale.ROOT).contains("qpdf");
|
||||
}
|
||||
|
||||
private List<String> getAffectedFeatures(String group) {
|
||||
List<String> endpoints = new ArrayList<>(endpointConfiguration.getEndpointsForGroup(group));
|
||||
return endpoints.stream().map(this::formatEndpointAsFeature).toList();
|
||||
}
|
||||
|
||||
private String formatEndpointAsFeature(String endpoint) {
|
||||
String feature = endpoint.replace("-", " ").replace("pdf", "PDF").replace("img", "image");
|
||||
return Arrays.stream(RegexPatternUtils.getInstance().getWordSplitPattern().split(feature))
|
||||
.map(this::capitalizeWord)
|
||||
.collect(Collectors.joining(" "));
|
||||
}
|
||||
|
||||
private String capitalizeWord(String word) {
|
||||
if (word == null || word.isEmpty()) return word;
|
||||
if ("pdf".equalsIgnoreCase(word)) return "PDF";
|
||||
return word.substring(0, 1).toUpperCase(Locale.ROOT)
|
||||
+ word.substring(1).toLowerCase(Locale.ROOT);
|
||||
}
|
||||
|
||||
private void checkPythonAndOpenCV() {
|
||||
String python = findFirstAvailable(List.of("python3", "python")).orElse(null);
|
||||
if (python == null) {
|
||||
disablePythonAndOpenCV("Python interpreter not found on PATH");
|
||||
return;
|
||||
}
|
||||
|
||||
// Check OpenCV import
|
||||
int ec = runAndWait(List.of(python, "-c", "import cv2"), DEFAULT_TIMEOUT).exitCode();
|
||||
if (ec != 0) {
|
||||
List<String> openCVFeatures = getAffectedFeatures("OpenCV");
|
||||
endpointConfiguration.disableGroup("OpenCV");
|
||||
log.warn(
|
||||
"OpenCV not available in Python - Disabling OpenCV features: {}",
|
||||
String.join(", ", openCVFeatures));
|
||||
}
|
||||
}
|
||||
|
||||
private void disablePythonAndOpenCV(String reason) {
|
||||
List<String> pythonFeatures = getAffectedFeatures("Python");
|
||||
List<String> openCVFeatures = getAffectedFeatures("OpenCV");
|
||||
endpointConfiguration.disableGroup("Python");
|
||||
endpointConfiguration.disableGroup("OpenCV");
|
||||
log.warn(
|
||||
"Missing dependency: Python (reason: {}) - Disabling Python features: {} and OpenCV"
|
||||
+ " features: {}",
|
||||
reason,
|
||||
String.join(", ", pythonFeatures),
|
||||
String.join(", ", openCVFeatures));
|
||||
}
|
||||
|
||||
private Optional<String> findFirstAvailable(List<String> commands) {
|
||||
for (String c : commands) {
|
||||
if (isCommandAvailable(c)) return Optional.of(c);
|
||||
}
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
private boolean isCommandAvailable(String command) {
|
||||
// First try OS-native lookup
|
||||
List<String> lookup = isWindows ? List.of("where", command) : List.of("which", command);
|
||||
ProbeResult res = runAndWait(lookup, DEFAULT_TIMEOUT);
|
||||
if (res.exitCode() == 0) return true;
|
||||
|
||||
// Fallback: try `--version` when helpful (covers py-launcher shims on Windows etc.)
|
||||
ProbeResult ver = runAndWait(List.of(command, "--version"), DEFAULT_TIMEOUT);
|
||||
return ver.exitCode() == 0;
|
||||
}
|
||||
|
||||
private Optional<String> getVersionSafe(String command, String arg) {
|
||||
try {
|
||||
ProbeResult res = runAndWait(List.of(command, arg), DEFAULT_TIMEOUT);
|
||||
if (res.exitCode() != 0) return Optional.empty();
|
||||
String text = res.combined();
|
||||
Matcher m = VERSION_PATTERN.matcher(text);
|
||||
return m.find() ? Optional.of(m.group(1)) : Optional.empty();
|
||||
} catch (Exception e) {
|
||||
return Optional.empty();
|
||||
}
|
||||
}
|
||||
|
||||
private void invokeAllWithTimeout(List<Callable<Void>> tasks, Duration timeout) {
|
||||
try {
|
||||
List<Future<Void>> futures =
|
||||
pool.invokeAll(tasks, timeout.toMillis(), TimeUnit.MILLISECONDS);
|
||||
for (Future<Void> f : futures) {
|
||||
try {
|
||||
f.get();
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
}
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
}
|
||||
}
|
||||
|
||||
private ProbeResult runAndWait(List<String> cmd, Duration timeout) {
|
||||
ProcessBuilder pb = new ProcessBuilder(cmd);
|
||||
try {
|
||||
Process p = pb.start();
|
||||
boolean finished = p.waitFor(timeout.toMillis(), TimeUnit.MILLISECONDS);
|
||||
if (!finished) {
|
||||
p.destroyForcibly();
|
||||
return new ProbeResult(124, "", "timeout");
|
||||
}
|
||||
String out = readStream(p.getInputStream());
|
||||
String err = readStream(p.getErrorStream());
|
||||
int ec = p.exitValue();
|
||||
return new ProbeResult(ec, out, err);
|
||||
} catch (IOException | InterruptedException e) {
|
||||
if (e instanceof InterruptedException) Thread.currentThread().interrupt();
|
||||
return new ProbeResult(127, "", String.valueOf(e.getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
private static String readStream(InputStream in) throws IOException {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
try (BufferedReader br =
|
||||
new BufferedReader(new InputStreamReader(in, StandardCharsets.UTF_8))) {
|
||||
String line;
|
||||
while ((line = br.readLine()) != null) {
|
||||
if (sb.length() > 0) sb.append('\n');
|
||||
sb.append(line);
|
||||
}
|
||||
}
|
||||
endpointConfiguration.logDisabledEndpointsSummary();
|
||||
dependenciesChecked = true;
|
||||
log.info("Dependency checks completed");
|
||||
return sb.toString().trim();
|
||||
}
|
||||
|
||||
private record ProbeResult(int exitCode, String stdout, String stderr) {
|
||||
String combined() {
|
||||
return (stdout == null ? "" : stdout) + "\n" + (stderr == null ? "" : stderr);
|
||||
}
|
||||
}
|
||||
|
||||
/** Simple numeric version comparator (major.minor.patch). */
|
||||
static class Version implements Comparable<Version> {
|
||||
private final int[] parts;
|
||||
|
||||
Version(String ver) {
|
||||
String[] tokens = ver.split("\\.");
|
||||
parts = new int[3];
|
||||
for (int i = 0; i < 3; i++) {
|
||||
if (i < tokens.length) {
|
||||
try {
|
||||
parts[i] = Integer.parseInt(tokens[i]);
|
||||
} catch (NumberFormatException e) {
|
||||
parts[i] = 0;
|
||||
}
|
||||
} else {
|
||||
parts[i] = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public int compareTo(Version o) {
|
||||
for (int i = 0; i < 3; i++) {
|
||||
int a = parts[i];
|
||||
int b = o.parts[i];
|
||||
if (a != b) return Integer.compare(a, b);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return parts[0] + "." + parts[1] + "." + parts[2];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -96,7 +96,7 @@ public class OpenApiConfig {
|
||||
|
||||
Components components = new Components().addSchemas("ErrorResponse", errorResponseSchema);
|
||||
|
||||
if (!applicationProperties.getSecurity().getEnableLogin()) {
|
||||
if (!applicationProperties.getSecurity().isEnableLogin()) {
|
||||
return openAPI.components(components);
|
||||
} else {
|
||||
SecurityScheme apiKeyScheme =
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package stirling.software.SPDF.controller.api;
|
||||
|
||||
import java.awt.image.BufferedImage;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
@@ -13,136 +14,279 @@ import org.apache.pdfbox.pdmodel.PDPageContentStream;
|
||||
import org.apache.pdfbox.pdmodel.PDPageContentStream.AppendMode;
|
||||
import org.apache.pdfbox.pdmodel.common.PDRectangle;
|
||||
import org.apache.pdfbox.pdmodel.graphics.form.PDFormXObject;
|
||||
import org.apache.pdfbox.rendering.PDFRenderer;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.ModelAttribute;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.SPDF.config.EndpointConfiguration;
|
||||
import stirling.software.SPDF.config.swagger.StandardPdfResponse;
|
||||
import stirling.software.SPDF.model.api.general.CropPdfForm;
|
||||
import stirling.software.common.annotations.AutoJobPostMapping;
|
||||
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.ProcessExecutor;
|
||||
import stirling.software.common.util.WebResponseUtils;
|
||||
|
||||
@GeneralApi
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/general")
|
||||
@Tag(name = "General", description = "General APIs")
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class CropController {
|
||||
|
||||
private final CustomPDFDocumentFactory pdfDocumentFactory;
|
||||
private final EndpointConfiguration endpointConfiguration;
|
||||
private static final int DEFAULT_RENDER_DPI = 150;
|
||||
private static final int WHITE_THRESHOLD = 250;
|
||||
private static final String TEMP_INPUT_PREFIX = "crop_input";
|
||||
private static final String TEMP_OUTPUT_PREFIX = "crop_output";
|
||||
private static final String PDF_EXTENSION = ".pdf";
|
||||
|
||||
private boolean isGhostscriptEnabled() {
|
||||
return endpointConfiguration.isGroupEnabled("Ghostscript");
|
||||
private final CustomPDFDocumentFactory pdfDocumentFactory;
|
||||
|
||||
private static int[] detectContentBounds(BufferedImage image) {
|
||||
int width = image.getWidth();
|
||||
int height = image.getHeight();
|
||||
|
||||
// Early exit if image is too small
|
||||
if (width < 1 || height < 1) {
|
||||
return new int[] {0, 0, width - 1, height - 1};
|
||||
}
|
||||
|
||||
// Sample every nth pixel for large images to reduce processing time
|
||||
int step = (width > 2000 || height > 2000) ? 2 : 1;
|
||||
|
||||
int top = 0;
|
||||
boolean found = false;
|
||||
for (int y = 0; y < height && !found; y += step) {
|
||||
for (int x = 0; x < width; x += step) {
|
||||
if (!isWhite(image.getRGB(x, y), WHITE_THRESHOLD)) {
|
||||
top = y;
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
int bottom = height - 1;
|
||||
found = false;
|
||||
for (int y = height - 1; y >= 0 && !found; y -= step) {
|
||||
for (int x = 0; x < width; x += step) {
|
||||
if (!isWhite(image.getRGB(x, y), WHITE_THRESHOLD)) {
|
||||
bottom = y;
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
int left = 0;
|
||||
found = false;
|
||||
for (int x = 0; x < width && !found; x += step) {
|
||||
for (int y = top; y <= bottom; y += step) {
|
||||
if (!isWhite(image.getRGB(x, y), WHITE_THRESHOLD)) {
|
||||
left = x;
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
int right = width - 1;
|
||||
found = false;
|
||||
for (int x = width - 1; x >= 0 && !found; x -= step) {
|
||||
for (int y = top; y <= bottom; y += step) {
|
||||
if (!isWhite(image.getRGB(x, y), WHITE_THRESHOLD)) {
|
||||
right = x;
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Return bounds in format: [left, bottom, right, top]
|
||||
// Note: Image coordinates are top-down, PDF coordinates are bottom-up
|
||||
return new int[] {left, height - bottom - 1, right, height - top - 1};
|
||||
}
|
||||
|
||||
@AutoJobPostMapping(value = "/crop", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||
@StandardPdfResponse
|
||||
private static boolean isWhite(int rgb, int threshold) {
|
||||
int r = (rgb >> 16) & 0xFF;
|
||||
int g = (rgb >> 8) & 0xFF;
|
||||
int b = rgb & 0xFF;
|
||||
return r >= threshold && g >= threshold && b >= threshold;
|
||||
}
|
||||
|
||||
@PostMapping(value = "/crop", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||
@Operation(
|
||||
summary = "Crops a PDF document",
|
||||
description =
|
||||
"This operation takes an input PDF file and crops it according to the given"
|
||||
+ " coordinates. Input:PDF Output:PDF Type:SISO")
|
||||
public ResponseEntity<byte[]> cropPdf(@ModelAttribute CropPdfForm request) throws IOException {
|
||||
if (request.isRemoveDataOutsideCrop() && isGhostscriptEnabled()) {
|
||||
if (request.isAutoCrop()) {
|
||||
return cropWithAutomaticDetection(request);
|
||||
}
|
||||
|
||||
if (request.getX() == null
|
||||
|| request.getY() == null
|
||||
|| request.getWidth() == null
|
||||
|| request.getHeight() == null) {
|
||||
throw new IllegalArgumentException(
|
||||
"Crop coordinates (x, y, width, height) are required when auto-crop is not enabled");
|
||||
}
|
||||
|
||||
if (request.isRemoveDataOutsideCrop()) {
|
||||
return cropWithGhostscript(request);
|
||||
} else {
|
||||
if (request.isRemoveDataOutsideCrop()) {
|
||||
log.warn(
|
||||
"Ghostscript not available - 'removeDataOutsideCrop' option requires Ghostscript. Falling back to visual crop only.");
|
||||
}
|
||||
return cropWithPDFBox(request);
|
||||
}
|
||||
}
|
||||
|
||||
private ResponseEntity<byte[]> cropWithAutomaticDetection(@ModelAttribute CropPdfForm request)
|
||||
throws IOException {
|
||||
try (PDDocument sourceDocument = pdfDocumentFactory.load(request)) {
|
||||
|
||||
try (PDDocument newDocument =
|
||||
pdfDocumentFactory.createNewDocumentBasedOnOldDocument(sourceDocument)) {
|
||||
PDFRenderer renderer = new PDFRenderer(sourceDocument);
|
||||
LayerUtility layerUtility = new LayerUtility(newDocument);
|
||||
|
||||
for (int i = 0; i < sourceDocument.getNumberOfPages(); i++) {
|
||||
PDPage sourcePage = sourceDocument.getPage(i);
|
||||
PDRectangle mediaBox = sourcePage.getMediaBox();
|
||||
|
||||
BufferedImage image = renderer.renderImageWithDPI(i, DEFAULT_RENDER_DPI);
|
||||
int[] bounds = detectContentBounds(image);
|
||||
|
||||
float scaleX = mediaBox.getWidth() / image.getWidth();
|
||||
float scaleY = mediaBox.getHeight() / image.getHeight();
|
||||
|
||||
CropBounds cropBounds = CropBounds.fromPixels(bounds, scaleX, scaleY);
|
||||
|
||||
PDPage newPage = new PDPage(mediaBox);
|
||||
newDocument.addPage(newPage);
|
||||
try (PDPageContentStream contentStream =
|
||||
new PDPageContentStream(
|
||||
newDocument, newPage, AppendMode.OVERWRITE, true, true)) {
|
||||
PDFormXObject formXObject =
|
||||
layerUtility.importPageAsForm(sourceDocument, i);
|
||||
contentStream.saveGraphicsState();
|
||||
contentStream.addRect(
|
||||
cropBounds.x, cropBounds.y, cropBounds.width, cropBounds.height);
|
||||
contentStream.clip();
|
||||
contentStream.drawForm(formXObject);
|
||||
contentStream.restoreGraphicsState();
|
||||
}
|
||||
|
||||
newPage.setMediaBox(
|
||||
new PDRectangle(
|
||||
cropBounds.x,
|
||||
cropBounds.y,
|
||||
cropBounds.width,
|
||||
cropBounds.height));
|
||||
}
|
||||
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
newDocument.save(baos);
|
||||
byte[] pdfContent = baos.toByteArray();
|
||||
|
||||
return WebResponseUtils.bytesToWebResponse(
|
||||
pdfContent,
|
||||
GeneralUtils.generateFilename(
|
||||
request.getFileInput().getOriginalFilename(), "_cropped.pdf"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private ResponseEntity<byte[]> cropWithPDFBox(@ModelAttribute CropPdfForm request)
|
||||
throws IOException {
|
||||
PDDocument sourceDocument = pdfDocumentFactory.load(request);
|
||||
try (PDDocument sourceDocument = pdfDocumentFactory.load(request)) {
|
||||
|
||||
PDDocument newDocument =
|
||||
pdfDocumentFactory.createNewDocumentBasedOnOldDocument(sourceDocument);
|
||||
try (PDDocument newDocument =
|
||||
pdfDocumentFactory.createNewDocumentBasedOnOldDocument(sourceDocument)) {
|
||||
int totalPages = sourceDocument.getNumberOfPages();
|
||||
LayerUtility layerUtility = new LayerUtility(newDocument);
|
||||
|
||||
int totalPages = sourceDocument.getNumberOfPages();
|
||||
for (int i = 0; i < totalPages; i++) {
|
||||
PDPage sourcePage = sourceDocument.getPage(i);
|
||||
|
||||
LayerUtility layerUtility = new LayerUtility(newDocument);
|
||||
// Create a new page with the size of the source page
|
||||
PDPage newPage = new PDPage(sourcePage.getMediaBox());
|
||||
newDocument.addPage(newPage);
|
||||
try (PDPageContentStream contentStream =
|
||||
new PDPageContentStream(
|
||||
newDocument, newPage, AppendMode.OVERWRITE, true, true)) {
|
||||
// Import the source page as a form XObject
|
||||
PDFormXObject formXObject =
|
||||
layerUtility.importPageAsForm(sourceDocument, i);
|
||||
|
||||
for (int i = 0; i < totalPages; i++) {
|
||||
PDPage sourcePage = sourceDocument.getPage(i);
|
||||
contentStream.saveGraphicsState();
|
||||
|
||||
// Create a new page with the size of the source page
|
||||
PDPage newPage = new PDPage(sourcePage.getMediaBox());
|
||||
newDocument.addPage(newPage);
|
||||
PDPageContentStream contentStream =
|
||||
new PDPageContentStream(newDocument, newPage, AppendMode.OVERWRITE, true, true);
|
||||
// Define the crop area
|
||||
contentStream.addRect(
|
||||
request.getX(),
|
||||
request.getY(),
|
||||
request.getWidth(),
|
||||
request.getHeight());
|
||||
contentStream.clip();
|
||||
|
||||
// Import the source page as a form XObject
|
||||
PDFormXObject formXObject = layerUtility.importPageAsForm(sourceDocument, i);
|
||||
// Draw the entire formXObject
|
||||
contentStream.drawForm(formXObject);
|
||||
|
||||
contentStream.saveGraphicsState();
|
||||
contentStream.restoreGraphicsState();
|
||||
}
|
||||
|
||||
// Define the crop area
|
||||
contentStream.addRect(
|
||||
request.getX(), request.getY(), request.getWidth(), request.getHeight());
|
||||
contentStream.clip();
|
||||
// Now, set the new page's media box to the cropped size
|
||||
newPage.setMediaBox(
|
||||
new PDRectangle(
|
||||
request.getX(),
|
||||
request.getY(),
|
||||
request.getWidth(),
|
||||
request.getHeight()));
|
||||
}
|
||||
|
||||
// Draw the entire formXObject
|
||||
contentStream.drawForm(formXObject);
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
newDocument.save(baos);
|
||||
|
||||
contentStream.restoreGraphicsState();
|
||||
|
||||
contentStream.close();
|
||||
|
||||
// Now, set the new page's media box to the cropped size
|
||||
newPage.setMediaBox(
|
||||
new PDRectangle(
|
||||
request.getX(),
|
||||
request.getY(),
|
||||
request.getWidth(),
|
||||
request.getHeight()));
|
||||
byte[] pdfContent = baos.toByteArray();
|
||||
return WebResponseUtils.bytesToWebResponse(
|
||||
pdfContent,
|
||||
GeneralUtils.generateFilename(
|
||||
request.getFileInput().getOriginalFilename(), "_cropped.pdf"));
|
||||
}
|
||||
}
|
||||
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
newDocument.save(baos);
|
||||
newDocument.close();
|
||||
sourceDocument.close();
|
||||
|
||||
byte[] pdfContent = baos.toByteArray();
|
||||
return WebResponseUtils.bytesToWebResponse(
|
||||
pdfContent,
|
||||
GeneralUtils.generateFilename(
|
||||
request.getFileInput().getOriginalFilename(), "_cropped.pdf"));
|
||||
}
|
||||
|
||||
private ResponseEntity<byte[]> cropWithGhostscript(@ModelAttribute CropPdfForm request)
|
||||
throws IOException {
|
||||
PDDocument sourceDocument = pdfDocumentFactory.load(request);
|
||||
Path tempInputFile = null;
|
||||
Path tempOutputFile = null;
|
||||
|
||||
for (int i = 0; i < sourceDocument.getNumberOfPages(); i++) {
|
||||
PDPage page = sourceDocument.getPage(i);
|
||||
PDRectangle cropBox =
|
||||
new PDRectangle(
|
||||
request.getX(),
|
||||
request.getY(),
|
||||
request.getWidth(),
|
||||
request.getHeight());
|
||||
page.setCropBox(cropBox);
|
||||
}
|
||||
try (PDDocument sourceDocument = pdfDocumentFactory.load(request)) {
|
||||
for (int i = 0; i < sourceDocument.getNumberOfPages(); i++) {
|
||||
PDPage page = sourceDocument.getPage(i);
|
||||
PDRectangle cropBox =
|
||||
new PDRectangle(
|
||||
request.getX(),
|
||||
request.getY(),
|
||||
request.getWidth(),
|
||||
request.getHeight());
|
||||
page.setCropBox(cropBox);
|
||||
}
|
||||
|
||||
Path tempInputFile = Files.createTempFile("crop_input", ".pdf");
|
||||
Path tempOutputFile = Files.createTempFile("crop_output", ".pdf");
|
||||
tempInputFile = Files.createTempFile(TEMP_INPUT_PREFIX, PDF_EXTENSION);
|
||||
tempOutputFile = Files.createTempFile(TEMP_OUTPUT_PREFIX, PDF_EXTENSION);
|
||||
|
||||
try {
|
||||
// Save the source document with crop boxes
|
||||
sourceDocument.save(tempInputFile.toFile());
|
||||
sourceDocument.close();
|
||||
|
||||
// Execute Ghostscript to process the crop boxes
|
||||
ProcessExecutor processExecutor =
|
||||
ProcessExecutor.getInstance(ProcessExecutor.Processes.GHOSTSCRIPT);
|
||||
List<String> command =
|
||||
@@ -160,19 +304,34 @@ public class CropController {
|
||||
|
||||
return WebResponseUtils.bytesToWebResponse(
|
||||
pdfContent,
|
||||
request.getFileInput().getOriginalFilename().replaceFirst("[.][^.]+$", "")
|
||||
+ "_cropped.pdf");
|
||||
GeneralUtils.generateFilename(
|
||||
request.getFileInput().getOriginalFilename(), "_cropped.pdf"));
|
||||
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new IOException("Ghostscript processing was interrupted", e);
|
||||
throw ExceptionUtils.createProcessingInterruptedException("Ghostscript", e);
|
||||
} finally {
|
||||
try {
|
||||
if (tempInputFile != null) {
|
||||
Files.deleteIfExists(tempInputFile);
|
||||
}
|
||||
if (tempOutputFile != null) {
|
||||
Files.deleteIfExists(tempOutputFile);
|
||||
} catch (IOException e) {
|
||||
log.debug("Failed to delete temporary files", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private record CropBounds(float x, float y, float width, float height) {
|
||||
|
||||
static CropBounds fromPixels(int[] pixelBounds, float scaleX, float scaleY) {
|
||||
if (pixelBounds.length != 4) {
|
||||
throw new IllegalArgumentException(
|
||||
"pixelBounds array must contain exactly 4 elements: [x1, y1, x2, y2]");
|
||||
}
|
||||
float x = pixelBounds[0] * scaleX;
|
||||
float y = pixelBounds[1] * scaleY;
|
||||
float width = (pixelBounds[2] - pixelBounds[0]) * scaleX;
|
||||
float height = (pixelBounds[3] - pixelBounds[1]) * scaleY;
|
||||
return new CropBounds(x, y, width, height);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+13
-36
@@ -10,65 +10,53 @@ import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.apache.pdfbox.pdmodel.PDPage;
|
||||
import org.apache.pdfbox.pdmodel.interactive.documentnavigation.outline.PDDocumentOutline;
|
||||
import org.apache.pdfbox.pdmodel.interactive.documentnavigation.outline.PDOutlineItem;
|
||||
import org.apache.pdfbox.pdmodel.interactive.documentnavigation.outline.PDOutlineNode;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.web.bind.annotation.ModelAttribute;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.ResponseBody;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.Setter;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.SPDF.config.swagger.StandardPdfResponse;
|
||||
import stirling.software.SPDF.model.api.EditTableOfContentsRequest;
|
||||
import stirling.software.common.annotations.AutoJobPostMapping;
|
||||
import stirling.software.common.annotations.api.GeneralApi;
|
||||
import stirling.software.common.service.CustomPDFDocumentFactory;
|
||||
import stirling.software.common.util.GeneralUtils;
|
||||
import stirling.software.common.util.WebResponseUtils;
|
||||
|
||||
@GeneralApi
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/general")
|
||||
@Slf4j
|
||||
@Tag(name = "General", description = "General APIs")
|
||||
@RequiredArgsConstructor
|
||||
public class EditTableOfContentsController {
|
||||
|
||||
private final CustomPDFDocumentFactory pdfDocumentFactory;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
@AutoJobPostMapping(
|
||||
value = "/extract-bookmarks",
|
||||
consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||
@PostMapping(value = "/extract-bookmarks", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||
@Operation(
|
||||
summary = "Extract PDF Bookmarks",
|
||||
description = "Extracts bookmarks/table of contents from a PDF document as JSON.")
|
||||
@ResponseBody
|
||||
public ResponseEntity<List<Map<String, Object>>> extractBookmarks(
|
||||
@RequestParam("file") MultipartFile file) throws Exception {
|
||||
PDDocument document = null;
|
||||
try {
|
||||
document = pdfDocumentFactory.load(file);
|
||||
public List<Map<String, Object>> extractBookmarks(@RequestParam("file") MultipartFile file)
|
||||
throws Exception {
|
||||
try (PDDocument document = pdfDocumentFactory.load(file)) {
|
||||
PDDocumentOutline outline = document.getDocumentCatalog().getDocumentOutline();
|
||||
|
||||
if (outline == null) {
|
||||
log.info("No outline/bookmarks found in PDF");
|
||||
return ResponseEntity.ok(new ArrayList<>());
|
||||
return new ArrayList<>();
|
||||
}
|
||||
|
||||
return ResponseEntity.ok(extractBookmarkItems(document, outline));
|
||||
} finally {
|
||||
if (document != null) {
|
||||
document.close();
|
||||
}
|
||||
return extractBookmarkItems(document, outline);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -97,7 +85,6 @@ public class EditTableOfContentsController {
|
||||
PDOutlineItem child = current.getFirstChild();
|
||||
if (child != null) {
|
||||
List<Map<String, Object>> children = new ArrayList<>();
|
||||
PDOutlineNode parent = current;
|
||||
|
||||
while (child != null) {
|
||||
// Recursively process child items
|
||||
@@ -155,20 +142,16 @@ public class EditTableOfContentsController {
|
||||
return bookmark;
|
||||
}
|
||||
|
||||
@AutoJobPostMapping(
|
||||
value = "/edit-table-of-contents",
|
||||
consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||
@StandardPdfResponse
|
||||
@PostMapping(value = "/edit-table-of-contents", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||
@Operation(
|
||||
summary = "Edit Table of Contents",
|
||||
description = "Add or edit bookmarks/table of contents in a PDF document.")
|
||||
public ResponseEntity<byte[]> editTableOfContents(
|
||||
@ModelAttribute EditTableOfContentsRequest request) throws Exception {
|
||||
MultipartFile file = request.getFileInput();
|
||||
PDDocument document = null;
|
||||
|
||||
try {
|
||||
document = pdfDocumentFactory.load(file);
|
||||
try (PDDocument document = pdfDocumentFactory.load(file);
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream()) {
|
||||
|
||||
// Parse the bookmark data from JSON
|
||||
List<BookmarkItem> bookmarks =
|
||||
@@ -183,18 +166,12 @@ public class EditTableOfContentsController {
|
||||
addBookmarksToOutline(document, outline, bookmarks);
|
||||
|
||||
// Save the document to a byte array
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
document.save(baos);
|
||||
|
||||
return WebResponseUtils.bytesToWebResponse(
|
||||
baos.toByteArray(),
|
||||
GeneralUtils.generateFilename(file.getOriginalFilename(), "_with_toc.pdf"),
|
||||
MediaType.APPLICATION_PDF);
|
||||
|
||||
} finally {
|
||||
if (document != null) {
|
||||
document.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -69,19 +69,29 @@ public class MergeController {
|
||||
// fileOrder is newline-delimited original filenames in the desired order.
|
||||
private static MultipartFile[] reorderFilesByProvidedOrder(
|
||||
MultipartFile[] files, String fileOrder) {
|
||||
String[] desired = fileOrder.split("\n", -1);
|
||||
// Split by various line endings and trim each entry
|
||||
String[] desired =
|
||||
stirling.software.common.util.RegexPatternUtils.getInstance()
|
||||
.getNewlineSplitPattern()
|
||||
.split(fileOrder);
|
||||
|
||||
List<MultipartFile> remaining = new ArrayList<>(Arrays.asList(files));
|
||||
List<MultipartFile> ordered = new ArrayList<>(files.length);
|
||||
|
||||
for (String name : desired) {
|
||||
if (name == null || name.isEmpty()) continue;
|
||||
name = name.trim();
|
||||
if (name.isEmpty()) {
|
||||
log.debug("Skipping empty entry");
|
||||
continue;
|
||||
}
|
||||
int idx = indexOfByOriginalFilename(remaining, name);
|
||||
if (idx >= 0) {
|
||||
ordered.add(remaining.remove(idx));
|
||||
} else {
|
||||
log.debug("Filename from order list not found in uploaded files: {}", name);
|
||||
}
|
||||
}
|
||||
|
||||
// Append any files not explicitly listed, preserving their relative order
|
||||
ordered.addAll(remaining);
|
||||
return ordered.toArray(new MultipartFile[0]);
|
||||
}
|
||||
@@ -276,8 +286,10 @@ public class MergeController {
|
||||
|
||||
// If front-end provided explicit visible order, honor it and override backend sorting
|
||||
if (fileOrder != null && !fileOrder.isBlank()) {
|
||||
log.info("Reordering files based on fileOrder parameter");
|
||||
files = reorderFilesByProvidedOrder(files, fileOrder);
|
||||
} else {
|
||||
log.info("Sorting files based on sortType: {}", request.getSortType());
|
||||
Arrays.sort(
|
||||
files,
|
||||
getSortComparator(
|
||||
|
||||
+35
-9
@@ -14,32 +14,34 @@ import org.apache.pdfbox.util.Matrix;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.ModelAttribute;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.SPDF.config.swagger.StandardPdfResponse;
|
||||
import stirling.software.SPDF.model.api.general.MergeMultiplePagesRequest;
|
||||
import stirling.software.common.annotations.AutoJobPostMapping;
|
||||
import stirling.software.common.annotations.api.GeneralApi;
|
||||
import stirling.software.common.service.CustomPDFDocumentFactory;
|
||||
import stirling.software.common.util.ExceptionUtils;
|
||||
import stirling.software.common.util.GeneralFormCopyUtils;
|
||||
import stirling.software.common.util.GeneralUtils;
|
||||
import stirling.software.common.util.WebResponseUtils;
|
||||
|
||||
@GeneralApi
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/general")
|
||||
@Tag(name = "General", description = "General APIs")
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class MultiPageLayoutController {
|
||||
|
||||
private final CustomPDFDocumentFactory pdfDocumentFactory;
|
||||
|
||||
@AutoJobPostMapping(
|
||||
value = "/multi-page-layout",
|
||||
consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||
@StandardPdfResponse
|
||||
@PostMapping(value = "/multi-page-layout", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||
@Operation(
|
||||
summary = "Merge multiple pages of a PDF document into a single page",
|
||||
description =
|
||||
@@ -55,7 +57,11 @@ public class MultiPageLayoutController {
|
||||
if (pagesPerSheet != 2
|
||||
&& pagesPerSheet != 3
|
||||
&& pagesPerSheet != (int) Math.sqrt(pagesPerSheet) * Math.sqrt(pagesPerSheet)) {
|
||||
throw new IllegalArgumentException("pagesPerSheet must be 2, 3 or a perfect square");
|
||||
throw ExceptionUtils.createIllegalArgumentException(
|
||||
"error.invalidFormat",
|
||||
"Invalid {0} format: {1}",
|
||||
"pagesPerSheet",
|
||||
"must be 2, 3 or a perfect square");
|
||||
}
|
||||
|
||||
int cols =
|
||||
@@ -136,6 +142,26 @@ public class MultiPageLayoutController {
|
||||
|
||||
contentStream.close();
|
||||
|
||||
// If any source page is rotated, skip form copying/transformation entirely
|
||||
boolean hasRotation = GeneralFormCopyUtils.hasAnyRotatedPage(sourceDocument);
|
||||
if (hasRotation) {
|
||||
log.info("Source document has rotated pages; skipping form field copying.");
|
||||
} else {
|
||||
try {
|
||||
GeneralFormCopyUtils.copyAndTransformFormFields(
|
||||
sourceDocument,
|
||||
newDocument,
|
||||
totalPages,
|
||||
pagesPerSheet,
|
||||
cols,
|
||||
rows,
|
||||
cellWidth,
|
||||
cellHeight);
|
||||
} catch (Exception e) {
|
||||
log.warn("Failed to copy and transform form fields: {}", e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
sourceDocument.close();
|
||||
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
|
||||
+12
-11
@@ -26,6 +26,7 @@ import stirling.software.SPDF.model.api.general.OverlayPdfsRequest;
|
||||
import stirling.software.common.annotations.AutoJobPostMapping;
|
||||
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.WebResponseUtils;
|
||||
|
||||
@@ -61,7 +62,8 @@ public class PdfOverlayController {
|
||||
int[] counts = request.getCounts(); // Used for FixedRepeatOverlay mode
|
||||
|
||||
try (PDDocument basePdf = pdfDocumentFactory.load(baseFile);
|
||||
Overlay overlay = new Overlay()) {
|
||||
Overlay overlay = new Overlay();
|
||||
ByteArrayOutputStream outputStream = new ByteArrayOutputStream()) {
|
||||
Map<Integer, String> overlayGuide =
|
||||
prepareOverlayGuide(
|
||||
basePdf.getNumberOfPages(),
|
||||
@@ -77,7 +79,6 @@ public class PdfOverlayController {
|
||||
overlay.setOverlayPosition(Overlay.Position.BACKGROUND);
|
||||
}
|
||||
|
||||
ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
|
||||
overlay.overlay(overlayGuide).save(outputStream);
|
||||
byte[] data = outputStream.toByteArray();
|
||||
String outputFilename =
|
||||
@@ -116,7 +117,8 @@ public class PdfOverlayController {
|
||||
fixedRepeatOverlay(overlayGuide, overlayFiles, counts, basePageCount);
|
||||
break;
|
||||
default:
|
||||
throw new IllegalArgumentException("Invalid overlay mode");
|
||||
throw ExceptionUtils.createIllegalArgumentException(
|
||||
"error.invalidFormat", "Invalid {0} format: {1}", "overlay mode", mode);
|
||||
}
|
||||
return overlayGuide;
|
||||
}
|
||||
@@ -138,12 +140,11 @@ public class PdfOverlayController {
|
||||
overlayFileIndex = (overlayFileIndex + 1) % overlayFiles.length;
|
||||
}
|
||||
|
||||
try (PDDocument overlayPdf = Loader.loadPDF(overlayFiles[overlayFileIndex])) {
|
||||
PDDocument singlePageDocument = new PDDocument();
|
||||
try (PDDocument overlayPdf = Loader.loadPDF(overlayFiles[overlayFileIndex]);
|
||||
PDDocument singlePageDocument = new PDDocument()) {
|
||||
singlePageDocument.addPage(overlayPdf.getPage(pageCountInCurrentOverlay));
|
||||
File tempFile = Files.createTempFile("overlay-page-", ".pdf").toFile();
|
||||
singlePageDocument.save(tempFile);
|
||||
singlePageDocument.close();
|
||||
|
||||
overlayGuide.put(basePageIndex, tempFile.getAbsolutePath());
|
||||
tempFiles.add(tempFile); // Keep track of the temporary file for cleanup
|
||||
@@ -179,8 +180,11 @@ public class PdfOverlayController {
|
||||
Map<Integer, String> overlayGuide, File[] overlayFiles, int[] counts, int basePageCount)
|
||||
throws IOException {
|
||||
if (overlayFiles.length != counts.length) {
|
||||
throw new IllegalArgumentException(
|
||||
"Counts array length must match the number of overlay files");
|
||||
throw ExceptionUtils.createIllegalArgumentException(
|
||||
"error.invalidFormat",
|
||||
"Invalid {0} format: {1}",
|
||||
"counts array",
|
||||
"length must match the number of overlay files");
|
||||
}
|
||||
int currentPage = 1;
|
||||
for (int i = 0; i < overlayFiles.length; i++) {
|
||||
@@ -200,6 +204,3 @@ public class PdfOverlayController {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Additional classes like OverlayPdfsRequest, WebResponseUtils, etc. are assumed to be defined
|
||||
// elsewhere.
|
||||
|
||||
+14
-9
@@ -4,6 +4,7 @@ import java.io.IOException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.apache.pdfbox.pdmodel.PDPage;
|
||||
@@ -202,7 +203,7 @@ public class RearrangePagesPDFController {
|
||||
|
||||
private List<Integer> processSortTypes(String sortTypes, int totalPages, String pageOrder) {
|
||||
try {
|
||||
SortTypes mode = SortTypes.valueOf(sortTypes.toUpperCase());
|
||||
SortTypes mode = SortTypes.valueOf(sortTypes.toUpperCase(Locale.ROOT));
|
||||
return switch (mode) {
|
||||
case REVERSE_ORDER -> reverseOrder(totalPages);
|
||||
case DUPLEX_SORT -> duplexSort(totalPages);
|
||||
@@ -214,7 +215,12 @@ public class RearrangePagesPDFController {
|
||||
case REMOVE_LAST -> removeLast(totalPages);
|
||||
case REMOVE_FIRST_AND_LAST -> removeFirstAndLast(totalPages);
|
||||
case DUPLICATE -> duplicate(totalPages, pageOrder);
|
||||
default -> throw new IllegalArgumentException("Unsupported custom mode");
|
||||
default ->
|
||||
throw ExceptionUtils.createIllegalArgumentException(
|
||||
"error.invalidFormat",
|
||||
"Invalid {0} format: {1}",
|
||||
"custom mode",
|
||||
"unsupported");
|
||||
};
|
||||
} catch (IllegalArgumentException e) {
|
||||
log.error("Unsupported custom mode", e);
|
||||
@@ -246,7 +252,7 @@ public class RearrangePagesPDFController {
|
||||
List<Integer> newPageOrder;
|
||||
if (sortType != null
|
||||
&& !sortType.isEmpty()
|
||||
&& !"custom".equals(sortType.toLowerCase())) {
|
||||
&& !"custom".equals(sortType.toLowerCase(Locale.ROOT))) {
|
||||
newPageOrder = processSortTypes(sortType, totalPages, pageOrder);
|
||||
} else {
|
||||
newPageOrder = GeneralUtils.parsePageList(pageOrderArr, totalPages, false);
|
||||
@@ -259,18 +265,17 @@ public class RearrangePagesPDFController {
|
||||
newPages.add(document.getPage(newPageOrder.get(i)));
|
||||
}
|
||||
|
||||
// Remove all the pages from the original document
|
||||
for (int i = document.getNumberOfPages() - 1; i >= 0; i--) {
|
||||
document.removePage(i);
|
||||
}
|
||||
// Create a new document based on the original one
|
||||
PDDocument rearrangedDocument =
|
||||
pdfDocumentFactory.createNewDocumentBasedOnOldDocument(document);
|
||||
|
||||
// Add the pages in the new order
|
||||
for (PDPage page : newPages) {
|
||||
document.addPage(page);
|
||||
rearrangedDocument.addPage(page);
|
||||
}
|
||||
|
||||
return WebResponseUtils.pdfDocToWebResponse(
|
||||
document,
|
||||
rearrangedDocument,
|
||||
GeneralUtils.generateFilename(
|
||||
pdfFile.getOriginalFilename(), "_rearranged.pdf"));
|
||||
} catch (IOException e) {
|
||||
|
||||
+109
-76
@@ -15,105 +15,42 @@ import org.apache.pdfbox.util.Matrix;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.ModelAttribute;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.SPDF.config.swagger.StandardPdfResponse;
|
||||
import stirling.software.SPDF.model.api.general.ScalePagesRequest;
|
||||
import stirling.software.common.annotations.AutoJobPostMapping;
|
||||
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.WebResponseUtils;
|
||||
|
||||
@GeneralApi
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/general")
|
||||
@Tag(name = "General", description = "General APIs")
|
||||
@Slf4j
|
||||
@RequiredArgsConstructor
|
||||
public class ScalePagesController {
|
||||
|
||||
private final CustomPDFDocumentFactory pdfDocumentFactory;
|
||||
|
||||
@AutoJobPostMapping(value = "/scale-pages", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||
@StandardPdfResponse
|
||||
@Operation(
|
||||
summary = "Change the size of a PDF page/document",
|
||||
description =
|
||||
"This operation takes an input PDF file and the size to scale the pages to in"
|
||||
+ " the output PDF file. Input:PDF Output:PDF Type:SISO")
|
||||
public ResponseEntity<byte[]> scalePages(@ModelAttribute ScalePagesRequest request)
|
||||
throws IOException {
|
||||
MultipartFile file = request.getFileInput();
|
||||
String targetPDRectangle = request.getPageSize();
|
||||
float scaleFactor = request.getScaleFactor();
|
||||
|
||||
PDDocument sourceDocument = pdfDocumentFactory.load(file);
|
||||
PDDocument outputDocument =
|
||||
pdfDocumentFactory.createNewDocumentBasedOnOldDocument(sourceDocument);
|
||||
|
||||
PDRectangle targetSize = getTargetSize(targetPDRectangle, sourceDocument);
|
||||
|
||||
int totalPages = sourceDocument.getNumberOfPages();
|
||||
for (int i = 0; i < totalPages; i++) {
|
||||
PDPage sourcePage = sourceDocument.getPage(i);
|
||||
PDRectangle sourceSize = sourcePage.getMediaBox();
|
||||
|
||||
float scaleWidth = targetSize.getWidth() / sourceSize.getWidth();
|
||||
float scaleHeight = targetSize.getHeight() / sourceSize.getHeight();
|
||||
float scale = Math.min(scaleWidth, scaleHeight) * scaleFactor;
|
||||
|
||||
PDPage newPage = new PDPage(targetSize);
|
||||
outputDocument.addPage(newPage);
|
||||
|
||||
PDPageContentStream contentStream =
|
||||
new PDPageContentStream(
|
||||
outputDocument,
|
||||
newPage,
|
||||
PDPageContentStream.AppendMode.APPEND,
|
||||
true,
|
||||
true);
|
||||
|
||||
float x = (targetSize.getWidth() - sourceSize.getWidth() * scale) / 2;
|
||||
float y = (targetSize.getHeight() - sourceSize.getHeight() * scale) / 2;
|
||||
|
||||
contentStream.saveGraphicsState();
|
||||
contentStream.transform(Matrix.getTranslateInstance(x, y));
|
||||
contentStream.transform(Matrix.getScaleInstance(scale, scale));
|
||||
|
||||
LayerUtility layerUtility = new LayerUtility(outputDocument);
|
||||
PDFormXObject form = layerUtility.importPageAsForm(sourceDocument, i);
|
||||
contentStream.drawForm(form);
|
||||
|
||||
contentStream.restoreGraphicsState();
|
||||
contentStream.close();
|
||||
}
|
||||
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
outputDocument.save(baos);
|
||||
outputDocument.close();
|
||||
sourceDocument.close();
|
||||
|
||||
return WebResponseUtils.bytesToWebResponse(
|
||||
baos.toByteArray(),
|
||||
GeneralUtils.generateFilename(file.getOriginalFilename(), "_scaled.pdf"));
|
||||
}
|
||||
|
||||
private PDRectangle getTargetSize(String targetPDRectangle, PDDocument sourceDocument) {
|
||||
private static PDRectangle getTargetSize(String targetPDRectangle, PDDocument sourceDocument) {
|
||||
if ("KEEP".equals(targetPDRectangle)) {
|
||||
if (sourceDocument.getNumberOfPages() == 0) {
|
||||
// Do not return null here; throw a clear exception so callers don't get a nullable
|
||||
// PDRectangle.
|
||||
throw ExceptionUtils.createInvalidPageSizeException("KEEP");
|
||||
}
|
||||
|
||||
// use the first page to determine the target page size
|
||||
PDPage sourcePage = sourceDocument.getPage(0);
|
||||
PDRectangle sourceSize = sourcePage.getMediaBox();
|
||||
|
||||
if (sourceSize == null) {
|
||||
// If media box is unexpectedly null, treat it as invalid
|
||||
throw ExceptionUtils.createInvalidPageSizeException("KEEP");
|
||||
}
|
||||
|
||||
@@ -129,9 +66,10 @@ public class ScalePagesController {
|
||||
throw ExceptionUtils.createInvalidPageSizeException(targetPDRectangle);
|
||||
}
|
||||
|
||||
private Map<String, PDRectangle> getSizeMap() {
|
||||
private static Map<String, PDRectangle> getSizeMap() {
|
||||
Map<String, PDRectangle> sizeMap = new HashMap<>();
|
||||
// Add A0 - A6
|
||||
|
||||
// Portrait sizes (A0-A6)
|
||||
sizeMap.put("A0", PDRectangle.A0);
|
||||
sizeMap.put("A1", PDRectangle.A1);
|
||||
sizeMap.put("A2", PDRectangle.A2);
|
||||
@@ -140,10 +78,105 @@ public class ScalePagesController {
|
||||
sizeMap.put("A5", PDRectangle.A5);
|
||||
sizeMap.put("A6", PDRectangle.A6);
|
||||
|
||||
// Add other sizes
|
||||
// Landscape sizes (A0-A6)
|
||||
sizeMap.put(
|
||||
"A0_LANDSCAPE",
|
||||
new PDRectangle(PDRectangle.A0.getHeight(), PDRectangle.A0.getWidth()));
|
||||
sizeMap.put(
|
||||
"A1_LANDSCAPE",
|
||||
new PDRectangle(PDRectangle.A1.getHeight(), PDRectangle.A1.getWidth()));
|
||||
sizeMap.put(
|
||||
"A2_LANDSCAPE",
|
||||
new PDRectangle(PDRectangle.A2.getHeight(), PDRectangle.A2.getWidth()));
|
||||
sizeMap.put(
|
||||
"A3_LANDSCAPE",
|
||||
new PDRectangle(PDRectangle.A3.getHeight(), PDRectangle.A3.getWidth()));
|
||||
sizeMap.put(
|
||||
"A4_LANDSCAPE",
|
||||
new PDRectangle(PDRectangle.A4.getHeight(), PDRectangle.A4.getWidth()));
|
||||
sizeMap.put(
|
||||
"A5_LANDSCAPE",
|
||||
new PDRectangle(PDRectangle.A5.getHeight(), PDRectangle.A5.getWidth()));
|
||||
sizeMap.put(
|
||||
"A6_LANDSCAPE",
|
||||
new PDRectangle(PDRectangle.A6.getHeight(), PDRectangle.A6.getWidth()));
|
||||
|
||||
// Portrait US sizes
|
||||
sizeMap.put("LETTER", PDRectangle.LETTER);
|
||||
sizeMap.put("LEGAL", PDRectangle.LEGAL);
|
||||
|
||||
// Landscape US sizes
|
||||
sizeMap.put(
|
||||
"LETTER_LANDSCAPE",
|
||||
new PDRectangle(PDRectangle.LETTER.getHeight(), PDRectangle.LETTER.getWidth()));
|
||||
sizeMap.put(
|
||||
"LEGAL_LANDSCAPE",
|
||||
new PDRectangle(PDRectangle.LEGAL.getHeight(), PDRectangle.LEGAL.getWidth()));
|
||||
|
||||
return sizeMap;
|
||||
}
|
||||
|
||||
@PostMapping(value = "/scale-pages", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||
@Operation(
|
||||
summary = "Change the size of a PDF page/document",
|
||||
description =
|
||||
"This operation takes an input PDF file and the size to scale the pages to in"
|
||||
+ " the output PDF file. Input:PDF Output:PDF Type:SISO")
|
||||
public ResponseEntity<byte[]> scalePages(@ModelAttribute ScalePagesRequest request)
|
||||
throws IOException {
|
||||
MultipartFile file = request.getFileInput();
|
||||
String targetPDRectangle = request.getPageSize();
|
||||
float scaleFactor = request.getScaleFactor();
|
||||
|
||||
try (PDDocument sourceDocument = pdfDocumentFactory.load(file);
|
||||
PDDocument outputDocument =
|
||||
pdfDocumentFactory.createNewDocumentBasedOnOldDocument(sourceDocument);
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream()) {
|
||||
|
||||
PDRectangle targetSize = getTargetSize(targetPDRectangle, sourceDocument);
|
||||
|
||||
// Create LayerUtility once outside the loop for better performance
|
||||
LayerUtility layerUtility = new LayerUtility(outputDocument);
|
||||
|
||||
int totalPages = sourceDocument.getNumberOfPages();
|
||||
for (int i = 0; i < totalPages; i++) {
|
||||
PDPage sourcePage = sourceDocument.getPage(i);
|
||||
PDRectangle sourceSize = sourcePage.getMediaBox();
|
||||
|
||||
float scaleWidth = targetSize.getWidth() / sourceSize.getWidth();
|
||||
float scaleHeight = targetSize.getHeight() / sourceSize.getHeight();
|
||||
float scale = Math.min(scaleWidth, scaleHeight) * scaleFactor;
|
||||
|
||||
PDPage newPage = new PDPage(targetSize);
|
||||
outputDocument.addPage(newPage);
|
||||
|
||||
try (PDPageContentStream contentStream =
|
||||
new PDPageContentStream(
|
||||
outputDocument,
|
||||
newPage,
|
||||
PDPageContentStream.AppendMode.APPEND,
|
||||
true,
|
||||
true)) {
|
||||
|
||||
float x = (targetSize.getWidth() - sourceSize.getWidth() * scale) / 2;
|
||||
float y = (targetSize.getHeight() - sourceSize.getHeight() * scale) / 2;
|
||||
|
||||
contentStream.saveGraphicsState();
|
||||
contentStream.transform(Matrix.getTranslateInstance(x, y));
|
||||
contentStream.transform(Matrix.getScaleInstance(scale, scale));
|
||||
|
||||
PDFormXObject form = layerUtility.importPageAsForm(sourceDocument, i);
|
||||
contentStream.drawForm(form);
|
||||
|
||||
contentStream.restoreGraphicsState();
|
||||
}
|
||||
}
|
||||
|
||||
outputDocument.save(baos);
|
||||
|
||||
return WebResponseUtils.bytesToWebResponse(
|
||||
baos.toByteArray(),
|
||||
GeneralUtils.generateFilename(file.getOriginalFilename(), "_scaled.pdf"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+6
-6
@@ -63,7 +63,7 @@ public class SettingsController {
|
||||
"defaultLocale", applicationProperties.getSystem().getDefaultLocale(),
|
||||
"showUpdate", applicationProperties.getSystem().isShowUpdate(),
|
||||
"showUpdateOnlyAdmin",
|
||||
applicationProperties.getSystem().getShowUpdateOnlyAdmin(),
|
||||
applicationProperties.getSystem().isShowUpdateOnlyAdmin(),
|
||||
"customHTMLFiles", applicationProperties.getSystem().isCustomHTMLFiles(),
|
||||
"fileUploadLimit", applicationProperties.getSystem().getFileUploadLimit()));
|
||||
return ResponseEntity.ok(settings);
|
||||
@@ -123,7 +123,7 @@ public class SettingsController {
|
||||
Map<String, Object> settings = new HashMap<>();
|
||||
ApplicationProperties.Security security = applicationProperties.getSecurity();
|
||||
|
||||
settings.put("enableLogin", security.getEnableLogin());
|
||||
settings.put("enableLogin", security.isEnableLogin());
|
||||
settings.put("loginMethod", security.getLoginMethod());
|
||||
settings.put("loginAttemptCount", security.getLoginAttemptCount());
|
||||
settings.put("loginResetTimeMinutes", security.getLoginResetTimeMinutes());
|
||||
@@ -351,8 +351,8 @@ public class SettingsController {
|
||||
Map<String, Object> settings = new HashMap<>();
|
||||
|
||||
settings.put("enableAnalytics", applicationProperties.getSystem().getEnableAnalytics());
|
||||
settings.put("googleVisibility", applicationProperties.getSystem().getGooglevisibility());
|
||||
settings.put("metricsEnabled", applicationProperties.getMetrics().getEnabled());
|
||||
settings.put("googleVisibility", applicationProperties.getSystem().isGooglevisibility());
|
||||
settings.put("metricsEnabled", applicationProperties.getMetrics().isEnabled());
|
||||
|
||||
return ResponseEntity.ok(settings);
|
||||
}
|
||||
@@ -394,9 +394,9 @@ public class SettingsController {
|
||||
settings.put("endpoints", applicationProperties.getEndpoints());
|
||||
settings.put(
|
||||
"enableAlphaFunctionality",
|
||||
applicationProperties.getSystem().getEnableAlphaFunctionality());
|
||||
applicationProperties.getSystem().isEnableAlphaFunctionality());
|
||||
settings.put("maxDPI", applicationProperties.getSystem().getMaxDPI());
|
||||
settings.put("enableUrlToPDF", applicationProperties.getSystem().getEnableUrlToPDF());
|
||||
settings.put("enableUrlToPDF", applicationProperties.getSystem().isEnableUrlToPDF());
|
||||
settings.put("customPaths", applicationProperties.getSystem().getCustomPaths());
|
||||
settings.put(
|
||||
"tempFileManagement", applicationProperties.getSystem().getTempFileManagement());
|
||||
|
||||
+6
-33
@@ -52,15 +52,12 @@ public class SplitPDFController {
|
||||
public ResponseEntity<byte[]> splitPdf(@ModelAttribute PDFWithPageNums request)
|
||||
throws IOException {
|
||||
|
||||
PDDocument document = null;
|
||||
List<ByteArrayOutputStream> splitDocumentsBoas = new ArrayList<>();
|
||||
TempFile outputTempFile = null;
|
||||
MultipartFile file = request.getFileInput();
|
||||
|
||||
try {
|
||||
outputTempFile = new TempFile(tempFileManager, ".zip");
|
||||
try (TempFile outputTempFile = new TempFile(tempFileManager, ".zip");
|
||||
PDDocument document = pdfDocumentFactory.load(file)) {
|
||||
|
||||
MultipartFile file = request.getFileInput();
|
||||
document = pdfDocumentFactory.load(file);
|
||||
List<ByteArrayOutputStream> splitDocumentsBoas = new ArrayList<>();
|
||||
|
||||
int totalPages = document.getNumberOfPages();
|
||||
List<Integer> pageNumbers = request.getPageNumbersList(document, false);
|
||||
@@ -78,7 +75,8 @@ public class SplitPDFController {
|
||||
int previousPageNumber = 0;
|
||||
for (int splitPoint : pageNumbers) {
|
||||
try (PDDocument splitDocument =
|
||||
pdfDocumentFactory.createNewDocumentBasedOnOldDocument(document)) {
|
||||
pdfDocumentFactory.createNewDocumentBasedOnOldDocument(document);
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream()) {
|
||||
for (int i = previousPageNumber; i <= splitPoint; i++) {
|
||||
PDPage page = document.getPage(i);
|
||||
splitDocument.addPage(page);
|
||||
@@ -89,7 +87,6 @@ public class SplitPDFController {
|
||||
// Transfer metadata to split pdf
|
||||
// PdfMetadataService.setMetadataToPdf(splitDocument, metadata);
|
||||
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
splitDocument.save(baos);
|
||||
splitDocumentsBoas.add(baos);
|
||||
} catch (Exception e) {
|
||||
@@ -98,8 +95,6 @@ public class SplitPDFController {
|
||||
}
|
||||
}
|
||||
|
||||
document.close();
|
||||
|
||||
String baseFilename = GeneralUtils.removeExtension(file.getOriginalFilename());
|
||||
|
||||
try (ZipOutputStream zipOut =
|
||||
@@ -131,28 +126,6 @@ public class SplitPDFController {
|
||||
GeneralUtils.generateFilename(file.getOriginalFilename(), "_split.zip");
|
||||
return WebResponseUtils.bytesToWebResponse(
|
||||
data, zipFilename, MediaType.APPLICATION_OCTET_STREAM);
|
||||
|
||||
} finally {
|
||||
try {
|
||||
// Close the main document
|
||||
if (document != null) {
|
||||
document.close();
|
||||
}
|
||||
|
||||
// Close all ByteArrayOutputStreams
|
||||
for (ByteArrayOutputStream baos : splitDocumentsBoas) {
|
||||
if (baos != null) {
|
||||
baos.close();
|
||||
}
|
||||
}
|
||||
|
||||
// Close the output temporary file
|
||||
if (outputTempFile != null) {
|
||||
outputTempFile.close();
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error("Error while cleaning up resources", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+2
-1
@@ -5,6 +5,7 @@ import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.zip.ZipEntry;
|
||||
import java.util.zip.ZipOutputStream;
|
||||
|
||||
@@ -242,7 +243,7 @@ public class SplitPdfByChaptersController {
|
||||
// split files will be named as "[FILE_NUMBER] [BOOKMARK_TITLE].pdf"
|
||||
|
||||
String fileName =
|
||||
String.format(fileNumberFormatter, i)
|
||||
String.format(Locale.ROOT, fileNumberFormatter, i)
|
||||
+ bookmarks.get(i).getTitle()
|
||||
+ ".pdf";
|
||||
ByteArrayOutputStream baos = splitDocumentsBoas.get(i);
|
||||
|
||||
+118
-43
@@ -4,8 +4,7 @@ import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.OutputStream;
|
||||
import java.nio.file.Files;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.*;
|
||||
import java.util.zip.ZipEntry;
|
||||
import java.util.zip.ZipOutputStream;
|
||||
|
||||
@@ -20,25 +19,30 @@ import org.apache.pdfbox.util.Matrix;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.ModelAttribute;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody;
|
||||
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
|
||||
import stirling.software.SPDF.config.swagger.MultiFileResponse;
|
||||
import stirling.software.SPDF.model.SplitTypes;
|
||||
import stirling.software.SPDF.model.api.SplitPdfBySectionsRequest;
|
||||
import stirling.software.common.annotations.AutoJobPostMapping;
|
||||
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.PDFService;
|
||||
import stirling.software.common.util.TempFile;
|
||||
import stirling.software.common.util.TempFileManager;
|
||||
import stirling.software.common.util.WebResponseUtils;
|
||||
|
||||
@GeneralApi
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/general")
|
||||
@Tag(name = "General", description = "General APIs")
|
||||
@RequiredArgsConstructor
|
||||
public class SplitPdfBySectionsController {
|
||||
|
||||
@@ -46,28 +50,35 @@ public class SplitPdfBySectionsController {
|
||||
private final TempFileManager tempFileManager;
|
||||
private final PDFService pdfService;
|
||||
|
||||
@AutoJobPostMapping(
|
||||
value = "/split-pdf-by-sections",
|
||||
consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||
@MultiFileResponse
|
||||
@PostMapping(value = "/split-pdf-by-sections", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||
@Operation(
|
||||
summary = "Split PDF pages into smaller sections",
|
||||
description =
|
||||
"Split each page of a PDF into smaller sections based on the user's choice"
|
||||
+ " (halves, thirds, quarters, etc.), both vertically and horizontally."
|
||||
+ " which page to split, and how to split"
|
||||
+ " ( halves, thirds, quarters, etc.), both vertically and horizontally."
|
||||
+ " Input:PDF Output:ZIP-PDF Type:SISO")
|
||||
public ResponseEntity<StreamingResponseBody> splitPdf(
|
||||
@ModelAttribute SplitPdfBySectionsRequest request) throws Exception {
|
||||
List<ByteArrayOutputStream> splitDocumentsBoas = new ArrayList<>();
|
||||
|
||||
MultipartFile file = request.getFileInput();
|
||||
String pageNumbers = request.getPageNumbers();
|
||||
SplitTypes splitMode =
|
||||
Optional.ofNullable(request.getSplitMode())
|
||||
.map(SplitTypes::valueOf)
|
||||
.orElse(SplitTypes.SPLIT_ALL);
|
||||
|
||||
PDDocument sourceDocument = pdfDocumentFactory.load(file);
|
||||
|
||||
Set<Integer> pagesToSplit =
|
||||
getPagesToSplit(pageNumbers, splitMode, sourceDocument.getNumberOfPages());
|
||||
|
||||
// Process the PDF based on split parameters
|
||||
int horiz = request.getHorizontalDivisions() + 1;
|
||||
int verti = request.getVerticalDivisions() + 1;
|
||||
boolean merge = Boolean.TRUE.equals(request.getMerge());
|
||||
List<PDDocument> splitDocuments = splitPdfPages(sourceDocument, verti, horiz);
|
||||
List<PDDocument> splitDocuments = splitPdfPages(sourceDocument, verti, horiz, pagesToSplit);
|
||||
|
||||
String filename = GeneralUtils.generateFilename(file.getOriginalFilename(), "_split.pdf");
|
||||
if (merge) {
|
||||
@@ -109,52 +120,116 @@ public class SplitPdfBySectionsController {
|
||||
return WebResponseUtils.zipFileToWebResponse(zipTempFile, filename + "_split.zip");
|
||||
}
|
||||
|
||||
// Based on the mode, get the pages that need to be split and return the pages set
|
||||
private Set<Integer> getPagesToSplit(String pageNumbers, SplitTypes splitMode, int totalPages) {
|
||||
Set<Integer> pagesToSplit = new HashSet<>();
|
||||
|
||||
switch (splitMode) {
|
||||
case CUSTOM:
|
||||
if (pageNumbers == null || pageNumbers.isBlank()) {
|
||||
throw ExceptionUtils.createIllegalArgumentException(
|
||||
"error.argumentRequired",
|
||||
"{0} is required for {1} mode",
|
||||
"page numbers",
|
||||
"custom");
|
||||
}
|
||||
String[] pageOrderArr = pageNumbers.split(",");
|
||||
List<Integer> pageListToSplit =
|
||||
GeneralUtils.parsePageList(pageOrderArr, totalPages, false);
|
||||
pagesToSplit.addAll(pageListToSplit);
|
||||
break;
|
||||
|
||||
case SPLIT_ALL:
|
||||
for (int i = 0; i < totalPages; i++) {
|
||||
pagesToSplit.add(i);
|
||||
}
|
||||
break;
|
||||
|
||||
case SPLIT_ALL_EXCEPT_FIRST:
|
||||
for (int i = 1; i < totalPages; i++) {
|
||||
pagesToSplit.add(i);
|
||||
}
|
||||
break;
|
||||
|
||||
case SPLIT_ALL_EXCEPT_LAST:
|
||||
for (int i = 0; i < totalPages - 1; i++) {
|
||||
pagesToSplit.add(i);
|
||||
}
|
||||
break;
|
||||
|
||||
case SPLIT_ALL_EXCEPT_FIRST_AND_LAST:
|
||||
for (int i = 1; i < totalPages - 1; i++) {
|
||||
pagesToSplit.add(i);
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
throw ExceptionUtils.createIllegalArgumentException(
|
||||
"error.invalidFormat", "Invalid {0} format: {1}", "split mode", splitMode);
|
||||
}
|
||||
|
||||
return pagesToSplit;
|
||||
}
|
||||
|
||||
public List<PDDocument> splitPdfPages(
|
||||
PDDocument document, int horizontalDivisions, int verticalDivisions)
|
||||
PDDocument document,
|
||||
int horizontalDivisions,
|
||||
int verticalDivisions,
|
||||
Set<Integer> pagesToSplit)
|
||||
throws IOException {
|
||||
List<PDDocument> splitDocuments = new ArrayList<>();
|
||||
|
||||
int pageIndex = 0;
|
||||
for (PDPage originalPage : document.getPages()) {
|
||||
PDRectangle originalMediaBox = originalPage.getMediaBox();
|
||||
float width = originalMediaBox.getWidth();
|
||||
float height = originalMediaBox.getHeight();
|
||||
float subPageWidth = width / horizontalDivisions;
|
||||
float subPageHeight = height / verticalDivisions;
|
||||
// If current page is not to split, add it to the splitDocuments directly.
|
||||
if (!pagesToSplit.contains(pageIndex)) {
|
||||
PDDocument newDoc = pdfDocumentFactory.createNewDocument();
|
||||
newDoc.addPage(originalPage);
|
||||
splitDocuments.add(newDoc);
|
||||
} else {
|
||||
// Otherwise, split current page.
|
||||
PDRectangle originalMediaBox = originalPage.getMediaBox();
|
||||
float width = originalMediaBox.getWidth();
|
||||
float height = originalMediaBox.getHeight();
|
||||
float subPageWidth = width / horizontalDivisions;
|
||||
float subPageHeight = height / verticalDivisions;
|
||||
|
||||
LayerUtility layerUtility = new LayerUtility(document);
|
||||
LayerUtility layerUtility = new LayerUtility(document);
|
||||
|
||||
for (int i = 0; i < horizontalDivisions; i++) {
|
||||
for (int j = 0; j < verticalDivisions; j++) {
|
||||
PDDocument subDoc = new PDDocument();
|
||||
PDPage subPage = new PDPage(new PDRectangle(subPageWidth, subPageHeight));
|
||||
subDoc.addPage(subPage);
|
||||
for (int i = 0; i < horizontalDivisions; i++) {
|
||||
for (int j = 0; j < verticalDivisions; j++) {
|
||||
PDDocument subDoc = new PDDocument();
|
||||
PDPage subPage = new PDPage(new PDRectangle(subPageWidth, subPageHeight));
|
||||
subDoc.addPage(subPage);
|
||||
|
||||
PDFormXObject form =
|
||||
layerUtility.importPageAsForm(
|
||||
document, document.getPages().indexOf(originalPage));
|
||||
PDFormXObject form =
|
||||
layerUtility.importPageAsForm(
|
||||
document, document.getPages().indexOf(originalPage));
|
||||
|
||||
try (PDPageContentStream contentStream =
|
||||
new PDPageContentStream(
|
||||
subDoc, subPage, AppendMode.APPEND, true, true)) {
|
||||
// Set clipping area and position
|
||||
float translateX = -subPageWidth * i;
|
||||
try (PDPageContentStream contentStream =
|
||||
new PDPageContentStream(
|
||||
subDoc, subPage, AppendMode.APPEND, true, true)) {
|
||||
// Set clipping area and position
|
||||
float translateX = -subPageWidth * i;
|
||||
|
||||
// float translateY = height - subPageHeight * (verticalDivisions - j);
|
||||
float translateY = -subPageHeight * (verticalDivisions - 1 - j);
|
||||
// float translateY = height - subPageHeight * (verticalDivisions - j);
|
||||
float translateY = -subPageHeight * (verticalDivisions - 1 - j);
|
||||
|
||||
contentStream.saveGraphicsState();
|
||||
contentStream.addRect(0, 0, subPageWidth, subPageHeight);
|
||||
contentStream.clip();
|
||||
contentStream.transform(new Matrix(1, 0, 0, 1, translateX, translateY));
|
||||
contentStream.saveGraphicsState();
|
||||
contentStream.addRect(0, 0, subPageWidth, subPageHeight);
|
||||
contentStream.clip();
|
||||
contentStream.transform(new Matrix(1, 0, 0, 1, translateX, translateY));
|
||||
|
||||
// Draw the form
|
||||
contentStream.drawForm(form);
|
||||
contentStream.restoreGraphicsState();
|
||||
// Draw the form
|
||||
contentStream.drawForm(form);
|
||||
contentStream.restoreGraphicsState();
|
||||
}
|
||||
|
||||
splitDocuments.add(subDoc);
|
||||
}
|
||||
|
||||
splitDocuments.add(subDoc);
|
||||
}
|
||||
}
|
||||
pageIndex++;
|
||||
}
|
||||
|
||||
return splitDocuments;
|
||||
|
||||
+208
@@ -0,0 +1,208 @@
|
||||
package stirling.software.SPDF.controller.api.converters;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Set;
|
||||
|
||||
import org.apache.commons.io.FilenameUtils;
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.ModelAttribute;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import io.github.pixee.security.Filenames;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.SPDF.config.EndpointConfiguration;
|
||||
import stirling.software.SPDF.model.api.converters.ConvertEbookToPdfRequest;
|
||||
import stirling.software.common.service.CustomPDFDocumentFactory;
|
||||
import stirling.software.common.util.GeneralUtils;
|
||||
import stirling.software.common.util.ProcessExecutor;
|
||||
import stirling.software.common.util.ProcessExecutor.ProcessExecutorResult;
|
||||
import stirling.software.common.util.TempFileManager;
|
||||
import stirling.software.common.util.WebResponseUtils;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/convert")
|
||||
@Tag(name = "Convert", description = "Convert APIs")
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class ConvertEbookToPDFController {
|
||||
|
||||
private static final Set<String> SUPPORTED_EXTENSIONS =
|
||||
Set.of("epub", "mobi", "azw3", "fb2", "txt", "docx");
|
||||
|
||||
private final CustomPDFDocumentFactory pdfDocumentFactory;
|
||||
private final TempFileManager tempFileManager;
|
||||
private final EndpointConfiguration endpointConfiguration;
|
||||
|
||||
private boolean isCalibreEnabled() {
|
||||
return endpointConfiguration.isGroupEnabled("Calibre");
|
||||
}
|
||||
|
||||
private boolean isGhostscriptEnabled() {
|
||||
return endpointConfiguration.isGroupEnabled("Ghostscript");
|
||||
}
|
||||
|
||||
@PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/ebook/pdf")
|
||||
@Operation(
|
||||
summary = "Convert an eBook file to PDF",
|
||||
description =
|
||||
"This endpoint converts common eBook formats (EPUB, MOBI, AZW3, FB2, TXT, DOCX)"
|
||||
+ " to PDF using Calibre. Input:BOOK Output:PDF Type:SISO")
|
||||
public ResponseEntity<byte[]> convertEbookToPdf(
|
||||
@ModelAttribute ConvertEbookToPdfRequest request) throws Exception {
|
||||
if (!isCalibreEnabled()) {
|
||||
throw new IllegalStateException("Calibre support is disabled");
|
||||
}
|
||||
|
||||
MultipartFile inputFile = request.getFileInput();
|
||||
if (inputFile == null || inputFile.isEmpty()) {
|
||||
throw new IllegalArgumentException("No input file provided");
|
||||
}
|
||||
|
||||
boolean optimizeForEbook = Boolean.TRUE.equals(request.getOptimizeForEbook());
|
||||
if (optimizeForEbook && !isGhostscriptEnabled()) {
|
||||
log.warn(
|
||||
"Ghostscript optimization requested but Ghostscript is not enabled/available"
|
||||
+ " for ebook conversion");
|
||||
optimizeForEbook = false;
|
||||
}
|
||||
boolean embedAllFonts = Boolean.TRUE.equals(request.getEmbedAllFonts());
|
||||
boolean includeTableOfContents = Boolean.TRUE.equals(request.getIncludeTableOfContents());
|
||||
boolean includePageNumbers = Boolean.TRUE.equals(request.getIncludePageNumbers());
|
||||
|
||||
String originalFilename = Filenames.toSimpleFileName(inputFile.getOriginalFilename());
|
||||
if (originalFilename == null || originalFilename.isBlank()) {
|
||||
originalFilename = "document";
|
||||
}
|
||||
|
||||
String extension = FilenameUtils.getExtension(originalFilename);
|
||||
if (extension == null || extension.isBlank()) {
|
||||
throw new IllegalArgumentException("Unable to determine file type");
|
||||
}
|
||||
|
||||
String lowerExtension = extension.toLowerCase(Locale.ROOT);
|
||||
if (!SUPPORTED_EXTENSIONS.contains(lowerExtension)) {
|
||||
throw new IllegalArgumentException("Unsupported eBook file extension: " + extension);
|
||||
}
|
||||
|
||||
String baseName = FilenameUtils.getBaseName(originalFilename);
|
||||
if (baseName == null || baseName.isBlank()) {
|
||||
baseName = "document";
|
||||
}
|
||||
|
||||
Path workingDirectory = tempFileManager.createTempDirectory();
|
||||
Path inputPath = workingDirectory.resolve(baseName + "." + lowerExtension);
|
||||
Path outputPath = workingDirectory.resolve(baseName + ".pdf");
|
||||
|
||||
try (InputStream inputStream = inputFile.getInputStream()) {
|
||||
Files.copy(inputStream, inputPath, StandardCopyOption.REPLACE_EXISTING);
|
||||
}
|
||||
|
||||
List<String> command =
|
||||
buildCalibreCommand(
|
||||
inputPath,
|
||||
outputPath,
|
||||
embedAllFonts,
|
||||
includeTableOfContents,
|
||||
includePageNumbers);
|
||||
ProcessExecutorResult result =
|
||||
ProcessExecutor.getInstance(ProcessExecutor.Processes.CALIBRE)
|
||||
.runCommandWithOutputHandling(command, workingDirectory.toFile());
|
||||
|
||||
if (result == null) {
|
||||
throw new IllegalStateException("Calibre conversion returned no result");
|
||||
}
|
||||
|
||||
if (result.getRc() != 0) {
|
||||
String errorMessage = result.getMessages();
|
||||
if (errorMessage == null || errorMessage.isBlank()) {
|
||||
errorMessage = "Calibre conversion failed";
|
||||
}
|
||||
throw new IllegalStateException(errorMessage);
|
||||
}
|
||||
|
||||
if (!Files.exists(outputPath) || Files.size(outputPath) == 0L) {
|
||||
throw new IllegalStateException("Calibre did not produce a PDF output");
|
||||
}
|
||||
|
||||
String outputFilename =
|
||||
GeneralUtils.generateFilename(originalFilename, "_convertedToPDF.pdf");
|
||||
|
||||
try {
|
||||
if (optimizeForEbook) {
|
||||
byte[] pdfBytes = Files.readAllBytes(outputPath);
|
||||
try {
|
||||
byte[] optimizedPdf = GeneralUtils.optimizePdfWithGhostscript(pdfBytes);
|
||||
return WebResponseUtils.bytesToWebResponse(optimizedPdf, outputFilename);
|
||||
} catch (IOException e) {
|
||||
log.warn(
|
||||
"Ghostscript optimization failed for ebook conversion, returning"
|
||||
+ " original PDF",
|
||||
e);
|
||||
return WebResponseUtils.bytesToWebResponse(pdfBytes, outputFilename);
|
||||
}
|
||||
}
|
||||
|
||||
try (PDDocument document = pdfDocumentFactory.load(outputPath.toFile())) {
|
||||
return WebResponseUtils.pdfDocToWebResponse(document, outputFilename);
|
||||
}
|
||||
} finally {
|
||||
cleanupTempFiles(workingDirectory, inputPath, outputPath);
|
||||
}
|
||||
}
|
||||
|
||||
private List<String> buildCalibreCommand(
|
||||
Path inputPath,
|
||||
Path outputPath,
|
||||
boolean embedAllFonts,
|
||||
boolean includeTableOfContents,
|
||||
boolean includePageNumbers) {
|
||||
List<String> command = new ArrayList<>();
|
||||
command.add("ebook-convert");
|
||||
command.add(inputPath.toString());
|
||||
command.add(outputPath.toString());
|
||||
|
||||
if (embedAllFonts) {
|
||||
command.add("--embed-all-fonts");
|
||||
}
|
||||
if (includeTableOfContents) {
|
||||
command.add("--pdf-add-toc");
|
||||
}
|
||||
if (includePageNumbers) {
|
||||
command.add("--pdf-page-numbers");
|
||||
}
|
||||
|
||||
return command;
|
||||
}
|
||||
|
||||
private void cleanupTempFiles(Path workingDirectory, Path inputPath, Path outputPath) {
|
||||
List<Path> pathsToDelete = new ArrayList<>();
|
||||
pathsToDelete.add(inputPath);
|
||||
pathsToDelete.add(outputPath);
|
||||
|
||||
for (Path path : pathsToDelete) {
|
||||
try {
|
||||
Files.deleteIfExists(path);
|
||||
} catch (IOException e) {
|
||||
log.warn("Failed to delete temporary file: {}", path, e);
|
||||
}
|
||||
}
|
||||
tempFileManager.deleteTempDirectory(workingDirectory);
|
||||
}
|
||||
}
|
||||
+5
-4
@@ -2,6 +2,7 @@ package stirling.software.SPDF.controller.api.converters;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Locale;
|
||||
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
import org.springframework.http.HttpStatus;
|
||||
@@ -43,9 +44,9 @@ public class ConvertEmlToPDF {
|
||||
summary = "Convert EML to PDF",
|
||||
description =
|
||||
"This endpoint converts EML (email) files to PDF format with extensive"
|
||||
+ " customization options. Features include font settings, image constraints, display modes, attachment handling,"
|
||||
+ " and HTML debug output. Input: EML file, Output: PDF"
|
||||
+ " or HTML file. Type: SISO")
|
||||
+ " customization options. Features include font settings, image"
|
||||
+ " constraints, display modes, attachment handling, and HTML debug output."
|
||||
+ " Input: EML file, Output: PDF or HTML file. Type: SISO")
|
||||
public ResponseEntity<byte[]> convertEmlToPdf(@ModelAttribute EmlToPdfRequest request) {
|
||||
|
||||
MultipartFile inputFile = request.getFileInput();
|
||||
@@ -65,7 +66,7 @@ public class ConvertEmlToPDF {
|
||||
}
|
||||
|
||||
// Validate file type - support EML
|
||||
String lowerFilename = originalFilename.toLowerCase();
|
||||
String lowerFilename = originalFilename.toLowerCase(Locale.ROOT);
|
||||
if (!lowerFilename.endsWith(".eml")) {
|
||||
log.error("Invalid file type for EML to PDF: {}", originalFilename);
|
||||
return ResponseEntity.badRequest()
|
||||
|
||||
+10
-51
@@ -8,7 +8,7 @@ import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Locale;
|
||||
import java.util.regex.Pattern;
|
||||
import java.util.stream.Stream;
|
||||
import java.util.zip.ZipEntry;
|
||||
@@ -18,7 +18,6 @@ import org.apache.commons.io.FileUtils;
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.apache.pdfbox.pdmodel.PDPage;
|
||||
import org.apache.pdfbox.rendering.ImageType;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.ModelAttribute;
|
||||
@@ -117,7 +116,7 @@ public class ConvertImgPDFController {
|
||||
newPdfBytes,
|
||||
"webp".equalsIgnoreCase(imageFormat)
|
||||
? "png"
|
||||
: imageFormat.toUpperCase(),
|
||||
: imageFormat.toUpperCase(Locale.ROOT),
|
||||
colorTypeResult,
|
||||
singleImage,
|
||||
dpi,
|
||||
@@ -279,19 +278,9 @@ public class ConvertImgPDFController {
|
||||
optimizeForEbook = false;
|
||||
}
|
||||
|
||||
byte[] pdfBytes;
|
||||
try {
|
||||
pdfBytes =
|
||||
CbzUtils.convertCbzToPdf(
|
||||
file, pdfDocumentFactory, tempFileManager, optimizeForEbook);
|
||||
} catch (IllegalArgumentException ex) {
|
||||
String message = ex.getMessage() == null ? "Invalid CBZ file" : ex.getMessage();
|
||||
Map<String, Object> errorBody =
|
||||
Map.of("error", "Invalid CBZ file", "message", message, "trace", "");
|
||||
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.body(errorBody);
|
||||
}
|
||||
byte[] pdfBytes =
|
||||
CbzUtils.convertCbzToPdf(
|
||||
file, pdfDocumentFactory, tempFileManager, optimizeForEbook);
|
||||
|
||||
String filename = createConvertedFilename(file.getOriginalFilename(), "_converted.pdf");
|
||||
|
||||
@@ -313,17 +302,7 @@ public class ConvertImgPDFController {
|
||||
dpi = 300;
|
||||
}
|
||||
|
||||
byte[] cbzBytes;
|
||||
try {
|
||||
cbzBytes = PdfToCbzUtils.convertPdfToCbz(file, dpi, pdfDocumentFactory);
|
||||
} catch (IllegalArgumentException ex) {
|
||||
String message = ex.getMessage() == null ? "Invalid PDF file" : ex.getMessage();
|
||||
Map<String, Object> errorBody =
|
||||
Map.of("error", "Invalid PDF file", "message", message, "trace", "");
|
||||
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.body(errorBody);
|
||||
}
|
||||
byte[] cbzBytes = PdfToCbzUtils.convertPdfToCbz(file, dpi, pdfDocumentFactory);
|
||||
|
||||
String filename = createConvertedFilename(file.getOriginalFilename(), "_converted.cbz");
|
||||
|
||||
@@ -348,19 +327,9 @@ public class ConvertImgPDFController {
|
||||
optimizeForEbook = false;
|
||||
}
|
||||
|
||||
byte[] pdfBytes;
|
||||
try {
|
||||
pdfBytes =
|
||||
CbrUtils.convertCbrToPdf(
|
||||
file, pdfDocumentFactory, tempFileManager, optimizeForEbook);
|
||||
} catch (IllegalArgumentException ex) {
|
||||
String message = ex.getMessage() == null ? "Invalid CBR file" : ex.getMessage();
|
||||
Map<String, Object> errorBody =
|
||||
Map.of("error", "Invalid CBR file", "message", message, "trace", "");
|
||||
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.body(errorBody);
|
||||
}
|
||||
byte[] pdfBytes =
|
||||
CbrUtils.convertCbrToPdf(
|
||||
file, pdfDocumentFactory, tempFileManager, optimizeForEbook);
|
||||
|
||||
String filename = createConvertedFilename(file.getOriginalFilename(), "_converted.pdf");
|
||||
|
||||
@@ -382,17 +351,7 @@ public class ConvertImgPDFController {
|
||||
dpi = 300;
|
||||
}
|
||||
|
||||
byte[] cbrBytes;
|
||||
try {
|
||||
cbrBytes = PdfToCbrUtils.convertPdfToCbr(file, dpi, pdfDocumentFactory);
|
||||
} catch (IllegalArgumentException ex) {
|
||||
String message = ex.getMessage() == null ? "Invalid PDF file" : ex.getMessage();
|
||||
Map<String, Object> errorBody =
|
||||
Map.of("error", "Invalid PDF file", "message", message, "trace", "");
|
||||
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.body(errorBody);
|
||||
}
|
||||
byte[] cbrBytes = PdfToCbrUtils.convertPdfToCbr(file, dpi, pdfDocumentFactory);
|
||||
|
||||
String filename = createConvertedFilename(file.getOriginalFilename(), "_converted.cbr");
|
||||
|
||||
|
||||
+14
-5
@@ -8,6 +8,7 @@ import java.nio.file.Path;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
|
||||
import org.apache.commons.io.FileUtils;
|
||||
import org.apache.commons.io.FilenameUtils;
|
||||
@@ -30,6 +31,7 @@ import stirling.software.common.configuration.RuntimePathConfig;
|
||||
import stirling.software.common.model.api.GeneralFile;
|
||||
import stirling.software.common.service.CustomPDFDocumentFactory;
|
||||
import stirling.software.common.util.CustomHtmlSanitizer;
|
||||
import stirling.software.common.util.ExceptionUtils;
|
||||
import stirling.software.common.util.GeneralUtils;
|
||||
import stirling.software.common.util.ProcessExecutor;
|
||||
import stirling.software.common.util.ProcessExecutor.ProcessExecutorResult;
|
||||
@@ -55,15 +57,16 @@ public class ConvertOfficeController {
|
||||
// Check for valid file extension and sanitize filename
|
||||
String originalFilename = Filenames.toSimpleFileName(inputFile.getOriginalFilename());
|
||||
if (originalFilename == null || originalFilename.isBlank()) {
|
||||
throw new IllegalArgumentException("Missing original filename");
|
||||
throw ExceptionUtils.createFileNoNameException();
|
||||
}
|
||||
|
||||
// Check for valid file extension
|
||||
String extension = FilenameUtils.getExtension(originalFilename);
|
||||
if (extension == null || !isValidFileExtension(extension)) {
|
||||
throw new IllegalArgumentException("Invalid file extension");
|
||||
throw ExceptionUtils.createIllegalArgumentException(
|
||||
"error.invalid.extension", "Invalid file extension: " + extension);
|
||||
}
|
||||
String extensionLower = extension.toLowerCase();
|
||||
String extensionLower = extension.toLowerCase(Locale.ROOT);
|
||||
|
||||
String baseName = FilenameUtils.getBaseName(originalFilename);
|
||||
if (baseName == null || baseName.isBlank()) {
|
||||
@@ -86,6 +89,7 @@ public class ConvertOfficeController {
|
||||
Files.copy(inputFile.getInputStream(), inputPath, StandardCopyOption.REPLACE_EXISTING);
|
||||
}
|
||||
|
||||
Path libreOfficeProfile = null;
|
||||
try {
|
||||
ProcessExecutorResult result;
|
||||
// Run Unoconvert command
|
||||
@@ -105,8 +109,10 @@ public class ConvertOfficeController {
|
||||
.runCommandWithOutputHandling(command);
|
||||
} // Run soffice command
|
||||
else {
|
||||
libreOfficeProfile = Files.createTempDirectory("libreoffice_profile_");
|
||||
List<String> command = new ArrayList<>();
|
||||
command.add("soffice");
|
||||
command.add(runtimePathConfig.getSOfficePath());
|
||||
command.add("-env:UserInstallation=" + libreOfficeProfile.toUri().toString());
|
||||
command.add("--headless");
|
||||
command.add("--nologo");
|
||||
command.add("--convert-to");
|
||||
@@ -137,7 +143,7 @@ public class ConvertOfficeController {
|
||||
p ->
|
||||
p.getFileName()
|
||||
.toString()
|
||||
.toLowerCase()
|
||||
.toLowerCase(Locale.ROOT)
|
||||
.endsWith(".pdf"))
|
||||
.findFirst()
|
||||
.orElse(null);
|
||||
@@ -162,6 +168,9 @@ public class ConvertOfficeController {
|
||||
} catch (IOException e) {
|
||||
log.warn("Failed to delete temp input file: {}", inputPath, e);
|
||||
}
|
||||
if (libreOfficeProfile != null) {
|
||||
FileUtils.deleteQuietly(libreOfficeProfile.toFile());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+204
@@ -0,0 +1,204 @@
|
||||
package stirling.software.SPDF.controller.api.converters;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
import org.apache.commons.io.FilenameUtils;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.ModelAttribute;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import io.github.pixee.security.Filenames;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.SPDF.config.EndpointConfiguration;
|
||||
import stirling.software.SPDF.model.api.converters.ConvertPdfToEpubRequest;
|
||||
import stirling.software.SPDF.model.api.converters.ConvertPdfToEpubRequest.OutputFormat;
|
||||
import stirling.software.SPDF.model.api.converters.ConvertPdfToEpubRequest.TargetDevice;
|
||||
import stirling.software.common.util.GeneralUtils;
|
||||
import stirling.software.common.util.ProcessExecutor;
|
||||
import stirling.software.common.util.ProcessExecutor.ProcessExecutorResult;
|
||||
import stirling.software.common.util.TempFileManager;
|
||||
import stirling.software.common.util.WebResponseUtils;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/convert")
|
||||
@Tag(name = "Convert", description = "Convert APIs")
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class ConvertPDFToEpubController {
|
||||
|
||||
private static final String CALIBRE_GROUP = "Calibre";
|
||||
private static final String DEFAULT_EXTENSION = "pdf";
|
||||
private static final String FILTERED_CSS =
|
||||
"font-family,color,background-color,margin-left,margin-right";
|
||||
private static final String SMART_CHAPTER_EXPRESSION =
|
||||
"//h:*[re:test(., '\\s*Chapter\\s+', 'i')]";
|
||||
|
||||
private final TempFileManager tempFileManager;
|
||||
private final EndpointConfiguration endpointConfiguration;
|
||||
|
||||
private static List<String> buildCalibreCommand(
|
||||
Path inputPath, Path outputPath, boolean detectChapters, TargetDevice targetDevice) {
|
||||
List<String> command = new ArrayList<>();
|
||||
command.add("ebook-convert");
|
||||
command.add(inputPath.toString());
|
||||
command.add(outputPath.toString());
|
||||
|
||||
// Golden defaults
|
||||
command.add("--enable-heuristics");
|
||||
command.add("--insert-blank-line");
|
||||
command.add("--filter-css");
|
||||
command.add(FILTERED_CSS);
|
||||
|
||||
if (detectChapters) {
|
||||
command.add("--chapter");
|
||||
command.add(SMART_CHAPTER_EXPRESSION);
|
||||
}
|
||||
|
||||
if (targetDevice != null) {
|
||||
command.add("--output-profile");
|
||||
command.add(targetDevice.getCalibreProfile());
|
||||
}
|
||||
|
||||
return command;
|
||||
}
|
||||
|
||||
@PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/pdf/epub")
|
||||
@Operation(
|
||||
summary = "Convert PDF to EPUB/AZW3",
|
||||
description =
|
||||
"Convert a PDF file to a high-quality EPUB or AZW3 ebook using Calibre. Input:PDF"
|
||||
+ " Output:EPUB/AZW3 Type:SISO")
|
||||
public ResponseEntity<byte[]> convertPdfToEpub(@ModelAttribute ConvertPdfToEpubRequest request)
|
||||
throws Exception {
|
||||
|
||||
if (!endpointConfiguration.isGroupEnabled(CALIBRE_GROUP)) {
|
||||
throw new IllegalStateException(
|
||||
"Calibre support is disabled. Enable the Calibre group or install Calibre to use"
|
||||
+ " this feature.");
|
||||
}
|
||||
|
||||
MultipartFile inputFile = request.getFileInput();
|
||||
if (inputFile == null || inputFile.isEmpty()) {
|
||||
throw new IllegalArgumentException("No input file provided");
|
||||
}
|
||||
|
||||
boolean detectChapters = !Boolean.FALSE.equals(request.getDetectChapters());
|
||||
TargetDevice targetDevice =
|
||||
request.getTargetDevice() == null
|
||||
? TargetDevice.TABLET_PHONE_IMAGES
|
||||
: request.getTargetDevice();
|
||||
OutputFormat outputFormat =
|
||||
request.getOutputFormat() == null ? OutputFormat.EPUB : request.getOutputFormat();
|
||||
|
||||
String originalFilename = Filenames.toSimpleFileName(inputFile.getOriginalFilename());
|
||||
if (originalFilename == null || originalFilename.isBlank()) {
|
||||
originalFilename = "document." + DEFAULT_EXTENSION;
|
||||
}
|
||||
|
||||
String extension = FilenameUtils.getExtension(originalFilename);
|
||||
if (extension.isBlank()) {
|
||||
throw new IllegalArgumentException("Unable to determine file type");
|
||||
}
|
||||
|
||||
if (!DEFAULT_EXTENSION.equalsIgnoreCase(extension)) {
|
||||
throw new IllegalArgumentException("Input file must be a PDF");
|
||||
}
|
||||
|
||||
String baseName = FilenameUtils.getBaseName(originalFilename);
|
||||
if (baseName == null || baseName.isBlank()) {
|
||||
baseName = "document";
|
||||
}
|
||||
|
||||
Path workingDirectory = null;
|
||||
Path inputPath = null;
|
||||
Path outputPath = null;
|
||||
|
||||
try {
|
||||
workingDirectory = tempFileManager.createTempDirectory();
|
||||
inputPath = workingDirectory.resolve(baseName + "." + DEFAULT_EXTENSION);
|
||||
outputPath = workingDirectory.resolve(baseName + "." + outputFormat.getExtension());
|
||||
|
||||
try (InputStream inputStream = inputFile.getInputStream()) {
|
||||
Files.copy(inputStream, inputPath, StandardCopyOption.REPLACE_EXISTING);
|
||||
}
|
||||
|
||||
List<String> command =
|
||||
buildCalibreCommand(inputPath, outputPath, detectChapters, targetDevice);
|
||||
ProcessExecutorResult result =
|
||||
ProcessExecutor.getInstance(ProcessExecutor.Processes.CALIBRE)
|
||||
.runCommandWithOutputHandling(command, workingDirectory.toFile());
|
||||
|
||||
if (result == null) {
|
||||
throw new IllegalStateException("Calibre conversion returned no result");
|
||||
}
|
||||
|
||||
if (result.getRc() != 0) {
|
||||
String errorMessage = result.getMessages();
|
||||
if (errorMessage == null || errorMessage.isBlank()) {
|
||||
errorMessage = "Calibre conversion failed";
|
||||
}
|
||||
throw new IllegalStateException(errorMessage);
|
||||
}
|
||||
|
||||
if (!Files.exists(outputPath) || Files.size(outputPath) == 0L) {
|
||||
throw new IllegalStateException(
|
||||
"Calibre did not produce a " + outputFormat.name() + " output");
|
||||
}
|
||||
|
||||
String outputFilename =
|
||||
GeneralUtils.generateFilename(
|
||||
originalFilename,
|
||||
"_convertedTo"
|
||||
+ outputFormat.name()
|
||||
+ "."
|
||||
+ outputFormat.getExtension());
|
||||
|
||||
byte[] outputBytes = Files.readAllBytes(outputPath);
|
||||
MediaType mediaType = MediaType.valueOf(outputFormat.getMediaType());
|
||||
return WebResponseUtils.bytesToWebResponse(outputBytes, outputFilename, mediaType);
|
||||
} finally {
|
||||
cleanupTempFiles(workingDirectory, inputPath, outputPath);
|
||||
}
|
||||
}
|
||||
|
||||
private void cleanupTempFiles(Path workingDirectory, Path inputPath, Path outputPath) {
|
||||
if (workingDirectory == null) {
|
||||
return;
|
||||
}
|
||||
List<Path> pathsToDelete = new ArrayList<>();
|
||||
if (inputPath != null) {
|
||||
pathsToDelete.add(inputPath);
|
||||
}
|
||||
if (outputPath != null) {
|
||||
pathsToDelete.add(outputPath);
|
||||
}
|
||||
for (Path path : pathsToDelete) {
|
||||
try {
|
||||
Files.deleteIfExists(path);
|
||||
} catch (IOException e) {
|
||||
log.warn("Failed to delete temporary file: {}", path, e);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
tempFileManager.deleteTempDirectory(workingDirectory);
|
||||
} catch (Exception e) {
|
||||
log.warn("Failed to delete temporary directory: {}", workingDirectory, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
+11
-7
@@ -3,34 +3,38 @@ package stirling.software.SPDF.controller.api.converters;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.ModelAttribute;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
|
||||
import stirling.software.SPDF.config.swagger.HtmlConversionResponse;
|
||||
import stirling.software.common.annotations.AutoJobPostMapping;
|
||||
import stirling.software.common.annotations.api.ConvertApi;
|
||||
import stirling.software.common.configuration.RuntimePathConfig;
|
||||
import stirling.software.common.model.api.PDFFile;
|
||||
import stirling.software.common.util.PDFToFile;
|
||||
import stirling.software.common.util.TempFileManager;
|
||||
|
||||
@ConvertApi
|
||||
@RestController
|
||||
@Tag(name = "Convert", description = "Convert APIs")
|
||||
@RequestMapping("/api/v1/convert")
|
||||
@RequiredArgsConstructor
|
||||
public class ConvertPDFToHtml {
|
||||
|
||||
private final TempFileManager tempFileManager;
|
||||
private final RuntimePathConfig runtimePathConfig;
|
||||
|
||||
@AutoJobPostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/pdf/html")
|
||||
@PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/pdf/html")
|
||||
@Operation(
|
||||
summary = "Convert PDF to HTML",
|
||||
description =
|
||||
"This endpoint converts a PDF file to HTML format. Input:PDF Output:HTML Type:SISO")
|
||||
@HtmlConversionResponse
|
||||
public ResponseEntity<byte[]> processPdfToHTML(@ModelAttribute PDFFile file) throws Exception {
|
||||
MultipartFile inputFile = file.getFileInput();
|
||||
PDFToFile pdfToFile = new PDFToFile(tempFileManager);
|
||||
PDFToFile pdfToFile = new PDFToFile(tempFileManager, runtimePathConfig);
|
||||
return pdfToFile.processPdfToHtml(inputFile);
|
||||
}
|
||||
}
|
||||
|
||||
+17
-19
@@ -7,21 +7,20 @@ import org.apache.pdfbox.text.PDFTextStripper;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.ModelAttribute;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
|
||||
import stirling.software.SPDF.config.swagger.PowerPointConversionResponse;
|
||||
import stirling.software.SPDF.config.swagger.TextPlainConversionResponse;
|
||||
import stirling.software.SPDF.config.swagger.WordConversionResponse;
|
||||
import stirling.software.SPDF.config.swagger.XmlConversionResponse;
|
||||
import stirling.software.SPDF.model.api.converters.PdfToPresentationRequest;
|
||||
import stirling.software.SPDF.model.api.converters.PdfToTextOrRTFRequest;
|
||||
import stirling.software.SPDF.model.api.converters.PdfToWordRequest;
|
||||
import stirling.software.common.annotations.AutoJobPostMapping;
|
||||
import stirling.software.common.annotations.api.ConvertApi;
|
||||
import stirling.software.common.configuration.RuntimePathConfig;
|
||||
import stirling.software.common.model.api.PDFFile;
|
||||
import stirling.software.common.service.CustomPDFDocumentFactory;
|
||||
import stirling.software.common.util.GeneralUtils;
|
||||
@@ -29,15 +28,17 @@ import stirling.software.common.util.PDFToFile;
|
||||
import stirling.software.common.util.TempFileManager;
|
||||
import stirling.software.common.util.WebResponseUtils;
|
||||
|
||||
@ConvertApi
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/convert")
|
||||
@Tag(name = "Convert", description = "Convert APIs")
|
||||
@RequiredArgsConstructor
|
||||
public class ConvertPDFToOffice {
|
||||
|
||||
private final CustomPDFDocumentFactory pdfDocumentFactory;
|
||||
private final TempFileManager tempFileManager;
|
||||
private final RuntimePathConfig runtimePathConfig;
|
||||
|
||||
@AutoJobPostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/pdf/presentation")
|
||||
@PowerPointConversionResponse
|
||||
@PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/pdf/presentation")
|
||||
@Operation(
|
||||
summary = "Convert PDF to Presentation format",
|
||||
description =
|
||||
@@ -48,12 +49,11 @@ public class ConvertPDFToOffice {
|
||||
throws IOException, InterruptedException {
|
||||
MultipartFile inputFile = request.getFileInput();
|
||||
String outputFormat = request.getOutputFormat();
|
||||
PDFToFile pdfToFile = new PDFToFile(tempFileManager);
|
||||
PDFToFile pdfToFile = new PDFToFile(tempFileManager, runtimePathConfig);
|
||||
return pdfToFile.processPdfToOfficeFormat(inputFile, outputFormat, "impress_pdf_import");
|
||||
}
|
||||
|
||||
@AutoJobPostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/pdf/text")
|
||||
@TextPlainConversionResponse
|
||||
@PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/pdf/text")
|
||||
@Operation(
|
||||
summary = "Convert PDF to Text or RTF format",
|
||||
description =
|
||||
@@ -74,13 +74,12 @@ public class ConvertPDFToOffice {
|
||||
MediaType.TEXT_PLAIN);
|
||||
}
|
||||
} else {
|
||||
PDFToFile pdfToFile = new PDFToFile(tempFileManager);
|
||||
PDFToFile pdfToFile = new PDFToFile(tempFileManager, runtimePathConfig);
|
||||
return pdfToFile.processPdfToOfficeFormat(inputFile, outputFormat, "writer_pdf_import");
|
||||
}
|
||||
}
|
||||
|
||||
@AutoJobPostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/pdf/word")
|
||||
@WordConversionResponse
|
||||
@PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/pdf/word")
|
||||
@Operation(
|
||||
summary = "Convert PDF to Word document",
|
||||
description =
|
||||
@@ -90,12 +89,11 @@ public class ConvertPDFToOffice {
|
||||
throws IOException, InterruptedException {
|
||||
MultipartFile inputFile = request.getFileInput();
|
||||
String outputFormat = request.getOutputFormat();
|
||||
PDFToFile pdfToFile = new PDFToFile(tempFileManager);
|
||||
PDFToFile pdfToFile = new PDFToFile(tempFileManager, runtimePathConfig);
|
||||
return pdfToFile.processPdfToOfficeFormat(inputFile, outputFormat, "writer_pdf_import");
|
||||
}
|
||||
|
||||
@AutoJobPostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/pdf/xml")
|
||||
@XmlConversionResponse
|
||||
@PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/pdf/xml")
|
||||
@Operation(
|
||||
summary = "Convert PDF to XML",
|
||||
description =
|
||||
@@ -104,7 +102,7 @@ public class ConvertPDFToOffice {
|
||||
public ResponseEntity<byte[]> processPdfToXML(@ModelAttribute PDFFile file) throws Exception {
|
||||
MultipartFile inputFile = file.getFileInput();
|
||||
|
||||
PDFToFile pdfToFile = new PDFToFile(tempFileManager);
|
||||
PDFToFile pdfToFile = new PDFToFile(tempFileManager, runtimePathConfig);
|
||||
return pdfToFile.processPdfToOfficeFormat(inputFile, "xml", "writer_pdf_import");
|
||||
}
|
||||
}
|
||||
|
||||
+1286
-265
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user