From ef7030d5a9f11c45e9b0e8e35647f25881722b06 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: Thu, 25 Sep 2025 22:26:11 +0200 Subject: [PATCH] feat(multi-layout): implement form field copying and transformation for multi-page PDF to keep form data (#4314) --- .../software/common/util/FormUtils.java | 658 ++++++++++++++++++ .../api/MultiPageLayoutController.java | 31 +- .../api/MultiPageLayoutControllerTest.java | 116 +++ 3 files changed, 802 insertions(+), 3 deletions(-) create mode 100644 app/common/src/main/java/stirling/software/common/util/FormUtils.java create mode 100644 app/core/src/test/java/stirling/software/SPDF/controller/api/MultiPageLayoutControllerTest.java diff --git a/app/common/src/main/java/stirling/software/common/util/FormUtils.java b/app/common/src/main/java/stirling/software/common/util/FormUtils.java new file mode 100644 index 000000000..19cda95ed --- /dev/null +++ b/app/common/src/main/java/stirling/software/common/util/FormUtils.java @@ -0,0 +1,658 @@ +package stirling.software.common.util; + +import java.io.IOException; +import java.lang.reflect.Method; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import org.apache.pdfbox.cos.COSName; +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.pdmodel.PDDocumentCatalog; +import org.apache.pdfbox.pdmodel.PDPage; +import org.apache.pdfbox.pdmodel.PDResources; +import org.apache.pdfbox.pdmodel.common.PDRectangle; +import org.apache.pdfbox.pdmodel.font.PDType1Font; +import org.apache.pdfbox.pdmodel.font.Standard14Fonts; +import org.apache.pdfbox.pdmodel.interactive.annotation.PDAnnotation; +import org.apache.pdfbox.pdmodel.interactive.annotation.PDAnnotationWidget; +import org.apache.pdfbox.pdmodel.interactive.form.*; + +import lombok.extern.slf4j.Slf4j; + +@Slf4j +public final class FormUtils { + + private FormUtils() {} + + public static boolean hasAnyRotatedPage(PDDocument document) { + try { + for (PDPage page : document.getPages()) { + int rot = page.getRotation(); + int norm = ((rot % 360) + 360) % 360; + if (norm != 0) { + return true; + } + } + } catch (Exception e) { + log.warn("Failed to inspect page rotations: {}", e.getMessage(), e); + } + return false; + } + + public static void copyAndTransformFormFields( + PDDocument sourceDocument, + PDDocument newDocument, + int totalPages, + int pagesPerSheet, + int cols, + int rows, + float cellWidth, + float cellHeight) + throws IOException { + + PDDocumentCatalog sourceCatalog = sourceDocument.getDocumentCatalog(); + PDAcroForm sourceAcroForm = sourceCatalog.getAcroForm(); + + if (sourceAcroForm == null || sourceAcroForm.getFields().isEmpty()) { + return; + } + + PDDocumentCatalog newCatalog = newDocument.getDocumentCatalog(); + PDAcroForm newAcroForm = new PDAcroForm(newDocument); + newCatalog.setAcroForm(newAcroForm); + + PDResources dr = new PDResources(); + PDType1Font helvetica = new PDType1Font(Standard14Fonts.FontName.HELVETICA); + PDType1Font zapfDingbats = new PDType1Font(Standard14Fonts.FontName.ZAPF_DINGBATS); + dr.put(COSName.getPDFName("Helv"), helvetica); + dr.put(COSName.getPDFName("ZaDb"), zapfDingbats); + newAcroForm.setDefaultResources(dr); + newAcroForm.setDefaultAppearance("/Helv 12 Tf 0 g"); + + // Do not mutate the source AcroForm; skip bad widgets during copy + newAcroForm.setNeedAppearances(true); + + Map fieldNameCounters = new HashMap<>(); + + // Build widget -> field map once for efficient lookups + Map widgetFieldMap = buildWidgetFieldMap(sourceAcroForm); + + for (int pageIndex = 0; pageIndex < totalPages; pageIndex++) { + PDPage sourcePage = sourceDocument.getPage(pageIndex); + List annotations = sourcePage.getAnnotations(); + + if (annotations.isEmpty()) { + continue; + } + + int destinationPageIndex = pageIndex / pagesPerSheet; + int adjustedPageIndex = pageIndex % pagesPerSheet; + int rowIndex = adjustedPageIndex / cols; + int colIndex = adjustedPageIndex % cols; + + if (destinationPageIndex >= newDocument.getNumberOfPages()) { + continue; + } + + PDPage destinationPage = newDocument.getPage(destinationPageIndex); + PDRectangle sourceRect = sourcePage.getMediaBox(); + + float scaleWidth = cellWidth / sourceRect.getWidth(); + float scaleHeight = cellHeight / sourceRect.getHeight(); + float scale = Math.min(scaleWidth, scaleHeight); + + float x = colIndex * cellWidth + (cellWidth - sourceRect.getWidth() * scale) / 2; + float y = + destinationPage.getMediaBox().getHeight() + - ((rowIndex + 1) * cellHeight + - (cellHeight - sourceRect.getHeight() * scale) / 2); + + copyBasicFormFields( + sourceAcroForm, + newAcroForm, + sourcePage, + destinationPage, + x, + y, + scale, + pageIndex, + fieldNameCounters, + widgetFieldMap); + } + + // Refresh appearances to ensure widgets render correctly across viewers + try { + // Use reflection to avoid compile-time dependency on PDFBox version + Method m = newAcroForm.getClass().getMethod("refreshAppearances"); + m.invoke(newAcroForm); + } catch (NoSuchMethodException nsme) { + log.warn( + "AcroForm.refreshAppearances() not available in this PDFBox version; relying on NeedAppearances."); + } catch (Throwable t) { + log.warn("Failed to refresh field appearances via AcroForm: {}", t.getMessage(), t); + } + } + + private static void copyBasicFormFields( + PDAcroForm sourceAcroForm, + PDAcroForm newAcroForm, + PDPage sourcePage, + PDPage destinationPage, + float offsetX, + float offsetY, + float scale, + int pageIndex, + Map fieldNameCounters, + Map widgetFieldMap) { + + try { + List sourceAnnotations = sourcePage.getAnnotations(); + List destinationAnnotations = destinationPage.getAnnotations(); + + for (PDAnnotation annotation : sourceAnnotations) { + if (annotation instanceof PDAnnotationWidget widgetAnnotation) { + if (widgetAnnotation.getRectangle() == null) { + continue; + } + PDField sourceField = + widgetFieldMap != null ? widgetFieldMap.get(widgetAnnotation) : null; + if (sourceField == null) { + continue; // skip widgets without a matching field + } + if (sourceField instanceof PDTextField pdtextfield) { + createSimpleTextField( + newAcroForm, + destinationPage, + destinationAnnotations, + pdtextfield, + widgetAnnotation, + offsetX, + offsetY, + scale, + pageIndex, + fieldNameCounters); + } else if (sourceField instanceof PDCheckBox pdCheckBox) { + createSimpleCheckBoxField( + newAcroForm, + destinationPage, + destinationAnnotations, + pdCheckBox, + widgetAnnotation, + offsetX, + offsetY, + scale, + pageIndex, + fieldNameCounters); + } else if (sourceField instanceof PDRadioButton pdRadioButton) { + createSimpleRadioButtonField( + newAcroForm, + destinationPage, + destinationAnnotations, + pdRadioButton, + widgetAnnotation, + offsetX, + offsetY, + scale, + pageIndex, + fieldNameCounters); + } else if (sourceField instanceof PDComboBox pdComboBox) { + createSimpleComboBoxField( + newAcroForm, + destinationPage, + destinationAnnotations, + pdComboBox, + widgetAnnotation, + offsetX, + offsetY, + scale, + pageIndex, + fieldNameCounters); + } else if (sourceField instanceof PDListBox pdlistbox) { + createSimpleListBoxField( + newAcroForm, + destinationPage, + destinationAnnotations, + pdlistbox, + widgetAnnotation, + offsetX, + offsetY, + scale, + pageIndex, + fieldNameCounters); + } else if (sourceField instanceof PDSignatureField pdSignatureField) { + createSimpleSignatureField( + newAcroForm, + destinationPage, + destinationAnnotations, + pdSignatureField, + widgetAnnotation, + offsetX, + offsetY, + scale, + pageIndex, + fieldNameCounters); + } else if (sourceField instanceof PDPushButton pdPushButton) { + createSimplePushButtonField( + newAcroForm, + destinationPage, + destinationAnnotations, + pdPushButton, + widgetAnnotation, + offsetX, + offsetY, + scale, + pageIndex, + fieldNameCounters); + } + } + } + } catch (Exception e) { + log.warn( + "Failed to copy basic form fields for page {}: {}", + pageIndex, + e.getMessage(), + e); + } + } + + private static void createSimpleTextField( + PDAcroForm newAcroForm, + PDPage destinationPage, + List destinationAnnotations, + PDTextField sourceField, + PDAnnotationWidget sourceWidget, + float offsetX, + float offsetY, + float scale, + int pageIndex, + Map fieldNameCounters) { + + try { + PDTextField newTextField = new PDTextField(newAcroForm); + newTextField.setDefaultAppearance("/Helv 12 Tf 0 g"); + + boolean initialized = + initializeFieldWithWidget( + newAcroForm, + destinationPage, + destinationAnnotations, + newTextField, + sourceField.getPartialName(), + "textField", + sourceWidget, + offsetX, + offsetY, + scale, + pageIndex, + fieldNameCounters); + + if (!initialized) { + return; + } + + if (sourceField.getValueAsString() != null) { + newTextField.setValue(sourceField.getValueAsString()); + } + + } catch (Exception e) { + log.warn( + "Failed to create text field '{}': {}", + sourceField.getPartialName(), + e.getMessage(), + e); + } + } + + private static void createSimpleCheckBoxField( + PDAcroForm newAcroForm, + PDPage destinationPage, + List destinationAnnotations, + PDCheckBox sourceField, + PDAnnotationWidget sourceWidget, + float offsetX, + float offsetY, + float scale, + int pageIndex, + Map fieldNameCounters) { + + try { + PDCheckBox newCheckBox = new PDCheckBox(newAcroForm); + + boolean initialized = + initializeFieldWithWidget( + newAcroForm, + destinationPage, + destinationAnnotations, + newCheckBox, + sourceField.getPartialName(), + "checkBox", + sourceWidget, + offsetX, + offsetY, + scale, + pageIndex, + fieldNameCounters); + + if (!initialized) { + return; + } + + if (sourceField.isChecked()) { + newCheckBox.check(); + } else { + newCheckBox.unCheck(); + } + + } catch (Exception e) { + log.warn( + "Failed to create checkbox field '{}': {}", + sourceField.getPartialName(), + e.getMessage(), + e); + } + } + + private static void createSimpleRadioButtonField( + PDAcroForm newAcroForm, + PDPage destinationPage, + List destinationAnnotations, + PDRadioButton sourceField, + PDAnnotationWidget sourceWidget, + float offsetX, + float offsetY, + float scale, + int pageIndex, + Map fieldNameCounters) { + + try { + PDRadioButton newRadioButton = new PDRadioButton(newAcroForm); + + boolean initialized = + initializeFieldWithWidget( + newAcroForm, + destinationPage, + destinationAnnotations, + newRadioButton, + sourceField.getPartialName(), + "radioButton", + sourceWidget, + offsetX, + offsetY, + scale, + pageIndex, + fieldNameCounters); + + if (!initialized) { + return; + } + + if (sourceField.getExportValues() != null) { + newRadioButton.setExportValues(sourceField.getExportValues()); + } + if (sourceField.getValue() != null) { + newRadioButton.setValue(sourceField.getValue()); + } + } catch (Exception e) { + log.warn( + "Failed to create radio button field '{}': {}", + sourceField.getPartialName(), + e.getMessage(), + e); + } + } + + private static void createSimpleComboBoxField( + PDAcroForm newAcroForm, + PDPage destinationPage, + List destinationAnnotations, + PDComboBox sourceField, + PDAnnotationWidget sourceWidget, + float offsetX, + float offsetY, + float scale, + int pageIndex, + Map fieldNameCounters) { + + try { + PDComboBox newComboBox = new PDComboBox(newAcroForm); + + boolean initialized = + initializeFieldWithWidget( + newAcroForm, + destinationPage, + destinationAnnotations, + newComboBox, + sourceField.getPartialName(), + "comboBox", + sourceWidget, + offsetX, + offsetY, + scale, + pageIndex, + fieldNameCounters); + + if (!initialized) { + return; + } + + if (sourceField.getOptions() != null) { + newComboBox.setOptions(sourceField.getOptions()); + } + if (sourceField.getValue() != null && !sourceField.getValue().isEmpty()) { + newComboBox.setValue(sourceField.getValue()); + } + } catch (Exception e) { + log.warn( + "Failed to create combo box field '{}': {}", + sourceField.getPartialName(), + e.getMessage(), + e); + } + } + + private static void createSimpleListBoxField( + PDAcroForm newAcroForm, + PDPage destinationPage, + List destinationAnnotations, + PDListBox sourceField, + PDAnnotationWidget sourceWidget, + float offsetX, + float offsetY, + float scale, + int pageIndex, + Map fieldNameCounters) { + + try { + PDListBox newListBox = new PDListBox(newAcroForm); + + boolean initialized = + initializeFieldWithWidget( + newAcroForm, + destinationPage, + destinationAnnotations, + newListBox, + sourceField.getPartialName(), + "listBox", + sourceWidget, + offsetX, + offsetY, + scale, + pageIndex, + fieldNameCounters); + + if (!initialized) { + return; + } + + if (sourceField.getOptions() != null) { + newListBox.setOptions(sourceField.getOptions()); + } + if (sourceField.getValue() != null && !sourceField.getValue().isEmpty()) { + newListBox.setValue(sourceField.getValue()); + } + } catch (Exception e) { + log.warn( + "Failed to create list box field '{}': {}", + sourceField.getPartialName(), + e.getMessage(), + e); + } + } + + private static void createSimpleSignatureField( + PDAcroForm newAcroForm, + PDPage destinationPage, + List destinationAnnotations, + PDSignatureField sourceField, + PDAnnotationWidget sourceWidget, + float offsetX, + float offsetY, + float scale, + int pageIndex, + Map fieldNameCounters) { + + try { + PDSignatureField newSignatureField = new PDSignatureField(newAcroForm); + + boolean initialized = + initializeFieldWithWidget( + newAcroForm, + destinationPage, + destinationAnnotations, + newSignatureField, + sourceField.getPartialName(), + "signature", + sourceWidget, + offsetX, + offsetY, + scale, + pageIndex, + fieldNameCounters); + + if (!initialized) { + return; + } + } catch (Exception e) { + log.warn( + "Failed to create signature field '{}': {}", + sourceField.getPartialName(), + e.getMessage(), + e); + } + } + + private static void createSimplePushButtonField( + PDAcroForm newAcroForm, + PDPage destinationPage, + List destinationAnnotations, + PDPushButton sourceField, + PDAnnotationWidget sourceWidget, + float offsetX, + float offsetY, + float scale, + int pageIndex, + Map fieldNameCounters) { + + try { + PDPushButton newPushButton = new PDPushButton(newAcroForm); + + boolean initialized = + initializeFieldWithWidget( + newAcroForm, + destinationPage, + destinationAnnotations, + newPushButton, + sourceField.getPartialName(), + "pushButton", + sourceWidget, + offsetX, + offsetY, + scale, + pageIndex, + fieldNameCounters); + + } catch (Exception e) { + log.warn( + "Failed to create push button field '{}': {}", + sourceField.getPartialName(), + e.getMessage(), + e); + } + } + + private static boolean initializeFieldWithWidget( + PDAcroForm newAcroForm, + PDPage destinationPage, + List destinationAnnotations, + T newField, + String originalName, + String fallbackName, + PDAnnotationWidget sourceWidget, + float offsetX, + float offsetY, + float scale, + int pageIndex, + Map fieldNameCounters) { + + String baseName = (originalName != null) ? originalName : fallbackName; + String newFieldName = generateUniqueFieldName(baseName, pageIndex, fieldNameCounters); + newField.setPartialName(newFieldName); + + PDAnnotationWidget newWidget = new PDAnnotationWidget(); + PDRectangle sourceRect = sourceWidget.getRectangle(); + if (sourceRect == null) { + return false; + } + + float newX = (sourceRect.getLowerLeftX() * scale) + offsetX; + float newY = (sourceRect.getLowerLeftY() * scale) + offsetY; + float newWidth = sourceRect.getWidth() * scale; + float newHeight = sourceRect.getHeight() * scale; + newWidget.setRectangle(new PDRectangle(newX, newY, newWidth, newHeight)); + newWidget.setPage(destinationPage); + + newField.getWidgets().add(newWidget); + newWidget.setParent(newField); + newAcroForm.getFields().add(newField); + destinationAnnotations.add(newWidget); + return true; + } + + private static String generateUniqueFieldName( + String originalName, int pageIndex, Map fieldNameCounters) { + String baseName = "page" + pageIndex + "_" + originalName; + + Integer counter = fieldNameCounters.get(baseName); + if (counter == null) { + counter = 0; + } else { + counter++; + } + fieldNameCounters.put(baseName, counter); + + return counter == 0 ? baseName : baseName + "_" + counter; + } + + private static Map buildWidgetFieldMap(PDAcroForm acroForm) { + Map map = new HashMap<>(); + if (acroForm == null) { + return map; + } + try { + for (PDField field : acroForm.getFieldTree()) { + List widgets = field.getWidgets(); + if (widgets != null) { + for (PDAnnotationWidget w : widgets) { + if (w != null) { + map.put(w, field); + } + } + } + } + } catch (Exception e) { + log.warn("Failed to build widget->field map: {}", e.getMessage(), e); + } + return map; + } +} 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 8af41eafd..82328918a 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 @@ -1,6 +1,6 @@ package stirling.software.SPDF.controller.api; -import java.awt.*; +import java.awt.Color; import java.io.ByteArrayOutputStream; import java.io.IOException; @@ -24,15 +24,18 @@ import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import stirling.software.SPDF.model.api.general.MergeMultiplePagesRequest; import stirling.software.common.service.CustomPDFDocumentFactory; +import stirling.software.common.util.FormUtils; import stirling.software.common.util.WebResponseUtils; @RestController @RequestMapping("/api/v1/general") @Tag(name = "General", description = "General APIs") @RequiredArgsConstructor +@Slf4j public class MultiPageLayoutController { private final CustomPDFDocumentFactory pdfDocumentFactory; @@ -103,7 +106,8 @@ public class MultiPageLayoutController { float scale = Math.min(scaleWidth, scaleHeight); int adjustedPageIndex = - i % pagesPerSheet; // This will reset the index for every new page + i % pagesPerSheet; // Close the current content stream and create a new + // page and content stream int rowIndex = adjustedPageIndex / cols; int colIndex = adjustedPageIndex % cols; @@ -131,7 +135,28 @@ public class MultiPageLayoutController { } } - contentStream.close(); // Close the final content stream + contentStream.close(); + + // If any source page is rotated, skip form copying/transformation entirely + boolean hasRotation = FormUtils.hasAnyRotatedPage(sourceDocument); + if (hasRotation) { + log.info("Source document has rotated pages; skipping form field copying."); + } else { + try { + FormUtils.copyAndTransformFormFields( + sourceDocument, + newDocument, + totalPages, + pagesPerSheet, + cols, + rows, + cellWidth, + cellHeight); + } catch (Exception e) { + log.warn("Failed to copy and transform form fields: {}", e.getMessage(), e); + } + } + sourceDocument.close(); ByteArrayOutputStream baos = new ByteArrayOutputStream(); diff --git a/app/core/src/test/java/stirling/software/SPDF/controller/api/MultiPageLayoutControllerTest.java b/app/core/src/test/java/stirling/software/SPDF/controller/api/MultiPageLayoutControllerTest.java new file mode 100644 index 000000000..444ab0c5d --- /dev/null +++ b/app/core/src/test/java/stirling/software/SPDF/controller/api/MultiPageLayoutControllerTest.java @@ -0,0 +1,116 @@ +package stirling.software.SPDF.controller.api; + +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.pdmodel.PDPage; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.Mockito; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.mock.web.MockMultipartFile; + +import stirling.software.SPDF.model.api.general.MergeMultiplePagesRequest; +import stirling.software.common.service.CustomPDFDocumentFactory; + +@ExtendWith(MockitoExtension.class) +class MultiPageLayoutControllerTest { + + @Mock private CustomPDFDocumentFactory pdfDocumentFactory; + + @InjectMocks private MultiPageLayoutController controller; + + private MockMultipartFile fileWithExt; + private MockMultipartFile fileNoExt; + + @BeforeEach + void setup() { + fileWithExt = + new MockMultipartFile( + "fileInput", "test.pdf", "application/pdf", new byte[] {1, 2, 3}); + fileNoExt = + new MockMultipartFile("fileInput", "name", "application/pdf", new byte[] {4, 5, 6}); + } + + @Test + @DisplayName("Rejects non-2/3 and non-perfect-square pagesPerSheet") + void invalidPagesPerSheetThrows() { + MergeMultiplePagesRequest req = new MergeMultiplePagesRequest(); + req.setPagesPerSheet(5); + req.setAddBorder(Boolean.TRUE); + req.setFileInput(fileWithExt); + + Assertions.assertThrows( + IllegalArgumentException.class, () -> controller.mergeMultiplePagesIntoOne(req)); + } + + @Test + @DisplayName("Generates PDF and filename suffix for perfect-square layout with no source pages") + void perfectSquareNoPages() throws Exception { + PDDocument source = new PDDocument(); + PDDocument target = new PDDocument(); + Mockito.when(pdfDocumentFactory.load(fileWithExt)).thenReturn(source); + Mockito.when(pdfDocumentFactory.createNewDocumentBasedOnOldDocument(source)) + .thenReturn(target); + + MergeMultiplePagesRequest req = new MergeMultiplePagesRequest(); + req.setPagesPerSheet(4); + req.setAddBorder(Boolean.FALSE); + req.setFileInput(fileWithExt); + + ResponseEntity resp = controller.mergeMultiplePagesIntoOne(req); + Assertions.assertEquals(HttpStatus.OK, resp.getStatusCode()); + Assertions.assertEquals(MediaType.APPLICATION_PDF, resp.getHeaders().getContentType()); + Assertions.assertNotNull(resp.getBody()); + Assertions.assertTrue(resp.getBody().length > 0); + Assertions.assertEquals( + "test_layoutChanged.pdf", resp.getHeaders().getContentDisposition().getFilename()); + } + + @Test + @DisplayName("Merges single source page into 2-up layout and returns PDF") + void twoUpWithSinglePage() throws Exception { + PDDocument source = new PDDocument(); + source.addPage(new PDPage()); + PDDocument target = new PDDocument(); + Mockito.when(pdfDocumentFactory.load(fileWithExt)).thenReturn(source); + Mockito.when(pdfDocumentFactory.createNewDocumentBasedOnOldDocument(source)) + .thenReturn(target); + + MergeMultiplePagesRequest req = new MergeMultiplePagesRequest(); + req.setPagesPerSheet(2); + req.setAddBorder(Boolean.TRUE); + req.setFileInput(fileWithExt); + + ResponseEntity resp = controller.mergeMultiplePagesIntoOne(req); + Assertions.assertEquals(HttpStatus.OK, resp.getStatusCode()); + Assertions.assertEquals(MediaType.APPLICATION_PDF, resp.getHeaders().getContentType()); + Assertions.assertNotNull(resp.getBody()); + Assertions.assertTrue(resp.getBody().length > 0); + } + + @Test + @DisplayName("Uses input name without extension and appends suffix for 3-up") + void threeUpWithNameNoExtension() throws Exception { + PDDocument source = new PDDocument(); + PDDocument target = new PDDocument(); + Mockito.when(pdfDocumentFactory.load(fileNoExt)).thenReturn(source); + Mockito.when(pdfDocumentFactory.createNewDocumentBasedOnOldDocument(source)) + .thenReturn(target); + + MergeMultiplePagesRequest req = new MergeMultiplePagesRequest(); + req.setPagesPerSheet(3); + req.setAddBorder(Boolean.TRUE); + req.setFileInput(fileNoExt); + + ResponseEntity resp = controller.mergeMultiplePagesIntoOne(req); + Assertions.assertEquals( + "name_layoutChanged.pdf", resp.getHeaders().getContentDisposition().getFilename()); + } +}