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 new file mode 100644 index 000000000..cfbbd8250 --- /dev/null +++ b/app/common/src/main/java/stirling/software/common/util/CbzUtils.java @@ -0,0 +1,201 @@ +package stirling.software.common.util; + +import java.io.BufferedInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +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; + +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 lombok.experimental.UtilityClass; +import lombok.extern.slf4j.Slf4j; + +import stirling.software.common.service.CustomPDFDocumentFactory; + +@Slf4j +@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 { + + validateCbzFile(cbzFile); + + try (TempFile tempFile = new TempFile(tempFileManager, ".cbz")) { + cbzFile.transferTo(tempFile.getFile()); + + // Early ZIP validity check using ZipInputStream (fail fast on non-zip content) + try (BufferedInputStream bis = + new BufferedInputStream( + new java.io.FileInputStream(tempFile.getFile())); + ZipInputStream zis = new ZipInputStream(bis)) { + if (zis.getNextEntry() == null) { + throw new IllegalArgumentException("Archive is empty or invalid ZIP"); + } + } catch (IOException e) { + throw new IllegalArgumentException("Invalid CBZ/ZIP archive", e); + } + + try (PDDocument document = pdfDocumentFactory.createNewDocument(); + ZipFile zipFile = new ZipFile(tempFile.getFile())) { + Enumeration entries = zipFile.entries(); + List imageEntries = new ArrayList<>(); + while (entries.hasMoreElements()) { + ZipEntry entry = entries.nextElement(); + if (!entry.isDirectory() && isImageFile(entry.getName())) { + try (InputStream is = zipFile.getInputStream(entry)) { + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + is.transferTo(baos); + imageEntries.add( + new ImageEntryData(entry.getName(), baos.toByteArray())); + } catch (IOException e) { + log.warn("Error reading image {}: {}", entry.getName(), e.getMessage()); + } + } + } + + imageEntries.sort( + Comparator.comparing(ImageEntryData::name, new NaturalOrderComparator())); + + if (imageEntries.isEmpty()) { + throw new IllegalArgumentException("No valid images found in the CBZ file"); + } + + 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 new IllegalArgumentException( + "No images could be processed from the CBZ file"); + } + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + document.save(baos); + return baos.toByteArray(); + } + } + } + + private void validateCbzFile(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 (!"cbz".equals(extension) && !"zip".equals(extension)) { + throw new IllegalArgumentException("File must be a CBZ or ZIP archive"); + } + } + + public boolean isCbzFile(MultipartFile file) { + String filename = file.getOriginalFilename(); + if (filename == null) { + return false; + } + + String extension = FilenameUtils.getExtension(filename).toLowerCase(); + return "cbz".equals(extension) || "zip".equals(extension); + } + + private boolean isImageFile(String filename) { + return IMAGE_PATTERN.matcher(filename).matches(); + } + + private record ImageEntryData(String name, byte[] data) {} + + private class NaturalOrderComparator implements Comparator { + @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); + } + + 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'; + } + } +} diff --git a/app/common/src/main/java/stirling/software/common/util/PdfToCbzUtils.java b/app/common/src/main/java/stirling/software/common/util/PdfToCbzUtils.java new file mode 100644 index 000000000..b1b7b5b8c --- /dev/null +++ b/app/common/src/main/java/stirling/software/common/util/PdfToCbzUtils.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 PdfToCbzUtils { + + public static byte[] convertPdfToCbz( + 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 createCbzFromPdf(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[] createCbzFromPdf(PDDocument document, int dpi) throws IOException { + PDFRenderer pdfRenderer = new PDFRenderer(document); + + try (ByteArrayOutputStream cbzOutputStream = new ByteArrayOutputStream(); + ZipOutputStream zipOut = new ZipOutputStream(cbzOutputStream)) { + + 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 cbzOutputStream.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/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 d65726a64..6255d3d16 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.regex.Pattern; import java.util.stream.Stream; import java.util.zip.ZipEntry; import java.util.zip.ZipOutputStream; @@ -16,6 +17,7 @@ import org.apache.commons.io.FileUtils; import org.apache.pdfbox.pdmodel.PDDocument; import org.apache.pdfbox.pdmodel.PDPage; import org.apache.pdfbox.rendering.ImageType; +import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.ModelAttribute; @@ -30,11 +32,22 @@ import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import stirling.software.SPDF.model.api.converters.ConvertCbzToPdfRequest; +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.*; +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.PdfToCbzUtils; +import stirling.software.common.util.PdfUtils; +import stirling.software.common.util.ProcessExecutor; import stirling.software.common.util.ProcessExecutor.ProcessExecutorResult; +import stirling.software.common.util.RegexPatternUtils; +import stirling.software.common.util.TempFileManager; +import stirling.software.common.util.WebResponseUtils; @RestController @RequestMapping("/api/v1/convert") @@ -44,6 +57,10 @@ import stirling.software.common.util.ProcessExecutor.ProcessExecutorResult; public class ConvertImgPDFController { private final CustomPDFDocumentFactory pdfDocumentFactory; + private final TempFileManager tempFileManager; + private static final Pattern EXTENSION_PATTERN = + RegexPatternUtils.getInstance().getPattern(RegexPatternUtils.getExtensionRegex()); + private static final String DEFAULT_COMIC_NAME = "comic"; @PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/pdf/img") @Operation( @@ -234,6 +251,74 @@ public class ConvertImgPDFController { GeneralUtils.generateFilename(file[0].getOriginalFilename(), "_converted.pdf")); } + @PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/cbz/pdf") + @Operation( + summary = "Convert CBZ comic book archive to PDF", + 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) + throws IOException { + MultipartFile file = request.getFileInput(); + byte[] pdfBytes; + try { + pdfBytes = CbzUtils.convertCbzToPdf(file, pdfDocumentFactory, tempFileManager); + } catch (IllegalArgumentException ex) { + String message = ex.getMessage() == null ? "Invalid CBZ file" : ex.getMessage(); + return ResponseEntity.status(HttpStatus.BAD_REQUEST) + .contentType(MediaType.TEXT_PLAIN) + .body(message.getBytes(java.nio.charset.StandardCharsets.UTF_8)); + } + + String filename = createConvertedFilename(file.getOriginalFilename(), "_converted.pdf"); + + return WebResponseUtils.bytesToWebResponse(pdfBytes, filename); + } + + @PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/pdf/cbz") + @Operation( + summary = "Convert PDF to CBZ comic book archive", + 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) + throws IOException { + MultipartFile file = request.getFileInput(); + Integer dpi = request.getDpi(); + + if (dpi == null || dpi <= 0) { + dpi = 300; + } + + byte[] cbzBytes; + try { + cbzBytes = PdfToCbzUtils.convertPdfToCbz(file, dpi, pdfDocumentFactory); + } catch (IllegalArgumentException ex) { + String message = ex.getMessage() == null ? "Invalid PDF file" : ex.getMessage(); + return ResponseEntity.status(HttpStatus.BAD_REQUEST) + .contentType(MediaType.TEXT_PLAIN) + .body(message.getBytes(java.nio.charset.StandardCharsets.UTF_8)); + } + + String filename = createConvertedFilename(file.getOriginalFilename(), "_converted.cbz"); + + return WebResponseUtils.bytesToWebResponse( + cbzBytes, filename, MediaType.APPLICATION_OCTET_STREAM); + } + + private String createConvertedFilename(String originalFilename, String suffix) { + if (originalFilename == null) { + return GeneralUtils.generateFilename(DEFAULT_COMIC_NAME, suffix); + } + + String baseName = EXTENSION_PATTERN.matcher(originalFilename).replaceFirst(""); + if (baseName.isBlank()) { + baseName = DEFAULT_COMIC_NAME; + } + + return GeneralUtils.generateFilename(baseName, suffix); + } + private String getMediaType(String imageFormat) { String mimeType = URLConnection.guessContentTypeFromName("." + imageFormat); return "null".equals(mimeType) ? MediaType.APPLICATION_OCTET_STREAM_VALUE : mimeType; 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 1c05aaabd..047e06482 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 @@ -23,6 +23,20 @@ public class ConverterWebController { return "convert/img-to-pdf"; } + @GetMapping("/cbz-to-pdf") + @Hidden + public String convertCbzToPdfForm(Model model) { + model.addAttribute("currentPage", "cbz-to-pdf"); + return "convert/cbz-to-pdf"; + } + + @GetMapping("/pdf-to-cbz") + @Hidden + public String convertPdfToCbzForm(Model model) { + model.addAttribute("currentPage", "pdf-to-cbz"); + return "convert/pdf-to-cbz"; + } + @GetMapping("/html-to-pdf") @Hidden public String convertHTMLToPdfForm(Model model) { 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 new file mode 100644 index 000000000..d10130a43 --- /dev/null +++ b/app/core/src/main/java/stirling/software/SPDF/model/api/converters/ConvertCbzToPdfRequest.java @@ -0,0 +1,18 @@ +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 ConvertCbzToPdfRequest { + + @Schema( + description = "The input CBZ file to be converted to a PDF file", + requiredMode = Schema.RequiredMode.REQUIRED) + private MultipartFile fileInput; +} 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 new file mode 100644 index 000000000..34058c9ee --- /dev/null +++ b/app/core/src/main/java/stirling/software/SPDF/model/api/converters/ConvertPdfToCbzRequest.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 ConvertPdfToCbzRequest { + + @Schema( + description = "The input PDF file to be converted to a CBZ file", + requiredMode = Schema.RequiredMode.REQUIRED) + private MultipartFile fileInput; + + @Schema( + description = "The DPI (Dots Per Inch) for rendering PDF pages as images", + example = "300", + requiredMode = Schema.RequiredMode.NOT_REQUIRED) + private Integer dpi = 300; +} diff --git a/app/core/src/main/resources/messages_en_GB.properties b/app/core/src/main/resources/messages_en_GB.properties index e52e9cc45..0a58ddc26 100644 --- a/app/core/src/main/resources/messages_en_GB.properties +++ b/app/core/src/main/resources/messages_en_GB.properties @@ -606,6 +606,14 @@ home.imageToPdf.title=Image to PDF home.imageToPdf.desc=Convert a image (PNG, JPEG, GIF, PSD) to PDF. imageToPdf.tags=conversion,img,jpg,picture,photo,psd,photoshop +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.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.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 @@ -1431,6 +1439,18 @@ imageToPDF.selectText.3=Multi file logic (Only enabled if working with multiple imageToPDF.selectText.4=Merge into single PDF imageToPDF.selectText.5=Convert to separate PDFs +#cbzToPDF +cbzToPDF.title=CBZ to PDF +cbzToPDF.header=CBZ to PDF +cbzToPDF.submit=Convert to PDF +cbzToPDF.selectText=Select CBZ file + +#pdfToCBZ +pdfToCBZ.title=PDF to CBZ +pdfToCBZ.header=PDF to CBZ +pdfToCBZ.submit=Convert to CBZ +pdfToCBZ.selectText=Select PDF file +pdfToCBZ.dpi=DPI (Dots Per Inch) #pdfToImage pdfToImage.title=PDF to Image 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 new file mode 100644 index 000000000..e4f3584ba --- /dev/null +++ b/app/core/src/main/resources/templates/convert/cbz-to-pdf.html @@ -0,0 +1,39 @@ + + + + + + + + + +
+
+ +

+
+
+
+
+ auto_stories + +
+
+
+
+ +
+ +
+
+
+
+
+ +
+ + + diff --git a/app/core/src/main/resources/templates/convert/pdf-to-cbz.html b/app/core/src/main/resources/templates/convert/pdf-to-cbz.html new file mode 100644 index 000000000..fd1023a9c --- /dev/null +++ b/app/core/src/main/resources/templates/convert/pdf-to-cbz.html @@ -0,0 +1,42 @@ + + + + + + + + + +
+
+ +

+
+
+
+
+ auto_stories + +
+
+
+
+
+ + +
+
+ +
+
+
+
+
+ +
+ + + diff --git a/app/core/src/main/resources/templates/fragments/navElements.html b/app/core/src/main/resources/templates/fragments/navElements.html index 19d6536ce..b5ae817f4 100644 --- a/app/core/src/main/resources/templates/fragments/navElements.html +++ b/app/core/src/main/resources/templates/fragments/navElements.html @@ -47,6 +47,9 @@
+
+
@@ -71,6 +74,9 @@
+
+
@@ -98,13 +104,16 @@ -
+