feat(cbr-to-pdf,pdf-to-cbr): add PDF to/from CBR conversion with ebook optimization option (#4581)

# Description of Changes

This pull request adds support for converting CBR (Comic Book RAR) files
to PDF, optimizes CBZ/CBR-to-PDF conversion for e-readers using
Ghostscript, and improves file type detection and image file handling.
It introduces the `CbrUtils` and `PdfToCbrUtils` utility classes,
refactors CBZ conversion logic, and integrates these features into the
API controller. The most important changes are grouped below.

### CBR Support and Conversion:

- Added the `com.github.junrar:junrar` dependency to support RAR/CBR
archive extraction in `build.gradle`. (https://github.com/junrar/junrar
and https://github.com/junrar/junrar?tab=License-1-ov-file#readme for
repo and license)
- Introduced the new utility class `CbrUtils` for converting CBR files
to PDF, including image extraction, sorting, and error handling.
- Added the `PdfToCbrUtils` utility class to convert PDF files into CBR
archives by rendering each page as an image and packaging them.

### CBZ/CBR Conversion Optimization:

- Refactored `CbzUtils.convertCbzToPdf` to support optional Ghostscript
optimization for e-reader compatibility and added a new method for this.
- Added `GeneralUtils.optimizePdfWithGhostscript`, which uses
Ghostscript to optimize PDFs for e-readers, and integrated error
handling.

### API Controller Integration:

- Updated `ConvertImgPDFController` to support CBR conversion, CBZ/CBR
optimization toggling, and Ghostscript availability checks.

### Endpoints
<img width="1298" height="522" alt="image"
src="https://github.com/user-attachments/assets/144d3e03-a637-451a-9c35-f784b2a66dc1"
/>

<img width="1279" height="472" alt="image"
src="https://github.com/user-attachments/assets/879f221d-b775-4224-8edb-a23dbea6a0ca"
/>

### UI

<img width="384" height="105" alt="image"
src="https://github.com/user-attachments/assets/5f861943-0706-4fad-8775-c40a9c1f3170"
/>


### File Type and Image Detection Improvements:

- Improved file extension detection for comic book files and image files
in `CbzUtils` and added a shared regex pattern utility for image files.

### Additional notes:
- Please keep in mind new the dependency, this is not dependency-free
implementation (as opposed to CBZ converter)
- RAR 5 currently not supported. (because JUNRAR does not support it)
- Added the new ebook optimization func to GeneralUtils since we'll soon
(hopefully) at least 3 book/ebook formats (EPUB, CBZ, CBR) all of which
can use it.
- Once again this has been thoroughly tested but can't share actual
"real life" file due to copyright.


Closes: #775
<!--
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

- [x] 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)

