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
@@ -610,10 +610,18 @@ home.cbzToPdf.title=CBZ to PDF
home.cbzToPdf.desc=Convert CBZ comic book archives to PDF format.
cbzToPdf.tags=conversion,comic,book,archive,cbz,zip
home.cbrToPdf.title=CBR to PDF
home.cbrToPdf.desc=Convert CBR comic book archives to PDF format.
cbrToPdf.tags=conversion,comic,book,archive,cbr,rar
home.pdfToCbz.title=PDF to CBZ
home.pdfToCbz.desc=Convert PDF files to CBZ comic book archives.
pdfToCbz.tags=conversion,comic,book,archive,cbz,pdf
home.pdfToCbr.title=PDF to CBR
home.pdfToCbr.desc=Convert PDF files to CBR comic book archives.
pdfToCbr.tags=conversion,comic,book,archive,cbr,rar
home.pdfToImage.title=PDF to Image
home.pdfToImage.desc=Convert a PDF to a image. (PNG, JPEG, GIF, PSD)
pdfToImage.tags=conversion,img,jpg,picture,photo,psd,photoshop
@@ -1445,6 +1453,7 @@ cbzToPDF.title=CBZ to PDF
cbzToPDF.header=CBZ to PDF
cbzToPDF.submit=Convert to PDF
cbzToPDF.selectText=Select CBZ file
cbzToPDF.optimizeForEbook=Optimize PDF for ebook readers (uses Ghostscript)
#pdfToCBZ
pdfToCBZ.title=PDF to CBZ
@@ -1453,6 +1462,21 @@ pdfToCBZ.submit=Convert to CBZ
pdfToCBZ.selectText=Select PDF file
pdfToCBZ.dpi=DPI (Dots Per Inch)
#cbrToPDF
cbrToPDF.title=CBR to PDF
cbrToPDF.header=CBR to PDF
cbrToPDF.submit=Convert to PDF
cbrToPDF.selectText=Select CBR file
cbrToPDF.optimizeForEbook=Optimize PDF for ebook readers (uses Ghostscript)
#pdfToCBR
pdfToCBR.title=PDF to CBR
pdfToCBR.header=PDF to CBR
pdfToCBR.submit=Convert to CBR
pdfToCBR.selectText=Select PDF file
pdfToCBR.dpi=DPI (Dots Per Inch)
pdfToCBR.dpiHelp=Higher DPI results in better quality but larger file size.
#pdfToImage
pdfToImage.title=PDF to Image
pdfToImage.header=PDF to Image
@@ -0,0 +1,44 @@
<!DOCTYPE html>
<html th:data-language="${#locale.toString()}" th:dir="#{language.direction}" th:lang="${#locale.language}"
xmlns:th="https://www.thymeleaf.org">
<head>
<th:block th:insert="~{fragments/common :: head(title=#{cbrToPDF.title}, header=#{cbrToPDF.header})}"></th:block>
</head>
<body>
<th:block th:insert="~{fragments/common :: game}"></th:block>
<div id="page-container">
<div id="content-wrap">
<th:block th:insert="~{fragments/navbar.html :: navbar}"></th:block>
<br><br>
<div class="container">
<div class="row justify-content-center">
<div class="col-md-6 bg-card">
<div class="tool-header">
<span class="material-symbols-rounded tool-header-icon convertto">auto_stories</span>
<span class="tool-header-text" th:text="#{cbrToPDF.header}"></span>
</div>
<form enctype="multipart/form-data" id="cbrToPDFForm" method="post"
th:action="@{'/api/v1/convert/cbr/pdf'}">
<div
th:replace="~{fragments/common :: fileSelector(name='fileInput', multipleInputsForSingleRequest=false, accept='.cbr,.rar', inputText=#{cbrToPDF.selectText})}">
</div>
<div class="form-check mb-3" th:if="${@endpointConfiguration.isGroupEnabled('Ghostscript')}">
<input id="optimizeForEbook" name="optimizeForEbook" type="checkbox" value="true">
<label for="optimizeForEbook" th:text="#{cbrToPDF.optimizeForEbook}">Optimize PDF for ebook readers (uses Ghostscript)</label>
</div>
<br>
<button class="btn btn-primary" id="submitBtn" th:text="#{cbrToPDF.submit}" type="submit">Convert to PDF</button>
</form>
</div>
</div>
</div>
</div>
<th:block th:insert="~{fragments/footer.html :: footer}"></th:block>
</div>
</body>
</html>
@@ -25,6 +25,11 @@
th:replace="~{fragments/common :: fileSelector(name='fileInput', multipleInputsForSingleRequest=false, accept='.cbz,.zip', inputText=#{cbzToPDF.selectText})}">
</div>
<div class="form-check mb-3" th:if="${@endpointConfiguration.isGroupEnabled('Ghostscript')}">
<input id="optimizeForEbook" name="optimizeForEbook" type="checkbox" value="true">
<label for="optimizeForEbook" th:text="#{cbzToPDF.optimizeForEbook}">Optimize PDF for ebook readers (uses Ghostscript)</label>
</div>
<br>
<button class="btn btn-primary" id="submitBtn" th:text="#{cbzToPDF.submit}" type="submit">Convert to PDF</button>
</form>
@@ -0,0 +1,45 @@
<!DOCTYPE html>
<html th:data-language="${#locale.toString()}" th:dir="#{language.direction}" th:lang="${#locale.language}"
xmlns:th="https://www.thymeleaf.org">
<head>
<th:block th:insert="~{fragments/common :: head(title=#{pdfToCBR.title}, header=#{pdfToCBR.header})}"></th:block>
</head>
<body>
<th:block th:insert="~{fragments/common :: game}"></th:block>
<div id="page-container">
<div id="content-wrap">
<th:block th:insert="~{fragments/navbar.html :: navbar}"></th:block>
<br><br>
<div class="container">
<div class="row justify-content-center">
<div class="col-md-6 bg-card">
<div class="tool-header">
<span class="material-symbols-rounded tool-header-icon convertto">auto_stories</span>
<span class="tool-header-text" th:text="#{pdfToCBR.header}"></span>
</div>
<form enctype="multipart/form-data" id="pdfToCBRForm" method="post"
th:action="@{'/api/v1/convert/pdf/cbr'}">
<div
th:replace="~{fragments/common :: fileSelector(name='fileInput', multipleInputsForSingleRequest=false, accept='.pdf', inputText=#{pdfToCBR.selectText})}">
</div>
<div class="mb-3">
<label class="form-label" for="dpi" th:text="#{pdfToCBR.dpi}">DPI:</label>
<input class="form-control" id="dpi" max="600" min="50" name="dpi" type="number" value="300">
<div class="form-text" th:text="#{pdfToCBR.dpiHelp}">Higher DPI results in better quality but larger file size.</div>
</div>
<br>
<button class="btn btn-primary" id="submitBtn" th:text="#{pdfToCBR.submit}" type="submit">Convert to CBR</button>
</form>
</div>
</div>
</div>
</div>
<th:block th:insert="~{fragments/footer.html :: footer}"></th:block>
</div>
</body>
</html>
@@ -50,6 +50,9 @@
<div
th:replace="~{fragments/navbarEntry :: navbarEntry('cbz-to-pdf', 'auto_stories', 'home.cbzToPdf.title', 'home.cbzToPdf.desc', 'cbzToPdf.tags', 'convertto')}">
</div>
<div
th:replace="~{fragments/navbarEntry :: navbarEntry('cbr-to-pdf', 'auto_stories', 'home.cbrToPdf.title', 'home.cbrToPdf.desc', 'cbrToPdf.tags', 'convertto')}">
</div>
<div
th:replace="~{fragments/navbarEntry :: navbarEntry('file-to-pdf', 'draft', 'home.fileToPDF.title', 'home.fileToPDF.desc', 'fileToPDF.tags', 'convertto')}">
</div>
@@ -77,6 +80,9 @@
<div
th:replace="~{fragments/navbarEntry :: navbarEntry('pdf-to-cbz', 'auto_stories', 'home.pdfToCbz.title', 'home.pdfToCbz.desc', 'pdfToCbz.tags', 'convert')}">
</div>
<div
th:replace="~{fragments/navbarEntry :: navbarEntry('pdf-to-cbr', 'auto_stories', 'home.pdfToCbr.title', 'home.pdfToCbr.desc', 'pdfToCbr.tags', 'convert')}">
</div>
<div
th:replace="~{fragments/navbarEntry :: navbarEntry('pdf-to-pdfa', 'picture_as_pdf', 'home.pdfToPDFA.title', 'home.pdfToPDFA.desc', 'pdfToPDFA.tags', 'convert')}">
</div>
@@ -114,6 +120,9 @@
<div
th:replace="~{fragments/navbarEntry :: navbarEntry('cbz-to-pdf', 'auto_stories', 'home.cbzToPdf.title', 'home.cbzToPdf.desc', 'cbzToPdf.tags', 'convertto')}">
</div>
<div
th:replace="~{fragments/navbarEntry :: navbarEntry('cbr-to-pdf', 'auto_stories', 'home.cbrToPdf.title', 'home.cbrToPdf.desc', 'cbrToPdf.tags', 'convertto')}">
</div>
<div
th:replace="~{fragments/navbarEntry :: navbarEntry('file-to-pdf', 'draft', 'home.fileToPDF.title', 'home.fileToPDF.desc', 'fileToPDF.tags', 'convertto')}">
</div>