mirror of
https://github.com/Frooodle/Stirling-PDF.git
synced 2026-04-22 23:08:53 +02:00
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -48,25 +48,144 @@ 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)) {
|
||||
String mode = request.getMode();
|
||||
if (mode == null || mode.trim().isEmpty()) {
|
||||
mode = "DEFAULT";
|
||||
}
|
||||
|
||||
int rows;
|
||||
int cols;
|
||||
int pagesPerSheet;
|
||||
|
||||
switch (mode) {
|
||||
case "DEFAULT":
|
||||
pagesPerSheet = request.getPagesPerSheet();
|
||||
if (pagesPerSheet != 2
|
||||
&& pagesPerSheet
|
||||
!= (int) Math.sqrt(pagesPerSheet) * Math.sqrt(pagesPerSheet)) {
|
||||
throw ExceptionUtils.createIllegalArgumentException(
|
||||
"error.invalidFormat",
|
||||
"Invalid {0} format: {1}",
|
||||
"pagesPerSheet",
|
||||
"must be 2 or a perfect square");
|
||||
}
|
||||
|
||||
cols = pagesPerSheet == 2 ? pagesPerSheet : (int) Math.sqrt(pagesPerSheet);
|
||||
rows = pagesPerSheet == 2 ? 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);
|
||||
}
|
||||
|
||||
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}",
|
||||
"pagesPerSheet",
|
||||
"must be 2, 3 or a perfect square");
|
||||
"orientation",
|
||||
"only 'PORTRAIT' and 'LANDSCAPE' are supported");
|
||||
}
|
||||
|
||||
int cols =
|
||||
pagesPerSheet == 2 || pagesPerSheet == 3
|
||||
? pagesPerSheet
|
||||
: (int) Math.sqrt(pagesPerSheet);
|
||||
int rows = pagesPerSheet == 2 || pagesPerSheet == 3 ? 1 : (int) Math.sqrt(pagesPerSheet);
|
||||
String arrangement = request.getArrangement();
|
||||
if (arrangement == null || arrangement.trim().isEmpty()) {
|
||||
arrangement = "BY_ROWS";
|
||||
}
|
||||
if (!"BY_ROWS".equals(arrangement) && !"BY_COLUMNS".equals(arrangement)) {
|
||||
throw ExceptionUtils.createIllegalArgumentException(
|
||||
"error.invalidFormat",
|
||||
"Invalid {0} format: {1}",
|
||||
"arrangement",
|
||||
"only 'BY_ROWS' and 'BY_COLUMNS' are supported");
|
||||
}
|
||||
|
||||
String readingDirection = request.getReadingDirection();
|
||||
if (readingDirection == null || readingDirection.trim().isEmpty()) {
|
||||
readingDirection = "LTR";
|
||||
}
|
||||
if (!"LTR".equals(readingDirection) && !"RTL".equals(readingDirection)) {
|
||||
throw ExceptionUtils.createIllegalArgumentException(
|
||||
"error.invalidFormat",
|
||||
"Invalid {0} format: {1}",
|
||||
"readingDirection",
|
||||
"only 'LTR' and 'RTL' are supported");
|
||||
}
|
||||
|
||||
boolean addBorder = Boolean.TRUE.equals(request.getAddBorder());
|
||||
|
||||
int topMargin = request.getTopMargin();
|
||||
int bottomMargin = request.getBottomMargin();
|
||||
int leftMargin = request.getLeftMargin();
|
||||
int rightMargin = request.getRightMargin();
|
||||
int innerMargin = request.getInnerMargin();
|
||||
|
||||
if (topMargin < 0
|
||||
|| bottomMargin < 0
|
||||
|| leftMargin < 0
|
||||
|| rightMargin < 0
|
||||
|| innerMargin < 0) {
|
||||
throw ExceptionUtils.createIllegalArgumentException(
|
||||
"error.invalidFormat",
|
||||
"Invalid {0} format: {1}",
|
||||
"Margins",
|
||||
"only positive values are allowed");
|
||||
}
|
||||
|
||||
int borderWidth = request.getBorderWidth() == 0 ? 1 : request.getBorderWidth();
|
||||
|
||||
if (addBorder && borderWidth <= 0) {
|
||||
throw ExceptionUtils.createIllegalArgumentException(
|
||||
"error.invalidFormat",
|
||||
"Invalid {0} format: {1}",
|
||||
"borderWidth",
|
||||
"only strictly positive values are allowed when addBorder is true");
|
||||
}
|
||||
|
||||
MultipartFile file = request.getFileInput();
|
||||
|
||||
try (PDDocument sourceDocument = pdfDocumentFactory.load(file)) {
|
||||
try (PDDocument newDocument =
|
||||
@@ -74,16 +193,53 @@ public class MultiPageLayoutController {
|
||||
int totalPages = sourceDocument.getNumberOfPages();
|
||||
LayerUtility layerUtility = new LayerUtility(newDocument);
|
||||
|
||||
// Margin between page and content:
|
||||
float pageWidth =
|
||||
"PORTRAIT".equals(orientation)
|
||||
? PDRectangle.A4.getWidth()
|
||||
: PDRectangle.A4.getHeight();
|
||||
float pageHeight =
|
||||
"PORTRAIT".equals(orientation)
|
||||
? PDRectangle.A4.getHeight()
|
||||
: PDRectangle.A4.getWidth();
|
||||
|
||||
// Calculate cell dimensions once (all output pages are A4) - declare outside try
|
||||
// blocks
|
||||
float cellWidth = PDRectangle.A4.getWidth() / cols;
|
||||
float cellHeight = PDRectangle.A4.getHeight() / rows;
|
||||
float cellWidth = (pageWidth - leftMargin - rightMargin) / cols;
|
||||
float cellHeight = (pageHeight - topMargin - bottomMargin) / rows;
|
||||
// Validate that outer margins and grid configuration yield positive cell size
|
||||
if (cellWidth <= 0 || cellHeight <= 0) {
|
||||
throw ExceptionUtils.createIllegalArgumentException(
|
||||
"error.invalidFormat",
|
||||
"Invalid {0} format: {1}",
|
||||
"margin/layout configuration",
|
||||
"Invalid margin or layout configuration: resulting cell size is non-positive. "
|
||||
+ "Please reduce outer margins or adjust rows/columns.");
|
||||
}
|
||||
|
||||
float innerWidth = cellWidth - 2 * innerMargin;
|
||||
float innerHeight = cellHeight - 2 * innerMargin;
|
||||
// Validate that inner margin fits within each cell
|
||||
if (innerWidth <= 0 || innerHeight <= 0) {
|
||||
throw ExceptionUtils.createIllegalArgumentException(
|
||||
"error.invalidFormat",
|
||||
"Invalid {0} format: {1}",
|
||||
"inner margin",
|
||||
"Invalid inner margin: resulting inner content area is non-positive. "
|
||||
+ "Please reduce inner margin or adjust outer margins/layout.");
|
||||
}
|
||||
|
||||
// Process pages in groups of pagesPerSheet, creating a new page and content stream
|
||||
// for each group
|
||||
for (int i = 0; i < totalPages; i += pagesPerSheet) {
|
||||
// Create a new output page for each group of pagesPerSheet
|
||||
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);
|
||||
|
||||
// Use try-with-resources for each content stream to ensure proper cleanup
|
||||
@@ -95,30 +251,52 @@ public class MultiPageLayoutController {
|
||||
PDPageContentStream.AppendMode.APPEND,
|
||||
true,
|
||||
true)) {
|
||||
float borderThickness = 1.5f; // Specify border thickness as required
|
||||
contentStream.setLineWidth(borderThickness);
|
||||
contentStream.setStrokingColor(Color.BLACK);
|
||||
|
||||
if (addBorder) {
|
||||
contentStream.setLineWidth(borderWidth);
|
||||
contentStream.setStrokingColor(Color.BLACK);
|
||||
}
|
||||
|
||||
// Process all pages in this group
|
||||
for (int j = 0; j < pagesPerSheet && (i + j) < totalPages; j++) {
|
||||
int pageIndex = i + j;
|
||||
PDPage sourcePage = sourceDocument.getPage(pageIndex);
|
||||
PDRectangle rect = sourcePage.getMediaBox();
|
||||
float scaleWidth = cellWidth / rect.getWidth();
|
||||
float scaleHeight = cellHeight / rect.getHeight();
|
||||
float scaleWidth = innerWidth / rect.getWidth();
|
||||
float scaleHeight = innerHeight / rect.getHeight();
|
||||
float scale = Math.min(scaleWidth, scaleHeight);
|
||||
|
||||
int adjustedPageIndex = j % pagesPerSheet;
|
||||
int rowIndex = adjustedPageIndex / cols;
|
||||
int colIndex = adjustedPageIndex % cols;
|
||||
int rowIndex;
|
||||
int colIndex;
|
||||
|
||||
if ("BY_ROWS".equals(arrangement)) {
|
||||
rowIndex = adjustedPageIndex / cols;
|
||||
if ("LTR".equals(readingDirection)) {
|
||||
colIndex = adjustedPageIndex % cols;
|
||||
} else {
|
||||
colIndex = cols - 1 - (adjustedPageIndex % cols);
|
||||
}
|
||||
} else {
|
||||
rowIndex = adjustedPageIndex % rows;
|
||||
if ("LTR".equals(readingDirection)) {
|
||||
colIndex = adjustedPageIndex / rows;
|
||||
} else {
|
||||
colIndex = cols - 1 - (adjustedPageIndex / rows);
|
||||
}
|
||||
}
|
||||
|
||||
float x =
|
||||
colIndex * cellWidth
|
||||
+ (cellWidth - rect.getWidth() * scale) / 2;
|
||||
leftMargin
|
||||
+ colIndex * cellWidth
|
||||
+ innerMargin
|
||||
+ (innerWidth - rect.getWidth() * scale) / 2;
|
||||
float y =
|
||||
newPage.getMediaBox().getHeight()
|
||||
- topMargin
|
||||
- ((rowIndex + 1) * cellHeight
|
||||
- (cellHeight - rect.getHeight() * scale) / 2);
|
||||
- innerMargin
|
||||
- (innerHeight - rect.getHeight() * scale) / 2);
|
||||
|
||||
contentStream.saveGraphicsState();
|
||||
contentStream.transform(Matrix.getTranslateInstance(x, y));
|
||||
@@ -132,11 +310,8 @@ public class MultiPageLayoutController {
|
||||
|
||||
if (addBorder) {
|
||||
// Draw border around each page
|
||||
float borderX = colIndex * cellWidth;
|
||||
float borderY =
|
||||
newPage.getMediaBox().getHeight()
|
||||
- (rowIndex + 1) * cellHeight;
|
||||
contentStream.addRect(borderX, borderY, cellWidth, cellHeight);
|
||||
contentStream.addRect(
|
||||
x, y, rect.getWidth() * scale, rect.getHeight() * scale);
|
||||
contentStream.stroke();
|
||||
}
|
||||
}
|
||||
@@ -145,7 +320,7 @@ public class MultiPageLayoutController {
|
||||
|
||||
// If any source page is rotated, skip form copying/transformation entirely
|
||||
boolean hasRotation = GeneralFormCopyUtils.hasAnyRotatedPage(sourceDocument);
|
||||
if (hasRotation) {
|
||||
if (hasRotation || "LANDSCAPE".equals(orientation)) {
|
||||
log.info("Source document has rotated pages; skipping form field copying.");
|
||||
} else {
|
||||
try {
|
||||
|
||||
@@ -11,13 +11,110 @@ import stirling.software.common.model.api.PDFFile;
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
public class MergeMultiplePagesRequest extends PDFFile {
|
||||
|
||||
@Schema(
|
||||
description =
|
||||
"Input mode: DEFAULT uses pagesPerSheet; CUSTOM uses explicit cols x 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 = "integer",
|
||||
requiredMode = Schema.RequiredMode.REQUIRED,
|
||||
allowableValues = {"2", "3", "4", "9", "16"})
|
||||
allowableValues = {"2", "4", "9", "16"})
|
||||
private int pagesPerSheet = 2;
|
||||
|
||||
@Schema(
|
||||
description =
|
||||
"The arrangement of pages on the sheet: BY_ROWS fills pages row by row, while BY_COLUMNS fills pages column by column.",
|
||||
type = "string",
|
||||
defaultValue = "BY_ROWS",
|
||||
allowableValues = {"BY_ROWS", "BY_COLUMNS"})
|
||||
private String arrangement;
|
||||
|
||||
@Schema(
|
||||
description =
|
||||
"The direction in which pages are arranged on the sheet: LTR (left-to-right) or RTL (right-to-left).",
|
||||
type = "string",
|
||||
defaultValue = "LTR",
|
||||
allowableValues = {"LTR", "RTL"})
|
||||
private String readingDirection;
|
||||
|
||||
@Schema(
|
||||
description = "Number of rows",
|
||||
type = "number",
|
||||
defaultValue = "1",
|
||||
maximum = "300",
|
||||
minimum = "1",
|
||||
example = "3")
|
||||
private int rows;
|
||||
|
||||
@Schema(
|
||||
description = "Number of columns",
|
||||
type = "number",
|
||||
defaultValue = "2",
|
||||
maximum = "300",
|
||||
minimum = "1",
|
||||
example = "2")
|
||||
private int cols;
|
||||
|
||||
@Schema(
|
||||
description = "The orientation of the output PDF pages",
|
||||
type = "string",
|
||||
defaultValue = "PORTRAIT",
|
||||
allowableValues = {"PORTRAIT", "LANDSCAPE"})
|
||||
private String orientation;
|
||||
|
||||
@Schema(
|
||||
description = "Inner margin (in points) to apply around each page when merging",
|
||||
type = "number",
|
||||
defaultValue = "0",
|
||||
minimum = "0",
|
||||
example = "200")
|
||||
private int innerMargin;
|
||||
|
||||
@Schema(
|
||||
description = "Top margin (in points) to apply to the output pages when merging",
|
||||
type = "number",
|
||||
defaultValue = "0",
|
||||
minimum = "0",
|
||||
example = "200")
|
||||
private int topMargin;
|
||||
|
||||
@Schema(
|
||||
description = "Bottom margin (in points) to apply to the output pages when merging",
|
||||
type = "number",
|
||||
defaultValue = "0",
|
||||
minimum = "0",
|
||||
example = "200")
|
||||
private int bottomMargin;
|
||||
|
||||
@Schema(
|
||||
description = "Left margin (in points) to apply to the output pages when merging",
|
||||
type = "number",
|
||||
defaultValue = "0",
|
||||
minimum = "0",
|
||||
example = "200")
|
||||
private int leftMargin;
|
||||
|
||||
@Schema(
|
||||
description = "Right margin (in points) to apply to the output pages when merging",
|
||||
type = "number",
|
||||
defaultValue = "0",
|
||||
minimum = "0",
|
||||
example = "200")
|
||||
private int rightMargin;
|
||||
|
||||
@Schema(
|
||||
description = "Border width (in points) to apply around each page when merging",
|
||||
type = "number",
|
||||
defaultValue = "1",
|
||||
minimum = "0",
|
||||
example = "2")
|
||||
private int borderWidth;
|
||||
|
||||
@Schema(description = "Boolean for if you wish to add border around the pages")
|
||||
private Boolean addBorder;
|
||||
}
|
||||
|
||||
@@ -106,7 +106,9 @@ class MultiPageLayoutControllerTest {
|
||||
.thenReturn(target);
|
||||
|
||||
MergeMultiplePagesRequest req = new MergeMultiplePagesRequest();
|
||||
req.setPagesPerSheet(3);
|
||||
req.setMode("CUSTOM");
|
||||
req.setCols(3);
|
||||
req.setRows(1);
|
||||
req.setAddBorder(Boolean.TRUE);
|
||||
req.setFileInput(fileNoExt);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user