This commit is contained in:
OUNZAR Aymane 2025-11-15 13:31:52 +00:00 committed by GitHub
commit 58ffa02663
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 332 additions and 23 deletions

View File

@ -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<byte[]> 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 =
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);
int rows = pagesPerSheet == 2 || pagesPerSheet == 3 ? 1 : (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": // LeftRight, then TopDown
rowIndex = adjustedPageIndex / cols;
colIndex = adjustedPageIndex % cols;
break;
case "RL_TD": // RightLeft, then TopDown
rowIndex = adjustedPageIndex / cols;
colIndex = cols - 1 - (adjustedPageIndex % cols);
break;
case "TD_LR": // TopDown, then LeftRight
colIndex = adjustedPageIndex / rows;
rowIndex = adjustedPageIndex % rows;
break;
case "TD_RL": // TopDown, then RightLeft
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 {

View File

@ -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;
}

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -18,9 +18,13 @@
</div>
<form id="multiPdfForm" th:action="@{'/api/v1/general/multi-page-layout'}" method="post" enctype="multipart/form-data">
<div th:replace="~{fragments/common :: fileSelector(name='fileInput', multipleInputsForSingleRequest=false, accept='application/pdf')}"></div>
<div class="mb-3">
<div class="form-check mb-3">
<input type="checkbox" id="modeCustom">
<label for="modeCustom" th:text="#{pageLayout.custom}"></label>
</div>
<div id="defaultSection" class="mb-3" style="border:1px solid #eee; padding:1em; border-radius:8px; margin-bottom:1em;">
<label for="pagesPerSheet" th:text="#{pageLayout.pagesPerSheet}"></label>
<select class="form-control" id="pagesPerSheet" name="pagesPerSheet">
<select class="form-control" id="pagesPerSheet" name="pagesPerSheet" required>
<option value="2">2</option>
<option value="3">3</option>
<option value="4">4</option>
@ -28,12 +32,91 @@
<option value="16">16</option>
</select>
</div>
<div id="customSection" class="mb-3" style="border:1px solid #eee; padding:1em; border-radius:8px; margin-bottom:1em;">
<div class="mb-3">
<label for="rows" th:text="#{pageLayout.rows}"></label>
<input class="form-control" type="number" id="rows" name="rows" step="1" min="1" max="300" value="1" required>
</div>
<div class="mb-3">
<label for="cols" th:text="#{pageLayout.columns}"></label>
<input class="form-control" type="number" id="cols" name="cols" step="1" min="1" max="300" value="1" required>
</div>
</div>
<div class="form-check mb-3">
<input id="advancedSettingsToggle" type="checkbox">
<label for="advancedSettingsToggle" th:text="#{pageLayout.advancedSettings}"></label>
</div>
<div id="advancedSettings" style="display:none; border:1px solid #eee; padding:1em; border-radius:8px; margin-bottom:1em;">
<div class="mb-3">
<label for="pageOrder" th:text="#{pageLayout.pageOrder}"></label>
<select class="form-control" id="pageOrder" name="pageOrder">
<option value="LR_TD" th:text="#{pageLayout.leftRightTopDown}"></option>
<option value="RL_TD" th:text="#{pageLayout.rightLeftTopDown}"></option>
<option value="TD_LR" th:text="#{pageLayout.topDownLeftRight}"></option>
<option value="TD_RL" th:text="#{pageLayout.topDownRightLeft}"></option>
</select>
</div>
<div class="mb-3">
<label for="orientation" th:text="#{pageLayout.orientation}"></label>
<select class="form-control" id="orientation" name="orientation">
<option value="PORTRAIT" th:text="#{pageLayout.portrait}"></option>
<option value="LANDSCAPE" th:text="#{pageLayout.landscape}"></option>
</select>
</div>
<div class="form-check mb-3">
<input id="addBorder" name="addBorder" type="checkbox">
<label for="addBorder" th:text="#{pageLayout.addBorder}"></label>
</div>
</div>
<button type="submit" id="submitBtn" class="btn btn-primary" th:text="#{pageLayout.submit}"></button>
</form>
<script>
(function(){
const modeCheckbox = document.getElementById('modeCustom');
const defaultSection = document.getElementById('defaultSection');
const customSection = document.getElementById('customSection');
const pagesPerSheet = document.getElementById('pagesPerSheet');
const rowsInput = document.getElementById('rows');
const colsInput = document.getElementById('cols');
function sync() {
const mode = modeCheckbox.checked? 'CUSTOM' : 'DEFAULT';
const isDefault = mode === 'DEFAULT';
// Enable/disable relevant fields
pagesPerSheet.disabled = !isDefault;
rowsInput.disabled = isDefault;
colsInput.disabled = isDefault;
if (isDefault) {
defaultSection.style.display = 'block'; // show Default
customSection.style.display = 'none'; // hide Custom
} else {
defaultSection.style.display = 'none'; // hide Default
customSection.style.display = 'block'; // show Custom
}
}
modeCheckbox.addEventListener('change', sync);
// Show/hide advanced settings
const advancedToggle = document.getElementById('advancedSettingsToggle');
const advancedSettings = document.getElementById('advancedSettings');
if (advancedToggle && advancedSettings) {
advancedToggle.addEventListener('change', function() {
advancedSettings.style.display = this.checked ? 'block' : 'none';
advancedSettings.querySelector('#addBorder').checked = false;
advancedSettings.querySelector('#pageOrder').value = 'LR_TD';
advancedSettings.querySelector('#orientation').value = 'PORTRAIT';
});
}
// initial state
sync();
})();
</script>
</div>
</div>
</div>