From 43345021bf68042398db8f23e4130bd783a251d0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bal=C3=A1zs=20Sz=C3=BCcs?= <127139797+balazs-szucs@users.noreply.github.com> Date: Fri, 21 Nov 2025 22:08:56 +0100 Subject: [PATCH] refactor(exceptions): RFC 7807 Problem Details, ResourceBundle i18n, and error factory pattern (#4791) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Description of Changes Implemented RFC 7807 problem-details standard to provide consistent, structured error responses across the application. All exceptions now flow through a centralized `GlobalExceptionHandler` with tailored handlers for password-protected files, corruption, validation, and Spring framework errors. Every response includes localized `errorCode`, contextual hints, actionable guidance, timestamps, and request paths. **Key improvements:** - Unified error handling prevents inconsistent response formats across services - Localized error messages support multiple languages at runtime - Error context (hints, actionRequired field) improves user experience - Structured metadata enables programmatic error handling on client side Reference: https://www.baeldung.com/spring-boot-return-errors-problemdetail ### Exception Handling - Centralized all exception handling in `GlobalExceptionHandler` with specific handlers for password protection, file corruption, validation errors, and Spring exceptions - Refactored `ExceptionUtils` to generate consistent `ProblemDetail` responses with error metadata - Introduced `ErrorCode` enum to centralize error identification and metadata (supports localization) - Added helper methods for creating standardized exceptions across services ### Implementation Details - **Replaced generic exceptions** with `ExceptionUtils` methods across utility classes (`CbrUtils`, `CbzUtils`, `PdfToCbrUtils`, `EmlProcessingUtils`, `ImageProcessingUtils`) for consistent error handling of invalid formats, empty files, and missing resources - **Improved method signatures** in `JobExecutorService` and `AutoJobAspect` by explicitly declaring thrown exceptions - **Refined job execution error handling** by removing unnecessary catch blocks in synchronous execution, allowing exceptions to propagate to the centralized handler - **Enhanced error messages** for unsupported formats (e.g., RAR5 limitations, PDF-to-CBR constraints) with clearer user guidance ### Response Format All errors now include: - `errorCode`: Machine-readable error identifier (localized at runtime) - `title`: Localized error title - `detail`: Context-specific error message - `hints`: Array of actionable suggestions - `actionRequired`: Boolean flag indicating if user action is needed - `timestamp`: Error occurrence time - `path`: Request path where error occurred **Error examples shown in PR include:** corrupt files, invalid passwords, unsupported formats (RAR5), missing images, OOM conditions, and invalid HTML ### Why This Change - Eliminates ad-hoc error handling patterns scattered across services - Provides actionable guidance instead of technical stack traces - Supports error messages in multiple languages without code changes - Aligns with RFC 7807, improving API interoperability - Error context reduces time to diagnose issues ### Error sample (note the unformatted JSON is NOT visible by default but for presenting purposes I clicked on show stack on each error. By default only error banner (top part of error) visible) Corrupt file: image Password-protected file (invalid password): image Unsupported RAR: image No images in RAR/ZIP: image OOM: image Invalid html (on the html to pdf endpoint) image GS conversion fail: image --- ## 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: Balázs Szücs --- .../software/common/aop/AutoJobAspect.java | 5 +- .../common/service/JobExecutorService.java | 40 +- .../software/common/util/CbrUtils.java | 25 +- .../software/common/util/CbzUtils.java | 15 +- .../common/util/EmlProcessingUtils.java | 4 +- .../software/common/util/ExceptionUtils.java | 1423 ++++++++++++++--- .../software/common/util/GeneralUtils.java | 14 +- .../common/util/ImageProcessingUtils.java | 2 +- .../software/common/util/PdfToCbrUtils.java | 41 +- .../software/common/util/PdfToCbzUtils.java | 30 +- .../software/common/util/PdfUtils.java | 84 +- .../common/util/RegexPatternUtils.java | 15 + .../util/misc/InvertFullColorStrategy.java | 16 +- .../service/JobExecutorServiceTest.java | 29 +- .../common/util/ExceptionUtilsTest.java | 35 +- .../SPDF/controller/api/CropController.java | 3 +- .../api/MultiPageLayoutController.java | 7 +- .../controller/api/PdfOverlayController.java | 11 +- .../api/RearrangePagesPDFController.java | 7 +- .../api/SplitPdfBySectionsController.java | 10 +- .../converters/ConvertImgPDFController.java | 58 +- .../converters/ConvertOfficeController.java | 6 +- .../ConvertPdfToVideoController.java | 14 +- .../api/converters/ConvertWebsiteToPDF.java | 8 +- .../converters/PdfVectorExportController.java | 51 +- .../api/misc/AutoSplitPdfController.java | 14 +- .../api/misc/BlankPageController.java | 20 +- .../api/misc/CompressController.java | 78 +- .../api/misc/ExtractImageScansController.java | 17 +- .../api/misc/FlattenController.java | 45 +- .../controller/api/misc/OCRController.java | 20 +- .../api/misc/PrintFileController.java | 4 +- .../controller/api/misc/RepairController.java | 5 +- .../api/misc/ScannerEffectController.java | 24 +- .../controller/api/misc/StampController.java | 13 +- .../controller/api/security/GetInfoOnPDF.java | 9 +- .../api/security/PasswordController.java | 12 +- .../api/security/RedactController.java | 9 +- .../exception/GlobalExceptionHandler.java | 1188 ++++++++++++++ .../SPDF/service/SignatureService.java | 4 +- .../src/main/resources/application.properties | 4 + .../main/resources/messages_en_GB.properties | 292 ++++ .../main/resources/static/js/DecryptFiles.js | 269 +++- .../main/resources/static/js/downloader.js | 322 +++- .../main/resources/static/js/errorBanner.js | 2 +- .../src/main/resources/static/js/homecard.js | 8 +- .../resources/templates/fragments/common.html | 14 + .../templates/fragments/errorBanner.html | 191 ++- .../fragments/errorBannerPerPage.html | 2 +- .../templates/security/get-info-on-pdf.html | 11 +- .../api/converters/PdfToCbzUtilsTest.java | 2 +- testing/cucumber/features/examples.feature | 3 +- 52 files changed, 3892 insertions(+), 643 deletions(-) create mode 100644 app/core/src/main/java/stirling/software/SPDF/exception/GlobalExceptionHandler.java diff --git a/app/common/src/main/java/stirling/software/common/aop/AutoJobAspect.java b/app/common/src/main/java/stirling/software/common/aop/AutoJobAspect.java index ac36cd0d7..abcc51bf3 100644 --- a/app/common/src/main/java/stirling/software/common/aop/AutoJobAspect.java +++ b/app/common/src/main/java/stirling/software/common/aop/AutoJobAspect.java @@ -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")); @@ -109,7 +109,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 jobIdRef = new AtomicReference<>(); diff --git a/app/common/src/main/java/stirling/software/common/service/JobExecutorService.java b/app/common/src/main/java/stirling/software/common/service/JobExecutorService.java index 8cbe0a1be..35eb97edc 100644 --- a/app/common/src/main/java/stirling/software/common/service/JobExecutorService.java +++ b/app/common/src/main/java/stirling/software/common/service/JobExecutorService.java @@ -2,7 +2,6 @@ package stirling.software.common.service; import java.io.IOException; import java.util.Locale; -import java.util.Map; import java.util.UUID; import java.util.concurrent.CompletableFuture; import java.util.concurrent.ExecutorService; @@ -65,8 +64,9 @@ public class JobExecutorService { * @param async Whether to run the job asynchronously * @param work The work to be done * @return The response + * @throws Exception if the job execution fails */ - public ResponseEntity runJobGeneric(boolean async, Supplier work) { + public ResponseEntity runJobGeneric(boolean async, Supplier work) throws Exception { return runJobGeneric(async, work, -1); } @@ -77,9 +77,10 @@ public class JobExecutorService { * @param work The work to be done * @param customTimeoutMs Custom timeout in milliseconds, or -1 to use the default * @return The response + * @throws Exception if the job execution fails */ public ResponseEntity runJobGeneric( - boolean async, Supplier work, long customTimeoutMs) { + boolean async, Supplier work, long customTimeoutMs) throws Exception { return runJobGeneric(async, work, customTimeoutMs, false, 50); } @@ -92,13 +93,15 @@ public class JobExecutorService { * @param queueable Whether this job can be queued when system resources are limited * @param resourceWeight The resource weight of this job (1-100) * @return The response + * @throws Exception if the job execution fails */ public ResponseEntity runJobGeneric( boolean async, Supplier work, long customTimeoutMs, boolean queueable, - int resourceWeight) { + int resourceWeight) + throws Exception { String jobId = UUID.randomUUID().toString(); // Store the job ID in the request for potential use by other components @@ -192,29 +195,18 @@ public class JobExecutorService { return ResponseEntity.ok().body(new JobResponse<>(true, jobId, null)); } else { - try { - log.debug("Running sync job with timeout {} ms", timeoutToUse); + log.debug("Running sync job with timeout {} ms", timeoutToUse); - // Execute with timeout - Object result = executeWithTimeout(() -> work.get(), timeoutToUse); + // Execute with timeout + Object result = executeWithTimeout(() -> work.get(), timeoutToUse); - // If the result is already a ResponseEntity, return it directly - if (result instanceof ResponseEntity) { - return (ResponseEntity) result; - } - - // Process different result types - return handleResultForSyncJob(result); - } catch (TimeoutException te) { - log.error("Synchronous job timed out after {} ms", timeoutToUse); - return ResponseEntity.internalServerError() - .body(Map.of("error", "Job timed out after " + timeoutToUse + " ms")); - } catch (Exception e) { - log.error("Error executing synchronous job: {}", e.getMessage(), e); - // Construct a JSON error response - return ResponseEntity.internalServerError() - .body(Map.of("error", "Job failed: " + e.getMessage())); + // If the result is already a ResponseEntity, return it directly + if (result instanceof ResponseEntity) { + return (ResponseEntity) result; } + + // Process different result types + return handleResultForSyncJob(result); } } diff --git a/app/common/src/main/java/stirling/software/common/util/CbrUtils.java b/app/common/src/main/java/stirling/software/common/util/CbrUtils.java index 98adfb79d..c6f9ea78f 100644 --- a/app/common/src/main/java/stirling/software/common/util/CbrUtils.java +++ b/app/common/src/main/java/stirling/software/common/util/CbrUtils.java @@ -59,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 = + throw ExceptionUtils.createCbrInvalidFormatException( "Invalid CBR/RAR archive. The file may be encrypted, corrupted, or" - + " use an unsupported format."; + + " 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."; + + " 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); @@ -172,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(Locale.ROOT); if (!"cbr".equals(extension) && !"rar".equals(extension)) { - throw new IllegalArgumentException("File must be a CBR or RAR archive"); + throw ExceptionUtils.createNotCbrFileException(); } } diff --git a/app/common/src/main/java/stirling/software/common/util/CbzUtils.java b/app/common/src/main/java/stirling/software/common/util/CbzUtils.java index 1b0910e39..d9fb40189 100644 --- a/app/common/src/main/java/stirling/software/common/util/CbzUtils.java +++ b/app/common/src/main/java/stirling/software/common/util/CbzUtils.java @@ -56,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(); @@ -84,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) { @@ -107,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); @@ -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(Locale.ROOT); if (!"cbz".equals(extension) && !"zip".equals(extension)) { - throw new IllegalArgumentException("File must be a CBZ or ZIP archive"); + throw ExceptionUtils.createNotCbzFileException(); } } diff --git a/app/common/src/main/java/stirling/software/common/util/EmlProcessingUtils.java b/app/common/src/main/java/stirling/software/common/util/EmlProcessingUtils.java index dc2c03478..69b181161 100644 --- a/app/common/src/main/java/stirling/software/common/util/EmlProcessingUtils.java +++ b/app/common/src/main/java/stirling/software/common/util/EmlProcessingUtils.java @@ -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(); } } diff --git a/app/common/src/main/java/stirling/software/common/util/ExceptionUtils.java b/app/common/src/main/java/stirling/software/common/util/ExceptionUtils.java index be2874333..02b8da9ee 100644 --- a/app/common/src/main/java/stirling/software/common/util/ExceptionUtils.java +++ b/app/common/src/main/java/stirling/software/common/util/ExceptionUtils.java @@ -2,108 +2,595 @@ package stirling.software.common.util; import java.io.IOException; import java.text.MessageFormat; +import java.util.ArrayList; +import java.util.List; import java.util.Locale; +import java.util.MissingResourceException; +import java.util.ResourceBundle; +import java.util.Set; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import org.apache.pdfbox.pdmodel.PDPage; +import org.apache.pdfbox.pdmodel.common.PDRectangle; + +import lombok.Getter; import lombok.extern.slf4j.Slf4j; /** * Utility class for handling exceptions with internationalized error messages. Provides consistent * error handling and user-friendly messages across the application. + * + *

This class works in with {@code GlobalExceptionHandler} to provide a complete exception + * handling solution: + * + *

Integration Pattern:

+ * + *
    + *
  1. Exception Creation: Use ExceptionUtils factory methods (e.g., {@link + * #createPdfCorruptedException}) to create typed exceptions with error codes + *
  2. HTTP Response: GlobalExceptionHandler catches these exceptions and + * converts them to RFC 7807 Problem Details responses + *
  3. Internationalization: Both use shared ResourceBundle (messages.properties) + * for consistent localized messages + *
  4. Error Codes: {@link ErrorCode} enum provides structured error tracking + *
+ * + *

Usage Example:

+ * + *
{@code
+ * // In service layer - create exception with ExceptionUtils
+ * try {
+ *     PDDocument doc = PDDocument.load(file);
+ * } catch (IOException e) {
+ *     throw ExceptionUtils.createPdfCorruptedException("during load", e);
+ * }
+ *
+ * // GlobalExceptionHandler automatically catches and converts to:
+ * // HTTP 422 with Problem Detail containing:
+ * // - error code: "E001"
+ * // - localized message from messages.properties
+ * // - RFC 7807 structured response
+ * }
+ * + * @see stirling.software.SPDF.exception.GlobalExceptionHandler */ @Slf4j public class ExceptionUtils { + private static final String MESSAGES_BUNDLE = "messages"; + private static final Object LOCK = new Object(); + private static final Pattern GS_PAGE_PATTERN = + Pattern.compile("Page\\s+(\\d+)", Pattern.CASE_INSENSITIVE); + private static volatile ResourceBundle messages; + /** - * Create an IOException with internationalized message for PDF corruption. + * Load hints for a given error code from the resource bundle. Looks for keys like: + * error.E001.hint.1, error.E001.hint.2, etc. * - * @param cause the original exception - * @return IOException with user-friendly message + * @param code the error code (e.g., "E001") + * @return list of hints */ - public static IOException createPdfCorruptedException(Exception cause) { - return createPdfCorruptedException(null, cause); + public static List getHintsForErrorCode(String code) { + if (code == null) return List.of(); + + ResourceBundle bundle = getMessages(java.util.Locale.getDefault()); + List hints = new java.util.ArrayList<>(); + + int index = 1; + StringBuilder keyBuilder = new StringBuilder("error.").append(code).append(".hint."); + int baseLength = keyBuilder.length(); + + while (true) { + keyBuilder.setLength(baseLength); + String key = keyBuilder.append(index).toString(); + try { + String hint = bundle.getString(key); + hints.add(hint); + index++; + } catch (MissingResourceException e) { + break; + } + } + + return hints.isEmpty() ? List.of() : List.copyOf(hints); } /** - * Create an IOException with internationalized message for PDF corruption with context. + * Load action required text for a given error code from the resource bundle. Looks for key + * like: error.E001.action + * + * @param code the error code (e.g., "E001") + * @return action required text, or null if not found + */ + public static String getActionRequiredForErrorCode(String code) { + if (code == null) return null; + + ResourceBundle bundle = getMessages(java.util.Locale.getDefault()); + String key = "error." + code + ".action"; + + try { + return bundle.getString(key); + } catch (MissingResourceException e) { + return null; + } + } + + /** + * Get or initialize the ResourceBundle with the specified locale. Uses double-checked locking + * for thread-safe lazy initialization. + * + * @param locale the locale for message retrieval + * @return the ResourceBundle instance + */ + private static ResourceBundle getMessages(java.util.Locale locale) { + if (messages == null) { + synchronized (LOCK) { + if (messages == null) { + try { + messages = ResourceBundle.getBundle(MESSAGES_BUNDLE, locale); + } catch (MissingResourceException e) { + log.warn( + "Could not load resource bundle '{}' for locale {}, using default", + MESSAGES_BUNDLE, + locale); + // Create a fallback empty bundle + messages = + new java.util.ListResourceBundle() { + @Override + protected Object[][] getContents() { + return new Object[0][0]; + } + }; + } + } + } + } + return messages; + } + + /** + * Get internationalized message from resource bundle with fallback to default message. + * + * @param messageKey the i18n message key + * @param defaultMessage the default message if i18n is not available + * @param args optional arguments for the message + * @return formatted message + */ + private static String getMessage(String messageKey, String defaultMessage, Object... args) { + String template = defaultMessage; + ResourceBundle bundle = getMessages(java.util.Locale.getDefault()); + + if (messageKey != null) { + try { + template = bundle.getString(messageKey); + } catch (MissingResourceException e) { + log.debug("Message key '{}' not found, using default", messageKey); + } + } + + // Use MessageFormat for {0}, {1} style placeholders (compatible with properties files) + return (args != null && args.length > 0) ? MessageFormat.format(template, args) : template; + } + + /** + * Get internationalized message from ErrorCode enum. + * + * @param errorCode the error code enum + * @param args optional arguments for the message + * @return formatted message + */ + private static String getMessage(ErrorCode errorCode, Object... args) { + requireNonNull(errorCode, "errorCode"); + return getMessage(errorCode.getMessageKey(), errorCode.getDefaultMessage(), args); + } + + /** + * Validate that an object is not null. + * + * @param the type of the object + * @param obj the object to check + * @param name the name of the parameter (for error message) + * @return the object if not null + * @throws IllegalArgumentException if the object is null + */ + private static T requireNonNull(T obj, String name) { + if (obj == null) { + String message = getMessage("error.nullArgument", "{0} must not be null", name); + throw new IllegalArgumentException(message); + } + return obj; + } + + /** + * Validates that rendering a PDF page at the given DPI will not exceed safe memory limits. This + * should be called BEFORE attempting to render to prevent OOM/NegativeArraySizeException. + * + *

The validation checks if the resulting image dimensions would exceed: + * + *

    + *
  • Java's maximum array size (Integer.MAX_VALUE) + *
  • Practical memory limits (considering bytes per pixel) + *
+ * + *

Usage Example: + * + *

{@code
+     * PDPage page = document.getPage(pageIndex);
+     * ExceptionUtils.validateRenderingDimensions(page, pageIndex + 1, dpi);
+     * // Only render if validation passes
+     * BufferedImage image = renderer.renderImageWithDPI(pageIndex, dpi);
+     * }
+ * + * @param page the PDF page to validate + * @param pageNumber the page number (1-based, for error messages) + * @param dpi the DPI value to use for rendering + * @throws OutOfMemoryDpiException if the dimensions would be too large + */ + public static void validateRenderingDimensions(PDPage page, int pageNumber, int dpi) + throws OutOfMemoryDpiException { + if (page == null) { + return; // Nothing to validate + } + + PDRectangle mediaBox = page.getMediaBox(); + if (mediaBox == null) { + return; // Cannot validate without dimensions + } + + // Get page dimensions in points (1 point = 1/72 inch) + float widthInPoints = mediaBox.getWidth(); + float heightInPoints = mediaBox.getHeight(); + + // Convert to pixels at the given DPI + // Formula: pixels = (points / 72) * dpi + long widthInPixels = Math.round((widthInPoints / 72.0) * dpi); + long heightInPixels = Math.round((heightInPoints / 72.0) * dpi); + + // Check if dimensions exceed Integer.MAX_VALUE + if (widthInPixels > Integer.MAX_VALUE || heightInPixels > Integer.MAX_VALUE) { + log.warn( + "Page {} dimensions too large: {}x{} pixels at {} DPI", + pageNumber, + widthInPixels, + heightInPixels, + dpi); + throw createOutOfMemoryDpiException( + pageNumber, + dpi, + new IllegalArgumentException( + "Dimension exceeds Integer.MAX_VALUE: " + + widthInPixels + + "x" + + heightInPixels)); + } + + // Check if total pixel count would exceed safe limits + // RGB images use 4 bytes per pixel (ARGB), but be conservative + long totalPixels = widthInPixels * heightInPixels; + long estimatedBytes = totalPixels * 4; // 4 bytes per pixel for ARGB + + // Java array max size is Integer.MAX_VALUE elements + // For byte array: Integer.MAX_VALUE bytes + // For int array (image pixels): Integer.MAX_VALUE ints = Integer.MAX_VALUE * 4 bytes + if (totalPixels > Integer.MAX_VALUE) { + log.warn( + "Page {} pixel count too large: {} pixels ({} MB) at {} DPI", + pageNumber, + totalPixels, + estimatedBytes / (1024 * 1024), + dpi); + throw createOutOfMemoryDpiException( + pageNumber, + dpi, + new IllegalArgumentException( + "Total pixel count exceeds safe limit: " + totalPixels)); + } + + // Additional safety check: warn about very large images (> 1GB estimated) + if (estimatedBytes > 1024L * 1024 * 1024) { + log.warn( + "Page {} will create a very large image: {}x{} pixels (~{} MB) at {} DPI. This may cause memory issues.", + pageNumber, + widthInPixels, + heightInPixels, + estimatedBytes / (1024 * 1024), + dpi); + } + } + + /** + * Execute a PDF rendering operation with automatic OutOfMemory exception handling. This wraps + * any rendering operation and automatically converts OutOfMemoryError or + * NegativeArraySizeException into properly typed OutOfMemoryDpiException. + * + *

Usage Example: + * + *

{@code
+     * // Simple - no page number tracking
+     * BufferedImage image = ExceptionUtils.handleOomRendering(
+     *     300,  // dpi
+     *     () -> pdfRenderer.renderImageWithDPI(pageIndex, 300)
+     * );
+     *
+     * // With page number for better error messages
+     * BufferedImage image = ExceptionUtils.handleOomRendering(
+     *     pageIndex + 1,  // page number (1-based)
+     *     300,            // dpi
+     *     () -> pdfRenderer.renderImageWithDPI(pageIndex, 300, ImageType.RGB)
+     * );
+     * }
+ * + * @param the return type of the rendering operation + * @param pageNumber the page number being rendered (1-based, for error messages) + * @param dpi the DPI value used for rendering + * @param operation the rendering operation to execute + * @return the result of the rendering operation + * @throws OutOfMemoryDpiException if OutOfMemoryError or NegativeArraySizeException occurs + * @throws IOException if any other I/O error occurs during rendering + */ + public static T handleOomRendering(int pageNumber, int dpi, RenderOperation operation) + throws IOException { + try { + return operation.render(); + } catch (OutOfMemoryError | NegativeArraySizeException e) { + throw createOutOfMemoryDpiException(pageNumber, dpi, e); + } + } + + /** + * Execute a PDF rendering operation with automatic OutOfMemory exception handling (no page + * number). + * + *

Use this variant when you don't have a specific page number context. + * + * @param the return type of the rendering operation + * @param dpi the DPI value used for rendering + * @param operation the rendering operation to execute + * @return the result of the rendering operation + * @throws OutOfMemoryDpiException if OutOfMemoryError or NegativeArraySizeException occurs + * @throws IOException if any other I/O error occurs during rendering + */ + public static T handleOomRendering(int dpi, RenderOperation operation) + throws IOException { + try { + return operation.render(); + } catch (OutOfMemoryError | NegativeArraySizeException e) { + throw createOutOfMemoryDpiException(dpi, e); + } + } + + /** + * Create IllegalArgumentException from ErrorCode with formatted arguments. + * + * @param errorCode the error code + * @param args optional arguments for message formatting + * @return IllegalArgumentException with formatted message + */ + public static IllegalArgumentException createIllegalArgumentException( + ErrorCode errorCode, Object... args) { + requireNonNull(errorCode, "errorCode"); + String message = getMessage(errorCode, args); + return new IllegalArgumentException(message); + } + + /** + * Create a PdfCorruptedException with internationalized message and context. * * @param context additional context (e.g., "during merge", "during image extraction") * @param cause the original exception - * @return IOException with user-friendly message + * @return PdfCorruptedException with user-friendly message */ - public static IOException createPdfCorruptedException(String context, Exception cause) { + public static PdfCorruptedException createPdfCorruptedException( + String context, Exception cause) { + requireNonNull(cause, "cause"); + String message; if (context != null && !context.isEmpty()) { - message = - String.format( - Locale.ROOT, - "Error %s: PDF file appears to be corrupted or damaged. Please try using the 'Repair PDF' feature first to fix the file before proceeding with this operation.", - context); + String contextKey = "error.pdfCorruptedDuring"; + String defaultMsg = + MessageFormat.format( + "Error {0}: {1}", context, getMessage(ErrorCode.PDF_CORRUPTED)); + message = getMessage(contextKey, defaultMsg, context); } else { message = - "PDF file appears to be corrupted or damaged. Please try using the 'Repair PDF'" - + " feature first to fix the file before proceeding with this operation."; + "PDF file appears to be corrupted or damaged. Please try using the 'Repair PDF' feature first to fix the file before proceeding with this operation."; } - return new IOException(message, cause); + + return new PdfCorruptedException(message, cause, ErrorCode.PDF_CORRUPTED.getCode()); } /** - * Create an IOException with internationalized message for multiple corrupted PDFs. + * Create a PdfCorruptedException for multiple corrupted PDFs. * * @param cause the original exception - * @return IOException with user-friendly message + * @return PdfCorruptedException with user-friendly message */ - public static IOException createMultiplePdfCorruptedException(Exception cause) { - String message = - "One or more PDF files appear to be corrupted or damaged. Please try using the" - + " 'Repair PDF' feature on each file first before attempting to merge them."; - return new IOException(message, cause); + public static PdfCorruptedException createMultiplePdfCorruptedException(Exception cause) { + requireNonNull(cause, "cause"); + String message = getMessage(ErrorCode.PDF_MULTIPLE_CORRUPTED); + return new PdfCorruptedException( + message, cause, ErrorCode.PDF_MULTIPLE_CORRUPTED.getCode()); } /** - * Create an IOException with internationalized message for PDF encryption issues. + * Create a PdfEncryptionException with internationalized message. * * @param cause the original exception - * @return IOException with user-friendly message + * @return PdfEncryptionException with user-friendly message */ - public static IOException createPdfEncryptionException(Exception cause) { - String message = - "The PDF appears to have corrupted encryption data. This can happen when the PDF" - + " was created with incompatible encryption methods. Please try using the" - + " 'Repair PDF' feature first, or contact the document creator for a new" - + " copy."; - return new IOException(message, cause); + public static PdfEncryptionException createPdfEncryptionException(Exception cause) { + requireNonNull(cause, "cause"); + String message = getMessage(ErrorCode.PDF_ENCRYPTION); + return new PdfEncryptionException(message, cause, ErrorCode.PDF_ENCRYPTION.getCode()); } /** - * Create an IOException with internationalized message for PDF password issues. + * Create a PdfPasswordException with internationalized message. * * @param cause the original exception - * @return IOException with user-friendly message + * @return PdfPasswordException with user-friendly message */ - public static IOException createPdfPasswordException(Exception cause) { - String message = - "The PDF Document is passworded and either the password was not provided or was" - + " incorrect"; - return new IOException(message, cause); + public static PdfPasswordException createPdfPasswordException(Exception cause) { + requireNonNull(cause, "cause"); + String message = getMessage(ErrorCode.PDF_PASSWORD); + return new PdfPasswordException(message, cause, ErrorCode.PDF_PASSWORD.getCode()); } /** - * Create an IOException with internationalized message for file processing errors. + * Create a CbrFormatException for corrupted or unsupported CBR/RAR archives. + * + * @param message the error message + * @return CbrFormatException with user-friendly message + */ + public static CbrFormatException createCbrInvalidFormatException(String message) { + String fullMessage = message != null ? message : getMessage(ErrorCode.CBR_INVALID_FORMAT); + return new CbrFormatException(fullMessage, ErrorCode.CBR_INVALID_FORMAT.getCode()); + } + + /** + * Create a CbrFormatException for encrypted CBR/RAR archives. Note: This now uses + * CBR_INVALID_FORMAT as encryption is covered by that error. + * + * @return CbrFormatException with user-friendly message + */ + public static CbrFormatException createCbrEncryptedException() { + String message = getMessage(ErrorCode.CBR_INVALID_FORMAT); + return new CbrFormatException(message, ErrorCode.CBR_INVALID_FORMAT.getCode()); + } + + /** + * Create a CbrFormatException for CBR files with no valid images. + * + * @return CbrFormatException with user-friendly message + */ + public static CbrFormatException createCbrNoImagesException() { + String message = getMessage(ErrorCode.CBR_NO_IMAGES); + return new CbrFormatException(message, ErrorCode.CBR_NO_IMAGES.getCode()); + } + + /** + * Create a CbrFormatException for CBR files where images are corrupted beyond recovery. + * + * @return CbrFormatException with user-friendly message + */ + public static CbrFormatException createCbrCorruptedImagesException() { + String message = getMessage(ErrorCode.CBR_NO_IMAGES); + return new CbrFormatException(message, ErrorCode.CBR_NO_IMAGES.getCode()); + } + + /** + * Create a CbrFormatException for non-CBR files. + * + * @return CbrFormatException with user-friendly message + */ + public static CbrFormatException createNotCbrFileException() { + String message = getMessage(ErrorCode.CBR_NOT_CBR); + return new CbrFormatException(message, ErrorCode.CBR_NOT_CBR.getCode()); + } + + /** + * Create a CbzFormatException for invalid CBZ/ZIP archives. + * + * @param cause the original exception + * @return CbzFormatException with user-friendly message + */ + public static CbzFormatException createCbzInvalidFormatException(Exception cause) { + String message = getMessage(ErrorCode.CBZ_INVALID_FORMAT); + return new CbzFormatException(message, cause, ErrorCode.CBZ_INVALID_FORMAT.getCode()); + } + + /** + * Create a CbzFormatException for empty CBZ archives. Note: This now uses CBZ_INVALID_FORMAT as + * empty archives are covered by that error. + * + * @return CbzFormatException with user-friendly message + */ + public static CbzFormatException createCbzEmptyException() { + String message = getMessage(ErrorCode.CBZ_INVALID_FORMAT); + return new CbzFormatException(message, ErrorCode.CBZ_INVALID_FORMAT.getCode()); + } + + /** + * Create a CbzFormatException for CBZ files with no valid images. + * + * @return CbzFormatException with user-friendly message + */ + public static CbzFormatException createCbzNoImagesException() { + String message = getMessage(ErrorCode.CBZ_NO_IMAGES); + return new CbzFormatException(message, ErrorCode.CBZ_NO_IMAGES.getCode()); + } + + /** + * Create a CbzFormatException for CBZ files where all images are corrupted. Note: This now uses + * CBZ_NO_IMAGES as corrupted images are covered by that error. + * + * @return CbzFormatException with user-friendly message + */ + public static CbzFormatException createCbzCorruptedImagesException() { + String message = getMessage(ErrorCode.CBZ_NO_IMAGES); + return new CbzFormatException(message, ErrorCode.CBZ_NO_IMAGES.getCode()); + } + + /** + * Create a CbzFormatException for non-CBZ files. + * + * @return CbzFormatException with user-friendly message + */ + public static CbzFormatException createNotCbzFileException() { + String message = getMessage(ErrorCode.CBZ_NOT_CBZ); + return new CbzFormatException(message, ErrorCode.CBZ_NOT_CBZ.getCode()); + } + + /** + * Create an EmlFormatException for empty or null EML files. + * + * @return EmlFormatException with user-friendly message + */ + public static EmlFormatException createEmlEmptyException() { + String message = getMessage(ErrorCode.EML_EMPTY); + return new EmlFormatException(message, ErrorCode.EML_EMPTY.getCode()); + } + + /** + * Create an EmlFormatException for invalid EML structure. + * + * @return EmlFormatException with user-friendly message + */ + public static EmlFormatException createEmlInvalidFormatException() { + String message = getMessage(ErrorCode.EML_INVALID_FORMAT); + return new EmlFormatException(message, ErrorCode.EML_INVALID_FORMAT.getCode()); + } + + /** + * Create an IOException for file processing errors. * * @param operation the operation being performed (e.g., "merge", "split", "convert") * @param cause the original exception * @return IOException with user-friendly message */ public static IOException createFileProcessingException(String operation, Exception cause) { + requireNonNull(operation, "operation"); + requireNonNull(cause, "cause"); String message = - String.format( - Locale.ROOT, - "An error occurred while processing the file during %s operation: %s", + getMessage( + ErrorCode.FILE_PROCESSING.getMessageKey(), + ErrorCode.FILE_PROCESSING.getDefaultMessage(), operation, cause.getMessage()); return new IOException(message, cause); } + public static IOException createImageReadException(String filename) { + requireNonNull(filename, "filename"); + String message = + getMessage( + ErrorCode.IMAGE_READ_ERROR.getMessageKey(), + ErrorCode.IMAGE_READ_ERROR.getDefaultMessage(), + filename); + return new IOException(message); + } + /** * Create a generic IOException with internationalized message. * @@ -115,8 +602,8 @@ public class ExceptionUtils { */ public static IOException createIOException( String messageKey, String defaultMessage, Exception cause, Object... args) { - String message = MessageFormat.format(defaultMessage, args); - return new IOException(message, cause); + String message = getMessage(messageKey, defaultMessage, args); + return cause != null ? new IOException(message, cause) : new IOException(message); } /** @@ -130,8 +617,8 @@ public class ExceptionUtils { */ public static RuntimeException createRuntimeException( String messageKey, String defaultMessage, Exception cause, Object... args) { - String message = MessageFormat.format(defaultMessage, args); - return new RuntimeException(message, cause); + String message = getMessage(messageKey, defaultMessage, args); + return cause != null ? new RuntimeException(message, cause) : new RuntimeException(message); } /** @@ -144,117 +631,394 @@ public class ExceptionUtils { */ public static IllegalArgumentException createIllegalArgumentException( String messageKey, String defaultMessage, Object... args) { - String message = MessageFormat.format(defaultMessage, args); + String message = getMessage(messageKey, defaultMessage, args); return new IllegalArgumentException(message); } /** Create file validation exceptions. */ public static IllegalArgumentException createHtmlFileRequiredException() { - return createIllegalArgumentException( - "error.fileFormatRequired", "File must be in {0} format", "HTML or ZIP"); + String message = getMessage(ErrorCode.HTML_FILE_REQUIRED); + return new IllegalArgumentException(message); } public static IllegalArgumentException createPdfFileRequiredException() { - return createIllegalArgumentException( - "error.fileFormatRequired", "File must be in {0} format", "PDF"); + String message = getMessage(ErrorCode.PDF_NOT_PDF); + return new IllegalArgumentException(message); } public static IllegalArgumentException createInvalidPageSizeException(String size) { - return createIllegalArgumentException( - "error.invalidFormat", "Invalid {0} format: {1}", "page size", size); + requireNonNull(size, "size"); + String message = + getMessage( + ErrorCode.INVALID_PAGE_SIZE.getMessageKey(), + ErrorCode.INVALID_PAGE_SIZE.getDefaultMessage(), + size); + return new IllegalArgumentException(message); + } + + public static IllegalArgumentException createFileNullOrEmptyException() { + String message = getMessage(ErrorCode.FILE_NULL_OR_EMPTY); + return new IllegalArgumentException(message); + } + + public static IllegalArgumentException createFileNoNameException() { + String message = getMessage(ErrorCode.FILE_NO_NAME); + return new IllegalArgumentException(message); + } + + public static IllegalArgumentException createPdfNoPages() { + String message = getMessage(ErrorCode.PDF_NO_PAGES); + return new IllegalArgumentException(message); } /** Create OCR-related exceptions. */ public static IOException createOcrLanguageRequiredException() { - return createIOException( - "error.optionsNotSpecified", "{0} options are not specified", null, "OCR language"); + String message = getMessage(ErrorCode.OCR_LANGUAGE_REQUIRED); + return new IOException(message); } public static IOException createOcrInvalidLanguagesException() { - return createIOException( - "error.invalidFormat", - "Invalid {0} format: {1}", - null, - "OCR languages", - "none of the selected languages are valid"); + String message = getMessage(ErrorCode.OCR_INVALID_LANGUAGES); + return new IOException(message); } public static IOException createOcrToolsUnavailableException() { - return createIOException( - "error.toolNotInstalled", "{0} is not installed", null, "OCR tools"); + String message = getMessage(ErrorCode.OCR_TOOLS_UNAVAILABLE); + return new IOException(message); + } + + public static IOException createOcrInvalidRenderTypeException() { + String message = getMessage(ErrorCode.OCR_INVALID_RENDER_TYPE); + return new IOException(message); + } + + public static IOException createOcrProcessingFailedException(int returnCode) { + String message = + getMessage( + ErrorCode.OCR_PROCESSING_FAILED.getMessageKey(), + ErrorCode.OCR_PROCESSING_FAILED.getDefaultMessage(), + returnCode); + return new IOException(message); } /** Create system requirement exceptions. */ + public static FfmpegRequiredException createFfmpegRequiredException() { + String message = getMessage(ErrorCode.FFMPEG_REQUIRED); + return new FfmpegRequiredException(message, ErrorCode.FFMPEG_REQUIRED.getCode()); + } + public static IOException createPythonRequiredForWebpException() { return createIOException( "error.toolRequired", "{0} is required for {1}", null, "Python", "WebP conversion"); } - public static IOException createFfmpegRequiredException() { - return createIOException( - "error.toolRequired", - "{0} is required for {1}", - null, - "FFmpeg", - "PDF to Video Slideshow conversion"); - } - - /** Create file operation exceptions. */ - public static IOException createFileNotFoundException(String fileId) { - return createIOException("error.fileNotFound", "File not found with ID: {0}", null, fileId); - } - - public static RuntimeException createPdfaConversionFailedException() { - return createRuntimeException( - "error.conversionFailed", "{0} conversion failed", null, "PDF/A"); - } - - public static IllegalArgumentException createInvalidComparatorException() { - return createIllegalArgumentException( - "error.invalidFormat", - "Invalid {0} format: {1}", - "comparator", - "only 'greater', 'equal', and 'less' are supported"); - } - /** Create compression-related exceptions. */ public static RuntimeException createMd5AlgorithmException(Exception cause) { - return createRuntimeException( - "error.algorithmNotAvailable", "{0} algorithm not available", cause, "MD5"); + requireNonNull(cause, "cause"); + String message = getMessage(ErrorCode.MD5_ALGORITHM); + return new RuntimeException(message, cause); } - public static IllegalArgumentException createCompressionOptionsException() { - return createIllegalArgumentException( - "error.optionsNotSpecified", - "{0} options are not specified", - "compression (expected output size and optimize level)"); + public static GhostscriptException createGhostscriptCompressionException() { + return createGhostscriptCompressionException(null, null); } - public static IOException createGhostscriptCompressionException() { - return createIOException( - "error.commandFailed", "{0} command failed", null, "Ghostscript compression"); + public static GhostscriptException createGhostscriptCompressionException(String processOutput) { + return createGhostscriptCompressionException(processOutput, null); } - public static IOException createGhostscriptCompressionException(Exception cause) { - return createIOException( - "error.commandFailed", "{0} command failed", cause, "Ghostscript compression"); + public static GhostscriptException createGhostscriptCompressionException(Exception cause) { + requireNonNull(cause, "cause"); + return createGhostscriptCompressionException(cause.getMessage(), cause); } - public static IOException createQpdfCompressionException(Exception cause) { - return createIOException("error.commandFailed", "{0} command failed", cause, "QPDF"); + public static GhostscriptException createGhostscriptCompressionException( + String processOutput, Exception cause) { + GhostscriptErrorInfo errorInfo = analyzeGhostscriptOutput(processOutput, cause); + return buildGhostscriptException(errorInfo, processOutput, cause); + } + + public static GhostscriptException detectGhostscriptCriticalError(String processOutput) { + GhostscriptErrorInfo errorInfo = analyzeGhostscriptOutput(processOutput, null); + if (errorInfo.critical()) { + return buildGhostscriptException(errorInfo, processOutput, null); + } + return null; + } + + private static GhostscriptException buildGhostscriptException( + GhostscriptErrorInfo errorInfo, String processOutput, Exception cause) { + String targetDescription; + if (errorInfo.affectedPages() != null && !errorInfo.affectedPages().isEmpty()) { + if (errorInfo.affectedPages().size() == 1) { + targetDescription = "page " + errorInfo.affectedPages().get(0); + } else { + targetDescription = + "pages " + + String.join( + ", ", + errorInfo.affectedPages().stream() + .map(String::valueOf) + .toArray(String[]::new)); + } + } else { + targetDescription = "the input file"; + } + + String diagnostic = + errorInfo.diagnostic() != null + ? errorInfo.diagnostic() + : deriveDefaultGhostscriptDiagnostic(processOutput); + + String message; + if (errorInfo.errorCode() == ErrorCode.GHOSTSCRIPT_PAGE_DRAWING) { + message = + getMessage( + errorInfo.errorCode().getMessageKey(), + errorInfo.errorCode().getDefaultMessage(), + targetDescription, + diagnostic); + } else { + message = getMessage(errorInfo.errorCode()); + if (errorInfo.diagnostic() != null && !errorInfo.diagnostic().isBlank()) { + message = message + " " + errorInfo.diagnostic(); + } + } + + return new GhostscriptException(message, cause, errorInfo.errorCode().getCode()); + } + + private static GhostscriptErrorInfo analyzeGhostscriptOutput( + String processOutput, Exception cause) { + String combinedOutput = processOutput; + if ((combinedOutput == null || combinedOutput.isBlank()) && cause != null) { + combinedOutput = cause.getMessage(); + } + + if (combinedOutput == null || combinedOutput.isBlank()) { + return GhostscriptErrorInfo.unknown(); + } + + String[] lines = + RegexPatternUtils.getInstance().getLineSeparatorPattern().split(combinedOutput); + List affectedPages = new ArrayList<>(); + Set uniqueDiagnostics = new java.util.LinkedHashSet<>(); + boolean recognized = false; + Integer currentPage = null; + + for (String rawLine : lines) { + String line = rawLine == null ? "" : rawLine.trim(); + if (line.isEmpty()) { + continue; + } + + // Check for page number markers + Matcher pageMatcher = GS_PAGE_PATTERN.matcher(line); + if (pageMatcher.find()) { + try { + currentPage = Integer.parseInt(pageMatcher.group(1)); + } catch (NumberFormatException ignore) { + // Ignore invalid page numbers and continue parsing + } + } + + String lowerLine = line.toLowerCase(Locale.ROOT); + if (lowerLine.contains("page drawing error") + || lowerLine.contains("could not draw this page") + || lowerLine.contains("eps files may not contain multiple pages")) { + recognized = true; + + // Record the page number if we found an error + if (currentPage != null && !affectedPages.contains(currentPage)) { + affectedPages.add(currentPage); + } + + String normalized = normalizeGhostscriptLine(line); + if (!normalized.isEmpty() && !normalized.startsWith("GPL Ghostscript")) { + uniqueDiagnostics.add(normalized); + } + } + } + + if (recognized) { + // Build a clean diagnostic message without duplicates + String diagnostic = String.join(". ", uniqueDiagnostics); + if (!diagnostic.isEmpty() && diagnostic.charAt(diagnostic.length() - 1) != '.') { + diagnostic += "."; + } + + // Use the first page number, or null if none found + Integer pageNumber = affectedPages.isEmpty() ? null : affectedPages.get(0); + + return new GhostscriptErrorInfo( + ErrorCode.GHOSTSCRIPT_PAGE_DRAWING, + pageNumber, + diagnostic, + true, + affectedPages); + } + + // Fallback: capture the first non-empty informative line for context + for (String rawLine : lines) { + String line = rawLine == null ? "" : rawLine.trim(); + if (line.isEmpty() || line.startsWith("GPL Ghostscript")) { + continue; + } + String normalized = normalizeGhostscriptLine(line); + if (!normalized.isEmpty()) { + return new GhostscriptErrorInfo( + ErrorCode.GHOSTSCRIPT_COMPRESSION, null, normalized, false, List.of()); + } + } + + return new GhostscriptErrorInfo( + ErrorCode.GHOSTSCRIPT_COMPRESSION, null, null, false, List.of()); + } + + private static String normalizeGhostscriptLine(String line) { + if (line == null) { + return ""; + } + String trimmed = line.trim(); + if (trimmed.isEmpty()) { + return ""; + } + return RegexPatternUtils.getInstance() + .getLeadingAsterisksWhitespacePattern() + .matcher(trimmed) + .replaceFirst(""); + } + + private static String deriveDefaultGhostscriptDiagnostic(String processOutput) { + return getMessage( + "error.ghostscriptDefaultDiagnostic", + "The source file contains content Ghostscript cannot render."); } public static IOException createGhostscriptConversionException(String outputType) { - return createIOException( - "error.commandFailed", "{0} command failed", null, "Ghostscript " + outputType); + requireNonNull(outputType, "outputType"); + String message = + getMessage( + ErrorCode.GHOSTSCRIPT_COMPRESSION.getMessageKey(), + ErrorCode.GHOSTSCRIPT_COMPRESSION.getDefaultMessage(), + outputType); + return new IOException(message); + } + + public static IOException createProcessingInterruptedException( + String processType, InterruptedException cause) { + requireNonNull(processType, "processType"); + requireNonNull(cause, "cause"); + String message = + getMessage( + ErrorCode.PROCESSING_INTERRUPTED.getMessageKey(), + ErrorCode.PROCESSING_INTERRUPTED.getDefaultMessage(), + processType); + return new IOException(message, cause); + } + + public static RuntimeException createPdfaConversionFailedException() { + String message = getMessage(ErrorCode.PDFA_CONVERSION_FAILED); + return new RuntimeException(message); + } + + public static IllegalArgumentException createInvalidArgumentException( + String argumentName, String value) { + requireNonNull(argumentName, "argumentName"); + requireNonNull(value, "value"); + String message = + getMessage( + ErrorCode.INVALID_ARGUMENT.getMessageKey(), + ErrorCode.INVALID_ARGUMENT.getDefaultMessage(), + argumentName, + value); + return new IllegalArgumentException(message); + } + + public static IllegalArgumentException createNullArgumentException(String argumentName) { + requireNonNull(argumentName, "argumentName"); + String message = + getMessage( + ErrorCode.NULL_ARGUMENT.getMessageKey(), + ErrorCode.NULL_ARGUMENT.getDefaultMessage(), + argumentName); + return new IllegalArgumentException(message); + } + + /** + * Create an OutOfMemoryDpiException for memory/image size errors when rendering PDF images with + * DPI. Handles OutOfMemoryError and related conditions (e.g., NegativeArraySizeException) that + * result from images exceeding Java's array/memory limits. + * + * @param pageNumber the page number that caused the error + * @param dpi the DPI value used + * @param cause the original error/exception (e.g., OutOfMemoryError, + * NegativeArraySizeException) + * @return OutOfMemoryDpiException with user-friendly message + */ + public static OutOfMemoryDpiException createOutOfMemoryDpiException( + int pageNumber, int dpi, Throwable cause) { + requireNonNull(cause, "cause"); + String message = + getMessage( + ErrorCode.OUT_OF_MEMORY_DPI.getMessageKey(), + ErrorCode.OUT_OF_MEMORY_DPI.getDefaultMessage(), + pageNumber, + dpi); + return new OutOfMemoryDpiException(message, cause, ErrorCode.OUT_OF_MEMORY_DPI.getCode()); + } + + /** + * Create an OutOfMemoryDpiException for OutOfMemoryError when rendering PDF images with DPI. + * + * @param pageNumber the page number that caused the error + * @param dpi the DPI value used + * @param cause the original OutOfMemoryError + * @return OutOfMemoryDpiException with user-friendly message + */ + public static OutOfMemoryDpiException createOutOfMemoryDpiException( + int pageNumber, int dpi, OutOfMemoryError cause) { + return createOutOfMemoryDpiException(pageNumber, dpi, (Throwable) cause); + } + + /** + * Create an OutOfMemoryDpiException for memory/image size errors when rendering PDF images with + * DPI. Handles OutOfMemoryError and related conditions (e.g., NegativeArraySizeException) that + * result from images exceeding Java's array/memory limits. + * + * @param dpi the DPI value used + * @param cause the original error/exception (e.g., OutOfMemoryError, + * NegativeArraySizeException) + * @return OutOfMemoryDpiException with user-friendly message + */ + public static OutOfMemoryDpiException createOutOfMemoryDpiException(int dpi, Throwable cause) { + requireNonNull(cause, "cause"); + String message = + getMessage( + ErrorCode.OUT_OF_MEMORY_DPI.getMessageKey(), + ErrorCode.OUT_OF_MEMORY_DPI.getDefaultMessage(), + dpi); + return new OutOfMemoryDpiException(message, cause, ErrorCode.OUT_OF_MEMORY_DPI.getCode()); + } + + /** + * Create an OutOfMemoryDpiException for OutOfMemoryError when rendering PDF images with DPI. + * + * @param dpi the DPI value used + * @param cause the original OutOfMemoryError + * @return OutOfMemoryDpiException with user-friendly message + */ + public static OutOfMemoryDpiException createOutOfMemoryDpiException( + int dpi, OutOfMemoryError cause) { + return createOutOfMemoryDpiException(dpi, (Throwable) cause); } /** * Check if an exception indicates a corrupted PDF and wrap it with appropriate message. * * @param e the exception to check - * @return the original exception if not PDF corruption, or a new IOException with user-friendly - * message + * @return the original exception if not PDF corruption, or a new PdfCorruptedException with + * user-friendly message */ public static IOException handlePdfException(IOException e) { return handlePdfException(e, null); @@ -265,10 +1029,12 @@ public class ExceptionUtils { * * @param e the exception to check * @param context additional context for the error - * @return the original exception if not PDF corruption, or a new IOException with user-friendly + * @return the original exception if not PDF corruption, or a new exception with user-friendly * message */ public static IOException handlePdfException(IOException e, String context) { + requireNonNull(e, "exception"); + if (PdfErrorUtils.isCorruptedPdfError(e)) { return createPdfCorruptedException(context, e); } @@ -284,6 +1050,340 @@ public class ExceptionUtils { return e; // Return original exception if no specific handling needed } + /** + * Log an exception with appropriate level based on its type. Returns the exception for fluent + * log-and-throw pattern. + * + * @param the exception type + * @param operation the operation being performed + * @param exception the exception that occurred + * @return the exception (for fluent log-and-throw) + */ + public static T logException(String operation, T exception) { + requireNonNull(operation, "operation"); + requireNonNull(exception, "exception"); + + if (PdfErrorUtils.isCorruptedPdfError(exception)) { + log.warn("PDF corruption detected during {}: {}", operation, exception.getMessage()); + } else if (exception instanceof IOException + && (isEncryptionError((IOException) exception) + || isPasswordError((IOException) exception))) { + log.info("PDF security issue during {}: {}", operation, exception.getMessage()); + } else { + log.error("Unexpected error during {}", operation, exception); + } + + return exception; + } + + /** + * Wrap a generic exception with appropriate context for better error reporting. This method is + * useful when catching exceptions in controllers and wanting to provide better context to + * GlobalExceptionHandler. + * + * @param e the exception to wrap + * @param operation the operation being performed (e.g., "PDF merge", "image extraction") + * @return a RuntimeException wrapping the original exception with operation context + */ + public static RuntimeException wrapException(Exception e, String operation) { + requireNonNull(e, "exception"); + requireNonNull(operation, "operation"); + + if (e instanceof RuntimeException) { + return (RuntimeException) e; + } + + if (e instanceof IOException) { + IOException ioException = handlePdfException((IOException) e, operation); + // BaseAppException extends IOException, wrap it in RuntimeException for rethrowing + if (ioException instanceof BaseAppException) { + return new RuntimeException(ioException); + } + return new RuntimeException(createFileProcessingException(operation, e)); + } + + return new RuntimeException( + MessageFormat.format("Error during {0}: {1}", operation, e.getMessage()), e); + } + + /** + * Error codes for consistent error tracking and documentation. Each error code includes a + * unique identifier, i18n message key, and default message. + * + *

These codes are used by {@link stirling.software.SPDF.exception.GlobalExceptionHandler} to + * provide consistent RFC 7807 Problem Details responses. + */ + @Getter + public enum ErrorCode { + // PDF-related errors + PDF_CORRUPTED( + "E001", + "error.pdfCorrupted", + "PDF file appears to be corrupted or damaged. Please try using the 'Repair PDF' feature first to fix the file before proceeding with this operation."), + PDF_MULTIPLE_CORRUPTED( + "E002", + "error.multiplePdfCorrupted", + "One or more PDF files appear to be corrupted or damaged. Please try using the 'Repair PDF' feature on each file first before attempting to merge them."), + PDF_ENCRYPTION( + "E003", + "error.pdfEncryption", + "The PDF appears to have corrupted encryption data. This can happen when the PDF was created with incompatible encryption methods. Please try using the 'Repair PDF' feature first, or contact the document creator for a new copy."), + PDF_PASSWORD( + "E004", + "error.pdfPassword", + "The PDF Document is passworded and either the password was not provided or was incorrect"), + PDF_NO_PAGES("E005", "error.pdfNoPages", "PDF file contains no pages"), + PDF_NOT_PDF("E006", "error.notPdfFile", "File must be in PDF format"), + + // CBR/CBZ errors + CBR_INVALID_FORMAT( + "E010", + "error.cbrInvalidFormat", + "Invalid or corrupted CBR/RAR archive. The file may be corrupted, use an unsupported RAR format (RAR5+), encrypted, or may not be a valid RAR archive."), + CBR_NO_IMAGES( + "E012", + "error.cbrNoImages", + "No valid images found in the CBR file. The archive may be empty, or all images may be corrupted or in unsupported formats."), + CBR_NOT_CBR("E014", "error.notCbrFile", "File must be a CBR or RAR archive"), + CBZ_INVALID_FORMAT( + "E015", + "error.cbzInvalidFormat", + "Invalid or corrupted CBZ/ZIP archive. The file may be empty, corrupted, or may not be a valid ZIP archive."), + CBZ_NO_IMAGES( + "E016", + "error.cbzNoImages", + "No valid images found in the CBZ file. The archive may be empty, or all images may be corrupted or in unsupported formats."), + CBZ_NOT_CBZ("E018", "error.notCbzFile", "File must be a CBZ or ZIP archive"), + + // EML errors + EML_EMPTY("E020", "error.emlEmpty", "EML file is empty or null"), + EML_INVALID_FORMAT("E021", "error.emlInvalidFormat", "Invalid EML file format"), + + // File processing errors + FILE_NOT_FOUND("E030", "error.fileNotFound", "File not found with ID: {0}"), + FILE_PROCESSING( + "E031", + "error.fileProcessing", + "An error occurred while processing the file during {0} operation: {1}"), + FILE_NULL_OR_EMPTY("E032", "error.fileNullOrEmpty", "File cannot be null or empty"), + FILE_NO_NAME("E033", "error.fileNoName", "File must have a name"), + IMAGE_READ_ERROR("E034", "error.imageReadError", "Unable to read image from file: {0}"), + + // OCR errors + OCR_LANGUAGE_REQUIRED( + "E040", "error.ocrLanguageRequired", "OCR language options are not specified"), + OCR_INVALID_LANGUAGES( + "E041", + "error.ocrInvalidLanguages", + "Invalid OCR languages format: none of the selected languages are valid"), + OCR_TOOLS_UNAVAILABLE("E042", "error.ocrToolsUnavailable", "OCR tools are not installed"), + OCR_INVALID_RENDER_TYPE( + "E043", + "error.ocrInvalidRenderType", + "Invalid OCR render type. Must be 'hocr' or 'sandwich'"), + OCR_PROCESSING_FAILED( + "E044", "error.ocrProcessingFailed", "OCRmyPDF failed with return code: {0}"), + + // Compression errors + COMPRESSION_OPTIONS( + "E050", + "error.compressionOptions", + "Compression options are not specified (expected output size and optimize level)"), + GHOSTSCRIPT_COMPRESSION( + "E051", "error.ghostscriptCompression", "Ghostscript compression command failed"), + QPDF_COMPRESSION("E052", "error.qpdfCompression", "QPDF command failed"), + PROCESSING_INTERRUPTED( + "E053", "error.processingInterrupted", "{0} processing was interrupted"), + GHOSTSCRIPT_PAGE_DRAWING( + "E054", "error.ghostscriptPageDrawing", "Ghostscript could not render {0}. {1}"), + + // Conversion errors + PDFA_CONVERSION_FAILED("E060", "error.pdfaConversionFailed", "PDF/A conversion failed"), + HTML_FILE_REQUIRED("E061", "error.htmlFileRequired", "File must be in HTML or ZIP format"), + PYTHON_REQUIRED_WEBP( + "E062", "error.pythonRequiredWebp", "Python is required for WebP conversion"), + FFMPEG_REQUIRED( + "E063", + "error.ffmpegRequired", + "FFmpeg must be installed to convert PDFs to video. Install FFmpeg and ensure it is available on the system PATH."), + + // Validation errors + INVALID_ARGUMENT("E070", "error.invalidArgument", "Invalid argument ''{0}'': {1}"), + NULL_ARGUMENT("E071", "error.nullArgument", "{0} must not be null"), + INVALID_PAGE_SIZE("E072", "error.invalidPageSize", "Invalid page size format: {0}"), + INVALID_COMPARATOR( + "E073", + "error.invalidComparator", + "Invalid comparator format: only 'greater', 'equal', and 'less' are supported"), + + // System errors + MD5_ALGORITHM("E080", "error.md5Algorithm", "MD5 algorithm not available"), + OUT_OF_MEMORY_DPI( + "E081", + "error.outOfMemoryDpi", + "Out of memory or image-too-large error while rendering PDF page {0} at {1} DPI. This can occur when the resulting image exceeds Java's array/memory limits (e.g., NegativeArraySizeException). Please use a lower DPI value (recommended: 150 or less) or process the document in smaller chunks."); + + private final String code; + private final String messageKey; + private final String defaultMessage; + + ErrorCode(String code, String messageKey, String defaultMessage) { + this.code = code; + this.messageKey = messageKey; + this.defaultMessage = defaultMessage; + } + } + + /** + * Functional interface for PDF rendering operations that may throw OutOfMemoryError or + * NegativeArraySizeException. + * + * @param the return type of the operation + */ + @FunctionalInterface + public interface RenderOperation { + T render() throws IOException; + } + + /** + * Common interface for exceptions that provide error codes. + * + *

This interface enables polymorphic handling of different exception types (BaseAppException + * and BaseValidationException) without using instanceof checks. + * + * @see BaseAppException + * @see BaseValidationException + */ + public interface ErrorCodeProvider { + /** + * Get the error message. + * + * @return the error message + */ + String getMessage(); + + /** + * Get the error code. + * + * @return the error code (e.g., "E001") + */ + String getErrorCode(); + } + + /** Exception thrown when Ghostscript fails to render or compress a file. */ + public static class GhostscriptException extends BaseAppException { + public GhostscriptException(String message, Throwable cause, String errorCode) { + super(message, cause, errorCode); + } + } + + /** Exception thrown when FFmpeg is not available on the host system. */ + public static class FfmpegRequiredException extends BaseAppException { + public FfmpegRequiredException(String message, String errorCode) { + super(message, null, errorCode); + } + + public FfmpegRequiredException(String message, Throwable cause, String errorCode) { + super(message, cause, errorCode); + } + } + + private record GhostscriptErrorInfo( + ErrorCode errorCode, + Integer pageNumber, + String diagnostic, + boolean critical, + List affectedPages) { + private GhostscriptErrorInfo( + ErrorCode errorCode, + Integer pageNumber, + String diagnostic, + boolean critical, + List affectedPages) { + this.errorCode = errorCode; + this.pageNumber = pageNumber; + this.diagnostic = diagnostic; + this.critical = critical; + this.affectedPages = affectedPages != null ? affectedPages : List.of(); + } + + private static GhostscriptErrorInfo unknown() { + return new GhostscriptErrorInfo( + ErrorCode.GHOSTSCRIPT_COMPRESSION, null, null, false, List.of()); + } + } + + /** Base exception with error code support for IO-related errors. */ + public abstract static class BaseAppException extends IOException implements ErrorCodeProvider { + @Getter(onMethod_ = {@Override}) + private final String errorCode; + + protected BaseAppException(String message, Throwable cause, String errorCode) { + super(message, cause); + this.errorCode = errorCode; + } + } + + /** Base exception with error code support for illegal argument errors. */ + public abstract static class BaseValidationException extends IllegalArgumentException + implements ErrorCodeProvider { + @Getter(onMethod_ = {@Override}) + private final String errorCode; + + protected BaseValidationException(String message, String errorCode) { + super(message); + this.errorCode = errorCode; + } + + protected BaseValidationException(String message, Throwable cause, String errorCode) { + super(message, cause); + this.errorCode = errorCode; + } + } + + /** Exception thrown when a PDF file is corrupted or damaged. */ + public static class PdfCorruptedException extends BaseAppException { + public PdfCorruptedException(String message, Throwable cause, String errorCode) { + super(message, cause, errorCode); + } + } + + /** Exception thrown when a PDF has encryption/decryption issues. */ + public static class PdfEncryptionException extends BaseAppException { + public PdfEncryptionException(String message, Throwable cause, String errorCode) { + super(message, cause, errorCode); + } + } + + /** Exception thrown when PDF password is incorrect or missing. */ + public static class PdfPasswordException extends BaseAppException { + public PdfPasswordException(String message, Throwable cause, String errorCode) { + super(message, cause, errorCode); + } + } + + /** Exception thrown when CBR/RAR archive is invalid or unsupported. */ + public static class CbrFormatException extends BaseValidationException { + public CbrFormatException(String message, String errorCode) { + super(message, errorCode); + } + + public CbrFormatException(String message, Throwable cause, String errorCode) { + super(message, cause, errorCode); + } + } + + /** Exception thrown when CBZ/ZIP archive is invalid. */ + public static class CbzFormatException extends BaseValidationException { + public CbzFormatException(String message, String errorCode) { + super(message, errorCode); + } + + public CbzFormatException(String message, Throwable cause, String errorCode) { + super(message, cause, errorCode); + } + } + /** * Check if an exception indicates a PDF encryption/decryption error. * @@ -315,100 +1415,17 @@ public class ExceptionUtils { || message.contains("PDF contains an encryption dictionary"); } - /** - * Log an exception with appropriate level based on its type. - * - * @param operation the operation being performed - * @param e the exception that occurred - */ - public static void logException(String operation, Exception e) { - if (PdfErrorUtils.isCorruptedPdfError(e)) { - log.warn("PDF corruption detected during {}: {}", operation, e.getMessage()); - } else if (e instanceof IOException - && (isEncryptionError((IOException) e) || isPasswordError((IOException) e))) { - log.info("PDF security issue during {}: {}", operation, e.getMessage()); - } else { - log.error("Unexpected error during {}", operation, e); + /** Exception thrown when EML file format is invalid. */ + public static class EmlFormatException extends BaseValidationException { + public EmlFormatException(String message, String errorCode) { + super(message, errorCode); } } - /** Create common validation exceptions. */ - public static IllegalArgumentException createInvalidArgumentException(String argumentName) { - return createIllegalArgumentException( - "error.invalidArgument", "Invalid argument: {0}", argumentName); - } - - public static IllegalArgumentException createInvalidArgumentException( - String argumentName, String value) { - return createIllegalArgumentException( - "error.invalidFormat", "Invalid {0} format: {1}", argumentName, value); - } - - public static IllegalArgumentException createNullArgumentException(String argumentName) { - return createIllegalArgumentException( - "error.argumentRequired", "{0} must not be null", argumentName); - } - - /** - * Create a RuntimeException for memory/image size errors when rendering PDF images with DPI. - * Handles OutOfMemoryError and related conditions (e.g., NegativeArraySizeException) that - * result from images exceeding Java's array/memory limits. - * - * @param pageNumber the page number that caused the error - * @param dpi the DPI value used - * @param cause the original error/exception (e.g., OutOfMemoryError, - * NegativeArraySizeException) - * @return RuntimeException with user-friendly message - */ - public static RuntimeException createOutOfMemoryDpiException( - int pageNumber, int dpi, Throwable cause) { - String message = - MessageFormat.format( - "Out of memory or image-too-large error while rendering PDF page {0} at {1}" - + " DPI. This can occur when the resulting image exceeds Java's" - + " array/memory limits (e.g., NegativeArraySizeException). Please use" - + " a lower DPI value (recommended: 150 or less) or process the" - + " document in smaller chunks.", - pageNumber, dpi); - return new RuntimeException(message, cause); - } - - /** - * Create a RuntimeException for OutOfMemoryError when rendering PDF images with DPI. - * - * @param pageNumber the page number that caused the error - * @param dpi the DPI value used - * @param cause the original OutOfMemoryError - * @return RuntimeException with user-friendly message - */ - public static RuntimeException createOutOfMemoryDpiException( - int pageNumber, int dpi, OutOfMemoryError cause) { - return createOutOfMemoryDpiException(pageNumber, dpi, (Throwable) cause); - } - - /** - * Create a RuntimeException for memory/image size errors when rendering PDF images with DPI. - * Handles OutOfMemoryError and related conditions (e.g., NegativeArraySizeException) that - * result from images exceeding Java's array/memory limits. - * - * @param dpi the DPI value used - * @param cause the original error/exception (e.g., OutOfMemoryError, - * NegativeArraySizeException) - * @return RuntimeException with user-friendly message - */ - public static RuntimeException createOutOfMemoryDpiException(int dpi, Throwable cause) { - String message = - MessageFormat.format( - "Out of memory or image-too-large error while rendering PDF at {0} DPI." - + " This can occur when the resulting image exceeds Java's array/memory" - + " limits (e.g., NegativeArraySizeException). Please use a lower DPI" - + " value (recommended: 150 or less) or process the document in smaller" - + " chunks.", - dpi); - return new RuntimeException(message, cause); - } - - public static RuntimeException createOutOfMemoryDpiException(int dpi, OutOfMemoryError cause) { - return createOutOfMemoryDpiException(dpi, (Throwable) cause); + /** Exception thrown when rendering PDF pages causes out-of-memory or array size errors. */ + public static class OutOfMemoryDpiException extends BaseAppException { + public OutOfMemoryDpiException(String message, Throwable cause, String errorCode) { + super(message, cause, errorCode); + } } } diff --git a/app/common/src/main/java/stirling/software/common/util/GeneralUtils.java b/app/common/src/main/java/stirling/software/common/util/GeneralUtils.java index a6724b628..874417990 100644 --- a/app/common/src/main/java/stirling/software/common/util/GeneralUtils.java +++ b/app/common/src/main/java/stirling/software/common/util/GeneralUtils.java @@ -1100,17 +1100,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) { diff --git a/app/common/src/main/java/stirling/software/common/util/ImageProcessingUtils.java b/app/common/src/main/java/stirling/software/common/util/ImageProcessingUtils.java index 77bc8c7b3..2a23080e0 100644 --- a/app/common/src/main/java/stirling/software/common/util/ImageProcessingUtils.java +++ b/app/common/src/main/java/stirling/software/common/util/ImageProcessingUtils.java @@ -144,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()); diff --git a/app/common/src/main/java/stirling/software/common/util/PdfToCbrUtils.java b/app/common/src/main/java/stirling/software/common/util/PdfToCbrUtils.java index 306939069..3d17cfc8e 100644 --- a/app/common/src/main/java/stirling/software/common/util/PdfToCbrUtils.java +++ b/app/common/src/main/java/stirling/software/common/util/PdfToCbrUtils.java @@ -35,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); @@ -44,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(Locale.ROOT); if (!"pdf".equals(extension)) { - throw new IllegalArgumentException("File must be a PDF"); + throw ExceptionUtils.createPdfFileRequiredException(); } } @@ -67,28 +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(Locale.ROOT, "page_%03d.png", pageIndex + 1); + 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); @@ -117,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()); diff --git a/app/common/src/main/java/stirling/software/common/util/PdfToCbzUtils.java b/app/common/src/main/java/stirling/software/common/util/PdfToCbzUtils.java index d0557406b..4fee38a7f 100644 --- a/app/common/src/main/java/stirling/software/common/util/PdfToCbzUtils.java +++ b/app/common/src/main/java/stirling/software/common/util/PdfToCbzUtils.java @@ -30,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); @@ -39,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(Locale.ROOT); if (!"pdf".equals(extension)) { - throw new IllegalArgumentException("File must be a PDF"); + throw ExceptionUtils.createPdfFileRequiredException(); } } @@ -62,25 +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); + ExceptionUtils.handleOomRendering( + currentPage + 1, + dpi, + () -> + pdfRenderer.renderImageWithDPI( + currentPage, dpi, ImageType.RGB)); String imageFilename = - String.format(Locale.ROOT, "page_%03d.png", pageIndex + 1); - + 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); } } diff --git a/app/common/src/main/java/stirling/software/common/util/PdfUtils.java b/app/common/src/main/java/stirling/software/common/util/PdfUtils.java index 39c776a61..d294da2ff 100644 --- a/app/common/src/main/java/stirling/software/common/util/PdfUtils.java +++ b/app/common/src/main/java/stirling/software/common/util/PdfUtils.java @@ -179,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() @@ -195,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); } @@ -222,6 +229,7 @@ public class PdfUtils { HashMap 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(); @@ -232,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() @@ -247,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 = @@ -276,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() @@ -294,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); } } @@ -318,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")) { @@ -332,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); @@ -375,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 @@ -384,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")) { @@ -395,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); diff --git a/app/common/src/main/java/stirling/software/common/util/RegexPatternUtils.java b/app/common/src/main/java/stirling/software/common/util/RegexPatternUtils.java index 8858c99bf..0aa4cfac0 100644 --- a/app/common/src/main/java/stirling/software/common/util/RegexPatternUtils.java +++ b/app/common/src/main/java/stirling/software/common/util/RegexPatternUtils.java @@ -301,6 +301,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]$"); @@ -518,6 +523,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 } diff --git a/app/common/src/main/java/stirling/software/common/util/misc/InvertFullColorStrategy.java b/app/common/src/main/java/stirling/software/common/util/misc/InvertFullColorStrategy.java index d220cfcfa..806515c63 100644 --- a/app/common/src/main/java/stirling/software/common/util/misc/InvertFullColorStrategy.java +++ b/app/common/src/main/java/stirling/software/common/util/misc/InvertFullColorStrategy.java @@ -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); diff --git a/app/common/src/test/java/stirling/software/common/service/JobExecutorServiceTest.java b/app/common/src/test/java/stirling/software/common/service/JobExecutorServiceTest.java index 3257c4573..7982b0991 100644 --- a/app/common/src/test/java/stirling/software/common/service/JobExecutorServiceTest.java +++ b/app/common/src/test/java/stirling/software/common/service/JobExecutorServiceTest.java @@ -1,6 +1,10 @@ package stirling.software.common.service; -import static org.junit.jupiter.api.Assertions.*; +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.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.ArgumentMatchers.anyString; @@ -8,7 +12,6 @@ import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; -import java.util.Map; import java.util.concurrent.CompletableFuture; import java.util.concurrent.TimeoutException; import java.util.function.Supplier; @@ -61,7 +64,7 @@ class JobExecutorServiceTest { } @Test - void shouldRunSyncJobSuccessfully() { + void shouldRunSyncJobSuccessfully() throws Exception { // Given Supplier work = () -> "test-result"; @@ -77,7 +80,7 @@ class JobExecutorServiceTest { } @Test - void shouldRunAsyncJobSuccessfully() { + void shouldRunAsyncJobSuccessfully() throws Exception { // Given Supplier work = () -> "test-result"; @@ -103,20 +106,16 @@ class JobExecutorServiceTest { throw new RuntimeException("Test error"); }; - // When - ResponseEntity response = jobExecutorService.runJobGeneric(false, work); - - // Then - assertEquals(HttpStatus.INTERNAL_SERVER_ERROR, response.getStatusCode()); - - @SuppressWarnings("unchecked") - Map errorMap = (Map) response.getBody(); - assertNotNull(errorMap); - assertEquals("Job failed: Test error", errorMap.get("error")); + // When/Then - Exception should propagate to GlobalExceptionHandler + RuntimeException thrown = + assertThrows( + RuntimeException.class, + () -> jobExecutorService.runJobGeneric(false, work)); + assertEquals("Test error", thrown.getMessage()); } @Test - void shouldQueueJobWhenResourcesLimited() { + void shouldQueueJobWhenResourcesLimited() throws Exception { // Given Supplier work = () -> "test-result"; CompletableFuture> future = new CompletableFuture<>(); diff --git a/app/common/src/test/java/stirling/software/common/util/ExceptionUtilsTest.java b/app/common/src/test/java/stirling/software/common/util/ExceptionUtilsTest.java index e4e93b84d..2584481ca 100644 --- a/app/common/src/test/java/stirling/software/common/util/ExceptionUtilsTest.java +++ b/app/common/src/test/java/stirling/software/common/util/ExceptionUtilsTest.java @@ -25,12 +25,9 @@ class ExceptionUtilsTest { @DisplayName("should create PdfCorruptedException without context") void testCreatePdfCorruptedExceptionWithoutContext() { Exception cause = new Exception("root"); - IOException ex = ExceptionUtils.createPdfCorruptedException(cause); + IOException ex = ExceptionUtils.createPdfCorruptedException(null, cause); - assertEquals( - "PDF file appears to be corrupted or damaged. Please try using the 'Repair PDF'" - + " feature first to fix the file before proceeding with this operation.", - ex.getMessage()); + assertTrue(ex.getMessage().contains("PDF file appears to be corrupted")); assertSame(cause, ex.getCause()); } @@ -176,36 +173,18 @@ class ExceptionUtilsTest { @DisplayName("File operation and compression exceptions") class FileAndCompressionTests { - @Test - void testCreateFileNotFoundException() { - IOException ex = ExceptionUtils.createFileNotFoundException("123"); - assertTrue(ex.getMessage().contains("123")); - } - @Test void testCreatePdfaConversionFailedException() { RuntimeException ex = ExceptionUtils.createPdfaConversionFailedException(); assertTrue(ex.getMessage().contains("PDF/A conversion failed")); } - @Test - void testCreateInvalidComparatorException() { - IllegalArgumentException ex = ExceptionUtils.createInvalidComparatorException(); - assertTrue(ex.getMessage().contains("comparator")); - } - @Test void testCreateMd5AlgorithmException() { RuntimeException ex = ExceptionUtils.createMd5AlgorithmException(new Exception("x")); assertTrue(ex.getMessage().contains("MD5")); } - @Test - void testCreateCompressionOptionsException() { - IllegalArgumentException ex = ExceptionUtils.createCompressionOptionsException(); - assertTrue(ex.getMessage().contains("compression")); - } - @Test void testCreateGhostscriptCompressionExceptionNoCause() { IOException ex = ExceptionUtils.createGhostscriptCompressionException(); @@ -218,12 +197,6 @@ class ExceptionUtilsTest { ExceptionUtils.createGhostscriptCompressionException(new Exception("cause")); assertTrue(ex.getMessage().contains("Ghostscript")); } - - @Test - void testCreateQpdfCompressionException() { - IOException ex = ExceptionUtils.createQpdfCompressionException(new Exception("cause")); - assertTrue(ex.getMessage().contains("QPDF")); - } } @Nested @@ -359,8 +332,10 @@ class ExceptionUtilsTest { @Test void testCreateInvalidArgumentExceptionSingle() { - IllegalArgumentException ex = ExceptionUtils.createInvalidArgumentException("arg"); + IllegalArgumentException ex = + ExceptionUtils.createInvalidArgumentException("arg", "invalidValue"); assertTrue(ex.getMessage().contains("arg")); + assertTrue(ex.getMessage().contains("invalidValue")); } @Test diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/CropController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/CropController.java index e22a04098..7db4265bd 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/CropController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/CropController.java @@ -30,6 +30,7 @@ import lombok.extern.slf4j.Slf4j; import stirling.software.SPDF.model.api.general.CropPdfForm; import stirling.software.common.service.CustomPDFDocumentFactory; +import stirling.software.common.util.ExceptionUtils; import stirling.software.common.util.GeneralUtils; import stirling.software.common.util.ProcessExecutor; import stirling.software.common.util.WebResponseUtils; @@ -308,7 +309,7 @@ public class CropController { } catch (InterruptedException e) { Thread.currentThread().interrupt(); - throw new IOException("Ghostscript processing was interrupted", e); + throw ExceptionUtils.createProcessingInterruptedException("Ghostscript", e); } finally { if (tempInputFile != null) { Files.deleteIfExists(tempInputFile); diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/MultiPageLayoutController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/MultiPageLayoutController.java index dee51b75a..2ed88c35d 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/MultiPageLayoutController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/MultiPageLayoutController.java @@ -27,6 +27,7 @@ import lombok.extern.slf4j.Slf4j; import stirling.software.SPDF.model.api.general.MergeMultiplePagesRequest; import stirling.software.common.service.CustomPDFDocumentFactory; +import stirling.software.common.util.ExceptionUtils; import stirling.software.common.util.FormUtils; import stirling.software.common.util.GeneralUtils; import stirling.software.common.util.WebResponseUtils; @@ -56,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 = diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/PdfOverlayController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/PdfOverlayController.java index 3830fd65e..18f44db9c 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/PdfOverlayController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/PdfOverlayController.java @@ -27,6 +27,7 @@ import lombok.RequiredArgsConstructor; import stirling.software.SPDF.model.api.general.OverlayPdfsRequest; import stirling.software.common.service.CustomPDFDocumentFactory; +import stirling.software.common.util.ExceptionUtils; import stirling.software.common.util.GeneralUtils; import stirling.software.common.util.WebResponseUtils; @@ -118,7 +119,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; } @@ -180,8 +182,11 @@ public class PdfOverlayController { Map 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++) { diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/RearrangePagesPDFController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/RearrangePagesPDFController.java index b81d9b676..1470c2e58 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/RearrangePagesPDFController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/RearrangePagesPDFController.java @@ -217,7 +217,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); diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/SplitPdfBySectionsController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/SplitPdfBySectionsController.java index 6a515bd43..f01fb5c95 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/SplitPdfBySectionsController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/SplitPdfBySectionsController.java @@ -33,6 +33,7 @@ import lombok.RequiredArgsConstructor; import stirling.software.SPDF.model.SplitTypes; import stirling.software.SPDF.model.api.SplitPdfBySectionsRequest; import stirling.software.common.service.CustomPDFDocumentFactory; +import stirling.software.common.util.ExceptionUtils; import stirling.software.common.util.GeneralUtils; import stirling.software.common.util.PDFService; import stirling.software.common.util.TempFile; @@ -126,7 +127,11 @@ public class SplitPdfBySectionsController { switch (splitMode) { case CUSTOM: if (pageNumbers == null || pageNumbers.isBlank()) { - throw new IllegalArgumentException("Custom mode requires page numbers input."); + throw ExceptionUtils.createIllegalArgumentException( + "error.argumentRequired", + "{0} is required for {1} mode", + "page numbers", + "custom"); } String[] pageOrderArr = pageNumbers.split(","); List pageListToSplit = @@ -159,7 +164,8 @@ public class SplitPdfBySectionsController { break; default: - throw new IllegalArgumentException("Unsupported split mode: " + splitMode); + throw ExceptionUtils.createIllegalArgumentException( + "error.invalidFormat", "Invalid {0} format: {1}", "split mode", splitMode); } return pagesToSplit; diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/converters/ConvertImgPDFController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/converters/ConvertImgPDFController.java index 361fe1408..2b4eb3cbc 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/converters/ConvertImgPDFController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/converters/ConvertImgPDFController.java @@ -9,7 +9,6 @@ import java.nio.file.Path; import java.util.ArrayList; import java.util.List; import java.util.Locale; -import java.util.Map; import java.util.regex.Pattern; import java.util.stream.Stream; import java.util.zip.ZipEntry; @@ -19,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; @@ -280,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 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"); @@ -314,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 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"); @@ -349,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 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"); @@ -383,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 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"); diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/converters/ConvertOfficeController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/converters/ConvertOfficeController.java index 8769024ab..a4ce59380 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/converters/ConvertOfficeController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/converters/ConvertOfficeController.java @@ -33,6 +33,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; @@ -60,13 +61,14 @@ 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(Locale.ROOT); diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/converters/ConvertPdfToVideoController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/converters/ConvertPdfToVideoController.java index 0edf95739..d5a49db3a 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/converters/ConvertPdfToVideoController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/converters/ConvertPdfToVideoController.java @@ -185,7 +185,19 @@ public class ConvertPdfToVideoController { "error.invalidFormat", "Invalid {0} format: {1}", "PDF", "no pages"); } for (int pageIndex = 0; pageIndex < pageCount; pageIndex++) { - BufferedImage image = renderer.renderImageWithDPI(pageIndex, dpi, ImageType.RGB); + final int currentPageIndex = pageIndex; + + // Validate dimensions BEFORE attempting to render to prevent OOM + ExceptionUtils.validateRenderingDimensions( + document.getPage(currentPageIndex), currentPageIndex + 1, dpi); + + BufferedImage image = + ExceptionUtils.handleOomRendering( + currentPageIndex + 1, + dpi, + () -> + renderer.renderImageWithDPI( + currentPageIndex, dpi, ImageType.RGB)); if (isWatermarkEnabled) { applyWatermark(image, opacity, watermarkText); } diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/converters/ConvertWebsiteToPDF.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/converters/ConvertWebsiteToPDF.java index 7e471adc4..cd7b305e2 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/converters/ConvertWebsiteToPDF.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/converters/ConvertWebsiteToPDF.java @@ -36,6 +36,7 @@ import stirling.software.SPDF.model.api.converters.UrlToPdfRequest; import stirling.software.common.configuration.RuntimePathConfig; import stirling.software.common.model.ApplicationProperties; import stirling.software.common.service.CustomPDFDocumentFactory; +import stirling.software.common.util.ExceptionUtils; import stirling.software.common.util.GeneralUtils; import stirling.software.common.util.ProcessExecutor; import stirling.software.common.util.RegexPatternUtils; @@ -188,8 +189,11 @@ public class ConvertWebsiteToPDF { client.send(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)); if (response.statusCode() >= 400 || response.body() == null) { - throw new IOException( - "Failed to retrieve remote HTML. Status: " + response.statusCode()); + throw ExceptionUtils.createIOException( + "error.httpRequestFailed", + "Failed to retrieve remote HTML. Status: {0}", + null, + response.statusCode()); } return response.body(); diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/converters/PdfVectorExportController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/converters/PdfVectorExportController.java index 754b3c634..b2db3bc06 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/converters/PdfVectorExportController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/converters/PdfVectorExportController.java @@ -155,24 +155,18 @@ public class PdfVectorExportController { command.add("gs"); // Set device based on output format - String device; - switch (outputFormat.toLowerCase(Locale.ROOT)) { - case "eps": - device = "eps2write"; - break; - case "ps": - device = "ps2write"; - break; - case "pcl": - device = "pxlcolor"; // PCL XL color - break; - case "xps": - device = "xpswrite"; - break; - default: - throw ExceptionUtils.createIllegalArgumentException( - "error.invalidFormat", "Unsupported output format: {0}", outputFormat); - } + String device = + switch (outputFormat.toLowerCase(Locale.ROOT)) { + case "eps" -> "eps2write"; + case "ps" -> "ps2write"; + case "pcl" -> "pxlcolor"; // PCL XL color + case "xps" -> "xpswrite"; + default -> + throw ExceptionUtils.createIllegalArgumentException( + "error.invalidFormat", + "Unsupported output format: {0}", + outputFormat); + }; command.add("-sDEVICE=" + device); command.add("-dNOPAUSE"); @@ -187,6 +181,17 @@ public class PdfVectorExportController { ProcessExecutor.getInstance(ProcessExecutor.Processes.GHOSTSCRIPT) .runCommandWithOutputHandling(command); + ExceptionUtils.GhostscriptException criticalError = + ExceptionUtils.detectGhostscriptCriticalError(result.getMessages()); + if (criticalError != null) { + log.error( + "Ghostscript PDF to {} conversion detected critical error: {}. Command: {}", + outputFormat.toUpperCase(), + criticalError.getMessage(), + String.join(" ", command)); + throw criticalError; + } + if (result.getRc() != 0) { log.error( "Ghostscript PDF to {} conversion failed with rc={} and messages={}. Command: {}", @@ -225,6 +230,16 @@ public class PdfVectorExportController { ProcessExecutor.getInstance(ProcessExecutor.Processes.GHOSTSCRIPT) .runCommandWithOutputHandling(command); + ExceptionUtils.GhostscriptException criticalError = + ExceptionUtils.detectGhostscriptCriticalError(result.getMessages()); + if (criticalError != null) { + log.error( + "Ghostscript PostScript-to-PDF conversion detected critical error: {}. Command: {}", + criticalError.getMessage(), + String.join(" ", command)); + throw criticalError; + } + if (result.getRc() != 0) { log.error( "Ghostscript PostScript-to-PDF conversion failed with rc={} and messages={}. Command: {}", diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/AutoSplitPdfController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/AutoSplitPdfController.java index d9c760795..6a1df0f9d 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/AutoSplitPdfController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/AutoSplitPdfController.java @@ -141,14 +141,14 @@ public class AutoSplitPdfController { if (properties != null && properties.getSystem() != null) { renderDpi = properties.getSystem().getMaxDPI(); } + final int dpi = renderDpi; + final int pageNum = page; - try { - bim = pdfRenderer.renderImageWithDPI(page, renderDpi); - } catch (OutOfMemoryError e) { - throw ExceptionUtils.createOutOfMemoryDpiException(page + 1, renderDpi, e); - } catch (NegativeArraySizeException e) { - throw ExceptionUtils.createOutOfMemoryDpiException(page + 1, renderDpi, e); - } + bim = + ExceptionUtils.handleOomRendering( + pageNum + 1, + dpi, + () -> pdfRenderer.renderImageWithDPI(pageNum, dpi)); String result = decodeQRCode(bim); boolean isValidQrCode = VALID_QR_CONTENTS.contains(result); diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/BlankPageController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/BlankPageController.java index 9139edb30..95a4c3b64 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/BlankPageController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/BlankPageController.java @@ -126,16 +126,16 @@ public class BlankPageController { if (properties != null && properties.getSystem() != null) { renderDpi = properties.getSystem().getMaxDPI(); } + final int dpi = renderDpi; + final int currentPageIndex = pageIndex; - try { - image = pdfRenderer.renderImageWithDPI(pageIndex, renderDpi); - } catch (OutOfMemoryError e) { - throw ExceptionUtils.createOutOfMemoryDpiException( - pageIndex + 1, renderDpi, e); - } catch (NegativeArraySizeException e) { - throw ExceptionUtils.createOutOfMemoryDpiException( - pageIndex + 1, renderDpi, e); - } + image = + ExceptionUtils.handleOomRendering( + currentPageIndex + 1, + dpi, + () -> + pdfRenderer.renderImageWithDPI( + currentPageIndex, dpi)); blank = isBlankImage(image, threshold, whitePercent, threshold); } } @@ -174,6 +174,8 @@ public class BlankPageController { return WebResponseUtils.baosToWebResponse( baos, filename + "_processed.zip", MediaType.APPLICATION_OCTET_STREAM); + } catch (ExceptionUtils.OutOfMemoryDpiException e) { + throw e; } catch (IOException e) { log.error("exception", e); return new ResponseEntity<>(HttpStatus.INTERNAL_SERVER_ERROR); diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/CompressController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/CompressController.java index d253b67b0..361da2dc8 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/CompressController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/CompressController.java @@ -5,7 +5,6 @@ import java.awt.image.BufferedImage; import java.io.ByteArrayOutputStream; import java.io.IOException; import java.io.InputStream; -import java.io.InterruptedIOException; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; @@ -104,7 +103,6 @@ public class CompressController { long totalCompressedBytes = 0; } - // Replace all instances of original images with their compressed versions private static void replaceImages( PDDocument doc, Map> uniqueImages, @@ -115,11 +113,9 @@ public class CompressController { ImageIdentity imageIdentity = entry.getKey(); List references = entry.getValue(); - // Skip if no compressed version exists PDImageXObject compressedImage = compressedVersions.get(imageIdentity); if (compressedImage == null) continue; - // Replace ALL instances with the compressed version for (ImageReference ref : references) { replaceImageReference(doc, ref, compressedImage); } @@ -305,19 +301,15 @@ public class CompressController { return compressedVersions; } - // Enhanced hash function to identify identical images with more data private static String generateImageHash(PDImageXObject image) { try { - // Create a stream for the raw stream data try (InputStream stream = image.getCOSObject().createRawInputStream()) { - // Read more data for better hash accuracy (16KB instead of 8KB) byte[] buffer = new byte[16384]; int bytesRead = stream.read(buffer); if (bytesRead > 0) { byte[] dataToHash = bytesRead == buffer.length ? buffer : Arrays.copyOf(buffer, bytesRead); - // Also include image dimensions and color space in the hash String enhancedData = new String(dataToHash, StandardCharsets.UTF_8) + "_" @@ -394,10 +386,8 @@ public class CompressController { } } - // Hash function to identify identical masks private static String generateMaskHash(PDImageXObject image) { try { - // Try to get mask data from either getMask() or getSoftMask() PDImageXObject mask = image.getMask(); if (mask == null) { mask = image.getSoftMask(); @@ -405,7 +395,6 @@ public class CompressController { if (mask != null) { try (InputStream stream = mask.getCOSObject().createRawInputStream()) { - // Read up to first 4KB of mask data for the hash byte[] buffer = new byte[4096]; int bytesRead = stream.read(buffer); if (bytesRead > 0) { @@ -931,11 +920,18 @@ public class CompressController { public ResponseEntity optimizePdf(@ModelAttribute OptimizePdfRequest request) throws Exception { MultipartFile inputFile = request.getFileInput(); + + // Validate input file + if (inputFile == null || inputFile.isEmpty()) { + throw ExceptionUtils.createFileNullOrEmptyException(); + } + Integer optimizeLevel = request.getOptimizeLevel(); String expectedOutputSizeString = request.getExpectedOutputSize(); Boolean convertToGrayscale = request.getGrayscale(); if (expectedOutputSizeString == null && optimizeLevel == null) { - throw new Exception("Both expected output size and optimize level are not specified"); + throw ExceptionUtils.createIllegalArgumentException( + ExceptionUtils.ErrorCode.COMPRESSION_OPTIONS); } Long expectedOutputSize = 0L; @@ -967,7 +963,6 @@ public class CompressController { boolean sizeMet = false; boolean imageCompressionApplied = false; - boolean externalCompressionApplied = false; while (!sizeMet && optimizeLevel <= 9) { // Apply external compression first @@ -978,8 +973,14 @@ public class CompressController { applyGhostscriptCompression(request, optimizeLevel, currentFile); log.info("Ghostscript compression applied successfully"); ghostscriptSuccess = true; + } catch (ExceptionUtils.GhostscriptException e) { + // Critical Ghostscript errors should be propagated + log.error("Ghostscript encountered a critical error: {}", e.getMessage()); + throw e; } catch (IOException e) { - log.warn("Ghostscript compression failed, continuing with other methods"); + log.warn( + "Ghostscript compression failed, continuing with other methods: {}", + e.getMessage()); } } @@ -989,7 +990,7 @@ public class CompressController { applyQpdfCompression(request, optimizeLevel, currentFile); log.info("QPDF compression applied successfully"); } catch (IOException e) { - log.warn("QPDF compression failed"); + log.warn("QPDF compression failed: {}", e.getMessage()); } } else if (!ghostscriptSuccess) { log.info( @@ -997,8 +998,6 @@ public class CompressController { + " only"); } - externalCompressionApplied = true; - // Skip image compression if Ghostscript succeeded if (ghostscriptSuccess) { imageCompressionApplied = true; @@ -1044,7 +1043,6 @@ public class CompressController { } else { // Reset flags for next iteration with higher optimization level imageCompressionApplied = false; - externalCompressionApplied = false; optimizeLevel = newOptimizeLevel; } } @@ -1067,8 +1065,12 @@ public class CompressController { GeneralUtils.generateFilename( inputFile.getOriginalFilename(), "_Optimized.pdf"); - return WebResponseUtils.pdfDocToWebResponse( - pdfDocumentFactory.load(currentFile.toFile()), outputFilename); + try { + return WebResponseUtils.pdfDocToWebResponse( + pdfDocumentFactory.load(currentFile.toFile()), outputFilename); + } catch (IOException e) { + throw ExceptionUtils.handlePdfException(e, "PDF optimization"); + } } finally { // Clean up all temporary files @@ -1176,6 +1178,16 @@ public class CompressController { ProcessExecutor.getInstance(ProcessExecutor.Processes.GHOSTSCRIPT) .runCommandWithOutputHandling(command); + // Check for critical errors in the output before checking return code + String gsOutput = returnCode.getMessages(); + ExceptionUtils.GhostscriptException criticalError = + ExceptionUtils.detectGhostscriptCriticalError(gsOutput); + if (criticalError != null) { + log.error( + "Ghostscript critical error detected: {}", criticalError.getMessage()); + throw criticalError; + } + if (returnCode.getRc() == 0) { // Update current file to the Ghostscript output Files.copy(gsOutputPath, currentFile, StandardCopyOption.REPLACE_EXISTING); @@ -1190,20 +1202,17 @@ public class CompressController { log.warn( "Ghostscript compression failed with return code: {}", returnCode.getRc()); - throw new IOException("Ghostscript compression failed"); + throw ExceptionUtils.createGhostscriptCompressionException(gsOutput); } - // replace the existing catch with these two catches } catch (InterruptedException e) { - // restore interrupted status and propagate as an IOException - Thread.currentThread().interrupt(); - InterruptedIOException ie = - new InterruptedIOException("Ghostscript command interrupted"); - ie.initCause(e); - throw ie; + throw ExceptionUtils.createProcessingInterruptedException("Ghostscript", e); + } catch (ExceptionUtils.GhostscriptException e) { + // Re-throw Ghostscript-specific exceptions + throw e; } catch (Exception e) { log.warn("Ghostscript compression failed, will fallback to other methods", e); - throw new IOException("Ghostscript compression failed", e); + throw ExceptionUtils.createGhostscriptCompressionException(e); } } } @@ -1296,16 +1305,15 @@ public class CompressController { } catch (IOException e) { if (returnCode != null && returnCode.getRc() != 3) { - throw new IOException("QPDF command failed", e); + throw ExceptionUtils.createIOException( + ExceptionUtils.ErrorCode.QPDF_COMPRESSION.getMessageKey(), + ExceptionUtils.ErrorCode.QPDF_COMPRESSION.getDefaultMessage(), + e); } // If QPDF fails, keep using the current file log.warn("QPDF compression failed, continuing with current file", e); } catch (InterruptedException e) { - // restore interrupted status and propagate as an IOException - Thread.currentThread().interrupt(); - InterruptedIOException ie = new InterruptedIOException("QPDF command interrupted"); - ie.initCause(e); - throw ie; + throw ExceptionUtils.createProcessingInterruptedException("QPDF", e); } } } diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/ExtractImageScansController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/ExtractImageScansController.java index 3cfeff80d..1f5df94bc 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/ExtractImageScansController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/ExtractImageScansController.java @@ -108,14 +108,14 @@ public class ExtractImageScansController { if (properties != null && properties.getSystem() != null) { renderDpi = properties.getSystem().getMaxDPI(); } + final int dpi = renderDpi; + final int pageIndex = i; - try { - image = pdfRenderer.renderImageWithDPI(i, renderDpi); - } catch (OutOfMemoryError e) { - throw ExceptionUtils.createOutOfMemoryDpiException(i + 1, renderDpi, e); - } catch (NegativeArraySizeException e) { - throw ExceptionUtils.createOutOfMemoryDpiException(i + 1, renderDpi, e); - } + image = + ExceptionUtils.handleOomRendering( + pageIndex + 1, + dpi, + () -> pdfRenderer.renderImageWithDPI(pageIndex, dpi)); ImageIO.write(image, "png", tempFile.toFile()); // Add temp file path to images list @@ -202,7 +202,8 @@ public class ExtractImageScansController { zipBytes, outputZipFilename, MediaType.APPLICATION_OCTET_STREAM); } if (processedImageBytes.isEmpty()) { - throw new IllegalArgumentException("No images detected"); + throw ExceptionUtils.createIllegalArgumentException( + "error.noContent", "No {0} detected", "images"); } else { // Return the processed image as a response diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/FlattenController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/FlattenController.java index 7a23ac84d..efc7866e2 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/FlattenController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/FlattenController.java @@ -82,24 +82,31 @@ public class FlattenController { : defaultRenderDpi; Integer requestedDpi = request.getRenderDpi(); - int renderDpi = maxDpi; + int renderDpiTemp = maxDpi; if (requestedDpi != null) { - renderDpi = Math.min(requestedDpi, maxDpi); - renderDpi = Math.max(renderDpi, 72); + renderDpiTemp = Math.min(requestedDpi, maxDpi); + renderDpiTemp = Math.max(renderDpiTemp, 72); } + final int renderDpi = renderDpiTemp; int numPages = document.getNumberOfPages(); for (int i = 0; i < numPages; i++) { + final int pageIndex = i; + BufferedImage image = null; try { - BufferedImage image; + // Validate dimensions BEFORE rendering to prevent OOM + ExceptionUtils.validateRenderingDimensions( + document.getPage(pageIndex), pageIndex + 1, renderDpi); + + // Wrap entire rendering operation to catch OutOfMemoryError from any depth + image = + ExceptionUtils.handleOomRendering( + pageIndex + 1, + renderDpi, + () -> + pdfRenderer.renderImageWithDPI( + pageIndex, renderDpi, ImageType.RGB)); - try { - image = pdfRenderer.renderImageWithDPI(i, renderDpi, ImageType.RGB); - } catch (OutOfMemoryError e) { - throw ExceptionUtils.createOutOfMemoryDpiException(i + 1, renderDpi, e); - } catch (NegativeArraySizeException e) { - throw ExceptionUtils.createOutOfMemoryDpiException(i + 1, renderDpi, e); - } PDPage page = new PDPage(); page.setMediaBox(document.getPage(i).getMediaBox()); newDocument.addPage(page); @@ -111,8 +118,22 @@ public class FlattenController { contentStream.drawImage(pdImage, 0, 0, pageWidth, pageHeight); } + } catch (ExceptionUtils.OutOfMemoryDpiException e) { + // Re-throw OutOfMemoryDpiException to be handled by GlobalExceptionHandler + newDocument.close(); + document.close(); + throw e; } catch (IOException e) { - log.error("exception", e); + log.error("IOException during page processing: ", e); + // Continue processing other pages + } catch (OutOfMemoryError e) { + // Catch any OutOfMemoryError that escaped the inner try block + newDocument.close(); + document.close(); + throw ExceptionUtils.createOutOfMemoryDpiException(i + 1, renderDpi, e); + } finally { + // Help GC by clearing the image reference + image = null; } } return WebResponseUtils.pdfDocToWebResponse( diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/OCRController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/OCRController.java index 1e803c839..291624629 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/OCRController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/OCRController.java @@ -101,7 +101,7 @@ public class OCRController { } if (!"hocr".equals(ocrRenderType) && !"sandwich".equals(ocrRenderType)) { - throw new IOException("ocrRenderType wrong"); + throw ExceptionUtils.createOcrInvalidRenderTypeException(); } // Get available Tesseract languages @@ -270,7 +270,7 @@ public class OCRController { } if (result.getRc() != 0) { - throw new IOException("OCRmyPDF failed with return code: " + result.getRc()); + throw ExceptionUtils.createOcrProcessingFailedException(result.getRc()); } // Remove images from the OCR processed PDF if the flag is set to true @@ -351,16 +351,14 @@ public class OCRController { && applicationProperties.getSystem() != null) { renderDpi = applicationProperties.getSystem().getMaxDPI(); } + final int dpi = renderDpi; + final int currentPageNum = pageNum; - try { - image = pdfRenderer.renderImageWithDPI(pageNum, renderDpi); - } catch (OutOfMemoryError e) { - throw ExceptionUtils.createOutOfMemoryDpiException( - pageNum + 1, renderDpi, e); - } catch (NegativeArraySizeException e) { - throw ExceptionUtils.createOutOfMemoryDpiException( - pageNum + 1, renderDpi, e); - } + image = + ExceptionUtils.handleOomRendering( + currentPageNum + 1, + dpi, + () -> pdfRenderer.renderImageWithDPI(currentPageNum, dpi)); File imagePath = new File( tempImagesDir, diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/PrintFileController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/PrintFileController.java index 1f0934779..159022495 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/PrintFileController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/PrintFileController.java @@ -31,6 +31,7 @@ import io.swagger.v3.oas.annotations.tags.Tag; import lombok.extern.slf4j.Slf4j; import stirling.software.SPDF.model.api.misc.PrintFileRequest; +import stirling.software.common.util.ExceptionUtils; @RestController @RequestMapping("/api/v1/misc") @@ -51,7 +52,8 @@ public class PrintFileController { String originalFilename = file.getOriginalFilename(); if (originalFilename != null && (originalFilename.contains("..") || Paths.get(originalFilename).isAbsolute())) { - throw new IOException("Invalid file path detected: " + originalFilename); + throw ExceptionUtils.createIllegalArgumentException( + "error.invalid.filepath", "Invalid file path detected: " + originalFilename); } String printerName = request.getPrinterName(); String contentType = file.getContentType(); diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/RepairController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/RepairController.java index f662f59f4..405df9778 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/RepairController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/RepairController.java @@ -21,6 +21,7 @@ import lombok.extern.slf4j.Slf4j; import stirling.software.SPDF.config.EndpointConfiguration; import stirling.software.common.model.api.PDFFile; 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.ProcessExecutor.ProcessExecutorResult; @@ -116,7 +117,9 @@ public class RepairController { repairSuccess = true; } } else { - throw new IOException("PDF repair failed with available tools"); + throw ExceptionUtils.createFileProcessingException( + "PDF repair", + new IOException("PDF repair failed with available tools")); } } diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/ScannerEffectController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/ScannerEffectController.java index 05c5b6ac7..8959ec287 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/ScannerEffectController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/ScannerEffectController.java @@ -45,6 +45,7 @@ import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import stirling.software.SPDF.model.api.misc.ScannerEffectRequest; import stirling.software.common.model.ApplicationProperties; @@ -58,6 +59,7 @@ import stirling.software.common.util.WebResponseUtils; @RequestMapping("/api/v1/misc") @Tag(name = "Misc", description = "Miscellaneous PDF APIs") @RequiredArgsConstructor +@Slf4j public class ScannerEffectController { private final CustomPDFDocumentFactory pdfDocumentFactory; @@ -95,11 +97,8 @@ public class ScannerEffectController { private static BufferedImage renderPageSafely(PDFRenderer renderer, int pageIndex, int dpi) throws IOException { - try { - return renderer.renderImageWithDPI(pageIndex, dpi); - } catch (OutOfMemoryError | NegativeArraySizeException e) { - throw ExceptionUtils.createOutOfMemoryDpiException(pageIndex + 1, dpi, e); - } + return ExceptionUtils.handleOomRendering( + pageIndex + 1, dpi, () -> renderer.renderImageWithDPI(pageIndex, dpi)); } private static BufferedImage convertColorspace( @@ -490,7 +489,8 @@ public class ScannerEffectController { float noise, boolean yellowish, int renderResolution, - ScannerEffectRequest.Colorspace colorspace) { + ScannerEffectRequest.Colorspace colorspace) + throws ExceptionUtils.OutOfMemoryDpiException { try { PDRectangle pageSize = renderingResources.getPageMediaBox(pageIndex); @@ -548,7 +548,8 @@ public class ScannerEffectController { } return new ProcessedPage(adjusted, origW, origH, offsetX, offsetY, drawW, drawH); } catch (IOException e) { - throw new RuntimeException("Failed to process page " + (pageIndex + 1), e); + throw ExceptionUtils.wrapException( + e, "scanner effect processing for page " + (pageIndex + 1)); } catch (OutOfMemoryError | NegativeArraySizeException e) { throw ExceptionUtils.createOutOfMemoryDpiException(pageIndex + 1, renderResolution, e); } @@ -690,9 +691,12 @@ public class ScannerEffectController { } } catch (InterruptedException e) { Thread.currentThread().interrupt(); - throw new IOException("Parallel page processing interrupted", e); + throw ExceptionUtils.createProcessingInterruptedException( + "scanner effect parallel page processing", e); } catch (ExecutionException e) { - throw new IOException("Parallel page processing failed", e.getCause()); + throw ExceptionUtils.createFileProcessingException( + "scanner effect parallel page processing", + new IOException("Processing failed", e.getCause())); } finally { renderingResources.remove(); for (RenderingResources resources : renderingResourcesToClose) { @@ -801,7 +805,7 @@ public class ScannerEffectController { if (!pool.awaitTermination(60, TimeUnit.SECONDS)) { pool.shutdownNow(); if (!pool.awaitTermination(60, TimeUnit.SECONDS)) { - throw new RuntimeException("ForkJoinPool did not terminate"); + log.warn("ForkJoinPool did not terminate within timeout"); } } } catch (InterruptedException e) { diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/StampController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/StampController.java index dbc421c43..dd570df68 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/StampController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/StampController.java @@ -46,6 +46,7 @@ import lombok.RequiredArgsConstructor; import stirling.software.SPDF.model.api.misc.AddStampRequest; import stirling.software.common.service.CustomPDFDocumentFactory; +import stirling.software.common.util.ExceptionUtils; import stirling.software.common.util.GeneralUtils; import stirling.software.common.util.RegexPatternUtils; import stirling.software.common.util.TempFile; @@ -90,7 +91,8 @@ public class StampController { MultipartFile pdfFile = request.getFileInput(); String pdfFileName = pdfFile.getOriginalFilename(); if (pdfFileName.contains("..") || pdfFileName.startsWith("/")) { - throw new IllegalArgumentException("Invalid PDF file path"); + throw ExceptionUtils.createIllegalArgumentException( + "error.invalid.filepath", "Invalid PDF file path: " + pdfFileName); } String stampType = request.getStampType(); @@ -98,14 +100,19 @@ public class StampController { MultipartFile stampImage = request.getStampImage(); if ("image".equalsIgnoreCase(stampType)) { if (stampImage == null) { - throw new IllegalArgumentException( + throw ExceptionUtils.createIllegalArgumentException( + "error.stamp.image.required", "Stamp image file must be provided when stamp type is 'image'"); } String stampImageName = stampImage.getOriginalFilename(); if (stampImageName == null || stampImageName.contains("..") || stampImageName.startsWith("/")) { - throw new IllegalArgumentException("Invalid stamp image file path"); + throw ExceptionUtils.createIllegalArgumentException( + "error.invalidFormat", + "Invalid {0} format: {1}", + "stamp image file path", + stampImageName); } } String alphabet = request.getAlphabet(); diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/security/GetInfoOnPDF.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/security/GetInfoOnPDF.java index 543b848f0..2c2c401f9 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/security/GetInfoOnPDF.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/security/GetInfoOnPDF.java @@ -474,10 +474,11 @@ public class GetInfoOnPDF { } if (file.getSize() > MAX_FILE_SIZE) { - throw new IllegalArgumentException( - String.format( - "File size (%d bytes) exceeds maximum allowed size (%d bytes)", - file.getSize(), MAX_FILE_SIZE)); + throw ExceptionUtils.createIllegalArgumentException( + "error.fileSizeLimit", + "File size ({0} bytes) exceeds maximum allowed size ({1} bytes)", + file.getSize(), + MAX_FILE_SIZE); } String contentType = file.getContentType(); diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/security/PasswordController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/security/PasswordController.java index f4dd529e6..7c053e958 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/security/PasswordController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/security/PasswordController.java @@ -43,7 +43,17 @@ public class PasswordController { throws IOException { MultipartFile fileInput = request.getFileInput(); String password = request.getPassword(); - PDDocument document = pdfDocumentFactory.load(fileInput, password); + + PDDocument document; + try { + document = pdfDocumentFactory.load(fileInput, password); + } catch (IOException e) { + // Handle password errors specifically + if (ExceptionUtils.isPasswordError(e)) { + throw ExceptionUtils.createPdfPasswordException(e); + } + throw ExceptionUtils.handlePdfException(e); + } try { document.setAllSecurityToBeRemoved(true); diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/security/RedactController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/security/RedactController.java index 096b450db..3f89d5903 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/security/RedactController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/security/RedactController.java @@ -62,6 +62,7 @@ import stirling.software.SPDF.utils.text.TextFinderUtils; import stirling.software.SPDF.utils.text.WidthCalculator; import stirling.software.common.model.api.security.RedactionArea; import stirling.software.common.service.CustomPDFDocumentFactory; +import stirling.software.common.util.ExceptionUtils; import stirling.software.common.util.GeneralUtils; import stirling.software.common.util.PdfUtils; import stirling.software.common.util.WebResponseUtils; @@ -508,7 +509,8 @@ public class RedactController { boolean wholeWordSearchBool = Boolean.TRUE.equals(request.getWholeWordSearch()); if (listOfText.length == 0 || (listOfText.length == 1 && listOfText[0].trim().isEmpty())) { - throw new IllegalArgumentException("No text patterns provided for redaction"); + throw ExceptionUtils.createIllegalArgumentException( + "error.redaction.no.patterns", "No text patterns provided for redaction"); } PDDocument document = null; @@ -517,14 +519,15 @@ public class RedactController { try { if (request.getFileInput() == null) { log.error("File input is null"); - throw new IllegalArgumentException("File input cannot be null"); + throw ExceptionUtils.createFileNullOrEmptyException(); } document = pdfDocumentFactory.load(request.getFileInput()); if (document == null) { log.error("Failed to load PDF document"); - throw new IllegalArgumentException("Failed to load PDF document"); + throw ExceptionUtils.createPdfCorruptedException( + "during redaction", new IOException("Failed to load PDF document")); } Map> allFoundTextsByPage = diff --git a/app/core/src/main/java/stirling/software/SPDF/exception/GlobalExceptionHandler.java b/app/core/src/main/java/stirling/software/SPDF/exception/GlobalExceptionHandler.java new file mode 100644 index 000000000..35f2bdd55 --- /dev/null +++ b/app/core/src/main/java/stirling/software/SPDF/exception/GlobalExceptionHandler.java @@ -0,0 +1,1188 @@ +package stirling.software.SPDF.exception; + +import java.io.IOException; +import java.net.URI; +import java.time.Instant; +import java.util.List; + +import org.springframework.context.MessageSource; +import org.springframework.context.i18n.LocaleContextHolder; +import org.springframework.core.env.Environment; +import org.springframework.http.HttpStatus; +import org.springframework.http.ProblemDetail; +import org.springframework.http.ResponseEntity; +import org.springframework.http.converter.HttpMessageNotReadableException; +import org.springframework.web.HttpMediaTypeNotSupportedException; +import org.springframework.web.HttpRequestMethodNotSupportedException; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.MissingServletRequestParameterException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.multipart.MaxUploadSizeExceededException; +import org.springframework.web.multipart.support.MissingServletRequestPartException; +import org.springframework.web.servlet.NoHandlerFoundException; + +import jakarta.servlet.http.HttpServletRequest; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +import stirling.software.common.util.ExceptionUtils; +import stirling.software.common.util.ExceptionUtils.*; +import stirling.software.common.util.RegexPatternUtils; + +/** + * Returns RFC 7807 Problem Details for HTTP APIs, ensuring consistent error responses across the + * application. + * + *

Exception Handler Hierarchy:

+ * + *
    + *
  1. Application Exceptions (extends BaseAppException) + *
      + *
    • {@link PdfPasswordException} - 400 Bad Request (user-provided input issue) + *
    • {@link OutOfMemoryDpiException} - 400 Bad Request (user-provided parameter issue) + *
    • {@link PdfCorruptedException} - 400 Bad Request (invalid file content) + *
    • {@link PdfEncryptionException} - 400 Bad Request (invalid file content) + *
    • {@link GhostscriptException} - 500 Internal Server Error (external process failure) + *
    • Other {@link BaseAppException} - 500 Internal Server Error + *
    + *
  2. Validation Exceptions (extends BaseValidationException) + *
      + *
    • {@link CbrFormatException} - 400 Bad Request + *
    • {@link CbzFormatException} - 400 Bad Request + *
    • {@link EmlFormatException} - 400 Bad Request + *
    • Other {@link BaseValidationException} - 400 Bad Request + *
    + *
  3. Spring Framework Exceptions + *
      + *
    • {@link MethodArgumentNotValidException} - 400 Bad Request + *
    • {@link MissingServletRequestParameterException} - 400 Bad Request + *
    • {@link MissingServletRequestPartException} - 400 Bad Request + *
    • {@link MaxUploadSizeExceededException} - 413 Payload Too Large + *
    • {@link HttpRequestMethodNotSupportedException} - 405 Method Not Allowed + *
    • {@link HttpMediaTypeNotSupportedException} - 415 Unsupported Media Type + *
    • {@link HttpMessageNotReadableException} - 400 Bad Request + *
    • {@link NoHandlerFoundException} - 404 Not Found + *
    + *
  4. Java Standard Exceptions + *
      + *
    • {@link IllegalArgumentException} - 400 Bad Request + *
    • {@link IOException} - 500 Internal Server Error + *
    • {@link Exception} - 500 Internal Server Error (catch-all) + *
    + *
+ * + *

Usage Examples:

+ * + *
{@code
+ * // In controllers/services - use ExceptionUtils to create typed exceptions:
+ * try {
+ *     PDDocument doc = PDDocument.load(file);
+ * } catch (IOException e) {
+ *     throw ExceptionUtils.createPdfCorruptedException("during load", e);
+ * }
+ * // -> GlobalExceptionHandler catches it and returns HTTP 422 with Problem Detail
+ *
+ * // For validation errors:
+ * if (file == null || file.isEmpty()) {
+ *     throw ExceptionUtils.createFileNullOrEmptyException();
+ * }
+ * // -> Returns HTTP 400 with error code "E032"
+ *
+ * // Spring validation automatically handled:
+ * public void processFile(@Valid FileRequest request) { ... }
+ * // -> Returns HTTP 400 with field-level validation errors
+ *
+ * // File size limits automatically enforced:
+ * // -> Returns HTTP 413 when upload exceeds spring.servlet.multipart.max-file-size
+ * }
+ * + *

Best Practices:

+ * + *
    + *
  • Use {@link ExceptionUtils} factory methods to create exceptions (ensures error codes) + *
  • Add context to exceptions (e.g., "during merge" helps debugging) + *
  • Let this handler convert exceptions to HTTP responses (don't return ResponseEntity from + * controllers) + *
  • Check messages.properties for localized error messages before adding new ones + *
+ * + *

Creating Custom Exceptions:

+ * + *
{@code
+ * // 1. Register a new error code in ExceptionUtils.ErrorCode enum:
+ * CUSTOM_ERROR("E999", "Custom error occurred"),
+ *
+ * // 2. Create a new exception class in ExceptionUtils:
+ * public static class CustomException extends BaseAppException {
+ *     public CustomException(String message, Throwable cause, String errorCode) {
+ *         super(message, cause, errorCode);
+ *     }
+ * }
+ *
+ * // 3. Create factory method in ExceptionUtils:
+ * public static CustomException createCustomException(String context) {
+ *     String message = getLocalizedMessage(
+ *         ErrorCode.CUSTOM_ERROR,
+ *         "Custom operation failed");
+ *     return new CustomException(
+ *         message + " " + context,
+ *         null,
+ *         ErrorCode.CUSTOM_ERROR.getCode());
+ * }
+ *
+ * // 4. Add handler in GlobalExceptionHandler:
+ * @ExceptionHandler(CustomException.class)
+ * public ResponseEntity handleCustomException(
+ *         CustomException ex, HttpServletRequest request) {
+ *     logException("error", "Custom", request, ex, ex.getErrorCode());
+ *     String title = getLocalizedMessage(
+ *         "error.custom.title",
+ *         ErrorTitles.CUSTOM_DEFAULT);
+ *     return createProblemDetailResponse(
+ *         ex, HttpStatus.BAD_REQUEST, ErrorTypes.CUSTOM, title, request);
+ * }
+ *
+ * // 5. Add localized messages in messages.properties:
+ * error.E999=Custom error occurred
+ * error.E999.hint.1=Check the input parameters
+ * error.E999.hint.2=Verify the configuration
+ * error.E999.actionRequired=Review and correct the request
+ * error.custom.title=Custom Error
+ * }
+ * + * @see RFC 7807: Problem Details for HTTP + * APIs + * @see ExceptionUtils + */ +@Slf4j +@RestControllerAdvice +@RequiredArgsConstructor +public class GlobalExceptionHandler { + + private final MessageSource messageSource; + private final Environment environment; + + private static final org.springframework.http.MediaType PROBLEM_JSON = + org.springframework.http.MediaType.APPLICATION_PROBLEM_JSON; + + private Boolean isDevelopmentMode; + + /** + * Create a base ProblemDetail with common properties (timestamp, path). + * + *

This method provides a foundation for all ProblemDetail responses with standardized + * metadata. + * + * @param status the HTTP status code + * @param detail the problem detail message + * @param request the HTTP servlet request + * @return a ProblemDetail with timestamp and path properties set + */ + private static ProblemDetail createBaseProblemDetail( + HttpStatus status, String detail, HttpServletRequest request) { + ProblemDetail problemDetail = ProblemDetail.forStatusAndDetail(status, detail); + problemDetail.setProperty("timestamp", Instant.now()); + problemDetail.setProperty("path", request.getRequestURI()); + return problemDetail; + } + + /** + * Helper method to create a standardized ProblemDetail response for exceptions with error + * codes. + * + *

This method uses the {@link ExceptionUtils.ErrorCodeProvider} interface for type-safe + * polymorphic handling of both {@link BaseAppException} and {@link BaseValidationException}, + * which are created by {@link ExceptionUtils} factory methods. + * + *

The error codes follow the format defined in {@link ExceptionUtils.ErrorCode} enum, + * ensuring consistency across the application. + * + * @param ex the exception implementing ErrorCodeProvider interface + * @param status the HTTP status + * @param typeUri the problem type URI + * @param title the problem title + * @param request the HTTP servlet request + * @return ResponseEntity with ProblemDetail including errorCode property + */ + private static ResponseEntity createProblemDetailResponse( + ExceptionUtils.ErrorCodeProvider ex, + HttpStatus status, + String typeUri, + String title, + HttpServletRequest request) { + + ProblemDetail problemDetail = createBaseProblemDetail(status, ex.getMessage(), request); + problemDetail.setType(URI.create(typeUri)); + problemDetail.setTitle(title); + // Also set as property to ensure serialization (Spring Boot compatibility) + problemDetail.setProperty("title", title); + problemDetail.setProperty("errorCode", ex.getErrorCode()); + + // Attach hints and actionRequired from centralized registry (single call) + enrichWithErrorMetadata(problemDetail, ex.getErrorCode()); + + return ResponseEntity.status(status).contentType(PROBLEM_JSON).body(problemDetail); + } + + /** + * Log exception with standardized format and appropriate log level. + * + * @param level the log level ("debug", "warn", "error") + * @param category the error category (e.g., "Validation", "PDF") + * @param request the HTTP servlet request + * @param ex the exception to log + * @param errorCode the error code (optional) + */ + private static void logException( + String level, + String category, + HttpServletRequest request, + Exception ex, + String errorCode) { + String message = + errorCode != null + ? String.format( + "%s error at %s: %s (%s)", + category, request.getRequestURI(), ex.getMessage(), errorCode) + : String.format( + "%s error at %s: %s", + category, request.getRequestURI(), ex.getMessage()); + + switch (level.toLowerCase()) { + case "warn" -> log.warn(message); + case "error" -> log.error(message, ex); + default -> log.debug(message); + } + } + + /** + * Enrich ProblemDetail with error metadata (hints and action required) from error code + * registry. + * + *

This method retrieves hints and actionRequired text for the given error code from the + * centralized error code registry in ExceptionUtils. + * + * @param problemDetail the ProblemDetail to enrich + * @param errorCode the error code to look up + */ + private static void enrichWithErrorMetadata(ProblemDetail problemDetail, String errorCode) { + List hints = ExceptionUtils.getHintsForErrorCode(errorCode); + if (!hints.isEmpty()) { + problemDetail.setProperty("hints", hints); + } + + String actionRequired = ExceptionUtils.getActionRequiredForErrorCode(errorCode); + if (actionRequired != null && !actionRequired.isBlank()) { + problemDetail.setProperty("actionRequired", actionRequired); + } + } + + /** + * Handle PDF password exceptions. + * + *

When thrown: When a PDF file requires a password that was not provided or is incorrect. + * + *

Client action: Prompt the user to provide the correct PDF password and retry the request. + * + *

Related: {@link ExceptionUtils#createPdfPasswordException(Exception)} + * + * @param ex the PdfPasswordException + * @param request the HTTP servlet request + * @return ProblemDetail with HTTP 400 BAD_REQUEST (changed from 422 for better client + * compatibility) + */ + @ExceptionHandler(PdfPasswordException.class) + public ResponseEntity handlePdfPassword( + PdfPasswordException ex, HttpServletRequest request) { + logException("warn", "PDF password", request, ex, ex.getErrorCode()); + + String title = + getLocalizedMessage("error.pdfPassword.title", ErrorTitles.PDF_PASSWORD_DEFAULT); + return createProblemDetailResponse( + ex, HttpStatus.BAD_REQUEST, ErrorTypes.PDF_PASSWORD, title, request); + } + + /** + * Handle Ghostscript processing exceptions originating from external binaries. + * + * @param ex the GhostscriptException + * @param request the HTTP servlet request + * @return ProblemDetail with HTTP 500 INTERNAL_SERVER_ERROR (external process failure) + */ + @ExceptionHandler(GhostscriptException.class) + public ResponseEntity handleGhostscriptException( + GhostscriptException ex, HttpServletRequest request) { + logException("warn", "Ghostscript", request, ex, ex.getErrorCode()); + + String title = + getLocalizedMessage( + "error.ghostscriptCompression.title", ErrorTitles.GHOSTSCRIPT_DEFAULT); + return createProblemDetailResponse( + ex, HttpStatus.INTERNAL_SERVER_ERROR, ErrorTypes.GHOSTSCRIPT, title, request); + } + + /** + * Handle FFmpeg dependency missing errors when media conversion endpoints are invoked. + * + * @param ex the FfmpegRequiredException + * @param request the HTTP servlet request + * @return ProblemDetail with HTTP 503 SERVICE_UNAVAILABLE + */ + @ExceptionHandler(FfmpegRequiredException.class) + public ResponseEntity handleFfmpegRequired( + FfmpegRequiredException ex, HttpServletRequest request) { + logException("warn", "FFmpeg unavailable", request, ex, ex.getErrorCode()); + + String title = + getLocalizedMessage( + "error.ffmpegRequired.title", ErrorTitles.FFMPEG_REQUIRED_DEFAULT); + return createProblemDetailResponse( + ex, HttpStatus.SERVICE_UNAVAILABLE, ErrorTypes.FFMPEG_REQUIRED, title, request); + } + + /** + * Handle PDF and DPI-related BaseAppException subtypes. + * + *

Related factory methods in {@link ExceptionUtils}: + * + *

    + *
  • {@link ExceptionUtils#createPdfCorruptedException(String, Exception)} + *
  • {@link ExceptionUtils#createPdfEncryptionException(Exception)} + *
  • {@link ExceptionUtils#createOutOfMemoryDpiException(int, int, Throwable)} + *
+ * + * @param ex the BaseAppException + * @param request the HTTP servlet request + * @return ProblemDetail with appropriate HTTP status + */ + @ExceptionHandler({ + PdfCorruptedException.class, + PdfEncryptionException.class, + OutOfMemoryDpiException.class + }) + public ResponseEntity handlePdfAndDpiExceptions( + BaseAppException ex, HttpServletRequest request) { + + HttpStatus status; + String type; + String title; + String category; + + if (ex instanceof OutOfMemoryDpiException) { + // Use BAD_REQUEST for better client compatibility (was 422/507) + status = HttpStatus.BAD_REQUEST; + type = ErrorTypes.OUT_OF_MEMORY_DPI; + title = + getLocalizedMessage( + "error.outOfMemoryDpi.title", ErrorTitles.OUT_OF_MEMORY_DPI_DEFAULT); + category = "Out of Memory DPI"; + } else if (ex instanceof PdfCorruptedException) { + // Use BAD_REQUEST for better client compatibility (was 422) + status = HttpStatus.BAD_REQUEST; + type = ErrorTypes.PDF_CORRUPTED; + title = + getLocalizedMessage( + "error.pdfCorrupted.title", ErrorTitles.PDF_CORRUPTED_DEFAULT); + category = "PDF Corrupted"; + } else if (ex instanceof PdfEncryptionException) { + // Use BAD_REQUEST for better client compatibility (was 422) + status = HttpStatus.BAD_REQUEST; + type = ErrorTypes.PDF_ENCRYPTION; + title = + getLocalizedMessage( + "error.pdfEncryption.title", ErrorTitles.PDF_ENCRYPTION_DEFAULT); + category = "PDF Encryption"; + } else { + status = HttpStatus.BAD_REQUEST; + type = ErrorTypes.APP_ERROR; + title = getLocalizedMessage("error.application.title", ErrorTitles.APPLICATION_DEFAULT); + category = "Application"; + } + + logException("error", category, request, ex, ex.getErrorCode()); + return createProblemDetailResponse(ex, status, type, title, request); + } + + /** + * Handle archive format validation exceptions. + * + *

Related factory methods in {@link ExceptionUtils}: + * + *

    + *
  • {@link ExceptionUtils#createCbrInvalidFormatException(String)} + *
  • {@link ExceptionUtils#createCbzInvalidFormatException(Exception)} + *
  • {@link ExceptionUtils#createEmlInvalidFormatException()} + *
+ * + * @param ex the format exception + * @param request the HTTP servlet request + * @return ProblemDetail with HTTP 400 BAD_REQUEST + */ + @ExceptionHandler({ + CbrFormatException.class, + CbzFormatException.class, + EmlFormatException.class + }) + public ResponseEntity handleFormatExceptions( + BaseValidationException ex, HttpServletRequest request) { + + String type; + String title; + String category; + + if (ex instanceof CbrFormatException) { + type = ErrorTypes.CBR_FORMAT; + title = getLocalizedMessage("error.cbrFormat.title", ErrorTitles.CBR_FORMAT_DEFAULT); + category = "CBR format"; + } else if (ex instanceof CbzFormatException) { + type = ErrorTypes.CBZ_FORMAT; + title = getLocalizedMessage("error.cbzFormat.title", ErrorTitles.CBZ_FORMAT_DEFAULT); + category = "CBZ format"; + } else if (ex instanceof EmlFormatException) { + type = ErrorTypes.EML_FORMAT; + title = getLocalizedMessage("error.emlFormat.title", ErrorTitles.EML_FORMAT_DEFAULT); + category = "EML format"; + } else { + type = ErrorTypes.FORMAT_ERROR; + title = + getLocalizedMessage( + "error.formatError.title", ErrorTitles.FORMAT_ERROR_DEFAULT); + category = "Format"; + } + + logException("warn", category, request, ex, ex.getErrorCode()); + return createProblemDetailResponse(ex, HttpStatus.BAD_REQUEST, type, title, request); + } + + /** + * Handle generic validation exceptions. + * + * @param ex the BaseValidationException + * @param request the HTTP servlet request + * @return ProblemDetail with HTTP 400 BAD_REQUEST + */ + @ExceptionHandler(BaseValidationException.class) + public ResponseEntity handleValidation( + BaseValidationException ex, HttpServletRequest request) { + logException("warn", "Validation", request, ex, ex.getErrorCode()); + String title = + getLocalizedMessage("error.validation.title", ErrorTitles.VALIDATION_DEFAULT); + return createProblemDetailResponse( + ex, HttpStatus.BAD_REQUEST, ErrorTypes.VALIDATION, title, request); + } + + /** + * Handle all BaseAppException subtypes not handled by specific handlers. + * + * @param ex the BaseAppException + * @param request the HTTP servlet request + * @return ProblemDetail with HTTP 500 INTERNAL_SERVER_ERROR + */ + @ExceptionHandler(BaseAppException.class) + public ResponseEntity handleBaseApp( + BaseAppException ex, HttpServletRequest request) { + logException("error", "Application", request, ex, ex.getErrorCode()); + String title = + getLocalizedMessage("error.application.title", ErrorTitles.APPLICATION_DEFAULT); + return createProblemDetailResponse( + ex, HttpStatus.INTERNAL_SERVER_ERROR, ErrorTypes.APPLICATION, title, request); + } + + /** + * Handle Bean Validation errors from @Valid annotations. + * + *

When thrown: When request body or parameters fail @Valid constraint validations. + * + *

Client action: Review the 'errors' field in the response for specific validation failures + * and correct the request payload. + * + * @param ex the MethodArgumentNotValidException + * @param request the HTTP servlet request + * @return ProblemDetail with HTTP 400 BAD_REQUEST + */ + @ExceptionHandler(MethodArgumentNotValidException.class) + public ResponseEntity handleMethodArgumentNotValid( + MethodArgumentNotValidException ex, HttpServletRequest request) { + log.warn( + "Bean validation error at {}: {} field errors", + request.getRequestURI(), + ex.getBindingResult().getErrorCount()); + + List errors = + ex.getBindingResult().getFieldErrors().stream() + .map( + error -> + String.format( + "%s: %s", + error.getField(), error.getDefaultMessage())) + .toList(); + + String title = + getLocalizedMessage( + "error.validation.title", ErrorTitles.REQUEST_VALIDATION_FAILED_DEFAULT); + String detail = getLocalizedMessage("error.validation.detail", "Validation failed"); + + ProblemDetail problemDetail = + createBaseProblemDetail(HttpStatus.BAD_REQUEST, detail, request); + problemDetail.setType(URI.create(ErrorTypes.VALIDATION)); + problemDetail.setTitle(title); + problemDetail.setProperty("title", title); // Ensure serialization + problemDetail.setProperty("errors", errors); + addStandardHints( + problemDetail, + "error.validation.hints", + List.of( + "Review the 'errors' list and correct the specified fields.", + "Ensure data types and formats match the API schema.", + "Resend the request after fixing validation issues.")); + problemDetail.setProperty( + "actionRequired", "Correct the invalid fields and resend the request."); + + return ResponseEntity.badRequest().contentType(PROBLEM_JSON).body(problemDetail); + } + + /** + * Handle missing request parameters. + * + *

When thrown: When a required @RequestParam is missing from the request. + * + *

Client action: Add the missing parameter specified in 'parameterName' to the request. + * + * @param ex the MissingServletRequestParameterException + * @param request the HTTP servlet request + * @return ProblemDetail with HTTP 400 BAD_REQUEST + */ + @ExceptionHandler(MissingServletRequestParameterException.class) + public ResponseEntity handleMissingParameter( + MissingServletRequestParameterException ex, HttpServletRequest request) { + log.warn("Missing parameter at {}: {}", request.getRequestURI(), ex.getParameterName()); + + String message = + getLocalizedMessage( + "error.missingParameter.detail", + String.format( + "Required parameter '%s' of type '%s' is missing", + ex.getParameterName(), ex.getParameterType()), + ex.getParameterName(), + ex.getParameterType()); + + String title = + getLocalizedMessage( + "error.missingParameter.title", ErrorTitles.MISSING_PARAMETER_DEFAULT); + + ProblemDetail problemDetail = + createBaseProblemDetail(HttpStatus.BAD_REQUEST, message, request); + problemDetail.setType(URI.create(ErrorTypes.MISSING_PARAMETER)); + problemDetail.setTitle(title); + problemDetail.setProperty("title", title); // Ensure serialization + problemDetail.setProperty("parameterName", ex.getParameterName()); + problemDetail.setProperty("parameterType", ex.getParameterType()); + addStandardHints( + problemDetail, + "error.missingParameter.hints", + List.of( + "Add the missing parameter to the query string or form data.", + "Verify the parameter name is spelled correctly.", + "Provide a value matching the required type.")); + problemDetail.setProperty( + "actionRequired", + String.format("Add the required '%s' parameter and retry.", ex.getParameterName())); + + return ResponseEntity.badRequest().contentType(PROBLEM_JSON).body(problemDetail); + } + + /** + * Handle missing multipart file in request. + * + *

When thrown: When a required @RequestPart (file upload) is missing from a multipart + * request. + * + *

Client action: Include the missing file part specified in 'partName' in the multipart + * request. + * + * @param ex the MissingServletRequestPartException + * @param request the HTTP servlet request + * @return ProblemDetail with HTTP 400 BAD_REQUEST + */ + @ExceptionHandler(MissingServletRequestPartException.class) + public ResponseEntity handleMissingPart( + MissingServletRequestPartException ex, HttpServletRequest request) { + log.warn("Missing file part at {}: {}", request.getRequestURI(), ex.getRequestPartName()); + + String message = + getLocalizedMessage( + "error.missingFile.detail", + String.format( + "Required file part '%s' is missing", ex.getRequestPartName()), + ex.getRequestPartName()); + + String title = + getLocalizedMessage("error.missingFile.title", ErrorTitles.MISSING_FILE_DEFAULT); + + ProblemDetail problemDetail = + createBaseProblemDetail(HttpStatus.BAD_REQUEST, message, request); + problemDetail.setType(URI.create(ErrorTypes.MISSING_FILE)); + problemDetail.setTitle(title); + problemDetail.setProperty("title", title); // Ensure serialization + problemDetail.setProperty("partName", ex.getRequestPartName()); + addStandardHints( + problemDetail, + "error.missingFile.hints", + List.of( + "Attach the missing file part to the multipart/form-data request.", + "Ensure the field name matches the API specification.", + "Check that your client is sending multipart data correctly.")); + problemDetail.setProperty( + "actionRequired", + String.format("Attach the '%s' file part and retry.", ex.getRequestPartName())); + + return ResponseEntity.badRequest().contentType(PROBLEM_JSON).body(problemDetail); + } + + /** + * Handle file upload size exceeded. + * + *

When thrown: When an uploaded file exceeds the maximum size configured in + * spring.servlet.multipart.max-file-size. + * + *

Client action: Reduce the file size or split into smaller files. Check 'maxSizeMB' + * property for the limit. + * + * @param ex the MaxUploadSizeExceededException + * @param request the HTTP servlet request + * @return ProblemDetail with HTTP 413 PAYLOAD_TOO_LARGE + */ + @ExceptionHandler(MaxUploadSizeExceededException.class) + public ResponseEntity handleMaxUploadSize( + MaxUploadSizeExceededException ex, HttpServletRequest request) { + log.warn("File upload size exceeded at {}", request.getRequestURI()); + + long maxSize = ex.getMaxUploadSize(); + String message = + maxSize > 0 + ? getLocalizedMessage( + "error.fileTooLarge.detail", + String.format( + "File size exceeds maximum allowed limit of %d MB", + maxSize / (1024 * 1024)), + maxSize / (1024 * 1024)) + : getLocalizedMessage( + "error.fileTooLarge.detailUnknown", + "File size exceeds maximum allowed limit"); + + String title = + getLocalizedMessage("error.fileTooLarge.title", ErrorTitles.FILE_TOO_LARGE_DEFAULT); + + ProblemDetail problemDetail = + createBaseProblemDetail(HttpStatus.PAYLOAD_TOO_LARGE, message, request); + problemDetail.setType(URI.create(ErrorTypes.FILE_TOO_LARGE)); + problemDetail.setTitle(title); + problemDetail.setProperty("title", title); // Ensure serialization + if (maxSize > 0) { + problemDetail.setProperty("maxSizeBytes", maxSize); + problemDetail.setProperty("maxSizeMB", maxSize / (1024 * 1024)); + } + addStandardHints( + problemDetail, + "error.fileTooLarge.hints", + List.of( + "Compress or reduce the resolution of the file before uploading.", + "Split the file into smaller parts if possible.", + "Contact the administrator to increase the upload limit if necessary.")); + problemDetail.setProperty( + "actionRequired", "Reduce the file size to be within the upload limit."); + + return ResponseEntity.status(HttpStatus.PAYLOAD_TOO_LARGE) + .contentType(PROBLEM_JSON) + .body(problemDetail); + } + + /** + * Handle HTTP method not supported. + * + *

When thrown: When a request uses an HTTP method (GET, POST, etc.) not supported by the + * endpoint. + * + *

Client action: Use one of the supported methods listed in 'supportedMethods' property. + * + * @param ex the HttpRequestMethodNotSupportedException + * @param request the HTTP servlet request + * @return ProblemDetail with HTTP 405 METHOD_NOT_ALLOWED + */ + @ExceptionHandler(HttpRequestMethodNotSupportedException.class) + public ResponseEntity handleMethodNotSupported( + HttpRequestMethodNotSupportedException ex, HttpServletRequest request) { + log.warn( + "Method not supported at {}: {} not allowed", + request.getRequestURI(), + ex.getMethod()); + + String message = + getLocalizedMessage( + "error.methodNotAllowed.detail", + String.format( + "HTTP method '%s' is not supported for this endpoint. Supported methods: %s", + ex.getMethod(), String.join(", ", ex.getSupportedMethods())), + ex.getMethod(), + String.join(", ", ex.getSupportedMethods())); + + String title = + getLocalizedMessage( + "error.methodNotAllowed.title", ErrorTitles.METHOD_NOT_ALLOWED_DEFAULT); + + ProblemDetail problemDetail = + createBaseProblemDetail(HttpStatus.METHOD_NOT_ALLOWED, message, request); + problemDetail.setType(URI.create(ErrorTypes.METHOD_NOT_ALLOWED)); + problemDetail.setTitle(title); + problemDetail.setProperty("title", title); // Ensure serialization + problemDetail.setProperty("method", ex.getMethod()); + problemDetail.setProperty("supportedMethods", ex.getSupportedMethods()); + addStandardHints( + problemDetail, + "error.methodNotAllowed.hints", + List.of( + "Change the HTTP method to one of the supported methods.", + "Consult the API documentation for the correct method.", + "If using a tool like curl or Postman, update the method accordingly.")); + problemDetail.setProperty("actionRequired", "Use one of the supported HTTP methods."); + + return ResponseEntity.status(HttpStatus.METHOD_NOT_ALLOWED) + .contentType(PROBLEM_JSON) + .body(problemDetail); + } + + /** + * Handle unsupported media type. + * + *

When thrown: When the Content-Type header contains a media type not supported by the + * endpoint. + * + *

Client action: Change the Content-Type header to one of the supported types in + * 'supportedMediaTypes' property. + * + * @param ex the HttpMediaTypeNotSupportedException + * @param request the HTTP servlet request + * @return ProblemDetail with HTTP 415 UNSUPPORTED_MEDIA_TYPE + */ + @ExceptionHandler(HttpMediaTypeNotSupportedException.class) + public ResponseEntity handleMediaTypeNotSupported( + HttpMediaTypeNotSupportedException ex, HttpServletRequest request) { + log.warn( + "Media type not supported at {}: {}", request.getRequestURI(), ex.getContentType()); + + String message = + getLocalizedMessage( + "error.unsupportedMediaType.detail", + String.format( + "Media type '%s' is not supported. Supported media types: %s", + ex.getContentType(), ex.getSupportedMediaTypes()), + String.valueOf(ex.getContentType()), + ex.getSupportedMediaTypes().toString()); + + String title = + getLocalizedMessage( + "error.unsupportedMediaType.title", + ErrorTitles.UNSUPPORTED_MEDIA_TYPE_DEFAULT); + + ProblemDetail problemDetail = + createBaseProblemDetail(HttpStatus.UNSUPPORTED_MEDIA_TYPE, message, request); + problemDetail.setType(URI.create(ErrorTypes.UNSUPPORTED_MEDIA_TYPE)); + problemDetail.setTitle(title); + problemDetail.setProperty("title", title); // Ensure serialization + problemDetail.setProperty("contentType", String.valueOf(ex.getContentType())); + problemDetail.setProperty("supportedMediaTypes", ex.getSupportedMediaTypes()); + addStandardHints( + problemDetail, + "error.unsupportedMediaType.hints", + List.of( + "Set the Content-Type header to a supported media type.", + "When sending JSON, use 'application/json'.", + "Check that the request body matches the declared Content-Type.")); + problemDetail.setProperty( + "actionRequired", "Change the Content-Type to a supported value."); + + return ResponseEntity.status(HttpStatus.UNSUPPORTED_MEDIA_TYPE) + .contentType(PROBLEM_JSON) + .body(problemDetail); + } + + // =========================================================================================== + // JAVA STANDARD EXCEPTIONS + // =========================================================================================== + + /** + * Handle malformed JSON or request body parsing errors. + * + *

When thrown: When the request body cannot be parsed (invalid JSON, wrong format, etc.). + * + *

Client action: Check the request body format and ensure it matches the expected structure. + * + * @param ex the HttpMessageNotReadableException + * @param request the HTTP servlet request + * @return ProblemDetail with HTTP 400 BAD_REQUEST + */ + @ExceptionHandler(HttpMessageNotReadableException.class) + public ResponseEntity handleMessageNotReadable( + HttpMessageNotReadableException ex, HttpServletRequest request) { + log.warn("Malformed request body at {}: {}", request.getRequestURI(), ex.getMessage()); + + String message = + getLocalizedMessage( + "error.malformedRequest.detail", + "Malformed JSON request or invalid request body format"); + Throwable cause = ex.getCause(); + if (cause != null && cause.getMessage() != null) { + message = + getLocalizedMessage( + "error.malformedRequest.detailWithCause", + "Invalid request body: " + cause.getMessage(), + cause.getMessage()); + } + + String title = + getLocalizedMessage( + "error.malformedRequest.title", ErrorTitles.MALFORMED_REQUEST_DEFAULT); + + ProblemDetail problemDetail = + createBaseProblemDetail(HttpStatus.BAD_REQUEST, message, request); + problemDetail.setType(URI.create(ErrorTypes.MALFORMED_REQUEST)); + problemDetail.setTitle(title); + problemDetail.setProperty("title", title); // Ensure serialization + addStandardHints( + problemDetail, + "error.malformedRequest.hints", + List.of( + "Validate the JSON or request body format before sending.", + "Ensure field names and types match the API contract.", + "Remove trailing commas and ensure proper quoting in JSON.")); + problemDetail.setProperty("actionRequired", "Fix the request body format and retry."); + + return ResponseEntity.badRequest().contentType(PROBLEM_JSON).body(problemDetail); + } + + /** + * Handle 404 Not Found errors. + * + *

When thrown: When no handler mapping exists for the requested URL and HTTP method. + * + *

Client action: Verify the endpoint URL and HTTP method are correct. + * + * @param ex the NoHandlerFoundException + * @param request the HTTP servlet request + * @return ProblemDetail with HTTP 404 NOT_FOUND + */ + @ExceptionHandler(NoHandlerFoundException.class) + public ResponseEntity handleNotFound( + NoHandlerFoundException ex, HttpServletRequest request) { + log.warn("Endpoint not found: {} {}", ex.getHttpMethod(), ex.getRequestURL()); + + String message = + getLocalizedMessage( + "error.notFound.detail", + String.format( + "No endpoint found for %s %s", + ex.getHttpMethod(), ex.getRequestURL()), + ex.getHttpMethod(), + ex.getRequestURL()); + + String title = getLocalizedMessage("error.notFound.title", ErrorTitles.NOT_FOUND_DEFAULT); + + ProblemDetail problemDetail = + createBaseProblemDetail(HttpStatus.NOT_FOUND, message, request); + problemDetail.setType(URI.create(ErrorTypes.NOT_FOUND)); + problemDetail.setTitle(title); + problemDetail.setProperty("title", title); // Ensure serialization + problemDetail.setProperty("method", ex.getHttpMethod()); + addStandardHints( + problemDetail, + "error.notFound.hints", + List.of( + "Verify the URL path and HTTP method are correct.", + "Check the API base path and version if applicable.", + "Ensure there are no typos in the endpoint path.")); + problemDetail.setProperty("actionRequired", "Use a valid endpoint URL and method."); + + return ResponseEntity.status(HttpStatus.NOT_FOUND) + .contentType(PROBLEM_JSON) + .body(problemDetail); + } + + /** + * Handle IllegalArgumentException. + * + *

When thrown: When method receives an illegal or inappropriate argument. + * + *

Client action: Review the error message and correct the invalid argument in the request. + * + * @param ex the IllegalArgumentException + * @param request the HTTP servlet request + * @return ProblemDetail with HTTP 400 BAD_REQUEST + */ + @ExceptionHandler(IllegalArgumentException.class) + public ResponseEntity handleIllegalArgument( + IllegalArgumentException ex, HttpServletRequest request) { + log.warn("Invalid argument at {}: {}", request.getRequestURI(), ex.getMessage()); + + String title = + getLocalizedMessage( + "error.invalidArgument.title", ErrorTitles.INVALID_ARGUMENT_DEFAULT); + + ProblemDetail problemDetail = + createBaseProblemDetail(HttpStatus.BAD_REQUEST, ex.getMessage(), request); + problemDetail.setType(URI.create(ErrorTypes.INVALID_ARGUMENT)); + problemDetail.setTitle(title); + problemDetail.setProperty("title", title); // Ensure serialization + addStandardHints( + problemDetail, + "error.invalidArgument.hints", + List.of( + "Review the error message and adjust the parameter value.", + "Consult the API docs for accepted ranges and formats.", + "Ensure required parameters are present.")); + problemDetail.setProperty("actionRequired", "Correct the invalid argument and retry."); + + return ResponseEntity.badRequest().contentType(PROBLEM_JSON).body(problemDetail); + } + + /** + * Handle IOException. + * + *

When thrown: When file I/O operations fail (read, write, corrupt file, etc.). + * + *

Client action: Verify the file is valid and not corrupted, then retry the request. + * + *

Note: This handler uses {@link ExceptionUtils#handlePdfException(IOException, String)} to + * detect and wrap PDF-specific errors (corruption, encryption, password) before processing. + * + * @param ex the IOException + * @param request the HTTP servlet request + * @return ProblemDetail with HTTP 500 INTERNAL_SERVER_ERROR + */ + @ExceptionHandler(IOException.class) + public ResponseEntity handleIOException( + IOException ex, HttpServletRequest request) { + + // Check if this is a PDF-specific error and wrap it appropriately + IOException processedException = + ExceptionUtils.handlePdfException(ex, request.getRequestURI()); + + // If it was wrapped as a specific PDF exception, the more specific handler will catch it on + // retry + if (processedException instanceof BaseAppException) { + return handleBaseApp((BaseAppException) processedException, request); + } + + log.error("IO error at {}: {}", request.getRequestURI(), ex.getMessage(), ex); + + String message = + getLocalizedMessage( + "error.ioError.detail", "An error occurred while processing the file"); + if (ex.getMessage() != null && !ex.getMessage().isBlank()) { + message = ex.getMessage(); + } + + String title = getLocalizedMessage("error.ioError.title", ErrorTitles.IO_ERROR_DEFAULT); + + ProblemDetail problemDetail = + createBaseProblemDetail(HttpStatus.INTERNAL_SERVER_ERROR, message, request); + problemDetail.setType(URI.create(ErrorTypes.IO_ERROR)); + problemDetail.setTitle(title); + problemDetail.setProperty("title", title); // Ensure serialization + addStandardHints( + problemDetail, + "error.ioError.hints", + List.of( + "Confirm the file exists and is accessible.", + "Ensure the file is not corrupted and is of a supported type.", + "Retry the operation in case of transient I/O issues.")); + problemDetail.setProperty("actionRequired", "Verify the file and try the request again."); + + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) + .contentType(PROBLEM_JSON) + .body(problemDetail); + } + + /** + * Handle generic exceptions as a fallback. + * + *

When thrown: Any exception not explicitly handled by other handlers. + * + *

Client action: This indicates an unexpected server error. Retry the request after a delay + * or contact support if the issue persists. + * + * @param ex the Exception + * @param request the HTTP servlet request + * @return ProblemDetail with HTTP 500 INTERNAL_SERVER_ERROR + */ + @ExceptionHandler(Exception.class) + public ResponseEntity handleGenericException( + Exception ex, HttpServletRequest request) { + log.error("Unexpected error at {}: {}", request.getRequestURI(), ex.getMessage(), ex); + + String userMessage = + getLocalizedMessage( + "error.unexpected", + "An unexpected error occurred. Please try again later."); + + String title = + getLocalizedMessage("error.unexpected.title", ErrorTitles.UNEXPECTED_DEFAULT); + + ProblemDetail problemDetail = + createBaseProblemDetail(HttpStatus.INTERNAL_SERVER_ERROR, userMessage, request); + problemDetail.setType(URI.create(ErrorTypes.UNEXPECTED)); + problemDetail.setTitle(title); + problemDetail.setProperty("title", title); // Ensure serialization + + addStandardHints( + problemDetail, + "error.unexpected.hints", + List.of( + "Retry the request after a short delay.", + "If the problem persists, contact support with the timestamp and path.", + "Check service status or logs for outages.")); + problemDetail.setProperty( + "actionRequired", + "Retry later; if persistent, contact support with the error details."); + + // Only expose detailed error info in development mode + if (isDevelopmentMode()) { + problemDetail.setProperty("debugMessage", ex.getMessage()); + problemDetail.setProperty("exceptionType", ex.getClass().getName()); + } + + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) + .contentType(PROBLEM_JSON) + .body(problemDetail); + } + + /** + * Get a localized message from the MessageSource. + * + *

Attempts to retrieve a message from the ResourceBundle using the provided key. If the key + * is not found, returns the default message. + * + * @param key the message key in the ResourceBundle + * @param defaultMessage the default message to use if the key is not found + * @return the localized message or the default message + */ + private String getLocalizedMessage(String key, String defaultMessage) { + return messageSource.getMessage(key, null, defaultMessage, LocaleContextHolder.getLocale()); + } + + /** + * Get a localized message from the MessageSource with arguments. + * + *

Attempts to retrieve a message from the ResourceBundle using the provided key and format + * it with the supplied arguments. If the key is not found, returns the default message. + * + * @param key the message key in the ResourceBundle + * @param defaultMessage the default message to use if the key is not found + * @param args arguments to format into the message + * @return the localized message or the default message + */ + private String getLocalizedMessage(String key, String defaultMessage, Object... args) { + return messageSource.getMessage(key, args, defaultMessage, LocaleContextHolder.getLocale()); + } + + /** + * Check if the application is running in development mode. + * + *

Development mode is identified by checking for "dev" or "development" in active Spring + * profiles. When enabled, additional debugging information is included in error responses. + * + *

The result is cached after the first call to avoid repeated array scans. + * + * @return true if development mode is active, false otherwise + */ + private boolean isDevelopmentMode() { + if (isDevelopmentMode == null) { + String[] activeProfiles = environment.getActiveProfiles(); + isDevelopmentMode = false; + for (String profile : activeProfiles) { + if ("dev".equalsIgnoreCase(profile) || "development".equalsIgnoreCase(profile)) { + isDevelopmentMode = true; + break; + } + } + } + return isDevelopmentMode; + } + + /** + * Add standard hints to a ProblemDetail from internationalized messages or defaults. + * + * @param problemDetail the ProblemDetail to enrich + * @param hintKey the i18n key for hints (should contain "|" separated hints) + * @param defaultHints the default hints if i18n key is not found + */ + private void addStandardHints( + ProblemDetail problemDetail, String hintKey, List defaultHints) { + String localizedHints = getLocalizedMessage(hintKey, null); + if (localizedHints != null) { + problemDetail.setProperty( + "hints", + List.of( + RegexPatternUtils.getInstance() + .getPipeDelimiterPattern() + .split(localizedHints))); + } else { + problemDetail.setProperty("hints", defaultHints); + } + } + + /** Constants for error types (RFC 7807 type URIs). */ + private static final class ErrorTypes { + static final String PDF_PASSWORD = "/errors/pdf-password"; + static final String GHOSTSCRIPT = "/errors/ghostscript"; + static final String FFMPEG_REQUIRED = "/errors/ffmpeg-required"; + static final String OUT_OF_MEMORY_DPI = "/errors/out-of-memory-dpi"; + static final String PDF_CORRUPTED = "/errors/pdf-corrupted"; + static final String PDF_ENCRYPTION = "/errors/pdf-encryption"; + static final String APP_ERROR = "/errors/app-error"; + static final String CBR_FORMAT = "/errors/cbr-format"; + static final String CBZ_FORMAT = "/errors/cbz-format"; + static final String EML_FORMAT = "/errors/eml-format"; + static final String FORMAT_ERROR = "/errors/format-error"; + static final String VALIDATION = "/errors/validation"; + static final String APPLICATION = "/errors/application"; + static final String MISSING_PARAMETER = "/errors/missing-parameter"; + static final String MISSING_FILE = "/errors/missing-file"; + static final String FILE_TOO_LARGE = "/errors/file-too-large"; + static final String METHOD_NOT_ALLOWED = "/errors/method-not-allowed"; + static final String UNSUPPORTED_MEDIA_TYPE = "/errors/unsupported-media-type"; + static final String MALFORMED_REQUEST = "/errors/malformed-request"; + static final String NOT_FOUND = "/errors/not-found"; + static final String INVALID_ARGUMENT = "/errors/invalid-argument"; + static final String IO_ERROR = "/errors/io-error"; + static final String UNEXPECTED = "/errors/unexpected"; + } + + /** Constants for default error titles. */ + private static final class ErrorTitles { + static final String PDF_PASSWORD_DEFAULT = "PDF Password Required"; + static final String GHOSTSCRIPT_DEFAULT = "Ghostscript Processing Error"; + static final String FFMPEG_REQUIRED_DEFAULT = "FFmpeg Required"; + static final String OUT_OF_MEMORY_DPI_DEFAULT = "Insufficient Memory for Image Rendering"; + static final String PDF_CORRUPTED_DEFAULT = "PDF File Corrupted"; + static final String PDF_ENCRYPTION_DEFAULT = "PDF Encryption Error"; + static final String APPLICATION_DEFAULT = "Application Error"; + static final String CBR_FORMAT_DEFAULT = "Invalid CBR File Format"; + static final String CBZ_FORMAT_DEFAULT = "Invalid CBZ File Format"; + static final String EML_FORMAT_DEFAULT = "Invalid EML File Format"; + static final String FORMAT_ERROR_DEFAULT = "Invalid File Format"; + static final String VALIDATION_DEFAULT = "Validation Error"; + static final String REQUEST_VALIDATION_FAILED_DEFAULT = "Request Validation Failed"; + static final String MISSING_PARAMETER_DEFAULT = "Missing Request Parameter"; + static final String MISSING_FILE_DEFAULT = "Missing File Upload"; + static final String FILE_TOO_LARGE_DEFAULT = "File Too Large"; + static final String METHOD_NOT_ALLOWED_DEFAULT = "HTTP Method Not Allowed"; + static final String UNSUPPORTED_MEDIA_TYPE_DEFAULT = "Unsupported Media Type"; + static final String MALFORMED_REQUEST_DEFAULT = "Malformed Request Body"; + static final String NOT_FOUND_DEFAULT = "Endpoint Not Found"; + static final String INVALID_ARGUMENT_DEFAULT = "Invalid Argument"; + static final String IO_ERROR_DEFAULT = "File Processing Error"; + static final String UNEXPECTED_DEFAULT = "Internal Server Error"; + } +} diff --git a/app/core/src/main/java/stirling/software/SPDF/service/SignatureService.java b/app/core/src/main/java/stirling/software/SPDF/service/SignatureService.java index 9c28160a9..b63dd149d 100644 --- a/app/core/src/main/java/stirling/software/SPDF/service/SignatureService.java +++ b/app/core/src/main/java/stirling/software/SPDF/service/SignatureService.java @@ -17,6 +17,7 @@ import lombok.extern.slf4j.Slf4j; import stirling.software.SPDF.model.SignatureFile; import stirling.software.common.configuration.InstallationPathConfig; +import stirling.software.common.util.ExceptionUtils; @Service @Slf4j @@ -103,7 +104,8 @@ public class SignatureService { private void validateFileName(String fileName) { if (fileName.contains("..") || fileName.contains("/") || fileName.contains("\\")) { - throw new IllegalArgumentException("Invalid filename"); + throw ExceptionUtils.createIllegalArgumentException( + "error.invalidFormat", "Invalid {0} format: {1}", "filename", fileName); } } } diff --git a/app/core/src/main/resources/application.properties b/app/core/src/main/resources/application.properties index 0ca864985..3e354785b 100644 --- a/app/core/src/main/resources/application.properties +++ b/app/core/src/main/resources/application.properties @@ -14,6 +14,10 @@ server.error.whitelabel.enabled=false server.error.include-stacktrace=always server.error.include-exception=true server.error.include-message=always + +# Enable RFC 7807 Problem Details for HTTP APIs +spring.mvc.problemdetails.enabled=true + #logging.level.org.springframework.web=DEBUG #logging.level.org.springframework=DEBUG #logging.level.org.springframework.security=DEBUG diff --git a/app/core/src/main/resources/messages_en_GB.properties b/app/core/src/main/resources/messages_en_GB.properties index 202a64176..a3e0f67fd 100644 --- a/app/core/src/main/resources/messages_en_GB.properties +++ b/app/core/src/main/resources/messages_en_GB.properties @@ -235,6 +235,92 @@ error.pdfBookmarksNotFound=No PDF bookmarks/outline found in document error.fontLoadingFailed=Error processing font file error.fontDirectoryReadFailed=Failed to read font directory error.noAttachmentsFound=No embedded attachments were found in the provided PDF. +error.nullArgument={0} must not be null +error.invalidPageSize=Invalid page size format: {0} +error.invalidComparator=Invalid comparator format: only 'greater', 'equal', and 'less' are supported + +# Error titles for GlobalExceptionHandler +error.pdfPassword.title=PDF Password Required +error.outOfMemoryDpi.title=Out of Memory - DPI Too High +error.pdfCorrupted.title=PDF File Corrupted +error.multiplePdfCorrupted=One or more PDF files appear to be corrupted or damaged. Please try using the 'Repair PDF' feature on each file first before attempting to merge them. +error.pdfEncryption.title=PDF Encryption Error +error.application.title=Application Error +error.cbrFormat.title=Invalid CBR File Format +error.cbzFormat.title=Invalid CBZ File Format +error.emlFormat.title=Invalid EML File Format +error.formatError.title=Invalid File Format +error.validation.title=Request Validation Failed +error.validation.detail=Validation failed +error.missingParameter.title=Missing Request Parameter +error.missingParameter.detail=Required parameter ''{0}'' of type ''{1}'' is missing +error.missingFile.title=Missing File Upload +error.missingFile.detail=Required file part ''{0}'' is missing +error.fileTooLarge.title=File Too Large +error.fileTooLarge.detail=File size exceeds maximum allowed limit of {0} MB +error.fileTooLarge.detailUnknown=File size exceeds maximum allowed limit +error.methodNotAllowed.title=HTTP Method Not Allowed +error.methodNotAllowed.detail=HTTP method ''{0}'' is not supported for this endpoint. Supported methods: {1} +error.unsupportedMediaType.title=Unsupported Media Type +error.unsupportedMediaType.detail=Media type ''{0}'' is not supported. Supported media types: {1} +error.malformedRequest.title=Malformed Request Body +error.malformedRequest.detail=Malformed JSON request or invalid request body format +error.malformedRequest.detailWithCause=Invalid request body: {0} +error.notFound.title=Endpoint Not Found +error.notFound.detail=No endpoint found for {0} {1} +error.invalidArgument.title=Invalid Argument +error.ioError.title=File Processing Error +error.ioError.detail=An error occurred while processing the file +error.unexpected.title=Internal Server Error +error.unexpected=An unexpected error occurred. Please try again later. + +# PDF-related error messages from ErrorCode enum +error.pdfNoPages=PDF file contains no pages +error.notPdfFile=File must be in PDF format + +# CBR/CBZ error messages from ErrorCode enum +error.cbrInvalidFormat=Invalid or corrupted CBR/RAR archive. The file may be corrupted, use an unsupported RAR format (RAR5+), encrypted, or may not be a valid RAR archive. +error.cbrNoImages=No valid images found in the CBR file. The archive may be empty, or all images may be corrupted or in unsupported formats. +error.notCbrFile=File must be a CBR or RAR archive +error.cbzInvalidFormat=Invalid or corrupted CBZ/ZIP archive. The file may be empty, corrupted, or may not be a valid ZIP archive. +error.cbzNoImages=No valid images found in the CBZ file. The archive may be empty, or all images may be corrupted or in unsupported formats. +error.notCbzFile=File must be a CBZ or ZIP archive + +# EML error messages from ErrorCode enum +error.emlEmpty=EML file is empty or null +error.emlInvalidFormat=Invalid EML file format + +# File processing error messages from ErrorCode enum +error.fileNullOrEmpty=File cannot be null or empty +error.fileNoName=File must have a name +error.imageReadError=Unable to read image from file: {0} + +# OCR error messages from ErrorCode enum +error.ocrLanguageRequired=OCR language options are not specified +error.ocrInvalidLanguages=Invalid OCR languages format: none of the selected languages are valid +error.ocrToolsUnavailable=OCR tools are not installed +error.ocrInvalidRenderType=Invalid OCR render type. Must be 'hocr' or 'sandwich' +error.ocrProcessingFailed=OCRmyPDF failed with return code: {0} + +# Compression error messages from ErrorCode enum +error.compressionOptions=Compression options are not specified (expected output size and optimise level) +error.ghostscriptCompression=Ghostscript compression command failed +error.ghostscriptCompression.title=Ghostscript Processing Error +error.ghostscriptPageDrawing=Ghostscript could not render {0}. {1} +error.ghostscriptDefaultDiagnostic=The source file contains content Ghostscript cannot render. +error.qpdfCompression=QPDF command failed +error.processingInterrupted={0} processing was interrupted + +# Conversion error messages from ErrorCode enum +error.pdfaConversionFailed=PDF/A conversion failed +error.htmlFileRequired=File must be in HTML or ZIP format +error.pythonRequiredWebp=Python is required for WebP conversion +error.ffmpegRequired=FFmpeg must be installed to convert PDFs to video. Install FFmpeg and ensure it is available on the system PATH. +error.ffmpegRequired.title=FFmpeg Required + +# System error messages from ErrorCode enum +error.md5Algorithm=MD5 algorithm not available +error.outOfMemoryDpi=Out of memory or image-too-large error while rendering PDF page {0} at {1} DPI. This can occur when the resulting image exceeds Java's array/memory limits (e.g., NegativeArraySizeException). Please use a lower DPI value (recommended: 150 or less) or process the document in smaller chunks. delete=Delete username=Username password=Password @@ -2037,3 +2123,209 @@ pdfToVector.header=PDF to Vector Image pdfToVector.description=Convert a PDF into Ghostscript-generated vector formats (EPS, PS, PCL, or XPS). pdfToVector.outputFormat=Output format pdfToVector.submit=Convert + +##################### +# Exception Hints # +##################### + +# PDF-related errors +error.E001.hint.1=Try the 'Repair PDF' feature, then retry this operation. +error.E001.hint.2=Re-export the PDF from the original application if possible. +error.E001.hint.3=Avoid transferring the file through tools that modify PDFs (e.g., fax/email converters). +error.E001.action=Repair the PDF and retry the operation. + +error.E002.hint.1=Identify which files are corrupted by processing them one by one. +error.E002.hint.2=Run 'Repair PDF' on each problematic file before merging. +error.E002.hint.3=If repair fails, re-export each source document to PDF again. +error.E002.action=Repair or re-export the corrupted PDFs individually, then retry the operation. + +error.E003.hint.1=Use the 'Repair PDF' feature to normalise encryption metadata. +error.E003.hint.2=If the file uses unusual encryption, re-save with a modern PDF tool. +error.E003.hint.3=If the PDF is password-protected, provide the correct password first. +error.E003.action=Repair the PDF or re-export it with compatible encryption. + +error.E004.hint.1=PDFs can have two passwords: a user password (opens the document) and an owner password (controls permissions). This operation requires the owner password. +error.E004.hint.2=If you can open the PDF without a password, it may only have an owner password set. Try submitting the permissions password. +error.E004.hint.3=Digitally signed PDFs cannot have security removed until the signature is removed. +error.E004.hint.4=Passwords are case-sensitive. Verify capitalisation, spaces, and special characters. +error.E063.hint.1=Install FFmpeg on the host system and ensure it is available on the PATH environment variable. +error.E063.hint.2=Restart Stirling-PDF after installing FFmpeg so the process can detect it. +error.E063.action=Install FFmpeg and restart Stirling-PDF before retrying the conversion. +error.E004.hint.5=Some creators use different encryption standards (40-bit, 128-bit, 256-bit AES). Ensure your password matches the encryption used. +error.E004.hint.6=If you only have the user password, you cannot remove security restrictions. Contact the document owner for the permissions password. +error.E004.action=Provide the owner/permissions password, not just the document open password. + +error.E005.hint.1=Verify the PDF is not empty or contains only unsupported objects. +error.E005.hint.2=Open the file in a PDF viewer to confirm it has pages. +error.E005.hint.3=Recreate the PDF ensuring pages are included (not just attachments). +error.E005.action=Provide a PDF that contains at least one page. + +error.E006.hint.1=Ensure the uploaded file is a valid PDF, not another format. +error.E006.hint.2=If it has a .pdf extension, verify the file is not actually another type (e.g., Word, image). +error.E006.hint.3=Try opening the file in a PDF reader to confirm validity. +error.E006.action=Upload a valid PDF file. + +# CBR/CBZ errors +error.E010.hint.1=RAR5 archives are not supported. Repack the archive as RAR4 or convert to CBZ (ZIP). +error.E010.hint.2=Ensure the archive is not encrypted and contains valid image files. +error.E010.hint.3=Try extracting the archive with a desktop tool to verify integrity. +error.E010.action=Convert the archive to CBZ/ZIP or RAR4, then retry. + +error.E012.hint.1=Make sure the archive contains image files (e.g., .jpg, .png). +error.E012.hint.2=Remove unsupported or corrupted files from the archive. +error.E012.hint.3=Repack the archive ensuring images are at the root or in proper folders. +error.E012.action=Add at least one valid image to the archive and retry. + +error.E014.hint.1=Upload a CBR (RAR) file for this operation. +error.E014.hint.2=If you have a ZIP/CBZ, use the CBZ conversion instead. +error.E014.hint.3=Check the file extension and actual format with an archive tool. +error.E014.action=Provide a valid CBR/RAR file. + +error.E015.hint.1=Ensure the ZIP/CBZ is not encrypted and is a valid archive. +error.E015.hint.2=Verify the archive is not empty and contains image files. +error.E015.hint.3=Try re-zipping the images using a standard ZIP tool (no compression anomalies). +error.E015.action=Recreate the CBZ/ZIP without encryption and with valid images, then retry. + +error.E016.hint.1=Add images (.jpg, .png, etc.) to the ZIP archive. +error.E016.hint.2=Remove non-image files or nested archives that aren't supported. +error.E016.hint.3=Ensure images are not corrupted and can be opened locally. +error.E016.action=Include at least one valid image in the CBZ file. + +error.E018.hint.1=Upload a CBZ (ZIP) file for this operation. +error.E018.hint.2=If you have a RAR/CBR, use the CBR conversion instead. +error.E018.hint.3=Check the file extension and actual format with an archive tool. +error.E018.action=Provide a valid CBZ/ZIP file. + +# EML errors +error.E020.hint.1=Verify the uploaded file is not zero bytes. +error.E020.hint.2=Export the EML again from your email client. +error.E020.hint.3=Ensure the file hasn't been stripped of content by email/security tools. +error.E020.action=Upload a non-empty EML file. + +error.E021.hint.1=Ensure the file is a raw EML message, not MSG or another email format. +error.E021.hint.2=Re-export the email as EML from your client. +error.E021.hint.3=Open the file with a text editor to verify standard EML headers are present. +error.E021.action=Provide a valid EML file export. + +# File processing errors +error.E030.hint.1=Confirm the file ID or path is correct. +error.E030.hint.2=Ensure the file wasn't deleted or moved. +error.E030.hint.3=If using a temporary upload, re-upload the file and try again. +error.E030.action=Provide an existing file reference and retry. + +error.E031.hint.1=Check that the file is not corrupted and is supported by this operation. +error.E031.hint.2=Retry the operation; transient I/O issues can occur. +error.E031.hint.3=If the problem persists, simplify the document (fewer pages, smaller images). +error.E031.action=Verify the file and operation parameters, then retry. + +error.E032.hint.1=Attach a file in the request. +error.E032.hint.2=Make sure the file is not zero bytes. +error.E032.hint.3=If uploading multiple files, ensure at least one is provided. +error.E032.action=Upload a non-empty file and retry. + +error.E033.hint.1=Provide a filename with an extension. +error.E033.hint.2=Ensure your client includes the original filename during upload. +error.E033.action=Include a filename for the uploaded file. + +error.E034.hint.1=Verify the image file is not corrupted and can be opened locally. +error.E034.hint.2=Ensure the file format is a supported image type. +error.E034.hint.3=Re-export or convert the image to a standard format (JPEG/PNG). +error.E034.action=Provide a readable, supported image file. + +# OCR errors +error.E040.hint.1=Select at least one OCR language from the options. +error.E040.hint.2=If unsure, choose the primary language of the document's text. +error.E040.hint.3=Multiple languages can be selected if mixed text is present. +error.E040.action=Specify one or more OCR languages. + +error.E041.hint.1=Use valid language codes (e.g., eng, fra, deu). +error.E041.hint.2=Remove unsupported or misspelt language codes. +error.E041.hint.3=Check installed OCR language packs and install missing ones. +error.E041.action=Provide valid OCR language codes or install missing language packs. + +error.E042.hint.1=Install OCR tools (e.g., OCRmyPDF/Tesseract) as per documentation. +error.E042.hint.2=Verify the tools are on the PATH and accessible by the application. +error.E042.hint.3=If running in Docker, use an image variant that includes OCR tools. +error.E042.action=Install and configure the OCR tools. + +error.E043.hint.1=Use 'hocr' for HTML OCR output or 'sandwich' to embed text in PDF. +error.E043.hint.2=Check the API docs for valid render types. +error.E043.hint.3=Avoid typos; values are case-sensitive. +error.E043.action=Choose either 'hocr' or 'sandwich' as render type. + +error.E044.hint.1=Check the server logs for the detailed OCRmyPDF error output. +error.E044.hint.2=Ensure required OCR dependencies and language packs are installed. +error.E044.hint.3=Try running OCR locally on the file to reproduce the issue. +error.E044.action=Investigate OCR logs and fix missing dependencies or inputs, then retry. + +# Compression/processing errors +error.E050.hint.1=Provide both target output size and optimisation level. +error.E050.hint.2=Review API docs for required compression parameters. +error.E050.hint.3=If unsure, start with default optimisation and adjust. +error.E050.action=Specify expected output size and optimise level for compression. + +error.E051.hint.1=Confirm Ghostscript is installed and accessible. +error.E051.hint.2=Simplify the PDF (e.g., reduce image sizes) and retry. +error.E051.hint.3=Review command-line arguments generated for Ghostscript in logs. +error.E051.action=Ensure Ghostscript is installed and the command executes successfully. +error.E054.hint.1=Convert EPS or PS files to a single-page PDF before retrying this operation. +error.E054.hint.2=Export each page individually from the authoring application so Ghostscript processes a single page at a time. +error.E054.hint.3=Use the Convert to PDF tool to flatten artwork or advanced effects, then rerun the command. +error.E054.action=Convert the source file to a single-page document (or PDF) and retry the operation. + +error.E052.hint.1=Ensure qpdf is installed and on PATH. +error.E052.hint.2=Verify that the PDF is not corrupted before compression. +error.E052.hint.3=Adjust compression parameters if the command fails. +error.E052.action=Install qpdf and retry with valid inputs. + +error.E053.hint.1=The operation was cancelled or interrupted by the system. +error.E053.hint.2=Avoid terminating the process or closing the browser mid-operation. +error.E053.hint.3=Retry the operation; if it persists, check server resource limits. +error.E053.action=Retry the operation and avoid interruption. + +# Conversion/System errors +error.E060.hint.1=Ensure the PDF is valid and supported by the converter. +error.E060.hint.2=Try converting to a different PDF/A level or re-export the source to PDF first. +error.E060.hint.3=Remove problematic elements (e.g., complex transparency) and retry. +error.E060.action=Adjust conversion settings or normalise the PDF, then retry. + +error.E061.hint.1=Provide either a single HTML file or a ZIP containing HTML and assets. +error.E061.hint.2=Ensure relative links in HTML point to included assets in the ZIP. +error.E061.action=Upload an HTML file or a ZIP of the website content. + +error.E062.hint.1=Install Python and required WebP libraries to enable conversion. +error.E062.hint.2=If using Docker, use an image variant with Python/WebP support. +error.E062.hint.3=Check PATH and environment to ensure Python is available. +error.E062.action=Install Python and WebP dependencies. + +# Validation errors +error.E070.hint.1=Review the parameter's allowed values in the API docs. +error.E070.hint.2=Ensure the value format matches expectations (case, range, pattern). +error.E070.hint.3=Correct the argument and resend the request. +error.E070.action=Provide a valid parameter value and retry. + +error.E071.hint.1=Include the missing parameter in the request. +error.E071.hint.2=Verify your client sends all required fields. +error.E071.action=Add the required parameter and retry. + +error.E072.hint.1=Use formats like 'A4', 'Letter', or 'WIDTHxHEIGHT' (e.g., 800x600). +error.E072.hint.2=Ensure units and separators are correct. +error.E072.hint.3=Refer to docs for supported sizes. +error.E072.action=Provide a supported page size value. + +error.E073.hint.1=Allowed values: 'greater', 'equal', 'less'. +error.E073.hint.2=Check for typos and use lowercase. +error.E073.hint.3=Consult API docs for comparator usage examples. +error.E073.action=Use one of 'greater', 'equal', or 'less' as comparator. + +# System errors +error.E080.hint.1=Your Java runtime may not include MD5. Use an alternative algorithm. +error.E080.hint.2=If hashing is optional, switch to SHA-256 or another supported digest. +error.E080.hint.3=Install appropriate security providers if MD5 is required. +error.E080.action=Use a supported hash algorithm (e.g., SHA-256) or install MD5 provider. + +error.E081.hint.1=Reduce the DPI (try 150 or lower). +error.E081.hint.2=Process the document in smaller chunks or fewer pages at a time. +error.E081.hint.3=Reduce page dimensions or image complexity before rendering. +error.E081.hint.4=Increase available heap memory for the application if possible. +error.E081.action=Lower the DPI and retry; 150 DPI is recommended for large pages. diff --git a/app/core/src/main/resources/static/js/DecryptFiles.js b/app/core/src/main/resources/static/js/DecryptFiles.js index e569d8839..057ca9837 100644 --- a/app/core/src/main/resources/static/js/DecryptFiles.js +++ b/app/core/src/main/resources/static/js/DecryptFiles.js @@ -1,3 +1,141 @@ +function formatProblemDetailsJson(input) { + try { + const obj = typeof input === 'string' ? JSON.parse(input) : input; + const preferredOrder = [ + 'errorCode', + 'title', + 'status', + 'type', + 'detail', + 'instance', + 'path', + 'timestamp', + 'hints', + 'actionRequired' + ]; + + const ordered = {}; + preferredOrder.forEach((key) => { + if (Object.prototype.hasOwnProperty.call(obj, key)) { + ordered[key] = obj[key]; + } + }); + + Object.keys(obj).forEach((key) => { + if (!Object.prototype.hasOwnProperty.call(ordered, key)) { + ordered[key] = obj[key]; + } + }); + + return JSON.stringify(ordered, null, 2); + } catch (err) { + if (typeof input === 'string') return input; + try { + return JSON.stringify(input, null, 2); + } catch (jsonErr) { + return String(input); + } + } +} + +function formatUserFriendlyError(json) { + if (!json || typeof json !== 'object') { + return typeof json === 'string' ? json : ''; + } + + const lines = []; + const title = json.title || json.error || ''; + const detail = json.detail || json.message || ''; + + const primaryLine = title && detail ? `${title}: ${detail}` : title || detail; + + if (primaryLine) { + lines.push(primaryLine); + } + + if (json.errorCode) { + lines.push(''); + lines.push(`Error Code: ${json.errorCode}`); + } + + const detailAlreadyIncluded = detail && primaryLine && primaryLine.includes(detail); + if (detail && !detailAlreadyIncluded) { + lines.push(''); + lines.push(detail); + } + + if (json.hints && Array.isArray(json.hints) && json.hints.length > 0) { + lines.push(''); + lines.push('How to fix:'); + json.hints.forEach((hint, index) => { + lines.push(` ${index + 1}. ${hint}`); + }); + } + + if (json.actionRequired) { + lines.push(''); + lines.push(json.actionRequired); + } + + if (json.supportId) { + lines.push(''); + lines.push(`Support ID: ${json.supportId}`); + } + + return lines + .filter((line, index, arr) => { + if (line !== '') return true; + if (index === 0 || index === arr.length - 1) return false; + return arr[index - 1] !== ''; + }) + .join('\n'); +} + +function buildPdfPasswordProblemDetail(fileName) { + const stirling = window.stirlingPDF || {}; + const detailTemplate = stirling.pdfPasswordDetail || 'The PDF Document is passworded and either the password was not provided or was incorrect'; + const title = stirling.pdfPasswordTitle || 'PDF Password Required'; + const hints = [ + stirling.pdfPasswordHint1, + stirling.pdfPasswordHint2, + stirling.pdfPasswordHint3, + stirling.pdfPasswordHint4, + stirling.pdfPasswordHint5, + stirling.pdfPasswordHint6 + ].filter((hint) => typeof hint === 'string' && hint.trim().length > 0); + const actionRequired = stirling.pdfPasswordAction || 'Provide the owner/permissions password, not just the document open password.'; + + return { + errorCode: 'E004', + title, + detail: detailTemplate.replace('{0}', fileName), + type: '/errors/pdf-password', + path: '/api/v1/security/remove-password', + hints, + actionRequired + }; +} + +function buildCorruptedPdfProblemDetail(fileName) { + const stirling = window.stirlingPDF || {}; + const detailTemplate = stirling.pdfCorruptedMessage || 'The PDF file "{0}" appears to be corrupted or has an invalid structure.'; + const hints = [ + stirling.pdfCorruptedHint1, + stirling.pdfCorruptedHint2, + stirling.pdfCorruptedHint3 + ].filter((hint) => typeof hint === 'string' && hint.trim().length > 0); + const actionRequired = stirling.pdfCorruptedAction || stirling.tryRepairMessage || ''; + + return { + errorCode: 'E001', + title: stirling.pdfCorruptedTitle || 'PDF File Corrupted', + detail: detailTemplate.replace('{0}', fileName), + type: '/errors/pdf-corrupted', + hints, + actionRequired + }; +} + export class DecryptFile { constructor(){ @@ -35,11 +173,8 @@ export class DecryptFile { if (!password) { // No password provided console.error(`No password provided for encrypted PDF: ${file.name}`); - this.showErrorBanner( - `${window.decrypt.noPassword.replace('{0}', file.name)}`, - '', - `${window.decrypt.unexpectedError}` - ); + const problemDetail = buildPdfPasswordProblemDetail(file.name); + this.showProblemDetail(problemDetail); return null; // No file to return } @@ -51,30 +186,48 @@ export class DecryptFile { body: formData, }); - if (response.ok) { - this.removeErrorBanner(); - const decryptedBlob = await response.blob(); - return new File([decryptedBlob], file.name, { - type: "application/pdf", - }); - } else { - const errorText = await response.text(); - console.error(`${window.decrypt.invalidPassword} ${errorText}`); - this.showErrorBanner( - `${window.decrypt.invalidPassword}`, - errorText, - `${window.decrypt.invalidPasswordHeader.replace('{0}', file.name)}` - ); + if (!response.ok) { + const contentType = response.headers.get('content-type') || ''; + if (contentType.includes('application/json') || contentType.includes('application/problem+json')) { + const errorJson = await response.json(); + this.showProblemDetail(errorJson); + } else { + const errorText = await response.text(); + console.error(`${window.decrypt.invalidPassword} ${errorText}`); + const fallbackProblem = buildPdfPasswordProblemDetail(file.name); + if (errorText && errorText.trim().length > 0) { + fallbackProblem.detail = errorText.trim(); + } + this.showProblemDetail(fallbackProblem); + } return null; // No file to return } + + this.removeErrorBanner(); + const decryptedBlob = await response.blob(); + return new File([decryptedBlob], file.name, { + type: 'application/pdf', + }); } catch (error) { // Handle network or unexpected errors console.error(`Failed to decrypt PDF: ${file.name}`, error); - this.showErrorBanner( - `${window.decrypt.unexpectedError.replace('{0}', file.name)}`, - `${error.message || window.decrypt.unexpectedError}`, - error - ); + const fallbackDetail = + (error && error.message) || + window.decrypt.unexpectedError || + 'There was an error processing the file. Please try again.'; + + const unexpectedProblem = { + title: (window.stirlingPDF && window.stirlingPDF.errorUnexpectedTitle) || 'Unexpected Error', + detail: fallbackDetail, + }; + + if (window.decrypt.serverError) { + unexpectedProblem.hints = [ + window.decrypt.serverError.replace('{0}', file.name), + ]; + } + + this.showProblemDetail(unexpectedProblem); return null; // No file to return } } @@ -129,11 +282,8 @@ export class DecryptFile { // Handle corrupted PDF files console.error('Corrupted PDF detected:', error); if (window.stirlingPDF.currentPage !== 'repair') { - this.showErrorBanner( - `${window.stirlingPDF.pdfCorruptedMessage.replace('{0}', file.name)}`, - error.stack || '', - `${window.stirlingPDF.tryRepairMessage}` - ); + const corruptedProblem = buildCorruptedPdfProblemDetail(file.name); + this.showProblemDetail(corruptedProblem); } else { console.log('Suppressing corrupted PDF warning banner on repair page'); } @@ -145,19 +295,62 @@ export class DecryptFile { } } - showErrorBanner(message, stackTrace, error) { + showProblemDetail(problemDetail) { const errorContainer = document.getElementById('errorContainer'); - errorContainer.style.display = 'block'; // Display the banner - errorContainer.querySelector('.alert-heading').textContent = error; - errorContainer.querySelector('p').textContent = message; - document.querySelector('#traceContent').textContent = stackTrace; + if (!errorContainer) { + console.error('Error container not found'); + return; + } + + errorContainer.style.display = 'block'; + + const heading = errorContainer.querySelector('.alert-heading'); + const messageEl = errorContainer.querySelector('p'); + const traceEl = document.querySelector('#traceContent'); + + const fallbackHeading = (window.stirlingPDF && window.stirlingPDF.error) || 'Error'; + + if (heading) { + heading.textContent = + (problemDetail && typeof problemDetail === 'object' && problemDetail.title) || + fallbackHeading; + } + + if (messageEl) { + messageEl.style.whiteSpace = 'pre-wrap'; + messageEl.textContent = + typeof problemDetail === 'object' + ? formatUserFriendlyError(problemDetail) + : String(problemDetail || ''); + } + + if (traceEl) { + traceEl.textContent = + typeof problemDetail === 'object' ? formatProblemDetailsJson(problemDetail) : ''; + } } removeErrorBanner() { const errorContainer = document.getElementById('errorContainer'); - errorContainer.style.display = 'none'; // Hide the banner - errorContainer.querySelector('.alert-heading').textContent = ''; - errorContainer.querySelector('p').textContent = ''; - document.querySelector('#traceContent').textContent = ''; + if (!errorContainer) { + return; + } + + errorContainer.style.display = 'none'; + + const heading = errorContainer.querySelector('.alert-heading'); + if (heading) { + heading.textContent = (window.stirlingPDF && window.stirlingPDF.error) || 'Error'; + } + + const messageEl = errorContainer.querySelector('p'); + if (messageEl) { + messageEl.textContent = ''; + } + + const traceEl = document.querySelector('#traceContent'); + if (traceEl) { + traceEl.textContent = ''; + } } } diff --git a/app/core/src/main/resources/static/js/downloader.js b/app/core/src/main/resources/static/js/downloader.js index 070fd90af..52113b731 100644 --- a/app/core/src/main/resources/static/js/downloader.js +++ b/app/core/src/main/resources/static/js/downloader.js @@ -19,12 +19,87 @@ error, } = window.stirlingPDF; + // Format Problem Details JSON with consistent key order and pretty-printing + function formatProblemDetailsJson(input) { + try { + const obj = typeof input === 'string' ? JSON.parse(input) : input; + const preferredOrder = [ + 'errorCode', + 'title', + 'status', + 'type', + 'detail', + 'instance', + 'path', + 'timestamp', + 'hints', + 'actionRequired' + ]; + + const out = {}; + // Place preferred keys first if present + preferredOrder.forEach((k) => { + if (Object.prototype.hasOwnProperty.call(obj, k)) { + out[k] = obj[k]; + } + }); + // Append remaining keys preserving their original order + Object.keys(obj).forEach((k) => { + if (!Object.prototype.hasOwnProperty.call(out, k)) { + out[k] = obj[k]; + } + }); + return JSON.stringify(out, null, 2); + } catch (e) { + // Fallback: if it's already a string, return as-is; otherwise pretty-print best effort + if (typeof input === 'string') return input; + try { + return JSON.stringify(input, null, 2); + } catch { + return String(input); + } + } + } + function showErrorBanner(message, stackTrace) { const errorContainer = document.getElementById('errorContainer'); + if (!errorContainer) { + console.error('Error container not found'); + return; + } errorContainer.style.display = 'block'; // Display the banner - errorContainer.querySelector('.alert-heading').textContent = error; - errorContainer.querySelector('p').textContent = message; - document.querySelector('#traceContent').textContent = stackTrace; + const heading = errorContainer.querySelector('.alert-heading'); + const messageEl = errorContainer.querySelector('p'); + const traceEl = document.querySelector('#traceContent'); + + if (heading) heading.textContent = error; + if (messageEl) { + messageEl.style.whiteSpace = 'pre-wrap'; + messageEl.textContent = message; + } + + // Format stack trace: if it looks like JSON, pretty-print with consistent key order; otherwise clean it up + if (traceEl) { + if (stackTrace) { + // Check if stackTrace is already JSON formatted + if (stackTrace.trim().startsWith('{') || stackTrace.trim().startsWith('[')) { + traceEl.textContent = formatProblemDetailsJson(stackTrace); + } else { + // Filter out unhelpful stack traces (internal browser/library paths) + // Only show if it contains meaningful error info + const lines = stackTrace.split('\n'); + const meaningfulLines = lines.filter(line => + !line.includes('pdfjs-legacy') && + !line.includes('pdf.worker') && + !line.includes('pdf.mjs') && + line.trim().length > 0 + ); + traceEl.textContent = meaningfulLines.length > 0 ? meaningfulLines.join('\n') : 'No additional trace information available'; + } + } else { + traceEl.textContent = ''; + } + } } function showSessionExpiredPrompt() { @@ -210,11 +285,38 @@ if (!password) { console.error(`No password provided for encrypted PDF: ${file.name}`); - showErrorBanner( - `${window.decrypt.noPassword.replace('{0}', file.name)}`, - `${window.decrypt.unexpectedError}` - ); - throw error; + + // Create a Problem Detail object matching the server's E004 response using localized strings + const passwordDetailTemplate = + window.stirlingPDF?.pdfPasswordDetail || + `The PDF file "${file.name}" requires a password to proceed.`; + const hints = [ + window.stirlingPDF?.pdfPasswordHint1, + window.stirlingPDF?.pdfPasswordHint2, + window.stirlingPDF?.pdfPasswordHint3, + window.stirlingPDF?.pdfPasswordHint4, + window.stirlingPDF?.pdfPasswordHint5, + window.stirlingPDF?.pdfPasswordHint6 + ].filter(Boolean); + const noProblemDetail = { + errorCode: 'E004', + title: window.stirlingPDF?.pdfPasswordTitle || 'PDF Password Required', + detail: passwordDetailTemplate.includes('{0}') + ? passwordDetailTemplate.replace('{0}', file.name) + : passwordDetailTemplate, + hints, + actionRequired: + window.stirlingPDF?.pdfPasswordAction || + 'Provide the owner/permissions password, not just the document open password.' + }; + + const bannerMessage = formatUserFriendlyError(noProblemDetail); + const debugInfo = formatProblemDetailsJson(noProblemDetail); + showErrorBanner(bannerMessage, debugInfo); + + const err = new Error(noProblemDetail.detail); + err.alreadyHandled = true; + throw err; } try { @@ -226,18 +328,30 @@ // Use handleSingleDownload to send the request const decryptionResult = await fetchWithCsrf(removePasswordUrl, {method: 'POST', body: formData}); + // Check if we got an error response (RFC 7807 Problem Details) + if (!decryptionResult.ok) { + const contentType = decryptionResult.headers.get('content-type'); + if (contentType && (contentType.includes('application/json') || contentType.includes('application/problem+json'))) { + // Parse the RFC 7807 error response + const errorJson = await decryptionResult.json(); + const formattedError = formatUserFriendlyError(errorJson); + const debugInfo = formatProblemDetailsJson(errorJson); + const title = errorJson.title || 'Decryption Failed'; + const detail = errorJson.detail || 'Failed to decrypt PDF'; + const bannerMessage = formattedError || `${title}: ${detail}`; + showErrorBanner(bannerMessage, debugInfo); + const err = new Error(detail); + err.alreadyHandled = true; // Mark error as already handled + throw err; + } else { + throw new Error('Decryption failed: Invalid server response'); + } + } + if (decryptionResult && decryptionResult.blob) { const decryptedBlob = await decryptionResult.blob(); const decryptedFile = new File([decryptedBlob], file.name, {type: 'application/pdf'}); - /* // Create a link element to download the file - const link = document.createElement('a'); - link.href = URL.createObjectURL(decryptedBlob); - link.download = 'test.pdf'; - document.body.appendChild(link); - link.click(); - document.body.removeChild(link); -*/ decryptedFiles.push(decryptedFile); console.log(`Successfully decrypted PDF: ${file.name}`); } else { @@ -245,10 +359,7 @@ } } catch (decryptError) { console.error(`Failed to decrypt PDF: ${file.name}`, decryptError); - showErrorBanner( - `${window.decrypt.invalidPasswordHeader.replace('{0}', file.name)}`, - `${window.decrypt.invalidPassword}` - ); + // Error banner already shown above with formatted hints/actions throw decryptError; } } else if (error.name === 'InvalidPDFException' || @@ -256,10 +367,34 @@ // Handle corrupted PDF files console.log(`Corrupted PDF detected: ${file.name}`, error); if (window.stirlingPDF.currentPage !== 'repair') { - showErrorBanner( - `${window.stirlingPDF.pdfCorruptedMessage.replace('{0}', file.name)}`, - `${window.stirlingPDF.tryRepairMessage}` - ); + // Create a formatted error message using properties from language files + const errorMessage = window.stirlingPDF.pdfCorruptedMessage.replace('{0}', file.name); + const hints = [ + window.stirlingPDF.pdfCorruptedHint1, + window.stirlingPDF.pdfCorruptedHint2, + window.stirlingPDF.pdfCorruptedHint3 + ].filter((hint) => typeof hint === 'string' && hint.trim().length > 0); + const action = window.stirlingPDF.pdfCorruptedAction || window.stirlingPDF.tryRepairMessage; + + const problemDetails = { + title: window.stirlingPDF.pdfCorruptedTitle || window.stirlingPDF.error || 'Error', + detail: errorMessage + }; + + if (hints.length > 0) { + problemDetails.hints = hints; + } + + if (action) { + problemDetails.actionRequired = action; + } + + const bannerMessage = formatUserFriendlyError(problemDetails); + const debugInfo = formatProblemDetailsJson(problemDetails); + + showErrorBanner(bannerMessage, debugInfo); + // Mark error as already handled to prevent double display + error.alreadyHandled = true; } else { // On repair page, suppress banner; user already knows and is repairing console.log('Suppressing corrupted PDF banner on repair page'); @@ -286,15 +421,33 @@ if (!response.ok) { errorMessage = response.status; + // Check for JSON error responses first (including RFC 7807 Problem Details) + if (contentType && (contentType.includes('application/json') || contentType.includes('application/problem+json'))) { + console.error('Throwing error banner, response was not okay'); + await handleJsonResponse(response); + // Return early - error banner already shown by handleJsonResponse + // Don't throw to avoid double error display + return null; + } + // Only show session expired for 401 without JSON body (actual auth failure) if (response.status === 401) { showSessionExpiredPrompt(); return; } - if (contentType && contentType.includes('application/json')) { - console.error('Throwing error banner, response was not okay'); - return handleJsonResponse(response); + // For non-JSON errors, try to extract error message from response body + try { + const errorText = await response.text(); + if (errorText && errorText.trim().length > 0) { + showErrorBanner(`HTTP ${response.status}`, errorText); + // Return early - error already shown + return null; + } + } catch (textError) { + // If we can't read the response body, show generic error + const errorMsg = `HTTP ${response.status} - ${response.statusText || 'Request failed'}`; + showErrorBanner('Error', errorMsg); + return null; } - throw new Error(`HTTP error! status: ${response.status}`); } const contentDisposition = response.headers.get('Content-Disposition'); @@ -351,20 +504,100 @@ return filename; } + /** + * Format error details in a user-friendly way + * Extracts key information and presents hints/actions prominently + */ + function formatUserFriendlyError(json) { + if (!json || typeof json !== 'object') { + return typeof json === 'string' ? json : ''; + } + + const lines = []; + const title = json.title || json.error || ''; + const detail = json.detail || json.message || ''; + + const primaryLine = title && detail + ? `${title}: ${detail}` + : title || detail; + + if (primaryLine) { + lines.push(primaryLine); + } + + if (json.errorCode) { + lines.push(''); + lines.push(`Error Code: ${json.errorCode}`); + } + + const detailAlreadyIncluded = detail && primaryLine && primaryLine.includes(detail); + + if (detail && !detailAlreadyIncluded) { + lines.push(''); + lines.push(detail); + } + + if (json.hints && Array.isArray(json.hints) && json.hints.length > 0) { + lines.push(''); + lines.push('How to fix:'); + json.hints.forEach((hint, index) => { + lines.push(` ${index + 1}. ${hint}`); + }); + } + + if (json.actionRequired) { + lines.push(''); + lines.push(json.actionRequired); + } + + if (json.supportId) { + lines.push(''); + lines.push(`Support ID: ${json.supportId}`); + } + + return lines + .filter((line, index, arr) => { + if (line !== '') return true; + if (index === 0 || index === arr.length - 1) return false; + return arr[index - 1] !== ''; + }) + .join('\n'); + } + async function handleJsonResponse(response) { const json = await response.json(); - const errorMessage = JSON.stringify(json, null, 2); - if ( - errorMessage.toLowerCase().includes('the password is incorrect') || - errorMessage.toLowerCase().includes('Password is not provided') || - errorMessage.toLowerCase().includes('PDF contains an encryption dictionary') - ) { + + // Format the full JSON response for display in stack trace with errorCode first + const formattedJson = formatProblemDetailsJson(json); + + // Check for PDF password errors using RFC 7807 fields + const isPdfPasswordError = + json.type === '/errors/pdf-password' || + json.errorCode === 'E004' || + (json.detail && ( + json.detail.toLowerCase().includes('pdf document is passworded') || + json.detail.toLowerCase().includes('password is incorrect') || + json.detail.toLowerCase().includes('password was not provided') || + json.detail.toLowerCase().includes('pdf contains an encryption dictionary') + )); + + const fallbackTitle = json.title || json.error || 'Error'; + const fallbackDetail = json.detail || json.message || ''; + const fallbackMessage = fallbackDetail ? `${fallbackTitle}: ${fallbackDetail}` : fallbackTitle; + const bannerMessage = formatUserFriendlyError(json) || fallbackMessage; + + if (isPdfPasswordError) { + showErrorBanner(bannerMessage, formattedJson); + + // Show alert only once for user attention if (!firstErrorOccurred) { firstErrorOccurred = true; - alert(pdfPasswordPrompt); + const detail = json.detail || 'The PDF document requires a password to open.'; + alert(pdfPasswordPrompt + '\n\n' + detail); } } else { - showErrorBanner(json.error + ':' + json.message, json.trace); + // Show user-friendly error, fallback to full JSON for debugging + showErrorBanner(bannerMessage, formattedJson); } } @@ -389,6 +622,10 @@ } function handleDownloadError(error) { + // Skip if error was already handled and displayed + if (error.alreadyHandled) { + return; + } const errorMessage = error.message; showErrorBanner(errorMessage); } @@ -469,12 +706,15 @@ try { const downloadDetails = await handleSingleDownload(url, fileFormData, true, zipFiles); console.log(downloadDetails); - if (zipFiles) { - jszip.file(downloadDetails.filename, downloadDetails.blob); - } else { - //downloadFile(downloadDetails.blob, downloadDetails.filename); + // If downloadDetails is null, error was already shown, skip processing + if (downloadDetails) { + if (zipFiles) { + jszip.file(downloadDetails.filename, downloadDetails.blob); + } else { + //downloadFile(downloadDetails.blob, downloadDetails.filename); + } + updateProgressBar(progressBar, Array.from(files).length); } - updateProgressBar(progressBar, Array.from(files).length); } catch (error) { handleDownloadError(error); console.error(error); diff --git a/app/core/src/main/resources/static/js/errorBanner.js b/app/core/src/main/resources/static/js/errorBanner.js index 727a854f7..528fe8f27 100644 --- a/app/core/src/main/resources/static/js/errorBanner.js +++ b/app/core/src/main/resources/static/js/errorBanner.js @@ -3,7 +3,7 @@ var traceVisible = false; function toggletrace() { var traceDiv = document.getElementById("trace"); if (!traceVisible) { - traceDiv.style.maxHeight = "500px"; + traceDiv.style.maxHeight = "100vh"; traceVisible = true; } else { traceDiv.style.maxHeight = "0px"; diff --git a/app/core/src/main/resources/static/js/homecard.js b/app/core/src/main/resources/static/js/homecard.js index 7da818d05..2e321b66d 100644 --- a/app/core/src/main/resources/static/js/homecard.js +++ b/app/core/src/main/resources/static/js/homecard.js @@ -186,7 +186,9 @@ function sortNavElements(criteria) { async function fetchPopularityData(url) { const response = await fetch(url); if (!response.ok) { - throw new Error(`HTTP error! status: ${response.status}`); + const errorText = await response.text().catch(() => ''); + const errorMsg = errorText || response.statusText || 'Request failed'; + throw new Error(`HTTP ${response.status}: ${errorMsg}`); } return await response.text(); } @@ -220,7 +222,9 @@ document.addEventListener('DOMContentLoaded', async function () { try { const response = await fetch('/files/popularity.txt'); if (!response.ok) { - throw new Error(`HTTP error! status: ${response.status}`); + const errorText = await response.text().catch(() => ''); + const errorMsg = errorText || response.statusText || 'Request failed'; + throw new Error(`HTTP ${response.status}: ${errorMsg}`); } const popularityData = await response.json(); applyPopularityData(popularityData); diff --git a/app/core/src/main/resources/templates/fragments/common.html b/app/core/src/main/resources/templates/fragments/common.html index 2883d905c..52777b3fd 100644 --- a/app/core/src/main/resources/templates/fragments/common.html +++ b/app/core/src/main/resources/templates/fragments/common.html @@ -419,6 +419,20 @@ window.stirlingPDF.uploadLimitExceededPlural = /*[[#{uploadLimitExceededPlural}]]*/ 'are too large. Maximum allowed size is'; window.stirlingPDF.pdfCorruptedMessage = /*[[#{error.pdfInvalid}]]*/ 'The PDF file "{0}" appears to be corrupted or has an invalid structure. Please try using the \'Repair PDF\' feature to fix the file before proceeding.'; window.stirlingPDF.tryRepairMessage = /*[[#{error.tryRepair}]]*/ 'Try using the Repair PDF feature to fix corrupted files.'; + window.stirlingPDF.pdfCorruptedTitle = /*[[#{error.pdfCorrupted.title}]]*/ 'PDF File Corrupted'; + window.stirlingPDF.pdfCorruptedHint1 = /*[[#{error.E001.hint.1}]]*/ 'Try the \'Repair PDF\' feature, then retry this operation.'; + window.stirlingPDF.pdfCorruptedHint2 = /*[[#{error.E001.hint.2}]]*/ 'Re-export the PDF from the original application if possible.'; + window.stirlingPDF.pdfCorruptedHint3 = /*[[#{error.E001.hint.3}]]*/ 'Avoid transferring the file through tools that modify PDFs (e.g., fax/email converters).'; + window.stirlingPDF.pdfCorruptedAction = /*[[#{error.E001.action}]]*/ 'Repair the PDF and retry the operation.'; + window.stirlingPDF.pdfPasswordTitle = /*[[#{error.pdfPassword.title}]]*/ 'PDF Password Required'; + window.stirlingPDF.pdfPasswordDetail = /*[[#{error.pdfPassword}]]*/ 'The PDF Document is passworded and either the password was not provided or was incorrect'; + window.stirlingPDF.pdfPasswordHint1 = /*[[#{error.E004.hint.1}]]*/ 'PDFs can have two passwords: a user password (opens the document) and an owner password (controls permissions). This operation requires the owner password.'; + window.stirlingPDF.pdfPasswordHint2 = /*[[#{error.E004.hint.2}]]*/ 'If you can open the PDF without a password, it may only have an owner password set. Try submitting the permissions password.'; + window.stirlingPDF.pdfPasswordHint3 = /*[[#{error.E004.hint.3}]]*/ 'Digitally signed PDFs cannot have security removed until the signature is removed.'; + window.stirlingPDF.pdfPasswordHint4 = /*[[#{error.E004.hint.4}]]*/ 'Passwords are case-sensitive. Verify capitalisation, spaces, and special characters.'; + window.stirlingPDF.pdfPasswordHint5 = /*[[#{error.E004.hint.5}]]*/ 'Some creators use different encryption standards (40-bit, 128-bit, 256-bit AES). Ensure your password matches the encryption used.'; + window.stirlingPDF.pdfPasswordHint6 = /*[[#{error.E004.hint.6}]]*/ 'If you only have the user password, you cannot remove security restrictions. Contact the document owner for the permissions password.'; + window.stirlingPDF.pdfPasswordAction = /*[[#{error.E004.action}]]*/ 'Provide the owner/permissions password, not just the document open password.'; window.stirlingPDF.currentPage = /*[[${currentPage}]]*/ ''; })(); diff --git a/app/core/src/main/resources/templates/fragments/errorBanner.html b/app/core/src/main/resources/templates/fragments/errorBanner.html index d682dcb92..bb65a9428 100644 --- a/app/core/src/main/resources/templates/fragments/errorBanner.html +++ b/app/core/src/main/resources/templates/fragments/errorBanner.html @@ -3,7 +3,7 @@