From 599beb79120a78b924d7b6831aa1cfd8e0a60635 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, 10 Oct 2025 15:10:44 +0200 Subject: [PATCH] feat(pdf-to-cbr): integrate RAR for CBR output generation (#4626) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Description of Changes This pull request introduces full support for generating true CBR (Comic Book RAR) archives from PDF files using the local RAR CLI ### CBR Conversion Implementation: - Refactored `PdfToCbrUtils.java` to generate image files for each PDF page, invoke the RAR CLI to create a `.cbr` archive, and clean up temporary files after conversion.. ### Dependency & Endpoint Management: - Added RAR as a required external dependency in `ExternalAppDepConfig.java` and checks for its availability, disabling related endpoints if missing. - Registered new endpoints under the "RAR" group in `EndpointConfiguration.java` and updated group validation logic. ### Controller and API Updates: - Updated the API controller to clarify that the output is a true CBR archive created with RAR, not ZIP-based. - Modified the web controller to check for endpoint availability and return a 404 error if the CBR conversion feature is disabled. ### Sample logs/verification: Conversion command > 23:12:41.552 [qtp1634254747-43] INFO s.s.common.util.ProcessExecutor - Running command: rar a -m5 -ep1 output.cbr page_001.png > 23:12:41.571 [Thread-25] INFO s.s.common.util.ProcessExecutor - > 23:12:41.571 [Thread-25] INFO s.s.common.util.ProcessExecutor - RAR 7.12 Copyright (c) 1993-2025 Alexander Roshal 23 Jun 2025 > 23:12:41.571 [Thread-25] INFO s.s.common.util.ProcessExecutor - Trial version Type 'rar -?' for help > 23:12:41.571 [Thread-25] INFO s.s.common.util.ProcessExecutor - > 23:12:41.571 [Thread-25] INFO s.s.common.util.ProcessExecutor - Evaluation copy. Please register. > 23:12:41.571 [Thread-25] INFO s.s.common.util.ProcessExecutor - > 23:12:41.572 [Thread-25] INFO s.s.common.util.ProcessExecutor - Creating archive output.cbr > 23:12:41.578 [Thread-25] INFO s.s.common.util.ProcessExecutor - > 23:12:41.587 [Thread-25] INFO s.s.common.util.ProcessExecutor - Adding page_001.png OK > 23:12:41.587 [Thread-25] INFO s.s.common.util.ProcessExecutor - Done Verification whether its RAR (not included in the code; was to verify whether the code works) > ~/Downloads > ❯ unrar l lorem-ipsum_converted.cbr > > UNRAR 7.12 freeware Copyright (c) 1993-2025 Alexander Roshal > > Archive: lorem-ipsum_converted.cbr > Details: RAR 5 > > Attributes Size Date Time Name > ----------- --------- ---------- ----- ---- > -rw-r--r-- 105955 2025-10-07 23:12 page_001.png > ----------- --------- ---------- ----- ---- > 105955 1 Logs on startup with no RAR CLI > INFO:unoserver:Started. > 12:09:16.592 [main] INFO s.s.p.s.configuration.DatabaseConfig - Using default H2 database > INFO:unoserver:Server PID: 46 > 12:09:21.281 [main] INFO s.s.c.config.TempFileConfiguration - Created temporary directory: /tmp/stirling-pdf/stirling-pdf > 12:09:21.329 [main] WARN s.s.SPDF.config.ExternalAppDepConfig - Missing dependency: rar - Disabling group: RAR (Affected features: Pdf/cbr, PDF To Cbr) > 12:09:22.066 [main] INFO s.s.S.config.EndpointConfiguration - Disabled tool groups: RAR (endpoints may have alternative implementations) > 12:09:22.066 [main] INFO s.s.S.config.EndpointConfiguration - Disabled functional groups: enterprise > 12:09:22.066 [main] INFO s.s.S.config.EndpointConfiguration - Total disabled endpoints: 3. Disabled endpoints: pdf-to-cbr, pdf/cbr, url-to-pdf > 12:09:22.407 [main] INFO s.s.p.s.service.DatabaseService - Source directory does not exist: configs/db/backup > 12:09:23.092 [main] INFO s.software.common.util.FileMonitor - Monitoring directory: ./pipeline/watchedFolders > 12:09:23.721 [main] INFO s.s.c.service.TempFileCleanupService - Created LibreOffice temp directory: /tmp/stirling-pdf/stirling-pdf/libreoffice --- ## Checklist ### General - [x] I have read the [Contribution Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md) - [x] 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) - [x] I have performed a self-review of my own code - [x] 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) - [x] 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 Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../software/common/util/PdfToCbrUtils.java | 98 ++++++++++++++++--- .../SPDF/config/EndpointConfiguration.java | 4 +- .../SPDF/config/ExternalAppDepConfig.java | 2 + .../converters/ConvertImgPDFController.java | 4 +- .../web/ConverterWebController.java | 7 ++ .../security/InitialSecuritySetup.java | 5 +- 6 files changed, 104 insertions(+), 16 deletions(-) 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 a5bf9a888..cb17429ff 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 @@ -2,9 +2,13 @@ package stirling.software.common.util; import java.awt.image.BufferedImage; import java.io.ByteArrayOutputStream; +import java.io.FileInputStream; import java.io.IOException; -import java.util.zip.ZipEntry; -import java.util.zip.ZipOutputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; import javax.imageio.ImageIO; @@ -17,6 +21,7 @@ import org.springframework.web.multipart.MultipartFile; import lombok.extern.slf4j.Slf4j; import stirling.software.common.service.CustomPDFDocumentFactory; +import stirling.software.common.util.ProcessExecutor.ProcessExecutorResult; @Slf4j public class PdfToCbrUtils { @@ -55,9 +60,9 @@ public class PdfToCbrUtils { private static byte[] createCbrFromPdf(PDDocument document, int dpi) throws IOException { PDFRenderer pdfRenderer = new PDFRenderer(document); - try (ByteArrayOutputStream cbrOutputStream = new ByteArrayOutputStream(); - ZipOutputStream zipOut = new ZipOutputStream(cbrOutputStream)) { - + Path tempDir = Files.createTempDirectory("stirling-pdf-cbr-"); + List generatedImages = new ArrayList<>(); + try { int totalPages = document.getNumberOfPages(); for (int pageIndex = 0; pageIndex < totalPages; pageIndex++) { @@ -66,12 +71,10 @@ public class PdfToCbrUtils { pdfRenderer.renderImageWithDPI(pageIndex, dpi, ImageType.RGB); String imageFilename = String.format("page_%03d.png", pageIndex + 1); + Path imagePath = tempDir.resolve(imageFilename); - ZipEntry zipEntry = new ZipEntry(imageFilename); - zipOut.putNextEntry(zipEntry); - - ImageIO.write(image, "PNG", zipOut); - zipOut.closeEntry(); + ImageIO.write(image, "PNG", imagePath.toFile()); + generatedImages.add(imagePath); } catch (IOException e) { log.warn("Error processing page {}: {}", pageIndex + 1, e.getMessage()); @@ -82,8 +85,79 @@ public class PdfToCbrUtils { } } - zipOut.finish(); - return cbrOutputStream.toByteArray(); + if (generatedImages.isEmpty()) { + throw new IOException("Failed to render any pages to images for CBR conversion"); + } + + return createRarArchive(tempDir, generatedImages); + } finally { + cleanupTempFiles(generatedImages, tempDir); + } + } + + private static byte[] createRarArchive(Path tempDir, List images) throws IOException { + List command = new ArrayList<>(); + command.add("rar"); + command.add("a"); + command.add("-m5"); + command.add("-ep1"); + + Path rarFile = tempDir.resolve("output.cbr"); + command.add(rarFile.getFileName().toString()); + + for (Path image : images) { + command.add(image.getFileName().toString()); + } + + ProcessExecutor executor = + ProcessExecutor.getInstance(ProcessExecutor.Processes.INSTALL_APP); + try { + ProcessExecutorResult result = + executor.runCommandWithOutputHandling(command, tempDir.toFile()); + if (result.getRc() != 0) { + throw new IOException("RAR command failed: " + result.getMessages()); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IOException("RAR command interrupted", e); + } + + if (!Files.exists(rarFile)) { + throw new IOException("RAR file was not created"); + } + + try (FileInputStream fis = new FileInputStream(rarFile.toFile()); + ByteArrayOutputStream baos = new ByteArrayOutputStream()) { + fis.transferTo(baos); + return baos.toByteArray(); + } + } + + private static void cleanupTempFiles(List images, Path tempDir) { + for (Path image : images) { + try { + Files.deleteIfExists(image); + } catch (IOException e) { + log.warn("Failed to delete temp image file {}: {}", image, e.getMessage()); + } + } + if (tempDir != null) { + try (var paths = Files.walk(tempDir)) { + paths.sorted(Comparator.reverseOrder()) + .forEach( + path -> { + try { + Files.deleteIfExists(path); + } catch (IOException e) { + log.warn( + "Failed to delete temp path {}: {}", + path, + e.getMessage()); + } + }); + } catch (IOException e) { + log.warn("Failed to clean up temp directory {}: {}", tempDir, e.getMessage()); + } } } diff --git a/app/core/src/main/java/stirling/software/SPDF/config/EndpointConfiguration.java b/app/core/src/main/java/stirling/software/SPDF/config/EndpointConfiguration.java index acee3db0c..2b074640d 100644 --- a/app/core/src/main/java/stirling/software/SPDF/config/EndpointConfiguration.java +++ b/app/core/src/main/java/stirling/software/SPDF/config/EndpointConfiguration.java @@ -386,6 +386,7 @@ public class EndpointConfiguration { addEndpointToGroup("Java", "pdf-to-markdown"); addEndpointToGroup("Java", "add-attachments"); addEndpointToGroup("Java", "compress-pdf"); + addEndpointToGroup("rar", "pdf-to-cbr"); // Javascript addEndpointToGroup("Javascript", "pdf-organizer"); @@ -484,7 +485,8 @@ public class EndpointConfiguration { || "Java".equals(group) || "Javascript".equals(group) || "Weasyprint".equals(group) - || "Pdftohtml".equals(group); + || "Pdftohtml".equals(group) + || "rar".equals(group); } private boolean isEndpointEnabledDirectly(String endpoint) { diff --git a/app/core/src/main/java/stirling/software/SPDF/config/ExternalAppDepConfig.java b/app/core/src/main/java/stirling/software/SPDF/config/ExternalAppDepConfig.java index 49129c548..fd3ab640d 100644 --- a/app/core/src/main/java/stirling/software/SPDF/config/ExternalAppDepConfig.java +++ b/app/core/src/main/java/stirling/software/SPDF/config/ExternalAppDepConfig.java @@ -43,6 +43,7 @@ public class ExternalAppDepConfig { put(unoconvPath, List.of("Unoconvert")); put("qpdf", List.of("qpdf")); put("tesseract", List.of("tesseract")); + put("rar", List.of("rar")); // Required for real CBR output } }; } @@ -120,6 +121,7 @@ public class ExternalAppDepConfig { checkDependencyAndDisableGroup(weasyprintPath); checkDependencyAndDisableGroup("pdftohtml"); checkDependencyAndDisableGroup(unoconvPath); + checkDependencyAndDisableGroup("rar"); // Special handling for Python/OpenCV dependencies boolean pythonAvailable = isCommandAvailable("python3") || isCommandAvailable("python"); if (!pythonAvailable) { 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 2ff3b5196..b58b5ea07 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 @@ -371,8 +371,8 @@ public class ConvertImgPDFController { @Operation( summary = "Convert PDF to CBR comic book archive", description = - "This endpoint converts a PDF file to a CBR-like (ZIP-based) comic book archive. " - + "Note: Output is ZIP-based for compatibility. Input:PDF Output:CBR Type:SISO") + "This endpoint converts a PDF file to a CBR comic book archive using the local RAR CLI. " + + "Input:PDF Output:CBR Type:SISO") public ResponseEntity convertPdfToCbr(@ModelAttribute ConvertPdfToCbrRequest request) throws IOException { MultipartFile file = request.getFileInput(); diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/web/ConverterWebController.java b/app/core/src/main/java/stirling/software/SPDF/controller/web/ConverterWebController.java index befde0bbd..970e0719a 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/web/ConverterWebController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/web/ConverterWebController.java @@ -1,13 +1,16 @@ package stirling.software.SPDF.controller.web; +import org.springframework.http.HttpStatus; import org.springframework.stereotype.Controller; import org.springframework.ui.Model; import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.server.ResponseStatusException; import org.springframework.web.servlet.ModelAndView; import io.swagger.v3.oas.annotations.Hidden; import io.swagger.v3.oas.annotations.tags.Tag; +import stirling.software.SPDF.config.EndpointConfiguration; import stirling.software.common.model.ApplicationProperties; import stirling.software.common.util.ApplicationContextProvider; import stirling.software.common.util.CheckProgramInstall; @@ -47,6 +50,10 @@ public class ConverterWebController { @GetMapping("/pdf-to-cbr") @Hidden public String convertPdfToCbrForm(Model model) { + if (!ApplicationContextProvider.getBean(EndpointConfiguration.class) + .isEndpointEnabled("pdf-to-cbr")) { + throw new ResponseStatusException(HttpStatus.NOT_FOUND); + } model.addAttribute("currentPage", "pdf-to-cbr"); return "convert/pdf-to-cbr"; } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/InitialSecuritySetup.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/InitialSecuritySetup.java index 1629bb619..2320ea8c6 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/InitialSecuritySetup.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/InitialSecuritySetup.java @@ -62,7 +62,10 @@ public class InitialSecuritySetup { boolean jwtEnabled = jwtProperties.isEnabled(); if (!v2Enabled || !jwtEnabled) { - log.debug("V2 enabled: {}, JWT enabled: {} - disabling all JWT features", v2Enabled, jwtEnabled); + log.debug( + "V2 enabled: {}, JWT enabled: {} - disabling all JWT features", + v2Enabled, + jwtEnabled); jwtProperties.setKeyCleanup(false); }