# 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:
Anthony Stirling
2025-12-21 10:40:32 +00:00
committed by GitHub
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
343 changed files with 25208 additions and 6588 deletions
+3 -3
View File
@@ -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,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
@@ -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) {
@@ -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);
}
@@ -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) {
@@ -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 "";
@@ -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,
@@ -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();
}
}
@@ -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);
@@ -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");
@@ -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");
}
}
@@ -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)) {
@@ -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");
}
}
}
@@ -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");
}
}
@@ -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);
@@ -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";
@@ -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);
@@ -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)) {
@@ -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);
@@ -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"));
@@ -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;
@@ -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,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;
@@ -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
@@ -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,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;
@@ -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;
@@ -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;
+3 -3
View File
@@ -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"
@@ -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);
}
}
}
@@ -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(
@@ -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();
@@ -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.
@@ -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) {
@@ -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"));
}
}
}
@@ -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());
@@ -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);
}
}
}
}
@@ -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);
@@ -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;
@@ -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);
}
}
@@ -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()
@@ -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");
@@ -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());
}
}
}
@@ -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);
}
}
}
@@ -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);
}
}
@@ -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");
}
}

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