diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/MultiPageLayoutController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/MultiPageLayoutController.java index dee51b75a..7cc53994e 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/MultiPageLayoutController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/MultiPageLayoutController.java @@ -27,6 +27,7 @@ import lombok.extern.slf4j.Slf4j; import stirling.software.SPDF.model.api.general.MergeMultiplePagesRequest; import stirling.software.common.service.CustomPDFDocumentFactory; +import stirling.software.common.util.ExceptionUtils; import stirling.software.common.util.FormUtils; import stirling.software.common.util.GeneralUtils; import stirling.software.common.util.WebResponseUtils; @@ -49,26 +50,113 @@ public class MultiPageLayoutController { public ResponseEntity mergeMultiplePagesIntoOne( @ModelAttribute MergeMultiplePagesRequest request) throws IOException { - int pagesPerSheet = request.getPagesPerSheet(); - MultipartFile file = request.getFileInput(); - boolean addBorder = Boolean.TRUE.equals(request.getAddBorder()); + int MAX_PAGES = 100000; + int MAX_COLS = 300; + int MAX_ROWS = 300; - if (pagesPerSheet != 2 - && pagesPerSheet != 3 - && pagesPerSheet != (int) Math.sqrt(pagesPerSheet) * Math.sqrt(pagesPerSheet)) { - throw new IllegalArgumentException("pagesPerSheet must be 2, 3 or a perfect square"); + String mode = request.getMode(); + if (mode == null || mode.trim().isEmpty()) { + mode = "DEFAULT"; } - int cols = - pagesPerSheet == 2 || pagesPerSheet == 3 - ? pagesPerSheet - : (int) Math.sqrt(pagesPerSheet); - int rows = pagesPerSheet == 2 || pagesPerSheet == 3 ? 1 : (int) Math.sqrt(pagesPerSheet); + int rows; + int cols; + int pagesPerSheet; + switch (mode) { + case "DEFAULT": + pagesPerSheet = request.getPagesPerSheet(); + if (pagesPerSheet != 2 + && pagesPerSheet != 3 + && pagesPerSheet + != (int) Math.sqrt(pagesPerSheet) * Math.sqrt(pagesPerSheet)) { + throw ExceptionUtils.createIllegalArgumentException( + "error.invalidFormat", + "Invalid {0} format: {1}", + "pagesPerSheet", + "only 2, 3, and perfect squares are supported"); + } + + cols = + pagesPerSheet == 2 || pagesPerSheet == 3 + ? pagesPerSheet + : (int) Math.sqrt(pagesPerSheet); + rows = + pagesPerSheet == 2 || pagesPerSheet == 3 + ? 1 + : (int) Math.sqrt(pagesPerSheet); + break; + case "CUSTOM": + rows = request.getRows(); + cols = request.getCols(); + if (rows <= 0 || cols <= 0) { + throw ExceptionUtils.createIllegalArgumentException( + "error.invalidFormat", + "Invalid {0} format: {1}", + "rows and cols", + "only strictly positive values are allowed"); + } + pagesPerSheet = cols * rows; + break; + default: + throw ExceptionUtils.createIllegalArgumentException( + "error.invalidFormat", + "Invalid {0} format: {1}", + "mode", + "only 'DEFAULT' and 'CUSTOM' are supported"); + } + + if (pagesPerSheet > MAX_PAGES) { + throw ExceptionUtils.createIllegalArgumentException( + "error.invalidArgument", + "Invalid {0} format: {1}", + "pagesPerSheet", + "must be less than " + MAX_PAGES); + } + if (cols > MAX_COLS) { + throw ExceptionUtils.createIllegalArgumentException( + "error.invalidArgument", + "Invalid {0} format: {1}", + "cols", + "must be less than " + MAX_COLS); + } + if (rows > MAX_ROWS) { + throw ExceptionUtils.createIllegalArgumentException( + "error.invalidArgument", + "Invalid {0} format: {1}", + "rows", + "must be less than " + MAX_ROWS); + } + + MultipartFile file = request.getFileInput(); + String orientation = request.getOrientation(); + if (orientation == null || orientation.trim().isEmpty()) { + orientation = "PORTRAIT"; + } + if (!"PORTRAIT".equals(orientation) && !"LANDSCAPE".equals(orientation)) { + throw ExceptionUtils.createIllegalArgumentException( + "error.invalidFormat", + "Invalid {0} format: {1}", + "orientation", + "only 'PORTRAIT' and 'LANDSCAPE' are supported"); + } + String pageOrder = request.getPageOrder(); + if (pageOrder == null || pageOrder.trim().isEmpty()) { + pageOrder = "LR_TD"; + } + + boolean addBorder = Boolean.TRUE.equals(request.getAddBorder()); PDDocument sourceDocument = pdfDocumentFactory.load(file); PDDocument newDocument = pdfDocumentFactory.createNewDocumentBasedOnOldDocument(sourceDocument); - PDPage newPage = new PDPage(PDRectangle.A4); + + // Create a new A4 landscape rectangle that we use when orientation is landscape + PDRectangle a4Landscape = + new PDRectangle(PDRectangle.A4.getHeight(), PDRectangle.A4.getWidth()); + PDPage newPage = + "PORTRAIT".equals(orientation) + ? new PDPage(PDRectangle.A4) + : new PDPage(a4Landscape); newDocument.addPage(newPage); int totalPages = sourceDocument.getNumberOfPages(); @@ -88,7 +176,10 @@ public class MultiPageLayoutController { if (i != 0 && i % pagesPerSheet == 0) { // Close the current content stream and create a new page and content stream contentStream.close(); - newPage = new PDPage(PDRectangle.A4); + newPage = + "PORTRAIT".equals(orientation) + ? new PDPage(PDRectangle.A4) + : new PDPage(a4Landscape); newDocument.addPage(newPage); contentStream = new PDPageContentStream( @@ -108,8 +199,36 @@ public class MultiPageLayoutController { int adjustedPageIndex = i % pagesPerSheet; // Close the current content stream and create a new // page and content stream - int rowIndex = adjustedPageIndex / cols; - int colIndex = adjustedPageIndex % cols; + int rowIndex; + int colIndex; + + switch (pageOrder) { + case "LR_TD": // Left→Right, then Top→Down + rowIndex = adjustedPageIndex / cols; + colIndex = adjustedPageIndex % cols; + break; + + case "RL_TD": // Right→Left, then Top→Down + rowIndex = adjustedPageIndex / cols; + colIndex = cols - 1 - (adjustedPageIndex % cols); + break; + + case "TD_LR": // Top→Down, then Left→Right + colIndex = adjustedPageIndex / rows; + rowIndex = adjustedPageIndex % rows; + break; + + case "TD_RL": // Top→Down, then Right→Left + colIndex = cols - 1 - (adjustedPageIndex / rows); + rowIndex = adjustedPageIndex % rows; + break; + default: + throw ExceptionUtils.createIllegalArgumentException( + "error.invalidFormat", + "Invalid {0} format: {1}", + "pageOrder", + "only 'LR_TD', 'RL_TD', 'TD_LR', and 'TD_RL' are supported"); + } float x = colIndex * cellWidth + (cellWidth - rect.getWidth() * scale) / 2; float y = @@ -139,7 +258,7 @@ public class MultiPageLayoutController { // If any source page is rotated, skip form copying/transformation entirely boolean hasRotation = FormUtils.hasAnyRotatedPage(sourceDocument); - if (hasRotation) { + if (hasRotation || "LANDSCAPE".equals(orientation)) { log.info("Source document has rotated pages; skipping form field copying."); } else { try { diff --git a/app/core/src/main/java/stirling/software/SPDF/model/api/general/MergeMultiplePagesRequest.java b/app/core/src/main/java/stirling/software/SPDF/model/api/general/MergeMultiplePagesRequest.java index 6d9254023..8abfb1b2f 100644 --- a/app/core/src/main/java/stirling/software/SPDF/model/api/general/MergeMultiplePagesRequest.java +++ b/app/core/src/main/java/stirling/software/SPDF/model/api/general/MergeMultiplePagesRequest.java @@ -10,15 +10,53 @@ import stirling.software.common.model.api.PDFFile; @Data @EqualsAndHashCode(callSuper = true) public class MergeMultiplePagesRequest extends PDFFile { + @Schema( + description = "Input mode: DEFAULT uses pagesPerSheet; CUSTOM uses explicit cols×rows.", + requiredMode = Schema.RequiredMode.REQUIRED, + type = "string", + defaultValue = "DEFAULT", + allowableValues = {"DEFAULT", "CUSTOM"}) + private String mode; @Schema( description = "The number of pages to fit onto a single sheet in the output PDF.", type = "number", defaultValue = "2", - requiredMode = Schema.RequiredMode.REQUIRED, allowableValues = {"2", "3", "4", "9", "16"}) private int pagesPerSheet; + @Schema( + description = "Options for the ordering of pages", + type = "string", + defaultValue = "LR_TD", + allowableValues = {"LR_TD", "RL_TD", "TD_LR", "TD_RL"}) + private String pageOrder; + + @Schema( + description = "Number of rows", + type = "number", + defaultValue = "1", + maximum = "300", + minimum = "1", + example = "3") + private Integer rows; + + @Schema( + description = "Number of columns", + type = "number", + defaultValue = "2", + maximum = "300", + minimum = "1", + example = "2") + private Integer cols; + + @Schema( + description = "The orientation of the output PDF pages", + type = "string", + defaultValue = "PORTRAIT", + allowableValues = {"PORTRAIT", "LANDSCAPE"}) + private String orientation; + @Schema(description = "Boolean for if you wish to add border around the pages") private Boolean addBorder; } diff --git a/app/core/src/main/resources/messages_en_GB.properties b/app/core/src/main/resources/messages_en_GB.properties index e4c6378f1..6762e021c 100644 --- a/app/core/src/main/resources/messages_en_GB.properties +++ b/app/core/src/main/resources/messages_en_GB.properties @@ -1166,7 +1166,20 @@ pipeline.title=Pipeline #pageLayout pageLayout.title=Multi Page Layout pageLayout.header=Multi Page Layout +pageLayout.mode=Mode +pageLayout.custom=Custom pageLayout.pagesPerSheet=Pages per sheet: +pageLayout.rows=Rows +pageLayout.columns=Columns +pageLayout.advancedSettings=Advanced settings +pageLayout.pageOrder=Page order : +pageLayout.leftRightTopDown=Left -> Right, then Top -> Bottom +pageLayout.rightLeftTopDown=Right -> Left, then Top -> Bottom +pageLayout.topDownLeftRight=Top -> Bottom, then Left -> Right +pageLayout.topDownRightLeft=Top -> Bottom, then Right -> Left +pageLayout.orientation=Orientation : +pageLayout.portrait=Portrait +pageLayout.landscape=Landscape pageLayout.addBorder=Add Borders pageLayout.submit=Submit diff --git a/app/core/src/main/resources/messages_en_US.properties b/app/core/src/main/resources/messages_en_US.properties index 287955226..392e95c2b 100644 --- a/app/core/src/main/resources/messages_en_US.properties +++ b/app/core/src/main/resources/messages_en_US.properties @@ -616,6 +616,10 @@ 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.ebookToPdf.title=eBook to PDF +home.ebookToPdf.desc=Convert eBook files (EPUB, MOBI, AZW3, FB2, TXT, DOCX) to PDF using Calibre. +ebookToPdf.tags=conversion,ebook,calibre,epub,mobi,azw3 + 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 @@ -1162,7 +1166,20 @@ pipeline.title=Pipeline #pageLayout pageLayout.title=Multi Page Layout pageLayout.header=Multi Page Layout +pageLayout.mode=Mode +pageLayout.custom=Custom pageLayout.pagesPerSheet=Pages per sheet: +pageLayout.rows=Rows +pageLayout.columns=Columns +pageLayout.advancedSettings=Advanced settings +pageLayout.pageOrder=Page order : +pageLayout.leftRightTopDown=Left -> Right, then Top -> Bottom +pageLayout.rightLeftTopDown=Right -> Left, then Top -> Bottom +pageLayout.topDownLeftRight=Top -> Bottom, then Left -> Right +pageLayout.topDownRightLeft=Top -> Bottom, then Right -> Left +pageLayout.orientation=Orientation : +pageLayout.portrait=Portrait +pageLayout.landscape=Landscape pageLayout.addBorder=Add Borders pageLayout.submit=Submit @@ -1490,6 +1507,17 @@ cbrToPDF.submit=Convert to PDF cbrToPDF.selectText=Select CBR file cbrToPDF.optimizeForEbook=Optimize PDF for ebook readers (uses Ghostscript) +#ebookToPDF +ebookToPDF.title=eBook to PDF +ebookToPDF.header=eBook to PDF +ebookToPDF.submit=Convert to PDF +ebookToPDF.selectText=Select eBook file +ebookToPDF.embedAllFonts=Embed all fonts in the output PDF (may increase file size) +ebookToPDF.includeTableOfContents=Add a generated table of contents to the PDF +ebookToPDF.includePageNumbers=Add page numbers to the generated PDF +ebookToPDF.optimizeForEbook=Optimize PDF for ebook readers (uses Ghostscript) +ebookToPDF.calibreDisabled=Calibre support is disabled. Enable the Calibre tool group or install Calibre to use this feature. + #pdfToCBR pdfToCBR.title=PDF to CBR pdfToCBR.header=PDF to CBR diff --git a/app/core/src/main/resources/messages_fr_FR.properties b/app/core/src/main/resources/messages_fr_FR.properties index 64e624dc4..0fc32e66b 100644 --- a/app/core/src/main/resources/messages_fr_FR.properties +++ b/app/core/src/main/resources/messages_fr_FR.properties @@ -76,7 +76,7 @@ lang.kir=Kirghize, Kyrgyz lang.kmr=Kurmandji (Kurde du Nord) lang.kor=Coréen lang.kor_vert=Coréen (Vertical) -lang.lao=Laotien +lang.lao=Laotien lang.lat=Latin lang.lav=Letton lang.lit=Lituanien @@ -616,6 +616,10 @@ home.cbrToPdf.title=CBR en PDF home.cbrToPdf.desc=Convertissez une archive de bande dessinée CBR en PDF. cbrToPdf.tags=bande dessinée,convertir,conversion,comic,book,archive,cbr,rar +home.ebookToPdf.title=eBook to PDF +home.ebookToPdf.desc=Convert eBook files (EPUB, MOBI, AZW3, FB2, TXT, DOCX) to PDF using Calibre. +ebookToPdf.tags=conversion,ebook,calibre,epub,mobi,azw3 + home.pdfToCbz.title=PDF en CBZ home.pdfToCbz.desc=Convertissez un fichier PDF en archive de bande dessinée CBZ. pdfToCbz.tags=bande dessinée,convertir,conversion,comic,book,archive,cbz,pdf @@ -1162,7 +1166,20 @@ pipeline.title=Pipeline #pageLayout pageLayout.title=Fusionner des pages pageLayout.header=Fusionner des pages +pageLayout.mode=Mode +pageLayout.custom=Custom pageLayout.pagesPerSheet=Pages par feuille +pageLayout.rows=Rows +pageLayout.columns=Columns +pageLayout.advancedSettings=Advanced settings +pageLayout.pageOrder=Page order : +pageLayout.leftRightTopDown=Left -> Right, then Top -> Bottom +pageLayout.rightLeftTopDown=Right -> Left, then Top -> Bottom +pageLayout.topDownLeftRight=Top -> Bottom, then Left -> Right +pageLayout.topDownRightLeft=Top -> Bottom, then Right -> Left +pageLayout.orientation=Orientation : +pageLayout.portrait=Portrait +pageLayout.landscape=Paysage pageLayout.addBorder=Ajouter des bordures pageLayout.submit=Fusionner @@ -1490,6 +1507,17 @@ cbrToPDF.submit=Convertir en PDF cbrToPDF.selectText=Sélectionnez un fichier CBR cbrToPDF.optimizeForEbook=Optimiser un PDF pour une liseuse (utilise Ghostscript) +#ebookToPDF +ebookToPDF.title=eBook to PDF +ebookToPDF.header=eBook to PDF +ebookToPDF.submit=Convert to PDF +ebookToPDF.selectText=Select eBook file +ebookToPDF.embedAllFonts=Embed all fonts in the output PDF (may increase file size) +ebookToPDF.includeTableOfContents=Add a generated table of contents to the PDF +ebookToPDF.includePageNumbers=Add page numbers to the generated PDF +ebookToPDF.optimizeForEbook=Optimize PDF for ebook readers (uses Ghostscript) +ebookToPDF.calibreDisabled=Calibre support is disabled. Enable the Calibre tool group or install Calibre to use this feature. + #pdfToCBR pdfToCBR.title=PDF en CBR pdfToCBR.header=PDF en CBR diff --git a/app/core/src/main/resources/templates/multi-page-layout.html b/app/core/src/main/resources/templates/multi-page-layout.html index 0a8f5ebb3..2749111ed 100644 --- a/app/core/src/main/resources/templates/multi-page-layout.html +++ b/app/core/src/main/resources/templates/multi-page-layout.html @@ -18,9 +18,13 @@
-
+
+ + +
+
- @@ -28,12 +32,91 @@
+
+
+ + +
+
+ + +
+
- - + + +
+ + + +