feat(pdf-to-cbr): integrate RAR for CBR output generation (#4626)

# 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


<!--
Please provide a summary of the changes, including:

- What was changed
- Why the change was made
- Any challenges encountered

Closes #(issue_number)
-->

---

## 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 <bszucs1209@gmail.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
Balázs Szücs 2025-10-10 15:10:44 +02:00 committed by GitHub
parent 0a02e3e231
commit 599beb7912
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 104 additions and 16 deletions

View File

@ -2,9 +2,13 @@ package stirling.software.common.util;
import java.awt.image.BufferedImage; import java.awt.image.BufferedImage;
import java.io.ByteArrayOutputStream; import java.io.ByteArrayOutputStream;
import java.io.FileInputStream;
import java.io.IOException; import java.io.IOException;
import java.util.zip.ZipEntry; import java.nio.file.Files;
import java.util.zip.ZipOutputStream; import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import javax.imageio.ImageIO; import javax.imageio.ImageIO;
@ -17,6 +21,7 @@ import org.springframework.web.multipart.MultipartFile;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import stirling.software.common.service.CustomPDFDocumentFactory; import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.ProcessExecutor.ProcessExecutorResult;
@Slf4j @Slf4j
public class PdfToCbrUtils { public class PdfToCbrUtils {
@ -55,9 +60,9 @@ public class PdfToCbrUtils {
private static byte[] createCbrFromPdf(PDDocument document, int dpi) throws IOException { private static byte[] createCbrFromPdf(PDDocument document, int dpi) throws IOException {
PDFRenderer pdfRenderer = new PDFRenderer(document); PDFRenderer pdfRenderer = new PDFRenderer(document);
try (ByteArrayOutputStream cbrOutputStream = new ByteArrayOutputStream(); Path tempDir = Files.createTempDirectory("stirling-pdf-cbr-");
ZipOutputStream zipOut = new ZipOutputStream(cbrOutputStream)) { List<Path> generatedImages = new ArrayList<>();
try {
int totalPages = document.getNumberOfPages(); int totalPages = document.getNumberOfPages();
for (int pageIndex = 0; pageIndex < totalPages; pageIndex++) { for (int pageIndex = 0; pageIndex < totalPages; pageIndex++) {
@ -66,12 +71,10 @@ public class PdfToCbrUtils {
pdfRenderer.renderImageWithDPI(pageIndex, dpi, ImageType.RGB); pdfRenderer.renderImageWithDPI(pageIndex, dpi, ImageType.RGB);
String imageFilename = String.format("page_%03d.png", pageIndex + 1); String imageFilename = String.format("page_%03d.png", pageIndex + 1);
Path imagePath = tempDir.resolve(imageFilename);
ZipEntry zipEntry = new ZipEntry(imageFilename); ImageIO.write(image, "PNG", imagePath.toFile());
zipOut.putNextEntry(zipEntry); generatedImages.add(imagePath);
ImageIO.write(image, "PNG", zipOut);
zipOut.closeEntry();
} catch (IOException e) { } catch (IOException e) {
log.warn("Error processing page {}: {}", pageIndex + 1, e.getMessage()); log.warn("Error processing page {}: {}", pageIndex + 1, e.getMessage());
@ -82,8 +85,79 @@ public class PdfToCbrUtils {
} }
} }
zipOut.finish(); if (generatedImages.isEmpty()) {
return cbrOutputStream.toByteArray(); 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<Path> images) throws IOException {
List<String> 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<Path> 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());
}
} }
} }

View File

@ -386,6 +386,7 @@ public class EndpointConfiguration {
addEndpointToGroup("Java", "pdf-to-markdown"); addEndpointToGroup("Java", "pdf-to-markdown");
addEndpointToGroup("Java", "add-attachments"); addEndpointToGroup("Java", "add-attachments");
addEndpointToGroup("Java", "compress-pdf"); addEndpointToGroup("Java", "compress-pdf");
addEndpointToGroup("rar", "pdf-to-cbr");
// Javascript // Javascript
addEndpointToGroup("Javascript", "pdf-organizer"); addEndpointToGroup("Javascript", "pdf-organizer");
@ -484,7 +485,8 @@ public class EndpointConfiguration {
|| "Java".equals(group) || "Java".equals(group)
|| "Javascript".equals(group) || "Javascript".equals(group)
|| "Weasyprint".equals(group) || "Weasyprint".equals(group)
|| "Pdftohtml".equals(group); || "Pdftohtml".equals(group)
|| "rar".equals(group);
} }
private boolean isEndpointEnabledDirectly(String endpoint) { private boolean isEndpointEnabledDirectly(String endpoint) {

View File

@ -43,6 +43,7 @@ public class ExternalAppDepConfig {
put(unoconvPath, List.of("Unoconvert")); put(unoconvPath, List.of("Unoconvert"));
put("qpdf", List.of("qpdf")); put("qpdf", List.of("qpdf"));
put("tesseract", List.of("tesseract")); 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(weasyprintPath);
checkDependencyAndDisableGroup("pdftohtml"); checkDependencyAndDisableGroup("pdftohtml");
checkDependencyAndDisableGroup(unoconvPath); checkDependencyAndDisableGroup(unoconvPath);
checkDependencyAndDisableGroup("rar");
// Special handling for Python/OpenCV dependencies // Special handling for Python/OpenCV dependencies
boolean pythonAvailable = isCommandAvailable("python3") || isCommandAvailable("python"); boolean pythonAvailable = isCommandAvailable("python3") || isCommandAvailable("python");
if (!pythonAvailable) { if (!pythonAvailable) {

View File

@ -371,8 +371,8 @@ public class ConvertImgPDFController {
@Operation( @Operation(
summary = "Convert PDF to CBR comic book archive", summary = "Convert PDF to CBR comic book archive",
description = description =
"This endpoint converts a PDF file to a CBR-like (ZIP-based) comic book archive. " "This endpoint converts a PDF file to a CBR comic book archive using the local RAR CLI. "
+ "Note: Output is ZIP-based for compatibility. Input:PDF Output:CBR Type:SISO") + "Input:PDF Output:CBR Type:SISO")
public ResponseEntity<?> convertPdfToCbr(@ModelAttribute ConvertPdfToCbrRequest request) public ResponseEntity<?> convertPdfToCbr(@ModelAttribute ConvertPdfToCbrRequest request)
throws IOException { throws IOException {
MultipartFile file = request.getFileInput(); MultipartFile file = request.getFileInput();

View File

@ -1,13 +1,16 @@
package stirling.software.SPDF.controller.web; package stirling.software.SPDF.controller.web;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Controller; import org.springframework.stereotype.Controller;
import org.springframework.ui.Model; import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.server.ResponseStatusException;
import org.springframework.web.servlet.ModelAndView; import org.springframework.web.servlet.ModelAndView;
import io.swagger.v3.oas.annotations.Hidden; import io.swagger.v3.oas.annotations.Hidden;
import io.swagger.v3.oas.annotations.tags.Tag; import io.swagger.v3.oas.annotations.tags.Tag;
import stirling.software.SPDF.config.EndpointConfiguration;
import stirling.software.common.model.ApplicationProperties; import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.util.ApplicationContextProvider; import stirling.software.common.util.ApplicationContextProvider;
import stirling.software.common.util.CheckProgramInstall; import stirling.software.common.util.CheckProgramInstall;
@ -47,6 +50,10 @@ public class ConverterWebController {
@GetMapping("/pdf-to-cbr") @GetMapping("/pdf-to-cbr")
@Hidden @Hidden
public String convertPdfToCbrForm(Model model) { 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"); model.addAttribute("currentPage", "pdf-to-cbr");
return "convert/pdf-to-cbr"; return "convert/pdf-to-cbr";
} }

View File

@ -62,7 +62,10 @@ public class InitialSecuritySetup {
boolean jwtEnabled = jwtProperties.isEnabled(); boolean jwtEnabled = jwtProperties.isEnabled();
if (!v2Enabled || !jwtEnabled) { 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); jwtProperties.setKeyCleanup(false);
} }