From ec1ac4cb2db75976aa3398c4542877c61f1a3971 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: Sat, 4 Oct 2025 12:15:23 +0200 Subject: [PATCH] feat(cbr-to-pdf,pdf-to-cbr): add PDF to/from CBR conversion with ebook optimization option (#4581) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # 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 image image ### UI image ### 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 --- ## 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 --- app/allowed-licenses.json | 4 + app/common/build.gradle | 1 + .../software/common/util/CbrUtils.java | 258 ++++++++++++++++++ .../software/common/util/CbzUtils.java | 42 ++- .../software/common/util/GeneralUtils.java | 63 +++++ .../software/common/util/PdfToCbrUtils.java | 99 +++++++ .../common/util/RegexPatternUtils.java | 5 + .../converters/ConvertImgPDFController.java | 112 +++++++- .../web/ConverterWebController.java | 14 + .../converters/ConvertCbrToPdfRequest.java | 23 ++ .../converters/ConvertCbzToPdfRequest.java | 5 + .../converters/ConvertPdfToCbrRequest.java | 24 ++ .../converters/ConvertPdfToCbzRequest.java | 6 +- .../main/resources/messages_en_GB.properties | 24 ++ .../templates/convert/cbr-to-pdf.html | 44 +++ .../templates/convert/cbz-to-pdf.html | 5 + .../templates/convert/pdf-to-cbr.html | 45 +++ .../templates/fragments/navElements.html | 9 + .../api/converters/CbrUtilsTest.java | 91 ++++++ 19 files changed, 856 insertions(+), 18 deletions(-) create mode 100644 app/common/src/main/java/stirling/software/common/util/CbrUtils.java create mode 100644 app/common/src/main/java/stirling/software/common/util/PdfToCbrUtils.java create mode 100644 app/core/src/main/java/stirling/software/SPDF/model/api/converters/ConvertCbrToPdfRequest.java create mode 100644 app/core/src/main/java/stirling/software/SPDF/model/api/converters/ConvertPdfToCbrRequest.java create mode 100644 app/core/src/main/resources/templates/convert/cbr-to-pdf.html create mode 100644 app/core/src/main/resources/templates/convert/pdf-to-cbr.html create mode 100644 app/core/src/test/java/stirling/software/SPDF/controller/api/converters/CbrUtilsTest.java diff --git a/app/allowed-licenses.json b/app/allowed-licenses.json index 80e919439..830ae037a 100644 --- a/app/allowed-licenses.json +++ b/app/allowed-licenses.json @@ -167,6 +167,10 @@ { "moduleName": ".*", "moduleLicense": "The W3C License" + }, + { + "moduleName": ".*", + "moduleLicense": "UnRar License" } ] } diff --git a/app/common/build.gradle b/app/common/build.gradle index 4078b38dc..33a710e96 100644 --- a/app/common/build.gradle +++ b/app/common/build.gradle @@ -37,6 +37,7 @@ dependencies { api 'com.drewnoakes:metadata-extractor:2.19.0' // Image metadata extractor api 'com.vladsch.flexmark:flexmark-html2md-converter:0.64.8' api "org.apache.pdfbox:pdfbox:$pdfboxVersion" + api 'com.github.junrar:junrar:7.5.5' // RAR archive support for CBR files api 'jakarta.servlet:jakarta.servlet-api:6.1.0' api 'org.snakeyaml:snakeyaml-engine:2.10' api "org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.13" 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 new file mode 100644 index 000000000..429d22407 --- /dev/null +++ b/app/common/src/main/java/stirling/software/common/util/CbrUtils.java @@ -0,0 +1,258 @@ +package stirling.software.common.util; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; + +import org.apache.commons.io.FilenameUtils; +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.pdmodel.PDPage; +import org.apache.pdfbox.pdmodel.PDPageContentStream; +import org.apache.pdfbox.pdmodel.common.PDRectangle; +import org.apache.pdfbox.pdmodel.graphics.image.PDImageXObject; +import org.springframework.web.multipart.MultipartFile; + +import com.github.junrar.Archive; +import com.github.junrar.exception.CorruptHeaderException; +import com.github.junrar.exception.RarException; +import com.github.junrar.rarfile.FileHeader; + +import lombok.experimental.UtilityClass; +import lombok.extern.slf4j.Slf4j; + +import stirling.software.common.service.CustomPDFDocumentFactory; + +@Slf4j +@UtilityClass +public class CbrUtils { + + public byte[] convertCbrToPdf( + MultipartFile cbrFile, + CustomPDFDocumentFactory pdfDocumentFactory, + TempFileManager tempFileManager) + throws IOException { + return convertCbrToPdf(cbrFile, pdfDocumentFactory, tempFileManager, false); + } + + public byte[] convertCbrToPdf( + MultipartFile cbrFile, + CustomPDFDocumentFactory pdfDocumentFactory, + TempFileManager tempFileManager, + boolean optimizeForEbook) + throws IOException { + + validateCbrFile(cbrFile); + + try (TempFile tempFile = new TempFile(tempFileManager, ".cbr")) { + cbrFile.transferTo(tempFile.getFile()); + + try (PDDocument document = pdfDocumentFactory.createNewDocument()) { + + Archive archive; + try { + archive = new Archive(tempFile.getFile()); + } catch (CorruptHeaderException e) { + 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."); + } 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."; + } else if (exMessage.isEmpty()) { + errorMessage = + "Invalid CBR/RAR archive. " + + "The file may be encrypted, corrupted, or use an unsupported format."; + } else { + errorMessage = + "Invalid CBR/RAR archive: " + + exMessage + + ". The file may be encrypted, corrupted, or use an 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); + } + + List imageEntries = new ArrayList<>(); + + try { + for (FileHeader fileHeader : archive) { + if (!fileHeader.isDirectory() && isImageFile(fileHeader.getFileName())) { + try (InputStream is = archive.getInputStream(fileHeader)) { + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + is.transferTo(baos); + imageEntries.add( + new ImageEntryData( + fileHeader.getFileName(), baos.toByteArray())); + } catch (Exception e) { + log.warn( + "Error reading image {}: {}", + fileHeader.getFileName(), + e.getMessage()); + } + } + } + } finally { + try { + archive.close(); + } catch (IOException e) { + log.warn("Error closing CBR/RAR archive: {}", e.getMessage()); + } + } + + imageEntries.sort( + Comparator.comparing(ImageEntryData::name, new NaturalOrderComparator())); + + if (imageEntries.isEmpty()) { + throw ExceptionUtils.createIllegalArgumentException( + "error.fileProcessing", + "No valid images found in the CBR file. The archive may be empty or contain no supported image formats."); + } + + for (ImageEntryData imageEntry : imageEntries) { + try { + PDImageXObject pdImage = + PDImageXObject.createFromByteArray( + document, imageEntry.data(), imageEntry.name()); + PDPage page = + new PDPage( + new PDRectangle(pdImage.getWidth(), pdImage.getHeight())); + document.addPage(page); + try (PDPageContentStream contentStream = + new PDPageContentStream(document, page)) { + contentStream.drawImage(pdImage, 0, 0); + } + } catch (IOException e) { + log.warn( + "Error processing image {}: {}", imageEntry.name(), e.getMessage()); + } + } + + if (document.getNumberOfPages() == 0) { + throw ExceptionUtils.createIllegalArgumentException( + "error.fileProcessing", + "No images could be processed from the CBR file. All images may be corrupted or in unsupported formats."); + } + + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + document.save(baos); + byte[] pdfBytes = baos.toByteArray(); + + // Apply Ghostscript optimization if requested + if (optimizeForEbook) { + try { + return GeneralUtils.optimizePdfWithGhostscript(pdfBytes); + } catch (IOException e) { + log.warn("Ghostscript optimization failed, returning unoptimized PDF", e); + return pdfBytes; + } + } + + return pdfBytes; + } + } + } + + private void validateCbrFile(MultipartFile file) { + if (file == null || file.isEmpty()) { + throw new IllegalArgumentException("File cannot be null or empty"); + } + + String filename = file.getOriginalFilename(); + if (filename == null) { + throw new IllegalArgumentException("File must have a name"); + } + + String extension = FilenameUtils.getExtension(filename).toLowerCase(); + if (!"cbr".equals(extension) && !"rar".equals(extension)) { + throw new IllegalArgumentException("File must be a CBR or RAR archive"); + } + } + + public boolean isCbrFile(MultipartFile file) { + String filename = file.getOriginalFilename(); + if (filename == null) { + return false; + } + + String extension = FilenameUtils.getExtension(filename).toLowerCase(); + return "cbr".equals(extension) || "rar".equals(extension); + } + + private boolean isImageFile(String filename) { + return RegexPatternUtils.getInstance().getImageFilePattern().matcher(filename).matches(); + } + + private record ImageEntryData(String name, byte[] data) {} + + private class NaturalOrderComparator implements Comparator { + private static String getChunk(String s, int length, int marker) { + StringBuilder chunk = new StringBuilder(); + char c = s.charAt(marker); + chunk.append(c); + marker++; + + if (isDigit(c)) { + while (marker < length && isDigit(s.charAt(marker))) { + chunk.append(s.charAt(marker)); + marker++; + } + } else { + while (marker < length && !isDigit(s.charAt(marker))) { + chunk.append(s.charAt(marker)); + marker++; + } + } + return chunk.toString(); + } + + private static boolean isDigit(char ch) { + return ch >= '0' && ch <= '9'; + } + + @Override + public int compare(String s1, String s2) { + int len1 = s1.length(); + int len2 = s2.length(); + int marker1 = 0, marker2 = 0; + + while (marker1 < len1 && marker2 < len2) { + String chunk1 = getChunk(s1, len1, marker1); + marker1 += chunk1.length(); + + String chunk2 = getChunk(s2, len2, marker2); + marker2 += chunk2.length(); + + int result; + if (isDigit(chunk1.charAt(0)) && isDigit(chunk2.charAt(0))) { + int thisNumericValue = Integer.parseInt(chunk1); + int thatNumericValue = Integer.parseInt(chunk2); + result = Integer.compare(thisNumericValue, thatNumericValue); + } else { + result = chunk1.compareTo(chunk2); + } + + if (result != 0) { + return result; + } + } + + return Integer.compare(len1, len2); + } + } +} 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 cfbbd8250..5eb620e8e 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 @@ -8,7 +8,6 @@ import java.util.ArrayList; import java.util.Comparator; import java.util.Enumeration; import java.util.List; -import java.util.regex.Pattern; import java.util.zip.ZipEntry; import java.util.zip.ZipFile; import java.util.zip.ZipInputStream; @@ -30,14 +29,20 @@ import stirling.software.common.service.CustomPDFDocumentFactory; @UtilityClass public class CbzUtils { - private final Pattern IMAGE_PATTERN = - Pattern.compile(".*\\.(jpg|jpeg|png|gif|bmp|webp)$", Pattern.CASE_INSENSITIVE); - public byte[] convertCbzToPdf( MultipartFile cbzFile, CustomPDFDocumentFactory pdfDocumentFactory, TempFileManager tempFileManager) throws IOException { + return convertCbzToPdf(cbzFile, pdfDocumentFactory, tempFileManager, false); + } + + public byte[] convertCbzToPdf( + MultipartFile cbzFile, + CustomPDFDocumentFactory pdfDocumentFactory, + TempFileManager tempFileManager, + boolean optimizeForEbook) + throws IOException { validateCbzFile(cbzFile); @@ -106,7 +111,19 @@ public class CbzUtils { } ByteArrayOutputStream baos = new ByteArrayOutputStream(); document.save(baos); - return baos.toByteArray(); + byte[] pdfBytes = baos.toByteArray(); + + // Apply Ghostscript optimization if requested + if (optimizeForEbook) { + try { + return GeneralUtils.optimizePdfWithGhostscript(pdfBytes); + } catch (IOException e) { + log.warn("Ghostscript optimization failed, returning unoptimized PDF", e); + return pdfBytes; + } + } + + return pdfBytes; } } } @@ -137,8 +154,21 @@ public class CbzUtils { return "cbz".equals(extension) || "zip".equals(extension); } + public static boolean isComicBookFile(MultipartFile file) { + String filename = file.getOriginalFilename(); + if (filename == null) { + return false; + } + + String extension = FilenameUtils.getExtension(filename).toLowerCase(); + return "cbz".equals(extension) + || "zip".equals(extension) + || "cbr".equals(extension) + || "rar".equals(extension); + } + private boolean isImageFile(String filename) { - return IMAGE_PATTERN.matcher(filename).matches(); + return RegexPatternUtils.getInstance().getImageFilePattern().matcher(filename).matches(); } private record ImageEntryData(String name, byte[] data) {} 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 ed73d7f88..10ac8b595 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 @@ -905,4 +905,67 @@ public class GeneralUtils { // If all components so far are equal, the longer version is considered higher return current.length > compare.length; } + + /** + * Optimizes a PDF using Ghostscript with ebook settings for better e-reader compatibility. Uses + * -dPDFSETTINGS=/ebook -dFastWebView=true settings to create an optimized PDF. + * + * @param inputPdfBytes Original PDF as byte array + * @return Optimized PDF as byte array + * @throws IOException if Ghostscript optimization fails + */ + public byte[] optimizePdfWithGhostscript(byte[] inputPdfBytes) throws IOException { + Path tempInput = null; + Path tempOutput = null; + + try { + tempInput = Files.createTempFile("gs_input_", ".pdf"); + tempOutput = Files.createTempFile("gs_output_", ".pdf"); + + Files.write(tempInput, inputPdfBytes); + + List command = new ArrayList<>(); + command.add("gs"); + command.add("-sDEVICE=pdfwrite"); + command.add("-dPDFSETTINGS=/ebook"); + command.add("-dFastWebView=true"); + command.add("-dNOPAUSE"); + command.add("-dQUIET"); + command.add("-dBATCH"); + command.add("-sOutputFile=" + tempOutput.toString()); + command.add(tempInput.toString()); + + ProcessExecutor.ProcessExecutorResult result = + ProcessExecutor.getInstance(ProcessExecutor.Processes.GHOSTSCRIPT) + .runCommandWithOutputHandling(command); + + if (result.getRc() != 0) { + log.warn( + "Ghostscript ebook optimization failed with return code: {}", + result.getRc()); + throw ExceptionUtils.createGhostscriptCompressionException(); + } + + return Files.readAllBytes(tempOutput); + + } catch (Exception e) { + log.warn("Ghostscript ebook optimization failed", e); + throw ExceptionUtils.createGhostscriptCompressionException(e); + } finally { + if (tempInput != null) { + try { + Files.deleteIfExists(tempInput); + } catch (IOException e) { + log.warn("Failed to delete temp input file: {}", tempInput, e); + } + } + if (tempOutput != null) { + try { + Files.deleteIfExists(tempOutput); + } catch (IOException e) { + log.warn("Failed to delete temp output file: {}", tempOutput, e); + } + } + } + } } 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 new file mode 100644 index 000000000..a5bf9a888 --- /dev/null +++ b/app/common/src/main/java/stirling/software/common/util/PdfToCbrUtils.java @@ -0,0 +1,99 @@ +package stirling.software.common.util; + +import java.awt.image.BufferedImage; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.util.zip.ZipEntry; +import java.util.zip.ZipOutputStream; + +import javax.imageio.ImageIO; + +import org.apache.commons.io.FilenameUtils; +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.rendering.ImageType; +import org.apache.pdfbox.rendering.PDFRenderer; +import org.springframework.web.multipart.MultipartFile; + +import lombok.extern.slf4j.Slf4j; + +import stirling.software.common.service.CustomPDFDocumentFactory; + +@Slf4j +public class PdfToCbrUtils { + + public static byte[] convertPdfToCbr( + MultipartFile pdfFile, int dpi, CustomPDFDocumentFactory pdfDocumentFactory) + throws IOException { + + validatePdfFile(pdfFile); + + try (PDDocument document = pdfDocumentFactory.load(pdfFile)) { + if (document.getNumberOfPages() == 0) { + throw new IllegalArgumentException("PDF file contains no pages"); + } + + return createCbrFromPdf(document, dpi); + } + } + + private static void validatePdfFile(MultipartFile file) { + if (file == null || file.isEmpty()) { + throw new IllegalArgumentException("File cannot be null or empty"); + } + + String filename = file.getOriginalFilename(); + if (filename == null) { + throw new IllegalArgumentException("File must have a name"); + } + + String extension = FilenameUtils.getExtension(filename).toLowerCase(); + if (!"pdf".equals(extension)) { + throw new IllegalArgumentException("File must be a PDF"); + } + } + + 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)) { + + int totalPages = document.getNumberOfPages(); + + for (int pageIndex = 0; pageIndex < totalPages; pageIndex++) { + try { + BufferedImage image = + pdfRenderer.renderImageWithDPI(pageIndex, dpi, ImageType.RGB); + + String imageFilename = String.format("page_%03d.png", pageIndex + 1); + + ZipEntry zipEntry = new ZipEntry(imageFilename); + zipOut.putNextEntry(zipEntry); + + ImageIO.write(image, "PNG", zipOut); + zipOut.closeEntry(); + + } 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); + } + } + + zipOut.finish(); + return cbrOutputStream.toByteArray(); + } + } + + public static boolean isPdfFile(MultipartFile file) { + String filename = file.getOriginalFilename(); + if (filename == null) { + return false; + } + + String extension = FilenameUtils.getExtension(filename).toLowerCase(); + return "pdf".equals(extension); + } +} 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 858ad0605..8858c99bf 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 @@ -437,6 +437,11 @@ public final class RegexPatternUtils { Pattern.CASE_INSENSITIVE); } + /** Pattern for matching image file extensions (case-insensitive) */ + public Pattern getImageFilePattern() { + return getPattern(".*\\.(jpg|jpeg|png|gif|bmp|webp)$", Pattern.CASE_INSENSITIVE); + } + /** Pattern for matching attachment section headers (case-insensitive) */ public Pattern getAttachmentSectionPattern() { return getPattern("attachments\\s*\\(\\d+\\)", Pattern.CASE_INSENSITIVE); 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 6255d3d16..2ff3b5196 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 @@ -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 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 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 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 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 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 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); 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 047e06482..befde0bbd 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 @@ -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) { diff --git a/app/core/src/main/java/stirling/software/SPDF/model/api/converters/ConvertCbrToPdfRequest.java b/app/core/src/main/java/stirling/software/SPDF/model/api/converters/ConvertCbrToPdfRequest.java new file mode 100644 index 000000000..4e4b64ef4 --- /dev/null +++ b/app/core/src/main/java/stirling/software/SPDF/model/api/converters/ConvertCbrToPdfRequest.java @@ -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; +} diff --git a/app/core/src/main/java/stirling/software/SPDF/model/api/converters/ConvertCbzToPdfRequest.java b/app/core/src/main/java/stirling/software/SPDF/model/api/converters/ConvertCbzToPdfRequest.java index d10130a43..08123c1e4 100644 --- a/app/core/src/main/java/stirling/software/SPDF/model/api/converters/ConvertCbzToPdfRequest.java +++ b/app/core/src/main/java/stirling/software/SPDF/model/api/converters/ConvertCbzToPdfRequest.java @@ -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; } diff --git a/app/core/src/main/java/stirling/software/SPDF/model/api/converters/ConvertPdfToCbrRequest.java b/app/core/src/main/java/stirling/software/SPDF/model/api/converters/ConvertPdfToCbrRequest.java new file mode 100644 index 000000000..9f79472dc --- /dev/null +++ b/app/core/src/main/java/stirling/software/SPDF/model/api/converters/ConvertPdfToCbrRequest.java @@ -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; +} diff --git a/app/core/src/main/java/stirling/software/SPDF/model/api/converters/ConvertPdfToCbzRequest.java b/app/core/src/main/java/stirling/software/SPDF/model/api/converters/ConvertPdfToCbzRequest.java index 34058c9ee..2cc216d4c 100644 --- a/app/core/src/main/java/stirling/software/SPDF/model/api/converters/ConvertPdfToCbzRequest.java +++ b/app/core/src/main/java/stirling/software/SPDF/model/api/converters/ConvertPdfToCbzRequest.java @@ -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; } diff --git a/app/core/src/main/resources/messages_en_GB.properties b/app/core/src/main/resources/messages_en_GB.properties index 1859c4868..69a5833d5 100644 --- a/app/core/src/main/resources/messages_en_GB.properties +++ b/app/core/src/main/resources/messages_en_GB.properties @@ -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 diff --git a/app/core/src/main/resources/templates/convert/cbr-to-pdf.html b/app/core/src/main/resources/templates/convert/cbr-to-pdf.html new file mode 100644 index 000000000..b49c06f81 --- /dev/null +++ b/app/core/src/main/resources/templates/convert/cbr-to-pdf.html @@ -0,0 +1,44 @@ + + + + + + + + + +
+
+ +

+
+
+
+
+ auto_stories + +
+
+
+
+ +
+ + +
+ +
+ +
+
+
+
+
+ +
+ + + \ No newline at end of file diff --git a/app/core/src/main/resources/templates/convert/cbz-to-pdf.html b/app/core/src/main/resources/templates/convert/cbz-to-pdf.html index e4f3584ba..c7e547cdb 100644 --- a/app/core/src/main/resources/templates/convert/cbz-to-pdf.html +++ b/app/core/src/main/resources/templates/convert/cbz-to-pdf.html @@ -25,6 +25,11 @@ th:replace="~{fragments/common :: fileSelector(name='fileInput', multipleInputsForSingleRequest=false, accept='.cbz,.zip', inputText=#{cbzToPDF.selectText})}"> +
+ + +
+
diff --git a/app/core/src/main/resources/templates/convert/pdf-to-cbr.html b/app/core/src/main/resources/templates/convert/pdf-to-cbr.html new file mode 100644 index 000000000..3594bd301 --- /dev/null +++ b/app/core/src/main/resources/templates/convert/pdf-to-cbr.html @@ -0,0 +1,45 @@ + + + + + + + + + +
+
+ +

+
+
+
+
+ auto_stories + +
+
+
+
+ +
+ + +
Higher DPI results in better quality but larger file size.
+
+ +
+ +
+
+
+
+
+ +
+ + + \ No newline at end of file diff --git a/app/core/src/main/resources/templates/fragments/navElements.html b/app/core/src/main/resources/templates/fragments/navElements.html index b5ae817f4..1441a1969 100644 --- a/app/core/src/main/resources/templates/fragments/navElements.html +++ b/app/core/src/main/resources/templates/fragments/navElements.html @@ -50,6 +50,9 @@
+
+
@@ -77,6 +80,9 @@
+
+
@@ -114,6 +120,9 @@
+
+
diff --git a/app/core/src/test/java/stirling/software/SPDF/controller/api/converters/CbrUtilsTest.java b/app/core/src/test/java/stirling/software/SPDF/controller/api/converters/CbrUtilsTest.java new file mode 100644 index 000000000..20a7ee39c --- /dev/null +++ b/app/core/src/test/java/stirling/software/SPDF/controller/api/converters/CbrUtilsTest.java @@ -0,0 +1,91 @@ +package stirling.software.SPDF.controller.api.converters; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.Test; +import org.springframework.mock.web.MockMultipartFile; + +import stirling.software.common.util.CbrUtils; + +class CbrUtilsTest { + + @Test + void testIsCbrFile_ValidCbrFile() { + MockMultipartFile cbrFile = + new MockMultipartFile( + "file", + "test.cbr", + "application/x-rar-compressed", + "test content".getBytes()); + + assertTrue(CbrUtils.isCbrFile(cbrFile)); + } + + @Test + void testIsCbrFile_ValidRarFile() { + MockMultipartFile rarFile = + new MockMultipartFile( + "file", + "test.rar", + "application/x-rar-compressed", + "test content".getBytes()); + + assertTrue(CbrUtils.isCbrFile(rarFile)); + } + + @Test + void testIsCbrFile_InvalidFile() { + MockMultipartFile textFile = + new MockMultipartFile("file", "test.txt", "text/plain", "test content".getBytes()); + + assertFalse(CbrUtils.isCbrFile(textFile)); + } + + @Test + void testIsCbrFile_NoFilename() { + MockMultipartFile noNameFile = + new MockMultipartFile( + "file", null, "application/x-rar-compressed", "test content".getBytes()); + + assertFalse(CbrUtils.isCbrFile(noNameFile)); + } + + @Test + void testIsCbrFile_PdfFile() { + MockMultipartFile pdfFile = + new MockMultipartFile( + "file", "document.pdf", "application/pdf", "pdf content".getBytes()); + + assertFalse(CbrUtils.isCbrFile(pdfFile)); + } + + @Test + void testIsCbrFile_JpegFile() { + MockMultipartFile jpegFile = + new MockMultipartFile("file", "image.jpg", "image/jpeg", "jpeg content".getBytes()); + + assertFalse(CbrUtils.isCbrFile(jpegFile)); + } + + @Test + void testIsCbrFile_ZipFile() { + MockMultipartFile zipFile = + new MockMultipartFile( + "file", "archive.zip", "application/zip", "zip content".getBytes()); + + assertFalse(CbrUtils.isCbrFile(zipFile)); + } + + @Test + void testIsCbrFile_MixedCaseExtension() { + MockMultipartFile cbrFile = + new MockMultipartFile( + "file", + "test.CBR", + "application/x-rar-compressed", + "test content".getBytes()); + + assertTrue(CbrUtils.isCbrFile(cbrFile)); + } +}