- [x] 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 <[email protected]>
This commit is contained in:
Balázs Szücs
2025-10-04 11:15:23 +01:00
committed by GitHub
parent fd95876d8f
commit ec1ac4cb2d
19 changed files with 856 additions and 18 deletions
@@ -8,6 +8,7 @@ import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.regex.Pattern;
import java.util.stream.Stream;
import java.util.zip.ZipEntry;
@@ -32,15 +33,20 @@ import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.SPDF.config.EndpointConfiguration;
import stirling.software.SPDF.model.api.converters.ConvertCbrToPdfRequest;
import stirling.software.SPDF.model.api.converters.ConvertCbzToPdfRequest;
import stirling.software.SPDF.model.api.converters.ConvertPdfToCbrRequest;
import stirling.software.SPDF.model.api.converters.ConvertPdfToCbzRequest;
import stirling.software.SPDF.model.api.converters.ConvertToImageRequest;
import stirling.software.SPDF.model.api.converters.ConvertToPdfRequest;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.CbrUtils;
import stirling.software.common.util.CbzUtils;
import stirling.software.common.util.CheckProgramInstall;
import stirling.software.common.util.ExceptionUtils;
import stirling.software.common.util.GeneralUtils;
import stirling.software.common.util.PdfToCbrUtils;
import stirling.software.common.util.PdfToCbzUtils;
import stirling.software.common.util.PdfUtils;
import stirling.software.common.util.ProcessExecutor;
@@ -58,10 +64,15 @@ public class ConvertImgPDFController {
private final CustomPDFDocumentFactory pdfDocumentFactory;
private final TempFileManager tempFileManager;
private final EndpointConfiguration endpointConfiguration;
private static final Pattern EXTENSION_PATTERN =
RegexPatternUtils.getInstance().getPattern(RegexPatternUtils.getExtensionRegex());
private static final String DEFAULT_COMIC_NAME = "comic";
private boolean isGhostscriptEnabled() {
return endpointConfiguration.isGroupEnabled("Ghostscript");
}
@PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/pdf/img")
@Operation(
summary = "Convert PDF to image(s)",
@@ -257,17 +268,29 @@ public class ConvertImgPDFController {
description =
"This endpoint converts a CBZ (ZIP) comic book archive to a PDF file. "
+ "Input:CBZ Output:PDF Type:SISO")
public ResponseEntity<byte[]> convertCbzToPdf(@ModelAttribute ConvertCbzToPdfRequest request)
public ResponseEntity<?> convertCbzToPdf(@ModelAttribute ConvertCbzToPdfRequest request)
throws IOException {
MultipartFile file = request.getFileInput();
boolean optimizeForEbook = request.isOptimizeForEbook();
// Disable optimization if Ghostscript is not available
if (optimizeForEbook && !isGhostscriptEnabled()) {
log.warn("Ghostscript optimization requested but Ghostscript is not enabled/available");
optimizeForEbook = false;
}
byte[] pdfBytes;
try {
pdfBytes = CbzUtils.convertCbzToPdf(file, pdfDocumentFactory, tempFileManager);
pdfBytes =
CbzUtils.convertCbzToPdf(
file, pdfDocumentFactory, tempFileManager, optimizeForEbook);
} catch (IllegalArgumentException ex) {
String message = ex.getMessage() == null ? "Invalid CBZ file" : ex.getMessage();
Map<String, Object> errorBody =
Map.of("error", "Invalid CBZ file", "message", message, "trace", "");
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.contentType(MediaType.TEXT_PLAIN)
.body(message.getBytes(java.nio.charset.StandardCharsets.UTF_8));
.contentType(MediaType.APPLICATION_JSON)
.body(errorBody);
}
String filename = createConvertedFilename(file.getOriginalFilename(), "_converted.pdf");
@@ -281,12 +304,12 @@ public class ConvertImgPDFController {
description =
"This endpoint converts a PDF file to a CBZ (ZIP) comic book archive. "
+ "Input:PDF Output:CBZ Type:SISO")
public ResponseEntity<byte[]> convertPdfToCbz(@ModelAttribute ConvertPdfToCbzRequest request)
public ResponseEntity<?> convertPdfToCbz(@ModelAttribute ConvertPdfToCbzRequest request)
throws IOException {
MultipartFile file = request.getFileInput();
Integer dpi = request.getDpi();
int dpi = request.getDpi();
if (dpi == null || dpi <= 0) {
if (dpi <= 0) {
dpi = 300;
}
@@ -295,9 +318,11 @@ public class ConvertImgPDFController {
cbzBytes = PdfToCbzUtils.convertPdfToCbz(file, dpi, pdfDocumentFactory);
} catch (IllegalArgumentException ex) {
String message = ex.getMessage() == null ? "Invalid PDF file" : ex.getMessage();
Map<String, Object> errorBody =
Map.of("error", "Invalid PDF file", "message", message, "trace", "");
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.contentType(MediaType.TEXT_PLAIN)
.body(message.getBytes(java.nio.charset.StandardCharsets.UTF_8));
.contentType(MediaType.APPLICATION_JSON)
.body(errorBody);
}
String filename = createConvertedFilename(file.getOriginalFilename(), "_converted.cbz");
@@ -306,6 +331,75 @@ public class ConvertImgPDFController {
cbzBytes, filename, MediaType.APPLICATION_OCTET_STREAM);
}
@PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/cbr/pdf")
@Operation(
summary = "Convert CBR comic book archive to PDF",
description =
"This endpoint converts a CBR (RAR) comic book archive to a PDF file. "
+ "Input:CBR Output:PDF Type:SISO")
public ResponseEntity<?> convertCbrToPdf(@ModelAttribute ConvertCbrToPdfRequest request)
throws IOException {
MultipartFile file = request.getFileInput();
boolean optimizeForEbook = request.isOptimizeForEbook();
// Disable optimization if Ghostscript is not available
if (optimizeForEbook && !isGhostscriptEnabled()) {
log.warn("Ghostscript optimization requested but Ghostscript is not enabled/available");
optimizeForEbook = false;
}
byte[] pdfBytes;
try {
pdfBytes =
CbrUtils.convertCbrToPdf(
file, pdfDocumentFactory, tempFileManager, optimizeForEbook);
} catch (IllegalArgumentException ex) {
String message = ex.getMessage() == null ? "Invalid CBR file" : ex.getMessage();
Map<String, Object> errorBody =
Map.of("error", "Invalid CBR file", "message", message, "trace", "");
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.contentType(MediaType.APPLICATION_JSON)
.body(errorBody);
}
String filename = createConvertedFilename(file.getOriginalFilename(), "_converted.pdf");
return WebResponseUtils.bytesToWebResponse(pdfBytes, filename);
}
@PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/pdf/cbr")
@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")
public ResponseEntity<?> convertPdfToCbr(@ModelAttribute ConvertPdfToCbrRequest request)
throws IOException {
MultipartFile file = request.getFileInput();
int dpi = request.getDpi();
if (dpi <= 0) {
dpi = 300;
}
byte[] cbrBytes;
try {
cbrBytes = PdfToCbrUtils.convertPdfToCbr(file, dpi, pdfDocumentFactory);
} catch (IllegalArgumentException ex) {
String message = ex.getMessage() == null ? "Invalid PDF file" : ex.getMessage();
Map<String, Object> errorBody =
Map.of("error", "Invalid PDF file", "message", message, "trace", "");
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.contentType(MediaType.APPLICATION_JSON)
.body(errorBody);
}
String filename = createConvertedFilename(file.getOriginalFilename(), "_converted.cbr");
return WebResponseUtils.bytesToWebResponse(
cbrBytes, filename, MediaType.APPLICATION_OCTET_STREAM);
}
private String createConvertedFilename(String originalFilename, String suffix) {
if (originalFilename == null) {
return GeneralUtils.generateFilename(DEFAULT_COMIC_NAME, suffix);
@@ -37,6 +37,20 @@ public class ConverterWebController {
return "convert/pdf-to-cbz";
}
@GetMapping("/cbr-to-pdf")
@Hidden
public String convertCbrToPdfForm(Model model) {
model.addAttribute("currentPage", "cbr-to-pdf");
return "convert/cbr-to-pdf";
}
@GetMapping("/pdf-to-cbr")
@Hidden
public String convertPdfToCbrForm(Model model) {
model.addAttribute("currentPage", "pdf-to-cbr");
return "convert/pdf-to-cbr";
}
@GetMapping("/html-to-pdf")
@Hidden
public String convertHTMLToPdfForm(Model model) {
@@ -0,0 +1,23 @@
package stirling.software.SPDF.model.api.converters;
import org.springframework.web.multipart.MultipartFile;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import lombok.EqualsAndHashCode;
@Data
@EqualsAndHashCode
public class ConvertCbrToPdfRequest {
@Schema(
description = "The input CBR file to be converted to a PDF file",
requiredMode = Schema.RequiredMode.REQUIRED)
private MultipartFile fileInput;
@Schema(
description = "Optimize the output PDF for ebook reading using Ghostscript",
defaultValue = "false")
private boolean optimizeForEbook;
}
@@ -15,4 +15,9 @@ public class ConvertCbzToPdfRequest {
description = "The input CBZ file to be converted to a PDF file",
requiredMode = Schema.RequiredMode.REQUIRED)
private MultipartFile fileInput;
@Schema(
description = "Optimize the output PDF for ebook reading using Ghostscript",
defaultValue = "false")
private boolean optimizeForEbook;
}
@@ -0,0 +1,24 @@
package stirling.software.SPDF.model.api.converters;
import org.springframework.web.multipart.MultipartFile;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import lombok.EqualsAndHashCode;
@Data
@EqualsAndHashCode
public class ConvertPdfToCbrRequest {
@Schema(
description = "The input PDF file to be converted to a CBR file",
requiredMode = Schema.RequiredMode.REQUIRED)
private MultipartFile fileInput;
@Schema(
description = "The DPI (Dots Per Inch) for rendering PDF pages as images",
example = "150",
requiredMode = Schema.RequiredMode.REQUIRED)
private int dpi = 150;
}
@@ -18,7 +18,7 @@ public class ConvertPdfToCbzRequest {
@Schema(
description = "The DPI (Dots Per Inch) for rendering PDF pages as images",
example = "300",
requiredMode = Schema.RequiredMode.NOT_REQUIRED)
private Integer dpi = 300;
example = "150",
requiredMode = Schema.RequiredMode.REQUIRED)
private int dpi = 150;
}